WIP 🔐 feat: PassKey (#5606)

* added PassKey authentication.

* fixed issue with test :)

* Delete client/src/components/Auth/AuthLayout.tsx

* fix: conflicted issue
This commit is contained in:
Ruben Talstra 2025-02-12 20:40:29 +01:00 committed by GitHub
parent 2a506df443
commit 1cb1c9196d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 569 additions and 12 deletions

4
api/cache/index.js vendored
View file

@ -1,5 +1,7 @@
const keyvFiles = require('./keyvFiles');
const getLogStores = require('./getLogStores');
const logViolation = require('./logViolation');
const mongoUserStore = require('./mongoUserStore');
const mongoChallengeStore = require('./mongoChallengeStore');
module.exports = { ...keyvFiles, getLogStores, logViolation };
module.exports = { ...keyvFiles, getLogStores, logViolation, mongoUserStore, mongoChallengeStore };

35
api/cache/mongoChallengeStore.js vendored Normal file
View file

@ -0,0 +1,35 @@
const ChallengeStore = require('~/models/ChallengeStore');
class MongoChallengeStore {
async get(userId) {
try {
const challenge = await ChallengeStore.findOne({ userId }).lean().exec();
return challenge ? challenge.challenge : undefined;
} catch (error) {
console.error(`❌ Error fetching challenge for userId ${userId}:`, error);
return undefined;
}
}
async save(userId, challenge) {
try {
await ChallengeStore.findOneAndUpdate(
{ userId },
{ challenge, createdAt: new Date() },
{ upsert: true, new: true, setDefaultsOnInsert: true },
).exec();
} catch (error) {
console.error(`❌ Error saving challenge for userId ${userId}:`, error);
}
}
async delete(userId) {
try {
await ChallengeStore.deleteOne({ userId }).exec();
} catch (error) {
console.error(`❌ Error deleting challenge for userId ${userId}:`, error);
}
}
}
module.exports = MongoChallengeStore;

55
api/cache/mongoUserStore.js vendored Normal file
View file

@ -0,0 +1,55 @@
const User = require('~/models');
class MongoUserStore {
async get(identifier, byID = false) {
let user;
if (byID) {
user = await User.getUserById(identifier);
} else {
user = await User.findUser({ email: identifier });
}
if (user) {
return {
id: user._id.toString(),
email: user.email,
passkeys: user.passkeys,
};
}
return undefined;
}
async save(user) {
if (!user.id) {
const createdUser = await User.createUser(
{
email: user.email,
username: user.email,
passkeys: user.passkeys,
},
/* disableTTL */ true,
/* returnUser */ true,
);
return {
id: createdUser._id.toString(),
email: createdUser.email,
passkeys: createdUser.passkeys,
};
} else {
const updatedUser = await User.updateUser(user.id, {
email: user.email,
username: user.email,
passkeys: user.passkeys,
});
if (!updatedUser) {
throw new Error('Failed to update user');
}
return {
id: updatedUser._id.toString(),
email: updatedUser.email,
passkeys: updatedUser.passkeys,
};
}
}
}
module.exports = MongoUserStore;

View file

@ -0,0 +1,6 @@
const mongoose = require('mongoose');
const challengeSchema = require('~/models/schema/challengeSchema');
const ChallengeStore = mongoose.model('Challenge', challengeSchema);
module.exports = ChallengeStore;

View file

@ -0,0 +1,22 @@
const mongoose = require('mongoose');
const challengeSchema = mongoose.Schema({
userId: {
type: String,
required: true,
unique: true,
},
challenge: {
type: String,
required: true,
},
createdAt: {
type: Date,
default: Date.now,
index: {
expires: '5m',
},
},
});
module.exports = challengeSchema;

View file

@ -39,6 +39,13 @@ const Session = mongoose.Schema({
},
});
const passkeySchema = mongoose.Schema({
id: { type: String, required: true },
publicKey: { type: Buffer, required: true },
counter: { type: Number, default: 0 },
transports: { type: [String], default: [] },
});
/** @type {MongooseSchema<MongoUser>} */
const userSchema = mongoose.Schema(
{
@ -117,6 +124,10 @@ const userSchema = mongoose.Schema(
unique: true,
sparse: true,
},
passkeys: {
type: [passkeySchema],
default: [],
},
plugins: {
type: Array,
default: [],

View file

@ -21,6 +21,8 @@ const AppService = require('./services/AppService');
const staticCache = require('./utils/staticCache');
const noIndex = require('./middleware/noIndex');
const routes = require('./routes');
const { WebAuthnStrategy } = require('passport-simple-webauthn2');
const { mongoUserStore, mongoChallengeStore } = require('~/cache');
const { PORT, HOST, ALLOW_SOCIAL_LOGIN, DISABLE_COMPRESSION } = process.env ?? {};
@ -77,11 +79,29 @@ const startServer = async () => {
passport.use(ldapLogin);
}
/* Passkey (WebAuthn) Strategy */
if (process.env.PASSKEY_ENABLED) {
const userStore = new mongoUserStore();
const challengeStore = new mongoChallengeStore();
passport.use(
new WebAuthnStrategy({
rpID: process.env.RP_ID || 'localhost',
rpName: process.env.APP_TITLE || 'LibreChat',
userStore,
challengeStore,
debug: true,
}),
);
}
if (isEnabled(ALLOW_SOCIAL_LOGIN)) {
configureSocialLogins(app);
}
app.use('/oauth', routes.oauth);
app.use('/webauthn', routes.authWebAuthn);
/* API Endpoints */
app.use('/api/auth', routes.auth);
app.use('/api/actions', routes.actions);

View file

@ -0,0 +1,44 @@
const express = require('express');
const passport = require('passport');
const { setAuthTokens } = require('~/server/services/AuthService');
const router = express.Router();
router.get(
'/register',
passport.authenticate('webauthn', { session: false }),
(req, res) => {
res.json(req.user);
},
);
router.post(
'/register',
passport.authenticate('webauthn', { session: false, failureRedirect: '/login' }),
(req, res) => {
res.json({ user: req.user });
},
);
router.get(
'/login',
passport.authenticate('webauthn', { session: false }),
(req, res) => {
res.json(req.user);
},
);
router.post(
'/login',
passport.authenticate('webauthn', { session: false, failureRedirect: '/login' }),
async (req, res) => {
try {
const token = await setAuthTokens(req.user.id, res);
res.status(200).json({ token, user: req.user });
} catch (err) {
console.error('[WebAuthn Login Callback]', err);
res.status(500).json({ message: 'Something went wrong during login' });
}
},
);
module.exports = router;

View file

@ -51,6 +51,7 @@ router.get('/', async function (req, res) {
!!process.env.APPLE_TEAM_ID &&
!!process.env.APPLE_KEY_ID &&
!!process.env.APPLE_PRIVATE_KEY_PATH,
passkeyLoginEnabled : !!process.env.PASSKEY_ENABLED && !!process.env.RP_ID,
openidLoginEnabled:
!!process.env.OPENID_CLIENT_ID &&
!!process.env.OPENID_CLIENT_SECRET &&

View file

@ -1,3 +1,4 @@
const authWebAuthn = require('./authWebAuthn');
const assistants = require('./assistants');
const categories = require('./categories');
const tokenizer = require('./tokenizer');
@ -55,5 +56,6 @@ module.exports = {
assistants,
categories,
staticRoute,
authWebAuthn,
banner,
};