auth: use sessions

This commit is contained in:
2026-07-02 22:42:46 +02:00
parent a3069f0864
commit 2039080a92
4 changed files with 130 additions and 47 deletions
+106 -35
View File
@@ -10,39 +10,30 @@ import '../models/change.dart';
import '../models/sheet.dart'; import '../models/sheet.dart';
/// HTTP client for communicating with the Sheetless API server. /// 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 { class ApiClient {
final _log = Logger('ApiClient'); final _log = Logger('ApiClient');
final String baseUrl; final String baseUrl;
String? token; String? accessToken; // Short-lived jwt
String? refreshToken; // Long lived binary token
ApiClient({required this.baseUrl, this.token}); ApiClient({required this.baseUrl, this.refreshToken});
/// Whether the client has an authentication token set.
bool get isAuthenticated => token != null;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Authentication // Authentication
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Authenticates with the server and stores the JWT token.
///
/// Throws an [Exception] if login fails.
Future<void> login(String username, String password) async { Future<void> login(String username, String password) async {
_log.info('Logging in...'); _log.info('Logging in...');
final url = Uri.parse('$baseUrl/auth/login');
final response = await http.post( final response = await post("/auth/login", {
url, 'username': username,
headers: {'Content-Type': 'application/json'}, 'password': password,
body: jsonEncode({'username': username, 'password': password}), }, authenticateWithAccessToken: false);
);
if (response.statusCode == 200) { if (response.statusCode == 200) {
final responseData = jsonDecode(response.body); final responseData = jsonDecode(response.body);
token = responseData['token']; accessToken = responseData['access_token'];
refreshToken = responseData['refresh_token'];
_log.info('Login successful'); _log.info('Login successful');
} else { } else {
throw ApiException( throw ApiException(
@@ -53,9 +44,37 @@ class ApiClient {
} }
} }
/// Clears the authentication token. Future<void> refreshAccessToken() async {
void logout() { _log.info('Refreshing access token...');
token = null; _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<void> 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'); _log.info('Logged out successfully');
} }
@@ -63,21 +82,46 @@ class ApiClient {
// HTTP Helpers // HTTP Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
Map<String, String> _buildHeaders({bool isBinary = false}) { Map<String, String> _buildHeaders({
return { bool authenticateWithAccessToken = false,
'Authorization': 'Bearer $token', bool isBinary = false,
}) {
final headers = {
if (authenticateWithAccessToken) 'Authorization': 'Bearer $accessToken',
if (!isBinary) 'Content-Type': 'application/json', if (!isBinary) 'Content-Type': 'application/json',
}; };
_log.fine("Header is: $headers");
return headers;
} }
/// Performs a GET request to the given endpoint. /// Performs a GET request to the given endpoint.
Future<http.Response> get(String endpoint, {bool isBinary = false}) async { Future<http.Response> 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 url = Uri.parse('$baseUrl$endpoint');
final response = await http.get( final response = await http.get(
url, 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) { if (response.statusCode != 200) {
_log.warning( _log.warning(
"GET '$endpoint' failed: ${response.statusCode}\n${response.body}", "GET '$endpoint' failed: ${response.statusCode}\n${response.body}",
@@ -93,15 +137,33 @@ class ApiClient {
} }
/// Performs a POST request with JSON body. /// Performs a POST request with JSON body.
Future<http.Response> post(String endpoint, Map<String, dynamic> body) async { Future<http.Response> post(
String endpoint,
Map<String, dynamic> body, {
bool authenticateWithAccessToken = true,
bool isRetry = false,
}) async {
final url = Uri.parse('$baseUrl$endpoint'); final url = Uri.parse('$baseUrl$endpoint');
final response = await http.post( final response = await http.post(
url, url,
headers: _buildHeaders(), headers: _buildHeaders(
authenticateWithAccessToken: authenticateWithAccessToken,
),
body: jsonEncode(body), 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) { if (response.statusCode != 200 && response.statusCode != 201) {
_log.warning( _log.warning(
"POST '$endpoint' failed: ${response.statusCode}\n${response.body}", "POST '$endpoint' failed: ${response.statusCode}\n${response.body}",
@@ -117,18 +179,28 @@ class ApiClient {
} }
/// Performs a POST request with form-encoded body. /// Performs a POST request with form-encoded body.
Future<http.Response> postFormData(String endpoint, String body) async { Future<http.Response> postFormData(
String endpoint,
String body, {
bool isRetry = false,
}) async {
final url = Uri.parse('$baseUrl$endpoint'); final url = Uri.parse('$baseUrl$endpoint');
final response = await http.post( final response = await http.post(
url, url,
headers: { headers: {
'Authorization': 'Bearer $token', 'Authorization': 'Bearer $accessToken',
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
}, },
body: body, 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) { if (response.statusCode != 200 && response.statusCode != 201) {
_log.warning( _log.warning(
"POST Form '$endpoint' failed: ${response.statusCode}\n${response.body}", "POST Form '$endpoint' failed: ${response.statusCode}\n${response.body}",
@@ -248,10 +320,9 @@ class ApiClient {
/// Returns true if the server responds, false otherwise. /// Returns true if the server responds, false otherwise.
Future<bool> checkConnection() async { Future<bool> checkConnection() async {
try { try {
final url = Uri.parse('$baseUrl/api/health'); final response = await get(
final response = await http "/api/health",
.get(url, headers: _buildHeaders()) ).timeout(const Duration(seconds: 5));
.timeout(const Duration(seconds: 5));
return response.statusCode == 200; return response.statusCode == 200;
} catch (e) { } catch (e) {
_log.fine('Connection check failed: $e'); _log.fine('Connection check failed: $e');
+4 -3
View File
@@ -7,7 +7,7 @@ import 'package:sheetless/core/models/config.dart';
import 'package:sheetless/core/models/sheet.dart'; import 'package:sheetless/core/models/sheet.dart';
/// Keys for secure storage (credentials and tokens). /// Keys for secure storage (credentials and tokens).
enum SecureStorageKey { url, jwt, email } enum SecureStorageKey { url, refreshToken, username }
/// Data class for storing annotations with metadata. /// Data class for storing annotations with metadata.
class StoredAnnotation { class StoredAnnotation {
@@ -110,7 +110,7 @@ class StorageService {
/// while preserving server URL and email for convenience. /// while preserving server URL and email for convenience.
Future<void> clearAllUserData() async { Future<void> clearAllUserData() async {
// Clear JWT token // Clear JWT token
await writeSecure(SecureStorageKey.jwt, null); await writeSecure(SecureStorageKey.refreshToken, null);
// Clear all Hive boxes // Clear all Hive boxes
final sheetAccessTimesBox = await Hive.openBox(_sheetAccessTimesBox); final sheetAccessTimesBox = await Hive.openBox(_sheetAccessTimesBox);
@@ -123,7 +123,8 @@ class StorageService {
await changeQueueBox.clear(); await changeQueueBox.clear();
final annotationsBox = await Hive.openBox(_annotationsBox); 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); final sheetsBox = await Hive.openBox(_sheetsBox);
await sheetsBox.clear(); await sheetsBox.clear();
+15 -7
View File
@@ -48,9 +48,12 @@ class _LoginPageState extends State<LoginPage> {
/// Attempts to auto-login using a stored JWT token. /// Attempts to auto-login using a stored JWT token.
Future<void> _tryAutoLogin() async { Future<void> _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(); await _navigateToHome();
return; return;
} }
@@ -71,7 +74,9 @@ class _LoginPageState extends State<LoginPage> {
/// Restores previously saved URL and username for convenience. /// Restores previously saved URL and username for convenience.
Future<void> _restoreCredentials() async { Future<void> _restoreCredentials() async {
final url = await _storageService.readSecure(SecureStorageKey.url); 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 (url != null) _urlController.text = url;
if (username != null) _usernameController.text = username; if (username != null) _usernameController.text = username;
@@ -96,7 +101,7 @@ class _LoginPageState extends State<LoginPage> {
await apiClient.login(_usernameController.text, _passwordController.text); await apiClient.login(_usernameController.text, _passwordController.text);
// Save credentials for next time // Save credentials for next time
await _saveCredentials(apiClient.token!); await _saveCredentials(apiClient.refreshToken!);
await _navigateToHome(); await _navigateToHome();
} catch (e) { } catch (e) {
_log.warning('Login failed', e); _log.warning('Login failed', e);
@@ -111,14 +116,17 @@ class _LoginPageState extends State<LoginPage> {
} }
/// Saves credentials after successful login. /// Saves credentials after successful login.
Future<void> _saveCredentials(String token) async { Future<void> _saveCredentials(String refreshToken) async {
await _storageService.writeSecure( await _storageService.writeSecure(
SecureStorageKey.url, SecureStorageKey.url,
_urlController.text, _urlController.text,
); );
await _storageService.writeSecure(SecureStorageKey.jwt, token);
await _storageService.writeSecure( await _storageService.writeSecure(
SecureStorageKey.email, SecureStorageKey.refreshToken,
refreshToken,
);
await _storageService.writeSecure(
SecureStorageKey.username,
_usernameController.text, _usernameController.text,
); );
} }
+5 -2
View File
@@ -103,9 +103,11 @@ class _HomePageState extends State<HomePage> with RouteAware {
Future<SyncResult> _loadSheets() async { Future<SyncResult> _loadSheets() async {
final url = await _storageService.readSecure(SecureStorageKey.url); 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( _syncService = SyncService(
apiClient: _apiClient!, apiClient: _apiClient!,
storageService: _storageService, storageService: _storageService,
@@ -169,6 +171,7 @@ class _HomePageState extends State<HomePage> with RouteAware {
Future<void> _handleLogout() async { Future<void> _handleLogout() async {
await _storageService.clearAllUserData(); await _storageService.clearAllUserData();
await _apiClient!.logout();
if (!mounted) return; if (!mounted) return;