feat(auth): implement role hierarchy and allow guest book browsing

- Add role inheritance where admin automatically has user permissions
- Update RequireRoleAspect to validate role hierarchy instead of exact match
- Expose /api/dashboard/get-all-books to unauthenticated guests
- Rename AdminDashBoardController to DashBoardController
- Enhance KDoc with role hierarchy rules and access control behavior

Closes: #126
This commit is contained in:
2026-05-22 12:50:53 +08:00
parent 79510b3267
commit 0ccc21288b
4 changed files with 48 additions and 29 deletions
@@ -1,8 +1,14 @@
package com.msksbr.bookmgr.annotation package com.msksbr.bookmgr.annotation
/* /*
* 角色权限注解,贴在 Controller 方法上由 RequireRoleAspect 校验 * 角色权限注解,贴在 Controller 方法上由 RequireRoleAspect 校验
* @param role 要求的角色名,如 "admin"、"user" *
* 角色继承规则(admin IS-A user):
* @RequireRole("admin") → 仅 admin 可访问
* @RequireRole("user") → admin 和 user 均可访问
* 无注解 → 任意已登录用户均可访问(若 WebConfig 排除了 JWT 校验则游客也可)
*
* @param role 要求的角色名
*/ */
@Target(AnnotationTarget.FUNCTION) @Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME) @Retention(AnnotationRetention.RUNTIME)
@@ -12,20 +12,28 @@ import org.springframework.web.context.request.ServletRequestAttributes
/* /*
* 角色权限校验切面 * 角色权限校验切面
* 拦截 @RequireRole 注解的方法,校验 request 中 role 是否匹配 * 拦截 @RequireRole 注解的方法,按角色继承关系校验
* *
* 等保三级审计日志字段 * 角色继承链
* - 用户身份:username * admin → 自动拥有 user 权限(admin IS-A user
* - 来源 IP * user → 仅拥有 user 权限
* - 请求路径:method + URI * 游客 → 无角色,仅能访问无需 @RequireRole 的公开接口
* - 所需角色 / 实际角色 *
* - 操作结果:denied / passed * 校验规则:
* @RequireRole("admin") → 仅 admin
* @RequireRole("user") → user 和 admin 均可
*/ */
@Aspect @Aspect
@Component @Component
class RequireRoleAspect( class RequireRoleAspect(
private val ipExtractor: IpExtractor private val ipExtractor: IpExtractor
) { ) {
// 角色权限集:admin 继承 user 的所有权限
private val rolePermissions = mapOf(
"admin" to setOf("admin", "user"),
"user" to setOf("user")
)
@Around("@annotation(requireRole)") @Around("@annotation(requireRole)")
fun checkRole( fun checkRole(
joinPoint: ProceedingJoinPoint, joinPoint: ProceedingJoinPoint,
@@ -38,7 +46,8 @@ class RequireRoleAspect(
val ip = ipExtractor.getRealIp(request) val ip = ipExtractor.getRealIp(request)
val path = "${request.method} ${request.requestURI}" val path = "${request.method} ${request.requestURI}"
if (role != requireRole.role) { val allowedRoles = rolePermissions[role] ?: emptySet()
if (requireRole.role !in allowedRoles) {
log.warn( log.warn(
"[AUDIT] access denied | user={} | ip={} | path={} | required={} | actual={}", "[AUDIT] access denied | user={} | ip={} | path={} | required={} | actual={}",
username, ip, path, requireRole.role, role username, ip, path, requireRole.role, role
@@ -9,9 +9,10 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
* Spring MVC 配置 — 注册 JWT 鉴权拦截器 * Spring MVC 配置 — 注册 JWT 鉴权拦截器
* *
* 拦截规则: * 拦截规则:
* /api/ — 所有 /api/ 下的请求都需要校验 JWT * /api/ — 所有 /api/ 下的请求都需要校验 JWT
* /api/auth/login — 排除,登录不需要 token(此时还没有 token * /api/auth/login — 排除,登录不需要 token
* /api/auth/logout — 排除,登出不校验 token(客户端自行删除即可) * /api/auth/logout — 排除,登出不校验 token
* /api/dashboard/get-all-books — 排除,游客可浏览图书列表
*/ */
@Configuration @Configuration
class WebConfig( class WebConfig(
@@ -23,7 +24,8 @@ class WebConfig(
.addPathPatterns("/api/**") .addPathPatterns("/api/**")
.excludePathPatterns( .excludePathPatterns(
"/api/auth/login", "/api/auth/login",
"/api/auth/logout" "/api/auth/logout",
"/api/dashboard/get-all-books"
) )
} }
} }
@@ -12,50 +12,52 @@ import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
/* /*
* 管理端仪表盘接口仅限管理员访问 * 仪表盘接口
* 路径前缀/api/admin/dashboard * 路径前缀/api/dashboard
* *
* 所有接口需要 @RequireRole("admin") + JWT 鉴权拦截器双重校验 * 权限模型
* getAllBooks 无注解 + WebConfig 排除 JWT游客 / 用户 / admin 均可
* getAllBorrowRecords @RequireRole("admin")仅管理员
*/ */
@RestController @RestController
@RequestMapping("/api/admin/dashboard") @RequestMapping("/api/dashboard")
class AdminDashBoardController( class DashBoardController(
private val adminDashBoardService: AdminDashBoardService, private val adminDashBoardService: AdminDashBoardService,
private val ipExtractor: IpExtractor, private val ipExtractor: IpExtractor,
) { ) {
/* /*
* GET /api/admin/dashboard/get-all-books * GET /api/dashboard/get-all-books
* 查询全部图书列表 * 查询全部图书列表游客无需登录即可访问
* *
* 成功响应code=200 * 成功响应code=200
* { "code":200, "message":"success", "data":[{"id":1,"name":"三体",...}, ...] } * { "code":200, "message":"success", "data":[{"id":1,"name":"三体",...}, ...] }
*/ */
@RequireRole("admin")
@GetMapping("/get-all-books") @GetMapping("/get-all-books")
fun getAllBooks( fun getAllBooks(
@RequestAttribute username: String, @RequestAttribute(required = false) username: String?,
request: HttpServletRequest request: HttpServletRequest
): Result<Any?> { ): Result<Any?> {
log.info("[AdminDashBoard] getAllBooks: user={}, ip={}", username, ipExtractor.getRealIp(request)) val user = username ?: "guest"
log.debug("[AdminDashBoard] user agent: {}", request.getHeader("User-Agent")) log.info("[DashBoard] getAllBooks: user={}, ip={}", user, ipExtractor.getRealIp(request))
log.info("[DashBoard] user agent: {}", request.getHeader("User-Agent"))
return adminDashBoardService.getAllBooks() return adminDashBoardService.getAllBooks()
} }
/* /*
* GET /api/admin/dashboard/get-all-borrow-records * GET /api/dashboard/admin/get-all-borrow-records
* 查询全借阅记录列表 * 查询全借阅记录仅管理员
* *
* 成功响应code=200 * 成功响应code=200
* { "code":200, "message":"success", "data":[{"id":1,"userId":1,"bookId":2,...}, ...] } * { "code":200, "message":"success", "data":[{"id":1,"userId":1,"bookId":2,...}, ...] }
*/ */
@RequireRole("admin") @RequireRole("admin")
@GetMapping("/get-all-borrow-records") @GetMapping("/admin/get-all-borrow-records")
fun getAllBorrowRecords( fun getAllBorrowRecords(
@RequestAttribute username: String, @RequestAttribute username: String,
request: HttpServletRequest request: HttpServletRequest
): Result<Any?> { ): Result<Any?> {
log.info("[AdminDashBoard] getAllBorrowRecords: user={}, ip={}", username, ipExtractor.getRealIp(request)) log.info("[AdminDashBoard] getAllBorrowRecords: user={}, ip={}", username, ipExtractor.getRealIp(request))
log.debug("[AdminDashBoard] user agent: {}", request.getHeader("User-Agent")) log.info("[AdminDashBoard] user agent: {}", request.getHeader("User-Agent"))
return adminDashBoardService.getAllBorrowRecords() return adminDashBoardService.getAllBorrowRecords()
} }
} }