mirror of
https://github.com/Suwayomi/Tachidesk.git
synced 2026-01-29 15:04:16 +01:00
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:
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user