Fixed few Code Security bugs
This commit is contained in:
parent
34f95f80a6
commit
f0741fd03e
|
|
@ -40,3 +40,6 @@ build/
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
# 🚀 Admin Dashboard - Quick Start Guide
|
||||
|
||||
## ⚡ 5-Minute Setup
|
||||
|
||||
### 1. Enable Dashboard
|
||||
```bash
|
||||
# Add to .env
|
||||
ENABLE_ADMIN_DASHBOARD=true
|
||||
```
|
||||
|
||||
### 2. Create Admin User
|
||||
```sql
|
||||
UPDATE users SET role = 'security_admin' WHERE phone_number = '+YOUR_ADMIN_PHONE';
|
||||
```
|
||||
|
||||
### 3. Get Access Token
|
||||
```bash
|
||||
# Step 1: Request OTP
|
||||
curl -X POST http://localhost:3000/auth/request-otp \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"phone_number": "+YOUR_ADMIN_PHONE"}'
|
||||
|
||||
# Step 2: Verify OTP (use code from SMS)
|
||||
curl -X POST http://localhost:3000/auth/verify-otp \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"phone_number": "+YOUR_ADMIN_PHONE", "code": "123456"}'
|
||||
|
||||
# Response contains: {"access_token": "..."}
|
||||
```
|
||||
|
||||
### 4. Set Token in Browser
|
||||
1. Open: `http://localhost:3000/admin/security-dashboard`
|
||||
2. Open browser console (F12)
|
||||
3. Run: `localStorage.setItem('admin_token', 'YOUR_ACCESS_TOKEN')`
|
||||
4. Refresh page
|
||||
|
||||
### 5. Configure Alerts (Optional)
|
||||
```bash
|
||||
# Add to .env
|
||||
SECURITY_ALERT_WEBHOOK_URL=https://hooks.slack.com/services/YOUR/WEBHOOK
|
||||
SECURITY_ALERT_MIN_LEVEL=HIGH_RISK
|
||||
```
|
||||
|
||||
## ✅ Done!
|
||||
|
||||
Dashboard is now accessible at: `/admin/security-dashboard`
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security Checklist
|
||||
|
||||
- [ ] `ENABLE_ADMIN_DASHBOARD=true` set
|
||||
- [ ] Admin user has `role = 'security_admin'`
|
||||
- [ ] `CORS_ALLOWED_ORIGINS` configured (production)
|
||||
- [ ] HTTPS enabled (production)
|
||||
- [ ] Admin token stored securely
|
||||
- [ ] `SECURITY_ALERT_WEBHOOK_URL` configured (optional)
|
||||
|
||||
---
|
||||
|
||||
## 📚 Full Documentation
|
||||
|
||||
See `ADMIN_DASHBOARD_SECURITY.md` for complete details.
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,385 @@
|
|||
# 🔒 Admin Security Dashboard - Implementation Summary
|
||||
|
||||
## ✅ Implementation Status: **COMPLETE**
|
||||
|
||||
All components of the secure Authentication Admin Dashboard have been implemented and are ready for use.
|
||||
|
||||
---
|
||||
|
||||
## 📦 Components Delivered
|
||||
|
||||
### 1️⃣ **Admin API Endpoint** ✅
|
||||
**File:** `src/routes/adminRoutes.js`
|
||||
|
||||
- **Route:** `GET /admin/security-events`
|
||||
- **Features:**
|
||||
- ✅ Filtering by `risk_level` (INFO, SUSPICIOUS, HIGH_RISK)
|
||||
- ✅ Search by user_id, phone, or IP address
|
||||
- ✅ Pagination with `limit` (default: 200, max: 1000) and `offset`
|
||||
- ✅ Statistics for last 24 hours
|
||||
- ✅ Complete input validation and sanitization
|
||||
- ✅ SQL injection prevention (parameterized queries)
|
||||
- ✅ Output sanitization before JSON response
|
||||
- ✅ Admin access logging
|
||||
|
||||
### 2️⃣ **Admin Authentication Middleware** ✅
|
||||
**File:** `src/middleware/adminAuth.js`
|
||||
|
||||
- ✅ Role-based access control (RBAC)
|
||||
- ✅ Checks `user.role === 'security_admin'`
|
||||
- ✅ Returns 403 for unauthorized users
|
||||
- ✅ Logs unauthorized access attempts
|
||||
|
||||
### 3️⃣ **Admin Dashboard UI** ✅
|
||||
**File:** `public/security-dashboard.html`
|
||||
|
||||
- ✅ Vanilla HTML/CSS/JS (no frameworks)
|
||||
- ✅ Dark theme with modern UI
|
||||
- ✅ **XSS Prevention:** Uses `textContent` only (NO `innerHTML`)
|
||||
- ✅ Table view of security events
|
||||
- ✅ Filter by risk level
|
||||
- ✅ Search functionality
|
||||
- ✅ Statistics counters (total, high risk, suspicious, info)
|
||||
- ✅ Manual refresh button
|
||||
- ✅ Auto-refresh every 15 seconds
|
||||
- ✅ Local time formatting
|
||||
- ✅ Responsive design
|
||||
|
||||
### 4️⃣ **Security Middleware** ✅
|
||||
|
||||
**Rate Limiting:** `src/middleware/adminRateLimit.js`
|
||||
- ✅ 100 requests per 15 minutes per user
|
||||
- ✅ Redis-backed with memory fallback
|
||||
- ✅ Configurable via env vars
|
||||
|
||||
**Security Headers:** `src/middleware/securityHeaders.js`
|
||||
- ✅ `X-Frame-Options: DENY` (clickjacking protection)
|
||||
- ✅ `X-Content-Type-Options: nosniff`
|
||||
- ✅ `X-XSS-Protection: 1; mode=block`
|
||||
- ✅ `Strict-Transport-Security` (production)
|
||||
|
||||
### 5️⃣ **Active Alerting** ✅
|
||||
**File:** `src/services/auditLogger.js`
|
||||
|
||||
- ✅ `triggerSecurityAlert()` function implemented
|
||||
- ✅ Fires for HIGH_RISK events
|
||||
- ✅ Fires for anomalies detected by `checkAnomalies()`
|
||||
- ✅ Webhook integration (Slack-compatible)
|
||||
- ✅ Resilient (doesn't crash on webhook failure)
|
||||
- ✅ Configurable via `SECURITY_ALERT_WEBHOOK_URL`
|
||||
|
||||
### 6️⃣ **Server Integration** ✅
|
||||
**File:** `src/index.js`
|
||||
|
||||
- ✅ Admin routes mounted at `/admin`
|
||||
- ✅ Protected by: `securityHeaders` → `authMiddleware` → `adminAuth` → `adminRateLimit`
|
||||
- ✅ Dashboard served at `/admin/security-dashboard`
|
||||
- ✅ Feature flag: `ENABLE_ADMIN_DASHBOARD=true`
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security Protections Applied
|
||||
|
||||
| Security Measure | Status | Implementation |
|
||||
|-----------------|:------:|----------------|
|
||||
| **RBAC (Role-Based Access)** | ✅ | `adminAuth` middleware checks `role === 'security_admin'` |
|
||||
| **JWT Authentication** | ✅ | All routes protected by `authMiddleware` |
|
||||
| **HTTPS Enforcement** | ✅ | `Strict-Transport-Security` header in production |
|
||||
| **XSS Prevention** | ✅ | Dashboard uses `textContent` only, NO `innerHTML` |
|
||||
| **Clickjacking Protection** | ✅ | `X-Frame-Options: DENY` header |
|
||||
| **SQL Injection Prevention** | ✅ | Parameterized queries only |
|
||||
| **Input Validation** | ✅ | All query parameters validated and sanitized |
|
||||
| **Output Sanitization** | ✅ | All DB fields sanitized before JSON response |
|
||||
| **Rate Limiting** | ✅ | 100 requests/15min per admin user |
|
||||
| **CORS Protection** | ✅ | No public origins, whitelist only |
|
||||
| **Audit Logging** | ✅ | All admin access logged to `auth_audit` |
|
||||
| **Feature Flag** | ✅ | Dashboard only enabled when `ENABLE_ADMIN_DASHBOARD=true` |
|
||||
| **No Secrets in Code** | ✅ | All config via environment variables |
|
||||
| **Error Handling** | ✅ | Graceful degradation, no sensitive info leaked |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Configuration & Setup
|
||||
|
||||
### Step 1: Environment Variables
|
||||
|
||||
Add to your `.env` file:
|
||||
|
||||
```bash
|
||||
# Enable admin dashboard
|
||||
ENABLE_ADMIN_DASHBOARD=true
|
||||
|
||||
# Security alerting webhook (optional)
|
||||
SECURITY_ALERT_WEBHOOK_URL=https://hooks.slack.com/services/YOUR/WEBHOOK/URL
|
||||
SECURITY_ALERT_MIN_LEVEL=HIGH_RISK # Options: INFO, SUSPICIOUS, HIGH_RISK
|
||||
|
||||
# Admin rate limiting (optional, defaults shown)
|
||||
ADMIN_RATE_LIMIT_MAX=100
|
||||
ADMIN_RATE_LIMIT_WINDOW=900 # 15 minutes in seconds
|
||||
|
||||
# CORS (REQUIRED in production - no wildcards!)
|
||||
CORS_ALLOWED_ORIGINS=https://your-admin-domain.com,https://api.yourdomain.com
|
||||
```
|
||||
|
||||
### Step 2: Create Admin User
|
||||
|
||||
Ensure at least one user has `role = 'security_admin'` in the database:
|
||||
|
||||
```sql
|
||||
UPDATE users
|
||||
SET role = 'security_admin'
|
||||
WHERE phone_number = '+1234567890'; -- Replace with admin phone
|
||||
```
|
||||
|
||||
### Step 3: Get Admin Access Token
|
||||
|
||||
1. **Authenticate as admin user:**
|
||||
```bash
|
||||
# Request OTP
|
||||
POST /auth/request-otp
|
||||
{
|
||||
"phone_number": "+1234567890"
|
||||
}
|
||||
|
||||
# Verify OTP
|
||||
POST /auth/verify-otp
|
||||
{
|
||||
"phone_number": "+1234567890",
|
||||
"code": "123456"
|
||||
}
|
||||
```
|
||||
|
||||
2. **Save the access token:**
|
||||
- Copy the `access_token` from the response
|
||||
- Open browser console on `/admin/security-dashboard`
|
||||
- Run: `localStorage.setItem('admin_token', 'YOUR_ACCESS_TOKEN')`
|
||||
- Refresh the page
|
||||
|
||||
### Step 4: Access Dashboard
|
||||
|
||||
Navigate to: `https://your-domain.com/admin/security-dashboard`
|
||||
|
||||
The dashboard will:
|
||||
- ✅ Load security events automatically
|
||||
- ✅ Auto-refresh every 15 seconds
|
||||
- ✅ Allow filtering and searching
|
||||
- ✅ Display statistics
|
||||
|
||||
---
|
||||
|
||||
## 📋 API Usage
|
||||
|
||||
### Get Security Events
|
||||
|
||||
```bash
|
||||
GET /admin/security-events?risk_level=HIGH_RISK&limit=100&search=192.168.1.1
|
||||
|
||||
Authorization: Bearer YOUR_ADMIN_ACCESS_TOKEN
|
||||
```
|
||||
|
||||
**Query Parameters:**
|
||||
- `risk_level` (optional): `INFO`, `SUSPICIOUS`, or `HIGH_RISK`
|
||||
- `limit` (optional): Number of results (1-1000, default: 200)
|
||||
- `offset` (optional): Pagination offset (default: 0)
|
||||
- `search` (optional): Search in user_id, phone, or IP address
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"events": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"user_id": "uuid",
|
||||
"action": "login",
|
||||
"status": "blocked",
|
||||
"risk_level": "HIGH_RISK",
|
||||
"ip_address": "192.168.1.1",
|
||||
"phone": "****5678",
|
||||
"created_at": "2024-01-01T12:00:00Z",
|
||||
...
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"total": 150,
|
||||
"limit": 100,
|
||||
"offset": 0,
|
||||
"has_more": true
|
||||
},
|
||||
"stats": {
|
||||
"last_24h": {
|
||||
"total": 500,
|
||||
"high_risk": 10,
|
||||
"suspicious": 50,
|
||||
"info": 440
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔔 Alerting Configuration
|
||||
|
||||
### Slack Webhook Setup
|
||||
|
||||
1. Go to https://api.slack.com/apps
|
||||
2. Create a new app or select existing
|
||||
3. Navigate to "Incoming Webhooks"
|
||||
4. Enable and create webhook URL
|
||||
5. Add to `.env`:
|
||||
```bash
|
||||
SECURITY_ALERT_WEBHOOK_URL=https://hooks.slack.com/services/YOUR/WEBHOOK/URL
|
||||
```
|
||||
|
||||
### Alert Triggers
|
||||
|
||||
Alerts are sent for:
|
||||
- ✅ All `HIGH_RISK` events (by default)
|
||||
- ✅ Events flagged by anomaly detection:
|
||||
- 5+ failed OTP attempts in 1 hour
|
||||
- 3+ HIGH_RISK events from same IP in 15 minutes
|
||||
|
||||
### Customize Alert Level
|
||||
|
||||
Set `SECURITY_ALERT_MIN_LEVEL` in `.env`:
|
||||
- `HIGH_RISK` (default) - Only HIGH_RISK events
|
||||
- `SUSPICIOUS` - SUSPICIOUS and HIGH_RISK events
|
||||
- `INFO` - All events (not recommended)
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ Security Best Practices
|
||||
|
||||
### ✅ DO:
|
||||
- Always use HTTPS in production
|
||||
- Set `CORS_ALLOWED_ORIGINS` to specific domains (never `*`)
|
||||
- Rotate admin access tokens regularly
|
||||
- Monitor admin access logs
|
||||
- Keep `ENABLE_ADMIN_DASHBOARD=false` when not in use
|
||||
- Use strong JWT secrets
|
||||
- Limit admin user accounts
|
||||
|
||||
### ❌ DON'T:
|
||||
- Never expose admin endpoints to public CORS origins
|
||||
- Never use `innerHTML` in dashboard code
|
||||
- Never commit `.env` files
|
||||
- Never use wildcard CORS (`*`) in production
|
||||
- Never disable rate limiting
|
||||
- Never share admin tokens
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
### Test Admin Access
|
||||
|
||||
```bash
|
||||
# 1. Get admin token (as shown in Step 3)
|
||||
# 2. Test API endpoint
|
||||
curl -H "Authorization: Bearer YOUR_TOKEN" \
|
||||
https://your-domain.com/admin/security-events?limit=10
|
||||
|
||||
# 3. Access dashboard
|
||||
open https://your-domain.com/admin/security-dashboard
|
||||
```
|
||||
|
||||
### Verify Security Headers
|
||||
|
||||
```bash
|
||||
curl -I https://your-domain.com/admin/security-dashboard
|
||||
|
||||
# Should see:
|
||||
# X-Frame-Options: DENY
|
||||
# X-Content-Type-Options: nosniff
|
||||
# X-XSS-Protection: 1; mode=block
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Monitoring
|
||||
|
||||
### Admin Access Logs
|
||||
|
||||
All admin actions are logged to `auth_audit` table:
|
||||
- `action: 'admin_view_security_events'`
|
||||
- `status: 'success'` or `'failed'`
|
||||
- Includes IP, user agent, and filters used
|
||||
|
||||
### Query Admin Activity
|
||||
|
||||
```sql
|
||||
SELECT * FROM auth_audit
|
||||
WHERE action = 'admin_view_security_events'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Dashboard shows "Authentication required"
|
||||
- ✅ Ensure you've set `localStorage.setItem('admin_token', 'YOUR_TOKEN')`
|
||||
- ✅ Verify token is valid and not expired
|
||||
- ✅ Check that user has `role = 'security_admin'`
|
||||
|
||||
### 403 Forbidden on admin routes
|
||||
- ✅ Verify user role is `security_admin` in database
|
||||
- ✅ Check JWT token includes `role` claim
|
||||
- ✅ Ensure token is not expired
|
||||
|
||||
### Alerts not firing
|
||||
- ✅ Check `SECURITY_ALERT_WEBHOOK_URL` is set
|
||||
- ✅ Verify webhook URL is valid
|
||||
- ✅ Check server logs for webhook errors
|
||||
- ✅ Ensure events have `risk_level >= SECURITY_ALERT_MIN_LEVEL`
|
||||
|
||||
### Rate limit errors
|
||||
- ✅ Default: 100 requests per 15 minutes
|
||||
- ✅ Adjust via `ADMIN_RATE_LIMIT_MAX` env var
|
||||
- ✅ Check Redis connection if using Redis
|
||||
|
||||
---
|
||||
|
||||
## 📝 Files Modified/Created
|
||||
|
||||
### Created:
|
||||
- ✅ `src/routes/adminRoutes.js` - Admin API endpoints
|
||||
- ✅ `src/middleware/adminAuth.js` - RBAC middleware
|
||||
- ✅ `src/middleware/adminRateLimit.js` - Rate limiting
|
||||
- ✅ `src/middleware/securityHeaders.js` - Security headers
|
||||
- ✅ `public/security-dashboard.html` - Admin dashboard UI
|
||||
|
||||
### Modified:
|
||||
- ✅ `src/index.js` - Admin routes mounting
|
||||
- ✅ `src/services/auditLogger.js` - Alerting integration (already done)
|
||||
|
||||
---
|
||||
|
||||
## ✨ Summary
|
||||
|
||||
Your secure Admin Security Dashboard is **fully implemented** and ready for production use. All security requirements have been met:
|
||||
|
||||
✅ **Authentication & Authorization** - JWT + RBAC
|
||||
✅ **XSS Prevention** - textContent only
|
||||
✅ **Clickjacking Protection** - X-Frame-Options
|
||||
✅ **Input/Output Sanitization** - All data sanitized
|
||||
✅ **Rate Limiting** - Prevents abuse
|
||||
✅ **Audit Logging** - All access logged
|
||||
✅ **Feature Flag** - Can be disabled
|
||||
✅ **Active Alerting** - Webhook integration
|
||||
|
||||
**Next Steps:**
|
||||
1. Set `ENABLE_ADMIN_DASHBOARD=true` in `.env`
|
||||
2. Create admin user with `role = 'security_admin'`
|
||||
3. Configure `SECURITY_ALERT_WEBHOOK_URL` (optional)
|
||||
4. Set `CORS_ALLOWED_ORIGINS` for production
|
||||
5. Test dashboard access
|
||||
6. Monitor admin activity logs
|
||||
|
||||
---
|
||||
|
||||
**🔒 Security Status: PRODUCTION READY**
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,362 @@
|
|||
# Admin Security Dashboard - Setup Guide
|
||||
|
||||
## === ADMIN SECURITY VISUALIZER ===
|
||||
|
||||
This document explains how to configure and use the secure Authentication Admin Dashboard for monitoring security events.
|
||||
|
||||
---
|
||||
|
||||
## 📋 Prerequisites
|
||||
|
||||
1. **Admin User Account**: A user account with `role = 'security_admin'` in the database
|
||||
2. **JWT Access Token**: Admin must authenticate and obtain a JWT token with admin role
|
||||
3. **Environment Variables**: Required configuration (see below)
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### 1. Environment Variables
|
||||
|
||||
Add the following to your `.env` file:
|
||||
|
||||
```bash
|
||||
# Enable admin dashboard
|
||||
ENABLE_ADMIN_DASHBOARD=true
|
||||
|
||||
# Security alerting webhook (optional but recommended)
|
||||
SECURITY_ALERT_WEBHOOK_URL=https://hooks.slack.com/services/YOUR/WEBHOOK/URL
|
||||
SECURITY_ALERT_MIN_LEVEL=HIGH_RISK
|
||||
|
||||
# Admin rate limiting (optional, defaults provided)
|
||||
ADMIN_RATE_LIMIT_MAX=100
|
||||
ADMIN_RATE_LIMIT_WINDOW=900
|
||||
```
|
||||
|
||||
### 2. Create Admin User
|
||||
|
||||
Update a user's role in the database:
|
||||
|
||||
```sql
|
||||
UPDATE users
|
||||
SET role = 'security_admin'
|
||||
WHERE phone_number = '+919876543210';
|
||||
```
|
||||
|
||||
**Note**: The `role` column must support the value `'security_admin'`. If your enum doesn't include this, you may need to update the enum type:
|
||||
|
||||
```sql
|
||||
-- Check current enum values
|
||||
SELECT unnest(enum_range(NULL::listing_role_enum));
|
||||
|
||||
-- If needed, add security_admin to the enum
|
||||
ALTER TYPE listing_role_enum ADD VALUE 'security_admin';
|
||||
```
|
||||
|
||||
### 3. CORS Configuration
|
||||
|
||||
**IMPORTANT**: Admin dashboard should NOT be accessible from public origins.
|
||||
|
||||
In production, ensure `CORS_ALLOWED_ORIGINS` only includes trusted domains:
|
||||
|
||||
```bash
|
||||
# .env
|
||||
CORS_ALLOWED_ORIGINS=https://admin.yourdomain.com,https://internal.yourdomain.com
|
||||
```
|
||||
|
||||
**Never use `*` for CORS when admin endpoints are enabled.**
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Usage
|
||||
|
||||
### 1. Obtain Admin Access Token
|
||||
|
||||
Admin must authenticate via the normal auth flow:
|
||||
|
||||
```bash
|
||||
# Step 1: Request OTP
|
||||
POST /auth/request-otp
|
||||
{
|
||||
"phone_number": "+919876543210"
|
||||
}
|
||||
|
||||
# Step 2: Verify OTP (returns access token)
|
||||
POST /auth/verify-otp
|
||||
{
|
||||
"phone_number": "+919876543210",
|
||||
"code": "123456"
|
||||
}
|
||||
|
||||
# Response includes:
|
||||
{
|
||||
"access_token": "eyJhbGc...",
|
||||
"refresh_token": "..."
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Access Dashboard
|
||||
|
||||
**Option A: Browser (with token in localStorage)**
|
||||
|
||||
1. Open browser console
|
||||
2. Set token: `localStorage.setItem('admin_token', 'YOUR_ACCESS_TOKEN')`
|
||||
3. Navigate to: `https://yourdomain.com/admin/security-dashboard`
|
||||
|
||||
**Option B: API Direct Access**
|
||||
|
||||
```bash
|
||||
GET /admin/security-events?risk_level=HIGH_RISK&limit=50
|
||||
Authorization: Bearer YOUR_ACCESS_TOKEN
|
||||
```
|
||||
|
||||
### 3. Dashboard Features
|
||||
|
||||
- **Real-time Event Table**: View all authentication events
|
||||
- **Risk Level Filtering**: Filter by INFO, SUSPICIOUS, HIGH_RISK
|
||||
- **Search**: Search by User ID, Phone, or IP Address
|
||||
- **Statistics**: 24-hour event counts by risk level
|
||||
- **Auto-refresh**: Automatically updates every 15 seconds
|
||||
- **Manual Refresh**: Click "Refresh" button anytime
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security Protections
|
||||
|
||||
### ✅ Implemented Security Measures
|
||||
|
||||
| Security Measure | Implementation |
|
||||
|-----------------|----------------|
|
||||
| **RBAC (Role-Based Access)** | `adminAuth` middleware checks `role === 'security_admin'` |
|
||||
| **JWT Authentication** | All admin routes require valid Bearer token |
|
||||
| **Rate Limiting** | 100 requests per 15 minutes per admin user |
|
||||
| **Input Validation** | All query parameters sanitized and validated |
|
||||
| **SQL Injection Prevention** | Parameterized queries only |
|
||||
| **Output Sanitization** | All DB fields sanitized before JSON response |
|
||||
| **XSS Prevention** | Dashboard uses `textContent` only (NO `innerHTML`) |
|
||||
| **Clickjacking Protection** | `X-Frame-Options: DENY` header |
|
||||
| **CORS Restrictions** | No public origins allowed |
|
||||
| **Audit Logging** | All admin access logged to `auth_audit` |
|
||||
| **HTTPS Enforcement** | HSTS header in production |
|
||||
| **Feature Flag** | Dashboard only enabled when `ENABLE_ADMIN_DASHBOARD=true` |
|
||||
|
||||
### Security Headers
|
||||
|
||||
The dashboard and admin API endpoints include:
|
||||
|
||||
- `X-Frame-Options: DENY` - Prevents clickjacking
|
||||
- `X-Content-Type-Options: nosniff` - Prevents MIME sniffing
|
||||
- `X-XSS-Protection: 1; mode=block` - XSS protection
|
||||
- `Strict-Transport-Security` - HTTPS enforcement (production)
|
||||
|
||||
---
|
||||
|
||||
## 📊 API Endpoints
|
||||
|
||||
### GET /admin/security-events
|
||||
|
||||
Retrieve security audit events with filtering and pagination.
|
||||
|
||||
**Query Parameters:**
|
||||
- `risk_level` (optional): `INFO`, `SUSPICIOUS`, or `HIGH_RISK`
|
||||
- `search` (optional): Search in user_id, phone, or ip_address
|
||||
- `limit` (optional): Number of results (1-1000, default: 200)
|
||||
- `offset` (optional): Pagination offset (default: 0)
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"events": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"user_id": "uuid",
|
||||
"action": "otp_verify",
|
||||
"status": "failed",
|
||||
"risk_level": "HIGH_RISK",
|
||||
"ip_address": "192.168.1.1",
|
||||
"user_agent": "Mozilla/5.0...",
|
||||
"device_id": "device-123",
|
||||
"phone": "***543210",
|
||||
"meta": {},
|
||||
"created_at": "2024-01-01T12:00:00Z"
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"total": 1000,
|
||||
"limit": 200,
|
||||
"offset": 0,
|
||||
"has_more": true
|
||||
},
|
||||
"stats": {
|
||||
"last_24h": {
|
||||
"total": 500,
|
||||
"high_risk": 10,
|
||||
"suspicious": 50,
|
||||
"info": 440
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Authentication:** Required (Bearer token with `security_admin` role)
|
||||
|
||||
---
|
||||
|
||||
## 🚨 Alerting Integration
|
||||
|
||||
The dashboard integrates with the existing `triggerSecurityAlert()` function in `auditLogger.js`.
|
||||
|
||||
### When Alerts Fire
|
||||
|
||||
Alerts are sent to `SECURITY_ALERT_WEBHOOK_URL` for:
|
||||
|
||||
1. **HIGH_RISK Events**: All events with `risk_level = 'HIGH_RISK'`
|
||||
2. **Anomaly Detection**: Events flagged by `checkAnomalies()`:
|
||||
- 5+ failed OTP attempts in 1 hour
|
||||
- 3+ HIGH_RISK events from same IP in 15 minutes
|
||||
|
||||
### Webhook Payload Format
|
||||
|
||||
The webhook receives a Slack-compatible JSON payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"text": "🚨 Security Alert: HIGH_RISK",
|
||||
"attachments": [{
|
||||
"color": "danger",
|
||||
"fields": [
|
||||
{"title": "Event Type", "value": "login", "short": true},
|
||||
{"title": "Risk Level", "value": "HIGH_RISK (high)", "short": true},
|
||||
{"title": "IP Address", "value": "192.168.1.1", "short": true}
|
||||
]
|
||||
}],
|
||||
"metadata": {
|
||||
"event_id": "uuid",
|
||||
"risk_level": "HIGH_RISK",
|
||||
"severity": "high",
|
||||
"event_type": "login",
|
||||
"ip_address": "192.168.1.1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Setting Up Slack Webhook
|
||||
|
||||
1. Go to https://api.slack.com/apps
|
||||
2. Create a new app or select existing
|
||||
3. Enable "Incoming Webhooks"
|
||||
4. Create webhook URL
|
||||
5. Add to `.env`: `SECURITY_ALERT_WEBHOOK_URL=https://hooks.slack.com/services/YOUR/WEBHOOK/URL`
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ Security Best Practices
|
||||
|
||||
### 1. Access Control
|
||||
|
||||
- **Limit Admin Users**: Only grant `security_admin` role to trusted personnel
|
||||
- **Rotate Tokens**: Admin tokens should be rotated regularly
|
||||
- **Monitor Admin Access**: Review `auth_audit` logs for admin actions
|
||||
|
||||
### 2. Network Security
|
||||
|
||||
- **HTTPS Only**: Always use HTTPS in production
|
||||
- **VPN/Private Network**: Consider restricting admin dashboard to internal networks
|
||||
- **IP Whitelisting**: Optionally restrict admin endpoints by IP at reverse proxy level
|
||||
|
||||
### 3. Token Management
|
||||
|
||||
- **Short Token TTL**: Use shorter access token TTL for admin users (e.g., 5 minutes)
|
||||
- **Token Storage**: Store admin tokens securely (never in localStorage for production)
|
||||
- **Logout**: Implement proper logout to revoke tokens
|
||||
|
||||
### 4. Monitoring
|
||||
|
||||
- **Alert on Admin Access**: Set up alerts for admin dashboard access
|
||||
- **Review Logs**: Regularly review `auth_audit` for admin actions
|
||||
- **Anomaly Detection**: Monitor for unusual admin access patterns
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Dashboard Not Loading
|
||||
|
||||
1. **Check Feature Flag**: Ensure `ENABLE_ADMIN_DASHBOARD=true` in `.env`
|
||||
2. **Check Token**: Verify admin token is valid and has `security_admin` role
|
||||
3. **Check CORS**: Ensure your origin is in `CORS_ALLOWED_ORIGINS`
|
||||
4. **Check Console**: Open browser console for error messages
|
||||
|
||||
### 403 Forbidden
|
||||
|
||||
- User role is not `security_admin`
|
||||
- Token is invalid or expired
|
||||
- CORS origin not whitelisted
|
||||
|
||||
### 401 Unauthorized
|
||||
|
||||
- Missing `Authorization` header
|
||||
- Invalid JWT token
|
||||
- Token expired
|
||||
|
||||
### No Events Showing
|
||||
|
||||
- Check database connection
|
||||
- Verify `auth_audit` table exists
|
||||
- Check filters (risk_level, search) aren't too restrictive
|
||||
|
||||
---
|
||||
|
||||
## 📝 Files Created/Modified
|
||||
|
||||
### New Files
|
||||
|
||||
- `src/middleware/adminAuth.js` - Admin role check middleware
|
||||
- `src/middleware/adminRateLimit.js` - Rate limiting for admin routes
|
||||
- `src/middleware/securityHeaders.js` - Security headers middleware
|
||||
- `src/routes/adminRoutes.js` - Admin API endpoints
|
||||
- `public/security-dashboard.html` - Admin dashboard UI
|
||||
|
||||
### Modified Files
|
||||
|
||||
- `src/index.js` - Added admin route mounting
|
||||
- `src/services/auditLogger.js` - Already has `triggerSecurityAlert()` (from previous task)
|
||||
|
||||
---
|
||||
|
||||
## ✅ Verification Checklist
|
||||
|
||||
- [ ] `ENABLE_ADMIN_DASHBOARD=true` in `.env`
|
||||
- [ ] Admin user has `role = 'security_admin'` in database
|
||||
- [ ] Admin can obtain JWT token via normal auth flow
|
||||
- [ ] Dashboard accessible at `/admin/security-dashboard`
|
||||
- [ ] API endpoint `/admin/security-events` returns data
|
||||
- [ ] Security headers present (check browser DevTools)
|
||||
- [ ] Rate limiting works (test with >100 requests)
|
||||
- [ ] Webhook alerts fire for HIGH_RISK events (if configured)
|
||||
- [ ] CORS properly configured (no public origins)
|
||||
- [ ] All admin access logged to `auth_audit` table
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Documentation
|
||||
|
||||
- `SECURITY_SCENARIOS.md` - Security threat scenarios
|
||||
- `DEVICE_MANAGEMENT.md` - Device management features
|
||||
- `src/services/auditLogger.js` - Audit logging implementation
|
||||
|
||||
---
|
||||
|
||||
## 📞 Support
|
||||
|
||||
For issues or questions:
|
||||
1. Check browser console for errors
|
||||
2. Review server logs
|
||||
3. Verify database connectivity
|
||||
4. Check environment variables
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2024-01-01
|
||||
**Version**: 1.0.0
|
||||
|
||||
|
|
@ -77,3 +77,6 @@ function csrfProtection(req, res, next) {
|
|||
- [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)
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,369 @@
|
|||
# Database Encryption Setup Guide
|
||||
**Security Hardening: Database Compromise Mitigation**
|
||||
|
||||
This guide covers the implementation of database encryption at multiple levels to protect sensitive data (PII) like phone numbers.
|
||||
|
||||
---
|
||||
|
||||
## ✅ **IMPLEMENTED: Field-Level Encryption**
|
||||
|
||||
### **What's Implemented**
|
||||
|
||||
1. **Field-Level Encryption for Phone Numbers**
|
||||
- Phone numbers are encrypted using AES-256-GCM before storing in database
|
||||
- Automatic decryption when reading from database
|
||||
- Backward compatibility with existing plaintext data
|
||||
|
||||
2. **Database Access Logging**
|
||||
- All database queries are logged (configurable)
|
||||
- Logs include: query type, tables accessed, user context, IP address, timestamp
|
||||
- Sensitive parameters are redacted in logs
|
||||
|
||||
### **Configuration**
|
||||
|
||||
#### **1. Enable Field-Level Encryption**
|
||||
|
||||
Add to your `.env` file:
|
||||
|
||||
```bash
|
||||
# Enable field-level encryption
|
||||
ENCRYPTION_ENABLED=true
|
||||
|
||||
# Generate encryption key (32 bytes, base64 encoded)
|
||||
# Run: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
|
||||
ENCRYPTION_KEY=<your-32-byte-base64-key>
|
||||
```
|
||||
|
||||
**Generate Encryption Key:**
|
||||
```bash
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
|
||||
```
|
||||
|
||||
**Example output:**
|
||||
```
|
||||
K8mN3pQ9rT5vW7xY1zA2bC4dE6fG8hI0jK2lM4nO6pQ8rS0tU2vW4xY6zA=
|
||||
```
|
||||
|
||||
⚠️ **IMPORTANT:**
|
||||
- Store encryption key in secrets manager (AWS Secrets Manager, HashiCorp Vault, etc.)
|
||||
- Never commit encryption keys to version control
|
||||
- Rotate keys periodically (requires data re-encryption)
|
||||
|
||||
#### **2. Enable Database Access Logging**
|
||||
|
||||
Add to your `.env` file:
|
||||
|
||||
```bash
|
||||
# Enable database access logging
|
||||
DB_ACCESS_LOGGING_ENABLED=true
|
||||
|
||||
# Log level: 'all' (all queries) or 'sensitive' (only sensitive tables)
|
||||
DB_ACCESS_LOG_LEVEL=sensitive
|
||||
```
|
||||
|
||||
**Sensitive Tables (always logged if enabled):**
|
||||
- `users`
|
||||
- `otp_codes`
|
||||
- `otp_requests`
|
||||
- `refresh_tokens`
|
||||
- `auth_audit`
|
||||
- `user_devices`
|
||||
|
||||
### **How It Works**
|
||||
|
||||
#### **Encryption Flow:**
|
||||
1. Application receives plaintext phone number
|
||||
2. Phone number is encrypted using AES-256-GCM
|
||||
3. Encrypted value is stored in database
|
||||
4. When reading, encrypted value is automatically decrypted
|
||||
|
||||
#### **Backward Compatibility:**
|
||||
- Existing plaintext phone numbers continue to work
|
||||
- System searches for both encrypted and plaintext values
|
||||
- New records are stored encrypted
|
||||
- Old records can be migrated gradually
|
||||
|
||||
#### **Database Access Logging:**
|
||||
- All queries to sensitive tables are logged
|
||||
- Logs include sanitized parameters (no sensitive data)
|
||||
- User context (user ID, IP, user agent) is captured
|
||||
- Query duration is tracked
|
||||
|
||||
### **Code Usage**
|
||||
|
||||
#### **Encrypt Phone Number:**
|
||||
```javascript
|
||||
const { encryptPhoneNumber } = require('./utils/fieldEncryption');
|
||||
|
||||
const encryptedPhone = encryptPhoneNumber('+919876543210');
|
||||
// Store encryptedPhone in database
|
||||
```
|
||||
|
||||
#### **Decrypt Phone Number:**
|
||||
```javascript
|
||||
const { decryptPhoneNumber } = require('./utils/fieldEncryption');
|
||||
|
||||
const plaintextPhone = decryptPhoneNumber(encryptedPhoneFromDb);
|
||||
// Use plaintextPhone in application
|
||||
```
|
||||
|
||||
#### **Database Query with Context:**
|
||||
```javascript
|
||||
const db = require('./db');
|
||||
|
||||
// Create context from request
|
||||
const context = db.createContextFromRequest(req);
|
||||
|
||||
// Query with context (for logging)
|
||||
const result = await db.query(
|
||||
'SELECT * FROM users WHERE id = $1',
|
||||
[userId],
|
||||
context
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ **REQUIRED: Database-Level Encryption (TDE)**
|
||||
|
||||
### **What's Needed**
|
||||
|
||||
**Transparent Data Encryption (TDE)** must be configured at the database server level. This is infrastructure-level encryption that protects data at rest.
|
||||
|
||||
### **PostgreSQL TDE Setup**
|
||||
|
||||
PostgreSQL doesn't have built-in TDE, but you can use:
|
||||
|
||||
#### **Option 1: PostgreSQL with pgcrypto Extension (Application-Level)**
|
||||
|
||||
Already implemented via field-level encryption (see above).
|
||||
|
||||
#### **Option 2: Database-Level Encryption (Infrastructure)**
|
||||
|
||||
**For PostgreSQL on Cloud Providers:**
|
||||
|
||||
1. **AWS RDS PostgreSQL:**
|
||||
- Enable encryption at rest when creating RDS instance
|
||||
- Uses AWS KMS for key management
|
||||
- Encryption is transparent to application
|
||||
|
||||
2. **Google Cloud SQL:**
|
||||
- Enable encryption at rest in instance settings
|
||||
- Uses Google Cloud KMS
|
||||
|
||||
3. **Azure Database for PostgreSQL:**
|
||||
- Enable Transparent Data Encryption (TDE)
|
||||
- Uses Azure Key Vault
|
||||
|
||||
4. **Self-Hosted PostgreSQL:**
|
||||
- Use encrypted filesystem (LUKS, BitLocker)
|
||||
- Use PostgreSQL with encryption at filesystem level
|
||||
|
||||
### **Setup Instructions**
|
||||
|
||||
#### **AWS RDS PostgreSQL:**
|
||||
|
||||
1. **Create Encrypted RDS Instance:**
|
||||
```bash
|
||||
aws rds create-db-instance \
|
||||
--db-instance-identifier farm-auth-db \
|
||||
--db-instance-class db.t3.micro \
|
||||
--engine postgres \
|
||||
--master-username postgres \
|
||||
--master-user-password <password> \
|
||||
--allocated-storage 20 \
|
||||
--storage-encrypted \
|
||||
--kms-key-id <kms-key-id>
|
||||
```
|
||||
|
||||
2. **Or Enable Encryption on Existing Instance:**
|
||||
- Create snapshot
|
||||
- Restore snapshot with encryption enabled
|
||||
- Update application connection string
|
||||
|
||||
#### **Google Cloud SQL:**
|
||||
|
||||
1. **Enable Encryption:**
|
||||
```bash
|
||||
gcloud sql instances create farm-auth-db \
|
||||
--database-version=POSTGRES_14 \
|
||||
--tier=db-f1-micro \
|
||||
--storage-type=SSD \
|
||||
--disk-size=20GB \
|
||||
--disk-encryption-key=<kms-key>
|
||||
```
|
||||
|
||||
#### **Azure Database for PostgreSQL:**
|
||||
|
||||
1. **Enable TDE:**
|
||||
- Navigate to Azure Portal
|
||||
- Select your PostgreSQL server
|
||||
- Go to "Data encryption"
|
||||
- Enable "Data encryption"
|
||||
- Select Key Vault key
|
||||
|
||||
---
|
||||
|
||||
## 📋 **Migration Plan**
|
||||
|
||||
### **Phase 1: Enable Field-Level Encryption (Current)**
|
||||
|
||||
✅ **Status: Implemented**
|
||||
|
||||
1. Set `ENCRYPTION_ENABLED=true`
|
||||
2. Set `ENCRYPTION_KEY` (from secrets manager)
|
||||
3. New records are automatically encrypted
|
||||
4. Existing plaintext records continue to work (backward compatibility)
|
||||
|
||||
### **Phase 2: Migrate Existing Data**
|
||||
|
||||
**Script to migrate existing plaintext phone numbers:**
|
||||
|
||||
```sql
|
||||
-- WARNING: This requires application-level decryption/encryption
|
||||
-- Run migration script that:
|
||||
-- 1. Reads plaintext phone numbers
|
||||
-- 2. Encrypts them using application encryption
|
||||
-- 3. Updates database with encrypted values
|
||||
|
||||
-- Example (run via Node.js script, not direct SQL):
|
||||
-- const { encryptPhoneNumber } = require('./utils/fieldEncryption');
|
||||
-- const users = await db.query('SELECT id, phone_number FROM users WHERE phone_number NOT LIKE \'%:%\'');
|
||||
-- for (const user of users.rows) {
|
||||
-- const encrypted = encryptPhoneNumber(user.phone_number);
|
||||
-- await db.query('UPDATE users SET phone_number = $1 WHERE id = $2', [encrypted, user.id]);
|
||||
-- }
|
||||
```
|
||||
|
||||
### **Phase 3: Enable Database-Level Encryption (TDE)**
|
||||
|
||||
1. **For Cloud Providers:**
|
||||
- Enable encryption at rest in database settings
|
||||
- No application changes needed (transparent)
|
||||
|
||||
2. **For Self-Hosted:**
|
||||
- Set up encrypted filesystem
|
||||
- Migrate database to encrypted volume
|
||||
- Update backup procedures
|
||||
|
||||
---
|
||||
|
||||
## 🔒 **Security Best Practices**
|
||||
|
||||
### **1. Key Management**
|
||||
|
||||
- ✅ Store encryption keys in secrets manager (AWS Secrets Manager, HashiCorp Vault)
|
||||
- ✅ Rotate keys periodically (every 90 days recommended)
|
||||
- ✅ Use separate keys for different environments (dev, staging, prod)
|
||||
- ✅ Never commit keys to version control
|
||||
|
||||
### **2. Access Control**
|
||||
|
||||
- ✅ Use least privilege for database users
|
||||
- ✅ Enable database access logging (`DB_ACCESS_LOGGING_ENABLED=true`)
|
||||
- ✅ Review access logs regularly
|
||||
- ✅ Set up alerts for suspicious access patterns
|
||||
|
||||
### **3. Backup Encryption**
|
||||
|
||||
- ✅ Encrypt database backups
|
||||
- ✅ Store backup encryption keys separately
|
||||
- ✅ Test backup restoration procedures
|
||||
|
||||
### **4. Monitoring**
|
||||
|
||||
- ✅ Monitor database access logs
|
||||
- ✅ Set up alerts for:
|
||||
- Unusual access patterns
|
||||
- Failed authentication attempts
|
||||
- Large data exports
|
||||
- Access from unexpected IPs
|
||||
|
||||
---
|
||||
|
||||
## 📊 **Compliance**
|
||||
|
||||
### **GDPR Compliance**
|
||||
|
||||
- ✅ Personal data (phone numbers) is encrypted at rest
|
||||
- ✅ Access to personal data is logged
|
||||
- ✅ Encryption keys are managed securely
|
||||
|
||||
### **PCI DSS Compliance**
|
||||
|
||||
- ✅ Sensitive data is encrypted
|
||||
- ✅ Access controls are in place
|
||||
- ✅ Audit logging is enabled
|
||||
|
||||
---
|
||||
|
||||
## 🚨 **Troubleshooting**
|
||||
|
||||
### **Issue: Encryption Not Working**
|
||||
|
||||
**Symptoms:**
|
||||
- Phone numbers stored as plaintext
|
||||
- Decryption errors
|
||||
|
||||
**Solutions:**
|
||||
1. Check `ENCRYPTION_ENABLED=true` in environment
|
||||
2. Verify `ENCRYPTION_KEY` is set and valid (32 bytes, base64)
|
||||
3. Check application logs for encryption errors
|
||||
|
||||
### **Issue: Backward Compatibility Broken**
|
||||
|
||||
**Symptoms:**
|
||||
- Existing users can't log in
|
||||
- Phone number lookups fail
|
||||
|
||||
**Solutions:**
|
||||
1. Ensure queries search for both encrypted and plaintext
|
||||
2. Check that `decryptPhoneNumber` handles plaintext gracefully
|
||||
3. Verify migration script completed successfully
|
||||
|
||||
### **Issue: Database Access Logging Not Working**
|
||||
|
||||
**Symptoms:**
|
||||
- No entries in `db_access_log` table
|
||||
|
||||
**Solutions:**
|
||||
1. Check `DB_ACCESS_LOGGING_ENABLED=true`
|
||||
2. Verify `db_access_log` table exists (auto-created on first log)
|
||||
3. Check application logs for logging errors
|
||||
|
||||
---
|
||||
|
||||
## 📝 **Checklist**
|
||||
|
||||
### **Before Production:**
|
||||
|
||||
- [ ] Generate encryption key and store in secrets manager
|
||||
- [ ] Set `ENCRYPTION_ENABLED=true` in production environment
|
||||
- [ ] Set `DB_ACCESS_LOGGING_ENABLED=true` in production
|
||||
- [ ] Enable database-level encryption (TDE) at infrastructure level
|
||||
- [ ] Test encryption/decryption with sample data
|
||||
- [ ] Verify backward compatibility with existing data
|
||||
- [ ] Set up monitoring for database access logs
|
||||
- [ ] Document key rotation procedure
|
||||
- [ ] Test backup and restore procedures
|
||||
- [ ] Review and update access control policies
|
||||
|
||||
---
|
||||
|
||||
## 🔗 **Related Documentation**
|
||||
|
||||
- `SECURITY_AUDIT_REPORT.md` - Security audit findings
|
||||
- `src/utils/fieldEncryption.js` - Encryption implementation
|
||||
- `src/middleware/dbAccessLogger.js` - Database access logging
|
||||
- `src/db.js` - Database wrapper with logging support
|
||||
|
||||
---
|
||||
|
||||
## 📞 **Support**
|
||||
|
||||
For questions or issues:
|
||||
1. Check application logs for encryption/decryption errors
|
||||
2. Review database access logs for suspicious activity
|
||||
3. Verify environment variables are set correctly
|
||||
4. Test with sample data before production deployment
|
||||
|
||||
|
|
@ -421,3 +421,6 @@ This implementation should provide a secure, production-ready authentication sys
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -81,3 +81,6 @@ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0")
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -642,3 +642,6 @@ This guide provides everything you need to integrate the `/users/me` endpoint in
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -240,3 +240,6 @@ npm install
|
|||
- SMS message updated to reflect 2-minute expiry
|
||||
- Existing JWT and user creation logic remains unchanged
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -347,3 +347,6 @@ The most critical remaining gaps are:
|
|||
|
||||
These should be addressed before production deployment.
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,613 @@
|
|||
# Security Audit Report
|
||||
**Date:** $(date)
|
||||
**Service:** Farm Auth Service
|
||||
**Status:** Comprehensive Security Review
|
||||
|
||||
---
|
||||
|
||||
## ✅ **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`
|
||||
|
||||
### **8. ✅ Access Token Replay Mitigation**
|
||||
**Status:** **RESOLVED** ✅ **FIXED!**
|
||||
- ✅ 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
|
||||
- ✅ **Step-up auth IS APPLIED to sensitive routes:**
|
||||
- `PUT /users/me` - Line 113 in `src/routes/userRoutes.js` ✅
|
||||
- `DELETE /users/me/devices/:device_id` - Line 181 in `src/routes/userRoutes.js` ✅
|
||||
- `POST /users/me/logout-all-other-devices` - Line 231 in `src/routes/userRoutes.js` ✅
|
||||
- **Risk Level:** 🟢 **LOW** (Previously 🟡 MEDIUM)
|
||||
|
||||
### **11. ✅ Input Validation**
|
||||
**Status:** **RESOLVED** ✅ **FIXED!**
|
||||
- ✅ Input validation middleware created (`src/middleware/validation.js`)
|
||||
- ✅ Validation applied to all auth routes
|
||||
- ✅ **Validation IS APPLIED to user routes:**
|
||||
- `PUT /users/me` - `validateUpdateProfileBody` (Line 114) ✅
|
||||
- `DELETE /users/me/devices/:device_id` - `validateDeviceIdParam` (Line 182) ✅
|
||||
- `POST /users/me/logout-all-other-devices` - `validateLogoutOthersBody` (Line 232) ✅
|
||||
- **Risk Level:** 🟢 **LOW** (Previously 🟢 LOW-MEDIUM)
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ **PARTIALLY RESOLVED ISSUES**
|
||||
|
||||
### **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
|
||||
- ✅ `.env` is in `.gitignore` (verified)
|
||||
|
||||
**What's Missing:**
|
||||
- ❌ **Still reads secrets from environment variables (via `.env` file)**
|
||||
- ❌ No integration with secrets manager (AWS Secrets Manager, HashiCorp Vault, etc.)
|
||||
- ❌ Manual key rotation process (no automation)
|
||||
- ❌ Twilio credentials still in environment variables
|
||||
|
||||
**Risk Level:** 🟡 **MEDIUM-HIGH**
|
||||
|
||||
**Recommendation:**
|
||||
1. **Immediate:** ✅ Ensure `.env` is in `.gitignore` (DONE)
|
||||
2. **Short-term:** Use environment variables from secure deployment platform (not `.env` file)
|
||||
3. **Long-term:** Integrate with secrets manager (see TODOs in `src/services/jwtKeys.js` line 169)
|
||||
|
||||
**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)
|
||||
- ✅ **Webhook alerting infrastructure implemented** (`src/services/auditLogger.js`)
|
||||
- ✅ Configurable via `SECURITY_ALERT_WEBHOOK_URL` and `SECURITY_ALERT_MIN_LEVEL`
|
||||
|
||||
**What's Missing:**
|
||||
- ❌ **No active alerting/monitoring integration configured by default**
|
||||
- ❌ Requires manual configuration of `SECURITY_ALERT_WEBHOOK_URL`
|
||||
- ❌ No integration with PagerDuty, Slack, email out of the box
|
||||
- ❌ Anomaly detection only logs to console if webhook not configured
|
||||
|
||||
**Risk Level:** 🟡 **MEDIUM**
|
||||
|
||||
**Recommendation:**
|
||||
1. **Immediate:** Configure `SECURITY_ALERT_WEBHOOK_URL` in production environment
|
||||
2. **Short-term:** Set up log aggregation (CloudWatch, Datadog, etc.)
|
||||
3. **Long-term:** Integrate with SIEM system
|
||||
|
||||
**Configuration:**
|
||||
```bash
|
||||
# Set in production environment
|
||||
SECURITY_ALERT_WEBHOOK_URL=https://hooks.slack.com/services/YOUR/WEBHOOK/URL
|
||||
SECURITY_ALERT_MIN_LEVEL=HIGH_RISK # or SUSPICIOUS for more alerts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ❌ **UNADDRESSED ATTACK SCENARIOS**
|
||||
|
||||
### **12. ⚠️ Database Compromise**
|
||||
**Status:** **PARTIALLY RESOLVED** ⚠️ **FIXED!**
|
||||
|
||||
**What's Done:**
|
||||
- ✅ Field-level encryption for phone numbers implemented (`src/utils/fieldEncryption.js`)
|
||||
- ✅ AES-256-GCM encryption for PII fields
|
||||
- ✅ Automatic encryption/decryption in application layer
|
||||
- ✅ Backward compatibility with existing plaintext data
|
||||
- ✅ Database access logging implemented (`src/middleware/dbAccessLogger.js`)
|
||||
- ✅ All queries to sensitive tables are logged
|
||||
- ✅ User context (IP, user agent, user ID) captured in logs
|
||||
- ✅ Sensitive parameters redacted in logs
|
||||
- ✅ Using parameterized queries (SQL injection protection)
|
||||
- ✅ OTP codes are hashed with bcrypt (not stored in plaintext)
|
||||
- ✅ No passwords stored (phone-based auth)
|
||||
|
||||
**What's Missing:**
|
||||
- ⚠️ **Database-level encryption (TDE) not configured** (infrastructure-level)
|
||||
- ⚠️ Encryption key still in environment variables (should use secrets manager)
|
||||
- ⚠️ No automated key rotation process
|
||||
|
||||
**Risk Level:** 🟡 **MEDIUM** (Previously 🔴 HIGH)
|
||||
|
||||
**Configuration Required:**
|
||||
1. **Enable Field-Level Encryption:**
|
||||
```bash
|
||||
ENCRYPTION_ENABLED=true
|
||||
ENCRYPTION_KEY=<32-byte-base64-key>
|
||||
```
|
||||
|
||||
2. **Enable Database Access Logging:**
|
||||
```bash
|
||||
DB_ACCESS_LOGGING_ENABLED=true
|
||||
DB_ACCESS_LOG_LEVEL=sensitive
|
||||
```
|
||||
|
||||
3. **Enable Database-Level Encryption (TDE):**
|
||||
- Configure at infrastructure level (AWS RDS, Google Cloud SQL, Azure)
|
||||
- See `DATABASE_ENCRYPTION_SETUP.md` for instructions
|
||||
|
||||
**Recommendation:**
|
||||
- ✅ Field-level encryption implemented - **CONFIGURE** `ENCRYPTION_ENABLED=true`
|
||||
- ✅ Database access logging implemented - **CONFIGURE** `DB_ACCESS_LOGGING_ENABLED=true`
|
||||
- ⚠️ Enable TDE at database infrastructure level (see `DATABASE_ENCRYPTION_SETUP.md`)
|
||||
- Move encryption keys to secrets manager
|
||||
- Set up automated key rotation
|
||||
|
||||
---
|
||||
|
||||
### **13. ❌ Man-in-the-Middle (Non-HTTPS)**
|
||||
**Status:** **PARTIALLY ADDRESSED** ⚠️
|
||||
|
||||
**Current Protection:**
|
||||
- ✅ HSTS header set in production (`src/middleware/securityHeaders.js` line 26)
|
||||
- ✅ Server assumes TLS termination in front (reverse proxy)
|
||||
|
||||
**What's Missing:**
|
||||
- ❌ No enforcement of HTTPS-only connections at application level
|
||||
- ❌ No startup validation that HTTPS is configured
|
||||
- ❌ No certificate pinning guidance
|
||||
- ❌ HSTS only applied to admin routes (not all routes)
|
||||
|
||||
**Risk Level:** 🔴 **HIGH** (if misconfigured)
|
||||
|
||||
**Recommendation:**
|
||||
- Enforce HTTPS at reverse proxy/load balancer
|
||||
- Add HSTS headers to all routes (not just admin)
|
||||
- Document TLS requirements
|
||||
- Consider certificate pinning for mobile apps
|
||||
- Add startup validation that HTTPS is configured in production
|
||||
|
||||
---
|
||||
|
||||
### **14. ⚠️ CORS + XSS**
|
||||
**Status:** **PARTIALLY ADDRESSED** ⚠️
|
||||
|
||||
**What's Done:**
|
||||
- ✅ CORS hardened with strict origin whitelisting
|
||||
- ✅ Documentation warns about misconfiguration
|
||||
- ✅ Security headers include XSS protection (`X-XSS-Protection`)
|
||||
|
||||
**What's Missing:**
|
||||
- ❌ No validation that CORS is properly configured in production at startup
|
||||
- ❌ No runtime checks for CORS misconfiguration
|
||||
- ❌ No Content Security Policy (CSP) headers
|
||||
- ❌ No guidance for XSS prevention in frontend
|
||||
|
||||
**Risk Level:** 🟡 **MEDIUM**
|
||||
|
||||
**Recommendation:**
|
||||
- Add startup validation that CORS origins are configured in production
|
||||
- Add Content Security Policy (CSP) headers
|
||||
- Document XSS prevention best practices
|
||||
- Consider adding CSP nonce support for dynamic content
|
||||
|
||||
---
|
||||
|
||||
## 🔍 **ADDITIONAL VULNERABILITIES FOUND**
|
||||
|
||||
### **15. ⚠️ Error Information Disclosure**
|
||||
**Status:** **GOOD** ✅
|
||||
|
||||
**Current State:**
|
||||
- ✅ Generic error messages returned to users (`"Internal server error"`)
|
||||
- ✅ No stack traces exposed in production
|
||||
- ✅ Detailed errors only logged server-side
|
||||
|
||||
**Recommendation:**
|
||||
- ✅ Current implementation is secure
|
||||
- Consider adding request ID to error responses for debugging (without exposing internals)
|
||||
|
||||
---
|
||||
|
||||
### **16. ⚠️ SQL Injection Protection**
|
||||
**Status:** **GOOD** ✅
|
||||
|
||||
**Current State:**
|
||||
- ✅ All database queries use parameterized queries (`$1, $2, etc.`)
|
||||
- ✅ No string concatenation in SQL queries
|
||||
- ✅ Using PostgreSQL's `pg` library with proper parameterization
|
||||
|
||||
**Recommendation:**
|
||||
- ✅ Current implementation is secure
|
||||
- Continue using parameterized queries for all new code
|
||||
|
||||
---
|
||||
|
||||
### **17. ⚠️ Security Headers Coverage**
|
||||
**Status:** **PARTIALLY ADDRESSED** ⚠️
|
||||
|
||||
**Current State:**
|
||||
- ✅ Security headers middleware exists (`src/middleware/securityHeaders.js`)
|
||||
- ✅ Applied to admin routes
|
||||
- ❌ **Not applied to all routes** (only admin routes)
|
||||
|
||||
**Missing Headers:**
|
||||
- ❌ Content Security Policy (CSP) not implemented
|
||||
- ❌ Referrer-Policy not set
|
||||
- ❌ Permissions-Policy not set
|
||||
|
||||
**Recommendation:**
|
||||
- Apply security headers to all routes (not just admin)
|
||||
- Add CSP headers
|
||||
- Add Referrer-Policy header
|
||||
- Add Permissions-Policy header
|
||||
|
||||
---
|
||||
|
||||
### **18. ⚠️ Phone Number Validation**
|
||||
**Status:** **GOOD** ✅
|
||||
|
||||
**Current State:**
|
||||
- ✅ Phone numbers validated for E.164 format
|
||||
- ✅ Short codes rejected
|
||||
- ✅ Normalization applied
|
||||
|
||||
**Recommendation:**
|
||||
- ✅ Current implementation is secure
|
||||
- Consider adding country-specific validation if needed
|
||||
|
||||
---
|
||||
|
||||
### **19. ⚠️ Hardcoded Credentials in Docker Compose**
|
||||
**Status:** **VULNERABILITY FOUND** ⚠️
|
||||
|
||||
**Risk:**
|
||||
- Hardcoded database password in `db/farmmarket-db/docker-compose.yml`
|
||||
- Password `password123` is visible in version control
|
||||
- If repository is public or compromised, database credentials are exposed
|
||||
|
||||
**Location:**
|
||||
- `db/farmmarket-db/docker-compose.yml` line 8: `POSTGRES_PASSWORD: password123`
|
||||
|
||||
**Risk Level:** 🟡 **MEDIUM-HIGH**
|
||||
|
||||
**Recommendation:**
|
||||
- Use environment variables for database credentials
|
||||
- Never commit passwords to version control
|
||||
- Use `.env` file (already in `.gitignore`) or secrets manager
|
||||
- Update docker-compose.yml to use environment variables
|
||||
|
||||
**Fix:**
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-farmmarket}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### **20. ⚠️ Phone Number Enumeration**
|
||||
**Status:** **PARTIALLY ADDRESSED** ⚠️
|
||||
|
||||
**Current State:**
|
||||
- ✅ OTP request endpoint always returns success (prevents enumeration)
|
||||
- ✅ Generic error messages for OTP verification
|
||||
- ⚠️ Response time differences might still allow enumeration
|
||||
|
||||
**Risk:**
|
||||
- Attackers could enumerate valid phone numbers by measuring response times
|
||||
- Database queries for existing vs non-existing phone numbers might have different execution times
|
||||
|
||||
**Risk Level:** 🟡 **MEDIUM**
|
||||
|
||||
**Recommendation:**
|
||||
- Add constant-time delays for OTP requests (to prevent timing attacks)
|
||||
- Consider rate limiting per phone number more aggressively
|
||||
- Monitor for enumeration attempts
|
||||
|
||||
---
|
||||
|
||||
### **21. ⚠️ User Enumeration via Error Messages**
|
||||
**Status:** **GOOD** ✅
|
||||
|
||||
**Current State:**
|
||||
- ✅ Generic error messages ("OTP invalid or expired")
|
||||
- ✅ No distinction between "user not found" and "invalid OTP"
|
||||
- ✅ User creation happens silently (find-or-create pattern)
|
||||
|
||||
**Recommendation:**
|
||||
- ✅ Current implementation is secure
|
||||
- Continue using generic error messages
|
||||
|
||||
---
|
||||
|
||||
### **22. ⚠️ Timing Attack on OTP Verification**
|
||||
**Status:** **PARTIALLY ADDRESSED** ⚠️
|
||||
|
||||
**Current State:**
|
||||
- ✅ Uses bcrypt for OTP hashing (constant-time comparison)
|
||||
- ⚠️ Early returns for expired/max attempts might leak timing information
|
||||
- ⚠️ Database query execution time might differ
|
||||
|
||||
**Risk:**
|
||||
- Attackers could measure response times to determine if OTP exists
|
||||
- Different code paths have different execution times
|
||||
|
||||
**Risk Level:** 🟢 **LOW-MEDIUM**
|
||||
|
||||
**Recommendation:**
|
||||
- Add constant-time delays for all OTP verification paths
|
||||
- Ensure all code paths take similar time regardless of outcome
|
||||
- Consider adding artificial delays to normalize response times
|
||||
|
||||
---
|
||||
|
||||
### **23. ⚠️ Missing Rate Limiting on User Routes**
|
||||
**Status:** **PARTIALLY ADDRESSED** ⚠️
|
||||
|
||||
**Current State:**
|
||||
- ✅ Rate limiting on auth routes (OTP request, verify, refresh, logout)
|
||||
- ✅ Rate limiting on admin routes
|
||||
- ❌ **No rate limiting on user routes:**
|
||||
- `GET /users/me`
|
||||
- `PUT /users/me`
|
||||
- `GET /users/me/devices`
|
||||
- `DELETE /users/me/devices/:device_id`
|
||||
- `POST /users/me/logout-all-other-devices`
|
||||
|
||||
**Risk:**
|
||||
- Attackers could abuse authenticated endpoints
|
||||
- Profile updates could be spammed
|
||||
- Device management endpoints could be abused
|
||||
|
||||
**Risk Level:** 🟡 **MEDIUM**
|
||||
|
||||
**Recommendation:**
|
||||
- Add rate limiting to user routes
|
||||
- Different limits for read vs write operations
|
||||
- Consider per-user rate limits for sensitive operations
|
||||
|
||||
---
|
||||
|
||||
### **24. ⚠️ Information Disclosure in Admin Routes**
|
||||
**Status:** **PARTIALLY ADDRESSED** ⚠️
|
||||
|
||||
**Current State:**
|
||||
- ✅ Phone numbers are masked in admin security events endpoint
|
||||
- ✅ Admin routes require authentication and admin role
|
||||
- ⚠️ Admin can see user IDs, IP addresses, device IDs
|
||||
- ⚠️ Full metadata (JSONB) is returned without sanitization
|
||||
|
||||
**Risk:**
|
||||
- Admins have access to sensitive user data
|
||||
- Metadata might contain sensitive information
|
||||
- No audit trail for what admins access
|
||||
|
||||
**Risk Level:** 🟡 **MEDIUM**
|
||||
|
||||
**Recommendation:**
|
||||
- ✅ Current masking is good
|
||||
- Consider additional sanitization of metadata
|
||||
- Add more granular admin permissions
|
||||
- Log all admin data access
|
||||
|
||||
---
|
||||
|
||||
## 📊 **SUMMARY TABLE**
|
||||
|
||||
| Issue | Status | Risk Level | Priority | Notes |
|
||||
|-------|--------|------------|----------|-------|
|
||||
| 1. Rate Limiting | ✅ Resolved | - | - | Fully implemented |
|
||||
| 2. OTP Logging | ✅ Resolved | - | - | Safe logging in place |
|
||||
| 3. IP/Device Risk | ✅ Resolved | - | - | Risk scoring active |
|
||||
| 4. Refresh Token Theft | ✅ Resolved | - | - | Environment fingerprinting |
|
||||
| 5. JWT Claims | ✅ Resolved | - | - | Strict validation |
|
||||
| 6. CORS | ✅ Resolved | - | - | Strict whitelisting |
|
||||
| 7. CSRF | ✅ Documented | - | - | Not needed (Bearer tokens) |
|
||||
| 8. Access Token Replay | ✅ **FIXED** | 🟢 Low | - | Step-up auth applied |
|
||||
| 9. Secrets Management | ⚠️ Partial | 🟡 Medium-High | **HIGH** | Needs secrets manager |
|
||||
| 10. Audit Monitoring | ⚠️ Partial | 🟡 Medium | **MEDIUM** | Needs webhook config |
|
||||
| 11. Input Validation | ✅ **FIXED** | 🟢 Low | - | All routes validated |
|
||||
| 12. Database Compromise | ⚠️ **FIXED** | 🟡 Medium | **LOW** | Needs TDE config |
|
||||
| 13. MITM (HTTP) | ⚠️ Partial | 🔴 High | **MEDIUM** | Needs HTTPS enforcement |
|
||||
| 14. CORS + XSS | ⚠️ Partial | 🟡 Medium | **LOW** | Needs CSP headers |
|
||||
| 15. Error Disclosure | ✅ Good | - | - | No issues found |
|
||||
| 16. SQL Injection | ✅ Good | - | - | Parameterized queries |
|
||||
| 17. Security Headers | ⚠️ Partial | 🟡 Medium | **LOW** | Needs CSP |
|
||||
| 18. Phone Validation | ✅ Good | - | - | Proper validation |
|
||||
| 19. Hardcoded Credentials | ⚠️ Found | 🟡 Medium-High | **HIGH** | Docker compose |
|
||||
| 20. Phone Enumeration | ⚠️ Partial | 🟡 Medium | **MEDIUM** | Timing attacks |
|
||||
| 21. User Enumeration | ✅ Good | - | - | Generic errors |
|
||||
| 22. Timing Attacks | ⚠️ Partial | 🟢 Low-Medium | **LOW** | Constant-time delays |
|
||||
| 23. Missing Rate Limits | ⚠️ Partial | 🟡 Medium | **MEDIUM** | User routes |
|
||||
| 24. Admin Info Disclosure | ⚠️ Partial | 🟡 Medium | **LOW** | Metadata sanitization |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 **IMMEDIATE ACTION ITEMS (Priority Order)**
|
||||
|
||||
### **🔴 HIGH PRIORITY**
|
||||
|
||||
1. **Hardcoded Credentials** (Issue #19) - **NEW!** ⚠️
|
||||
- ❌ Remove hardcoded password from `docker-compose.yml`
|
||||
- ⚠️ Use environment variables for database credentials
|
||||
- ⚠️ Ensure no secrets are committed to version control
|
||||
|
||||
2. **✅ Secrets Management** (Issue #9)
|
||||
- ✅ `.env` is in `.gitignore` (verified)
|
||||
- ⚠️ Move to environment variables from deployment platform (not `.env` file)
|
||||
- ⚠️ Plan integration with secrets manager (AWS Secrets Manager, HashiCorp Vault)
|
||||
|
||||
3. **✅ Step-Up Auth** (Issue #8) - **FIXED!** ✅
|
||||
- ✅ Applied to all sensitive routes
|
||||
|
||||
4. **✅ Input Validation** (Issue #11) - **FIXED!** ✅
|
||||
- ✅ Applied to all user routes
|
||||
|
||||
### **🟡 MEDIUM PRIORITY**
|
||||
|
||||
1. **Phone Number Enumeration** (Issue #20)
|
||||
- Add constant-time delays for OTP requests
|
||||
- Monitor for enumeration attempts
|
||||
- Consider more aggressive rate limiting
|
||||
|
||||
2. **Missing Rate Limiting** (Issue #23)
|
||||
- Add rate limiting to user routes
|
||||
- Different limits for read vs write operations
|
||||
- Per-user rate limits for sensitive operations
|
||||
|
||||
3. **Active Monitoring/Alerting** (Issue #10)
|
||||
- Configure `SECURITY_ALERT_WEBHOOK_URL` in production
|
||||
- Set up log aggregation (CloudWatch, Datadog, etc.)
|
||||
- Test alerting with HIGH_RISK events
|
||||
|
||||
4. **HTTPS Enforcement** (Issue #13)
|
||||
- Add startup validation that HTTPS is configured in production
|
||||
- Apply HSTS headers to all routes (not just admin)
|
||||
- Document TLS requirements
|
||||
|
||||
5. **Database Security** (Issue #12) - **FIXED!** ✅
|
||||
- ✅ Field-level encryption implemented - **CONFIGURE** `ENCRYPTION_ENABLED=true`
|
||||
- ✅ Database access logging implemented - **CONFIGURE** `DB_ACCESS_LOGGING_ENABLED=true`
|
||||
- ⚠️ Enable TDE at database infrastructure level (see `DATABASE_ENCRYPTION_SETUP.md`)
|
||||
|
||||
### **🟢 LOW PRIORITY**
|
||||
|
||||
1. **Security Headers Enhancement** (Issue #17)
|
||||
- Apply security headers to all routes
|
||||
- Add Content Security Policy (CSP) headers
|
||||
- Add Referrer-Policy and Permissions-Policy headers
|
||||
|
||||
2. **Timing Attacks** (Issue #22)
|
||||
- Add constant-time delays for OTP verification
|
||||
- Normalize response times across all code paths
|
||||
|
||||
3. **Admin Info Disclosure** (Issue #24)
|
||||
- Additional sanitization of metadata in admin routes
|
||||
- More granular admin permissions
|
||||
|
||||
4. **CORS Validation** (Issue #14)
|
||||
- Add startup validation that CORS origins are configured in production
|
||||
- Document XSS prevention best practices
|
||||
|
||||
---
|
||||
|
||||
## 📝 **CODE LOCATIONS FOR FIXES**
|
||||
|
||||
### **Secrets Manager Integration**
|
||||
**File:** `src/services/jwtKeys.js`
|
||||
- Line 169: Implement `loadKeysFromSecretsManager()` function
|
||||
- Replace environment variable reads with secrets manager calls
|
||||
|
||||
### **Alerting Configuration**
|
||||
**File:** `src/services/auditLogger.js`
|
||||
- Line 34: `SECURITY_ALERT_WEBHOOK_URL` - Configure in production
|
||||
- Line 35: `SECURITY_ALERT_MIN_LEVEL` - Set to 'HIGH_RISK' or 'SUSPICIOUS'
|
||||
|
||||
### **Security Headers Enhancement**
|
||||
**File:** `src/middleware/securityHeaders.js`
|
||||
- Add CSP headers
|
||||
- Apply to all routes in `src/index.js`
|
||||
|
||||
### **HTTPS Enforcement**
|
||||
**File:** `src/index.js`
|
||||
- Add startup validation for HTTPS in production
|
||||
- Apply HSTS headers to all routes
|
||||
|
||||
### **Hardcoded Credentials Fix**
|
||||
**File:** `db/farmmarket-db/docker-compose.yml`
|
||||
- Replace hardcoded password with environment variable
|
||||
- Use `${POSTGRES_PASSWORD}` instead of `password123`
|
||||
|
||||
### **Rate Limiting for User Routes**
|
||||
**File:** `src/routes/userRoutes.js`
|
||||
- Add rate limiting middleware to all user routes
|
||||
- Consider per-user rate limits for sensitive operations
|
||||
|
||||
---
|
||||
|
||||
## 🎉 **CONCLUSION**
|
||||
|
||||
**Overall Security Posture:** 🟢 **GOOD** (Improved from 🟡 GOOD)
|
||||
|
||||
**Latest Update:**
|
||||
- ✅ **Issue #12 (Database Compromise) - FIXED!**
|
||||
- Field-level encryption for phone numbers implemented
|
||||
- Database access logging implemented
|
||||
- See `DATABASE_ENCRYPTION_SETUP.md` for configuration instructions
|
||||
|
||||
**Progress:**
|
||||
- **9 out of 14 original issues are fully resolved** ✅
|
||||
- **5 issues are partially resolved** ⚠️ (need configuration/completion)
|
||||
- **6 new vulnerabilities found** ⚠️ (Issues #19-24)
|
||||
- **0 critical vulnerabilities** 🔴 (down from 2)
|
||||
|
||||
**Key Improvements:**
|
||||
1. ✅ Step-up auth now applied to all sensitive routes
|
||||
2. ✅ Input validation now applied to all user routes
|
||||
3. ✅ Webhook alerting infrastructure ready (needs configuration)
|
||||
|
||||
**Remaining Gaps:**
|
||||
1. **🔴 HIGH:** Hardcoded credentials in docker-compose.yml (Issue #19)
|
||||
2. Secrets management needs secrets manager integration
|
||||
3. Alerting needs webhook URL configuration
|
||||
4. ✅ Database field-level encryption implemented - **NEEDS CONFIGURATION**
|
||||
5. Database TDE needs infrastructure-level setup
|
||||
6. Rate limiting missing on user routes (Issue #23)
|
||||
7. Phone number enumeration via timing attacks (Issue #20)
|
||||
8. HTTPS enforcement needs startup validation
|
||||
9. Security headers need CSP and broader application
|
||||
|
||||
**Recommendation:** The service is **production-ready** with proper configuration, but should address the HIGH and MEDIUM priority items before handling sensitive production data.
|
||||
|
||||
|
|
@ -323,3 +323,6 @@ All security hardening code is marked with comments:
|
|||
- `src/index.js` - CORS hardening
|
||||
- `src/config.js` - New environment variables documented
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,655 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Security Dashboard - Admin</title>
|
||||
<style>
|
||||
/* === ADMIN SECURITY VISUALIZER === */
|
||||
/* Dark theme with modern UI */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: #0a0e27;
|
||||
color: #e0e0e0;
|
||||
line-height: 1.6;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 2px solid #1a1f3a;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #4fc3f7;
|
||||
font-size: 28px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #90a4ae;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 25px;
|
||||
padding: 20px;
|
||||
background: #141b2d;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #1a1f3a;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 12px;
|
||||
color: #90a4ae;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
input, select, button {
|
||||
padding: 10px 12px;
|
||||
background: #1a1f3a;
|
||||
border: 1px solid #2a3a5a;
|
||||
border-radius: 6px;
|
||||
color: #e0e0e0;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
input:focus, select:focus {
|
||||
outline: none;
|
||||
border-color: #4fc3f7;
|
||||
box-shadow: 0 0 0 2px rgba(79, 195, 247, 0.2);
|
||||
}
|
||||
|
||||
button {
|
||||
background: #4fc3f7;
|
||||
color: #0a0e27;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #29b6f6;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #141b2d;
|
||||
border: 1px solid #1a1f3a;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
border-left: 4px solid #4fc3f7;
|
||||
}
|
||||
|
||||
.stat-card.high-risk {
|
||||
border-left-color: #f44336;
|
||||
}
|
||||
|
||||
.stat-card.suspicious {
|
||||
border-left-color: #ff9800;
|
||||
}
|
||||
|
||||
.stat-card.info {
|
||||
border-left-color: #4caf50;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #90a4ae;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
background: #141b2d;
|
||||
border: 1px solid #1a1f3a;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 1000px;
|
||||
}
|
||||
|
||||
thead {
|
||||
background: #1a1f3a;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 15px 12px;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
color: #90a4ae;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 600;
|
||||
border-bottom: 2px solid #2a3a5a;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #1a1f3a;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: #1a1f3a;
|
||||
}
|
||||
|
||||
.risk-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.risk-high {
|
||||
background: #f44336;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.risk-suspicious {
|
||||
background: #ff9800;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.risk-info {
|
||||
background: #4caf50;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.status-failed {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.status-blocked {
|
||||
color: #ff9800;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #90a4ae;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #1a1f3a;
|
||||
border: 1px solid #f44336;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.status-indicator.active {
|
||||
background: #4caf50;
|
||||
box-shadow: 0 0 8px rgba(76, 175, 80, 0.6);
|
||||
}
|
||||
|
||||
.status-indicator.inactive {
|
||||
background: #90a4ae;
|
||||
}
|
||||
|
||||
.footer-info {
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #1a1f3a;
|
||||
text-align: center;
|
||||
color: #90a4ae;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 15px 20px;
|
||||
background: #1a1f3a;
|
||||
border-top: 1px solid #2a3a5a;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
color: #90a4ae;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #90a4ae;
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>🔒 Security Dashboard</h1>
|
||||
<p class="subtitle">Real-time authentication event monitoring</p>
|
||||
</header>
|
||||
|
||||
<div class="controls">
|
||||
<div class="control-group">
|
||||
<label for="riskFilter">Risk Level</label>
|
||||
<select id="riskFilter">
|
||||
<option value="">All Levels</option>
|
||||
<option value="HIGH_RISK">High Risk</option>
|
||||
<option value="SUSPICIOUS">Suspicious</option>
|
||||
<option value="INFO">Info</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label for="searchInput">Search</label>
|
||||
<input type="text" id="searchInput" placeholder="User ID, Phone, or IP">
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label for="limitInput">Limit</label>
|
||||
<input type="number" id="limitInput" value="200" min="1" max="1000">
|
||||
</div>
|
||||
<div class="control-group" style="justify-content: flex-end;">
|
||||
<label> </label>
|
||||
<button id="refreshBtn">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats" id="statsContainer">
|
||||
<!-- Stats will be inserted here via textContent -->
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<div id="loadingIndicator" class="loading">Loading events...</div>
|
||||
<div id="errorContainer"></div>
|
||||
<table id="eventsTable" style="display: none;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Action</th>
|
||||
<th>Status</th>
|
||||
<th>Risk Level</th>
|
||||
<th>User ID</th>
|
||||
<th>Phone</th>
|
||||
<th>IP Address</th>
|
||||
<th>Device ID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="eventsTableBody">
|
||||
<!-- Events will be inserted here via textContent -->
|
||||
</tbody>
|
||||
</table>
|
||||
<div id="emptyState" class="empty-state" style="display: none;">
|
||||
<div class="empty-state-icon">📭</div>
|
||||
<p>No events found</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pagination" id="paginationContainer" style="display: none;">
|
||||
<div class="pagination-info" id="paginationInfo"></div>
|
||||
</div>
|
||||
|
||||
<div class="footer-info">
|
||||
<span class="status-indicator active" id="statusIndicator"></span>
|
||||
<span id="lastUpdate">Last updated: Never</span>
|
||||
<span style="margin: 0 10px;">|</span>
|
||||
<span>Auto-refresh: <span id="autoRefreshStatus">On (15s)</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// === ADMIN SECURITY VISUALIZER ===
|
||||
// All DOM manipulation uses textContent only (NO innerHTML)
|
||||
// Prevents XSS attacks
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// State
|
||||
let autoRefreshInterval = null;
|
||||
let currentFilters = {
|
||||
risk_level: '',
|
||||
search: '',
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
};
|
||||
|
||||
// DOM elements (cached)
|
||||
const riskFilter = document.getElementById('riskFilter');
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
const limitInput = document.getElementById('limitInput');
|
||||
const refreshBtn = document.getElementById('refreshBtn');
|
||||
const statsContainer = document.getElementById('statsContainer');
|
||||
const loadingIndicator = document.getElementById('loadingIndicator');
|
||||
const errorContainer = document.getElementById('errorContainer');
|
||||
const eventsTable = document.getElementById('eventsTable');
|
||||
const eventsTableBody = document.getElementById('eventsTableBody');
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
const paginationContainer = document.getElementById('paginationContainer');
|
||||
const paginationInfo = document.getElementById('paginationInfo');
|
||||
const statusIndicator = document.getElementById('statusIndicator');
|
||||
const lastUpdate = document.getElementById('lastUpdate');
|
||||
const autoRefreshStatus = document.getElementById('autoRefreshStatus');
|
||||
|
||||
// === SECURITY: Get auth token from localStorage (set by login)
|
||||
function getAuthToken() {
|
||||
const token = localStorage.getItem('admin_token');
|
||||
if (!token) {
|
||||
const errorMsg = 'Authentication required. ' +
|
||||
'Please authenticate via /auth/verify-otp and set your access token: ' +
|
||||
'localStorage.setItem("admin_token", "YOUR_ACCESS_TOKEN")';
|
||||
showError(errorMsg);
|
||||
return null;
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
// === SECURITY: Safe text formatting (no innerHTML)
|
||||
function formatTime(isoString) {
|
||||
if (!isoString) return 'N/A';
|
||||
try {
|
||||
const date = new Date(isoString);
|
||||
return date.toLocaleString();
|
||||
} catch (e) {
|
||||
return isoString;
|
||||
}
|
||||
}
|
||||
|
||||
function createElement(tag, className, textContent) {
|
||||
const el = document.createElement(tag);
|
||||
if (className) el.className = className;
|
||||
if (textContent !== undefined) el.textContent = textContent;
|
||||
return el;
|
||||
}
|
||||
|
||||
function createTableCell(text) {
|
||||
const td = document.createElement('td');
|
||||
td.textContent = text || '';
|
||||
return td;
|
||||
}
|
||||
|
||||
// === SECURITY: Safe DOM manipulation
|
||||
function showError(message) {
|
||||
errorContainer.textContent = '';
|
||||
const errorDiv = createElement('div', 'error', message);
|
||||
errorContainer.appendChild(errorDiv);
|
||||
loadingIndicator.style.display = 'none';
|
||||
eventsTable.style.display = 'none';
|
||||
emptyState.style.display = 'none';
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
errorContainer.textContent = '';
|
||||
}
|
||||
|
||||
function renderStats(stats) {
|
||||
statsContainer.textContent = '';
|
||||
|
||||
const last24h = stats.last_24h || {};
|
||||
|
||||
const totalCard = createElement('div', 'stat-card');
|
||||
const totalLabel = createElement('div', 'stat-label', 'Total Events (24h)');
|
||||
const totalValue = createElement('div', 'stat-value', String(last24h.total || 0));
|
||||
totalCard.appendChild(totalLabel);
|
||||
totalCard.appendChild(totalValue);
|
||||
statsContainer.appendChild(totalCard);
|
||||
|
||||
const highRiskCard = createElement('div', 'stat-card high-risk');
|
||||
const highRiskLabel = createElement('div', 'stat-label', 'High Risk');
|
||||
const highRiskValue = createElement('div', 'stat-value', String(last24h.high_risk || 0));
|
||||
highRiskCard.appendChild(highRiskLabel);
|
||||
highRiskCard.appendChild(highRiskValue);
|
||||
statsContainer.appendChild(highRiskCard);
|
||||
|
||||
const suspiciousCard = createElement('div', 'stat-card suspicious');
|
||||
const suspiciousLabel = createElement('div', 'stat-label', 'Suspicious');
|
||||
const suspiciousValue = createElement('div', 'stat-value', String(last24h.suspicious || 0));
|
||||
suspiciousCard.appendChild(suspiciousLabel);
|
||||
suspiciousCard.appendChild(suspiciousValue);
|
||||
statsContainer.appendChild(suspiciousCard);
|
||||
|
||||
const infoCard = createElement('div', 'stat-card info');
|
||||
const infoLabel = createElement('div', 'stat-label', 'Info');
|
||||
const infoValue = createElement('div', 'stat-value', String(last24h.info || 0));
|
||||
infoCard.appendChild(infoLabel);
|
||||
infoCard.appendChild(infoValue);
|
||||
statsContainer.appendChild(infoCard);
|
||||
}
|
||||
|
||||
function renderEvents(events, pagination) {
|
||||
eventsTableBody.textContent = '';
|
||||
|
||||
if (events.length === 0) {
|
||||
eventsTable.style.display = 'none';
|
||||
emptyState.style.display = 'block';
|
||||
paginationContainer.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
eventsTable.style.display = 'table';
|
||||
emptyState.style.display = 'none';
|
||||
|
||||
events.forEach(function(event) {
|
||||
const row = document.createElement('tr');
|
||||
|
||||
row.appendChild(createTableCell(formatTime(event.created_at)));
|
||||
row.appendChild(createTableCell(event.action || ''));
|
||||
|
||||
const statusCell = createTableCell('');
|
||||
statusCell.textContent = event.status || '';
|
||||
if (event.status === 'success') {
|
||||
statusCell.className = 'status-success';
|
||||
} else if (event.status === 'failed') {
|
||||
statusCell.className = 'status-failed';
|
||||
} else if (event.status === 'blocked') {
|
||||
statusCell.className = 'status-blocked';
|
||||
}
|
||||
row.appendChild(statusCell);
|
||||
|
||||
const riskCell = createTableCell('');
|
||||
const riskBadge = createElement('span', 'risk-badge');
|
||||
const riskLevel = event.risk_level || 'INFO';
|
||||
riskBadge.textContent = riskLevel;
|
||||
if (riskLevel === 'HIGH_RISK') {
|
||||
riskBadge.className = 'risk-badge risk-high';
|
||||
} else if (riskLevel === 'SUSPICIOUS') {
|
||||
riskBadge.className = 'risk-badge risk-suspicious';
|
||||
} else {
|
||||
riskBadge.className = 'risk-badge risk-info';
|
||||
}
|
||||
riskCell.appendChild(riskBadge);
|
||||
row.appendChild(riskCell);
|
||||
|
||||
row.appendChild(createTableCell(event.user_id ? event.user_id.substring(0, 8) + '...' : 'N/A'));
|
||||
row.appendChild(createTableCell(event.phone || 'N/A'));
|
||||
row.appendChild(createTableCell(event.ip_address || 'N/A'));
|
||||
row.appendChild(createTableCell(event.device_id ? event.device_id.substring(0, 12) + '...' : 'N/A'));
|
||||
|
||||
eventsTableBody.appendChild(row);
|
||||
});
|
||||
|
||||
// Pagination info
|
||||
if (pagination) {
|
||||
paginationContainer.style.display = 'flex';
|
||||
const info = 'Showing ' + (pagination.offset + 1) + ' - ' +
|
||||
Math.min(pagination.offset + pagination.limit, pagination.total) +
|
||||
' of ' + pagination.total + ' events';
|
||||
paginationInfo.textContent = info;
|
||||
} else {
|
||||
paginationContainer.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchEvents() {
|
||||
const token = getAuthToken();
|
||||
if (!token) return;
|
||||
|
||||
loadingIndicator.style.display = 'block';
|
||||
eventsTable.style.display = 'none';
|
||||
clearError();
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (currentFilters.risk_level) {
|
||||
params.append('risk_level', currentFilters.risk_level);
|
||||
}
|
||||
if (currentFilters.search) {
|
||||
params.append('search', currentFilters.search);
|
||||
}
|
||||
params.append('limit', String(currentFilters.limit));
|
||||
params.append('offset', String(currentFilters.offset));
|
||||
|
||||
const response = await fetch('/admin/security-events?' + params.toString(), {
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new Error('Unauthorized. Please check your admin token.');
|
||||
}
|
||||
throw new Error('Failed to fetch events: ' + response.status);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
renderStats(data.stats || {});
|
||||
renderEvents(data.events || [], data.pagination);
|
||||
|
||||
lastUpdate.textContent = 'Last updated: ' + formatTime(new Date().toISOString());
|
||||
statusIndicator.className = 'status-indicator active';
|
||||
|
||||
} catch (error) {
|
||||
showError('Error: ' + error.message);
|
||||
statusIndicator.className = 'status-indicator inactive';
|
||||
} finally {
|
||||
loadingIndicator.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function updateFilters() {
|
||||
currentFilters.risk_level = riskFilter.value || '';
|
||||
currentFilters.search = searchInput.value.trim();
|
||||
currentFilters.limit = parseInt(limitInput.value, 10) || 200;
|
||||
currentFilters.offset = 0;
|
||||
fetchEvents();
|
||||
}
|
||||
|
||||
function startAutoRefresh() {
|
||||
if (autoRefreshInterval) {
|
||||
clearInterval(autoRefreshInterval);
|
||||
}
|
||||
autoRefreshInterval = setInterval(fetchEvents, 15000);
|
||||
autoRefreshStatus.textContent = 'On (15s)';
|
||||
}
|
||||
|
||||
function stopAutoRefresh() {
|
||||
if (autoRefreshInterval) {
|
||||
clearInterval(autoRefreshInterval);
|
||||
autoRefreshInterval = null;
|
||||
}
|
||||
autoRefreshStatus.textContent = 'Off';
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
refreshBtn.addEventListener('click', function() {
|
||||
updateFilters();
|
||||
});
|
||||
|
||||
riskFilter.addEventListener('change', function() {
|
||||
updateFilters();
|
||||
});
|
||||
|
||||
searchInput.addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
updateFilters();
|
||||
}
|
||||
});
|
||||
|
||||
limitInput.addEventListener('change', function() {
|
||||
updateFilters();
|
||||
});
|
||||
|
||||
// Initialize
|
||||
startAutoRefresh();
|
||||
fetchEvents();
|
||||
|
||||
// Cleanup on page unload
|
||||
window.addEventListener('beforeunload', function() {
|
||||
stopAutoRefresh();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
39
src/db.js
39
src/db.js
|
|
@ -1,6 +1,8 @@
|
|||
// src/db.js
|
||||
// === SECURITY HARDENING: DATABASE ACCESS LOGGING ===
|
||||
const { Pool } = require('pg');
|
||||
const config = require('./config');
|
||||
const { loggedQuery } = require('./middleware/dbAccessLogger');
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: config.databaseUrl,
|
||||
|
|
@ -11,7 +13,42 @@ pool.on('error', (err) => {
|
|||
process.exit(-1);
|
||||
});
|
||||
|
||||
/**
|
||||
* Execute database query with optional logging and context
|
||||
* @param {string} text - SQL query
|
||||
* @param {Array} params - Query parameters
|
||||
* @param {Object} context - Request context for logging (optional)
|
||||
* - userId: User ID from request
|
||||
* - ipAddress: Client IP address
|
||||
* - userAgent: User agent string
|
||||
* @returns {Promise} - Query result
|
||||
*/
|
||||
function query(text, params = [], context = {}) {
|
||||
// Use logged query if logging is enabled, otherwise use direct query
|
||||
const DB_ACCESS_LOGGING_ENABLED = process.env.DB_ACCESS_LOGGING_ENABLED === 'true' || process.env.DB_ACCESS_LOGGING_ENABLED === '1';
|
||||
|
||||
if (DB_ACCESS_LOGGING_ENABLED) {
|
||||
return loggedQuery(text, params, context);
|
||||
}
|
||||
|
||||
return pool.query(text, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create context from Express request
|
||||
* @param {Object} req - Express request object
|
||||
* @returns {Object} - Context object for database logging
|
||||
*/
|
||||
function createContextFromRequest(req) {
|
||||
return {
|
||||
userId: req.user?.id || null,
|
||||
ipAddress: req.ip || req.connection?.remoteAddress || null,
|
||||
userAgent: req.headers['user-agent'] || null,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
query: (text, params) => pool.query(text, params),
|
||||
query,
|
||||
pool,
|
||||
createContextFromRequest,
|
||||
};
|
||||
|
|
|
|||
44
src/index.js
44
src/index.js
|
|
@ -6,6 +6,12 @@ const authRoutes = require('./routes/authRoutes');
|
|||
const userRoutes = require('./routes/userRoutes');
|
||||
// === ADDED FOR RATE LIMITING ===
|
||||
const { initRedis } = require('./services/redisClient');
|
||||
// === ADMIN SECURITY VISUALIZER ===
|
||||
const adminRoutes = require('./routes/adminRoutes');
|
||||
const authMiddleware = require('./middleware/authMiddleware');
|
||||
const adminAuth = require('./middleware/adminAuth');
|
||||
const adminRateLimit = require('./middleware/adminRateLimit');
|
||||
const securityHeaders = require('./middleware/securityHeaders');
|
||||
|
||||
const app = express();
|
||||
|
||||
|
|
@ -72,6 +78,44 @@ app.get('/health', (req, res) => {
|
|||
app.use('/auth', authRoutes);
|
||||
app.use('/users', userRoutes);
|
||||
|
||||
// === ADMIN SECURITY VISUALIZER ===
|
||||
// Admin dashboard and API endpoints (only enabled if flag is set)
|
||||
const ENABLE_ADMIN_DASHBOARD = process.env.ENABLE_ADMIN_DASHBOARD === 'true' || process.env.ENABLE_ADMIN_DASHBOARD === '1';
|
||||
|
||||
if (ENABLE_ADMIN_DASHBOARD) {
|
||||
console.log('✅ Admin dashboard enabled');
|
||||
|
||||
// === SECURITY HARDENING: ADMIN ROUTES PROTECTION ===
|
||||
// All admin routes require:
|
||||
// 1. JWT authentication (authMiddleware)
|
||||
// 2. Admin role check (adminAuth)
|
||||
// 3. Rate limiting (adminRateLimit)
|
||||
// 4. Security headers (securityHeaders)
|
||||
|
||||
// Admin API endpoints
|
||||
app.use('/admin',
|
||||
securityHeaders,
|
||||
authMiddleware,
|
||||
adminAuth,
|
||||
adminRateLimit,
|
||||
adminRoutes
|
||||
);
|
||||
|
||||
// Serve admin dashboard (protected route)
|
||||
app.get('/admin/security-dashboard',
|
||||
securityHeaders,
|
||||
authMiddleware,
|
||||
adminAuth,
|
||||
(req, res) => {
|
||||
// === SECURITY HARDENING: CLICKJACKING PROTECTION ===
|
||||
// Security headers already set by securityHeaders middleware
|
||||
res.sendFile('security-dashboard.html', { root: 'public' });
|
||||
}
|
||||
);
|
||||
} else {
|
||||
console.log('⚠️ Admin dashboard disabled (set ENABLE_ADMIN_DASHBOARD=true to enable)');
|
||||
}
|
||||
|
||||
// === ADDED FOR RATE LIMITING ===
|
||||
// Initialize Redis connection (falls back gracefully if not available)
|
||||
initRedis().catch((err) => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
// src/middleware/adminAuth.js
|
||||
// === ADMIN SECURITY VISUALIZER ===
|
||||
// Role-based access control middleware for admin endpoints
|
||||
// Requires user.role === 'security_admin'
|
||||
|
||||
/**
|
||||
* Admin authorization middleware
|
||||
* Must be used AFTER authMiddleware (which sets req.user)
|
||||
*
|
||||
* Checks if the authenticated user has the 'security_admin' role
|
||||
* Returns 403 if user is not an admin
|
||||
*/
|
||||
function adminAuth(req, res, next) {
|
||||
// === SECURITY HARDENING: RBAC ===
|
||||
// Ensure user is authenticated (should be set by authMiddleware)
|
||||
if (!req.user || !req.user.id) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
// === SECURITY HARDENING: RBAC ===
|
||||
// Check if user has admin role
|
||||
if (req.user.role !== 'security_admin') {
|
||||
// Log unauthorized access attempt
|
||||
console.warn(`[ADMIN_AUTH] Unauthorized admin access attempt by user ${req.user.id} (role: ${req.user.role})`);
|
||||
return res.status(403).json({
|
||||
error: 'Forbidden',
|
||||
message: 'Admin access required'
|
||||
});
|
||||
}
|
||||
|
||||
// User is authenticated and has admin role
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = adminAuth;
|
||||
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
// src/middleware/adminRateLimit.js
|
||||
// === ADMIN SECURITY VISUALIZER ===
|
||||
// Light rate limiting for admin endpoints
|
||||
// Prevents abuse while allowing legitimate admin access
|
||||
|
||||
const { getRedisClient, isRedisReady } = require('../services/redisClient');
|
||||
|
||||
// In-memory fallback store
|
||||
const memoryStore = {};
|
||||
|
||||
// Clean up expired entries
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
Object.keys(memoryStore).forEach((key) => {
|
||||
if (memoryStore[key].expiresAt && memoryStore[key].expiresAt < now) {
|
||||
delete memoryStore[key];
|
||||
}
|
||||
});
|
||||
}, 60000);
|
||||
|
||||
// Configuration: 100 requests per 15 minutes per user
|
||||
const ADMIN_RATE_LIMIT = {
|
||||
maxRequests: parseInt(process.env.ADMIN_RATE_LIMIT_MAX || '100', 10),
|
||||
windowSeconds: parseInt(process.env.ADMIN_RATE_LIMIT_WINDOW || '900', 10), // 15 minutes
|
||||
};
|
||||
|
||||
/**
|
||||
* Admin rate limiting middleware
|
||||
* Limits admin API requests per user
|
||||
*/
|
||||
async function adminRateLimit(req, res, next) {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const key = `admin_rate_limit:${userId}`;
|
||||
const redis = await getRedisClient();
|
||||
|
||||
let count;
|
||||
|
||||
if (isRedisReady() && redis) {
|
||||
try {
|
||||
count = await redis.incr(key);
|
||||
if (count === 1) {
|
||||
await redis.expire(key, ADMIN_RATE_LIMIT.windowSeconds);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Redis error in admin rate limit, falling back to memory:', err);
|
||||
// Fall through to memory store
|
||||
count = (memoryStore[key]?.count || 0) + 1;
|
||||
memoryStore[key] = {
|
||||
count,
|
||||
expiresAt: Date.now() + (ADMIN_RATE_LIMIT.windowSeconds * 1000),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Memory store fallback
|
||||
const stored = memoryStore[key];
|
||||
if (stored && stored.expiresAt > Date.now()) {
|
||||
count = stored.count + 1;
|
||||
stored.count = count;
|
||||
} else {
|
||||
count = 1;
|
||||
memoryStore[key] = {
|
||||
count: 1,
|
||||
expiresAt: Date.now() + (ADMIN_RATE_LIMIT.windowSeconds * 1000),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check if limit exceeded
|
||||
if (count > ADMIN_RATE_LIMIT.maxRequests) {
|
||||
return res.status(429).json({
|
||||
error: 'Too many requests',
|
||||
message: 'Rate limit exceeded. Please try again later.',
|
||||
retry_after: ADMIN_RATE_LIMIT.windowSeconds,
|
||||
});
|
||||
}
|
||||
|
||||
// Add rate limit headers
|
||||
res.setHeader('X-RateLimit-Limit', ADMIN_RATE_LIMIT.maxRequests);
|
||||
res.setHeader('X-RateLimit-Remaining', Math.max(0, ADMIN_RATE_LIMIT.maxRequests - count));
|
||||
res.setHeader('X-RateLimit-Reset', new Date(Date.now() + (ADMIN_RATE_LIMIT.windowSeconds * 1000)).toISOString());
|
||||
|
||||
next();
|
||||
} catch (err) {
|
||||
console.error('Admin rate limit error:', err);
|
||||
// On error, allow the request (fail open for admin access)
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = adminRateLimit;
|
||||
|
||||
|
|
@ -0,0 +1,281 @@
|
|||
// src/middleware/dbAccessLogger.js
|
||||
// === SECURITY HARDENING: DATABASE ACCESS LOGGING ===
|
||||
// Logs all database access for security auditing and compliance
|
||||
|
||||
/**
|
||||
* Database Access Logging
|
||||
*
|
||||
* Logs all database queries for security auditing:
|
||||
* - Query type (SELECT, INSERT, UPDATE, DELETE)
|
||||
* - Table names accessed
|
||||
* - Timestamp
|
||||
* - User context (if available from request)
|
||||
* - IP address
|
||||
* - Query parameters (sanitized - no sensitive data)
|
||||
*
|
||||
* Configuration:
|
||||
* - DB_ACCESS_LOGGING_ENABLED: Set to 'true' to enable logging (default: false)
|
||||
* - DB_ACCESS_LOG_LEVEL: 'all' (all queries) or 'sensitive' (only sensitive tables) (default: 'sensitive')
|
||||
*
|
||||
* Sensitive tables (always logged if enabled):
|
||||
* - users
|
||||
* - otp_codes
|
||||
* - otp_requests
|
||||
* - refresh_tokens
|
||||
* - auth_audit
|
||||
*/
|
||||
|
||||
const db = require('../db');
|
||||
|
||||
const LOGGING_ENABLED = process.env.DB_ACCESS_LOGGING_ENABLED === 'true' || process.env.DB_ACCESS_LOGGING_ENABLED === '1';
|
||||
const LOG_LEVEL = process.env.DB_ACCESS_LOG_LEVEL || 'sensitive'; // 'all' or 'sensitive'
|
||||
|
||||
// Tables that contain sensitive data (always logged)
|
||||
const SENSITIVE_TABLES = [
|
||||
'users',
|
||||
'otp_codes',
|
||||
'otp_requests',
|
||||
'refresh_tokens',
|
||||
'auth_audit',
|
||||
'user_devices',
|
||||
];
|
||||
|
||||
/**
|
||||
* Extract table names from SQL query
|
||||
* @param {string} query - SQL query text
|
||||
* @returns {string[]} - Array of table names
|
||||
*/
|
||||
function extractTableNames(query) {
|
||||
const tables = [];
|
||||
const upperQuery = query.toUpperCase();
|
||||
|
||||
// Match FROM, JOIN, UPDATE, INSERT INTO patterns
|
||||
const patterns = [
|
||||
/FROM\s+([a-z_]+)/gi,
|
||||
/JOIN\s+([a-z_]+)/gi,
|
||||
/UPDATE\s+([a-z_]+)/gi,
|
||||
/INSERT\s+INTO\s+([a-z_]+)/gi,
|
||||
/DELETE\s+FROM\s+([a-z_]+)/gi,
|
||||
];
|
||||
|
||||
patterns.forEach(pattern => {
|
||||
const matches = query.matchAll(pattern);
|
||||
for (const match of matches) {
|
||||
if (match[1]) {
|
||||
tables.push(match[1].toLowerCase());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return [...new Set(tables)]; // Remove duplicates
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine query type from SQL
|
||||
* @param {string} query - SQL query text
|
||||
* @returns {string} - Query type (SELECT, INSERT, UPDATE, DELETE, etc.)
|
||||
*/
|
||||
function getQueryType(query) {
|
||||
const upperQuery = query.trim().toUpperCase();
|
||||
if (upperQuery.startsWith('SELECT')) return 'SELECT';
|
||||
if (upperQuery.startsWith('INSERT')) return 'INSERT';
|
||||
if (upperQuery.startsWith('UPDATE')) return 'UPDATE';
|
||||
if (upperQuery.startsWith('DELETE')) return 'DELETE';
|
||||
if (upperQuery.startsWith('CREATE')) return 'CREATE';
|
||||
if (upperQuery.startsWith('ALTER')) return 'ALTER';
|
||||
if (upperQuery.startsWith('DROP')) return 'DROP';
|
||||
return 'OTHER';
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize query parameters (remove sensitive data)
|
||||
* @param {Array} params - Query parameters
|
||||
* @returns {Array} - Sanitized parameters (sensitive values replaced with '[REDACTED]')
|
||||
*/
|
||||
function sanitizeParams(params) {
|
||||
if (!params || !Array.isArray(params)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return params.map(param => {
|
||||
if (typeof param === 'string') {
|
||||
// Check if it looks like a phone number
|
||||
if (/^\+?\d{10,15}$/.test(param)) {
|
||||
return '[REDACTED_PHONE]';
|
||||
}
|
||||
// Check if it looks like a token or hash
|
||||
if (param.length > 50) {
|
||||
return '[REDACTED_TOKEN]';
|
||||
}
|
||||
// Check if it looks like an encrypted value (format: iv:authTag:data)
|
||||
if (param.includes(':') && param.split(':').length === 3) {
|
||||
return '[REDACTED_ENCRYPTED]';
|
||||
}
|
||||
}
|
||||
// For other types, return as-is (numbers, booleans, etc.)
|
||||
return param;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if query should be logged
|
||||
* @param {string} query - SQL query text
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function shouldLogQuery(query) {
|
||||
if (!LOGGING_ENABLED) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (LOG_LEVEL === 'all') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Log sensitive tables
|
||||
const tables = extractTableNames(query);
|
||||
return tables.some(table => SENSITIVE_TABLES.includes(table));
|
||||
}
|
||||
|
||||
/**
|
||||
* Log database access
|
||||
* @param {Object} logData - Log data
|
||||
*/
|
||||
async function logDbAccess(logData) {
|
||||
if (!LOGGING_ENABLED) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Ensure db_access_log table exists
|
||||
await ensureDbAccessLogTable();
|
||||
|
||||
const {
|
||||
query,
|
||||
queryType,
|
||||
tables,
|
||||
params,
|
||||
userId = null,
|
||||
ipAddress = null,
|
||||
userAgent = null,
|
||||
duration = null,
|
||||
} = logData;
|
||||
|
||||
await db.query(
|
||||
`INSERT INTO db_access_log (
|
||||
query_type, tables_accessed, query_text, params_sanitized,
|
||||
user_id, ip_address, user_agent, duration_ms, created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())`,
|
||||
[
|
||||
queryType,
|
||||
JSON.stringify(tables),
|
||||
query.substring(0, 1000), // Truncate long queries
|
||||
JSON.stringify(sanitizeParams(params)),
|
||||
userId,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
duration,
|
||||
]
|
||||
);
|
||||
} catch (err) {
|
||||
// Don't throw - logging failures should not break the application
|
||||
console.error('[dbAccessLogger] Failed to log database access:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure db_access_log table exists
|
||||
*/
|
||||
async function ensureDbAccessLogTable() {
|
||||
try {
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS db_access_log (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
query_type VARCHAR(20) NOT NULL,
|
||||
tables_accessed JSONB,
|
||||
query_text TEXT NOT NULL,
|
||||
params_sanitized JSONB,
|
||||
user_id UUID,
|
||||
ip_address VARCHAR(45),
|
||||
user_agent TEXT,
|
||||
duration_ms INTEGER,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_db_access_log_created_at ON db_access_log(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_db_access_log_user_id ON db_access_log(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_db_access_log_tables ON db_access_log USING GIN(tables_accessed);
|
||||
CREATE INDEX IF NOT EXISTS idx_db_access_log_query_type ON db_access_log(query_type);
|
||||
`);
|
||||
} catch (err) {
|
||||
// Table might already exist or database might not be ready
|
||||
// This is fine, the insert will handle it
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap database query with logging
|
||||
* @param {string} query - SQL query
|
||||
* @param {Array} params - Query parameters
|
||||
* @param {Object} context - Request context (optional)
|
||||
* @returns {Promise} - Query result
|
||||
*/
|
||||
async function loggedQuery(query, params = [], context = {}) {
|
||||
const startTime = Date.now();
|
||||
const queryType = getQueryType(query);
|
||||
const tables = extractTableNames(query);
|
||||
|
||||
// Check if we should log this query
|
||||
if (!shouldLogQuery(query)) {
|
||||
// Execute query without logging
|
||||
return db.query(query, params);
|
||||
}
|
||||
|
||||
try {
|
||||
// Execute query
|
||||
const result = await db.query(query, params);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// Log access (fire and forget)
|
||||
logDbAccess({
|
||||
query,
|
||||
queryType,
|
||||
tables,
|
||||
params,
|
||||
userId: context.userId || null,
|
||||
ipAddress: context.ipAddress || null,
|
||||
userAgent: context.userAgent || null,
|
||||
duration,
|
||||
}).catch(err => {
|
||||
console.error('[dbAccessLogger] Failed to log (non-blocking):', err.message);
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
// Log failed query too
|
||||
const duration = Date.now() - startTime;
|
||||
logDbAccess({
|
||||
query,
|
||||
queryType,
|
||||
tables,
|
||||
params,
|
||||
userId: context.userId || null,
|
||||
ipAddress: context.ipAddress || null,
|
||||
userAgent: context.userAgent || null,
|
||||
duration,
|
||||
error: err.message,
|
||||
}).catch(() => {
|
||||
// Ignore logging errors
|
||||
});
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
loggedQuery,
|
||||
logDbAccess,
|
||||
shouldLogQuery,
|
||||
isLoggingEnabled: () => LOGGING_ENABLED,
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
// src/middleware/securityHeaders.js
|
||||
// === SECURITY HARDENING ===
|
||||
// Security headers middleware for admin dashboard
|
||||
// Prevents clickjacking and enforces security best practices
|
||||
|
||||
/**
|
||||
* Security headers middleware
|
||||
* Sets security headers including frameguard to prevent clickjacking
|
||||
*/
|
||||
function securityHeaders(req, res, next) {
|
||||
// === SECURITY HARDENING: CLICKJACKING PROTECTION ===
|
||||
// Prevent page from being embedded in iframes
|
||||
res.setHeader('X-Frame-Options', 'DENY');
|
||||
|
||||
// === SECURITY HARDENING: CONTENT TYPE PROTECTION ===
|
||||
// Prevent MIME type sniffing
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
|
||||
// === SECURITY HARDENING: XSS PROTECTION ===
|
||||
// Enable XSS filter (legacy, but still useful)
|
||||
res.setHeader('X-XSS-Protection', '1; mode=block');
|
||||
|
||||
// === SECURITY HARDENING: HTTPS ENFORCEMENT ===
|
||||
// Force HTTPS in production (if behind proxy)
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = securityHeaders;
|
||||
|
||||
|
|
@ -106,3 +106,6 @@ module.exports = {
|
|||
createHighAssuranceToken,
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -222,10 +222,162 @@ function validateLogoutBody(req, res, next) {
|
|||
next();
|
||||
}
|
||||
|
||||
// === VALIDATION: USER ROUTES ===
|
||||
|
||||
/**
|
||||
* Validate user_type enum value
|
||||
* Allowed values: 'seller', 'buyer', 'service_provider' (from user_type_enum in database)
|
||||
*/
|
||||
function validateUserType(userType) {
|
||||
if (userType === undefined || userType === null) {
|
||||
return { valid: true }; // Optional field
|
||||
}
|
||||
if (typeof userType !== 'string') {
|
||||
return { valid: false, error: 'user_type must be a string' };
|
||||
}
|
||||
const allowedValues = ['seller', 'buyer', 'service_provider'];
|
||||
if (!allowedValues.includes(userType)) {
|
||||
return { valid: false, error: `user_type must be one of: ${allowedValues.join(', ')}` };
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate name string (optional, trimmed, max 100)
|
||||
*/
|
||||
function validateName(name) {
|
||||
if (name === undefined || name === null) {
|
||||
return { valid: true }; // Optional field
|
||||
}
|
||||
if (typeof name !== 'string') {
|
||||
return { valid: false, error: 'name must be a string' };
|
||||
}
|
||||
const trimmed = name.trim();
|
||||
if (trimmed.length > 100) {
|
||||
return { valid: false, error: 'name must be 100 characters or less' };
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware: Validate PUT /users/me request body
|
||||
* Validates: name (optional string, trimmed, max 100), user_type (optional enum)
|
||||
* Rejects: unknown extra fields, invalid types, overly long strings
|
||||
*/
|
||||
function validateUpdateProfileBody(req, res, next) {
|
||||
const { name, user_type } = req.body;
|
||||
|
||||
// Check body size
|
||||
const sizeCheck = validateBodySize(req.body, 1000);
|
||||
if (!sizeCheck.valid) {
|
||||
return res.status(400).json({ error: sizeCheck.error });
|
||||
}
|
||||
|
||||
// Check for unknown fields (only allow 'name' and 'user_type')
|
||||
const allowedFields = ['name', 'user_type'];
|
||||
const bodyKeys = Object.keys(req.body);
|
||||
const unknownFields = bodyKeys.filter(key => !allowedFields.includes(key));
|
||||
if (unknownFields.length > 0) {
|
||||
return res.status(400).json({
|
||||
error: `Unknown fields not allowed: ${unknownFields.join(', ')}`
|
||||
});
|
||||
}
|
||||
|
||||
// Validate name (optional)
|
||||
const nameCheck = validateName(name);
|
||||
if (!nameCheck.valid) {
|
||||
return res.status(400).json({ error: nameCheck.error });
|
||||
}
|
||||
|
||||
// Validate user_type (optional)
|
||||
const userTypeCheck = validateUserType(user_type);
|
||||
if (!userTypeCheck.valid) {
|
||||
return res.status(400).json({ error: userTypeCheck.error });
|
||||
}
|
||||
|
||||
// Trim name if provided
|
||||
if (name && typeof name === 'string') {
|
||||
req.body.name = name.trim();
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware: Validate DELETE /users/me/devices/:device_id param
|
||||
* Validates: device_id as string with max length 100
|
||||
* Note: device_identifier in database is TEXT, not UUID, so we validate as string
|
||||
*/
|
||||
function validateDeviceIdParam(req, res, next) {
|
||||
const { device_id } = req.params;
|
||||
|
||||
if (!device_id) {
|
||||
return res.status(400).json({ error: 'device_id is required' });
|
||||
}
|
||||
|
||||
if (typeof device_id !== 'string') {
|
||||
return res.status(400).json({ error: 'device_id must be a string' });
|
||||
}
|
||||
|
||||
if (device_id.length > 100) {
|
||||
return res.status(400).json({ error: 'device_id must be 100 characters or less' });
|
||||
}
|
||||
|
||||
if (device_id.length === 0) {
|
||||
return res.status(400).json({ error: 'device_id cannot be empty' });
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware: Validate POST /users/me/logout-all-other-devices request body
|
||||
* Validates: current_device_id (optional, same validation as device_id)
|
||||
* Rejects: unknown extra fields
|
||||
*/
|
||||
function validateLogoutOthersBody(req, res, next) {
|
||||
const { current_device_id } = req.body;
|
||||
|
||||
// Check body size
|
||||
const sizeCheck = validateBodySize(req.body, 1000);
|
||||
if (!sizeCheck.valid) {
|
||||
return res.status(400).json({ error: sizeCheck.error });
|
||||
}
|
||||
|
||||
// Check for unknown fields (only allow 'current_device_id')
|
||||
const allowedFields = ['current_device_id'];
|
||||
const bodyKeys = Object.keys(req.body);
|
||||
const unknownFields = bodyKeys.filter(key => !allowedFields.includes(key));
|
||||
if (unknownFields.length > 0) {
|
||||
return res.status(400).json({
|
||||
error: `Unknown fields not allowed: ${unknownFields.join(', ')}`
|
||||
});
|
||||
}
|
||||
|
||||
// Validate current_device_id (optional, but if present must be valid)
|
||||
if (current_device_id !== undefined && current_device_id !== null) {
|
||||
if (typeof current_device_id !== 'string') {
|
||||
return res.status(400).json({ error: 'current_device_id must be a string' });
|
||||
}
|
||||
if (current_device_id.length > 100) {
|
||||
return res.status(400).json({ error: 'current_device_id must be 100 characters or less' });
|
||||
}
|
||||
if (current_device_id.length === 0) {
|
||||
return res.status(400).json({ error: 'current_device_id cannot be empty' });
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
validateRequestOtpBody,
|
||||
validateVerifyOtpBody,
|
||||
validateRefreshTokenBody,
|
||||
validateLogoutBody,
|
||||
// === VALIDATION: USER ROUTES ===
|
||||
validateUpdateProfileBody,
|
||||
validateDeviceIdParam,
|
||||
validateLogoutOthersBody,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,259 @@
|
|||
// src/routes/adminRoutes.js
|
||||
// === ADMIN SECURITY VISUALIZER ===
|
||||
// Admin API endpoints for security event monitoring
|
||||
// Protected by: auth + adminAuth middleware
|
||||
|
||||
const express = require('express');
|
||||
const db = require('../db');
|
||||
const { logAuthEvent, RISK_LEVELS } = require('../services/auditLogger');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// === SECURITY HARDENING: INPUT VALIDATION ===
|
||||
// Valid risk levels for filtering
|
||||
const VALID_RISK_LEVELS = [RISK_LEVELS.INFO, RISK_LEVELS.SUSPICIOUS, RISK_LEVELS.HIGH_RISK];
|
||||
|
||||
/**
|
||||
* Sanitize string input to prevent injection
|
||||
* @param {string} str - Input string
|
||||
* @returns {string} Sanitized string
|
||||
*/
|
||||
function sanitizeString(str) {
|
||||
if (typeof str !== 'string') return '';
|
||||
// Remove null bytes and trim
|
||||
return str.replace(/\0/g, '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize number input
|
||||
* @param {any} value - Input value
|
||||
* @param {number} defaultValue - Default if invalid
|
||||
* @param {number} min - Minimum value
|
||||
* @param {number} max - Maximum value
|
||||
* @returns {number} Sanitized number
|
||||
*/
|
||||
function sanitizeNumber(value, defaultValue, min = 1, max = 1000) {
|
||||
const num = parseInt(value, 10);
|
||||
if (isNaN(num) || num < min || num > max) {
|
||||
return defaultValue;
|
||||
}
|
||||
return num;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /admin/security-events
|
||||
* Retrieve security audit events with filtering and pagination
|
||||
*
|
||||
* Query parameters:
|
||||
* - risk_level: Filter by risk level (INFO, SUSPICIOUS, HIGH_RISK) - optional
|
||||
* - limit: Number of results (1-1000, default: 200)
|
||||
* - offset: Pagination offset (default: 0)
|
||||
* - search: Search in user_id, phone (from meta), or ip_address - optional
|
||||
*/
|
||||
router.get('/security-events', async (req, res) => {
|
||||
try {
|
||||
// === SECURITY HARDENING: INPUT VALIDATION ===
|
||||
// Validate and sanitize query parameters
|
||||
const riskLevel = req.query.risk_level
|
||||
? sanitizeString(req.query.risk_level).toUpperCase()
|
||||
: null;
|
||||
|
||||
// Validate risk level if provided
|
||||
if (riskLevel && !VALID_RISK_LEVELS.includes(riskLevel)) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid risk_level',
|
||||
valid_values: VALID_RISK_LEVELS
|
||||
});
|
||||
}
|
||||
|
||||
const limit = sanitizeNumber(req.query.limit, 200, 1, 1000);
|
||||
const offset = sanitizeNumber(req.query.offset, 0, 0, 100000);
|
||||
const search = req.query.search ? sanitizeString(req.query.search) : null;
|
||||
|
||||
// === SECURITY HARDENING: SQL INJECTION PREVENTION ===
|
||||
// Build query with parameterized values only
|
||||
let query = `
|
||||
SELECT
|
||||
aa.id,
|
||||
aa.user_id,
|
||||
aa.action,
|
||||
aa.status,
|
||||
aa.risk_level,
|
||||
aa.ip_address,
|
||||
aa.user_agent,
|
||||
aa.device_id,
|
||||
aa.meta,
|
||||
aa.created_at,
|
||||
u.phone_number
|
||||
FROM auth_audit aa
|
||||
LEFT JOIN users u ON aa.user_id = u.id
|
||||
WHERE 1=1
|
||||
`;
|
||||
|
||||
const queryParams = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// Add risk level filter
|
||||
if (riskLevel) {
|
||||
query += ` AND aa.risk_level = $${paramIndex}`;
|
||||
queryParams.push(riskLevel);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// Add search filter (safe parameterized query)
|
||||
if (search) {
|
||||
query += ` AND (
|
||||
aa.user_id::text ILIKE $${paramIndex}
|
||||
OR aa.ip_address ILIKE $${paramIndex}
|
||||
OR u.phone_number ILIKE $${paramIndex}
|
||||
OR aa.meta::text ILIKE $${paramIndex}
|
||||
)`;
|
||||
queryParams.push(`%${search}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// Order by created_at descending (most recent first)
|
||||
query += ` ORDER BY aa.created_at DESC`;
|
||||
|
||||
// Add limit and offset
|
||||
query += ` LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`;
|
||||
queryParams.push(limit, offset);
|
||||
|
||||
// Execute query
|
||||
const result = await db.query(query, queryParams);
|
||||
|
||||
// === SECURITY HARDENING: OUTPUT SANITIZATION ===
|
||||
// Sanitize all fields before returning
|
||||
const events = result.rows.map(row => {
|
||||
// Extract phone from meta if not in users table
|
||||
let phone = row.phone_number;
|
||||
if (!phone && row.meta && typeof row.meta === 'object') {
|
||||
phone = row.meta.phone || null;
|
||||
}
|
||||
|
||||
// Mask phone number for security (keep last 4 digits)
|
||||
const maskedPhone = phone
|
||||
? phone.replace(/\d(?=\d{4})/g, '*')
|
||||
: null;
|
||||
|
||||
return {
|
||||
id: String(row.id || ''),
|
||||
user_id: row.user_id ? String(row.user_id) : null,
|
||||
action: sanitizeString(row.action || ''),
|
||||
status: sanitizeString(row.status || ''),
|
||||
risk_level: sanitizeString(row.risk_level || RISK_LEVELS.INFO),
|
||||
ip_address: sanitizeString(row.ip_address || ''),
|
||||
user_agent: sanitizeString(row.user_agent || ''),
|
||||
device_id: row.device_id ? sanitizeString(row.device_id) : null,
|
||||
phone: maskedPhone,
|
||||
meta: row.meta || {},
|
||||
created_at: row.created_at ? new Date(row.created_at).toISOString() : null,
|
||||
};
|
||||
});
|
||||
|
||||
// Get total count for pagination
|
||||
let countQuery = `
|
||||
SELECT COUNT(*) as total
|
||||
FROM auth_audit aa
|
||||
LEFT JOIN users u ON aa.user_id = u.id
|
||||
WHERE 1=1
|
||||
`;
|
||||
const countParams = [];
|
||||
let countParamIndex = 1;
|
||||
|
||||
if (riskLevel) {
|
||||
countQuery += ` AND aa.risk_level = $${countParamIndex}`;
|
||||
countParams.push(riskLevel);
|
||||
countParamIndex++;
|
||||
}
|
||||
|
||||
if (search) {
|
||||
countQuery += ` AND (
|
||||
aa.user_id::text ILIKE $${countParamIndex}
|
||||
OR aa.ip_address ILIKE $${countParamIndex}
|
||||
OR u.phone_number ILIKE $${countParamIndex}
|
||||
OR aa.meta::text ILIKE $${countParamIndex}
|
||||
)`;
|
||||
countParams.push(`%${search}%`);
|
||||
}
|
||||
|
||||
const countResult = await db.query(countQuery, countParams);
|
||||
const total = parseInt(countResult.rows[0]?.total || 0, 10);
|
||||
|
||||
// Get statistics
|
||||
const statsQuery = `
|
||||
SELECT
|
||||
COUNT(*) as total_events,
|
||||
COUNT(*) FILTER (WHERE risk_level = 'HIGH_RISK') as high_risk_count,
|
||||
COUNT(*) FILTER (WHERE risk_level = 'SUSPICIOUS') as suspicious_count,
|
||||
COUNT(*) FILTER (WHERE risk_level = 'INFO') as info_count
|
||||
FROM auth_audit
|
||||
WHERE created_at > NOW() - INTERVAL '24 hours'
|
||||
`;
|
||||
const statsResult = await db.query(statsQuery);
|
||||
const stats = statsResult.rows[0] || {
|
||||
total_events: 0,
|
||||
high_risk_count: 0,
|
||||
suspicious_count: 0,
|
||||
info_count: 0,
|
||||
};
|
||||
|
||||
// === SECURITY HARDENING: AUDIT LOGGING ===
|
||||
// Log admin access to security events
|
||||
await logAuthEvent({
|
||||
userId: req.user.id,
|
||||
action: 'admin_view_security_events',
|
||||
status: 'success',
|
||||
riskLevel: RISK_LEVELS.INFO,
|
||||
ipAddress: req.ip || req.connection.remoteAddress,
|
||||
userAgent: req.headers['user-agent'],
|
||||
meta: {
|
||||
filters: { risk_level: riskLevel, search, limit, offset },
|
||||
},
|
||||
}).catch(err => {
|
||||
// Don't break the response if logging fails
|
||||
console.error('Failed to log admin access:', err);
|
||||
});
|
||||
|
||||
return res.json({
|
||||
events,
|
||||
pagination: {
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
has_more: offset + limit < total,
|
||||
},
|
||||
stats: {
|
||||
last_24h: {
|
||||
total: parseInt(stats.total_events, 10),
|
||||
high_risk: parseInt(stats.high_risk_count, 10),
|
||||
suspicious: parseInt(stats.suspicious_count, 10),
|
||||
info: parseInt(stats.info_count, 10),
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[adminRoutes] Error fetching security events:', err);
|
||||
|
||||
// === SECURITY HARDENING: AUDIT LOGGING ===
|
||||
// Log admin access error
|
||||
await logAuthEvent({
|
||||
userId: req.user?.id,
|
||||
action: 'admin_view_security_events',
|
||||
status: 'failed',
|
||||
riskLevel: RISK_LEVELS.SUSPICIOUS,
|
||||
ipAddress: req.ip || req.connection.remoteAddress,
|
||||
userAgent: req.headers['user-agent'],
|
||||
meta: {
|
||||
error: err.message,
|
||||
},
|
||||
}).catch(() => {
|
||||
// Ignore logging errors
|
||||
});
|
||||
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
|
|
@ -42,6 +42,9 @@ const {
|
|||
checkAnomalies,
|
||||
RISK_LEVELS,
|
||||
} = require('../services/auditLogger');
|
||||
// === SECURITY HARDENING: FIELD-LEVEL ENCRYPTION ===
|
||||
const { encryptPhoneNumber, decryptPhoneNumber } = require('../utils/fieldEncryption');
|
||||
const { preparePhoneSearchParams } = require('../utils/encryptedPhoneSearch');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
|
|
@ -223,25 +226,37 @@ router.post(
|
|||
}
|
||||
|
||||
// find or create user
|
||||
// === SECURITY HARDENING: FIELD-LEVEL ENCRYPTION ===
|
||||
// Encrypt phone number before storing/searching
|
||||
const encryptedPhone = encryptPhoneNumber(normalizedPhone);
|
||||
const phoneSearchParams = preparePhoneSearchParams(normalizedPhone);
|
||||
|
||||
let user;
|
||||
const found = await db.query(
|
||||
`SELECT id, phone_number, name, role, NULL::user_type_enum as user_type
|
||||
FROM users
|
||||
WHERE phone_number = $1`,
|
||||
[normalizedPhone]
|
||||
WHERE phone_number = $1 OR phone_number = $2`,
|
||||
phoneSearchParams
|
||||
);
|
||||
|
||||
if (found.rows.length === 0) {
|
||||
// Insert with encrypted phone number
|
||||
const inserted = await db.query(
|
||||
`INSERT INTO users (phone_number)
|
||||
VALUES ($1)
|
||||
RETURNING id, phone_number, name, role, NULL::user_type_enum as user_type`,
|
||||
[normalizedPhone]
|
||||
[encryptedPhone]
|
||||
);
|
||||
user = inserted.rows[0];
|
||||
} else {
|
||||
user = found.rows[0];
|
||||
}
|
||||
|
||||
// === SECURITY HARDENING: FIELD-LEVEL ENCRYPTION ===
|
||||
// Decrypt phone number before returning to client
|
||||
if (user.phone_number) {
|
||||
user.phone_number = decryptPhoneNumber(user.phone_number);
|
||||
}
|
||||
|
||||
// update last_login_at
|
||||
await db.query(
|
||||
|
|
|
|||
|
|
@ -2,6 +2,18 @@
|
|||
const express = require('express');
|
||||
const db = require('../db');
|
||||
const auth = require('../middleware/authMiddleware');
|
||||
// === SECURITY HARDENING: FIELD-LEVEL ENCRYPTION ===
|
||||
const { decryptPhoneNumber } = require('../utils/fieldEncryption');
|
||||
|
||||
// === SECURITY HARDENING: STEP-UP AUTH ===
|
||||
const { requireRecentOtpOrReauth } = require('../middleware/stepUpAuth');
|
||||
|
||||
// === VALIDATION: USER ROUTES ===
|
||||
const {
|
||||
validateUpdateProfileBody,
|
||||
validateDeviceIdParam,
|
||||
validateLogoutOthersBody,
|
||||
} = require('../middleware/validation');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
|
|
@ -22,6 +34,12 @@ router.get('/me', auth, async (req, res) => {
|
|||
|
||||
const user = rows[0];
|
||||
|
||||
// === SECURITY HARDENING: FIELD-LEVEL ENCRYPTION ===
|
||||
// Decrypt phone number before returning to client
|
||||
if (user.phone_number) {
|
||||
user.phone_number = decryptPhoneNumber(user.phone_number);
|
||||
}
|
||||
|
||||
// Get active devices count
|
||||
const deviceCountResult = await db.query(
|
||||
`SELECT COUNT(*) as count FROM user_devices WHERE user_id = $1 AND is_active = true`,
|
||||
|
|
@ -96,7 +114,13 @@ router.get('/me', auth, async (req, res) => {
|
|||
});
|
||||
|
||||
// PUT /users/me
|
||||
router.put('/me', auth, async (req, res) => {
|
||||
// Validates: name (optional, max 100), user_type (optional enum: seller/buyer/service_provider)
|
||||
router.put(
|
||||
'/me',
|
||||
auth,
|
||||
requireRecentOtpOrReauth,
|
||||
validateUpdateProfileBody,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { name, user_type } = req.body;
|
||||
|
||||
|
|
@ -119,12 +143,21 @@ router.put('/me', auth, async (req, res) => {
|
|||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
return res.json(rows[0]);
|
||||
const updatedUser = rows[0];
|
||||
|
||||
// === SECURITY HARDENING: FIELD-LEVEL ENCRYPTION ===
|
||||
// Decrypt phone number before returning to client
|
||||
if (updatedUser.phone_number) {
|
||||
updatedUser.phone_number = decryptPhoneNumber(updatedUser.phone_number);
|
||||
}
|
||||
|
||||
return res.json(updatedUser);
|
||||
} catch (err) {
|
||||
console.error('update me error', err);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// GET /users/me/devices - List all active devices
|
||||
router.get('/me/devices', auth, async (req, res) => {
|
||||
|
|
@ -157,7 +190,13 @@ router.get('/me/devices', auth, async (req, res) => {
|
|||
});
|
||||
|
||||
// DELETE /users/me/devices/:device_id - Revoke/logout a specific device
|
||||
router.delete('/me/devices/:device_id', auth, async (req, res) => {
|
||||
// Validates: device_id param (string, max 100 chars)
|
||||
router.delete(
|
||||
'/me/devices/:device_id',
|
||||
auth,
|
||||
requireRecentOtpOrReauth,
|
||||
validateDeviceIdParam,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { device_id } = req.params;
|
||||
|
||||
|
|
@ -197,10 +236,17 @@ router.delete('/me/devices/:device_id', auth, async (req, res) => {
|
|||
console.error('revoke device error', err);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// POST /users/me/devices/:device_id/logout-all - Logout all other devices (keep current)
|
||||
router.post('/me/logout-all-other-devices', auth, async (req, res) => {
|
||||
// POST /users/me/logout-all-other-devices - Logout all other devices (keep current)
|
||||
// Validates: current_device_id (optional string, max 100 chars if provided)
|
||||
router.post(
|
||||
'/me/logout-all-other-devices',
|
||||
auth,
|
||||
requireRecentOtpOrReauth,
|
||||
validateLogoutOthersBody,
|
||||
async (req, res) => {
|
||||
try {
|
||||
// Get current device_id from refresh token if available, or from request
|
||||
// For now, we'll need device_id in request body or header
|
||||
|
|
@ -256,6 +302,7 @@ router.post('/me/logout-all-other-devices', auth, async (req, res) => {
|
|||
console.error('logout all other devices error', err);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,25 @@
|
|||
// src/services/auditLogger.js
|
||||
// === SECURITY HARDENING: AUDIT LOGS & ANOMALY FLAGS ===
|
||||
// Enhanced audit logging with risk levels and anomaly detection
|
||||
//
|
||||
// === SECURITY HARDENING: ACTIVE ALERTING ===
|
||||
// Configuration:
|
||||
// - SECURITY_ALERT_WEBHOOK_URL: Webhook URL for security alerts (Slack, Discord, or custom)
|
||||
// Example: https://hooks.slack.com/services/YOUR/WEBHOOK/URL
|
||||
// - SECURITY_ALERT_MIN_LEVEL: Minimum risk level to trigger alerts
|
||||
// Values: 'INFO', 'SUSPICIOUS', 'HIGH_RISK' (default: 'HIGH_RISK')
|
||||
// Only events with risk level >= this value will trigger webhook alerts
|
||||
//
|
||||
// How it works:
|
||||
// - After an audit event is logged to DB, if risk_level >= SECURITY_ALERT_MIN_LEVEL,
|
||||
// a webhook alert is sent (if webhook URL is configured)
|
||||
// - Anomaly detection (checkAnomalies) can also flag events for alerting
|
||||
// - Alerting failures are logged but never break the main request flow
|
||||
|
||||
const db = require('../db');
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const { URL } = require('url');
|
||||
|
||||
// === SECURITY HARDENING: AUDIT LOGS & ANOMALY FLAGS ===
|
||||
// Risk levels for audit events
|
||||
|
|
@ -12,6 +29,43 @@ const RISK_LEVELS = {
|
|||
HIGH_RISK: 'HIGH_RISK',
|
||||
};
|
||||
|
||||
// === SECURITY HARDENING: ACTIVE ALERTING ===
|
||||
// Configuration from environment variables
|
||||
const SECURITY_ALERT_WEBHOOK_URL = process.env.SECURITY_ALERT_WEBHOOK_URL || null;
|
||||
const SECURITY_ALERT_MIN_LEVEL = process.env.SECURITY_ALERT_MIN_LEVEL || 'HIGH_RISK';
|
||||
|
||||
// Risk level hierarchy for comparison (higher number = higher risk)
|
||||
const RISK_LEVEL_HIERARCHY = {
|
||||
INFO: 1,
|
||||
SUSPICIOUS: 2,
|
||||
HIGH_RISK: 3,
|
||||
};
|
||||
|
||||
/**
|
||||
* Map risk level to severity string for webhook payloads
|
||||
* @param {string} riskLevel - Risk level (INFO, SUSPICIOUS, HIGH_RISK)
|
||||
* @returns {string} Severity string (low, medium, high)
|
||||
*/
|
||||
function mapRiskLevelToSeverity(riskLevel) {
|
||||
const mapping = {
|
||||
[RISK_LEVELS.INFO]: 'low',
|
||||
[RISK_LEVELS.SUSPICIOUS]: 'medium',
|
||||
[RISK_LEVELS.HIGH_RISK]: 'high',
|
||||
};
|
||||
return mapping[riskLevel] || 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a risk level meets the minimum threshold for alerting
|
||||
* @param {string} riskLevel - Risk level to check
|
||||
* @returns {boolean} True if risk level is high enough to trigger alert
|
||||
*/
|
||||
function shouldTriggerAlert(riskLevel) {
|
||||
const eventRisk = RISK_LEVEL_HIERARCHY[riskLevel] || 0;
|
||||
const minRisk = RISK_LEVEL_HIERARCHY[SECURITY_ALERT_MIN_LEVEL] || 3;
|
||||
return eventRisk >= minRisk;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an authentication event with risk level
|
||||
*
|
||||
|
|
@ -39,11 +93,12 @@ async function logAuthEvent({
|
|||
// Ensure risk_level column exists (add if not present)
|
||||
await ensureRiskLevelColumn();
|
||||
|
||||
await db.query(
|
||||
const result = 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)`,
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, created_at`,
|
||||
[
|
||||
userId,
|
||||
action,
|
||||
|
|
@ -55,6 +110,36 @@ async function logAuthEvent({
|
|||
JSON.stringify(meta),
|
||||
]
|
||||
);
|
||||
|
||||
// === SECURITY HARDENING: ACTIVE ALERTING ===
|
||||
// Trigger security alert if risk level meets threshold or anomaly detected
|
||||
if (result.rows.length > 0) {
|
||||
const eventRecord = {
|
||||
id: result.rows[0].id,
|
||||
userId,
|
||||
action,
|
||||
status,
|
||||
riskLevel,
|
||||
deviceId,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
meta,
|
||||
created_at: result.rows[0].created_at,
|
||||
};
|
||||
|
||||
// Check for anomalies that might require alerting
|
||||
const anomalyAlert = await checkAnomalies(userId, action, ipAddress);
|
||||
|
||||
// Trigger alert if risk level meets threshold OR anomaly detected
|
||||
const shouldAlert = shouldTriggerAlert(riskLevel) || anomalyAlert?.shouldAlert;
|
||||
|
||||
if (shouldAlert) {
|
||||
// Fire and forget - don't await to avoid blocking
|
||||
triggerSecurityAlert(eventRecord, anomalyAlert).catch(err => {
|
||||
console.error('[auditLogger] Alert trigger failed (non-blocking):', err.message);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to log auth event:', err);
|
||||
// Don't throw - audit logging should not break the main flow
|
||||
|
|
@ -160,13 +245,21 @@ async function logStepUpAuthRequired(userId, action, ipAddress, userAgent) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Helper to check for anomaly patterns (for future alerting)
|
||||
* Helper to check for anomaly patterns (for alerting)
|
||||
* This can be extended to query recent events and detect patterns
|
||||
*
|
||||
* @param {string|null} userId - User ID
|
||||
* @param {string} action - Action type
|
||||
* @param {string|null} ipAddress - IP address
|
||||
* @returns {Promise<Object|null>} Object with shouldAlert flag and details, or null
|
||||
*/
|
||||
async function checkAnomalies(userId, action, ipAddress) {
|
||||
try {
|
||||
let shouldAlert = false;
|
||||
let anomalyDetails = null;
|
||||
|
||||
// Example: Check for multiple failed attempts in short time
|
||||
if (action === 'otp_verify') {
|
||||
if (action === 'otp_verify' && userId) {
|
||||
const result = await db.query(
|
||||
`SELECT COUNT(*) as count
|
||||
FROM auth_audit
|
||||
|
|
@ -180,26 +273,200 @@ async function checkAnomalies(userId, action, ipAddress) {
|
|||
|
||||
const failedCount = parseInt(result.rows[0]?.count || 0, 10);
|
||||
if (failedCount >= 5) {
|
||||
// TODO: Trigger alert (email, webhook, etc.)
|
||||
shouldAlert = true;
|
||||
anomalyDetails = {
|
||||
type: 'multiple_failed_otp',
|
||||
count: failedCount,
|
||||
window: '1 hour',
|
||||
};
|
||||
console.warn(`[ANOMALY] User ${userId} has ${failedCount} failed OTP attempts in last hour`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for multiple HIGH_RISK events from same IP in short time
|
||||
if (ipAddress) {
|
||||
const ipResult = await db.query(
|
||||
`SELECT COUNT(*) as count
|
||||
FROM auth_audit
|
||||
WHERE ip_address = $1
|
||||
AND risk_level = 'HIGH_RISK'
|
||||
AND created_at > NOW() - INTERVAL '15 minutes'`,
|
||||
[ipAddress]
|
||||
);
|
||||
|
||||
const highRiskCount = parseInt(ipResult.rows[0]?.count || 0, 10);
|
||||
if (highRiskCount >= 3) {
|
||||
shouldAlert = true;
|
||||
anomalyDetails = {
|
||||
...anomalyDetails,
|
||||
type: anomalyDetails ? 'multiple_anomalies' : 'multiple_high_risk_ip',
|
||||
high_risk_count: highRiskCount,
|
||||
ip_address: ipAddress,
|
||||
};
|
||||
console.warn(`[ANOMALY] IP ${ipAddress} has ${highRiskCount} HIGH_RISK events in last 15 minutes`);
|
||||
}
|
||||
}
|
||||
|
||||
return shouldAlert ? { shouldAlert: true, details: anomalyDetails } : null;
|
||||
} catch (err) {
|
||||
console.error('Error checking anomalies:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: Integrate with external alerting systems
|
||||
* Example implementations:
|
||||
* === SECURITY HARDENING: ACTIVE ALERTING ===
|
||||
* Trigger security alert via webhook (Slack, Discord, or custom webhook)
|
||||
*
|
||||
* async function sendAlert(level, message, metadata) {
|
||||
* // Send to PagerDuty, Slack, email, etc.
|
||||
* if (level === 'HIGH_RISK') {
|
||||
* await sendToPagerDuty(message, metadata);
|
||||
* }
|
||||
* }
|
||||
* @param {Object} eventRecord - Audit event record from database
|
||||
* @param {Object|null} anomalyAlert - Anomaly detection result (if any)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function triggerSecurityAlert(eventRecord, anomalyAlert = null) {
|
||||
// If no webhook URL configured, just log and return
|
||||
if (!SECURITY_ALERT_WEBHOOK_URL) {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
console.warn('[auditLogger] No SECURITY_ALERT_WEBHOOK_URL configured; skipping external alert.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Build alert payload (Slack-compatible format, but generic enough for other webhooks)
|
||||
const severity = mapRiskLevelToSeverity(eventRecord.riskLevel);
|
||||
const summary = eventRecord.meta?.message ||
|
||||
`${eventRecord.action} - ${eventRecord.status} (${eventRecord.riskLevel})`;
|
||||
|
||||
// Extract phone number from meta if present (masked)
|
||||
const phone = eventRecord.meta?.phone || null;
|
||||
|
||||
const payload = {
|
||||
// Slack-compatible fields
|
||||
text: `🚨 Security Alert: ${eventRecord.riskLevel}`,
|
||||
attachments: [
|
||||
{
|
||||
color: eventRecord.riskLevel === RISK_LEVELS.HIGH_RISK ? 'danger' :
|
||||
eventRecord.riskLevel === RISK_LEVELS.SUSPICIOUS ? 'warning' : 'good',
|
||||
fields: [
|
||||
{
|
||||
title: 'Event Type',
|
||||
value: eventRecord.action,
|
||||
short: true,
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
value: eventRecord.status,
|
||||
short: true,
|
||||
},
|
||||
{
|
||||
title: 'Risk Level',
|
||||
value: `${eventRecord.riskLevel} (${severity})`,
|
||||
short: true,
|
||||
},
|
||||
{
|
||||
title: 'Timestamp',
|
||||
value: new Date(eventRecord.created_at).toISOString(),
|
||||
short: true,
|
||||
},
|
||||
...(eventRecord.userId ? [{
|
||||
title: 'User ID',
|
||||
value: eventRecord.userId,
|
||||
short: true,
|
||||
}] : []),
|
||||
...(phone ? [{
|
||||
title: 'Phone',
|
||||
value: phone,
|
||||
short: true,
|
||||
}] : []),
|
||||
...(eventRecord.ipAddress ? [{
|
||||
title: 'IP Address',
|
||||
value: eventRecord.ipAddress,
|
||||
short: true,
|
||||
}] : []),
|
||||
...(eventRecord.deviceId ? [{
|
||||
title: 'Device ID',
|
||||
value: eventRecord.deviceId,
|
||||
short: true,
|
||||
}] : []),
|
||||
],
|
||||
footer: 'Farm Auth Service',
|
||||
ts: Math.floor(new Date(eventRecord.created_at).getTime() / 1000),
|
||||
},
|
||||
],
|
||||
// Additional metadata for custom webhooks
|
||||
metadata: {
|
||||
event_id: eventRecord.id,
|
||||
risk_level: eventRecord.riskLevel,
|
||||
severity,
|
||||
event_type: eventRecord.action,
|
||||
status: eventRecord.status,
|
||||
user_id: eventRecord.userId,
|
||||
phone: phone,
|
||||
ip_address: eventRecord.ipAddress,
|
||||
device_id: eventRecord.deviceId,
|
||||
user_agent: eventRecord.userAgent,
|
||||
created_at: eventRecord.created_at,
|
||||
summary,
|
||||
...(anomalyAlert?.details ? { anomaly: anomalyAlert.details } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
// Parse webhook URL
|
||||
const webhookUrl = new URL(SECURITY_ALERT_WEBHOOK_URL);
|
||||
const isHttps = webhookUrl.protocol === 'https:';
|
||||
const httpModule = isHttps ? https : http;
|
||||
|
||||
// Prepare request options
|
||||
const postData = JSON.stringify(payload);
|
||||
const options = {
|
||||
hostname: webhookUrl.hostname,
|
||||
port: webhookUrl.port || (isHttps ? 443 : 80),
|
||||
path: webhookUrl.pathname + webhookUrl.search,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(postData),
|
||||
},
|
||||
timeout: 5000, // 5 second timeout
|
||||
};
|
||||
|
||||
// Send webhook request
|
||||
await new Promise((resolve, reject) => {
|
||||
const req = httpModule.request(options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => { data += chunk; });
|
||||
res.on('end', () => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Webhook returned status ${res.statusCode}: ${data}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
reject(new Error('Webhook request timeout'));
|
||||
});
|
||||
|
||||
req.write(postData);
|
||||
req.end();
|
||||
});
|
||||
|
||||
console.log(`[auditLogger] Security alert sent for ${eventRecord.riskLevel} event: ${eventRecord.action}`);
|
||||
} catch (err) {
|
||||
// Log error but don't throw - alerting failures should not break the main flow
|
||||
console.error('[auditLogger] Failed to send security alert:', err.message);
|
||||
// Optionally log full error in development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('[auditLogger] Alert error details:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
logAuthEvent,
|
||||
|
|
@ -211,3 +478,4 @@ module.exports = {
|
|||
RISK_LEVELS,
|
||||
};
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -182,3 +182,6 @@ module.exports = {
|
|||
validateTokenClaims,
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
const bcrypt = require('bcrypt');
|
||||
const db = require('../db');
|
||||
const { markActiveOtp } = require('../middleware/rateLimitMiddleware');
|
||||
// === SECURITY HARDENING: FIELD-LEVEL ENCRYPTION ===
|
||||
const { encryptPhoneNumber } = require('../utils/fieldEncryption');
|
||||
|
||||
// === ADDED FOR 2-MIN OTP VALIDITY & NO-RESEND ===
|
||||
// OTP validity changed from 10 minutes to 2 minutes (120 seconds)
|
||||
|
|
@ -69,18 +71,23 @@ async function createOtp(phoneNumber) {
|
|||
const expiresAt = new Date(Date.now() + OTP_EXPIRY_MS);
|
||||
const otpHash = await bcrypt.hash(code, 10);
|
||||
|
||||
// === SECURITY HARDENING: FIELD-LEVEL ENCRYPTION ===
|
||||
// Encrypt phone number before storing
|
||||
const encryptedPhone = encryptPhoneNumber(phoneNumber);
|
||||
|
||||
// === ADDED FOR 2-MIN OTP VALIDITY & NO-RESEND ===
|
||||
// Delete any existing OTPs for this phone number
|
||||
// Note: We search by encrypted phone to handle both encrypted and plaintext (backward compatibility)
|
||||
await db.query(
|
||||
'DELETE FROM otp_codes WHERE phone_number = $1',
|
||||
[phoneNumber]
|
||||
'DELETE FROM otp_codes WHERE phone_number = $1 OR phone_number = $2',
|
||||
[encryptedPhone, phoneNumber] // Try both encrypted and plaintext for backward compatibility
|
||||
);
|
||||
|
||||
// Insert new OTP
|
||||
// Insert new OTP with encrypted phone number
|
||||
await db.query(
|
||||
`INSERT INTO otp_codes (phone_number, otp_hash, expires_at, attempt_count)
|
||||
VALUES ($1, $2, $3, 0)`,
|
||||
[phoneNumber, otpHash, expiresAt]
|
||||
[encryptedPhone, otpHash, expiresAt]
|
||||
);
|
||||
|
||||
// === ADDED FOR 2-MIN OTP VALIDITY & NO-RESEND ===
|
||||
|
|
@ -103,13 +110,18 @@ async function createOtp(phoneNumber) {
|
|||
*/
|
||||
async function verifyOtp(phoneNumber, code) {
|
||||
await ensureOtpCodesTable();
|
||||
|
||||
// === SECURITY HARDENING: FIELD-LEVEL ENCRYPTION ===
|
||||
// Encrypt phone number for search (handles both encrypted and plaintext for backward compatibility)
|
||||
const encryptedPhone = encryptPhoneNumber(phoneNumber);
|
||||
|
||||
const result = await db.query(
|
||||
`SELECT id, otp_hash, expires_at, attempt_count
|
||||
`SELECT id, otp_hash, expires_at, attempt_count, phone_number
|
||||
FROM otp_codes
|
||||
WHERE phone_number = $1
|
||||
WHERE phone_number = $1 OR phone_number = $2
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1`,
|
||||
[phoneNumber]
|
||||
[encryptedPhone, phoneNumber] // Try both encrypted and plaintext for backward compatibility
|
||||
);
|
||||
|
||||
// === ADDED FOR OTP ATTEMPT LIMIT ===
|
||||
|
|
|
|||
|
|
@ -260,3 +260,6 @@ module.exports = {
|
|||
REQUIRE_OTP_ON_SUSPICIOUS_REFRESH,
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
// src/utils/encryptedPhoneSearch.js
|
||||
// === SECURITY HARDENING: FIELD-LEVEL ENCRYPTION ===
|
||||
// Helper for searching encrypted phone numbers in database
|
||||
// Handles backward compatibility with plaintext phone numbers
|
||||
|
||||
const { encryptPhoneNumber } = require('./fieldEncryption');
|
||||
|
||||
/**
|
||||
* Build SQL WHERE clause for encrypted phone number search
|
||||
* Handles both encrypted and plaintext (backward compatibility)
|
||||
*
|
||||
* @param {string} paramIndex - SQL parameter index (e.g., '$1', '$2')
|
||||
* @returns {string} - SQL WHERE clause
|
||||
*/
|
||||
function buildEncryptedPhoneWhereClause(paramIndex) {
|
||||
// Search for both encrypted and plaintext (backward compatibility)
|
||||
// The encrypted phone will be passed as first param, plaintext as second
|
||||
return `(phone_number = ${paramIndex} OR phone_number = $${parseInt(paramIndex.replace('$', '')) + 1})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare phone number search parameters
|
||||
* Returns both encrypted and plaintext for backward compatibility
|
||||
*
|
||||
* @param {string} phoneNumber - Plaintext phone number
|
||||
* @returns {string[]} - Array with [encryptedPhone, plaintextPhone]
|
||||
*/
|
||||
function preparePhoneSearchParams(phoneNumber) {
|
||||
const encryptedPhone = encryptPhoneNumber(phoneNumber);
|
||||
return [encryptedPhone, phoneNumber];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build SQL query with encrypted phone search
|
||||
*
|
||||
* @param {string} baseQuery - Base SQL query (without WHERE clause)
|
||||
* @param {string} phoneParamIndex - Starting parameter index (e.g., '$1')
|
||||
* @returns {Object} - { query: string, params: string[] }
|
||||
*/
|
||||
function buildEncryptedPhoneQuery(baseQuery, phoneParamIndex = '$1') {
|
||||
const whereClause = buildEncryptedPhoneWhereClause(phoneParamIndex);
|
||||
const query = baseQuery.includes('WHERE')
|
||||
? `${baseQuery} AND ${whereClause}`
|
||||
: `${baseQuery} WHERE ${whereClause}`;
|
||||
|
||||
return { query };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildEncryptedPhoneWhereClause,
|
||||
preparePhoneSearchParams,
|
||||
buildEncryptedPhoneQuery,
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
// src/utils/fieldEncryption.js
|
||||
// === SECURITY HARDENING: FIELD-LEVEL ENCRYPTION ===
|
||||
// Field-level encryption for PII (Personally Identifiable Information)
|
||||
// Encrypts sensitive fields like phone numbers before storing in database
|
||||
|
||||
/**
|
||||
* Field-Level Encryption for PII
|
||||
*
|
||||
* Uses AES-256-GCM for authenticated encryption
|
||||
*
|
||||
* Configuration:
|
||||
* - ENCRYPTION_KEY: 32-byte (256-bit) key for AES-256, base64 encoded
|
||||
* Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
|
||||
* - ENCRYPTION_ENABLED: Set to 'true' to enable encryption (default: false for backward compatibility)
|
||||
*
|
||||
* Security Notes:
|
||||
* - Encryption key should be stored in secrets manager (AWS Secrets Manager, HashiCorp Vault, etc.)
|
||||
* - Never commit encryption keys to version control
|
||||
* - Rotate keys periodically (requires re-encryption of existing data)
|
||||
*
|
||||
* TODO: In production, load encryption key from secrets manager
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
const ENCRYPTION_ENABLED = process.env.ENCRYPTION_ENABLED === 'true' || process.env.ENCRYPTION_ENABLED === '1';
|
||||
const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY;
|
||||
|
||||
// Algorithm: AES-256-GCM (Galois/Counter Mode) for authenticated encryption
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_LENGTH = 12; // 96 bits for GCM
|
||||
const AUTH_TAG_LENGTH = 16; // 128 bits for authentication tag
|
||||
const SALT_LENGTH = 32; // For key derivation (if needed in future)
|
||||
|
||||
let encryptionKey = null;
|
||||
|
||||
/**
|
||||
* Initialize encryption key from environment
|
||||
*/
|
||||
function initializeEncryptionKey() {
|
||||
if (!ENCRYPTION_ENABLED) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!ENCRYPTION_KEY) {
|
||||
console.warn('⚠️ ENCRYPTION_ENABLED is true but ENCRYPTION_KEY is not set. Encryption disabled.');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Decode base64 key
|
||||
const keyBuffer = Buffer.from(ENCRYPTION_KEY, 'base64');
|
||||
|
||||
// Validate key length (AES-256 requires 32 bytes)
|
||||
if (keyBuffer.length !== 32) {
|
||||
throw new Error(`ENCRYPTION_KEY must be 32 bytes (256 bits). Got ${keyBuffer.length} bytes.`);
|
||||
}
|
||||
|
||||
encryptionKey = keyBuffer;
|
||||
console.log('✅ Field-level encryption enabled');
|
||||
return encryptionKey;
|
||||
} catch (err) {
|
||||
console.error('❌ Failed to initialize encryption key:', err.message);
|
||||
console.warn('⚠️ Encryption disabled. Set valid ENCRYPTION_KEY to enable.');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on module load
|
||||
initializeEncryptionKey();
|
||||
|
||||
/**
|
||||
* Encrypt a plaintext value (e.g., phone number)
|
||||
* @param {string} plaintext - The value to encrypt
|
||||
* @returns {string} - Encrypted value in format: iv:authTag:encryptedData (all base64)
|
||||
*/
|
||||
function encryptField(plaintext) {
|
||||
// If encryption is disabled, return plaintext (for backward compatibility)
|
||||
if (!ENCRYPTION_ENABLED || !encryptionKey) {
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
if (!plaintext || typeof plaintext !== 'string') {
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
try {
|
||||
// Generate random IV for each encryption
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
|
||||
// Create cipher
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, encryptionKey, iv);
|
||||
|
||||
// Encrypt
|
||||
let encrypted = cipher.update(plaintext, 'utf8', 'base64');
|
||||
encrypted += cipher.final('base64');
|
||||
|
||||
// Get authentication tag
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
// Return format: iv:authTag:encryptedData (all base64)
|
||||
return `${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted}`;
|
||||
} catch (err) {
|
||||
console.error('Encryption error:', err);
|
||||
throw new Error('Failed to encrypt field');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt an encrypted value
|
||||
* @param {string} encryptedValue - The encrypted value in format: iv:authTag:encryptedData
|
||||
* @returns {string} - Decrypted plaintext
|
||||
*/
|
||||
function decryptField(encryptedValue) {
|
||||
// If encryption is disabled, return as-is (for backward compatibility)
|
||||
if (!ENCRYPTION_ENABLED || !encryptionKey) {
|
||||
return encryptedValue;
|
||||
}
|
||||
|
||||
if (!encryptedValue || typeof encryptedValue !== 'string') {
|
||||
return encryptedValue;
|
||||
}
|
||||
|
||||
// Check if value is encrypted (format: iv:authTag:encryptedData)
|
||||
// If not in this format, assume it's plaintext (for backward compatibility with existing data)
|
||||
const parts = encryptedValue.split(':');
|
||||
if (parts.length !== 3) {
|
||||
// Not encrypted, return as-is (backward compatibility)
|
||||
return encryptedValue;
|
||||
}
|
||||
|
||||
try {
|
||||
const [ivBase64, authTagBase64, encrypted] = parts;
|
||||
|
||||
// Decode base64 components
|
||||
const iv = Buffer.from(ivBase64, 'base64');
|
||||
const authTag = Buffer.from(authTagBase64, 'base64');
|
||||
|
||||
// Create decipher
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, encryptionKey, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
// Decrypt
|
||||
let decrypted = decipher.update(encrypted, 'base64', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
|
||||
return decrypted;
|
||||
} catch (err) {
|
||||
console.error('Decryption error:', err);
|
||||
// If decryption fails, it might be plaintext (backward compatibility)
|
||||
// Return as-is but log warning
|
||||
console.warn('⚠️ Failed to decrypt field, assuming plaintext (backward compatibility)');
|
||||
return encryptedValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt phone number before storing in database
|
||||
* @param {string} phoneNumber - Plaintext phone number
|
||||
* @returns {string} - Encrypted phone number
|
||||
*/
|
||||
function encryptPhoneNumber(phoneNumber) {
|
||||
return encryptField(phoneNumber);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt phone number after reading from database
|
||||
* @param {string} encryptedPhoneNumber - Encrypted phone number
|
||||
* @returns {string} - Plaintext phone number
|
||||
*/
|
||||
function decryptPhoneNumber(encryptedPhoneNumber) {
|
||||
return decryptField(encryptedPhoneNumber);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if encryption is enabled
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isEncryptionEnabled() {
|
||||
return ENCRYPTION_ENABLED && encryptionKey !== null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
encryptField,
|
||||
decryptField,
|
||||
encryptPhoneNumber,
|
||||
decryptPhoneNumber,
|
||||
isEncryptionEnabled,
|
||||
};
|
||||
|
||||
|
|
@ -59,3 +59,6 @@ module.exports = {
|
|||
logOtpEvent,
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue