From b4bdbdedc2e4b2fa78644e24f6326ada85dd0177 Mon Sep 17 00:00:00 2001 From: Ian Mustafa Date: Wed, 3 Dec 2025 17:19:55 +0700 Subject: [PATCH] Implement macro for signature verification --- src/helpers/signature.ts | 17 ++-- src/middlewares/apiKeyAuth.ts | 141 ++++++++++++++++++++++++++++------ src/modules/auth/router.ts | 22 ++++-- 3 files changed, 146 insertions(+), 34 deletions(-) diff --git a/src/helpers/signature.ts b/src/helpers/signature.ts index cd9af8b..e99e1ea 100644 --- a/src/helpers/signature.ts +++ b/src/helpers/signature.ts @@ -102,8 +102,13 @@ export function canonicalizeObject(data: T): unknown { * @param data The data to canonicalize. * @returns The canonical JSON string. */ -export function canonicalize(data: unknown): string { - return JSON.stringify(canonicalizeObject(data)) +export function canonicalize(data: unknown, timestamp: number, nonce: string): string { + const body = canonicalizeObject(data) + return JSON.stringify({ + timestamp, + nonce, + body, + }) } /** @@ -113,9 +118,9 @@ export function canonicalize(data: unknown): string { * @param secretKey The secret key. * @returns The signature. */ -export function sign(data: unknown, secretKey: string): string { +export function sign(data: unknown, timestamp: number, nonce: string, secretKey: string): string { return createHmac('sha256', secretKey) - .update(canonicalize(data), 'utf8') + .update(canonicalize(data, timestamp, nonce), 'utf8') .digest('hex') } @@ -127,9 +132,9 @@ export function sign(data: unknown, secretKey: string): string { * @param secretKey The secret key. * @returns True if the signature is valid, false otherwise. */ -export function verify(data: unknown, signature: string, secretKey: string): boolean { +export function verify(data: unknown, timestamp: number, nonce: string, signature: string, secretKey: string): boolean { try { - const computed = sign(data, secretKey) + const computed = sign(data, timestamp, nonce, secretKey) const computedBuf = Buffer.from(computed, 'hex') const providedBuf = Buffer.from(signature, 'hex') if (computedBuf.length !== providedBuf.length) { diff --git a/src/middlewares/apiKeyAuth.ts b/src/middlewares/apiKeyAuth.ts index d18b288..d1b451b 100644 --- a/src/middlewares/apiKeyAuth.ts +++ b/src/middlewares/apiKeyAuth.ts @@ -1,35 +1,123 @@ -import Elysia, { t } from 'elysia' +import Elysia, { HTTPHeaders, StatusMap } from 'elysia' import { db, table as $t } from '~/db' import { and, eq } from 'drizzle-orm' import { apiAuth } from '~/config' import merchantNonces from '~/db/schema/merchant_nonces' import { verify } from '~/helpers/signature' import { UnauthenticatedError } from '~/helpers/errors' +import { logger } from '~/plugins' -export const apiKeyAuth = new Elysia().macro({ - verifyKey: { - headers: t.Object({ - 'x-api-key': t.String(), - 'x-nonce': t.String(), - 'x-signature': t.String(), - 'x-timestamp': t.Number(), - }), - body: t.Unknown(), +export const apiKeyAuthMiddleware = async ( + headers: Record, + body: unknown, + set: { + headers: HTTPHeaders + status?: number | keyof StatusMap + }, +) => { + const apiKey = headers['x-api-key'] + const nonce = headers['x-nonce'] + const signature = headers['x-signature'] + const rawTimestamp = headers['x-timestamp'] + if (!apiKey || !nonce || !signature || !rawTimestamp) { + logger.debug({ + 'headers.authorization': headers.authorization, + }, 'Failed API key authentication attempt due to missing auth headers') + set.status = 401 + throw new UnauthenticatedError('Missing auth headers') + } + + const timestamp = Number(rawTimestamp) + if (!Number.isFinite(timestamp)) { + logger.debug({ + 'headers.authorization': headers.authorization, + }, 'Failed API key authentication attempt due to invalid timestamp') + set.status = 401 + throw new UnauthenticatedError('Invalid timestamp') + } + const nowSec = Math.floor(Date.now() / 1000) + if (Math.abs(nowSec - timestamp) > apiAuth.skewSeconds) { + logger.debug({ + 'headers.authorization': headers.authorization, + }, 'Failed API key authentication attempt due to timestamp out of range') + set.status = 401 + throw new UnauthenticatedError('Timestamp out of range') + } + + const merchant = await db.query.merchants.findFirst({ + where: (t, { and, eq }) => and( + eq(t.apiKey, apiKey), + eq(t.isActive, true), + ), + }) + if (!merchant) { + logger.debug({ + 'headers.authorization': headers.authorization, + }, 'Failed API key authentication attempt due to invalid API key') + set.status = 401 + throw new UnauthenticatedError('Invalid API key') + } + + const existingNonce = await db.$count($t.merchantNonces, and( + eq($t.merchantNonces.merchantId, merchant.merchantId), + eq($t.merchantNonces.nonce, nonce), + )) + if (existingNonce > 0) { + logger.debug({ + 'headers.authorization': headers.authorization, + }, 'Failed API key authentication attempt due to request replay detected') + set.status = 401 + throw new UnauthenticatedError('Duplicate request detected, ensure your nonce is unique') + } + + db.insert(merchantNonces).values({ + merchantId: merchant.merchantId, + nonce, + }).catch((error) => { + logger.error({ + errorMessage: error.message, + }, 'Failed to save merchant nonce into database') + }) + + if (!verify(body, timestamp, nonce, signature, merchant.secretKey)) { + logger.debug({ + 'headers.authorization': headers.authorization, + }, 'Failed API key authentication attempt due to invalid signature') + set.status = 401 + throw new UnauthenticatedError('Invalid signature') + } +} + +export const apiKeyAuthMacro = new Elysia().macro({ + verifyKey: (enabled: boolean) => ({ async beforeHandle({ headers, body }) { + if (!enabled) { + return + } + const apiKey = headers['x-api-key'] const nonce = headers['x-nonce'] const signature = headers['x-signature'] - const timestamp = headers['x-timestamp'] - if (!apiKey || !nonce || !signature || !timestamp) { + const rawTimestamp = headers['x-timestamp'] + if (!apiKey || !nonce || !signature || !rawTimestamp) { + logger.debug({ + 'headers.authorization': headers.authorization, + }, 'Failed API key authentication attempt due to missing auth headers') throw new UnauthenticatedError('Missing auth headers') } - const tsNum = Number(timestamp) - if (!Number.isFinite(tsNum)) { + const timestamp = Number(rawTimestamp) + if (!Number.isFinite(timestamp)) { + logger.debug({ + 'headers.authorization': headers.authorization, + }, 'Failed API key authentication attempt due to invalid timestamp') throw new UnauthenticatedError('Invalid timestamp') } const nowSec = Math.floor(Date.now() / 1000) - if (Math.abs(nowSec - tsNum) > apiAuth.skewSeconds) { + if (Math.abs(nowSec - timestamp) > apiAuth.skewSeconds) { + logger.debug({ + 'headers.authorization': headers.authorization, + }, 'Failed API key authentication attempt due to timestamp out of range') throw new UnauthenticatedError('Timestamp out of range') } @@ -40,29 +128,38 @@ export const apiKeyAuth = new Elysia().macro({ ), }) if (!merchant) { + logger.debug({ + 'headers.authorization': headers.authorization, + }, 'Failed API key authentication attempt due to invalid API key') throw new UnauthenticatedError('Invalid API key') } - if (!nonce) { - throw new UnauthenticatedError('Nonce is required') - } - const existingNonce = await db.$count($t.merchantNonces, and( eq($t.merchantNonces.merchantId, merchant.merchantId), eq($t.merchantNonces.nonce, nonce), )) if (existingNonce > 0) { - throw new UnauthenticatedError('API replay detected') + logger.debug({ + 'headers.authorization': headers.authorization, + }, 'Failed API key authentication attempt due to replay attack detected') + throw new UnauthenticatedError('Duplicate request detected, ensure your nonce is unique') } db.insert(merchantNonces).values({ merchantId: merchant.merchantId, nonce, + }).catch((error) => { + logger.error({ + errorMessage: error.message, + }, 'Failed to save merchant nonce into database') }) - if (!verify(body, signature, merchant.secretKey)) { + if (!verify(body, timestamp, nonce, signature, merchant.secretKey)) { + logger.debug({ + 'headers.authorization': headers.authorization, + }, 'Failed API key authentication attempt due to invalid signature') throw new UnauthenticatedError('Invalid signature') } }, - }, + }), }) diff --git a/src/modules/auth/router.ts b/src/modules/auth/router.ts index eae8730..743831b 100644 --- a/src/modules/auth/router.ts +++ b/src/modules/auth/router.ts @@ -1,9 +1,9 @@ -import { Elysia } from 'elysia' +import { Elysia, t } from 'elysia' import { createMerchantBody, createMerchantResponseSchema } from './schema' import CreateMerchantService from './commands/service' import { basicAuthMiddleware } from '~/middlewares/basicAuth' import { DataAlreadyExistsError } from '~/helpers/errors' -import { apiKeyAuth } from '~/middlewares/apiKeyAuth' +import { apiKeyAuthMacro } from '~/middlewares/apiKeyAuth' export const router = new Elysia({ name: 'modules.auth', @@ -41,13 +41,23 @@ export const router = new Elysia({ security: [{ basicAuth: [] }], }, }) - .use(apiKeyAuth) - .get('/test-key', async ({ body }) => { + .use(apiKeyAuthMacro) + .post('/test-key', async ({ body }) => { return body }, { verifyKey: true, - detail: { - security: [{ basicAuth: [] }], + body: t.Object({ + foo: t.String(), + bar: t.Object({ + lorem: t.Integer(), + ipsum: t.Boolean(), + }), + }), + response: { + 200: t.Any(), + 401: t.Object({ + message: t.String(), + }), }, })