diff --git a/app/src/main/java/ru/sweetbread/unn/UNNApp.kt b/app/src/main/java/ru/sweetbread/unn/UNNApp.kt index a0eec65..482b5f1 100644 --- a/app/src/main/java/ru/sweetbread/unn/UNNApp.kt +++ b/app/src/main/java/ru/sweetbread/unn/UNNApp.kt @@ -4,6 +4,8 @@ package ru.sweetbread.unn import android.app.Application import android.content.Context +import ru.sweetbread.unn.core.AuthRepository +import ru.sweetbread.unn.core.CredentialsStore import ru.sweetbread.unn.core.SessionManager import ru.sweetbread.unn.core.network.PortalApi import ru.sweetbread.unn.core.network.createHttpClient @@ -27,6 +29,7 @@ class UNNApp : Application() { } class AppContainer(context: Context) { + val credentialsStore = CredentialsStore(context) val sessionManager = SessionManager() val httpClient = createHttpClient() @@ -36,6 +39,8 @@ class AppContainer(context: Context) { session = sessionManager ) + val authRepo = AuthRepository(credentialsStore, portalApi) + val database = roomDb(name = "cache") { fallbackToDestructiveMigration(dropAllTables = true) } diff --git a/app/src/main/java/ru/sweetbread/unn/core/AuthRepository.kt b/app/src/main/java/ru/sweetbread/unn/core/AuthRepository.kt new file mode 100644 index 0000000..b027c61 --- /dev/null +++ b/app/src/main/java/ru/sweetbread/unn/core/AuthRepository.kt @@ -0,0 +1,46 @@ +// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE). + +package ru.sweetbread.unn.core + +import android.util.Log +import kotlinx.io.IOException +import ru.sweetbread.unn.core.network.LoginResult +import ru.sweetbread.unn.core.network.PortalApi + +class AuthRepository( + private val credentialsStore: CredentialsStore, + private val api: PortalApi +) { + suspend fun login( + login: String, + password: String + ): Boolean { + val res = api.login(login, password) == LoginResult.Success + + if (res) { + credentialsStore.save( + Credentials(login, password) + ) + } + + return res + } + + suspend fun restoreSession(): Boolean { + val credentials = + credentialsStore.get() + ?: return false + + return try { + val res = api.login( + credentials.login, + credentials.password + ) + + res != LoginResult.InvalidCredentials + } catch (e: IOException) { + Log.e("AuthRepo", "restoreSession error", e) + false + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/sweetbread/unn/core/CredentialsStore.kt b/app/src/main/java/ru/sweetbread/unn/core/CredentialsStore.kt new file mode 100644 index 0000000..b1f41c2 --- /dev/null +++ b/app/src/main/java/ru/sweetbread/unn/core/CredentialsStore.kt @@ -0,0 +1,144 @@ +// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE). + +package ru.sweetbread.unn.core + +import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import androidx.core.content.edit +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + + +data class Credentials( + val login: String, + val password: String +) + +class CredentialsStore( + context: Context +) { + private val prefs = context.getSharedPreferences( + "credentials", + Context.MODE_PRIVATE + ) + + private val keyAlias = "credentials_key" + + private val key: SecretKey + get() { + val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { + load(null) + } + + val existingKey = keyStore.getKey( + keyAlias, + null + ) as? SecretKey + + if (existingKey != null) + return existingKey + + val keyGenerator = KeyGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_AES, + "AndroidKeyStore" + ) + + keyGenerator.init( + KeyGenParameterSpec.Builder( + keyAlias, + KeyProperties.PURPOSE_ENCRYPT or + KeyProperties.PURPOSE_DECRYPT + ).setBlockModes( + KeyProperties.BLOCK_MODE_GCM + ).setEncryptionPaddings( + KeyProperties.ENCRYPTION_PADDING_NONE + ).setKeySize(256) + .build() + ) + + return keyGenerator.generateKey() + } + + fun save(credentials: Credentials) { + val plainText = + "${credentials.login}\u0000${credentials.password}" + .encodeToByteArray() + + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + + cipher.init( + Cipher.ENCRYPT_MODE, + key + ) + + val encrypted = cipher.doFinal(plainText) + + prefs.edit { + putString( + "data", + Base64.encodeToString( + encrypted, + Base64.NO_WRAP + ) + ) + putString( + "iv", + Base64.encodeToString( + cipher.iv, + Base64.NO_WRAP + ) + ) + } + } + + fun get(): Credentials? { + val encryptedString = + prefs.getString("data", null) + ?: return null + + val ivString = + prefs.getString("iv", null) + ?: return null + + val encrypted = Base64.decode( + encryptedString, + Base64.NO_WRAP + ) + + val iv = Base64.decode( + ivString, + Base64.NO_WRAP + ) + + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + + cipher.init( + Cipher.DECRYPT_MODE, + key, + GCMParameterSpec(128, iv) + ) + + val plainText = cipher + .doFinal(encrypted) + .decodeToString() + + val parts = plainText.split('\u0000', limit = 2) + + if (parts.size != 2) + return null + + return Credentials( + login = parts[0], + password = parts[1] + ) + } + + fun clear() { + prefs.edit { clear() } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/sweetbread/unn/core/SessionManager.kt b/app/src/main/java/ru/sweetbread/unn/core/SessionManager.kt index 8aaee26..2522bd9 100644 --- a/app/src/main/java/ru/sweetbread/unn/core/SessionManager.kt +++ b/app/src/main/java/ru/sweetbread/unn/core/SessionManager.kt @@ -13,28 +13,22 @@ class SessionManager { var sessionId: String? = null private set - var csrf: String? = null - private set - var uns: Int? = null private set fun update( user: User, sessionId: String, - csrf: String, uns: Int ) { this._user.value = user this.sessionId = sessionId - this.csrf = csrf this.uns = uns } fun clear() { _user.value = null sessionId = null - csrf = null uns = null } } \ No newline at end of file diff --git a/app/src/main/java/ru/sweetbread/unn/core/network/PortalAPI.kt b/app/src/main/java/ru/sweetbread/unn/core/network/PortalAPI.kt index 75344da..6fb761e 100644 --- a/app/src/main/java/ru/sweetbread/unn/core/network/PortalAPI.kt +++ b/app/src/main/java/ru/sweetbread/unn/core/network/PortalAPI.kt @@ -3,8 +3,8 @@ package ru.sweetbread.unn.core.network import io.ktor.client.HttpClient +import kotlinx.io.IOException import ru.sweetbread.unn.core.SessionManager -import ru.sweetbread.unn.ui.layout.LoginData class PortalApi ( @@ -18,25 +18,27 @@ class PortalApi ( /** * Authorize user by [login] and [password] */ - suspend fun auth( - login: String = LoginData.login, - password: String = LoginData.password, - forced: Boolean = false - ): Boolean { - if (!forced and (session.sessionId != null)) - return true + suspend fun login( + login: String, + password: String + ): LoginResult { + return try { + val id = auth.getToken(login, password) - val id = this.auth.getToken(login, password) - if (id != null) { - session.update( - bitrix.getCurrentProfile(), - id, - this.auth.getCSRF(), - login.drop(1).toInt() - ) - return true + if (id == null) { + LoginResult.InvalidCredentials + } else { + session.update( + user = bitrix.getCurrentProfile(), + sessionId = id, + uns = login.drop(1).toInt() + ) + + LoginResult.Success + } + } catch (_: IOException) { + LoginResult.NetworkError } - return false } // /** @@ -92,4 +94,10 @@ class PortalApi ( // // return schedule // } +} + +sealed interface LoginResult { + data object Success : LoginResult + data object InvalidCredentials : LoginResult + data object NetworkError : LoginResult } \ No newline at end of file diff --git a/app/src/main/java/ru/sweetbread/unn/core/network/endpoints.kt b/app/src/main/java/ru/sweetbread/unn/core/network/endpoints.kt index d7e27ce..d3e3cfd 100644 --- a/app/src/main/java/ru/sweetbread/unn/core/network/endpoints.kt +++ b/app/src/main/java/ru/sweetbread/unn/core/network/endpoints.kt @@ -3,6 +3,7 @@ package ru.sweetbread.unn.core.network import io.ktor.client.HttpClient +import io.ktor.client.plugins.retry import io.ktor.client.request.forms.submitForm import io.ktor.client.request.get import io.ktor.client.request.parameter @@ -49,7 +50,7 @@ class Auth ( append("USER_LOGIN", login) append("USER_PASSWORD", password) } - ) + ) { retry { noRetry() } } if (r.status.value == 302) return """PHPSESSID=([\w\d]+)""".toRegex().find(r.headers["Set-Cookie"]!!)!!.groupValues[1] diff --git a/app/src/main/java/ru/sweetbread/unn/ui/layout/LoginActivity.kt b/app/src/main/java/ru/sweetbread/unn/ui/layout/LoginActivity.kt index 0ca330d..771a53c 100644 --- a/app/src/main/java/ru/sweetbread/unn/ui/layout/LoginActivity.kt +++ b/app/src/main/java/ru/sweetbread/unn/ui/layout/LoginActivity.kt @@ -36,35 +36,29 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import ru.sweetbread.unn.R import ru.sweetbread.unn.UNNApp +import ru.sweetbread.unn.core.AuthRepository import ru.sweetbread.unn.ui.theme.UNNTheme import splitties.activities.start -import splitties.init.appCtx -import splitties.preferences.Preferences -object LoginData : Preferences("loginData") { - var login by stringPref("login", "") - var password by stringPref("password", "") -} class LoginActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val api = (application as UNNApp).container.portalApi + val authRepo = (application as UNNApp).container.authRepo - if ((LoginData.login != "") and (LoginData.password != "")) - runBlocking { - if (api.auth()) { - start() - finish() - } + + runBlocking { + if (authRepo.restoreSession()) { + start() + finish() } + } setContent { UNNTheme { @@ -80,12 +74,13 @@ class LoginActivity : ComponentActivity() { SnackbarHost(hostState = snackbarHostState) } ) { innerPadding -> - Box(Modifier.padding(innerPadding).fillMaxSize(), Alignment.Center) { + Box(Modifier + .padding(innerPadding) + .fillMaxSize(), Alignment.Center) { LoginPanel( Modifier.imePadding(), - { login, password -> - LoginData.login = login - LoginData.password = password + authRepo, + { start() finish() }, @@ -110,10 +105,10 @@ class LoginActivity : ComponentActivity() { @Composable fun LoginPanel( modifier: Modifier = Modifier, - ok: (login: String, password: String) -> Unit, + authRepo: AuthRepository, + ok: () -> Unit, error: () -> Unit ) { - val api = (appCtx as UNNApp).container.portalApi var login by rememberSaveable { mutableStateOf("") } var password by rememberSaveable { mutableStateOf("") } var loading by remember { mutableStateOf(false) } @@ -152,8 +147,8 @@ fun LoginPanel( onClick = { loading = true scope.launch { - if (api.auth(login, password)) - ok(login, password) + if (authRepo.login(login, password)) + ok() else error() loading = false @@ -169,10 +164,10 @@ fun LoginPanel( } } -@Preview(showBackground = true) -@Composable -fun LoginPreview() { - UNNTheme { - LoginPanel(ok = { _, _ -> }, error = {}) - } -} \ No newline at end of file +//@Preview(showBackground = true) +//@Composable +//fun LoginPreview() { +// UNNTheme { +// LoginPanel(ok = { _, _ -> }, error = {}) +// } +//} \ No newline at end of file