feat(auth): trusted devices & recovery codes on the mobile client Consumes the merged backend trusted-device + MFA feature (RunicGateway/website#93, docs#32) per docs/android/PLAN.md §4.1.1. Login (POST /auth/mobile/login): - "Trust this device" checkbox and a "use a recovery code instead" toggle on the 401 { totpRequired } step; sends trustDevice / recoveryCode / device_name and replays a stored X-Trust-Token. - A returned trustToken is stored in a dedicated, username-scoped EncryptedSharedPreferences file (runic_trust, AES-256-GCM), separate from the session store so it deliberately SURVIVES logout — the token is only consulted at a fresh login, so clearing it there would make the feature a no-op. Cleared only on a Settings→Server switch, untrust-all, or server-side revocation. (Supersedes the handoff note that said clear-on-logout; matches the canonical rg_trust design.) Account → Security: - Trusted Devices screen: list / revoke one / untrust all / trust this device (persists the returned token). - Recovery Codes screen: remaining count + password-stepped regenerate with a show-once copy/share display; the one-time batch from enabling 2FA is also surfaced on the account screen. Login-time trust cap (trustLimitReached) is surfaced + resolved on the Trusted Devices screen rather than a blocking login modal, since the native login has already issued the session. Tests: DTO decode for all new wire shapes + AccountRepository logic (the 409 cap-body parse, revoke, recovery). 154 unit tests pass; assembleDebug clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr @
This commit is contained in:
@@ -54,6 +54,66 @@ class AccountDtoTest {
|
||||
assertTrue(json.decodeFromString<TotpStateDto>("""{"totp_enabled":true}""").totp_enabled)
|
||||
}
|
||||
|
||||
@Test fun totpEnableCarriesOneTimeRecoveryCodes() {
|
||||
// Enabling 2FA now returns the fresh single-use batch once (TRUSTED_DEVICES_MFA.md).
|
||||
val dto = json.decodeFromString<TotpStateDto>(
|
||||
"""{"totp_enabled":true,"recoveryCodes":["aaaa-1111","bbbb-2222"]}""",
|
||||
)
|
||||
assertTrue(dto.totp_enabled)
|
||||
assertEquals(listOf("aaaa-1111", "bbbb-2222"), dto.recoveryCodes)
|
||||
}
|
||||
|
||||
@Test fun totpStateDisableHasNoRecoveryCodes() {
|
||||
// Disable (and older backends) omit the field — must decode to null, not crash.
|
||||
val dto = json.decodeFromString<TotpStateDto>("""{"totp_enabled":false}""")
|
||||
assertFalse(dto.totp_enabled)
|
||||
assertEquals(null, dto.recoveryCodes)
|
||||
}
|
||||
|
||||
@Test fun trustedDeviceDecodes() {
|
||||
val dto = json.decodeFromString<TrustedDeviceDto>(
|
||||
"""{"id":5,"platform":"mobile","deviceName":"Pixel 8","userAgent":"RunicGatewayApp/1.0",
|
||||
"createdAt":"2026-07-20T10:00:00Z","lastUsedAt":"2026-07-22T09:00:00Z",
|
||||
"expiresAt":"2026-08-19T10:00:00Z"}""",
|
||||
)
|
||||
assertEquals(5L, dto.id)
|
||||
assertEquals("mobile", dto.platform)
|
||||
assertEquals("Pixel 8", dto.deviceName)
|
||||
assertEquals("2026-07-22T09:00:00Z", dto.lastUsedAt)
|
||||
}
|
||||
|
||||
@Test fun trustDeviceResultCarriesNativeToken() {
|
||||
val dto = json.decodeFromString<TrustDeviceResultDto>(
|
||||
"""{"trusted":true,"trustToken":"opaque-token-abc"}""",
|
||||
)
|
||||
assertTrue(dto.trusted)
|
||||
assertEquals("opaque-token-abc", dto.trustToken)
|
||||
}
|
||||
|
||||
@Test fun trustedDeviceLimitDecodesDevices() {
|
||||
val dto = json.decodeFromString<TrustedDeviceLimitDto>(
|
||||
"""{"error":"trusted_device_limit","devices":[
|
||||
{"id":1,"platform":"web","deviceName":"Firefox"},
|
||||
{"id":2,"platform":"mobile","deviceName":"Pixel"}]}""",
|
||||
)
|
||||
assertEquals("trusted_device_limit", dto.error)
|
||||
assertEquals(2, dto.devices.size)
|
||||
assertEquals(2L, dto.devices[1].id)
|
||||
}
|
||||
|
||||
@Test fun recoveryStatusAndCodesDecode() {
|
||||
assertEquals(7, json.decodeFromString<RecoveryStatusDto>("""{"remaining":7}""").remaining)
|
||||
val codes = json.decodeFromString<RecoveryCodesDto>(
|
||||
"""{"recoveryCodes":["c1","c2","c3"]}""",
|
||||
)
|
||||
assertEquals(3, codes.recoveryCodes.size)
|
||||
}
|
||||
|
||||
@Test fun revokedResultsDecode() {
|
||||
assertTrue(json.decodeFromString<RevokedFlagDto>("""{"revoked":true}""").revoked)
|
||||
assertEquals(4, json.decodeFromString<RevokedCountDto>("""{"revoked":4}""").revoked)
|
||||
}
|
||||
|
||||
@Test fun linkedIdentityDecodes() {
|
||||
val dto = json.decodeFromString<LinkedIdentityDto>(
|
||||
"""{"provider":"discord","email":"u@example.com","linked_at":"2026-07-19T22:00:00Z"}""",
|
||||
|
||||
@@ -68,4 +68,35 @@ class AuthDtoTest {
|
||||
assertNull(dto.expiresIn)
|
||||
assertEquals("admin", dto.user.role)
|
||||
}
|
||||
|
||||
@Test fun loginCarriesTrustTokenWhenDeviceTrusted() {
|
||||
// trustDevice accepted → an opaque token to persist + replay (TRUSTED_DEVICES_MFA.md).
|
||||
val dto = json.decodeFromString<MobileTokenResponse>(
|
||||
"""{"accessToken":"a","refreshToken":"r","user":{"id":3,"username":"c","role":"player"},
|
||||
"trustToken":"opaque-abc"}""",
|
||||
)
|
||||
assertEquals("opaque-abc", dto.trustToken)
|
||||
assertFalse(dto.trustLimitReached)
|
||||
}
|
||||
|
||||
@Test fun loginSignalsTrustLimitWithDevices() {
|
||||
// At the cap: login still succeeds, but no token; the device list is returned.
|
||||
val dto = json.decodeFromString<MobileTokenResponse>(
|
||||
"""{"accessToken":"a","refreshToken":"r","user":{"id":3,"username":"c","role":"player"},
|
||||
"trustLimitReached":true,"devices":[{"id":1,"platform":"mobile","deviceName":"Old"}]}""",
|
||||
)
|
||||
assertNull(dto.trustToken)
|
||||
assertTrue(dto.trustLimitReached)
|
||||
assertEquals(1, dto.devices.size)
|
||||
}
|
||||
|
||||
@Test fun loginWithoutTrustFieldsDefaultsCleanly() {
|
||||
// A normal (no-trust) login omits every trust field — must not crash or mis-flag.
|
||||
val dto = json.decodeFromString<MobileTokenResponse>(
|
||||
"""{"accessToken":"a","refreshToken":"r","user":{"id":4,"username":"d","role":"player"}}""",
|
||||
)
|
||||
assertNull(dto.trustToken)
|
||||
assertFalse(dto.trustLimitReached)
|
||||
assertTrue(dto.devices.isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.repository
|
||||
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.api.MeApi
|
||||
import com.runicgateway.app.data.api.dto.ChangePasswordRequest
|
||||
import com.runicgateway.app.data.api.dto.ChangeUsernameRequest
|
||||
import com.runicgateway.app.data.api.dto.LinkedIdentityDto
|
||||
import com.runicgateway.app.data.api.dto.PlayerAccountDto
|
||||
import com.runicgateway.app.data.api.dto.RecoveryCodesDto
|
||||
import com.runicgateway.app.data.api.dto.RecoveryGenerateRequest
|
||||
import com.runicgateway.app.data.api.dto.RecoveryStatusDto
|
||||
import com.runicgateway.app.data.api.dto.RevokedCountDto
|
||||
import com.runicgateway.app.data.api.dto.RevokedFlagDto
|
||||
import com.runicgateway.app.data.api.dto.TotpCodeRequest
|
||||
import com.runicgateway.app.data.api.dto.TotpSetupDto
|
||||
import com.runicgateway.app.data.api.dto.TotpStateDto
|
||||
import com.runicgateway.app.data.api.dto.TrustDeviceRequest
|
||||
import com.runicgateway.app.data.api.dto.TrustDeviceResultDto
|
||||
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
|
||||
import com.runicgateway.app.data.api.dto.UsernameResponse
|
||||
import com.runicgateway.app.data.repository.AccountRepository.TrustOutcome
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import retrofit2.Response
|
||||
|
||||
/**
|
||||
* [AccountRepository] trusted-device + recovery logic (TRUSTED_DEVICES_MFA.md) over a
|
||||
* fake [MeApi]. The interesting case is the `409` cap: the device list must survive
|
||||
* into a typed [TrustOutcome.LimitReached] rather than being lost as a bare error.
|
||||
*/
|
||||
class AccountTrustedDevicesTest {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
/** A fake MeApi; only the trusted-device/recovery methods under test are wired. */
|
||||
private open class FakeMeApi(
|
||||
var trustResponse: Response<TrustDeviceResultDto>? = null,
|
||||
var devices: List<TrustedDeviceDto> = emptyList(),
|
||||
var revokeFlag: Boolean = true,
|
||||
var revokeCount: Int = 0,
|
||||
var remaining: Int = 0,
|
||||
var generated: List<String> = emptyList(),
|
||||
) : MeApi {
|
||||
override suspend fun trustedDevices(): List<TrustedDeviceDto> = devices
|
||||
override suspend fun trustThisDevice(body: TrustDeviceRequest): Response<TrustDeviceResultDto> =
|
||||
trustResponse ?: Response.success(TrustDeviceResultDto(trusted = true, trustToken = "t"))
|
||||
override suspend fun revokeTrustedDevice(id: Long): RevokedFlagDto = RevokedFlagDto(revokeFlag)
|
||||
override suspend fun revokeAllTrustedDevices(): RevokedCountDto = RevokedCountDto(revokeCount)
|
||||
override suspend fun recoveryCodesStatus(): RecoveryStatusDto = RecoveryStatusDto(remaining)
|
||||
override suspend fun generateRecoveryCodes(body: RecoveryGenerateRequest): RecoveryCodesDto =
|
||||
RecoveryCodesDto(generated)
|
||||
|
||||
// Unused by these tests.
|
||||
override suspend fun getAccount(): PlayerAccountDto = PlayerAccountDto()
|
||||
override suspend fun changeUsername(body: ChangeUsernameRequest): UsernameResponse = UsernameResponse()
|
||||
override suspend fun changePassword(body: ChangePasswordRequest) = Unit
|
||||
override suspend fun totpSetup(): TotpSetupDto = TotpSetupDto()
|
||||
override suspend fun totpEnable(body: TotpCodeRequest): TotpStateDto = TotpStateDto()
|
||||
override suspend fun totpDisable(body: TotpCodeRequest): TotpStateDto = TotpStateDto()
|
||||
override suspend fun identities(): List<LinkedIdentityDto> = emptyList()
|
||||
override suspend fun unlinkIdentity(provider: String) = Unit
|
||||
}
|
||||
|
||||
private fun repo(api: MeApi) = AccountRepository(api, json)
|
||||
|
||||
@Test fun trustThisDeviceReturnsToken() = runTest {
|
||||
val api = FakeMeApi(trustResponse = Response.success(TrustDeviceResultDto(true, "opaque-xyz")))
|
||||
val outcome = repo(api).trustThisDevice("Pixel")
|
||||
assertTrue(outcome is TrustOutcome.Trusted)
|
||||
assertEquals("opaque-xyz", (outcome as TrustOutcome.Trusted).trustToken)
|
||||
}
|
||||
|
||||
@Test fun trustThisDeviceParsesCapDevicesFrom409() = runTest {
|
||||
val body = """{"error":"trusted_device_limit","devices":[
|
||||
{"id":1,"platform":"web","deviceName":"Firefox"},
|
||||
{"id":2,"platform":"mobile","deviceName":"Pixel"}]}"""
|
||||
.toResponseBody("application/json".toMediaTypeOrNull())
|
||||
val api = FakeMeApi(trustResponse = Response.error(409, body))
|
||||
val outcome = repo(api).trustThisDevice(null)
|
||||
assertTrue(outcome is TrustOutcome.LimitReached)
|
||||
val devices = (outcome as TrustOutcome.LimitReached).devices
|
||||
assertEquals(2, devices.size)
|
||||
assertEquals("Pixel", devices[1].deviceName)
|
||||
}
|
||||
|
||||
@Test fun trustThisDeviceOtherErrorIsServerError() = runTest {
|
||||
val body = """{"message":"boom"}""".toResponseBody("application/json".toMediaTypeOrNull())
|
||||
val api = FakeMeApi(trustResponse = Response.error(500, body))
|
||||
assertTrue(repo(api).trustThisDevice(null) is TrustOutcome.ServerError)
|
||||
}
|
||||
|
||||
@Test fun revokeMapsFlagAndCount() = runTest {
|
||||
val revoked = repo(FakeMeApi(revokeFlag = true)).revokeTrustedDevice(9)
|
||||
assertTrue(revoked is ApiResult.Ok && revoked.data)
|
||||
|
||||
val all = repo(FakeMeApi(revokeCount = 3)).revokeAllTrustedDevices()
|
||||
assertTrue(all is ApiResult.Ok && all.data == 3)
|
||||
}
|
||||
|
||||
@Test fun recoveryStatusAndGenerateMap() = runTest {
|
||||
val status = repo(FakeMeApi(remaining = 6)).recoveryCodesStatus()
|
||||
assertTrue(status is ApiResult.Ok && status.data.remaining == 6)
|
||||
|
||||
val gen = repo(FakeMeApi(generated = listOf("a", "b"))).generateRecoveryCodes("pw")
|
||||
assertTrue(gen is ApiResult.Ok)
|
||||
assertEquals(listOf("a", "b"), (gen as ApiResult.Ok).data.recoveryCodes)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user