文章

Ktor 的 JWT 配置

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"(盐)是一种用于增强密码哈希安全性的方式。当用户设置密码时,系统会生成一个唯一的随机字符串(即盐),并将这个盐与用户的原始密码进行混合(如拼接或使用特定算法处理),然后再通过哈希函数生成最终的哈希值存储在数据库中。

盐的主要作用有两点:

  1. 防止彩虹表攻击:由于每个用户的密码哈希都添加了独一无二的盐,即使两个用户设置了相同的密码,其对应的哈希值也会因为盐的不同而不同,这样攻击者就无法直接利用预先计算好的哈希-密码对应表(即彩虹表)进行破解。
  2. 增加预计算攻击难度:即使攻击者获取了所有加盐后的哈希值,为了破解每一个密码,他们必须对每一个单独的哈希值进行暴力破解或者字典攻击,因为盐的存在使得批量破解变得极其困难。

因此,在现代密码系统中,为用户密码添加盐是提高系统安全性的常见做法。

首先,在 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/requestsdata/responses 两个包中分别新建 AuthRequestAuthResponse 数据类,方便 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。

携 Token 调用需要验证的接口

License:  CC BY 4.0