mirror of
https://github.com/ParadiseSS13/Paradise.git
synced 2026-07-15 00:53:23 +01:00
2FA for server login (#16094)
* AA2FA * Its always dbconfig * Extra security * oops
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]'")
|
||||
|
||||
|
||||
@@ -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 ..()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = "<p>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)</p>"
|
||||
var/img_data = "<div style=\"text-align:center;\"><img src=\"[qr_img_src]\"></div>"
|
||||
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
|
||||
|
||||
@@ -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 += "<table><tr><td width='340px' height='300px' valign='top'>"
|
||||
dat += "<h2>General Settings</h2>"
|
||||
dat += "<b>2FA Setup:</b> <a href='?_src_=prefs;preference=edit_2fa'>[_2fastatus_to_text()]</a><br>"
|
||||
if(user.client.holder)
|
||||
dat += "<b>Adminhelp sound:</b> <a href='?_src_=prefs;preference=hear_adminhelps'><b>[(sound & SOUND_ADMINHELP)?"On":"Off"]</b></a><br>"
|
||||
dat += "<b>AFK Cryoing:</b> <a href='?_src_=prefs;preference=afk_watch'>[(toggles2 & PREFTOGGLE_2_AFKWATCH) ? "Yes" : "No"]</a><br>"
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user