479 lines
No EOL
14 KiB
JavaScript
479 lines
No EOL
14 KiB
JavaScript
// src-ssr/routes/auth.js
|
|
import express from 'express';
|
|
import {
|
|
generateRegistrationOptions,
|
|
verifyRegistrationResponse,
|
|
generateAuthenticationOptions,
|
|
verifyAuthenticationResponse,
|
|
} from '@simplewebauthn/server';
|
|
import { isoBase64URL } from '@simplewebauthn/server/helpers'; // Ensure this is imported if not already
|
|
import prisma from '../database.js';
|
|
import { rpID, rpName, origin, challengeStore } from '../server.js'; // Import RP details and challenge store
|
|
import { getSetting } from '../utils/settings.js';
|
|
|
|
const router = express.Router();
|
|
|
|
// Helper function to get user authenticators
|
|
async function getUserAuthenticators(userId)
|
|
{
|
|
return prisma.authenticator.findMany({
|
|
where: { userId },
|
|
select: {
|
|
credentialID: true,
|
|
credentialPublicKey: true,
|
|
counter: true,
|
|
transports: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
// Helper function to get a user by username
|
|
async function getUserByUsername(username)
|
|
{
|
|
return prisma.user.findUnique({ where: { username } });
|
|
}
|
|
|
|
// Helper function to get a user by ID
|
|
async function getUserById(id)
|
|
{
|
|
return prisma.user.findUnique({ where: { id } });
|
|
}
|
|
|
|
// Helper function to get an authenticator by credential ID
|
|
async function getAuthenticatorByCredentialID(credentialID)
|
|
{
|
|
return prisma.authenticator.findUnique({ where: { credentialID } });
|
|
}
|
|
|
|
|
|
// Generate Registration Options
|
|
router.post('/generate-registration-options', async(req, res) =>
|
|
{
|
|
// Destructure username, email, and fullName from the request body
|
|
const { username, email, fullName, registrationToken } = req.body;
|
|
|
|
if (!username)
|
|
{
|
|
return res.status(400).json({ error: 'Username is required' });
|
|
}
|
|
|
|
//Check if the registrationToken matches the setting
|
|
const registrationTokenSetting = await getSetting('REGISTRATION_TOKEN');
|
|
|
|
if (registrationTokenSetting !== registrationToken)
|
|
{
|
|
return res.status(403).json({ error: 'Invalid registration token' });
|
|
}
|
|
|
|
try
|
|
{
|
|
let user = await getUserByUsername(username);
|
|
|
|
// If user doesn't exist, create one with the provided details
|
|
if (!user)
|
|
{
|
|
const userData = { username };
|
|
if (email) userData.email = email; // Add email if provided
|
|
if (fullName) userData.fullName = fullName; // Add fullName if provided
|
|
|
|
user = await prisma.user.create({
|
|
data: userData,
|
|
});
|
|
}
|
|
|
|
const userAuthenticators = await getUserAuthenticators(user.id);
|
|
|
|
if(userAuthenticators.length > 0)
|
|
{
|
|
//The user is trying to register a new authenticator, so we need to check if the user registering is the same as the one in the session
|
|
if (!req.session.loggedInUserId || req.session.loggedInUserId !== user.id)
|
|
{
|
|
return res.status(403).json({ error: 'Invalid registration attempt.' });
|
|
}
|
|
}
|
|
|
|
const options = await generateRegistrationOptions({
|
|
rpName,
|
|
rpID,
|
|
userName: user.username,
|
|
// Don't prompt users for additional authenticators if they've already registered some
|
|
excludeCredentials: userAuthenticators.map(auth => ({
|
|
id: auth.credentialID, // Use isoBase64URL helper
|
|
type: 'public-key',
|
|
// Optional: Specify transports if you know them
|
|
transports: auth.transports ? auth.transports.split(',') : undefined,
|
|
})),
|
|
authenticatorSelection: {
|
|
// Defaults
|
|
residentKey: 'required',
|
|
userVerification: 'preferred',
|
|
},
|
|
// Strong advice: Always require attestation for registration
|
|
attestationType: 'none', // Use 'none' for simplicity, 'direct' or 'indirect' recommended for production
|
|
});
|
|
|
|
// Store the challenge
|
|
challengeStore.set(user.id, options.challenge);
|
|
req.session.userId = user.id; // Temporarily store userId in session for verification step
|
|
|
|
res.json(options);
|
|
}
|
|
catch (error)
|
|
{
|
|
console.error('Registration options error:', error);
|
|
// Handle potential Prisma unique constraint errors (e.g., email already exists)
|
|
if (error.code === 'P2002' && error.meta?.target?.includes('email'))
|
|
{
|
|
return res.status(409).json({ error: 'Email address is already in use.' });
|
|
}
|
|
res.status(500).json({ error: 'Failed to generate registration options' });
|
|
}
|
|
});
|
|
|
|
// Verify Registration
|
|
router.post('/verify-registration', async(req, res) =>
|
|
{
|
|
const { registrationResponse } = req.body;
|
|
const userId = req.session.userId; // Retrieve userId stored during options generation
|
|
|
|
if (!userId)
|
|
{
|
|
return res.status(400).json({ error: 'User session not found. Please start registration again.' });
|
|
}
|
|
|
|
const expectedChallenge = challengeStore.get(userId);
|
|
|
|
if (!expectedChallenge)
|
|
{
|
|
return res.status(400).json({ error: 'Challenge not found or expired' });
|
|
}
|
|
|
|
try
|
|
{
|
|
const user = await getUserById(userId);
|
|
if (!user)
|
|
{
|
|
return res.status(404).json({ error: 'User not found' });
|
|
}
|
|
|
|
const verification = await verifyRegistrationResponse({
|
|
response: registrationResponse,
|
|
expectedChallenge: expectedChallenge,
|
|
expectedOrigin: origin,
|
|
expectedRPID: rpID,
|
|
requireUserVerification: false, // Adjust based on your requirements
|
|
});
|
|
|
|
const { verified, registrationInfo } = verification;
|
|
|
|
if (verified && registrationInfo)
|
|
{
|
|
const { credential, credentialDeviceType, credentialBackedUp } = registrationInfo;
|
|
|
|
const credentialID = credential.id;
|
|
const credentialPublicKey = credential.publicKey;
|
|
const counter = credential.counter;
|
|
const transports = credential.transports || []; // Use empty array if transports are not provided
|
|
|
|
// Check if authenticator with this ID already exists
|
|
const existingAuthenticator = await getAuthenticatorByCredentialID(isoBase64URL.fromBuffer(credentialID));
|
|
|
|
if (existingAuthenticator)
|
|
{
|
|
return res.status(409).json({ error: 'Authenticator already registered' });
|
|
}
|
|
|
|
// Save the authenticator
|
|
await prisma.authenticator.create({
|
|
data: {
|
|
credentialID, // Store as Base64URL string
|
|
credentialPublicKey: Buffer.from(credentialPublicKey), // Store as Bytes
|
|
counter: BigInt(counter), // Store as BigInt
|
|
credentialDeviceType,
|
|
credentialBackedUp,
|
|
transports: transports.join(','), // Store transports as comma-separated string
|
|
userId: user.id,
|
|
},
|
|
});
|
|
|
|
// Clear the challenge and temporary userId
|
|
challengeStore.delete(userId);
|
|
delete req.session.userId;
|
|
|
|
// Log the user in by setting the final session userId
|
|
req.session.loggedInUserId = user.id;
|
|
|
|
res.json({ verified: true });
|
|
}
|
|
else
|
|
{
|
|
// This else block was previously misplaced before the if block
|
|
res.status(400).json({ error: 'Registration verification failed' });
|
|
}
|
|
}
|
|
catch (error)
|
|
{
|
|
console.error('Registration verification error:', error);
|
|
// Handle potential Prisma unique constraint errors (e.g., email already exists)
|
|
if (error.code === 'P2002' && error.meta?.target?.includes('email'))
|
|
{
|
|
return res.status(409).json({ error: 'Email address is already in use.' });
|
|
}
|
|
challengeStore.delete(userId); // Clean up challenge on error
|
|
delete req.session.userId;
|
|
res.status(500).json({ error: 'Failed to verify registration', details: error.message });
|
|
}
|
|
});
|
|
|
|
// Generate Authentication Options
|
|
router.post('/generate-authentication-options', async(req, res) =>
|
|
{
|
|
const { username } = req.body;
|
|
|
|
try
|
|
{
|
|
let user;
|
|
if (username)
|
|
{
|
|
user = await getUserByUsername(username);
|
|
}
|
|
else if (req.session.loggedInUserId)
|
|
{
|
|
// If already logged in, allow re-authentication (e.g., for step-up)
|
|
user = await getUserById(req.session.loggedInUserId);
|
|
}
|
|
|
|
if (!user)
|
|
{
|
|
return res.status(404).json({ error: 'User not found' });
|
|
}
|
|
|
|
const userAuthenticators = await getUserAuthenticators(user.id);
|
|
|
|
const options = await generateAuthenticationOptions({
|
|
rpID,
|
|
// Require users to use a previously-registered authenticator
|
|
allowCredentials: userAuthenticators.map(auth => ({
|
|
id: auth.credentialID,
|
|
type: 'public-key',
|
|
transports: auth.transports ? auth.transports.split(',') : undefined,
|
|
})),
|
|
userVerification: 'preferred',
|
|
});
|
|
|
|
// Store the challenge associated with the user ID for verification
|
|
challengeStore.set(user.id, options.challenge);
|
|
req.session.challengeUserId = user.id; // Store user ID associated with this challenge
|
|
|
|
res.json(options);
|
|
}
|
|
catch (error)
|
|
{
|
|
console.error('Authentication options error:', error);
|
|
res.status(500).json({ error: 'Failed to generate authentication options' });
|
|
}
|
|
});
|
|
|
|
// Verify Authentication
|
|
router.post('/verify-authentication', async(req, res) =>
|
|
{
|
|
const { authenticationResponse } = req.body;
|
|
const challengeUserId = req.session.challengeUserId; // Get user ID associated with the challenge
|
|
|
|
if (!challengeUserId)
|
|
{
|
|
return res.status(400).json({ error: 'Challenge session not found. Please try logging in again.' });
|
|
}
|
|
|
|
const expectedChallenge = challengeStore.get(challengeUserId);
|
|
|
|
if (!expectedChallenge)
|
|
{
|
|
return res.status(400).json({ error: 'Challenge not found or expired' });
|
|
}
|
|
|
|
try
|
|
{
|
|
const user = await getUserById(challengeUserId);
|
|
if (!user)
|
|
{
|
|
return res.status(404).json({ error: 'User associated with challenge not found' });
|
|
}
|
|
|
|
const authenticator = await getAuthenticatorByCredentialID(authenticationResponse.id);
|
|
|
|
if (!authenticator)
|
|
{
|
|
return res.status(404).json({ error: 'Authenticator not found' });
|
|
}
|
|
|
|
// Ensure the authenticator belongs to the user attempting to log in
|
|
if (authenticator.userId !== user.id)
|
|
{
|
|
return res.status(403).json({ error: 'Authenticator does not belong to this user' });
|
|
}
|
|
|
|
const verification = await verifyAuthenticationResponse({
|
|
response: authenticationResponse,
|
|
expectedChallenge: expectedChallenge,
|
|
expectedOrigin: origin,
|
|
expectedRPID: rpID,
|
|
credential: {
|
|
id: authenticator.credentialID,
|
|
publicKey: authenticator.credentialPublicKey,
|
|
counter: authenticator.counter.toString(), // Convert BigInt to string for comparison
|
|
transports: authenticator.transports ? authenticator.transports.split(',') : undefined,
|
|
},
|
|
requireUserVerification: false, // Enforce user verification
|
|
});
|
|
|
|
const { verified, authenticationInfo } = verification;
|
|
|
|
if (verified)
|
|
{
|
|
// Update the authenticator counter
|
|
await prisma.authenticator.update({
|
|
where: { credentialID: authenticator.credentialID },
|
|
data: { counter: BigInt(authenticationInfo.newCounter) }, // Update with the new counter
|
|
});
|
|
|
|
// Clear the challenge and associated user ID
|
|
challengeStore.delete(challengeUserId);
|
|
delete req.session.challengeUserId;
|
|
|
|
// Log the user in
|
|
req.session.loggedInUserId = user.id;
|
|
|
|
res.json({ verified: true, user: { id: user.id, username: user.username } });
|
|
}
|
|
else
|
|
{
|
|
res.status(400).json({ error: 'Authentication verification failed' });
|
|
}
|
|
}
|
|
catch (error)
|
|
{
|
|
console.error('Authentication verification error:', error);
|
|
challengeStore.delete(challengeUserId); // Clean up challenge on error
|
|
delete req.session.challengeUserId;
|
|
res.status(500).json({ error: 'Failed to verify authentication', details: error.message });
|
|
}
|
|
});
|
|
|
|
// GET Passkeys for Logged-in User
|
|
router.get('/passkeys', async(req, res) =>
|
|
{
|
|
if (!req.session.loggedInUserId)
|
|
{
|
|
return res.status(401).json({ error: 'Not authenticated' });
|
|
}
|
|
|
|
try
|
|
{
|
|
const userId = req.session.loggedInUserId;
|
|
const authenticators = await prisma.authenticator.findMany({
|
|
where: { userId },
|
|
select: {
|
|
credentialID: true, // Already Base64URL string
|
|
// Add other fields if needed, e.g., createdAt if you add it to the schema
|
|
// createdAt: true,
|
|
},
|
|
});
|
|
|
|
// No need to convert credentialID here as it's stored as Base64URL string
|
|
res.json(authenticators);
|
|
}
|
|
catch (error)
|
|
{
|
|
console.error('Error fetching passkeys:', error);
|
|
res.status(500).json({ error: 'Failed to fetch passkeys' });
|
|
}
|
|
});
|
|
|
|
// DELETE Passkey
|
|
router.delete('/passkeys/:credentialID', async(req, res) =>
|
|
{
|
|
if (!req.session.loggedInUserId)
|
|
{
|
|
return res.status(401).json({ error: 'Not authenticated' });
|
|
}
|
|
|
|
const { credentialID } = req.params; // This is already a Base64URL string from the client
|
|
|
|
if (!credentialID)
|
|
{
|
|
return res.status(400).json({ error: 'Credential ID is required' });
|
|
}
|
|
|
|
try
|
|
{
|
|
const userId = req.session.loggedInUserId;
|
|
|
|
// Find the authenticator first to ensure it belongs to the logged-in user
|
|
const authenticator = await prisma.authenticator.findUnique({
|
|
where: { credentialID: credentialID }, // Use the Base64URL string directly
|
|
});
|
|
|
|
if (!authenticator)
|
|
{
|
|
return res.status(404).json({ error: 'Passkey not found' });
|
|
}
|
|
|
|
// Security check: Ensure the passkey belongs to the user trying to delete it
|
|
if (authenticator.userId !== userId)
|
|
{
|
|
return res.status(403).json({ error: 'Permission denied' });
|
|
}
|
|
|
|
// Delete the authenticator
|
|
await prisma.authenticator.delete({
|
|
where: { credentialID: credentialID },
|
|
});
|
|
|
|
res.json({ message: 'Passkey deleted successfully' });
|
|
}
|
|
catch (error)
|
|
{
|
|
console.error('Error deleting passkey:', error);
|
|
// Handle potential Prisma errors, e.g., record not found if deleted between check and delete
|
|
if (error.code === 'P2025')
|
|
{ // Prisma code for record not found on delete/update
|
|
return res.status(404).json({ error: 'Passkey not found' });
|
|
}
|
|
res.status(500).json({ error: 'Failed to delete passkey' });
|
|
}
|
|
});
|
|
|
|
// Check Authentication Status
|
|
router.get('/status', async(req, res) =>
|
|
{
|
|
if (req.session.loggedInUserId)
|
|
{
|
|
const user = await getUserById(req.session.loggedInUserId);
|
|
if (!user)
|
|
{
|
|
req.session.destroy(err =>
|
|
{});
|
|
return res.status(401).json({ status: 'unauthenticated' });
|
|
}
|
|
// Include email and fullName in the response
|
|
return res.json({ status: 'authenticated', user: { id: user.id, username: user.username, email: user.email, fullName: user.fullName } });
|
|
}
|
|
res.json({ status: 'unauthenticated' });
|
|
});
|
|
|
|
// Logout
|
|
router.post('/logout', (req, res) =>
|
|
{
|
|
req.session.destroy(err =>
|
|
{
|
|
if (err)
|
|
{
|
|
console.error('Logout error:', err);
|
|
return res.status(500).json({ error: 'Failed to logout' });
|
|
}
|
|
res.json({ message: 'Logged out successfully' });
|
|
});
|
|
});
|
|
|
|
export default router; |