P05 | JWT Token 鉴权:AuthInterceptor 完整实现

💰 付费文章 | 第一阶段:环境与架构

Token 鉴权流程

前端发送请求
    │
    ▼
AuthInterceptor(拦截所有请求)
    │
    ├── 白名单接口?(登录、获取公钥等)→ 直接放行
    │
    ├── 请求头有 Token?
    │       ├── 没有 → 返回 401
    │       └── 有 → 校验 Token
    │               ├── Token 无效/过期 → 返回 401
    │               └── Token 有效 → 放行,并把用户信息存入上下文
    │
    ▼
Controller 处理请求

AuthInterceptor 完整代码

@Component
class AuthInterceptor(
    private val redisTemplate: StringRedisTemplate
) : HandlerInterceptor {

    companion object {
        // 不需要 Token 的接口白名单
        private val WHITE_LIST = listOf(
            "/pw/operator/login",
            "/web/user/login",
            "/web/user/wxPhone",
            "/pw/operator/captcha",
            "/plat/publicKey",
            "/develop/**"
        )
        
        // 线程本地存储:当前请求的用户信息
        val currentUser = ThreadLocal<CurrentUser>()
    }

    override fun preHandle(
        request: HttpServletRequest,
        response: HttpServletResponse,
        handler: Any
    ): Boolean {
        val requestPath = request.requestURI.removePrefix(request.contextPath)
        
        // 1. 白名单放行
        if (isWhiteListed(requestPath)) {
            return true
        }
        
        // 2. 获取 Token
        val token = request.getHeader("Authorization")?.removePrefix("Bearer ")
            ?: run {
                writeUnauthorized(response, "请先登录")
                return false
            }
        
        // 3. 从 Redis 校验 Token
        val userJson = redisTemplate.opsForValue().get("token:$token")
            ?: run {
                writeUnauthorized(response, "登录已过期,请重新登录")
                return false
            }
        
        // 4. 解析用户信息,存入 ThreadLocal
        val user = jacksonObjectMapper().readValue(userJson, CurrentUser::class.java)
        currentUser.set(user)
        
        // 5. 刷新 Token 过期时间(滑动过期)
        redisTemplate.expire("token:$token", 7, TimeUnit.DAYS)
        
        return true
    }

    override fun afterCompletion(
        request: HttpServletRequest,
        response: HttpServletResponse,
        handler: Any,
        ex: Exception?
    ) {
        // 请求结束后清理 ThreadLocal,防止内存泄漏
        currentUser.remove()
    }

    private fun isWhiteListed(path: String): Boolean {
        return WHITE_LIST.any { pattern ->
            if (pattern.endsWith("/**")) {
                path.startsWith(pattern.removeSuffix("/**"))
            } else {
                path == pattern
            }
        }
    }

    private fun writeUnauthorized(response: HttpServletResponse, msg: String) {
        response.status = HttpServletResponse.SC_OK  // 统一返回 200,业务码标识错误
        response.contentType = "application/json;charset=UTF-8"
        response.writer.write("""{"code":401,"msg":"$msg","body":null}""")
    }
}

// 当前用户信息
data class CurrentUser(
    val userId: String,
    val userName: String,
    val roleIds: List<String>,
    val tokenType: String  // "operator"(运营员) | "user"(普通用户)
)

在 Controller 中获取当前用户

@RestController
@RequestMapping("/web/favorite")
class FavoriteController(
    private val favoriteService: FavoriteService
) {
    
    @PostMapping("/add")
    fun addFavorite(@RequestBody params: Map<String, Any>): Result<*> {
        // 从 ThreadLocal 获取当前用户
        val currentUser = AuthInterceptor.currentUser.get()
            ?: return Result.error("未登录")
        
        val attractionId = params["attractionId"] as? String
            ?: return Result.error("参数错误")
        
        return Result.ok(favoriteService.add(currentUser.userId, attractionId))
    }
}

注册拦截器

@Configuration
class WebMvcConfig(
    private val authInterceptor: AuthInterceptor
) : WebMvcConfigurer {
    
    override fun addInterceptors(registry: InterceptorRegistry) {
        registry.addInterceptor(authInterceptor)
            .addPathPatterns("/**")  // 拦截所有请求
    }
    
    // 跨域配置(允许前端访问)
    override fun addCorsMappings(registry: CorsRegistry) {
        registry.addMapping("/**")
            .allowedOriginPatterns("*")
            .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
            .allowedHeaders("*")
            .allowCredentials(true)
            .maxAge(3600)
    }
}

登录接口:生成 Token

@PostMapping("/login")
fun login(@RequestBody params: Map<String, Any>): Result<*> {
    val operNo = params["operNo"] as? String ?: return Result.error("用户名不能为空")
    val password = params["password"] as? String ?: return Result.error("密码不能为空")
    
    // 查询用户
    val operator = operatorService.findByOperNo(operNo)
        ?: return Result.error("用户名或密码错误")
    
    // 验证密码(MD5(SHA1(pwd).toUpperCase()))
    val hashedPwd = MD5Util.encode(SHA1Util.encode(password).uppercase())
    if (operator.password != hashedPwd) {
        return Result.error("用户名或密码错误")
    }
    
    // 生成 Token
    val token = UUID.randomUUID().toString().replace("-", "")
    
    // 存入 Redis(7天过期)
    val userInfo = CurrentUser(
        userId = operator.commId!!,
        userName = operator.operName ?: "",
        roleIds = operatorService.getRoleIds(operator.commId!!),
        tokenType = "operator"
    )
    redisTemplate.opsForValue().set(
        "token:$token",
        jacksonObjectMapper().writeValueAsString(userInfo),
        7, TimeUnit.DAYS
    )
    
    return Result.ok(mapOf(
        "token" to token,
        "userInfo" to mapOf(
            "operName" to operator.operName,
            "avatar" to operator.avatar
        )
    ))
}

下一篇

P06 → 统一响应格式:Result 和 PageResult 设计

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐