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';
/// 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<void> 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<void> 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<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');
}
@@ -63,21 +82,46 @@ class ApiClient {
// HTTP Helpers
// ---------------------------------------------------------------------------
Map<String, String> _buildHeaders({bool isBinary = false}) {
return {
'Authorization': 'Bearer $token',
Map<String, String> _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<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 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<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 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<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 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<bool> 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');
+4 -3
View File
@@ -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<void> 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();
+15 -7
View File
@@ -48,9 +48,12 @@ class _LoginPageState extends State<LoginPage> {
/// Attempts to auto-login using a stored JWT token.
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();
return;
}
@@ -71,7 +74,9 @@ class _LoginPageState extends State<LoginPage> {
/// Restores previously saved URL and username for convenience.
Future<void> _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<LoginPage> {
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<LoginPage> {
}
/// Saves credentials after successful login.
Future<void> _saveCredentials(String token) async {
Future<void> _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,
);
}
+5 -2
View File
@@ -103,9 +103,11 @@ class _HomePageState extends State<HomePage> with RouteAware {
Future<SyncResult> _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<HomePage> with RouteAware {
Future<void> _handleLogout() async {
await _storageService.clearAllUserData();
await _apiClient!.logout();
if (!mounted) return;