我不是罗大锤我不是罗大锤

我不是罗大锤

我不是罗大锤我不是罗大锤

我不是罗大锤

首页首页
分类分类
标签标签
友情链接友情
日记日记
开发中
博客仍在开发中。
Powered byNola

Ktor后端JWT配置

&笔记#后端#Ktor#Kotlin

允许评论

8 个月前

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/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。

携 Token 调用需要验证的接口

目录
一、导入依赖
二、配置插件
1. 项目结构
2. JWT 插件
3. 序列化插件
三、Token
四、盐值与哈希
五、用户数据表
六、后端接口
1. 响应与请求
2. 注册
3. 登录
4. 验证
七、启用插件和路由
八、测试
1. 注册用户
2. 尝试调用需验证的接口
3. 登录
4. 携 Token 调用需要验证的接口
暂无评论