auth: use sessions
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user