alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
515 lines (448 loc) • 15.7 kB
text/typescript
import { $inject, Alepha } from "alepha";
import {
type VerificationController,
VerificationService,
} from "alepha/api/verifications";
import { $logger } from "alepha/logger";
import type { Page } from "alepha/orm";
import { CryptoProvider } from "alepha/security";
import { BadRequestError, ConflictError } 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 { CreateUser } from "../schemas/createUserSchema.ts";
import type { UpdateUser } from "../schemas/updateUserSchema.ts";
import type { UserQuery } from "../schemas/userQuerySchema.ts";
export class UserService {
protected readonly alepha = $inject(Alepha);
protected readonly log = $logger();
// Validation (`validateVerificationCode`) goes through the public controller;
// code *creation* uses the service directly so the raw token is never exposed
// over HTTP (the controller no longer has a token-returning request action).
protected readonly verificationController = $client<VerificationController>();
protected readonly verificationService = $inject(VerificationService);
protected readonly realmProvider = $inject(RealmProvider);
protected readonly cryptoProvider = $inject(CryptoProvider);
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);
}
return undefined;
}
public users(userRealmName?: string) {
return this.realmProvider.userRepository(userRealmName);
}
/**
* Request email verification for a user.
* @param email - The email address to verify.
* @param userRealmName - Optional realm name.
* @param method - The verification method: "code" (default) or "link".
* @param verifyUrl - Base URL for verification link (required when method is "link").
*/
public async requestEmailVerification(
email: string,
userRealmName?: string,
method: "code" | "link" = "code",
): Promise<boolean> {
this.log.trace("Requesting email verification", {
email,
userRealmName,
method,
});
const user = await this.users(userRealmName).findOne({
where: { email: { eq: email } },
});
if (!user) {
this.log.debug("Email verification requested for non-existent user", {
email,
});
return true;
}
if (user.emailVerified) {
this.log.debug("Email verification requested for already verified user", {
email,
userId: user.id,
});
return true;
}
try {
const verification = await this.verificationService.createVerification({
type: method,
target: email,
});
if (method === "link") {
// Build verification URL from realm settings (server-controlled, not user input)
const realm = this.realmProvider.getRealm(userRealmName);
const realmSettings = await realm.getSettings();
const baseUrl = realmSettings.verifyEmailUrl ?? "/verify-email";
const url = new URL(baseUrl, "http://localhost");
url.searchParams.set("email", email);
url.searchParams.set("token", verification.token);
const fullVerifyUrl = realmSettings.verifyEmailUrl
? `${baseUrl}${url.search}`
: url.pathname + url.search;
await this.userNotifications(userRealmName)?.emailVerificationLink.push(
{
contact: email,
variables: {
email,
verifyUrl: fullVerifyUrl,
expiresInMinutes: Math.floor(verification.codeExpiration / 60),
},
},
);
this.log.debug("Email verification link sent", {
email,
userId: user.id,
});
} else {
await this.userNotifications(userRealmName)?.emailVerification.push({
contact: email,
variables: {
email,
code: verification.token,
expiresInMinutes: Math.floor(verification.codeExpiration / 60),
},
});
this.log.debug("Email verification code sent", {
email,
userId: user.id,
});
}
} catch (error) {
// Silent fail for security
this.log.warn("Failed to send email verification", { email, error });
}
return true;
}
/**
* Verify a user's email using a valid verification token.
* Supports both code (6-digit) and link (UUID) verification tokens.
*/
public async verifyEmail(
email: string,
token: string,
userRealmName?: string,
): Promise<void> {
this.log.trace("Verifying email", { email, userRealmName });
// Detect verification type based on token format
// Codes are 6-digit numbers, links are UUIDs
const isCode = /^\d{6}$/.test(token);
const type = isCode ? "code" : "link";
const result = await this.verificationController
.validateVerificationCode({
params: { type },
body: { target: email, token },
})
.catch(() => {
this.log.warn("Invalid email verification token", { email, type });
throw new BadRequestError("Invalid or expired verification token");
});
if (result.alreadyVerified) {
this.log.warn("Email verification token already used", { email });
throw new BadRequestError("Invalid or expired verification token");
}
const user = await this.users(userRealmName).getOne({
where: { email: { eq: email } },
});
await this.users(userRealmName).updateById(user.id, {
emailVerified: true,
});
this.log.info("Email verified", { email, userId: user.id, type });
const realm = this.realmProvider.getRealm(userRealmName);
await this.userAudits(userRealmName)?.user.log("update", {
resourceType: "user",
userId: user.id,
userEmail: email,
userRealm: realm.name,
resourceId: user.id,
description: "Email verified",
metadata: { email, verificationType: type },
});
}
/**
* Check if an email is verified.
*/
public async isEmailVerified(
email: string,
userRealmName?: string,
): Promise<boolean> {
this.log.trace("Checking if email is verified", { email, userRealmName });
const user = await this.users(userRealmName).findOne({
where: { email: { eq: email } },
});
return user?.emailVerified ?? false;
}
/**
* Find users with pagination and filtering.
*/
public async findUsers(
q: UserQuery = {},
userRealmName?: string,
): Promise<Page<UserEntity>> {
this.log.trace("Finding users", { query: q, userRealmName });
q.sort ??= "-createdAt";
const where = this.users(userRealmName).createQueryWhere();
if (q.search) {
const pattern = `%${q.search}%`;
where.or = [
{ email: { ilike: pattern } },
{ username: { ilike: pattern } },
{ firstName: { ilike: pattern } },
{ lastName: { ilike: pattern } },
];
}
if (q.email) {
where.email = { like: q.email };
}
if (q.enabled !== undefined) {
where.enabled = { eq: q.enabled };
}
if (q.emailVerified !== undefined) {
where.emailVerified = { eq: q.emailVerified };
}
if (q.roles) {
where.roles = { arrayContains: q.roles };
}
const result = await this.users(userRealmName).paginate(
q,
{ where },
{ count: true },
);
this.log.debug("Users found", {
count: result.content.length,
total: result.page.totalElements,
});
return result;
}
/**
* Get a user by ID.
*/
public async getUserById(
id: string,
userRealmName?: string,
): Promise<UserEntity> {
this.log.trace("Getting user by ID", { id, userRealmName });
return await this.users(userRealmName).getById(id);
}
/**
* Create a new user.
*/
public async createUser(
data: CreateUser,
userRealmName?: string,
): Promise<UserEntity> {
this.log.trace("Creating user", {
username: data.username,
email: data.email,
userRealmName,
});
const realm = this.realmProvider.getRealm(userRealmName);
const realmSettings = await realm.getSettings();
// Check for existing user based on provided unique fields (scoped to realm)
if (data.username) {
const existingUser = await this.users(userRealmName).findOne({
where: { realm: realm.name, username: { ilike: data.username } },
});
if (existingUser) {
this.log.debug("Username already taken", { username: data.username });
throw new BadRequestError("User with this username already exists");
}
}
if (data.email) {
const existingUser = await this.users(userRealmName).findOne({
where: { realm: realm.name, email: { eq: data.email } },
});
if (existingUser) {
this.log.debug("Email already taken", { email: data.email });
throw new BadRequestError("User with this email already exists");
}
}
if (data.phoneNumber) {
const existingUser = await this.users(userRealmName).findOne({
where: { realm: realm.name, phoneNumber: { eq: data.phoneNumber } },
});
if (existingUser) {
this.log.debug("Phone number already taken", {
phoneNumber: data.phoneNumber,
});
throw new BadRequestError("User with this phone number already exists");
}
}
const user = await this.users(userRealmName).create({
...data,
roles: data.roles ?? realmSettings.defaultRoles,
realm: realm.name,
});
this.log.info("User created", {
userId: user.id,
username: user.username,
email: user.email,
});
await this.userAudits(userRealmName)?.user.log("create", {
resourceType: "user",
userRealm: realm.name,
resourceId: user.id,
description: "User created",
metadata: {
username: user.username,
email: user.email,
roles: user.roles,
},
});
return user;
}
/**
* Update an existing user.
*/
public async updateUser(
id: string,
data: UpdateUser,
userRealmName?: string,
): Promise<UserEntity> {
this.log.trace("Updating user", { id, userRealmName });
const before = await this.getUserById(id, userRealmName);
const realm = this.realmProvider.getRealm(userRealmName);
const users = this.users(userRealmName);
// Conflict checks — surface a friendly 409 instead of letting the
// unique constraint blow up the SQL driver with a generic error.
if (
data.username !== undefined &&
data.username !== null &&
data.username !== before.username
) {
const existing = await users.findOne({
where: { realm: realm.name, username: { ilike: data.username } },
});
if (existing && existing.id !== id) {
throw new ConflictError("User with this username already exists");
}
}
if (
data.email !== undefined &&
data.email !== null &&
data.email !== before.email
) {
const existing = await users.findOne({
where: { realm: realm.name, email: { eq: data.email } },
});
if (existing && existing.id !== id) {
throw new ConflictError("User with this email already exists");
}
// Changing the email invalidates the verified flag — never trust
// the client-passed value when email changes.
data.emailVerified = false;
}
const user = await users.updateById(id, data);
this.log.debug("User updated", { userId: id });
// Build changes object showing what was updated
const changes: Record<string, { from: unknown; to: unknown }> = {};
for (const key of Object.keys(data) as (keyof UpdateUser)[]) {
if (data[key] !== undefined && before[key] !== data[key]) {
changes[key] = { from: before[key], to: data[key] };
}
}
// Detect role changes for special handling
const isRoleChange =
data.roles !== undefined &&
JSON.stringify(before.roles) !== JSON.stringify(data.roles);
await this.userAudits(userRealmName)?.user.log(
isRoleChange ? "role_change" : "update",
{
resourceType: "user",
userRealm: realm.name,
resourceId: user.id,
description: isRoleChange
? "User roles changed"
: `User updated: ${Object.keys(changes).join(", ")}`,
metadata: { changes },
},
);
return user;
}
/**
* Set (or reset) a user's password. Upserts a "credentials" identity
* with the new hash. Used by admin password-set flows; does NOT
* verify any old password or token — the caller is responsible for
* authorization.
*/
public async setPassword(
id: string,
newPassword: string,
userRealmName?: string,
): Promise<void> {
this.log.trace("Setting password", { id, userRealmName });
const user = await this.getUserById(id, userRealmName);
const realm = this.realmProvider.getRealm(userRealmName);
const settings = await realm.getSettings();
if (settings.passwordPolicy) {
const policy = settings.passwordPolicy;
if (policy.minLength && newPassword.length < policy.minLength) {
throw new BadRequestError(
`Password must be at least ${policy.minLength} characters`,
);
}
}
const hash = await this.cryptoProvider.hashPassword(newPassword);
const identities = this.realmProvider.identityRepository(userRealmName);
const existing = await identities.findOne({
where: { userId: { eq: id }, provider: { eq: "credentials" } },
});
if (existing) {
await identities.updateById(existing.id, { password: hash });
} else {
await identities.create({
userId: id,
provider: "credentials",
password: hash,
});
}
await this.userAudits(userRealmName)?.user.log("password_change", {
resourceType: "user",
userId: id,
userEmail: user.email ?? undefined,
userRealm: realm.name,
resourceId: id,
severity: "warning",
description: "Password set by admin",
});
}
/**
* Delete a user by ID.
*/
public async deleteUser(id: string, userRealmName?: string): Promise<void> {
this.log.trace("Deleting user", { id, userRealmName });
const user = await this.getUserById(id, userRealmName);
// Clean up related sessions and identities before deleting the user
await this.realmProvider
.sessionRepository(userRealmName)
.deleteMany({ userId: { eq: id } });
await this.realmProvider
.identityRepository(userRealmName)
.deleteMany({ userId: { eq: id } });
await this.users(userRealmName).deleteById(id);
this.log.info("User deleted", { userId: id });
const realm = this.realmProvider.getRealm(userRealmName);
await this.userAudits(userRealmName)?.user.log("delete", {
resourceType: "user",
userRealm: realm.name,
resourceId: id,
severity: "warning",
description: "User deleted",
metadata: {
username: user.username,
email: user.email,
},
});
}
}