From 67c6784839e40a3a08de503e0a02f7e9ec0d775d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Vandendaelen?= Date: Sat, 24 Aug 2024 18:38:41 +0200 Subject: [PATCH 1/2] feat: Added JWT support --- build.gradle.kts | 4 +- src/main/kotlin/be/vandeas/Application.kt | 3 +- .../vandeas/controller/v1/FileController.kt | 168 +++++++++++++ .../vandeas/controller/v2/FileController.kt | 225 ++++++++++++++++++ src/main/kotlin/be/vandeas/logic/AuthLogic.kt | 25 ++ .../be/vandeas/logic/impl/AuthLogicImpl.kt | 49 +++- .../kotlin/be/vandeas/plugins/KoinConfig.kt | 25 +- src/main/kotlin/be/vandeas/plugins/Routing.kt | 179 ++------------ .../kotlin/be/vandeas/plugins/Security.kt | 35 +++ .../vandeas/service/{ => v1}/FileService.kt | 2 +- .../service/{ => v1}/impl/FileServiceImpl.kt | 4 +- .../be/vandeas/service/v2/FileService.kt | 8 + .../service/v2/impl/FileServiceImpl.kt | 17 ++ src/main/resources/application.yaml | 4 +- 14 files changed, 574 insertions(+), 174 deletions(-) create mode 100644 src/main/kotlin/be/vandeas/controller/v1/FileController.kt create mode 100644 src/main/kotlin/be/vandeas/controller/v2/FileController.kt create mode 100644 src/main/kotlin/be/vandeas/plugins/Security.kt rename src/main/kotlin/be/vandeas/service/{ => v1}/FileService.kt (95%) rename src/main/kotlin/be/vandeas/service/{ => v1}/impl/FileServiceImpl.kt (95%) create mode 100644 src/main/kotlin/be/vandeas/service/v2/FileService.kt create mode 100644 src/main/kotlin/be/vandeas/service/v2/impl/FileServiceImpl.kt diff --git a/build.gradle.kts b/build.gradle.kts index b8c7af6..8d13725 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -38,12 +38,14 @@ dependencies { implementation("io.ktor:ktor-server-host-common-jvm") implementation("io.ktor:ktor-server-cio-jvm") implementation("ch.qos.logback:logback-classic:$logback_version") - implementation("io.ktor:ktor-server-config-yaml:2.3.12") + implementation("io.ktor:ktor-server-config-yaml") // Ktor features plugins implementation("io.ktor:ktor-server-partial-content:$ktor_version") implementation("io.ktor:ktor-server-auto-head-response:$ktor_version") implementation("io.ktor:ktor-server-status-pages:$ktor_version") + implementation("io.ktor:ktor-server-auth:$ktor_version") + implementation("io.ktor:ktor-server-auth-jwt:$ktor_version") // Cache4k implementation("io.github.reactivecircus.cache4k:cache4k:0.13.0") diff --git a/src/main/kotlin/be/vandeas/Application.kt b/src/main/kotlin/be/vandeas/Application.kt index bc5e630..77344ba 100644 --- a/src/main/kotlin/be/vandeas/Application.kt +++ b/src/main/kotlin/be/vandeas/Application.kt @@ -14,7 +14,8 @@ fun Application.module() { configureSerialization() configureMonitoring() configureHTTP() - configureRouting() configureKoin() configureStatus() + configureSecurity() + configureRouting() } diff --git a/src/main/kotlin/be/vandeas/controller/v1/FileController.kt b/src/main/kotlin/be/vandeas/controller/v1/FileController.kt new file mode 100644 index 0000000..4bd20e6 --- /dev/null +++ b/src/main/kotlin/be/vandeas/controller/v1/FileController.kt @@ -0,0 +1,168 @@ +package be.vandeas.controller.v1 + +import be.vandeas.domain.* +import be.vandeas.dto.* +import be.vandeas.dto.ReadFileBytesResult.Companion.mapToReadFileBytesDto +import be.vandeas.exception.AuthorizationException +import be.vandeas.service.v1.FileService +import io.ktor.http.* +import io.ktor.http.content.* +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import org.koin.ktor.ext.inject + +fun Route.fileControllerV1() = route("/file") { + val fileService by inject() + + get { + val path = call.request.queryParameters["path"] ?: "" + val fileName = call.request.queryParameters["fileName"] ?: "" + val authorization = call.request.authorization() ?: throw AuthorizationException("Authorization header is required") + val accept = call.request.accept()?.let { ContentType.parse(it) } ?: ContentType.Application.Json + + if (path.isBlank() || fileName.isBlank()) { + call.respond(HttpStatusCode.BadRequest, mapOf("path" to path, "fileName" to fileName)) + return@get + } + + val options = FileReadOptions( + path = path, + fileName = fileName + ) + + when (val result = fileService.readFile(authorization, options)) { + is FileBytesReadResult.Failure -> call.respond(HttpStatusCode.InternalServerError, result.message) + is FileBytesReadResult.NotFound -> call.respond(HttpStatusCode.NotFound, FileNameWithPath(path = options.path, fileName = options.fileName)) + is FileBytesReadResult.Success -> when(accept) { + ContentType.Application.Json -> call.respond(HttpStatusCode.OK, result.mapToReadFileBytesDto()) + ContentType.Application.OctetStream -> call.respondBytes(result.data) + else -> call.respond(HttpStatusCode.NotAcceptable, "Accept header must be application/json or application/octet-stream") + } + } + } + + get("/embed") { + val path = call.request.queryParameters["path"] ?: "" + val fileName = call.request.queryParameters["fileName"] ?: "" + val downloadFileName = call.request.queryParameters["download"] ?: "" + val authorization = call.request.queryParameters["token"] ?: call.request.authorization() ?: throw IllegalArgumentException("Authorization header is required") + + if (path.isBlank() || fileName.isBlank()) { + call.respond(HttpStatusCode.BadRequest, mapOf("path" to path, "fileName" to fileName)) + return@get + } + + when (val result = fileService.getFile(authorization, FileReadOptions(path = path, fileName = fileName))) { + is FileReadResult.Failure -> call.respond(HttpStatusCode.InternalServerError, result.message) + is FileReadResult.NotFound -> call.respond(HttpStatusCode.NotFound, FileNameWithPath(path = path, fileName = fileName)) + is FileReadResult.Success -> { + if (downloadFileName.isNotBlank()) { + call.response.header( + HttpHeaders.ContentDisposition, + ContentDisposition.Attachment.withParameter(ContentDisposition.Parameters.FileName, downloadFileName) + .toString() + ) + } else { + call.response.header( + HttpHeaders.ContentDisposition, + ContentDisposition.Attachment.withParameter(ContentDisposition.Parameters.FileName, fileName) + .toString() + ) + } + call.respondFile(result.file) + } + } + } + + post { + val options: Base64FileCreationOptions = call.receive() + val authorization = call.request.authorization() ?: throw AuthorizationException("Authorization header is required") + + when (val result = fileService.createFile(authorization, options)) { + is FileCreationResult.Duplicate -> call.respond(HttpStatusCode.Conflict, FileNameWithPath(path = options.path, fileName = options.fileName)) + is FileCreationResult.Failure -> call.respond(HttpStatusCode.InternalServerError, result.message) + is FileCreationResult.NotFound -> call.respond(HttpStatusCode.NotFound, mapOf("path" to options.path)) + is FileCreationResult.Success -> call.respond(HttpStatusCode.Created, FileNameWithPath(path = options.path, fileName = options.fileName)) + } + } + + post("/upload") { + val authorization = call.request.authorization() ?: throw AuthorizationException("Authorization header is required") + val multipart = call.receiveMultipart() + + var fileName: String? = null + var path: String? = null + var data: ByteArray? = null + + multipart.forEachPart { part -> + when (part) { + is PartData.FormItem -> { + when (part.name) { + "path" -> path = part.value + "fileName" -> fileName = part.value + } + } + is PartData.FileItem -> { + data = part.streamProvider().readBytes() + } + else -> throw IllegalArgumentException("Unsupported part type: ${part::class.simpleName}") + } + part.dispose() + } + + requireNotNull(fileName) { "fileName is required" } + requireNotNull(path) { "path is required" } + requireNotNull(data) { "data is required" } + + val options = BytesFileCreationOptions( + path = path!!, + fileName = fileName!!, + content = data!! + ) + + when (val result = fileService.createFile(authorization, options)) { + is FileCreationResult.Duplicate -> call.respond(HttpStatusCode.Conflict, FileNameWithPath(path = options.path, fileName = options.fileName)) + is FileCreationResult.Failure -> call.respond(HttpStatusCode.InternalServerError, result.message) + is FileCreationResult.NotFound -> call.respond(HttpStatusCode.NotFound, mapOf("path" to options.path)) + is FileCreationResult.Success -> call.respond(HttpStatusCode.Created, FileNameWithPath(path = options.path, fileName = options.fileName)) + } + + } + + delete { + val path = call.request.queryParameters["path"] ?: throw IllegalArgumentException("path query parameter is required") + val fileName = call.request.queryParameters["fileName"] + val recursive = call.request.queryParameters["recursive"]?.toBoolean() ?: false + + val authorization = call.request.authorization() ?: throw IllegalArgumentException("Authorization header is required") + + if (fileName == null) { + val options = DirectoryDeleteOptions( + path = path, + recursive = recursive + ) + + when (val result = fileService.deleteDirectory(authorization, options)) { + is DirectoryDeleteResult.DirectoryHasChildren -> call.respond(HttpStatusCode.BadRequest, mapOf("path" to options.path, "hasChildren" to true)) + is DirectoryDeleteResult.Failure -> call.respond(HttpStatusCode.InternalServerError, result.message) + is DirectoryDeleteResult.IsAFile -> call.respond(HttpStatusCode.BadRequest, mapOf("path" to options.path, "hasChildren" to false)) + is DirectoryDeleteResult.NotFound -> call.respond(HttpStatusCode.NotFound, mapOf("path" to options.path)) + is DirectoryDeleteResult.Success -> call.respond(HttpStatusCode.NoContent) + } + } else { + val options = FileDeleteOptions( + path = call.request.queryParameters["path"] ?: throw IllegalArgumentException("path query parameter is required"), + fileName = call.request.queryParameters["fileName"] ?: "" + ) + + when (val result = fileService.deleteFile(authorization, options)) { + is FileDeleteResult.Failure -> call.respond(HttpStatusCode.InternalServerError, result.message) + is FileDeleteResult.IsADirectory -> call.respond(HttpStatusCode.BadRequest, FileNameWithPath(path = options.path, fileName = options.fileName)) + is FileDeleteResult.NotFound -> call.respond(HttpStatusCode.NotFound, FileNameWithPath(path = options.path, fileName = options.fileName)) + is FileDeleteResult.Success -> call.respond(HttpStatusCode.NoContent) + } + } + } +} diff --git a/src/main/kotlin/be/vandeas/controller/v2/FileController.kt b/src/main/kotlin/be/vandeas/controller/v2/FileController.kt new file mode 100644 index 0000000..c9dd40f --- /dev/null +++ b/src/main/kotlin/be/vandeas/controller/v2/FileController.kt @@ -0,0 +1,225 @@ +package be.vandeas.controller.v2 + +import be.vandeas.domain.* +import be.vandeas.dto.* +import be.vandeas.dto.ReadFileBytesResult.Companion.mapToReadFileBytesDto +import be.vandeas.logic.FileLogic +import be.vandeas.service.v2.FileService +import io.ktor.http.* +import io.ktor.http.content.* +import io.ktor.server.application.* +import io.ktor.server.auth.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import org.koin.ktor.ext.inject + +fun Route.fileControllerV2() = route("/file") { + + val fileLogic by inject() + val fileService by inject() + + authenticate("auth-jwt") { + get { + val path = call.request.queryParameters["path"] ?: "" + val fileName = call.request.queryParameters["fileName"] ?: "" + val accept = call.request.accept()?.let { ContentType.parse(it) } ?: ContentType.Application.Json + + if (path.isBlank() || fileName.isBlank()) { + call.respond(HttpStatusCode.BadRequest, mapOf("path" to path, "fileName" to fileName)) + return@get + } + + val options = FileReadOptions( + path = path, + fileName = fileName + ) + + when (val result = fileLogic.readFile(options)) { + is FileBytesReadResult.Failure -> call.respond(HttpStatusCode.InternalServerError, result.message) + is FileBytesReadResult.NotFound -> call.respond( + HttpStatusCode.NotFound, + FileNameWithPath(path = options.path, fileName = options.fileName) + ) + + is FileBytesReadResult.Success -> when (accept) { + ContentType.Application.Json -> call.respond(HttpStatusCode.OK, result.mapToReadFileBytesDto()) + ContentType.Application.OctetStream -> call.respondBytes(result.data) + else -> call.respond( + HttpStatusCode.NotAcceptable, + "Accept header must be application/json or application/octet-stream" + ) + } + } + } + + post { + val options: Base64FileCreationOptions = call.receive() + + when (val result = fileLogic.createFile(options)) { + is FileCreationResult.Duplicate -> call.respond( + HttpStatusCode.Conflict, + FileNameWithPath(path = options.path, fileName = options.fileName) + ) + + is FileCreationResult.Failure -> call.respond(HttpStatusCode.InternalServerError, result.message) + is FileCreationResult.NotFound -> call.respond(HttpStatusCode.NotFound, mapOf("path" to options.path)) + is FileCreationResult.Success -> call.respond( + HttpStatusCode.Created, + FileNameWithPath(path = options.path, fileName = options.fileName) + ) + } + } + + post("/upload") { + val multipart = call.receiveMultipart() + + var fileName: String? = null + var path: String? = null + var data: ByteArray? = null + + multipart.forEachPart { part -> + when (part) { + is PartData.FormItem -> { + when (part.name) { + "path" -> path = part.value + "fileName" -> fileName = part.value + } + } + + is PartData.FileItem -> { + data = part.streamProvider().readBytes() + } + + else -> throw IllegalArgumentException("Unsupported part type: ${part::class.simpleName}") + } + part.dispose() + } + + requireNotNull(fileName) { "fileName is required" } + requireNotNull(path) { "path is required" } + requireNotNull(data) { "data is required" } + + val options = BytesFileCreationOptions( + path = path!!, + fileName = fileName!!, + content = data!! + ) + + when (val result = fileLogic.createFile(options)) { + is FileCreationResult.Duplicate -> call.respond( + HttpStatusCode.Conflict, + FileNameWithPath(path = options.path, fileName = options.fileName) + ) + + is FileCreationResult.Failure -> call.respond(HttpStatusCode.InternalServerError, result.message) + is FileCreationResult.NotFound -> call.respond(HttpStatusCode.NotFound, mapOf("path" to options.path)) + is FileCreationResult.Success -> call.respond( + HttpStatusCode.Created, + FileNameWithPath(path = options.path, fileName = options.fileName) + ) + } + + } + + delete { + val path = call.request.queryParameters["path"] + ?: throw IllegalArgumentException("path query parameter is required") + val fileName = call.request.queryParameters["fileName"] + val recursive = call.request.queryParameters["recursive"]?.toBoolean() ?: false + + if (fileName == null) { + val options = DirectoryDeleteOptions( + path = path, + recursive = recursive + ) + + when (val result = fileLogic.deleteDirectory(options)) { + is DirectoryDeleteResult.DirectoryHasChildren -> call.respond( + HttpStatusCode.BadRequest, + mapOf("path" to options.path, "hasChildren" to true) + ) + + is DirectoryDeleteResult.Failure -> call.respond(HttpStatusCode.InternalServerError, result.message) + is DirectoryDeleteResult.IsAFile -> call.respond( + HttpStatusCode.BadRequest, + mapOf("path" to options.path, "hasChildren" to false) + ) + + is DirectoryDeleteResult.NotFound -> call.respond( + HttpStatusCode.NotFound, + mapOf("path" to options.path) + ) + + is DirectoryDeleteResult.Success -> call.respond(HttpStatusCode.NoContent) + } + } else { + val options = FileDeleteOptions( + path = call.request.queryParameters["path"] + ?: throw IllegalArgumentException("path query parameter is required"), + fileName = call.request.queryParameters["fileName"] ?: "" + ) + + when (val result = fileLogic.deleteFile(options)) { + is FileDeleteResult.Failure -> call.respond(HttpStatusCode.InternalServerError, result.message) + is FileDeleteResult.IsADirectory -> call.respond( + HttpStatusCode.BadRequest, + FileNameWithPath(path = options.path, fileName = options.fileName) + ) + + is FileDeleteResult.NotFound -> call.respond( + HttpStatusCode.NotFound, + FileNameWithPath(path = options.path, fileName = options.fileName) + ) + + is FileDeleteResult.Success -> call.respond(HttpStatusCode.NoContent) + } + } + } + } + get("/embed") { + val path = call.request.queryParameters["path"] ?: "" + val fileName = call.request.queryParameters["fileName"] ?: "" + val downloadFileName = call.request.queryParameters["download"] ?: "" + val authorization = + call.request.queryParameters["token"] ?: call.request.authorization() ?: throw IllegalArgumentException( + "Authorization header is required" + ) + + if (path.isBlank() || fileName.isBlank()) { + call.respond(HttpStatusCode.BadRequest, mapOf("path" to path, "fileName" to fileName)) + return@get + } + + when (val result = fileService.getFile(authorization, FileReadOptions(path = path, fileName = fileName))) { + is FileReadResult.Failure -> call.respond(HttpStatusCode.InternalServerError, result.message) + is FileReadResult.NotFound -> call.respond( + HttpStatusCode.NotFound, + FileNameWithPath(path = path, fileName = fileName) + ) + + is FileReadResult.Success -> { + if (downloadFileName.isNotBlank()) { + call.response.header( + HttpHeaders.ContentDisposition, + ContentDisposition.Attachment.withParameter( + ContentDisposition.Parameters.FileName, + downloadFileName + ) + .toString() + ) + } else { + call.response.header( + HttpHeaders.ContentDisposition, + ContentDisposition.Attachment.withParameter( + ContentDisposition.Parameters.FileName, + fileName + ) + .toString() + ) + } + call.respondFile(result.file) + } + } + } +} diff --git a/src/main/kotlin/be/vandeas/logic/AuthLogic.kt b/src/main/kotlin/be/vandeas/logic/AuthLogic.kt index 59cf72f..e8700b1 100644 --- a/src/main/kotlin/be/vandeas/logic/AuthLogic.kt +++ b/src/main/kotlin/be/vandeas/logic/AuthLogic.kt @@ -2,6 +2,7 @@ package be.vandeas.logic import java.nio.file.InvalidPathException import java.nio.file.Path +import kotlin.time.Duration interface AuthLogic { @@ -17,6 +18,7 @@ interface AuthLogic { * * @throws InvalidPathException if the path is invalid. */ + @Deprecated("Use JWT tokens instead") fun getOneTimeToken(apiKey: String, path: String, fileName: String?): String /** @@ -24,6 +26,7 @@ interface AuthLogic { * * @return The path if the token is valid, null otherwise. */ + @Deprecated("Use JWT tokens instead") fun validateOneTimeToken(token: String, path: Path): Path? /** @@ -31,5 +34,27 @@ interface AuthLogic { * * @return The result of the protected method. */ + @Deprecated("Use JWT tokens instead") fun guard(token: String, path: Path, protectedMethod: () -> T): T + + /** + * Guards a protected method with a JWT token. + * + * @return The result of the protected method. + */ + fun guard(jwt: String, protectedMethod: () -> T): T + + /** + * Generates a JWT token for the given API key. + * + * @return The JWT token. + */ + fun getJwtToken(apiKey: String, duration: Duration): String + + /** + * Validates a JWT token. + * + * @return true if the token is valid, false otherwise. + */ + fun validateJwtToken(token: String): Boolean } diff --git a/src/main/kotlin/be/vandeas/logic/impl/AuthLogicImpl.kt b/src/main/kotlin/be/vandeas/logic/impl/AuthLogicImpl.kt index ac806f4..da61f79 100644 --- a/src/main/kotlin/be/vandeas/logic/impl/AuthLogicImpl.kt +++ b/src/main/kotlin/be/vandeas/logic/impl/AuthLogicImpl.kt @@ -2,17 +2,27 @@ package be.vandeas.logic.impl import be.vandeas.exception.AuthorizationException import be.vandeas.logic.AuthLogic +import com.auth0.jwt.JWT +import com.auth0.jwt.algorithms.Algorithm import io.github.reactivecircus.cache4k.Cache +import io.ktor.server.application.* import java.nio.file.Path -import java.util.UUID +import java.util.* +import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes -class AuthLogicImpl : AuthLogic { +class AuthLogicImpl( + private val secret: String, + private val issuer: String, + private val audience: String, + private val realm: String, +) : AuthLogic { private val tokenCache = Cache.Builder().expireAfterWrite(5.minutes).build() private val apiKey: String = System.getenv("API_KEY") ?: throw IllegalStateException("API_KEY is not set") + @Deprecated("Use JWT tokens instead") override fun getOneTimeToken(apiKey: String, path: String, fileName: String?): String { - if (apiKey != this.apiKey) { + if (!validateApiKey(apiKey)) { throw AuthorizationException("Invalid API key") } @@ -21,15 +31,48 @@ class AuthLogicImpl : AuthLogic { } } + @Deprecated("Use JWT tokens instead") override fun validateOneTimeToken(token: String, path: Path): Path = tokenCache.get(token) ?.takeIf { it == path }?.also { tokenCache.invalidate(token) } ?: throw AuthorizationException("Invalid one-time token") + @Deprecated("Use JWT tokens instead") override fun guard(token: String, path: Path, protectedMethod: () -> T): T = validateOneTimeToken(token, path).let { protectedMethod() } + override fun guard(jwt: String, protectedMethod: () -> T): T { + TODO("Not yet implemented") + } + + override fun getJwtToken(apiKey: String, duration: Duration): String { + if (validateApiKey(apiKey)) { + throw AuthorizationException("Invalid API key") + } + + val token = JWT.create() + .withAudience(audience) + .withIssuer(issuer) + .withExpiresAt(Date(System.currentTimeMillis() + duration.inWholeMilliseconds)) + .sign(Algorithm.HMAC256(secret)) + + return token + } + + override fun validateJwtToken(token: String): Boolean { + runCatching { + JWT.require(Algorithm.HMAC256(secret)) + .withAudience(audience) + .withIssuer(issuer) + .build() + .verify(token) + }.onFailure { + return false + } + return true + } + override fun validateApiKey(apiKey: String): Boolean = apiKey == this.apiKey } diff --git a/src/main/kotlin/be/vandeas/plugins/KoinConfig.kt b/src/main/kotlin/be/vandeas/plugins/KoinConfig.kt index 6467e82..3d3c8c4 100644 --- a/src/main/kotlin/be/vandeas/plugins/KoinConfig.kt +++ b/src/main/kotlin/be/vandeas/plugins/KoinConfig.kt @@ -4,8 +4,10 @@ import be.vandeas.logic.AuthLogic import be.vandeas.logic.FileLogic import be.vandeas.logic.impl.AuthLogicImpl import be.vandeas.logic.impl.FileLogicImpl -import be.vandeas.service.FileService -import be.vandeas.service.impl.FileServiceImpl +import be.vandeas.service.v1.FileService as FileServiceV1 +import be.vandeas.service.v2.FileService as FileServiceV2 +import be.vandeas.service.v1.impl.FileServiceImpl as FileServiceImplV1 +import be.vandeas.service.v2.impl.FileServiceImpl as FileServiceImplV2 import io.ktor.server.application.* import org.koin.dsl.module import org.koin.ktor.plugin.Koin @@ -14,24 +16,33 @@ import org.koin.ktor.plugin.KoinApplicationStopPreparing import org.koin.ktor.plugin.KoinApplicationStopped import org.koin.logger.slf4jLogger -val appModule = module { +fun appModule(environment: ApplicationEnvironment) = module { single { FileLogicImpl() } single { - AuthLogicImpl() + AuthLogicImpl( + secret = System.getenv("JWT_SECRET"), + issuer = System.getenv("JWT_ISSUER"), + audience = System.getenv("JWT_AUDIENCE"), + realm = System.getenv("JWT_REALM"), + ) } - single { - FileServiceImpl(get(), get()) + single { + FileServiceImplV1(get(), get()) + } + + single { + FileServiceImplV2(get(), get()) } } fun Application.configureKoin() { install(Koin) { slf4jLogger() - modules(appModule) + modules(appModule(environment)) } environment.monitor.subscribe(KoinApplicationStarted) { diff --git a/src/main/kotlin/be/vandeas/plugins/Routing.kt b/src/main/kotlin/be/vandeas/plugins/Routing.kt index 8bd3be0..dce0feb 100644 --- a/src/main/kotlin/be/vandeas/plugins/Routing.kt +++ b/src/main/kotlin/be/vandeas/plugins/Routing.kt @@ -1,188 +1,53 @@ package be.vandeas.plugins -import be.vandeas.domain.* -import be.vandeas.dto.* -import be.vandeas.dto.ReadFileBytesResult.Companion.mapToReadFileBytesDto -import be.vandeas.exception.AuthorizationException import be.vandeas.logic.AuthLogic -import be.vandeas.service.FileService +import be.vandeas.controller.v1.fileControllerV1 +import be.vandeas.controller.v2.fileControllerV2 import io.ktor.http.* -import io.ktor.http.content.* import io.ktor.server.application.* +import io.ktor.server.auth.* import io.ktor.server.plugins.autohead.* import io.ktor.server.plugins.partialcontent.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* import org.koin.ktor.ext.inject +import kotlin.time.Duration.Companion.seconds fun Application.configureRouting() { install(PartialContent) install(AutoHeadResponse) - val fileService by inject() val authLogic by inject() routing { route("/v1") { - route("/file") { - get { - val path = call.request.queryParameters["path"] ?: "" - val fileName = call.request.queryParameters["fileName"] ?: "" - val authorization = call.request.authorization() ?: throw AuthorizationException("Authorization header is required") - val accept = call.request.accept()?.let { ContentType.parse(it) } ?: ContentType.Application.Json - - if (path.isBlank() || fileName.isBlank()) { - call.respond(HttpStatusCode.BadRequest, mapOf("path" to path, "fileName" to fileName)) - return@get - } - - val options = FileReadOptions( - path = path, - fileName = fileName - ) - - when (val result = fileService.readFile(authorization, options)) { - is FileBytesReadResult.Failure -> call.respond(HttpStatusCode.InternalServerError, result.message) - is FileBytesReadResult.NotFound -> call.respond(HttpStatusCode.NotFound, FileNameWithPath(path = options.path, fileName = options.fileName)) - is FileBytesReadResult.Success -> when(accept) { - ContentType.Application.Json -> call.respond(HttpStatusCode.OK, result.mapToReadFileBytesDto()) - ContentType.Application.OctetStream -> call.respondBytes(result.data) - else -> call.respond(HttpStatusCode.NotAcceptable, "Accept header must be application/json or application/octet-stream") - } - } - } - - get("/embed") { - val path = call.request.queryParameters["path"] ?: "" - val fileName = call.request.queryParameters["fileName"] ?: "" - val downloadFileName = call.request.queryParameters["download"] ?: "" - val authorization = call.request.queryParameters["token"] ?: call.request.authorization() ?: throw IllegalArgumentException("Authorization header is required") - - if (path.isBlank() || fileName.isBlank()) { - call.respond(HttpStatusCode.BadRequest, mapOf("path" to path, "fileName" to fileName)) - return@get - } - - when (val result = fileService.getFile(authorization, FileReadOptions(path = path, fileName = fileName))) { - is FileReadResult.Failure -> call.respond(HttpStatusCode.InternalServerError, result.message) - is FileReadResult.NotFound -> call.respond(HttpStatusCode.NotFound, FileNameWithPath(path = path, fileName = fileName)) - is FileReadResult.Success -> { - if (downloadFileName.isNotBlank()) { - call.response.header( - HttpHeaders.ContentDisposition, - ContentDisposition.Attachment.withParameter(ContentDisposition.Parameters.FileName, downloadFileName) - .toString() - ) - } else { - call.response.header( - HttpHeaders.ContentDisposition, - ContentDisposition.Attachment.withParameter(ContentDisposition.Parameters.FileName, fileName) - .toString() - ) - } - call.respondFile(result.file) - } - } - } - - post { - val options: Base64FileCreationOptions = call.receive() - val authorization = call.request.authorization() ?: throw AuthorizationException("Authorization header is required") - - when (val result = fileService.createFile(authorization, options)) { - is FileCreationResult.Duplicate -> call.respond(HttpStatusCode.Conflict, FileNameWithPath(path = options.path, fileName = options.fileName)) - is FileCreationResult.Failure -> call.respond(HttpStatusCode.InternalServerError, result.message) - is FileCreationResult.NotFound -> call.respond(HttpStatusCode.NotFound, mapOf("path" to options.path)) - is FileCreationResult.Success -> call.respond(HttpStatusCode.Created, FileNameWithPath(path = options.path, fileName = options.fileName)) - } - } - - post("/upload") { - val authorization = call.request.authorization() ?: throw AuthorizationException("Authorization header is required") - val multipart = call.receiveMultipart() - - var fileName: String? = null - var path: String? = null - var data: ByteArray? = null - - multipart.forEachPart { part -> - when (part) { - is PartData.FormItem -> { - when (part.name) { - "path" -> path = part.value - "fileName" -> fileName = part.value - } - } - is PartData.FileItem -> { - data = part.streamProvider().readBytes() - } - else -> throw IllegalArgumentException("Unsupported part type: ${part::class.simpleName}") - } - part.dispose() - } - - requireNotNull(fileName) { "fileName is required" } - requireNotNull(path) { "path is required" } - requireNotNull(data) { "data is required" } - - val options = BytesFileCreationOptions( - path = path!!, - fileName = fileName!!, - content = data!! - ) - - when (val result = fileService.createFile(authorization, options)) { - is FileCreationResult.Duplicate -> call.respond(HttpStatusCode.Conflict, FileNameWithPath(path = options.path, fileName = options.fileName)) - is FileCreationResult.Failure -> call.respond(HttpStatusCode.InternalServerError, result.message) - is FileCreationResult.NotFound -> call.respond(HttpStatusCode.NotFound, mapOf("path" to options.path)) - is FileCreationResult.Success -> call.respond(HttpStatusCode.Created, FileNameWithPath(path = options.path, fileName = options.fileName)) - } - - } - - delete { - val path = call.request.queryParameters["path"] ?: throw IllegalArgumentException("path query parameter is required") + fileControllerV1() + route("/auth") { + get("/token") { + val authorization = call.request.authorization() + ?: throw IllegalArgumentException("Authorization header is required") + val path = + call.request.queryParameters["path"] ?: throw IllegalArgumentException("path is required") val fileName = call.request.queryParameters["fileName"] - val recursive = call.request.queryParameters["recursive"]?.toBoolean() ?: false - - val authorization = call.request.authorization() ?: throw IllegalArgumentException("Authorization header is required") - - if (fileName == null) { - val options = DirectoryDeleteOptions( - path = path, - recursive = recursive - ) - - when (val result = fileService.deleteDirectory(authorization, options)) { - is DirectoryDeleteResult.DirectoryHasChildren -> call.respond(HttpStatusCode.BadRequest, mapOf("path" to options.path, "hasChildren" to true)) - is DirectoryDeleteResult.Failure -> call.respond(HttpStatusCode.InternalServerError, result.message) - is DirectoryDeleteResult.IsAFile -> call.respond(HttpStatusCode.BadRequest, mapOf("path" to options.path, "hasChildren" to false)) - is DirectoryDeleteResult.NotFound -> call.respond(HttpStatusCode.NotFound, mapOf("path" to options.path)) - is DirectoryDeleteResult.Success -> call.respond(HttpStatusCode.NoContent) - } - } else { - val options = FileDeleteOptions( - path = call.request.queryParameters["path"] ?: throw IllegalArgumentException("path query parameter is required"), - fileName = call.request.queryParameters["fileName"] ?: "" - ) + ?: throw IllegalArgumentException("fileName is required") - when (val result = fileService.deleteFile(authorization, options)) { - is FileDeleteResult.Failure -> call.respond(HttpStatusCode.InternalServerError, result.message) - is FileDeleteResult.IsADirectory -> call.respond(HttpStatusCode.BadRequest, FileNameWithPath(path = options.path, fileName = options.fileName)) - is FileDeleteResult.NotFound -> call.respond(HttpStatusCode.NotFound, FileNameWithPath(path = options.path, fileName = options.fileName)) - is FileDeleteResult.Success -> call.respond(HttpStatusCode.NoContent) - } + authLogic.getOneTimeToken(authorization, path, fileName).let { + call.respond(HttpStatusCode.OK, mapOf("token" to it)) } } } + } + route("/v2") { + fileControllerV2() route("/auth") { get("/token") { - val authorization = call.request.authorization() ?: throw IllegalArgumentException("Authorization header is required") - val path = call.request.queryParameters["path"] ?: throw IllegalArgumentException("path is required") - val fileName = call.request.queryParameters["fileName"] ?: throw IllegalArgumentException("fileName is required") + val authorization = call.request.authorization() + ?: throw IllegalArgumentException("Authorization header is required") + val lifeTime = call.request.queryParameters["lifeTime"]?.toInt() + ?: throw IllegalArgumentException("lifeTime is required") - authLogic.getOneTimeToken(authorization, path, fileName).let { + authLogic.getJwtToken(authorization, lifeTime.seconds).let { call.respond(HttpStatusCode.OK, mapOf("token" to it)) } } diff --git a/src/main/kotlin/be/vandeas/plugins/Security.kt b/src/main/kotlin/be/vandeas/plugins/Security.kt new file mode 100644 index 0000000..b3dead8 --- /dev/null +++ b/src/main/kotlin/be/vandeas/plugins/Security.kt @@ -0,0 +1,35 @@ +package be.vandeas.plugins + +import com.auth0.jwt.JWT +import com.auth0.jwt.algorithms.Algorithm +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.auth.* +import io.ktor.server.auth.jwt.* +import io.ktor.server.response.* + +fun Application.configureSecurity() { + val secret = System.getenv("JWT_SECRET") + val issuer = System.getenv("JWT_ISSUER") + val audience = System.getenv("JWT_AUDIENCE") + val myRealm = System.getenv("JWT_REALM") + + install(Authentication) { + jwt("auth-jwt") { + realm = myRealm + verifier( + JWT + .require(Algorithm.HMAC256(secret)) + .withAudience(audience) + .withIssuer(issuer) + .build() + ) + validate { credential -> + JWTPrincipal(credential.payload) + } + challenge { defaultScheme, realm -> + call.respond(HttpStatusCode.Unauthorized, "Token is not valid or has expired") + } + } + } +} diff --git a/src/main/kotlin/be/vandeas/service/FileService.kt b/src/main/kotlin/be/vandeas/service/v1/FileService.kt similarity index 95% rename from src/main/kotlin/be/vandeas/service/FileService.kt rename to src/main/kotlin/be/vandeas/service/v1/FileService.kt index 7cc903e..6480484 100644 --- a/src/main/kotlin/be/vandeas/service/FileService.kt +++ b/src/main/kotlin/be/vandeas/service/v1/FileService.kt @@ -1,4 +1,4 @@ -package be.vandeas.service +package be.vandeas.service.v1 import be.vandeas.domain.* import be.vandeas.dto.* diff --git a/src/main/kotlin/be/vandeas/service/impl/FileServiceImpl.kt b/src/main/kotlin/be/vandeas/service/v1/impl/FileServiceImpl.kt similarity index 95% rename from src/main/kotlin/be/vandeas/service/impl/FileServiceImpl.kt rename to src/main/kotlin/be/vandeas/service/v1/impl/FileServiceImpl.kt index 9bcf45f..f0fdfce 100644 --- a/src/main/kotlin/be/vandeas/service/impl/FileServiceImpl.kt +++ b/src/main/kotlin/be/vandeas/service/v1/impl/FileServiceImpl.kt @@ -1,10 +1,10 @@ -package be.vandeas.service.impl +package be.vandeas.service.v1.impl import be.vandeas.domain.* import be.vandeas.dto.* import be.vandeas.logic.AuthLogic import be.vandeas.logic.FileLogic -import be.vandeas.service.FileService +import be.vandeas.service.v1.FileService import java.nio.file.Path class FileServiceImpl( diff --git a/src/main/kotlin/be/vandeas/service/v2/FileService.kt b/src/main/kotlin/be/vandeas/service/v2/FileService.kt new file mode 100644 index 0000000..9f917fd --- /dev/null +++ b/src/main/kotlin/be/vandeas/service/v2/FileService.kt @@ -0,0 +1,8 @@ +package be.vandeas.service.v2 + +import be.vandeas.domain.* +import be.vandeas.dto.* + +interface FileService { + fun getFile(jwt: String, fileReadOptions: FileReadOptions): FileReadResult +} diff --git a/src/main/kotlin/be/vandeas/service/v2/impl/FileServiceImpl.kt b/src/main/kotlin/be/vandeas/service/v2/impl/FileServiceImpl.kt new file mode 100644 index 0000000..54aab40 --- /dev/null +++ b/src/main/kotlin/be/vandeas/service/v2/impl/FileServiceImpl.kt @@ -0,0 +1,17 @@ +package be.vandeas.service.v2.impl + +import be.vandeas.domain.* +import be.vandeas.dto.* +import be.vandeas.logic.AuthLogic +import be.vandeas.logic.FileLogic +import be.vandeas.service.v2.FileService + +class FileServiceImpl( + private val fileLogic: FileLogic, + private val authLogic: AuthLogic +) : FileService { + override fun getFile(jwt: String, fileReadOptions: FileReadOptions): FileReadResult = + authLogic.guard(jwt) { + fileLogic.getFile(fileReadOptions) + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 57267ec..f3bce82 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,6 +1,6 @@ ktor: application: modules: - - com.example.ApplicationKt.module + - be.vandeas.ApplicationKt.module deployment: - port: 8080 + port: 8082 From 19d19bb3d214872cf0270a1b23b6e3b135e74942 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Vandendaelen?= Date: Fri, 30 Aug 2024 22:58:13 +0200 Subject: [PATCH 2/2] test: Added test for JWT support --- .../be/vandeas/logic/impl/AuthLogicImpl.kt | 2 +- .../be/vandeas/{ => v1}/ApplicationTest.kt | 2 +- .../kotlin/be/vandeas/v2/ApplicationTest.kt | 194 ++++++++++++++++++ 3 files changed, 196 insertions(+), 2 deletions(-) rename src/test/kotlin/be/vandeas/{ => v1}/ApplicationTest.kt (99%) create mode 100644 src/test/kotlin/be/vandeas/v2/ApplicationTest.kt diff --git a/src/main/kotlin/be/vandeas/logic/impl/AuthLogicImpl.kt b/src/main/kotlin/be/vandeas/logic/impl/AuthLogicImpl.kt index da61f79..51f660f 100644 --- a/src/main/kotlin/be/vandeas/logic/impl/AuthLogicImpl.kt +++ b/src/main/kotlin/be/vandeas/logic/impl/AuthLogicImpl.kt @@ -47,7 +47,7 @@ class AuthLogicImpl( } override fun getJwtToken(apiKey: String, duration: Duration): String { - if (validateApiKey(apiKey)) { + if (!validateApiKey(apiKey)) { throw AuthorizationException("Invalid API key") } diff --git a/src/test/kotlin/be/vandeas/ApplicationTest.kt b/src/test/kotlin/be/vandeas/v1/ApplicationTest.kt similarity index 99% rename from src/test/kotlin/be/vandeas/ApplicationTest.kt rename to src/test/kotlin/be/vandeas/v1/ApplicationTest.kt index 5a06a43..23225d8 100644 --- a/src/test/kotlin/be/vandeas/ApplicationTest.kt +++ b/src/test/kotlin/be/vandeas/v1/ApplicationTest.kt @@ -1,4 +1,4 @@ -package be.vandeas +package be.vandeas.v1 import be.vandeas.dto.Base64FileCreationOptions import be.vandeas.dto.ReadFileBytesResult diff --git a/src/test/kotlin/be/vandeas/v2/ApplicationTest.kt b/src/test/kotlin/be/vandeas/v2/ApplicationTest.kt new file mode 100644 index 0000000..a0325f3 --- /dev/null +++ b/src/test/kotlin/be/vandeas/v2/ApplicationTest.kt @@ -0,0 +1,194 @@ +package be.vandeas.v2 + +import be.vandeas.dto.Base64FileCreationOptions +import be.vandeas.dto.ReadFileBytesResult +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.engine.cio.* +import io.ktor.client.request.* +import io.ktor.client.request.forms.* +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* +import io.ktor.util.* +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import java.util.* +import kotlin.io.path.toPath +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation as ClientContentNegotiation + +class ApplicationTest { + + val apiKey = System.getenv("API_KEY") ?: throw IllegalStateException("API_KEY is not set") + + private val client = HttpClient(CIO) { + install(ClientContentNegotiation) { + json() + } + } + + private suspend fun getToken(lifeTime: Duration): String?{ + return client.get("http://localhost:8082/v2/auth/token?lifeTime=${lifeTime.inWholeSeconds}") { + header("Authorization", apiKey) + }.apply { + assertEquals(HttpStatusCode.OK, status) + }.body>()["token"] + } + + @Test + fun `Should be able to write and read`() { + runBlocking { + val jwt = getToken(60.seconds)!! + val dirName = UUID.randomUUID().toString() + val fileNames = listOf( + "file.txt", + "file.pdf", + "img.webp" + ) + + fileNames.forEach { fileName -> + val testedFile = this::class.java.classLoader.getResource("input/$fileName")!!.toURI().toPath().toFile() + + client.post("http://localhost:8082/v2/file") { + contentType(ContentType.Application.Json) + accept(ContentType.Application.Json) + bearerAuth(jwt) + setBody( + Base64FileCreationOptions( + path = dirName, + fileName = fileName, + content = testedFile.readBytes().encodeBase64() + ) + ) + }.apply { + assertEquals(HttpStatusCode.Created, status) + assertEquals(mapOf("path" to dirName, "fileName" to fileName) , body()) + } + + client.get("http://localhost:8082/v2/file?path=$dirName&fileName=$fileName") { + bearerAuth(jwt) + contentType(ContentType.Application.Json) + accept(ContentType.Application.Json) + }.apply { + assertEquals(HttpStatusCode.OK, status) + assertEquals(testedFile.readBytes().toList(), body().content.decodeBase64Bytes().toList()) + } + } + } + } + + @Test + fun `Should not be able to use a token once expired`() { + runBlocking { + val dirName = UUID.randomUUID().toString() + val fileName = "file.txt" + val testedFile = this::class.java.classLoader.getResource("input/$fileName")!!.toURI().toPath().toFile() + + val token = getToken(5.seconds)!! + + client.post("http://localhost:8082/v2/file") { + contentType(ContentType.Application.Json) + bearerAuth(token) + setBody( + Base64FileCreationOptions( + path = dirName, + fileName = fileName, + content = testedFile.readBytes().encodeBase64() + ) + ) + }.apply { + assertEquals(HttpStatusCode.Created, status) + assertEquals(mapOf("path" to dirName, "fileName" to fileName) , body()) + } + + delay(6.seconds) + + client.get("http://localhost:8082/v2/file?path=$dirName&fileName=$fileName") { + bearerAuth(token) + }.apply { + assertEquals(HttpStatusCode.Unauthorized, status) + } + } + } + + @Test + fun `Should be able to delete a file`() { + runBlocking { + val dirName = UUID.randomUUID().toString() + val fileName = "file.txt" + val testedFile = this::class.java.classLoader.getResource("input/$fileName")!!.toURI().toPath().toFile() + + val jwt = getToken(60.seconds)!! + + client.post("http://localhost:8082/v2/file") { + contentType(ContentType.Application.Json) + bearerAuth(jwt) + setBody( + Base64FileCreationOptions( + path = dirName, + fileName = fileName, + content = testedFile.readBytes().encodeBase64() + ) + ) + }.apply { + assertEquals(HttpStatusCode.Created, status) + assertEquals(mapOf("path" to dirName, "fileName" to fileName) , body()) + } + + client.delete("http://localhost:8082/v2/file?path=$dirName&fileName=$fileName") { + bearerAuth(jwt) + }.apply { + assertEquals(HttpStatusCode.NoContent, status) + } + + client.get("http://localhost:8082/v2/file?path=$dirName&fileName=$fileName") { + bearerAuth(jwt) + }.apply { + assertEquals(HttpStatusCode.NotFound, status) + } + } + } + + @Test + fun `Should be able to upload in multipart-form data`() { + runBlocking { + val fileNames = mapOf( + "file.txt" to ContentType.Text.Plain, + "file.pdf" to ContentType.Application.Pdf, + "img.webp" to ContentType.Image.Any + ) + + val jwt = getToken(60.seconds)!! + val dirName = "multipart-${UUID.randomUUID()}" + + fileNames.forEach { (fileName, contentType) -> + val testedFile = this::class.java.classLoader.getResource("input/$fileName")!!.toURI().toPath().toFile() + + client.submitFormWithBinaryData("http://localhost:8082/v2/file/upload", formData { + append(key = "path", value = dirName) + append(key = "fileName", value = fileName) + append(key = "content", value = testedFile.readBytes(), headers = Headers.build { + append(HttpHeaders.ContentType, contentType.toString()) + append(HttpHeaders.ContentDisposition, "filename=\"$fileName\"") + }) + }) { + bearerAuth(jwt) + }.apply { + assertEquals(HttpStatusCode.Created, status) + assertEquals(mapOf("path" to dirName, "fileName" to fileName) , body()) + } + + client.get("http://localhost:8082/v2/file?path=$dirName&fileName=$fileName") { + bearerAuth(jwt) + }.apply { + assertEquals(HttpStatusCode.OK, status) + assertEquals(testedFile.readBytes().toList(), body().content.decodeBase64Bytes().toList()) + } + } + } + } + +}