Skip to content

Commit ea1eda6

Browse files
committed
feat: login with passkey (biometrics, face ID, etc)
1 parent 3751679 commit ea1eda6

File tree

12 files changed

+1402
-72
lines changed

12 files changed

+1402
-72
lines changed

README.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ I also tried to make it as **generic** and **reusable** as possible to cover mos
7171
## Features
7272

7373
- Login
74-
- **Local Authentication** Sign in with Email and Password, Passwordless
74+
- **Local Authentication** Sign in with Email and Password, Passwordless, Passkey / Biometrics
7575
- **OAuth 2.0 Authentication:** Sign in with Google, Microsoft, Facebook, LinkedIn, X (Twitter), Twitch, GitHub, Discord
7676
- **User Profile and Account Management**
7777
- Gravatar
@@ -549,13 +549,14 @@ The metadata for Open Graph is only set up for the home page (`home.pug`). Updat
549549
| **controllers**/contact.js | Controller for contact form. |
550550
| **controllers**/home.js | Controller for home page (index). |
551551
| **controllers**/user.js | Controller for user account management. |
552+
| **controllers**/webauthn.js | Controller for webauthn management (passkey / biometrics login) |
552553
| **models**/User.js | Mongoose schema and model for User. |
553554
| **public**/ | Static assets (fonts, css, js, img). |
554555
| **public**/**js**/application.js | Specify client-side JavaScript dependencies. |
555556
| **public**/**js**/app.js | Place your client-side JavaScript here. |
556557
| **public**/**css**/main.scss | Main stylesheet for your app. |
557558
| **test**/\*.js | Tests, related configs and helpers. |
558-
| **views/account**/ | Templates for _login, password reset, signup, profile_. |
559+
| **views/account**/ | Templates for _login, password reset, signup, profile, webauthn_ |
559560
| **views/ai**/ | Templates for AI examples and boilerplates. |
560561
| **views/api**/ | Templates for API examples. |
561562
| **views/partials**/flash.pug | Error, info and success flash notifications. |
@@ -591,6 +592,7 @@ Required to run the project before your modifications
591592
| @googleapis/drive | Google Drive API integration library. |
592593
| @googleapis/sheets | Google Sheets API integration library. |
593594
| @huggingface/inference | Client library for Hugging Face Inference providers |
595+
| @keyv/mongo | MongoDB storage adapter for Keyv |
594596
| @langchain/community | Third party integrations for Langchain |
595597
| @langchain/core | Base LangChain abstractions and Expression Language |
596598
| @langchain/mongodb | MongoDB integrations for LangChain |
@@ -600,6 +602,8 @@ Required to run the project before your modifications
600602
| @octokit/rest | GitHub API library. |
601603
| @passport-js/passport-twitter | X (Twitter) login support (OAuth 2). |
602604
| @popperjs/core | Frontend js library for poppers and tooltips. |
605+
| @simplewebauthn/browser | WebAuthn frontend library (passkey / biometrics authentication) |
606+
| @simplewebauthn/server | WebAuthn backend library (passkey / biometrics authentication) |
603607
| bootstrap | CSS Framework. |
604608
| bootstrap-social | Social buttons library. |
605609
| bowser | User agent parser |
@@ -613,6 +617,7 @@ Required to run the project before your modifications
613617
| express-rate-limit | Rate limiting middleware for abuse protection. |
614618
| express-session | Simple session middleware for Express. |
615619
| jquery | Front-end JS library to interact with HTML elements. |
620+
| keyv | key-value storage with support for multiple backends |
616621
| langchain | Framework for developing LLM applications |
617622
| lastfm | Last.fm API library. |
618623
| lusca | CSRF middleware. |

app.js

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ const userController = require('./controllers/user');
7272
const apiController = require('./controllers/api');
7373
const aiController = require('./controllers/ai');
7474
const contactController = require('./controllers/contact');
75+
const webauthnController = require('./controllers/webauthn');
7576

7677
/**
7778
* API keys and Passport configuration.
@@ -149,7 +150,7 @@ app.use((req, res, next) => {
149150
const isSafeRedirect = (url) => /^\/[a-zA-Z0-9/_-]*$/.test(url);
150151
app.use((req, res, next) => {
151152
// After successful login, redirect back to the intended page
152-
if (!req.user && req.path !== '/login' && req.path !== '/signup' && !req.path.startsWith('/auth') && !req.path.includes('.')) {
153+
if (!req.user && req.path !== '/login' && !req.path.startsWith('/login/webauthn-') && req.path !== '/signup' && !req.path.startsWith('/auth') && !req.path.includes('.')) {
153154
const returnTo = req.originalUrl;
154155
if (isSafeRedirect(returnTo)) {
155156
req.session.returnTo = returnTo;
@@ -175,6 +176,7 @@ app.use('/js/lib', express.static(path.join(__dirname, 'node_modules/chart.js/di
175176
app.use('/js/lib', express.static(path.join(__dirname, 'node_modules/@popperjs/core/dist/umd'), { maxAge: 31557600000 }));
176177
app.use('/js/lib', express.static(path.join(__dirname, 'node_modules/bootstrap/dist/js'), { maxAge: 31557600000 }));
177178
app.use('/js/lib', express.static(path.join(__dirname, 'node_modules/jquery/dist'), { maxAge: 31557600000 }));
179+
app.use('/js/lib', express.static(path.join(__dirname, 'node_modules/@simplewebauthn/browser/dist/bundle'), { maxAge: 31557600000 }));
178180
app.use('/webfonts', express.static(path.join(__dirname, 'node_modules/@fortawesome/fontawesome-free/webfonts'), { maxAge: 31557600000 }));
179181
app.use('/image-cache', express.static(path.join(__dirname, 'tmp/image-cache'), { maxAge: 31557600000 }));
180182

@@ -192,6 +194,9 @@ app.get('/', homeController.index);
192194
app.get('/login', userController.getLogin);
193195
app.post('/login', loginLimiter, userController.postLogin);
194196
app.get('/login/verify/:token', loginLimiter, userController.getLoginByEmail);
197+
app.post('/login/webauthn-start', loginLimiter, webauthnController.postLoginStart);
198+
app.get('/login/webauthn-start', (req, res) => res.redirect('/login')); // webauthn-start requires a POST
199+
app.post('/login/webauthn-verify', loginLimiter, webauthnController.postLoginVerify);
195200
app.get('/logout', userController.logout);
196201
app.get('/forgot', userController.getForgot);
197202
app.post('/forgot', strictLimiter, userController.postForgot);
@@ -209,6 +214,10 @@ app.post('/account/password', passportConfig.isAuthenticated, userController.pos
209214
app.post('/account/delete', passportConfig.isAuthenticated, userController.postDeleteAccount);
210215
app.post('/account/logout-everywhere', passportConfig.isAuthenticated, userController.postLogoutEverywhere);
211216
app.get('/account/unlink/:provider', passportConfig.isAuthenticated, userController.getOauthUnlink);
217+
app.post('/account/webauthn/register', passportConfig.isAuthenticated, webauthnController.postRegisterStart);
218+
app.get('/account/webauthn/register', (req, res) => res.redirect('/account')); // webauthn/register start requires a POST
219+
app.post('/account/webauthn/verify', passportConfig.isAuthenticated, webauthnController.postRegisterVerify);
220+
app.post('/account/webauthn/remove', passportConfig.isAuthenticated, webauthnController.postRemove);
212221

213222
/**
214223
* API examples routes.

controllers/webauthn.js

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
const crypto = require('node:crypto');
2+
const { generateRegistrationOptions, verifyRegistrationResponse, generateAuthenticationOptions, verifyAuthenticationResponse } = require('@simplewebauthn/server');
3+
const User = require('../models/User');
4+
5+
function generateDefaultPublicKey() {
6+
// Dummy COSE public key used to force uniform WebAuthn verification on failed logins.
7+
const { publicKey } = crypto.generateKeyPairSync('ec', {
8+
namedCurve: 'P-256',
9+
publicKeyEncoding: { format: 'jwk' },
10+
});
11+
const x = Buffer.from(publicKey.x, 'base64url'); // 32 bytes
12+
const y = Buffer.from(publicKey.y, 'base64url'); // 32 bytes
13+
// COSE_Key: map(5) {1:2, 3:-7, -1:1, -2:x, -3:y}
14+
return Buffer.concat([Buffer.from([0xa5, 0x01, 0x02, 0x03, 0x26, 0x20, 0x01, 0x21, 0x58, 0x20]), x, Buffer.from([0x22, 0x58, 0x20]), y]);
15+
}
16+
const DUMMY_COSE_PUBLIC_KEY = generateDefaultPublicKey();
17+
18+
const rpName = 'Hackathon Starter';
19+
const rpID = new URL(process.env.BASE_URL).hostname;
20+
const expectedOrigin = new URL(process.env.BASE_URL).origin;
21+
22+
/**
23+
* POST /login/webauthn-start
24+
*/
25+
exports.postLoginStart = async (req, res) => {
26+
try {
27+
const { email, useEmailWithBiometrics } = req.body;
28+
req.session.webauthnLoginEmail = useEmailWithBiometrics && email ? email.toLowerCase().trim() : null;
29+
const options = await generateAuthenticationOptions({
30+
rpID,
31+
userVerification: 'preferred',
32+
});
33+
req.session.loginChallenge = options.challenge;
34+
res.render('account/webauthn-login', {
35+
title: 'Biometric Login',
36+
publicKey: JSON.stringify(options),
37+
});
38+
} catch (err) {
39+
console.error('Error in postLoginStart:', err);
40+
req.flash('errors', { msg: 'Passkey / Biometric Failure.' });
41+
res.redirect('/login');
42+
}
43+
};
44+
45+
/**
46+
* POST /login/webauthn-verify
47+
*/
48+
exports.postLoginVerify = async (req, res) => {
49+
try {
50+
let noUserFound = false;
51+
const { credential } = req.body;
52+
const expectedChallenge = req.session.loginChallenge;
53+
const scopedEmail = req.session.webauthnLoginEmail;
54+
delete req.session.webauthnLoginEmail;
55+
if (!credential || !expectedChallenge) {
56+
delete req.session.loginChallenge;
57+
req.flash('errors', { msg: 'Passkey / Biometric authentication failed - invalid request.' });
58+
return res.redirect('/login');
59+
}
60+
const parsedCredential = JSON.parse(credential);
61+
const credentialId = Buffer.from(parsedCredential.id, 'base64url');
62+
const user = await User.findOne({ 'webauthnCredentials.credentialId': credentialId });
63+
let userCredential;
64+
if (!user) {
65+
noUserFound = true;
66+
userCredential = { credentialId: credentialId, publicKey: DUMMY_COSE_PUBLIC_KEY, counter: 0, transports: [] };
67+
} else {
68+
userCredential = user.webauthnCredentials.find((c) => c.credentialId.equals(credentialId));
69+
}
70+
const verification = await verifyAuthenticationResponse({
71+
response: parsedCredential,
72+
expectedChallenge,
73+
expectedOrigin,
74+
expectedRPID: rpID,
75+
requireUserVerification: false,
76+
credential: {
77+
id: userCredential.credentialId,
78+
publicKey: userCredential.publicKey,
79+
counter: userCredential.counter,
80+
transports: userCredential.transports,
81+
},
82+
});
83+
delete req.session.loginChallenge;
84+
if (!verification.verified || noUserFound || (scopedEmail && user.email !== scopedEmail)) {
85+
if (scopedEmail) {
86+
req.flash('errors', { msg: 'Passkey / Biometric authentication failed, or did not match the provided email.' });
87+
} else {
88+
req.flash('errors', { msg: 'Passkey / Biometric authentication failed.' });
89+
}
90+
return res.redirect('/login');
91+
}
92+
userCredential.counter = verification.authenticationInfo.newCounter;
93+
userCredential.lastUsedAt = new Date();
94+
await user.save();
95+
req.logIn(user, (err) => {
96+
if (err) {
97+
console.error('Error in postLoginVerify - Login session error:', err);
98+
req.flash('errors', { msg: 'Login failed. Please try again.' });
99+
return res.redirect('/login');
100+
}
101+
req.flash('success', { msg: 'Success! You are logged in.' });
102+
res.redirect(req.session.returnTo || '/');
103+
});
104+
} catch (err) {
105+
console.error('Error in postLoginVerify:', err);
106+
delete req.session.loginChallenge;
107+
req.flash('errors', { msg: 'Passkey / Biometric authentication failed - system error.' });
108+
res.redirect('/login');
109+
}
110+
};
111+
112+
/**
113+
* POST /account/webauthn/register
114+
*/
115+
exports.postRegisterStart = async (req, res) => {
116+
try {
117+
const { user } = req;
118+
if (!user.emailVerified) {
119+
req.flash('errors', { msg: 'Please verify your email address before enabling passkey login.' });
120+
return res.redirect('/account');
121+
}
122+
if (!user.webauthnUserID) {
123+
user.webauthnUserID = crypto.randomBytes(32);
124+
await user.save();
125+
}
126+
const existingCredentials = (user.webauthnCredentials || []).map((cred) => ({
127+
id: cred.credentialId,
128+
type: 'public-key',
129+
transports: cred.transports,
130+
}));
131+
const options = await generateRegistrationOptions({
132+
rpName,
133+
rpID,
134+
userID: user.webauthnUserID,
135+
userName: user.email,
136+
userDisplayName: user.profile?.name || user.email,
137+
excludeCredentials: existingCredentials,
138+
authenticatorSelection: {
139+
residentKey: 'discouraged',
140+
userVerification: 'preferred',
141+
},
142+
});
143+
req.session.registerChallenge = options.challenge;
144+
res.render('account/webauthn-register', {
145+
title: 'Enable Biometric Login',
146+
publicKey: JSON.stringify(options),
147+
});
148+
} catch (err) {
149+
console.error('Error in postRegisterStart:', err);
150+
req.flash('errors', { msg: 'Failed to start passkey registration. Please try again.' });
151+
res.redirect('/account');
152+
}
153+
};
154+
155+
/**
156+
* POST /account/webauthn/verify
157+
*/
158+
exports.postRegisterVerify = async (req, res) => {
159+
try {
160+
if (!req.user.emailVerified) {
161+
req.flash('errors', { msg: 'Please verify your email address before enabling passkey login.' });
162+
return res.redirect('/account');
163+
}
164+
const { credential } = req.body;
165+
const expectedChallenge = req.session.registerChallenge;
166+
if (!credential || !expectedChallenge) {
167+
delete req.session.registerChallenge;
168+
req.flash('errors', { msg: 'Registration failed. Please try again.' });
169+
return res.redirect('/account');
170+
}
171+
const parsedCredential = JSON.parse(credential);
172+
const verification = await verifyRegistrationResponse({
173+
response: parsedCredential,
174+
expectedChallenge,
175+
expectedOrigin,
176+
expectedRPID: rpID,
177+
requireUserVerification: false,
178+
});
179+
delete req.session.registerChallenge;
180+
if (!verification?.verified || !verification.registrationInfo?.credential) {
181+
req.flash('errors', { msg: 'Registration failed. Please try again.' });
182+
return res.redirect('/account');
183+
}
184+
const c = verification.registrationInfo.credential;
185+
if (!c.id || !c.publicKey) {
186+
console.error('Error in postRegisterVerify - registrationInfo payload:', verification.registrationInfo);
187+
req.flash('errors', { msg: 'Registration failed. Please try again.' });
188+
return res.redirect('/account');
189+
}
190+
req.user.webauthnCredentials = Array.isArray(req.user.webauthnCredentials) ? req.user.webauthnCredentials : [];
191+
192+
const newCredentialId = Buffer.from(c.id, 'base64url');
193+
const alreadyOnUser = req.user.webauthnCredentials.some((cred) => Buffer.isBuffer(cred.credentialId) && cred.credentialId.equals(newCredentialId));
194+
if (alreadyOnUser) {
195+
req.flash('errors', { msg: 'This passkey is already registered to your account.' });
196+
return res.redirect('/account');
197+
}
198+
199+
req.user.webauthnCredentials.push({
200+
credentialId: newCredentialId,
201+
publicKey: Buffer.from(c.publicKey),
202+
counter: typeof c.counter === 'number' ? c.counter : 0,
203+
transports: Array.isArray(c.transports) ? c.transports : [],
204+
deviceType: verification.registrationInfo.credentialDeviceType,
205+
backedUp: Boolean(verification.registrationInfo.credentialBackedUp),
206+
deviceName: 'Biometric Device',
207+
createdAt: new Date(),
208+
lastUsedAt: new Date(),
209+
});
210+
try {
211+
await req.user.save();
212+
} catch (err) {
213+
if (err.code === 11000) {
214+
req.flash('errors', { msg: 'This passkey is already registered to an account.' });
215+
return res.redirect('/account');
216+
}
217+
throw err;
218+
}
219+
req.flash('success', { msg: 'Biometric login has been enabled successfully.' });
220+
return res.redirect('/account');
221+
} catch (err) {
222+
console.error('Error in postRegisterVerify:', err);
223+
delete req.session.registerChallenge;
224+
req.flash('errors', { msg: 'Registration failed. Please try again.' });
225+
return res.redirect('/account');
226+
}
227+
};
228+
229+
/**
230+
* POST /account/webauthn/remove
231+
*/
232+
exports.postRemove = async (req, res) => {
233+
try {
234+
req.user.webauthnCredentials = [];
235+
req.user.webauthnUserID = undefined;
236+
await req.user.save();
237+
req.flash('success', { msg: 'Biometric login has been removed successfully.' });
238+
res.redirect('/account');
239+
} catch (err) {
240+
console.error('Error in postRemove:', err);
241+
req.flash('errors', { msg: 'Failed to remove biometric login. Please try again.' });
242+
res.redirect('/account');
243+
}
244+
};

models/User.js

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,21 @@ const userSchema = new mongoose.Schema(
2020
loginExpires: Date,
2121
loginIpHash: String,
2222

23+
webauthnUserID: { type: Buffer, minlength: 16, maxlength: 64 },
24+
webauthnCredentials: [
25+
{
26+
credentialId: { type: Buffer, required: true },
27+
publicKey: { type: Buffer, required: true },
28+
counter: { type: Number, required: true, default: 0 },
29+
transports: { type: [String], default: [] },
30+
deviceType: String,
31+
backedUp: Boolean,
32+
deviceName: String,
33+
createdAt: { type: Date, default: Date.now },
34+
lastUsedAt: { type: Date, default: Date.now },
35+
},
36+
],
37+
2338
discord: String,
2439
facebook: String,
2540
github: String,
@@ -46,7 +61,10 @@ const userSchema = new mongoose.Schema(
4661
{ timestamps: true },
4762
);
4863

49-
// Indexes for verification fileds that are queried
64+
// Webauthn credential Id should be globally unique across all users
65+
userSchema.index({ 'webauthnCredentials.credentialId': 1 }, { unique: true, sparse: true });
66+
67+
// Indexes for verification fields that are queried
5068
userSchema.index({ passwordResetToken: 1 });
5169
userSchema.index({ emailVerificationToken: 1 });
5270
userSchema.index({ loginToken: 1 });

0 commit comments

Comments
 (0)