Android Kotlin #11: Autentikasi – Login dan Menyimpan Token JWT


Android Kotlin #11: Autentikasi – Login dan Menyimpan Token JWT

Halo lagi, calon developer aplikasi wisata! 

Sampai saat ini, aplikasi kita bisa melakukan operasi CRUD dengan bebas. Tapi di dunia nyata, tidak semua orang boleh sembarangan nambah, edit, atau hapus data. Di API kita, endpoint-endpoint tertentu (POST, PUT, DELETE) sudah dilindungi dengan JWT (JSON Web Token). Artinya, hanya pengguna yang sudah login yang bisa mengaksesnya. Nah, sekarang kita akan menambahkan fitur login di aplikasi Android, menyimpan token yang didapat, dan menyertakannya di setiap request yang membutuhkan autentikasi.

🤣 Kenapa JWT disebut token? Karena dia ibarat tiket masuk ke area terlarang. Kalau nggak punya tiket, pulang lo! 😆

Yang Akan Kita Pelajari

  • Membuat halaman login (sederhana) dengan username dan password.
  • Memanggil endpoint login API dan mendapatkan token.
  • Menyimpan token dengan DataStore (pengganti SharedPreferences yang modern).
  • Membuat AuthInterceptor untuk menambahkan token ke setiap request Retrofit.
  • Mengarahkan pengguna ke halaman utama jika sudah login, atau ke login jika belum.

Langkah 0: Siapkan API dan Endpoint Login

API Wisata kita di seri sebelumnya sudah memiliki endpoint login: POST /api/auth/login yang menerima JSON { "username": "...", "password": "..." } dan mengembalikan token dalam format:

{
    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}

Pastikan API berjalan dan bisa diakses dari emulator (http://10.0.2.2:7000).

Langkah 1: Tambahkan Dependencies

Buka build.gradle (Module: app). Tambahkan dependency untuk DataStore dan juga interceptor (jika belum).

// DataStore
implementation "androidx.datastore:datastore-preferences:1.1.0"
// Untuk lifecyle (viewModelScope)
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0"
// Retrofit & interceptor sudah ada sebelumnya

Klik Sync Now.

Langkah 2: Buat Model untuk Login Request dan Response

Buat data class untuk login request di file baru atau di dalam file yang sama:

data class LoginRequest(
    val username: String,
    val password: String
)

data class LoginResponse(
    val token: String
)

Langkah 3: Tambahkan Endpoint Login di ApiService

Buka ApiService.kt dan tambahkan:

@POST("api/auth/login")
suspend fun login(@Body request: LoginRequest): Response<LoginResponse>

Langkah 4: Membuat TokenManager dengan DataStore

DataStore adalah cara modern menyimpan data kecil secara asynchronous. Kita akan membuat kelas TokenManager yang bertugas menyimpan dan mengambil token.

Buat file baru: TokenManager.kt

package com.example.wisataapp

import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map

private val Context.dataStore by preferencesDataStore("auth_prefs")

class TokenManager(private val context: Context) {

    companion object {
        private val TOKEN_KEY = stringPreferencesKey("jwt_token")
    }

    // Simpan token
    suspend fun saveToken(token: String) {
        context.dataStore.edit { preferences ->
            preferences[TOKEN_KEY] = token
        }
    }

    // Ambil token sebagai Flow (akan dipantau perubahannya)
    fun getToken(): Flow<String?> {
        return context.dataStore.data.map { preferences ->
            preferences[TOKEN_KEY]
        }
    }

    // Hapus token (untuk logout)
    suspend fun clearToken() {
        context.dataStore.edit { preferences ->
            preferences.remove(TOKEN_KEY)
        }
    }
}

Penjelasan:

  • preferencesDataStore membuat instance DataStore dengan nama "auth_prefs".
  • stringPreferencesKey mendefinisikan kunci untuk token.
  • saveToken menyimpan token ke DataStore (suspend function).
  • getToken mengembalikan Flow<String?> yang bisa diamati.

Langkah 5: Membuat AuthInterceptor untuk Menyisipkan Token

Kita perlu menambahkan header Authorization: Bearer <token> ke setiap request yang membutuhkan autentikasi. Cara terbaik adalah dengan Interceptor di OkHttp.

Buat file baru: AuthInterceptor.kt

package com.example.wisataapp

import okhttp3.Interceptor
import okhttp3.Response
import kotlinx.coroutines.runBlocking

class AuthInterceptor(private val tokenManager: TokenManager) : Interceptor {

    override fun intercept(chain: Interceptor.Chain): Response {
        val request = chain.request()

        // Ambil token secara blocking (karena interceptor berjalan di background thread)
        val token = runBlocking { tokenManager.getToken().firstOrNull() }

        // Jika token ada, tambahkan header Authorization
        val newRequest = if (!token.isNullOrBlank()) {
            request.newBuilder()
                .addHeader("Authorization", "Bearer $token")
                .build()
        } else {
            request
        }

        return chain.proceed(newRequest)
    }
}

Perhatikan: kita gunakan runBlocking untuk mengambil token dari Flow karena interceptor berjalan di thread background dan tidak mendukung suspend. Ini aman karena DataStore operasinya cepat.

Langkah 6: Update RetrofitInstance dengan Interceptor

Sekarang kita perlu mengintegrasikan interceptor ke dalam OkHttpClient yang digunakan Retrofit. Modifikasi RetrofitInstance.kt:

package com.example.wisataapp

import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory

object RetrofitInstance {

    private const val BASE_URL = "http://10.0.2.2:7000/"

    // Buat instance TokenManager (perlu context, akan di-set dari Application)
    lateinit var tokenManager: TokenManager

    private val loggingInterceptor = HttpLoggingInterceptor().apply {
        level = HttpLoggingInterceptor.Level.BODY
    }

    private val client: OkHttpClient by lazy {
        OkHttpClient.Builder()
            .addInterceptor(loggingInterceptor)
            .addInterceptor(AuthInterceptor(tokenManager)) // tambahkan interceptor auth
            .build()
    }

    val api: ApiService by lazy {
        Retrofit.Builder()
            .baseUrl(BASE_URL)
            .client(client)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
            .create(ApiService::class.java)
    }
}

Perhatikan bahwa tokenManager harus diinisialisasi sebelum digunakan. Kita akan inisialisasi di Application class.

Langkah 7: Membuat Aplikasi Class untuk Inisialisasi Global

Buat class baru: WisataApplication.kt (atau MyApp) yang extends Application. Di sini kita inisialisasi TokenManager dan set ke RetrofitInstance.

package com.example.wisataapp

import android.app.Application

class WisataApplication : Application() {

    override fun onCreate() {
        super.onCreate()

        // Inisialisasi TokenManager
        val tokenManager = TokenManager(applicationContext)
        RetrofitInstance.tokenManager = tokenManager
    }
}

Jangan lupa daftarkan aplikasi ini di AndroidManifest.xml dengan menambahkan atribut android:name=".WisataApplication" di tag <application>.

Langkah 8: Membuat Halaman Login (Activity)

Buat activity baru: LoginActivity.kt dan layout activity_login.xml. Juga buat ViewModel untuk login.

activity_login.xml sederhana:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:padding="16dp">

    <EditText
        android:id="@+id/editUsername"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="Username"
        android:inputType="text" />

    <EditText
        android:id="@+id/editPassword"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="Password"
        android:inputType="textPassword"
        android:layout_marginTop="8dp" />

    <Button
        android:id="@+id/btnLogin"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Login"
        android:layout_marginTop="16dp" />

    <ProgressBar
        android:id="@+id/progressBar"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:layout_marginTop="16dp"
        android:visibility="gone" />

</LinearLayout>

LoginViewModel.kt:

package com.example.wisataapp

import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch

class LoginViewModel : ViewModel() {

    private val _isLoading = MutableLiveData<Boolean>()
    val isLoading: LiveData<Boolean> get() = _isLoading

    private val _errorMessage = MutableLiveData<String?>()
    val errorMessage: LiveData<String?> get() = _errorMessage

    private val _loginSuccess = MutableLiveData<String?>() // token
    val loginSuccess: LiveData<String?> get() = _loginSuccess

    fun login(username: String, password: String) {
        viewModelScope.launch {
            _isLoading.value = true
            try {
                val response = RetrofitInstance.api.login(LoginRequest(username, password))
                if (response.isSuccessful) {
                    val token = response.body()?.token
                    if (token != null) {
                        _loginSuccess.value = token
                    } else {
                        _errorMessage.value = "Token tidak ditemukan"
                    }
                } else {
                    _errorMessage.value = "Login gagal: ${response.code()}"
                }
            } catch (e: Exception) {
                _errorMessage.value = "Error: ${e.message}"
            } finally {
                _isLoading.value = false
            }
        }
    }
}

LoginActivity.kt:

package com.example.wisataapp

import android.content.Intent
import android.os.Bundle
import android.widget.Button
import android.widget.EditText
import android.widget.ProgressBar
import android.widget.Toast
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.Observer

class LoginActivity : AppCompatActivity() {

    private lateinit var editUsername: EditText
    private lateinit var editPassword: EditText
    private lateinit var btnLogin: Button
    private lateinit var progressBar: ProgressBar

    private val loginViewModel: LoginViewModel by viewModels()
    private lateinit var tokenManager: TokenManager

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_login)

        editUsername = findViewById(R.id.editUsername)
        editPassword = findViewById(R.id.editPassword)
        btnLogin = findViewById(R.id.btnLogin)
        progressBar = findViewById(R.id.progressBar)

        tokenManager = TokenManager(this)

        // Cek apakah sudah login? Jika sudah, langsung ke MainActivity
        tokenManager.getToken().observe(this) { token ->
            if (!token.isNullOrBlank()) {
                startActivity(Intent(this, MainActivity::class.java))
                finish()
            }
        }

        btnLogin.setOnClickListener {
            val username = editUsername.text.toString().trim()
            val password = editPassword.text.toString().trim()
            if (username.isEmpty() || password.isEmpty()) {
                Toast.makeText(this, "Username dan password harus diisi", Toast.LENGTH_SHORT).show()
                return@setOnClickListener
            }
            loginViewModel.login(username, password)
        }

        loginViewModel.isLoading.observe(this, Observer { loading ->
            progressBar.visibility = if (loading) android.view.View.VISIBLE else android.view.View.GONE
            btnLogin.isEnabled = !loading
        })

        loginViewModel.errorMessage.observe(this, Observer { error ->
            error?.let {
                Toast.makeText(this, it, Toast.LENGTH_LONG).show()
            }
        })

        loginViewModel.loginSuccess.observe(this, Observer { token ->
            token?.let {
                // Simpan token menggunakan TokenManager
                lifecycleScope.launch {
                    tokenManager.saveToken(it)
                    // Setelah token disimpan, observer di atas akan otomatis mengarahkan ke MainActivity
                }
            }
        })
    }
}

Jangan lupa import androidx.lifecycle.lifecycleScope dan tambahkan dependency jika perlu.

Langkah 9: Mengatur Activity Awal (Launch)

Ubah AndroidManifest.xml agar LoginActivity menjadi activity pertama (launcher), dan MainActivity tidak lagi menjadi launcher.

<activity
    android:name=".LoginActivity"
    android:exported="true">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
</activity>
<activity android:name=".MainActivity" />

Langkah 10: Menambahkan Logout

Untuk melengkapi, kita bisa tambahkan tombol logout di MainActivity yang menghapus token dan kembali ke login. Misalnya di menu atau di suatu tempat.

Contoh di MainActivity tambahkan tombol logout dan fungsi:

// Di MainActivity, tambahkan tombol logout misal di toolbar atau FAB khusus
// Panggil tokenManager.clearToken() dan arahkan ke LoginActivity

lifecycleScope.launch {
    tokenManager.clearToken()
    startActivity(Intent(this@MainActivity, LoginActivity::class.java))
    finish()
}

Jangan lupa untuk mendapatkan instance TokenManager (bisa dari RetrofitInstance.tokenManager).

Langkah 11: Uji Coba

  • Jalankan API.
  • Hapus data aplikasi atau uninstall dari emulator agar token lama hilang.
  • Jalankan aplikasi. Harusnya muncul halaman login.
  • Coba login dengan username dan password yang sudah didaftarkan di API (misal joko/rahasia123). Jika berhasil, akan langsung masuk ke MainActivity.
  • Coba akses fitur tambah kategori. Karena sekarang request menyertakan token, seharusnya berhasil (tidak error 401).
  • Coba logout dan akses lagi, harus kembali ke login.
💡 Jika gagal login, periksa apakah API mengembalikan token. Cek di Logcat dengan filter "OkHttp" untuk melihat request dan response. Pastikan username dan password benar.

Selamat! Aplikasi Kamu Sekarang Punya Autentikasi

Sekarang aplikasi wisata kamu sudah aman. Hanya pengguna terdaftar yang bisa menambah, mengedit, atau menghapus data. Kamu telah mempelajari cara menyimpan token dengan DataStore, membuat interceptor, dan mengelola alur login.

Rangkuman

  • DataStore: menyimpan token dengan aman dan asinkron.
  • AuthInterceptor: menambahkan token ke setiap request Retrofit.
  • LoginActivity: menangani input user dan memanggil API login.
  • Setelah login, token disimpan dan otomatis digunakan untuk request berikutnya.
  • Logout membersihkan token dan mengembalikan ke login.

Selanjutnya?

Di artikel berikutnya (Android Kotlin #12: Upload Gambar dari Galeri ke API), kita akan menambahkan fitur upload gambar untuk tempat wisata. Kamu akan belajar memilih gambar dari galeri, kompresi, dan mengirimnya ke endpoint upload API. Sampai jumpa!

😎 Kalau tokenmu error, jangan salahkan JWT. Cek lagi interceptor-nya, mungkin tokennya nggak kebaca. Token itu ibarat kunci: kalau salah gembok, ya nggak bisa masuk!

Ditulis oleh Kakak programmer yang dulu juga sering lupa menambahkan header Authorization. Kalau ada pertanyaan, tulis di komentar ya!

Lebih baru Lebih lama

نموذج الاتصال