權限控制是企業級應用的核心橫切關注點。傳統在 Controller/Service 中硬編碼 if (hasPermission) 導致代碼分散、難維護。Spring AOP 通過切面將權限校驗與業務邏輯解耦,配合自定義註解實現聲明式權限控制,是成熟架構的標準實踐。本文提供從核心概念到生產級代碼的完整落地指南。
// 權限標識:資源:動作 (如 "order:create", "user:delete")
public class Permission {
private final String resource;
private final String action;
// 構造、equals/hashCode、toString
public static Permission of(String resource, String action) { ... }
public static Permission parse(String expression) { ... } // "order:create"
}
// 用戶上下文:ThreadLocal 存儲當前登錄用戶
public class SecurityContext {
private static final ThreadLocal LOCAL = new ThreadLocal<>();
public static void setPrincipal(UserPrincipal principal) { LOCAL.set(principal); }
public static UserPrincipal getPrincipal() { return LOCAL.get(); }
public static void clear() { LOCAL.remove(); }
}
// 用戶主體接口
public interface UserPrincipal {
Long getUserId();
Set getRoles(); // 角色集合
Set getPermissions(); // 權限集合
default boolean hasPermission(Permission p) { return getPermissions().contains(p); }
default boolean hasRole(String role) { return getRoles().contains(role); }
}
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited // 類註解繼承給方法
public @interface RequiresPermission {
/** 權限表達式,支持 SpEL:#{@permissionEvaluator.hasPermission(#order.id, 'order:update')} */
String[] value() default {};
/** 邏輯關係:AND(默認) 需全部擁有,OR 任一擁有即可 */
Logical logical() default Logical.AND;
/** 拒絕訪問時的處理策略 */
DenyStrategy denyStrategy() default DenyStrategy.THROW_EXCEPTION;
public enum Logical { AND, OR }
public enum DenyStrategy { THROW_EXCEPTION, RETURN_DEFAULT, CUSTOM_HANDLER }
}
public class AccessDeniedException extends RuntimeException {
private final Permission requiredPermission;
public AccessDeniedException(String message, Permission requiredPermission) {
super(message);
this.requiredPermission = requiredPermission;
}
}
// 全局異常處理器
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(AccessDeniedException.class)
public R handleAccessDenied(AccessDeniedException e) {
log.warn("權限不足: user={}, required={}", SecurityContext.getPrincipal().getUserId(), e.getRequiredPermission());
return R.fail(403, "權限不足: " + e.getMessage());
}
}
@Aspect
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 10) // 優先於事務切面執行
@Slf4j
public class PermissionAspect {
private final PermissionEvaluator permissionEvaluator; // SpEL 權限評估器
public PermissionAspect(PermissionEvaluator permissionEvaluator) {
this.permissionEvaluator = permissionEvaluator;
}
/**
* 切點:所有標註 @RequiresPermission 的方法(類或方法級別)
* 支持類級別註解作為默認,方法級別覆蓋/追加
*/
@Pointcut("@annotation(com.example.security.RequiresPermission) " +
"|| @within(com.example.security.RequiresPermission)")
public void permissionCheckPointcut() {}
@Around("permissionCheckPointcut()")
public Object checkPermission(ProceedingJoinPoint pjp) throws Throwable {
// 1. 獲取註解(方法優先,其次類)
RequiresPermission methodAnn = getAnnotation(pjp, RequiresPermission.class);
RequiresPermission classAnn = getClassAnnotation(pjp, RequiresPermission.class);
// 合併註解屬性(方法覆蓋類)
RequiresPermission effectiveAnn = mergeAnnotations(classAnn, methodAnn);
if (effectiveAnn == null || effectiveAnn.value().length == 0) {
return pjp.proceed(); // 無權限要求,直接放行
}
// 2. 獲取當前用戶
UserPrincipal principal = SecurityContext.getPrincipal();
if (principal == null) {
throw new AccessDeniedException("未登錄或會話失效", null);
}
// 3. 解析權限表達式(支持 SpEL 動態參數)
String[] expressions = effectiveAnn.value();
List required = new ArrayList<>(expressions.length);
for (String expr : expressions) {
Permission perm = resolvePermissionExpression(expr, pjp);
if (perm != null) required.add(perm);
}
// 4. 校驗權限
boolean hasPermission = evaluatePermissions(principal, required, effectiveAnn.logical());
if (!hasPermission) {
Permission denied = required.stream()
.filter(p -> !principal.hasPermission(p))
.findFirst().orElse(required.get(0));
throw new AccessDeniedException("缺少權限: " + denied, denied);
}
// 5. 校驗通過,執行目標方法
return pjp.proceed();
}
/** 解析 SpEL 表達式:#{@bean.method(#arg)} 或 靜態 "resource:action" */
private Permission resolvePermissionExpression(String expr, ProceedingJoinPoint pjp) {
if (expr.startsWith("#{") && expr.endsWith("}")) {
// SpEL 表達式:#{@permissionService.check(#order.id, 'order:update')}
String spel = expr.substring(2, expr.length() - 1);
StandardEvaluationContext context = new StandardEvaluationContext();
// 綁定方法參數為 SpEL 變量
String[] paramNames = getParameterNames(pjp);
Object[] args = pjp.getArgs();
for (int i = 0; i < paramNames.length; i++) {
context.setVariable(paramNames[i], args[i]);
}
// 註冊 Spring Bean 解析器
context.setBeanResolver(new BeanFactoryResolver(applicationContext));
return new SpelExpressionParser().parseExpression(spel).getValue(context, Permission.class);
}
return Permission.parse(expr); // 靜態權限字符串
}
private boolean evaluatePermissions(UserPrincipal principal, List required, RequiresPermission.Logical logical) {
if (logical == RequiresPermission.Logical.OR) {
return required.stream().anyMatch(principal::hasPermission);
}
return required.stream().allMatch(principal::hasPermission); // AND
}
// 輔助方法:獲取方法/類註解、合併屬性、獲取參數名(需編譯參數 -parameters 或用 LocalVariableTableParameterNameDiscoverer)
}
@Service
public class OrderService {
// 動態權限:只有訂單創建者或管理員可取消
@RequiresPermission("#{@orderPermissionEvaluator.canCancel(#orderId, principal)}")
public void cancelOrder(Long orderId) { ... }
}
@Component
public class OrderPermissionEvaluator {
public boolean canCancel(Long orderId, UserPrincipal principal) {
Order order = orderRepo.findById(orderId).orElseThrow();
return order.getUserId().equals(principal.getUserId()) || principal.hasRole("ADMIN");
}
}
@AfterReturning(pointcut = "@annotation(requiresPermission)", returning = "result")
public void checkReturnPermission(JoinPoint jp, RequiresPermission requiresPermission, Object result) {
// 例如:查詢敏感數據後,檢查用戶是否有導出權限
if (result instanceof List && requiresPermission.value().length > 0) {
// 可在此處做數據脫敏或二次校驗
}
}
手寫切面適合輕量級項目,企業級建議接入 Spring Security,復用其完整認證授權體系:
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/login", "/public/**").permitAll()
.anyRequest().authenticated()
.and()
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
}
// 自定義 PermissionEvaluator 接入 SpEL
@Bean
public PermissionEvaluator permissionEvaluator() {
return new CustomPermissionEvaluator();
}
}
public class CustomPermissionEvaluator implements PermissionEvaluator {
@Override
public boolean hasPermission(Authentication auth, Object targetDomainObject, Object permission) {
UserPrincipal principal = (UserPrincipal) auth.getPrincipal();
return principal.hasPermission(Permission.parse(permission.toString()));
}
@Override
public boolean hasPermission(Authentication auth, Serializable targetId, String targetType, Object permission) {
return hasPermission(auth, null, permission);
}
}
使用 Spring Security 註解:
@PreAuthorize("hasPermission(#orderId, 'order:update')") // SpEL
@PreAuthorize("@orderPermissionEvaluator.canCancel(#orderId)") // Bean 方法
@Secured("ROLE_ADMIN") // 角色
@RolesAllowed({"USER", "ADMIN"}) // JSR-250
public void updateOrder(Long orderId, OrderDTO dto) { ... }
| 場景 | 建議方案 | 關鍵點 |
|---|---|---|
| 靜態權限 (CRUD) | @RequiresPermission("order:create") | 簡單、可讀、易審計 |
| 動態權限 (資源所有者) | @PreAuthorize("@evaluator.check(#id, principal)") | SpEL 表達式、Bean 方法、參數綁定 |
| 方法級覆蓋類級 | 註解合併邏輯:方法 value 替換類 value,logical/denyStrategy 方法優先 | 切面中正確合併屬性 |
| 異步/線程池調用 | 手動傳遞 SecurityContext 或用 TransmittableThreadLocal | ThreadLocal 不自動傳遞到子線程 |
| WebFlux 響應式 | ReactiveSecurityContextHolder + Mono.deferContextual | 非阻塞上下文傳遞 |
| 性能熱點 | 權限緩存 (Caffeine/Redis)、編譯期 SpEL 解析緩存 | 避免每次請求解析 SpEL、查庫 |
| 測試 | @WithMockUser / @WithUserDetails 單元測試切面 | 模擬不同角色權限 |
權限控制不是「加個 if」,而是領域模型、安全架構、基礎設施的系統工程。掌握 AOP 切面設計、SpEL 動態表達、Spring Security 整合三層能力,才能構建既安全又靈活的授權體系。