diff --git a/SQL/paradise_schema.sql b/SQL/paradise_schema.sql index a1ece757484..f73134e6704 100644 --- a/SQL/paradise_schema.sql +++ b/SQL/paradise_schema.sql @@ -277,6 +277,7 @@ CREATE TABLE `player` ( `fupdate` smallint(4) DEFAULT '0', `parallax` tinyint(1) DEFAULT '8', `byond_date` DATE DEFAULT NULL, + `2fa_status` ENUM('DISABLED','ENABLED_IP','ENABLED_ALWAYS') NOT NULL DEFAULT 'DISABLED' COLLATE 'utf8mb4_general_ci', PRIMARY KEY (`id`), UNIQUE KEY `ckey` (`ckey`), KEY `lastseen` (`lastseen`), @@ -585,3 +586,16 @@ CREATE TABLE `round` ( `station_name` VARCHAR(80) NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + + +-- +-- Table structure for table `2fa_secrets` +-- +CREATE TABLE `2fa_secrets` ( + `ckey` VARCHAR(50) NOT NULL COLLATE 'utf8mb4_general_ci', + `secret` VARCHAR(64) NOT NULL COLLATE 'utf8mb4_general_ci', + `date_setup` DATETIME NOT NULL DEFAULT current_timestamp(), + `last_time` DATETIME NULL DEFAULT NULL, + PRIMARY KEY (`ckey`) USING BTREE +) +COLLATE='utf8mb4_general_ci' ENGINE=InnoDB; diff --git a/SQL/paradise_schema_prefixed.sql b/SQL/paradise_schema_prefixed.sql index 9b2fdb845e0..16ea512fb08 100644 --- a/SQL/paradise_schema_prefixed.sql +++ b/SQL/paradise_schema_prefixed.sql @@ -276,6 +276,7 @@ CREATE TABLE `SS13_player` ( `fupdate` smallint(4) DEFAULT '0', `parallax` tinyint(1) DEFAULT '8', `byond_date` DATE DEFAULT NULL, + `2fa_status` ENUM('DISABLED','ENABLED_IP','ENABLED_ALWAYS') NOT NULL DEFAULT 'DISABLED' COLLATE 'utf8mb4_general_ci', PRIMARY KEY (`id`), UNIQUE KEY `ckey` (`ckey`), KEY `lastseen` (`lastseen`), @@ -582,3 +583,15 @@ CREATE TABLE `SS13_round` ( `station_name` VARCHAR(80) NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- +-- Table structure for table `2fa_secrets` +-- +CREATE TABLE `SS13_2fa_secrets` ( + `ckey` VARCHAR(50) NOT NULL COLLATE 'utf8mb4_general_ci', + `secret` VARCHAR(64) NOT NULL COLLATE 'utf8mb4_general_ci', + `date_setup` DATETIME NOT NULL DEFAULT current_timestamp(), + `last_time` DATETIME NULL DEFAULT NULL, + PRIMARY KEY (`ckey`) USING BTREE +) +COLLATE='utf8mb4_general_ci' ENGINE=InnoDB; diff --git a/SQL/updates/23-24.sql b/SQL/updates/23-24.sql new file mode 100644 index 00000000000..9beee82ae82 --- /dev/null +++ b/SQL/updates/23-24.sql @@ -0,0 +1,13 @@ +# Updates DB from 23 to 24 -AffectedArc07 +# Add new column to player table for 2FA status +ALTER TABLE `player` ADD COLUMN `2fa_status` ENUM('DISABLED','ENABLED_IP','ENABLED_ALWAYS') NOT NULL DEFAULT 'DISABLED' AFTER `byond_date`; + +# Create new table for 2FA tokens +CREATE TABLE `2fa_secrets` ( + `ckey` VARCHAR(50) NOT NULL COLLATE 'utf8mb4_general_ci', + `secret` VARCHAR(64) NOT NULL COLLATE 'utf8mb4_general_ci', + `date_setup` DATETIME NOT NULL DEFAULT current_timestamp(), + `last_time` DATETIME NULL DEFAULT NULL, + PRIMARY KEY (`ckey`) USING BTREE +) +COLLATE='utf8mb4_general_ci' ENGINE=InnoDB; diff --git a/code/__DEFINES/misc.dm b/code/__DEFINES/misc.dm index f9aba1efc1b..ad182c9f9c4 100644 --- a/code/__DEFINES/misc.dm +++ b/code/__DEFINES/misc.dm @@ -363,7 +363,7 @@ #define INVESTIGATE_BOMB "bombs" // The SQL version required by this version of the code -#define SQL_VERSION 23 +#define SQL_VERSION 24 // Vending machine stuff #define CAT_NORMAL 1 diff --git a/code/__DEFINES/preferences.dm b/code/__DEFINES/preferences.dm index 7ee21738019..d136d7190d5 100644 --- a/code/__DEFINES/preferences.dm +++ b/code/__DEFINES/preferences.dm @@ -104,3 +104,12 @@ #define PARALLAX_MED 4 #define PARALLAX_HIGH 8 #define PARALLAX_INSANE 16 + +// 2FA Defines. These are the same as the schema DB enums // + +/// Client has 2FA disabled +#define _2FA_DISABLED "DISABLED" +/// Client will be prompted for 2FA on IP changes +#define _2FA_ENABLED_IP "ENABLED_IP" +/// Client will be prompted for 2FA always +#define _2FA_ENABLED_ALWAYS "ENABLED_ALWAYS" diff --git a/code/__HELPERS/unsorted.dm b/code/__HELPERS/unsorted.dm index 3056485d755..5f95d44efc4 100644 --- a/code/__HELPERS/unsorted.dm +++ b/code/__HELPERS/unsorted.dm @@ -2072,7 +2072,7 @@ GLOBAL_DATUM_INIT(dview_mob, /mob/dview, new) return _list /// Waits at a line of code until X is true -#define UNTIL(X) while(!(X)) stoplag() +#define UNTIL(X) while(!(X)) sleep(world.tick_lag) // Check if the source atom contains another atom /atom/proc/contains(atom/location) diff --git a/code/controllers/configuration.dm b/code/controllers/configuration.dm index 26be0f214b7..2c3d3202a31 100644 --- a/code/controllers/configuration.dm +++ b/code/controllers/configuration.dm @@ -278,6 +278,9 @@ // Enable map voting var/map_voting_enabled = FALSE + // 2FA auth host + var/_2fa_auth_host = null + /datum/configuration/New() for(var/T in subtypesof(/datum/game_mode)) var/datum/game_mode/M = T @@ -775,6 +778,8 @@ auto_profile = TRUE if("enable_map_voting") map_voting_enabled = TRUE + if("2fa_host") + _2fa_auth_host = value else log_config("Unknown setting in configuration: '[name]'") diff --git a/code/controllers/subsystem/http.dm b/code/controllers/subsystem/http.dm index ad5407bfdca..6c7178a69fa 100644 --- a/code/controllers/subsystem/http.dm +++ b/code/controllers/subsystem/http.dm @@ -12,8 +12,11 @@ SUBSYSTEM_DEF(http) /// Total requests the SS has processed in a round var/total_requests -/datum/controller/subsystem/http/Initialize(start_timeofday) +/datum/controller/subsystem/http/PreInit() + . = ..() rustg_create_async_http_client() // Open the door + +/datum/controller/subsystem/http/Initialize(start_timeofday) active_async_requests = list() return ..() diff --git a/code/modules/admin/IsBanned.dm b/code/modules/admin/IsBanned.dm index e160ee3838d..e70fa4acac8 100644 --- a/code/modules/admin/IsBanned.dm +++ b/code/modules/admin/IsBanned.dm @@ -1,5 +1,5 @@ //Blocks an attempt to connect before even creating our client datum thing. -/world/IsBanned(key, address, computer_id, type, check_ipintel = TRUE) +/world/IsBanned(key, address, computer_id, type, check_ipintel = TRUE, check_2fa = TRUE) if(!key || !address || !computer_id) log_adminwarn("Failed Login (invalid data): [key] [address]-[computer_id]") @@ -44,6 +44,49 @@ return list("reason"="using proxy or vpn", "desc"="\nReason: Proxies/VPNs are not allowed here. [mistakemessage]") + // If 2FA is enabled, makes sure they were authed within the last minute + if(check_2fa && config._2fa_auth_host) + // First see if they exist at all + var/datum/db_query/check_query = SSdbcore.NewQuery("SELECT 2fa_status, ip FROM [format_table_name("player")] WHERE ckey=:ckey", list("ckey" = ckey(key))) + + if(!check_query.warn_execute()) + message_admins("Failed to do a DB 2FA check for [key]. You have been warned.") + qdel(check_query) + return + + + // If a row is returned, the player exists + var/_2fa_enabled = FALSE + var/always_check = FALSE + var/last_ip + if(check_query.NextRow()) + if(check_query.item[1] != _2FA_DISABLED) + _2fa_enabled = TRUE + if(check_query.item[1] == _2FA_ENABLED_ALWAYS) + always_check = TRUE + last_ip = check_query.item[2] + qdel(check_query) + + // If client has 2FA enabled, and they either: + // Have it set to always check, or their IP is different + if(_2fa_enabled && (always_check || (address != last_ip))) + // They have 2FA enabled, lets make sure they have authed within the last minute + var/datum/db_query/verify_query = SSdbcore.NewQuery("SELECT ckey FROM [format_table_name("2fa_secrets")] WHERE (last_time BETWEEN NOW() - INTERVAL 1 MINUTE AND NOW()) AND ckey=:ckey LIMIT 1", list( + "ckey" = ckey(key) + )) + + if(!verify_query.warn_execute()) + message_admins("Failed to do a DB 2FA check for [key]. You have been warned.") + qdel(verify_query) + return + + if(!verify_query.NextRow()) + // If no row was returned, fail 2FA + qdel(verify_query) + return list("reason"="2fa check failed", "desc"="You have 2FA enabled but did not properly authenticate.") + + qdel(verify_query) + if(config.ban_legacy_system) //Ban Checking . = CheckBan(ckey(key), computer_id, address) diff --git a/code/modules/client/2fa.dm b/code/modules/client/2fa.dm new file mode 100644 index 00000000000..9de8ef73476 --- /dev/null +++ b/code/modules/client/2fa.dm @@ -0,0 +1,131 @@ +// This is in its own file as it has so much stuff to contend with +/client/proc/edit_2fa() + if(!config._2fa_auth_host) + alert(usr, "This server does not have 2FA enabled.") + return + // Client does not have 2FA enabled. Set it up. + if(prefs._2fa_status == _2FA_DISABLED) + // Get us an auth token + var/datum/http_response/qrcr = wrap_http_get("[config._2fa_auth_host]/generateQR?ckey=[ckey]") + // If this fails, shits gone bad + if(qrcr.errored) + alert(usr, "Something has gone VERY wrong ingame. Please inform the server host.\nError details: [qrcr.error]") + return + + if(qrcr.status_code != 200) + alert(usr, "2FA QR code generation returned a non-200 code. Please inform the server host.\nError code: [qrcr.status_code]\nError details: [qrcr.body]") + return + + var/list/data = json_decode(qrcr.body) + var/qr_img_src = data["qr_image"] + var/datum/browser/B = new(usr, "2fa_qrc", "2FA QR Code", 600, 400) + var/title_text = "

Below is a QR code to scan inside your authenticator app to generate 2FA codes. Please verify it before closing this window. (Behind this window is a text box)

" + var/img_data = "
" + B.set_content("[title_text][img_data]") + B.open(FALSE) + + var/entered_code = input(usr, "Please enter a code from your auth app. Failure to enter the code correctly will abort 2FA setup.", "2FA Validation") + if(!entered_code) + // Cleanup so they can start again + var/datum/db_query/dbq = SSdbcore.NewQuery("DELETE FROM [format_table_name("2fa_secrets")] WHERE ckey=:ckey", list("ckey" = ckey)) + dbq.warn_execute() + alert(usr, "2FA Setup aborted!") + B.close() + return + + var/datum/http_response/vr = wrap_http_get("[config._2fa_auth_host]/validateCode?ckey=[ckey]&code=[entered_code]") + // If this fails, shits gone bad + if(vr.errored) + alert(usr, "Something has gone VERY wrong ingame. Please inform the server host.\nError details: [vr.error]") + B.close() + return + + if(vr.status_code != 200) + // Cleanup so they can start again + var/datum/db_query/dbq = SSdbcore.NewQuery("DELETE FROM [format_table_name("2fa_secrets")] WHERE ckey=:ckey", list("ckey" = ckey)) + dbq.warn_execute() + + // See if its unauthorised. I used 400 for that dont at me + if(vr.status_code == 400) + alert(usr, "Invalid code entered. 2FA Setup aborted!") + B.close() + else + alert(usr, "2FA validation returned a non-200 code. Please inform the server host.\nError code: [vr.status_code]\nError details: [vr.body]") + B.close() + return + + // If we are here, they authed successfully + alert(usr, "Congratulations. 2FA is now setup properly for your account. In preferences, you can change whether you want it to ask for a code on every connection or only when your IP changes") + B.close() + // Default to IP change only + prefs._2fa_status = _2FA_ENABLED_IP + prefs.save_preferences(src) + prefs.ShowChoices(usr) + return + + + // If we are here, they just want to change the mode + var/option = alert(usr, "Would you like to change 2FA mode or disable it entirely?", "2FA Mode", "Enable (Always)", "Enable (On IP Change)", "Deactivate") + switch(option) + if("Enable (Always)") + prefs._2fa_status = _2FA_ENABLED_ALWAYS + prefs.save_preferences(src) + prefs.ShowChoices(usr) + if("Enable (On IP Change)") + prefs._2fa_status = _2FA_ENABLED_IP + prefs.save_preferences(src) + prefs.ShowChoices(usr) + if("Deactivate") + var/confirm = alert(usr, "Are you SURE you want to deactivate 2FA?", "WARNING", "Yes", "No") + if(confirm != "Yes") + return + + // Prompt them for a code to make sure they know what they are doing + var/entered_code = input(usr, "Please enter a code from your auth app", "2FA Validation") + if(!entered_code) + alert(usr, "2FA deactivation aborted!") + return + + var/datum/http_response/vr = wrap_http_get("[config._2fa_auth_host]/validateCode?ckey=[ckey]&code=[entered_code]") + // If this fails, shits gone bad + if(vr.errored) + alert(usr, "Something has gone VERY wrong ingame. Please inform the server host.\nError details: [vr.error]") + return + + if(vr.status_code != 200) + // See if its unauthorised. I used 400 for that dont at me + if(vr.status_code == 400) + alert(usr, "Invalid code entered. 2FA deactivation aborted!") + else + alert(usr, "2FA validation returned non-200 code. Please inform the server host.\nError code: [vr.status_code]\nError details: [vr.body]") + return + + // If we are here, they authed properly + var/datum/db_query/dbq = SSdbcore.NewQuery("DELETE FROM [format_table_name("2fa_secrets")] WHERE ckey=:ckey", list("ckey" = ckey)) + dbq.warn_execute() + prefs._2fa_status = _2FA_DISABLED + prefs.save_preferences(src) + prefs.ShowChoices(usr) + alert(usr, "2FA disabled successfully") + +/datum/preferences/proc/_2fastatus_to_text() + switch(_2fa_status) + if(_2FA_DISABLED) + return "Disabled" + if(_2FA_ENABLED_IP) + return "Enabled (Will prompt on IP changes)" + if(_2FA_ENABLED_ALWAYS) + return "Enabled (Will prompt every time)" + + +// Proc to wrap HTTP requests properly, without needing SShttp firing +/proc/wrap_http_get(url) + var/datum/http_request/req = new() + req.prepare(RUSTG_HTTP_METHOD_GET, url) + req.begin_async() + // Check if we are complete + UNTIL(req.is_complete()) + var/datum/http_response/res = req.into_response() + + return res + diff --git a/code/modules/client/preference/preferences.dm b/code/modules/client/preference/preferences.dm index 7d403060c19..12d7bc62237 100644 --- a/code/modules/client/preference/preferences.dm +++ b/code/modules/client/preference/preferences.dm @@ -205,6 +205,8 @@ GLOBAL_LIST_INIT(special_role_times, list( //minimum age (in days) for accounts var/gear_tab = "General" // Parallax var/parallax = PARALLAX_HIGH + /// 2FA status + var/_2fa_status = _2FA_DISABLED /// Do we want to force our runechat colour to be white? var/force_white_runechat = FALSE @@ -448,6 +450,7 @@ GLOBAL_LIST_INIT(special_role_times, list( //minimum age (in days) for accounts // LEFT SIDE OF THE PAGE dat += "
" dat += "

General Settings

" + dat += "2FA Setup: [_2fastatus_to_text()]
" if(user.client.holder) dat += "Adminhelp sound: [(sound & SOUND_ADMINHELP)?"On":"Off"]
" dat += "AFK Cryoing: [(toggles2 & PREFTOGGLE_2_AFKWATCH) ? "Yes" : "No"]
" @@ -2144,6 +2147,11 @@ GLOBAL_LIST_INIT(special_role_times, list( //minimum age (in days) for accounts if(parent && parent.mob && parent.mob.hud_used) parent.mob.hud_used.update_parallax_pref() + if("edit_2fa") + // Do this async so we arent holding up a topic() call + INVOKE_ASYNC(user.client, /client.proc/edit_2fa) + return // We return here to avoid focus being lost + ShowChoices(user) return 1 diff --git a/code/modules/client/preference/preferences_mysql.dm b/code/modules/client/preference/preferences_mysql.dm index 063ee29402c..bcb5823af6a 100644 --- a/code/modules/client/preference/preferences_mysql.dm +++ b/code/modules/client/preference/preferences_mysql.dm @@ -16,7 +16,8 @@ clientfps, atklog, fuid, - parallax + parallax, + 2fa_status FROM [format_table_name("player")] WHERE ckey=:ckey"}, list( "ckey" = C.ckey @@ -45,6 +46,7 @@ atklog = text2num(query.item[14]) fuid = text2num(query.item[15]) parallax = text2num(query.item[16]) + _2fa_status = query.item[17] qdel(query) @@ -93,7 +95,8 @@ volume_mixer=:volume_mixer, lastchangelog=:lastchangelog, clientfps=:clientfps, - parallax=:parallax + parallax=:parallax, + 2fa_status=:_2fa_status WHERE ckey=:ckey"}, list( // OH GOD THE PARAMETERS "ooccolour" = ooccolor, @@ -111,7 +114,8 @@ "lastchangelog" = lastchangelog, "clientfps" = clientfps, "parallax" = parallax, - "ckey" = C.ckey + "_2fa_status" = _2fa_status, + "ckey" = C.ckey, ) ) diff --git a/config/example/config.txt b/config/example/config.txt index a88b2f705e4..27fe31e980e 100644 --- a/config/example/config.txt +++ b/config/example/config.txt @@ -474,3 +474,6 @@ ENABLE_AUTO_PROFILER ## Uncomment to enable map voting #ENABLE_MAP_VOTING + +## Host for a custom 2FA server. Comment to disable. Do not use a trailing slash or https. +#2FA_HOST http://127.0.0.1:8080 diff --git a/config/example/dbconfig.txt b/config/example/dbconfig.txt index d009ca2c7ea..ffceb33e7de 100644 --- a/config/example/dbconfig.txt +++ b/config/example/dbconfig.txt @@ -18,7 +18,7 @@ FEEDBACK_DATABASE feedback ## This value must be set to the version of the paradise schema in use. ## If this value does not match, the SQL database will not be loaded and an error will be generated. ## Roundstart will be delayed. -DB_VERSION 23 +DB_VERSION 24 ## Prefix to be added to the name of every table, older databases will require this be set to erro_ ## If left out defaults to erro_ for legacy reasons, if you want no table prefix, give a blank prefix rather then comment out diff --git a/goon/code/datums/browserOutput.dm b/goon/code/datums/browserOutput.dm index 80f89f73c87..431aa3d90bd 100644 --- a/goon/code/datums/browserOutput.dm +++ b/goon/code/datums/browserOutput.dm @@ -185,7 +185,7 @@ var/list/chatResources = list( var/list/row = connectionHistory[i] if(!row || row.len < 3 || !(row["ckey"] && row["compid"] && row["ip"])) return - if(world.IsBanned(key=row["ckey"], address=row["ip"], computer_id=row["compid"], type=null, check_ipintel=FALSE)) + if(world.IsBanned(key=row["ckey"], address=row["ip"], computer_id=row["compid"], type=null, check_ipintel=FALSE, check_2fa=FALSE)) found = row break CHECK_TICK diff --git a/paradise.dme b/paradise.dme index 48d929fe5b9..bd12100904c 100644 --- a/paradise.dme +++ b/paradise.dme @@ -1345,6 +1345,7 @@ #include "code\modules\buildmode\submodes\save.dm" #include "code\modules\buildmode\submodes\throwing.dm" #include "code\modules\buildmode\submodes\variable_edit.dm" +#include "code\modules\client\2fa.dm" #include "code\modules\client\asset_cache.dm" #include "code\modules\client\client_defines.dm" #include "code\modules\client\client_procs.dm" diff --git a/tools/ci/dbconfig.txt b/tools/ci/dbconfig.txt index fc7d80f39aa..f3a4b587962 100644 --- a/tools/ci/dbconfig.txt +++ b/tools/ci/dbconfig.txt @@ -2,7 +2,7 @@ # Dont use it ingame # Remember to update this when you increase the SQL version! -aa SQL_ENABLED -DB_VERSION 23 +DB_VERSION 24 ADDRESS 127.0.0.1 PORT 3306 FEEDBACK_DATABASE feedback