Employee-only App gegen bestehende Backend-API: Login inkl. TOTP-Schritt, Ein-/Ausstempeln, Pause, Status "heute". Refresh-Token läuft nur als HttpOnly-Cookie (Backend gibt ihn nie im Body zurück) → persistente Cookie-Jar statt Secure-Storage-Refresh-Token. android/-Projektordner via flutter create auf root@192.168.1.236 generiert, INTERNET-Permission nachgetragen. Ungetestet auf echtem Gerät. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dzdm3EWeNdYFUuXFRJryA1
103 lines
3.4 KiB
Dart
103 lines
3.4 KiB
Dart
import 'package:dio/dio.dart';
|
|
import 'package:cookie_jar/cookie_jar.dart';
|
|
import 'package:dio_cookie_manager/dio_cookie_manager.dart';
|
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
|
|
/// Zentraler HTTP-Client. Access-Token liegt in Secure Storage, Refresh-Token
|
|
/// kommt vom Backend AUSSCHLIESSLICH als HttpOnly-Cookie (siehe auth.py:
|
|
/// TokenResponse.refresh_token wird bei jedem Login/Refresh serverseitig auf
|
|
/// None gesetzt) → persistente Cookie-Jar nötig, kein Body-Refresh-Token.
|
|
class ApiClient {
|
|
ApiClient._internal();
|
|
static final ApiClient instance = ApiClient._internal();
|
|
|
|
static const _storage = FlutterSecureStorage();
|
|
static const _keyBaseUrl = 'api_base_url';
|
|
static const _keyAccessToken = 'access_token';
|
|
|
|
late final Dio dio;
|
|
late final PersistCookieJar _cookieJar;
|
|
void Function()? onUnauthorized;
|
|
|
|
String _baseUrl = 'https://timemaster.example.com/api/v1';
|
|
|
|
Future<void> init() async {
|
|
final saved = await _storage.read(key: _keyBaseUrl);
|
|
if (saved != null && saved.isNotEmpty) _baseUrl = saved;
|
|
|
|
final dir = await getApplicationSupportDirectory();
|
|
_cookieJar = PersistCookieJar(storage: FileStorage('${dir.path}/cookies'));
|
|
|
|
dio = Dio(BaseOptions(
|
|
baseUrl: _baseUrl,
|
|
connectTimeout: const Duration(seconds: 10),
|
|
receiveTimeout: const Duration(seconds: 15),
|
|
));
|
|
dio.interceptors.add(CookieManager(_cookieJar));
|
|
|
|
dio.interceptors.add(InterceptorsWrapper(
|
|
onRequest: (options, handler) async {
|
|
final token = await _storage.read(key: _keyAccessToken);
|
|
if (token != null) {
|
|
options.headers['Authorization'] = 'Bearer $token';
|
|
}
|
|
handler.next(options);
|
|
},
|
|
onError: (error, handler) async {
|
|
if (error.response?.statusCode == 401 &&
|
|
error.requestOptions.path != '/auth/refresh') {
|
|
final refreshed = await _tryRefresh();
|
|
if (refreshed) {
|
|
final opts = error.requestOptions;
|
|
final token = await _storage.read(key: _keyAccessToken);
|
|
opts.headers['Authorization'] = 'Bearer $token';
|
|
try {
|
|
final response = await dio.fetch(opts);
|
|
return handler.resolve(response);
|
|
} catch (_) {
|
|
// fällt durch zu logout unten
|
|
}
|
|
}
|
|
await clearSession();
|
|
onUnauthorized?.call();
|
|
}
|
|
handler.next(error);
|
|
},
|
|
));
|
|
}
|
|
|
|
Future<bool> _tryRefresh() async {
|
|
try {
|
|
// Cookie wird von CookieManager automatisch mitgeschickt.
|
|
final response = await dio.post('/auth/refresh');
|
|
final data = response.data as Map<String, dynamic>;
|
|
await _storage.write(key: _keyAccessToken, value: data['access_token'] as String);
|
|
return true;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<void> setBaseUrl(String url) async {
|
|
_baseUrl = url;
|
|
dio.options.baseUrl = url;
|
|
await _storage.write(key: _keyBaseUrl, value: url);
|
|
}
|
|
|
|
String get baseUrl => _baseUrl;
|
|
|
|
Future<void> saveAccessToken(String accessToken) async {
|
|
await _storage.write(key: _keyAccessToken, value: accessToken);
|
|
}
|
|
|
|
Future<void> clearSession() async {
|
|
await _storage.delete(key: _keyAccessToken);
|
|
await _cookieJar.deleteAll();
|
|
}
|
|
|
|
Future<bool> hasSession() async {
|
|
return (await _storage.read(key: _keyAccessToken)) != null;
|
|
}
|
|
}
|