# SpringSecurity从入门到精通 ## 0.简介 **Spring Security** 是 Spring 家族中的一个安全管理框架。相比与另外一个安全框架**Shiro**,它提供了更丰富的功能,社区资源也比Shiro丰富。 一般来说中大型的项目都是使用**SpringSecurity** 来做安全框架。小项目有Shiro的比较多,因为相比与SpringSecurity,Shiro的上手更加的简单。 一般Web应用的需要进行**认证**和**授权**。 **认证:验证当前访问系统的是不是本系统的用户,并且要确认具体是哪个用户** **授权:经过认证后判断当前用户是否有权限进行某个操作** 而认证和授权也是SpringSecurity作为安全框架的核心功能。 ## 1. 快速入门 ### 1.1 准备工作 技术版本: ~~~ sprinboot3 mybatisplus3 redis6+ jwt mysql8+ springsecurity 6.2+ ~~~ 我们先要搭建一个简单的SpringBoot工程 ① 设置父工程 添加依赖 ~~~~xml <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.6.3</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies> ~~~~ ② 创建启动类 ~~~~java package cn.hxzy; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class SecurityApplication { public static void main(String[] args) { SpringApplication.run(SecurityApplication.class,args); } } ~~~~ ③ 创建Controller ~~~~java package cn.hxzy.controller; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController public class HelloController { @RequestMapping("/hello") public String hello(){ return "hello"; } } ~~~~ ### 1.2 引入SpringSecurity 在SpringBoot项目中使用SpringSecurity我们只需要引入依赖即可实现入门案例。 ~~~~xml <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> ~~~~ 引入依赖后我们在尝试去访问之前的接口就会自动跳转到一个SpringSecurity的默认登陆页面,默认用户名是user,密码会输出在控制台。 必须登陆之后才能对接口进行访问。 退出:输入logout即可。 ## 2. 认证 ### 2.1 登录校验流程 ![image-20211215094003288](SpringSecurity-从入门到精通.assets/1.png) ### 2.2 原理分析 想要知道如何实现自己的登陆流程就必须要先知道入门案例中SpringSecurity的流程。 #### 2.2.1 SpringSecurity完整流程 SpringSecurity的原理其实就是一个过滤器链,内部包含了提供各种功能的过滤器。这里我们可以看看入门案例中的过滤器。 ![123](SpringSecurity-从入门到精通.assets/2.png) 图中只展示了核心过滤器,其它的非核心过滤器并没有在图中展示。 **UsernamePasswordAuthenticationFilter**:负责处理我们在登陆页面填写了用户名密码后的登陆请求。入门案例的认证工作主要有它负责。 **ExceptionTranslationFilter:**处理过滤器链中抛出的任何AccessDeniedException和AuthenticationException 。 **FilterSecurityInterceptor:**负责权限校验的过滤器。通俗一点就是授权由它负责。(鉴权) 我们可以通过Debug查看当前系统中SpringSecurity过滤器链中有哪些过滤器及它们的顺序。 ![111](SpringSecurity-从入门到精通.assets/3.png) ![222](SpringSecurity-从入门到精通.assets/4.png) 输入:run.getBean(DefaultSecurityFilterChain.class),敲回车 ![333](SpringSecurity-从入门到精通.assets/5.png) #### 2.2.2 认证流程详解 ![image-20211214151515385](SpringSecurity-从入门到精通.assets/6.png) 概念速查: Authentication接口: 它的实现类,表示当前访问系统的用户,封装了用户相关信息。 ![555](SpringSecurity-从入门到精通.assets/7.png) AuthenticationManager接口:定义了认证Authentication的方法 UserDetailsService接口:加载用户特定数据的核心接口。里面定义了一个根据用户名查询用户信息的方法。 UserDetails接口:提供核心用户信息。通过UserDetailsService根据用户名获取处理的用户信息要封装成UserDetails对象返回。然后将这些信息封装到Authentication对象中。 ### 2.3 解决问题 #### 2.3.1 思路分析 `登录` ![8](SpringSecurity-从入门到精通.assets/8.png) ①自定义登录接口 调用ProviderManager的方法进行认证 如果认证通过生成jwt 把用户信息存入redis中 ②自定义UserDetailsService 在这个实现类中去查询数据库 `校验`: 思考:从JWT认证过滤器中获取到userid后怎么获取到完整的用户信息? ![9](SpringSecurity-从入门到精通.assets/9.png) 结论:如果认证通过,使用用户id生成一个jwt,然后用userid作为key,用户信息作为value存入redis,用户下一次访问的时候,到达JWT认证过滤器后,再去redis中就可以取到对应的用户信息(缓解一部分数据库的压力) 两种方案: 1.redis中存储jwt 2.不在redis中存储jwt,自解释 ①定义Jwt认证过滤器 获取token 解析token获取其中的userid 从redis中获取用户信息(可选) 存入SecurityContextHolder #### 2.3.2 添加依赖及配置文件 ~~~~xml <!--fastjson依赖--> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>1.2.33</version> </dependency> <!--jwt依赖--> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.0</version> </dependency> <!--web依赖--> <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> </dependency> <!--mybatisplus依赖--> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId> <version>3.5.6</version> </dependency> <!--mysql驱动依赖--> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid-spring-boot-starter</artifactId> <version>1.2.8</version> </dependency> <!--lombok依赖--> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </dependency> <!--validation依赖--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <!--redis坐标--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!--springdoc-openapi--> <dependency> <groupId>org.springdoc</groupId> <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId> <version>2.1.0</version> </dependency> <dependency> <groupId>org.springdoc</groupId> <artifactId>springdoc-openapi-starter-webmvc-api</artifactId> <version>2.1.0</version> </dependency> ~~~~ yml配置 ~~~yml server: port: 8001 #address: 127.0.0.1 #spring数据源配置 spring: application: name: token #项目名 # 数据源 datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/security_manager?serverTimezone=GMT%2B8&useUnicode=true&useSSL=false&characterEncoding=utf8 username: root password: root max-active: 20 max-wait: 5000 initial-size: 1 filters: stat,log4j,wall validationQuery: SELECT 'x' #验证连接 SQL心跳包 enable: true # redis 配置 redis: database: 0 host: 127.0.0.1 port: 6379 #password: 123456 lettuce: pool: #最大连接数 max-active: 8 #最大阻塞等待时间(负数表示没限制) max-wait: -1 #最大空闲 max-idle: 8 #最小空闲 min-idle: 0 #连接超时时间 timeout: 10000 # jackson 配置 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 # mybatis-plus配置 mybatis-plus: global-config: db-config: logic-delete-field: deleted # 全局逻辑删除的实体字段名(since 3.3.0,配置后可以忽略不配置步骤2) logic-delete-value: 1 # 逻辑已删除值(默认为 1) logic-not-delete-value: 0 # 逻辑未删除值(默认为 0) configuration: map-underscore-to-camel-case: true # 数据库下划线自动转驼峰标示关闭 log-impl: org.apache.ibatis.logging.stdout.StdOutImpl #日志配置 mapper-locations: classpath*:/mapper/**/*.xml ~~~ #### 2.3.3 添加Redis相关配置 ~~~~java package cn.hxzy.config; import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.parser.ParserConfig; import com.alibaba.fastjson.serializer.SerializerFeature; import org.springframework.data.redis.serializer.RedisSerializer; import org.springframework.data.redis.serializer.SerializationException; import java.nio.charset.Charset; /** * Redis使用FastJson序列化 * * @author mengshujun */ 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; } @Override public byte[] serialize(T t) throws SerializationException { if (t == null) { return new byte[0]; } return JSON.toJSONString(t, SerializerFeature.WriteClassName).getBytes(DEFAULT_CHARSET); } @Override public 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); } } ~~~~ ~~~~java package cn.hxzy.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.StringRedisSerializer; @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; } } ~~~~ #### 2.3.4 添加响应类 ~~~~java package cn.hxzy.common; import lombok.Data; import java.util.HashMap; import java.util.Map; @Data public class R { private Boolean success; //返回的成功或者失败的标识符 private Integer code; //返回的状态码 private String message; //提示信息 private Map<String, Object> data = new HashMap<String, Object>(); //数据 //把构造方法私有 private R() {} //成功的静态方法 public static R ok(){ R r=new R(); r.setSuccess(true); r.setCode(ResultCode.SUCCESS); r.setMessage("成功"); return r; } //失败的静态方法 public static R error(){ R r=new R(); r.setSuccess(false); r.setCode(ResultCode.ERROR); r.setMessage("失败"); return r; } //使用下面四个方法,方面以后使用链式编程 // R.ok().success(true) // r.message("ok).data("item",list) public R success(Boolean success){ this.setSuccess(success); return this; //当前对象 R.success(true).message("操作成功").code().data() } public R message(String message){ this.setMessage(message); return this; } public R code(Integer code){ this.setCode(code); return this; } public R data(String key, Object value){ this.data.put(key, value); return this; } public R data(Map<String, Object> map){ this.setData(map); return this; } } ~~~~ 接口 ~~~java package cn.hxzy.common; public interface ResultCode { Integer SUCCESS=20000; Integer ERROR=20001; } ~~~ #### 2.3.5 添加工具类-基于token的鉴权机制 ##### 1 什么是SSO SSO(Single Sign On),中文翻译为单点登录,它是目前流行的企业业务整合的解决方案之一,SSO的目标是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。 使用SSO整合后,只需要登录一次就可以进入多个系统,而不需要重新登录,这不仅仅带来了更好的用户体验,更重要的是降低了安全的风险和管理的消耗。 ##### 2 为什么要用SSO 现在流行的Web系统不断变的复杂,从最早的单系统模块发展到现在的多系统多模块的应用群,用户在访问这些群的各个系统的时候,是不是都要分别登录,登出?如果是这样,有N多个系统,用户可能会疯掉。用户希望的是在这些系统中统一的登录和登出,换句话说,不管登录哪个系统之后,其他子系统就无需再登录了。 举个实际应用的例子,比如京东的各个子系统,你会发现在浏览器不关闭的情况下,登录一个成功后,再访问另一个是无需再登录的,这种方式就是单点登录SSO的应用。 单点登录的实现方式,要实现单点登录,方式有很多,原理也各不相同,在这里主要讲主流的方案:使用JWT机制实现单点登录 。 token:uuid 后台返回token, 前端用什么保存? Cookie,Sessionstorage,Localstorage ##### 3 什么是JWT? 官网:https://jwt.io/introduction JSON Web Token令牌(JWT)是一种开放标准([RFC 7519](https://tools.ietf.org/html/rfc7519))它定义了一种紧凑且自包含的方式,用于在各方之间作为JSON对象安全地传输信息。此信息可以验证和信任,因为它是经过数字签名的。JWT可以使用密钥(使用**HMAC**算法)或使用**RSA**或**ECDSA**的公钥/私钥对进行签名。 **通俗的说**: JSON Web Token简称:JWT,也就是通过JSON形式作为Web应用的令牌,用于在各方之间安全地将信息作为JSON对象传输。在数据传输过程中还可以完成数据加密,签名等相关处理。 ##### 4 JWT的作用 JSON Web Token (JWT)是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准,它定义了一种紧凑的、自包含的方式,用于作为JSON对象在各方之间安全地传输信息。该信息可以被验证和信任,因为它是数字签名的。 ##### 5 JWT工作流程 Authorization (授权) : 这是使用JWT的最常见场景。一旦用户登录,后续每个请求都将包含JWT,允许用户访问该令牌允许的路由、服务和资源。 流程上是这样的: - 用户使用用户名密码来请求服务器 - 服务器进行验证用户的信息 - 服务器通过验证发送给用户一个token - 客户端存储token,并在每次请求时携带上这个token值 - 服务端验证token值,并返回数据 ##### 6 JWT长什么样? JWT是由三段信息构成的,将这三段信息文本用`.`链接一起就构成了Jwt字符串。就像这样: ~~~ eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ ~~~ ##### 7 JWT的构成 第一部分我们称它为头部(header) 第二部分我们称其为载荷(payload, 类似于飞机上承载的物品) 第三部分是签证(signature). **header ** jwt的头部承载两部分信息: - 声明类型,这里是jwt - 声明加密的算法 通常直接使用 HMAC SHA256 完整的头部就像下面这样的JSON: ~~~json { 'typ': 'JWT', 'alg': 'HS256' } ~~~ 然后将头部进行base64加密,构成了第一部分 ~~~ eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9 ~~~ **playload** 载荷就是存放有效信息的地方。这个名字像是特指飞机上承载的货品,这些有效信息包含三个部分 - 标准中注册的声明 - 公共的声明 - 私有的声明 **标准中注册的声明** (建议但不强制使用) : - **iss**: jwt签发者 - **sub**: jwt所面向的用户 - **aud**: 接收jwt的一方 - **exp**: jwt的过期时间,这个过期时间必须要大于签发时间 - **nbf**: 定义在什么时间之前,该jwt都是不可用的. - **iat**: jwt的签发时间 - **jti**: jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击。 **公共的声明** : 公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息.但不建议添加敏感信息,因为该部分在客户端可解密. **私有的声明** : 私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息,因为base64是对称解密的,意味着该部分信息可以归类为明文信息。 定义一个payload: 用户信息 ~~~java { "sub": "1234567890", "name": "mengshujun", "admin": true } ~~~ 然后将其进行base64加密,得到Jwt的第二部分。 ~~~ eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9 ~~~ **signature** jwt的第三部分是一个签证信息,这个签证信息由三部分组成: - header (base64后的) - payload (base64后的) - secret (盐-密钥) 这个部分需要base64加密后的header和base64加密后的payload使用`.`连接组成的字符串,然后通过header中声明的加密方式进行加盐`secret`组合加密,然后就构成了jwt的第三部分。 ~~~java // javascript var encodedString = base64UrlEncode(header) + '.' + base64UrlEncode(payload); var signature = HMACSHA256(encodedString, 'secret'); // TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ ~~~ 将这三部分用`.`连接成一个完整的字符串,构成了最终的jwt: 注意:secret是保存在服务器端的,jwt的签发生成也是在服务器端的,secret就是用来进行jwt的签发和jwt的验证,所以,它就是你服务端的私钥,在任何场景都不应该流露出去。一旦客户端得知这个secret, 那就意味着客户端是可以自我签发jwt了。 ~~~~java package cn.hxzy.utils; import io.jsonwebtoken.Claims; import io.jsonwebtoken.JwtBuilder; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import javax.crypto.SecretKey; import javax.crypto.spec.SecretKeySpec; import java.util.Base64; import java.util.Date; import java.util.UUID; /** * JWT工具类 */ public class JwtUtils { //有效期为 public static final Long JWT_TTL = 60 * 60 *1000L;// 60 * 60 *1000 一个小时 //设置秘钥明文(盐) public static final String JWT_KEY = "qW21YIU&^%$"; //生成令牌 public static String getUUID(){ String token = UUID.randomUUID().toString().replaceAll("-", ""); return token; } /** * 生成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(); } //生成jwt的业务逻辑代码 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("xx") // 签发者 .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(); } /** * 生成加密后的秘钥 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; } /** * 解析jwt * * @param jwt * @return * @throws Exception */ public static Claims parseJWT(String jwt) throws Exception { SecretKey secretKey = generalKey(); return Jwts.parser() .setSigningKey(secretKey) .parseClaimsJws(jwt) .getBody(); } } ~~~~ 添加依赖 ~~~xml <dependency> <groupId>javax.xml.bind</groupId> <artifactId>jaxb-api</artifactId> <version>2.3.1</version> </dependency> ~~~ 实体类 ~~~~java package cn.hxzy.entity; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import java.io.Serializable; import java.util.Date; /** * 用户表(User)实体类 * * @author mengshujun */ @Data @AllArgsConstructor @NoArgsConstructor @TableName("sys_user") public class User implements Serializable { private static final long serialVersionUID = -40356785423868312L; /** * 主键 */ @TableId private 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 deleted; } ~~~~ #### 2.3.6 实现 ##### 1 配置数据库校验登录用户 从之前的分析我们可以知道,我们可以自定义一个UserDetailsService,让SpringSecurity使用我们的UserDetailsService。我们自己的UserDetailsService可以从数据库中查询用户名和密码。 `准备工作` 我们先创建一个用户表, 建表语句如下: ~~~~mysql CREATE TABLE `sys_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 '更新时间', `deleted` INT(11) DEFAULT '0' COMMENT '删除标志(0代表未删除,1代表已删除)', PRIMARY KEY (`id`) ) ENGINE=INNODB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COMMENT='用户表' ~~~~ 定义Mapper接口 ~~~~java public interface UserMapper extends BaseMapper<User> { } ~~~~ 配置Mapper扫描 ~~~~java @MapperScan("cn.hxzy.mapper") ~~~~ 测试MP是否能正常使用 ~~~~java @SpringBootTest public class MapperTest { @Autowired private UserMapper userMapper; @Test public void testUserMapper(){ List<User> users = userMapper.selectList(null); System.out.println(users); } } ~~~~ `核心代码实现` 因为UserDetailsService方法的返回值是UserDetails类型,所以需要定义一个类,实现该接口,把用户信息封装在其中。 ```java package cn.hxzy.entity; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import java.util.Collection; @Data @NoArgsConstructor @AllArgsConstructor public class LoginUser implements UserDetails { private User user; //用来返回权限信息 @Override public Collection<? extends GrantedAuthority> getAuthorities() { return null; } //获取密码 @Override public String getPassword() { return user.getPassword(); } //获取用户名 @Override public String getUsername() { return user.getUserName(); } //判断账号是否未过期 @Override public boolean isAccountNonExpired() { return true; } //判断账号是否没有锁定 @Override public boolean isAccountNonLocked() { return true; } //判断账号是否没有超时 @Override public boolean isCredentialsNonExpired() { return true; } //判断账号是否可用 @Override public boolean isEnabled() { return true; } } ``` 创建一个类实现UserDetailsService接口,重写其中的方法。使用用户名从数据库中查询用户信息 ~~~~java package cn.hxzy.service.impl; import cn.hxzy.entity.LoginUser; import cn.hxzy.entity.User; import cn.hxzy.mapper.UserMapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; import java.util.Objects; @Service public class UserDetailsServiceImpl implements UserDetailsService { @Autowired private UserMapper userMapper; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { //查询用户信息 QueryWrapper<User> queryWrapper=new QueryWrapper<>(); queryWrapper.eq("user_name",username); User user = userMapper.selectOne(queryWrapper); //如果没有查询到用户,就抛出异常 if(Objects.isNull(user)){ throw new RuntimeException("用户名或密码错误"); } //TODO 查询用户对应的权限信息 //如果有,把数据封装成UserDetails对象返回 return new LoginUser(user); } } ~~~~ 注意:如果要测试,需要往用户表中写入用户数据,并且如果你想让用户的密码是明文存储,需要在密码前加{noop}。例如 ![10](SpringSecurity-从入门到精通.assets/10.png) 这样登陆的时候就可以用mengshujun作为用户名,123作为密码来登陆了。 ##### 2 密码加密存储 实际项目中我们不会把密码明文存储在数据库中。 默认使用的PasswordEncoder要求数据库中的密码格式为:{id}password 。它会根据id去判断密码的加密方式。但是我们一般不会采用这种方式。所以就需要替换PasswordEncoder。 我们一般使用SpringSecurity为我们提供的BCryptPasswordEncoder。 我们只需要使用把BCryptPasswordEncoder对象注入Spring容器中,SpringSecurity就会使用该PasswordEncoder来进行密码校验。 我们可以定义一个SpringSecurity的配置类,SpringSecurity要求这个配置类要继承WebSecurityConfigurerAdapter。 ~~~~java package cn.hxzy.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; @Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { //创建BCryptPasswordEncoder注入容器 @Bean public PasswordEncoder passwordEncoder(){ return new BCryptPasswordEncoder(); } } ~~~~ 测试: ~~~java package cn.hxzy; import cn.hxzy.entity.User; import cn.hxzy.mapper.UserMapper; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.security.crypto.password.PasswordEncoder; import java.util.List; @SpringBootTest public class Springsecurity01ApplicationTests { @Autowired private PasswordEncoder passwordEncoder; @Test public void testPassWord(){ //随机的盐 String encode1 = passwordEncoder.encode("123"); String encode2 = passwordEncoder.encode("123"); System.out.println(encode1); System.out.println(encode2); } } ~~~ 相同的明文,它内部使用了随机的盐,导致产生不同的密文。为了安全性 比较明文与加密后的密文 ~~~java @Test public void test1(){ boolean bool = passwordEncoder.matches("123", "$2a$10$OPRsPDxlBCOp6bQd3oyzq.WFA4BayXfdVzhrlaRRIaSR.DbAYvcli"); System.out.println(bool); } ~~~ ##### 3 自定义登陆接口 接下我们需要自定义登陆接口,然后让SpringSecurity对这个接口放行,让用户访问这个接口的时候不用登录也能访问。 在接口中我们通过AuthenticationManager的authenticate方法来进行用户认证,所以需要在SecurityConfig中配置把AuthenticationManager注入容器。 认证成功的话要生成一个jwt,放入响应中返回。并且为了让用户下回请求时能通过jwt识别出具体的是哪个用户,我们需要把用户信息存入redis,可以把用户id作为key。 ~~~~java package cn.hxzy.controller; import cn.hxzy.common.R; import cn.hxzy.entity.User; import cn.hxzy.service.UserService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/user") public class UserController { @Autowired private UserService userService; @RequestMapping("/login") public R login(@RequestBody User user) { //登录 return userService.login(user); } } ~~~~ 使用AuthenticationManager对象 ~~~java package cn.hxzy.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; @Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { //创建BCryptPasswordEncoder注入容器 @Bean public PasswordEncoder passwordEncoder(){ return new BCryptPasswordEncoder(); } @Bean //2、重写WebSecurity中的authenticationManagerBean来创建一个供咱们使用的AuthenticationManager对象 @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } } ~~~ 在AuthenticationManager中找到authenticate方法,查看参数authentication,在参数的类型上按住Ctrl+Alt点击左键,可以查看到这个接口的全部实现类列表,这里使用UsernamePasswordAuthenticationToken实现类 ![11](SpringSecurity-从入门到精通.assets/11.png) 接口放行的配置 ~~~java package cn.hxzy.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; @Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { //WebSecurityConfigurer<WebSecurity> //创建BCryptPasswordEncoder注入容器 @Bean public PasswordEncoder passwordEncoder(){ return new BCryptPasswordEncoder(); } //放行登录接口 @Override protected void configure(HttpSecurity http) throws Exception { http //关闭csrf .csrf().disable() //不通过Session获取SecurityContext .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() // 对于登录接口 允许匿名访问 .antMatchers("/user/login").anonymous() // 除上面外的所有请求全部需要鉴权认证 .anyRequest().authenticated(); } //把对应的AuthenticationManager注入到容器中 @Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } } ~~~ ~~~~java package cn.hxzy.service.impl; import cn.hxzy.common.R; import cn.hxzy.common.ResultCode; import cn.hxzy.entity.User; import cn.hxzy.entity.vo.LoginUser; import cn.hxzy.service.UserService; import cn.hxzy.utils.JwtUtil; import cn.hxzy.utils.RedisCache; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.stereotype.Service; import java.util.Objects; @Service public class UserServiceImpl implements UserService { @Autowired private AuthenticationManager authenticationManager; @Autowired private StringRedisTemplate stringRedisTemplate; @Override public R login(User user) { //不需要连接数据库 //把登录时候的用户名与密码封装成一个UsernamePasswordAuthenticationToken对象 UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getUserName(), user.getPassword()); //通过AuthenticationManager的authenticate方法来进行用户认证 Authentication authenticate = authenticationManager.authenticate(authenticationToken); //如果认证不通过,就返回自定义的异常 if (Objects.isNull(authenticate)) { throw new RuntimeException("登录失败"); } //如果认证成功,就从authenticate对象的getPrincipal方法中拿到认证通过后的登录用户对象 LoginUser loginUser = (LoginUser) authenticate.getPrincipal(); //得到用户编号 String userid = loginUser.getUser().getId().toString(); //生成令牌 String jwt = JwtUtil.createJWT(userid); //1、把登录用户存入redis stringRedisTemplate.opsForValue().set("login:"+userid,loginUser); //2、把jwt返回给调用者 return R.ok().code(ResultCode.SUCCESS).message("登录成功!").data("token",jwt); } } ~~~~ 打开postman进行测试 ![12](SpringSecurity-从入门到精通.assets/12.png) ##### 4 认证过滤器 我们需要自定义一个过滤器,这个过滤器会去获取请求头中的token,对token进行解析取出其中的userid。 使用userid去redis中获取对应的LoginUser对象。 然后封装Authentication对象存入SecurityContextHolder ~~~~java package cn.hxzy.filter; import cn.hxzy.entity.LoginUser; import cn.hxzy.utils.JwtUtil; import cn.hxzy.utils.RedisCache; import io.jsonwebtoken.Claims; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.Objects; @Component public class JwtAuthenticationTokenFilter extends OncePerRequestFilter { @Autowired private StringRedisTemplate stringRedisTemplate; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { //获取token String token = request.getHeader("token"); if (!StringUtils.hasText(token)) { //放行 try { filterChain.doFilter(request, response); } catch (IOException e) { e.printStackTrace(); } catch (ServletException e) { e.printStackTrace(); } return; } //解析token String userid=null; try { Claims claims = JwtUtil.parseJWT(token); userid = claims.getSubject(); } catch (Exception e) { e.printStackTrace(); throw new RuntimeException("token非法"); } //从redis中获取用户信息 String redisKey = "login:" + userid; LoginUser loginUser = stringRedisTemplate.opsForValue().get(redisKey); if(Objects.isNull(loginUser)){ throw new RuntimeException("用户未登录"); } //存入SecurityContextHolder //TODO 获取权限信息封装到Authentication中 UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser,null,null); SecurityContextHolder.getContext().setAuthentication(authenticationToken); //放行 filterChain.doFilter(request, response); } } ~~~~ 安全框架配置类: ~~~java package cn.hxzy.config; import cn.hxzy.filter.JwtAuthenticationTokenFilter; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { //创建BCryptPasswordEncoder注入容器 @Bean public PasswordEncoder passwordEncoder(){ return new BCryptPasswordEncoder(); } @Autowired private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter; //放行登录接口 @Override protected 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(jwtAuthenticationTokenFilter,UsernamePasswordAuthenticationFilter.class); } //把对应的AuthenticationManager注入到容器中 @Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } } ~~~ ##### 5 退出登陆 先去controller中定义退出的方法 ~~~java package cn.hxzy.controller; import cn.hxzy.common.R; import cn.hxzy.entity.User; import cn.hxzy.service.UserService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/user") public class UserController { @Autowired private UserService userService; @RequestMapping("/login") public R login(@RequestBody User user) { //登录 return userService.login(user); } @RequestMapping("/logout") public R logout(){ return userService.logout(); } } ~~~ 定义一个登出接口,然后获取SecurityContextHolder中的认证信息,删除redis中对应的数据即可。 ~~~~java package cn.hxzy.service.impl; import cn.hxzy.common.R; import cn.hxzy.entity.LoginUser; import cn.hxzy.entity.User; import cn.hxzy.service.UserService; import cn.hxzy.utils.JwtUtil; import cn.hxzy.utils.RedisCache; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; import java.util.Objects; @Service public class UserServiceImpl implements UserService { @Autowired private AuthenticationManager authenticationManager; @Autowired private StringRedisTemplate stringRedisTemplate; @Override public R login(User user) { //把登录时候的用户名与密码封装成一个UsernamePasswordAuthenticationToken对象 UsernamePasswordAuthenticationToken authenticationToken=new UsernamePasswordAuthenticationToken(user.getUserName(),user.getPassword()); //通过AuthenticationManager的authenticate方法来进行用户认证 Authentication authenticate = authenticationManager.authenticate(authenticationToken); //如果认证没通过,给出对应的提示 if(Objects.isNull(authenticate)){ throw new RuntimeException("登录失败"); } //如果认证通过了,使用userid生成一个jwt,存入R进行返回 LoginUser loginUser = (LoginUser) authenticate.getPrincipal(); String userid = loginUser.getUser().getId().toString(); String jwt = JwtUtil.createJWT(userid); //把完整的用户信息存入redis,userid作为key stringRedisTemplate.opsForValue().set("login:"+userid,loginUser); return R.ok().code(20000).message("登录成功").data("token",jwt); } //退出 @Override public R logout() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); LoginUser loginUser = (LoginUser) authentication.getPrincipal(); Long userid = loginUser.getUser().getId(); stringRedisTemplate.opsForValue().delete("login:"+userid); return R.ok().message("退出成功").code(20000); } } ~~~~ ## 3. 授权 ### 3.0 权限系统的作用 例如一个学校图书馆的管理系统,如果是普通学生登录就能看到借书还书相关的功能,不可能让他看到并且去使用添加书籍信息,删除书籍信息等功能。但是如果是一个图书馆管理员的账号登录了,应该就能看到并使用添加书籍信息,删除书籍信息等功能。 总结起来就是**不同的用户可以使用不同的功能**。这就是权限系统要去实现的效果。 我们不能只依赖前端去判断用户的权限来选择显示哪些菜单哪些按钮。因为如果只是这样,如果有人知道了对应功能的接口地址就可以不通过前端,直接去发送请求来实现相关功能操作。 所以我们还需要在后台进行用户权限的判断,判断当前用户是否有相应的权限,必须具有所需权限才能进行相应的操作。 ### 3.1 授权基本流程 在SpringSecurity中,会使用默认的FilterSecurityInterceptor来进行权限校验。在FilterSecurityInterceptor中会从SecurityContextHolder获取其中的Authentication,然后获取其中的权限信息。当前用户是否拥有访问当前资源所需的权限。 所以我们在项目中只需要把当前登录用户的权限信息也存入Authentication。然后设置我们的资源所需要的权限即可。 ### 3.2 授权实现 #### 3.2.1 限制访问资源所需权限 SpringSecurity为我们提供了基于注解的权限控制方案,这也是我们项目中主要采用的方式。我们可以使用注解去指定访问对应的资源所需的权限。 但是要使用它我们需要先开启相关配置。 ~~~~java @EnableGlobalMethodSecurity(prePostEnabled = true) ~~~~ 然后就可以使用对应的注解。@PreAuthorize ~~~~java @RestController public class HelloController { @RequestMapping("/hello") @PreAuthorize("hasAuthority('test')") public String hello(){ return "hello"; } } ~~~~ #### 3.2.2 封装权限信息 我们前面在写UserDetailsServiceImpl的时候说过,在查询出用户后还要获取对应的权限信息,封装到UserDetails中返回。 我们先直接把权限信息写死封装到UserDetails中进行测试。 LoginUser修改完后我们就可以在UserDetailsServiceImpl中去把权限信息封装到LoginUser中了。我们写死权限进行测试,后面我们再从数据库中查询权限信息。 ~~~~java package cn.hxzy.service.impl; import cn.hxzy.entity.LoginUser; import cn.hxzy.entity.User; import cn.hxzy.mapper.UserMapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Objects; @Service public class UserDetailsServiceImpl implements UserDetailsService { @Autowired private UserMapper userMapper; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { //查询用户信息 QueryWrapper<User> queryWrapper=new QueryWrapper<>(); queryWrapper.eq("user_name",username); User user = userMapper.selectOne(queryWrapper); //如果没有查询到用户,就抛出异常 if(Objects.isNull(user)){ throw new RuntimeException("用户名或密码错误"); } //TODO 查询用户对应的权限信息 List<String> list = new ArrayList<>(Arrays.asList("test","admin")); //如果有,把数据封装成UserDetails对象返回 return new LoginUser(user,list); } } ~~~~ 我们之前定义了UserDetails的实现类LoginUser,想要让其能封装权限信息就要对其进行修改。 ~~~~java package cn.hxzy.entity; import com.alibaba.fastjson.annotation.JSONField; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.stream.Collectors; @Data @NoArgsConstructor public class LoginUser implements UserDetails { private User user; //存储权限信息 private List<String> permissions; public LoginUser(User user,List<String> permissions) { this.user = user; this.permissions = permissions; } //存储SpringSecurity所需要的权限信息的集合 @JSONField(serialize = false) private List<SimpleGrantedAuthority> authorities; //用来返回权限信息 @Override public Collection<? extends GrantedAuthority> getAuthorities() { if(authorities!=null){ return authorities; } //把permissions中String类型的权限信息封装成SimpleGrantedAuthority对象 authorities = new ArrayList<>(); for(String permission:permissions){ SimpleGrantedAuthority authority=new SimpleGrantedAuthority(permission); authorities.add(authority); } return authorities; } //获取密码 @Override public String getPassword() { return user.getPassword(); } //获取用户名 @Override public String getUsername() { return user.getUserName(); } //判断账号是否过期 @Override public boolean isAccountNonExpired() { return true; } //判断账号是否没有锁定 @Override public boolean isAccountNonLocked() { return true; } //判断账号是否没有超时 @Override public boolean isCredentialsNonExpired() { return true; } //判断账号是否可用 @Override public boolean isEnabled() { return true; } } ~~~~ 然后在JwtAuthenticationTokenFilter认证过滤器中进行改造,添加从redis中取到用户后,再把用户认证通过后得到的权限信息列表设置进去loginUser.getAuthorities() ~~~java package cn.hxzy.filter; import cn.hxzy.entity.vo.LoginUser; import cn.hxzy.utils.JwtUtil; import cn.hxzy.utils.RedisCache; import io.jsonwebtoken.Claims; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.Objects; @Component public class JwtAuthenticationTokenFilter extends OncePerRequestFilter { @Autowired private RedisCache redisCache; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token = request.getHeader("token"); if (!StringUtils.hasText(token)) { //放行 try { filterChain.doFilter(request, response); } catch (IOException e) { e.printStackTrace(); } catch (ServletException e) { e.printStackTrace(); } return; } //如果请求头中携带了token String userId = null; try { Claims claims = JwtUtil.parseJWT(token); userId = claims.getSubject(); } catch (Exception e) { e.printStackTrace(); throw new RuntimeException("token非法"); } //从redis中去获取用户对象 String redisKey="login:"+userId; LoginUser loginUser = redisCache.getCacheObject(redisKey); //如果loginUser不存在,说明登录超时,(未登录的状态) if(Objects.isNull(loginUser)){ throw new RuntimeException("用户未登录"); } //存入SecurityContextHolder //TODO UsernamePasswordAuthenticationToken authenticationToken=new UsernamePasswordAuthenticationToken(loginUser,null,loginUser.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(authenticationToken); filterChain.doFilter(request, response); } } ~~~ #### 3.2.3 从数据库查询权限信息 ##### 3.2.3.1 RBAC权限模型 RBAC权限模型(Role-Based Access Control)即:基于角色的权限控制。这是目前最常被开发者使用也是相对易用、通用权限模型。 ![14](SpringSecurity-从入门到精通.assets/13.png) RBAC权限表设计 1、用户表 | 用户ID | 用户账号 | 用户密码 | | ------ | ------------ | -------- | | 1000 | mengshujun | | | 1001 | jiaxiaoliang | | 2、角色表 | 角色ID | 角色名称 | 角色说明 | | ------ | ----------- | ---------- | | 100 | super_admin | 超级管理员 | | 101 | admin_add | 添加管理员 | 3、用户角色表 | ID | 角色ID | 用户ID | | ---- | ------ | ------ | | 1 | 100 | 1000 | | 2 | 101 | 1001 | 4、角色权限表 | ID | 角色ID | 权限ID | | ---- | ------ | ------ | | 1 | 100 | 1 | | 2 | 100 | 2 | | 3 | 100 | 3 | | 4 | 100 | 4 | | 5 | 101 | 1 | 5、权限表 | 权限ID | 请求URL | 权限名 | 权限标识符 | | ------ | ----------- | -------- | ---------- | | 1 | /addUser | 新增用户 | addUser | | 2 | /updateUser | 修改用户 | updateUser | | 3 | /delUser | 删除用户 | delUser | | 4 | /showUser | 查询用户 | showUser | ##### 3.2.3.2 准备工作 MySQL在5.5.3之后增加了这个utf8mb4的编码,mb4就是most bytes 4的意思,专门用来兼容四字节的unicode。好在utf8mb4是utf8的超集,除了将编码改为utf8mb4外不需要做其他转换。当然,为了节省空间,一般情况下使用utf8也就够了。 ~~~~sql CREATE DATABASE /*!32312 IF NOT EXISTS*/`security_manager` /*!40100 DEFAULT CHARACTER SET utf8mb4 */; USE `security_manager`; /*Table structure for table `sys_menu` */ DROP TABLE IF EXISTS `sys_menu`; CREATE TABLE `sys_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 `sys_role` */ DROP TABLE IF EXISTS `sys_role`; CREATE TABLE `sys_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 `sys_role_menu` */ DROP TABLE IF EXISTS `sys_role_menu`; CREATE TABLE `sys_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 `sys_user` */ DROP TABLE IF EXISTS `sys_user`; CREATE TABLE `sys_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 `sys_user_role` */ DROP TABLE IF EXISTS `sys_user_role`; CREATE TABLE `sys_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; ~~~~ mysql查询权限SQL语句 ~~~mysql SELECT DISTINCT t4.`perms` FROM sys_user_role t1 LEFT JOIN `sys_role` t2 ON t1.`role_id` = t2.`id` LEFT JOIN `sys_role_menu` t3 ON t1.`role_id` = t3.`role_id` LEFT JOIN `sys_menu` t4 ON t4.`id` = t3.`menu_id` WHERE user_id = 2 AND t2.`status` = 0 AND t4.`status` = 0 ~~~ 创建Menu实体类 @JsonInclude用法 **JsonJsonInclude.Include.ALWAYS** 这个是默认策略,任何情况下都序列化该字段,和不写这个注解是一样的效 果。 **JsonJsonInclude.Include.NON_NULL**这个最常用,即如果加该注解的字段为null,那么就不序列化这个字段了。 作用在类上表明该类为NULL的字段不参加序列化! ~~~~java package cn.hxzy.entity; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import com.fasterxml.jackson.annotation.JsonInclude; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import java.io.Serializable; import java.util.Date; /** * 菜单表(Menu)实体类 */ @TableName(value="sys_menu") @Data @AllArgsConstructor @NoArgsConstructor @JsonInclude(JsonInclude.Include.NON_NULL) public class Menu implements Serializable { private static final long serialVersionUID = -54979041104113736L; @TableId private 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 代码实现 我们只需要根据用户id去查询到其所对应的权限信息即可。 所以我们可以先定义个mapper,其中提供一个方法可以根据userid查询权限信息。 ~~~~java package cn.hxzy.mapper; import cn.hxzy.entity.Menu; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Param; import org.springframework.stereotype.Repository; import java.util.List; @Repository public interface MenuMapper extends BaseMapper<Menu> { List<String> selectPermsByUserId(@Param("userId") Long userId); } ~~~~ 尤其是自定义方法,所以需要创建对应的mapper文件,定义对应的sql语句 ~~~~xml <?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="cn.hxzy.mapper.MenuMapper"> <select id="selectPermsByUserId" resultType="java.lang.String"> SELECT DISTINCT t4.`perms` FROM sys_user_role t1 LEFT JOIN `sys_role` t2 ON t1.`role_id` = t2.`id` LEFT JOIN `sys_role_menu` t3 ON t1.`role_id` = t3.`role_id` LEFT JOIN `sys_menu` t4 ON t4.`id` = t3.`menu_id` WHERE user_id = #{userId} AND t2.`status` = 0 AND t4.`status` = 0 </select> </mapper> ~~~~ 在application.yml中配置mapperXML文件的位置 ~~~~yaml mapper-locations: classpath*:/mapper/**/*.xml ~~~~ 测试MenuMapper ~~~java @Test public void testMenu(){ List<String> list = menuMapper.selectPermsByUserId(2L); for (String item:list) { System.out.println("item:"+item); } } ~~~ 然后我们可以在UserDetailsServiceImpl中去调用该mapper的方法查询权限信息封装到LoginUser对象中即可。 ~~~~java package cn.hxzy.service.impl; import cn.hxzy.entity.LoginUser; import cn.hxzy.entity.User; import cn.hxzy.mapper.MenuMapper; import cn.hxzy.mapper.UserMapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; import java.util.List; import java.util.Objects; @Service public class UserDetailsServiceImpl implements UserDetailsService { @Autowired private UserMapper userMapper; @Autowired private MenuMapper menuMapper; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { //查询用户信息 QueryWrapper<User> queryWrapper=new QueryWrapper<>(); queryWrapper.eq("user_name",username); User user = userMapper.selectOne(queryWrapper); //如果没有查询到用户,就抛出异常 if(Objects.isNull(user)){ throw new RuntimeException("用户名或密码错误"); } //TODO 查询用户对应的权限信息 //测试写法 //List<String> list = new ArrayList<>(Arrays.asList("test","admin")); List<String> list = menuMapper.selectPermsByUserId(user.getId()); //如果有,把数据封装成UserDetails对象返回 return new LoginUser(user,list); } } ~~~~ ## 4. 自定义失败处理 我们还希望在认证失败或者是授权失败的情况下也能和我们的接口一样返回相同结构的json,这样可以让前端能对响应进行统一的处理。要实现这个功能我们需要知道SpringSecurity的异常处理机制。 在SpringSecurity中,如果我们在认证或者授权的过程中出现了异常会被ExceptionTranslationFilter捕获到。在ExceptionTranslationFilter中会去判断是认证失败还是授权失败出现的异常。 如果是认证过程中出现的异常会被封装成AuthenticationException然后调用**AuthenticationEntryPoint**对象的方法去进行异常处理。 如果是授权过程中出现的异常会被封装成AccessDeniedException然后调用**AccessDeniedHandler**对象的方法去进行异常处理。 所以如果我们需要自定义异常处理,我们只需要自定义AuthenticationEntryPoint和AccessDeniedHandler然后配置给SpringSecurity即可。 #### 4.1自定义实现类 创建自定义的AuthenticationEntryPointImpl类 ~~~~java package cn.hxzy.handler; import cn.hxzy.common.R; import com.alibaba.fastjson.JSON; import org.springframework.http.HttpStatus; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.stereotype.Component; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; @Component public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { //处理异常 R result = R.error().message("用户认证失败,请重新登录").code(HttpStatus.UNAUTHORIZED.value()); String str = JSON.toJSONString(result); response.setStatus(200); response.setContentType("application/json"); response.setCharacterEncoding("utf-8"); response.getWriter().print(str); } } ~~~~ 创建自定义实现类AccessDeniedHandlerImpl ~~~~java package cn.hxzy.handler; import cn.hxzy.common.R; import com.alibaba.fastjson.JSON; import org.springframework.http.HttpStatus; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.stereotype.Component; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; @Component public class AccessDeniedHandlerImpl implements AccessDeniedHandler { @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { //处理异常 R result = R.error().message("您的权限不足").code(HttpStatus.FORBIDDEN.value()); String str = JSON.toJSONString(result); response.setStatus(200); response.setContentType("application/json"); response.setCharacterEncoding("utf-8"); response.getWriter().print(str); } } ~~~~ #### 4.2 配置给SpringSecurity 我们可以使用HttpSecurity对象的方法去配置。 ~~~~java //配置异常处理器 http.exceptionHandling() //配置认证失败处理器 .authenticationEntryPoint(authenticationEntryPoint) //配置授权认证失败处理器 .accessDeniedHandler(accessDeniedHandler); ~~~~ SecurityConfig完整代码如下: ~~~~java package cn.hxzy.config; import cn.hxzy.filter.JwtAuthenticationTokenFilter; import cn.hxzy.handler.AccessDeniedHandlerImpl; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration @EnableGlobalMethodSecurity(prePostEnabled = true) public class SecurityConfig extends WebSecurityConfigurerAdapter { //创建BCryptPasswordEncoder注入容器 @Bean public PasswordEncoder passwordEncoder(){ return new BCryptPasswordEncoder(); } @Autowired private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter; @Autowired private AuthenticationEntryPoint authenticationEntryPoint; @Autowired private AccessDeniedHandler accessDeniedHandler; //放行登录接口 @Override protected 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(jwtAuthenticationTokenFilter,UsernamePasswordAuthenticationFilter.class); //配置异常处理器 http.exceptionHandling() //配置认证失败处理器 .authenticationEntryPoint(authenticationEntryPoint) //配置授权失败处理器 .accessDeniedHandler(accessDeniedHandler); } //把对应的AuthenticationManager注入到容器中 @Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } } ~~~~ #### 4.3 测试认证的异常 ![14](SpringSecurity-从入门到精通.assets/14.png) 测试授权的异常 更改权限字符串 system:dept:list ![15](SpringSecurity-从入门到精通.assets/15.png) postman接口调试 ![16](SpringSecurity-从入门到精通.assets/16.png) ## 5. 跨域的配置 浏览器出于安全的考虑,使用 XMLHttpRequest对象发起 HTTP请求时必须遵守同源策略,否则就是跨域的HTTP请求,默认情况下是被禁止的。 同源策略要求源相同才能正常进行通信,即协议、域名、端口号都完全一致。 前后端分离项目,前端项目和后端项目一般都不是同源的,所以肯定会存在跨域请求的问题。 所以我们就要处理一下,让前端能进行跨域请求。 先对SpringBoot配置,运行跨域请求 ~~~~java @Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { // 设置允许跨域的路径 registry.addMapping("/**") // 设置允许跨域请求的域名 .allowedOriginPatterns("*") // 是否允许cookie .allowCredentials(true) // 设置允许的请求方式 .allowedMethods("GET", "POST", "DELETE", "PUT") // 设置允许的header属性 .allowedHeaders("*") // 跨域允许时间 .maxAge(3600); } } ~~~~ 开启SpringSecurity的跨域访问 由于我们的资源都会收到SpringSecurity的保护,所以想要跨域访问还要让SpringSecurity运行跨域访问。 ~~~~java package cn.hxzy.config; import cn.hxzy.filter.JwtAuthenticationTokenFilter; import cn.hxzy.handler.AccessDeniedHandlerImpl; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration @EnableGlobalMethodSecurity(prePostEnabled = true) public class SecurityConfig extends WebSecurityConfigurerAdapter { //创建BCryptPasswordEncoder注入容器 @Bean public PasswordEncoder passwordEncoder(){ return new BCryptPasswordEncoder(); } @Autowired private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter; @Autowired private AuthenticationEntryPoint authenticationEntryPoint; @Autowired private AccessDeniedHandler accessDeniedHandler; //放行登录接口 @Override protected 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(jwtAuthenticationTokenFilter,UsernamePasswordAuthenticationFilter.class); //配置异常处理器 http.exceptionHandling() //配置认证失败处理器 .authenticationEntryPoint(authenticationEntryPoint) //配置授权认证失败处理器 .accessDeniedHandler(accessDeniedHandler); //允许跨域 http.cors(); } //把对应的AuthenticationManager注入到容器中 @Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } } ~~~~
05-15
263
![](https://csdnimg.cn/release/blogv2/dist/pc/img/readCountWhite.png)
07-10
1564
![](https://csdnimg.cn/release/blogv2/dist/pc/img/readCountWhite.png)
07-12
527
![](https://csdnimg.cn/release/blogv2/dist/pc/img/readCountWhite.png)
07-11
384
![](https://csdnimg.cn/release/blogv2/dist/pc/img/readCountWhite.png)
“相关推荐”对你有帮助么?
-
非常没帮助
-
没帮助
-
一般
-
有帮助
-
非常有帮助
提交