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 "