refactor(auth): migrate session storage to JWT with persistent secret (#508)
- Replace in-memory session Map with stateless JWT tokens - Add jose library for JWT signing and verification - Implement persistent JWT secret storage in ~/.config/openchamber - Support OPENCODE_JWT_SECRET environment variable override - Update SessionAuthGate and useServerSessionStatus hooks - Remove session cleanup timer (JWTs are stateless) Co-authored-by: Jovines <jovines@qq.com>
This commit is contained in:
committed by
GitHub
co-authored by
Jovines
parent
5473381720
commit
7a151290be
@@ -261,6 +261,7 @@
|
|||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"ghostty-web": "0.3.0",
|
"ghostty-web": "0.3.0",
|
||||||
"http-proxy-middleware": "^3.0.5",
|
"http-proxy-middleware": "^3.0.5",
|
||||||
|
"jose": "^6.1.3",
|
||||||
"jsonc-parser": "^3.3.1",
|
"jsonc-parser": "^3.3.1",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"node-pty": "^1.1.0",
|
"node-pty": "^1.1.0",
|
||||||
@@ -2176,6 +2177,8 @@
|
|||||||
|
|
||||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||||
|
|
||||||
|
"jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
|
||||||
|
|
||||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||||
|
|
||||||
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||||
@@ -3224,6 +3227,8 @@
|
|||||||
|
|
||||||
"@openchamber/ui/ghostty-web": ["ghostty-web@0.4.0", "", {}, "sha512-0puDBik2qapbD/QQBW9o5ZHfXnZBqZWx/ctBiVtKZ6ZLds4NYb+wZuw1cRLXZk9zYovIQ908z3rvFhexAvc5Hg=="],
|
"@openchamber/ui/ghostty-web": ["ghostty-web@0.4.0", "", {}, "sha512-0puDBik2qapbD/QQBW9o5ZHfXnZBqZWx/ctBiVtKZ6ZLds4NYb+wZuw1cRLXZk9zYovIQ908z3rvFhexAvc5Hg=="],
|
||||||
|
|
||||||
|
"@openchamber/web/@opencode-ai/sdk": ["@opencode-ai/sdk@1.2.10", "", {}, "sha512-SyXcVqry2hitPVvQtvXOhqsWyFhSycG/+LTLYXrcq8AFmd9FR7dyBSDB3f5Ol6IPkYOegk8P2Eg2kKPNSNiKGw=="],
|
||||||
|
|
||||||
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||||
|
|
||||||
"@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
"@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||||
|
|||||||
@@ -10,17 +10,21 @@ import { DesktopHostSwitcherInline } from '@/components/desktop/DesktopHostSwitc
|
|||||||
const STATUS_CHECK_ENDPOINT = '/auth/session';
|
const STATUS_CHECK_ENDPOINT = '/auth/session';
|
||||||
|
|
||||||
const fetchSessionStatus = async (): Promise<Response> => {
|
const fetchSessionStatus = async (): Promise<Response> => {
|
||||||
return fetch(STATUS_CHECK_ENDPOINT, {
|
console.log('[Frontend Auth] Checking session status...');
|
||||||
|
const response = await fetch(STATUS_CHECK_ENDPOINT, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
console.log('[Frontend Auth] Session status response:', response.status, response.statusText);
|
||||||
|
return response;
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitPassword = async (password: string): Promise<Response> => {
|
const submitPassword = async (password: string): Promise<Response> => {
|
||||||
return fetch(STATUS_CHECK_ENDPOINT, {
|
console.log('[Frontend Auth] Submitting password...');
|
||||||
|
const response = await fetch(STATUS_CHECK_ENDPOINT, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -29,6 +33,8 @@ const submitPassword = async (password: string): Promise<Response> => {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({ password }),
|
body: JSON.stringify({ password }),
|
||||||
});
|
});
|
||||||
|
console.log('[Frontend Auth] Password submit response:', response.status, response.statusText);
|
||||||
|
return response;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AuthShell: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
const AuthShell: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||||
@@ -134,30 +140,58 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
|||||||
|
|
||||||
const checkStatus = React.useCallback(async () => {
|
const checkStatus = React.useCallback(async () => {
|
||||||
if (skipAuth) {
|
if (skipAuth) {
|
||||||
|
console.log('[Frontend Auth] VSCode runtime, skipping auth');
|
||||||
setState('authenticated');
|
setState('authenticated');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查 cookie 是否存在
|
||||||
|
const cookies = document.cookie;
|
||||||
|
const hasAccessToken = cookies.includes('oc_ui_session=');
|
||||||
|
const hasRefreshToken = cookies.includes('oc_ui_refresh=');
|
||||||
|
console.log('[Frontend Auth] Cookies check - access:', hasAccessToken, 'refresh:', hasRefreshToken);
|
||||||
|
console.log('[Frontend Auth] All cookies:', cookies.split(';').map(c => c.trim().split('=')[0]));
|
||||||
|
|
||||||
setState((prev) => (prev === 'authenticated' ? prev : 'pending'));
|
setState((prev) => (prev === 'authenticated' ? prev : 'pending'));
|
||||||
try {
|
try {
|
||||||
const response = await fetchSessionStatus();
|
const response = await fetchSessionStatus();
|
||||||
|
const responseText = await response.text();
|
||||||
|
console.log('[Frontend Auth] Raw response:', response.status, responseText);
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
|
console.log('[Frontend Auth] Session is authenticated');
|
||||||
setState('authenticated');
|
setState('authenticated');
|
||||||
setErrorMessage('');
|
setErrorMessage('');
|
||||||
setRetryAfter(undefined);
|
setRetryAfter(undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
|
let data: { debug?: { hasRefreshToken: boolean; message: string } } = {};
|
||||||
|
try {
|
||||||
|
data = JSON.parse(responseText);
|
||||||
|
} catch {
|
||||||
|
data = {};
|
||||||
|
}
|
||||||
|
console.warn('[Frontend Auth] Session is locked (401)', data);
|
||||||
|
if (data.debug) {
|
||||||
|
console.warn('[Frontend Auth] Debug info:', data.debug);
|
||||||
|
}
|
||||||
setState('locked');
|
setState('locked');
|
||||||
setRetryAfter(undefined);
|
setRetryAfter(undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (response.status === 429) {
|
if (response.status === 429) {
|
||||||
const data = await response.json().catch(() => ({}));
|
let data: { retryAfter?: number } = {};
|
||||||
|
try {
|
||||||
|
data = JSON.parse(responseText);
|
||||||
|
} catch {
|
||||||
|
data = {};
|
||||||
|
}
|
||||||
setRetryAfter(data.retryAfter);
|
setRetryAfter(data.retryAfter);
|
||||||
setState('rate-limited');
|
setState('rate-limited');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
console.error('[Frontend Auth] Unexpected response status:', response.status);
|
||||||
setState('error');
|
setState('error');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to check session status:', error);
|
console.warn('Failed to check session status:', error);
|
||||||
@@ -254,24 +288,34 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
|||||||
try {
|
try {
|
||||||
const response = await submitPassword(password);
|
const response = await submitPassword(password);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
|
console.log('[Frontend Auth] Login successful');
|
||||||
|
// 检查登录后 cookie 是否被设置
|
||||||
|
const cookies = document.cookie;
|
||||||
|
const hasAccessToken = cookies.includes('oc_ui_session=');
|
||||||
|
const hasRefreshToken = cookies.includes('oc_ui_refresh=');
|
||||||
|
console.log('[Frontend Auth] After login - access:', hasAccessToken, 'refresh:', hasRefreshToken);
|
||||||
|
console.log('[Frontend Auth] All cookies after login:', cookies.split(';').map(c => c.trim().split('=')[0]).filter(Boolean));
|
||||||
setPassword('');
|
setPassword('');
|
||||||
setState('authenticated');
|
setState('authenticated');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
|
console.warn('[Frontend Auth] Login failed: Invalid password');
|
||||||
setErrorMessage('Incorrect password. Try again.');
|
setErrorMessage('Incorrect password. Try again.');
|
||||||
setState('locked');
|
setState('locked');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status === 429) {
|
if (response.status === 429) {
|
||||||
|
console.warn('[Frontend Auth] Login failed: Rate limited');
|
||||||
const data = await response.json().catch(() => ({}));
|
const data = await response.json().catch(() => ({}));
|
||||||
setRetryAfter(data.retryAfter);
|
setRetryAfter(data.retryAfter);
|
||||||
setState('rate-limited');
|
setState('rate-limited');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.error('[Frontend Auth] Login failed: Unexpected response', response.status);
|
||||||
setErrorMessage('Unexpected response from server.');
|
setErrorMessage('Unexpected response from server.');
|
||||||
setState('error');
|
setState('error');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ export function useServerSessionStatus() {
|
|||||||
headers: { Accept: 'application/json' },
|
headers: { Accept: 'application/json' },
|
||||||
}).then(async (r) => {
|
}).then(async (r) => {
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
|
console.warn('[useServerSessionStatus] API returned', r.status);
|
||||||
|
if (r.status === 401) {
|
||||||
|
console.warn('[useServerSessionStatus] Authentication required - session may have expired');
|
||||||
|
}
|
||||||
throw new Error(String(r.status));
|
throw new Error(String(r.status));
|
||||||
}
|
}
|
||||||
return (await r.json()) as ServerSnapshotResponse;
|
return (await r.json()) as ServerSnapshotResponse;
|
||||||
|
|||||||
@@ -48,6 +48,7 @@
|
|||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"ghostty-web": "0.3.0",
|
"ghostty-web": "0.3.0",
|
||||||
"http-proxy-middleware": "^3.0.5",
|
"http-proxy-middleware": "^3.0.5",
|
||||||
|
"jose": "^6.1.3",
|
||||||
"jsonc-parser": "^3.3.1",
|
"jsonc-parser": "^3.3.1",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"node-pty": "^1.1.0",
|
"node-pty": "^1.1.0",
|
||||||
|
|||||||
@@ -5713,10 +5713,22 @@ async function main(options = {}) {
|
|||||||
console.log('UI password protection enabled for browser sessions');
|
console.log('UI password protection enabled for browser sessions');
|
||||||
}
|
}
|
||||||
|
|
||||||
app.get('/auth/session', (req, res) => uiAuthController.handleSessionStatus(req, res));
|
app.get('/auth/session', async (req, res) => {
|
||||||
|
try {
|
||||||
|
await uiAuthController.handleSessionStatus(req, res);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
app.post('/auth/session', (req, res) => uiAuthController.handleSessionCreate(req, res));
|
app.post('/auth/session', (req, res) => uiAuthController.handleSessionCreate(req, res));
|
||||||
|
|
||||||
app.use('/api', (req, res, next) => uiAuthController.requireAuth(req, res, next));
|
app.use('/api', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await uiAuthController.requireAuth(req, res, next);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const parsePushSubscribeBody = (body) => {
|
const parsePushSubscribeBody = (body) => {
|
||||||
if (!body || typeof body !== 'object') return null;
|
if (!body || typeof body !== 'object') return null;
|
||||||
@@ -5757,7 +5769,7 @@ async function main(options = {}) {
|
|||||||
await ensurePushInitialized();
|
await ensurePushInitialized();
|
||||||
|
|
||||||
const uiToken = uiAuthController?.ensureSessionToken
|
const uiToken = uiAuthController?.ensureSessionToken
|
||||||
? uiAuthController.ensureSessionToken(req, res)
|
? await uiAuthController.ensureSessionToken(req, res)
|
||||||
: getUiSessionTokenFromRequest(req);
|
: getUiSessionTokenFromRequest(req);
|
||||||
if (!uiToken) {
|
if (!uiToken) {
|
||||||
return res.status(401).json({ error: 'UI session missing' });
|
return res.status(401).json({ error: 'UI session missing' });
|
||||||
@@ -5805,7 +5817,7 @@ async function main(options = {}) {
|
|||||||
await ensurePushInitialized();
|
await ensurePushInitialized();
|
||||||
|
|
||||||
const uiToken = uiAuthController?.ensureSessionToken
|
const uiToken = uiAuthController?.ensureSessionToken
|
||||||
? uiAuthController.ensureSessionToken(req, res)
|
? await uiAuthController.ensureSessionToken(req, res)
|
||||||
: getUiSessionTokenFromRequest(req);
|
: getUiSessionTokenFromRequest(req);
|
||||||
if (!uiToken) {
|
if (!uiToken) {
|
||||||
return res.status(401).json({ error: 'UI session missing' });
|
return res.status(401).json({ error: 'UI session missing' });
|
||||||
@@ -5820,9 +5832,9 @@ async function main(options = {}) {
|
|||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/push/visibility', (req, res) => {
|
app.post('/api/push/visibility', async (req, res) => {
|
||||||
const uiToken = uiAuthController?.ensureSessionToken
|
const uiToken = uiAuthController?.ensureSessionToken
|
||||||
? uiAuthController.ensureSessionToken(req, res)
|
? await uiAuthController.ensureSessionToken(req, res)
|
||||||
: getUiSessionTokenFromRequest(req);
|
: getUiSessionTokenFromRequest(req);
|
||||||
if (!uiToken) {
|
if (!uiToken) {
|
||||||
return res.status(401).json({ error: 'UI session missing' });
|
return res.status(401).json({ error: 'UI session missing' });
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
import { SignJWT, jwtVerify } from 'jose';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import os from 'os';
|
||||||
|
|
||||||
const SESSION_COOKIE_NAME = 'oc_ui_session';
|
const SESSION_COOKIE_NAME = 'oc_ui_session';
|
||||||
const SESSION_TTL_MS = 12 * 60 * 60 * 1000;
|
const SESSION_TTL_MS = 12 * 60 * 60 * 1000;
|
||||||
const CLEANUP_INTERVAL_MS = 10 * 60 * 1000;
|
|
||||||
|
|
||||||
const RATE_LIMIT_WINDOW_MS = 5 * 60 * 1000;
|
const RATE_LIMIT_WINDOW_MS = 5 * 60 * 1000;
|
||||||
const RATE_LIMIT_MAX_ATTEMPTS = Number(process.env.OPENCHAMBER_RATE_LIMIT_MAX_ATTEMPTS) || 10;
|
const RATE_LIMIT_MAX_ATTEMPTS = Number(process.env.OPENCHAMBER_RATE_LIMIT_MAX_ATTEMPTS) || 10;
|
||||||
@@ -271,6 +274,37 @@ const normalizePassword = (candidate) => {
|
|||||||
return candidate.normalize().trim();
|
return candidate.normalize().trim();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR
|
||||||
|
? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
|
||||||
|
: path.join(os.homedir(), '.config', 'openchamber');
|
||||||
|
const JWT_SECRET_FILE = path.join(OPENCHAMBER_DATA_DIR, 'jwt-secret');
|
||||||
|
|
||||||
|
function getOrCreateJwtSecret() {
|
||||||
|
const envSecret = process.env.OPENCODE_JWT_SECRET;
|
||||||
|
if (envSecret) {
|
||||||
|
return new TextEncoder().encode(envSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(JWT_SECRET_FILE)) {
|
||||||
|
return new TextEncoder().encode(fs.readFileSync(JWT_SECRET_FILE, 'utf8').trim());
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[JWT] Failed to read secret file:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = crypto.randomBytes(32).toString('hex');
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(OPENCHAMBER_DATA_DIR, { recursive: true });
|
||||||
|
fs.writeFileSync(JWT_SECRET_FILE, secret, { mode: 0o600 });
|
||||||
|
console.log('[JWT] Generated and persisted new secret to', JWT_SECRET_FILE);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[JWT] Failed to persist secret:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TextEncoder().encode(secret);
|
||||||
|
}
|
||||||
|
|
||||||
export const createUiAuth = ({
|
export const createUiAuth = ({
|
||||||
password,
|
password,
|
||||||
cookieName = SESSION_COOKIE_NAME,
|
cookieName = SESSION_COOKIE_NAME,
|
||||||
@@ -291,7 +325,7 @@ export const createUiAuth = ({
|
|||||||
res.setHeader('Set-Cookie', header);
|
res.setHeader('Set-Cookie', header);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ensureSessionToken = (req, res) => {
|
const ensureSessionToken = async (req, res) => {
|
||||||
const cookies = parseCookies(req.headers.cookie);
|
const cookies = parseCookies(req.headers.cookie);
|
||||||
if (cookies[cookieName]) {
|
if (cookies[cookieName]) {
|
||||||
return cookies[cookieName];
|
return cookies[cookieName];
|
||||||
@@ -319,9 +353,7 @@ export const createUiAuth = ({
|
|||||||
|
|
||||||
const salt = crypto.randomBytes(16);
|
const salt = crypto.randomBytes(16);
|
||||||
const expectedHash = crypto.scryptSync(normalizedPassword, salt, 64);
|
const expectedHash = crypto.scryptSync(normalizedPassword, salt, 64);
|
||||||
const sessions = new Map();
|
const JWT_SECRET = getOrCreateJwtSecret();
|
||||||
|
|
||||||
let cleanupTimer = null;
|
|
||||||
|
|
||||||
const getTokenFromRequest = (req) => {
|
const getTokenFromRequest = (req) => {
|
||||||
const cookies = parseCookies(req.headers.cookie);
|
const cookies = parseCookies(req.headers.cookie);
|
||||||
@@ -331,12 +363,6 @@ export const createUiAuth = ({
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const dropSession = (token) => {
|
|
||||||
if (token) {
|
|
||||||
sessions.delete(token);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const setSessionCookie = (req, res, token) => {
|
const setSessionCookie = (req, res, token) => {
|
||||||
const secure = isSecureRequest(req);
|
const secure = isSecureRequest(req);
|
||||||
const maxAgeSeconds = Math.floor(sessionTtlMs / 1000);
|
const maxAgeSeconds = Math.floor(sessionTtlMs / 1000);
|
||||||
@@ -376,49 +402,28 @@ export const createUiAuth = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isSessionValid = (token) => {
|
const isSessionValid = async (token) => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const record = sessions.get(token);
|
try {
|
||||||
if (!record) {
|
await jwtVerify(token, JWT_SECRET);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (Date.now() - record.lastSeen > sessionTtlMs) {
|
|
||||||
sessions.delete(token);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
record.lastSeen = Date.now();
|
|
||||||
return true;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const issueSession = (req, res) => {
|
const issueSession = async (req, res) => {
|
||||||
const token = crypto.randomBytes(32).toString('base64url');
|
const token = await new SignJWT({ type: 'ui-session' })
|
||||||
const now = Date.now();
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
sessions.set(token, { createdAt: now, lastSeen: now });
|
.setIssuedAt()
|
||||||
|
.setExpirationTime(sessionTtlMs / 1000 + 's')
|
||||||
|
.sign(JWT_SECRET);
|
||||||
setSessionCookie(req, res, token);
|
setSessionCookie(req, res, token);
|
||||||
return token;
|
return token;
|
||||||
};
|
};
|
||||||
|
|
||||||
const cleanupStaleSessions = () => {
|
|
||||||
const now = Date.now();
|
|
||||||
for (const [token, record] of sessions.entries()) {
|
|
||||||
if (now - record.lastSeen > sessionTtlMs) {
|
|
||||||
sessions.delete(token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const startCleanup = () => {
|
|
||||||
if (!cleanupTimer) {
|
|
||||||
cleanupTimer = setInterval(cleanupStaleSessions, CLEANUP_INTERVAL_MS);
|
|
||||||
if (cleanupTimer && typeof cleanupTimer.unref === 'function') {
|
|
||||||
cleanupTimer.unref();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
startCleanup();
|
|
||||||
startRateLimitCleanup();
|
startRateLimitCleanup();
|
||||||
|
|
||||||
const respondUnauthorized = (req, res) => {
|
const respondUnauthorized = (req, res) => {
|
||||||
@@ -431,21 +436,21 @@ export const createUiAuth = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const requireAuth = (req, res, next) => {
|
const requireAuth = async (req, res, next) => {
|
||||||
if (req.method === 'OPTIONS') {
|
if (req.method === 'OPTIONS') {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
const token = getTokenFromRequest(req);
|
const token = getTokenFromRequest(req);
|
||||||
if (isSessionValid(token)) {
|
if (await isSessionValid(token)) {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
clearSessionCookie(req, res);
|
clearSessionCookie(req, res);
|
||||||
return respondUnauthorized(req, res);
|
return respondUnauthorized(req, res);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSessionStatus = (req, res) => {
|
const handleSessionStatus = async (req, res) => {
|
||||||
const token = getTokenFromRequest(req);
|
const token = getTokenFromRequest(req);
|
||||||
if (isSessionValid(token)) {
|
if (await isSessionValid(token)) {
|
||||||
res.json({ authenticated: true });
|
res.json({ authenticated: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -479,22 +484,12 @@ export const createUiAuth = ({
|
|||||||
|
|
||||||
await clearRateLimit(req);
|
await clearRateLimit(req);
|
||||||
|
|
||||||
const previousToken = getTokenFromRequest(req);
|
await issueSession(req, res);
|
||||||
if (previousToken) {
|
|
||||||
dropSession(previousToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
issueSession(req, res);
|
|
||||||
res.json({ authenticated: true });
|
res.json({ authenticated: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
const dispose = () => {
|
const dispose = () => {
|
||||||
sessions.clear();
|
|
||||||
loginRateLimiter.clear();
|
loginRateLimiter.clear();
|
||||||
if (cleanupTimer) {
|
|
||||||
clearInterval(cleanupTimer);
|
|
||||||
cleanupTimer = null;
|
|
||||||
}
|
|
||||||
if (rateLimitCleanupTimer) {
|
if (rateLimitCleanupTimer) {
|
||||||
clearInterval(rateLimitCleanupTimer);
|
clearInterval(rateLimitCleanupTimer);
|
||||||
rateLimitCleanupTimer = null;
|
rateLimitCleanupTimer = null;
|
||||||
@@ -506,9 +501,9 @@ export const createUiAuth = ({
|
|||||||
requireAuth,
|
requireAuth,
|
||||||
handleSessionStatus,
|
handleSessionStatus,
|
||||||
handleSessionCreate,
|
handleSessionCreate,
|
||||||
ensureSessionToken: (req, _res) => {
|
ensureSessionToken: async (req, _res) => {
|
||||||
const token = getTokenFromRequest(req);
|
const token = getTokenFromRequest(req);
|
||||||
return isSessionValid(token) ? token : null;
|
return (await isSessionValid(token)) ? token : null;
|
||||||
},
|
},
|
||||||
dispose,
|
dispose,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user