Skip to content

Commit

Permalink
feat(xo-server/REST API): implement authentication tokens route
Browse files Browse the repository at this point in the history
  • Loading branch information
MathieuRA committed Oct 22, 2024
1 parent ac86590 commit fb45340
Show file tree
Hide file tree
Showing 4 changed files with 110 additions and 21 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

> Users must be able to say: “Nice enhancement, I'm eager to test it”
- [REST API] Ability to generate an authentication token via `POST /rest/v0/users/authentication_tokens` (using Basic Authentication)

### Bug fixes

> Users must be able to say: “I had this issue, happy to know it's fixed”
Expand Down
4 changes: 4 additions & 0 deletions packages/xo-server/src/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,10 @@ async function createExpressApp(config) {
// access the username and password from the sign in form.
app.use(bodyParser.urlencoded({ extended: false }))

// Registers the body-parser json middleware, needed to retrieve
// the body of the POST/PUT request in `req.body`
app.use(bodyParser.json())

// Registers Passport's middlewares.
app.use(passport.initialize())

Expand Down
53 changes: 53 additions & 0 deletions packages/xo-server/src/rest-api/middlewares/authentications.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// -----------------------------------------------------------

import { invalidCredentials } from 'xo-common/api-errors.js'

export function authenticateUserFromToken(app) {
return async function (req, res, next) {
const { cookies, ip } = req
const token = cookies.authenticationToken ?? cookies.token
if (token === undefined) {
return next()
}
try {
const { user } = await app.authenticateUser({ token }, { ip })
return app.runWithApiContext(user, next)
} catch (error) {
if (invalidCredentials.is(error)) {
res.sendStatus(401)
} else {
next(error)
}
}
}
}

// -----------------------------------------------------------

export function isAdmin(app) {
return function (req, res, next) {
const permission = app.apiContext?.permission

if (permission === undefined || permission !== 'admin') {
res.sendStatus(401)
return
}

next()
}
}

// -----------------------------------------------------------

export function isUnauthenticated(app) {
return function (req, res, next) {
const user = app.apiContext?.user

if (user !== undefined) {
res.sendStatus(403)
return
}

next()
}
}
72 changes: 51 additions & 21 deletions packages/xo-server/src/xo-mixins/rest-api.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { defer } from 'golike-defer'
import { every } from '@vates/predicates'
import { extractIdsFromSimplePattern } from '@xen-orchestra/backups/extractIdsFromSimplePattern.mjs'
import { ifDef } from '@xen-orchestra/defined'
import { featureUnauthorized, invalidCredentials, noSuchObject } from 'xo-common/api-errors.js'
import { featureUnauthorized, noSuchObject } from 'xo-common/api-errors.js'
import { pipeline } from 'node:stream/promises'
import { json, Router } from 'express'
import { Readable } from 'node:stream'
Expand All @@ -18,6 +18,7 @@ import * as CM from 'complex-matcher'
import { VDI_FORMAT_RAW, VDI_FORMAT_VHD } from '@xen-orchestra/xapi'
import { parse } from 'xo-remote-parser'

import { authenticateUserFromToken, isAdmin, isUnauthenticated } from '../rest-api/middlewares/authentications.mjs'
import {
getFromAsyncCache,
getUserPublicProperties,
Expand Down Expand Up @@ -476,25 +477,7 @@ export default class RestApi {
const api = subRouter(express, '/rest/v0')
this.#api = api

api.use((req, res, next) => {
const { cookies, ip } = req
app.authenticateUser({ token: cookies.authenticationToken ?? cookies.token }, { ip }).then(
({ user }) => {
if (user.permission === 'admin') {
return app.runWithApiContext(user, next)
}

res.sendStatus(401)
},
error => {
if (invalidCredentials.is(error)) {
res.sendStatus(401)
} else {
next(error)
}
}
)
})
api.use(authenticateUserFromToken(app))

const collections = { __proto__: null }

Expand Down Expand Up @@ -815,6 +798,19 @@ export default class RestApi {
return handleArray(await app.getAllUsers(), filter, limit)
},
routes: {
async authentication_token(req, res) {
const { filter, limit } = req.query

const me = app.apiContext.user
const user = req.object
if (me.id !== user.id) {
return res.sendStatus(403)
}

const tokens = await app.getAuthenticationTokensForUser(me.id)

res.json(handleArray(tokens, filter, limit))
},
async groups(req, res) {
const { filter, limit } = req.query
await sendObjects(
Expand Down Expand Up @@ -949,14 +945,43 @@ export default class RestApi {

api.get(
'/',
isAdmin(app),
wrap((req, res) => sendObjects(Object.values(collections), req, res))
)

// For compatibility redirect from /backups* to /backup
api.get('/backups*', (req, res) => {
api.get('/backups*', isAdmin(app), (req, res) => {
res.redirect(308, req.baseUrl + '/backup' + req.params[0])
})

api.get('/users/me*', isAdmin(app), (req, res) => {
const user = app.apiContext.user
res.redirect(308, req.baseUrl + '/users/' + user.id + req.params[0])
})

api.post('/users/authentication_token', isUnauthenticated(app), async (req, res) => {
const authorization = req.headers.authorization ?? ''
const [, encodedCredentials] = authorization.split(' ')
if (encodedCredentials === undefined) {
return res.sendStatus(401)
}

const [username, password] = Buffer.from(encodedCredentials, 'base64').toString().split(':')

try {
const { user } = await app.authenticateUser({ username, password, otp: req.query.otp })
const token = await app.createAuthenticationToken({
client: req.body.client,
userId: user.id,
description: req.body.description,
expiresIn: req.body.expiresIn,
})
res.json({ token })
} catch (error) {
res.status(401).json(error.message)
}
})

const backupTypes = {
__proto__: null,

Expand All @@ -965,6 +990,7 @@ export default class RestApi {
vm: 'backup',
}

api.use('/backup', isAdmin(app))
api
.get(
'/backup',
Expand Down Expand Up @@ -1013,11 +1039,13 @@ export default class RestApi {

api.get(
'/dashboard',
isAdmin(app),
wrap(async (req, res) => {
res.json(await _getDashboardStats(app))
})
)

api.use('/restore', isAdmin(app))
api
.get(
'/restore',
Expand All @@ -1041,6 +1069,7 @@ export default class RestApi {
})
)

api.use('/tasks', isAdmin(app))
api
.delete(
'/tasks',
Expand Down Expand Up @@ -1076,6 +1105,7 @@ export default class RestApi {

api.get(
'/:collection',
isAdmin(app),
wrap(async (req, res) => {
const { collection, query } = req

Expand Down

0 comments on commit fb45340

Please sign in to comment.