From 22ed0bfeb3bc5e77814402b2ab817acf2396bd0b Mon Sep 17 00:00:00 2001 From: Chandresh Kerkar Date: Sun, 30 Nov 2025 01:28:58 +0530 Subject: [PATCH] Updated Auth --- .env.example | 48 ++ .gitignore | 1 + API_INTEGRATION.md | 1443 ++++++++++++++++++++++++++---------- SETUP.md | 92 +++ TWILIO_SETUP.md | 201 +++++ src/routes/authRoutes.js | 45 +- src/routes/userRoutes.js | 61 +- src/services/smsService.js | 45 +- 8 files changed, 1528 insertions(+), 408 deletions(-) create mode 100644 .env.example create mode 100644 SETUP.md create mode 100644 TWILIO_SETUP.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4a98458 --- /dev/null +++ b/.env.example @@ -0,0 +1,48 @@ +# ============================================ +# REQUIRED ENVIRONMENT VARIABLES +# ============================================ +# Copy this file to .env and fill in your values + +# Database Connection (PostgreSQL) +DATABASE_URL=postgres://username:password@localhost:5432/database_name + +# JWT Secrets (use strong random strings) +# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +JWT_ACCESS_SECRET=your-access-token-secret-here-minimum-32-characters +JWT_REFRESH_SECRET=your-refresh-token-secret-here-minimum-32-characters + +# ============================================ +# OPTIONAL ENVIRONMENT VARIABLES +# ============================================ + +# Server Configuration +PORT=3000 +NODE_ENV=development + +# CORS Configuration (comma-separated list, required in production) +# Example: https://yourdomain.com,https://www.yourdomain.com +CORS_ALLOWED_ORIGINS= + +# JWT Token Expiration (default values shown) +JWT_ACCESS_TTL=15m +JWT_REFRESH_TTL=7d + +# Refresh Token Inactivity Timeout (in minutes, default: 4320 = 3 days) +REFRESH_MAX_IDLE_MINUTES=4320 + +# OTP Configuration +OTP_MAX_ATTEMPTS=5 + +# ============================================ +# TWILIO SMS CONFIGURATION (Optional) +# ============================================ +# Required for sending OTP via SMS +# If not configured, OTP will be logged to console in development + +TWILIO_ACCOUNT_SID=your-twilio-account-sid +TWILIO_AUTH_TOKEN=your-twilio-auth-token + +# Use either TWILIO_MESSAGING_SERVICE_SID (recommended) OR TWILIO_FROM_NUMBER +TWILIO_MESSAGING_SERVICE_SID=your-messaging-service-sid +# OR +TWILIO_FROM_NUMBER=+1234567890 diff --git a/.gitignore b/.gitignore index bbff9bf..e273a43 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,4 @@ build/ # Temporary files *.tmp *.temp + diff --git a/API_INTEGRATION.md b/API_INTEGRATION.md index c42661b..99b3bf7 100644 --- a/API_INTEGRATION.md +++ b/API_INTEGRATION.md @@ -1,6 +1,6 @@ # Farm Auth Service - API Integration Guide -Quick reference for integrating the Farm Auth Service into Kotlin mobile applications. +Complete integration guide for Farm Auth Service with Kotlin Multiplatform Mobile (KMM) or Kotlin Native applications. ## Base URL @@ -9,13 +9,25 @@ http://localhost:3000 (development) https://your-domain.com (production) ``` +## Table of Contents + +- [Authentication Flow](#authentication-flow) +- [API Endpoints](#api-endpoints) +- [Kotlin Multiplatform Implementation](#kotlin-multiplatform-implementation) +- [Error Handling](#error-handling) +- [Security Best Practices](#security-best-practices) +- [Token Management](#token-management) + +--- + ## Authentication Flow 1. **Request OTP** → User enters phone number -2. **Verify OTP** → User enters code, receives tokens +2. **Verify OTP** → User enters code, receives tokens + user info 3. **Use Access Token** → Include in `Authorization` header for protected endpoints -4. **Refresh Token** → When access token expires, get new tokens -5. **Logout** → Revoke refresh token +4. **Refresh Token** → When access token expires, get new tokens (auto-rotation) +5. **Logout** → Revoke refresh token on current device +6. **Device Management** → View/manage active devices --- @@ -39,20 +51,24 @@ https://your-domain.com (production) } ``` -**Kotlin Example:** -```kotlin -data class RequestOtpRequest(val phone_number: String) -data class RequestOtpResponse(val ok: Boolean) - -suspend fun requestOtp(phoneNumber: String): Result { - val request = RequestOtpRequest(phoneNumber) - return apiClient.post("/auth/request-otp", request) +**Error (400):** +```json +{ + "error": "phone_number is required" } ``` -**Note:** Phone numbers are auto-normalized: -- `9876543210` → `+919876543210` (10-digit assumed as Indian) -- `+919876543210` → `+919876543210` (already formatted) +**Error (500):** +```json +{ + "error": "Failed to send OTP" +} +``` + +**Phone Number Normalization:** +- `9876543210` → Automatically becomes `+919876543210` (10-digit assumed as Indian) +- `+919876543210` → Kept as is +- Phone numbers should ideally be in E.164 format with `+` prefix --- @@ -81,69 +97,42 @@ suspend fun requestOtp(phoneNumber: String): Result { ```json { "user": { - "id": "uuid-here", + "id": "550e8400-e29b-41d4-a716-446655440000", "phone_number": "+919876543210", "name": null, "role": "user", "user_type": null }, - "access_token": "eyJhbGc...", - "refresh_token": "eyJhbGc...", - "needs_profile": true -} -``` - -**Kotlin Example:** -```kotlin -data class DeviceInfo( - val platform: String, - val model: String? = null, - val os_version: String? = null, - val app_version: String? = null, - val language_code: String? = null, - val timezone: String? = null -) - -data class VerifyOtpRequest( - val phone_number: String, - val code: String, - val device_id: String, - val device_info: DeviceInfo? = null -) - -data class User( - val id: String, - val phone_number: String, - val name: String?, - val role: String, - val user_type: String? -) - -data class VerifyOtpResponse( - val user: User, - val access_token: String, - val refresh_token: String, - val needs_profile: Boolean -) - -suspend fun verifyOtp( - phoneNumber: String, - code: String, - deviceId: String, - deviceInfo: DeviceInfo? = null -): Result { - val request = VerifyOtpRequest(phoneNumber, code, deviceId, deviceInfo) - return apiClient.post("/auth/verify-otp", request) + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "needs_profile": true, + "is_new_device": true, + "is_new_account": false, + "active_devices_count": 2 } ``` **Error (400):** ```json +{ + "error": "phone_number and code are required" +} +``` +or +```json { "error": "Invalid or expired OTP" } ``` +**Notes:** +- `device_id` is required and will be sanitized (must be 4-128 alphanumeric characters, otherwise hashed) +- `device_info` is optional but recommended for better device tracking +- `needs_profile` is `true` if `name` or `user_type` is null +- `is_new_device` indicates if this device was seen for the first time +- `is_new_account` indicates if the user account was just created +- `active_devices_count` shows how many active devices the user has + --- ### 3. Refresh Token @@ -153,34 +142,173 @@ suspend fun verifyOtp( **Request:** ```json { - "refresh_token": "eyJhbGc..." + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." } ``` **Response (200):** ```json { - "access_token": "eyJhbGc...", - "refresh_token": "eyJhbGc..." + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." } ``` -**Kotlin Example:** -```kotlin -data class RefreshRequest(val refresh_token: String) -data class RefreshResponse(val access_token: String, val refresh_token: String) - -suspend fun refreshToken(refreshToken: String): Result { - val request = RefreshRequest(refreshToken) - return apiClient.post("/auth/refresh", request) +**Error (400):** +```json +{ + "error": "refresh_token is required" } ``` -**Note:** Refresh tokens rotate on each use. Always save the new `refresh_token`. +**Error (401):** +```json +{ + "error": "Invalid refresh token" +} +``` + +**Important:** +- Refresh tokens **rotate on each use** - always save the new `refresh_token` +- Old refresh token is automatically revoked +- If refresh token is compromised and reused, all tokens for that device are revoked --- -### 4. Update Profile +### 4. Logout + +**Endpoint:** `POST /auth/logout` + +**Request:** +```json +{ + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +} +``` + +**Response (200):** +```json +{ + "ok": true +} +``` + +**Note:** Returns `ok: true` even if token is already invalid (idempotent) + +--- + +### 5. Get Current User Profile + +**Endpoint:** `GET /users/me` + +**Headers:** +``` +Authorization: Bearer +``` + +**Response (200):** +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "phone_number": "+919876543210", + "name": "John Doe", + "role": "user", + "user_type": "seller", + "avatar_url": "https://example.com/avatar.jpg", + "language": "en", + "timezone": "Asia/Kolkata", + "created_at": "2024-01-15T10:30:00Z", + "last_login_at": "2024-01-20T14:22:00Z", + "active_devices_count": 2, + "location": { + "id": "location-uuid-here", + "country": "India", + "state": "Maharashtra", + "district": "Pune", + "city_village": "Pune City", + "pincode": "411001", + "coordinates": { + "latitude": 18.5204, + "longitude": 73.8567 + }, + "location_type": "home", + "is_saved_address": true, + "source_type": "manual", + "source_confidence": "high", + "created_at": "2024-01-15T10:30:00Z", + "updated_at": "2024-01-20T14:22:00Z" + }, + "locations": [ + { + "id": "location-uuid-here", + "country": "India", + "state": "Maharashtra", + "district": "Pune", + "city_village": "Pune City", + "pincode": "411001", + "coordinates": { + "latitude": 18.5204, + "longitude": 73.8567 + }, + "location_type": "home", + "is_saved_address": true, + "source_type": "manual", + "source_confidence": "high", + "created_at": "2024-01-15T10:30:00Z", + "updated_at": "2024-01-20T14:22:00Z" + } + ] +} +``` + +**Response Notes:** +- `location`: Primary/most recent saved location (or `null` if no saved locations) +- `locations`: Array of all saved locations (empty array if none) +- `coordinates`: `null` if latitude/longitude not available +- All optional fields (`avatar_url`, `language`, `timezone`) may be `null` + +**Response with No Saved Locations:** +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "phone_number": "+919876543210", + "name": "John Doe", + "role": "user", + "user_type": "seller", + "avatar_url": null, + "language": null, + "timezone": null, + "created_at": "2024-01-15T10:30:00Z", + "last_login_at": "2024-01-20T14:22:00Z", + "active_devices_count": 1, + "location": null, + "locations": [] +} +``` + +**Error (401):** +```json +{ + "error": "Missing Authorization header" +} +``` +or +```json +{ + "error": "Invalid or expired token" +} +``` + +**Error (404):** +```json +{ + "error": "User not found" +} +``` + +--- + +### 6. Update User Profile **Endpoint:** `PUT /users/me` @@ -200,7 +328,7 @@ Authorization: Bearer **Response (200):** ```json { - "id": "uuid-here", + "id": "550e8400-e29b-41d4-a716-446655440000", "phone_number": "+919876543210", "name": "John Doe", "role": "user", @@ -208,342 +336,111 @@ Authorization: Bearer } ``` -**user_type values:** `seller`, `buyer`, `service_provider` +**Valid `user_type` values:** +- `seller` +- `buyer` +- `service_provider` -**Kotlin Example:** -```kotlin -data class UpdateProfileRequest(val name: String, val user_type: String) -data class UpdateProfileResponse( - val id: String, - val phone_number: String, - val name: String?, - val role: String, - val user_type: String? -) - -suspend fun updateProfile( - name: String, - userType: String, - accessToken: String -): Result { - val request = UpdateProfileRequest(name, userType) - return apiClient.put("/users/me", request, accessToken) +**Error (400):** +```json +{ + "error": "name and user_type are required" } ``` --- -### 5. Logout +### 7. List Active Devices -**Endpoint:** `POST /auth/logout` +**Endpoint:** `GET /users/me/devices` -**Request:** +**Headers:** +``` +Authorization: Bearer +``` + +**Response (200):** ```json { - "refresh_token": "eyJhbGc..." + "devices": [ + { + "device_identifier": "android-installation-id-123", + "device_platform": "android", + "device_model": "Samsung SM-M326B", + "os_version": "Android 14", + "app_version": "1.0.0", + "language_code": "en-IN", + "timezone": "Asia/Kolkata", + "first_seen_at": "2024-01-15T10:30:00Z", + "last_seen_at": "2024-01-20T14:22:00Z", + "is_active": true + } + ] +} +``` + +--- + +### 8. Logout Specific Device + +**Endpoint:** `DELETE /users/me/devices/:device_id` + +**Headers:** +``` +Authorization: Bearer +``` + +**Response (200):** +```json +{ + "ok": true, + "message": "Device logged out successfully" +} +``` + +**Note:** This will: +- Mark the device as inactive in `user_devices` +- Revoke all refresh tokens for that device +- Log the action in `auth_audit` + +--- + +### 9. Logout All Other Devices + +**Endpoint:** `POST /users/me/logout-all-other-devices` + +**Headers:** +``` +Authorization: Bearer +X-Device-Id: +``` + +**Alternative:** Send `current_device_id` in request body: +```json +{ + "current_device_id": "android-installation-id-123" } ``` **Response (200):** ```json { - "ok": true + "ok": true, + "message": "Logged out 2 device(s)", + "revoked_devices_count": 2 } ``` -**Kotlin Example:** -```kotlin -suspend fun logout(refreshToken: String): Result { - val request = RefreshRequest(refreshToken) - return apiClient.post("/auth/logout", request) -} -``` - ---- - -## Complete Kotlin Integration Example - -### 1. API Client Setup - -```kotlin -import kotlinx.coroutines.* -import kotlinx.serialization.* -import kotlinx.serialization.json.* -import io.ktor.client.* -import io.ktor.client.call.* -import io.ktor.client.request.* -import io.ktor.client.statement.* -import io.ktor.http.* - -class AuthApiClient(private val baseUrl: String) { - private val client = HttpClient { - install(JsonFeature) { - serializer = KotlinxSerializationJson() - } - } - - suspend fun requestOtp(phoneNumber: String): Result { - return try { - val response = client.post("$baseUrl/auth/request-otp") { - contentType(ContentType.Application.Json) - setBody(JsonObject(mapOf("phone_number" to JsonPrimitive(phoneNumber)))) - } - Result.success(response.body()) - } catch (e: Exception) { - Result.failure(e) - } - } - - suspend fun verifyOtp( - phoneNumber: String, - code: String, - deviceId: String - ): Result { - return try { - val request = VerifyOtpRequest(phoneNumber, code, deviceId, getDeviceInfo()) - val response = client.post("$baseUrl/auth/verify-otp") { - contentType(ContentType.Application.Json) - setBody(request) - } - Result.success(response.body()) - } catch (e: Exception) { - Result.failure(e) - } - } - - suspend fun refreshToken(refreshToken: String): Result { - return try { - val request = RefreshRequest(refreshToken) - val response = client.post("$baseUrl/auth/refresh") { - contentType(ContentType.Application.Json) - setBody(request) - } - Result.success(response.body()) - } catch (e: Exception) { - Result.failure(e) - } - } - - suspend fun updateProfile( - name: String, - userType: String, - accessToken: String - ): Result { - return try { - val request = UpdateProfileRequest(name, userType) - val response = client.put("$baseUrl/users/me") { - contentType(ContentType.Application.Json) - header("Authorization", "Bearer $accessToken") - setBody(request) - } - Result.success(response.body()) - } catch (e: Exception) { - Result.failure(e) - } - } - - private fun getDeviceInfo(): DeviceInfo { - return DeviceInfo( - platform = "android", - model = Build.MODEL, - os_version = Build.VERSION.RELEASE, - app_version = BuildConfig.VERSION_NAME, - language_code = Locale.getDefault().toString(), - timezone = TimeZone.getDefault().id - ) - } -} -``` - -### 2. Token Storage (Secure SharedPreferences) - -```kotlin -import android.content.Context -import android.content.SharedPreferences -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKey - -class TokenManager(context: Context) { - private val masterKey = MasterKey.Builder(context) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .build() - - private val prefs: SharedPreferences = EncryptedSharedPreferences.create( - context, - "auth_tokens", - masterKey, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM - ) - - fun saveTokens(accessToken: String, refreshToken: String) { - prefs.edit().apply { - putString("access_token", accessToken) - putString("refresh_token", refreshToken) - apply() - } - } - - fun getAccessToken(): String? = prefs.getString("access_token", null) - fun getRefreshToken(): String? = prefs.getString("refresh_token", null) - - fun clearTokens() { - prefs.edit().clear().apply() - } -} -``` - -### 3. Authentication Manager - -```kotlin -class AuthManager( - private val apiClient: AuthApiClient, - private val tokenManager: TokenManager -) { - suspend fun login(phoneNumber: String, code: String): Result { - val deviceId = getDeviceId() - return apiClient.verifyOtp(phoneNumber, code, deviceId) - .onSuccess { response -> - tokenManager.saveTokens(response.access_token, response.refresh_token) - } - .map { it.user } - } - - suspend fun refreshTokens(): Result> { - val refreshToken = tokenManager.getRefreshToken() - ?: return Result.failure(Exception("No refresh token")) - - return apiClient.refreshToken(refreshToken) - .onSuccess { response -> - tokenManager.saveTokens(response.access_token, response.refresh_token) - } - .map { it.access_token to it.refresh_token } - } - - fun getAccessToken(): String? = tokenManager.getAccessToken() - - suspend fun logout() { - tokenManager.getRefreshToken()?.let { refreshToken -> - apiClient.logout(refreshToken) - } - tokenManager.clearTokens() - } - - private fun getDeviceId(): String { - // Use Android ID or Installation ID - return Settings.Secure.getString( - context.contentResolver, - Settings.Secure.ANDROID_ID - ) - } -} -``` - -### 4. Usage in Activity/Fragment - -```kotlin -class LoginActivity : AppCompatActivity() { - private val authManager by lazy { - val apiClient = AuthApiClient("http://your-api-url") - val tokenManager = TokenManager(this) - AuthManager(apiClient, tokenManager) - } - - private fun requestOtp() { - lifecycleScope.launch { - val phoneNumber = phoneInput.text.toString() - authManager.requestOtp(phoneNumber) - .onSuccess { showToast("OTP sent!") } - .onFailure { showError(it.message) } - } - } - - private fun verifyOtp() { - lifecycleScope.launch { - val phoneNumber = phoneInput.text.toString() - val code = otpInput.text.toString() - - authManager.login(phoneNumber, code) - .onSuccess { user -> - if (user.needs_profile) { - startActivity(Intent(this, ProfileSetupActivity::class.java)) - } else { - startActivity(Intent(this, MainActivity::class.java)) - } - finish() - } - .onFailure { showError("Invalid OTP") } - } - } -} -``` - ---- - -## Error Handling - -### Common Error Codes - -| Status | Error | Description | -|--------|-------|-------------| -| 400 | `phone_number is required` | Missing phone number | -| 400 | `Invalid or expired OTP` | Wrong code or OTP expired (10 min) | -| 401 | `Invalid refresh token` | Token expired or revoked | -| 401 | `Missing Authorization header` | Access token not provided | -| 403 | `Origin not allowed` | CORS restriction (production) | -| 500 | `Internal server error` | Server issue | - -### Error Response Format - +**Error (400):** ```json { - "error": "Error message here" + "error": "current_device_id is required in header or body" } ``` --- -## Security Best Practices - -1. **Store tokens securely** - Use `EncryptedSharedPreferences` (Android) or Keychain (iOS) -2. **Handle token expiration** - Automatically refresh when access token expires (401) -3. **Rotate refresh tokens** - Always save the new `refresh_token` after refresh -4. **Validate device_id** - Use consistent device identifier (Android ID, Installation ID) -5. **Handle reuse detection** - If refresh returns 401, force re-login (token compromised) - ---- - -## Token Expiration - -- **Access Token:** 15 minutes (default, configurable via `JWT_ACCESS_TTL`) -- **Refresh Token:** 7 days (default, configurable via `JWT_REFRESH_TTL`) -- **OTP:** 10 minutes (fixed) - ---- - -## Example: Auto-refresh on 401 - -```kotlin -suspend fun callWithAuth(block: suspend (String) -> Result): Result { - val token = tokenManager.getAccessToken() ?: return Result.failure(Exception("Not logged in")) - - return block(token).recoverCatching { error -> - if (error is HttpException && error.response.status == HttpStatusCode.Unauthorized) { - // Token expired, refresh and retry - refreshTokens() - .getOrNull() - ?.let { (newAccess, _) -> block(newAccess) } - ?: Result.failure(Exception("Failed to refresh token")) - } else { - Result.failure(error) - } - } -} -``` - ---- - -## Health Check +### 10. Health Check **Endpoint:** `GET /health` @@ -558,9 +455,777 @@ Use this to verify the service is running before attempting authentication. --- +## Kotlin Multiplatform Implementation + +### 1. Data Models (Common Module) + +```kotlin +// commonMain/kotlin/models/Requests.kt +package com.farm.auth.models + +import kotlinx.serialization.Serializable + +@Serializable +data class RequestOtpRequest(val phone_number: String) + +@Serializable +data class RequestOtpResponse(val ok: Boolean) + +@Serializable +data class DeviceInfo( + val platform: String, + val model: String? = null, + @SerialName("os_version") val osVersion: String? = null, + @SerialName("app_version") val appVersion: String? = null, + @SerialName("language_code") val languageCode: String? = null, + val timezone: String? = null +) + +@Serializable +data class VerifyOtpRequest( + @SerialName("phone_number") val phoneNumber: String, + val code: String, + @SerialName("device_id") val deviceId: String, + @SerialName("device_info") val deviceInfo: DeviceInfo? = null +) + +@Serializable +data class Coordinates( + val latitude: Double, + val longitude: Double +) + +@Serializable +data class Location( + val id: String, + val country: String?, + val state: String?, + val district: String?, + @SerialName("city_village") val cityVillage: String?, + val pincode: String?, + val coordinates: Coordinates?, + @SerialName("location_type") val locationType: String?, + @SerialName("is_saved_address") val isSavedAddress: Boolean, + @SerialName("source_type") val sourceType: String?, + @SerialName("source_confidence") val sourceConfidence: String?, + @SerialName("created_at") val createdAt: String, + @SerialName("updated_at") val updatedAt: String +) + +@Serializable +data class User( + val id: String, + @SerialName("phone_number") val phoneNumber: String, + val name: String?, + val role: String, + @SerialName("user_type") val userType: String?, + @SerialName("avatar_url") val avatarUrl: String? = null, + val language: String? = null, + val timezone: String? = null, + @SerialName("created_at") val createdAt: String? = null, + @SerialName("last_login_at") val lastLoginAt: String? = null, + @SerialName("active_devices_count") val activeDevicesCount: Int? = null, + val location: Location? = null, + val locations: List = emptyList() +) + +@Serializable +data class VerifyOtpResponse( + val user: User, + @SerialName("access_token") val accessToken: String, + @SerialName("refresh_token") val refreshToken: String, + @SerialName("needs_profile") val needsProfile: Boolean, + @SerialName("is_new_device") val isNewDevice: Boolean, + @SerialName("is_new_account") val isNewAccount: Boolean, + @SerialName("active_devices_count") val activeDevicesCount: Int +) + +@Serializable +data class RefreshRequest(@SerialName("refresh_token") val refreshToken: String) + +@Serializable +data class RefreshResponse( + @SerialName("access_token") val accessToken: String, + @SerialName("refresh_token") val refreshToken: String +) + +@Serializable +data class LogoutRequest(@SerialName("refresh_token") val refreshToken: String) + +@Serializable +data class LogoutResponse(val ok: Boolean) + +@Serializable +data class UpdateProfileRequest( + val name: String, + @SerialName("user_type") val userType: String +) + +@Serializable +data class Device( + @SerialName("device_identifier") val deviceIdentifier: String, + @SerialName("device_platform") val devicePlatform: String, + @SerialName("device_model") val deviceModel: String?, + @SerialName("os_version") val osVersion: String?, + @SerialName("app_version") val appVersion: String?, + @SerialName("language_code") val languageCode: String?, + val timezone: String?, + @SerialName("first_seen_at") val firstSeenAt: String, + @SerialName("last_seen_at") val lastSeenAt: String, + @SerialName("is_active") val isActive: Boolean +) + +@Serializable +data class DevicesResponse(val devices: List) + +@Serializable +data class LogoutAllOtherDevicesRequest( + @SerialName("current_device_id") val currentDeviceId: String +) + +@Serializable +data class LogoutAllOtherDevicesResponse( + val ok: Boolean, + val message: String, + @SerialName("revoked_devices_count") val revokedDevicesCount: Int +) + +@Serializable +data class ErrorResponse(val error: String) + +@Serializable +data class HealthResponse(val ok: Boolean) +``` + +### 2. API Client (Common Module) + +```kotlin +// commonMain/kotlin/network/AuthApiClient.kt +package com.farm.auth.network + +import com.farm.auth.models.* +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.request.* +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* +import kotlinx.serialization.json.Json + +class AuthApiClient( + private val baseUrl: String, + private val httpClient: HttpClient +) { + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + encodeDefaults = false + } + + init { + httpClient.config { + install(ContentNegotiation) { + json(json) + } + } + } + + suspend fun requestOtp(phoneNumber: String): Result { + return try { + val response = httpClient.post("$baseUrl/auth/request-otp") { + contentType(ContentType.Application.Json) + setBody(RequestOtpRequest(phoneNumber)) + } + Result.success(response.body()) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun verifyOtp( + phoneNumber: String, + code: String, + deviceId: String, + deviceInfo: DeviceInfo? = null + ): Result { + return try { + val request = VerifyOtpRequest(phoneNumber, code, deviceId, deviceInfo) + val response = httpClient.post("$baseUrl/auth/verify-otp") { + contentType(ContentType.Application.Json) + setBody(request) + } + Result.success(response.body()) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun refreshToken(refreshToken: String): Result { + return try { + val request = RefreshRequest(refreshToken) + val response = httpClient.post("$baseUrl/auth/refresh") { + contentType(ContentType.Application.Json) + setBody(request) + } + Result.success(response.body()) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun logout(refreshToken: String): Result { + return try { + val request = LogoutRequest(refreshToken) + val response = httpClient.post("$baseUrl/auth/logout") { + contentType(ContentType.Application.Json) + setBody(request) + } + Result.success(response.body()) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun getCurrentUser(accessToken: String): Result { + return try { + val response = httpClient.get("$baseUrl/users/me") { + contentType(ContentType.Application.Json) + header("Authorization", "Bearer $accessToken") + } + Result.success(response.body()) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun updateProfile( + name: String, + userType: String, + accessToken: String + ): Result { + return try { + val request = UpdateProfileRequest(name, userType) + val response = httpClient.put("$baseUrl/users/me") { + contentType(ContentType.Application.Json) + header("Authorization", "Bearer $accessToken") + setBody(request) + } + Result.success(response.body()) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun getDevices(accessToken: String): Result { + return try { + val response = httpClient.get("$baseUrl/users/me/devices") { + contentType(ContentType.Application.Json) + header("Authorization", "Bearer $accessToken") + } + Result.success(response.body()) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun logoutDevice( + deviceId: String, + accessToken: String + ): Result { + return try { + val response = httpClient.delete("$baseUrl/users/me/devices/$deviceId") { + contentType(ContentType.Application.Json) + header("Authorization", "Bearer $accessToken") + } + Result.success(response.body()) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun logoutAllOtherDevices( + currentDeviceId: String, + accessToken: String + ): Result { + return try { + val request = LogoutAllOtherDevicesRequest(currentDeviceId) + val response = httpClient.post("$baseUrl/users/me/logout-all-other-devices") { + contentType(ContentType.Application.Json) + header("Authorization", "Bearer $accessToken") + header("X-Device-Id", currentDeviceId) + setBody(request) + } + Result.success(response.body()) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun healthCheck(): Result { + return try { + val response = httpClient.get("$baseUrl/health") + Result.success(response.body()) + } catch (e: Exception) { + Result.failure(e) + } + } +} +``` + +### 3. Token Storage Interface (Common Module) + +```kotlin +// commonMain/kotlin/storage/TokenStorage.kt +package com.farm.auth.storage + +interface TokenStorage { + fun saveTokens(accessToken: String, refreshToken: String) + fun getAccessToken(): String? + fun getRefreshToken(): String? + fun clearTokens() +} +``` + +### 4. Android Token Storage Implementation + +```kotlin +// androidMain/kotlin/storage/AndroidTokenStorage.kt +package com.farm.auth.storage + +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey + +class AndroidTokenStorage(private val context: Context) : TokenStorage { + private val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + private val prefs: SharedPreferences = EncryptedSharedPreferences.create( + context, + "auth_tokens", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + + override fun saveTokens(accessToken: String, refreshToken: String) { + prefs.edit().apply { + putString("access_token", accessToken) + putString("refresh_token", refreshToken) + apply() + } + } + + override fun getAccessToken(): String? = prefs.getString("access_token", null) + + override fun getRefreshToken(): String? = prefs.getString("refresh_token", null) + + override fun clearTokens() { + prefs.edit().clear().apply() + } +} +``` + +### 5. iOS Token Storage Implementation (using Keychain) + +```kotlin +// iosMain/kotlin/storage/IosTokenStorage.kt +package com.farm.auth.storage + +import platform.Security.* +import platform.Foundation.* + +class IosTokenStorage : TokenStorage { + private val accessTokenKey = "access_token" + private val refreshTokenKey = "refresh_token" + private val service = "com.farm.auth" + + override fun saveTokens(accessToken: String, refreshToken: String) { + saveToKeychain(accessTokenKey, accessToken) + saveToKeychain(refreshTokenKey, refreshToken) + } + + override fun getAccessToken(): String? = getFromKeychain(accessTokenKey) + + override fun getRefreshToken(): String? = getFromKeychain(refreshTokenKey) + + override fun clearTokens() { + deleteFromKeychain(accessTokenKey) + deleteFromKeychain(refreshTokenKey) + } + + private fun saveToKeychain(key: String, value: String): Boolean { + val data = (value as NSString).dataUsingEncoding(NSUTF8StringEncoding) ?: return false + val query = mapOf( + kSecClass to kSecClassGenericPassword, + kSecAttrService to service, + kSecAttrAccount to key, + kSecValueData to data + ) + SecItemDelete(query.toCFDictionary()) + return SecItemAdd(query.toCFDictionary(), null) == errSecSuccess + } + + private fun getFromKeychain(key: String): String? { + val query = mapOf( + kSecClass to kSecClassGenericPassword, + kSecAttrService to service, + kSecAttrAccount to key, + kSecReturnData to kCFBooleanTrue + ) + val result = alloc() + if (SecItemCopyMatching(query.toCFDictionary(), result.ptr) == errSecSuccess) { + val data = result.value as? NSData ?: return null + return NSString.create(data, NSUTF8StringEncoding) as String + } + return null + } + + private fun deleteFromKeychain(key: String) { + val query = mapOf( + kSecClass to kSecClassGenericPassword, + kSecAttrService to service, + kSecAttrAccount to key + ) + SecItemDelete(query.toCFDictionary()) + } +} +``` + +### 6. Device Info Provider Interface + +```kotlin +// commonMain/kotlin/device/DeviceInfoProvider.kt +package com.farm.auth.device + +import com.farm.auth.models.DeviceInfo + +interface DeviceInfoProvider { + fun getDeviceId(): String + fun getDeviceInfo(): DeviceInfo +} +``` + +### 7. Authentication Manager + +```kotlin +// commonMain/kotlin/auth/AuthManager.kt +package com.farm.auth.auth + +import com.farm.auth.models.* +import com.farm.auth.network.AuthApiClient +import com.farm.auth.storage.TokenStorage +import com.farm.auth.device.DeviceInfoProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class AuthManager( + private val apiClient: AuthApiClient, + private val tokenStorage: TokenStorage, + private val deviceInfoProvider: DeviceInfoProvider +) { + private val _currentUser = MutableStateFlow(null) + val currentUser: StateFlow = _currentUser.asStateFlow() + + private val _isAuthenticated = MutableStateFlow(false) + val isAuthenticated: StateFlow = _isAuthenticated.asStateFlow() + + suspend fun requestOtp(phoneNumber: String): Result { + return apiClient.requestOtp(phoneNumber) + } + + suspend fun verifyOtp(phoneNumber: String, code: String): Result { + val deviceId = deviceInfoProvider.getDeviceId() + val deviceInfo = deviceInfoProvider.getDeviceInfo() + + return apiClient.verifyOtp(phoneNumber, code, deviceId, deviceInfo) + .onSuccess { response -> + tokenStorage.saveTokens(response.accessToken, response.refreshToken) + _currentUser.value = response.user + _isAuthenticated.value = true + } + } + + suspend fun refreshTokens(): Result> { + val refreshToken = tokenStorage.getRefreshToken() + ?: return Result.failure(Exception("No refresh token")) + + return apiClient.refreshToken(refreshToken) + .onSuccess { response -> + tokenStorage.saveTokens(response.accessToken, response.refreshToken) + } + .onFailure { + // If refresh fails, clear tokens and logout + if (it.message?.contains("Invalid refresh token") == true) { + logout() + } + } + .map { it.accessToken to it.refreshToken } + } + + suspend fun logout() { + tokenStorage.getRefreshToken()?.let { refreshToken -> + apiClient.logout(refreshToken) + } + tokenStorage.clearTokens() + _currentUser.value = null + _isAuthenticated.value = false + } + + suspend fun getCurrentUser(): Result { + val accessToken = tokenStorage.getAccessToken() + ?: return Result.failure(Exception("Not authenticated")) + + return apiClient.getCurrentUser(accessToken) + .onSuccess { user -> + _currentUser.value = user + } + .recoverCatching { error -> + if (error.message?.contains("401") == true || + error.message?.contains("Unauthorized") == true) { + // Try to refresh token and retry + refreshTokens().getOrNull()?.let { (newAccessToken, _) -> + apiClient.getCurrentUser(newAccessToken) + .onSuccess { user -> + _currentUser.value = user + } + } ?: Result.failure(error) + } else { + Result.failure(error) + } + } + } + + suspend fun updateProfile(name: String, userType: String): Result { + val accessToken = tokenStorage.getAccessToken() + ?: return Result.failure(Exception("Not authenticated")) + + return apiClient.updateProfile(name, userType, accessToken) + .onSuccess { user -> + _currentUser.value = user + } + } + + suspend fun getDevices(): Result { + val accessToken = tokenStorage.getAccessToken() + ?: return Result.failure(Exception("Not authenticated")) + + return callWithAuth { token -> + apiClient.getDevices(token) + } + } + + suspend fun logoutDevice(deviceId: String): Result { + return callWithAuth { token -> + apiClient.logoutDevice(deviceId, token) + } + } + + suspend fun logoutAllOtherDevices(): Result { + val currentDeviceId = deviceInfoProvider.getDeviceId() + return callWithAuth { token -> + apiClient.logoutAllOtherDevices(currentDeviceId, token) + } + } + + fun getAccessToken(): String? = tokenStorage.getAccessToken() + + private suspend fun callWithAuth( + block: suspend (String) -> Result + ): Result { + val token = tokenStorage.getAccessToken() + ?: return Result.failure(Exception("Not authenticated")) + + return block(token).recoverCatching { error -> + if (error.message?.contains("401") == true || + error.message?.contains("Unauthorized") == true) { + // Token expired, refresh and retry + refreshTokens().getOrNull()?.let { (newAccessToken, _) -> + block(newAccessToken) + } ?: Result.failure(Exception("Failed to refresh token")) + } else { + Result.failure(error) + } + } + } +} +``` + +--- + +## Error Handling + +### Common Error Codes + +| Status | Error | Description | +|--------|-------|-------------| +| 400 | `phone_number is required` | Missing phone number in request | +| 400 | `Invalid or expired OTP` | Wrong code, OTP expired (10 min), or max attempts exceeded (5) | +| 400 | `name and user_type are required` | Missing required fields in profile update | +| 400 | `current_device_id is required` | Missing device ID for logout all devices | +| 401 | `Invalid refresh token` | Token expired, revoked, or compromised | +| 401 | `Missing Authorization header` | Access token not provided | +| 401 | `Invalid or expired token` | Access token invalid or expired | +| 404 | `User not found` | User account doesn't exist | +| 403 | `Origin not allowed` | CORS restriction (production only) | +| 500 | `Internal server error` | Server-side error | +| 500 | `Failed to send OTP` | Twilio SMS sending failed | + +### Error Response Format + +```json +{ + "error": "Error message here" +} +``` + +--- + +## Security Best Practices + +1. **Store tokens securely** + - **Android**: Use `EncryptedSharedPreferences` (Android Security Library) + - **iOS**: Use Keychain Services + - Never store tokens in plain SharedPreferences/UserDefaults + +2. **Handle token expiration** + - Automatically refresh when access token expires (401 response) + - Implement retry logic with token refresh + +3. **Rotate refresh tokens** + - Always save the new `refresh_token` after refresh + - Old refresh tokens are automatically revoked + +4. **Validate device_id** + - Use consistent device identifier (Android ID, Installation ID, or Vendor ID) + - Device ID should be 4-128 alphanumeric characters + - Server will hash invalid device IDs + +5. **Handle reuse detection** + - If refresh returns 401 with "Invalid refresh token", force re-login + - This indicates potential token compromise + +6. **Secure network communication** + - Always use HTTPS in production + - Implement certificate pinning if needed + +7. **Rate limiting awareness** + - Don't spam OTP requests + - Implement client-side rate limiting if possible + +--- + +## Token Management + +### Token Expiration + +- **Access Token:** 15 minutes (default, configurable via `JWT_ACCESS_TTL` env var) +- **Refresh Token:** 7 days (default, configurable via `JWT_REFRESH_TTL` env var) +- **Refresh Token Idle Timeout:** 3 days (default, configurable via `REFRESH_MAX_IDLE_MINUTES` = 4320) +- **OTP:** 10 minutes (fixed) + +### Auto-refresh Pattern + +```kotlin +suspend fun callWithAuth( + block: suspend (String) -> Result +): Result { + val token = tokenStorage.getAccessToken() + ?: return Result.failure(Exception("Not authenticated")) + + return block(token).recoverCatching { error -> + if (error.message?.contains("401") == true || + error.message?.contains("Unauthorized") == true) { + // Token expired, refresh and retry + refreshTokens().getOrNull()?.let { (newAccessToken, _) -> + block(newAccessToken) + } ?: Result.failure(Exception("Failed to refresh token")) + } else { + Result.failure(error) + } + } +} +``` + +--- + ## Notes -- Phone numbers must be in E.164 format (`+` prefix with country code) -- Device ID should be 4-128 alphanumeric characters, or it will be hashed +- Phone numbers are auto-normalized: 10-digit numbers → `+91` prefix +- Device ID should be 4-128 alphanumeric characters (server sanitizes if invalid) - Refresh tokens rotate on each use - always update stored token - If `needs_profile: true`, prompt user to complete profile before accessing app +- `is_new_device` and `is_new_account` flags help with onboarding flows +- Device management endpoints require authentication +- All timestamps are in ISO 8601 format with timezone (UTC) + +--- + +## Example Usage (Android Activity) + +```kotlin +class LoginActivity : AppCompatActivity() { + private lateinit var authManager: AuthManager + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val httpClient = HttpClient(Android) { + install(ContentNegotiation) { + json() + } + } + + val apiClient = AuthApiClient("http://your-api-url", httpClient) + val tokenStorage = AndroidTokenStorage(this) + val deviceInfoProvider = AndroidDeviceInfoProvider(this) + + authManager = AuthManager(apiClient, tokenStorage, deviceInfoProvider) + + // Observe authentication state + lifecycleScope.launch { + authManager.isAuthenticated.collect { isAuth -> + if (isAuth) { + // Navigate to main screen + } + } + } + } + + private fun requestOtp() { + lifecycleScope.launch { + val phoneNumber = phoneInput.text.toString() + authManager.requestOtp(phoneNumber) + .onSuccess { + showToast("OTP sent!") + } + .onFailure { + showError(it.message ?: "Failed to send OTP") + } + } + } + + private fun verifyOtp() { + lifecycleScope.launch { + val phoneNumber = phoneInput.text.toString() + val code = otpInput.text.toString() + + authManager.verifyOtp(phoneNumber, code) + .onSuccess { response -> + if (response.needsProfile) { + startActivity(Intent(this@LoginActivity, ProfileSetupActivity::class.java)) + } else { + startActivity(Intent(this@LoginActivity, MainActivity::class.java)) + } + finish() + } + .onFailure { + showError("Invalid OTP") + } + } + } +} +``` diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..e64db1b --- /dev/null +++ b/SETUP.md @@ -0,0 +1,92 @@ +# Environment Variables Setup + +## Required Variables (MUST provide) + +These are **mandatory** - the service will not start without them: + +```env +DATABASE_URL=postgres://username:password@localhost:5432/database_name +JWT_ACCESS_SECRET=your-secret-here-minimum-32-characters +JWT_REFRESH_SECRET=your-secret-here-minimum-32-characters +``` + +### How to generate JWT secrets: + +```bash +node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +``` + +Run this twice to get two different secrets. + +--- + +## Optional Variables (Can skip) + +### Twilio SMS Configuration + +**You DO NOT need to provide Twilio credentials** - the service will work without them! + +If Twilio is **NOT configured**: +- ✅ Service starts normally +- ✅ OTP codes are logged to console for testing +- ⚠️ SMS will not be sent (OTP shown in server logs) + +If Twilio **IS configured**: +- ✅ OTP codes sent via SMS automatically + +```env +# Twilio (Optional - only if you want SMS delivery) +TWILIO_ACCOUNT_SID=your-twilio-account-sid +TWILIO_AUTH_TOKEN=your-twilio-auth-token +TWILIO_MESSAGING_SERVICE_SID=your-messaging-service-sid +# OR +TWILIO_FROM_NUMBER=+1234567890 +``` + +### Other Optional Variables + +```env +PORT=3000 # Server port (default: 3000) +NODE_ENV=development # Environment (development/production) +CORS_ALLOWED_ORIGINS= # Comma-separated origins (required in production) +JWT_ACCESS_TTL=15m # Access token expiry (default: 15m) +JWT_REFRESH_TTL=7d # Refresh token expiry (default: 7d) +REFRESH_MAX_IDLE_MINUTES=4320 # Refresh token inactivity timeout (default: 3 days) +OTP_MAX_ATTEMPTS=5 # Max OTP verification attempts (default: 5) +``` + +--- + +## Quick Setup + +1. **Copy the example file:** + ```bash + cp .env.example .env + ``` + +2. **Fill in REQUIRED variables only:** + ```env + DATABASE_URL=postgres://postgres:password123@localhost:5433/farmmarket + JWT_ACCESS_SECRET= + JWT_REFRESH_SECRET= + ``` + +3. **Skip Twilio** (optional - for development, OTP will show in console) + +4. **Start the service:** + ```bash + npm run dev + ``` + +--- + +## Testing Without Twilio + +When Twilio is not configured: +- Request OTP: `POST /auth/request-otp` +- Check server console - OTP code will be logged: `📱 DEBUG OTP: +919876543210 Code: 123456` +- Use that code to verify: `POST /auth/verify-otp` + +This is perfect for local development! + + diff --git a/TWILIO_SETUP.md b/TWILIO_SETUP.md new file mode 100644 index 0000000..92adf96 --- /dev/null +++ b/TWILIO_SETUP.md @@ -0,0 +1,201 @@ +# Twilio SMS Setup + +## Required Twilio Variables + +Add these to your `.env` file: + +```env +# Twilio SMS Configuration +TWILIO_ACCOUNT_SID=your-account-sid-here +TWILIO_AUTH_TOKEN=your-auth-token-here + +# Use EITHER Messaging Service (recommended) OR From Number +TWILIO_MESSAGING_SERVICE_SID=your-messaging-service-sid +# OR +TWILIO_FROM_NUMBER=+1234567890 +``` + +## Twilio Setup Options + +### Option 1: Using Messaging Service (Recommended) +```env +TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +TWILIO_AUTH_TOKEN=your_auth_token_here +TWILIO_MESSAGING_SERVICE_SID=MGxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +### Option 2: Using Phone Number +```env +TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +TWILIO_AUTH_TOKEN=your_auth_token_here +TWILIO_FROM_NUMBER=+1234567890 +``` + +## How to Get Twilio Credentials + +1. Sign up at https://www.twilio.com/ +2. Get your Account SID and Auth Token from the Twilio Console Dashboard +3. For Messaging Service: + - Go to Messaging > Services in Twilio Console + - Create or select a Messaging Service + - Copy the Service SID (starts with MG) +4. For Phone Number: + - Get a Twilio phone number from Phone Numbers section + - Format: +1234567890 (E.164 format) + +## Important Notes + +- Use **Messaging Service** (Option 1) if you have one - it's recommended +- Use **Phone Number** (Option 2) if you don't have a Messaging Service +- You only need **ONE** of: `TWILIO_MESSAGING_SERVICE_SID` or `TWILIO_FROM_NUMBER` +- The phone number must be in E.164 format: `+[country code][number]` +- Example: `+919876543210` (India) + +## Testing + +After adding Twilio credentials: +1. Restart your server: `npm run dev` +2. Request OTP: `POST /auth/request-otp` +3. Check server logs - should see: `✅ Twilio SMS sent, SID: SMxxxxx` +4. User receives SMS with OTP code + +--- + +## Troubleshooting Common Errors + +### Error: "Short Code" Error + +**Error Message:** +``` +❌ Failed to send SMS via Twilio: 'To' number cannot be a Short Code: +9174114XXXX +``` + +**Cause:** +- You're trying to send SMS to a short code (5-6 digit number) +- Twilio doesn't allow sending to short codes +- Often happens with test numbers or invalid phone numbers + +**Solution:** +- Use valid full-length phone numbers in E.164 format (e.g., `+919876543210`) +- Short codes are typically 5-6 digits total +- The service now validates phone numbers and rejects short codes + +**Fallback:** +- OTP is automatically logged to console: `📱 DEBUG OTP (fallback): +91741147986 Code: 153502` +- Check server logs for the OTP code during development/testing + +--- + +### Error: "Unverified Number" Error (Trial Account) + +**Error Message:** +``` +❌ Failed to send SMS via Twilio: The number +91741114XXXX is unverified. Trial accounts cannot send messages to unverified numbers +``` + +**Cause:** +- You're using a **Twilio Trial Account** +- Trial accounts can only send SMS to verified phone numbers +- This is a security feature to prevent abuse + +**Solutions:** + +**Option 1: Verify the Phone Number (Free)** +1. Go to [Twilio Console - Verified Numbers](https://console.twilio.com/us1/develop/phone-numbers/manage/verified) +2. Click "Add a new number" +3. Enter the phone number and verify via SMS/call +4. You can verify up to 10 numbers on a trial account + +**Option 2: Upgrade to Paid Account (Recommended for Production)** +1. Add payment method in Twilio Console +2. Upgrade your account (minimum $20 credit required) +3. Once upgraded, you can send SMS to any valid phone number +4. Pay only for messages sent (per SMS pricing) + +**Option 3: Use Console Logs (Development Only)** +- For testing/development, check server console logs +- OTP codes are logged: `📱 DEBUG OTP (fallback): +919876543210 Code: 123456` +- This allows testing without SMS delivery + +--- + +### Error: "Failed to send OTP" + +**Error Message:** +``` +❌ Failed to send SMS via Twilio: [any error] +``` + +**General Troubleshooting:** + +1. **Check Twilio Credentials:** + - Verify `TWILIO_ACCOUNT_SID` and `TWILIO_AUTH_TOKEN` in `.env` + - Credentials should start with `AC` and be valid + +2. **Check From Number/Service:** + - Ensure `TWILIO_FROM_NUMBER` or `TWILIO_MESSAGING_SERVICE_SID` is set + - Phone number must be in E.164 format: `+1234567890` + +3. **Check Account Status:** + - Login to [Twilio Console](https://console.twilio.com/) + - Verify account is active (not suspended) + - Check if you have credits/balance + +4. **Check Phone Number Format:** + - Must be E.164 format: `+[country code][number]` + - Example: `+919876543210` (India), `+1234567890` (US) + - No spaces or special characters + +5. **Development Fallback:** + - If SMS fails, OTP is always logged to console + - Check server logs: `📱 DEBUG OTP (fallback): [phone] Code: [otp]` + +--- + +## Understanding the Logs + +**✅ Success:** +``` +✅ Twilio SMS sent, SID: SMa7e2f23b3bdb05be1a275f43b71b2209 +``` +- SMS was successfully sent via Twilio +- User should receive SMS with OTP + +**❌ Failure (with fallback):** +``` +❌ Failed to send SMS via Twilio: [error message] +📱 DEBUG OTP (fallback): +919876543210 Code: 123456 +``` +- SMS sending failed, but OTP was generated +- OTP code is logged to console for development/testing +- User can still verify using the OTP code from logs + +**⚠️ Warning (Twilio not configured):** +``` +⚠️ Twilio credentials are not set. SMS sending will be disabled. OTP will be logged to console. +📱 DEBUG OTP (Twilio not configured): +919876543210 Code: 123456 +``` +- Twilio credentials missing in `.env` +- Service still works, but OTPs only appear in logs +- Add Twilio credentials to enable SMS delivery + +--- + +## Best Practices + +1. **Development:** + - Use console logs for testing + - Verify test numbers in Twilio Console + +2. **Production:** + - Upgrade to paid Twilio account + - Use Messaging Service (recommended) + - Monitor SMS delivery rates + - Implement proper error handling in frontend + +3. **Security:** + - Never log OTPs in production + - Use environment variables for credentials + - Rotate Twilio credentials periodically + + diff --git a/src/routes/authRoutes.js b/src/routes/authRoutes.js index e82bb2b..7b1190e 100644 --- a/src/routes/authRoutes.js +++ b/src/routes/authRoutes.js @@ -16,13 +16,32 @@ const router = express.Router(); // helper: normalize phone function normalizePhone(phone) { - const p = phone.trim(); + const p = phone.trim().replace(/\s+/g, ''); // Remove spaces if (p.startsWith('+')) return p; // if user enters 10-digit, prepend +91 if (p.length === 10) return '+91' + p; return p; // fallback } +// helper: validate phone number format +function isValidPhoneNumber(phone) { + // E.164 format: + followed by 1-15 digits + // Reject short codes (5-6 digits) + const e164Pattern = /^\+[1-9]\d{1,14}$/; + + if (!e164Pattern.test(phone)) { + return false; + } + + // Reject short codes (typically 5-6 digits total) + const digitsOnly = phone.replace(/\D/g, ''); + if (digitsOnly.length <= 6) { + return false; // Likely a short code + } + + return true; +} + function sanitizeDeviceId(deviceId) { if (!deviceId || typeof deviceId !== 'string') { @@ -50,16 +69,28 @@ router.post('/request-otp', async (req, res) => { const normalizedPhone = normalizePhone(phone_number); + // Validate phone number format + if (!isValidPhoneNumber(normalizedPhone)) { + return res.status(400).json({ + error: 'Invalid phone number format. Please use E.164 format (e.g., +919876543210)' + }); + } + // TODO: rate limiting per phone / IP const { code } = await createOtp(normalizedPhone); - try { - await sendOtpSms(normalizedPhone, code); - } catch (err) { - console.error('Failed to send OTP SMS via Twilio', err); - // you can choose whether to fail here or still return ok - return res.status(500).json({ error: 'Failed to send OTP' }); + // Attempt to send SMS (will fallback to console log if Twilio fails) + const smsResult = await sendOtpSms(normalizedPhone, code); + + // Even if SMS fails, we still return success because OTP is generated + // The OTP code is logged to console for testing/development + // In production, you may want to return an error if SMS fails + if (!smsResult || !smsResult.success) { + console.warn('⚠️ SMS sending failed, but OTP was generated and logged to console'); + // Option 1: Still return success (current behavior - allows testing) + // Option 2: Return error (uncomment below for production) + // return res.status(500).json({ error: 'Failed to send OTP via SMS' }); } return res.json({ ok: true }); diff --git a/src/routes/userRoutes.js b/src/routes/userRoutes.js index ca16af1..bd3c2ea 100644 --- a/src/routes/userRoutes.js +++ b/src/routes/userRoutes.js @@ -8,8 +8,9 @@ const router = express.Router(); // GET /users/me router.get('/me', auth, async (req, res) => { try { + // Get user basic information const { rows } = await db.query( - `SELECT id, phone_number, name, role, user_type, created_at, last_login_at + `SELECT id, phone_number, name, role, user_type, created_at, last_login_at, avatar_url, language, timezone FROM users WHERE id = $1`, [req.user.id] @@ -28,9 +29,65 @@ router.get('/me', auth, async (req, res) => { ); const activeDevicesCount = parseInt(deviceCountResult.rows[0].count, 10); + // Get user's saved locations (addresses) + const locationsResult = await db.query( + `SELECT + id, + country, + state, + district, + city_village, + pincode, + lat, + lng, + location_type, + is_saved_address, + source_type, + source_confidence, + created_at, + updated_at + FROM locations + WHERE user_id = $1 AND is_saved_address = true + ORDER BY updated_at DESC`, + [req.user.id] + ); + + const locations = locationsResult.rows.map(loc => ({ + id: loc.id, + country: loc.country, + state: loc.state, + district: loc.district, + city_village: loc.city_village, + pincode: loc.pincode, + coordinates: loc.lat && loc.lng ? { + latitude: parseFloat(loc.lat), + longitude: parseFloat(loc.lng) + } : null, + location_type: loc.location_type, + is_saved_address: loc.is_saved_address, + source_type: loc.source_type, + source_confidence: loc.source_confidence, + created_at: loc.created_at, + updated_at: loc.updated_at + })); + + // Get primary location (most recent saved address, or first if none) + const primaryLocation = locations.length > 0 ? locations[0] : null; + return res.json({ - ...user, + id: user.id, + phone_number: user.phone_number, + name: user.name, + role: user.role, + user_type: user.user_type, + avatar_url: user.avatar_url, + language: user.language, + timezone: user.timezone, + created_at: user.created_at, + last_login_at: user.last_login_at, active_devices_count: activeDevicesCount, + location: primaryLocation, // Primary/saved location for convenience + locations: locations // All saved locations }); } catch (err) { console.error('get me error', err); diff --git a/src/services/smsService.js b/src/services/smsService.js index dd5812a..74bed7c 100644 --- a/src/services/smsService.js +++ b/src/services/smsService.js @@ -8,11 +8,13 @@ const { TWILIO_MESSAGING_SERVICE_SID, } = process.env; -if (!TWILIO_ACCOUNT_SID || !TWILIO_AUTH_TOKEN) { - console.warn('⚠️ Twilio credentials are not set. SMS sending will fail.'); -} +let client = null; -const client = twilio(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN); +if (TWILIO_ACCOUNT_SID && TWILIO_AUTH_TOKEN) { + client = twilio(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN); +} else { + console.warn('⚠️ Twilio credentials are not set. SMS sending will be disabled. OTP will be logged to console.'); +} /** * Send OTP SMS using Twilio @@ -20,12 +22,12 @@ const client = twilio(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN); * @param {string} code OTP code */ async function sendOtpSms(toPhone, code) { - if (!TWILIO_ACCOUNT_SID || !TWILIO_AUTH_TOKEN) { - console.log('DEBUG OTP (no Twilio configured):', toPhone, code); + if (!client) { + console.log('📱 DEBUG OTP (Twilio not configured):', toPhone, 'Code:', code); return; } - const messageBody = `Your verification code is ${code}. It will expire in 5 minutes.`; + const messageBody = `Your verification code is ${code}. It will expire in 10 minutes.`; const msgConfig = { body: messageBody, @@ -38,11 +40,34 @@ async function sendOtpSms(toPhone, code) { } else if (TWILIO_FROM_NUMBER) { msgConfig.from = TWILIO_FROM_NUMBER; } else { - throw new Error('Neither TWILIO_MESSAGING_SERVICE_SID nor TWILIO_FROM_NUMBER is set'); + console.warn('⚠️ Neither TWILIO_MESSAGING_SERVICE_SID nor TWILIO_FROM_NUMBER is set. OTP logged to console.'); + console.log('📱 DEBUG OTP:', toPhone, 'Code:', code); + return; } - const msg = await client.messages.create(msgConfig); - console.log('Twilio SMS sent, SID:', msg.sid); + try { + const msg = await client.messages.create(msgConfig); + console.log('✅ Twilio SMS sent, SID:', msg.sid); + return { success: true, sid: msg.sid }; + } catch (err) { + const errorMessage = err.message || 'Unknown error'; + console.error('❌ Failed to send SMS via Twilio:', errorMessage); + + // Check for specific Twilio error types + const isShortCodeError = errorMessage.includes('Short Code'); + const isUnverifiedNumberError = errorMessage.includes('unverified'); + const isTrialAccountError = errorMessage.includes('Trial account'); + + if (isShortCodeError) { + console.warn('⚠️ Cannot send to short codes (5-6 digit numbers). OTP logged to console for testing.'); + } else if (isUnverifiedNumberError || isTrialAccountError) { + console.warn('⚠️ Trial account limitation: Verify the number at https://console.twilio.com/us1/develop/phone-numbers/manage/verified'); + console.warn('⚠️ Or upgrade to a paid account to send to any number.'); + } + + console.log('📱 DEBUG OTP (fallback):', toPhone, 'Code:', code); + return { success: false, error: errorMessage }; + } } module.exports = {