Dev.to Security 🔐 Cybersecurity 👁 0 📖 8 min read

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

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

  1. Membuat aplikasi Flutter yang bisa autentikasi (daftar, masuk, keluar) memakai Supabase Auth.
  2. Menerapkan otorisasi berbasis peran (RBAC) di mana aturannya dipegang oleh database, bukan oleh UI.
  3. 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

  1. Buat project baru di supabase.com.
  2. Buka Project Settings → API Keys, salin Project URL dan anon (publishable) key.
  3. Catat: service_role key 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:

Gambar 1 — Halaman setup kredensial Supabase yang menampilkan form Project URL, anon key, dan panduan pengambilan kredensial

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 mencakup anon.
  • 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.

Gambar 2 — Halaman login dengan form email, password, tombol MASUK, dan catatan verifikasi email

Gambar 3 — Halaman daftar akun baru dengan empat input dan tombol BUAT AKUN

5. Hasil Pengamatan

5.1 Masuk sebagai role user

Gambar 4 — Dashboard role user: profil, badge ROLE: USER, dan kartu HAK AKSES yang menandai "Lihat semua pengguna", "Ubah role pengguna", dan "Akses panel admin" sebagai ditolak

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 profiles langsung lewat REST, hasilnya hanya baris sendiri (bukan error).

5.2 Masuk sebagai role admin

Gambar 5 — Dashboard role admin: badge ROLE: ADMIN dan seluruh lima izin bertanda centang, plus tombol PANEL ADMIN di bawah

Pengamatan:

  • Badge berubah menjadi ROLE: ADMIN setelah 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

Gambar 6 — Panel admin: daftar 5 pengguna dengan tombol JADIKAN ADMIN / JADIKAN USER per baris

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

  1. Autentikasi Supabase berjalan normal di Flutter: daftar, masuk, keluar, dan sesi tersimpan otomatis.
  2. RBAC berhasil diterapkan: user hanya melihat dirinya; admin melihat semua pengguna dan bisa mengganti role.
  3. Izin ditetapkan oleh RLS + trigger di database, sehingga aman walau UI disontek.
  4. Kolom role harus dikunci terpisah (column grant atau trigger), karena policy kepemilikan baris tidak menutup celah self-promotion.
  5. 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

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.