Basic JWT implementation (#1524)

* Basic JWT implementation

* Move JWT to UI_LOGIN mode and bring back SIMPLE_LOGIN as before

* Update server/src/main/kotlin/suwayomi/tachidesk/global/impl/util/Jwt.kt

Co-authored-by: Mitchell Syer <Syer10@users.noreply.github.com>

* Refresh: Update only access token

Co-authored-by: Mitchell Syer <Syer10@users.noreply.github.com>

* Implement JWT Audience

* Store JWT key

Generates the key on startup if not set

* Handle invalid Base64

* Make JWT expiry configurable

* Missing value parse

* Update server/src/main/kotlin/suwayomi/tachidesk/global/impl/util/Jwt.kt

Co-authored-by: Mitchell Syer <Syer10@users.noreply.github.com>

* Simplify Duration parsing

* JWT Protect Mutations

* JWT Protect Queries and Subscriptions

* JWT Protect v1 WebSockets

* WebSockets allow sending token via protocol header

* Also respect the `suwayomi-server-token` cookie

* JWT reduce default token expiry

* JWT Support cookie on WebSocket as well

* Lint

* Authenticate graphql subscription via connection_init payload

* WebView: Prefer explicit token over cookie

This hack was implemented because WebView sent `"null"` if no token was
supplied, just don't send a bad token, then we can do this properly

* WebView: Implement basic login dialog if no token supplied

---------

Co-authored-by: Mitchell Syer <Syer10@users.noreply.github.com>
Co-authored-by: schroda <50052685+schroda@users.noreply.github.com>
This commit is contained in:
Constantin Piber
2025-08-21 00:04:48 +02:00
committed by GitHub
parent d90bfb6e3e
commit 8547159eec
60 changed files with 1567 additions and 410 deletions

View File

@@ -9,6 +9,9 @@ package suwayomi.tachidesk.global.controller
import io.javalin.http.HttpStatus
import suwayomi.tachidesk.global.impl.GlobalMeta
import suwayomi.tachidesk.server.JavalinSetup.Attribute
import suwayomi.tachidesk.server.JavalinSetup.getAttribute
import suwayomi.tachidesk.server.user.requireUser
import suwayomi.tachidesk.server.util.formParam
import suwayomi.tachidesk.server.util.handler
import suwayomi.tachidesk.server.util.withOperation
@@ -24,6 +27,7 @@ object GlobalMetaController {
}
},
behaviorOf = { ctx ->
ctx.getAttribute(Attribute.TachideskUser).requireUser()
ctx.json(GlobalMeta.getMetaMap())
ctx.status(200)
},
@@ -44,6 +48,7 @@ object GlobalMetaController {
}
},
behaviorOf = { ctx, key, value ->
ctx.getAttribute(Attribute.TachideskUser).requireUser()
GlobalMeta.modifyMeta(key, value)
ctx.status(200)
},

View File

@@ -12,7 +12,10 @@ import suwayomi.tachidesk.global.impl.About
import suwayomi.tachidesk.global.impl.AboutDataClass
import suwayomi.tachidesk.global.impl.AppUpdate
import suwayomi.tachidesk.global.impl.UpdateDataClass
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.util.handler
import suwayomi.tachidesk.server.util.withOperation
@@ -28,6 +31,7 @@ object SettingsController {
}
},
behaviorOf = { ctx ->
ctx.getAttribute(Attribute.TachideskUser).requireUser()
ctx.json(About.getAbout())
},
withResults = {
@@ -45,6 +49,7 @@ object SettingsController {
}
},
behaviorOf = { ctx ->
ctx.getAttribute(Attribute.TachideskUser).requireUser()
ctx.future {
future { AppUpdate.checkUpdate() }
.thenApply { ctx.json(it) }

View File

@@ -12,6 +12,9 @@ import io.javalin.http.HttpStatus
import io.javalin.websocket.WsConfig
import suwayomi.tachidesk.global.impl.WebView
import suwayomi.tachidesk.i18n.LocalizationHelper
import suwayomi.tachidesk.server.JavalinSetup.Attribute
import suwayomi.tachidesk.server.JavalinSetup.getAttribute
import suwayomi.tachidesk.server.user.requireUser
import suwayomi.tachidesk.server.util.handler
import suwayomi.tachidesk.server.util.queryParam
import suwayomi.tachidesk.server.util.withOperation
@@ -28,6 +31,7 @@ object WebViewController {
}
},
behaviorOf = { ctx, lang ->
// intentionally not user-protected, this pages handles login by itself
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.contentType(ContentType.TEXT_HTML)
ctx.render(
@@ -41,8 +45,15 @@ object WebViewController {
)
fun webviewWS(ws: WsConfig) {
ws.onConnect { ctx -> WebView.addClient(ctx) }
ws.onMessage { ctx -> WebView.handleRequest(ctx) }
ws.onClose { ctx -> WebView.removeClient(ctx) }
ws.onConnect { ctx ->
ctx.getAttribute(Attribute.TachideskUser).requireUser()
WebView.addClient(ctx)
}
ws.onMessage { ctx ->
WebView.handleRequest(ctx)
}
ws.onClose { ctx ->
WebView.removeClient(ctx)
}
}
}

View File

@@ -89,6 +89,10 @@ object WebView : Websocket<String>() {
@SerialName("copy")
class JsCopyMessage : TypeObject()
@Serializable
@SerialName("ping")
class JsPingMessage : TypeObject()
override fun handleRequest(ctx: WsMessageContext) {
val dr = driver ?: return
try {
@@ -113,6 +117,9 @@ object WebView : Websocket<String>() {
is JsCopyMessage -> {
dr.copy()
}
is JsPingMessage -> {
notifyAllClients("{\"type\":\"pong\"}")
}
}
} catch (e: Exception) {
logger.warn(e) { "Failed to deserialize client request: ${ctx.message()}" }

View File

@@ -0,0 +1,126 @@
package suwayomi.tachidesk.global.impl.util
import android.app.Application
import android.content.Context
import com.auth0.jwt.JWT
import com.auth0.jwt.JWTVerifier
import com.auth0.jwt.algorithms.Algorithm
import com.auth0.jwt.exceptions.JWTVerificationException
import io.github.oshai.kotlinlogging.KotlinLogging
import suwayomi.tachidesk.server.serverConfig
import suwayomi.tachidesk.server.user.UserType
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.security.SecureRandom
import java.time.Instant
import javax.crypto.spec.SecretKeySpec
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
object Jwt {
private val preferenceStore =
Injekt.get<Application>().getSharedPreferences("jwt", Context.MODE_PRIVATE)
private val logger = KotlinLogging.logger {}
private const val ALGORITHM = "HmacSHA256"
private val accessTokenExpiry get() = serverConfig.jwtTokenExpiry.value
private val refreshTokenExpiry get() = serverConfig.jwtRefreshExpiry.value
private const val ISSUER = "suwayomi-server"
private val AUDIENCE get() = serverConfig.jwtAudience.value
private const val PREF_KEY = "jwt_key"
@OptIn(ExperimentalEncodingApi::class)
fun generateSecret(): String {
val byteString = preferenceStore.getString(PREF_KEY, "")
val decodedKeyBytes =
try {
Base64.Default.decode(byteString)
} catch (e: IllegalArgumentException) {
logger.warn(e) { "Invalid key specified, regenerating" }
null
}
val keyBytes =
if (decodedKeyBytes?.size == 32) {
decodedKeyBytes
} else {
val k = ByteArray(32)
SecureRandom().nextBytes(k)
preferenceStore.edit().putString(PREF_KEY, Base64.Default.encode(k)).apply()
k
}
val secretKey = SecretKeySpec(keyBytes, ALGORITHM)
return Base64.encode(secretKey.encoded)
}
private val algorithm: Algorithm = Algorithm.HMAC256(generateSecret())
private val verifier: JWTVerifier = JWT.require(algorithm).build()
class JwtTokens(
val accessToken: String,
val refreshToken: String,
)
fun generateJwt(): JwtTokens {
val accessToken = createAccessToken()
val refreshToken = createRefreshToken()
return JwtTokens(
accessToken = accessToken,
refreshToken = refreshToken,
)
}
fun refreshJwt(refreshToken: String): String {
val jwt = verifier.verify(refreshToken)
require(jwt.getClaim("token_type").asString() == "refresh") {
"Cannot use access token to refresh"
}
require(jwt.audience.single() == AUDIENCE) {
"Token intended for different audience ${jwt.audience}"
}
return createAccessToken()
}
fun verifyJwt(jwt: String): UserType {
try {
val decodedJWT = verifier.verify(jwt)
require(decodedJWT.getClaim("token_type").asString() == "access") {
"Cannot use refresh token to access"
}
require(decodedJWT.audience.single() == AUDIENCE) {
"Token intended for different audience ${decodedJWT.audience}"
}
return UserType.Admin(1)
} catch (e: JWTVerificationException) {
logger.warn(e) { "Received invalid token" }
return UserType.Visitor
}
}
private fun createAccessToken(): String {
val jwt =
JWT
.create()
.withIssuer(ISSUER)
.withAudience(AUDIENCE)
.withClaim("token_type", "access")
.withExpiresAt(Instant.now().plusSeconds(accessTokenExpiry.inWholeSeconds))
return jwt.sign(algorithm)
}
private fun createRefreshToken(): String =
JWT
.create()
.withIssuer(ISSUER)
.withAudience(AUDIENCE)
.withClaim("token_type", "refresh")
.withExpiresAt(Instant.now().plusSeconds(refreshTokenExpiry.inWholeSeconds))
.sign(algorithm)
}