diff --git a/src/main/kotlin/dev/usbharu/hideout/Application.kt b/src/main/kotlin/dev/usbharu/hideout/Application.kt index b6e8d924..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 @@ -29,6 +31,7 @@ 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) @@ -89,6 +92,14 @@ fun Application.parent() { 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 { @@ -103,7 +114,8 @@ fun Application.parent() { inject().value, inject().value, inject().value, - inject().value + inject().value, + inject().value, ) configureRouting( inject().value, diff --git a/src/main/kotlin/dev/usbharu/hideout/plugins/Routing.kt b/src/main/kotlin/dev/usbharu/hideout/plugins/Routing.kt index 7c843ce9..fed45736 100644 --- a/src/main/kotlin/dev/usbharu/hideout/plugins/Routing.kt +++ b/src/main/kotlin/dev/usbharu/hideout/plugins/Routing.kt @@ -4,7 +4,6 @@ import dev.usbharu.hideout.routing.activitypub.inbox import dev.usbharu.hideout.routing.activitypub.outbox import dev.usbharu.hideout.routing.activitypub.usersAP import dev.usbharu.hideout.routing.api.v1.statuses -import dev.usbharu.hideout.routing.authTestRouting import dev.usbharu.hideout.routing.wellknown.webfinger import dev.usbharu.hideout.service.IPostService import dev.usbharu.hideout.service.activitypub.ActivityPubService @@ -32,7 +31,5 @@ fun Application.configureRouting( route("/api/v1") { statuses(postService) } - - authTestRouting() } } diff --git a/src/main/kotlin/dev/usbharu/hideout/plugins/Security.kt b/src/main/kotlin/dev/usbharu/hideout/plugins/Security.kt index 42210def..674b228a 100644 --- a/src/main/kotlin/dev/usbharu/hideout/plugins/Security.kt +++ b/src/main/kotlin/dev/usbharu/hideout/plugins/Security.kt @@ -1,13 +1,10 @@ -@file:Suppress("UnusedPrivateMember") - package dev.usbharu.hideout.plugins -import com.auth0.jwk.JwkProviderBuilder +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.property import dev.usbharu.hideout.repository.IUserRepository import dev.usbharu.hideout.service.IJwtService import dev.usbharu.hideout.service.IMetaService @@ -20,7 +17,6 @@ import io.ktor.server.auth.jwt.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* -import java.util.concurrent.TimeUnit const val TOKEN_AUTH = "jwt-auth" @@ -29,13 +25,10 @@ fun Application.configureSecurity( userAuthService: IUserAuthService, metaService: IMetaService, userRepository: IUserRepository, - jwtService: IJwtService + jwtService: IJwtService, + jwkProvider: JwkProvider ) { - val issuer = property("hideout.url") - val jwkProvider = JwkProviderBuilder(issuer) - .cached(10, 24, TimeUnit.HOURS) - .rateLimited(10, 1, TimeUnit.MINUTES) - .build() + val issuer = Config.configData.url install(Authentication) { jwt(TOKEN_AUTH) { verifier(jwkProvider, issuer) { @@ -78,5 +71,12 @@ fun Application.configureSecurity( 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") + } + } } } diff --git a/src/main/kotlin/dev/usbharu/hideout/routing/AuthTestRouting.kt b/src/main/kotlin/dev/usbharu/hideout/routing/AuthTestRouting.kt deleted file mode 100644 index 0667a7a7..00000000 --- a/src/main/kotlin/dev/usbharu/hideout/routing/AuthTestRouting.kt +++ /dev/null @@ -1,18 +0,0 @@ -package dev.usbharu.hideout.routing - -import dev.usbharu.hideout.plugins.TOKEN_AUTH -import io.ktor.server.application.* -import io.ktor.server.auth.* -import io.ktor.server.auth.jwt.* -import io.ktor.server.response.* -import io.ktor.server.routing.* - -fun Routing.authTestRouting() { - authenticate(TOKEN_AUTH) { - get("/auth-check") { - val principal = call.principal() - val username = principal!!.payload.getClaim("username") - call.respondText("Hello $username") - } - } -} 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..d5f6a9ea --- /dev/null +++ b/src/test/kotlin/dev/usbharu/hideout/plugins/SecurityKtTest.kt @@ -0,0 +1,282 @@ +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.UserLogin +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.application.* +import io.ktor.server.config.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import io.ktor.server.testing.* +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.anyString +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +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 + +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) + } + externalServices { + hosts("http://localhost:8080") { + routing { + get("/.well-known/jwks.json") { + call.application.log.info("aaaaaaaaaaaaaa") + println("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + call.respondText( + contentType = ContentType.Application.Json, + text = JsonWebKeyUtil.publicKeyToJwk(rsaPublicKey, kid.toString()) + ) + } + } + } + } + + + client.get("/auth-check") { + header("Authorization", "Bearer $token") + }.apply { + assertEquals(HttpStatusCode.OK, call.response.status) + assertEquals("Hello \"test\"",call.response.bodyAsText()) + } + } +} 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"