Phase 3.3: topic sensitivity + Phase 3.4: auth foundation
Phase 3.3 — Session Topic Sensitivity (complete): - Backend: topic_sensitivity column + session_sensitivity_log, sensitivity service (flip with one-way-latch + audit), PATCH /api/shared/chat/sessions/:id/topic, topic carried in pairing + extension WS payloads, CC filter + sensitive stats + per-mitra sensitive columns on activity page - client_app: TopicSelectionBottomSheet before pricing, topic flows through pairing request, silent WS handler for session_topic_updated - mitra_app: SensitivityBadge + SensitivityTheme + sensitivityConfigProvider, overlay badge + yellow accent, chat screen app-bar toggle with configurable confirmation + latch, extension card shows current flag, history + transcript yellow theme - control_center: Sensitivitas Topik settings section, topic filter + column with inline audit log, sensitive stats dashboard card, mitra activity sensitive columns with QC flag Phase 3.4 — Self-Managed Auth (foundation only): - Migration: auth_sessions + otp_requests tables, social identity columns on customers, password_hash + lockout on control_center_users, OTP + CC lockout app_config keys - New services: password (bcrypt + complexity), token (JWT HS256 + refresh rotation, session_id claim pre-wires future Valkey revocation), social-identity (Google + Apple JWKS), OTP (Fazpass stub — real API TBD) - Constants: AuthProvider + OtpChannel - Middleware, auth route rewrites, WS auth update, Firebase → FCM isolation still pending (next chunk); Fazpass docs + Apple Developer setup still required before E2E testing Docs: - requirement/phase3.3.md, phase3.3-plan.md, phase3.3-testing.md - requirement/phase3.4.md, phase3.4-plan.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
170
backend/src/services/otp.service.js
Normal file
170
backend/src/services/otp.service.js
Normal file
@@ -0,0 +1,170 @@
|
||||
import crypto from 'node:crypto'
|
||||
import { getDb } from '../db/client.js'
|
||||
import { getOtpRateLimits } from './config.service.js'
|
||||
import { OtpChannel, UserType } from '../constants.js'
|
||||
|
||||
const sql = getDb()
|
||||
|
||||
const OTP_TTL_MINUTES = 5
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// ⚠ Fazpass integration — STUB until real API docs are obtained.
|
||||
//
|
||||
// In production, Fazpass is the source of truth for the OTP code.
|
||||
// We will only ever handle a reference ID (string) returned by Fazpass,
|
||||
// never the raw code. For now, we generate a 6-digit code locally and
|
||||
// store its bcrypt hash in the metadata field of otp_requests via
|
||||
// fazpass_reference (reused as "<reference>:<hash>") so the stub can
|
||||
// round-trip without schema changes.
|
||||
//
|
||||
// When real docs arrive, replace fazpassSendStub + fazpassVerifyStub
|
||||
// with real HTTP calls and drop the local code generation.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const generate6DigitCode = () => {
|
||||
// Avoid Math.random for OTP generation — use crypto.randomInt
|
||||
return String(crypto.randomInt(0, 1_000_000)).padStart(6, '0')
|
||||
}
|
||||
|
||||
const fazpassSendStub = async ({ phone, channel }) => {
|
||||
const reference = `stub_${crypto.randomUUID()}`
|
||||
const code = generate6DigitCode()
|
||||
// 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
|
||||
}
|
||||
|
||||
const fazpassVerifyStub = async ({ reference, code, expectedCode }) => {
|
||||
return { valid: code === expectedCode }
|
||||
}
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
export class OtpError extends Error {
|
||||
constructor(message, code, statusCode) {
|
||||
super(message)
|
||||
this.code = code
|
||||
this.statusCode = statusCode
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
throw new OtpError(
|
||||
`Please wait ${Math.ceil(limits.resend_cooldown_seconds - elapsed)}s before requesting another OTP`,
|
||||
'OTP_COOLDOWN', 429,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
throw new OtpError('Too many OTP requests for this number', 'OTP_RATE_LIMIT_PHONE', 429)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
throw new OtpError('Too many OTP requests from this network', 'OTP_RATE_LIMIT_IP', 429)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 }) => {
|
||||
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 })
|
||||
|
||||
const { reference, channel_used, code } = await fazpassSendStub({ phone, channel })
|
||||
|
||||
// Store the reference. In stub mode, we also store the expected code appended
|
||||
// after a colon so the verify stub can compare. Real Fazpass flow will NOT store
|
||||
// the code; Fazpass itself holds it. This line is the main place to change
|
||||
// when switching to real Fazpass.
|
||||
const storedReference = code ? `${reference}:${code}` : reference
|
||||
|
||||
const [row] = await sql`
|
||||
INSERT INTO otp_requests (phone, ip_address, user_type, fazpass_reference, channel, expires_at)
|
||||
VALUES (
|
||||
${phone}, ${ipAddress ?? null}, ${userType}, ${storedReference}, ${channel_used},
|
||||
NOW() + (${OTP_TTL_MINUTES} || ' minutes')::interval
|
||||
)
|
||||
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 }) => {
|
||||
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
|
||||
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}`
|
||||
|
||||
// Stub: fazpass_reference is stored as "<ref>:<code>"
|
||||
const [reference, expectedCode] = (row.fazpass_reference || '').split(':')
|
||||
const { valid } = await fazpassVerifyStub({ reference, code, expectedCode })
|
||||
|
||||
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user