Merge pull request #12 from usbharu/feature/auth

Feature/auth
This commit is contained in:
usbharu 2023-05-03 17:04:23 +09:00 committed by GitHub
commit 85bbe55628
40 changed files with 1461 additions and 56 deletions

View File

@ -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<org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask<*>>().con
compilerOptions.apiVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_8)
}
tasks.withType<ShadowJar> {
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")

View File

@ -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<String>): 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<IPostService> { PostService(get(), get()) }
single<IPostRepository> { PostRepositoryImpl(get(), get()) }
single<IdGenerateService> { TwitterSnowflakeIdGenerateService }
single<IMetaRepository> { MetaRepositoryImpl(get()) }
single<IServerInitialiseService> { ServerInitialiseServiceImpl(get()) }
single<IJwtRefreshTokenRepository> { JwtRefreshTokenRepositoryImpl(get(), get()) }
single<IMetaService> { MetaServiceImpl(get()) }
single<IJwtService> { JwtServiceImpl(get(), get(), get()) }
single<JwkProvider> {
JwkProviderBuilder(Config.configData.url).cached(
10,
24,
TimeUnit.HOURS
)
.rateLimited(10, 1, TimeUnit.MINUTES).build()
}
}
configureKoin(module)
runBlocking {
inject<IServerInitialiseService>().value.init()
}
configureHTTP()
configureStaticRouting()
configureMonitoring()
configureSerialization()
register(inject<IUserService>().value)
configureSecurity(
inject<IUserAuthService>().value,
inject<IMetaService>().value,
inject<IUserRepository>().value,
inject<IJwtService>().value,
inject<JwkProvider>().value,
)
configureRouting(
inject<HttpSignatureVerifyService>().value,
inject<ActivityPubService>().value,

View File

@ -0,0 +1,3 @@
package dev.usbharu.hideout.domain.model.hideout.dto
data class JwtToken(val token: String, val refreshToken: String)

View File

@ -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)

View File

@ -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
)

View File

@ -0,0 +1,3 @@
package dev.usbharu.hideout.domain.model.hideout.entity
data class Meta(val version: String, val jwt: Jwt)

View File

@ -0,0 +1,3 @@
package dev.usbharu.hideout.domain.model.hideout.form
data class RefreshToken(val refreshToken: String)

View File

@ -0,0 +1,3 @@
package dev.usbharu.hideout.domain.model.hideout.form
data class UserLogin(val username: String, val password: String)

View File

@ -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)
}

View File

@ -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
)
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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<UserLogin>()
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<RefreshToken>()
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<JWTPrincipal>()
val username = principal!!.payload.getClaim("username")
call.respondText("Hello $username")
}
skipWhen { true }
}
}
// install(Sessions) {
// cookie<UserSession>("MY_SESSION") {
// cookie.extensions["SameSite"] = "lax"
// }
// }
}

View File

@ -7,11 +7,11 @@ import io.ktor.server.response.*
fun Application.configureStatusPages() {
install(StatusPages) {
exception<Throwable> { call, cause ->
call.respondText(text = "500: $cause", status = HttpStatusCode.InternalServerError)
}
exception<IllegalArgumentException> { call, cause ->
call.respondText(text = "400: $cause", status = HttpStatusCode.BadRequest)
}
exception<Throwable> { call, cause ->
call.respondText(text = "500: $cause", status = HttpStatusCode.InternalServerError)
}
}
}

View File

@ -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()
}

View File

@ -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?
}

View File

@ -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 <T> 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)
}

View File

@ -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 <T> 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)
}

View File

@ -20,6 +20,7 @@ class PostRepositoryImpl(database: Database, private val idGenerateService: IdGe
}
}
@Suppress("InjectDispatcher")
suspend fun <T> query(block: suspend () -> T): T =
newSuspendedTransaction(Dispatchers.IO) { block() }

View File

@ -21,6 +21,7 @@ class UserRepository(private val database: Database, private val idGenerateServi
}
}
@Suppress("InjectDispatcher")
suspend fun <T> query(block: suspend () -> T): T =
newSuspendedTransaction(Dispatchers.IO) { block() }

View File

@ -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()
}

View File

@ -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
}

View File

@ -0,0 +1,5 @@
package dev.usbharu.hideout.service
interface IServerInitialiseService {
suspend fun init()
}

View File

@ -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()
}
}

View File

@ -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
}

View File

@ -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))
}
}

View File

@ -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 {

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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))
}

View File

@ -0,0 +1,6 @@
package dev.usbharu.hideout.util
object ServerUtil {
fun getImplementationVersion(): String =
ServerUtil.javaClass.`package`.implementationVersion ?: "DEVELOPMENT-VERSION"
}

View File

@ -54,6 +54,7 @@ class ExposedJobRepository(
}
}
@Suppress("InjectDispatcher")
suspend fun <T> 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
}
},

View File

@ -33,6 +33,7 @@ class ExposedLockRepository(
}
}
@Suppress("InjectDispatcher")
suspend fun <T> query(block: suspend () -> T): T = newSuspendedTransaction(Dispatchers.IO) { block() }
override suspend fun exists(id: UUID): Boolean {

View File

@ -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'"
}

View File

@ -1,5 +1,58 @@
import {Component} from "solid-js";
import {Component, createSignal} from "solid-js";
export const App: Component = () => {
return (<p>aaa</p>)
const fn = (form: HTMLButtonElement) => {
console.log(form)
}
const [username, setUsername] = createSignal("")
const [password, setPassword] = createSignal("")
return (
<form onSubmit={function (e: SubmitEvent) {
e.preventDefault()
fetch("/login", {
method: "POST",
body: JSON.stringify({username: username(), password: password()}),
headers: {
'Content-Type': 'application/json'
}
}).then(res => 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))
})
}
}>
<input name="username" type="text" placeholder="Username" required
onChange={(e) => setUsername(e.currentTarget.value)}/>
<input name="password" type="password" placeholder="Password" required
onChange={(e) => setPassword(e.currentTarget.value)}/>
<button type="submit">Submit</button>
</form>
)
}
declare module 'solid-js' {
namespace JSX {
interface Directives {
fn: (form: HTMLFormElement) => void
}
}
}

View File

@ -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<IUserAuthService> {
onBlocking { verifyAccount(eq("testUser"), eq("password")) } doReturn true
}
val metaService = mock<IMetaService>()
val userRepository = mock<IUserRepository> {
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<IJwtService> {
onBlocking { createToken(any()) } doReturn jwtToken
}
val jwkProvider = mock<JwkProvider>()
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<IUserAuthService> {
onBlocking { verifyAccount(anyString(), anyString()) }.doReturn(false)
}
val metaService = mock<IMetaService>()
val userRepository = mock<IUserRepository>()
val jwtService = mock<IJwtService>()
val jwkProvider = mock<JwkProvider>()
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<IUserAuthService> {
onBlocking { verifyAccount(anyString(), eq("InvalidPassword")) } doReturn false
}
val metaService = mock<IMetaService>()
val userRepository = mock<IUserRepository>()
val jwtService = mock<IJwtService>()
val jwkProvider = mock<JwkProvider>()
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<IMetaService> {
onBlocking { getJwtMeta() }.doReturn(
Jwt(
kid,
Base64Util.encode(keyPair.private.encoded),
Base64Util.encode(rsaPublicKey.encoded)
)
)
}
val readValue = Config.configData.objectMapper.readerFor(Map::class.java)
.readValue<MutableMap<String, Any>?>(
JsonWebKeyUtil.publicKeyToJwk(
rsaPublicKey,
kid.toString()
)
)
val jwkProvider = mock<JwkProvider> {
onBlocking { get(anyString()) }.doReturn(
Jwk.fromValues(
(readValue["keys"] as List<Map<String, Any>>)[0]
)
)
}
val userRepository = mock<IUserRepository>()
val jwtService = mock<IJwtService>()
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<IMetaService> {
onBlocking { getJwtMeta() }.doReturn(
Jwt(
kid,
Base64Util.encode(keyPair.private.encoded),
Base64Util.encode(rsaPublicKey.encoded)
)
)
}
val readValue = Config.configData.objectMapper.readerFor(Map::class.java)
.readValue<MutableMap<String, Any>?>(
JsonWebKeyUtil.publicKeyToJwk(
rsaPublicKey,
kid.toString()
)
)
val jwkProvider = mock<JwkProvider> {
onBlocking { get(anyString()) }.doReturn(
Jwk.fromValues(
(readValue["keys"] as List<Map<String, Any>>)[0]
)
)
}
val userRepository = mock<IUserRepository>()
val jwtService = mock<IJwtService>()
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<IMetaService> {
onBlocking { getJwtMeta() }.doReturn(
Jwt(
kid,
Base64Util.encode(keyPair.private.encoded),
Base64Util.encode(rsaPublicKey.encoded)
)
)
}
val readValue = Config.configData.objectMapper.readerFor(Map::class.java)
.readValue<MutableMap<String, Any>?>(
JsonWebKeyUtil.publicKeyToJwk(
rsaPublicKey,
kid.toString()
)
)
val jwkProvider = mock<JwkProvider> {
onBlocking { get(anyString()) }.doReturn(
Jwk.fromValues(
(readValue["keys"] as List<Map<String, Any>>)[0]
)
)
}
val userRepository = mock<IUserRepository>()
val jwtService = mock<IJwtService>()
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<IMetaService> {
onBlocking { getJwtMeta() }.doReturn(
Jwt(
kid,
Base64Util.encode(keyPair.private.encoded),
Base64Util.encode(rsaPublicKey.encoded)
)
)
}
val readValue = Config.configData.objectMapper.readerFor(Map::class.java)
.readValue<MutableMap<String, Any>?>(
JsonWebKeyUtil.publicKeyToJwk(
rsaPublicKey,
kid.toString()
)
)
val jwkProvider = mock<JwkProvider> {
onBlocking { get(anyString()) }.doReturn(
Jwk.fromValues(
(readValue["keys"] as List<Map<String, Any>>)[0]
)
)
}
val userRepository = mock<IUserRepository>()
val jwtService = mock<IJwtService>()
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<IMetaService> {
onBlocking { getJwtMeta() }.doReturn(
Jwt(
kid,
Base64Util.encode(keyPair.private.encoded),
Base64Util.encode(rsaPublicKey.encoded)
)
)
}
val readValue = Config.configData.objectMapper.readerFor(Map::class.java)
.readValue<MutableMap<String, Any>?>(
JsonWebKeyUtil.publicKeyToJwk(
rsaPublicKey,
kid.toString()
)
)
val jwkProvider = mock<JwkProvider> {
onBlocking { get(anyString()) }.doReturn(
Jwk.fromValues(
(readValue["keys"] as List<Map<String, Any>>)[0]
)
)
}
val userRepository = mock<IUserRepository>()
val jwtService = mock<IJwtService>()
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<IJwtService> {
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<IJwtService> {
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)
}
}
}

View File

@ -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<IMetaService> {
onBlocking { getJwtMeta() } doReturn Jwt(
kid,
Base64Util.encode(generateKeyPair.private.encoded),
Base64Util.encode(generateKeyPair.public.encoded)
)
}
val refreshTokenRepository = mock<IJwtRefreshTokenRepository> {
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<IJwtRefreshTokenRepository> {
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<IUserService> {
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<IMetaService> {
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<IJwtRefreshTokenRepository> {
onBlocking { findByToken("InvalidRefreshToken") } doReturn null
}
val jwtService = JwtServiceImpl(mock(), refreshTokenRepository, mock())
assertThrows<InvalidRefreshTokenException> { jwtService.refreshToken(RefreshToken("InvalidRefreshToken")) }
}
@Test
fun `refreshToken 未来に作成されたリフレッシュトークンは失敗する`() = runTest {
val refreshTokenRepository = mock<IJwtRefreshTokenRepository> {
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<InvalidRefreshTokenException> { jwtService.refreshToken(RefreshToken("refreshToken")) }
}
@Test
fun `refreshToken 期限切れのリフレッシュトークンでは失敗する`() = runTest {
val refreshTokenRepository = mock<IJwtRefreshTokenRepository> {
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<InvalidRefreshTokenException> { jwtService.refreshToken(RefreshToken("refreshToken")) }
}
}

View File

@ -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"

View File

@ -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',