android: add auth sessions hardening APIs and tests
Some checks are pending
CI / test (push) Has started running

This commit is contained in:
Codex
2026-03-09 13:04:12 +03:00
parent 08815bac7b
commit bd6a8a43ed
11 changed files with 214 additions and 0 deletions

View File

@@ -142,3 +142,11 @@
- Improved socket lifecycle hygiene by cancelling heartbeat on close/failure/disconnect paths. - Improved socket lifecycle hygiene by cancelling heartbeat on close/failure/disconnect paths.
- Added `connect` event mapping and centralized reconcile trigger in realtime handler. - Added `connect` event mapping and centralized reconcile trigger in realtime handler.
- On realtime reconnect, chat repository now refreshes `all` and `archived` snapshots to reduce stale state after transient disconnects. - On realtime reconnect, chat repository now refreshes `all` and `archived` snapshots to reduce stale state after transient disconnects.
### Step 23 - Sprint P0 / 6) Auth hardening foundation
- Extended auth API/repository contracts with sessions management endpoints:
- `GET /api/v1/auth/sessions`
- `DELETE /api/v1/auth/sessions/{jti}`
- `DELETE /api/v1/auth/sessions`
- Added domain model and use-cases for listing/revoking sessions.
- Added unit coverage for session DTO -> domain mapping in `NetworkAuthRepositoryTest`.

View File

@@ -1,6 +1,7 @@
package ru.daemonlord.messenger.data.auth.api package ru.daemonlord.messenger.data.auth.api
import ru.daemonlord.messenger.data.auth.dto.AuthUserDto import ru.daemonlord.messenger.data.auth.dto.AuthUserDto
import ru.daemonlord.messenger.data.auth.dto.AuthSessionDto
import ru.daemonlord.messenger.data.auth.dto.LoginRequestDto import ru.daemonlord.messenger.data.auth.dto.LoginRequestDto
import ru.daemonlord.messenger.data.auth.dto.RefreshTokenRequestDto import ru.daemonlord.messenger.data.auth.dto.RefreshTokenRequestDto
import ru.daemonlord.messenger.data.auth.dto.TokenResponseDto import ru.daemonlord.messenger.data.auth.dto.TokenResponseDto
@@ -8,6 +9,8 @@ import retrofit2.http.Headers
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.DELETE
import retrofit2.http.Path
interface AuthApiService { interface AuthApiService {
@Headers("No-Auth: true") @Headers("No-Auth: true")
@@ -20,4 +23,13 @@ interface AuthApiService {
@GET("/api/v1/auth/me") @GET("/api/v1/auth/me")
suspend fun me(): AuthUserDto suspend fun me(): AuthUserDto
@GET("/api/v1/auth/sessions")
suspend fun sessions(): List<AuthSessionDto>
@DELETE("/api/v1/auth/sessions/{jti}")
suspend fun revokeSession(@Path("jti") jti: String)
@DELETE("/api/v1/auth/sessions")
suspend fun revokeAllSessions()
} }

View File

@@ -37,6 +37,20 @@ data class AuthUserDto(
val emailVerified: Boolean, val emailVerified: Boolean,
) )
@Serializable
data class AuthSessionDto(
val jti: String,
@SerialName("created_at")
val createdAt: String,
@SerialName("ip_address")
val ipAddress: String? = null,
@SerialName("user_agent")
val userAgent: String? = null,
val current: Boolean? = null,
@SerialName("token_type")
val tokenType: String? = null,
)
@Serializable @Serializable
data class ErrorResponseDto( data class ErrorResponseDto(
val detail: String? = null, val detail: String? = null,

View File

@@ -7,10 +7,12 @@ import ru.daemonlord.messenger.core.token.TokenBundle
import ru.daemonlord.messenger.core.token.TokenRepository import ru.daemonlord.messenger.core.token.TokenRepository
import ru.daemonlord.messenger.data.auth.api.AuthApiService import ru.daemonlord.messenger.data.auth.api.AuthApiService
import ru.daemonlord.messenger.data.auth.dto.AuthUserDto import ru.daemonlord.messenger.data.auth.dto.AuthUserDto
import ru.daemonlord.messenger.data.auth.dto.AuthSessionDto
import ru.daemonlord.messenger.data.auth.dto.LoginRequestDto import ru.daemonlord.messenger.data.auth.dto.LoginRequestDto
import ru.daemonlord.messenger.data.auth.dto.RefreshTokenRequestDto import ru.daemonlord.messenger.data.auth.dto.RefreshTokenRequestDto
import ru.daemonlord.messenger.di.IoDispatcher import ru.daemonlord.messenger.di.IoDispatcher
import ru.daemonlord.messenger.domain.auth.model.AuthUser import ru.daemonlord.messenger.domain.auth.model.AuthUser
import ru.daemonlord.messenger.domain.auth.model.AuthSession
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
import ru.daemonlord.messenger.domain.common.AppError import ru.daemonlord.messenger.domain.common.AppError
import ru.daemonlord.messenger.domain.common.AppResult import ru.daemonlord.messenger.domain.common.AppResult
@@ -96,6 +98,32 @@ class NetworkAuthRepository @Inject constructor(
} }
} }
override suspend fun listSessions(): AppResult<List<AuthSession>> = withContext(ioDispatcher) {
try {
AppResult.Success(authApiService.sessions().map { it.toDomain() })
} catch (error: Throwable) {
AppResult.Error(error.toAppError(forLogin = false))
}
}
override suspend fun revokeSession(jti: String): AppResult<Unit> = withContext(ioDispatcher) {
try {
authApiService.revokeSession(jti = jti)
AppResult.Success(Unit)
} catch (error: Throwable) {
AppResult.Error(error.toAppError(forLogin = false))
}
}
override suspend fun revokeAllSessions(): AppResult<Unit> = withContext(ioDispatcher) {
try {
authApiService.revokeAllSessions()
AppResult.Success(Unit)
} catch (error: Throwable) {
AppResult.Error(error.toAppError(forLogin = false))
}
}
override suspend fun logout() { override suspend fun logout() {
tokenRepository.clearTokens() tokenRepository.clearTokens()
} }
@@ -111,6 +139,17 @@ class NetworkAuthRepository @Inject constructor(
) )
} }
private fun AuthSessionDto.toDomain(): AuthSession {
return AuthSession(
jti = jti,
createdAt = createdAt,
ipAddress = ipAddress,
userAgent = userAgent,
current = current,
tokenType = tokenType,
)
}
private fun Throwable.toAppError(forLogin: Boolean): AppError { private fun Throwable.toAppError(forLogin: Boolean): AppError {
return when (this) { return when (this) {
is IOException -> AppError.Network is IOException -> AppError.Network

View File

@@ -0,0 +1,10 @@
package ru.daemonlord.messenger.domain.auth.model
data class AuthSession(
val jti: String,
val createdAt: String,
val ipAddress: String?,
val userAgent: String?,
val current: Boolean?,
val tokenType: String?,
)

View File

@@ -1,6 +1,7 @@
package ru.daemonlord.messenger.domain.auth.repository package ru.daemonlord.messenger.domain.auth.repository
import ru.daemonlord.messenger.domain.auth.model.AuthUser import ru.daemonlord.messenger.domain.auth.model.AuthUser
import ru.daemonlord.messenger.domain.auth.model.AuthSession
import ru.daemonlord.messenger.domain.common.AppResult import ru.daemonlord.messenger.domain.common.AppResult
interface AuthRepository { interface AuthRepository {
@@ -8,5 +9,8 @@ interface AuthRepository {
suspend fun refreshTokens(): AppResult<Unit> suspend fun refreshTokens(): AppResult<Unit>
suspend fun getMe(): AppResult<AuthUser> suspend fun getMe(): AppResult<AuthUser>
suspend fun restoreSession(): AppResult<AuthUser> suspend fun restoreSession(): AppResult<AuthUser>
suspend fun listSessions(): AppResult<List<AuthSession>>
suspend fun revokeSession(jti: String): AppResult<Unit>
suspend fun revokeAllSessions(): AppResult<Unit>
suspend fun logout() suspend fun logout()
} }

View File

@@ -0,0 +1,14 @@
package ru.daemonlord.messenger.domain.auth.usecase
import ru.daemonlord.messenger.domain.auth.model.AuthSession
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
import ru.daemonlord.messenger.domain.common.AppResult
import javax.inject.Inject
class ListSessionsUseCase @Inject constructor(
private val repository: AuthRepository,
) {
suspend operator fun invoke(): AppResult<List<AuthSession>> {
return repository.listSessions()
}
}

View File

@@ -0,0 +1,13 @@
package ru.daemonlord.messenger.domain.auth.usecase
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
import ru.daemonlord.messenger.domain.common.AppResult
import javax.inject.Inject
class RevokeAllSessionsUseCase @Inject constructor(
private val repository: AuthRepository,
) {
suspend operator fun invoke(): AppResult<Unit> {
return repository.revokeAllSessions()
}
}

View File

@@ -0,0 +1,13 @@
package ru.daemonlord.messenger.domain.auth.usecase
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
import ru.daemonlord.messenger.domain.common.AppResult
import javax.inject.Inject
class RevokeSessionUseCase @Inject constructor(
private val repository: AuthRepository,
) {
suspend operator fun invoke(jti: String): AppResult<Unit> {
return repository.revokeSession(jti = jti)
}
}

View File

@@ -13,6 +13,7 @@ import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer import okhttp3.mockwebserver.MockWebServer
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
@@ -101,6 +102,34 @@ class NetworkAuthRepositoryTest {
assertEquals(AppError.InvalidCredentials, error.reason) assertEquals(AppError.InvalidCredentials, error.reason)
} }
@Test
fun listSessions_success_mapsSessionDtos() = runTest(dispatcher) {
server.enqueue(
MockResponse().setResponseCode(200).setBody(
"""
[
{"jti":"abc","created_at":"2026-03-09T10:00:00Z","ip_address":"127.0.0.1","user_agent":"Android","current":true,"token_type":"refresh"},
{"jti":"def","created_at":"2026-03-09T11:00:00Z","ip_address":null,"user_agent":null,"current":false,"token_type":"access"}
]
""".trimIndent()
)
)
val repository = NetworkAuthRepository(
authApiService = authApiService,
tokenRepository = tokenRepository,
ioDispatcher = dispatcher,
)
val result = repository.listSessions()
assertTrue(result is AppResult.Success)
val sessions = (result as AppResult.Success).data
assertEquals(2, sessions.size)
assertEquals("abc", sessions.first().jti)
assertTrue(sessions.first().current == true)
assertFalse(sessions.last().current == true)
}
private class InMemoryTokenRepository : TokenRepository { private class InMemoryTokenRepository : TokenRepository {
private val state = MutableStateFlow<TokenBundle?>(null) private val state = MutableStateFlow<TokenBundle?>(null)

View File

@@ -4,6 +4,8 @@ import androidx.room.Room
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.After import org.junit.After
@@ -14,9 +16,20 @@ import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
import ru.daemonlord.messenger.data.chat.local.db.MessengerDatabase import ru.daemonlord.messenger.data.chat.local.db.MessengerDatabase
import ru.daemonlord.messenger.core.token.TokenBundle
import ru.daemonlord.messenger.core.token.TokenRepository
import ru.daemonlord.messenger.data.media.api.MediaApiService
import ru.daemonlord.messenger.data.media.dto.AttachmentCreateRequestDto
import ru.daemonlord.messenger.data.media.dto.ChatAttachmentReadDto
import ru.daemonlord.messenger.data.media.dto.UploadUrlRequestDto
import ru.daemonlord.messenger.data.media.dto.UploadUrlResponseDto
import ru.daemonlord.messenger.data.message.api.MessageApiService import ru.daemonlord.messenger.data.message.api.MessageApiService
import ru.daemonlord.messenger.data.message.dto.MessageCreateRequestDto import ru.daemonlord.messenger.data.message.dto.MessageCreateRequestDto
import ru.daemonlord.messenger.data.message.dto.MessageForwardRequestDto
import ru.daemonlord.messenger.data.message.dto.MessageReadDto import ru.daemonlord.messenger.data.message.dto.MessageReadDto
import ru.daemonlord.messenger.data.message.dto.MessageReactionDto
import ru.daemonlord.messenger.data.message.dto.MessageReactionToggleRequestDto
import ru.daemonlord.messenger.data.message.dto.MessageStatusUpdateRequestDto
import ru.daemonlord.messenger.data.message.dto.MessageUpdateRequestDto import ru.daemonlord.messenger.data.message.dto.MessageUpdateRequestDto
import ru.daemonlord.messenger.domain.common.AppResult import ru.daemonlord.messenger.domain.common.AppResult
import ru.daemonlord.messenger.domain.media.repository.MediaRepository import ru.daemonlord.messenger.domain.media.repository.MediaRepository
@@ -95,6 +108,8 @@ class NetworkMessageRepositoryTest {
messageDao = db.messageDao(), messageDao = db.messageDao(),
chatDao = db.chatDao(), chatDao = db.chatDao(),
mediaRepository = FakeMediaRepository(), mediaRepository = FakeMediaRepository(),
mediaApiService = FakeMediaApiService(),
tokenRepository = FakeTokenRepository(),
ioDispatcher = dispatcher, ioDispatcher = dispatcher,
) )
} }
@@ -123,6 +138,19 @@ class NetworkMessageRepositoryTest {
} }
override suspend fun deleteMessage(messageId: Long, forAll: Boolean) = Unit override suspend fun deleteMessage(messageId: Long, forAll: Boolean) = Unit
override suspend fun updateMessageStatus(request: MessageStatusUpdateRequestDto) = Unit
override suspend fun forwardMessage(messageId: Long, request: MessageForwardRequestDto): MessageReadDto {
return sendResponse.copy(id = messageId + 1000, chatId = request.targetChatId)
}
override suspend fun listReactions(messageId: Long): List<MessageReactionDto> = emptyList()
override suspend fun toggleReaction(
messageId: Long,
request: MessageReactionToggleRequestDto,
): List<MessageReactionDto> = emptyList()
} }
private class FakeMediaRepository : MediaRepository { private class FakeMediaRepository : MediaRepository {
@@ -135,4 +163,34 @@ class NetworkMessageRepositoryTest {
return AppResult.Success(Unit) return AppResult.Success(Unit)
} }
} }
private class FakeMediaApiService : MediaApiService {
override suspend fun requestUploadUrl(request: UploadUrlRequestDto): UploadUrlResponseDto {
error("Not expected in this test")
}
override suspend fun createAttachment(request: AttachmentCreateRequestDto) = Unit
override suspend fun getChatAttachments(
chatId: Long,
limit: Int,
beforeId: Long?,
): List<ChatAttachmentReadDto> = emptyList()
}
private class FakeTokenRepository : TokenRepository {
private val state = MutableStateFlow<TokenBundle?>(null)
override fun observeTokens(): Flow<TokenBundle?> = state
override suspend fun getTokens(): TokenBundle? = state.value
override suspend fun saveTokens(tokens: TokenBundle) {
state.value = tokens
}
override suspend fun clearTokens() {
state.value = null
}
}
} }