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"
|
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")
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
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()
|
// 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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user