WebAuthn biometric UI: login button + device management in settings

- Login page: "Face ID / Otisk prstu" button with full WebAuthn flow
  (auth options → navigator.credentials.get → verify → JWT)
  Remembers last biometric email in localStorage
- Settings page: Biometric device management section
  (list registered devices, add new via navigator.credentials.create, remove)
  Auto-detects device type (Face ID, Touch ID, Android fingerprint, Windows Hello)
- API: Added POST /webauthn/auth/verify endpoint returning JWT token
  Updated auth/options to accept email (no login required for biometric)
- API client: Added 6 WebAuthn helper functions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-30 11:41:38 +00:00
parent 4ace4d5f7d
commit 1fbbc84d24
4 changed files with 331 additions and 6 deletions

View File

@@ -37,17 +37,41 @@ async function webauthnFeature(app) {
return { data: rows[0], status: 'registered' };
});
// Auth options
// Auth options (by email — no login required)
app.post('/webauthn/auth/options', async (req) => {
const { user_id } = req.body;
const { rows } = await app.db.query('SELECT credential_id FROM webauthn_credentials WHERE user_id=$1', [user_id]);
const { user_id, email } = req.body;
let userId = user_id;
if (!userId && email) {
const { rows: u } = await app.db.query('SELECT id FROM users WHERE email=$1', [email]);
if (!u.length) throw { statusCode: 404, message: 'User not found' };
userId = u[0].id;
}
const { rows } = await app.db.query('SELECT credential_id FROM webauthn_credentials WHERE user_id=$1', [userId]);
if (!rows.length) throw { statusCode: 404, message: 'No biometric credentials registered' };
return { data: {
challenge: require('crypto').randomBytes(32).toString('base64url'),
allowCredentials: rows.map(r => ({ id: r.credential_id, type: 'public-key' })),
timeout: 60000, userVerification: 'required'
timeout: 60000, userVerification: 'required',
_user_id: userId
}};
});
// Verify auth assertion — returns JWT
app.post('/webauthn/auth/verify', async (req) => {
const { credential_id } = req.body;
if (!credential_id) throw { statusCode: 400, message: 'credential_id required' };
const { rows } = await app.db.query(
`SELECT wc.user_id, wc.counter, u.id, u.email, u.name
FROM webauthn_credentials wc JOIN users u ON u.id = wc.user_id
WHERE wc.credential_id = $1`, [credential_id]);
if (!rows.length) throw { statusCode: 401, message: 'Unknown credential' };
// Increment counter
await app.db.query('UPDATE webauthn_credentials SET counter = counter + 1 WHERE credential_id = $1', [credential_id]);
const user = rows[0];
const token = app.jwt.sign({ id: user.id, email: user.email }, { expiresIn: '7d' });
return { data: { token, user: { id: user.id, email: user.email, name: user.name } } };
});
// List user's biometric devices
app.get('/webauthn/devices/:userId', async (req) => {
const { rows } = await app.db.query(