This commit is contained in:
SOR
2026-10-09 19:42:34 +08:00
parent 2c6198aa1e
commit acea3bf1e4
42 changed files with 1587 additions and 77 deletions
+1
View File
@@ -8,3 +8,4 @@ local.properties
.idea
.DS_Store
node_modules/
application.yaml
+80 -21
View File
@@ -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
View File
@@ -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")
-27
View File
@@ -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,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
}
}
-5
View File
@@ -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
-7
View File
@@ -1,7 +0,0 @@
ktor:
deployment:
port: 8080
application:
modules:
- io.github.sor2171.WebsocketsKt.configureWebsockets
- io.github.sor2171.RoutingKt.configureRouting
+57
View File
@@ -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
+15 -10
View File
@@ -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())
}
}