Merge branch 'feature/auth'
This commit is contained in:
@@ -102,8 +102,13 @@ export function canonicalizeObject<T = unknown>(data: T): unknown {
|
|||||||
* @param data The data to canonicalize.
|
* @param data The data to canonicalize.
|
||||||
* @returns The canonical JSON string.
|
* @returns The canonical JSON string.
|
||||||
*/
|
*/
|
||||||
export function canonicalize(data: unknown): string {
|
export function canonicalize(data: unknown, timestamp: number, nonce: string): string {
|
||||||
return JSON.stringify(canonicalizeObject(data))
|
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.
|
* @param secretKey The secret key.
|
||||||
* @returns The signature.
|
* @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)
|
return createHmac('sha256', secretKey)
|
||||||
.update(canonicalize(data), 'utf8')
|
.update(canonicalize(data, timestamp, nonce), 'utf8')
|
||||||
.digest('hex')
|
.digest('hex')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,9 +132,9 @@ export function sign(data: unknown, secretKey: string): string {
|
|||||||
* @param secretKey The secret key.
|
* @param secretKey The secret key.
|
||||||
* @returns True if the signature is valid, false otherwise.
|
* @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 {
|
try {
|
||||||
const computed = sign(data, secretKey)
|
const computed = sign(data, timestamp, nonce, secretKey)
|
||||||
const computedBuf = Buffer.from(computed, 'hex')
|
const computedBuf = Buffer.from(computed, 'hex')
|
||||||
const providedBuf = Buffer.from(signature, 'hex')
|
const providedBuf = Buffer.from(signature, 'hex')
|
||||||
if (computedBuf.length !== providedBuf.length) {
|
if (computedBuf.length !== providedBuf.length) {
|
||||||
|
|||||||
+119
-22
@@ -1,35 +1,123 @@
|
|||||||
import Elysia, { t } from 'elysia'
|
import Elysia, { HTTPHeaders, StatusMap } from 'elysia'
|
||||||
import { db, table as $t } from '~/db'
|
import { db, table as $t } from '~/db'
|
||||||
import { and, eq } from 'drizzle-orm'
|
import { and, eq } from 'drizzle-orm'
|
||||||
import { apiAuth } from '~/config'
|
import { apiAuth } from '~/config'
|
||||||
import merchantNonces from '~/db/schema/merchant_nonces'
|
import merchantNonces from '~/db/schema/merchant_nonces'
|
||||||
import { verify } from '~/helpers/signature'
|
import { verify } from '~/helpers/signature'
|
||||||
import { UnauthenticatedError } from '~/helpers/errors'
|
import { UnauthenticatedError } from '~/helpers/errors'
|
||||||
|
import { logger } from '~/plugins'
|
||||||
|
|
||||||
export const apiKeyAuth = new Elysia().macro({
|
export const apiKeyAuthMiddleware = async (
|
||||||
verifyKey: {
|
headers: Record<string, string | undefined>,
|
||||||
headers: t.Object({
|
body: unknown,
|
||||||
'x-api-key': t.String(),
|
set: {
|
||||||
'x-nonce': t.String(),
|
headers: HTTPHeaders
|
||||||
'x-signature': t.String(),
|
status?: number | keyof StatusMap
|
||||||
'x-timestamp': t.Number(),
|
},
|
||||||
}),
|
) => {
|
||||||
body: t.Unknown(),
|
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 }) {
|
async beforeHandle({ headers, body }) {
|
||||||
|
if (!enabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const apiKey = headers['x-api-key']
|
const apiKey = headers['x-api-key']
|
||||||
const nonce = headers['x-nonce']
|
const nonce = headers['x-nonce']
|
||||||
const signature = headers['x-signature']
|
const signature = headers['x-signature']
|
||||||
const timestamp = headers['x-timestamp']
|
const rawTimestamp = headers['x-timestamp']
|
||||||
if (!apiKey || !nonce || !signature || !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')
|
throw new UnauthenticatedError('Missing auth headers')
|
||||||
}
|
}
|
||||||
|
|
||||||
const tsNum = Number(timestamp)
|
const timestamp = Number(rawTimestamp)
|
||||||
if (!Number.isFinite(tsNum)) {
|
if (!Number.isFinite(timestamp)) {
|
||||||
|
logger.debug({
|
||||||
|
'headers.authorization': headers.authorization,
|
||||||
|
}, 'Failed API key authentication attempt due to invalid timestamp')
|
||||||
throw new UnauthenticatedError('Invalid timestamp')
|
throw new UnauthenticatedError('Invalid timestamp')
|
||||||
}
|
}
|
||||||
const nowSec = Math.floor(Date.now() / 1000)
|
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')
|
throw new UnauthenticatedError('Timestamp out of range')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,29 +128,38 @@ export const apiKeyAuth = new Elysia().macro({
|
|||||||
),
|
),
|
||||||
})
|
})
|
||||||
if (!merchant) {
|
if (!merchant) {
|
||||||
|
logger.debug({
|
||||||
|
'headers.authorization': headers.authorization,
|
||||||
|
}, 'Failed API key authentication attempt due to invalid API key')
|
||||||
throw new UnauthenticatedError('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(
|
const existingNonce = await db.$count($t.merchantNonces, and(
|
||||||
eq($t.merchantNonces.merchantId, merchant.merchantId),
|
eq($t.merchantNonces.merchantId, merchant.merchantId),
|
||||||
eq($t.merchantNonces.nonce, nonce),
|
eq($t.merchantNonces.nonce, nonce),
|
||||||
))
|
))
|
||||||
if (existingNonce > 0) {
|
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({
|
db.insert(merchantNonces).values({
|
||||||
merchantId: merchant.merchantId,
|
merchantId: merchant.merchantId,
|
||||||
nonce,
|
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')
|
throw new UnauthenticatedError('Invalid signature')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Elysia } from 'elysia'
|
import { Elysia, t } from 'elysia'
|
||||||
import { createMerchantBody, createMerchantResponseSchema } from './schema'
|
import { createMerchantBody, createMerchantResponseSchema } from './schema'
|
||||||
import CreateMerchantService from './commands/service'
|
import CreateMerchantService from './commands/service'
|
||||||
import { basicAuthMiddleware } from '~/middlewares/basicAuth'
|
import { basicAuthMiddleware } from '~/middlewares/basicAuth'
|
||||||
import { DataAlreadyExistsError } from '~/helpers/errors'
|
import { DataAlreadyExistsError } from '~/helpers/errors'
|
||||||
import { apiKeyAuth } from '~/middlewares/apiKeyAuth'
|
import { apiKeyAuthMacro } from '~/middlewares/apiKeyAuth'
|
||||||
|
|
||||||
export const router = new Elysia({
|
export const router = new Elysia({
|
||||||
name: 'modules.auth',
|
name: 'modules.auth',
|
||||||
@@ -41,13 +41,23 @@ export const router = new Elysia({
|
|||||||
security: [{ basicAuth: [] }],
|
security: [{ basicAuth: [] }],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.use(apiKeyAuth)
|
.use(apiKeyAuthMacro)
|
||||||
.get('/test-key', async ({ body }) => {
|
.post('/test-key', async ({ body }) => {
|
||||||
return body
|
return body
|
||||||
}, {
|
}, {
|
||||||
verifyKey: true,
|
verifyKey: true,
|
||||||
detail: {
|
body: t.Object({
|
||||||
security: [{ basicAuth: [] }],
|
foo: t.String(),
|
||||||
|
bar: t.Object({
|
||||||
|
lorem: t.Integer(),
|
||||||
|
ipsum: t.Boolean(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: t.Any(),
|
||||||
|
401: t.Object({
|
||||||
|
message: t.String(),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user