android: add auth sessions hardening APIs and tests
Some checks are pending
CI / test (push) Has started running
Some checks are pending
CI / test (push) Has started running
This commit is contained in:
@@ -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`.
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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?,
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user