/** * Auth Service - Core authentication business logic */ import bcrypt from 'bcrypt'; import jwt from 'jsonwebtoken'; import crypto from 'crypto'; import { Pool, PoolClient } from 'pg'; import { User, Session, Token, Tenant, RegisterInput, LoginInput, LoginResponse, RefreshInput, RefreshResponse, PasswordResetRequestInput, PasswordResetInput, ChangePasswordInput, LogoutInput, AuthTokens, JWTPayload, DeviceInfo, UserStatus, } from '../types/auth.types'; import { InvalidCredentialsError, UserNotFoundError, UserAlreadyExistsError, TenantNotFoundError, SessionExpiredError, SessionRevokedError, InvalidTokenError, AccountLockedError, AccountSuspendedError, AccountNotVerifiedError, PasswordMismatchError, WeakPasswordError, } from '../utils/errors'; import { getPool, setTenantContext, jwtConfig, passwordConfig, securityConfig } from '../config'; import { logger } from '../utils/logger'; export class AuthService { private pool: Pool; constructor() { this.pool = getPool(); } /** * Register a new user */ async register(input: RegisterInput): Promise<{ user: Omit; tenant: Tenant }> { const client = await this.pool.connect(); try { await client.query('BEGIN'); // Validate password strength this.validatePassword(input.password); // Hash password const passwordHash = await bcrypt.hash(input.password, passwordConfig.saltRounds); // Create tenant if tenant info provided let tenant: Tenant; if (input.tenantName && input.tenantSlug) { // Check if tenant slug exists const existingTenant = await client.query( 'SELECT id FROM tenants.tenants WHERE slug = $1', [input.tenantSlug] ); if (existingTenant.rows.length > 0) { throw new UserAlreadyExistsError(`Tenant with slug ${input.tenantSlug} already exists`); } // Create new tenant const tenantResult = await client.query( `INSERT INTO tenants.tenants (name, slug, owner_email, status) VALUES ($1, $2, $3, 'active') RETURNING id, name, slug, status`, [input.tenantName, input.tenantSlug, input.email] ); tenant = tenantResult.rows[0]; } else { throw new Error('Tenant name and slug are required for registration'); } // Set tenant context for RLS await setTenantContext(client, tenant.id); // Check if user exists in this tenant const existingUser = await client.query( 'SELECT id FROM users.users WHERE email = $1 AND tenant_id = $2', [input.email, tenant.id] ); if (existingUser.rows.length > 0) { throw new UserAlreadyExistsError(input.email); } // Create user const userResult = await client.query( `INSERT INTO users.users ( tenant_id, email, password_hash, first_name, last_name, display_name, status, is_owner, email_verified, password_changed_at ) VALUES ($1, $2, $3, $4, $5, $6, 'pending', true, false, NOW()) RETURNING *`, [ tenant.id, input.email, passwordHash, input.firstName || null, input.lastName || null, input.firstName && input.lastName ? `${input.firstName} ${input.lastName}` : input.firstName || input.email.split('@')[0], ] ); await client.query('COMMIT'); const user = this.sanitizeUser(userResult.rows[0]); logger.info('User registered successfully', { userId: user.id, tenantId: tenant.id }); return { user, tenant }; } catch (error) { await client.query('ROLLBACK'); throw error; } finally { client.release(); } } /** * Login user */ async login(input: LoginInput): Promise { const client = await this.pool.connect(); try { // Find user by email let userQuery = ` SELECT u.*, t.id as "tenantId", t.name as "tenantName", t.slug as "tenantSlug", t.status as "tenantStatus" FROM users.users u JOIN tenants.tenants t ON u.tenant_id = t.id WHERE u.email = $1 `; const params: string[] = [input.email]; if (input.tenantId) { userQuery += ' AND u.tenant_id = $2'; params.push(input.tenantId); } const result = await client.query(userQuery, params); if (result.rows.length === 0) { throw new InvalidCredentialsError(); } // If multiple tenants, require tenant selection if (result.rows.length > 1 && !input.tenantId) { const tenants = result.rows.map((r) => ({ id: r.tenantId, name: r.tenantName, slug: r.tenantSlug, })); throw new InvalidCredentialsError( `Multiple tenants found. Please specify tenantId. Available: ${JSON.stringify(tenants)}` ); } const userRow = result.rows[0]; const tenantId = userRow.tenant_id; // Set tenant context await setTenantContext(client, tenantId); // Check account status await this.checkAccountStatus(userRow); // Verify password const isValidPassword = await bcrypt.compare(input.password, userRow.password_hash); if (!isValidPassword) { await this.handleFailedLogin(client, userRow.id, tenantId); throw new InvalidCredentialsError(); } // Reset failed login attempts on success await client.query( 'UPDATE users.users SET failed_login_attempts = 0, locked_until = NULL WHERE id = $1 AND tenant_id = $2', [userRow.id, tenantId] ); // Generate tokens const tokens = this.generateTokens({ sub: userRow.id, email: userRow.email, tenantId, isOwner: userRow.is_owner, }); // Create session const session = await this.createSession( client, userRow.id, tenantId, tokens.refreshToken, input.deviceInfo ); // Update last login await client.query( `UPDATE users.users SET last_login_at = NOW(), last_login_ip = $3 WHERE id = $1 AND tenant_id = $2`, [userRow.id, tenantId, input.deviceInfo?.ipAddress || null] ); const user = this.sanitizeUser(userRow); logger.info('User logged in successfully', { userId: user.id, tenantId }); return { user, tokens, session: { id: session.id, deviceType: session.deviceType, expiresAt: session.expiresAt, }, }; } finally { client.release(); } } /** * Refresh access token */ async refresh(input: RefreshInput): Promise { const client = await this.pool.connect(); try { // Verify refresh token let decoded: JWTPayload; try { decoded = jwt.verify(input.refreshToken, jwtConfig.secret) as JWTPayload; } catch { throw new InvalidTokenError('Invalid refresh token'); } // Set tenant context await setTenantContext(client, decoded.tenantId); // Find session by token hash const tokenHash = this.hashToken(input.refreshToken); const sessionResult = await client.query( `SELECT s.*, u.email, u.is_owner, u.status as user_status FROM auth.sessions s JOIN users.users u ON s.user_id = u.id WHERE s.token_hash = $1 AND s.tenant_id = $2`, [tokenHash, decoded.tenantId] ); if (sessionResult.rows.length === 0) { throw new SessionExpiredError(); } const session = sessionResult.rows[0]; // Check session status if (session.status === 'revoked') { throw new SessionRevokedError(); } if (session.status === 'expired' || new Date(session.expires_at) < new Date()) { throw new SessionExpiredError(); } // Check user status if (session.user_status !== 'active') { throw new AccountSuspendedError(); } // Update session last active await client.query( 'UPDATE auth.sessions SET last_active_at = NOW() WHERE id = $1', [session.id] ); // Generate new access token const accessToken = jwt.sign( { sub: decoded.sub, email: session.email, tenantId: decoded.tenantId, isOwner: session.is_owner, } as Omit, jwtConfig.secret, { expiresIn: jwtConfig.accessTokenExpiry, issuer: jwtConfig.issuer, } ); const expiresIn = this.parseExpiry(jwtConfig.accessTokenExpiry); logger.info('Token refreshed', { userId: decoded.sub, tenantId: decoded.tenantId }); return { accessToken, expiresIn, }; } finally { client.release(); } } /** * Logout user */ async logout(input: LogoutInput): Promise<{ message: string; sessionsRevoked: number }> { const client = await this.pool.connect(); try { await setTenantContext(client, input.tenantId); let result; if (input.logoutAll) { // Revoke all sessions for user result = await client.query( `UPDATE auth.sessions SET status = 'revoked', revoked_at = NOW() WHERE user_id = $1 AND tenant_id = $2 AND status = 'active'`, [input.userId, input.tenantId] ); } else if (input.sessionId) { // Revoke specific session result = await client.query( `UPDATE auth.sessions SET status = 'revoked', revoked_at = NOW() WHERE id = $1 AND user_id = $2 AND tenant_id = $3 AND status = 'active'`, [input.sessionId, input.userId, input.tenantId] ); } else { throw new Error('Either sessionId or logoutAll must be provided'); } logger.info('User logged out', { userId: input.userId, tenantId: input.tenantId, sessionsRevoked: result.rowCount, }); return { message: 'Logged out successfully', sessionsRevoked: result.rowCount || 0, }; } finally { client.release(); } } /** * Request password reset */ async requestPasswordReset(input: PasswordResetRequestInput): Promise<{ message: string }> { const client = await this.pool.connect(); try { await setTenantContext(client, input.tenantId); // Find user const userResult = await client.query( 'SELECT id, email FROM users.users WHERE email = $1 AND tenant_id = $2', [input.email, input.tenantId] ); // Always return success to prevent email enumeration if (userResult.rows.length === 0) { return { message: 'If the email exists, a reset link will be sent' }; } const user = userResult.rows[0]; // Generate reset token const resetToken = crypto.randomBytes(32).toString('hex'); const tokenHash = this.hashToken(resetToken); const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour // Invalidate existing reset tokens await client.query( `UPDATE auth.tokens SET status = 'revoked', revoked_at = NOW() WHERE user_id = $1 AND tenant_id = $2 AND token_type = 'password_reset' AND status = 'active'`, [user.id, input.tenantId] ); // Create new reset token await client.query( `INSERT INTO auth.tokens (user_id, tenant_id, token_type, token_hash, status, expires_at) VALUES ($1, $2, 'password_reset', $3, 'active', $4)`, [user.id, input.tenantId, tokenHash, expiresAt] ); // TODO: Send email with reset link containing resetToken logger.info('Password reset requested', { userId: user.id, tenantId: input.tenantId }); return { message: 'If the email exists, a reset link will be sent' }; } finally { client.release(); } } /** * Reset password with token */ async resetPassword(input: PasswordResetInput): Promise<{ message: string }> { const client = await this.pool.connect(); try { await client.query('BEGIN'); const tokenHash = this.hashToken(input.token); // Find valid token const tokenResult = await client.query( `SELECT t.*, u.tenant_id FROM auth.tokens t JOIN users.users u ON t.user_id = u.id WHERE t.token_hash = $1 AND t.token_type = 'password_reset' AND t.status = 'active'`, [tokenHash] ); if (tokenResult.rows.length === 0) { throw new InvalidTokenError('Invalid or expired reset token'); } const token = tokenResult.rows[0]; if (new Date(token.expires_at) < new Date()) { throw new InvalidTokenError('Reset token has expired'); } await setTenantContext(client, token.tenant_id); // Validate new password this.validatePassword(input.newPassword); // Hash new password const passwordHash = await bcrypt.hash(input.newPassword, passwordConfig.saltRounds); // Update password await client.query( `UPDATE users.users SET password_hash = $1, password_changed_at = NOW(), failed_login_attempts = 0, locked_until = NULL WHERE id = $2 AND tenant_id = $3`, [passwordHash, token.user_id, token.tenant_id] ); // Mark token as used await client.query( `UPDATE auth.tokens SET status = 'used', used_at = NOW() WHERE id = $1`, [token.id] ); // Revoke all active sessions await client.query( `UPDATE auth.sessions SET status = 'revoked', revoked_at = NOW() WHERE user_id = $1 AND tenant_id = $2 AND status = 'active'`, [token.user_id, token.tenant_id] ); await client.query('COMMIT'); logger.info('Password reset successfully', { userId: token.user_id, tenantId: token.tenant_id }); return { message: 'Password reset successfully' }; } catch (error) { await client.query('ROLLBACK'); throw error; } finally { client.release(); } } /** * Change password (authenticated) */ async changePassword(input: ChangePasswordInput): Promise<{ message: string }> { const client = await this.pool.connect(); try { await setTenantContext(client, input.tenantId); // Get user const userResult = await client.query( 'SELECT id, password_hash FROM users.users WHERE id = $1 AND tenant_id = $2', [input.userId, input.tenantId] ); if (userResult.rows.length === 0) { throw new UserNotFoundError(input.userId); } const user = userResult.rows[0]; // Verify current password const isValidPassword = await bcrypt.compare(input.currentPassword, user.password_hash); if (!isValidPassword) { throw new PasswordMismatchError(); } // Validate new password this.validatePassword(input.newPassword); // Hash new password const passwordHash = await bcrypt.hash(input.newPassword, passwordConfig.saltRounds); // Update password await client.query( `UPDATE users.users SET password_hash = $1, password_changed_at = NOW() WHERE id = $2 AND tenant_id = $3`, [passwordHash, input.userId, input.tenantId] ); logger.info('Password changed', { userId: input.userId, tenantId: input.tenantId }); return { message: 'Password changed successfully' }; } finally { client.release(); } } /** * Verify access token */ async verifyToken(token: string): Promise { try { const decoded = jwt.verify(token, jwtConfig.secret) as JWTPayload; return decoded; } catch { throw new InvalidTokenError(); } } /** * Get user sessions */ async getUserSessions(userId: string, tenantId: string): Promise { const client = await this.pool.connect(); try { await setTenantContext(client, tenantId); const result = await client.query( `SELECT * FROM auth.sessions WHERE user_id = $1 AND tenant_id = $2 AND status = 'active' ORDER BY last_active_at DESC`, [userId, tenantId] ); return result.rows.map(this.mapSession); } finally { client.release(); } } // Private helper methods private validatePassword(password: string): void { const issues: string[] = []; if (password.length < passwordConfig.minLength) { issues.push(`Password must be at least ${passwordConfig.minLength} characters`); } if (passwordConfig.requireUppercase && !/[A-Z]/.test(password)) { issues.push('Password must contain at least one uppercase letter'); } if (passwordConfig.requireLowercase && !/[a-z]/.test(password)) { issues.push('Password must contain at least one lowercase letter'); } if (passwordConfig.requireNumbers && !/\d/.test(password)) { issues.push('Password must contain at least one number'); } if (passwordConfig.requireSpecialChars && !/[!@#$%^&*(),.?":{}|<>]/.test(password)) { issues.push('Password must contain at least one special character'); } if (issues.length > 0) { throw new WeakPasswordError(issues); } } private async checkAccountStatus(user: Record): Promise { const status = user.status as UserStatus; if (status === 'suspended' || status === 'banned') { throw new AccountSuspendedError(); } if (status === 'deleted') { throw new InvalidCredentialsError(); } // Check if account is locked if (user.locked_until && new Date(user.locked_until as string) > new Date()) { throw new AccountLockedError(new Date(user.locked_until as string)); } // Check if email verification is required // Uncomment to enforce email verification // if (!user.email_verified) { // throw new AccountNotVerifiedError(); // } } private async handleFailedLogin(client: PoolClient, userId: string, tenantId: string): Promise { const result = await client.query( `UPDATE users.users SET failed_login_attempts = failed_login_attempts + 1 WHERE id = $1 AND tenant_id = $2 RETURNING failed_login_attempts`, [userId, tenantId] ); const attempts = result.rows[0]?.failed_login_attempts || 0; if (attempts >= securityConfig.maxLoginAttempts) { const lockedUntil = new Date(Date.now() + securityConfig.lockoutDuration); await client.query( 'UPDATE users.users SET locked_until = $1 WHERE id = $2 AND tenant_id = $3', [lockedUntil, userId, tenantId] ); logger.warn('Account locked due to failed login attempts', { userId, tenantId, attempts }); } } private generateTokens(payload: Omit): AuthTokens { const accessToken = jwt.sign(payload, jwtConfig.secret, { expiresIn: jwtConfig.accessTokenExpiry, issuer: jwtConfig.issuer, }); const refreshToken = jwt.sign(payload, jwtConfig.secret, { expiresIn: jwtConfig.refreshTokenExpiry, issuer: jwtConfig.issuer, }); return { accessToken, refreshToken, expiresIn: this.parseExpiry(jwtConfig.accessTokenExpiry), tokenType: 'Bearer', }; } private async createSession( client: PoolClient, userId: string, tenantId: string, refreshToken: string, deviceInfo?: DeviceInfo ): Promise { const tokenHash = this.hashToken(refreshToken); const expiresAt = new Date(Date.now() + securityConfig.sessionDuration); const result = await client.query( `INSERT INTO auth.sessions ( user_id, tenant_id, token_hash, device_type, device_name, browser, browser_version, os, os_version, ip_address, user_agent, status, expires_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'active', $12) RETURNING *`, [ userId, tenantId, tokenHash, deviceInfo?.deviceType || 'unknown', deviceInfo?.deviceName || null, deviceInfo?.browser || null, deviceInfo?.browserVersion || null, deviceInfo?.os || null, deviceInfo?.osVersion || null, deviceInfo?.ipAddress || null, deviceInfo?.userAgent || null, expiresAt, ] ); return this.mapSession(result.rows[0]); } private hashToken(token: string): string { return crypto.createHash('sha256').update(token).digest('hex'); } private parseExpiry(expiry: string): number { const match = expiry.match(/^(\d+)([smhd])$/); if (!match) return 900; // default 15 minutes const value = parseInt(match[1], 10); const unit = match[2]; switch (unit) { case 's': return value; case 'm': return value * 60; case 'h': return value * 60 * 60; case 'd': return value * 24 * 60 * 60; default: return 900; } } private sanitizeUser(row: Record): Omit { const { password_hash, mfa_secret, ...rest } = row; return this.mapUser(rest); } private mapUser(row: Record): Omit { return { id: row.id as string, tenantId: row.tenant_id as string, email: row.email as string, firstName: row.first_name as string | null, lastName: row.last_name as string | null, displayName: row.display_name as string | null, avatarUrl: row.avatar_url as string | null, phone: row.phone as string | null, status: row.status as UserStatus, isOwner: row.is_owner as boolean, emailVerified: row.email_verified as boolean, emailVerifiedAt: row.email_verified_at ? new Date(row.email_verified_at as string) : null, phoneVerified: row.phone_verified as boolean, phoneVerifiedAt: row.phone_verified_at ? new Date(row.phone_verified_at as string) : null, mfaEnabled: row.mfa_enabled as boolean, passwordChangedAt: new Date(row.password_changed_at as string), failedLoginAttempts: row.failed_login_attempts as number, lockedUntil: row.locked_until ? new Date(row.locked_until as string) : null, lastLoginAt: row.last_login_at ? new Date(row.last_login_at as string) : null, lastLoginIp: row.last_login_ip as string | null, preferences: (row.preferences as User['preferences']) || { theme: 'light', language: 'es', notifications: { email: true, push: true, sms: false }, }, createdAt: new Date(row.created_at as string), updatedAt: new Date(row.updated_at as string), } as Omit; } private mapSession(row: Record): Session { return { id: row.id as string, userId: row.user_id as string, tenantId: row.tenant_id as string, tokenHash: row.token_hash as string, deviceType: row.device_type as Session['deviceType'], deviceName: row.device_name as string | null, browser: row.browser as string | null, browserVersion: row.browser_version as string | null, os: row.os as string | null, osVersion: row.os_version as string | null, ipAddress: row.ip_address as string | null, userAgent: row.user_agent as string | null, status: row.status as Session['status'], lastActiveAt: new Date(row.last_active_at as string), expiresAt: new Date(row.expires_at as string), revokedAt: row.revoked_at ? new Date(row.revoked_at as string) : null, createdAt: new Date(row.created_at as string), }; } } export const authService = new AuthService();