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:

  1. 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.
  2. 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 Terupdate

Penyimpanan 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:

  1. Menyisipkan header Authorization: Bearer <access_token> secara otomatis ke setiap request keluar (onRequest).
  2. Mencegat error 401 Unauthorized dari 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 Dio utama 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:

  1. Mengarahkan otomatis pengguna yang belum masuk ke halaman /login jika mencoba membuka halaman beranda /home.
  2. Mengarahkan otomatis pengguna yang sudah masuk ke halaman /home jika 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:

  1. Klien mobile melakukan proses masuk menggunakan SDK Google.
  2. Klien mendapatkan ID Token dari Google (berupa token JWT terenkripsi berisi data profil terverifikasi Google).
  3. Klien mobile mengirimkan ID Token tersebut ke server API backend kita (POST /auth/google-login).
  4. Server API backend kita memverifikasi keaslian ID Token tersebut ke server resmi Google.
  5. 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_storage untuk memanfaatkan API pengamanan fisik Keystore (Android) dan Keychain (iOS). Jangan gunakan SharedPreferences biasa.
  • TokenStorage Utilitas 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 /refresh agar 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.

← Sebelumnya: Error Handling   Berikutnya: GraphQL →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact