/** * Authentication Service * * Handles user registration, login, token management, and password operations * Uses JWT for access tokens and refresh tokens for session management */ import bcrypt from 'bcryptjs'; import jwt from 'jsonwebtoken'; import { v4 as uuidv4 } from 'uuid'; import { getDb } from '../config/db.js'; import crypto from 'crypto'; const JWT_SECRET = process.env.JWT_SECRET || 'your-jwt-secret-here-change-in-production'; const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '15m'; const REFRESH_TOKEN_EXPIRES_IN = 7 * 24 * 60 * 60; // 7 days in seconds const BCRYPT_ROUNDS = 12; /** * Register a new user */ export async function register({ email, password, name }) { const db = getDb(); const now = Math.floor(Date.now() / 1000); // Validate input if (!email || !password) { throw new Error('Email and password are required'); } if (password.length < 8) { throw new Error('Password must be at least 8 characters'); } // Check if user already exists const existingUser = db.prepare('SELECT id FROM users WHERE email = ?').get(email.toLowerCase()); if (existingUser) { throw new Error('User with this email already exists'); } // Hash password const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS); // Generate email verification token const verificationToken = crypto.randomBytes(32).toString('hex'); const verificationExpires = now + (24 * 60 * 60); // 24 hours // Create user const userId = uuidv4(); db.prepare(` INSERT INTO users ( id, email, name, password_hash, email_verified, email_verification_token, email_verification_expires, status, failed_login_attempts, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( userId, email.toLowerCase(), name || null, passwordHash, 0, // email_verified verificationToken, verificationExpires, 'active', 0, // failed_login_attempts now, now ); // TODO: Send verification email (implement email service) console.log(`[Auth] User registered: ${email} (verification token: ${verificationToken})`); return { userId, email: email.toLowerCase(), name, verificationToken // Return for testing, remove in production }; } /** * Login user and issue tokens */ export async function login({ email, password, deviceInfo, ipAddress }) { const db = getDb(); const now = Math.floor(Date.now() / 1000); if (!email || !password) { throw new Error('Email and password are required'); } // Find user const user = db.prepare(` SELECT id, email, name, password_hash, status, email_verified, failed_login_attempts, locked_until, is_system_admin FROM users WHERE email = ? `).get(email.toLowerCase()); if (!user) { throw new Error('Invalid email or password'); } // Check if account is locked if (user.locked_until && user.locked_until > now) { const minutesLeft = Math.ceil((user.locked_until - now) / 60); throw new Error(`Account is locked. Try again in ${minutesLeft} minutes`); } // Check if account is suspended if (user.status === 'suspended') { throw new Error('Account is suspended. Contact support'); } if (user.status === 'deleted') { throw new Error('Account not found'); } // Verify password const isValidPassword = await bcrypt.compare(password, user.password_hash); if (!isValidPassword) { // Increment failed login attempts const failedAttempts = (user.failed_login_attempts || 0) + 1; const lockUntil = failedAttempts >= 5 ? now + (15 * 60) : null; // Lock for 15 mins after 5 attempts db.prepare(` UPDATE users SET failed_login_attempts = ?, locked_until = ? WHERE id = ? `).run(failedAttempts, lockUntil, user.id); if (lockUntil) { throw new Error('Too many failed attempts. Account locked for 15 minutes'); } throw new Error('Invalid email or password'); } // Reset failed login attempts on successful login db.prepare(` UPDATE users SET failed_login_attempts = 0, locked_until = NULL, last_login_at = ? WHERE id = ? `).run(now, user.id); // Generate access token (JWT) const accessToken = generateAccessToken(user); // Generate refresh token const refreshToken = await createRefreshToken({ userId: user.id, deviceInfo, ipAddress }); return { accessToken, refreshToken, user: { id: user.id, email: user.email, name: user.name, emailVerified: Boolean(user.email_verified), is_system_admin: Boolean(user.is_system_admin) } }; } /** * Refresh access token using refresh token */ export async function refreshAccessToken(refreshToken) { const db = getDb(); const now = Math.floor(Date.now() / 1000); if (!refreshToken) { throw new Error('Refresh token is required'); } // Hash the token to compare const tokenHash = hashToken(refreshToken); // Find refresh token const token = db.prepare(` SELECT id, user_id, expires_at, revoked FROM refresh_tokens WHERE token_hash = ? `).get(tokenHash); if (!token) { throw new Error('Invalid refresh token'); } if (token.revoked) { throw new Error('Refresh token has been revoked'); } if (token.expires_at < now) { throw new Error('Refresh token has expired'); } // Get user const user = db.prepare(` SELECT id, email, name, status, email_verified, is_system_admin FROM users WHERE id = ? `).get(token.user_id); if (!user || user.status !== 'active') { throw new Error('User not found or inactive'); } // Generate new access token const accessToken = generateAccessToken(user); return { accessToken, user: { id: user.id, email: user.email, name: user.name, emailVerified: Boolean(user.email_verified), is_system_admin: Boolean(user.is_system_admin) } }; } /** * Revoke refresh token (logout) */ export async function revokeRefreshToken(refreshToken) { const db = getDb(); const now = Math.floor(Date.now() / 1000); if (!refreshToken) { return { success: true }; // Silently succeed if no token } const tokenHash = hashToken(refreshToken); db.prepare(` UPDATE refresh_tokens SET revoked = 1, revoked_at = ? WHERE token_hash = ? `).run(now, tokenHash); return { success: true }; } /** * Revoke all refresh tokens for a user (logout all devices) */ export async function revokeAllUserTokens(userId) { const db = getDb(); const now = Math.floor(Date.now() / 1000); db.prepare(` UPDATE refresh_tokens SET revoked = 1, revoked_at = ? WHERE user_id = ? AND revoked = 0 `).run(now, userId); return { success: true }; } /** * Request password reset */ export async function requestPasswordReset(email, ipAddress) { const db = getDb(); const now = Math.floor(Date.now() / 1000); // Find user const user = db.prepare('SELECT id, email, status FROM users WHERE email = ?') .get(email.toLowerCase()); // Always return success (don't reveal if email exists) if (!user || user.status !== 'active') { console.log(`[Auth] Password reset requested for non-existent/inactive user: ${email}`); return { success: true }; } // Generate reset token const resetToken = crypto.randomBytes(32).toString('hex'); const tokenHash = hashToken(resetToken); const expiresAt = now + (60 * 60); // 1 hour // Store reset token const tokenId = uuidv4(); db.prepare(` INSERT INTO password_reset_tokens ( id, user_id, token_hash, expires_at, used, ip_address, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?) `).run(tokenId, user.id, tokenHash, expiresAt, 0, ipAddress, now); // TODO: Send password reset email console.log(`[Auth] Password reset token for ${email}: ${resetToken}`); return { success: true, resetToken // Return for testing, remove in production }; } /** * Reset password using token */ export async function resetPassword(token, newPassword) { const db = getDb(); const now = Math.floor(Date.now() / 1000); if (!token || !newPassword) { throw new Error('Token and new password are required'); } if (newPassword.length < 8) { throw new Error('Password must be at least 8 characters'); } const tokenHash = hashToken(token); // Find reset token const resetToken = db.prepare(` SELECT id, user_id, expires_at, used FROM password_reset_tokens WHERE token_hash = ? `).get(tokenHash); if (!resetToken) { throw new Error('Invalid or expired reset token'); } if (resetToken.used) { throw new Error('Reset token has already been used'); } if (resetToken.expires_at < now) { throw new Error('Reset token has expired'); } // Hash new password const passwordHash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS); // Update password and mark token as used db.transaction(() => { db.prepare(` UPDATE users SET password_hash = ?, failed_login_attempts = 0, locked_until = NULL, updated_at = ? WHERE id = ? `).run(passwordHash, now, resetToken.user_id); db.prepare(` UPDATE password_reset_tokens SET used = 1, used_at = ? WHERE id = ? `).run(now, resetToken.id); // Revoke all refresh tokens for security db.prepare(` UPDATE refresh_tokens SET revoked = 1, revoked_at = ? WHERE user_id = ? AND revoked = 0 `).run(now, resetToken.user_id); })(); return { success: true }; } /** * Verify email with token */ export async function verifyEmail(token) { const db = getDb(); const now = Math.floor(Date.now() / 1000); if (!token) { throw new Error('Verification token is required'); } // Find user with this verification token const user = db.prepare(` SELECT id, email, email_verified, email_verification_expires FROM users WHERE email_verification_token = ? `).get(token); if (!user) { throw new Error('Invalid verification token'); } if (user.email_verified) { return { success: true, message: 'Email already verified' }; } if (user.email_verification_expires < now) { throw new Error('Verification token has expired'); } // Mark email as verified db.prepare(` UPDATE users SET email_verified = 1, email_verification_token = NULL, email_verification_expires = NULL, updated_at = ? WHERE id = ? `).run(now, user.id); return { success: true, email: user.email }; } // ==================== Helper Functions ==================== /** * Generate JWT access token */ function generateAccessToken(user) { const payload = { userId: user.id, email: user.email, emailVerified: Boolean(user.email_verified) }; return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN, issuer: 'navidocs', subject: user.id }); } /** * Create refresh token */ async function createRefreshToken({ userId, deviceInfo, ipAddress }) { const db = getDb(); const now = Math.floor(Date.now() / 1000); // Generate random token const token = crypto.randomBytes(64).toString('hex'); const tokenHash = hashToken(token); // Store in database const tokenId = uuidv4(); const expiresAt = now + REFRESH_TOKEN_EXPIRES_IN; db.prepare(` INSERT INTO refresh_tokens ( id, user_id, token_hash, device_info, ip_address, expires_at, revoked, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `).run( tokenId, userId, tokenHash, deviceInfo || null, ipAddress || null, expiresAt, 0, now ); return token; } /** * Hash token for storage */ function hashToken(token) { return crypto.createHash('sha256').update(token).digest('hex'); } /** * Verify JWT token */ export function verifyAccessToken(token) { try { const decoded = jwt.verify(token, JWT_SECRET); return { valid: true, payload: decoded }; } catch (error) { return { valid: false, error: error.message }; } } /** * Get user by ID */ export function getUserById(userId) { const db = getDb(); const user = db.prepare(` SELECT id, email, name, status, email_verified, created_at, last_login_at FROM users WHERE id = ? `).get(userId); if (!user) { return null; } return { id: user.id, email: user.email, name: user.name, status: user.status, emailVerified: Boolean(user.email_verified), createdAt: user.created_at, lastLoginAt: user.last_login_at }; }