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_storage untuk menyimpan token — ia menggunakan Keychain (iOS) dan Keystore (Android) yang terenkripsi. Jangan gunakan SharedPreferences untuk data sensitif.
  • Buat TokenStorage service yang memusatkan semua operasi token — read, write, clear, dan pengecekan expiry — di satu tempat.
  • Auth Interceptor Dio menangani dua hal: inject Authorization: Bearer header 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.

← Sebelumnya: Error Handling   Berikutnya: GraphQL →

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