From fa7071def5360d7edb2444c3ac84c7cba9b576f2 Mon Sep 17 00:00:00 2001 From: ramadhan sjamsani Date: Mon, 27 Apr 2026 13:43:56 +0800 Subject: [PATCH] Phase 3.4: structured rate-limit retry-after + auth error logging OtpError now carries an optional details object; rate-limit branches in checkRateLimits compute retry_after_seconds (cooldown delta for OTP_COOLDOWN, window-roll-out delta for OTP_RATE_LIMIT_PHONE / OTP_RATE_LIMIT_IP) so the client can disable Kirim OTP / Kirim ulang CTAs with a real countdown. All four sendAuthError helpers (client, mitra, shared, internal) now surface err.details and log unhandled (no statusCode) errors at level 50. New GET /api/shared/config/otp returns the resend cooldown so the OTP screen can gate the resend countdown without hardcoding. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/src/routes/internal/auth.routes.js | 15 ++++++-- .../src/routes/public/client.auth.routes.js | 15 ++++++-- .../src/routes/public/mitra.auth.routes.js | 15 ++++++-- .../src/routes/public/shared.auth.routes.js | 15 ++++++-- .../src/routes/public/shared.config.routes.js | 10 ++++- backend/src/services/otp.service.js | 37 +++++++++++++++++-- 6 files changed, 86 insertions(+), 21 deletions(-) diff --git a/backend/src/routes/internal/auth.routes.js b/backend/src/routes/internal/auth.routes.js index 36e3f28..b59aff2 100644 --- a/backend/src/routes/internal/auth.routes.js +++ b/backend/src/routes/internal/auth.routes.js @@ -14,10 +14,17 @@ const extractDeviceInfo = (request) => ({ ip: request.ip || null, }) -const sendAuthError = (reply, err) => reply.code(err.statusCode || 500).send({ - success: false, - error: { code: err.code || 'INTERNAL', message: err.message }, -}) +const sendAuthError = (reply, err) => { + if (!err.statusCode) reply.request.log.error({ err }, 'Unhandled auth error') + return reply.code(err.statusCode || 500).send({ + success: false, + error: { + code: err.code || 'INTERNAL', + message: err.message, + ...(err.details && { details: err.details }), + }, + }) +} const cookieOpts = () => ({ httpOnly: true, diff --git a/backend/src/routes/public/client.auth.routes.js b/backend/src/routes/public/client.auth.routes.js index 924a057..d35096f 100644 --- a/backend/src/routes/public/client.auth.routes.js +++ b/backend/src/routes/public/client.auth.routes.js @@ -13,10 +13,17 @@ const extractDeviceInfo = (request) => ({ ip: request.ip || null, }) -const sendAuthError = (reply, err) => reply.code(err.statusCode || 500).send({ - success: false, - error: { code: err.code || 'INTERNAL', message: err.message }, -}) +const sendAuthError = (reply, err) => { + if (!err.statusCode) reply.request.log.error({ err }, 'Unhandled auth error') + return reply.code(err.statusCode || 500).send({ + success: false, + error: { + code: err.code || 'INTERNAL', + message: err.message, + ...(err.details && { details: err.details }), + }, + }) +} export const clientAuthRoutes = async (app) => { // --- Phone OTP --- diff --git a/backend/src/routes/public/mitra.auth.routes.js b/backend/src/routes/public/mitra.auth.routes.js index 65a2449..15ba831 100644 --- a/backend/src/routes/public/mitra.auth.routes.js +++ b/backend/src/routes/public/mitra.auth.routes.js @@ -9,10 +9,17 @@ const extractDeviceInfo = (request) => ({ ip: request.ip || null, }) -const sendAuthError = (reply, err) => reply.code(err.statusCode || 500).send({ - success: false, - error: { code: err.code || 'INTERNAL', message: err.message }, -}) +const sendAuthError = (reply, err) => { + if (!err.statusCode) reply.request.log.error({ err }, 'Unhandled auth error') + return reply.code(err.statusCode || 500).send({ + success: false, + error: { + code: err.code || 'INTERNAL', + message: err.message, + ...(err.details && { details: err.details }), + }, + }) +} export const mitraAuthRoutes = async (app) => { app.post('/otp/request', async (request, reply) => { diff --git a/backend/src/routes/public/shared.auth.routes.js b/backend/src/routes/public/shared.auth.routes.js index 6108e77..a2479fd 100644 --- a/backend/src/routes/public/shared.auth.routes.js +++ b/backend/src/routes/public/shared.auth.routes.js @@ -10,10 +10,17 @@ const extractDeviceInfo = (request) => ({ ip: request.ip || null, }) -const sendAuthError = (reply, err) => reply.code(err.statusCode || 500).send({ - success: false, - error: { code: err.code || 'INTERNAL', message: err.message }, -}) +const sendAuthError = (reply, err) => { + if (!err.statusCode) reply.request.log.error({ err }, 'Unhandled auth error') + return reply.code(err.statusCode || 500).send({ + success: false, + error: { + code: err.code || 'INTERNAL', + message: err.message, + ...(err.details && { details: err.details }), + }, + }) +} export const sharedAuthRoutes = async (app) => { // Issue an anonymous customer session diff --git a/backend/src/routes/public/shared.config.routes.js b/backend/src/routes/public/shared.config.routes.js index d7819aa..ce8d665 100644 --- a/backend/src/routes/public/shared.config.routes.js +++ b/backend/src/routes/public/shared.config.routes.js @@ -1,5 +1,5 @@ import { authenticate } from '../../plugins/auth.js' -import { getAnonymityConfig, getSensitivityConfig } from '../../services/config.service.js' +import { getAnonymityConfig, getSensitivityConfig, getOtpRateLimits } from '../../services/config.service.js' export const sharedConfigRoutes = async (app) => { app.get('/anonymity', async (request, reply) => { @@ -11,4 +11,12 @@ export const sharedConfigRoutes = async (app) => { const config = await getSensitivityConfig() return reply.send({ success: true, data: config }) }) + + app.get('/otp', async (request, reply) => { + const limits = await getOtpRateLimits() + return reply.send({ + success: true, + data: { resend_cooldown_seconds: limits.resend_cooldown_seconds }, + }) + }) } diff --git a/backend/src/services/otp.service.js b/backend/src/services/otp.service.js index f599dd0..7d76934 100644 --- a/backend/src/services/otp.service.js +++ b/backend/src/services/otp.service.js @@ -41,13 +41,24 @@ const fazpassVerifyStub = async ({ reference, code, expectedCode }) => { // ------------------------------------------------------------------- export class OtpError extends Error { - constructor(message, code, statusCode) { + 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` @@ -58,9 +69,11 @@ const checkRateLimits = async ({ phone, ipAddress, limits }) => { 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 ${Math.ceil(limits.resend_cooldown_seconds - elapsed)}s before requesting another OTP`, + `Please wait ${retryAfter}s before requesting another OTP`, 'OTP_COOLDOWN', 429, + { retry_after_seconds: retryAfter }, ) } } @@ -71,7 +84,15 @@ const checkRateLimits = async ({ phone, ipAddress, limits }) => { WHERE phone = ${phone} AND created_at >= NOW() - INTERVAL '1 hour' ` if (phone_count >= limits.max_per_phone_per_hour) { - throw new OtpError('Too many OTP requests for this number', 'OTP_RATE_LIMIT_PHONE', 429) + 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) @@ -81,7 +102,15 @@ const checkRateLimits = async ({ phone, ipAddress, limits }) => { WHERE ip_address = ${ipAddress} AND created_at >= NOW() - INTERVAL '1 hour' ` if (ip_count >= limits.max_per_ip_per_hour) { - throw new OtpError('Too many OTP requests from this network', 'OTP_RATE_LIMIT_IP', 429) + 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, + ) } } }