UNPKG

alepha

Version:

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

449 lines (396 loc) 14.3 kB
import { $inject, Alepha } from "alepha"; import { VerificationService } from "alepha/api/verifications"; import { $cache } from "alepha/cache"; import { DatabaseCacheProvider } from "alepha/cache/database"; import { DateTimeProvider } from "alepha/datetime"; import { $logger } from "alepha/logger"; import { CryptoProvider } from "alepha/security"; import { BadRequestError, HttpError } from "alepha/server"; import type { RealmAuthSettings } from "../atoms/realmAuthSettingsAtom.ts"; import { SessionAudits } from "../audits/SessionAudits.ts"; import { UserAudits } from "../audits/UserAudits.ts"; import { UserNotifications } from "../notifications/UserNotifications.ts"; import { RealmProvider } from "../providers/RealmProvider.ts"; import type { CompletePasswordResetRequest } from "../schemas/completePasswordResetRequestSchema.ts"; import type { PasswordResetIntentResponse } from "../schemas/passwordResetIntentResponseSchema.ts"; /** * Intent stored in cache during the password reset flow. */ interface PasswordResetIntent { email: string; userId: string; identityId: string; realmName?: string; expiresAt: string; } const INTENT_TTL_MINUTES = 10; /** * Verification purpose bucket for password-reset codes. Keeping this distinct * from the default bucket means a reset request is not rate-limited by an * unrelated email-verification code on the same address (e.g. a user who just * registered and immediately asks to reset their password). */ const PASSWORD_RESET_PURPOSE = "password-reset"; export class CredentialService { protected readonly alepha = $inject(Alepha); protected readonly log = $logger(); protected readonly cryptoProvider = $inject(CryptoProvider); protected readonly dateTimeProvider = $inject(DateTimeProvider); protected readonly verificationService = $inject(VerificationService); protected readonly realmProvider = $inject(RealmProvider); 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; } protected readonly intentCache = $cache<PasswordResetIntent>({ // Use the SQL-backed cache so phase-2 reads what phase-1 wrote with // strong consistency, and so bare deployments don't need a distributed // KV resource just to support password reset. provider: DatabaseCacheProvider, name: "api:users:password-reset-intents", ttl: [INTENT_TTL_MINUTES, "minutes"], }); 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); } /** * Validate a password against the realm's password policy. */ public validatePasswordPolicy( password: string, policy: RealmAuthSettings["passwordPolicy"], ): void { if (password.length < policy.minLength) { throw new BadRequestError( `Password must be at least ${policy.minLength} characters`, ); } if (policy.requireUppercase && !/[A-Z]/.test(password)) { throw new BadRequestError( "Password must contain at least one uppercase letter", ); } if (policy.requireLowercase && !/[a-z]/.test(password)) { throw new BadRequestError( "Password must contain at least one lowercase letter", ); } if (policy.requireNumbers && !/\d/.test(password)) { throw new BadRequestError("Password must contain at least one number"); } if (policy.requireSpecialCharacters && !/[^a-zA-Z0-9]/.test(password)) { throw new BadRequestError( "Password must contain at least one special character", ); } } /** * Phase 1: Create a password reset intent. * * Validates the email, checks for existing user with credentials, * sends verification code, and stores the intent in cache. * * @param email - User's email address * @param userRealmName - Optional realm name * @returns Intent response with intentId and expiration (always returns for security) */ public async createPasswordResetIntent( email: string, userRealmName?: string, ): Promise<PasswordResetIntentResponse> { this.log.trace("Creating password reset intent", { email, userRealmName }); // Generate intent ID and expiration upfront for consistent response const intentId = this.cryptoProvider.randomUUID(); const expiresAt = this.dateTimeProvider .now() .add(INTENT_TTL_MINUTES, "minutes") .toISOString(); // Check if password reset is allowed for this realm const realm = this.realmProvider.getRealm(userRealmName); const realmSettings = await realm.getSettings(); if (realmSettings.resetPasswordAllowed === false) { this.log.debug("Password reset not allowed for realm", { userRealmName }); return { intentId, expiresAt }; } // Find user by email (silent fail for security) const user = await this.users(userRealmName).findOne({ where: { email: { eq: email } }, }); if (!user) { // Silent fail - don't reveal that email doesn't exist this.log.debug("Password reset requested for non-existent email", { email, }); return { intentId, expiresAt }; } // Find the credentials identity for this user const identity = await this.identities(userRealmName).findOne({ where: { userId: { eq: user.id }, provider: { eq: "credentials" }, }, }); if (!identity) { // User doesn't have credentials identity (maybe OAuth only) this.log.debug("Password reset requested for user without credentials", { userId: user.id, }); return { intentId, expiresAt }; } // Create verification using verification controller // This handles: token generation, expiration, rate limiting, cooldown try { const verification = await this.verificationService.createVerification({ type: "code", target: email, purpose: PASSWORD_RESET_PURPOSE, }); // Send password reset notification with the code await this.userNotifications(userRealmName)?.passwordReset.push({ contact: email, variables: { email, code: verification.token, expiresInMinutes: Math.floor(verification.codeExpiration / 60), }, }); // Store intent in cache const intent: PasswordResetIntent = { email, userId: user.id, identityId: identity.id, realmName: userRealmName, expiresAt, }; await this.intentCache.set(intentId, intent); this.log.info("Password reset intent created", { intentId, userId: user.id, email, }); } catch (error) { // If rate limit or cooldown hit, still return success for security this.log.warn("Failed to create password reset verification", error); } return { intentId, expiresAt }; } /** * Phase 2: Complete password reset using an intent. * * Validates the verification code, updates the password, * and invalidates all existing sessions. * * @param body - Request body with intentId, code, and newPassword */ public async completePasswordReset( body: CompletePasswordResetRequest, ): Promise<void> { this.log.trace("Completing password reset", { intentId: body.intentId }); // Fetch intent from cache const intent = await this.intentCache.get(body.intentId); if (!intent) { this.log.warn("Invalid or expired password reset intent", { intentId: body.intentId, }); throw new HttpError({ status: 410, message: "Invalid or expired password reset intent", }); } // Validate password against realm policy before consuming the verification code const realm = this.realmProvider.getRealm(intent.realmName); const realmSettings = await realm.getSettings(); this.validatePasswordPolicy(body.newPassword, realmSettings.passwordPolicy); // Verify code using verification service const result = await this.verificationService .verifyCode( { type: "code", target: intent.email, purpose: PASSWORD_RESET_PURPOSE, }, body.code, ) .catch(() => { this.log.warn("Invalid verification code for password reset", { intentId: body.intentId, email: intent.email, }); throw new BadRequestError("Invalid or expired verification code"); }); // If already verified, this is a code reuse attempt if (result.alreadyVerified) { this.log.warn("Verification code reuse attempt", { intentId: body.intentId, email: intent.email, }); throw new BadRequestError("Verification code has already been used"); } // Hash the new password const hashedPassword = await this.cryptoProvider.hashPassword( body.newPassword, ); // Update the identity with new password await this.identities(intent.realmName).updateById(intent.identityId, { password: hashedPassword, }); // Invalidate all existing sessions for this user await this.sessions(intent.realmName).deleteMany({ userId: { eq: intent.userId }, }); // Invalidate intent after all operations succeed, // so the user can retry if password update or session cleanup fails. // The verification code was already consumed (verifiedAt set), so replay is // prevented even if the intent is still in cache. await this.intentCache.invalidate(body.intentId); this.log.info("Password reset completed", { userId: intent.userId, email: intent.email, }); // Audit: password reset await this.userAudits(intent.realmName)?.user.log("update", { resourceType: "user", userId: intent.userId, userEmail: intent.email, userRealm: realm.name, resourceId: intent.userId, description: "Password reset completed", metadata: { email: intent.email }, }); // Audit: sessions invalidated (security event) await this.sessionAudits(intent.realmName)?.security.log( "sessions_invalidated", { userId: intent.userId, userEmail: intent.email, userRealm: realm.name, resourceId: intent.userId, severity: "warning", description: "All sessions invalidated after password reset", }, ); } // Legacy methods kept for backward compatibility /** * @deprecated Use createPasswordResetIntent instead */ public async requestPasswordReset( email: string, userRealmName?: string, ): Promise<boolean> { await this.createPasswordResetIntent(email, userRealmName); return true; } /** * @deprecated Use completePasswordReset instead */ public async validateResetToken( email: string, token: string, _userRealmName?: string, ): Promise<string> { // Verify using verification service const isValid = await this.verificationService .verifyCode( { type: "code", target: email, purpose: PASSWORD_RESET_PURPOSE }, token, ) .catch(() => undefined); if (!isValid?.ok) { throw new BadRequestError("Invalid or expired reset token"); } return email; } /** * @deprecated Use completePasswordReset instead */ public async resetPassword( email: string, token: string, newPassword: string, userRealmName?: string, ): Promise<void> { // Verify token using verification service const result = await this.verificationService .verifyCode( { type: "code", target: email, purpose: PASSWORD_RESET_PURPOSE }, token, ) .catch(() => { throw new BadRequestError("Invalid or expired reset token"); }); // If already verified, this is a token reuse attempt if (result.alreadyVerified) { throw new BadRequestError("Invalid or expired reset token"); } // Validate password against realm policy const realm = this.realmProvider.getRealm(userRealmName); const realmSettings = await realm.getSettings(); this.validatePasswordPolicy(newPassword, realmSettings.passwordPolicy); // Find user and identity const user = await this.users(userRealmName).getOne({ where: { email: { eq: email } }, }); const identity = await this.identities(userRealmName).getOne({ where: { userId: { eq: user.id }, provider: { eq: "credentials" }, }, }); // Hash the new password const hashedPassword = await this.cryptoProvider.hashPassword(newPassword); // Update the identity with new password await this.identities(userRealmName).updateById(identity.id, { password: hashedPassword, }); // Invalidate all existing sessions for this user await this.sessions(userRealmName).deleteMany({ userId: { eq: user.id }, }); // Audit: password reset (legacy method) await this.userAudits(userRealmName)?.user.log("update", { resourceType: "user", userId: user.id, userEmail: email, userRealm: realm.name, resourceId: user.id, description: "Password reset completed (legacy)", metadata: { email }, }); // Audit: sessions invalidated await this.sessionAudits(userRealmName)?.security.log( "sessions_invalidated", { userId: user.id, userEmail: email, userRealm: realm.name, resourceId: user.id, severity: "warning", description: "All sessions invalidated after password reset", }, ); } }