CC 咖啡猫的工作空间 Coding Space

服务安全之系统鉴权

认证(Authentication)解决“当前主体是谁”,授权(Authorization)判断该主体能否执行某个动作。本文用 Spring Security、Sa-Token 和 Shiro 展示常见实现,并重点说明会话、资源归属和框架版本的边界。


一、核心概念

1.1 认证 vs 授权

概念 说明 问题
认证 验证用户身份 登录时验证用户名密码
授权 验证访问权限 判断用户有没有访问接口的权限(RBAC)

1.2 通用核心组件

组件 说明
认证信息 存放用户身份(用户名、密码、角色)
认证管理器 校验认证信息
密码哈希器 密码哈希与验证
权限决策器 决定是否能访问资源
上下文持有者 持有当前 Authentication 的上下文

1.3 三框架核心定位

框架 核心机制 与 Spring 耦合度
Spring Security 过滤器链(Filter Chain) 高(强关联)
Sa-Token Token 管理 + 注解鉴权 低(独立使用)
Shiro 过滤器链(Filter Chain) 中(需要集成)

二、Spring Security 深入原理

2.1 过滤器链原理

Spring Security 通过 SecurityFilterChain 组合安全过滤器。实际过滤器由启用的登录方式、CSRF、Session、资源服务器等配置决定,不应依赖一张固定的全量顺序表。概念流程如下:

HTTP 请求
    ↓
SecurityFilterChain
    ├── 加载或建立 SecurityContext
    ├── 执行当前登录方式对应的认证过滤器
    ├── 执行 CSRF、响应头、Session 等已启用防护
    ├── AuthorizationFilter 调用 AuthorizationManager
    └── ExceptionTranslationFilter 将认证/授权异常交给入口或拒绝处理器
    ↓
通过 → 业务代码
不通过 → 403 Forbidden / 302 重定向到登录页

2.2 请求鉴权的完整流程

请求 GET /admin/users
    ↓
安全过滤器从 SecurityContext 获取当前 Authentication
    ↓
AuthorizationManager 根据请求规则检查 ADMIN 权限
    ↓
授权失败,抛出 AccessDeniedException
    ↓
ExceptionTranslationFilter 捕获
    ↓
尚未建立有效身份 → AuthenticationEntryPoint 返回 401 或发起登录跳转
已有身份但权限不足 → AccessDeniedHandler 返回 403

实际响应取决于 API、表单登录和 OAuth2 等配置,不能假设所有未登录请求都会返回 302。

2.3 认证流程

UsernamePasswordAuthenticationFilter 处理登录:

登录请求 POST /login,携带 username & password
    ↓
attemptAuthentication(request, response)
    ↓
从 request 获取 username 和 password
    ↓
创建 UsernamePasswordAuthenticationToken(未认证)
    ↓
交给 AuthenticationManager.authenticate(token)
    ↓
认证成功:
      1. SecurityContextHolder.getContext().setAuthentication(Authentication)
      2. 调用 AuthenticationSuccessHandler
    认证失败:
      1. SecurityContext 清空
      2. 调用 AuthenticationFailureHandler

2.4 核心组件详解

AuthenticationManager 认证管理器

// 默认实现是 ProviderManager,内部维护多个 AuthenticationProvider
AuthenticationManager(ProviderManager)
    ↓
DaoAuthenticationProvider → 查数据库(UserDetailsService)
JwtAuthenticationProvider → JWT 验证
OAuth2AuthenticationProvider → OAuth2 认证

UserDetailsService

@Service
public class MyUserDetailsService implements UserDetailsService {

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userMapper.findByUsername(username);
        if (user == null) throw new UsernameNotFoundException("用户不存在");
        // 返回 UserDetails 包含用户名、密码、角色列表
        return User.withUsername(user.getUsername())
            .password(user.getPassword())
            .roles(user.getRoles().toArray(new String[0]))
            .build();
    }
}

PasswordEncoder

@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();  // 推荐:带盐,强度自适应
}

2.5 授权流程

基于配置文件的授权

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/public/**", "/login").permitAll()
                .requestMatchers("/admin/**").hasRole("ADMIN")
                .requestMatchers("/user/**").hasAnyRole("ADMIN", "USER")
                .anyRequest().authenticated()
            );
        return http.build();
    }
}

基于注解的方法级授权

@RestController
public class AdminController {

    // 只有 ADMIN 角色才能访问
    @PreAuthorize("hasRole('ADMIN')")
    @GetMapping("/admin/users")
    public List<User> getUsers() { ... }

    // 表达式:用户名是当前用户本人或者是 ADMIN
    @PreAuthorize("#username == authentication.name or hasRole('ADMIN')")
    @GetMapping("/user/{username}")
    public User getUser(@PathVariable String username) { ... }
}

当前授权核心机制

AuthorizationFilter / 方法安全拦截器
    ↓
AuthorizationManager 根据 Authentication 与受保护对象作出决定
    ↓
允许:继续过滤器链或方法调用
拒绝:抛出 AccessDeniedException,并转换为 403 等响应

较早版本常用 AccessDecisionManager 和 voter 描述授权;在新版本中它们属于旧模型。新项目应从 AuthorizationManager、请求级规则和方法级规则理解,迁移旧项目时再对照 voter 机制。@PreAuthorize 还需要显式启用方法安全,不能只添加注解。

2.6 Session 管理

并发控制

http.sessionManagement(session -> session
    .maximumSessions(1)               // 同一个用户最多一个会话
    .maxSessionsPreventsLogin(false)   // false=踢掉前一个用户,true=禁止新登录
);

Redis Session 共享(多节点部署):

<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>
@Configuration
@EnableRedisHttpSession  // Session 存 Redis
public class SessionConfig {}

2.7 获取当前登录用户

// 方式1:通过 SecurityContext
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String username = auth.getName();

// 方式2:通过 @AuthenticationPrincipal 注解(推荐)
@GetMapping("/me")
public UserDetails me(@AuthenticationPrincipal UserDetails user) {
    return user;
}

// 方式3:Controller 方法参数注入
@GetMapping("/me2")
public User me3(Authentication auth) {
    return userService.findByUsername(auth.getName());
}

三、Sa-Token 深入原理

轻量级 Java 权限认证框架,核心是登录凭证(Token)管理。开箱即用,API 极简,适用场景:不想引入 Spring Security 的复杂性,又需要一个完整的认证授权方案。

3.1 工作原理

登录流程

@PostMapping("/login")
public Result<?> doLogin(String username, String password) {
    // 1. 业务校验:验证用户名密码
    User user = userService.checkPassword(username, password);
    if (user == null) return Result.error("用户名或密码错误");

    // 2. 登录:创建 Token,存入 Redis
    StpUtil.login(user.getId());  // 参数是用户唯一标识
    // 默认 Token 名:satoken,值是一串 UUID
    // 存入 Redis:satoken:uuid → userId,过期时间 30 天

    // 3. 返回 Token 给前端
    return Result.ok().put("token", StpUtil.getTokenValue());
}

验证流程

请求到达
    ↓
SaTokenFilter(拦截器)读取 satoken
    ↓
Redis 查询 Token 是否存在且未过期
    ↓
Token 有效 → 取出 userId → 继续业务代码
    ↓
Token 无效/过期 → 抛出 NotLoginException → 全局异常处理返回 401

前端携带 Token

Header: satoken: <token值>
或
Cookie: satoken=<token值>

3.2 StpLogic 核心接口

public interface StpLogic {
    void login(Object id);           // 登录
    void logout();                   // 登出
    void checkLogin();              // 检查是否已登录
    void checkRole(String... roles); // 检查角色
    void checkPermission(String code); // 检查权限码
    Object getLoginId();             // 获取当前 userId
    TokenInfo getTokenInfo();       // 获取 Token 信息
}

3.3 核心功能

登录与验证

StpUtil.login(userId);              // 登录
StpUtil.checkLogin();               // 验证是否已登录(未登录抛异常)
StpUtil.getLoginIdAsLong();         // 获取当前登录用户 ID
StpUtil.logout();                   // 登出

角色与权限校验

StpUtil.checkRole("admin");                      // 必须有 admin 角色
StpUtil.checkRole("admin", "user");              // 必须拥有所有指定角色
StpUtil.checkRoleOr("admin", "super_admin");    // 拥有任一角色即可
StpUtil.checkPermission("user:add");             // 必须有 user:add 权限码
StpUtil.checkPermissionOr("user:add", "user:export"); // 拥有任一即可

注解鉴权

@SaCheckLogin                     // 必须登录才能访问
@GetMapping("/user/info")
public User getUserInfo() { ... }

@SaCheckRole("admin")              // 必须有 admin 角色
@DeleteMapping("/user/{id}")
public void deleteUser(@PathVariable Long id) { ... }

@SaCheckPermission("user:add")     // 必须有 user:add 权限码
@PostMapping("/user")
public void addUser(@RequestBody User user) { ... }

踢人下线

StpUtil.kickout(userId);           // 让指定用户的 Token 失效(强制下线)
StpUtil.kickoutByToken(tokenValue); // 让指定 Token 失效
StpUtil.getSession().getLoginCount(); // 查询当前在线用户数

3.4 Token 存储模式

Redis 存储(默认)

sa-token:
  token-name: satoken
  timeout: 86400              # Token 有效期(秒),默认 30 天
  token-style: uuid            # Token 生成风格
Key: satoken:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
Value: userId
TTL: 30 天

JWT 模式(无状态)

不依赖 Redis,Token 本身包含用户信息,签发后无法主动撤销:

Header: {"alg": "HS256", "typ": "JWT"}
Payload: {"loginId": 10086, "loginType": "login", "exp": 1735689600}
Signature: HMAC-SHA256(header + "." + payload, secret)
  • JWT 优点:不依赖 Redis,可跨服务验证
  • JWT 缺点:Token 一旦签发无法主动撤销,必须等过期

Cookie 模式

StpUtil.login(userId, "SatokenCookie");  // 指定 Cookie 名写入
StpUtil.getCookie().getValue();           // 获取 Cookie 值

3.5 SSO 单点登录

用户在一个系统登录后,访问另一个系统无需重新登录:

用户登录 app1.com → 获取 Token
    ↓
访问 app2.com → 自动带上 app1.com 的 Token
    ↓
app2.com 验证 Token → 发现是跨域 → 向 app1.com 发起校验
    ↓
app1.com 返回:有效,返回用户信息

SSO 有三种模式:

  • OAuth2 模式:标准单点登录,通过 Ticket 票据交换
  • Header 模式:前端把 Token 一并带走
  • URL 重定向模式:通过 URL 参数传递 Ticket

四、Shiro 深入原理

Apache Shiro 是 Java 领域历史最悠久的权限框架,比 Spring Security 更早出现,核心特点是简单易用、与 Spring 集成灵活。它不依赖 Spring,可以独立使用,也可以无缝集成到 Spring/SpringBoot 项目中。

4.1 核心架构

Shiro 包含三大核心组件:

Subject(主体)     → 当前用户(可以是人、进程、爬虫等)
SecurityManager(安全管理器) → 核心,所有安全操作都经过它
Realm(领域)        → 连接你的数据源(数据库/LDAP),提供认证和授权数据

工作流程

Subject.login(token)
    ↓
SecurityManager.login(subject, token)
    ↓
获取配置的 Realm 进行认证
    ↓
Realm 查询 UserStore 返回 AuthenticationInfo
    ↓
比对密码,认证成功 → 创建 Subject(包含 Principal + Credentials)

4.2 过滤器链原理

Shiro 的请求处理同样基于过滤器链,与 Spring Security 类似:

HTTP 请求
    ↓
Filter Chain(ShiroFilter)
    ↓
①OncePerRequestFilter           → 确保过滤器只执行一次
②BasicHttpAuthenticationFilter  → HTTP Basic 认证
③FormAuthenticationFilter       → 表单登录认证
④UsernamePasswordAuthenticationFilter → 用户名密码认证
⑤AnonymousFilter                → 匿名访问(不放行)
⑥AuthcBasicFilter               → 认证基础过滤
⑦UserFilter                     → "rememberMe" 用户校验
⑧AccessControlFilter            → 权限校验基类
⑨PathMatchingFilter             → 路径匹配
    ↓
通过 → 业务代码
不通过 → 跳转到 loginUrl 或返回 401/403

配置示例(ShiroFilterFactoryBean)

@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean(
        SecurityManager securityManager) {
    ShiroFilterFactoryBean factory = new ShiroFilterFactoryBean();
    factory.setSecurityManager(securityManager);
    factory.setLoginUrl("/login");        // 未登录跳转
    factory.setUnauthorizedUrl("/403");    // 无权限跳转

    Map<String, Filter> filters = factory.getFilters();
    filters.put("authc", new FormAuthenticationFilter());
    filters.put("roles", new RolesAuthorizationFilter());

    // 拦截规则:key = URL 路径,value = 过滤器链名称
    Map<String, String> filterChain = new LinkedHashMap<>();
    filterChain.put("/public/**", "anon");      // anon = 匿名访问
    filterChain.put("/user/**", "authc");       // authc = 需认证
    filterChain.put("/admin/**", "roles[admin]"); // 需 admin 角色
    filterChain.put("/**", "anon");             // 默认
    factory.setFilterChainDefinitionMap(filterChain);

    return factory;
}

4.3 认证流程

UsernamePasswordToken 是认证凭证:

UsernamePasswordToken token = new UsernamePasswordToken(username, password);
token.setRememberMe(true);  // 记住我
subject.login(token);

认证流程

subject.login(token)
    ↓
SecurityManager.getSubject().login(token)
    ↓
DelegatingSubject.login(token)
    ↓
SecurityManager.login(subject, token)
    ↓
获取配置的 Realms(可能有多个)
    ↓
遍历 Realms 执行 doAuthenticate(AuthenticationToken)
    ↓
MyRealm.doGetAuthenticationInfo(token)
    ↓
从数据库查询用户信息,返回 AuthenticationInfo
    ↓
CredentialsMatcher 比对密码(MD5 / BCrypt / 自定义)
    ↓
认证成功:Subject 绑定 AuthenticationInfo
    ↓
认证失败:抛出 AuthenticationException

自定义 Realm 示例

public class MyRealm extends AuthorizingRealm {

    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(
            AuthenticationToken token) throws AuthenticationException {

        UsernamePasswordToken upToken = (UsernamePasswordToken) token;
        String username = upToken.getUsername();

        // 从数据库查询用户
        User user = userMapper.findByUsername(username);
        if (user == null) {
            throw new UnknownAccountException("用户不存在");
        }

        // 返回 AuthenticationInfo(包含密码,会由 CredentialsMatcher 比对)
        return new SimpleAuthenticationInfo(
            user.getUsername(),
            user.getPassword(),
            ByteSource.Util.bytes(user.getSalt()),  // 盐值
            getName()                               // Realm 名称
        );
    }

    // 授权(获取角色和权限)
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(
            PrincipalCollection principals) {
        String username = (String) principals.getPrimaryPrincipal();
        User user = userMapper.findByUsername(username);

        SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
        info.setRoles(user.getRoles());           // 角色集合
        info.setStringPermissions(user.getPermissions()); // 权限码集合
        return info;
    }
}

4.4 授权流程

权限校验的三种方式

// 1. 编程方式
Subject subject = SecurityUtils.getSubject();
if (subject.hasRole("admin")) {
    // 有 admin 角色
}
if (subject.isPermitted("user:delete")) {
    // 有 user:delete 权限
}

// 2. 注解方式(需开启 AOP)
@RequiresRoles("admin")
@GetMapping("/admin/users")
public List<User> getUsers() { ... }

@RequiresPermissions("user:delete")
@DeleteMapping("/user/{id}")
public void deleteUser(@PathVariable Long id) { ... }

@RequiresAuthentication  // 需认证
@RequiresGuest           // 匿名
@RequiresUser           // 已记住或已认证

// 3. JSP/Thymeleaf 标签
<shiro:hasRole name="admin">
    <a href="/admin">管理后台</a>
</shiro:hasRole>

角色继承

// 配置角色继承(admin 继承 user 的权限)
HashMap<String, String> roleHierarchy = new HashMap<>();
roleHierarchy.put("admin", "user guest");  // admin 拥有 user 和 guest 的所有权限

4.5 Session 管理

Shiro 有独立的 Session 管理,不依赖 Servlet 容器:

Subject subject = SecurityUtils.getSubject();
Session session = subject.getSession();

// 常见操作
session.setAttribute("user", currentUser);
session.setAttribute("key", value);
session.setTimeout(30 * 60 * 1000);  // 30 分钟超时

Object value = session.getAttribute("key");
session.removeAttribute("key");

Spring Boot 集成 Redis Session

// 依赖 shiro-redis(需自行实现 SessionDAO)
@Bean
public SessionsSecurityManager securityManager(
        Realm realm,
        SessionManager sessionManager) {
    DefaultWebSecurityManager sm = new DefaultWebSecurityManager();
    sm.setRealm(realm);
    sm.setSessionManager(sessionManager);
    return sm;
}

4.6 与 Spring Security 的核心区别

维度 Shiro Spring Security
架构理念 简单灵活,专注认证授权 功能全面,与 Spring 生态深度绑定
配置复杂度 相对简单 配置项极多,学习曲线陡峭
Session 管理 独立 Session 管理(可脱离容器) 依赖 Servlet Session
OAuth2 / SAML 不支持(需扩展) 原生支持
方法级安全 注解 + AOP @PreAuthorize 等更强大
密码加密 支持 MD5/SHA/BCrypt BCryptPasswordEncoder
适用场景 非 Spring 项目、中小型系统 Spring 全家桶、企业级复杂场景

完整集成示例

// 1. 定义 Realm
@Component
public class MyRealm extends AuthorizingRealm {
    @Autowired
    private UserMapper userMapper;

    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) {
        UsernamePasswordToken upToken = (UsernamePasswordToken) token;
        User user = userMapper.findByUsername(upToken.getUsername());
        if (user == null) throw new UnknownAccountException("用户不存在");
        return new SimpleAuthenticationInfo(
            user.getUsername(),
            user.getPassword(),
            ByteSource.Util.bytes(user.getSalt()),
            getName()
        );
    }

    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        String username = (String) principals.getPrimaryPrincipal();
        User user = userMapper.findByUsername(username);
        SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
        info.setRoles(user.getRoles());
        info.setStringPermissions(user.getPermissions());
        return info;
    }
}

// 2. 配置 SecurityManager
@Bean
public SecurityManager securityManager(Realm realm, SessionManager sessionManager) {
    DefaultWebSecurityManager sm = new DefaultWebSecurityManager();
    sm.setRealm(realm);
    sm.setSessionManager(sessionManager);
    sm.setRememberMeManager(new CookieRememberMeManager());
    return sm;
}

// 3. 配置 ShiroFilterFactoryBean
@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) {
    ShiroFilterFactoryBean factory = new ShiroFilterFactoryBean();
    factory.setSecurityManager(securityManager);
    factory.setLoginUrl("/login");
    factory.setUnauthorizedUrl("/403");

    Map<String, Filter> filters = new HashMap<>();
    filters.put("authc", new FormAuthenticationFilter());
    filters.put("roles", new RolesAuthorizationFilter());
    factory.setFilters(filters);

    Map<String, String> chain = new LinkedHashMap<>();
    chain.put("/public/**", "anon");
    chain.put("/login", "anon");
    chain.put("/user/**", "authc,roles[user]");
    chain.put("/admin/**", "authc,roles[admin]");
    chain.put("/**", "anon");
    factory.setFilterChainDefinitionMap(chain);

    return factory;
}

// 4. 开启 Shiro 注解(需配合 AOP)
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
    AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
    advisor.setSecurityManager(securityManager);
    return advisor;
}

开启注解支持(在 Spring Boot 主类或配置类上添加):

@Bean
public static LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
    return new LifecycleBeanPostProcessor();
}

@Bean
@ConditionalOnMissingBean
public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
    DefaultAdvisorAutoProxyCreator proxy = new DefaultAdvisorAutoProxyCreator();
    proxy.setProxyTargetClass(true);
    return proxy;
}

常用注解说明

注解 说明
@RequiresAuthentication 需要认证才能访问
@RequiresGuest 匿名访问
@RequiresUser 已记住或已认证的用户
@RequiresRoles("admin") 需要指定角色
@RequiresPermissions("user:delete") 需要指定权限码

五、综合对比

5.1 功能维度对比

三者都能完成基础认证和角色/权限判断,真正拉开差异的是协议生态、默认安全策略、扩展方式和团队维护能力。不要用单个勾叉表示“支持 OAuth2、SSO 或 CSRF”:协议中的客户端、授权服务器、资源服务器是不同角色,框架支持范围也会随版本和扩展包变化。

评估维度 要核对的问题
身份协议 只需站内登录,还是需要 OAuth 2.0/OIDC、SAML、企业身份源?
会话模型 服务端 Session、Opaque Token、JWT 分别如何撤销、轮换和过期?
授权粒度 URL、方法、资源归属和租户隔离分别在哪一层执行?
浏览器防护 Cookie、CORS、CSRF、CSP 和登录跳转由谁配置?
审计与运维 登录失败、权限拒绝、密钥轮换和异常会话如何观察与处置?
生态与升级 是否匹配当前 Spring Boot BOM,依赖是否仍维护,安全公告由谁跟进?

5.2 学习曲线对比

学习成本取决于要实现的协议与安全边界,星级或“几分钟上手”无法复现。比较时应让候选框架实现同一组最小用例:登录、退出、过期、撤销、横向越权防护、并发会话和审计日志,再记录代码量、默认行为和失败结果。

5.3 性能与轻量对比

依赖大小、启动时间、内存和 Redis 次数会随模块、配置、JDK、会话模式及业务过滤器变化。若这些指标影响部署,应在相同 JDK、相同依赖范围和相同认证流程下测量冷启动、稳态内存、P95 延迟及外部存储请求数,不使用脱离版本与场景的固定数字。

5.4 适用场景对比

已知条件 优先调查方向 仍需验证
Spring 应用且依赖 OAuth 2.0/OIDC 生态 Spring Security 所需角色、授权服务器选型和迁移成本
非 Spring 应用或已有 Shiro 资产 Shiro 或现有方案升级 当前维护状态、漏洞修复和现代协议扩展
主要是站内 Token、会话和权限码 Sa-Token 等轻量方案 浏览器防护、协议互通、团队运维能力
多租户或行级数据权限 框架认证 + 业务数据约束 资源归属检查不能只靠角色名

5.5 选型决策树

先列身份来源、客户端类型和合规要求
  → 确认需要实现的协议角色
  → 设计 Session / Token 的过期、撤销与轮换
  → 设计 URL、方法、资源归属和租户四层授权
  → 用同一组失败用例验证候选方案
  → 再比较接入、升级和运维成本

5.6 各框架推荐版本

不要在长期笔记中写固定“推荐版本”。Spring 项目优先跟随所用 Spring Boot 版本管理的依赖组合;其他框架在实施时查看官方稳定版、运行环境要求、迁移说明和安全公告,并把实际版本写进项目锁文件或构建清单。


六、常见问题

6.1 Token 过期后如何处理

// Spring Security
@ExceptionHandler(AccessDeniedException.class)
public Result<?> handleAccessDenied(AccessDeniedException e) {
    return Result.error(403, "无权限访问");
}

// Sa-Token
@ExceptionHandler(NotLoginException.class)
public Result<?> handleNotLogin(NotLoginException e) {
    return Result.error(401, "未登录或登录已过期");
}

6.2 前后端分离如何传递 Token

// 前端:放在 Header 中(推荐)
axios.defaults.headers.common['satoken'] = localStorage.getItem('satoken');

// 或放在 Cookie 中(自动携带)
document.cookie = "satoken=" + token;

// Spring Security JWT 方式
axios.defaults.headers.common['Authorization'] = 'Bearer ' + token;

6.3 @PreAuthorize 不生效(Spring Security)

检查是否开启了全局方法安全注解:

@Configuration
@EnableMethodSecurity  // 必须加!否则 @PreAuthorize 不生效
public class SecurityConfig {}

6.4 Sa-Token 无法替代 Spring Security 的场景

  • 需要细粒度行级数据权限控制
  • 需要 OAuth2 资源服务器认证
  • 需要与 Spring Security OAuth2 配合的第三方登录(微信、GitHub)
  • 复杂的企业级安全管理(多租户、敏感操作审计)

6.5 Shiro 注解不生效

检查是否正确配置了 AOP 支持:

// 1. 确保添加了这两个 Bean
@Bean
public static LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
    return new LifecycleBeanPostProcessor();
}

@Bean
public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
    DefaultAdvisorAutoProxyCreator proxy = new DefaultAdvisorAutoProxyCreator();
    proxy.setProxyTargetClass(true);
    return proxy;
}

// 2. 确保添加了 Advisor
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
    return new AuthorizationAttributeSourceAdvisor(securityManager);
}

6.6 Session 与 Token 选择建议

场景 推荐方案 原因
PC 端 Web(需会话保持) Session + Cookie 传统方案,Cookie 自动携带
移动端 APP Token(Header) 无 Cookie,跨平台
微服务架构 Token(Redis 存储) 无状态,易扩展
前后端分离 Token(Header) 跨域,JWT 可自包含
需要 SSO Token 跨域携带方便

七、要点速记

7.1 核心口诀

  • 认证 = 验证"你是谁"(登录)
  • 授权 = 验证"你能做什么"(权限)
  • Session = 服务端存储会话状态
  • Token = 客户端携带的凭证;可以是自包含 JWT,也可以是服务端可查询的引用令牌

7.2 三框架选型口诀

先定身份协议 → 再选框架
先定撤销与轮换 → 再选 Token 形态
URL 规则管入口 → 方法规则管功能 → 数据条件管资源归属

7.3 密码安全

  • 使用专门的自适应密码哈希算法,如 Argon2、scrypt、bcrypt 或 PBKDF2,并按环境校准成本参数。
  • 不要直接用 MD5、SHA-1 或单次 SHA-256 保存密码;它们不是密码哈希方案。
  • bcrypt 等格式通常把算法参数和盐编码在最终哈希串中,无需另建“盐字段”;另行管理的 pepper 应放在密钥系统中,不与数据库一起保存。

7.4 权限校验三时机

请求入口(Filter) → URL 级权限
    ↓
方法调用(@PreAuthorize) → 功能级权限
    ↓
数据访问(WHERE 条件) → 行级数据权限(需自研)

7.5 安全最佳实践

  1. 密码加密:BCrypt,不自研加密算法
  2. Token 生命周期:明确过期、轮换、撤销与密钥泄露后的处置;JWT 可通过短有效期、会话版本或撤销列表限制风险
  3. 敏感接口:加验证码、防爬、限流
  4. 登录日志:记录 IP、时间、设备
  5. 权限最小化:先拒绝再授权
  6. 防止 CSRF:Spring Security 默认开启,Sa-Token 需手动处理

八、验证清单与参考

  • 匿名用户访问受保护接口得到 401,已登录但无权限得到 403。
  • 普通用户不能通过修改路径参数读取或修改他人的资源。
  • 方法级授权已显式启用,并有允许与拒绝两类测试。
  • Token 或 Session 过期、退出、密码修改和密钥轮换后的行为符合约定。
  • Cookie 场景验证 CSRF,跨域场景验证 CORS;两者不是同一个问题。
  • 日志不记录密码、完整 Token、验证码或敏感个人数据。

参考: