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.js

324 lines
8.0 KiB

import bcrypt from 'bcryptjs'
import { userGroups, users as usersTable, userKeys } from '../db/schema.js'
import { eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { DateTime } from 'luxon'
import { flatten, uniq } from 'es-toolkit/array'
/**
* Users model
*/
class Users {
async getByEmail(email) {
const res = await WIKI.db.select().from(usersTable).where(eq(usersTable.email, email)).limit(1)
return res?.[0] ?? null
}
async init(ids) {
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 }, req) {
if (strategyId in WIKI.auth.strategies) {
const str = WIKI.auth.strategies[strategyId]
const strInfo = WIKI.data.authentication.find((a) => a.key === str.module)
const context = {
ip,
siteId,
...(strInfo.useForm && {
username,
password
})
}
// Authenticate
const user = await str.authenticate(context)
// Perform post-login checks
return this.afterLoginChecks(
user,
strategyId,
context,
{
skipTFA: !strInfo.useForm,
skipChangePwd: !strInfo.useForm
},
req
)
} else {
throw new Error('Invalid Strategy ID')
}
}
async afterLoginChecks(
user,
strategyId,
context,
{ skipTFA, skipChangePwd } = { skipTFA: false, skipChangePwd: false },
req
) {
const str = WIKI.auth.strategies[strategyId]
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) => r?.groups || [])
// Get redirect target
let redirect = '/'
if (user.groups && user.groups.length > 0) {
for (const grp of user.groups) {
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 WIKI.db.userKeys.generateToken({
kind: 'tfa',
userId: user.id,
meta: {
strategyId
}
})
return {
nextAction: 'provideTfa',
continuationToken: tfaToken,
redirect
}
} catch (errc) {
WIKI.logger.warn(errc)
throw new WIKI.Error.AuthGenericError()
}
} else if (str.config?.enforceTfa || authStr.tfaRequired) {
try {
const tfaQRImage = await user.generateTFA(strategyId, context.siteId)
const tfaToken = await WIKI.db.userKeys.generateToken({
kind: 'tfaSetup',
userId: user.id,
meta: {
strategyId
}
})
return {
nextAction: 'setupTfa',
continuationToken: tfaToken,
tfaQRImage,
redirect
}
} catch (errc) {
WIKI.logger.warn(errc)
throw new WIKI.Error.AuthGenericError()
}
}
}
// Must Change Password?
if (!skipChangePwd && authStr.mustChangePwd) {
try {
const pwdChangeToken = await this.generateToken({
kind: 'changePwd',
userId: user.id,
meta: {
strategyId
}
})
return {
nextAction: 'changePassword',
continuationToken: pwdChangeToken,
redirect
}
} catch (errc) {
WIKI.logger.warn(errc)
throw new WIKI.Error.AuthGenericError()
}
}
// Set Session Data
this.updateSession(user, req)
return {
authenticated: true,
nextAction: 'redirect',
redirect
}
}
async loginChangePassword({ strategyId, siteId, continuationToken, newPassword, ip }, req) {
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))
return this.afterLoginChecks(
user,
strategyId,
{ ip, siteId },
{ skipChangePwd: true, skipTFA: true },
req
)
} else {
throw new Error('ERR_INVALID_USER')
}
}
updateSession(user, req) {
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) => g.permissions)))
}
async generateToken({ userId, kind, meta = {} }) {
WIKI.logger.debug(`Generating ${kind} token for user ${userId}...`)
const token = await nanoid()
await WIKI.db.insert(userKeys).values({
kind,
token,
meta,
validUntil: DateTime.utc().plus({ days: 1 }).toISO(),
userId
})
return token
}
async validateToken({ kind, token, skipDelete }) {
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))
}
if (DateTime.utc() > DateTime.fromISO(res.validUntil)) {
throw new Error('ERR_EXPIRED_VALIDATION_TOKEN')
}
return {
...res.meta,
user: res.user
}
} else {
throw new Error('ERR_INVALID_VALIDATION_TOKEN')
}
}
async destroyToken({ token }) {
return WIKI.db.delete(userKeys).where(eq(userKeys.token, token))
}
}
export const users = new Users()