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