Files
simstudioai--sim/apps/sim/lib/core/security/otp.ts
T
wehub-resource-sync d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

251 lines
7.5 KiB
TypeScript

import { randomInt } from 'crypto'
import { db } from '@sim/db'
import { verification } from '@sim/db/schema'
import { generateId } from '@sim/utils/id'
import { and, eq, gt } from 'drizzle-orm'
import { getRedisClient } from '@/lib/core/config/redis'
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
import { getStorageMethod } from '@/lib/core/storage'
export type DeploymentKind = 'chat' | 'file'
/**
* Shared OTP configuration for deployment email-auth gates (chat + public file shares).
*/
export const OTP_EXPIRY_SECONDS = 15 * 60
export const OTP_EXPIRY_MS = OTP_EXPIRY_SECONDS * 1000
export const MAX_OTP_ATTEMPTS = 5
export const OTP_IP_RATE_LIMIT: TokenBucketConfig = {
maxTokens: 10,
refillRate: 10,
refillIntervalMs: 15 * 60_000,
}
export const OTP_EMAIL_RATE_LIMIT: TokenBucketConfig = {
maxTokens: 3,
refillRate: 3,
refillIntervalMs: 15 * 60_000,
}
/**
* Key formats are kept per-kind to preserve any in-flight OTPs already issued
* against existing chat deployments. The chat Redis key uses the legacy `otp:`
* prefix; the chat DB identifier uses `chat-otp:`.
*/
const OTP_KEYS = {
chat: {
redisKey: (email: string, deploymentId: string) => `otp:${email}:${deploymentId}`,
dbIdentifier: (email: string, deploymentId: string) => `chat-otp:${deploymentId}:${email}`,
},
file: {
redisKey: (email: string, deploymentId: string) => `otp:file:${email}:${deploymentId}`,
dbIdentifier: (email: string, deploymentId: string) => `file-otp:${deploymentId}:${email}`,
},
} as const satisfies Record<
DeploymentKind,
{
redisKey: (email: string, deploymentId: string) => string
dbIdentifier: (email: string, deploymentId: string) => string
}
>
/** Returns a cryptographically random 6-digit OTP code. */
export function generateOTP(): string {
return randomInt(100000, 1000000).toString()
}
/**
* OTP values are stored as `"code:attempts"` (e.g. `"654321:0"`).
* This keeps the attempt counter in the same key/row as the OTP itself.
*/
function encodeOTPValue(otp: string, attempts: number): string {
return `${otp}:${attempts}`
}
export function decodeOTPValue(value: string): { otp: string; attempts: number } {
const lastColon = value.lastIndexOf(':')
if (lastColon === -1) return { otp: value, attempts: 0 }
const attempts = Number.parseInt(value.slice(lastColon + 1), 10)
return { otp: value.slice(0, lastColon), attempts: Number.isNaN(attempts) ? 0 : attempts }
}
/**
* Stores an OTP for a deployment+email pair, choosing Redis or the
* `verification` table based on the configured storage method.
*/
export async function storeOTP(
kind: DeploymentKind,
deploymentId: string,
email: string,
otp: string
): Promise<void> {
const keys = OTP_KEYS[kind]
const value = encodeOTPValue(otp, 0)
const storageMethod = getStorageMethod()
if (storageMethod === 'redis') {
const redis = getRedisClient()
if (!redis) throw new Error('Redis configured but client unavailable')
await redis.set(keys.redisKey(email, deploymentId), value, 'EX', OTP_EXPIRY_SECONDS)
return
}
const now = new Date()
const expiresAt = new Date(now.getTime() + OTP_EXPIRY_MS)
const identifier = keys.dbIdentifier(email, deploymentId)
await db.transaction(async (tx) => {
await tx.delete(verification).where(eq(verification.identifier, identifier))
await tx.insert(verification).values({
id: generateId(),
identifier,
value,
expiresAt,
createdAt: now,
updatedAt: now,
})
})
}
export async function getOTP(
kind: DeploymentKind,
deploymentId: string,
email: string
): Promise<string | null> {
const keys = OTP_KEYS[kind]
const storageMethod = getStorageMethod()
if (storageMethod === 'redis') {
const redis = getRedisClient()
if (!redis) throw new Error('Redis configured but client unavailable')
return redis.get(keys.redisKey(email, deploymentId))
}
const now = new Date()
const [record] = await db
.select({ value: verification.value })
.from(verification)
.where(
and(
eq(verification.identifier, keys.dbIdentifier(email, deploymentId)),
gt(verification.expiresAt, now)
)
)
.limit(1)
return record?.value ?? null
}
/**
* Lua script for atomic OTP attempt increment in Redis.
* Returns `'LOCKED'` if max attempts reached (key deleted), new encoded value
* otherwise, nil if key missing.
*/
const ATOMIC_INCREMENT_SCRIPT = `
local val = redis.call('GET', KEYS[1])
if not val then return nil end
local colon = val:find(':([^:]*$)')
local otp, attempts
if colon then
otp = val:sub(1, colon - 1)
attempts = tonumber(val:sub(colon + 1)) or 0
else
otp = val
attempts = 0
end
attempts = attempts + 1
if attempts >= tonumber(ARGV[1]) then
redis.call('DEL', KEYS[1])
return 'LOCKED'
end
local newVal = otp .. ':' .. attempts
local ttl = redis.call('TTL', KEYS[1])
if ttl > 0 then
redis.call('SET', KEYS[1], newVal, 'EX', ttl)
else
redis.call('SET', KEYS[1], newVal)
end
return newVal
`
/**
* Atomically increments an OTP's failed-attempt counter. Returns `'locked'`
* if the max-attempts threshold was reached (and the OTP was deleted), or
* `'incremented'` otherwise. The DB path uses optimistic locking with retry.
*/
export async function incrementOTPAttempts(
kind: DeploymentKind,
deploymentId: string,
email: string,
currentValue: string
): Promise<'locked' | 'incremented'> {
const keys = OTP_KEYS[kind]
const storageMethod = getStorageMethod()
if (storageMethod === 'redis') {
const redis = getRedisClient()
if (!redis) throw new Error('Redis configured but client unavailable')
const key = keys.redisKey(email, deploymentId)
const result = await redis.eval(ATOMIC_INCREMENT_SCRIPT, 1, key, MAX_OTP_ATTEMPTS)
if (result === null || result === 'LOCKED') return 'locked'
return 'incremented'
}
const identifier = keys.dbIdentifier(email, deploymentId)
const MAX_RETRIES = 3
let value = currentValue
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
const { otp, attempts } = decodeOTPValue(value)
const newAttempts = attempts + 1
if (newAttempts >= MAX_OTP_ATTEMPTS) {
await db.delete(verification).where(eq(verification.identifier, identifier))
return 'locked'
}
const newValue = encodeOTPValue(otp, newAttempts)
const updated = await db
.update(verification)
.set({ value: newValue, updatedAt: new Date() })
.where(and(eq(verification.identifier, identifier), eq(verification.value, value)))
.returning({ id: verification.id })
if (updated.length > 0) return 'incremented'
const fresh = await getOTP(kind, deploymentId, email)
if (!fresh) return 'locked'
value = fresh
}
/**
* Retry exhaustion under heavy DB-path contention: this request did not
* succeed in writing its own +1, so the stored count may not reflect it.
* Fail closed — invalidate the OTP rather than return `'incremented'` with
* a possibly-undercounted attempt total.
*/
await db.delete(verification).where(eq(verification.identifier, identifier))
return 'locked'
}
export async function deleteOTP(
kind: DeploymentKind,
deploymentId: string,
email: string
): Promise<void> {
const keys = OTP_KEYS[kind]
const storageMethod = getStorageMethod()
if (storageMethod === 'redis') {
const redis = getRedisClient()
if (!redis) throw new Error('Redis configured but client unavailable')
await redis.del(keys.redisKey(email, deploymentId))
return
}
await db
.delete(verification)
.where(eq(verification.identifier, keys.dbIdentifier(email, deploymentId)))
}