一般来说中大型的项目都是使用SpringSecurity 来做安全框架。小项目有Shiro的比较多,因为相比与SpringSecurity,Shiro的上手更加的简单。
一般Web应用的需要进行认证和授权。
认证:验证当前访问系统的是不是本系统的用户,并且要确认具体是哪个用户
授权:经过认证后判断当前用户是否有权限进行某个操作
而认证和授权也是SpringSecurity作为安全框架的核心功能。
我所学习的正是,认证和授权和流程。
另外默认SpringBoot、Redis会调用。
1.简单入门Demo
新建一个SpringBoot工程。这是使用的依赖。
<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><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency><!--其他必须依赖,下面需要的--><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency><dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-boot-starter</artifactId><version>3.5.3.1</version></dependency><dependency><groupId>mysql</groupId><artifactId>mysql-connector-java</artifactId></dependency><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId></dependency><dependency><groupId>com.alibaba</groupId><artifactId>fastjson</artifactId><version>1.2.58</version></dependency><dependency><groupId>com.fasterxml.jackson.datatype</groupId><artifactId>jackson-datatype-jdk8</artifactId></dependency><dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt</artifactId><version>0.9.1</version></dependency><dependency><groupId>org.apache.commons</groupId><artifactId>commons-pool2</artifactId></dependency><!-- 导入4个jar包,解决jdk9缺失jar包引起的报错--><!-- java.lang.NoClassDefFoundError: javax/xml/bind/DatatypeConverter--><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><dependency><groupId>javax.activation</groupId><artifactId>activation</artifactId><version>1.1.1</version></dependency>
启动之后,访问localhost:8888, 出现一下页面代表SpringSecurity生效了。
此时我们发现控制台生成:
我们向登录表单输入,这段密码和用户名user,即可登录通过。
2.认证
2.1 登录校验流程
当然,我们实际情况,可能会加一个redis做缓存,登录之后,用用户id -> 用户信息,存储到redis中。
这样我们登录之后,解析token,就是从redis查询。
2.2 SpringSecurity验证流程
图中只展示了核心过滤器,其它的非核心过滤器并没有在图中展示。
UsernamePasswordAuthenticationFilter:负责处理我们在登陆页面填写了用户名密码后的登陆请求。入门案例的认证工作主要有它负责。
ExceptionTranslationFilter:处理过滤器链中抛出的任何AccessDeniedException和AuthenticationException 。
FilterSecurityInterceptor:负责权限校验的过滤器。
2.3 认证流程详解
Authentication接口: 它的实现类,表示当前访问系统的用户,封装了用户相关信息。
AuthenticationManager接口:定义了认证Authentication的方法
UserDetailsService接口:加载用户特定数据的核心接口。里面定义了一个根据用户名查询用户信息的方法。
UserDetails接口:提供核心用户信息。通过UserDetailsService根据用户名获取处理的用户信息要封装成UserDetails对象返回。然后将这些信息封装到Authentication对象中。
2.4 思路分析
1. ...,在现在前后端分离项目,我们肯定是要自定义登录接口,不能用SpringSecurity的登录页面;
2. UserDetailService 是在内存中查找比较用户输入的用户名和密码,那我们需要是需要查询数据库比对的。
2.5 问题解决
2.5.0 前提
user.sql
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (`id` bigint NOT NULL COMMENT '主键',`user_name` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '用户名',`nick_name` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '昵称',`password` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '密码',`status` char(1) DEFAULT '0' COMMENT '账号状态(0正常 1停用)',`email` varchar(64) DEFAULT NULL COMMENT '邮箱',`phonenumber` varchar(32) DEFAULT NULL COMMENT '手机号',`sex` char(1) DEFAULT NULL COMMENT '用户性别(0男,1女,2未知)',`avatar` varchar(128) DEFAULT NULL COMMENT '头像',`user_type` char(1) NOT NULL DEFAULT '1' COMMENT '用户类型(0管理员,1普通用户)',`create_by` bigint DEFAULT NULL COMMENT '创建人的用户id',`create_time` datetime DEFAULT NULL COMMENT '创建时间',`update_by` bigint DEFAULT NULL COMMENT '更新人',`update_time` datetime DEFAULT NULL COMMENT '更新时间',`del_flag` int DEFAULT '0' COMMENT '删除标志(0代表未删除,1代表已删除)',PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='用户表';
application.yaml 中数据库配置
spring:datasource:url: jdbc:mysql://localhost:3306/test?characterEncoding=utf-8&serverTimezone=UTCusername: rootpassword: rootdriver-class-name: com.mysql.cj.jdbc.Driver
UserMapper
@Mapper
public interface UserMapper extends BaseMapper<User> {
}
User
@Data
@AllArgsConstructor
@NoArgsConstructor
@TableName(value = "user")
public class User {/*** 主键*/@TableIdprivate Long id;/*** 用户名*/private String userName;/*** 昵称*/private String nickName;/*** 密码*/private String password;/*** 账号状态(0正常 1停用)*/private String status;/*** 邮箱*/private String email;/*** 手机号*/private String phonenumber;/*** 用户性别(0男,1女,2未知)*/private String sex;/*** 头像*/private String avatar;/*** 用户类型(0管理员,1普通用户)*/private String userType;/*** 创建人的用户id*/private Long createBy;/*** 创建时间*/private Date createTime;/*** 更新人*/private Long updateBy;/*** 更新时间*/private Date updateTime;/*** 删除标志(0代表未删除,1代表已删除)*/private Integer delFlag;
}
UserDetailServiceImpl
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserDetailsImpl implements UserDetails {private User user;//private List<String> authList;////@JSONField(serialize = false)//private List<SimpleGrantedAuthority> authorities; // SimpleGrantedAuthority对象不支持序列化,无法存入redis//////public UserDetailsImpl(User user, List<String> authList) { // 将对应的权限字符串列表传入// this.user = user;// this.authList = authList;//}//@Overridepublic Collection<? extends GrantedAuthority> getAuthorities() {初始化之后,我们后续其他拦截器,也会获取; 没必要多次初始化;//if(authorities != null){// return authorities;//}else{// authorities = new ArrayList<>();//}//第一次登录,封装UserDetails对象,初始化权限列表//for (String auth : authList) {// SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(auth);// authorities.add(simpleGrantedAuthority); // 对,默认是个空的//}//return authorities;return null;}@Overridepublic String getPassword() {return user.getPassword();}@Overridepublic String getUsername() {return user.getUserName();}@Overridepublic boolean isAccountNonExpired() { // 下面bool值,全部响应为true,UserDetail对象返回校验过程中,会因为没权限报错return true;}@Overridepublic boolean isAccountNonLocked() {return true;}@Overridepublic boolean isCredentialsNonExpired() {return true;}@Overridepublic boolean isEnabled() {return true;}
}
RedisConfig : redis配置类
@Configuration
public class RedisConfig {@Bean@SuppressWarnings(value = { "unchecked", "rawtypes" })public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory){RedisTemplate<Object, Object> template = new RedisTemplate<>();template.setConnectionFactory(connectionFactory);FastJsonRedisSerializer serializer = new FastJsonRedisSerializer(Object.class);// 使用StringRedisSerializer来序列化和反序列化redis的key值template.setKeySerializer(new StringRedisSerializer());template.setValueSerializer(serializer);// Hash的key也采用StringRedisSerializer的序列化方式template.setHashKeySerializer(new StringRedisSerializer());template.setHashValueSerializer(serializer);template.afterPropertiesSet();return template;}
}
Result : 后端统一封装响应
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Result<T> {private T data;private String mes;private Integer code;public Result(String mes, Integer _code) {}public static <T> Result success(T data){return new Result(data,"操作成功",200);}public static <T> Result success(String _mes){return new Result(_mes,200);}public static <T> Result error(String _mes,Integer _code){return new Result(_mes,_code);}
}
JwtUtil 工具类
/*** JWT工具类*/
public class JwtUtil {//有效期为public static final Long JWT_TTL = 60 * 60 *1000L;// 60 * 60 *1000 一个小时//设置秘钥明文public static final String JWT_KEY = "sangeng";public static String getUUID(){String token = UUID.randomUUID().toString().replaceAll("-", "");return token;}/*** 生成jtw* @param subject token中要存放的数据(json格式)* @return*/public static String createJWT(String subject) {JwtBuilder builder = getJwtBuilder(subject, null, getUUID());// 设置过期时间return builder.compact();}/*** 生成jtw* @param subject token中要存放的数据(json格式)* @param ttlMillis token超时时间* @return*/public static String createJWT(String subject, Long ttlMillis) {JwtBuilder builder = getJwtBuilder(subject, ttlMillis, getUUID());// 设置过期时间return builder.compact();}private static JwtBuilder getJwtBuilder(String subject, Long ttlMillis, String uuid) {SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;SecretKey secretKey = generalKey();long nowMillis = System.currentTimeMillis();Date now = new Date(nowMillis);if(ttlMillis==null){ttlMillis=JwtUtil.JWT_TTL;}long expMillis = nowMillis + ttlMillis;Date expDate = new Date(expMillis);return Jwts.builder().setId(uuid) //唯一的ID.setSubject(subject) // 主题 可以是JSON数据.setIssuer("sg") // 签发者.setIssuedAt(now) // 签发时间.signWith(signatureAlgorithm, secretKey) //使用HS256对称加密算法签名, 第二个参数为秘钥.setExpiration(expDate);}/*** 创建token* @param id* @param subject* @param ttlMillis* @return*/public static String createJWT(String id, String subject, Long ttlMillis) {JwtBuilder builder = getJwtBuilder(subject, ttlMillis, id);// 设置过期时间return builder.compact();}public static void main(String[] args) throws Exception {
// String jwt = createJWT("2123");Claims claims = parseJWT("eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiIyOTY2ZGE3NGYyZGM0ZDAxOGU1OWYwNjBkYmZkMjZhMSIsInN1YiI6IjIiLCJpc3MiOiJzZyIsImlhdCI6MTYzOTk2MjU1MCwiZXhwIjoxNjM5OTY2MTUwfQ.NluqZnyJ0gHz-2wBIari2r3XpPp06UMn4JS2sWHILs0");String subject = claims.getSubject();System.out.println(subject);
// System.out.println(claims);}/*** 生成加密后的秘钥 secretKey* @return*/public static SecretKey generalKey() {byte[] encodedKey = Base64.getDecoder().decode(JwtUtil.JWT_KEY);SecretKey key = new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES");return key;}/*** 解析** @param jwt* @return* @throws Exception*/public static Claims parseJWT(String jwt) throws Exception {SecretKey secretKey = generalKey();return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwt).getBody();}}
FastJsonRedisSerializer
public class FastJsonRedisSerializer<T> implements RedisSerializer<T>
{public static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8");private Class<T> clazz;static{ParserConfig.getGlobalInstance().setAutoTypeSupport(true);}public FastJsonRedisSerializer(Class<T> clazz){super();this.clazz = clazz;}@Overridepublic byte[] serialize(T t) throws SerializationException{if (t == null){return new byte[0];}return JSON.toJSONString(t, SerializerFeature.WriteClassName).getBytes(DEFAULT_CHARSET);}@Overridepublic T deserialize(byte[] bytes) throws SerializationException{if (bytes == null || bytes.length <= 0){return null;}String str = new String(bytes, DEFAULT_CHARSET);return JSON.parseObject(str, clazz);}protected JavaType getJavaType(Class<?> clazz){return TypeFactory.defaultInstance().constructType(clazz);}
}
2.5.1 自定义UserDetailService
我们自定义UserDatailService 实现UserDatailService接口,注入到spring容器中,这样就会在SpringSecurity的认证流程中调用我们自定义的实现类。
@Service
public class UserDetailServiceImpl extends ServiceImpl< UserMapper, User> implements UserDetailsService {@Overridepublic UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {// 用用户名查询对应UserLambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();queryWrapper.eq(User::getUserName,username);// 判断是否空User user = this.getOne(queryWrapper);if(user == null){throw new RuntimeException("用户名或密码错误");}// 将查询到user封装到自定义UserDeatail中return new UserDetailsImpl(user);}
}
我们看下图,我们输入的用户名和密码,最终会传入UserDetailService 对象,加载loadUserByUsername 方法,返回UserDtail对象。返回过程中,会与UserDetail中的password和username进行比对,不同则报错。
注意:如果要测试,需要往用户表中写入用户数据,并且如果你想让用户的密码是明文存储,需要在密码前加{noop}。例如 : 数据库中,username:sg, password: {noop}1234
这样登陆的时候就可以用sg作为用户名,1234作为密码来登陆了。
2.5.2 密码加密存储
在实际中项目中,我一般不会明文存储,而是采用PasswordEncoder加密的方式;
我们一般使用SpringSecurity为我们提供的BCryptPasswordEncoder。
我们只需要使用把BCryptPasswordEncoder对象注入Spring容器中,SpringSecurity就会使用该PasswordEncoder来进行密码校验
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {@Beanpublic PasswordEncoder passwordEncoder(){return new BCryptPasswordEncoder();}}
2.5.3 自定义登录接口
- 接下我们需要自定义登陆接口,然后让SpringSecurity对这个接口放行,让用户访问这个接口的时候不用登录也能访问。
- 在接口中我们通过AuthenticationManager的authenticate方法来进行用户认证,所以需要在SecurityConfig中配置把AuthenticationManager注入容器。
- 认证成功的话要生成一个jwt,放入响应中返回。并且为了让用户下回请求时能通过jwt识别出具体的是哪个用户,我们需要把用户信息存入redis,可以把用户id作为key。
UserController
@RestController
@RequestMapping("/user")
public class UserController {@Autowiredprivate UserService userService;@PostMapping("/login")Result login(@RequestBody User user){return userService.login(user);}}
public interface UserService extends IService<User> {Result login(User user);
}
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {@Autowiredprivate AuthenticationManager authenticationManager;@Autowiredprivate RedisTemplate redisTemplate;@Overridepublic Result login(User user) {// AuthenticationManager authenticationManager 进行认证UsernamePasswordAuthenticationToken authenticationToken =new UsernamePasswordAuthenticationToken(user.getUserName(), user.getPassword());Authentication authenticate = authenticationManager.authenticate(authenticationToken);// 如果认证通过、给出对应提示if(Objects.isNull(authenticate)){throw new RuntimeException("登录失败");}// 认证通过使用userid生成jwtUserDetailsImpl udi = (UserDetailsImpl) authenticate.getPrincipal();String userId = udi.getUser().getId().toString();String token = JwtUtil.createJWT(userId);HashMap<String, String> map = new HashMap<>();map.put("token",token);// 把完整用户信息存入redisredisTemplate.opsForValue().set("login:" +userId,udi);return Result.success(map,"登录成功");}}
SpringSecurityConfig
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {@Beanpublic PasswordEncoder passwordEncoder(){return new BCryptPasswordEncoder();}@Overrideprotected void configure(HttpSecurity http) throws Exception {http//关闭csrf.csrf().disable()//不通过Session获取SecurityContext.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and().authorizeRequests()// 对于登录接口 允许匿名访问.antMatchers("/user/login").anonymous()// 除上面外的所有请求全部需要鉴权认证.anyRequest().authenticated();}@Bean@Overridepublic AuthenticationManager authenticationManagerBean() throws Exception {return super.authenticationManagerBean();}
}
spring:# mysql配置datasource:url: jdbc:mysql://localhost:3306/test?characterEncoding=utf-8&serverTimezone=UTCusername: rootpassword: 123456driver-class-name: com.mysql.cj.jdbc.Driver# redis配置redis:# 默认0库database: 0#连接超时时间timeout: 10000msport: 6379host: 192.168.213.136lettuce:pool:# 设置最大连接数max-active: 1024# 最大阻塞时间max-wait: 10000ms# 最大空间连接,默认8max-idle: 200# 最小空间连接,默认5min-idle: 5
server:port: 8888
2.5.4 自定义jwt过滤器
以下跨域设置,postman测试是完全没有问题的。但是,我们是为了前后端分离,会有问题。
当登录之后,携带token的请求头,被jwt过滤器捕获解析之后,获得userId,用userId将从redis拿出UserDetailImpl封装到 SecurityContextHolder.getContext()中,被SpringSecurity过滤链捕获到,证明没有问题,因此放行访问资源。
/*** jwt过滤器** @author: qhx20040819* @date: 2023-09-09 21:27**/
@Component
public class JwtFilter extends OncePerRequestFilter {@Autowiredprivate RedisTemplate redisTemplate;@Overrideprotected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {// 设置跨域response.setHeader("Access-Control-Allow-Origin", request.getHeader("Origin")); // 修改携带cookie,PSresponse.setHeader("Access-Control-Allow-Methods", "GET,POST,DELETE,PUT");response.setHeader("Access-Control-Allow-Headers", "Authorization,content-type"); // PS// 预检请求缓存时间(秒),即在这个时间内相同的预检请求不再发送,直接使用缓存结果。response.setHeader("Access-Control-Max-Age", "3600");//获取tokenString authorization = request.getHeader("Authorization");if(StringUtils.isEmpty(authorization)){filterChain.doFilter(request,response);return;}String token = authorization.substring(6);//解析tokenString userid;try {Claims claims = JwtUtil.parseJWT(token);userid = claims.getSubject();} catch (Exception e) {e.printStackTrace();throw new RuntimeException("token非法");}//从redis中获取用户信息String redisKey = "login:" + userid;UserDetailsImpl udi = (UserDetailsImpl) redisTemplate.opsForValue().get(redisKey);if(Objects.isNull(udi)){throw new RuntimeException("用户未登录");}//存入SecurityContextHolder//获取权限信息封装到Authentication中UsernamePasswordAuthenticationToken authenticationToken = // 现在权限字段是nullnew UsernamePasswordAuthenticationToken(udi,null,udi.getAuthorities());SecurityContextHolder.getContext().setAuthentication(authenticationToken);//放行filterChain.doFilter(request, response);}
}
3.授权
3.1 授权流程
在SpringSecurity中,会使用默认FiterSecuritInterceptor来进行权限校验,而它又是从SpringSecurityContex中Authentication,来获取其中的权限信息。判断当前用户是否拥有访问当前资源权限。
因此,我们只需要将权限信息存储到Authentication中即可,设置资源的访问权限。
3.2 问题解决
3.2.1 相关配置
用注解开启相关配置。
@EnableGlobalMethodSecurity(prePostEnabled = true)
在相应的资源上开启访问权限管控。
@RequestMapping("/hello")@PreAuthorize("hasAnyAuthority('test')") // 限制访问权限字段public String hello(){return "hello";}
3.2.2 封装权限字段
我们之前UserDetailServiceImpl中loadUserByUsername()中,返回的UserDetailImpl对象,我们之前创建时,并没传入权限字段,我们先自定义权限字段模拟用户权限列表。
@Service
public class UserDetailServiceImpl extends ServiceImpl< UserMapper, User> implements UserDetailsService {@Overridepublic UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {// 用用户名查询对应UserLambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();queryWrapper.eq(User::getUserName,username);// 判断是否空User user = this.getOne(queryWrapper);if(user == null){throw new RuntimeException("用户名或密码错误");}List<String> authlist = new ArrayList<>(); // 暂且封装这样权限字段authlist.add("test");authlist.add("test0");return new UserDetailsImpl(user,authlist);}
}
相应的UserDetailImpl中也需要修改,seucrity过滤器链是从getAuthorities()方法中来获取用户权限字段的。
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserDetailsImpl implements UserDetails {private User user;private List<String> authList;@JSONField(serialize = false)private List<SimpleGrantedAuthority> authorities; // SimpleGrantedAuthority对象不支持序列化,无法存入redispublic UserDetailsImpl(User user, List<String> authList) { // 将对应的权限字符串列表传入this.user = user;this.authList = authList;}@Overridepublic Collection<? extends GrantedAuthority> getAuthorities() {// 初始化之后,我们后续其他拦截器,也会获取; 没必要多次初始化;if(authorities != null){return authorities;}else{authorities = new ArrayList<>();}// 第一次登录,封装UserDetails对象,初始化权限列表for (String auth : authList) {SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(auth);authorities.add(simpleGrantedAuthority); // 对,默认是个空的}return authorities;}@Overridepublic String getPassword() {return user.getPassword();}@Overridepublic String getUsername() {return user.getUserName();}@Overridepublic boolean isAccountNonExpired() {return true;}@Overridepublic boolean isAccountNonLocked() {return true;}@Overridepublic boolean isCredentialsNonExpired() {return true;}@Overridepublic boolean isEnabled() {return true;}
}
这是拿着携带token去请求,能访问成功。
3.2.3 数据库查询权限字段
就是我们真实的权限应该封装在数据库中。
3.2.3.1 RBAC权限模型
一个用户,可以对应多个角色;一个角色,可以对应多个用户;
一个角色,可以拥有多个权限字段; 一个权限字段,可以被多个角色所拥有;
3.2.3.2 准备工作
USE `test`;/*Table structure for table `menu` */DROP TABLE IF EXISTS `menu`;CREATE TABLE `menu` (`id` bigint(20) NOT NULL AUTO_INCREMENT,`menu_name` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '菜单名',`path` varchar(200) DEFAULT NULL COMMENT '路由地址',`component` varchar(255) DEFAULT NULL COMMENT '组件路径',`visible` char(1) DEFAULT '0' COMMENT '菜单状态(0显示 1隐藏)',`status` char(1) DEFAULT '0' COMMENT '菜单状态(0正常 1停用)',`perms` varchar(100) DEFAULT NULL COMMENT '权限标识',`icon` varchar(100) DEFAULT '#' COMMENT '菜单图标',`create_by` bigint(20) DEFAULT NULL,`create_time` datetime DEFAULT NULL,`update_by` bigint(20) DEFAULT NULL,`update_time` datetime DEFAULT NULL,`del_flag` int(11) DEFAULT '0' COMMENT '是否删除(0未删除 1已删除)',`remark` varchar(500) DEFAULT NULL COMMENT '备注',PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COMMENT='菜单表';/*Table structure for table `role` */DROP TABLE IF EXISTS `role`;CREATE TABLE `role` (`id` bigint(20) NOT NULL AUTO_INCREMENT,`name` varchar(128) DEFAULT NULL,`role_key` varchar(100) DEFAULT NULL COMMENT '角色权限字符串',`status` char(1) DEFAULT '0' COMMENT '角色状态(0正常 1停用)',`del_flag` int(1) DEFAULT '0' COMMENT 'del_flag',`create_by` bigint(200) DEFAULT NULL,`create_time` datetime DEFAULT NULL,`update_by` bigint(200) DEFAULT NULL,`update_time` datetime DEFAULT NULL,`remark` varchar(500) DEFAULT NULL COMMENT '备注',PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COMMENT='角色表';/*Table structure for table `role_menu` */DROP TABLE IF EXISTS `role_menu`;CREATE TABLE `role_menu` (`role_id` bigint(200) NOT NULL AUTO_INCREMENT COMMENT '角色ID',`menu_id` bigint(200) NOT NULL DEFAULT '0' COMMENT '菜单id',PRIMARY KEY (`role_id`,`menu_id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4;/*Table structure for table `user` */DROP TABLE IF EXISTS `user`;CREATE TABLE `user` (`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',`user_name` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '用户名',`nick_name` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '昵称',`password` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '密码',`status` char(1) DEFAULT '0' COMMENT '账号状态(0正常 1停用)',`email` varchar(64) DEFAULT NULL COMMENT '邮箱',`phonenumber` varchar(32) DEFAULT NULL COMMENT '手机号',`sex` char(1) DEFAULT NULL COMMENT '用户性别(0男,1女,2未知)',`avatar` varchar(128) DEFAULT NULL COMMENT '头像',`user_type` char(1) NOT NULL DEFAULT '1' COMMENT '用户类型(0管理员,1普通用户)',`create_by` bigint(20) DEFAULT NULL COMMENT '创建人的用户id',`create_time` datetime DEFAULT NULL COMMENT '创建时间',`update_by` bigint(20) DEFAULT NULL COMMENT '更新人',`update_time` datetime DEFAULT NULL COMMENT '更新时间',`del_flag` int(11) DEFAULT '0' COMMENT '删除标志(0代表未删除,1代表已删除)',PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COMMENT='用户表';/*Table structure for table `user_role` */DROP TABLE IF EXISTS `user_role`;CREATE TABLE `user_role` (`user_id` bigint(200) NOT NULL AUTO_INCREMENT COMMENT '用户id',`role_id` bigint(200) NOT NULL DEFAULT '0' COMMENT '角色id',PRIMARY KEY (`user_id`,`role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
SELECT DISTINCT m.`perms`
FROMuser_role urLEFT JOIN `role` r ON ur.`role_id` = r.`id`LEFT JOIN `role_menu` rm ON ur.`role_id` = rm.`role_id`LEFT JOIN `menu` m ON m.`id` = rm.`menu_id`
WHEREuser_id = 2AND r.`status` = 0AND m.`status` = 0
@TableName(value="menu")
@Data
@AllArgsConstructor
@NoArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Menu implements Serializable {private static final long serialVersionUID = -54979041104113736L;@TableIdprivate Long id;/*** 菜单名*/private String menuName;/*** 路由地址*/private String path;/*** 组件路径*/private String component;/*** 菜单状态(0显示 1隐藏)*/private String visible;/*** 菜单状态(0正常 1停用)*/private String status;/*** 权限标识*/private String perms;/*** 菜单图标*/private String icon;private Long createBy;private Date createTime;private Long updateBy;private Date updateTime;/*** 是否删除(0未删除 1已删除)*/private Integer delFlag;/*** 备注*/private String remark;
}
3.2.3.3 代码实现
@Mapper
public interface MenuMapper extends BaseMapper<Menu> {List<String> selectPermsByUserId(Long userId);
}
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.qhx.springsecuritydemo.mapper.MenuMapper"><select id="selectPermsByUserId" resultType="java.lang.String">SELECTm.permsFROM`user_role` as urLEFT JOIN `role` as r on ur.role_id = r.idLEFT JOIN `role_menu` as rm on ur.role_id = rm.role_idLEFT JOIN `menu` as m on rm.menu_id = m.idWHEREur.`user_id` = #{userId} AND r.`status`=0 and m.`status`=0;</select></mapper>
修改UserDetailServiceImpl。
@Service
public class UserDetailServiceImpl extends ServiceImpl< UserMapper, User> implements UserDetailsService {@Autowiredprivate MenuMapper menuMapper;@Overridepublic UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {// 用用户名查询对应UserLambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();queryWrapper.eq(User::getUserName,username);// 判断是否空User user = this.getOne(queryWrapper);if(user == null){throw new RuntimeException("用户名或密码错误");}List<String> authList = menuMapper.selectPermsByUserId(user.getId());return new UserDetailsImpl(user,authlist);}
}
4.异常处理
4.1 SpringSecurity异常处理
我们发现,就算我们登录输入的用户名和错误的:
1.SpringSecurity没有抛出异常 2.也有没有相关响应提示信息;
我是并不希望这样,因此要添加自定义异常处理器:
/*** 授权异常处理器**/
@Component
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {@Overridepublic void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {Result result = Result.error("权限不足", 403);String jsonResult = JSON.toJSONString(result);response.setStatus(200);response.setContentType("application/json");response.getWriter().write(jsonResult);}
}
/*** 认证异常处理器**/
@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {@Overridepublic void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {Result result = Result.error("您认证错误/请检查你的用户名或密码是否正确", 401);String jsonResult = JSON.toJSONString(result);response.setStatus(200);response.setContentType("application/json");response.getWriter().write(jsonResult);}
}
在SecurityConfig配置中引入
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {@Autowiredprivate JwtFilter jwtFilter;@Beanpublic PasswordEncoder passwordEncoder(){return new BCryptPasswordEncoder();}@Autowiredprivate AccessDeniedHandlerImpl accessDeniedHandler;@Autowiredprivate AuthenticationEntryPoint authenticationEntryPoint;@Overrideprotected void configure(HttpSecurity http) throws Exception {http//关闭csrf.csrf().disable()//不通过Session获取SecurityContext.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and().authorizeRequests()// 对于登录接口 允许匿名访问.antMatchers("/user/login").anonymous()// 除上面外的所有请求全部需要鉴权认证.anyRequest().authenticated();// 添加自定义token过滤器到链中http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);// 添加自定义异常处理器http.exceptionHandling().accessDeniedHandler(accessDeniedHandler).authenticationEntryPoint(authenticationEntryPoint);}@Bean@Overridepublic AuthenticationManager authenticationManagerBean() throws Exception {return super.authenticationManagerBean();}
}
4.2 自定义异常处理
上面除了我们SpringSecurity异常,那么还有一些其他异常,我们不希望每次捕获,并手动抛出,因此可以转化为自定义异常,用SpringBoot的全局异常处理器捕获并且抛出;
5. 跨域
我们现在是用postman测试,但是未来项目是设计到前后端分离项目交互的,都伴随着跨域的问题。
后端能接受到前端原生request对象,response对象,都涉及到跨域的问题。
SpringSecurity解决跨域
SringMvc解决跨域
@Configuration
public class CorsConfig implements WebMvcConfigurer {@Overridepublic void addCorsMappings(CorsRegistry registry) {// 设置允许跨域的路径registry.addMapping("/**")// 设置允许跨域请求的域名.allowedOriginPatterns("*")// 是否允许cookie.allowCredentials(true)// 设置允许的请求方式.allowedMethods("GET", "POST", "DELETE", "PUT")// 设置允许的header属性.allowedHeaders("*")// 跨域允许时间.maxAge(3600);}
}
自定义jwt过滤器解决跨域