Files
speckle-server/packages/server/modules/core/services/tokens.ts
T
Kristaps Fabians Geikins 37d51072fb feat(server): resource limits on app tokens (#1959)
* WIP new mutation arg

* limited resource token creation done

* token resource rule creation validation

* updated authorizeResolver implementation

* introduced resource access rule checks in authorizeResolver everywhere

* more checks added

* updated projects resolvers

* updated stream resolvers

* more checks added

* error page theme resolution fix

* WIP testss

* more tests

* implemented checks in REST auth pipeline

* REST API coverage & tests

* some tests fixed

* test fixess

* added tests

* feat(server): new automation result reporting scope (#1976)

* feat(server): new automation result reporting scope

* tests fix
2024-01-19 18:14:49 +01:00

221 lines
5.7 KiB
TypeScript

import bcrypt from 'bcrypt'
import crs from 'crypto-random-string'
import knex from '@/db/knex'
import {
ServerAcl,
ApiTokens,
PersonalApiTokens,
TokenScopes,
UserServerAppTokens,
TokenResourceAccess
} from '@/modules/core/dbSchema'
import {
TokenResourceAccessRecord,
TokenValidationResult
} from '@/modules/core/helpers/types'
import { getTokenAppInfo } from '@/modules/core/repositories/tokens'
import { Optional, ServerRoles } from '@speckle/shared'
import { TokenResourceIdentifierInput } from '@/modules/core/graph/generated/graphql'
/*
Tokens
Note: tokens are composed of a 10 char token id and a 32 char token string.
The token string is smoked, salted and hashed and stored in the database.
*/
export async function createBareToken() {
const tokenId = crs({ length: 10 })
const tokenString = crs({ length: 32 })
const tokenHash = await bcrypt.hash(tokenString, 10)
const lastChars = tokenString.slice(tokenString.length - 6, tokenString.length)
return { tokenId, tokenString, tokenHash, lastChars }
}
export async function createToken({
userId,
name,
scopes,
lifespan,
limitResources
}: {
userId: string
name: string
scopes: string[]
lifespan?: number | bigint
/**
* Optionally limit the resources that the token can access
*/
limitResources?: TokenResourceIdentifierInput[] | null
}) {
const { tokenId, tokenString, tokenHash, lastChars } = await createBareToken()
if (scopes.length === 0) throw new Error('No scopes provided')
const token = {
id: tokenId,
tokenDigest: tokenHash,
lastChars,
owner: userId,
name,
lifespan
}
const tokenScopes = scopes.map((scope) => ({ tokenId, scopeName: scope }))
const resourceAccessEntries: Optional<TokenResourceAccessRecord[]> =
limitResources?.map((resource) => ({
tokenId,
resourceId: resource.id,
resourceType: resource.type
}))
await ApiTokens.knex().insert(token)
await Promise.all([
TokenScopes.knex().insert(tokenScopes),
...(resourceAccessEntries?.length
? [TokenResourceAccess.knex().insert(resourceAccessEntries)]
: [])
])
return { id: tokenId, token: tokenId + tokenString }
}
export async function createAppToken(
params: Parameters<typeof createToken>[0] & { appId: string }
) {
const token = await createToken(params)
await UserServerAppTokens.knex().insert({
tokenId: token.token.slice(0, 10),
userId: params.userId,
appId: params.appId
})
return token.token
}
// Creates a personal access token for a user with a set of given scopes.
export async function createPersonalAccessToken(
userId: string,
name: string,
scopes: string[],
lifespan?: number | bigint
) {
const { id, token } = await createToken({
userId,
name,
scopes,
lifespan
})
// Store the relationship
await PersonalApiTokens.knex().insert({ userId, tokenId: id })
return token
}
export async function validateToken(
tokenString: string
): Promise<TokenValidationResult> {
const tokenId = tokenString.slice(0, 10)
const tokenContent = tokenString.slice(10, 42)
const token = await ApiTokens.knex().where({ id: tokenId }).select('*').first()
if (!token) {
return { valid: false }
}
const timeDiff = Math.abs(Date.now() - new Date(token.createdAt).getTime())
if (timeDiff > token.lifespan) {
await revokeToken(tokenId, token.owner)
return { valid: false }
}
const valid = await bcrypt.compare(tokenContent, token.tokenDigest)
if (valid) {
const [scopes, acl, app, resourceAccessRules] = await Promise.all([
TokenScopes.knex()
.select<{ scopeName: string }[]>('scopeName')
.where({ tokenId }),
ServerAcl.knex()
.select<{ role: ServerRoles }[]>('role')
.where({ userId: token.owner })
.first(),
getTokenAppInfo({ token: tokenString }),
TokenResourceAccess.knex<TokenResourceAccessRecord[]>().where({
[TokenResourceAccess.col.tokenId]: tokenId
}),
ApiTokens.knex().where({ id: tokenId }).update({ lastUsed: knex.fn.now() })
])
const role = acl!.role
return {
valid: true,
userId: token.owner,
role,
scopes: scopes.map((s) => s.scopeName),
appId: app?.id || null,
resourceAccessRules: resourceAccessRules.length ? resourceAccessRules : null
}
} else return { valid: false }
}
export async function revokeToken(tokenId: string, userId: string) {
tokenId = tokenId.slice(0, 10)
const delCount = await ApiTokens.knex().where({ id: tokenId, owner: userId }).del()
if (delCount === 0) throw new Error('Did not revoke token')
return true
}
export async function revokeTokenById(tokenId: string) {
const delCount = await ApiTokens.knex()
.where({ id: tokenId.slice(0, 10) })
.del()
if (delCount === 0) throw new Error('Token revokation failed')
return true
}
export async function getUserTokens(userId: string) {
const { rows } = await knex.raw(
`
SELECT
t.id,
t.name,
t."lastChars",
t."createdAt",
t.lifespan,
t."name",
t."lastUsed",
ts.scopes
FROM
api_tokens t
JOIN (
SELECT
ARRAY_AGG(token_scopes. "scopeName") AS "scopes",
token_scopes. "tokenId" AS id
FROM
token_scopes
JOIN api_tokens ON "api_tokens"."id" = "token_scopes"."tokenId"
GROUP BY
token_scopes. "tokenId" ) ts USING (id)
WHERE
t.id IN(
SELECT
"tokenId" FROM personal_api_tokens
WHERE
"userId" = ? )
`,
[userId]
)
return rows as {
id: string
name: string | null
lastChars: string | null
createdAt: Date
lifespan: number
lastUsed: Date
scopes: string[]
}[]
}