Authentication #
Autentikasi pengguna merupakan pilar keamanan mendasar yang hampir selalu ada di setiap aplikasi seluler tingkat produksi. Melalui autentikasi, sistem dapat memverifikasi identitas pengguna, menjaga kerahasiaan data sensitif, serta memastikan bahwa setiap request yang masuk ke server API berasal dari sumber yang sah.
Di dalam arsitektur API modern, protokol autentikasi yang paling umum digunakan adalah sistem token berbasis JWT (JSON Web Token). Mengelola sistem autentikasi di Flutter secara profesional menuntut kita untuk menguasai berbagai aspek teknis yang kompleks: menyimpan token secara terenkripsi di penyimpanan lokal perangkat, menyisipkan token secara otomatis ke setiap headers request, mendeteksi jika token akses kedaluwarsa, melakukan penyegaran token (refresh token) secara otomatis di latar belakang tanpa mengganggu kenyamanan pengguna, hingga memproteksi rute halaman (route guarding).
Dalam panduan ini, kita akan mengupas tuntas cara mengimplementasikan sistem autentikasi token JWT di Flutter secara komprehensif. Kita akan mempelajari alur kerja JWT, memanfaatkan area penyimpanan aman sistem operasi, membuat interceptor antrean request, menyusun state management autentikasi, serta mengintegrasikan Google Sign-In sebagai OAuth pihak ketiga.
Alur Kerja Autentikasi JWT #
Sistem autentikasi berbasis JWT membagi token keamanan menjadi dua jenis utama yang memiliki masa aktif berbeda:
- Access Token: Token utama yang digunakan untuk membuktikan hak akses kita ke server. Token ini biasanya memiliki masa aktif yang sangat singkat (misal: 15 menit hingga 1 jam) demi alasan keamanan.
- Refresh Token: Token cadangan yang digunakan murni untuk meminta Access Token baru ketika Access Token lama telah kedaluwarsa. Token ini disimpan secara aman dan biasanya memiliki masa aktif yang jauh lebih panjang (misal: 7 hari hingga 30 hari).
Berikut adalah diagram urutan (sequence diagram) yang menggambarkan bagaimana siklus hidup autentikasi JWT berjalan, mulai dari pemanggilan API normal, deteksi token mati, refresh otomatis di latar belakang, hingga pengulangan request asli secara transparan bagi pengguna:
sequenceDiagram
autonumber
actor Pengguna
participant UI as Widget UI
participant Client as Dio HTTP Client
participant Interceptor as Auth Interceptor
participant Server as Server API Backend
Pengguna->>UI: Klik tombol ambil data
UI->>Client: getProduk()
Client->>Interceptor: Cek token akses
Interceptor->>Server: GET /produk (Header: Expired Access Token)
Server-->>Interceptor: 401 Unauthorized (Token Expired)
Note over Interceptor: Picu Refresh Token
Interceptor->>Server: POST /auth/refresh (Body: Refresh Token)
Server-->>Interceptor: "200 OK (New Access Token & Refresh Token)"
Note over Interceptor: Simpan Token Baru ke Secure Storage
Note over Interceptor: Retry Request Asli
Interceptor->>Server: "GET /produk (Header: New Access Token)"
Server-->>Interceptor: 200 OK (Daftar Produk)
Interceptor-->>Client: Kembalikan Response Sukses
Client-->>UI: Kirim Data Produk
UI-->>Pengguna: Tampilkan Data TerupdatePenyimpanan Token Aman Menggunakan flutter_secure_storage #
Salah satu kesalahan paling fatal dalam keamanan Flutter adalah menyimpan Access Token dan Refresh Token di dalam SharedPreferences biasa. Data yang ditulis di SharedPreferences disimpan dalam format berkas XML teks biasa (plain text) yang tidak terenkripsi, sehingga dapat dibaca dengan mudah oleh aplikasi lain atau malware di perangkat Android yang telah di-root.
Kita wajib menggunakan flutter_secure_storage. Pustaka ini memanfaatkan area penyimpanan terenkripsi yang disediakan oleh sistem operasi perangkat, yaitu Keychain pada iOS dan Keystore pada Android yang terenkripsi menggunakan algoritma AES-256.
Tambahkan dependensi berikut ke berkas pubspec.yaml kita:
dependencies:
flutter_secure_storage: ^9.2.2
Membuat Token Storage Service #
Mari kita buat kelas pembungkus untuk memusatkan operasi penulisan, pembacaan, dan penghapusan token secara aman:
// core/auth/token_storage.dart
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class TokenStorage {
// Mengonfigurasi enkripsi Shared Preferences khusus untuk Android
// dan tingkat aksesibilitas Keychain untuk iOS
static const _secureStorage = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
);
static const _keyAccessToken = 'token_akses';
static const _keyRefreshToken = 'token_refresh';
static const _keyExpiresAt = 'waktu_kadaluwarsa_token';
// 1. Menyimpan token setelah berhasil masuk/daftar
static Future<void> simpanToken({
required String accessToken,
required String refreshToken,
required int durasiDetik,
}) async {
// Menghitung waktu absolut kedaluwarsa token di masa depan
final waktuKadaluwarsa = DateTime.now()
.add(Duration(seconds: durasiDetik))
.millisecondsSinceEpoch
.toString();
await Future.wait([
_secureStorage.write(key: _keyAccessToken, value: accessToken),
_secureStorage.write(key: _keyRefreshToken, value: refreshToken),
_secureStorage.write(key: _keyExpiresAt, value: waktuKadaluwarsa),
]);
}
// 2. Membaca access token
static Future<String?> ambilAccessToken() async {
return _secureStorage.read(key: _keyAccessToken);
}
// 3. Membaca refresh token
static Future<String?> ambilRefreshToken() async {
return _secureStorage.read(key: _keyRefreshToken);
}
// 4. Melakukan pengecekan kedaluwarsa secara proaktif
static Future<bool> apakahTokenExpired() async {
final strWaktu = await _secureStorage.read(key: _keyExpiresAt);
if (strWaktu == null) return true;
final waktuKadaluwarsa = DateTime.fromMillisecondsSinceEpoch(int.parse(strWaktu));
// Memberikan batas toleransi waktu (buffer) selama 30 detik.
// Kita meminta refresh sebelum token benar-benar mati saat request dikirim.
final waktuBuffer = waktuKadaluwarsa.subtract(const Duration(seconds: 30));
return DateTime.now().isAfter(waktuBuffer);
}
// 5. Verifikasi ketersediaan token yang valid
static Future<bool> apakahMemilikiTokenValid() async {
final token = await ambilAccessToken();
if (token == null) return false;
return !await apakahTokenExpired();
}
// 6. Menghapus semua token saat pengguna keluar (logout)
static Future<void> hapusSemuaToken() async {
await Future.wait([
_secureStorage.delete(key: _keyAccessToken),
_secureStorage.delete(key: _keyRefreshToken),
_secureStorage.delete(key: _keyExpiresAt),
]);
}
}
Auth Interceptor: Injeksi & Refresh Token Otomatis #
Bagian paling menantang dari sistem autentikasi adalah menulis Auth Interceptor. Interceptor ini bertugas melakukan dua pekerjaan berat:
- Menyisipkan header
Authorization: Bearer <access_token>secara otomatis ke setiap request keluar (onRequest). - Mencegat error
401 Unauthorizeddari server, melakukan panggilan API refresh token secara diam-diam, menyimpan token baru, dan mengulangi request asli yang sempat gagal tadi (onError).
Mengatasi Masalah Infinite Loop & Request Antre #
- Pemisahan Instansi Dio: Kita tidak boleh menggunakan instansi
Dioutama yang sama untuk melakukan pemanggilan request refresh token. Jika kita melakukannya, request refresh token tersebut akan ikut dicegat oleh interceptor ini dan memicu panggilan refresh bertumpuk yang menyebabkan infinite loop (putaran tak terbatas) hingga aplikasi crash. Kita harus membuat instansi Dio kedua yang bersih dari interceptor khusus untuk rute/auth/refresh. - Antrean Request (Request Queue): Jika pengguna sedang memuat halaman yang memicu 3 panggilan API sekaligus secara paralel, dan ketiganya gagal dengan error 401, kita tidak boleh menembak API refresh token sebanyak 3 kali. Kita harus mengunci proses refresh pertama, mengantre request kedua dan ketiga menggunakan
Completer, lalu melepas kunci antrean setelah token baru berhasil didapatkan.
Berikut adalah kode lengkap AuthInterceptor yang tangguh dan aman:
import 'dart:async';
import 'package:dio/dio.dart';
import 'token_storage.dart';
import '../errors/exceptions.dart';
class AuthInterceptor extends Interceptor {
final Dio _dioUtama;
// Instansi Dio terisolasi tanpa interceptor untuk menghindari infinite loop
final Dio _dioRefresh;
bool _sedangProsesRefresh = false;
// Menyimpan daftar request yang ditangguhkan selama proses refresh token berjalan
final List<_RequestDitangguhkan> _antreanRequest = [];
AuthInterceptor(this._dioUtama)
: _dioRefresh = Dio(BaseOptions(baseUrl: 'https://api.tokokita.com/v1'));
@override
Future<void> onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
// 1. Lewatkan injeksi token jika rute yang dituju adalah endpoint autentikasi dasar
if (_apakahRuteAuth(options.path)) {
return handler.next(options);
}
// 2. Lakukan refresh proaktif jika token diketahui sudah expired sebelum request dikirim
if (await TokenStorage.apakahTokenExpired()) {
try {
await _jalankanRefresh();
} catch (_) {
// Jika gagal refresh proaktif, biarkan request dikirim dan ditangani di onError
}
}
final token = await TokenStorage.ambilAccessToken();
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
}
@override
Future<void> onError(
DioException err,
ErrorInterceptorHandler handler,
) async {
// 3. Kita hanya menangani error 401 dan bukan dari endpoint auth itu sendiri
if (err.response?.statusCode != 401 || _apakahRuteAuth(err.requestOptions.path)) {
return handler.next(err);
}
final optionsRequest = err.requestOptions;
// 4. Jika proses refresh sedang berjalan oleh request lain, antre request ini
if (_sedangProsesRefresh) {
final completerRequest = Completer<Response>();
_antreanRequest.add(
_RequestDitangguhkan(
options: optionsRequest,
completer: completerRequest,
),
);
try {
final responseUlang = await completerRequest.future;
handler.resolve(responseUlang);
} catch (e) {
handler.next(err);
}
return;
}
// 5. Mulai proses refresh token utama
_sedangProsesRefresh = true;
try {
await _jalankanRefresh();
final tokenBaru = await TokenStorage.ambilAccessToken();
// Update header request asli dengan token yang baru
optionsRequest.headers['Authorization'] = 'Bearer $tokenBaru';
// Jalankan kembali request asli
final responseUtama = await _dioUtama.fetch(optionsRequest);
// 6. Jalankan kembali seluruh request yang mengantre di dalam daftar
for (final requestAntre in _antreanRequest) {
requestAntre.options.headers['Authorization'] = 'Bearer $tokenBaru';
final responseUlangAntre = await _dioUtama.fetch(requestAntre.options);
requestAntre.completer.complete(responseUlangAntre);
}
_antreanRequest.clear();
handler.resolve(responseUtama);
} catch (e) {
// 7. Jika proses refresh gagal (refresh token mati), bersihkan token dan paksa logout
await TokenStorage.hapusSemuaToken();
for (final requestAntre in _antreanRequest) {
requestAntre.completer.completeError(e);
}
_antreanRequest.clear();
handler.next(err);
} finally {
_sedangProsesRefresh = false;
}
}
Future<void> _jalankanRefresh() async {
final tokenRefresh = await TokenStorage.ambilRefreshToken();
if (tokenRefresh == null) throw UnauthorizedException();
final response = await _dioRefresh.post(
'/auth/refresh',
data: {'refresh_token': tokenRefresh},
);
// Simpan token baru yang dikirimkan oleh server backend
await TokenStorage.simpanToken(
accessToken: response.data['access_token'],
refreshToken: response.data['refresh_token'],
durasiDetik: response.data['expires_in'],
);
}
bool _apakahRuteAuth(String path) {
return path.contains('/auth/login') ||
path.contains('/auth/register') ||
path.contains('/auth/refresh');
}
}
class _RequestDitangguhkan {
final RequestOptions options;
final Completer<Response> completer;
_RequestDitangguhkan({
required this.options,
required this.completer,
});
}
Pengelolaan State Autentikasi (Auth State Management) #
Status autentikasi aplikasi bersifat global dan harus selalu memantau kondisi transisi pengguna (apakah terautentikasi, belum masuk, sedang memuat, atau mengalami error). Kita menggunakan paket Freezed untuk menyusun kelas AuthState secara terstruktur:
// features/auth/auth_state.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../domain/entities/user.dart'; // Asumsikan model User sudah ada
part 'auth_state.freezed.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 pesan) = _Error;
}
Membuat Auth Notifier #
Berikut adalah implementasi AuthNotifier berbasis Riverpod untuk mengelola interaksi autentikasi terpusat:
// features/auth/auth_notifier.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'auth_state.dart';
import 'token_storage.dart';
import '../../domain/repositories/auth_repository.dart';
class AuthNotifier extends AutoDisposeAsyncNotifier<AuthState> {
@override
Future<AuthState> build() async {
// 1. Cek ketersediaan token saat aplikasi pertama kali dibuka (startup check)
if (await TokenStorage.apakahMemilikiTokenValid()) {
try {
final repository = ref.read(authRepositoryProvider);
final user = await repository.ambilProfilPengguna();
return AuthState.authenticated(user);
} catch (_) {
// Jika gagal mengambil data profil (misal token bermasalah di server), bersihkan storage
await TokenStorage.hapusSemuaToken();
}
}
return const AuthState.unauthenticated();
}
// 2. Aksi masuk (login)
Future<void> login(String email, String password) async {
state = const AsyncValue.data(AuthState.loading());
final repository = ref.read(authRepositoryProvider);
final hasil = await repository.loginEmailPassword(email, password);
hasil.fold(
(appException) => state = AsyncValue.data(AuthState.error(appException.message)),
(authResult) async {
await TokenStorage.simpanToken(
accessToken: authResult.accessToken,
refreshToken: authResult.refreshToken,
durasiDetik: authResult.durasiDetik,
);
state = AsyncValue.data(AuthState.authenticated(authResult.user));
},
);
}
// 3. Aksi keluar (logout)
Future<void> logout() async {
try {
final repository = ref.read(authRepositoryProvider);
await repository.logoutServer();
} finally {
// Selalu bersihkan token lokal terlepas dari hasil hit server
await TokenStorage.hapusSemuaToken();
state = const AsyncValue.data(AuthState.unauthenticated());
}
}
// 4. Logout paksa ketika token benar-benar expired total
void logoutPaksa() {
TokenStorage.hapusSemuaToken();
state = const AsyncValue.data(AuthState.unauthenticated());
}
}
final authProvider = AsyncNotifierProvider.autoDispose<AuthNotifier, AuthState>(
AuthNotifier.new,
);
Membangun Route Guard (Auth Guard) dengan GoRouter #
Setelah state autentikasi kita siap, kita harus menyusun pengaman rute navigasi (Route Guard) menggunakan GoRouter. Pengaman ini berfungsi untuk:
- Mengarahkan otomatis pengguna yang belum masuk ke halaman
/loginjika mencoba membuka halaman beranda/home. - Mengarahkan otomatis pengguna yang sudah masuk ke halaman
/homejika mencoba mengakses kembali halaman/login.
import 'package:go_router/go_router.dart';
import 'package:flutter/material.dart';
import 'auth_notifier.dart';
import 'auth_state.dart';
final routerProvider = Provider<GoRouter>((ref) {
return GoRouter(
initialLocation: '/login',
// Evaluasi pengamanan rute setiap kali terjadi perpindahan navigasi
redirect: (BuildContext context, GoRouterState stateNav) {
// Membaca status autentikasi saat ini
final authState = ref.read(authProvider).valueOrNull;
final apakahSudahLogin = authState is _Authenticated;
final sedangDiLayarLogin = stateNav.matchedLocation == '/login';
// Skenario 1: Jika belum login dan tidak di layar login, paksa ke login
if (!apakahSudahLogin && !sedangDiLayarLogin) {
return '/login';
}
// Skenario 2: Jika sudah login dan masih di layar login, arahkan ke beranda
if (apakahSudahLogin && sedangDiLayarLogin) {
return '/home';
}
return null; // Bebas akses rute jika kondisi terpenuhi
},
// Memantau perubahan Stream AuthState untuk memicu redirect otomatis
refreshListenable: GoRouterRefreshStream(ref.watch(authProvider.stream)),
routes: [
GoRoute(path: '/login', builder: (_, __) => const HalamanLogin()),
GoRoute(path: '/home', builder: (_, __) => const HalamanBeranda()),
],
);
});
// Helper class untuk mengonversi Stream Dart biasa menjadi Listenable yang dibaca GoRouter
class GoRouterRefreshStream extends ChangeNotifier {
late final StreamSubscription<dynamic> _subscription;
GoRouterRefreshStream(Stream<dynamic> stream) {
notifyListeners();
_subscription = stream.asBroadcastStream().listen((_) => notifyListeners());
}
@override
void dispose() {
_subscription.cancel();
super.dispose();
}
}
Integrasi Autentikasi Pihak Ketiga (Google Sign-In) #
Aplikasi seluler modern sering menyertakan fitur integrasi masuk satu tombol (Single Sign-On / SSO) seperti masuk menggunakan akun Google (OAuth 2.0).
Aturan Keamanan Utama OAuth di Mobile: #
Kita tidak boleh langsung menggunakan token akses Google yang didapat dari klien mobile untuk mengakses server API backend kita sendiri. Skenario yang benar adalah:
- Klien mobile melakukan proses masuk menggunakan SDK Google.
- Klien mendapatkan ID Token dari Google (berupa token JWT terenkripsi berisi data profil terverifikasi Google).
- Klien mobile mengirimkan ID Token tersebut ke server API backend kita (
POST /auth/google-login). - Server API backend kita memverifikasi keaslian ID Token tersebut ke server resmi Google.
- Jika sah, server API backend kita akan membuat akun user baru (jika belum ada) dan menerbitkan Access Token JWT internal milik sistem kita sendiri untuk dikembalikan ke aplikasi Flutter.
Tambahkan dependensi berikut ke berkas pubspec.yaml proyek kita:
dependencies:
google_sign_in: ^6.2.2
Kode Implementasi Google Sign-In #
// services/google_auth_service.dart
import 'package:google_sign_in/google_sign_in.dart';
import '../errors/exceptions.dart';
class GoogleAuthService {
final GoogleSignIn _googleSignIn = GoogleSignIn(
scopes: ['email', 'profile'],
);
Future<GoogleSignInAuthentication?> loginGoogle() async {
try {
// 1. Memunculkan dialog pilihan akun Google di perangkat
final akunGoogle = await _googleSignIn.signIn();
if (akunGoogle == null) return null; // Pengguna membatalkan proses masuk
// 2. Mengambil detail autentikasi (ID Token & Access Token Google)
final auth = await akunGoogle.authentication;
return auth;
} catch (e) {
throw AppException('Proses masuk dengan Google gagal: $e');
}
}
Future<void> logoutGoogle() async {
await _googleSignIn.signOut();
}
}
Lalu kita integrasikan metode di atas ke dalam AuthNotifier kita:
// Di dalam kelas AuthNotifier:
Future<void> loginDenganGoogle() async {
state = const AsyncValue.data(AuthState.loading());
try {
final googleAuth = await ref.read(googleAuthServiceProvider).loginGoogle();
if (googleAuth == null) {
state = const AsyncValue.data(AuthState.unauthenticated());
return;
}
final repository = ref.read(authRepositoryProvider);
// Kirim idToken Google ke backend kita untuk ditukar dengan token JWT kita sendiri
final hasil = await repository.verifikasiGoogleToken(googleAuth.idToken!);
hasil.fold(
(error) => state = AsyncValue.data(AuthState.error(error.message)),
(authResult) async {
await TokenStorage.simpanToken(
accessToken: authResult.accessToken,
refreshToken: authResult.refreshToken,
durasiDetik: authResult.durasiDetik,
);
state = AsyncValue.data(AuthState.authenticated(authResult.user));
},
);
} catch (e) {
state = AsyncValue.data(AuthState.error(e.toString()));
}
}
Contoh Kasus Lengkap: Widget Layar Login #
Terakhir, mari kita rangkum seluruh integrasi di atas ke dalam sebuah tampilan layar login (HalamanLogin) yang interaktif dan lengkap dengan penanganan status loading, error message, dan tombol masuk dengan Google:
// presentation/screens/halaman_login.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/produk_providers.dart'; // Mengimpor authProvider
import '../providers/router_provider.dart'; // Mengimpor routerProvider
import '../../features/auth/auth_state.dart';
class HalamanLogin extends ConsumerStatefulWidget {
const HalamanLogin({super.key});
@override
ConsumerState<HalamanLogin> createState() => _HalamanLoginState();
}
class _HalamanLoginState extends ConsumerState<HalamanLogin> {
final _kunciForm = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
bool _sembunyikanPassword = true;
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
void _submitForm() {
if (!_kunciForm.currentState!.validate()) return;
ref.read(authProvider.notifier).login(
_emailController.text.trim(),
_passwordController.text,
);
}
@override
Widget build(BuildContext context) {
final stateAutentikasi = ref.watch(authProvider);
final apakahMemuat = stateAutentikasi.valueOrNull is _Loading;
final stringError = stateAutentikasi.valueOrNull is _Error
? (stateAutentikasi.value as _Error).pesan
: null;
return Scaffold(
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(28.0),
child: Form(
key: _kunciForm,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Icon(Icons.lock_person_rounded, size: 80, color: Colors.blue),
const SizedBox(height: 24),
Text(
'Selamat Datang Kembali',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Alamat Email',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.email_outlined),
),
validator: (nilai) {
if (nilai == null || !nilai.contains('@')) {
return 'Mohon masukkan email yang valid.';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: _sembunyikanPassword,
decoration: InputDecoration(
labelText: 'Kata Sandi',
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.lock_outline_rounded),
suffixIcon: IconButton(
icon: Icon(
_sembunyikanPassword
? Icons.visibility_off_outlined
: Icons.visibility_outlined,
),
onPressed: () {
setState(() => _sembunyikanPassword = !_sembunyikanPassword);
},
),
),
validator: (nilai) {
if (nilai == null || nilai.length < 8) {
return 'Kata sandi minimal terdiri atas 8 karakter.';
}
return null;
},
),
if (stringError != null) ...[
const SizedBox(height: 16),
Text(
stringError,
style: TextStyle(color: Theme.of(context).colorScheme.error),
textAlign: TextAlign.center,
),
],
const SizedBox(height: 28),
ElevatedButton(
onPressed: apakahMemuat ? null : _submitForm,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: apakahMemuat
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Masuk ke Akun'),
),
const SizedBox(height: 16),
const Row(
children: [
Expanded(child: Divider()),
Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Text('atau'),
),
Expanded(child: Divider()),
],
),
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: apakahMemuat
? null
: () => ref.read(authProvider.notifier).loginDenganGoogle(),
icon: const Icon(Icons.g_mobiledata, size: 28),
label: const Text('Masuk dengan Google'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
],
),
),
),
),
);
}
}
Ringkasan #
- Penyimpanan Token Terenkripsi harus menggunakan
flutter_secure_storageuntuk memanfaatkan API pengamanan fisik Keystore (Android) dan Keychain (iOS). Jangan gunakanSharedPreferencesbiasa.TokenStorageUtilitas mengonsolidasikan seluruh manajemen penyimpanan, pelacakan kedaluwarsa proaktif, dan penghapusan token secara terpusat.- Auth Interceptor Dio secara dinamis menyuntikkan token akses JWT ke header request dan mendeteksi serta mengurusi refresh token otomatis saat menerima error 401.
- Dio Client Terpisah wajib digunakan untuk endpoint
/refreshagar terhindar dari pemanggilan interceptor yang rekursif (infinite loop).- Antrean Request (Completer) mengantre semua request paralel yang sempat gagal selama proses refresh token utama berjalan demi menghemat resource jaringan.
- State Autentikasi dikelola secara reaktif menggunakan Notifier Riverpod dengan status transisi yang terdefinisi rapi berbasis kelas serikat Freezed.
- GoRouter Route Guard memfasilitasi pengalihan navigasi otomatis bagi user yang belum masuk ke halaman login dan user yang sudah masuk ke beranda secara deklaratif.
- OAuth Google Sign-In diatur secara aman dengan mengirimkan ID Token terverifikasi dari perangkat ke API backend lokal, bukan Google access token.