Authentication #
Autentikasi adalah fitur yang hampir selalu ada di aplikasi produksi. Flutter menyediakan ekosistem yang lengkap untuk menangani auth — dari penyimpanan token yang aman, injeksi header otomatis di setiap request, hingga refresh token yang transparan bagi pengguna.
Alur JWT Authentication #
JWT (JSON Web Token) adalah standar token yang paling umum digunakan di REST API:
ALUR LOGIN:
App → POST /auth/login {email, password}
← 200 OK {access_token, refresh_token, expires_in}
App simpan token ke secure storage
App navigasi ke home screen
ALUR REQUEST DENGAN TOKEN:
App → GET /produk
Header: Authorization: Bearer <access_token>
← 200 OK {data: [...]}
ALUR TOKEN EXPIRED:
App → GET /produk
Header: Authorization: Bearer <expired_token>
← 401 Unauthorized
Auth Interceptor mendeteksi 401
App → POST /auth/refresh {refresh_token}
← 200 OK {access_token, refresh_token}
App update token di storage
App retry request yang gagal dengan token baru
← 200 OK {data: [...]} (transparan bagi pengguna!)
ALUR LOGOUT:
App hapus token dari storage
App navigasi ke login screen
flutter_secure_storage — Simpan Token dengan Aman #
Jangan menyimpan token di SharedPreferences biasa — ia tidak terenkripsi. Gunakan flutter_secure_storage yang menggunakan Keychain (iOS) dan Keystore (Android):
# pubspec.yaml
dependencies:
flutter_secure_storage: ^9.2.2
Token Storage Service #
// core/auth/token_storage.dart
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class TokenStorage {
static const _storage = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
);
static const _accessTokenKey = 'access_token';
static const _refreshTokenKey = 'refresh_token';
static const _expiresAtKey = 'token_expires_at';
// Simpan token setelah login berhasil
static Future<void> saveTokens({
required String accessToken,
required String refreshToken,
required int expiresIn, // dalam detik
}) async {
final expiresAt = DateTime.now()
.add(Duration(seconds: expiresIn))
.millisecondsSinceEpoch
.toString();
await Future.wait([
_storage.write(key: _accessTokenKey, value: accessToken),
_storage.write(key: _refreshTokenKey, value: refreshToken),
_storage.write(key: _expiresAtKey, value: expiresAt),
]);
}
static Future<String?> getAccessToken() async {
return _storage.read(key: _accessTokenKey);
}
static Future<String?> getRefreshToken() async {
return _storage.read(key: _refreshTokenKey);
}
static Future<bool> isAccessTokenExpired() async {
final expiresAtStr = await _storage.read(key: _expiresAtKey);
if (expiresAtStr == null) return true;
final expiresAt = DateTime.fromMillisecondsSinceEpoch(int.parse(expiresAtStr));
// Buffer 30 detik -- refresh sebelum benar-benar expired
return DateTime.now().isAfter(expiresAt.subtract(const Duration(seconds: 30)));
}
static Future<bool> hasValidToken() async {
final token = await getAccessToken();
if (token == null) return false;
return !await isAccessTokenExpired();
}
static Future<void> clearTokens() async {
await Future.wait([
_storage.delete(key: _accessTokenKey),
_storage.delete(key: _refreshTokenKey),
_storage.delete(key: _expiresAtKey),
]);
}
}
Auth Interceptor — Inject Token Otomatis #
Auth interceptor menambahkan token ke setiap request dan menangani refresh token secara otomatis:
// core/network/auth_interceptor.dart
class AuthInterceptor extends Interceptor {
final Dio _dio;
final Dio _refreshDio; // Dio terpisah untuk refresh -- hindari infinite loop
bool _isRefreshing = false;
final List<_PendingRequest> _pendingRequests = [];
AuthInterceptor(this._dio)
: _refreshDio = Dio(BaseOptions(baseUrl: AppConfig.baseUrl));
@override
Future<void> onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
// Skip injection untuk endpoint auth
if (_isAuthEndpoint(options.path)) {
return handler.next(options);
}
// Cek apakah token akan expired -- proactive refresh
if (await TokenStorage.isAccessTokenExpired()) {
await _refreshTokens();
}
final token = await TokenStorage.getAccessToken();
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
}
@override
Future<void> onError(
DioException err,
ErrorInterceptorHandler handler,
) async {
// Hanya handle 401 -- token expired di server tapi belum di sisi kita
if (err.response?.statusCode != 401 || _isAuthEndpoint(err.requestOptions.path)) {
return handler.next(err);
}
if (_isRefreshing) {
// Antri request yang gagal saat refresh sedang berlangsung
final completer = Completer<Response>();
_pendingRequests.add(_PendingRequest(
options: err.requestOptions,
completer: completer,
));
try {
final response = await completer.future;
handler.resolve(response);
} catch (e) {
handler.next(err);
}
return;
}
_isRefreshing = true;
try {
await _refreshTokens();
// Retry request asli dengan token baru
final token = await TokenStorage.getAccessToken();
err.requestOptions.headers['Authorization'] = 'Bearer $token';
final response = await _dio.fetch(err.requestOptions);
// Resolve semua request yang antri
for (final pending in _pendingRequests) {
pending.options.headers['Authorization'] = 'Bearer $token';
final r = await _dio.fetch(pending.options);
pending.completer.complete(r);
}
_pendingRequests.clear();
handler.resolve(response);
} catch (e) {
// Refresh gagal -- logout
await TokenStorage.clearTokens();
for (final pending in _pendingRequests) {
pending.completer.completeError(e);
}
_pendingRequests.clear();
handler.next(err);
} finally {
_isRefreshing = false;
}
}
Future<void> _refreshTokens() async {
final refreshToken = await TokenStorage.getRefreshToken();
if (refreshToken == null) throw UnauthorizedException();
final response = await _refreshDio.post(
'/auth/refresh',
data: {'refresh_token': refreshToken},
);
await TokenStorage.saveTokens(
accessToken: response.data['access_token'],
refreshToken: response.data['refresh_token'],
expiresIn: response.data['expires_in'],
);
}
bool _isAuthEndpoint(String path) {
return path.contains('/auth/login') ||
path.contains('/auth/register') ||
path.contains('/auth/refresh');
}
}
class _PendingRequest {
final RequestOptions options;
final Completer<Response> completer;
_PendingRequest({required this.options, required this.completer});
}
Auth State Management #
// features/auth/auth_state.dart
@freezed
class AuthState with _$AuthState {
const factory AuthState.initial() = _Initial;
const factory AuthState.loading() = _Loading;
const factory AuthState.authenticated(User user) = _Authenticated;
const factory AuthState.unauthenticated() = _Unauthenticated;
const factory AuthState.error(String message) = _Error;
}
// features/auth/auth_notifier.dart
class AuthNotifier extends AsyncNotifier<AuthState> {
@override
Future<AuthState> build() async {
// Cek apakah ada token valid saat startup
if (await TokenStorage.hasValidToken()) {
try {
final user = await ref.read(authRepositoryProvider).getCurrentUser();
return AuthState.authenticated(user);
} catch (_) {
await TokenStorage.clearTokens();
}
}
return const AuthState.unauthenticated();
}
Future<void> login(String email, String password) async {
state = const AsyncData(AuthState.loading());
try {
final result = await ref.read(authRepositoryProvider).login(email, password);
await TokenStorage.saveTokens(
accessToken: result.accessToken,
refreshToken: result.refreshToken,
expiresIn: result.expiresIn,
);
state = AsyncData(AuthState.authenticated(result.user));
} on AppException catch (e) {
state = AsyncData(AuthState.error(e.message));
}
}
Future<void> logout() async {
try {
await ref.read(authRepositoryProvider).logout();
} finally {
await TokenStorage.clearTokens();
state = const AsyncData(AuthState.unauthenticated());
}
}
Future<void> register(String nama, String email, String password) async {
state = const AsyncData(AuthState.loading());
try {
final result = await ref.read(authRepositoryProvider).register(
nama: nama, email: email, password: password,
);
await TokenStorage.saveTokens(
accessToken: result.accessToken,
refreshToken: result.refreshToken,
expiresIn: result.expiresIn,
);
state = AsyncData(AuthState.authenticated(result.user));
} on AppException catch (e) {
state = AsyncData(AuthState.error(e.message));
}
}
}
final authProvider = AsyncNotifierProvider<AuthNotifier, AuthState>(
AuthNotifier.new,
);
Auth Guard — Proteksi Route #
// Dengan GoRouter redirect
final router = GoRouter(
navigatorKey: rootNavigatorKey,
redirect: (context, state) {
final container = ProviderScope.containerOf(context);
final authState = container.read(authProvider).valueOrNull;
final isAuthenticated = authState is _Authenticated;
final isOnLoginPage = state.matchedLocation == '/login';
if (!isAuthenticated && !isOnLoginPage) return '/login';
if (isAuthenticated && isOnLoginPage) return '/home';
return null;
},
refreshListenable: GoRouterRefreshStream(
// Provider → Stream untuk trigger redirect saat auth berubah
ProviderContainer().read(authProvider.stream),
),
routes: [
GoRoute(path: '/login', builder: (_, __) => const LoginScreen()),
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
],
);
Google Sign-In (OAuth) #
# pubspec.yaml
dependencies:
google_sign_in: ^6.2.2
// services/google_auth_service.dart
import 'package:google_sign_in/google_sign_in.dart';
class GoogleAuthService {
final _googleSignIn = GoogleSignIn(
scopes: ['email', 'profile'],
);
Future<GoogleAuthResult?> signIn() async {
try {
final account = await _googleSignIn.signIn();
if (account == null) return null; // pengguna membatalkan
final auth = await account.authentication;
return GoogleAuthResult(
idToken: auth.idToken!,
accessToken: auth.accessToken!,
email: account.email,
nama: account.displayName ?? '',
fotoUrl: account.photoUrl,
);
} catch (e) {
throw AppException('Gagal login dengan Google: $e');
}
}
Future<void> signOut() async {
await _googleSignIn.signOut();
}
}
// Di AuthNotifier
Future<void> loginWithGoogle() async {
state = const AsyncData(AuthState.loading());
try {
final googleResult = await ref.read(googleAuthServiceProvider).signIn();
if (googleResult == null) {
state = const AsyncData(AuthState.unauthenticated());
return;
}
// Kirim Google ID token ke backend kita untuk verifikasi
final result = await ref.read(authRepositoryProvider).loginWithGoogle(
idToken: googleResult.idToken,
);
await TokenStorage.saveTokens(
accessToken: result.accessToken,
refreshToken: result.refreshToken,
expiresIn: result.expiresIn,
);
state = AsyncData(AuthState.authenticated(result.user));
} on AppException catch (e) {
state = AsyncData(AuthState.error(e.message));
}
}
Login Screen — Contoh Lengkap #
class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key});
@override
ConsumerState<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _emailCtrl = TextEditingController();
final _passwordCtrl = TextEditingController();
bool _obscurePassword = true;
@override
void dispose() {
_emailCtrl.dispose();
_passwordCtrl.dispose();
super.dispose();
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
await ref.read(authProvider.notifier).login(
_emailCtrl.text.trim(),
_passwordCtrl.text,
);
}
@override
Widget build(BuildContext context) {
// Listen untuk navigasi setelah login berhasil
ref.listen<AsyncValue<AuthState>>(authProvider, (_, state) {
state.whenData((auth) {
if (auth is _Authenticated) context.go('/home');
});
});
final authState = ref.watch(authProvider);
final isLoading = authState.valueOrNull is _Loading;
final error = authState.valueOrNull is _Error
? (authState.value as _Error).message
: null;
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextFormField(
controller: _emailCtrl,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(labelText: 'Email'),
validator: (v) => v!.contains('@') ? null : 'Email tidak valid',
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordCtrl,
obscureText: _obscurePassword,
decoration: InputDecoration(
labelText: 'Password',
suffixIcon: IconButton(
icon: Icon(_obscurePassword ? Icons.visibility : Icons.visibility_off),
onPressed: () => setState(() => _obscurePassword = !_obscurePassword),
),
),
validator: (v) => v!.length >= 8 ? null : 'Minimal 8 karakter',
),
if (error != null) ...[
const SizedBox(height: 12),
Text(error, style: TextStyle(color: Theme.of(context).colorScheme.error)),
],
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: isLoading ? null : _submit,
child: isLoading
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Masuk'),
),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: isLoading ? null : () => ref.read(authProvider.notifier).loginWithGoogle(),
icon: const Icon(Icons.g_mobiledata),
label: const Text('Masuk dengan Google'),
),
],
),
),
),
),
);
}
}
Ringkasan #
- Gunakan
flutter_secure_storageuntuk menyimpan token — ia menggunakan Keychain (iOS) dan Keystore (Android) yang terenkripsi. Jangan gunakanSharedPreferencesuntuk data sensitif.- Buat
TokenStorageservice yang memusatkan semua operasi token — read, write, clear, dan pengecekan expiry — di satu tempat.- Auth Interceptor Dio menangani dua hal: inject
Authorization: Bearerheader ke setiap request, dan refresh token otomatis saat terima 401.- Gunakan Dio terpisah untuk request refresh token — jika pakai Dio yang sama, interceptor akan terpanggil secara rekursif (infinite loop).
- Antri request yang gagal saat refresh berlangsung menggunakan
Completer— sehingga semua request di-retry setelah token baru tersedia.- Auth state dikelola di Notifier/Bloc — initial state dicek dari storage saat startup, perubahan state memicu redirect via GoRouter.
- Untuk Google Sign-In: dapatkan ID token dari Google, kirim ke backend kita untuk verifikasi, dan backend kembalikan JWT kita sendiri. Jangan simpan Google access token sebagai auth token aplikasi.