From f3fcf07b051fcdf1e1d769eeefe8ea4ee403abae Mon Sep 17 00:00:00 2001 From: petegregoryy Date: Fri, 23 Jan 2026 17:55:55 +0000 Subject: [PATCH] add refresh token support --- lib/services/api_service.dart | 186 +++++++++++++++--------- lib/services/authservice.dart | 124 ++++++++++++---- lib/services/token_storage_service.dart | 41 +++++- pubspec.yaml | 2 +- test/pages/dashboard_page_test.dart | 1 + 5 files changed, 245 insertions(+), 109 deletions(-) diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 615f21b..88df4b8 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -2,7 +2,7 @@ import 'dart:convert'; import 'package:http/http.dart' as http; typedef TokenProvider = String? Function(); -typedef UnauthorizedHandler = Future Function(); +typedef UnauthorizedHandler = Future Function(); class ApiService { String _baseUrl; @@ -36,35 +36,47 @@ class ApiService { _client.close(); } - Map _buildHeaders(Map? extra) { + Map _buildHeaders( + Map? extra, { + bool includeAuth = true, + }) { final token = _getToken?.call(); final headers = {'accept': 'application/json', ...?extra}; - if (token != null && token.isNotEmpty) { + if (includeAuth && token != null && token.isNotEmpty) { headers['Authorization'] = 'Bearer $token'; } return headers; } - Future get(String endpoint, {Map? headers}) async { - final response = await _client - .get( - Uri.parse('$baseUrl$endpoint'), - headers: _buildHeaders(headers), - ) - .timeout(timeout); + Future get( + String endpoint, { + Map? headers, + bool includeAuth = true, + bool allowRetry = true, + }) async { + final response = await _sendWithRetry( + () => _client.get( + Uri.parse('$baseUrl$endpoint'), + headers: _buildHeaders(headers, includeAuth: includeAuth), + ), + allowRetry: allowRetry, + ); return _processResponse(response); } Future getBytes( String endpoint, { Map? headers, + bool includeAuth = true, + bool allowRetry = true, }) async { - final response = await _client - .get( - Uri.parse('$baseUrl$endpoint'), - headers: _buildHeaders(headers), - ) - .timeout(timeout); + final response = await _sendWithRetry( + () => _client.get( + Uri.parse('$baseUrl$endpoint'), + headers: _buildHeaders(headers, includeAuth: includeAuth), + ), + allowRetry: allowRetry, + ); if (response.statusCode >= 200 && response.statusCode < 300) { final contentDisposition = response.headers['content-disposition']; @@ -76,10 +88,6 @@ class ApiService { ); } - if (response.statusCode == 401 && _onUnauthorized != null) { - await _onUnauthorized!(); - } - final body = _decodeBody(response); final message = _extractErrorMessage(body); throw ApiException( @@ -93,15 +101,21 @@ class ApiService { String endpoint, dynamic data, { Map? headers, + bool includeAuth = true, + bool allowRetry = true, }) async { final hasBody = data != null; - final response = await _client - .post( - Uri.parse('$baseUrl$endpoint'), - headers: _buildHeaders(hasBody ? _jsonHeaders(headers) : headers), - body: hasBody ? jsonEncode(data) : null, - ) - .timeout(timeout); + final response = await _sendWithRetry( + () => _client.post( + Uri.parse('$baseUrl$endpoint'), + headers: _buildHeaders( + hasBody ? _jsonHeaders(headers) : headers, + includeAuth: includeAuth, + ), + body: hasBody ? jsonEncode(data) : null, + ), + allowRetry: allowRetry, + ); return _processResponse(response); } @@ -112,38 +126,53 @@ class ApiService { String fieldName = 'file', Map? fields, Map? headers, + bool includeAuth = true, + bool allowRetry = true, }) async { - final request = http.MultipartRequest( - 'POST', - Uri.parse('$baseUrl$endpoint'), - ); - request.headers.addAll(_buildHeaders(headers)); - if (fields != null && fields.isNotEmpty) { - request.fields.addAll(fields); + Future send() async { + final request = http.MultipartRequest( + 'POST', + Uri.parse('$baseUrl$endpoint'), + ); + request.headers.addAll(_buildHeaders(headers, includeAuth: includeAuth)); + if (fields != null && fields.isNotEmpty) { + request.fields.addAll(fields); + } + request.files.add( + http.MultipartFile.fromBytes( + fieldName, + bytes, + filename: filename, + ), + ); + final streamed = await _client.send(request); + return http.Response.fromStream(streamed); } - request.files.add( - http.MultipartFile.fromBytes( - fieldName, - bytes, - filename: filename, - ), - ); - final streamed = await _client.send(request).timeout(timeout); - final response = await http.Response.fromStream(streamed); + + final response = await _sendWithRetry(send, allowRetry: allowRetry); return _processResponse(response); } - Future postForm(String endpoint, Map data) async { - final response = await _client - .post( - Uri.parse('$baseUrl$endpoint'), - headers: _buildHeaders({ + Future postForm( + String endpoint, + Map data, { + bool includeAuth = true, + bool allowRetry = true, + }) async { + final response = await _sendWithRetry( + () => _client.post( + Uri.parse('$baseUrl$endpoint'), + headers: _buildHeaders( + { 'Content-Type': 'application/x-www-form-urlencoded', 'accept': 'application/json', - }), - body: data, // http package handles form-encoding for Map - ) - .timeout(timeout); + }, + includeAuth: includeAuth, + ), + body: data, // http package handles form-encoding for Map + ), + allowRetry: allowRetry, + ); return _processResponse(response); } @@ -151,28 +180,37 @@ class ApiService { String endpoint, dynamic data, { Map? headers, + bool includeAuth = true, + bool allowRetry = true, }) async { final hasBody = data != null; - final response = await _client - .put( - Uri.parse('$baseUrl$endpoint'), - headers: _buildHeaders(hasBody ? _jsonHeaders(headers) : headers), - body: hasBody ? jsonEncode(data) : null, - ) - .timeout(timeout); + final response = await _sendWithRetry( + () => _client.put( + Uri.parse('$baseUrl$endpoint'), + headers: _buildHeaders( + hasBody ? _jsonHeaders(headers) : headers, + includeAuth: includeAuth, + ), + body: hasBody ? jsonEncode(data) : null, + ), + allowRetry: allowRetry, + ); return _processResponse(response); } Future delete( String endpoint, { Map? headers, + bool includeAuth = true, + bool allowRetry = true, }) async { - final response = await _client - .delete( - Uri.parse('$baseUrl$endpoint'), - headers: _buildHeaders(headers), - ) - .timeout(timeout); + final response = await _sendWithRetry( + () => _client.delete( + Uri.parse('$baseUrl$endpoint'), + headers: _buildHeaders(headers, includeAuth: includeAuth), + ), + allowRetry: allowRetry, + ); return _processResponse(response); } @@ -186,10 +224,6 @@ class ApiService { return body; } - if (res.statusCode == 401 && _onUnauthorized != null) { - await _onUnauthorized!(); - } - final message = _extractErrorMessage(body); throw ApiException( statusCode: res.statusCode, @@ -239,6 +273,20 @@ class ApiService { return body.toString(); } + Future _sendWithRetry( + Future Function() send, { + required bool allowRetry, + }) async { + var response = await send().timeout(timeout); + if (response.statusCode == 401 && allowRetry && _onUnauthorized != null) { + final refreshed = await _onUnauthorized!(); + if (refreshed) { + response = await send().timeout(timeout); + } + } + return response; + } + String? _extractFilename(String? contentDisposition) { if (contentDisposition == null || contentDisposition.isEmpty) return null; final utf8Match = diff --git a/lib/services/authservice.dart b/lib/services/authservice.dart index e8cceee..b0e5ab8 100644 --- a/lib/services/authservice.dart +++ b/lib/services/authservice.dart @@ -6,18 +6,20 @@ import 'package:mileograph_flutter/services/token_storage_service.dart'; class AuthService extends ChangeNotifier { final ApiService api; bool _restoring = false; + String? _accessToken; + Future? _refreshFuture; final TokenStorageService _tokenStorage = TokenStorageService(); AuthService({required this.api}) { api.setTokenProvider(() => token); - api.setUnauthorizedHandler(handleTokenExpired); + api.setUnauthorizedHandler(_handleUnauthorized); } AuthenticatedUserData? _user; bool get isLoggedIn => _user != null; - String? get token => _user?.accessToken; + String? get token => _accessToken; String? get userId => _user?.userId; String? get username => _user?.username; String? get fullName => _user?.fullName; @@ -33,11 +35,13 @@ class AuthService extends ChangeNotifier { required String fullName, required String accessToken, required String email, + String? refreshToken, String entriesVisibility = 'private', String mileageVisibility = 'private', bool isElevated = false, bool isDisabled = false, }) { + _accessToken = accessToken; _user = AuthenticatedUserData( userId: userId, username: username, @@ -49,7 +53,7 @@ class AuthService extends ChangeNotifier { isElevated: isElevated, disabled: isDisabled, ); - _persistToken(accessToken); + _persistTokens(accessToken, refreshToken); notifyListeners(); } @@ -64,8 +68,9 @@ class AuthService extends ChangeNotifier { }; // 1. Get token - final tokenResponse = await api.postForm('/token', formData); + final tokenResponse = await api.postForm('/token', formData, includeAuth: false); final accessToken = tokenResponse['access_token']; + final refreshToken = tokenResponse['refresh_token']; // 2. Get user details final userResponse = await api.get( @@ -83,6 +88,7 @@ class AuthService extends ChangeNotifier { fullName: userResponse['full_name'], accessToken: accessToken, email: userResponse['email'], + refreshToken: refreshToken, entriesVisibility: _parseVisibility( userResponse['user_entries_visibility'] ?? userResponse['entries_visibility'], 'private', @@ -103,33 +109,31 @@ class AuthService extends ChangeNotifier { // read token from secure storage (with fallback) final token = await _tokenStorage.getToken(); if (token == null || token.isEmpty) return; + _accessToken = token; final userResponse = await api.get( '/users/me', - headers: { - 'Authorization': 'Bearer $token', - 'accept': 'application/json', - }, ); - setLoginData( - userId: userResponse['user_id'], - username: userResponse['username'], - fullName: userResponse['full_name'], - accessToken: token, - email: userResponse['email'], - entriesVisibility: _parseVisibility( - userResponse['user_entries_visibility'] ?? userResponse['entries_visibility'], - 'private', - ), - mileageVisibility: _parseVisibility( - userResponse['user_mileage_visibility'] ?? userResponse['mileage_visibility'], - 'private', - ), - isElevated: _parseIsElevated(userResponse), - isDisabled: _parseIsDisabled(userResponse), - ); - } catch (_) { + final restoredAccessToken = _accessToken ?? token; + setLoginData( + userId: userResponse['user_id'], + username: userResponse['username'], + fullName: userResponse['full_name'], + accessToken: restoredAccessToken, + email: userResponse['email'], + entriesVisibility: _parseVisibility( + userResponse['user_entries_visibility'] ?? userResponse['entries_visibility'], + 'private', + ), + mileageVisibility: _parseVisibility( + userResponse['user_mileage_visibility'] ?? userResponse['mileage_visibility'], + 'private', + ), + isElevated: _parseIsElevated(userResponse), + isDisabled: _parseIsDisabled(userResponse), + ); + } catch (_) { await _clearToken(); } finally { _restoring = false; @@ -140,12 +144,9 @@ class AuthService extends ChangeNotifier { final token = await _tokenStorage.getToken(); if (token == null || token.isEmpty) return false; try { + _accessToken = token; await api.get( '/validate', - headers: { - 'Authorization': 'Bearer $token', - 'accept': 'application/json', - }, ); return true; } catch (_) { @@ -154,11 +155,15 @@ class AuthService extends ChangeNotifier { } } - Future _persistToken(String token) async { - await _tokenStorage.setToken(token); + Future _persistTokens(String accessToken, String? refreshToken) async { + await _tokenStorage.setToken(accessToken); + if (refreshToken != null && refreshToken.isNotEmpty) { + await _tokenStorage.setRefreshToken(refreshToken); + } } Future _clearToken() async { + _accessToken = null; await _tokenStorage.clearToken(); } @@ -181,6 +186,61 @@ class AuthService extends ChangeNotifier { await api.postForm('/register', formData); } + Future _handleUnauthorized() async { + if (_refreshFuture != null) { + return _refreshFuture!; + } + _refreshFuture = _refreshTokens(); + final refreshed = await _refreshFuture!; + _refreshFuture = null; + return refreshed; + } + + Future _refreshTokens() async { + final refreshToken = await _tokenStorage.getRefreshToken(); + if (refreshToken == null || refreshToken.isEmpty) { + await handleTokenExpired(); + return false; + } + try { + final response = await api.post( + '/token/refresh', + {'refresh_token': refreshToken}, + includeAuth: false, + allowRetry: false, + ); + final accessToken = response['access_token']; + final newRefreshToken = response['refresh_token']; + if (accessToken is! String || + accessToken.isEmpty || + newRefreshToken is! String || + newRefreshToken.isEmpty) { + await handleTokenExpired(); + return false; + } + _accessToken = accessToken; + await _persistTokens(accessToken, newRefreshToken); + if (_user != null) { + _user = AuthenticatedUserData( + userId: _user!.userId, + username: _user!.username, + fullName: _user!.fullName, + accessToken: accessToken, + email: _user!.email, + entriesVisibility: _user!.entriesVisibility, + mileageVisibility: _user!.mileageVisibility, + isElevated: _user!.isElevated, + isDisabled: _user!.disabled, + ); + } + notifyListeners(); + return true; + } catch (_) { + await handleTokenExpired(); + return false; + } + } + Future handleTokenExpired() async { _user = null; await _clearToken(); diff --git a/lib/services/token_storage_service.dart b/lib/services/token_storage_service.dart index ca4be51..a3944b7 100644 --- a/lib/services/token_storage_service.dart +++ b/lib/services/token_storage_service.dart @@ -10,7 +10,8 @@ class TokenStorageService { factory TokenStorageService() => _instance; - static const _tokenKey = 'auth_token'; + static const _accessTokenKey = 'auth_token'; + static const _refreshTokenKey = 'refresh_token'; final FlutterSecureStorage _secureStorage = const FlutterSecureStorage(); Future get _prefs async => @@ -18,17 +19,17 @@ class TokenStorageService { Future setToken(String token) async { try { - await _secureStorage.write(key: _tokenKey, value: token); + await _secureStorage.write(key: _accessTokenKey, value: token); } catch (_) { // ignore secure storage failures in debug/unsupported environments } final prefs = await _prefs; - await prefs.setString(_tokenKey, token); + await prefs.setString(_accessTokenKey, token); } Future getToken() async { try { - final secured = await _secureStorage.read(key: _tokenKey); + final secured = await _secureStorage.read(key: _accessTokenKey); if (secured != null && secured.isNotEmpty) { return secured; } @@ -36,22 +37,48 @@ class TokenStorageService { // ignore and fall back } final prefs = await _prefs; - final token = prefs.getString(_tokenKey); + final token = prefs.getString(_accessTokenKey); return (token == null || token.isEmpty) ? null : token; } Future clearToken() async { try { - await _secureStorage.delete(key: _tokenKey); + await _secureStorage.delete(key: _accessTokenKey); + await _secureStorage.delete(key: _refreshTokenKey); } catch (_) { // ignore } final prefs = await _prefs; - await prefs.remove(_tokenKey); + await prefs.remove(_accessTokenKey); + await prefs.remove(_refreshTokenKey); } Future hasToken() async { final token = await getToken(); return token != null && token.isNotEmpty; } + + Future setRefreshToken(String token) async { + try { + await _secureStorage.write(key: _refreshTokenKey, value: token); + } catch (_) { + // ignore secure storage failures in debug/unsupported environments + } + final prefs = await _prefs; + await prefs.setString(_refreshTokenKey, token); + } + + Future getRefreshToken() async { + try { + final secured = await _secureStorage.read(key: _refreshTokenKey); + if (secured != null && secured.isNotEmpty) { + return secured; + } + } catch (_) { + // ignore and fall back + } + final prefs = await _prefs; + final token = prefs.getString(_refreshTokenKey); + return (token == null || token.isEmpty) ? null : token; + } } diff --git a/pubspec.yaml b/pubspec.yaml index e9d27dc..1ca4a7c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.7.6+14 +version: 0.7.7+15 environment: sdk: ^3.8.1 diff --git a/test/pages/dashboard_page_test.dart b/test/pages/dashboard_page_test.dart index 0aaf731..d390217 100644 --- a/test/pages/dashboard_page_test.dart +++ b/test/pages/dashboard_page_test.dart @@ -1,3 +1,4 @@ +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; import 'package:shared_preferences/shared_preferences.dart';