From acea3bf1e4f2a65c4b2fb4b166d5c2fed47fd668 Mon Sep 17 00:00:00 2001 From: SOR <3272194354@qq.com> Date: Fri, 9 Oct 2026 19:42:34 +0800 Subject: [PATCH] copy from https://github.com/SOR2171/Springboot4-Vue3_Demo --- .gitignore | 1 + README.md | 101 ++++++++-- build.gradle.kts | 10 +- src/main/kotlin/Routing.kt | 27 --- .../io/github/sor2171/backend/Application.kt | 139 +++++++++++++ .../backend/database/AccountRepository.kt | 68 +++++++ .../sor2171/backend/database/Accounts.kt | 15 ++ .../backend/database/DatabaseFactory.kt | 44 +++++ .../sor2171/backend/entity/ApiResponse.kt | 41 ++++ .../sor2171/backend/entity/dto/Account.kt | 14 ++ .../entity/dto/EmailVerificationMsg.kt | 11 ++ .../sor2171/backend/entity/enums/EmailType.kt | 13 ++ .../entity/vo/request/EmailRegisterVO.kt | 27 +++ .../backend/entity/vo/request/LoginVO.kt | 10 + .../entity/vo/request/PasswordResetVO.kt | 23 +++ .../backend/entity/vo/response/AuthorizeVO.kt | 13 ++ .../backend/entity/vo/response/ReloginVO.kt | 11 ++ .../kotlin/io/github/sor2171/backend/main.kt | 7 + .../io/github/sor2171/backend/plugins/CORS.kt | 21 ++ .../sor2171/backend/plugins/FlowLimit.kt | 53 +++++ .../sor2171/backend/plugins/Security.kt | 34 ++++ .../sor2171/backend/plugins/Serialization.kt | 23 +++ .../sor2171/backend/plugins/StatusPages.kt | 36 ++++ .../sor2171/backend/plugins}/Websockets.kt | 4 +- .../sor2171/backend/routes/AuthRoutes.kt | 123 ++++++++++++ .../sor2171/backend/routes/TestRoutes.kt | 23 +++ .../github/sor2171/backend/routes/WsRoutes.kt | 33 ++++ .../sor2171/backend/security/JwtUtils.kt | 92 +++++++++ .../backend/service/AuthorizationService.kt | 187 ++++++++++++++++++ .../sor2171/backend/service/MailService.kt | 64 ++++++ .../sor2171/backend/service/RabbitService.kt | 78 ++++++++ .../sor2171/backend/service/RedisService.kt | 65 ++++++ .../sor2171/backend/service/WsTokenService.kt | 32 +++ .../backend/utils/BigDecimalSerializer.kt | 29 +++ .../io/github/sor2171/backend/utils/Const.kt | 25 +++ .../github/sor2171/backend/utils/DateUtils.kt | 17 ++ .../sor2171/backend/utils/PasswordEncoder.kt | 14 ++ src/main/kotlin/main.kt | 5 - src/main/resources/application-example.yaml | 42 ++++ src/main/resources/application.yaml | 7 - src/main/resources/springboot_vue3.sql | 57 ++++++ src/test/kotlin/ServerTest.kt | 25 ++- 42 files changed, 1587 insertions(+), 77 deletions(-) delete mode 100644 src/main/kotlin/Routing.kt create mode 100644 src/main/kotlin/io/github/sor2171/backend/Application.kt create mode 100644 src/main/kotlin/io/github/sor2171/backend/database/AccountRepository.kt create mode 100644 src/main/kotlin/io/github/sor2171/backend/database/Accounts.kt create mode 100644 src/main/kotlin/io/github/sor2171/backend/database/DatabaseFactory.kt create mode 100644 src/main/kotlin/io/github/sor2171/backend/entity/ApiResponse.kt create mode 100644 src/main/kotlin/io/github/sor2171/backend/entity/dto/Account.kt create mode 100644 src/main/kotlin/io/github/sor2171/backend/entity/dto/EmailVerificationMsg.kt create mode 100644 src/main/kotlin/io/github/sor2171/backend/entity/enums/EmailType.kt create mode 100644 src/main/kotlin/io/github/sor2171/backend/entity/vo/request/EmailRegisterVO.kt create mode 100644 src/main/kotlin/io/github/sor2171/backend/entity/vo/request/LoginVO.kt create mode 100644 src/main/kotlin/io/github/sor2171/backend/entity/vo/request/PasswordResetVO.kt create mode 100644 src/main/kotlin/io/github/sor2171/backend/entity/vo/response/AuthorizeVO.kt create mode 100644 src/main/kotlin/io/github/sor2171/backend/entity/vo/response/ReloginVO.kt create mode 100644 src/main/kotlin/io/github/sor2171/backend/main.kt create mode 100644 src/main/kotlin/io/github/sor2171/backend/plugins/CORS.kt create mode 100644 src/main/kotlin/io/github/sor2171/backend/plugins/FlowLimit.kt create mode 100644 src/main/kotlin/io/github/sor2171/backend/plugins/Security.kt create mode 100644 src/main/kotlin/io/github/sor2171/backend/plugins/Serialization.kt create mode 100644 src/main/kotlin/io/github/sor2171/backend/plugins/StatusPages.kt rename src/main/kotlin/{ => io/github/sor2171/backend/plugins}/Websockets.kt (78%) create mode 100644 src/main/kotlin/io/github/sor2171/backend/routes/AuthRoutes.kt create mode 100644 src/main/kotlin/io/github/sor2171/backend/routes/TestRoutes.kt create mode 100644 src/main/kotlin/io/github/sor2171/backend/routes/WsRoutes.kt create mode 100644 src/main/kotlin/io/github/sor2171/backend/security/JwtUtils.kt create mode 100644 src/main/kotlin/io/github/sor2171/backend/service/AuthorizationService.kt create mode 100644 src/main/kotlin/io/github/sor2171/backend/service/MailService.kt create mode 100644 src/main/kotlin/io/github/sor2171/backend/service/RabbitService.kt create mode 100644 src/main/kotlin/io/github/sor2171/backend/service/RedisService.kt create mode 100644 src/main/kotlin/io/github/sor2171/backend/service/WsTokenService.kt create mode 100644 src/main/kotlin/io/github/sor2171/backend/utils/BigDecimalSerializer.kt create mode 100644 src/main/kotlin/io/github/sor2171/backend/utils/Const.kt create mode 100644 src/main/kotlin/io/github/sor2171/backend/utils/DateUtils.kt create mode 100644 src/main/kotlin/io/github/sor2171/backend/utils/PasswordEncoder.kt delete mode 100644 src/main/kotlin/main.kt create mode 100644 src/main/resources/application-example.yaml delete mode 100644 src/main/resources/application.yaml create mode 100644 src/main/resources/springboot_vue3.sql diff --git a/.gitignore b/.gitignore index 409bacd..9ac1e96 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ local.properties .idea .DS_Store node_modules/ +application.yaml diff --git a/README.md b/README.md index f76ea81..4a2f141 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,93 @@ -# Ktor_Server_Demo +# Ktor Server Demo -This project was created using the [Ktor Project Generator](https://start.ktor.io). +Ktor 3.x 后端项目,采用纯正、原生的 Ktor 方式(Idiomatic Ktor)完全重构实现,与 `Springboot4-Vue3` 项目功能与数据结构对齐,支持 Vue 3 前端无缝对接。 -Here are some useful links to get you started: +--- -* [Ktor Documentation](https://ktor.io/docs/home.html) -* [Ktor GitHub page](https://github.com/ktorio/ktor) -* [Ktor Slack chat](https://app.slack.com/client/T09229ZC6/C0A974TJ9). [Request an invite](https://surveys.jetbrains.com/s3/kotlin-slack-sign-up). +## 技术栈 -## Features +- **语言与构建**:Kotlin (`jvmToolchain(21)` / `25`) + Gradle (Kotlin DSL) +- **Web 框架**:Ktor 3.6.0 (Engine: CIO) +- **数据库与 ORM**: + - MySQL 8+ (`mysql-connector-j:9.2.0`) + - HikariCP 连接池 (`5.1.0`) + - JetBrains Exposed ORM (`0.55.0`,包含 Core、JDBC、DAO 与 `exposed-kotlin-datetime`) +- **缓存与频控**:Redis 6+ + Lettuce Client (`6.3.2.RELEASE`) +- **消息队列**:RabbitMQ 3.8+ (`amqp-client:5.34.0`) +- **邮件服务**:Jakarta Mail (`org.eclipse.angus:jakarta.mail:2.0.4`) +- **认证与加密**: + - Auth0 Java-JWT (`4.4.0`) + Ktor Auth JWT (`ktor-server-auth-jwt`) + - BCrypt 密码加密 (`at.favre.lib:bcrypt:0.10.2`,与 Spring Security BCrypt 100% 兼容) +- **序列化与时间**:`kotlinx-serialization-json` + `kotlinx-datetime` +- **实时通信**:Ktor WebSockets (`ktor-server-websockets`) +- **跨域与异常拦截**:Ktor CORS (`ktor-server-cors`) + StatusPages (`ktor-server-status-pages`) -Here's a list of features included in this project: +--- -| Name | Description | -|-----------------------------------------------------------------|----------------------------------------------------------------------| -| [WebSockets](https://start.ktor.io/p/io.ktor/server-websockets) | Adds WebSocket protocol support for bidirectional client connections | +## 运行要求 -## Building & Running +- JDK 21+(推荐 JDK 21 或 JDK 25) +- MySQL 8.0+ +- Redis 6.0+ +- RabbitMQ 3.8+ -To build or run the project, use one of the following tasks: +--- -| Task | Description | -|-------------------|-------------------| -| `./gradlew test` | Run the tests | -| `./gradlew build` | Build the project | -| `./gradlew run` | Run the server | +## 快速启动 -If the server starts successfully, you'll see the following output: +### 1. 初始化数据库 +导入项目自带的 SQL 架构与初始数据: + +```bash +mysql -u backend -p kotlin_springboot < src/main/resources/springboot_vue3.sql ``` -2024-12-04 14:32:45.584 [main] INFO Application - Application started in 0.303 seconds. -2024-12-04 14:32:45.682 [main] INFO Application - Responding at http://0.0.0.0:8080 + +> **默认测试账户**: +> - 用户名:`test` +> - 密码:`123456` +> - 邮箱:`1234567890@gmail.com` + +### 2. 配置应用参数 + +检查并修改 `src/main/resources/application.yaml`(或参考 `application-example.yaml`): + +- **datasource**:数据库连接地址、用户名和密码 +- **redis**:Redis 连接 URL(默认 `redis://localhost:6379`) +- **rabbitmq**:RabbitMQ 连接凭据与队列名(默认队列 `mail`) +- **mail**:SMTP 邮件发送账号与授权码 +- **jwt**:JWT 签名秘钥与过期时间 + +### 3. 构建与运行 + +在项目根目录下执行 Gradle 任务: + +```bash +# 构建项目 +./gradlew build + +# 运行服务器 +./gradlew run ``` + +服务启动成功后,默认监听并在控制台输出: +```text +INFO Application - Responding at http://0.0.0.0:8080 +``` + +--- + +## 接口列表 + +| 方法 | 路径 | 认证方式 | 说明 | +|:-------|:------------------------------------------------|:---------------|:---------------------------------------------| +| `POST` | `/api/v1/auth/login` 或 `/api/auth/login` | 公开 | 用户名/邮箱与密码登录,返回 Token 与过期时间 | +| `GET` | `/api/v1/auth/ask-code` 或 `/api/auth/ask-code` | 公开 | 发送邮箱验证码(`?email=...&type=register`) | +| `POST` | `/api/v1/auth/register` 或 `/api/auth/register` | 公开 | 邮箱注册新账号 | +| `POST` | `/api/v1/auth/reset` 或 `/api/auth/reset` | 公开 | 通过验证码重置密码 | +| `GET` | `/api/v1/auth/logout` 或 `/api/auth/logout` | `Bearer Token` | 注销 Token 并加入 Redis 黑名单 | +| `GET` | `/api/v1/auth/relogin` 或 `/api/auth/relogin` | `Bearer Token` | 刷新并续签 Token | +| `GET` | `/api/test/hello` | 公开 | 连通性测试 | +| `GET` | `/api/test/wstoken` | 公开 | 签发 WebSocket 一次性握手 Token | +| `WS` | `/ws/test` | 公开 | 测试 WebSocket 管道 | +| `WS` | `/ws/v1/example` | `?token=...` | 校验一次性 Token 后建立连接 | diff --git a/build.gradle.kts b/build.gradle.kts index 2851b19..b61ff91 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,5 @@ +@file:Suppress("VulnerableLibrariesLocal") + plugins { alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.serialization) @@ -34,12 +36,12 @@ dependencies { implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.1") - // Security & JWT (对应 Spring Boot Security & java-jwt) + // Security & JWT (Spring Boot Security & java-jwt) implementation("com.auth0:java-jwt:4.4.0") implementation("at.favre.lib:bcrypt:0.10.2") // Database: MySQL / MariaDB & Exposed ORM & HikariCP - implementation("com.mysql:mysql-connector-j:9.2.0") + implementation("com.mysql:mysql-connector-j:26.7.0") implementation("org.mariadb.jdbc:mariadb-java-client:3.5.9") implementation("com.zaxxer:HikariCP:5.1.0") implementation("org.jetbrains.exposed:exposed-core:0.55.0") @@ -47,8 +49,8 @@ dependencies { implementation("org.jetbrains.exposed:exposed-dao:0.55.0") implementation("org.jetbrains.exposed:exposed-kotlin-datetime:0.55.0") - // Mail (对应 Spring Boot Starter Mail) - implementation("org.eclipse.angus:jakarta.mail:2.0.3") + // Mail (Spring Boot Starter Mail) + implementation("org.eclipse.angus:jakarta.mail:2.0.4") // Redis implementation("io.lettuce:lettuce-core:6.3.2.RELEASE") diff --git a/src/main/kotlin/Routing.kt b/src/main/kotlin/Routing.kt deleted file mode 100644 index b81c178..0000000 --- a/src/main/kotlin/Routing.kt +++ /dev/null @@ -1,27 +0,0 @@ -package io.github.sor2171 - -import io.ktor.server.application.* -import io.ktor.server.response.* -import io.ktor.server.routing.* -import io.ktor.server.websocket.* -import io.ktor.websocket.* -import java.time.Duration - -fun Application.configureRouting() { - routing { - get("/") { - call.respondText("Hello, World!") - } - webSocket("/ws") { // websocketSession - for (frame in incoming) { - if (frame is Frame.Text) { - val text = frame.readText() - outgoing.send(Frame.Text("YOU SAID: $text")) - if (text.equals("bye", ignoreCase = true)) { - close(CloseReason(CloseReason.Codes.NORMAL, "Client said BYE")) - } - } - } - } - } -} \ No newline at end of file diff --git a/src/main/kotlin/io/github/sor2171/backend/Application.kt b/src/main/kotlin/io/github/sor2171/backend/Application.kt new file mode 100644 index 0000000..c45c36a --- /dev/null +++ b/src/main/kotlin/io/github/sor2171/backend/Application.kt @@ -0,0 +1,139 @@ +package io.github.sor2171.backend + +import io.github.sor2171.backend.plugins.FlowLimitPlugin +import io.github.sor2171.backend.plugins.configureCORS +import io.github.sor2171.backend.plugins.configureSecurity +import io.github.sor2171.backend.plugins.configureSerialization +import io.github.sor2171.backend.plugins.configureStatusPages +import io.github.sor2171.backend.plugins.configureWebsockets +import io.github.sor2171.backend.service.AuthorizationService +import io.github.sor2171.backend.service.MailService +import io.github.sor2171.backend.service.RabbitService +import io.github.sor2171.backend.service.RedisService +import io.github.sor2171.backend.service.WsTokenService +import io.github.sor2171.backend.database.AccountRepository +import io.github.sor2171.backend.database.DatabaseFactory +import io.github.sor2171.backend.routes.authRoutes +import io.github.sor2171.backend.routes.testRoutes +import io.github.sor2171.backend.routes.wsRoutes +import io.github.sor2171.security.JwtUtils +import io.ktor.server.application.* +import io.ktor.server.config.* +import io.ktor.server.routing.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import org.slf4j.LoggerFactory + +fun Application.module() { + val logger = LoggerFactory.getLogger("Application") + val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + // 1. Initialize Database + try { + DatabaseFactory.init(environment.config) + logger.info("Database initialized successfully.") + } catch (e: Exception) { + logger.error("Failed to initialize database: ${e.message}", e) + } + + // 2. Initialize Repositories + val accountRepository = AccountRepository() + + // 3. Initialize Redis + val redisUrl = environment.config.tryGetString("redis.url") ?: "redis://localhost:6379" + val redisService = try { + RedisService(redisUrl).also { + logger.info("Redis connected successfully.") + } + } catch (e: Exception) { + logger.error("Failed to connect Redis: ${e.message}", e) + RedisService(redisUrl) + } + + // 4. Initialize JWT + val jwtKey = environment.config.tryGetString("jwt.key") + ?: "2837452984690238475628394756203847562038475" + val jwtExpireHours = environment.config.tryGetString("jwt.expire-hours")?.toIntOrNull() ?: 168 + val jwtUtils = JwtUtils(jwtKey, jwtExpireHours, redisService) + + // 5. Initialize Mail + val mailHost = environment.config.tryGetString("mail.host") ?: "smtp.qq.com" + val mailPort = environment.config.tryGetString("mail.port")?.toIntOrNull() ?: 465 + val mailUser = environment.config.tryGetString("mail.username") ?: "" + val mailPass = environment.config.tryGetString("mail.password") ?: "" + val mailService = MailService(mailHost, mailPort, mailUser, mailPass) + + // 6. Initialize RabbitMQ + val rabbitHost = environment.config.tryGetString("rabbitmq.host") ?: "localhost" + val rabbitPort = environment.config.tryGetString("rabbitmq.port")?.toIntOrNull() ?: 5672 + val rabbitVhost = environment.config.tryGetString("rabbitmq.virtual-host") ?: "/" + val rabbitUser = environment.config.tryGetString("rabbitmq.username") ?: "admin" + val rabbitPass = environment.config.tryGetString("rabbitmq.password") ?: "123456" + val rabbitQueue = environment.config.tryGetString("rabbitmq.queue") ?: "mail" + + val rabbitService = try { + RabbitService( + rabbitHost, + rabbitPort, + rabbitVhost, + rabbitUser, + rabbitPass, + rabbitQueue, + mailService, + appScope + ).also { + logger.info("RabbitMQ initialized successfully.") + } + } catch (e: Exception) { + logger.error("Failed to connect RabbitMQ: ${e.message}", e) + RabbitService(rabbitHost, rabbitPort, rabbitVhost, rabbitUser, rabbitPass, rabbitQueue, mailService, appScope) + } + + // 7. Register Graceful Shutdown Hook for AutoCloseable resources + val closeableResources: List = listOf( + rabbitService, + redisService, + DatabaseFactory + ) + + monitor.subscribe(ApplicationStopping) { + logger.info("Application is stopping, releasing resources...") + closeableResources.forEach { resource -> + runCatching { resource.close() } + .onFailure { logger.error("Failed to close resource ${resource::class.simpleName}: ${it.message}", it) } + } + logger.info("All resources released successfully.") + } + + // 8. Initialize Business Services + val wsTokenExpire = environment.config.tryGetString("ws.token-expire-time")?.toLongOrNull() ?: 300L + val wsTokenService = WsTokenService(redisService, wsTokenExpire) + val authorizationService = AuthorizationService(accountRepository, jwtUtils, redisService, rabbitService) + + // 8. Configure Ktor Plugins + configureSerialization() + configureCORS() + configureSecurity(jwtUtils) + configureStatusPages() + configureWebsockets() + + // 9. Configure FlowLimit Plugin + val flowCount = environment.config.tryGetString("flow-limit.count")?.toIntOrNull() ?: 1000 + val flowLimitTime = environment.config.tryGetString("flow-limit.limit-time")?.toIntOrNull() ?: 3 + val flowBanTime = environment.config.tryGetString("flow-limit.ban-time")?.toIntOrNull() ?: 60 + + install(FlowLimitPlugin) { + this.limitCount = flowCount + this.limitTime = flowLimitTime + this.banTime = flowBanTime + this.redisService = redisService + } + + // 10. Configure Routes + routing { + authRoutes(authorizationService) + testRoutes(wsTokenService) + wsRoutes(wsTokenService) + } +} diff --git a/src/main/kotlin/io/github/sor2171/backend/database/AccountRepository.kt b/src/main/kotlin/io/github/sor2171/backend/database/AccountRepository.kt new file mode 100644 index 0000000..ad5a8fb --- /dev/null +++ b/src/main/kotlin/io/github/sor2171/backend/database/AccountRepository.kt @@ -0,0 +1,68 @@ +package io.github.sor2171.backend.database + +import io.github.sor2171.backend.database.DatabaseFactory.dbQuery +import io.github.sor2171.backend.entity.dto.Account +import org.jetbrains.exposed.sql.* + +class AccountRepository { + + private fun toAccount(row: ResultRow): Account = + Account( + id = row[Accounts.id], + username = row[Accounts.username], + password = row[Accounts.password], + email = row[Accounts.email], + role = row[Accounts.role], + registerTime = row[Accounts.registerTime] + ) + + suspend fun findByName(username: String): Account? = dbQuery { + Accounts.selectAll() + .where { Accounts.username eq username } + .map(::toAccount) + .singleOrNull() + } + + suspend fun findByEmail(email: String): Account? = dbQuery { + Accounts.selectAll() + .where { Accounts.email eq email } + .map(::toAccount) + .singleOrNull() + } + + suspend fun findByNameOrEmail(text: String): Account? = dbQuery { + Accounts.selectAll() + .where { (Accounts.username eq text) or (Accounts.email eq text) } + .map(::toAccount) + .singleOrNull() + } + + suspend fun existByName(username: String): Boolean = dbQuery { + Accounts.selectAll() + .where { Accounts.username eq username } + .count() > 0 + } + + suspend fun existByEmail(email: String): Boolean = dbQuery { + Accounts.selectAll() + .where { Accounts.email eq email } + .count() > 0 + } + + suspend fun save(account: Account): Int = dbQuery { + Accounts.insert { + it[username] = account.username + it[password] = account.password + it[email] = account.email + it[role] = account.role + it[registerTime] = account.registerTime + }[Accounts.id] + } + + suspend fun resetPasswordByEmail(email: String, encodedPassword: String): Boolean = dbQuery { + val updatedRows = Accounts.update({ Accounts.email eq email }) { + it[password] = encodedPassword + } + updatedRows > 0 + } +} diff --git a/src/main/kotlin/io/github/sor2171/backend/database/Accounts.kt b/src/main/kotlin/io/github/sor2171/backend/database/Accounts.kt new file mode 100644 index 0000000..e19a680 --- /dev/null +++ b/src/main/kotlin/io/github/sor2171/backend/database/Accounts.kt @@ -0,0 +1,15 @@ +package io.github.sor2171.backend.database + +import org.jetbrains.exposed.sql.Table +import org.jetbrains.exposed.sql.kotlin.datetime.datetime + +object Accounts : Table("account") { + val id = integer("id").autoIncrement() + val username = varchar("username", 255).uniqueIndex("unique_name") + val password = varchar("password", 255) + val email = varchar("email", 255).uniqueIndex("unique_email") + val role = varchar("role", 255) + val registerTime = datetime("register_time").nullable() + + override val primaryKey = PrimaryKey(id) +} diff --git a/src/main/kotlin/io/github/sor2171/backend/database/DatabaseFactory.kt b/src/main/kotlin/io/github/sor2171/backend/database/DatabaseFactory.kt new file mode 100644 index 0000000..5d14943 --- /dev/null +++ b/src/main/kotlin/io/github/sor2171/backend/database/DatabaseFactory.kt @@ -0,0 +1,44 @@ +package io.github.sor2171.backend.database + +import com.zaxxer.hikari.HikariConfig +import com.zaxxer.hikari.HikariDataSource +import io.ktor.server.config.* +import kotlinx.coroutines.Dispatchers +import org.jetbrains.exposed.sql.Database +import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction + +object DatabaseFactory : AutoCloseable { + private var dataSource: HikariDataSource? = null + + fun init(config: ApplicationConfig) { + val url = config.tryGetString("datasource.url") + ?: "jdbc:mysql://localhost:3306/kotlin_springboot?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC" + val driver = config.tryGetString("datasource.driver") + ?: "com.mysql.cj.jdbc.Driver" + val username = config.tryGetString("datasource.username") ?: "backend" + val password = config.tryGetString("datasource.password") ?: "123456" + val maxPoolSize = config.tryGetString("datasource.max-pool-size")?.toIntOrNull() ?: 10 + + val hikariConfig = HikariConfig().apply { + jdbcUrl = url + driverClassName = driver + this.username = username + this.password = password + maximumPoolSize = maxPoolSize + isAutoCommit = false + transactionIsolation = "TRANSACTION_REPEATABLE_READ" + validate() + } + + val ds = HikariDataSource(hikariConfig) + dataSource = ds + Database.connect(ds) + } + + override fun close() { + dataSource?.close() + } + + suspend fun dbQuery(block: suspend () -> T): T = + newSuspendedTransaction(Dispatchers.IO) { block() } +} diff --git a/src/main/kotlin/io/github/sor2171/backend/entity/ApiResponse.kt b/src/main/kotlin/io/github/sor2171/backend/entity/ApiResponse.kt new file mode 100644 index 0000000..ec7e3ea --- /dev/null +++ b/src/main/kotlin/io/github/sor2171/backend/entity/ApiResponse.kt @@ -0,0 +1,41 @@ +package io.github.sor2171.backend.entity + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +@Serializable +data class ApiResponse( + val code: Int, + val data: T, + val message: String +) { + companion object { + fun success(data: T, message: String = "Success"): ApiResponse { + return ApiResponse(200, data, message) + } + + // 针对无 data 返回的情况,统一使用 Any? + fun success(message: String = "Success"): ApiResponse { + return ApiResponse(200, null, message) + } + + fun failure(code: Int = 401, data: T, message: String?): ApiResponse { + return ApiResponse(code, data, message ?: "Failure") + } + + fun unauthenticated(message: String?) = + failure(401, null, message) + + fun forbidden(message: String?) = + failure(403, null, message) + + fun logoutFailed(message: String = ""): ApiResponse { + val formatMessage = if (message.isBlank()) "" else ": $message" + return failure(400, null, "Logout Failed$formatMessage") + } + } +} + +inline fun ApiResponse.toJsonString(): String { + return Json.encodeToString(this) +} diff --git a/src/main/kotlin/io/github/sor2171/backend/entity/dto/Account.kt b/src/main/kotlin/io/github/sor2171/backend/entity/dto/Account.kt new file mode 100644 index 0000000..5763bda --- /dev/null +++ b/src/main/kotlin/io/github/sor2171/backend/entity/dto/Account.kt @@ -0,0 +1,14 @@ +package io.github.sor2171.backend.entity.dto + +import kotlinx.datetime.LocalDateTime +import kotlinx.serialization.Serializable + +@Serializable +data class Account( + val id: Int? = null, + val username: String = "", + val password: String = "", + val email: String = "", + val role: String = "", + val registerTime: LocalDateTime? = null +) diff --git a/src/main/kotlin/io/github/sor2171/backend/entity/dto/EmailVerificationMsg.kt b/src/main/kotlin/io/github/sor2171/backend/entity/dto/EmailVerificationMsg.kt new file mode 100644 index 0000000..eca7294 --- /dev/null +++ b/src/main/kotlin/io/github/sor2171/backend/entity/dto/EmailVerificationMsg.kt @@ -0,0 +1,11 @@ +package io.github.sor2171.backend.entity.dto + +import io.github.sor2171.backend.entity.enums.EmailType +import kotlinx.serialization.Serializable + +@Serializable +data class EmailVerificationMsg( + val email: String, + val type: EmailType, + val code: String +) diff --git a/src/main/kotlin/io/github/sor2171/backend/entity/enums/EmailType.kt b/src/main/kotlin/io/github/sor2171/backend/entity/enums/EmailType.kt new file mode 100644 index 0000000..10bfb7e --- /dev/null +++ b/src/main/kotlin/io/github/sor2171/backend/entity/enums/EmailType.kt @@ -0,0 +1,13 @@ +package io.github.sor2171.backend.entity.enums + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +enum class EmailType { + @SerialName("register") + REGISTER, + + @SerialName("reset") + RESET, +} diff --git a/src/main/kotlin/io/github/sor2171/backend/entity/vo/request/EmailRegisterVO.kt b/src/main/kotlin/io/github/sor2171/backend/entity/vo/request/EmailRegisterVO.kt new file mode 100644 index 0000000..f6ab2e0 --- /dev/null +++ b/src/main/kotlin/io/github/sor2171/backend/entity/vo/request/EmailRegisterVO.kt @@ -0,0 +1,27 @@ +package io.github.sor2171.backend.entity.vo.request + +import kotlinx.serialization.Serializable + +@Serializable +data class EmailRegisterVO( + val email: String = "", + val code: String? = null, + val username: String = "", + val password: String = "", +) { + fun validate(): String? { + if (!email.matches(Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$"))) { + return "Invalid email address format" + } + if (code == null || code.length != 6) { + return "Verification code must be 6 digits" + } + if (username.length !in 4..16 || !username.matches(Regex("^[a-zA-Z0-9\\u4e00-\\u9fa5]+$"))) { + return "Username must be 4-16 characters and contain only letters, numbers, or Chinese characters" + } + if (password.length !in 6..20) { + return "Password must be 6-20 characters long" + } + return null + } +} diff --git a/src/main/kotlin/io/github/sor2171/backend/entity/vo/request/LoginVO.kt b/src/main/kotlin/io/github/sor2171/backend/entity/vo/request/LoginVO.kt new file mode 100644 index 0000000..29b1a85 --- /dev/null +++ b/src/main/kotlin/io/github/sor2171/backend/entity/vo/request/LoginVO.kt @@ -0,0 +1,10 @@ +package io.github.sor2171.backend.entity.vo.request + +import kotlinx.serialization.Serializable + +@Serializable +data class LoginVO( + val username: String = "", + val password: String = "", + val remember: Boolean = false, +) diff --git a/src/main/kotlin/io/github/sor2171/backend/entity/vo/request/PasswordResetVO.kt b/src/main/kotlin/io/github/sor2171/backend/entity/vo/request/PasswordResetVO.kt new file mode 100644 index 0000000..a5a3bb8 --- /dev/null +++ b/src/main/kotlin/io/github/sor2171/backend/entity/vo/request/PasswordResetVO.kt @@ -0,0 +1,23 @@ +package io.github.sor2171.backend.entity.vo.request + +import kotlinx.serialization.Serializable + +@Serializable +data class PasswordResetVO( + val email: String = "", + val code: String? = null, + val password: String = "", +) { + fun validate(): String? { + if (!email.matches(Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$"))) { + return "Invalid email address format" + } + if (code == null || code.length != 6) { + return "Verification code must be 6 digits" + } + if (password.length !in 6..20) { + return "Password must be 6-20 characters long" + } + return null + } +} diff --git a/src/main/kotlin/io/github/sor2171/backend/entity/vo/response/AuthorizeVO.kt b/src/main/kotlin/io/github/sor2171/backend/entity/vo/response/AuthorizeVO.kt new file mode 100644 index 0000000..568a33b --- /dev/null +++ b/src/main/kotlin/io/github/sor2171/backend/entity/vo/response/AuthorizeVO.kt @@ -0,0 +1,13 @@ +package io.github.sor2171.backend.entity.vo.response + +import io.github.sor2171.backend.utils.DateUtils.getCurrentDateTime +import kotlinx.datetime.LocalDateTime +import kotlinx.serialization.Serializable + +@Serializable +data class AuthorizeVO( + val username: String = "", + val role: String = "", + val token: String = "", + val expire: LocalDateTime = getCurrentDateTime(), +) diff --git a/src/main/kotlin/io/github/sor2171/backend/entity/vo/response/ReloginVO.kt b/src/main/kotlin/io/github/sor2171/backend/entity/vo/response/ReloginVO.kt new file mode 100644 index 0000000..0e6501f --- /dev/null +++ b/src/main/kotlin/io/github/sor2171/backend/entity/vo/response/ReloginVO.kt @@ -0,0 +1,11 @@ +package io.github.sor2171.backend.entity.vo.response + +import io.github.sor2171.backend.utils.DateUtils.getCurrentDateTime +import kotlinx.datetime.LocalDateTime +import kotlinx.serialization.Serializable + +@Serializable +data class ReloginVO( + val token: String = "", + val expire: LocalDateTime = getCurrentDateTime(), +) diff --git a/src/main/kotlin/io/github/sor2171/backend/main.kt b/src/main/kotlin/io/github/sor2171/backend/main.kt new file mode 100644 index 0000000..3e7044f --- /dev/null +++ b/src/main/kotlin/io/github/sor2171/backend/main.kt @@ -0,0 +1,7 @@ +package io.github.sor2171.backend + +import io.ktor.server.cio.EngineMain + +fun main(args: Array) { + EngineMain.main(args) +} diff --git a/src/main/kotlin/io/github/sor2171/backend/plugins/CORS.kt b/src/main/kotlin/io/github/sor2171/backend/plugins/CORS.kt new file mode 100644 index 0000000..316f15e --- /dev/null +++ b/src/main/kotlin/io/github/sor2171/backend/plugins/CORS.kt @@ -0,0 +1,21 @@ +package io.github.sor2171.backend.plugins + +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.plugins.cors.routing.* + +fun Application.configureCORS() { + install(CORS) { + anyHost() + allowHeader(HttpHeaders.ContentType) + allowHeader(HttpHeaders.Authorization) + allowMethod(HttpMethod.Options) + allowMethod(HttpMethod.Put) + allowMethod(HttpMethod.Delete) + allowMethod(HttpMethod.Patch) + allowMethod(HttpMethod.Post) + allowMethod(HttpMethod.Get) + allowCredentials = true + allowNonSimpleContentTypes = true + } +} diff --git a/src/main/kotlin/io/github/sor2171/backend/plugins/FlowLimit.kt b/src/main/kotlin/io/github/sor2171/backend/plugins/FlowLimit.kt new file mode 100644 index 0000000..89f6783 --- /dev/null +++ b/src/main/kotlin/io/github/sor2171/backend/plugins/FlowLimit.kt @@ -0,0 +1,53 @@ +package io.github.sor2171.backend.plugins + +import io.github.sor2171.backend.entity.ApiResponse +import io.github.sor2171.backend.service.RedisService +import io.github.sor2171.backend.utils.Const +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.plugins.* +import io.ktor.server.response.* + +class FlowLimitConfiguration { + var limitCount: Int = 1000 + var limitTime: Int = 3 + var banTime: Int = 60 + lateinit var redisService: RedisService +} + +val FlowLimitPlugin = createApplicationPlugin( + name = "FlowLimitPlugin", + createConfiguration = ::FlowLimitConfiguration +) { + val limitCount = pluginConfig.limitCount + val limitTime = pluginConfig.limitTime + val banTime = pluginConfig.banTime + val redisService = pluginConfig.redisService + + onCall { call -> + val ip = call.request.origin.remoteHost + val blockKey = Const.FLOW_LIMIT_BLOCK + ip + val counterKey = Const.FLOW_LIMIT_COUNTER + ip + + val isBlocked = redisService.hasKey(blockKey) + if (isBlocked) { + call.respond( + HttpStatusCode.Forbidden, + ApiResponse.forbidden("Too many requests") + ) + return@onCall + } + + val count = redisService.increment(counterKey) + if (count == 1L) { + redisService.expire(counterKey, limitTime.toLong()) + } else if (count > limitCount) { + redisService.set(blockKey, "1", banTime.toLong()) + call.respond( + HttpStatusCode.Forbidden, + ApiResponse.forbidden("Too many requests") + ) + return@onCall + } + } +} diff --git a/src/main/kotlin/io/github/sor2171/backend/plugins/Security.kt b/src/main/kotlin/io/github/sor2171/backend/plugins/Security.kt new file mode 100644 index 0000000..996f5fe --- /dev/null +++ b/src/main/kotlin/io/github/sor2171/backend/plugins/Security.kt @@ -0,0 +1,34 @@ +package io.github.sor2171.backend.plugins + +import io.github.sor2171.backend.entity.ApiResponse +import io.github.sor2171.security.JwtUtils +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(jwtUtils: JwtUtils) { + install(Authentication) { + jwt("auth-jwt") { + verifier(jwtUtils.jwtVerifier) + validate { credential -> + val uuid = credential.payload.id ?: return@validate null + if (jwtUtils.isInvalidToken(uuid)) { + return@validate null + } + val username = credential.payload.getClaim("username").asString() + if (username.isNullOrBlank()) { + return@validate null + } + JWTPrincipal(credential.payload) + } + challenge { _, _ -> + call.respond( + HttpStatusCode.Unauthorized, + ApiResponse.unauthenticated("Authentication failed or token expired.") + ) + } + } + } +} diff --git a/src/main/kotlin/io/github/sor2171/backend/plugins/Serialization.kt b/src/main/kotlin/io/github/sor2171/backend/plugins/Serialization.kt new file mode 100644 index 0000000..91871d4 --- /dev/null +++ b/src/main/kotlin/io/github/sor2171/backend/plugins/Serialization.kt @@ -0,0 +1,23 @@ +package io.github.sor2171.backend.plugins + +import io.github.sor2171.backend.utils.BigDecimalSerializer +import io.ktor.serialization.kotlinx.json.* +import io.ktor.server.application.* +import io.ktor.server.plugins.contentnegotiation.* +import kotlinx.serialization.json.Json +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.contextual + +fun Application.configureSerialization() { + install(ContentNegotiation) { + json(Json { + serializersModule = SerializersModule { + contextual(BigDecimalSerializer) + } + ignoreUnknownKeys = true + isLenient = true + encodeDefaults = false + prettyPrint = true + }) + } +} diff --git a/src/main/kotlin/io/github/sor2171/backend/plugins/StatusPages.kt b/src/main/kotlin/io/github/sor2171/backend/plugins/StatusPages.kt new file mode 100644 index 0000000..4830854 --- /dev/null +++ b/src/main/kotlin/io/github/sor2171/backend/plugins/StatusPages.kt @@ -0,0 +1,36 @@ +package io.github.sor2171.backend.plugins + +import io.github.sor2171.backend.entity.ApiResponse +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.plugins.* +import io.ktor.server.plugins.statuspages.* +import io.ktor.server.response.* +import org.slf4j.LoggerFactory + +fun Application.configureStatusPages() { + val logger = LoggerFactory.getLogger("StatusPages") + + install(StatusPages) { + exception { call, cause -> + logger.warn("BadRequestException: ${cause.message}") + call.respond( + HttpStatusCode.BadRequest, + ApiResponse.failure(400, null, cause.message ?: "Invalid request format") + ) + } + exception { call, cause -> + logger.error("Unhandled exception: ${cause.message}", cause) + call.respond( + HttpStatusCode.InternalServerError, + ApiResponse.failure(500, null, cause.message ?: "Internal server error") + ) + } + status(HttpStatusCode.NotFound) { call, status -> + call.respond( + status, + ApiResponse.failure(404, null, "Endpoint not found") + ) + } + } +} diff --git a/src/main/kotlin/Websockets.kt b/src/main/kotlin/io/github/sor2171/backend/plugins/Websockets.kt similarity index 78% rename from src/main/kotlin/Websockets.kt rename to src/main/kotlin/io/github/sor2171/backend/plugins/Websockets.kt index 99cbf6c..ee1341e 100644 --- a/src/main/kotlin/Websockets.kt +++ b/src/main/kotlin/io/github/sor2171/backend/plugins/Websockets.kt @@ -1,9 +1,7 @@ -package io.github.sor2171 +package io.github.sor2171.backend.plugins import io.ktor.server.application.* -import io.ktor.server.response.* import io.ktor.server.websocket.* -import io.ktor.websocket.* import kotlin.time.Duration.Companion.seconds fun Application.configureWebsockets() { diff --git a/src/main/kotlin/io/github/sor2171/backend/routes/AuthRoutes.kt b/src/main/kotlin/io/github/sor2171/backend/routes/AuthRoutes.kt new file mode 100644 index 0000000..207de15 --- /dev/null +++ b/src/main/kotlin/io/github/sor2171/backend/routes/AuthRoutes.kt @@ -0,0 +1,123 @@ +package io.github.sor2171.backend.routes + +import io.github.sor2171.backend.entity.ApiResponse +import io.github.sor2171.backend.entity.enums.EmailType +import io.github.sor2171.backend.entity.vo.request.EmailRegisterVO +import io.github.sor2171.backend.entity.vo.request.LoginVO +import io.github.sor2171.backend.entity.vo.request.PasswordResetVO +import io.github.sor2171.backend.service.AuthorizationService +import io.github.sor2171.backend.utils.Const +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.auth.* +import io.ktor.server.plugins.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* + +fun Route.authRoutes(authorizationService: AuthorizationService) { + val handleLogin: suspend (ApplicationCall) -> Unit = { call -> + val (username, password) = try { + if (call.request.contentType().match(ContentType.Application.FormUrlEncoded)) { + val params = call.receiveParameters() + Pair(params["username"].orEmpty(), params["password"].orEmpty()) + } else { + val body = call.receive() + Pair(body.username, body.password) + } + } catch (_: Exception) { + Pair("", "") + } + + if (username.isBlank() || password.isBlank()) { + call.respond( + HttpStatusCode.BadRequest, + ApiResponse.failure(400, null, "Username and password cannot be empty") + ) + } else { + val result = authorizationService.login(username, password) + if (result != null) { + call.respond(ApiResponse.success(result)) + } else { + call.respond( + HttpStatusCode.Unauthorized, + ApiResponse.unauthenticated("Invalid username or password") + ) + } + } + } + + val handleAskCode: suspend (ApplicationCall) -> Unit = { call -> + val email = call.parameters["email"].orEmpty() + val typeStr = call.parameters["type"].orEmpty() + val type = try { + EmailType.valueOf(typeStr.uppercase()) + } catch (_: Exception) { + EmailType.REGISTER + } + val ip = call.request.origin.remoteHost + + if (email.isBlank()) { + call.respond(HttpStatusCode.BadRequest, ApiResponse.failure(400, null, "Email cannot be blank")) + } else { + val msg = authorizationService.askEmailVerifyCode(type, email, ip) + call.respond(Const.messageHandler(msg)) + } + } + + val handleRegister: suspend (ApplicationCall) -> Unit = { call -> + val vo = call.receive() + val msg = authorizationService.registerEmailAccount(vo) + call.respond(Const.messageHandler(msg)) + } + + val handleReset: suspend (ApplicationCall) -> Unit = { call -> + val vo = call.receive() + val msg = authorizationService.resetEmailAccountPassword(vo) + call.respond(Const.messageHandler(msg)) + } + + // Public auth endpoints supporting both /api/v1/auth and /api/auth + route("/api") { + listOf("/v1/auth", "/auth").forEach { prefix -> + route(prefix) { + post("/login") { handleLogin(call) } + get("/ask-code") { handleAskCode(call) } + post("/register") { handleRegister(call) } + post("/reset") { handleReset(call) } + } + } + } + + // Protected auth endpoints + authenticate("auth-jwt") { + route("/api") { + listOf("/v1/auth", "/auth").forEach { prefix -> + route(prefix) { + get("/logout") { + val header = call.request.headers[HttpHeaders.Authorization] + val success = authorizationService.invalidateJwt(header) + if (success) { + call.respond(ApiResponse.success()) + } else { + call.respond(HttpStatusCode.BadRequest, ApiResponse.logoutFailed()) + } + } + + get("/relogin") { + val header = call.request.headers[HttpHeaders.Authorization] + val vo = authorizationService.jwtTokenRelogin(header) + if (vo != null) { + call.respond(ApiResponse.success(vo)) + } else { + call.respond( + HttpStatusCode.Unauthorized, + ApiResponse.failure(401, null, "Please login with password.") + ) + } + } + } + } + } + } +} diff --git a/src/main/kotlin/io/github/sor2171/backend/routes/TestRoutes.kt b/src/main/kotlin/io/github/sor2171/backend/routes/TestRoutes.kt new file mode 100644 index 0000000..058d5bc --- /dev/null +++ b/src/main/kotlin/io/github/sor2171/backend/routes/TestRoutes.kt @@ -0,0 +1,23 @@ +package io.github.sor2171.backend.routes + +import io.github.sor2171.backend.entity.ApiResponse +import io.github.sor2171.backend.service.WsTokenService +import io.ktor.server.response.* +import io.ktor.server.routing.* + +fun Route.testRoutes(wsTokenService: WsTokenService) { + route("/api/test") { + get("/hello") { + call.respondText("Hello Kotlin WebFlux!") + } + + get("/wstoken") { + try { + val token = wsTokenService.registerToken("ExampleHandler") + call.respond(ApiResponse.success(token)) + } catch (e: Exception) { + call.respond(ApiResponse.failure(500, null, e.message)) + } + } + } +} diff --git a/src/main/kotlin/io/github/sor2171/backend/routes/WsRoutes.kt b/src/main/kotlin/io/github/sor2171/backend/routes/WsRoutes.kt new file mode 100644 index 0000000..165154e --- /dev/null +++ b/src/main/kotlin/io/github/sor2171/backend/routes/WsRoutes.kt @@ -0,0 +1,33 @@ +package io.github.sor2171.backend.routes + +import io.github.sor2171.backend.service.WsTokenService +import io.ktor.server.routing.* +import io.ktor.server.websocket.* +import io.ktor.websocket.* + +fun Route.wsRoutes(wsTokenService: WsTokenService) { + webSocket("/ws/test") { + for (frame in incoming) { + if (frame is Frame.Text) { + val text = frame.readText() + send(Frame.Text("Kotlin springboot4 received: $text")) + } + } + } + + webSocket("/ws/v1/example") { + val token = call.request.queryParameters["token"] + val isValid = wsTokenService.validateToken("ExampleHandler", token) + if (!isValid) { + close(CloseReason(CloseReason.Codes.VIOLATED_POLICY, "Invalid or expired token")) + return@webSocket + } + + for (frame in incoming) { + if (frame is Frame.Text) { + val text = frame.readText() + send(Frame.Text("Kotlin springboot4 received: $text")) + } + } + } +} diff --git a/src/main/kotlin/io/github/sor2171/backend/security/JwtUtils.kt b/src/main/kotlin/io/github/sor2171/backend/security/JwtUtils.kt new file mode 100644 index 0000000..17a4796 --- /dev/null +++ b/src/main/kotlin/io/github/sor2171/backend/security/JwtUtils.kt @@ -0,0 +1,92 @@ +package io.github.sor2171.security + +import com.auth0.jwt.JWT +import com.auth0.jwt.JWTVerifier +import com.auth0.jwt.algorithms.Algorithm +import com.auth0.jwt.interfaces.DecodedJWT +import io.github.sor2171.backend.service.RedisService +import io.github.sor2171.backend.utils.Const +import org.slf4j.LoggerFactory +import java.util.* + +class JwtUtils( + key: String, + private val expireHours: Int, + private val redisService: RedisService +) { + val algorithm: Algorithm = Algorithm.HMAC256(key) + val jwtVerifier: JWTVerifier = JWT.require(algorithm).build() + private val logger = LoggerFactory.getLogger(JwtUtils::class.java) + + suspend fun invalidateJwt(headerToken: String?): Boolean { + val token = convertToToken(headerToken) ?: return false + return try { + val jwt = jwtVerifier.verify(token) + val id = jwt.id ?: return false + deleteToken(id, jwt.expiresAt) + } catch (e: Exception) { + logger.error("Error occurred while invalidating JWT", e) + false + } + } + + private suspend fun deleteToken(uuid: String, expiresAt: Date?): Boolean { + val now = Date() + val expireMillis = if (expiresAt != null) (expiresAt.time - now.time).coerceAtLeast(0) else 0L + val expireSeconds = (expireMillis / 1000).coerceAtLeast(1) + redisService.set( + Const.JWT_BLACK_LIST + uuid, + "deleted", + expireSeconds + ) + return true + } + + suspend fun isInvalidToken(uuid: String): Boolean = + redisService.hasKey(Const.JWT_BLACK_LIST + uuid) + + suspend fun resolveJwt(headerToken: String?): DecodedJWT? { + val token = convertToToken(headerToken) ?: return null + return try { + val decodedJWT = jwtVerifier.verify(token) + val uuid = decodedJWT.id + if (uuid != null && isInvalidToken(uuid)) return null + val expiresAt = decodedJWT.expiresAt + if (expiresAt != null && Date().after(expiresAt)) null + else decodedJWT + } catch (e: Exception) { + logger.error("Error occurred while resolving JWT: ${e.message}") + null + } + } + + fun createJwt( + id: Int, + username: String, + authorities: List = listOf("user") + ): String { + return JWT.create() + .withJWTId(UUID.randomUUID().toString()) + .withClaim("id", id) + .withClaim("username", username) + .withClaim("authorities", authorities) + .withExpiresAt(expiresTime()) + .withIssuedAt(Date()) + .sign(algorithm) + } + + fun toId(jwt: DecodedJWT): Int = jwt.claims["id"]?.asInt() ?: -1 + + fun toUsername(jwt: DecodedJWT): String = jwt.claims["username"]?.asString() ?: "" + + fun expiresTime(): Date { + val cal = Calendar.getInstance() + cal.add(Calendar.HOUR, expireHours) + return cal.time + } + + fun convertToToken(headerToken: String?): String? { + val token = headerToken?.replace("Bearer ", "")?.trim() + return token?.takeIf { it.isNotEmpty() } + } +} diff --git a/src/main/kotlin/io/github/sor2171/backend/service/AuthorizationService.kt b/src/main/kotlin/io/github/sor2171/backend/service/AuthorizationService.kt new file mode 100644 index 0000000..68ef48e --- /dev/null +++ b/src/main/kotlin/io/github/sor2171/backend/service/AuthorizationService.kt @@ -0,0 +1,187 @@ +package io.github.sor2171.backend.service + +import io.github.sor2171.backend.database.AccountRepository +import io.github.sor2171.backend.entity.dto.Account +import io.github.sor2171.backend.entity.dto.EmailVerificationMsg +import io.github.sor2171.backend.entity.enums.EmailType +import io.github.sor2171.backend.entity.vo.request.EmailRegisterVO +import io.github.sor2171.backend.entity.vo.request.PasswordResetVO +import io.github.sor2171.backend.entity.vo.response.AuthorizeVO +import io.github.sor2171.backend.entity.vo.response.ReloginVO +import io.github.sor2171.security.JwtUtils +import io.github.sor2171.backend.utils.Const +import io.github.sor2171.backend.utils.DateUtils +import io.github.sor2171.backend.utils.DateUtils.getCurrentDateTime +import io.github.sor2171.backend.utils.PasswordEncoder +import org.slf4j.LoggerFactory + +class AuthorizationService( + private val accountRepository: AccountRepository, + private val jwtUtils: JwtUtils, + private val redisService: RedisService, + private val rabbitService: RabbitService +) { + private val logger = LoggerFactory.getLogger(AuthorizationService::class.java) + + suspend fun askEmailVerifyCode(type: EmailType, email: String, ip: String): String { + logger.info("IP {} requested verification code for email: {}, type: {}", ip, email, type) + val allowed = redisService.limitOnceCheck(Const.VERIFY_EMAIL_LIMIT + ip, 60) + if (!allowed) { + logger.warn("Verification code request limit exceeded for IP: {}", ip) + return "Request limit exceeded. Please try again later." + } + + val code = (100000..999999).random().toString() + val data = EmailVerificationMsg(email, type, code) + + rabbitService.sendMailMessage(data) + redisService.set( + Const.VERIFY_EMAIL_DATA + email, + code, + 180 // 3 minutes + ) + logger.info("Verification code generated and dispatched for email: {}", email) + return "" + } + + suspend fun registerEmailAccount(vo: EmailRegisterVO): String { + logger.info("Processing registration for username: {}, email: {}", vo.username, vo.email) + val validationErr = vo.validate() + if (validationErr != null) { + logger.warn("Registration validation failed for user {}: {}", vo.username, validationErr) + return validationErr + } + + val (email, code, username, password) = vo + val verifyErr = verifyCode(email, code) + if (verifyErr.isNotBlank()) { + logger.warn("Registration verify code check failed for email {}: {}", email, verifyErr) + return verifyErr + } + + if (accountRepository.existByEmail(email)) { + logger.warn("Registration failed: email {} already exists", email) + return "account with the same email already exists." + } + if (accountRepository.existByName(username)) { + logger.warn("Registration failed: username {} already exists", username) + return "username already exists." + } + + val encodedPassword = PasswordEncoder.encode(password) + val account = Account( + id = null, + username = username, + password = encodedPassword, + email = email, + role = "user", + registerTime = getCurrentDateTime() + ) + + val id = accountRepository.save(account) + if (id > 0) { + redisService.delete(Const.VERIFY_EMAIL_DATA + email) + logger.info("User {} successfully registered with id: {}", username, id) + return "" + } + logger.error("Failed to save user account for {}", username) + return Const.INTERNAL_ERROR_STR + } + + suspend fun resetEmailAccountPassword(vo: PasswordResetVO): String { + logger.info("Processing password reset for email: {}", vo.email) + val validationErr = vo.validate() + if (validationErr != null) { + logger.warn("Password reset validation failed for email {}: {}", vo.email, validationErr) + return validationErr + } + + val (email, code, password) = vo + val verifyErr = verifyCode(email, code) + if (verifyErr.isNotBlank()) { + logger.warn("Password reset verify code check failed for email {}: {}", email, verifyErr) + return verifyErr + } + + val encodedPassword = PasswordEncoder.encode(password) + val updated = accountRepository.resetPasswordByEmail(email, encodedPassword) + return if (updated) { + redisService.delete(Const.VERIFY_EMAIL_DATA + email) + logger.info("Password successfully reset for email: {}", email) + "Password reset successfully" + } else { + logger.warn("Password reset failed: account not found for email {}", email) + "Account not found" + } + } + + suspend fun login(usernameOrEmail: String, password: String): AuthorizeVO? { + logger.info("User login attempt for: {}", usernameOrEmail) + val account = accountRepository.findByNameOrEmail(usernameOrEmail) + if (account == null) { + logger.warn("Login failed: account not found for '{}'", usernameOrEmail) + return null + } + if (!PasswordEncoder.matches(password, account.password)) { + logger.warn("Login failed: password mismatch for user '{}'", account.username) + return null + } + val token = jwtUtils.createJwt( + id = account.id ?: -1, + username = account.username, + authorities = listOf(account.role) + ) + logger.info("User '{}' logged in successfully", account.username) + return AuthorizeVO( + username = account.username, + role = account.role, + token = token, + expire = DateUtils.dateToLocalDateTime(jwtUtils.expiresTime()) + ) + } + + suspend fun jwtTokenRelogin(headerToken: String?): ReloginVO? { + val jwt = jwtUtils.resolveJwt(headerToken) + if (jwt == null) { + logger.warn("Relogin failed: token invalid or expired") + return null + } + val username = jwtUtils.toUsername(jwt) + val account = accountRepository.findByName(username) + if (account == null) { + logger.warn("Relogin failed: account '{}' not found", username) + return null + } + + val newToken = jwtUtils.createJwt( + id = account.id ?: -1, + username = account.username, + authorities = listOf(account.role) + ) + logger.info("Token successfully renewed for user '{}'", username) + return ReloginVO( + token = newToken, + expire = DateUtils.dateToLocalDateTime(jwtUtils.expiresTime()) + ) + } + + suspend fun invalidateJwt(headerToken: String?): Boolean { + logger.info("Invalidating JWT token") + return jwtUtils.invalidateJwt(headerToken) + } + + private suspend fun verifyCode(email: String, receivedCode: String?): String { + val code = redisService.get(Const.VERIFY_EMAIL_DATA + email) + if (code == null) { + logger.warn("Verify code check failed: code not found or expired for email {}", email) + return "verify code has not been sent" + } + + return if (receivedCode == null || code != receivedCode) { + logger.warn("Verify code check failed: code mismatch for email {}", email) + "verify code is wrong." + } else { + "" + } + } +} diff --git a/src/main/kotlin/io/github/sor2171/backend/service/MailService.kt b/src/main/kotlin/io/github/sor2171/backend/service/MailService.kt new file mode 100644 index 0000000..cc284fa --- /dev/null +++ b/src/main/kotlin/io/github/sor2171/backend/service/MailService.kt @@ -0,0 +1,64 @@ +package io.github.sor2171.backend.service + +import io.github.sor2171.backend.entity.enums.EmailType +import jakarta.mail.Authenticator +import jakarta.mail.Message +import jakarta.mail.PasswordAuthentication +import jakarta.mail.Session +import jakarta.mail.Transport +import jakarta.mail.internet.InternetAddress +import jakarta.mail.internet.MimeMessage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.slf4j.LoggerFactory +import java.util.Properties + +class MailService( + private val host: String, + private val port: Int, + private val username: String, + private val password: String +) { + private val logger = LoggerFactory.getLogger(MailService::class.java) + + suspend fun sendVerificationEmail(email: String, type: EmailType, code: String) = withContext(Dispatchers.IO) { + val (subject, content) = when (type) { + EmailType.REGISTER -> Pair( + "Welcome to Our Service", + "Thank you for registering! Your verification code is: $code\nThis code is valid for 3 minutes." + ) + EmailType.RESET -> Pair( + "Password Reset Request", + "You requested a password reset. Your verification code is: $code\nThis code is valid for 3 minutes." + ) + } + + try { + val props = Properties().apply { + put("mail.smtp.auth", "true") + put("mail.smtp.ssl.enable", "true") + put("mail.smtp.host", host) + put("mail.smtp.port", port.toString()) + put("mail.smtp.timeout", "5000") + } + + val session = Session.getInstance(props, object : Authenticator() { + override fun getPasswordAuthentication(): PasswordAuthentication { + return PasswordAuthentication(username, password) + } + }) + + val message = MimeMessage(session).apply { + setFrom(InternetAddress(username)) + setRecipients(Message.RecipientType.TO, InternetAddress.parse(email)) + this.subject = subject + setText(content, "UTF-8") + } + + Transport.send(message) + logger.info("Email sent to $email successfully.") + } catch (e: Exception) { + logger.error("Failed to send email to $email: ${e.message}", e) + } + } +} diff --git a/src/main/kotlin/io/github/sor2171/backend/service/RabbitService.kt b/src/main/kotlin/io/github/sor2171/backend/service/RabbitService.kt new file mode 100644 index 0000000..7a052f0 --- /dev/null +++ b/src/main/kotlin/io/github/sor2171/backend/service/RabbitService.kt @@ -0,0 +1,78 @@ +package io.github.sor2171.backend.service + +import com.rabbitmq.client.AMQP +import com.rabbitmq.client.Channel +import com.rabbitmq.client.Connection +import com.rabbitmq.client.ConnectionFactory +import io.github.sor2171.backend.entity.dto.EmailVerificationMsg +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import org.slf4j.LoggerFactory + +class RabbitService( + host: String, + port: Int, + virtualHost: String = "/", + username: String, + password: String, + private val queueName: String = "mail", + private val mailService: MailService, + private val scope: CoroutineScope +) : AutoCloseable { + private val logger = LoggerFactory.getLogger(RabbitService::class.java) + private val connection: Connection + private val channel: Channel + + init { + val factory = ConnectionFactory().apply { + this.host = host + this.port = port + this.virtualHost = virtualHost + this.username = username + this.password = password + } + connection = factory.newConnection() + channel = connection.createChannel() + channel.queueDeclare(queueName, true, false, false, null) + startConsumer() + } + + fun sendMailMessage(msg: EmailVerificationMsg) { + val json = Json.encodeToString(msg) + val props = AMQP.BasicProperties.Builder() + .contentType("application/json") + .deliveryMode(2) + .build() + channel.basicPublish("", queueName, props, json.toByteArray(Charsets.UTF_8)) + logger.info("Sent email verification message for ${msg.email} to RabbitMQ queue '$queueName'") + } + + private fun startConsumer() { + channel.basicConsume(queueName, false, { _, delivery -> + val body = String(delivery.body, Charsets.UTF_8) + channel.basicAck(delivery.envelope.deliveryTag, false) + scope.launch(Dispatchers.IO) { + try { + val data = Json.decodeFromString(body) + mailService.sendVerificationEmail(data.email, data.type, data.code) + } catch (e: Exception) { + logger.error("Error processing mail queue message: ${e.message}", e) + } + } + }, { consumerTag -> + logger.warn("Consumer $consumerTag cancelled") + }) + logger.info("RabbitMQ listener on queue '$queueName' started.") + } + + override fun close() { + try { + channel.close() + connection.close() + } catch (e: Exception) { + logger.error("Error closing RabbitMQ connection: ${e.message}", e) + } + } +} diff --git a/src/main/kotlin/io/github/sor2171/backend/service/RedisService.kt b/src/main/kotlin/io/github/sor2171/backend/service/RedisService.kt new file mode 100644 index 0000000..7b0d19c --- /dev/null +++ b/src/main/kotlin/io/github/sor2171/backend/service/RedisService.kt @@ -0,0 +1,65 @@ +package io.github.sor2171.backend.service + +import io.lettuce.core.RedisClient +import io.lettuce.core.api.StatefulRedisConnection +import io.lettuce.core.api.sync.RedisCommands +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.slf4j.LoggerFactory + +class RedisService(redisUrl: String = "redis://localhost:6379") : AutoCloseable { + private val logger = LoggerFactory.getLogger(RedisService::class.java) + private val client: RedisClient = RedisClient.create(redisUrl) + private val connection: StatefulRedisConnection = client.connect() + private val commands: RedisCommands = connection.sync() + + suspend fun get(key: String): String? = withContext(Dispatchers.IO) { + commands.get(key) + } + + suspend fun set(key: String, value: String, expireSeconds: Long? = null): Unit = withContext(Dispatchers.IO) { + if (expireSeconds != null && expireSeconds > 0) { + commands.setex(key, expireSeconds, value) + } else { + commands.set(key, value) + } + } + + suspend fun delete(key: String): Boolean = withContext(Dispatchers.IO) { + val deleted = commands.del(key) + deleted != null && deleted > 0 + } + + suspend fun hasKey(key: String): Boolean = withContext(Dispatchers.IO) { + val count = commands.exists(key) + count != null && count > 0 + } + + suspend fun increment(key: String): Long = withContext(Dispatchers.IO) { + commands.incr(key) + } + + suspend fun expire(key: String, seconds: Long): Boolean = withContext(Dispatchers.IO) { + commands.expire(key, seconds) ?: false + } + + suspend fun limitOnceCheck(key: String, blockTimeSeconds: Long): Boolean = withContext(Dispatchers.IO) { + val exists = commands.exists(key) ?: 0 + if (exists > 0) { + false + } else { + commands.setex(key, blockTimeSeconds, "") + true + } + } + + override fun close() { + try { + logger.info("Closing Redis connection and client...") + connection.close() + client.shutdown() + } catch (e: Exception) { + logger.error("Error closing Redis client: ${e.message}", e) + } + } +} diff --git a/src/main/kotlin/io/github/sor2171/backend/service/WsTokenService.kt b/src/main/kotlin/io/github/sor2171/backend/service/WsTokenService.kt new file mode 100644 index 0000000..d3d9be4 --- /dev/null +++ b/src/main/kotlin/io/github/sor2171/backend/service/WsTokenService.kt @@ -0,0 +1,32 @@ +package io.github.sor2171.backend.service + +import io.github.sor2171.backend.utils.Const.WS_REQUEST_TOKEN +import java.util.* + +class WsTokenService( + private val redisService: RedisService, + private val defaultTokenExpireTime: Long = 300 +) { + suspend fun registerToken( + handlerName: String, + expireTime: Long? = null, + ): String { + val token = UUID.randomUUID().toString() + val key = "$WS_REQUEST_TOKEN:$handlerName:$token" + redisService.set( + key, + "1", + expireTime ?: defaultTokenExpireTime + ) + return token + } + + suspend fun validateToken( + handlerName: String, + token: String?, + ): Boolean { + if (token.isNullOrBlank()) return false + val key = "$WS_REQUEST_TOKEN:$handlerName:$token" + return redisService.delete(key) + } +} diff --git a/src/main/kotlin/io/github/sor2171/backend/utils/BigDecimalSerializer.kt b/src/main/kotlin/io/github/sor2171/backend/utils/BigDecimalSerializer.kt new file mode 100644 index 0000000..69f1343 --- /dev/null +++ b/src/main/kotlin/io/github/sor2171/backend/utils/BigDecimalSerializer.kt @@ -0,0 +1,29 @@ +package io.github.sor2171.backend.utils + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonPrimitive +import java.math.BigDecimal + +object BigDecimalSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("BigDecimal", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: BigDecimal) { + encoder.encodeString(value.toPlainString()) + } + + override fun deserialize(decoder: Decoder): BigDecimal { + val element = (decoder as JsonDecoder).decodeJsonElement() + if (element !is JsonPrimitive) { + throw SerializationException("Expected JSON primitive, got $element") + } + return element.content.toBigDecimal() + } +} diff --git a/src/main/kotlin/io/github/sor2171/backend/utils/Const.kt b/src/main/kotlin/io/github/sor2171/backend/utils/Const.kt new file mode 100644 index 0000000..13630e8 --- /dev/null +++ b/src/main/kotlin/io/github/sor2171/backend/utils/Const.kt @@ -0,0 +1,25 @@ +package io.github.sor2171.backend.utils + +import io.github.sor2171.backend.entity.ApiResponse + +object Const { + const val JWT_BLACK_LIST = "jwt:blacklist:" + const val VERIFY_EMAIL_LIMIT = "verify:email:limit:" + const val VERIFY_EMAIL_DATA = "verify:email:data:" + + const val FLOW_LIMIT_COUNTER = "flow:counter:" + const val FLOW_LIMIT_BLOCK = "flow:block:" + + const val WS_REQUEST_TOKEN = "ws:token:" + + const val INTERNAL_ERROR_STR = "something went wrong. Please contact the administrator." + + // for Routes / Controllers + fun messageHandler(wrongMessage: String): ApiResponse { + return if (wrongMessage.isBlank()) { + ApiResponse.success() + } else { + ApiResponse.failure(400, null, wrongMessage) + } + } +} diff --git a/src/main/kotlin/io/github/sor2171/backend/utils/DateUtils.kt b/src/main/kotlin/io/github/sor2171/backend/utils/DateUtils.kt new file mode 100644 index 0000000..58d24e3 --- /dev/null +++ b/src/main/kotlin/io/github/sor2171/backend/utils/DateUtils.kt @@ -0,0 +1,17 @@ +package io.github.sor2171.backend.utils + +import kotlinx.datetime.* +import kotlinx.datetime.TimeZone +import java.util.* + +object DateUtils { + fun getCurrentDateTime(): LocalDateTime = + Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()) + + fun dateToLocalDateTime(date: Date): LocalDateTime { + return date + .toInstant() + .toKotlinInstant() + .toLocalDateTime(TimeZone.currentSystemDefault()) + } +} diff --git a/src/main/kotlin/io/github/sor2171/backend/utils/PasswordEncoder.kt b/src/main/kotlin/io/github/sor2171/backend/utils/PasswordEncoder.kt new file mode 100644 index 0000000..6e73e5d --- /dev/null +++ b/src/main/kotlin/io/github/sor2171/backend/utils/PasswordEncoder.kt @@ -0,0 +1,14 @@ +package io.github.sor2171.backend.utils + +import at.favre.lib.crypto.bcrypt.BCrypt + +object PasswordEncoder { + fun encode(raw: String): String { + return BCrypt.withDefaults().hashToString(10, raw.toCharArray()) + } + + fun matches(raw: String, hashed: String): Boolean { + val result = BCrypt.verifyer().verify(raw.toCharArray(), hashed.toCharArray()) + return result.verified + } +} diff --git a/src/main/kotlin/main.kt b/src/main/kotlin/main.kt deleted file mode 100644 index 7cfde47..0000000 --- a/src/main/kotlin/main.kt +++ /dev/null @@ -1,5 +0,0 @@ -package io.github.sor2171 - -fun main(args: Array) { - io.ktor.server.cio.EngineMain.main(args) -} diff --git a/src/main/resources/application-example.yaml b/src/main/resources/application-example.yaml new file mode 100644 index 0000000..ae14b08 --- /dev/null +++ b/src/main/resources/application-example.yaml @@ -0,0 +1,42 @@ +ktor: + deployment: + port: 8080 + application: + modules: + - io.github.sor2171.ApplicationKt.module + +datasource: + url: "jdbc:mysql://localhost:3306/kotlin_springboot?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC" + username: "backend" + password: "123456" + driver: "com.mysql.cj.jdbc.Driver" + max-pool-size: 10 + +redis: + url: "redis://localhost:6379" + +rabbitmq: + host: "localhost" + port: 5672 + virtual-host: "/" + username: "admin" + password: "123456" + queue: "mail" + +mail: + host: "smtp.xxx.com" + port: 465 + username: "11111@xxx.com" + password: "**********" + +jwt: + key: "11111111111111111111111" + expire-hours: 168 + +flow-limit: + count: 1000 + limit-time: 3 + ban-time: 60 + +ws: + token-expire-time: 300 diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml deleted file mode 100644 index 605459a..0000000 --- a/src/main/resources/application.yaml +++ /dev/null @@ -1,7 +0,0 @@ -ktor: - deployment: - port: 8080 - application: - modules: - - io.github.sor2171.WebsocketsKt.configureWebsockets - - io.github.sor2171.RoutingKt.configureRouting diff --git a/src/main/resources/springboot_vue3.sql b/src/main/resources/springboot_vue3.sql new file mode 100644 index 0000000..1c5110a --- /dev/null +++ b/src/main/resources/springboot_vue3.sql @@ -0,0 +1,57 @@ +-- MySQL dump 10.13 Distrib 8.0.43, for Linux (x86_64) +-- +-- Host: 127.0.0.1 Database: kotlin_springboot +-- ------------------------------------------------------ +-- Server version 8.0.43-0ubuntu0.24.04.2 + +/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; +/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; +/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; +/*!50503 SET NAMES utf8mb4 */; +/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; +/*!40103 SET TIME_ZONE='+00:00' */; +/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; +/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; + +-- +-- Table structure for table `account` +-- + +DROP TABLE IF EXISTS `account`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `account` ( + `id` int NOT NULL AUTO_INCREMENT, + `username` varchar(255) DEFAULT NULL, + `password` varchar(255) DEFAULT NULL, + `email` varchar(255) DEFAULT NULL, + `role` varchar(255) DEFAULT NULL, + `register_time` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `unique_email` (`email`), + UNIQUE KEY `unique_name` (`username`) +) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `account` +-- + +LOCK TABLES `account` WRITE; +/*!40000 ALTER TABLE `account` DISABLE KEYS */; +INSERT INTO `account` VALUES (1,'test','$2a$10$Xqe2W.5zs/I8FAs3cK331e6tyuo4XWTHNhW1oV6bOHUzwMZ2kAPTa','1234567890@gmail.com','user','2025-11-06 15:56:37'); +/*!40000 ALTER TABLE `account` ENABLE KEYS */; +UNLOCK TABLES; +/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; + +/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; +/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; +/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; +/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; +/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; +/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; +/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; + +-- Dump completed on 2025-11-24 17:53:21 diff --git a/src/test/kotlin/ServerTest.kt b/src/test/kotlin/ServerTest.kt index 989abd7..21908c4 100644 --- a/src/test/kotlin/ServerTest.kt +++ b/src/test/kotlin/ServerTest.kt @@ -1,18 +1,23 @@ package io.github.sor2171 -import io.ktor.client.request.get -import io.ktor.http.HttpStatusCode -import io.ktor.server.testing.testApplication +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import io.ktor.server.testing.* import kotlin.test.* class ServerTest { - @Test - fun `test root endpoint`() = testApplication { - // loads default configuration - configure() - // verify server root returns 200 - assertEquals(HttpStatusCode.OK, client.get("/").status) + fun `test basic test route`() = testApplication { + routing { + get("/api/test/hello") { + call.respondText("Hello Kotlin WebFlux!") + } + } + val response = client.get("/api/test/hello") + assertEquals(HttpStatusCode.OK, response.status) + assertEquals("Hello Kotlin WebFlux!", response.bodyAsText()) } - }