mirror of
https://github.com/SOR2171/Ktor_Server_Demo.git
synced 2026-10-23 18:41:10 +08:00
This commit is contained in:
@@ -8,3 +8,4 @@ local.properties
|
||||
.idea
|
||||
.DS_Store
|
||||
node_modules/
|
||||
application.yaml
|
||||
|
||||
@@ -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 后建立连接 |
|
||||
|
||||
+6
-4
@@ -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")
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<AutoCloseable> = 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 <T> dbQuery(block: suspend () -> T): T =
|
||||
newSuspendedTransaction(Dispatchers.IO) { block() }
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package io.github.sor2171.backend.entity
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@Serializable
|
||||
data class ApiResponse<T>(
|
||||
val code: Int,
|
||||
val data: T,
|
||||
val message: String
|
||||
) {
|
||||
companion object {
|
||||
fun <T> success(data: T, message: String = "Success"): ApiResponse<T> {
|
||||
return ApiResponse(200, data, message)
|
||||
}
|
||||
|
||||
// 针对无 data 返回的情况,统一使用 Any?
|
||||
fun success(message: String = "Success"): ApiResponse<Any?> {
|
||||
return ApiResponse(200, null, message)
|
||||
}
|
||||
|
||||
fun <T> failure(code: Int = 401, data: T, message: String?): ApiResponse<T> {
|
||||
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<Any?> {
|
||||
val formatMessage = if (message.isBlank()) "" else ": $message"
|
||||
return failure(400, null, "Logout Failed$formatMessage")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <reified T> ApiResponse<T>.toJsonString(): String {
|
||||
return Json.encodeToString(this)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
package io.github.sor2171.backend
|
||||
|
||||
import io.ktor.server.cio.EngineMain
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
EngineMain.main(args)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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<BadRequestException> { call, cause ->
|
||||
logger.warn("BadRequestException: ${cause.message}")
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
ApiResponse.failure(400, null, cause.message ?: "Invalid request format")
|
||||
)
|
||||
}
|
||||
exception<Throwable> { 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")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-3
@@ -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() {
|
||||
@@ -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<LoginVO>()
|
||||
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<EmailRegisterVO>()
|
||||
val msg = authorizationService.registerEmailAccount(vo)
|
||||
call.respond(Const.messageHandler(msg))
|
||||
}
|
||||
|
||||
val handleReset: suspend (ApplicationCall) -> Unit = { call ->
|
||||
val vo = call.receive<PasswordResetVO>()
|
||||
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.")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String> = 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() }
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<EmailVerificationMsg>(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String, String> = client.connect()
|
||||
private val commands: RedisCommands<String, String> = 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<BigDecimal> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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<Any?> {
|
||||
return if (wrongMessage.isBlank()) {
|
||||
ApiResponse.success()
|
||||
} else {
|
||||
ApiResponse.failure(400, null, wrongMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
package io.github.sor2171
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
io.ktor.server.cio.EngineMain.main(args)
|
||||
}
|
||||
@@ -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
|
||||
@@ -1,7 +0,0 @@
|
||||
ktor:
|
||||
deployment:
|
||||
port: 8080
|
||||
application:
|
||||
modules:
|
||||
- io.github.sor2171.WebsocketsKt.configureWebsockets
|
||||
- io.github.sor2171.RoutingKt.configureRouting
|
||||
@@ -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
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user