alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
449 lines (396 loc) • 14.3 kB
text/typescript
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",
},
);
}
}