SpringSecurity6
什么是SpringSecurity?
Spring Security 是一个强大的、高度可定制的身份验证(Authentication)和访问控制(Authorization)框架。它是 Spring 框架家族的一员,主要用于保护基于 Java 的应用程序,无论是Web应用还是非Web应用。Spring Security 提供了以下功能:
-
认证:管理用户凭证的验证过程,确定用户是否可以登录到系统。
-
授权:控制经过认证的用户能够访问哪些资源或执行哪些操作。
-
会话管理:对于Web应用,Spring Security 还处理用户的会话。
-
跨站请求伪造(CSRF)保护:防止恶意网站利用用户的登录状态执行不受信任的操作。
-
点击劫持保护:通过HTTP头部设置来帮助防御点击劫持攻击。
-
加密和编码支持:提供密码加密和其他安全相关的编码任务。
Spring Security 可以与 Spring MVC 和 Spring WebFlux 紧密集成,同时也支持传统的 Servlet API。它允许开发者以声明式的方式定义安全约束,并且可以通过编程方式自定义安全策略。此外,Spring Security 还支持多种认证方式,如表单登录、HTTP基本认证、OAuth2、OpenID Connect 等。
在过去,Spring Security 的配置相对复杂,但是随着 Spring Boot 的出现,它提供了自动配置方案,使得集成 Spring Security 变得更为简单,甚至可以做到“零配置”使用。这使得 Spring Security 在现代 Java 应用程序的安全性管理方面变得非常流行。
Spring Security实现权限
要对Web资源进行保护,最好的办法莫过于Filter 要想对方法调用进行保护,最好的办法莫过于AOP。
Spring Security进行认证和鉴权的时候,就是利用的一系列的Filter来进行拦截的。
如图所示,一个请求想要访问到API就会从左到右经过蓝线框里的过滤器,其中绿色部分是负责认证的过滤器,蓝色部分是负责异常处理,橙色部分则是负责授权。进过一系列拦截最终访问到我们的API。
这里面我们只需要重点关注两个过滤器即可:UsernamePasswordAuthenticationFilter
负责登录认证,FilterSecurityInterceptor
负责权限授权。
说明:Spring Security的核心逻辑全在这一套过滤器中,过滤器里会调用各种组件完成功能,掌握了这些过滤器和组件你就掌握了Spring Security!这个框架的使用方式就是对这些过滤器和组件进行扩展。
用户认证流程
认证核心
我们系统中会有许多用户,确认当前是哪个用户正在使用我们系统就是登录认证的最终目的。这里我们就提取出了一个核心概念:当前登录用户/当前认证用户。整个系统安全都是围绕当前登录用户展开的,这个不难理解,要是当前登录用户都不能确认了,那A下了一个订单,下到了B的账户上这不就乱套了。这一概念在Spring Security中的体现就是 Authentication
,它存储了认证信息,代表当前登录用户。
我们在程序中如何获取并使用它呢?我们需要通过 SecurityContext
来获取Authentication
,SecurityContext
就是我们的上下文对象!这个上下文对象则是交由 SecurityContextHolder
进行管理,你可以在程序任何地方使用它:
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
SecurityContextHolder
原理非常简单,就是使用ThreadLocal
来保证一个线程中传递同一个对象!
现在我们已经知道了Spring Security中三个核心组件:
1、Authentication
:存储了认证信息,代表当前登录用户
2、SeucirtyContext
:上下文对象,用来获取Authentication
3、SecurityContextHolder
:上下文管理对象,用来在程序任何地方获取SecurityContext
Authentication
中是什么信息呢:
1、Principal
:用户信息,没有认证时一般是用户名,认证后一般是用户对象
2、Credentials
:用户凭证,一般是密码
3、Authorities
:用户权限
认证接口
AuthenticationManager
的校验逻辑非常简单:
根据用户名先查询出用户对象(没有查到则抛出异常)将用户对象的密码和传递过来的密码进行校验,密码不匹配则抛出异常。
这个逻辑没啥好说的,再简单不过了。重点是这里每一个步骤Spring Security都提供了组件:
1、是谁执行 根据用户名查询出用户对象 逻辑的呢?用户对象数据可以存在内存中、文件中、数据库中,你得确定好怎么查才行。这一部分就是交由UserDetialsService
处理,该接口只有一个方法loadUserByUsername(String username)
,通过用户名查询用户对象,默认实现是在内存中查询。
2、那查询出来的 用户对象 又是什么呢?每个系统中的用户对象数据都不尽相同,咱们需要确认我们的用户数据是啥样的才行。Spring Security中的用户数据则是由UserDetails
来体现,该接口中提供了账号、密码等通用属性。
3、对密码进行校验大家可能会觉得比较简单,if、else
搞定,就没必要用什么组件了吧?但框架毕竟是框架考虑的比较周全,除了if、else
外还解决了密码加密的问题,这个组件就是PasswordEncoder
,负责密码加密与校验。
我们可以看下AuthenticationManager
校验逻辑的大概源码:
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
...省略其他代码
// 传递过来的用户名
String username = authentication.getName();
// 调用UserDetailService的方法,通过用户名查询出用户对象UserDetail(查询不出来UserDetailService则会抛出异常)
UserDetails userDetails = this.getUserDetailsService().loadUserByUsername(username);
String presentedPassword = authentication.getCredentials().toString();
// 传递过来的密码
String password = authentication.getCredentials().toString();
// 使用密码解析器PasswordEncoder传递过来的密码是否和真实的用户密码匹配
if (!passwordEncoder.matches(password, userDetails.getPassword())) {
// 密码错误则抛出异常
throw new BadCredentialsException("错误信息...");
}
// 注意哦,这里返回的已认证Authentication,是将整个UserDetails放进去充当Principal
UsernamePasswordAuthenticationToken result = new UsernamePasswordAuthenticationToken(userDetails,
authentication.getCredentials(), userDetails.getAuthorities());
return result;
...省略其他代码
}
UserDetialsService
、UserDetails
、PasswordEncoder
,这三个组件Spring Security都有默认实现,这一般是满足不了我们的实际需求的,所以这里我们自己来实现这些组件!
加密器PasswordEncoder
加密我们项目采取MD5加密
操作模块:spring-security模块
自定义加密处理组件:CustomMd5PasswordEncoder
package com.atguigu.system.custom;
import com.atguigu.common.util.MD5;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
/**
* <p>
* 密码处理
* </p>
*
*/
@Component
public class CustomMd5PasswordEncoder implements PasswordEncoder {
public String encode(CharSequence rawPassword) {
return MD5.encrypt(rawPassword.toString());
}
public boolean matches(CharSequence rawPassword, String encodedPassword) {
return encodedPassword.equals(MD5.encrypt(rawPassword.toString()));
}
}
用户对象UserDetails
该接口就是我们所说的用户对象,它提供了用户的一些通用属性,源码如下:
public interface UserDetails extends Serializable {
/**
* 用户权限集合(这个权限对象现在不管它,到权限时我会讲解)
*/
Collection<? extends GrantedAuthority> getAuthorities();
/**
* 用户密码
*/
String getPassword();
/**
* 用户名
*/
String getUsername();
/**
* 用户没过期返回true,反之则false
*/
boolean isAccountNonExpired();
/**
* 用户没锁定返回true,反之则false
*/
boolean isAccountNonLocked();
/**
* 用户凭据(通常为密码)没过期返回true,反之则false
*/
boolean isCredentialsNonExpired();
/**
* 用户是启用状态返回true,反之则false
*/
boolean isEnabled();
}
实际开发中我们的用户属性各种各样,这些默认属性可能是满足不了,所以我们一般会自己实现该接口,然后设置好我们实际的用户实体对象。实现此接口要重写很多方法比较麻烦,我们可以继承Spring Security提供的org.springframework.security.core.userdetails.User
类,该类实现了UserDetails
接口帮我们省去了重写方法的工作。
了解以上后我们即可进入使用了
表结构
#用户表
CREATE TABLE `sys_user` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '用户主键',
`wx_openid` varchar(100) DEFAULT NULL COMMENT '微信的Openid',
`session_key` varchar(100) DEFAULT NULL COMMENT '微信的sessionKey(选择存储)',
`phone` varchar(20) DEFAULT NULL COMMENT '手机号',
`sex` char(1) DEFAULT NULL COMMENT '性别',
`username` varchar(35) DEFAULT NULL COMMENT '用户名称',
`vx_avatar` varchar(255) DEFAULT NULL COMMENT '微信头像路径',
`status` tinyint(1) DEFAULT NULL COMMENT '是否可用 0可用 1不可用,默认0',
`pwd` varchar(255) DEFAULT NULL COMMENT '密码',
`type` tinyint(1) NOT NULL COMMENT '0用户登录,1管理员',
`gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
`is_deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '删除标记(0:未删除,1:删除)',
`gmt_founder` varchar(35) DEFAULT NULL COMMENT '创建人',
PRIMARY KEY (`id`),
UNIQUE KEY `wx_openid` (`wx_openid`),
UNIQUE KEY `phone` (`phone`),
UNIQUE KEY `username` (`username`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=58 DEFAULT CHARSET=utf8mb3 COMMENT='用户表';
#用户角色表
CREATE TABLE `sys_user_role` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '用户角色主键',
`role_id` bigint NOT NULL COMMENT '角色主键',
`user_id` bigint NOT NULL COMMENT '用户主键',
`gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
`is_deleted` tinyint(1) DEFAULT '0' COMMENT '删除标记(0:未删除,1:删除)',
`gmt_founder` varchar(35) DEFAULT NULL COMMENT '创建人',
PRIMARY KEY (`id`),
KEY `id_role_id` (`role_id`) USING BTREE,
KEY `id_user_id` (`user_id`) USING BTREE,
CONSTRAINT `sys_user_role_ibfk_1` FOREIGN KEY (`role_id`) REFERENCES `sys_role` (`id`),
CONSTRAINT `sys_user_role_ibfk_2` FOREIGN KEY (`user_id`) REFERENCES `sys_user` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb3 COMMENT='用户角色表';
#角色表
CREATE TABLE `sys_role` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '角色主键',
`role_name` varchar(20) NOT NULL COMMENT '角色名称',
`role_code` varchar(20) DEFAULT NULL COMMENT '角色编码',
`description` varchar(100) DEFAULT NULL COMMENT '角色描述',
`gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
`is_deleted` tinyint(1) DEFAULT '0' COMMENT '删除标记(0:未删除,1:删除)',
`gmt_founder` varchar(35) DEFAULT NULL COMMENT '创建人',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=17 DEFAULT CHARSET=utf8mb3 COMMENT='角色管理表';
#菜单表
CREATE TABLE `sys_menu` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '菜单主键',
`parent_id` bigint NOT NULL COMMENT '所属上级菜单',
`name` varchar(20) NOT NULL COMMENT '菜单名字',
`type` tinyint NOT NULL COMMENT '菜单类型(0:目录,1:菜单,2:按钮)',
`path` varchar(100) DEFAULT NULL COMMENT '路由地址',
`component` varchar(100) DEFAULT NULL COMMENT '组件路径',
`perms` varchar(100) DEFAULT NULL COMMENT '权限标识',
`icon` varchar(100) DEFAULT NULL COMMENT '菜单图标',
`sort_value` int DEFAULT NULL COMMENT '菜单排序',
`status` tinyint DEFAULT NULL COMMENT '状态(0:禁止,1:正常)',
`gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
`is_deleted` tinyint(1) DEFAULT '0' COMMENT '删除标记(0:未删除,1:删除)',
`always_show` tinyint unsigned DEFAULT NULL COMMENT '总是展示(0:不展示,1展示)',
`hidden` tinyint(1) DEFAULT NULL COMMENT '是否展示(0:不展示,1展示)',
`keep_alive` tinyint(1) DEFAULT NULL COMMENT '是否缓存(0:不缓存,1缓存)',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb3 COMMENT='菜单表';
#角色菜单关联表
CREATE TABLE `sys_role_menu` (
`id` bigint NOT NULL AUTO_INCREMENT,
`role_id` bigint NOT NULL DEFAULT '0',
`menu_id` bigint NOT NULL DEFAULT '0',
`gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
`is_deleted` tinyint NOT NULL DEFAULT '0' COMMENT '删除标记(0:可用 1:已删除)',
PRIMARY KEY (`id`),
KEY `id_role_id` (`role_id`) USING BTREE,
KEY `id_menu_id` (`menu_id`) USING BTREE,
CONSTRAINT `sys_role_menu_ibfk_1` FOREIGN KEY (`menu_id`) REFERENCES `sys_menu` (`id`),
CONSTRAINT `sys_role_menu_ibfk_2` FOREIGN KEY (`role_id`) REFERENCES `sys_role` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=426 DEFAULT CHARSET=utf8mb3 ROW_FORMAT=DYNAMIC COMMENT='角色菜单';
表关系
首先要明白表之间的关系,了解下上面表的字段!!!准备工作完成后即可进入代码环节了。
Spring Security6的用户认证
这里我Springboot的版本是3.x.x
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>${jjwt.version}</version>
</dependency>
<!-- 如果jdk大于1.8,则还需导入下面依赖-->
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>${jaxb.version}</version>
</dependency>
<!-- SpringSecurity依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- JWT依赖 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>2.3.0</version>
</dependency>
<dependency>
<groupId>com.sun.xml.bind</groupId>
<artifactId>jaxb-impl</artifactId>
<version>2.3.0</version>
</dependency>
<dependency>
<groupId>com.sun.xml.bind</groupId>
<artifactId>jaxb-core</artifactId>
<version>2.3.0</version>
</dependency>
导入依赖后无法启动时正常的,因为没有配置文件~~~
流程:
1.我们先将账号密码交于UsernamePasswordAuthenticationToken
2.随后配置security
3.关联数据库获取UserDetail
4.编写认证监听器、过滤器等等
编写登录接口
controller
@RestController
@Tag(name = "登录接口/认证")
@RequestMapping("/api/v1/auth")
public class LoginController {
@Resource
private UserService userService;
@Resource
private RedisTemplate<String, String> redisTemplate;
/*
* @param loginDto
* @return
*/
@Operation(summary = "账号密码登录接口")
@PostMapping("/login")
public Result login(@RequestBody LoginDto loginDto){
return userService.login(loginDto);
}
}
注意这里的路径我们是自定义登录接口所以路径为:/api/v1/auth/login
IMPL
@Service
@RequiredArgsConstructor
@Slf4j
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
@Autowired
private UserMapper userMapper;
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Autowired
private SmsUtils smsUtils;
@Autowired
private MenuService menuService;
private final AuthenticationManager authenticationManager;
@Override
public Result login(LoginDto loginDto) {
if (StringUtils.isBlank(loginDto.getUsername()) && StringUtils.isBlank(loginDto.getPassword())) {
return Result.fail(500, "用户名或密码不能为空~");
}
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginDto.getUsername(), loginDto.getPassword());
Authentication authentication = authenticationManager.authenticate(authenticationToken);
String accessToken = JwtUtils.generateToken(authentication);
LoginVo loginVO = new LoginVo().setAccessToken(accessToken).setTokenType("Bearer");
return Result.success(loginVO);
}
}
解释:我们上面就把账号密码交于UsernamePasswordAuthenticationToken去处理了
jwt工具类
/**
* JWT 工具类
*
* @author debug
*/
@Component
public class JwtUtils {
/**
* JWT 加解密使用的密钥
*/
private static byte[] key;
/**
* JWT Token 的有效时间(单位:秒)
*/
private static int ttl;
/**
* 生成 JWT Token
*
* @param authentication 用户认证信息
* @return Token 字符串
*/
public static String generateToken(Authentication authentication) {
SysUserDetails userDetails = (SysUserDetails) authentication.getPrincipal();
Map<String, Object> payload = new HashMap<>();
payload.put(JwtClaimConstants.USER_ID, userDetails.getUserId()); // 用户ID
// claims 中添加角色信息
Set<String> roles = userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toSet());
payload.put(JwtClaimConstants.AUTHORITIES, roles);
Date now = new Date();
Date expiration = DateUtil.offsetSecond(now, ttl);
payload.put(JWTPayload.ISSUED_AT, now);
payload.put(JWTPayload.EXPIRES_AT, expiration);
payload.put(JWTPayload.SUBJECT, authentication.getName());
payload.put(JWTPayload.JWT_ID, IdUtil.simpleUUID());
return JWTUtil.createToken(payload, JwtUtils.key);
}
/**
* 从 JWT Token 中解析 Authentication 用户认证信息
*
* @param payload JWT 载体
* @return 用户认证信息
*/
public static UsernamePasswordAuthenticationToken getAuthentication(Map<String, Object> payload) {
SysUserDetails userDetails = new SysUserDetails();
// 用户ID
userDetails.setUserId(Convert.toLong(payload.get(JwtClaimConstants.USER_ID)));
// 用户名
userDetails.setUsername(Convert.toStr(payload.get(JWTPayload.SUBJECT)));
// 角色集合
Set<SimpleGrantedAuthority> authorities = ((JSONArray) payload.get(JwtClaimConstants.AUTHORITIES))
.stream()
.map(authority -> new SimpleGrantedAuthority(Convert.toStr(authority)))
.collect(Collectors.toSet());
return new UsernamePasswordAuthenticationToken(userDetails, "", authorities);
}
/**
* 解析 JWT Token 获取载体信息
*
* @param token JWT Token
* @return 载体信息
*/
public static Map<String, Object> parseToken(String token) {
try {
if (StrUtil.isBlank(token)) {
return null;
}
if (token.startsWith("Bearer ")) {
token = token.substring(7);
}
JWT jwt = JWTUtil.parseToken(token);
if (jwt.setKey(JwtUtils.key).validate(0)) {
return jwt.getPayloads();
}
} catch (Exception ignored) {
}
return null;
}
@Value("${jwt.key}")
public void setKey(String key) {
JwtUtils.key = key.getBytes();
}
@Value("${jwt.ttl}")
public void setTtl(Integer ttl) {
JwtUtils.ttl = ttl;
}
}
# 认证配置
jwt:
# 密钥
key: SecretKey012345678901234567890123456789012345678901234567890123456789
# token 过期时间(单位:秒)
ttl: 7200
//JwtClaimConstants
public interface JwtClaimConstants {
/**
* 用户ID
*/
String USER_ID = "userId";
/**
* 权限(角色Code)集合
*/
String AUTHORITIES = "authorities";
}
绑定管理数据库获取UserDetail
/**
* Spring Security 用户对象
*
* @author debug
*/
@Data
@NoArgsConstructor
public class SysUserDetails implements UserDetails {
private Long userId;
private String username;
private String phone;
private String password;
// private Boolean enabled;
private Integer status;
private Collection<SimpleGrantedAuthority> authorities;
//权限信息
@TableField(exist = false)
private Set<String> perms;
@TableField(exist = false)
private Set<String> roles;
private Boolean enabled;
//数据范围
private Integer dataScope;
public SysUserDetails(UserAuthInfo user) {
this.userId = user.getUserId();
this.roles=user.getRoles();
Set<String> roles = user.getRoles();
Set<SimpleGrantedAuthority> authorities;
if (CollectionUtil.isNotEmpty(roles)) {
authorities = roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role)) // 标识角色
.collect(Collectors.toSet());
} else {
authorities = Collections.EMPTY_SET;
}
this.authorities = authorities;
this.username = user.getUsername();
this.password = user.getPassword();
this.enabled = ObjectUtil.equal(user.getStatus(), 0);
this.perms = user.getPerms();
}
public Long getUserId() {
return this.userId;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.authorities;
}
@Override
public String getPassword() {
return this.password;
}
@Override
public String getUsername() {
return this.username;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return this.enabled;
}
}
通过实现UserDetailService的loadUserByUsername方法获取数据库里面的用户数据等。
/**
* 系统用户认证
* @author debug
*/
@Service
@RequiredArgsConstructor
public class SysUserDetailsService implements UserDetailsService {
private final UserMapper userMapper;
private final MenuService menuService;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
UserAuthInfo userAuthInfo = this.userMapper.getUserAuthInfo(username);
if (userAuthInfo == null) {
throw new UsernameNotFoundException(username);
}
Set<String> roles = userAuthInfo.getRoles();
if (CollectionUtil.isNotEmpty(roles)) {
Set<String> perms = menuService.listRolePerms(roles);
userAuthInfo.setPerms(perms);
}
return new SysUserDetails(userAuthInfo);
}
}
getUserAuthInfo()
listRolePerms()
<select id="listRolePerms" resultType="java.lang.String">
SELECT
DISTINCT t1.perms
FROM
sys_menu t1
INNER JOIN sys_role_menu t2 ON t1.id = t2.menu_id
INNER JOIN sys_role t3 ON t3.id = t2.role_id
AND t1.type = 2
AND t1.perms IS NOT NULL
<choose>
<when test="roles!=null and roles.size()>0">
AND t3.role_code IN
<foreach collection="roles" item="role" separator="," open="(" close=")">
#{role}
</foreach>
</when>
<otherwise>
AND t1.id = -1
</otherwise>
</choose>
</select>
Security配置类
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
// 自定义未认证处理类
private final MyAuthenticationEntryPoint authenticationEntryPoint;
// 自定义无权限访问处理类
@Resource
private final MyAccessDeniedHandler accessDeniedHandler;
// Redis操作模板
@Autowired
private final RedisTemplate<String, Object> redisTemplate;
/**
* 配置Spring Security过滤器链。
*
* @param http HttpSecurity对象,用于构建安全配置
* @return 构建好的SecurityFilterChain对象
* @throws Exception 配置过程中可能抛出的异常
*/
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(requestMatcherRegistry ->// 配置请求授权规则
//登录路径公开访问
requestMatcherRegistry.requestMatchers(SecurityConstants.LOGIN_PATH,
SecurityConstants.LOGOUT_PATH,
SecurityConstants.VERIFY_TREE_PATH,
SecurityConstants.GET_PHONE_CODE_PATH,
SecurityConstants.PHONE_LOGIN_PATH
).permitAll()
// 其他所有请求都需要认证
.anyRequest().authenticated()
)
// 禁用Session创建
.sessionManagement(configurer -> configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 配置异常处理
.exceptionHandling(httpSecurityExceptionHandlingConfigurer ->
httpSecurityExceptionHandlingConfigurer
// 设置未认证处理入口
.authenticationEntryPoint(authenticationEntryPoint)
// 设置无权限访问处理
.accessDeniedHandler(accessDeniedHandler)
)
// 禁用CSRF保护
.csrf(AbstractHttpConfigurer::disable)
;
// JWT 校验过滤器
http.addFilterBefore(new JwtValidationFilter(redisTemplate), UsernamePasswordAuthenticationFilter.class);
// 构建并返回过滤器链
return http.build();
}
/**
* 不走过滤器链的放行配置
*/
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
// 忽略指定路径的安全检查
return (web) -> web.ignoring()
.requestMatchers(
"/api/v1/auth/captcha",
"/webjars/**",
"/doc.html",
"/swagger-resources/**",
"/v3/api-docs/**",
"/swagger-ui/**",
"/swagger-ui.html",
"/ws/**",
"/ws-app/**"
);
}
/**
* 密码编码器
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 手动注入AuthenticationManager,用于处理认证和授权请求。
*
* @param authenticationConfiguration 认证配置对象
* @return AuthenticationManager对象
* @throws Exception 配置过程中可能抛出的异常
*/
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
// 获取认证管理器实例
return authenticationConfiguration.getAuthenticationManager();
}
}
放开接口类SecurityConstants
public interface SecurityConstants {
/**
* 登录接口路径
*/
String LOGIN_PATH = "/api/v1/auth/login";
/**
* 验证码接口路径
*/
String VERIFY_TREE_PATH = "/api/v1/auth/getVerifyThree";
/**
* 退出登录接口
*/
String LOGOUT_PATH = "/api/v1/auth/logout";
/**
* 手机号登录接口
*/
String PHONE_LOGIN_PATH = "/api/v1/auth/phoneLogin";
/**
* 获取手机验证码
*/
String GET_PHONE_CODE_PATH = "/api/v1/auth/sendCode";
}
jwt校验过滤器
@Slf4j
public class JwtValidationFilter extends OncePerRequestFilter {
private final RedisTemplate<String, Object> redisTemplate;
/**
* 构造函数
* @param redisTemplate Redis模板,用于操作Redis
*/
public JwtValidationFilter(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* 从请求中获取 JWT Token,校验 JWT Token 是否合法
* <p>
* 如果合法则将 Authentication 设置到 Spring Security Context 上下文中
* 如果不合法则清空 Spring Security Context 上下文,并直接返回响应
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
//从请求头中提取Token
String token = request.getHeader(HttpHeaders.AUTHORIZATION);
try {
//如果Token非空,则进行解析
if (StrUtil.isNotBlank(token)) {
//解析Token的Payload部分
Map<String, Object> payload = JwtUtils.parseToken(token);
String jti = null;
//如果Payload非空,提取JWT ID
if (payload != null) {
jti = Convert.toStr(payload.get(JWTPayload.JWT_ID));
}
//从Payload中获取认证信息
Authentication authentication = JwtUtils.getAuthentication(payload);
//将认证信息设置到Spring Security上下文中
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (CustomException ex) {
log.error("拦截出现错误,错误码为:{}", ex.getCode());
ex.printStackTrace();
//this is very important, since it guarantees the user is not authenticated at all
//如果解析过程中出现业务异常,清除Security上下文并返回错误响应
SecurityContextHolder.clearContext();
ResponseUtils.writeErrMsg(response, ex.getCode());
return;
}
//继续请求链
filterChain.doFilter(request, response);
}
}
每个请求都会先进该过滤器
认证异常处理类
/**
* 认证异常处理
* 当未认证的用户尝试访问需要认证的资源时,该类负责处理相关的认证异常
* 并向客户端返回具体的错误信息
*/
@Component
@Slf4j
public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint {
/**
* 开始处理认证异常
*
* @param request 当前的HTTP请求
* @param response 当前的HTTP响应
* @param authException 引发的认证异常
* @throws IOException 如果在处理过程中发生输入输出异常
* @throws ServletException 如果在处理过程中发生Servlet异常
*/
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
// 获取当前HTTP响应的状态码
int status = response.getStatus();
// 判断HTTP状态码是否为未找到资源(404)
if (status == HttpServletResponse.SC_NOT_FOUND) {
// 资源不存在,向客户端返回自定义的资源未找到错误信息
ResponseUtils.writeErrMsg(response, ResultEnum.RESOURCE_NOT_FOUND);
} else {
// 判断引发的认证异常是否为凭证无效异常(例如用户名或密码错误)
if(authException instanceof BadCredentialsException){
// 用户名或密码错误,向客户端返回自定义的用户名或密码错误信息
ResponseUtils.writeErrMsg(response, ResultEnum.ARGUMENT_VALID_ERROR);
} else {
// 处理其他类型的认证异常,如未认证或者令牌(token)过期
// 向客户端返回自定义的令牌无效错误信息
ResponseUtils.writeErrMsg(response, ResultEnum.TOKEN_INVALID);
}
}
}
}
Security访问异常处理器
@Component
public class MyAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
//访问没有授权
ResponseUtils.writeErrMsg(response, ResultEnum.ACCESS_UNAUTHORIZED);
}
}
ResultEnum类
package com.brush.brushcommon.enums;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* @ClassName: ResultEnum
* @Description:
* @Author: cws
* @Date: 2023/1/5 16:43
*/
@Getter
@AllArgsConstructor
@NoArgsConstructor
public enum ResultEnum {
ENUM_USERNAME_NULL(6666,"账号不正确或者没有此用户哦~"),
SUCCESS(200,"成功"),
FAIL(201, "失败"),
SERVICE_ERROR(2012, "服务异常"),
DATA_ERROR(204, "数据异常"),
ILLEGAL_REQUEST(205, "非法请求"),
REPEAT_SUBMIT(206, "重复提交"),
ARGUMENT_VALID_ERROR(210, "参数校验异常"),
LOGIN_AUTH(208, "未登陆"),
PERMISSION(209, "没有权限"),
ACCOUNT_ERROR(214, "账号不正确"),
PASSWORD_ERROR(215, "密码不正确"),
LOGIN_MOBLE_ERROR( 216, "账号不正确"),
ACCOUNT_STOP( 217, "账号已停用"),
NODE_ERROR( 218, "该节点下有子节点,不可以删除"),
TOKEN_INVALID(230, "token无效或已过期"),
TOKEN_ACCESS_FORBIDDEN(231, "token已被禁止访问"),
ACCESS_UNAUTHORIZED(301, "访问未授权"),
RESOURCE_NOT_FOUND(401, "请求资源不存在"),
PARAM_ERROR(400, "用户请求参数错误"),
;
private int code;
private String msg;
}
ResponseUtils工具
public class ResponseUtils {
/**
* 异常消息返回方法,针对不同类型的错误设置适当的HTTP状态码
* 并以JSON格式向客户端返回错误信息
*
* @param response HttpServletResponse对象,用于获取响应输出流并设置响应头信息
* @param resultEnum 结果枚举,表示不同的错误类型,用于确定响应的状态码和消息体内容
* @throws IOException 如果在写入响应时发生I/O错误
*/
public static void writeErrMsg(HttpServletResponse response, ResultEnum resultEnum) throws IOException {
// 根据不同的结果枚举设置相应的HTTP状态码
switch (resultEnum) {
case ACCESS_UNAUTHORIZED:
case TOKEN_INVALID:
response.setStatus(HttpStatus.UNAUTHORIZED.value());
break;
case TOKEN_ACCESS_FORBIDDEN:
response.setStatus(HttpStatus.FORBIDDEN.value());
break;
default:
response.setStatus(HttpStatus.BAD_REQUEST.value());
break;
}
// 设置响应内容类型为JSON
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
// 设置字符编码,确保响应内容的正确显示
response.setCharacterEncoding("UTF-8");
// 将错误信息结果转换为JSON字符串并写入响应
//TODO:这里强转了,不知道会不会错。
response.getWriter().print(JSONUtil.toJsonStr(Result.fail(resultEnum.toString())));
}
public static void writeErrMsg(HttpServletResponse response, Integer resultEnum) throws IOException {
// 根据不同的结果枚举设置相应的HTTP状态码
switch (resultEnum) {
case 301:
case 230:
response.setStatus(HttpStatus.UNAUTHORIZED.value());
break;
case 231:
response.setStatus(HttpStatus.FORBIDDEN.value());
break;
default:
response.setStatus(HttpStatus.BAD_REQUEST.value());
break;
}
// 设置响应内容类型为JSON
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
// 设置字符编码,确保响应内容的正确显示
response.setCharacterEncoding("UTF-8");
// 将错误信息结果转换为JSON字符串并写入响应
//TODO:这里强转了,不知道会不会错。
response.getWriter().print(JSONUtil.toJsonStr(Result.fail(resultEnum.toString())));
}
}
最后一步就是编写自定义异常处理了
自定义异常
@AllArgsConstructor
@NoArgsConstructor
@Data
public class CustomException extends RuntimeException{
private Integer code;
private String msg;
}
@ControllerAdvice
//顾名思义,@ControllerAdvice就是@Controller 的增强版。@ControllerAdvice主要用来处理全局数据,一般搭配@ExceptionHandler、@ModelAttribute以及@InitBinder使用。
@Slf4j
public class AllExceptionHandler {
//进行异常处理,处理Exception.class的异常
@ExceptionHandler(Exception.class)
@ResponseBody //返回json数据如果不加就返回页面了
public Result doException(Exception ex) {
//e.printStackTrace();是打印异常的堆栈信息,指明错误原因,
// 其实当发生异常时,通常要处理异常,这是编程的好习惯,所以e.printStackTrace()可以方便你调试程序!
ex.printStackTrace();
System.out.println(ex.getClass());
System.out.println(ex.getMessage());
log.error("出现异常:{}",ex.getClass()+":"+ex.getMessage());
return Result.fail(9999,ex.getMessage());
}
//自定义异常
@ExceptionHandler(CustomException.class)
@ResponseBody //返回json数据如果不加就返回页面了
public Result CustomException(CustomException ex) {
//e.printStackTrace();是打印异常的堆栈信息,指明错误原因,
// 其实当发生异常时,通常要处理异常,这是编程的好习惯,所以e.printStackTrace()可以方便你调试程序!
ex.printStackTrace();
//自定义的code和msg
log.error("出现异常:{}",ex.getClass()+":"+ex.getMessage());
return Result.fail(ex.getCode(),ex.getMsg());
}
/**
* 参数不能为空
*/
@ExceptionHandler(MissingServletRequestParameterException.class)
@ResponseBody
public Result bindException(MissingServletRequestParameterException exception) {
log.error("出现异常:{}",exception.getClass()+":"+exception.getMessage());
return Result.fail(400, String.format("参数%s不能为空!", exception.getParameterName()));
}
/**
* boby参数为空异常
* @param exception
* @return
*/
@ExceptionHandler(HttpMessageNotReadableException.class)
@ResponseBody
public Result bindException(HttpMessageNotReadableException exception) {
log.error("出现异常:{}",exception.getClass()+":"+exception.getMessage());
return Result.fail(400, "body参数不能为空!");
}
/**
* AuthorizationDeniedException 没有权限访问
*/
@ExceptionHandler(AuthorizationDeniedException.class)
@ResponseBody
public Result bindException(AuthorizationDeniedException exception) {
log.error("出现异常:{}",exception.getClass()+":"+exception.getMessage());
return Result.fail(403, "您没有权限访问该接口!");
}
/**
* 缺少参数异常
* @param exception
* @return
*/
@ExceptionHandler(MissingRequestHeaderException.class)
@ResponseBody
public Result bindException(MissingRequestHeaderException exception) {
log.error("出现异常:{}",exception.getClass()+":"+exception.getMessage());
return Result.fail(400, String.format("参数%s不能为空!", exception.getHeaderName()));
}
@ExceptionHandler(BindException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result processException(BindException e) {
log.error("BindException:{}", e.getMessage());
String msg = e.getAllErrors().stream().map(DefaultMessageSourceResolvable::getDefaultMessage).collect(Collectors.joining(";"));
return Result.fail(ResultEnum.PARAM_ERROR.getCode(), msg);
}
/**
* 请求方式异常
* @param exception
* @return
*/
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
@ResponseBody
public Result bindException(HttpRequestMethodNotSupportedException exception) {
log.error("出现异常:{}",exception.getClass()+":"+exception.getMessage());
return Result.fail(400, String.format("请求方式异常", exception.getMessage()));
}
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler({SQLException.class})
@ResponseBody
public Result handleSQLException(SQLException exception) {
log.error("出现异常:{}",exception.getClass()+":"+exception.getMessage());
return Result.fail(400, String.format("服务运行SQLException异常", exception.getMessage()));
}
/**
* 校验参数异常
* @param exception
* @return
*/
@ExceptionHandler(ValidationException.class)
@ResponseBody
public Result bindException(ValidationException exception) {
if(exception instanceof ConstraintViolationException) {
return Result.fail(400, String.format("参数%s不能为空!", ((ConstraintViolationException) exception).getConstraintViolations()));
}
log.error("出现异常:{}",exception.getClass()+":"+exception.getMessage());
return Result.fail(400, String.format("参数%s不能为空!", exception.getCause()));
}
/**
* 数据库异常
* @param
* @return
*/
@ExceptionHandler(value = DataAccessException.class)
@ResponseBody
public Result repeatException(SQLIntegrityConstraintViolationException exception) {
log.error("出现异常:{}",exception.getClass()+":"+exception.getMessage());
return Result.fail(999, exception.getMessage());
}
/**
* BuilderException mybatis sql 构建异常
*/
@ExceptionHandler(value = RuntimeException.class)
@ResponseBody
public Result repeatException(RuntimeException exception) {
log.error("出现异常:{}",exception.getClass()+":"+exception.getMessage());
return Result.fail(999, exception.getMessage());
}
}
测试
带token即可返回成功!!!
用户授权
在这之前我们需要了解一个注解@PreAuthorize()
:在 Spring Security 中,@PreAuthorize 是一个用于方法级别的安全注解,它允许你在方法执行之前基于表达式来进行访问控制。当一个带有 @PreAuthorize 注解的方法被调用时,Spring Security 会先评估 @PreAuthorize 注解中的表达式。如果表达式的结果为 true,则允许方法执行;如果结果为 false,则会抛出一个 AccessDeniedException 异常,阻止方法的执行。 @PreAuthorize 注解通常包含一个字符串表达式,这个表达式可以使用 Spring Expression Language (SpEL) 来编写。表达式可以访问当前认证对象 (authentication),以及方法的参数等。常用的表达式包括但不限于: hasRole('ROLE_ADMIN'):检查用户是否具有特定的角色。 hasAuthority('DELETE_PRIVILEGE'):检查用户是否具有特定的权限。 principal.username.equals('admin'):检查当前登录用户名是否等于 'admin'。 #id > 0:检查方法参数 id 是否大于0。
这里先说使用方法:
/**
* 获取菜单结点 menu:list
*/
@Parameters({
@Parameter(name = "Authorization", description = "请求token", required = true, in = ParameterIn.HEADER)
})
@Operation(summary = "获取菜单结点")
@GetMapping("findNodes")
@PreAuthorize("@ss.hasPerm('sys:user:select')")
public Result findNodes() {
List<MenuVo> menusVo = menuService.findNodes();
return Result.success(menusVo);
}
这里的sys:user:select也就是在上面认证的perms
说白了就是你的角色是日志管理员,那么你的权限是系统日志
这个模块,其他模块没有权限去请求。上图的perms字段是用户控制按钮的权限。
实现
@Component("ss")
@RequiredArgsConstructor
@Slf4j
public class PermissionService {
private final RedisTemplate<String, Object> redisTemplate;
private final MenuService menuService;
/**
* 判断当前登录用户是否拥有操作权限
*
* @param requiredPerm 所需权限
* @return 是否有权限
*/
public boolean hasPerm(String requiredPerm) {
if (StrUtil.isBlank(requiredPerm)) {
return false;
}
// 超级管理员放行
if (SecurityUtils.isRoot()) {
return true;
}
// 获取当前登录用户的角色编码集合
Set<String> roleCodes = SecurityUtils.getRoles();
if (CollectionUtil.isEmpty(roleCodes)) {
return false;
}
// 获取当前登录用户的所有角色的权限列表
Set<String> rolePerms = this.getRolePermsFormCache(roleCodes);
if (CollectionUtil.isEmpty(rolePerms)) {
return false;
}
// 判断当前登录用户的所有角色的权限列表中是否包含所需权限
boolean hasPermission = rolePerms.stream()
.anyMatch(rolePerm ->
// 匹配权限,支持通配符(* 等)
PatternMatchUtils.simpleMatch(rolePerm, requiredPerm)
);
if (!hasPermission) {
log.error("-------------------------用户无操作权限-----------------------------------");
}
return hasPermission;
}
/**
* 从缓存中获取角色权限列表
*
* @param roleCodes 角色编码集合
* @return 角色权限列表
*/
public Set<String> getRolePermsFormCache(Set<String> roleCodes) {
// 检查输入是否为空
if (CollectionUtil.isEmpty(roleCodes)) {
return Collections.emptySet();
}
Set<String> perms = menuService.listRolePerms(roleCodes);
log.info("通过角色查询出来的权限列表为:{}",Arrays.toString(perms.toArray()));
return perms;
}
}
建议:上面getRolePermsFormCache不应该总是去数据库查询,应该启动之前把menu加到redis中。
工具
public class SecurityUtils {
/**
* 获取当前登录人信息
*
* @return SysUserDetails
*/
public static SysUserDetails getUser() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null) {
Object principal = authentication.getPrincipal();
if (principal instanceof SysUserDetails) {
return (SysUserDetails) authentication.getPrincipal();
}
}
return null;
}
/**
* 获取用户ID
*
* @return Long
*/
public static Long getUserId() {
Long userId = Convert.toLong(getUser().getUserId());
return userId;
}
/**
* 获取用户角色集合
*
* @return 角色集合
*/
public static Set<String> getRoles() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null) {
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
if (CollectionUtil.isNotEmpty(authorities)) {
return authorities.stream().filter(item -> item.getAuthority().startsWith("ROLE_"))
.map(item -> StrUtil.removePrefix(item.getAuthority(), "ROLE_"))
.collect(Collectors.toSet());
}
}
return Collections.EMPTY_SET;
}
/**
* 是否超级管理员
* <p>
* 超级管理员忽视任何权限判断
*
* @return
*/
public static boolean isRoot() {
Set<String> roles = getRoles();
return roles.contains("ROOT");
}
}
注意:这里isRoot方面的ROOT,应该在角色的role_code 设置。
测试
@PreAuthorize("@ss.hasPerm('sys:user:ll')")
public Result findNodes() {
List<MenuVo> menusVo = menuService.findNodes();
return Result.success(menusVo);
这里我数据库并没有sys:user:ll权限,结果为:
@PreAuthorize("@ss.hasPerm('sys:user:select')")
public Result findNodes() {
List<MenuVo> menusVo = menuService.findNodes();
return Result.success(menusVo);
}
这里没有进行前端的对接,关注后续更新对接前端哦~~~