Compare commits

...

3 Commits

Author SHA1 Message Date
Julien Fontanet
7da0146d3e feat(xo-server/token): savelog last use info 2023-10-27 11:37:44 +02:00
Julien Fontanet
f3bbcbde08 feat(xo-cli): only create a single token per instance (and user) 2023-10-27 11:28:39 +02:00
Julien Fontanet
0559fe8649 feat(xo-server/token): client info support 2023-10-27 11:28:39 +02:00
5 changed files with 81 additions and 11 deletions

View File

@@ -53,6 +53,7 @@
- @xen-orchestra/fs patch - @xen-orchestra/fs patch
- @xen-orchestra/mixins minor - @xen-orchestra/mixins minor
- @xen-orchestra/xapi minor - @xen-orchestra/xapi minor
- xo-cli minor
- xo-server minor - xo-server minor
- xo-server-backup-reports minor - xo-server-backup-reports minor
- xo-server-netbox patch - xo-server-netbox patch

View File

@@ -13,6 +13,7 @@ import humanFormat from 'human-format'
import identity from 'lodash/identity.js' import identity from 'lodash/identity.js'
import isObject from 'lodash/isObject.js' import isObject from 'lodash/isObject.js'
import micromatch from 'micromatch' import micromatch from 'micromatch'
import os from 'os'
import pairs from 'lodash/toPairs.js' import pairs from 'lodash/toPairs.js'
import pick from 'lodash/pick.js' import pick from 'lodash/pick.js'
import prettyMs from 'pretty-ms' import prettyMs from 'pretty-ms'
@@ -47,7 +48,7 @@ async function connect() {
return xo return xo
} }
async function parseRegisterArgs(args, tokenDescription, acceptToken = false) { async function parseRegisterArgs(args, tokenDescription, client, acceptToken = false) {
const { const {
allowUnauthorized, allowUnauthorized,
expiresIn, expiresIn,
@@ -84,21 +85,21 @@ async function parseRegisterArgs(args, tokenDescription, acceptToken = false) {
pw(resolve) pw(resolve)
}), }),
] = opts ] = opts
result.token = await _createToken({ ...result, description: tokenDescription, email, password }) result.token = await _createToken({ ...result, client, description: tokenDescription, email, password })
} }
return result return result
} }
async function _createToken({ allowUnauthorized, description, email, expiresIn, password, url }) { async function _createToken({ allowUnauthorized, client, description, email, expiresIn, password, url }) {
const xo = new Xo({ rejectUnauthorized: !allowUnauthorized, url }) const xo = new Xo({ rejectUnauthorized: !allowUnauthorized, url })
await xo.open() await xo.open()
try { try {
await xo.signIn({ email, password }) await xo.signIn({ email, password })
console.warn('Successfully logged with', xo.user.email) console.warn('Successfully logged with', xo.user.email)
return await xo.call('token.create', { description, expiresIn }).catch(error => { return await xo.call('token.create', { client, description, expiresIn }).catch(error => {
// if invalid parameter error, retry without description for backward compatibility // if invalid parameter error, retry without client and description for backward compatibility
if (error.code === 10) { if (error.code === 10) {
return xo.call('token.create', { expiresIn }) return xo.call('token.create', { expiresIn })
} }
@@ -219,6 +220,8 @@ function wrap(val) {
// =================================================================== // ===================================================================
const PACKAGE_JSON = JSON.parse(readFileSync(new URL('package.json', import.meta.url)))
const help = wrap( const help = wrap(
(function (pkg) { (function (pkg) {
return `Usage: return `Usage:
@@ -355,7 +358,7 @@ $name v$version`.replace(/<([^>]+)>|\$(\w+)/g, function (_, arg, key) {
return pkg[key] return pkg[key]
}) })
})(JSON.parse(readFileSync(new URL('package.json', import.meta.url)))) })(PACKAGE_JSON)
) )
// ------------------------------------------------------------------- // -------------------------------------------------------------------
@@ -422,9 +425,18 @@ async function createToken(args) {
COMMANDS.createToken = createToken COMMANDS.createToken = createToken
async function register(args) { async function register(args) {
const opts = await parseRegisterArgs(args, 'xo-cli --register', true) let { clientId } = await config.load()
if (clientId === undefined) {
clientId = Math.random().toString(36).slice(2)
}
const { name, version } = PACKAGE_JSON
const label = `${name}@${version} - ${os.hostname()} - ${os.type()} ${os.machine()}`
const opts = await parseRegisterArgs(args, label, { id: clientId }, true)
await config.set({ await config.set({
allowUnauthorized: opts.allowUnauthorized, allowUnauthorized: opts.allowUnauthorized,
clientId,
server: opts.url, server: opts.url,
token: opts.token, token: opts.token,
}) })

View File

@@ -1,8 +1,9 @@
// TODO: Prevent token connections from creating tokens. // TODO: Prevent token connections from creating tokens.
// TODO: Token permission. // TODO: Token permission.
export async function create({ description, expiresIn }) { export async function create({ client, description, expiresIn }) {
return ( return (
await this.createAuthenticationToken({ await this.createAuthenticationToken({
client,
description, description,
expiresIn, expiresIn,
userId: this.apiContext.user.id, userId: this.apiContext.user.id,
@@ -17,6 +18,15 @@ create.params = {
optional: true, optional: true,
type: 'string', type: 'string',
}, },
client: {
description:
'client this authentication token belongs to, if a previous token exists, it will be updated and returned',
optional: true,
type: 'object',
properties: {
id: { description: 'unique identifier of this client', type: 'string' },
},
},
expiresIn: { expiresIn: {
optional: true, optional: true,
type: ['number', 'string'], type: ['number', 'string'],

View File

@@ -3,7 +3,25 @@ import Collection from '../collection/redis.mjs'
// =================================================================== // ===================================================================
export class Tokens extends Collection { export class Tokens extends Collection {
_serialize(token) {
const { client, lastUse } = token
if (client !== undefined) {
const { id, ...rest } = client
token.client_id = id
token.client = JSON.stringify(rest)
}
}
_unserialize(token) { _unserialize(token) {
const { client, client_id } = token
if (client !== undefined) {
token.client = {
...JSON.parse(client),
id: client_id,
}
delete token.client_id
}
if (token.created_at !== undefined) { if (token.created_at !== undefined) {
token.created_at = +token.created_at token.created_at = +token.created_at
} }

View File

@@ -49,13 +49,23 @@ export default class {
}) })
// Token authentication provider. // Token authentication provider.
this.registerAuthenticationProvider(async ({ token: tokenId }) => { this.registerAuthenticationProvider(async ({ token: tokenId }, { ip } = {}) => {
if (!tokenId) { if (!tokenId) {
return return
} }
try { try {
const token = await app.getAuthenticationToken(tokenId) const token = await app.getAuthenticationToken(tokenId)
this._tokens.update({
...token,
lastUse: {
ip,
timestamp: Date.now(),
},
})
return { expiration: token.expiration, userId: token.user_id } return { expiration: token.expiration, userId: token.user_id }
} catch (error) {} } catch (error) {}
}) })
@@ -79,7 +89,7 @@ export default class {
const tokensDb = (this._tokens = new Tokens({ const tokensDb = (this._tokens = new Tokens({
connection: app._redis, connection: app._redis,
namespace: 'token', namespace: 'token',
indexes: ['user_id'], indexes: ['client_id', 'user_id'],
})) }))
app.addConfigManager( app.addConfigManager(
@@ -180,7 +190,7 @@ export default class {
// ----------------------------------------------------------------- // -----------------------------------------------------------------
async createAuthenticationToken({ description, expiresIn, userId }) { async createAuthenticationToken({ client, description, expiresIn, userId }) {
let duration = this._defaultTokenValidity let duration = this._defaultTokenValidity
if (expiresIn !== undefined) { if (expiresIn !== undefined) {
duration = parseDuration(expiresIn) duration = parseDuration(expiresIn)
@@ -191,8 +201,27 @@ export default class {
} }
} }
const tokens = this._tokens
const now = Date.now() const now = Date.now()
const clientId = client?.id
if (clientId !== undefined) {
const token = await tokens.first({ client_id: clientId, user_id: userId })
if (token !== undefined) {
if (token.expiration > now) {
token.description = description
token.expiration = now + duration
tokens.update(token)::ignoreErrors()
return token
}
tokens.remove(token.id)::ignoreErrors()
}
}
const token = { const token = {
client,
created_at: now, created_at: now,
description, description,
id: await generateToken(), id: await generateToken(),