📋 Daftar Isi
1. Autentikasi Mobile
Autentikasi mobile memiliki tantangan unik: perangkat bisa hilang/dicuri, jaringan tidak stabil, dan user menginginkan login yang cepat. Artikel ini membahas pola autentikasi modern untuk mobile.
Mobile Auth Flow
User Login
Credential
Biometric
Biometric
→
Auth Server
OAuth 2.0 / OIDC
Token issuance
Token issuance
→
Token Storage
Secure storage
Keychain / Keystore
Keychain / Keystore
2. OAuth 2.0 + PKCE
PKCE (Proof Key for Code Exchange) adalah ekstensi OAuth 2.0 yang wajib untuk mobile apps. PKCE mencegah serangan authorization code interception.
Kotlin — OAuth PKCE Flow
class OAuthManager(
private val clientId: String,
private val redirectUri: String,
private val authEndpoint: String,
private val tokenEndpoint: String,
) {
// Generate PKCE parameters
fun generatePKCE(): PKCEParams {
// Code verifier: random string 43-128 chars
val codeVerifier = generateRandomString(128)
// Code challenge: SHA256 of verifier, base64url encoded
val codeChallenge = base64UrlEncode(
sha256(codeVerifier.toByteArray())
)
return PKCEParams(
codeVerifier = codeVerifier,
codeChallenge = codeChallenge,
codeChallengeMethod = "S256"
)
}
// Step 1: Build authorization URL
fun buildAuthUrl(pkce: PKCEParams): String {
return Uri.parse(authEndpoint).buildUpon()
.appendQueryParameter("response_type", "code")
.appendQueryParameter("client_id", clientId)
.appendQueryParameter("redirect_uri", redirectUri)
.appendQueryParameter("code_challenge", pkce.codeChallenge)
.appendQueryParameter("code_challenge_method", pkce.codeChallengeMethod)
.appendQueryParameter("state", generateRandomString(32))
.appendQueryParameter("scope", "openid profile email")
.build().toString()
}
// Step 2: Exchange code for token
suspend fun exchangeCode(
authorizationCode: String,
codeVerifier: String
): TokenResponse {
return httpClient.post(tokenEndpoint) {
parameter("grant_type", "authorization_code")
parameter("code", authorizationCode)
parameter("redirect_uri", redirectUri)
parameter("client_id", clientId)
parameter("code_verifier", codeVerifier) // PKCE!
}.body()
}
}
Swift — ASWebAuthenticationSession (iOS)
import AuthenticationServices
class OAuthService {
func login() async throws -> TokenResponse {
let pkce = generatePKCE()
let authURL = buildAuthURL(pkce: pkce)
// Present system auth sheet
let code = try await withCheckedThrowingContinuation { cont in
let session = ASWebAuthenticationSession(
url: authURL,
callbackURLScheme: "myapp"
) { callbackURL, error in
if let error { cont.resume(throwing: error); return }
guard let code = callbackURL?.queryValue("code") else {
cont.resume(throwing: AuthError.noCode)
return
}
cont.resume(returning: code)
}
session.presentationContextProvider = self
session.prefersEphemeralWebBrowserSession = true
session.start()
}
// Exchange code for tokens
return try await exchangeCode(code: code, verifier: pkce.codeVerifier)
}
}
3. Token Storage
Kotlin — Android Secure Storage
// Menggunakan EncryptedSharedPreferences
class SecureTokenStorage(context: Context) {
private val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val prefs = EncryptedSharedPreferences.create(
context,
"secure_tokens",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
fun saveTokens(accessToken: String, refreshToken: String) {
prefs.edit()
.putString("access_token", accessToken)
.putString("refresh_token", refreshToken)
.putLong("saved_at", System.currentTimeMillis())
.apply()
}
fun getAccessToken(): String? = prefs.getString("access_token", null)
fun getRefreshToken(): String? = prefs.getString("refresh_token", null)
fun clearTokens() {
prefs.edit().clear().apply()
}
}
Swift — iOS Keychain
import Security
class KeychainService {
func save(key: String, value: String) throws {
guard let data = value.data(using: .utf8) else { return }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
]
SecItemDelete(query as CFDictionary) // Remove existing
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError.saveFailed(status)
}
}
func get(key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else {
return nil
}
return String(data: data, encoding: .utf8)
}
func delete(key: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
]
SecItemDelete(query as CFDictionary)
}
}
3.1 Perbandingan Storage
| Storage | Enkripsi | Platform | Aman? |
|---|---|---|---|
| SharedPreferences / UserDefaults | Tidak | Keduanya | ❌ Tidak |
| EncryptedSharedPreferences | AES-256 | Android | ✅ Ya |
| Keychain | Hardware-backed | iOS | ✅ Ya |
| Flutter Secure Storage | Platform-level | Keduanya | ✅ Ya |
| SQLite (unencrypted) | Tidak | Keduanya | ❌ Tidak |
| SQLCipher | AES-256 | Keduanya | ✅ Ya |
⚠️ Jangan Simpan Token di SharedPreferences Biasa
SharedPreferences dan UserDefaults TIDAK terenkripsi. Siapa pun dengan akses device bisa membaca token. Selalu gunakan Keychain (iOS) atau EncryptedSharedPreferences (Android).
4. Biometric Authentication
Kotlin — BiometricPrompt (Android)
import androidx.biometric.BiometricPrompt
import androidx.biometric.BiometricManager
class BiometricAuthManager(private val activity: FragmentActivity) {
fun isAvailable(): Boolean {
val biometricManager = BiometricManager.from(activity)
return biometricManager.canAuthenticate(
BiometricManager.Authenticators.BIOMETRIC_STRONG
) == BiometricManager.BIOMETRIC_SUCCESS
}
fun authenticate(
onSuccess: () -> Unit,
onError: (String) -> Unit
) {
val executor = ContextCompat.getMainExecutor(activity)
val callback = object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result)
onSuccess()
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
super.onAuthenticationError(errorCode, errString)
onError(errString.toString())
}
}
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Autentikasi")
.setSubtitle("Verifikasi identitas Anda")
.setNegativeButtonText("Batal")
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
.build()
BiometricPrompt(activity, executor, callback)
.authenticate(promptInfo)
}
}
Swift — LAContext (iOS)
import LocalAuthentication
class BiometricService {
enum BiometricType {
case faceID, touchID, none
}
var biometricType: BiometricType {
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
return .none
}
return context.biometryType == .faceID ? .faceID : .touchID
}
func authenticate(reason: String) async throws -> Bool {
let context = LAContext()
return try await context.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: reason
)
}
}
// Penggunaan
let biometric = BiometricService()
do {
let success = try await biometric.authenticate(reason: "Login ke MyApp")
if success {
// Load token dari Keychain, auto-login
let token = keychain.get(key: "access_token")
}
} catch {
// Fallback ke password
showPasswordLogin()
}
5. Session Management
Dart — Token Refresh Interceptor
import 'package:dio/dio.dart';
class AuthInterceptor extends Interceptor {
final Dio dio;
final SecureStorage storage;
bool _isRefreshing = false;
final _queue = >[];
AuthInterceptor({required this.dio, required this.storage});
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
final token = await storage.getAccessToken();
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
if (err.response?.statusCode == 401) {
// Token expired
if (!_isRefreshing) {
_isRefreshing = true;
try {
final refreshToken = await storage.getRefreshToken();
final response = await dio.post('/auth/refresh', data: {
'refresh_token': refreshToken,
});
await storage.saveTokens(
accessToken: response.data['access_token'],
refreshToken: response.data['refresh_token'],
);
// Retry original request
err.requestOptions.headers['Authorization'] =
'Bearer ${response.data['access_token']}';
final retryResponse = await dio.fetch(err.requestOptions);
handler.resolve(retryResponse);
} catch (e) {
// Refresh failed, force logout
await storage.clearTokens();
navigatorKey.currentState?.pushReplacementNamed('/login');
handler.reject(err);
} finally {
_isRefreshing = false;
// Process queued requests
for (var completer in _queue) {
completer.complete();
}
_queue.clear();
}
} else {
// Wait for refresh to complete
final completer = Completer();
_queue.add(completer);
await completer.future;
// Retry with new token
final token = await storage.getAccessToken();
err.requestOptions.headers['Authorization'] = 'Bearer $token';
final retryResponse = await dio.fetch(err.requestOptions);
handler.resolve(retryResponse);
}
} else {
handler.next(err);
}
}
}
5.1 Token Lifecycle
| Token | Durasi | Gunakan Untuk |
|---|---|---|
| Access Token | 15-60 menit | Autentikasi API request |
| Refresh Token | 7-30 hari | Mendapatkan access token baru |
| ID Token (OIDC) | 15-60 menit | Identitas user (claims) |
| Session Token | Varies | Server-side session |
6. Social Login
Dart — Google Sign-In
import 'package:google_sign_in/google_sign_in.dart'; final GoogleSignIn _google = GoogleSignIn( scopes: ['email', 'profile'], ); FuturesignInWithGoogle() async { try { final account = await _google.signIn(); if (account == null) return; // User cancelled final auth = await account.authentication; final idToken = auth.idToken; final accessToken = auth.accessToken; // Send tokens to your backend for verification final response = await apiClient.post('/auth/google', data: { 'id_token': idToken, 'access_token': accessToken, }); // Save server-issued tokens await secureStorage.saveTokens( accessToken: response.data['access_token'], refreshToken: response.data['refresh_token'], ); navigateToHome(); } catch (e) { showError('Login gagal: $e'); } }
7. Security Best Practices
| Praktik | Mengapa |
|---|---|
| Gunakan PKCE untuk OAuth | Cegah code interception |
| Simpan token di Keychain/Keystore | Enkripsi hardware-backed |
| Implement token refresh | User tidak perlu login ulang |
| Biometric sebagai second factor | Tambah layer keamanan |
| Certificate pinning | Cegah MITM attack |
| Root/jailbreak detection | Cegah running di compromised device |
| Auto-logout setelah idle | Cegah unauthorized access |