Files
xen-orchestra/packages/xo-server/src/xo-mixins/authentication.mjs
T

279 lines
7.7 KiB
JavaScript

import { createLogger } from '@xen-orchestra/log'
import { createPredicate } from 'value-matcher'
import { ignoreErrors } from 'promise-toolbox'
import { invalidCredentials, noSuchObject } from 'xo-common/api-errors.js'
import { parseDuration } from '@vates/parse-duration'
import patch from '../patch.mjs'
import { Tokens } from '../models/token.mjs'
import { forEach, generateToken } from '../utils.mjs'
// ===================================================================
const log = createLogger('xo:authentification')
const noSuchAuthenticationToken = id => noSuchObject(id, 'authenticationToken')
const unserialize = token => {
if (token.created_at !== undefined) {
token.created_at = +token.created_at
}
token.expiration = +token.expiration
}
export default class {
constructor(app) {
app.config.watch('authentication', config => {
this._defaultTokenValidity = parseDuration(config.defaultTokenValidity)
this._maxTokenValidity = parseDuration(config.maxTokenValidity)
this._throttlingDelay = parseDuration(config.throttlingDelay)
})
this._providers = new Set()
this._app = app
// Store last failures by user to throttle tries (slow bruteforce
// attacks).
this._failures = { __proto__: null }
// Password authentication provider.
this.registerAuthenticationProvider(async ({ username, password }, { ip } = {}) => {
if (username === undefined || password === undefined) {
return
}
const user = await app.getUserByName(username, true)
if (user && (await app.checkUserPassword(user.id, password))) {
return { userId: user.id }
}
app.emit('xo:audit', 'signInFailed', {
userId: user?.id,
userName: username,
userIp: ip,
})
})
// Token authentication provider.
this.registerAuthenticationProvider(async ({ token: tokenId }) => {
if (!tokenId) {
return
}
try {
const token = await app.getAuthenticationToken(tokenId)
return { expiration: token.expiration, userId: token.user_id }
} catch (error) {}
})
app.hooks.on('clean', async () => {
const tokensDb = this._tokens
const tokens = await tokensDb.get()
const toRemove = []
const now = Date.now()
forEach(tokens, ({ expiration, id }) => {
if (!expiration || expiration < now) {
toRemove.push(id)
}
})
await tokensDb.remove(toRemove)
return tokensDb.rebuildIndexes()
})
app.hooks.on('core started', () => {
// Creates persistent collections.
const tokensDb = (this._tokens = new Tokens({
connection: app._redis,
namespace: 'token',
indexes: ['user_id'],
}))
app.addConfigManager(
'authTokens',
() => tokensDb.get(),
tokens => tokensDb.update(tokens)
)
})
}
registerAuthenticationProvider(provider) {
return this._providers.add(provider)
}
unregisterAuthenticationProvider(provider) {
return this._providers.delete(provider)
}
async _authenticateUser(credentials, userData) {
for (const provider of this._providers) {
try {
// A provider can return:
// - `undefined`/`null` if the user could not be authenticated
// - an object containing:
// - `userId`
// - optionally `expiration` to indicate when the session is no longer
// valid
const result = await provider(credentials, userData)
// No match.
if (result == null) {
continue
}
const { userId, expiration } = result
return {
user: await this._app.getUser(userId),
expiration,
}
} catch (error) {
// DEPRECATED: Authentication providers may just throw `null`
// to indicate they could not authenticate the user without
// any special errors.
if (error !== null) log.error(error)
}
}
}
async authenticateUser(credentials, userData) {
// don't even attempt to authenticate with empty password
const { password } = credentials
if (password === '') {
throw new Error('empty password')
}
// TODO: remove when email has been replaced by username.
if (credentials.email) {
credentials.username = credentials.email
} else if (credentials.username) {
credentials.email = credentials.username
}
const failures = this._failures
const { username } = credentials
const now = Date.now()
let lastFailure
if (username && (lastFailure = failures[username]) && lastFailure + this._throttlingDelay > now) {
throw new Error('too fast authentication tries')
}
const result = await this._authenticateUser(credentials, userData)
if (result === undefined) {
failures[username] = now
throw invalidCredentials()
}
delete failures[username]
return result
}
// -----------------------------------------------------------------
async createAuthenticationToken({ description, expiresIn, userId }) {
let duration = this._defaultTokenValidity
if (expiresIn !== undefined) {
duration = parseDuration(expiresIn)
if (duration <= 60e3) {
throw new Error('invalid expiresIn duration: ' + expiresIn)
} else if (duration > this._maxTokenValidity) {
throw new Error('too high expiresIn duration: ' + expiresIn)
}
}
const now = Date.now()
const token = {
created_at: now,
description,
id: await generateToken(),
user_id: userId,
expiration: now + duration,
}
await this._tokens.add(token)
return token
}
async deleteAuthenticationToken(id) {
let predicate
const { apiContext } = this._app
if (apiContext === undefined || apiContext.permission === 'admin') {
predicate = id
} else {
predicate = { id, user_id: apiContext.user.id }
}
if (!(await this._tokens.remove(predicate))) {
throw noSuchAuthenticationToken(id)
}
}
async deleteAuthenticationTokens({ filter }) {
let predicate
const { apiContext } = this._app
if (apiContext !== undefined && apiContext.permission !== 'admin') {
predicate = { user_id: apiContext.user.id }
}
const db = this._tokens
return db.remove((await db.get(predicate)).filter(createPredicate(filter)).map(({ id }) => id))
}
async _getAuthenticationToken(id, properties) {
const token = await this._tokens.first(properties ?? id)
if (token !== undefined) {
unserialize(token)
if (token.expiration > Date.now()) {
return token
}
this._tokens.remove(id)::ignoreErrors()
}
}
async getAuthenticationToken(properties) {
const id = typeof properties === 'string' ? properties : properties.id
const token = await this._getAuthenticationToken(id, properties)
if (token === undefined) {
throw noSuchAuthenticationToken(id)
}
return token
}
async getAuthenticationTokensForUser(userId) {
const tokens = []
const now = Date.now()
const tokensDb = this._tokens
const toRemove = []
for (const token of await tokensDb.get({ user_id: userId })) {
unserialize(token)
const { expiration } = token
if (expiration < now) {
toRemove.push(token.id)
} else {
tokens.push(token)
}
}
tokensDb.remove(toRemove).catch(log.warn)
return tokens
}
async isValidAuthenticationToken(id) {
const token = await this._getAuthenticationToken(id)
return token !== undefined && (await this._app.doesUserExist(token.user_id))
}
async updateAuthenticationToken(properties, { description }) {
const token = await this.getAuthenticationToken(properties)
patch(token, { description })
await this._tokens.update(token)
return token
}
}