diff --git a/src/main/kotlin/com/msksbr/bookmgr/config/JwtPopulateFilter.kt b/src/main/kotlin/com/msksbr/bookmgr/config/JwtPopulateFilter.kt new file mode 100644 index 0000000..c068746 --- /dev/null +++ b/src/main/kotlin/com/msksbr/bookmgr/config/JwtPopulateFilter.kt @@ -0,0 +1,37 @@ +package com.msksbr.bookmgr.config + +import com.msksbr.bookmgr.script.log +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter + +/* +* JWT 用户识别 Filter(可选模式,永不拦截) +* 在 Spring 参数解析之前运行,从 Authorization 头提取 JWT 并写入 request attribute +* 无 token / 格式错误 / 过期均静默放行 +*/ +@Component +class JwtPopulateFilter( + private val jwtUtils: JwtUtils +) : OncePerRequestFilter() { + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain + ) { + val authHeader = request.getHeader("Authorization") + if (authHeader != null && authHeader.startsWith("Bearer ")) { + val token = authHeader.removePrefix("Bearer ") + val claims = jwtUtils.parseToken(token) + if (claims != null) { + request.setAttribute("username", claims.subject) + request.setAttribute("role", claims.get("role", String::class.java)) + log.debug("[JWT] identified: user={}, role={}", claims.subject, claims.get("role", String::class.java)) + } + } + filterChain.doFilter(request, response) + } +} diff --git a/src/main/kotlin/com/msksbr/bookmgr/config/RequireRoleAspect.kt b/src/main/kotlin/com/msksbr/bookmgr/config/RequireRoleAspect.kt index 1d1557c..afad2de 100644 --- a/src/main/kotlin/com/msksbr/bookmgr/config/RequireRoleAspect.kt +++ b/src/main/kotlin/com/msksbr/bookmgr/config/RequireRoleAspect.kt @@ -11,24 +11,19 @@ import org.springframework.web.context.request.RequestContextHolder import org.springframework.web.context.request.ServletRequestAttributes /* -* 角色权限校验切面 -* 拦截 @RequireRole 注解的方法,按角色继承关系校验 -* -* 角色继承链: -* admin → 自动拥有 user 权限(admin IS-A user) -* user → 仅拥有 user 权限 -* 游客 → 无角色,仅能访问无需 @RequireRole 的公开接口 +* 角色权限校验切面,拦截 @RequireRole 注解的方法 * +* 前置条件:JwtPopulateFilter 已将 request attribute(username / role)设置好 * 校验规则: -* @RequireRole("admin") → 仅 admin -* @RequireRole("user") → user 和 admin 均可 +* username 为 null → 401(未登录或 token 无效) +* 角色不匹配 → 403(权限不足) +* 角色继承:admin IS-A user */ @Aspect @Component class RequireRoleAspect( private val ipExtractor: IpExtractor ) { - // 角色权限集:admin 继承 user 的所有权限 private val rolePermissions = mapOf( "admin" to setOf("admin", "user"), "user" to setOf("user") @@ -41,11 +36,17 @@ class RequireRoleAspect( ): Any? { val request = (RequestContextHolder .currentRequestAttributes() as ServletRequestAttributes).request - val username = request.getAttribute("username") as? String ?: "unknown" - val role = request.getAttribute("role") as? String val ip = ipExtractor.getRealIp(request) val path = "${request.method} ${request.requestURI}" + val username = request.getAttribute("username") as? String + val role = request.getAttribute("role") as? String + + if (username == null) { + log.warn("[AUDIT] unauthenticated | ip={} | path={} | required={}", ip, path, requireRole.role) + return Result.unauthorized("Missing or invalid token") + } + val allowedRoles = rolePermissions[role] ?: emptySet() if (requireRole.role !in allowedRoles) { log.warn( diff --git a/src/main/kotlin/com/msksbr/bookmgr/config/WebConfig.kt b/src/main/kotlin/com/msksbr/bookmgr/config/WebConfig.kt deleted file mode 100644 index 76964e8..0000000 --- a/src/main/kotlin/com/msksbr/bookmgr/config/WebConfig.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.msksbr.bookmgr.config - -import com.msksbr.bookmgr.interceptor.JwtAuthInterceptor -import org.springframework.context.annotation.Configuration -import org.springframework.web.servlet.config.annotation.InterceptorRegistry -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer - -/* -* Spring MVC 配置 — 注册 JWT 鉴权拦截器 -* -* 拦截规则: -* /api/ — 所有 /api/ 下的请求都需要校验 JWT -* /api/auth/login — 排除,登录不需要 token -* /api/auth/logout — 排除,登出不校验 token -* /api/dashboard/get-all-books — 排除,游客可浏览图书列表 -*/ -@Configuration -class WebConfig( - private val jwtAuthInterceptor: JwtAuthInterceptor -) : WebMvcConfigurer { - - override fun addInterceptors(registry: InterceptorRegistry) { - registry.addInterceptor(jwtAuthInterceptor) - .addPathPatterns("/api/**") - .excludePathPatterns( - "/api/auth/login", - "/api/auth/logout", - "/api/dashboard/get-all-books" - ) - } -} diff --git a/src/main/kotlin/com/msksbr/bookmgr/controller/DashBoardController.kt b/src/main/kotlin/com/msksbr/bookmgr/controller/DashBoardController.kt index cfb34b7..d0c9905 100644 --- a/src/main/kotlin/com/msksbr/bookmgr/controller/DashBoardController.kt +++ b/src/main/kotlin/com/msksbr/bookmgr/controller/DashBoardController.kt @@ -16,7 +16,7 @@ import org.springframework.web.bind.annotation.RestController * 路径前缀:/api/dashboard * * 权限模型: -* getAllBooks — 无注解 + WebConfig 排除 JWT,游客 / 用户 / admin 均可 +* getAllBooks — 无注解,游客 / 用户 / admin 均可 * getAllBorrowRecords — @RequireRole("admin"),仅管理员 */ @RestController @@ -53,10 +53,10 @@ class DashBoardController( @RequireRole("admin") @GetMapping("/admin/get-all-borrow-records") fun getAllBorrowRecords( - @RequestAttribute username: String, + @RequestAttribute(required = false) username: String?, request: HttpServletRequest ): Result { - log.info("[AdminDashBoard] getAllBorrowRecords: user={}, ip={}", username, ipExtractor.getRealIp(request)) + log.info("[AdminDashBoard] getAllBorrowRecords: user={}, ip={}", username ?: "unknown", ipExtractor.getRealIp(request)) log.info("[AdminDashBoard] user agent: {}", request.getHeader("User-Agent")) return adminDashBoardService.getAllBorrowRecords() } diff --git a/src/main/kotlin/com/msksbr/bookmgr/interceptor/JwtAuthInterceptor.kt b/src/main/kotlin/com/msksbr/bookmgr/interceptor/JwtAuthInterceptor.kt deleted file mode 100644 index 788db1c..0000000 --- a/src/main/kotlin/com/msksbr/bookmgr/interceptor/JwtAuthInterceptor.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.msksbr.bookmgr.interceptor - -import com.msksbr.bookmgr.config.JwtUtils -import com.msksbr.bookmgr.script.log -import jakarta.servlet.http.HttpServletRequest -import jakarta.servlet.http.HttpServletResponse -import org.springframework.http.HttpStatus -import org.springframework.stereotype.Component -import org.springframework.web.servlet.HandlerInterceptor - -/* -* JWT 鉴权拦截器 -* 在每个受保护的 API 请求到达 Controller 之前执行,从 Authorization 头提取并校验 JWT -* -* 校验失败时直接返回 401 JSON 响应(使用 Result.unauthorized 格式),请求不会到达 Controller -* 校验成功后从 token 中提取 username 和 role,写入 request attribute,后续可通过 @RequestAttribute 获取 -* -* 返回体格式: -* {"code":401,"message":"Missing Authorization header"} -* {"code":401,"message":"Invalid token format"} -* {"code":401,"message":"Token invalid or expired"} -*/ -@Component -class JwtAuthInterceptor( - private val jwtUtils: JwtUtils -) : HandlerInterceptor { - - override fun preHandle( - request: HttpServletRequest, - response: HttpServletResponse, - handler: Any - ): Boolean { - // 1. 检查 Authorization 头是否存在 - val authHeader = request.getHeader("Authorization") ?: run { - writeUnauthorized(response, "Missing Authorization header") - return false - } - // 2. 检查前缀是否为 "Bearer " - if (!authHeader.startsWith("Bearer ")) { - writeUnauthorized(response, "Invalid token format") - return false - } - // 3. 解析并验证 token - val token = authHeader.removePrefix("Bearer ") - val claims = jwtUtils.parseToken(token) - if (claims == null) { - writeUnauthorized(response, "Token invalid or expired") - return false - } - // 4. 校验通过,用户信息写入 request attribute - request.setAttribute("username", claims.subject) - request.setAttribute("role", claims.get("role", String::class.java)) - log.debug("[JWT] authenticated: user={}, role={}", claims.subject, claims.get("role", String::class.java)) - return true - } - - /* - * 写入 401 响应,message 字段按 JSON 字符串规范转义 - */ - private fun writeUnauthorized(response: HttpServletResponse, message: String) { - response.status = HttpStatus.UNAUTHORIZED.value() - response.contentType = "application/json;charset=UTF-8" - response.writer.write("""{"code":401,"message":"${message.replace("\\", "\\\\").replace("\"", "\\\"")}"}""") - } -}