616 lines
17 KiB
TypeScript
616 lines
17 KiB
TypeScript
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;
|