alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
589 lines (523 loc) • 19.9 kB
text/typescript
import { $inject, Alepha, AlephaError } from "alepha";
import {
type VerificationController,
VerificationService,
} from "alepha/api/verifications";
import { $cache } from "alepha/cache";
import { DatabaseCacheProvider } from "alepha/cache/database";
import { CaptchaProvider } from "alepha/captcha";
import { DateTimeProvider } from "alepha/datetime";
import { $logger } from "alepha/logger";
import { CryptoProvider } from "alepha/security";
import { BadRequestError, ConflictError, HttpError } from "alepha/server";
import { $client } from "alepha/server/links";
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 type { CompleteRegistrationRequest } from "../schemas/completeRegistrationRequestSchema.ts";
import type { RegisterRequest } from "../schemas/registerRequestSchema.ts";
import type { RegistrationIntentResponse } from "../schemas/registrationIntentResponseSchema.ts";
import { CredentialService } from "./CredentialService.ts";
import { UsernameSlugger } from "./UsernameSlugger.ts";
/**
* Intent stored in cache during the registration flow.
*/
interface RegistrationIntent {
data: {
username?: string;
email?: string;
phoneNumber?: string;
firstName?: string;
lastName?: string;
picture?: string;
passwordHash: string;
};
requirements: {
email: boolean;
phone: boolean;
captcha: boolean;
};
realmName?: string;
expiresAt: string;
}
const INTENT_TTL_MINUTES = 10;
export class RegistrationService {
protected readonly alepha = $inject(Alepha);
protected readonly log = $logger();
protected readonly dateTimeProvider = $inject(DateTimeProvider);
protected readonly cryptoProvider = $inject(CryptoProvider);
// Validation goes through the public controller; code *creation* uses the
// service directly so the raw token is never exposed over HTTP.
protected readonly verificationController = $client<VerificationController>();
protected readonly verificationService = $inject(VerificationService);
protected readonly realmProvider = $inject(RealmProvider);
protected readonly credentialService = $inject(CredentialService);
protected readonly captchaProvider = $inject(CaptchaProvider);
protected readonly usernameSlugger = $inject(UsernameSlugger);
protected readonly intentCache = $cache<RegistrationIntent>({
// Pinned to the SQL-backed cache so:
// - phase 2 reliably reads the partial-registration payload phase 1 wrote;
// - the password hash held in the intent never lives in a distributed
// K/V outside the user's own DB unless they explicitly opt in.
provider: DatabaseCacheProvider,
name: "api:users:registrations",
ttl: [INTENT_TTL_MINUTES, "minutes"],
});
protected readonly rateLimitCache = $cache<number>({
// Use the SQL-backed cache so `incr()` is atomic (`INSERT ... ON CONFLICT
// DO UPDATE SET count = count + 1`). Cloudflare KV silently coalesces
// concurrent writes to the same key, which makes the limiter useless
// against bursts.
provider: DatabaseCacheProvider,
name: "api:users:registration-rate-limit",
ttl: [15, "minutes"],
});
protected userAudits(realmName?: string) {
const realm = this.realmProvider.getRealm(realmName);
if (realm.features.audits) {
return this.alepha.inject(UserAudits);
}
return undefined;
}
protected userNotifications(realmName?: string) {
const realm = this.realmProvider.getRealm(realmName);
if (realm.features.notifications) {
return this.alepha.inject(UserNotifications);
}
throw new AlephaError(
`Notifications feature is not enabled for realm "${realmName}". Please enable it in the realm settings to use registration notifications.`,
);
}
/**
* Phase 1: Create a registration intent.
*
* Validates the registration data, checks for existing users,
* creates verification sessions, and stores the intent in cache.
*/
public async createRegistrationIntent(
body: RegisterRequest,
userRealmName?: string,
): Promise<RegistrationIntentResponse> {
this.log.trace("Creating registration intent", {
email: body.email,
username: body.username,
userRealmName,
});
const realm = this.realmProvider.getRealm(userRealmName);
const realmSettings = await realm.getSettings();
// IP rate limiting — threshold driven by realm settings so dev/e2e can
// bump it (a single localhost IP otherwise burns through the default 10
// before the suite finishes).
const request = this.alepha.store.get("alepha.http.request");
const ipKey = request?.ip ? `register:ip:${request.ip}` : undefined;
if (ipKey) {
const count = (await this.rateLimitCache.get(ipKey)) ?? 0;
if (count >= realmSettings.registrationIpMaxAttempts) {
this.log.warn("Registration rate limit exceeded", { ip: request?.ip });
throw new BadRequestError(
"Too many registration attempts, please try again later",
);
}
await this.rateLimitCache.set(ipKey, count + 1);
}
// Check if registration is allowed
if (realmSettings?.registrationAllowed === false) {
this.log.warn("Registration not allowed for realm", { userRealmName });
throw new BadRequestError("Registration is not allowed");
}
// Validate required fields based on settings
if (realmSettings?.username === "required" && !body.username) {
this.log.debug("Registration rejected: username required", {
userRealmName,
});
throw new BadRequestError("Username is required");
}
// In "email" mode the server is authoritative — any username sent in
// the request is dropped on the floor and replaced by the slugger
// output below.
if (realmSettings?.username === "email") {
body.username = undefined;
}
if (body.username) {
// Security note: usernameRegExp is admin-controlled (from realmAuthSettingsAtom),
// not user input. Default is ^[a-zA-Z0-9_-]{3,30}$ which is ReDoS-safe.
// No need for regex timeout or safe-regex validation here.
const usernameRegExp = realmSettings?.usernameRegExp;
if (usernameRegExp) {
const regex = new RegExp(usernameRegExp);
if (!regex.test(body.username)) {
this.log.debug("Registration rejected: username regex mismatch", {
userRealmName,
username: body.username,
});
throw new BadRequestError(
"Username does not meet the required format",
);
}
}
// Manual usernames must also clear the realm blocklist — same set
// that the slugger uses, so apps can't be sidestepped by clients
// POSTing the reserved name directly.
if (await this.usernameSlugger.isBlocked(userRealmName, body.username)) {
this.log.debug("Registration rejected: username is blocked", {
userRealmName,
username: body.username,
});
throw new BadRequestError("This username is not available");
}
}
if (realmSettings?.email === "required" && !body.email) {
this.log.debug("Registration rejected: email required", {
userRealmName,
});
throw new BadRequestError("Email is required");
}
if (realmSettings?.phoneNumber === "required" && !body.phoneNumber) {
this.log.debug("Registration rejected: phone required", {
userRealmName,
});
throw new BadRequestError("Phone number is required");
}
if (
realmSettings?.firstNameLastName === "required" &&
(!body.firstName?.trim() || !body.lastName?.trim())
) {
this.log.debug("Registration rejected: first/last name required", {
userRealmName,
});
throw new BadRequestError("First name and last name are required");
}
// In "email" mode, derive the username from the email *now* so that
// checkUserAvailability picks it up too, the DB unique index sees a
// concrete value, and the persisted intent already carries the final
// username.
if (realmSettings?.username === "email") {
if (!body.email) {
throw new BadRequestError(
"Email is required to derive a username from email",
);
}
const base = this.usernameSlugger.slug(body.email);
body.username = await this.usernameSlugger.pickAvailable(
userRealmName,
base,
);
}
// Check for existing users (username, email, phone)
await this.checkUserAvailability(body, userRealmName);
// Validate password against realm policy
this.credentialService.validatePasswordPolicy(
body.password,
realmSettings.passwordPolicy,
);
// Validate captcha BEFORE any side effects (email sends, rate-limit counters).
// This blocks bots from triggering verification emails on other people's addresses.
if (realmSettings?.captchaRequired === true) {
if (!body.captchaToken) {
throw new BadRequestError("Captcha verification is required");
}
const valid = await this.captchaProvider.verify(body.captchaToken);
if (!valid) {
throw new BadRequestError("Captcha verification failed");
}
}
// Hash the password
const passwordHash = await this.cryptoProvider.hashPassword(body.password);
// Determine requirements based on realm settings
const requirements = {
email: realmSettings?.verifyEmailRequired === true && !!body.email,
phone: realmSettings?.verifyPhoneRequired === true && !!body.phoneNumber,
captcha: false, // validated above, single-use — no gate at complete time
};
// Create verification sessions and send codes
if (requirements.email && body.email) {
await this.sendEmailVerification(body.email, userRealmName);
}
if (requirements.phone && body.phoneNumber) {
await this.sendPhoneVerification(body.phoneNumber, userRealmName);
}
// Generate intent ID and expiration
const intentId = this.cryptoProvider.randomUUID();
const expiresAt = this.dateTimeProvider
.now()
.add(INTENT_TTL_MINUTES, "minutes")
.toISOString();
// Store intent in cache
const intent: RegistrationIntent = {
data: {
username: body.username,
email: body.email,
phoneNumber: body.phoneNumber,
firstName: body.firstName,
lastName: body.lastName,
picture: body.picture,
passwordHash,
},
requirements,
realmName: userRealmName,
expiresAt,
};
await this.intentCache.set(intentId, intent);
this.log.info("Registration intent created", {
intentId,
email: body.email,
username: body.username,
requiresEmailVerification: requirements.email,
requiresPhoneVerification: requirements.phone,
});
return {
intentId,
expectCaptcha: requirements.captcha,
captchaSiteKey: requirements.captcha
? this.captchaProvider.getSiteKey()
: undefined,
expectEmailVerification: requirements.email,
expectPhoneVerification: requirements.phone,
expiresAt,
};
}
/**
* Phase 2: Complete registration using an intent.
*
* Validates all requirements (verification codes, captcha),
* creates the user and credentials, and returns the user.
*/
public async completeRegistration(
body: CompleteRegistrationRequest,
): Promise<UserEntity> {
this.log.trace("Completing registration", { intentId: body.intentId });
// Fetch intent from cache
const intent = await this.intentCache.get(body.intentId);
if (!intent) {
this.log.warn("Invalid or expired registration intent", {
intentId: body.intentId,
});
throw new HttpError({
status: 410,
message: "Invalid or expired registration intent",
});
}
const userRealmName = intent.realmName;
const userRepository = this.realmProvider.userRepository(userRealmName);
const identityRepository =
this.realmProvider.identityRepository(userRealmName);
// Validate email verification if required
if (intent.requirements.email) {
if (!body.emailCode) {
this.log.debug("Registration completion missing email code", {
intentId: body.intentId,
});
throw new BadRequestError("Email verification code is required");
}
if (!intent.data.email) {
throw new BadRequestError("Email is missing from registration intent");
}
await this.verifyEmailCode(intent.data.email, body.emailCode);
}
// Validate phone verification if required
if (intent.requirements.phone) {
if (!body.phoneCode) {
this.log.debug("Registration completion missing phone code", {
intentId: body.intentId,
});
throw new BadRequestError("Phone verification code is required");
}
if (!intent.data.phoneNumber) {
throw new BadRequestError(
"Phone number is missing from registration intent",
);
}
await this.verifyPhoneCode(intent.data.phoneNumber, body.phoneCode);
}
// Captcha is validated at intent creation (see createIntent) so bots can't
// trigger verification emails; nothing to re-check here.
// Final availability check (race condition guard)
await this.checkUserAvailability(
{
username: intent.data.username,
email: intent.data.email,
phoneNumber: intent.data.phoneNumber,
},
userRealmName,
);
const realm = this.realmProvider.getRealm(userRealmName);
const realmSettings = await realm.getSettings();
// Create the user
const user = await userRepository.create({
realm: realm.name,
username: intent.data.username,
email: intent.data.email,
phoneNumber: intent.data.phoneNumber,
firstName: intent.data.firstName,
lastName: intent.data.lastName,
picture: intent.data.picture,
roles: realmSettings.defaultRoles,
enabled: true,
emailVerified: intent.requirements.email, // Marked as verified if we verified during registration
});
// Create credentials identity
await identityRepository.create({
userId: user.id,
provider: "credentials",
password: intent.data.passwordHash,
});
// Invalidate intent after both user and identity creation succeed,
// so the user can retry if either operation fails.
await this.intentCache.invalidate(body.intentId);
this.log.info("User registered successfully", {
userId: user.id,
email: user.email,
username: user.username,
});
await this.userAudits(userRealmName)?.user.log("create", {
resourceType: "user",
userId: user.id,
userEmail: user.email ?? undefined,
userRealm: realm.name,
resourceId: user.id,
description: "User registered",
metadata: {
username: user.username,
email: user.email,
emailVerified: user.emailVerified,
registrationMethod: "credentials",
},
});
return user;
}
/**
* Check if username, email, and phone are available.
*/
protected async checkUserAvailability(
body: Pick<RegisterRequest, "username" | "email" | "phoneNumber">,
userRealmName?: string,
): Promise<void> {
const realm = this.realmProvider.getRealm(userRealmName);
const userRepository = this.realmProvider.userRepository(userRealmName);
if (body.username) {
const existingUser = await userRepository.findOne({
where: { realm: realm.name, username: { ilike: body.username } },
});
if (existingUser) {
this.log.debug("Username already taken", { username: body.username });
throw new ConflictError("User with this username already exists");
}
}
if (body.email) {
const existingUser = await userRepository.findOne({
where: { realm: realm.name, email: { eq: body.email } },
});
if (existingUser) {
this.log.debug("Email already taken", { email: body.email });
throw new ConflictError("User with this email already exists");
}
}
if (body.phoneNumber) {
const existingUser = await userRepository.findOne({
where: { realm: realm.name, phoneNumber: { eq: body.phoneNumber } },
});
if (existingUser) {
this.log.debug("Phone number already taken", {
phoneNumber: body.phoneNumber,
});
throw new ConflictError("User with this phone number already exists");
}
}
}
/**
* Send email verification code.
*/
protected async sendEmailVerification(
email: string,
realmName?: string,
): Promise<void> {
this.log.debug("Sending email verification code", { email });
const verification = await this.verificationService.createVerification({
type: "code",
target: email,
});
await this.userNotifications(realmName).emailVerification.push({
contact: email,
variables: {
email,
code: verification.token,
expiresInMinutes: Math.floor(verification.codeExpiration / 60),
},
});
this.log.debug("Email verification code sent", { email });
}
/**
* Send phone verification code.
*/
protected async sendPhoneVerification(
phoneNumber: string,
realmName?: string,
): Promise<void> {
this.log.debug("Sending phone verification code", { phoneNumber });
try {
const verification = await this.verificationService.createVerification({
type: "code",
target: phoneNumber,
});
await this.userNotifications(realmName).phoneVerification.push({
contact: phoneNumber,
variables: {
phoneNumber,
code: verification.token,
expiresInMinutes: Math.floor(verification.codeExpiration / 60),
},
});
this.log.debug("Phone verification code sent", { phoneNumber });
} catch (error) {
// Silent fail - verification service may have rate limiting
this.log.warn("Failed to send phone verification code", {
phoneNumber,
error,
});
}
}
/**
* Verify email code using verification service.
*/
protected async verifyEmailCode(email: string, code: string): Promise<void> {
const result = await this.verificationController
.validateVerificationCode({
params: { type: "code" },
body: { target: email, token: code },
})
.catch(() => {
this.log.warn("Invalid email verification code", { email });
throw new BadRequestError("Invalid or expired email verification code");
});
if (result.alreadyVerified) {
this.log.warn("Email verification code already used", { email });
throw new BadRequestError(
"Email verification code has already been used",
);
}
}
/**
* Verify phone code using verification service.
*/
protected async verifyPhoneCode(
phoneNumber: string,
code: string,
): Promise<void> {
const result = await this.verificationController
.validateVerificationCode({
params: { type: "code" },
body: { target: phoneNumber, token: code },
})
.catch(() => {
this.log.warn("Invalid phone verification code", { phoneNumber });
throw new BadRequestError("Invalid or expired phone verification code");
});
if (result.alreadyVerified) {
this.log.warn("Phone verification code already used", { phoneNumber });
throw new BadRequestError(
"Phone verification code has already been used",
);
}
}
}