Merge pull request #3314 from specklesystems/fabians/core-ioc-76

chore(server): core IoC #76 - validateTokenFactory
This commit is contained in:
Alessandro Magionami
2024-10-17 12:01:11 +02:00
committed by GitHub
8 changed files with 212 additions and 80 deletions
@@ -43,7 +43,7 @@ export type ApiTokenRecord = {
revoked: boolean
lifespan: number | bigint
createdAt: string
lastUsed: string
lastUsed: Date
}
const tables = {
+21 -4
View File
@@ -1,8 +1,8 @@
import cors from 'cors'
import {
validateToken,
createBareToken,
createAppTokenFactory
createAppTokenFactory,
validateTokenFactory
} from '@/modules/core/services/tokens'
import { validateScopes } from '@/modules/shared'
import { InvalidAccessCodeRequestError } from '@/modules/auth/errors'
@@ -15,7 +15,8 @@ import {
getAuthorizationCodeFactory,
deleteAuthorizationCodeFactory,
createRefreshTokenFactory,
getRefreshTokenFactory
getRefreshTokenFactory,
getTokenAppInfoFactory
} from '@/modules/auth/repositories/apps'
import { db } from '@/db/knex'
import {
@@ -24,12 +25,18 @@ import {
} from '@/modules/auth/services/serverApps'
import { Express } from 'express'
import {
getApiTokenByIdFactory,
getTokenResourceAccessDefinitionsByIdFactory,
getTokenScopesByIdFactory,
revokeTokenByIdFactory,
revokeUserTokenByIdFactory,
storeApiTokenFactory,
storeTokenResourceAccessDefinitionsFactory,
storeTokenScopesFactory,
storeUserServerAppTokenFactory
storeUserServerAppTokenFactory,
updateApiTokenFactory
} from '@/modules/core/repositories/tokens'
import { getUserRoleFactory } from '@/modules/core/repositories/users'
// TODO: Secure these endpoints!
export default function (app: Express) {
@@ -41,6 +48,16 @@ export default function (app: Express) {
try {
const getApp = getAppFactory({ db })
const createAuthorizationCode = createAuthorizationCodeFactory({ db })
const validateToken = validateTokenFactory({
revokeUserTokenById: revokeUserTokenByIdFactory({ db }),
getApiTokenById: getApiTokenByIdFactory({ db }),
getTokenAppInfo: getTokenAppInfoFactory({ db }),
getTokenScopesById: getTokenScopesByIdFactory({ db }),
getUserRole: getUserRoleFactory({ db }),
getTokenResourceAccessDefinitionsById:
getTokenResourceAccessDefinitionsByIdFactory({ db }),
updateApiToken: updateApiTokenFactory({ db })
})
const preventRedirect = !!req.query.preventRedirect
const appId = req.query.appId as Optional<string>
@@ -2,9 +2,9 @@
const expect = require('chai').expect
const {
validateToken,
createBareToken,
createAppTokenFactory
createAppTokenFactory,
validateTokenFactory
} = require(`@/modules/core/services/tokens`)
const { beforeEachContext } = require(`@/test/hooks`)
@@ -24,7 +24,8 @@ const {
deleteAuthorizationCodeFactory,
createRefreshTokenFactory,
getRefreshTokenFactory,
revokeRefreshTokenFactory
revokeRefreshTokenFactory,
getTokenAppInfoFactory
} = require('@/modules/auth/repositories/apps')
const {
createAppTokenFromAccessCodeFactory,
@@ -42,7 +43,8 @@ const {
getUserFactory,
storeUserFactory,
countAdminUsersFactory,
storeUserAclFactory
storeUserAclFactory,
getUserRoleFactory
} = require('@/modules/core/repositories/users')
const { getServerInfo } = require('@/modules/core/services/generic')
const {
@@ -66,7 +68,12 @@ const {
storeApiTokenFactory,
storeTokenScopesFactory,
storeTokenResourceAccessDefinitionsFactory,
storeUserServerAppTokenFactory
storeUserServerAppTokenFactory,
revokeUserTokenByIdFactory,
getApiTokenByIdFactory,
getTokenScopesByIdFactory,
getTokenResourceAccessDefinitionsByIdFactory,
updateApiTokenFactory
} = require('@/modules/core/repositories/tokens')
const db = knex
@@ -135,6 +142,17 @@ const createUser = createUserFactory({
}),
usersEventsEmitter: UsersEmitter.emit
})
const validateToken = validateTokenFactory({
revokeUserTokenById: revokeUserTokenByIdFactory({ db }),
getApiTokenById: getApiTokenByIdFactory({ db }),
getTokenAppInfo: getTokenAppInfoFactory({ db }),
getTokenScopesById: getTokenScopesByIdFactory({ db }),
getUserRole: getUserRoleFactory({ db }),
getTokenResourceAccessDefinitionsById: getTokenResourceAccessDefinitionsByIdFactory({
db
}),
updateApiToken: updateApiTokenFactory({ db })
})
describe('Services @apps-services', () => {
const actor = {
@@ -6,7 +6,8 @@ import {
UserServerAppToken
} from '@/modules/core/domain/tokens/types'
import { TokenResourceIdentifierInput } from '@/modules/core/graph/generated/graphql'
import { NullableKeysToOptional, ServerScope } from '@speckle/shared'
import { TokenValidationResult } from '@/modules/core/helpers/types'
import { NullableKeysToOptional, Optional, ServerScope } from '@speckle/shared'
import { SetOptional } from 'type-fest'
export type StoreApiToken = (
@@ -46,6 +47,19 @@ export type RevokeTokenById = (tokenId: string) => Promise<boolean>
export type RevokeUserTokenById = (tokenId: string, userId: string) => Promise<boolean>
export type GetApiTokenById = (tokenId: string) => Promise<Optional<ApiToken>>
export type GetTokenScopesById = (tokenId: string) => Promise<TokenScope[]>
export type GetTokenResourceAccessDefinitionsById = (
tokenId: string
) => Promise<TokenResourceAccessDefinition[]>
export type UpdateApiToken = (
tokenId: string,
token: Partial<ApiToken>
) => Promise<ApiToken>
export type CreateAndStoreUserToken = (params: {
userId: string
name: string
@@ -66,3 +80,5 @@ export type CreateAndStorePersonalAccessToken = (
scopes: ServerScope[],
lifespan?: number | bigint
) => Promise<string>
export type ValidateToken = (tokenString: string) => Promise<TokenValidationResult>
@@ -12,6 +12,9 @@ import {
UserServerAppTokens
} from '@/modules/core/dbSchema'
import {
GetApiTokenById,
GetTokenResourceAccessDefinitionsById,
GetTokenScopesById,
GetUserPersonalAccessTokens,
RevokeTokenById,
RevokeUserTokenById,
@@ -19,7 +22,8 @@ import {
StorePersonalApiToken,
StoreTokenResourceAccessDefinitions,
StoreTokenScopes,
StoreUserServerAppToken
StoreUserServerAppToken,
UpdateApiToken
} from '@/modules/core/domain/tokens/operations'
import { UserInputError } from '@/modules/core/errors/userinput'
import { TokenResourceAccessRecord } from '@/modules/core/helpers/types'
@@ -137,3 +141,31 @@ export const revokeUserTokenByIdFactory =
if (delCount === 0) throw new UserInputError('Did not revoke token')
return true
}
export const getApiTokenByIdFactory =
(deps: { db: Knex }): GetApiTokenById =>
async (tokenId) => {
return tables.apiTokens(deps.db).where({ id: tokenId }).first()
}
export const getTokenScopesByIdFactory =
(deps: { db: Knex }): GetTokenScopesById =>
async (tokenId: string) => {
return tables.tokenScopes(deps.db).where({ tokenId })
}
export const getTokenResourceAccessDefinitionsByIdFactory =
(deps: { db: Knex }): GetTokenResourceAccessDefinitionsById =>
async (tokenId: string) => {
return tables.tokenResourceAccess(deps.db).where({ tokenId })
}
export const updateApiTokenFactory =
(deps: { db: Knex }): UpdateApiToken =>
async (tokenId, token) => {
const [updatedToken] = await tables
.apiTokens(deps.db)
.where({ id: tokenId })
.update(token, '*')
return updatedToken
}
+54 -58
View File
@@ -1,29 +1,28 @@
import bcrypt from 'bcrypt'
import crs from 'crypto-random-string'
import knex, { db } from '@/db/knex'
import {
ServerAcl,
ApiTokens,
TokenScopes,
TokenResourceAccess
} from '@/modules/core/dbSchema'
import {
TokenResourceAccessRecord,
TokenValidationResult
} from '@/modules/core/helpers/types'
import { Optional, ServerRoles, ServerScope } from '@speckle/shared'
import { getTokenAppInfoFactory } from '@/modules/auth/repositories/apps'
import { Optional, ServerScope } from '@speckle/shared'
import {
CreateAndStoreAppToken,
CreateAndStorePersonalAccessToken,
CreateAndStoreUserToken,
GetApiTokenById,
GetTokenResourceAccessDefinitionsById,
GetTokenScopesById,
RevokeUserTokenById,
StoreApiToken,
StorePersonalApiToken,
StoreTokenResourceAccessDefinitions,
StoreTokenScopes,
StoreUserServerAppToken
StoreUserServerAppToken,
UpdateApiToken,
ValidateToken
} from '@/modules/core/domain/tokens/operations'
import { revokeUserTokenByIdFactory } from '@/modules/core/repositories/tokens'
import { GetTokenAppInfo } from '@/modules/auth/domain/operations'
import { GetUserRole } from '@/modules/core/domain/users/operations'
/*
Tokens
@@ -124,53 +123,50 @@ export const createPersonalAccessTokenFactory =
return token
}
export async function validateToken(
tokenString: string
): Promise<TokenValidationResult> {
const revokeToken = revokeUserTokenByIdFactory({ db })
export const validateTokenFactory =
(deps: {
revokeUserTokenById: RevokeUserTokenById
getApiTokenById: GetApiTokenById
getTokenAppInfo: GetTokenAppInfo
getTokenScopesById: GetTokenScopesById
getUserRole: GetUserRole
getTokenResourceAccessDefinitionsById: GetTokenResourceAccessDefinitionsById
updateApiToken: UpdateApiToken
}): ValidateToken =>
async (tokenString: string): Promise<TokenValidationResult> => {
const tokenId = tokenString.slice(0, 10)
const tokenContent = tokenString.slice(10, 42)
const tokenId = tokenString.slice(0, 10)
const tokenContent = tokenString.slice(10, 42)
const token = await deps.getApiTokenById(tokenId)
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 getTokenAppInfo = getTokenAppInfoFactory({ db })
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
if (!token) {
return { valid: false }
}
} else return { valid: false }
}
const timeDiff = Math.abs(Date.now() - new Date(token.createdAt).getTime())
if (timeDiff > token.lifespan) {
await deps.revokeUserTokenById(tokenId, token.owner)
return { valid: false }
}
const valid = await bcrypt.compare(tokenContent, token.tokenDigest)
if (valid) {
const [scopes, role, app, resourceAccessRules] = await Promise.all([
deps.getTokenScopesById(tokenId),
deps.getUserRole(token.owner),
deps.getTokenAppInfo({ token: tokenString }),
deps.getTokenResourceAccessDefinitionsById(tokenId),
deps.updateApiToken(tokenId, { lastUsed: new Date() })
])
return {
valid: true,
userId: token.owner,
role: role!,
scopes: scopes.map((s) => s.scopeName),
appId: app?.id || null,
resourceAccessRules: resourceAccessRules.length ? resourceAccessRules : null
}
} else return { valid: false }
}
@@ -3,8 +3,8 @@ const expect = require('chai').expect
const assert = require('assert')
const {
validateToken,
createPersonalAccessTokenFactory
createPersonalAccessTokenFactory,
validateTokenFactory
} = require('../services/tokens')
const { getBranchesByStreamId } = require('../services/branches')
@@ -88,7 +88,8 @@ const {
isLastAdminUserFactory,
deleteUserRecordFactory,
updateUserServerRoleFactory,
searchUsersFactory
searchUsersFactory,
getUserRoleFactory
} = require('@/modules/core/repositories/users')
const {
findEmailFactory,
@@ -131,8 +132,13 @@ const {
storeTokenResourceAccessDefinitionsFactory,
storePersonalApiTokenFactory,
getUserPersonalAccessTokensFactory,
revokeUserTokenByIdFactory
revokeUserTokenByIdFactory,
getApiTokenByIdFactory,
getTokenScopesByIdFactory,
getTokenResourceAccessDefinitionsByIdFactory,
updateApiTokenFactory
} = require('@/modules/core/repositories/tokens')
const { getTokenAppInfoFactory } = require('@/modules/auth/repositories/apps')
const getUser = legacyGetUserFactory({ db })
const getUsers = getUsersFactory({ db })
@@ -266,6 +272,17 @@ const createPersonalAccessToken = createPersonalAccessTokenFactory({
})
const getUserTokens = getUserPersonalAccessTokensFactory({ db })
const revokeToken = revokeUserTokenByIdFactory({ db })
const validateToken = validateTokenFactory({
revokeUserTokenById: revokeUserTokenByIdFactory({ db }),
getApiTokenById: getApiTokenByIdFactory({ db }),
getTokenAppInfo: getTokenAppInfoFactory({ db }),
getTokenScopesById: getTokenScopesByIdFactory({ db }),
getUserRole: getUserRoleFactory({ db }),
getTokenResourceAccessDefinitionsById: getTokenResourceAccessDefinitionsByIdFactory({
db
}),
updateApiToken: updateApiTokenFactory({ db })
})
describe('Actors & Tokens @user-services', () => {
const myTestActor = {
@@ -8,7 +8,6 @@ import {
import { Request, Response, NextFunction, Handler } from 'express'
import { ForbiddenError, UnauthorizedError } from '@/modules/shared/errors'
import { ensureError } from '@/modules/shared/helpers/errorHelper'
import { validateToken } from '@/modules/core/services/tokens'
import { TokenValidationResult } from '@/modules/core/helpers/types'
import { buildRequestLoaders } from '@/modules/core/loaders'
import {
@@ -27,6 +26,17 @@ import { resourceAccessRuleToIdentifier } from '@/modules/core/helpers/token'
import { delayGraphqlResponsesBy } from '@/modules/shared/helpers/envHelper'
import { subscriptionLogger } from '@/logging/logging'
import { GetUser } from '@/modules/core/domain/users/operations'
import { validateTokenFactory } from '@/modules/core/services/tokens'
import {
getApiTokenByIdFactory,
getTokenResourceAccessDefinitionsByIdFactory,
getTokenScopesByIdFactory,
revokeUserTokenByIdFactory,
updateApiTokenFactory
} from '@/modules/core/repositories/tokens'
import { db } from '@/db/knex'
import { getTokenAppInfoFactory } from '@/modules/auth/repositories/apps'
import { getUserRoleFactory } from '@/modules/core/repositories/users'
export const authMiddlewareCreator = (steps: AuthPipelineFunction[]) => {
const pipeline = authPipelineCreator(steps)
@@ -72,9 +82,7 @@ export const getTokenFromRequest = (req: Request | null | undefined): string | n
*/
export async function createAuthContextFromToken(
rawToken: string | null,
tokenValidator: (
tokenString: string
) => Promise<TokenValidationResult> = validateToken
tokenValidator: (tokenString: string) => Promise<TokenValidationResult>
): Promise<AuthContext> {
// null, undefined or empty string tokens can continue without errors and auth: false
// to enable anonymous user access to public resources
@@ -112,8 +120,22 @@ export async function authContextMiddleware(
res: Response,
next: NextFunction
) {
const validateToken = validateTokenFactory({
revokeUserTokenById: revokeUserTokenByIdFactory({ db }),
getApiTokenById: getApiTokenByIdFactory({ db }),
getTokenAppInfo: getTokenAppInfoFactory({ db }),
getTokenScopesById: getTokenScopesByIdFactory({ db }),
getUserRole: getUserRoleFactory({ db }),
getTokenResourceAccessDefinitionsById: getTokenResourceAccessDefinitionsByIdFactory(
{
db
}
),
updateApiToken: updateApiTokenFactory({ db })
})
const token = getTokenFromRequest(req)
const authContext = await createAuthContextFromToken(token)
const authContext = await createAuthContextFromToken(token, validateToken)
const loggedContext = Object.fromEntries(
Object.entries(authContext).filter(
([key]) => !['token'].includes(key.toLocaleLowerCase())
@@ -160,9 +182,23 @@ export async function buildContext({
token?: Nullable<string>
cleanLoadersEarly?: boolean
}): Promise<GraphQLContext> {
const validateToken = validateTokenFactory({
revokeUserTokenById: revokeUserTokenByIdFactory({ db }),
getApiTokenById: getApiTokenByIdFactory({ db }),
getTokenAppInfo: getTokenAppInfoFactory({ db }),
getTokenScopesById: getTokenScopesByIdFactory({ db }),
getUserRole: getUserRoleFactory({ db }),
getTokenResourceAccessDefinitionsById: getTokenResourceAccessDefinitionsByIdFactory(
{
db
}
),
updateApiToken: updateApiTokenFactory({ db })
})
const ctx =
req?.context ||
(await createAuthContextFromToken(token ?? getTokenFromRequest(req)))
(await createAuthContextFromToken(token ?? getTokenFromRequest(req), validateToken))
const log = Observability.extendLoggerComponent(
req?.log || subscriptionLogger,