Add passkey login for protected UI (#845)
* feat(auth): add passkey login for protected UI * fix: polish passkey setup and correct WebAuthn user IDs * feat: improve passkey login management * build: align passkey deps with upstream main * refactor: move ui auth out of opencode module --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
37cf7d9c79
commit
75a10ea66c
@@ -1,7 +1,7 @@
|
||||
# OpenCode Module Documentation
|
||||
|
||||
## Purpose
|
||||
This module provides OpenCode server integration utilities for the web server runtime, including configuration management, provider authentication, and UI authentication with rate limiting.
|
||||
This module provides OpenCode server integration utilities for the web server runtime, including configuration management and provider authentication.
|
||||
|
||||
## Entrypoints and structure
|
||||
- `packages/web/server/lib/opencode/index.js`: public entrypoint (currently baseline placeholder).
|
||||
@@ -40,7 +40,8 @@ This module provides OpenCode server integration utilities for the web server ru
|
||||
- `packages/web/server/lib/opencode/session-runtime.js`: session status/attention/activity runtime for OpenCode SSE events.
|
||||
- `packages/web/server/lib/opencode/watcher.js`: global SSE watcher runtime for push/session event fanout.
|
||||
- `packages/web/server/lib/opencode/shared.js`: shared utilities for config, markdown, skills, and git helpers.
|
||||
- `packages/web/server/lib/opencode/ui-auth.js`: UI session authentication with rate limiting.
|
||||
- `packages/web/server/lib/ui-auth/ui-auth.js`: UI session authentication runtime (outside OpenCode module).
|
||||
- `packages/web/server/lib/ui-auth/ui-passkeys.js`: UI passkey storage and WebAuthn registration/authentication helpers (outside OpenCode module).
|
||||
|
||||
## Public exports (auth.js)
|
||||
- `readAuthFile()`: Reads and parses `~/.local/share/opencode/auth.json`.
|
||||
@@ -67,15 +68,6 @@ This module provides OpenCode server integration utilities for the web server ru
|
||||
- `resolveSkillSearchDirectories(workingDirectory)`: Returns skill search path order (config, project, home, custom).
|
||||
- `listSkillSupportingFiles(skillDir)`, `readSkillSupportingFile(skillDir, relativePath)`, `writeSkillSupportingFile(skillDir, relativePath, content)`, `deleteSkillSupportingFile(skillDir, relativePath)`: Skill supporting file management.
|
||||
|
||||
## Public exports (ui-auth.js)
|
||||
- `createUiAuth({ password, cookieName, sessionTtlMs })`: Creates UI auth instance with methods:
|
||||
- `enabled`: Boolean indicating if auth is configured.
|
||||
- `requireAuth(req, res, next)`: Express middleware to enforce authentication.
|
||||
- `handleSessionStatus(req, res)`: Returns authentication status.
|
||||
- `handleSessionCreate(req, res)`: Handles login with rate limiting.
|
||||
- `ensureSessionToken(req, res)`: Returns or creates session token.
|
||||
- `dispose()`: Cleans up timers and state.
|
||||
|
||||
## Public exports (routes.js)
|
||||
- `registerOpenCodeRoutes(app, dependencies)`: Registers OpenCode-owned HTTP routes and internal module runtime:
|
||||
- `GET /api/config/settings`
|
||||
@@ -230,11 +222,19 @@ This module provides OpenCode server integration utilities for the web server ru
|
||||
- `GET /health`
|
||||
- `POST /api/system/shutdown`
|
||||
- `GET /api/system/info`
|
||||
- `registerAuthAndAccessRoutes(app, dependencies)`: registers browser auth/session exchange and API access middleware:
|
||||
- `GET /auth/session`
|
||||
- `POST /auth/session`
|
||||
- `GET /connect`
|
||||
- `app.use('/api', ...)` auth/tunnel guard
|
||||
- `registerAuthAndAccessRoutes(app, dependencies)`: registers browser auth/session exchange and API access middleware:
|
||||
- `GET /auth/session`
|
||||
- `POST /auth/session`
|
||||
- `GET /auth/passkey/status`
|
||||
- `POST /auth/passkey/authenticate/options`
|
||||
- `POST /auth/passkey/authenticate/verify`
|
||||
- `POST /auth/passkey/register/options`
|
||||
- `POST /auth/passkey/register/verify`
|
||||
- `GET /api/passkeys`
|
||||
- `DELETE /api/passkeys/:id`
|
||||
- `POST /api/auth/reset`
|
||||
- `GET /connect`
|
||||
- `app.use('/api', ...)` auth/tunnel guard
|
||||
- `registerSettingsUtilityRoutes(app, dependencies)`: registers small settings utility endpoints:
|
||||
- `GET /api/config/themes`
|
||||
- `POST /api/config/reload`
|
||||
|
||||
+4
-1
@@ -60,7 +60,10 @@ export const createBootstrapRuntime = (dependencies) => {
|
||||
|
||||
registerCommonRequestMiddleware(app, { express });
|
||||
|
||||
const uiAuthController = createUiAuth({ password: uiPassword });
|
||||
const uiAuthController = createUiAuth({
|
||||
password: uiPassword,
|
||||
readSettingsFromDiskMigrated,
|
||||
});
|
||||
if (uiAuthController.enabled) {
|
||||
console.log('UI password protection enabled for browser sessions');
|
||||
}
|
||||
|
||||
@@ -67,6 +67,100 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
|
||||
return uiAuthController.handleSessionCreate(req, res);
|
||||
});
|
||||
|
||||
app.get('/auth/passkey/status', (req, res) => {
|
||||
const requestScope = tunnelAuthController.classifyRequestScope(req);
|
||||
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
|
||||
return res.json({ enabled: false, hasPasskeys: false, passkeyCount: 0, rpID: null, tunnelLocked: true });
|
||||
}
|
||||
return uiAuthController.handlePasskeyStatus(req, res);
|
||||
});
|
||||
|
||||
app.post('/auth/passkey/authenticate/options', (req, res) => {
|
||||
const requestScope = tunnelAuthController.classifyRequestScope(req);
|
||||
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
|
||||
return res.status(403).json({ error: 'Passkey login is disabled for tunnel scope', tunnelLocked: true });
|
||||
}
|
||||
return uiAuthController.handlePasskeyAuthenticationOptions(req, res);
|
||||
});
|
||||
|
||||
app.post('/auth/passkey/authenticate/verify', (req, res) => {
|
||||
const requestScope = tunnelAuthController.classifyRequestScope(req);
|
||||
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
|
||||
return res.status(403).json({ error: 'Passkey login is disabled for tunnel scope', tunnelLocked: true });
|
||||
}
|
||||
return uiAuthController.handlePasskeyAuthenticationVerify(req, res);
|
||||
});
|
||||
|
||||
app.post('/auth/passkey/register/options', async (req, res, next) => {
|
||||
const requestScope = tunnelAuthController.classifyRequestScope(req);
|
||||
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
|
||||
return res.status(403).json({ error: 'Passkey setup is disabled for tunnel scope', tunnelLocked: true });
|
||||
}
|
||||
try {
|
||||
await uiAuthController.requireAuth(req, res, async () => {
|
||||
await uiAuthController.handlePasskeyRegistrationOptions(req, res);
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/auth/passkey/register/verify', async (req, res, next) => {
|
||||
const requestScope = tunnelAuthController.classifyRequestScope(req);
|
||||
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
|
||||
return res.status(403).json({ error: 'Passkey setup is disabled for tunnel scope', tunnelLocked: true });
|
||||
}
|
||||
try {
|
||||
await uiAuthController.requireAuth(req, res, async () => {
|
||||
await uiAuthController.handlePasskeyRegistrationVerify(req, res);
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/passkeys', async (req, res, next) => {
|
||||
const requestScope = tunnelAuthController.classifyRequestScope(req);
|
||||
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
|
||||
return res.status(403).json({ error: 'Passkey management is disabled for tunnel scope', tunnelLocked: true });
|
||||
}
|
||||
try {
|
||||
await uiAuthController.requireAuth(req, res, async () => {
|
||||
await uiAuthController.handlePasskeyList(req, res);
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/passkeys/:id', async (req, res, next) => {
|
||||
const requestScope = tunnelAuthController.classifyRequestScope(req);
|
||||
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
|
||||
return res.status(403).json({ error: 'Passkey management is disabled for tunnel scope', tunnelLocked: true });
|
||||
}
|
||||
try {
|
||||
await uiAuthController.requireAuth(req, res, async () => {
|
||||
await uiAuthController.handlePasskeyRevoke(req, res);
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/reset', async (req, res, next) => {
|
||||
const requestScope = tunnelAuthController.classifyRequestScope(req);
|
||||
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
|
||||
return res.status(403).json({ error: 'Global sign-out is disabled for tunnel scope', tunnelLocked: true });
|
||||
}
|
||||
try {
|
||||
await uiAuthController.requireAuth(req, res, async () => {
|
||||
await uiAuthController.handleResetAuth(req, res);
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/connect', async (req, res) => {
|
||||
try {
|
||||
const token = typeof req.query?.t === 'string' ? req.query.t : '';
|
||||
|
||||
@@ -55,7 +55,7 @@ export {
|
||||
OPENCODE_DATA_DIR,
|
||||
} from './auth.js';
|
||||
|
||||
export { createUiAuth } from './ui-auth.js';
|
||||
export { createUiAuth } from '../ui-auth/ui-auth.js';
|
||||
|
||||
export {
|
||||
listMcpConfigs,
|
||||
|
||||
@@ -1,510 +0,0 @@
|
||||
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_TTL_MS = 12 * 60 * 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_LOCKOUT_MS = 15 * 60 * 1000;
|
||||
const RATE_LIMIT_CLEANUP_MS = 60 * 60 * 1000;
|
||||
const RATE_LIMIT_NO_IP_MAX_ATTEMPTS = Number(process.env.OPENCHAMBER_RATE_LIMIT_NO_IP_MAX_ATTEMPTS) || 3;
|
||||
|
||||
const loginRateLimiter = new Map();
|
||||
let rateLimitCleanupTimer = null;
|
||||
|
||||
const rateLimitLocks = new Map();
|
||||
|
||||
const getClientIp = (req) => {
|
||||
const forwarded = req.headers['x-forwarded-for'];
|
||||
if (typeof forwarded === 'string') {
|
||||
const ip = forwarded.split(',')[0].trim();
|
||||
if (ip.startsWith('::ffff:')) {
|
||||
return ip.substring(7);
|
||||
}
|
||||
return ip;
|
||||
}
|
||||
|
||||
const ip = req.ip || req.connection?.remoteAddress;
|
||||
if (ip) {
|
||||
if (ip.startsWith('::ffff:')) {
|
||||
return ip.substring(7);
|
||||
}
|
||||
return ip;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getRateLimitKey = (req) => {
|
||||
const ip = getClientIp(req);
|
||||
if (ip) return ip;
|
||||
return 'rate-limit:no-ip';
|
||||
};
|
||||
|
||||
const getRateLimitConfig = (key) => {
|
||||
if (key === 'rate-limit:no-ip') {
|
||||
return {
|
||||
maxAttempts: RATE_LIMIT_NO_IP_MAX_ATTEMPTS,
|
||||
windowMs: RATE_LIMIT_WINDOW_MS
|
||||
};
|
||||
}
|
||||
return {
|
||||
maxAttempts: RATE_LIMIT_MAX_ATTEMPTS,
|
||||
windowMs: RATE_LIMIT_WINDOW_MS
|
||||
};
|
||||
};
|
||||
|
||||
const acquireRateLimitLock = async (key) => {
|
||||
const prev = rateLimitLocks.get(key) || Promise.resolve();
|
||||
const curr = prev.then(() => rateLimitLocks.delete(key));
|
||||
rateLimitLocks.set(key, curr);
|
||||
await curr;
|
||||
};
|
||||
|
||||
const checkRateLimit = async (req) => {
|
||||
const key = getRateLimitKey(req);
|
||||
await acquireRateLimitLock(key);
|
||||
|
||||
const now = Date.now();
|
||||
const { maxAttempts } = getRateLimitConfig(key);
|
||||
|
||||
let record;
|
||||
try {
|
||||
record = loginRateLimiter.get(key);
|
||||
} catch (err) {
|
||||
console.error('[RateLimit] Failed to get record', { key, error: err.message });
|
||||
return {
|
||||
allowed: true,
|
||||
limit: maxAttempts,
|
||||
remaining: maxAttempts,
|
||||
reset: Math.ceil((now + RATE_LIMIT_WINDOW_MS) / 1000)
|
||||
};
|
||||
}
|
||||
|
||||
if (record?.lockedUntil && now < record.lockedUntil) {
|
||||
return {
|
||||
allowed: false,
|
||||
retryAfter: Math.ceil((record.lockedUntil - now) / 1000),
|
||||
locked: true,
|
||||
limit: maxAttempts,
|
||||
remaining: 0,
|
||||
reset: Math.ceil(record.lockedUntil / 1000)
|
||||
};
|
||||
}
|
||||
|
||||
if (record?.lockedUntil && now >= record.lockedUntil) {
|
||||
try {
|
||||
loginRateLimiter.delete(key);
|
||||
} catch (err) {
|
||||
console.error('[RateLimit] Failed to delete expired record', { key, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
if (!record || now - record.lastAttempt > RATE_LIMIT_WINDOW_MS) {
|
||||
return {
|
||||
allowed: true,
|
||||
limit: maxAttempts,
|
||||
remaining: maxAttempts,
|
||||
reset: Math.ceil((now + RATE_LIMIT_WINDOW_MS) / 1000)
|
||||
};
|
||||
}
|
||||
|
||||
if (record.count >= maxAttempts) {
|
||||
const lockedUntil = now + RATE_LIMIT_LOCKOUT_MS;
|
||||
try {
|
||||
loginRateLimiter.set(key, { count: record.count + 1, lastAttempt: now, lockedUntil });
|
||||
} catch (err) {
|
||||
console.error('[RateLimit] Failed to set lockout', { key, error: err.message });
|
||||
}
|
||||
return {
|
||||
allowed: false,
|
||||
retryAfter: Math.ceil(RATE_LIMIT_LOCKOUT_MS / 1000),
|
||||
locked: true,
|
||||
limit: maxAttempts,
|
||||
remaining: 0,
|
||||
reset: Math.ceil(lockedUntil / 1000)
|
||||
};
|
||||
}
|
||||
|
||||
const remaining = maxAttempts - record.count;
|
||||
const reset = Math.ceil((record.lastAttempt + RATE_LIMIT_WINDOW_MS) / 1000);
|
||||
return {
|
||||
allowed: true,
|
||||
limit: maxAttempts,
|
||||
remaining,
|
||||
reset
|
||||
};
|
||||
};
|
||||
|
||||
const recordFailedAttempt = async (req) => {
|
||||
const key = getRateLimitKey(req);
|
||||
await acquireRateLimitLock(key);
|
||||
|
||||
const now = Date.now();
|
||||
const { maxAttempts } = getRateLimitConfig(key);
|
||||
const record = loginRateLimiter.get(key);
|
||||
|
||||
if (!record || now - record.lastAttempt > RATE_LIMIT_WINDOW_MS) {
|
||||
try {
|
||||
loginRateLimiter.set(key, { count: 1, lastAttempt: now });
|
||||
} catch (err) {
|
||||
console.error('[RateLimit] Failed to record attempt', { key, error: err.message });
|
||||
}
|
||||
} else {
|
||||
const newCount = record.count + 1;
|
||||
try {
|
||||
loginRateLimiter.set(key, { count: newCount, lastAttempt: now });
|
||||
} catch (err) {
|
||||
console.error('[RateLimit] Failed to record attempt', { key, error: err.message });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const clearRateLimit = async (req) => {
|
||||
const key = getRateLimitKey(req);
|
||||
await acquireRateLimitLock(key);
|
||||
|
||||
try {
|
||||
loginRateLimiter.delete(key);
|
||||
} catch (err) {
|
||||
console.error('[RateLimit] Failed to clear', { key, error: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const cleanupRateLimitRecords = () => {
|
||||
const now = Date.now();
|
||||
for (const [key, record] of loginRateLimiter.entries()) {
|
||||
const isExpired = record.lockedUntil && now >= record.lockedUntil;
|
||||
const isStale = now - record.lastAttempt > RATE_LIMIT_CLEANUP_MS;
|
||||
if (isExpired || isStale) {
|
||||
try {
|
||||
loginRateLimiter.delete(key);
|
||||
} catch (err) {
|
||||
console.error('[RateLimit] Cleanup failed', { key, error: err.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const startRateLimitCleanup = () => {
|
||||
if (!rateLimitCleanupTimer) {
|
||||
rateLimitCleanupTimer = setInterval(cleanupRateLimitRecords, RATE_LIMIT_CLEANUP_MS);
|
||||
if (rateLimitCleanupTimer && typeof rateLimitCleanupTimer.unref === 'function') {
|
||||
rateLimitCleanupTimer.unref();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const stopRateLimitCleanup = () => {
|
||||
if (rateLimitCleanupTimer) {
|
||||
clearInterval(rateLimitCleanupTimer);
|
||||
rateLimitCleanupTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const isSecureRequest = (req) => {
|
||||
if (req.secure) {
|
||||
return true;
|
||||
}
|
||||
const forwardedProto = req.headers['x-forwarded-proto'];
|
||||
if (typeof forwardedProto === 'string') {
|
||||
const firstProto = forwardedProto.split(',')[0]?.trim().toLowerCase();
|
||||
return firstProto === 'https';
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const parseCookies = (cookieHeader) => {
|
||||
if (!cookieHeader || typeof cookieHeader !== 'string') {
|
||||
return {};
|
||||
}
|
||||
|
||||
return cookieHeader.split(';').reduce((acc, segment) => {
|
||||
const [name, ...rest] = segment.split('=');
|
||||
if (!name) {
|
||||
return acc;
|
||||
}
|
||||
const key = name.trim();
|
||||
if (!key) {
|
||||
return acc;
|
||||
}
|
||||
const value = rest.join('=').trim();
|
||||
acc[key] = decodeURIComponent(value || '');
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
const buildCookie = ({
|
||||
name,
|
||||
value,
|
||||
maxAge,
|
||||
secure,
|
||||
}) => {
|
||||
const attributes = [
|
||||
`${name}=${value}`,
|
||||
'Path=/',
|
||||
'HttpOnly',
|
||||
'SameSite=Strict',
|
||||
];
|
||||
|
||||
if (typeof maxAge === 'number') {
|
||||
attributes.push(`Max-Age=${Math.max(0, Math.floor(maxAge))}`);
|
||||
}
|
||||
|
||||
const expires = maxAge === 0
|
||||
? 'Thu, 01 Jan 1970 00:00:00 GMT'
|
||||
: new Date(Date.now() + maxAge * 1000).toUTCString();
|
||||
|
||||
attributes.push(`Expires=${expires}`);
|
||||
|
||||
if (secure) {
|
||||
attributes.push('Secure');
|
||||
}
|
||||
|
||||
return attributes.join('; ');
|
||||
};
|
||||
|
||||
const normalizePassword = (candidate) => {
|
||||
if (typeof candidate !== 'string') {
|
||||
return '';
|
||||
}
|
||||
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 = ({
|
||||
password,
|
||||
cookieName = SESSION_COOKIE_NAME,
|
||||
sessionTtlMs = SESSION_TTL_MS,
|
||||
} = {}) => {
|
||||
const normalizedPassword = normalizePassword(password);
|
||||
|
||||
if (!normalizedPassword) {
|
||||
const setSessionCookie = (req, res, token) => {
|
||||
const secure = isSecureRequest(req);
|
||||
const maxAgeSeconds = Math.floor(sessionTtlMs / 1000);
|
||||
const header = buildCookie({
|
||||
name: cookieName,
|
||||
value: encodeURIComponent(token),
|
||||
maxAge: maxAgeSeconds,
|
||||
secure,
|
||||
});
|
||||
res.setHeader('Set-Cookie', header);
|
||||
};
|
||||
|
||||
const ensureSessionToken = async (req, res) => {
|
||||
const cookies = parseCookies(req.headers.cookie);
|
||||
if (cookies[cookieName]) {
|
||||
return cookies[cookieName];
|
||||
}
|
||||
const token = crypto.randomBytes(32).toString('base64url');
|
||||
setSessionCookie(req, res, token);
|
||||
return token;
|
||||
};
|
||||
|
||||
return {
|
||||
enabled: false,
|
||||
requireAuth: (_req, _res, next) => next(),
|
||||
handleSessionStatus: (_req, res) => {
|
||||
res.json({ authenticated: true, disabled: true });
|
||||
},
|
||||
handleSessionCreate: (_req, res) => {
|
||||
res.status(400).json({ error: 'UI password not configured' });
|
||||
},
|
||||
ensureSessionToken,
|
||||
dispose: () => {
|
||||
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const salt = crypto.randomBytes(16);
|
||||
const expectedHash = crypto.scryptSync(normalizedPassword, salt, 64);
|
||||
const JWT_SECRET = getOrCreateJwtSecret();
|
||||
|
||||
const getTokenFromRequest = (req) => {
|
||||
const cookies = parseCookies(req.headers.cookie);
|
||||
if (cookies[cookieName]) {
|
||||
return cookies[cookieName];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const setSessionCookie = (req, res, token) => {
|
||||
const secure = isSecureRequest(req);
|
||||
const maxAgeSeconds = Math.floor(sessionTtlMs / 1000);
|
||||
const header = buildCookie({
|
||||
name: cookieName,
|
||||
value: encodeURIComponent(token),
|
||||
maxAge: maxAgeSeconds,
|
||||
secure,
|
||||
});
|
||||
res.setHeader('Set-Cookie', header);
|
||||
};
|
||||
|
||||
const clearSessionCookie = (req, res) => {
|
||||
const secure = isSecureRequest(req);
|
||||
const header = buildCookie({
|
||||
name: cookieName,
|
||||
value: '',
|
||||
maxAge: 0,
|
||||
secure,
|
||||
});
|
||||
res.setHeader('Set-Cookie', header);
|
||||
};
|
||||
|
||||
const verifyPassword = (candidate) => {
|
||||
if (!candidate) {
|
||||
return false;
|
||||
}
|
||||
const normalizedCandidate = normalizePassword(candidate);
|
||||
if (!normalizedCandidate) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const candidateHash = crypto.scryptSync(normalizedCandidate, salt, 64);
|
||||
return crypto.timingSafeEqual(candidateHash, expectedHash);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const isSessionValid = async (token) => {
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await jwtVerify(token, JWT_SECRET);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const issueSession = async (req, res) => {
|
||||
const token = await new SignJWT({ type: 'ui-session' })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(sessionTtlMs / 1000 + 's')
|
||||
.sign(JWT_SECRET);
|
||||
setSessionCookie(req, res, token);
|
||||
return token;
|
||||
};
|
||||
|
||||
startRateLimitCleanup();
|
||||
|
||||
const respondUnauthorized = (req, res) => {
|
||||
res.status(401);
|
||||
const acceptsJson = req.headers.accept?.includes('application/json');
|
||||
if (acceptsJson || req.path.startsWith('/api')) {
|
||||
res.json({ error: 'UI authentication required', locked: true });
|
||||
} else {
|
||||
res.type('text/plain').send('Authentication required');
|
||||
}
|
||||
};
|
||||
|
||||
const requireAuth = async (req, res, next) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
return next();
|
||||
}
|
||||
const token = getTokenFromRequest(req);
|
||||
if (await isSessionValid(token)) {
|
||||
return next();
|
||||
}
|
||||
clearSessionCookie(req, res);
|
||||
return respondUnauthorized(req, res);
|
||||
};
|
||||
|
||||
const handleSessionStatus = async (req, res) => {
|
||||
const token = getTokenFromRequest(req);
|
||||
if (await isSessionValid(token)) {
|
||||
res.json({ authenticated: true });
|
||||
return;
|
||||
}
|
||||
clearSessionCookie(req, res);
|
||||
res.status(401).json({ authenticated: false, locked: true });
|
||||
};
|
||||
|
||||
const handleSessionCreate = async (req, res) => {
|
||||
const rateLimitResult = await checkRateLimit(req);
|
||||
|
||||
res.setHeader('X-RateLimit-Limit', rateLimitResult.limit);
|
||||
res.setHeader('X-RateLimit-Remaining', rateLimitResult.remaining);
|
||||
res.setHeader('X-RateLimit-Reset', rateLimitResult.reset);
|
||||
|
||||
if (!rateLimitResult.allowed) {
|
||||
res.setHeader('Retry-After', rateLimitResult.retryAfter);
|
||||
res.status(429).json({
|
||||
error: 'Too many login attempts, please try again later',
|
||||
retryAfter: rateLimitResult.retryAfter
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const candidate = typeof req.body?.password === 'string' ? req.body.password : '';
|
||||
if (!verifyPassword(candidate)) {
|
||||
await recordFailedAttempt(req);
|
||||
clearSessionCookie(req, res);
|
||||
res.status(401).json({ error: 'Invalid credentials' });
|
||||
return;
|
||||
}
|
||||
|
||||
await clearRateLimit(req);
|
||||
|
||||
await issueSession(req, res);
|
||||
res.json({ authenticated: true });
|
||||
};
|
||||
|
||||
const dispose = () => {
|
||||
loginRateLimiter.clear();
|
||||
if (rateLimitCleanupTimer) {
|
||||
clearInterval(rateLimitCleanupTimer);
|
||||
rateLimitCleanupTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
requireAuth,
|
||||
handleSessionStatus,
|
||||
handleSessionCreate,
|
||||
ensureSessionToken: async (req, _res) => {
|
||||
const token = getTokenFromRequest(req);
|
||||
return (await isSessionValid(token)) ? token : null;
|
||||
},
|
||||
dispose,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user