refactor(auth): throw-pattern for auth.cts (login, TOTP, WebAuthn flows)
Standards-unification batch 4a. Login, TOTP challenge/setup/enable/disable, change-password, and all five WebAuthn handlers converted: inline standardizeError bodies -> ApiError factory throws; try/catch-500 wrappers removed (Express 5 forwards async rejections; terminal handler logs + masks). Audit logging on failure paths unchanged. AUTH_ERROR/FORBIDDEN codes and field hints preserved (change-password current_password 401, TOTP code 401). Server suite 252/252. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
c0c3c2f347
commit
d0ab375f9a
213
routes/auth.cts
213
routes/auth.cts
|
|
@ -1,7 +1,6 @@
|
||||||
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
|
// @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';
|
import type { Req, Res, Next } from '../types/http';
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { log } = require('../utils/logger.cts');
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
let _appVersion;
|
let _appVersion;
|
||||||
|
|
@ -33,8 +32,13 @@ const { decryptSecret } = require('../services/encryptionService.cts');
|
||||||
const { getCsrfToken } = require('../middleware/csrf.cts');
|
const { getCsrfToken } = require('../middleware/csrf.cts');
|
||||||
const { requireAuth } = require('../middleware/requireAuth.cts');
|
const { requireAuth } = require('../middleware/requireAuth.cts');
|
||||||
const { getPublicOidcInfo } = require('../services/oidcService.cts');
|
const { getPublicOidcInfo } = require('../services/oidcService.cts');
|
||||||
const { ValidationError, formatError } = require('../utils/apiError.cts');
|
const {
|
||||||
const { standardizeError } = require('../middleware/errorFormatter.cts');
|
ApiError,
|
||||||
|
ValidationError,
|
||||||
|
AuthError,
|
||||||
|
ForbiddenError,
|
||||||
|
NotFoundError,
|
||||||
|
} = require('../utils/apiError.cts');
|
||||||
const { passwordLimiter } = require('../middleware/rateLimiter.cts');
|
const { passwordLimiter } = require('../middleware/rateLimiter.cts');
|
||||||
const { logAudit } = require('../services/auditService.cts');
|
const { logAudit } = require('../services/auditService.cts');
|
||||||
|
|
||||||
|
|
@ -54,30 +58,17 @@ router.post(
|
||||||
async (req: Req, res: Res) => {
|
async (req: Req, res: Res) => {
|
||||||
// Respect admin-configured login method toggle
|
// Respect admin-configured login method toggle
|
||||||
if (getSetting('local_login_enabled') === 'false') {
|
if (getSetting('local_login_enabled') === 'false') {
|
||||||
return res
|
throw ForbiddenError('Local username/password login is not enabled on this server.');
|
||||||
.status(403)
|
|
||||||
.json(
|
|
||||||
standardizeError(
|
|
||||||
'Local username/password login is not enabled on this server.',
|
|
||||||
'FORBIDDEN',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { username, password } = req.body;
|
const { username, password } = req.body;
|
||||||
if (!username || !password) {
|
if (!username || !password) {
|
||||||
return res
|
throw ValidationError(
|
||||||
.status(400)
|
|
||||||
.json(
|
|
||||||
standardizeError(
|
|
||||||
'Username and password are required',
|
'Username and password are required',
|
||||||
'VALIDATION_ERROR',
|
|
||||||
!username ? 'username' : 'password',
|
!username ? 'username' : 'password',
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await login(username, password);
|
const result = await login(username, password);
|
||||||
if (!result || result.error) {
|
if (!result || result.error) {
|
||||||
logAudit({
|
logAudit({
|
||||||
|
|
@ -91,7 +82,7 @@ router.post(
|
||||||
if (result?.error === 'bad_password') {
|
if (result?.error === 'bad_password') {
|
||||||
recordFailedLogin(result.userId, req.ip, req.get('user-agent'));
|
recordFailedLogin(result.userId, req.ip, req.get('user-agent'));
|
||||||
}
|
}
|
||||||
return res.status(401).json(standardizeError('Invalid username or password', 'AUTH_ERROR'));
|
throw AuthError('Invalid username or password');
|
||||||
}
|
}
|
||||||
|
|
||||||
// TOTP required — don't create a session yet
|
// TOTP required — don't create a session yet
|
||||||
|
|
@ -119,10 +110,6 @@ router.post(
|
||||||
|
|
||||||
res.cookie(COOKIE_NAME, result.sessionId, cookieOpts(req));
|
res.cookie(COOKIE_NAME, result.sessionId, cookieOpts(req));
|
||||||
res.json({ user: result.user });
|
res.json({ user: result.user });
|
||||||
} catch (err) {
|
|
||||||
log.error('Login error:', err);
|
|
||||||
res.status(500).json(standardizeError('Login failed', 'SERVER_ERROR'));
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -266,20 +253,14 @@ const { encryptSecret: encTotpSecret } = require('../services/encryptionService.
|
||||||
router.post('/totp/challenge', async (req: Req, res: Res) => {
|
router.post('/totp/challenge', async (req: Req, res: Res) => {
|
||||||
req.csrfSkip = true;
|
req.csrfSkip = true;
|
||||||
const { challenge_token, code, recovery_code } = req.body || {};
|
const { challenge_token, code, recovery_code } = req.body || {};
|
||||||
if (!challenge_token)
|
if (!challenge_token) throw ValidationError('challenge_token is required');
|
||||||
return res
|
|
||||||
.status(400)
|
|
||||||
.json(standardizeError('challenge_token is required', 'VALIDATION_ERROR'));
|
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const userId = consumeChallenge(db, challenge_token);
|
const userId = consumeChallenge(db, challenge_token);
|
||||||
if (!userId)
|
if (!userId) throw AuthError('Challenge expired or invalid. Please sign in again.');
|
||||||
return res
|
|
||||||
.status(401)
|
|
||||||
.json(standardizeError('Challenge expired or invalid. Please sign in again.', 'AUTH_ERROR'));
|
|
||||||
|
|
||||||
const user = db.prepare('SELECT * FROM users WHERE id = ? AND active = 1').get(userId);
|
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'));
|
if (!user) throw AuthError('User not found.');
|
||||||
|
|
||||||
let verified = false;
|
let verified = false;
|
||||||
if (recovery_code) {
|
if (recovery_code) {
|
||||||
|
|
@ -299,14 +280,12 @@ router.post('/totp/challenge', async (req: Req, res: Res) => {
|
||||||
ip_address: req.ip,
|
ip_address: req.ip,
|
||||||
user_agent: req.get('user-agent'),
|
user_agent: req.get('user-agent'),
|
||||||
});
|
});
|
||||||
return res.status(401).json(standardizeError('Invalid authenticator code.', 'AUTH_ERROR'));
|
throw AuthError('Invalid authenticator code.');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
const { createSession } = require('../services/authService.cts');
|
const { createSession } = require('../services/authService.cts');
|
||||||
const session = await createSession(userId);
|
const session = await createSession(userId);
|
||||||
if (!session)
|
if (!session) throw new ApiError('SERVER_ERROR', 'Failed to create session', 500);
|
||||||
return res.status(500).json(standardizeError('Failed to create session', 'SERVER_ERROR'));
|
|
||||||
|
|
||||||
logAudit({
|
logAudit({
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
|
|
@ -318,51 +297,25 @@ router.post('/totp/challenge', async (req: Req, res: Res) => {
|
||||||
|
|
||||||
res.cookie(COOKIE_NAME, session.sessionId, cookieOpts(req));
|
res.cookie(COOKIE_NAME, session.sessionId, cookieOpts(req));
|
||||||
res.json({ user: session.user });
|
res.json({ user: session.user });
|
||||||
} catch (err) {
|
|
||||||
log.error('[totp/challenge]', err);
|
|
||||||
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.
|
// 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.
|
// 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) => {
|
router.get('/totp/setup', requireAuth, async (req: Req, res: Res) => {
|
||||||
if (req.singleUserMode)
|
if (req.singleUserMode) throw ValidationError('TOTP is not available in single-user mode.');
|
||||||
return res
|
|
||||||
.status(400)
|
|
||||||
.json(standardizeError('TOTP is not available in single-user mode.', 'VALIDATION_ERROR'));
|
|
||||||
try {
|
|
||||||
const secret = generateSecret();
|
const secret = generateSecret();
|
||||||
const user = getDb().prepare('SELECT username FROM users WHERE id = ?').get(req.user.id);
|
const user = getDb().prepare('SELECT username FROM users WHERE id = ?').get(req.user.id);
|
||||||
const { uri, qr_data_url } = await generateQrCode(secret, user.username);
|
const { uri, qr_data_url } = await generateQrCode(secret, user.username);
|
||||||
res.json({ secret, uri, qr_data_url });
|
res.json({ secret, uri, qr_data_url });
|
||||||
} catch (err) {
|
|
||||||
log.error('[totp/setup]', err);
|
|
||||||
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.
|
// POST /api/auth/totp/enable — verify a code against the submitted secret, then enable TOTP.
|
||||||
router.post('/totp/enable', requireAuth, (req: Req, res: Res) => {
|
router.post('/totp/enable', requireAuth, (req: Req, res: Res) => {
|
||||||
if (req.singleUserMode)
|
if (req.singleUserMode) throw ValidationError('TOTP is not available in single-user mode.');
|
||||||
return res
|
|
||||||
.status(400)
|
|
||||||
.json(standardizeError('TOTP is not available in single-user mode.', 'VALIDATION_ERROR'));
|
|
||||||
const { secret, code } = req.body || {};
|
const { secret, code } = req.body || {};
|
||||||
if (!secret || !code)
|
if (!secret || !code) throw ValidationError('secret and code are required');
|
||||||
return res
|
|
||||||
.status(400)
|
|
||||||
.json(standardizeError('secret and code are required', 'VALIDATION_ERROR'));
|
|
||||||
if (!verifyTokenRaw(secret, code))
|
if (!verifyTokenRaw(secret, code))
|
||||||
return res
|
throw ValidationError('Invalid authenticator code. Check your app and try again.', 'code');
|
||||||
.status(400)
|
|
||||||
.json(
|
|
||||||
standardizeError(
|
|
||||||
'Invalid authenticator code. Check your app and try again.',
|
|
||||||
'VALIDATION_ERROR',
|
|
||||||
'code',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const plainCodes = generateRecoveryCodes();
|
const plainCodes = generateRecoveryCodes();
|
||||||
const hashedCodes = plainCodes.map(hashRecoveryCode);
|
const hashedCodes = plainCodes.map(hashRecoveryCode);
|
||||||
|
|
@ -388,8 +341,7 @@ router.post('/totp/disable', requireAuth, (req: Req, res: Res) => {
|
||||||
const { code, recovery_code } = req.body || {};
|
const { code, recovery_code } = req.body || {};
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
|
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
|
||||||
if (!user?.totp_enabled)
|
if (!user?.totp_enabled) throw ValidationError('TOTP is not enabled.');
|
||||||
return res.status(400).json(standardizeError('TOTP is not enabled.', 'VALIDATION_ERROR'));
|
|
||||||
|
|
||||||
let verified = false;
|
let verified = false;
|
||||||
if (recovery_code) {
|
if (recovery_code) {
|
||||||
|
|
@ -398,9 +350,7 @@ router.post('/totp/disable', requireAuth, (req: Req, res: Res) => {
|
||||||
verified = verifyToken(user.totp_secret, code);
|
verified = verifyToken(user.totp_secret, code);
|
||||||
}
|
}
|
||||||
if (!verified)
|
if (!verified)
|
||||||
return res
|
throw new ApiError('AUTH_ERROR', 'Invalid authenticator code.', 401, { field: 'code' });
|
||||||
.status(401)
|
|
||||||
.json(standardizeError('Invalid authenticator code.', 'AUTH_ERROR', 'code'));
|
|
||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`UPDATE users SET totp_enabled=0, totp_secret=NULL, totp_recovery_codes=NULL, updated_at=datetime('now') WHERE id=?`,
|
`UPDATE users SET totp_enabled=0, totp_secret=NULL, totp_recovery_codes=NULL, updated_at=datetime('now') WHERE id=?`,
|
||||||
|
|
@ -452,9 +402,7 @@ router.get('/mode', (req: Req, res: Res) => {
|
||||||
// login without needing access to Admin routes.
|
// login without needing access to Admin routes.
|
||||||
router.post('/restore-multi-user-mode', requireAuth, (req: Req, res: Res) => {
|
router.post('/restore-multi-user-mode', requireAuth, (req: Req, res: Res) => {
|
||||||
if (!req.singleUserMode && getSetting('auth_mode') !== 'single') {
|
if (!req.singleUserMode && getSetting('auth_mode') !== 'single') {
|
||||||
return res
|
throw ValidationError('Single-user mode is not enabled.', 'auth_mode');
|
||||||
.status(400)
|
|
||||||
.json(standardizeError('Single-user mode is not enabled.', 'VALIDATION_ERROR', 'auth_mode'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setSetting('auth_mode', 'multi');
|
setSetting('auth_mode', 'multi');
|
||||||
|
|
@ -479,30 +427,19 @@ router.post('/change-password', passwordLimiter, requireAuth, async (req: Req, r
|
||||||
const { current_password, new_password } = req.body;
|
const { current_password, new_password } = req.body;
|
||||||
|
|
||||||
if (!new_password || new_password.length < 8) {
|
if (!new_password || new_password.length < 8) {
|
||||||
return res
|
throw ValidationError('New password must be at least 8 characters', 'new_password');
|
||||||
.status(400)
|
|
||||||
.json(
|
|
||||||
standardizeError(
|
|
||||||
'New password must be at least 8 characters',
|
|
||||||
'VALIDATION_ERROR',
|
|
||||||
'new_password',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
|
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
|
||||||
|
|
||||||
try {
|
|
||||||
if (!user.must_change_password) {
|
if (!user.must_change_password) {
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const valid = await bcrypt.compare(current_password || '', user.password_hash);
|
const valid = await bcrypt.compare(current_password || '', user.password_hash);
|
||||||
if (!valid)
|
if (!valid)
|
||||||
return res
|
throw new ApiError('AUTH_ERROR', 'Current password is incorrect', 401, {
|
||||||
.status(401)
|
field: 'current_password',
|
||||||
.json(
|
});
|
||||||
standardizeError('Current password is incorrect', 'AUTH_ERROR', 'current_password'),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const hash = await hashPassword(new_password);
|
const hash = await hashPassword(new_password);
|
||||||
|
|
@ -531,10 +468,6 @@ router.post('/change-password', passwordLimiter, requireAuth, async (req: Req, r
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (err) {
|
|
||||||
log.error('[auth] change-password error:', err.message);
|
|
||||||
res.status(500).json(standardizeError('Password change failed', 'SERVER_ERROR'));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── WebAuthn / FIDO2 security key ─────────────────────────────────────────────
|
// ── WebAuthn / FIDO2 security key ─────────────────────────────────────────────
|
||||||
|
|
@ -568,48 +501,24 @@ router.get('/webauthn/credentials', requireAuth, (req: Req, res: Res) => {
|
||||||
|
|
||||||
// GET /api/auth/webauthn/setup — begin registration
|
// GET /api/auth/webauthn/setup — begin registration
|
||||||
router.get('/webauthn/setup', requireAuth, async (req: Req, res: Res) => {
|
router.get('/webauthn/setup', requireAuth, async (req: Req, res: Res) => {
|
||||||
if (req.singleUserMode)
|
if (req.singleUserMode) throw ValidationError('WebAuthn is not available in single-user mode.');
|
||||||
return res
|
|
||||||
.status(400)
|
|
||||||
.json(standardizeError('WebAuthn is not available in single-user mode.', 'VALIDATION_ERROR'));
|
|
||||||
try {
|
|
||||||
const { options, challengeId } = await createRegistrationChallenge(
|
const { options, challengeId } = await createRegistrationChallenge(
|
||||||
getDb(),
|
getDb(),
|
||||||
req.user.id,
|
req.user.id,
|
||||||
req.user.username,
|
req.user.username,
|
||||||
);
|
);
|
||||||
res.json({ options, challengeId });
|
res.json({ options, challengeId });
|
||||||
} catch (err) {
|
|
||||||
log.error('[webauthn/setup]', err);
|
|
||||||
res.status(500).json(standardizeError('Failed to generate setup options', 'SERVER_ERROR'));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/auth/webauthn/enable — complete registration
|
// POST /api/auth/webauthn/enable — complete registration
|
||||||
router.post('/webauthn/enable', requireAuth, async (req: Req, res: Res) => {
|
router.post('/webauthn/enable', requireAuth, async (req: Req, res: Res) => {
|
||||||
if (req.singleUserMode)
|
if (req.singleUserMode) throw ValidationError('WebAuthn is not available in single-user mode.');
|
||||||
return res
|
|
||||||
.status(400)
|
|
||||||
.json(standardizeError('WebAuthn is not available in single-user mode.', 'VALIDATION_ERROR'));
|
|
||||||
const { challengeId, response, credential_name } = req.body || {};
|
const { challengeId, response, credential_name } = req.body || {};
|
||||||
if (!challengeId || !response)
|
if (!challengeId || !response) throw ValidationError('challengeId and response are required');
|
||||||
return res
|
|
||||||
.status(400)
|
|
||||||
.json(standardizeError('challengeId and response are required', 'VALIDATION_ERROR'));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const result = await verifyRegistration(
|
const result = await verifyRegistration(db, req.user.id, challengeId, response, credential_name);
|
||||||
db,
|
if (!result.verified) throw ValidationError(result.error || 'Registration failed');
|
||||||
req.user.id,
|
|
||||||
challengeId,
|
|
||||||
response,
|
|
||||||
credential_name,
|
|
||||||
);
|
|
||||||
if (!result.verified)
|
|
||||||
return res
|
|
||||||
.status(400)
|
|
||||||
.json(standardizeError(result.error || 'Registration failed', 'VALIDATION_ERROR'));
|
|
||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
"UPDATE users SET webauthn_enabled = 1, updated_at = datetime('now') WHERE id = ?",
|
"UPDATE users SET webauthn_enabled = 1, updated_at = datetime('now') WHERE id = ?",
|
||||||
|
|
@ -622,28 +531,21 @@ router.post('/webauthn/enable', requireAuth, async (req: Req, res: Res) => {
|
||||||
user_agent: req.get('user-agent'),
|
user_agent: req.get('user-agent'),
|
||||||
});
|
});
|
||||||
res.json({ enabled: true, credential_id: result.credentialId });
|
res.json({ enabled: true, credential_id: result.credentialId });
|
||||||
} catch (err) {
|
|
||||||
log.error('[webauthn/enable]', err);
|
|
||||||
res.status(500).json(standardizeError('Registration failed', 'SERVER_ERROR'));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// DELETE /api/auth/webauthn/credentials/:credentialId — remove one key
|
// DELETE /api/auth/webauthn/credentials/:credentialId — remove one key
|
||||||
router.delete('/webauthn/credentials/:credentialId', requireAuth, async (req: Req, res: Res) => {
|
router.delete('/webauthn/credentials/:credentialId', requireAuth, async (req: Req, res: Res) => {
|
||||||
const { password } = req.body || {};
|
const { password } = req.body || {};
|
||||||
if (!password)
|
if (!password) throw ValidationError('password is required');
|
||||||
return res.status(400).json(standardizeError('password is required', 'VALIDATION_ERROR'));
|
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const user = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.user.id);
|
const user = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.user.id);
|
||||||
try {
|
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
if (!(await bcrypt.compare(password, user.password_hash)))
|
if (!(await bcrypt.compare(password, user.password_hash)))
|
||||||
return res.status(401).json(standardizeError('Password is incorrect', 'AUTH_ERROR'));
|
throw AuthError('Password is incorrect');
|
||||||
|
|
||||||
const result = deleteCredential(db, req.params.credentialId, req.user.id);
|
const result = deleteCredential(db, req.params.credentialId, req.user.id);
|
||||||
if (result.changes === 0)
|
if (result.changes === 0) throw NotFoundError('Credential not found');
|
||||||
return res.status(404).json(standardizeError('Credential not found', 'NOT_FOUND'));
|
|
||||||
|
|
||||||
const { n } = db
|
const { n } = db
|
||||||
.prepare('SELECT COUNT(*) AS n FROM webauthn_credentials WHERE user_id = ?')
|
.prepare('SELECT COUNT(*) AS n FROM webauthn_credentials WHERE user_id = ?')
|
||||||
|
|
@ -660,29 +562,22 @@ router.delete('/webauthn/credentials/:credentialId', requireAuth, async (req: Re
|
||||||
user_agent: req.get('user-agent'),
|
user_agent: req.get('user-agent'),
|
||||||
});
|
});
|
||||||
res.json({ success: true, webauthn_enabled: n > 0 });
|
res.json({ success: true, webauthn_enabled: n > 0 });
|
||||||
} catch (err) {
|
|
||||||
log.error('[webauthn/credentials/delete]', err);
|
|
||||||
res.status(500).json(standardizeError('Failed to remove credential', 'SERVER_ERROR'));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/auth/webauthn/disable — remove all keys, disable WebAuthn
|
// POST /api/auth/webauthn/disable — remove all keys, disable WebAuthn
|
||||||
router.post('/webauthn/disable', requireAuth, async (req: Req, res: Res) => {
|
router.post('/webauthn/disable', requireAuth, async (req: Req, res: Res) => {
|
||||||
const { password } = req.body || {};
|
const { password } = req.body || {};
|
||||||
if (!password)
|
if (!password) throw ValidationError('password is required');
|
||||||
return res.status(400).json(standardizeError('password is required', 'VALIDATION_ERROR'));
|
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const user = db
|
const user = db
|
||||||
.prepare('SELECT password_hash, webauthn_enabled FROM users WHERE id = ?')
|
.prepare('SELECT password_hash, webauthn_enabled FROM users WHERE id = ?')
|
||||||
.get(req.user.id);
|
.get(req.user.id);
|
||||||
if (!user?.webauthn_enabled)
|
if (!user?.webauthn_enabled) throw ValidationError('WebAuthn is not enabled.');
|
||||||
return res.status(400).json(standardizeError('WebAuthn is not enabled.', 'VALIDATION_ERROR'));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
if (!(await bcrypt.compare(password, user.password_hash)))
|
if (!(await bcrypt.compare(password, user.password_hash)))
|
||||||
return res.status(401).json(standardizeError('Password is incorrect', 'AUTH_ERROR'));
|
throw AuthError('Password is incorrect');
|
||||||
|
|
||||||
db.prepare('DELETE FROM webauthn_credentials WHERE user_id = ?').run(req.user.id);
|
db.prepare('DELETE FROM webauthn_credentials WHERE user_id = ?').run(req.user.id);
|
||||||
db.prepare(
|
db.prepare(
|
||||||
|
|
@ -696,10 +591,6 @@ router.post('/webauthn/disable', requireAuth, async (req: Req, res: Res) => {
|
||||||
user_agent: req.get('user-agent'),
|
user_agent: req.get('user-agent'),
|
||||||
});
|
});
|
||||||
res.json({ enabled: false });
|
res.json({ enabled: false });
|
||||||
} catch (err) {
|
|
||||||
log.error('[webauthn/disable]', err);
|
|
||||||
res.status(500).json(standardizeError('Failed to disable WebAuthn', 'SERVER_ERROR'));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/auth/webauthn/challenge — second step of login when WebAuthn is enabled.
|
// POST /api/auth/webauthn/challenge — second step of login when WebAuthn is enabled.
|
||||||
|
|
@ -708,27 +599,16 @@ router.post('/webauthn/challenge', async (req: Req, res: Res) => {
|
||||||
req.csrfSkip = true;
|
req.csrfSkip = true;
|
||||||
const { challenge_token, response } = req.body || {};
|
const { challenge_token, response } = req.body || {};
|
||||||
if (!challenge_token || !response)
|
if (!challenge_token || !response)
|
||||||
return res
|
throw ValidationError('challenge_token and response are required');
|
||||||
.status(400)
|
|
||||||
.json(standardizeError('challenge_token and response are required', 'VALIDATION_ERROR'));
|
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const session = consumeLoginChallenge(db, challenge_token);
|
const session = consumeLoginChallenge(db, challenge_token);
|
||||||
if (!session)
|
if (!session) throw AuthError('Challenge expired or invalid. Please sign in again.');
|
||||||
return res
|
|
||||||
.status(401)
|
|
||||||
.json(standardizeError('Challenge expired or invalid. Please sign in again.', 'AUTH_ERROR'));
|
|
||||||
|
|
||||||
const user = db.prepare('SELECT * FROM users WHERE id = ? AND active = 1').get(session.userId);
|
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'));
|
if (!user) throw AuthError('User not found.');
|
||||||
|
|
||||||
try {
|
const result = await verifyAuthentication(db, session.userId, session.authChallengeId, response);
|
||||||
const result = await verifyAuthentication(
|
|
||||||
db,
|
|
||||||
session.userId,
|
|
||||||
session.authChallengeId,
|
|
||||||
response,
|
|
||||||
);
|
|
||||||
if (!result.verified) {
|
if (!result.verified) {
|
||||||
logAudit({
|
logAudit({
|
||||||
user_id: session.userId,
|
user_id: session.userId,
|
||||||
|
|
@ -736,15 +616,12 @@ router.post('/webauthn/challenge', async (req: Req, res: Res) => {
|
||||||
ip_address: req.ip,
|
ip_address: req.ip,
|
||||||
user_agent: req.get('user-agent'),
|
user_agent: req.get('user-agent'),
|
||||||
});
|
});
|
||||||
return res
|
throw AuthError('Security key verification failed.');
|
||||||
.status(401)
|
|
||||||
.json(standardizeError('Security key verification failed.', 'AUTH_ERROR'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { createSession } = require('../services/authService.cts');
|
const { createSession } = require('../services/authService.cts');
|
||||||
const s = await createSession(session.userId);
|
const s = await createSession(session.userId);
|
||||||
if (!s)
|
if (!s) throw new ApiError('SERVER_ERROR', 'Failed to create session', 500);
|
||||||
return res.status(500).json(standardizeError('Failed to create session', 'SERVER_ERROR'));
|
|
||||||
|
|
||||||
logAudit({
|
logAudit({
|
||||||
user_id: session.userId,
|
user_id: session.userId,
|
||||||
|
|
@ -757,10 +634,6 @@ router.post('/webauthn/challenge', async (req: Req, res: Res) => {
|
||||||
|
|
||||||
res.cookie(COOKIE_NAME, s.sessionId, cookieOpts(req));
|
res.cookie(COOKIE_NAME, s.sessionId, cookieOpts(req));
|
||||||
res.json({ user: s.user });
|
res.json({ user: s.user });
|
||||||
} catch (err) {
|
|
||||||
log.error('[webauthn/challenge]', err);
|
|
||||||
res.status(500).json(standardizeError('Login failed', 'SERVER_ERROR'));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-webauthn-login-${process.pid
|
||||||
process.env.DB_PATH = dbPath;
|
process.env.DB_PATH = dbPath;
|
||||||
|
|
||||||
const { getDb, closeDb } = require('../db/database.cts');
|
const { getDb, closeDb } = require('../db/database.cts');
|
||||||
|
const { formatError } = require('../utils/apiError.cts');
|
||||||
const { hashPassword } = require('../services/authService.cts');
|
const { hashPassword } = require('../services/authService.cts');
|
||||||
const router = require('../routes/auth.cts');
|
const router = require('../routes/auth.cts');
|
||||||
|
|
||||||
|
|
@ -50,7 +51,15 @@ function callLogin(body) {
|
||||||
resolve({ status: this.statusCode, body: data, cookies });
|
resolve({ status: this.statusCode, body: data, cookies });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
Promise.resolve(h(req, res)).catch(reject);
|
// Routes follow the throw-pattern: mirror the app's terminal error handler
|
||||||
|
// so thrown/rejected ApiErrors resolve to the same wire shape.
|
||||||
|
Promise.resolve(h(req, res)).catch((err) => {
|
||||||
|
if (err && (err.status || err.name === 'ApiError')) {
|
||||||
|
resolve({ status: err.status || 500, body: formatError(err), cookies });
|
||||||
|
} else {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue