Spring Security
-
描述:Spring 生态中核心的安全框架
- 认证:Security封装了【加载用户信息------身份校验】过程,提供过滤精细化拦截
- 鉴权:Security封装了Web接口前置鉴权过程
-
引入依赖
xml
<!-- Spring Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
-
原理
- Spring Security本质上就是多个【servlet 过滤器】组成的【FilterChain】
- 显然,采用MVC拦截器完全可以替代,而Spring Security则提供了标准化的认证 / 授权体系
前端/客户端 Security过滤器链 Service AuthenticationManager AuthenticationProvider Redis缓存 登录请求流程 1. 登录请求,过滤器放行 2. 创建待认证Authentication 3. AuthenticationManager.authenticate()开启验证 4. 调用UserDetailsService验证并加载用户信息 /调用三方API验证并加载用户信息 验证失败,返回403 5. 验证成功,缓存用户信息 6. 返回Token:登录成功 非登录请求流程 1. 非登录请求,过滤器拦截 未携带Token,返回403 2. 过滤器拦截:验证Token有效性和时效性 验证失败,返回401 3. 验证成功,将用户信息注入SecurityContext,进入鉴权检查 鉴权失败,返回403 4. 请求进入业务层 5. 业务层复用SecurityContext获取用户信息 6. 请求处理完毕,返回Token+业务数据 前端/客户端 Security过滤器链 Service AuthenticationManager AuthenticationProvider Redis缓存
Token认证技术
-
Token认证是【对非登录请求实现前置身份验证】的解决方案
- 本地或三方查库验证身份过程会产生大量IO,除了登录请求,其他请求都这么验证身份会很影响性能
- 将用户信息通过加密等手段转换为Token字符串,此后就可以通过Token凭证直接校验用户身份,无需真的查库校验身份
- Token可以是本地生成的(如JWT),也可以使用可信三方提供的Token(如OAuth)
- 推荐使用Redis缓存Token,保证性能同时兼顾了安全性
-
本地Token
- 用户登录时,本地数据库验证通过后,通过JWT技术生成Token
- 根据用户信息再本地JWT生成Token,并保存到Redis中
- 前端后续请求都携带此Token,验证通过后直接使用Redis中的用户信息
- 实现方式见【本地登录功能认证】
前端/客户端 后端 Redis缓存 1.登录请求 2.系统内部查库验证 验证失败 3.验证成功,内部生成Token 4.保存{Token:用户信息}到Redis 5.登录成功,返回Token给前端 前端/客户端 后端 Redis缓存
-
三方Token(OAuth 2.0)
- 用户登录时,调用三方API,验证通过后得到三方返回的Token
- 通过三方Token去查三方API,得用户信息
- 根据用户信息再本地JWT生成Token(本地Token可以自定义有效期、数据格式等高级功能)
- 将Token+用户信息保存到Redis中
- 前端后续请求都携带此Token,验证通过后直接使用Redis中的用户信息
- 实现方式见[【OAuth】](#OAuth 2.0)
RBAC模型
-
RBAC 是系统鉴权的主流解决方案
- 降低维护成本:【用户 - 角色 - 权限】解耦,例如用户离职时,仅修改用户-角色关联表即可
- 权限结构清晰:用户只关联角色信息,角色只关联权限信息,权限信息是系统唯一鉴权标准
- 功能拓展性强:用户可以有多个角色,角色可以有多个权限,后期信息变化时只需要同步关联表即可
- 框架适配性强:Spring Security原生适配并推荐 RBAC 作为主流方案
-
设计数据表
- 用户信息表
user:维护用户基本信息 - 角色表
role:维护系统含有哪些角色 - 权限表
peimisson:维护系统含有哪些权限 - 用户-角色关联表
user_role:一个用户可以有多个角色 - 角色-权限关联表
role_permisssion:一个角色可以有多个权限 - 中大型系统:增加「部门、数据权限、资源、租户、角色约束」等表,实现精细化权限管控
- 用户信息表
mysql
`user` (
`phone` varchar(50) NOT NULL PRIMARY
`name` varchar(50) NOT NULL
`password` varchar(128) NOT NULL
)
`role` (
`id` int NOT NULL PRIMARY
`name` varchar(255) NOT NULL
)
`permission` (
`code` varchar(20) NOT NULL PRIMARY
`name` varchar(255) NOT NULL
)
`user_role` (
`phone` varchar(20) NOT NULL PRIMARY
`role_id` int NOT NULL PRIMARY
)
`role_permission` (
`role_id` int NOT NULL PRIMARY
`permission_code` varchar(20) NOT NULL PRIMARY
)
-
创建对应PO
- 用户只关联角色,角色只关联权限
- 逻辑关联,PO不物理关联
java
@AllArgsConstructor
@NoArgsConstructor
@Data
public class UserPo {
private String phone;
private String name;
private String password;
}
@Data
@AllArgsConstructor
@NoArgsConstructor
public class RolePo {
private Integer id;
private String name;
}
@Data
@AllArgsConstructor
@NoArgsConstructor
public class PermissionPo {
private String code;
private String name;
}
- 【用户-角色】一对多、【角色-权限】多对多,因此实现批量查询方式,否则在业务层循环查库性能极低
java
public interface UserRoleDao {
// 根据用户ID,查询关联的索引角色ID
Set<Integer> getRoleIdsByPhone(@Param("phone") String phone);
}
public interface RoleDao {
// 根据所有角色ID,一次性批量查询到所有的角色详细信息
Set<String> batchGetRoleNames(@Param("roleIds") Set<Integer> roleIds);
}
public interface RolePermissionDao {
// 根据所有的角色ID,一次性查询到所有关联的权限ID
Set<String> batchGetPermissionCodesByRoleIds(@Param("roleIds") Set<Integer> roleId);
}
public interface PermissionDao {
// 根据所有的权限ID,一次性查询到所有的权限详细信息
Set<String> batchGetPermissionsByCode(@Param("codeSet") Set<String> codes);
}
Security客户端配置
-
核心配置项
- 指定哪些请求需要认证或者鉴权:默认所有Web接口都需要
- 注册自定义过滤器:除了Security框架排除的请求,所有的请求都会经过【见会话保持章节】
- 设置认证异常处理器:Security框架中产生认证异常时会自动处理【见会话保持章节】
- 注册注销处理器:快速实现登出功能【见注销处理器章节】
- 设置具体的鉴权规则:见鉴权章节
java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(securedEnabled = true) // 启用@PreAuthorize
public class SecurityConfig {
@Autowired
private JwtRefreshFilter jwtRefreshFilter; // 自定义过滤器
@Autowired
private AuthenticationExceptionHandler authenticationExceptionHandler; // 认证异常处理器
@Autowired
private LogoutHandler logoutHandler; // 注销处理器
@Autowired
private AccessDeniedExceptionHandler accessDeniedExceptionHandler; // 鉴权异常处理器
@Autowired
private UserDetailsService userDetailsService; // 用户信息加载实现类
@Autowired
private RedisClient redisClient; // redis缓存
/**
* 密码编码器
* Spring Security 必须通过 PasswordEncoder 加密密码,若不配置该 Bean,启动会直接报错
* 采用 BCrypt 哈希算法,不可逆,只能密文比较
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// 关闭csrf防护,用于前后端分离场景
http.csrf(AbstractHttpConfigurer::disable);
// 关闭Session,用于分布式场景
http.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
// 注册web接口
http.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.POST, "/user/login").permitAll()
.requestMatchers("/test").permitAll()
.requestMatchers(HttpMethod.POST, "/user/logout").permitAll()
.anyRequest().authenticated()
);
// 注册自定义过滤器
http.addFilterBefore(jwtRefreshFilter, UsernamePasswordAuthenticationFilter.class);
// 注册自定义处理器
http.logout(logout -> logout
.logoutUrl("/user/logout") // 自定义注销路径(替代默认 /logout)
.logoutSuccessHandler(logoutHandler) // 你的自定义注销成功处理器
);
// 注册异常处理器
http.exceptionHandling(ex -> ex
.accessDeniedHandler(accessDeniedExceptionHandler) // 权限不足
.authenticationEntryPoint(authenticationExceptionHandler) // 未认证
);
return http.build();
}
/**
* SpringSecurity 认证管理器
*/
@Bean
public AuthenticationManager configureAuthenticationManager(PasswordEncoder passwordEncoder) {
// 1. 创建 DaoAuthenticationProvider(用于用户名密码登录)
DaoAuthenticationProvider daoAuthProvider = new DaoAuthenticationProvider();
daoAuthProvider.setUserDetailsService(userDetailsService);
daoAuthProvider.setPasswordEncoder(passwordEncoder);
// 2.创建 SmsCodeAuthenticationProvider (用户验证码登录)
SmsCodeAuthenticationProvider smsAuthProvider = new SmsCodeAuthenticationProvider();
smsAuthProvider.setUserDetailsService(userDetailsService);
smsAuthProvider.setRedisClient(redisClient);
// 2. 返回包含所有 Provider 的 AuthenticationManager
return new ProviderManager(Arrays.asList(
daoAuthProvider, // 用户名密码登录
smsAuthProvider // 短信登录
));
}
}
- 目前主流版本已升级到Security 6,如果老项目是Security 5配置格式会有差异
java
@Configuration
@EnableWebSecurity // 开启认证功能
@EnableGlobalMethodSecurity(securedEnabled = true) // 注意这里是 @EnableGlobalMethodSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter { // Security5需要继承WebSecurityConfigurerAdapter
/**
* 密码编码器
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable(); // 5.x的语法
// 分布式场景,禁止Session传递
http.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
// 设置验证请求路径和鉴权规则
http.authorizeRequests()
.antMatchers(HttpMethod.POST, "/user/login").permitAll()
.antMatchers(HttpMethod.POST, "/user/logout").permitAll()
.anyRequest().authenticated();
// 添加自定义过滤器
http.addFilterBefore(jwtRefreshFilter(), UsernamePasswordAuthenticationFilter.class);
// 配置注销
http.logout()
.logoutUrl("/user/logout") // 自定义注销路径
.logoutSuccessHandler(logoutHandler()); // 注销成功处理器
// 异常处理器
http.exceptionHandling()
.accessDeniedHandler(accessDeniedExceptionHandler()) // 权限异常处理器
.authenticationEntryPoint(authenticationExceptionHandler()); // 鉴权异常处理器
}
/**
* 认证管理器 - 5.x需要显式配置
*/
@Override
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
UserDetails封装用户信息
-
Spring Security提供了
UserDetails接口,用于本地登录充当用户信息实体类UserDetailsService只支持UserDetails传递用户信息UserDetails一定要配置安全的构造函数,防止传入冗余或敏感信息- 角色信息必须以
ROLE_开头, 否则 Security 识别hasRole()表达式会出错
java
/*
* Spring Security 规定 UserDetailsService 接口只接受 UserDetails 类型
* 禁止UserDetails父类getter参与Json序列化,引用引用外部类的安全信息
* */
@Data
public class UserSecurityDetails implements UserDetails {
// 引用外部类的安全信息
private final UserDto userDto;
public UserSecurityDetails(UserDto userDto) {
this.userDto = Objects.requireNonNull(userDto);
}
@Override
@JSONField(serialize = false)
public Collection<? extends GrantedAuthority> getAuthorities() {
List<GrantedAuthority> authorities = new ArrayList<>();
//添加角色
if (!CollectionUtils.isEmpty(userDto.getRoleNames())) {
//将角色设置到GrantedAuthority中,官网要求角色要加上前缀 ROLE_xxx 区分其它权限
userDto.getRoleNames()
.forEach(role -> authorities.add(new SimpleGrantedAuthority("ROLE_" + role)));
}
//添加权限
if (!CollectionUtils.isEmpty(userDto.getPermissionNames())) {
//将权限设置到GrantedAuthority中
userDto.getPermissionNames()
.forEach(permission -> authorities.add(new SimpleGrantedAuthority(permission)));
}
return authorities;
}
@Override
@JSONField(serialize = false)
public String getPassword() {
// 返回用户密码
return userDto.getPassword();
}
@Override
@JSONField(serialize = false)
public String getUsername() {
// 返回用户主键,注意不一定是姓名,比如这里是手机号
return userDto.getPhone();
}
@Override
@JSONField(serialize = false)
public boolean isAccountNonExpired() {
// 账号是否过期,如果在数据库中没有设置,给默认值不过期
return true;
}
@Override
@JSONField(serialize = false)
public boolean isAccountNonLocked() {
// 账号是否锁定,如果在数据库中没有设置,给默认值不锁定
return true;
}
@Override
@JSONField(serialize = false)
public boolean isCredentialsNonExpired() {
// 密码是否过期,如果在数据库中没有设置,给默认值不过期
return true;
}
@Override
@JSONField(serialize = false)
public boolean isEnabled() {
// 账号是否可用,如果在数据库中没有设置,给默认值可用
return true;
}
}
- 外部映射DTO类中感冒信息不参与序列化
java
@NoArgsConstructor
@Data
public class UserDto {
// 用户基本信息
private String phone;
private String name;
@JSONField(serialize = false) // 密码不参与序列化
private String password;
// 用户角色信息
private Set<String> roleNames;
// 角色权限信息
private Set<String> permissionNames;
public UserDto(String phone, String name, String password, Set<String> roleNames, Set<String> permissionNames) {
this.phone = phone;
this.name = name;
this.password = password;
this.roleNames = roleNames;
this.permissionNames = permissionNames;
}
}
UserDetailsService加载用户信息
- 本地登录场景下,用户信息是存储在本地库的,不同登录方式的认证过程不同,但最终从本地库加载用户信息的逻辑是相同的
java
@Component
public class UserSecurityService implements UserDetailsService {
@Autowired
private UserDao userDao;
@Autowired
private RoleDao roleDao;
@Autowired
private PermissionDao permissionDao;
@Autowired
private UserRoleDao userRoleDao;
@Autowired
private RolePermissionDao rolePermissionDao;
@Override
public UserDetails loadUserByUsername(String phone) {
// 查询用户信息,防止用户未注册
UserPo userPo = userDao.getUserByPhone(phone);
if (userPo == null) {
throw new UsernameNotFoundException("user not exist: " + phone);
}
// 查询对应角色信息
Set<Integer> roleIdSet = userRoleDao.getRoleIdsByPhone(phone);
if (CollectionUtils.isEmpty(roleIdSet)) {
throw new UsernameNotFoundException("user have no role: " + phone);
}
Set<String> roleNameSet = roleDao.getRoleNameSet(roleIdSet);
if (CollectionUtils.isEmpty(roleNameSet)) {
throw new UsernameNotFoundException("role not found: " + roleIdSet);
}
// 查询角色对应权限信息
Set<String> codeSet = rolePermissionDao.batchGetPermissionCodesByRoleIds(roleIdSet);
if (CollectionUtils.isEmpty(codeSet)) {
throw new UsernameNotFoundException("role have no permission: " + roleIdSet);
}
Set<String> permissionSet = permissionDao.getPermissionByCodes(codeSet);
if (CollectionUtils.isEmpty(permissionSet)) {
throw new UsernameNotFoundException("permission not found: " + codeSet);
}
UserDto userDto = new UserDto(
userPo.getPhone(),
userPo.getName(),
userPo.getPassword(),
roleNameSet,
permissionSet
);
return new UserSecurityDetails(userDto);
}
}
本地登录认证
-
描述
- Security封装了本地用户密码认证过程,可以自动认证登录身份
- 本地其他方式登录实现对应的
Provider即可
本地密码登录
- Security已经内置了
UsernamePasswordAuthenticationToken+DaoAuthenticationProvider本地密码认证 - 直接调用
AuthenticationManager.authenticate(UsernamePasswordAuthenticationToken Authentication)即可
java
/**
* DaoAuthenticationProvider 源码
*/
public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {
private static final String USER_NOT_FOUND_PASSWORD = "userNotFoundPassword";
private PasswordEncoder passwordEncoder;
private volatile String userNotFoundEncodedPassword;
private UserDetailsService userDetailsService;
private UserDetailsPasswordService userDetailsPasswordService;
// getter、setter...
@Override
@SuppressWarnings("deprecation")
protected void additionalAuthenticationChecks(UserDetails userDetails,
UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
if (authentication.getCredentials() == null) {
this.logger.debug("Failed to authenticate since no credentials provided");
throw new BadCredentialsException(this.messages
.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
String presentedPassword = authentication.getCredentials().toString();
if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
this.logger.debug("Failed to authenticate since password does not match stored value");
throw new BadCredentialsException(this.messages
.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
}
@Override
protected void doAfterPropertiesSet() {
Assert.notNull(this.userDetailsService, "A UserDetailsService must be set");
}
@Override
protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
prepareTimingAttackProtection();
try {
UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
if (loadedUser == null) {
throw new InternalAuthenticationServiceException(
"UserDetailsService returned null, which is an interface contract violation");
}
return loadedUser;
}
catch (UsernameNotFoundException ex) {
mitigateAgainstTimingAttack(authentication);
throw ex;
}
catch (InternalAuthenticationServiceException ex) {
throw ex;
}
catch (Exception ex) {
throw new InternalAuthenticationServiceException(ex.getMessage(), ex);
}
}
@Override
protected Authentication createSuccessAuthentication(Object principal, Authentication authentication,
UserDetails user) {
boolean upgradeEncoding = this.userDetailsPasswordService != null
&& this.passwordEncoder.upgradeEncoding(user.getPassword());
if (upgradeEncoding) {
String presentedPassword = authentication.getCredentials().toString();
String newPassword = this.passwordEncoder.encode(presentedPassword);
user = this.userDetailsPasswordService.updatePassword(user, newPassword);
}
return super.createSuccessAuthentication(principal, authentication, user);
}
private void prepareTimingAttackProtection() {
if (this.userNotFoundEncodedPassword == null) {
this.userNotFoundEncodedPassword = this.passwordEncoder.encode(USER_NOT_FOUND_PASSWORD);
}
}
private void mitigateAgainstTimingAttack(UsernamePasswordAuthenticationToken authentication) {
if (authentication.getCredentials() != null) {
String presentedPassword = authentication.getCredentials().toString();
this.passwordEncoder.matches(presentedPassword, this.userNotFoundEncodedPassword);
}
}
// 父类已经指定了token类型为UsernamePasswordAuthenticationToken
@Override
public boolean supports(Class<?> authentication) {
return (UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication));
}
}
验证码本地登录
- 自定义
AuthenticationToken:用于传递用户输入的验证码和认证通过后的AuthenticationToken
java
public class SmsCodeAuthenticationToken extends AbstractAuthenticationToken {
private final transient Object principal; // 认证前放手机号,认证后放UserDetails
private transient Object credentials; // 用户输入的验证码
// 构造函数1:用于认证前(未认证状态)
public SmsCodeAuthenticationToken(Object principal, Object credentials) {
super(null);
this.principal = principal;
this.credentials = credentials;
this.setAuthenticated(false); // 初始状态:未认证
}
// 构造函数2:用于认证后(已认证状态
public SmsCodeAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
super.setAuthenticated(true); // 认证成功状态
}
@Override
public Object getCredentials() {
return this.credentials;
}
@Override
public Object getPrincipal() {
return this.principal;
}
// 重要:防止意外调用setAuthenticated(true)破坏状态
@Override
public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
if (isAuthenticated) {
throw new IllegalArgumentException(
"Cannot set this token to trusted - use constructor with GrantedAuthority list instead");
}
super.setAuthenticated(false);
}
}
- 自定义
AuthenticationProvider:Redis验证通过后,加载用户信息,返回携带UserDetails的AuthenticationToken
java
@Setter
public class SmsCodeAuthenticationProvider implements AuthenticationProvider {
// 仿照DaoAuthenticationProvider
private UserDetailsService userDetailsService;
private RedisClient redisClient;
@Override
public Authentication authenticate(Authentication authentication) {
String phone = authentication.getName();
String inputCode = (String) authentication.getCredentials();
// 1. 验证验证码(Redis校验)
String storedCode = redisClient.get(SMS_CODE + phone);
if (!StringUtils.hasText(storedCode) || !inputCode.equals(storedCode)) {
throw new BadCredentialsException("验证码错误或已过期");
}
redisClient.delete(SMS_CODE + phone);
// 2. 加载用户信息(复用UserDetailsService)
UserDetails userDetails = userDetailsService.loadUserByUsername(phone);
// 3. 返回认证成功的Token
return new SmsCodeAuthenticationToken(userDetails, userDetails.getAuthorities());
}
@Override
public boolean supports(Class<?> authentication) {
// 只处理自定义的 SmsCodeAuthenticationToken 类型
return SmsCodeAuthenticationToken.class.isAssignableFrom(authentication);
}
}
实现登录功能
- 业务层实现时,可以将验证逻辑解耦出来
java
@Service
@Slf4j
public class UserService {
@Autowired
private RedisClient redisClient;
@Autowired
private AuthenticationManager authenticationManager;
// 用户登录,用户密码匹配模式
public Result<String> loginByPassword(String phone, String password, String token) {
Authentication unauthentication = new UsernamePasswordAuthenticationToken(phone, password);
return doAuth(unauthentication, token);
}
// 用户登录,用户密码匹配模式
public Result<String> loginBySms(String phone, String code, String token) {
Authentication unauthentication = new SmsCodeAuthenticationToken(phone, code);
return doAuth(unauthentication, token);
}
private Result<String> doAuth(Authentication unauthentication,String token) {
try {
Authentication authentication = authenticationManager.authenticate(unauthentication);
// 认证成功,authentication即为已认证的token,其中Principal即为加载的SecurityDetails
UserSecurityDetails userDetails = (UserSecurityDetails) authentication.getPrincipal();
// 生成 JWT token
String newToken = JWTUtil.generateToken(userDetails.getUsername());
// 存入Redis,不传密码
redisClient.set(
USERINFO_PREFIX + newToken,
JsonUtil.toJson(userDetails),
TOKEN_EXPIRE_TIME, TimeUnit.HOURS);
if (StringUtils.hasText(token)) {
// 直接删除旧数据
// 不能直接复用数据,用户可能会切换账户
redisClient.delete(USERINFO_PREFIX + token);
}
// 登录成功,返回token
return Result.ok(newToken);
} catch (AuthenticationException e) {
// 认证失败时会抛出异常(如 AuthenticationException),而不是返回 null
log.error("用户认证失败: {}", e.getMessage());
return Result.fail("认证失败");
} catch (Exception e) {
// 捕获所有其他异常
log.error("用户登录失败: {}", e.getMessage());
return Result.fail("登录失败,请联系管理员,异常信息:" + e.getMessage());
}
}
}
登录注销
-
Spring Security提供了
LogoutSuccessHandler接口,可以不编写Controller方法快速实现注销功能- 本地登出功能实现很简单,直接删除Redis中的信息即可,后续请求Redis查不到直接认为未登录即可
- 敏感应用场景(如银行、企业应用)可能还需要立即使所有已颁发的访问令牌全部失效(一般不涉及)
- 编写完成后,将登出处理器注册到
SecurityFilterChain即可
java
@Component
public class LogoutHandler implements LogoutSuccessHandler {
@Autowired
private RedisClient redisClient;
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
// 这里演示本地登出删除Redis数据
String token = request.getHeader("token");
if (StringUtils.hasText(token)) {
redisClient.delete(USERINFO_PREFIX + token);
}
response.setContentType(HttpConstants.HTTP_JSON_TYPE);
response.getWriter().print(JsonUtil.toJson(Result.ok("已登出")));
}
}
服务资源保护
- 描述:访问受保护资源,在请求到达Servlet前先检查身份信息,用户已经登录时才能访问
注册身份验证过滤器
-
实现
OncePerRequestFilter- 对于不涉及认证的请求可以在过滤器中忽略(例如注册、登出、登录功能)
- Token过滤器只建立或清除安全上下文,产生的认证异常由Security后续组件处理
- 对于高频访问场景,可以优化Token复用:当临近过期时才重新生成Token
java
/*
* Spring Security 自定义 Filter
* */
@Component
@Slf4j
public class TokenRefreshFilter extends OncePerRequestFilter {
@Autowired
private RedisClient redisClient;
/**
* 虽然SecurityFilterChain指定请求放行,但依然会经过过滤器,只是不抛出AuthenticationException异常
* 但登录等请求不需要经过此过滤器,否则token会重复刷新
*/
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
// 获取当前请求路径
String requestURI = request.getRequestURI();
// 排除 路径(返回 true 表示不过滤该路径)
return requestURI.startsWith("/user/login") || requestURI.startsWith("/user/register")
|| requestURI.startsWith("/user/logout");
}
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws Exception {
try {
String token = request.getHeader(JWT_TOKEN_HEADER);
// JWT检查(JWTUtil见JWT篇)
if (!StringUtils.hasText(token) || !JWTUtil.checkToken(token)) {
// 无token/过期,直接放行
filterChain.doFilter(request, response);
return;
}
//redis中获取用户信息
String userInfo = redisClient.get(USERINFO_PREFIX + token);
if (!StringUtils.hasText(userInfo)) {
// 必须同时校验「JWT 有效性」和「Redis 状态」,且Redis 状态是最终判据(因为 Redis 可动态管控)
filterChain.doFilter(request, response);
return;
}
UserSecurityDetails userSecurityDetails = JsonUtil.parse(userInfo, UserSecurityDetails.class);
if (ObjectUtils.isEmpty(userSecurityDetails)) {
filterChain.doFilter(request, response);
return;
}
// 刷新或复用Token
String newToken = refreshExpire(token, userInfo);
/*
* 封装用户信息,保存到SecurityContextHolder中
* principal:UserSecurityDetails类
* credentials:用于存储用户密码,但已认证场景下建议传 null
* authorities:权限集合,即用户权限信息
* */
// 将用户信息送到SpringSecurity上下文中
Authentication authenticationToken = new UsernamePasswordAuthenticationToken(
userSecurityDetails, null, userSecurityDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
// 最终放行请求(所有分支都执行到这里,保证请求传递到后续过滤器)
response.setHeader(JWT_TOKEN_HEADER, newToken);
filterChain.doFilter(request, response);
} catch (Exception e) {
// 异常时清空上下文,这样保证Spring Security最终会拦截请求
SecurityContextHolder.clearContext();
filterChain.doFilter(request, response);
}
}
// 刷新token有效期,选择复用token
private String refreshExpire(String token, String userInfo) {
Long expire = redisClient.getExpire(USERINFO_PREFIX + token);
if (expire != -1 && expire < TOKEN_EXPIRE_IN) {
// 有效期不足五分钟,重置Redis和Token有效期(JWTUtil见JWT篇)
String newToken = JWTUtil.refreshToken(token);
// 这里在高并发场景下有线程安全风险,可以使用分布式锁
redisClient.set(USERINFO_PREFIX + newToken, userInfo, TOKEN_EXPIRE_TIME, TimeUnit.HOURS);
redisClient.delete(USERINFO_PREFIX + token);
return newToken;
}
// 直接复用token
return token;
}
}
配置AccessDeniedHandler异常处理器
-
Spring Security提供的权限异常处理器,注册到
SecurityFilterChain即可,触发场景如下- 过滤器中主动抛出
AccessDeniedException - 【
authenticated()请求】不匹配【aceess()指定的权限】 - 【
authenticated()请求】不匹配【@PreAuthorize指定的权限】 - 注意:业务层抛出的
AuthenticationException不会触发AuthenticationEntryPoint
- 过滤器中主动抛出
java
@Component
public class AccessDeniedExceptionHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest req, HttpServletResponse res, AccessDeniedException ex)
throws IOException, ServletException {
res.setContentType(HttpConstants.HTTP_JSON_TYPE);
res.getWriter().write(JsonUtil.toJson(Result.fail("权限不足")));
}
}
鉴权
- 描述:用户登录后(用户已登录是鉴权的前提),检查用户权限信息,如果权限不够拒绝访问此Web接口
声明接口权限
-
优先级说明
( )括号!逻辑非and逻辑与or逻辑或
-
Security 虽然支持【角色+权限】验证,但标准的RBAC鉴权标准是验证权限信息,如果不是兼容老项目,尽量不要使用角色验证
基于配置类声明接口权限
java
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true) // 开启鉴权
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(
HttpSecurity http,
JwtRefreshFilter jwtRefreshFilter,
AuthenticationExceptionHandler authenticationExceptionHandler,
LogoutHandler logoutHandler,
AccessDeniedExceptionHandler accessDeniedExceptionHandler) throws Exception {
http.csrf(AbstractHttpConfigurer::disable);
http.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
http.authorizeHttpRequests(auth -> auth
// 公共API接口
.requestMatchers("/user/login", "user/logout").permitAll()
// 登录后,具有 ALL 权限可以访问
.requestMatchers("/user/all").hasAuthority("ALL")
// 登录后,同时具有 ADD 和 UPDATE 权限可以访问
.requestMatchers("/user/addAndUpdate").access(
new WebExpressionAuthorizationManager("hasAuthority('ADD') and hasAuthority('UPDATE')"))
// 登录后,具有 ADD 或者 UPDATE 权限就可以访问
.requestMatchers("/user/allOrUpdate").hasAnyAuthority("ALL", "UPDATE")
// 登录后,具有SUPER_ADMIN角色才能访问
.requestMatchers("/user/super").hasRole("SUPER_ADMIN")
// 登录后,具有admin或者super角色才能访问
.requestMatchers("/user/adminOrSuper").hasAnyRole("ADMIN", "SUPER_ADMIN")
// 登录后,同时具有APPROVE和PL角色才能访问
.requestMatchers("/user/approveAndPL").access(
new WebExpressionAuthorizationManager("hasRole('APPROVE') and hasRole('PL')"))
// 登录后,具有admin或者super角色、或者具有 VIEW 或者 ADD 权限才可以访问
.requestMatchers("/user/approveAndPL").access(
new WebExpressionAuthorizationManager(
"hasAnyRole('SUPER_ADMIN','ADMIN') " +
"or" +
"hasAuthority('VIEW') and hasAuthority('ADD')"
)
)
// 基于SpEL表达式的复杂控制
.requestMatchers("/api/project/{id}/**")
.access("@projectAccessControl.checkAccess(authentication, #id)")
// 记住我功能的访问控制
.requestMatchers("/api/profile/**").fullyAuthenticated()
// 任何其他请求都需要认证
.anyRequest().authenticated()
);
http.addFilterBefore(jwtRefreshFilter, UsernamePasswordAuthenticationFilter.class) // 添加过滤器
//注册自定义处理器(注销处理器,注销成功后删除redis中的数据)
.logout(logout -> logout
.logoutUrl("/user/logout") // 自定义注销路径(替代默认 /logout)
.logoutSuccessHandler(logoutHandler) // 你的自定义注销成功处理器
)
// 异常处理器(6.x 语法不变)
.exceptionHandling(ex -> ex
.accessDeniedHandler(accessDeniedExceptionHandler) // 403 权限不足
.authenticationEntryPoint(authenticationExceptionHandler) // 401 未认证
);
http.rememberMe(remember -> remember
.tokenValiditySeconds(7 * 24 * 60 * 60) // 7天
);
return http.build();
}
/**
* 密码编码器
* Spring Security 必须通过 PasswordEncoder 加密密码,若不配置该 Bean,启动会直接报错
* 采用 BCrypt 哈希算法,不可逆,只能密文比较
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
基于注解声明接口权限
java
@RestController
@RequestMapping("user")
public class UserController {
@PostMapping("/login") // 登录请求已经全局设置allPermission,不参与鉴权
public void login(@Valid @RequestBody UserVo userVo, HttpServletRequest request, HttpServletResponse response) throws IOException {
...
}
@PostMapping("/register") // 注册请求完全忽略Spring Security,不参与鉴权
public void register(@Valid @RequestBody UserVo userVo, HttpServletResponse response) throws IOException {
...
}
/**
* 登录后,具有 ALL 权限可以访问
*/
@PreAuthorize(value = "hasAuthority('ALL')")
@GetMapping("/All")
public Result all(){
return Result.ok("ok");
}
/**
* 登录后,具有 VIEW 或者 ALL 权限可以访问
*/
@PreAuthorize(value = "hasAnyAuthority('VIEW','ALL')")
@GetMapping( "/viewOrAll")
public Result viewOrAll(){
return Result.ok("ok");
}
/**
* 登录后,同时具有 ADD 和 UPDATE 权限可以访问
*/
@PreAuthorize(value = "hasAuthority('ADD') and hasAuthority('UPDATE')")
@GetMapping( "/addAndUpdate")
public Result addAndUpdate(){
return Result.ok("ok");
}
/**
* 登录后,具有SUPER_ADMIN角色才能访问
*/
@PreAuthorize(value = "hasRole('SUPER_ADMIN')")
@GetMapping(value = "/super")
public Result superAdmin(){
return Result.ok("ok");
}
/**
* 登录后,具有admin或者super角色才能访问
*/
@PreAuthorize(value = "hasAnyRole('SUPER_ADMIN','ADMIN')")
@GetMapping(value = "/adminOrSuper")
public Result adminOrSuper(){
return Result.ok("ok");
}
/**
* 登录后,同时具有APPROVE和LEADER角色才能访问
*/
@PreAuthorize(value = "hasRole('APPROVE') and hasRole('LEADER')")
@GetMapping(value = "/test5")
public Result approveAndLeader(){
return Result.ok("ok");
}
/**
* 登录后,具有admin或者super角色、或者具有 VIEW 或者 ADD 权限才可以访问
*/
@GetMapping("test")
@PreAuthorize("hasAnyRole('SUPER_ADMIN','ADMIN') or ( hasAuthority('VIEW') and hasAuthority('ADD') )")
public Result compound(){
return Result.ok("ok");
}
}
配置AuthenticationEntryPoint异常处理器
-
Spring Security 提供的认证异常处理器,[注册到
SecurityFilterChain即可](#Spring Security 提供的认证异常处理器,注册到SecurityFilterChain即可,触发场景如下),触发场景如下- 过滤器中主动抛出
AuthenticationException - 过滤器链结束后,
SecurityContextHolder中信息为空则会自动抛AuthenticationException - 注意:业务层抛出的
AuthenticationException不会触发AuthenticationEntryPoint
- 过滤器中主动抛出
java
@Component
public class AuthenticationExceptionHandler implements AuthenticationEntryPoint {
/*
* AuthenticationException处理器
* Spring Security 会调用该接口的 commence() 方法处理未认证响应
*/
@Override
public void commence(HttpServletRequest req, HttpServletResponse res, AuthenticationException ex)
throws IOException {
res.setContentType(HttpConstants.HTTP_JSON_TYPE);
res.getWriter().print(JsonUtil.toJson(Result.fail("用户身份认证异常")));
}
}