263 lines
11 KiB
JavaScript
263 lines
11 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 };
|
|
}
|
|
|
|
// ── Register ──────────────────────────────────────────────────────────────────
|
|
|
|
async function register(req, res) {
|
|
const { email, password } = req.body || {};
|
|
|
|
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, password } = req.body || {};
|
|
|
|
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, password } = req.body || {};
|
|
|
|
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 };
|