Spring Security 实践之认证鉴权

前言


「认证只是安全的第一步」

Spring Security 实践之登录 中,我们实现了基于Spring Security的基础登录逻辑,但用户登录后:

  • 🔒 如何控制不同角色访问API的权限?
  • ⚖️ 如何实现方法级的细粒度权限控制?
  • 🛡️ 如何进行匿名访问和接口开放?

本文将对上述问题进行注意解决。

Spring Security 鉴权过程


权限校验过程图解

概述

请求拦截阶段

  • JwtAuthFilter 检查请求头中的 Token
  • 有 Token → 进入认证流程
  • 无 Token → 进入匿名(暂未生成匿名用户)处理流程

认证处理阶段

A. 有效 Token 流程

  • JwtAuthFilter 解析 Token
  • 调用 AuthenticationManager 验证 Token
  • 验证通过后生成 Authentication 对象
  • 将认证信息存入 SecurityContextHolder

B. 无效/缺失 Token 流程

  • 验证访问资源是否为 permitAll()
  • 如果是 permitAll() 资源,直接放行
  • 如果不是 permitAll() 资源,AnonymousAuthenticationFilter 介入
  • 生成匿名认证对象 anonymousUser
  • 将匿名认证信息存入 SecurityContextHolder

授权检查阶段

  • FilterSecurityInterceptor 拦截请求
  • 调用 AuthorizationManager 进行权限决策
  • SecurityContextHolder 获取当前用户权限
  • 从系统配置获取接口所需权限
  • 进行权限比对

结果处理阶段

  • 权限足够 → 放行请求,返回业务数据(200)
  • 未认证(或匿名用户权限不足) → 触发 AuthenticationEntryPoint(返回 401)
  • 无权限 → 触发 AccessDeniedHandler(返回 403)

核心组件清单

  • 认证相关

    • JwtAuthFilter:Token 解析
    • AuthenticationManager:认证协调
    • SecurityContextHolder:存储认证信息
  • 授权相关

    • FilterSecurityInterceptor:最终权限检查
    • AuthorizationManager:权限决策
  • 异常处理

    • AuthenticationEntryPoint:处理 401
    • AccessDeniedHandler:处理 403

鉴权实现

在本文中,我们主要通过自定义的 JwtAuthFilter进行认证和权限信息的加载和注册。AuthenticationEntryPointAccessDeniedHandler虽然也有实现,但只做简单实现,将错误信息进行统一化处理。

同时对于上篇 Spring Security 实践之登录 中所提及的登录逻辑进行一部分的改造,以适配后续认证鉴权的实现。

登录改造

LoginSuccessHandler 修改

在原有登录成功的逻辑中,我们在登录成功后将 UserDetails 信息存储 UserTokenCache 中,在后续的认证鉴权中可以直接从缓存中获取信息。

ini 复制代码
public void onAuthenticationSuccess(
    HttpServletRequest request,
    HttpServletResponse response,
    Authentication authentication)
throws IOException, ServletException {

    // 生成 token 返回前端
    UserDetails details = (UserDetails) authentication.getDetails();
    // accessToken 过期时间 30分钟
    Long accessTokenExpireSeconds = configProperties.getAuth().getAccessTokenExpireSeconds();
    // refreshToken 过期时间 6小时
    Long refreshTokenExpireSeconds = configProperties.getAuth().getRefreshTokenExpireSeconds();
    String accessToken = jwtUtil.createToken(details.getUsername(), accessTokenExpireSeconds);
    String refreshToken = jwtUtil.createToken(accessToken, refreshTokenExpireSeconds);

    // 设置 TokenCache 也就是当前登录人信息
    userTokenCache.setToken(accessToken, (AuthUserInfo) details);

    Map<String, String> tokenMap = new HashMap<>();
    tokenMap.put("accessToken", accessToken);
    tokenMap.put("refreshToken", refreshToken);

    response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
    response.getWriter().write(JSON.toJSONString(ApiResult.success(tokenMap)));
}

SmsAuthProvider 修改

需要对原登录逻辑进行一定的调整,调整内容包括:修改原先登录成功后返回的用户信息实体;抽象化原登录逻辑,后续交给 UserService进行实现。

java 复制代码
@Component
public class SmsAuthProvider implements AuthenticationProvider {

    @Autowired
    private AbstractLogin abstractLogin;

    /**
     * 验证手机验证码登录认证
     *
     * @param authentication the authentication request object.
     * @return
     * @throws AuthenticationException
     */
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        SmsAuthenticationToken token = (SmsAuthenticationToken) authentication;
        String phone = token.getPrincipal();
        String code = token.getCredentials();
        try {
            UserDetails userDetails = abstractLogin.smsLogin(phone, code);
            token.setDetails(userDetails);
        } catch (AuthenticationException authenticationException) {
            throw authenticationException;
        } catch (Exception e) {
            throw new LoginFailException();
        }
        return token;
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return SmsAuthenticationToken.class.equals(authentication);
    }
}

AbstractLogin 定义

这里只对登录过程进行定义,后续在实现 多模式登录 时,做具体说明,同时现阶段也是每个登录动作做出具体的定义,而非使用策略模式做相应扩展,后续再对扩展性做可行性分析。

typescript 复制代码
public interface AbstractLogin {

    /**
     * 短信验证码登录
     * @param phone
     * @param code
     * @return
     */
    AuthUserInfo smsLogin(String phone, String code);

    /**
     * 用户名密码登录
     * @param username
     * @param password
     * @return
     */
    AuthUserInfo userPasswordLogin(String username, String password);

    /**
     * 微信直接登录
     * @param code
     * @return
     */
    AuthUserInfo wxLogin(String code);

}

AuthUserInfo 实现

AuthUserInfo是对 UserDetails的具体实现

typescript 复制代码
public class AuthUserInfo implements UserDetails {

    private String phoneNum;
    private String username;

    private List<String> roles;

    @Override
    public List<? extends GrantedAuthority> getAuthorities() {
        if (roles != null) {
            return roles.stream()
                    .map(role -> new SimpleGrantedAuthority("ROLE_" + role))
                    .collect(Collectors.toList());
        }
        return Collections.emptyList();
    }

    // ...... 
}

至此,我们对原登录逻辑的修改就告一段落。接下来是实际鉴权过程的实现。

具体实现

实现思路

  • JwtAuthFilter实现OncePerRequestFilter,对所有URL(除已配置的URL外)进行请求过滤
  • JwtAuthFilter从请求中获取 Token 信息
  • JwtUtil校验及解析 Token
  • UserTokenCache 中获取用户信息并构建认证和权限信息
  • 继续请求,交给后续的 Spring Security 内置的权限校验

JwtAuthFilter

java 复制代码
@Component
public class JwtAuthFilter extends OncePerRequestFilter {

    private JwtUtil jwtUtil;
    private UserTokenCache userTokenCache;

    public JwtAuthFilter(JwtUtil jwtUtil, UserTokenCache userTokenCache) {
        this.jwtUtil = jwtUtil;
        this.userTokenCache = userTokenCache;
    }
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {
        // 1. 从请求头提取Token
        String token = getToken(request);

        if (token != null && jwtUtil.validateToken(token)) {
            // 2. 构建认证对象
            Authentication auth = buildAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(auth);
        }

        // 3. 继续过滤器链
        chain.doFilter(request, response);
    }

    private String getToken(HttpServletRequest request) {
        String header = request.getHeader("Authorization");
        if (StringUtils.hasText(header) && header.startsWith("Bearer ")) {
            return header.substring(7);
        }
        return null;
    }

    /**
     * 根据JWT构建Authentication对象
     * @param token 有效的JWT令牌
     * @return 已认证的Authentication对象
     */
    public Authentication buildAuthentication(String token) {
        // 1. 从JWT中提取用户名
        String username = jwtUtil.parseToken(token);

        // 2. 加载用户信息
        // 从JWT自定义声明中直接读取权限(推荐无状态方案)
        UserDetails userDetails = getTokenUser(token);

        return new UsernamePasswordAuthenticationToken(
                userDetails.getUsername(),
                // credentials置空
                null,
                userDetails.getAuthorities()
        );
    }

    /**
     * 从缓存中获取用户权限信息
     */
    private UserDetails getTokenUser(String token) {
        // 从缓存中获取
        AuthUserInfo authUserInfo = userTokenCache.tokenUser(token);
        if (authUserInfo == null) {
            throw new UserNotLoginException();
        }

        return authUserInfo;
    }
}

UserTokenCache 缓存实现

typescript 复制代码
@Component
public class UserTokenCache {

    private static final Logger LOGGER = LoggerFactory.getLogger(UserTokenCache.class);

    private static final String TOKEN_REDIS_PREFIX = "TOKEN::";
    private static final long TOKEN_TIME_OUT_SECOND = 30 * 60;

    @Autowired
    private RedisTemplate<String, String> redisTemplate;


    public void setToken(String token, AuthUserInfo userInfo) {
        setToken(token, JSON.toJSONString(userInfo));
    }

    private void setToken(String token, String userInfoJson) {
        ValueOperations<String, String> ops = redisTemplate.opsForValue();
        ops.set(getTokenKey(token), userInfoJson, TOKEN_TIME_OUT_SECOND, TimeUnit.SECONDS);
    }

    /**
     * 获取 token 对应的用户信息
     * 每次调用此方法获取信息,都会将token有效期延长 TOKEN_TIME_OUT_SECOND 秒
     * @param token
     * @return
     */
    public AuthUserInfo tokenUser(String token) {
        ValueOperations<String, String> ops = redisTemplate.opsForValue();
        String userJson = ops.get(getTokenKey(token));
        if (StringUtils.isEmpty(userJson)) {
            throw new UserNotLoginException();
        }
        AuthUserInfo authUserInfo = JSON.parseObject(userJson, AuthUserInfo.class);
        setToken(token, userJson);
        return authUserInfo;
    }

    String getTokenKey(String token) {
        return TOKEN_REDIS_PREFIX + token;
    }

}

:::tips 该缓存设计中,在通过Token获取用户信息的同时,再次执行了 setToken 操作,对 Token 的有效期进行了顺延的操作。这也是本文中 Token 超时和延时 的具体方案。

对比双Token来说,各有利弊。

在本文的之后篇章中再做两种方案的分析。

:::

JwtAuthConfig 配置修改

scss 复制代码
@Configuration
@EnableWebSecurity
// 启用方法级别安全控制
@EnableGlobalMethodSecurity(
        // 启用Spring的@PreAuthorize等注解
        prePostEnabled = true,
        // 启用Spring的@Secured注解
        securedEnabled = true,
        // 启用JSR-250标准注解(如@RolesAllowed、@PermitAll)
        jsr250Enabled = true
)
public class JwtAuthConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private JwtLoginConfig loginConfig;

    @Autowired
    private JwtAuthFilter authFilter;

    @Autowired
    private NeedLoginHandler needLoginHandler;
    @Autowired
    private CustomAccessDeniedHandler customAccessDeniedHandler;

    @Override
    public void configure(WebSecurity web) throws Exception {
        super.configure(web);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .csrf()
                .disable()
                // 禁用表单登录
                .formLogin().disable()
                // 不会写入Cookie JSESSIONID
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .apply(loginConfig)
                .and()
                .authorizeRequests()
                // 过滤登录等需要放开的请求
                .antMatchers("/auth/**", "/open/**").permitAll()
                // 其余请求需要登录
                .anyRequest().authenticated()
                .and()
                .addFilterBefore(authFilter, UsernamePasswordAuthenticationFilter.class)
                //定义异常处理器
                .exceptionHandling()
                // 未登录处理
                .authenticationEntryPoint(needLoginHandler)
                // 无权限处理
                .accessDeniedHandler(customAccessDeniedHandler);
    }
}

主要修改信息:

  • 启用Spring的@PreAuthorize等注解
  • 启用Spring的@Secured注解
  • 启用JSR-250标准注解(如@RolesAllowed、@PermitAll)
  • anyRequest().authenticated()所有请求开启登录认证信息校验
  • addFilterBefore(authFilter, UsernamePasswordAuthenticationFilter.class)注册请求过滤器,加载用户信息
  • 定义相关权限异常处理器,needLoginHandlercustomAccessDeniedHandler都只做了简单实现,本文中不做具体说明

基于 Spring Security的登录认证鉴权方案已经基本完成,接下来进入验证阶段。

其他

Token有效期顺延 vs 双Token方案

单Token顺延方案

实现方式

  • 每次合法请求时,在返回业务数据的同时 刷新原Token有效期(重新生成或延长过期时间)
  • 客户端需持续更新本地存储的Token

优点

  • ✅ 实现简单:服务端只需维护单个Token的签发/刷新逻辑
  • ✅ 流量优化:减少一次获取新Token的HTTP请求(对比Refresh Token方案)
  • ✅ 实时性强:每次交互都检查Token活性

缺点

  • ❌ 安全性风险:长期有效的Token一旦泄露,攻击窗口期较大
  • ❌ 状态依赖:需要服务端记录Token过期时间(违背JWT无状态原则)
  • ❌ 客户端耦合:要求客户端必须及时更新Token

适用场景

▸ 内部系统或低安全要求场景

▸ 需要简化实现的短期项目

双Token方案(Access Token + Refresh Token)

实现方式

  • Access Token:短期有效(如2小时),用于业务请求
  • Refresh Token:长期有效(如7天),仅用于获取新Access Token
  • 通过专用接口 /refresh-token 轮换Access Token

优点

  • ✅ 安全性高:Access Token短期有效,泄露风险低
  • ✅ 无状态性:Refresh Token可设置服务端黑名单,平衡安全与无状态
  • ✅ 权限控制灵活:可独立吊销Refresh Token

缺点

  • ❌ 实现复杂:需额外处理Token轮换逻辑和并发请求问题
  • ❌ 网络开销:需频繁调用刷新接口
  • ❌ 客户端适配:需处理Token过期和刷新的边缘情况

适用场景

▸ 面向公众的高安全要求系统

▸ 需要精细控制会话生命周期的场景

总结

后续 Spring Security 的相关文章和方案,均会采用 单Token顺延方案。 主要还是简单!!

匿名访问/接口开放

接口开放指被 permitAll()标记的接口,如本文中的 /auth/**/open/**接口。

permitAll 与匿名用户的本质区别

概念差异

  • permitAll():是 权限放行规则,直接绕过所有安全检查(不关心用户是谁)。
  • 匿名用户:是 一种特殊的认证身份(AnonymousAuthenticationToken),仍需经过权限校验。

特点对比

特性 permitAll() 匿名用户
本质 权限配置(Authorization) 认证身份(Authentication)
是否检查身份 ❌ 完全不检查 ✅ 生成 anonymousUser身份
是否走权限决策 ❌ 直接放行 ✅ 需通过 AuthorizationManager检查权限
典型配置 .requestMatchers("/open/**").permitAll() 默认启用(http.anonymous().disable()可关闭)
安全影响 完全开放 仍受 hasRole('ANONYMOUS')等规则约束

流程差异(以访问 /api/data 为例)

场景1:配置为 permitAll()
scss 复制代码
.requestMatchers("/api/data").permitAll()

流程:

  • 请求到达 FilterSecurityInterceptor
  • 检查到 permitAll() → 直接放行 → 不生成身份,不检查权限
场景2:未配置 permitAll()(启用匿名)
scss 复制代码
.requestMatchers("/api/data").authenticated()

流程:

  • 请求无 Token → AnonymousAuthenticationFilter 生成 AnonymousAuthenticationToken
  • AuthorizationManager 检查权限 → 因要求 authenticated(),拒绝匿名用户
  • 返回 401 Unauthorized

差异总结

  • 目的不同:

    • permitAll():完全开放接口(如健康检查、静态资源)。
    • 匿名用户:区分游客与登录用户(如论坛允许匿名浏览,但评论需登录)。
  • 技术实现不同:

    • permitAll() 的接口 不会出现在权限决策流程中。
    • 匿名用户 仍属于认证体系,只是身份特殊。
  • 安全影响不同:

scss 复制代码
.requestMatchers("/admin").permitAll()   // 危险!任何人可访问管理员接口
.requestMatchers("/admin").hasRole("ADMIN") // 匿名用户会被拒绝

PermitAll 注解 及 配置优先级

启用方式

Spring Security 默认不处理 @PermitAll 注解,必须显式启用。

less 复制代码
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(jsr250Enabled = true) // 必须添加这一行
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    // 其他配置...
}
优先级问题
  • @PermitAll@PreAuthorize@PreAuthorize 优先级更高,会覆盖 @PermitAll
  • @PermitAllHttpSecurity 配置:若全局配置为 denyAll(),注解会被忽略(全局配置优先级最高)。
  • @PermitAllanyRequest() 配置:若全局配置为 anyRequest(),注解会被忽略(全局配置优先级最高)。
总结

@PermitAll 是 Java EE/Jakarta EE 的标准安全注解,在 Spring Security 中用于声明某个类或方法允许所有用户访问(包括匿名用户),无需任何身份认证或权限检查。
优先级规则:

  1. 全局配置 > 方法注解 > 类注解

    • HttpSecurity 的规则优先级最高,其次是方法级安全注解,最后是类级 @PermitAll
  2. 注解之间的覆盖关系

    • @PreAuthorize > @RolesAllowed > @PermitAll
    • 更具体的注解(方法级)会覆盖更通用的注解(类级)。
  3. 隐式权限冲突

    • 如果接口被标记为 @PermitAll,但全局配置要求认证(如 .anyRequest().authenticated()),实际会 要求认证(全局配置胜出)。

多登录模式

后续计划实现 短信验证码、邮箱验证码、用户密码、扫码登录、WX授权等多种模式的登录。

具体实现可仿照 SMS 方式进行,该文不做过多赘述,后续会单独文章说明。

SMS短信认证

注:因各大运营商要求,目前个人开发者已无法再使用阿里云(其他厂商一样)的SMS短信服务。本文中的验证码已改为"假"验证码。后续 "多登录模式" 支持后,改用其他方式,如邮箱、密码、公众号扫码之类。

相关推荐
不过普通话一乙不改名2 小时前
第一章:Go语言基础入门之函数
开发语言·后端·golang
豌豆花下猫3 小时前
Python 潮流周刊#112:欢迎 AI 时代的编程新人
后端·python·ai
Electrolux4 小时前
你敢信,不会点算法没准你赛尔号都玩不明白
前端·后端·算法
whhhhhhhhhw4 小时前
Go语言-fmt包中Print、Println与Printf的区别
开发语言·后端·golang
ん贤4 小时前
Zap日志库指南
后端·go
Spliceㅤ4 小时前
Spring框架
java·服务器·后端·spring·servlet·java-ee·tomcat
IguoChan5 小时前
10. Redis Operator (3) —— 监控配置
后端
Micro麦可乐6 小时前
前端与 Spring Boot 后端无感 Token 刷新 - 从原理到全栈实践
前端·spring boot·后端·jwt·refresh token·无感token刷新
方块海绵7 小时前
浅析 MongoDB
后端
中东大鹅7 小时前
SpringBoot配置外部Servlet
spring boot·后端·servlet