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> {
// 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<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 { 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();

View File

@ -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<SilverBulletHooks>;
@ -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;

View File

@ -57,8 +57,8 @@
<header>
<h1>Login to <img src="/.client/logo.png" style="height: 1ch;" /> SilverBullet</h1>
</header>
<form action="/.auth" method="POST">
<input type="hidden" name="refer" value="" />
<form action="/.auth" method="POST" id="login">
<input type="hidden" name="csrf" value="" />
<div class="error-message"></div>
<div>
<input type="text" name="username" id="username" autocomplete="off" autocorrect="off" autocapitalize="off"
@ -77,15 +77,45 @@
<script>
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;
}
</script>
</body>