616 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { isRecord } from '@/shared/object';
import logger from '@/shared/logger';
import crypto from 'crypto';
import bcrypt from 'bcrypt';
import type { Request } from 'express';
import UsersDBApi from '@/db/api/users';
import AuthRefreshTokensDBApi from '@/db/api/auth_refresh_tokens';
import ValidationError from '@/shared/errors/validation';
import ForbiddenError from '@/shared/errors/forbidden';
import EmailAddressVerificationEmail from '@/services/email/list/addressVerification';
import InvitationEmail from '@/services/email/list/invitation';
import PasswordResetEmail from '@/services/email/list/passwordReset';
import EmailSender from '@/services/email';
import config from '@/shared/config';
import {
USER_NAME_PREFIX_VALUES,
type UserNamePrefix,
} from '@/shared/constants/users';
import { jwtSign } from '@/shared/jwt';
import db from '@/db/models';
import type {
AuthenticatedUser,
CurrentUser,
DbApiOptions,
FileInput,
} from '@/db/api/types';
import type {
SessionOptions,
RoleDto,
OrganizationDto,
CampusDto,
} from '@/services/auth.types';
type PlainRecord = Record<string, unknown>;
/** Minimal user shape needed to mint a session (accepts a record or instance). */
type SessionUser = Pick<AuthenticatedUser, 'id' | 'email' | 'organizationId'>;
type CreateFromAuthData = Parameters<typeof UsersDBApi.createFromAuth>[0];
type UpdateProfileData = Parameters<typeof UsersDBApi.update>[1];
function asStringOrNull(value: unknown): string | null {
return typeof value === 'string' ? value : null;
}
function asId(value: unknown): string {
return typeof value === 'string' ? value : '';
}
function frontendUrl(host: string | undefined, path: string): string {
const fallbackBase = config.uiUrl || config.backUrl || 'http://localhost:3000';
const base = host
? (/^https?:\/\//i.test(host) ? host : `https://${host}`)
: fallbackBase;
return new URL(path, base.endsWith('/') ? base : `${base}/`).toString();
}
/** Returns the plain attributes of a Sequelize instance, or the record itself. */
function toPlainRecord(record: unknown): PlainRecord | null {
if (!isRecord(record)) {
return null;
}
if (typeof record.get === 'function') {
const plain: unknown = record.get({ plain: true });
return isRecord(plain) ? plain : null;
}
return record;
}
function toRoleDto(role: unknown): RoleDto | null {
const plain = toPlainRecord(role);
if (!plain) return null;
return {
id: asStringOrNull(plain.id),
name: asStringOrNull(plain.name),
scope: asStringOrNull(plain.scope),
globalAccess: plain.globalAccess === true,
};
}
function toOrganizationDto(organization: unknown): OrganizationDto | null {
const plain = toPlainRecord(organization);
const id = asStringOrNull(plain?.id);
if (!plain || !id) return null;
return {
id,
name: asStringOrNull(plain.name),
logo: asStringOrNull(plain.logo),
esaEnabled: plain.esaEnabled !== false,
};
}
function toCampusDto(campus: unknown): CampusDto | null {
const plain = toPlainRecord(campus);
const id = asStringOrNull(plain?.id);
if (!plain || !id) return null;
return {
id,
name: asStringOrNull(plain.name),
code: asStringOrNull(plain.code),
logo: asStringOrNull(plain.logo),
};
}
/** Minimal tenant identity for the active-scope badge / selectors. */
function toTenantDto(
value: unknown,
): { id: string; name: string | null; logo: string | null } | null {
const plain = toPlainRecord(value);
const id = asStringOrNull(plain?.id);
if (!plain || !id) return null;
return {
id,
name: asStringOrNull(plain.name),
logo: asStringOrNull(plain.logo),
};
}
function permissionNamesOf(value: unknown): string[] {
return (Array.isArray(value) ? value : [])
.map((permission) => toPlainRecord(permission))
.filter(
(permission): permission is { name: string } =>
isRecord(permission) && typeof permission.name === 'string',
)
.map((permission) => permission.name);
}
/**
* Effective permissions = (role per-user grants) per-user exclusions. The
* `custom_permissions_filter` lets a manager remove specific permissions from a
* user that their role would otherwise grant.
*/
function getPermissionNames(
rolePermissions: unknown,
customPermissions: unknown,
filterPermissions: unknown = [],
): string[] {
const granted = new Set([
...permissionNamesOf(rolePermissions),
...permissionNamesOf(customPermissions),
]);
for (const excluded of permissionNamesOf(filterPermissions)) {
granted.delete(excluded);
}
return [...granted];
}
function getTokenPayload(user: SessionUser) {
return {
user: {
id: user.id,
email: user.email,
},
};
}
function getRequestUserAgent(req: Request): string | null {
const header = req.headers['user-agent'];
return typeof header === 'string' ? header : null;
}
function getRequestIp(req: Request): string | null {
return req.ip || req.socket?.remoteAddress || null;
}
function generateOpaqueRefreshToken(): string {
return crypto
.randomBytes(config.auth.refreshTokenBytes)
.toString('base64url');
}
function hashRefreshToken(token: string): string {
return crypto
.createHash(config.auth.refreshTokenHashAlgorithm)
.update(token)
.digest('hex');
}
function getRefreshTokenExpiry(): Date {
return new Date(Date.now() + config.auth.refreshTokenMaxAgeMs);
}
class Auth {
static async currentUserProfile(currentUser?: CurrentUser) {
if (!currentUser || !currentUser.id) {
throw new ForbiddenError();
}
const user = await UsersDBApi.findProfileById(currentUser.id);
if (!user) {
throw new ForbiddenError();
}
const campusDto = toCampusDto(user.campus);
return {
id: user.id,
email: user.email,
name_prefix: user.name_prefix ?? null,
firstName: user.firstName,
lastName: user.lastName,
phoneNumber: user.phoneNumber,
avatar: user.avatar?.[0]?.privateUrl ?? null,
organizationId: user.organizationId,
organizations: toOrganizationDto(user.organizations),
app_role: toRoleDto(user.app_role),
campus: campusDto,
campusId: user.campusId ?? campusDto?.id ?? null,
// Active-scope tenant chain (for the dynamic badge + scope context).
school: toTenantDto(user.school),
schoolId: user.schoolId ?? null,
classRoom: toTenantDto(user.class),
classId: user.classId ?? null,
permissions: getPermissionNames(
user.app_role_permissions,
user.custom_permissions,
user.custom_permissions_filter,
),
};
}
static async createSession(
user: SessionUser,
req: Request,
options: SessionOptions = {},
) {
const refreshToken = generateOpaqueRefreshToken();
const familyId = options.familyId || crypto.randomUUID();
const tokenRecord = await AuthRefreshTokensDBApi.create(
{
userId: asId(user.id),
organizationId: asStringOrNull(user.organizationId),
tokenHash: hashRefreshToken(refreshToken),
familyId,
previousTokenId: options.previousTokenId || null,
userAgent: getRequestUserAgent(req),
ipAddress: getRequestIp(req),
expiresAt: getRefreshTokenExpiry(),
},
options,
);
return {
accessToken: jwtSign(getTokenPayload(user)),
refreshToken,
refreshTokenRecord: tokenRecord,
user,
};
}
static async refreshSession(refreshToken: string | undefined, req: Request) {
if (!refreshToken) {
throw new ForbiddenError();
}
const tokenHash = hashRefreshToken(refreshToken);
const transaction = await db.sequelize.transaction();
let committed = false;
try {
const existingToken = await AuthRefreshTokensDBApi.findByHash(tokenHash, {
transaction,
});
if (!existingToken) {
throw new ForbiddenError();
}
if (existingToken.revokedAt) {
await AuthRefreshTokensDBApi.revokeFamily(existingToken.familyId, {
transaction,
});
await transaction.commit();
committed = true;
throw new ForbiddenError();
}
if (new Date(existingToken.expiresAt).getTime() <= Date.now()) {
await AuthRefreshTokensDBApi.revoke(existingToken.id, null, {
transaction,
});
await transaction.commit();
committed = true;
throw new ForbiddenError();
}
const user = await UsersDBApi.findBy({ id: existingToken.userId });
if (!user || user.disabled) {
await AuthRefreshTokensDBApi.revokeFamily(existingToken.familyId, {
transaction,
});
await transaction.commit();
committed = true;
throw new ForbiddenError();
}
const nextSession = await this.createSession(user, req, {
familyId: existingToken.familyId,
previousTokenId: existingToken.id,
transaction,
});
await AuthRefreshTokensDBApi.revoke(
existingToken.id,
nextSession.refreshTokenRecord.id,
{ transaction },
);
await transaction.commit();
committed = true;
return nextSession;
} catch (error) {
if (!committed) {
await transaction.rollback();
}
throw error;
}
}
static async revokeSession(refreshToken: string | undefined) {
if (!refreshToken) {
return;
}
const tokenRecord = await AuthRefreshTokensDBApi.findByHash(
hashRefreshToken(refreshToken),
);
if (!tokenRecord || tokenRecord.revokedAt) {
return;
}
await AuthRefreshTokensDBApi.revoke(tokenRecord.id, null);
}
static async signup(
email: string,
password: string,
organizationId: string | null,
options: DbApiOptions = {},
host?: string,
) {
const user = await UsersDBApi.findBy({ email });
const hashedPassword = await bcrypt.hash(password, config.bcrypt.saltRounds);
if (user) {
if (user.disabled) {
throw new ValidationError('auth.userDisabled');
}
await UsersDBApi.updatePassword(asId(user.id), hashedPassword, options);
if (EmailSender.isConfigured) {
await this.sendEmailAddressVerificationEmail(email, host);
}
return { user };
}
const newUser = await UsersDBApi.createFromAuth(
{
firstName: email.split('@')[0],
password: hashedPassword,
email,
organizationId,
} satisfies CreateFromAuthData,
options,
);
if (EmailSender.isConfigured) {
await this.sendEmailAddressVerificationEmail(email, host);
}
return { user: newUser };
}
static async signin(email: string, password: string) {
const user = await UsersDBApi.findBy({ email });
if (!user) {
throw new ValidationError('auth.userNotFound');
}
if (user.disabled) {
throw new ValidationError('auth.userDisabled');
}
if (!user.password) {
throw new ValidationError('auth.wrongPassword');
}
if (!EmailSender.isConfigured) {
user.emailVerified = true;
}
if (!user.emailVerified) {
throw new ValidationError('auth.userNotVerified');
}
const passwordsMatch = await bcrypt.compare(password, String(user.password));
if (!passwordsMatch) {
throw new ValidationError('auth.wrongPassword');
}
return { user };
}
static async sendEmailAddressVerificationEmail(email: string, host?: string) {
let link: string;
try {
const token = await UsersDBApi.generateEmailVerificationToken(email);
link = frontendUrl(host, `/verify-email?token=${token}`);
} catch (error) {
logger.error('Failed to generate email verification token', error);
throw new ValidationError('auth.emailAddressVerificationEmail.error');
}
const emailAddressVerificationEmail = new EmailAddressVerificationEmail(
email,
link,
);
return new EmailSender(emailAddressVerificationEmail).send();
}
static async sendPasswordResetEmail(
email: string,
_type: 'register' | 'invitation' = 'register',
host?: string,
) {
let link: string;
try {
const token = await UsersDBApi.generatePasswordResetToken(email);
link = frontendUrl(host, `/password-reset?token=${token}`);
} catch (error) {
logger.error('Failed to generate password reset token', error);
throw new ValidationError('auth.passwordReset.error');
}
const passwordResetEmail = new PasswordResetEmail(email, link);
return new EmailSender(passwordResetEmail).send();
}
static async sendInvitationEmail(
email: string,
temporaryPassword: string,
host?: string,
) {
const invitationEmail = new InvitationEmail(email, {
loginUrl: frontendUrl(host, '/login'),
temporaryPassword,
});
return new EmailSender(invitationEmail).send();
}
static isEmailConfigured(): boolean {
return EmailSender.isConfigured;
}
static async verifyEmail(token: string, options: DbApiOptions = {}) {
const user = await UsersDBApi.findByEmailVerificationToken(token, options);
if (!user) {
throw new ValidationError(
'auth.emailAddressVerificationEmail.invalidToken',
);
}
return UsersDBApi.markEmailVerified(user.id, options);
}
static async passwordUpdate(
currentPassword: string,
newPassword: string,
options: DbApiOptions,
) {
const currentUser = options.currentUser ?? null;
if (!currentUser) {
throw new ForbiddenError();
}
const storedPassword = String(currentUser.password ?? '');
const currentPasswordMatch = await bcrypt.compare(
currentPassword,
storedPassword,
);
if (!currentPasswordMatch) {
throw new ValidationError('auth.wrongPassword');
}
const newPasswordMatch = await bcrypt.compare(newPassword, storedPassword);
if (newPasswordMatch) {
throw new ValidationError('auth.passwordUpdate.samePassword');
}
const hashedPassword = await bcrypt.hash(
newPassword,
config.bcrypt.saltRounds,
);
return UsersDBApi.updatePassword(asId(currentUser.id), hashedPassword, options);
}
/**
* Self-service profile update for the signed-in user: honorific, name,
* phone, and email. Role/tenant/disabled are NOT self-editable (a boss changes
* those via the users admin). Returns the refreshed profile.
*/
static async updateOwnProfile(
data: {
name_prefix?: unknown;
firstName?: unknown;
lastName?: unknown;
phoneNumber?: unknown;
email?: unknown;
avatar?: unknown;
},
options: DbApiOptions,
) {
const currentUser = options.currentUser ?? null;
if (!currentUser?.id) {
throw new ForbiddenError();
}
const updates: {
name_prefix?: UserNamePrefix | null;
firstName?: string;
lastName?: string;
phoneNumber?: string | null;
email?: string;
avatar?: FileInput | FileInput[] | null;
} = {};
if (data?.name_prefix === null) {
updates.name_prefix = null;
} else if (typeof data?.name_prefix === 'string') {
const prefix = USER_NAME_PREFIX_VALUES.find((p) => p === data.name_prefix);
if (prefix) {
updates.name_prefix = prefix;
}
}
if (typeof data?.firstName === 'string') {
updates.firstName = data.firstName.trim();
}
if (typeof data?.lastName === 'string') {
updates.lastName = data.lastName.trim();
}
if (data?.phoneNumber === null) {
updates.phoneNumber = null;
} else if (typeof data?.phoneNumber === 'string') {
updates.phoneNumber = data.phoneNumber.trim() || null;
}
if (typeof data?.email === 'string' && data.email.trim().length > 0) {
updates.email = data.email.trim();
}
// Avatar: a privateUrl string from the file upload becomes a new file
// relation; `null` clears it. The download endpoint serves it by privateUrl,
// so publicUrl mirrors it for local storage.
if (data?.avatar === null) {
updates.avatar = [];
} else if (typeof data?.avatar === 'string' && data.avatar.length > 0) {
const privateUrl = data.avatar;
const name = privateUrl.split('/').pop() || 'avatar';
updates.avatar = [{ new: true, name, privateUrl, publicUrl: privateUrl }];
}
await UsersDBApi.update(asId(currentUser.id), updates, false, options);
return this.currentUserProfile(currentUser);
}
static async passwordReset(
token: string,
password: string,
options: DbApiOptions = {},
) {
const user = await UsersDBApi.findByPasswordResetToken(token, options);
if (!user) {
throw new ValidationError('auth.passwordReset.invalidToken');
}
const hashedPassword = await bcrypt.hash(password, config.bcrypt.saltRounds);
return UsersDBApi.updatePassword(user.id, hashedPassword, options);
}
static async updateProfile(data: UpdateProfileData, currentUser: CurrentUser) {
const transaction = await db.sequelize.transaction();
try {
await UsersDBApi.update(asId(currentUser.id), data, false, {
currentUser,
transaction,
});
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
}
export default Auth;