'use strict'; const crypto = require('crypto'); const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); const jwksClient = require('jwks-rsa'); const db = require('../db/queries/users'); const { signAccessToken, signRefreshToken, verifyRefreshToken } = require('../services/jwtService'); const { sendPasswordResetEmail, sendVerificationEmail } = require('../services/mailgunService'); const { isLorealEmail, isStrongPassword } = require('../utils/validators'); // ── Helpers ─────────────────────────────────────────────────────────────────── const REFRESH_COOKIE = 'refresh_token'; const COOKIE_OPTS = { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'Strict', maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days in ms path: '/', }; function setRefreshCookie(res, token) { res.cookie(REFRESH_COOKIE, token, COOKIE_OPTS); } function clearRefreshCookie(res) { res.clearCookie(REFRESH_COOKIE, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'Strict', path: '/' }); } function userPublic(user) { return { id: user.id, email: user.email, displayName: user.display_name, authMethod: user.auth_method }; } // Decode base64-encoded password from frontend (unicode-safe) function decodePassword(encoded) { try { return Buffer.from(encoded, 'base64').toString('utf-8'); } catch { return encoded; // fallback: treat as plaintext (backwards compat) } } // ── Register ────────────────────────────────────────────────────────────────── async function register(req, res) { const { email } = req.body || {}; const password = decodePassword(req.body?.password); if (!email || !password) return res.status(400).json({ error: 'Email and password are required.' }); if (!isLorealEmail(email)) return res.status(400).json({ error: 'Only @loreal.com email addresses are allowed.' }); if (!isStrongPassword(password)) return res.status(400).json({ error: 'Password must be at least 8 characters with uppercase, lowercase, and a number.' }); const existing = await db.findByEmail(email); if (existing) return res.status(409).json({ error: 'An account with this email already exists.' }); const passwordHash = await bcrypt.hash(password, 12); const user = await db.createUser({ email, passwordHash, authMethod: 'email' }); // Send verification email const rawToken = crypto.randomBytes(32).toString('hex'); const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24h await db.createEmailVerificationToken(user.id, rawToken, expiresAt); await sendVerificationEmail(email, rawToken); return res.status(201).json({ message: 'Account created. Please check your inbox to verify your email.' }); } // ── Verify email ────────────────────────────────────────────────────────────── async function verifyEmail(req, res) { const { token } = req.params; if (!token) return res.status(400).json({ error: 'Token is required.' }); const record = await db.findEmailVerificationToken(token); if (!record) return res.status(400).json({ error: 'Verification link is invalid or has expired.' }); await db.setEmailVerified(record.user_id); await db.deleteEmailVerificationToken(record.id); return res.json({ message: 'Email verified. You can now sign in.' }); } // ── Login ───────────────────────────────────────────────────────────────────── async function login(req, res) { const { email } = req.body || {}; const password = decodePassword(req.body?.password); if (!email || !password) return res.status(400).json({ error: 'Email and password are required.' }); const user = await db.findByEmail(email); if (!user || !user.password_hash) return res.status(401).json({ error: 'Invalid email or password.' }); if (!user.is_active) return res.status(403).json({ error: 'Account is disabled.' }); if (!user.email_verified) return res.status(403).json({ error: 'Please verify your email before signing in.' }); const valid = await bcrypt.compare(password, user.password_hash); if (!valid) return res.status(401).json({ error: 'Invalid email or password.' }); const refreshToken = signRefreshToken({ sub: user.id }); await db.setRefreshTokenHash(user.id, refreshToken); setRefreshCookie(res, refreshToken); const accessToken = signAccessToken({ sub: user.id, email: user.email }); return res.json({ accessToken, user: userPublic(user) }); } // ── SSO (MSAL ID token → app JWT) ───────────────────────────────────────────── const msalJwksClient = jwksClient({ jwksUri: `https://login.microsoftonline.com/${process.env.AZURE_TENANT_ID}/discovery/v2.0/keys`, cache: true, rateLimit: true, }); function getKey(header, callback) { msalJwksClient.getSigningKey(header.kid, (err, key) => { if (err) return callback(err); callback(null, key.getPublicKey()); }); } async function ssoLogin(req, res) { const { idToken } = req.body || {}; if (!idToken) return res.status(400).json({ error: 'idToken is required.' }); let claims; try { claims = await new Promise((resolve, reject) => { jwt.verify(idToken, getKey, { audience: process.env.AZURE_CLIENT_ID, issuer: [`https://login.microsoftonline.com/${process.env.AZURE_TENANT_ID}/v2.0`], }, (err, decoded) => { if (err) reject(err); else resolve(decoded); }); }); } catch (err) { return res.status(401).json({ error: 'Invalid Microsoft ID token.' }); } const email = (claims.preferred_username || claims.email || '').toLowerCase(); // Azure AD tenant membership is already the gate — no domain restriction for SSO const user = await db.upsertSsoUser({ email, azureOid: claims.oid, displayName: claims.name || null, }); const refreshToken = signRefreshToken({ sub: user.id }); await db.setRefreshTokenHash(user.id, refreshToken); setRefreshCookie(res, refreshToken); const accessToken = signAccessToken({ sub: user.id, email: user.email }); return res.json({ accessToken, user: userPublic(user) }); } // ── Logout ──────────────────────────────────────────────────────────────────── async function logout(req, res) { const token = req.cookies[REFRESH_COOKIE]; if (token) { try { const payload = verifyRefreshToken(token); await db.clearRefreshToken(payload.sub); } catch { // expired / invalid — still clear cookie } } clearRefreshCookie(res); return res.json({ message: 'Signed out.' }); } // ── Refresh ─────────────────────────────────────────────────────────────────── async function refresh(req, res) { const token = req.cookies[REFRESH_COOKIE]; if (!token) return res.status(401).json({ error: 'No refresh token.' }); let payload; try { payload = verifyRefreshToken(token); } catch { return res.status(401).json({ error: 'Refresh token expired or invalid.' }); } const user = await db.findById(payload.sub); if (!user || !user.is_active) { clearRefreshCookie(res); return res.status(401).json({ error: 'User not found or disabled.' }); } // Validate stored hash matches incoming token (rotation check) const crypto = require('crypto'); const incoming = crypto.createHash('sha256').update(token).digest('hex'); if (user.refresh_token_hash !== incoming) { clearRefreshCookie(res); return res.status(401).json({ error: 'Refresh token reuse detected.' }); } // Rotate refresh token const newRefreshToken = signRefreshToken({ sub: user.id }); await db.setRefreshTokenHash(user.id, newRefreshToken); setRefreshCookie(res, newRefreshToken); const accessToken = signAccessToken({ sub: user.id, email: user.email }); return res.json({ accessToken, user: userPublic(user) }); } // ── Forgot password ─────────────────────────────────────────────────────────── async function forgotPassword(req, res) { const { email } = req.body || {}; // Always return 200 to prevent user enumeration if (!email || !isLorealEmail(email)) return res.json({ message: 'If that email exists, a reset link has been sent.' }); const user = await db.findByEmail(email); if (user && user.is_active) { const rawToken = crypto.randomBytes(32).toString('hex'); const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1h await db.createPasswordResetToken(user.id, rawToken, expiresAt); try { await sendPasswordResetEmail(email, rawToken); } catch (err) { console.error('Mailgun error:', err.message); } } return res.json({ message: 'If that email exists, a reset link has been sent.' }); } // ── Validate reset token ────────────────────────────────────────────────────── async function validateResetToken(req, res) { const { token } = req.params; if (!token) return res.status(400).json({ valid: false }); const record = await db.findPasswordResetToken(token); if (!record) return res.status(400).json({ valid: false }); return res.json({ valid: true }); } // ── Reset password ──────────────────────────────────────────────────────────── async function resetPassword(req, res) { const { token } = req.body || {}; const password = decodePassword(req.body?.password); if (!token || !password) return res.status(400).json({ error: 'Token and password are required.' }); if (!isStrongPassword(password)) return res.status(400).json({ error: 'Password must be at least 8 characters with uppercase, lowercase, and a number.' }); const record = await db.findPasswordResetToken(token); if (!record) return res.status(400).json({ error: 'Reset link is invalid or has expired.' }); const passwordHash = await bcrypt.hash(password, 12); await db.updatePassword(record.user_id, passwordHash); await db.markPasswordResetTokenUsed(record.id); // Invalidate any active refresh token so all existing sessions are logged out await db.clearRefreshToken(record.user_id); return res.json({ message: 'Password updated. You can now sign in.' }); } // ── Me ──────────────────────────────────────────────────────────────────────── async function me(req, res) { const user = await db.findById(req.user.sub); if (!user) return res.status(404).json({ error: 'User not found.' }); return res.json({ user: userPublic(user) }); } module.exports = { register, verifyEmail, login, ssoLogin, logout, refresh, forgotPassword, validateResetToken, resetPassword, me };