/** * Local Auth Provider (Username/Password) * Documentation: documentation/features/audiobookshelf-integration.md */ import bcrypt from 'bcrypt'; import { IAuthProvider, LoginInitiation, CallbackParams, AuthResult, UserInfo, AuthTokens, } from './IAuthProvider'; import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt'; import { getConfigService } from '@/lib/services/config.service'; import { getEncryptionService } from '@/lib/services/encryption.service'; import { prisma } from '@/lib/db'; import { RMABLogger } from '@/lib/utils/logger'; const logger = RMABLogger.create('LocalAuth'); interface LocalLoginParams extends CallbackParams { username: string; password: string; } interface RegisterParams { username: string; password: string; } export class LocalAuthProvider implements IAuthProvider { type: 'local' = 'local'; private configService = getConfigService(); private encryptionService = getEncryptionService(); /** * Initiate login (no-op for local auth) */ async initiateLogin(): Promise { // Local auth doesn't need initiation - return empty return {}; } /** * Handle login with username/password */ async handleCallback(params: CallbackParams): Promise { try { const { username, password } = params as LocalLoginParams; if (!username || !password) { return { success: false, error: 'Username and password required' }; } const normalizedUsername = username.trim().toLowerCase(); // Find user (exclude soft-deleted users) const user = await prisma.user.findFirst({ where: { plexUsername: normalizedUsername, authProvider: 'local', deletedAt: null, // Exclude soft-deleted users }, }); if (!user) { return { success: false, error: 'Invalid username or password' }; } // Check registration status if (user.registrationStatus === 'pending_approval') { return { success: false, requiresApproval: true, }; } if (user.registrationStatus === 'rejected') { return { success: false, error: 'Account has been rejected' }; } // Verify password let passwordValid = false; try { // Decrypt the stored hash const decryptedHash = this.encryptionService.decrypt(user.authToken || ''); passwordValid = await bcrypt.compare(password, decryptedHash); } catch (error) { logger.error('Password verification failed', { error: error instanceof Error ? error.message : String(error) }); return { success: false, error: 'Invalid username or password' }; } if (!passwordValid) { return { success: false, error: 'Invalid username or password' }; } // Update last login await prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() }, }); // Generate tokens logger.info('Generating tokens for user', { id: user.id, plexId: user.plexId, username: user.plexUsername, role: user.role, authProvider: user.authProvider, }); const tokens = await this.generateTokens({ id: user.id, plexId: user.plexId, username: user.plexUsername, role: user.role, }); logger.info('Tokens generated, returning user data'); return { success: true, user: { id: user.id, plexId: user.plexId, username: user.plexUsername, role: user.role, authProvider: 'local', }, tokens, }; } catch (error) { logger.error('Login failed', { error: error instanceof Error ? error.message : String(error) }); return { success: false, error: error instanceof Error ? error.message : 'Authentication failed', }; } } /** * Register a new user */ async register(params: RegisterParams): Promise { try { const { username, password } = params; const normalizedUsername = username?.trim().toLowerCase(); // Validate if (!normalizedUsername || normalizedUsername.length < 3) { return { success: false, error: 'Username must be at least 3 characters' }; } const allowWeakPassword = process.env.ALLOW_WEAK_PASSWORD === 'true'; if (!password) { return { success: false, error: 'Password is required' }; } if (!allowWeakPassword && password.length < 8) { return { success: false, error: 'Password must be at least 8 characters' }; } // Check if registration is enabled const registrationEnabled = await this.configService.get('auth.registration_enabled'); if (registrationEnabled !== 'true') { return { success: false, error: 'Registration is disabled' }; } // Check username uniqueness (only among non-deleted users) const existing = await prisma.user.findFirst({ where: { plexUsername: normalizedUsername, authProvider: 'local', deletedAt: null, // Allow reuse of usernames from deleted accounts }, }); if (existing) { return { success: false, error: 'Username already taken' }; } // Hash password const passwordHash = await bcrypt.hash(password, 10); // Encrypt the hash before storing const encryptedHash = this.encryptionService.encrypt(passwordHash); // Determine registration status const requireApproval = (await this.configService.get('auth.require_admin_approval')) === 'true'; const registrationStatus = requireApproval ? 'pending_approval' : 'approved'; // Check if first user (make admin) const userCount = await prisma.user.count(); const isFirstUser = userCount === 0; // Create user const user = await prisma.user.create({ data: { plexId: `local-${normalizedUsername}`, plexUsername: normalizedUsername, authToken: encryptedHash, authProvider: 'local', role: isFirstUser ? 'admin' : 'user', isSetupAdmin: isFirstUser, registrationStatus: isFirstUser ? 'approved' : registrationStatus, lastLoginAt: new Date(), }, }); // If requires approval and not first user, return pending status if (requireApproval && !isFirstUser) { return { success: false, requiresApproval: true, }; } // Generate tokens for immediate login const tokens = await this.generateTokens({ id: user.id, plexId: user.plexId, username: user.plexUsername, role: user.role, }); return { success: true, user: { id: user.id, plexId: user.plexId, username: user.plexUsername, role: user.role, authProvider: 'local', }, tokens, }; } catch (error) { logger.error('Registration failed', { error: error instanceof Error ? error.message : String(error) }); return { success: false, error: error instanceof Error ? error.message : 'Registration failed', }; } } /** * Generate JWT access and refresh tokens */ private async generateTokens(userInfo: UserInfo & { plexId: string }): Promise { const tokenPayload = { sub: userInfo.id, id: userInfo.id, plexId: userInfo.plexId, username: userInfo.username, role: userInfo.role || 'user', }; logger.debug('JWT token payload', { tokenPayload }); const accessToken = generateAccessToken(tokenPayload); const refreshToken = generateRefreshToken(userInfo.id); return { accessToken, refreshToken, }; } /** * Refresh JWT tokens */ async refreshToken(refreshToken: string): Promise { // JWT refresh is handled by existing JWT utilities // This method is a placeholder for future implementation return null; } /** * Validate user has access */ async validateAccess(userInfo: UserInfo): Promise { try { // Check if user exists and is approved const user = await prisma.user.findUnique({ where: { id: userInfo.id }, }); if (!user || user.authProvider !== 'local') { return false; } // Reject soft-deleted users if (user.deletedAt) { return false; } if (user.registrationStatus === 'pending_approval' || user.registrationStatus === 'rejected') { return false; } return true; } catch (error) { logger.error('Access validation failed', { error: error instanceof Error ? error.message : String(error) }); return false; } } }