From 2039080a921ec6ded0b11e3c54ce797bfdfe176a Mon Sep 17 00:00:00 2001 From: Julian Mutter Date: Thu, 2 Jul 2026 22:42:46 +0200 Subject: [PATCH] auth: use sessions --- lib/core/services/api_client.dart | 141 +++++++++++++++++++------ lib/core/services/storage_service.dart | 7 +- lib/features/auth/login_page.dart | 22 ++-- lib/features/home/home_page.dart | 7 +- 4 files changed, 130 insertions(+), 47 deletions(-) diff --git a/lib/core/services/api_client.dart b/lib/core/services/api_client.dart index 215fdc5..197ede7 100644 --- a/lib/core/services/api_client.dart +++ b/lib/core/services/api_client.dart @@ -10,39 +10,30 @@ import '../models/change.dart'; import '../models/sheet.dart'; /// HTTP client for communicating with the Sheetless API server. -/// -/// Handles authentication, sheet listing, and PDF downloads. -/// Provides caching for PDF files on native platforms. class ApiClient { final _log = Logger('ApiClient'); + final String baseUrl; - String? token; + String? accessToken; // Short-lived jwt + String? refreshToken; // Long lived binary token - ApiClient({required this.baseUrl, this.token}); - - /// Whether the client has an authentication token set. - bool get isAuthenticated => token != null; + ApiClient({required this.baseUrl, this.refreshToken}); // --------------------------------------------------------------------------- // Authentication // --------------------------------------------------------------------------- - - /// Authenticates with the server and stores the JWT token. - /// - /// Throws an [Exception] if login fails. Future login(String username, String password) async { _log.info('Logging in...'); - final url = Uri.parse('$baseUrl/auth/login'); - final response = await http.post( - url, - headers: {'Content-Type': 'application/json'}, - body: jsonEncode({'username': username, 'password': password}), - ); + final response = await post("/auth/login", { + 'username': username, + 'password': password, + }, authenticateWithAccessToken: false); if (response.statusCode == 200) { final responseData = jsonDecode(response.body); - token = responseData['token']; + accessToken = responseData['access_token']; + refreshToken = responseData['refresh_token']; _log.info('Login successful'); } else { throw ApiException( @@ -53,9 +44,37 @@ class ApiClient { } } - /// Clears the authentication token. - void logout() { - token = null; + Future refreshAccessToken() async { + _log.info('Refreshing access token...'); + _log.info('Refresh token is: $refreshToken'); + final response = await post("/auth/refresh", { + "refresh_token": refreshToken, + }, authenticateWithAccessToken: false); + if (response.statusCode == 200) { + final responseData = jsonDecode(response.body); + accessToken = responseData['access_token']; + _log.info('Token refresh successful -> $accessToken'); + } else { + throw ApiException( + 'Token refresh failed', + statusCode: response.statusCode, + body: response.body, + ); + } + } + + Future logout() async { + try { + await post( + "/auth/logout", + {}, + authenticateWithAccessToken: true, + ); // Ignoring response, logging out in any case + } catch (e) { + _log.fine('Logout failed: $e'); + } + accessToken = null; + refreshToken = null; _log.info('Logged out successfully'); } @@ -63,21 +82,46 @@ class ApiClient { // HTTP Helpers // --------------------------------------------------------------------------- - Map _buildHeaders({bool isBinary = false}) { - return { - 'Authorization': 'Bearer $token', + Map _buildHeaders({ + bool authenticateWithAccessToken = false, + bool isBinary = false, + }) { + final headers = { + if (authenticateWithAccessToken) 'Authorization': 'Bearer $accessToken', if (!isBinary) 'Content-Type': 'application/json', }; + _log.fine("Header is: $headers"); + return headers; } /// Performs a GET request to the given endpoint. - Future get(String endpoint, {bool isBinary = false}) async { + Future get( + String endpoint, { + bool isBinary = false, + bool authenticateWithAccessToken = true, + bool isRetry = false, + }) async { + _log.fine("Starting get: $endpoint"); final url = Uri.parse('$baseUrl$endpoint'); final response = await http.get( url, - headers: _buildHeaders(isBinary: isBinary), + headers: _buildHeaders( + isBinary: isBinary, + authenticateWithAccessToken: authenticateWithAccessToken, + ), ); + // Unauthorized, refresh access token + if (authenticateWithAccessToken && response.statusCode == 401 && !isRetry) { + await refreshAccessToken(); + return get( + endpoint, + isBinary: isBinary, + authenticateWithAccessToken: authenticateWithAccessToken, + isRetry: true, + ); + } + if (response.statusCode != 200) { _log.warning( "GET '$endpoint' failed: ${response.statusCode}\n${response.body}", @@ -93,15 +137,33 @@ class ApiClient { } /// Performs a POST request with JSON body. - Future post(String endpoint, Map body) async { + Future post( + String endpoint, + Map body, { + bool authenticateWithAccessToken = true, + bool isRetry = false, + }) async { final url = Uri.parse('$baseUrl$endpoint'); final response = await http.post( url, - headers: _buildHeaders(), + headers: _buildHeaders( + authenticateWithAccessToken: authenticateWithAccessToken, + ), body: jsonEncode(body), ); + // Unauthorized, refresh access token + if (authenticateWithAccessToken && response.statusCode == 401 && !isRetry) { + await refreshAccessToken(); + return post( + endpoint, + body, + authenticateWithAccessToken: authenticateWithAccessToken, + isRetry: true, + ); + } + if (response.statusCode != 200 && response.statusCode != 201) { _log.warning( "POST '$endpoint' failed: ${response.statusCode}\n${response.body}", @@ -117,18 +179,28 @@ class ApiClient { } /// Performs a POST request with form-encoded body. - Future postFormData(String endpoint, String body) async { + Future postFormData( + String endpoint, + String body, { + bool isRetry = false, + }) async { final url = Uri.parse('$baseUrl$endpoint'); final response = await http.post( url, headers: { - 'Authorization': 'Bearer $token', + 'Authorization': 'Bearer $accessToken', 'Content-Type': 'application/x-www-form-urlencoded', }, body: body, ); + // Unauthorized, refresh access token + if (response.statusCode == 401 && !isRetry) { + await refreshAccessToken(); + return postFormData(endpoint, body, isRetry: true); + } + if (response.statusCode != 200 && response.statusCode != 201) { _log.warning( "POST Form '$endpoint' failed: ${response.statusCode}\n${response.body}", @@ -248,10 +320,9 @@ class ApiClient { /// Returns true if the server responds, false otherwise. Future checkConnection() async { try { - final url = Uri.parse('$baseUrl/api/health'); - final response = await http - .get(url, headers: _buildHeaders()) - .timeout(const Duration(seconds: 5)); + final response = await get( + "/api/health", + ).timeout(const Duration(seconds: 5)); return response.statusCode == 200; } catch (e) { _log.fine('Connection check failed: $e'); diff --git a/lib/core/services/storage_service.dart b/lib/core/services/storage_service.dart index 5afe121..ebb09d1 100644 --- a/lib/core/services/storage_service.dart +++ b/lib/core/services/storage_service.dart @@ -7,7 +7,7 @@ import 'package:sheetless/core/models/config.dart'; import 'package:sheetless/core/models/sheet.dart'; /// Keys for secure storage (credentials and tokens). -enum SecureStorageKey { url, jwt, email } +enum SecureStorageKey { url, refreshToken, username } /// Data class for storing annotations with metadata. class StoredAnnotation { @@ -110,7 +110,7 @@ class StorageService { /// while preserving server URL and email for convenience. Future clearAllUserData() async { // Clear JWT token - await writeSecure(SecureStorageKey.jwt, null); + await writeSecure(SecureStorageKey.refreshToken, null); // Clear all Hive boxes final sheetAccessTimesBox = await Hive.openBox(_sheetAccessTimesBox); @@ -123,7 +123,8 @@ class StorageService { await changeQueueBox.clear(); final annotationsBox = await Hive.openBox(_annotationsBox); - await annotationsBox.clear(); + await annotationsBox + .clear(); // TODO: this can lead to a loss of important data. Make sure the user is warned final sheetsBox = await Hive.openBox(_sheetsBox); await sheetsBox.clear(); diff --git a/lib/features/auth/login_page.dart b/lib/features/auth/login_page.dart index 83e8cbf..eaa6193 100644 --- a/lib/features/auth/login_page.dart +++ b/lib/features/auth/login_page.dart @@ -48,9 +48,12 @@ class _LoginPageState extends State { /// Attempts to auto-login using a stored JWT token. Future _tryAutoLogin() async { - final jwt = await _storageService.readSecure(SecureStorageKey.jwt); + final refreshToken = await _storageService.readSecure( + SecureStorageKey.refreshToken, + ); - if (jwt != null && _isTokenValid(jwt)) { + if (refreshToken != null) { + // TODO: this assumes the refresh token is always valid await _navigateToHome(); return; } @@ -71,7 +74,9 @@ class _LoginPageState extends State { /// Restores previously saved URL and username for convenience. Future _restoreCredentials() async { final url = await _storageService.readSecure(SecureStorageKey.url); - final username = await _storageService.readSecure(SecureStorageKey.email); + final username = await _storageService.readSecure( + SecureStorageKey.username, + ); if (url != null) _urlController.text = url; if (username != null) _usernameController.text = username; @@ -96,7 +101,7 @@ class _LoginPageState extends State { await apiClient.login(_usernameController.text, _passwordController.text); // Save credentials for next time - await _saveCredentials(apiClient.token!); + await _saveCredentials(apiClient.refreshToken!); await _navigateToHome(); } catch (e) { _log.warning('Login failed', e); @@ -111,14 +116,17 @@ class _LoginPageState extends State { } /// Saves credentials after successful login. - Future _saveCredentials(String token) async { + Future _saveCredentials(String refreshToken) async { await _storageService.writeSecure( SecureStorageKey.url, _urlController.text, ); - await _storageService.writeSecure(SecureStorageKey.jwt, token); await _storageService.writeSecure( - SecureStorageKey.email, + SecureStorageKey.refreshToken, + refreshToken, + ); + await _storageService.writeSecure( + SecureStorageKey.username, _usernameController.text, ); } diff --git a/lib/features/home/home_page.dart b/lib/features/home/home_page.dart index 832875a..413c032 100644 --- a/lib/features/home/home_page.dart +++ b/lib/features/home/home_page.dart @@ -103,9 +103,11 @@ class _HomePageState extends State with RouteAware { Future _loadSheets() async { final url = await _storageService.readSecure(SecureStorageKey.url); - final jwt = await _storageService.readSecure(SecureStorageKey.jwt); + final refreshToken = await _storageService.readSecure( + SecureStorageKey.refreshToken, + ); - _apiClient = ApiClient(baseUrl: url!, token: jwt); + _apiClient = ApiClient(baseUrl: url!, refreshToken: refreshToken); _syncService = SyncService( apiClient: _apiClient!, storageService: _storageService, @@ -169,6 +171,7 @@ class _HomePageState extends State with RouteAware { Future _handleLogout() async { await _storageService.clearAllUserData(); + await _apiClient!.logout(); if (!mounted) return;