Files
worker/docs/AUTH_FLOW.md
Phuoc Nguyen 36bdf6613b add auth
2025-11-10 14:21:27 +07:00

13 KiB

**# Authentication Flow - Frappe/ERPNext Integration

Overview

The authentication system integrates with Frappe/ERPNext API using a session-based approach with SID (Session ID) and CSRF tokens stored in FlutterSecureStorage.

Complete Flow

1. App Startup (Check Saved Session)

When: User opens the app

Process:

  1. Auth provider's build() method is called
  2. Checks if user session exists in FlutterSecureStorage
  3. If logged-in session exists (userId != public_api@dbiz.com), returns User entity
  4. Otherwise returns null (user not logged in)
  5. Note: Public session is NOT fetched on startup to avoid provider disposal issues

Important: The public session will be fetched lazily when needed:

  • Before login (on login page load)
  • Before registration (when loading cities/customer groups)
  • Before any API call that requires session (via ensureSession())

API Endpoint: POST /api/method/dbiz_common.dbiz_common.api.auth.get_session

Request:

curl -X POST 'https://land.dbiz.com/api/method/dbiz_common.dbiz_common.api.auth.get_session' \
  -H 'Content-Type: application/json' \
  -d ''

Response:

{
  "session_expired": 1,
  "message": {
    "data": {
      "sid": "8c39b583...",
      "csrf_token": "f8a7754a9ce5..."
    }
  },
  "home_page": "/app",
  "full_name": "Guest"
}

Storage (FlutterSecureStorage):

  • frappe_sid: "8c39b583..."
  • frappe_csrf_token: "f8a7754a9ce5..."
  • frappe_full_name: "Guest"
  • frappe_user_id: "public_api@dbiz.com"

2. Initialize Public Session (When Needed)

When: Before login or registration, or before any API call

Process:

  1. Call ref.read(initializeFrappeSessionProvider.future) on the page
  2. Checks if session exists in FlutterSecureStorage
  3. If no session, calls FrappeAuthService.getSession()
  4. Stores public session (sid, csrf_token) in FlutterSecureStorage

Usage Example:

// In login page or registration page initState/useEffect
@override
void initState() {
  super.initState();
  // Initialize session when page loads
  WidgetsBinding.instance.addPostFrameCallback((_) {
    ref.read(initializeFrappeSessionProvider.future);
  });
}

Or use FutureBuilder:

FutureBuilder(
  future: ref.read(initializeFrappeSessionProvider.future),
  builder: (context, snapshot) {
    if (snapshot.connectionState == ConnectionState.waiting) {
      return LoadingIndicator();
    }
    return LoginForm(); // or RegistrationForm
  },
)

3. Loading Cities & Customer Groups (Using Public Session)

When: User navigates to registration screen

Process:

  1. Session initialized (if not already) via initializeFrappeSessionProvider
  2. AuthRemoteDataSource.getCities() is called
  3. Gets stored session from FlutterSecureStorage
  4. Calls API with session headers
  5. Returns list of cities for address selection

API Endpoint: POST /api/method/frappe.client.get_list

Request:

curl -X POST 'https://land.dbiz.com/api/method/frappe.client.get_list' \
  -H 'Cookie: sid=8c39b583...; full_name=Guest; system_user=no; user_id=public_api%40dbiz.com; user_image=' \
  -H 'X-Frappe-CSRF-Token: f8a7754a9ce5...' \
  -H 'Content-Type: application/json' \
  -d '{
    "doctype": "City",
    "fields": ["city_name", "name", "code"],
    "limit_page_length": 0
  }'

Response:

{
  "message": [
    {"city_name": "Hồ Chí Minh", "name": "HCM", "code": "HCM"},
    {"city_name": "Hà Nội", "name": "HN", "code": "HN"}
  ]
}

Similarly for Customer Groups:

{
  "doctype": "Customer Group",
  "fields": ["customer_group_name", "name", "value"],
  "filters": {
    "is_group": 0,
    "is_active": 1,
    "customer": 1
  }
}

4. User Login (Get Authenticated Session)

When: User enters phone number and password, clicks login

Process:

  1. Auth.login() is called with phone number
  2. Gets current session from FlutterSecureStorage
  3. Calls AuthRemoteDataSource.login() with phone + current session
  4. API returns new authenticated session
  5. FrappeAuthService.login() stores new session in FlutterSecureStorage
  6. Dio interceptor automatically uses new session for all subsequent requests
  7. Returns User entity with user data

API Endpoint: POST /api/method/building_material.building_material.api.auth.login

Request:

curl -X POST 'https://land.dbiz.com/api/method/building_material.building_material.api.auth.login' \
  -H 'Cookie: sid=8c39b583...' \
  -H 'X-Frappe-CSRF-Token: f8a7754a9ce5...' \
  -H 'Content-Type: application/json' \
  -d '{
    "username": "0123456789",
    "googleid": null,
    "facebookid": null,
    "zaloid": null
  }'

Response:

{
  "session_expired": 1,
  "message": {
    "data": {
      "sid": "new_authenticated_sid_123...",
      "csrf_token": "new_csrf_token_456..."
    }
  },
  "home_page": "/app",
  "full_name": "Nguyễn Văn A"
}

Storage Update (FlutterSecureStorage):

  • frappe_sid: "new_authenticated_sid_123..."
  • frappe_csrf_token: "new_csrf_token_456..."
  • frappe_full_name: "Nguyễn Văn A"
  • frappe_user_id: "0123456789"

5. Authenticated API Requests

When: User makes any API request after login

Process:

  1. AuthInterceptor.onRequest() is called
  2. Reads session from FlutterSecureStorage
  3. Builds cookie header with all required fields
  4. Adds headers to request

Cookie Header Format:

Cookie: sid=new_authenticated_sid_123...; full_name=Nguyễn Văn A; system_user=no; user_id=0123456789; user_image=
X-Frappe-CSRF-Token: new_csrf_token_456...

Example: Getting products

curl -X POST 'https://land.dbiz.com/api/method/frappe.client.get_list' \
  -H 'Cookie: sid=new_authenticated_sid_123...; full_name=Nguyễn%20Văn%20A; system_user=no; user_id=0123456789; user_image=' \
  -H 'X-Frappe-CSRF-Token: new_csrf_token_456...' \
  -H 'Content-Type: application/json' \
  -d '{
    "doctype": "Item",
    "fields": ["item_name", "item_code", "standard_rate"],
    "limit_page_length": 20
  }'

6. User Logout

When: User clicks logout button

Process:

  1. Auth.logout() is called
  2. Clears session from both:
    • AuthLocalDataSource (legacy Hive)
    • FrappeAuthService (FlutterSecureStorage)
  3. Gets new public session for next login/registration
  4. Returns null (user logged out)

Storage Cleared:

  • frappe_sid
  • frappe_csrf_token
  • frappe_full_name
  • frappe_user_id

New Public Session: Immediately calls getSession() again to get fresh public session


File Structure

Core Services

  • lib/core/services/frappe_auth_service.dart - Centralized session management
  • lib/core/models/frappe_session_model.dart - Session response model
  • lib/core/network/api_interceptor.dart - Dio interceptor for adding session headers

Auth Feature

  • lib/features/auth/data/datasources/auth_remote_datasource.dart - API calls (login, getCities, getCustomerGroups, register)
  • lib/features/auth/data/datasources/auth_local_datasource.dart - Legacy Hive storage
  • lib/features/auth/presentation/providers/auth_provider.dart - State management

Key Components

FrappeAuthService:

class FrappeAuthService {
  Future<FrappeSessionResponse> getSession(); // Get public session
  Future<FrappeSessionResponse> login(String phone, {String? password}); // Login
  Future<Map<String, String>?> getStoredSession(); // Read from storage
  Future<Map<String, String>> ensureSession(); // Ensure session exists
  Future<Map<String, String>> getHeaders(); // Get headers for API calls
  Future<void> clearSession(); // Clear on logout
}

AuthRemoteDataSource:

class AuthRemoteDataSource {
  Future<GetSessionResponse> getSession(); // Wrapper for Frappe getSession
  Future<GetSessionResponse> login({phone, csrfToken, sid, password}); // Login API
  Future<List<City>> getCities({csrfToken, sid}); // Get cities for registration
  Future<List<CustomerGroup>> getCustomerGroups({csrfToken, sid}); // Get customer groups
  Future<Map<String, dynamic>> register({...}); // Register new user
}

Auth Provider:

@riverpod
class Auth extends _$Auth {
  @override
  Future<User?> build(); // Initialize session on app startup

  Future<void> login({phoneNumber, password}); // Login flow
  Future<void> logout(); // Logout and get new public session
}

AuthInterceptor:

class AuthInterceptor extends Interceptor {
  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    // Read from FlutterSecureStorage
    // Build cookie header
    // Add to request headers
  }
}

Session Storage

All session data is stored in FlutterSecureStorage (encrypted):

Key Description Example
frappe_sid Session ID "8c39b583..."
frappe_csrf_token CSRF Token "f8a7754a9ce5..."
frappe_full_name User's full name "Nguyễn Văn A"
frappe_user_id User ID (phone or email) "0123456789" or "public_api@dbiz.com"

Public vs Authenticated Session

Public Session

  • User ID: public_api@dbiz.com
  • Full Name: "Guest"
  • Used for: Registration, loading cities/customer groups
  • Obtained: On app startup, after logout

Authenticated Session

  • User ID: User's phone number (e.g., "0123456789")
  • Full Name: User's actual name (e.g., "Nguyễn Văn A")
  • Used for: All user-specific operations (orders, cart, profile)
  • Obtained: After successful login

Error Handling

All API calls use proper exception handling:

  • 401 Unauthorized: UnauthorizedException - Session expired or invalid
  • 404 Not Found: NotFoundException - Endpoint not found
  • Network errors: NetworkException - Connection failed
  • Validation errors: ValidationException - Invalid data

Future Enhancements

  1. Password Support: Currently reserved but not sent. When backend supports password:

    Future<GetSessionResponse> login({
      required String phone,
      required String csrfToken,
      required String sid,
      String? password, // Remove nullable, make required
    }) async {
      // Add 'password': password to request body
    }
    
  2. Token Refresh: Implement automatic token refresh on 401 errors

  3. Session Expiry: Add session expiry tracking and automatic re-authentication

  4. Biometric Login: Store phone number and use biometric for quick re-login


Testing the Flow

1. Test Public Session

final frappeService = ref.read(frappeAuthServiceProvider).value!;
final session = await frappeService.getSession();
print('SID: ${session.sid}');
print('CSRF: ${session.csrfToken}');

2. Test Login

final auth = ref.read(authProvider.notifier);
await auth.login(
  phoneNumber: '0123456789',
  password: 'not_used_yet',
);

3. Test Authenticated Request

final remoteDataSource = ref.read(authRemoteDataSourceProvider).value!;
final cities = await remoteDataSource.getCities(
  csrfToken: 'from_storage',
  sid: 'from_storage',
);

4. Test Logout

await ref.read(authProvider.notifier).logout();

Debugging

Enable cURL logging to see all requests:

In dio_client.dart:

dio.interceptors.add(CurlLoggerDioInterceptor());

Console Output:

╔══════════════════════════════════════════════════════════════
║ POST https://land.dbiz.com/api/method/building_material.building_material.api.auth.login
║ Headers: {Cookie: [HIDDEN], X-Frappe-CSRF-Token: [HIDDEN], ...}
║ Body: {username: 0123456789, googleid: null, ...}
╚══════════════════════════════════════════════════════════════

╔══════════════════════════════════════════════════════════════
║ Response: {session_expired: 1, message: {...}, full_name: Nguyễn Văn A}
╚══════════════════════════════════════════════════════════════

Summary

The authentication flow is now fully integrated with Frappe/ERPNext:

  1. App startup checks for saved user session
  2. Public session fetched lazily when needed (via initializeFrappeSessionProvider)
  3. Public session used for cities/customer groups
  4. Login updates session to authenticated
  5. All API requests use session from FlutterSecureStorage
  6. Dio interceptor automatically adds headers
  7. Logout clears session and gets new public session
  8. cURL logging for debugging
  9. No provider disposal errors

All session management is centralized in FrappeAuthService with automatic integration via AuthInterceptor.**