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> {
|
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("");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue