diff --git a/.env.example b/.env.example index 3101a34..16270e9 100644 --- a/.env.example +++ b/.env.example @@ -25,4 +25,6 @@ JWT_SECRET= HTTP_BASE_URL=http://example.com HTTP_USERNAME= HTTP_PASSWORD= -HTTP_TIMEOUT=3000 \ No newline at end of file +HTTP_TIMEOUT=3000 + +MERCHANT_REGISTER=[{"merchantId":"","merchantName":"", "contactPhone": "", "apiKey": "", "secretKey": ""}] \ No newline at end of file diff --git a/src/config/index.ts b/src/config/index.ts index 0797ce8..56bc885 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -31,6 +31,10 @@ export const apiAuth = { apiPrefix: process.env.AUTH_API_KEY_PREFIX || 'ak_prod', secretPrefix: process.env.AUTH_SECRET_KEY_PREFIX || 'sk_prod', skewSeconds: Number(process.env.API_KEY_SKEW_SECONDS || 300), + rateLimit: { + windowSeconds: Number(process.env.API_KEY_RATE_LIMIT_WINDOW_SECONDS || 60), + maxRequests: Number(process.env.API_KEY_RATE_LIMIT_MAX_REQUESTS || 100), + }, } export const s3Configs = { @@ -73,3 +77,5 @@ export const httpConfig = { password: process.env.HTTP_PASSWORD, timeout: Number(process.env.HTTP_TIMEOUT) || 3000, } + +export const merchantRegister = process.env.MERCHANT_REGISTER || '[]' diff --git a/src/helpers/errors/RateLimitError.ts b/src/helpers/errors/RateLimitError.ts new file mode 100644 index 0000000..3a73a24 --- /dev/null +++ b/src/helpers/errors/RateLimitError.ts @@ -0,0 +1,5 @@ +export class RateLimitError extends Error { + constructor(public readonly message: string) { + super(message) + } +} diff --git a/src/helpers/errors/index.ts b/src/helpers/errors/index.ts index 58b21ce..ef498b9 100644 --- a/src/helpers/errors/index.ts +++ b/src/helpers/errors/index.ts @@ -2,10 +2,12 @@ import { FieldValidationError } from './FieldValidationError' import { MultiFieldValidationError } from './MultiFieldValidationError' import { UnauthenticatedError } from './UnauthenticatedError' import { DataAlreadyExistsError } from './DataAlreadyExistsError' +import { RateLimitError } from './RateLimitError' export * from './FieldValidationError' export * from './UnauthenticatedError' export * from './DataAlreadyExistsError' +export * from './RateLimitError' export * from './DeviceErrorResponseMap' export { InternalServerError } from 'elysia' @@ -14,4 +16,5 @@ export default { MULTI_FIELD_VALIDATION: MultiFieldValidationError, UNAUTHENTICATED: UnauthenticatedError, DATA_ALREADY_EXISTS: DataAlreadyExistsError, + RATE_LIMIT: RateLimitError, } diff --git a/src/middlewares/apiKeyAuthRedis.ts b/src/middlewares/apiKeyAuthRedis.ts new file mode 100644 index 0000000..b7cb391 --- /dev/null +++ b/src/middlewares/apiKeyAuthRedis.ts @@ -0,0 +1,168 @@ +import Elysia from 'elysia' +import { apiAuth, merchantRegister } from '~/config' +import { verify } from '~/helpers/signature' +import { UnauthenticatedError, RateLimitError } from '~/helpers/errors' +import { logger } from '~/plugins' +import { acquireLock, get, set, incrBy, redisKey } from '~/helpers/cache/redis' + +interface RegisteredMerchant { + merchantId: string + merchantName: string + contactPhone: string + apiKey: string + secretKey: string +} + +function getRegisteredMerchants(): RegisteredMerchant[] { + try { + const parsed = JSON.parse(merchantRegister) + if (!Array.isArray(parsed)) return [] + return parsed.map((m) => ({ + merchantId: String(m.merchantId), + merchantName: String(m.merchantName), + contactPhone: String(m.contactPhone), + apiKey: String(m.apiKey), + secretKey: String(m.secretKey), + })) + } catch { + return [] + } +} + +function findMerchant(apiKey: string): RegisteredMerchant | undefined { + return getRegisteredMerchants().find((m) => m.apiKey === apiKey) +} + +const nonceKey = (merchantId: string, nonce: string) => + redisKey('merchant_nonces', merchantId, nonce) + +const rateLimitKey = (merchantId: string) => + redisKey('merchant_nonces_limitter', merchantId) + +const lockKey = (merchantId: string, nonce: string) => + redisKey('merchant_nonces', 'lock', merchantId, nonce) + +async function enforceRateLimit(merchantId: string): Promise { + const key = rateLimitKey(merchantId) + const current = await incrBy(key, 1) + if (current === 1) { + await set(key, String(current), apiAuth.rateLimit.windowSeconds) + } + if (current > apiAuth.rateLimit.maxRequests) { + logger.debug({ + merchantId, + current, + max: apiAuth.rateLimit.maxRequests, + }, 'Rate limit exceeded for merchant') + throw new RateLimitError('Too many requests, please slow down') + } +} + +async function enforceUniqueNonce(merchantId: string, nonce: string): Promise { + const key = nonceKey(merchantId, nonce) + + const existing = await get(key) + if (existing) { + logger.debug({ + merchantId, + nonce, + }, 'Failed API key authentication attempt due to replay attack detected') + throw new UnauthenticatedError('Duplicate request detected, ensure your nonce is unique') + } + + const lock = await acquireLock(lockKey(merchantId, nonce), 5000) + if (!lock) { + logger.debug({ + merchantId, + nonce, + }, 'Failed API key authentication attempt due to concurrent nonce contention') + throw new UnauthenticatedError('Duplicate request detected, ensure your nonce is unique') + } + + try { + const concurrent = await get(key) + if (concurrent) { + throw new UnauthenticatedError('Duplicate request detected, ensure your nonce is unique') + } + await set(key, '1', apiAuth.skewSeconds) + } finally { + await lock.unlock() + } +} + +async function authenticate( + headers: Record, + payload: unknown, +): Promise { + const apiKey = headers['x-api-key'] + const nonce = headers['x-nonce'] + const signature = headers['x-signature'] + const rawTimestamp = headers['x-timestamp'] + + const missingHeaders: string[] = [] + if (!apiKey) missingHeaders.push('X-API-Key') + if (!nonce) missingHeaders.push('X-Nonce') + if (!rawTimestamp) missingHeaders.push('X-Timestamp') + if (!signature) missingHeaders.push('X-Signature') + if (missingHeaders.length > 0) { + const missing = missingHeaders.join(', ') + logger.debug({ + 'headers.authorization': headers.authorization, + }, 'Failed API key authentication attempt due to missing auth headers: ' + missing) + throw new UnauthenticatedError('Missing auth headers: ' + missing) + } + + const apiKeyStr = apiKey as string + const nonceStr = nonce as string + const signatureStr = signature as string + + 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 - 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') + } + + const merchant = findMerchant(apiKeyStr) + if (!merchant) { + logger.debug({ + 'headers.authorization': headers.authorization, + }, 'Failed API key authentication attempt due to invalid API key') + throw new UnauthenticatedError('Invalid API key') + } + + await enforceRateLimit(merchant.merchantId) + await enforceUniqueNonce(merchant.merchantId, nonceStr) + + if (!verify(payload, timestamp, nonceStr, signatureStr, merchant.secretKey)) { + logger.debug({ + 'headers.authorization': headers.authorization, + }, 'Failed API key authentication attempt due to invalid signature') + throw new UnauthenticatedError('Invalid signature') + } +} + +export const apiKeyAuthRedisMacro = new Elysia().macro({ + verifyKeyRedis: (enabled: boolean) => ({ + async beforeHandle({ headers, body }) { + if (!enabled) return + await authenticate(headers as Record, body) + }, + }), + verifyKeyQueryRedis: (enabled: boolean) => ({ + async beforeHandle({ headers, query }) { + if (!enabled) return + await authenticate(headers as Record, query) + }, + }), +}) + +export default apiKeyAuthRedisMacro diff --git a/src/plugins/defaultHandler.ts b/src/plugins/defaultHandler.ts index fdbed73..4b65847 100644 --- a/src/plugins/defaultHandler.ts +++ b/src/plugins/defaultHandler.ts @@ -44,6 +44,11 @@ export const defaultHandlerPlugin = new Elysia({ message: error.message, }) + case 'RATE_LIMIT': + return status(429, { + message: error.message, + }) + case 'FIELD_VALIDATION': return status(422, handleFieldValidationError(error)) diff --git a/tsconfig.json b/tsconfig.json index d4dcb17..f751f68 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -28,6 +28,7 @@ "module": "es2022", /* Specify what module code is generated. */ // "rootDir": "./", /* Specify the root folder within your source files. */ "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ + "ignoreDeprecations": "6.0", /* Silence deprecation warnings for deprecated compiler options. */ // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ "paths": { /* Specify a set of entries that re-map imports to additional lookup locations. */ "~/*": ["./src/*"],