Files
halobestie-clone/backend/src/services/otp.service.js
Ramadhan Sjamsani 6fd98ca99c OTP overhaul: test-user bypass + hash-at-rest + Fazpass integration
- Test-OTP bypass allowlist for Apple reviewers / QA: phone-scoped static OTPs
  managed in CC (Settings → Test OTP Bypass), bcrypt-hashed on save, kill-switch
  toggle, per-entry expires_at. New `otp_requests` columns (is_bypass, code_hash)
  + DB CHECK enforcing bypass-row shape.
- Hash-at-rest for stub OTPs: replaced plaintext `<ref>:<code>` storage with
  bcrypt(code_hash); reference goes to fazpass_reference alone. Verify routes on
  sovereign is_bypass flag, defers code_hash-NULL rows to Fazpass.
- Fazpass integration (gated by FAZPASS_ENABLED env, default off): new
  fazpass.service.js calling /v1/otp/{request,verify}; distinct errors for wrong
  OTP (CODE_MISMATCH 401) vs provider outage (OTP_PROVIDER_FAILED 502).
- Removed redundant Free Trial CC section (was a back-compat shim for the same
  pricing_promotions row as "Diskon Sesi Pertama") + unused alias in
  pricing.service.js.

208 tests green (34 new for OTP + Fazpass). Fazpass API + dashboard PDFs added
at project root for reference (docs are auth-gated).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 22:39:34 +08:00

368 lines
14 KiB
JavaScript

import crypto from 'node:crypto'
import bcrypt from 'bcrypt'
import { getDb } from '../db/client.js'
import { getOtpRateLimits, getTestOtpBypassMatch, getFazpassConfig } from './config.service.js'
import { fazpassRequestOtp, fazpassVerifyOtp, FazpassError } from './fazpass.service.js'
import { OtpChannel, UserType } from '../constants.js'
const sql = getDb()
const OTP_TTL_MINUTES = 5
// bcrypt cost for OTP codes. Lower than password (12) because OTPs live 5 min
// and the verify call happens once per attempt — total budget ~80ms per verify
// is fine, and the lower cost makes the verify SLA tighter on slow Cloud Run
// cold starts.
const OTP_BCRYPT_COST = 10
// -------------------------------------------------------------------
// Fazpass integration — STUB until real API docs are obtained.
//
// In production, Fazpass is the source of truth for the OTP code: the backend
// never sees the plaintext code. The stub generates a 6-digit code locally,
// bcrypt-hashes it into otp_requests.code_hash, and ships the plaintext only
// to in-memory (for /peek-otp dev convenience) and to the dev console log.
//
// When real docs arrive: replace fazpassSendStub with a real HTTP call, and
// stop writing code_hash on the normal path (Fazpass owns verification then).
// The bypass path keeps writing code_hash exactly as it does today — that's
// the only place backend-owned verification survives post-cutover.
// -------------------------------------------------------------------
const generate6DigitCode = () => {
// Dev escape hatch: when OTP_STATIC_CODE is set (6 digits), every stub OTP
// returns this exact value. Lets manual testers skip the peek round-trip.
// Leave unset in production — real Fazpass owns the code there.
const staticCode = process.env.OTP_STATIC_CODE
if (staticCode && /^\d{6}$/.test(staticCode)) return staticCode
// Avoid Math.random for OTP generation — use crypto.randomInt
return String(crypto.randomInt(0, 1_000_000)).padStart(6, '0')
}
// Dev-only in-memory cache of latest stub OTP per phone, read by the
// /internal/_test/peek-otp endpoint to make Maestro flows deterministic
// without baking test phone numbers into production code paths.
const stubOtpByPhone = new Map()
export const peekStubOtp = (phone) => stubOtpByPhone.get(phone) ?? null
const fazpassSendStub = async ({ phone, channel }) => {
const reference = `stub_${crypto.randomUUID()}`
const code = generate6DigitCode()
stubOtpByPhone.set(phone, { code, reference, channel, generated_at: new Date().toISOString() })
// Log the code so developers can read it during dev testing.
// eslint-disable-next-line no-console
console.log(`[OTP STUB] phone=${phone} channel=${channel} code=${code} ref=${reference}`)
return { reference, channel_used: channel, code } // `code` only present in stub
}
// -------------------------------------------------------------------
export class OtpError extends Error {
constructor(message, code, statusCode, details = null) {
super(message)
this.code = code
this.statusCode = statusCode
this.details = details
}
}
// Returns seconds until the oldest of N most-recent matching requests falls
// out of the 1-hour rolling window — i.e. when the next slot opens up.
const computeRetryAfterFromRollingWindow = async (whereClauseFragment) => {
const [row] = await whereClauseFragment
if (!row) return null
const oldestTs = new Date(row.created_at).getTime()
const slotOpensAt = oldestTs + 60 * 60 * 1000
return Math.max(1, Math.ceil((slotOpensAt - Date.now()) / 1000))
}
const checkRateLimits = async ({ phone, ipAddress, limits }) => {
// Resend cooldown
const [lastRow] = await sql`
SELECT created_at FROM otp_requests
WHERE phone = ${phone}
ORDER BY created_at DESC LIMIT 1
`
if (lastRow) {
const elapsed = (Date.now() - new Date(lastRow.created_at).getTime()) / 1000
if (elapsed < limits.resend_cooldown_seconds) {
const retryAfter = Math.ceil(limits.resend_cooldown_seconds - elapsed)
throw new OtpError(
`Please wait ${retryAfter}s before requesting another OTP`,
'OTP_COOLDOWN', 429,
{ retry_after_seconds: retryAfter },
)
}
}
// Per-phone hourly limit
const [{ phone_count }] = await sql`
SELECT COUNT(*)::int AS phone_count FROM otp_requests
WHERE phone = ${phone} AND created_at >= NOW() - INTERVAL '1 hour'
`
if (phone_count >= limits.max_per_phone_per_hour) {
const retryAfter = await computeRetryAfterFromRollingWindow(sql`
SELECT created_at FROM otp_requests
WHERE phone = ${phone} AND created_at >= NOW() - INTERVAL '1 hour'
ORDER BY created_at ASC LIMIT 1
`)
throw new OtpError(
'Too many OTP requests for this number', 'OTP_RATE_LIMIT_PHONE', 429,
retryAfter ? { retry_after_seconds: retryAfter } : null,
)
}
// Per-IP hourly limit (only if ip provided)
if (ipAddress) {
const [{ ip_count }] = await sql`
SELECT COUNT(*)::int AS ip_count FROM otp_requests
WHERE ip_address = ${ipAddress} AND created_at >= NOW() - INTERVAL '1 hour'
`
if (ip_count >= limits.max_per_ip_per_hour) {
const retryAfter = await computeRetryAfterFromRollingWindow(sql`
SELECT created_at FROM otp_requests
WHERE ip_address = ${ipAddress} AND created_at >= NOW() - INTERVAL '1 hour'
ORDER BY created_at ASC LIMIT 1
`)
throw new OtpError(
'Too many OTP requests from this network', 'OTP_RATE_LIMIT_IP', 429,
retryAfter ? { retry_after_seconds: retryAfter } : null,
)
}
}
}
/**
* Start an OTP flow. Returns { otp_request_id, channel_used, expires_at }.
* Does NOT return the code to the caller.
*/
export const requestOtp = async ({ phone, userType, ipAddress, channel = OtpChannel.WHATSAPP, logger }) => {
if (!phone || !/^\+[1-9]\d{6,14}$/.test(phone)) {
throw new OtpError('Invalid phone format (E.164 expected)', 'PHONE_INVALID', 422)
}
if (userType !== UserType.CUSTOMER && userType !== UserType.MITRA) {
throw new OtpError('Invalid user type', 'USER_TYPE_INVALID', 400)
}
const limits = await getOtpRateLimits()
await checkRateLimits({ phone, ipAddress, limits })
// Test-user bypass: when this phone is in the CC-managed allowlist,
// plant a pre-hashed static OTP and skip Fazpass entirely. Logged loudly so
// any successful bypass is visible in audit pipelines.
const bypassEntry = await getTestOtpBypassMatch({ phone, userType })
if (bypassEntry) {
const [row] = await sql`
INSERT INTO otp_requests (
phone, ip_address, user_type, fazpass_reference, channel, expires_at,
is_bypass, code_hash
)
VALUES (
${phone}, ${ipAddress ?? null}, ${userType}, NULL, ${channel},
NOW() + (${OTP_TTL_MINUTES} || ' minutes')::interval,
TRUE, ${bypassEntry.otp_hash}
)
RETURNING id, expires_at
`
if (logger) {
logger.info({
event: 'test_otp_bypass.request',
otp_request_id: row.id,
label: bypassEntry.label,
phone_last4: phone.slice(-4),
user_type: userType,
}, 'test OTP bypass triggered')
}
return {
otp_request_id: row.id,
channel_used: channel,
expires_at: row.expires_at,
}
}
// Live Fazpass path. Provider owns the code AND verification — we only
// hold the reference. code_hash MUST stay NULL so verifyOtp's branching
// routes this row to Fazpass (the DB CHECK constraint also relies on the
// is_bypass=false shape we set here).
const fazpass = getFazpassConfig()
if (fazpass.enabled) {
const { reference, channel_used: providerChannel, provider } = await fazpassRequestOtp({
phone, logger,
})
if (logger) {
logger.info({
event: 'fazpass.request.ok',
phone_last4: phone.slice(-4),
provider, provider_channel: providerChannel, requested_channel: channel,
}, 'Fazpass OTP request succeeded')
}
const [row] = await sql`
INSERT INTO otp_requests (
phone, ip_address, user_type, fazpass_reference, channel, expires_at,
is_bypass, code_hash
)
VALUES (
${phone}, ${ipAddress ?? null}, ${userType}, ${reference}, ${channel},
NOW() + (${OTP_TTL_MINUTES} || ' minutes')::interval,
FALSE, NULL
)
RETURNING id, expires_at
`
return {
otp_request_id: row.id,
// Echo the client-requested channel for backwards compatibility — apps
// already render this in user-facing strings. Provider's internal
// channel code lives in logs only.
channel_used: channel,
expires_at: row.expires_at,
}
}
// Stub fallback (FAZPASS_ENABLED=false). Generates a local 6-digit code,
// stores its bcrypt hash, and lets the in-memory peek endpoint expose the
// plaintext for Maestro / dev. Removed once Fazpass is the only path.
const { reference, channel_used, code } = await fazpassSendStub({ phone, channel })
const codeHash = await bcrypt.hash(code, OTP_BCRYPT_COST)
const [row] = await sql`
INSERT INTO otp_requests (
phone, ip_address, user_type, fazpass_reference, channel, expires_at,
is_bypass, code_hash
)
VALUES (
${phone}, ${ipAddress ?? null}, ${userType}, ${reference}, ${channel_used},
NOW() + (${OTP_TTL_MINUTES} || ' minutes')::interval,
FALSE, ${codeHash}
)
RETURNING id, expires_at
`
return {
otp_request_id: row.id,
channel_used,
expires_at: row.expires_at,
}
}
/**
* Verify an OTP code. Returns { phone, user_type } on success.
* Throws OtpError on failure.
*/
export const verifyOtp = async ({ otpRequestId, code, logger }) => {
if (typeof code !== 'string' || !/^\d{4,8}$/.test(code)) {
throw new OtpError('Invalid code format', 'CODE_INVALID', 422)
}
const limits = await getOtpRateLimits()
const [row] = await sql`
SELECT id, phone, user_type, fazpass_reference, attempts, used_at, expires_at,
is_bypass, code_hash
FROM otp_requests
WHERE id = ${otpRequestId}
`
if (!row) {
throw new OtpError('OTP request not found', 'OTP_NOT_FOUND', 404)
}
if (row.used_at) {
throw new OtpError('OTP already used', 'OTP_USED', 409)
}
if (new Date(row.expires_at) <= new Date()) {
throw new OtpError('OTP expired', 'OTP_EXPIRED', 410)
}
if (row.attempts >= limits.verify_max_attempts) {
throw new OtpError('Too many verification attempts; request a new OTP', 'OTP_ATTEMPTS_EXCEEDED', 429)
}
await sql`UPDATE otp_requests SET attempts = attempts + 1 WHERE id = ${otpRequestId}`
// Verification routing: the is_bypass flag is sovereign — never use the
// mere presence of code_hash to decide which verifier runs, because a
// bug or errant migration could leave code_hash populated on a normal row.
let valid = false
if (row.is_bypass) {
if (!row.code_hash) {
// DB CHECK constraint should make this impossible, but defend anyway.
if (logger) {
logger.error({ otp_request_id: row.id }, 'bypass row missing code_hash — refusing')
}
throw new OtpError('OTP system error', 'OTP_CORRUPT', 500)
}
valid = await bcrypt.compare(code, row.code_hash)
if (valid && logger) {
logger.info({
event: 'test_otp_bypass.verify_success',
otp_request_id: row.id,
phone_last4: row.phone.slice(-4),
user_type: row.user_type,
}, 'test OTP bypass verified')
}
} else {
// Normal row. Routing depends on which mode wrote it:
// - stub-mode row → code_hash is set, bcrypt-compare locally
// - Fazpass-live row → code_hash is NULL, defer to provider
// Distinguishing by code_hash presence is safe here because the
// is_bypass=true case is already handled above; this branch only sees
// normal rows where the writer's mode is encoded by which fields they
// populated (CHECK constraint ensures bypass rows can't reach here).
if (row.code_hash) {
valid = await bcrypt.compare(code, row.code_hash)
} else {
if (!row.fazpass_reference) {
// Both code_hash AND fazpass_reference are NULL — row is unverifiable
// (a bug, partial write, or someone tampering). Don't fall through to
// "valid by default"; reject and alert.
if (logger) {
logger.error({ otp_request_id: row.id }, 'non-bypass row has no code_hash and no fazpass_reference — unverifiable')
}
throw new OtpError('OTP system error', 'OTP_CORRUPT', 500)
}
try {
const result = await fazpassVerifyOtp({
reference: row.fazpass_reference,
code,
logger,
})
valid = result.valid
if (!valid && logger) {
logger.info({
event: 'fazpass.verify.invalid',
otp_request_id: row.id,
provider_code: result.providerCode,
provider_message: result.providerMessage,
}, 'Fazpass reported invalid OTP — surfacing as CODE_MISMATCH')
}
} catch (err) {
// Provider outage / our state corrupt / Fazpass schema drift.
// Distinct from "wrong code" — preserve attempt increment but throw
// 502 so the client distinguishes "retry the code" from "retry later".
if (err instanceof FazpassError) {
if (logger) {
logger.error({
err: {
message: err.message,
provider_code: err.providerCode,
provider_message: err.providerMessage,
http_status: err.httpStatus,
},
otp_request_id: row.id,
}, 'Fazpass verify failed (provider-side)')
}
throw new OtpError('OTP verification temporarily unavailable', 'OTP_PROVIDER_FAILED', 502, {
provider_code: err.providerCode,
})
}
throw err
}
}
}
if (!valid) {
throw new OtpError('Incorrect code', 'CODE_MISMATCH', 401)
}
await sql`UPDATE otp_requests SET used_at = NOW() WHERE id = ${otpRequestId}`
return { phone: row.phone, user_type: row.user_type }
}