This commit is contained in:
Chandresh Kerkar 2025-12-19 23:19:47 +05:30
parent 736bfe4766
commit 9c82c677ce
18 changed files with 1255 additions and 14 deletions

1
.gitignore vendored
View File

@ -47,3 +47,4 @@ build/

View File

@ -156,3 +156,4 @@ Common issues and solutions are documented in `docs/getting-started/AWS_DATABASE
**Security Requirements Met**: ✅ Yes **Security Requirements Met**: ✅ Yes
**Backward Compatibility**: ✅ Maintained **Backward Compatibility**: ✅ Maintained

192
docs/API_SIGNUP_ENDPOINT.md Normal file
View File

@ -0,0 +1,192 @@
# Signup API Endpoint
## POST /auth/signup
Creates a new user account with name, phone number, and optional location information.
### Request Body
```json
{
"name": "John Doe", // Required: User's name (string, max 100 chars)
"phone_number": "+919876543210", // Required: Phone number in E.164 format
"state": "Maharashtra", // Optional: State name (string, max 100 chars)
"district": "Mumbai", // Optional: District name (string, max 100 chars)
"city_village": "Andheri", // Optional: City/Village name (string, max 150 chars)
"device_id": "device-123", // Optional: Device identifier
"device_info": { // Optional: Device information
"platform": "android",
"model": "Samsung Galaxy S21",
"os_version": "Android 13",
"app_version": "1.0.0",
"language_code": "en",
"timezone": "Asia/Kolkata"
}
}
```
### Success Response (201 Created)
```json
{
"success": true,
"user": {
"id": "uuid-here",
"phone_number": "+919876543210",
"name": "John Doe",
"country_code": "+91",
"created_at": "2024-01-15T10:30:00Z"
},
"access_token": "jwt-access-token",
"refresh_token": "jwt-refresh-token",
"needs_profile": true,
"is_new_account": true,
"is_new_device": true,
"active_devices_count": 1,
"location_id": "uuid-of-location" // null if no location provided
}
```
### Error Responses
#### 400 Bad Request - Validation Error
```json
{
"error": "name is required"
}
```
#### 409 Conflict - User Already Exists
```json
{
"success": false,
"message": "User with this phone number already exists. Please sign in instead.",
"user_exists": true
}
```
#### 403 Forbidden - IP Blocked
```json
{
"success": false,
"message": "Access denied from this location."
}
```
#### 500 Internal Server Error
```json
{
"success": false,
"message": "Internal server error"
}
```
### Features
1. **User Existence Check**: Automatically checks if a user with the phone number already exists
2. **Phone Number Encryption**: Phone numbers are encrypted before storing in database
3. **Location Creation**: If state/district/city_village provided, creates a location entry
4. **Token Issuance**: Automatically issues access and refresh tokens
5. **Device Tracking**: Records device information for security
6. **Audit Logging**: Logs signup events for security monitoring
### Example Usage
#### cURL
```bash
curl -X POST http://localhost:3000/auth/signup \
-H "Content-Type: application/json" \
-d '{
"name": "John Doe",
"phone_number": "+919876543210",
"state": "Maharashtra",
"district": "Mumbai",
"city_village": "Andheri",
"device_id": "android-device-123",
"device_info": {
"platform": "android",
"model": "Samsung Galaxy S21",
"os_version": "Android 13"
}
}'
```
#### JavaScript/TypeScript
```javascript
const response = await fetch('/auth/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'John Doe',
phone_number: '+919876543210',
state: 'Maharashtra',
district: 'Mumbai',
city_village: 'Andheri',
device_id: 'android-device-123',
device_info: {
platform: 'android',
model: 'Samsung Galaxy S21',
os_version: 'Android 13'
}
})
});
const data = await response.json();
if (data.success) {
// Store tokens
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
}
```
#### Kotlin/Android
```kotlin
data class SignupRequest(
val name: String,
val phone_number: String,
val state: String? = null,
val district: String? = null,
val city_village: String? = null,
val device_id: String? = null,
val device_info: Map<String, String?>? = null
)
data class SignupResponse(
val success: Boolean,
val user: User,
val access_token: String,
val refresh_token: String,
val needs_profile: Boolean,
val is_new_account: Boolean,
val is_new_device: Boolean,
val active_devices_count: Int,
val location_id: String?
)
// Usage
val request = SignupRequest(
name = "John Doe",
phone_number = "+919876543210",
state = "Maharashtra",
district = "Mumbai",
city_village = "Andheri",
device_id = getDeviceId(),
device_info = mapOf(
"platform" to "android",
"model" to Build.MODEL,
"os_version" to Build.VERSION.RELEASE
)
)
val response = apiClient.post<SignupResponse>("/auth/signup", request)
```
### Notes
- Phone number must be in E.164 format (e.g., `+919876543210`)
- If phone number is 10 digits without `+`, it will be normalized to `+91` prefix
- Location fields are optional - user can be created without location
- If user already exists, returns 409 Conflict with `user_exists: true`
- All phone numbers are encrypted in the database for security
- Country code is automatically extracted from phone number

235
docs/KOTLIN_SIGNUP_FIX.md Normal file
View File

@ -0,0 +1,235 @@
# Kotlin Signup API Response Fix
## Problem
The error "Expected response body of the type 'class com.example.livingai...'" indicates that your Kotlin data class doesn't match the actual API response structure.
## Actual API Response Structure
The `/auth/signup` endpoint returns:
```json
{
"success": true,
"user": {
"id": "uuid-here",
"phone_number": "+919876543210",
"name": "John Doe",
"country_code": "+91",
"created_at": "2024-01-15T10:30:00Z"
},
"access_token": "jwt-token",
"refresh_token": "jwt-token",
"needs_profile": true,
"is_new_account": true,
"is_new_device": true,
"active_devices_count": 1,
"location_id": "uuid-or-null"
}
```
## Correct Kotlin Data Classes
### Option 1: Using Gson/Moshi with @SerializedName
```kotlin
import com.google.gson.annotations.SerializedName
// OR for Moshi: import com.squareup.moshi.Json
data class SignupUser(
val id: String,
@SerializedName("phone_number") val phoneNumber: String,
val name: String?,
@SerializedName("country_code") val countryCode: String?,
@SerializedName("created_at") val createdAt: String?
)
data class SignupResponse(
val success: Boolean,
val user: SignupUser,
@SerializedName("access_token") val accessToken: String,
@SerializedName("refresh_token") val refreshToken: String,
@SerializedName("needs_profile") val needsProfile: Boolean,
@SerializedName("is_new_account") val isNewAccount: Boolean,
@SerializedName("is_new_device") val isNewDevice: Boolean,
@SerializedName("active_devices_count") val activeDevicesCount: Int,
@SerializedName("location_id") val locationId: String?
)
```
### Option 2: Using Kotlinx Serialization
```kotlin
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class SignupUser(
val id: String,
@SerialName("phone_number") val phoneNumber: String,
val name: String? = null,
@SerialName("country_code") val countryCode: String? = null,
@SerialName("created_at") val createdAt: String? = null
)
@Serializable
data class SignupResponse(
val success: Boolean,
val user: SignupUser,
@SerialName("access_token") val accessToken: String,
@SerialName("refresh_token") val refreshToken: String,
@SerialName("needs_profile") val needsProfile: Boolean,
@SerialName("is_new_account") val isNewAccount: Boolean,
@SerialName("is_new_device") val isNewDevice: Boolean,
@SerialName("active_devices_count") val activeDevicesCount: Int,
@SerialName("location_id") val locationId: String? = null
)
```
### Option 3: Using Retrofit with Field Naming Strategy
If using Retrofit, you can configure it to handle snake_case automatically:
```kotlin
// In your Retrofit builder
val retrofit = Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(
GsonConverterFactory.create(
GsonBuilder()
.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
.create()
)
)
.build()
// Then your data classes can use camelCase
data class SignupUser(
val id: String,
val phoneNumber: String, // Will map to "phone_number"
val name: String?,
val countryCode: String?, // Will map to "country_code"
val createdAt: String? // Will map to "created_at"
)
data class SignupResponse(
val success: Boolean,
val user: SignupUser,
val accessToken: String, // Will map to "access_token"
val refreshToken: String, // Will map to "refresh_token"
val needsProfile: Boolean,
val isNewAccount: Boolean,
val isNewDevice: Boolean,
val activeDevicesCount: Int,
val locationId: String?
)
```
## Complete Example with Error Handling
```kotlin
// SignupRequest.kt
data class SignupRequest(
val name: String,
@SerializedName("phone_number") val phoneNumber: String,
val state: String? = null,
val district: String? = null,
@SerializedName("city_village") val cityVillage: String? = null,
@SerializedName("device_id") val deviceId: String? = null,
@SerializedName("device_info") val deviceInfo: Map<String, String?>? = null
)
// SignupResponse.kt
data class SignupUser(
val id: String,
@SerializedName("phone_number") val phoneNumber: String,
val name: String?,
@SerializedName("country_code") val countryCode: String?,
@SerializedName("created_at") val createdAt: String?
)
data class SignupResponse(
val success: Boolean,
val user: SignupUser,
@SerializedName("access_token") val accessToken: String,
@SerializedName("refresh_token") val refreshToken: String,
@SerializedName("needs_profile") val needsProfile: Boolean,
@SerializedName("is_new_account") val isNewAccount: Boolean,
@SerializedName("is_new_device") val isNewDevice: Boolean,
@SerializedName("active_devices_count") val activeDevicesCount: Int,
@SerializedName("location_id") val locationId: String?
)
// ErrorResponse.kt
data class ErrorResponse(
val success: Boolean? = null,
val error: String? = null,
val message: String? = null,
@SerializedName("user_exists") val userExists: Boolean? = null
)
// Usage in your ViewModel or Repository
suspend fun signup(
name: String,
phoneNumber: String,
state: String? = null,
district: String? = null,
cityVillage: String? = null
): Result<SignupResponse> {
return try {
val request = SignupRequest(
name = name,
phoneNumber = phoneNumber,
state = state,
district = district,
cityVillage = cityVillage,
deviceId = getDeviceId(),
deviceInfo = getDeviceInfo()
)
val response = apiClient.post<SignupResponse>("/auth/signup", request)
if (response.isSuccessful && response.body()?.success == true) {
Result.success(response.body()!!)
} else {
// Parse error response
val errorBody = response.errorBody()?.string()
val errorResponse = Gson().fromJson(errorBody, ErrorResponse::class.java)
Result.failure(Exception(errorResponse.message ?: errorResponse.error ?: "Signup failed"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
```
## Common Issues and Fixes
### Issue 1: Field Name Mismatch
**Problem**: API uses `snake_case` but Kotlin uses `camelCase`
**Fix**: Use `@SerializedName` annotation or configure Retrofit with field naming policy
### Issue 2: Nullable Fields
**Problem**: Some fields might be null (like `location_id`)
**Fix**: Mark optional fields as nullable: `val locationId: String?`
### Issue 3: Nested Objects
**Problem**: `user` is a nested object
**Fix**: Create separate data class for `SignupUser`
### Issue 4: Type Mismatch
**Problem**: API returns `"success": true` but expecting different type
**Fix**: Use `Boolean` type: `val success: Boolean`
## Testing the Response
Add logging to see the actual response:
```kotlin
val response = apiClient.post("/auth/signup", request)
Log.d("Signup", "Response code: ${response.code()}")
Log.d("Signup", "Response body: ${response.body()?.string()}")
```
This will help you see exactly what the API is returning and adjust your data class accordingly.

View File

@ -308,3 +308,4 @@ For issues or questions:
3. Test AWS credentials with AWS CLI 3. Test AWS credentials with AWS CLI
4. Review IAM permissions for SSM access 4. Review IAM permissions for SSM access

View File

@ -276,3 +276,4 @@ When you start the application, you'll see:
- Switch by changing one environment variable - Switch by changing one environment variable
- All business logic remains unchanged - All business logic remains unchanged

View File

@ -87,3 +87,4 @@ PORT=3000
CORS_ALLOWED_ORIGINS=https://your-app-domain.com CORS_ALLOWED_ORIGINS=https://your-app-domain.com
``` ```

View File

@ -71,3 +71,4 @@ Both should return `true`.
1. Restart your application 1. Restart your application
2. Try creating an OTP - it should work now 2. Try creating an OTP - it should work now

View File

@ -107,3 +107,4 @@ The default postgres user has superuser privileges.
3. Restart your application 3. Restart your application

View File

@ -94,3 +94,4 @@ SELECT
Both should return `true`. Both should return `true`.

View File

@ -286,3 +286,4 @@ redis-cli INFO memory
4. Verify connection with `✅ Redis Client: Ready` message 4. Verify connection with `✅ Redis Client: Ready` message
5. Test rate limiting to ensure it's working 5. Test rate limiting to ensure it's working

231
kotlin_otp_fix.md Normal file
View File

@ -0,0 +1,231 @@
# Kotlin OTP Verification Fix
## Issue Analysis
The backend `/auth/verify-otp` endpoint expects:
```json
{
"phone_number": "+919876543210", // Must be E.164 format with +
"code": "123456", // String, not number
"device_id": "optional-device-id",
"device_info": {
"platform": "android",
"model": "device-model",
"os_version": "Android 13",
"app_version": "1.0.0",
"language_code": "en",
"timezone": "Asia/Kolkata"
}
}
```
## Common Issues in Kotlin Implementation
1. **Phone Number Format**: Must include `+` prefix (E.164 format)
2. **OTP Code Type**: Must be sent as string, not integer
3. **Request Body**: Must match exact field names (`phone_number`, `code`)
4. **Missing Device Info**: Backend accepts but doesn't require device_info
## Fixed Kotlin Code
### Option 1: Update AuthManager.login() method
```kotlin
// In AuthManager.kt or AuthApiClient.kt
suspend fun login(phoneNumber: String, otpCode: String): Result<LoginResponse> {
return try {
// Ensure phone number has + prefix (E.164 format)
val normalizedPhone = if (phoneNumber.startsWith("+")) {
phoneNumber
} else if (phoneNumber.length == 10) {
"+91$phoneNumber" // Add +91 for 10-digit Indian numbers
} else {
phoneNumber // Keep as is if already formatted
}
// Ensure OTP is string (not integer)
val otpString = otpCode.toString().trim()
// Get device info
val deviceId = getDeviceId() // Your method to get device ID
val deviceInfo = getDeviceInfo() // Your method to get device info
val requestBody = mapOf(
"phone_number" to normalizedPhone,
"code" to otpString,
"device_id" to deviceId,
"device_info" to deviceInfo
)
val response = apiClient.post("/auth/verify-otp", requestBody)
if (response.isSuccessful) {
val loginResponse = response.body() // Parse your LoginResponse
Result.success(loginResponse)
} else {
// Handle error response
val errorBody = response.errorBody()?.string()
Result.failure(Exception("OTP verification failed: $errorBody"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
// Helper function to get device info
private fun getDeviceInfo(): Map<String, String?> {
return mapOf(
"platform" to "android",
"model" to android.os.Build.MODEL,
"os_version" to android.os.Build.VERSION.RELEASE,
"app_version" to getAppVersion(), // Your method
"language_code" to Locale.getDefault().language,
"timezone" to TimeZone.getDefault().id
)
}
private fun getDeviceId(): String {
// Use your existing device ID logic
// Could be Android ID, UUID, etc.
return Settings.Secure.getString(
context.contentResolver,
Settings.Secure.ANDROID_ID
) ?: UUID.randomUUID().toString()
}
```
### Option 2: Quick Fix in OtpScreen.kt
Update your `OtpScreen.kt` to ensure proper formatting:
```kotlin
@Composable
fun OtpScreen(navController: NavController, phoneNumber: String, name: String) {
val otp = remember { mutableStateOf("") }
val context = LocalContext.current.applicationContext
val scope = rememberCoroutineScope()
val authManager = remember { AuthManager(context, AuthApiClient(context), TokenManager(context)) }
val isSignInFlow = name == "existing_user"
// Normalize phone number to ensure it has + prefix
val normalizedPhone = remember(phoneNumber) {
if (phoneNumber.startsWith("+")) {
phoneNumber
} else if (phoneNumber.length == 10) {
"+91$phoneNumber"
} else {
phoneNumber
}
}
Box(
modifier = Modifier.fillMaxSize()
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 36.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(200.dp))
Text("Enter OTP", fontSize = 24.sp, fontWeight = FontWeight.Medium, color = Color(0xFF364153))
Spacer(modifier = Modifier.height(32.dp))
TextField(
value = otp.value,
onValueChange = { if (it.length <= 6 && it.all { char -> char.isDigit() }) otp.value = it },
modifier = Modifier
.fillMaxWidth()
.height(60.dp)
.shadow(elevation = 1.dp, shape = RoundedCornerShape(16.dp)),
shape = RoundedCornerShape(16.dp),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.White.copy(alpha = 0.9f),
unfocusedContainerColor = Color.White.copy(alpha = 0.9f),
disabledContainerColor = Color.White.copy(alpha = 0.9f),
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center, fontSize = 24.sp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
Spacer(modifier = Modifier.height(48.dp))
Button(
onClick = {
scope.launch {
// Ensure OTP is not empty and is 6 digits
if (otp.value.length != 6) {
Toast.makeText(context, "Please enter a valid 6-digit OTP", Toast.LENGTH_SHORT).show()
return@launch
}
// Use normalized phone number
authManager.login(normalizedPhone, otp.value.trim())
.onSuccess { response ->
if (isSignInFlow) {
navController.navigate("success") { popUpTo("login") { inclusive = true } }
} else {
if (response.needsProfile) {
navController.navigate("create_profile/$name")
} else {
navController.navigate("success") { popUpTo("login") { inclusive = true } }
}
}
}
.onFailure { error ->
// More detailed error handling
val errorMessage = error.message ?: "Invalid or expired OTP"
Toast.makeText(context, errorMessage, Toast.LENGTH_SHORT).show()
Log.e("OtpScreen", "OTP verification failed", error)
}
}
},
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFFE9A00)),
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
.shadow(elevation = 4.dp, shape = RoundedCornerShape(16.dp))
) {
Text("Continue", color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.Medium)
}
}
}
}
```
## Key Changes to Check in Your AuthManager/AuthApiClient
1. **Phone Number Normalization**: Ensure `+` prefix is present
2. **OTP as String**: Send OTP as string, not integer
3. **Request Body Format**: Use exact field names from backend
4. **Error Handling**: Check response status and error body
## Debugging Steps
1. **Add Logging**: Log the exact request being sent
```kotlin
Log.d("AuthManager", "Sending verify-otp: phone=$normalizedPhone, code=$otpString")
Log.d("AuthManager", "Request body: $requestBody")
```
2. **Check Response**: Log the response
```kotlin
Log.d("AuthManager", "Response code: ${response.code()}")
Log.d("AuthManager", "Response body: ${response.body()?.string()}")
```
3. **Compare with HTML**: Use the same phone number and OTP in HTML test page to verify backend is working
## Most Likely Issues
1. **Phone number missing `+` prefix** - Backend normalizes but expects E.164 format
2. **OTP sent as number instead of string** - Backend expects string
3. **Wrong field names** - Must be `phone_number` and `code` (with underscores)
4. **Request body not properly serialized** - Check your JSON serialization

View File

@ -133,3 +133,4 @@ storeAdminCredentials().catch((error) => {
process.exit(1); process.exit(1);
}); });

View File

@ -25,13 +25,19 @@ function validatePhone(phone) {
* Validate OTP code format * Validate OTP code format
*/ */
function validateOtpCode(code) { function validateOtpCode(code) {
if (!code || typeof code !== 'string') { if (!code) {
return { valid: false, error: 'code must be a string' }; return { valid: false, error: 'code is required' };
} }
if (!/^\d{6}$/.test(code)) {
// Convert to string if it's a number (JSON may send numbers)
const codeString = String(code);
// Validate it's exactly 6 digits
if (!/^\d{6}$/.test(codeString)) {
return { valid: false, error: 'code must be exactly 6 digits' }; return { valid: false, error: 'code must be exactly 6 digits' };
} }
return { valid: true };
return { valid: true, normalizedCode: codeString };
} }
/** /**
@ -160,6 +166,14 @@ function validateVerifyOtpBody(req, res, next) {
if (!codeCheck.valid) { if (!codeCheck.valid) {
return res.status(400).json({ error: codeCheck.error }); return res.status(400).json({ error: codeCheck.error });
} }
// Normalize code to string (in case it came as number from JSON)
// This ensures bcrypt.compare works correctly
if (codeCheck.normalizedCode) {
req.body.code = codeCheck.normalizedCode;
} else {
req.body.code = String(code);
}
const deviceIdCheck = validateDeviceId(device_id); const deviceIdCheck = validateDeviceId(device_id);
if (!deviceIdCheck.valid) { if (!deviceIdCheck.valid) {
@ -399,11 +413,90 @@ function validateLogoutOthersBody(req, res, next) {
next(); next();
} }
/**
* Validate signup request body
*/
function validateSignupBody(req, res, next) {
const { name, phone_number, state, district, city_village, device_id, device_info } = req.body;
// Check body size
const sizeCheck = validateBodySize(req.body, 2000);
if (!sizeCheck.valid) {
return res.status(400).json({ error: sizeCheck.error });
}
// Validate required fields
if (!name) {
return res.status(400).json({ error: 'name is required' });
}
if (!phone_number) {
return res.status(400).json({ error: 'phone_number is required' });
}
// Validate each field
const nameCheck = validateName(name);
if (!nameCheck.valid) {
return res.status(400).json({ error: nameCheck.error });
}
const phoneCheck = validatePhone(phone_number);
if (!phoneCheck.valid) {
return res.status(400).json({ error: phoneCheck.error });
}
// Validate optional location fields
if (state !== undefined && state !== null) {
if (typeof state !== 'string' || state.trim().length > 100) {
return res.status(400).json({ error: 'state must be a string with max 100 characters' });
}
}
if (district !== undefined && district !== null) {
if (typeof district !== 'string' || district.trim().length > 100) {
return res.status(400).json({ error: 'district must be a string with max 100 characters' });
}
}
if (city_village !== undefined && city_village !== null) {
if (typeof city_village !== 'string' || city_village.trim().length > 150) {
return res.status(400).json({ error: 'city_village must be a string with max 150 characters' });
}
}
const deviceIdCheck = validateDeviceId(device_id);
if (!deviceIdCheck.valid) {
return res.status(400).json({ error: deviceIdCheck.error });
}
const deviceInfoCheck = validateDeviceInfo(device_info);
if (!deviceInfoCheck.valid) {
return res.status(400).json({ error: deviceInfoCheck.error });
}
// Trim and sanitize
if (name && typeof name === 'string') {
req.body.name = name.trim();
}
if (state && typeof state === 'string') {
req.body.state = state.trim();
}
if (district && typeof district === 'string') {
req.body.district = district.trim();
}
if (city_village && typeof city_village === 'string') {
req.body.city_village = city_village.trim();
}
next();
}
module.exports = { module.exports = {
validateRequestOtpBody, validateRequestOtpBody,
validateVerifyOtpBody, validateVerifyOtpBody,
validateRefreshTokenBody, validateRefreshTokenBody,
validateLogoutBody, validateLogoutBody,
validateSignupBody,
// === VALIDATION: USER ROUTES === // === VALIDATION: USER ROUTES ===
validateUpdateProfileBody, validateUpdateProfileBody,
validateDeviceIdParam, validateDeviceIdParam,

View File

@ -28,6 +28,7 @@ const {
validateVerifyOtpBody, validateVerifyOtpBody,
validateRefreshTokenBody, validateRefreshTokenBody,
validateLogoutBody, validateLogoutBody,
validateSignupBody,
} = require('../middleware/validation'); } = require('../middleware/validation');
// === SECURITY HARDENING: IP/DEVICE RISK === // === SECURITY HARDENING: IP/DEVICE RISK ===
const { const {
@ -263,8 +264,22 @@ router.post(
} }
const normalizedPhone = normalizePhone(phone_number); const normalizedPhone = normalizePhone(phone_number);
// Ensure code is a string (handle case where it might come as number from JSON)
// bcrypt.compare requires string, and validation expects string
// Note: If code comes as number, leading zeros may be lost - client should send as string
const codeString = String(code);
// Debug logging (remove in production or use proper logger)
console.log('[OTP Verify] Request body:', JSON.stringify({
phone_number: normalizedPhone.replace(/\d(?=\d{4})/g, '*'),
code: codeString,
code_type: typeof code,
code_length: codeString.length,
device_id: device_id?.substring(0, 20) + '...'
}));
const result = await verifyOtp(normalizedPhone, code); const result = await verifyOtp(normalizedPhone, codeString);
// === ADDED FOR OTP ATTEMPT LIMIT === // === ADDED FOR OTP ATTEMPT LIMIT ===
// === SECURITY HARDENING: AUDIT LOGS & ANOMALY FLAGS === // === SECURITY HARDENING: AUDIT LOGS & ANOMALY FLAGS ===
@ -660,4 +675,370 @@ router.post(
} }
); );
// POST /auth/signup
// Signup endpoint: Create new user with name, phone, and location
router.post(
'/signup',
validateSignupBody,
async (req, res) => {
const clientIp = req.ip || req.connection.remoteAddress || 'unknown';
// Check if IP is blocked
if (isIpBlocked(clientIp)) {
await logBlockedIpLogin(clientIp, req.headers['user-agent']);
return res.status(403).json({
success: false,
message: 'Access denied from this location.',
});
}
try {
const { name, phone_number, state, district, city_village, device_id, device_info } = req.body;
// Normalize phone number
const normalizedPhone = normalizePhone(phone_number);
// Validate phone number format
if (!isValidPhoneNumber(normalizedPhone)) {
return res.status(400).json({
success: false,
message: 'Invalid phone number format. Please use E.164 format (e.g., +919876543210)',
});
}
// === SECURITY HARDENING: FIELD-LEVEL ENCRYPTION ===
// Encrypt phone number before storing/searching
const encryptedPhone = encryptPhoneNumber(normalizedPhone);
const phoneSearchParams = preparePhoneSearchParams(normalizedPhone);
// Check if user already exists
const existingUser = await db.query(
`SELECT id, phone_number, name, role, NULL::user_type_enum as user_type,
COALESCE(token_version, 1) as token_version
FROM users
WHERE (phone_number = $1 OR phone_number = $2)
AND deleted = FALSE`,
phoneSearchParams
).catch(async (err) => {
if (err.code === '42703' && err.message.includes('token_version')) {
return await db.query(
`SELECT id, phone_number, name, role, NULL::user_type_enum as user_type, 1 as token_version
FROM users
WHERE (phone_number = $1 OR phone_number = $2)
AND deleted = FALSE`,
phoneSearchParams
);
}
throw err;
});
if (existingUser.rows.length > 0) {
const existing = existingUser.rows[0];
// Check if user was just created by verify-otp (has no name) - allow update
if (!existing.name || existing.name.trim() === '') {
// User exists but has no name (was just created by verify-otp), update it with signup data
const countryCode = normalizedPhone.startsWith('+')
? normalizedPhone.match(/^\+(\d{1,3})/)?.[0] || '+91'
: '+91';
// Update user with name and country code
const updatedUser = await db.query(
`UPDATE users
SET name = $1, country_code = $2
WHERE id = $3
RETURNING id, phone_number, name, role, NULL::user_type_enum as user_type,
COALESCE(token_version, 1) as token_version, country_code, created_at`,
[name, countryCode, existing.id]
).catch(async (err) => {
if (err.code === '42703' && err.message.includes('token_version')) {
const result = await db.query(
`UPDATE users
SET name = $1, country_code = $2
WHERE id = $3
RETURNING id, phone_number, name, role, NULL::user_type_enum as user_type,
1 as token_version, country_code, created_at`,
[name, countryCode, existing.id]
);
result.rows[0].token_version = 1;
return result;
}
throw err;
});
const user = updatedUser.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);
}
// Create location entry if location data provided
let locationId = null;
if (state || district || city_village) {
const locationResult = await db.query(
`INSERT INTO locations (user_id, state, district, city_village, is_saved_address, location_type, source_type, source_confidence)
VALUES ($1, $2, $3, $4, TRUE, 'home', 'manual', 'high')
RETURNING id`,
[user.id, state || null, district || null, city_village || null]
);
locationId = locationResult.rows[0]?.id;
}
// Update last_login_at
await db.query(
`UPDATE users SET last_login_at = NOW() WHERE id = $1`,
[user.id]
);
const devId = sanitizeDeviceId(device_id);
// Upsert user_devices row
await db.query(
`
INSERT INTO user_devices (
user_id, device_identifier, device_platform, device_model,
os_version, app_version, language_code, timezone, last_seen_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())
ON CONFLICT (user_id, device_identifier)
DO UPDATE SET last_seen_at = EXCLUDED.last_seen_at,
device_platform = EXCLUDED.device_platform,
device_model = EXCLUDED.device_model,
os_version = EXCLUDED.os_version,
app_version = EXCLUDED.app_version,
language_code = EXCLUDED.language_code,
timezone = EXCLUDED.timezone,
is_active = true
`,
[
user.id,
devId,
device_info?.platform || 'android',
device_info?.model || null,
device_info?.os_version || null,
device_info?.app_version || null,
device_info?.language_code || null,
device_info?.timezone || null,
]
);
// === SECURITY HARDENING: AUDIT LOGS & ANOMALY FLAGS ===
await logAuthEvent({
userId: user.id,
action: 'signup',
status: 'success',
riskLevel: RISK_LEVELS.INFO,
deviceId: devId,
ipAddress: clientIp,
userAgent: req.headers['user-agent'],
meta: {
is_new_account: false,
is_profile_completed: true,
platform: device_info?.platform || 'android',
},
});
// Issue tokens
const accessToken = signAccessToken(user, { highAssurance: true });
const refreshToken = await issueRefreshToken({
userId: user.id,
deviceId: devId,
userAgent: req.headers['user-agent'],
ip: clientIp,
});
// Check if profile needs completion (user_type not set)
const needsProfile = !user.user_type || user.user_type === 'user';
// Get count of active devices
const deviceCountResult = await db.query(
`SELECT COUNT(*) as count FROM user_devices WHERE user_id = $1 AND is_active = true`,
[user.id]
);
const activeDevicesCount = parseInt(deviceCountResult.rows[0].count, 10);
return res.json({
success: true,
user: {
id: user.id,
phone_number: user.phone_number,
name: user.name,
country_code: user.country_code,
created_at: user.created_at,
},
access_token: accessToken,
refresh_token: refreshToken,
needs_profile: needsProfile,
is_new_account: false,
is_new_device: true,
active_devices_count: activeDevicesCount,
location_id: locationId,
});
} else {
// User already exists with a name - they should sign in instead
return res.status(409).json({
success: false,
message: 'User with this phone number already exists. Please sign in instead.',
user_exists: true,
});
}
}
// Extract country code from phone number
const countryCode = normalizedPhone.startsWith('+')
? normalizedPhone.match(/^\+(\d{1,3})/)?.[0] || '+91'
: '+91';
// Create new user
const newUser = await db.query(
`INSERT INTO users (phone_number, name, country_code)
VALUES ($1, $2, $3)
RETURNING id, phone_number, name, role, NULL::user_type_enum as user_type,
COALESCE(token_version, 1) as token_version, country_code, created_at`,
[encryptedPhone, name, countryCode]
).catch(async (err) => {
if (err.code === '42703' && err.message.includes('token_version')) {
const result = await db.query(
`INSERT INTO users (phone_number, name, country_code)
VALUES ($1, $2, $3)
RETURNING id, phone_number, name, role, NULL::user_type_enum as user_type,
1 as token_version, country_code, created_at`,
[encryptedPhone, name, countryCode]
);
result.rows[0].token_version = 1;
return result;
}
throw err;
});
const user = newUser.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);
}
// Create location entry if location data provided
let locationId = null;
if (state || district || city_village) {
const locationResult = await db.query(
`INSERT INTO locations (user_id, state, district, city_village, is_saved_address, location_type, source_type, source_confidence)
VALUES ($1, $2, $3, $4, TRUE, 'home', 'manual', 'high')
RETURNING id`,
[user.id, state || null, district || null, city_village || null]
);
locationId = locationResult.rows[0]?.id;
}
// Update last_login_at
await db.query(
`UPDATE users SET last_login_at = NOW() WHERE id = $1`,
[user.id]
);
const devId = sanitizeDeviceId(device_id);
// Upsert user_devices row
await db.query(
`
INSERT INTO user_devices (
user_id, device_identifier, device_platform, device_model,
os_version, app_version, language_code, timezone, last_seen_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())
ON CONFLICT (user_id, device_identifier)
DO UPDATE SET last_seen_at = EXCLUDED.last_seen_at,
device_platform = EXCLUDED.device_platform,
device_model = EXCLUDED.device_model,
os_version = EXCLUDED.os_version,
app_version = EXCLUDED.app_version,
language_code = EXCLUDED.language_code,
timezone = EXCLUDED.timezone,
is_active = true
`,
[
user.id,
devId,
device_info?.platform || 'android',
device_info?.model || null,
device_info?.os_version || null,
device_info?.app_version || null,
device_info?.language_code || null,
device_info?.timezone || null,
]
);
// === SECURITY HARDENING: AUDIT LOGS & ANOMALY FLAGS ===
await logAuthEvent({
userId: user.id,
action: 'signup',
status: 'success',
riskLevel: RISK_LEVELS.INFO,
deviceId: devId,
ipAddress: clientIp,
userAgent: req.headers['user-agent'],
meta: {
is_new_account: true,
platform: device_info?.platform || 'android',
},
});
// Issue tokens
const accessToken = signAccessToken(user, { highAssurance: true });
const refreshToken = await issueRefreshToken({
userId: user.id,
deviceId: devId,
userAgent: req.headers['user-agent'],
ip: clientIp,
});
// Check if profile needs completion (user_type not set)
const needsProfile = !user.user_type || user.user_type === 'user';
// Get count of active devices
const deviceCountResult = await db.query(
`SELECT COUNT(*) as count FROM user_devices WHERE user_id = $1 AND is_active = true`,
[user.id]
);
const activeDevicesCount = parseInt(deviceCountResult.rows[0].count, 10);
return res.json({
success: true,
user: {
id: user.id,
phone_number: user.phone_number,
name: user.name,
country_code: user.country_code,
created_at: user.created_at,
},
access_token: accessToken,
refresh_token: refreshToken,
needs_profile: needsProfile,
is_new_account: true,
is_new_device: true,
active_devices_count: activeDevicesCount,
location_id: locationId,
});
} catch (err) {
console.error('signup error', err);
// Handle unique constraint violation (phone number already exists)
if (err.code === '23505' && err.constraint === 'users_phone_number_key') {
return res.status(409).json({
success: false,
message: 'User with this phone number already exists. Please sign in instead.',
user_exists: true,
});
}
return res.status(500).json({
success: false,
message: 'Internal server error',
});
}
}
);
module.exports = router; module.exports = router;

View File

@ -189,3 +189,4 @@ module.exports = {

View File

@ -104,24 +104,94 @@ async function createOtp(phoneNumber) {
*/ */
async function verifyOtp(phoneNumber, code) { async function verifyOtp(phoneNumber, code) {
// === SECURITY HARDENING: FIELD-LEVEL ENCRYPTION === // === SECURITY HARDENING: FIELD-LEVEL ENCRYPTION ===
// Encrypt phone number for search (handles both encrypted and plaintext for backward compatibility) // For search, we need to handle encrypted phone numbers
const encryptedPhone = encryptPhoneNumber(phoneNumber); // Since encryption uses random IV, we can't encrypt and match directly
// Instead, we search for plaintext OR try to match by decrypting stored values
// The simplest approach: search by plaintext first, then by encrypted if needed
// Debug logging
console.log('[OTP Service] Verifying OTP for phone:', phoneNumber.replace(/\d(?=\d{4})/g, '*'), 'Code:', code, 'Code type:', typeof code);
// Try to find OTP by plaintext phone number first (works if encryption is disabled or for backward compatibility)
// Also try encrypted if encryption is enabled (though it may not match due to random IV)
const encryptedPhone = encryptPhoneNumber(phoneNumber);
console.log('[OTP Service] Encrypted phone (first 20 chars):', encryptedPhone.substring(0, 20) + '...');
// Search strategy: Get all recent OTPs for this phone (both encrypted and plaintext)
// Then filter in application code if needed
const result = await db.query( const result = await db.query(
`SELECT id, otp_hash, expires_at, attempt_count, phone_number, consumed_at `SELECT id, otp_hash, expires_at, attempt_count, phone_number, consumed_at, deleted, created_at
FROM otp_requests FROM otp_requests
WHERE (phone_number = $1 OR phone_number = $2) WHERE (phone_number = $1 OR phone_number = $2)
AND deleted = FALSE AND deleted = FALSE
AND consumed_at IS NULL AND consumed_at IS NULL
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT 1`, LIMIT 5`,
[encryptedPhone, phoneNumber] // Try both encrypted and plaintext for backward compatibility [encryptedPhone, phoneNumber] // Try both encrypted and plaintext for backward compatibility
); );
// If encryption is enabled and we got results, we need to check if any match by decrypting
// But for now, let's just use the first result if phone_number matches plaintext or we can decrypt it
let otpRecord = null;
if (result.rows.length > 0) {
// Find the record that matches (either plaintext match or can be decrypted)
for (const row of result.rows) {
if (row.phone_number === phoneNumber) {
// Plaintext match
otpRecord = row;
break;
}
// Try to decrypt and match (if encryption is enabled)
try {
const { decryptPhoneNumber } = require('../utils/fieldEncryption');
const decrypted = decryptPhoneNumber(row.phone_number);
if (decrypted === phoneNumber) {
otpRecord = row;
break;
}
} catch (err) {
// If decryption fails, it might be plaintext that doesn't match, continue
continue;
}
}
}
// If no matching record found, set result.rows to empty for consistent handling
if (!otpRecord && result.rows.length > 0) {
console.log('[OTP Service] Found records but none matched after decryption check');
result.rows = [];
} else if (otpRecord) {
result.rows = [otpRecord];
}
// Debug logging
console.log('[OTP Service] Found OTP records:', result.rows.length);
if (result.rows.length > 0) {
const record = result.rows[0];
console.log('[OTP Service] OTP record - attempt_count:', record.attempt_count, 'expires_at:', record.expires_at, 'consumed_at:', record.consumed_at);
} else {
// Check if OTP exists but is consumed or deleted
const allRecords = await db.query(
`SELECT id, attempt_count, consumed_at, deleted, created_at, phone_number
FROM otp_requests
WHERE (phone_number = $1 OR phone_number = $2)
ORDER BY created_at DESC
LIMIT 3`,
[encryptedPhone, phoneNumber]
);
console.log('[OTP Service] All OTP records (including consumed):', allRecords.rows.length);
if (allRecords.rows.length > 0) {
allRecords.rows.forEach((r, i) => {
const storedPhone = r.phone_number.length > 20 ? r.phone_number.substring(0, 20) + '...' : r.phone_number;
console.log(`[OTP Service] Record ${i + 1}: phone=${storedPhone}, consumed_at=${r.consumed_at}, deleted=${r.deleted}, attempts=${r.attempt_count}`);
});
}
}
// === SECURITY HARDENING: TIMING ATTACK PROTECTION === // === SECURITY HARDENING: TIMING ATTACK PROTECTION ===
// Always perform bcrypt.compare() to maintain constant execution time // Always perform bcrypt.compare() to maintain constant execution time
// Use a dummy hash if OTP not found to prevent timing leaks // Use a dummy hash if OTP not found to prevent timing leaks
let otpRecord = result.rows[0]; // otpRecord is already set from the loop above (or null if not found)
let isNotFound = false; let isNotFound = false;
let isExpired = false; let isExpired = false;
let isMaxAttempts = false; let isMaxAttempts = false;
@ -145,6 +215,15 @@ async function verifyOtp(phoneNumber, code) {
// Check if max attempts exceeded (but don't return early - continue to bcrypt.compare) // Check if max attempts exceeded (but don't return early - continue to bcrypt.compare)
isMaxAttempts = otpRecord.attempt_count >= MAX_OTP_ATTEMPTS; isMaxAttempts = otpRecord.attempt_count >= MAX_OTP_ATTEMPTS;
console.log('[OTP Service] OTP record details:', {
id: otpRecord.id,
attempt_count: otpRecord.attempt_count,
isExpired: isExpired,
isMaxAttempts: isMaxAttempts,
expires_at: otpRecord.expires_at,
hash_length: hashToCompare?.length
});
} }
// === SECURITY HARDENING: TIMING ATTACK PROTECTION === // === SECURITY HARDENING: TIMING ATTACK PROTECTION ===
@ -152,7 +231,9 @@ async function verifyOtp(phoneNumber, code) {
// This ensures all code paths take similar time // This ensures all code paths take similar time
// Even if we know the OTP is expired or max attempts exceeded, we still compare // Even if we know the OTP is expired or max attempts exceeded, we still compare
// to prevent attackers from distinguishing between different failure modes // to prevent attackers from distinguishing between different failure modes
console.log('[OTP Service] Comparing code:', code, 'with hash (first 20 chars):', hashToCompare?.substring(0, 20) + '...');
const matches = await bcrypt.compare(code, hashToCompare); const matches = await bcrypt.compare(code, hashToCompare);
console.log('[OTP Service] bcrypt.compare result:', matches);
// === SECURITY HARDENING: TIMING ATTACK PROTECTION === // === SECURITY HARDENING: TIMING ATTACK PROTECTION ===
// Determine the actual result after constant-time comparison // Determine the actual result after constant-time comparison
@ -182,14 +263,30 @@ async function verifyOtp(phoneNumber, code) {
// Check if code matches (only if not expired and not max attempts) // Check if code matches (only if not expired and not max attempts)
if (!matches) { if (!matches) {
console.log('[OTP Service] ❌ Code mismatch! Code provided:', code, 'does not match stored hash');
// === ADDED FOR OTP ATTEMPT LIMIT === // === ADDED FOR OTP ATTEMPT LIMIT ===
// Increment attempt count // Increment attempt count
await db.query( if (otpRecord.id) {
'UPDATE otp_requests SET attempt_count = attempt_count + 1 WHERE id = $1', const updateResult = await db.query(
[otpRecord.id] 'UPDATE otp_requests SET attempt_count = attempt_count + 1 WHERE id = $1 RETURNING attempt_count',
); [otpRecord.id]
);
const newAttemptCount = updateResult.rows[0]?.attempt_count || 0;
console.log('[OTP Service] Code mismatch. Incremented attempt_count to:', newAttemptCount);
// If max attempts reached, mark as consumed
if (newAttemptCount >= MAX_OTP_ATTEMPTS) {
console.log('[OTP Service] Max attempts reached, marking as consumed');
await db.query(
'UPDATE otp_requests SET consumed_at = NOW() WHERE id = $1',
[otpRecord.id]
);
}
}
return { ok: false, reason: 'invalid' }; return { ok: false, reason: 'invalid' };
} }
console.log('[OTP Service] ✅ Code matches! OTP verified successfully');
// === ADDED FOR 2-MIN OTP VALIDITY & NO-RESEND === // === ADDED FOR 2-MIN OTP VALIDITY & NO-RESEND ===
// Mark OTP as consumed once verified to prevent reuse // Mark OTP as consumed once verified to prevent reuse

View File

@ -66,3 +66,4 @@ module.exports = {