Merge pull request 'feat(auth): trusted devices & recovery codes on the mobile client' (#23) from feature/trusted-devices-mfa into main
All checks were successful
SonarQube / analysis (push) Successful in 1m14s
Release APK / release (push) Successful in 9m37s

Reviewed-on: #23
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
2026-07-22 06:26:54 +00:00
25 changed files with 1329 additions and 33 deletions

View File

@@ -0,0 +1,34 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.auth
import android.os.Build
import javax.inject.Inject
import javax.inject.Singleton
/**
* Supplies a friendly label for this device, sent as `device_name` at login so a
* trusted-device / active-session row is recognizable in the account lists
* (TRUSTED_DEVICES_MFA.md). Behind an interface so the auth repository stays free of
* `android.os.Build` and unit-testable on the JVM.
*/
fun interface DeviceNameProvider {
/** A human label like "Google Pixel 8", or null if nothing meaningful is available. */
fun deviceName(): String?
}
/** Production impl: manufacturer + model from [Build] (e.g. "Samsung SM-S918B"). */
@Singleton
class BuildDeviceNameProvider @Inject constructor() : DeviceNameProvider {
override fun deviceName(): String? {
val manufacturer = Build.MANUFACTURER?.trim().orEmpty()
val model = Build.MODEL?.trim().orEmpty()
val label = when {
model.isEmpty() -> manufacturer
manufacturer.isEmpty() || model.startsWith(manufacturer, ignoreCase = true) -> model
else -> "$manufacturer $model"
}.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
return label.take(100).ifBlank { null }
}
}

View File

@@ -0,0 +1,65 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.auth
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* [TrustTokenStore] backed by its **own** EncryptedSharedPreferences file
* (Tink/AES-256-GCM), distinct from the session store so it is never wiped by
* [SessionManager.onSignedOut] — the trust token must outlive a logout to do its
* job (TRUSTED_DEVICES_MFA.md). The token is stored alongside the username it was
* minted for so [tokenFor] only returns it for a matching login.
*
* The prefs handle is lazy so a device that never trusts pays the keystore cost
* only if a token is actually stored or read.
*/
@Singleton
class EncryptedTrustTokenStore @Inject constructor(
@param:ApplicationContext private val context: Context,
) : TrustTokenStore {
private val prefs: SharedPreferences by lazy {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
PREFS_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
override fun tokenFor(username: String): String? {
val token = prefs.getString(KEY_TOKEN, null) ?: return null
val owner = prefs.getString(KEY_USERNAME, null) ?: return null
// Case-insensitive: usernames are matched case-insensitively server-side.
return if (owner.equals(username, ignoreCase = true)) token else null
}
override fun save(username: String, token: String) {
prefs.edit()
.putString(KEY_TOKEN, token)
.putString(KEY_USERNAME, username)
.apply()
}
override fun clear() {
prefs.edit().clear().apply()
}
private companion object {
const val PREFS_NAME = "runic_trust"
const val KEY_TOKEN = "trust_token"
const val KEY_USERNAME = "trust_username"
}
}

View File

@@ -0,0 +1,31 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.auth
/**
* At-rest home for the opaque trusted-device token (TRUSTED_DEVICES_MFA.md). It is
* the native analogue of the web `rg_trust` cookie: a device that holds a valid
* token skips the TOTP step on its next login (never the password).
*
* Deliberately **separate** from [TokenStore] and untouched by session teardown —
* the token must **survive logout and a dead-refresh sign-out**, because it is only
* ever consulted at a *fresh* login (exactly the moment after the session is gone).
* Clearing it there would make the feature a no-op. It is scoped to the username it
* was minted for so it is never replayed for a different account on a shared device,
* and is cleared only by an explicit untrust, a Settings → Server switch, or a
* server-side revocation (password change/reset, TOTP disable) that renders it dead.
*
* Tokens are sensitive, so the production impl uses EncryptedSharedPreferences —
* never plain prefs or logs. Kept behind an interface for an in-memory test fake.
*/
interface TrustTokenStore {
/** The stored trust token for [username], or null if this device isn't trusted for them. */
fun tokenFor(username: String): String?
/** Persist [token] as the trust token for [username] (overwrites any prior one). */
fun save(username: String, token: String)
/** Drop the trust token — untrust-all and the Settings → Server hard reset. */
fun clear()
}

View File

@@ -10,6 +10,7 @@ import com.runicgateway.app.data.api.dto.MobileTokenResponse
import retrofit2.Response import retrofit2.Response
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Headers import retrofit2.http.Headers
import retrofit2.http.POST import retrofit2.http.POST
@@ -27,9 +28,15 @@ import retrofit2.http.POST
interface AuthApi { interface AuthApi {
// Literal header value required by Retrofit @Headers; matches Http.NO_SESSION_HEADER. // Literal header value required by Retrofit @Headers; matches Http.NO_SESSION_HEADER.
// [trustToken] rides the `X-Trust-Token` header (TRUSTED_DEVICES_MFA.md): a valid
// token bound to this user lets the server skip the TOTP step. Retrofit omits the
// header entirely when it is null, so an untrusted device sends nothing.
@Headers("X-Runic-No-Session: 1") @Headers("X-Runic-No-Session: 1")
@POST("api/v1/auth/mobile/login") @POST("api/v1/auth/mobile/login")
suspend fun login(@Body body: MobileLoginRequest): Response<MobileTokenResponse> suspend fun login(
@Body body: MobileLoginRequest,
@Header("X-Trust-Token") trustToken: String? = null,
): Response<MobileTokenResponse>
@POST("api/v1/auth/mobile/logout") @POST("api/v1/auth/mobile/logout")
suspend fun logout(@Body body: MobileLogoutRequest): Response<Unit> suspend fun logout(@Body body: MobileLogoutRequest): Response<Unit>

View File

@@ -7,11 +7,21 @@ import com.runicgateway.app.data.api.dto.ChangePasswordRequest
import com.runicgateway.app.data.api.dto.ChangeUsernameRequest import com.runicgateway.app.data.api.dto.ChangeUsernameRequest
import com.runicgateway.app.data.api.dto.LinkedIdentityDto import com.runicgateway.app.data.api.dto.LinkedIdentityDto
import com.runicgateway.app.data.api.dto.PlayerAccountDto 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.TotpCodeRequest
import com.runicgateway.app.data.api.dto.TotpSetupDto import com.runicgateway.app.data.api.dto.TotpSetupDto
import com.runicgateway.app.data.api.dto.TotpStateDto 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.api.dto.UsernameResponse
import retrofit2.Response
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.HTTP import retrofit2.http.HTTP
import retrofit2.http.PATCH import retrofit2.http.PATCH
@@ -52,4 +62,28 @@ interface MeApi {
// path template explicit alongside the provider argument. // path template explicit alongside the provider argument.
@HTTP(method = "DELETE", path = "api/v1/auth/me/account/identities/{provider}") @HTTP(method = "DELETE", path = "api/v1/auth/me/account/identities/{provider}")
suspend fun unlinkIdentity(@Path("provider") provider: String): Unit suspend fun unlinkIdentity(@Path("provider") provider: String): Unit
// ── Trusted devices (TRUSTED_DEVICES_MFA.md) — devices allowed to skip TOTP ──
@GET("api/v1/auth/me/trusted-devices")
suspend fun trustedDevices(): List<TrustedDeviceDto>
// Raw [Response] so the caller can read the `409 { error, devices }` cap body,
// which a thrown HttpException would discard.
@POST("api/v1/auth/me/trusted-devices")
suspend fun trustThisDevice(@Body body: TrustDeviceRequest): Response<TrustDeviceResultDto>
@DELETE("api/v1/auth/me/trusted-devices/{id}")
suspend fun revokeTrustedDevice(@Path("id") id: Long): RevokedFlagDto
@DELETE("api/v1/auth/me/trusted-devices")
suspend fun revokeAllTrustedDevices(): RevokedCountDto
// ── Recovery (backup) codes ──────────────────────────────────────────────
@GET("api/v1/auth/me/account/recovery-codes/status")
suspend fun recoveryCodesStatus(): RecoveryStatusDto
@POST("api/v1/auth/me/account/recovery-codes/generate")
suspend fun generateRecoveryCodes(@Body body: RecoveryGenerateRequest): RecoveryCodesDto
} }

View File

@@ -55,9 +55,78 @@ data class TotpSetupDto(
@Serializable @Serializable
data class TotpCodeRequest(val code: String) data class TotpCodeRequest(val code: String)
/** Result of enabling/disabling 2FA. */ /**
* Result of enabling/disabling 2FA. Enabling also returns the freshly generated
* single-use [recoveryCodes] **once** (null on disable and for older backends) — the
* app shows them for the user to save and never persists them.
*/
@Serializable @Serializable
data class TotpStateDto(val totp_enabled: Boolean = false) data class TotpStateDto(
val totp_enabled: Boolean = false,
val recoveryCodes: List<String>? = null,
)
// ── Trusted devices & recovery codes (TRUSTED_DEVICES_MFA.md) ───────────────
/**
* An active trusted device (`GET /auth/me/trusted-devices`): a browser/app allowed
* to skip the TOTP step at login. Never carries the token. Timestamps are ISO-8601
* strings shown as-is (advisory display).
*/
@Serializable
data class TrustedDeviceDto(
val id: Long = 0,
val platform: String? = null,
val deviceName: String? = null,
val userAgent: String? = null,
val createdAt: String? = null,
val lastUsedAt: String? = null,
val expiresAt: String? = null,
)
/** `POST /auth/me/trusted-devices` body — an optional friendly label. */
@Serializable
data class TrustDeviceRequest(val deviceName: String? = null)
/**
* `POST /auth/me/trusted-devices` success (native): the opaque [trustToken] to store
* and replay via `X-Trust-Token`. Web receives the token as a cookie and no body token.
*/
@Serializable
data class TrustDeviceResultDto(
val trusted: Boolean = false,
val trustToken: String? = null,
)
/**
* `409 { error: "trusted_device_limit", devices }` from a trust attempt at the cap —
* the app lists [devices] and asks the user to revoke one, then retry.
*/
@Serializable
data class TrustedDeviceLimitDto(
val error: String? = null,
val devices: List<TrustedDeviceDto> = emptyList(),
)
/** `DELETE /auth/me/trusted-devices/:id` — idempotent single-revoke result. */
@Serializable
data class RevokedFlagDto(val revoked: Boolean = false)
/** `DELETE /auth/me/trusted-devices` — count of devices untrusted ("untrust all"). */
@Serializable
data class RevokedCountDto(val revoked: Int = 0)
/** `GET /auth/me/account/recovery-codes/status` — remaining unused count only. */
@Serializable
data class RecoveryStatusDto(val remaining: Int = 0)
/** `POST /auth/me/account/recovery-codes/generate` body — password step-up. */
@Serializable
data class RecoveryGenerateRequest(val currentPassword: String? = null)
/** A fresh single-use recovery-code batch, returned **once** (generate + totp enable). */
@Serializable
data class RecoveryCodesDto(val recoveryCodes: List<String> = emptyList())
/** A linked external identity (`GET /auth/me/account/identities`). */ /** A linked external identity (`GET /auth/me/account/identities`). */
@Serializable @Serializable

View File

@@ -12,12 +12,23 @@ import kotlinx.serialization.Serializable
* safe (§8, recorded for M1). * safe (§8, recorded for M1).
*/ */
/** `POST /auth/mobile/login` body. [code] is only sent on the 2FA retry. */ /**
* `POST /auth/mobile/login` body (trusted-devices contract, TRUSTED_DEVICES_MFA.md).
* [code] is only sent on the 2FA retry; [recoveryCode] is its single-use fallback
* (sent instead of [code]). [trustDevice] asks the server to remember this device so
* future logins skip the second factor — on success the response carries a
* [MobileTokenResponse.trustToken] the app stores and replays via `X-Trust-Token`.
* [device_name] labels the resulting trusted-device / session row (snake_case to
* match the backend field exactly).
*/
@Serializable @Serializable
data class MobileLoginRequest( data class MobileLoginRequest(
val username: String, val username: String,
val password: String, val password: String,
val code: String? = null, val code: String? = null,
val recoveryCode: String? = null,
val trustDevice: Boolean? = null,
val device_name: String? = null,
) )
/** `POST /auth/mobile/refresh` body. */ /** `POST /auth/mobile/refresh` body. */
@@ -34,6 +45,11 @@ data class MobileLogoutRequest(
/** /**
* Success payload from login and refresh: the token pair, the access lifetime * Success payload from login and refresh: the token pair, the access lifetime
* (a zeit/ms duration string, e.g. "15m"), and the safe (secret-stripped) user. * (a zeit/ms duration string, e.g. "15m"), and the safe (secret-stripped) user.
*
* Login additionally carries the trusted-device outcome when `trustDevice` was set:
* [trustToken] is the opaque token to persist + replay (present only when the trust
* was accepted), or [trustLimitReached] + [devices] when the per-user cap blocked it
* (the login itself still succeeded). Refresh never sets these.
*/ */
@Serializable @Serializable
data class MobileTokenResponse( data class MobileTokenResponse(
@@ -41,6 +57,9 @@ data class MobileTokenResponse(
val refreshToken: String, val refreshToken: String,
val expiresIn: String? = null, val expiresIn: String? = null,
val user: SafeUserDto, val user: SafeUserDto,
val trustToken: String? = null,
val trustLimitReached: Boolean = false,
val devices: List<TrustedDeviceDto> = emptyList(),
) )
/** The minimal, non-sensitive user the app needs to render + gate the menu (§5). */ /** The minimal, non-sensitive user the app needs to render + gate the menu (§5). */

View File

@@ -10,10 +10,19 @@ import com.runicgateway.app.data.api.dto.ChangePasswordRequest
import com.runicgateway.app.data.api.dto.ChangeUsernameRequest import com.runicgateway.app.data.api.dto.ChangeUsernameRequest
import com.runicgateway.app.data.api.dto.LinkedIdentityDto import com.runicgateway.app.data.api.dto.LinkedIdentityDto
import com.runicgateway.app.data.api.dto.PlayerAccountDto 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.TotpCodeRequest import com.runicgateway.app.data.api.dto.TotpCodeRequest
import com.runicgateway.app.data.api.dto.TotpSetupDto import com.runicgateway.app.data.api.dto.TotpSetupDto
import com.runicgateway.app.data.api.dto.TotpStateDto import com.runicgateway.app.data.api.dto.TotpStateDto
import com.runicgateway.app.data.api.dto.TrustDeviceRequest
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
import com.runicgateway.app.data.api.dto.TrustedDeviceLimitDto
import com.runicgateway.app.data.api.dto.UsernameResponse import com.runicgateway.app.data.api.dto.UsernameResponse
import kotlinx.coroutines.CancellationException
import kotlinx.serialization.json.Json
import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -26,6 +35,7 @@ import javax.inject.Singleton
@Singleton @Singleton
class AccountRepository @Inject constructor( class AccountRepository @Inject constructor(
private val api: MeApi, private val api: MeApi,
private val json: Json,
) { ) {
suspend fun getAccount(): ApiResult<PlayerAccountDto> = safeApiCall { api.getAccount() } suspend fun getAccount(): ApiResult<PlayerAccountDto> = safeApiCall { api.getAccount() }
@@ -48,4 +58,63 @@ class AccountRepository @Inject constructor(
suspend fun unlinkIdentity(provider: String): ApiResult<Unit> = suspend fun unlinkIdentity(provider: String): ApiResult<Unit> =
safeApiCall { api.unlinkIdentity(provider) } safeApiCall { api.unlinkIdentity(provider) }
// ── Trusted devices (TRUSTED_DEVICES_MFA.md) ───────────────────────────
suspend fun trustedDevices(): ApiResult<List<TrustedDeviceDto>> =
safeApiCall { api.trustedDevices() }
/** The distinct outcomes of trusting the current device — the cap is a first-class case. */
sealed interface TrustOutcome {
/** Trusted; [trustToken] is the opaque token to persist (native). */
data class Trusted(val trustToken: String?) : TrustOutcome
/** At the per-user cap — [devices] must be pruned before retrying. */
data class LimitReached(val devices: List<TrustedDeviceDto>) : TrustOutcome
data object NetworkError : TrustOutcome
data object ServerError : TrustOutcome
}
/**
* Trust the current device. Reads the raw response so the `409 { error, devices }`
* cap body survives (a thrown [retrofit2.HttpException] would discard it).
*/
suspend fun trustThisDevice(deviceName: String? = null): TrustOutcome {
val response = try {
api.trustThisDevice(TrustDeviceRequest(deviceName))
} catch (e: CancellationException) {
throw e
} catch (_: IOException) {
return TrustOutcome.NetworkError
} catch (_: Exception) {
return TrustOutcome.ServerError
}
if (response.isSuccessful) {
return TrustOutcome.Trusted(response.body()?.trustToken)
}
if (response.code() == 409) {
val devices = runCatching {
val raw = response.errorBody()?.string()
if (raw.isNullOrBlank()) emptyList()
else json.decodeFromString<TrustedDeviceLimitDto>(raw).devices
}.getOrDefault(emptyList())
return TrustOutcome.LimitReached(devices)
}
return TrustOutcome.ServerError
}
suspend fun revokeTrustedDevice(id: Long): ApiResult<Boolean> =
safeApiCall { api.revokeTrustedDevice(id).revoked }
suspend fun revokeAllTrustedDevices(): ApiResult<Int> =
safeApiCall { api.revokeAllTrustedDevices().revoked }
// ── Recovery (backup) codes ────────────────────────────────────────────
suspend fun recoveryCodesStatus(): ApiResult<RecoveryStatusDto> =
safeApiCall { api.recoveryCodesStatus() }
/** Regenerate the single-use codes (password step-up). Returned once — never stored. */
suspend fun generateRecoveryCodes(currentPassword: String?): ApiResult<RecoveryCodesDto> =
safeApiCall { api.generateRecoveryCodes(RecoveryGenerateRequest(currentPassword)) }
} }

View File

@@ -3,7 +3,9 @@
*/ */
package com.runicgateway.app.data.repository package com.runicgateway.app.data.repository
import com.runicgateway.app.core.auth.DeviceNameProvider
import com.runicgateway.app.core.auth.SessionManager import com.runicgateway.app.core.auth.SessionManager
import com.runicgateway.app.core.auth.TrustTokenStore
import com.runicgateway.app.core.push.PushManager import com.runicgateway.app.core.push.PushManager
import com.runicgateway.app.data.api.AuthApi import com.runicgateway.app.data.api.AuthApi
import com.runicgateway.app.data.api.SsoApi import com.runicgateway.app.data.api.SsoApi
@@ -12,6 +14,7 @@ import com.runicgateway.app.data.api.dto.MobileLogoutRequest
import com.runicgateway.app.data.api.dto.MobileTokenResponse import com.runicgateway.app.data.api.dto.MobileTokenResponse
import com.runicgateway.app.data.api.dto.SsoProviderDto import com.runicgateway.app.data.api.dto.SsoProviderDto
import com.runicgateway.app.data.api.dto.TotpRequiredError import com.runicgateway.app.data.api.dto.TotpRequiredError
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@@ -32,6 +35,8 @@ class AuthRepository @Inject constructor(
private val ssoApi: SsoApi, private val ssoApi: SsoApi,
private val sessionManager: SessionManager, private val sessionManager: SessionManager,
private val pushManager: PushManager, private val pushManager: PushManager,
private val trustTokenStore: TrustTokenStore,
private val deviceNameProvider: DeviceNameProvider,
private val json: Json, private val json: Json,
) { ) {
@@ -73,7 +78,15 @@ class AuthRepository @Inject constructor(
/** Outcome of a login attempt (§4.1). */ /** Outcome of a login attempt (§4.1). */
sealed interface LoginResult { sealed interface LoginResult {
data object Success : LoginResult /**
* Signed in. [trustLimitReached] is true when "trust this device" was asked
* for but the per-user cap blocked it (the login still succeeded, but no trust
* token was issued); [devices] then lists the trusted devices to manage.
*/
data class Success(
val trustLimitReached: Boolean = false,
val devices: List<TrustedDeviceDto> = emptyList(),
) : LoginResult
/** The account has 2FA on — reveal the code field and resubmit with a code. */ /** The account has 2FA on — reveal the code field and resubmit with a code. */
data object TotpRequired : LoginResult data object TotpRequired : LoginResult
@@ -89,9 +102,32 @@ class AuthRepository @Inject constructor(
data object NetworkError : LoginResult data object NetworkError : LoginResult
} }
suspend fun login(username: String, password: String, code: String? = null): LoginResult { /**
* Native login (TRUSTED_DEVICES_MFA.md). A stored trust token bound to [username]
* rides the `X-Trust-Token` header so a trusted device skips the TOTP step. A
* second factor is either a [code] (TOTP) or a single-use [recoveryCode]. With
* [trustDevice], the server may return a fresh trust token to persist for next time.
*/
suspend fun login(
username: String,
password: String,
code: String? = null,
recoveryCode: String? = null,
trustDevice: Boolean = false,
): LoginResult {
val storedTrustToken = trustTokenStore.tokenFor(username)
val response: Response<MobileTokenResponse> = try { val response: Response<MobileTokenResponse> = try {
authApi.login(MobileLoginRequest(username = username, password = password, code = code)) authApi.login(
MobileLoginRequest(
username = username,
password = password,
code = code,
recoveryCode = recoveryCode,
trustDevice = trustDevice.takeIf { it },
device_name = if (trustDevice) deviceNameProvider.deviceName() else null,
),
trustToken = storedTrustToken,
)
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
} catch (_: IOException) { } catch (_: IOException) {
@@ -100,8 +136,14 @@ class AuthRepository @Inject constructor(
if (response.isSuccessful) { if (response.isSuccessful) {
val body = response.body() ?: return LoginResult.ServerError val body = response.body() ?: return LoginResult.ServerError
// Persist a freshly minted trust token (scoped to this account) so the next
// login skips the second factor — it deliberately outlives logout.
body.trustToken?.let { trustTokenStore.save(username, it) }
sessionManager.onSignedIn(body.accessToken, body.refreshToken, body.user) sessionManager.onSignedIn(body.accessToken, body.refreshToken, body.user)
return LoginResult.Success return LoginResult.Success(
trustLimitReached = body.trustLimitReached,
devices = body.devices,
)
} }
return when (response.code()) { return when (response.code()) {
@@ -111,6 +153,20 @@ class AuthRepository @Inject constructor(
} }
} }
/**
* Persist a trust token minted by the self-service "trust this device" action
* (Account → Trusted Devices), scoped to [username] exactly like the login path.
*/
fun saveTrustToken(username: String, token: String) = trustTokenStore.save(username, token)
/**
* Drop the locally stored trust token so this device stops skipping the TOTP step
* (used after "untrust all" and on a Settings → Server switch). Server-side
* revocation makes any surviving token inert anyway — the next login just prompts
* for the code — so this is a client-side cleanliness step, never load-bearing.
*/
fun clearTrustToken() = trustTokenStore.clear()
/** /**
* Revoke this session (or, with [allDevices], every session) and clear local * Revoke this session (or, with [allDevices], every session) and clear local
* tokens (§4.3). Best-effort: the local session is torn down even if the * tokens (§4.3). Best-effort: the local session is torn down even if the

View File

@@ -4,6 +4,7 @@
package com.runicgateway.app.data.repository package com.runicgateway.app.data.repository
import com.runicgateway.app.core.auth.SessionManager import com.runicgateway.app.core.auth.SessionManager
import com.runicgateway.app.core.auth.TrustTokenStore
import com.runicgateway.app.core.net.BaseUrlHolder import com.runicgateway.app.core.net.BaseUrlHolder
import com.runicgateway.app.core.net.ServerUrl import com.runicgateway.app.core.net.ServerUrl
import com.runicgateway.app.core.prefs.ServerPreferences import com.runicgateway.app.core.prefs.ServerPreferences
@@ -26,6 +27,7 @@ class ConnectionRepository @Inject constructor(
private val prefs: ServerPreferences, private val prefs: ServerPreferences,
private val baseUrlHolder: BaseUrlHolder, private val baseUrlHolder: BaseUrlHolder,
private val sessionManager: SessionManager, private val sessionManager: SessionManager,
private val trustTokenStore: TrustTokenStore,
private val pushManager: com.runicgateway.app.core.push.PushManager, private val pushManager: com.runicgateway.app.core.push.PushManager,
private val config: com.runicgateway.app.core.AppConfig, private val config: com.runicgateway.app.core.AppConfig,
) { ) {
@@ -106,6 +108,9 @@ class ConnectionRepository @Inject constructor(
} }
pushManager.setNtfyUrl(null) pushManager.setNtfyUrl(null)
sessionManager.onSignedOut() sessionManager.onSignedOut()
// The trust token is bound to the old host — drop it so we don't replay it
// against a different shard (it survives a plain logout, but not a host switch).
trustTokenStore.clear()
prefs.clear() prefs.clear()
baseUrlHolder.set(null) baseUrlHolder.set(null)
} }

View File

@@ -3,8 +3,12 @@
*/ */
package com.runicgateway.app.di package com.runicgateway.app.di
import com.runicgateway.app.core.auth.BuildDeviceNameProvider
import com.runicgateway.app.core.auth.DeviceNameProvider
import com.runicgateway.app.core.auth.EncryptedTokenStore import com.runicgateway.app.core.auth.EncryptedTokenStore
import com.runicgateway.app.core.auth.EncryptedTrustTokenStore
import com.runicgateway.app.core.auth.TokenStore import com.runicgateway.app.core.auth.TokenStore
import com.runicgateway.app.core.auth.TrustTokenStore
import com.runicgateway.app.core.auth.sso.EncryptedPendingSsoStore import com.runicgateway.app.core.auth.sso.EncryptedPendingSsoStore
import com.runicgateway.app.core.auth.sso.PendingSsoStore import com.runicgateway.app.core.auth.sso.PendingSsoStore
import dagger.Binds import dagger.Binds
@@ -25,4 +29,13 @@ abstract class StorageModule {
@Binds @Binds
@Singleton @Singleton
abstract fun bindPendingSsoStore(impl: EncryptedPendingSsoStore): PendingSsoStore abstract fun bindPendingSsoStore(impl: EncryptedPendingSsoStore): PendingSsoStore
/** The trusted-device token store — its own encrypted file, outlives session teardown. */
@Binds
@Singleton
abstract fun bindTrustTokenStore(impl: EncryptedTrustTokenStore): TrustTokenStore
@Binds
@Singleton
abstract fun bindDeviceNameProvider(impl: BuildDeviceNameProvider): DeviceNameProvider
} }

View File

@@ -51,6 +51,8 @@ import com.runicgateway.app.core.auth.Session
import com.runicgateway.app.data.api.dto.BrandDto import com.runicgateway.app.data.api.dto.BrandDto
import com.runicgateway.app.ui.auth.AccountScreen import com.runicgateway.app.ui.auth.AccountScreen
import com.runicgateway.app.ui.auth.LoginScreen import com.runicgateway.app.ui.auth.LoginScreen
import com.runicgateway.app.ui.auth.RecoveryCodesScreen
import com.runicgateway.app.ui.auth.TrustedDevicesScreen
import com.runicgateway.app.ui.auth.roleLabelRes import com.runicgateway.app.ui.auth.roleLabelRes
import com.runicgateway.app.ui.contact.ContactScreen import com.runicgateway.app.ui.contact.ContactScreen
import com.runicgateway.app.ui.home.HomeScreen import com.runicgateway.app.ui.home.HomeScreen
@@ -339,12 +341,27 @@ private fun RunicNavHost(
roleLabel = stringResource(roleLabelRes(s.user.role)), roleLabel = stringResource(roleLabelRes(s.user.role)),
onSignOut = onSignOut, onSignOut = onSignOut,
onSignOutEverywhere = onSignOutEverywhere, onSignOutEverywhere = onSignOutEverywhere,
onOpenTrustedDevices = { navController.navigate(Routes.ACCOUNT_TRUSTED_DEVICES) },
onOpenRecoveryCodes = { navController.navigate(Routes.ACCOUNT_RECOVERY_CODES) },
) )
Session.SignedOut -> LaunchedEffect(Unit) { Session.SignedOut -> LaunchedEffect(Unit) {
navController.navigateTopLevel(Routes.HOME) navController.navigateTopLevel(Routes.HOME)
} }
} }
} }
composable(Routes.ACCOUNT_TRUSTED_DEVICES) {
// Signed-in only; a drop (sign-out/demotion) sends the user home (§4.3).
when (session) {
is Session.SignedIn -> TrustedDevicesScreen()
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
}
}
composable(Routes.ACCOUNT_RECOVERY_CODES) {
when (session) {
is Session.SignedIn -> RecoveryCodesScreen()
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
}
}
composable(Routes.NOTIFICATIONS) { composable(Routes.NOTIFICATIONS) {
// Signed-in only; a sign-out (or demotion) sends the user home rather than // Signed-in only; a sign-out (or demotion) sends the user home rather than
// leaving stale settings up. The backend gates every call regardless (§5). // leaving stale settings up. The backend gates every call regardless (§5).

View File

@@ -65,6 +65,8 @@ fun AccountScreen(
roleLabel: String, roleLabel: String,
onSignOut: () -> Unit, onSignOut: () -> Unit,
onSignOutEverywhere: () -> Unit, onSignOutEverywhere: () -> Unit,
onOpenTrustedDevices: () -> Unit,
onOpenRecoveryCodes: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: AccountViewModel = hiltViewModel(), viewModel: AccountViewModel = hiltViewModel(),
) { ) {
@@ -78,10 +80,15 @@ fun AccountScreen(
) { ) {
IdentityCard(username = username, roleLabel = roleLabel) IdentityCard(username = username, roleLabel = roleLabel)
// One-time recovery codes surfaced right after enabling 2FA — save them now.
state.recoveryCodesOnce?.let { codes ->
RecoveryCodesShowOnceCard(codes, onDismiss = viewModel::dismissRecoveryCodes)
}
when (val account = state.account) { when (val account = state.account) {
is UiState.Loading -> LoadingView(Modifier.padding(top = 32.dp)) is UiState.Loading -> LoadingView(Modifier.padding(top = 32.dp))
is UiState.Error -> ErrorView(account.kind, onRetry = viewModel::load, modifier = Modifier.padding(top = 32.dp)) is UiState.Error -> ErrorView(account.kind, onRetry = viewModel::load, modifier = Modifier.padding(top = 32.dp))
is UiState.Success -> AccountSections(account.data, state, viewModel) is UiState.Success -> AccountSections(account.data, state, viewModel, onOpenTrustedDevices, onOpenRecoveryCodes)
} }
HorizontalDivider(Modifier.padding(vertical = 20.dp)) HorizontalDivider(Modifier.padding(vertical = 20.dp))
@@ -117,13 +124,34 @@ private fun AccountSections(
account: PlayerAccountDto, account: PlayerAccountDto,
state: AccountViewModel.State, state: AccountViewModel.State,
viewModel: AccountViewModel, viewModel: AccountViewModel,
onOpenTrustedDevices: () -> Unit,
onOpenRecoveryCodes: () -> Unit,
) { ) {
UsernameSection(account, state, viewModel) UsernameSection(account, state, viewModel)
PasswordSection(account, state, viewModel) PasswordSection(account, state, viewModel)
TwoFactorSection(account, state, viewModel) TwoFactorSection(account, state, viewModel)
SecuritySection(onOpenTrustedDevices, onOpenRecoveryCodes)
IdentitiesSection(state, viewModel) IdentitiesSection(state, viewModel)
} }
/**
* Links to the dedicated trusted-device and recovery-code screens
* (TRUSTED_DEVICES_MFA.md). Kept simple — the management UX lives on those screens.
*/
@Composable
private fun SecuritySection(onOpenTrustedDevices: () -> Unit, onOpenRecoveryCodes: () -> Unit) {
SectionCard(R.string.account_security_title) {
OutlinedButton(
onClick = onOpenTrustedDevices,
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
) { Text(stringResource(R.string.account_security_trusted_devices)) }
OutlinedButton(
onClick = onOpenRecoveryCodes,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
) { Text(stringResource(R.string.account_security_recovery_codes)) }
}
}
@Composable @Composable
private fun SectionCard(@StringRes titleRes: Int, content: @Composable () -> Unit) { private fun SectionCard(@StringRes titleRes: Int, content: @Composable () -> Unit) {
Card(Modifier.fillMaxWidth().padding(top = 12.dp)) { Card(Modifier.fillMaxWidth().padding(top = 12.dp)) {

View File

@@ -49,6 +49,8 @@ class AccountViewModel @Inject constructor(
val busy: Boolean = false, val busy: Boolean = false,
/** The pending TOTP enrollment (QR shown) between setup and enable. */ /** The pending TOTP enrollment (QR shown) between setup and enable. */
val totpSetup: TotpSetupDto? = null, val totpSetup: TotpSetupDto? = null,
/** The single-use recovery codes returned once when 2FA was just enabled. */
val recoveryCodesOnce: List<String>? = null,
val feedback: Feedback? = null, val feedback: Feedback? = null,
) )
@@ -122,9 +124,12 @@ class AccountViewModel @Inject constructor(
if (_state.value.busy) return if (_state.value.busy) return
_state.update { it.copy(busy = true, feedback = null) } _state.update { it.copy(busy = true, feedback = null) }
viewModelScope.launch { viewModelScope.launch {
when (accountRepository.totpEnable(code.trim())) { when (val result = accountRepository.totpEnable(code.trim())) {
is ApiResult.Ok -> { is ApiResult.Ok -> {
_state.update { it.copy(totpSetup = null) } // 2FA enable returns the fresh recovery-code batch once — surface it.
_state.update {
it.copy(totpSetup = null, recoveryCodesOnce = result.data.recoveryCodes?.takeIf(List<String>::isNotEmpty))
}
finish(Section.TOTP, true, R.string.account_totp_enabled) finish(Section.TOTP, true, R.string.account_totp_enabled)
reloadAccount() reloadAccount()
} }
@@ -134,6 +139,9 @@ class AccountViewModel @Inject constructor(
} }
} }
/** Dismiss the one-time recovery-code batch shown after enabling 2FA. */
fun dismissRecoveryCodes() = _state.update { it.copy(recoveryCodesOnce = null) }
fun disableTotp(code: String) { fun disableTotp(code: String) {
if (_state.value.busy) return if (_state.value.busy) return
_state.update { it.copy(busy = true, feedback = null) } _state.update { it.copy(busy = true, feedback = null) }

View File

@@ -5,6 +5,7 @@ package com.runicgateway.app.ui.auth
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -16,6 +17,7 @@ import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -123,6 +125,24 @@ fun LoginScreen(
) )
if (state.totpRequired) { if (state.totpRequired) {
if (state.useRecoveryCode) {
OutlinedTextField(
value = state.recoveryCode,
onValueChange = viewModel::onRecoveryCodeChange,
singleLine = true,
enabled = !state.submitting,
label = { Text(stringResource(R.string.login_recovery_code)) },
supportingText = { Text(stringResource(R.string.login_recovery_hint)) },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Go,
),
keyboardActions = KeyboardActions(onGo = { viewModel.submit() }),
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp),
)
} else {
OutlinedTextField( OutlinedTextField(
value = state.code, value = state.code,
onValueChange = viewModel::onCodeChange, onValueChange = viewModel::onCodeChange,
@@ -141,6 +161,39 @@ fun LoginScreen(
) )
} }
// Toggle between authenticator code and a single-use recovery code.
TextButton(
onClick = { viewModel.onUseRecoveryCodeChange(!state.useRecoveryCode) },
enabled = !state.submitting,
modifier = Modifier.align(Alignment.Start),
) {
Text(
stringResource(
if (state.useRecoveryCode) R.string.login_use_totp_instead
else R.string.login_use_recovery_instead,
),
)
}
// "Trust this device" → skip the 2FA step on future logins here.
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp),
) {
Checkbox(
checked = state.trustDevice,
onCheckedChange = viewModel::onTrustDeviceChange,
enabled = !state.submitting,
)
Text(
text = stringResource(R.string.login_trust_device),
style = MaterialTheme.typography.bodyMedium,
)
}
}
state.error?.let { err -> state.error?.let { err ->
Text( Text(
text = stringResource(loginErrorRes(err)), text = stringResource(loginErrorRes(err)),

View File

@@ -39,8 +39,14 @@ class LoginViewModel @Inject constructor(
val username: String = "", val username: String = "",
val password: String = "", val password: String = "",
val code: String = "", val code: String = "",
/** A single-use recovery code, entered instead of [code] when [useRecoveryCode]. */
val recoveryCode: String = "",
/** True once the account is known to have 2FA on — reveal the code field. */ /** True once the account is known to have 2FA on — reveal the code field. */
val totpRequired: Boolean = false, val totpRequired: Boolean = false,
/** "Enter a recovery code instead" — swap the TOTP field for the recovery field. */
val useRecoveryCode: Boolean = false,
/** "Trust this device" — skip the 2FA step on future logins (TRUSTED_DEVICES_MFA.md). */
val trustDevice: Boolean = false,
val submitting: Boolean = false, val submitting: Boolean = false,
val error: LoginError? = null, val error: LoginError? = null,
val signedIn: Boolean = false, val signedIn: Boolean = false,
@@ -83,6 +89,16 @@ class LoginViewModel @Inject constructor(
fun onCodeChange(value: String) = fun onCodeChange(value: String) =
_state.update { it.copy(code = value.filter(Char::isDigit).take(8), error = null) } _state.update { it.copy(code = value.filter(Char::isDigit).take(8), error = null) }
/** Recovery codes are alphanumeric; keep it permissive, just trim length + noise. */
fun onRecoveryCodeChange(value: String) =
_state.update { it.copy(recoveryCode = value.filterNot(Char::isWhitespace).take(32), error = null) }
fun onTrustDeviceChange(value: Boolean) = _state.update { it.copy(trustDevice = value) }
/** Toggle between the TOTP field and the recovery-code field on the 2FA step. */
fun onUseRecoveryCodeChange(value: Boolean) =
_state.update { it.copy(useRecoveryCode = value, error = null) }
val registerUrl: String? get() = websiteUrls.register() val registerUrl: String? get() = websiteUrls.register()
val forgotPasswordUrl: String? get() = websiteUrls.forgotPassword() val forgotPasswordUrl: String? get() = websiteUrls.forgotPassword()
@@ -141,26 +157,42 @@ class LoginViewModel @Inject constructor(
_state.update { it.copy(error = LoginError.INVALID_CREDENTIALS) } _state.update { it.copy(error = LoginError.INVALID_CREDENTIALS) }
return return
} }
// If 2FA is being requested, a code must accompany the resubmit. // If 2FA is being requested, the chosen second factor must accompany the resubmit.
if (s.totpRequired && s.code.isBlank()) { if (s.totpRequired) {
val factor = if (s.useRecoveryCode) s.recoveryCode else s.code
if (factor.isBlank()) {
_state.update { it.copy(error = LoginError.BAD_CODE) } _state.update { it.copy(error = LoginError.BAD_CODE) }
return return
} }
}
_state.update { it.copy(submitting = true, error = null) } _state.update { it.copy(submitting = true, error = null) }
viewModelScope.launch { viewModelScope.launch {
val code = s.code.trim().takeIf { it.isNotBlank() } // Only one second factor is sent; the recovery toggle picks which.
when (authRepository.login(s.username.trim(), s.password, code)) { val code = s.code.trim().takeIf { it.isNotBlank() && !s.useRecoveryCode }
LoginResult.Success -> val recoveryCode = s.recoveryCode.trim().takeIf { it.isNotBlank() && s.useRecoveryCode }
val result = authRepository.login(
username = s.username.trim(),
password = s.password,
code = code,
recoveryCode = recoveryCode,
trustDevice = s.trustDevice,
)
when (result) {
is LoginResult.Success ->
// The trusted-device cap (result.trustLimitReached) is an edge case:
// login succeeded but the device wasn't remembered. It's surfaced +
// managed on the Trusted Devices screen rather than blocking sign-in.
_state.update { it.copy(submitting = false, signedIn = true) } _state.update { it.copy(submitting = false, signedIn = true) }
LoginResult.TotpRequired -> LoginResult.TotpRequired ->
// Reveal the code field; a wrong code re-lands here as BAD_CODE. // Reveal the 2FA fields; a wrong code/recovery code re-lands here as BAD_CODE.
_state.update { _state.update {
val hadFactor = if (it.useRecoveryCode) it.recoveryCode.isNotBlank() else it.code.isNotBlank()
it.copy( it.copy(
submitting = false, submitting = false,
totpRequired = true, totpRequired = true,
error = if (it.code.isNotBlank()) LoginError.BAD_CODE else null, error = if (hadFactor) LoginError.BAD_CODE else null,
) )
} }

View File

@@ -0,0 +1,153 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.auth
import android.content.Intent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R
import com.runicgateway.app.ui.UiState
/**
* Account → Recovery Codes (TRUSTED_DEVICES_MFA.md): shows the remaining count and a
* password-stepped regenerate that reveals a fresh single-use batch **once**. The
* codes are shown only in memory — copy or share them before leaving; they are never
* stored on the device.
*/
@Composable
fun RecoveryCodesScreen(
modifier: Modifier = Modifier,
viewModel: RecoveryCodesViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
var currentPassword by rememberSaveable { mutableStateOf("") }
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
Text(stringResource(R.string.recovery_codes_title), style = MaterialTheme.typography.titleLarge)
Text(
stringResource(R.string.recovery_codes_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
val remainingText = when (val r = state.remaining) {
is UiState.Success -> stringResource(R.string.recovery_codes_remaining, r.data)
is UiState.Error -> stringResource(R.string.recovery_codes_remaining_unknown)
UiState.Loading -> stringResource(R.string.recovery_codes_remaining_loading)
}
Text(remainingText, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(top = 16.dp))
state.freshCodes?.let { codes ->
RecoveryCodesShowOnceCard(codes, onDismiss = { viewModel.dismissFreshCodes(); currentPassword = "" })
}
OutlinedTextField(
value = currentPassword,
onValueChange = { currentPassword = it },
singleLine = true,
enabled = !state.busy,
label = { Text(stringResource(R.string.account_password_current)) },
supportingText = { Text(stringResource(R.string.recovery_codes_password_hint)) },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
modifier = Modifier.fillMaxWidth().padding(top = 20.dp),
)
state.error?.let { err ->
Text(
text = stringResource(err),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 12.dp),
)
}
Button(
onClick = { viewModel.regenerate(currentPassword) },
enabled = !state.busy,
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
) { Text(stringResource(R.string.recovery_codes_regenerate)) }
}
}
/**
* A show-once display of a freshly generated recovery-code batch, with copy/share and
* a dismiss. Shared by this screen and the "2FA just enabled" surface on AccountScreen.
*/
@Composable
fun RecoveryCodesShowOnceCard(codes: List<String>, onDismiss: () -> Unit) {
val context = LocalContext.current
val clipboard = LocalClipboardManager.current
val joined = remember(codes) { codes.joinToString("\n") }
Card(Modifier.fillMaxWidth().padding(top = 16.dp)) {
Column(Modifier.padding(16.dp)) {
Text(stringResource(R.string.recovery_codes_new_title), style = MaterialTheme.typography.titleMedium)
Text(
stringResource(R.string.recovery_codes_new_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
codes.forEach { code ->
Text(
code,
style = MaterialTheme.typography.bodyLarge.copy(fontFamily = FontFamily.Monospace),
modifier = Modifier.padding(top = 8.dp),
)
}
Row(Modifier.fillMaxWidth().padding(top = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(
onClick = { clipboard.setText(AnnotatedString(joined)) },
) { Text(stringResource(R.string.recovery_codes_copy)) }
OutlinedButton(
onClick = {
val send = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, joined)
}
context.startActivity(Intent.createChooser(send, null))
},
) { Text(stringResource(R.string.recovery_codes_share)) }
Button(onClick = onDismiss) { Text(stringResource(R.string.recovery_codes_done)) }
}
}
}
}

View File

@@ -0,0 +1,84 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.auth
import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.R
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.repository.AccountRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.toUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Drives Account → Recovery Codes (TRUSTED_DEVICES_MFA.md): the remaining-count
* status and a password-stepped regenerate that surfaces a fresh single-use batch
* **once** (never persisted). The freshly generated codes live only in memory until
* the user leaves the screen or dismisses them.
*/
@HiltViewModel
class RecoveryCodesViewModel @Inject constructor(
private val accountRepository: AccountRepository,
) : ViewModel() {
data class State(
/** Remaining unused codes (the status endpoint). */
val remaining: UiState<Int> = UiState.Loading,
/** A just-generated batch to show once, or null. Cleared on dismiss/leave. */
val freshCodes: List<String>? = null,
val busy: Boolean = false,
@param:StringRes val error: Int? = null,
)
private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state.asStateFlow()
init {
load()
}
fun load() {
_state.update { it.copy(remaining = UiState.Loading) }
viewModelScope.launch {
_state.update { it.copy(remaining = accountRepository.recoveryCodesStatus().toUiState().map { s -> s.remaining }) }
}
}
/** Regenerate the codes; [currentPassword] is required for accounts that have one. */
fun regenerate(currentPassword: String?) {
if (_state.value.busy) return
_state.update { it.copy(busy = true, error = null, freshCodes = null) }
viewModelScope.launch {
when (val result = accountRepository.generateRecoveryCodes(currentPassword?.takeIf { it.isNotBlank() })) {
is ApiResult.Ok -> {
_state.update { it.copy(busy = false, freshCodes = result.data.recoveryCodes) }
// Refresh the remaining count to reflect the new batch.
_state.update { it.copy(remaining = accountRepository.recoveryCodesStatus().toUiState().map { s -> s.remaining }) }
}
is ApiResult.HttpError ->
_state.update { it.copy(busy = false, error = R.string.recovery_codes_error) }
is ApiResult.NetworkError ->
_state.update { it.copy(busy = false, error = R.string.error_network) }
}
}
}
/** Drop the shown-once batch from memory (user saved them / navigated away). */
fun dismissFreshCodes() = _state.update { it.copy(freshCodes = null) }
}
/** Map an [UiState] success value (local helper mirroring ApiResult.map). */
private inline fun <T, R> UiState<T>.map(transform: (T) -> R): UiState<R> = when (this) {
is UiState.Success -> UiState.Success(transform(data))
is UiState.Loading -> UiState.Loading
is UiState.Error -> this
}

View File

@@ -0,0 +1,136 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.auth
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
/**
* Account → Trusted Devices (TRUSTED_DEVICES_MFA.md): the devices allowed to skip
* the TOTP step at login. Trust the current device, revoke one, or untrust all. The
* server re-checks ownership on every call; this screen just renders the outcomes.
*/
@Composable
fun TrustedDevicesScreen(
modifier: Modifier = Modifier,
viewModel: TrustedDevicesViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
Text(
stringResource(R.string.trusted_devices_title),
style = MaterialTheme.typography.titleLarge,
)
Text(
stringResource(R.string.trusted_devices_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
state.feedback?.let { fb ->
Text(
text = stringResource(fb.messageRes),
color = if (fb.ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 12.dp),
)
}
when (val devices = state.devices) {
is UiState.Loading -> LoadingView(Modifier.padding(top = 32.dp))
is UiState.Error -> ErrorView(devices.kind, onRetry = viewModel::load, modifier = Modifier.padding(top = 32.dp))
is UiState.Success -> {
if (devices.data.isEmpty()) {
Text(
stringResource(R.string.trusted_devices_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 24.dp),
)
} else {
devices.data.forEach { device ->
TrustedDeviceRow(device, state.busy, onRevoke = { viewModel.revoke(device.id) })
}
}
HorizontalDivider(Modifier.padding(vertical = 20.dp))
Button(
onClick = viewModel::trustThisDevice,
enabled = !state.busy,
modifier = Modifier.fillMaxWidth(),
) { Text(stringResource(R.string.trusted_devices_trust_this)) }
if (devices.data.isNotEmpty()) {
OutlinedButton(
onClick = viewModel::revokeAll,
enabled = !state.busy,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
) { Text(stringResource(R.string.trusted_devices_untrust_all)) }
}
}
}
}
}
@Composable
private fun TrustedDeviceRow(device: TrustedDeviceDto, busy: Boolean, onRevoke: () -> Unit) {
Card(Modifier.fillMaxWidth().padding(top = 12.dp)) {
Row(
Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(
text = device.deviceName?.takeIf { it.isNotBlank() }
?: device.platform?.replaceFirstChar { it.uppercase() }
?: stringResource(R.string.trusted_devices_unknown),
style = MaterialTheme.typography.bodyLarge,
)
device.lastUsedAt?.let {
Text(
stringResource(R.string.trusted_devices_last_used, it),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
TextButton(onClick = onRevoke, enabled = !busy) {
Text(stringResource(R.string.trusted_devices_revoke))
}
}
}
}

View File

@@ -0,0 +1,125 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.auth
import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.R
import com.runicgateway.app.core.auth.DeviceNameProvider
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
import com.runicgateway.app.data.repository.AccountRepository
import com.runicgateway.app.data.repository.AccountRepository.TrustOutcome
import com.runicgateway.app.data.repository.AuthRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.toUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Drives the Trusted Devices screen (TRUSTED_DEVICES_MFA.md): list the devices
* allowed to skip the TOTP step, trust the current one (persisting the returned
* token via [AuthRepository]), revoke one, and untrust all. The trust action folds
* the `409` cap into a first-class [Feedback] telling the user to revoke one first.
*/
@HiltViewModel
class TrustedDevicesViewModel @Inject constructor(
private val accountRepository: AccountRepository,
private val authRepository: AuthRepository,
private val sessionManager: com.runicgateway.app.core.auth.SessionManager,
private val deviceNameProvider: DeviceNameProvider,
) : ViewModel() {
/** A one-shot result banner shown above the list. */
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int)
data class State(
val devices: UiState<List<TrustedDeviceDto>> = UiState.Loading,
val busy: Boolean = false,
val feedback: Feedback? = null,
)
private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state.asStateFlow()
init {
load()
}
fun load() {
_state.update { it.copy(devices = UiState.Loading) }
viewModelScope.launch {
_state.update { it.copy(devices = accountRepository.trustedDevices().toUiState()) }
}
}
/** Trust the current device; persist the returned token so future logins skip 2FA. */
fun trustThisDevice() {
if (_state.value.busy) return
_state.update { it.copy(busy = true, feedback = null) }
viewModelScope.launch {
when (val outcome = accountRepository.trustThisDevice(deviceNameProvider.deviceName())) {
is TrustOutcome.Trusted -> {
// Bind the fresh token to the signed-in username (mirrors the login path).
val username = sessionManager.state.value.let {
(it as? com.runicgateway.app.core.auth.Session.SignedIn)?.user?.username
}
if (outcome.trustToken != null && username != null) {
authRepository.saveTrustToken(username, outcome.trustToken)
}
finish(true, R.string.trusted_devices_trusted)
reload()
}
is TrustOutcome.LimitReached -> finish(false, R.string.trusted_devices_limit)
TrustOutcome.NetworkError -> finish(false, R.string.error_network)
TrustOutcome.ServerError -> finish(false, R.string.trusted_devices_error)
}
}
}
fun revoke(id: Long) {
if (_state.value.busy) return
_state.update { it.copy(busy = true, feedback = null) }
viewModelScope.launch {
when (accountRepository.revokeTrustedDevice(id)) {
is ApiResult.Ok -> {
finish(true, R.string.trusted_devices_revoked)
reload()
}
else -> finish(false, R.string.trusted_devices_error)
}
}
}
fun revokeAll() {
if (_state.value.busy) return
_state.update { it.copy(busy = true, feedback = null) }
viewModelScope.launch {
when (accountRepository.revokeAllTrustedDevices()) {
is ApiResult.Ok -> {
// Every device is untrusted now, including this one — drop the local token.
authRepository.clearTrustToken()
finish(true, R.string.trusted_devices_revoked_all)
reload()
}
else -> finish(false, R.string.trusted_devices_error)
}
}
}
fun clearFeedback() = _state.update { it.copy(feedback = null) }
private suspend fun reload() {
_state.update { it.copy(devices = accountRepository.trustedDevices().toUiState()) }
}
private fun finish(ok: Boolean, @StringRes messageRes: Int) =
_state.update { it.copy(busy = false, feedback = Feedback(ok, messageRes)) }
}

View File

@@ -18,6 +18,10 @@ object Routes {
const val LOGIN = "login" const val LOGIN = "login"
const val ACCOUNT = "account" const val ACCOUNT = "account"
/** MFA management, reached from Account (TRUSTED_DEVICES_MFA.md). Signed-in only. */
const val ACCOUNT_TRUSTED_DEVICES = "account/trusted-devices"
const val ACCOUNT_RECOVERY_CODES = "account/recovery-codes"
/** Opt-in push notification settings (§11, signed-in). */ /** Opt-in push notification settings (§11, signed-in). */
const val NOTIFICATIONS = "notifications" const val NOTIFICATIONS = "notifications"

View File

@@ -201,6 +201,49 @@
<string name="account_identity_unlinked">Account unlinked.</string> <string name="account_identity_unlinked">Account unlinked.</string>
<string name="account_identity_error">Couldn\'t unlink that account.</string> <string name="account_identity_error">Couldn\'t unlink that account.</string>
<!-- ── Trusted devices & recovery codes (TRUSTED_DEVICES_MFA.md) ─────── -->
<!-- Login 2FA step -->
<string name="login_recovery_code">Recovery code</string>
<string name="login_recovery_hint">Enter one of your single-use backup codes.</string>
<string name="login_use_recovery_instead">Use a recovery code instead</string>
<string name="login_use_totp_instead">Use your authenticator code instead</string>
<string name="login_trust_device">Trust this device (skip codes for 30 days)</string>
<!-- Account: security section -->
<string name="account_security_title">Security</string>
<string name="account_security_trusted_devices">Trusted devices</string>
<string name="account_security_recovery_codes">Recovery codes</string>
<!-- Trusted devices screen -->
<string name="trusted_devices_title">Trusted devices</string>
<string name="trusted_devices_subtitle">These devices can skip the authentication code at sign-in for 30 days.</string>
<string name="trusted_devices_empty">No trusted devices yet.</string>
<string name="trusted_devices_unknown">Unknown device</string>
<string name="trusted_devices_last_used">Last used %1$s</string>
<string name="trusted_devices_revoke">Revoke</string>
<string name="trusted_devices_trust_this">Trust this device</string>
<string name="trusted_devices_untrust_all">Untrust all devices</string>
<string name="trusted_devices_trusted">This device is now trusted.</string>
<string name="trusted_devices_revoked">Device revoked.</string>
<string name="trusted_devices_revoked_all">All devices untrusted.</string>
<string name="trusted_devices_limit">You\'ve reached the trusted-device limit. Revoke one, then try again.</string>
<string name="trusted_devices_error">Something went wrong. Please try again.</string>
<!-- Recovery codes screen -->
<string name="recovery_codes_title">Recovery codes</string>
<string name="recovery_codes_subtitle">Single-use backup codes let you sign in if you lose your authenticator.</string>
<string name="recovery_codes_remaining">%1$d codes remaining</string>
<string name="recovery_codes_remaining_loading">Checking remaining codes…</string>
<string name="recovery_codes_remaining_unknown">Couldn\'t load the remaining count.</string>
<string name="recovery_codes_password_hint">Enter your current password to generate a new set.</string>
<string name="recovery_codes_regenerate">Generate new codes</string>
<string name="recovery_codes_error">Couldn\'t generate codes. Check your password and that two-factor is on.</string>
<string name="recovery_codes_new_title">Your new recovery codes</string>
<string name="recovery_codes_new_hint">Save these now — they\'re shown only once and each works a single time.</string>
<string name="recovery_codes_copy">Copy</string>
<string name="recovery_codes_share">Share</string>
<string name="recovery_codes_done">Done</string>
<!-- ── Player: game-account linking (§6.3) ─────────────────────────── --> <!-- ── Player: game-account linking (§6.3) ─────────────────────────── -->
<string name="player_link_title">Link your game account</string> <string name="player_link_title">Link your game account</string>
<string name="player_link_hint">In game, type [link to get a one-time code, then enter it here to see your characters, vendors and houses.</string> <string name="player_link_hint">In game, type [link to get a one-time code, then enter it here to see your characters, vendors and houses.</string>

View File

@@ -54,6 +54,66 @@ class AccountDtoTest {
assertTrue(json.decodeFromString<TotpStateDto>("""{"totp_enabled":true}""").totp_enabled) 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() { @Test fun linkedIdentityDecodes() {
val dto = json.decodeFromString<LinkedIdentityDto>( val dto = json.decodeFromString<LinkedIdentityDto>(
"""{"provider":"discord","email":"u@example.com","linked_at":"2026-07-19T22:00:00Z"}""", """{"provider":"discord","email":"u@example.com","linked_at":"2026-07-19T22:00:00Z"}""",

View File

@@ -68,4 +68,35 @@ class AuthDtoTest {
assertNull(dto.expiresIn) assertNull(dto.expiresIn)
assertEquals("admin", dto.user.role) 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())
}
} }

View File

@@ -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)
}
}