CSRF and authentication changes
parent
341be037f8
commit
e0fe7897b7
|
@ -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
|
||||
}
|
||||
});
|
|
@ -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("");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
Loading…
Reference in New Issue