MVP for API Key authentication
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
import { Elysia } from 'elysia'
|
||||
import deviceRouter from '~/modules/device/router'
|
||||
import authRouter from '~/modules/auth/router'
|
||||
|
||||
export const router = new Elysia({
|
||||
name: 'codebase.router',
|
||||
})
|
||||
.use(authRouter)
|
||||
.use(deviceRouter)
|
||||
.get('/', () => ({
|
||||
message: 'This service is running as expected.',
|
||||
|
||||
@@ -27,6 +27,12 @@ export const jwt = {
|
||||
},
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
|
||||
export const s3Configs = {
|
||||
config: {
|
||||
region: process.env.S3_REGION || 'us-east-1',
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
CREATE OR REPLACE FUNCTION generate_weekly_id(p_prefix text)
|
||||
RETURNS text
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_yy smallint;
|
||||
v_ww smallint;
|
||||
v_seq integer;
|
||||
BEGIN
|
||||
SELECT
|
||||
EXTRACT(ISOYEAR FROM CURRENT_DATE)::smallint,
|
||||
EXTRACT(WEEK FROM CURRENT_DATE)::smallint
|
||||
INTO v_yy, v_ww;
|
||||
|
||||
INSERT INTO weekly_id_counters (prefix, yy, ww, last_value)
|
||||
VALUES (p_prefix, v_yy, v_ww, 1)
|
||||
ON CONFLICT (prefix, yy, ww)
|
||||
DO UPDATE SET last_value = weekly_id_counters.last_value + 1
|
||||
RETURNING last_value INTO v_seq;
|
||||
|
||||
RETURN format(
|
||||
'%s-%02s%02s%04s',
|
||||
p_prefix,
|
||||
v_yy % 100,
|
||||
v_ww,
|
||||
v_seq
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "merchant_nonces" (
|
||||
"merchant_id" varchar(64) NOT NULL,
|
||||
"nonce" varchar(128) NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "merchant_nonces_merchant_id_nonce_pk" PRIMARY KEY("merchant_id","nonce")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "merchants" (
|
||||
"merchant_id" text PRIMARY KEY DEFAULT generate_weekly_id('MERCH') NOT NULL,
|
||||
"merchant_name" text NOT NULL,
|
||||
"contact_email" text NOT NULL,
|
||||
"contact_phone" text NOT NULL,
|
||||
"api_key" text NOT NULL,
|
||||
"secret_key" text NOT NULL,
|
||||
"is_active" boolean DEFAULT true NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"revoked_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "weekly_id_counters" (
|
||||
"prefix" text NOT NULL,
|
||||
"yy" smallint NOT NULL,
|
||||
"ww" smallint NOT NULL,
|
||||
"last_value" integer NOT NULL,
|
||||
CONSTRAINT "weekly_id_counters_prefix_yy_ww_pk" PRIMARY KEY("prefix","yy","ww")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "merchant_nonces" ADD CONSTRAINT "merchant_nonces_merchant_id_merchants_merchant_id_fk" FOREIGN KEY ("merchant_id") REFERENCES "public"."merchants"("merchant_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "merchants_secret_key_idx" ON "merchants" USING btree ("secret_key");--> statement-breakpoint
|
||||
CREATE INDEX "merchants_is_active_idx" ON "merchants" USING btree ("is_active");--> statement-breakpoint
|
||||
CREATE INDEX "merchants_contact_email_idx" ON "merchants" USING btree ("contact_email");--> statement-breakpoint
|
||||
CREATE INDEX "merchants_contact_phone_idx" ON "merchants" USING btree ("contact_phone");
|
||||
@@ -0,0 +1,358 @@
|
||||
{
|
||||
"id": "64d5c135-050b-402c-9bb5-e2eff6b17d93",
|
||||
"prevId": "7f01d0a9-3f79-476c-b885-b83b3191bdf8",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.merchant_nonces": {
|
||||
"name": "merchant_nonces",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"merchant_id": {
|
||||
"name": "merchant_id",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"nonce": {
|
||||
"name": "nonce",
|
||||
"type": "varchar(128)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"merchant_nonces_merchant_id_merchants_merchant_id_fk": {
|
||||
"name": "merchant_nonces_merchant_id_merchants_merchant_id_fk",
|
||||
"tableFrom": "merchant_nonces",
|
||||
"tableTo": "merchants",
|
||||
"columnsFrom": [
|
||||
"merchant_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"merchant_id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"merchant_nonces_merchant_id_nonce_pk": {
|
||||
"name": "merchant_nonces_merchant_id_nonce_pk",
|
||||
"columns": [
|
||||
"merchant_id",
|
||||
"nonce"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.merchants": {
|
||||
"name": "merchants",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"merchant_id": {
|
||||
"name": "merchant_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "generate_weekly_id('MERCH')"
|
||||
},
|
||||
"merchant_name": {
|
||||
"name": "merchant_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"contact_email": {
|
||||
"name": "contact_email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"contact_phone": {
|
||||
"name": "contact_phone",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"api_key": {
|
||||
"name": "api_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"secret_key": {
|
||||
"name": "secret_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"is_active": {
|
||||
"name": "is_active",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"revoked_at": {
|
||||
"name": "revoked_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"merchants_secret_key_idx": {
|
||||
"name": "merchants_secret_key_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "secret_key",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"merchants_is_active_idx": {
|
||||
"name": "merchants_is_active_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "is_active",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"merchants_contact_email_idx": {
|
||||
"name": "merchants_contact_email_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "contact_email",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"merchants_contact_phone_idx": {
|
||||
"name": "merchants_contact_phone_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "contact_phone",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"phone": {
|
||||
"name": "phone",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "varchar(127)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"google_id": {
|
||||
"name": "google_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "role",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'user'"
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"deleted_at": {
|
||||
"name": "deleted_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"users_email_unique": {
|
||||
"name": "users_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.weekly_id_counters": {
|
||||
"name": "weekly_id_counters",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"prefix": {
|
||||
"name": "prefix",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"yy": {
|
||||
"name": "yy",
|
||||
"type": "smallint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"ww": {
|
||||
"name": "ww",
|
||||
"type": "smallint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"last_value": {
|
||||
"name": "last_value",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"weekly_id_counters_prefix_yy_ww_pk": {
|
||||
"name": "weekly_id_counters_prefix_yy_ww_pk",
|
||||
"columns": [
|
||||
"prefix",
|
||||
"yy",
|
||||
"ww"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.role": {
|
||||
"name": "role",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"admin",
|
||||
"visitor",
|
||||
"user",
|
||||
"volunteer"
|
||||
]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,13 @@
|
||||
"when": 1764313178750,
|
||||
"tag": "1764313178_flimsy_tarantula",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1764747189387,
|
||||
"tag": "1764747189_brave_kree",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import users, * as userSchema from './schema/users'
|
||||
import merchants, * as merchantSchema from './schema/merchants'
|
||||
import merchantNonces, * as merchantNonceSchema from './schema/merchant_nonces'
|
||||
|
||||
// relation
|
||||
// import * as relations from './relations'
|
||||
@@ -6,10 +8,14 @@ import users, * as userSchema from './schema/users'
|
||||
export const schema = {
|
||||
// ...relations,
|
||||
...userSchema,
|
||||
...merchantSchema,
|
||||
...merchantNonceSchema,
|
||||
}
|
||||
|
||||
export const table = {
|
||||
users,
|
||||
merchants,
|
||||
merchantNonces,
|
||||
} as const
|
||||
|
||||
export type Table = typeof table
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// db/schema.ts
|
||||
import { pgTable, varchar, timestamp, primaryKey } from 'drizzle-orm/pg-core'
|
||||
import merchants from './merchants'
|
||||
|
||||
export const merchantNonces = pgTable('merchant_nonces', {
|
||||
merchantId: varchar('merchant_id', { length: 64 })
|
||||
.notNull()
|
||||
.references(() => merchants.merchantId, { onDelete: 'cascade' }),
|
||||
nonce: varchar('nonce', { length: 128 }).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (t) => [
|
||||
primaryKey({ columns: [t.merchantId, t.nonce] }),
|
||||
])
|
||||
|
||||
export default merchantNonces
|
||||
@@ -0,0 +1,22 @@
|
||||
// db/schema.ts
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { pgTable, text, boolean, timestamp, index } from 'drizzle-orm/pg-core'
|
||||
|
||||
export const merchants = pgTable('merchants', {
|
||||
merchantId: text('merchant_id').primaryKey().notNull().default(sql<string>`generate_weekly_id('MERCH')`),
|
||||
merchantName: text('merchant_name').notNull(),
|
||||
contactEmail: text('contact_email').notNull(),
|
||||
contactPhone: text('contact_phone').notNull(),
|
||||
apiKey: text('api_key').notNull(),
|
||||
secretKey: text('secret_key').notNull(),
|
||||
isActive: boolean('is_active').notNull().default(true),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
revokedAt: timestamp('revoked_at', { withTimezone: true }),
|
||||
}, (t) => [
|
||||
index('merchants_secret_key_idx').on(t.secretKey),
|
||||
index('merchants_is_active_idx').on(t.isActive),
|
||||
index('merchants_contact_email_idx').on(t.contactEmail),
|
||||
index('merchants_contact_phone_idx').on(t.contactPhone),
|
||||
])
|
||||
|
||||
export default merchants
|
||||
@@ -0,0 +1,14 @@
|
||||
import { integer, pgTable, primaryKey, smallint, text } from 'drizzle-orm/pg-core'
|
||||
|
||||
export const weeklyIdCounters = pgTable(
|
||||
'weekly_id_counters',
|
||||
{
|
||||
prefix: text('prefix').notNull(),
|
||||
yy: smallint('yy').notNull(),
|
||||
ww: smallint('ww').notNull(),
|
||||
lastValue: integer('last_value').notNull(),
|
||||
},
|
||||
(t) => [
|
||||
primaryKey({ columns: [t.prefix, t.yy, t.ww] }),
|
||||
],
|
||||
)
|
||||
+36
-19
@@ -1,7 +1,8 @@
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto'
|
||||
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
|
||||
import { apiAuth } from '~/config'
|
||||
|
||||
/**
|
||||
* Canonicalizes an object by recursively removing 'signature' properties,
|
||||
* Canonicalizes an object by recursively removing 'signature', 'nonce', and 'timestamp' properties,
|
||||
* sorting object keys lexicographically, preserving array order,
|
||||
* omitting undefined, functions, and symbols from objects,
|
||||
* converting undefined or unsupported array elements to null,
|
||||
@@ -10,7 +11,7 @@ import { createHmac, timingSafeEqual } from 'node:crypto'
|
||||
* Throws on circular references or unsupported objects.
|
||||
*
|
||||
* @param data The data to canonicalize.
|
||||
* @returns The canonicalized, signature-free structure.
|
||||
* @returns The canonicalized structure with signature, nonce, and timestamp omitted.
|
||||
*/
|
||||
export function canonicalizeObject<T = unknown>(data: T): unknown {
|
||||
const visited = new Set<unknown>()
|
||||
@@ -60,7 +61,7 @@ export function canonicalizeObject<T = unknown>(data: T): unknown {
|
||||
const result: Record<string, unknown> = {}
|
||||
|
||||
for (const key in obj) {
|
||||
if (Object.prototype.hasOwnProperty.call(obj, key) && typeof key === 'string' && key !== 'signature') {
|
||||
if (Object.prototype.hasOwnProperty.call(obj, key) && typeof key === 'string' && key !== 'signature' && key !== 'nonce' && key !== 'timestamp') {
|
||||
const val = obj[key]
|
||||
if (val !== undefined && typeof val !== 'function' && typeof val !== 'symbol') {
|
||||
result[key] = recurse(val)
|
||||
@@ -109,21 +110,13 @@ export function canonicalize(data: unknown): string {
|
||||
* Signs the data using HMAC-SHA256 and returns the signature as lowercase hex.
|
||||
*
|
||||
* @param data The data to sign.
|
||||
* @param secretKey The secret key. If not provided, uses process.env.SECRET_KEY.
|
||||
* @param secretKey The secret key.
|
||||
* @returns The signature.
|
||||
*/
|
||||
export function sign(data: unknown, secretKey?: string): string {
|
||||
if (!secretKey) {
|
||||
secretKey = process.env.SECRET_KEY
|
||||
if (!secretKey || secretKey.trim() === '') {
|
||||
throw new Error('Secret key is required. Provide it as a parameter or set process.env.SECRET_KEY.')
|
||||
}
|
||||
}
|
||||
|
||||
const canonical = canonicalize(data)
|
||||
const hmac = createHmac('sha256', secretKey)
|
||||
hmac.update(canonical, 'utf8')
|
||||
return hmac.digest('hex')
|
||||
export function sign(data: unknown, secretKey: string): string {
|
||||
return createHmac('sha256', secretKey)
|
||||
.update(canonicalize(data), 'utf8')
|
||||
.digest('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -131,10 +124,10 @@ export function sign(data: unknown, secretKey?: string): string {
|
||||
*
|
||||
* @param data The data to verify.
|
||||
* @param signature The expected signature.
|
||||
* @param secretKey The secret key. If not provided, uses process.env.SECRET_KEY.
|
||||
* @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, signature: string, secretKey: string): boolean {
|
||||
try {
|
||||
const computed = sign(data, secretKey)
|
||||
const computedBuf = Buffer.from(computed, 'hex')
|
||||
@@ -148,6 +141,30 @@ export function verify(data: unknown, signature: string, secretKey?: string): bo
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an API key.
|
||||
*
|
||||
* @param bytes The number of bytes to generate.
|
||||
* @returns The secret key.
|
||||
*/
|
||||
export function generateApiKey(bytes = 16): string {
|
||||
const raw = randomBytes(bytes)
|
||||
const hex = raw.toString('base64url')
|
||||
return `${apiAuth.apiPrefix}:${hex}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a secret key for HMAC-SHA256.
|
||||
*
|
||||
* @param bytes The number of bytes to generate.
|
||||
* @returns The secret key.
|
||||
*/
|
||||
export function generateSecretKey(bytes = 32): string {
|
||||
const raw = randomBytes(bytes)
|
||||
const hex = raw.toString('base64url')
|
||||
return `${apiAuth.secretPrefix}:${hex}`
|
||||
}
|
||||
|
||||
// console.table([
|
||||
// { act: 'command', signature: sign(
|
||||
// {
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { db, table } from '~/db'
|
||||
import { generateApiKey, generateSecretKey } from '~/helpers/signature'
|
||||
import { DataAlreadyExistsError } from '~/helpers/errors'
|
||||
import { CreateMerchantBody } from '../schema'
|
||||
import { checkMerchantExists } from '../queries/query'
|
||||
|
||||
export async function createMerchant(body: CreateMerchantBody) {
|
||||
// Check if merchant with same email or phone exists
|
||||
const exists = await checkMerchantExists(body.contactEmail, body.contactPhone)
|
||||
if (exists) {
|
||||
throw new DataAlreadyExistsError('Merchant with this email or phone already exists')
|
||||
}
|
||||
|
||||
// Generate keys
|
||||
const apiKey = generateApiKey()
|
||||
const secretKey = generateSecretKey()
|
||||
|
||||
// Insert new merchant
|
||||
const result = await db
|
||||
.insert(table.merchants)
|
||||
.values({
|
||||
merchantName: body.merchantName,
|
||||
contactEmail: body.contactEmail,
|
||||
contactPhone: body.contactPhone,
|
||||
apiKey,
|
||||
secretKey,
|
||||
})
|
||||
.returning({
|
||||
merchantId: table.merchants.merchantId,
|
||||
apiKey: table.merchants.apiKey,
|
||||
secretKey: table.merchants.secretKey,
|
||||
createdAt: table.merchants.createdAt,
|
||||
})
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new Error('Failed to create merchant')
|
||||
}
|
||||
|
||||
return result[0]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createMerchant } from './command'
|
||||
import { CreateMerchantBody } from '../schema'
|
||||
|
||||
export default abstract class CreateMerchantService {
|
||||
static async createMerchant(body: CreateMerchantBody): Promise<{
|
||||
merchantId: string
|
||||
apiKey: string
|
||||
secretKey: string
|
||||
}> {
|
||||
return await createMerchant(body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { eq, or } from 'drizzle-orm'
|
||||
import { db, table } from '~/db'
|
||||
|
||||
export async function checkMerchantExists(email: string, phone: string) {
|
||||
const existingMerchant = await db.$count(table.merchants, or(
|
||||
eq(table.merchants.contactEmail, email),
|
||||
eq(table.merchants.contactPhone, phone),
|
||||
))
|
||||
|
||||
return existingMerchant > 0
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Elysia } from 'elysia'
|
||||
import { createMerchantBody, createMerchantResponseSchema } from './schema'
|
||||
import CreateMerchantService from './commands/service'
|
||||
import { basicAuthMiddleware } from '~/middlewares/basicAuth'
|
||||
import { DataAlreadyExistsError } from '~/helpers/errors'
|
||||
|
||||
export const router = new Elysia({
|
||||
name: 'modules.auth',
|
||||
detail: { tags: ['Auth'] },
|
||||
prefix: '/auth',
|
||||
})
|
||||
.post('/get-key', async ({ body, headers, set }) => {
|
||||
basicAuthMiddleware(headers, set)
|
||||
|
||||
try {
|
||||
const result = await CreateMerchantService.createMerchant(body)
|
||||
return {
|
||||
status: 'success' as const,
|
||||
message: 'Merchant created successfully' as const,
|
||||
data: result,
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof DataAlreadyExistsError) {
|
||||
set.status = 409
|
||||
return {
|
||||
status: 'conflict' as const,
|
||||
message: 'Merchant with this email or phone already exists' as const,
|
||||
}
|
||||
}
|
||||
set.status = 500
|
||||
return {
|
||||
status: 'internal_error' as const,
|
||||
message: 'Server error' as const,
|
||||
}
|
||||
}
|
||||
}, {
|
||||
body: createMerchantBody,
|
||||
response: createMerchantResponseSchema,
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,34 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
// Request body schema
|
||||
export const createMerchantBody = z.object({
|
||||
merchantName: z.string(),
|
||||
contactEmail: z.string().email(),
|
||||
contactPhone: z.string(),
|
||||
})
|
||||
export type CreateMerchantBody = z.infer<typeof createMerchantBody>
|
||||
|
||||
// Response schemas
|
||||
export const createMerchantResponseSchema = {
|
||||
200: z.object({
|
||||
status: z.literal('success'),
|
||||
message: z.literal('Merchant created successfully'),
|
||||
data: z.object({
|
||||
merchantId: z.string(),
|
||||
apiKey: z.string(),
|
||||
secretKey: z.string(),
|
||||
}),
|
||||
}),
|
||||
400: z.object({
|
||||
status: z.literal('invalid_request'),
|
||||
message: z.literal('Invalid request format'),
|
||||
}),
|
||||
409: z.object({
|
||||
status: z.literal('conflict'),
|
||||
message: z.literal('Merchant with this email or phone already exists'),
|
||||
}),
|
||||
500: z.object({
|
||||
status: z.literal('internal_error'),
|
||||
message: z.literal('Server error'),
|
||||
}),
|
||||
}
|
||||
@@ -6,7 +6,7 @@ interface OpenApiDocWithTagGroups { 'x-tagGroups': { name: string, tags: string[
|
||||
type OpenApiDocumentation = Omit<Partial<OpenAPIV3.Document<OpenApiDocWithTagGroups>>, 'x-express-openapi-additional-middleware' | 'x-express-openapi-validation-strict'>
|
||||
|
||||
export const swaggerPlugin = openapi({
|
||||
path: '/swagger',
|
||||
path: '/docs',
|
||||
provider: 'scalar',
|
||||
mapJsonSchema: {
|
||||
zod: z.toJSONSchema,
|
||||
@@ -17,10 +17,12 @@ export const swaggerPlugin = openapi({
|
||||
version: 'v1.0.0',
|
||||
},
|
||||
'tags': [
|
||||
{ name: 'Auth', description: 'Auth API' },
|
||||
{ name: 'Device', description: 'Device API' },
|
||||
],
|
||||
// --- Grouping di atas tag ---
|
||||
'x-tagGroups': [
|
||||
{ name: 'Auth', tags: ['Auth'] },
|
||||
{ name: 'Device', tags: ['Device'] },
|
||||
],
|
||||
'components': {
|
||||
|
||||
Reference in New Issue
Block a user