Terakhir diperbarui: 27 September 2026

Bahasa Indonesia | English

Tujuan pembelajaran

Setelah menyelesaikan codelab ini, mahasiswa mampu:

Persiapan

1. Model autentikasi yang dipakai di codelab ini

Tiga pola login yang umum di industri:

PolaCara kerja singkatKapan dipakai
Firebase Auth (email/Google)SDK Firebase menukar kredensial menjadi ID token JWT yang diverifikasi backendAplikasi kampus yang butuh login cepat tanpa membangun server auth sendiri
JWT + refresh token (REST API sendiri)Server menerbitkan access token berumur pendek + refresh token berumur panjangBackend kampus/JSONPlaceholder-style milik sendiri
OAuth (Google Login)Aplikasi mendapat authorization code, ditukar menjadi token via providerLogin sosial / SSO kampus

Codelab ini memakai mock auth provider + JWT simulasi agar bisa berjalan tanpa backend, lalu menunjukkan titik di mana Firebase Auth dipasang. Pola repository dan token refresh-nya identik, sehingga migrasi ke Firebase Auth hanya mengganti sumber token.

2. Token: access, refresh, dan ID token

Login --> access (15 mnt) + refresh (7 hari) tersimpan aman
Request API --header Bearer access--> 401 expired?
Ya --> tukar refresh --> access baru --> ulangi request sekali
Refresh ikut kedaluwarsa --> logout, kembali ke /login

3. Arsitektur FCM

App Server (backend) --kirim ke--> Firebase Cloud Messaging
FCM --push--> Perangkat Android / iOS
Aplikasi --daftar token--> Backend (simpan token per user)

Alur wajibnya: (1) aplikasi meminta izin notifikasi, (2) aplikasi mengambil registration token via FirebaseMessaging.instance.getToken(), (3) token dikirim ke backend dan disimpan per user, (4) backend memanggil FCM API untuk mengirim pesan ke token/topik tertentu.

4. Notification payload vs data payload

JenisIsiPerilaku sistem
notificationtitle + body tampilanAndroid menampilkan otomatis saat background/terminated; foreground butuh tampil manual
datakey-value bebas mis. {route: /pengumuman/3}Selalu diteruskan ke handler aplikasi; tidak ditampilkan otomatis

Aturan praktis Campus Notification App: selalu kirim gabungan notification + data. notification untuk teks yang dibaca manusia, data.route untuk deep link tujuan klik.

5. Tiga app state yang wajib diuji

StateArtiHandler yang bekerja
ForegroundAplikasi terbukaFirebaseMessaging.onMessage (tampilkan manual via local notification)
BackgroundAplikasi diminimizeBanner sistem otomatis + onMessageOpenedApp saat diklik
TerminatedAplikasi dimatikangetInitialMessage() saat dibuka dari notifikasi

Siapkan project

flutter create campus_notify
cd campus_notify
flutter pub add flutter_riverpod go_router dio flutter_secure_storage
flutter pub add firebase_core firebase_messaging flutter_local_notifications

Struktur folder:

lib/
├── main.dart
├── data/
│   ├── auth_repository.dart
│   ├── token_store.dart
│   └── api_client.dart
├── providers/
│   └── auth_provider.dart
├── messaging/
│   └── push_service.dart
└── pages/
    ├── login_page.dart
    ├── home_page.dart
    └── announcement_page.dart

1. Penyimpanan token yang aman

Buat lib/data/token_store.dart. Seluruh token hanya keluar-masuk lewat kelas ini:

import 'package:flutter_secure_storage/flutter_secure_storage.dart';

class TokenStore {
  TokenStore({FlutterSecureStorage? storage})
      : _storage = storage ?? const FlutterSecureStorage();

  final FlutterSecureStorage _storage;
  static const _accessKey = 'access_token';
  static const _refreshKey = 'refresh_token';

  Future<void> save({required String access, required String refresh}) async {
    await _storage.write(key: _accessKey, value: access);
    await _storage.write(key: _refreshKey, value: refresh);
  }

  Future<String?> readAccess() => _storage.read(key: _accessKey);
  Future<String?> readRefresh() => _storage.read(key: _refreshKey);

  Future<void> clear() => _storage.deleteAll();
}

2. Repository auth (mock yang siap diganti Firebase Auth)

Buat lib/data/auth_repository.dart:

class AuthSession {
  const AuthSession({required this.access, required this.refresh});
  final String access;
  final String refresh;
}

class AuthRepository {
  // GANTI titik ini dengan FirebaseAuth.instance.signInWithEmailAndPassword
  // atau GoogleSignIn saat backend Firebase sudah siap.
  Future<AuthSession> login(
      {required String email, required String password}) async {
    await Future.delayed(const Duration(milliseconds: 500));
    if (!email.contains('@') || password.length < 6) {
      throw Exception('Email atau kata sandi tidak valid');
    }
    // Simulasi JWT: header.payload.signature (jangan parse manual di produksi,
    // gunakan verifikasi server).
    return AuthSession(
      access: 'mock-access-for-$email',
      refresh: 'mock-refresh-for-$email',
    );
  }

  Future<String> refresh(String refreshToken) async {
    await Future.delayed(const Duration(milliseconds: 300));
    if (refreshToken.isEmpty) throw Exception('Refresh token hilang');
    return 'mock-access-renewed-${DateTime.now().millisecondsSinceEpoch}';
  }
}

3. Dio dengan refresh otomatis

Buat lib/data/api_client.dart. Interceptor mencoba refresh satu kali saat menerima 401, lalu mengulang request:

import 'package:dio/dio.dart';
import 'auth_repository.dart';
import 'token_store.dart';

Dio buildApiClient(TokenStore store, AuthRepository auth) {
  final dio = Dio(BaseOptions(baseUrl: 'https://example-campus-api.test'));
  dio.interceptors.add(InterceptorsWrapper(
    onRequest: (options, handler) async {
      final access = await store.readAccess();
      if (access != null) {
        options.headers['Authorization'] = 'Bearer $access';
      }
      handler.next(options);
    },
    onError: (e, handler) async {
      if (e.response?.statusCode == 401) {
        final refresh = await store.readRefresh();
        if (refresh == null) return handler.next(e);
        try {
          final renewed = await auth.refresh(refresh);
          await store.save(access: renewed, refresh: refresh);
          final retry = await dio.fetch(
            e.requestOptions..headers['Authorization'] = 'Bearer $renewed',
          );
          return handler.resolve(retry);
        } catch (_) {
          await store.clear(); // refresh ikut mati -> paksa login ulang
        }
      }
      handler.next(e);
    },
  ));
  return dio;
}

4. Provider auth + guard route

Contoh lib/providers/auth_provider.dart dan pengaman GoRouter di main.dart:

final authStateProvider =
    AsyncNotifierProvider<AuthNotifier, bool>(AuthNotifier.new);

class AuthNotifier extends AsyncNotifier<bool> {
  @override
  Future<bool> build() async {
    final token = await ref.watch(tokenStoreProvider).readAccess();
    return token != null;
  }

  Future<void> login(String email, String password) async {
    state = const AsyncLoading();
    state = await AsyncValue.guard(() async {
      final session = await ref
          .read(authRepositoryProvider)
          .login(email: email, password: password);
      await ref
          .read(tokenStoreProvider)
          .save(access: session.access, refresh: session.refresh);
      return true;
    });
  }

  Future<void> logout() async {
    await ref.read(tokenStoreProvider).clear();
    ref.invalidateSelf();
  }
}
GoRouter(
  redirect: (context, state) {
    final loggedIn =
        container.read(authStateProvider).value ?? false;
    final goingLogin = state.matchedLocation == '/login';
    if (!loggedIn && !goingLogin) return '/login';
    if (loggedIn && goingLogin) return '/';
    return null;
  },
  routes: [
    GoRoute(path: '/login', builder: (_, __) => const LoginPage()),
    GoRoute(path: '/', builder: (_, __) => const HomePage()),
    GoRoute(
      path: '/pengumuman/:id',
      builder: (_, s) =>
          AnnouncementPage(id: s.pathParameters['id'] ?? ''),
    ),
  ],
);

1. Daftarkan aplikasi ke Firebase

  1. Buat project di Firebase Console, tambahkan aplikasi Android dengan package name sesuai applicationId Anda.
  2. Unduh google-services.json ke android/app/ dan ikuti panduan FCM Flutter client (terapkan plugin google-services dan dependensi). Untuk iOS tambahkan GoogleService-Info.plist.
  3. Pastikan firebase_core diinisialisasi sebelum runApp: await Firebase.initializeApp().

2. Minta izin notifikasi

Android 13+ dan iOS wajib meminta izin runtime. Buat lib/messaging/push_service.dart:

import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';

final _local = FlutterLocalNotificationsPlugin();

Future<bool> requestNotificationPermission() async {
  final settings = await FirebaseMessaging.instance.requestPermission(
    alert: true, badge: true, sound: true,
    announcement: false, carPlay: false, criticalAlert: false,
  );
  return settings.authorizationStatus == AuthorizationStatus.authorized ||
      settings.authorizationStatus == AuthorizationStatus.provisional;
}

Future<void> initLocalNotifications() async {
  const android = AndroidInitializationSettings('@mipmap/ic_launcher');
  const ios = DarwinInitializationSettings();
  await _local.initialize(
    const InitializationSettings(android: android, iOS: ios),
    onDidReceiveNotificationResponse: (response) {
      // Klik banner foreground -> teruskan payload ke router.
      pendingDeepLink = response.payload;
    },
  );
}

String? pendingDeepLink;

3. Token lifecycle: ambil, kirim ke backend, pantau perubahan

Future<void> initFcmToken({required Future<void> Function(String token) onToken}) async {
  // 1. Ambil token saat ini dan kirim ke backend.
  final token = await FirebaseMessaging.instance.getToken();
  if (token != null) await onToken(token);

  // 2. Token bisa berubah (reinstall, clear data, rotasi keamanan).
  //    Listener ini WAJIB ada, jika tidak backend menyimpan token basi.
  FirebaseMessaging.instance.onTokenRefresh.listen(onToken);

  // 3. Langganan topik kampus (mis. semua mahasiswa angkatan).
  await FirebaseMessaging.instance.subscribeToTopic('pengumuman-kampus');
}

Contoh pengiriman token ke backend Anda (ganti URL dengan API kampus):

await initFcmToken(onToken: (token) async {
  await dio.post('/devices', data: {'fcm_token': token, 'platform': 'android'});
});

4. Uji kirim pertama dari Firebase Console

  1. Buka Firebase Console -> Messaging -> buat campaign notifikasi percobaan.
  2. Masukkan title dan body, targetkan aplikasi Android Anda.
  3. Kirim saat aplikasi dalam state background: banner sistem harus muncul. Klik banner: aplikasi terbuka.
  4. Catat hasilnya sebagai bukti screenshots/fcm-console-test.png.

1. Background handler wajib top-level

Handler background harus fungsi top-level (bukan method kelas) karena berjalan di isolate terpisah:

@pragma('vm:entry-point')
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
  // Jangan akses BuildContext / Riverpod di sini.
  // Tugasnya: catat / simpan ringan saja. Navigasi dilakukan saat klik.
}

void registerBackgroundHandler() {
  FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler);
}

2. Tiga handler + contoh payload gabungan

Payload yang dikirim backend (contoh JSON via FCM HTTP v1):

{
  "message": {
    "topic": "pengumuman-kampus",
    "notification": {
      "title": "Jadwal kuliah berubah",
      "body": "Kelas Mobile pindah ke Ruang A2 jam 13.00"
    },
    "data": {
      "route": "/pengumuman/3",
      "id": "3"
    }
  }
}
void listenForeground(void Function(String route) go) {
  // Foreground: sistem TIDAK menampilkan banner otomatis,
  // jadi tampilkan manual via local notification.
  FirebaseMessaging.onMessage.listen((message) async {
    final route = message.data['route'] ?? '/';
    const androidDetails = AndroidNotificationDetails(
      'pengumuman', 'Pengumuman Kampus',
      importance: Importance.high, priority: Priority.high,
    );
    await _local.show(
      message.hashCode,
      message.notification?.title ?? 'Pengumuman',
      message.notification?.body ?? '',
      const NotificationDetails(android: androidDetails),
      payload: route,
    );
  });

  // Background -> diklik.
  FirebaseMessaging.onMessageOpenedApp.listen((message) {
    go(message.data['route'] ?? '/');
  });
}

Future<void> handleTerminated(void Function(String route) go) async {
  // Terminated -> dibuka dari notifikasi.
  final initial = await FirebaseMessaging.instance.getInitialMessage();
  if (initial != null) go(initial.data['route'] ?? '/');
  if (pendingDeepLink != null) go(pendingDeepLink!);
}

3. Matriks pengujian wajib

Uji ketiga state dengan payload yang sama dan isi tabel ini di README:

StateYang diharapkanCara uji
ForegroundBanner lokal muncul, klik masuk ke /pengumuman/3Aplikasi terbuka, kirim dari console/backend
BackgroundBanner sistem muncul, klik masuk ke rute yang benarTekan Home, kirim, klik banner
TerminatedAplikasi terbuka ke rute yang benar via getInitialMessageSwipe-close aplikasi, kirim, klik banner

4. Topic messaging

// Berlangganan / berhenti dari kode:
await FirebaseMessaging.instance.subscribeToTopic('pengumuman-kampus');
await FirebaseMessaging.instance.unsubscribeFromTopic('pengumuman-kampus');

Aturan topik: nama tanpa spasi, gunakan untuk broadcast (semua mahasiswa, satu kelas, satu UKM). Untuk pesan personal (nilai, tagihan) selalu gunakan token perangkat, bukan topik.

Peran AI pada codelab ini

AI boleh membuat draf awal service FCM dan boilerplate auth, tetapi Anda yang membuktikan perilakunya. Bug FCM yang paling mahal (token basi, klik nyasar, banner ganda) tidak terlihat dari membaca kode saja.

AI Prompt Challenge

Minta AI coding assistant dengan prompt berikut:

Aplikasi Flutter Campus Notification App.
Stack: firebase_messaging, flutter_local_notifications,
flutter_secure_storage, go_router, Riverpod.
Buatkan PushService dengan:
- requestPermission + getToken + onTokenRefresh (kirim ke POST /devices)
- onMessage (tampilkan local notification manual)
- onMessageOpenedApp + getInitialMessage (navigasi ke data.route)
- subscribe/unsubscribe topic pengumuman-kampus
- background handler top-level dengan @pragma('vm:entry-point')
Tandai bagian yang BERBEDA untuk Android 13+ vs iOS,
dan bagian yang tidak boleh mengakses BuildContext.

AI Verification Checklist

Sebelum draf AI diterima, verifikasi dan catat temuan di README/docs:

Refactoring Challenge

  1. Pindahkan semua string rute (/login, /pengumuman/:id) ke satu file lib/routes.dart agar deep link dari FCM dan GoRouter memakai konstanta yang sama.
  2. Ekstrak parsing RemoteMessage -> route ke fungsi murni routeFromMessage(Map<String, dynamic> data) agar bisa diunit-test tanpa Firebase.
  3. Pindahkan pemetaan DioException -> pesan ramah pengguna (401, timeout, offline) ke lib/data/api_errors.dart agar UI hanya menerima pesan, bukan exception mentah.

Testing: unit test tanpa Firebase sungguhan

Buat test/auth_push_test.dart. Firebase tidak diuji langsung; yang diuji adalah logika di sekitarnya:

import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

String routeFromMessage(Map<String, String> data) {
  final route = data['route'] ?? '/';
  return route.startsWith('/') ? route : '/$route';
}

class FakeTokenStore {
  String? access;
  String? refresh;
}

void main() {
  test('routeFromMessage menangani route kosong dan tanpa slash', () {
    expect(routeFromMessage({}), '/');
    expect(routeFromMessage({'route': 'pengumuman/3'}), '/pengumuman/3');
    expect(routeFromMessage({'route': '/pengumuman/3'}), '/pengumuman/3');
  });

  test('data payload membawa id pengumuman', () {
    const data = {'route': '/pengumuman/3', 'id': '3'};
    expect(data['id'], '3');
    expect(routeFromMessage(data), '/pengumuman/3');
  });

  test('provider auth membaca status login dari token', () async {
    final store = FakeTokenStore()..access = 'mock-access';
    expect(store.access != null, isTrue);
    store.access = null;
    expect(store.access != null, isFalse);
  });

  test('refresh gagal -> sesi dibersihkan (paksa login ulang)', () async {
    final store = FakeTokenStore()..refresh = '';
    final needsLogin = (store.refresh ?? '').isEmpty;
    expect(needsLogin, isTrue);
  });
}

Jalankan:

flutter analyze
flutter test

Error umum dan solusinya

GejalaPenyebab umumSolusi
Token null di emulatorEmulator tanpa Google Play ServicesPakai emulator dengan ikon Play Store atau perangkat fisik
Banner tidak muncul saat foregroundMengandalkan banner otomatis sistemTampilkan manual via flutter_local_notifications di onMessage
Klik notifikasi tidak navigasi (terminated)getInitialMessage tidak dipanggil saat startupPanggil handleTerminated setelah router siap, teruskan data.route
401 berulang meski sudah loginInterceptor refresh tidak mengulang request / refresh ikut kedaluwarsaUlangi request sekali setelah refresh; bila gagal, clear() dan arahkan ke /login
MissingPluginException secure storage / messagingHot reload setelah tambah pluginHentikan penuh lalu flutter run ulang
Notifikasi iOS tidak munculBelum ada APNs key / capability pushKonfigurasi APNs di Firebase Console + aktifkan Push di Xcode

Checklist verifikasi mandiri

Mini project / Industry Challenge

Bangun Campus Notification App (kembangkan project codelab atau buat baru):

  1. Login (mock/Firebase Auth) dengan guard route: belum login selalu diarahkan ke /login.
  2. Token disimpan di secure storage; Dio otomatis refresh sekali saat 401 dan logout bila refresh mati.
  3. FCM terintegrasi: permission, getToken + onTokenRefresh terkirim ke backend (atau didokumentasikan endpoint POST /devices), dan subscribe topik pengumuman-kampus.
  4. Notifikasi gabungan notification + data; klik membuka /pengumuman/:id pada ketiga app state. Isi tabel pengujian foreground/background/terminated di README.
  5. Screenshot bukti (token terpotong, banner tiap state, halaman tujuan deep link) di folder screenshots/.
  6. Sertakan minimal 2 test yang lulus (parsing route + logika sesi/refresh).
  7. Kerjakan AI Challenge dan dokumentasikan prompt, output awal AI, perbaikan manual, dan alasan teknis di docs/.
  8. Push ke repository portfolio pada folder 06-week-6-authentication-security-fcm/ dengan struktur lib/, test/, docs/, README.md, dan screenshots/. README menjelaskan tujuan, fitur utama, stack teknologi, cara menjalankan, dan hasil yang dicapai.

Refleksi

Referensi pendukung