diff --git a/build.gradle.kts b/build.gradle.kts index 25d07a41..e28896a3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,5 @@ +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar + val ktor_version: String by project val kotlin_version: String by project val logback_version: String by project @@ -31,6 +33,18 @@ tasks.withType>().con compilerOptions.apiVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_8) } +tasks.withType { + manifest { + attributes( + "Implementation-Version" to project.version.toString() + ) + } +} + +tasks.clean { + delete += listOf("$rootDir/src/main/resources/static") +} + repositories { mavenCentral() } @@ -45,7 +59,8 @@ kotlin { dependencies { implementation("io.ktor:ktor-server-core-jvm:$ktor_version") - implementation("io.ktor:ktor-server-auth-jvm:$ktor_version") + implementation("io.ktor:ktor-server-auth:$ktor_version") + implementation("io.ktor:ktor-server-auth-jwt:$ktor_version") implementation("io.ktor:ktor-server-sessions-jvm:$ktor_version") implementation("io.ktor:ktor-server-auto-head-response-jvm:$ktor_version") implementation("io.ktor:ktor-server-cors-jvm:$ktor_version") diff --git a/src/main/kotlin/dev/usbharu/hideout/Application.kt b/src/main/kotlin/dev/usbharu/hideout/Application.kt index de723c6b..1bc2747e 100644 --- a/src/main/kotlin/dev/usbharu/hideout/Application.kt +++ b/src/main/kotlin/dev/usbharu/hideout/Application.kt @@ -1,5 +1,7 @@ package dev.usbharu.hideout +import com.auth0.jwk.JwkProvider +import com.auth0.jwk.JwkProviderBuilder import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper @@ -8,15 +10,9 @@ import dev.usbharu.hideout.config.ConfigData import dev.usbharu.hideout.domain.model.job.DeliverPostJob import dev.usbharu.hideout.domain.model.job.ReceiveFollowJob import dev.usbharu.hideout.plugins.* -import dev.usbharu.hideout.repository.IPostRepository -import dev.usbharu.hideout.repository.IUserRepository -import dev.usbharu.hideout.repository.PostRepositoryImpl -import dev.usbharu.hideout.repository.UserRepository +import dev.usbharu.hideout.repository.* import dev.usbharu.hideout.routing.register -import dev.usbharu.hideout.service.IPostService -import dev.usbharu.hideout.service.IUserAuthService -import dev.usbharu.hideout.service.IdGenerateService -import dev.usbharu.hideout.service.TwitterSnowflakeIdGenerateService +import dev.usbharu.hideout.service.* import dev.usbharu.hideout.service.activitypub.* import dev.usbharu.hideout.service.impl.IUserService import dev.usbharu.hideout.service.impl.PostService @@ -32,8 +28,10 @@ import io.ktor.client.engine.cio.* import io.ktor.client.plugins.logging.* import io.ktor.server.application.* import kjob.core.kjob +import kotlinx.coroutines.runBlocking import org.jetbrains.exposed.sql.Database import org.koin.ktor.ext.inject +import java.util.concurrent.TimeUnit fun main(args: Array): Unit = io.ktor.server.cio.EngineMain.main(args) @@ -43,7 +41,7 @@ val Application.property: Application.(propertyName: String) -> String } // application.conf references the main function. This annotation prevents the IDE from marking it as unused. -@Suppress("unused") +@Suppress("unused", "LongMethod") fun Application.parent() { Config.configData = ConfigData( url = property("hideout.url"), @@ -89,14 +87,36 @@ fun Application.parent() { single { PostService(get(), get()) } single { PostRepositoryImpl(get(), get()) } single { TwitterSnowflakeIdGenerateService } + single { MetaRepositoryImpl(get()) } + single { ServerInitialiseServiceImpl(get()) } + single { JwtRefreshTokenRepositoryImpl(get(), get()) } + single { MetaServiceImpl(get()) } + single { JwtServiceImpl(get(), get(), get()) } + single { + JwkProviderBuilder(Config.configData.url).cached( + 10, + 24, + TimeUnit.HOURS + ) + .rateLimited(10, 1, TimeUnit.MINUTES).build() + } } - configureKoin(module) + runBlocking { + inject().value.init() + } configureHTTP() configureStaticRouting() configureMonitoring() configureSerialization() register(inject().value) + configureSecurity( + inject().value, + inject().value, + inject().value, + inject().value, + inject().value, + ) configureRouting( inject().value, inject().value, diff --git a/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/dto/JwtToken.kt b/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/dto/JwtToken.kt new file mode 100644 index 00000000..9c232f82 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/dto/JwtToken.kt @@ -0,0 +1,3 @@ +package dev.usbharu.hideout.domain.model.hideout.dto + +data class JwtToken(val token: String, val refreshToken: String) diff --git a/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/entity/Jwt.kt b/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/entity/Jwt.kt new file mode 100644 index 00000000..07f3ad55 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/entity/Jwt.kt @@ -0,0 +1,5 @@ +package dev.usbharu.hideout.domain.model.hideout.entity + +import java.util.* + +data class Jwt(val kid: UUID, val privateKey: String, val publicKey: String) diff --git a/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/entity/JwtRefreshToken.kt b/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/entity/JwtRefreshToken.kt new file mode 100644 index 00000000..a7b54817 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/entity/JwtRefreshToken.kt @@ -0,0 +1,11 @@ +package dev.usbharu.hideout.domain.model.hideout.entity + +import java.time.Instant + +data class JwtRefreshToken( + val id: Long, + val userId: Long, + val refreshToken: String, + val createdAt: Instant, + val expiresAt: Instant +) diff --git a/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/entity/Meta.kt b/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/entity/Meta.kt new file mode 100644 index 00000000..770b04d1 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/entity/Meta.kt @@ -0,0 +1,3 @@ +package dev.usbharu.hideout.domain.model.hideout.entity + +data class Meta(val version: String, val jwt: Jwt) diff --git a/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/form/RefreshToken.kt b/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/form/RefreshToken.kt new file mode 100644 index 00000000..5d7898a1 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/form/RefreshToken.kt @@ -0,0 +1,3 @@ +package dev.usbharu.hideout.domain.model.hideout.form + +data class RefreshToken(val refreshToken: String) diff --git a/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/form/UserLogin.kt b/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/form/UserLogin.kt new file mode 100644 index 00000000..d9d5fc4d --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/form/UserLogin.kt @@ -0,0 +1,3 @@ +package dev.usbharu.hideout.domain.model.hideout.form + +data class UserLogin(val username: String, val password: String) diff --git a/src/main/kotlin/dev/usbharu/hideout/exception/InvalidRefreshTokenException.kt b/src/main/kotlin/dev/usbharu/hideout/exception/InvalidRefreshTokenException.kt new file mode 100644 index 00000000..08c8ab7d --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/exception/InvalidRefreshTokenException.kt @@ -0,0 +1,8 @@ +package dev.usbharu.hideout.exception + +class InvalidRefreshTokenException : IllegalArgumentException { + constructor() : super() + constructor(s: String?) : super(s) + constructor(message: String?, cause: Throwable?) : super(message, cause) + constructor(cause: Throwable?) : super(cause) +} diff --git a/src/main/kotlin/dev/usbharu/hideout/exception/NotInitException.kt b/src/main/kotlin/dev/usbharu/hideout/exception/NotInitException.kt new file mode 100644 index 00000000..10ccdf29 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/exception/NotInitException.kt @@ -0,0 +1,14 @@ +package dev.usbharu.hideout.exception + +class NotInitException : Exception { + constructor() : super() + constructor(message: String?) : super(message) + constructor(message: String?, cause: Throwable?) : super(message, cause) + constructor(cause: Throwable?) : super(cause) + constructor(message: String?, cause: Throwable?, enableSuppression: Boolean, writableStackTrace: Boolean) : super( + message, + cause, + enableSuppression, + writableStackTrace + ) +} diff --git a/src/main/kotlin/dev/usbharu/hideout/exception/UserNotFoundException.kt b/src/main/kotlin/dev/usbharu/hideout/exception/UserNotFoundException.kt index 4634e141..0c8ca15e 100644 --- a/src/main/kotlin/dev/usbharu/hideout/exception/UserNotFoundException.kt +++ b/src/main/kotlin/dev/usbharu/hideout/exception/UserNotFoundException.kt @@ -1,14 +1,8 @@ package dev.usbharu.hideout.exception -class UserNotFoundException : Exception { +class UserNotFoundException : IllegalArgumentException { constructor() : super() - constructor(message: String?) : super(message) + constructor(s: String?) : super(s) constructor(message: String?, cause: Throwable?) : super(message, cause) constructor(cause: Throwable?) : super(cause) - constructor( - message: String?, - cause: Throwable?, - enableSuppression: Boolean, - writableStackTrace: Boolean - ) : super(message, cause, enableSuppression, writableStackTrace) } diff --git a/src/main/kotlin/dev/usbharu/hideout/plugins/HTTP.kt b/src/main/kotlin/dev/usbharu/hideout/plugins/HTTP.kt index 234130ad..98e5e259 100644 --- a/src/main/kotlin/dev/usbharu/hideout/plugins/HTTP.kt +++ b/src/main/kotlin/dev/usbharu/hideout/plugins/HTTP.kt @@ -1,21 +1,20 @@ package dev.usbharu.hideout.plugins -import io.ktor.http.* import io.ktor.server.application.* -import io.ktor.server.plugins.cors.routing.* import io.ktor.server.plugins.defaultheaders.* import io.ktor.server.plugins.forwardedheaders.* fun Application.configureHTTP() { - install(CORS) { - allowMethod(HttpMethod.Options) - allowMethod(HttpMethod.Put) - allowMethod(HttpMethod.Delete) - allowMethod(HttpMethod.Patch) - allowHeader(HttpHeaders.Authorization) - allowHeader("MyCustomHeader") - anyHost() // @TODO: Don't do this in production if possible. Try to limit it. - } +// install(CORS) { +// allowMethod(HttpMethod.Options) +// allowMethod(HttpMethod.Put) +// allowMethod(HttpMethod.Delete) +// allowMethod(HttpMethod.Patch) +// allowHeader(HttpHeaders.Authorization) +// allow +// allowHeader("MyCustomHeader") +// anyHost() // @TODO: Don't do this in production if possible. Try to limit it. +// } install(DefaultHeaders) { header("X-Engine", "Ktor") // will send this header with each response } diff --git a/src/main/kotlin/dev/usbharu/hideout/plugins/Security.kt b/src/main/kotlin/dev/usbharu/hideout/plugins/Security.kt index da66a042..939fe5ba 100644 --- a/src/main/kotlin/dev/usbharu/hideout/plugins/Security.kt +++ b/src/main/kotlin/dev/usbharu/hideout/plugins/Security.kt @@ -1,25 +1,82 @@ -@file:Suppress("UnusedPrivateMember") - package dev.usbharu.hideout.plugins +import com.auth0.jwk.JwkProvider +import dev.usbharu.hideout.config.Config +import dev.usbharu.hideout.domain.model.hideout.form.RefreshToken +import dev.usbharu.hideout.domain.model.hideout.form.UserLogin +import dev.usbharu.hideout.exception.UserNotFoundException +import dev.usbharu.hideout.repository.IUserRepository +import dev.usbharu.hideout.service.IJwtService +import dev.usbharu.hideout.service.IMetaService import dev.usbharu.hideout.service.IUserAuthService +import dev.usbharu.hideout.util.JsonWebKeyUtil +import io.ktor.http.* import io.ktor.server.application.* import io.ktor.server.auth.* +import io.ktor.server.auth.jwt.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* -const val TOKEN_AUTH = "token-auth" +const val TOKEN_AUTH = "jwt-auth" -fun Application.configureSecurity(userAuthService: IUserAuthService) { +@Suppress("MagicNumber") +fun Application.configureSecurity( + userAuthService: IUserAuthService, + metaService: IMetaService, + userRepository: IUserRepository, + jwtService: IJwtService, + jwkProvider: JwkProvider +) { + val issuer = Config.configData.url install(Authentication) { - bearer(TOKEN_AUTH) { - authenticate { bearerTokenCredential -> - UserIdPrincipal(bearerTokenCredential.token) + jwt(TOKEN_AUTH) { + verifier(jwkProvider, issuer) { + acceptLeeway(3) + } + validate { jwtCredential -> + if (jwtCredential.payload.getClaim("username")?.asString().isNullOrBlank().not()) { + JWTPrincipal(jwtCredential.payload) + } else { + null + } + } + } + } + + routing { + post("/login") { + val loginUser = call.receive() + val check = userAuthService.verifyAccount(loginUser.username, loginUser.password) + if (check.not()) { + return@post call.respond(HttpStatusCode.Unauthorized) + } + + val user = userRepository.findByNameAndDomain(loginUser.username, Config.configData.domain) + ?: throw UserNotFoundException("${loginUser.username} was not found.") + + return@post call.respond(jwtService.createToken(user)) + } + + post("/refresh-token") { + val refreshToken = call.receive() + return@post call.respond(jwtService.refreshToken(refreshToken)) + } + + get("/.well-known/jwks.json") { + //language=JSON + val jwt = metaService.getJwtMeta() + call.respondText( + contentType = ContentType.Application.Json, + text = JsonWebKeyUtil.publicKeyToJwk(jwt.publicKey, jwt.kid.toString()) + ) + } + authenticate(TOKEN_AUTH) { + get("/auth-check") { + val principal = call.principal() + val username = principal!!.payload.getClaim("username") + call.respondText("Hello $username") } - skipWhen { true } } } -// install(Sessions) { -// cookie("MY_SESSION") { -// cookie.extensions["SameSite"] = "lax" -// } -// } } diff --git a/src/main/kotlin/dev/usbharu/hideout/plugins/StatusPages.kt b/src/main/kotlin/dev/usbharu/hideout/plugins/StatusPages.kt index cd078e28..67ffdb1b 100644 --- a/src/main/kotlin/dev/usbharu/hideout/plugins/StatusPages.kt +++ b/src/main/kotlin/dev/usbharu/hideout/plugins/StatusPages.kt @@ -7,11 +7,11 @@ import io.ktor.server.response.* fun Application.configureStatusPages() { install(StatusPages) { - exception { call, cause -> - call.respondText(text = "500: $cause", status = HttpStatusCode.InternalServerError) - } exception { call, cause -> call.respondText(text = "400: $cause", status = HttpStatusCode.BadRequest) } + exception { call, cause -> + call.respondText(text = "500: $cause", status = HttpStatusCode.InternalServerError) + } } } diff --git a/src/main/kotlin/dev/usbharu/hideout/repository/IJwtRefreshTokenRepository.kt b/src/main/kotlin/dev/usbharu/hideout/repository/IJwtRefreshTokenRepository.kt new file mode 100644 index 00000000..9e6a3c96 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/repository/IJwtRefreshTokenRepository.kt @@ -0,0 +1,20 @@ +package dev.usbharu.hideout.repository + +import dev.usbharu.hideout.domain.model.hideout.entity.JwtRefreshToken + +interface IJwtRefreshTokenRepository { + suspend fun generateId(): Long + + suspend fun save(token: JwtRefreshToken) + + suspend fun findById(id: Long): JwtRefreshToken? + suspend fun findByToken(token: String): JwtRefreshToken? + suspend fun findByUserId(userId: Long): JwtRefreshToken? + + suspend fun delete(token: JwtRefreshToken) + suspend fun deleteById(id: Long) + suspend fun deleteByToken(token: String) + suspend fun deleteByUserId(userId: Long) + + suspend fun deleteAll() +} diff --git a/src/main/kotlin/dev/usbharu/hideout/repository/IMetaRepository.kt b/src/main/kotlin/dev/usbharu/hideout/repository/IMetaRepository.kt new file mode 100644 index 00000000..b90be212 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/repository/IMetaRepository.kt @@ -0,0 +1,10 @@ +package dev.usbharu.hideout.repository + +import dev.usbharu.hideout.domain.model.hideout.entity.Meta + +interface IMetaRepository { + + suspend fun save(meta: Meta) + + suspend fun get(): Meta? +} diff --git a/src/main/kotlin/dev/usbharu/hideout/repository/JwtRefreshTokenRepositoryImpl.kt b/src/main/kotlin/dev/usbharu/hideout/repository/JwtRefreshTokenRepositoryImpl.kt new file mode 100644 index 00000000..74bf019c --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/repository/JwtRefreshTokenRepositoryImpl.kt @@ -0,0 +1,118 @@ +package dev.usbharu.hideout.repository + +import dev.usbharu.hideout.domain.model.hideout.entity.JwtRefreshToken +import dev.usbharu.hideout.service.IdGenerateService +import kotlinx.coroutines.Dispatchers +import org.jetbrains.exposed.sql.* +import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq +import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction +import org.jetbrains.exposed.sql.transactions.transaction +import java.time.Instant + +class JwtRefreshTokenRepositoryImpl( + private val database: Database, + private val idGenerateService: IdGenerateService +) : + IJwtRefreshTokenRepository { + + init { + transaction(database) { + SchemaUtils.create(JwtRefreshTokens) + SchemaUtils.createMissingTablesAndColumns(JwtRefreshTokens) + } + } + + @Suppress("InjectDispatcher") + suspend fun query(block: suspend () -> T): T = + newSuspendedTransaction(Dispatchers.IO) { block() } + + override suspend fun generateId(): Long = idGenerateService.generateId() + + override suspend fun save(token: JwtRefreshToken) { + query { + if (JwtRefreshTokens.select { JwtRefreshTokens.id.eq(token.id) }.empty()) { + JwtRefreshTokens.insert { + it[id] = token.id + it[userId] = token.userId + it[refreshToken] = token.refreshToken + it[createdAt] = token.createdAt.toEpochMilli() + it[expiresAt] = token.expiresAt.toEpochMilli() + } + } else { + JwtRefreshTokens.update({ JwtRefreshTokens.id eq token.id }) { + it[userId] = token.userId + it[refreshToken] = token.refreshToken + it[createdAt] = token.createdAt.toEpochMilli() + it[expiresAt] = token.expiresAt.toEpochMilli() + } + } + } + } + + override suspend fun findById(id: Long): JwtRefreshToken? { + return query { + JwtRefreshTokens.select { JwtRefreshTokens.id.eq(id) }.singleOrNull()?.toJwtRefreshToken() + } + } + + override suspend fun findByToken(token: String): JwtRefreshToken? { + return query { + JwtRefreshTokens.select { JwtRefreshTokens.refreshToken.eq(token) }.singleOrNull()?.toJwtRefreshToken() + } + } + + override suspend fun findByUserId(userId: Long): JwtRefreshToken? { + return query { + JwtRefreshTokens.select { JwtRefreshTokens.userId.eq(userId) }.singleOrNull()?.toJwtRefreshToken() + } + } + + override suspend fun delete(token: JwtRefreshToken) { + return query { + JwtRefreshTokens.deleteWhere { JwtRefreshTokens.id eq token.id } + } + } + + override suspend fun deleteById(id: Long) { + return query { + JwtRefreshTokens.deleteWhere { JwtRefreshTokens.id eq id } + } + } + + override suspend fun deleteByToken(token: String) { + return query { + JwtRefreshTokens.deleteWhere { JwtRefreshTokens.refreshToken eq token } + } + } + + override suspend fun deleteByUserId(userId: Long) { + return query { + JwtRefreshTokens.deleteWhere { JwtRefreshTokens.userId eq userId } + } + } + + override suspend fun deleteAll() { + return query { + JwtRefreshTokens.deleteAll() + } + } +} + +fun ResultRow.toJwtRefreshToken(): JwtRefreshToken { + return JwtRefreshToken( + this[JwtRefreshTokens.id], + this[JwtRefreshTokens.userId], + this[JwtRefreshTokens.refreshToken], + Instant.ofEpochMilli(this[JwtRefreshTokens.createdAt]), + Instant.ofEpochMilli(this[JwtRefreshTokens.expiresAt]) + ) +} + +object JwtRefreshTokens : Table("jwt_refresh_tokens") { + val id = long("id") + val userId = long("user_id") + val refreshToken = varchar("refresh_token", 1000) + val createdAt = long("created_at") + val expiresAt = long("expires_at") + override val primaryKey = PrimaryKey(id) +} diff --git a/src/main/kotlin/dev/usbharu/hideout/repository/MetaRepositoryImpl.kt b/src/main/kotlin/dev/usbharu/hideout/repository/MetaRepositoryImpl.kt new file mode 100644 index 00000000..f58e555e --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/repository/MetaRepositoryImpl.kt @@ -0,0 +1,63 @@ +package dev.usbharu.hideout.repository + +import dev.usbharu.hideout.domain.model.hideout.entity.Jwt +import kotlinx.coroutines.Dispatchers +import org.jetbrains.exposed.sql.* +import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction +import org.jetbrains.exposed.sql.transactions.transaction +import java.util.* + +class MetaRepositoryImpl(private val database: Database) : IMetaRepository { + + init { + transaction(database) { + SchemaUtils.create(Meta) + SchemaUtils.createMissingTablesAndColumns(Meta) + } + } + + @Suppress("InjectDispatcher") + suspend fun query(block: suspend () -> T): T = + newSuspendedTransaction(Dispatchers.IO) { block() } + + override suspend fun save(meta: dev.usbharu.hideout.domain.model.hideout.entity.Meta) { + return query { + if (Meta.select { Meta.id eq 1 }.empty()) { + Meta.insert { + it[id] = 1 + it[this.version] = meta.version + it[kid] = UUID.randomUUID().toString() + it[this.jwtPrivateKey] = meta.jwt.privateKey + it[this.jwtPublicKey] = meta.jwt.publicKey + } + } else { + Meta.update({ Meta.id eq 1 }) { + it[this.version] = meta.version + it[kid] = UUID.randomUUID().toString() + it[this.jwtPrivateKey] = meta.jwt.privateKey + it[this.jwtPublicKey] = meta.jwt.publicKey + } + } + } + } + + override suspend fun get(): dev.usbharu.hideout.domain.model.hideout.entity.Meta? { + return query { + Meta.select { Meta.id eq 1 }.singleOrNull()?.let { + dev.usbharu.hideout.domain.model.hideout.entity.Meta( + it[Meta.version], + Jwt(UUID.fromString(it[Meta.kid]), it[Meta.jwtPrivateKey], it[Meta.jwtPublicKey]) + ) + } + } + } +} + +object Meta : Table("meta_info") { + val id = long("id") + val version = varchar("version", 1000) + val kid = varchar("kid", 1000) + val jwtPrivateKey = varchar("jwt_private_key", 100000) + val jwtPublicKey = varchar("jwt_public_key", 100000) + override val primaryKey: PrimaryKey = PrimaryKey(id) +} diff --git a/src/main/kotlin/dev/usbharu/hideout/repository/PostRepositoryImpl.kt b/src/main/kotlin/dev/usbharu/hideout/repository/PostRepositoryImpl.kt index 237acb6b..669a0df7 100644 --- a/src/main/kotlin/dev/usbharu/hideout/repository/PostRepositoryImpl.kt +++ b/src/main/kotlin/dev/usbharu/hideout/repository/PostRepositoryImpl.kt @@ -20,6 +20,7 @@ class PostRepositoryImpl(database: Database, private val idGenerateService: IdGe } } + @Suppress("InjectDispatcher") suspend fun query(block: suspend () -> T): T = newSuspendedTransaction(Dispatchers.IO) { block() } diff --git a/src/main/kotlin/dev/usbharu/hideout/repository/UserRepository.kt b/src/main/kotlin/dev/usbharu/hideout/repository/UserRepository.kt index 2f2905d8..8dc92ba0 100644 --- a/src/main/kotlin/dev/usbharu/hideout/repository/UserRepository.kt +++ b/src/main/kotlin/dev/usbharu/hideout/repository/UserRepository.kt @@ -21,6 +21,7 @@ class UserRepository(private val database: Database, private val idGenerateServi } } + @Suppress("InjectDispatcher") suspend fun query(block: suspend () -> T): T = newSuspendedTransaction(Dispatchers.IO) { block() } diff --git a/src/main/kotlin/dev/usbharu/hideout/service/IJwtService.kt b/src/main/kotlin/dev/usbharu/hideout/service/IJwtService.kt new file mode 100644 index 00000000..f722517f --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/service/IJwtService.kt @@ -0,0 +1,14 @@ +package dev.usbharu.hideout.service + +import dev.usbharu.hideout.domain.model.hideout.dto.JwtToken +import dev.usbharu.hideout.domain.model.hideout.entity.User +import dev.usbharu.hideout.domain.model.hideout.form.RefreshToken + +interface IJwtService { + suspend fun createToken(user: User): JwtToken + suspend fun refreshToken(refreshToken: RefreshToken): JwtToken + + suspend fun revokeToken(refreshToken: RefreshToken) + suspend fun revokeToken(user: User) + suspend fun revokeAll() +} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/IMetaService.kt b/src/main/kotlin/dev/usbharu/hideout/service/IMetaService.kt new file mode 100644 index 00000000..84c59d60 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/service/IMetaService.kt @@ -0,0 +1,10 @@ +package dev.usbharu.hideout.service + +import dev.usbharu.hideout.domain.model.hideout.entity.Jwt +import dev.usbharu.hideout.domain.model.hideout.entity.Meta + +interface IMetaService { + suspend fun getMeta(): Meta + suspend fun updateMeta(meta: Meta) + suspend fun getJwtMeta(): Jwt +} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/IServerInitialiseService.kt b/src/main/kotlin/dev/usbharu/hideout/service/IServerInitialiseService.kt new file mode 100644 index 00000000..49a613fd --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/service/IServerInitialiseService.kt @@ -0,0 +1,5 @@ +package dev.usbharu.hideout.service + +interface IServerInitialiseService { + suspend fun init() +} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/JwtServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/service/JwtServiceImpl.kt new file mode 100644 index 00000000..3548c84d --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/service/JwtServiceImpl.kt @@ -0,0 +1,97 @@ +package dev.usbharu.hideout.service + +import com.auth0.jwt.JWT +import com.auth0.jwt.algorithms.Algorithm +import dev.usbharu.hideout.config.Config +import dev.usbharu.hideout.domain.model.hideout.dto.JwtToken +import dev.usbharu.hideout.domain.model.hideout.entity.JwtRefreshToken +import dev.usbharu.hideout.domain.model.hideout.entity.User +import dev.usbharu.hideout.domain.model.hideout.form.RefreshToken +import dev.usbharu.hideout.exception.InvalidRefreshTokenException +import dev.usbharu.hideout.repository.IJwtRefreshTokenRepository +import dev.usbharu.hideout.service.impl.IUserService +import dev.usbharu.hideout.util.RsaUtil +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.* + +@Suppress("InjectDispatcher") +class JwtServiceImpl( + private val metaService: IMetaService, + private val refreshTokenRepository: IJwtRefreshTokenRepository, + private val userService: IUserService +) : IJwtService { + + private val privateKey by lazy { + CoroutineScope(Dispatchers.IO).async { + RsaUtil.decodeRsaPrivateKey(metaService.getJwtMeta().privateKey) + } + } + + private val publicKey by lazy { + CoroutineScope(Dispatchers.IO).async { + RsaUtil.decodeRsaPublicKey(metaService.getJwtMeta().publicKey) + } + } + + private val keyId by lazy { + CoroutineScope(Dispatchers.IO).async { + metaService.getJwtMeta().kid + } + } + + @Suppress("MagicNumber") + override suspend fun createToken(user: User): JwtToken { + val now = Instant.now() + val token = JWT.create() + .withAudience("${Config.configData.url}/users/${user.name}") + .withIssuer(Config.configData.url) + .withKeyId(keyId.await().toString()) + .withClaim("username", user.name) + .withExpiresAt(now.plus(30, ChronoUnit.MINUTES)) + .sign(Algorithm.RSA256(publicKey.await(), privateKey.await())) + + val jwtRefreshToken = JwtRefreshToken( + id = refreshTokenRepository.generateId(), + userId = user.id, + refreshToken = UUID.randomUUID().toString(), + createdAt = now, + expiresAt = now.plus(14, ChronoUnit.DAYS) + ) + refreshTokenRepository.save(jwtRefreshToken) + return JwtToken(token, jwtRefreshToken.refreshToken) + } + + override suspend fun refreshToken(refreshToken: RefreshToken): JwtToken { + val token = refreshTokenRepository.findByToken(refreshToken.refreshToken) + ?: throw InvalidRefreshTokenException("Invalid Refresh Token") + + val user = userService.findById(token.userId) + + val now = Instant.now() + if (token.createdAt.isAfter(now)) { + throw InvalidRefreshTokenException("Invalid Refresh Token") + } + + if (token.expiresAt.isBefore(now)) { + throw InvalidRefreshTokenException("Refresh Token Expired") + } + + return createToken(user) + } + + override suspend fun revokeToken(refreshToken: RefreshToken) { + refreshTokenRepository.deleteByToken(refreshToken.refreshToken) + } + + override suspend fun revokeToken(user: User) { + refreshTokenRepository.deleteByUserId(user.id) + } + + override suspend fun revokeAll() { + refreshTokenRepository.deleteAll() + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/MetaServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/service/MetaServiceImpl.kt new file mode 100644 index 00000000..4e5eb17a --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/service/MetaServiceImpl.kt @@ -0,0 +1,16 @@ +package dev.usbharu.hideout.service + +import dev.usbharu.hideout.domain.model.hideout.entity.Jwt +import dev.usbharu.hideout.domain.model.hideout.entity.Meta +import dev.usbharu.hideout.exception.NotInitException +import dev.usbharu.hideout.repository.IMetaRepository + +class MetaServiceImpl(private val metaRepository: IMetaRepository) : IMetaService { + override suspend fun getMeta(): Meta = metaRepository.get() ?: throw NotInitException("Meta is null") + + override suspend fun updateMeta(meta: Meta) { + metaRepository.save(meta) + } + + override suspend fun getJwtMeta(): Jwt = getMeta().jwt +} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/ServerInitialiseServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/service/ServerInitialiseServiceImpl.kt new file mode 100644 index 00000000..35f53843 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/service/ServerInitialiseServiceImpl.kt @@ -0,0 +1,55 @@ +package dev.usbharu.hideout.service + +import dev.usbharu.hideout.domain.model.hideout.entity.Jwt +import dev.usbharu.hideout.domain.model.hideout.entity.Meta +import dev.usbharu.hideout.repository.IMetaRepository +import dev.usbharu.hideout.util.ServerUtil +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.security.KeyPairGenerator +import java.util.* + +class ServerInitialiseServiceImpl(private val metaRepository: IMetaRepository) : IServerInitialiseService { + + val logger: Logger = LoggerFactory.getLogger(ServerInitialiseServiceImpl::class.java) + + override suspend fun init() { + val savedMeta = metaRepository.get() + val implementationVersion = ServerUtil.getImplementationVersion() + if (wasInitialised(savedMeta).not()) { + logger.info("Start Initialise") + initialise(implementationVersion) + logger.info("Finish Initialise") + return + } + + if (isVersionChanged(savedMeta!!)) { + logger.info("Version changed!! (${savedMeta.version} -> $implementationVersion)") + updateVersion(savedMeta, implementationVersion) + } + } + + private fun wasInitialised(meta: Meta?): Boolean { + logger.debug("Initialise checking...") + return meta != null + } + + private fun isVersionChanged(meta: Meta): Boolean = meta.version != ServerUtil.getImplementationVersion() + + private suspend fun initialise(implementationVersion: String) { + val keyPairGenerator = KeyPairGenerator.getInstance("RSA") + keyPairGenerator.initialize(2048) + val generateKeyPair = keyPairGenerator.generateKeyPair() + val jwt = Jwt( + UUID.randomUUID(), + Base64.getEncoder().encodeToString(generateKeyPair.private.encoded), + Base64.getEncoder().encodeToString(generateKeyPair.public.encoded) + ) + val meta = Meta(implementationVersion, jwt) + metaRepository.save(meta) + } + + private suspend fun updateVersion(meta: Meta, version: String) { + metaRepository.save(meta.copy(version = version)) + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/activitypub/ActivityPubUserServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/service/activitypub/ActivityPubUserServiceImpl.kt index 18811258..767a6bdf 100644 --- a/src/main/kotlin/dev/usbharu/hideout/service/activitypub/ActivityPubUserServiceImpl.kt +++ b/src/main/kotlin/dev/usbharu/hideout/service/activitypub/ActivityPubUserServiceImpl.kt @@ -77,7 +77,7 @@ class ActivityPubUserServiceImpl( publicKeyPem = userEntity.publicKey ) ) - } catch (e: UserNotFoundException) { + } catch (ignore: UserNotFoundException) { val httpResponse = if (targetActor != null) { httpClient.getAp(url, "$targetActor#pubkey") } else { diff --git a/src/main/kotlin/dev/usbharu/hideout/service/impl/UserAuthService.kt b/src/main/kotlin/dev/usbharu/hideout/service/impl/UserAuthService.kt index 2d1ef29b..51356cff 100644 --- a/src/main/kotlin/dev/usbharu/hideout/service/impl/UserAuthService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/service/impl/UserAuthService.kt @@ -1,7 +1,6 @@ package dev.usbharu.hideout.service.impl import dev.usbharu.hideout.config.Config -import dev.usbharu.hideout.exception.UserNotFoundException import dev.usbharu.hideout.repository.IUserRepository import dev.usbharu.hideout.service.IUserAuthService import io.ktor.util.* @@ -24,7 +23,7 @@ class UserAuthService( override suspend fun verifyAccount(username: String, password: String): Boolean { val userEntity = userRepository.findByNameAndDomain(username, Config.configData.domain) - ?: throw UserNotFoundException("$username was not found") + ?: return false return userEntity.password == hash(password) } diff --git a/src/main/kotlin/dev/usbharu/hideout/util/Base64Util.kt b/src/main/kotlin/dev/usbharu/hideout/util/Base64Util.kt new file mode 100644 index 00000000..451d4ade --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/util/Base64Util.kt @@ -0,0 +1,9 @@ +package dev.usbharu.hideout.util + +import java.util.* + +object Base64Util { + fun decode(str: String): ByteArray = Base64.getDecoder().decode(str) + + fun encode(bytes: ByteArray): String = Base64.getEncoder().encodeToString(bytes) +} diff --git a/src/main/kotlin/dev/usbharu/hideout/util/JsonWebKeyUtil.kt b/src/main/kotlin/dev/usbharu/hideout/util/JsonWebKeyUtil.kt new file mode 100644 index 00000000..c733388e --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/util/JsonWebKeyUtil.kt @@ -0,0 +1,39 @@ +package dev.usbharu.hideout.util + +import java.math.BigInteger +import java.security.KeyFactory +import java.security.interfaces.RSAPublicKey +import java.security.spec.X509EncodedKeySpec +import java.util.* + +object JsonWebKeyUtil { + + fun publicKeyToJwk(publicKey: String, kid: String): String { + val x509EncodedKeySpec = X509EncodedKeySpec(Base64.getDecoder().decode(publicKey)) + val generatePublic = KeyFactory.getInstance("RSA").generatePublic(x509EncodedKeySpec) + return publicKeyToJwk(generatePublic as RSAPublicKey, kid) + } + + fun publicKeyToJwk(publicKey: RSAPublicKey, kid: String): String { + val e = encodeBase64UInt(publicKey.publicExponent) + val n = encodeBase64UInt(publicKey.modulus) + return """{"keys":[{"e":"$e","n":"$n","use":"sig","kid":"$kid","kty":"RSA"}]}""" + } + + private fun encodeBase64UInt(bigInteger: BigInteger, minLength: Int = -1): String { + require(bigInteger.signum() >= 0) { "Cannot encode negative numbers" } + + var bytes = bigInteger.toByteArray() + if (bigInteger.bitLength() % 8 == 0 && (bytes[0] == 0.toByte()) && bytes.size > 1) { + bytes = Arrays.copyOfRange(bytes, 1, bytes.size) + } + if (minLength != -1) { + if (bytes.size < minLength) { + val array = ByteArray(minLength) + System.arraycopy(bytes, 0, array, minLength - bytes.size, bytes.size) + bytes = array + } + } + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/util/RsaUtil.kt b/src/main/kotlin/dev/usbharu/hideout/util/RsaUtil.kt new file mode 100644 index 00000000..e0ebbfc8 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/util/RsaUtil.kt @@ -0,0 +1,23 @@ +package dev.usbharu.hideout.util + +import java.security.KeyFactory +import java.security.interfaces.RSAPrivateKey +import java.security.interfaces.RSAPublicKey +import java.security.spec.PKCS8EncodedKeySpec +import java.security.spec.X509EncodedKeySpec + +object RsaUtil { + fun decodeRsaPublicKey(byteArray: ByteArray): RSAPublicKey { + val x509EncodedKeySpec = X509EncodedKeySpec(byteArray) + return KeyFactory.getInstance("RSA").generatePublic(x509EncodedKeySpec) as RSAPublicKey + } + + fun decodeRsaPublicKey(encoded: String): RSAPublicKey = decodeRsaPublicKey(Base64Util.decode(encoded)) + + fun decodeRsaPrivateKey(byteArray: ByteArray): RSAPrivateKey { + val pkcS8EncodedKeySpec = PKCS8EncodedKeySpec(byteArray) + return KeyFactory.getInstance("RSA").generatePrivate(pkcS8EncodedKeySpec) as RSAPrivateKey + } + + fun decodeRsaPrivateKey(encoded: String): RSAPrivateKey = decodeRsaPrivateKey(Base64Util.decode(encoded)) +} diff --git a/src/main/kotlin/dev/usbharu/hideout/util/ServerUtil.kt b/src/main/kotlin/dev/usbharu/hideout/util/ServerUtil.kt new file mode 100644 index 00000000..e8264487 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/util/ServerUtil.kt @@ -0,0 +1,6 @@ +package dev.usbharu.hideout.util + +object ServerUtil { + fun getImplementationVersion(): String = + ServerUtil.javaClass.`package`.implementationVersion ?: "DEVELOPMENT-VERSION" +} diff --git a/src/main/kotlin/dev/usbharu/kjob/exposed/ExposedJobRepository.kt b/src/main/kotlin/dev/usbharu/kjob/exposed/ExposedJobRepository.kt index a9f5e5bb..af71d07f 100644 --- a/src/main/kotlin/dev/usbharu/kjob/exposed/ExposedJobRepository.kt +++ b/src/main/kotlin/dev/usbharu/kjob/exposed/ExposedJobRepository.kt @@ -54,6 +54,7 @@ class ExposedJobRepository( } } + @Suppress("InjectDispatcher") suspend fun query(block: suspend () -> T): T = newSuspendedTransaction(Dispatchers.IO) { block() } override suspend fun completeProgress(id: String): Boolean { @@ -204,7 +205,7 @@ class ExposedJobRepository( this ?: return emptyMap() return json.parseToJsonElement(this).jsonObject.mapValues { (_, el) -> if (el is JsonObject) { - val t = el["t"]?.jsonPrimitive?.content ?: error("Cannot get jsonPrimitive") + val t = el["t"]?.run { jsonPrimitive.content } ?: error("Cannot get jsonPrimitive") val value = el["v"]?.jsonArray ?: error("Cannot get jsonArray") when (t) { "s" -> value.map { it.jsonPrimitive.content } @@ -289,7 +290,7 @@ class ExposedJobRepository( try { @Suppress("SwallowedException") UUID.fromString(it) - } catch (e: IllegalArgumentException) { + } catch (ignored: IllegalArgumentException) { null } }, diff --git a/src/main/kotlin/dev/usbharu/kjob/exposed/ExposedLockRepository.kt b/src/main/kotlin/dev/usbharu/kjob/exposed/ExposedLockRepository.kt index 9ed2146f..e29341ba 100644 --- a/src/main/kotlin/dev/usbharu/kjob/exposed/ExposedLockRepository.kt +++ b/src/main/kotlin/dev/usbharu/kjob/exposed/ExposedLockRepository.kt @@ -33,6 +33,7 @@ class ExposedLockRepository( } } + @Suppress("InjectDispatcher") suspend fun query(block: suspend () -> T): T = newSuspendedTransaction(Dispatchers.IO) { block() } override suspend fun exists(id: UUID): Boolean { diff --git a/src/main/resources/application.conf b/src/main/resources/application.conf index beb8fa3b..db3cc28b 100644 --- a/src/main/resources/application.conf +++ b/src/main/resources/application.conf @@ -20,3 +20,10 @@ hideout { password = "" } } + +jwt { + privateKey = "MIIBVQIBADANBgkqhkiG9w0BAQEFAASCAT8wggE7AgEAAkEAtfJaLrzXILUg1U3N1KV8yJr92GHn5OtYZR7qWk1Mc4cy4JGjklYup7weMjBD9f3bBVoIsiUVX6xNcYIr0Ie0AQIDAQABAkEAg+FBquToDeYcAWBe1EaLVyC45HG60zwfG1S4S3IB+y4INz1FHuZppDjBh09jptQNd+kSMlG1LkAc/3znKTPJ7QIhANpyB0OfTK44lpH4ScJmCxjZV52mIrQcmnS3QzkxWQCDAiEA1Tn7qyoh+0rOO/9vJHP8U/beo51SiQMw0880a1UaiisCIQDNwY46EbhGeiLJR1cidr+JHl86rRwPDsolmeEF5AdzRQIgK3KXL3d0WSoS//K6iOkBX3KMRzaFXNnDl0U/XyeGMuUCIHaXv+n+Brz5BDnRbWS+2vkgIe9bUNlkiArpjWvX+2we" + issuer = "http://0.0.0.0:8080/" + audience = "http://0.0.0.0:8080/hello" + realm = "Access to 'hello'" +} diff --git a/src/main/web/App.tsx b/src/main/web/App.tsx index d1b57e50..0da03fa6 100644 --- a/src/main/web/App.tsx +++ b/src/main/web/App.tsx @@ -1,5 +1,58 @@ -import {Component} from "solid-js"; +import {Component, createSignal} from "solid-js"; export const App: Component = () => { - return (

aaa

) + + const fn = (form: HTMLButtonElement) => { + console.log(form) + } + + const [username, setUsername] = createSignal("") + const [password, setPassword] = createSignal("") + + return ( +
res.json()) + // .then(res => fetch("/auth-check", { + // method: "GET", + // headers: { + // 'Authorization': 'Bearer ' + res.token + // } + // })) + // .then(res => res.json()) + .then(res => { + console.log(res.token); + fetch("/refresh-token", { + method: "POST", + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({refreshToken: res.refreshToken}), + }).then(res=> res.json()).then(res => console.log(res.token)) + }) + } + + }> + setUsername(e.currentTarget.value)}/> + setPassword(e.currentTarget.value)}/> + +
+ ) +} + + +declare module 'solid-js' { + namespace JSX { + interface Directives { + fn: (form: HTMLFormElement) => void + } + } } diff --git a/src/test/kotlin/dev/usbharu/hideout/plugins/SecurityKtTest.kt b/src/test/kotlin/dev/usbharu/hideout/plugins/SecurityKtTest.kt new file mode 100644 index 00000000..9c558f36 --- /dev/null +++ b/src/test/kotlin/dev/usbharu/hideout/plugins/SecurityKtTest.kt @@ -0,0 +1,535 @@ +package dev.usbharu.hideout.plugins + +import com.auth0.jwk.Jwk +import com.auth0.jwk.JwkProvider +import com.auth0.jwt.JWT +import com.auth0.jwt.algorithms.Algorithm +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import dev.usbharu.hideout.config.Config +import dev.usbharu.hideout.config.ConfigData +import dev.usbharu.hideout.domain.model.hideout.dto.JwtToken +import dev.usbharu.hideout.domain.model.hideout.entity.Jwt +import dev.usbharu.hideout.domain.model.hideout.entity.User +import dev.usbharu.hideout.domain.model.hideout.form.RefreshToken +import dev.usbharu.hideout.domain.model.hideout.form.UserLogin +import dev.usbharu.hideout.exception.InvalidRefreshTokenException +import dev.usbharu.hideout.repository.IUserRepository +import dev.usbharu.hideout.service.IJwtService +import dev.usbharu.hideout.service.IMetaService +import dev.usbharu.hideout.service.IUserAuthService +import dev.usbharu.hideout.util.Base64Util +import dev.usbharu.hideout.util.JsonWebKeyUtil +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.server.config.* +import io.ktor.server.testing.* +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.anyString +import org.mockito.kotlin.* +import java.security.KeyPairGenerator +import java.security.interfaces.RSAPrivateKey +import java.security.interfaces.RSAPublicKey +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.* +import kotlin.test.assertEquals + +class SecurityKtTest { + @Test + fun `login ログイン出来るか`() = testApplication { + environment { + config = ApplicationConfig("empty.conf") + } + Config.configData = ConfigData(url = "http://example.com", objectMapper = jacksonObjectMapper()) + val userAuthService = mock { + onBlocking { verifyAccount(eq("testUser"), eq("password")) } doReturn true + } + val metaService = mock() + val userRepository = mock { + onBlocking { findByNameAndDomain(eq("testUser"), eq("example.com")) } doReturn User( + id = 1L, + name = "testUser", + domain = "example.com", + screenName = "test", + description = "", + password = "hashedPassword", + inbox = "https://example.com/inbox", + outbox = "https://example.com/outbox", + url = "https://example.com/profile", + publicKey = "", + privateKey = "", + createdAt = Instant.now() + ) + } + val jwtToken = JwtToken("Token", "RefreshToken") + val jwtService = mock { + onBlocking { createToken(any()) } doReturn jwtToken + } + val jwkProvider = mock() + application { + configureSerialization() + configureSecurity(userAuthService, metaService, userRepository, jwtService, jwkProvider) + } + + client.post("/login") { + contentType(ContentType.Application.Json) + setBody(Config.configData.objectMapper.writeValueAsString(UserLogin("testUser", "password"))) + }.apply { + assertEquals(HttpStatusCode.OK, call.response.status) + assertEquals(jwtToken, Config.configData.objectMapper.readValue(call.response.bodyAsText())) + } + } + + @Test + fun `login 存在しないユーザーのログインに失敗する`() = testApplication { + environment { + config = ApplicationConfig("empty.conf") + } + Config.configData = ConfigData(url = "http://example.com", objectMapper = jacksonObjectMapper()) + val userAuthService = mock { + onBlocking { verifyAccount(anyString(), anyString()) }.doReturn(false) + } + val metaService = mock() + val userRepository = mock() + val jwtService = mock() + val jwkProvider = mock() + application { + configureSerialization() + configureSecurity(userAuthService, metaService, userRepository, jwtService, jwkProvider) + } + client.post("/login") { + contentType(ContentType.Application.Json) + setBody(Config.configData.objectMapper.writeValueAsString(UserLogin("InvalidTtestUser", "password"))) + }.apply { + assertEquals(HttpStatusCode.Unauthorized, call.response.status) + } + } + + @Test + fun `login 不正なパスワードのログインに失敗する`() = testApplication { + environment { + config = ApplicationConfig("empty.conf") + } + Config.configData = ConfigData(url = "http://example.com", objectMapper = jacksonObjectMapper()) + val userAuthService = mock { + onBlocking { verifyAccount(anyString(), eq("InvalidPassword")) } doReturn false + } + val metaService = mock() + val userRepository = mock() + val jwtService = mock() + val jwkProvider = mock() + application { + configureSerialization() + configureSecurity(userAuthService, metaService, userRepository, jwtService, jwkProvider) + } + client.post("/login") { + contentType(ContentType.Application.Json) + setBody(Config.configData.objectMapper.writeValueAsString(UserLogin("TestUser", "InvalidPassword"))) + }.apply { + assertEquals(HttpStatusCode.Unauthorized, call.response.status) + } + } + + @Test + fun `auth-check Authorizedヘッダーが無いと401が帰ってくる`() = testApplication { + environment { + config = ApplicationConfig("empty.conf") + } + Config.configData = ConfigData(url = "http://example.com", objectMapper = jacksonObjectMapper()) + application { + configureSerialization() + configureSecurity(mock(), mock(), mock(), mock(), mock()) + } + client.get("/auth-check").apply { + assertEquals(HttpStatusCode.Unauthorized, call.response.status) + } + } + + @Test + fun `auth-check Authorizedヘッダーの形式が間違っていると401が帰ってくる`() = testApplication { + environment { + config = ApplicationConfig("empty.conf") + } + Config.configData = ConfigData(url = "http://example.com", objectMapper = jacksonObjectMapper()) + application { + configureSerialization() + configureSecurity(mock(), mock(), mock(), mock(), mock()) + } + client.get("/auth-check") { + header("Authorization", "Digest dsfjjhogalkjdfmlhaog") + }.apply { + assertEquals(HttpStatusCode.Unauthorized, call.response.status) + } + } + + @Test + fun `auth-check Authorizedヘッダーが空だと401が帰ってくる`() = testApplication { + environment { + config = ApplicationConfig("empty.conf") + } + Config.configData = ConfigData(url = "http://example.com", objectMapper = jacksonObjectMapper()) + application { + configureSerialization() + configureSecurity(mock(), mock(), mock(), mock(), mock()) + } + client.get("/auth-check") { + header("Authorization", "") + }.apply { + assertEquals(HttpStatusCode.Unauthorized, call.response.status) + } + } + + @Test + fun `auth-check AuthorizedヘッダーがBeararで空だと401が帰ってくる`() = testApplication { + environment { + config = ApplicationConfig("empty.conf") + } + Config.configData = ConfigData(url = "http://example.com", objectMapper = jacksonObjectMapper()) + + application { + configureSerialization() + configureSecurity(mock(), mock(), mock(), mock(), mock()) + } + client.get("/auth-check") { + header("Authorization", "Bearer ") + }.apply { + assertEquals(HttpStatusCode.Unauthorized, call.response.status) + } + } + + @Test + fun `auth-check 正当なJWTだとアクセスできる`() = testApplication { + environment { + config = ApplicationConfig("empty.conf") + } + val keyPairGenerator = KeyPairGenerator.getInstance("RSA") + keyPairGenerator.initialize(2048) + val keyPair = keyPairGenerator.generateKeyPair() + val rsaPublicKey = keyPair.public as RSAPublicKey + + Config.configData = ConfigData(url = "https://localhost", objectMapper = jacksonObjectMapper()) + + val now = Instant.now() + val kid = UUID.randomUUID() + val token = JWT.create() + .withAudience("${Config.configData.url}/users/test") + .withIssuer(Config.configData.url) + .withKeyId(kid.toString()) + .withClaim("username", "test") + .withExpiresAt(now.plus(30, ChronoUnit.MINUTES)) + .sign(Algorithm.RSA256(rsaPublicKey, keyPair.private as RSAPrivateKey)) + val metaService = mock { + onBlocking { getJwtMeta() }.doReturn( + Jwt( + kid, + Base64Util.encode(keyPair.private.encoded), + Base64Util.encode(rsaPublicKey.encoded) + ) + ) + } + + val readValue = Config.configData.objectMapper.readerFor(Map::class.java) + .readValue?>( + JsonWebKeyUtil.publicKeyToJwk( + rsaPublicKey, + kid.toString() + ) + ) + val jwkProvider = mock { + onBlocking { get(anyString()) }.doReturn( + Jwk.fromValues( + (readValue["keys"] as List>)[0] + ) + ) + } + val userRepository = mock() + val jwtService = mock() + application { + configureSerialization() + configureSecurity(mock(), metaService, userRepository, jwtService, jwkProvider) + } + + client.get("/auth-check") { + header("Authorization", "Bearer $token") + }.apply { + assertEquals(HttpStatusCode.OK, call.response.status) + assertEquals("Hello \"test\"", call.response.bodyAsText()) + } + } + + @Test + fun `auth-check 期限切れのトークンではアクセスできない`() = testApplication { + environment { + config = ApplicationConfig("empty.conf") + } + val keyPairGenerator = KeyPairGenerator.getInstance("RSA") + keyPairGenerator.initialize(2048) + val keyPair = keyPairGenerator.generateKeyPair() + val rsaPublicKey = keyPair.public as RSAPublicKey + + Config.configData = ConfigData(url = "https://localhost", objectMapper = jacksonObjectMapper()) + + val now = Instant.now() + val kid = UUID.randomUUID() + val token = JWT.create() + .withAudience("${Config.configData.url}/users/test") + .withIssuer(Config.configData.url) + .withKeyId(kid.toString()) + .withClaim("username", "test") + .withExpiresAt(now.minus(30, ChronoUnit.MINUTES)) + .sign(Algorithm.RSA256(rsaPublicKey, keyPair.private as RSAPrivateKey)) + val metaService = mock { + onBlocking { getJwtMeta() }.doReturn( + Jwt( + kid, + Base64Util.encode(keyPair.private.encoded), + Base64Util.encode(rsaPublicKey.encoded) + ) + ) + } + + val readValue = Config.configData.objectMapper.readerFor(Map::class.java) + .readValue?>( + JsonWebKeyUtil.publicKeyToJwk( + rsaPublicKey, + kid.toString() + ) + ) + val jwkProvider = mock { + onBlocking { get(anyString()) }.doReturn( + Jwk.fromValues( + (readValue["keys"] as List>)[0] + ) + ) + } + val userRepository = mock() + val jwtService = mock() + application { + configureSerialization() + configureSecurity(mock(), metaService, userRepository, jwtService, jwkProvider) + } + client.get("/auth-check") { + header("Authorization", "Bearer $token") + }.apply { + assertEquals(HttpStatusCode.Unauthorized, call.response.status) + } + } + + @Test + fun `auth-check issuerが間違っているとアクセスできない`() = testApplication { + environment { + config = ApplicationConfig("empty.conf") + } + val keyPairGenerator = KeyPairGenerator.getInstance("RSA") + keyPairGenerator.initialize(2048) + val keyPair = keyPairGenerator.generateKeyPair() + val rsaPublicKey = keyPair.public as RSAPublicKey + + Config.configData = ConfigData(url = "https://localhost", objectMapper = jacksonObjectMapper()) + + val now = Instant.now() + val kid = UUID.randomUUID() + val token = JWT.create() + .withAudience("${Config.configData.url}/users/test") + .withIssuer("https://example.com") + .withKeyId(kid.toString()) + .withClaim("username", "test") + .withExpiresAt(now.plus(30, ChronoUnit.MINUTES)) + .sign(Algorithm.RSA256(rsaPublicKey, keyPair.private as RSAPrivateKey)) + val metaService = mock { + onBlocking { getJwtMeta() }.doReturn( + Jwt( + kid, + Base64Util.encode(keyPair.private.encoded), + Base64Util.encode(rsaPublicKey.encoded) + ) + ) + } + + val readValue = Config.configData.objectMapper.readerFor(Map::class.java) + .readValue?>( + JsonWebKeyUtil.publicKeyToJwk( + rsaPublicKey, + kid.toString() + ) + ) + val jwkProvider = mock { + onBlocking { get(anyString()) }.doReturn( + Jwk.fromValues( + (readValue["keys"] as List>)[0] + ) + ) + } + val userRepository = mock() + val jwtService = mock() + application { + configureSerialization() + configureSecurity(mock(), metaService, userRepository, jwtService, jwkProvider) + } + client.get("/auth-check") { + header("Authorization", "Bearer $token") + }.apply { + assertEquals(HttpStatusCode.Unauthorized, call.response.status) + } + } + + @Test + fun `auth-check usernameが空だと失敗する`() = testApplication { + environment { + config = ApplicationConfig("empty.conf") + } + val keyPairGenerator = KeyPairGenerator.getInstance("RSA") + keyPairGenerator.initialize(2048) + val keyPair = keyPairGenerator.generateKeyPair() + val rsaPublicKey = keyPair.public as RSAPublicKey + + Config.configData = ConfigData(url = "https://localhost", objectMapper = jacksonObjectMapper()) + + val now = Instant.now() + val kid = UUID.randomUUID() + val token = JWT.create() + .withAudience("${Config.configData.url}/users/test") + .withIssuer(Config.configData.url) + .withKeyId(kid.toString()) + .withClaim("username", "") + .withExpiresAt(now.plus(30, ChronoUnit.MINUTES)) + .sign(Algorithm.RSA256(rsaPublicKey, keyPair.private as RSAPrivateKey)) + val metaService = mock { + onBlocking { getJwtMeta() }.doReturn( + Jwt( + kid, + Base64Util.encode(keyPair.private.encoded), + Base64Util.encode(rsaPublicKey.encoded) + ) + ) + } + + val readValue = Config.configData.objectMapper.readerFor(Map::class.java) + .readValue?>( + JsonWebKeyUtil.publicKeyToJwk( + rsaPublicKey, + kid.toString() + ) + ) + val jwkProvider = mock { + onBlocking { get(anyString()) }.doReturn( + Jwk.fromValues( + (readValue["keys"] as List>)[0] + ) + ) + } + val userRepository = mock() + val jwtService = mock() + application { + configureSerialization() + configureSecurity(mock(), metaService, userRepository, jwtService, jwkProvider) + } + client.get("/auth-check") { + header("Authorization", "Bearer $token") + }.apply { + assertEquals(HttpStatusCode.Unauthorized, call.response.status) + } + } + + @Test + fun `auth-check usernameが存在しないと失敗する`() = testApplication { + environment { + config = ApplicationConfig("empty.conf") + } + val keyPairGenerator = KeyPairGenerator.getInstance("RSA") + keyPairGenerator.initialize(2048) + val keyPair = keyPairGenerator.generateKeyPair() + val rsaPublicKey = keyPair.public as RSAPublicKey + + Config.configData = ConfigData(url = "https://localhost", objectMapper = jacksonObjectMapper()) + + val now = Instant.now() + val kid = UUID.randomUUID() + val token = JWT.create() + .withAudience("${Config.configData.url}/users/test") + .withIssuer(Config.configData.url) + .withKeyId(kid.toString()) + .withExpiresAt(now.plus(30, ChronoUnit.MINUTES)) + .sign(Algorithm.RSA256(rsaPublicKey, keyPair.private as RSAPrivateKey)) + val metaService = mock { + onBlocking { getJwtMeta() }.doReturn( + Jwt( + kid, + Base64Util.encode(keyPair.private.encoded), + Base64Util.encode(rsaPublicKey.encoded) + ) + ) + } + + val readValue = Config.configData.objectMapper.readerFor(Map::class.java) + .readValue?>( + JsonWebKeyUtil.publicKeyToJwk( + rsaPublicKey, + kid.toString() + ) + ) + val jwkProvider = mock { + onBlocking { get(anyString()) }.doReturn( + Jwk.fromValues( + (readValue["keys"] as List>)[0] + ) + ) + } + val userRepository = mock() + val jwtService = mock() + application { + configureSerialization() + configureSecurity(mock(), metaService, userRepository, jwtService, jwkProvider) + } + client.get("/auth-check") { + header("Authorization", "Bearer $token") + }.apply { + assertEquals(HttpStatusCode.Unauthorized, call.response.status) + } + } + + @Test + fun `refresh-token リフレッシュトークンが正当だとトークンを発行する`() = testApplication { + Config.configData = ConfigData(url = "https://localhost", objectMapper = jacksonObjectMapper()) + environment { + config = ApplicationConfig("empty.conf") + } + val jwtService = mock { + onBlocking { refreshToken(any()) }.doReturn(JwtToken("token", "refreshToken2")) + } + application { + configureSerialization() + configureSecurity(mock(), mock(), mock(), jwtService, mock()) + } + client.post("/refresh-token") { + header("Content-Type", "application/json") + setBody(Config.configData.objectMapper.writeValueAsString(RefreshToken("refreshToken"))) + }.apply { + assertEquals(HttpStatusCode.OK, call.response.status) + } + } + + @Test + fun `refresh-token リフレッシュトークンが不正だと失敗する`() = testApplication { + Config.configData = ConfigData(url = "https://localhost", objectMapper = jacksonObjectMapper()) + environment { + config = ApplicationConfig("empty.conf") + } + val jwtService = mock { + onBlocking { refreshToken(any()) } doThrow InvalidRefreshTokenException("Invalid Refresh Token") + } + application { + configureStatusPages() + configureSerialization() + configureSecurity(mock(), mock(), mock(), jwtService, mock()) + } + client.post("/refresh-token") { + header("Content-Type", "application/json") + setBody(Config.configData.objectMapper.writeValueAsString(RefreshToken("InvalidRefreshToken"))) + }.apply { + assertEquals(HttpStatusCode.BadRequest, call.response.status) + } + } +} diff --git a/src/test/kotlin/dev/usbharu/hideout/service/JwtServiceImplTest.kt b/src/test/kotlin/dev/usbharu/hideout/service/JwtServiceImplTest.kt new file mode 100644 index 00000000..7c4f90bb --- /dev/null +++ b/src/test/kotlin/dev/usbharu/hideout/service/JwtServiceImplTest.kt @@ -0,0 +1,185 @@ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package dev.usbharu.hideout.service + +import com.auth0.jwt.JWT +import com.auth0.jwt.algorithms.Algorithm +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import dev.usbharu.hideout.config.Config +import dev.usbharu.hideout.config.ConfigData +import dev.usbharu.hideout.domain.model.hideout.entity.Jwt +import dev.usbharu.hideout.domain.model.hideout.entity.JwtRefreshToken +import dev.usbharu.hideout.domain.model.hideout.entity.User +import dev.usbharu.hideout.domain.model.hideout.form.RefreshToken +import dev.usbharu.hideout.exception.InvalidRefreshTokenException +import dev.usbharu.hideout.repository.IJwtRefreshTokenRepository +import dev.usbharu.hideout.service.impl.IUserService +import dev.usbharu.hideout.util.Base64Util +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import java.security.KeyPairGenerator +import java.security.interfaces.RSAPrivateKey +import java.security.interfaces.RSAPublicKey +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.* +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +class JwtServiceImplTest { + @Test + fun `createToken トークンを作成できる`() = runTest { + Config.configData = ConfigData(url = "https://example.com", objectMapper = jacksonObjectMapper()) + val kid = UUID.randomUUID() + val keyPairGenerator = KeyPairGenerator.getInstance("RSA") + keyPairGenerator.initialize(2048) + val generateKeyPair = keyPairGenerator.generateKeyPair() + + val metaService = mock { + onBlocking { getJwtMeta() } doReturn Jwt( + kid, + Base64Util.encode(generateKeyPair.private.encoded), + Base64Util.encode(generateKeyPair.public.encoded) + ) + } + val refreshTokenRepository = mock { + onBlocking { generateId() } doReturn 1L + } + val jwtService = JwtServiceImpl(metaService, refreshTokenRepository, mock()) + val token = jwtService.createToken( + User( + id = 1L, + name = "test", + domain = "example.com", + screenName = "testUser", + description = "", + password = "hashedPassword", + inbox = "https://example.com/inbox", + outbox = "https://example.com/outbox", + url = "https://example.com", + publicKey = "-----BEGIN PUBLIC KEY-----...-----END PUBLIC KEY-----", + privateKey = "-----BEGIN PUBLIC KEY-----...-----END PUBLIC KEY-----", + createdAt = Instant.now() + ) + ) + assertNotEquals("", token.token) + assertNotEquals("", token.refreshToken) + val verify = JWT.require( + Algorithm.RSA256( + generateKeyPair.public as RSAPublicKey, + generateKeyPair.private as RSAPrivateKey + ) + ) + .withAudience("https://example.com/users/test") + .withIssuer("https://example.com") + .acceptLeeway(3L) + .build() + .verify(token.token) + + assertEquals(kid.toString(), verify.keyId) + } + + @Test + fun `refreshToken リフレッシュトークンからトークンを作成できる`() = runTest { + Config.configData = ConfigData(url = "https://example.com", objectMapper = jacksonObjectMapper()) + val kid = UUID.randomUUID() + val keyPairGenerator = KeyPairGenerator.getInstance("RSA") + keyPairGenerator.initialize(2048) + val generateKeyPair = keyPairGenerator.generateKeyPair() + + val refreshTokenRepository = mock { + onBlocking { findByToken("refreshToken") } doReturn JwtRefreshToken( + id = 1L, + userId = 1L, + refreshToken = "refreshToken", + createdAt = Instant.now().minus(60, ChronoUnit.MINUTES), + expiresAt = Instant.now().plus(14, ChronoUnit.DAYS).minus(60, ChronoUnit.MINUTES) + ) + onBlocking { generateId() } doReturn 2L + } + val userService = mock { + onBlocking { findById(1L) } doReturn User( + id = 1L, + name = "test", + domain = "example.com", + screenName = "testUser", + description = "", + password = "hashedPassword", + inbox = "https://example.com/inbox", + outbox = "https://example.com/outbox", + url = "https://example.com", + publicKey = "-----BEGIN PUBLIC KEY-----...-----BEGIN PUBLIC KEY-----", + privateKey = "-----BEGIN PRIVATE KEY-----...-----BEGIN PRIVATE KEY-----", + createdAt = Instant.now() + ) + } + val metaService = mock { + onBlocking { getJwtMeta() } doReturn Jwt( + kid, + Base64Util.encode(generateKeyPair.private.encoded), + Base64Util.encode(generateKeyPair.public.encoded) + ) + } + val jwtService = JwtServiceImpl(metaService, refreshTokenRepository, userService) + val refreshToken = jwtService.refreshToken(RefreshToken("refreshToken")) + assertNotEquals("", refreshToken.token) + assertNotEquals("", refreshToken.refreshToken) + + val verify = JWT.require( + Algorithm.RSA256( + generateKeyPair.public as RSAPublicKey, + generateKeyPair.private as RSAPrivateKey + ) + ) + .withAudience("https://example.com/users/test") + .withIssuer("https://example.com") + .acceptLeeway(3L) + .build() + .verify(refreshToken.token) + + assertEquals(kid.toString(), verify.keyId) + } + + @Test + fun `refreshToken 無効なリフレッシュトークンは失敗する`() = runTest { + val refreshTokenRepository = mock { + onBlocking { findByToken("InvalidRefreshToken") } doReturn null + } + val jwtService = JwtServiceImpl(mock(), refreshTokenRepository, mock()) + assertThrows { jwtService.refreshToken(RefreshToken("InvalidRefreshToken")) } + } + + @Test + fun `refreshToken 未来に作成されたリフレッシュトークンは失敗する`() = runTest { + val refreshTokenRepository = mock { + onBlocking { findByToken("refreshToken") } doReturn JwtRefreshToken( + id = 1L, + userId = 1L, + refreshToken = "refreshToken", + createdAt = Instant.now().plus(10, ChronoUnit.MINUTES), + expiresAt = Instant.now().plus(10, ChronoUnit.MINUTES).plus(14, ChronoUnit.DAYS) + ) + } + val jwtService = JwtServiceImpl(mock(), refreshTokenRepository, mock()) + assertThrows { jwtService.refreshToken(RefreshToken("refreshToken")) } + } + + @Test + fun `refreshToken 期限切れのリフレッシュトークンでは失敗する`() = runTest { + val refreshTokenRepository = mock { + onBlocking { findByToken("refreshToken") } doReturn JwtRefreshToken( + id = 1L, + userId = 1L, + refreshToken = "refreshToken", + createdAt = Instant.now().minus(30, ChronoUnit.DAYS), + expiresAt = Instant.now().minus(16, ChronoUnit.DAYS) + ) + } + val jwtService = JwtServiceImpl(mock(), refreshTokenRepository, mock()) + assertThrows { jwtService.refreshToken(RefreshToken("refreshToken")) } + } +} diff --git a/src/test/resources/empty.conf b/src/test/resources/empty.conf index ba691e1c..3c142bff 100644 --- a/src/test/resources/empty.conf +++ b/src/test/resources/empty.conf @@ -10,8 +10,7 @@ ktor { } hideout { - hostname = "https://localhost:8080" - hostname = ${?HOSTNAME} + url = "http://localhost:8080" database { url = "jdbc:h2:./test;MODE=POSTGRESQL" driver = "org.h2.Driver" diff --git a/vite.config.ts b/vite.config.ts index 391fa37d..3f3c0c58 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,7 +7,10 @@ export default defineConfig({ server: { port: 3000, proxy: { - '/api': 'http://localhost:8080' + '/api': 'http://localhost:8080', + '/login': 'http://localhost:8080', + '/auth-check': 'http://localhost:8080', + '/refresh-token': 'http://localhost:8080', } }, root: './src/main/web',