Merge pull request 'feat(m3): native auth — login+TOTP, token storage, refresh, access-level menu' (#8) from feat/m3-auth into main
Reviewed-on: #8 Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
@@ -93,6 +93,9 @@ dependencies {
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.androidx.security.crypto)
|
||||
|
||||
// Web hand-off (Chrome Custom Tabs) for register / invite / reset / SSO (§4.2)
|
||||
implementation(libs.androidx.browser)
|
||||
|
||||
// Images
|
||||
implementation(libs.coil.compose)
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
/**
|
||||
* [TokenStore] backed by Jetpack Security's [EncryptedSharedPreferences]
|
||||
* (Tink/AES-256-GCM), so the token pair is encrypted at rest (PLAN.md §2, §4.3).
|
||||
* The base URL stays in plain DataStore ([com.runicgateway.app.core.prefs.ServerPreferences]);
|
||||
* only tokens live here.
|
||||
*
|
||||
* The prefs handle is created lazily so a first-launch device (no session yet)
|
||||
* pays the keystore cost only once a user actually signs in.
|
||||
*/
|
||||
@Singleton
|
||||
class EncryptedTokenStore @Inject constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
) : TokenStore {
|
||||
|
||||
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 load(): StoredSession? {
|
||||
val access = prefs.getString(KEY_ACCESS, null) ?: return null
|
||||
val refresh = prefs.getString(KEY_REFRESH, null) ?: return null
|
||||
val username = prefs.getString(KEY_USERNAME, null) ?: return null
|
||||
val role = prefs.getString(KEY_ROLE, null) ?: return null
|
||||
val id = prefs.getLong(KEY_USER_ID, -1L)
|
||||
if (id < 0) return null
|
||||
return StoredSession(access, refresh, id, username, role)
|
||||
}
|
||||
|
||||
override fun save(session: StoredSession) {
|
||||
prefs.edit()
|
||||
.putString(KEY_ACCESS, session.accessToken)
|
||||
.putString(KEY_REFRESH, session.refreshToken)
|
||||
.putLong(KEY_USER_ID, session.userId)
|
||||
.putString(KEY_USERNAME, session.username)
|
||||
.putString(KEY_ROLE, session.role)
|
||||
.apply()
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
prefs.edit().clear().apply()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PREFS_NAME = "runic_session"
|
||||
const val KEY_ACCESS = "access_token"
|
||||
const val KEY_REFRESH = "refresh_token"
|
||||
const val KEY_USER_ID = "user_id"
|
||||
const val KEY_USERNAME = "username"
|
||||
const val KEY_ROLE = "role"
|
||||
}
|
||||
}
|
||||
60
app/src/main/java/com/runicgateway/app/core/auth/Session.kt
Normal file
60
app/src/main/java/com/runicgateway/app/core/auth/Session.kt
Normal file
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.auth
|
||||
|
||||
import com.runicgateway.app.data.api.dto.SafeUserDto
|
||||
|
||||
/**
|
||||
* The signed-in identity the app carries (PLAN.md §4.3, §5). Role is *advisory*
|
||||
* for menu rendering only — the backend re-checks every gated call, so the app
|
||||
* treats a 403 as authoritative and never assumes access from this value.
|
||||
*/
|
||||
data class SessionUser(
|
||||
val id: Long,
|
||||
val username: String,
|
||||
val role: Role,
|
||||
) {
|
||||
val isPlayer: Boolean get() = role == Role.PLAYER
|
||||
}
|
||||
|
||||
/**
|
||||
* The account roles the backend issues. The three staff roles are gated by
|
||||
* capability, not rank (PLAN.md §5); [UNKNOWN] absorbs any future role so an
|
||||
* additive backend change never crashes the menu.
|
||||
*/
|
||||
enum class Role(val wire: String) {
|
||||
PLAYER("player"),
|
||||
MODERATOR("moderator"),
|
||||
EDITOR("editor"),
|
||||
ADMIN("admin"),
|
||||
UNKNOWN("");
|
||||
|
||||
val isStaff: Boolean get() = this == MODERATOR || this == EDITOR || this == ADMIN
|
||||
|
||||
companion object {
|
||||
fun fromWire(value: String?): Role =
|
||||
entries.firstOrNull { it.wire.equals(value, ignoreCase = true) } ?: UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
/** The two auth states the UI observes. */
|
||||
sealed interface Session {
|
||||
data object SignedOut : Session
|
||||
data class SignedIn(val user: SessionUser) : Session
|
||||
}
|
||||
|
||||
internal fun SafeUserDto.toSessionUser(): SessionUser =
|
||||
SessionUser(id = id, username = username, role = Role.fromWire(role))
|
||||
|
||||
internal fun SafeUserDto.toStored(accessToken: String, refreshToken: String): StoredSession =
|
||||
StoredSession(
|
||||
accessToken = accessToken,
|
||||
refreshToken = refreshToken,
|
||||
userId = id,
|
||||
username = username,
|
||||
role = role,
|
||||
)
|
||||
|
||||
internal fun StoredSession.toSessionUser(): SessionUser =
|
||||
SessionUser(id = userId, username = username, role = Role.fromWire(role))
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.auth
|
||||
|
||||
import com.runicgateway.app.data.api.dto.SafeUserDto
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The single source of truth for the current session (PLAN.md §4.3). It holds the
|
||||
* in-memory token pair the network layer reads on every call, mirrors the
|
||||
* signed-in identity into an observable [state] the UI + menu react to, and keeps
|
||||
* the encrypted [TokenStore] in sync.
|
||||
*
|
||||
* Threading: [state] and the token holders are read from the UI thread and
|
||||
* written from both coroutines (login/logout) and the OkHttp
|
||||
* [com.runicgateway.app.core.net.TokenAuthenticator] dispatcher thread (silent
|
||||
* refresh), so tokens live in [AtomicReference]s and the mutators are
|
||||
* `@Synchronized` to keep the token pair and [state] consistent with each other.
|
||||
*/
|
||||
@Singleton
|
||||
class SessionManager @Inject constructor(
|
||||
private val store: TokenStore,
|
||||
) {
|
||||
private val accessRef = AtomicReference<String?>(null)
|
||||
private val refreshRef = AtomicReference<String?>(null)
|
||||
|
||||
private val _state: MutableStateFlow<Session>
|
||||
val state: StateFlow<Session>
|
||||
|
||||
init {
|
||||
val restored = store.load()
|
||||
if (restored != null) {
|
||||
accessRef.set(restored.accessToken)
|
||||
refreshRef.set(restored.refreshToken)
|
||||
_state = MutableStateFlow(Session.SignedIn(restored.toSessionUser()))
|
||||
} else {
|
||||
_state = MutableStateFlow(Session.SignedOut)
|
||||
}
|
||||
state = _state.asStateFlow()
|
||||
}
|
||||
|
||||
/** The bearer for the current request, or null when signed out. */
|
||||
fun currentAccessToken(): String? = accessRef.get()
|
||||
|
||||
/** The refresh token the authenticator rotates, or null when signed out. */
|
||||
fun currentRefreshToken(): String? = refreshRef.get()
|
||||
|
||||
val isSignedIn: Boolean get() = _state.value is Session.SignedIn
|
||||
|
||||
/** Establish a session from a successful login (§4.1). */
|
||||
@Synchronized
|
||||
fun onSignedIn(accessToken: String, refreshToken: String, user: SafeUserDto) {
|
||||
accessRef.set(accessToken)
|
||||
refreshRef.set(refreshToken)
|
||||
store.save(user.toStored(accessToken, refreshToken))
|
||||
_state.value = Session.SignedIn(user.toSessionUser())
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a rotated token pair after a silent refresh (§4.3). Keeps the current
|
||||
* user; if somehow signed out already, it is a no-op (the refresh raced a
|
||||
* logout and must not resurrect the session).
|
||||
*/
|
||||
@Synchronized
|
||||
fun onRefreshed(accessToken: String, refreshToken: String, user: SafeUserDto) {
|
||||
if (_state.value !is Session.SignedIn) return
|
||||
accessRef.set(accessToken)
|
||||
refreshRef.set(refreshToken)
|
||||
store.save(user.toStored(accessToken, refreshToken))
|
||||
// Refresh may carry an updated role — reflect it so the menu stays honest.
|
||||
_state.value = Session.SignedIn(user.toSessionUser())
|
||||
}
|
||||
|
||||
/** Refresh the cached identity from a `/auth/me` re-validation (§4.3). */
|
||||
@Synchronized
|
||||
fun onUserRefreshed(user: SafeUserDto) {
|
||||
val current = _state.value
|
||||
if (current !is Session.SignedIn) return
|
||||
val access = accessRef.get() ?: return
|
||||
val refresh = refreshRef.get() ?: return
|
||||
store.save(user.toStored(access, refresh))
|
||||
_state.value = Session.SignedIn(user.toSessionUser())
|
||||
}
|
||||
|
||||
/** Tear the session down — user logout, dead refresh, or a server switch (§3). */
|
||||
@Synchronized
|
||||
fun onSignedOut() {
|
||||
accessRef.set(null)
|
||||
refreshRef.set(null)
|
||||
store.clear()
|
||||
_state.value = Session.SignedOut
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.auth
|
||||
|
||||
/**
|
||||
* The at-rest home for a signed-in session (PLAN.md §4.3): the access + refresh
|
||||
* tokens plus the cached safe-user. Tokens are sensitive, so the production
|
||||
* implementation stores them in EncryptedSharedPreferences — never plain
|
||||
* DataStore or logs. Kept behind an interface so [SessionManager] is unit-testable
|
||||
* against an in-memory fake.
|
||||
*/
|
||||
interface TokenStore {
|
||||
/** The persisted session restored on launch, or null when signed out. */
|
||||
fun load(): StoredSession?
|
||||
|
||||
/** Persist (overwrite) the current session atomically. */
|
||||
fun save(session: StoredSession)
|
||||
|
||||
/** Wipe every stored token — sign-out and the Settings → Server hard reset (§3). */
|
||||
fun clear()
|
||||
}
|
||||
|
||||
/** A persisted session: the token pair and the non-sensitive user it belongs to. */
|
||||
data class StoredSession(
|
||||
val accessToken: String,
|
||||
val refreshToken: String,
|
||||
val userId: Long,
|
||||
val username: String,
|
||||
val role: String,
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.net
|
||||
|
||||
import com.runicgateway.app.core.auth.SessionManager
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Attaches the current bearer access token to outbound calls (PLAN.md §4.1).
|
||||
* Public endpoints simply carry a token the backend ignores; the credential
|
||||
* endpoints (login/refresh) tag themselves [Http.NO_SESSION_HEADER] and are left
|
||||
* bare so a credential `401` is never mistaken for an expired session. A request
|
||||
* that already set its own Authorization (the authenticator's retry) is untouched.
|
||||
*/
|
||||
@Singleton
|
||||
class AuthInterceptor @Inject constructor(
|
||||
private val sessionManager: SessionManager,
|
||||
) : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
if (request.header(Http.NO_SESSION_HEADER) != null) {
|
||||
return chain.proceed(request)
|
||||
}
|
||||
if (request.header(Http.AUTHORIZATION) != null) {
|
||||
return chain.proceed(request)
|
||||
}
|
||||
val token = sessionManager.currentAccessToken()
|
||||
?: return chain.proceed(request)
|
||||
val authed = request.newBuilder()
|
||||
.header(Http.AUTHORIZATION, Http.bearer(token))
|
||||
.build()
|
||||
return chain.proceed(authed)
|
||||
}
|
||||
}
|
||||
19
app/src/main/java/com/runicgateway/app/core/net/Http.kt
Normal file
19
app/src/main/java/com/runicgateway/app/core/net/Http.kt
Normal file
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.net
|
||||
|
||||
/** Shared HTTP constants for the auth layer (PLAN.md §4). */
|
||||
object Http {
|
||||
/**
|
||||
* Marks the credential endpoints (login, refresh) that must run *without* a
|
||||
* bearer and must never trigger the refresh-on-401 [TokenAuthenticator].
|
||||
* [AuthInterceptor] sees it and skips attaching a token; the authenticator
|
||||
* sees it on the failed request and declines to refresh. It is a harmless
|
||||
* unknown header to the backend.
|
||||
*/
|
||||
const val NO_SESSION_HEADER = "X-Runic-No-Session"
|
||||
const val AUTHORIZATION = "Authorization"
|
||||
|
||||
fun bearer(token: String): String = "Bearer $token"
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.net
|
||||
|
||||
import com.runicgateway.app.core.auth.SessionManager
|
||||
import com.runicgateway.app.data.api.AuthRefreshApi
|
||||
import com.runicgateway.app.data.api.dto.MobileRefreshRequest
|
||||
import okhttp3.Authenticator
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.Route
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Transparently refreshes an expired access token on a bearer `401` and replays
|
||||
* the request (PLAN.md §4.1, §4.3). Refresh tokens are single-use and rotated, so
|
||||
* this is serialized behind a mutex: concurrent 401s trigger exactly one refresh
|
||||
* and the losers reuse its result. A refresh that comes back `401` means the
|
||||
* session is truly dead → sign out; a network error leaves the session intact so
|
||||
* a later call can retry.
|
||||
*
|
||||
* The refresh call runs on [AuthRefreshApi] (its own bare client with no
|
||||
* authenticator), so it can never recurse back into here.
|
||||
*/
|
||||
@Singleton
|
||||
class TokenAuthenticator @Inject constructor(
|
||||
private val sessionManager: SessionManager,
|
||||
private val refreshApi: AuthRefreshApi,
|
||||
) : Authenticator {
|
||||
|
||||
private val lock = Any()
|
||||
|
||||
override fun authenticate(route: Route?, response: Response): Request? {
|
||||
val failed = response.request
|
||||
// Credential endpoints (login/refresh) must never be "refreshed".
|
||||
if (failed.header(Http.NO_SESSION_HEADER) != null) return null
|
||||
// Give up after a single refresh+replay to avoid an auth loop.
|
||||
if (priorResponseCount(response) >= 2) return null
|
||||
|
||||
val attemptedAuth = failed.header(Http.AUTHORIZATION)
|
||||
|
||||
synchronized(lock) {
|
||||
// Another thread may have already refreshed while we waited on the lock.
|
||||
val current = sessionManager.currentAccessToken()
|
||||
if (current != null && Http.bearer(current) != attemptedAuth) {
|
||||
return failed.retryWith(current)
|
||||
}
|
||||
|
||||
val refreshToken = sessionManager.currentRefreshToken()
|
||||
?: return null // already signed out
|
||||
|
||||
val refreshed = try {
|
||||
refreshApi.refresh(MobileRefreshRequest(refreshToken)).execute()
|
||||
} catch (_: IOException) {
|
||||
// Transient — surface the original 401 but keep the session.
|
||||
return null
|
||||
}
|
||||
|
||||
val body = refreshed.body()
|
||||
if (!refreshed.isSuccessful || body == null) {
|
||||
// The refresh token is dead (401/expired/revoked) → session is over.
|
||||
sessionManager.onSignedOut()
|
||||
return null
|
||||
}
|
||||
|
||||
sessionManager.onRefreshed(body.accessToken, body.refreshToken, body.user)
|
||||
return failed.retryWith(body.accessToken)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Request.retryWith(accessToken: String): Request =
|
||||
newBuilder().header(Http.AUTHORIZATION, Http.bearer(accessToken)).build()
|
||||
|
||||
private fun priorResponseCount(response: Response): Int {
|
||||
var count = 1
|
||||
var prior = response.priorResponse
|
||||
while (prior != null) {
|
||||
count++
|
||||
prior = prior.priorResponse
|
||||
}
|
||||
return count
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.web
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
|
||||
/**
|
||||
* Opens the website's own pages in a Chrome Custom Tab (PLAN.md §4.2):
|
||||
* registration, invite acceptance, forgot/reset password, and SSO all stay
|
||||
* website-handled, so the app hands off rather than rebuilding those flows. The
|
||||
* user completes them in the browser and returns to sign in natively (§4.1).
|
||||
*/
|
||||
object WebHandoff {
|
||||
|
||||
/**
|
||||
* Launch [url] in a Custom Tab. Returns false if no browser could handle it
|
||||
* (extremely rare on Android) so the caller can surface a fallback.
|
||||
*/
|
||||
fun open(context: Context, url: String): Boolean = try {
|
||||
CustomTabsIntent.Builder()
|
||||
.setShowTitle(true)
|
||||
.build()
|
||||
.launchUrl(context, Uri.parse(url))
|
||||
true
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.web
|
||||
|
||||
import com.runicgateway.app.core.net.BaseUrlHolder
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Resolves the website's front-end page paths against the configured base URL,
|
||||
* for the Custom-Tab hand-offs (PLAN.md §4.2). These are the React SPA routes
|
||||
* (mirrored from `website/client` `App.jsx`), not API endpoints. Null before a
|
||||
* shard site is configured.
|
||||
*/
|
||||
@Singleton
|
||||
class WebsiteUrls @Inject constructor(
|
||||
private val baseUrlHolder: BaseUrlHolder,
|
||||
) {
|
||||
private fun resolve(path: String): String? =
|
||||
baseUrlHolder.current?.resolve(path)?.toString()
|
||||
|
||||
/** Create an account on the website. */
|
||||
fun register(): String? = resolve(REGISTER)
|
||||
|
||||
/** Forgot / reset password (the flow built on the backend before app work, §8). */
|
||||
fun forgotPassword(): String? = resolve(FORGOT)
|
||||
|
||||
/** The website login page — carries the SSO provider buttons (§4.2). */
|
||||
fun login(): String? = resolve(LOGIN)
|
||||
|
||||
private companion object {
|
||||
const val REGISTER = "account/register"
|
||||
const val FORGOT = "account/forgot"
|
||||
const val LOGIN = "account/login"
|
||||
}
|
||||
}
|
||||
40
app/src/main/java/com/runicgateway/app/data/api/AuthApi.kt
Normal file
40
app/src/main/java/com/runicgateway/app/data/api/AuthApi.kt
Normal file
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api
|
||||
|
||||
import com.runicgateway.app.data.api.dto.MeResponse
|
||||
import com.runicgateway.app.data.api.dto.MobileLoginRequest
|
||||
import com.runicgateway.app.data.api.dto.MobileLogoutRequest
|
||||
import com.runicgateway.app.data.api.dto.MobileTokenResponse
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Headers
|
||||
import retrofit2.http.POST
|
||||
|
||||
/**
|
||||
* The native bearer-auth surface (PLAN.md §4.1). Login and logout run on the main
|
||||
* OkHttp client; [com.runicgateway.app.core.net.AuthInterceptor] attaches the
|
||||
* access token to logout + `/auth/me`, and [com.runicgateway.app.core.net.TokenAuthenticator]
|
||||
* transparently refreshes on a `401`.
|
||||
*
|
||||
* Login is tagged [com.runicgateway.app.core.net.Http.NO_SESSION_HEADER] so it
|
||||
* carries no bearer and a credential `401` (bad password / `totpRequired`) is not
|
||||
* misread as an expired session. It returns a raw [Response] so the caller can
|
||||
* inspect the status and parse the `{ totpRequired }` error body.
|
||||
*/
|
||||
interface AuthApi {
|
||||
|
||||
// Literal header value required by Retrofit @Headers; matches Http.NO_SESSION_HEADER.
|
||||
@Headers("X-Runic-No-Session: 1")
|
||||
@POST("api/v1/auth/mobile/login")
|
||||
suspend fun login(@Body body: MobileLoginRequest): Response<MobileTokenResponse>
|
||||
|
||||
@POST("api/v1/auth/mobile/logout")
|
||||
suspend fun logout(@Body body: MobileLogoutRequest): Response<Unit>
|
||||
|
||||
/** Current user — the app's authoritative role source, re-validated on resume (§4.3). */
|
||||
@GET("api/v1/auth/me")
|
||||
suspend fun me(): MeResponse
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api
|
||||
|
||||
import com.runicgateway.app.data.api.dto.MobileRefreshRequest
|
||||
import com.runicgateway.app.data.api.dto.MobileTokenResponse
|
||||
import retrofit2.Call
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.Headers
|
||||
import retrofit2.http.POST
|
||||
|
||||
/**
|
||||
* The token-rotation endpoint, isolated onto its own **bare** OkHttp client
|
||||
* (no auth interceptor, no authenticator) so refreshing can never recurse
|
||||
* through the very [com.runicgateway.app.core.net.TokenAuthenticator] that calls
|
||||
* it (PLAN.md §4.3). It is a blocking [Call] because the authenticator runs on an
|
||||
* OkHttp dispatcher thread, outside any coroutine, and executes it synchronously.
|
||||
*
|
||||
* Tagged `NO_SESSION` so it carries no stale bearer.
|
||||
*/
|
||||
interface AuthRefreshApi {
|
||||
|
||||
@Headers("X-Runic-No-Session: 1")
|
||||
@POST("api/v1/auth/mobile/refresh")
|
||||
fun refresh(@Body body: MobileRefreshRequest): Call<MobileTokenResponse>
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* The mobile bearer-auth wire shapes (PLAN.md §4.1). Field names match the
|
||||
* backend's `auth/mobile` controller and `/auth/me` exactly; every DTO ignores
|
||||
* unknown keys (NetworkModule's lenient Json), so additive backend fields are
|
||||
* safe (§8, recorded for M1).
|
||||
*/
|
||||
|
||||
/** `POST /auth/mobile/login` body. [code] is only sent on the 2FA retry. */
|
||||
@Serializable
|
||||
data class MobileLoginRequest(
|
||||
val username: String,
|
||||
val password: String,
|
||||
val code: String? = null,
|
||||
)
|
||||
|
||||
/** `POST /auth/mobile/refresh` body. */
|
||||
@Serializable
|
||||
data class MobileRefreshRequest(val refreshToken: String)
|
||||
|
||||
/** `POST /auth/mobile/logout` body — revoke this session or (with [all]) every session. */
|
||||
@Serializable
|
||||
data class MobileLogoutRequest(
|
||||
val refreshToken: String? = null,
|
||||
val all: Boolean? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@Serializable
|
||||
data class MobileTokenResponse(
|
||||
val accessToken: String,
|
||||
val refreshToken: String,
|
||||
val expiresIn: String? = null,
|
||||
val user: SafeUserDto,
|
||||
)
|
||||
|
||||
/** The minimal, non-sensitive user the app needs to render + gate the menu (§5). */
|
||||
@Serializable
|
||||
data class SafeUserDto(
|
||||
val id: Long,
|
||||
val username: String,
|
||||
val role: String,
|
||||
)
|
||||
|
||||
/** `GET /auth/me` envelope — the role source, re-validated on resume (§4.3). */
|
||||
@Serializable
|
||||
data class MeResponse(val user: SafeUserDto)
|
||||
|
||||
/**
|
||||
* The `401 { totpRequired: true }` body the single-request 2FA flow returns when
|
||||
* an account has TOTP on and no/invalid code accompanied the login (§4.1). Parsed
|
||||
* from the error body since it is not a 2xx response.
|
||||
*/
|
||||
@Serializable
|
||||
data class TotpRequiredError(
|
||||
val totpRequired: Boolean = false,
|
||||
val message: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.repository
|
||||
|
||||
import com.runicgateway.app.core.auth.SessionManager
|
||||
import com.runicgateway.app.data.api.AuthApi
|
||||
import com.runicgateway.app.data.api.dto.MobileLoginRequest
|
||||
import com.runicgateway.app.data.api.dto.MobileLogoutRequest
|
||||
import com.runicgateway.app.data.api.dto.MobileTokenResponse
|
||||
import com.runicgateway.app.data.api.dto.TotpRequiredError
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import retrofit2.Response
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Native username/password (+TOTP) auth — the app's only native credential flow
|
||||
* (PLAN.md §4.1). It drives the [SessionManager]: a successful login establishes
|
||||
* the session; logout revokes it. Registration/invite/reset/SSO are website
|
||||
* hand-offs (§4.2), not here.
|
||||
*/
|
||||
@Singleton
|
||||
class AuthRepository @Inject constructor(
|
||||
private val authApi: AuthApi,
|
||||
private val sessionManager: SessionManager,
|
||||
private val json: Json,
|
||||
) {
|
||||
|
||||
/** Outcome of a login attempt (§4.1). */
|
||||
sealed interface LoginResult {
|
||||
data object Success : LoginResult
|
||||
|
||||
/** The account has 2FA on — reveal the code field and resubmit with a code. */
|
||||
data object TotpRequired : LoginResult
|
||||
data object InvalidCredentials : LoginResult
|
||||
|
||||
/** Guarded by per-IP backoff → slowdown → hard cap; back off and retry. */
|
||||
data object RateLimited : LoginResult
|
||||
|
||||
/** Any other server failure (5xx / unexpected). */
|
||||
data object ServerError : LoginResult
|
||||
|
||||
/** No answer — offline, DNS, TLS, timeout. */
|
||||
data object NetworkError : LoginResult
|
||||
}
|
||||
|
||||
suspend fun login(username: String, password: String, code: String? = null): LoginResult {
|
||||
val response: Response<MobileTokenResponse> = try {
|
||||
authApi.login(MobileLoginRequest(username = username, password = password, code = code))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: IOException) {
|
||||
return LoginResult.NetworkError
|
||||
}
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body() ?: return LoginResult.ServerError
|
||||
sessionManager.onSignedIn(body.accessToken, body.refreshToken, body.user)
|
||||
return LoginResult.Success
|
||||
}
|
||||
|
||||
return when (response.code()) {
|
||||
401 -> if (isTotpRequired(response)) LoginResult.TotpRequired else LoginResult.InvalidCredentials
|
||||
429 -> LoginResult.RateLimited
|
||||
else -> LoginResult.ServerError
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* network call fails, so the user is always signed out locally.
|
||||
*/
|
||||
suspend fun logout(allDevices: Boolean = false) {
|
||||
val refreshToken = sessionManager.currentRefreshToken()
|
||||
try {
|
||||
authApi.logout(MobileLogoutRequest(refreshToken = refreshToken, all = allDevices))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
// Ignore — we still drop the local session below.
|
||||
}
|
||||
sessionManager.onSignedOut()
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-validate the session against `GET /auth/me` on app resume (§4.3). A
|
||||
* success refreshes the cached role (roles change server-side); a `401` that
|
||||
* survives the silent refresh means the session is dead → sign out. Transient
|
||||
* failures are ignored so a flaky network doesn't bounce the user.
|
||||
*/
|
||||
suspend fun revalidate() {
|
||||
if (!sessionManager.isSignedIn) return
|
||||
try {
|
||||
val me = authApi.me()
|
||||
sessionManager.onUserRefreshed(me.user)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: retrofit2.HttpException) {
|
||||
if (e.code() == 401) sessionManager.onSignedOut()
|
||||
} catch (_: IOException) {
|
||||
// Offline — keep the session; the next authed call will re-check.
|
||||
}
|
||||
}
|
||||
|
||||
private fun isTotpRequired(response: Response<*>): Boolean = try {
|
||||
val raw = response.errorBody()?.string()
|
||||
!raw.isNullOrBlank() && json.decodeFromString<TotpRequiredError>(raw).totpRequired
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
package com.runicgateway.app.data.repository
|
||||
|
||||
import com.runicgateway.app.core.auth.SessionManager
|
||||
import com.runicgateway.app.core.net.BaseUrlHolder
|
||||
import com.runicgateway.app.core.net.ServerUrl
|
||||
import com.runicgateway.app.core.prefs.ServerPreferences
|
||||
@@ -24,6 +25,7 @@ class ConnectionRepository @Inject constructor(
|
||||
private val api: PublicApi,
|
||||
private val prefs: ServerPreferences,
|
||||
private val baseUrlHolder: BaseUrlHolder,
|
||||
private val sessionManager: SessionManager,
|
||||
private val config: com.runicgateway.app.core.AppConfig,
|
||||
) {
|
||||
|
||||
@@ -81,10 +83,12 @@ class ConnectionRepository @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard reset for a Settings → Server switch (§3): clear the saved URL and
|
||||
* deactivate it. Token/cache clearing joins here in M3 once sessions exist.
|
||||
* Hard reset for a Settings → Server switch (§3): sign out (clear stored
|
||||
* tokens), clear the saved URL, and deactivate it — the app returns to a
|
||||
* signed-out state against the new host.
|
||||
*/
|
||||
suspend fun disconnect() {
|
||||
sessionManager.onSignedOut()
|
||||
prefs.clear()
|
||||
baseUrlHolder.set(null)
|
||||
}
|
||||
|
||||
@@ -6,9 +6,13 @@ package com.runicgateway.app.di
|
||||
import android.os.Build
|
||||
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||
import com.runicgateway.app.BuildConfig
|
||||
import com.runicgateway.app.core.net.AuthInterceptor
|
||||
import com.runicgateway.app.core.net.BaseUrlHolder
|
||||
import com.runicgateway.app.core.net.HostSelectionInterceptor
|
||||
import com.runicgateway.app.core.net.TokenAuthenticator
|
||||
import com.runicgateway.app.core.net.UserAgentInterceptor
|
||||
import com.runicgateway.app.data.api.AuthApi
|
||||
import com.runicgateway.app.data.api.AuthRefreshApi
|
||||
import com.runicgateway.app.data.api.PublicApi
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
@@ -42,16 +46,25 @@ object NetworkModule {
|
||||
return UserAgentInterceptor(ua)
|
||||
}
|
||||
|
||||
/**
|
||||
* The main client every API and the SSE stream ride on. Order: identify (UA),
|
||||
* retarget onto the configured shard host, then attach the bearer; the
|
||||
* [TokenAuthenticator] handles silent refresh-on-401 (§4.1). Logging sits last
|
||||
* so it observes the final, authed request.
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOkHttpClient(
|
||||
hostSelectionInterceptor: HostSelectionInterceptor,
|
||||
userAgentInterceptor: UserAgentInterceptor,
|
||||
authInterceptor: AuthInterceptor,
|
||||
tokenAuthenticator: TokenAuthenticator,
|
||||
): OkHttpClient {
|
||||
val builder = OkHttpClient.Builder()
|
||||
// User-Agent first, then host retargeting, so both apply to every call.
|
||||
.addInterceptor(userAgentInterceptor)
|
||||
.addInterceptor(hostSelectionInterceptor)
|
||||
.addInterceptor(authInterceptor)
|
||||
.authenticator(tokenAuthenticator)
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
builder.addInterceptor(
|
||||
@@ -75,4 +88,33 @@ object NetworkModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providePublicApi(retrofit: Retrofit): PublicApi = retrofit.create(PublicApi::class.java)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAuthApi(retrofit: Retrofit): AuthApi = retrofit.create(AuthApi::class.java)
|
||||
|
||||
/**
|
||||
* Token refresh runs on its own **bare** client — UA + host retargeting only,
|
||||
* no auth interceptor and no authenticator — so a refresh can never recurse
|
||||
* back through [TokenAuthenticator] (§4.3). This throwaway client/Retrofit is
|
||||
* not exposed as a bean, so there is no ambiguous [OkHttpClient] binding.
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAuthRefreshApi(
|
||||
hostSelectionInterceptor: HostSelectionInterceptor,
|
||||
userAgentInterceptor: UserAgentInterceptor,
|
||||
json: Json,
|
||||
): AuthRefreshApi {
|
||||
val bareClient = OkHttpClient.Builder()
|
||||
.addInterceptor(userAgentInterceptor)
|
||||
.addInterceptor(hostSelectionInterceptor)
|
||||
.build()
|
||||
val retrofit = Retrofit.Builder()
|
||||
.baseUrl(BaseUrlHolder.PLACEHOLDER_BASE_URL)
|
||||
.client(bareClient)
|
||||
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||
.build()
|
||||
return retrofit.create(AuthRefreshApi::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
22
app/src/main/java/com/runicgateway/app/di/StorageModule.kt
Normal file
22
app/src/main/java/com/runicgateway/app/di/StorageModule.kt
Normal file
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.di
|
||||
|
||||
import com.runicgateway.app.core.auth.EncryptedTokenStore
|
||||
import com.runicgateway.app.core.auth.TokenStore
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** Binds the at-rest token store to its EncryptedSharedPreferences impl (§4.3). */
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class StorageModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindTokenStore(impl: EncryptedTokenStore): TokenStore
|
||||
}
|
||||
@@ -4,7 +4,6 @@
|
||||
package com.runicgateway.app.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -15,6 +14,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalDrawerSheet
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
import androidx.compose.material3.NavigationDrawerItem
|
||||
@@ -24,11 +24,15 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
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.LifecycleResumeEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
@@ -37,13 +41,20 @@ import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.runicgateway.app.R
|
||||
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.roleLabelRes
|
||||
import com.runicgateway.app.ui.contact.ContactScreen
|
||||
import com.runicgateway.app.ui.home.HomeScreen
|
||||
import com.runicgateway.app.ui.navigation.APP_MENU
|
||||
import com.runicgateway.app.ui.navigation.Routes
|
||||
import com.runicgateway.app.ui.navigation.visibleEntries
|
||||
import com.runicgateway.app.ui.news.NewsScreen
|
||||
import com.runicgateway.app.ui.news.PostScreen
|
||||
import com.runicgateway.app.ui.page.PageScreen
|
||||
import com.runicgateway.app.ui.session.SessionViewModel
|
||||
import com.runicgateway.app.ui.shard.ChampsScreen
|
||||
import com.runicgateway.app.ui.shard.GovernorsScreen
|
||||
import com.runicgateway.app.ui.shard.GuildsScreen
|
||||
@@ -54,27 +65,17 @@ import com.runicgateway.app.ui.wiki.WikiPageScreen
|
||||
import com.runicgateway.app.ui.wiki.WikiScreen
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** A navigation menu entry (PLAN.md §5). For M1 every entry is public. */
|
||||
private data class MenuEntry(val route: String, val labelRes: Int)
|
||||
|
||||
private val PUBLIC_MENU = listOf(
|
||||
MenuEntry(Routes.HOME, R.string.menu_home),
|
||||
MenuEntry(Routes.NEWS, R.string.menu_news),
|
||||
MenuEntry(Routes.WIKI, R.string.menu_wiki),
|
||||
MenuEntry(Routes.SHARD, R.string.menu_shard),
|
||||
MenuEntry(Routes.page("about"), R.string.menu_about),
|
||||
MenuEntry(Routes.CONTACT, R.string.menu_contact),
|
||||
)
|
||||
|
||||
/** Destinations that show the drawer (hamburger); others show a back arrow. */
|
||||
private val TOP_LEVEL_ROUTES = setOf(
|
||||
Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.CONTACT, Routes.PAGE,
|
||||
Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.CONTACT, Routes.PAGE, Routes.ACCOUNT,
|
||||
)
|
||||
|
||||
/**
|
||||
* The main app shell once a shard site is configured (PLAN.md §5): one shared,
|
||||
* declarative navigation drawer over the public content graph, plus the
|
||||
* Settings → Server switch. The signed-in menu groups and auth toggle join in M3.
|
||||
* declarative, access-level navigation drawer whose entries are filtered by the
|
||||
* current session, plus the Sign in / Sign out toggle and the Settings → Server
|
||||
* switch. The signed-in role is re-validated against the backend on every resume
|
||||
* (§4.3), so a server-side demotion drops menu access promptly.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -82,14 +83,24 @@ fun RunicApp(
|
||||
brand: BrandDto?,
|
||||
onChangeServer: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
sessionViewModel: SessionViewModel = hiltViewModel(),
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val session by sessionViewModel.session.collectAsStateWithLifecycle()
|
||||
|
||||
// Re-validate the cached role each time the app returns to the foreground (§4.3).
|
||||
LifecycleResumeEffect(Unit) {
|
||||
sessionViewModel.revalidate()
|
||||
onPauseOrDispose { }
|
||||
}
|
||||
|
||||
val backStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = backStackEntry?.destination?.route
|
||||
val isTopLevel = currentRoute in TOP_LEVEL_ROUTES
|
||||
val entries = visibleEntries(APP_MENU, session)
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
@@ -99,12 +110,12 @@ fun RunicApp(
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = brand?.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.app_name),
|
||||
style = androidx.compose.material3.MaterialTheme.typography.titleLarge,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp),
|
||||
)
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(8.dp))
|
||||
PUBLIC_MENU.forEach { entry ->
|
||||
entries.forEach { entry ->
|
||||
NavigationDrawerItem(
|
||||
label = { Text(stringResource(entry.labelRes)) },
|
||||
selected = currentRoute == entry.route,
|
||||
@@ -115,7 +126,29 @@ fun RunicApp(
|
||||
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
|
||||
// Sign in / Sign out toggles on the session (§5).
|
||||
val signInLabel = if (session is Session.SignedIn) {
|
||||
R.string.menu_sign_out
|
||||
} else {
|
||||
R.string.menu_sign_in
|
||||
}
|
||||
NavigationDrawerItem(
|
||||
label = { Text(stringResource(signInLabel)) },
|
||||
selected = false,
|
||||
onClick = {
|
||||
scope.launch { drawerState.close() }
|
||||
if (session is Session.SignedIn) {
|
||||
sessionViewModel.signOut()
|
||||
navController.navigateTopLevel(Routes.HOME)
|
||||
} else {
|
||||
navController.navigate(Routes.LOGIN)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
|
||||
)
|
||||
NavigationDrawerItem(
|
||||
label = { Text(stringResource(R.string.menu_change_server)) },
|
||||
selected = false,
|
||||
@@ -158,6 +191,9 @@ fun RunicApp(
|
||||
RunicNavHost(
|
||||
navController = navController,
|
||||
brand = brand,
|
||||
session = session,
|
||||
onSignOut = { sessionViewModel.signOut() },
|
||||
onSignOutEverywhere = { sessionViewModel.signOut(allDevices = true) },
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
)
|
||||
}
|
||||
@@ -168,6 +204,9 @@ fun RunicApp(
|
||||
private fun RunicNavHost(
|
||||
navController: NavHostController,
|
||||
brand: BrandDto?,
|
||||
session: Session,
|
||||
onSignOut: () -> Unit,
|
||||
onSignOutEverywhere: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
NavHost(
|
||||
@@ -226,6 +265,24 @@ private fun RunicNavHost(
|
||||
composable(Routes.CONTACT) {
|
||||
ContactScreen()
|
||||
}
|
||||
composable(Routes.LOGIN) {
|
||||
LoginScreen(onSignedIn = { navController.popBackStack() })
|
||||
}
|
||||
composable(Routes.ACCOUNT) {
|
||||
// Only meaningful while signed in; a sign-out (here or from the drawer)
|
||||
// sends the user home rather than leaving a stale identity on screen.
|
||||
when (val s = session) {
|
||||
is Session.SignedIn -> AccountScreen(
|
||||
username = s.user.username,
|
||||
roleLabel = stringResource(roleLabelRes(s.user.role)),
|
||||
onSignOut = onSignOut,
|
||||
onSignOutEverywhere = onSignOutEverywhere,
|
||||
)
|
||||
Session.SignedOut -> LaunchedEffect(Unit) {
|
||||
navController.navigateTopLevel(Routes.HOME)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.auth
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
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.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.core.auth.Role
|
||||
|
||||
/**
|
||||
* The signed-in account surface (PLAN.md §5, "My Account"). For the M3 functional
|
||||
* pass it shows the identity + role and the sign-out controls; full self-service
|
||||
* (change username/password, TOTP, linked identities via the `/auth/me` surface)
|
||||
* lands in M4 (§6.3).
|
||||
*/
|
||||
@Composable
|
||||
fun AccountScreen(
|
||||
username: String,
|
||||
roleLabel: String,
|
||||
onSignOut: () -> Unit,
|
||||
onSignOutEverywhere: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Top,
|
||||
) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(20.dp)) {
|
||||
Text(
|
||||
text = username,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
Text(
|
||||
text = roleLabel,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = onSignOut,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 24.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.account_sign_out))
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = onSignOutEverywhere,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 4.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.account_sign_out_all))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Human label for a role (advisory display only — §4.3). */
|
||||
@StringRes
|
||||
fun roleLabelRes(role: Role): Int = when (role) {
|
||||
Role.PLAYER -> R.string.role_player
|
||||
Role.MODERATOR -> R.string.role_moderator
|
||||
Role.EDITOR -> R.string.role_editor
|
||||
Role.ADMIN -> R.string.role_admin
|
||||
Role.UNKNOWN -> R.string.role_unknown
|
||||
}
|
||||
184
app/src/main/java/com/runicgateway/app/ui/auth/LoginScreen.kt
Normal file
184
app/src/main/java/com/runicgateway/app/ui/auth/LoginScreen.kt
Normal file
@@ -0,0 +1,184 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.auth
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
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.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
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.core.web.WebHandoff
|
||||
import com.runicgateway.app.ui.auth.LoginViewModel.LoginError
|
||||
|
||||
/**
|
||||
* Native username/password (+TOTP) login (PLAN.md §4.1) — the app's only native
|
||||
* credential screen. Registration, forgot-password, and SSO are website hand-offs
|
||||
* opened in a Custom Tab (§4.2); the user completes them in the browser and
|
||||
* returns here to sign in.
|
||||
*/
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
onSignedIn: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: LoginViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(state.signedIn) {
|
||||
if (state.signedIn) onSignedIn()
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.login_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.login_subtitle),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 8.dp, bottom = 24.dp),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.username,
|
||||
onValueChange = viewModel::onUsernameChange,
|
||||
singleLine = true,
|
||||
enabled = !state.submitting,
|
||||
label = { Text(stringResource(R.string.login_username)) },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Text,
|
||||
imeAction = ImeAction.Next,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.password,
|
||||
onValueChange = viewModel::onPasswordChange,
|
||||
singleLine = true,
|
||||
enabled = !state.submitting,
|
||||
label = { Text(stringResource(R.string.login_password)) },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = if (state.totpRequired) ImeAction.Next else ImeAction.Go,
|
||||
),
|
||||
keyboardActions = KeyboardActions(onGo = { viewModel.submit() }),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 12.dp),
|
||||
)
|
||||
|
||||
if (state.totpRequired) {
|
||||
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),
|
||||
)
|
||||
}
|
||||
|
||||
state.error?.let { err ->
|
||||
Text(
|
||||
text = stringResource(loginErrorRes(err)),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = viewModel::submit,
|
||||
enabled = !state.submitting,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 20.dp),
|
||||
) {
|
||||
if (state.submitting) {
|
||||
CircularProgressIndicator(
|
||||
strokeWidth = 2.dp,
|
||||
modifier = Modifier.size(20.dp),
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
} else {
|
||||
Text(stringResource(R.string.login_button))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Website hand-offs (§4.2): open the site's own pages in a Custom Tab ──
|
||||
viewModel.registerUrl?.let { url ->
|
||||
TextButton(
|
||||
onClick = { WebHandoff.open(context, url) },
|
||||
modifier = Modifier.padding(top = 16.dp),
|
||||
) { Text(stringResource(R.string.login_register)) }
|
||||
}
|
||||
viewModel.forgotPasswordUrl?.let { url ->
|
||||
TextButton(onClick = { WebHandoff.open(context, url) }) {
|
||||
Text(stringResource(R.string.login_forgot))
|
||||
}
|
||||
}
|
||||
viewModel.ssoLoginUrl?.let { url ->
|
||||
TextButton(onClick = { WebHandoff.open(context, url) }) {
|
||||
Text(stringResource(R.string.login_sso))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loginErrorRes(error: LoginError): Int = when (error) {
|
||||
LoginError.INVALID_CREDENTIALS -> R.string.login_error_credentials
|
||||
LoginError.BAD_CODE -> R.string.login_error_code
|
||||
LoginError.RATE_LIMITED -> R.string.login_error_rate_limited
|
||||
LoginError.SERVER -> R.string.login_error_server
|
||||
LoginError.NETWORK -> R.string.login_error_network
|
||||
}
|
||||
101
app/src/main/java/com/runicgateway/app/ui/auth/LoginViewModel.kt
Normal file
101
app/src/main/java/com/runicgateway/app/ui/auth/LoginViewModel.kt
Normal file
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.auth
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.core.web.WebsiteUrls
|
||||
import com.runicgateway.app.data.repository.AuthRepository
|
||||
import com.runicgateway.app.data.repository.AuthRepository.LoginResult
|
||||
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 native login screen (PLAN.md §4.1): username/password, single-request
|
||||
* TOTP (the code field is revealed once the backend answers `totpRequired`), and
|
||||
* friendly 429 backoff handling. Registration / forgot-password / SSO are website
|
||||
* hand-offs whose URLs it exposes (§4.2).
|
||||
*/
|
||||
@HiltViewModel
|
||||
class LoginViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
private val websiteUrls: WebsiteUrls,
|
||||
) : ViewModel() {
|
||||
|
||||
/** The transient error surfaced under the form after a failed attempt. */
|
||||
enum class LoginError { INVALID_CREDENTIALS, BAD_CODE, RATE_LIMITED, SERVER, NETWORK }
|
||||
|
||||
data class UiState(
|
||||
val username: String = "",
|
||||
val password: String = "",
|
||||
val code: String = "",
|
||||
/** True once the account is known to have 2FA on — reveal the code field. */
|
||||
val totpRequired: Boolean = false,
|
||||
val submitting: Boolean = false,
|
||||
val error: LoginError? = null,
|
||||
val signedIn: Boolean = false,
|
||||
)
|
||||
|
||||
private val _state = MutableStateFlow(UiState())
|
||||
val state: StateFlow<UiState> = _state.asStateFlow()
|
||||
|
||||
fun onUsernameChange(value: String) = _state.update { it.copy(username = value, error = null) }
|
||||
fun onPasswordChange(value: String) = _state.update { it.copy(password = value, error = null) }
|
||||
fun onCodeChange(value: String) =
|
||||
_state.update { it.copy(code = value.filter(Char::isDigit).take(8), error = null) }
|
||||
|
||||
val registerUrl: String? get() = websiteUrls.register()
|
||||
val forgotPasswordUrl: String? get() = websiteUrls.forgotPassword()
|
||||
val ssoLoginUrl: String? get() = websiteUrls.login()
|
||||
|
||||
fun submit() {
|
||||
val s = _state.value
|
||||
if (s.submitting) return
|
||||
if (s.username.isBlank() || s.password.isBlank()) {
|
||||
_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
|
||||
}
|
||||
|
||||
_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 ->
|
||||
_state.update { it.copy(submitting = false, signedIn = true) }
|
||||
|
||||
LoginResult.TotpRequired ->
|
||||
// Reveal the code field; a wrong code re-lands here as BAD_CODE.
|
||||
_state.update {
|
||||
it.copy(
|
||||
submitting = false,
|
||||
totpRequired = true,
|
||||
error = if (it.code.isNotBlank()) LoginError.BAD_CODE else null,
|
||||
)
|
||||
}
|
||||
|
||||
LoginResult.InvalidCredentials ->
|
||||
_state.update { it.copy(submitting = false, error = LoginError.INVALID_CREDENTIALS) }
|
||||
|
||||
LoginResult.RateLimited ->
|
||||
_state.update { it.copy(submitting = false, error = LoginError.RATE_LIMITED) }
|
||||
|
||||
LoginResult.ServerError ->
|
||||
_state.update { it.copy(submitting = false, error = LoginError.SERVER) }
|
||||
|
||||
LoginResult.NetworkError ->
|
||||
_state.update { it.copy(submitting = false, error = LoginError.NETWORK) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
59
app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt
Normal file
59
app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt
Normal file
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.navigation
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.core.auth.Session
|
||||
|
||||
/**
|
||||
* One shared, declarative, access-level navigation definition (PLAN.md §5): a
|
||||
* single list where each entry declares the minimum access it needs, filtered by
|
||||
* the current session — not a pile of `if role ==` checks. The server stays the
|
||||
* source of truth; a hidden item is a UX convenience and every gated call still
|
||||
* enforces on the backend.
|
||||
*/
|
||||
enum class MenuAccess {
|
||||
/** Visible to everyone, signed in or not. */
|
||||
PUBLIC,
|
||||
|
||||
/** Visible to any signed-in account (§5, "My Account"). */
|
||||
SIGNED_IN,
|
||||
|
||||
/** Visible only to a player (linked game data lands in M4, §6.3). */
|
||||
PLAYER,
|
||||
}
|
||||
|
||||
data class MenuEntry(
|
||||
val route: String,
|
||||
@param:StringRes val labelRes: Int,
|
||||
val access: MenuAccess = MenuAccess.PUBLIC,
|
||||
)
|
||||
|
||||
/**
|
||||
* The full menu, in display order. Public content first, then the signed-in
|
||||
* surfaces. Player game-data groups (My Characters / Vendors / Houses) join in M4.
|
||||
*/
|
||||
val APP_MENU: List<MenuEntry> = listOf(
|
||||
MenuEntry(Routes.HOME, R.string.menu_home),
|
||||
MenuEntry(Routes.NEWS, R.string.menu_news),
|
||||
MenuEntry(Routes.WIKI, R.string.menu_wiki),
|
||||
MenuEntry(Routes.SHARD, R.string.menu_shard),
|
||||
MenuEntry(Routes.page("about"), R.string.menu_about),
|
||||
MenuEntry(Routes.CONTACT, R.string.menu_contact),
|
||||
MenuEntry(Routes.ACCOUNT, R.string.menu_account, MenuAccess.SIGNED_IN),
|
||||
)
|
||||
|
||||
/**
|
||||
* The entries the given [session] may see. Pure + side-effect-free so the access
|
||||
* gating is unit-tested without Compose.
|
||||
*/
|
||||
fun visibleEntries(entries: List<MenuEntry>, session: Session): List<MenuEntry> =
|
||||
entries.filter { entry ->
|
||||
when (entry.access) {
|
||||
MenuAccess.PUBLIC -> true
|
||||
MenuAccess.SIGNED_IN -> session is Session.SignedIn
|
||||
MenuAccess.PLAYER -> session is Session.SignedIn && session.user.isPlayer
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,10 @@ object Routes {
|
||||
const val WIKI = "wiki"
|
||||
const val CONTACT = "contact"
|
||||
|
||||
/** Native login (§4.1) and the signed-in account surface (§5). */
|
||||
const val LOGIN = "login"
|
||||
const val ACCOUNT = "account"
|
||||
|
||||
/** Public shard hub (§6.2). */
|
||||
const val SHARD = "shard"
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.session
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.core.auth.Session
|
||||
import com.runicgateway.app.core.auth.SessionManager
|
||||
import com.runicgateway.app.data.repository.AuthRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Activity-scoped view of the current session for the app shell (PLAN.md §4.3,
|
||||
* §5): the shared menu observes [session] to reveal signed-in groups + the
|
||||
* sign-in/out toggle, and the shell drives resume re-validation and sign-out.
|
||||
* Login itself lives in [com.runicgateway.app.ui.auth.LoginViewModel].
|
||||
*/
|
||||
@HiltViewModel
|
||||
class SessionViewModel @Inject constructor(
|
||||
sessionManager: SessionManager,
|
||||
private val authRepository: AuthRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
val session: StateFlow<Session> = sessionManager.state
|
||||
|
||||
/** Re-validate the cached role against the backend on app resume. */
|
||||
fun revalidate() {
|
||||
viewModelScope.launch { authRepository.revalidate() }
|
||||
}
|
||||
|
||||
/** Sign out of this session, or every session with [allDevices]. */
|
||||
fun signOut(allDevices: Boolean = false) {
|
||||
viewModelScope.launch { authRepository.logout(allDevices) }
|
||||
}
|
||||
}
|
||||
@@ -41,8 +41,38 @@
|
||||
<string name="menu_shard">Shard</string>
|
||||
<string name="menu_about">About</string>
|
||||
<string name="menu_contact">Contact</string>
|
||||
<string name="menu_account">My account</string>
|
||||
<string name="menu_sign_in">Sign in</string>
|
||||
<string name="menu_sign_out">Sign out</string>
|
||||
<string name="menu_change_server">Change server</string>
|
||||
|
||||
<!-- ── Auth: login (§4.1) ──────────────────────────────────────────── -->
|
||||
<string name="login_title">Sign in</string>
|
||||
<string name="login_subtitle">Sign in with your shard account.</string>
|
||||
<string name="login_username">Username</string>
|
||||
<string name="login_password">Password</string>
|
||||
<string name="login_totp_code">Authentication code</string>
|
||||
<string name="login_totp_hint">Enter the 6-digit code from your authenticator app.</string>
|
||||
<string name="login_button">Sign in</string>
|
||||
<string name="login_register">Create an account</string>
|
||||
<string name="login_forgot">Forgot your password?</string>
|
||||
<string name="login_sso">Sign in with Google or Discord (on the website)</string>
|
||||
<string name="login_error_credentials">Incorrect username or password.</string>
|
||||
<string name="login_error_code">That code didn\'t match. Try the current code.</string>
|
||||
<string name="login_error_rate_limited">Too many attempts. Please try again shortly.</string>
|
||||
<string name="login_error_server">Something went wrong. Please try again.</string>
|
||||
<string name="login_error_network">Can\'t reach the site. Check your connection and try again.</string>
|
||||
|
||||
<!-- ── Auth: account (§5, §6.3) ────────────────────────────────────── -->
|
||||
<string name="account_title">My account</string>
|
||||
<string name="account_sign_out">Sign out</string>
|
||||
<string name="account_sign_out_all">Sign out on all devices</string>
|
||||
<string name="role_player">Player</string>
|
||||
<string name="role_moderator">Moderator</string>
|
||||
<string name="role_editor">Editor</string>
|
||||
<string name="role_admin">Administrator</string>
|
||||
<string name="role_unknown">Signed in</string>
|
||||
|
||||
<!-- ── Home / status (§6.1) ────────────────────────────────────────── -->
|
||||
<string name="home_status_live">Online</string>
|
||||
<string name="home_status_maintenance">Under maintenance</string>
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.auth
|
||||
|
||||
import com.runicgateway.app.data.api.dto.SafeUserDto
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Session lifecycle over a fake [TokenStore] (PLAN.md §4.3): restore on launch,
|
||||
* establish/rotate/tear-down, and the invariant that the in-memory token pair and
|
||||
* the observable [Session] state always agree.
|
||||
*/
|
||||
class SessionManagerTest {
|
||||
|
||||
/** In-memory stand-in for EncryptedSharedPreferences. */
|
||||
private class FakeTokenStore(var stored: StoredSession? = null) : TokenStore {
|
||||
override fun load(): StoredSession? = stored
|
||||
override fun save(session: StoredSession) { stored = session }
|
||||
override fun clear() { stored = null }
|
||||
}
|
||||
|
||||
private fun user(id: Long = 1, name: String = "alice", role: String = "player") =
|
||||
SafeUserDto(id = id, username = name, role = role)
|
||||
|
||||
@Test fun startsSignedOutWithNoStoredSession() {
|
||||
val mgr = SessionManager(FakeTokenStore())
|
||||
assertEquals(Session.SignedOut, mgr.state.value)
|
||||
assertNull(mgr.currentAccessToken())
|
||||
assertNull(mgr.currentRefreshToken())
|
||||
}
|
||||
|
||||
@Test fun restoresSignedInFromStore() {
|
||||
val store = FakeTokenStore(
|
||||
StoredSession("access-1", "refresh-1", 5, "bob", "admin"),
|
||||
)
|
||||
val mgr = SessionManager(store)
|
||||
val state = mgr.state.value
|
||||
assertTrue(state is Session.SignedIn)
|
||||
assertEquals("bob", (state as Session.SignedIn).user.username)
|
||||
assertEquals(Role.ADMIN, state.user.role)
|
||||
assertEquals("access-1", mgr.currentAccessToken())
|
||||
assertEquals("refresh-1", mgr.currentRefreshToken())
|
||||
}
|
||||
|
||||
@Test fun signInPersistsAndExposesTokens() {
|
||||
val store = FakeTokenStore()
|
||||
val mgr = SessionManager(store)
|
||||
mgr.onSignedIn("access-A", "refresh-A", user(role = "player"))
|
||||
|
||||
assertTrue(mgr.state.value is Session.SignedIn)
|
||||
assertEquals("access-A", mgr.currentAccessToken())
|
||||
assertEquals("refresh-A", mgr.currentRefreshToken())
|
||||
// Persisted so a relaunch restores it.
|
||||
assertEquals("refresh-A", store.stored?.refreshToken)
|
||||
}
|
||||
|
||||
@Test fun refreshRotatesTokensWhileSignedIn() {
|
||||
val mgr = SessionManager(FakeTokenStore())
|
||||
mgr.onSignedIn("access-A", "refresh-A", user())
|
||||
mgr.onRefreshed("access-B", "refresh-B", user())
|
||||
|
||||
assertEquals("access-B", mgr.currentAccessToken())
|
||||
assertEquals("refresh-B", mgr.currentRefreshToken())
|
||||
}
|
||||
|
||||
@Test fun refreshIsNoOpAfterSignOut() {
|
||||
val mgr = SessionManager(FakeTokenStore())
|
||||
mgr.onSignedIn("access-A", "refresh-A", user())
|
||||
mgr.onSignedOut()
|
||||
// A refresh that races a logout must not resurrect the session.
|
||||
mgr.onRefreshed("access-B", "refresh-B", user())
|
||||
|
||||
assertEquals(Session.SignedOut, mgr.state.value)
|
||||
assertNull(mgr.currentAccessToken())
|
||||
}
|
||||
|
||||
@Test fun userRefreshUpdatesRoleKeepingTokens() {
|
||||
val mgr = SessionManager(FakeTokenStore())
|
||||
mgr.onSignedIn("access-A", "refresh-A", user(role = "player"))
|
||||
// A /auth/me re-validation reports a promotion.
|
||||
mgr.onUserRefreshed(user(role = "editor"))
|
||||
|
||||
val state = mgr.state.value as Session.SignedIn
|
||||
assertEquals(Role.EDITOR, state.user.role)
|
||||
assertEquals("access-A", mgr.currentAccessToken())
|
||||
}
|
||||
|
||||
@Test fun signOutClearsStoreAndState() {
|
||||
val store = FakeTokenStore()
|
||||
val mgr = SessionManager(store)
|
||||
mgr.onSignedIn("access-A", "refresh-A", user())
|
||||
mgr.onSignedOut()
|
||||
|
||||
assertEquals(Session.SignedOut, mgr.state.value)
|
||||
assertNull(store.stored)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Decoding tests for the mobile auth wire shapes (PLAN.md §4.1). Field names come
|
||||
* from the website's `auth/mobile` controller + `/auth/me`; the parser ignores
|
||||
* unknown keys (additive backend fields, §8).
|
||||
*/
|
||||
class AuthDtoTest {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
@Test fun tokenResponseDecodesWithSafeUser() {
|
||||
val dto = json.decodeFromString<MobileTokenResponse>(
|
||||
"""{"accessToken":"aaa.bbb.ccc","refreshToken":"r-123","expiresIn":"15m",
|
||||
"user":{"id":7,"username":"alice","role":"player"}}""",
|
||||
)
|
||||
assertEquals("aaa.bbb.ccc", dto.accessToken)
|
||||
assertEquals("r-123", dto.refreshToken)
|
||||
assertEquals("15m", dto.expiresIn)
|
||||
assertEquals(7L, dto.user.id)
|
||||
assertEquals("alice", dto.user.username)
|
||||
assertEquals("player", dto.user.role)
|
||||
}
|
||||
|
||||
@Test fun meResponseIgnoresExtraUserFields() {
|
||||
// /auth/me returns the full safe user row — the app only needs id/username/role.
|
||||
val dto = json.decodeFromString<MeResponse>(
|
||||
"""{"user":{"id":1,"username":"staff","role":"editor","status":"active",
|
||||
"createdAt":"2026-01-01T00:00:00Z","totp_enabled":true}}""",
|
||||
)
|
||||
assertEquals(1L, dto.user.id)
|
||||
assertEquals("editor", dto.user.role)
|
||||
}
|
||||
|
||||
@Test fun totpRequiredErrorDecodes() {
|
||||
val dto = json.decodeFromString<TotpRequiredError>(
|
||||
"""{"totpRequired":true,"message":"A verification code is required."}""",
|
||||
)
|
||||
assertTrue(dto.totpRequired)
|
||||
}
|
||||
|
||||
@Test fun totpRequiredDefaultsFalseForPlainCredentialFailure() {
|
||||
// A bad-password 401 has no totpRequired flag — must not read as a 2FA prompt.
|
||||
val dto = json.decodeFromString<TotpRequiredError>(
|
||||
"""{"message":"Incorrect username or password."}""",
|
||||
)
|
||||
assertFalse(dto.totpRequired)
|
||||
}
|
||||
|
||||
@Test fun tokenResponseExpiresInOptional() {
|
||||
val dto = json.decodeFromString<MobileTokenResponse>(
|
||||
"""{"accessToken":"a","refreshToken":"r","user":{"id":2,"username":"bob","role":"admin"}}""",
|
||||
)
|
||||
assertNull(dto.expiresIn)
|
||||
assertEquals("admin", dto.user.role)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.navigation
|
||||
|
||||
import com.runicgateway.app.core.auth.Role
|
||||
import com.runicgateway.app.core.auth.Session
|
||||
import com.runicgateway.app.core.auth.SessionUser
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The declarative access-level menu filter (PLAN.md §5): one list gated by the
|
||||
* current session, not scattered `if role ==` checks.
|
||||
*/
|
||||
class MenuAccessTest {
|
||||
|
||||
private fun signedIn(role: Role) =
|
||||
Session.SignedIn(SessionUser(id = 1, username = "u", role = role))
|
||||
|
||||
private fun routes(session: Session) =
|
||||
visibleEntries(APP_MENU, session).map { it.route }
|
||||
|
||||
@Test fun anonymousSeesOnlyPublicEntries() {
|
||||
val visible = routes(Session.SignedOut)
|
||||
assertTrue(visible.contains(Routes.HOME))
|
||||
assertTrue(visible.contains(Routes.SHARD))
|
||||
// Signed-in surfaces are hidden.
|
||||
assertFalse(visible.contains(Routes.ACCOUNT))
|
||||
}
|
||||
|
||||
@Test fun signedInPlayerSeesAccount() {
|
||||
val visible = routes(signedIn(Role.PLAYER))
|
||||
assertTrue(visible.contains(Routes.ACCOUNT))
|
||||
assertTrue(visible.contains(Routes.HOME))
|
||||
}
|
||||
|
||||
@Test fun staffSeeAccountButNoPlayerOnlyGroups() {
|
||||
val visible = routes(signedIn(Role.EDITOR))
|
||||
assertTrue(visible.contains(Routes.ACCOUNT))
|
||||
// No PLAYER-access entry leaks to staff (none exist yet in M3; guard the rule).
|
||||
val playerOnly = APP_MENU.filter { it.access == MenuAccess.PLAYER }.map { it.route }
|
||||
assertTrue(playerOnly.none { visible.contains(it) })
|
||||
}
|
||||
|
||||
@Test fun publicEntryCountIsStableAcrossSessions() {
|
||||
val publicCount = APP_MENU.count { it.access == MenuAccess.PUBLIC }
|
||||
assertEquals(publicCount, routes(Session.SignedOut).size)
|
||||
}
|
||||
|
||||
@Test fun roleFromWireMapsKnownAndUnknown() {
|
||||
assertEquals(Role.ADMIN, Role.fromWire("admin"))
|
||||
assertEquals(Role.PLAYER, Role.fromWire("player"))
|
||||
assertEquals(Role.UNKNOWN, Role.fromWire("superuser"))
|
||||
assertEquals(Role.UNKNOWN, Role.fromWire(null))
|
||||
}
|
||||
|
||||
@Test fun playerAccessGatedFunction() {
|
||||
// A synthetic PLAYER-gated entry is visible to a player, hidden from staff/anon.
|
||||
val entries = listOf(MenuEntry("game", 0, MenuAccess.PLAYER))
|
||||
assertTrue(visibleEntries(entries, signedIn(Role.PLAYER)).isNotEmpty())
|
||||
assertTrue(visibleEntries(entries, signedIn(Role.ADMIN)).isEmpty())
|
||||
assertTrue(visibleEntries(entries, Session.SignedOut).isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,9 @@ retrofitSerializationConverter = "1.0.0"
|
||||
datastore = "1.1.1"
|
||||
securityCrypto = "1.1.0-alpha06"
|
||||
|
||||
# Web hand-off (Chrome Custom Tabs) — §4.2
|
||||
browser = "1.8.0"
|
||||
|
||||
# Images
|
||||
coil = "2.7.0"
|
||||
|
||||
@@ -79,6 +82,9 @@ retrofit-kotlinx-serialization-converter = { group = "com.jakewharton.retrofit",
|
||||
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
|
||||
|
||||
# Web hand-off
|
||||
androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "browser" }
|
||||
|
||||
# Images
|
||||
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user