import { Hono } from 'hono' import { getCookie } from 'hono/cookie' import { randomUUID, createHash } from 'node:crypto' import { config } from '../../config' import { getDb } from '../../db/sqlite' import { hashPasswordAsync, verifyPassword, verifyPasswordAsync, } from '../../auth/password' import { issueSessionToken, resolveSessionToken, revokeSessionToken, } from '../../auth/session' import { buildSessionCookies, buildClearCookies } from '../../auth/cookie' import { sessionAuth } from '../../auth/middleware' import { SYSTEM_USER_ID } from '../../auth/constants' const app = new Hono() /** * Detect whether the current request is over HTTPS, taking reverse-proxy * headers (X-Forwarded-Proto) into account. */ function isSecureRequest(c: import('hono').Context): boolean { const forwardedProto = c.req.header('X-Forwarded-Proto') if (forwardedProto === 'https') return true return c.req.url.startsWith('https://') } /** * Dummy argon2id hash used for timing-safe login when the user does not * exist. Prevents timing side-channel attacks that would reveal whether * a username is registered. The value is a valid PHC-format argon2id hash * that will never match any real password. */ const DUMMY_HASH = '$argon2id$v=19$m=65536,t=3,p=1$dGVzdHNhbHQ$dGVzdGhhc2h0ZXN0aGFzaA' // --------------------------------------------------------------------------- // Login rate limiting (Task 2) — in-memory, per IP+username // --------------------------------------------------------------------------- const RATE_LIMIT_MAX_ATTEMPTS = 5 const RATE_LIMIT_LOCKOUT_MS = 5 * 60 * 1000 // 5 minutes const RATE_LIMIT_CLEANUP_INTERVAL_MS = 10 * 60 * 1000 // 10 minutes interface RateLimitEntry { count: number lockedUntil: number } const loginAttempts = new Map() /** Reset the rate limit map (exported for testing). */ export function _resetRateLimits(): void { loginAttempts.clear() } // Periodically clean up stale entries to prevent memory leaks const rateLimitCleanupTimer = setInterval(() => { const now = Date.now() for (const [key, entry] of loginAttempts) { if (entry.lockedUntil < now && entry.count === 0) { loginAttempts.delete(key) } } }, RATE_LIMIT_CLEANUP_INTERVAL_MS) // Allow Node/Bun to exit even if timer is active if (rateLimitCleanupTimer.unref) { rateLimitCleanupTimer.unref() } function getRateLimitKey(ip: string, username: string): string { return `${ip}:${username.toLowerCase()}` } /** * Returns true if the request is allowed, false if rate-limited. */ export function checkRateLimit(ip: string, username: string): boolean { const key = getRateLimitKey(ip, username) const entry = loginAttempts.get(key) if (!entry) return true if (entry.lockedUntil > Date.now()) return false return true } /** * Record a failed login attempt. Returns true if now locked out. */ export function recordFailedAttempt(ip: string, username: string): boolean { const key = getRateLimitKey(ip, username) const now = Date.now() const entry = loginAttempts.get(key) || { count: 0, lockedUntil: 0 } // If a previous lockout has expired, reset counter for a fresh window. // Only reset when lockedUntil was previously set (> 0) and has now expired. if (entry.lockedUntil > 0 && entry.lockedUntil < now) { entry.count = 0 entry.lockedUntil = 0 } entry.count += 1 if (entry.count >= RATE_LIMIT_MAX_ATTEMPTS) { entry.lockedUntil = now + RATE_LIMIT_LOCKOUT_MS } loginAttempts.set(key, entry) return entry.lockedUntil > now } /** * Clear rate limit on successful login. */ function clearRateLimit(ip: string, username: string): void { const key = getRateLimitKey(ip, username) loginAttempts.delete(key) } /** * Extract client IP from request (respects X-Forwarded-For). */ function getClientIp(c: import('hono').Context): string { const forwarded = c.req.header('X-Forwarded-For') if (forwarded) { const first = forwarded.split(',')[0]?.trim() if (first) return first } return c.req.header('X-Real-IP') || 'unknown' } interface UserRow { id: string username: string password_hash: string role: string created_at: string } /** * Helper: issue tokens, set cookies, return JSON response. */ function issueAndRespond( c: import('hono').Context, userId: string, isHttps: boolean, ) { const db = getDb() const { accessToken, refreshToken } = issueSessionToken(userId, db) const cookies = buildSessionCookies(accessToken, refreshToken, { secure: isHttps, }) for (const cookie of cookies) { c.header('Set-Cookie', cookie, { append: true }) } return c.json({ accessToken, refreshToken, }) } // POST /setup — First-deploy admin bootstrap app.post('/setup', async c => { let body: Record try { body = await c.req.json() } catch { return c.json({ error: 'Invalid JSON' }, 400) } const { apiKey, username, password } = body as { apiKey?: string username?: string password?: string } // Validate API key if (!apiKey || !config.apiKeys.includes(apiKey)) { return c.json({ error: 'Invalid API key' }, 401) } // Validate username if (!username || typeof username !== 'string') { return c.json({ error: 'Username is required' }, 400) } // Validate password if (!password || typeof password !== 'string') { return c.json({ error: 'Password is required' }, 400) } if (password.length < 8) { return c.json({ error: 'Password must be at least 8 characters' }, 400) } const db = getDb() // Atomic INSERT with WHERE NOT EXISTS guard — race-free under SQLite's // serial write model. The first request INSERTs (changes=1), the second // finds the admin row already present (changes=0 → 409). const userId = `usr_${randomUUID().replace(/-/g, '')}` const passwordHash = await hashPasswordAsync(password) const now = new Date().toISOString() try { const result = db .query( `INSERT INTO users (id, username, password_hash, role, created_at) SELECT $id, $username, $passwordHash, 'admin', $now WHERE NOT EXISTS (SELECT 1 FROM users WHERE role = 'admin')`, ) .run({ $id: userId, $username: username, $passwordHash: passwordHash, $now: now, }) if (result.changes === 0) { return c.json({ error: 'Setup already completed' }, 409) } } catch (err) { console.warn('[/setup] insert failed:', err) return c.json({ error: 'Setup failed' }, 500) } const isHttps = isSecureRequest(c) return issueAndRespond(c, userId, isHttps) }) // POST /login app.post('/login', async c => { let body: Record try { body = await c.req.json() } catch { return c.json({ error: 'Invalid JSON' }, 400) } const { username, password } = body as { username?: string password?: string } if (!username || !password) { return c.json({ error: 'Username and password are required' }, 400) } // Rate limit by IP + username const clientIp = getClientIp(c) if (!checkRateLimit(clientIp, username)) { return c.json( { error: 'Too many failed login attempts. Try again later.' }, 429, ) } const db = getDb() const user = db .query('SELECT * FROM users WHERE username = $username') .get({ $username: username }) as UserRow | null // Generic error to avoid leaking user existence. // When user doesn't exist, still run verifyPassword against a dummy hash // to prevent timing side-channel attacks. if (!user) { verifyPassword(password, DUMMY_HASH) recordFailedAttempt(clientIp, username) return c.json({ error: 'Invalid credentials' }, 401) } if (!verifyPassword(password, user.password_hash)) { recordFailedAttempt(clientIp, username) return c.json({ error: 'Invalid credentials' }, 401) } // Successful login — clear rate limit clearRateLimit(clientIp, username) // Update last_login_at db.query('UPDATE users SET last_login_at = $now WHERE id = $id').run({ $now: new Date().toISOString(), $id: user.id, }) const isHttps = isSecureRequest(c) return issueAndRespond(c, user.id, isHttps) }) // POST /refresh — rotate both access and refresh tokens (Task 4) app.post('/refresh', async c => { let body: Record = {} try { body = await c.req.json() } catch { // body may be empty } // Get refresh token from body or cookie const refreshToken = (body.refreshToken as string) || (body.refresh_token as string) || getCookie(c, 'rcs_refresh') if (!refreshToken) { return c.json({ error: 'Refresh token required' }, 401) } const db = getDb() const userId = resolveSessionToken(refreshToken, db, 'refresh') if (!userId) { return c.json({ error: 'Invalid or expired refresh token' }, 401) } // Revoke the old refresh token (rotation) revokeSessionToken(refreshToken, db) // Issue new access + refresh token pair const isHttps = isSecureRequest(c) const { accessToken, refreshToken: newRefreshToken } = issueSessionToken( userId, db, ) const cookies = buildSessionCookies(accessToken, newRefreshToken, { secure: isHttps, }) for (const cookie of cookies) { c.header('Set-Cookie', cookie, { append: true }) } return c.json({ accessToken, refreshToken: newRefreshToken, }) }) // POST /logout app.post('/logout', async c => { // Try to get the token for revocation const authHeader = c.req.header('Authorization') const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice('Bearer '.length) : undefined const cookieToken = getCookie(c, 'rcs_access') const token = bearerToken || cookieToken if (token) { try { const db = getDb() revokeSessionToken(token, db) } catch (err) { console.warn('[/logout] failed to revoke access token:', err) } } // Also revoke the refresh token if present const refreshToken = getCookie(c, 'rcs_refresh') if (refreshToken) { try { const db = getDb() revokeSessionToken(refreshToken, db) } catch { // ignore — refresh token may already be invalid } } // Always clear cookies (idempotent) const clearCookies = buildClearCookies() for (const cookie of clearCookies) { c.header('Set-Cookie', cookie, { append: true }) } return c.json({ ok: true }) }) // GET /register-status — Whether self-registration is open app.get('/register-status', c => { return c.json({ allowed: config.allowRegistration }) }) // POST /register — Self-register a new member account (no team association). // Only available when RCS_ALLOW_REGISTRATION !== 'false'. app.post('/register', async c => { if (!config.allowRegistration) { return c.json({ error: 'Registration is disabled' }, 403) } let body: Record try { body = await c.req.json() } catch { return c.json({ error: 'Invalid JSON' }, 400) } const { username, password } = body as { username?: string password?: string } if (!username || typeof username !== 'string') { return c.json({ error: 'Username is required' }, 400) } if (!password || typeof password !== 'string') { return c.json({ error: 'Password is required' }, 400) } if (password.length < 8) { return c.json({ error: 'Password must be at least 8 characters' }, 400) } const db = getDb() const userId = `usr_${randomUUID().replace(/-/g, '')}` const passwordHash = await hashPasswordAsync(password) const now = new Date().toISOString() try { const result = db .query( `INSERT OR IGNORE INTO users (id, username, password_hash, role, created_at) VALUES ($id, $username, $passwordHash, 'member', $now)`, ) .run({ $id: userId, $username: username, $passwordHash: passwordHash, $now: now, }) if (result.changes === 0) { return c.json({ error: 'Username already taken' }, 409) } } catch (err) { console.warn('[/register] insert failed:', err) return c.json({ error: 'Registration failed' }, 500) } const isHttps = isSecureRequest(c) return issueAndRespond(c, userId, isHttps) }) // POST /join — Join via invitation token app.post('/join', async c => { let body: Record try { body = await c.req.json() } catch { return c.json({ error: 'Invalid JSON' }, 400) } const { inviteToken, username, password } = body as { inviteToken?: string username?: string password?: string } if (!inviteToken || !username || !password) { return c.json({ error: 'All fields are required' }, 400) } if (password.length < 8) { return c.json({ error: 'Password must be at least 8 characters' }, 400) } const db = getDb() const tokenHash = createHash('sha256').update(inviteToken).digest('hex') // Look up invitation (read-only, for validation) const invitation = db .query('SELECT * FROM invitations WHERE token_hash = $hash') .get({ $hash: tokenHash }) as { token_hash: string team_id: string | null role: string expires_at: string max_uses: number uses: number consumed_at: string | null } | null if (!invitation) { return c.json({ error: 'Invalid invitation' }, 400) } // Check expiry if (new Date(invitation.expires_at).getTime() < Date.now()) { return c.json({ error: 'Invitation expired' }, 400) } // Check if already fully consumed (pre-check; atomic check below). // Only check uses >= max_uses — consumed_at is set only when the last // slot is used, so checking it here would break max_uses > 1 invitations. if (invitation.uses >= invitation.max_uses) { return c.json({ error: 'Invitation already used' }, 400) } // Check username uniqueness (pre-check; atomic check inside transaction) const existingUser = db .query('SELECT id FROM users WHERE username = $username') .get({ $username: username }) if (existingUser) { return c.json({ error: 'Username already taken' }, 409) } // Create user + atomically consume invitation slot in a transaction. // The UPDATE with `uses < max_uses` clause ensures that concurrent joins // only succeed up to max_uses times (affected rows = 0 for the losers). const userId = `usr_${randomUUID().replace(/-/g, '')}` const now = new Date().toISOString() try { const passwordHash = await hashPasswordAsync(password) db.transaction(() => { // Re-check invitation state inside the transaction (may have changed). // Only check uses >= max_uses — consumed_at is set conditionally below. const currentInv = db .query( 'SELECT uses, max_uses FROM invitations WHERE token_hash = $hash', ) .get({ $hash: tokenHash }) as { uses: number; max_uses: number } | null if (!currentInv || currentInv.uses >= currentInv.max_uses) { throw new Error('invitation_used') } // Insert user — INSERT OR IGNORE handles the UNIQUE username race: // if another concurrent request inserted the same username, changes=0. const result = db .query( `INSERT OR IGNORE INTO users (id, username, password_hash, role, created_at) VALUES ($id, $username, $passwordHash, $role, $now)`, ) .run({ $id: userId, $username: username, $passwordHash: passwordHash, $role: invitation.role, $now: now, }) if (result.changes === 0) { throw new Error('username_taken') } // Atomically increment uses — will fail (changes=0) if race condition. // consumed_at is only set when the last slot is used (uses + 1 >= max_uses). const updateResult = db .query( `UPDATE invitations SET uses = uses + 1, consumed_at = CASE WHEN uses + 1 >= max_uses THEN $now ELSE consumed_at END, consumed_by = CASE WHEN uses + 1 >= max_uses THEN $userId ELSE consumed_by END WHERE token_hash = $hash AND uses < max_uses`, ) .run({ $now: now, $userId: userId, $hash: tokenHash }) if (updateResult.changes === 0) { throw new Error('invitation_used') } // Phase 2: If invitation is linked to a team, auto-add user to team. // Inside the transaction — if this fails, the whole transaction rolls // back (user creation + invitation consumption included), preserving // atomicity. team_id and role come from a validated invitation row, so // FK/CHECK failures should not occur in normal operation. if (invitation.team_id) { db.query( `INSERT OR IGNORE INTO team_members (team_id, user_id, role, added_at, added_by) VALUES ($teamId, $userId, $role, $now, $addedBy)`, ).run({ $teamId: invitation.team_id, $userId: userId, $role: invitation.role, $now: now, $addedBy: userId, }) } })() } catch (err) { if (err instanceof Error && err.message === 'invitation_used') { return c.json({ error: 'Invitation already used or invalid' }, 400) } if (err instanceof Error && err.message === 'username_taken') { return c.json({ error: 'Username already taken' }, 409) } console.warn( '[/join] transaction failed:', err instanceof Error ? `${err.name}: ${err.message}` : err, ) return c.json({ error: 'Failed to process invitation' }, 500) } const isHttps = isSecureRequest(c) return issueAndRespond(c, userId, isHttps) }) // GET /setup-status — Check if initial setup is needed app.get('/setup-status', c => { try { const db = getDb() const count = db.query('SELECT COUNT(*) as count FROM users').get() as { count: number } return c.json({ needsSetup: count.count === 0 }) } catch (err) { console.warn('[/setup-status] DB check failed:', err) return c.json({ needsSetup: true }) } }) // POST /change-password — Change current user's password app.post( '/change-password', async (c, next) => { const result = await sessionAuth(c, next) if (result) return result }, async c => { const userId = c.get('userId') as string if (userId === SYSTEM_USER_ID) { return c.json({ error: 'Cannot change system user password' }, 403) } let body: Record try { body = await c.req.json() } catch { return c.json({ error: 'Invalid JSON' }, 400) } const { currentPassword, newPassword } = body as { currentPassword?: string newPassword?: string } if (!currentPassword || !newPassword) { return c.json( { error: 'Current password and new password are required' }, 400, ) } if (newPassword.length < 8) { return c.json({ error: 'Password must be at least 8 characters' }, 400) } const db = getDb() const user = db .query('SELECT password_hash FROM users WHERE id = $id') .get({ $id: userId }) as { password_hash: string } | null if (!user) { return c.json({ error: 'User not found' }, 404) } const isValid = await verifyPasswordAsync( currentPassword, user.password_hash, ) if (!isValid) { return c.json({ error: 'Current password is incorrect' }, 401) } const newHash = await hashPasswordAsync(newPassword) db.query('UPDATE users SET password_hash = $hash WHERE id = $id').run({ $hash: newHash, $id: userId, }) return c.json({ ok: true }) }, ) // POST /logout-all — Revoke all refresh tokens for the current user app.post( '/logout-all', async (c, next) => { const result = await sessionAuth(c, next) if (result) return result }, c => { const userId = c.get('userId') as string const db = getDb() const now = new Date().toISOString() db.query( `UPDATE session_tokens SET revoked_at = $now WHERE user_id = $userId AND kind = 'refresh'`, ).run({ $now: now, $userId: userId }) // Also clear cookies const clearCookies = buildClearCookies() for (const cookie of clearCookies) { c.header('Set-Cookie', cookie, { append: true }) } return c.json({ ok: true }) }, ) // GET /me — Current user info (requires sessionAuth) app.get( '/me', async (c, next) => { const result = await sessionAuth(c, next) if (result) return result }, c => { const userId = c.get('userId') as string if (userId === SYSTEM_USER_ID) { return c.json({ userId: SYSTEM_USER_ID, username: 'system', role: 'admin', isAdmin: true, }) } const db = getDb() const user = db .query('SELECT id, username, role FROM users WHERE id = $id') .get({ $id: userId }) as { id: string username: string role: string } | null if (!user) { return c.json({ error: 'User not found' }, 404) } return c.json({ userId: user.id, username: user.username, role: user.role, isAdmin: user.role === 'admin', }) }, ) export default app