Latest
This commit is contained in:
parent
736bfe4766
commit
9c82c677ce
|
|
@ -47,3 +47,4 @@ build/
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -156,3 +156,4 @@ Common issues and solutions are documented in `docs/getting-started/AWS_DATABASE
|
|||
**Security Requirements Met**: ✅ Yes
|
||||
**Backward Compatibility**: ✅ Maintained
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
@ -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.
|
||||
|
||||
|
||||
|
|
@ -308,3 +308,4 @@ For issues or questions:
|
|||
3. Test AWS credentials with AWS CLI
|
||||
4. Review IAM permissions for SSM access
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -276,3 +276,4 @@ When you start the application, you'll see:
|
|||
- Switch by changing one environment variable
|
||||
- All business logic remains unchanged
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -87,3 +87,4 @@ PORT=3000
|
|||
CORS_ALLOWED_ORIGINS=https://your-app-domain.com
|
||||
```
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -71,3 +71,4 @@ Both should return `true`.
|
|||
1. Restart your application
|
||||
2. Try creating an OTP - it should work now
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -107,3 +107,4 @@ The default postgres user has superuser privileges.
|
|||
|
||||
3. Restart your application
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -94,3 +94,4 @@ SELECT
|
|||
|
||||
Both should return `true`.
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
@ -133,3 +133,4 @@ storeAdminCredentials().catch((error) => {
|
|||
process.exit(1);
|
||||
});
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -161,6 +167,14 @@ function validateVerifyOtpBody(req, res, next) {
|
|||
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) {
|
||||
return res.status(400).json({ error: deviceIdCheck.error });
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ const {
|
|||
validateVerifyOtpBody,
|
||||
validateRefreshTokenBody,
|
||||
validateLogoutBody,
|
||||
validateSignupBody,
|
||||
} = require('../middleware/validation');
|
||||
// === SECURITY HARDENING: IP/DEVICE RISK ===
|
||||
const {
|
||||
|
|
@ -264,7 +265,21 @@ router.post(
|
|||
|
||||
const normalizedPhone = normalizePhone(phone_number);
|
||||
|
||||
const result = await verifyOtp(normalizedPhone, code);
|
||||
// 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, 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;
|
||||
|
|
|
|||
|
|
@ -189,3 +189,4 @@ module.exports = {
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,15 +263,31 @@ 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
|
||||
// Using consumed_at instead of deleting (matches final_db.sql schema)
|
||||
|
|
|
|||
|
|
@ -66,3 +66,4 @@ module.exports = {
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue