diff --git a/SQL/database_changelog.txt b/SQL/database_changelog.txt index c5a37c779af..3cc1698d32f 100644 --- a/SQL/database_changelog.txt +++ b/SQL/database_changelog.txt @@ -8,6 +8,22 @@ INSERT INTO `SS13_schema_revision` (`major`, `minor`) VALUES (5, 15); In any query remember to add a prefix to the table names if you use one. +----------------------------------------------------- +Version 5.15, 2 June 2021, by Mothblocks +Added verified admin connection log used for 2FA + +``` +DROP TABLE IF EXISTS `admin_connections`; +CREATE TABLE `admin_connections` ( + `id` INT NOT NULL AUTO_INCREMENT, + `ckey` VARCHAR(32) NOT NULL, + `ip` INT(11) UNSIGNED NOT NULL, + `cid` VARCHAR(32) NOT NULL, + `verification_time` DATETIME NULL, + PRIMARY KEY (`id`), + UNIQUE INDEX `unique_constraints` (`ckey`, `ip`, `cid`)); +``` + ----------------------------------------------------- Version 5.15, 24 May 2021, by Anturke diff --git a/SQL/tgstation_schema.sql b/SQL/tgstation_schema.sql index b943725f99f..6573fbbbc3f 100644 --- a/SQL/tgstation_schema.sql +++ b/SQL/tgstation_schema.sql @@ -652,6 +652,20 @@ CREATE TABLE `text_adventures` ( PRIMARY KEY (`id`) ) ENGINE=InnoDB; +-- +-- Table structure for table `admin_connections` +-- +DROP TABLE IF EXISTS `admin_connections`; +CREATE TABLE `admin_connections` ( + `id` INT NOT NULL AUTO_INCREMENT, + `ckey` VARCHAR(32) NOT NULL, + `ip` INT(11) UNSIGNED NOT NULL, + `cid` VARCHAR(32) NOT NULL, + `verification_time` DATETIME NULL, + PRIMARY KEY (`id`), + UNIQUE INDEX `unique_constraints` (`ckey`, `ip`, `cid`) +) ENGINE=InnoDB; + /*!40101 SET SQL_MODE=@OLD_SQL_MODE */; /*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; /*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; diff --git a/SQL/tgstation_schema_prefixed.sql b/SQL/tgstation_schema_prefixed.sql index 669597e4c4a..8a6cedc4242 100644 --- a/SQL/tgstation_schema_prefixed.sql +++ b/SQL/tgstation_schema_prefixed.sql @@ -637,6 +637,20 @@ CREATE TABLE `SS13_text_adventures` ( PRIMARY KEY (`id`) ) ENGINE=InnoDB; +-- +-- Table structure for table `admin_connections` +-- +DROP TABLE IF EXISTS `SS13_admin_connections`; +CREATE TABLE `SS13_admin_connections` ( + `id` INT NOT NULL AUTO_INCREMENT, + `ckey` VARCHAR(32) NOT NULL, + `ip` INT(11) UNSIGNED NOT NULL, + `cid` VARCHAR(32) NOT NULL, + `verification_time` DATETIME NULL, + PRIMARY KEY (`id`), + UNIQUE INDEX `unique_constraints` (`ckey`, `ip`, `cid`) +) ENGINE=InnoDB; + /*!40101 SET SQL_MODE=@OLD_SQL_MODE */; /*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; /*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; diff --git a/code/__HELPERS/roundend.dm b/code/__HELPERS/roundend.dm index 78593cc9d74..f6403c902ff 100644 --- a/code/__HELPERS/roundend.dm +++ b/code/__HELPERS/roundend.dm @@ -774,19 +774,33 @@ //json format backup file generation stored per server var/json_file = file("data/admins_backup.json") - var/list/file_data = list("ranks" = list(), "admins" = list()) + var/list/file_data = list( + "ranks" = list(), + "admins" = list(), + "connections" = list(), + ) for(var/datum/admin_rank/R in GLOB.admin_ranks) file_data["ranks"]["[R.name]"] = list() file_data["ranks"]["[R.name]"]["include rights"] = R.include_rights file_data["ranks"]["[R.name]"]["exclude rights"] = R.exclude_rights file_data["ranks"]["[R.name]"]["can edit rights"] = R.can_edit_rights - for(var/i in GLOB.admin_datums+GLOB.deadmins) - var/datum/admins/A = GLOB.admin_datums[i] - if(!A) - A = GLOB.deadmins[i] - if (!A) + + for(var/admin_ckey in GLOB.admin_datums + GLOB.deadmins) + var/datum/admins/admin = GLOB.admin_datums[admin_ckey] + + if(!admin) + admin = GLOB.deadmins[admin_ckey] + if (!admin) continue - file_data["admins"]["[i]"] = A.rank.name + + file_data["admins"][admin_ckey] = admin.rank.name + + if (admin.owner) + file_data["connections"][admin_ckey] = list( + "cid" = admin.owner.computer_id, + "ip" = admin.owner.address, + ) + fdel(json_file) WRITE_FILE(json_file, json_encode(file_data)) diff --git a/code/controllers/configuration/entries/general.dm b/code/controllers/configuration/entries/general.dm index eeaeaa08418..435d3ccfd5f 100644 --- a/code/controllers/configuration/entries/general.dm +++ b/code/controllers/configuration/entries/general.dm @@ -514,16 +514,5 @@ /datum/config_entry/string/centcom_source_whitelist -//SKYRAT EDIT ADDITION BEGIN -/datum/config_entry/string/servertagline - config_entry_value = "We forgot to set the server's tagline in config.txt" - -/datum/config_entry/string/discord_link - config_entry_value = "We forgot to set the server's discord link in config.txt" - -/datum/config_entry/flag/sql_game_log - protection = CONFIG_ENTRY_LOCKED - -/datum/config_entry/flag/file_game_log - protection = CONFIG_ENTRY_LOCKED -//SKYRAT EDIT END +/// URL for admins to be redirected to for 2FA +/datum/config_entry/string/admin_2fa_url diff --git a/code/modules/admin/admin_verbs.dm b/code/modules/admin/admin_verbs.dm index fd6a3f4e774..cff1152dd8e 100644 --- a/code/modules/admin/admin_verbs.dm +++ b/code/modules/admin/admin_verbs.dm @@ -818,3 +818,10 @@ GLOBAL_PROTECT(admin_verbs_hideable) set category = "Debug" src << output("", "statbrowser:create_debug") + +/client/proc/admin_2fa_verify() + set name = "Verify Admin" + set category = "Admin" + + var/datum/admins/admin = GLOB.admin_datums[ckey] + admin?.associate(src) diff --git a/code/modules/admin/holder2.dm b/code/modules/admin/holder2.dm index 43409363f0a..660fc225653 100644 --- a/code/modules/admin/holder2.dm +++ b/code/modules/admin/holder2.dm @@ -6,6 +6,9 @@ GLOBAL_PROTECT(protected_admins) GLOBAL_VAR_INIT(href_token, GenerateToken()) GLOBAL_PROTECT(href_token) +#define RESULT_2FA_VALID 1 +#define RESULT_2FA_ID 2 + /datum/admins var/datum/admin_rank/rank @@ -30,6 +33,12 @@ GLOBAL_PROTECT(href_token) var/datum/filter_editor/filteriffic + /// Whether or not the user tried to connect, but was blocked by 2FA + var/blocked_by_2fa = FALSE + + /// Whether or not this user can bypass 2FA + var/bypass_2fa = FALSE + /datum/admins/New(datum/admin_rank/R, ckey, force_active = FALSE, protected) if(IsAdminAdvancedProcCall()) var/msg = " has tried to elevate permissions!" @@ -95,27 +104,45 @@ GLOBAL_PROTECT(href_token) disassociate() add_verb(C, /client/proc/readmin) -/datum/admins/proc/associate(client/C) +/datum/admins/proc/associate(client/client) if(IsAdminAdvancedProcCall()) var/msg = " has tried to elevate permissions!" message_admins("[key_name_admin(usr)][msg]") log_admin("[key_name(usr)][msg]") return - if(istype(C)) - if(C.ckey != target) - var/msg = " has attempted to associate with [target]'s admin datum" - message_admins("[key_name_admin(C)][msg]") - log_admin("[key_name(C)][msg]") - return - if (deadmined) - activate() - owner = C - owner.holder = src - owner.add_admin_verbs() //TODO <--- todo what? the proc clearly exists and works since its the backbone to our entire admin system - remove_verb(owner, /client/proc/readmin) - owner.init_verbs() //re-initialize the verb list - GLOB.admins |= C + if(!istype(client)) + return + + if(client?.ckey != target) + var/msg = " has attempted to associate with [target]'s admin datum" + message_admins("[key_name_admin(client)][msg]") + log_admin("[key_name(client)][msg]") + return + + var/result_2fa = check_2fa(client) + if (!result_2fa[RESULT_2FA_VALID]) + blocked_by_2fa = TRUE + alert_2fa_necessary(client) + start_2fa_process(client, result_2fa[RESULT_2FA_ID]) + + return + else if (blocked_by_2fa) + sync_lastadminrank(client.ckey, client.key) + + blocked_by_2fa = FALSE + + if (deadmined) + activate() + + remove_verb(client, /client/proc/admin_2fa_verify) + + owner = client + owner.holder = src + owner.add_admin_verbs() + remove_verb(owner, /client/proc/readmin) + owner.init_verbs() //re-initialize the verb list + GLOB.admins |= client /datum/admins/proc/disassociate() if(IsAdminAdvancedProcCall()) @@ -148,6 +175,154 @@ GLOBAL_PROTECT(href_token) return TRUE //we have all the rights they have and more return FALSE +// TRUE for a vaild connection, null is the id (it is unnecessary) +#define VALID_2FA_CONNECTION list(TRUE, null) + +/// Returns whether or not the given client has a verified 2FA connection. +/// The output is in the form of a list with the first index being whether or not the +/// check was successful, the 2nd is the ID of the associated database entry +/// if its a false result and if one can be found. +/datum/admins/proc/check_2fa(client/client) + if (bypass_2fa) + return VALID_2FA_CONNECTION + + var/admin_2fa_url = CONFIG_GET(string/admin_2fa_url) + + // 2FA not being enabled == everyone passes + if (isnull(admin_2fa_url) || admin_2fa_url == "") + return VALID_2FA_CONNECTION + + // I believe this is only in the case of Dream Seeker. + if (isnull(client?.address)) + return VALID_2FA_CONNECTION + + if (!SSdbcore.Connect()) + if (verify_backup_data(client)) + return VALID_2FA_CONNECTION + else + return list(FALSE, null) + + var/datum/db_query/query = SSdbcore.NewQuery({" + SELECT id, verification_time FROM [format_table_name("admin_connections")] + WHERE ckey = :ckey + AND ip = INET_ATON(:ip) + AND cid = :cid + "}, list( + "ckey" = client.ckey, + "ip" = client.address, + "cid" = client.computer_id, + )) + + if (!query.Execute()) + qdel(query) + return list(FALSE, null) + + var/is_valid = FALSE + var/id = null + + if (query.NextRow()) + id = query.item[1] + is_valid = !isnull(query.item[2]) + + qdel(query) + return list(is_valid, id) + +#undef VALID_2FA_CONNECTION + +#define ERROR_2FA_REQUEST_PERMISSIONS "

You could not be verified, and a DB connection couldn't be established. Please contact an admin with +PERMISSIONS to grant you permission.

" + +/datum/admins/proc/start_2fa_process(client/client, id) + add_verb(client, /client/proc/admin_2fa_verify) + client?.init_verbs() + + var/admin_2fa_url = CONFIG_GET(string/admin_2fa_url) + + if (!SSdbcore.Connect()) + to_chat( + client, + type = MESSAGE_TYPE_ADMINLOG, + html = ERROR_2FA_REQUEST_PERMISSIONS, + confidential = TRUE, + ) + + return + + if (isnull(id)) + var/datum/db_query/insert_query = SSdbcore.NewQuery({" + INSERT INTO [format_table_name("admin_connections")] (ckey, ip, cid) + VALUES(:ckey, INET_ATON(:ip), :cid) + "}, list( + "ckey" = client.ckey, + "ip" = client.address, + "cid" = client.computer_id, + )) + + if (!insert_query.Execute()) + qdel(insert_query) + to_chat( + client, + type = MESSAGE_TYPE_ADMINLOG, + html = ERROR_2FA_REQUEST_PERMISSIONS, + confidential = TRUE, + ) + + return + + id = insert_query.last_insert_id + + var/url_for_2fa = replacetextEx(admin_2fa_url, "%ID%", id) + to_chat( + client, + type = MESSAGE_TYPE_ADMINLOG, + html = {" +

You could not be verified.

+

Please visit [url_for_2fa] to verify.

+

When you are done, click the 'Verify Admin' button in your admin tab.

+ "}, + confidential = TRUE, + ) + +#undef ERROR_2FA_REQUEST_PERMISSIONS + +/datum/admins/proc/verify_backup_data(client/client) + var/backup_file = file2text("data/admins_backup.json") + if (isnull(backup_file)) + log_world("Unable to locate admins backup file.") + return FALSE + + var/list/backup_file_json = json_decode(backup_file) + var/connections = backup_file_json["connections"] + + // This can happen for older admins_backup.json files + if (isnull(connections)) + return FALSE + + var/most_recent_valid_connection = connections[client?.ckey] + if (isnull(most_recent_valid_connection)) + return FALSE + + return most_recent_valid_connection["cid"] == client?.computer_id \ + && most_recent_valid_connection["ip"] == client?.address + +/datum/admins/proc/alert_2fa_necessary(client/client) + var/msg = " is trying to join, but needs to verify their ckey." + message_admins("[key_name_admin(client)][msg]") + log_admin("[key_name(client)][msg]") + + for (var/client/admin_client as anything in GLOB.admins) + if (admin_client == client) + continue + + if (!check_rights_for(admin_client, R_PERMISSIONS)) + continue + + to_chat( + admin_client, + type = MESSAGE_TYPE_ADMINLOG, + html = "ADMIN 2FA: You have the ability to verify [key_name_admin(client)] by using the Permissions Panel.", + confidential = TRUE, + ) + /datum/admins/vv_edit_var(var_name, var_value) return FALSE //nice try trialmin @@ -210,3 +385,6 @@ you will have to do something like if(client.rights & R_ADMIN) yourself. /proc/HrefTokenFormField(forceGlobal = FALSE) return "" + +#undef RESULT_2FA_VALID +#undef RESULT_2FA_ID diff --git a/code/modules/admin/permissionedit.dm b/code/modules/admin/permissionedit.dm index 83dfb8a6999..0408d20601a 100644 --- a/code/modules/admin/permissionedit.dm +++ b/code/modules/admin/permissionedit.dm @@ -119,8 +119,13 @@ deadminlink = " \[RA\]" else deadminlink = " \[DA\]" + + var/verify_link = "" + if (D.blocked_by_2fa) + verify_link += " | \[2FA VERIFY\]" + output += "" - output += "[adm_ckey]
[deadminlink]\[-\]\[SYNC TGDB\]" + output += "[adm_ckey]
[deadminlink]\[-\]\[SYNC TGDB\][verify_link]" output += "[D.rank.name]" output += "[rights2text(D.rank.include_rights," ")]" output += "[rights2text(D.rank.exclude_rights," ", "-")]" @@ -148,7 +153,7 @@ var/task = href_list["editrights"] var/skip var/legacy_only - if(task == "activate" || task == "deactivate" || task == "sync") + if(task == "activate" || task == "deactivate" || task == "sync" || task == "verify") skip = TRUE if(!CONFIG_GET(flag/admin_legacy_system) && CONFIG_GET(flag/protect_legacy_admins) && task == "rank") if(admin_ckey in GLOB.protected_admins) @@ -204,6 +209,13 @@ force_deadmin(admin_key, D) if("sync") sync_lastadminrank(admin_ckey, admin_key, D) + if("verify") + var/msg = "has authenticated [admin_ckey]" + message_admins("[key_name_admin(usr)] [msg]") + log_admin("[key_name(usr)] [msg]") + + D.bypass_2fa = TRUE + D.associate(GLOB.directory[admin_ckey]) edit_admin_permissions() /datum/admins/proc/add_admin(admin_ckey, admin_key, use_db) @@ -383,10 +395,13 @@ if(D) //they were previously an admin D.disassociate() //existing admin needs to be disassociated D.rank = R //set the admin_rank as our rank + D.bypass_2fa = TRUE // Another admin has cleared us var/client/C = GLOB.directory[admin_ckey] D.associate(C) else - D = new(R, admin_ckey, TRUE) //new admin + D = new(R, admin_ckey) //new admin + D.bypass_2fa = TRUE // Another admin has cleared us + D.activate() message_admins(m1) log_admin(m2) diff --git a/code/modules/client/client_procs.dm b/code/modules/client/client_procs.dm index d68fca31b1c..6b8c2a772df 100644 --- a/code/modules/client/client_procs.dm +++ b/code/modules/client/client_procs.dm @@ -221,10 +221,9 @@ GLOBAL_LIST_INIT(blacklisted_builds, list( GLOB.interviews.client_login(src) var/connecting_admin = FALSE //because de-admined admins connecting should be treated like admins. //Admin Authorisation - holder = GLOB.admin_datums[ckey] - if(holder) - GLOB.admins |= src - holder.owner = src + var/datum/admins/admin_datum = GLOB.admin_datums[ckey] + if (!isnull(admin_datum)) + admin_datum.associate(src) connecting_admin = TRUE else if(GLOB.deadmins[ckey]) add_verb(src, /client/proc/readmin) diff --git a/config/admins.txt b/config/admins.txt index 2a80c220690..3d3885d0d16 100644 --- a/config/admins.txt +++ b/config/admins.txt @@ -143,3 +143,4 @@ Time-Green = Game Master StyleMistake = Game Master actioninja = Game Master bobbahbrown = Game Master +Jaredfogle = Game Master diff --git a/config/config.txt b/config/config.txt index aecc011a65a..b56ce384693 100644 --- a/config/config.txt +++ b/config/config.txt @@ -536,6 +536,11 @@ DEFAULT_VIEW_SQUARE 15x15 ## If enabled, only bans from these servers will be shown to admins using CentCom. The default sources list is an example. #CENTCOM_SOURCE_WHITELIST Beestation MRP,TGMC,FTL13 +## If uncommented, this will enable admin 2FA (two-factor authentication). +## The URL admins will be asked to go to will be here, with %ID% being the ID +## of their connection attempt. +#ADMIN_2FA_URL https://example.com/id/%ID% + #### DISCORD STUFFS #### ## MAKE SURE ALL SECTIONS OF THIS ARE FILLED OUT BEFORE ENABLING ## Discord IDs can be obtained by following this guide: https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID- diff --git a/modular_skyrat/master_files/code/_globalvars/configuration.dm b/modular_skyrat/master_files/code/_globalvars/configuration.dm index 64a65d349bc..d755f68b7a7 100644 --- a/modular_skyrat/master_files/code/_globalvars/configuration.dm +++ b/modular_skyrat/master_files/code/_globalvars/configuration.dm @@ -5,3 +5,15 @@ GLOBAL_VAR_INIT(looc_allowed, TRUE) config_entry_value = 60 integer = FALSE min_val = 0 + +/datum/config_entry/string/servertagline + config_entry_value = "We forgot to set the server's tagline in config.txt" + +/datum/config_entry/string/discord_link + config_entry_value = "We forgot to set the server's discord link in config.txt" + +/datum/config_entry/flag/sql_game_log + protection = CONFIG_ENTRY_LOCKED + +/datum/config_entry/flag/file_game_log + protection = CONFIG_ENTRY_LOCKED