feat: add middleware using Redis as data source
This commit is contained in:
+3
-1
@@ -25,4 +25,6 @@ JWT_SECRET=
|
|||||||
HTTP_BASE_URL=http://example.com
|
HTTP_BASE_URL=http://example.com
|
||||||
HTTP_USERNAME=
|
HTTP_USERNAME=
|
||||||
HTTP_PASSWORD=
|
HTTP_PASSWORD=
|
||||||
HTTP_TIMEOUT=3000
|
HTTP_TIMEOUT=3000
|
||||||
|
|
||||||
|
MERCHANT_REGISTER=[{"merchantId":"","merchantName":"", "contactPhone": "", "apiKey": "", "secretKey": ""}]
|
||||||
@@ -31,6 +31,10 @@ export const apiAuth = {
|
|||||||
apiPrefix: process.env.AUTH_API_KEY_PREFIX || 'ak_prod',
|
apiPrefix: process.env.AUTH_API_KEY_PREFIX || 'ak_prod',
|
||||||
secretPrefix: process.env.AUTH_SECRET_KEY_PREFIX || 'sk_prod',
|
secretPrefix: process.env.AUTH_SECRET_KEY_PREFIX || 'sk_prod',
|
||||||
skewSeconds: Number(process.env.API_KEY_SKEW_SECONDS || 300),
|
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 = {
|
export const s3Configs = {
|
||||||
@@ -73,3 +77,5 @@ export const httpConfig = {
|
|||||||
password: process.env.HTTP_PASSWORD,
|
password: process.env.HTTP_PASSWORD,
|
||||||
timeout: Number(process.env.HTTP_TIMEOUT) || 3000,
|
timeout: Number(process.env.HTTP_TIMEOUT) || 3000,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const merchantRegister = process.env.MERCHANT_REGISTER || '[]'
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export class RateLimitError extends Error {
|
||||||
|
constructor(public readonly message: string) {
|
||||||
|
super(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,12 @@ import { FieldValidationError } from './FieldValidationError'
|
|||||||
import { MultiFieldValidationError } from './MultiFieldValidationError'
|
import { MultiFieldValidationError } from './MultiFieldValidationError'
|
||||||
import { UnauthenticatedError } from './UnauthenticatedError'
|
import { UnauthenticatedError } from './UnauthenticatedError'
|
||||||
import { DataAlreadyExistsError } from './DataAlreadyExistsError'
|
import { DataAlreadyExistsError } from './DataAlreadyExistsError'
|
||||||
|
import { RateLimitError } from './RateLimitError'
|
||||||
|
|
||||||
export * from './FieldValidationError'
|
export * from './FieldValidationError'
|
||||||
export * from './UnauthenticatedError'
|
export * from './UnauthenticatedError'
|
||||||
export * from './DataAlreadyExistsError'
|
export * from './DataAlreadyExistsError'
|
||||||
|
export * from './RateLimitError'
|
||||||
export * from './DeviceErrorResponseMap'
|
export * from './DeviceErrorResponseMap'
|
||||||
export { InternalServerError } from 'elysia'
|
export { InternalServerError } from 'elysia'
|
||||||
|
|
||||||
@@ -14,4 +16,5 @@ export default {
|
|||||||
MULTI_FIELD_VALIDATION: MultiFieldValidationError,
|
MULTI_FIELD_VALIDATION: MultiFieldValidationError,
|
||||||
UNAUTHENTICATED: UnauthenticatedError,
|
UNAUTHENTICATED: UnauthenticatedError,
|
||||||
DATA_ALREADY_EXISTS: DataAlreadyExistsError,
|
DATA_ALREADY_EXISTS: DataAlreadyExistsError,
|
||||||
|
RATE_LIMIT: RateLimitError,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<string, string | undefined>,
|
||||||
|
payload: unknown,
|
||||||
|
): Promise<void> {
|
||||||
|
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<string, string | undefined>, body)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
verifyKeyQueryRedis: (enabled: boolean) => ({
|
||||||
|
async beforeHandle({ headers, query }) {
|
||||||
|
if (!enabled) return
|
||||||
|
await authenticate(headers as Record<string, string | undefined>, query)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export default apiKeyAuthRedisMacro
|
||||||
@@ -44,6 +44,11 @@ export const defaultHandlerPlugin = new Elysia({
|
|||||||
message: error.message,
|
message: error.message,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
case 'RATE_LIMIT':
|
||||||
|
return status(429, {
|
||||||
|
message: error.message,
|
||||||
|
})
|
||||||
|
|
||||||
case 'FIELD_VALIDATION':
|
case 'FIELD_VALIDATION':
|
||||||
return status(422, handleFieldValidationError(error))
|
return status(422, handleFieldValidationError(error))
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"module": "es2022", /* Specify what module code is generated. */
|
"module": "es2022", /* Specify what module code is generated. */
|
||||||
// "rootDir": "./", /* Specify the root folder within your source files. */
|
// "rootDir": "./", /* Specify the root folder within your source files. */
|
||||||
"moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
|
"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. */
|
// "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. */
|
"paths": { /* Specify a set of entries that re-map imports to additional lookup locations. */
|
||||||
"~/*": ["./src/*"],
|
"~/*": ["./src/*"],
|
||||||
|
|||||||
Reference in New Issue
Block a user