Fixed Security issues 8 rate limiting etc

This commit is contained in:
Chandresh Kerkar 2025-12-02 01:45:06 +05:30
parent 32ff5064e5
commit 34f95f80a6
27 changed files with 3362 additions and 153 deletions

2
.gitignore vendored
View File

@ -38,3 +38,5 @@ build/
*.temp

79
CSRF_NOTES.md Normal file
View File

@ -0,0 +1,79 @@
# CSRF Protection Notes
## Current Implementation
Currently, this authentication service uses **Bearer tokens** in the `Authorization` header. This approach is **CSRF-safe** because:
1. **Same-Origin Policy**: Browsers enforce same-origin policy for JavaScript requests
2. **Custom Headers**: Bearer tokens in custom headers cannot be set by malicious sites
3. **No Cookies**: We don't store tokens in cookies, so there's no automatic cookie sending
## Future Considerations
### If Moving to HTTP-Only Cookies
If you decide to move tokens to HTTP-only cookies in the future (for XSS protection), **CSRF protection becomes mandatory**. Here's what you should implement:
### Recommended CSRF Protection Strategy
1. **SameSite Cookie Attribute**
```javascript
// Set cookies with SameSite=Strict or SameSite=Lax
res.cookie('access_token', token, {
httpOnly: true,
secure: true, // HTTPS only
sameSite: 'strict', // or 'lax'
maxAge: 15 * 60 * 1000 // 15 minutes
});
```
2. **CSRF Token Validation**
- Issue a CSRF token on login
- Store CSRF token in a separate cookie (not httpOnly)
- Require CSRF token in a custom header (e.g., `X-CSRF-Token`) for state-changing requests
- Validate CSRF token on each request
3. **Double Submit Cookie Pattern**
- Store CSRF token in both:
- Cookie (httpOnly: false, so JavaScript can read it)
- Request header (sent by JavaScript)
- Validate that both values match
### Implementation Example (if needed)
```javascript
// Middleware to validate CSRF token
function csrfProtection(req, res, next) {
// Skip for GET, HEAD, OPTIONS (safe methods)
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
return next();
}
const csrfToken = req.headers['x-csrf-token'];
const cookieToken = req.cookies.csrf_token;
if (!csrfToken || !cookieToken || csrfToken !== cookieToken) {
return res.status(403).json({ error: 'Invalid CSRF token' });
}
next();
}
```
### Additional Recommendations
1. **Origin Header Validation**: Validate the `Origin` header matches your allowed origins
2. **Referer Header Check**: As a fallback, check `Referer` header (though it can be spoofed)
3. **State Parameter**: For OAuth flows, use state parameters to prevent CSRF
## Current Status
**No CSRF protection needed** - Using Bearer tokens in headers is CSRF-safe
⚠️ **If you move to cookies** - Implement CSRF protection immediately
## References
- [OWASP CSRF Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html)
- [MDN: SameSite Cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite)

View File

@ -419,3 +419,5 @@ Please implement:
This implementation should provide a secure, production-ready authentication system with persistent login capability. The user should be able to login once and remain logged in (until tokens expire or logout) with seamless token refresh happening automatically in the background.

View File

@ -79,3 +79,5 @@ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0")
---

View File

@ -640,3 +640,5 @@ Content-Type: application/json
This guide provides everything you need to integrate the `/users/me` endpoint in your Kotlin application!

View File

@ -0,0 +1,242 @@
# Rate Limiting & OTP Throttling Implementation
## Overview
This document explains the secure rate limiting and OTP throttling implementation added to the authentication service.
## Architecture
### Components Added
1. **Redis Client** (`src/services/redisClient.js`)
- Manages Redis connection with graceful fallback to in-memory storage
- Supports `REDIS_URL` or `REDIS_HOST`/`REDIS_PORT` configuration
2. **Rate Limiting Middleware** (`src/middleware/rateLimitMiddleware.js`)
- Per-phone rate limiting for OTP requests
- Per-IP rate limiting for OTP requests
- Active OTP checking (2-minute no-resend rule)
- Failed verification attempt tracking
3. **Updated OTP Service** (`src/services/otpService.js`)
- Changed OTP expiry from 10 minutes to 2 minutes (120 seconds)
- Enhanced attempt tracking with generic error responses
4. **Updated Auth Routes** (`src/routes/authRoutes.js`)
- Integrated all rate limiting middleware
- Updated error responses to be generic
## Rate Limiting Rules
### POST /auth/request-otp
**Per Phone Number:**
- Max 3 requests per 10 minutes
- Max 10 requests per 24 hours
- **No resend if active OTP exists** (within 2 minutes of generation)
**Per IP Address:**
- Max 20 requests per 10 minutes
- Max 100 requests per 24 hours
**Response on Limit Exceeded:**
```json
{
"success": false,
"message": "Too many OTP requests. Please try again later."
}
```
HTTP Status: `429 Too Many Requests`
**Response if Active OTP Exists:**
```json
{
"success": false,
"message": "An OTP is already active. Please wait a moment before requesting a new one."
}
```
HTTP Status: `429 Too Many Requests`
### POST /auth/verify-otp
**Per OTP:**
- Max 5 verification attempts per OTP
- After 5 failed attempts, OTP is invalidated
**Per Phone Number:**
- Max 10 failed verifications per hour
- If exceeded, all verification attempts are blocked temporarily
**Response on Failure:**
```json
{
"success": false,
"message": "OTP invalid or expired. Please request a new one."
}
```
HTTP Status: `400 Bad Request`
**Response on Too Many Failed Attempts:**
```json
{
"success": false,
"message": "Too many attempts. Please try again later."
}
```
HTTP Status: `429 Too Many Requests`
## OTP Validity
- **OTP expires after 2 minutes (120 seconds)** from generation
- **No new OTP can be sent** to the same phone number while an active OTP exists
- Once verified successfully, OTP is immediately deleted
## Redis Setup
### Configuration
The system uses Redis for rate limiting counters with automatic TTL expiration. If Redis is not available, it falls back to in-memory storage.
**Environment Variables:**
```bash
# Option 1: Full Redis URL
REDIS_URL=redis://localhost:6379
# or with password
REDIS_URL=redis://:password@localhost:6379
# Option 2: Separate host/port
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=your_password # Optional
```
### Redis Keys Used
- `otp_req:phone:{phone}:10min` - Phone requests (10 min window)
- `otp_req:phone:{phone}:day` - Phone requests (24 hour window)
- `otp_req:ip:{ip}:10min` - IP requests (10 min window)
- `otp_req:ip:{ip}:day` - IP requests (24 hour window)
- `otp_active:phone:{phone}` - Active OTP marker (2 min TTL)
- `otp_verify_failed:phone:{phone}:hour` - Failed verifications (1 hour window)
All keys automatically expire based on their TTL.
## Configuration
All limits are configurable via environment variables:
```bash
# OTP Request Limits
OTP_REQ_PHONE_10MIN_LIMIT=3 # Default: 3
OTP_REQ_PHONE_DAY_LIMIT=10 # Default: 10
OTP_REQ_IP_10MIN_LIMIT=20 # Default: 20
OTP_REQ_IP_DAY_LIMIT=100 # Default: 100
# OTP Verification Limits
OTP_VERIFY_MAX_ATTEMPTS=5 # Default: 5
OTP_VERIFY_FAILED_PER_HOUR_LIMIT=10 # Default: 10
# OTP Validity
OTP_TTL_SECONDS=120 # Default: 120 (2 minutes)
# Proxy Support (for correct IP detection)
TRUST_PROXY=true # Set if behind reverse proxy
```
## Implementation Details
### Redis Client Initialization
The Redis client is initialized in `src/index.js`:
```javascript
const { initRedis } = require('./services/redisClient');
initRedis().catch((err) => {
console.warn('Redis initialization warning:', err.message);
});
```
If Redis is unavailable, the system logs a warning and continues with in-memory fallback.
### Rate Limiting Logic
1. **Request Flow for `/auth/request-otp`:**
```
Request → checkActiveOtpForPhone → rateLimitRequestOtpByPhone → rateLimitRequestOtpByIp → createOtp
```
2. **Request Flow for `/auth/verify-otp`:**
```
Request → rateLimitVerifyOtpByPhone → verifyOtp → (on failure) incrementFailedVerify
```
### Memory Store Fallback
When Redis is not available, the system uses an in-memory store that:
- Maintains counters with expiration timestamps
- Automatically cleans up expired entries every minute
- Works identically to Redis for rate limiting purposes
- **Note:** In-memory store is per-process and doesn't persist across restarts
### Error Handling
All rate limiting middleware uses a "fail open" strategy:
- If Redis errors occur, the request is allowed to proceed
- Errors are logged but don't block legitimate users
- This ensures availability even if rate limiting infrastructure fails
## Security Features
1. **Generic Error Messages:** All error responses use generic messages to avoid information leakage
2. **No Information Leakage:** Errors don't distinguish between "wrong OTP", "expired OTP", or "max attempts"
3. **Automatic Cleanup:** Expired OTPs and rate limit counters are automatically cleaned up
4. **IP-based Protection:** Prevents abuse from single IP addresses
5. **Phone-based Protection:** Prevents abuse targeting specific phone numbers
## Testing
To test the rate limiting:
1. **Test Active OTP Rule:**
```bash
# Request OTP
curl -X POST http://localhost:3000/auth/request-otp \
-H "Content-Type: application/json" \
-d '{"phone_number": "+1234567890"}'
# Immediately try again (should be blocked)
curl -X POST http://localhost:3000/auth/request-otp \
-H "Content-Type: application/json" \
-d '{"phone_number": "+1234567890"}'
```
2. **Test Rate Limits:**
```bash
# Send multiple requests quickly (should hit limit after 3)
for i in {1..5}; do
curl -X POST http://localhost:3000/auth/request-otp \
-H "Content-Type: application/json" \
-d '{"phone_number": "+1234567890"}'
sleep 1
done
```
## Dependencies
Added to `package.json`:
- `redis`: ^4.7.0
Install with:
```bash
npm install
```
## Notes
- The system gracefully degrades if Redis is unavailable
- All rate limits are enforced independently (phone and IP limits both apply)
- OTP expiry time changed from 10 minutes to 2 minutes
- SMS message updated to reflect 2-minute expiry
- Existing JWT and user creation logic remains unchanged

349
REMAINING_SECURITY_GAPS.md Normal file
View File

@ -0,0 +1,349 @@
# Remaining Security Gaps Analysis
## ✅ Fully Resolved Issues
### 1. ✅ Rate Limiting / OTP Throttling
**Status:** **RESOLVED**
- Rate limiting implemented per phone and per IP
- OTP request throttling (3 per 10 min, 10 per 24h per phone)
- OTP verification attempt limits (5 per OTP, 10 failed per hour)
- **Location:** `src/middleware/rateLimitMiddleware.js`
### 2. ✅ OTP Exposure in Logs
**Status:** **RESOLVED**
- Safe OTP logging helper created (`src/utils/otpLogger.js`)
- Only logs in development mode
- Never logs to production
- **Location:** `src/services/smsService.js` uses `logOtpForDebug()`
### 3. ✅ IP/Device Risk Controls
**Status:** **RESOLVED**
- IP blocking for configured CIDR ranges
- Risk scoring based on IP/device/user-agent changes
- Suspicious refresh detection
- **Location:** `src/services/riskScoring.js`, integrated in `src/routes/authRoutes.js`
### 4. ✅ Refresh Token Theft Mitigation
**Status:** **RESOLVED**
- Environment fingerprinting (IP, user-agent, device ID)
- Suspicious refresh detection with risk scoring
- Optional OTP re-verification for suspicious refreshes
- Enhanced logging of suspicious events
- **Location:** `src/routes/authRoutes.js` (refresh endpoint)
### 5. ✅ JWT Claims Validation
**Status:** **RESOLVED**
- Strict validation of `iss`, `aud`, `exp`, `iat`, `nbf`
- Centralized validation function
- Used in all token verification paths
- **Location:** `src/services/jwtKeys.js` - `validateTokenClaims()`
### 6. ✅ CORS Hardening
**Status:** **RESOLVED**
- Strict origin whitelisting
- No wildcard support when credentials involved
- Production requirement for explicit origins
- **Location:** `src/index.js`
### 7. ✅ CSRF Protection (Documented)
**Status:** **RESOLVED** (Not needed - using Bearer tokens)
- Comprehensive documentation provided
- Guidance for future cookie-based implementation
- **Location:** `CSRF_NOTES.md`
---
## ⚠️ Partially Resolved Issues
### 8. ⚠️ Access Token Replay Mitigation
**Status:** **PARTIALLY RESOLVED**
**What's Done:**
- ✅ Step-up authentication middleware created (`src/middleware/stepUpAuth.js`)
- ✅ Access tokens include `high_assurance` claim after OTP verification
- ✅ Middleware checks for recent OTP or high assurance token
**What's Missing:**
- ❌ **Step-up auth NOT applied to sensitive routes**
- ❌ Sensitive operations in `src/routes/userRoutes.js` don't use `requireRecentOtpOrReauth`:
- `PUT /users/me` - Profile updates (could change phone number)
- `DELETE /users/me/devices/:device_id` - Device revocation
- `POST /users/me/logout-all-other-devices` - Mass device logout
**Risk Level:** 🟡 **MEDIUM**
- Stolen access token can be used for sensitive operations within 15-minute window
- Attacker could revoke devices, update profile, etc.
**Recommendation:**
```javascript
// Apply to sensitive routes:
router.put('/me', auth, requireRecentOtpOrReauth, async (req, res) => { ... });
router.delete('/me/devices/:device_id', auth, requireRecentOtpOrReauth, async (req, res) => { ... });
router.post('/me/logout-all-other-devices', auth, requireRecentOtpOrReauth, async (req, res) => { ... });
```
---
### 9. ⚠️ Secrets Management & Rotation
**Status:** **PARTIALLY RESOLVED**
**What's Done:**
- ✅ JWT key rotation structure implemented
- ✅ Support for multiple keys with `kid` (key ID)
- ✅ Graceful rotation without breaking existing tokens
- ✅ Code structure ready for secrets manager integration
**What's Missing:**
- ❌ **Still reads secrets from `.env` file**
- ❌ No integration with secrets manager (AWS Secrets Manager, HashiCorp Vault, etc.)
- ❌ Manual key rotation process (no automation)
- ❌ Twilio credentials still in `.env`
**Risk Level:** 🟡 **MEDIUM-HIGH**
- If `.env` leaks or is committed to git, attacker can:
- Forge JWTs
- Send SMS via Twilio
- Access database
**Recommendation:**
1. **Immediate:** Ensure `.env` is in `.gitignore` and never committed
2. **Short-term:** Use environment variables from secure deployment platform
3. **Long-term:** Integrate with secrets manager (see TODOs in `src/services/jwtKeys.js`)
**TODO in Code:**
- `src/services/jwtKeys.js` line 169: Example implementation for AWS Secrets Manager
- Need to implement `loadKeysFromSecretsManager()` function
---
### 10. ⚠️ Audit Logs Active Monitoring
**Status:** **PARTIALLY RESOLVED**
**What's Done:**
- ✅ Enhanced audit logging with risk levels (INFO, SUSPICIOUS, HIGH_RISK)
- ✅ Structured logging with metadata
- ✅ Anomaly detection helper function (`checkAnomalies()`)
- ✅ Suspicious events logged (failed OTPs, suspicious refreshes, blocked IPs)
**What's Missing:**
- ❌ **No active alerting/monitoring integration**
- ❌ No automated alerts for HIGH_RISK events
- ❌ No integration with PagerDuty, Slack, email, etc.
- ❌ Anomaly detection only logs to console (line 183 in `auditLogger.js`)
**Risk Level:** 🟡 **MEDIUM**
- Attacks happen silently until manual log inspection
- No real-time notification of security events
**Recommendation:**
1. **Immediate:** Set up log aggregation (CloudWatch, Datadog, etc.)
2. **Short-term:** Implement alerting for HIGH_RISK events
3. **Long-term:** Integrate with SIEM system
**TODO in Code:**
- `src/services/auditLogger.js` line 183: `// TODO: Trigger alert (email, webhook, etc.)`
- `src/services/auditLogger.js` line 193: Example implementation for external alerting
---
### 11. ⚠️ Input Validation
**Status:** **PARTIALLY RESOLVED**
**What's Done:**
- ✅ Input validation middleware created (`src/middleware/validation.js`)
- ✅ Validation applied to all auth routes:
- `/auth/request-otp`
- `/auth/verify-otp`
- `/auth/refresh`
- `/auth/logout`
**What's Missing:**
- ❌ **Validation NOT applied to user routes** (`src/routes/userRoutes.js`):
- `PUT /users/me` - No validation for `name`, `user_type`
- `DELETE /users/me/devices/:device_id` - No validation for `device_id` param
- `POST /users/me/logout-all-other-devices` - No validation for `current_device_id`
**Risk Level:** 🟢 **LOW-MEDIUM**
- Less critical than auth endpoints, but still important
- Could allow unexpected payloads or edge-case bugs
**Recommendation:**
```javascript
// Add validation middleware to user routes:
const { validateUpdateProfileBody, validateDeviceIdParam } = require('../middleware/validation');
router.put('/me', auth, validateUpdateProfileBody, async (req, res) => { ... });
router.delete('/me/devices/:device_id', auth, validateDeviceIdParam, async (req, res) => { ... });
```
---
## ❌ Unaddressed Attack Scenarios
### 12. ❌ Database Compromise
**Status:** **NOT ADDRESSED**
**Risk:**
- If database is compromised, attacker can:
- See phone numbers, device metadata, audit logs
- Correlate patterns for phishing/SIM-swap attacks
- Access user data
**Mitigation Needed:**
- ✅ Already using parameterized queries (good)
- ❌ No encryption at rest for sensitive fields
- ❌ No field-level encryption for PII
- ❌ No database access logging/auditing
**Recommendation:**
- Encrypt sensitive fields (phone numbers) at rest
- Implement database access logging
- Use database encryption (TDE, etc.)
- Regular security audits
---
### 13. ❌ Man-in-the-Middle (Non-HTTPS)
**Status:** **NOT ADDRESSED** (Infrastructure concern)
**Risk:**
- If client app talks to service over HTTP (no TLS):
- Attacker can intercept access/refresh tokens
- Attacker can intercept OTPs
- Full account compromise
**Mitigation:**
- ✅ Server assumes TLS termination in front (reverse proxy)
- ❌ No enforcement of HTTPS-only connections
- ❌ No HSTS headers
- ❌ No certificate pinning guidance
**Recommendation:**
- Enforce HTTPS at reverse proxy/load balancer
- Add HSTS headers
- Document TLS requirements
- Consider certificate pinning for mobile apps
---
### 14. ❌ Misconfigured CORS + XSS
**Status:** **PARTIALLY ADDRESSED**
**What's Done:**
- ✅ CORS hardened with strict origin whitelisting
- ✅ Documentation warns about misconfiguration
**What's Missing:**
- ❌ No validation that CORS is properly configured in production
- ❌ No runtime checks for CORS misconfiguration
- ❌ No guidance for XSS prevention in frontend
**Risk Level:** 🟡 **MEDIUM**
- If frontend has XSS and CORS is misconfigured:
- Attacker can use victim's tokens from malicious page
- Account takeover possible
**Recommendation:**
- Add startup validation that CORS origins are configured in production
- Document XSS prevention best practices
- Consider Content Security Policy (CSP) headers
---
## Summary Table
| Issue | Status | Risk Level | Priority |
|-------|--------|------------|----------|
| 1. Rate Limiting | ✅ Resolved | - | - |
| 2. OTP Logging | ✅ Resolved | - | - |
| 3. IP/Device Risk | ✅ Resolved | - | - |
| 4. Refresh Token Theft | ✅ Resolved | - | - |
| 5. JWT Claims | ✅ Resolved | - | - |
| 6. CORS | ✅ Resolved | - | - |
| 7. CSRF | ✅ Documented | - | - |
| 8. Access Token Replay | ⚠️ Partial | 🟡 Medium | **HIGH** |
| 9. Secrets Management | ⚠️ Partial | 🟡 Medium-High | **HIGH** |
| 10. Audit Monitoring | ⚠️ Partial | 🟡 Medium | **MEDIUM** |
| 11. Input Validation | ⚠️ Partial | 🟢 Low-Medium | **MEDIUM** |
| 12. Database Compromise | ❌ Not Addressed | 🔴 High | **MEDIUM** |
| 13. MITM (HTTP) | ❌ Not Addressed | 🔴 High | **LOW** (Infra) |
| 14. CORS + XSS | ⚠️ Partial | 🟡 Medium | **LOW** |
---
## Immediate Action Items (Priority Order)
### 🔴 HIGH PRIORITY
1. **Apply Step-Up Auth to Sensitive Routes**
- Add `requireRecentOtpOrReauth` to user routes
- Prevents access token replay attacks
2. **Secrets Management**
- Move secrets to environment variables (not `.env` file)
- Plan integration with secrets manager
- Ensure `.env` is never committed
### 🟡 MEDIUM PRIORITY
3. **Active Monitoring/Alerting**
- Set up alerts for HIGH_RISK audit events
- Integrate with monitoring system (CloudWatch, Datadog, etc.)
4. **Complete Input Validation**
- Add validation middleware to user routes
- Validate all request parameters
### 🟢 LOW PRIORITY
5. **Database Security**
- Plan encryption at rest
- Implement database access logging
6. **Infrastructure Security**
- Document TLS/HTTPS requirements
- Add HSTS headers
- Validate CORS configuration at startup
---
## Code Locations for Fixes
### Step-Up Auth Application
**File:** `src/routes/userRoutes.js`
- Line 99: `PUT /users/me` - Add `requireRecentOtpOrReauth`
- Line 160: `DELETE /users/me/devices/:device_id` - Add `requireRecentOtpOrReauth`
- Line 203: `POST /users/me/logout-all-other-devices` - Add `requireRecentOtpOrReauth`
### Input Validation
**File:** `src/routes/userRoutes.js`
- Add validation middleware imports
- Apply to all routes
### Secrets Manager Integration
**File:** `src/services/jwtKeys.js`
- Line 169: Implement `loadKeysFromSecretsManager()` function
- Replace `.env` reads with secrets manager calls
### Alerting Integration
**File:** `src/services/auditLogger.js`
- Line 183: Implement alert triggering
- Line 193: Add external alerting integration
---
## Conclusion
**7 out of 14 issues are fully resolved** ✅
**4 issues are partially resolved** ⚠️ (need completion)
**3 issues are not addressed** ❌ (infrastructure/planning)
**Overall Security Posture:** 🟡 **GOOD** (with room for improvement)
The most critical remaining gaps are:
1. Step-up auth not applied to sensitive routes
2. Secrets still in `.env` file
3. No active monitoring/alerting
These should be addressed before production deployment.

View File

@ -0,0 +1,325 @@
# Security Hardening Implementation Summary
## Overview
This document summarizes the security hardening improvements added to the authentication service. All changes are marked with clear comments in the code for easy identification.
## 1. OTP Logging Safety ✅
**Implementation:** `src/utils/otpLogger.js`
- **Safe OTP logging helper** that only logs in development mode
- Never logs OTPs to production or centralized logging systems
- Uses `logOtpForDebug()` function that:
- Only logs when `NODE_ENV === 'development'`
- Masks phone numbers (shows only last 4 digits)
- Clearly marked as `[DEV-ONLY]`
**Updated Files:**
- `src/services/smsService.js` - Now uses safe logging instead of direct `console.log`
**Configuration:**
- No configuration needed - automatically detects environment
## 2. IP/Device Risk Controls ✅
**Implementation:** `src/services/riskScoring.js`
- **IP blocking**: Blocks logins from configured CIDR ranges (private IPs, test ranges)
- **Risk scoring**: Calculates risk score based on:
- IP address changes
- Device changes
- User agent changes
- **Suspicious refresh detection**: Detects when refresh tokens are used from different environments
**Features:**
- Configurable blocked IP ranges via `BLOCKED_IP_RANGES` environment variable
- Optional OTP re-verification requirement for suspicious refreshes
- Risk scores logged to audit trail
**Updated Files:**
- `src/routes/authRoutes.js` - Integrated IP blocking and risk scoring
**Configuration:**
```bash
BLOCKED_IP_RANGES=10.0.0.0/8,172.16.0.0/12 # Comma-separated CIDR blocks
REQUIRE_OTP_ON_SUSPICIOUS_REFRESH=true # Require OTP on suspicious refresh
```
## 3. Access Token Replay Mitigation ✅
**Implementation:** `src/middleware/stepUpAuth.js`
- **Step-up authentication middleware** for sensitive operations
- Requires either:
- Recent OTP verification (within configurable window)
- High assurance flag in token (set after OTP verification)
- Access tokens now include `high_assurance` claim after OTP verification
**Usage:**
```javascript
router.post('/users/me/change-phone',
authMiddleware,
requireRecentOtpOrReauth,
async (req, res) => { ... }
);
```
**Updated Files:**
- `src/services/tokenService.js` - Added `highAssurance` option to `signAccessToken()`
- `src/middleware/authMiddleware.js` - Extracts `high_assurance` claim from token
- `src/routes/authRoutes.js` - Issues tokens with `highAssurance: true` after OTP verification
**Configuration:**
```bash
STEP_UP_OTP_WINDOW_MINUTES=5 # Time window for "recent" OTP (default: 5)
```
## 4. Refresh Token Theft Mitigation ✅
**Implementation:** Enhanced in `src/routes/authRoutes.js` and `src/services/riskScoring.js`
- **Environment fingerprinting**: Tracks IP, user-agent, device ID
- **Suspicious refresh detection**: Compares current refresh with previous environment
- **Optional OTP requirement**: Can require OTP re-verification for suspicious refreshes
- **Enhanced logging**: All suspicious refreshes logged with risk scores
**Features:**
- Detects IP changes, device changes, user-agent changes
- Calculates risk score (0-100)
- Logs suspicious events to audit trail
- Optionally blocks suspicious refreshes until OTP verification
**Configuration:**
```bash
REQUIRE_OTP_ON_SUSPICIOUS_REFRESH=true # Require OTP on suspicious refresh
```
## 5. JWT Key Rotation & Secrets Management ✅
**Implementation:** `src/services/jwtKeys.js`
- **Multiple signing keys** with key IDs (kid) in JWT header
- **Active key for signing**: Configurable via `JWT_ACTIVE_KEY_ID`
- **Multiple verification keys**: Supports key rotation without breaking existing tokens
- **Strict claims validation**: Validates `iss`, `aud`, `exp`, `iat`, `nbf`
**Key Features:**
- Tokens include `kid` in header for key identification
- Supports multiple keys for graceful rotation
- Validates issuer and audience claims
- Backward compatible with legacy single-key setup
**Updated Files:**
- `src/services/tokenService.js` - Uses new key management system
- `src/middleware/authMiddleware.js` - Validates tokens with key rotation support
**Configuration:**
```bash
JWT_ACTIVE_KEY_ID=1 # Key ID for signing new tokens
JWT_KEYS_JSON='{"1":"secret1","2":"secret2"}' # Multiple keys for rotation
JWT_ISSUER=farm-auth-service # Issuer claim
JWT_AUDIENCE=mobile-app # Audience claim
JWT_REFRESH_KEY_ID=1 # Key ID for refresh tokens (optional)
```
**TODO for Production:**
- Load keys from secrets manager (AWS Secrets Manager, HashiCorp Vault, etc.)
- See comments in `src/services/jwtKeys.js` for implementation example
## 6. Stricter JWT Claims Validation ✅
**Implementation:** `src/services/jwtKeys.js` - `validateTokenClaims()`
- **Issuer (iss) validation**: Ensures tokens are from correct service
- **Audience (aud) validation**: Ensures tokens are for correct application
- **Expiration (exp) validation**: Already handled, but now with clock skew tolerance
- **Issued at (iat) validation**: Prevents tokens issued in the future
- **Not before (nbf) validation**: Validates if present
**Features:**
- Configurable clock skew (default: 60 seconds)
- Centralized validation function
- Used in all token verification paths
**Configuration:**
- Claims values set via `JWT_ISSUER` and `JWT_AUDIENCE` environment variables
## 7. CORS Hardening ✅
**Implementation:** `src/index.js`
- **Strict origin whitelisting**: Only allows explicitly configured origins
- **No wildcard support**: Never uses `*` when credentials are involved
- **Clear warnings**: Logs when allowing all origins (development only)
- **Production requirement**: CORS origins must be configured in production
**Features:**
- Development mode: Allows all origins only if none configured (with warning)
- Production mode: Requires explicit origin whitelist
- Validates origin on every request
- Blocks unauthorized origins with 403
**Configuration:**
```bash
CORS_ALLOWED_ORIGINS=https://app.example.com,https://api.example.com
```
**WARNING:** Never use `*` as an allowed origin when credentials or tokens are involved.
## 8. CSRF Future-Proofing ✅
**Implementation:** `CSRF_NOTES.md`
- **Documentation**: Comprehensive notes on CSRF protection
- **Current status**: No CSRF protection needed (using Bearer tokens)
- **Future guidance**: Implementation strategy if moving to cookies
**Key Points:**
- Current implementation (Bearer tokens) is CSRF-safe
- If moving to HTTP-only cookies, CSRF protection becomes mandatory
- Recommended: SameSite cookies + CSRF token validation
## 9. Enhanced Audit Logging ✅
**Implementation:** `src/services/auditLogger.js`
- **Risk levels**: INFO, SUSPICIOUS, HIGH_RISK
- **Enhanced logging**: All events include risk level
- **Anomaly detection**: Helper functions for pattern detection
- **Suspicious event logging**:
- Multiple failed OTP attempts
- Suspicious refresh events
- Blocked IP logins
**Features:**
- Automatic `risk_level` column creation in `auth_audit` table
- Structured logging with metadata
- Easy integration with external alerting systems
**Updated Files:**
- `src/routes/authRoutes.js` - Uses enhanced audit logging throughout
**Logging Functions:**
- `logAuthEvent()` - General auth event logging
- `logSuspiciousOtpAttempt()` - Failed OTP attempts
- `logBlockedIpLogin()` - Blocked IP logins
- `logSuspiciousRefresh()` - Suspicious refresh events
- `checkAnomalies()` - Pattern detection (for future alerting)
**TODO for Production:**
- Integrate with external alerting (PagerDuty, Slack, email)
- See comments in `src/services/auditLogger.js` for implementation example
## 10. Input Validation ✅
**Implementation:** `src/middleware/validation.js`
- **Centralized validation**: Reusable middleware for all endpoints
- **Type checking**: Validates field types
- **Length limits**: Prevents DoS via large payloads
- **Format validation**: Validates phone numbers, OTP codes, etc.
**Validation Middleware:**
- `validateRequestOtpBody()` - OTP request validation
- `validateVerifyOtpBody()` - OTP verification validation
- `validateRefreshTokenBody()` - Refresh token validation
- `validateLogoutBody()` - Logout validation
**Features:**
- Validates required fields
- Validates field types
- Validates string lengths
- Validates formats (phone, OTP code)
- Prevents oversized payloads
**Updated Files:**
- `src/routes/authRoutes.js` - All endpoints use validation middleware
## Environment Variables Summary
### Required
```bash
DATABASE_URL=postgresql://...
JWT_ACCESS_SECRET=... # Or use JWT_KEYS_JSON
JWT_REFRESH_SECRET=... # Or use JWT_KEYS_JSON
```
### Optional (with defaults)
```bash
# JWT Configuration
JWT_ACTIVE_KEY_ID=1
JWT_KEYS_JSON='{"1":"secret1","2":"secret2"}'
JWT_ISSUER=farm-auth-service
JWT_AUDIENCE=mobile-app
JWT_REFRESH_KEY_ID=1
# Security Hardening
BLOCKED_IP_RANGES=10.0.0.0/8,172.16.0.0/12
REQUIRE_OTP_ON_SUSPICIOUS_REFRESH=false
STEP_UP_OTP_WINDOW_MINUTES=5
# CORS
CORS_ALLOWED_ORIGINS=https://app.example.com
# Rate Limiting (from previous implementation)
OTP_REQ_PHONE_10MIN_LIMIT=3
OTP_REQ_PHONE_DAY_LIMIT=10
OTP_REQ_IP_10MIN_LIMIT=20
OTP_REQ_IP_DAY_LIMIT=100
OTP_VERIFY_MAX_ATTEMPTS=5
OTP_VERIFY_FAILED_PER_HOUR_LIMIT=10
OTP_TTL_SECONDS=120
```
## Code Markers
All security hardening code is marked with comments:
- `// === SECURITY HARDENING: OTP LOGGING ===`
- `// === SECURITY HARDENING: IP/DEVICE RISK ===`
- `// === SECURITY HARDENING: JWT KEY ROTATION ===`
- `// === SECURITY HARDENING: ACCESS TOKEN REPLAY MITIGATION ===`
- `// === SECURITY HARDENING: REFRESH TOKEN THEFT MITIGATION ===`
- `// === SECURITY HARDENING: AUDIT LOGS & ANOMALY FLAGS ===`
- `// === SECURITY HARDENING: INPUT VALIDATION ===`
- `// === SECURITY HARDENING: CORS ===`
- `// === SECURITY HARDENING: CSRF (FUTURE-PROOFING) ===`
## Testing Recommendations
1. **OTP Logging**: Verify OTPs are not logged in production
2. **IP Blocking**: Test with blocked IP ranges
3. **Risk Scoring**: Test with different IPs/devices
4. **JWT Key Rotation**: Test token verification with multiple keys
5. **CORS**: Test with allowed and blocked origins
6. **Input Validation**: Test with invalid payloads
7. **Audit Logging**: Verify risk levels are logged correctly
## Next Steps
1. **Secrets Management**: Integrate with AWS Secrets Manager or HashiCorp Vault
2. **Alerting**: Set up alerts for HIGH_RISK events
3. **Monitoring**: Monitor audit logs for suspicious patterns
4. **Key Rotation**: Implement automated key rotation process
5. **CSRF**: If moving to cookies, implement CSRF protection
## Files Created
- `src/utils/otpLogger.js` - Safe OTP logging
- `src/services/riskScoring.js` - IP/device risk scoring
- `src/services/jwtKeys.js` - JWT key management
- `src/middleware/validation.js` - Input validation
- `src/services/auditLogger.js` - Enhanced audit logging
- `src/middleware/stepUpAuth.js` - Step-up authentication
- `CSRF_NOTES.md` - CSRF protection documentation
- `SECURITY_HARDENING_SUMMARY.md` - This file
## Files Modified
- `src/services/smsService.js` - Safe OTP logging
- `src/services/tokenService.js` - JWT key rotation, claims validation
- `src/middleware/authMiddleware.js` - JWT key rotation support
- `src/routes/authRoutes.js` - All security features integrated
- `src/index.js` - CORS hardening
- `src/config.js` - New environment variables documented

View File

@ -91,3 +91,5 @@ This is perfect for local development!

100
node_modules/.package-lock.json generated vendored
View File

@ -4,6 +4,65 @@
"lockfileVersion": 3,
"requires": true,
"packages": {
"node_modules/@redis/bloom": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz",
"integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==",
"license": "MIT",
"peerDependencies": {
"@redis/client": "^1.0.0"
}
},
"node_modules/@redis/client": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
"license": "MIT",
"dependencies": {
"cluster-key-slot": "1.1.2",
"generic-pool": "3.9.0",
"yallist": "4.0.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@redis/graph": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz",
"integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==",
"license": "MIT",
"peerDependencies": {
"@redis/client": "^1.0.0"
}
},
"node_modules/@redis/json": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz",
"integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==",
"license": "MIT",
"peerDependencies": {
"@redis/client": "^1.0.0"
}
},
"node_modules/@redis/search": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz",
"integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==",
"license": "MIT",
"peerDependencies": {
"@redis/client": "^1.0.0"
}
},
"node_modules/@redis/time-series": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz",
"integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==",
"license": "MIT",
"peerDependencies": {
"@redis/client": "^1.0.0"
}
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@ -211,6 +270,15 @@
"fsevents": "~2.3.2"
}
},
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@ -590,6 +658,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/generic-pool": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz",
"integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==",
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@ -1331,6 +1408,23 @@
"node": ">=8.10.0"
}
},
"node_modules/redis": {
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz",
"integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==",
"license": "MIT",
"workspaces": [
"./packages/*"
],
"dependencies": {
"@redis/bloom": "1.2.0",
"@redis/client": "1.6.1",
"@redis/graph": "1.1.1",
"@redis/json": "1.0.7",
"@redis/search": "1.2.0",
"@redis/time-series": "1.1.0"
}
},
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
@ -1662,6 +1756,12 @@
"engines": {
"node": ">=0.4"
}
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
}
}
}

101
package-lock.json generated
View File

@ -15,12 +15,72 @@
"express": "^5.1.0",
"jsonwebtoken": "^9.0.2",
"pg": "^8.16.3",
"redis": "^4.7.0",
"twilio": "^5.10.6"
},
"devDependencies": {
"nodemon": "^3.1.11"
}
},
"node_modules/@redis/bloom": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz",
"integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==",
"license": "MIT",
"peerDependencies": {
"@redis/client": "^1.0.0"
}
},
"node_modules/@redis/client": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
"license": "MIT",
"dependencies": {
"cluster-key-slot": "1.1.2",
"generic-pool": "3.9.0",
"yallist": "4.0.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@redis/graph": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz",
"integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==",
"license": "MIT",
"peerDependencies": {
"@redis/client": "^1.0.0"
}
},
"node_modules/@redis/json": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz",
"integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==",
"license": "MIT",
"peerDependencies": {
"@redis/client": "^1.0.0"
}
},
"node_modules/@redis/search": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz",
"integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==",
"license": "MIT",
"peerDependencies": {
"@redis/client": "^1.0.0"
}
},
"node_modules/@redis/time-series": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz",
"integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==",
"license": "MIT",
"peerDependencies": {
"@redis/client": "^1.0.0"
}
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@ -228,6 +288,15 @@
"fsevents": "~2.3.2"
}
},
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@ -622,6 +691,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/generic-pool": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz",
"integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==",
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@ -1363,6 +1441,23 @@
"node": ">=8.10.0"
}
},
"node_modules/redis": {
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz",
"integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==",
"license": "MIT",
"workspaces": [
"./packages/*"
],
"dependencies": {
"@redis/bloom": "1.2.0",
"@redis/client": "1.6.1",
"@redis/graph": "1.1.1",
"@redis/json": "1.0.7",
"@redis/search": "1.2.0",
"@redis/time-series": "1.1.0"
}
},
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
@ -1694,6 +1789,12 @@
"engines": {
"node": ">=0.4"
}
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
}
}
}

View File

@ -18,6 +18,7 @@
"express": "^5.1.0",
"jsonwebtoken": "^9.0.2",
"pg": "^8.16.3",
"redis": "^4.7.0",
"twilio": "^5.10.6"
},
"devDependencies": {

View File

@ -1,4 +1,38 @@
// src/config.js
// === ADDED FOR RATE LIMITING ===
// Environment variables for rate limiting (used in middleware/rateLimitMiddleware.js):
// - REDIS_URL: Full Redis connection URL (e.g., redis://localhost:6379)
// OR use REDIS_HOST and REDIS_PORT separately
// - REDIS_HOST: Redis host (default: localhost)
// - REDIS_PORT: Redis port (default: 6379)
// - REDIS_PASSWORD: Redis password (optional)
// - OTP_REQ_PHONE_10MIN_LIMIT: Max OTP requests per phone per 10 min (default: 3)
// - OTP_REQ_PHONE_DAY_LIMIT: Max OTP requests per phone per 24 hours (default: 10)
// - OTP_REQ_IP_10MIN_LIMIT: Max OTP requests per IP per 10 min (default: 20)
// - OTP_REQ_IP_DAY_LIMIT: Max OTP requests per IP per 24 hours (default: 100)
// - OTP_VERIFY_MAX_ATTEMPTS: Max verification attempts per OTP (default: 5)
// - OTP_VERIFY_FAILED_PER_HOUR_LIMIT: Max failed verifications per phone per hour (default: 10)
// - OTP_TTL_SECONDS: OTP validity in seconds (default: 120, i.e., 2 minutes)
//
// === SECURITY HARDENING: JWT KEY ROTATION ===
// - JWT_ACTIVE_KEY_ID: Key ID to use for signing new tokens (default: '1')
// - JWT_KEYS_JSON: JSON object mapping key IDs to secrets, e.g. '{"1":"secret1","2":"secret2"}'
// OR use legacy: JWT_ACCESS_SECRET, JWT_REFRESH_SECRET
// - JWT_REFRESH_KEY_ID: Key ID for refresh tokens (default: same as active key)
// - JWT_ISSUER: Issuer claim (iss) for tokens (default: 'farm-auth-service')
// - JWT_AUDIENCE: Audience claim (aud) for tokens (default: 'mobile-app')
//
// === SECURITY HARDENING: IP/DEVICE RISK ===
// - BLOCKED_IP_RANGES: Comma-separated CIDR blocks to block (e.g., '10.0.0.0/8,172.16.0.0/12')
// - REQUIRE_OTP_ON_SUSPICIOUS_REFRESH: Require OTP re-verification on suspicious refresh (default: false)
//
// === SECURITY HARDENING: ACCESS TOKEN REPLAY MITIGATION ===
// - STEP_UP_OTP_WINDOW_MINUTES: Time window for "recent" OTP verification (default: 5)
//
// === SECURITY HARDENING: CORS ===
// - CORS_ALLOWED_ORIGINS: Comma-separated list of allowed origins (REQUIRED in production)
// Example: 'https://app.example.com,https://api.example.com'
// WARNING: Never use '*' when credentials or tokens are involved
require('dotenv').config();
const REQUIRED_ENV = [

View File

@ -4,27 +4,54 @@ const cors = require('cors');
const config = require('./config');
const authRoutes = require('./routes/authRoutes');
const userRoutes = require('./routes/userRoutes');
// === ADDED FOR RATE LIMITING ===
const { initRedis } = require('./services/redisClient');
const app = express();
// === ADDED FOR RATE LIMITING ===
// Trust proxy to get correct client IP (important for rate limiting by IP)
// Set this if your app is behind a reverse proxy (nginx, load balancer, etc.)
if (process.env.TRUST_PROXY === 'true' || process.env.TRUST_PROXY === '1') {
app.set('trust proxy', true);
}
// === SECURITY HARDENING: CORS ===
// CORS configuration with strict origin whitelisting
// IMPORTANT: Never use wildcard '*' when credentials or tokens are involved
const allowAllOrigins =
!config.isProduction && config.corsAllowedOrigins.length === 0;
if (allowAllOrigins) {
// Development mode: allow all origins (only when no origins configured)
// This is safe for development but should never be used in production
console.warn('⚠️ CORS: Allowing all origins (development mode only)');
app.use(cors());
} else {
// === SECURITY HARDENING: CORS ===
// Production mode: Only allow explicitly whitelisted origins
// This prevents malicious websites from making requests to your API
const corsOptions = {
origin(origin, callback) {
// Allow requests with no origin (mobile apps, Postman, etc.)
if (!origin) {
return callback(null, true);
}
// Check if origin is in whitelist
if (config.corsAllowedOrigins.includes(origin)) {
return callback(null, true);
}
// Origin not allowed
console.warn(`CORS: Blocked origin: ${origin}`);
return callback(new Error('Not allowed by CORS'));
},
credentials: true,
credentials: true, // Allow cookies/credentials
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
};
app.use(cors(corsOptions));
app.use((err, req, res, next) => {
if (err && err.message === 'Not allowed by CORS') {
@ -45,6 +72,12 @@ app.get('/health', (req, res) => {
app.use('/auth', authRoutes);
app.use('/users', userRoutes);
// === ADDED FOR RATE LIMITING ===
// Initialize Redis connection (falls back gracefully if not available)
initRedis().catch((err) => {
console.warn('Redis initialization warning:', err.message);
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Auth service running at http://localhost:${PORT}`);

View File

@ -1,7 +1,11 @@
// src/middleware/authMiddleware.js
const jwt = require('jsonwebtoken');
const config = require('../config');
const ACCESS_SECRET = config.jwtAccessSecret;
// === SECURITY HARDENING: JWT KEY ROTATION ===
const {
getKeySecret,
getAllKeys,
validateTokenClaims,
} = require('../services/jwtKeys');
function authMiddleware(req, res, next) {
const auth = req.headers.authorization || '';
@ -11,18 +15,71 @@ function authMiddleware(req, res, next) {
return res.status(401).json({ error: 'Missing Authorization header' });
}
// === SECURITY HARDENING: JWT KEY ROTATION ===
// Decode token to get key ID from header
let decoded;
try {
const payload = jwt.verify(token, ACCESS_SECRET);
req.user = {
id: payload.sub,
phone_number: payload.phone_number,
role: payload.role,
user_type: payload.user_type,
};
next();
decoded = jwt.decode(token, { complete: true });
if (!decoded || !decoded.header) {
return res.status(401).json({ error: 'Invalid token format' });
}
} catch (err) {
return res.status(401).json({ error: 'Invalid token' });
}
// Get secret for the key ID (if present) or try all keys
const keyId = decoded.header.kid;
let payload = null;
let verified = false;
if (keyId) {
const secret = getKeySecret(keyId);
if (secret) {
try {
payload = jwt.verify(token, secret);
verified = true;
} catch (err) {
// Key ID specified but verification failed
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
}
// If key ID not found or not specified, try all keys (for rotation support)
if (!verified) {
const allKeys = getAllKeys();
for (const [kid, keySecret] of Object.entries(allKeys)) {
try {
payload = jwt.verify(token, keySecret);
verified = true;
break;
} catch (err) {
// Try next key
continue;
}
}
}
if (!verified || !payload) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
// === SECURITY HARDENING: JWT KEY ROTATION ===
// Validate JWT claims (iss, aud, exp, iat, nbf)
const claimsValidation = validateTokenClaims(payload);
if (!claimsValidation.valid) {
return res.status(401).json({ error: 'Invalid token claims' });
}
req.user = {
id: payload.sub,
phone_number: payload.phone_number,
role: payload.role,
user_type: payload.user_type,
// === SECURITY HARDENING: ACCESS TOKEN REPLAY MITIGATION ===
high_assurance: payload.high_assurance || false,
};
next();
}
module.exports = authMiddleware;

View File

@ -0,0 +1,326 @@
// src/middleware/rateLimitMiddleware.js
// === ADDED FOR RATE LIMITING ===
// Rate limiting middleware for OTP requests and verification
const { getRedisClient, isRedisReady } = require('../services/redisClient');
// In-memory fallback store (used when Redis is not available)
// Unified store for all rate limiting keys
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 config = {
// OTP request limits
OTP_REQ_PHONE_10MIN_LIMIT: parseInt(process.env.OTP_REQ_PHONE_10MIN_LIMIT || '3', 10),
OTP_REQ_PHONE_DAY_LIMIT: parseInt(process.env.OTP_REQ_PHONE_DAY_LIMIT || '10', 10),
OTP_REQ_IP_10MIN_LIMIT: parseInt(process.env.OTP_REQ_IP_10MIN_LIMIT || '20', 10),
OTP_REQ_IP_DAY_LIMIT: parseInt(process.env.OTP_REQ_IP_DAY_LIMIT || '100', 10),
// OTP verification limits
OTP_VERIFY_MAX_ATTEMPTS: parseInt(process.env.OTP_VERIFY_MAX_ATTEMPTS || '5', 10),
OTP_VERIFY_FAILED_PER_HOUR_LIMIT: parseInt(process.env.OTP_VERIFY_FAILED_PER_HOUR_LIMIT || '10', 10),
// OTP validity
OTP_TTL_SECONDS: parseInt(process.env.OTP_TTL_SECONDS || '120', 10), // 2 minutes
};
/**
* 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: 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: Check if key exists in Redis or memory store
*/
async function exists(key) {
const redis = await getRedisClient();
if (isRedisReady() && redis) {
try {
const result = await redis.exists(key);
return result === 1;
} catch (err) {
console.error('Redis exists error, falling back to memory:', err);
// Fall through to memory store
}
}
// Memory store fallback
if (memoryStore[key] && memoryStore[key].expiresAt > Date.now()) {
return true;
}
return false;
}
/**
* Helper: Set key with TTL in Redis or memory store
*/
async function setWithTTL(key, value, ttlSeconds) {
const redis = await getRedisClient();
if (isRedisReady() && redis) {
try {
await redis.setEx(key, ttlSeconds, value);
return true;
} catch (err) {
console.error('Redis setEx error, falling back to memory:', err);
// Fall through to memory store
}
}
// Memory store fallback
memoryStore[key] = {
value,
expiresAt: Date.now() + ttlSeconds * 1000,
};
return true;
}
/**
* === ADDED FOR 2-MIN OTP VALIDITY & NO-RESEND ===
* Middleware: Check if there's an active OTP for the phone number
* Prevents sending a new OTP if one is already active (within 2 minutes)
*/
async function checkActiveOtpForPhone(req, res, next) {
try {
const { phone_number } = req.body;
if (!phone_number) {
return next(); // Let validation handle this
}
// Normalize phone (same logic as in routes)
const normalizedPhone = phone_number.trim().replace(/\s+/g, '');
const phone = normalizedPhone.startsWith('+')
? normalizedPhone
: (normalizedPhone.length === 10 ? '+91' + normalizedPhone : normalizedPhone);
const activeOtpKey = `otp_active:phone:${phone}`;
const hasActiveOtp = await exists(activeOtpKey);
if (hasActiveOtp) {
return res.status(429).json({
success: false,
message: 'An OTP is already active. Please wait a moment before requesting a new one.',
});
}
next();
} catch (err) {
console.error('checkActiveOtpForPhone error:', err);
// On error, allow the request to proceed (fail open)
next();
}
}
/**
* === ADDED FOR RATE LIMITING ===
* Middleware: Rate limit OTP requests per phone number
*/
async function rateLimitRequestOtpByPhone(req, res, next) {
try {
const { phone_number } = req.body;
if (!phone_number) {
return next(); // Let validation handle this
}
// Normalize phone (same logic as in routes)
const normalizedPhone = phone_number.trim().replace(/\s+/g, '');
const phone = normalizedPhone.startsWith('+')
? normalizedPhone
: (normalizedPhone.length === 10 ? '+91' + normalizedPhone : normalizedPhone);
// Check 10-minute limit
const key10min = `otp_req:phone:${phone}:10min`;
const count10min = await incrementCounter(key10min, 600); // 10 minutes = 600 seconds
if (count10min > config.OTP_REQ_PHONE_10MIN_LIMIT) {
return res.status(429).json({
success: false,
message: 'Too many OTP requests. Please try again later.',
});
}
// Check 24-hour limit
const keyDay = `otp_req:phone:${phone}:day`;
const countDay = await incrementCounter(keyDay, 86400); // 24 hours = 86400 seconds
if (countDay > config.OTP_REQ_PHONE_DAY_LIMIT) {
return res.status(429).json({
success: false,
message: 'Too many OTP requests. Please try again later.',
});
}
next();
} catch (err) {
console.error('rateLimitRequestOtpByPhone error:', err);
// On error, allow the request to proceed (fail open)
next();
}
}
/**
* === ADDED FOR RATE LIMITING ===
* Middleware: Rate limit OTP requests per IP address
*/
async function rateLimitRequestOtpByIp(req, res, next) {
try {
// Get client IP (considering proxies)
const ip = req.ip || req.connection.remoteAddress || 'unknown';
// Check 10-minute limit
const key10min = `otp_req:ip:${ip}:10min`;
const count10min = await incrementCounter(key10min, 600); // 10 minutes = 600 seconds
if (count10min > config.OTP_REQ_IP_10MIN_LIMIT) {
return res.status(429).json({
success: false,
message: 'Too many OTP requests. Please try again later.',
});
}
// Check 24-hour limit
const keyDay = `otp_req:ip:${ip}:day`;
const countDay = await incrementCounter(keyDay, 86400); // 24 hours = 86400 seconds
if (countDay > config.OTP_REQ_IP_DAY_LIMIT) {
return res.status(429).json({
success: false,
message: 'Too many OTP requests. Please try again later.',
});
}
next();
} catch (err) {
console.error('rateLimitRequestOtpByIp error:', err);
// On error, allow the request to proceed (fail open)
next();
}
}
/**
* === ADDED FOR OTP ATTEMPT LIMIT ===
* Middleware: Rate limit failed OTP verification attempts per phone
*/
async function rateLimitVerifyOtpByPhone(req, res, next) {
try {
const { phone_number } = req.body;
if (!phone_number) {
return next(); // Let validation handle this
}
// Normalize phone (same logic as in routes)
const normalizedPhone = phone_number.trim().replace(/\s+/g, '');
const phone = normalizedPhone.startsWith('+')
? normalizedPhone
: (normalizedPhone.length === 10 ? '+91' + normalizedPhone : normalizedPhone);
// Check failed verification limit per hour
const key = `otp_verify_failed:phone:${phone}:hour`;
const count = await getCounter(key);
if (count >= config.OTP_VERIFY_FAILED_PER_HOUR_LIMIT) {
return res.status(429).json({
success: false,
message: 'Too many attempts. Please try again later.',
});
}
next();
} catch (err) {
console.error('rateLimitVerifyOtpByPhone error:', err);
// On error, allow the request to proceed (fail open)
next();
}
}
/**
* Helper: Mark active OTP (called after OTP is created)
*/
async function markActiveOtp(phone, ttlSeconds) {
const key = `otp_active:phone:${phone}`;
await setWithTTL(key, '1', ttlSeconds);
}
/**
* Helper: Increment failed verification counter
*/
async function incrementFailedVerify(phone) {
const key = `otp_verify_failed:phone:${phone}:hour`;
await incrementCounter(key, 3600); // 1 hour = 3600 seconds
}
module.exports = {
checkActiveOtpForPhone,
rateLimitRequestOtpByPhone,
rateLimitRequestOtpByIp,
rateLimitVerifyOtpByPhone,
markActiveOtp,
incrementFailedVerify,
config,
};

View File

@ -0,0 +1,108 @@
// src/middleware/stepUpAuth.js
// === SECURITY HARDENING: ACCESS TOKEN REPLAY MITIGATION ===
// Step-up authentication middleware for sensitive operations
/**
* Step-up authentication middleware
*
* For sensitive operations (changing phone, password, deleting account, etc.),
* require either:
* 1. A recent OTP verification (within last 5 minutes)
* 2. A "high assurance" flag in the token (can be set after recent OTP)
*
* Usage:
* router.post('/users/me/change-phone',
* authMiddleware,
* requireRecentOtpOrReauth,
* async (req, res) => { ... }
* );
*/
const db = require('../db');
const { logStepUpAuthRequired } = require('../services/auditLogger');
// === SECURITY HARDENING: ACCESS TOKEN REPLAY MITIGATION ===
// Time window for "recent" OTP verification (in minutes)
const RECENT_OTP_WINDOW_MINUTES = parseInt(process.env.STEP_UP_OTP_WINDOW_MINUTES || '5', 10);
/**
* Middleware: Require recent OTP verification or high assurance token
*
* Checks:
* 1. If token has 'high_assurance' claim set to true (from recent OTP)
* 2. If user has verified OTP within the time window
*
* If neither condition is met, returns 403 with message indicating OTP is required
*/
async function requireRecentOtpOrReauth(req, res, next) {
try {
// User should be set by authMiddleware
if (!req.user || !req.user.id) {
return res.status(401).json({ error: 'Authentication required' });
}
const userId = req.user.id;
// Check if token has high_assurance claim
if (req.user.high_assurance === true) {
return next(); // Token already has high assurance
}
// Check for recent OTP verification in audit logs
const recentOtpResult = await db.query(
`SELECT created_at
FROM auth_audit
WHERE user_id = $1
AND action = 'otp_verify'
AND status = 'success'
AND created_at > NOW() - INTERVAL '${RECENT_OTP_WINDOW_MINUTES} minutes'
ORDER BY created_at DESC
LIMIT 1`,
[userId]
);
if (recentOtpResult.rows.length > 0) {
// Recent OTP verification found, allow the request
return next();
}
// No recent OTP or high assurance token
// Log the attempt
await logStepUpAuthRequired(
userId,
req.path,
req.ip,
req.headers['user-agent']
);
return res.status(403).json({
error: 'step_up_required',
message: 'This action requires additional verification. Please verify your OTP first.',
requires_otp: true,
});
} catch (err) {
console.error('Error in requireRecentOtpOrReauth:', err);
return res.status(500).json({ error: 'Internal server error' });
}
}
/**
* Helper: Mark token as high assurance after OTP verification
* This can be used to issue a new access token with high_assurance claim
* after a user verifies OTP for a sensitive action
*/
function createHighAssuranceToken(user) {
// This would be called after OTP verification to issue a new token
// with high_assurance: true claim
// Implementation would be in tokenService
return {
...user,
high_assurance: true,
};
}
module.exports = {
requireRecentOtpOrReauth,
createHighAssuranceToken,
};

View File

@ -0,0 +1,231 @@
// src/middleware/validation.js
// === SECURITY HARDENING: INPUT VALIDATION ===
// Request validation middleware using simple validation
/**
* Validation middleware for request bodies
* Uses simple validation without external dependencies
*/
/**
* Validate phone number format
*/
function validatePhone(phone) {
if (!phone || typeof phone !== 'string') {
return { valid: false, error: 'phone_number must be a string' };
}
const trimmed = phone.trim();
if (trimmed.length < 10 || trimmed.length > 20) {
return { valid: false, error: 'phone_number must be 10-20 characters' };
}
return { valid: true };
}
/**
* Validate OTP code format
*/
function validateOtpCode(code) {
if (!code || typeof code !== 'string') {
return { valid: false, error: 'code must be a string' };
}
if (!/^\d{6}$/.test(code)) {
return { valid: false, error: 'code must be exactly 6 digits' };
}
return { valid: true };
}
/**
* Validate device ID
*/
function validateDeviceId(deviceId) {
if (deviceId === undefined || deviceId === null) {
return { valid: true }; // Optional field
}
if (typeof deviceId !== 'string') {
return { valid: false, error: 'device_id must be a string' };
}
if (deviceId.length > 128) {
return { valid: false, error: 'device_id must be 128 characters or less' };
}
return { valid: true };
}
/**
* Validate and sanitize device info object
* Truncates long values instead of rejecting them (safe for informational fields)
*/
function validateDeviceInfo(deviceInfo) {
if (deviceInfo === undefined || deviceInfo === null) {
return { valid: true }; // Optional field
}
if (typeof deviceInfo !== 'object' || Array.isArray(deviceInfo)) {
return { valid: false, error: 'device_info must be an object' };
}
const maxLength = 100;
const fields = ['platform', 'model', 'os_version', 'app_version', 'language_code', 'timezone'];
for (const field of fields) {
if (deviceInfo[field] !== undefined && deviceInfo[field] !== null) {
if (typeof deviceInfo[field] !== 'string') {
return { valid: false, error: `device_info.${field} must be a string` };
}
// Truncate long values instead of rejecting (safe for informational fields)
if (deviceInfo[field].length > maxLength) {
deviceInfo[field] = deviceInfo[field].substring(0, maxLength);
}
}
}
return { valid: true };
}
/**
* Validate refresh token
*/
function validateRefreshToken(refreshToken) {
if (!refreshToken || typeof refreshToken !== 'string') {
return { valid: false, error: 'refresh_token must be a string' };
}
if (refreshToken.length > 1000) {
return { valid: false, error: 'refresh_token is too long' };
}
return { valid: true };
}
/**
* Validate request body size (prevent DoS)
*/
function validateBodySize(body, maxSize = 10000) {
const bodyStr = JSON.stringify(body);
if (bodyStr.length > maxSize) {
return { valid: false, error: 'Request body too large' };
}
return { valid: true };
}
/**
* Middleware: Validate /auth/request-otp request
*/
function validateRequestOtpBody(req, res, next) {
const { phone_number } = req.body;
// Check body size
const sizeCheck = validateBodySize(req.body, 1000);
if (!sizeCheck.valid) {
return res.status(400).json({ error: sizeCheck.error });
}
// Validate phone
if (!phone_number) {
return res.status(400).json({ error: 'phone_number is required' });
}
const phoneCheck = validatePhone(phone_number);
if (!phoneCheck.valid) {
return res.status(400).json({ error: phoneCheck.error });
}
next();
}
/**
* Middleware: Validate /auth/verify-otp request
*/
function validateVerifyOtpBody(req, res, next) {
const { phone_number, code, device_id, device_info } = req.body;
// Check body size
const sizeCheck = validateBodySize(req.body, 2000);
if (!sizeCheck.valid) {
return res.status(400).json({ error: sizeCheck.error });
}
// Validate required fields
if (!phone_number) {
return res.status(400).json({ error: 'phone_number is required' });
}
if (!code) {
return res.status(400).json({ error: 'code is required' });
}
// Validate each field
const phoneCheck = validatePhone(phone_number);
if (!phoneCheck.valid) {
return res.status(400).json({ error: phoneCheck.error });
}
const codeCheck = validateOtpCode(code);
if (!codeCheck.valid) {
return res.status(400).json({ error: codeCheck.error });
}
const deviceIdCheck = validateDeviceId(device_id);
if (!deviceIdCheck.valid) {
return res.status(400).json({ error: deviceIdCheck.error });
}
const deviceInfoCheck = validateDeviceInfo(device_info);
if (!deviceInfoCheck.valid) {
return res.status(400).json({ error: deviceInfoCheck.error });
}
next();
}
/**
* Middleware: Validate /auth/refresh request
*/
function validateRefreshTokenBody(req, res, next) {
const { refresh_token } = req.body;
// Check body size
const sizeCheck = validateBodySize(req.body, 2000);
if (!sizeCheck.valid) {
return res.status(400).json({ error: sizeCheck.error });
}
if (!refresh_token) {
return res.status(400).json({ error: 'refresh_token is required' });
}
const tokenCheck = validateRefreshToken(refresh_token);
if (!tokenCheck.valid) {
return res.status(400).json({ error: tokenCheck.error });
}
next();
}
/**
* Middleware: Validate /auth/logout request
*/
function validateLogoutBody(req, res, next) {
const { refresh_token } = req.body;
// Check body size
const sizeCheck = validateBodySize(req.body, 2000);
if (!sizeCheck.valid) {
return res.status(400).json({ error: sizeCheck.error });
}
if (!refresh_token) {
return res.status(400).json({ error: 'refresh_token is required' });
}
const tokenCheck = validateRefreshToken(refresh_token);
if (!tokenCheck.valid) {
return res.status(400).json({ error: tokenCheck.error });
}
next();
}
module.exports = {
validateRequestOtpBody,
validateVerifyOtpBody,
validateRefreshTokenBody,
validateLogoutBody,
};

View File

@ -11,6 +11,37 @@ const {
rotateRefreshToken,
revokeRefreshToken,
} = require('../services/tokenService');
// === ADDED FOR RATE LIMITING ===
const {
checkActiveOtpForPhone,
rateLimitRequestOtpByPhone,
rateLimitRequestOtpByIp,
rateLimitVerifyOtpByPhone,
incrementFailedVerify,
} = require('../middleware/rateLimitMiddleware');
// === SECURITY HARDENING: INPUT VALIDATION ===
const {
validateRequestOtpBody,
validateVerifyOtpBody,
validateRefreshTokenBody,
validateLogoutBody,
} = require('../middleware/validation');
// === SECURITY HARDENING: IP/DEVICE RISK ===
const {
isIpBlocked,
calculateRiskScore,
getPreviousAuthInfo,
REQUIRE_OTP_ON_SUSPICIOUS_REFRESH,
} = require('../services/riskScoring');
// === SECURITY HARDENING: AUDIT LOGS & ANOMALY FLAGS ===
const {
logAuthEvent,
logSuspiciousOtpAttempt,
logBlockedIpLogin,
logSuspiciousRefresh,
checkAnomalies,
RISK_LEVELS,
} = require('../services/auditLogger');
const router = express.Router();
@ -60,61 +91,136 @@ function sanitizeDeviceId(deviceId) {
}
// POST /auth/request-otp
router.post('/request-otp', async (req, res) => {
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)'
// === SECURITY HARDENING: INPUT VALIDATION ===
// === ADDED FOR RATE LIMITING ===
// Middleware order matters:
// 1. Input validation
// 2. Check for active OTP first (2-minute no-resend rule)
// 3. Rate limit by phone number
// 4. Rate limit by IP address
// 5. IP blocking check
router.post(
'/request-otp',
validateRequestOtpBody,
checkActiveOtpForPhone,
rateLimitRequestOtpByPhone,
rateLimitRequestOtpByIp,
async (req, res) => {
// === SECURITY HARDENING: IP/DEVICE RISK ===
// Check if IP is blocked
const clientIp = req.ip || req.connection.remoteAddress || 'unknown';
if (isIpBlocked(clientIp)) {
await logBlockedIpLogin(clientIp, req.headers['user-agent']);
return res.status(403).json({
success: false,
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' });
}
// TODO: rate limiting per phone / IP
const normalizedPhone = normalizePhone(phone_number);
const { code } = await createOtp(normalizedPhone);
// 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)'
});
}
// Attempt to send SMS (will fallback to console log if Twilio fails)
const smsResult = await sendOtpSms(normalizedPhone, code);
// Even if SMS fails, we still return success because OTP is generated
// The OTP code is logged to console for testing/development
// 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 and logged to console');
// 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' });
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 res.status(500).json({ error: 'Failed to send OTP via SMS' });
}
return res.json({ ok: true });
} catch (err) {
console.error('request-otp error', err);
return res.status(500).json({ error: 'Internal server error' });
}
return res.json({ ok: true });
} catch (err) {
console.error('request-otp error', err);
return res.status(500).json({ error: 'Internal server error' });
}
});
);
// POST /auth/verify-otp
router.post('/verify-otp', async (req, res) => {
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' });
// === SECURITY HARDENING: INPUT VALIDATION ===
// === ADDED FOR OTP ATTEMPT LIMIT ===
// Rate limit failed verification attempts per phone
router.post(
'/verify-otp',
validateVerifyOtpBody,
rateLimitVerifyOtpByPhone,
async (req, res) => {
// === SECURITY HARDENING: IP/DEVICE RISK ===
// Check if IP is blocked
const clientIp = req.ip || req.connection.remoteAddress || 'unknown';
if (isIpBlocked(clientIp)) {
await logBlockedIpLogin(clientIp, req.headers['user-agent']);
return res.status(403).json({
success: false,
message: 'Access denied from this location.',
});
}
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 normalizedPhone = normalizePhone(phone_number);
const result = await verifyOtp(normalizedPhone, code);
if (!result.ok) {
return res.status(400).json({ error: 'Invalid or expired OTP' });
}
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
}
// Log suspicious OTP attempt
await logSuspiciousOtpAttempt(
normalizedPhone,
clientIp,
req.headers['user-agent'],
result.reason || 'invalid'
);
return res.status(400).json({
success: false,
message: 'OTP invalid or expired. Please request a new one.'
});
}
// find or create user
let user;
@ -181,34 +287,53 @@ router.post('/verify-otp', async (req, res) => {
]
);
// Log authentication event
try {
await db.query(
`INSERT INTO auth_audit (user_id, action, status, device_id, ip_address, user_agent, meta)
VALUES ($1, 'login', 'success', $2, $3, $4, $5)`,
[
user.id,
devId,
req.ip,
req.headers['user-agent'],
JSON.stringify({
is_new_device: isNewDevice,
is_new_account: !isExistingAccount,
platform: device_info?.platform || 'unknown',
}),
]
);
} catch (auditErr) {
console.error('Failed to log auth audit', auditErr);
// Don't fail the login if audit logging fails
}
// === SECURITY HARDENING: IP/DEVICE RISK ===
// Calculate risk score for this login
const previousAuth = await getPreviousAuthInfo(user.id, devId);
const riskScore = await calculateRiskScore({
userId: user.id,
currentIp: clientIp,
currentUserAgent: req.headers['user-agent'],
currentDeviceId: devId,
currentDeviceInfo: device_info,
previousIp: previousAuth.previousIp,
previousUserAgent: previousAuth.previousUserAgent,
previousDeviceId: previousAuth.previousDeviceId,
});
const accessToken = signAccessToken(user);
// === SECURITY HARDENING: AUDIT LOGS & ANOMALY FLAGS ===
// Log authentication event with risk level
await logAuthEvent({
userId: user.id,
action: 'login',
status: 'success',
riskLevel: riskScore.isSuspicious
? (riskScore.score >= 50 ? RISK_LEVELS.HIGH_RISK : RISK_LEVELS.SUSPICIOUS)
: RISK_LEVELS.INFO,
deviceId: devId,
ipAddress: clientIp,
userAgent: req.headers['user-agent'],
meta: {
is_new_device: isNewDevice,
is_new_account: !isExistingAccount,
platform: device_info?.platform || 'unknown',
risk_score: riskScore.score,
risk_reasons: riskScore.reasons,
},
});
// Check for anomalies
await checkAnomalies(user.id, 'otp_verify', clientIp);
// === SECURITY HARDENING: ACCESS TOKEN REPLAY MITIGATION ===
// Issue access token with high_assurance flag if this is a fresh OTP verification
// This allows step-up auth for sensitive actions
const accessToken = signAccessToken(user, { highAssurance: true });
const refreshToken = await issueRefreshToken({
userId: user.id,
deviceId: devId,
userAgent: req.headers['user-agent'],
ip: req.ip,
ip: clientIp,
});
const needsProfile = !user.name || !user.user_type;
@ -236,35 +361,103 @@ router.post('/verify-otp', async (req, res) => {
});
// POST /auth/refresh
router.post('/refresh', async (req, res) => {
try {
const { refresh_token } = req.body;
if (!refresh_token) {
return res.status(400).json({ error: 'refresh_token is required' });
}
// === SECURITY HARDENING: INPUT VALIDATION ===
// === SECURITY HARDENING: REFRESH TOKEN THEFT MITIGATION ===
// === SECURITY HARDENING: CSRF (FUTURE-PROOFING) ===
// NOTE: If tokens are ever moved to HTTP-only cookies, CSRF protection becomes mandatory.
// Consider implementing: SameSite cookie attribute + CSRF token validation
router.post(
'/refresh',
validateRefreshTokenBody,
async (req, res) => {
try {
const { refresh_token } = req.body;
const clientIp = req.ip || req.connection.remoteAddress || 'unknown';
const verification = await verifyRefreshToken(refresh_token);
if (!verification || verification.reuseDetected) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
// === SECURITY HARDENING: IP/DEVICE RISK ===
// Check if IP is blocked
if (isIpBlocked(clientIp)) {
await logBlockedIpLogin(clientIp, req.headers['user-agent']);
return res.status(403).json({ error: 'Access denied from this location.' });
}
const { userId, deviceId } = verification;
const verification = await verifyRefreshToken(refresh_token);
if (!verification || verification.reuseDetected) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
const { rows } = await db.query(
`SELECT id, phone_number, name, role, user_type FROM users WHERE id = $1`,
[userId]
);
if (rows.length === 0) {
return res.status(401).json({ error: 'User not found' });
}
const { userId, deviceId, row: tokenRow } = verification;
const user = rows[0];
const newAccess = signAccessToken(user);
const newRefresh = await rotateRefreshToken({
tokenRow: verification.row,
userAgent: req.headers['user-agent'],
ip: req.ip,
});
const { rows } = await db.query(
`SELECT id, phone_number, name, role, user_type FROM users WHERE id = $1`,
[userId]
);
if (rows.length === 0) {
return res.status(401).json({ error: 'User not found' });
}
const user = rows[0];
// === SECURITY HARDENING: REFRESH TOKEN THEFT MITIGATION ===
// Calculate risk score for refresh from different environment
const previousAuth = await getPreviousAuthInfo(userId, deviceId);
const riskScore = await calculateRiskScore({
userId,
currentIp: clientIp,
currentUserAgent: req.headers['user-agent'],
currentDeviceId: deviceId,
currentDeviceInfo: null,
previousIp: tokenRow.ip_address || previousAuth.previousIp,
previousUserAgent: tokenRow.user_agent || previousAuth.previousUserAgent,
previousDeviceId: deviceId,
});
// === SECURITY HARDENING: REFRESH TOKEN THEFT MITIGATION ===
// If refresh is from suspicious environment, log it
if (riskScore.isSuspicious) {
await logSuspiciousRefresh(
userId,
deviceId,
clientIp,
req.headers['user-agent'],
riskScore.score,
riskScore.reasons
);
// Optionally require OTP re-verification for suspicious refreshes
if (REQUIRE_OTP_ON_SUSPICIOUS_REFRESH) {
return res.status(403).json({
error: 'step_up_required',
message: 'Additional verification required. Please verify your OTP.',
requires_otp: true,
});
}
}
const newAccess = signAccessToken(user);
const newRefresh = await rotateRefreshToken({
tokenRow,
userAgent: req.headers['user-agent'],
ip: clientIp,
});
// === SECURITY HARDENING: AUDIT LOGS & ANOMALY FLAGS ===
// Log refresh event
await logAuthEvent({
userId,
action: 'token_refresh',
status: 'success',
riskLevel: riskScore.isSuspicious
? (riskScore.score >= 50 ? RISK_LEVELS.HIGH_RISK : RISK_LEVELS.SUSPICIOUS)
: RISK_LEVELS.INFO,
deviceId,
ipAddress: clientIp,
userAgent: req.headers['user-agent'],
meta: {
risk_score: riskScore.score,
risk_reasons: riskScore.reasons,
},
});
// Update device last_seen_at when tokens are refreshed
try {
@ -285,25 +478,39 @@ router.post('/refresh', async (req, res) => {
});
// POST /auth/logout
router.post('/logout', async (req, res) => {
try {
const { refresh_token } = req.body;
if (!refresh_token) {
return res.status(400).json({ error: 'refresh_token is required' });
// === SECURITY HARDENING: INPUT VALIDATION ===
router.post(
'/logout',
validateLogoutBody,
async (req, res) => {
try {
const { refresh_token } = req.body;
const clientIp = req.ip || req.connection.remoteAddress || 'unknown';
const info = await verifyRefreshToken(refresh_token);
if (!info || info.reuseDetected) {
return res.status(200).json({ ok: true }); // already invalid -> treat as logged out
}
await revokeRefreshToken(info.userId, info.deviceId);
// === SECURITY HARDENING: AUDIT LOGS & ANOMALY FLAGS ===
await logAuthEvent({
userId: info.userId,
action: 'logout',
status: 'success',
riskLevel: RISK_LEVELS.INFO,
deviceId: info.deviceId,
ipAddress: clientIp,
userAgent: req.headers['user-agent'],
});
return res.json({ ok: true });
} catch (err) {
console.error('logout error', err);
return res.status(500).json({ error: 'Internal server error' });
}
const info = await verifyRefreshToken(refresh_token);
if (!info || info.reuseDetected) {
return res.status(200).json({ ok: true }); // already invalid -> treat as logged out
}
await revokeRefreshToken(info.userId, info.deviceId);
return res.json({ ok: true });
} catch (err) {
console.error('logout error', err);
return res.status(500).json({ error: 'Internal server error' });
}
});
);
module.exports = router;

213
src/services/auditLogger.js Normal file
View File

@ -0,0 +1,213 @@
// src/services/auditLogger.js
// === SECURITY HARDENING: AUDIT LOGS & ANOMALY FLAGS ===
// Enhanced audit logging with risk levels and anomaly detection
const db = require('../db');
// === SECURITY HARDENING: AUDIT LOGS & ANOMALY FLAGS ===
// Risk levels for audit events
const RISK_LEVELS = {
INFO: 'INFO',
SUSPICIOUS: 'SUSPICIOUS',
HIGH_RISK: 'HIGH_RISK',
};
/**
* Log an authentication event with risk level
*
* @param {Object} params
* @param {string} params.userId - User ID (can be null for failed attempts)
* @param {string} params.action - Action type (e.g., 'login', 'token_refresh', 'otp_request', 'otp_verify')
* @param {string} params.status - Status ('success', 'failed', 'blocked')
* @param {string} params.riskLevel - Risk level (INFO, SUSPICIOUS, HIGH_RISK)
* @param {string} params.deviceId - Device identifier
* @param {string} params.ipAddress - IP address
* @param {string} params.userAgent - User agent
* @param {Object} params.meta - Additional metadata
*/
async function logAuthEvent({
userId = null,
action,
status,
riskLevel = RISK_LEVELS.INFO,
deviceId = null,
ipAddress = null,
userAgent = null,
meta = {},
}) {
try {
// Ensure risk_level column exists (add if not present)
await ensureRiskLevelColumn();
await db.query(
`INSERT INTO auth_audit (
user_id, action, status, risk_level, device_id, ip_address, user_agent, meta
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
userId,
action,
status,
riskLevel,
deviceId,
ipAddress,
userAgent,
JSON.stringify(meta),
]
);
} catch (err) {
console.error('Failed to log auth event:', err);
// Don't throw - audit logging should not break the main flow
}
}
/**
* Ensure risk_level column exists in auth_audit table
*/
async function ensureRiskLevelColumn() {
try {
await db.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'auth_audit' AND column_name = 'risk_level'
) THEN
ALTER TABLE auth_audit ADD COLUMN risk_level VARCHAR(20) DEFAULT 'INFO';
CREATE INDEX IF NOT EXISTS idx_auth_audit_risk_level ON auth_audit(risk_level);
END IF;
END $$;
`);
} catch (err) {
// Column might already exist or table might not exist yet
// This is fine, the insert will handle it
}
}
/**
* Log suspicious OTP verification attempts
*/
async function logSuspiciousOtpAttempt(phone, ipAddress, userAgent, reason) {
await logAuthEvent({
userId: null, // Phone not yet linked to user
action: 'otp_verify',
status: 'failed',
riskLevel: RISK_LEVELS.SUSPICIOUS,
ipAddress,
userAgent,
meta: {
phone: phone.replace(/\d(?=\d{4})/g, '*'), // Mask phone
reason,
message: 'Multiple failed OTP attempts detected',
},
});
}
/**
* Log blocked login from disallowed IP
*/
async function logBlockedIpLogin(ipAddress, userAgent, userId = null) {
await logAuthEvent({
userId,
action: 'login',
status: 'blocked',
riskLevel: RISK_LEVELS.HIGH_RISK,
ipAddress,
userAgent,
meta: {
reason: 'blocked_ip_range',
message: 'Login attempt from blocked IP range',
},
});
}
/**
* Log suspicious refresh event
*/
async function logSuspiciousRefresh(userId, deviceId, ipAddress, userAgent, riskScore, reasons) {
await logAuthEvent({
userId,
action: 'token_refresh',
status: 'success', // Token was issued, but suspicious
riskLevel: riskScore >= 50 ? RISK_LEVELS.HIGH_RISK : RISK_LEVELS.SUSPICIOUS,
deviceId,
ipAddress,
userAgent,
meta: {
risk_score: riskScore,
risk_reasons: reasons,
message: 'Token refresh from suspicious environment',
},
});
}
/**
* Log step-up authentication requirement
*/
async function logStepUpAuthRequired(userId, action, ipAddress, userAgent) {
await logAuthEvent({
userId,
action: `step_up_${action}`,
status: 'failed',
riskLevel: RISK_LEVELS.SUSPICIOUS,
ipAddress,
userAgent,
meta: {
reason: 'step_up_required',
message: 'Sensitive action requires step-up authentication',
},
});
}
/**
* Helper to check for anomaly patterns (for future alerting)
* This can be extended to query recent events and detect patterns
*/
async function checkAnomalies(userId, action, ipAddress) {
try {
// Example: Check for multiple failed attempts in short time
if (action === 'otp_verify') {
const result = await db.query(
`SELECT COUNT(*) as count
FROM auth_audit
WHERE user_id = $1
AND action = 'otp_verify'
AND status = 'failed'
AND risk_level IN ('SUSPICIOUS', 'HIGH_RISK')
AND created_at > NOW() - INTERVAL '1 hour'`,
[userId]
);
const failedCount = parseInt(result.rows[0]?.count || 0, 10);
if (failedCount >= 5) {
// TODO: Trigger alert (email, webhook, etc.)
console.warn(`[ANOMALY] User ${userId} has ${failedCount} failed OTP attempts in last hour`);
}
}
} catch (err) {
console.error('Error checking anomalies:', err);
}
}
/**
* TODO: Integrate with external alerting systems
* Example implementations:
*
* async function sendAlert(level, message, metadata) {
* // Send to PagerDuty, Slack, email, etc.
* if (level === 'HIGH_RISK') {
* await sendToPagerDuty(message, metadata);
* }
* }
*/
module.exports = {
logAuthEvent,
logSuspiciousOtpAttempt,
logBlockedIpLogin,
logSuspiciousRefresh,
logStepUpAuthRequired,
checkAnomalies,
RISK_LEVELS,
};

184
src/services/jwtKeys.js Normal file
View File

@ -0,0 +1,184 @@
// src/services/jwtKeys.js
// === SECURITY HARDENING: JWT KEY ROTATION ===
// JWT key management with support for key rotation and multiple signing keys
/**
* JWT Key Management
*
* Supports:
* - Multiple signing keys with key IDs (kid)
* - Active key for signing new tokens
* - Multiple verification keys for token rotation
* - Key rotation without breaking existing tokens
*
* Configuration:
* - JWT_ACTIVE_KEY_ID: The key ID to use for signing new tokens (default: '1')
* - JWT_KEYS_JSON: JSON object mapping key IDs to secrets
* Example: {"1": "secret1", "2": "secret2"}
* - JWT_ISSUER: Issuer claim (iss) for tokens (default: 'farm-auth-service')
* - JWT_AUDIENCE: Audience claim (aud) for tokens (default: 'mobile-app')
*
* TODO: In production, load keys from a secrets manager (AWS Secrets Manager,
* HashiCorp Vault, etc.) instead of environment variables
*/
const crypto = require('crypto');
// === SECURITY HARDENING: JWT KEY ROTATION ===
// Load keys from environment
const ACTIVE_KEY_ID = process.env.JWT_ACTIVE_KEY_ID || '1';
const JWT_ISSUER = process.env.JWT_ISSUER || 'farm-auth-service';
const JWT_AUDIENCE = process.env.JWT_AUDIENCE || 'mobile-app';
// Parse keys from environment
// Format: JWT_KEYS_JSON='{"1":"secret1","2":"secret2"}'
// OR use legacy single keys: JWT_ACCESS_SECRET, JWT_REFRESH_SECRET
let keys = {};
function loadKeys() {
// Try new format first (JWT_KEYS_JSON)
if (process.env.JWT_KEYS_JSON) {
try {
keys = JSON.parse(process.env.JWT_KEYS_JSON);
} catch (err) {
console.error('Failed to parse JWT_KEYS_JSON:', err);
keys = {};
}
}
// Fallback to legacy format for backward compatibility
if (Object.keys(keys).length === 0) {
if (process.env.JWT_ACCESS_SECRET) {
keys['1'] = process.env.JWT_ACCESS_SECRET;
}
if (process.env.JWT_REFRESH_SECRET && !keys['1']) {
keys['1'] = process.env.JWT_REFRESH_SECRET;
}
}
// Ensure we have at least one key
if (Object.keys(keys).length === 0) {
throw new Error('No JWT keys configured. Set JWT_KEYS_JSON or JWT_ACCESS_SECRET');
}
// Ensure active key exists
if (!keys[ACTIVE_KEY_ID]) {
console.warn(`Active key ID ${ACTIVE_KEY_ID} not found, using first available key`);
const firstKeyId = Object.keys(keys)[0];
return { keys, activeKeyId: firstKeyId };
}
return { keys, activeKeyId: ACTIVE_KEY_ID };
}
const { keys: loadedKeys, activeKeyId } = loadKeys();
/**
* Get the secret for a specific key ID
*/
function getKeySecret(keyId) {
return loadedKeys[keyId] || null;
}
/**
* Get the active key ID and secret for signing new tokens
*/
function getActiveKey() {
return {
keyId: activeKeyId,
secret: loadedKeys[activeKeyId],
};
}
/**
* Get all keys (for verification during rotation)
*/
function getAllKeys() {
return loadedKeys;
}
/**
* Get issuer claim
*/
function getIssuer() {
return JWT_ISSUER;
}
/**
* Get audience claim
*/
function getAudience() {
return JWT_AUDIENCE;
}
/**
* Validate JWT claims (iss, aud, exp, iat, nbf)
*/
function validateTokenClaims(payload, options = {}) {
const {
allowExpired = false,
clockSkewSeconds = 60, // Allow 60 seconds clock skew
} = options;
const now = Math.floor(Date.now() / 1000);
const skew = clockSkewSeconds;
// Validate issuer
if (payload.iss && payload.iss !== JWT_ISSUER) {
return { valid: false, reason: 'invalid_issuer' };
}
// Validate audience
if (payload.aud && payload.aud !== JWT_AUDIENCE) {
return { valid: false, reason: 'invalid_audience' };
}
// Validate expiration
if (payload.exp) {
if (!allowExpired && payload.exp < now - skew) {
return { valid: false, reason: 'expired' };
}
}
// Validate issued at (should not be in the future beyond clock skew)
if (payload.iat) {
if (payload.iat > now + skew) {
return { valid: false, reason: 'issued_in_future' };
}
}
// Validate not before (if present)
if (payload.nbf) {
if (payload.nbf > now + skew) {
return { valid: false, reason: 'not_yet_valid' };
}
}
return { valid: true };
}
/**
* TODO: Load keys from secrets manager
* Example implementation for AWS Secrets Manager:
*
* async function loadKeysFromSecretsManager() {
* const AWS = require('aws-sdk');
* const secretsManager = new AWS.SecretsManager();
*
* const secret = await secretsManager.getSecretValue({
* SecretId: process.env.JWT_SECRETS_ARN
* }).promise();
*
* return JSON.parse(secret.SecretString);
* }
*/
module.exports = {
getKeySecret,
getActiveKey,
getAllKeys,
getIssuer,
getAudience,
validateTokenClaims,
};

View File

@ -1,9 +1,13 @@
// src/services/otpService.js
const bcrypt = require('bcrypt');
const db = require('../db');
const { markActiveOtp } = require('../middleware/rateLimitMiddleware');
const OTP_EXPIRY_MS = 10 * 60 * 1000;
const MAX_OTP_ATTEMPTS = Number(process.env.OTP_MAX_ATTEMPTS || 5);
// === ADDED FOR 2-MIN OTP VALIDITY & NO-RESEND ===
// OTP validity changed from 10 minutes to 2 minutes (120 seconds)
const OTP_TTL_SECONDS = parseInt(process.env.OTP_TTL_SECONDS || '120', 10); // 2 minutes
const OTP_EXPIRY_MS = OTP_TTL_SECONDS * 1000;
const MAX_OTP_ATTEMPTS = Number(process.env.OTP_VERIFY_MAX_ATTEMPTS || process.env.OTP_MAX_ATTEMPTS || 5);
let otpTableReadyPromise;
@ -65,6 +69,7 @@ async function createOtp(phoneNumber) {
const expiresAt = new Date(Date.now() + OTP_EXPIRY_MS);
const otpHash = await bcrypt.hash(code, 10);
// === ADDED FOR 2-MIN OTP VALIDITY & NO-RESEND ===
// Delete any existing OTPs for this phone number
await db.query(
'DELETE FROM otp_codes WHERE phone_number = $1',
@ -78,6 +83,15 @@ async function createOtp(phoneNumber) {
[phoneNumber, otpHash, expiresAt]
);
// === ADDED FOR 2-MIN OTP VALIDITY & NO-RESEND ===
// Mark OTP as active in Redis/memory store to prevent resend within 2 minutes
try {
await markActiveOtp(phoneNumber, OTP_TTL_SECONDS);
} catch (err) {
console.error('Failed to mark active OTP in rate limiter:', err);
// Don't fail OTP creation if rate limiter fails
}
return { code };
}
@ -98,8 +112,10 @@ async function verifyOtp(phoneNumber, code) {
[phoneNumber]
);
// === ADDED FOR OTP ATTEMPT LIMIT ===
// Generic error message to avoid leaking information
if (result.rows.length === 0) {
return { ok: false };
return { ok: false, reason: 'not_found' };
}
const otpRecord = result.rows[0];
@ -107,26 +123,32 @@ async function verifyOtp(phoneNumber, code) {
// Check if expired
if (new Date() > new Date(otpRecord.expires_at)) {
await db.query('DELETE FROM otp_codes WHERE id = $1', [otpRecord.id]);
return { ok: false };
return { ok: false, reason: 'expired' };
}
// === ADDED FOR OTP ATTEMPT LIMIT ===
// Check if max attempts exceeded
if (otpRecord.attempt_count >= MAX_OTP_ATTEMPTS) {
await db.query('DELETE FROM otp_codes WHERE id = $1', [otpRecord.id]);
return { ok: false };
return { ok: false, reason: 'max_attempts' };
}
const matches = await bcrypt.compare(code, otpRecord.otp_hash);
// Check if code matches
if (!matches) {
// === ADDED FOR OTP ATTEMPT LIMIT ===
// Increment attempt count
await db.query(
'UPDATE otp_codes SET attempt_count = attempt_count + 1 WHERE id = $1',
[otpRecord.id]
);
return { ok: false };
return { ok: false, reason: 'invalid' };
}
// === ADDED FOR 2-MIN OTP VALIDITY & NO-RESEND ===
// Delete OTP once verified to prevent reuse
// Also clears the active OTP marker (via deletion, Redis TTL will handle cleanup)
await db.query('DELETE FROM otp_codes WHERE id = $1', [otpRecord.id]);
return { ok: true };

153
src/services/redisClient.js Normal file
View File

@ -0,0 +1,153 @@
// src/services/redisClient.js
// === ADDED FOR RATE LIMITING ===
// Redis client setup for rate limiting and OTP tracking
const redis = require('redis');
// Redis connection configuration
// Supports REDIS_URL or REDIS_HOST + REDIS_PORT
const redisUrl = process.env.REDIS_URL;
const redisHost = process.env.REDIS_HOST || 'localhost';
const redisPort = process.env.REDIS_PORT || 6379;
const redisPassword = process.env.REDIS_PASSWORD || undefined;
let redisClient = null;
let redisClientReady = false;
let redisInitAttempted = false;
let redisInitFailed = false;
let errorLogged = false;
/**
* Initialize Redis client
* Falls back gracefully if Redis is not available
*/
async function initRedis() {
// If already initialized and ready, return it
if (redisClient && redisClientReady) {
return redisClient;
}
// If we've already attempted and failed, don't try again
if (redisInitFailed) {
return null;
}
// If we've already attempted and client exists but not ready, return it (don't retry)
if (redisInitAttempted && redisClient) {
return redisClient;
}
redisInitAttempted = true;
try {
const config = redisUrl
? { url: redisUrl }
: {
socket: {
host: redisHost,
port: parseInt(redisPort, 10),
reconnectStrategy: false, // Disable automatic reconnection
},
password: redisPassword,
};
redisClient = redis.createClient(config);
// Track if we've logged the error to avoid spam
redisClient.on('error', (err) => {
if (!errorLogged) {
console.warn('⚠️ Redis not available. Rate limiting will use in-memory fallback.');
console.warn(' To enable Redis, set REDIS_URL or REDIS_HOST/REDIS_PORT');
console.warn(' Error:', err.message);
errorLogged = true;
}
redisClientReady = false;
redisInitFailed = true;
});
redisClient.on('connect', () => {
if (!errorLogged) {
console.log('Redis Client: Connecting...');
}
});
redisClient.on('ready', () => {
console.log('✅ Redis Client: Ready');
redisClientReady = true;
redisInitFailed = false;
errorLogged = false; // Reset error flag on successful connection
});
redisClient.on('end', () => {
if (redisClientReady) {
console.log('Redis Client: Connection ended');
}
redisClientReady = false;
});
await redisClient.connect();
return redisClient;
} catch (err) {
if (!errorLogged) {
console.warn('⚠️ Redis not available. Rate limiting will use in-memory fallback.');
console.warn(' To enable Redis, set REDIS_URL or REDIS_HOST/REDIS_PORT');
errorLogged = true;
}
redisClientReady = false;
redisInitFailed = true;
// Clean up the client if connection failed
if (redisClient) {
try {
await redisClient.quit().catch(() => {});
} catch (e) {
// Ignore cleanup errors
}
redisClient = null;
}
return null;
}
}
/**
* Get Redis client (initialize if needed)
*/
async function getRedisClient() {
// If we've already failed to connect, don't try again
if (redisInitFailed) {
return null;
}
if (!redisClient && !redisInitAttempted) {
await initRedis();
}
return redisClient;
}
/**
* Check if Redis is available and ready
*/
function isRedisReady() {
return redisClientReady && redisClient;
}
/**
* Gracefully close Redis connection
*/
async function closeRedis() {
if (redisClient) {
try {
await redisClient.quit();
redisClient = null;
redisClientReady = false;
} catch (err) {
console.error('Error closing Redis connection:', err);
}
}
}
module.exports = {
initRedis,
getRedisClient,
isRedisReady,
closeRedis,
};

262
src/services/riskScoring.js Normal file
View File

@ -0,0 +1,262 @@
// src/services/riskScoring.js
// === SECURITY HARDENING: IP/DEVICE RISK ===
// Basic risk scoring for IP addresses and device fingerprinting
const crypto = require('crypto');
const db = require('../db');
// === SECURITY HARDENING: IP/DEVICE RISK ===
// Configuration for suspicious IP ranges
// Can be set via environment variable as comma-separated CIDR blocks
// Example: BLOCKED_IP_RANGES=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
const BLOCKED_IP_RANGES = (process.env.BLOCKED_IP_RANGES || '')
.split(',')
.map(r => r.trim())
.filter(Boolean);
// === SECURITY HARDENING: IP/DEVICE RISK ===
// Whether to require OTP re-verification on suspicious refresh
const REQUIRE_OTP_ON_SUSPICIOUS_REFRESH = process.env.REQUIRE_OTP_ON_SUSPICIOUS_REFRESH === 'true';
/**
* Check if an IP address is in a blocked CIDR range
* Simple implementation for common private ranges
*/
function isIpBlocked(ip) {
if (!ip || ip === 'unknown') return false;
// Check against configured blocked ranges
for (const range of BLOCKED_IP_RANGES) {
if (isIpInRange(ip, range)) {
return true;
}
}
// Block common private/test ranges by default
const privateRanges = [
{ start: '10.0.0.0', end: '10.255.255.255' },
{ start: '172.16.0.0', end: '172.31.255.255' },
{ start: '192.168.0.0', end: '192.168.255.255' },
{ start: '127.0.0.0', end: '127.255.255.255' },
];
for (const range of privateRanges) {
if (isIpInRange(ip, range.start, range.end)) {
return true;
}
}
return false;
}
/**
* Simple IP range check (for IPv4)
*/
function isIpInRange(ip, startOrCidr, end) {
if (!ip) return false;
// Handle CIDR notation
if (startOrCidr.includes('/')) {
const [network, prefix] = startOrCidr.split('/');
const mask = parseInt(prefix, 10);
const ipNum = ipToNumber(ip);
const networkNum = ipToNumber(network);
const maskNum = (0xFFFFFFFF << (32 - mask)) >>> 0;
return (ipNum & maskNum) === (networkNum & maskNum);
}
// Handle start-end range
if (end) {
const ipNum = ipToNumber(ip);
const startNum = ipToNumber(startOrCidr);
const endNum = ipToNumber(end);
return ipNum >= startNum && ipNum <= endNum;
}
return false;
}
/**
* Convert IPv4 address to number
*/
function ipToNumber(ip) {
const parts = ip.split('.');
if (parts.length !== 4) return 0;
return parts.reduce((acc, part) => (acc << 8) + parseInt(part, 10), 0) >>> 0;
}
/**
* Create a device fingerprint from user agent and device info
*/
function createDeviceFingerprint(userAgent, deviceInfo = {}) {
const components = [
userAgent || 'unknown',
deviceInfo.platform || 'unknown',
deviceInfo.model || 'unknown',
deviceInfo.os_version || 'unknown',
];
const fingerprint = components.join('|');
return crypto.createHash('sha256').update(fingerprint).digest('hex').slice(0, 32);
}
/**
* Calculate risk score for a login/refresh attempt
* Returns: { score: number (0-100), reasons: string[], isSuspicious: boolean }
*/
async function calculateRiskScore({
userId,
currentIp,
currentUserAgent,
currentDeviceId,
currentDeviceInfo,
previousIp,
previousUserAgent,
previousDeviceId,
}) {
const reasons = [];
let score = 0;
// Check if IP is blocked
if (isIpBlocked(currentIp)) {
score += 50;
reasons.push('blocked_ip_range');
}
// Check for IP change
if (previousIp && currentIp !== previousIp) {
// Check if IP changed significantly (different subnet)
const ipChanged = !areIpsSimilar(currentIp, previousIp);
if (ipChanged) {
score += 20;
reasons.push('ip_change');
}
}
// Check for device change
if (previousDeviceId && currentDeviceId !== previousDeviceId) {
score += 30;
reasons.push('device_change');
}
// Check for user agent change
if (previousUserAgent && currentUserAgent !== previousUserAgent) {
const uaChanged = !areUserAgentsSimilar(currentUserAgent, previousUserAgent);
if (uaChanged) {
score += 15;
reasons.push('user_agent_change');
}
}
// Check for new device (no previous data)
if (!previousIp && !previousDeviceId) {
// First login is normal, but still track it
reasons.push('first_login');
}
const isSuspicious = score >= 30; // Threshold for suspicious activity
return {
score: Math.min(score, 100),
reasons,
isSuspicious,
};
}
/**
* Check if two IPs are in similar ranges (same /24 subnet)
*/
function areIpsSimilar(ip1, ip2) {
if (!ip1 || !ip2) return false;
const num1 = ipToNumber(ip1);
const num2 = ipToNumber(ip2);
// Same /24 subnet (first 24 bits match)
return (num1 >>> 8) === (num2 >>> 8);
}
/**
* Check if two user agents are similar (basic check)
*/
function areUserAgentsSimilar(ua1, ua2) {
if (!ua1 || !ua2) return false;
// Extract browser/OS from user agent (simplified)
const extractKey = (ua) => {
const lower = ua.toLowerCase();
if (lower.includes('chrome')) return 'chrome';
if (lower.includes('firefox')) return 'firefox';
if (lower.includes('safari')) return 'safari';
if (lower.includes('android')) return 'android';
if (lower.includes('ios')) return 'ios';
return 'unknown';
};
return extractKey(ua1) === extractKey(ua2);
}
/**
* Get previous login/refresh information for a user
*/
async function getPreviousAuthInfo(userId, deviceId) {
try {
// Get most recent successful auth for this user
const result = await db.query(
`SELECT ip_address, user_agent, device_id
FROM auth_audit
WHERE user_id = $1
AND status = 'success'
AND action IN ('login', 'token_refresh')
ORDER BY created_at DESC
LIMIT 1`,
[userId]
);
if (result.rows.length > 0) {
return {
previousIp: result.rows[0].ip_address,
previousUserAgent: result.rows[0].user_agent,
previousDeviceId: result.rows[0].device_id,
};
}
// Fallback: check refresh_tokens for this device
if (deviceId) {
const tokenResult = await db.query(
`SELECT ip_address, user_agent
FROM refresh_tokens
WHERE user_id = $1 AND device_id = $2
ORDER BY created_at DESC
LIMIT 1`,
[userId, deviceId]
);
if (tokenResult.rows.length > 0) {
return {
previousIp: tokenResult.rows[0].ip_address,
previousUserAgent: tokenResult.rows[0].user_agent,
previousDeviceId: deviceId,
};
}
}
return {
previousIp: null,
previousUserAgent: null,
previousDeviceId: null,
};
} catch (err) {
console.error('Error getting previous auth info:', err);
return {
previousIp: null,
previousUserAgent: null,
previousDeviceId: null,
};
}
}
module.exports = {
isIpBlocked,
createDeviceFingerprint,
calculateRiskScore,
getPreviousAuthInfo,
REQUIRE_OTP_ON_SUSPICIOUS_REFRESH,
};

View File

@ -1,5 +1,7 @@
// src/services/smsService.js
const twilio = require('twilio');
// === SECURITY HARDENING: OTP LOGGING ===
const { logOtpForDebug, logOtpEvent } = require('../utils/otpLogger');
const {
TWILIO_ACCOUNT_SID,
@ -13,7 +15,7 @@ let client = null;
if (TWILIO_ACCOUNT_SID && TWILIO_AUTH_TOKEN) {
client = twilio(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN);
} else {
console.warn('⚠️ Twilio credentials are not set. SMS sending will be disabled. OTP will be logged to console.');
console.warn('⚠️ Twilio credentials are not set. SMS sending will be disabled.');
}
/**
@ -22,12 +24,17 @@ if (TWILIO_ACCOUNT_SID && TWILIO_AUTH_TOKEN) {
* @param {string} code OTP code
*/
async function sendOtpSms(toPhone, code) {
// === SECURITY HARDENING: OTP LOGGING ===
// Use safe OTP logging (only in development)
if (!client) {
console.log('📱 DEBUG OTP (Twilio not configured):', toPhone, 'Code:', code);
return;
logOtpForDebug(toPhone, code);
logOtpEvent(toPhone, false);
return { success: false, error: 'Twilio not configured' };
}
const messageBody = `Your verification code is ${code}. It will expire in 10 minutes.`;
// === ADDED FOR 2-MIN OTP VALIDITY & NO-RESEND ===
// OTP validity changed to 2 minutes
const messageBody = `Your verification code is ${code}. It will expire in 2 minutes.`;
const msgConfig = {
body: messageBody,
@ -40,14 +47,17 @@ async function sendOtpSms(toPhone, code) {
} else if (TWILIO_FROM_NUMBER) {
msgConfig.from = TWILIO_FROM_NUMBER;
} else {
console.warn('⚠️ Neither TWILIO_MESSAGING_SERVICE_SID nor TWILIO_FROM_NUMBER is set. OTP logged to console.');
console.log('📱 DEBUG OTP:', toPhone, 'Code:', code);
return;
// === SECURITY HARDENING: OTP LOGGING ===
// Use safe logging instead of direct console.log
logOtpForDebug(toPhone, code);
logOtpEvent(toPhone, false);
return { success: false, error: 'No Twilio sender configured' };
}
try {
const msg = await client.messages.create(msgConfig);
console.log('✅ Twilio SMS sent, SID:', msg.sid);
logOtpEvent(toPhone, true);
return { success: true, sid: msg.sid };
} catch (err) {
const errorMessage = err.message || 'Unknown error';
@ -59,13 +69,16 @@ async function sendOtpSms(toPhone, code) {
const isTrialAccountError = errorMessage.includes('Trial account');
if (isShortCodeError) {
console.warn('⚠️ Cannot send to short codes (5-6 digit numbers). OTP logged to console for testing.');
console.warn('⚠️ Cannot send to short codes (5-6 digit numbers).');
} else if (isUnverifiedNumberError || isTrialAccountError) {
console.warn('⚠️ Trial account limitation: Verify the number at https://console.twilio.com/us1/develop/phone-numbers/manage/verified');
console.warn('⚠️ Or upgrade to a paid account to send to any number.');
}
console.log('📱 DEBUG OTP (fallback):', toPhone, 'Code:', code);
// === SECURITY HARDENING: OTP LOGGING ===
// Use safe logging for fallback
logOtpForDebug(toPhone, code);
logOtpEvent(toPhone, false);
return { success: false, error: errorMessage };
}
}

View File

@ -4,23 +4,56 @@ const bcrypt = require('bcrypt');
const { randomUUID } = require('crypto');
const db = require('../db');
const config = require('../config');
// === SECURITY HARDENING: JWT KEY ROTATION ===
const {
getActiveKey,
getKeySecret,
getAllKeys,
getIssuer,
getAudience,
validateTokenClaims,
} = require('./jwtKeys');
const ACCESS_SECRET = config.jwtAccessSecret;
const REFRESH_SECRET = config.jwtRefreshSecret;
const ACCESS_TTL = config.jwtAccessTtl;
const REFRESH_TTL = config.jwtRefreshTtl;
const REFRESH_MAX_IDLE_MS = config.refreshMaxIdleMinutes * 60 * 1000;
function signAccessToken(user) {
// === SECURITY HARDENING: JWT KEY ROTATION ===
// Get active key for signing
const { keyId: ACTIVE_KEY_ID, secret: ACTIVE_SECRET } = getActiveKey();
// For refresh tokens, use a separate key if available, otherwise use active key
const REFRESH_KEY_ID = process.env.JWT_REFRESH_KEY_ID || ACTIVE_KEY_ID;
const REFRESH_SECRET = getKeySecret(REFRESH_KEY_ID) || ACTIVE_SECRET;
function signAccessToken(user, options = {}) {
// === SECURITY HARDENING: JWT KEY ROTATION ===
// Include key ID in token header and add standard claims
const payload = {
sub: user.id,
phone_number: user.phone_number,
role: user.role,
user_type: user.user_type || null,
// === SECURITY HARDENING: JWT KEY ROTATION ===
// Standard JWT claims
iss: getIssuer(),
aud: getAudience(),
iat: Math.floor(Date.now() / 1000),
// High assurance flag (set after OTP verification for sensitive actions)
high_assurance: options.highAssurance || false,
};
return jwt.sign(
payload,
ACTIVE_SECRET,
{
sub: user.id,
phone_number: user.phone_number,
role: user.role,
user_type: user.user_type || null,
},
ACCESS_SECRET,
{ expiresIn: ACCESS_TTL }
expiresIn: ACCESS_TTL,
// === SECURITY HARDENING: JWT KEY ROTATION ===
// Include key ID in header for key rotation support
header: {
kid: ACTIVE_KEY_ID,
alg: 'HS256',
},
}
);
}
@ -31,15 +64,31 @@ async function issueRefreshToken({
ip,
rotatedFromId = null,
}) {
// === SECURITY HARDENING: JWT KEY ROTATION ===
const tokenId = randomUUID();
const payload = {
sub: userId,
device_id: deviceId,
jti: tokenId,
// === SECURITY HARDENING: JWT KEY ROTATION ===
// Standard JWT claims
iss: getIssuer(),
aud: getAudience(),
iat: Math.floor(Date.now() / 1000),
};
const token = jwt.sign(
{
sub: userId,
device_id: deviceId,
jti: tokenId,
},
payload,
REFRESH_SECRET,
{ expiresIn: REFRESH_TTL }
{
expiresIn: REFRESH_TTL,
// === SECURITY HARDENING: JWT KEY ROTATION ===
// Include key ID in header
header: {
kid: REFRESH_KEY_ID,
alg: 'HS256',
},
}
);
const decoded = jwt.decode(token);
const expiresAt = new Date(decoded.exp * 1000);
@ -98,13 +147,62 @@ async function storeRefreshToken({
}
async function verifyRefreshToken(rawToken) {
let payload;
// === SECURITY HARDENING: JWT KEY ROTATION ===
// Decode token to get key ID from header
let decoded;
try {
payload = jwt.verify(rawToken, REFRESH_SECRET);
decoded = jwt.decode(rawToken, { complete: true });
if (!decoded || !decoded.header) {
return null;
}
} catch (err) {
return null;
}
// Get secret for the key ID (if present) or try all keys
const keyId = decoded.header.kid;
let payload = null;
let verified = false;
if (keyId) {
const secret = getKeySecret(keyId);
if (secret) {
try {
payload = jwt.verify(rawToken, secret);
verified = true;
} catch (err) {
// Key ID specified but verification failed
return null;
}
}
}
// If key ID not found or not specified, try all keys (for rotation support)
if (!verified) {
const allKeys = getAllKeys();
for (const [kid, keySecret] of Object.entries(allKeys)) {
try {
payload = jwt.verify(rawToken, keySecret);
verified = true;
break;
} catch (err) {
// Try next key
continue;
}
}
}
if (!verified || !payload) {
return null;
}
// === SECURITY HARDENING: JWT KEY ROTATION ===
// Validate JWT claims (iss, aud, exp, iat, nbf)
const claimsValidation = validateTokenClaims(payload);
if (!claimsValidation.valid) {
return null;
}
const { sub: userId, device_id: deviceId, jti: tokenId } = payload;
if (!tokenId) {
return null;

61
src/utils/otpLogger.js Normal file
View File

@ -0,0 +1,61 @@
// src/utils/otpLogger.js
// === SECURITY HARDENING: OTP LOGGING ===
// Safe OTP logging helper that only logs in development and never to centralized logs
const isDevelopment = process.env.NODE_ENV === 'development';
const isProduction = process.env.NODE_ENV === 'production';
/**
* Safely log OTP code for debugging purposes
* - Only logs in development mode
* - Never logs to production or centralized logging systems
* - Uses console.log (not console.error) to avoid log aggregation
* - Clearly marked as DEV-ONLY
*
* @param {string} phone - Phone number (can be partially masked)
* @param {string} otp - OTP code (will be logged only in dev)
*/
function logOtpForDebug(phone, otp) {
if (!isDevelopment) {
// In production, never log OTPs
return;
}
// Mask phone number for additional safety (show only last 4 digits)
const maskedPhone = phone.length > 4
? phone.slice(0, -4).replace(/\d/g, '*') + phone.slice(-4)
: '****';
// Use console.log (not error/warn) to avoid log aggregation systems
// Prefix with [DEV-ONLY] to make it clear this is development-only
console.log('[DEV-ONLY] OTP generated:', {
phone: maskedPhone,
code: otp,
warning: 'This log should NEVER appear in production logs'
});
}
/**
* Log OTP generation event without exposing the code
* Safe for production use
*
* @param {string} phone - Phone number (masked)
* @param {boolean} smsSent - Whether SMS was successfully sent
*/
function logOtpEvent(phone, smsSent) {
const maskedPhone = phone.length > 4
? phone.slice(0, -4).replace(/\d/g, '*') + phone.slice(-4)
: '****';
if (smsSent) {
console.log('OTP sent via SMS:', maskedPhone);
} else {
console.warn('OTP generated but SMS failed:', maskedPhone);
}
}
module.exports = {
logOtpForDebug,
logOtpEvent,
};