@flowfuse/flowfuse
Version:
An open source low-code development platform
473 lines (449 loc) • 17.5 kB
JavaScript
const { Op } = require('sequelize')
const { generateToken, generateNumericToken, sha256, randomPhrase } = require('../utils')
const DEFAULT_TOKEN_SESSION_EXPIRY = 1000 * 60 * 30 // 30 mins session - with refresh token support
const DEFAULT_DEVICE_OTC_EXPIRY = 1000 * 60 * 60 * 24 // 24 hours
/*
* fft - project
* ffpr - password reset
* ffd - device
* ffu - user
* ffadp - auto device provisioning
* ffpat - personal access token
* ffhttp - httpNode access token
* fftpb - third party broker
* ffnpm - Team npm registry
*/
module.exports = {
/**
* Create an AccessToken for the given project.
* The token is hashed in the database. The only time the
* true value is available is when it is returned from this function.
*/
createTokenForProject: async function (app, project, expiresAt, scope = []) {
const existingProjectToken = await project.getAccessToken()
if (existingProjectToken) {
await existingProjectToken.destroy()
}
const token = generateToken(32, 'fft')
await app.db.models.AccessToken.create({
token,
expiresAt,
scope,
ownerId: project.id,
ownerType: 'project'
})
return { token }
},
/**
* Create an AccessToken for a user's password reset request
*/
createTokenForPasswordReset: async function (app, user) {
// Ensure any existing tokens are removed first
await app.db.controllers.AccessToken.deleteAllUserPasswordResetTokens(user)
const token = generateToken(32, 'ffpr')
const expiresAt = new Date(Date.now() + (1800 * 1000)) // 30 minutes
await app.db.models.AccessToken.create({
token,
expiresAt,
scope: 'password:reset',
ownerId: user.hashid,
ownerType: 'user'
})
return { token }
},
/**
* Deletes any pending password-change tokens for a user.
*/
deleteAllUserPasswordResetTokens: async function (app, user) {
await app.db.models.AccessToken.destroy({
where: {
ownerType: 'user',
scope: 'password:reset',
ownerId: user.hashid
}
})
},
/**
* Create an AccessToken for a user's email verification
*/
createTokenForEmailVerification: async function (app, user) {
// Ensure any existing tokens are removed first
await app.db.controllers.AccessToken.deleteAllUserEmailVerificationTokens(user)
const token = generateNumericToken()
const expiresAt = new Date(Date.now() + (1000 * 60 * 30)) // 30 minutes
await app.db.models.AccessToken.create({
token,
expiresAt,
scope: 'email:verify',
ownerId: '' + user.id,
ownerType: 'user'
})
return { token }
},
/**
* Deletes any pending email-verification tokens for a user.
*/
deleteAllUserEmailVerificationTokens: async function (app, user) {
await app.db.models.AccessToken.destroy({
where: {
ownerType: 'user',
scope: 'email:verify',
ownerId: '' + user.id
}
})
},
/**
* Create an AccessToken for the given device.
* The token is hashed in the database. The only time the
* true value is available is when it is returned from this function.
*/
createTokenForDevice: async function (app, device) {
const existingDeviceToken = await device.getAccessToken()
if (existingDeviceToken) {
await existingDeviceToken.destroy()
}
const token = generateToken(32, 'ffd')
await app.db.models.AccessToken.create({
token,
expiresAt: null,
scope: 'device',
ownerId: '' + device.id,
ownerType: 'device'
})
return { token }
},
createDeviceOTC: async function (app, device) {
const existing = await app.db.models.AccessToken.findOne({
where: {
ownerId: '' + device.id,
ownerType: 'device',
scope: 'device:otc'
}
})
if (existing) {
await existing.destroy()
}
const otc = randomPhrase(3, 2, 15, '-') // 3 words, min 2 chars, max 15 chars, separated by '-'
const token = Buffer.from(otc).toString('base64')
const data = {
token,
expiresAt: Date.now() + DEFAULT_DEVICE_OTC_EXPIRY,
scope: 'device:otc',
ownerId: '' + device.id,
ownerType: 'device'
}
await app.db.models.AccessToken.create(data)
return { otc }
},
/**
* Create an AccessToken for the editor.
*/
createTokenForUser: async function (app, user, expiresAt, scope = [], includeRefresh) {
const userId = typeof user === 'number' ? user : user.id
const token = generateToken(32, 'ffu')
const refreshToken = includeRefresh ? generateToken(32, 'ffu') : null
if (refreshToken && !expiresAt) {
expiresAt = Date.now() + DEFAULT_TOKEN_SESSION_EXPIRY
}
await app.db.models.AccessToken.create({
token,
refreshToken,
expiresAt,
scope,
ownerId: '' + userId,
ownerType: 'user'
})
return { token, expiresAt, refreshToken }
},
/**
* Create a (restricted) auto device provisioning token (adp) for the given team.
* The token is hashed in the database. The only time the
* true value is available is when it is returned from this function.
* @param {Object} app - The app object
* @param {string} name - A name for this token
* @param {Object|number} team - The team object or id
* @param {'application'|'instance'} [autoAssignType] - The type of auto assign (set to `null` or `undefined` to remove auto assign)
* @param {Object|string} [autoAssignItem] - The auto assign project/application id (Only valid if autoAssignType is set)
* @param {Date|'never'} [expiresAt] - The expiry date. If `undefined`, the token never expires
*/
createTokenForTeamDeviceProvisioning: async function (app, name, team, autoAssignType, autoAssignItem, expiresAt) {
const generatedToken = generateToken(32, 'ffadp')
const scope = ['device:provision', `name:${name}`]
let autoAssignId = null
if (autoAssignItem) {
if (typeof autoAssignItem === 'object') {
autoAssignId = autoAssignItem.id
} else {
autoAssignId = autoAssignItem || null
}
}
const projectId = autoAssignType === 'instance' ? autoAssignId : null
const applicationId = autoAssignType === 'application' ? autoAssignId : null
const teamId = (team && typeof team === 'object') ? team.id : team
if (applicationId) {
scope.push(`application:${applicationId}`)
} else if (projectId) {
scope.push(`project:${projectId}`)
}
const newToken = await app.db.models.AccessToken.create({
token: generatedToken,
expiresAt,
scope, // scope format: ['device:provision', `name:${token name}`, `project:${project id}`]
ownerId: teamId,
ownerType: 'team'
})
const token = await app.db.views.AccessToken.provisioningTokenSummary(newToken)
token.token = generatedToken
return token
},
/**
* Update an auto device provisioning token (adp).
* Only the project and expiry date can be updated.
* @param {Object} app - The app object
* @param {Object} token - The token to update
* @param {'application'|'instance'} [autoAssignType] - The type of auto assign (set to `null` or `undefined` to remove auto assign)
* @param {Object|string} [autoAssignItem] - The auto assign project/application (or id). Set to `null` or `undefined` to remove auto assign
* @param {Date|'never'} [expiresAt] - The expiry date. If `undefined`, the token never expires
*/
updateTokenForTeamDeviceProvisioning: async function (app, token, autoAssignType, autoAssignItem, expiresAt) {
let scope = [...(token.scope || [])]
let autoAssignId = null
if (autoAssignItem) {
if (typeof autoAssignItem === 'object') {
autoAssignId = autoAssignItem.id
} else {
autoAssignId = autoAssignItem || null
}
}
const instanceId = autoAssignType === 'instance' ? autoAssignId : null
const applicationId = autoAssignType === 'application' ? autoAssignId : null
// remove instance/application scope & add updated instance/application scope (if set)
scope = scope.filter((s) => !s.startsWith('project:'))
scope = scope.filter((s) => !s.startsWith('application:'))
if (applicationId) {
scope.push(`application:${applicationId}`)
} else if (instanceId) {
scope.push(`project:${instanceId}`)
}
const tokenUpdates = {
scope, // scope format: ['device:provision', `name:${token name}`, `project:${project id}`]
expiresAt
}
await app.db.models.AccessToken.update(tokenUpdates, { where: { id: token.id } })
return { token: token.id }
},
generatePlatformStatisticsToken: async function (app, user) {
// Clear any existing platform:stats token
await app.db.controllers.AccessToken.removePlatformStatisticsToken()
await app.settings.set('platform:stats:token', true)
return app.db.controllers.AccessToken.createTokenForUser(user, null, ['platform:stats'])
},
removePlatformStatisticsToken: async function (app) {
// This assumes we only have this one path for creating such a token.
// In the future, if we support Personal Access Tokens, it will be
// possible to have multiple tokens with just this scope - so this
// logic will need changing
await app.db.models.AccessToken.destroy({
where: {
scope: 'platform:stats'
}
})
await app.settings.set('platform:stats:token', false)
},
createPersonalAccessToken: async function (app, user, scope, expiresAt, name) {
const userId = typeof user === 'number' ? user : user.id
const token = generateToken(32, 'ffpat')
const tok = await app.db.models.AccessToken.create({
name,
token,
scope,
expiresAt,
ownerId: '' + userId,
ownerType: 'user'
})
// Overwrite the hashed token with the plain value
const result = app.db.views.AccessToken.personalAccessTokenSummary(tok)
result.token = token
return result
},
updatePersonalAccessToken: async function (app, user, tokenId, scope, expiresAt) {
const userId = typeof user === 'number' ? user : user.id
const token = await app.db.models.AccessToken.byId(tokenId, 'user', userId)
if (token) {
token.scope = scope
if (expiresAt === undefined) {
token.expiresAt = null
} else {
token.expiresAt = expiresAt
}
await token.save()
} else {
// should throw unknown token error
throw new Error('Not Found')
}
return token
},
// Should these only get added via forge/ee/lib/httpTokens?
createHTTPNodeToken: async function (app, project, name, scope = [''], expiresAt) {
const projectId = (project && typeof project === 'object') ? project.id : project
const token = generateToken(32, 'ffhttp')
const tok = await app.db.models.AccessToken.create({
token,
expiresAt,
name,
scope,
ownerId: projectId,
ownerType: 'http'
})
// Overwrite the hashed token with the plain value
const result = app.db.views.AccessToken.instanceHTTPTokenSummary(tok)
result.token = token
return result
},
updateHTTPNodeToken: async function (app, project, tokenId, scope = [''], expiresAt) {
const projectId = (project && typeof project === 'object') ? project.id : project
const token = await app.db.models.AccessToken.byId(tokenId, 'http', projectId)
if (token) {
token.scope = scope
if (expiresAt === undefined) {
token.expiresAt = null
} else {
token.expiresAt = expiresAt
}
await token.save()
} else {
// should throw unknown token error
throw new Error('Not Found')
}
return token
},
refreshToken: async function (app, refreshToken) {
const existingToken = await app.db.models.AccessToken.byRefreshToken(refreshToken)
if (existingToken) {
const [prefix] = refreshToken.split('_')
const tokenUpdates = {
token: generateToken(32, prefix),
refreshToken: generateToken(32, prefix),
expiresAt: Date.now() + DEFAULT_TOKEN_SESSION_EXPIRY
}
await app.db.models.AccessToken.update(tokenUpdates, { where: { refreshToken: existingToken.refreshToken } })
return tokenUpdates
}
return null
},
/**
* Get a token by its id. If the session has expired, it is deleted
* and nothing returned.
*/
getOrExpire: async function (app, token) {
let accessToken = await app.db.models.AccessToken.findOne({
where: {
token: sha256(token),
scope: {
[Op.notIn]: ['password:reset', 'email:verify']
}
}
})
if (accessToken) {
if (accessToken.expiresAt && accessToken.expiresAt.getTime() < Date.now()) {
await accessToken.destroy()
accessToken = null
}
}
return accessToken
},
getOrExpirePasswordResetToken: async function (app, token) {
let accessToken = await app.db.models.AccessToken.findOne({
where: {
token: sha256(token),
scope: 'password:reset'
}
})
if (accessToken) {
if (accessToken.expiresAt && accessToken.expiresAt.getTime() < Date.now()) {
await accessToken.destroy()
accessToken = null
}
}
return accessToken
},
getOrExpireEmailVerificationToken: async function (app, user, token) {
let accessToken = await app.db.models.AccessToken.findOne({
where: {
token: sha256(token),
ownerId: '' + user.id,
ownerType: 'user',
scope: 'email:verify'
}
})
if (accessToken) {
if (accessToken.expiresAt && accessToken.expiresAt.getTime() < Date.now()) {
await accessToken.destroy()
accessToken = null
}
}
return accessToken
},
destroyToken: async function (app, token) {
const accessToken = await app.db.models.AccessToken.findOne({
where: {
token: sha256(token)
}
})
if (accessToken) {
await accessToken.destroy()
}
},
createTokenForBroker: async function (app, broker, expiresAt, scope = ['broker:credentials', 'broker:topics']) {
const existingBrokerToken = await app.db.models.AccessToken.findOne({
where: {
ownerId: '' + broker.id,
ownerType: 'broker'
}
})
if (existingBrokerToken) {
await existingBrokerToken.destroy()
}
const token = generateToken(32, 'fftpb')
await app.db.models.AccessToken.create({
token,
expiresAt,
scope,
ownerId: '' + broker.id,
ownerType: 'broker'
})
return { token }
},
createTokenForNPM: async function (app, entity, team, scope = ['team:packages:read']) {
// Adding prefix to the entityId of `p-`, `d-` and `u-` rather than relying on
// no hashid collisions
let ownerId
if (entity instanceof app.db.models.Project) {
ownerId = `p-${entity.id}@${team.hashid}`
} else if (entity instanceof app.db.models.Device) {
ownerId = `d-${entity.hashid}@${team.hashid}`
} else if (entity instanceof app.db.models.User) {
ownerId = entity.username
}
const existingNPMToken = await app.db.models.AccessToken.findOne({
where: {
ownerId,
ownerType: 'npm'
}
})
if (existingNPMToken) {
await existingNPMToken.destroy()
}
const token = generateToken(32, 'ffnpm')
await app.db.models.AccessToken.create({
token,
ownerId,
ownerType: 'npm',
scope
})
return {
username: ownerId,
token
}
}
}