archetecture security fix
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
import crypto from 'crypto'
|
||||
|
||||
export function generateCompanyApiKey() {
|
||||
const secret = crypto.randomBytes(32).toString('base64url')
|
||||
const prefix = `rdg_${crypto.randomBytes(6).toString('hex')}`
|
||||
const rawKey = `${prefix}_${secret}`
|
||||
return { rawKey, prefix, keyHash: hashApiKey(rawKey) }
|
||||
}
|
||||
|
||||
export function getApiKeyPrefix(rawKey: string) {
|
||||
const parts = rawKey.split('_')
|
||||
if (parts.length < 3) return null
|
||||
return `${parts[0]}_${parts[1]}`
|
||||
}
|
||||
|
||||
export function hashApiKey(rawKey: string) {
|
||||
return crypto.createHash('sha256').update(rawKey).digest('hex')
|
||||
}
|
||||
|
||||
export function timingSafeEqualHex(a: string, b: string) {
|
||||
const left = Buffer.from(a, 'hex')
|
||||
const right = Buffer.from(b, 'hex')
|
||||
if (left.length !== right.length) return false
|
||||
return crypto.timingSafeEqual(left, right)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { AdminRole } from '@rentaldrivego/database'
|
||||
|
||||
export const AdminPolicy = {
|
||||
impersonateCompany: ['SUPER_ADMIN'],
|
||||
manageAdmins: ['SUPER_ADMIN', 'ADMIN'],
|
||||
managePricingPlans: ['SUPER_ADMIN', 'ADMIN'],
|
||||
manageSubscriptions: ['SUPER_ADMIN', 'ADMIN', 'FINANCE'],
|
||||
performRefund: ['SUPER_ADMIN', 'ADMIN', 'FINANCE'],
|
||||
viewSensitiveCredentials: ['SUPER_ADMIN'],
|
||||
} as const satisfies Record<string, readonly AdminRole[]>
|
||||
|
||||
export type AdminPolicyAction = keyof typeof AdminPolicy
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { EmployeeRole } from '@rentaldrivego/database'
|
||||
|
||||
export const CompanyPolicy = {
|
||||
viewApiKey: ['OWNER'],
|
||||
regenerateApiKey: ['OWNER'],
|
||||
updateAccountingSettings: ['OWNER', 'MANAGER'],
|
||||
deletePricingRule: ['OWNER', 'MANAGER'],
|
||||
managePricingRules: ['OWNER', 'MANAGER'],
|
||||
manageTeam: ['OWNER', 'MANAGER'],
|
||||
viewBilling: ['OWNER', 'MANAGER'],
|
||||
manageBilling: ['OWNER'],
|
||||
} as const satisfies Record<string, readonly EmployeeRole[]>
|
||||
|
||||
export type CompanyPolicyAction = keyof typeof CompanyPolicy
|
||||
@@ -0,0 +1,17 @@
|
||||
import crypto from 'crypto'
|
||||
|
||||
export function generatePublicAccessToken() {
|
||||
const token = crypto.randomBytes(32).toString('base64url')
|
||||
return { token, tokenHash: hashPublicAccessToken(token) }
|
||||
}
|
||||
|
||||
export function hashPublicAccessToken(token: string) {
|
||||
return crypto.createHash('sha256').update(token).digest('hex')
|
||||
}
|
||||
|
||||
export function timingSafeEqualTokenHash(a: string, b: string) {
|
||||
const left = Buffer.from(a, 'hex')
|
||||
const right = Buffer.from(b, 'hex')
|
||||
if (left.length !== right.length) return false
|
||||
return crypto.timingSafeEqual(left, right)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Response } from 'express'
|
||||
import type { ActorType } from './tokens'
|
||||
|
||||
const COOKIE_NAMES: Record<ActorType, string> = {
|
||||
admin: 'admin_session',
|
||||
employee: 'employee_session',
|
||||
renter: 'renter_session',
|
||||
}
|
||||
|
||||
export function getSessionCookieName(actorType: ActorType) {
|
||||
return COOKIE_NAMES[actorType]
|
||||
}
|
||||
|
||||
export function setSessionCookie(res: Response, actorType: ActorType, token: string, maxAgeMs?: number) {
|
||||
res.cookie(getSessionCookieName(actorType), token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: actorType === 'admin' ? 'strict' : 'lax',
|
||||
path: '/',
|
||||
maxAge: maxAgeMs,
|
||||
})
|
||||
}
|
||||
|
||||
export function clearSessionCookie(res: Response, actorType: ActorType) {
|
||||
res.clearCookie(getSessionCookieName(actorType), {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: actorType === 'admin' ? 'strict' : 'lax',
|
||||
path: '/',
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import jwt from 'jsonwebtoken'
|
||||
|
||||
export type ActorType = 'admin' | 'employee' | 'renter'
|
||||
|
||||
export type ActorTokenPayload = jwt.JwtPayload & {
|
||||
sub: string
|
||||
type: ActorType
|
||||
sessionVersion?: number
|
||||
last2faAt?: number
|
||||
}
|
||||
|
||||
const ISSUER = 'rentaldrivego-api'
|
||||
const ALG: jwt.Algorithm[] = ['HS256']
|
||||
|
||||
function getJwtSecret() {
|
||||
const secret = process.env.JWT_SECRET
|
||||
if (!secret) throw new Error('JWT_SECRET is required')
|
||||
return secret
|
||||
}
|
||||
|
||||
export function signActorToken(
|
||||
actorId: string,
|
||||
actorType: ActorType,
|
||||
options: Pick<jwt.SignOptions, 'expiresIn'> & { sessionVersion?: number; last2faAt?: number } = {},
|
||||
) {
|
||||
const { sessionVersion, last2faAt, ...signOptions } = options
|
||||
return jwt.sign(
|
||||
{
|
||||
sub: actorId,
|
||||
type: actorType,
|
||||
...(sessionVersion === undefined ? {} : { sessionVersion }),
|
||||
...(last2faAt === undefined ? {} : { last2faAt }),
|
||||
},
|
||||
getJwtSecret(),
|
||||
{
|
||||
algorithm: 'HS256',
|
||||
issuer: ISSUER,
|
||||
audience: actorType,
|
||||
expiresIn: signOptions.expiresIn ?? '8h',
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export function verifyActorToken(token: string, expectedType: ActorType): ActorTokenPayload {
|
||||
const payload = jwt.verify(token, getJwtSecret(), {
|
||||
algorithms: ALG,
|
||||
issuer: ISSUER,
|
||||
audience: expectedType,
|
||||
}) as jwt.JwtPayload
|
||||
|
||||
if (typeof payload.sub !== 'string' || payload.type !== expectedType) {
|
||||
throw new Error('Invalid actor token')
|
||||
}
|
||||
|
||||
return payload as ActorTokenPayload
|
||||
}
|
||||
|
||||
export function verifyAnyActorToken(token: string): ActorTokenPayload {
|
||||
const decoded = jwt.decode(token) as jwt.JwtPayload | null
|
||||
const actorType = decoded?.type
|
||||
|
||||
if (actorType !== 'admin' && actorType !== 'employee' && actorType !== 'renter') {
|
||||
throw new Error('Invalid actor token')
|
||||
}
|
||||
|
||||
return verifyActorToken(token, actorType)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import crypto from 'crypto'
|
||||
|
||||
type WebhookStatus = 'PROCESSING' | 'PROCESSED' | 'FAILED'
|
||||
|
||||
type WebhookProcessInput<T> = {
|
||||
provider: string
|
||||
providerEventId: string
|
||||
eventType: string
|
||||
rawBody: string | Buffer
|
||||
handle: () => Promise<T>
|
||||
}
|
||||
|
||||
function payloadHash(rawBody: string | Buffer) {
|
||||
return crypto.createHash('sha256').update(rawBody).digest('hex')
|
||||
}
|
||||
|
||||
function getWebhookEventModel() {
|
||||
try {
|
||||
// Lazy load keeps isolated unit tests from requiring a generated Prisma client
|
||||
// before `prisma generate` has run in the local sandbox.
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { prisma } = require('../lib/prisma')
|
||||
return (prisma as any).webhookEvent as any | undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function findExisting(model: any, provider: string, providerEventId: string) {
|
||||
return model.findUnique({
|
||||
where: { provider_providerEventId: { provider, providerEventId } },
|
||||
})
|
||||
}
|
||||
|
||||
async function mark(model: any, id: string, status: WebhookStatus, errorMessage?: string) {
|
||||
await model.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status,
|
||||
processedAt: status === 'PROCESSED' ? new Date() : undefined,
|
||||
errorMessage: errorMessage?.slice(0, 1000),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function processWebhookOnce<T>({
|
||||
provider,
|
||||
providerEventId,
|
||||
eventType,
|
||||
rawBody,
|
||||
handle,
|
||||
}: WebhookProcessInput<T>): Promise<{ duplicate: boolean; result?: T }> {
|
||||
const model = getWebhookEventModel()
|
||||
if (!model) {
|
||||
const result = await handle()
|
||||
return { duplicate: false, result }
|
||||
}
|
||||
|
||||
const hash = payloadHash(rawBody)
|
||||
let eventRecord: any
|
||||
|
||||
try {
|
||||
eventRecord = await model.create({
|
||||
data: {
|
||||
provider,
|
||||
providerEventId,
|
||||
eventType,
|
||||
status: 'PROCESSING',
|
||||
payloadHash: hash,
|
||||
},
|
||||
})
|
||||
} catch (err: any) {
|
||||
const existing = await findExisting(model, provider, providerEventId)
|
||||
if (existing?.status === 'PROCESSED') return { duplicate: true }
|
||||
if (existing?.payloadHash && existing.payloadHash !== hash) {
|
||||
throw new Error('Webhook event ID replayed with a different payload hash')
|
||||
}
|
||||
if (existing?.status === 'PROCESSING') return { duplicate: true }
|
||||
eventRecord = existing
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handle()
|
||||
if (eventRecord?.id) await mark(model, eventRecord.id, 'PROCESSED')
|
||||
return { duplicate: false, result }
|
||||
} catch (err: any) {
|
||||
if (eventRecord?.id) await mark(model, eventRecord.id, 'FAILED', err?.message)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export function getWebhookEventId(provider: 'amanpay' | 'paypal', payload: any) {
|
||||
if (provider === 'paypal') {
|
||||
return String(payload.id ?? payload.event_id ?? `${payload.event_type ?? 'unknown'}:${payload.resource?.id ?? 'missing'}`)
|
||||
}
|
||||
return String(payload.event_id ?? payload.id ?? payload.transaction_id ?? 'missing')
|
||||
}
|
||||
Reference in New Issue
Block a user