even better security
This commit is contained in:
+20
-26
@@ -7,12 +7,15 @@ import {
|
||||
type User,
|
||||
type UserRole,
|
||||
} from "./users";
|
||||
import {
|
||||
persistRevokedToken,
|
||||
isTokenRevoked,
|
||||
pruneExpiredRevokedTokens,
|
||||
} from "./db";
|
||||
|
||||
const JWT_ISSUER = "overlord-server";
|
||||
const JWT_AUDIENCE = "overlord-client";
|
||||
|
||||
const tokenBlacklist = new Set<string>();
|
||||
|
||||
const tokenCache = new Map<
|
||||
string,
|
||||
{ payload: JWTPayload; timestamp: number }
|
||||
@@ -67,7 +70,7 @@ export async function generateToken(user: User): Promise<string> {
|
||||
}
|
||||
|
||||
export async function verifyToken(token: string): Promise<JWTPayload | null> {
|
||||
if (tokenBlacklist.has(token)) {
|
||||
if (isTokenRevoked(token)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -106,31 +109,22 @@ export async function verifyToken(token: string): Promise<JWTPayload | null> {
|
||||
}
|
||||
|
||||
export function revokeToken(token: string): void {
|
||||
tokenBlacklist.add(token);
|
||||
let expiresAt = Math.floor(Date.now() / 1000) + getSessionTtlSeconds();
|
||||
try {
|
||||
const parts = token.split(".");
|
||||
if (parts.length === 3) {
|
||||
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString());
|
||||
if (typeof payload.exp === "number") expiresAt = payload.exp;
|
||||
}
|
||||
} catch { /* keep fallback */ }
|
||||
|
||||
persistRevokedToken(token, expiresAt);
|
||||
tokenCache.delete(token);
|
||||
console.log("[auth] Token revoked");
|
||||
console.log("[auth] Token revoked (persisted)");
|
||||
}
|
||||
|
||||
export async function cleanupBlacklist(): Promise<void> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
let cleaned = 0;
|
||||
|
||||
for (const token of tokenBlacklist) {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, getSecretKey(), {
|
||||
issuer: JWT_ISSUER,
|
||||
audience: JWT_AUDIENCE,
|
||||
});
|
||||
|
||||
if (payload.exp && payload.exp < now) {
|
||||
tokenBlacklist.delete(token);
|
||||
cleaned++;
|
||||
}
|
||||
} catch {
|
||||
tokenBlacklist.delete(token);
|
||||
cleaned++;
|
||||
}
|
||||
}
|
||||
const pruned = pruneExpiredRevokedTokens();
|
||||
|
||||
const cacheNow = Date.now();
|
||||
let cacheCleared = 0;
|
||||
@@ -141,9 +135,9 @@ export async function cleanupBlacklist(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
if (cleaned > 0 || cacheCleared > 0) {
|
||||
if (pruned > 0 || cacheCleared > 0) {
|
||||
console.log(
|
||||
`[auth] Cleaned up ${cleaned} expired tokens from blacklist, ${cacheCleared} from cache`,
|
||||
`[auth] Cleaned up ${pruned} expired tokens from blacklist, ${cacheCleared} from cache`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,16 @@ db.run(
|
||||
`CREATE INDEX IF NOT EXISTS idx_banned_ips_created_at ON banned_ips(created_at DESC);`,
|
||||
);
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS revoked_tokens (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
db.run(
|
||||
`CREATE INDEX IF NOT EXISTS idx_revoked_tokens_expires ON revoked_tokens(expires_at);`,
|
||||
);
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS builds (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -317,6 +327,41 @@ export function isIpBanned(ip: string): boolean {
|
||||
return !!row?.ip;
|
||||
}
|
||||
|
||||
// ── Revoked token persistence ──
|
||||
|
||||
function hashToken(token: string): string {
|
||||
return new Bun.CryptoHasher("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
export function persistRevokedToken(token: string, expiresAt: number): void {
|
||||
db.run(
|
||||
`INSERT OR IGNORE INTO revoked_tokens (token_hash, expires_at) VALUES (?, ?)`,
|
||||
hashToken(token),
|
||||
expiresAt,
|
||||
);
|
||||
}
|
||||
|
||||
export function isTokenRevoked(token: string): boolean {
|
||||
const row = db.query<{ token_hash: string }>(
|
||||
`SELECT token_hash FROM revoked_tokens WHERE token_hash=?`,
|
||||
).get(hashToken(token));
|
||||
return !!row;
|
||||
}
|
||||
|
||||
export function loadAllRevokedTokenHashes(): Set<string> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const rows = db.query<{ token_hash: string }>(
|
||||
`SELECT token_hash FROM revoked_tokens WHERE expires_at > ?`,
|
||||
).all(now);
|
||||
return new Set(rows.map((r) => r.token_hash));
|
||||
}
|
||||
|
||||
export function pruneExpiredRevokedTokens(): number {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const result = db.run(`DELETE FROM revoked_tokens WHERE expires_at <= ?`, now);
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
export function markAllClientsOffline() {
|
||||
db.run(`UPDATE clients SET online=0`);
|
||||
console.log("[db] marked all clients as offline");
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { logger } from "../logger";
|
||||
import { timingSafeEqual } from "crypto";
|
||||
|
||||
function safeCompare(a: string, b: string): boolean {
|
||||
const bufA = Buffer.from(a);
|
||||
const bufB = Buffer.from(b);
|
||||
if (bufA.length !== bufB.length) return false;
|
||||
return timingSafeEqual(bufA, bufB);
|
||||
}
|
||||
|
||||
export function isAuthorizedAgentRequest(
|
||||
req: Request,
|
||||
@@ -21,7 +29,9 @@ export function isAuthorizedAgentRequest(
|
||||
|
||||
const headerToken = req.headers.get("x-agent-token");
|
||||
const queryToken = url.searchParams.get("token");
|
||||
const isAuthed = headerToken === token || queryToken === token;
|
||||
const isAuthed =
|
||||
(headerToken !== null && safeCompare(headerToken, token)) ||
|
||||
(queryToken !== null && safeCompare(queryToken, token));
|
||||
|
||||
if (!isAuthed) {
|
||||
logger.info("[auth] Agent auth failed");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const CORS_HEADERS = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Origin": "null",
|
||||
"Access-Control-Allow-Methods": "GET,OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
};
|
||||
@@ -10,4 +10,6 @@ export const SECURITY_HEADERS = {
|
||||
"X-Frame-Options": "DENY",
|
||||
"X-XSS-Protection": "1; mode=block",
|
||||
"Referrer-Policy": "strict-origin-when-cross-origin",
|
||||
"Strict-Transport-Security": "max-age=63072000; includeSubDomains",
|
||||
"Permissions-Policy": "camera=(), geolocation=(), payment=()",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user