diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index 8d90b96..0b2bda7 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -142,3 +142,11 @@ - Improved socket lifecycle hygiene by cancelling heartbeat on close/failure/disconnect paths. - 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. + +### 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`. diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/auth/api/AuthApiService.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/auth/api/AuthApiService.kt index 2c54fbe..8c339a8 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/auth/api/AuthApiService.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/auth/api/AuthApiService.kt @@ -1,6 +1,7 @@ package ru.daemonlord.messenger.data.auth.api 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.RefreshTokenRequestDto import ru.daemonlord.messenger.data.auth.dto.TokenResponseDto @@ -8,6 +9,8 @@ import retrofit2.http.Headers import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.POST +import retrofit2.http.DELETE +import retrofit2.http.Path interface AuthApiService { @Headers("No-Auth: true") @@ -20,4 +23,13 @@ interface AuthApiService { @GET("/api/v1/auth/me") suspend fun me(): AuthUserDto + + @GET("/api/v1/auth/sessions") + suspend fun sessions(): List + + @DELETE("/api/v1/auth/sessions/{jti}") + suspend fun revokeSession(@Path("jti") jti: String) + + @DELETE("/api/v1/auth/sessions") + suspend fun revokeAllSessions() } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/auth/dto/AuthDtos.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/auth/dto/AuthDtos.kt index 804b393..08d7b43 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/auth/dto/AuthDtos.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/auth/dto/AuthDtos.kt @@ -37,6 +37,20 @@ data class AuthUserDto( 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 data class ErrorResponseDto( val detail: String? = null, diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/auth/repository/NetworkAuthRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/auth/repository/NetworkAuthRepository.kt index fe83643..2eef809 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/auth/repository/NetworkAuthRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/auth/repository/NetworkAuthRepository.kt @@ -7,10 +7,12 @@ import ru.daemonlord.messenger.core.token.TokenBundle import ru.daemonlord.messenger.core.token.TokenRepository import ru.daemonlord.messenger.data.auth.api.AuthApiService 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.RefreshTokenRequestDto import ru.daemonlord.messenger.di.IoDispatcher 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.common.AppError import ru.daemonlord.messenger.domain.common.AppResult @@ -96,6 +98,32 @@ class NetworkAuthRepository @Inject constructor( } } + override suspend fun listSessions(): AppResult> = 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 = withContext(ioDispatcher) { + try { + authApiService.revokeSession(jti = jti) + AppResult.Success(Unit) + } catch (error: Throwable) { + AppResult.Error(error.toAppError(forLogin = false)) + } + } + + override suspend fun revokeAllSessions(): AppResult = withContext(ioDispatcher) { + try { + authApiService.revokeAllSessions() + AppResult.Success(Unit) + } catch (error: Throwable) { + AppResult.Error(error.toAppError(forLogin = false)) + } + } + override suspend fun logout() { 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 { return when (this) { is IOException -> AppError.Network diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/model/AuthSession.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/model/AuthSession.kt new file mode 100644 index 0000000..2412ba9 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/model/AuthSession.kt @@ -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?, +) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/repository/AuthRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/repository/AuthRepository.kt index 715456e..a03a543 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/repository/AuthRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/repository/AuthRepository.kt @@ -1,6 +1,7 @@ package ru.daemonlord.messenger.domain.auth.repository import ru.daemonlord.messenger.domain.auth.model.AuthUser +import ru.daemonlord.messenger.domain.auth.model.AuthSession import ru.daemonlord.messenger.domain.common.AppResult interface AuthRepository { @@ -8,5 +9,8 @@ interface AuthRepository { suspend fun refreshTokens(): AppResult suspend fun getMe(): AppResult suspend fun restoreSession(): AppResult + suspend fun listSessions(): AppResult> + suspend fun revokeSession(jti: String): AppResult + suspend fun revokeAllSessions(): AppResult suspend fun logout() } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/usecase/ListSessionsUseCase.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/usecase/ListSessionsUseCase.kt new file mode 100644 index 0000000..d3d44b9 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/usecase/ListSessionsUseCase.kt @@ -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> { + return repository.listSessions() + } +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/usecase/RevokeAllSessionsUseCase.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/usecase/RevokeAllSessionsUseCase.kt new file mode 100644 index 0000000..aca0d0e --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/usecase/RevokeAllSessionsUseCase.kt @@ -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 { + return repository.revokeAllSessions() + } +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/usecase/RevokeSessionUseCase.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/usecase/RevokeSessionUseCase.kt new file mode 100644 index 0000000..dd965cc --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/usecase/RevokeSessionUseCase.kt @@ -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 { + return repository.revokeSession(jti = jti) + } +} diff --git a/android/app/src/test/java/ru/daemonlord/messenger/data/auth/repository/NetworkAuthRepositoryTest.kt b/android/app/src/test/java/ru/daemonlord/messenger/data/auth/repository/NetworkAuthRepositoryTest.kt index 05c63e4..e8c6e0f 100644 --- a/android/app/src/test/java/ru/daemonlord/messenger/data/auth/repository/NetworkAuthRepositoryTest.kt +++ b/android/app/src/test/java/ru/daemonlord/messenger/data/auth/repository/NetworkAuthRepositoryTest.kt @@ -13,6 +13,7 @@ import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -101,6 +102,34 @@ class NetworkAuthRepositoryTest { 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 val state = MutableStateFlow(null) diff --git a/android/app/src/test/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepositoryTest.kt b/android/app/src/test/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepositoryTest.kt index ab8e0c9..a14dc34 100644 --- a/android/app/src/test/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepositoryTest.kt +++ b/android/app/src/test/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepositoryTest.kt @@ -4,6 +4,8 @@ import androidx.room.Room import androidx.test.core.app.ApplicationProvider import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.After @@ -14,9 +16,20 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner 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.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.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.domain.common.AppResult import ru.daemonlord.messenger.domain.media.repository.MediaRepository @@ -95,6 +108,8 @@ class NetworkMessageRepositoryTest { messageDao = db.messageDao(), chatDao = db.chatDao(), mediaRepository = FakeMediaRepository(), + mediaApiService = FakeMediaApiService(), + tokenRepository = FakeTokenRepository(), ioDispatcher = dispatcher, ) } @@ -123,6 +138,19 @@ class NetworkMessageRepositoryTest { } 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 = emptyList() + + override suspend fun toggleReaction( + messageId: Long, + request: MessageReactionToggleRequestDto, + ): List = emptyList() } private class FakeMediaRepository : MediaRepository { @@ -135,4 +163,34 @@ class NetworkMessageRepositoryTest { 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 = emptyList() + } + + private class FakeTokenRepository : TokenRepository { + private val state = MutableStateFlow(null) + + override fun observeTokens(): Flow = state + + override suspend fun getTokens(): TokenBundle? = state.value + + override suspend fun saveTokens(tokens: TokenBundle) { + state.value = tokens + } + + override suspend fun clearTokens() { + state.value = null + } + } }