mirror of
https://github.com/Suwayomi/Suwayomi-Server.git
synced 2026-07-04 19:34:35 -05:00
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:
@@ -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()}" }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user