import { Context, Effect, Layer } from "effect"; import { AuthError, DatabaseError, Session, User, ValidationError, } from "../../../shared/types.js"; import { PasswordService } from "./PasswordService.js"; import { TokenService } from "./TokenService.js"; import { UserRepository } from "./UserRepository.js"; import { SessionRepository } from "./SessionRepository.js"; import { AuditLogService } from "./AuditLogService.js"; import { UserManagementService } from "../../user-management/UserManagementService.js"; const MAX_FAILED_ATTEMPTS = 5; const LOCKOUT_DURATION_MS = 15 * 60 * 1000; // 15 minutes export interface AuthServiceDeps { readonly register: ( username: string, email: string, password: string, ipAddress?: string | null, userAgent?: string | null ) => Effect.Effect; readonly login: ( email: string, password: string, ipAddress?: string | null, userAgent?: string | null ) => Effect.Effect< { user: User; token: string; session: Session }, AuthError | DatabaseError >; readonly logout: ( sessionId: string, userId: string ) => Effect.Effect; readonly verifyToken: ( token: string ) => Effect.Effect<{ user: User; session: Session }, AuthError | DatabaseError>; } export class AuthService extends Context.Tag("AuthService")< AuthService, AuthServiceDeps >() { static Live = Layer.effect( this, Effect.gen(function* () { const passwordService = yield* PasswordService; const tokenService = yield* TokenService; const userRepo = yield* UserRepository; const sessionRepo = yield* SessionRepository; const auditLog = yield* AuditLogService; const userManagement = yield* UserManagementService; const sanitizeLinuxUsername = (username: string): string => { // Convert to lowercase, replace non-alphanumeric with underscore let sanitized = username .toLowerCase() .replace(/[^a-z0-9_]/g, "_") .replace(/^[0-9_]+/, "") // Cannot start with number or underscore .slice(0, 32); // Max length for Linux usernames // Ensure it starts with a letter if (!/^[a-z]/.test(sanitized)) { sanitized = "user_" + sanitized; } return sanitized; }; const generateUniqueLinuxUsername = ( baseUsername: string ): Effect.Effect => Effect.gen(function* () { let linuxUsername = sanitizeLinuxUsername(baseUsername); let counter = 0; // Keep trying until we find a unique username while (true) { const existingUser = yield* userRepo.findByUsername(linuxUsername); if (!existingUser) { return linuxUsername; } counter++; const suffix = `_${counter}`; const maxBase = 32 - suffix.length; linuxUsername = sanitizeLinuxUsername(baseUsername).slice(0, maxBase) + suffix; } }); const validateEmail = (email: string): Effect.Effect => Effect.gen(function* () { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(email)) { return yield* Effect.fail( new ValidationError("email", "Invalid email format") ); } }); const validateUsername = ( username: string ): Effect.Effect => Effect.gen(function* () { if (username.length < 3) { return yield* Effect.fail( new ValidationError( "username", "Username must be at least 3 characters long" ) ); } if (username.length > 32) { return yield* Effect.fail( new ValidationError( "username", "Username must be at most 32 characters long" ) ); } if (!/^[a-zA-Z0-9_]+$/.test(username)) { return yield* Effect.fail( new ValidationError( "username", "Username can only contain letters, numbers, and underscores" ) ); } }); const isAccountLocked = (user: User): boolean => { if (user.failedLoginAttempts < MAX_FAILED_ATTEMPTS) { return false; } // Check if lockout period has expired const now = Date.now(); const lockoutExpiry = user.updatedAt + LOCKOUT_DURATION_MS; return now < lockoutExpiry; }; const register: AuthServiceDeps["register"] = ( username, email, password, ipAddress = null, userAgent = null ) => Effect.gen(function* () { // Validate inputs yield* validateUsername(username); yield* validateEmail(email); yield* passwordService.validatePasswordStrength(password); // Check if user already exists const existingEmail = yield* userRepo.findByEmail(email); if (existingEmail) { return yield* Effect.fail( new ValidationError("email", "Email already registered") ); } const existingUsername = yield* userRepo.findByUsername(username); if (existingUsername) { return yield* Effect.fail( new ValidationError("username", "Username already taken") ); } // Hash password const passwordHash = yield* passwordService.hash(password); // Generate unique Linux username const linuxUsername = yield* generateUniqueLinuxUsername(username); // Create user in database const user = yield* userRepo.create({ username, email, passwordHash, linuxUsername, }); // Create Linux user with home directory and Claude config // This runs in the background and doesn't block registration // If it fails, user can still authenticate but won't have Linux access yield* userManagement .createUser({ username: linuxUsername, email, shell: "/bin/rbash", // Restricted bash for security diskQuotaMB: 2048, // 2GB default quota }) .pipe( Effect.catchAll((error) => { // Log the error but don't fail registration console.error( `Failed to create Linux user for ${linuxUsername}:`, error ); return Effect.succeed(void 0); }) ); // Log audit event yield* auditLog.log({ userId: user.id, action: "USER_REGISTERED", resourceType: "user", resourceId: user.id, ipAddress: ipAddress ?? null, userAgent: userAgent ?? null, }); return user; }); const login: AuthServiceDeps["login"] = ( email, password, ipAddress = null, userAgent = null ) => Effect.gen(function* () { // Find user by email const user = yield* userRepo.findByEmail(email); if (!user) { return yield* Effect.fail( new AuthError("INVALID_CREDENTIALS", "Invalid email or password") ); } // Check if account is locked if (isAccountLocked(user)) { yield* auditLog.log({ userId: user.id, action: "LOGIN_FAILED", resourceType: "user", resourceId: user.id, ipAddress: ipAddress ?? null, userAgent: userAgent ?? null, details: "Account locked due to too many failed attempts", }); return yield* Effect.fail( new AuthError( "ACCOUNT_LOCKED", "Account is locked due to too many failed login attempts. Please try again in 15 minutes." ) ); } // Verify password if (!user.passwordHash) { return yield* Effect.fail( new AuthError( "INVALID_CREDENTIALS", "Invalid email or password" ) ); } const isPasswordValid = yield* passwordService.verify( password, user.passwordHash ); if (!isPasswordValid) { // Increment failed login attempts yield* userRepo.incrementFailedLogins(user.id); yield* auditLog.log({ userId: user.id, action: "LOGIN_FAILED", resourceType: "user", resourceId: user.id, ipAddress: ipAddress ?? null, userAgent: userAgent ?? null, details: "Invalid password", }); return yield* Effect.fail( new AuthError("INVALID_CREDENTIALS", "Invalid email or password") ); } // Reset failed login attempts on successful login yield* userRepo.resetFailedLogins(user.id); // Update last login time const updatedUser = yield* userRepo.update(user.id, { lastLoginAt: Date.now(), }); // Generate a unique placeholder token to avoid constraint violations const expiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000; // 7 days const placeholderToken = `placeholder_${user.id}_${Date.now()}_${Math.random()}`; const placeholderHash = yield* tokenService.hashToken(placeholderToken); // Create session with placeholder hash const session = yield* sessionRepo.create({ userId: user.id, tokenHash: placeholderHash, ipAddress: ipAddress ?? null, userAgent: userAgent ?? null, expiresAt, }); // Generate JWT token with the session ID const token = yield* tokenService.generateToken({ sub: user.id, username: user.username, email: user.email, jti: session.id, }); // Update the session with the actual token hash const actualTokenHash = yield* tokenService.hashToken(token); const finalSession = yield* sessionRepo.updateTokenHash(session.id, actualTokenHash); // Log audit event yield* auditLog.log({ userId: user.id, action: "USER_LOGIN", resourceType: "session", resourceId: finalSession.id, ipAddress: ipAddress ?? null, userAgent: userAgent ?? null, }); return { user: updatedUser, token, session: finalSession }; }); const logout: AuthServiceDeps["logout"] = (sessionId, userId) => Effect.gen(function* () { // Revoke session yield* sessionRepo.revoke(sessionId); // Log audit event yield* auditLog.log({ userId, action: "USER_LOGOUT", resourceType: "session", resourceId: sessionId, ipAddress: null, userAgent: null, }); }); const verifyToken: AuthServiceDeps["verifyToken"] = (token) => Effect.gen(function* () { // Verify JWT const payload = yield* tokenService.verifyToken(token); // Hash token to find session const tokenHash = yield* tokenService.hashToken(token); const session = yield* sessionRepo.findByTokenHash(tokenHash); if (!session) { return yield* Effect.fail( new AuthError("INVALID_TOKEN", "Session not found") ); } // Check if session is expired if (session.expiresAt < Date.now()) { return yield* Effect.fail( new AuthError("TOKEN_EXPIRED", "Session has expired") ); } // Check if session is revoked if (session.isRevoked) { return yield* Effect.fail( new AuthError("SESSION_REVOKED", "Session has been revoked") ); } // Find user const user = yield* userRepo.findById(payload.sub); if (!user) { return yield* Effect.fail( new AuthError("USER_NOT_FOUND", "User not found") ); } // Check if user is active if (!user.isActive) { return yield* Effect.fail( new AuthError("USER_INACTIVE", "User account is inactive") ); } return { user, session }; }); return { register, login, logout, verifyToken }; }) ); }