diff --git a/.env.example b/.env.example index ea40d3a..475fe57 100644 --- a/.env.example +++ b/.env.example @@ -9,10 +9,16 @@ DATABASE_URL="postgresql://username:password@localhost:5432/learnault_db?schema= STELLAR_FUNDING_AMOUNT=10 STELLAR_FUNDING_MIN_BALANCE=1 STELLAR_FUNDING_MAX_RETRIES=5 -# STELLAR_FUNDING_SOURCE_SECRET=S... (funding source account secret — set via secure env, never committed) - -# Account Lifecycle Configuration -DELETION_COOLING_OFF_DAYS=30 -EXPORT_TTL_DAYS=7 -# Interval for the background lifecycle sweep (export generation, deletion finalization). 0 = disabled (lazy sweep on requests only) -LIFECYCLE_SWEEP_INTERVAL_MS=0 +# STELLAR_FUNDING_SOURCE_SECRET=S... (funding source account secret — set via secure env, never committed) + +# Account Lifecycle Configuration +DELETION_COOLING_OFF_DAYS=30 +EXPORT_TTL_DAYS=7 +# Interval for the background lifecycle sweep (export generation, deletion finalization). 0 = disabled (lazy sweep on requests only) +LIFECYCLE_SWEEP_INTERVAL_MS=0 + +# Phone OTP Configuration +# Only "mock" is implemented until a real carrier (Twilio, Termii, etc.) is integrated — see docs/decisions/0001-phone-otp-authentication.md +SMS_PROVIDER=mock +RATE_LIMIT_OTP_WINDOW_MS=900000 +RATE_LIMIT_OTP_MAX=5 diff --git a/ROADMAP.md b/ROADMAP.md index 7aa5f0b..7ee1735 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -56,7 +56,7 @@ Status baseline: 17 July 2026. The present schema supports users, flat modules, - [~] Complete email/password or email/PIN registration and login using real persisted users and normalized responses. - [ ] Add `EmailVerificationToken`, `PasswordResetToken`, `RefreshSession`, and `LoginAttempt` models with expiry and revocation. - [ ] Add email verification, resend, forgot-password, reset-password, refresh-token rotation, logout-one-session, and logout-all-session endpoints. -- [ ] Add phone/OTP challenge and verification models/endpoints only if retained in launch scope, with provider mocks and abuse limits. +- [x] Add phone/OTP challenge and verification models/endpoints — retained in launch scope; see [`docs/decisions/0001-phone-otp-authentication.md`](docs/decisions/0001-phone-otp-authentication.md). Implemented with a hashed/expiring/attempt-limited `OtpChallenge` model, a provider-abstracted `SmsProvider` (mock only for now), and phone/IP/device abuse limits. - [ ] Add session/device listing and revocation endpoints. - [ ] Enforce verified-email, account status, role, password policy, token audience/issuer, and secret rotation rules. diff --git a/docs/API.md b/docs/API.md index 766ed2b..6188353 100644 --- a/docs/API.md +++ b/docs/API.md @@ -85,6 +85,7 @@ Retry-After: 60 | Limiter | Applies to | Default window | Default max | |---------|-----------|----------------|-------------| | `authLimiter` | `/auth/login`, `/auth/resend-verification`, `/auth/forgot-password` | 15 min | 10 | +| `otpLimiter` | `/auth/otp/request`, `/auth/otp/verify` | 15 min | 5 | | `employerLimiter` | All `/employer/*` routes | 15 min | 500 | | `authenticatedLimiter` | All `/sync/*` routes | 15 min | 1000 | | `generalLimiter` | Everything else | 15 min | 100 | @@ -202,6 +203,58 @@ On success, all active sessions are revoked and all pending verification tokens --- +### `POST /auth/otp/request` + +Requests a 6-digit SMS code. Behavior depends on whether a Bearer token is sent: + +- **No token → `LOGIN`**: the phone must already be verified on an existing account. Always returns 200 with the same generic message, whether or not the phone is registered, to avoid leaking phone existence. +- **With token → `PHONE_VERIFICATION`**: attaches/verifies this phone number on the caller's own account. + +Rate-limited by IP (`otpLimiter`), by phone (1/min cooldown, 5/hour), and — if `deviceId` is supplied — by device (10/hour). SMS is sent via a mocked provider (`SMS_PROVIDER=mock`) until a real carrier is integrated; see [`docs/decisions/0001-phone-otp-authentication.md`](decisions/0001-phone-otp-authentication.md). + +**Request body** + +| Field | Type | Required | Notes | +|-------|------|----------|-------| +| `phone` | string | ✅ | E.164 format, e.g. `+2348012345678` | +| `deviceId` | string | ❌ | Used for device-level rate limiting | + +**Responses** + +| Status | Meaning | +|--------|---------| +| 200 | Code sent (or silently ignored for an unregistered/unverified `LOGIN` phone) | +| 400 | Validation failed or phone not in E.164 format | +| 409 | Phone already verified on a different account (`PHONE_VERIFICATION` only) | +| 429 | IP, phone, or device rate limit reached | + +```json +{ "message": "If this phone number is registered, a verification code has been sent." } +``` + +--- + +### `POST /auth/otp/verify` + +Verifies the code from `otp/request`. Codes are single-use, expire after 5 minutes, and the challenge locks after 5 wrong attempts (request a new code to retry). + +- **No token → `LOGIN`**: on success, returns the same JWT/user shape as `POST /auth/login`, after the same account-status checks (deactivated/pending-deletion/deleted). +- **With token → `PHONE_VERIFICATION`**: on success, marks the phone verified on the caller's account. + +**Request body:** `{ "phone": "+2348012345678", "code": "123456" }` + +**Responses** + +| Status | Meaning | +|--------|---------| +| 200 | Verified — login response or `{ "message": "Phone number verified successfully" }` | +| 400 | Invalid/expired code, or validation failed | +| 401 | Invalid credentials (tombstoned account; `LOGIN` only) | +| 403 | Account deactivated or pending deletion (`LOGIN` only) | +| 429 | Too many wrong attempts — challenge locked | + +--- + ## Users — `/users` ### `GET /users/me` 🔒 diff --git a/docs/decisions/0001-phone-otp-authentication.md b/docs/decisions/0001-phone-otp-authentication.md new file mode 100644 index 0000000..fe6d0f6 --- /dev/null +++ b/docs/decisions/0001-phone-otp-authentication.md @@ -0,0 +1,87 @@ +# 0001 — Phone OTP Authentication Scope + +- **Status:** Accepted +- **Date:** 2026-07-20 +- **Roadmap item:** Phase 1 / Authentication and session models — "Add phone/OTP challenge and verification models/endpoints only if retained in launch scope, with provider mocks and abuse limits." (`ROADMAP.md`) + +## Context + +Before this decision, no phone OTP code existed in the repository: no route, controller, schema, Prisma model, or SMS service, and the `feat/phone-otp` branch had zero commits. The roadmap explicitly left the feature conditional pending a scope call. Two items were listed as dependencies: + +- **Auth token / session persistence models** — not built as a discrete feature. `Session` exists in `prisma/schema.prisma` but login never writes to it (stateless JWT only); there is no `LoginAttempt` model. +- **Transaction outbox / job delivery foundation** — not built as a generic abstraction. Five domains (`EmailDelivery`, `NotificationLog`, `WebhookDelivery`, `StellarFunding`, `DataExportRequest`) each independently duplicate the same `status/attemptCount/maxAttempts/nextAttemptAt` retry shape. + +## Decision + +**Retain phone OTP in launch scope**, implemented as: + +1. A **login/verification mechanism keyed by phone number**, not a replacement for email/password. Two use cases share one pair of endpoints, disambiguated by whether the caller is authenticated: + - **Authenticated caller** → `PHONE_VERIFICATION`: attach and verify a phone number on the caller's own account. + - **Unauthenticated caller** → `LOGIN`: sign in with a phone number that has already been verified on some account, receiving the same JWT shape as `POST /auth/login`. +2. A **provider-abstracted SMS interface** (`SmsProvider`) with a `MockSmsProvider` as the only implementation. Sending is synchronous request/response (not an outbox job) because an OTP is only useful within its short expiry window — unlike email, there is no value in a multi-hour retry-with-backoff; a failed send just fails the request. `SMS_PROVIDER=mock` is the only supported value until a real provider (Twilio, Termii, Africa's Talking, etc.) is integrated; the factory throws on any other value instead of silently falling back. +3. A **hashed, expiring, attempt-limited challenge** (`OtpChallenge`) rather than reusing `VerificationToken`, because OTP needs numeric-code semantics (fixed length, attempt counter, phone-scoped lookup) that don't fit the existing token-link model. +4. **Phone + IP + device rate limits**, layered the same way the existing `forgotPassword`/`resendVerification` flows already do (IP via a route-level limiter, phone/device via in-memory windowed counters in the controller) — see "Dependency handling" below for why this is an acceptable substitute for a persisted device/session model. + +### Why not exclude it + +Phone-first login materially matters for Learnault's stated target market (low-connectivity, mobile-money-first regions per `ROADMAP.md` Phase 5), and product has not said otherwise. The blocking dependencies turned out to be softer than the roadmap phrasing implies — see below — so there is no technical reason to defer the whole feature. + +### Threat model and mitigations + +| Threat | Mitigation | +|---|---| +| SMS interception / SIM swap | OTP is a convenience login path, not a step-up for high-value actions (wallet export, payouts are out of scope for this change); code expires in 5 minutes and is single-use. | +| Brute-force code guessing | Codes are 6-digit (1,000,000 space), hashed (`sha256(phone:code)`, phone-peppered so a leaked hash table can't be replayed against another number), and the challenge locks after 5 wrong attempts, forcing a fresh request. | +| Phone number enumeration via `LOGIN` | Unauthenticated `otp/request` always returns the same generic 200 body regardless of whether the phone is registered/verified — mirrors the existing `forgotPassword` anti-enumeration pattern. No challenge row or SMS is created for unknown/unverified phones. | +| Request flooding to exhaust SMS budget | Three independent limits: per-IP (route middleware), per-phone (cooldown + hourly cap), per-device where a `deviceId` is supplied. A new request always revokes prior pending challenges for that user+purpose, so only one challenge can be outstanding at a time. | +| Phone takeover (attacker verifies a phone another user already verified) | `PHONE_VERIFICATION` checks for an existing *verified* owner of the normalized number before creating a challenge and rejects with 409. | + +### Cost rationale + +SMS is the only paid-per-message channel in this API (email/push are effectively free at this scale). The combination of a 60-second phone cooldown, a 5-request/hour phone cap, single-outstanding-challenge-per-user, and IP-level limiting bounds worst-case spend per abusive actor to a small, fixed number of messages per hour regardless of how many times they hit the endpoint. `MockSmsProvider` is the default in every environment until a paid provider is deliberately wired in, so no cost is incurred by running the test suite, CI, or a fresh local checkout. + +### Dependency handling + +Rather than block on building the two prerequisite foundations first, this change accepts the codebase's existing informal patterns and does not attempt to generalize them: + +- **Session persistence**: OTP login issues a stateless JWT via the same `generateToken` path `login()` already uses. It does not write to `Session`, because `login()` doesn't either — this stays consistent with current behavior rather than making OTP the first endpoint to diverge. Revisiting this is tracked by the roadmap's existing `RefreshSession`/`LoginAttempt` item, independent of OTP. +- **Outbox/job delivery**: OTP sends are synchronous, not queued, for the reason in point 2 above — there is nothing to generalize into a shared outbox here, since the durable-retry shape used by `EmailDelivery` etc. doesn't fit a 5-minute-lived code. + +### Known limitation + +Device-level rate limiting depends on the caller supplying an optional `deviceId` in the request body; there is no persisted device/session identity to fall back on, so a caller that omits it is only bounded by phone- and IP-level limits. This is acceptable for launch and should be revisited once a real device/session model exists. + +## Verification evidence + +Redacted request/response pairs (mock SMS provider, local dev server): + +```sh +$ curl -s -X POST http://localhost:3000/api/v1/auth/otp/request \ + -H 'Content-Type: application/json' \ + -d '{"phone":"+2348012XXXXXX"}' +{"message":"If this phone number is registered, a verification code has been sent."} + +$ curl -s -X POST http://localhost:3000/api/v1/auth/otp/verify \ + -H 'Content-Type: application/json' \ + -d '{"phone":"+2348012XXXXXX","code":"XXXXXX"}' +{"message":"Login successful","token":"","user":{"id":"","email":"...","username":"...","role":"learner"}} + +# Authenticated phone-verification variant +$ curl -s -X POST http://localhost:3000/api/v1/auth/otp/request \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{"phone":"+2348099XXXXXX"}' +{"message":"Verification code sent."} +``` + +Expiry, attempt-limit/lockout, consumption, and rate-limit behavior are covered by automated tests rather than manual timing: + +- `tests/otp.service.test.ts` — hashed-challenge lifecycle: expiry (`marks the challenge expired and returns expired`), attempt increment/lockout (`increments attempts on a wrong code...`, `locks out on the attempt that reaches maxAttempts`), and single-use consumption (`consumes the challenge and returns ok on a correct code`). +- `tests/auth.controller.test.ts` (`requestOtp`/`verifyOtp` blocks) — anti-enumeration on `LOGIN`, phone conflict on `PHONE_VERIFICATION`, 429 on phone/device rate-limit exhaustion, and 429 propagation when a challenge is locked. + +## Consequences + +- `User` gains `phone` (unique, nullable) and `phoneVerifiedAt` fields. +- New `OtpChallenge` model and migration. +- New `POST /auth/otp/request` and `POST /auth/otp/verify` endpoints, documented in the OpenAPI spec and `docs/API.md`. +- `ROADMAP.md` line item is updated from conditional to resolved. diff --git a/prisma/migrations/20260720120000_add_phone_otp/migration.sql b/prisma/migrations/20260720120000_add_phone_otp/migration.sql new file mode 100644 index 0000000..f2ba60b --- /dev/null +++ b/prisma/migrations/20260720120000_add_phone_otp/migration.sql @@ -0,0 +1,35 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "phone" TEXT, +ADD COLUMN "phoneVerifiedAt" TIMESTAMP(3); + +-- CreateTable +CREATE TABLE "otp_challenges" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "phone" TEXT NOT NULL, + "purpose" TEXT NOT NULL, + "codeHash" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'PENDING', + "attempts" INTEGER NOT NULL DEFAULT 0, + "maxAttempts" INTEGER NOT NULL DEFAULT 5, + "expiresAt" TIMESTAMP(3) NOT NULL, + "consumedAt" TIMESTAMP(3), + "requestIp" TEXT, + "deviceId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "otp_challenges_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "users_phone_key" ON "users"("phone"); + +-- CreateIndex +CREATE INDEX "otp_challenges_phone_purpose_status_idx" ON "otp_challenges"("phone", "purpose", "status"); + +-- CreateIndex +CREATE INDEX "otp_challenges_userId_purpose_status_idx" ON "otp_challenges"("userId", "purpose", "status"); + +-- AddForeignKey +ALTER TABLE "otp_challenges" ADD CONSTRAINT "otp_challenges_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7498f0c..e831d87 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -14,6 +14,8 @@ model User { role Role @default(LEARNER) walletAddress String? @unique isVerified Boolean @default(false) + phone String? @unique + phoneVerifiedAt DateTime? status String @default("ACTIVE") // ACTIVE, DEACTIVATED, PENDING_DELETION, DELETED statusChangedAt DateTime? createdAt DateTime @default(now()) @@ -37,6 +39,7 @@ model User { audits AuditLog[] dataExports DataExportRequest[] deletionRequests AccountDeletionRequest[] + otpChallenges OtpChallenge[] @@map("users") } @@ -372,6 +375,28 @@ model DataExportRequest { @@map("data_export_requests") } +model OtpChallenge { + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + phone String + purpose String // LOGIN, PHONE_VERIFICATION + codeHash String + status String @default("PENDING") // PENDING, CONSUMED, EXPIRED, REVOKED, LOCKED + attempts Int @default(0) + maxAttempts Int @default(5) + expiresAt DateTime + consumedAt DateTime? + requestIp String? + deviceId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([phone, purpose, status]) + @@index([userId, purpose, status]) + @@map("otp_challenges") +} + model AccountDeletionRequest { id String @id @default(uuid()) userId String diff --git a/src/config/env.ts b/src/config/env.ts index 75a7e6a..a49daed 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -19,6 +19,12 @@ export const env = { RATE_LIMIT_AUTHENTICATED_WINDOW_MS: parseInt(process.env.RATE_LIMIT_AUTHENTICATED_WINDOW_MS || '900000', 10), // 15 minutes RATE_LIMIT_AUTHENTICATED_MAX: parseInt(process.env.RATE_LIMIT_AUTHENTICATED_MAX || '1000', 10), + RATE_LIMIT_OTP_WINDOW_MS: parseInt(process.env.RATE_LIMIT_OTP_WINDOW_MS || '900000', 10), // 15 minutes + RATE_LIMIT_OTP_MAX: parseInt(process.env.RATE_LIMIT_OTP_MAX || '5', 10), + + // SMS provider selection — only "mock" is implemented until a real provider is integrated + SMS_PROVIDER: process.env.SMS_PROVIDER || 'mock', + // Account lifecycle configurations DELETION_COOLING_OFF_DAYS: parseInt(process.env.DELETION_COOLING_OFF_DAYS || '30', 10), EXPORT_TTL_DAYS: parseInt(process.env.EXPORT_TTL_DAYS || '7', 10), diff --git a/src/config/swagger.ts b/src/config/swagger.ts index 7e6d1db..9b90d95 100644 --- a/src/config/swagger.ts +++ b/src/config/swagger.ts @@ -48,7 +48,7 @@ const options: swaggerJsdoc.Options = { ], tags: [ { name: 'Health', description: 'Service health check' }, - { name: 'Auth', description: 'Registration, login, email verification, password reset' }, + { name: 'Auth', description: 'Registration, login, email verification, password reset, phone OTP' }, { name: 'Users', description: 'User profile management' }, { name: 'Modules', description: 'Learning module catalogue and progress tracking' }, { name: 'Credentials', description: 'On-chain verifiable credentials' }, diff --git a/src/controllers/auth.controller.ts b/src/controllers/auth.controller.ts index d385a69..0b74a46 100644 --- a/src/controllers/auth.controller.ts +++ b/src/controllers/auth.controller.ts @@ -3,9 +3,10 @@ import { Request, Response } from 'express' import bcrypt from 'bcryptjs' import jwt from 'jsonwebtoken' import prisma from '../config/database' -import { loginSchema, registerSchema, verifyEmailSchema, resendVerificationSchema, forgotPasswordSchema, resetPasswordSchema } from '../schemas/auth.schema' +import { loginSchema, registerSchema, verifyEmailSchema, resendVerificationSchema, forgotPasswordSchema, resetPasswordSchema, otpRequestSchema, otpVerifySchema } from '../schemas/auth.schema' import { UserRole } from '../types/user.types' import { emailService } from '../services/email.service' +import { otpService, normalizePhone, OtpPurpose } from '../services/otp.service' import logger from '../utils/logger' const JWT_SECRET = process.env.JWT_SECRET || 'your-default-secret' @@ -19,11 +20,18 @@ const RESEND_ACCOUNT_WINDOW_MS = 24 * 60 * 60 * 1000 // 24 hours const RESET_PASSWORD_COOLDOWN_MS = 60 * 1000 // 1 minute const RESET_PASSWORD_ACCOUNT_LIMIT = 3 const RESET_PASSWORD_ACCOUNT_WINDOW_MS = 24 * 60 * 60 * 1000 // 24 hours +const OTP_PHONE_COOLDOWN_MS = 60 * 1000 // 1 minute between requests to the same phone +const OTP_PHONE_LIMIT = 5 +const OTP_PHONE_WINDOW_MS = 60 * 60 * 1000 // 1 hour +const OTP_DEVICE_LIMIT = 10 +const OTP_DEVICE_WINDOW_MS = 60 * 60 * 1000 // 1 hour export const resendCooldowns = new Map() export const resendAccountCounts = new Map() export const resetPasswordCooldowns = new Map() export const resetPasswordAccountCounts = new Map() +export const otpPhoneCounts = new Map() +export const otpDeviceCounts = new Map() function generateVerificationToken(): { rawToken: string; tokenHash: string } { const rawToken = crypto.randomBytes(32).toString('hex') @@ -489,33 +497,9 @@ export class AuthController { return } - if (user.status === 'DELETED') { - // Tombstoned account — indistinguishable from unknown credentials - res.status(401).json({ error: 'Invalid credentials' }) - - return - } - - if (user.status === 'DEACTIVATED') { - res.status(403).json({ - error: 'Account is deactivated', - code: 'ACCOUNT_DEACTIVATED', - }) - - return - } - - if (user.status === 'PENDING_DELETION') { - const deletionRequest = await prisma.accountDeletionRequest.findFirst({ - where: { userId: user.id, status: 'pending' }, - select: { scheduledFor: true }, - }) - - res.status(403).json({ - error: 'Account is scheduled for deletion', - code: 'ACCOUNT_PENDING_DELETION', - scheduledFor: deletionRequest?.scheduledFor ?? null, - }) + const statusError = await this.getAccountStatusError(user) + if (statusError) { + res.status(statusError.statusCode).json(statusError.body) return } @@ -791,6 +775,328 @@ export class AuthController { } } + /** + * @openapi + * /auth/otp/request: + * post: + * operationId: authOtpRequest + * summary: Request a phone OTP code (login or phone verification) + * description: > + * Without a Bearer token, requests a LOGIN code for a phone number + * already verified on some account. Always returns 200 with the + * same message regardless of whether the number is registered, to + * avoid leaking phone existence. + * + * With a Bearer token, requests a PHONE_VERIFICATION code to attach + * that phone number to the caller's own account. + * + * Rate-limited by IP, phone (1/min, 5/hour), and, if a `deviceId` + * is supplied, by device (10/hour). + * tags: [Auth] + * security: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/OtpRequestInput' + * responses: + * 200: + * description: A code has been sent, or the request was silently ignored (LOGIN, unregistered/unverified phone). + * 400: + * description: Validation failed or phone number is not in E.164 format. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 409: + * description: Phone number is already verified on a different account. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 429: + * description: Too many requests — IP, phone, or device rate limit reached. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + */ + async requestOtp(req: Request, res: Response): Promise { + try { + const validation = otpRequestSchema.safeParse(req.body) + if (!validation.success) { + res.status(400).json({ + error: 'Validation failed', + details: validation.error.format() + }) + + return + } + + const { phone, deviceId } = validation.data + const normalizedPhone = normalizePhone(phone) + + if (!normalizedPhone) { + res.status(400).json({ error: 'Phone number must be in E.164 format, e.g. +2348012345678' }) + + return + } + + if (deviceId && this.isDeviceOtpLimited(deviceId)) { + res.status(429).json({ error: 'Too many requests. Please try again later.' }) + + return + } + + const authenticatedUserId = req.user?.id + const purpose: OtpPurpose = authenticatedUserId ? 'PHONE_VERIFICATION' : 'LOGIN' + + if (this.isRateLimited(`otp:phone:${purpose}:${normalizedPhone}`, OTP_PHONE_COOLDOWN_MS) + || this.isPhoneOtpLimited(normalizedPhone, purpose)) { + res.status(429).json({ error: 'Too many requests. Please try again later.' }) + + return + } + + let targetUserId: string + + if (authenticatedUserId) { + const conflict = await prisma.user.findFirst({ + where: { + phone: normalizedPhone, + phoneVerifiedAt: { not: null }, + NOT: { id: authenticatedUserId }, + }, + }) + + if (conflict) { + res.status(409).json({ error: 'Phone number is already verified on another account' }) + + return + } + + targetUserId = authenticatedUserId + } else { + const user = await prisma.user.findUnique({ where: { phone: normalizedPhone } }) + + if (!user || !user.phoneVerifiedAt || user.status !== 'ACTIVE') { + res.status(200).json({ message: 'If this phone number is registered, a verification code has been sent.' }) + + return + } + + targetUserId = user.id + } + + this.recordPhoneOtpRequest(normalizedPhone, purpose) + if (deviceId) { + this.recordDeviceOtpRequest(deviceId) + } + + const ip = (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() + || (req.headers['x-real-ip'] as string) + || req.socket.remoteAddress + || 'unknown' + + await otpService.requestChallenge(normalizedPhone, purpose, targetUserId, { ip, deviceId }) + + res.status(200).json( + purpose === 'PHONE_VERIFICATION' + ? { message: 'Verification code sent.' } + : { message: 'If this phone number is registered, a verification code has been sent.' } + ) + } catch (error) { + console.error('OTP request error:', error) + res.status(500).json({ error: 'Internal server error' }) + } + } + + /** + * @openapi + * /auth/otp/verify: + * post: + * operationId: authOtpVerify + * summary: Verify a phone OTP code (completes login or phone verification) + * description: > + * Without a Bearer token, verifies a LOGIN code and returns a JWT, + * identical in shape to POST /auth/login. With a Bearer token, + * verifies a PHONE_VERIFICATION code and marks the phone verified + * on the caller's account. + * + * Codes are single-use, expire after 5 minutes, and the challenge + * locks after 5 wrong attempts (request a new code to retry). + * tags: [Auth] + * security: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/OtpVerifyInput' + * responses: + * 200: + * description: Verified — login response (LOGIN) or confirmation message (PHONE_VERIFICATION). + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AuthResponse' + * 400: + * description: Validation failed, malformed phone, or invalid/expired code. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 401: + * description: Invalid credentials (tombstoned account; LOGIN purpose only). + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 403: + * description: Account is deactivated or pending deletion (LOGIN purpose only). + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AccountStatusError' + * 429: + * description: Too many wrong attempts — the challenge is locked; request a new code. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + */ + async verifyOtp(req: Request, res: Response): Promise { + try { + const validation = otpVerifySchema.safeParse(req.body) + if (!validation.success) { + res.status(400).json({ + error: 'Validation failed', + details: validation.error.format() + }) + + return + } + + const { phone, code } = validation.data + const normalizedPhone = normalizePhone(phone) + + if (!normalizedPhone) { + res.status(400).json({ error: 'Invalid or expired code' }) + + return + } + + const authenticatedUserId = req.user?.id + const purpose: OtpPurpose = authenticatedUserId ? 'PHONE_VERIFICATION' : 'LOGIN' + + const result = await otpService.verifyChallenge(normalizedPhone, code, purpose, authenticatedUserId) + + if (!result.ok) { + if (result.reason === 'locked') { + res.status(429).json({ error: 'Too many attempts. Please request a new code.' }) + + return + } + + res.status(400).json({ error: 'Invalid or expired code' }) + + return + } + + if (purpose === 'PHONE_VERIFICATION') { + await prisma.user.update({ + where: { id: result.userId }, + data: { phone: normalizedPhone, phoneVerifiedAt: new Date() }, + }) + + res.status(200).json({ message: 'Phone number verified successfully' }) + + return + } + + const user = await prisma.user.findUnique({ where: { id: result.userId } }) + + if (!user) { + res.status(401).json({ error: 'Invalid credentials' }) + + return + } + + const statusError = await this.getAccountStatusError(user) + if (statusError) { + res.status(statusError.statusCode).json(statusError.body) + + return + } + + await prisma.user.update({ + where: { id: user.id }, + data: { lastLoginAt: new Date() } + }) + + const token = this.generateToken(user.id, user.role) + + res.status(200).json({ + message: 'Login successful', + token, + user: { + id: user.id, + email: user.email, + username: user.username, + role: user.role + } + }) + } catch (error) { + console.error('OTP verify error:', error) + res.status(500).json({ error: 'Internal server error' }) + } + } + + private async getAccountStatusError(user: { id: string; status: string }): Promise<{ statusCode: number; body: Record } | null> { + if (user.status === 'DELETED') { + // Tombstoned account — indistinguishable from unknown credentials + return { statusCode: 401, body: { error: 'Invalid credentials' } } + } + + if (user.status === 'DEACTIVATED') { + return { + statusCode: 403, + body: { error: 'Account is deactivated', code: 'ACCOUNT_DEACTIVATED' }, + } + } + + if (user.status === 'PENDING_DELETION') { + const deletionRequest = await prisma.accountDeletionRequest.findFirst({ + where: { userId: user.id, status: 'pending' }, + select: { scheduledFor: true }, + }) + + return { + statusCode: 403, + body: { + error: 'Account is scheduled for deletion', + code: 'ACCOUNT_PENDING_DELETION', + scheduledFor: deletionRequest?.scheduledFor ?? null, + }, + } + } + + return null + } + private generateToken(userId: string, role: string): string { return jwt.sign( { id: userId, role }, @@ -853,4 +1159,49 @@ export class AuthController { record.count++ } } + + private isPhoneOtpLimited(phone: string, purpose: string): boolean { + const now = Date.now() + const record = otpPhoneCounts.get(`${purpose}:${phone}`) + + if (!record || record.resetAt < now) { + return false + } + + return record.count >= OTP_PHONE_LIMIT + } + + private recordPhoneOtpRequest(phone: string, purpose: string): void { + const now = Date.now() + const key = `${purpose}:${phone}` + const record = otpPhoneCounts.get(key) + + if (!record || record.resetAt < now) { + otpPhoneCounts.set(key, { count: 1, resetAt: now + OTP_PHONE_WINDOW_MS }) + } else { + record.count++ + } + } + + private isDeviceOtpLimited(deviceId: string): boolean { + const now = Date.now() + const record = otpDeviceCounts.get(deviceId) + + if (!record || record.resetAt < now) { + return false + } + + return record.count >= OTP_DEVICE_LIMIT + } + + private recordDeviceOtpRequest(deviceId: string): void { + const now = Date.now() + const record = otpDeviceCounts.get(deviceId) + + if (!record || record.resetAt < now) { + otpDeviceCounts.set(deviceId, { count: 1, resetAt: now + OTP_DEVICE_WINDOW_MS }) + } else { + record.count++ + } + } } diff --git a/src/docs/schemas.ts b/src/docs/schemas.ts index 0be7cf1..794db27 100644 --- a/src/docs/schemas.ts +++ b/src/docs/schemas.ts @@ -164,6 +164,32 @@ * minLength: 8 * example: NewP@ss1 * + * OtpRequestInput: + * type: object + * required: [phone] + * properties: + * phone: + * type: string + * description: E.164 format (leading +, country code, no spaces). + * example: '+2348012345678' + * deviceId: + * type: string + * description: Optional client-generated device identifier, used for device-level rate limiting. + * + * OtpVerifyInput: + * type: object + * required: [phone, code] + * properties: + * phone: + * type: string + * example: '+2348012345678' + * code: + * type: string + * description: 6-digit code sent by SMS. + * example: '123456' + * deviceId: + * type: string + * */ /** diff --git a/src/middleware/rate-limit.middleware.ts b/src/middleware/rate-limit.middleware.ts index 65eb9e0..6e719bc 100644 --- a/src/middleware/rate-limit.middleware.ts +++ b/src/middleware/rate-limit.middleware.ts @@ -90,6 +90,16 @@ export const authLimiter = createRateLimiter( new WeakMap() ) +// Strict limiter for phone OTP endpoints (request + verify) +export const otpLimiter = createRateLimiter( + { + windowMs: env.RATE_LIMIT_OTP_WINDOW_MS, + max: env.RATE_LIMIT_OTP_MAX, + }, + createStore(), + new WeakMap() +) + // Employer-specific limiter with higher limits export const employerLimiter = createRateLimiter( { diff --git a/src/routes/v1/auth.routes.ts b/src/routes/v1/auth.routes.ts index 021240d..627388c 100644 --- a/src/routes/v1/auth.routes.ts +++ b/src/routes/v1/auth.routes.ts @@ -1,6 +1,7 @@ import { Router } from 'express' import { AuthController } from '../../controllers/auth.controller' -import { authLimiter } from '../../middleware/rate-limit.middleware' +import { authLimiter, otpLimiter } from '../../middleware/rate-limit.middleware' +import { optionalAuthenticate } from '../../middleware/auth.middleware' const router: Router = Router() const authController = new AuthController() @@ -54,4 +55,18 @@ router.post('/forgot-password', authLimiter, authController.forgotPassword.bind( */ router.post('/reset-password', authController.resetPassword.bind(authController)) +/** + * @route POST /api/v1/auth/otp/request + * @desc Request a phone OTP code (login if unauthenticated, phone verification if authenticated) + * @access Public (optional Bearer token) + */ +router.post('/otp/request', otpLimiter, optionalAuthenticate, authController.requestOtp.bind(authController)) + +/** + * @route POST /api/v1/auth/otp/verify + * @desc Verify a phone OTP code (completes login if unauthenticated, phone verification if authenticated) + * @access Public (optional Bearer token) + */ +router.post('/otp/verify', otpLimiter, optionalAuthenticate, authController.verifyOtp.bind(authController)) + export default router diff --git a/src/schemas/auth.schema.ts b/src/schemas/auth.schema.ts index dc1867d..1c500bd 100644 --- a/src/schemas/auth.schema.ts +++ b/src/schemas/auth.schema.ts @@ -30,9 +30,22 @@ export const resetPasswordSchema = z.object({ newPassword: z.string().min(8, 'Password must be at least 8 characters long'), }) +export const otpRequestSchema = z.object({ + phone: z.string().min(1, 'Phone number is required'), + deviceId: z.string().min(1).optional(), +}) + +export const otpVerifySchema = z.object({ + phone: z.string().min(1, 'Phone number is required'), + code: z.string().length(6, 'Code must be 6 digits'), + deviceId: z.string().min(1).optional(), +}) + export type RegisterInput = z.infer; export type LoginInput = z.infer; export type VerifyEmailInput = z.infer; export type ResendVerificationInput = z.infer; export type ForgotPasswordInput = z.infer; export type ResetPasswordInput = z.infer; +export type OtpRequestInput = z.infer; +export type OtpVerifyInput = z.infer; diff --git a/src/services/otp.service.ts b/src/services/otp.service.ts new file mode 100644 index 0000000..943be03 --- /dev/null +++ b/src/services/otp.service.ts @@ -0,0 +1,128 @@ +import crypto from 'crypto' +import prisma from '../config/database' +import logger from '../utils/logger' +import { getSmsProvider } from './sms/sms-provider.factory' + +export const OTP_CODE_LENGTH = 6 +export const OTP_EXPIRY_MS = 5 * 60 * 1000 +export const OTP_MAX_ATTEMPTS = 5 + +export type OtpPurpose = 'LOGIN' | 'PHONE_VERIFICATION' + +const E164_PATTERN = /^\+[1-9]\d{7,14}$/ + +export function normalizePhone(input: string): string | null { + const trimmed = input.trim() + + return E164_PATTERN.test(trimmed) ? trimmed : null +} + +function generateCode(): string { + return crypto.randomInt(0, 10 ** OTP_CODE_LENGTH).toString().padStart(OTP_CODE_LENGTH, '0') +} + +function hashCode(code: string, phone: string): string { + return crypto.createHash('sha256').update(`${phone}:${code}`).digest('hex') +} + +export type OtpVerifyFailureReason = 'not_found' | 'expired' | 'locked' | 'mismatch' +export type OtpVerifyResult = + | { ok: true; userId: string } + | { ok: false; reason: OtpVerifyFailureReason } + +export class OtpService { + async requestChallenge( + phone: string, + purpose: OtpPurpose, + userId: string, + context: { ip?: string; deviceId?: string } = {} + ): Promise { + await prisma.otpChallenge.updateMany({ + where: { userId, purpose, status: 'PENDING' }, + data: { status: 'REVOKED' }, + }) + + const code = generateCode() + const codeHash = hashCode(code, phone) + const expiresAt = new Date(Date.now() + OTP_EXPIRY_MS) + + await prisma.otpChallenge.create({ + data: { + userId, + phone, + purpose, + codeHash, + expiresAt, + requestIp: context.ip, + deviceId: context.deviceId, + }, + }) + + const provider = getSmsProvider() + const result = await provider.send( + phone, + `Your Learnault verification code is ${code}. It expires in 5 minutes.` + ) + + if (!result.success) { + logger.error(`[OtpService] SMS send failed for phone=${phone}: ${result.error}`) + } + } + + async verifyChallenge( + phone: string, + code: string, + purpose: OtpPurpose, + expectedUserId?: string + ): Promise { + const challenge = await prisma.otpChallenge.findFirst({ + where: { phone, purpose, status: 'PENDING' }, + orderBy: { createdAt: 'desc' }, + }) + + if (!challenge || (expectedUserId && challenge.userId !== expectedUserId)) { + return { ok: false, reason: 'not_found' } + } + + if (new Date() > challenge.expiresAt) { + await prisma.otpChallenge.update({ + where: { id: challenge.id }, + data: { status: 'EXPIRED' }, + }) + + return { ok: false, reason: 'expired' } + } + + if (challenge.attempts >= challenge.maxAttempts) { + await prisma.otpChallenge.update({ + where: { id: challenge.id }, + data: { status: 'LOCKED' }, + }) + + return { ok: false, reason: 'locked' } + } + + const codeHash = hashCode(code, phone) + + if (codeHash !== challenge.codeHash) { + const attempts = challenge.attempts + 1 + const lockedOut = attempts >= challenge.maxAttempts + + await prisma.otpChallenge.update({ + where: { id: challenge.id }, + data: { attempts, ...(lockedOut ? { status: 'LOCKED' } : {}) }, + }) + + return { ok: false, reason: lockedOut ? 'locked' : 'mismatch' } + } + + await prisma.otpChallenge.update({ + where: { id: challenge.id }, + data: { status: 'CONSUMED', consumedAt: new Date() }, + }) + + return { ok: true, userId: challenge.userId } + } +} + +export const otpService = new OtpService() diff --git a/src/services/sms/mock-sms.provider.ts b/src/services/sms/mock-sms.provider.ts new file mode 100644 index 0000000..f1920ba --- /dev/null +++ b/src/services/sms/mock-sms.provider.ts @@ -0,0 +1,10 @@ +import logger from '../../utils/logger' +import { SmsProvider, SmsSendResult } from './sms-provider.interface' + +export class MockSmsProvider implements SmsProvider { + async send(to: string, body: string): Promise { + logger.info(`[MockSmsProvider] SMS to=${to} body="${body}"`) + + return { success: true, providerMessageId: `mock_${Date.now()}` } + } +} diff --git a/src/services/sms/sms-provider.factory.ts b/src/services/sms/sms-provider.factory.ts new file mode 100644 index 0000000..911ea7c --- /dev/null +++ b/src/services/sms/sms-provider.factory.ts @@ -0,0 +1,30 @@ +import { env } from '../../config/env' +import { MockSmsProvider } from './mock-sms.provider' +import { SmsProvider } from './sms-provider.interface' + +let instance: SmsProvider | null = null + +/** + * Only "mock" is implemented today. Swapping in a real carrier (Twilio, + * Termii, Africa's Talking, etc.) means adding a class that implements + * SmsProvider and a case below — the OTP service never needs to change. + */ +export function getSmsProvider(): SmsProvider { + if (instance) { + return instance + } + + switch (env.SMS_PROVIDER) { + case 'mock': + instance = new MockSmsProvider() + break + default: + throw new Error(`Unsupported SMS_PROVIDER: ${env.SMS_PROVIDER}`) + } + + return instance +} + +export function resetSmsProviderForTests(): void { + instance = null +} diff --git a/src/services/sms/sms-provider.interface.ts b/src/services/sms/sms-provider.interface.ts new file mode 100644 index 0000000..a1a398c --- /dev/null +++ b/src/services/sms/sms-provider.interface.ts @@ -0,0 +1,9 @@ +export interface SmsSendResult { + success: boolean + providerMessageId?: string + error?: string +} + +export interface SmsProvider { + send(to: string, body: string): Promise +} diff --git a/tests/auth.controller.test.ts b/tests/auth.controller.test.ts index efa323b..dabfd40 100644 --- a/tests/auth.controller.test.ts +++ b/tests/auth.controller.test.ts @@ -1,9 +1,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { Request, Response } from 'express' -import { AuthController, resendCooldowns, resendAccountCounts } from '../src/controllers/auth.controller' +import { AuthController, resendCooldowns, resendAccountCounts, otpPhoneCounts, otpDeviceCounts } from '../src/controllers/auth.controller' import prisma from '../src/config/database' import bcrypt from 'bcryptjs' import { emailService } from '../src/services/email.service' +import { otpService } from '../src/services/otp.service' const mockTokenHash = 'abc123def456hash' const mockRawToken = 'aaabbbcccddd00112233445566778899aabbccddeeff00112233445566778899' @@ -62,6 +63,18 @@ vi.mock('../src/services/email.service', () => ({ }, })) +vi.mock('../src/services/otp.service', async () => { + const actual = await vi.importActual('../src/services/otp.service') + + return { + ...actual, + otpService: { + requestChallenge: vi.fn().mockResolvedValue(undefined), + verifyChallenge: vi.fn(), + }, + } +}) + describe('AuthController', () => { let authController: AuthController let mockRequest: Partial @@ -79,6 +92,8 @@ describe('AuthController', () => { } resendCooldowns.clear() resendAccountCounts.clear() + otpPhoneCounts.clear() + otpDeviceCounts.clear() vi.clearAllMocks() }) @@ -721,4 +736,210 @@ describe('AuthController', () => { }) }) }) + + describe('requestOtp', () => { + it('should reject a malformed phone number', async () => { + mockRequest.body = { phone: '08012345678' } + + await authController.requestOtp(mockRequest as Request, mockResponse as Response) + + expect(mockResponse.status).toHaveBeenCalledWith(400) + expect(otpService.requestChallenge).not.toHaveBeenCalled() + }) + + it('LOGIN: returns a generic 200 without creating a challenge for an unregistered phone', async () => { + mockRequest.body = { phone: '+2348012345678' } + + ;(prisma.user.findUnique as any).mockResolvedValue(null) + + await authController.requestOtp(mockRequest as Request, mockResponse as Response) + + expect(otpService.requestChallenge).not.toHaveBeenCalled() + expect(mockResponse.status).toHaveBeenCalledWith(200) + expect(mockResponse.json).toHaveBeenCalledWith({ + message: 'If this phone number is registered, a verification code has been sent.', + }) + }) + + it('LOGIN: returns the same generic 200 for a phone that has not been verified', async () => { + mockRequest.body = { phone: '+2348012345678' } + + ;(prisma.user.findUnique as any).mockResolvedValue({ + id: 'user1', + status: 'ACTIVE', + phoneVerifiedAt: null, + }) + + await authController.requestOtp(mockRequest as Request, mockResponse as Response) + + expect(otpService.requestChallenge).not.toHaveBeenCalled() + expect(mockResponse.status).toHaveBeenCalledWith(200) + expect(mockResponse.json).toHaveBeenCalledWith({ + message: 'If this phone number is registered, a verification code has been sent.', + }) + }) + + it('LOGIN: requests a challenge for a verified, active phone', async () => { + mockRequest.body = { phone: '+2348012345678' } + + ;(prisma.user.findUnique as any).mockResolvedValue({ + id: 'user1', + status: 'ACTIVE', + phoneVerifiedAt: new Date(), + }) + + await authController.requestOtp(mockRequest as Request, mockResponse as Response) + + expect(otpService.requestChallenge).toHaveBeenCalledWith( + '+2348012345678', + 'LOGIN', + 'user1', + expect.objectContaining({ ip: '127.0.0.1' }) + ) + expect(mockResponse.status).toHaveBeenCalledWith(200) + }) + + it('PHONE_VERIFICATION: requests a challenge for the authenticated caller', async () => { + mockRequest.body = { phone: '+2348012345678' } + ;(mockRequest as any).user = { id: 'user1', role: 'learner' } + + ;(prisma.user.findFirst as any).mockResolvedValue(null) + + await authController.requestOtp(mockRequest as Request, mockResponse as Response) + + expect(otpService.requestChallenge).toHaveBeenCalledWith( + '+2348012345678', + 'PHONE_VERIFICATION', + 'user1', + expect.anything() + ) + expect(mockResponse.status).toHaveBeenCalledWith(200) + expect(mockResponse.json).toHaveBeenCalledWith({ message: 'Verification code sent.' }) + }) + + it('PHONE_VERIFICATION: rejects a phone already verified on another account', async () => { + mockRequest.body = { phone: '+2348012345678' } + ;(mockRequest as any).user = { id: 'user1', role: 'learner' } + + ;(prisma.user.findFirst as any).mockResolvedValue({ id: 'user2' }) + + await authController.requestOtp(mockRequest as Request, mockResponse as Response) + + expect(otpService.requestChallenge).not.toHaveBeenCalled() + expect(mockResponse.status).toHaveBeenCalledWith(409) + }) + + it('should rate-limit repeated requests for the same phone', async () => { + ;(prisma.user.findUnique as any).mockResolvedValue({ + id: 'user1', + status: 'ACTIVE', + phoneVerifiedAt: new Date(), + }) + + mockRequest.body = { phone: '+2348012345678' } + await authController.requestOtp(mockRequest as Request, mockResponse as Response) + expect(mockResponse.status).toHaveBeenCalledWith(200) + + vi.clearAllMocks() + mockRequest.body = { phone: '+2348012345678' } + await authController.requestOtp(mockRequest as Request, mockResponse as Response) + + expect(mockResponse.status).toHaveBeenCalledWith(429) + expect(otpService.requestChallenge).not.toHaveBeenCalled() + }) + + it('should rate-limit repeated requests from the same device regardless of phone', async () => { + ;(prisma.user.findUnique as any).mockResolvedValue({ + id: 'user1', + status: 'ACTIVE', + phoneVerifiedAt: new Date(), + }) + + otpDeviceCounts.set('device-1', { count: 10, resetAt: Date.now() + 60_000 }) + + mockRequest.body = { phone: '+2348012345678', deviceId: 'device-1' } + await authController.requestOtp(mockRequest as Request, mockResponse as Response) + + expect(mockResponse.status).toHaveBeenCalledWith(429) + expect(otpService.requestChallenge).not.toHaveBeenCalled() + }) + }) + + describe('verifyOtp', () => { + it('should return 400 for an invalid/expired code', async () => { + mockRequest.body = { phone: '+2348012345678', code: '000000' } + + ;(otpService.verifyChallenge as any).mockResolvedValue({ ok: false, reason: 'mismatch' }) + + await authController.verifyOtp(mockRequest as Request, mockResponse as Response) + + expect(mockResponse.status).toHaveBeenCalledWith(400) + expect(mockResponse.json).toHaveBeenCalledWith({ error: 'Invalid or expired code' }) + }) + + it('should return 429 once the challenge is locked', async () => { + mockRequest.body = { phone: '+2348012345678', code: '000000' } + + ;(otpService.verifyChallenge as any).mockResolvedValue({ ok: false, reason: 'locked' }) + + await authController.verifyOtp(mockRequest as Request, mockResponse as Response) + + expect(mockResponse.status).toHaveBeenCalledWith(429) + }) + + it('LOGIN: issues a JWT on a correct code for an active account', async () => { + mockRequest.body = { phone: '+2348012345678', code: '123456' } + + ;(otpService.verifyChallenge as any).mockResolvedValue({ ok: true, userId: 'user1' }) + ;(prisma.user.findUnique as any).mockResolvedValue({ + id: 'user1', + email: 'test@example.com', + username: 'testuser', + role: 'LEARNER', + status: 'ACTIVE', + }) + ;(prisma.user.update as any).mockResolvedValue({}) + + await authController.verifyOtp(mockRequest as Request, mockResponse as Response) + + expect(mockResponse.status).toHaveBeenCalledWith(200) + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Login successful', token: 'mock_token' }) + ) + }) + + it('LOGIN: blocks a deactivated account the same way as password login', async () => { + mockRequest.body = { phone: '+2348012345678', code: '123456' } + + ;(otpService.verifyChallenge as any).mockResolvedValue({ ok: true, userId: 'user1' }) + ;(prisma.user.findUnique as any).mockResolvedValue({ + id: 'user1', + status: 'DEACTIVATED', + }) + + await authController.verifyOtp(mockRequest as Request, mockResponse as Response) + + expect(mockResponse.status).toHaveBeenCalledWith(403) + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ code: 'ACCOUNT_DEACTIVATED' }) + ) + }) + + it('PHONE_VERIFICATION: marks the phone verified on the authenticated caller', async () => { + mockRequest.body = { phone: '+2348012345678', code: '123456' } + ;(mockRequest as any).user = { id: 'user1', role: 'learner' } + + ;(otpService.verifyChallenge as any).mockResolvedValue({ ok: true, userId: 'user1' }) + ;(prisma.user.update as any).mockResolvedValue({}) + + await authController.verifyOtp(mockRequest as Request, mockResponse as Response) + + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: 'user1' }, + data: { phone: '+2348012345678', phoneVerifiedAt: expect.any(Date) }, + }) + expect(mockResponse.status).toHaveBeenCalledWith(200) + expect(mockResponse.json).toHaveBeenCalledWith({ message: 'Phone number verified successfully' }) + }) + }) }) diff --git a/tests/otp.service.test.ts b/tests/otp.service.test.ts new file mode 100644 index 0000000..0e9a477 --- /dev/null +++ b/tests/otp.service.test.ts @@ -0,0 +1,212 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { OtpService, normalizePhone } from '../src/services/otp.service' +import prisma from '../src/config/database' +import { getSmsProvider } from '../src/services/sms/sms-provider.factory' + +vi.mock('../src/config/database', () => ({ + default: { + otpChallenge: { + updateMany: vi.fn(), + create: vi.fn(), + findFirst: vi.fn(), + update: vi.fn(), + }, + }, +})) + +vi.mock('../src/services/sms/sms-provider.factory', () => ({ + getSmsProvider: vi.fn(), +})) + +vi.mock('../src/utils/logger', () => ({ + default: { + info: vi.fn(), + error: vi.fn(), + }, +})) + +describe('normalizePhone', () => { + it('accepts E.164 phone numbers', () => { + expect(normalizePhone('+2348012345678')).toBe('+2348012345678') + expect(normalizePhone(' +14155552671 ')).toBe('+14155552671') + }) + + it('rejects numbers without a leading +', () => { + expect(normalizePhone('2348012345678')).toBeNull() + }) + + it('rejects numbers that are too short or too long', () => { + expect(normalizePhone('+1234567')).toBeNull() + expect(normalizePhone('+1234567890123456')).toBeNull() + }) + + it('rejects non-numeric input', () => { + expect(normalizePhone('+abc4567890')).toBeNull() + }) +}) + +describe('OtpService', () => { + let otpService: OtpService + let mockSend: ReturnType + + beforeEach(() => { + otpService = new OtpService() + mockSend = vi.fn().mockResolvedValue({ success: true, providerMessageId: 'mock_1' }) + ;(getSmsProvider as any).mockReturnValue({ send: mockSend }) + vi.clearAllMocks() + mockSend.mockResolvedValue({ success: true, providerMessageId: 'mock_1' }) + ;(getSmsProvider as any).mockReturnValue({ send: mockSend }) + }) + + describe('requestChallenge', () => { + it('revokes prior pending challenges and creates a fresh one', async () => { + ;(prisma.otpChallenge.updateMany as any).mockResolvedValue({ count: 1 }) + ;(prisma.otpChallenge.create as any).mockResolvedValue({ id: 'c1' }) + + await otpService.requestChallenge('+2348012345678', 'LOGIN', 'user1', { ip: '1.2.3.4' }) + + expect(prisma.otpChallenge.updateMany).toHaveBeenCalledWith({ + where: { userId: 'user1', purpose: 'LOGIN', status: 'PENDING' }, + data: { status: 'REVOKED' }, + }) + expect(prisma.otpChallenge.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + userId: 'user1', + phone: '+2348012345678', + purpose: 'LOGIN', + requestIp: '1.2.3.4', + }), + }) + ) + expect(mockSend).toHaveBeenCalledWith('+2348012345678', expect.stringContaining('expires in 5 minutes')) + }) + + it('never sends the raw code hash unhashed to the database', async () => { + ;(prisma.otpChallenge.updateMany as any).mockResolvedValue({ count: 0 }) + ;(prisma.otpChallenge.create as any).mockResolvedValue({ id: 'c1' }) + + await otpService.requestChallenge('+2348012345678', 'PHONE_VERIFICATION', 'user1', {}) + + const createArgs = (prisma.otpChallenge.create as any).mock.calls[0][0] + expect(createArgs.data.codeHash).toMatch(/^[0-9a-f]{64}$/) + + const smsBody = mockSend.mock.calls[0][1] as string + const codeInSms = smsBody.match(/code is (\d{6})/)?.[1] + expect(codeInSms).toBeTruthy() + expect(createArgs.data.codeHash).not.toBe(codeInSms) + }) + }) + + describe('verifyChallenge', () => { + function buildChallenge (overrides: Partial = {}) { + return { + id: 'c1', + userId: 'user1', + phone: '+2348012345678', + purpose: 'LOGIN', + codeHash: 'deadbeef', + status: 'PENDING', + attempts: 0, + maxAttempts: 5, + expiresAt: new Date(Date.now() + 60_000), + createdAt: new Date(), + ...overrides, + } + } + + it('returns not_found when no pending challenge exists', async () => { + ;(prisma.otpChallenge.findFirst as any).mockResolvedValue(null) + + const result = await otpService.verifyChallenge('+2348012345678', '123456', 'LOGIN') + + expect(result).toEqual({ ok: false, reason: 'not_found' }) + }) + + it('returns not_found when the challenge belongs to a different user (authenticated flow)', async () => { + ;(prisma.otpChallenge.findFirst as any).mockResolvedValue(buildChallenge({ userId: 'someone-else' })) + + const result = await otpService.verifyChallenge('+2348012345678', '123456', 'PHONE_VERIFICATION', 'user1') + + expect(result).toEqual({ ok: false, reason: 'not_found' }) + }) + + it('marks the challenge expired and returns expired', async () => { + ;(prisma.otpChallenge.findFirst as any).mockResolvedValue( + buildChallenge({ expiresAt: new Date(Date.now() - 1000) }) + ) + ;(prisma.otpChallenge.update as any).mockResolvedValue({}) + + const result = await otpService.verifyChallenge('+2348012345678', '123456', 'LOGIN') + + expect(result).toEqual({ ok: false, reason: 'expired' }) + expect(prisma.otpChallenge.update).toHaveBeenCalledWith({ + where: { id: 'c1' }, + data: { status: 'EXPIRED' }, + }) + }) + + it('locks a challenge that already exhausted its attempts', async () => { + ;(prisma.otpChallenge.findFirst as any).mockResolvedValue( + buildChallenge({ attempts: 5, maxAttempts: 5 }) + ) + ;(prisma.otpChallenge.update as any).mockResolvedValue({}) + + const result = await otpService.verifyChallenge('+2348012345678', '123456', 'LOGIN') + + expect(result).toEqual({ ok: false, reason: 'locked' }) + expect(prisma.otpChallenge.update).toHaveBeenCalledWith({ + where: { id: 'c1' }, + data: { status: 'LOCKED' }, + }) + }) + + it('increments attempts on a wrong code without locking below the threshold', async () => { + ;(prisma.otpChallenge.findFirst as any).mockResolvedValue( + buildChallenge({ attempts: 1, maxAttempts: 5 }) + ) + ;(prisma.otpChallenge.update as any).mockResolvedValue({}) + + const result = await otpService.verifyChallenge('+2348012345678', '000000', 'LOGIN') + + expect(result).toEqual({ ok: false, reason: 'mismatch' }) + expect(prisma.otpChallenge.update).toHaveBeenCalledWith({ + where: { id: 'c1' }, + data: { attempts: 2 }, + }) + }) + + it('locks out on the attempt that reaches maxAttempts', async () => { + ;(prisma.otpChallenge.findFirst as any).mockResolvedValue( + buildChallenge({ attempts: 4, maxAttempts: 5 }) + ) + ;(prisma.otpChallenge.update as any).mockResolvedValue({}) + + const result = await otpService.verifyChallenge('+2348012345678', '000000', 'LOGIN') + + expect(result).toEqual({ ok: false, reason: 'locked' }) + expect(prisma.otpChallenge.update).toHaveBeenCalledWith({ + where: { id: 'c1' }, + data: { attempts: 5, status: 'LOCKED' }, + }) + }) + + it('consumes the challenge and returns ok on a correct code', async () => { + const crypto = await import('crypto') + const correctHash = crypto.createHash('sha256').update('+2348012345678:123456').digest('hex') + + ;(prisma.otpChallenge.findFirst as any).mockResolvedValue( + buildChallenge({ codeHash: correctHash }) + ) + ;(prisma.otpChallenge.update as any).mockResolvedValue({}) + + const result = await otpService.verifyChallenge('+2348012345678', '123456', 'LOGIN') + + expect(result).toEqual({ ok: true, userId: 'user1' }) + expect(prisma.otpChallenge.update).toHaveBeenCalledWith({ + where: { id: 'c1' }, + data: { status: 'CONSUMED', consumedAt: expect.any(Date) }, + }) + }) + }) +})