[MIRROR] 2FA for Admins (#6221)

* 2FA for Admins

* a

Co-authored-by: Mothblocks <35135081+Mothblocks@users.noreply.github.com>
Co-authored-by: Gandalf <jzo123@hotmail.com>
This commit is contained in:
SkyratBot
2021-06-10 23:59:30 +01:00
committed by GitHub
co-authored by Mothblocks Gandalf
parent afc02da7e8
commit 0fa2b48b59
12 changed files with 306 additions and 42 deletions
+16
View File
@@ -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
+14
View File
@@ -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 */;
+14
View File
@@ -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 */;
+21 -7
View File
@@ -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))
@@ -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
+7
View File
@@ -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)
+193 -15
View File
@@ -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 "<h1><b class='danger'>You could not be verified, and a DB connection couldn't be established. Please contact an admin with +PERMISSIONS to grant you permission.</b></h1>"
/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 = {"
<h1><b class='danger'>You could not be verified.</b></h1>
<h2><b class='danger'>Please visit <a href='[url_for_2fa]'>[url_for_2fa]</a> to verify.</b></h2>
<h2><b class='danger'>When you are done, click the 'Verify Admin' button in your admin tab.</b></h2>
"},
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 = "<span class='admin'><span class='prefix'>ADMIN 2FA:</span> You have the ability to verify [key_name_admin(client)] by using the Permissions Panel.</span>",
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 "<input type='hidden' name='admin_token' value='[RawHrefToken(forceGlobal)]'>"
#undef RESULT_2FA_VALID
#undef RESULT_2FA_ID
+18 -3
View File
@@ -119,8 +119,13 @@
deadminlink = " <a class='small' href='?src=[REF(src)];[HrefToken()];editrights=activate;key=[adm_ckey]'>\[RA\]</a>"
else
deadminlink = " <a class='small' href='?src=[REF(src)];[HrefToken()];editrights=deactivate;key=[adm_ckey]'>\[DA\]</a>"
var/verify_link = ""
if (D.blocked_by_2fa)
verify_link += " | <a class='small' href='?src=[REF(src)];[HrefToken()];editrights=verify;key=[adm_ckey]'>\[2FA VERIFY\]</a>"
output += "<tr>"
output += "<td style='text-align:center;'>[adm_ckey]<br>[deadminlink]<a class='small' href='?src=[REF(src)];[HrefToken()];editrights=remove;key=[adm_ckey]'>\[-\]</a><a class='small' href='?src=[REF(src)];[HrefToken()];editrights=sync;key=[adm_ckey]'>\[SYNC TGDB\]</a></td>"
output += "<td style='text-align:center;'>[adm_ckey]<br>[deadminlink]<a class='small' href='?src=[REF(src)];[HrefToken()];editrights=remove;key=[adm_ckey]'>\[-\]</a><a class='small' href='?src=[REF(src)];[HrefToken()];editrights=sync;key=[adm_ckey]'>\[SYNC TGDB\]</a>[verify_link]</td>"
output += "<td><a href='?src=[REF(src)];[HrefToken()];editrights=rank;key=[adm_ckey]'>[D.rank.name]</a></td>"
output += "<td><a class='small' href='?src=[REF(src)];[HrefToken()];editrights=permissions;key=[adm_ckey]'>[rights2text(D.rank.include_rights," ")]</a></td>"
output += "<td><a class='small' href='?src=[REF(src)];[HrefToken()];editrights=permissions;key=[adm_ckey]'>[rights2text(D.rank.exclude_rights," ", "-")]</a></td>"
@@ -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)
+3 -4
View File
@@ -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)
+1
View File
@@ -143,3 +143,4 @@ Time-Green = Game Master
StyleMistake = Game Master
actioninja = Game Master
bobbahbrown = Game Master
Jaredfogle = Game Master
+5
View File
@@ -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-
@@ -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