OPDS: Allow fallback to Basic Auth (#1613)

* Move API authorization to UserType

We already verify the JWT there, so do the same with cookies. This makes
the next steps easier

* OPDS: Allow basic auth as fallback

* Send 404 for any unmatched API request

Redirecting to the UI is weird and can cause problems with the
SIMPLE_LOGIN check (which ignores API requests)

* Webview: Present Login page in SIMPLE_LOGIN mode

For BASIC_AUTH, the dialog is always presented. With UI_LOGIN, we have a
custom login dialog.
Before, SIMPLE_LOGIN would just say "Unauthorized", as with all API
endpoints. With the last commits, SIMPLE_LOGIN is checked by the
endpoints, which Webview did not, so the page would load, but then the
Websocket would error out, despite showing the login dialog.

* Lint
This commit is contained in:
Constantin Piber
2025-08-24 18:36:11 +02:00
committed by GitHub
parent 9a33e3808a
commit 46e2ef125a
4 changed files with 94 additions and 36 deletions

View File

@@ -9,15 +9,20 @@ package suwayomi.tachidesk.global.controller
import io.javalin.http.ContentType
import io.javalin.http.HttpStatus
import io.javalin.http.RedirectResponse
import io.javalin.websocket.WsConfig
import suwayomi.tachidesk.global.impl.WebView
import suwayomi.tachidesk.graphql.types.AuthMode
import suwayomi.tachidesk.i18n.LocalizationHelper
import suwayomi.tachidesk.server.JavalinSetup.Attribute
import suwayomi.tachidesk.server.JavalinSetup.getAttribute
import suwayomi.tachidesk.server.serverConfig
import suwayomi.tachidesk.server.user.UnauthorizedException
import suwayomi.tachidesk.server.user.requireUser
import suwayomi.tachidesk.server.util.handler
import suwayomi.tachidesk.server.util.queryParam
import suwayomi.tachidesk.server.util.withOperation
import java.net.URLEncoder
import java.util.Locale
object WebViewController {
@@ -31,7 +36,18 @@ object WebViewController {
}
},
behaviorOf = { ctx, lang ->
// intentionally not user-protected, this pages handles login by itself
// intentionally not user-protected, this pages handles login by itself in UI_LOGIN mode
// for SIMPLE_LOGIN, we need to manually redirect to make this work
// for BASIC_AUTH, JavalinSetup already handles this
if (serverConfig.authMode.value == AuthMode.SIMPLE_LOGIN) {
try {
ctx.getAttribute(Attribute.TachideskUser).requireUser()
} catch (_: UnauthorizedException) {
val url = "/login.html?redirect=" + URLEncoder.encode(ctx.fullUrl(), Charsets.UTF_8)
ctx.header("Location", url)
throw RedirectResponse(HttpStatus.SEE_OTHER)
}
}
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.contentType(ContentType.TEXT_HTML)
ctx.render(

View File

@@ -12,7 +12,7 @@ import suwayomi.tachidesk.opds.impl.OpdsFeedBuilder
import suwayomi.tachidesk.server.JavalinSetup.Attribute
import suwayomi.tachidesk.server.JavalinSetup.future
import suwayomi.tachidesk.server.JavalinSetup.getAttribute
import suwayomi.tachidesk.server.user.requireUser
import suwayomi.tachidesk.server.user.requireUserWithBasicFallback
import suwayomi.tachidesk.server.util.handler
import suwayomi.tachidesk.server.util.pathParam
import suwayomi.tachidesk.server.util.queryParam
@@ -67,7 +67,7 @@ object OpdsV1Controller {
}
},
behaviorOf = { ctx, lang ->
ctx.getAttribute(Attribute.TachideskUser).requireUser()
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.contentType(OPDS_MIME).result(OpdsFeedBuilder.getRootFeed(BASE_URL, locale))
},
@@ -90,7 +90,7 @@ object OpdsV1Controller {
}
},
behaviorOf = { ctx, pageNumber, lang ->
ctx.getAttribute(Attribute.TachideskUser).requireUser()
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.future {
future {
@@ -116,7 +116,7 @@ object OpdsV1Controller {
}
},
behaviorOf = { ctx, lang ->
ctx.getAttribute(Attribute.TachideskUser).requireUser()
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.contentType("application/opensearchdescription+xml").result(
"""
@@ -144,7 +144,7 @@ object OpdsV1Controller {
handler(
documentWith = { withOperation { summary("OPDS Series in Library Feed") } },
behaviorOf = { ctx ->
ctx.getAttribute(Attribute.TachideskUser).requireUser()
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
val pageNumber = ctx.queryParam("pageNumber")?.toIntOrNull()
val query = ctx.queryParam("query")
val author = ctx.queryParam("author")
@@ -199,7 +199,7 @@ object OpdsV1Controller {
}
},
behaviorOf = { ctx, pageNumber, lang ->
ctx.getAttribute(Attribute.TachideskUser).requireUser()
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.future {
future {
@@ -226,7 +226,7 @@ object OpdsV1Controller {
}
},
behaviorOf = { ctx, pageNumber, lang ->
ctx.getAttribute(Attribute.TachideskUser).requireUser()
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.future {
future {
@@ -253,7 +253,7 @@ object OpdsV1Controller {
}
},
behaviorOf = { ctx, pageNumber, lang ->
ctx.getAttribute(Attribute.TachideskUser).requireUser()
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.future {
future {
@@ -280,7 +280,7 @@ object OpdsV1Controller {
}
},
behaviorOf = { ctx, pageNumber, lang ->
ctx.getAttribute(Attribute.TachideskUser).requireUser()
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.future {
future {
@@ -306,7 +306,7 @@ object OpdsV1Controller {
}
},
behaviorOf = { ctx, lang ->
ctx.getAttribute(Attribute.TachideskUser).requireUser()
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.future {
future {
@@ -332,7 +332,7 @@ object OpdsV1Controller {
}
},
behaviorOf = { ctx, lang ->
ctx.getAttribute(Attribute.TachideskUser).requireUser()
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.future {
future {
@@ -359,7 +359,7 @@ object OpdsV1Controller {
}
},
behaviorOf = { ctx, pageNumber, lang ->
ctx.getAttribute(Attribute.TachideskUser).requireUser()
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.future {
future {
@@ -388,7 +388,7 @@ object OpdsV1Controller {
}
},
behaviorOf = { ctx, sourceId, pageNumber, sort, lang ->
ctx.getAttribute(Attribute.TachideskUser).requireUser()
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.future {
future {
@@ -424,7 +424,7 @@ object OpdsV1Controller {
pathParam<Long>("sourceId"),
documentWith = { withOperation { summary("OPDS Library Source Specific Series Feed") } },
behaviorOf = { ctx, sourceId ->
ctx.getAttribute(Attribute.TachideskUser).requireUser()
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
val criteria = buildCriteriaFromContext(ctx, OpdsMangaFilter(sourceId = sourceId, primaryFilter = PrimaryFilterType.SOURCE))
getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria, isSearch = false)
},
@@ -442,7 +442,7 @@ object OpdsV1Controller {
pathParam<Int>("categoryId"),
documentWith = { withOperation { summary("OPDS Category Specific Series Feed") } },
behaviorOf = { ctx, categoryId ->
ctx.getAttribute(Attribute.TachideskUser).requireUser()
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
val criteria =
buildCriteriaFromContext(ctx, OpdsMangaFilter(categoryId = categoryId, primaryFilter = PrimaryFilterType.CATEGORY))
getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria, isSearch = false)
@@ -461,7 +461,7 @@ object OpdsV1Controller {
pathParam<String>("genre"),
documentWith = { withOperation { summary("OPDS Genre Specific Series Feed") } },
behaviorOf = { ctx, genre ->
ctx.getAttribute(Attribute.TachideskUser).requireUser()
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
val criteria = buildCriteriaFromContext(ctx, OpdsMangaFilter(genre = genre, primaryFilter = PrimaryFilterType.GENRE))
getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria, isSearch = false)
},
@@ -479,7 +479,7 @@ object OpdsV1Controller {
pathParam<Int>("statusId"),
documentWith = { withOperation { summary("OPDS Status Specific Series Feed") } },
behaviorOf = { ctx, statusId ->
ctx.getAttribute(Attribute.TachideskUser).requireUser()
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
val criteria = buildCriteriaFromContext(ctx, OpdsMangaFilter(statusId = statusId, primaryFilter = PrimaryFilterType.STATUS))
getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria, isSearch = false)
},
@@ -502,7 +502,7 @@ object OpdsV1Controller {
}
},
behaviorOf = { ctx, langCode ->
ctx.getAttribute(Attribute.TachideskUser).requireUser()
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
val criteria =
buildCriteriaFromContext(ctx, OpdsMangaFilter(langCode = langCode, primaryFilter = PrimaryFilterType.LANGUAGE))
getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria, isSearch = false)
@@ -530,7 +530,7 @@ object OpdsV1Controller {
}
},
behaviorOf = { ctx, seriesId, pageNumber, sort, filter, lang ->
ctx.getAttribute(Attribute.TachideskUser).requireUser()
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.future {
future {
@@ -561,7 +561,7 @@ object OpdsV1Controller {
}
},
behaviorOf = { ctx, seriesId, chapterIndex, lang ->
ctx.getAttribute(Attribute.TachideskUser).requireUser()
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.future {
future {

View File

@@ -11,10 +11,12 @@ import gg.jte.ContentType
import gg.jte.TemplateEngine
import io.github.oshai.kotlinlogging.KotlinLogging
import io.javalin.Javalin
import io.javalin.apibuilder.ApiBuilder.after
import io.javalin.apibuilder.ApiBuilder.path
import io.javalin.http.Context
import io.javalin.http.HandlerType
import io.javalin.http.HttpStatus
import io.javalin.http.NotFoundResponse
import io.javalin.http.RedirectResponse
import io.javalin.http.UnauthorizedResponse
import io.javalin.http.staticfiles.Location
@@ -126,6 +128,14 @@ object JavalinSetup {
OpdsAPI.defineEndpoints()
GraphQL.defineEndpoints()
after { ctx ->
// If not matched, the request was for an invalid endpoint
// Return a 404 instead of redirecting to the UI for usability
if (ctx.endpointHandlerPath() == "*") {
throw NotFoundResponse()
}
}
}
}
}
@@ -180,6 +190,7 @@ object JavalinSetup {
!ctx.path().substring(1).contains('/') &&
listOf(".png", ".jpg", ".ico").any { ctx.path().endsWith(it) }
val isPreFlight = ctx.method() == HandlerType.OPTIONS
val isApi = ctx.path().startsWith("/api/")
val requiresAuthentication = !isPreFlight && !isPageIcon && !isWebManifest
if (!requiresAuthentication) {
@@ -200,11 +211,7 @@ object JavalinSetup {
return username == serverConfig.authUsername.value
}
if (authMode == AuthMode.SIMPLE_LOGIN && !cookieValid() && ctx.path().startsWith("/api")) {
throw UnauthorizedResponse()
}
if (authMode == AuthMode.SIMPLE_LOGIN && !cookieValid()) {
if (authMode == AuthMode.SIMPLE_LOGIN && !cookieValid() && !isApi) {
val url = "/login.html?redirect=" + URLEncoder.encode(ctx.fullUrl(), Charsets.UTF_8)
ctx.header("Location", url)
throw RedirectResponse(HttpStatus.SEE_OTHER)
@@ -216,6 +223,7 @@ object JavalinSetup {
}
ctx.setAttribute(Attribute.TachideskUser, getUserFromContext(ctx))
ctx.setAttribute(Attribute.TachideskBasic, credentialsValid())
}
app.events { event ->
@@ -294,6 +302,8 @@ object JavalinSetup {
val name: String,
) {
data object TachideskUser : Attribute<UserType>("user")
data object TachideskBasic : Attribute<Boolean>("basicAuthValid")
}
private fun <T : Any> Context.setAttribute(

View File

@@ -5,6 +5,8 @@ import io.javalin.http.Header
import io.javalin.websocket.WsConnectContext
import suwayomi.tachidesk.global.impl.util.Jwt
import suwayomi.tachidesk.graphql.types.AuthMode
import suwayomi.tachidesk.server.JavalinSetup.Attribute
import suwayomi.tachidesk.server.JavalinSetup.getAttribute
import suwayomi.tachidesk.server.serverConfig
sealed class UserType {
@@ -21,11 +23,17 @@ fun UserType.requireUser(): Int =
UserType.Visitor -> throw UnauthorizedException()
}
fun getUserFromToken(token: String?): UserType {
if (serverConfig.authMode.value != AuthMode.UI_LOGIN) {
return UserType.Admin(1)
fun UserType.requireUserWithBasicFallback(ctx: Context): Int =
when (this) {
is UserType.Admin -> id
UserType.Visitor if ctx.getAttribute(Attribute.TachideskBasic) -> 1
UserType.Visitor -> {
ctx.header("WWW-Authenticate", "Basic")
throw UnauthorizedException()
}
}
fun getUserFromToken(token: String?): UserType {
if (token.isNullOrBlank()) {
return UserType.Visitor
}
@@ -34,18 +42,42 @@ fun getUserFromToken(token: String?): UserType {
}
fun getUserFromContext(ctx: Context): UserType {
val authentication = ctx.header(Header.AUTHORIZATION) ?: ctx.cookie("suwayomi-server-token")
val token = authentication?.substringAfter("Bearer ") ?: ctx.queryParam("token")
fun cookieValid(): Boolean {
val username = ctx.sessionAttribute<String>("logged-in") ?: return false
return username == serverConfig.authUsername.value
}
return getUserFromToken(token)
return when (serverConfig.authMode.value) {
// NOTE: Basic Auth is expected to have been validated by JavalinSetup
AuthMode.NONE, AuthMode.BASIC_AUTH -> UserType.Admin(1)
AuthMode.SIMPLE_LOGIN -> if (cookieValid()) UserType.Admin(1) else UserType.Visitor
AuthMode.UI_LOGIN -> {
val authentication = ctx.header(Header.AUTHORIZATION) ?: ctx.cookie("suwayomi-server-token")
val token = authentication?.substringAfter("Bearer ") ?: ctx.queryParam("token")
getUserFromToken(token)
}
}
}
fun getUserFromWsContext(ctx: WsConnectContext): UserType {
val authentication =
ctx.header(Header.AUTHORIZATION) ?: ctx.header("Sec-WebSocket-Protocol") ?: ctx.cookie("suwayomi-server-token")
val token = authentication?.substringAfter("Bearer ") ?: ctx.queryParam("token")
fun cookieValid(): Boolean {
val username = ctx.sessionAttribute<String>("logged-in") ?: return false
return username == serverConfig.authUsername.value
}
return getUserFromToken(token)
return when (serverConfig.authMode.value) {
// NOTE: Basic Auth is expected to have been validated by JavalinSetup
AuthMode.NONE, AuthMode.BASIC_AUTH -> UserType.Admin(1)
AuthMode.SIMPLE_LOGIN -> if (cookieValid()) UserType.Admin(1) else UserType.Visitor
AuthMode.UI_LOGIN -> {
val authentication =
ctx.header(Header.AUTHORIZATION) ?: ctx.header("Sec-WebSocket-Protocol") ?: ctx.cookie("suwayomi-server-token")
val token = authentication?.substringAfter("Bearer ") ?: ctx.queryParam("token")
getUserFromToken(token)
}
}
}
class UnauthorizedException : IllegalStateException("Unauthorized")