Ktor 是 JetBrains 开发的一款轻量级的后端框架,下面讲述如何在 Ktor 中配置 JWT。
一、导入依赖
/** Ktor JWT 核心依赖 **/
implementation("io.ktor:ktor-server-auth-jvm")
implementation("io.ktor:ktor-server-auth-jwt-jvm")
/** Ktor 序列化依赖,也可以使用 Gson **/
implementation("io.ktor:ktor-serialization-kotlinx-json-jvm")
/** 用于加密、编码、进制转换等操作 **/
implementation("commons-codec:commons-codec:1.16.0")
二、配置插件
1. 项目结构

在开始之前,首先在 security/token
包中新建 TokenConfig
数据类。
package cc.loac.security.token
data class TokenConfig(
/** 令牌发行者 **/
val issuer: String,
/** 令牌受众 **/
val audience: String,
/** 令牌有效期 **/
val expiresIn: Long,
/** 令牌 **/
val secret: String
)
本项目使用 EngineMain
方式启用服务,application.conf
中的配置如下。
ktor {
deployment {
port = 8090
}
application {
modules = [cc.loac.ApplicationKt.module]
}
}
/** 配置了 JWT 的一些默认设置 **/
jwt {
issuer = "http://0.0.0.0:8090"
domain = "http://0.0.0.0:8090"
audience = "users"
realm = "Nola"
}
2. JWT 插件
在 plugins
包中新建 Authentication.kt
文件,并写入以下内容,完成 JWT 配置。
package cc.loac.plugins
fun Application.configureSecurity(
config: TokenConfig
) {
// 配置应用的安全认证模块
authentication {
// 配置JWT(JSON Web Token)认证方式
jwt {
// 从 application.conf 中读取 realm 并设为当前 JWT 的 realm
realm = this@configureSecurity.environment.config.property("jwt.realm").getString()
// JWT 验证器
verifier(
JWT
// 使用 HMAC256 算法,并设置秘钥
.require(Algorithm.HMAC256(config.secret))
.withAudience(config.audience)
.withIssuer(config.issuer)
.build()
)
// JWT 的有效性验证
validate { credential ->
// 检查 JWT 载荷中的 audience 是否包含 config.audience
if (credential.payload.audience.contains(config.audience)) {
// 如果包含,则创建并返回一个对象,该对象封装了 JWT 的有效载荷信息
JWTPrincipal(credential.payload)
// 否则,返回null表示验证失败
} else null
}
}
}
}
3. 序列化插件
在 plugins
包中新建 Serialization.kt
文件,写入以下代码来配置序列化插件。
/** Serialization.kt **/
fun Application.configureSerialization() {
install(ContentNegotiation) {
json()
}
}
同时在 Application.kt
中启用该插件。
/** Application.kt **/
fun Application.module() {
/** ... **/
configureSerialization()
}
三、Token
在 security/token
包中新建 TokenClaim
数据类,用于存放令牌声明。
package cc.loac.security.token
data class TokenClaim(
val name: String,
val value: String
)
同时,在相同包下新建 TokenService
接口,里面有一个用于生成 Token 的抽象方法。
package cc.loac.security.token
interface TokenService {
fun generate(config: TokenConfig, vararg claims: TokenClaim): String
}
最后,在当前包下新建 JwtTokenService
类,并实现 TokenService
接口。
package cc.loac.security.token
class JwtTokenService: TokenService {
/**
* 生成 JWT 令牌
* @param config 令牌配置
* @param claims Token Claim
*/
override fun generate(config: TokenConfig, vararg claims: TokenClaim): String {
// 初始化一个新的 JWT 实例
var token = JWT.create()
.withAudience(config.audience)
.withIssuer(config.issuer)
.withExpiresAt(Date(System.currentTimeMillis() + config.expiresIn))
// 遍历所有自定义声明,并将它们添加到 JWT 中
claims.forEach { claim ->
token = token.withClaim(claim.name, claim.value)
}
// 使用 HMAC-SHA256 算法以及配置提供的密钥对 JWT 进行签名
return token.sign(Algorithm.HMAC256(config.secret))
}
}
四、盐值与哈希
在计算机科学,特别是在密码学和网络安全领域中,"salt"(盐)是一种用于增强密码哈希安全性的方式。当用户设置密码时,系统会生成一个唯一的随机字符串(即盐),并将这个盐与用户的原始密码进行混合(如拼接或使用特定算法处理),然后再通过哈希函数生成最终的哈希值存储在数据库中。
盐的主要作用有两点:
- 防止彩虹表攻击:由于每个用户的密码哈希都添加了独一无二的盐,即使两个用户设置了相同的密码,其对应的哈希值也会因为盐的不同而不同,这样攻击者就无法直接利用预先计算好的哈希-密码对应表(即彩虹表)进行破解。
- 增加预计算攻击难度:即使攻击者获取了所有加盐后的哈希值,为了破解每一个密码,他们必须对每一个单独的哈希值进行暴力破解或者字典攻击,因为盐的存在使得批量破解变得极其困难。
因此,在现代密码系统中,为用户密码添加盐是提高系统安全性的常见做法。
首先,在 security/hasing
包中新建 SaltedHash
数据类。
package cc.loac.security.hashing
/**
* 盐、哈希数据类
*/
data class SaltedHash(
val hash: String,
val salt: String
)
而后,在当前包新建 HashingService
接口,该接口中有两个抽象方法,分别用于生成加盐哈希,和验证哈希。
package cc.loac.security.hashing
interface HashingService {
fun generatedSaltedHash(value: String, saltLength: Int = 32): SaltedHash
fun verify(value: String, saltedHash: SaltedHash): Boolean
}
最后,在当前包新建 SHA256HashingService
类,并实现 HashingService
接口。
package cc.loac.security.hashing
import org.apache.commons.codec.binary.Hex
import org.apache.commons.codec.digest.DigestUtils
import java.security.SecureRandom
class SHA256HashingService: HashingService {
/**
* 生成加盐哈希
* @param value 待哈希的原始字符串数据
* @param saltLength 盐的字节长度
* @return 返回一个包含哈希值与盐值的 SaltedHash 对象
*/
override fun generatedSaltedHash(value: String, saltLength: Int): SaltedHash {
// 使用 SecureRandom 实例生成指定长度的安全随机盐值
val salt = SecureRandom
// "SHA1PRNG" 是 Java 中的一个伪随机数生成器算法的名称
.getInstance("SHA1PRNG")
.generateSeed(saltLength)
// 将盐值转换为十六进制字符串表示形式
val saltAsHex = Hex.encodeHexString(salt)
// 将盐值与原始数据拼接,并使用 SHA-256 算法计算其哈希值
val hash = DigestUtils.sha256Hex("$saltAsHex$value")
// 创建并返回包含哈希值与盐值的SaltedHash对象
return SaltedHash(
hash = hash,
salt = saltAsHex
)
}
/**
* 验证哈希值是否匹配
* @param value 待验证的原始字符串数据
* @param saltedHash 包含已知哈希值与盐值的 SaltedHash 对象
* @return 如果重新计算的哈希值与给定的哈希值一致,则返回 true,否则返回 false
*/
override fun verify(value: String, saltedHash: SaltedHash): Boolean {
// 将存储的盐值与待验证的数据拼接,再次计算哈希值并与原哈希比较
return DigestUtils.sha256Hex("${saltedHash.salt}$value") == saltedHash.hash
}
}
五、用户数据表
由于本文主要关注 JWT,故这里只简述一下用户表的字段和对用户表操作的方法。
当前项目采用 Exposed 框架作为 ORM,下面的单例类 User 即为用户表。
/**
* 用户表
* @author Loac
* @version 1.0, 2024-01-08
*/
object Users : Table("user") {
/** 用户 ID **/
val id = integer("id").autoIncrement()
/** 用户名 **/
val name = varchar("name", 50)
/** 电子邮箱 **/
val email = varchar("email", 50)
/** 显示名称 **/
val displayName = varchar("display_name", 100)
/** 密码 **/
val password = varchar("password", 100)
/** 盐值,用于加密密码 **/
val salt = varchar("salt", 100)
/** 描述 **/
val description = varchar("description", 1000).nullable()
/** 注册日期 **/
val createDate = datetime("create_date")
/** 头像地址 URL **/
val avatar = varchar("avatar", 100).nullable()
/** 用户角色 [UserRole] **/
val role = enumerationByName<UserRole>("role", 20)
override val primaryKey = PrimaryKey(id)
}
下面的接口展示了对用户表的几个操作。
interface UserDao {
suspend fun allUsers(): List<User>
suspend fun user(id: Int): User?
suspend fun user(name: String): User?
suspend fun addUser(user: User): User?
suspend fun editUser(user: User): Boolean
suspend fun deleteUser(id: Int): Boolean
}
六、后端接口
下面仅展示对应的登录、注册等操作具体操作的代码,不关注 Ktor 的路由配置。
1. 响应与请求
首先在 data/requests
与 data/responses
两个包中分别新建 AuthRequest
和 AuthResponse
数据类,方便 Ktor 将调用接口的参数直接包装为对象,以及返回 Token。
package cc.loac.data.requests
/**
* 验证请求数据类
* 相当于注册或登录请求接受 username 和 password 作为参数
*/
@Serializable
data class AuthRequest(
val username: String,
val password: String
)
package cc.loac.data.responses
@Serializable
data class AuthResponse(
val token: String
)
2. 注册
fun Routing.userRouting(
hashingService: HashingService,
tokenService: TokenService,
tokenConfig: TokenConfig
) {
/** ... **/
route("/user") {
post {
// 判断接口调用方是否正确传参 username 和 password
val request = runCatching {
call.receiveNullable<AuthRequest>()
}.getOrNull() ?: run {
// 参数有误,返回 Code 400 BadRequest
call.respond(HttpStatusCode.BadRequest)
return@post
}
// 对 username 和 password 进行合法性判断
val areFieldsBlank = request.username.isBlank() ||
request.password.isBlank()
val isPwTooShort = request.password.length < 8
if (areFieldsBlank || isPwTooShort) {
call.respond(HttpStatusCode.Conflict)
return@post
}
// 将 password 进行加密操作
val saltHash = hashingService.generatedSaltedHash(request.password)
// 封装用户对象,包含了盐值和经过加密后的密码哈希
val user = User(
name = request.username,
password = saltHash.hash,
salt = saltHash.salt,
email = "admin@loac.cc",
displayName = "Loac",
role = UserRole.SUPER_ADMIN
)
// 往数据库添加用户
val wasAcknowledged = userDao.addUser(user)
if (wasAcknowledged == null) {
call.respond(HttpStatusCode.Conflict)
return@post
}
call.respond(HttpStatusCode.OK)
}
}
}
3. 登录
fun Routing.userRouting(
hashingService: HashingService,
tokenService: TokenService,
tokenConfig: TokenConfig
) {
/** ... **/
route("/user") {
post("/login") {
// 判断接口调用方是否正确传参 username 和 password
val request = runCatching {
call.receiveNullable<AuthRequest>()
}.getOrNull() ?: run {
// 参数有误,返回 Code 400 BadRequest
call.respond(HttpStatusCode.BadRequest)
return@post
}
// 根据 username 检索用户
val user = userDao.user(request.username)
// 用户不存在
if (user == null) {
call.respond(HttpStatusCode.Conflict, "非法用户名或密码")
return@post
}
// 验证密码是否合法
// 将 user.salt 和调用方传来的密码拼接哈希与 user.password 比对
val isValidPassword = hashingService.verify(
value = request.password,
saltedHash = SaltedHash(
salt = user.salt,
hash = user.password
)
)
// 密码错误
if (!isValidPassword) {
call.respond(HttpStatusCode.Conflict, "非法用户名或密码")
return@post
}
// 密码正确,生成 Token 并签名
val token = tokenService.generate(
// 这里是一些 Token 配置,稍后会讲到配置内容
config = tokenConfig,
// 将 user.id 作为一个自定义声明与生成的 Token 绑定
// 方便后面根据 Token 获取到对应的用户
TokenClaim(
name = "userId",
value = user.id.toString()
)
)
// 返回生成的 Token
call.respond(
status = HttpStatusCode.OK,
message = AuthResponse(
token = token
)
)
}
}
}
4. 验证
这里再添加一个接口,用于测试调用方是否已经登录(传来的 Token 是否合法),如果调用方未登录,则响应 Code 401 Unauthorized,否则响应与当前 Token 绑定的用户 ID。
fun Routing.userRouting(...) {
// 进行身份验证(是否携带合法 Token,验证方法看上面 JWT 插件配置)
// 只有经过验证的请求才能访问此路由下的资源
authenticate {
// 没有经过验证的请求,无法访问此接口
get("/secret") {
// 从当前请求上下文中获取已验证用户的主体信息
val principal = call.principal<JWTPrincipal>()
// 检查并提取 JWT 令牌中的 userId 声明,如果存在则转换为 String 类型。
val userId = principal?.getClaim("userId", String::class)
// 如果 userId 存在,则返回一个包含消息的 HTTP 响应,状态码为 200 OK。
call.respond(HttpStatusCode.OK, "Your userId is $userId")
}
}
}
七、启用插件和路由
下面将在 Ktor 服务入口处,启用 JWT 插件和路由。
/** Application.kt **/
package cc.loac
fun main(args: Array<String>): Unit = EngineMain.main(args)
fun Application.module() {
// 初始化数据库
DatabaseSingleton.init()
// 用于生成 Token 令牌
val tokenService = JwtTokenService()
// 令牌配置
val tokenConfig = TokenConfig(
// 此处的 jwt.issuer、jwt.audience 为 application.conf 中的参数,请看上面 二、配置插件
issuer = environment.config.property("jwt.issuer").getString(),
audience = environment.config.property("jwt.audience").getString(),
// Token 过期时间 24 小时
expiresIn = 24 * 1000 * 60 * 60,
// 秘钥通过环境变量的方式传入,例如:JWT_SECRET=test
secret = System.getenv("JWT_SECRET")
)
// 用于生成和验证加盐哈希
val hashingService = SHA256HashingService()
// 配置 JWT 插件
configureSecurity(tokenConfig)
// 配置序列化插件
configureSerialization()
// 配置 Ktor 路由
configureRouting(hashingService, tokenService, tokenConfig)
}
八、测试
1. 注册用户
2. 尝试调用需验证的接口
可以看到,在还没有登录的情况下,访问 /secret
响应了 Code 401 Unauthorized,提示未经授权。
3. 登录
在用户名和密码正确的情况下,Ktor 响应了 Code 200 OK 并返回了 Token。
4. 携 Token 调用需要验证的接口
在 Header 中填写了正确的 Token,调用 /secret
接口成功返回了当前 Token 绑定的用户的用户 ID。