Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] User Accounts #623

Draft
wants to merge 22 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build_pull_request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ jobs:
path: master
fetch-depth: 0

- name: Set up JDK 1.8
- name: Set up JDK
uses: actions/setup-java@v1
with:
java-version: 1.8
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/build_push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ jobs:
path: master
fetch-depth: 0

- name: Set up JDK 1.8
- name: Set up JDK
uses: actions/setup-java@v1
with:
java-version: 1.8
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ jobs:
path: master
fetch-depth: 0

- name: Set up JDK 1.8
- name: Set up JDK
uses: actions/setup-java@v1
with:
java-version: 1.8
Expand Down
4 changes: 4 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,10 @@ cron4j = "it.sauronsoftware.cron4j:cron4j:2.2.5"
# cron-utils
cronUtils = "com.cronutils:cron-utils:9.2.1"

# User
bcrypt = "at.favre.lib:bcrypt:0.10.2"
jwt = "com.auth0:java-jwt:4.4.0"

[plugins]
# Kotlin
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin"}
Expand Down
3 changes: 3 additions & 0 deletions server/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ dependencies {
implementation(libs.cron4j)

implementation(libs.cronUtils)

implementation(libs.bcrypt)
implementation(libs.jwt)
}

application {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ package suwayomi.tachidesk.global.controller

import io.javalin.http.HttpCode
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
Expand All @@ -24,7 +27,8 @@ object GlobalMetaController {
}
},
behaviorOf = { ctx ->
ctx.json(GlobalMeta.getMetaMap())
val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser()
ctx.json(GlobalMeta.getMetaMap(userId))
ctx.status(200)
},
withResults = {
Expand All @@ -44,7 +48,8 @@ object GlobalMetaController {
}
},
behaviorOf = { ctx, key, value ->
GlobalMeta.modifyMeta(key, value)
val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser()
GlobalMeta.modifyMeta(userId, key, value)
ctx.status(200)
},
withResults = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -28,6 +31,7 @@ object SettingsController {
}
},
behaviorOf = { ctx ->
ctx.getAttribute(Attribute.TachideskUser).requireUser()
ctx.json(About.getAbout())
},
withResults = {
Expand All @@ -45,6 +49,7 @@ object SettingsController {
}
},
behaviorOf = { ctx ->
ctx.getAttribute(Attribute.TachideskUser).requireUser()
ctx.future(
future { AppUpdate.checkUpdate() },
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
package suwayomi.tachidesk.global.impl

import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
import suwayomi.tachidesk.global.model.table.GlobalMetaTable
Expand All @@ -16,31 +16,33 @@ import suwayomi.tachidesk.global.model.table.GlobalMetaTable

object GlobalMeta {
fun modifyMeta(
userId: Int,
key: String,
value: String,
) {
transaction {
val meta =
transaction {
GlobalMetaTable.select { GlobalMetaTable.key eq key }
GlobalMetaTable.select { GlobalMetaTable.key eq key and (GlobalMetaTable.user eq userId) }
}.firstOrNull()

if (meta == null) {
GlobalMetaTable.insert {
it[GlobalMetaTable.key] = key
it[GlobalMetaTable.value] = value
it[GlobalMetaTable.user] = userId
}
} else {
GlobalMetaTable.update({ GlobalMetaTable.key eq key }) {
GlobalMetaTable.update({ GlobalMetaTable.key eq key and (GlobalMetaTable.user eq userId) }) {
it[GlobalMetaTable.value] = value
}
}
}
}

fun getMetaMap(): Map<String, String> {
fun getMetaMap(userId: Int): Map<String, String> {
return transaction {
GlobalMetaTable.selectAll()
GlobalMetaTable.select { GlobalMetaTable.user eq userId }
.associate { it[GlobalMetaTable.key] to it[GlobalMetaTable.value] }
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package suwayomi.tachidesk.global.impl.util

import at.favre.lib.crypto.bcrypt.BCrypt

object Bcrypt {
private val hasher = BCrypt.with(BCrypt.Version.VERSION_2B)
private val verifyer = BCrypt.verifyer(BCrypt.Version.VERSION_2B)

fun encryptPassword(password: String): String {
return hasher.hashToString(12, password.toCharArray())
}

fun verify(
hash: String,
password: String,
): Boolean {
return verifyer.verify(password.toCharArray(), hash.toCharArray()).verified
}
}
128 changes: 128 additions & 0 deletions server/src/main/kotlin/suwayomi/tachidesk/global/impl/util/Jwt.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package suwayomi.tachidesk.global.impl.util

import com.auth0.jwt.JWT
import com.auth0.jwt.JWTVerifier
import com.auth0.jwt.algorithms.Algorithm
import com.auth0.jwt.exceptions.JWTVerificationException
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.global.model.table.UserPermissionsTable
import suwayomi.tachidesk.global.model.table.UserRolesTable
import suwayomi.tachidesk.server.user.Permissions
import suwayomi.tachidesk.server.user.UserType
import java.security.SecureRandom
import java.time.Instant
import javax.crypto.spec.SecretKeySpec
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours

object Jwt {
private const val ALGORITHM = "HmacSHA256"
private val accessTokenExpiry = 1.hours
private val refreshTokenExpiry = 60.days
private const val ISSUER = "tachidesk"
private const val AUDIENCE = "" // todo audience

@OptIn(ExperimentalEncodingApi::class)
fun generateSecret(): String {
val keyBytes = ByteArray(32)
SecureRandom().nextBytes(keyBytes)

val secretKey = SecretKeySpec(keyBytes, ALGORITHM)

return Base64.encode(secretKey.encoded)
}

private val algorithm: Algorithm = Algorithm.HMAC256(generateSecret()) // todo store secret
private val verifier: JWTVerifier = JWT.require(algorithm).build()

class JwtTokens(
val accessToken: String,
val refreshToken: String,
)

fun generateJwt(userId: Int): JwtTokens {
val accessToken = createAccessToken(userId)
val refreshToken = createRefreshToken(userId)

return JwtTokens(
accessToken = accessToken,
refreshToken = refreshToken,
)
}

fun refreshJwt(refreshToken: String): JwtTokens {
val jwt = verifier.verify(refreshToken)
require(jwt.getClaim("token_type").asString() == "refresh") {
"Cannot use access token to refresh"
}
return generateJwt(jwt.subject.toInt())
}

fun verifyJwt(jwt: String): UserType {
try {
val decodedJWT = verifier.verify(jwt)

require(decodedJWT.getClaim("token_type").asString() == "access") {
"Cannot use refresh token to access"
}

val user = decodedJWT.subject.toInt()
val roles: List<String> = decodedJWT.getClaim("roles").asList(String::class.java)
val permissions: List<String> = decodedJWT.getClaim("permissions").asList(String::class.java)

return if (roles.any { it.equals("admin", ignoreCase = true) }) {
UserType.Admin(user)
} else {
UserType.User(
id = user,
permissions =
permissions.mapNotNull { permission ->
Permissions.entries.find { it.name == permission }
},
)
}
} catch (e: JWTVerificationException) {
return UserType.Visitor
}
}

private fun createAccessToken(userId: Int): String {
val jwt =
JWT.create()
.withIssuer(ISSUER)
.withAudience(AUDIENCE)
.withSubject(userId.toString())
.withClaim("token_type", "access")
.withExpiresAt(Instant.now().plusSeconds(accessTokenExpiry.inWholeSeconds))

val roles =
transaction {
UserRolesTable.select { UserRolesTable.user eq userId }.toList()
.map { it[UserRolesTable.role] }
}
val permissions =
transaction {
UserPermissionsTable.select { UserPermissionsTable.user eq userId }.toList()
.map { it[UserPermissionsTable.permission] }
}

jwt.withClaim("roles", roles)

jwt.withClaim("permissions", permissions)

return jwt.sign(algorithm)
}

private fun createRefreshToken(userId: Int): String {
return JWT.create()
.withIssuer(ISSUER)
.withAudience(AUDIENCE)
.withSubject(userId.toString())
.withClaim("token_type", "refresh")
.withExpiresAt(Instant.now().plusSeconds(refreshTokenExpiry.inWholeSeconds))
.sign(algorithm)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ package suwayomi.tachidesk.global.model.table
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */

import org.jetbrains.exposed.dao.id.IntIdTable
import org.jetbrains.exposed.sql.ReferenceOption

/**
* Metadata storage for clients, server/global level.
*/
object GlobalMetaTable : IntIdTable() {
val key = varchar("key", 256)
val value = varchar("value", 4096)
val user = reference("user", UserTable, ReferenceOption.CASCADE)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package suwayomi.tachidesk.global.model.table

/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */

import org.jetbrains.exposed.sql.ReferenceOption
import org.jetbrains.exposed.sql.Table

/**
* Users registered in Tachidesk.
*/
object UserPermissionsTable : Table() {
val user = reference("user", UserTable, ReferenceOption.CASCADE)
val permission = varchar("permission", 128)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package suwayomi.tachidesk.global.model.table

/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */

import org.jetbrains.exposed.sql.ReferenceOption
import org.jetbrains.exposed.sql.Table

/**
* Users registered in Tachidesk.
*/
object UserRolesTable : Table() {
val user = reference("user", UserTable, ReferenceOption.CASCADE)
val role = varchar("role", 24)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package suwayomi.tachidesk.global.model.table

/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */

import org.jetbrains.exposed.dao.id.IntIdTable

/**
* Users registered in Tachidesk.
*/
object UserTable : IntIdTable() {
val username = varchar("username", 64)
val password = varchar("password", 90)
}
Loading
Loading