You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
wiki/backend/models/users.ts

2255 lines
76 KiB

import bcrypt from 'bcryptjs'
import QRCode from 'qrcode'
import {
authentication as authenticationTable,
groups as groupsTable,
sessions as sessionsTable,
userAvatars,
userGroups,
users as usersTable,
userKeys
} from '../db/schema.ts'
import { and, count, desc, eq, ilike, inArray, isNotNull, notExists, or, sql } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { flatten, uniq } from 'es-toolkit/array'
import { detectImageMime, resizeImageToSquareJpeg } from '../helpers/images.ts'
import { buildTotpUri, generateTotpSecret, verifyTotpCode } from '../helpers/totp.ts'
import type { AuthStrategy, ProviderProfile } from './authentication.ts'
import type { SystemIds } from './types.ts'
/** The essential user fields, mirroring the `UserCore` API schema. */
export interface UserCore {
id: string
name: string
email: string
hasAvatar: boolean
isSystem: boolean
isActive: boolean
isVerified: boolean
createdAt: Date
updatedAt: Date
lastLoginAt: Date | null
}
/** One page of users, with the total matching the filter rather than the page size. */
export interface UserPage {
total: number
users: UserCore[]
}
/** A user and when they last signed in — all `getRecentLogins()` discloses. */
export interface RecentLogin {
id: string
name: string
email: string
lastLoginAt: Date | null
}
/**
* An authentication provider linked to a user, as exposed by the API. Secrets held in the stored
* `auth` blob (the password hash, the TFA secret) are never included — `isPasswordSet` and
* `isTfaSetup` report their state instead.
*/
export interface UserAuthProvider {
authId: string
authName: string
strategyKey: string
strategyIcon: string
config: Record<string, any>
}
/**
* One authentication provider as the user's own profile page sees it: enough to render what can be
* done with it, and nothing else. Unlike the administrator's view this carries no provider flags —
* only whether a password exists, whether 2FA is set up, and whether the user is allowed to turn it
* off again.
*/
export interface UserProfileAuthMethod {
authId: string
authName: string
strategyKey: string
strategyIcon: string
config: {
isPasswordSet: boolean
isTfaSetup: boolean
isTfaRequired: boolean
/** False once password login has been turned off, whether by the user or by an administrator. */
isPasswordLoginEnabled: boolean
/** Whether the account has another way in, and may therefore turn password login off. */
canDisablePasswordLogin: boolean
}
}
/** The subset of user fields that may be modified. `isSystem` is deliberately absent. */
export interface UserPatch {
name?: string
email?: string
isActive?: boolean
isVerified?: boolean
meta?: Record<string, any>
prefs?: Record<string, any>
}
/**
* The self-service view of a user, flattening the `meta` and `prefs` blobs into the fields the
* profile page shows. Mirrors the `UserProfile` API schema.
*/
export interface UserProfile {
id: string
name: string
email: string
hasAvatar: boolean
location: string
jobTitle: string
pronouns: string
timezone: string
dateFormat: string
timeFormat: string
appearance: string
cvd: string
}
/**
* What a user's public profile page shows to whoever opens it.
*
* A strict subset of `UserProfile`, and deliberately not built by trimming one: the email is absent
* because a profile page is readable by anyone who can read the wiki, and every preference that only
* decides how the wiki is DRAWN for its owner — date format, appearance, colour vision — says nothing
* about the person. The time zone is the one preference that does, since it is what the card needs to
* say what time it is where they are.
*/
export interface PublicUserProfile {
id: string
name: string
hasAvatar: boolean
location: string
jobTitle: string
pronouns: string
timezone: string
lastLoginAt: Date | null
}
/** The fields a user may change on its own profile. Notably not the email, nor any admin flag. */
export interface UserProfilePatch {
name?: string
location?: string
jobTitle?: string
pronouns?: string
timezone?: string
dateFormat?: string
timeFormat?: string
appearance?: string
cvd?: string
}
/** The `meta` keys the profile owns, and the `prefs` keys it owns. */
const profileMetaKeys = ['location', 'jobTitle', 'pronouns'] as const
const profilePrefsKeys = ['timezone', 'dateFormat', 'timeFormat', 'appearance', 'cvd'] as const
/**
* The square, in pixels, an avatar is resized to. The profile page and the account menu both display
* one at 180px; nothing displays one larger.
*/
const avatarSize = 180
/**
* Escape the LIKE wildcards `%` and `_` (and the escape character itself) so that a user-supplied
* filter is matched literally. Values are still parameterized by the driver — this is about a `%`
* in the filter silently matching everything, not about injection.
*/
function escapeLikePattern(value: string): string {
return value.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')
}
/**
* Count a wrong 2FA code against a continuation token, destroying the token once `maxTfaAttempts`
* have been used up — the client then has nothing left to continue with and has to start over.
*
* A token that has already been destroyed, or never existed, is not an error here: the caller is
* about to reject the attempt either way.
*/
async function countTfaFailure(token: string): Promise<void> {
const rows = await WIKI.db
.select({ id: userKeys.id, meta: userKeys.meta, userId: userKeys.userId })
.from(userKeys)
.where(eq(userKeys.token, token))
.limit(1)
const row = rows[0]
if (!row) {
return
}
const meta = (row.meta ?? {}) as Record<string, any>
const attempts = (meta.attempts ?? 0) + 1
if (attempts >= maxTfaAttempts) {
await WIKI.db.delete(userKeys).where(eq(userKeys.id, row.id))
WIKI.models.flags.authDebug(
`Discarded the 2FA continuation token of user ${row.userId} after ${attempts} incorrect codes`
)
return
}
await WIKI.db
.update(userKeys)
.set({ meta: { ...meta, attempts } })
.where(eq(userKeys.id, row.id))
}
/**
* How many wrong 2FA codes a continuation token survives before it is destroyed and the user has to
* start the login over. Retries have to be allowed — six digits get mistyped, and a code that rotates
* every 30 seconds is regularly entered a moment too late — but an unlimited number of them against a
* token that lives for 24 hours is a code space small enough to walk through.
*/
const maxTfaAttempts = 5
/**
* How many ways into the account remain if the given provider stops working: the other providers
* linked to it, plus every registered passkey.
*
* A provider that is itself restricted does not count — it is no way in either. Passkeys are counted
* whichever host they were registered against: on a multi-site instance one bound to another site
* still leaves the account reachable, which is what this guards against.
*/
function countAlternativeLogins(user: any, strategyId: string): number {
const auth = (user.auth ?? {}) as Record<string, any>
const otherProviders = Object.entries(auth).filter(
([id, config]) => id !== strategyId && !config?.restrictLogin
).length
const passkeys = ((user.passkeys ?? {}).authenticators ?? []).length
return otherProviders + passkeys
}
/** Selection shared by the list / detail queries. Never includes `auth` or `passkeys`. */
const userSelection = {
id: usersTable.id,
name: usersTable.name,
email: usersTable.email,
hasAvatar: usersTable.hasAvatar,
isSystem: usersTable.isSystem,
isActive: usersTable.isActive,
isVerified: usersTable.isVerified,
createdAt: usersTable.createdAt,
updatedAt: usersTable.updatedAt,
lastLoginAt: usersTable.lastLoginAt
}
export interface LoginOptions {
siteId: string
strategyId: string
username?: string
password?: string
ip?: string
}
export interface AfterLoginResult {
authenticated?: boolean
nextAction: string
continuationToken?: string
tfaQRImage?: string
tfaSecret?: string
redirect: string
}
/**
* Users model
*/
class Users {
async getByEmail(email: string) {
const res = await WIKI.db.select().from(usersTable).where(eq(usersTable.email, email)).limit(1)
return res?.[0] ?? null
}
/**
* Who unattended work is recorded as having done it.
*
* Content arriving without a person behind it still has to name an author — a page pulled in by a
* scheduled storage sync lands in the wiki as an ordinary page, and an ordinary page has an author.
* The wiki's own longest-standing administrator is the least surprising answer: it is an account
* that exists on every instance, and one whose owner is entitled to have created the content.
*
* @returns The user id, or null on an instance with no active administrator at all
*/
async getSystemActorId(): Promise<string | null> {
const rows = await WIKI.db
.select({ id: usersTable.id })
.from(usersTable)
.innerJoin(userGroups, eq(userGroups.userId, usersTable.id))
.innerJoin(groupsTable, eq(groupsTable.id, userGroups.groupId))
.where(
and(
eq(usersTable.isActive, true),
eq(usersTable.isSystem, false),
sql`${groupsTable.permissions} @> '["manage:system"]'::jsonb`
)
)
.orderBy(usersTable.createdAt)
.limit(1)
return rows[0]?.id ?? null
}
async getById(id: string) {
const res = await WIKI.db.select().from(usersTable).where(eq(usersTable.id, id)).limit(1)
return res?.[0] ?? null
}
/**
* Fetch the users who logged in most recently, most recent first.
*
* Identity and the moment only — this answers a dashboard panel readable by anyone in the admin area,
* which is a tier below the `read:users` that the user list itself needs, so it deliberately carries
* none of the account state `getUsers()` selects.
*
* An account that has never logged in has no place in the answer rather than trailing the end of it,
* hence the `isNotNull`. System accounts are excluded because the guest is one: nothing signs in as
* it, and a `lastLoginAt` on it would be an artefact rather than a visit.
*
* @param limit How many to return
* @returns The most recent logins, newest first
*/
async getRecentLogins({ limit = 10 }: { limit?: number } = {}): Promise<RecentLogin[]> {
return WIKI.db
.select({
id: usersTable.id,
name: usersTable.name,
email: usersTable.email,
lastLoginAt: usersTable.lastLoginAt
})
.from(usersTable)
.where(and(isNotNull(usersTable.lastLoginAt), eq(usersTable.isSystem, false)))
.orderBy(desc(usersTable.lastLoginAt))
.limit(limit)
}
/**
* Fetch a page of users, optionally filtered by name or email
*
* @param filter Matched literally against name and email, case-insensitively
* @param assignableToGroupId Keep only the users that may be assigned to this group
* @returns The page of users plus the total number matching the filter
*/
async getUsers({
filter = '',
assignableToGroupId = '',
page = 1,
limit = 20
}: {
filter?: string
assignableToGroupId?: string
page?: number
limit?: number
} = {}): Promise<UserPage> {
const conditions = []
if (filter) {
const pattern = `%${escapeLikePattern(filter)}%`
conditions.push(or(ilike(usersTable.name, pattern), ilike(usersTable.email, pattern))!)
}
if (assignableToGroupId) {
// -> Members of the group have nothing left to assign, and system users (the guest account)
// have a fixed membership that `POST /groups/:id/users/:id` refuses to change
conditions.push(eq(usersTable.isSystem, false))
conditions.push(
notExists(
WIKI.db
.select({ exists: sql`1` })
.from(userGroups)
.where(
and(eq(userGroups.userId, usersTable.id), eq(userGroups.groupId, assignableToGroupId))
)
)
)
}
const where = conditions.length > 0 ? and(...conditions) : undefined
const totals = await WIKI.db.select({ total: count() }).from(usersTable).where(where)
const users = await WIKI.db
.select(userSelection)
.from(usersTable)
.where(where)
.orderBy(usersTable.name)
.limit(limit)
.offset((page - 1) * limit)
return {
total: totals[0]?.total ?? 0,
users
}
}
/**
* Fetch a single user with the groups it belongs to and the authentication providers linked to it.
*
* The stored `auth` blob is keyed by strategy ID and holds secrets, so it is reshaped into a list
* of providers carrying only state (`isPasswordSet`, `isTfaSetup`) — never the password hash or
* the TFA secret.
*
* @param id User ID
* @returns The user, or null if no such user exists
*/
async getUserDetail(id: string) {
const results = await WIKI.db.select().from(usersTable).where(eq(usersTable.id, id)).limit(1)
const user = results[0]
if (!user) {
return null
}
const groups = await this.getUserGroups(id)
const strategies = await WIKI.db.select().from(authenticationTable)
const auth: UserAuthProvider[] = []
for (const [strategyId, rawConfig] of Object.entries(
(user.auth ?? {}) as Record<string, any>
)) {
const strategy = strategies.find((s: any) => s.id === strategyId)
const definition = WIKI.data.authentication?.find((d: any) => d.key === strategy?.module)
const { password, tfaSecret, tfaIsActive, tfaRequired, ...config } = rawConfig ?? {}
auth.push({
authId: strategyId,
authName: strategy?.displayName || definition?.title || strategy?.module || 'Unknown',
strategyKey: strategy?.module ?? 'unknown',
strategyIcon: definition?.icon ?? '',
config: {
...config,
isPasswordSet: Boolean(password),
// -> Named as the profile page's own view names them, so one piece of state is not called two
// things across the API. Whether 2FA is set up is `tfaIsActive` and a stored secret both:
// a secret that was generated but never confirmed is not 2FA being on.
isTfaSetup: Boolean(tfaIsActive && tfaSecret),
isTfaRequired: Boolean(tfaRequired)
}
})
}
return {
id: user.id,
name: user.name,
email: user.email,
hasAvatar: user.hasAvatar,
isSystem: user.isSystem,
isActive: user.isActive,
isVerified: user.isVerified,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
lastLoginAt: user.lastLoginAt,
meta: user.meta,
prefs: user.prefs,
auth,
groups
}
}
/**
* Create a new user, authenticated against the local strategy.
*
* @returns The new user's ID
*/
async createUser({
name,
email,
password,
groups = [],
mustChangePassword = false,
isVerified = true,
strategyId
}: {
name: string
email: string
password: string
groups?: string[]
mustChangePassword?: boolean
/**
* Defaults to true: an administrator creating the account vouches for the address. Login rejects
* an unverified user with `ERR_USER_NOT_VERIFIED`, which is cleared either by the link in the
* registration email or by an administrator marking the account verified.
*/
isVerified?: boolean
/**
* Which local strategy the password is filed under. Defaults to the built-in one, which is where
* every account seeded or created by an administrator keeps it.
*
* It matters because the local module reads `user.auth[its own strategy ID]` — a password stored
* under one local strategy authenticates nobody through another. A registration therefore files
* it under the strategy that was registered through, rather than assuming the built-in one.
*/
strategyId?: string
}): Promise<string> {
const localStrategyId = strategyId ?? WIKI.data.systemIds.localAuthId
const result = await WIKI.db
.insert(usersTable)
.values({
email: email.toLowerCase(),
name,
auth: {
[localStrategyId]: {
password: await bcrypt.hash(password, 12),
mustChangePwd: mustChangePassword,
restrictLogin: false,
tfaIsActive: false,
tfaRequired: false,
tfaSecret: ''
}
},
isSystem: false,
isActive: true,
isVerified,
meta: {
location: '',
jobTitle: '',
pronouns: ''
},
prefs: {
// -> Seeded from the instance-wide user defaults, which an administrator can change
timezone: WIKI.config.userDefaults?.timezone ?? 'America/New_York',
dateFormat: WIKI.config.userDefaults?.dateFormat ?? 'YYYY-MM-DD',
timeFormat: WIKI.config.userDefaults?.timeFormat ?? '12h',
appearance: 'site',
cvd: 'none'
}
})
.returning({ id: usersTable.id })
const userId = result[0].id
if (groups.length > 0) {
await this.setUserGroups(userId, groups)
}
WIKI.models.flags.authDebug(
`Created user ${userId} <${email.toLowerCase()}> in ${groups.length} group(s), mustChangePwd: ${mustChangePassword}, verified: ${isVerified}`
)
await WIKI.models.hooks.emit('user:join', {
userId,
metadata: {
name,
email: email.toLowerCase()
}
})
return userId
}
/**
* Update a user's own fields. Group membership is handled by `setUserGroups()`.
*
* @param patch Fields to change — must not be empty
* @returns Whether a user was updated
*/
async updateUser(id: string, patch: UserPatch): Promise<boolean> {
const values: Record<string, any> = { ...patch, updatedAt: sql`now()` }
if (typeof values.email === 'string') {
values.email = values.email.toLowerCase()
}
const result = await WIKI.db.update(usersTable).set(values).where(eq(usersTable.id, id))
return (result.rowCount ?? 0) > 0
}
/**
* The profile of a single user, as shown on its own profile page.
*
* `meta` and `prefs` are free-form blobs, so every field is defaulted here rather than trusted to
* be present — a user created before a given key existed simply has none.
*
* @returns The profile, or null if no such user exists
*/
async getProfile(id: string): Promise<UserProfile | null> {
const user = await this.getById(id)
if (!user) {
return null
}
const meta = (user.meta ?? {}) as Record<string, any>
const prefs = (user.prefs ?? {}) as Record<string, any>
return {
id: user.id,
name: user.name,
email: user.email,
hasAvatar: user.hasAvatar,
location: meta.location ?? '',
jobTitle: meta.jobTitle ?? '',
pronouns: meta.pronouns ?? '',
// -> An empty time zone / date format means "whatever the client resolves", which is what the
// profile page falls back to
timezone: prefs.timezone ?? '',
dateFormat: prefs.dateFormat ?? '',
timeFormat: prefs.timeFormat ?? '12h',
appearance: prefs.appearance ?? 'site',
cvd: prefs.cvd ?? 'none'
}
}
/**
* A user as their public profile page presents them, which is anybody who can read the wiki.
*
* `meta` and `prefs` are defaulted here for the same reason `getProfile` defaults them: they are
* free-form blobs, and a user created before a key existed simply has none.
*
* @returns The profile, or null when there is no person behind the ID — no such user, or a system
* account, which is what the guest every anonymous reader is and what content nobody
* authored is attributed to. Neither has a profile to show.
*/
async getPublicProfile(id: string): Promise<PublicUserProfile | null> {
const user = await this.getById(id)
if (!user || user.isSystem) {
return null
}
const meta = (user.meta ?? {}) as Record<string, any>
const prefs = (user.prefs ?? {}) as Record<string, any>
return {
id: user.id,
name: user.name,
hasAvatar: user.hasAvatar,
location: meta.location ?? '',
jobTitle: meta.jobTitle ?? '',
pronouns: meta.pronouns ?? '',
// -> Empty for a user who never picked one, which leaves the card with no local time to show
// rather than one from a zone nobody chose
timezone: prefs.timezone ?? '',
lastLoginAt: user.lastLoginAt
}
}
/**
* A user's own settings for one editor.
*
* Kept under `prefs.editors[editor]` so each editor owns its own blob and adding a second one
* needs no migration. The shape is whatever that editor saves; this only guarantees an object.
*
* @returns The saved settings, or `{}` for a user who has never saved any
*/
async getEditorSettings(id: string, editor: string): Promise<Record<string, any>> {
const user = await this.getById(id)
if (!user) {
return {}
}
const prefs = (user.prefs ?? {}) as Record<string, any>
return (prefs.editors?.[editor] ?? {}) as Record<string, any>
}
/**
* Replace a user's settings for one editor.
*
* Merges at both levels for the same reason `updateProfile` does: another editor's settings, and
* every other preference, have to survive one editor saving its own.
*
* @returns The saved settings, or null if no such user exists
*/
async setEditorSettings(
id: string,
editor: string,
config: Record<string, any>
): Promise<Record<string, any> | null> {
const user = await this.getById(id)
if (!user) {
return null
}
const prefs = { ...((user.prefs ?? {}) as Record<string, any>) }
prefs.editors = { ...((prefs.editors ?? {}) as Record<string, any>), [editor]: config }
await this.updateUser(id, { prefs })
return config
}
/**
* Update a user's own profile fields, merging into the `meta` and `prefs` blobs rather than
* replacing them — an administrator's notes and any key this endpoint does not expose must survive
* a user saving its profile.
*
* @param patch Fields to change; omitted ones are left as they are
* @returns The updated profile, or null if no such user exists
*/
async updateProfile(id: string, patch: UserProfilePatch): Promise<UserProfile | null> {
const user = await this.getById(id)
if (!user) {
return null
}
const meta = { ...((user.meta ?? {}) as Record<string, any>) }
const prefs = { ...((user.prefs ?? {}) as Record<string, any>) }
for (const key of profileMetaKeys) {
if (patch[key] !== undefined) {
meta[key] = patch[key]
}
}
for (const key of profilePrefsKeys) {
if (patch[key] !== undefined) {
prefs[key] = patch[key]
}
}
const values: UserPatch = { meta, prefs }
if (patch.name !== undefined) {
values.name = patch.name
}
await this.updateUser(id, values)
return this.getProfile(id)
}
/**
* A user's avatar, with the type its bytes say it is.
*
* The type is sniffed rather than stored: an avatar written while Sharp was installed is a JPEG,
* one written without it is whatever was uploaded, and nothing records which. Unrecognizable bytes
* are reported as JPEG, which is what every avatar stored by 2.x is.
*
* @returns The avatar, or null if this user has none
*/
async getAvatar(userId: string): Promise<{ data: Buffer; mime: string } | null> {
const rows = await WIKI.db
.select({ data: userAvatars.data })
.from(userAvatars)
.where(eq(userAvatars.id, userId))
.limit(1)
const data = rows[0]?.data
if (!data) {
return null
}
return { data, mime: detectImageMime(data) ?? 'image/jpeg' }
}
/**
* Replace a user's avatar.
*
* Normalized to a square JPEG when the Sharp extension is installed — an avatar is displayed at one
* small size, so there is no reason to keep a multi-megabyte original around. Without Sharp the
* uploaded bytes are stored as they came in, which is why reading one sniffs the type.
*
* @param data The uploaded image, already known to be one of the supported formats
*/
async setAvatar(userId: string, data: Buffer): Promise<void> {
const normalized = (await resizeImageToSquareJpeg(data, avatarSize)) ?? data
await WIKI.db
.insert(userAvatars)
.values({ id: userId, data: normalized })
.onConflictDoUpdate({ target: userAvatars.id, set: { data: normalized } })
await WIKI.db
.update(usersTable)
.set({ hasAvatar: true, updatedAt: sql`now()` })
.where(eq(usersTable.id, userId))
}
/**
* Remove a user's avatar, leaving it to be rendered as initials again.
*/
async clearAvatar(userId: string): Promise<void> {
await WIKI.db.delete(userAvatars).where(eq(userAvatars.id, userId))
await WIKI.db
.update(usersTable)
.set({ hasAvatar: false, updatedAt: sql`now()` })
.where(eq(usersTable.id, userId))
}
/**
* The groups a user belongs to, by name. Only the identity of each group — never its permissions or
* page rules, which a user has no business reading about itself.
*/
async getUserGroups(userId: string): Promise<Array<{ id: string; name: string }>> {
return WIKI.db
.select({ id: groupsTable.id, name: groupsTable.name })
.from(userGroups)
.innerJoin(groupsTable, eq(groupsTable.id, userGroups.groupId))
.where(eq(userGroups.userId, userId))
.orderBy(groupsTable.name)
}
/**
* The IDs of the groups a user belongs to
*/
async getUserGroupIds(userId: string): Promise<string[]> {
const rows = await WIKI.db
.select({ groupId: userGroups.groupId })
.from(userGroups)
.where(eq(userGroups.userId, userId))
return rows.map((r: any) => r.groupId)
}
/**
* Replace a user's group membership with exactly the given groups.
*
* Unknown group IDs are ignored rather than failing the whole update, so that a stale client does
* not block an otherwise valid save. So is a membership that may not exist — see
* `groups.guestMembershipViolation`: this is the one call that sets every group at once, and it is
* reached from creating a user, editing one, and enrolling one that an identity provider has just
* sent. Dropping what may not be granted keeps all three honest without any of them having to know
* about the guests group.
*/
async setUserGroups(userId: string, groupIds: string[]): Promise<void> {
const user = await this.getById(userId)
const allowed = groupIds.filter(
(groupId) => !WIKI.models.groups.guestMembershipViolation(groupId, user)
)
if (allowed.length !== groupIds.length) {
WIKI.logger.warn(
`Dropped ${groupIds.length - allowed.length} group assignment(s) for user ${userId} that may not be granted.`
)
}
/*
The guest account keeps the membership it was seeded with whatever was asked for: it is the one
user whose groups are not an administrator's to set, and an empty list would otherwise leave
anonymous access resolving against no rules at all.
*/
if (user?.isSystem) {
return
}
const wanted =
allowed.length > 0
? await WIKI.db
.select({ id: groupsTable.id })
.from(groupsTable)
.where(inArray(groupsTable.id, allowed))
: []
const wantedIds = wanted.map((g: any) => g.id)
await WIKI.db.delete(userGroups).where(eq(userGroups.userId, userId))
if (wantedIds.length > 0) {
await WIKI.db
.insert(userGroups)
.values(wantedIds.map((groupId: string) => ({ userId, groupId })))
}
}
/**
* Update the local-strategy behaviour flags for a user, leaving secrets and any other linked
* provider untouched.
*
* @param flags Any of `mustChangePwd`, `restrictLogin`, `tfaRequired`
* @returns False if the user does not exist
*/
async setUserAuthFlags(id: string, flags: Record<string, any>): Promise<boolean> {
const user = await this.getById(id)
if (!user) {
return false
}
const localStrategyId = WIKI.data.systemIds.localAuthId
const auth = (user.auth ?? {}) as Record<string, any>
const current = auth[localStrategyId]
if (!current) {
// -> The user does not use local authentication, so there are no local flags to set
return false
}
for (const key of ['mustChangePwd', 'restrictLogin', 'tfaRequired'] as const) {
if (flags[key] !== undefined) {
current[key] = Boolean(flags[key])
}
}
auth[localStrategyId] = current
await WIKI.db
.update(usersTable)
.set({ auth, updatedAt: sql`now()` })
.where(eq(usersTable.id, id))
return true
}
/**
* Set a user's local-strategy password, leaving any other linked provider untouched.
*
* @returns False if the user does not exist
*/
async setUserPassword({
id,
newPassword,
mustChangePassword = false
}: {
id: string
newPassword: string
mustChangePassword?: boolean
}): Promise<boolean> {
const user = await this.getById(id)
if (!user) {
return false
}
const localStrategyId = WIKI.data.systemIds.localAuthId
const auth = (user.auth ?? {}) as Record<string, any>
auth[localStrategyId] = {
...auth[localStrategyId],
password: await bcrypt.hash(newPassword, 12),
mustChangePwd: mustChangePassword
}
await WIKI.db
.update(usersTable)
.set({ auth, updatedAt: sql`now()` })
.where(eq(usersTable.id, id))
return true
}
/**
* The authentication providers linked to a user, as its own profile page shows them.
*
* Reshaped from the stored `auth` blob the same way `getUserDetail()` does it, but reporting only
* what the user may act on. `isTfaRequired` is what greys out the "turn off 2FA" button, so it
* accounts for the strategy enforcing 2FA for everyone as well as this user being flagged for it.
*/
async getProfileAuthMethods(userId: string): Promise<UserProfileAuthMethod[]> {
const user = await this.getById(userId)
if (!user) {
return []
}
const strategies = await WIKI.db.select().from(authenticationTable)
const methods: UserProfileAuthMethod[] = []
for (const [strategyId, rawConfig] of Object.entries(
(user.auth ?? {}) as Record<string, any>
)) {
const strategy = strategies.find((s: any) => s.id === strategyId)
const definition = WIKI.data.authentication?.find((d: any) => d.key === strategy?.module)
const config = rawConfig ?? {}
methods.push({
authId: strategyId,
authName: strategy?.displayName || definition?.title || strategy?.module || 'Unknown',
strategyKey: strategy?.module ?? 'unknown',
strategyIcon: definition?.icon ?? '',
config: {
isPasswordSet: Boolean(config.password),
isTfaSetup: Boolean(config.tfaIsActive && config.tfaSecret),
isTfaRequired: Boolean(
config.tfaRequired || (strategy?.config as Record<string, any>)?.enforceTfa
),
isPasswordLoginEnabled: !config.restrictLogin,
canDisablePasswordLogin: countAlternativeLogins(user, strategyId) > 0
}
})
}
return methods
}
/**
* Change a user's own password, having checked the current one.
*
* Distinct from `setUserPassword()`, which is an administrator replacing a password it does not
* know. This also clears `mustChangePwd`: a user who has just chosen a password satisfies the
* requirement to choose one.
*
* @throws `ERR_INVALID_USER`, `ERR_INVALID_STRATEGY`, `ERR_PASSWORD_TOO_SHORT` or
* `ERR_INCORRECT_CURRENT_PASSWORD`
*/
async changeOwnPassword({
userId,
strategyId,
currentPassword,
newPassword
}: {
userId: string
strategyId: string
currentPassword: string
newPassword: string
}): Promise<void> {
const user = await this.getById(userId)
if (!user) {
throw new Error('ERR_INVALID_USER')
}
if (!newPassword || newPassword.length < 8) {
throw new Error('ERR_PASSWORD_TOO_SHORT')
}
const auth = (user.auth ?? {}) as Record<string, any>
// -> Only a provider that stores a password here has one to change; an external identity provider
// holds it somewhere this instance cannot reach
if (!auth[strategyId]?.password) {
throw new Error('ERR_INVALID_STRATEGY')
}
if ((await bcrypt.compare(currentPassword, auth[strategyId].password)) !== true) {
WIKI.models.flags.authDebug(
`Password change for user ${userId} rejected: the current password did not match`
)
throw new Error('ERR_INCORRECT_CURRENT_PASSWORD')
}
auth[strategyId] = {
...auth[strategyId],
password: await bcrypt.hash(newPassword, 12),
mustChangePwd: false
}
await WIKI.db
.update(usersTable)
.set({ auth, updatedAt: sql`now()` })
.where(eq(usersTable.id, userId))
}
/**
* Turn password login on or off for a user's own account, which is the same `restrictLogin` flag an
* administrator sets from the admin area.
*
* Turning it off is refused unless something else can still sign the account in — a passkey or
* another linked provider — because the alternative is a user locking themselves out of their own
* account with one click. Turning it back on needs no such check, and the password itself is neither
* cleared nor asked for: a session that got this far has already been authenticated.
*
* @throws `ERR_INVALID_USER`, `ERR_INVALID_STRATEGY`, `ERR_PASSWORD_LOGIN_NOT_APPLICABLE` or
* `ERR_NO_OTHER_LOGIN_METHOD`
*/
async setPasswordLoginEnabled({
userId,
strategyId,
isEnabled
}: {
userId: string
strategyId: string
isEnabled: boolean
}): Promise<void> {
const user = await this.getById(userId)
if (!user) {
throw new Error('ERR_INVALID_USER')
}
const auth = (user.auth ?? {}) as Record<string, any>
if (!auth[strategyId]) {
throw new Error('ERR_INVALID_STRATEGY')
}
// -> The flag is only ever read by the local module's `authenticate()`, so setting it on a provider
// that authenticates elsewhere would be a switch connected to nothing
const strategy = await WIKI.models.authentication.getStrategyById(strategyId)
if (strategy?.module !== 'local' || !auth[strategyId].password) {
throw new Error('ERR_PASSWORD_LOGIN_NOT_APPLICABLE')
}
if (!isEnabled && countAlternativeLogins(user, strategyId) < 1) {
throw new Error('ERR_NO_OTHER_LOGIN_METHOD')
}
auth[strategyId] = { ...auth[strategyId], restrictLogin: !isEnabled }
await WIKI.db
.update(usersTable)
.set({ auth, updatedAt: sql`now()` })
.where(eq(usersTable.id, userId))
WIKI.models.flags.authDebug(
`User ${userId} <${user.email}> turned password login ${isEnabled ? 'on' : 'off'}`
)
}
/**
* Start 2FA setup for a user: store a fresh secret, inactive, and return the QR code to scan.
*
* The secret is stored before it is proven to work, because the user has to be able to scan it and
* come back with a code generated from it. It counts for nothing until `enableTfa()` marks it
* active, and starting the setup again simply replaces it.
*
* @param user The user row, whose `auth` blob is updated in place as well as saved
* @param siteId The site being logged into, which names the entry in the authenticator app
* @returns The QR code as an SVG document, and the secret it encodes — which is shown as text too,
* for a user who would rather type it into an authenticator app than scan anything
*/
async startTfaSetup(
user: any,
strategyId: string,
siteId?: string
): Promise<{ secret: string; tfaQRImage: string }> {
WIKI.logger.debug(`Generating a new 2FA secret for user ${user.id}...`)
// -> The title is only a label in the user's authenticator app, so any site will do when the one
// being logged into cannot be resolved
const site = (siteId ? WIKI.sites[siteId] : null) ?? Object.values(WIKI.sites ?? {})[0]
const issuer = (site as any)?.config?.title || 'Wiki'
const secret = generateTotpSecret()
user.auth = (user.auth ?? {}) as Record<string, any>
user.auth[strategyId] = {
...user.auth[strategyId],
tfaSecret: secret,
tfaIsActive: false
}
await WIKI.db
.update(usersTable)
.set({ auth: user.auth, updatedAt: sql`now()` })
.where(eq(usersTable.id, user.id))
return {
secret,
tfaQRImage: await QRCode.toString(buildTotpUri({ secret, account: user.email, issuer }), {
type: 'svg',
margin: 1
})
}
}
/**
* Mark a user's stored 2FA secret as active, i.e. required from now on. Called once the user has
* proven it produces the codes this server expects.
*/
async enableTfa(user: any, strategyId: string): Promise<void> {
user.auth[strategyId] = { ...user.auth[strategyId], tfaIsActive: true }
await WIKI.db
.update(usersTable)
.set({ auth: user.auth, updatedAt: sql`now()` })
.where(eq(usersTable.id, user.id))
WIKI.models.flags.authDebug(`User ${user.id} <${user.email}> enabled 2FA`)
}
/**
* Turn 2FA off for a user and forget the secret, so that setting it up again starts from a new one.
*
* @throws `ERR_INVALID_USER`, `ERR_INVALID_STRATEGY`, `ERR_TFA_NOT_ACTIVE` or `ERR_TFA_ENFORCED`
*/
async disableTfa(userId: string, strategyId: string): Promise<void> {
const user = await this.getById(userId)
if (!user) {
throw new Error('ERR_INVALID_USER')
}
const auth = (user.auth ?? {}) as Record<string, any>
if (!auth[strategyId]) {
throw new Error('ERR_INVALID_STRATEGY')
}
if (!auth[strategyId].tfaIsActive) {
throw new Error('ERR_TFA_NOT_ACTIVE')
}
// -> Turning it off would be undone at the next login, which is worth an error rather than a
// confusing round trip. The client greys the button out, but that is a client.
const strategy = await WIKI.models.authentication.getStrategyById(strategyId)
if (auth[strategyId].tfaRequired || (strategy?.config as Record<string, any>)?.enforceTfa) {
throw new Error('ERR_TFA_ENFORCED')
}
auth[strategyId] = { ...auth[strategyId], tfaIsActive: false, tfaSecret: '' }
await WIKI.db
.update(usersTable)
.set({ auth, updatedAt: sql`now()` })
.where(eq(usersTable.id, userId))
WIKI.models.flags.authDebug(`User ${userId} <${user.email}> disabled 2FA`)
}
/**
* Whether a security code matches the 2FA secret stored for a user under one strategy.
*/
verifyTfaCode(user: any, strategyId: string, securityCode: string): boolean {
const secret = ((user.auth ?? {}) as Record<string, any>)[strategyId]?.tfaSecret
return Boolean(secret) && verifyTotpCode(secret, securityCode)
}
/**
* Delete a user.
*
* Group assignments cascade, but sessions and keys do not — they are login artifacts, so they are
* cleared here rather than blocking the delete. References from authored content (pages, assets)
* have no cascade either and will make this throw, which is deliberate: the delete is refused
* rather than silently orphaning content.
*
* @returns Whether a user was deleted
*/
async deleteUser(id: string): Promise<boolean> {
await WIKI.db.delete(userKeys).where(eq(userKeys.userId, id))
await WIKI.db.delete(sessionsTable).where(eq(sessionsTable.userId, id))
const result = await WIKI.db.delete(usersTable).where(eq(usersTable.id, id))
return (result.rowCount ?? 0) > 0
}
async init(ids: SystemIds): Promise<void> {
WIKI.logger.info('Inserting default users...')
await WIKI.db.insert(usersTable).values([
{
id: ids.userAdminId,
email: process.env.ADMIN_EMAIL ?? 'admin@example.com',
auth: {
[ids.authModuleId]: {
password: await bcrypt.hash(process.env.ADMIN_PASS || '12345678', 12),
mustChangePwd: !process.env.ADMIN_PASS,
restrictLogin: false,
tfaIsActive: false,
tfaRequired: false,
tfaSecret: ''
}
},
name: 'Administrator',
isSystem: false,
isActive: true,
isVerified: true,
meta: {
location: '',
jobTitle: '',
pronouns: ''
},
prefs: {
timezone: 'America/New_York',
dateFormat: 'YYYY-MM-DD',
timeFormat: '12h',
appearance: 'site',
cvd: 'none'
}
},
{
id: ids.userGuestId,
email: 'guest@example.com',
auth: {},
name: 'Guest',
isSystem: true,
isActive: true,
isVerified: true,
meta: {},
prefs: {
timezone: 'America/New_York',
dateFormat: 'YYYY-MM-DD',
timeFormat: '12h',
appearance: 'site',
cvd: 'none'
}
}
])
await WIKI.db.insert(userGroups).values([
{
userId: ids.userAdminId,
groupId: ids.groupAdminId
},
{
userId: ids.userGuestId,
groupId: ids.groupGuestId
}
])
}
async login(
{ siteId, strategyId, username, password, ip }: LoginOptions,
req: any
): Promise<AfterLoginResult> {
if (strategyId in WIKI.auth.strategies) {
const str = WIKI.auth.strategies[strategyId] as any
const strInfo = WIKI.data.authentication.find((a: any) => a.key === str.module)
const context = {
ip,
siteId,
...(strInfo.useForm && {
username,
password
})
}
// -> Never the password, flag or no flag
WIKI.models.flags.authDebug(
`Login attempt on site ${siteId} using ${str.module} strategy ${strategyId}${username ? ` as "${username}"` : ''} from ${ip}`
)
// Authenticate
let user
try {
user = await str.authenticate(context)
} catch (err: any) {
WIKI.models.flags.authDebug(
`Strategy ${str.module} rejected the attempt${username ? ` for "${username}"` : ''}: ${err.message}`
)
throw err
}
// Perform post-login checks
return this.afterLoginChecks(
user,
strategyId,
context,
{
skipTFA: !strInfo.useForm,
skipChangePwd: !strInfo.useForm
},
req
)
} else {
WIKI.models.flags.authDebug(`Login attempt using unknown strategy ${strategyId} from ${ip}`)
throw new Error('Invalid Strategy ID')
}
}
/**
* Log somebody in from what an identity provider said about them, creating the account if the
* strategy is set to accept new users.
*
* The email address is the identity: a provider's own `id` is recorded so that an address changing
* upstream does not orphan the account, but matching starts with the address because that is what
* an administrator invited, what a group rule was written against, and what every other strategy
* keys on. A module must therefore only ever report an address it has established belongs to the
* person — see `ProviderProfile`.
*
* Registration is refused rather than silently allowed: a wiki that has not opened its doors to a
* provider gets `ERR_REGISTRATION_DISABLED` for an unknown account, and one that has can still
* limit who by, with the strategy's email allow-list pattern.
*
* @throws `ERR_REGISTRATION_DISABLED`, `ERR_EMAIL_NOT_ALLOWED`, `ERR_INACTIVE_USER`
*/
async loginWithProvider(
{
siteId,
strategy,
profile,
ip
}: {
siteId: string
strategy: AuthStrategy
profile: ProviderProfile
ip?: string
},
req: any
): Promise<AfterLoginResult> {
const email = profile.email.toLowerCase().trim()
let user = await this.getByEmail(email)
if (!user) {
if (!strategy.registration) {
WIKI.models.flags.authDebug(
`Provider login for unknown address <${email}> refused: strategy ${strategy.id} does not accept new users`
)
throw new Error('ERR_REGISTRATION_DISABLED')
}
if (strategy.allowedEmailRegex) {
let allowed = false
try {
allowed = new RegExp(strategy.allowedEmailRegex).test(email)
} catch (err: any) {
// -> A pattern that will not compile allows nobody, rather than everybody
WIKI.logger.warn(
`Strategy ${strategy.id} has an invalid email pattern, refusing: ${err.message}`
)
}
if (!allowed) {
throw new Error('ERR_EMAIL_NOT_ALLOWED')
}
}
const userId = await this.createUser({
name: profile.name || email,
email,
// -> Nothing signs in with it: this account authenticates at the provider, and the local
// strategy's own entry is what a password would live under
password: nanoid(32),
groups: strategy.autoEnrollGroups ?? [],
isVerified: true
})
user = await this.getById(userId)
WIKI.models.flags.authDebug(
`Created user ${userId} <${email}> from ${strategy.module} strategy ${strategy.id}`
)
}
if (!user) {
throw new Error('ERR_LOGIN_FAILED')
}
if (!user.isActive) {
throw new Error('ERR_INACTIVE_USER')
}
/*
The link between this account and the provider's, written on every login: it records which
account at the provider this is, and it is what tells the profile page that this user signs in
through this strategy.
*/
const auth = (user.auth ?? {}) as Record<string, any>
auth[strategy.id] = {
...auth[strategy.id],
id: profile.id,
email
}
user.auth = auth
await WIKI.db
.update(usersTable)
.set({ auth, updatedAt: sql`now()` })
.where(eq(usersTable.id, user.id))
/*
Neither 2FA nor a password change is asked for: both are the local strategy's, and this user has
just proved who they are somewhere else — where whatever second factor that provider enforces has
already been satisfied.
*/
return this.afterLoginChecks(
user,
strategy.id,
{ ip, siteId },
{ skipTFA: true, skipChangePwd: true },
req
)
}
async afterLoginChecks(
user: any,
strategyId: string,
context: any,
{ skipTFA, skipChangePwd }: { skipTFA?: boolean; skipChangePwd?: boolean } = {
skipTFA: false,
skipChangePwd: false
},
req?: any
): Promise<AfterLoginResult> {
const str = WIKI.auth.strategies[strategyId] as any
if (!str) {
throw new Error('ERR_INVALID_STRATEGY')
}
// Get user groups
user.groups = await WIKI.db.query.users
.findFirst({
columns: {},
where: {
id: user.id
},
with: {
groups: {
columns: {
id: true,
permissions: true,
redirectOnLogin: true
}
}
}
})
.then((r: any) => r?.groups || [])
// Get redirect target
let redirect = '/'
if (user.groups && user.groups.length > 0) {
for (const grp of user.groups as any[]) {
if (grp.redirectOnLogin && grp.redirectOnLogin !== '/') {
redirect = grp.redirectOnLogin
break
}
}
}
// Get auth strategy flags
const authStr = user.auth[strategyId] || {}
// Is 2FA required?
if (!skipTFA) {
if (authStr.tfaIsActive && authStr.tfaSecret) {
try {
const tfaToken = await this.generateToken({
kind: 'tfa',
userId: user.id,
meta: {
strategyId
}
})
WIKI.models.flags.authDebug(
`User ${user.id} <${user.email}> authenticated, but a 2FA code is required first`
)
return {
nextAction: 'provideTfa',
continuationToken: tfaToken,
redirect
}
} catch (errc) {
WIKI.logger.warn(errc)
throw new Error('ERR_TFA_FAILED')
}
/*
`conf`, not `config`: what a module is constructed with is its stored settings, and every
one of them keeps them under that name. `config` is free for a module to use for something
else, and two of them do — on the OIDC and Google strategies it holds the provider's
openid-client `Configuration`, which has no `enforceTfa` and never will. Read the wrong one
and this arm is simply never taken, which is what made "Enforce Two-Factor Authentication"
do nothing at all.
*/
} else if (str.conf?.enforceTfa || authStr.tfaRequired) {
try {
const { secret, tfaQRImage } = await this.startTfaSetup(user, strategyId, context.siteId)
const tfaToken = await this.generateToken({
kind: 'tfaSetup',
userId: user.id,
meta: {
strategyId
}
})
WIKI.models.flags.authDebug(
`User ${user.id} <${user.email}> authenticated, but must set up 2FA first`
)
return {
nextAction: 'setupTfa',
continuationToken: tfaToken,
tfaQRImage,
tfaSecret: secret,
redirect
}
} catch (errc) {
WIKI.logger.warn(errc)
throw new Error('ERR_TFA_FAILED')
}
}
}
// Must Change Password?
if (!skipChangePwd && authStr.mustChangePwd) {
try {
const pwdChangeToken = await this.generateToken({
kind: 'changePwd',
userId: user.id,
meta: {
strategyId
}
})
WIKI.models.flags.authDebug(
`User ${user.id} <${user.email}> authenticated, but must change their password first`
)
return {
nextAction: 'changePassword',
continuationToken: pwdChangeToken,
redirect
}
} catch (errc) {
WIKI.logger.warn(errc)
throw new Error('ERR_CHANGE_PASSWORD_FAILED')
}
}
// Set Session Data
this.updateSession(user, req)
WIKI.models.flags.authDebug(
`User ${user.id} <${user.email}> logged in with ${user.groups.length} group(s) and ${req?.session?.permissions?.length ?? 0} permission(s), redirecting to ${redirect}`
)
// -> Only once the login has actually succeeded: an attempt stopped by 2FA or a forced password
// change is not a login yet.
// Every login path -- local, provider, passkey, and the 2FA / password-change continuations --
// ends up here, so this is the one place the stamp belongs. `updatedAt` is deliberately left
// alone: signing in is not an edit of the account.
await WIKI.db
.update(usersTable)
.set({ lastLoginAt: sql`now()` })
.where(eq(usersTable.id, user.id))
await WIKI.models.hooks.emit('user:login', {
userId: user.id,
strategyId,
ip: context.ip,
metadata: {
name: user.name,
email: user.email
}
})
/*
The one audit entry not written from a route handler, and for the same reason the stamp above
is not: every way of signing in — local, a provider, a passkey, and the 2FA and forced password
change continuations — converges here, and recording it at each of those routes instead would
be six copies of one event that would drift apart.
A login that FAILED is deliberately not recorded anywhere. This endpoint is open to whoever can
reach the wiki, so a credential-stuffing run would otherwise be able to fill the table from the
outside; `models/rateLimits.ts` is what answers that, and the wiki's own log is where a refused
attempt shows up.
*/
await WIKI.models.auditLog.record({
kind: 'auth',
action: 'login',
actor: { id: user.id, name: user.name, email: user.email, ip: context.ip ?? '' },
meta: { strategyId, siteId: context.siteId ?? null }
})
return {
authenticated: true,
nextAction: 'redirect',
redirect
}
}
/**
* Finish a login that stopped for 2FA — either to ask for a code, or to have the user set 2FA up
* because the strategy or the account requires it.
*
* The continuation token identifies the half-finished login, and is kept rather than consumed while
* codes are being tried: a mistyped or just-expired code has to be retryable. It is destroyed here
* as soon as one is correct, and by `countTfaFailure()` once too many have not been.
*
* @param setup True when the token came from a required setup, in which case a correct code also
* activates the secret that was generated for it
* @throws `ERR_TFA_INVALID_REQUEST`, `ERR_INVALID_USER`, `ERR_INVALID_STRATEGY` or
* `ERR_TFA_INCORRECT_TOKEN`, plus whatever `validateToken()` raises for a token that is
* unknown or expired
*/
async loginTFA(
{
strategyId,
siteId,
securityCode,
continuationToken,
setup = false,
ip
}: {
strategyId: string
siteId: string
securityCode: string
continuationToken: string
setup?: boolean
ip?: string
},
req: any
): Promise<AfterLoginResult> {
if (!continuationToken || !/^[0-9]{6}$/.test(securityCode)) {
throw new Error('ERR_TFA_INVALID_REQUEST')
}
const { user, strategyId: expectedStrategyId } = await this.validateToken({
kind: setup ? 'tfaSetup' : 'tfa',
token: continuationToken,
skipDelete: true
})
if (!user) {
throw new Error('ERR_INVALID_USER')
}
if (strategyId !== expectedStrategyId) {
throw new Error('ERR_INVALID_STRATEGY')
}
if (!this.verifyTfaCode(user, strategyId, securityCode)) {
await countTfaFailure(continuationToken)
WIKI.models.flags.authDebug(`User ${user.id} <${user.email}> submitted an incorrect 2FA code`)
throw new Error('ERR_TFA_INCORRECT_TOKEN')
}
await this.destroyToken({ token: continuationToken })
if (setup) {
await this.enableTfa(user, strategyId)
}
// -> The remaining checks still apply: a user who owed a password change before 2FA still owes it
return this.afterLoginChecks(user, strategyId, { ip, siteId }, { skipTFA: true }, req)
}
/**
* Start 2FA setup from the profile page, for a user who is already logged in.
*
* @returns The QR code to scan, the secret behind it for manual entry, and the token that
* `confirmTfaSetup()` expects back
* @throws `ERR_INVALID_USER`, `ERR_INVALID_STRATEGY` or `ERR_TFA_ALREADY_ACTIVE`
*/
async startProfileTfaSetup({
userId,
strategyId,
siteId
}: {
userId: string
strategyId: string
siteId?: string
}): Promise<{ continuationToken: string; tfaQRImage: string; tfaSecret: string }> {
const user = await this.getById(userId)
if (!user) {
throw new Error('ERR_INVALID_USER')
}
const auth = (user.auth ?? {}) as Record<string, any>
if (!auth[strategyId]) {
throw new Error('ERR_INVALID_STRATEGY')
}
// -> Replacing a working secret would silently invalidate the app entry the user already has;
// turning 2FA off first is the way to start again
if (auth[strategyId].tfaIsActive) {
throw new Error('ERR_TFA_ALREADY_ACTIVE')
}
const { secret, tfaQRImage } = await this.startTfaSetup(user, strategyId, siteId)
const continuationToken = await this.generateToken({
kind: 'tfaSetup',
userId,
meta: { strategyId }
})
return { continuationToken, tfaQRImage, tfaSecret: secret }
}
/**
* Finish 2FA setup from the profile page: check a code from the user's authenticator, then activate
* the secret that was generated for it.
*
* Deliberately not `loginTFA()` with `setup`: the user is already logged in, and running the login
* checks again would rebuild the session and emit a second login event for one visit.
*
* @throws `ERR_TFA_INVALID_REQUEST`, `ERR_INVALID_USER`, `ERR_INVALID_STRATEGY` or
* `ERR_TFA_INCORRECT_TOKEN`
*/
async confirmTfaSetup({
userId,
strategyId,
continuationToken,
securityCode
}: {
userId: string
strategyId: string
continuationToken: string
securityCode: string
}): Promise<void> {
if (!continuationToken || !/^[0-9]{6}$/.test(securityCode)) {
throw new Error('ERR_TFA_INVALID_REQUEST')
}
const { user, strategyId: expectedStrategyId } = await this.validateToken({
kind: 'tfaSetup',
token: continuationToken,
skipDelete: true
})
// -> The token is a bearer credential, so it only counts for the session that asked for it
if (!user || user.id !== userId) {
throw new Error('ERR_INVALID_USER')
}
if (strategyId !== expectedStrategyId) {
throw new Error('ERR_INVALID_STRATEGY')
}
if (!this.verifyTfaCode(user, strategyId, securityCode)) {
await countTfaFailure(continuationToken)
throw new Error('ERR_TFA_INCORRECT_TOKEN')
}
await this.destroyToken({ token: continuationToken })
await this.enableTfa(user, strategyId)
}
/**
* Where to send a user after logging out.
*
* A group's own target wins over the site's, which is what the admin area promises: the site setting
* says it "can be overridden at the group level". With several groups the first one that names a
* target wins, the same arbitrary-but-stable rule the login redirect uses.
*
* @param userId The user logging out, or null for a request that was not logged in
* @param siteId The site being logged out of, if it is known
* @returns A path or URL, never empty — the site root when nothing is configured
*/
async getLogoutRedirect(userId: string | null, siteId?: string): Promise<string> {
if (userId) {
const groups = await WIKI.db.query.users
.findFirst({
columns: {},
where: {
id: userId
},
with: {
groups: {
columns: {
redirectOnLogout: true
}
}
}
})
.then((r: any) => r?.groups ?? [])
for (const grp of groups as any[]) {
if (grp.redirectOnLogout && grp.redirectOnLogout !== '/') {
return grp.redirectOnLogout
}
}
}
const site = siteId ? await WIKI.models.sites.getSiteById({ id: siteId }) : null
return site?.config?.auth?.logoutRedirect || '/'
}
async loginChangePassword(
{
strategyId,
siteId,
continuationToken,
newPassword,
ip
}: {
strategyId: string
siteId: string
continuationToken: string
newPassword: string
ip?: string
},
req: any
): Promise<AfterLoginResult> {
if (!newPassword || newPassword.length < 8) {
throw new Error('ERR_PASSWORD_TOO_SHORT')
}
const { user, strategyId: expectedStrategyId } = await this.validateToken({
kind: 'changePwd',
token: continuationToken
})
if (strategyId !== expectedStrategyId) {
throw new Error('ERR_INVALID_STRATEGY')
}
if (user) {
user.auth[strategyId].password = await bcrypt.hash(newPassword, 12)
user.auth[strategyId].mustChangePwd = false
await WIKI.db.update(usersTable).set({ auth: user.auth }).where(eq(usersTable.id, user.id))
// -> Recorded separately from the `login` the call below writes: two things happened, and a
// password that a login insisted be changed is the one worth being able to find on its own
await WIKI.models.auditLog.record({
kind: 'auth',
action: 'forcedPasswordChange',
actor: { id: user.id, name: user.name, email: user.email, ip: ip ?? '' },
meta: { siteId, strategyId }
})
return this.afterLoginChecks(
user,
strategyId,
{ ip, siteId },
{ skipChangePwd: true, skipTFA: true },
req
)
} else {
throw new Error('ERR_INVALID_USER')
}
}
/**
* Create an account from the login screen's own registration form.
*
* Only the local module registers this way. The providers that sign users in elsewhere create
* accounts too, but they do it in `loginWithProvider()` on the way through a successful sign-in —
* there is no form to fill in, and no password to choose.
*
* The strategy has to be one the site actually offers (`getSiteStrategy`), not merely one that
* exists: a strategy an administrator has taken off a site must stop creating accounts on it, and
* the ID is in the hands of anybody who has ever loaded that login screen.
*
* What happens next depends on the strategy's `emailValidation` prop. With it off the account is
* usable at once and this returns whatever an ordinary login would have — including a 2FA setup,
* for a strategy that enforces one. With it on the account is created unverified, the address is
* sent a link, and `verifyEmail` is what comes back: there is nothing to log in to yet.
*
* @param baseUrl Where this wiki is reachable, for the link in the email
* @throws `ERR_INVALID_STRATEGY`, `ERR_REGISTRATION_DISABLED`, `ERR_EMAIL_NOT_ALLOWED`,
* `ERR_ACCOUNT_ALREADY_EXISTS`, `ERR_PASSWORD_TOO_SHORT`, `ERR_MAIL_NOT_CONFIGURED`
*/
async registerUser(
{
siteId,
strategyId,
name,
email,
password,
ip,
baseUrl
}: {
siteId: string
strategyId: string
name: string
email: string
password: string
ip?: string
baseUrl: string
},
req: any
): Promise<AfterLoginResult> {
const strategy = await WIKI.models.authentication.getSiteStrategy(siteId, strategyId)
if (!strategy || strategy.module !== 'local') {
WIKI.models.flags.authDebug(
`Registration on site ${siteId} refused: ${strategyId} is not a local strategy offered there`
)
throw new Error('ERR_INVALID_STRATEGY')
}
if (!strategy.registration) {
throw new Error('ERR_REGISTRATION_DISABLED')
}
if (!password || password.length < 8) {
throw new Error('ERR_PASSWORD_TOO_SHORT')
}
const address = email.toLowerCase().trim()
if (strategy.allowedEmailRegex) {
let allowed = false
try {
allowed = new RegExp(strategy.allowedEmailRegex).test(address)
} catch (err: any) {
// -> A pattern that will not compile allows nobody, rather than everybody
WIKI.logger.warn(
`Strategy ${strategy.id} has an invalid email pattern, refusing: ${err.message}`
)
}
if (!allowed) {
WIKI.models.flags.authDebug(
`Registration refused for <${address}>: the address is outside strategy ${strategy.id}'s allow-list`
)
throw new Error('ERR_EMAIL_NOT_ALLOWED')
}
}
if (await this.getByEmail(address)) {
throw new Error('ERR_ACCOUNT_ALREADY_EXISTS')
}
/*
An address this wiki has undertaken to check has to actually be checkable. Refusing here rather
than quietly creating a verified account is the point: the setting says addresses are confirmed,
and an instance with nothing to send the confirmation through cannot honour it. The alternative
-- an unverified account nobody can ever send a link to -- is a dead end that needs an
administrator either way, and this at least says so while somebody is looking.
*/
const mustVerify = strategy.config?.emailValidation === true
if (mustVerify && !WIKI.models.mail.isConfigured) {
WIKI.logger.warn(
`Registration refused: strategy ${strategy.id} validates email addresses, but no SMTP server is configured.`
)
throw new Error('ERR_MAIL_NOT_CONFIGURED')
}
const userId = await this.createUser({
name: name.trim(),
email: address,
password,
groups: strategy.autoEnrollGroups ?? [],
isVerified: !mustVerify,
strategyId: strategy.id
})
/*
Called from both of the paths below rather than here, because the account is undone again if
the verification email cannot be sent — and an audit entry for an account that no longer
exists, pointing at a deleted row, is worse than no entry.
*/
const recordRegistration = () =>
WIKI.models.auditLog.record({
kind: 'auth',
action: 'register',
actor: { id: userId, name: name.trim(), email: address, ip: ip ?? '' },
meta: { siteId, strategyId: strategy.id, mustVerify }
})
if (mustVerify) {
const token = await this.generateToken({
kind: 'verifyEmail',
userId,
meta: { strategyId: strategy.id, siteId }
})
try {
await WIKI.models.mail.send({
siteId,
to: address,
template: 'welcome',
data: {
name: name.trim(),
baseUrl,
// -> The login screen, which asks for a press before it confirms anything. Never an
// endpoint that would confirm on being fetched: the scanners that follow every link
// in a message before it is delivered would spend the token before the reader does.
verifyUrl: `${baseUrl}/login?verify=${token}`
}
})
} catch (err: any) {
/*
Undone rather than left behind. The account cannot be signed into and cannot be confirmed,
and leaving it would take the address with it -- a second attempt, after whatever was wrong
with the mail server is fixed, would be refused as already registered. Nothing else has
happened to it yet, so there is nothing else to unwind.
*/
await this.deleteUser(userId)
WIKI.logger.warn(`Could not send the verification email to <${address}>: ${err.message}`)
throw new Error('ERR_MAIL_SEND_FAILED')
}
WIKI.models.flags.authDebug(
`Registered user ${userId} <${address}> on site ${siteId} from ${ip}, pending email verification`
)
await recordRegistration()
return {
nextAction: 'verifyEmail',
redirect: '/'
}
}
// -> Best effort, and deliberately not undone on failure: unlike the verification link this is a
// courtesy, and the account it welcomes works whether or not it arrives
if (WIKI.models.mail.isConfigured) {
try {
await WIKI.models.mail.send({
siteId,
to: address,
template: 'welcome',
data: { name: name.trim(), baseUrl }
})
} catch (err: any) {
WIKI.logger.warn(`Could not send the welcome email to <${address}>: ${err.message}`)
}
}
const user = await this.getById(userId)
WIKI.models.flags.authDebug(
`Registered user ${userId} <${address}> on site ${siteId} from ${ip}, signing them in`
)
await recordRegistration()
return this.afterLoginChecks(user, strategy.id, { ip, siteId }, {}, req)
}
/**
* Tell somebody an account has been made for them.
*
* The administrator's version of what registration sends itself: no confirmation link, because an
* account an administrator created is verified by definition, and no password, because the mail is
* not the place for one. What it carries is the wiki's name and where to sign in.
*
* @param siteId Which site to welcome them to. Defaults to whichever one the request was addressed
* to, since an instance with one site has no choice to make.
* @throws `ERR_INVALID_USER`, `ERR_MAIL_NOT_CONFIGURED`, and whatever the mail server said
*/
async sendWelcomeEmail({
userId,
siteId,
req
}: {
userId: string
siteId?: string
req?: any
}): Promise<void> {
const user = await this.getById(userId)
if (!user) {
throw new Error('ERR_INVALID_USER')
}
const targetSiteId =
(siteId && WIKI.sites[siteId] ? siteId : null) ??
(await WIKI.models.sites.getSiteByHostname({ hostname: req?.hostname ?? '*' }))?.id ??
''
await WIKI.models.mail.send({
siteId: targetSiteId,
to: user.email,
template: 'welcome',
data: {
name: user.name,
baseUrl: WIKI.models.mail.baseUrl({ req, siteId: targetSiteId })
}
})
}
/**
* Confirm an address from the link in a registration email.
*
* The token is consumed whether or not it was still needed, so the link works once. Nobody is
* signed in by it: the browser reading the mail is not necessarily the one that registered, and
* whoever it is still has to know the password.
*
* @throws `ERR_INVALID_VALIDATION_TOKEN`, `ERR_EXPIRED_VALIDATION_TOKEN`, `ERR_INVALID_USER`
*/
async verifyUserEmail(token: string, ip?: string): Promise<void> {
const { user } = await this.validateToken({ kind: 'verifyEmail', token })
if (!user) {
throw new Error('ERR_INVALID_USER')
}
if (!user.isVerified) {
await WIKI.db
.update(usersTable)
.set({ isVerified: true, updatedAt: sql`now()` })
.where(eq(usersTable.id, user.id))
}
WIKI.models.flags.authDebug(`User ${user.id} <${user.email}> confirmed their email address`)
await WIKI.models.auditLog.record({
kind: 'auth',
action: 'verifyEmail',
actor: { id: user.id, name: user.name, email: user.email, ip: ip ?? '' },
meta: {}
})
}
/**
* Send somebody the link they choose a new password from, if there is anybody to send it to.
*
* **This never says whether the address is registered.** It returns the same way for an account
* that got a mail, an address nobody here has, an account that signs in through a provider and has
* no local password, and a deactivated one — because the form is public, and answering the question
* would make it a way to find out who has an account. What is not about the address is reported
* normally: a strategy that does not offer resets at all, and an instance with no mail server, are
* both misconfigurations rather than answers about a user.
*
* @param baseUrl Where this wiki is reachable, for the link in the email
* @throws `ERR_INVALID_STRATEGY`, `ERR_FORGOT_PASSWORD_DISABLED`, `ERR_MAIL_NOT_CONFIGURED`
*/
async requestPasswordReset({
siteId,
strategyId,
email,
ip,
baseUrl
}: {
siteId: string
strategyId: string
email: string
ip?: string
baseUrl: string
}): Promise<void> {
const strategy = await WIKI.models.authentication.getSiteStrategy(siteId, strategyId)
if (!strategy || strategy.module !== 'local') {
throw new Error('ERR_INVALID_STRATEGY')
}
if (strategy.config?.allowForgotPassword !== true) {
throw new Error('ERR_FORGOT_PASSWORD_DISABLED')
}
if (!WIKI.models.mail.isConfigured) {
WIKI.logger.warn(
'A password reset was requested, but no SMTP server is configured to send it through.'
)
throw new Error('ERR_MAIL_NOT_CONFIGURED')
}
const address = email.toLowerCase().trim()
const user = await this.getByEmail(address)
const auth = (user?.auth ?? {}) as Record<string, any>
if (!user || !user.isActive || !auth[strategy.id]?.password) {
WIKI.models.flags.authDebug(
`Password reset requested from ${ip} for <${address}>, which has no password on strategy ${strategy.id}; nothing sent`
)
return
}
const token = await this.generateToken({
kind: 'resetPwd',
userId: user.id,
meta: { strategyId: strategy.id, siteId }
})
await WIKI.models.mail.send({
siteId,
to: user.email,
template: 'resetPwd',
data: {
name: user.name,
baseUrl,
resetUrl: `${baseUrl}/login?reset=${token}`
}
})
WIKI.models.flags.authDebug(
`Password reset requested from ${ip} for user ${user.id} <${user.email}>, link sent`
)
/*
Only when a link was actually sent. The early return above covers an address nobody holds, and
recording those would turn an endpoint open to the internet into a way of writing arbitrary
addresses into the audit log.
*/
await WIKI.models.auditLog.record({
kind: 'auth',
action: 'requestPasswordReset',
actor: { id: user.id, name: user.name, email: user.email, ip: ip ?? '' },
meta: { siteId, strategyId: strategy.id }
})
}
/**
* Set the new password a reset link was followed to choose.
*
* The token is consumed on the first attempt, correct password or not — unlike the 2FA
* continuation token, there is nothing to get wrong here that would need a second try, and a link
* sitting in a mailbox should stop working as soon as it has been used.
*
* The account is also marked verified. Following the link proves control of the mailbox, which is
* the whole of what verification asks — and without this, somebody who registered, never got the
* confirmation and then reset their password would still not be able to sign in.
*
* Nobody is signed in by it: the new password is what does that, on the login screen.
*
* @throws `ERR_INVALID_VALIDATION_TOKEN`, `ERR_EXPIRED_VALIDATION_TOKEN`, `ERR_INVALID_USER`,
* `ERR_INVALID_STRATEGY`, `ERR_PASSWORD_TOO_SHORT`
*/
async resetPassword({
token,
newPassword,
ip
}: {
token: string
newPassword: string
ip?: string
}): Promise<void> {
if (!newPassword || newPassword.length < 8) {
throw new Error('ERR_PASSWORD_TOO_SHORT')
}
const { user, strategyId } = await this.validateToken({ kind: 'resetPwd', token })
if (!user) {
throw new Error('ERR_INVALID_USER')
}
const auth = (user.auth ?? {}) as Record<string, any>
// -> The strategy could have been deleted, or password login turned off, since the link was sent
if (!auth[strategyId]?.password) {
throw new Error('ERR_INVALID_STRATEGY')
}
auth[strategyId] = {
...auth[strategyId],
password: await bcrypt.hash(newPassword, 12),
mustChangePwd: false
}
await WIKI.db
.update(usersTable)
.set({ auth, isVerified: true, updatedAt: sql`now()` })
.where(eq(usersTable.id, user.id))
WIKI.models.flags.authDebug(`User ${user.id} <${user.email}> reset their password`)
await WIKI.models.auditLog.record({
kind: 'auth',
action: 'resetPassword',
actor: { id: user.id, name: user.name, email: user.email, ip: ip ?? '' },
meta: { strategyId }
})
}
updateSession(user: any, req: any): void {
req.session.authenticated = true
req.session.user = {
id: user.id,
email: user.email,
name: user.name,
hasAvatar: user.hasAvatar,
timezone: user.prefs?.timezone,
dateFormat: user.prefs?.dateFormat,
timeFormat: user.prefs?.timeFormat,
appearance: user.prefs?.appearance,
cvd: user.prefs?.cvd
}
req.session.permissions = uniq(flatten(user.groups?.map((g: any) => g.permissions)))
// -> Group ids as well as their permissions, since navigation items are limited per group
req.session.groups = (user.groups ?? []).map((g: any) => g.id)
}
async generateToken({
userId,
kind,
meta = {}
}: {
userId: string
kind: string
meta?: Record<string, any>
}): Promise<string> {
WIKI.logger.debug(`Generating ${kind} token for user ${userId}...`)
const token = await nanoid()
await WIKI.db.insert(userKeys).values({
kind,
token,
meta,
// NOTE: ISO string rather than a Date, for the same UTC-vs-local reason as models/jobs.ts.
// 24 hours rather than 1 day: Temporal.Instant takes exact time units only, and in UTC
// a calendar day is exactly 24 hours.
validUntil: Temporal.Now.instant()
.add({ hours: 24 })
.toString({ smallestUnit: 'millisecond' }) as any,
userId
})
return token
}
async validateToken({
kind,
token,
skipDelete
}: {
kind: string
token: string
skipDelete?: boolean
}): Promise<any> {
const res = await WIKI.db.query.userKeys.findFirst({
where: {
kind,
token
},
with: {
user: true
}
})
if (res) {
if (skipDelete !== true) {
await WIKI.db.delete(userKeys).where(eq(userKeys.id, res.id))
}
// -> BEHAVIOR CHANGE (Temporal migration): this previously read
// `DateTime.utc() > DateTime.fromISO(res.validUntil)`. `validUntil` is a `timestamp`
// column, so drizzle hands back a Date, and `fromISO` given a Date produced an *Invalid*
// DateTime whose comparison was always false — tokens never expired. Temporal has no
// Invalid sentinel to reproduce that with, so the check now works as intended.
if (
Temporal.Instant.compare(Temporal.Now.instant(), res.validUntil.toTemporalInstant()) > 0
) {
throw new Error('ERR_EXPIRED_VALIDATION_TOKEN')
}
return {
...(res.meta as Record<string, any>),
user: res.user
}
} else {
throw new Error('ERR_INVALID_VALIDATION_TOKEN')
}
}
async destroyToken({ token }: { token: string }) {
return WIKI.db.delete(userKeys).where(eq(userKeys.token, token))
}
}
export const users = new Users()