CSRF and authentication changes

pull/599/head
Zef Hemel 2023-12-11 12:11:56 +01:00
parent 341be037f8
commit e0fe7897b7
5 changed files with 203 additions and 46 deletions

27
server/crypto.test.ts Normal file
View File

@ -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
}
});

View File

@ -1,13 +1,87 @@
export async function hashSHA256(message: string): Promise<string> { import {
// Transform the string into an ArrayBuffer create,
const encoder = new TextEncoder(); getNumericDate,
const data = encoder.encode(message); verify,
} from "https://deno.land/x/djwt@v3.0.1/mod.ts";
import { KvPrimitives } from "../plugos/lib/kv_primitives.ts";
// Generate the hash const jwtSecretKey = "jwtSecretKey";
const hashBuffer = await window.crypto.subtle.digest("SHA-256", data);
// Transform the hash into a hex string export class JWTIssuer {
return Array.from(new Uint8Array(hashBuffer)).map((b) => private key!: CryptoKey;
b.toString(16).padStart(2, "0")
).join(""); 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<string, unknown>,
expirySeconds: number,
): Promise<string> {
return create({ alg: "HS512", typ: "JWT" }, {
...payload,
exp: getNumericDate(expirySeconds),
}, this.key);
}
verifyAndDecodeJWT(jwt: string): Promise<Record<string, unknown>> {
return verify(jwt, this.key);
}
async hashSHA256(message: string): Promise<string> {
// 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("");
}
} }

View File

@ -11,12 +11,11 @@ import { FileMeta } from "$sb/types.ts";
import { ShellRequest, SyscallRequest, SyscallResponse } from "./rpc.ts"; import { ShellRequest, SyscallRequest, SyscallResponse } from "./rpc.ts";
import { determineShellBackend } from "./shell_backend.ts"; import { determineShellBackend } from "./shell_backend.ts";
import { SpaceServer, SpaceServerConfig } from "./instance.ts"; import { SpaceServer, SpaceServerConfig } from "./instance.ts";
import { import { KvPrimitives } from "../plugos/lib/kv_primitives.ts";
KvPrimitives,
PrefixedKvPrimitives,
} from "../plugos/lib/kv_primitives.ts";
import { EndpointHook } from "../plugos/hooks/endpoint.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 = { export type ServerOptions = {
app: Application; app: Application;
@ -129,6 +128,8 @@ export class HttpServer {
} }
start() { start() {
// Initialize JWT issuer
// First check if auth string (username:password) has changed
// Serve static files (javascript, css, html) // Serve static files (javascript, css, html)
this.app.use(this.serveStatic.bind(this)); this.app.use(this.serveStatic.bind(this));
@ -251,25 +252,35 @@ export class HttpServer {
const values = await request.body({ type: "form" }).value; const values = await request.body({ type: "form" }).value;
const username = values.get("username")!; const username = values.get("username")!;
const password = values.get("password")!; 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 spaceServer = await this.ensureSpaceServer(request);
const hashedPassword = await hashSHA256(password);
const [expectedUser, expectedPassword] = spaceServer.auth!.split(":"); const [expectedUser, expectedPassword] = spaceServer.auth!.split(":");
if ( if (username === expectedUser && password === expectedPassword) {
username === expectedUser && // Generate a JWT and set it as a cookie
hashedPassword === await hashSHA256(expectedPassword) const jwt = await spaceServer.jwtIssuer.createJWT(
) { { username },
authenticationExpirySeconds,
);
await cookies.set( await cookies.set(
authCookieName(host), 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", sameSite: "strict",
}, },
); );
response.redirect(refer || "/"); response.redirect("/");
// console.log("All headers", request.headers);
} else { } else {
response.redirect("/.auth?error=1"); response.redirect("/.auth?error=1");
} }
@ -294,20 +305,25 @@ export class HttpServer {
if (!excludedPaths.includes(request.url.pathname)) { if (!excludedPaths.includes(request.url.pathname)) {
const authCookie = await cookies.get(authCookieName(host)); const authCookie = await cookies.get(authCookieName(host));
if (!authCookie) { if (!authCookie) {
response.redirect("/.auth"); return response.redirect("/.auth");
return;
} }
const spaceServer = await this.ensureSpaceServer(request); const [expectedUser] = spaceServer.auth!.split(
const [username, hashedPassword] = authCookie.split(":");
const [expectedUser, expectedPassword] = spaceServer.auth!.split(
":", ":",
); );
if (
username !== expectedUser || try {
hashedPassword !== await hashSHA256(expectedPassword) const verifiedJwt = await spaceServer.jwtIssuer.verifyAndDecodeJWT(
) { authCookie,
response.redirect("/.auth"); );
return; 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(); await next();

View File

@ -5,8 +5,10 @@ import { SpacePrimitives } from "../common/spaces/space_primitives.ts";
import { ensureSettingsAndIndex } from "../common/util.ts"; import { ensureSettingsAndIndex } from "../common/util.ts";
import { AssetBundle } from "../plugos/asset_bundle/bundle.ts"; import { AssetBundle } from "../plugos/asset_bundle/bundle.ts";
import { KvPrimitives } from "../plugos/lib/kv_primitives.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 { System } from "../plugos/system.ts";
import { BuiltinSettings } from "../web/types.ts"; import { BuiltinSettings } from "../web/types.ts";
import { JWTIssuer } from "./crypto.ts";
import { gitIgnoreCompiler } from "./deps.ts"; import { gitIgnoreCompiler } from "./deps.ts";
import { ServerSystem } from "./server_system.ts"; import { ServerSystem } from "./server_system.ts";
import { ShellBackend } from "./shell_backend.ts"; import { ShellBackend } from "./shell_backend.ts";
@ -27,6 +29,8 @@ export class SpaceServer {
private settings?: BuiltinSettings; private settings?: BuiltinSettings;
spacePrimitives: SpacePrimitives; spacePrimitives: SpacePrimitives;
jwtIssuer: JWTIssuer;
// Only set when syncOnly == false // Only set when syncOnly == false
private serverSystem?: ServerSystem; private serverSystem?: ServerSystem;
system?: System<SilverBulletHooks>; system?: System<SilverBulletHooks>;
@ -35,11 +39,12 @@ export class SpaceServer {
config: SpaceServerConfig, config: SpaceServerConfig,
public shellBackend: ShellBackend, public shellBackend: ShellBackend,
plugAssetBundle: AssetBundle, plugAssetBundle: AssetBundle,
kvPrimitives?: KvPrimitives, private kvPrimitives?: KvPrimitives,
) { ) {
this.pagesPath = config.pagesPath; this.pagesPath = config.pagesPath;
this.hostname = config.hostname; this.hostname = config.hostname;
this.auth = config.auth; this.auth = config.auth;
this.jwtIssuer = new JWTIssuer(kvPrimitives || new MemoryKvPrimitives());
let fileFilterFn: (s: string) => boolean = () => true; let fileFilterFn: (s: string) => boolean = () => true;
@ -71,6 +76,11 @@ export class SpaceServer {
} }
async init() { async init() {
if (this.auth) {
// Initialize JWT issuer
await this.jwtIssuer.init(this.auth);
}
if (this.serverSystem) { if (this.serverSystem) {
await this.serverSystem.init(); await this.serverSystem.init();
this.system = this.serverSystem.system; this.system = this.serverSystem.system;

View File

@ -57,8 +57,8 @@
<header> <header>
<h1>Login to <img src="/.client/logo.png" style="height: 1ch;" /> SilverBullet</h1> <h1>Login to <img src="/.client/logo.png" style="height: 1ch;" /> SilverBullet</h1>
</header> </header>
<form action="/.auth" method="POST"> <form action="/.auth" method="POST" id="login">
<input type="hidden" name="refer" value="" /> <input type="hidden" name="csrf" value="" />
<div class="error-message"></div> <div class="error-message"></div>
<div> <div>
<input type="text" name="username" id="username" autocomplete="off" autocorrect="off" autocapitalize="off" <input type="text" name="username" id="username" autocomplete="off" autocorrect="off" autocapitalize="off"
@ -77,15 +77,45 @@
<script> <script>
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const error = params.get('error');
const refer = params.get('refer'); if (error === "1") {
if (refer) {
document.querySelector('input[name="refer"]').value = refer;
}
if (params.get('error')) {
document.querySelector('.error-message').innerText = "Invalid username or password"; 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;
}
</script> </script>
</body> </body>