diff --git a/server/src/main/kotlin/suwayomi/tachidesk/global/controller/WebViewController.kt b/server/src/main/kotlin/suwayomi/tachidesk/global/controller/WebViewController.kt index feeb068b..4adcc2c9 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/global/controller/WebViewController.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/global/controller/WebViewController.kt @@ -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( diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/controller/OpdsV1Controller.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/controller/OpdsV1Controller.kt index f2dd21ab..0b9fe5a3 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/opds/controller/OpdsV1Controller.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/controller/OpdsV1Controller.kt @@ -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("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("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("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("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 { diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/JavalinSetup.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/JavalinSetup.kt index 005f7143..22bce3e5 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/server/JavalinSetup.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/JavalinSetup.kt @@ -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("user") + + data object TachideskBasic : Attribute("basicAuthValid") } private fun Context.setAttribute( diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/user/UserType.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/user/UserType.kt index c2b6659d..fae75fa1 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/server/user/UserType.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/user/UserType.kt @@ -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("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("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")