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) <noreply@anthropic.com>
This commit is contained in:
@@ -14,10 +14,17 @@ const extractDeviceInfo = (request) => ({
|
|||||||
ip: request.ip || null,
|
ip: request.ip || null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const sendAuthError = (reply, err) => reply.code(err.statusCode || 500).send({
|
const sendAuthError = (reply, err) => {
|
||||||
|
if (!err.statusCode) reply.request.log.error({ err }, 'Unhandled auth error')
|
||||||
|
return reply.code(err.statusCode || 500).send({
|
||||||
success: false,
|
success: false,
|
||||||
error: { code: err.code || 'INTERNAL', message: err.message },
|
error: {
|
||||||
|
code: err.code || 'INTERNAL',
|
||||||
|
message: err.message,
|
||||||
|
...(err.details && { details: err.details }),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const cookieOpts = () => ({
|
const cookieOpts = () => ({
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
|
|||||||
@@ -13,10 +13,17 @@ const extractDeviceInfo = (request) => ({
|
|||||||
ip: request.ip || null,
|
ip: request.ip || null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const sendAuthError = (reply, err) => reply.code(err.statusCode || 500).send({
|
const sendAuthError = (reply, err) => {
|
||||||
|
if (!err.statusCode) reply.request.log.error({ err }, 'Unhandled auth error')
|
||||||
|
return reply.code(err.statusCode || 500).send({
|
||||||
success: false,
|
success: false,
|
||||||
error: { code: err.code || 'INTERNAL', message: err.message },
|
error: {
|
||||||
|
code: err.code || 'INTERNAL',
|
||||||
|
message: err.message,
|
||||||
|
...(err.details && { details: err.details }),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export const clientAuthRoutes = async (app) => {
|
export const clientAuthRoutes = async (app) => {
|
||||||
// --- Phone OTP ---
|
// --- Phone OTP ---
|
||||||
|
|||||||
@@ -9,10 +9,17 @@ const extractDeviceInfo = (request) => ({
|
|||||||
ip: request.ip || null,
|
ip: request.ip || null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const sendAuthError = (reply, err) => reply.code(err.statusCode || 500).send({
|
const sendAuthError = (reply, err) => {
|
||||||
|
if (!err.statusCode) reply.request.log.error({ err }, 'Unhandled auth error')
|
||||||
|
return reply.code(err.statusCode || 500).send({
|
||||||
success: false,
|
success: false,
|
||||||
error: { code: err.code || 'INTERNAL', message: err.message },
|
error: {
|
||||||
|
code: err.code || 'INTERNAL',
|
||||||
|
message: err.message,
|
||||||
|
...(err.details && { details: err.details }),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export const mitraAuthRoutes = async (app) => {
|
export const mitraAuthRoutes = async (app) => {
|
||||||
app.post('/otp/request', async (request, reply) => {
|
app.post('/otp/request', async (request, reply) => {
|
||||||
|
|||||||
@@ -10,10 +10,17 @@ const extractDeviceInfo = (request) => ({
|
|||||||
ip: request.ip || null,
|
ip: request.ip || null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const sendAuthError = (reply, err) => reply.code(err.statusCode || 500).send({
|
const sendAuthError = (reply, err) => {
|
||||||
|
if (!err.statusCode) reply.request.log.error({ err }, 'Unhandled auth error')
|
||||||
|
return reply.code(err.statusCode || 500).send({
|
||||||
success: false,
|
success: false,
|
||||||
error: { code: err.code || 'INTERNAL', message: err.message },
|
error: {
|
||||||
|
code: err.code || 'INTERNAL',
|
||||||
|
message: err.message,
|
||||||
|
...(err.details && { details: err.details }),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export const sharedAuthRoutes = async (app) => {
|
export const sharedAuthRoutes = async (app) => {
|
||||||
// Issue an anonymous customer session
|
// Issue an anonymous customer session
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { authenticate } from '../../plugins/auth.js'
|
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) => {
|
export const sharedConfigRoutes = async (app) => {
|
||||||
app.get('/anonymity', async (request, reply) => {
|
app.get('/anonymity', async (request, reply) => {
|
||||||
@@ -11,4 +11,12 @@ export const sharedConfigRoutes = async (app) => {
|
|||||||
const config = await getSensitivityConfig()
|
const config = await getSensitivityConfig()
|
||||||
return reply.send({ success: true, data: config })
|
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 },
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,13 +41,24 @@ const fazpassVerifyStub = async ({ reference, code, expectedCode }) => {
|
|||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
export class OtpError extends Error {
|
export class OtpError extends Error {
|
||||||
constructor(message, code, statusCode) {
|
constructor(message, code, statusCode, details = null) {
|
||||||
super(message)
|
super(message)
|
||||||
this.code = code
|
this.code = code
|
||||||
this.statusCode = statusCode
|
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 }) => {
|
const checkRateLimits = async ({ phone, ipAddress, limits }) => {
|
||||||
// Resend cooldown
|
// Resend cooldown
|
||||||
const [lastRow] = await sql`
|
const [lastRow] = await sql`
|
||||||
@@ -58,9 +69,11 @@ const checkRateLimits = async ({ phone, ipAddress, limits }) => {
|
|||||||
if (lastRow) {
|
if (lastRow) {
|
||||||
const elapsed = (Date.now() - new Date(lastRow.created_at).getTime()) / 1000
|
const elapsed = (Date.now() - new Date(lastRow.created_at).getTime()) / 1000
|
||||||
if (elapsed < limits.resend_cooldown_seconds) {
|
if (elapsed < limits.resend_cooldown_seconds) {
|
||||||
|
const retryAfter = Math.ceil(limits.resend_cooldown_seconds - elapsed)
|
||||||
throw new OtpError(
|
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,
|
'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'
|
WHERE phone = ${phone} AND created_at >= NOW() - INTERVAL '1 hour'
|
||||||
`
|
`
|
||||||
if (phone_count >= limits.max_per_phone_per_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)
|
// 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'
|
WHERE ip_address = ${ipAddress} AND created_at >= NOW() - INTERVAL '1 hour'
|
||||||
`
|
`
|
||||||
if (ip_count >= limits.max_per_ip_per_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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user