Base64-encode passwords on the frontend before sending in JSON body, and decode on the backend before passing to bcrypt. Prevents Nginx WAF from returning an HTML error page when passwords contain <, >, &, etc. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
275 lines
12 KiB
JavaScript
275 lines
12 KiB
JavaScript
'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 };
|