diff --git a/packages/server/modules/auth/repositories/index.ts b/packages/server/modules/auth/repositories/index.ts index ccd1593ab..79768f4cb 100644 --- a/packages/server/modules/auth/repositories/index.ts +++ b/packages/server/modules/auth/repositories/index.ts @@ -43,7 +43,7 @@ export type ApiTokenRecord = { revoked: boolean lifespan: number | bigint createdAt: string - lastUsed: string + lastUsed: Date } const tables = { diff --git a/packages/server/modules/auth/rest/index.ts b/packages/server/modules/auth/rest/index.ts index 17a0108b6..59f700051 100644 --- a/packages/server/modules/auth/rest/index.ts +++ b/packages/server/modules/auth/rest/index.ts @@ -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 diff --git a/packages/server/modules/auth/tests/apps.spec.js b/packages/server/modules/auth/tests/apps.spec.js index 30f6e3c37..d8baa1642 100644 --- a/packages/server/modules/auth/tests/apps.spec.js +++ b/packages/server/modules/auth/tests/apps.spec.js @@ -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 = { diff --git a/packages/server/modules/core/domain/tokens/operations.ts b/packages/server/modules/core/domain/tokens/operations.ts index 0a31fa497..225d3ec17 100644 --- a/packages/server/modules/core/domain/tokens/operations.ts +++ b/packages/server/modules/core/domain/tokens/operations.ts @@ -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 export type RevokeUserTokenById = (tokenId: string, userId: string) => Promise +export type GetApiTokenById = (tokenId: string) => Promise> + +export type GetTokenScopesById = (tokenId: string) => Promise + +export type GetTokenResourceAccessDefinitionsById = ( + tokenId: string +) => Promise + +export type UpdateApiToken = ( + tokenId: string, + token: Partial +) => Promise + export type CreateAndStoreUserToken = (params: { userId: string name: string @@ -66,3 +80,5 @@ export type CreateAndStorePersonalAccessToken = ( scopes: ServerScope[], lifespan?: number | bigint ) => Promise + +export type ValidateToken = (tokenString: string) => Promise diff --git a/packages/server/modules/core/repositories/tokens.ts b/packages/server/modules/core/repositories/tokens.ts index a431b0e8b..f60df9828 100644 --- a/packages/server/modules/core/repositories/tokens.ts +++ b/packages/server/modules/core/repositories/tokens.ts @@ -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 + } diff --git a/packages/server/modules/core/services/tokens.ts b/packages/server/modules/core/services/tokens.ts index 5adcd0df2..a7f022610 100644 --- a/packages/server/modules/core/services/tokens.ts +++ b/packages/server/modules/core/services/tokens.ts @@ -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 { - 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 => { + 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().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 } + } diff --git a/packages/server/modules/core/tests/users.spec.js b/packages/server/modules/core/tests/users.spec.js index 9889294ff..92e2d4961 100644 --- a/packages/server/modules/core/tests/users.spec.js +++ b/packages/server/modules/core/tests/users.spec.js @@ -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 = { diff --git a/packages/server/modules/shared/middleware/index.ts b/packages/server/modules/shared/middleware/index.ts index 78a20529a..eeb9892c9 100644 --- a/packages/server/modules/shared/middleware/index.ts +++ b/packages/server/modules/shared/middleware/index.ts @@ -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 = validateToken + tokenValidator: (tokenString: string) => Promise ): Promise { // 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 cleanLoadersEarly?: boolean }): Promise { + 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,