Add CSRF token support

This commit is contained in:
2025-09-03 15:36:28 +01:00
parent bdbf33098f
commit 0c2a8f3d98
7 changed files with 66 additions and 22 deletions

View File

@@ -37,6 +37,15 @@ Metrics/MethodLength:
Metrics/ClassLength: Metrics/ClassLength:
Enabled: false Enabled: false
Metrics/BlockLength:
Enabled: false
Layout/FirstHashElementIndentation:
Enabled: false
Layout/FirstArrayElementIndentation:
Enabled: false
Layout/EmptyLineAfterGuardClause: Layout/EmptyLineAfterGuardClause:
Enabled: false Enabled: false

View File

@@ -8,6 +8,7 @@
content="initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no" content="initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no"
/> />
<meta name="signed_in" content="<%= @signed_in %>"> <meta name="signed_in" content="<%= @signed_in %>">
<meta name="csrf" content="<%= @csrf_token %>">
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <link

View File

@@ -2,10 +2,10 @@
module Logman module Logman
def self.log(log) def self.log(log)
file, line = caller(1, 1)[0].split(":") file, line = caller(1, 1)[0].split(":")
File.write("log/main.log", "[#{Time.now}] {#{file}:#{line}} #{log}\n", mode: "a") File.write("log/main.log", "[#{Time.now}] {#{file}:#{line}} #{log}\n", mode: "a") if ENV_HASH["ENV"] == "dev"
end end
def self.imp(log) def self.imp(log)
File.write("log/imp.log", "[#{Time.now}] #{log}\n", mode: "a") File.write("log/imp.log", "[#{Time.now}] #{log}\n", mode: "a") if ENV_HASH["ENV"] == "dev"
end end
end end

37
main.rb
View File

@@ -8,8 +8,6 @@ require "uri"
require "xxhash" require "xxhash"
require "zlib" require "zlib"
load "logman.rb"
ALPHANUM = [*"0".."9", *"A".."Z", *"a".."z", "-", "_"].freeze ALPHANUM = [*"0".."9", *"A".."Z", *"a".."z", "-", "_"].freeze
env_data = File.exist?(".env") ? File.read(".env") : "" env_data = File.exist?(".env") ? File.read(".env") : ""
@@ -20,10 +18,9 @@ env_data.each_line do |line|
ENV_HASH[match[1]] = match[2] ENV_HASH[match[1]] = match[2]
end end
load "logman.rb"
# Logman.log ENV_HASH.inspect # Logman.log ENV_HASH.inspect
CODE_ENV = :dev
db_file = File.expand_path("infinsweeper.db") db_file = File.expand_path("infinsweeper.db")
DB = Sequel.connect("sqlite:///#{db_file}", single_threaded: false) DB = Sequel.connect("sqlite:///#{db_file}", single_threaded: false)
DB.run("PRAGMA foreign_keys = ON;") DB.run("PRAGMA foreign_keys = ON;")
@@ -40,6 +37,8 @@ get "/" do
@message = session.message || "" @message = session.message || ""
session.message = "" session.message = ""
@signed_in = session.signed_in?.nil? ? false : true @signed_in = session.signed_in?.nil? ? false : true
@csrf_token = Array.new(32) { ALPHANUM.sample }.join
session["csrf_token"] = @csrf_token
ERB.new(File.read("index.erb")).result(binding) ERB.new(File.read("index.erb")).result(binding)
end end
@@ -53,23 +52,28 @@ post "/signup" do
uid = session["user"] uid = session["user"]
session.logout unless uid.nil? || $active_users[uid].nil? session.logout unless uid.nil? || $active_users[uid].nil?
data = JSON.parse(request.body.read) data = JSON.parse(request.body.read)
unless session.csrf_auth?
status 401
return { "message" => "Unauthorized (invalid CSRF token)!" }.to_json
end
session["csrf_token"] = Array.new(32) { ALPHANUM.sample }.join
if data["email"].nil? || data["pass"].nil? || data["username"].nil? if data["email"].nil? || data["pass"].nil? || data["username"].nil?
status 400 status 400
return { "message" => "Bad request made!" }.to_json return { "message" => "Bad request made!", "csrf_token" => session["csrf_token"] }.to_json
end end
signup_status = Players.mk_player(data["username"], data["email"], data["pass"]) signup_status = Players.mk_player(data["username"], data["email"], data["pass"])
if signup_status[0] == 200 if signup_status[0] == 200
login_status = session.login(data["username"], data["pass"]) login_status = session.login(data["username"], data["pass"])
if login_status[0] == 200 if login_status[0] == 200
status 200 status 200
return { "message" => login_status[1], "success" => "true" }.to_json return { "message" => login_status[1], "success" => "true", "csrf_token" => session["csrf_token"] }.to_json
else else
status login_status[0] status login_status[0]
return { "message" => login_status[1] }.to_json return { "message" => login_status[1], "csrf_token" => session["csrf_token"] }.to_json
end end
end end
status signup_status[0] status signup_status[0]
return { "message" => signup_status[1] }.to_json return { "message" => signup_status[1], "csrf_token" => session["csrf_token"] }.to_json
end end
get "/verify/:code" do get "/verify/:code" do
@@ -82,21 +86,30 @@ post "/login" do
data = JSON.parse(request.body.read) data = JSON.parse(request.body.read)
session = Sessions.new request, response session = Sessions.new request, response
uid = session["user"] uid = session["user"]
Logman.log(request.env["HTTP_X_CSRF_TOKEN"].to_s + " " + session["csrf_token"].to_s)
unless session.csrf_auth?
status 401
return { "message" => "Unauthorized (invalid CSRF token)!" }.to_json
end
session["csrf_token"] = Array.new(32) { ALPHANUM.sample }.join
if $active_users[uid] && !session.logout if $active_users[uid] && !session.logout
status 500 status 500
return { "message" => "Internal server error when signing the existing session out!" }.to_json return {
"message" => "Internal server error when signing the existing session out!",
"csrf_token" => session["csrf_token"],
}.to_json
end end
if data["username"].nil? || data["pass"].nil? if data["username"].nil? || data["pass"].nil?
status 400 status 400
return { "message" => "Bad request made!" }.to_json return { "message" => "Bad request made!", "csrf_token" => session["csrf_token"] }.to_json
end end
login_status = session.login(data["username"], data["pass"]) login_status = session.login(data["username"], data["pass"])
if login_status[0] == 200 if login_status[0] == 200
status 200 status 200
return { "message" => login_status[1], "success" => "true" }.to_json return { "message" => login_status[1], "success" => "true", "csrf_token" => session["csrf_token"] }.to_json
else else
status login_status[0] status login_status[0]
return { "message" => login_status[1] }.to_json return { "message" => login_status[1], "csrf_token" => session["csrf_token"] }.to_json
end end
end end

View File

@@ -9,21 +9,24 @@ module Players
end end
def self.mk_player(username, email, pass) def self.mk_player(username, email, pass)
raise ArgumentError, "Email format is wrong!" unless email.match?(/\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/) # rubocop:disable Layout/LineLength
raise ArgumentError, "Email format is wrong!" unless
email.match?(%r[(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])])
# rubocop:enable Layout/LineLength
raise ArgumentError, "Username must be at least 4 characters long and of valid format." unless raise ArgumentError, "Username must be at least 4 characters long and of valid format." unless
username.match?(/\A[a-zA-Z][a-zA-Z0-9_.-]+\z/) && username.length >= 4 username.match?(/\A[a-zA-Z][a-zA-Z0-9_.-]+\z/) && username.length >= 4
raise ArgumentError, "Password must be at least 8 characters and of valid format." unless raise ArgumentError, "Password must be at least 8 characters and of valid format." unless
pass.match?(/\A[a-zA-Z0-9_.!?@#$%^&*()+=-]+\z/) && pass.length >= 8 pass.match?(/\A[a-zA-Z0-9_.!?@#$%^&*()+=-]+\z/) && pass.length >= 8
digest = XXhash.xxh32(pass, ENV_HASH["SALT"]) digest = XXhash.xxh32(pass, ENV_HASH["SALT"])
code = CODE_ENV == :prod ? Array.new(24) { ALPHANUM.sample }.join : "!" code = ENV_HASH["ENV"] == "prod" ? Array.new(24) { ALPHANUM.sample }.join : "!"
DB[ DB[
"insert into Players (username, digest, email, activation_code) values (?, ?, ?, ?)", "insert into Players (username, digest, email, activation_code) values (?, ?, ?, ?)",
username, digest, email, code username, digest, email, code
].insert ].insert
send_email(:new, email, username, code) if CODE_ENV == :prod send_email(:new, email, username, code) if ENV_HASH["ENV"] == "prod"
[200, "Successfully signed up!"] [200, "Successfully signed up!"]
rescue ArgumentError => e rescue ArgumentError => e

View File

@@ -1,3 +1,5 @@
const csrfMeta = document.querySelector('meta[name="csrf"]');
var csrf = csrfMeta?.content;
window.onload = async () => { window.onload = async () => {
const popup = document.getElementById("popup"); const popup = document.getElementById("popup");
const loginSection = document.getElementById("login"); const loginSection = document.getElementById("login");
@@ -71,11 +73,12 @@ window.onload = async () => {
const { username, pass } = loginForm; const { username, pass } = loginForm;
const res = await fetch("/login", { const res = await fetch("/login", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf },
body: JSON.stringify({ username: username.value, pass: pass.value }), body: JSON.stringify({ username: username.value, pass: pass.value }),
}); });
const data = await res.json(); const data = await res.json();
loginInfo.innerText = data.message; loginInfo.innerText = data.message;
csrf = data.csrf_token;
if (data.success == "true") { if (data.success == "true") {
loginButton.style.display = "none"; loginButton.style.display = "none";
signupButton.style.display = "none"; signupButton.style.display = "none";
@@ -91,7 +94,7 @@ window.onload = async () => {
const { username, email, pass } = signupForm; const { username, email, pass } = signupForm;
const res = await fetch("/signup", { const res = await fetch("/signup", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf },
body: JSON.stringify({ body: JSON.stringify({
username: username.value, username: username.value,
email: email.value, email: email.value,
@@ -100,6 +103,7 @@ window.onload = async () => {
}); });
const data = await res.json(); const data = await res.json();
signupInfo.innerText = data.message; signupInfo.innerText = data.message;
csrf = data.csrf_token;
if (data.success == "true") { if (data.success == "true") {
loginButton.style.display = "none"; loginButton.style.display = "none";
signupButton.style.display = "none"; signupButton.style.display = "none";

View File

@@ -40,17 +40,22 @@ class Sessions
end end
end end
# TODO: Use .all here
def []=(key, val) def []=(key, val)
session = @request.cookies["session"] session = @request.cookies["session"]
session = session.nil? ? "{}" : Zlib::Inflate.inflate(Base64.decode64(session)) session = session.nil? ? "{}" : Zlib::Inflate.inflate(Base64.decode64(session))
session = JSON.parse(session) session = JSON.parse(session)
session[key] = val session[key] = val
Logman.log "Updated: #{key} to #{val}"
compressed = Zlib::Deflate.deflate(JSON.generate(session)) compressed = Zlib::Deflate.deflate(JSON.generate(session))
encoded = Base64.encode64(compressed) encoded = Base64.encode64(compressed)
@response.set_cookie("session", @response.set_cookie("session",
value: encoded, value: encoded,
path: "/", path: "/",
expires: Time.now + 360 * 24 * 60 * 60) expires: Time.now + 360 * 24 * 60 * 60,
httponly: true,
secure: ENV_HASH["ENV"] == "prod",
samesite: :strict)
uid = session["user"] uid = session["user"]
DB["UPDATE SignedInUsers SET last_used_at = CURRENT_TIMESTAMP WHERE code = ?", uid].update if uid DB["UPDATE SignedInUsers SET last_used_at = CURRENT_TIMESTAMP WHERE code = ?", uid].update if uid
rescue JSON::ParserError, Zlib::Error rescue JSON::ParserError, Zlib::Error
@@ -79,13 +84,19 @@ class Sessions
@response.set_cookie("message", @response.set_cookie("message",
value: val, value: val,
path: "/", path: "/",
expires: Time.now + 360 * 24 * 60 * 60) expires: Time.now + 360 * 24 * 60 * 60,
secure: ENV_HASH["ENV"] == "prod",
samesite: :strict)
end end
def message def message
@request.cookies["message"] @request.cookies["message"]
end end
def csrf_auth?
@request.env["HTTP_X_CSRF_TOKEN"] == self["csrf_token"]
end
def all def all
session = @request.cookies["session"] session = @request.cookies["session"]
session = session.nil? ? "{}" : Zlib::Inflate.inflate(Base64.decode64(session)) session = session.nil? ? "{}" : Zlib::Inflate.inflate(Base64.decode64(session))
@@ -107,7 +118,10 @@ class Sessions
@response.set_cookie("session", @response.set_cookie("session",
value: encoded, value: encoded,
path: "/", path: "/",
expires: Time.now + 360 * 24 * 60 * 60) expires: Time.now + 360 * 24 * 60 * 60,
httponly: true,
secure: ENV_HASH["ENV"] == "prod",
samesite: :strict)
rescue JSON::ParserError, Zlib::Error rescue JSON::ParserError, Zlib::Error
@response.delete_cookie("session") @response.delete_cookie("session")
end end