diff --git a/.gitignore b/.gitignore index b60207a..571d357 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,4 @@ build/ + diff --git a/MIGRATION_SUMMARY.md b/MIGRATION_SUMMARY.md index f8df960..af0e415 100644 --- a/MIGRATION_SUMMARY.md +++ b/MIGRATION_SUMMARY.md @@ -156,3 +156,4 @@ Common issues and solutions are documented in `docs/getting-started/AWS_DATABASE **Security Requirements Met**: ✅ Yes **Backward Compatibility**: ✅ Maintained + diff --git a/docs/API_SIGNUP_ENDPOINT.md b/docs/API_SIGNUP_ENDPOINT.md new file mode 100644 index 0000000..ba7f781 --- /dev/null +++ b/docs/API_SIGNUP_ENDPOINT.md @@ -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? = 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("/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 + diff --git a/docs/KOTLIN_SIGNUP_FIX.md b/docs/KOTLIN_SIGNUP_FIX.md new file mode 100644 index 0000000..092ed67 --- /dev/null +++ b/docs/KOTLIN_SIGNUP_FIX.md @@ -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? = 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 { + return try { + val request = SignupRequest( + name = name, + phoneNumber = phoneNumber, + state = state, + district = district, + cityVillage = cityVillage, + deviceId = getDeviceId(), + deviceInfo = getDeviceInfo() + ) + + val response = apiClient.post("/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. + + diff --git a/docs/getting-started/AWS_DATABASE_MIGRATION.md b/docs/getting-started/AWS_DATABASE_MIGRATION.md index 58a6608..d7afa30 100644 --- a/docs/getting-started/AWS_DATABASE_MIGRATION.md +++ b/docs/getting-started/AWS_DATABASE_MIGRATION.md @@ -308,3 +308,4 @@ For issues or questions: 3. Test AWS credentials with AWS CLI 4. Review IAM permissions for SSM access + diff --git a/docs/getting-started/DATABASE_MODE_SWITCH.md b/docs/getting-started/DATABASE_MODE_SWITCH.md index a23f5a3..b0c82db 100644 --- a/docs/getting-started/DATABASE_MODE_SWITCH.md +++ b/docs/getting-started/DATABASE_MODE_SWITCH.md @@ -276,3 +276,4 @@ When you start the application, you'll see: - Switch by changing one environment variable - All business logic remains unchanged + diff --git a/docs/getting-started/ENV_FILE_TEMPLATE.md b/docs/getting-started/ENV_FILE_TEMPLATE.md index a891f51..998c977 100644 --- a/docs/getting-started/ENV_FILE_TEMPLATE.md +++ b/docs/getting-started/ENV_FILE_TEMPLATE.md @@ -87,3 +87,4 @@ PORT=3000 CORS_ALLOWED_ORIGINS=https://your-app-domain.com ``` + diff --git a/docs/getting-started/FIX_DATABASE_PERMISSIONS.md b/docs/getting-started/FIX_DATABASE_PERMISSIONS.md index 2006d1f..0758278 100644 --- a/docs/getting-started/FIX_DATABASE_PERMISSIONS.md +++ b/docs/getting-started/FIX_DATABASE_PERMISSIONS.md @@ -71,3 +71,4 @@ Both should return `true`. 1. Restart your application 2. Try creating an OTP - it should work now + diff --git a/docs/getting-started/GET_ADMIN_DB_CREDENTIALS.md b/docs/getting-started/GET_ADMIN_DB_CREDENTIALS.md index e52799b..2a19294 100644 --- a/docs/getting-started/GET_ADMIN_DB_CREDENTIALS.md +++ b/docs/getting-started/GET_ADMIN_DB_CREDENTIALS.md @@ -107,3 +107,4 @@ The default postgres user has superuser privileges. 3. Restart your application + diff --git a/docs/getting-started/QUICK_FIX_PERMISSIONS.md b/docs/getting-started/QUICK_FIX_PERMISSIONS.md index 3d3099f..0f4219a 100644 --- a/docs/getting-started/QUICK_FIX_PERMISSIONS.md +++ b/docs/getting-started/QUICK_FIX_PERMISSIONS.md @@ -94,3 +94,4 @@ SELECT Both should return `true`. + diff --git a/docs/getting-started/REDIS_SETUP.md b/docs/getting-started/REDIS_SETUP.md index 3360000..4d4c147 100644 --- a/docs/getting-started/REDIS_SETUP.md +++ b/docs/getting-started/REDIS_SETUP.md @@ -286,3 +286,4 @@ redis-cli INFO memory 4. Verify connection with `✅ Redis Client: Ready` message 5. Test rate limiting to ensure it's working + diff --git a/kotlin_otp_fix.md b/kotlin_otp_fix.md new file mode 100644 index 0000000..c976ed7 --- /dev/null +++ b/kotlin_otp_fix.md @@ -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 { + 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 { + 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 + diff --git a/scripts/store-admin-credentials.js b/scripts/store-admin-credentials.js index 30cca7a..e5c0227 100644 --- a/scripts/store-admin-credentials.js +++ b/scripts/store-admin-credentials.js @@ -133,3 +133,4 @@ storeAdminCredentials().catch((error) => { process.exit(1); }); + diff --git a/src/middleware/validation.js b/src/middleware/validation.js index c08ef7b..55dc6a2 100644 --- a/src/middleware/validation.js +++ b/src/middleware/validation.js @@ -25,13 +25,19 @@ function validatePhone(phone) { * Validate OTP code format */ function validateOtpCode(code) { - if (!code || typeof code !== 'string') { - return { valid: false, error: 'code must be a string' }; + if (!code) { + 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: true }; + + return { valid: true, normalizedCode: codeString }; } /** @@ -160,6 +166,14 @@ function validateVerifyOtpBody(req, res, next) { if (!codeCheck.valid) { 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); if (!deviceIdCheck.valid) { @@ -399,11 +413,90 @@ function validateLogoutOthersBody(req, res, 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 = { validateRequestOtpBody, validateVerifyOtpBody, validateRefreshTokenBody, validateLogoutBody, + validateSignupBody, // === VALIDATION: USER ROUTES === validateUpdateProfileBody, validateDeviceIdParam, diff --git a/src/routes/authRoutes.js b/src/routes/authRoutes.js index 9d415e4..8dd2696 100644 --- a/src/routes/authRoutes.js +++ b/src/routes/authRoutes.js @@ -28,6 +28,7 @@ const { validateVerifyOtpBody, validateRefreshTokenBody, validateLogoutBody, + validateSignupBody, } = require('../middleware/validation'); // === SECURITY HARDENING: IP/DEVICE RISK === const { @@ -263,8 +264,22 @@ router.post( } 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 === // === 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; diff --git a/src/services/jwtKeys.js b/src/services/jwtKeys.js index 0544f48..3b88ee8 100644 --- a/src/services/jwtKeys.js +++ b/src/services/jwtKeys.js @@ -189,3 +189,4 @@ module.exports = { + diff --git a/src/services/otpService.js b/src/services/otpService.js index da31562..d57390e 100644 --- a/src/services/otpService.js +++ b/src/services/otpService.js @@ -104,24 +104,94 @@ async function createOtp(phoneNumber) { */ async function verifyOtp(phoneNumber, code) { // === SECURITY HARDENING: FIELD-LEVEL ENCRYPTION === - // Encrypt phone number for search (handles both encrypted and plaintext for backward compatibility) - const encryptedPhone = encryptPhoneNumber(phoneNumber); + // For search, we need to handle encrypted phone numbers + // 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( - `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 WHERE (phone_number = $1 OR phone_number = $2) AND deleted = FALSE AND consumed_at IS NULL ORDER BY created_at DESC - LIMIT 1`, + LIMIT 5`, [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 === // Always perform bcrypt.compare() to maintain constant execution time // Use a dummy hash if OTP not found to prevent timing leaks - let otpRecord = result.rows[0]; + // otpRecord is already set from the loop above (or null if not found) let isNotFound = false; let isExpired = 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) 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 === @@ -152,7 +231,9 @@ async function verifyOtp(phoneNumber, code) { // This ensures all code paths take similar time // Even if we know the OTP is expired or max attempts exceeded, we still compare // to prevent attackers from distinguishing between different failure modes + console.log('[OTP Service] Comparing code:', code, 'with hash (first 20 chars):', hashToCompare?.substring(0, 20) + '...'); const matches = await bcrypt.compare(code, hashToCompare); + console.log('[OTP Service] bcrypt.compare result:', matches); // === SECURITY HARDENING: TIMING ATTACK PROTECTION === // 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) if (!matches) { + console.log('[OTP Service] ❌ Code mismatch! Code provided:', code, 'does not match stored hash'); // === ADDED FOR OTP ATTEMPT LIMIT === // Increment attempt count - await db.query( - 'UPDATE otp_requests SET attempt_count = attempt_count + 1 WHERE id = $1', - [otpRecord.id] - ); + if (otpRecord.id) { + const updateResult = await db.query( + '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' }; } + + console.log('[OTP Service] ✅ Code matches! OTP verified successfully'); // === ADDED FOR 2-MIN OTP VALIDITY & NO-RESEND === // Mark OTP as consumed once verified to prevent reuse diff --git a/src/utils/otpLogger.js b/src/utils/otpLogger.js index 883f8df..feb2740 100644 --- a/src/utils/otpLogger.js +++ b/src/utils/otpLogger.js @@ -66,3 +66,4 @@ module.exports = { +