From e0fe7897b711daec8b10a4024e42a89fc03f5cd5 Mon Sep 17 00:00:00 2001 From: Zef Hemel Date: Mon, 11 Dec 2023 12:11:56 +0100 Subject: [PATCH] CSRF and authentication changes --- server/crypto.test.ts | 27 +++++++++++++ server/crypto.ts | 94 ++++++++++++++++++++++++++++++++++++++----- server/http_server.ts | 68 +++++++++++++++++++------------ server/instance.ts | 12 +++++- web/auth.html | 48 +++++++++++++++++----- 5 files changed, 203 insertions(+), 46 deletions(-) create mode 100644 server/crypto.test.ts diff --git a/server/crypto.test.ts b/server/crypto.test.ts new file mode 100644 index 00000000..b1300be5 --- /dev/null +++ b/server/crypto.test.ts @@ -0,0 +1,27 @@ +import { sleep } from "$sb/lib/async.ts"; +import { MemoryKvPrimitives } from "../plugos/lib/memory_kv_primitives.ts"; +import { assertEquals } from "../test_deps.ts"; +import { JWTIssuer } from "./crypto.ts"; + +Deno.test("Test JWT crypto", async () => { + const db = new MemoryKvPrimitives(); + const jwt = new JWTIssuer(db); + await jwt.init("test"); + // Timeout value is 0 seconds, which means it should expire immediately with a 1 second grace period + const token = await jwt.createJWT({ user: "pete", role: "admin" }, 0); + const verified = await jwt.verifyAndDecodeJWT(token); + assertEquals(verified.user, "pete"); + try { + await jwt.verifyAndDecodeJWT(token + "bla"); + assertEquals(true, false, "Should have thrown invalid signature"); + } catch { + // expected + } + await sleep(1500); + try { + await jwt.verifyAndDecodeJWT(token); + assertEquals(true, false, "Should have thrown a timeout"); + } catch { + // expected + } +}); diff --git a/server/crypto.ts b/server/crypto.ts index ce88fefe..bf044e30 100644 --- a/server/crypto.ts +++ b/server/crypto.ts @@ -1,13 +1,87 @@ -export async function hashSHA256(message: string): Promise { - // Transform the string into an ArrayBuffer - const encoder = new TextEncoder(); - const data = encoder.encode(message); +import { + create, + getNumericDate, + verify, +} from "https://deno.land/x/djwt@v3.0.1/mod.ts"; +import { KvPrimitives } from "../plugos/lib/kv_primitives.ts"; - // Generate the hash - const hashBuffer = await window.crypto.subtle.digest("SHA-256", data); +const jwtSecretKey = "jwtSecretKey"; - // Transform the hash into a hex string - return Array.from(new Uint8Array(hashBuffer)).map((b) => - b.toString(16).padStart(2, "0") - ).join(""); +export class JWTIssuer { + private key!: CryptoKey; + + constructor(readonly kv: KvPrimitives) { + } + + async init(authString: string) { + const [secret] = await this.kv.batchGet([[jwtSecretKey]]); + if (!secret) { + return this.generateNewKey(); + } else { + this.key = await crypto.subtle.importKey( + "raw", + secret, + { name: "HMAC", hash: "SHA-512" }, + true, + ["sign", "verify"], + ); + } + + // Check if the authentication has changed since last run + const [currentAuthHash] = await this.kv.batchGet([[ + "authHash", + ]]); + const newAuthHash = await this.hashSHA256(authString); + if (currentAuthHash && currentAuthHash !== newAuthHash) { + // It has, so we need to generate a new key to invalidate all existing tokens + await this.generateNewKey(); + } + if (currentAuthHash !== newAuthHash) { + // Persist new auth hash + await this.kv.batchSet([{ + key: ["authHash"], + value: newAuthHash, + }]); + } + } + + async generateNewKey() { + this.key = await crypto.subtle.generateKey( + { name: "HMAC", hash: "SHA-512" }, + true, + ["sign", "verify"], + ); + await this.kv.batchSet([{ + key: [jwtSecretKey], + value: await crypto.subtle.exportKey("raw", this.key), + }]); + } + + createJWT( + payload: Record, + expirySeconds: number, + ): Promise { + return create({ alg: "HS512", typ: "JWT" }, { + ...payload, + exp: getNumericDate(expirySeconds), + }, this.key); + } + + verifyAndDecodeJWT(jwt: string): Promise> { + return verify(jwt, this.key); + } + + async hashSHA256(message: string): Promise { + // Transform the string into an ArrayBuffer + const encoder = new TextEncoder(); + const data = encoder.encode(message); + + // Generate the hash + const hashBuffer = await window.crypto.subtle.digest("SHA-256", data); + + // Transform the hash into a hex string + return Array.from(new Uint8Array(hashBuffer)).map((b) => + b.toString(16).padStart(2, "0") + ).join(""); + } } diff --git a/server/http_server.ts b/server/http_server.ts index 30c08b42..659867ea 100644 --- a/server/http_server.ts +++ b/server/http_server.ts @@ -11,12 +11,11 @@ import { FileMeta } from "$sb/types.ts"; import { ShellRequest, SyscallRequest, SyscallResponse } from "./rpc.ts"; import { determineShellBackend } from "./shell_backend.ts"; import { SpaceServer, SpaceServerConfig } from "./instance.ts"; -import { - KvPrimitives, - PrefixedKvPrimitives, -} from "../plugos/lib/kv_primitives.ts"; +import { KvPrimitives } from "../plugos/lib/kv_primitives.ts"; import { EndpointHook } from "../plugos/hooks/endpoint.ts"; -import { hashSHA256 } from "./crypto.ts"; +import { PrefixedKvPrimitives } from "../plugos/lib/prefixed_kv_primitives.ts"; + +const authenticationExpirySeconds = 60 * 60 * 24 * 7; // 1 week export type ServerOptions = { app: Application; @@ -129,6 +128,8 @@ export class HttpServer { } start() { + // Initialize JWT issuer + // First check if auth string (username:password) has changed // Serve static files (javascript, css, html) this.app.use(this.serveStatic.bind(this)); @@ -251,25 +252,35 @@ export class HttpServer { const values = await request.body({ type: "form" }).value; const username = values.get("username")!; const password = values.get("password")!; - const refer = values.get("refer"); + + const formCSRF = values.get("csrf"); + const cookieCSRF = await cookies.get("csrf_token"); + + if (formCSRF !== cookieCSRF) { + response.redirect("/.auth?error=2"); + console.log("CSRF mismatch", formCSRF, cookieCSRF); + return; + } + + await cookies.delete("csrf_token"); const spaceServer = await this.ensureSpaceServer(request); - const hashedPassword = await hashSHA256(password); const [expectedUser, expectedPassword] = spaceServer.auth!.split(":"); - if ( - username === expectedUser && - hashedPassword === await hashSHA256(expectedPassword) - ) { + if (username === expectedUser && password === expectedPassword) { + // Generate a JWT and set it as a cookie + const jwt = await spaceServer.jwtIssuer.createJWT( + { username }, + authenticationExpirySeconds, + ); await cookies.set( authCookieName(host), - `${username}:${hashedPassword}`, + jwt, { - expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), // in a week + expires: new Date(Date.now() + authenticationExpirySeconds), // in a week sameSite: "strict", }, ); - response.redirect(refer || "/"); - // console.log("All headers", request.headers); + response.redirect("/"); } else { response.redirect("/.auth?error=1"); } @@ -294,20 +305,25 @@ export class HttpServer { if (!excludedPaths.includes(request.url.pathname)) { const authCookie = await cookies.get(authCookieName(host)); if (!authCookie) { - response.redirect("/.auth"); - return; + return response.redirect("/.auth"); } - const spaceServer = await this.ensureSpaceServer(request); - const [username, hashedPassword] = authCookie.split(":"); - const [expectedUser, expectedPassword] = spaceServer.auth!.split( + const [expectedUser] = spaceServer.auth!.split( ":", ); - if ( - username !== expectedUser || - hashedPassword !== await hashSHA256(expectedPassword) - ) { - response.redirect("/.auth"); - return; + + try { + const verifiedJwt = await spaceServer.jwtIssuer.verifyAndDecodeJWT( + authCookie, + ); + if (verifiedJwt.username !== expectedUser) { + throw new Error("Username mismatch"); + } + } catch (e: any) { + console.error( + "Error verifying JWT, redirecting to auth page", + e.message, + ); + return response.redirect("/.auth"); } } await next(); diff --git a/server/instance.ts b/server/instance.ts index 57eddb7c..42711b39 100644 --- a/server/instance.ts +++ b/server/instance.ts @@ -5,8 +5,10 @@ import { SpacePrimitives } from "../common/spaces/space_primitives.ts"; import { ensureSettingsAndIndex } from "../common/util.ts"; import { AssetBundle } from "../plugos/asset_bundle/bundle.ts"; import { KvPrimitives } from "../plugos/lib/kv_primitives.ts"; +import { MemoryKvPrimitives } from "../plugos/lib/memory_kv_primitives.ts"; import { System } from "../plugos/system.ts"; import { BuiltinSettings } from "../web/types.ts"; +import { JWTIssuer } from "./crypto.ts"; import { gitIgnoreCompiler } from "./deps.ts"; import { ServerSystem } from "./server_system.ts"; import { ShellBackend } from "./shell_backend.ts"; @@ -27,6 +29,8 @@ export class SpaceServer { private settings?: BuiltinSettings; spacePrimitives: SpacePrimitives; + jwtIssuer: JWTIssuer; + // Only set when syncOnly == false private serverSystem?: ServerSystem; system?: System; @@ -35,11 +39,12 @@ export class SpaceServer { config: SpaceServerConfig, public shellBackend: ShellBackend, plugAssetBundle: AssetBundle, - kvPrimitives?: KvPrimitives, + private kvPrimitives?: KvPrimitives, ) { this.pagesPath = config.pagesPath; this.hostname = config.hostname; this.auth = config.auth; + this.jwtIssuer = new JWTIssuer(kvPrimitives || new MemoryKvPrimitives()); let fileFilterFn: (s: string) => boolean = () => true; @@ -71,6 +76,11 @@ export class SpaceServer { } async init() { + if (this.auth) { + // Initialize JWT issuer + await this.jwtIssuer.init(this.auth); + } + if (this.serverSystem) { await this.serverSystem.init(); this.system = this.serverSystem.system; diff --git a/web/auth.html b/web/auth.html index 5ba25daf..3e6b9ebd 100644 --- a/web/auth.html +++ b/web/auth.html @@ -57,8 +57,8 @@

Login to SilverBullet

-
- + +
const params = new URLSearchParams(window.location.search); - - const refer = params.get('refer'); - if (refer) { - document.querySelector('input[name="refer"]').value = refer; - } - - if (params.get('error')) { + const error = params.get('error'); + if (error === "1") { document.querySelector('.error-message').innerText = "Invalid username or password"; + } else if (error === "2") { + document.querySelector('.error-message').innerText = "Invalid CSRF token"; } + + // Generate CSRF token + const csrf = generateCSRFToken(); + + // Inject CSRF token in form + document.querySelector('input[name="csrf"]').value = csrf; + + function generateRandomString(length) { + const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let result = ''; + for (let i = 0; i < length; i++) { + result += characters.charAt(Math.floor(Math.random() * characters.length)); + } + return result; + } + + function generateCSRFToken() { + // Generate random strings + const randomPart1 = generateRandomString(16); + const randomPart2 = generateRandomString(16); + + // Create a timestamp for uniqueness + const timestamp = new Date().getTime(); + + // Combine random strings and timestamp + const csrfToken = randomPart1 + timestamp + randomPart2; + + // Set cookie + document.cookie = `csrf_token=${csrfToken}; SameSite=Lax; Secure`; + + return csrfToken; + } +