streamflow/backend/routes/auth.js

777 lines
26 KiB
JavaScript
Raw Permalink Normal View History

const express = require('express');
const router = express.Router();
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const { body, validationResult } = require('express-validator');
const { authLimiter } = require('../middleware/rateLimiter');
const { authenticate } = require('../middleware/auth');
const { db } = require('../database/db');
const logger = require('../utils/logger');
const { validatePassword, calculatePasswordStrength } = require('../utils/passwordPolicy');
const SecurityAuditLogger = require('../utils/securityAudit');
const {
enforceAccountLockout,
recordFailedLogin,
clearFailedAttempts,
updatePasswordExpiry,
createSession,
checkPasswordExpiry
} = require('../middleware/securityEnhancements');
const JWT_SECRET = process.env.JWT_SECRET || 'change_this_in_production';
const JWT_EXPIRES_IN = '7d';
// Register - Controlled by DISABLE_SIGNUPS environment variable
router.post('/register',
[
body('username').trim().isLength({ min: 3, max: 50 }).isAlphanumeric(),
body('email').isEmail().normalizeEmail(),
body('password').notEmpty()
],
async (req, res) => {
const ip = req.ip || req.headers['x-forwarded-for'] || req.connection.remoteAddress;
const userAgent = req.headers['user-agent'];
// Check if signups are disabled (default: true)
const disableSignups = process.env.DISABLE_SIGNUPS !== 'false';
if (disableSignups) {
await SecurityAuditLogger.logAuthEvent(null, 'registration_attempt', 'blocked', {
ip,
userAgent,
reason: 'Registration disabled'
});
return res.status(403).json({
error: 'Registration is disabled. Contact an administrator to create your account.'
});
}
// If signups are enabled, proceed with registration (fallback for future flexibility)
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { username, email, password } = req.body;
try {
// Validate password against policy
const passwordValidation = validatePassword(password, username, email);
if (!passwordValidation.valid) {
await SecurityAuditLogger.logAuthEvent(null, 'registration_attempt', 'failed', {
ip,
userAgent,
username,
reason: passwordValidation.errors.join(', ')
});
return res.status(400).json({
error: 'Password does not meet requirements',
details: passwordValidation.errors,
strength: calculatePasswordStrength(password)
});
}
const hashedPassword = await bcrypt.hash(password, 10);
const now = new Date().toISOString();
db.run(
'INSERT INTO users (username, email, password, role, must_change_password, password_changed_at, password_expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
[username, email, hashedPassword, 'user', 0, now, null],
async function(err) {
if (err) {
if (err.message.includes('UNIQUE')) {
await SecurityAuditLogger.logAuthEvent(null, 'registration_attempt', 'failed', {
ip,
userAgent,
username,
reason: 'Duplicate username or email'
});
return res.status(400).json({ error: 'Username or email already exists' });
}
logger.error('Registration error:', err);
return res.status(500).json({ error: 'Registration failed' });
}
const userId = this.lastID;
// Update password expiry
await updatePasswordExpiry(userId);
// Log successful registration
await SecurityAuditLogger.logAuthEvent(userId, 'registration', 'success', {
ip,
userAgent,
username
});
const token = jwt.sign(
{ userId, role: 'user' },
JWT_SECRET,
{ expiresIn: JWT_EXPIRES_IN }
);
// CWE-778: Log token issuance
await SecurityAuditLogger.logTokenIssuance(userId, 'JWT', {
ip,
userAgent,
expiresIn: JWT_EXPIRES_IN,
purpose: 'registration'
});
// Create session
await createSession(userId, token, req);
// Set secure HTTP-only cookie
res.cookie('auth_token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
});
res.status(201).json({
message: 'Registration successful',
token,
user: {
id: userId,
username,
email,
role: 'user'
}
});
}
);
} catch (error) {
logger.error('Registration error:', error);
await SecurityAuditLogger.logAuthEvent(null, 'registration_attempt', 'error', {
ip,
userAgent,
error: error.message
});
res.status(500).json({ error: 'Registration failed' });
}
}
);
// Login with strict rate limiting and account lockout
router.post('/login', authLimiter, enforceAccountLockout,
[
body('username').trim().notEmpty(),
body('password').notEmpty()
],
async (req, res) => {
const ip = req.ip || req.headers['x-forwarded-for'] || req.connection.remoteAddress;
const userAgent = req.headers['user-agent'];
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { username, password } = req.body;
try {
db.get(
'SELECT * FROM users WHERE username = ? OR email = ?',
[username, username],
async (err, user) => {
if (err) {
logger.error('Login error:', err);
return res.status(500).json({ error: 'Login failed' });
}
if (!user) {
await recordFailedLogin(username, ip, userAgent);
return res.status(401).json({ error: 'Invalid credentials' });
}
// Check if user is active
if (!user.is_active) {
await SecurityAuditLogger.logLoginFailure(username, 'Account disabled', { ip, userAgent });
return res.status(403).json({ error: 'Account is disabled. Contact an administrator.' });
}
const isValidPassword = await bcrypt.compare(password, user.password);
if (!isValidPassword) {
await recordFailedLogin(username, ip, userAgent);
return res.status(401).json({ error: 'Invalid credentials' });
}
// Clear failed attempts on successful password check
await clearFailedAttempts(user.id);
// Check password expiry
const expiryStatus = await checkPasswordExpiry(user.id);
if (expiryStatus.expired) {
await SecurityAuditLogger.logLoginFailure(username, 'Password expired', { ip, userAgent });
return res.status(403).json({
error: expiryStatus.message,
passwordExpired: true,
requirePasswordChange: true
});
}
// Check if 2FA is enabled
if (user.two_factor_enabled) {
// Create temporary token for 2FA verification
const tempToken = jwt.sign(
{ userId: user.id, temp: true, purpose: '2fa' },
JWT_SECRET,
{ expiresIn: '10m' }
);
// CWE-778: Log temp token issuance for 2FA
await SecurityAuditLogger.logTokenIssuance(user.id, 'TEMP_2FA', {
ip,
userAgent,
expiresIn: '10m',
purpose: '2fa'
});
await SecurityAuditLogger.logAuthEvent(user.id, '2fa_required', 'pending', { ip, userAgent });
return res.json({
require2FA: true,
tempToken,
userId: user.id,
passwordWarning: expiryStatus.warning ? expiryStatus.message : null
});
}
const token = jwt.sign(
{ userId: user.id, role: user.role },
JWT_SECRET,
{ expiresIn: JWT_EXPIRES_IN }
);
// CWE-778: Log token issuance
await SecurityAuditLogger.logTokenIssuance(user.id, 'JWT', {
ip,
userAgent,
expiresIn: JWT_EXPIRES_IN,
purpose: 'login'
});
// Update last login
db.run(
'UPDATE users SET last_login_at = ?, last_login_ip = ? WHERE id = ?',
[new Date().toISOString(), ip, user.id]
);
// Create session
await createSession(user.id, token, req);
// Log successful login
await SecurityAuditLogger.logLoginSuccess(user.id, { ip, userAgent });
// Set secure HTTP-only cookie
res.cookie('auth_token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
});
res.json({
message: 'Login successful',
token,
user: {
id: user.id,
username: user.username,
email: user.email,
role: user.role,
must_change_password: user.must_change_password === 1
},
passwordWarning: expiryStatus.warning ? expiryStatus.message : null
});
}
);
} catch (error) {
logger.error('Login error:', error);
await SecurityAuditLogger.logAuthEvent(null, 'login_attempt', 'error', {
ip,
userAgent,
error: error.message
});
res.status(500).json({ error: 'Login failed' });
}
}
);
// Verify 2FA and complete login
router.post('/verify-2fa', authLimiter,
[
body('tempToken').notEmpty(),
body('code').notEmpty()
],
async (req, res) => {
const ip = req.ip || req.headers['x-forwarded-for'] || req.connection.remoteAddress;
const userAgent = req.headers['user-agent'];
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { tempToken, code } = req.body;
try {
// Verify temp token
const decoded = jwt.verify(tempToken, JWT_SECRET);
if (!decoded.temp || decoded.purpose !== '2fa') {
return res.status(401).json({ error: 'Invalid token' });
}
const speakeasy = require('speakeasy');
db.get(
'SELECT * FROM users WHERE id = ?',
[decoded.userId],
async (err, user) => {
if (err || !user) {
logger.error('2FA verify - user not found:', err);
return res.status(401).json({ error: 'Invalid token' });
}
if (!user.two_factor_enabled) {
return res.status(400).json({ error: '2FA not enabled for this user' });
}
// Check if it's a backup code
db.get(
'SELECT id FROM two_factor_backup_codes WHERE user_id = ? AND code = ? AND used = 0',
[user.id, code.toUpperCase()],
async (err, backupCode) => {
if (backupCode) {
// Mark backup code as used
db.run(
'UPDATE two_factor_backup_codes SET used = 1, used_at = CURRENT_TIMESTAMP WHERE id = ?',
[backupCode.id]
);
logger.info(`Backup code used for user ${user.id}`);
// Log 2FA success with backup code
await SecurityAuditLogger.log2FAEvent(user.id, 'backup_code_used', 'success', { ip, userAgent });
// Generate full token
const token = jwt.sign(
{ userId: user.id, role: user.role },
JWT_SECRET,
{ expiresIn: JWT_EXPIRES_IN }
);
// CWE-778: Log token issuance after 2FA backup code
await SecurityAuditLogger.logTokenIssuance(user.id, 'JWT', {
ip,
userAgent,
expiresIn: JWT_EXPIRES_IN,
purpose: '2fa_backup_verification'
});
// Update last login
db.run(
'UPDATE users SET last_login_at = ?, last_login_ip = ? WHERE id = ?',
[new Date().toISOString(), ip, user.id]
);
// Create session
await createSession(user.id, token, req);
// Log successful login
await SecurityAuditLogger.logLoginSuccess(user.id, { ip, userAgent, method: '2fa_backup' });
// Set secure HTTP-only cookie
res.cookie('auth_token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
});
return res.json({
message: 'Login successful with backup code',
token,
user: {
id: user.id,
username: user.username,
email: user.email,
role: user.role,
must_change_password: user.must_change_password === 1
}
});
}
// Verify TOTP code
const verified = speakeasy.totp.verify({
secret: user.two_factor_secret,
encoding: 'base32',
token: code,
window: 2
});
if (!verified) {
await SecurityAuditLogger.log2FAEvent(user.id, 'totp_verification', 'failed', {
ip,
userAgent,
reason: 'Invalid code'
});
return res.status(400).json({ error: 'Invalid 2FA code' });
}
// Log 2FA success
await SecurityAuditLogger.log2FAEvent(user.id, 'totp_verification', 'success', { ip, userAgent });
// Generate full token
const token = jwt.sign(
{ userId: user.id, role: user.role },
JWT_SECRET,
{ expiresIn: JWT_EXPIRES_IN }
);
// CWE-778: Log token issuance after TOTP 2FA
await SecurityAuditLogger.logTokenIssuance(user.id, 'JWT', {
ip,
userAgent,
expiresIn: JWT_EXPIRES_IN,
purpose: '2fa_totp_verification'
});
// Update last login
db.run(
'UPDATE users SET last_login_at = ?, last_login_ip = ? WHERE id = ?',
[new Date().toISOString(), ip, user.id]
);
// Create session
await createSession(user.id, token, req);
// Log successful login
await SecurityAuditLogger.logLoginSuccess(user.id, { ip, userAgent, method: '2fa_totp' });
// Set secure HTTP-only cookie
res.cookie('auth_token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
});
res.json({
message: 'Login successful',
token,
user: {
id: user.id,
username: user.username,
email: user.email,
role: user.role,
must_change_password: user.must_change_password === 1
}
});
}
);
}
);
} catch (error) {
logger.error('2FA verify error:', error);
if (error.name === 'TokenExpiredError') {
return res.status(401).json({ error: '2FA session expired. Please login again' });
}
res.status(500).json({ error: 'Failed to verify 2FA' });
}
}
);
// Change password with enhanced security
router.post('/change-password',
[
body('currentPassword').notEmpty(),
body('newPassword').notEmpty()
],
async (req, res) => {
const token = req.headers.authorization?.split(' ')[1];
const ip = req.ip || req.headers['x-forwarded-for'] || req.connection.remoteAddress;
const userAgent = req.headers['user-agent'];
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
try {
const decoded = jwt.verify(token, JWT_SECRET);
const { currentPassword, newPassword } = req.body;
db.get(
'SELECT * FROM users WHERE id = ?',
[decoded.userId],
async (err, user) => {
if (err || !user) {
return res.status(404).json({ error: 'User not found' });
}
const isValidPassword = await bcrypt.compare(currentPassword, user.password);
if (!isValidPassword) {
await SecurityAuditLogger.logPasswordChange(user.id, 'failed', {
ip,
userAgent,
reason: 'Incorrect current password'
});
return res.status(401).json({ error: 'Current password is incorrect' });
}
// Validate new password
const passwordValidation = validatePassword(newPassword, user.username, user.email);
if (!passwordValidation.valid) {
await SecurityAuditLogger.logPasswordChange(user.id, 'failed', {
ip,
userAgent,
reason: passwordValidation.errors.join(', ')
});
return res.status(400).json({
error: 'New password does not meet requirements',
details: passwordValidation.errors,
strength: calculatePasswordStrength(newPassword)
});
}
// Check password history (prevent reuse of last 5 passwords)
db.all(
'SELECT password_hash FROM password_history WHERE user_id = ? ORDER BY changed_at DESC LIMIT 5',
[user.id],
async (err, history) => {
if (err) {
logger.error('Password history check error:', err);
return res.status(500).json({ error: 'Failed to change password' });
}
// Check if new password matches any recent password
for (const record of history || []) {
const matches = await bcrypt.compare(newPassword, record.password_hash);
if (matches) {
await SecurityAuditLogger.logPasswordChange(user.id, 'failed', {
ip,
userAgent,
reason: 'Password reused from history'
});
return res.status(400).json({
error: 'Cannot reuse any of your last 5 passwords'
});
}
}
const hashedPassword = await bcrypt.hash(newPassword, 10);
// Save old password to history
db.run(
'INSERT INTO password_history (user_id, password_hash) VALUES (?, ?)',
[user.id, user.password]
);
// Update password
db.run(
'UPDATE users SET password = ?, must_change_password = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
[hashedPassword, user.id],
async (err) => {
if (err) {
logger.error('Password change error:', err);
return res.status(500).json({ error: 'Failed to change password' });
}
// Update password expiry
await updatePasswordExpiry(user.id);
// CWE-778: Revoke all tokens on password change (security best practice)
await SecurityAuditLogger.logTokenRevocation(user.id, 'password_change', {
ip,
userAgent
});
// Log successful password change
await SecurityAuditLogger.logPasswordChange(user.id, 'success', { ip, userAgent });
res.json({ message: 'Password changed successfully' });
}
);
}
);
}
);
} catch (error) {
logger.error('Password change error:', error);
res.status(401).json({ error: 'Invalid token' });
}
}
);
// Verify token
router.get('/verify', async (req, res) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
try {
const decoded = jwt.verify(token, JWT_SECRET);
db.get(
'SELECT id, username, email, role, must_change_password, is_active FROM users WHERE id = ?',
[decoded.userId],
async (err, user) => {
if (err || !user) {
return res.status(401).json({ error: 'Invalid token' });
}
if (!user.is_active) {
return res.status(403).json({ error: 'Account is disabled' });
}
// Check password expiry for warning
const expiryStatus = await checkPasswordExpiry(user.id);
res.json({
valid: true,
user: {
...user,
must_change_password: user.must_change_password === 1
},
passwordWarning: expiryStatus.warning ? expiryStatus.message : null,
daysUntilExpiry: expiryStatus.daysRemaining || null
});
}
);
} catch (error) {
res.status(401).json({ error: 'Invalid token' });
}
});
// Check password strength
router.post('/check-password-strength',
[body('password').notEmpty()],
(req, res) => {
const { password, username, email } = req.body;
const validation = validatePassword(password, username, email);
const strength = calculatePasswordStrength(password);
res.json({
valid: validation.valid,
errors: validation.errors,
strength: {
score: strength.score,
level: strength.level,
feedback: strength.feedback
}
});
}
);
// Get security status for current user
router.get('/security-status', async (req, res) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
try {
const decoded = jwt.verify(token, JWT_SECRET);
db.get(
'SELECT id, username, two_factor_enabled, password_changed_at, password_expires_at, last_login_at, last_login_ip, failed_login_attempts FROM users WHERE id = ?',
[decoded.userId],
async (err, user) => {
if (err || !user) {
return res.status(401).json({ error: 'Invalid token' });
}
// Get active sessions count
const sessions = await new Promise((resolve, reject) => {
db.all(
'SELECT COUNT(*) as count, MAX(last_activity) as last_activity FROM active_sessions WHERE user_id = ? AND expires_at > ?',
[user.id, new Date().toISOString()],
(err, rows) => err ? reject(err) : resolve(rows[0])
);
});
// Get recent security events
let recentEvents = [];
try {
recentEvents = await SecurityAuditLogger.getUserSecurityEvents(user.id, 10);
} catch (eventErr) {
logger.error('Error fetching security events:', eventErr);
}
// Check password expiry
const expiryStatus = await checkPasswordExpiry(user.id);
res.json({
twoFactorEnabled: user.two_factor_enabled === 1,
passwordAge: user.password_changed_at ?
Math.floor((Date.now() - new Date(user.password_changed_at)) / (24 * 60 * 60 * 1000)) : null,
passwordExpiry: expiryStatus,
lastLogin: {
timestamp: user.last_login_at,
ip: user.last_login_ip
},
activeSessions: sessions.count || 0,
lastActivity: sessions.last_activity,
failedLoginAttempts: user.failed_login_attempts || 0,
recentEvents: Array.isArray(recentEvents) ? recentEvents.map(e => ({
type: e.event_type,
status: e.status,
timestamp: e.created_at
})) : []
});
}
);
} catch (error) {
logger.error('Security status error:', error);
res.status(401).json({ error: 'Invalid token' });
}
});
// Logout endpoint - invalidate session and clear cookie
router.post('/logout', authenticate, async (req, res) => {
const token = req.headers.authorization?.replace('Bearer ', '');
const ip = req.ip || req.headers['x-forwarded-for'] || req.connection.remoteAddress;
const userAgent = req.headers['user-agent'];
try {
// Delete the session from database
await new Promise((resolve, reject) => {
db.run(
'DELETE FROM active_sessions WHERE session_token = ?',
[token],
(err) => err ? reject(err) : resolve()
);
});
// CWE-778: Log token revocation on logout
await SecurityAuditLogger.logTokenRevocation(req.user.userId, 'user_logout', {
ip,
userAgent
});
// Log logout event
await SecurityAuditLogger.logAuthEvent(req.user.userId, 'logout', 'success', {
ip,
userAgent
});
// Clear the HTTP-only cookie
res.clearCookie('auth_token', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict'
});
res.json({ message: 'Logout successful' });
} catch (error) {
logger.error('Logout error:', error);
res.status(500).json({ error: 'Logout failed' });
}
});
module.exports = router;