From 90f6abdabed8b5dd908218431c38fe67b20e2e4c Mon Sep 17 00:00:00 2001 From: Chandresh Kerkar Date: Sat, 13 Dec 2025 14:14:36 +0530 Subject: [PATCH] updated code --- .gitignore | 2 + ADMIN_DASHBOARD_QUICK_START.md | 2 + ADMIN_DASHBOARD_SECURITY.md | 2 + CORS_XSS_IMPLEMENTATION.md | 229 +++++++ CSRF_NOTES.md | 2 + DATABASE_OVERVIEW.md | 341 +++++++++ DOCKER_SETUP.md | 374 ++++++++++ GEMINI_PROMPT_AUTH_IMPLEMENTATION.md | 2 + GEMINI_PROMPT_CONCISE.md | 2 + KOTLIN_INTEGRATION_GUIDE.md | 2 + LOGOUT_ALL_DEVICES_IMPLEMENTATION.md | 209 ++++++ OTP_TABLE_ANALYSIS.md | 316 +++++++++ RATE_LIMITING_IMPLEMENTATION.md | 2 + REMAINING_SECURITY_GAPS.md | 2 + SECURITY_AUDIT_REPORT.md | 236 ++++--- SECURITY_HARDENING_SUMMARY.md | 2 + SETUP.md | 2 + TIMING_ATTACK_PROTECTION.md | 294 ++++++++ XSS_PREVENTION_GUIDE.md | 287 ++++++++ db.zip | Bin 0 -> 35182 bytes db/farmmarket-db | 2 +- docs/ARCHITECTURE.md | 949 ++++++++++++++++++++++++++ src/index.js | 27 +- src/middleware/authMiddleware.js | 28 +- src/middleware/rateLimitMiddleware.js | 82 +++ src/middleware/securityHeaders.js | 83 ++- src/middleware/stepUpAuth.js | 2 + src/middleware/userRateLimit.js | 184 +++++ src/routes/authRoutes.js | 227 ++++-- src/routes/userRoutes.js | 66 +- src/services/auditLogger.js | 174 +++-- src/services/jwtKeys.js | 2 + src/services/otpService.js | 79 ++- src/services/riskScoring.js | 2 + src/services/tokenService.js | 54 ++ src/utils/corsValidator.js | 110 +++ src/utils/enumerationDetection.js | 262 +++++++ src/utils/otpLogger.js | 2 + src/utils/timingProtection.js | 146 ++++ 39 files changed, 4544 insertions(+), 245 deletions(-) create mode 100644 CORS_XSS_IMPLEMENTATION.md create mode 100644 DATABASE_OVERVIEW.md create mode 100644 DOCKER_SETUP.md create mode 100644 LOGOUT_ALL_DEVICES_IMPLEMENTATION.md create mode 100644 OTP_TABLE_ANALYSIS.md create mode 100644 TIMING_ATTACK_PROTECTION.md create mode 100644 XSS_PREVENTION_GUIDE.md create mode 100644 db.zip create mode 100644 docs/ARCHITECTURE.md create mode 100644 src/middleware/userRateLimit.js create mode 100644 src/utils/corsValidator.js create mode 100644 src/utils/enumerationDetection.js create mode 100644 src/utils/timingProtection.js diff --git a/.gitignore b/.gitignore index ef9c1df..a81aed7 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,5 @@ build/ + + diff --git a/ADMIN_DASHBOARD_QUICK_START.md b/ADMIN_DASHBOARD_QUICK_START.md index c0c1287..0893f04 100644 --- a/ADMIN_DASHBOARD_QUICK_START.md +++ b/ADMIN_DASHBOARD_QUICK_START.md @@ -64,3 +64,5 @@ See `ADMIN_DASHBOARD_SECURITY.md` for complete details. + + diff --git a/ADMIN_DASHBOARD_SECURITY.md b/ADMIN_DASHBOARD_SECURITY.md index 7644159..90940d4 100644 --- a/ADMIN_DASHBOARD_SECURITY.md +++ b/ADMIN_DASHBOARD_SECURITY.md @@ -383,3 +383,5 @@ Your secure Admin Security Dashboard is **fully implemented** and ready for prod + + diff --git a/CORS_XSS_IMPLEMENTATION.md b/CORS_XSS_IMPLEMENTATION.md new file mode 100644 index 0000000..957865c --- /dev/null +++ b/CORS_XSS_IMPLEMENTATION.md @@ -0,0 +1,229 @@ +# CORS + XSS Security Implementation Summary + +## Overview + +This document summarizes the security enhancements implemented to address CORS and XSS vulnerabilities (Issue #14 from the Security Audit Report). + +## Implementation Date + +2024 + +## Status + +✅ **FULLY IMPLEMENTED** + +## Changes Made + +### 1. CORS Startup Validation + +**File:** `src/utils/corsValidator.js` + +- **Startup Validation:** Validates CORS configuration when the application starts +- **Production Enforcement:** Fails fast if CORS origins are not configured in production +- **Origin Format Validation:** Checks for valid URL format and warns about suspicious patterns +- **Wildcard Detection:** Prevents wildcard (`*`) usage in production + +**Features:** +- Validates that `CORS_ALLOWED_ORIGINS` is set in production +- Checks origin format (must be valid URLs) +- Warns about HTTP origins in production (should be HTTPS) +- Prevents wildcard origins in production +- Provides clear error messages for misconfiguration + +### 2. Runtime CORS Checks + +**File:** `src/utils/corsValidator.js` + +- **Runtime Monitoring:** Checks CORS requests at runtime +- **Suspicious Pattern Detection:** Identifies potentially misconfigured origins +- **Logging:** Logs warnings for suspicious CORS patterns + +**Features:** +- Detects HTTP origins in production +- Identifies localhost/127.0.0.1 usage in production +- Logs blocked origins for monitoring +- Provides runtime feedback without blocking legitimate requests + +### 3. Content Security Policy (CSP) + +**File:** `src/middleware/securityHeaders.js` + +- **CSP Headers:** Implements comprehensive Content Security Policy +- **Nonce Support:** Generates unique nonces for each request to allow safe inline scripts/styles +- **Strict Directives:** Restricts resource loading to prevent XSS attacks + +**CSP Directives:** +- `default-src 'self'` - Only allow resources from same origin +- `script-src 'self' 'nonce-...' 'unsafe-eval'` - Scripts from self or with nonce +- `style-src 'self' 'nonce-...'` - Styles from self or with nonce +- `img-src 'self' data: https:` - Images from self, data URIs, or HTTPS +- `font-src 'self' data: https:` - Fonts from self, data URIs, or HTTPS +- `connect-src 'self' [CORS origins]` - API calls to self or allowed CORS origins +- `frame-ancestors 'none'` - Prevent embedding (clickjacking protection) +- `base-uri 'self'` - Restrict base tag +- `form-action 'self'` - Forms can only submit to same origin +- `upgrade-insecure-requests` - Upgrade HTTP to HTTPS + +**Nonce Usage:** +- Nonce is generated per request and stored in `res.locals.cspNonce` +- Can be used in templates/views for inline scripts/styles +- Example: `` + +### 4. Additional Security Headers + +**File:** `src/middleware/securityHeaders.js` + +Added headers: +- **Referrer-Policy:** `strict-origin-when-cross-origin` - Controls referrer information +- **Permissions-Policy:** Restricts browser features (geolocation, camera, microphone, etc.) + +Existing headers maintained: +- `X-Frame-Options: DENY` - Clickjacking protection +- `X-Content-Type-Options: nosniff` - MIME type sniffing protection +- `X-XSS-Protection: 1; mode=block` - Legacy XSS filter +- `Strict-Transport-Security` - HSTS for HTTPS enforcement + +### 5. Global Security Headers Application + +**File:** `src/index.js` + +- **Before:** Security headers only applied to admin routes +- **After:** Security headers applied to ALL routes globally +- **Placement:** Applied early in middleware chain for maximum protection + +### 6. XSS Prevention Documentation + +**File:** `XSS_PREVENTION_GUIDE.md` + +Comprehensive guide covering: +- Server-side protections +- Frontend best practices +- Output encoding techniques +- Framework-specific guidance (React, Vue, Angular) +- Common attack vectors +- Testing methodologies +- Security checklist + +## Configuration + +### Environment Variables + +No new environment variables required. Uses existing: +- `CORS_ALLOWED_ORIGINS` - Comma-separated list of allowed origins (required in production) +- `NODE_ENV` - Set to `production` for strict validation + +### CSP Configuration + +CSP is automatically configured. To customize: + +1. Edit `src/middleware/securityHeaders.js` +2. Modify the `buildCSP()` function +3. Adjust directives as needed + +**Note:** Current CSP allows `'unsafe-inline'` and `'unsafe-eval'` for compatibility. Consider tightening in production by: +- Removing `'unsafe-inline'` and using nonces exclusively +- Removing `'unsafe-eval'` if not needed + +## Testing + +### CORS Validation Testing + +1. **Test startup validation:** + ```bash + # Should fail in production without CORS_ALLOWED_ORIGINS + NODE_ENV=production node src/index.js + ``` + +2. **Test runtime checks:** + - Make request from non-whitelisted origin + - Check logs for warnings + +### CSP Testing + +1. **Test CSP headers:** + ```bash + curl -I http://localhost:3000/health + # Check for Content-Security-Policy header + ``` + +2. **Test nonce generation:** + - Each request should have unique nonce + - Check `res.locals.cspNonce` in route handlers + +### Browser Testing + +1. Open browser DevTools → Network tab +2. Check response headers for: + - `Content-Security-Policy` + - `X-Frame-Options` + - `Referrer-Policy` + - `Permissions-Policy` + +## Security Impact + +### Before +- ⚠️ No CORS validation at startup +- ⚠️ No runtime CORS monitoring +- ⚠️ No CSP headers +- ⚠️ Security headers only on admin routes +- ⚠️ No XSS prevention guidance + +### After +- ✅ CORS validated at startup (fails fast if misconfigured) +- ✅ Runtime CORS monitoring and logging +- ✅ Comprehensive CSP with nonce support +- ✅ Security headers on all routes +- ✅ Complete XSS prevention documentation + +## Risk Level + +**Before:** 🟡 MEDIUM +**After:** 🟢 LOW + +## Migration Notes + +### Breaking Changes + +None. All changes are backward compatible. + +### Recommendations + +1. **Tighten CSP in production:** + - Remove `'unsafe-inline'` and use nonces + - Remove `'unsafe-eval'` if not needed + +2. **Monitor CORS logs:** + - Watch for blocked origins + - Review suspicious pattern warnings + +3. **Update frontend code:** + - Follow XSS prevention guide + - Use nonces for inline scripts/styles + - Sanitize user input + +## Files Modified + +1. `src/utils/corsValidator.js` - **NEW FILE** +2. `src/middleware/securityHeaders.js` - **ENHANCED** +3. `src/index.js` - **UPDATED** +4. `XSS_PREVENTION_GUIDE.md` - **NEW FILE** +5. `SECURITY_AUDIT_REPORT.md` - **UPDATED** + +## References + +- [OWASP XSS Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html) +- [MDN Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) +- [CORS Specification](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) + +## Support + +For questions or issues: +1. Review `XSS_PREVENTION_GUIDE.md` for frontend guidance +2. Check `SECURITY_AUDIT_REPORT.md` for security context +3. Review code comments in implementation files + +--- + +**Implementation Status:** ✅ Complete +**Testing Status:** ✅ Syntax validated +**Documentation Status:** ✅ Complete diff --git a/CSRF_NOTES.md b/CSRF_NOTES.md index 0683871..2d8f30f 100644 --- a/CSRF_NOTES.md +++ b/CSRF_NOTES.md @@ -80,3 +80,5 @@ function csrfProtection(req, res, next) { + + diff --git a/DATABASE_OVERVIEW.md b/DATABASE_OVERVIEW.md new file mode 100644 index 0000000..e901b6d --- /dev/null +++ b/DATABASE_OVERVIEW.md @@ -0,0 +1,341 @@ +# Database Overview - What Your Database is Doing + +## Database Purpose + +Your PostgreSQL database is the **central data store** for a **Farm Market Authentication Service** that handles: +1. **User Authentication** (Phone-based OTP login) +2. **Farm/Marketplace Data** (Animals, Listings, Locations) +3. **Security & Audit Logging** +4. **Session Management** (Multi-device support) + +--- + +## Table Categories + +### 🔐 **AUTHENTICATION TABLES** + +#### 1. `users` - User Accounts +**Purpose:** Store user account information +**Key Fields:** +- `id` (UUID) - Primary key +- `phone_number` (UNIQUE) - Phone is the login identifier +- `name` - User's name (can be NULL until profile completed) +- `user_type` - Enum: 'seller', 'buyer', 'service_provider' +- `role` - System role: 'user', 'admin', 'moderator' +- `token_version` - Incremented on logout-all to invalidate all tokens +- `last_login_at` - Tracks last login time + +**Current Usage:** +- Created **AFTER** OTP verification (find-or-create pattern) +- Phone number is encrypted before storage +- One phone number = One account (UNIQUE constraint) + +#### 2. `otp_codes` - OTP Storage (ACTIVELY USED) +**Purpose:** Store OTP codes for phone verification +**Key Fields:** +- `id` (UUID) +- `phone_number` - Phone requesting OTP (NO user_id - user doesn't exist yet) +- `otp_hash` - Hashed OTP code (bcrypt) +- `expires_at` - OTP expiry (2 minutes default) +- `attempt_count` - Failed verification attempts +- `created_at` + +**Current Usage:** +- Created when `/auth/request-otp` is called +- Deleted after successful verification or expiry +- Used for OTP verification in `/auth/verify-otp` + +#### 3. `otp_requests` - Legacy OTP Table (NOT ACTIVELY USED) +**Purpose:** Alternative OTP storage (currently unused) +**Note:** Your code uses `otp_codes` table, not `otp_requests` + +#### 4. `refresh_tokens` - Session Management +**Purpose:** Store refresh tokens for JWT authentication +**Key Fields:** +- `id` (UUID) +- `user_id` - Links to users table +- `token_id` (UNIQUE) - UUID identifier for token +- `token_hash` - Hashed refresh token (bcrypt) +- `device_id` - Device identifier from client +- `expires_at` - Token expiry +- `revoked_at` - NULL = active, timestamp = revoked +- `rotated_from_id` - Links to previous token (rotation tracking) +- `reuse_detected_at` - Detects token theft/reuse + +**Current Usage:** +- Created during OTP verification +- Rotated on each refresh (old token revoked, new one created) +- Tracks which device each token belongs to +- Supports multi-device logins (one token per device) + +#### 5. `user_devices` - Device Tracking +**Purpose:** Track user's logged-in devices +**Key Fields:** +- `id` (UUID) +- `user_id` - Links to users +- `device_identifier` - Unique device ID (e.g., Firebase Installation ID) +- `device_platform` - 'android', 'ios' +- `device_model`, `os_version`, `app_version` +- `first_seen_at`, `last_seen_at` +- `is_active` - Whether device session is active +- UNIQUE(`user_id`, `device_identifier`) - One record per user-device pair + +**Current Usage:** +- Created/updated during OTP verification +- Tracks all devices a user is logged in from +- Used for device management (view/revoke devices) + +#### 6. `auth_audit` - Security Audit Log +**Purpose:** Log all authentication events for security monitoring +**Key Fields:** +- `id` (UUID) +- `user_id` - NULL if user doesn't exist yet (e.g., failed login) +- `action` - 'otp_request', 'otp_verify', 'token_refresh', 'logout', etc. +- `status` - 'success', 'failed', 'error' +- `ip_address`, `user_agent`, `device_id` +- `meta` (JSONB) - Additional metadata (errors, risk scores, etc.) +- `created_at` + +**Current Usage:** +- Logs every authentication event +- Used by admin dashboard for security monitoring +- Used for risk scoring and anomaly detection +- Tracks suspicious activities (enumeration, brute force, etc.) + +--- + +### 🏪 **MARKETPLACE TABLES** + +#### 7. `species` - Animal Species +**Purpose:** Master data for animal species (e.g., "Cow", "Goat", "Sheep") +**Key Fields:** +- `id` (UUID) +- `name` (UNIQUE) + +#### 8. `breeds` - Animal Breeds +**Purpose:** Breeds within each species (e.g., "Holstein", "Jersey" for Cow) +**Key Fields:** +- `id` (UUID) +- `species_id` - Links to species +- `name` - Breed name +- UNIQUE(`species_id`, `name`) + +#### 9. `locations` - Geographic Locations +**Purpose:** Store farm/home/office locations +**Key Fields:** +- `id` (UUID) +- `user_id` - Links to users (NULL = temporary/captured location) +- `is_saved_address` - Whether user saved this location +- `location_type` - Enum: 'farm', 'home', 'office', etc. +- `country`, `state`, `district`, `city_village`, `pincode` +- `lat`, `lng` - GPS coordinates +- `source_type` - 'device_gps', 'manual', 'unknown' +- `source_confidence` - 'high', 'medium', 'low' + +**Current Usage:** +- Stores where animals are kept +- Stores listing locations +- Can be temporary (no user_id) or saved (with user_id) + +#### 10. `animals` - Animal Records +**Purpose:** Store individual animal information +**Key Fields:** +- `id` (UUID) +- `species_id`, `breed_id` - Animal classification +- `location_id` - Where animal is kept +- `sex` - Enum: 'M', 'F', 'Neutered' +- `age_months`, `weight_kg` +- `purpose` - Enum: 'dairy', 'meat', 'breeding', 'pet', 'work', 'other' +- `health_status` - Enum: 'healthy', 'minor_issues', 'serious_issues' +- `vaccinated`, `dewormed` +- `milk_yield_litre_per_day` - For dairy animals +- `ear_tag_no` - Ear tag identification +- `quantity` - Number of animals (default: 1) + +**Current Usage:** +- Created by sellers when they want to list animals +- Linked to listings (one animal per listing) + +#### 11. `listings` - Marketplace Listings +**Purpose:** Animals for sale/stud service/adoption +**Key Fields:** +- `id` (UUID) +- `seller_id` - Links to users (who is selling) +- `animal_id` - Links to animals (what is being sold) +- `title`, `price`, `currency` (default: 'INR') +- `listing_type` - Enum: 'sale', 'stud_service', 'adoption' +- `status` - Enum: 'active', 'sold', 'expired', 'hidden' +- `is_negotiable` - Price negotiation allowed +- Engagement metrics: `views_count`, `bookmarks_count`, `enquiries_call_count`, `enquiries_whatsapp_count`, `clicks_count` + +**Current Usage:** +- Created when seller lists an animal +- Tracked engagement metrics +- Can be marked as sold/expired/hidden + +#### 12. `listing_images` - Listing Photos +**Purpose:** Store images for listings +**Key Fields:** +- `id` (UUID) +- `listing_id` - Links to listings +- `image_url` - URL to image +- `is_primary` - Main image flag +- `sort_order` - Image ordering + +--- + +### 🔮 **FUTURE TABLES** + +#### 13. `oauth_accounts` - OAuth Integration (Placeholder) +**Purpose:** Future OAuth support (Google, Facebook, Apple) +**Key Fields:** +- `id` (UUID) +- `user_id` - Links to users +- `provider` - 'google', 'facebook', 'apple' +- `provider_user_id` - OAuth provider's user ID +- UNIQUE(`provider`, `provider_user_id`) + +**Current Usage:** +- Table exists but not actively used yet + +--- + +## Key Relationships & Constraints + +### Foreign Key Relationships: +``` +users (1) ──< (many) user_devices +users (1) ──< (many) refresh_tokens +users (1) ──< (many) listings +users (1) ──< (many) locations +listings (1) ──< (many) listing_images +listings (many) ──> (1) animals +animals (many) ──> (1) locations +animals (many) ──> (1) species +animals (many) ──> (1) breeds +breeds (many) ──> (1) species +``` + +### Cascade Behaviors: +- **DELETE user** → Cascades delete to: `user_devices`, `refresh_tokens`, `listings`, `locations` +- **DELETE species** → RESTRICTED (can't delete if animals reference it) +- **DELETE breed** → Sets `breed_id` to NULL on animals (SET NULL) +- **DELETE listing** → Cascades delete to: `listing_images` +- **DELETE animal** → RESTRICTED (can't delete if listings reference it) +- **DELETE location** → RESTRICTED (can't delete if animals reference it) + +--- + +## Current Data Flow + +### Authentication Flow: +``` +1. User requests OTP + └─> INSERT into `otp_codes` (phone_number, otp_hash, expires_at) + +2. User verifies OTP + ├─> Verify OTP from `otp_codes` table + ├─> DELETE OTP from `otp_codes` + ├─> FIND or CREATE user in `users` table + ├─> INSERT/UPDATE `user_devices` table + ├─> INSERT into `refresh_tokens` table + └─> INSERT into `auth_audit` (log event) + +3. User refreshes token + ├─> Verify refresh token from `refresh_tokens` table + ├─> ROTATE token (revoke old, create new) + └─> INSERT into `auth_audit` (log event) +``` + +### Marketplace Flow: +``` +1. Seller creates listing + ├─> CREATE `location` (where animal is) + ├─> CREATE `animal` (animal details) + └─> CREATE `listing` (link animal to seller) + +2. Buyer views listing + └─> UPDATE `listings.views_count` + +3. Buyer bookmarks/contacts + └─> UPDATE engagement metrics (bookmarks_count, enquiries_*) +``` + +--- + +## Database Features + +### 1. **Automatic Timestamps** +- All tables have `created_at` and `updated_at` +- `updated_at` automatically updated via database triggers + +### 2. **UUID Primary Keys** +- All tables use UUIDs (not auto-increment integers) +- Generated via PostgreSQL `uuid-ossp` extension + +### 3. **Enum Types** +- PostgreSQL ENUM types for controlled values: + - `sex_enum`, `purpose_enum`, `health_status_enum` + - `location_type_enum`, `listing_type_enum`, `listing_status_enum` + - `user_type_enum`, `listing_role_enum` + +### 4. **Indexes** +- Indexes on foreign keys for fast joins +- Indexes on frequently queried fields (phone_number, expires_at, status) +- Partial indexes for performance (e.g., unconsumed OTPs) + +### 5. **Data Integrity** +- CHECK constraints (e.g., non-negative counters) +- UNIQUE constraints (phone_number, user+device pairs) +- Foreign key constraints with appropriate CASCADE/RESTRICT behaviors + +--- + +## What's NOT in the Database (Handled Elsewhere) + +1. **Access Tokens (JWT)** - Stored only on client, validated via signature +2. **Rate Limiting Counters** - Stored in Redis or in-memory (not PostgreSQL) +3. **OTP Codes (Plain)** - Only hashed versions stored, plain codes never persisted +4. **SMS Messages** - Sent via Twilio, not stored in database +5. **User Passwords** - Phone-based auth only, no password storage + +--- + +## Current Issues / Design Notes + +### ⚠️ Dual OTP Tables +- `otp_codes` - **ACTIVELY USED** by your code +- `otp_requests` - **NOT USED** (legacy/alternative table) +- **Recommendation:** Consider removing `otp_requests` if not needed + +### ⚠️ Phone Number Encryption +- Phone numbers in `users` and `otp_codes` are **encrypted at application level** +- Database stores encrypted values, not plaintext +- Encryption handled by `src/utils/fieldEncryption.js` + +### ✅ Multi-Device Support +- Users can log in from multiple devices simultaneously +- Each device has its own refresh token +- Devices can be managed (viewed/revoked) via API + +### ✅ Security Features +- OTP attempt tracking (prevents brute force) +- Token rotation (prevents token reuse) +- Audit logging (tracks all auth events) +- Risk scoring (detects suspicious activity) + +--- + +## Summary + +Your database is a **well-structured PostgreSQL database** that: +- ✅ Handles phone-based authentication securely +- ✅ Supports a farm marketplace (animals, listings, locations) +- ✅ Tracks multi-device user sessions +- ✅ Logs security events for monitoring +- ✅ Uses proper constraints and relationships +- ✅ Supports data encryption at application level + +The design follows good practices with UUIDs, enums, cascades, and proper indexing. + + diff --git a/DOCKER_SETUP.md b/DOCKER_SETUP.md new file mode 100644 index 0000000..0c05fea --- /dev/null +++ b/DOCKER_SETUP.md @@ -0,0 +1,374 @@ +# Docker PostgreSQL Setup Guide + +This guide will help you set up and run PostgreSQL using Docker for the Farm Auth Service. + +--- + +## Prerequisites + +### 1. Install Docker Desktop + +**For Windows:** +1. Download Docker Desktop from: https://www.docker.com/products/docker-desktop +2. Run the installer and follow the setup wizard +3. Restart your computer if prompted +4. Launch Docker Desktop and wait for it to start (you'll see a whale icon in the system tray) + +**Verify Docker is installed:** +```bash +docker --version +docker-compose --version +``` + +You should see version numbers for both commands. + +--- + +## Quick Start + +### Step 1: Navigate to Docker Compose Directory + +```bash +cd g:\LivingAi\farm-auth-service\db\farmmarket-db +``` + +### Step 2: Start PostgreSQL Container + +```bash +docker-compose up -d +``` + +The `-d` flag runs the container in **detached mode** (in the background). + +**What this does:** +- Downloads PostgreSQL 16 image (if not already downloaded) +- Creates a container named `farmmarket-postgres` +- Starts PostgreSQL on port `5433` (host) → `5432` (container) +- Automatically runs `init.sql` to create database schema +- Creates a persistent volume to store data + +### Step 3: Verify Container is Running + +```bash +docker ps +``` + +You should see `farmmarket-postgres` in the list with status "Up". + +### Step 4: Check Logs (Optional) + +```bash +docker-compose logs -f +``` + +Press `Ctrl+C` to exit log view. + +--- + +## Database Configuration + +### Connection Details + +From your `docker-compose.yml`: + +| Setting | Value | +|---------|-------| +| **Host** | `localhost` | +| **Port** | `5433` | +| **Database** | `farmmarket` | +| **Username** | `postgres` | +| **Password** | `password123` | + +### Connection String + +Use this in your `.env` file: + +```env +DATABASE_URL=postgres://postgres:password123@localhost:5433/farmmarket +``` + +--- + +## Understanding docker-compose.yml + +```yaml +services: + postgres: + image: postgres:16 # PostgreSQL version 16 + container_name: farmmarket-postgres # Container name + restart: always # Auto-restart if container stops + environment: + POSTGRES_USER: postgres # Database user + POSTGRES_PASSWORD: password123 # Database password + POSTGRES_DB: farmmarket # Database name + ports: + - "5433:5432" # Host:Container port mapping + volumes: + - postgres_data:/var/lib/postgresql/data # Persistent data storage + - ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro # Auto-run SQL on first start +``` + +**Key Points:** +- **Port 5433**: External port (what you connect to) +- **Port 5432**: Internal container port (PostgreSQL default) +- **Volumes**: Data persists even if container is removed +- **init.sql**: Runs automatically on first container start + +--- + +## Common Commands + +### Start Database +```bash +cd g:\LivingAi\farm-auth-service\db\farmmarket-db +docker-compose up -d +``` + +### Stop Database +```bash +docker-compose down +``` + +### Stop and Remove All Data (⚠️ WARNING: Deletes database) +```bash +docker-compose down -v +``` + +### Restart Database +```bash +docker-compose restart +``` + +### View Container Status +```bash +docker-compose ps +``` + +### View Logs +```bash +# All logs +docker-compose logs + +# Follow logs (live) +docker-compose logs -f + +# Last 100 lines +docker-compose logs --tail=100 +``` + +### Access PostgreSQL CLI +```bash +docker exec -it farmmarket-postgres psql -U postgres -d farmmarket +``` + +Once inside, you can run SQL commands: +```sql +\dt -- List all tables +SELECT * FROM users; -- Query users table +\q -- Exit +``` + +--- + +## Troubleshooting + +### Problem: "Cannot connect to Docker daemon" + +**Solution:** +- Make sure Docker Desktop is running +- Check the system tray for Docker icon +- Restart Docker Desktop if needed + +### Problem: "Port 5433 is already in use" + +**Solution 1:** Stop the existing service using port 5433 +```bash +# Find what's using the port (Windows) +netstat -ano | findstr :5433 + +# Kill the process (replace PID with actual process ID) +taskkill /PID /F +``` + +**Solution 2:** Change the port in `docker-compose.yml` +```yaml +ports: + - "5434:5432" # Change 5433 to 5434 +``` + +Then update your `.env`: +```env +DATABASE_URL=postgres://postgres:password123@localhost:5434/farmmarket +``` + +### Problem: Container keeps stopping + +**Check logs:** +```bash +docker-compose logs +``` + +**Common causes:** +- Port conflict +- Insufficient disk space +- Memory issues + +**Restart container:** +```bash +docker-compose restart +``` + +### Problem: Database schema not created + +**Solution:** +1. Stop and remove container: + ```bash + docker-compose down -v + ``` +2. Start again (this will re-run init.sql): + ```bash + docker-compose up -d + ``` + +### Problem: "Permission denied" on init.sql + +**Solution (Windows):** +- Make sure `init.sql` file exists in `db/farmmarket-db/` directory +- Check file permissions (should be readable) + +### Problem: Can't connect from application + +**Check:** +1. Container is running: `docker ps` +2. Port is correct: `5433` (not `5432`) +3. Connection string in `.env` matches: + ```env + DATABASE_URL=postgres://postgres:password123@localhost:5433/farmmarket + ``` + +--- + +## Advanced Configuration + +### Change Database Password + +1. Edit `docker-compose.yml`: + ```yaml + POSTGRES_PASSWORD: your-new-password + ``` + +2. Update `.env`: + ```env + DATABASE_URL=postgres://postgres:your-new-password@localhost:5433/farmmarket + ``` + +3. Recreate container: + ```bash + docker-compose down -v + docker-compose up -d + ``` + +### Change Port + +1. Edit `docker-compose.yml`: + ```yaml + ports: + - "5434:5432" # Change 5433 to your desired port + ``` + +2. Update `.env`: + ```env + DATABASE_URL=postgres://postgres:password123@localhost:5434/farmmarket + ``` + +3. Restart: + ```bash + docker-compose restart + ``` + +### Backup Database + +```bash +# Create backup +docker exec farmmarket-postgres pg_dump -U postgres farmmarket > backup.sql + +# Restore backup +docker exec -i farmmarket-postgres psql -U postgres farmmarket < backup.sql +``` + +### View Database Size + +```bash +docker exec farmmarket-postgres psql -U postgres -d farmmarket -c "SELECT pg_size_pretty(pg_database_size('farmmarket'));" +``` + +--- + +## First Time Setup Checklist + +- [ ] Docker Desktop installed and running +- [ ] Navigate to `db/farmmarket-db` directory +- [ ] Run `docker-compose up -d` +- [ ] Verify container is running: `docker ps` +- [ ] Check logs for any errors: `docker-compose logs` +- [ ] Update `.env` with correct `DATABASE_URL` +- [ ] Test connection from your application + +--- + +## Environment Variables for Application + +After Docker is running, add this to your `.env` file in the project root: + +```env +# Database (from Docker) +DATABASE_URL=postgres://postgres:password123@localhost:5433/farmmarket + +# JWT Secrets (generate these) +JWT_ACCESS_SECRET= +JWT_REFRESH_SECRET= +``` + +**Generate JWT secrets:** +```bash +node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +``` + +Run this command twice to get two different secrets. + +--- + +## Stopping Everything + +When you're done working: + +```bash +# Stop database (keeps data) +docker-compose down + +# Stop and delete all data (fresh start) +docker-compose down -v +``` + +--- + +## Need Help? + +- Check Docker Desktop logs +- View container logs: `docker-compose logs` +- Verify container status: `docker ps` +- Test connection: `docker exec -it farmmarket-postgres psql -U postgres -d farmmarket` + +--- + +## Next Steps + +After Docker PostgreSQL is running: + +1. ✅ Update `.env` with `DATABASE_URL` +2. ✅ Generate JWT secrets and add to `.env` +3. ✅ Start your auth service: `npm run dev` +4. ✅ Test OTP request: `POST http://localhost:3000/auth/request-otp` + +Your database is ready! 🎉 + diff --git a/GEMINI_PROMPT_AUTH_IMPLEMENTATION.md b/GEMINI_PROMPT_AUTH_IMPLEMENTATION.md index 653016e..b61e25c 100644 --- a/GEMINI_PROMPT_AUTH_IMPLEMENTATION.md +++ b/GEMINI_PROMPT_AUTH_IMPLEMENTATION.md @@ -424,3 +424,5 @@ This implementation should provide a secure, production-ready authentication sys + + diff --git a/GEMINI_PROMPT_CONCISE.md b/GEMINI_PROMPT_CONCISE.md index 1549a25..56c5f45 100644 --- a/GEMINI_PROMPT_CONCISE.md +++ b/GEMINI_PROMPT_CONCISE.md @@ -84,3 +84,5 @@ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0") + + diff --git a/KOTLIN_INTEGRATION_GUIDE.md b/KOTLIN_INTEGRATION_GUIDE.md index e891848..21d828c 100644 --- a/KOTLIN_INTEGRATION_GUIDE.md +++ b/KOTLIN_INTEGRATION_GUIDE.md @@ -645,3 +645,5 @@ This guide provides everything you need to integrate the `/users/me` endpoint in + + diff --git a/LOGOUT_ALL_DEVICES_IMPLEMENTATION.md b/LOGOUT_ALL_DEVICES_IMPLEMENTATION.md new file mode 100644 index 0000000..7f68f74 --- /dev/null +++ b/LOGOUT_ALL_DEVICES_IMPLEMENTATION.md @@ -0,0 +1,209 @@ +# Logout from All Devices - Implementation Summary + +## Overview + +This document describes the implementation of the "Logout from All Devices" feature, which allows users to immediately invalidate all access and refresh tokens across all devices when a security breach or account compromise is suspected. + +## Implementation Details + +### 1. Database Schema Changes + +**Added Column to `users` table:** +- `token_version` (INT, NOT NULL, DEFAULT 1) + - Incremented on logout-all-devices to invalidate all existing access tokens + - Validated on each access token verification + +**Migration Required:** +For existing databases, run: +```sql +ALTER TABLE users ADD COLUMN IF NOT EXISTS token_version INT NOT NULL DEFAULT 1; +``` + +### 2. Token Service Updates + +**File: `src/services/tokenService.js`** + +- **Updated `signAccessToken()`**: Now includes `token_version` claim in JWT payload +- **Added `revokeAllUserTokens(userId)`**: + - Revokes all refresh tokens for the user + - Marks all devices as inactive + - Increments user's `token_version` to invalidate all access tokens + +### 3. Authentication Middleware Updates + +**File: `src/middleware/authMiddleware.js`** + +- **Made middleware async**: Required for database query +- **Added token version validation**: + - Queries user's current `token_version` from database + - Compares with `token_version` claim in access token + - Rejects token if versions don't match (token has been invalidated) + +### 4. New API Endpoint + +**Endpoint: `POST /users/me/logout-all-devices`** + +**Security Requirements:** +- Requires authentication (access token) +- Requires step-up authentication (recent OTP or `high_assurance` token) +- Rate limited: 10 requests per hour per user + +**Functionality:** +1. Revokes all refresh tokens for the user +2. Marks all devices as inactive +3. Increments user's `token_version` (invalidates all access tokens) +4. Logs HIGH_RISK security event (triggers security alert webhook) + +**Response:** +```json +{ + "ok": true, + "message": "Logged out from all devices successfully", + "revoked_tokens_count": 5 +} +``` + +### 5. Audit Logging + +**Event Type:** `logout_all_devices` +**Risk Level:** `HIGH_RISK` +**Status:** `success` + +**Metadata:** +- `revoked_tokens_count`: Number of refresh tokens revoked +- `new_token_version`: New token version after increment +- `reason`: "user_initiated_global_logout" +- `message`: "User initiated logout from all devices - security breach suspected" + +**Alerting:** This event triggers security alert webhook (if configured) due to HIGH_RISK level. + +## How It Works + +### Access Token Invalidation + +1. **Token Issuance**: When an access token is issued, it includes the user's current `token_version` in the JWT payload. + +2. **Token Validation**: On each authenticated request: + - `authMiddleware` extracts `token_version` from the JWT payload + - Queries the user's current `token_version` from the database + - Compares versions - if they don't match, the token is rejected + +3. **Global Logout**: When user calls `/users/me/logout-all-devices`: + - All refresh tokens are revoked (marked with `revoked_at`) + - All devices are marked as inactive + - User's `token_version` is incremented + - All existing access tokens (even if not expired) become invalid immediately + +### Refresh Token Invalidation + +Refresh tokens are stored in the database and can be directly revoked by setting `revoked_at`. The `revokeAllUserTokens()` function revokes all refresh tokens for a user. + +## Security Considerations + +1. **Step-Up Authentication Required**: Users must provide recent OTP verification or have a `high_assurance` token to perform this action. + +2. **Rate Limiting**: Limited to 10 requests per hour per user to prevent abuse. + +3. **HIGH_RISK Logging**: All logout-all-devices events are logged with HIGH_RISK level and trigger security alerts. + +4. **Immediate Invalidation**: Access tokens are invalidated immediately via token versioning, not just on expiry. + +5. **Database Query Overhead**: Token version validation requires a database query on each authenticated request. This is acceptable for security-critical operations. + +## Testing + +### Manual Testing + +1. **Login and get tokens:** + ```bash + POST /auth/verify-otp + # Save access_token and refresh_token + ``` + +2. **Verify token works:** + ```bash + GET /users/me + Authorization: Bearer + # Should return 200 OK + ``` + +3. **Logout from all devices:** + ```bash + POST /users/me/logout-all-devices + Authorization: Bearer + # Requires step-up auth (recent OTP or high_assurance token) + ``` + +4. **Verify old token is invalid:** + ```bash + GET /users/me + Authorization: Bearer + # Should return 401 Unauthorized + ``` + +5. **Verify refresh token is invalid:** + ```bash + POST /auth/refresh + { "refresh_token": "" } + # Should return 401 Unauthorized + ``` + +## API Integration + +### Request + +```http +POST /users/me/logout-all-devices +Authorization: Bearer +``` + +**Note:** The access token must have `high_assurance: true` or the user must have verified OTP within the last 5 minutes. + +### Response + +**Success (200 OK):** +```json +{ + "ok": true, + "message": "Logged out from all devices successfully", + "revoked_tokens_count": 5 +} +``` + +**Error (403 Forbidden - Step-up required):** +```json +{ + "error": "step_up_required", + "message": "This action requires additional verification. Please verify your OTP first.", + "requires_otp": true +} +``` + +**Error (429 Too Many Requests):** +```json +{ + "error": "Too many requests", + "retry_after": 3600 +} +``` + +## Files Modified + +1. `db/farmmarket-db/init.sql` - Added `token_version` column to users table +2. `src/services/tokenService.js` - Added token versioning and `revokeAllUserTokens()` function +3. `src/middleware/authMiddleware.js` - Added token version validation +4. `src/routes/authRoutes.js` - Updated user queries to include `token_version` +5. `src/routes/userRoutes.js` - Added `/users/me/logout-all-devices` endpoint +6. `docs/ARCHITECTURE.md` - Updated documentation with new flow + +## Future Improvements + +1. **Caching**: Consider caching user's `token_version` in Redis to reduce database queries (with TTL matching access token expiry). + +2. **Metrics**: Add metrics for logout-all-devices events to track security incidents. + +3. **Notification**: Optionally notify user via email/SMS when logout-all-devices is triggered. + +4. **Admin Override**: Allow admins to trigger logout-all-devices for a user (with proper audit logging). + + diff --git a/OTP_TABLE_ANALYSIS.md b/OTP_TABLE_ANALYSIS.md new file mode 100644 index 0000000..c59ae5a --- /dev/null +++ b/OTP_TABLE_ANALYSIS.md @@ -0,0 +1,316 @@ +# OTP Tables Analysis - What Your Code Actually Does + +## Summary: **NO `user_id` in OTP Tables - This is CORRECT** + +Your code uses **phone number only** for OTP tables. Users are created **AFTER** OTP verification. + +--- + +## Database Tables + +### 1. `otp_requests` Table (in `init.sql` lines 94-109) +**Status: ❌ NOT USED BY YOUR CODE** + +```sql +CREATE TABLE IF NOT EXISTS otp_requests ( + id UUID PRIMARY KEY, + phone_number VARCHAR(20) NOT NULL, -- NO user_id! + otp_hash VARCHAR(255) NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + consumed_at TIMESTAMPTZ, -- Extra field for tracking consumption + attempt_count INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` + +**Note:** This table exists in your database schema but is **never referenced in your code**. + +--- + +### 2. `otp_codes` Table (ACTUALLY USED) +**Status: ✅ ACTIVELY USED BY YOUR CODE** + +#### Database Schema (`init.sql` lines 115-122): +```sql +CREATE TABLE IF NOT EXISTS otp_codes ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + phone_number VARCHAR(20) NOT NULL, -- NO user_id! + otp_hash VARCHAR(255) NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + attempt_count INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` + +#### Code Definition (`src/services/otpService.js` lines 34-41): +```javascript +CREATE TABLE IF NOT EXISTS otp_codes ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + phone_number VARCHAR(20) NOT NULL, // ← NO user_id! + otp_hash VARCHAR(255) NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + attempt_count INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` + +**Both schemas match - NO `user_id` column!** + +--- + +## How Your Code Uses `otp_codes` + +### Step 1: Request OTP (`/auth/request-otp`) + +**Location:** `src/routes/authRoutes.js` line 189 +```javascript +const { code } = await createOtp(normalizedPhone); +``` + +**Function:** `src/services/otpService.js` lines 82-117 +```javascript +async function createOtp(phoneNumber) { + await ensureOtpCodesTable(); + const code = generateOtpCode(); + const expiresAt = new Date(Date.now() + OTP_EXPIRY_MS); + const otpHash = await bcrypt.hash(code, 10); + + // Encrypt phone number before storing + const encryptedPhone = encryptPhoneNumber(phoneNumber); + + // Delete any existing OTPs for this phone number + await db.query( + 'DELETE FROM otp_codes WHERE phone_number = $1 OR phone_number = $2', + [encryptedPhone, phoneNumber] + ); + + // ← INSERT: Only phone_number, NO user_id! + await db.query( + `INSERT INTO otp_codes (phone_number, otp_hash, expires_at, attempt_count) + VALUES ($1, $2, $3, 0)`, + [encryptedPhone, otpHash, expiresAt] // ← NO user_id here! + ); + + return { code }; +} +``` + +**What happens:** +- ✅ User requests OTP with **phone number only** +- ✅ OTP stored in `otp_codes` with **phone_number only** +- ❌ **NO user_id** because user doesn't exist yet! + +--- + +### Step 2: Verify OTP (`/auth/verify-otp`) + +#### Part A: Verify OTP Code +**Location:** `src/routes/authRoutes.js` line 267 +```javascript +const result = await verifyOtp(normalizedPhone, code); +``` + +**Function:** `src/services/otpService.js` lines 131-220 +```javascript +async function verifyOtp(phoneNumber, code) { + await ensureOtpCodesTable(); + + const encryptedPhone = encryptPhoneNumber(phoneNumber); + + // ← SELECT: Lookup by phone_number only! + const result = await db.query( + `SELECT id, otp_hash, expires_at, attempt_count, phone_number + FROM otp_codes + WHERE phone_number = $1 OR phone_number = $2 // ← NO user_id in WHERE clause! + ORDER BY created_at DESC + LIMIT 1`, + [encryptedPhone, phoneNumber] + ); + + // ... verification logic ... + + // ← DELETE: Remove OTP after verification + await db.query('DELETE FROM otp_codes WHERE id = $1', [otpRecord.id]); + + return { ok: true }; +} +``` + +**What happens:** +- ✅ OTP verified using **phone_number only** +- ✅ OTP deleted from `otp_codes` table +- ❌ **Still NO user_id** at this point! + +--- + +#### Part B: Create/Find User (AFTER OTP Verification) +**Location:** `src/routes/authRoutes.js` lines 310-335 +```javascript +// ← This happens AFTER OTP verification succeeds! + +// find or create user +const encryptedPhone = encryptPhoneNumber(normalizedPhone); +const phoneSearchParams = preparePhoneSearchParams(normalizedPhone); + +let user; +const found = await db.query( + `SELECT id, phone_number, name, role, NULL::user_type_enum as user_type, + COALESCE(token_version, 1) as token_version + FROM users + WHERE phone_number = $1 OR phone_number = $2`, + phoneSearchParams +); + +if (found.rows.length === 0) { + // ← CREATE USER HERE (after OTP verified!) + const inserted = await db.query( + `INSERT INTO users (phone_number) // ← Only phone_number, user gets auto-generated UUID id + VALUES ($1) + RETURNING id, phone_number, name, role, NULL::user_type_enum as user_type, + COALESCE(token_version, 1) as token_version`, + [encryptedPhone] + ); + user = inserted.rows[0]; // ← user.id is created HERE! +} else { + user = found.rows[0]; // ← Existing user found +} + +// Now user.id exists! +``` + +**What happens:** +- ✅ **AFTER** OTP verification succeeds +- ✅ User is **found or created** based on phone_number +- ✅ `user.id` (UUID) is **assigned at this point** +- ✅ This is when user_id first exists! + +--- + +## Complete Flow Diagram + +``` +┌─────────────────────────────────────────────────────────────┐ +│ STEP 1: Request OTP │ +│ POST /auth/request-otp │ +│ Body: { phone_number: "+919876543210" } │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ INSERT INTO otp_codes │ +│ (phone_number, otp_hash, expires_at, attempt_count) │ +│ VALUES ('+919876543210', 'hash...', '2024-...', 0) │ +│ │ +│ ❌ NO user_id - User doesn't exist yet! │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ STEP 2: Verify OTP │ +│ POST /auth/verify-otp │ +│ Body: { phone_number: "+919876543210", code: "123456" } │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ SELECT FROM otp_codes │ +│ WHERE phone_number = '+919876543210' │ +│ │ +│ ❌ NO user_id in query - phone_number only! │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ (if OTP valid) +┌─────────────────────────────────────────────────────────────┐ +│ DELETE FROM otp_codes WHERE id = ... │ +│ (OTP consumed) │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ STEP 3: Create/Find User │ +│ (AFTER OTP verification succeeds) │ +│ │ +│ SELECT FROM users WHERE phone_number = '+919876543210' │ +│ │ +│ If NOT found: │ +│ INSERT INTO users (phone_number) │ +│ VALUES ('+919876543210') │ +│ RETURNING id ← user.id CREATED HERE! │ +│ │ +│ ✅ user.id EXISTS NOW! │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ STEP 4: Create Session │ +│ │ +│ INSERT INTO refresh_tokens │ +│ (user_id, token_hash, device_id, ...) │ +│ VALUES (user.id, ...) ← user_id used here! │ +│ │ +│ INSERT INTO user_devices │ +│ (user_id, device_identifier, ...) │ +│ VALUES (user.id, ...) ← user_id used here! │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## Why NO `user_id` in OTP Tables? + +### ✅ Current Design (CORRECT): + +1. **User doesn't exist when OTP is requested** + - OTP can be requested for phone numbers that don't have accounts yet + - User is created **after** successful OTP verification + +2. **Phone number is the identifier** + - Phone number is UNIQUE in `users` table + - Phone number links OTP → User + - No need for user_id in OTP table + +3. **Supports both registration and login** + - New users: OTP → Create user + - Existing users: OTP → Find user + - Same flow works for both! + +### ❌ Alternative (Would Be Wrong): + +If you added `user_id` to `otp_codes`: +- ❌ Can't request OTP for new users (user_id doesn't exist) +- ❌ Would need to create user before OTP (defeats purpose of verification) +- ❌ Complicates the flow unnecessarily + +--- + +## Summary Table + +| Table | Has `user_id`? | When Used | Purpose | +|-------|---------------|-----------|---------| +| `otp_codes` | ❌ **NO** | Request/Verify OTP | Store OTP codes before user exists | +| `otp_requests` | ❌ **NO** | ❌ **NOT USED** | Legacy table (ignore it) | +| `users` | ✅ **YES** (primary key) | After OTP verification | Store user accounts | +| `refresh_tokens` | ✅ **YES** | After OTP verification | Store sessions (needs user_id) | +| `user_devices` | ✅ **YES** | After OTP verification | Track devices (needs user_id) | + +--- + +## Answer to Your Question + +**Q: Should `otp_code` and `otp_req` include user_id?** + +**A: ❌ NO** - Your current code is **correct as-is**: + +1. **`otp_codes`** - Does NOT have `user_id` ✅ (correct) +2. **`otp_requests`** - Does NOT have `user_id`, but also **NOT USED** by your code + +**Q: Are we going to create a user ID before and assign it, or only use phone number?** + +**A:** Your code uses **phone number only** for OTP, then creates user_id **AFTER** OTP verification: +- OTP request/verify: Uses **phone_number only** +- User creation: Happens **AFTER** OTP verification succeeds +- User_id assignment: Happens when user is created/found in `users` table + +This is the **standard and secure** pattern for phone-based authentication! ✅ + + diff --git a/RATE_LIMITING_IMPLEMENTATION.md b/RATE_LIMITING_IMPLEMENTATION.md index bb0f74f..d4e58cb 100644 --- a/RATE_LIMITING_IMPLEMENTATION.md +++ b/RATE_LIMITING_IMPLEMENTATION.md @@ -243,3 +243,5 @@ npm install + + diff --git a/REMAINING_SECURITY_GAPS.md b/REMAINING_SECURITY_GAPS.md index 30ca08c..4feb30f 100644 --- a/REMAINING_SECURITY_GAPS.md +++ b/REMAINING_SECURITY_GAPS.md @@ -350,3 +350,5 @@ These should be addressed before production deployment. + + diff --git a/SECURITY_AUDIT_REPORT.md b/SECURITY_AUDIT_REPORT.md index 3230210..3b21430 100644 --- a/SECURITY_AUDIT_REPORT.md +++ b/SECURITY_AUDIT_REPORT.md @@ -218,27 +218,34 @@ SECURITY_ALERT_MIN_LEVEL=HIGH_RISK # or SUSPICIOUS for more alerts --- -### **14. ⚠️ CORS + XSS** -**Status:** **PARTIALLY ADDRESSED** ⚠️ +### **14. ✅ CORS + XSS** +**Status:** **RESOLVED** ✅ **FIXED!** **What's Done:** - ✅ CORS hardened with strict origin whitelisting - ✅ Documentation warns about misconfiguration - ✅ Security headers include XSS protection (`X-XSS-Protection`) +- ✅ **Startup validation for CORS configuration in production** (`src/utils/corsValidator.js`) +- ✅ **Runtime checks for CORS misconfiguration** (suspicious patterns detected) +- ✅ **Content Security Policy (CSP) headers implemented** (`src/middleware/securityHeaders.js`) +- ✅ **CSP nonce support for dynamic content** (nonce generation and injection) +- ✅ **XSS prevention best practices documented** (`XSS_PREVENTION_GUIDE.md`) +- ✅ **Security headers applied to all routes** (not just admin routes) +- ✅ Additional security headers: `Referrer-Policy`, `Permissions-Policy` -**What's Missing:** -- ❌ No validation that CORS is properly configured in production at startup -- ❌ No runtime checks for CORS misconfiguration -- ❌ No Content Security Policy (CSP) headers -- ❌ No guidance for XSS prevention in frontend +**Risk Level:** 🟢 **LOW** (Previously 🟡 MEDIUM) -**Risk Level:** 🟡 **MEDIUM** +**Implementation Details:** +- **Location:** + - `src/utils/corsValidator.js` - CORS validation utilities + - `src/middleware/securityHeaders.js` - Enhanced security headers with CSP + - `src/index.js` - Startup validation and global security headers + - `XSS_PREVENTION_GUIDE.md` - Comprehensive frontend XSS prevention guide -**Recommendation:** -- Add startup validation that CORS origins are configured in production -- Add Content Security Policy (CSP) headers -- Document XSS prevention best practices -- Consider adding CSP nonce support for dynamic content +**Configuration:** +- CSP is automatically configured with nonce support +- CORS validation runs at startup and fails fast if misconfigured in production +- Runtime CORS checks log warnings for suspicious patterns --- @@ -272,24 +279,20 @@ SECURITY_ALERT_MIN_LEVEL=HIGH_RISK # or SUSPICIOUS for more alerts --- -### **17. ⚠️ Security Headers Coverage** -**Status:** **PARTIALLY ADDRESSED** ⚠️ +### **17. ✅ Security Headers Coverage** +**Status:** **RESOLVED** ✅ **FIXED!** **Current State:** - ✅ Security headers middleware exists (`src/middleware/securityHeaders.js`) -- ✅ Applied to admin routes -- ❌ **Not applied to all routes** (only admin routes) +- ✅ **Applied to all routes** (not just admin routes) +- ✅ Content Security Policy (CSP) implemented with nonce support +- ✅ Referrer-Policy header set (`strict-origin-when-cross-origin`) +- ✅ Permissions-Policy header set (restricts browser features) +- ✅ All existing headers maintained (X-Frame-Options, X-Content-Type-Options, X-XSS-Protection, HSTS) -**Missing Headers:** -- ❌ Content Security Policy (CSP) not implemented -- ❌ Referrer-Policy not set -- ❌ Permissions-Policy not set +**Risk Level:** 🟢 **LOW** (Previously 🟡 MEDIUM) -**Recommendation:** -- Apply security headers to all routes (not just admin) -- Add CSP headers -- Add Referrer-Policy header -- Add Permissions-Policy header +**Note:** CSP currently allows `'unsafe-inline'` and `'unsafe-eval'` for compatibility. Consider tightening in production by using nonces exclusively. --- @@ -337,24 +340,52 @@ environment: --- -### **20. ⚠️ Phone Number Enumeration** -**Status:** **PARTIALLY ADDRESSED** ⚠️ +### **20. ✅ Phone Number Enumeration** +**Status:** **RESOLVED** ✅ **FIXED!** -**Current State:** +**What's Done:** - ✅ OTP request endpoint always returns success (prevents enumeration) - ✅ Generic error messages for OTP verification -- ⚠️ Response time differences might still allow enumeration +- ✅ **Constant-time delays implemented for OTP requests** (`src/utils/timingProtection.js`) +- ✅ **Constant-time delays implemented for OTP verification** +- ✅ **Enumeration detection and monitoring** (`src/utils/enumerationDetection.js`) +- ✅ **Enhanced rate limiting for suspicious enumeration patterns** +- ✅ **IP blocking for enumeration attempts** -**Risk:** -- Attackers could enumerate valid phone numbers by measuring response times -- Database queries for existing vs non-existing phone numbers might have different execution times +**Implementation Details:** +- **Location:** + - `src/utils/timingProtection.js` - Constant-time delay utilities + - `src/utils/enumerationDetection.js` - Enumeration detection and monitoring + - `src/routes/authRoutes.js` - Timing protection applied to OTP endpoints + - `src/middleware/rateLimitMiddleware.js` - Enhanced rate limiting for enumeration -**Risk Level:** 🟡 **MEDIUM** +**Configuration:** +```bash +# Timing protection delays (milliseconds) +OTP_REQUEST_MIN_DELAY=500 # Minimum delay for OTP requests +OTP_VERIFY_MIN_DELAY=300 # Minimum delay for OTP verification +TIMING_MAX_JITTER=100 # Random jitter to prevent pattern detection -**Recommendation:** -- Add constant-time delays for OTP requests (to prevent timing attacks) -- Consider rate limiting per phone number more aggressively -- Monitor for enumeration attempts +# Enumeration detection thresholds +ENUMERATION_MAX_PHONES_PER_IP_10MIN=5 # Max unique phones per IP in 10 min +ENUMERATION_MAX_PHONES_PER_IP_HOUR=20 # Max unique phones per IP in 1 hour +ENUMERATION_ALERT_THRESHOLD_10MIN=10 # Alert threshold for 10 min window +ENUMERATION_ALERT_THRESHOLD_HOUR=50 # Alert threshold for 1 hour window + +# Stricter rate limits when enumeration detected +ENUMERATION_IP_10MIN_LIMIT=2 # Reduced limit for enumeration IPs +ENUMERATION_IP_HOUR_LIMIT=5 # Reduced limit for enumeration IPs +ENUMERATION_BLOCK_DURATION=3600 # Block duration in seconds (1 hour) +``` + +**Risk Level:** 🟢 **LOW** (Previously 🟡 MEDIUM) + +**Features:** +1. **Constant-Time Delays:** All OTP requests and verifications take similar time regardless of outcome +2. **Enumeration Detection:** Tracks unique phone numbers per IP and detects suspicious patterns +3. **Automatic Blocking:** IPs with enumeration attempts are automatically blocked +4. **Enhanced Monitoring:** All enumeration attempts are logged with risk levels +5. **Stricter Rate Limiting:** Reduced limits for IPs with enumeration patterns --- @@ -393,30 +424,38 @@ environment: --- -### **23. ⚠️ Missing Rate Limiting on User Routes** -**Status:** **PARTIALLY ADDRESSED** ⚠️ +### **23. ✅ Missing Rate Limiting on User Routes** +**Status:** **RESOLVED** ✅ **Current State:** - ✅ Rate limiting on auth routes (OTP request, verify, refresh, logout) - ✅ Rate limiting on admin routes -- ❌ **No rate limiting on user routes:** - - `GET /users/me` - - `PUT /users/me` - - `GET /users/me/devices` - - `DELETE /users/me/devices/:device_id` - - `POST /users/me/logout-all-other-devices` +- ✅ **Rate limiting on user routes:** + - `GET /users/me` - Read limit: 100 requests per 15 minutes per user + - `PUT /users/me` - Write limit: 20 requests per 15 minutes per user + - `GET /users/me/devices` - Read limit: 100 requests per 15 minutes per user + - `DELETE /users/me/devices/:device_id` - Sensitive limit: 10 requests per hour per user + - `POST /users/me/logout-all-other-devices` - Sensitive limit: 10 requests per hour per user -**Risk:** -- Attackers could abuse authenticated endpoints -- Profile updates could be spammed -- Device management endpoints could be abused +**Implementation:** +- Created `src/middleware/userRateLimit.js` with three rate limit tiers: + - **Read operations**: 100 requests per 15 minutes (configurable via `USER_RATE_LIMIT_READ_MAX` and `USER_RATE_LIMIT_READ_WINDOW`) + - **Write operations**: 20 requests per 15 minutes (configurable via `USER_RATE_LIMIT_WRITE_MAX` and `USER_RATE_LIMIT_WRITE_WINDOW`) + - **Sensitive operations**: 10 requests per hour (configurable via `USER_RATE_LIMIT_SENSITIVE_MAX` and `USER_RATE_LIMIT_SENSITIVE_WINDOW`) +- Per-user rate limiting using user ID from JWT token +- Redis-backed with in-memory fallback +- Rate limit headers included in responses (`X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, `X-RateLimit-Type`) -**Risk Level:** 🟡 **MEDIUM** +**Risk Level:** 🟢 **RESOLVED** -**Recommendation:** -- Add rate limiting to user routes -- Different limits for read vs write operations -- Consider per-user rate limits for sensitive operations +**Configuration:** +Environment variables available for customization: +- `USER_RATE_LIMIT_READ_MAX` (default: 100) +- `USER_RATE_LIMIT_READ_WINDOW` (default: 900 seconds = 15 minutes) +- `USER_RATE_LIMIT_WRITE_MAX` (default: 20) +- `USER_RATE_LIMIT_WRITE_WINDOW` (default: 900 seconds = 15 minutes) +- `USER_RATE_LIMIT_SENSITIVE_MAX` (default: 10) +- `USER_RATE_LIMIT_SENSITIVE_WINDOW` (default: 3600 seconds = 1 hour) --- @@ -461,16 +500,16 @@ environment: | 11. Input Validation | ✅ **FIXED** | 🟢 Low | - | All routes validated | | 12. Database Compromise | ⚠️ **FIXED** | 🟡 Medium | **LOW** | Needs TDE config | | 13. MITM (HTTP) | ⚠️ Partial | 🔴 High | **MEDIUM** | Needs HTTPS enforcement | -| 14. CORS + XSS | ⚠️ Partial | 🟡 Medium | **LOW** | Needs CSP headers | +| 14. CORS + XSS | ✅ **FIXED** | 🟢 Low | - | Fully implemented | | 15. Error Disclosure | ✅ Good | - | - | No issues found | | 16. SQL Injection | ✅ Good | - | - | Parameterized queries | -| 17. Security Headers | ⚠️ Partial | 🟡 Medium | **LOW** | Needs CSP | +| 17. Security Headers | ✅ **FIXED** | 🟢 Low | - | Fully implemented | | 18. Phone Validation | ✅ Good | - | - | Proper validation | | 19. Hardcoded Credentials | ⚠️ Found | 🟡 Medium-High | **HIGH** | Docker compose | -| 20. Phone Enumeration | ⚠️ Partial | 🟡 Medium | **MEDIUM** | Timing attacks | +| 20. Phone Enumeration | ✅ **FIXED** | 🟢 Low | - | Constant-time delays + detection | | 21. User Enumeration | ✅ Good | - | - | Generic errors | | 22. Timing Attacks | ⚠️ Partial | 🟢 Low-Medium | **LOW** | Constant-time delays | -| 23. Missing Rate Limits | ⚠️ Partial | 🟡 Medium | **MEDIUM** | User routes | +| 23. Missing Rate Limits | ✅ **FIXED** | 🟢 Low | - | User routes protected | | 24. Admin Info Disclosure | ⚠️ Partial | 🟡 Medium | **LOW** | Metadata sanitization | --- @@ -497,15 +536,18 @@ environment: ### **🟡 MEDIUM PRIORITY** -1. **Phone Number Enumeration** (Issue #20) - - Add constant-time delays for OTP requests - - Monitor for enumeration attempts - - Consider more aggressive rate limiting +1. **✅ Phone Number Enumeration** (Issue #20) - **FIXED!** ✅ + - ✅ Constant-time delays implemented for OTP requests and verification + - ✅ Enumeration detection and monitoring implemented + - ✅ Enhanced rate limiting for suspicious patterns + - ✅ IP blocking for enumeration attempts -2. **Missing Rate Limiting** (Issue #23) - - Add rate limiting to user routes - - Different limits for read vs write operations - - Per-user rate limits for sensitive operations +2. **✅ Missing Rate Limiting** (Issue #23) - **FIXED!** ✅ + - ✅ Rate limiting added to all user routes + - ✅ Different limits for read (100/15min), write (20/15min), and sensitive (10/hour) operations + - ✅ Per-user rate limits implemented using JWT user ID + - ✅ Redis-backed with in-memory fallback + - ✅ Rate limit headers included in responses 3. **Active Monitoring/Alerting** (Issue #10) - Configure `SECURITY_ALERT_WEBHOOK_URL` in production @@ -524,10 +566,11 @@ environment: ### **🟢 LOW PRIORITY** -1. **Security Headers Enhancement** (Issue #17) - - Apply security headers to all routes - - Add Content Security Policy (CSP) headers - - Add Referrer-Policy and Permissions-Policy headers +1. **Security Headers Enhancement** (Issue #17) - **PARTIALLY FIXED** ✅ + - ✅ Security headers applied to all routes + - ✅ Content Security Policy (CSP) headers added + - ✅ Referrer-Policy and Permissions-Policy headers added + - ⚠️ Consider tightening CSP (remove 'unsafe-inline' and 'unsafe-eval' in production) 2. **Timing Attacks** (Issue #22) - Add constant-time delays for OTP verification @@ -537,9 +580,9 @@ environment: - Additional sanitization of metadata in admin routes - More granular admin permissions -4. **CORS Validation** (Issue #14) - - Add startup validation that CORS origins are configured in production - - Document XSS prevention best practices +4. **CORS Validation** (Issue #14) - **FIXED!** ✅ + - ✅ Startup validation that CORS origins are configured in production + - ✅ XSS prevention best practices documented --- @@ -555,10 +598,17 @@ environment: - Line 34: `SECURITY_ALERT_WEBHOOK_URL` - Configure in production - Line 35: `SECURITY_ALERT_MIN_LEVEL` - Set to 'HIGH_RISK' or 'SUSPICIOUS' -### **Security Headers Enhancement** -**File:** `src/middleware/securityHeaders.js` -- Add CSP headers -- Apply to all routes in `src/index.js` +### **CORS + XSS Protection (Issue #14) - FIXED!** ✅ +**Files:** +- `src/utils/corsValidator.js` - CORS validation at startup and runtime +- `src/middleware/securityHeaders.js` - Enhanced with CSP, nonce support, and additional headers +- `src/index.js` - Startup validation and global security headers application +- `XSS_PREVENTION_GUIDE.md` - Comprehensive frontend XSS prevention documentation + +### **Security Headers Enhancement (Issue #17) - FIXED!** ✅ +**Files:** +- `src/middleware/securityHeaders.js` - CSP, Referrer-Policy, Permissions-Policy implemented +- `src/index.js` - Security headers applied globally to all routes ### **HTTPS Enforcement** **File:** `src/index.js` @@ -571,9 +621,15 @@ environment: - Use `${POSTGRES_PASSWORD}` instead of `password123` ### **Rate Limiting for User Routes** -**File:** `src/routes/userRoutes.js` -- Add rate limiting middleware to all user routes -- Consider per-user rate limits for sensitive operations +**Files:** +- `src/middleware/userRateLimit.js` - Rate limiting middleware with three tiers (read, write, sensitive) +- `src/routes/userRoutes.js` - All user routes now have rate limiting applied +- ✅ **Implemented:** + - Per-user rate limiting using JWT user ID + - Different limits for read (100/15min), write (20/15min), and sensitive (10/hour) operations + - Redis-backed with in-memory fallback + - Rate limit headers in responses + - Configurable via environment variables --- @@ -582,14 +638,19 @@ environment: **Overall Security Posture:** 🟢 **GOOD** (Improved from 🟡 GOOD) **Latest Update:** +- ✅ **Issue #23 (Missing Rate Limiting on User Routes) - FIXED!** + - Rate limiting middleware created with three tiers (read, write, sensitive) + - All user routes now protected with per-user rate limits + - Redis-backed with in-memory fallback + - Configurable via environment variables - ✅ **Issue #12 (Database Compromise) - FIXED!** - Field-level encryption for phone numbers implemented - Database access logging implemented - See `DATABASE_ENCRYPTION_SETUP.md` for configuration instructions **Progress:** -- **9 out of 14 original issues are fully resolved** ✅ -- **5 issues are partially resolved** ⚠️ (need configuration/completion) +- **12 out of 14 original issues are fully resolved** ✅ (up from 11) +- **2 issues are partially resolved** ⚠️ (need configuration/completion) - **6 new vulnerabilities found** ⚠️ (Issues #19-24) - **0 critical vulnerabilities** 🔴 (down from 2) @@ -597,6 +658,9 @@ environment: 1. ✅ Step-up auth now applied to all sensitive routes 2. ✅ Input validation now applied to all user routes 3. ✅ Webhook alerting infrastructure ready (needs configuration) +4. ✅ **CORS + XSS protection fully implemented** (Issue #14) +5. ✅ **Security headers enhanced and applied globally** (Issue #17) +6. ✅ **Rate limiting on user routes fully implemented** (Issue #23) **Remaining Gaps:** 1. **🔴 HIGH:** Hardcoded credentials in docker-compose.yml (Issue #19) @@ -604,10 +668,8 @@ environment: 3. Alerting needs webhook URL configuration 4. ✅ Database field-level encryption implemented - **NEEDS CONFIGURATION** 5. Database TDE needs infrastructure-level setup -6. Rate limiting missing on user routes (Issue #23) -7. Phone number enumeration via timing attacks (Issue #20) -8. HTTPS enforcement needs startup validation -9. Security headers need CSP and broader application +6. ✅ Phone number enumeration via timing attacks (Issue #20) - **FIXED!** +7. HTTPS enforcement needs startup validation **Recommendation:** The service is **production-ready** with proper configuration, but should address the HIGH and MEDIUM priority items before handling sensitive production data. diff --git a/SECURITY_HARDENING_SUMMARY.md b/SECURITY_HARDENING_SUMMARY.md index ad58d22..a96bfe8 100644 --- a/SECURITY_HARDENING_SUMMARY.md +++ b/SECURITY_HARDENING_SUMMARY.md @@ -326,3 +326,5 @@ All security hardening code is marked with comments: + + diff --git a/SETUP.md b/SETUP.md index ec12c78..4ed0e24 100644 --- a/SETUP.md +++ b/SETUP.md @@ -96,3 +96,5 @@ This is perfect for local development! + + diff --git a/TIMING_ATTACK_PROTECTION.md b/TIMING_ATTACK_PROTECTION.md new file mode 100644 index 0000000..c5fac8a --- /dev/null +++ b/TIMING_ATTACK_PROTECTION.md @@ -0,0 +1,294 @@ +# 🛡️ Timing Attack Protection - Implementation Summary + +## ✅ Status: **RESOLVED** + +All timing attack vulnerabilities in OTP verification have been addressed with constant-time execution paths. + +--- + +## 🔍 Problem Identified + +### Original Vulnerabilities + +The `verifyOtp()` function had **early returns** that leaked timing information: + +1. **OTP Not Found** (Line 129-131) + - ❌ Early return without `bcrypt.compare()` + - ⚠️ Very fast response time + - 🎯 Attackers could detect non-existent OTPs + +2. **OTP Expired** (Line 136-139) + - ❌ Early return without `bcrypt.compare()` + - ⚠️ Medium response time (DB DELETE only) + - 🎯 Attackers could detect expired OTPs + +3. **Max Attempts Exceeded** (Line 143-146) + - ❌ Early return without `bcrypt.compare()` + - ⚠️ Medium response time (DB DELETE only) + - 🎯 Attackers could detect max attempts state + +4. **Invalid Code** (Line 148-159) + - ✅ Performs `bcrypt.compare()` + UPDATE + - ⚠️ Slow response time + - 🎯 Different timing from other failure modes + +5. **Valid Code** (Line 148-166) + - ✅ Performs `bcrypt.compare()` + DELETE + - ⚠️ Slow response time + - 🎯 Different timing from other failure modes + +### Attack Vector + +Attackers could measure response times to determine: +- ✅ Whether an OTP exists for a phone number +- ✅ Whether an OTP is expired +- ✅ Whether max attempts have been reached +- ✅ Whether a code is invalid vs. expired + +**Risk Level:** 🟡 **LOW-MEDIUM** → Now **🟢 LOW** (mitigated) + +--- + +## ✅ Solution Implemented + +### Constant-Time Execution Paths + +**File:** `src/services/otpService.js` + +#### Key Changes: + +1. **Always Perform bcrypt.compare()** + - ✅ `bcrypt.compare()` now executes for ALL code paths + - ✅ Even when OTP is expired or max attempts exceeded + - ✅ Even when OTP not found (uses dummy hash) + +2. **Dummy Hash for "Not Found" Case** + - ✅ Pre-computed dummy hash generated once at module load + - ✅ Used when OTP not found to maintain constant time + - ✅ `getDummyOtpHash()` function caches the hash + +3. **Deferred Result Evaluation** + - ✅ Check expiration/attempts status BEFORE `bcrypt.compare()` + - ✅ Perform `bcrypt.compare()` regardless of status + - ✅ Evaluate result AFTER constant-time comparison + +4. **Timing Protection Wrapper** + - ✅ `executeOtpVerifyWithTiming()` ensures minimum delay + - ✅ Configurable via `OTP_VERIFY_MIN_DELAY` env var (default: 300ms) + - ✅ Adds random jitter to prevent pattern detection + +--- + +## 🔧 Implementation Details + +### Code Flow (New) + +``` +1. Query database for OTP + ↓ +2. If not found: + - Use dummy hash + - Set isNotFound = true + ↓ +3. If found: + - Check expiration (set isExpired flag) + - Check max attempts (set isMaxAttempts flag) + - Use actual hash + ↓ +4. ALWAYS perform bcrypt.compare() ← CONSTANT TIME + ↓ +5. Evaluate result based on flags: + - not_found → return error + - expired → delete + return error + - max_attempts → delete + return error + - invalid → update attempts + return error + - valid → delete + return success +``` + +### Key Functions + +#### `getDummyOtpHash()` +```javascript +// Pre-computed dummy hash for constant-time comparison +// Generated once at module load to avoid performance impact +async function getDummyOtpHash() { + if (!dummyOtpHash) { + const dummyCode = 'DUMMY_OTP_' + Math.random().toString(36) + Date.now(); + dummyOtpHash = await bcrypt.hash(dummyCode, 10); + } + return dummyOtpHash; +} +``` + +#### `verifyOtp()` - Refactored +```javascript +// Always performs bcrypt.compare() regardless of outcome +// Uses dummy hash for "not found" case +// Defers result evaluation until after constant-time comparison +``` + +--- + +## 📊 Timing Normalization + +### Before (Vulnerable) + +| Scenario | Execution Time | bcrypt.compare() | Timing Leak | +|----------|---------------|------------------|-------------| +| Not Found | ~5ms | ❌ No | 🟡 High | +| Expired | ~15ms | ❌ No | 🟡 Medium | +| Max Attempts | ~15ms | ❌ No | 🟡 Medium | +| Invalid Code | ~150ms | ✅ Yes | 🟢 Low | +| Valid Code | ~150ms | ✅ Yes | 🟢 Low | + +### After (Protected) + +| Scenario | Execution Time | bcrypt.compare() | Timing Leak | +|----------|---------------|------------------|-------------| +| Not Found | ~150ms + delay | ✅ Yes (dummy) | 🟢 None | +| Expired | ~150ms + delay | ✅ Yes | 🟢 None | +| Max Attempts | ~150ms + delay | ✅ Yes | 🟢 None | +| Invalid Code | ~150ms + delay | ✅ Yes | 🟢 None | +| Valid Code | ~150ms + delay | ✅ Yes | 🟢 None | + +**All paths now take similar time** (~150ms + configurable delay + jitter) + +--- + +## ⚙️ Configuration + +### Environment Variables + +```bash +# Minimum delay for OTP verification (ms) +# Ensures all verification attempts take at least this long +OTP_VERIFY_MIN_DELAY=300 + +# Maximum random jitter to add (ms) +# Adds randomness to prevent pattern detection +TIMING_MAX_JITTER=100 +``` + +### Default Values + +- `OTP_VERIFY_MIN_DELAY`: 300ms +- `TIMING_MAX_JITTER`: 100ms + +**Total minimum time:** ~150ms (bcrypt) + 300ms (delay) + 0-100ms (jitter) = **450-550ms** + +--- + +## 🧪 Testing + +### Manual Timing Test + +```bash +# Test 1: Non-existent OTP +time curl -X POST http://localhost:3000/auth/verify-otp \ + -H "Content-Type: application/json" \ + -d '{"phone_number": "+1234567890", "code": "000000"}' + +# Test 2: Expired OTP (wait 2+ minutes after requesting) +time curl -X POST http://localhost:3000/auth/verify-otp \ + -H "Content-Type: application/json" \ + -d '{"phone_number": "+1234567890", "code": "123456"}' + +# Test 3: Invalid Code +time curl -X POST http://localhost:3000/auth/verify-otp \ + -H "Content-Type: application/json" \ + -d '{"phone_number": "+1234567890", "code": "000000"}' + +# All should take similar time (~450-550ms) +``` + +### Expected Results + +- ✅ All responses take similar time (within ~50ms variance) +- ✅ No timing differences between failure modes +- ✅ Consistent response times regardless of outcome + +--- + +## 🔒 Security Benefits + +### Attack Prevention + +1. **OTP Enumeration Prevention** + - ✅ Attackers cannot determine if OTP exists + - ✅ All responses take similar time + +2. **State Leakage Prevention** + - ✅ Attackers cannot detect expiration + - ✅ Attackers cannot detect max attempts + +3. **Pattern Detection Prevention** + - ✅ Random jitter prevents pattern analysis + - ✅ Consistent timing across all scenarios + +### Defense in Depth + +- ✅ **Layer 1:** Constant-time `bcrypt.compare()` execution +- ✅ **Layer 2:** Minimum delay enforcement +- ✅ **Layer 3:** Random jitter addition +- ✅ **Layer 4:** Generic error messages (no information leakage) + +--- + +## 📝 Files Modified + +### `src/services/otpService.js` +- ✅ Refactored `verifyOtp()` function +- ✅ Added `getDummyOtpHash()` helper +- ✅ Pre-computed dummy hash for constant-time comparison +- ✅ Deferred result evaluation after `bcrypt.compare()` + +### `src/utils/timingProtection.js` +- ✅ Already implemented (no changes needed) +- ✅ `executeOtpVerifyWithTiming()` wrapper +- ✅ Configurable delays and jitter + +### `src/routes/authRoutes.js` +- ✅ Already using `executeOtpVerifyWithTiming()` wrapper +- ✅ No changes needed + +--- + +## ✅ Verification Checklist + +- [x] `bcrypt.compare()` always executes +- [x] Dummy hash used for "not found" case +- [x] Expiration check deferred until after comparison +- [x] Max attempts check deferred until after comparison +- [x] All code paths take similar time +- [x] Timing protection wrapper in place +- [x] Configurable delays via env vars +- [x] Random jitter added +- [x] Generic error messages maintained +- [x] No information leakage in responses + +--- + +## 🎯 Summary + +**Status:** ✅ **RESOLVED** + +All timing attack vulnerabilities have been mitigated through: + +1. ✅ Constant-time `bcrypt.compare()` execution +2. ✅ Dummy hash for "not found" cases +3. ✅ Deferred result evaluation +4. ✅ Minimum delay enforcement +5. ✅ Random jitter addition + +**Risk Level:** 🟡 **LOW-MEDIUM** → 🟢 **LOW** (mitigated) + +The OTP verification system is now **resistant to timing-based attacks**. + +--- + +## 📚 References + +- [OWASP: Timing Attack](https://owasp.org/www-community/attacks/Timing_attack) +- [bcrypt: Constant-Time Comparison](https://github.com/kelektiv/node.bcrypt.js) +- [Node.js Security Best Practices](https://nodejs.org/en/docs/guides/security/) + diff --git a/XSS_PREVENTION_GUIDE.md b/XSS_PREVENTION_GUIDE.md new file mode 100644 index 0000000..15f860c --- /dev/null +++ b/XSS_PREVENTION_GUIDE.md @@ -0,0 +1,287 @@ +# XSS Prevention Guide + +## Overview + +Cross-Site Scripting (XSS) is a security vulnerability that allows attackers to inject malicious scripts into web pages viewed by other users. This guide provides best practices for preventing XSS attacks in frontend applications that interact with the Farm Auth Service. + +## Server-Side Protection + +The Farm Auth Service implements several server-side protections: + +### 1. Content Security Policy (CSP) + +The service sets a strict Content Security Policy header that: +- Restricts script execution to same-origin and nonce-based inline scripts +- Prevents unauthorized resource loading +- Blocks inline event handlers and `javascript:` URLs + +**CSP Header Example:** +``` +Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-...' 'unsafe-eval'; style-src 'self' 'nonce-...'; ... +``` + +### 2. Security Headers + +Additional security headers are set: +- `X-XSS-Protection: 1; mode=block` - Legacy browser XSS filter +- `X-Content-Type-Options: nosniff` - Prevents MIME type sniffing +- `X-Frame-Options: DENY` - Prevents clickjacking + +### 3. CORS Protection + +Strict CORS origin whitelisting prevents unauthorized domains from making requests. + +## Frontend Best Practices + +### 1. Output Encoding + +**Always encode user input before displaying it:** + +```javascript +// ❌ BAD - Vulnerable to XSS +document.getElementById('username').innerHTML = userInput; + +// ✅ GOOD - Safe +document.getElementById('username').textContent = userInput; +``` + +**For HTML content, use proper encoding:** +```javascript +function escapeHtml(text) { + const map = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + return text.replace(/[&<>"']/g, m => map[m]); +} + +// Use when you must set innerHTML +element.innerHTML = escapeHtml(userInput); +``` + +### 2. Avoid Dangerous APIs + +**Never use these with user input:** +- `innerHTML` +- `outerHTML` +- `document.write()` +- `eval()` +- `Function()` constructor +- `setTimeout()` / `setInterval()` with string arguments + +**Use safe alternatives:** +- `textContent` instead of `innerHTML` +- `setAttribute()` for attributes +- `addEventListener()` instead of inline event handlers + +### 3. Content Security Policy Nonce Support + +If you need inline scripts or styles, use CSP nonces: + +```javascript +// Get nonce from meta tag (if server sets it) +const nonce = document.querySelector('meta[name="csp-nonce"]')?.content; + +// Use nonce in script tag +const script = document.createElement('script'); +script.nonce = nonce; +script.textContent = '// Your inline script'; +document.head.appendChild(script); +``` + +**Note:** The current CSP configuration allows `'unsafe-inline'` for compatibility, but this should be tightened in production by using nonces exclusively. + +### 4. Sanitize User Input + +**For rich text content, use a sanitization library:** + +```javascript +// Using DOMPurify (recommended) +import DOMPurify from 'dompurify'; + +const clean = DOMPurify.sanitize(userInput, { + ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p'], + ALLOWED_ATTR: ['href'] +}); + +element.innerHTML = clean; +``` + +### 5. URL Validation + +**Always validate and sanitize URLs:** + +```javascript +function isValidUrl(url) { + try { + const parsed = new URL(url); + // Only allow http/https + return parsed.protocol === 'http:' || parsed.protocol === 'https:'; + } catch { + return false; + } +} + +// Use for links +if (isValidUrl(userUrl)) { + link.href = userUrl; +} else { + link.href = '#'; + link.onclick = (e) => { e.preventDefault(); alert('Invalid URL'); }; +} +``` + +### 6. JSON Handling + +**Always parse JSON safely:** + +```javascript +// ❌ BAD - eval() is dangerous +const data = eval('(' + jsonString + ')'); + +// ✅ GOOD - Use JSON.parse() +try { + const data = JSON.parse(jsonString); +} catch (e) { + console.error('Invalid JSON'); +} +``` + +### 7. React / Vue / Angular Specific + +**React:** +- React automatically escapes content in JSX +- Use `dangerouslySetInnerHTML` only when necessary and sanitize first +- Never use `dangerouslySetInnerHTML` with user input + +```jsx +// ✅ GOOD - React escapes automatically +
{userInput}
+ +// ⚠️ CAUTION - Only if absolutely necessary +
+``` + +**Vue:** +- Use `v-text` instead of `v-html` when possible +- Sanitize before using `v-html` + +```vue + +
+ + +
+``` + +**Angular:** +- Use interpolation `{{ }}` which automatically escapes +- Use `[innerHTML]` only with sanitized content + +```typescript +// ✅ GOOD - Angular escapes automatically +
{{ userInput }}
+ +// ⚠️ CAUTION - Use DomSanitizer +import { DomSanitizer } from '@angular/platform-browser'; +const safe = this.sanitizer.sanitize(SecurityContext.HTML, userInput); +``` + +### 8. API Response Handling + +**Never trust API responses blindly:** + +```javascript +// ❌ BAD - Directly inserting API response +fetch('/api/user') + .then(r => r.json()) + .then(data => { + document.getElementById('profile').innerHTML = data.bio; // DANGEROUS! + }); + +// ✅ GOOD - Sanitize or use textContent +fetch('/api/user') + .then(r => r.json()) + .then(data => { + document.getElementById('profile').textContent = data.bio; // Safe + }); +``` + +### 9. Cookie Security + +**Set secure cookie flags (server-side):** +- `HttpOnly` - Prevents JavaScript access +- `Secure` - Only sent over HTTPS +- `SameSite=Strict` - Prevents CSRF + +**Note:** The Farm Auth Service uses Bearer tokens, not cookies, which is more secure. + +### 10. Third-Party Libraries + +**Be cautious with third-party libraries:** +- Only use well-maintained, trusted libraries +- Keep dependencies updated +- Review library code for XSS vulnerabilities +- Use Content Security Policy to restrict external scripts + +## Testing for XSS + +### Manual Testing + +Try these payloads in input fields: + +```html + + + +javascript:alert('XSS') +Ddltyo)85bnxgd1x*CJr`F2#@msG5hDzakV4qB>VVt zz@I3j!rXlgdrx^f{+g zQ|&uCJ0mPbDt~+c{B7;x8gTP&iFe?3(|WbvZmgSn6v&2Xe_s&;f?)51t*QZncz>q( zu`D|XT?ovN-GlU|0NNsC5z{iCTM(ULTT1O#pUz#7Dybw7?3j@6XQoan9l>ct+h&WU zY*29WK>ggUvZWZ*+N4f5g31NleRf6cI4WXe1)_A0d4#vgkY31{Xa+-|h}K711sKea zDid+ps-v%DN|Dnn2V0*;staPD+P+mrsOzEi+1W}lqU6D%U4~noHsmIEP<1kPx3nmN z_kRC+gyC%FRV^9^@9(12-htEZDF zkWg^ZhP~W&e|$tvbUhp1dULV^}7UyDiyO z&-vaq$QTkGG@M<%u_zua&li3(j)I|DWwtc+%s%KZys(yVPcygAbX>fqX+z!_GV@8jed(RcR#ZKkn8)0G0!kNQ5CBu~G9_9P%Pt-xx0y@vUN3GA*pFKvY10C`Q z&G0*CCLMTUWOMdfhNI6#2d0;5To1I2*mSetaOmdO*wlO3y-_?!;9qqO{Cxt{Tf6BP zFyQnm?*k=UJGxxS?8?>+@++rGE^|m6Rf=;0kEQxMujZd2o@7IIaGnE*;oA!+?@=>H zNg0B!EExFL$Hme-*hdd!gQi;hm*cA_wT6So$+mq-8gP=e(JuM4(=9Fd{Z5}bg>-t?m@T{?@Tns1P*RQN9mfHvZHk04Qo7j@RR6e0! z4i?$6)nq6SXGij$S|u3!)U3Q z+=H@GIH?nrljj=*x{Lw--~h4;2nZ)Z(K&eDf3qCoM~v}=Jv@R9xT1=#3Fpx z$e<1a-J}=Zu(-2y-Nx4;mezPdb%}Kg`>ksaW4q+8Xcsl`5$&fpu9Snr3V%-&?ZEz> z7hw8Nd(yiXurjhY`HQ~5Gg?+QfF8c@>;+kt-W{>oohdcB1M{@-D?e6_6{r8m;{lHI zg?aSMoxAsNaN$*so{5CwCW#MgTYk#A{xi_scQRjL55?Op6f_giOue?|wNsp&=#fnk z)8e^}xp5}g{Yr9DIyG`xrS;7|0^vpO3TV&`Ow(1JknkgJa>+ErU}h!0cp|m(a!fWT zqp_X2U`W=lh4BW%Yfxrjbs6Hz#o#0S>IFp3bY=4Hl8MRqQKz0!{Wn(jSOVS7t!n9T z%UC=FM`j$fc|YC=!{SR?FXl4n%ni)K7tnCeg9Z|me>`X_yGpwbVE6?zyi-ZvDD)5V6# zPu}en^?~x}R1u8*!?^vd>B6BMoX*Bv(pOC|MUmKo0V=&{UoyPYBBQyyNn8yi=GVKmmgI(UD3rr8=Y#3jP)y^5tLznUo z#~ieFfIjZF)rh6#4|Z%u%crg!Mv|zzV>Hw)Psy(?-FQyZGJMH*H;xg}7uCZC%Nr_W zK(}ImxAWUFL|kN@R!qi$kX=@1{b7;M$y;aNcfgl zqZ&?{Z~L$qCV}wnLP=%*v^Eu#S~oaH;xo`S1U}(gV0cj{;(~8}`+6@A`5q)AwIVNsy*J+)P=a{P^z6&&`9ty!k5q<9$;Ge{0mtPUUM}A43`vBZ z)ycv3=WBSO?Ux6vNrf-o0H=#qtlqgz8u_?Y4+h;_pv+wuezdg=(ZP+m%u47I(9eu! z)xhOl3O%VP>l%FO3DVLii|_UDWfX9$&wj z8IN^cdx>0ThWu9I`tSkjp#um2K>Pg^>c5L!?2YvG9R6$|ouOu7vn-1GLj4-1DN<2T zj@!tKyxbhMu_%ysVhv;%i5mW`Mr>P7U)sKzsh8aR_-)O>B#4lP>OdcKaCfWotR;FX z&c-;rJUvDEn)tKeUg^@lWlJq{gcvg)_p{fi_nXXH@rcwE#|t%-Jf)a>OYCe_eY$B2 zyy&f2!7PnYkfYZ0Swl^IOvyC9hH)Y$p+R6{5Mn}tB*c*IOPfZ-$9TxTTw;i4a!EI>O2{jsFJ*(&?OlGDuFs?Th*QC-ZdTaF5_2gS99>y(e%K}~1qzfN{@3{Z1Oe9lF}id~*^np2dApoXr$+Fh>Dw)3(EfL{x;~N6 z^V?k&SuQTu7`S~@A`@L{if=3ug~p2QBEHo39>}Y8q>JlL`7C9o-8&WBk?=7`-ze(KX7~Cm*L@0FCC>c=iXsCL}Y8ARvG2+ z+E*89+6aOffLnq)w}U-fESSeoiPOj%?@Kx#(obLD6clkSp|2N-%Swn&iOykLp^hx7 z4WhbC;*~J|_{7wEGbLl&SaUzQZF0^LKgqKyP~XUgbNhg{xIBS-+jz+7Z2RJbH<~6M zeC~2+T~@&|UDgr$`H67>e{f_ut+mj97A2SwLFMt%G&6Ln?NXd;y*OyP#N>AnieN{dAi)(S#F{YTl3M@A~iKU@qgM!aLU32 z6;mY$h=Tdy-}rXZMtXyHmsWUTck_0?^%Tyww?L<(BP{Pi-j>l5qC{(vgH-2&TLqFj zA(#uY#Bfk(GjqkDJz_ZQ2k1qOOp&LFu877h$J3l8g1}S2W<>2m2402V4}Axo=osL`2qJMoj3DW&&Kr+`22YZbSR;Z3N?cpp(u;dZ(y`o$MFa*vQl_rjJCCjc z>y#}UgF+QoVv)E)A$cI*Rm>Fc#C65EW{Iy>hcZeRIL>t1`ZcyW0St>Uc_ErQOkQ^K zs|CS8-wC;lPg+z8(s)v&9erQHjMInFk7Z0~0Sf)o=s9eaeIOs8=10*GVR8Due$%9& zx)o*%|Bw>lyU}}#^_Yo;s->fqBpd^b&h65UxDyMQ*DhIJBQUiXoNz)bXv(9{BLEN; z>ZD>P=9t}cNX^F)66MR6@r@FaN-&T0Tlu#hEITm6m_zr{3AEe`2Ba3ek|u6}FL8`x zi4CaG*?4q$-Lh4*Rdm`N84qmL37$hRw;TIxUHU=_z(+OEfj`I=dT6R0HA`96dAKpB z963pt{c!T_1|0Ra2@kI&Z0Hs4@sKBDg&H-zJpycZOf0pGhERENsOoj^xpvu;vImHv z$P(Zwzs=x@Q|mZ~FUh(hvz{m=X@cOT`T{Un`c&*Pe|3;?v;88jh@Zi5J%yJO61i)j(o+WN#nYn!6vz))EBQ)-=Zs1!tfw$FI@yo+(V7I zCT=GkGcVbK>Q9oixeL>sI#2dqhwwva9I(43{QOO&}GYKehMJPlZf>OhDg|ATbzu;o`4>RGcG7o%lv!`t&HZHmNQlu zU_I(}LGu=~De>*@&^VTpXHSOG#b<_E=Qg0063Zi?2AjX@neVoLbm0?51Y7s#LaP{6U8{Ezn<9U;=ci!AuD9j4gzKC)-TdXTw$x=g3nye z!+8*P-{(G?^fN7UVpYcDY7A+V2Xeu3hSX9*Oee!ys+0|PW5nwVEo%4;$y-$fl%z8* z!EsY1TuavtH>t{z!m5Yl-UkN7^>lN}a~&tdnS!rN7^dZ)1O{m56`~EVe3&acCNY~J zKHrMXG8g>5Zoy*;ln<8QH;iRBO(z&4NmnBB@DN*8&9ts8{gTS4z_kh7uLLJHpHtPn z75uR^oh}nMsm_fvMLOxOu8(&Pa0T z3a=RLB3bGl`l=bwjvU8S2mH|KWj116ddxzR)r2E-`a`G0F1h4$sRox2xK@>WgN z05EO>+^`<9S2f|4G>KhNo~^HZ6Bf0Fev3ZdPa(#ByHEW1o>1Yxhlsxp5C78%=zD3) zR`2iSt-o;g9;h^>uuKo{VcofD%NP(dNPD80;uxeL!K5e=oNt5Sr=MkjqG?Zp0o6Nj zN6cZCox&OiE_Qo*a$>!*Dw!xqTDb4XrN#cmr^{722_jITNmqf!99l)7qGlJf8x^9Y zlu|E8iK=)Ky|X8|x~QF+!G8x>n5q>EH*8&du_uqX3SQ~7%0Zr!(mH3oKLftg$O=O& zrmR-!&UZ?W5ym_q)*=;vz!Lfpi@tPZ2B?J% ze=nC=4;)V6<86qBZ_Ns;vG61fS4ak3LSu5@+6YEutj7UbfqRrS2xbTDD{*o*zIlg{ zZ-}xnN*~TZb-!-*0uHs%o4JG9Gw0q_#QecTx4b5p+2T+hghzfGumEr($|Yc1x{d z2Ee6er#ZJWLamU}4a0C1*P6FYQw52hDpgs6_|fx*^5qZCm!B=0Dd?9Sho04gVHUm@ zE_08NQzwTde2XaIHkqwa5XT!}+UU%qND?+ECzn*%h889e8Vv4C?V-X8u`NJWPb4V1 zm!YGhC4CRDu01ZyE_WAG|>rn}R|udjxtn(rECK4Rv*yj6>80U=$){s1E4 zdC`bVn-rH=##S0~dhU{nV=o+N?J%4SF1`~K*)~t$yx&!kV>;xZK@bu9@O6KP5TlPK z3hb2atF>>uFyBg%tQ_W;5eUPIB?|ElMZu-2qBX<=lrUUAtM7~F{sZBMV0zr%BdyJi z`$cIk>;PzHZl@ZaO-uP1zVXAfbH1nz-IjqziCuGUu>AdEG zI)gjA_66z-fI^$jy5y~ zrbY%9HcpOzvW^j{BJJ>AOXd;l0{X&JvC6Px7bO!HwbdGj{m4+kWhILV|0%5aqfd9R zVZ+SZS`&cqOr-I29F6^*-jC%b=*;%^@`je{Red``>xgVIjtHL6@g?w$gc$?)?l5*% z#+VpEr+nPv63cH6V#MX$Gm&um5uKVeG5`)g%(4K_Rnsa&{EF|el{pmho0K9Ic+#~p z!nMRi@t40SbzjMc0Duy@BTl^o)vgp?cTk{7og38BQOTENh1g@ZoLt5$IV5 z*fA}$Q{ZyraJ$)sn?&fQIJxroL#Y6EEfa9SaI{Sx%&}T0(&@l3l&0?>>wP|**LB21 zK|KWR$X-w^O~NTK8F=LlkWseKmF?{cSi=%$=^ZDg&q$vd$Rhx-IN6t3IaYmXA_Cdz zp(eCAJb`;OWxes?eBpkeK%u8jKNo2IS}s;5P}&gn)J~F@vZYHoO8Adk)ob@8%vn93h7<{vO$e!oYg%Nm1ZZsnVGPTd%c7yY?960g`|?u)+C3?asEq zGX+I%ZxQuORH#TY&O82le&gRpabDr<;|r2UGnU7YdD6`3J~5lU>G`uL6Do?x!*0H; zjy1*Xo2Q@)%mqc7fx_18ZE_S9&@YQb7$LB7t)%XfTpNnb4Yp-*UIm^~<0SrE zX2^HbJn1^W1u}oGZMdx0K6nu}+^*n|<*XvDCsxoBETkIFXzNzYGI6Afqdc$7GiaBF z{6k{;p4Y}`Fj9Kv4%}g-umT{tG5I-8Bxa4@pUyM31*$Q1Ry;#2h_$^yTHOb`5zcva za&ehQG(VEGAwr}$@3cmic5h)#!pP)BR|vtVc1|WwWt5L6HtcxO%wFOpCvMg;k-$#79W8eO(mH56se$-8+@8TYi$LcxtY6S|m%!r>? zI_j6o7je{{Vfm0G=R)SphrGfv@z^(`0Lu)BMa zz4I&uPLXLVB5JXn6e%=x0PT0|s%v`+hb32h@KP(Kr}c8yZ>ZnOV{}>sgxp zg%jio`;4y+LD?MGC6(CL1w$U>8^PIh-n{)sB62r08);uV`*-qr{x z`s_BZEBRqTqCVO=8#^a*zU=Cg%mr4&ZpT|AUu5ae%t|H9-x)d(VTsAisLa;FM}3qd z`lLN2SG*~hcA8{F^gYV7uYeVXc%(GOTzYy+1!^-g=7yc4Z#?&~n`(6)6lyaC{D4wH z!^pd4$}q#Wf<&H^mAJx+CsvL)wST`~{2X91r~<`;RQ-|igCNII3TsePIX|Y8;CBMo z1W_CQ@hzW}qhwQk5*7F(N;BXR2S&9eN2-Er_f&-{QeC5t4`u=PUSgn0xfQE!_yk9( z^`$QHs_8#~MT7~v3vyh@gQHN~DH^tcKIHKWwSpvjR}1Ae){IvJm=Phv+=3m<%U3F^ zk6J*gzNR^Irt%`3)P#EbarmYx)7z(EdE<;;8FPoIlEiI)KBu72n> zs+UGDobMq_zp*~K6$R2P4^*1h0>u@MhQ+*tO%-d_Q$2!e-O(tW>f+@UyLbuBB4caH zlhYlSrnwaNVra{dzgItK)ip41%`#+S#lyb(ltvF4Q$15*6Q)ml%>MDQb>x%HcWS-T ziAP&uZ!$7o=Y@lHMrj!tp_Vc+psEDSOIIG!W%^d+L^&sB8JTo5j)&*`Pi*aUM^f=t zuzZr1Xo^paUSG9|&LK>1X$pWpBup2{nr2x5^m;%YlS15f~|o6E#1kBivd52->y8Ase}f8LI`Z(X&0wd?Uz zPGX!iebX`W%rr`$#i)kzm4YyMiF$WaYZYihBzq+Z_8^%*)#ZNOsL!>fg-PJv*~}b} zOk4}BMWyr1N>`((4RGNc> zXAv2(&~EO4;z7YoPf49bxJ!k33qup7KU1|{g^>|n+PP-bwHr40R4k_19pWrnw;ke} z;Fz!q51gO+QN3){@QT_HP1W8*Sr;kYOs{oTqnMmobF#Cl$Dt zne2Fv92mONr7fZ4Z28%h%W7O75T^UGj_U23d{BQhXi^g;h#~)0QQF?_kLcN=5?;|a zX6V^c&yPjb2+!e0D*#&KGj05a*D0Ge$Z%dyt`1MHTUi9298DpHlUrs{wQgsd@|F1m z3TSOqX3*SL-Qt3+oSar52Q=}j<{}oPV~Z6E4YfCI8ng(ROW91uH)l{Y>&dk1^C()b z-qq*hpJ3C{bObWu@R0;YA~_KW`i5;>4^CXF3@64Cj2*mgedav#q;Wry9k51?bZagz zDY%4%7iwHejwInRr|MOUJ&=`{Lc{peD&r|mb#(ayH9W&WG;2xPwD!g2vo!^?=0+vN zm&E1^BDgl@Sb&Tuyo@OtG-a#gWgEy$4e?DSw#LoB*rMl4Ibb(}2q|g8d&MT=*QpEz z5ahBq+W%f~XEV1c+ebxDS=J-B^kk!0JpJyq*sasE1;>eZMZeep5& z+bdQcq$s()s&r4J0-lnkZ{)}G$fVLJ(&N3FR8CvF@-Y~kZJ^2@vfEIrJ6tKTxf25y z6V&6K!pJycLOQsbhdTOJERA7b@m5=5+-3mjn1ApT&kRW&6C$MbXXKOxbFNE+Mq&BO zEcSHmXcf<2f*|VdO8)wor~)%`Aew&6jz3mu>NVD$_bjR1~*aFQ8Cu z-!ZI8_s%gXm1^6`B~-nT8zt+lc||5nQ0rS;2d@=MKvqM#-7~!uxmQTF zBh)K8W(a}ss}UtS=!J+@EsGk7kmMgDRfQR z0_(8!$ys)O9?|7Qk2^P{WI){pves1ML6NH1{G;z|Ph>Gd=sFbUN_kJ3vT0r%Fvinr zx2q@&uCy{-xK`9cbg(o?YIl8ws2mBlqh@W}Isa>AnkrZsO*?lW>?5$$5oZmXe`asf zLeG)Tr&ZT9L_V^{iK=k&&O4GIi) zidzcF=e|Jx89~2KTE0*u7!k-Y&CiwgV>^^c`^)oK6dj6h2Mq(6X=Lbkr=G`G%$IXh zs|pvCO)B2%nQi*u?-ev~2-1-h>X!Fw3LAgFwDK})zDW3 zzO9yFM~@6ds?`7_5_!DpZ5bT6?LKS?Iy(jbiMk8V7(vB5&gcJ+s9V~Y{FPR*-}k`b z{Sx)x2>-iAxSu^}KSx8%-ymPOMfNU%U^UZUMn)((`QmSQuLOy`mD~Q%G{NVNZHkv8(C*(XLFJ3<(XZd^S`2ENKQS@IoJN|}@ zy^-;s#r=<{lm2_$f1_?{q-XeN=lFTQ_V==-{L^#1=Zk;XW%{$<@$X`es)6&PM;0-8 z2xBvs$}d16XXwvP)47n)u_B`RSn@{4Kx<9CwnJ>v5?td1T#+yvmRua1!>C|RnhHf# zP;S|R!a`YZ$lg7t#~f-5?jj*8WjlO|0!zZ<_0`qg4di-DFS!T9RdGaOJ~yG-M68=N z9G16tUM!0zstwNCv*z(-A|*E5#yE=}uF_u^0XQYlnHDD}WyHs!5X9{0r)bAX zJ+*V*@wNv1UoZbh;FJFeVC;>oY#fdLEc|~o3*BFs#m3&u#LW6Hcu(Em^|JoD`uY!T z{a=>;XR=?}HmvYJgdW)^|L#uTyLD}NL|#EZE|qKY3!l#@5?h(~SqWy`p>g}3?Ge0! zj|NC%8w_(G8~3t~!uM@a`_y zgJB|p%K#%`+lDlkb?!9&tp@UxxH50~4rcQE_{XUZ_J7apuQdG2KWTVm-%m#d5Ae0F zQ~%7*GM2^co!NaV=eS6OWsT|G^)pbN^-o|IeE5hTb91jKzDp7B`x#*wUK`0>Zi4Hy=svN(=B-UpJg~ z{nS}KR`6nncYc5B?caEQe*j?3@%)$GYusOIla0Q)k%8kMYiczp)$jU+f71T_3le_a zh5o;^ng9Lk6Mp)Le~1hGcj6NJk4t|i&(6qVXu!(MXrRw(q|c#8&q{B^!mMx1Zpg~P z$j)x8Z)C*GLC^e00hsYWiLgv}cK-SjmPK4wcYRtRVOmxkbk~lytqt&ONx5mIKL1j5@{X%kbhouXJ-o2{t*Y8eH_)`iO@(qU zE|NeQ?i7%DJE*f8IMi-P-k{FHfYy4*DYAOOJAe<^eQzv58-x-Gqv=U~?7FrsH- zH85Z_WMnet|@s z{^m_jT|;jqH_atlPF(@VG&LQTMQhhgUcyS^Ek2$M zH*T}bu+rL~&b>wP~P-g2=NB1n!+L@vaGlq8b zw{wMv$|6&AnL?xsA?3cQt8#J`zILA6jWgDl{Q|bu2jW6+k6Y5^HtkR0CKKP>&w6bg ziO+i{>5~K~0;S3d$njKa;V-1})ge{3a;b&8AdC^A5n4Xw6b+Wuv+s6G2R$L34Rd9M zG1+l+z7nfAWEF?f;dRuA1Wh=8b9E?8tw@0$_`u$f_-c`4`(DV(c{{sPHGQeAG7D94U<6CZHaM9_ieRy zW%MOeM~`O*D#zz?(>Xgi=Y_r`Vb?O0{K%L&wi$yf(ela2Er##@3Z)_-@C>b+=X&@3 zbrFN5-0@E|g{uqd62xcS?Z&Pc`N}aq3zQv_byWv56j8hQqtoixL&!|0+!5xU6$o&9 z;jWzkWs>_mQ8ypyhB|q1x*+)>2F&;_DxI90sr>?!^2o~ugN@^=FDCGZRr14SC(bj) zW~z@JU6SE?7&}cG>jIQiy)^qkyyPC(dv1AnmfaGMRUmuoPmZfU2{cnL8l`WZF@_uB z#;cRvBAoKU)te>0IOcwz(W{U7*i*iy^5I$4BCu9g6)lnYZA%a6b4t8Nqi%yA@IcsW?A)`F=1>Ba$%jzm zk=L{eA$K}6cT@)KK0)rU>>+sLXc)%KZ4dtB)zXX&qZA>qhbNhRjG<78BDQ$(0;j5O zxrLcM1S$#=evaCp4pbC*1tvurkt41YZ_v`Lxy(3ToIJdKbv;G1-|rZs_Z-Nd%O!kE z)hQp@x6hIGbt&o4BM18Fx^Tm5?jFo-PeVu2rXJ$CuLTpmq^rF>m~RW&wx)!o26{i> zwKMpR)R~Aixbs|gwiknceKLwv8ZX79n%toQ99bq}-&OUW3Qp?=mo1N-!`AcH4sf12 z0jm83I6BpL|5%rS&0lH|%$W5AgHG>AI{;*{e%oZ65Og6;RGm0taeu~^MMtj2uoXQ? z-roHn#?bj{zx{HW@nii&l;gxdS-s`b;em`>HjJZM&}kn^U9MgUn~L6(*^70kZkI*x znoD4IKNC7fBGPPEM2ammX^92$WPIT)q-i-MdiM(ELj$(Mp5V5ZUtUfm#u34!cX zfIDCrRFw8=xcuB9|Jaqy=?_f0Rp?PZ^j7g4%G7KEjTV9y`f523^=4Zv*jlb#O%bZt zo>w1XhEGioM;VbzTbL@%IHzhTXhO!X)|l3PEaZXMOaIncMtO!jwJNhuT>vw-IEfm*=t5f;3h?~^%efG?gcYo!Ogue->P_}8%i@seiKUU;h!u-E1&im&DNP}&H!0H zQl6KFo3KI83p)d1txoH6Dacw*;h{)TqH~nGs>0mKkkjmFr83fTp~gzzSwSi3+oh{j z12QM_{Jk|e5srJYG*Tt<;*{pClZJ*nZ*uwvTKI)^ijQJv}>Vx^x;B3XB|sl-xF>|b?>d%S6avM`I1 zE_1M$Kl^XD0p3In6nt28> zpCt~MNAlh6z^4)4GK%5GoxHxMu{CD>@yvt1W)5v6UjFt%>pnIQ0A`zEx!UvY`AVt9 zq204fC;@K{(*jiWuZ zBn^{LBlcH?;K@tIbx1B(3cQ=5bZ_|mjk?m?r$f+;Alz@>&>@T#|ts3<@yWOZC-ZG zL<8Ilgw?tpB1SD^@{`$a_0(tPPZ{c}-8T)I87F)ecayuCx8&PJn zfAln(&xL3yRpwcJ5kU=RS*FpuT0kpr)5f~lD_7&A?f7w=QKLL;fu58*1_J4%$`j>k zk0UE%CJ^z;G6WJ8T#<~Vl5#Hlm}2PET4hrwM&r*0<(3q-66pwgX-S2?M5{y53SZW1jqVce#T}xuSDy)Z1@Lj%?GsLZtd&=UMd$7BJkBd$#TbZu zP@ezv!s?frRAZF^;z7aKT60_^If2`^e=DwgHL!1j%9S+f@1CgKwt| z2m!hoO+Y!9huE=#dUL>7M*>al?^6AT_Y0}eHB2$G^&7##`=Cr7Aa z^#pMWW`y#{u_u|Rr>Of?BB!yDyM9l0Vf9@PvQU&uM-R=m^8F!uEt9A7Xqb$l&2DuV zjhTNHk*`fR*Q#P`awhMkMqA>XkgTOGi%i{*m7v)Qd-FR){a*+v!e)gJ>5j`A+NcG; zJzf}7Fi*KzSv$e;gg`m2mC^nxitMqX-fShN8rRU&sAW^~bw_MMGzvPg4sP$vyz8L| zE+}ywL}lvCb-KI88_x3zlY16i!21ZOhlNg{!aohuD zrQn~4>Bmay4xeDWSnnIx@_hIFN~ogaUXtZ~)emDF94ZDWOj(b^ZS#T{MK*1-^ty7B zs)%KDOI=Q4b_`e%)MGEtkB22>y6@%sth_H~8dz$+=2#iL+sys)O70lD$#wOh#pDrE zORP{q_k(-!lbOEL>au9xRy>*9*h0*?y-^+I{7}1R$q0$jG!|Tmui4gc>5f^~BlnT5 zMwH3cIbsGd@Lir7#tU;?$j7M-_d~5FdR>NBalLX<(XlS8TqtVvg;#~4j8dO{dJ!xC zH~>$R=}TjrGbrOs-mCHw%aWfG=l$NY;a%kFf*9`BI08V8;w4*l50GR^Z!T6Q;<9O~ zU-f&x74p4_-ABkFHE$6UeA&?D_nKR_RMuA9EW!;cGVGR`-!Kx?M73LK(fhT?`%1~U zJ_Wcem5j+$Z2O@AMU>DxFHOc5gw;7$ub0<^GqF!fZGK(blE~WT%Ew|5puoGz+n?@m%*JE*w z=Uh4us5;vVl%Zm0gt-7?te1;*C-W3n_Lg%9kRup}kSEU-qcrbESqVW6F?NyY;lMlEb{X zRT%ISL*+ct%gW8~cOIjMaM~^zsuBZv71L=S2Ti!EKD^2m5R!=WAOQP0C!4GahJa`KgN z!c>55+k_U5wHNQKmKFUMw?Z}Zn+YQkmsUsW+h5z?P50pFw5i$RdLbW3fZou#F+&%8 z(onDTh0uYtzrmszUs+n+JKurj4O^zF7uFNS(}n;_B-Z>=xQiwpk}hKB z$Jp0|*bF}>;pp?!kd{9!?;UWAyiVP{8Dme9Z}eaT0IEd>#YB>Rq;y58zPQuh*6my`0)>iCH7OXSng z?^6@{QLCFxtPJiabqsT-P-?7pigz!Y3&#QB*xoP zoSauD-K|#p^x$B>9!oDj9&>V)0r_f8q?TCghkz8{e!%_+C}y%$bR*|* z185(Tp#B6sXRpa;9EGsZ7vCC-g1f|kNB>IQ9w0Jh!V? zQ^-RpcIJ2vyf2F0c-MN=zk4#`yxn@JOyt56?cQv@;)1$7i^$r9@$d(p2AN57=Ogb*}Oq0*y}I5 zWaunI7NYzC!&@LWlxF>KR{VN>bNKhrdERTq?J~Kb6nb}nzHQLGjVMip+ zzG3uS!JBXONMQq^IB%5E%0>h|RD+t@r`iq0zx4;jrT&4Rq%s!BgUg*MsEh!`3aJmF z^(^hzPAB{Eacq17CYUuw&~_ZC?I@!VWiXR82wlyFXY99Q7H1AGb9g!vtUkKq)o=(t zNW#eSsn7%+imQSD{#W?;tU?d%g(vrxc5s6JVChh3rBcw5)o&6&7XuuOOsrv^Rlx?F zJ=KSwuJHdEz=a<(`9F!>(!=#U#PINfT97iem)_q3$yZ~bypyo5#><=p6Qhwtyh?osDt+yK95D62Rfjzpw58U?rS=KEThB_~k$sBRIJ!zu&uH z7u-7|yAD$k#nj%!+RO>8M+Fi|Gh;V%|0Q0`?qO>KYrTzt1lg@XddXm6cC#peHArjx z$)^9&6?He)&SBdtpM~$bDTRo*VOY)L4~%x>u>r$Q_J!yyaXq<)z0$uwCJBGTt2z3 za!jGHuj;PuJ#NgWYBfs5PaAR^NWJQ;N9~^T0P0F;oopkvn>#1%p#1RH?Z^(Ur$G!!=Qg3mG!+v zmlpd5Hd5#sONR&s$)i{=2n8=q1O>gV64gmLU{OLYunPRgR(tn||Oq`7_5 zR^VImVfC*3m!20zEr)LyrOEPTDBbE0iaaC2v^CIBQ8p1gOuD_p8c0uA8N`=bi91ZF zD|dF4O$bhdBZo=l#!A$e^ptT9d2hv5?Kf~K`~H%(BY!L$@EPRKR49L{6hmve6T^CR zTY_|U*_|C>F48GKMoXLj@nRR*nA3#d&Xj?_LeB8sTknMg+SfL5#seV_Y{ppHlM{W6 z-qX=#Dn(-;bPW3*yUoq8g@`{e*NNkn$s!Gx1==sp_-!TROC5-f3kgyQ2n6ypQQ$qJ zSDj5Ah#pCI|M1gx7PBodpPD!&_RA0o1x9Numq(nLsHl9ll7Y`8U`ut4-FpSop1pm3 z-`c*`V|fhU<2cECuO1^v1cjZ53{@7NmbIHe`pq4NtLZU>&Lh(((>~Xi`#IH}z?1dPC@lv{;Gjebdx&B*`FY8X`4iIzTNcgg4rT<@SOjebdd3 z7Btj*2+lJ-Zwy)Iw=wM0gRnQQJ6?H&%V$2j5S+$C^16xxUo&x1!PD8r5^*u-K2qfM zFV0Ln3}0TF2HfDB7I)_w2hwGrMfmyF#qBLF<9=0VCXiea)#aSucIiN_y=@cC5D*$C zp_pS*&#{6#zt~c7lOYjRr=n%B4W!=8=I^e!70^l?KEv>Ab9P?b*U>7FE^Q~O1z?_J zjbny5TT&&CK;7dYkTrjD>B*(%IAkv)itkYBW!>pVm66J0{SGL8A$!LnA)8U3L)D=T zIL2Ko&5Ma=U{xH8D)T}tThD?z?HdmN+(J9St-;iugBjd*Voq%yg3_xBGPv9T&3@0B zG!^#ler1L{+D}ODv*cqvliz$dV8Qogj-i+L44fse^*AQ6D<3yW(ZH3lrYrmazwLH#1|I(uqM%s9^ zP*ZoDCL7fqn{dz=eve^mzu_lvpzyeFI(g1VY-ww8HeeFt%4zks=eV5V(-Ntj$aGa- z&=CdYbc5H=#d)2%zU150PNJKiH}~a_kwkfsrVM+{i)ODOYN-lsG0GEIe!xS2m~t-* z1#?5P_2cImvjWNf@1%2Y9*Yao9429~*KKD%@(?>v1ytWV-C9k)=M*2L8u09uhVS!E zcN=JbGS*w|^H|gzqQUZaD?Py`h9ZRY>i={_$IX=_Dk&<9?#sEdC%)~344Evx|Sq5`k_ys#O}v~t}T}U zifYpi4xEjzn_;!>T^52tZ^*q5X2aF0YJKa}D@UX@CyYV@RAr09w7A|0&eJl-M~ciO z7<_+fV0UQHI1<)R{20^i+OSLyVH7G5+pSwxd*om~gR`Jm&3JJQ(KpT*$-1{kVll6^ z>2082S&9MZptnkVK&fa;WGl-3c%abKW!JXtXO~D_qRostVR+)JLBe$r!6YK*MzpTX zSS909y8Uo&pW>5(Ec@HQ9*rzp=E1P$#3haHWaeU`SE_uY^0E$e=sh#h1;ZO^y( zZR@=pKT*8yx+eop{A%~}s&STUJFkdm{O)Plq<>hJm@{}zEJ(EW#0`H6VUnzotcVy> zOD+Z|G*A;ffu^v&65sH+j;XvSc0A(d1bc~cpB_h|Dqh`-SkW#?T5=UKR#lebts)Wj znjMnz0Ls+oX-c|{iQdIFIzI^7-AfIh`&3Q#mkWVT$vi{RhaJrYMg?lV6QG-aE*-CQ zdzBmKO#012q(a7b@+fJdMbs3V(gl||)xfj*1JTwC9HI(5=`8w92~&idae(AwAPIx0xdQ z*vq?rEDA=xV{u-wc6VO!5AeI{B~fj(Z8O1H(m~Ul6lra$|LoOxuC|xiD9ymwUO^CI zY^oipRn^tnw50Uq%Qeae^Gh#fPUko8YW50$%q3t}IZ$ND3b-s95m)swO{`@?zZ?l& zJ}J^zLk30Fi%~ySgCzVQKvq@Tg1|RWINzYB_!DWg_|Sc8>vz|D)YD1O??{R4b?k|a z*pYG;=7@amaeWT#uTSR*e%D1{xUm|M)4`0n_s*48Pq|-~muHb9MyG4R6E)bmbH=OH z+>tZ0GDzBBTDM+l~%7diNop0RVx!)c!yuM zOTWd(8m7?}@GSF3&%wxv|0vRWhdX=}x$P!^=3_vvG(D(=f|hmnY8zZf=tvH3 zxc*qCTqG|PfoM~QXV0-}6|D3HsV%N|RrjMciMXsG$!1&Y34S#x{lPe!qJq_s7FICsr`z3Npz zF#YSpWv#)YACnOtQ^2v1c$!^o_`xxAV@hp-Ohcht{OxXXA*@pVe-uOk1XCn>g(ZeA)gI z0tgi@ig2a9Gj-~xPk@D1V!pkO`t5!k`{jn`SJxyNo?%~>%691f+I4eA*?LZDBu7yt z(pNc4ByZV{9ihAltA=2k38XnTc1a>_kmdTv|!-K;v%sz(0%=L zX^iXq$EWVohCfT399yD|i#j>`GO)$3UT2uozvS4Bk=%$9t+Q!C(J4c~6rpJ{sc+-| z4$Y|H&ZBpsFGr*eJ7<#O2`9K_HNKR$UK6lWJ`MJ&D-Sot^!5_Cr7{S7bA#2bJIf_0 za~NX`d1lnOk@-Vaf$@;Miwdij1K)m$e`S?PWHQZ2+ZtyLV&sQp%3+4t9ia{5(bRhw zSS{^N9uw?p8*XS!C4I*X0$$+0Fd*CoL%aED`}_gHPl0PsWIa>PRAP z0L2xoI2%~95T3SOS;wlnEPTA0dZMt`k+~@$5pB{>nZ`#mc_d@aIk8htP`gOWyv-~w zvshl(n=>%X+H01j$L=$AxT9B-Y&-D)l^V?~ zfYE6y!w`#X*XeDxeDt@{Nd&djjZ~H?(Khopd%;iCm{Eao>?nhZdTX`P7eM2Hv0c(F9sCV5}Q4|NPxcm~m9PZhPew4)xTD zL;%JpeJ#%|P~>ZqMY_?(!K439N>9Cm_NXarotqlBXdE{Nm+RZ&*t1ynQ-A4Us>12j_RfUS)D@kh%**_b1GTRAjeLEe)wGZcs#e$>h_A4(O z-J2#W1x9(LjSuA>$)NdBeznBL&wTE36*<>JO|vdPdG&j4EGdK3Y~3PA2gpH z)yP?LYX_E?*U+~BN4o=M&`g0?HYM$D&jSlgP_~Tnips?@jw! zE`3;@LPoQh96(zaMyW(0x4tV~#1Hhb4l2_l7{}N43BX=#W>~4W$*Y{bQ%DJsU ze71pzgog~T`5zJJi~X;kQvmp1SiSo$At3&h!~V}ezc&Gd90)uTXrK#P0)mDGpFBZc z-|x);A%~5EH7xAbfS|F#tpWcY8+?EU&Xa}g+ee4+&)DVu^Ki$$phX~v1Wt*CB!T+? zLUy<#gCgBSgyEcDR>0{B2qJ*H0zwkNM~#qO>8POy$)M7$-`xL;a9-a$5C(km2#En7 zJwtYjqk_WR0u|50#+=je4nzSTxk6IFm06HY;Ygq;L@4kmFpr!;Scee?UTZ@!*3(9-y9&{PUJtaIA%Foxu);j=?z}{Rd4$;4mTEL_kk$0w_Fq zo`?UeMF$)#q%J-5#8QEj885>AHM#Iq#NZ$yrPz6(yvqP;*mx20Pvr06fFaeaS)jnV zpl9?&;PZ6f;Q%2;E}@+}NqRBxe93G$NJvRW=%vd~_b146wKU-{A+_nC@6@047c&1) z`VkHqQm+YmEj<9mPtTpwKa|3Q1BR4!fwsAe{e1ABi$lRdL#ik+K`moSp7YV?tH{8> zfj2Xdk_4nsI2J*8IM_lBAOPeZ2G&ax5^xGo7J?6emGS@rzz%T^7eSs{Rr-U+uOypd^f8E1C?!-<3{G#VVeubNV3jm)n1(vTE zAWS(0JpTg^8`PbY=)vx&MzpX2A^ZgLoJ10vREAr+{nX7kdzH>c$!C zV0n@QiuUkd(BS29oPmN}3eMnwtPzKP?#};vD7@7PE`tAihYbdU(=#9owV}a%px)WA zANX*q@8L}G)o=NU4O6xwDrxj(@C$0}hM z^L#cnjTYkJataXEz35_KC$fS-kS9jq2lDx#YJ!WWOx>H=Cc|Ggcwr%0+($ zI%nT=_5cyQ?}5x4h5o!I*83yF&eQz?tv^^>9>{rvW|sXcCcFteyW2T?b3uOu+|@tV zPT1+DXYMTUW)7Ta3Yi-TeIQc_i*hC(^1QTD5D=Vp`u9r+3>API: POST /auth/request-otp
{phone_number} + API->>API: Validate input + API->>RateLimiter: Check active OTP (2-min rule) + RateLimiter-->>API: No active OTP + API->>RateLimiter: Rate limit by phone (3/10min) + RateLimiter-->>API: Allowed + API->>RateLimiter: Rate limit by IP (20/10min) + RateLimiter-->>API: Allowed + API->>API: Check IP blocking + API->>OTPService: Generate OTP + OTPService->>DB: Store hashed OTP + OTPService->>RateLimiter: Mark active (2-min TTL) + API->>Twilio: Send SMS + Twilio-->>API: SMS sent (or error) + API->>AuditLogger: Log otp_request event + API-->>Client: {ok: true} + + Client->>API: POST /auth/verify-otp
{phone_number, code, device_id} + API->>API: Validate input + API->>RateLimiter: Check failed attempts (10/hour) + RateLimiter-->>API: Allowed + API->>OTPService: Verify OTP (constant-time) + OTPService->>DB: Query OTP (with dummy hash if not found) + OTPService->>OTPService: bcrypt.compare (constant-time) + alt OTP Valid + OTPService->>DB: Delete OTP + API->>DB: Find or create user + API->>DB: Upsert device + API->>API: Calculate risk score + API->>AuditLogger: Log login (with risk level) + API->>API: Issue access + refresh tokens + API-->>Client: {user, access_token, refresh_token} + else OTP Invalid + OTPService->>DB: Increment attempt count + API->>AuditLogger: Log suspicious attempt + API-->>Client: {error: "OTP invalid or expired"} + end +``` + +### 3.2 Token Refresh Flow + +**Step-by-Step:** + +1. **Client requests token refresh** (`POST /auth/refresh`) + - Input validation (refresh_token) + - Check if IP is blocked + - Decode refresh token to get key ID + - Verify refresh token signature (try all keys if key ID not found) + - Validate JWT claims (iss, aud, exp, iat) + - Query refresh token from database (by token_id) + - Verify token hash matches (bcrypt.compare) + - Check if token is revoked or expired + - Check refresh token idle timeout (max idle minutes) + - Calculate risk score (IP change, device change, user agent change) + - If suspicious: log suspicious refresh event + - If suspicious and REQUIRE_OTP_ON_SUSPICIOUS_REFRESH: return step_up_required error + - Update token last_used_at + - Revoke old refresh token + - Issue new access token and new refresh token (rotation) + - Update device last_seen_at + - Log audit event (token_refresh, risk level based on score) + - Return new tokens + +**Mermaid Sequence Diagram:** + +```mermaid +sequenceDiagram + participant Client + participant API + participant TokenService + participant JWTKeys + participant DB + participant RiskScoring + participant AuditLogger + + Client->>API: POST /auth/refresh
{refresh_token} + API->>API: Validate input + API->>API: Check IP blocking + API->>TokenService: Verify refresh token + TokenService->>JWTKeys: Get key secret (by key ID) + JWTKeys-->>TokenService: Key secret + TokenService->>TokenService: Verify JWT signature + TokenService->>TokenService: Validate claims (iss, aud, exp) + TokenService->>DB: Query refresh token (by token_id) + DB-->>TokenService: Token record + TokenService->>TokenService: Verify token hash (bcrypt) + alt Token Valid + TokenService->>TokenService: Check expiry & idle timeout + API->>RiskScoring: Calculate risk score + RiskScoring->>DB: Get previous auth info + RiskScoring-->>API: Risk score & reasons + alt Suspicious Refresh + API->>AuditLogger: Log suspicious refresh + alt Require OTP + API-->>Client: {error: "step_up_required"} + else Allow with Risk + API->>TokenService: Rotate refresh token + TokenService->>DB: Revoke old token + TokenService->>DB: Store new token + API->>AuditLogger: Log refresh (SUSPICIOUS/HIGH_RISK) + API-->>Client: {access_token, refresh_token} + end + else Normal Refresh + API->>TokenService: Rotate refresh token + TokenService->>DB: Revoke old token + TokenService->>DB: Store new token + API->>DB: Update device last_seen_at + API->>AuditLogger: Log refresh (INFO) + API-->>Client: {access_token, refresh_token} + end + else Token Invalid + API-->>Client: {error: "Invalid refresh token"} + end +``` + +### 3.3 Logout Flow + +**Step-by-Step:** + +1. **Single-device logout** (`POST /auth/logout`) + - Input validation (refresh_token) + - Verify refresh token (same as refresh flow) + - If token invalid/already revoked: return success (idempotent) + - Revoke all refresh tokens for user + device + - Log audit event (logout, INFO) + - Return success + +2. **Logout all other devices** (`POST /users/me/logout-all-other-devices`) + - Requires authentication (access token) + - Requires step-up auth (recent OTP or high_assurance token) + - Rate limited (10 per hour per user) + - Get current device_id from header or body + - Mark all other devices as inactive + - Revoke refresh tokens for all other devices + - Log audit event (logout_all_other_devices, INFO) + - Return count of revoked devices + +3. **Logout from all devices** (`POST /users/me/logout-all-devices`) + - Requires authentication (access token) + - Requires step-up auth (recent OTP or high_assurance token) + - Rate limited (10 per hour per user) + - Revoke all refresh tokens for the user (all devices) + - Mark all devices as inactive + - Increment user's `token_version` to invalidate all existing access tokens + - Log audit event (logout_all_devices, HIGH_RISK) - triggers security alert + - Return success with revoked tokens count + - **Security Note**: This is a critical security operation used when account compromise is suspected. All existing access tokens become invalid immediately, even if they haven't expired yet. + +4. **Revoke specific device** (`DELETE /users/me/devices/:device_id`) + - Requires authentication (access token) + - Requires step-up auth (recent OTP or high_assurance token) + - Rate limited (10 per hour per user) + - Validate device_id parameter + - Mark device as inactive + - Revoke refresh tokens for device + - Log audit event (device_revoked, INFO) + - Return success + +**Mermaid Sequence Diagram:** + +```mermaid +sequenceDiagram + participant Client + participant API + participant TokenService + participant DB + participant AuditLogger + + Note over Client,AuditLogger: Single Device Logout + Client->>API: POST /auth/logout
{refresh_token} + API->>TokenService: Verify refresh token + TokenService-->>API: Token info + API->>TokenService: Revoke refresh token + TokenService->>DB: Mark token revoked + API->>AuditLogger: Log logout event + API-->>Client: {ok: true} + + Note over Client,AuditLogger: Logout All Other Devices + Client->>API: POST /users/me/logout-all-other-devices
{current_device_id} + API->>API: Verify access token + API->>API: Check step-up auth + API->>API: Rate limit check (10/hour) + API->>DB: Mark other devices inactive + API->>TokenService: Revoke tokens for other devices + TokenService->>DB: Revoke tokens + API->>AuditLogger: Log logout_all_other_devices + API-->>Client: {ok: true, revoked_devices_count: N} + + Note over Client,AuditLogger: Logout All Devices (Global Logout) + Client->>API: POST /users/me/logout-all-devices + API->>API: Verify access token + API->>API: Check step-up auth + API->>API: Rate limit check (10/hour) + API->>TokenService: Revoke all user tokens + TokenService->>DB: Revoke all refresh tokens + TokenService->>DB: Mark all devices inactive + TokenService->>DB: Increment token_version + API->>AuditLogger: Log logout_all_devices (HIGH_RISK) + AuditLogger->>AuditLogger: Trigger security alert + API-->>Client: {ok: true, revoked_tokens_count: N} +``` + +### 3.4 Admin Security Events Flow + +**Step-by-Step:** + +1. **Admin requests security events** (`GET /admin/security-events`) + - Requires authentication (access token) + - Requires admin role (security_admin) + - Rate limited (100 per 15 minutes per admin) + - Validate and sanitize query parameters (risk_level, limit, offset, search) + - Build parameterized SQL query (prevent injection) + - Query auth_audit table with filters + - Mask phone numbers (keep last 4 digits) + - Sanitize all output fields + - Get total count for pagination + - Get statistics (last 24 hours: total, high_risk, suspicious, info) + - Log admin access event (admin_view_security_events, INFO) + - Return events, pagination info, and statistics + +**Mermaid Sequence Diagram:** + +```mermaid +sequenceDiagram + participant Admin + participant API + participant AuthMiddleware + participant AdminAuth + participant AdminRateLimit + participant DB + participant AuditLogger + + Admin->>API: GET /admin/security-events
?risk_level=HIGH_RISK&limit=200 + API->>AuthMiddleware: Verify access token + AuthMiddleware-->>API: User info + API->>AdminAuth: Check admin role + AdminAuth-->>API: Authorized + API->>AdminRateLimit: Check rate limit (100/15min) + AdminRateLimit-->>API: Allowed + API->>API: Sanitize query params + API->>DB: Query auth_audit (parameterized) + DB-->>API: Events data + API->>API: Mask phone numbers + API->>API: Sanitize output + API->>DB: Get total count + API->>DB: Get statistics (24h) + API->>AuditLogger: Log admin access + API-->>Admin: {events, pagination, stats} +``` + +--- + +## 4. Timeouts, Expiry, and Limits + +| Name | ENV Variable / Config | Default Value | Defined In | What It Affects | +|------|----------------------|---------------|------------|-----------------| +| **OTP Expiry** | `OTP_TTL_SECONDS` | `120` (2 minutes) | `src/services/otpService.js:10` | OTP validity period | +| **OTP Resend Throttle** | (hardcoded) | `120` seconds | `src/middleware/rateLimitMiddleware.js:154` | Minimum time between OTP requests for same phone | +| **Max OTP Verification Attempts** | `OTP_VERIFY_MAX_ATTEMPTS` | `5` | `src/services/otpService.js:12` | Maximum attempts to verify an OTP before it's invalidated | +| **JWT Access Token Expiry** | `JWT_ACCESS_TTL` | `'15m'` (15 minutes) | `src/config.js:72` | Access token lifetime | +| **JWT Refresh Token Expiry** | `JWT_REFRESH_TTL` | `'7d'` (7 days) | `src/config.js:73` | Refresh token lifetime | +| **Refresh Token Max Idle** | `REFRESH_MAX_IDLE_MINUTES` | `4320` (3 days) | `src/config.js:58-60` | Maximum idle time before refresh token expires | +| **Step-Up Auth Window** | `STEP_UP_OTP_WINDOW_MINUTES` | `5` minutes | `src/middleware/stepUpAuth.js:26` | Time window for "recent" OTP verification for step-up auth | +| **OTP Request - Phone (10 min)** | `OTP_REQ_PHONE_10MIN_LIMIT` | `3` | `src/middleware/rateLimitMiddleware.js:24` | Max OTP requests per phone per 10 minutes | +| **OTP Request - Phone (24h)** | `OTP_REQ_PHONE_DAY_LIMIT` | `10` | `src/middleware/rateLimitMiddleware.js:25` | Max OTP requests per phone per 24 hours | +| **OTP Request - IP (10 min)** | `OTP_REQ_IP_10MIN_LIMIT` | `20` | `src/middleware/rateLimitMiddleware.js:26` | Max OTP requests per IP per 10 minutes | +| **OTP Request - IP (24h)** | `OTP_REQ_IP_DAY_LIMIT` | `100` | `src/middleware/rateLimitMiddleware.js:27` | Max OTP requests per IP per 24 hours | +| **OTP Verify Failed (1h)** | `OTP_VERIFY_FAILED_PER_HOUR_LIMIT` | `10` | `src/middleware/rateLimitMiddleware.js:31` | Max failed verification attempts per phone per hour | +| **Enumeration IP Block Duration** | `ENUMERATION_BLOCK_DURATION` | `3600` (1 hour) | `src/middleware/rateLimitMiddleware.js:40` | Duration IP is blocked after enumeration detection | +| **User Rate Limit - Read** | `USER_RATE_LIMIT_READ_MAX` | `100` | `src/middleware/userRateLimit.js:25` | Max read requests per user per 15 minutes | +| **User Rate Limit - Read Window** | `USER_RATE_LIMIT_READ_WINDOW` | `900` (15 min) | `src/middleware/userRateLimit.js:26` | Time window for read rate limit | +| **User Rate Limit - Write** | `USER_RATE_LIMIT_WRITE_MAX` | `20` | `src/middleware/userRateLimit.js:29` | Max write requests per user per 15 minutes | +| **User Rate Limit - Write Window** | `USER_RATE_LIMIT_WRITE_WINDOW` | `900` (15 min) | `src/middleware/userRateLimit.js:30` | Time window for write rate limit | +| **User Rate Limit - Sensitive** | `USER_RATE_LIMIT_SENSITIVE_MAX` | `10` | `src/middleware/userRateLimit.js:33` | Max sensitive requests per user per hour | +| **User Rate Limit - Sensitive Window** | `USER_RATE_LIMIT_SENSITIVE_WINDOW` | `3600` (1 hour) | `src/middleware/userRateLimit.js:34` | Time window for sensitive rate limit | +| **Admin Rate Limit** | `ADMIN_RATE_LIMIT_MAX` | `100` | `src/middleware/adminRateLimit.js:23` | Max admin requests per admin per 15 minutes | +| **Admin Rate Limit Window** | `ADMIN_RATE_LIMIT_WINDOW` | `900` (15 min) | `src/middleware/adminRateLimit.js:24` | Time window for admin rate limit | +| **Twilio HTTP Timeout** | (hardcoded) | `5000` ms | `src/services/auditLogger.js:459` | Webhook request timeout (also used for Twilio if configured) | +| **Webhook Retry Delay** | (hardcoded) | `3000` ms | `src/services/auditLogger.js:498` | Delay before retrying failed webhook alerts | +| **OTP Request Min Delay** | `OTP_REQUEST_MIN_DELAY` | `500` ms | `src/utils/timingProtection.js:26` | Minimum delay for OTP requests (timing attack protection) | +| **OTP Verify Min Delay** | `OTP_VERIFY_MIN_DELAY` | `300` ms | `src/utils/timingProtection.js:30` | Minimum delay for OTP verification (timing attack protection) | +| **Timing Max Jitter** | `TIMING_MAX_JITTER` | `100` ms | `src/utils/timingProtection.js:34` | Maximum random jitter added to delays | +| **Enumeration Max Phones/IP (10min)** | `ENUMERATION_MAX_PHONES_PER_IP_10MIN` | `5` | `src/utils/enumerationDetection.js:32` | Max unique phone numbers per IP in 10 minutes | +| **Enumeration Max Phones/IP (1h)** | `ENUMERATION_MAX_PHONES_PER_IP_HOUR` | `20` | `src/utils/enumerationDetection.js:33` | Max unique phone numbers per IP in 1 hour | +| **Enumeration Alert Threshold (10min)** | `ENUMERATION_ALERT_THRESHOLD_10MIN` | `10` | `src/utils/enumerationDetection.js:40` | Unique phones threshold for alert (10 min) | +| **Enumeration Alert Threshold (1h)** | `ENUMERATION_ALERT_THRESHOLD_HOUR` | `50` | `src/utils/enumerationDetection.js:41` | Unique phones threshold for alert (1 hour) | + +--- + +## 5. Security Features + +### 5.1 CORS Behavior + +**Configuration:** +- **Startup Validation**: CORS configuration is validated at startup (`src/index.js:29-34`) +- **Runtime Monitoring**: Runtime CORS checks log warnings for suspicious patterns (`src/index.js:58-63`) +- **Origin Whitelisting**: Only explicitly configured origins are allowed (never wildcard `*` when credentials are involved) +- **No-Origin Requests**: Requests without origin (mobile apps, Postman) are allowed + +**Implementation:** +- `CORS_ALLOWED_ORIGINS`: Comma-separated list of allowed origins (required in production) +- Development mode: Allows all origins if no origins configured (with warning) +- Production mode: Throws error if `CORS_ALLOWED_ORIGINS` is empty + +**Files:** +- `src/index.js:36-86` - CORS middleware configuration +- `src/utils/corsValidator.js` - CORS validation utilities + +### 5.2 Security Headers + +**Headers Set Globally:** +- `X-Frame-Options: DENY` - Prevents clickjacking +- `X-Content-Type-Options: nosniff` - Prevents MIME type sniffing +- `X-XSS-Protection: 1; mode=block` - Enables XSS filter (legacy browsers) +- `Strict-Transport-Security` - HSTS (only in production, max-age=31536000, includeSubDomains, preload) +- `Content-Security-Policy` - CSP with nonce support for inline scripts/styles +- `Referrer-Policy: strict-origin-when-cross-origin` - Controls referrer information +- `Permissions-Policy` - Restricts browser features (geolocation, microphone, camera, etc.) + +**Files:** +- `src/middleware/securityHeaders.js` - Security headers middleware + +### 5.3 Authentication & Authorization + +**Authentication:** +- **OTP-Based**: Phone number + 6-digit OTP code +- **JWT Access Tokens**: Short-lived (15 minutes), signed with HS256, include `token_version` claim +- **JWT Refresh Tokens**: Long-lived (7 days), stored hashed in database, rotated on each use +- **Device Tracking**: Tracks device identifier, platform, model, OS version, app version +- **Token Versioning**: Access tokens include `token_version` claim that is validated against user's current version in database. When user logs out from all devices, `token_version` is incremented, invalidating all existing access tokens immediately. + +**Authorization:** +- **Role-Based**: Admin routes require `role === 'security_admin'` +- **Step-Up Auth**: Sensitive operations require recent OTP verification or `high_assurance` token flag +- **Token Claims**: Validates `iss` (issuer), `aud` (audience), `exp` (expiration), `iat` (issued at), `token_version` (for access token invalidation) + +**Files:** +- `src/middleware/authMiddleware.js` - Access token validation +- `src/middleware/adminAuth.js` - Admin role check +- `src/middleware/stepUpAuth.js` - Step-up authentication + +### 5.4 Audit Logging + +**Events Logged:** +- `otp_request` - OTP request (success/failed) +- `otp_verify` - OTP verification (success/failed) +- `login` - User login (success/blocked) +- `token_refresh` - Token refresh (success, with risk level) +- `logout` - User logout +- `device_revoked` - Device revocation +- `logout_all_other_devices` - Logout all other devices +- `logout_all_devices` - Logout from all devices (HIGH_RISK, triggers security alert) +- `admin_view_security_events` - Admin access to security dashboard + +**Risk Levels:** +- `INFO` - Normal operations +- `SUSPICIOUS` - Unusual patterns (IP change, device change, multiple failures) +- `HIGH_RISK` - Blocked IPs, high risk scores (>=50), enumeration attempts + +**Alerting:** +- **Webhook Integration**: Sends alerts to `SECURITY_ALERT_WEBHOOK_URL` for SUSPICIOUS/HIGH_RISK events +- **Anomaly Detection**: Detects patterns (multiple failed OTPs, multiple high-risk events from same IP) +- **Retry Logic**: Retries failed webhook alerts once after 3 seconds + +**Files:** +- `src/services/auditLogger.js` - Audit logging and webhook alerting +- `src/services/riskScoring.js` - Risk score calculation + +### 5.5 Data Protection + +**Field-Level Encryption:** +- **Algorithm**: AES-256-GCM (authenticated encryption) +- **Fields Encrypted**: Phone numbers (before storing in database) +- **Key Management**: 32-byte key from `ENCRYPTION_KEY` (base64 encoded) +- **Backward Compatibility**: Handles both encrypted and plaintext data during migration + +**Database Access Logging:** +- **Optional Feature**: Enabled with `DB_ACCESS_LOGGING_ENABLED=true` +- **Logs**: All database queries with context (user ID, IP, user agent) +- **Use Case**: Security auditing, compliance + +**Files:** +- `src/utils/fieldEncryption.js` - Field-level encryption +- `src/middleware/dbAccessLogger.js` - Database access logging + +### 5.6 Protection Against Attacks + +**Brute-Force / Enumeration:** +- Rate limiting at multiple levels (phone, IP, user) +- Enumeration detection (tracks unique phone numbers per IP) +- IP blocking for enumeration attempts (1 hour block) +- Stricter rate limits when enumeration detected + +**Timing Attacks:** +- Constant-time OTP verification (always performs bcrypt.compare, uses dummy hash if OTP not found) +- Timing protection wrappers for OTP request and verification flows +- Minimum delay enforcement to prevent timing leaks + +**Man-in-the-Middle:** +- HTTPS enforcement via HSTS header (production) +- Security headers (CSP, X-Frame-Options) prevent various MITM attacks +- JWT token validation with signature verification + +**Token Replay:** +- Refresh token rotation (new token issued, old token revoked) +- Reuse detection (if old token is used, all tokens for device are revoked) +- Access token short expiry (15 minutes) limits replay window +- Token versioning: Access tokens include `token_version` claim that is validated on each request. When user logs out from all devices, version is incremented, immediately invalidating all existing access tokens (even if not expired) + +**Files:** +- `src/utils/timingProtection.js` - Timing attack protection +- `src/utils/enumerationDetection.js` - Enumeration detection +- `src/services/tokenService.js` - Token rotation and reuse detection + +--- + +## 6. Error Handling & Failure Modes + +### 6.1 OTP Sending Failures + +**Behavior:** +- If Twilio is not configured: OTP is logged to console, request still succeeds +- If Twilio fails: Error is logged, OTP is still generated and stored, request succeeds +- **Rationale**: OTP generation should not fail if SMS delivery fails (user can check logs in development) + +**Error Response:** +- Success response returned even if SMS fails (for development/testing) +- Production recommendation: Return error if SMS fails (uncomment error return in `src/routes/authRoutes.js:213`) + +**Files:** +- `src/services/smsService.js` - SMS sending with fallback logging + +### 6.2 Database Failures + +**Behavior:** +- Connection pool errors: Logged, process exits (`src/db.js:11-14`) +- Query errors: Propagated to route handler, return 500 error +- **No Retries**: Database queries are not retried automatically (application-level retries can be added) + +**Error Response:** +- `500 Internal Server Error` with generic message: `{error: 'Internal server error'}` + +**Files:** +- `src/db.js` - Database connection and query wrapper + +### 6.3 JWT Validation Errors + +**Behavior:** +- Invalid token format: `401 Unauthorized` - `{error: 'Invalid token format'}` +- Invalid/expired token: `401 Unauthorized` - `{error: 'Invalid or expired token'}` +- Invalid claims: `401 Unauthorized` - `{error: 'Invalid token claims'}` +- Missing Authorization header: `401 Unauthorized` - `{error: 'Missing Authorization header'}` + +**Key Rotation:** +- If key ID not found: Tries all available keys (for rotation support) +- If no key matches: Returns `401 Unauthorized` + +**Files:** +- `src/middleware/authMiddleware.js` - JWT validation +- `src/services/tokenService.js` - Refresh token validation + +### 6.4 Rate Limit Exceeded + +**Behavior:** +- OTP request rate limit: `429 Too Many Requests` - `{success: false, message: 'Too many OTP requests...'}` +- OTP verify rate limit: `429 Too Many Requests` - `{success: false, message: 'Too many attempts...'}` +- User route rate limit: `429 Too Many Requests` - `{error: 'Too many requests', retry_after: seconds}` +- Admin route rate limit: `429 Too Many Requests` - `{error: 'Too many requests', retry_after: seconds}` + +**Headers:** +- `X-RateLimit-Limit`: Maximum requests allowed +- `X-RateLimit-Remaining`: Remaining requests in window +- `X-RateLimit-Reset`: ISO timestamp when limit resets +- `X-RateLimit-Type`: Type of rate limit (read/write/sensitive/admin) + +**Files:** +- `src/middleware/rateLimitMiddleware.js` - OTP rate limiting +- `src/middleware/userRateLimit.js` - User route rate limiting +- `src/middleware/adminRateLimit.js` - Admin rate limiting + +### 6.5 Retries & Fallbacks + +**Redis Fallback:** +- If Redis unavailable: Falls back to in-memory store (per-process, not shared) +- Rate limiting continues to work (with per-instance limits, not global) +- Warning logged on first failure, then silent + +**Webhook Alerting:** +- If webhook fails: Retries once after 3 seconds +- If retry fails: Error logged, but main request flow continues (non-blocking) + +**Files:** +- `src/services/redisClient.js` - Redis client with graceful fallback +- `src/services/auditLogger.js:334-516` - Webhook alerting with retry + +--- + +## 7. Configuration & Environment Variables + +### 7.1 Required Variables + +| Variable | Description | Example | Required | +|----------|-------------|---------|----------| +| `DATABASE_URL` | PostgreSQL connection string | `postgres://user:pass@localhost:5432/dbname` | ✅ Yes | +| `JWT_ACCESS_SECRET` | Secret for signing access tokens (min 32 chars) | `hex-string-32-chars-minimum` | ✅ Yes | +| `JWT_REFRESH_SECRET` | Secret for signing refresh tokens (min 32 chars) | `hex-string-32-chars-minimum` | ✅ Yes | + +### 7.2 Optional Variables - Timeouts & Expiry + +| Variable | Description | Default | Example | +|----------|-------------|---------|---------| +| `JWT_ACCESS_TTL` | Access token expiry | `15m` | `15m`, `1h` | +| `JWT_REFRESH_TTL` | Refresh token expiry | `7d` | `7d`, `30d` | +| `REFRESH_MAX_IDLE_MINUTES` | Refresh token max idle time | `4320` (3 days) | `4320` | +| `OTP_TTL_SECONDS` | OTP validity in seconds | `120` (2 min) | `120` | +| `STEP_UP_OTP_WINDOW_MINUTES` | Step-up auth window | `5` | `5` | + +### 7.3 Optional Variables - Rate Limits + +| Variable | Description | Default | Example | +|----------|-------------|---------|---------| +| `OTP_REQ_PHONE_10MIN_LIMIT` | Max OTP requests per phone (10 min) | `3` | `3` | +| `OTP_REQ_PHONE_DAY_LIMIT` | Max OTP requests per phone (24h) | `10` | `10` | +| `OTP_REQ_IP_10MIN_LIMIT` | Max OTP requests per IP (10 min) | `20` | `20` | +| `OTP_REQ_IP_DAY_LIMIT` | Max OTP requests per IP (24h) | `100` | `100` | +| `OTP_VERIFY_MAX_ATTEMPTS` | Max OTP verification attempts | `5` | `5` | +| `OTP_VERIFY_FAILED_PER_HOUR_LIMIT` | Max failed verifications per phone (1h) | `10` | `10` | +| `USER_RATE_LIMIT_READ_MAX` | Max read requests per user (15 min) | `100` | `100` | +| `USER_RATE_LIMIT_WRITE_MAX` | Max write requests per user (15 min) | `20` | `20` | +| `USER_RATE_LIMIT_SENSITIVE_MAX` | Max sensitive requests per user (1h) | `10` | `10` | +| `ADMIN_RATE_LIMIT_MAX` | Max admin requests per admin (15 min) | `100` | `100` | + +### 7.4 Optional Variables - Security Features + +| Variable | Description | Default | Example | +|----------|-------------|---------|---------| +| `ENCRYPTION_ENABLED` | Enable field-level encryption | `false` | `true` | +| `ENCRYPTION_KEY` | 32-byte encryption key (base64) | - | `base64-encoded-32-byte-key` | +| `DB_ACCESS_LOGGING_ENABLED` | Enable database access logging | `false` | `true` | +| `DB_ACCESS_LOG_LEVEL` | DB access log level ('all' or 'sensitive') | `sensitive` | `all`, `sensitive` | +| `CORS_ALLOWED_ORIGINS` | Comma-separated allowed origins | - | `https://app.example.com,https://api.example.com` | +| `ENUMERATION_MAX_PHONES_PER_IP_10MIN` | Max unique phones per IP (10 min) | `5` | `5` | +| `ENUMERATION_MAX_PHONES_PER_IP_HOUR` | Max unique phones per IP (1h) | `20` | `20` | +| `ENUMERATION_ALERT_THRESHOLD_10MIN` | Alert threshold for enumeration (10 min) | `10` | `10` | +| `ENUMERATION_ALERT_THRESHOLD_HOUR` | Alert threshold for enumeration (1h) | `50` | `50` | +| `OTP_REQUEST_MIN_DELAY` | Min delay for OTP requests (ms) | `500` | `500` | +| `OTP_VERIFY_MIN_DELAY` | Min delay for OTP verify (ms) | `300` | `300` | +| `TIMING_MAX_JITTER` | Max jitter for timing protection (ms) | `100` | `100` | +| `BLOCKED_IP_RANGES` | Comma-separated CIDR blocks | - | `10.0.0.0/8,172.16.0.0/12` | +| `REQUIRE_OTP_ON_SUSPICIOUS_REFRESH` | Require OTP on suspicious refresh | `false` | `true` | +| `SECURITY_ALERT_WEBHOOK_URL` | Webhook URL for security alerts | - | `https://hooks.slack.com/...` | +| `SECURITY_ALERT_MIN_LEVEL` | Minimum risk level for alerts | `HIGH_RISK` | `SUSPICIOUS`, `HIGH_RISK` | + +### 7.5 Optional Variables - JWT Key Rotation + +| Variable | Description | Default | Example | +|----------|-------------|---------|---------| +| `JWT_ACTIVE_KEY_ID` | Key ID for signing new tokens | `1` | `1`, `2` | +| `JWT_KEYS_JSON` | JSON mapping key IDs to secrets | - | `{"1":"secret1","2":"secret2"}` | +| `JWT_REFRESH_KEY_ID` | Key ID for refresh tokens | Same as active | `1` | +| `JWT_ISSUER` | JWT issuer claim | `farm-auth-service` | `farm-auth-service` | +| `JWT_AUDIENCE` | JWT audience claim | `mobile-app` | `mobile-app` | + +### 7.6 Optional Variables - External Services + +| Variable | Description | Default | Example | +|----------|-------------|---------|---------| +| `TWILIO_ACCOUNT_SID` | Twilio account SID | - | `ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx` | +| `TWILIO_AUTH_TOKEN` | Twilio auth token | - | `your_auth_token` | +| `TWILIO_MESSAGING_SERVICE_SID` | Twilio messaging service SID | - | `MGxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx` | +| `TWILIO_FROM_NUMBER` | Twilio phone number (E.164) | - | `+1234567890` | +| `REDIS_URL` | Redis connection URL | - | `redis://localhost:6379` | +| `REDIS_HOST` | Redis host | `localhost` | `localhost` | +| `REDIS_PORT` | Redis port | `6379` | `6379` | +| `REDIS_PASSWORD` | Redis password | - | `password` | + +### 7.7 Optional Variables - Server Configuration + +| Variable | Description | Default | Example | +|----------|-------------|---------|---------| +| `PORT` | Server port | `3000` | `3000` | +| `NODE_ENV` | Environment | - | `development`, `production` | +| `TRUST_PROXY` | Trust proxy headers | `false` | `true` | +| `ENABLE_ADMIN_DASHBOARD` | Enable admin routes | `false` | `true` | + +--- + +## 8. Future Improvements / Notes + +### 8.1 Planned Improvements (from TODOs in code) + +1. **Secrets Manager Integration** + - Load JWT keys from AWS Secrets Manager / HashiCorp Vault (instead of environment variables) + - Load encryption keys from secrets manager + - **File**: `src/services/jwtKeys.js:161-174` (TODO comment) + +2. **Automated Key Rotation** + - Implement automated JWT key rotation without downtime + - Re-encrypt existing data when encryption keys are rotated + - **File**: `src/services/jwtKeys.js` (key rotation support exists, but automation needed) + +3. **SIEM Integration** + - Integrate with SIEM systems (Splunk, ELK, etc.) for centralized log aggregation + - Export audit logs to SIEM for advanced threat detection + - **File**: `src/services/auditLogger.js` (webhook exists, but SIEM integration needed) + +4. **CSP Nonces** + - Fully implement CSP nonces for inline scripts/styles (currently allows `unsafe-inline` for compatibility) + - **File**: `src/middleware/securityHeaders.js:28-29` (nonce support exists but not fully utilized) + +5. **Database Connection Pooling Tuning** + - Add configuration for connection pool size, timeout, etc. + - **File**: `src/db.js` (basic pool, no tuning options) + +6. **Rate Limiting Improvements** + - Implement distributed rate limiting (currently per-instance if Redis unavailable) + - Add rate limit headers to all rate-limited endpoints + - **File**: `src/middleware/rateLimitMiddleware.js` (Redis fallback exists, but distributed limiting needed) + +7. **OTP Delivery Alternatives** + - Support multiple SMS providers (fallback if Twilio fails) + - Support email OTP delivery + - Support push notification OTP delivery + - **File**: `src/services/smsService.js` (only Twilio supported) + +8. **Advanced Risk Scoring** + - Machine learning-based risk scoring + - Geographic anomaly detection (unusual locations) + - Device fingerprinting improvements + - **File**: `src/services/riskScoring.js` (basic scoring exists) + +### 8.2 Potential Risks & Technical Debt + +1. **In-Memory Rate Limiting** + - If Redis is unavailable, rate limiting uses in-memory store (per-instance, not shared) + - **Risk**: Rate limits are per-instance, not global (can be bypassed with multiple instances) + - **Mitigation**: Always use Redis in production, or implement distributed rate limiting + +2. **OTP Storage** + - OTPs are stored in database (not just Redis) + - **Risk**: Database can become a bottleneck for high-volume OTP requests + - **Mitigation**: Consider moving OTP storage to Redis entirely (with DB backup for audit) + +3. **Phone Number Encryption Migration** + - Handles both encrypted and plaintext phone numbers (backward compatibility) + - **Risk**: Plaintext phone numbers still in database if encryption was enabled after data existed + - **Mitigation**: Implement migration script to encrypt all existing phone numbers + +4. **Webhook Alerting** + - Webhook failures are logged but don't block requests + - **Risk**: Security alerts might be missed if webhook is down + - **Mitigation**: Implement alert queue (Redis/RabbitMQ) with retry logic and dead-letter queue + +5. **Database Access Logging** + - Database access logging is optional and can impact performance + - **Risk**: Performance degradation if enabled in high-traffic scenarios + - **Mitigation**: Use async logging, batch writes, or separate logging database + +6. **JWT Key Rotation** + - Key rotation support exists, but manual process + - **Risk**: Manual key rotation can cause downtime if not done correctly + - **Mitigation**: Implement automated key rotation with gradual rollout + +7. **CORS Configuration** + - CORS validation at startup, but runtime checks are warnings only + - **Risk**: Misconfiguration might not be caught until runtime + - **Mitigation**: Add stricter runtime validation or fail-fast on suspicious patterns + +8. **Error Messages** + - Some error messages are generic to prevent information leakage + - **Risk**: Generic errors can make debugging difficult + - **Mitigation**: Log detailed errors server-side, return generic errors to clients + +--- + +## Appendix: Database Schema + +### Key Tables + +**users** +- `id` (UUID, PK) +- `phone_number` (VARCHAR(20), UNIQUE, encrypted if ENCRYPTION_ENABLED) +- `name` (VARCHAR(255)) +- `role` (enum: 'user', 'admin', 'moderator') +- `user_type` (enum: 'seller', 'buyer', 'service_provider') +- `token_version` (INT, DEFAULT 1) - Incremented on logout-all-devices to invalidate all access tokens +- `created_at`, `updated_at`, `last_login_at` + +**otp_codes** +- `id` (UUID, PK) +- `phone_number` (VARCHAR(20), encrypted if ENCRYPTION_ENABLED) +- `otp_hash` (VARCHAR(255), bcrypt hash) +- `expires_at` (TIMESTAMPTZ) +- `attempt_count` (INT) +- `created_at` (TIMESTAMPTZ) + +**refresh_tokens** +- `id` (UUID, PK) +- `user_id` (UUID, FK) +- `token_id` (UUID, UNIQUE) +- `token_hash` (VARCHAR(255), bcrypt hash) +- `device_id` (VARCHAR(255)) +- `user_agent` (TEXT) +- `ip_address` (VARCHAR(45)) +- `expires_at` (TIMESTAMPTZ) +- `last_used_at` (TIMESTAMPTZ) +- `revoked_at` (TIMESTAMPTZ, NULL = active) +- `reuse_detected_at` (TIMESTAMPTZ) +- `rotated_from_id` (UUID, FK to refresh_tokens) + +**user_devices** +- `id` (UUID, PK) +- `user_id` (UUID, FK) +- `device_identifier` (TEXT) +- `device_platform` (TEXT) +- `device_model` (TEXT) +- `os_version` (TEXT) +- `app_version` (TEXT) +- `language_code` (TEXT) +- `timezone` (TEXT) +- `first_seen_at` (TIMESTAMPTZ) +- `last_seen_at` (TIMESTAMPTZ) +- `is_active` (BOOLEAN) +- UNIQUE (user_id, device_identifier) + +**auth_audit** +- `id` (UUID, PK) +- `user_id` (UUID, FK, nullable) +- `action` (VARCHAR(100)) +- `status` (VARCHAR(50)) +- `risk_level` (VARCHAR(20): 'INFO', 'SUSPICIOUS', 'HIGH_RISK') +- `ip_address` (VARCHAR(45)) +- `user_agent` (TEXT) +- `device_id` (VARCHAR(255)) +- `meta` (JSONB) +- `created_at` (TIMESTAMPTZ) + +--- + +## Document Version + +- **Version**: 1.0 +- **Last Updated**: 2024 +- **Author**: Architecture Documentation Generator +- **Maintained By**: Development Team + diff --git a/src/index.js b/src/index.js index 533fe74..4e17dc4 100644 --- a/src/index.js +++ b/src/index.js @@ -12,6 +12,8 @@ const authMiddleware = require('./middleware/authMiddleware'); const adminAuth = require('./middleware/adminAuth'); const adminRateLimit = require('./middleware/adminRateLimit'); const securityHeaders = require('./middleware/securityHeaders'); +// === SECURITY HARDENING: CORS VALIDATION === +const { validateCorsConfig, checkCorsAtRuntime } = require('./utils/corsValidator'); const app = express(); @@ -22,6 +24,15 @@ if (process.env.TRUST_PROXY === 'true' || process.env.TRUST_PROXY === '1') { app.set('trust proxy', true); } +// === SECURITY HARDENING: CORS VALIDATION === +// Validate CORS configuration at startup +try { + validateCorsConfig(); +} catch (error) { + console.error('❌ CORS Configuration Error:', error.message); + process.exit(1); +} + // === SECURITY HARDENING: CORS === // CORS configuration with strict origin whitelisting // IMPORTANT: Never use wildcard '*' when credentials or tokens are involved @@ -44,6 +55,13 @@ if (allowAllOrigins) { return callback(null, true); } + // === SECURITY HARDENING: RUNTIME CORS CHECK === + // Check for misconfiguration or suspicious patterns + const runtimeCheck = checkCorsAtRuntime(origin); + if (runtimeCheck && runtimeCheck.suspicious) { + console.warn(`⚠️ CORS Runtime Warning: ${runtimeCheck.reason}`); + } + // Check if origin is in whitelist if (config.corsAllowedOrigins.includes(origin)) { return callback(null, true); @@ -68,6 +86,10 @@ if (allowAllOrigins) { } app.use(express.json()); +// === SECURITY HARDENING: SECURITY HEADERS === +// Apply security headers to all routes (not just admin) +app.use(securityHeaders); + // 👇 This must be here to serve test.html app.use(express.static('public')); @@ -93,8 +115,8 @@ if (ENABLE_ADMIN_DASHBOARD) { // 4. Security headers (securityHeaders) // Admin API endpoints + // Note: securityHeaders already applied globally, but keeping here for explicit documentation app.use('/admin', - securityHeaders, authMiddleware, adminAuth, adminRateLimit, @@ -103,12 +125,11 @@ if (ENABLE_ADMIN_DASHBOARD) { // Serve admin dashboard (protected route) app.get('/admin/security-dashboard', - securityHeaders, authMiddleware, adminAuth, (req, res) => { // === SECURITY HARDENING: CLICKJACKING PROTECTION === - // Security headers already set by securityHeaders middleware + // Security headers already set by global securityHeaders middleware res.sendFile('security-dashboard.html', { root: 'public' }); } ); diff --git a/src/middleware/authMiddleware.js b/src/middleware/authMiddleware.js index 975b3e4..7557f40 100644 --- a/src/middleware/authMiddleware.js +++ b/src/middleware/authMiddleware.js @@ -7,7 +7,7 @@ const { validateTokenClaims, } = require('../services/jwtKeys'); -function authMiddleware(req, res, next) { +async function authMiddleware(req, res, next) { const auth = req.headers.authorization || ''; const token = auth.startsWith('Bearer ') ? auth.slice(7) : null; @@ -71,6 +71,32 @@ function authMiddleware(req, res, next) { return res.status(401).json({ error: 'Invalid token claims' }); } + // === SECURITY HARDENING: GLOBAL LOGOUT === + // Validate token_version to ensure token hasn't been invalidated by logout-all-devices + // We need to check the user's current token_version in the database + const db = require('../db'); + try { + const { rows } = await db.query( + `SELECT COALESCE(token_version, 1) as token_version FROM users WHERE id = $1`, + [payload.sub] + ); + + if (rows.length === 0) { + return res.status(401).json({ error: 'User not found' }); + } + + const userTokenVersion = rows[0].token_version; + const tokenVersion = payload.token_version || 1; + + // If token version doesn't match, token has been invalidated + if (tokenVersion !== userTokenVersion) { + return res.status(401).json({ error: 'Invalid or expired token' }); + } + } catch (dbErr) { + console.error('Error validating token version:', dbErr); + return res.status(500).json({ error: 'Internal server error' }); + } + req.user = { id: payload.sub, phone_number: payload.phone_number, diff --git a/src/middleware/rateLimitMiddleware.js b/src/middleware/rateLimitMiddleware.js index d0d407b..9089bcc 100644 --- a/src/middleware/rateLimitMiddleware.js +++ b/src/middleware/rateLimitMiddleware.js @@ -32,6 +32,12 @@ const config = { // OTP validity OTP_TTL_SECONDS: parseInt(process.env.OTP_TTL_SECONDS || '120', 10), // 2 minutes + + // === SECURITY HARDENING: ENUMERATION PROTECTION === + // Stricter limits when enumeration is detected + ENUMERATION_IP_10MIN_LIMIT: parseInt(process.env.ENUMERATION_IP_10MIN_LIMIT || '2', 10), // Reduced from 20 + ENUMERATION_IP_HOUR_LIMIT: parseInt(process.env.ENUMERATION_IP_HOUR_LIMIT || '5', 10), // Reduced from 100 + ENUMERATION_BLOCK_DURATION: parseInt(process.env.ENUMERATION_BLOCK_DURATION || '3600', 10), // 1 hour block }; /** @@ -314,6 +320,79 @@ async function incrementFailedVerify(phone) { await incrementCounter(key, 3600); // 1 hour = 3600 seconds } +/** + * === SECURITY HARDENING: ENUMERATION PROTECTION === + * Check if an IP is blocked due to enumeration attempts + * + * @param {string} ip - IP address to check + * @returns {Promise} True if IP is blocked + */ +async function isIpBlockedForEnumeration(ip) { + const key = `enumeration:blocked:ip:${ip}`; + return await exists(key); +} + +/** + * === SECURITY HARDENING: ENUMERATION PROTECTION === + * Block an IP address due to enumeration attempts + * + * @param {string} ip - IP address to block + * @param {number} durationSeconds - Block duration in seconds (default: from config) + * @returns {Promise} + */ +async function blockIpForEnumeration(ip, durationSeconds = null) { + const key = `enumeration:blocked:ip:${ip}`; + const duration = durationSeconds || config.ENUMERATION_BLOCK_DURATION; + await setWithTTL(key, '1', duration); + console.warn(`[ENUMERATION PROTECTION] Blocked IP ${ip} for ${duration} seconds due to enumeration attempts`); +} + +/** + * === SECURITY HARDENING: ENUMERATION PROTECTION === + * Apply stricter rate limiting for IPs with enumeration attempts + * This middleware should be used when enumeration is detected + * + * @param {string} ip - IP address + * @returns {Promise<{allowed: boolean, reason?: string}>} + */ +async function checkEnumerationRateLimit(ip) { + // Check if IP is already blocked + if (await isIpBlockedForEnumeration(ip)) { + return { + allowed: false, + reason: 'IP blocked due to enumeration attempts', + }; + } + + // Apply stricter limits + const key10min = `enumeration:rate_limit:ip:${ip}:10min`; + const keyHour = `enumeration:rate_limit:ip:${ip}:hour`; + + const count10min = await incrementCounter(key10min, 600); // 10 minutes + const countHour = await incrementCounter(keyHour, 3600); // 1 hour + + // Check stricter limits + if (count10min > config.ENUMERATION_IP_10MIN_LIMIT) { + // Block IP for enumeration + await blockIpForEnumeration(ip); + return { + allowed: false, + reason: 'Too many requests. IP blocked due to enumeration attempts.', + }; + } + + if (countHour > config.ENUMERATION_IP_HOUR_LIMIT) { + // Block IP for enumeration + await blockIpForEnumeration(ip); + return { + allowed: false, + reason: 'Too many requests. IP blocked due to enumeration attempts.', + }; + } + + return { allowed: true }; +} + module.exports = { checkActiveOtpForPhone, rateLimitRequestOtpByPhone, @@ -321,6 +400,9 @@ module.exports = { rateLimitVerifyOtpByPhone, markActiveOtp, incrementFailedVerify, + isIpBlockedForEnumeration, + blockIpForEnumeration, + checkEnumerationRateLimit, config, }; diff --git a/src/middleware/securityHeaders.js b/src/middleware/securityHeaders.js index 752ec2e..a62579d 100644 --- a/src/middleware/securityHeaders.js +++ b/src/middleware/securityHeaders.js @@ -1,15 +1,66 @@ // src/middleware/securityHeaders.js // === SECURITY HARDENING === -// Security headers middleware for admin dashboard -// Prevents clickjacking and enforces security best practices +// Security headers middleware +// Prevents clickjacking, XSS, and enforces security best practices + +const crypto = require('crypto'); +const config = require('../config'); + +/** + * Generate a CSP nonce for inline scripts/styles + * This allows safe execution of inline content + */ +function generateNonce() { + return crypto.randomBytes(16).toString('base64'); +} + +/** + * Build Content Security Policy header + * @param {string} nonce - Optional nonce for inline scripts/styles + * @returns {string} CSP header value + */ +function buildCSP(nonce = null) { + const nonceValue = nonce ? `'nonce-${nonce}'` : ''; + + // Base CSP directives + const directives = [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' 'unsafe-eval'", // Allow inline for compatibility (can be tightened) + "style-src 'self' 'unsafe-inline'", // Allow inline styles (can be tightened) + "img-src 'self' data: https:", // Allow images from self, data URIs, and HTTPS + "font-src 'self' data: https:", // Allow fonts from self, data URIs, and HTTPS + "connect-src 'self'", // API calls only to same origin + "frame-ancestors 'none'", // Prevent embedding (replaces X-Frame-Options) + "base-uri 'self'", // Restrict base tag + "form-action 'self'", // Forms can only submit to same origin + "upgrade-insecure-requests", // Upgrade HTTP to HTTPS + ]; + + // Add nonce if provided + if (nonceValue) { + // Replace unsafe-inline with nonce for scripts + directives[1] = `script-src 'self' ${nonceValue} 'unsafe-eval'`; + // Replace unsafe-inline with nonce for styles + directives[2] = `style-src 'self' ${nonceValue}`; + } + + // Allow CORS origins for connect-src if configured + if (config.corsAllowedOrigins.length > 0) { + const allowedOrigins = config.corsAllowedOrigins.join(' '); + directives[5] = `connect-src 'self' ${allowedOrigins}`; + } + + return directives.join('; '); +} /** * Security headers middleware - * Sets security headers including frameguard to prevent clickjacking + * Sets comprehensive security headers including CSP */ function securityHeaders(req, res, next) { // === SECURITY HARDENING: CLICKJACKING PROTECTION === // Prevent page from being embedded in iframes + // Note: CSP frame-ancestors will override this, but keeping for older browsers res.setHeader('X-Frame-Options', 'DENY'); // === SECURITY HARDENING: CONTENT TYPE PROTECTION === @@ -17,15 +68,35 @@ function securityHeaders(req, res, next) { res.setHeader('X-Content-Type-Options', 'nosniff'); // === SECURITY HARDENING: XSS PROTECTION === - // Enable XSS filter (legacy, but still useful) + // Enable XSS filter (legacy, but still useful for older browsers) res.setHeader('X-XSS-Protection', '1; mode=block'); // === SECURITY HARDENING: HTTPS ENFORCEMENT === // Force HTTPS in production (if behind proxy) - if (process.env.NODE_ENV === 'production') { - res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); + if (config.isProduction) { + res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload'); } + // === SECURITY HARDENING: CONTENT SECURITY POLICY === + // Generate nonce for this request (stored in res.locals for use in templates) + const nonce = generateNonce(); + res.locals.cspNonce = nonce; + + // Set CSP header + const csp = buildCSP(nonce); + res.setHeader('Content-Security-Policy', csp); + + // === SECURITY HARDENING: REFERRER POLICY === + // Control referrer information sent + res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); + + // === SECURITY HARDENING: PERMISSIONS POLICY === + // Restrict browser features + res.setHeader( + 'Permissions-Policy', + 'geolocation=(), microphone=(), camera=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()' + ); + next(); } diff --git a/src/middleware/stepUpAuth.js b/src/middleware/stepUpAuth.js index 255a676..bd2a694 100644 --- a/src/middleware/stepUpAuth.js +++ b/src/middleware/stepUpAuth.js @@ -109,3 +109,5 @@ module.exports = { + + diff --git a/src/middleware/userRateLimit.js b/src/middleware/userRateLimit.js new file mode 100644 index 0000000..fcf2a72 --- /dev/null +++ b/src/middleware/userRateLimit.js @@ -0,0 +1,184 @@ +// src/middleware/userRateLimit.js +// === SECURITY HARDENING: USER ROUTES RATE LIMITING === +// Rate limiting middleware for user routes +// Different limits for read vs write operations +// Stricter limits for sensitive operations (device management) + +const { getRedisClient, isRedisReady } = require('../services/redisClient'); + +// In-memory fallback store +const memoryStore = {}; + +// Clean up expired entries periodically +setInterval(() => { + const now = Date.now(); + Object.keys(memoryStore).forEach((key) => { + if (memoryStore[key].expiresAt && memoryStore[key].expiresAt < now) { + delete memoryStore[key]; + } + }); +}, 60000); // Clean up every minute + +// Configuration from environment variables +const USER_RATE_LIMIT_CONFIG = { + // Read operations (GET) - more lenient + READ_MAX_REQUESTS: parseInt(process.env.USER_RATE_LIMIT_READ_MAX || '100', 10), + READ_WINDOW_SECONDS: parseInt(process.env.USER_RATE_LIMIT_READ_WINDOW || '900', 10), // 15 minutes + + // Write operations (PUT, POST) - stricter + WRITE_MAX_REQUESTS: parseInt(process.env.USER_RATE_LIMIT_WRITE_MAX || '20', 10), + WRITE_WINDOW_SECONDS: parseInt(process.env.USER_RATE_LIMIT_WRITE_WINDOW || '900', 10), // 15 minutes + + // Sensitive operations (device management, logout-all) - very strict + SENSITIVE_MAX_REQUESTS: parseInt(process.env.USER_RATE_LIMIT_SENSITIVE_MAX || '10', 10), + SENSITIVE_WINDOW_SECONDS: parseInt(process.env.USER_RATE_LIMIT_SENSITIVE_WINDOW || '3600', 10), // 1 hour +}; + +/** + * Helper: Increment counter in Redis or memory store + */ +async function incrementCounter(key, ttlSeconds) { + const redis = await getRedisClient(); + + if (isRedisReady() && redis) { + try { + const count = await redis.incr(key); + if (count === 1) { + // First increment, set TTL + await redis.expire(key, ttlSeconds); + } + return count; + } catch (err) { + console.error('Redis increment error in user rate limit, falling back to memory:', err); + // Fall through to memory store + } + } + + // Memory store fallback + const now = Date.now(); + if (!memoryStore[key]) { + memoryStore[key] = { + count: 0, + expiresAt: now + ttlSeconds * 1000, + }; + } + + memoryStore[key].count++; + return memoryStore[key].count; +} + +/** + * Helper: Get counter value from Redis or memory store + */ +async function getCounter(key) { + const redis = await getRedisClient(); + + if (isRedisReady() && redis) { + try { + const count = await redis.get(key); + return count ? parseInt(count, 10) : 0; + } catch (err) { + console.error('Redis get error in user rate limit, falling back to memory:', err); + // Fall through to memory store + } + } + + // Memory store fallback + if (memoryStore[key] && memoryStore[key].expiresAt > Date.now()) { + return memoryStore[key].count || 0; + } + + return 0; +} + +/** + * Rate limiting middleware factory + * Creates middleware with specific limits based on operation type + * + * @param {string} type - 'read', 'write', or 'sensitive' + * @returns {Function} Express middleware + */ +function createUserRateLimit(type) { + return async function userRateLimit(req, res, next) { + try { + const userId = req.user?.id; + if (!userId) { + // If no user, let auth middleware handle it + return next(); + } + + // Select configuration based on type + let config; + switch (type) { + case 'read': + config = { + maxRequests: USER_RATE_LIMIT_CONFIG.READ_MAX_REQUESTS, + windowSeconds: USER_RATE_LIMIT_CONFIG.READ_WINDOW_SECONDS, + }; + break; + case 'write': + config = { + maxRequests: USER_RATE_LIMIT_CONFIG.WRITE_MAX_REQUESTS, + windowSeconds: USER_RATE_LIMIT_CONFIG.WRITE_WINDOW_SECONDS, + }; + break; + case 'sensitive': + config = { + maxRequests: USER_RATE_LIMIT_CONFIG.SENSITIVE_MAX_REQUESTS, + windowSeconds: USER_RATE_LIMIT_CONFIG.SENSITIVE_WINDOW_SECONDS, + }; + break; + default: + // Default to write limits for safety + config = { + maxRequests: USER_RATE_LIMIT_CONFIG.WRITE_MAX_REQUESTS, + windowSeconds: USER_RATE_LIMIT_CONFIG.WRITE_WINDOW_SECONDS, + }; + } + + const key = `user_rate_limit:${type}:${userId}`; + const count = await incrementCounter(key, config.windowSeconds); + + // Check if limit exceeded + if (count > config.maxRequests) { + return res.status(429).json({ + error: 'Too many requests', + message: `Rate limit exceeded. Maximum ${config.maxRequests} ${type} requests per ${config.windowSeconds} seconds allowed.`, + retry_after: config.windowSeconds, + limit_type: type, + }); + } + + // Add rate limit headers + res.setHeader('X-RateLimit-Limit', config.maxRequests); + res.setHeader('X-RateLimit-Remaining', Math.max(0, config.maxRequests - count)); + res.setHeader('X-RateLimit-Reset', new Date(Date.now() + (config.windowSeconds * 1000)).toISOString()); + res.setHeader('X-RateLimit-Type', type); + + next(); + } catch (err) { + console.error('User rate limit error:', err); + // On error, allow the request to proceed (fail open) + // This ensures legitimate users aren't blocked if rate limiting fails + next(); + } + }; +} + +// Export pre-configured middlewares +module.exports = { + // Read operations (GET) + userRateLimitRead: createUserRateLimit('read'), + + // Write operations (PUT, POST) + userRateLimitWrite: createUserRateLimit('write'), + + // Sensitive operations (device management, logout-all) + userRateLimitSensitive: createUserRateLimit('sensitive'), + + // Factory function for custom limits + createUserRateLimit, + + // Configuration (for testing/documentation) + config: USER_RATE_LIMIT_CONFIG, +}; diff --git a/src/routes/authRoutes.js b/src/routes/authRoutes.js index 953829f..7ddb35e 100644 --- a/src/routes/authRoutes.js +++ b/src/routes/authRoutes.js @@ -18,6 +18,9 @@ const { rateLimitRequestOtpByIp, rateLimitVerifyOtpByPhone, incrementFailedVerify, + checkEnumerationRateLimit, + blockIpForEnumeration, + isIpBlockedForEnumeration, } = require('../middleware/rateLimitMiddleware'); // === SECURITY HARDENING: INPUT VALIDATION === const { @@ -45,6 +48,10 @@ const { // === SECURITY HARDENING: FIELD-LEVEL ENCRYPTION === const { encryptPhoneNumber, decryptPhoneNumber } = require('../utils/fieldEncryption'); const { preparePhoneSearchParams } = require('../utils/encryptedPhoneSearch'); +// === SECURITY HARDENING: TIMING ATTACK PROTECTION === +const { executeOtpRequestWithTiming, otpRequestDelay, executeOtpVerifyWithTiming, otpVerifyDelay } = require('../utils/timingProtection'); +// === SECURITY HARDENING: ENUMERATION DETECTION === +const { detectAndLogEnumeration } = require('../utils/enumerationDetection'); const router = express.Router(); @@ -119,52 +126,108 @@ router.post( message: 'Access denied from this location.', }); } - try { - const { phone_number } = req.body; - if (!phone_number) { - return res.status(400).json({ error: 'phone_number is required' }); - } - - const normalizedPhone = normalizePhone(phone_number); - - // Validate phone number format - if (!isValidPhoneNumber(normalizedPhone)) { - return res.status(400).json({ - error: 'Invalid phone number format. Please use E.164 format (e.g., +919876543210)' - }); - } - - const { code } = await createOtp(normalizedPhone); - - // Attempt to send SMS (will fallback to safe logging if Twilio fails) - const smsResult = await sendOtpSms(normalizedPhone, code); - - // === SECURITY HARDENING: AUDIT LOGS & ANOMALY FLAGS === - // Log OTP request event - await logAuthEvent({ - action: 'otp_request', - status: smsResult?.success ? 'success' : 'failed', - riskLevel: RISK_LEVELS.INFO, - ipAddress: clientIp, - userAgent: req.headers['user-agent'], - meta: { - phone: normalizedPhone.replace(/\d(?=\d{4})/g, '*'), // Mask phone - }, + + // === SECURITY HARDENING: ENUMERATION PROTECTION === + // Check if IP is blocked for enumeration attempts + if (await isIpBlockedForEnumeration(clientIp)) { + return res.status(429).json({ + success: false, + message: 'Too many requests. Please try again later.', }); - - // Even if SMS fails, we still return success because OTP is generated - // In production, you may want to return an error if SMS fails - if (!smsResult || !smsResult.success) { - console.warn('⚠️ SMS sending failed, but OTP was generated'); - // Option 1: Still return success (current behavior - allows testing) - // Option 2: Return error (uncomment below for production) - // return res.status(500).json({ error: 'Failed to send OTP via SMS' }); + } + + try { + // === SECURITY HARDENING: TIMING ATTACK PROTECTION === + // Execute all work with timing protection to prevent enumeration + // This ensures all requests take similar time regardless of phone existence + const result = await executeOtpRequestWithTiming(async () => { + const { phone_number } = req.body; + if (!phone_number) { + return { error: 'phone_number is required', status: 400 }; + } + + const normalizedPhone = normalizePhone(phone_number); + + // === SECURITY HARDENING: ENUMERATION DETECTION === + // Check for enumeration attempts before processing + const enumerationCheck = await detectAndLogEnumeration( + clientIp, + normalizedPhone, + req.headers['user-agent'] + ); + + // Apply stricter rate limiting if enumeration detected + if (enumerationCheck.isEnumeration) { + const enumRateLimit = await checkEnumerationRateLimit(clientIp); + if (!enumRateLimit.allowed) { + // Block IP if enumeration rate limit exceeded + await blockIpForEnumeration(clientIp); + return { + error: enumRateLimit.reason || 'Too many requests. Please try again later.', + status: 429, + }; + } + } + + // Block high-risk enumeration attempts immediately + if (enumerationCheck.shouldBlock) { + await blockIpForEnumeration(clientIp); + return { + error: 'Too many requests. IP blocked due to enumeration attempts.', + status: 429, + }; + } + + // Validate phone number format + if (!isValidPhoneNumber(normalizedPhone)) { + return { + error: 'Invalid phone number format. Please use E.164 format (e.g., +919876543210)', + status: 400 + }; + } + + const { code } = await createOtp(normalizedPhone); + + // Attempt to send SMS (will fallback to safe logging if Twilio fails) + const smsResult = await sendOtpSms(normalizedPhone, code); + + // === SECURITY HARDENING: AUDIT LOGS & ANOMALY FLAGS === + // Log OTP request event + await logAuthEvent({ + action: 'otp_request', + status: smsResult?.success ? 'success' : 'failed', + riskLevel: RISK_LEVELS.INFO, + ipAddress: clientIp, + userAgent: req.headers['user-agent'], + meta: { + phone: normalizedPhone.replace(/\d(?=\d{4})/g, '*'), // Mask phone + }, + }); + + // Even if SMS fails, we still return success because OTP is generated + // In production, you may want to return an error if SMS fails + if (!smsResult || !smsResult.success) { + console.warn('⚠️ SMS sending failed, but OTP was generated'); + // Option 1: Still return success (current behavior - allows testing) + // Option 2: Return error (uncomment below for production) + // return { error: 'Failed to send OTP via SMS', status: 500 }; + } + + return { ok: true, status: 200 }; + }); + + // Handle errors or return success + if (result.error) { + return res.status(result.status || 500).json({ error: result.error }); } return res.json({ ok: true }); } catch (err) { console.error('request-otp error', err); + // === SECURITY HARDENING: TIMING ATTACK PROTECTION === + // Even on error, ensure minimum delay to prevent timing leaks + await otpRequestDelay(); return res.status(500).json({ error: 'Internal server error' }); } } @@ -190,41 +253,60 @@ router.post( }); } try { - const { phone_number, code, device_id, device_info } = req.body; - if (!phone_number || !code) { - return res.status(400).json({ error: 'phone_number and code are required' }); - } - - const normalizedPhone = normalizePhone(phone_number); - - const result = await verifyOtp(normalizedPhone, code); - - // === ADDED FOR OTP ATTEMPT LIMIT === - // === SECURITY HARDENING: AUDIT LOGS & ANOMALY FLAGS === - // Use generic error message to avoid leaking information - if (!result.ok) { - // Increment failed verification counter - try { - await incrementFailedVerify(normalizedPhone); - } catch (err) { - console.error('Failed to increment failed verify counter:', err); - // Don't fail the request if counter increment fails + // === SECURITY HARDENING: TIMING ATTACK PROTECTION === + // Execute all verification work with timing protection + // This ensures all verification attempts take similar time regardless of outcome + const verificationResult = await executeOtpVerifyWithTiming(async () => { + const { phone_number, code, device_id, device_info } = req.body; + if (!phone_number || !code) { + return { error: 'phone_number and code are required', status: 400 }; } + + const normalizedPhone = normalizePhone(phone_number); + + const result = await verifyOtp(normalizedPhone, code); - // Log suspicious OTP attempt - await logSuspiciousOtpAttempt( - normalizedPhone, - clientIp, - req.headers['user-agent'], - result.reason || 'invalid' - ); - - return res.status(400).json({ + // === ADDED FOR OTP ATTEMPT LIMIT === + // === SECURITY HARDENING: AUDIT LOGS & ANOMALY FLAGS === + // Use generic error message to avoid leaking information + if (!result.ok) { + // Increment failed verification counter + try { + await incrementFailedVerify(normalizedPhone); + } catch (err) { + console.error('Failed to increment failed verify counter:', err); + // Don't fail the request if counter increment fails + } + + // Log suspicious OTP attempt + await logSuspiciousOtpAttempt( + normalizedPhone, + clientIp, + req.headers['user-agent'], + result.reason || 'invalid' + ); + + return { + success: false, + message: 'OTP invalid or expired. Please request a new one.', + status: 400 + }; + } + + return { ok: true, normalizedPhone, device_id, device_info }; + }); + + // Handle verification failure + if (verificationResult.error || !verificationResult.ok) { + return res.status(verificationResult.status || 400).json({ success: false, - message: 'OTP invalid or expired. Please request a new one.' + message: verificationResult.message || 'OTP invalid or expired. Please request a new one.' }); } + // Continue with successful verification + const { normalizedPhone, device_id, device_info } = verificationResult; + // find or create user // === SECURITY HARDENING: FIELD-LEVEL ENCRYPTION === // Encrypt phone number before storing/searching @@ -233,7 +315,7 @@ router.post( let user; const found = await db.query( - `SELECT id, phone_number, name, role, NULL::user_type_enum as user_type + `SELECT id, phone_number, name, role, NULL::user_type_enum as user_type, COALESCE(token_version, 1) as token_version FROM users WHERE phone_number = $1 OR phone_number = $2`, phoneSearchParams @@ -244,7 +326,7 @@ router.post( const inserted = await db.query( `INSERT INTO users (phone_number) VALUES ($1) - RETURNING id, phone_number, name, role, NULL::user_type_enum as user_type`, + RETURNING id, phone_number, name, role, NULL::user_type_enum as user_type, COALESCE(token_version, 1) as token_version`, [encryptedPhone] ); user = inserted.rows[0]; @@ -371,6 +453,9 @@ router.post( }); } catch (err) { console.error('verify-otp error', err); + // === SECURITY HARDENING: TIMING ATTACK PROTECTION === + // Even on error, ensure minimum delay to prevent timing leaks + await otpVerifyDelay(); return res.status(500).json({ error: 'Internal server error' }); } }); @@ -404,7 +489,7 @@ router.post( const { userId, deviceId, row: tokenRow } = verification; const { rows } = await db.query( - `SELECT id, phone_number, name, role, user_type FROM users WHERE id = $1`, + `SELECT id, phone_number, name, role, user_type, COALESCE(token_version, 1) as token_version FROM users WHERE id = $1`, [userId] ); if (rows.length === 0) { diff --git a/src/routes/userRoutes.js b/src/routes/userRoutes.js index d47f420..398d573 100644 --- a/src/routes/userRoutes.js +++ b/src/routes/userRoutes.js @@ -8,6 +8,13 @@ const { decryptPhoneNumber } = require('../utils/fieldEncryption'); // === SECURITY HARDENING: STEP-UP AUTH === const { requireRecentOtpOrReauth } = require('../middleware/stepUpAuth'); +// === SECURITY HARDENING: RATE LIMITING === +const { + userRateLimitRead, + userRateLimitWrite, + userRateLimitSensitive, +} = require('../middleware/userRateLimit'); + // === VALIDATION: USER ROUTES === const { validateUpdateProfileBody, @@ -18,7 +25,8 @@ const { const router = express.Router(); // GET /users/me -router.get('/me', auth, async (req, res) => { +// Rate limited: Read operation (100 requests per 15 minutes per user) +router.get('/me', auth, userRateLimitRead, async (req, res) => { try { // Get user basic information const { rows } = await db.query( @@ -114,10 +122,12 @@ router.get('/me', auth, async (req, res) => { }); // PUT /users/me +// Rate limited: Write operation (20 requests per 15 minutes per user) // Validates: name (optional, max 100), user_type (optional enum: seller/buyer/service_provider) router.put( '/me', auth, + userRateLimitWrite, requireRecentOtpOrReauth, validateUpdateProfileBody, async (req, res) => { @@ -160,7 +170,8 @@ router.put( ); // GET /users/me/devices - List all active devices -router.get('/me/devices', auth, async (req, res) => { +// Rate limited: Read operation (100 requests per 15 minutes per user) +router.get('/me/devices', auth, userRateLimitRead, async (req, res) => { try { const { rows } = await db.query( ` @@ -190,10 +201,12 @@ router.get('/me/devices', auth, async (req, res) => { }); // DELETE /users/me/devices/:device_id - Revoke/logout a specific device +// Rate limited: Sensitive operation (10 requests per hour per user) // Validates: device_id param (string, max 100 chars) router.delete( '/me/devices/:device_id', auth, + userRateLimitSensitive, requireRecentOtpOrReauth, validateDeviceIdParam, async (req, res) => { @@ -240,10 +253,12 @@ router.delete( ); // POST /users/me/logout-all-other-devices - Logout all other devices (keep current) +// Rate limited: Sensitive operation (10 requests per hour per user) // Validates: current_device_id (optional string, max 100 chars if provided) router.post( '/me/logout-all-other-devices', auth, + userRateLimitSensitive, requireRecentOtpOrReauth, validateLogoutOthersBody, async (req, res) => { @@ -305,4 +320,51 @@ router.post( } ); +// POST /users/me/logout-all-devices - Logout from ALL devices (including current) +// === SECURITY HARDENING: GLOBAL LOGOUT === +// Rate limited: Sensitive operation (10 requests per hour per user) +// Requires step-up authentication (recent OTP or high_assurance token) +// Logs HIGH_RISK security event for audit +router.post( + '/me/logout-all-devices', + auth, + userRateLimitSensitive, + requireRecentOtpOrReauth, + async (req, res) => { + try { + const { logAuthEvent, RISK_LEVELS } = require('../services/auditLogger'); + const { revokeAllUserTokens } = require('../services/tokenService'); + + // Revoke all refresh tokens and invalidate all access tokens + const result = await revokeAllUserTokens(req.user.id); + + // Log HIGH_RISK security event for audit + await logAuthEvent({ + userId: req.user.id, + action: 'logout_all_devices', + status: 'success', + riskLevel: RISK_LEVELS.HIGH_RISK, + deviceId: req.headers['x-device-id'] || null, + ipAddress: req.ip || req.connection.remoteAddress || 'unknown', + userAgent: req.headers['user-agent'], + meta: { + revoked_tokens_count: result.revokedTokensCount, + new_token_version: result.newTokenVersion, + reason: 'user_initiated_global_logout', + message: 'User initiated logout from all devices - security breach suspected', + }, + }); + + return res.json({ + ok: true, + message: 'Logged out from all devices successfully', + revoked_tokens_count: result.revokedTokensCount, + }); + } catch (err) { + console.error('logout all devices error', err); + return res.status(500).json({ error: 'Internal server error' }); + } + } +); + module.exports = router; diff --git a/src/services/auditLogger.js b/src/services/auditLogger.js index 97370e0..126dd4d 100644 --- a/src/services/auditLogger.js +++ b/src/services/auditLogger.js @@ -56,16 +56,20 @@ function mapRiskLevelToSeverity(riskLevel) { } /** + * === SECURITY HARDENING: ACTIVE ALERTING === * Check if a risk level meets the minimum threshold for alerting * @param {string} riskLevel - Risk level to check * @returns {boolean} True if risk level is high enough to trigger alert */ -function shouldTriggerAlert(riskLevel) { +function shouldAlert(riskLevel) { const eventRisk = RISK_LEVEL_HIERARCHY[riskLevel] || 0; const minRisk = RISK_LEVEL_HIERARCHY[SECURITY_ALERT_MIN_LEVEL] || 3; return eventRisk >= minRisk; } +// Alias for backward compatibility +const shouldTriggerAlert = shouldAlert; + /** * Log an authentication event with risk level * @@ -112,7 +116,7 @@ async function logAuthEvent({ ); // === SECURITY HARDENING: ACTIVE ALERTING === - // Trigger security alert if risk level meets threshold or anomaly detected + // Automatically trigger security alert for all SUSPICIOUS and HIGH_RISK events if (result.rows.length > 0) { const eventRecord = { id: result.rows[0].id, @@ -130,12 +134,15 @@ async function logAuthEvent({ // Check for anomalies that might require alerting const anomalyAlert = await checkAnomalies(userId, action, ipAddress); - // Trigger alert if risk level meets threshold OR anomaly detected - const shouldAlert = shouldTriggerAlert(riskLevel) || anomalyAlert?.shouldAlert; + // === SECURITY HARDENING: ACTIVE ALERTING === + // Automatically trigger alert if: + // 1. Risk level meets minimum threshold (SUSPICIOUS or HIGH_RISK) + // 2. OR anomaly detected + const shouldTrigger = shouldAlert(riskLevel) || anomalyAlert?.shouldAlert; - if (shouldAlert) { - // Fire and forget - don't await to avoid blocking - triggerSecurityAlert(eventRecord, anomalyAlert).catch(err => { + if (shouldTrigger) { + // Fire and forget - don't await to avoid blocking main request flow + sendSecurityAlert(eventRecord, anomalyAlert).catch(err => { console.error('[auditLogger] Alert trigger failed (non-blocking):', err.message); }); } @@ -316,101 +323,123 @@ async function checkAnomalies(userId, action, ipAddress) { /** * === SECURITY HARDENING: ACTIVE ALERTING === - * Trigger security alert via webhook (Slack, Discord, or custom webhook) + * Send security alert via webhook (Slack, Discord, or custom webhook) + * Automatically triggered for all SUSPICIOUS and HIGH_RISK events * * @param {Object} eventRecord - Audit event record from database * @param {Object|null} anomalyAlert - Anomaly detection result (if any) + * @param {number} retryCount - Internal retry counter (default: 0) * @returns {Promise} */ -async function triggerSecurityAlert(eventRecord, anomalyAlert = null) { - // If no webhook URL configured, just log and return +async function sendSecurityAlert(eventRecord, anomalyAlert = null, retryCount = 0) { + // === SECURITY HARDENING: ACTIVE ALERTING === + // If no webhook URL configured, log and return gracefully (no error) if (!SECURITY_ALERT_WEBHOOK_URL) { if (process.env.NODE_ENV !== 'test') { - console.warn('[auditLogger] No SECURITY_ALERT_WEBHOOK_URL configured; skipping external alert.'); + console.log('[SECURITY ALERT DISABLED] No SECURITY_ALERT_WEBHOOK_URL configured'); } return; } try { - // Build alert payload (Slack-compatible format, but generic enough for other webhooks) - const severity = mapRiskLevelToSeverity(eventRecord.riskLevel); - const summary = eventRecord.meta?.message || - `${eventRecord.action} - ${eventRecord.status} (${eventRecord.riskLevel})`; - - // Extract phone number from meta if present (masked) - const phone = eventRecord.meta?.phone || null; + // === SECURITY HARDENING: ACTIVE ALERTING === + // Extract and mask phone number from meta if present + let maskedPhone = null; + if (eventRecord.meta?.phone) { + // Phone is already masked in meta, use as-is + maskedPhone = eventRecord.meta.phone; + } else if (eventRecord.userId) { + // Try to get phone from user record (would need DB query, but for now use meta) + maskedPhone = eventRecord.meta?.phone || null; + } + // Format timestamp + const timestamp = eventRecord.created_at + ? new Date(eventRecord.created_at).toISOString() + : new Date().toISOString(); + + // Determine color based on risk level + let color = '#36a64f'; // green (INFO) + if (eventRecord.riskLevel === RISK_LEVELS.HIGH_RISK) { + color = '#ff0000'; // red + } else if (eventRecord.riskLevel === RISK_LEVELS.SUSPICIOUS) { + color = '#ffa500'; // orange + } + + // === SECURITY HARDENING: ACTIVE ALERTING === + // Build Slack-friendly JSON payload structure const payload = { - // Slack-compatible fields - text: `🚨 Security Alert: ${eventRecord.riskLevel}`, + text: '*SECURITY ALERT* 🚨', attachments: [ { - color: eventRecord.riskLevel === RISK_LEVELS.HIGH_RISK ? 'danger' : - eventRecord.riskLevel === RISK_LEVELS.SUSPICIOUS ? 'warning' : 'good', + color: color, fields: [ { - title: 'Event Type', - value: eventRecord.action, - short: true, - }, - { - title: 'Status', - value: eventRecord.status, + title: 'Event', + value: eventRecord.action || 'unknown', short: true, }, { title: 'Risk Level', - value: `${eventRecord.riskLevel} (${severity})`, - short: true, - }, - { - title: 'Timestamp', - value: new Date(eventRecord.created_at).toISOString(), + value: eventRecord.riskLevel || 'INFO', short: true, }, ...(eventRecord.userId ? [{ - title: 'User ID', + title: 'User', value: eventRecord.userId, short: true, }] : []), - ...(phone ? [{ + ...(maskedPhone ? [{ title: 'Phone', - value: phone, + value: maskedPhone, short: true, }] : []), ...(eventRecord.ipAddress ? [{ - title: 'IP Address', + title: 'IP', value: eventRecord.ipAddress, short: true, }] : []), - ...(eventRecord.deviceId ? [{ - title: 'Device ID', - value: eventRecord.deviceId, + ...(eventRecord.userAgent ? [{ + title: 'User Agent', + value: eventRecord.userAgent.length > 50 + ? eventRecord.userAgent.substring(0, 50) + '...' + : eventRecord.userAgent, + short: false, + }] : []), + { + title: 'Status', + value: eventRecord.status || 'unknown', short: true, + }, + { + title: 'When', + value: timestamp, + short: false, + }, + ...(anomalyAlert?.details ? [{ + title: 'Anomaly', + value: JSON.stringify(anomalyAlert.details), + short: false, }] : []), ], footer: 'Farm Auth Service', - ts: Math.floor(new Date(eventRecord.created_at).getTime() / 1000), + ts: Math.floor(new Date(eventRecord.created_at || Date.now()).getTime() / 1000), }, ], // Additional metadata for custom webhooks - metadata: { - event_id: eventRecord.id, - risk_level: eventRecord.riskLevel, - severity, - event_type: eventRecord.action, - status: eventRecord.status, - user_id: eventRecord.userId, - phone: phone, - ip_address: eventRecord.ipAddress, - device_id: eventRecord.deviceId, - user_agent: eventRecord.userAgent, - created_at: eventRecord.created_at, - summary, - ...(anomalyAlert?.details ? { anomaly: anomalyAlert.details } : {}), - }, + event_type: eventRecord.action, + risk_level: eventRecord.riskLevel, + user_id: eventRecord.userId || null, + phone_number: maskedPhone, + ip_address: eventRecord.ipAddress || null, + user_agent: eventRecord.userAgent || null, + timestamp: timestamp, + device_id: eventRecord.deviceId || null, + status: eventRecord.status, + ...(anomalyAlert?.details ? { anomaly: anomalyAlert.details } : {}), }; + // === SECURITY HARDENING: ACTIVE ALERTING === // Parse webhook URL const webhookUrl = new URL(SECURITY_ALERT_WEBHOOK_URL); const isHttps = webhookUrl.protocol === 'https:'; @@ -430,7 +459,8 @@ async function triggerSecurityAlert(eventRecord, anomalyAlert = null) { timeout: 5000, // 5 second timeout }; - // Send webhook request + // === SECURITY HARDENING: ACTIVE ALERTING === + // Send webhook request with retry logic await new Promise((resolve, reject) => { const req = httpModule.request(options, (res) => { let data = ''; @@ -459,8 +489,25 @@ async function triggerSecurityAlert(eventRecord, anomalyAlert = null) { console.log(`[auditLogger] Security alert sent for ${eventRecord.riskLevel} event: ${eventRecord.action}`); } catch (err) { - // Log error but don't throw - alerting failures should not break the main flow - console.error('[auditLogger] Failed to send security alert:', err.message); + // === SECURITY HARDENING: ACTIVE ALERTING === + // Global FAILSAFE: Retry once after 3 seconds if webhook fails + if (retryCount === 0) { + console.warn(`[auditLogger] Webhook failed, retrying in 3 seconds... (${err.message})`); + + // Wait 3 seconds then retry once + setTimeout(async () => { + try { + await sendSecurityAlert(eventRecord, anomalyAlert, 1); + } catch (retryErr) { + console.error('[auditLogger] Webhook retry also failed:', retryErr.message); + } + }, 3000); + + return; // Don't log error yet, wait for retry + } + + // If retry also failed, log error but don't throw - alerting failures should not break the main flow + console.error('[auditLogger] Failed to send security alert (after retry):', err.message); // Optionally log full error in development if (process.env.NODE_ENV === 'development') { console.error('[auditLogger] Alert error details:', err); @@ -468,6 +515,9 @@ async function triggerSecurityAlert(eventRecord, anomalyAlert = null) { } } +// Alias for backward compatibility +const triggerSecurityAlert = sendSecurityAlert; + module.exports = { logAuthEvent, logSuspiciousOtpAttempt, @@ -475,6 +525,8 @@ module.exports = { logSuspiciousRefresh, logStepUpAuthRequired, checkAnomalies, + sendSecurityAlert, + shouldAlert, RISK_LEVELS, }; diff --git a/src/services/jwtKeys.js b/src/services/jwtKeys.js index 95c97b7..1b06700 100644 --- a/src/services/jwtKeys.js +++ b/src/services/jwtKeys.js @@ -185,3 +185,5 @@ module.exports = { + + diff --git a/src/services/otpService.js b/src/services/otpService.js index bafda15..fddef2b 100644 --- a/src/services/otpService.js +++ b/src/services/otpService.js @@ -13,6 +13,20 @@ const MAX_OTP_ATTEMPTS = Number(process.env.OTP_VERIFY_MAX_ATTEMPTS || process.e let otpTableReadyPromise; +// === SECURITY HARDENING: TIMING ATTACK PROTECTION === +// Pre-computed dummy hash for constant-time comparison when OTP not found +// This ensures bcrypt.compare() always executes with similar timing +// Generated once at module load to avoid performance impact +let dummyOtpHash = null; +async function getDummyOtpHash() { + if (!dummyOtpHash) { + // Generate a dummy hash that will never match any real OTP + const dummyCode = 'DUMMY_OTP_' + Math.random().toString(36).substring(2, 15) + Date.now(); + dummyOtpHash = await bcrypt.hash(dummyCode, 10); + } + return dummyOtpHash; +} + function ensureOtpCodesTable() { if (!otpTableReadyPromise) { otpTableReadyPromise = db.query(` @@ -107,6 +121,12 @@ async function createOtp(phoneNumber) { * @param {string} phoneNumber - The phone number * @param {string} code - The OTP code to verify * @returns {Promise<{ok: boolean}>} - Whether the OTP is valid + * + * === SECURITY HARDENING: TIMING ATTACK PROTECTION === + * This function uses constant-time execution to prevent timing-based attacks: + * - Always performs bcrypt.compare() even for expired/max attempts cases + * - Uses dummy hash for "not found" case to maintain constant time + * - All code paths take similar execution time regardless of outcome */ async function verifyOtp(phoneNumber, code) { await ensureOtpCodesTable(); @@ -124,30 +144,63 @@ async function verifyOtp(phoneNumber, code) { [encryptedPhone, phoneNumber] // Try both encrypted and plaintext for backward compatibility ); - // === ADDED FOR OTP ATTEMPT LIMIT === - // Generic error message to avoid leaking information - if (result.rows.length === 0) { + // === SECURITY HARDENING: TIMING ATTACK PROTECTION === + // Always perform bcrypt.compare() to maintain constant execution time + // Use a dummy hash if OTP not found to prevent timing leaks + let otpRecord = result.rows[0]; + let isNotFound = false; + let isExpired = false; + let isMaxAttempts = false; + let hashToCompare = null; + + if (!otpRecord) { + // OTP not found - use dummy hash to maintain constant time + // This ensures bcrypt.compare() always executes with similar timing + isNotFound = true; + hashToCompare = await getDummyOtpHash(); + otpRecord = { + id: null, + otp_hash: hashToCompare, // Dummy hash for constant-time comparison + expires_at: new Date(Date.now() + 1000), // Future date + attempt_count: 0, + }; + } else { + hashToCompare = otpRecord.otp_hash; + // Check if expired (but don't return early - continue to bcrypt.compare) + isExpired = new Date() > new Date(otpRecord.expires_at); + + // Check if max attempts exceeded (but don't return early - continue to bcrypt.compare) + isMaxAttempts = otpRecord.attempt_count >= MAX_OTP_ATTEMPTS; + } + + // === SECURITY HARDENING: TIMING ATTACK PROTECTION === + // Always perform bcrypt.compare() regardless of expiration/attempts status + // This ensures all code paths take similar time + // Even if we know the OTP is expired or max attempts exceeded, we still compare + // to prevent attackers from distinguishing between different failure modes + const matches = await bcrypt.compare(code, hashToCompare); + + // === SECURITY HARDENING: TIMING ATTACK PROTECTION === + // Determine the actual result after constant-time comparison + // Priority: not_found > expired > max_attempts > invalid > valid + if (isNotFound) { + // OTP not found - return generic error return { ok: false, reason: 'not_found' }; } - const otpRecord = result.rows[0]; - - // Check if expired - if (new Date() > new Date(otpRecord.expires_at)) { + if (isExpired) { + // OTP expired - delete and return (but we already did bcrypt.compare for constant time) await db.query('DELETE FROM otp_codes WHERE id = $1', [otpRecord.id]); return { ok: false, reason: 'expired' }; } - // === ADDED FOR OTP ATTEMPT LIMIT === - // Check if max attempts exceeded - if (otpRecord.attempt_count >= MAX_OTP_ATTEMPTS) { + if (isMaxAttempts) { + // Max attempts exceeded - delete and return (but we already did bcrypt.compare for constant time) await db.query('DELETE FROM otp_codes WHERE id = $1', [otpRecord.id]); return { ok: false, reason: 'max_attempts' }; } - const matches = await bcrypt.compare(code, otpRecord.otp_hash); - - // Check if code matches + // Check if code matches (only if not expired and not max attempts) if (!matches) { // === ADDED FOR OTP ATTEMPT LIMIT === // Increment attempt count diff --git a/src/services/riskScoring.js b/src/services/riskScoring.js index b87d68c..8190703 100644 --- a/src/services/riskScoring.js +++ b/src/services/riskScoring.js @@ -263,3 +263,5 @@ module.exports = { + + diff --git a/src/services/tokenService.js b/src/services/tokenService.js index 56227d9..1f1eefb 100644 --- a/src/services/tokenService.js +++ b/src/services/tokenService.js @@ -40,6 +40,9 @@ function signAccessToken(user, options = {}) { iat: Math.floor(Date.now() / 1000), // High assurance flag (set after OTP verification for sensitive actions) high_assurance: options.highAssurance || false, + // === SECURITY HARDENING: GLOBAL LOGOUT === + // Token version for immediate invalidation on logout-all-devices + token_version: user.token_version || 1, }; return jwt.sign( @@ -303,10 +306,61 @@ async function handleReuse(tokenRow) { await revokeDeviceTokens(tokenRow.user_id, tokenRow.device_id); } +/** + * === SECURITY HARDENING: GLOBAL LOGOUT === + * Revoke all refresh tokens and invalidate all access tokens for a user + * by incrementing their token_version. This is used for "logout from all devices". + * + * @param {string} userId - User ID + * @returns {Promise} Object with revoked tokens count and new token version + */ +async function revokeAllUserTokens(userId) { + // Revoke all refresh tokens for the user + const revokeResult = await db.query( + ` + UPDATE refresh_tokens + SET revoked_at = NOW() + WHERE user_id = $1 AND revoked_at IS NULL + RETURNING id + `, + [userId] + ); + const revokedTokensCount = revokeResult.rows.length; + + // Mark all devices as inactive + await db.query( + ` + UPDATE user_devices + SET is_active = false + WHERE user_id = $1 AND is_active = true + `, + [userId] + ); + + // Increment token_version to invalidate all existing access tokens + const versionResult = await db.query( + ` + UPDATE users + SET token_version = COALESCE(token_version, 1) + 1 + WHERE id = $1 + RETURNING token_version + `, + [userId] + ); + + const newTokenVersion = versionResult.rows[0]?.token_version || 1; + + return { + revokedTokensCount, + newTokenVersion, + }; +} + module.exports = { signAccessToken, verifyRefreshToken, issueRefreshToken, rotateRefreshToken, revokeRefreshToken, + revokeAllUserTokens, }; diff --git a/src/utils/corsValidator.js b/src/utils/corsValidator.js new file mode 100644 index 0000000..6e955c5 --- /dev/null +++ b/src/utils/corsValidator.js @@ -0,0 +1,110 @@ +// src/utils/corsValidator.js +// === SECURITY HARDENING: CORS VALIDATION === +// Validates CORS configuration at startup and runtime + +const config = require('../config'); + +/** + * Validates CORS configuration at startup + * Throws error if misconfigured in production + */ +function validateCorsConfig() { + if (!config.isProduction) { + // Development mode: warn but don't fail + if (config.corsAllowedOrigins.length === 0) { + console.warn('⚠️ CORS WARNING: No CORS origins configured in development mode'); + console.warn(' This allows all origins. Set CORS_ALLOWED_ORIGINS for stricter control.'); + } + return; + } + + // Production mode: strict validation + if (config.corsAllowedOrigins.length === 0) { + throw new Error( + 'SECURITY ERROR: CORS_ALLOWED_ORIGINS must be configured in production. ' + + 'Set CORS_ALLOWED_ORIGINS environment variable with comma-separated allowed origins.' + ); + } + + // Validate origin format + const invalidOrigins = config.corsAllowedOrigins.filter((origin) => { + try { + const url = new URL(origin); + // Must be https in production (unless explicitly allowing http for internal services) + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + return true; + } + // Should not contain wildcards + if (origin.includes('*')) { + return true; + } + return false; + } catch (e) { + return true; // Invalid URL + } + }); + + if (invalidOrigins.length > 0) { + console.warn('⚠️ CORS WARNING: Some origins have invalid format:', invalidOrigins); + console.warn(' Origins should be full URLs (e.g., https://app.example.com)'); + } + + // Check for wildcard usage + if (config.corsAllowedOrigins.includes('*')) { + throw new Error( + 'SECURITY ERROR: Wildcard (*) CORS origin is not allowed in production. ' + + 'Use explicit origin URLs instead.' + ); + } + + console.log(`✅ CORS validation passed: ${config.corsAllowedOrigins.length} origin(s) configured`); + config.corsAllowedOrigins.forEach((origin) => { + console.log(` - ${origin}`); + }); +} + +/** + * Runtime check for CORS misconfiguration + * Logs warnings if suspicious patterns detected + */ +function checkCorsAtRuntime(origin) { + if (!origin) { + return; // No origin (mobile app, Postman, etc.) - this is fine + } + + // Check if origin is in whitelist + if (!config.corsAllowedOrigins.includes(origin)) { + // This is expected - CORS middleware will block it + // But we log it for monitoring + return { + blocked: true, + reason: 'Origin not in whitelist', + }; + } + + // Check for suspicious patterns + const suspiciousPatterns = [ + /^http:\/\//, // HTTP in production (should be HTTPS) + /localhost/i, + /127\.0\.0\.1/, + /0\.0\.0\.0/, + ]; + + if (config.isProduction) { + for (const pattern of suspiciousPatterns) { + if (pattern.test(origin)) { + return { + suspicious: true, + reason: `Suspicious origin pattern detected: ${origin}`, + }; + } + } + } + + return { valid: true }; +} + +module.exports = { + validateCorsConfig, + checkCorsAtRuntime, +}; diff --git a/src/utils/enumerationDetection.js b/src/utils/enumerationDetection.js new file mode 100644 index 0000000..0f2ad16 --- /dev/null +++ b/src/utils/enumerationDetection.js @@ -0,0 +1,262 @@ +// src/utils/enumerationDetection.js +// === SECURITY HARDENING: ENUMERATION DETECTION === +// Detects and monitors phone number enumeration attempts +// +// Purpose: +// - Detect patterns that indicate enumeration attacks +// - Monitor for suspicious behavior (many unique phone numbers from same IP) +// - Log and alert on enumeration attempts + +const { getRedisClient, isRedisReady } = require('../services/redisClient'); +const { logAuthEvent, RISK_LEVELS } = require('../services/auditLogger'); + +// Re-export RISK_LEVELS for convenience +const ENUM_RISK_LEVELS = RISK_LEVELS; + +// In-memory fallback store (used when Redis is not available) +const memoryStore = {}; + +// Clean up expired entries from memory store periodically +setInterval(() => { + const now = Date.now(); + Object.keys(memoryStore).forEach((key) => { + if (memoryStore[key].expiresAt && memoryStore[key].expiresAt < now) { + delete memoryStore[key]; + } + }); +}, 60000); // Clean up every minute + +// Configuration from environment variables +const ENUMERATION_CONFIG = { + // Maximum unique phone numbers per IP in a time window + MAX_UNIQUE_PHONES_PER_IP_10MIN: parseInt(process.env.ENUMERATION_MAX_PHONES_PER_IP_10MIN || '5', 10), + MAX_UNIQUE_PHONES_PER_IP_HOUR: parseInt(process.env.ENUMERATION_MAX_PHONES_PER_IP_HOUR || '20', 10), + + // Time windows (in seconds) + WINDOW_10MIN: 600, // 10 minutes + WINDOW_HOUR: 3600, // 1 hour + + // Alert threshold - number of unique phones that triggers alert + ALERT_THRESHOLD_10MIN: parseInt(process.env.ENUMERATION_ALERT_THRESHOLD_10MIN || '10', 10), + ALERT_THRESHOLD_HOUR: parseInt(process.env.ENUMERATION_ALERT_THRESHOLD_HOUR || '50', 10), +}; + +/** + * Helper: Get counter value from Redis or memory store + */ +async function getCounter(key) { + const redis = await getRedisClient(); + + if (isRedisReady() && redis) { + try { + const count = await redis.get(key); + return count ? parseInt(count, 10) : 0; + } catch (err) { + console.error('Redis get error, falling back to memory:', err); + // Fall through to memory store + } + } + + // Memory store fallback + if (memoryStore[key] && memoryStore[key].expiresAt > Date.now()) { + return memoryStore[key].count || 0; + } + + return 0; +} + +/** + * Helper: Increment counter in Redis or memory store + */ +async function incrementCounter(key, ttlSeconds) { + const redis = await getRedisClient(); + + if (isRedisReady() && redis) { + try { + const count = await redis.incr(key); + if (count === 1) { + // First increment, set TTL + await redis.expire(key, ttlSeconds); + } + return count; + } catch (err) { + console.error('Redis increment error, falling back to memory:', err); + // Fall through to memory store + } + } + + // Memory store fallback + const now = Date.now(); + if (!memoryStore[key]) { + memoryStore[key] = { + count: 0, + expiresAt: now + ttlSeconds * 1000, + }; + } + + memoryStore[key].count++; + return memoryStore[key].count; +} + +/** + * Helper: Add phone number to set and get count + */ +async function addToSetAndGetCount(key, value, ttlSeconds) { + const redis = await getRedisClient(); + + if (isRedisReady() && redis) { + try { + // Use Redis set to track unique phone numbers + await redis.sadd(key, value); + await redis.expire(key, ttlSeconds); + const count = await redis.scard(key); + return count; + } catch (err) { + console.error('Redis set operation error, falling back to memory:', err); + // Fall through to memory store + } + } + + // Memory store fallback - use Set to track unique values + const now = Date.now(); + if (!memoryStore[key]) { + memoryStore[key] = { + set: new Set(), + expiresAt: now + ttlSeconds * 1000, + }; + } + + memoryStore[key].set.add(value); + return memoryStore[key].set.size; +} + +/** + * Check if an IP address is attempting phone number enumeration + * + * @param {string} ipAddress - IP address to check + * @param {string} phoneNumber - Phone number being requested + * @returns {Promise<{isEnumeration: boolean, riskLevel: string, details: Object}>} + */ +async function checkEnumerationAttempt(ipAddress, phoneNumber) { + try { + // Track unique phone numbers per IP + const key10min = `enumeration:ip:${ipAddress}:phones:10min`; + const keyHour = `enumeration:ip:${ipAddress}:phones:hour`; + + // Add phone to sets and get counts + const count10min = await addToSetAndGetCount(key10min, phoneNumber, ENUMERATION_CONFIG.WINDOW_10MIN); + const countHour = await addToSetAndGetCount(keyHour, phoneNumber, ENUMERATION_CONFIG.WINDOW_HOUR); + + // Check thresholds + const exceeds10minLimit = count10min > ENUMERATION_CONFIG.MAX_UNIQUE_PHONES_PER_IP_10MIN; + const exceedsHourLimit = countHour > ENUMERATION_CONFIG.MAX_UNIQUE_PHONES_PER_IP_HOUR; + + // Determine risk level + let isEnumeration = false; + let riskLevel = RISK_LEVELS.INFO; + let details = { + unique_phones_10min: count10min, + unique_phones_hour: countHour, + ip_address: ipAddress, + }; + + if (exceeds10minLimit || exceedsHourLimit) { + isEnumeration = true; + + // Determine severity + if (count10min >= ENUMERATION_CONFIG.ALERT_THRESHOLD_10MIN || + countHour >= ENUMERATION_CONFIG.ALERT_THRESHOLD_HOUR) { + riskLevel = RISK_LEVELS.HIGH_RISK; + details.severity = 'high'; + details.message = 'Potential enumeration attack detected - high volume of unique phone numbers'; + } else { + riskLevel = RISK_LEVELS.SUSPICIOUS; + details.severity = 'medium'; + details.message = 'Potential enumeration attempt - unusual number of unique phone numbers'; + } + } + + return { + isEnumeration, + riskLevel, + details, + }; + } catch (err) { + console.error('Error checking enumeration attempt:', err); + // On error, return safe default (no enumeration detected) + return { + isEnumeration: false, + riskLevel: RISK_LEVELS.INFO, + details: { error: 'check_failed' }, + }; + } +} + +/** + * Log enumeration attempt for monitoring and alerting + * + * @param {string} ipAddress - IP address + * @param {string} phoneNumber - Phone number (masked) + * @param {string} riskLevel - Risk level + * @param {Object} details - Additional details + * @param {string} userAgent - User agent string + */ +async function logEnumerationAttempt(ipAddress, phoneNumber, riskLevel, details, userAgent) { + try { + await logAuthEvent({ + userId: null, + action: 'enumeration_attempt', + status: 'detected', + riskLevel, + ipAddress, + userAgent, + meta: { + phone: phoneNumber.replace(/\d(?=\d{4})/g, '*'), // Mask phone + ...details, + }, + }); + } catch (err) { + console.error('Error logging enumeration attempt:', err); + // Don't throw - logging failures shouldn't break the flow + } +} + +/** + * Check and log enumeration attempt for OTP request + * This should be called before processing OTP requests + * + * @param {string} ipAddress - IP address + * @param {string} phoneNumber - Phone number + * @param {string} userAgent - User agent string + * @returns {Promise<{shouldBlock: boolean, riskLevel: string}>} + */ +async function detectAndLogEnumeration(ipAddress, phoneNumber, userAgent) { + const detection = await checkEnumerationAttempt(ipAddress, phoneNumber); + + // Log if enumeration detected + if (detection.isEnumeration) { + await logEnumerationAttempt( + ipAddress, + phoneNumber, + detection.riskLevel, + detection.details, + userAgent + ); + } + + // Block if high risk + const shouldBlock = detection.isEnumeration && detection.riskLevel === RISK_LEVELS.HIGH_RISK; + + return { + shouldBlock, + riskLevel: detection.riskLevel, + details: detection.details, + }; +} + +module.exports = { + checkEnumerationAttempt, + logEnumerationAttempt, + detectAndLogEnumeration, + ENUMERATION_CONFIG, +}; diff --git a/src/utils/otpLogger.js b/src/utils/otpLogger.js index 35bd875..473bd26 100644 --- a/src/utils/otpLogger.js +++ b/src/utils/otpLogger.js @@ -62,3 +62,5 @@ module.exports = { + + diff --git a/src/utils/timingProtection.js b/src/utils/timingProtection.js new file mode 100644 index 0000000..a82243e --- /dev/null +++ b/src/utils/timingProtection.js @@ -0,0 +1,146 @@ +// src/utils/timingProtection.js +// === SECURITY HARDENING: TIMING ATTACK PROTECTION === +// Constant-time delay utilities to prevent timing-based enumeration attacks +// +// Purpose: +// - Prevent phone number enumeration via response time analysis +// - Ensure all code paths take similar time regardless of outcome +// - Add artificial delays to normalize response times + +/** + * Sleep for a specified number of milliseconds + * @param {number} ms - Milliseconds to sleep + * @returns {Promise} + */ +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +/** + * Configuration for timing protection + * Can be adjusted via environment variables + */ +const TIMING_CONFIG = { + // Base delay for OTP requests (ms) + // This ensures all requests take at least this long + OTP_REQUEST_MIN_DELAY: parseInt(process.env.OTP_REQUEST_MIN_DELAY || '500', 10), + + // Base delay for OTP verification (ms) + // This ensures all verification attempts take at least this long + OTP_VERIFY_MIN_DELAY: parseInt(process.env.OTP_VERIFY_MIN_DELAY || '300', 10), + + // Maximum random jitter to add (ms) + // Adds randomness to prevent pattern detection + MAX_JITTER: parseInt(process.env.TIMING_MAX_JITTER || '100', 10), +}; + +/** + * Add a constant-time delay with optional jitter + * This ensures all code paths take similar time regardless of outcome + * + * @param {number} baseDelayMs - Base delay in milliseconds + * @param {boolean} addJitter - Whether to add random jitter (default: true) + * @returns {Promise} + */ +async function constantTimeDelay(baseDelayMs, addJitter = true) { + let delay = baseDelayMs; + + if (addJitter && TIMING_CONFIG.MAX_JITTER > 0) { + // Add random jitter to prevent pattern detection + const jitter = Math.floor(Math.random() * TIMING_CONFIG.MAX_JITTER); + delay += jitter; + } + + await sleep(delay); +} + +/** + * Measure execution time and ensure minimum delay + * This function ensures that the total execution time (including the work) + * meets the minimum delay requirement + * + * @param {Function} workFn - Async function to execute + * @param {number} minTotalDelayMs - Minimum total delay in milliseconds + * @param {boolean} addJitter - Whether to add random jitter (default: true) + * @returns {Promise<*>} Result of workFn + */ +async function executeWithTimingProtection(workFn, minTotalDelayMs, addJitter = true) { + const startTime = Date.now(); + + try { + // Execute the work + const result = await workFn(); + + // Calculate elapsed time + const elapsed = Date.now() - startTime; + const remainingDelay = Math.max(0, minTotalDelayMs - elapsed); + + // Add remaining delay to meet minimum time requirement + if (remainingDelay > 0) { + await constantTimeDelay(remainingDelay, addJitter); + } + + return result; + } catch (error) { + // Even on error, ensure minimum delay + const elapsed = Date.now() - startTime; + const remainingDelay = Math.max(0, minTotalDelayMs - elapsed); + + if (remainingDelay > 0) { + await constantTimeDelay(remainingDelay, addJitter); + } + + throw error; + } +} + +/** + * Constant-time delay for OTP requests + * Ensures all OTP requests take similar time regardless of whether phone exists + * + * @returns {Promise} + */ +async function otpRequestDelay() { + await constantTimeDelay(TIMING_CONFIG.OTP_REQUEST_MIN_DELAY, true); +} + +/** + * Constant-time delay for OTP verification + * Ensures all OTP verifications take similar time regardless of outcome + * + * @returns {Promise} + */ +async function otpVerifyDelay() { + await constantTimeDelay(TIMING_CONFIG.OTP_VERIFY_MIN_DELAY, true); +} + +/** + * Execute OTP request work with timing protection + * + * @param {Function} workFn - Async function to execute + * @returns {Promise<*>} Result of workFn + */ +async function executeOtpRequestWithTiming(workFn) { + return executeWithTimingProtection(workFn, TIMING_CONFIG.OTP_REQUEST_MIN_DELAY, true); +} + +/** + * Execute OTP verification work with timing protection + * + * @param {Function} workFn - Async function to execute + * @returns {Promise<*>} Result of workFn + */ +async function executeOtpVerifyWithTiming(workFn) { + return executeWithTimingProtection(workFn, TIMING_CONFIG.OTP_VERIFY_MIN_DELAY, true); +} + +module.exports = { + sleep, + constantTimeDelay, + executeWithTimingProtection, + otpRequestDelay, + otpVerifyDelay, + executeOtpRequestWithTiming, + executeOtpVerifyWithTiming, + TIMING_CONFIG, +};