diff --git a/app.js b/app.js index 97150d8..1e16358 100644 --- a/app.js +++ b/app.js @@ -201,7 +201,6 @@ app.post('/account/password', passportConfig.isAuthenticated, userController.pos app.post('/account/delete', passportConfig.isAuthenticated, userController.postDeleteAccount); app.post('/account/logout-everywhere', passportConfig.isAuthenticated, userController.postLogoutEverywhere); app.get('/account/unlink/:provider', passportConfig.isAuthenticated, userController.getOauthUnlink); - /** * API examples routes. */ @@ -367,3 +366,48 @@ To avoid this, set BASE_URL to the HTTPS endpoint and always access the app thro }); module.exports = app; + +/** + * Security routes (biometric auth, 2FA) + */ +const securityController = require('./controllers/security'); + +// Add these routes after your existing account routes +app.get('/account/security', passportConfig.isAuthenticated, securityController.getSecuritySettings); +app.get('/security/2fa/setup', passportConfig.isAuthenticated, securityController.getTwoFactorSetup); +app.post('/security/2fa/verify', passportConfig.isAuthenticated, securityController.postTwoFactorVerify); +app.post('/security/2fa/disable', passportConfig.isAuthenticated, securityController.postTwoFactorDisable); +app.get('/security/webauthn/register-options', passportConfig.isAuthenticated, securityController.getWebAuthnRegistration); +app.post('/security/webauthn/register', passportConfig.isAuthenticated, securityController.postWebAuthnRegistration); +app.get('/security/webauthn/auth-options', securityController.getWebAuthnAuthentication); +app.post('/security/webauthn/login', securityController.postWebAuthnLogin); +app.post('/security/webauthn/remove', passportConfig.isAuthenticated, securityController.postRemoveWebAuthnCredential); +app.post('/login/2fa/verify', securityController.postTwoFactorVerify); + +// Update the login route to handle biometric login +app.get('/login/biometric', userController.getLogin); + +// Biometric Auth Rate Limiter Config +const biometricLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 10, // 10 attempts per 15 minutes + standardHeaders: true, + legacyHeaders: false, +}); + +// Apply to biometric routes +app.post('/security/webauthn/login', biometricLimiter); +app.post('/security/webauthn/register', biometricLimiter); + +/** + * Microsoft OAuth routes + */ +app.get('/auth/microsoft', passport.authenticate('microsoft')); +app.get('/auth/microsoft/callback', passport.authenticate('microsoft', { failureRedirect: '/auth/failure' }), (req, res) => { + res.redirect(req.session.returnTo || '/'); +}); + +/** + * API examples routes - Add Microsoft to the list + */ +app.get('/api/microsoft', passportConfig.isAuthenticated, passportConfig.isAuthorized, apiController.getMicrosoft); \ No newline at end of file diff --git a/config/passport.js b/config/passport.js index e72b044..bf5bfc4 100644 --- a/config/passport.js +++ b/config/passport.js @@ -903,3 +903,77 @@ exports.isAuthorized = async (req, res, next) => { // Add export for testing the internal function exports._saveOAuth2UserTokens = saveOAuth2UserTokens; + +const MicrosoftStrategy = require('passport-oauth2').Strategy; +const User = require('../models/User'); + +// Add this to your existing passport configuration +passport.use(new MicrosoftStrategy({ + clientID: process.env.MICROSOFT_APP_ID, + clientSecret: process.env.MICROSOFT_APP_SECRET, + callbackURL: `${process.env.BASE_URL}${process.env.MICROSOFT_CALLBACK_URL || '/auth/microsoft/callback'}`, + authorizationURL: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', + tokenURL: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + scope: ['openid', 'profile', 'email', 'User.Read'], + state: true +}, async (accessToken, refreshToken, params, profile, done) => { + try { + // Microsoft returns user information in the params as an ID token + const idToken = params.id_token; + if (!idToken) { + return done(new Error('No ID token received from Microsoft')); + } + + // Decode the ID token to get user information + const payload = JSON.parse(Buffer.from(idToken.split('.')[1], 'base64').toString()); + + const { + sub: id, + email, + name, + given_name: firstName, + family_name: lastName, + picture + } = payload; + + // Check if user already exists by Microsoft ID + let user = await User.findOne({ microsoft: id }); + + if (user) { + return done(null, user); + } + + // Check if user exists by email (for account linking) + user = await User.findOne({ email: email.toLowerCase() }); + + if (user) { + // Link Microsoft account to existing user + user.microsoft = id; + user.tokens.push({ kind: 'microsoft', accessToken }); + await user.save(); + return done(null, user); + } + + // Create new user + const newUser = new User({ + email: email.toLowerCase(), + profile: { + name: name, + gender: '', + location: '', + website: '', + picture: picture || '' + }, + microsoft: id, + tokens: [{ kind: 'microsoft', accessToken }] + }); + + await newUser.save(); + done(null, newUser); + } catch (error) { + done(error); + } +})); + +// Add Microsoft to the list of authorized providers +const authorizedProviders = ['github', 'facebook', 'twitter', 'linkedin', 'google', 'twitch', 'microsoft', 'discord', 'tumblr', 'steam', 'quickbooks', 'trakt']; \ No newline at end of file diff --git a/config/webauth.js b/config/webauth.js new file mode 100644 index 0000000..0fbd39c --- /dev/null +++ b/config/webauth.js @@ -0,0 +1,58 @@ +const { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse } = require('@simplewebauthn/server'); +const { isoBase64URL, isoUint8Array } = require('@simplewebauthn/server/helpers'); + +class WebAuthnConfig { + constructor() { + this.rpID = process.env.DOMAIN || 'localhost'; + this.rpName = process.env.APP_NAME || 'Express Starter'; + this.origin = process.env.BASE_URL || `http://localhost:${process.env.PORT || 8080}`; + } + + async generateRegistrationOptions(user) { + return await generateRegistrationOptions({ + rpName: this.rpName, + rpID: this.rpID, + userID: user._id.toString(), + userName: user.email, + userDisplayName: user.profile.name || user.email, + attestationType: 'none', + authenticatorSelection: { + residentKey: 'preferred', + userVerification: 'preferred', + }, + }); + } + + async generateAuthenticationOptions() { + return await generateAuthenticationOptions({ + rpID: this.rpID, + userVerification: 'preferred', + }); + } + + async verifyRegistrationResponse(attestationResponse, expectedChallenge) { + return await verifyRegistrationResponse({ + response: attestationResponse, + expectedChallenge, + expectedOrigin: this.origin, + expectedRPID: this.rpID, + }); + } + + async verifyAuthenticationResponse(assertionResponse, expectedChallenge, credential) { + return await verifyAuthenticationResponse({ + response: assertionResponse, + expectedChallenge, + expectedOrigin: this.origin, + expectedRPID: this.rpID, + authenticator: { + credentialID: isoBase64URL.toBuffer(credential.credentialID), + credentialPublicKey: isoUint8Array.fromHex(credential.publicKey), + counter: credential.counter, + transports: credential.transports, + }, + }); + } +} + +module.exports = new WebAuthnConfig(); \ No newline at end of file diff --git a/controllers/api.js b/controllers/api.js index 02c6f46..d2143dc 100644 --- a/controllers/api.js +++ b/controllers/api.js @@ -1551,3 +1551,56 @@ exports.getPubChem = async (req, res, next) => { next(error); } }; + +const axios = require('axios'); + +/** + * GET /api/microsoft + * Microsoft Graph API example. + */ +exports.getMicrosoft = async (req, res, next) => { + try { + const token = req.user.tokens.find(token => token.kind === 'microsoft'); + + if (!token) { + req.flash('errors', { msg: 'You must link your Microsoft account first.' }); + return res.redirect('/api'); + } + + // Get user profile from Microsoft Graph + const profileResponse = await axios.get('https://graph.microsoft.com/v1.0/me', { + headers: { + Authorization: `Bearer ${token.accessToken}`, + 'Content-Type': 'application/json' + } + }); + + // Get user photo if available + let photoUrl = null; + try { + const photoResponse = await axios.get('https://graph.microsoft.com/v1.0/me/photo/$value', { + headers: { + Authorization: `Bearer ${token.accessToken}`, + }, + responseType: 'arraybuffer' + }); + + const photoBase64 = Buffer.from(photoResponse.data, 'binary').toString('base64'); + photoUrl = `data:${photoResponse.headers['content-type']};base64,${photoBase64}`; + } catch (photoError) { + console.log('No profile photo available or error fetching photo:', photoError.message); + } + + res.render('api/microsoft', { + title: 'Microsoft Graph API', + profile: profileResponse.data, + photoUrl + }); + } catch (error) { + if (error.response && error.response.status === 401) { + req.flash('errors', { msg: 'Your Microsoft token has expired. Please link your account again.' }); + return res.redirect('/api'); + } + next(error); + } +}; \ No newline at end of file diff --git a/controllers/security.js b/controllers/security.js new file mode 100644 index 0000000..3eb77f1 --- /dev/null +++ b/controllers/security.js @@ -0,0 +1,258 @@ +const speakeasy = require('speakeasy'); +const QRCode = require('qrcode'); +const webauthnConfig = require('../config/webauthn'); +const User = require('../models/User'); + +exports.getTwoFactorSetup = async (req, res) => { + try { + const user = await User.findById(req.user.id); + + if (user.twoFactorEnabled) { + req.flash('info', { msg: 'Two-factor authentication is already enabled.' }); + return res.redirect('/account'); + } + + // Generate a new secret + const secret = speakeasy.generateSecret({ + name: `${process.env.APP_NAME || 'Express App'} (${user.email})`, + issuer: process.env.APP_NAME || 'Express App' + }); + + // Generate QR code + const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url); + + // Store temporary secret in session + req.session.tempTwoFactorSecret = secret.base32; + + res.render('account/twofactor-setup', { + title: 'Setup Two-Factor Authentication', + secret: secret.base32, + qrCodeUrl + }); + } catch (error) { + console.error('Two-factor setup error:', error); + req.flash('errors', { msg: 'Error setting up two-factor authentication.' }); + res.redirect('/account'); + } +}; + +exports.postTwoFactorVerify = async (req, res) => { + try { + const { token } = req.body; + const user = await User.findById(req.user.id); + + if (user.twoFactorEnabled) { + req.flash('errors', { msg: 'Two-factor authentication is already enabled.' }); + return res.redirect('/account'); + } + + const verified = speakeasy.totp.verify({ + secret: req.session.tempTwoFactorSecret, + encoding: 'base32', + token, + window: 1 + }); + + if (verified) { + user.twoFactorEnabled = true; + user.twoFactorSecret = req.session.tempTwoFactorSecret; + user.generateBackupCodes(); + await user.save(); + + req.session.tempTwoFactorSecret = null; + + req.flash('success', { + msg: 'Two-factor authentication enabled successfully. Please save your backup codes.' + }); + + res.render('account/backup-codes', { + title: 'Backup Codes', + backupCodes: user.twoFactorBackupCodes + }); + } else { + req.flash('errors', { msg: 'Invalid verification code. Please try again.' }); + res.redirect('/security/2fa/setup'); + } + } catch (error) { + console.error('Two-factor verification error:', error); + req.flash('errors', { msg: 'Error verifying two-factor authentication.' }); + res.redirect('/security/2fa/setup'); + } +}; + +exports.postTwoFactorDisable = async (req, res) => { + try { + const { token } = req.body; + const user = await User.findById(req.user.id); + + if (!user.twoFactorEnabled) { + req.flash('errors', { msg: 'Two-factor authentication is not enabled.' }); + return res.redirect('/account'); + } + + const verified = speakeasy.totp.verify({ + secret: user.twoFactorSecret, + encoding: 'base32', + token, + window: 1 + }); + + if (verified) { + user.twoFactorEnabled = false; + user.twoFactorSecret = undefined; + user.twoFactorBackupCodes = []; + await user.save(); + + req.flash('success', { msg: 'Two-factor authentication disabled successfully.' }); + } else { + req.flash('errors', { msg: 'Invalid verification code. Please try again.' }); + } + + res.redirect('/account'); + } catch (error) { + console.error('Two-factor disable error:', error); + req.flash('errors', { msg: 'Error disabling two-factor authentication.' }); + res.redirect('/account'); + } +}; + +exports.getWebAuthnRegistration = async (req, res) => { + try { + const user = await User.findById(req.user.id); + const options = await webauthnConfig.generateRegistrationOptions(user); + + req.session.webauthnChallenge = options.challenge; + + res.json(options); + } catch (error) { + console.error('WebAuthn registration options error:', error); + res.status(500).json({ error: 'Failed to generate registration options' }); + } +}; + +exports.postWebAuthnRegistration = async (req, res) => { + try { + const { attestationResponse, deviceName } = req.body; + const user = await User.findById(req.user.id); + + const verification = await webauthnConfig.verifyRegistrationResponse( + attestationResponse, + req.session.webauthnChallenge + ); + + if (verification.verified) { + const { credential } = verification.registrationInfo; + + user.addWebAuthnCredential({ + credentialID: credential.id, + publicKey: Buffer.from(credential.publicKey).toString('hex'), + counter: credential.counter, + transports: attestationResponse.response.transports || [], + deviceType: this.getDeviceType(attestationResponse), + name: deviceName || 'Security Key' + }); + + await user.save(); + req.session.webauthnChallenge = null; + + res.json({ success: true, message: 'Biometric credential registered successfully' }); + } else { + res.status(400).json({ error: 'Registration verification failed' }); + } + } catch (error) { + console.error('WebAuthn registration error:', error); + res.status(500).json({ error: 'Registration failed' }); + } +}; + +exports.getWebAuthnAuthentication = async (req, res) => { + try { + const options = await webauthnConfig.generateAuthenticationOptions(); + req.session.webauthnChallenge = options.challenge; + res.json(options); + } catch (error) { + console.error('WebAuthn authentication options error:', error); + res.status(500).json({ error: 'Failed to generate authentication options' }); + } +}; + +exports.postWebAuthnLogin = async (req, res, next) => { + try { + const { assertionResponse, email } = req.body; + + const user = await User.findOne({ email: email.toLowerCase() }); + if (!user) { + return res.status(400).json({ error: 'Invalid credentials' }); + } + + // Find the credential used for authentication + const credential = user.webauthnCredentials.find( + cred => cred.credentialID === assertionResponse.id + ); + + if (!credential) { + return res.status(400).json({ error: 'Unknown credential' }); + } + + const verification = await webauthnConfig.verifyAuthenticationResponse( + assertionResponse, + req.session.webauthnChallenge, + credential + ); + + if (verification.verified) { + // Update credential counter + credential.counter = verification.authenticationInfo.newCounter; + await user.save(); + + req.session.webauthnChallenge = null; + + // Log the user in + req.logIn(user, (err) => { + if (err) { return next(err); } + res.json({ success: true, redirectTo: req.session.returnTo || '/' }); + }); + } else { + res.status(400).json({ error: 'Authentication verification failed' }); + } + } catch (error) { + console.error('WebAuthn login error:', error); + res.status(500).json({ error: 'Authentication failed' }); + } +}; + +exports.getDeviceType = (attestationResponse) => { + // Simple device type detection based on user agent and response + const ua = attestationResponse.clientExtensionResults?.userAgent; + if (ua?.includes('Windows')) return 'Windows Hello'; + if (ua?.includes('Mac')) return 'Touch ID'; + if (ua?.includes('Android')) return 'Android Biometric'; + if (ua?.includes('iPhone')) return 'Face ID/Touch ID'; + return 'Security Key'; +}; + +exports.getSecuritySettings = async (req, res) => { + const user = await User.findById(req.user.id); + res.render('account/security', { + title: 'Security Settings', + twoFactorEnabled: user.twoFactorEnabled, + webauthnCredentials: user.webauthnCredentials + }); +}; + +exports.postRemoveWebAuthnCredential = async (req, res) => { + try { + const { credentialId } = req.body; + const user = await User.findById(req.user.id); + + user.removeWebAuthnCredential(credentialId); + await user.save(); + + req.flash('success', { msg: 'Biometric credential removed successfully.' }); + res.redirect('/account/security'); + } catch (error) { + console.error('Remove WebAuthn credential error:', error); + req.flash('errors', { msg: 'Error removing biometric credential.' }); + res.redirect('/account/security'); + } +}; \ No newline at end of file diff --git a/controllers/user.js b/controllers/user.js index a0fd98d..f5b7769 100644 --- a/controllers/user.js +++ b/controllers/user.js @@ -706,3 +706,130 @@ exports.postLogoutEverywhere = async (req, res, next) => { return next(err); } }; + +const speakeasy = require('speakeasy'); + +// Update the postLogin method to handle 2FA +exports.postLogin = async (req, res, next) => { + try { + const { email, password, token } = req.body; + + // Find user and validate password (your existing logic) + const user = await User.findOne({ email: email.toLowerCase() }); + if (!user || !(await user.comparePassword(password))) { + req.flash('errors', { msg: 'Invalid email or password.' }); + return res.redirect('/login'); + } + + // Check if 2FA is enabled + if (user.twoFactorEnabled) { + if (!token) { + // Store user ID in session and show 2FA prompt + req.session.twoFactorUserId = user._id.toString(); + return res.render('account/twofactor-prompt', { + title: 'Two-Factor Authentication', + email: user.email + }); + } + + // Verify 2FA token + const verified = speakeasy.totp.verify({ + secret: user.twoFactorSecret, + encoding: 'base32', + token, + window: 1 + }); + + // Check backup codes if TOTP fails + if (!verified && !user.verifyBackupCode(token)) { + req.flash('errors', { msg: 'Invalid two-factor authentication code.' }); + return res.render('account/twofactor-prompt', { + title: 'Two-Factor Authentication', + email: user.email + }); + } + + await user.save(); // Save if backup code was used + } + + // Continue with normal login + req.logIn(user, (err) => { + if (err) { return next(err); } + req.session.twoFactorUserId = null; + req.flash('success', { msg: 'Success! You are logged in.' }); + res.redirect(req.session.returnTo || '/'); + }); + } catch (error) { + next(error); + } +}; + +// Add 2FA verification endpoint +exports.postTwoFactorVerify = async (req, res, next) => { + try { + const { token } = req.body; + + if (!req.session.twoFactorUserId) { + req.flash('errors', { msg: 'Two-factor authentication session expired.' }); + return res.redirect('/login'); + } + + const user = await User.findById(req.session.twoFactorUserId); + if (!user) { + req.flash('errors', { msg: 'User not found.' }); + return res.redirect('/login'); + } + + const verified = speakeasy.totp.verify({ + secret: user.twoFactorSecret, + encoding: 'base32', + token, + window: 1 + }); + + if (!verified && !user.verifyBackupCode(token)) { + req.flash('errors', { msg: 'Invalid two-factor authentication code.' }); + return res.render('account/twofactor-prompt', { + title: 'Two-Factor Authentication', + email: user.email + }); + } + + await user.save(); // Save if backup code was used + + req.logIn(user, (err) => { + if (err) { return next(err); } + req.session.twoFactorUserId = null; + req.flash('success', { msg: 'Success! You are logged in.' }); + res.redirect(req.session.returnTo || '/'); + }); + } catch (error) { + next(error); + } +}; + +exports.getOauthUnlink = async (req, res) => { + const { provider } = req.params; + const validProviders = ['github', 'facebook', 'twitter', 'linkedin', 'google', 'microsoft', 'twitch', 'discord']; + + if (!validProviders.includes(provider)) { + req.flash('errors', { msg: `Invalid OAuth provider: ${provider}` }); + return res.redirect('/account'); + } + + try { + const user = await User.findById(req.user.id); + user[provider] = undefined; + + // Remove Microsoft tokens + user.tokens = user.tokens.filter(token => token.kind !== provider); + + await user.save(); + req.flash('success', { msg: `${provider.charAt(0).toUpperCase() + provider.slice(1)} account has been unlinked.` }); + } catch (error) { + console.error('OAuth unlinking error:', error); + req.flash('errors', { msg: `Error unlinking ${provider} account.` }); + } + + res.redirect('/account'); +}; \ No newline at end of file diff --git a/models/User.js b/models/User.js index 468d61a..c0ccf92 100644 --- a/models/User.js +++ b/models/User.js @@ -4,6 +4,7 @@ const mongoose = require('mongoose'); const userSchema = new mongoose.Schema( { + microsoft: String, email: { type: String, unique: true, required: true }, password: String, @@ -44,6 +45,7 @@ const userSchema = new mongoose.Schema( }, { timestamps: true }, ); +userSchema.index({ microsoft: 1 }); // Indexes for verification fileds that are queried userSchema.index({ passwordResetToken: 1 }); @@ -169,3 +171,64 @@ userSchema.methods.verifyTokenAndIp = function verifyTokenAndIp(token, ip, token const User = mongoose.model('User', userSchema); module.exports = User; + +const userSchema = new mongoose.Schema({ + // ... your existing fields ... + + // 2FA fields + twoFactorEnabled: { type: Boolean, default: false }, + twoFactorSecret: String, + twoFactorBackupCodes: [String], + + // Biometric authentication fields + webauthnCredentials: [{ + credentialID: String, + publicKey: String, + counter: Number, + transports: [String], + deviceType: String, + name: String, + createdAt: { type: Date, default: Date.now } + }], + + // Session management for "logout everywhere" + currentSessions: [{ + sessionId: String, + userAgent: String, + ipAddress: String, + createdAt: { type: Date, default: Date.now }, + lastActive: { type: Date, default: Date.now } + }] +}); + +// Add method to generate 2FA backup codes +userSchema.methods.generateBackupCodes = function() { + const codes = []; + for (let i = 0; i < 10; i++) { + codes.push(require('crypto').randomBytes(5).toString('hex').toUpperCase()); + } + this.twoFactorBackupCodes = codes; + return codes; +}; + +// Add method to verify 2FA backup code +userSchema.methods.verifyBackupCode = function(code) { + const index = this.twoFactorBackupCodes.indexOf(code.toUpperCase()); + if (index > -1) { + this.twoFactorBackupCodes.splice(index, 1); + return true; + } + return false; +}; + +// Add method to add WebAuthn credential +userSchema.methods.addWebAuthnCredential = function(credential) { + this.webauthnCredentials.push(credential); +}; + +// Add method to remove WebAuthn credential +userSchema.methods.removeWebAuthnCredential = function(credentialId) { + this.webauthnCredentials = this.webauthnCredentials.filter( + cred => cred.credentialID !== credentialId + ); +}; \ No newline at end of file diff --git a/views/account/2-fac-prompt.pug b/views/account/2-fac-prompt.pug new file mode 100644 index 0000000..c44c3e8 --- /dev/null +++ b/views/account/2-fac-prompt.pug @@ -0,0 +1,23 @@ +extends ../layout + +block content + .container + .row.justify-content-center + .col-md-6 + .card + .card-header + h5.card-title Two-Factor Authentication Required + .card-body + p Please enter the verification code from your authenticator app for #{email}. + + if messages.errors + .alert.alert-danger + for error in messages.errors + div= error.msg + + form(method='POST' action='/login/2fa/verify') + .form-group + label(for='token') Verification Code: + input.form-control(type='text' name='token' placeholder='000000' required) + button.btn.btn-primary(type='submit') Verify + a.btn.btn-link(href='/login') Back to Login \ No newline at end of file diff --git a/views/account/2-fac-setup.pug b/views/account/2-fac-setup.pug new file mode 100644 index 0000000..3450d11 --- /dev/null +++ b/views/account/2-fac-setup.pug @@ -0,0 +1,28 @@ +extends ../layout + +block content + .container + .row.justify-content-center + .col-md-6 + .card + .card-header + h5.card-title Setup Two-Factor Authentication + .card-body + p Scan the QR code with your authenticator app (Google Authenticator, Authy, etc.) + + .text-center + img(src=qrCodeUrl alt='QR Code' style='max-width: 200px;') + + .form-group + label Manual setup code: + input.form-control(type='text' value=secret readonly) + small.form-text.text-muted If you can't scan the QR code, enter this code manually. + + hr + + form(method='POST' action='/security/2fa/verify') + .form-group + label(for='token') Enter verification code: + input.form-control(type='text' name='token' placeholder='000000' required) + button.btn.btn-primary(type='submit') Verify and Enable + a.btn.btn-link(href='/account') Cancel \ No newline at end of file diff --git a/views/account/bkup-codes.pug b/views/account/bkup-codes.pug new file mode 100644 index 0000000..9ca2393 --- /dev/null +++ b/views/account/bkup-codes.pug @@ -0,0 +1,23 @@ +extends ../layout + +block content + .container + .row.justify-content-center + .col-md-8 + .card + .card-header + h5.card-title Backup Codes + .card-body + .alert.alert-warning + h6 Important! + p Save these backup codes in a secure place. Each code can be used once if you lose access to your authenticator app. + + .text-center + each code in backupCodes + .h5= code + br + + p.text-muted These codes are one-time use and will be shown only once. + + .text-center + a.btn.btn-primary(href='/account') Continue to Account \ No newline at end of file diff --git a/views/account/login.pug b/views/account/login.pug index 5645fd8..a56b4f4 100644 --- a/views/account/login.pug +++ b/views/account/login.pug @@ -71,3 +71,49 @@ block content document.getElementById('password').value = ''; } }); + + +// Add this after the regular login form +.row.mt-4 + .col-12 + .text-center + hr + p Or + button.btn.btn-outline-primary(type='button' onclick='startBiometricLogin()') + i.fas.fa-fingerprint + | Login with Biometrics + + script. + async function startBiometricLogin() { + try { + // Get authentication options + const optionsResponse = await fetch('/security/webauthn/auth-options'); + const options = await optionsResponse.json(); + + // Get credential + const credential = await navigator.credentials.get({ + publicKey: options + }); + + // Send to server for verification + const verificationResponse = await fetch('/security/webauthn/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + assertionResponse: credential, + email: document.querySelector('input[name="email"]').value + }) + }); + + const result = await verificationResponse.json(); + + if (result.success) { + window.location.href = result.redirectTo; + } else { + alert('Biometric login failed: ' + result.error); + } + } catch (error) { + console.error('Biometric login error:', error); + alert('Biometric login not available or failed. Please use regular login.'); + } + } \ No newline at end of file diff --git a/views/account/profile.pug b/views/account/profile.pug index 8f568e0..1973107 100644 --- a/views/account/profile.pug +++ b/views/account/profile.pug @@ -156,3 +156,22 @@ block content p.mb-1: a.text-danger(href='/account/unlink/x') Unlink your X account else p.mb-1: a(href='/auth/x') Link your X account + +//- In the linked accounts section, add this: +if user.microsoft + .col-md-6.mb-3 + .card + .card-body + h6.card-title + i.fab.fa-microsoft.text-primary + | Microsoft + p.card-text Microsoft account linked + a.btn.btn-outline-danger.btn-sm(href='/account/unlink/microsoft') Unlink +else + .col-md-6.mb-3 + .card + .card-body + h6.card-title + i.fab.fa-microsoft + | Microsoft + a.btn.btn-primary.btn-sm(href='/auth/microsoft') Link Microsoft Account \ No newline at end of file diff --git a/views/account/security.pug b/views/account/security.pug new file mode 100644 index 0000000..9ba05de --- /dev/null +++ b/views/account/security.pug @@ -0,0 +1,107 @@ +extends ../layout + +block content + .container + .row + .col-sm-12 + h1 Security Settings + + if messages.errors + .alert.alert-danger + for error in messages.errors + div= error.msg + + if messages.success + .alert.alert-success + for success in messages.success + div= success.msg + + .row + .col-md-6 + .card + .card-header + h5.card-title Two-Factor Authentication + .card-body + if user.twoFactorEnabled + p.text-success + i.fas.fa-check-circle + | Two-factor authentication is enabled + form(method='POST' action='/security/2fa/disable') + .form-group + label(for='token') Enter verification code to disable: + input.form-control(type='text' name='token' placeholder='000000' required) + button.btn.btn-warning(type='submit') Disable 2FA + else + p Two-factor authentication adds an extra layer of security to your account. + a.btn.btn-primary(href='/security/2fa/setup') Enable 2FA + + .col-md-6 + .card + .card-header + h5.card-title Biometric Authentication + .card-body + p Use Windows Hello, Touch ID, Face ID, or security keys to log in. + + if webauthnCredentials.length > 0 + h6 Registered Devices: + ul.list-group + for credential in webauthnCredentials + li.list-group-item.d-flex.justify-content-between.align-items-center + div + strong= credential.name + br + small.text-muted= credential.deviceType + form(method='POST' action='/security/webauthn/remove') + input(type='hidden' name='credentialId' value=credential.credentialID) + button.btn.btn-sm.btn-outline-danger(type='submit') Remove + + .mt-3 + button.btn.btn-primary(type='button' onclick='registerBiometric()') Register New Biometric Device + + .row.mt-4 + .col-12 + .card + .card-header + h5.card-title Active Sessions + .card-body + p View and manage your active login sessions. + a.btn.btn-info(href='/account/sessions') Manage Sessions + + script. + async function registerBiometric() { + try { + // Get registration options + const optionsResponse = await fetch('/security/webauthn/register-options'); + const options = await optionsResponse.json(); + + // Get device name from user + const deviceName = prompt('Enter a name for this device:', 'My Security Key'); + + // Create credential + const credential = await navigator.credentials.create({ + publicKey: options + }); + + // Send credential to server + const verificationResponse = await fetch('/security/webauthn/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + attestationResponse: credential, + deviceName: deviceName + }) + }); + + const result = await verificationResponse.json(); + + if (result.success) { + alert('Biometric device registered successfully!'); + location.reload(); + } else { + alert('Registration failed: ' + result.error); + } + } catch (error) { + console.error('Biometric registration error:', error); + alert('Registration failed. Please try again.'); + } + } \ No newline at end of file diff --git a/views/api/index.pug b/views/api/index.pug index fcaa666..8625fd5 100644 --- a/views/api/index.pug +++ b/views/api/index.pug @@ -137,3 +137,14 @@ block content .card-body img(src='https://i.imgur.com/J9bd6qK.png', height=40, style='padding: 0px 10px 0px 0px') | PubChem + +//- Add this card to your existing API examples grid +.col-md-4 + .card + .card-body + h5.card-title Microsoft Graph + p.card-text Access Microsoft Graph API to get user profile information, photos, and more. + if user && user.microsoft + a.btn.btn-success(href='/api/microsoft') Microsoft Graph API + else + a.btn.btn-primary(href='/auth/microsoft') Link Microsoft Account \ No newline at end of file diff --git a/views/api/microsoft.pug b/views/api/microsoft.pug new file mode 100644 index 0000000..c5d7f58 --- /dev/null +++ b/views/api/microsoft.pug @@ -0,0 +1,60 @@ +extends ../layout + +block content + .container + .row + .col-sm-12 + h1 Microsoft Graph API + + .row + .col-md-6 + .card + .card-header + h5.card-title User Profile Information + .card-body + if profile + table.table + tbody + tr + td Name + td= profile.displayName + tr + td Email + td= profile.mail || profile.userPrincipalName + tr + td Job Title + td= profile.jobTitle || 'Not specified' + tr + td Office Location + td= profile.officeLocation || 'Not specified' + tr + td Mobile Phone + td= profile.mobilePhone || 'Not specified' + tr + td ID + td= profile.id + else + p.text-danger Failed to load profile information + + .col-md-6 + if photoUrl + .card + .card-header + h5.card-title Profile Photo + .card-body.text-center + img(src=photoUrl alt='Profile Photo' style='max-width: 200px; border-radius: 50%;') + else + .card + .card-header + h5.card-title Profile Photo + .card-body + p.text-muted No profile photo available + + .row.mt-4 + .col-12 + .card + .card-header + h5.card-title Raw Data + .card-body + pre + code.json= JSON.stringify(profile, null, 2) \ No newline at end of file