security: WebAuthn / FIDO2 hardware security key 2FA
This commit is contained in:
parent
2c9cc37593
commit
99abca9868
|
|
@ -1,5 +1,11 @@
|
||||||
# Bill Tracker — Changelog
|
# Bill Tracker — Changelog
|
||||||
|
|
||||||
|
## v0.37.0
|
||||||
|
|
||||||
|
### 🔧 Changed
|
||||||
|
|
||||||
|
- **WebAuthn / FIDO2 hardware security key 2FA** — Migration v0.92 adds `webauthn_enabled` and `webauthn_user_id` columns to `users`, a `webauthn_credentials` table (per-user, multiple keys supported — stores credential ID, CBOR public key as base64url, sign counter, transports, backup eligibility, friendly name, and AAGUID), and a `webauthn_challenges` table for short-lived registration, authentication, and login challenges. The new `webauthnService.js` handles the full lifecycle via `@simplewebauthn/server`: generating registration options (with `excludeCredentials` to prevent re-registering existing keys), verifying attestation responses, generating authentication options (passing allowed credentials and transports), verifying assertion responses (updating the sign counter on each use to detect cloned authenticators), and issuing/consuming login challenge tokens. The login flow mirrors TOTP exactly — after password verification succeeds, if `webauthn_enabled` is set, the server returns `requires_webauthn: true` alongside a `challenge_token` (a short-lived login challenge) and `webauthn_options` (the pre-generated assertion options); the client calls `startAuthentication()` from `@simplewebauthn/browser`, and `POST /api/auth/webauthn/challenge` verifies the assertion and creates a session. Six new endpoints added to `routes/auth.js`: `GET /webauthn/status` (enabled flag + credential count), `GET /webauthn/credentials` (list registered keys with name, AAGUID, backup flags, and timestamps), `GET /webauthn/setup` (begin registration — returns options + challengeId), `POST /webauthn/enable` (complete registration — verifies attestation, stores credential, sets `webauthn_enabled = 1`), `DELETE /webauthn/credentials/:credentialId` (remove one key — requires password confirmation; auto-disables WebAuthn when last key is removed), `POST /webauthn/disable` (remove all keys — requires password confirmation). RP ID and origin are configurable via `WEBAUTHN_RP_ID` and `WEBAUTHN_ORIGIN` env vars (default to `localhost` for dev). `publicUser()` in `authService.js` now includes `webauthn_enabled` so the frontend login flow knows to prompt for a security key tap. Expired WebAuthn challenges are pruned in the daily worker alongside expired sessions. OIDC and single-user mode are unaffected. `@simplewebauthn/server` and `@simplewebauthn/browser` v13 added to dependencies.
|
||||||
|
|
||||||
## v0.36.0
|
## v0.36.0
|
||||||
|
|
||||||
### 🔧 Changed
|
### 🔧 Changed
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,14 @@ export const api = {
|
||||||
totpDisable: (data) => post('/auth/totp/disable', data),
|
totpDisable: (data) => post('/auth/totp/disable', data),
|
||||||
totpChallenge: (data) => post('/auth/totp/challenge', data),
|
totpChallenge: (data) => post('/auth/totp/challenge', data),
|
||||||
|
|
||||||
|
webauthnStatus: () => get('/auth/webauthn/status'),
|
||||||
|
webauthnSetup: () => get('/auth/webauthn/setup'),
|
||||||
|
webauthnEnable: (data) => post('/auth/webauthn/enable', data),
|
||||||
|
webauthnDisable: (data) => post('/auth/webauthn/disable', data),
|
||||||
|
webauthnCredentials: () => get('/auth/webauthn/credentials'),
|
||||||
|
webauthnDeleteCred: (id, data) => _fetch('DELETE', `/auth/webauthn/credentials/${encodeURIComponent(id)}`, data),
|
||||||
|
webauthnChallenge: (data) => post('/auth/webauthn/challenge', data),
|
||||||
|
|
||||||
// Admin
|
// Admin
|
||||||
hasUsers: () => get('/admin/has-users'),
|
hasUsers: () => get('/admin/has-users'),
|
||||||
adminUsers: () => get('/admin/users'),
|
adminUsers: () => get('/admin/users'),
|
||||||
|
|
|
||||||
|
|
@ -2942,6 +2942,48 @@ function runMigrations() {
|
||||||
`);
|
`);
|
||||||
console.log('[v0.91] composite indexes created on categories, bills, payments');
|
console.log('[v0.91] composite indexes created on categories, bills, payments');
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 'v0.92',
|
||||||
|
description: 'auth: WebAuthn/FIDO2 security key support — webauthn_credentials + webauthn_challenges tables',
|
||||||
|
dependsOn: ['v0.91'],
|
||||||
|
run: function() {
|
||||||
|
const cols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
|
||||||
|
if (!cols.includes('webauthn_enabled'))
|
||||||
|
db.exec('ALTER TABLE users ADD COLUMN webauthn_enabled INTEGER NOT NULL DEFAULT 0');
|
||||||
|
if (!cols.includes('webauthn_user_id'))
|
||||||
|
db.exec('ALTER TABLE users ADD COLUMN webauthn_user_id TEXT');
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS webauthn_credentials (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
credential_id TEXT NOT NULL UNIQUE,
|
||||||
|
public_key TEXT NOT NULL,
|
||||||
|
sign_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
transports TEXT,
|
||||||
|
backup_eligible INTEGER NOT NULL DEFAULT 0,
|
||||||
|
backup_state INTEGER NOT NULL DEFAULT 0,
|
||||||
|
credential_name TEXT NOT NULL DEFAULT 'Security Key',
|
||||||
|
aaguid TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_webauthn_creds_user ON webauthn_credentials(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS webauthn_challenges (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
challenge_type TEXT NOT NULL CHECK(challenge_type IN ('registration','authentication','login')),
|
||||||
|
challenge TEXT NOT NULL DEFAULT '',
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_user ON webauthn_challenges(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires ON webauthn_challenges(expires_at);
|
||||||
|
`);
|
||||||
|
console.log('[v0.92] WebAuthn tables + users columns added');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "bill-tracker",
|
"name": "bill-tracker",
|
||||||
"version": "0.36.0",
|
"version": "0.36.1",
|
||||||
"description": "Monthly bill tracking system",
|
"description": "Monthly bill tracking system",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
@ -40,6 +40,8 @@
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"nodemailer": "^8.0.9",
|
"nodemailer": "^8.0.9",
|
||||||
"openid-client": "^5.7.1",
|
"openid-client": "^5.7.1",
|
||||||
|
"@simplewebauthn/browser": "^13.0.0",
|
||||||
|
"@simplewebauthn/server": "^13.0.0",
|
||||||
"otplib": "^13.4.1",
|
"otplib": "^13.4.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
|
|
||||||
154
routes/auth.js
154
routes/auth.js
|
|
@ -424,4 +424,158 @@ router.post('/users', requireAuth, requireAdmin, async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── WebAuthn / FIDO2 security key ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
const {
|
||||||
|
createRegistrationChallenge, verifyRegistration,
|
||||||
|
createAuthenticationChallenge, verifyAuthentication,
|
||||||
|
consumeLoginChallenge, getCredentials, deleteCredential,
|
||||||
|
} = require('../services/webauthnService');
|
||||||
|
|
||||||
|
// GET /api/auth/webauthn/status
|
||||||
|
router.get('/webauthn/status', requireAuth, (req, 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, 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, res) => {
|
||||||
|
if (req.singleUserMode)
|
||||||
|
return res.status(400).json(standardizeError('WebAuthn is not available in single-user mode.', 'VALIDATION_ERROR'));
|
||||||
|
try {
|
||||||
|
const { options, challengeId } = await createRegistrationChallenge(getDb(), req.user.id, req.user.username);
|
||||||
|
res.json({ options, challengeId });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[webauthn/setup]', err);
|
||||||
|
res.status(500).json(standardizeError('Failed to generate setup options', 'SERVER_ERROR'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/auth/webauthn/enable — complete registration
|
||||||
|
router.post('/webauthn/enable', requireAuth, async (req, res) => {
|
||||||
|
if (req.singleUserMode)
|
||||||
|
return res.status(400).json(standardizeError('WebAuthn is not available in single-user mode.', 'VALIDATION_ERROR'));
|
||||||
|
const { challengeId, response, credential_name } = req.body || {};
|
||||||
|
if (!challengeId || !response)
|
||||||
|
return res.status(400).json(standardizeError('challengeId and response are required', 'VALIDATION_ERROR'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
const result = await verifyRegistration(db, req.user.id, challengeId, response, credential_name);
|
||||||
|
if (!result.verified)
|
||||||
|
return res.status(400).json(standardizeError(result.error || 'Registration failed', 'VALIDATION_ERROR'));
|
||||||
|
|
||||||
|
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 });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[webauthn/enable]', err);
|
||||||
|
res.status(500).json(standardizeError('Registration failed', 'SERVER_ERROR'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/auth/webauthn/credentials/:credentialId — remove one key
|
||||||
|
router.delete('/webauthn/credentials/:credentialId', requireAuth, async (req, res) => {
|
||||||
|
const { password } = req.body || {};
|
||||||
|
if (!password)
|
||||||
|
return res.status(400).json(standardizeError('password is required', 'VALIDATION_ERROR'));
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const user = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.user.id);
|
||||||
|
try {
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
if (!await bcrypt.compare(password, user.password_hash))
|
||||||
|
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'));
|
||||||
|
|
||||||
|
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 });
|
||||||
|
} catch (err) {
|
||||||
|
console.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
|
||||||
|
router.post('/webauthn/disable', requireAuth, async (req, res) => {
|
||||||
|
const { password } = req.body || {};
|
||||||
|
if (!password)
|
||||||
|
return res.status(400).json(standardizeError('password is required', 'VALIDATION_ERROR'));
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const user = db.prepare('SELECT password_hash, webauthn_enabled FROM users WHERE id = ?').get(req.user.id);
|
||||||
|
if (!user?.webauthn_enabled)
|
||||||
|
return res.status(400).json(standardizeError('WebAuthn is not enabled.', 'VALIDATION_ERROR'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
if (!await bcrypt.compare(password, user.password_hash))
|
||||||
|
return res.status(401).json(standardizeError('Password is incorrect', 'AUTH_ERROR'));
|
||||||
|
|
||||||
|
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 });
|
||||||
|
} catch (err) {
|
||||||
|
console.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.
|
||||||
|
// Mirrors POST /totp/challenge exactly.
|
||||||
|
router.post('/webauthn/challenge', async (req, res) => {
|
||||||
|
req.csrfSkip = true;
|
||||||
|
const { challenge_token, response } = req.body || {};
|
||||||
|
if (!challenge_token || !response)
|
||||||
|
return res.status(400).json(standardizeError('challenge_token and response are required', 'VALIDATION_ERROR'));
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const session = consumeLoginChallenge(db, challenge_token);
|
||||||
|
if (!session)
|
||||||
|
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);
|
||||||
|
if (!user) return res.status(401).json(standardizeError('User not found.', 'AUTH_ERROR'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await verifyAuthentication(db, session.userId, session.authChallengeId, response);
|
||||||
|
if (!result.verified) {
|
||||||
|
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'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { createSession } = require('../services/authService');
|
||||||
|
const s = await createSession(session.userId);
|
||||||
|
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') });
|
||||||
|
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) {
|
||||||
|
console.error('[webauthn/challenge]', err);
|
||||||
|
res.status(500).json(standardizeError('Login failed', 'SERVER_ERROR'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,14 @@ async function login(username, password) {
|
||||||
return { requires_totp: true, challenge_token: challengeToken };
|
return { requires_totp: true, challenge_token: challengeToken };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WebAuthn is enabled — issue a WebAuthn authentication challenge instead
|
||||||
|
if (user.webauthn_enabled) {
|
||||||
|
const { createAuthenticationChallenge, createLoginChallenge } = require('./webauthnService');
|
||||||
|
const { options, challengeId } = await createAuthenticationChallenge(getDb(), user.id);
|
||||||
|
const loginToken = createLoginChallenge(getDb(), user.id, challengeId);
|
||||||
|
return { requires_webauthn: true, challenge_token: loginToken, webauthn_options: options };
|
||||||
|
}
|
||||||
|
|
||||||
// Clean up expired sessions for this user before creating new session
|
// Clean up expired sessions for this user before creating new session
|
||||||
try {
|
try {
|
||||||
db.prepare("DELETE FROM sessions WHERE user_id = ? AND expires_at < datetime('now')").run(user.id);
|
db.prepare("DELETE FROM sessions WHERE user_id = ? AND expires_at < datetime('now')").run(user.id);
|
||||||
|
|
@ -179,6 +187,7 @@ function publicUser(u) {
|
||||||
must_change_password: !!u.must_change_password,
|
must_change_password: !!u.must_change_password,
|
||||||
first_login: !!u.first_login,
|
first_login: !!u.first_login,
|
||||||
last_seen_version: u.last_seen_version || null,
|
last_seen_version: u.last_seen_version || null,
|
||||||
|
webauthn_enabled: !!u.webauthn_enabled,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,220 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const {
|
||||||
|
generateRegistrationOptions,
|
||||||
|
verifyRegistrationResponse,
|
||||||
|
generateAuthenticationOptions,
|
||||||
|
verifyAuthenticationResponse,
|
||||||
|
} = require('@simplewebauthn/server');
|
||||||
|
|
||||||
|
const APP_NAME = 'Bill Tracker';
|
||||||
|
const CHALLENGE_TTL_MS = 15 * 60 * 1000;
|
||||||
|
|
||||||
|
function getRpId() {
|
||||||
|
return process.env.WEBAUTHN_RP_ID || 'localhost';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrigin() {
|
||||||
|
const o = process.env.WEBAUTHN_ORIGIN;
|
||||||
|
if (o) return o;
|
||||||
|
const port = process.env.PORT || 3000;
|
||||||
|
return `http://localhost:${port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Registration ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function createRegistrationChallenge(db, userId, username) {
|
||||||
|
let { webauthn_user_id } = db.prepare('SELECT webauthn_user_id FROM users WHERE id = ?').get(userId) || {};
|
||||||
|
|
||||||
|
if (!webauthn_user_id) {
|
||||||
|
webauthn_user_id = crypto.randomBytes(32).toString('base64url');
|
||||||
|
db.prepare("UPDATE users SET webauthn_user_id = ?, updated_at = datetime('now') WHERE id = ?")
|
||||||
|
.run(webauthn_user_id, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exclude already-registered credentials so the authenticator won't re-register them
|
||||||
|
const existing = db.prepare('SELECT credential_id FROM webauthn_credentials WHERE user_id = ?').all(userId);
|
||||||
|
|
||||||
|
const options = await generateRegistrationOptions({
|
||||||
|
rpName: APP_NAME,
|
||||||
|
rpID: getRpId(),
|
||||||
|
userID: Buffer.from(webauthn_user_id, 'base64url'),
|
||||||
|
userName: username,
|
||||||
|
userDisplayName: username,
|
||||||
|
attestationType: 'none',
|
||||||
|
excludeCredentials: existing.map(c => ({ id: c.credential_id })),
|
||||||
|
authenticatorSelection: {
|
||||||
|
residentKey: 'preferred',
|
||||||
|
userVerification: 'preferred',
|
||||||
|
},
|
||||||
|
supportedAlgorithmIDs: [-7, -257],
|
||||||
|
});
|
||||||
|
|
||||||
|
const challengeId = crypto.randomUUID();
|
||||||
|
const expiresAt = new Date(Date.now() + CHALLENGE_TTL_MS).toISOString().slice(0, 19).replace('T', ' ');
|
||||||
|
|
||||||
|
db.prepare("DELETE FROM webauthn_challenges WHERE user_id = ? AND challenge_type = 'registration'").run(userId);
|
||||||
|
db.prepare('INSERT INTO webauthn_challenges (id, user_id, challenge_type, challenge, expires_at) VALUES (?, ?, ?, ?, ?)')
|
||||||
|
.run(challengeId, userId, 'registration', options.challenge, expiresAt);
|
||||||
|
|
||||||
|
return { options, challengeId };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyRegistration(db, userId, challengeId, response, credentialName) {
|
||||||
|
const row = db.prepare(
|
||||||
|
"SELECT challenge FROM webauthn_challenges WHERE id = ? AND user_id = ? AND challenge_type = 'registration' AND expires_at > datetime('now')"
|
||||||
|
).get(challengeId, userId);
|
||||||
|
if (!row) return { verified: false, error: 'Challenge expired or invalid' };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const verification = await verifyRegistrationResponse({
|
||||||
|
response,
|
||||||
|
expectedChallenge: row.challenge,
|
||||||
|
expectedOrigin: getOrigin(),
|
||||||
|
expectedRPID: getRpId(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!verification.verified) return { verified: false, error: 'Registration verification failed' };
|
||||||
|
|
||||||
|
const { credential, aaguid, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO webauthn_credentials
|
||||||
|
(user_id, credential_id, public_key, sign_count, transports, backup_eligible, backup_state, credential_name, aaguid)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
userId,
|
||||||
|
credential.id,
|
||||||
|
Buffer.from(credential.publicKey).toString('base64url'),
|
||||||
|
credential.counter,
|
||||||
|
credential.transports ? JSON.stringify(credential.transports) : null,
|
||||||
|
credentialDeviceType === 'multiDevice' ? 1 : 0,
|
||||||
|
credentialBackedUp ? 1 : 0,
|
||||||
|
credentialName || 'Security Key',
|
||||||
|
aaguid || null,
|
||||||
|
);
|
||||||
|
|
||||||
|
db.prepare('DELETE FROM webauthn_challenges WHERE id = ?').run(challengeId);
|
||||||
|
|
||||||
|
return { verified: true, credentialId: credential.id };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[webauthn] registration error:', err.message);
|
||||||
|
return { verified: false, error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Authentication ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function createAuthenticationChallenge(db, userId) {
|
||||||
|
const credentials = db.prepare('SELECT credential_id, transports FROM webauthn_credentials WHERE user_id = ?').all(userId);
|
||||||
|
if (!credentials.length) throw new Error('No registered WebAuthn credentials');
|
||||||
|
|
||||||
|
const options = await generateAuthenticationOptions({
|
||||||
|
rpID: getRpId(),
|
||||||
|
allowCredentials: credentials.map(c => ({
|
||||||
|
id: c.credential_id,
|
||||||
|
transports: c.transports ? JSON.parse(c.transports) : undefined,
|
||||||
|
})),
|
||||||
|
userVerification: 'preferred',
|
||||||
|
});
|
||||||
|
|
||||||
|
const challengeId = crypto.randomUUID();
|
||||||
|
const expiresAt = new Date(Date.now() + CHALLENGE_TTL_MS).toISOString().slice(0, 19).replace('T', ' ');
|
||||||
|
|
||||||
|
db.prepare("DELETE FROM webauthn_challenges WHERE user_id = ? AND challenge_type = 'authentication'").run(userId);
|
||||||
|
db.prepare('INSERT INTO webauthn_challenges (id, user_id, challenge_type, challenge, expires_at) VALUES (?, ?, ?, ?, ?)')
|
||||||
|
.run(challengeId, userId, 'authentication', options.challenge, expiresAt);
|
||||||
|
|
||||||
|
return { options, challengeId };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyAuthentication(db, userId, challengeId, response) {
|
||||||
|
const row = db.prepare(
|
||||||
|
"SELECT challenge FROM webauthn_challenges WHERE id = ? AND user_id = ? AND challenge_type = 'authentication' AND expires_at > datetime('now')"
|
||||||
|
).get(challengeId, userId);
|
||||||
|
if (!row) return { verified: false, error: 'Challenge expired or invalid' };
|
||||||
|
|
||||||
|
const cred = db.prepare('SELECT credential_id, public_key, sign_count FROM webauthn_credentials WHERE user_id = ? AND credential_id = ?')
|
||||||
|
.get(userId, response.id);
|
||||||
|
if (!cred) return { verified: false, error: 'Credential not registered to this user' };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const verification = await verifyAuthenticationResponse({
|
||||||
|
response,
|
||||||
|
expectedChallenge: row.challenge,
|
||||||
|
expectedOrigin: getOrigin(),
|
||||||
|
expectedRPID: getRpId(),
|
||||||
|
credential: {
|
||||||
|
id: cred.credential_id,
|
||||||
|
publicKey: Buffer.from(cred.public_key, 'base64url'),
|
||||||
|
counter: cred.sign_count,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!verification.verified) return { verified: false, error: 'Authentication failed' };
|
||||||
|
|
||||||
|
// Update sign count to detect cloned authenticators
|
||||||
|
db.prepare("UPDATE webauthn_credentials SET sign_count = ?, updated_at = datetime('now') WHERE user_id = ? AND credential_id = ?")
|
||||||
|
.run(verification.authenticationInfo.newCounter, userId, cred.credential_id);
|
||||||
|
|
||||||
|
db.prepare('DELETE FROM webauthn_challenges WHERE id = ?').run(challengeId);
|
||||||
|
|
||||||
|
return { verified: true };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[webauthn] authentication error:', err.message);
|
||||||
|
return { verified: false, error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Login challenge (mirrors totpService.createChallenge / consumeChallenge) ──
|
||||||
|
// Issued after password passes; consumed when the WebAuthn assertion is verified.
|
||||||
|
|
||||||
|
function createLoginChallenge(db, userId, webauthnChallengeId) {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const expiresAt = new Date(Date.now() + CHALLENGE_TTL_MS).toISOString().slice(0, 19).replace('T', ' ');
|
||||||
|
db.prepare("DELETE FROM webauthn_challenges WHERE user_id = ? AND challenge_type = 'login'").run(userId);
|
||||||
|
// Piggy-back the authentication challengeId in the challenge column for retrieval
|
||||||
|
db.prepare('INSERT INTO webauthn_challenges (id, user_id, challenge_type, challenge, expires_at) VALUES (?, ?, ?, ?, ?)')
|
||||||
|
.run(id, userId, 'login', webauthnChallengeId, expiresAt);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function consumeLoginChallenge(db, loginChallengeId) {
|
||||||
|
const row = db.prepare(
|
||||||
|
"SELECT user_id, challenge FROM webauthn_challenges WHERE id = ? AND challenge_type = 'login' AND expires_at > datetime('now')"
|
||||||
|
).get(loginChallengeId);
|
||||||
|
if (!row) return null;
|
||||||
|
db.prepare('DELETE FROM webauthn_challenges WHERE id = ?').run(loginChallengeId);
|
||||||
|
return { userId: row.user_id, authChallengeId: row.challenge };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Credential management ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getCredentials(db, userId) {
|
||||||
|
return db.prepare(
|
||||||
|
'SELECT id, credential_id, credential_name, aaguid, backup_eligible, backup_state, created_at FROM webauthn_credentials WHERE user_id = ? ORDER BY created_at DESC'
|
||||||
|
).all(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteCredential(db, credentialId, userId) {
|
||||||
|
return db.prepare('DELETE FROM webauthn_credentials WHERE credential_id = ? AND user_id = ?').run(credentialId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cleanup ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function pruneExpiredChallenges(db) {
|
||||||
|
db.prepare("DELETE FROM webauthn_challenges WHERE expires_at <= datetime('now')").run();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createRegistrationChallenge,
|
||||||
|
verifyRegistration,
|
||||||
|
createAuthenticationChallenge,
|
||||||
|
verifyAuthentication,
|
||||||
|
createLoginChallenge,
|
||||||
|
consumeLoginChallenge,
|
||||||
|
getCredentials,
|
||||||
|
deleteCredential,
|
||||||
|
pruneExpiredChallenges,
|
||||||
|
};
|
||||||
|
|
@ -4,6 +4,7 @@ const cron = require('node-cron');
|
||||||
const { getDb } = require('../db/database');
|
const { getDb } = require('../db/database');
|
||||||
const { buildTrackerRow, getCycleRange } = require('../services/statusService');
|
const { buildTrackerRow, getCycleRange } = require('../services/statusService');
|
||||||
const { pruneExpiredSessions } = require('../services/authService');
|
const { pruneExpiredSessions } = require('../services/authService');
|
||||||
|
const { pruneExpiredChallenges: pruneWebAuthnChallenges } = require('../services/webauthnService');
|
||||||
const { runNotifications, runDriftNotifications } = require('../services/notificationService');
|
const { runNotifications, runDriftNotifications } = require('../services/notificationService');
|
||||||
const { runAllCleanup } = require('../services/cleanupService');
|
const { runAllCleanup } = require('../services/cleanupService');
|
||||||
const {
|
const {
|
||||||
|
|
@ -90,6 +91,7 @@ async function runDailyTasks() {
|
||||||
}
|
}
|
||||||
|
|
||||||
pruneExpiredSessions();
|
pruneExpiredSessions();
|
||||||
|
pruneWebAuthnChallenges(db);
|
||||||
|
|
||||||
await runNotifications().catch(err => {
|
await runNotifications().catch(err => {
|
||||||
console.error('[worker] Notification error (non-fatal):', err.message);
|
console.error('[worker] Notification error (non-fatal):', err.message);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue