2026-07-06 11:26:50 -05:00
|
|
|
// @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';
|
2026-05-03 19:51:57 -05:00
|
|
|
const express = require('express');
|
2026-07-06 16:05:18 -05:00
|
|
|
const { log } = require('../utils/logger.cts');
|
2026-05-03 19:51:57 -05:00
|
|
|
const router = express.Router();
|
|
|
|
|
|
2026-05-14 21:00:07 -05:00
|
|
|
let _appVersion;
|
|
|
|
|
function getAppVersion() {
|
|
|
|
|
if (!_appVersion) {
|
2026-07-06 14:23:53 -05:00
|
|
|
try {
|
|
|
|
|
_appVersion = require('../package.json').version;
|
|
|
|
|
} catch {
|
|
|
|
|
_appVersion = '0.0.0';
|
|
|
|
|
}
|
2026-05-14 21:00:07 -05:00
|
|
|
}
|
|
|
|
|
return _appVersion;
|
|
|
|
|
}
|
|
|
|
|
|
refactor(server): migrate middleware, workers, and db layer to TypeScript (.cts)
- middleware/ (5): requireAuth, securityHeaders, errorFormatter, csrf,
rateLimiter — fully typed against the shared http Req/Res/Next types.
- workers/dailyWorker — fully typed.
- db/ (4): database, subscriptionCatalogSeed, migrations/versionedMigrations,
migrations/legacyReconcileMigrations — large dynamic schema/migration infra,
converted with @ts-nocheck (typing deferred). All requires updated to .cts.
Behavior-preserving. Verified: typecheck:server 0, check:server 0, suite 226/226.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 11:34:08 -05:00
|
|
|
const { getDb, getSetting, setSetting } = require('../db/database.cts');
|
2026-07-06 14:23:53 -05:00
|
|
|
const {
|
|
|
|
|
login,
|
|
|
|
|
logout,
|
|
|
|
|
hashPassword,
|
|
|
|
|
cookieOpts,
|
|
|
|
|
COOKIE_NAME,
|
|
|
|
|
SINGLE_COOKIE_NAME,
|
|
|
|
|
rotateSessionId,
|
|
|
|
|
invalidateOtherSessions,
|
|
|
|
|
recordLogin,
|
|
|
|
|
recordFailedLogin,
|
|
|
|
|
} = require('../services/authService.cts');
|
refactor(server): migrate 10 services to TypeScript (.cts)
Continues the incremental server TS migration (after utils/*.mts and
paymentValidation.cts): converts 10 services to .cts (CommonJS TypeScript,
run natively via Node type-stripping — no build step), type-checked by
tsconfig.server.json.
Migrated: totpService, amountSuggestionService, encryptionService,
paymentAccountingService, statusService, aprService, driftService,
analyticsService, spendingService, billMerchantRuleService.
- types/db.d.ts: shared minimal better-sqlite3 Db/Statement types (the lib
ships none), reused across the migrated modules.
- Every require of a migrated service updated to the explicit .cts extension
(extensionless require does not resolve .cts) across routes / services /
db migrations / server.js / workers / tests — require-only changes.
- package.json: check:server find pattern now includes *.cts (it silently
skipped .cts before, so paymentValidation.cts had no node --check gate).
Behavior-preserving (types only). Verified: typecheck:server 0,
check:server 0, full server suite 226/226, real boot → /api/health 200.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 10:35:29 -05:00
|
|
|
const { decryptSecret } = require('../services/encryptionService.cts');
|
refactor(server): migrate middleware, workers, and db layer to TypeScript (.cts)
- middleware/ (5): requireAuth, securityHeaders, errorFormatter, csrf,
rateLimiter — fully typed against the shared http Req/Res/Next types.
- workers/dailyWorker — fully typed.
- db/ (4): database, subscriptionCatalogSeed, migrations/versionedMigrations,
migrations/legacyReconcileMigrations — large dynamic schema/migration infra,
converted with @ts-nocheck (typing deferred). All requires updated to .cts.
Behavior-preserving. Verified: typecheck:server 0, check:server 0, suite 226/226.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 11:34:08 -05:00
|
|
|
const { getCsrfToken } = require('../middleware/csrf.cts');
|
|
|
|
|
const { requireAuth, requireAdmin } = require('../middleware/requireAuth.cts');
|
2026-07-06 11:22:44 -05:00
|
|
|
const { getPublicOidcInfo } = require('../services/oidcService.cts');
|
2026-07-06 10:47:16 -05:00
|
|
|
const { ValidationError, formatError } = require('../utils/apiError.cts');
|
refactor(server): migrate middleware, workers, and db layer to TypeScript (.cts)
- middleware/ (5): requireAuth, securityHeaders, errorFormatter, csrf,
rateLimiter — fully typed against the shared http Req/Res/Next types.
- workers/dailyWorker — fully typed.
- db/ (4): database, subscriptionCatalogSeed, migrations/versionedMigrations,
migrations/legacyReconcileMigrations — large dynamic schema/migration infra,
converted with @ts-nocheck (typing deferred). All requires updated to .cts.
Behavior-preserving. Verified: typecheck:server 0, check:server 0, suite 226/226.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 11:34:08 -05:00
|
|
|
const { standardizeError } = require('../middleware/errorFormatter.cts');
|
|
|
|
|
const { passwordLimiter } = require('../middleware/rateLimiter.cts');
|
2026-07-06 10:47:16 -05:00
|
|
|
const { logAudit } = require('../services/auditService.cts');
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
|
|
|
// ─────────────────────────────────────────
|
|
|
|
|
// PUBLIC AUTH ROUTES
|
|
|
|
|
// ─────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
// POST /api/auth/login
|
2026-07-06 14:23:53 -05:00
|
|
|
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') {
|
|
|
|
|
return res
|
|
|
|
|
.status(403)
|
|
|
|
|
.json(
|
|
|
|
|
standardizeError(
|
|
|
|
|
'Local username/password login is not enabled on this server.',
|
|
|
|
|
'FORBIDDEN',
|
|
|
|
|
),
|
|
|
|
|
);
|
2026-05-10 12:20:50 -05:00
|
|
|
}
|
2026-05-03 19:51:57 -05:00
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
const { username, password } = req.body;
|
|
|
|
|
if (!username || !password) {
|
|
|
|
|
return res
|
|
|
|
|
.status(400)
|
|
|
|
|
.json(
|
|
|
|
|
standardizeError(
|
|
|
|
|
'Username and password are required',
|
|
|
|
|
'VALIDATION_ERROR',
|
|
|
|
|
!username ? 'username' : 'password',
|
|
|
|
|
),
|
|
|
|
|
);
|
2026-06-04 04:10:14 -05:00
|
|
|
}
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
try {
|
|
|
|
|
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'));
|
|
|
|
|
}
|
|
|
|
|
return res.status(401).json(standardizeError('Invalid username or password', 'AUTH_ERROR'));
|
|
|
|
|
}
|
2026-05-10 00:03:12 -05:00
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
// TOTP required — don't create a session yet
|
|
|
|
|
if (result.requires_totp) {
|
|
|
|
|
return res.json({ requires_totp: true, challenge_token: result.challenge_token });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 });
|
|
|
|
|
} catch (err) {
|
2026-07-06 16:05:18 -05:00
|
|
|
log.error('Login error:', err);
|
2026-07-06 14:23:53 -05:00
|
|
|
res.status(500).json(standardizeError('Login failed', 'SERVER_ERROR'));
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-05-03 19:51:57 -05:00
|
|
|
|
2026-05-31 15:52:50 -05:00
|
|
|
// 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).
|
2026-07-06 11:26:50 -05:00
|
|
|
router.get('/csrf-token', (req: Req, res: Res) => {
|
2026-05-31 15:52:50 -05:00
|
|
|
const token = getCsrfToken(req, res);
|
|
|
|
|
res.json({ token });
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
// POST /api/auth/logout
|
2026-07-06 11:26:50 -05:00
|
|
|
router.post('/logout', requireAuth, (req: Req, res: Res) => {
|
2026-05-03 19:51:57 -05:00
|
|
|
logout(req.cookies?.[COOKIE_NAME]);
|
2026-07-06 14:23:53 -05:00
|
|
|
logAudit({
|
|
|
|
|
user_id: req.user.id,
|
|
|
|
|
action: 'logout',
|
|
|
|
|
ip_address: req.ip,
|
|
|
|
|
user_agent: req.get('user-agent'),
|
|
|
|
|
});
|
2026-05-03 19:51:57 -05:00
|
|
|
res.clearCookie(COOKIE_NAME, { path: '/', ...cookieOpts(req), maxAge: undefined });
|
|
|
|
|
res.json({ success: true });
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-10 03:55:14 -05:00
|
|
|
// POST /api/auth/logout-all
|
2026-07-06 11:26:50 -05:00
|
|
|
router.post('/logout-all', requireAuth, (req: Req, res: Res) => {
|
2026-05-10 03:55:14 -05:00
|
|
|
// Delete ALL sessions for this user
|
|
|
|
|
invalidateOtherSessions(req.user.id, null); // null means delete all sessions
|
2026-07-05 16:05:59 -05:00
|
|
|
|
2026-05-10 03:55:14 -05:00
|
|
|
// Also clear the current session
|
|
|
|
|
logout(req.cookies?.[COOKIE_NAME]);
|
2026-07-05 16:05:59 -05:00
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
logAudit({
|
|
|
|
|
user_id: req.user.id,
|
|
|
|
|
action: 'logout.all',
|
|
|
|
|
ip_address: req.ip,
|
|
|
|
|
user_agent: req.get('user-agent'),
|
|
|
|
|
});
|
2026-07-05 16:05:59 -05:00
|
|
|
|
2026-05-10 03:55:14 -05:00
|
|
|
res.clearCookie(COOKIE_NAME, { path: '/', ...cookieOpts(req), maxAge: undefined });
|
|
|
|
|
res.json({ success: true });
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-05 16:05:59 -05:00
|
|
|
// POST /api/auth/logout-others — sign out every OTHER device, keep this one signed in.
|
2026-07-06 11:26:50 -05:00
|
|
|
router.post('/logout-others', requireAuth, (req: Req, res: Res) => {
|
2026-07-05 16:05:59 -05:00
|
|
|
const result = invalidateOtherSessions(req.user.id, req.cookies?.[COOKIE_NAME]);
|
2026-07-06 14:23:53 -05:00
|
|
|
logAudit({
|
|
|
|
|
user_id: req.user.id,
|
|
|
|
|
action: 'logout.others',
|
|
|
|
|
ip_address: req.ip,
|
|
|
|
|
user_agent: req.get('user-agent'),
|
|
|
|
|
});
|
2026-07-05 16:05:59 -05:00
|
|
|
res.json({ success: true, count: result?.changes || 0 });
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
// GET /api/auth/me
|
2026-07-06 11:26:50 -05:00
|
|
|
router.get('/me', requireAuth, (req: Req, res: Res) => {
|
2026-05-14 21:00:07 -05:00
|
|
|
const currentVersion = getAppVersion();
|
2026-05-03 19:51:57 -05:00
|
|
|
res.json({
|
|
|
|
|
user: req.user,
|
|
|
|
|
single_user_mode: !!req.singleUserMode,
|
2026-07-06 14:23:53 -05:00
|
|
|
current_version: currentVersion,
|
2026-05-15 22:45:38 -05:00
|
|
|
release_notes_version: currentVersion,
|
2026-07-06 14:23:53 -05:00
|
|
|
has_new_version: req.user.last_seen_version !== currentVersion,
|
2026-05-03 19:51:57 -05:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-04 03:38:32 -05:00
|
|
|
// GET /api/auth/login-history — last 10 logins for the authenticated user (encrypted at rest, decrypted here)
|
2026-07-06 11:26:50 -05:00
|
|
|
router.get('/login-history', requireAuth, (req: Req, res: Res) => {
|
2026-05-15 01:36:56 -05:00
|
|
|
const db = getDb();
|
2026-07-06 14:23:53 -05:00
|
|
|
const rows = db
|
|
|
|
|
.prepare(
|
|
|
|
|
`
|
2026-05-15 22:45:38 -05:00
|
|
|
SELECT id, logged_in_at, ip_address, user_agent,
|
2026-06-04 03:38:32 -05:00
|
|
|
browser, os, device_type, device_fingerprint, session_fingerprint,
|
|
|
|
|
success, location_city, location_country, location_region, location_isp
|
2026-05-15 01:36:56 -05:00
|
|
|
FROM user_login_history
|
|
|
|
|
WHERE user_id = ?
|
|
|
|
|
ORDER BY logged_in_at DESC
|
2026-06-04 03:38:32 -05:00
|
|
|
LIMIT 10
|
2026-07-06 14:23:53 -05:00
|
|
|
`,
|
|
|
|
|
)
|
|
|
|
|
.all(req.user.id);
|
2026-06-04 03:38:32 -05:00
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
const safeDecrypt = (v) => {
|
2026-06-04 03:38:32 -05:00
|
|
|
if (!v) return null;
|
2026-07-06 14:23:53 -05:00
|
|
|
try {
|
|
|
|
|
return decryptSecret(v);
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
2026-06-04 03:38:32 -05:00
|
|
|
};
|
|
|
|
|
|
2026-06-04 03:53:38 -05:00
|
|
|
// 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];
|
2026-06-04 03:38:32 -05:00
|
|
|
const currentFingerprint = currentCookie
|
|
|
|
|
? require('crypto').createHash('sha256').update(currentCookie).digest('hex').slice(0, 32)
|
|
|
|
|
: null;
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
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,
|
2026-06-04 03:38:32 -05:00
|
|
|
device_fingerprint: r.device_fingerprint,
|
2026-07-06 14:23:53 -05:00
|
|
|
success: r.success !== 0,
|
2026-06-04 03:38:32 -05:00
|
|
|
is_current_session: !!(currentFingerprint && r.session_fingerprint === currentFingerprint),
|
2026-07-06 14:23:53 -05:00
|
|
|
location_city: safeDecrypt(r.location_city),
|
|
|
|
|
location_country: safeDecrypt(r.location_country),
|
|
|
|
|
location_region: safeDecrypt(r.location_region),
|
|
|
|
|
location_isp: safeDecrypt(r.location_isp),
|
2026-06-04 03:38:32 -05:00
|
|
|
}));
|
|
|
|
|
|
2026-05-15 01:36:56 -05:00
|
|
|
res.json({ history });
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-04 04:10:14 -05:00
|
|
|
// ── TOTP / Authenticator App ─────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
const {
|
2026-07-06 14:23:53 -05:00
|
|
|
generateSecret,
|
|
|
|
|
generateQrCode,
|
|
|
|
|
verifyToken,
|
|
|
|
|
verifyTokenRaw,
|
|
|
|
|
generateRecoveryCodes,
|
|
|
|
|
hashRecoveryCode,
|
|
|
|
|
consumeRecoveryCode,
|
|
|
|
|
createChallenge,
|
|
|
|
|
consumeChallenge,
|
refactor(server): migrate 10 services to TypeScript (.cts)
Continues the incremental server TS migration (after utils/*.mts and
paymentValidation.cts): converts 10 services to .cts (CommonJS TypeScript,
run natively via Node type-stripping — no build step), type-checked by
tsconfig.server.json.
Migrated: totpService, amountSuggestionService, encryptionService,
paymentAccountingService, statusService, aprService, driftService,
analyticsService, spendingService, billMerchantRuleService.
- types/db.d.ts: shared minimal better-sqlite3 Db/Statement types (the lib
ships none), reused across the migrated modules.
- Every require of a migrated service updated to the explicit .cts extension
(extensionless require does not resolve .cts) across routes / services /
db migrations / server.js / workers / tests — require-only changes.
- package.json: check:server find pattern now includes *.cts (it silently
skipped .cts before, so paymentValidation.cts had no node --check gate).
Behavior-preserving (types only). Verified: typecheck:server 0,
check:server 0, full server suite 226/226, real boot → /api/health 200.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 10:35:29 -05:00
|
|
|
} = require('../services/totpService.cts');
|
|
|
|
|
const { encryptSecret: encTotpSecret } = require('../services/encryptionService.cts');
|
2026-06-04 04:10:14 -05:00
|
|
|
|
|
|
|
|
// 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.
|
2026-07-06 11:26:50 -05:00
|
|
|
router.post('/totp/challenge', async (req: Req, res: Res) => {
|
2026-06-04 04:10:14 -05:00
|
|
|
req.csrfSkip = true;
|
|
|
|
|
const { challenge_token, code, recovery_code } = req.body || {};
|
2026-07-06 14:23:53 -05:00
|
|
|
if (!challenge_token)
|
|
|
|
|
return res
|
|
|
|
|
.status(400)
|
|
|
|
|
.json(standardizeError('challenge_token is required', 'VALIDATION_ERROR'));
|
2026-06-04 04:10:14 -05:00
|
|
|
|
|
|
|
|
const db = getDb();
|
|
|
|
|
const userId = consumeChallenge(db, challenge_token);
|
2026-07-06 14:23:53 -05:00
|
|
|
if (!userId)
|
|
|
|
|
return res
|
|
|
|
|
.status(401)
|
|
|
|
|
.json(standardizeError('Challenge expired or invalid. Please sign in again.', 'AUTH_ERROR'));
|
2026-06-04 04:10:14 -05:00
|
|
|
|
|
|
|
|
const user = db.prepare('SELECT * FROM users WHERE id = ? AND active = 1').get(userId);
|
|
|
|
|
if (!user) return res.status(401).json(standardizeError('User not found.', 'AUTH_ERROR'));
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-07-06 14:23:53 -05:00
|
|
|
logAudit({
|
|
|
|
|
user_id: userId,
|
|
|
|
|
action: 'totp.failure',
|
|
|
|
|
ip_address: req.ip,
|
|
|
|
|
user_agent: req.get('user-agent'),
|
|
|
|
|
});
|
2026-06-04 04:10:14 -05:00
|
|
|
return res.status(401).json(standardizeError('Invalid authenticator code.', 'AUTH_ERROR'));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2026-07-06 11:06:05 -05:00
|
|
|
const { createSession } = require('../services/authService.cts');
|
2026-06-04 04:10:14 -05:00
|
|
|
const session = await createSession(userId);
|
2026-07-06 14:23:53 -05:00
|
|
|
if (!session)
|
|
|
|
|
return res.status(500).json(standardizeError('Failed to create session', 'SERVER_ERROR'));
|
|
|
|
|
|
|
|
|
|
logAudit({
|
|
|
|
|
user_id: userId,
|
|
|
|
|
action: 'login.success',
|
|
|
|
|
ip_address: req.ip,
|
|
|
|
|
user_agent: req.get('user-agent'),
|
|
|
|
|
});
|
2026-06-04 04:10:14 -05:00
|
|
|
recordLogin(userId, req.ip, req.get('user-agent'), session.sessionId);
|
|
|
|
|
|
|
|
|
|
res.cookie(COOKIE_NAME, session.sessionId, cookieOpts(req));
|
|
|
|
|
res.json({ user: session.user });
|
|
|
|
|
} catch (err) {
|
2026-07-06 16:05:18 -05:00
|
|
|
log.error('[totp/challenge]', err);
|
2026-06-04 04:10:14 -05:00
|
|
|
res.status(500).json(standardizeError('Login failed', 'SERVER_ERROR'));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 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.
|
2026-07-06 11:26:50 -05:00
|
|
|
router.get('/totp/setup', requireAuth, async (req: Req, res: Res) => {
|
2026-07-06 14:23:53 -05:00
|
|
|
if (req.singleUserMode)
|
|
|
|
|
return res
|
|
|
|
|
.status(400)
|
|
|
|
|
.json(standardizeError('TOTP is not available in single-user mode.', 'VALIDATION_ERROR'));
|
2026-06-04 04:10:14 -05:00
|
|
|
try {
|
|
|
|
|
const secret = generateSecret();
|
2026-07-06 14:23:53 -05:00
|
|
|
const user = getDb().prepare('SELECT username FROM users WHERE id = ?').get(req.user.id);
|
2026-06-04 04:10:14 -05:00
|
|
|
const { uri, qr_data_url } = await generateQrCode(secret, user.username);
|
|
|
|
|
res.json({ secret, uri, qr_data_url });
|
|
|
|
|
} catch (err) {
|
2026-07-06 16:05:18 -05:00
|
|
|
log.error('[totp/setup]', err);
|
2026-06-04 04:10:14 -05:00
|
|
|
res.status(500).json(standardizeError('Failed to generate setup data', 'SERVER_ERROR'));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// POST /api/auth/totp/enable — verify a code against the submitted secret, then enable TOTP.
|
2026-07-06 11:26:50 -05:00
|
|
|
router.post('/totp/enable', requireAuth, (req: Req, res: Res) => {
|
2026-07-06 14:23:53 -05:00
|
|
|
if (req.singleUserMode)
|
|
|
|
|
return res
|
|
|
|
|
.status(400)
|
|
|
|
|
.json(standardizeError('TOTP is not available in single-user mode.', 'VALIDATION_ERROR'));
|
2026-06-04 04:10:14 -05:00
|
|
|
const { secret, code } = req.body || {};
|
2026-07-06 14:23:53 -05:00
|
|
|
if (!secret || !code)
|
|
|
|
|
return res
|
|
|
|
|
.status(400)
|
|
|
|
|
.json(standardizeError('secret and code are required', 'VALIDATION_ERROR'));
|
|
|
|
|
if (!verifyTokenRaw(secret, code))
|
|
|
|
|
return res
|
|
|
|
|
.status(400)
|
|
|
|
|
.json(
|
|
|
|
|
standardizeError(
|
|
|
|
|
'Invalid authenticator code. Check your app and try again.',
|
|
|
|
|
'VALIDATION_ERROR',
|
|
|
|
|
'code',
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const plainCodes = generateRecoveryCodes();
|
2026-06-04 04:10:14 -05:00
|
|
|
const hashedCodes = plainCodes.map(hashRecoveryCode);
|
|
|
|
|
const db = getDb();
|
2026-07-06 14:23:53 -05:00
|
|
|
db.prepare(
|
|
|
|
|
`
|
2026-06-04 04:10:14 -05:00
|
|
|
UPDATE users SET totp_enabled=1, totp_secret=?, totp_recovery_codes=?, updated_at=datetime('now')
|
|
|
|
|
WHERE id=?
|
2026-07-06 14:23:53 -05:00
|
|
|
`,
|
|
|
|
|
).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'),
|
|
|
|
|
});
|
2026-06-04 04:10:14 -05:00
|
|
|
res.json({ enabled: true, recovery_codes: plainCodes });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// POST /api/auth/totp/disable — disable TOTP. Requires a valid TOTP code or recovery code.
|
2026-07-06 11:26:50 -05:00
|
|
|
router.post('/totp/disable', requireAuth, (req: Req, res: Res) => {
|
2026-06-04 04:10:14 -05:00
|
|
|
const { code, recovery_code } = req.body || {};
|
2026-07-06 14:23:53 -05:00
|
|
|
const db = getDb();
|
2026-06-04 04:10:14 -05:00
|
|
|
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
|
2026-07-06 14:23:53 -05:00
|
|
|
if (!user?.totp_enabled)
|
|
|
|
|
return res.status(400).json(standardizeError('TOTP is not enabled.', 'VALIDATION_ERROR'));
|
2026-06-04 04:10:14 -05:00
|
|
|
|
|
|
|
|
let verified = false;
|
|
|
|
|
if (recovery_code) {
|
|
|
|
|
verified = consumeRecoveryCode(db, req.user.id, recovery_code).used;
|
|
|
|
|
} else if (code) {
|
|
|
|
|
verified = verifyToken(user.totp_secret, code);
|
|
|
|
|
}
|
2026-07-06 14:23:53 -05:00
|
|
|
if (!verified)
|
|
|
|
|
return res
|
|
|
|
|
.status(401)
|
|
|
|
|
.json(standardizeError('Invalid authenticator code.', 'AUTH_ERROR', 'code'));
|
2026-06-04 04:10:14 -05:00
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
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'),
|
|
|
|
|
});
|
2026-06-04 04:10:14 -05:00
|
|
|
res.json({ enabled: false });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// GET /api/auth/totp/status — is TOTP enabled for the current user?
|
2026-07-06 11:26:50 -05:00
|
|
|
router.get('/totp/status', requireAuth, (req: Req, res: Res) => {
|
2026-06-04 04:10:14 -05:00
|
|
|
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
|
2026-07-06 11:26:50 -05:00
|
|
|
router.post('/acknowledge-version', requireAuth, (req: Req, res: Res) => {
|
2026-05-14 21:00:07 -05:00
|
|
|
const currentVersion = getAppVersion();
|
|
|
|
|
getDb()
|
|
|
|
|
.prepare("UPDATE users SET last_seen_version = ?, updated_at = datetime('now') WHERE id = ?")
|
|
|
|
|
.run(currentVersion, req.user.id);
|
2026-07-06 14:23:53 -05:00
|
|
|
res.json({
|
|
|
|
|
success: true,
|
|
|
|
|
last_seen_version: currentVersion,
|
|
|
|
|
release_notes_version: currentVersion,
|
|
|
|
|
});
|
2026-05-14 21:00:07 -05:00
|
|
|
});
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
// GET /api/auth/mode
|
|
|
|
|
// Public — tells the login page which options are available.
|
|
|
|
|
// Never returns secrets. local_enabled/oidc_enabled reflect admin settings.
|
2026-07-06 11:26:50 -05:00
|
|
|
router.get('/mode', (req: Req, res: Res) => {
|
2026-07-06 14:23:53 -05:00
|
|
|
const oidcInfo = getPublicOidcInfo();
|
|
|
|
|
const localEnabled = getSetting('local_login_enabled') !== 'false';
|
2026-05-03 19:51:57 -05:00
|
|
|
res.json({
|
2026-07-06 14:23:53 -05:00
|
|
|
auth_mode: getSetting('auth_mode') || 'multi',
|
2026-05-03 19:51:57 -05:00
|
|
|
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.
|
2026-07-06 11:26:50 -05:00
|
|
|
router.post('/restore-multi-user-mode', requireAuth, (req: Req, res: Res) => {
|
2026-05-03 19:51:57 -05:00
|
|
|
if (!req.singleUserMode && getSetting('auth_mode') !== 'single') {
|
2026-07-06 14:23:53 -05:00
|
|
|
return res
|
|
|
|
|
.status(400)
|
|
|
|
|
.json(standardizeError('Single-user mode is not enabled.', 'VALIDATION_ERROR', 'auth_mode'));
|
2026-05-03 19:51:57 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setSetting('auth_mode', 'multi');
|
|
|
|
|
setSetting('default_user_id', '');
|
|
|
|
|
|
|
|
|
|
res.json({ success: true, auth_mode: 'multi' });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// POST /api/auth/acknowledge-privacy
|
2026-07-06 11:26:50 -05:00
|
|
|
router.post('/acknowledge-privacy', requireAuth, (req: Req, res: Res) => {
|
2026-07-06 14:23:53 -05:00
|
|
|
getDb()
|
|
|
|
|
.prepare("UPDATE users SET first_login = 0, updated_at = datetime('now') WHERE id = ?")
|
|
|
|
|
.run(req.user.id);
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
|
|
|
res.json({ success: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// POST /api/auth/change-password
|
2026-05-09 13:03:36 -05:00
|
|
|
// Password change endpoint with dedicated rate limiter
|
v0.25.0: roadmap redesign, import CSRF fix, AdminDashboard removed
- RoadmapPage: kanban-style priority lanes, shadcn Collapsible/Tabs,
lazy-loaded activity log, admin-only /api/about/roadmap + /dev-log endpoints
- Import CSRF fix: added x-csrf-token header to importAdminBackup,
previewSpreadsheetImport, previewUserDbImport raw fetch() calls
- Removed AdminDashboard.jsx, replaced by RoadmapPage
- Added @radix-ui/react-collapsible + collapsible shadcn component
- Security audit by Private_Hudson: PASS (CSRF fix verified,
admin endpoints gated, path traversal mitigated, XSS safe)
2026-05-11 21:42:36 -05:00
|
|
|
// CSRF protected via csrfMiddleware on /api/auth mount
|
2026-07-06 11:26:50 -05:00
|
|
|
router.post('/change-password', passwordLimiter, requireAuth, async (req: Req, res: Res) => {
|
2026-05-03 19:51:57 -05:00
|
|
|
const { current_password, new_password } = req.body;
|
|
|
|
|
|
|
|
|
|
if (!new_password || new_password.length < 8) {
|
2026-07-06 14:23:53 -05:00
|
|
|
return res
|
|
|
|
|
.status(400)
|
|
|
|
|
.json(
|
|
|
|
|
standardizeError(
|
|
|
|
|
'New password must be at least 8 characters',
|
|
|
|
|
'VALIDATION_ERROR',
|
|
|
|
|
'new_password',
|
|
|
|
|
),
|
|
|
|
|
);
|
2026-05-03 19:51:57 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const db = getDb();
|
|
|
|
|
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
|
|
|
|
|
|
2026-05-31 15:06:10 -05:00
|
|
|
try {
|
|
|
|
|
if (!user.must_change_password) {
|
|
|
|
|
const bcrypt = require('bcryptjs');
|
|
|
|
|
const valid = await bcrypt.compare(current_password || '', user.password_hash);
|
2026-07-06 14:23:53 -05:00
|
|
|
if (!valid)
|
|
|
|
|
return res
|
|
|
|
|
.status(401)
|
|
|
|
|
.json(
|
|
|
|
|
standardizeError('Current password is incorrect', 'AUTH_ERROR', 'current_password'),
|
|
|
|
|
);
|
2026-05-31 15:06:10 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const hash = await hashPassword(new_password);
|
2026-05-03 19:51:57 -05:00
|
|
|
|
2026-05-31 15:06:10 -05:00
|
|
|
db.prepare(
|
2026-07-06 14:23:53 -05:00
|
|
|
"UPDATE users SET password_hash = ?, must_change_password = 0, last_password_change_at = datetime('now'), updated_at = datetime('now') WHERE id = ?",
|
2026-05-31 15:06:10 -05:00
|
|
|
).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));
|
|
|
|
|
}
|
2026-05-10 03:55:14 -05:00
|
|
|
}
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
logAudit({
|
|
|
|
|
user_id: req.user.id,
|
|
|
|
|
action: 'password.change',
|
|
|
|
|
ip_address: req.ip,
|
|
|
|
|
user_agent: req.get('user-agent'),
|
|
|
|
|
});
|
2026-05-10 00:03:12 -05:00
|
|
|
|
2026-05-31 15:06:10 -05:00
|
|
|
res.json({ success: true });
|
|
|
|
|
} catch (err) {
|
2026-07-06 16:05:18 -05:00
|
|
|
log.error('[auth] change-password error:', err.message);
|
2026-05-31 15:06:10 -05:00
|
|
|
res.status(500).json(standardizeError('Password change failed', 'SERVER_ERROR'));
|
|
|
|
|
}
|
2026-05-03 19:51:57 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ─────────────────────────────────────────
|
|
|
|
|
// ADMIN ROUTES (MOUNTED AT /api/admin)
|
|
|
|
|
// ─────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
// GET /api/admin/has-users
|
2026-07-06 11:26:50 -05:00
|
|
|
router.get('/has-users', (req: Req, res: Res) => {
|
2026-07-06 14:23:53 -05:00
|
|
|
const count = getDb().prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'user'").get().n;
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
|
|
|
res.json({ has_users: count > 0 });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// GET /api/admin/users
|
2026-07-06 11:26:50 -05:00
|
|
|
router.get('/users', requireAuth, requireAdmin, (req: Req, res: Res) => {
|
2026-07-06 14:23:53 -05:00
|
|
|
const users = getDb()
|
|
|
|
|
.prepare(
|
|
|
|
|
'SELECT id, username, role, must_change_password, first_login, created_at FROM users ORDER BY role DESC, username ASC',
|
|
|
|
|
)
|
|
|
|
|
.all();
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
|
|
|
res.json(users);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// POST /api/admin/users
|
2026-07-06 11:26:50 -05:00
|
|
|
router.post('/users', requireAuth, requireAdmin, async (req: Req, res: Res) => {
|
2026-05-03 19:51:57 -05:00
|
|
|
const { username, password } = req.body;
|
|
|
|
|
|
|
|
|
|
if (!username || username.length < 3) {
|
2026-07-06 14:23:53 -05:00
|
|
|
return res
|
|
|
|
|
.status(400)
|
|
|
|
|
.json(
|
|
|
|
|
standardizeError('Username must be at least 3 characters', 'VALIDATION_ERROR', 'username'),
|
|
|
|
|
);
|
2026-05-03 19:51:57 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!password || password.length < 8) {
|
2026-07-06 14:23:53 -05:00
|
|
|
return res
|
|
|
|
|
.status(400)
|
|
|
|
|
.json(
|
|
|
|
|
standardizeError('Password must be at least 8 characters', 'VALIDATION_ERROR', 'password'),
|
|
|
|
|
);
|
2026-05-03 19:51:57 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const db = getDb();
|
|
|
|
|
|
|
|
|
|
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(username);
|
2026-07-06 14:23:53 -05:00
|
|
|
if (existing)
|
|
|
|
|
return res.status(409).json(standardizeError('Username already taken', 'CONFLICT', 'username'));
|
2026-05-03 19:51:57 -05:00
|
|
|
|
2026-05-31 15:06:10 -05:00
|
|
|
try {
|
|
|
|
|
const hash = await hashPassword(password);
|
2026-05-03 19:51:57 -05:00
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
const result = db
|
|
|
|
|
.prepare(
|
|
|
|
|
"INSERT INTO users (username, password_hash, role, first_login, last_seen_version) VALUES (?, ?, 'user', 1, ?)",
|
|
|
|
|
)
|
|
|
|
|
.run(username, hash, getAppVersion());
|
2026-05-03 19:51:57 -05:00
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
const created = db
|
|
|
|
|
.prepare(
|
|
|
|
|
'SELECT id, username, role, must_change_password, first_login, created_at FROM users WHERE id = ?',
|
|
|
|
|
)
|
|
|
|
|
.get(result.lastInsertRowid);
|
2026-05-03 19:51:57 -05:00
|
|
|
|
2026-05-31 15:06:10 -05:00
|
|
|
res.status(201).json(created);
|
|
|
|
|
} catch (err) {
|
2026-07-06 16:05:18 -05:00
|
|
|
log.error('[auth] create-user error:', err.message);
|
2026-05-31 15:06:10 -05:00
|
|
|
res.status(500).json(standardizeError('Failed to create user', 'SERVER_ERROR'));
|
|
|
|
|
}
|
2026-05-03 19:51:57 -05:00
|
|
|
});
|
|
|
|
|
|
2026-06-05 22:05:23 -05:00
|
|
|
// ── WebAuthn / FIDO2 security key ─────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
const {
|
2026-07-06 14:23:53 -05:00
|
|
|
createRegistrationChallenge,
|
|
|
|
|
verifyRegistration,
|
|
|
|
|
createAuthenticationChallenge,
|
|
|
|
|
verifyAuthentication,
|
|
|
|
|
consumeLoginChallenge,
|
|
|
|
|
getCredentials,
|
|
|
|
|
deleteCredential,
|
2026-07-06 11:06:05 -05:00
|
|
|
} = require('../services/webauthnService.cts');
|
2026-06-05 22:05:23 -05:00
|
|
|
|
|
|
|
|
// GET /api/auth/webauthn/status
|
2026-07-06 11:26:50 -05:00
|
|
|
router.get('/webauthn/status', requireAuth, (req: Req, res: Res) => {
|
2026-06-05 22:05:23 -05:00
|
|
|
if (req.singleUserMode) return res.json({ enabled: false, credential_count: 0 });
|
2026-07-06 14:23:53 -05:00
|
|
|
const db = getDb();
|
2026-06-05 22:05:23 -05:00
|
|
|
const user = db.prepare('SELECT webauthn_enabled FROM users WHERE id = ?').get(req.user.id);
|
2026-07-06 14:23:53 -05:00
|
|
|
const { n } = db
|
|
|
|
|
.prepare('SELECT COUNT(*) AS n FROM webauthn_credentials WHERE user_id = ?')
|
|
|
|
|
.get(req.user.id);
|
2026-06-05 22:05:23 -05:00
|
|
|
res.json({ enabled: !!user?.webauthn_enabled, credential_count: n });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// GET /api/auth/webauthn/credentials
|
2026-07-06 11:26:50 -05:00
|
|
|
router.get('/webauthn/credentials', requireAuth, (req: Req, res: Res) => {
|
2026-06-05 22:05:23 -05:00
|
|
|
if (req.singleUserMode) return res.json({ credentials: [] });
|
|
|
|
|
res.json({ credentials: getCredentials(getDb(), req.user.id) });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// GET /api/auth/webauthn/setup — begin registration
|
2026-07-06 11:26:50 -05:00
|
|
|
router.get('/webauthn/setup', requireAuth, async (req: Req, res: Res) => {
|
2026-06-05 22:05:23 -05:00
|
|
|
if (req.singleUserMode)
|
2026-07-06 14:23:53 -05:00
|
|
|
return res
|
|
|
|
|
.status(400)
|
|
|
|
|
.json(standardizeError('WebAuthn is not available in single-user mode.', 'VALIDATION_ERROR'));
|
2026-06-05 22:05:23 -05:00
|
|
|
try {
|
2026-07-06 14:23:53 -05:00
|
|
|
const { options, challengeId } = await createRegistrationChallenge(
|
|
|
|
|
getDb(),
|
|
|
|
|
req.user.id,
|
|
|
|
|
req.user.username,
|
|
|
|
|
);
|
2026-06-05 22:05:23 -05:00
|
|
|
res.json({ options, challengeId });
|
|
|
|
|
} catch (err) {
|
2026-07-06 16:05:18 -05:00
|
|
|
log.error('[webauthn/setup]', err);
|
2026-06-05 22:05:23 -05:00
|
|
|
res.status(500).json(standardizeError('Failed to generate setup options', 'SERVER_ERROR'));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// POST /api/auth/webauthn/enable — complete registration
|
2026-07-06 11:26:50 -05:00
|
|
|
router.post('/webauthn/enable', requireAuth, async (req: Req, res: Res) => {
|
2026-06-05 22:05:23 -05:00
|
|
|
if (req.singleUserMode)
|
2026-07-06 14:23:53 -05:00
|
|
|
return res
|
|
|
|
|
.status(400)
|
|
|
|
|
.json(standardizeError('WebAuthn is not available in single-user mode.', 'VALIDATION_ERROR'));
|
2026-06-05 22:05:23 -05:00
|
|
|
const { challengeId, response, credential_name } = req.body || {};
|
|
|
|
|
if (!challengeId || !response)
|
2026-07-06 14:23:53 -05:00
|
|
|
return res
|
|
|
|
|
.status(400)
|
|
|
|
|
.json(standardizeError('challengeId and response are required', 'VALIDATION_ERROR'));
|
2026-06-05 22:05:23 -05:00
|
|
|
|
|
|
|
|
try {
|
2026-07-06 14:23:53 -05:00
|
|
|
const db = getDb();
|
|
|
|
|
const result = await verifyRegistration(
|
|
|
|
|
db,
|
|
|
|
|
req.user.id,
|
|
|
|
|
challengeId,
|
|
|
|
|
response,
|
|
|
|
|
credential_name,
|
|
|
|
|
);
|
2026-06-05 22:05:23 -05:00
|
|
|
if (!result.verified)
|
2026-07-06 14:23:53 -05:00
|
|
|
return res
|
|
|
|
|
.status(400)
|
|
|
|
|
.json(standardizeError(result.error || 'Registration failed', 'VALIDATION_ERROR'));
|
2026-06-05 22:05:23 -05:00
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
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'),
|
|
|
|
|
});
|
2026-06-05 22:05:23 -05:00
|
|
|
res.json({ enabled: true, credential_id: result.credentialId });
|
|
|
|
|
} catch (err) {
|
2026-07-06 16:05:18 -05:00
|
|
|
log.error('[webauthn/enable]', err);
|
2026-06-05 22:05:23 -05:00
|
|
|
res.status(500).json(standardizeError('Registration failed', 'SERVER_ERROR'));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// DELETE /api/auth/webauthn/credentials/:credentialId — remove one key
|
2026-07-06 11:26:50 -05:00
|
|
|
router.delete('/webauthn/credentials/:credentialId', requireAuth, async (req: Req, res: Res) => {
|
2026-06-05 22:05:23 -05:00
|
|
|
const { password } = req.body || {};
|
|
|
|
|
if (!password)
|
|
|
|
|
return res.status(400).json(standardizeError('password is required', 'VALIDATION_ERROR'));
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
const db = getDb();
|
2026-06-05 22:05:23 -05:00
|
|
|
const user = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.user.id);
|
|
|
|
|
try {
|
|
|
|
|
const bcrypt = require('bcryptjs');
|
2026-07-06 14:23:53 -05:00
|
|
|
if (!(await bcrypt.compare(password, user.password_hash)))
|
2026-06-05 22:05:23 -05:00
|
|
|
return res.status(401).json(standardizeError('Password is incorrect', 'AUTH_ERROR'));
|
|
|
|
|
|
|
|
|
|
const result = deleteCredential(db, req.params.credentialId, req.user.id);
|
|
|
|
|
if (result.changes === 0)
|
|
|
|
|
return res.status(404).json(standardizeError('Credential not found', 'NOT_FOUND'));
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
const { n } = db
|
|
|
|
|
.prepare('SELECT COUNT(*) AS n FROM webauthn_credentials WHERE user_id = ?')
|
|
|
|
|
.get(req.user.id);
|
2026-06-05 22:05:23 -05:00
|
|
|
if (n === 0)
|
2026-07-06 14:23:53 -05:00
|
|
|
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'),
|
|
|
|
|
});
|
2026-06-05 22:05:23 -05:00
|
|
|
res.json({ success: true, webauthn_enabled: n > 0 });
|
|
|
|
|
} catch (err) {
|
2026-07-06 16:05:18 -05:00
|
|
|
log.error('[webauthn/credentials/delete]', err);
|
2026-06-05 22:05:23 -05:00
|
|
|
res.status(500).json(standardizeError('Failed to remove credential', 'SERVER_ERROR'));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// POST /api/auth/webauthn/disable — remove all keys, disable WebAuthn
|
2026-07-06 11:26:50 -05:00
|
|
|
router.post('/webauthn/disable', requireAuth, async (req: Req, res: Res) => {
|
2026-06-05 22:05:23 -05:00
|
|
|
const { password } = req.body || {};
|
|
|
|
|
if (!password)
|
|
|
|
|
return res.status(400).json(standardizeError('password is required', 'VALIDATION_ERROR'));
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
const db = getDb();
|
|
|
|
|
const user = db
|
|
|
|
|
.prepare('SELECT password_hash, webauthn_enabled FROM users WHERE id = ?')
|
|
|
|
|
.get(req.user.id);
|
2026-06-05 22:05:23 -05:00
|
|
|
if (!user?.webauthn_enabled)
|
|
|
|
|
return res.status(400).json(standardizeError('WebAuthn is not enabled.', 'VALIDATION_ERROR'));
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const bcrypt = require('bcryptjs');
|
2026-07-06 14:23:53 -05:00
|
|
|
if (!(await bcrypt.compare(password, user.password_hash)))
|
2026-06-05 22:05:23 -05:00
|
|
|
return res.status(401).json(standardizeError('Password is incorrect', 'AUTH_ERROR'));
|
|
|
|
|
|
|
|
|
|
db.prepare('DELETE FROM webauthn_credentials WHERE user_id = ?').run(req.user.id);
|
2026-07-06 14:23:53 -05:00
|
|
|
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'),
|
|
|
|
|
});
|
2026-06-05 22:05:23 -05:00
|
|
|
res.json({ enabled: false });
|
|
|
|
|
} catch (err) {
|
2026-07-06 16:05:18 -05:00
|
|
|
log.error('[webauthn/disable]', err);
|
2026-06-05 22:05:23 -05:00
|
|
|
res.status(500).json(standardizeError('Failed to disable WebAuthn', 'SERVER_ERROR'));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// POST /api/auth/webauthn/challenge — second step of login when WebAuthn is enabled.
|
|
|
|
|
// Mirrors POST /totp/challenge exactly.
|
2026-07-06 11:26:50 -05:00
|
|
|
router.post('/webauthn/challenge', async (req: Req, res: Res) => {
|
2026-06-05 22:05:23 -05:00
|
|
|
req.csrfSkip = true;
|
|
|
|
|
const { challenge_token, response } = req.body || {};
|
|
|
|
|
if (!challenge_token || !response)
|
2026-07-06 14:23:53 -05:00
|
|
|
return res
|
|
|
|
|
.status(400)
|
|
|
|
|
.json(standardizeError('challenge_token and response are required', 'VALIDATION_ERROR'));
|
2026-06-05 22:05:23 -05:00
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
const db = getDb();
|
2026-06-05 22:05:23 -05:00
|
|
|
const session = consumeLoginChallenge(db, challenge_token);
|
|
|
|
|
if (!session)
|
2026-07-06 14:23:53 -05:00
|
|
|
return res
|
|
|
|
|
.status(401)
|
|
|
|
|
.json(standardizeError('Challenge expired or invalid. Please sign in again.', 'AUTH_ERROR'));
|
2026-06-05 22:05:23 -05:00
|
|
|
|
|
|
|
|
const user = db.prepare('SELECT * FROM users WHERE id = ? AND active = 1').get(session.userId);
|
|
|
|
|
if (!user) return res.status(401).json(standardizeError('User not found.', 'AUTH_ERROR'));
|
|
|
|
|
|
|
|
|
|
try {
|
2026-07-06 14:23:53 -05:00
|
|
|
const result = await verifyAuthentication(
|
|
|
|
|
db,
|
|
|
|
|
session.userId,
|
|
|
|
|
session.authChallengeId,
|
|
|
|
|
response,
|
|
|
|
|
);
|
2026-06-05 22:05:23 -05:00
|
|
|
if (!result.verified) {
|
2026-07-06 14:23:53 -05:00
|
|
|
logAudit({
|
|
|
|
|
user_id: session.userId,
|
|
|
|
|
action: 'webauthn.failure',
|
|
|
|
|
ip_address: req.ip,
|
|
|
|
|
user_agent: req.get('user-agent'),
|
|
|
|
|
});
|
|
|
|
|
return res
|
|
|
|
|
.status(401)
|
|
|
|
|
.json(standardizeError('Security key verification failed.', 'AUTH_ERROR'));
|
2026-06-05 22:05:23 -05:00
|
|
|
}
|
|
|
|
|
|
2026-07-06 11:06:05 -05:00
|
|
|
const { createSession } = require('../services/authService.cts');
|
2026-06-05 22:05:23 -05:00
|
|
|
const s = await createSession(session.userId);
|
2026-07-06 14:23:53 -05:00
|
|
|
if (!s)
|
|
|
|
|
return res.status(500).json(standardizeError('Failed to create session', 'SERVER_ERROR'));
|
|
|
|
|
|
|
|
|
|
logAudit({
|
|
|
|
|
user_id: session.userId,
|
|
|
|
|
action: 'login.success',
|
|
|
|
|
details: { method: 'webauthn' },
|
|
|
|
|
ip_address: req.ip,
|
|
|
|
|
user_agent: req.get('user-agent'),
|
|
|
|
|
});
|
2026-06-05 22:05:23 -05:00
|
|
|
recordLogin(session.userId, req.ip, req.get('user-agent'), s.sessionId);
|
|
|
|
|
|
|
|
|
|
res.cookie(COOKIE_NAME, s.sessionId, cookieOpts(req));
|
|
|
|
|
res.json({ user: s.user });
|
|
|
|
|
} catch (err) {
|
2026-07-06 16:05:18 -05:00
|
|
|
log.error('[webauthn/challenge]', err);
|
2026-06-05 22:05:23 -05:00
|
|
|
res.status(500).json(standardizeError('Login failed', 'SERVER_ERROR'));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
module.exports = router;
|