Add multi-user MAX authentication and tenant isolation

This commit is contained in:
Курнат Андрей
2026-07-14 07:35:04 +03:00
parent 582f99ed0e
commit 440de7325f
36 changed files with 904 additions and 118 deletions
+2 -2
View File
@@ -22,8 +22,8 @@ android {
applicationId = "xyz.kusoft.qmax"
minSdk = 26
targetSdk = 36
versionCode = 54
versionName = "0.1.53"
versionCode = 55
versionName = "0.1.54"
buildConfigField("String", "QMAX_DEFAULT_SERVER_URL", "\"https://qmax.kusoft.xyz\"")
buildConfigField("String", "QMAX_DEFAULT_PAIRING_CODE", "\"qmax-MxRq4h2HQBEIFs6k\"")
@@ -275,11 +275,6 @@ private fun QMaxApp(vm: QMaxViewModel) {
Box(Modifier.fillMaxSize().background(QMaxBackground)) {
when {
state.session == null && state.pairingCode.isNotBlank() -> AutoLoginScreen(
loading = state.loading,
error = state.error,
onRetry = vm::login
)
state.session == null -> LoginScreen(vm)
state.selectedChat != null -> ChatScreen(vm)
else -> ChatListScreen(vm)
@@ -359,6 +354,17 @@ private fun LoginScreen(vm: QMaxViewModel) {
value = state.serverUrl,
onValueChange = vm::updateServerUrl,
label = { Text("Сервер") },
enabled = state.phoneAuthChallenge == null,
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = state.phoneNumber,
onValueChange = vm::updatePhoneNumber,
label = { Text("Номер телефона MAX") },
placeholder = { Text("+7 900 000-00-00") },
enabled = state.phoneAuthChallenge == null,
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
@@ -366,15 +372,29 @@ private fun LoginScreen(vm: QMaxViewModel) {
OutlinedTextField(
value = state.pairingCode,
onValueChange = vm::updatePairingCode,
label = { Text("Код подключения") },
placeholder = { Text("qmax-...") },
supportingText = { Text("Код выдаёт сервер QMAX для привязки телефона") },
label = { Text("Код регистрации сервера (если задан)") },
enabled = state.phoneAuthChallenge == null,
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(18.dp))
Button(onClick = vm::login, modifier = Modifier.fillMaxWidth()) {
Text("Войти")
if (state.phoneAuthChallenge == null) {
Spacer(Modifier.height(18.dp))
Button(onClick = vm::login, enabled = state.phoneNumber.isNotBlank(), modifier = Modifier.fillMaxWidth()) {
Text("Получить код MAX")
}
} else {
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = state.maxCode,
onValueChange = vm::updateMaxCode,
label = { Text("Код из MAX") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(18.dp))
Button(onClick = vm::completePhoneLogin, enabled = state.maxCode.isNotBlank(), modifier = Modifier.fillMaxWidth()) {
Text("Войти в QMAX")
}
}
ErrorLine(state.error)
}
@@ -28,6 +28,7 @@ import xyz.kusoft.qmax.core.model.ChatDto
import xyz.kusoft.qmax.core.model.ChatPresenceDto
import xyz.kusoft.qmax.core.model.ContactDto
import xyz.kusoft.qmax.core.model.PhoneContactDto
import xyz.kusoft.qmax.core.model.PhoneAuthChallengeResponse
import xyz.kusoft.qmax.core.model.MaxBridgeStatusDto
import xyz.kusoft.qmax.core.model.MaxChannelSearchResultDto
import xyz.kusoft.qmax.core.model.MessageDto
@@ -68,6 +69,18 @@ class QMaxRepository(
return response
}
suspend fun beginPhoneAuth(serverUrl: String, phoneNumber: String, registrationCode: String): PhoneAuthChallengeResponse {
val resolvedServer = serverUrl.ifBlank { BuildConfig.QMAX_DEFAULT_SERVER_URL }
return api.beginPhoneAuth(resolvedServer, phoneNumber, registrationCode.ifBlank { null }, android.os.Build.MODEL)
}
suspend fun completePhoneAuth(serverUrl: String, challengeId: String, challengeToken: String, code: String): AuthResponse {
val resolvedServer = serverUrl.ifBlank { BuildConfig.QMAX_DEFAULT_SERVER_URL }
val response = api.completePhoneAuth(resolvedServer, challengeId, challengeToken, code)
tokenStore.save(QMaxSession(resolvedServer, response.accessToken, response.refreshToken, response.user.displayName))
return response
}
suspend fun logout() {
tokenStore.clear()
draftStore.clearAll()
@@ -24,6 +24,20 @@ data class DeviceLoginRequest(
val deviceName: String
)
@Serializable
data class BeginPhoneAuthRequest(val phoneNumber: String, val deviceName: String, val registrationCode: String? = null)
@Serializable
data class CompletePhoneAuthRequest(val challengeId: String, val challengeToken: String, val code: String)
@Serializable
data class PhoneAuthChallengeResponse(
val challengeId: String,
val challengeToken: String,
val maxStatus: MaxBridgeStatusDto,
val expiresAt: String
)
@Serializable
data class RefreshTokenRequest(val refreshToken: String)
@@ -22,6 +22,7 @@ import xyz.kusoft.qmax.BuildConfig
import xyz.kusoft.qmax.core.model.AuthResponse
import xyz.kusoft.qmax.core.model.ArgusManifestDto
import xyz.kusoft.qmax.core.model.BeginMaxLoginRequest
import xyz.kusoft.qmax.core.model.BeginPhoneAuthRequest
import xyz.kusoft.qmax.core.model.ChatBulkActionRequest
import xyz.kusoft.qmax.core.model.ChatDto
import xyz.kusoft.qmax.core.model.ChatPresenceDto
@@ -29,6 +30,8 @@ import xyz.kusoft.qmax.core.model.CreateDirectChatRequest
import xyz.kusoft.qmax.core.model.ContactDto
import xyz.kusoft.qmax.core.model.ContactUserRequest
import xyz.kusoft.qmax.core.model.DeviceLoginRequest
import xyz.kusoft.qmax.core.model.CompletePhoneAuthRequest
import xyz.kusoft.qmax.core.model.PhoneAuthChallengeResponse
import xyz.kusoft.qmax.core.model.EditMessageRequest
import xyz.kusoft.qmax.core.model.ForwardMessageRequest
import xyz.kusoft.qmax.core.model.MarkChatReadRequest
@@ -73,6 +76,14 @@ class QMaxApi {
return post(serverUrl, "/api/auth/device/login", null, DeviceLoginRequest(pairingCode, deviceName))
}
suspend fun beginPhoneAuth(serverUrl: String, phoneNumber: String, registrationCode: String?, deviceName: String): PhoneAuthChallengeResponse {
return post(serverUrl, "/api/auth/phone/start", null, BeginPhoneAuthRequest(phoneNumber, deviceName, registrationCode))
}
suspend fun completePhoneAuth(serverUrl: String, challengeId: String, challengeToken: String, code: String): AuthResponse {
return post(serverUrl, "/api/auth/phone/code", null, CompletePhoneAuthRequest(challengeId, challengeToken, code))
}
suspend fun refresh(serverUrl: String, refreshToken: String): AuthResponse {
return post(serverUrl, "/api/auth/refresh", null, xyz.kusoft.qmax.core.model.RefreshTokenRequest(refreshToken))
}
@@ -22,6 +22,7 @@ import xyz.kusoft.qmax.core.model.MaxChannelSearchResultDto
import xyz.kusoft.qmax.core.model.MessageDeletedDto
import xyz.kusoft.qmax.core.model.MessageDto
import xyz.kusoft.qmax.core.model.PhoneContactDto
import xyz.kusoft.qmax.core.model.PhoneAuthChallengeResponse
import xyz.kusoft.qmax.core.model.QMaxSession
import xyz.kusoft.qmax.core.network.QMaxHttpException
import xyz.kusoft.qmax.core.realtime.QMaxRealtimeClient
@@ -32,6 +33,8 @@ import java.util.UUID
data class QMaxUiState(
val serverUrl: String = BuildConfig.QMAX_DEFAULT_SERVER_URL,
val pairingCode: String = BuildConfig.QMAX_DEFAULT_PAIRING_CODE,
val phoneNumber: String = "",
val phoneAuthChallenge: PhoneAuthChallengeResponse? = null,
val session: QMaxSession? = null,
val chats: List<ChatDto> = emptyList(),
val contacts: List<ContactDto> = emptyList(),
@@ -133,7 +136,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
pushRegistrationInFlightForToken = null
pushRegisteredForToken = null
realtime.disconnect()
if (!autoLoginAttempted && state.value.pairingCode.isNotBlank()) {
if (!autoLoginAttempted && state.value.phoneNumber.isNotBlank()) {
autoLoginAttempted = true
autoLoginJob?.cancel()
autoLoginJob = viewModelScope.launch {
@@ -162,6 +165,10 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
state.value = state.value.copy(pairingCode = value)
}
fun updatePhoneNumber(value: String) {
state.value = state.value.copy(phoneNumber = value)
}
fun updateComposer(value: String) {
state.value = state.value.copy(composerText = value)
if (state.value.editTarget != null) {
@@ -263,7 +270,17 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
fun login() = launchLoading {
val current = state.value
repository.login(current.serverUrl, current.pairingCode)
val challenge = repository.beginPhoneAuth(current.serverUrl, current.phoneNumber, current.pairingCode)
state.value = state.value.copy(phoneAuthChallenge = challenge, maxStatus = challenge.maxStatus)
}
fun completePhoneLogin() = launchLoading {
val current = state.value
val challenge = current.phoneAuthChallenge ?: return@launchLoading
val code = current.maxCode.trim()
if (code.isBlank()) return@launchLoading
repository.completePhoneAuth(current.serverUrl, challenge.challengeId, challenge.challengeToken, code)
state.value = state.value.copy(phoneAuthChallenge = null, maxCode = "")
}
fun logout() = viewModelScope.launch {