diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b73b4b..2800d8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Changed +- Improved metrics and logging for checkUserAccess and checkAdminAccess directives ## [1.40.4] - 2024-04-29 diff --git a/node/clients/IdentityClient.ts b/node/clients/IdentityClient.ts index b80d658..5fa811b 100644 --- a/node/clients/IdentityClient.ts +++ b/node/clients/IdentityClient.ts @@ -14,4 +14,14 @@ export default class IdentityClient extends JanusClient { public async validateToken({ token }: { token: string }): Promise { return this.http.post('/api/vtexid/credential/validate', { token }) } + + public async getToken({ + appkey, + apptoken, + }: { + appkey: string + apptoken: string + }): Promise { + return this.http.post('/api/vtexid/apptoken/login', { appkey, apptoken }) + } } diff --git a/node/directives/checkAdminAccess.ts b/node/directives/checkAdminAccess.ts index 967122c..dd44b03 100644 --- a/node/directives/checkAdminAccess.ts +++ b/node/directives/checkAdminAccess.ts @@ -4,6 +4,7 @@ import type { GraphQLField } from 'graphql' import { defaultFieldResolver } from 'graphql' import sendAuthMetric, { AuthMetric } from '../metrics/auth' +import { validateAdminToken, validateApiToken } from './helper' export class CheckAdminAccess extends SchemaDirectiveVisitor { public visitFieldDefinition(field: GraphQLField) { @@ -16,68 +17,70 @@ export class CheckAdminAccess extends SchemaDirectiveVisitor { info: any ) => { const { - vtex: { adminUserAuthToken, logger }, - clients: { identity }, + vtex: { adminUserAuthToken, storeUserAuthToken, logger }, } = context + const { hasAdminToken, hasValidAdminToken, hasCurrentValidAdminToken } = + await validateAdminToken(context, adminUserAuthToken as string) + + const { hasApiToken, hasValidApiToken } = await validateApiToken(context) + + const hasStoreToken = !!storeUserAuthToken // we don't need to validate store token + + // now we emit a metric with all the collected data before we proceed const operation = field.astNode?.name?.value ?? context.request.url - const metric = new AuthMetric( - context.vtex.account, + const userAgent = context?.request?.headers['user-agent'] as string + const caller = context?.request?.headers['x-vtex-caller'] as string + const forwardedHost = context?.request?.headers[ + 'x-forwarded-host' + ] as string + + const auditMetric = new AuthMetric( + context?.vtex?.account, { operation, - forwardedHost: context.request.header['x-forwarded-host'] as string, - caller: context.request.header['x-vtex-caller'] as string, - userAgent: context.request.header['user-agent'] as string, - hasAdminToken: !!adminUserAuthToken, - hasStoreToken: false, - hasApiToken: false, + forwardedHost, + caller, + userAgent, + hasAdminToken, + hasValidAdminToken, + hasApiToken, + hasValidApiToken, + hasStoreToken, }, - 'CheckAdminAccess' + 'CheckAdminAccessAudit' ) - if (!adminUserAuthToken) { - metric.error = 'No admin token provided' - sendAuthMetric(logger, metric) + sendAuthMetric(logger, auditMetric) + + if (!hasAdminToken) { logger.warn({ - message: 'CheckAdminAccess: No admin token provided', - userAgent: context.request.header['user-agent'], - vtexCaller: context.request.header['x-vtex-caller'], - forwardedHost: context.request.header['x-forwarded-host'], + message: 'CheckAdminAccess: No token provided', + userAgent, + caller, + forwardedHost, operation, + hasAdminToken, + hasValidAdminToken, + hasApiToken, + hasValidApiToken, + hasStoreToken, }) throw new AuthenticationError('No token was provided') } - try { - const authUser = await identity.validateToken({ - token: adminUserAuthToken, - }) - - // This is the first step before actually enabling this code. - // For now we only log in case of errors, but in follow up commits - // we should also throw an exception inside this if in case of errors - if (!authUser?.audience || authUser?.audience !== 'admin') { - metric.error = 'Token is not an admin token' - sendAuthMetric(logger, metric) - logger.warn({ - message: `CheckUserAccess: Token is not an admin token`, - userAgent: context.request.header['user-agent'], - vtexCaller: context.request.header['x-vtex-caller'], - forwardedHost: context.request.header['x-forwarded-host'], - operation, - }) - } - } catch (err) { - metric.error = 'Invalid token' - sendAuthMetric(logger, metric) + if (!hasCurrentValidAdminToken) { logger.warn({ - error: err, message: 'CheckAdminAccess: Invalid token', - userAgent: context.request.header['user-agent'], - vtexCaller: context.request.header['x-vtex-caller'], - forwardedHost: context.request.header['x-forwarded-host'], + userAgent, + caller, + forwardedHost, operation, - token: adminUserAuthToken, + hasAdminToken, + hasValidAdminToken, + hasApiToken, + hasValidApiToken, + hasStoreToken, }) throw new ForbiddenError('Unauthorized Access') } diff --git a/node/directives/checkUserAccess.ts b/node/directives/checkUserAccess.ts index eb3af26..f8c233f 100644 --- a/node/directives/checkUserAccess.ts +++ b/node/directives/checkUserAccess.ts @@ -3,161 +3,94 @@ import type { GraphQLField } from 'graphql' import { defaultFieldResolver } from 'graphql' import { SchemaDirectiveVisitor } from 'graphql-tools' -import { getActiveUserByEmail } from '../resolvers/Queries/Users' import sendAuthMetric, { AuthMetric } from '../metrics/auth' +import { + validateAdminToken, + validateApiToken, + validateStoreToken, +} from './helper' -export async function checkUserOrAdminTokenAccess( - ctx: Context, - operation?: string -) { - const { - vtex: { adminUserAuthToken, storeUserAuthToken, logger }, - clients: { identity, vtexId }, - } = ctx +export class CheckUserAccess extends SchemaDirectiveVisitor { + public visitFieldDefinition(field: GraphQLField) { + const { resolve = defaultFieldResolver } = field - const metric = new AuthMetric( - ctx.vtex.account, - { - operation: operation ?? ctx.request.url, - forwardedHost: ctx.request.header['x-forwarded-host'] as string, - caller: ctx.request.header['x-vtex-caller'] as string, - userAgent: ctx.request.header['user-agent'] as string, - hasAdminToken: !!adminUserAuthToken, - hasStoreToken: !!storeUserAuthToken, - hasApiToken: false, - }, - 'CheckUserAccess' - ) + field.resolve = async ( + root: any, + args: any, + context: Context, + info: any + ) => { + const { + vtex: { adminUserAuthToken, storeUserAuthToken, logger }, + } = context - if (!adminUserAuthToken && !storeUserAuthToken) { - metric.error = 'No admin or store token was provided' - sendAuthMetric(logger, metric) - logger.warn({ - message: `CheckUserAccess: No admin or store token was provided`, - userAgent: ctx.request.header['user-agent'], - vtexCaller: ctx.request.header['x-vtex-caller'], - forwardedHost: ctx.request.header['x-forwarded-host'], - operation, - }) - throw new AuthenticationError('No admin or store token was provided') - } + const { hasAdminToken, hasValidAdminToken, hasCurrentValidAdminToken } = + await validateAdminToken(context, adminUserAuthToken as string) + + const { hasApiToken, hasValidApiToken } = await validateApiToken(context) + + const { hasStoreToken, hasValidStoreToken, hasCurrentValidStoreToken } = + await validateStoreToken(context, storeUserAuthToken as string) + + // now we emit a metric with all the collected data before we proceed + const operation = field?.astNode?.name?.value ?? context?.request?.url + const userAgent = context?.request?.headers['user-agent'] as string + const caller = context?.request?.headers['x-vtex-caller'] as string + const forwardedHost = context?.request?.headers[ + 'x-forwarded-host' + ] as string - if (adminUserAuthToken) { - try { - const authUser = await identity.validateToken({ - token: adminUserAuthToken, - }) + const auditMetric = new AuthMetric( + context?.vtex?.account, + { + operation, + forwardedHost, + caller, + userAgent, + hasAdminToken, + hasValidAdminToken, + hasApiToken, + hasValidApiToken, + hasStoreToken, + hasValidStoreToken, + }, + 'CheckUserAccessAudit' + ) + + sendAuthMetric(logger, auditMetric) - // This is the first step before actually enabling this code. - // For now we only log in case of errors, but in follow up commits - // we should also throw an exception inside this if in case of errors - if (!authUser?.audience || authUser?.audience !== 'admin') { - metric.error = 'Token is not an admin token' - sendAuthMetric(logger, metric) + if (!hasAdminToken && !hasStoreToken) { logger.warn({ - message: `CheckUserAccess: Token is not an admin token`, - userAgent: ctx.request.header['user-agent'], - vtexCaller: ctx.request.header['x-vtex-caller'], - forwardedHost: ctx.request.header['x-forwarded-host'], + message: 'CheckUserAccess: No token provided', + userAgent, + caller, + forwardedHost, operation, + hasAdminToken, + hasValidAdminToken, + hasApiToken, + hasValidApiToken, + hasStoreToken, }) + throw new AuthenticationError('No token was provided') } - } catch (err) { - metric.error = 'Invalid admin token' - sendAuthMetric(logger, metric) - logger.warn({ - error: err, - message: `CheckUserAccess: Invalid admin token`, - userAgent: ctx.request.header['user-agent'], - vtexCaller: ctx.request.header['x-vtex-caller'], - forwardedHost: ctx.request.header['x-forwarded-host'], - operation, - }) - throw new ForbiddenError('Unauthorized Access') - } - } else if (storeUserAuthToken) { - let authUser = null - try { - authUser = await vtexId.getAuthenticatedUser(storeUserAuthToken) - if (!authUser?.user) { - metric.error = 'No valid user found by store user token' - sendAuthMetric(logger, metric) + if (!hasCurrentValidAdminToken && !hasCurrentValidStoreToken) { logger.warn({ - message: `CheckUserAccess: No valid user found by store user token`, - userAgent: ctx.request.header['user-agent'], - vtexCaller: ctx.request.header['x-vtex-caller'], - forwardedHost: ctx.request.header['x-forwarded-host'], + message: `CheckUserAccess: Invalid token`, + userAgent, + caller, + forwardedHost, operation, + hasAdminToken, + hasValidAdminToken, + hasApiToken, + hasValidApiToken, + hasStoreToken, + hasValidStoreToken, }) - authUser = null - } else { - // This is the first step before actually enabling this code. - // For now we only log in case of errors, but in follow up commits - // we will remove this additional try/catch and set authUser = null - // in case of errors - try { - const user = (await getActiveUserByEmail( - null, - { email: authUser?.user }, - ctx - )) as { roleId: string } | null - - if (!user?.roleId) { - metric.error = 'No active user found by store user token' - sendAuthMetric(logger, metric) - logger.warn({ - message: `CheckUserAccess: No active user found by store user token`, - userAgent: ctx.request.header['user-agent'], - vtexCaller: ctx.request.header['x-vtex-caller'], - forwardedHost: ctx.request.header['x-forwarded-host'], - operation, - }) - } - } catch (err) { - metric.error = 'Error getting user by email' - sendAuthMetric(logger, metric) - logger.warn({ - error: err, - message: `CheckUserAccess: Error getting user by email`, - userAgent: ctx.request.header['user-agent'], - vtexCaller: ctx.request.header['x-vtex-caller'], - forwardedHost: ctx.request.header['x-forwarded-host'], - operation, - }) - } + throw new ForbiddenError('Unauthorized Access') } - } catch (err) { - metric.error = 'Invalid store user token' - sendAuthMetric(logger, metric) - logger.warn({ - error: err, - message: `CheckUserAccess: Invalid store user token`, - userAgent: ctx.request.header['user-agent'], - vtexCaller: ctx.request.header['x-vtex-caller'], - forwardedHost: ctx.request.header['x-forwarded-host'], - operation, - }) - authUser = null - } - - if (!authUser) { - throw new ForbiddenError('Unauthorized Access') - } - } -} - -export class CheckUserAccess extends SchemaDirectiveVisitor { - public visitFieldDefinition(field: GraphQLField) { - const { resolve = defaultFieldResolver } = field - - field.resolve = async ( - root: any, - args: any, - context: Context, - info: any - ) => { - await checkUserOrAdminTokenAccess(context, field.astNode?.name?.value) return resolve(root, args, context, info) } diff --git a/node/directives/helper.ts b/node/directives/helper.ts new file mode 100644 index 0000000..374db05 --- /dev/null +++ b/node/directives/helper.ts @@ -0,0 +1,123 @@ +import { getActiveUserByEmail } from '../resolvers/Queries/Users' + +export const validateAdminToken = async ( + context: Context, + adminUserAuthToken: string +): Promise<{ + hasAdminToken: boolean + hasValidAdminToken: boolean + hasCurrentValidAdminToken: boolean +}> => { + const { + clients: { identity }, + } = context + + // check if has admin token and if it is valid + const hasAdminToken = !!adminUserAuthToken + let hasValidAdminToken = false + // this is used to check if the token is valid by current standards + let hasCurrentValidAdminToken = false + + if (hasAdminToken) { + try { + const authUser = await identity.validateToken({ + token: adminUserAuthToken, + }) + + // we set this flag to true if the token is valid by current standards + // in the future we should remove this line + hasCurrentValidAdminToken = true + + if (authUser?.audience === 'admin') { + hasValidAdminToken = true + } + } catch (err) { + // noop so we leave hasValidAdminToken as false + } + } + + return { hasAdminToken, hasValidAdminToken, hasCurrentValidAdminToken } +} + +export const validateApiToken = async ( + context: Context +): Promise<{ + hasApiToken: boolean + hasValidApiToken: boolean +}> => { + const { + clients: { identity }, + } = context + + // check if has api token and if it is valid + const apiToken = context?.headers['vtex-api-apptoken'] as string + const appKey = context?.headers['vtex-api-appkey'] as string + const hasApiToken = !!(apiToken?.length && appKey?.length) + let hasValidApiToken = false + + if (hasApiToken) { + try { + const { token } = await identity.getToken({ + appkey: appKey, + apptoken: apiToken, + }) + + const authUser = await identity.validateToken({ + token, + }) + + if (authUser?.audience === 'admin') { + hasValidApiToken = true + } + } catch (err) { + // noop so we leave hasValidApiToken as false + } + } + + return { hasApiToken, hasValidApiToken } +} + +export const validateStoreToken = async ( + context: Context, + storeUserAuthToken: string +): Promise<{ + hasStoreToken: boolean + hasValidStoreToken: boolean + hasCurrentValidStoreToken: boolean +}> => { + const { + clients: { vtexId }, + } = context + + // check if has store token and if it is valid + const hasStoreToken = !!storeUserAuthToken + let hasValidStoreToken = false + // this is used to check if the token is valid by current standards + let hasCurrentValidStoreToken = false + + if (hasStoreToken) { + try { + const authUser = await vtexId.getAuthenticatedUser(storeUserAuthToken) + + if (authUser?.user) { + // we set this flag to true if the token is valid by current standards + // in the future we should remove this line + hasCurrentValidStoreToken = true + + const user = (await getActiveUserByEmail( + null, + { email: authUser?.user }, + context + )) as { roleId: string } | null + + if (user?.roleId) { + hasValidStoreToken = true + } + } + } catch (err) { + // noop so we leave hasValidStoreToken as false + } + } + + return { hasStoreToken, hasValidStoreToken, hasCurrentValidStoreToken } +} diff --git a/node/metrics/auth.ts b/node/metrics/auth.ts index 2dda758..c2c675c 100644 --- a/node/metrics/auth.ts +++ b/node/metrics/auth.ts @@ -11,8 +11,11 @@ export interface AuthAuditMetric { role?: string permissions?: string[] hasAdminToken: boolean + hasValidAdminToken?: boolean hasStoreToken: boolean + hasValidStoreToken?: boolean hasApiToken: boolean + hasValidApiToken?: boolean } export class AuthMetric implements Metric {