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/api/authentication.ts

1141 lines
36 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import { nanoid } from 'nanoid'
import { limitAuthAttempts } from '../helpers/rateLimit.ts'
import type { FastifyInstance, FastifyRequest } from 'fastify'
/**
* How long a redirect login may take before its callback is refused.
*
* Long enough for somebody to be asked for a password and a second factor at the provider, short
* enough that a `state` left lying around in a URL somewhere is no longer worth anything.
*/
const AUTH_FLOW_MINUTES = 15
/**
* Where a provider sends the browser back, as an absolute URL.
*
* Built from the request rather than stored, so an instance reachable on more than one hostname keeps
* working — but it has to match what the administrator registered with the provider, which is why the
* admin area shows this exact shape on the strategy's page.
*/
function callbackUrl(req: FastifyRequest, strategyId: string): string {
return `${req.protocol}://${req.host}/_api/auth/${strategyId}/callback`
}
/**
* The login screen, carrying what went wrong.
*
* A redirect login fails at the provider or on the way back, where there is no request left to answer
* with an error — so the browser is sent to the login screen with a code it can put in front of the
* user, and `redirect` is preserved so that a successful second attempt still lands where the first
* one was going.
*/
function loginErrorUrl(redirect: string, code: string): string {
const params = new URLSearchParams({ error: code })
if (redirect && redirect !== '/') {
params.set('redirect', redirect)
}
return `/login?${params.toString()}`
}
/**
* Authentication API Routes
*/
async function routes(app: FastifyInstance) {
/**
* GET SITE AUTHENTICATION STRATEGIES
*/
app.get<{ Params: { siteId: string }; Querystring: { visibleOnly?: boolean } }>(
'/sites/:siteId/auth/strategies',
{
config: {
publicAccess: true
},
schema: {
summary: 'List all site authentication strategies',
description:
'Ordered by the position configured for the site. `activeStrategy` holds the per-instance settings, nested under it `strategy` holds the module definition.',
tags: ['Authentication'],
params: {
type: 'object',
properties: {
siteId: {
type: 'string',
format: 'uuid'
}
},
required: ['siteId']
},
querystring: {
type: 'object',
properties: {
visibleOnly: {
type: 'boolean',
default: false
}
}
},
response: {
200: {
description: 'List of site authentication strategies',
type: 'array',
items: {
type: 'object',
properties: {
id: {
type: 'string',
format: 'uuid'
},
order: {
type: 'integer'
},
isVisible: {
type: 'boolean'
},
activeStrategy: {
type: 'object',
properties: {
displayName: {
type: 'string'
},
registration: {
type: 'boolean'
},
allowForgotPassword: {
type: 'boolean',
description:
'Whether this strategy offers a password reset from the login screen. False for a strategy whose module has no such setting.'
},
strategy: {
type: 'object',
properties: {
key: {
type: 'string'
},
title: {
type: 'string'
},
icon: {
type: 'string'
},
color: {
type: 'string'
},
useForm: {
type: 'boolean'
},
usernameType: {
type: 'string'
}
}
}
}
}
}
}
}
}
}
},
async (req, reply) => {
const site = await WIKI.models.sites.getSiteById({ id: req.params.siteId })
if (!site) {
return reply.badRequest('Invalid Site ID')
}
/*
`getActiveStrategies` rather than the raw rows: it completes each config from the module's
declared defaults, so a prop added to a module after a strategy was configured reads as its
default here instead of as a missing key.
*/
const activeStrategies = (await WIKI.models.authentication.getActiveStrategies()).filter(
(str: any) => str.isEnabled
)
// -> A site created before it had strategies configured has no list at all
const configuredStrategies = site.config.authStrategies ?? []
const siteStrategies = activeStrategies
.map((str: any) => {
const authModule = WIKI.data.authentication.find((m: any) => m.key === str.module)
const siteStr = configuredStrategies.find((s: any) => s.id === str.id) || {}
return {
id: str.id,
order: siteStr.order ?? 0,
isVisible: siteStr.isVisible ?? false,
activeStrategy: {
displayName: str.displayName,
registration: str.registration,
/*
Named explicitly, like every other field here: this endpoint is public and a strategy's
config is where an OAuth client secret lives, so nothing may reach it by spreading.
A module that declares no such prop reads as false, which is correct rather than a
default -- a strategy with no password of its own has no password to reset.
*/
allowForgotPassword: str.config?.allowForgotPassword === true,
strategy: {
key: authModule?.key ?? str.module,
title: authModule?.title ?? str.module,
icon: authModule?.icon ?? '',
color: authModule?.color ?? 'primary',
useForm: authModule?.useForm ?? false,
usernameType: authModule?.usernameType ?? 'email'
}
}
}
})
.sort((a: any, b: any) => a.order - b.order)
return req.query.visibleOnly ? siteStrategies.filter((s: any) => s.isVisible) : siteStrategies
}
)
/**
* LOGIN USING USER/PASS
*/
app.put<{
Params: { siteId: string }
Body: { strategyId: string; username?: string; password?: string }
}>(
'/sites/:siteId/auth/login',
{
config: {
publicAccess: true
},
// -> Guessing is what this endpoint is attacked with; see `helpers/rateLimit.ts`
onRequest: limitAuthAttempts,
schema: {
summary: 'Login',
tags: ['Authentication'],
params: {
type: 'object',
properties: {
siteId: {
type: 'string',
format: 'uuid'
}
}
},
body: {
type: 'object',
required: ['strategyId'],
properties: {
strategyId: {
type: 'string',
format: 'uuid'
},
username: {
type: 'string',
minLength: 1,
maxLength: 255
},
password: {
type: 'string',
minLength: 1,
maxLength: 255
}
}
},
response: {
200: { $ref: 'AuthLoginResult#' }
}
}
},
async (req, reply) => {
try {
const result = await WIKI.models.users.login(
{
siteId: req.params.siteId,
strategyId: req.body.strategyId,
username: req.body.username,
password: req.body.password,
ip: req.ip
},
req
)
if (!result) {
throw new Error('Unexpected empty login response.')
}
return {
ok: true,
...result
}
} catch (err: any) {
if (err.message.startsWith('ERR_')) {
return reply.badRequest(err.message)
} else {
// -> An unexpected failure, reported to the client as a generic one. The detail is behind
// the authDebug flag rather than logged on every failed login.
WIKI.logger.debug(err)
WIKI.models.flags.authDebug(`Login failed unexpectedly: ${err.message}`)
return reply.badRequest('ERR_LOGIN_FAILED')
}
}
}
)
/**
* CHANGE PASSWORD
*/
app.put<{
Params: { siteId: string }
Body: { strategyId: string; continuationToken: string; newPassword: string }
}>(
'/sites/:siteId/auth/changePassword',
{
config: {
publicAccess: true
},
// -> Guessing is what this endpoint is attacked with; see `helpers/rateLimit.ts`
onRequest: limitAuthAttempts,
schema: {
summary: 'Change Password From Login',
tags: ['Authentication'],
params: {
type: 'object',
properties: {
siteId: {
type: 'string',
format: 'uuid'
}
}
},
body: {
type: 'object',
required: ['strategyId', 'continuationToken', 'newPassword'],
properties: {
strategyId: {
type: 'string',
format: 'uuid'
},
continuationToken: {
type: 'string',
minLength: 1,
maxLength: 255
},
newPassword: {
type: 'string',
minLength: 1,
maxLength: 255
}
}
},
response: {
200: { $ref: 'AuthLoginResult#' }
}
}
},
async (req, reply) => {
try {
const result = await WIKI.models.users.loginChangePassword(
{
siteId: req.params.siteId,
strategyId: req.body.strategyId,
continuationToken: req.body.continuationToken,
newPassword: req.body.newPassword,
ip: req.ip
},
req
)
if (!result) {
throw new Error('Unexpected empty change password response.')
}
if (result?.authenticated) {
req.session.authenticated = true
}
return {
ok: true,
...result
}
} catch (err: any) {
if (err.message.startsWith('ERR_')) {
WIKI.models.flags.authDebug(`Password change from login rejected: ${err.message}`)
return reply.badRequest(err.message)
} else {
WIKI.logger.debug(err)
WIKI.models.flags.authDebug(`Password change from login failed: ${err.message}`)
return reply.badRequest('ERR_CHANGE_PASSWORD_FAILED')
}
}
}
)
/**
* SUBMIT A 2FA CODE
*
* The other half of a login that answered `provideTfa` or `setupTfa`: the continuation token stands
* for the login that got that far, and the code proves the second factor. With `setup`, a correct
* code also activates the secret the login generated, which is how an account that is required to
* use 2FA gets it configured.
*/
app.put<{
Params: { siteId: string }
Body: {
strategyId: string
continuationToken: string
securityCode: string
setup?: boolean
}
}>(
'/sites/:siteId/auth/tfa',
{
config: {
publicAccess: true
},
// -> Guessing is what this endpoint is attacked with; see `helpers/rateLimit.ts`
onRequest: limitAuthAttempts,
schema: {
summary: 'Submit a 2FA Security Code From Login',
description:
'Answers like the login route does, since the same checks continue afterwards: a user who also owes a password change is asked for one next. A wrong code can be retried a few times before the continuation token is discarded and the login has to be started again.',
tags: ['Authentication'],
params: {
type: 'object',
properties: {
siteId: {
type: 'string',
format: 'uuid'
}
},
required: ['siteId']
},
body: {
type: 'object',
required: ['strategyId', 'continuationToken', 'securityCode'],
properties: {
strategyId: {
type: 'string',
format: 'uuid'
},
continuationToken: {
type: 'string',
minLength: 1,
maxLength: 255
},
securityCode: {
type: 'string',
pattern: '^[0-9]{6}$',
description: 'The six digits shown by the authenticator app.'
},
setup: {
type: 'boolean',
default: false,
description:
'True when answering a `setupTfa` login, i.e. the code confirms a secret that was just generated.'
}
}
},
response: {
200: { $ref: 'AuthLoginResult#' }
}
}
},
async (req, reply) => {
try {
const result = await WIKI.models.users.loginTFA(
{
siteId: req.params.siteId,
strategyId: req.body.strategyId,
continuationToken: req.body.continuationToken,
securityCode: req.body.securityCode,
setup: req.body.setup ?? false,
ip: req.ip
},
req
)
return {
ok: true,
...result
}
} catch (err: any) {
if (err.message.startsWith('ERR_')) {
WIKI.models.flags.authDebug(`2FA verification rejected: ${err.message}`)
return reply.badRequest(err.message)
} else {
WIKI.logger.debug(err)
WIKI.models.flags.authDebug(`2FA verification failed unexpectedly: ${err.message}`)
return reply.badRequest('ERR_TFA_FAILED')
}
}
}
)
/**
* REQUEST A PASSKEY CHALLENGE
*
* Takes no identity: a passkey says which account it belongs to, so there is nobody to name until the
* assertion comes back. The challenge is remembered on the session.
*/
app.post<{ Params: { siteId: string } }>(
'/sites/:siteId/auth/passkey/challenge',
{
config: {
publicAccess: true
},
// -> Guessing is what this endpoint is attacked with; see `helpers/rateLimit.ts`
onRequest: limitAuthAttempts,
schema: {
summary: 'Get the options for logging in with a passkey',
description:
"Pass the result to the browser's WebAuthn API, then send what the authenticator produces to `PUT /sites/:siteId/auth/passkey/login`. No credential list is sent and no user is named: passkeys are registered as discoverable credentials, so the authenticator offers whichever ones it holds for this hostname and the assertion identifies the account.",
tags: ['Authentication'],
params: {
type: 'object',
properties: {
siteId: {
type: 'string',
format: 'uuid'
}
},
required: ['siteId']
},
response: {
200: {
description: 'Passkey challenge generated',
type: 'object',
properties: {
ok: { type: 'boolean' },
authOptions: {
type: 'object',
additionalProperties: true,
description: 'A WebAuthn `PublicKeyCredentialRequestOptions`, JSON-encoded.'
}
}
}
}
}
},
async (req, reply) => {
try {
const { authOptions, pending } = await WIKI.models.passkeys.startLogin({
hostname: req.hostname,
origin: req.headers.origin
})
req.session.passkeyLogin = pending
return {
ok: true,
authOptions
}
} catch (err: any) {
if (err.message.startsWith('ERR_')) {
return reply.badRequest(err.message)
} else {
WIKI.logger.debug(err)
return reply.badRequest('ERR_LOGIN_FAILED')
}
}
}
)
/**
* LOGIN USING A PASSKEY
*/
app.put<{ Params: { siteId: string }; Body: { authResponse: Record<string, any> } }>(
'/sites/:siteId/auth/passkey/login',
{
config: {
publicAccess: true
},
// -> Guessing is what this endpoint is attacked with; see `helpers/rateLimit.ts`
onRequest: limitAuthAttempts,
schema: {
summary: 'Login With a Passkey',
description:
'Verifies what the authenticator signed and, if it holds up, logs the user in. A passkey establishes both identity and presence, so no password or 2FA code is asked for on top of it.',
tags: ['Authentication'],
params: {
type: 'object',
properties: {
siteId: {
type: 'string',
format: 'uuid'
}
},
required: ['siteId']
},
body: {
type: 'object',
required: ['authResponse'],
properties: {
authResponse: {
type: 'object',
additionalProperties: true,
description: "The browser's WebAuthn authentication response, JSON-encoded."
}
}
},
response: {
200: { $ref: 'AuthLoginResult#' }
}
}
},
async (req, reply) => {
try {
const result = await WIKI.models.passkeys.verifyLogin(
{
authResponse: req.body.authResponse as any,
pending: req.session.passkeyLogin,
ip: req.ip
},
req
)
return {
ok: true,
...result
}
} catch (err: any) {
if (err.message.startsWith('ERR_')) {
return reply.badRequest(err.message)
} else {
WIKI.logger.debug(err)
WIKI.models.flags.authDebug(`Passkey login failed unexpectedly: ${err.message}`)
return reply.badRequest('ERR_LOGIN_FAILED')
}
} finally {
// -> Spent either way: a rejected assertion does not get a second go at the same challenge
req.session.passkeyLogin = undefined
}
}
)
/**
* START A REDIRECT LOGIN
*/
app.get<{
Params: { strategyId: string }
Querystring: { siteId?: string; redirect?: string }
}>(
'/auth/:strategyId/authorize',
{
config: {
publicAccess: true
},
schema: {
summary: 'Start a login at an identity provider',
description:
'Answers with a redirect to the provider, for a strategy whose module signs users in there rather than through a form — OpenID Connect, Google, GitHub. The `state`, `nonce` and PKCE verifier that tie the answer back to this browser are generated here and kept on the session; the browser is never trusted with any of them.\n\nOpened by following the link, not by fetching it: what comes back is a page at the provider.',
tags: ['Authentication'],
params: {
type: 'object',
properties: {
strategyId: { type: 'string', format: 'uuid' }
},
required: ['strategyId']
},
querystring: {
type: 'object',
properties: {
siteId: { type: 'string', format: 'uuid' },
redirect: {
type: 'string',
maxLength: 255,
description:
'Where to send the user once they are logged in. A path on this wiki; anything else is ignored.'
}
}
},
response: {
302: { description: 'Redirect to the identity provider', type: 'null' }
}
}
},
async (req, reply) => {
const strategy = await WIKI.models.authentication.getStrategyById(req.params.strategyId)
const instance = WIKI.auth.strategies[req.params.strategyId] as any
if (!strategy?.isEnabled || typeof instance?.authorizationUrl !== 'function') {
return reply.notFound('There is no such login provider.')
}
const siteId = req.query.siteId ?? WIKI.sitesMappings[req.hostname] ?? ''
const flow = {
strategyId: strategy.id,
siteId,
state: nanoid(32),
nonce: nanoid(32),
codeVerifier: nanoid(64),
// -> Only a path on this wiki: an open redirect is how a login page is turned into a lure
redirect: (req.query.redirect ?? '').startsWith('/') ? req.query.redirect! : '/',
startedAt: Temporal.Now.instant().toString({ smallestUnit: 'millisecond' })
}
req.session.authFlow = flow
try {
const url = await instance.authorizationUrl({
redirectUri: callbackUrl(req, strategy.id),
state: flow.state,
nonce: flow.nonce,
codeVerifier: flow.codeVerifier
})
WIKI.models.flags.authDebug(
`Redirecting to ${strategy.module} provider for strategy ${strategy.id} from ${req.ip}`
)
return reply.redirect(url)
} catch (err: any) {
WIKI.logger.warn(`Could not start a login at ${strategy.module}: ${err.message}`)
return reply.redirect(loginErrorUrl(flow.redirect, err.message))
}
}
)
/**
* FINISH A REDIRECT LOGIN
*/
app.get<{
Params: { strategyId: string }
Querystring: { code?: string; state?: string; error?: string; error_description?: string }
}>(
'/auth/:strategyId/callback',
{
config: {
publicAccess: true
},
// -> A callback is a password check by another name: whatever it carries decides who is logged in
onRequest: limitAuthAttempts,
schema: {
summary: 'Finish a login at an identity provider',
description:
"Where the provider sends the browser back. The answer is only accepted if it matches the flow this session started — same strategy, same `state`, and within the time a login takes — after which the module turns the code into an account and the session is established. Ends in a redirect either way: to where the login was heading, or to the login screen carrying an error code.\n\nThis is the URL an administrator registers with the provider; it is shown on the strategy's own page in the admin area.",
tags: ['Authentication'],
params: {
type: 'object',
properties: {
strategyId: { type: 'string', format: 'uuid' }
},
required: ['strategyId']
},
response: {
302: { description: 'Redirect back into the wiki', type: 'null' }
}
}
},
async (req, reply) => {
const flow = req.session.authFlow
const redirect = flow?.redirect ?? '/'
/*
Everything about the answer is checked against the flow this session started. A callback that
arrives with no flow behind it, for another strategy, with a different `state`, or long after
the login began is not this session's login — and is refused without the code being spent.
*/
if (
!flow ||
flow.strategyId !== req.params.strategyId ||
!req.query.state ||
req.query.state !== flow.state ||
Temporal.Instant.compare(
Temporal.Instant.from(flow.startedAt).add({ minutes: AUTH_FLOW_MINUTES }),
Temporal.Now.instant()
) < 0
) {
WIKI.models.flags.authDebug(
`Callback for strategy ${req.params.strategyId} from ${req.ip} did not match this session's login`
)
req.session.authFlow = undefined
return reply.redirect(loginErrorUrl(redirect, 'ERR_LOGIN_EXPIRED'))
}
// -> Spent, whatever happens next: one callback per login
req.session.authFlow = undefined
if (req.query.error) {
WIKI.models.flags.authDebug(
`Provider refused the login for strategy ${flow.strategyId}: ${req.query.error} ${req.query.error_description ?? ''}`
)
return reply.redirect(loginErrorUrl(redirect, 'ERR_LOGIN_FAILED'))
}
const strategy = await WIKI.models.authentication.getStrategyById(flow.strategyId)
const instance = WIKI.auth.strategies[flow.strategyId] as any
if (!strategy?.isEnabled || typeof instance?.profile !== 'function') {
return reply.redirect(loginErrorUrl(redirect, 'ERR_LOGIN_FAILED'))
}
try {
const profile = await instance.profile({
redirectUri: callbackUrl(req, strategy.id),
state: flow.state,
nonce: flow.nonce,
codeVerifier: flow.codeVerifier,
currentUrl: `${callbackUrl(req, strategy.id)}?${new URLSearchParams(req.query as Record<string, string>).toString()}`,
code: req.query.code
})
const result = await WIKI.models.users.loginWithProvider(
{ siteId: flow.siteId, strategy, profile, ip: req.ip },
req
)
return reply.redirect(result.redirect || redirect)
} catch (err: any) {
WIKI.models.flags.authDebug(
`Login through ${strategy.module} strategy ${strategy.id} failed: ${err.message}`
)
return reply.redirect(loginErrorUrl(redirect, err.message))
}
}
)
/**
* LOGOUT
*/
app.post<{ Params: { siteId: string } }>(
'/sites/:siteId/auth/logout',
{
config: {
publicAccess: true
},
schema: {
summary: 'Logout',
description:
"Destroys the current session and answers with where to send the user next: the first of the user's groups that sets a logout redirect, otherwise the site's own setting, otherwise the site root. A request that was not logged in gets the same answer rather than an error, so that a client acting on a session the server has already forgotten still ends up somewhere sensible.",
tags: ['Authentication'],
params: {
type: 'object',
properties: {
siteId: {
type: 'string',
format: 'uuid'
}
},
required: ['siteId']
},
response: {
200: {
description: 'Logged out successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
redirect: {
type: 'string',
description: 'A path within this wiki, or an absolute URL if one is configured.'
}
}
}
}
}
},
async (req, reply) => {
const user = req.session?.authenticated ? req.session.user : null
// -> Resolved before the session goes away, since it depends on who was logged in
const redirect = await WIKI.models.users.getLogoutRedirect(
user?.id ?? null,
req.params.siteId
)
if (req.session) {
// -> Drops the stored session, so the cookie the browser still holds refers to nothing
await req.session.destroy()
}
// -> And clear that cookie too: `destroy()` detaches the session, which leaves the plugin's own
// save hook with nothing to do. Name and options match the registration in `index.ts`.
reply.clearCookie('wikiSession')
if (user) {
WIKI.models.flags.authDebug(
`User ${user.id} <${user.email}> logged out, redirecting to ${redirect}`
)
await WIKI.models.hooks.emit('user:logout', {
userId: user.id,
ip: req.ip,
metadata: {
name: user.name,
email: user.email
}
})
}
return {
ok: true,
redirect
}
}
)
/**
* LIST AUTHENTICATION MODULES
*/
app.get(
'/authentication/modules',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'List the authentication modules available on this server',
description:
'Read from `modules/authentication` at startup, so installing a module means dropping it on disk and restarting. Modules that declare themselves unavailable are not listed.',
tags: ['Authentication'],
response: {
200: {
description: 'List of authentication modules',
type: 'array',
items: { $ref: 'AuthModule#' }
}
}
}
},
async () => {
return WIKI.models.authentication.getModules()
}
)
/**
* LIST CONFIGURED STRATEGIES
*/
app.get(
'/authentication/strategies',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'List the configured authentication strategies',
description:
'Instance-wide, i.e. every strategy regardless of which sites offer it. Which of them a given site shows on its login screen, and in what order, is part of that sites configuration. Configuration values include any secrets a module stores, hence the `manage:system` requirement.',
tags: ['Authentication'],
response: {
200: {
description: 'List of configured strategies',
type: 'array',
items: { $ref: 'AuthStrategy#' }
}
}
}
},
async () => {
return WIKI.models.authentication.getActiveStrategies()
}
)
/**
* GET CONFIGURED STRATEGY
*/
app.get<{ Params: { strategyId: string } }>(
'/authentication/strategies/:strategyId',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Get a single configured authentication strategy',
tags: ['Authentication'],
params: {
type: 'object',
properties: {
strategyId: {
type: 'string',
format: 'uuid'
}
},
required: ['strategyId']
},
response: {
200: { $ref: 'AuthStrategy#' }
}
}
},
async (req, reply) => {
const strategy = await WIKI.models.authentication.getStrategyById(req.params.strategyId)
if (!strategy) {
return reply.notFound('Authentication strategy does not exist.')
}
return strategy
}
)
/**
* CREATE STRATEGY
*/
app.post<{ Body: Record<string, any> }>(
'/authentication/strategies',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Configure a new authentication strategy',
description:
'A module can be configured more than once, so that two instances of the same provider can coexist. A new strategy is not offered by any site until that site adds it to its login screen.',
tags: ['Authentication'],
body: {
allOf: [{ $ref: 'AuthStrategyInput#' }, { type: 'object', required: ['module'] }]
},
response: {
200: {
description: 'Strategy created successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
},
id: {
type: 'string',
format: 'uuid'
}
}
}
}
}
},
async (req, reply) => {
const mod = WIKI.models.authentication.getModule(req.body.module)
if (!mod) {
return reply.badRequest(`There is no authentication module named "${req.body.module}".`)
}
const invalid =
(await WIKI.models.authentication.validateStrategy({
module: req.body.module,
displayName: req.body.displayName,
isEnabled: req.body.isEnabled,
allowedEmailRegex: req.body.allowedEmailRegex,
autoEnrollGroups: req.body.autoEnrollGroups
})) ?? WIKI.models.authentication.validateConfig(req.body.module, req.body.config)
if (invalid) {
return reply.badRequest(invalid)
}
const id = await WIKI.models.authentication.createStrategy(req.body as any)
return {
ok: true,
message: 'Authentication strategy created successfully.',
id
}
}
)
/**
* UPDATE STRATEGY
*/
app.put<{ Params: { strategyId: string }; Body: Record<string, any> }>(
'/authentication/strategies/:strategyId',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Update an authentication strategy',
description:
'Accepts any subset of the fields, except `module`, which is fixed once a strategy exists. The strategies are reloaded on success, so a configuration change applies to the next login rather than after a restart.',
tags: ['Authentication'],
params: {
type: 'object',
properties: {
strategyId: {
type: 'string',
format: 'uuid'
}
},
required: ['strategyId']
},
body: { $ref: 'AuthStrategyInput#' },
response: {
200: {
description: 'Strategy updated successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
}
}
}
}
}
},
async (req, reply) => {
const current = await WIKI.models.authentication.getStrategyById(req.params.strategyId)
if (!current) {
return reply.notFound('Authentication strategy does not exist.')
}
if (req.body.module !== undefined && req.body.module !== current.module) {
return reply.badRequest('The module of an existing strategy cannot be changed.')
}
const patch: Record<string, any> = {}
for (const field of [
'displayName',
'isEnabled',
'registration',
'allowedEmailRegex',
'autoEnrollGroups',
'config'
] as const) {
if (req.body[field] !== undefined) {
patch[field] = req.body[field]
}
}
if (Object.keys(patch).length < 1) {
return reply.badRequest('No strategy fields provided to update.')
}
const invalid =
(await WIKI.models.authentication.validateStrategy({
id: current.id,
module: current.module,
...patch
})) ?? WIKI.models.authentication.validateConfig(current.module, patch.config)
if (invalid) {
return reply.badRequest(invalid)
}
if (!(await WIKI.models.authentication.updateStrategy(req.params.strategyId, patch))) {
return reply.internalServerError('Failed to update the authentication strategy.')
}
return {
ok: true,
message: 'Authentication strategy updated successfully.'
}
}
)
/**
* DELETE STRATEGY
*/
app.delete<{ Params: { strategyId: string } }>(
'/authentication/strategies/:strategyId',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Delete an authentication strategy',
description:
'Also removes it from every sites login screen. The built-in local strategy cannot be deleted: every account stores its password under that strategy ID, so removing it would leave no way in.',
tags: ['Authentication'],
params: {
type: 'object',
properties: {
strategyId: {
type: 'string',
format: 'uuid'
}
},
required: ['strategyId']
},
response: {
204: {
description: 'Strategy deleted successfully'
}
}
}
},
async (req, reply) => {
const strategy = await WIKI.models.authentication.getStrategyById(req.params.strategyId)
if (!strategy) {
return reply.notFound('Authentication strategy does not exist.')
}
if (strategy.id === WIKI.data.systemIds.localAuthId) {
return reply.conflict('The built-in local strategy cannot be deleted.')
}
await WIKI.models.authentication.deleteStrategy(req.params.strategyId)
return reply.code(204).send()
}
)
}
export default routes