Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,6 @@ __pycache__/

# QA campaign notes stay local (owner, 2026-09-15)
docs/qa/

# Release keystore and its password (backed up by Kopia, never committed)
signing/
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ import io.ktor.client.plugins.HttpSend
import io.ktor.client.plugins.plugin
import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
import io.ktor.http.Url
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.header
import io.ktor.client.request.url
import io.ktor.http.ContentType
import io.ktor.http.contentType
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
Expand Down Expand Up @@ -94,11 +102,39 @@ class KtorAdventureLogNetwork(
private val rejections = MutableSharedFlow<Unit>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
override val sessionRejections: Flow<Unit> = rejections.asSharedFlow()

private val csrfToken = newCsrfToken()

/** Null until the first request to a server finds out; reset whenever the server changes. */
private var behindWebProxy: Boolean? = null
private val probeLock = Mutex()

init {
// One place sees every answer, so no screen has to recognise an ended session on its own.
// Most of them could not: /api/locations/ answers an anonymous caller 200 with an empty
// list, and Places told a signed-out user "No places yet" over 22 of them (measured).
adventurelogClient.plugin(HttpSend).intercept { request ->
// The session comes from the request's own header, not from sessionToken: sign-out
// sends its DELETE in the background after clearSession() has already emptied it.
val requestSession = request.headers[SESSION_TOKEN_HEADER]
val toOwnServer = baseUrl?.let { Url(it).origin() == request.url.build().origin() } == true
if (requestSession != null || toOwnServer) {
val origin = request.url.build().origin()
val proxied = behindWebProxy ?: probeLock.withLock {
behindWebProxy ?: run {
val probe = HttpRequestBuilder().apply {
url("$origin$CSRF_PROBE_PATH")
header("X-Is-Mobile", "true")
}
val answer = execute(probe).response
(answer.contentType()?.match(ContentType.Text.Html) == true).also {
logger.d { "Server at $origin: ${if (it) "behind the web frontend's proxy" else "Django directly"}" }
behindWebProxy = it
}
}
}
if (proxied) request.attachProxiedSession(requestSession)
else request.attachDjangoSession(requestSession, csrfToken)
}
val call = execute(request)
val signingOut = request.method == HttpMethod.Delete && call.request.url.encodedPath.endsWith(SESSION_PATH)
if (call.response.status == HttpStatusCode.Unauthorized && request.headers.contains(SESSION_TOKEN_HEADER) && !signingOut) {
Expand Down Expand Up @@ -240,6 +276,7 @@ class KtorAdventureLogNetwork(
sessionToken: String?
) {
this.baseUrl = serverUrl.trimEnd('/')
behindWebProxy = null
this.sessionToken = sessionToken
logger.d {
"Network initialized from existing session - BaseURL: ${this.baseUrl}, " +
Expand All @@ -262,6 +299,7 @@ class KtorAdventureLogNetwork(
override fun clearSession() {
sessionToken = null
baseUrl = null
behindWebProxy = null
logger.d { "Network session cleared - all tokens and base URL reset" }
}

Expand All @@ -271,6 +309,7 @@ class KtorAdventureLogNetwork(
password: String
): UserDetailsDTO {
baseUrl = url.trimEnd('/')
behindWebProxy = null
// Initialize the session info before calling login
// The authDataSource will use the sessionProvider to get the baseUrl

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package com.desarrollodroide.adventurelog.core.network.ktor

import io.ktor.client.request.HttpRequestBuilder
import io.ktor.http.HeadersBuilder
import io.ktor.http.HttpHeaders
import io.ktor.http.Url
import io.ktor.http.encodedPath
import kotlinx.serialization.json.Json

data class SessionInfo(
Expand All @@ -11,9 +14,73 @@ data class SessionInfo(

internal const val SESSION_TOKEN_HEADER = "X-Session-Token"

/** allauth's browser session: GET is the session, DELETE is signing out. */
/**
* allauth's app client, not the browser one: its login hands the session back in the body, where
* the web frontend's proxy can't strip it (it drops every Set-Cookie). The token is a Django
* session key, sent like any other session.
*/
internal const val LOGIN_PATH = "/auth/app/v1/auth/login"

/**
* allauth's browser session: GET is the session, DELETE is signing out. Not the app client's: its
* DELETE answers 401 and leaves the session alive (measured on 0.13.1, direct and proxied).
*/
internal const val SESSION_PATH = "/auth/browser/v1/auth/session"

/**
* Django answers it with JSON. On the all-in-one install the web frontend takes it and answers
* its own HTML 404 - that is how the app knows its requests go through the frontend's proxy.
*/
internal const val CSRF_PROBE_PATH = "/csrf/"

internal const val CSRF_HEADER = "X-CSRFToken"

private val CSRF_ALPHABET = ('A'..'Z') + ('a'..'z') + ('0'..'9')

/**
* A CSRF token the app makes up itself. Django's check is double-submit - the `csrftoken` cookie
* must equal the `X-CSRFToken` header - so it needs no round trip to the server, only the right
* shape: 32 characters from [A-Za-z0-9], or Django rejects it as malformed.
*/
internal fun newCsrfToken(): String = (1..32).map { CSRF_ALPHABET.random() }.joinToString("")

internal fun Url.origin(): String =
if (port == protocol.defaultPort) "${protocol.name}://$host" else "${protocol.name}://$host:$port"

/**
* What a Django session needs since server 0.13.1, which stopped reading [SESSION_TOKEN_HEADER]
* and checks CSRF on login: the session as a `sessionid` cookie, a csrftoken pair, and a
* same-origin Referer (required on every unsafe request over HTTPS). [SESSION_TOKEN_HEADER] stays
* on the request: older servers still read it, and it marks a request that carried a session.
*/
internal fun HttpRequestBuilder.attachDjangoSession(sessionToken: String?, csrfToken: String) {
val cookies = listOfNotNull("csrftoken=$csrfToken", sessionToken?.let { "sessionid=$it" })
headers.append(HttpHeaders.Cookie, cookies.joinToString("; "))
headers.append(CSRF_HEADER, csrfToken)
if (!headers.contains(HttpHeaders.Referrer)) {
headers.append(HttpHeaders.Referrer, url.build().origin())
}
}

/**
* The same session through the web frontend's proxy (upstream's all-in-one install): the proxy
* fetches its own CSRF token and adds it, so a second X-CSRFToken from the app turns into two
* values and Django refuses it - only the session cookie goes. And the frontend answers a path
* ending in "/" with a 308 to the same path without it, which a POST does not survive; the proxy
* puts the slash back before calling Django. SvelteKit also refuses a form post (a photo upload)
* whose Origin is not the site: 403 "Cross-site POST form submissions are forbidden".
*/
internal fun HttpRequestBuilder.attachProxiedSession(sessionToken: String?) {
sessionToken?.let { headers.append(HttpHeaders.Cookie, "sessionid=$it") }
if (!headers.contains(HttpHeaders.Origin)) {
headers.append(HttpHeaders.Origin, url.build().origin())
}
val path = url.encodedPath
if (path.length > 1 && path.endsWith("/")) {
url.encodedPath = path.trimEnd('/')
}
}

internal fun HeadersBuilder.commonHeaders(sessionToken: String?) {
append(HttpHeaders.Accept, "application/json")
append("X-Is-Mobile", "true")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import co.touchlab.kermit.Logger
import com.desarrollodroide.adventurelog.core.network.api.AuthApi
import com.desarrollodroide.adventurelog.core.network.ktor.HttpException
import com.desarrollodroide.adventurelog.core.network.ktor.SessionInfo
import com.desarrollodroide.adventurelog.core.network.ktor.LOGIN_PATH
import com.desarrollodroide.adventurelog.core.network.ktor.SESSION_PATH
import com.desarrollodroide.adventurelog.core.network.ktor.commonHeaders
import com.desarrollodroide.adventurelog.core.network.ktor.defaultJson
Expand Down Expand Up @@ -37,7 +38,7 @@ class KtorAuthApi(
password: String
): UserDetailsDTO {
val sessionInfo = sessionProvider()
val loginUrl = "${sessionInfo.baseUrl}/auth/browser/v1/auth/login"
val loginUrl = "${sessionInfo.baseUrl}$LOGIN_PATH"
logger.d { "Login URL: $loginUrl" }

val response = httpClient.post(loginUrl) {
Expand All @@ -56,33 +57,24 @@ class KtorAuthApi(
logger.d { "Login response status: ${response.status}" }

if (response.status.isSuccess()) {
val cookies = response.headers.getAll("Set-Cookie") ?: emptyList()
val responseBody = response.body<String>()
val loginResponse = json.decodeFromString<LoginResponse>(responseBody)

var sessionToken: String? = null
for (cookie in cookies) {
if (cookie.contains("sessionid=")) {
val sessionidPattern = Regex("sessionid=([^;]+)")
val matchResult = sessionidPattern.find(cookie)
sessionToken = matchResult?.groupValues?.get(1)
}
}
// The app client returns the session in the body, so it survives the web frontend's
// proxy, which strips every Set-Cookie. The cookie is only a fallback.
val sessionToken = loginResponse.meta.sessionToken
?: response.headers.getAll("Set-Cookie").orEmpty()
.firstNotNullOfOrNull { Regex("sessionid=([^;]+)").find(it)?.groupValues?.get(1) }

if (sessionToken == null) {
// Pointing the app at the web frontend instead of the backend lands here: the
// SvelteKit proxy strips set-cookie from everything it forwards, so login answers
// 200 with no session at all.
logger.w { "No session token found in cookies - is the server URL the backend?" }
logger.w { "Login answered ${response.status.value} with no session token" }
} else {
// Never log the token itself - it grants full account access.
logger.d { "Session token received (${sessionToken.length} chars)" }
// Notify the parent about the new session token
onSessionTokenReceived(sessionToken)
}

val responseBody = response.body<String>()

val loginResponse = json.decodeFromString<LoginResponse>(responseBody)

logger.d { "Login successful for user: ${loginResponse.data.user.username}" }

return UserDetailsDTO(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ data class LoginUserData(
val display: String,
@SerialName("has_usable_password")
val hasUsablePassword: Boolean? = null,
val email: String,
/** The app client leaves it out when the account has none. */
val email: String = "",
val username: String,
@SerialName("first_name")
val firstName: String? = null,
Expand Down Expand Up @@ -59,5 +60,8 @@ data class LoginMethod(
@Serializable
data class LoginMeta(
@SerialName("is_authenticated")
val isAuthenticated: Boolean
val isAuthenticated: Boolean,
/** Only the app client sends it; it is the Django session key. */
@SerialName("session_token")
val sessionToken: String? = null
)
Loading
Loading