Implemented network-based authentication and secure token storage
- Replaced mock login logic in `LoginViewModel` with actual API calls using Retrofit. - Integrated `EncryptedSharedPreferences` via a new `TokenStorage` class to securely persist access and refresh tokens. - Added `RetrofitProvider` and `APIService` to handle network requests and JSON serialization. - Updated `Account` entity and database schema (version 2) to include user roles, enabling destructive migration for development. - Added necessary internet permissions and allowed cleartext traffic in `AndroidManifest.xml`. - Included dependencies for Retrofit, OkHttp logging, and AndroidX Security.
This commit is contained in:
@@ -65,4 +65,12 @@ dependencies {
|
||||
val room_version = "2.8.4"
|
||||
implementation("androidx.room:room-runtime:$room_version")
|
||||
ksp("androidx.room:room-compiler:$room_version")
|
||||
|
||||
// Retrofit + Gson
|
||||
implementation("com.squareup.retrofit2:retrofit:2.9.0")
|
||||
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
|
||||
// OkHttp logging (debug)
|
||||
implementation("com.squareup.okhttp3:logging-interceptor:4.9.3")
|
||||
// AndroidX Security (EncryptedSharedPreferences)
|
||||
implementation("androidx.security:security-crypto:1.1.0-alpha03")
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
@@ -10,6 +12,7 @@
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:theme="@style/Theme.ShapPlanner">
|
||||
<activity
|
||||
android:name=".activities.MainActivity"
|
||||
|
||||
39
app/src/main/java/de/miaurizius/shap_planner/TokenStorage.kt
Normal file
39
app/src/main/java/de/miaurizius/shap_planner/TokenStorage.kt
Normal file
@@ -0,0 +1,39 @@
|
||||
package de.miaurizius.shap_planner
|
||||
|
||||
import android.content.Context
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
|
||||
class TokenStorage(context: Context) {
|
||||
|
||||
private val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
|
||||
private val prefs = EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"wg_token_prefs",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
|
||||
fun saveTokens(accountId: String, accessToken: String, refreshToken: String) {
|
||||
prefs.edit()
|
||||
.putString("access_$accountId", accessToken)
|
||||
.putString("refresh_$accountId", refreshToken)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun getAccess(accountId: String): String? = prefs.getString("access_$accountId", null)
|
||||
fun getRefresh(accountId: String): String? = prefs.getString("refresh_$accountId", null)
|
||||
|
||||
fun clearTokens(accountId: String) {
|
||||
prefs.edit()
|
||||
.remove("access_$accountId")
|
||||
.remove("refresh_$accountId")
|
||||
.apply()
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -53,7 +53,7 @@ class MainActivity : ComponentActivity() {
|
||||
// enableEdgeToEdge()
|
||||
|
||||
val prefs = UserPreferences(this)
|
||||
val loginViewModel = LoginViewModel(prefs)
|
||||
val loginViewModel = LoginViewModel(prefs, applicationContext)
|
||||
|
||||
val database = AppDatabase.getDatabase(applicationContext)
|
||||
val dao = database.accountDao()
|
||||
|
||||
@@ -17,6 +17,7 @@ data class Account (
|
||||
val wgName: String,
|
||||
val avatarUrl: String? = null,
|
||||
val serverUrl: String,
|
||||
val role: String,
|
||||
)
|
||||
|
||||
@Dao
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package de.miaurizius.shap_planner.network
|
||||
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.POST
|
||||
|
||||
data class LoginRequest(val username: String, val password: String)
|
||||
data class LoginUser(val id: String, val username: String, val role: String, val avatarUrl: String?)
|
||||
data class LoginResponse(val access_token: String, val refresh_token: String, val user: LoginUser, val wgName: String)
|
||||
|
||||
interface APIService {
|
||||
@POST("api/login")
|
||||
suspend fun login(@Body req: LoginRequest): Response<LoginResponse>
|
||||
|
||||
@POST("api/refresh")
|
||||
suspend fun refresh(@Body req: Map<String, String>): Response<Map<String, String>>
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package de.miaurizius.shap_planner.network
|
||||
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
|
||||
object RetrofitProvider {
|
||||
|
||||
fun create(serverUrl: String): APIService {
|
||||
val base = if (serverUrl.endsWith("/")) serverUrl else "$serverUrl/"
|
||||
|
||||
val logger = HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC }
|
||||
|
||||
val client = OkHttpClient.Builder()
|
||||
.addInterceptor(logger)
|
||||
.build()
|
||||
|
||||
val retrofit = Retrofit.Builder()
|
||||
.baseUrl(base)
|
||||
.client(client)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
|
||||
return retrofit.create(APIService::class.java)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import androidx.room.RoomDatabase
|
||||
import de.miaurizius.shap_planner.entities.Account
|
||||
import de.miaurizius.shap_planner.entities.AccountDao
|
||||
|
||||
@Database(entities = [Account::class], version = 1)
|
||||
@Database(entities = [Account::class], version = 2)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun accountDao(): AccountDao
|
||||
companion object {
|
||||
@@ -20,7 +20,7 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
context.applicationContext,
|
||||
AppDatabase::class.java,
|
||||
"shap_planner_database"
|
||||
).build()
|
||||
).fallbackToDestructiveMigration(true).build()
|
||||
INSTANCE = instance
|
||||
instance
|
||||
}
|
||||
|
||||
@@ -1,25 +1,65 @@
|
||||
package de.miaurizius.shap_planner.viewmodels
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import de.miaurizius.shap_planner.TokenStorage
|
||||
import de.miaurizius.shap_planner.UserPreferences
|
||||
import de.miaurizius.shap_planner.entities.Account
|
||||
import de.miaurizius.shap_planner.network.LoginRequest
|
||||
import de.miaurizius.shap_planner.network.RetrofitProvider
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.UUID
|
||||
|
||||
class LoginViewModel(private val prefs: UserPreferences) : ViewModel() {
|
||||
class LoginViewModel(private val prefs: UserPreferences, private val appContext: Context) : ViewModel() {
|
||||
|
||||
private val tokenStorage = TokenStorage(appContext)
|
||||
|
||||
val isLoggedIn = prefs.isLoggedInFlow.stateIn(viewModelScope, SharingStarted.Lazily, false)
|
||||
val lastUserId = prefs.lastUserLoginFlow.stateIn(viewModelScope, SharingStarted.Lazily, null)
|
||||
|
||||
fun login(serverUrl: String, username: String, password: String, viewModel: MainViewModel) {
|
||||
val uuid = UUID.randomUUID();
|
||||
val acc = Account(uuid, username, "Pfadi-WG", null, serverUrl) //TODO: get data from backend
|
||||
viewModel.addAccount(acc)
|
||||
println("Logged in as ${username} in ${serverUrl}")
|
||||
viewModelScope.launch { prefs.saveLogin(uuid.toString()) }
|
||||
viewModelScope.launch {
|
||||
val api = RetrofitProvider.create(serverUrl)
|
||||
|
||||
try {
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
api.login(LoginRequest(username, password))
|
||||
}
|
||||
|
||||
if(response.isSuccessful) {
|
||||
val body = response.body() ?: run {
|
||||
return@launch
|
||||
}
|
||||
|
||||
val access = body.access_token
|
||||
val refresh = body.refresh_token
|
||||
|
||||
tokenStorage.saveTokens(body.user.id, access, refresh)
|
||||
|
||||
val account = Account(
|
||||
id = UUID.fromString(body.user.id),
|
||||
name = username,
|
||||
wgName = body.wgName,
|
||||
avatarUrl = null,
|
||||
serverUrl = serverUrl,
|
||||
role = body.user.role
|
||||
)
|
||||
|
||||
viewModel.addAccount(account)
|
||||
|
||||
prefs.saveLogin(body.user.id)
|
||||
} else {
|
||||
println("Login failed: ${response.code()} ${response.errorBody()?.toString()}")
|
||||
}
|
||||
} catch(e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
|
||||
Reference in New Issue
Block a user