MVP for API Key authentication
This commit is contained in:
@@ -1,33 +1,68 @@
|
||||
// Clock skew window: ±5 minutes
|
||||
// const CLOCK_SKEW_MS = 5 * 60 * 1000
|
||||
import Elysia, { t } 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'
|
||||
|
||||
export const deviceGuard = () => ({
|
||||
beforeHandle(context: unknown) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { headers, set } = context as any
|
||||
// Validate X-API-Key
|
||||
const apiKey = headers['x-api-key']
|
||||
if (!apiKey || typeof apiKey !== 'string' || apiKey.trim() === '') {
|
||||
set.status = 401
|
||||
throw new Error('invalid_api_key')
|
||||
}
|
||||
export const apiKey = new Elysia().macro({
|
||||
verifyKey: {
|
||||
headers: t.Object({
|
||||
'x-api-key': t.String(),
|
||||
'x-nonce': t.String(),
|
||||
'x-signature': t.String(),
|
||||
'x-timestamp': t.Number(),
|
||||
}),
|
||||
|
||||
// Validate X-Timestamp
|
||||
// const timestampStr = headers['x-timestamp']
|
||||
// if (!timestampStr) {
|
||||
// set.status = 403
|
||||
// throw new Error('replay_attack_detected')
|
||||
// }
|
||||
// const timestamp = new Date(timestampStr)
|
||||
// if (isNaN(timestamp.getTime()) || timestampStr !== timestamp.toISOString()) {
|
||||
// set.status = 403
|
||||
// throw new Error('replay_attack_detected')
|
||||
// }
|
||||
// const now = new Date()
|
||||
// const diff = Math.abs(now.getTime() - timestamp.getTime())
|
||||
// if (diff > CLOCK_SKEW_MS) {
|
||||
// set.status = 403
|
||||
// throw new Error('replay_attack_detected')
|
||||
// }
|
||||
async beforeHandle({ headers, body }) {
|
||||
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) {
|
||||
throw new UnauthenticatedError('Missing auth headers')
|
||||
}
|
||||
|
||||
const tsNum = Number(timestamp)
|
||||
if (!Number.isFinite(tsNum)) {
|
||||
throw new UnauthenticatedError('Invalid timestamp')
|
||||
}
|
||||
const nowSec = Math.floor(Date.now() / 1000)
|
||||
if (Math.abs(nowSec - tsNum) > apiAuth.skewSeconds) {
|
||||
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) {
|
||||
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')
|
||||
}
|
||||
|
||||
db.insert(merchantNonces).values({
|
||||
merchantId: merchant.merchantId,
|
||||
nonce,
|
||||
})
|
||||
|
||||
if (!verify(body, signature, merchant.secretKey)) {
|
||||
throw new UnauthenticatedError('Invalid signature')
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user