UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

832 lines (728 loc) 25.2 kB
import { randomInt } from "node:crypto"; import { $inject, Alepha } from "alepha"; import type { FileController } from "alepha/api/files"; import { CacheProvider } from "alepha/cache"; import { DateTimeProvider } from "alepha/datetime"; import { $logger } from "alepha/logger"; import { CryptoProvider, InvalidCredentialsError, type UserAccount, } from "alepha/security"; import { BadRequestError, UnauthorizedError } from "alepha/server"; import type { OAuth2Profile } from "alepha/server/auth"; import { $client } from "alepha/server/links"; import { FileSystemProvider } from "alepha/system"; import { SessionAudits } from "../audits/SessionAudits.ts"; import { UserAudits } from "../audits/UserAudits.ts"; import type { UserEntity } from "../entities/users.ts"; import { UserNotifications } from "../notifications/UserNotifications.ts"; import { RealmProvider } from "../providers/RealmProvider.ts"; import { UsernameSlugger } from "./UsernameSlugger.ts"; export class SessionService { protected readonly alepha = $inject(Alepha); protected readonly fsp = $inject(FileSystemProvider); protected readonly dateTimeProvider = $inject(DateTimeProvider); protected readonly cryptoProvider = $inject(CryptoProvider); protected readonly log = $logger(); protected readonly realmProvider = $inject(RealmProvider); protected readonly fileController = $client<FileController>(); protected readonly cacheProvider = $inject(CacheProvider); protected readonly usernameSlugger = $inject(UsernameSlugger); protected userAudits(realmName?: string) { const realm = this.realmProvider.getRealm(realmName); if (realm.features.audits) { return this.alepha.inject(UserAudits); } return undefined; } protected sessionAudits(realmName?: string) { const realm = this.realmProvider.getRealm(realmName); if (realm.features.audits) { return this.alepha.inject(SessionAudits); } return undefined; } protected userNotifications(realmName?: string) { const realm = this.realmProvider.getRealm(realmName); if (realm.features.notifications) { return this.alepha.inject(UserNotifications); } return undefined; } public users(userRealmName?: string) { return this.realmProvider.userRepository(userRealmName); } public sessions(userRealmName?: string) { return this.realmProvider.sessionRepository(userRealmName); } public identities(userRealmName?: string) { return this.realmProvider.identityRepository(userRealmName); } /** * Check if user should be auto-promoted to admin based on adminEmails/adminUsernames settings. * If user matches and doesn't have admin role, promote them. */ protected async ensureAdminRole( user: { id: string; email?: string | null; username?: string | null; roles: string[]; }, userRealmName?: string, ): Promise<boolean> { if (user.roles.includes("admin")) return false; const realm = this.realmProvider.getRealm(userRealmName); const settings = await realm.getSettings(); const { name } = realm; const adminEmails = settings.adminEmails ?? []; const adminUsernames = settings.adminUsernames ?? []; const isAdminByEmail = user.email && adminEmails.includes(user.email); const isAdminByUsername = user.username && adminUsernames.includes(user.username); if (!isAdminByEmail && !isAdminByUsername) return false; // Promote to admin user.roles = [...user.roles.filter((r) => r !== "admin"), "admin"]; await this.users(userRealmName).updateById(user.id, { roles: user.roles }); const reason = isAdminByEmail ? "adminEmails" : "adminUsernames"; this.log.info(`User auto-promoted to admin via ${reason} setting`, { userId: user.id, email: user.email, username: user.username, realm: name, }); await this.userAudits(userRealmName)?.user.log("role_change", { resourceType: "user", userId: user.id, userEmail: user.email ?? undefined, userRealm: name, resourceId: user.id, description: `User auto-promoted to admin via ${reason} setting`, metadata: { addedRole: "admin", reason }, }); return true; } /** * Generate a unique username from an OAuth profile. * * Routes through {@link UsernameSlugger}, which is the same code path as * `username: "email"` registration. The OAuth profile's email is the * primary signal; if absent (rare — most IDPs return one), we fall back * to `profile.name`, then to a random handle. The slugger applies the * realm's `usernameBlocklist` and retries on collision. */ protected async generateUniqueUsername( profile: OAuth2Profile, _realmSettings: any, _users: any, realmName?: string, ): Promise<string> { const seed = profile.email ?? profile.name ?? `user-${Math.random().toString(36).slice(2, 8)}`; const base = this.usernameSlugger.slug(seed); return this.usernameSlugger.pickAvailable(realmName, base); } /** * Random delay to prevent timing attacks (50-200ms) * Uses cryptographically secure random number generation */ protected randomDelay(): Promise<void> { return new Promise((resolve) => setTimeout(resolve, randomInt(50, 201))); } protected static readonly LOGIN_CACHE_NAME = "login-rate-limit"; /** * Check if a login key is currently locked out. * Read-only — does not increment the counter. */ protected async isLoginLocked(key: string, max: number): Promise<boolean> { try { const count = await this.cacheProvider.getTyped<number>( SessionService.LOGIN_CACHE_NAME, key, ); return count != null && count >= max; } catch (error) { this.log.warn( "Failed to check login rate limit, allowing attempt", error, ); return false; } } /** * Record a failed login attempt. Uses getTyped + setTyped (not incr) so that * each write refreshes the TTL — implementing sliding-window behavior. * * Returns `true` if this failure just crossed the lockout threshold. */ protected async recordFailedLogin( key: string, max: number, windowMs: number, ): Promise<boolean> { try { const count = (await this.cacheProvider.getTyped<number>( SessionService.LOGIN_CACHE_NAME, key, )) ?? 0; const newCount = count + 1; await this.cacheProvider.setTyped( SessionService.LOGIN_CACHE_NAME, key, newCount, { ttl: windowMs }, ); return newCount === max; } catch (error) { this.log.warn("Failed to record failed login attempt", error); return false; } } /** * Validate user credentials and return the user if valid. */ public async login( provider: string, username: string, password: string, userRealmName?: string, ): Promise<UserEntity> { const realm = this.realmProvider.getRealm(userRealmName); const settings = await realm.getSettings(); const { name } = realm; const { loginRateLimit } = settings; const isEmail = username.includes("@"); const isPhone = /^[+\d][\d\s()-]+$/.test(username); const isUsername = !isEmail && !isPhone; const identities = this.identities(userRealmName); const users = this.users(userRealmName); // IP rate limit check (global, cross-realm) — before any DB work const request = this.alepha.store.get("alepha.http.request"); const ipKey = request?.ip ? `login:ip:${request.ip}` : undefined; if (ipKey) { const ipLocked = await this.isLoginLocked( ipKey, loginRateLimit.ipMaxAttempts, ); if (ipLocked) { this.log.warn("Login blocked — IP rate limit exceeded", { ip: request?.ip, }); throw new InvalidCredentialsError(); } } await this.randomDelay(); try { const where = users.createQueryWhere(); where.realm = name; if (settings.username !== "none" && isUsername) { // validate username format if regex is provided if (settings.usernameRegExp) { const regex = new RegExp(settings.usernameRegExp); if (!regex.test(username)) { this.log.warn("Username does not match required format", { provider, username, realm: name, }); await this.sessionAudits(userRealmName)?.auth.log("login", { userRealm: name, success: false, description: "Username does not match required format", metadata: { provider, username }, }); throw new InvalidCredentialsError(); } } where.username = { ilike: username }; } else if (settings.email !== "none" && isEmail) { where.email = username; } else if (settings.phoneNumber !== "none" && isPhone) { where.phoneNumber = username; } else { this.log.warn("Invalid login identifier format", { provider, username, realm: name, }); await this.sessionAudits(userRealmName)?.auth.log("login", { userRealm: name, success: false, description: "Invalid login identifier format", metadata: { provider, username }, }); throw new InvalidCredentialsError(); } const user = await users.findOne({ where }); if (!user) { this.log.warn("User not found during login attempt", { provider, username, realm: name, }); await this.sessionAudits(userRealmName)?.auth.log("login", { userRealm: name, success: false, description: "User not found", metadata: { provider, username }, }); // Only increment IP counter (no user ID to track) if (ipKey) { const justLocked = await this.recordFailedLogin( ipKey, loginRateLimit.ipMaxAttempts, loginRateLimit.windowMs, ); if (justLocked) { await this.sessionAudits(userRealmName)?.security.log( "rate_limited", { userRealm: name, success: false, description: "IP temporarily locked due to too many failed login attempts", metadata: { ip: request?.ip }, }, ); } } throw new InvalidCredentialsError(); } // Check if user account is enabled if (!user.enabled) { this.log.warn("Login attempt for disabled account", { userId: user.id, realm: name, }); await this.sessionAudits(userRealmName)?.auth.log("login", { userRealm: name, success: false, resourceId: user.id, description: "Login attempt for disabled account", metadata: { provider, username }, }); throw new InvalidCredentialsError(); } // Account rate limit check (per-realm) const accountKey = `login:account:${name}:${user.id}`; const accountLocked = await this.isLoginLocked( accountKey, loginRateLimit.accountMaxAttempts, ); if (accountLocked) { this.log.warn("Login blocked — account rate limit exceeded", { userId: user.id, realm: name, }); throw new InvalidCredentialsError(); } const identity = await identities.getOne({ where: { provider: { eq: provider }, userId: { eq: user.id }, }, }); const storedPassword = identity.password; if (!storedPassword) { this.log.error("Identity has no password configured", { provider, username, identityId: identity.id, realm: name, }); throw new InvalidCredentialsError(); } const valid = await this.cryptoProvider.verifyPassword( password, storedPassword, ); if (!valid) { this.log.warn("Invalid password during login attempt", { provider, username, realm: name, }); await this.sessionAudits(userRealmName)?.auth.log("login", { userRealm: name, success: false, resourceId: user.id, description: "Invalid password", metadata: { provider, username }, }); // Record failed attempt on both IP and account counters if (ipKey) { const ipJustLocked = await this.recordFailedLogin( ipKey, loginRateLimit.ipMaxAttempts, loginRateLimit.windowMs, ); if (ipJustLocked) { await this.sessionAudits(userRealmName)?.security.log( "rate_limited", { userRealm: name, success: false, description: "IP temporarily locked due to too many failed login attempts", metadata: { ip: request?.ip }, }, ); } } const accountJustLocked = await this.recordFailedLogin( accountKey, loginRateLimit.accountMaxAttempts, loginRateLimit.windowMs, ); if (accountJustLocked) { await this.sessionAudits(userRealmName)?.security.log( "rate_limited", { userRealm: name, resourceId: user.id, success: false, description: "Account temporarily locked due to too many failed login attempts", metadata: { userId: user.id }, }, ); // Notify user about account lockout if (user.email) { const lockoutMinutes = Math.round(loginRateLimit.windowMs / 60_000); await this.userNotifications(userRealmName)?.accountLockout.push({ contact: user.email, variables: { email: user.email, lockoutMinutes }, }); } } throw new InvalidCredentialsError(); } await this.sessionAudits(userRealmName)?.auth.log("login", { userId: user.id, userEmail: user.email ?? undefined, userRealm: name, resourceId: user.id, description: `User logged in via ${provider}`, metadata: { provider, username }, }); // Auto-promote to admin if configured await this.ensureAdminRole(user, userRealmName); return user; } catch (error) { if (error instanceof InvalidCredentialsError) { throw error; } this.log.warn("Error during login attempt", error); throw new InvalidCredentialsError(); } } public async createSession( user: UserAccount, expiresIn: number, userRealmName?: string, clientId?: string, ) { this.log.trace("Creating session", { userId: user.id, expiresIn }); const request = this.alepha.store.get("alepha.http.request"); const refreshToken = this.cryptoProvider.randomUUID(); const expiresAt = this.dateTimeProvider .now() .add(expiresIn, "seconds") .toISOString(); const nowIso = this.dateTimeProvider.nowISOString(); const session = await this.sessions(userRealmName).create({ userId: user.id, expiresAt, lastUsedAt: nowIso, ip: request?.ip, country: request?.geo?.country, userAgent: request?.userAgent, refreshToken, clientId, }); await this.users(userRealmName).updateById(user.id, { lastLoginAt: nowIso, }); this.log.info("Session created", { sessionId: session.id, userId: user.id, ip: request?.ip, }); return { refreshToken, sessionId: session.id, }; } public async refreshSession(refreshToken: string, userRealmName?: string) { this.log.trace("Refreshing session"); // getOne() throws DbEntityNotFoundError if not found — never returns null. // No null check needed here. const session = await this.sessions(userRealmName).getOne({ where: { refreshToken: { eq: refreshToken }, }, }); const now = this.dateTimeProvider.now(); const expiresAt = this.dateTimeProvider.of(session.expiresAt); if (this.dateTimeProvider.of(session.expiresAt) < now) { this.log.debug("Session expired during refresh", { sessionId: session.id, userId: session.userId, }); await this.sessions(userRealmName).deleteById(session.id); throw new UnauthorizedError("Session expired"); } // Idle timeout check — opt-in via realm settings. // Falls back to createdAt when lastUsedAt is null (pre-migration rows or // sessions that never refreshed since the column was introduced). const realm = this.realmProvider.getRealm(userRealmName); const settings = await realm.getSettings(); const idleMs = settings.refreshToken?.expirationIdle; if (idleMs && idleMs > 0) { const lastUsedRef = session.lastUsedAt ?? session.createdAt; const idleSince = now.diff(this.dateTimeProvider.of(lastUsedRef)); if (idleSince > idleMs) { this.log.info("Session expired (idle timeout)", { sessionId: session.id, userId: session.userId, idleMs: idleSince, thresholdMs: idleMs, }); await this.sessions(userRealmName).deleteById(session.id); throw new UnauthorizedError("Session expired"); } } const user = await this.users(userRealmName).getOne({ where: { id: { eq: session.userId }, }, }); // Check if user account is still enabled if (!user.enabled) { this.log.warn("Session refresh for disabled account", { userId: user.id, sessionId: session.id, }); await this.sessions(userRealmName).deleteById(session.id); throw new UnauthorizedError("Account disabled"); } // Auto-promote to admin if configured (handles "I promote you admin" case) await this.ensureAdminRole(user, userRealmName); // Update lastUsedAt — sliding-window for idle timeout enforcement. await this.sessions(userRealmName).updateById(session.id, { lastUsedAt: now.toISOString(), }); this.log.debug("Session refreshed", { sessionId: session.id, userId: session.userId, }); return { user, expiresIn: expiresAt.unix() - now.unix(), sessionId: session.id, }; } public async deleteSession(refreshToken: string, userRealmName?: string) { this.log.trace("Deleting session"); // Get session info before deletion for audit const session = await this.sessions(userRealmName).findOne({ where: { refreshToken: { eq: refreshToken } }, }); await this.sessions(userRealmName).deleteOne({ refreshToken, }); this.log.debug("Session deleted"); if (session) { const { name } = this.realmProvider.getRealm(userRealmName); await this.sessionAudits(userRealmName)?.auth.log("logout", { userId: session.userId, userRealm: name, sessionId: session.id, description: "User logged out", }); } } public async link( provider: string, profile: OAuth2Profile, userRealmName?: string, ) { this.log.trace("Linking OAuth2 profile", { provider, profileSub: profile.sub, email: profile.email, }); const realm = this.realmProvider.getRealm(userRealmName); const identities = this.identities(userRealmName); const users = this.users(userRealmName); const identity = await identities.findOne({ where: { provider, providerUserId: profile.sub, }, }); // existing identity found, return associated user if (identity) { this.log.debug("Existing identity found", { provider, identityId: identity.id, userId: identity.userId, }); const user = await users.getById(identity.userId); await this.sessionAudits(userRealmName)?.auth.log("login", { userId: user.id, userEmail: user.email ?? undefined, userRealm: realm.name, resourceId: user.id, description: `User logged in via OAuth2 (${provider})`, metadata: { provider, providerUserId: profile.sub }, }); // Auto-promote to admin if configured await this.ensureAdminRole(user, userRealmName); return user; } if (!profile.email) { this.log.debug("OAuth2 profile has no email, returning profile as-is", { provider, profileSub: profile.sub, }); return { id: profile.sub, ...profile, }; } const existing = await users.findOne({ where: { realm: realm.name, email: profile.email, }, }); if (existing) { // Refuse auto-link if the OAuth provider explicitly says email is not verified if (profile.email_verified === false) { this.log.warn( "OAuth2 profile email not verified by provider, refusing auto-link", { provider, email: profile.email, userId: existing.id }, ); throw new BadRequestError( "Cannot link account: email not verified by provider", ); } this.log.debug("Linking OAuth2 profile to existing user by email", { provider, profileSub: profile.sub, userId: existing.id, email: profile.email, }); await identities.create({ provider, providerUserId: profile.sub, userId: existing.id, }); await this.sessionAudits(userRealmName)?.auth.log("login", { userId: existing.id, userEmail: existing.email ?? undefined, userRealm: realm.name, resourceId: existing.id, description: `OAuth2 identity linked to existing user (${provider})`, metadata: { provider, providerUserId: profile.sub, linked: true }, }); // Auto-promote to admin if configured await this.ensureAdminRole(existing, userRealmName); return existing; } const realmSettings = await realm.getSettings(); const adminEmails = realmSettings?.adminEmails ?? []; const isAdmin = profile.email && adminEmails.includes(profile.email); if (realmSettings?.registrationAllowed === false && !isAdmin) { this.log.warn("Registration not allowed for realm via OAuth2", { provider, userRealmName, }); throw new BadRequestError("Account doesn't exist"); } const username = await this.generateUniqueUsername( profile, realmSettings, users, userRealmName, ); const user = await users.create({ realm: realm.name, username, email: profile.email, firstName: profile.given_name, lastName: profile.family_name, // we trust the OAuth2 provider emailVerified: true, roles: realmSettings.defaultRoles, }); if (profile.picture) { this.log.debug("Fetching user profile picture from OAuth2 provider", { provider, url: profile.picture, }); try { const response = await fetch(profile.picture); const file = this.fsp.createFile({ response, }); if (response.ok && response.body) { const fileEntity = await this.fileController.uploadFile( { body: { file }, }, { user, }, ); await users.updateById(user.id, { picture: fileEntity.id }); } } catch (error) { this.log.warn("Failed to fetch user profile picture", error); } } await this.identities(userRealmName).create({ provider, providerUserId: profile.sub, userId: user.id, }); this.log.info("New user created via OAuth2 link", { provider, userId: user.id, email: user.email, username: user.username, }); // Audit: user created via OAuth await this.userAudits(userRealmName)?.user.log("create", { resourceType: "user", userId: user.id, userEmail: user.email ?? undefined, userRealm: realm.name, resourceId: user.id, description: `User created via OAuth2 (${provider})`, metadata: { provider, providerUserId: profile.sub, username: user.username, email: user.email, }, }); // Audit: login event await this.sessionAudits(userRealmName)?.auth.log("login", { userId: user.id, userEmail: user.email ?? undefined, userRealm: realm.name, resourceId: user.id, description: `First login via OAuth2 (${provider})`, metadata: { provider, providerUserId: profile.sub, firstLogin: true }, }); // Auto-promote to admin if configured await this.ensureAdminRole(user, userRealmName); return user; } }