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:
2026-02-27 17:33:34 +01:00
parent 13d0df0864
commit 465a699b30
9 changed files with 145 additions and 9 deletions

View File

@@ -65,4 +65,12 @@ dependencies {
val room_version = "2.8.4" val room_version = "2.8.4"
implementation("androidx.room:room-runtime:$room_version") implementation("androidx.room:room-runtime:$room_version")
ksp("androidx.room:room-compiler:$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")
} }

View File

@@ -2,6 +2,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<application <application
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
@@ -10,6 +12,7 @@
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.ShapPlanner"> android:theme="@style/Theme.ShapPlanner">
<activity <activity
android:name=".activities.MainActivity" android:name=".activities.MainActivity"

View 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()
}
}

View File

@@ -53,7 +53,7 @@ class MainActivity : ComponentActivity() {
// enableEdgeToEdge() // enableEdgeToEdge()
val prefs = UserPreferences(this) val prefs = UserPreferences(this)
val loginViewModel = LoginViewModel(prefs) val loginViewModel = LoginViewModel(prefs, applicationContext)
val database = AppDatabase.getDatabase(applicationContext) val database = AppDatabase.getDatabase(applicationContext)
val dao = database.accountDao() val dao = database.accountDao()

View File

@@ -17,6 +17,7 @@ data class Account (
val wgName: String, val wgName: String,
val avatarUrl: String? = null, val avatarUrl: String? = null,
val serverUrl: String, val serverUrl: String,
val role: String,
) )
@Dao @Dao

View File

@@ -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>>
}

View File

@@ -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)
}
}

View File

@@ -7,7 +7,7 @@ import androidx.room.RoomDatabase
import de.miaurizius.shap_planner.entities.Account import de.miaurizius.shap_planner.entities.Account
import de.miaurizius.shap_planner.entities.AccountDao import de.miaurizius.shap_planner.entities.AccountDao
@Database(entities = [Account::class], version = 1) @Database(entities = [Account::class], version = 2)
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
abstract fun accountDao(): AccountDao abstract fun accountDao(): AccountDao
companion object { companion object {
@@ -20,7 +20,7 @@ abstract class AppDatabase : RoomDatabase() {
context.applicationContext, context.applicationContext,
AppDatabase::class.java, AppDatabase::class.java,
"shap_planner_database" "shap_planner_database"
).build() ).fallbackToDestructiveMigration(true).build()
INSTANCE = instance INSTANCE = instance
instance instance
} }

View File

@@ -1,25 +1,65 @@
package de.miaurizius.shap_planner.viewmodels package de.miaurizius.shap_planner.viewmodels
import android.content.Context
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import de.miaurizius.shap_planner.TokenStorage
import de.miaurizius.shap_planner.UserPreferences import de.miaurizius.shap_planner.UserPreferences
import de.miaurizius.shap_planner.entities.Account 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.SharingStarted
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.UUID 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 isLoggedIn = prefs.isLoggedInFlow.stateIn(viewModelScope, SharingStarted.Lazily, false)
val lastUserId = prefs.lastUserLoginFlow.stateIn(viewModelScope, SharingStarted.Lazily, null) val lastUserId = prefs.lastUserLoginFlow.stateIn(viewModelScope, SharingStarted.Lazily, null)
fun login(serverUrl: String, username: String, password: String, viewModel: MainViewModel) { fun login(serverUrl: String, username: String, password: String, viewModel: MainViewModel) {
val uuid = UUID.randomUUID(); viewModelScope.launch {
val acc = Account(uuid, username, "Pfadi-WG", null, serverUrl) //TODO: get data from backend val api = RetrofitProvider.create(serverUrl)
viewModel.addAccount(acc)
println("Logged in as ${username} in ${serverUrl}") try {
viewModelScope.launch { prefs.saveLogin(uuid.toString()) } 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() { fun logout() {