Laporan Praktikum: Autentikasi dan Otorisasi (RBAC) di Flutter + Supabase
Laporan Praktikum: Autentikasi dan Otorisasi (RBAC) di Flutter + Supabase Nama: Muhammad Dhiyaul Atha Tanggal praktikum: 10 November 2026 Aplikasi: NEO AUTH — aplikasi contoh login/register dengan dua role (user, admin
Laporan Praktikum: Autentikasi dan Otorisasi (RBAC) di Flutter + Supabase
Nama: Muhammad Dhiyaul Atha
Tanggal praktikum: 10 November 2026
Aplikasi: NEO AUTH — aplikasi contoh login/register dengan dua role (user, admin)
1. Tujuan
- Membuat aplikasi Flutter yang bisa autentikasi (daftar, masuk, keluar) memakai Supabase Auth.
- Menerapkan otorisasi berbasis peran (RBAC) di mana aturannya dipegang oleh database, bukan oleh UI.
- Menguji bahwa user biasa tidak bisa membaca semua data dan tidak bisa menaikkan role-nya sendiri, walau memanggil API secara langsung.
2. Alat dan Bahan
| No | Bahan | Keterangan |
|---|---|---|
| 1 | Flutter 3.47.2 / Dart 3.13.2 | flutter --version |
| 2 | Paket supabase_flutter
|
klien resmi Supabase untuk Flutter |
| 3 | Paket shared_preferences
|
menyimpan kredensial project |
| 4 | Project Supabase | memberikan Project URL + anon (publishable) key |
| 5 | SQL Editor Supabase | tempat menjalankan supabase/schema.sql
|
| 6 | Editor teks / VS Code | menulis kode Dart dan SQL |
Struktur berkas yang dibuat:
lib/
├── main.dart # bootstrap Supabase + route awal
├── core/app_config.dart # simpan/muat kredensial
├── core/supabase_config.dart # konfigurasi compile-time (dart-define)
├── core/app_theme.dart # tema neo brutalism
├── models/profile.dart # model profil + role
├── services/auth_service.dart # semua akses Auth & tabel profiles
├── widgets/neo_button.dart # tombol neo brutalism
├── widgets/neo_widgets.dart # kartu, input, latar, loader
└── pages/
├── setup_page.dart # input kredensial Supabase
├── auth_gate.dart # penentu halaman berdasarkan sesi
├── login_page.dart
├── register_page.dart
├── dashboard_page.dart
└── admin_page.dart
supabase/schema.sql # tabel, trigger, policy RLS
3. Dasar Teori (singkat)
- Autentikasi = memastikan kamu siapa (login).
- Otorisasi = memastikan kamu boleh apa (role).
- Row Level Security (RLS) = aturan di PostgreSQL/Supabase yang memfilter baris yang boleh dibaca/diubah berdasarkan user yang membawa JWT.
- RBAC = keputusan izin ditentukan role yang tersimpan di database.
Prinsip yang dipakai sepanjang praktikum: menyembunyikan tombol di UI hanya untuk kenyamanan; keamanan harus ada di database.
4. Langkah Kerja
Langkah 1 — Buat project Flutter dan pasang dependensi
flutter create auth
cd auth
flutter pub add supabase_flutter shared_preferences
Langkah 2 — Buat project Supabase dan ambil kredensial
- Buat project baru di supabase.com.
- Buka Project Settings → API Keys, salin Project URL dan anon (publishable) key.
- Catat:
service_rolekey tidak pernah boleh masuk ke aplikasi.
Kredensial bisa dimasukkan lewat --dart-define-from-file=env.json (file ini saya masukkan ke .gitignore), atau lewat halaman setup bawaan aplikasi:
flutter run --dart-define-from-file=env.json
{
"SUPABASE_URL": "https://xxxx.supabase.co",
"SUPABASE_ANON_KEY": "eyJhbGciOi..."
}
Halaman setup juga menampilkan panduan ini di aplikasi:
Langkah 3 — Buat skema database (supabase/schema.sql)
Role disimpan sebagai enum supaya nilai di luar user/admin tidak bisa masuk:
create type public.user_role as enum ('user', 'admin');
create table public.profiles (
id uuid primary key references auth.users (id) on delete cascade,
email text,
full_name text,
role public.user_role not null default 'user',
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
alter table public.profiles enable row level security;
Fungsi pembantu is_admin() dibuat security definer supaya bisa dipanggil di dalam policy tanpa memicu rekursi policy pada tabel yang sama:
create or replace function public.is_admin(uid uuid)
returns boolean
language sql
security definer
set search_path = public
stable
as $$
select exists (
select 1 from public.profiles
where id = uid and role = 'admin'
);
$$;
Langkah 4 — Profil dibuat otomatis saat signup
Agar aplikasi tidak perlu insert manual, auth.users diberi trigger:
create or replace function public.handle_new_user()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
insert into public.profiles (id, email, full_name)
values (
new.id,
new.email,
coalesce(
nullif(new.raw_user_meta_data ->> 'full_name', ''),
split_part(new.email, '@', 1)
)
)
on conflict (id) do nothing;
return new;
end;
$$;
create trigger on_auth_user_created
after insert on auth.users
for each row execute function public.handle_new_user();
Langkah 5 — Tulis policy RLS
create policy "profiles_select_own_or_admin"
on public.profiles for select to authenticated
using (id = auth.uid() or public.is_admin(auth.uid()));
create policy "profiles_insert_own"
on public.profiles for insert to authenticated
with check (id = auth.uid() and role = 'user');
create policy "profiles_update_own"
on public.profiles for update to authenticated
using (id = auth.uid())
with check (id = auth.uid());
create policy "profiles_update_admin"
on public.profiles for update to authenticated
using (public.is_admin(auth.uid()))
with check (public.is_admin(auth.uid()));
Dua hal penting di sini:
- Policy ditulis eksplisit
to authenticated, karena default role juga mencakupanon. -
with check (role = 'user')di insert mencegah akun baru langsung terdaftar sebagai admin.
Langkah 6 — Kunci kolom role: trigger penjaga
Policy profiles_update_own mengizinkan user mengubah barisnya sendiri, termasuk kolom role. Tanpa pengaman tambahan, user bisa memanggil update({'role': 'admin'}) dan lolos. Dua cara menutupnya:
Opsi A — column grant (paling ringkas):
revoke update on public.profiles from authenticated;
grant update (full_name) on public.profiles to authenticated;
Opsi B — trigger (yang saya pakai):
create or replace function public.guard_profile_changes()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
if new.id is distinct from old.id then
raise exception 'id profil tidak boleh diubah';
end if;
if new.role is distinct from old.role
and auth.uid() is not null
and not public.is_admin(auth.uid()) then
raise exception 'hanya admin yang boleh mengubah role';
end if;
return new;
end;
$$;
create trigger profiles_guard_changes
before update on public.profiles
for each row execute function public.guard_profile_changes();
Kondisi auth.uid() is not null diperlukan karena file ini dijalankan manual di SQL Editor, tempat di mana tidak ada JWT.
Langkah 7 — Jalankan skema di SQL Editor
Salin seluruh isi supabase/schema.sql ke SQL Editor Supabase lalu Run. Langkah ini wajib: tanpa tabel profiles + policy, aplikasi akan menampilkan pesan "Profil tidak ditemukan".
Langkah 8 — Implementasi layanan auth di Dart (lib/services/auth_service.dart)
class AuthService {
SupabaseClient get _client => Supabase.instance.client;
Future<AuthResponse> signUp({
required String email,
required String password,
required String fullName,
}) {
return _client.auth.signUp(
email: email,
password: password,
data: {'full_name': fullName},
);
}
Future<Profile?> fetchProfile(String userId) async {
final data = await _client
.from('profiles')
.select()
.eq('id', userId)
.maybeSingle();
if (data == null) return null;
return Profile.fromMap(Map<String, dynamic>.from(data));
}
Future<void> updateRole(String userId, UserRole role) {
return _client
.from('profiles')
.update({'role': role.name})
.eq('id', userId);
}
}
Karena profil dibuat oleh trigger, ada jeda antara signUp selesai dan baris profil muncul. Ditangani dengan retry:
Future<Profile?> fetchProfileWithRetry(String userId) async {
for (var attempt = 0; attempt < 5; attempt++) {
final profile = await fetchProfile(userId);
if (profile != null) return profile;
await Future<void>.delayed(Duration(milliseconds: 400 * (attempt + 1)));
}
return null;
}
Langkah 9 — Auth gate: penentu halaman (lib/pages/auth_gate.dart)
@override
void initState() {
super.initState();
_subscription = _auth.onAuthStateChange.listen(
(state) => _sync(state.session?.user),
);
_sync(_auth.currentUser);
}
Logika build-nya: masih loading → layar loader; ada error → kartu error + tombol coba lagi; belum login → LoginPage; sudah login → DashboardPage. updateRole admin memakai query yang sama dengan user biasa — pembeda satu-satunya adalah hasil is_admin() di database.
Langkah 10 — UI neo brutalism (lib/widgets/neo_button.dart)
child: Transform.translate(
offset: Offset(pressed ? 5.0 : 0.0, pressed ? 5.0 : 0.0),
child: Container(
decoration: BoxDecoration(
color: _enabled ? widget.color : NeoColors.grey,
border: Border.all(color: NeoColors.ink, width: 3),
boxShadow: pressed
? const []
: const [
BoxShadow(
color: NeoColors.ink,
offset: Offset(5, 5),
blurRadius: 0,
),
],
),
),
),
Kuncinya blurRadius: 0 (shadow keras tanpa gradasi) dan offset yang berkurang menjadi 0 saat tombol ditekan — rasanya seperti tombol benar-benar ditekan.
Langkah 11 — Jalankan dan uji alurnya
flutter analyze # tidak ada error
flutter test # 6 test lulus
flutter build web --release
Alur uji: buka aplikasi → halaman login → tombol DAFTAR → isi form → BUAT AKUN → dashboard → ubah role menjadi admin → reload → buka PANEL ADMIN.
5. Hasil Pengamatan
5.1 Masuk sebagai role user
Pengamatan:
- Badge menunjukkan
ROLE: USER. - Kartu "HAK AKSES" menandai tiga izin sebagai ditolak (tanda ×), termasuk akses panel admin.
- Tombol PANEL ADMIN tidak muncul di layar.
- Jika memanggil
select * from profileslangsung lewat REST, hasilnya hanya baris sendiri (bukan error).
5.2 Masuk sebagai role admin
Pengamatan:
- Badge berubah menjadi
ROLE: ADMINsetelah role diubah di database, lalu aplikasi di-reload. - Seluruh lima izin bertanda centang.
- Tombol PANEL ADMIN muncul di bawah kartu izin.
5.3 Panel admin
Pengamatan:
- Panel menampilkan 5 pengguna dengan tombol ganti role per baris.
- Menekan JADIKAN ADMIN hanya memanggil
update('role')biasa — tidak ada endpoint khusus; yang menentukan lolos atau tidak adalah policy + trigger di database. - Untuk user non-admin, query yang sama menghasilkan array kosong, bukan pesan error (select difilter, bukan ditolak).
Screenshots di atas diambil dari build web rilis aplikasi yang berjalan di browser; data yang tampil adalah data demo.
6. Pembahasan
1. Menyembunyikan tombol bukan otorisasi. Tombol PANEL ADMIN hanya tidak dirender untuk user. Kalau API dipanggil langsung dengan anon key, yang jadi penentu adalah policy RLS di database. UI hanya soal UX.
2. Kepemilikan baris ≠ batas kolom. Policy id = auth.uid() mengizinkan user mengubah seluruh baris miliknya, termasuk role. Celah ini ditutup lewat column grant (Langkah 6, Opsi A) atau trigger guard_profile_changes (Opsi B).
3. SQL Editor bukan alat uji policy. Query di dashboard berjalan sebagai postgres, sedangkan owner tabel melewati row security. Akibatnya semua "terlihat berhasil" di editor. Untuk menguji yang sebenarnya, ubah role dropdown editor ke authenticated lalu pilih user-nya.
4. Gejala RLS yang terselubung. Ketika sesi hilang atau auth.uid() null, insert gagal dengan kode 42501, sedangkan select diam-diam mengembalikan kosong tanpa error. Di aplikasi, kode 42501 saya petakan jadi pesan yang mudah dibaca:
if (error is PostgrestException) {
if (error.code == '42501') {
return 'Akses ditolak oleh Row Level Security.';
}
return error.message;
}
5. Trigger signup itu eventual. Profil dibuat tepat setelah row auth.users masuk, sehingga ada jeda kecil. Retry di Langkah 8 dibuat khusus untuk itu.
7. Kesimpulan
- Autentikasi Supabase berjalan normal di Flutter: daftar, masuk, keluar, dan sesi tersimpan otomatis.
- RBAC berhasil diterapkan:
userhanya melihat dirinya;adminmelihat semua pengguna dan bisa mengganti role. - Izin ditetapkan oleh RLS + trigger di database, sehingga aman walau UI disontek.
- Kolom
roleharus dikunci terpisah (column grant atau trigger), karena policy kepemilikan baris tidak menutup celah self-promotion. - SQL Editor tidak bisa dipakai untuk menguji policy; pengujian harus sebagai
authenticated.
Status pengujian: flutter analyze bersih, 6 test lulus, flutter build web --release sukses.
8. Daftar Pustaka
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.




