import { hash } from "@prsm/hash"; import ID from "@prsm/ids"; import ms from "@prsm/ms"; import type { Request, Response } from "express"; import { LessThanOrEqual, MoreThanOrEqual, type DataSource } from "typeorm"; import { ConfirmationExpiredError, ConfirmationNotFoundError, EmailNotVerifiedError, EmailTakenError, InvalidEmailError, InvalidPasswordError, InvalidTokenError, InvalidUsernameError, ResetDisabledError, ResetExpiredError, ResetNotFoundError, TooManyResetsError, UserInactiveError, UsernameTakenError, UserNotFoundError, UserNotLoggedInError, } from "./errors.js"; import { UserConfirmation } from "./user-confirmation.entity.js"; import { UserRemember } from "./user-remember.entity.js"; import { UserReset } from "./user-reset.entity.js"; import { AuthStatus, getRoleMap, getStatusMap, User } from "./user.entity.js"; import { ensureRequiredMiddlewares, isValidEmail } from "./util.js"; declare module "express-session" { export interface SessionData { auth: AuthSession; } } interface AuthenticatedRequest extends Request { auth: Awaited>; authAdmin: ReturnType; } type AuthSession = { loggedIn: boolean; userId: string; email: string; username: string; status: number; rolemask: number; remembered: boolean; lastResync: Date; forceLogout: number; verified: boolean; }; type ReqResDatasource = { req: Request; res: Response; datasource: DataSource; }; type TokenCallback = (token: string) => void; type CreateUserOptions = { requireUsername: boolean; email: string; password: string; username?: string; callback?: TokenCallback; }; const validateEmail = (email: string) => { if (typeof email !== "string") { throw new InvalidEmailError(); } if (!email.trim()) { throw new InvalidEmailError(); } if (!isValidEmail(email)) { throw new InvalidEmailError(); } }; const validatePassword = (password: string) => { const minLength = process.env.AUTH_MINIMUM_PASSWORD_LENGTH ? +process.env.AUTH_MINIMUM_PASSWORD_LENGTH : 8; const maxLength = process.env.AUTH_MAXIMUM_PASSWORD_LENGTH ? +process.env.AUTH_MAXIMUM_PASSWORD_LENGTH : 64; if (typeof password !== "string") { throw new InvalidPasswordError(); } if (password.length < minLength) { throw new InvalidPasswordError(); } if (password.length > maxLength) { throw new InvalidPasswordError(); } }; const createUserManager = ({ req, res, datasource }: ReqResDatasource) => { const userRepository = () => datasource.getRepository(User); const userConfirmationRepository = () => datasource.getRepository(UserConfirmation); const userRememberRepository = () => datasource.getRepository(UserRemember); const userResetRepository = () => datasource.getRepository(UserReset); const getByUsername = (username: string) => userRepository().findOne({ where: { username } }); const getByEmail = (email: string) => userRepository().findOne({ where: { email } }); const getById = (id: number) => userRepository().findOne({ where: { id } }); /** * Operates on session.auth. */ const hasRole = async (role: number) => { if (req.session.auth) { return (req.session.auth.rolemask & role) === role; } const user = await getUser(); return (user.rolemask & role) === role; }; /** * Operates on session.auth. */ const isRemembered = () => req.session.auth?.remembered ?? false; /** * Operates on session.auth. */ const isAdmin = async () => hasRole(1); const getSessionProperty = (property: PropertyKey) => { return req.session?.auth ? req.session.auth[property] : null; }; /** Returns the logged-in user's `id` property. */ const getId = () => getSessionProperty("userId") ? ID.decode(getSessionProperty("userId")) : null; /** * Operates on session.auth. * Returns the logged-in user's `email` property. */ const getEmail = () => getSessionProperty("email"); /** * Operates on session.auth. * Returns the logged-in user's `status` property. */ const getStatus = (): number => getSessionProperty("status"); /** * Operates on session.auth. * Returns the logged-in user's `verified` property. */ const getVerified = (): number => getSessionProperty("verified"); /** * Operates on session.auth. * Returns the logged-in user. */ const getUser = async () => { const userId = getId(); if (!userId) { return null; } const user = await userRepository().findOne({ where: { id: userId } }); if (!user) { return null; } return user; }; /** * Operates on session.auth. */ const getRoleNames = (rolemask?: number) => { const mask = rolemask === undefined ? getSessionProperty("rolemask") : rolemask; if (!mask && mask !== 0) { return []; } return Object.entries(getRoleMap()) .filter(([key, value]) => mask & parseInt(key)) .map(([key, value]) => value); }; /** * Operates on session.auth. */ const getStatusName = () => { const status = getStatus(); return getStatusMap()[status]; }; const createUserInternal = async ({ requireUsername, email, password, username, callback, }: CreateUserOptions) => { validateEmail(email); validatePassword(password); const trimmedUsername = username?.trim(); if (trimmedUsername === "") { throw new InvalidUsernameError(); } if (requireUsername && trimmedUsername) { const existingUser = await getByUsername(username); if (existingUser) { throw new UsernameTakenError(); } } const existingUser = await userRepository().findOne({ where: { email } }); if (existingUser) { throw new EmailTakenError(); } const hashedPassword = hash.encode(password); const verified = typeof callback !== "function"; const user = userRepository().create({ email, password: hashedPassword, username: trimmedUsername, verified, status: AuthStatus.Normal, resettable: true, rolemask: 0, registered: new Date(), lastLogin: null, forceLogout: 0, }); await userRepository().save(user); if (!verified) { await createConfirmationToken(user, email, callback); } return user; }; const createConfirmationToken = async ( user: User, email: string, callback: TokenCallback, ) => { const token = hash.encode(email); const expires = new Date( Date.now() + 1000 * 60 * 60 * 24 * 7, // 1 week ); await userConfirmationRepository().delete({ user }); const confirmation = userConfirmationRepository().create({ user, token, expires, email, }); await userConfirmationRepository().save(confirmation); if (callback) { callback(token); } }; const recreateConfirmationTokenForUserId = async ( userId: number, callback: TokenCallback, ) => { const user = await getById(userId); if (!user) { throw new UserNotFoundError(); } return recreateConfirmationToken(user, callback); }; const recreateConfirmationTokenForEmail = async ( email: string, callback: TokenCallback, ) => { const user = await getByEmail(email); if (!user) { throw new UserNotFoundError(); } return recreateConfirmationToken(user, callback); }; const recreateConfirmationToken = async ( user: User, callback: TokenCallback, ) => { const latestAttempt = await userConfirmationRepository().findOne({ where: { user }, order: { expires: "DESC" }, }); if (!latestAttempt) { throw new ConfirmationNotFoundError(); } await createConfirmationToken(user, latestAttempt.email, callback); }; const createRememberDirective = async (user: User) => { const token = hash.encode(user.email); const expires = new Date( Date.now() + ms(process.env.AUTH_SESSION_REMEMBER_DURATION), ); await userRememberRepository().delete({ user }); await userRememberRepository().insert({ user, token, expires, }); setRememberCookie(token, expires); return token; }; const setRememberCookie = (token: string, expires: Date) => { const cookieName = process.env.AUTH_SESSION_REMEMBER_COOKIE_NAME; const cookieOptions = { expires, httpOnly: true, secure: false }; res.cookie(cookieName, token, cookieOptions); }; /** * Registers a new user with the provided email, password, and optional username. * * - When a callback is provided, the user's `verified` property will be set to `0` and a confirmation token will be created. * The token will be passed to the callback. You should email the token to the user and have a route that accepts * the token and then calls `confirmEmail` or `confirmEmailAndLogin` with it. * * @throws {InvalidEmailError} When the provided email is invalid. * @throws {InvalidPasswordError} When the provided password is invalid. * @throws {EmailTakenError} When the provided email is already in use. * @throws {InvalidUsernameError} When the provided username is invalid. */ const register = async ( email: string, password: string, username?: string, callback?: TokenCallback, ) => createUserInternal({ requireUsername: false, email, password, username, callback, }); const registerWithUniqueUsername = async ( email: string, password: string, username: string, callback: TokenCallback, ) => createUserInternal({ requireUsername: true, email, password, username, callback, }); return { register, registerWithUniqueUsername, getId, getEmail, getStatus, getVerified, getRoleNames, getStatusName, getUser, getById, getByEmail, getByUsername, userRepository, userResetRepository, userConfirmationRepository, userRememberRepository, setRememberCookie, createRememberDirective, createConfirmationToken, recreateConfirmationTokenForEmail, recreateConfirmationTokenForUserId, isAdmin, hasRole, isRemembered, }; }; export const createAuth = async ({ req, res, datasource, }: ReqResDatasource) => { if (!datasource) { throw new Error("datasource is required"); } const um = createUserManager({ req, res, datasource }); const isLoggedIn = () => req.session?.auth?.loggedIn ?? false; /** * Resynchronizes the session with the latest user data. * * - Does nothing if the user is not logged in. * - Resynchronizes only if the last resync was before the configured interval. * - Logs out the user if the user cannot be found. * - Logs out the user if the forceLogout value in the database is greater than the session's forceLogout value. * * @throws {Error} When session regeneration fails. */ const resyncSession = async () => { if (!isLoggedIn()) { return; } const interval = ms(process.env.AUTH_SESSION_RESYNC_INTERVAL || "30m"); const lastResync = new Date(req.session.auth.lastResync); if (lastResync && lastResync.getTime() > Date.now() - interval) { return; } const user = await um.getUser(); if (!user) { await logout(); return; } if (user.forceLogout > req.session.auth.forceLogout) { await logout(); return; } req.session.auth.email = user.email; req.session.auth.username = user.username; req.session.auth.status = user.status; req.session.auth.rolemask = user.rolemask; req.session.auth.verified = user.verified; req.session.auth.lastResync = new Date(); }; await resyncSession(); const processRememberDirective = async () => { if (!isLoggedIn()) { return; } const { token } = getRememberToken(); if (!token) { return; } const directive = await um .userRememberRepository() .findOne({ where: { token } }); if (!directive) { return; } if (!directive.user) { await logout(); return; } // remove expired directives for this user const expiredRemembers = await um.userRememberRepository().find({ where: { user: directive.user, expires: LessThanOrEqual(new Date()) }, }); await um.userRememberRepository().remove(expiredRemembers); // is this directive expired? if (new Date() > directive.expires) { await um.userRememberRepository().remove(directive); um.setRememberCookie(null, new Date(0)); return; } // okay to login await onLoginSuccessful(directive.user, true); }; /** * Logs in a user with the provided email and password. * * @throws {UserNotFoundError} When the user cannot be found by the provided email. * @throws {InvalidPasswordError} When the provided password is incorrect. * @throws {EmailNotVerifiedError} When the user's email is not verified. * @throws {UserInactiveError} When the user's status is not normal. */ const login = async (email: string, password: string, remember = false) => loginWithCredentials({ email, password, remember }); const loginWithCredentials = async (credentials: { email: string; password: string; username?: string; remember: boolean; }) => { const user = credentials.email ? await um.getByEmail(credentials.email) : await um.getByUsername(credentials.username); if (!user) { throw new UserNotFoundError(); } if (!hash.verify(user.password, credentials.password)) { throw new InvalidPasswordError(); } if (!user.verified) { throw new EmailNotVerifiedError(); } if (user.status !== AuthStatus.Normal) { throw new UserInactiveError(); } await onLoginSuccessful(user, credentials.remember); }; /** * Logs out the currently logged-in user. * * - Deletes the remember token if it exists. * - Clears the remember cookie. * * @throws {Error} When session regeneration fails. */ const logout = async () => { if (!isLoggedIn()) { return; } const { token } = getRememberToken(); if (token) { await um.userRememberRepository().delete({ token }); um.setRememberCookie(null, new Date(0)); } req.session.auth = undefined; }; /** * Forces logout for a user identified by id. * * - Increments the forceLogout counter for the user. * * @throws {TypeError} When the provided id is not a number. */ const forceLogoutForUserById = async (id: number) => { if (typeof id !== "number") { throw new TypeError("User ID must be a number"); } await um.userRememberRepository().delete({ user: { id } }); await um.userRepository().increment({ id }, "forceLogout", 1); }; /** * Forces logout for the currently logged-in user. */ const forceLogoutForUser = async () => { const userId = um.getId(); if (userId) { await forceLogoutForUserById(userId); } }; /** * Logs out the user from all sessions except the current one. * * - Increments the forceLogout counter for the user. * - Regenerates the session to apply the forceLogout change. * * Since this session's forceLogout value will not be greater than the * value in the database, the user will be logged out from all other sessions, * but not from the current one. See resyncSession for clarity on this behavior. * * @throws {UserNotFoundError} When the user cannot be found by the provided identifier. */ const logoutEverywhereElse = async () => { if (!isLoggedIn()) { return; } const userId = um.getId(); const user = await um.getById(userId); if (!user) { await logout(); return; } await forceLogoutForUserById(userId); req.session.auth.forceLogout += 1; await regenerate(); }; /** * Logs out the user from all sessions, including the current one. * * - Calls `logoutEverywhereElse` to log out from all other sessions. * - Calls `logout` to log out from the current session. * * @throws {Error} When session regeneration fails. */ const logoutEverywhere = async () => { if (!isLoggedIn()) { return; } await logoutEverywhereElse(); await logout(); }; /** * Regenerates the session while preserving the current auth data. * * - Copies the current session's auth data before regenerating the session. * - Restores the auth data to the new session. * * @throws {Error} When session regeneration fails. */ const regenerate = async () => { const auth = { ...req.session.auth }; return new Promise((resolve, reject) => { req.session.regenerate((err) => { if (err) { reject(err); return; } req.session.auth = auth; resolve(); }); }); }; const getRememberToken = () => { if (!req.cookies) { return { token: null }; } const cookieName = process.env.AUTH_SESSION_REMEMBER_COOKIE_NAME; const token = req.cookies[cookieName]; if (!token) { return { token: null }; } return { token }; }; const getRememberExpiry = async () => { if (!isLoggedIn()) { return; } const { token } = getRememberToken(); if (!token) { return null; } const directive = await um .userRememberRepository() .findOne({ where: { token } }); return directive?.expires ?? null; }; /** * Handles successful login for a user. * * - Updates the user's last login timestamp. * - Regenerates the session to prevent session fixation attacks. * - Sets the session's auth data with user details. * - Creates a remember directive if the remember option is true. * * @throws {Error} When session regeneration fails. */ const onLoginSuccessful = async (user: User, remember = false) => { await um.userRepository().update(user.id, { lastLogin: new Date() }); return new Promise((resolve, reject) => { if (!req.session?.regenerate) { console.log( "COULD NOT REGENERATE SESSION WTF. req:session:", req.session, ); resolve(); } req.session.regenerate(async (err) => { if (err) { reject(err); return; } const session: AuthSession = { loggedIn: true, userId: ID.encode(user.id), email: user.email, username: user.username, status: user.status, rolemask: user.rolemask, remembered: remember, lastResync: new Date(), forceLogout: user.forceLogout, verified: user.verified, }; req.session.auth = session; if (remember) { await um.createRememberDirective(user); } req.session.save((err) => { if (err) { reject(err); return; } resolve(); }); }); }); }; /** * Initiates an email change for the logged-in user. * * - Sends a confirmation token to the new email address. * * @throws {UserNotLoggedInError} When no user is currently logged in. * @throws {InvalidEmailError} When the provided email is invalid. * @throws {EmailTakenError} When the provided email is already in use. * @throws {UserNotFoundError} When the logged-in user cannot be found. * @throws {EmailNotVerifiedError} When the logged-in user's email is not verified. */ const changeEmail = async (newEmail: string, callback: TokenCallback) => { if (!isLoggedIn()) { throw new UserNotLoggedInError(); } validateEmail(newEmail); const existing = await um.getByEmail(newEmail); if (existing) { throw new EmailTakenError(); } const user = await um.getById(um.getId()); if (!user) { throw new UserNotFoundError(); } if (!user.verified) { throw new EmailNotVerifiedError(); } await um.createConfirmationToken(user, newEmail, callback); }; /** * Confirms an email change using the provided token. * * @throws {ConfirmationNotFoundError} When the confirmation token cannot be found. * @throws {ConfirmationExpiredError} When the confirmation token has expired. * @throws {InvalidTokenError} When the provided token is invalid. */ const confirmChangeEmail = async (token: string) => { const confirmation = await um.userConfirmationRepository().findOne({ where: { token }, }); if (!confirmation) { throw new ConfirmationNotFoundError(); } if (new Date(confirmation.expires) < new Date()) { throw new ConfirmationExpiredError(); } if (!hash.verify(token, confirmation.email)) { throw new InvalidTokenError(); } await um.userRepository().update(confirmation.user.id, { verified: true, email: confirmation.email, }); if ( isLoggedIn() && req.session?.auth?.userId === ID.encode(confirmation.user.id) ) { req.session.auth.verified = true; req.session.auth.email = confirmation.email; } await um.userConfirmationRepository().remove(confirmation); return confirmation.email; }; /** * Confirms an email change using the provided token. * * @throws {ConfirmationNotFoundError} When the confirmation token cannot be found. * @throws {ConfirmationExpiredError} When the confirmation token has expired. * @throws {InvalidTokenError} When the provided token is invalid. */ const confirmEmail = async (token: string) => confirmChangeEmail(token); /** * Confirms an email change using the provided token and logs in the user. * * - Logs in the user if not already logged in. * * @throws {UserNotFoundError} When the user cannot be found by the provided email. */ const confirmEmailAndLogin = async (token: string) => confirmChangeEmailAndLogin(token); /** * Confirms an email change using the provided token and logs in the user. * * - Logs in the user if not already logged in. * * @throws {UserNotFoundError} When the user cannot be found by the provided email. */ const confirmChangeEmailAndLogin = async ( token: string, remember = false, ) => { const email = await confirmChangeEmail(token); if (isLoggedIn()) { return; } const user = await um.getByEmail(email); if (!user) { throw new UserNotFoundError(); } await onLoginSuccessful(user, remember); }; /** * Updates the password for the specified user. * * @throws {InvalidPasswordError} When the provided password is invalid. */ const updatePasswordInternal = async (user: User, password: string) => { await um .userRepository() .update(user.id, { password: hash.encode(password) }); }; /** * Initiates a password reset for the user identified by email. * * - Limits the number of open reset requests to `maxOpenRequests`. * * @throws {EmailNotVerifiedError} When the user's email is not verified. * @throws {ResetDisabledError} When the user's reset functionality is disabled. * @throws {TooManyResetsError} When the user has too many open reset requests. */ const resetPassword = async ( email: string, expiresAfter: string | number | null, maxOpenRequests: number | null, callback?: TokenCallback, ) => { validateEmail(email); expiresAfter = !expiresAfter ? ms("6h") : ms(expiresAfter); maxOpenRequests = maxOpenRequests === null ? 2 : Math.max(1, maxOpenRequests); const user = await um.userRepository().findOne({ where: { email } }); if (!user || !user.verified) { throw new EmailNotVerifiedError(); } if (!user.resettable) { throw new ResetDisabledError(); } // find all open, non-expired reset requests const openRequests = await um .userResetRepository() .find({ where: { user, expires: MoreThanOrEqual(new Date()) } }); if (openRequests.length >= maxOpenRequests) { throw new TooManyResetsError(); } const token = hash.encode(email); const expires = new Date(Date.now() + ms(expiresAfter)); await um.userResetRepository().insert({ user, token, expires }); }; /** * Confirms a password reset using the provided token and sets a new password. * * - Logs out the user from all sessions if `logout` is true. * * @throws {ResetNotFoundError} When the reset token cannot be found. * @throws {ResetExpiredError} When the reset token has expired. * @throws {ResetDisabledError} When the user's reset functionality is disabled. * @throws {InvalidTokenError} When the provided token is invalid. * @throws {InvalidPasswordError} When the provided password is invalid. */ const confirmResetPassword = async ( token: string, password: string, logout = true, ) => { const reset = await um .userResetRepository() .findOne({ where: { token }, order: { expires: "DESC" } }); if (!reset) { throw new ResetNotFoundError(); } if (new Date(reset.expires) < new Date()) { throw new ResetExpiredError(); } if (!reset.user.resettable) { throw new ResetDisabledError(); } validatePassword(password); if (!hash.verify(token, reset.user.email)) { throw new InvalidTokenError(); } await updatePasswordInternal(reset.user, password); if (logout) { await forceLogoutForUserById(reset.user.id); } await um.userResetRepository().remove(reset); }; /** * Verifies the provided password against the logged-in user's password. * * @throws {UserNotLoggedInError} When no user is currently logged in. * @throws {UserNotFoundError} When the logged-in user cannot be found. */ const verifyPassword = async (password: string) => { if (!isLoggedIn()) { throw new UserNotLoggedInError(); } const user = await um.getUser(); if (!user) { throw new UserNotFoundError(); } return hash.verify(user.password, password); }; return { processRememberDirective, forceLogoutForUser, forceLogoutForUserById, login, logout, logoutEverywhere, logoutEverywhereElse, register: um.register, changeEmail, confirmEmail, confirmEmailAndLogin, confirmChangeEmail, confirmChangeEmailAndLogin, resetPassword, confirmResetPassword, verifyPassword, isAdmin: um.isAdmin, hasRole: um.hasRole, isLoggedIn, isRemembered: um.isRemembered, getId: um.getId, getEmail: um.getEmail, getStatus: um.getStatus, getVerified: um.getVerified, getUser: um.getUser, getRoleNames: um.getRoleNames, getStatusName: um.getStatusName, userRepository: um.userRepository, userConfirmationRepository: um.userConfirmationRepository, userResetRepository: um.userResetRepository, userRememberRepository: um.userRememberRepository, onLoginSuccessful, getById: um.getById, getByEmail: um.getByEmail, getByUsername: um.getByUsername, }; }; export const createAuthAdmin = ({ req, res, datasource, auth, }: ReqResDatasource & { auth: Awaited> }) => { const loginAsUser = async (user: User) => { await auth.onLoginSuccessful(user, false); }; const loginAsUserBy = async (identifier: { id?: number; email?: string; username?: string; }) => { let user: User | null = null; if (identifier.id !== undefined) { user = await auth .userRepository() .findOne({ where: { id: identifier.id } }); } else if (identifier.email !== undefined) { user = await auth .userRepository() .findOne({ where: { email: identifier.email } }); } else if (identifier.username !== undefined) { user = await auth .userRepository() .findOne({ where: { username: identifier.username } }); } if (!user) { throw new UserNotFoundError(); } await loginAsUser(user); }; const createUserInternal = async ( requireUniqueUsername: boolean, credentials: { email: string; password: string; username?: string }, callback?: TokenCallback, ) => { validateEmail(credentials.email); validatePassword(credentials.password); if (credentials.username) { credentials.username = credentials.username.trim(); } if (requireUniqueUsername) { if (!credentials.username) { throw new InvalidUsernameError(); } const occurrences = await auth.userRepository().count({ where: { username: credentials.username }, }); if (occurrences > 0) { throw new UsernameTakenError(); } } const hashed = hash.encode(credentials.password); const verified = Boolean(callback); const user = await auth.userRepository().insert({ email: credentials.email, password: hashed, username: credentials.username, verified, status: AuthStatus.Normal, resettable: true, rolemask: 0, registered: new Date(), lastLogin: null, forceLogout: 0, }); }; /** * Creates a new user with the provided credentials. * * @throws {InvalidEmailError} When the provided email is invalid. * @throws {InvalidPasswordError} When the provided password is invalid. * @throws {EmailTakenError} When the provided email is already in use. */ const createUser = async ( credentials: { email: string; password: string; username?: string; }, callback?: TokenCallback, ) => { return createUserInternal(false, credentials, callback); }; /** * Creates a new user with a unique username. * * - Ensures the username is unique before creating the user. * * @throws {InvalidEmailError} When the provided email is invalid. * @throws {InvalidPasswordError} When the provided password is invalid. * @throws {InvalidUsernameError} When the provided username is invalid. * @throws {UsernameTakenError} When the provided username is already in use. * @throws {EmailTakenError} When the provided email is already in use. */ const createUserWithUniqueUsername = async ( credentials: { email: string; password: string; username: string; }, callback?: TokenCallback, ) => { return createUserInternal(true, credentials, callback); }; const addRoleForUser = async (user: User, role: number) => { const rolemask = user.rolemask | role; await auth.userRepository().update(user.id, { rolemask }); }; /** * Adds a role for a user identified by id, email, or username. * * @throws {UserNotFoundError} When the user cannot be found by the provided identifier. */ const addRoleForUserBy = async ( identifier: { id?: number; email?: string; username?: string }, role: number, ) => { let user: User | null = null; if (identifier.id !== undefined) { user = await auth.getById(identifier.id); } else if (identifier.email !== undefined) { user = await auth.getByEmail(identifier.email); } else if (identifier.username !== undefined) { user = await auth.getByUsername(identifier.username); } if (!user) { throw new UserNotFoundError(); } return addRoleForUser(user, role); }; const removeRoleForUser = async (user: User, role: number) => { const rolemask = user.rolemask & ~role; await auth.userRepository().update(user.id, { rolemask }); }; /** * Removes a role for a user identified by id, email, or username. * * @throws {UserNotFoundError} When the user cannot be found by the provided identifier. */ const removeRoleForUserBy = async ( identifier: { id?: number; email?: string; username?: string }, role: number, ) => { let user: User | null = null; if (identifier.id !== undefined) { user = await auth.getById(identifier.id); } else if (identifier.email !== undefined) { user = await auth.getByEmail(identifier.email); } else if (identifier.username !== undefined) { user = await auth.getByUsername(identifier.username); } if (!user) { throw new UserNotFoundError(); } return removeRoleForUser(user, role); }; /** * Retrieves the roles for a user identified by id, email, or username. * * @throws {UserNotFoundError} When the user cannot be found by the provided identifier. */ const getRolesForUserBy = async (identifier: { id?: number; email?: string; username?: string; }) => { let user: User | null = null; if (identifier.id !== undefined) { user = await auth.getById(identifier.id); } else if (identifier.email !== undefined) { user = await auth.getByEmail(identifier.email); } else if (identifier.username !== undefined) { user = await auth.getByUsername(identifier.username); } if (!user) { throw new UserNotFoundError(); } return auth.getRoleNames(user.rolemask); }; /** * Checks if a user identified by id, email, or username has a specific role. * * @throws {UserNotFoundError} When the user cannot be found by the provided identifier. */ const hasRoleForUserBy = async ( identifier: { id?: number; email?: string; username?: string }, role: number, ) => { let user: User | null = null; if (identifier.id !== undefined) { user = await auth.getById(identifier.id); } else if (identifier.email !== undefined) { user = await auth.getByEmail(identifier.email); } else if (identifier.username !== undefined) { user = await auth.getByUsername(identifier.username); } if (!user) { throw new UserNotFoundError(); } return (user.rolemask & role) === role; }; const deleteUser = async (user: User) => { await auth.userResetRepository().delete({ user }); await auth.userRememberRepository().delete({ user }); await auth.userConfirmationRepository().delete({ user }); await auth.userRepository().delete(user); }; /** * Deletes a user identified by id, email, or username. * * @throws {UserNotFoundError} When the user cannot be found by the provided identifier. */ const deleteUserBy = async (identifier: { id?: number; email?: string; username?: string; }) => { let user: User | null = null; if (identifier.id !== undefined) { user = await auth.getById(identifier.id); } else if (identifier.email !== undefined) { user = await auth.getByEmail(identifier.email); } else if (identifier.username !== undefined) { user = await auth.getByUsername(identifier.username); } if (!user) { throw new UserNotFoundError(); } return deleteUser(user); }; const changePasswordForUser = async (user: User, password: string) => { await auth .userRepository() .update(user.id, { password: hash.encode(password) }); }; /** * Changes the password for a user identified by id, email, or username. * * @throws {UserNotFoundError} When the user cannot be found by the provided identifier. */ const changePasswordForUserBy = async ( identifier: { id?: number; email?: string; username?: string }, password: string, ) => { let user: User | null = null; if (identifier.id !== undefined) { user = await auth.getById(identifier.id); } else if (identifier.email !== undefined) { user = await auth.getByEmail(identifier.email); } else if (identifier.username !== undefined) { user = await auth.getByUsername(identifier.username); } if (!user) { throw new UserNotFoundError(); } return changePasswordForUser(user, password); }; /** * Sets the status for a user identified by id, email, or username. * * @throws {UserNotFoundError} When the user cannot be found by the provided identifier. */ const setStatusForUserBy = async ( identifier: { id?: number; email?: string; username?: string }, status: number, ) => { let user: User | null = null; if (identifier.id !== undefined) { user = await auth.getById(identifier.id); } else if (identifier.email !== undefined) { user = await auth.getByEmail(identifier.email); } else if (identifier.username !== undefined) { user = await auth.getByUsername(identifier.username); } if (!user) { throw new UserNotFoundError(); } return auth.userRepository().update(user.id, { status }); }; /** * Initiates a password reset for the user, but only if that user * has already verified their email address. * * - Ignores user.resettable (i.e., initiates reset regardless of this value). * - Doesn't care about how many open requests there currently are for this user. * * @throws {EmailNotVerifiedError} When the user's email is not verified. */ const initiatePasswordResetForUser = async ( user: User, expiresAfter: string | number | null, callback?: TokenCallback, ) => { if (!user.verified) { throw new EmailNotVerifiedError(); } expiresAfter = !expiresAfter ? ms("6h") : ms(expiresAfter); const token = hash.encode(user.email); const expires = new Date(Date.now() + ms(expiresAfter)); await auth.userResetRepository().insert({ user, token, expires }); if (callback) { callback(token); } }; /** * Initiates a password reset for a user identified by id, email, or username. * * @throws {UserNotFoundError} When the user cannot be found by the provided identifier. */ const initiatePasswordResetForUserBy = async ( identifier: { id?: number; email?: string; username?: string }, expiresAfter: string | number | null, callback?: TokenCallback, ) => { let user: User | null = null; if (identifier.id !== undefined) { user = await auth.getById(identifier.id); } else if (identifier.email !== undefined) { user = await auth.getByEmail(identifier.email); } else if (identifier.username !== undefined) { user = await auth.getByUsername(identifier.username); } if (!user) { throw new UserNotFoundError(); } return initiatePasswordResetForUser(user, expiresAfter, callback); }; return { loginAsUserBy, createUser, createUserWithUniqueUsername, deleteUserBy, getRolesForUserBy, addRoleForUserBy, removeRoleForUserBy, hasRoleForUserBy, changePasswordForUserBy, setStatusForUserBy, initiatePasswordResetForUserBy, }; }; export const middleware = ({ datasource }: { datasource: DataSource }) => { return async (req: AuthenticatedRequest, res, next) => { ensureRequiredMiddlewares(req.app); req.auth = await createAuth({ req, res, datasource }); req.authAdmin = createAuthAdmin({ req, res, datasource, auth: req.auth }); await req.auth.processRememberDirective(); next(); }; };