448 lines
13 KiB
Markdown
448 lines
13 KiB
Markdown
**# 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**:
|
|
```bash
|
|
curl -X POST 'https://land.dbiz.com/api/method/dbiz_common.dbiz_common.api.auth.get_session' \
|
|
-H 'Content-Type: application/json' \
|
|
-d ''
|
|
```
|
|
|
|
**Response**:
|
|
```json
|
|
{
|
|
"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**:
|
|
```dart
|
|
// 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`:
|
|
```dart
|
|
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**:
|
|
```bash
|
|
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**:
|
|
```json
|
|
{
|
|
"message": [
|
|
{"city_name": "Hồ Chí Minh", "name": "HCM", "code": "HCM"},
|
|
{"city_name": "Hà Nội", "name": "HN", "code": "HN"}
|
|
]
|
|
}
|
|
```
|
|
|
|
**Similarly for Customer Groups**:
|
|
```json
|
|
{
|
|
"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**:
|
|
```bash
|
|
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**:
|
|
```json
|
|
{
|
|
"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
|
|
```bash
|
|
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**:
|
|
```dart
|
|
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**:
|
|
```dart
|
|
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**:
|
|
```dart
|
|
@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**:
|
|
```dart
|
|
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:
|
|
```dart
|
|
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
|
|
```dart
|
|
final frappeService = ref.read(frappeAuthServiceProvider).value!;
|
|
final session = await frappeService.getSession();
|
|
print('SID: ${session.sid}');
|
|
print('CSRF: ${session.csrfToken}');
|
|
```
|
|
|
|
### 2. Test Login
|
|
```dart
|
|
final auth = ref.read(authProvider.notifier);
|
|
await auth.login(
|
|
phoneNumber: '0123456789',
|
|
password: 'not_used_yet',
|
|
);
|
|
```
|
|
|
|
### 3. Test Authenticated Request
|
|
```dart
|
|
final remoteDataSource = ref.read(authRemoteDataSourceProvider).value!;
|
|
final cities = await remoteDataSource.getCities(
|
|
csrfToken: 'from_storage',
|
|
sid: 'from_storage',
|
|
);
|
|
```
|
|
|
|
### 4. Test Logout
|
|
```dart
|
|
await ref.read(authProvider.notifier).logout();
|
|
```
|
|
|
|
---
|
|
|
|
## Debugging
|
|
|
|
Enable cURL logging to see all requests:
|
|
|
|
**In `dio_client.dart`**:
|
|
```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`.**
|