Merge pull request #3314 from specklesystems/fabians/core-ioc-76
chore(server): core IoC #76 - validateTokenFactory
This commit is contained in:
@@ -43,7 +43,7 @@ export type ApiTokenRecord = {
|
||||
revoked: boolean
|
||||
lifespan: number | bigint
|
||||
createdAt: string
|
||||
lastUsed: string
|
||||
lastUsed: Date
|
||||
}
|
||||
|
||||
const tables = {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user