diff --git a/app/src/main/java/com/runicgateway/app/core/auth/DeviceNameProvider.kt b/app/src/main/java/com/runicgateway/app/core/auth/DeviceNameProvider.kt new file mode 100644 index 0000000..f94dcf7 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/core/auth/DeviceNameProvider.kt @@ -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 } + } +} diff --git a/app/src/main/java/com/runicgateway/app/core/auth/EncryptedTrustTokenStore.kt b/app/src/main/java/com/runicgateway/app/core/auth/EncryptedTrustTokenStore.kt new file mode 100644 index 0000000..ba58800 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/core/auth/EncryptedTrustTokenStore.kt @@ -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" + } +} diff --git a/app/src/main/java/com/runicgateway/app/core/auth/TrustTokenStore.kt b/app/src/main/java/com/runicgateway/app/core/auth/TrustTokenStore.kt new file mode 100644 index 0000000..5f8705b --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/core/auth/TrustTokenStore.kt @@ -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() +} diff --git a/app/src/main/java/com/runicgateway/app/data/api/AuthApi.kt b/app/src/main/java/com/runicgateway/app/data/api/AuthApi.kt index 107319e..df80cdb 100644 --- a/app/src/main/java/com/runicgateway/app/data/api/AuthApi.kt +++ b/app/src/main/java/com/runicgateway/app/data/api/AuthApi.kt @@ -10,6 +10,7 @@ import com.runicgateway.app.data.api.dto.MobileTokenResponse import retrofit2.Response import retrofit2.http.Body import retrofit2.http.GET +import retrofit2.http.Header import retrofit2.http.Headers import retrofit2.http.POST @@ -27,9 +28,15 @@ import retrofit2.http.POST interface AuthApi { // 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") @POST("api/v1/auth/mobile/login") - suspend fun login(@Body body: MobileLoginRequest): Response + suspend fun login( + @Body body: MobileLoginRequest, + @Header("X-Trust-Token") trustToken: String? = null, + ): Response @POST("api/v1/auth/mobile/logout") suspend fun logout(@Body body: MobileLogoutRequest): Response diff --git a/app/src/main/java/com/runicgateway/app/data/api/MeApi.kt b/app/src/main/java/com/runicgateway/app/data/api/MeApi.kt index 73c907d..49df279 100644 --- a/app/src/main/java/com/runicgateway/app/data/api/MeApi.kt +++ b/app/src/main/java/com/runicgateway/app/data/api/MeApi.kt @@ -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.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 retrofit2.Response import retrofit2.http.Body +import retrofit2.http.DELETE import retrofit2.http.GET import retrofit2.http.HTTP import retrofit2.http.PATCH @@ -52,4 +62,28 @@ interface MeApi { // path template explicit alongside the provider argument. @HTTP(method = "DELETE", path = "api/v1/auth/me/account/identities/{provider}") 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 + + // 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 + + @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 } diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/AccountDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/AccountDto.kt index 094525d..9d5aaa6 100644 --- a/app/src/main/java/com/runicgateway/app/data/api/dto/AccountDto.kt +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/AccountDto.kt @@ -55,9 +55,78 @@ data class TotpSetupDto( @Serializable 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 -data class TotpStateDto(val totp_enabled: Boolean = false) +data class TotpStateDto( + val totp_enabled: Boolean = false, + val recoveryCodes: List? = 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 = 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 = emptyList()) /** A linked external identity (`GET /auth/me/account/identities`). */ @Serializable diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/AuthDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/AuthDto.kt index 284a570..d733c10 100644 --- a/app/src/main/java/com/runicgateway/app/data/api/dto/AuthDto.kt +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/AuthDto.kt @@ -12,12 +12,23 @@ import kotlinx.serialization.Serializable * 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 data class MobileLoginRequest( val username: String, val password: String, val code: String? = null, + val recoveryCode: String? = null, + val trustDevice: Boolean? = null, + val device_name: String? = null, ) /** `POST /auth/mobile/refresh` body. */ @@ -34,6 +45,11 @@ data class MobileLogoutRequest( /** * 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. + * + * 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 data class MobileTokenResponse( @@ -41,6 +57,9 @@ data class MobileTokenResponse( val refreshToken: String, val expiresIn: String? = null, val user: SafeUserDto, + val trustToken: String? = null, + val trustLimitReached: Boolean = false, + val devices: List = emptyList(), ) /** The minimal, non-sensitive user the app needs to render + gate the menu (§5). */ diff --git a/app/src/main/java/com/runicgateway/app/data/repository/AccountRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/AccountRepository.kt index fc4f9fc..cb5ebbc 100644 --- a/app/src/main/java/com/runicgateway/app/data/repository/AccountRepository.kt +++ b/app/src/main/java/com/runicgateway/app/data/repository/AccountRepository.kt @@ -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.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.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.TrustedDeviceDto +import com.runicgateway.app.data.api.dto.TrustedDeviceLimitDto 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.Singleton @@ -26,6 +35,7 @@ import javax.inject.Singleton @Singleton class AccountRepository @Inject constructor( private val api: MeApi, + private val json: Json, ) { suspend fun getAccount(): ApiResult = safeApiCall { api.getAccount() } @@ -48,4 +58,63 @@ class AccountRepository @Inject constructor( suspend fun unlinkIdentity(provider: String): ApiResult = safeApiCall { api.unlinkIdentity(provider) } + + // ── Trusted devices (TRUSTED_DEVICES_MFA.md) ─────────────────────────── + + suspend fun trustedDevices(): ApiResult> = + 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) : 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(raw).devices + }.getOrDefault(emptyList()) + return TrustOutcome.LimitReached(devices) + } + return TrustOutcome.ServerError + } + + suspend fun revokeTrustedDevice(id: Long): ApiResult = + safeApiCall { api.revokeTrustedDevice(id).revoked } + + suspend fun revokeAllTrustedDevices(): ApiResult = + safeApiCall { api.revokeAllTrustedDevices().revoked } + + // ── Recovery (backup) codes ──────────────────────────────────────────── + + suspend fun recoveryCodesStatus(): ApiResult = + safeApiCall { api.recoveryCodesStatus() } + + /** Regenerate the single-use codes (password step-up). Returned once — never stored. */ + suspend fun generateRecoveryCodes(currentPassword: String?): ApiResult = + safeApiCall { api.generateRecoveryCodes(RecoveryGenerateRequest(currentPassword)) } } diff --git a/app/src/main/java/com/runicgateway/app/data/repository/AuthRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/AuthRepository.kt index 79a3207..f49da81 100644 --- a/app/src/main/java/com/runicgateway/app/data/repository/AuthRepository.kt +++ b/app/src/main/java/com/runicgateway/app/data/repository/AuthRepository.kt @@ -3,7 +3,9 @@ */ 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.TrustTokenStore import com.runicgateway.app.core.push.PushManager import com.runicgateway.app.data.api.AuthApi 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.SsoProviderDto import com.runicgateway.app.data.api.dto.TotpRequiredError +import com.runicgateway.app.data.api.dto.TrustedDeviceDto import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import kotlinx.serialization.json.Json @@ -32,6 +35,8 @@ class AuthRepository @Inject constructor( private val ssoApi: SsoApi, private val sessionManager: SessionManager, private val pushManager: PushManager, + private val trustTokenStore: TrustTokenStore, + private val deviceNameProvider: DeviceNameProvider, private val json: Json, ) { @@ -73,7 +78,15 @@ class AuthRepository @Inject constructor( /** Outcome of a login attempt (§4.1). */ 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 = emptyList(), + ) : LoginResult /** The account has 2FA on — reveal the code field and resubmit with a code. */ data object TotpRequired : LoginResult @@ -89,9 +102,32 @@ class AuthRepository @Inject constructor( 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 = 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) { throw e } catch (_: IOException) { @@ -100,8 +136,14 @@ class AuthRepository @Inject constructor( if (response.isSuccessful) { 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) - return LoginResult.Success + return LoginResult.Success( + trustLimitReached = body.trustLimitReached, + devices = body.devices, + ) } 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 * tokens (§4.3). Best-effort: the local session is torn down even if the diff --git a/app/src/main/java/com/runicgateway/app/data/repository/ConnectionRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/ConnectionRepository.kt index 998c7cb..f3ebd0a 100644 --- a/app/src/main/java/com/runicgateway/app/data/repository/ConnectionRepository.kt +++ b/app/src/main/java/com/runicgateway/app/data/repository/ConnectionRepository.kt @@ -4,6 +4,7 @@ package com.runicgateway.app.data.repository 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.ServerUrl import com.runicgateway.app.core.prefs.ServerPreferences @@ -26,6 +27,7 @@ class ConnectionRepository @Inject constructor( private val prefs: ServerPreferences, private val baseUrlHolder: BaseUrlHolder, private val sessionManager: SessionManager, + private val trustTokenStore: TrustTokenStore, private val pushManager: com.runicgateway.app.core.push.PushManager, private val config: com.runicgateway.app.core.AppConfig, ) { @@ -106,6 +108,9 @@ class ConnectionRepository @Inject constructor( } pushManager.setNtfyUrl(null) 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() baseUrlHolder.set(null) } diff --git a/app/src/main/java/com/runicgateway/app/di/StorageModule.kt b/app/src/main/java/com/runicgateway/app/di/StorageModule.kt index 96c9c91..750d046 100644 --- a/app/src/main/java/com/runicgateway/app/di/StorageModule.kt +++ b/app/src/main/java/com/runicgateway/app/di/StorageModule.kt @@ -3,8 +3,12 @@ */ 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.EncryptedTrustTokenStore 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.PendingSsoStore import dagger.Binds @@ -25,4 +29,13 @@ abstract class StorageModule { @Binds @Singleton 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 } diff --git a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt index 1f17854..3f4db7c 100644 --- a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt +++ b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt @@ -51,6 +51,8 @@ import com.runicgateway.app.core.auth.Session import com.runicgateway.app.data.api.dto.BrandDto import com.runicgateway.app.ui.auth.AccountScreen 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.contact.ContactScreen import com.runicgateway.app.ui.home.HomeScreen @@ -339,12 +341,27 @@ private fun RunicNavHost( roleLabel = stringResource(roleLabelRes(s.user.role)), onSignOut = onSignOut, onSignOutEverywhere = onSignOutEverywhere, + onOpenTrustedDevices = { navController.navigate(Routes.ACCOUNT_TRUSTED_DEVICES) }, + onOpenRecoveryCodes = { navController.navigate(Routes.ACCOUNT_RECOVERY_CODES) }, ) Session.SignedOut -> LaunchedEffect(Unit) { 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) { // 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). diff --git a/app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt b/app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt index 3af9f29..f771c93 100644 --- a/app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt @@ -65,6 +65,8 @@ fun AccountScreen( roleLabel: String, onSignOut: () -> Unit, onSignOutEverywhere: () -> Unit, + onOpenTrustedDevices: () -> Unit, + onOpenRecoveryCodes: () -> Unit, modifier: Modifier = Modifier, viewModel: AccountViewModel = hiltViewModel(), ) { @@ -78,10 +80,15 @@ fun AccountScreen( ) { 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) { 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.Success -> AccountSections(account.data, state, viewModel) + is UiState.Success -> AccountSections(account.data, state, viewModel, onOpenTrustedDevices, onOpenRecoveryCodes) } HorizontalDivider(Modifier.padding(vertical = 20.dp)) @@ -117,13 +124,34 @@ private fun AccountSections( account: PlayerAccountDto, state: AccountViewModel.State, viewModel: AccountViewModel, + onOpenTrustedDevices: () -> Unit, + onOpenRecoveryCodes: () -> Unit, ) { UsernameSection(account, state, viewModel) PasswordSection(account, state, viewModel) TwoFactorSection(account, state, viewModel) + SecuritySection(onOpenTrustedDevices, onOpenRecoveryCodes) 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 private fun SectionCard(@StringRes titleRes: Int, content: @Composable () -> Unit) { Card(Modifier.fillMaxWidth().padding(top = 12.dp)) { diff --git a/app/src/main/java/com/runicgateway/app/ui/auth/AccountViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/auth/AccountViewModel.kt index 3efa8db..5d0a47d 100644 --- a/app/src/main/java/com/runicgateway/app/ui/auth/AccountViewModel.kt +++ b/app/src/main/java/com/runicgateway/app/ui/auth/AccountViewModel.kt @@ -49,6 +49,8 @@ class AccountViewModel @Inject constructor( val busy: Boolean = false, /** The pending TOTP enrollment (QR shown) between setup and enable. */ val totpSetup: TotpSetupDto? = null, + /** The single-use recovery codes returned once when 2FA was just enabled. */ + val recoveryCodesOnce: List? = null, val feedback: Feedback? = null, ) @@ -122,9 +124,12 @@ class AccountViewModel @Inject constructor( if (_state.value.busy) return _state.update { it.copy(busy = true, feedback = null) } viewModelScope.launch { - when (accountRepository.totpEnable(code.trim())) { + when (val result = accountRepository.totpEnable(code.trim())) { 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::isNotEmpty)) + } finish(Section.TOTP, true, R.string.account_totp_enabled) 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) { if (_state.value.busy) return _state.update { it.copy(busy = true, feedback = null) } diff --git a/app/src/main/java/com/runicgateway/app/ui/auth/LoginScreen.kt b/app/src/main/java/com/runicgateway/app/ui/auth/LoginScreen.kt index 286d95b..b2fa4e1 100644 --- a/app/src/main/java/com/runicgateway/app/ui/auth/LoginScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/auth/LoginScreen.kt @@ -5,6 +5,7 @@ package com.runicgateway.app.ui.auth import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize 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.verticalScroll import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme @@ -123,22 +125,73 @@ fun LoginScreen( ) if (state.totpRequired) { - OutlinedTextField( - value = state.code, - onValueChange = viewModel::onCodeChange, - singleLine = true, + 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( + value = state.code, + onValueChange = viewModel::onCodeChange, + singleLine = true, + enabled = !state.submitting, + label = { Text(stringResource(R.string.login_totp_code)) }, + supportingText = { Text(stringResource(R.string.login_totp_hint)) }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.NumberPassword, + imeAction = ImeAction.Go, + ), + keyboardActions = KeyboardActions(onGo = { viewModel.submit() }), + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp), + ) + } + + // Toggle between authenticator code and a single-use recovery code. + TextButton( + onClick = { viewModel.onUseRecoveryCodeChange(!state.useRecoveryCode) }, enabled = !state.submitting, - label = { Text(stringResource(R.string.login_totp_code)) }, - supportingText = { Text(stringResource(R.string.login_totp_hint)) }, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.NumberPassword, - imeAction = ImeAction.Go, - ), - keyboardActions = KeyboardActions(onGo = { viewModel.submit() }), + 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 = 12.dp), - ) + .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 -> diff --git a/app/src/main/java/com/runicgateway/app/ui/auth/LoginViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/auth/LoginViewModel.kt index 3080407..f71865f 100644 --- a/app/src/main/java/com/runicgateway/app/ui/auth/LoginViewModel.kt +++ b/app/src/main/java/com/runicgateway/app/ui/auth/LoginViewModel.kt @@ -39,8 +39,14 @@ class LoginViewModel @Inject constructor( val username: String = "", val password: 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. */ 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 error: LoginError? = null, val signedIn: Boolean = false, @@ -83,6 +89,16 @@ class LoginViewModel @Inject constructor( fun onCodeChange(value: String) = _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 forgotPasswordUrl: String? get() = websiteUrls.forgotPassword() @@ -141,26 +157,42 @@ class LoginViewModel @Inject constructor( _state.update { it.copy(error = LoginError.INVALID_CREDENTIALS) } return } - // If 2FA is being requested, a code must accompany the resubmit. - if (s.totpRequired && s.code.isBlank()) { - _state.update { it.copy(error = LoginError.BAD_CODE) } - return + // If 2FA is being requested, the chosen second factor must accompany the resubmit. + if (s.totpRequired) { + val factor = if (s.useRecoveryCode) s.recoveryCode else s.code + if (factor.isBlank()) { + _state.update { it.copy(error = LoginError.BAD_CODE) } + return + } } _state.update { it.copy(submitting = true, error = null) } viewModelScope.launch { - val code = s.code.trim().takeIf { it.isNotBlank() } - when (authRepository.login(s.username.trim(), s.password, code)) { - LoginResult.Success -> + // Only one second factor is sent; the recovery toggle picks which. + val code = s.code.trim().takeIf { it.isNotBlank() && !s.useRecoveryCode } + 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) } 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 { + val hadFactor = if (it.useRecoveryCode) it.recoveryCode.isNotBlank() else it.code.isNotBlank() it.copy( submitting = false, totpRequired = true, - error = if (it.code.isNotBlank()) LoginError.BAD_CODE else null, + error = if (hadFactor) LoginError.BAD_CODE else null, ) } diff --git a/app/src/main/java/com/runicgateway/app/ui/auth/RecoveryCodesScreen.kt b/app/src/main/java/com/runicgateway/app/ui/auth/RecoveryCodesScreen.kt new file mode 100644 index 0000000..4c518f6 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/auth/RecoveryCodesScreen.kt @@ -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, 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)) } + } + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/auth/RecoveryCodesViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/auth/RecoveryCodesViewModel.kt new file mode 100644 index 0000000..14e163f --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/auth/RecoveryCodesViewModel.kt @@ -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 = UiState.Loading, + /** A just-generated batch to show once, or null. Cleared on dismiss/leave. */ + val freshCodes: List? = null, + val busy: Boolean = false, + @param:StringRes val error: Int? = null, + ) + + private val _state = MutableStateFlow(State()) + val state: StateFlow = _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 UiState.map(transform: (T) -> R): UiState = when (this) { + is UiState.Success -> UiState.Success(transform(data)) + is UiState.Loading -> UiState.Loading + is UiState.Error -> this +} diff --git a/app/src/main/java/com/runicgateway/app/ui/auth/TrustedDevicesScreen.kt b/app/src/main/java/com/runicgateway/app/ui/auth/TrustedDevicesScreen.kt new file mode 100644 index 0000000..c8e45fb --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/auth/TrustedDevicesScreen.kt @@ -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)) + } + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/auth/TrustedDevicesViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/auth/TrustedDevicesViewModel.kt new file mode 100644 index 0000000..1a68349 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/auth/TrustedDevicesViewModel.kt @@ -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> = UiState.Loading, + val busy: Boolean = false, + val feedback: Feedback? = null, + ) + + private val _state = MutableStateFlow(State()) + val state: StateFlow = _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)) } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt b/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt index f088cf6..cc83ba9 100644 --- a/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt +++ b/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt @@ -18,6 +18,10 @@ object Routes { const val LOGIN = "login" 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). */ const val NOTIFICATIONS = "notifications" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 77e38ef..7ae4813 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -201,6 +201,49 @@ Account unlinked. Couldn\'t unlink that account. + + + Recovery code + Enter one of your single-use backup codes. + Use a recovery code instead + Use your authenticator code instead + Trust this device (skip codes for 30 days) + + + Security + Trusted devices + Recovery codes + + + Trusted devices + These devices can skip the authentication code at sign-in for 30 days. + No trusted devices yet. + Unknown device + Last used %1$s + Revoke + Trust this device + Untrust all devices + This device is now trusted. + Device revoked. + All devices untrusted. + You\'ve reached the trusted-device limit. Revoke one, then try again. + Something went wrong. Please try again. + + + Recovery codes + Single-use backup codes let you sign in if you lose your authenticator. + %1$d codes remaining + Checking remaining codes… + Couldn\'t load the remaining count. + Enter your current password to generate a new set. + Generate new codes + Couldn\'t generate codes. Check your password and that two-factor is on. + Your new recovery codes + Save these now — they\'re shown only once and each works a single time. + Copy + Share + Done + Link your game account In game, type [link to get a one-time code, then enter it here to see your characters, vendors and houses. diff --git a/app/src/test/java/com/runicgateway/app/data/api/dto/AccountDtoTest.kt b/app/src/test/java/com/runicgateway/app/data/api/dto/AccountDtoTest.kt index ccbadf9..43d0d4d 100644 --- a/app/src/test/java/com/runicgateway/app/data/api/dto/AccountDtoTest.kt +++ b/app/src/test/java/com/runicgateway/app/data/api/dto/AccountDtoTest.kt @@ -54,6 +54,66 @@ class AccountDtoTest { assertTrue(json.decodeFromString("""{"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( + """{"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("""{"totp_enabled":false}""") + assertFalse(dto.totp_enabled) + assertEquals(null, dto.recoveryCodes) + } + + @Test fun trustedDeviceDecodes() { + val dto = json.decodeFromString( + """{"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( + """{"trusted":true,"trustToken":"opaque-token-abc"}""", + ) + assertTrue(dto.trusted) + assertEquals("opaque-token-abc", dto.trustToken) + } + + @Test fun trustedDeviceLimitDecodesDevices() { + val dto = json.decodeFromString( + """{"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("""{"remaining":7}""").remaining) + val codes = json.decodeFromString( + """{"recoveryCodes":["c1","c2","c3"]}""", + ) + assertEquals(3, codes.recoveryCodes.size) + } + + @Test fun revokedResultsDecode() { + assertTrue(json.decodeFromString("""{"revoked":true}""").revoked) + assertEquals(4, json.decodeFromString("""{"revoked":4}""").revoked) + } + @Test fun linkedIdentityDecodes() { val dto = json.decodeFromString( """{"provider":"discord","email":"u@example.com","linked_at":"2026-07-19T22:00:00Z"}""", diff --git a/app/src/test/java/com/runicgateway/app/data/api/dto/AuthDtoTest.kt b/app/src/test/java/com/runicgateway/app/data/api/dto/AuthDtoTest.kt index a1b153b..17068d6 100644 --- a/app/src/test/java/com/runicgateway/app/data/api/dto/AuthDtoTest.kt +++ b/app/src/test/java/com/runicgateway/app/data/api/dto/AuthDtoTest.kt @@ -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( + """{"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( + """{"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( + """{"accessToken":"a","refreshToken":"r","user":{"id":4,"username":"d","role":"player"}}""", + ) + assertNull(dto.trustToken) + assertFalse(dto.trustLimitReached) + assertTrue(dto.devices.isEmpty()) + } } diff --git a/app/src/test/java/com/runicgateway/app/data/repository/AccountTrustedDevicesTest.kt b/app/src/test/java/com/runicgateway/app/data/repository/AccountTrustedDevicesTest.kt new file mode 100644 index 0000000..4e59f44 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/data/repository/AccountTrustedDevicesTest.kt @@ -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? = null, + var devices: List = emptyList(), + var revokeFlag: Boolean = true, + var revokeCount: Int = 0, + var remaining: Int = 0, + var generated: List = emptyList(), + ) : MeApi { + override suspend fun trustedDevices(): List = devices + override suspend fun trustThisDevice(body: TrustDeviceRequest): Response = + 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 = 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) + } +}