BillTracker/routes/auth.cts

656 lines
22 KiB
TypeScript

// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
import type { Req, Res, Next } from '../types/http';
const express = require('express');
const router = express.Router();
let _appVersion;
function getAppVersion() {
if (!_appVersion) {
try {
_appVersion = require('../package.json').version;
} catch {
_appVersion = '0.0.0';
}
}
return _appVersion;
}
const { getDb, getSetting, setSetting } = require('../db/database.cts');
const {
login,
logout,
hashPassword,
cookieOpts,
COOKIE_NAME,
SINGLE_COOKIE_NAME,
rotateSessionId,
invalidateOtherSessions,
recordLogin,
recordFailedLogin,
createSession,
} = require('../services/authService.cts');
const { decryptSecret } = require('../services/encryptionService.cts');
const { getCsrfToken } = require('../middleware/csrf.cts');
const { requireAuth } = require('../middleware/requireAuth.cts');
const { getPublicOidcInfo } = require('../services/oidcService.cts');
const {
ApiError,
ValidationError,
AuthError,
ForbiddenError,
NotFoundError,
} = require('../utils/apiError.cts');
const { passwordLimiter } = require('../middleware/rateLimiter.cts');
const { logAudit } = require('../services/auditService.cts');
// ─────────────────────────────────────────
// PUBLIC AUTH ROUTES
// ─────────────────────────────────────────
// POST /api/auth/login
router.post(
'/login',
(req: Req, res: Res, next: Next) => {
// Exempt login from CSRF - no session exists yet to hijack
// CSRF validation happens on all other authenticated routes
req.csrfSkip = true;
next();
},
async (req: Req, res: Res) => {
// Respect admin-configured login method toggle
if (getSetting('local_login_enabled') === 'false') {
throw ForbiddenError('Local username/password login is not enabled on this server.');
}
const { username, password } = req.body;
if (!username || !password) {
throw ValidationError(
'Username and password are required',
!username ? 'username' : 'password',
);
}
const result = await login(username, password);
if (!result || result.error) {
logAudit({
user_id: null,
action: 'login.failure',
details: { username },
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
// Track failed attempt against known accounts (wrong password only — not unknown usernames)
if (result?.error === 'bad_password') {
recordFailedLogin(result.userId, req.ip, req.get('user-agent'));
}
throw AuthError('Invalid username or password');
}
// TOTP required — don't create a session yet
if (result.requires_totp) {
return res.json({ requires_totp: true, challenge_token: result.challenge_token });
}
// WebAuthn required — same two-step shape as TOTP; the session is only
// created after POST /webauthn/challenge verifies the key.
if (result.requires_webauthn) {
return res.json({
requires_webauthn: true,
challenge_token: result.challenge_token,
webauthn_options: result.webauthn_options,
});
}
logAudit({
user_id: result.user.id,
action: 'login.success',
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
recordLogin(result.user.id, req.ip, req.get('user-agent'), result.sessionId);
res.cookie(COOKIE_NAME, result.sessionId, cookieOpts(req));
res.json({ user: result.user });
},
);
// GET /api/auth/csrf-token
// Public — returns the CSRF token from the httpOnly cookie so the SPA can
// store it in memory and send it in the x-csrf-token header for mutations.
// Cross-origin access is prevented by the same-origin fetch policy (no CORS).
router.get('/csrf-token', (req: Req, res: Res) => {
const token = getCsrfToken(req, res);
res.json({ token });
});
// POST /api/auth/logout
router.post('/logout', requireAuth, (req: Req, res: Res) => {
logout(req.cookies?.[COOKIE_NAME]);
logAudit({
user_id: req.user.id,
action: 'logout',
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
res.clearCookie(COOKIE_NAME, { path: '/', ...cookieOpts(req), maxAge: undefined });
res.json({ success: true });
});
// POST /api/auth/logout-all
router.post('/logout-all', requireAuth, (req: Req, res: Res) => {
// Delete ALL sessions for this user
invalidateOtherSessions(req.user.id, null); // null means delete all sessions
// Also clear the current session
logout(req.cookies?.[COOKIE_NAME]);
logAudit({
user_id: req.user.id,
action: 'logout.all',
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
res.clearCookie(COOKIE_NAME, { path: '/', ...cookieOpts(req), maxAge: undefined });
res.json({ success: true });
});
// POST /api/auth/logout-others — sign out every OTHER device, keep this one signed in.
router.post('/logout-others', requireAuth, (req: Req, res: Res) => {
const result = invalidateOtherSessions(req.user.id, req.cookies?.[COOKIE_NAME]);
logAudit({
user_id: req.user.id,
action: 'logout.others',
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
res.json({ success: true, count: result?.changes || 0 });
});
// GET /api/auth/me
router.get('/me', requireAuth, (req: Req, res: Res) => {
const currentVersion = getAppVersion();
res.json({
user: req.user,
single_user_mode: !!req.singleUserMode,
current_version: currentVersion,
release_notes_version: currentVersion,
has_new_version: req.user.last_seen_version !== currentVersion,
});
});
// GET /api/auth/login-history — last 10 logins for the authenticated user (encrypted at rest, decrypted here)
router.get('/login-history', requireAuth, (req: Req, res: Res) => {
const db = getDb();
const rows = db
.prepare(
`
SELECT id, logged_in_at, ip_address, user_agent,
browser, os, device_type, device_fingerprint, session_fingerprint,
success, location_city, location_country, location_region, location_isp
FROM user_login_history
WHERE user_id = ?
ORDER BY logged_in_at DESC
LIMIT 10
`,
)
.all(req.user.id);
const safeDecrypt = (v) => {
if (!v) return null;
try {
return decryptSecret(v);
} catch {
return null;
}
};
// Compute fingerprint of the current session cookie to mark "this session".
// Single-user mode has no COOKIE_NAME — use the presence cookie instead.
const currentCookie = req.singleUserMode
? req.cookies?.[SINGLE_COOKIE_NAME]
: req.cookies?.[COOKIE_NAME];
const currentFingerprint = currentCookie
? require('crypto').createHash('sha256').update(currentCookie).digest('hex').slice(0, 32)
: null;
const history = rows.map((r) => ({
id: r.id,
logged_in_at: r.logged_in_at,
ip_address: safeDecrypt(r.ip_address),
user_agent: safeDecrypt(r.user_agent),
browser: r.browser,
os: r.os,
device_type: r.device_type,
device_fingerprint: r.device_fingerprint,
success: r.success !== 0,
is_current_session: !!(currentFingerprint && r.session_fingerprint === currentFingerprint),
location_city: safeDecrypt(r.location_city),
location_country: safeDecrypt(r.location_country),
location_region: safeDecrypt(r.location_region),
location_isp: safeDecrypt(r.location_isp),
}));
res.json({ history });
});
// ── TOTP / Authenticator App ─────────────────────────────────────────────────
const {
generateSecret,
generateQrCode,
verifyToken,
verifyTokenRaw,
generateRecoveryCodes,
hashRecoveryCode,
consumeRecoveryCode,
createChallenge,
consumeChallenge,
} = require('../services/totpService.cts');
const { encryptSecret: encTotpSecret } = require('../services/encryptionService.cts');
// POST /api/auth/totp/challenge — second step of login when TOTP is enabled.
// Takes challenge_token (from first login step) + totp_code, creates a session.
router.post('/totp/challenge', async (req: Req, res: Res) => {
req.csrfSkip = true;
const { challenge_token, code, recovery_code } = req.body || {};
if (!challenge_token) throw ValidationError('challenge_token is required');
const db = getDb();
const userId = consumeChallenge(db, challenge_token);
if (!userId) throw AuthError('Challenge expired or invalid. Please sign in again.');
const user = db.prepare('SELECT * FROM users WHERE id = ? AND active = 1').get(userId);
if (!user) throw AuthError('User not found.');
let verified = false;
if (recovery_code) {
const result = consumeRecoveryCode(db, userId, recovery_code);
verified = result.used;
if (verified && result.remaining === 0) {
// Warn but don't block — they're in, but should regenerate codes
}
} else if (code) {
verified = verifyToken(user.totp_secret, code);
}
if (!verified) {
logAudit({
user_id: userId,
action: 'totp.failure',
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
throw AuthError('Invalid authenticator code.');
}
const session = await createSession(userId);
if (!session) throw new ApiError('SERVER_ERROR', 'Failed to create session', 500);
logAudit({
user_id: userId,
action: 'login.success',
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
recordLogin(userId, req.ip, req.get('user-agent'), session.sessionId);
res.cookie(COOKIE_NAME, session.sessionId, cookieOpts(req));
res.json({ user: session.user });
});
// GET /api/auth/totp/setup — generate a new pending secret + QR code for the authenticated user.
// The secret is NOT saved yet; the user must confirm a valid code via /totp/enable.
router.get('/totp/setup', requireAuth, async (req: Req, res: Res) => {
if (req.singleUserMode) throw ValidationError('TOTP is not available in single-user mode.');
const secret = generateSecret();
const user = getDb().prepare('SELECT username FROM users WHERE id = ?').get(req.user.id);
const { uri, qr_data_url } = await generateQrCode(secret, user.username);
res.json({ secret, uri, qr_data_url });
});
// POST /api/auth/totp/enable — verify a code against the submitted secret, then enable TOTP.
router.post('/totp/enable', requireAuth, (req: Req, res: Res) => {
if (req.singleUserMode) throw ValidationError('TOTP is not available in single-user mode.');
const { secret, code } = req.body || {};
if (!secret || !code) throw ValidationError('secret and code are required');
if (!verifyTokenRaw(secret, code))
throw ValidationError('Invalid authenticator code. Check your app and try again.', 'code');
const plainCodes = generateRecoveryCodes();
const hashedCodes = plainCodes.map(hashRecoveryCode);
const db = getDb();
db.prepare(
`
UPDATE users SET totp_enabled=1, totp_secret=?, totp_recovery_codes=?, updated_at=datetime('now')
WHERE id=?
`,
).run(encTotpSecret(secret), encTotpSecret(JSON.stringify(hashedCodes)), req.user.id);
logAudit({
user_id: req.user.id,
action: 'totp.enabled',
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
res.json({ enabled: true, recovery_codes: plainCodes });
});
// POST /api/auth/totp/disable — disable TOTP. Requires a valid TOTP code or recovery code.
router.post('/totp/disable', requireAuth, (req: Req, res: Res) => {
const { code, recovery_code } = req.body || {};
const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
if (!user?.totp_enabled) throw ValidationError('TOTP is not enabled.');
let verified = false;
if (recovery_code) {
verified = consumeRecoveryCode(db, req.user.id, recovery_code).used;
} else if (code) {
verified = verifyToken(user.totp_secret, code);
}
if (!verified)
throw new ApiError('AUTH_ERROR', 'Invalid authenticator code.', 401, { field: 'code' });
db.prepare(
`UPDATE users SET totp_enabled=0, totp_secret=NULL, totp_recovery_codes=NULL, updated_at=datetime('now') WHERE id=?`,
).run(req.user.id);
logAudit({
user_id: req.user.id,
action: 'totp.disabled',
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
res.json({ enabled: false });
});
// GET /api/auth/totp/status — is TOTP enabled for the current user?
router.get('/totp/status', requireAuth, (req: Req, res: Res) => {
const user = getDb().prepare('SELECT totp_enabled FROM users WHERE id = ?').get(req.user.id);
res.json({ enabled: !!user?.totp_enabled });
});
// POST /api/auth/totp/acknowledge-version — user has seen the release notes
router.post('/acknowledge-version', requireAuth, (req: Req, res: Res) => {
const currentVersion = getAppVersion();
getDb()
.prepare("UPDATE users SET last_seen_version = ?, updated_at = datetime('now') WHERE id = ?")
.run(currentVersion, req.user.id);
res.json({
success: true,
last_seen_version: currentVersion,
release_notes_version: currentVersion,
});
});
// GET /api/auth/mode
// Public — tells the login page which options are available.
// Never returns secrets. local_enabled/oidc_enabled reflect admin settings.
router.get('/mode', (req: Req, res: Res) => {
const oidcInfo = getPublicOidcInfo();
const localEnabled = getSetting('local_login_enabled') !== 'false';
res.json({
auth_mode: getSetting('auth_mode') || 'multi',
local_enabled: localEnabled,
...oidcInfo,
});
});
// POST /api/auth/restore-multi-user-mode
// Recovery path for single-user mode. In single-user mode requireAuth attaches
// the configured default user, so this lets that Settings page restore normal
// login without needing access to Admin routes.
router.post('/restore-multi-user-mode', requireAuth, (req: Req, res: Res) => {
if (!req.singleUserMode && getSetting('auth_mode') !== 'single') {
throw ValidationError('Single-user mode is not enabled.', 'auth_mode');
}
setSetting('auth_mode', 'multi');
setSetting('default_user_id', '');
res.json({ success: true, auth_mode: 'multi' });
});
// POST /api/auth/acknowledge-privacy
router.post('/acknowledge-privacy', requireAuth, (req: Req, res: Res) => {
getDb()
.prepare("UPDATE users SET first_login = 0, updated_at = datetime('now') WHERE id = ?")
.run(req.user.id);
res.json({ success: true });
});
// POST /api/auth/change-password
// Password change endpoint with dedicated rate limiter
// CSRF protected via csrfMiddleware on /api/auth mount
router.post('/change-password', passwordLimiter, requireAuth, async (req: Req, res: Res) => {
const { current_password, new_password } = req.body;
if (!new_password || new_password.length < 8) {
throw ValidationError('New password must be at least 8 characters', 'new_password');
}
const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
if (!user.must_change_password) {
const bcrypt = require('bcryptjs');
const valid = await bcrypt.compare(current_password || '', user.password_hash);
if (!valid)
throw new ApiError('AUTH_ERROR', 'Current password is incorrect', 401, {
field: 'current_password',
});
}
const hash = await hashPassword(new_password);
db.prepare(
"UPDATE users SET password_hash = ?, must_change_password = 0, last_password_change_at = datetime('now'), updated_at = datetime('now') WHERE id = ?",
).run(hash, req.user.id);
// Invalidate all other sessions for this user
const currentSessionId = req.cookies?.[COOKIE_NAME];
if (currentSessionId) {
invalidateOtherSessions(req.user.id, currentSessionId);
// Rotate the current session ID for security
const newSessionId = rotateSessionId(currentSessionId, req.user.id);
if (newSessionId) {
res.cookie(COOKIE_NAME, newSessionId, cookieOpts(req));
}
}
logAudit({
user_id: req.user.id,
action: 'password.change',
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
res.json({ success: true });
});
// ── WebAuthn / FIDO2 security key ─────────────────────────────────────────────
const {
createRegistrationChallenge,
verifyRegistration,
createAuthenticationChallenge,
verifyAuthentication,
consumeLoginChallenge,
getCredentials,
deleteCredential,
} = require('../services/webauthnService.cts');
// GET /api/auth/webauthn/status
router.get('/webauthn/status', requireAuth, (req: Req, res: Res) => {
if (req.singleUserMode) return res.json({ enabled: false, credential_count: 0 });
const db = getDb();
const user = db.prepare('SELECT webauthn_enabled FROM users WHERE id = ?').get(req.user.id);
const { n } = db
.prepare('SELECT COUNT(*) AS n FROM webauthn_credentials WHERE user_id = ?')
.get(req.user.id);
res.json({ enabled: !!user?.webauthn_enabled, credential_count: n });
});
// GET /api/auth/webauthn/credentials
router.get('/webauthn/credentials', requireAuth, (req: Req, res: Res) => {
if (req.singleUserMode) return res.json({ credentials: [] });
res.json({ credentials: getCredentials(getDb(), req.user.id) });
});
// GET /api/auth/webauthn/setup — begin registration
router.get('/webauthn/setup', requireAuth, async (req: Req, res: Res) => {
if (req.singleUserMode) throw ValidationError('WebAuthn is not available in single-user mode.');
const { options, challengeId } = await createRegistrationChallenge(
getDb(),
req.user.id,
req.user.username,
);
res.json({ options, challengeId });
});
// POST /api/auth/webauthn/enable — complete registration
router.post('/webauthn/enable', requireAuth, async (req: Req, res: Res) => {
if (req.singleUserMode) throw ValidationError('WebAuthn is not available in single-user mode.');
const { challengeId, response, credential_name } = req.body || {};
if (!challengeId || !response) throw ValidationError('challengeId and response are required');
const db = getDb();
const result = await verifyRegistration(
db,
req.user.id,
challengeId,
response,
credential_name,
req.get('origin'),
);
if (!result.verified) throw ValidationError(result.error || 'Registration failed');
db.prepare(
"UPDATE users SET webauthn_enabled = 1, updated_at = datetime('now') WHERE id = ?",
).run(req.user.id);
logAudit({
user_id: req.user.id,
action: 'webauthn.credential_added',
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
res.json({ enabled: true, credential_id: result.credentialId });
});
// DELETE /api/auth/webauthn/credentials/:credentialId — remove one key
router.delete('/webauthn/credentials/:credentialId', requireAuth, async (req: Req, res: Res) => {
const { password } = req.body || {};
if (!password) throw ValidationError('password is required');
const db = getDb();
const user = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.user.id);
const bcrypt = require('bcryptjs');
if (!(await bcrypt.compare(password, user.password_hash)))
throw AuthError('Password is incorrect');
const result = deleteCredential(db, req.params.credentialId, req.user.id);
if (result.changes === 0) throw NotFoundError('Credential not found');
const { n } = db
.prepare('SELECT COUNT(*) AS n FROM webauthn_credentials WHERE user_id = ?')
.get(req.user.id);
if (n === 0)
db.prepare(
"UPDATE users SET webauthn_enabled = 0, updated_at = datetime('now') WHERE id = ?",
).run(req.user.id);
logAudit({
user_id: req.user.id,
action: 'webauthn.credential_removed',
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
res.json({ success: true, webauthn_enabled: n > 0 });
});
// POST /api/auth/webauthn/disable — remove all keys, disable WebAuthn
router.post('/webauthn/disable', requireAuth, async (req: Req, res: Res) => {
const { password } = req.body || {};
if (!password) throw ValidationError('password is required');
const db = getDb();
const user = db
.prepare('SELECT password_hash, webauthn_enabled FROM users WHERE id = ?')
.get(req.user.id);
if (!user?.webauthn_enabled) throw ValidationError('WebAuthn is not enabled.');
const bcrypt = require('bcryptjs');
if (!(await bcrypt.compare(password, user.password_hash)))
throw AuthError('Password is incorrect');
// Credential delete + flag clear land together: a failure between them would
// leave webauthn_enabled=1 with zero credentials (the stale-flag state).
db.transaction(() => {
db.prepare('DELETE FROM webauthn_credentials WHERE user_id = ?').run(req.user.id);
db.prepare(
"UPDATE users SET webauthn_enabled = 0, updated_at = datetime('now') WHERE id = ?",
).run(req.user.id);
})();
logAudit({
user_id: req.user.id,
action: 'webauthn.disabled',
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
res.json({ enabled: false });
});
// POST /api/auth/webauthn/challenge — second step of login when WebAuthn is enabled.
// Mirrors POST /totp/challenge exactly.
router.post('/webauthn/challenge', async (req: Req, res: Res) => {
req.csrfSkip = true;
const { challenge_token, response } = req.body || {};
if (!challenge_token || !response)
throw ValidationError('challenge_token and response are required');
const db = getDb();
const session = consumeLoginChallenge(db, challenge_token);
if (!session) throw AuthError('Challenge expired or invalid. Please sign in again.');
const user = db.prepare('SELECT * FROM users WHERE id = ? AND active = 1').get(session.userId);
if (!user) throw AuthError('User not found.');
const result = await verifyAuthentication(
db,
session.userId,
session.authChallengeId,
response,
req.get('origin'),
);
if (!result.verified) {
logAudit({
user_id: session.userId,
action: 'webauthn.failure',
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
throw AuthError('Security key verification failed.');
}
const s = await createSession(session.userId);
if (!s) throw new ApiError('SERVER_ERROR', 'Failed to create session', 500);
logAudit({
user_id: session.userId,
action: 'login.success',
details: { method: 'webauthn' },
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
recordLogin(session.userId, req.ip, req.get('user-agent'), s.sessionId);
res.cookie(COOKIE_NAME, s.sessionId, cookieOpts(req));
res.json({ user: s.user });
});
module.exports = router;