前言
「认证只是安全的第一步」
在 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
:处理 401AccessDeniedHandler
:处理 403
鉴权实现
在本文中,我们主要通过自定义的 JwtAuthFilter
进行认证和权限信息的加载和注册。AuthenticationEntryPoint
和 AccessDeniedHandler
虽然也有实现,但只做简单实现,将错误信息进行统一化处理。
同时对于上篇 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)
注册请求过滤器,加载用户信息- 定义相关权限异常处理器,
needLoginHandler
和customAccessDeniedHandler
都只做了简单实现,本文中不做具体说明
基于 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
@PermitAll
与HttpSecurity
配置:若全局配置为denyAll()
,注解会被忽略(全局配置优先级最高)。@PermitAll
与anyRequest()
配置:若全局配置为anyRequest()
,注解会被忽略(全局配置优先级最高)。
总结
@PermitAll
是 Java EE/Jakarta EE 的标准安全注解,在 Spring Security 中用于声明某个类或方法允许所有用户访问(包括匿名用户),无需任何身份认证或权限检查。
优先级规则:
-
全局配置 > 方法注解 > 类注解
HttpSecurity
的规则优先级最高,其次是方法级安全注解,最后是类级@PermitAll
。
-
注解之间的覆盖关系
@PreAuthorize
>@RolesAllowed
>@PermitAll
- 更具体的注解(方法级)会覆盖更通用的注解(类级)。
-
隐式权限冲突
- 如果接口被标记为
@PermitAll
,但全局配置要求认证(如.anyRequest().authenticated()
),实际会 要求认证(全局配置胜出)。
- 如果接口被标记为
多登录模式
后续计划实现 短信验证码、邮箱验证码、用户密码、扫码登录、WX授权等多种模式的登录。
具体实现可仿照 SMS 方式进行,该文不做过多赘述,后续会单独文章说明。
SMS短信认证
注:因各大运营商要求,目前个人开发者已无法再使用阿里云(其他厂商一样)的SMS短信服务。本文中的验证码已改为"假"验证码。后续 "多登录模式" 支持后,改用其他方式,如邮箱、密码、公众号扫码之类。
