/** * AuthService - JWT-based authentication for UI sessions */ import * as plugins from '../plugins.ts'; import { User, Session } from '../models/index.ts'; import { AuditService } from './audit.service.ts'; export interface IJwtPayload { sub: string; // User ID email: string; sessionId: string; type: 'access' | 'refresh'; iat: number; exp: number; } export interface IAuthResult { success: boolean; user?: User; accessToken?: string; refreshToken?: string; sessionId?: string; errorCode?: string; errorMessage?: string; } export interface IAuthConfig { jwtSecret: string; accessTokenExpiresIn: number; // seconds (default: 15 minutes) refreshTokenExpiresIn: number; // seconds (default: 7 days) issuer: string; } export class AuthService { private config: IAuthConfig; private auditService: AuditService; constructor(config: Partial = {}) { this.config = { jwtSecret: config.jwtSecret || Deno.env.get('JWT_SECRET') || 'change-me-in-production', accessTokenExpiresIn: config.accessTokenExpiresIn || 15 * 60, // 15 minutes refreshTokenExpiresIn: config.refreshTokenExpiresIn || 7 * 24 * 60 * 60, // 7 days issuer: config.issuer || 'stack.gallery', }; this.auditService = new AuditService({ actorType: 'system' }); } /** * Login with email and password */ public async login( email: string, password: string, options: { userAgent?: string; ipAddress?: string } = {} ): Promise { const auditContext = AuditService.withContext({ actorIp: options.ipAddress, actorUserAgent: options.userAgent, actorType: 'anonymous', }); // Find user by email let user: User | null = null; try { user = await User.findByEmail(email); } catch (err) { console.error('[AuthService] Database error finding user:', err); return { success: false, errorCode: 'DATABASE_ERROR', errorMessage: 'Unable to verify credentials. Please try again.', }; } if (!user) { await auditContext.logUserLogin('', false, 'User not found'); return { success: false, errorCode: 'INVALID_CREDENTIALS', errorMessage: 'Invalid email or password', }; } // Verify password const isValid = await user.verifyPassword(password); if (!isValid) { await auditContext.logUserLogin(user.id, false, 'Invalid password'); return { success: false, errorCode: 'INVALID_CREDENTIALS', errorMessage: 'Invalid email or password', }; } // Check if user is active if (!user.isActive) { await auditContext.logUserLogin(user.id, false, 'Account inactive'); return { success: false, errorCode: 'ACCOUNT_INACTIVE', errorMessage: 'Account is inactive', }; } // Create session const session = await Session.createSession({ userId: user.id, userAgent: options.userAgent || '', ipAddress: options.ipAddress || '', }); // Generate tokens const accessToken = await this.generateAccessToken(user, session.id); const refreshToken = await this.generateRefreshToken(user, session.id); // Update user last login user.lastLoginAt = new Date(); await user.save(); // Audit log await AuditService.withContext({ actorId: user.id, actorType: 'user', actorIp: options.ipAddress, actorUserAgent: options.userAgent, }).logUserLogin(user.id, true); return { success: true, user, accessToken, refreshToken, sessionId: session.id, }; } /** * Refresh access token using refresh token */ public async refresh(refreshToken: string): Promise { // Verify refresh token const payload = await this.verifyToken(refreshToken); if (!payload) { return { success: false, errorCode: 'INVALID_TOKEN', errorMessage: 'Invalid refresh token', }; } if (payload.type !== 'refresh') { return { success: false, errorCode: 'INVALID_TOKEN_TYPE', errorMessage: 'Not a refresh token', }; } // Validate session const session = await Session.findValidSession(payload.sessionId); if (!session) { return { success: false, errorCode: 'SESSION_INVALID', errorMessage: 'Session is invalid or expired', }; } // Get user const user = await User.findById(payload.sub); if (!user || !user.isActive) { return { success: false, errorCode: 'USER_INVALID', errorMessage: 'User not found or inactive', }; } // Update session activity await session.touchActivity(); // Generate new access token const accessToken = await this.generateAccessToken(user, session.id); return { success: true, user, accessToken, sessionId: session.id, }; } /** * Logout - invalidate session */ public async logout( sessionId: string, options: { userId?: string; ipAddress?: string } = {} ): Promise { const session = await Session.findValidSession(sessionId); if (!session) return false; await session.invalidate('logout'); if (options.userId) { await AuditService.withContext({ actorId: options.userId, actorType: 'user', actorIp: options.ipAddress, }).logUserLogout(options.userId); } return true; } /** * Logout all sessions for a user */ public async logoutAll( userId: string, options: { ipAddress?: string } = {} ): Promise { const count = await Session.invalidateAllUserSessions(userId, 'logout_all'); await AuditService.withContext({ actorId: userId, actorType: 'user', actorIp: options.ipAddress, }).log('USER_LOGOUT', 'user', { resourceId: userId, metadata: { sessionsInvalidated: count }, success: true, }); return count; } /** * Validate access token and return user */ public async validateAccessToken(accessToken: string): Promise<{ user: User; sessionId: string } | null> { const payload = await this.verifyToken(accessToken); if (!payload || payload.type !== 'access') return null; // Validate session is still valid const session = await Session.findValidSession(payload.sessionId); if (!session) return null; const user = await User.findById(payload.sub); if (!user || !user.isActive) return null; return { user, sessionId: payload.sessionId }; } /** * Generate access token */ private async generateAccessToken(user: User, sessionId: string): Promise { const now = Math.floor(Date.now() / 1000); const payload: IJwtPayload = { sub: user.id, email: user.email, sessionId, type: 'access', iat: now, exp: now + this.config.accessTokenExpiresIn, }; return await this.signToken(payload); } /** * Generate refresh token */ private async generateRefreshToken(user: User, sessionId: string): Promise { const now = Math.floor(Date.now() / 1000); const payload: IJwtPayload = { sub: user.id, email: user.email, sessionId, type: 'refresh', iat: now, exp: now + this.config.refreshTokenExpiresIn, }; return await this.signToken(payload); } /** * Sign a JWT token */ private async signToken(payload: IJwtPayload): Promise { const header = { alg: 'HS256', typ: 'JWT' }; const encodedHeader = this.base64UrlEncode(JSON.stringify(header)); const encodedPayload = this.base64UrlEncode(JSON.stringify(payload)); const data = `${encodedHeader}.${encodedPayload}`; const signature = await this.hmacSign(data); return `${data}.${signature}`; } /** * Verify and decode a JWT token */ private async verifyToken(token: string): Promise { try { const parts = token.split('.'); if (parts.length !== 3) return null; const [encodedHeader, encodedPayload, signature] = parts; const data = `${encodedHeader}.${encodedPayload}`; // Verify signature const expectedSignature = await this.hmacSign(data); if (signature !== expectedSignature) return null; // Decode payload const payload: IJwtPayload = JSON.parse(this.base64UrlDecode(encodedPayload)); // Check expiration const now = Math.floor(Date.now() / 1000); if (payload.exp < now) return null; return payload; } catch { return null; } } /** * HMAC-SHA256 sign */ private async hmacSign(data: string): Promise { const encoder = new TextEncoder(); const key = await crypto.subtle.importKey( 'raw', encoder.encode(this.config.jwtSecret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] ); const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data)); return this.base64UrlEncode(String.fromCharCode(...new Uint8Array(signature))); } /** * Base64 URL encode */ private base64UrlEncode(str: string): string { const base64 = btoa(str); return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); } /** * Base64 URL decode */ private base64UrlDecode(str: string): string { let base64 = str.replace(/-/g, '+').replace(/_/g, '/'); while (base64.length % 4) { base64 += '='; } return atob(base64); } /** * Hash a password using bcrypt-like approach with Web Crypto * Note: In production, use a proper bcrypt library */ public static async hashPassword(password: string): Promise { const salt = crypto.getRandomValues(new Uint8Array(16)); const saltHex = Array.from(salt) .map((b) => b.toString(16).padStart(2, '0')) .join(''); const encoder = new TextEncoder(); const data = encoder.encode(saltHex + password); // Multiple rounds of hashing for security let hash = data; for (let i = 0; i < 10000; i++) { hash = new Uint8Array(await crypto.subtle.digest('SHA-256', hash)); } const hashHex = Array.from(hash) .map((b) => b.toString(16).padStart(2, '0')) .join(''); return `${saltHex}:${hashHex}`; } /** * Verify a password against a hash */ public static async verifyPassword(password: string, storedHash: string): Promise { const [saltHex, expectedHash] = storedHash.split(':'); if (!saltHex || !expectedHash) return false; const encoder = new TextEncoder(); const data = encoder.encode(saltHex + password); let hash = data; for (let i = 0; i < 10000; i++) { hash = new Uint8Array(await crypto.subtle.digest('SHA-256', hash)); } const hashHex = Array.from(hash) .map((b) => b.toString(16).padStart(2, '0')) .join(''); return hashHex === expectedHash; } }