Terakhir diperbarui: 27 September 2026
Setelah menyelesaikan codelab ini, mahasiswa mampu:
getToken, onTokenRefresh);Tiga pola login yang umum di industri:
| Pola | Cara kerja singkat | Kapan dipakai |
|---|---|---|
Firebase Auth (email/Google) | SDK Firebase menukar kredensial menjadi ID token JWT yang diverifikasi backend | Aplikasi 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 panjang | Backend kampus/JSONPlaceholder-style milik sendiri |
OAuth (Google Login) | Aplikasi mendapat authorization code, ditukar menjadi token via provider | Login 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.
Authorization: Bearer ....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
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.
| Jenis | Isi | Perilaku sistem |
|---|---|---|
notification | title + body tampilan | Android menampilkan otomatis saat background/terminated; foreground butuh tampil manual |
data | key-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.
| State | Arti | Handler yang bekerja |
|---|---|---|
| Foreground | Aplikasi terbuka | FirebaseMessaging.onMessage (tampilkan manual via local notification) |
| Background | Aplikasi diminimize | Banner sistem otomatis + onMessageOpenedApp saat diklik |
| Terminated | Aplikasi dimatikan | getInitialMessage() saat dibuka dari notifikasi |
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
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();
}
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}';
}
}
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;
}
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'] ?? ''),
),
],
);
applicationId Anda.google-services.json ke android/app/ dan ikuti panduan FCM Flutter client (terapkan plugin google-services dan dependensi). Untuk iOS tambahkan GoogleService-Info.plist.firebase_core diinisialisasi sebelum runApp: await Firebase.initializeApp().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;
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'});
});
title dan body, targetkan aplikasi Android Anda.screenshots/fcm-console-test.png.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);
}
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!);
}
Uji ketiga state dengan payload yang sama dan isi tabel ini di README:
| State | Yang diharapkan | Cara uji |
|---|---|---|
| Foreground | Banner lokal muncul, klik masuk ke /pengumuman/3 | Aplikasi terbuka, kirim dari console/backend |
| Background | Banner sistem muncul, klik masuk ke rute yang benar | Tekan Home, kirim, klik banner |
| Terminated | Aplikasi terbuka ke rute yang benar via getInitialMessage | Swipe-close aplikasi, kirim, klik banner |
// 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.
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.
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.
Sebelum draf AI diterima, verifikasi dan catat temuan di README/docs:
@pragma('vm:entry-point')? (tolak jika berupa method kelas).onTokenRefresh benar-benar mengirim token baru ke backend, bukan hanya dicetak ke log?/login, /pengumuman/:id) ke satu file lib/routes.dart agar deep link dari FCM dan GoRouter memakai konstanta yang sama.RemoteMessage -> route ke fungsi murni routeFromMessage(Map<String, dynamic> data) agar bisa diunit-test tanpa Firebase.DioException -> pesan ramah pengguna (401, timeout, offline) ke lib/data/api_errors.dart agar UI hanya menerima pesan, bukan exception mentah.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
| Gejala | Penyebab umum | Solusi |
|---|---|---|
| Token null di emulator | Emulator tanpa Google Play Services | Pakai emulator dengan ikon Play Store atau perangkat fisik |
| Banner tidak muncul saat foreground | Mengandalkan banner otomatis sistem | Tampilkan manual via flutter_local_notifications di onMessage |
| Klik notifikasi tidak navigasi (terminated) | getInitialMessage tidak dipanggil saat startup | Panggil handleTerminated setelah router siap, teruskan data.route |
| 401 berulang meski sudah login | Interceptor refresh tidak mengulang request / refresh ikut kedaluwarsa | Ulangi request sekali setelah refresh; bila gagal, clear() dan arahkan ke /login |
MissingPluginException secure storage / messaging | Hot reload setelah tambah plugin | Hentikan penuh lalu flutter run ulang |
| Notifikasi iOS tidak muncul | Belum ada APNs key / capability push | Konfigurasi APNs di Firebase Console + aktifkan Push di Xcode |
flutter_secure_storage, tidak di SharedPreferences/log/screenshot penuh.flutter analyze bersih dan semua test lulus.Bangun Campus Notification App (kembangkan project codelab atau buat baru):
/login.getToken + onTokenRefresh terkirim ke backend (atau didokumentasikan endpoint POST /devices), dan subscribe topik pengumuman-kampus.notification + data; klik membuka /pengumuman/:id pada ketiga app state. Isi tabel pengujian foreground/background/terminated di README.screenshots/.docs/.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.onTokenRefresh diabaikan selama satu semester perkuliahan?