简介
Spring Security是一个能够为基于Spring的企业应用系统提供声明式的安全访问控制解决方案的安全框架。它提供了一组可以在Spring应用上下文中配置的Bean,充分利用了Spring IoC,DI(控制反转Inversion of Control ,DI:Dependency Injection 依赖注入)和AOP(面向切面编程)功能,为应用系统提供声明式的安全访问控制功能,减少了为企业系统安全控制编写大量重复代码的工作。
这里需要知道两个概念:认证和授权
认证:验证访问当前系统的用户是不是本系统的用户,并且确认具体是哪一个用户
授权:判断经过认证后的用户是否有权限进行某个操作(比如一些页面的访问权限)
这两个概念就是贯穿Security的核心功能
初体验
准备工作
新建一个springboot项目,加入springweb、lombok、security依赖

编写测试接口
新建一个controller
@RestController public class TestController { @RequestMapping("/test") public String Hello(){ return "你好,security"; } }
启动服务器,访问这个路径,你会发现不像以前那样直接就能访问到页面,而是自动跳转到了一个登录页面,这是security自带的登录页面,比如登录过后才能访问页面
默认的账号是:user

密码在控制台

登录过后就会自动跳转到之前的页面了

如果想退出登录就访问logout

认证
登录校验
前后端分离项目中,登录校验流程图

那么如何实现自定义的登录流程呢?security已经帮我们实现了太多功能,但是怎么改成自己的呢?所以这里需要知道
security的完整流程

上图是一些核心过滤器,其实还有很多...
UsernamePasswordAuthenticationFilter
认证操作全靠这个过滤器,负责处理登录页面的登录请求,默认匹配URL为/login且必须为POST请求。
BasicAuthenticationFilter
此过滤器会自动解析HTTP请求中头部名字为Authentication,且以Basic开头的头信息。
ExceptionTranslationFilter
异常转换过滤器位于整个springSecurityFilterChain的后方,用来转换整个链路中出现的异常,处理过滤器链中抛出的任何AccessDeniedException和AuthenticationException
FilterSecurityInterceptor
获取所配置资源访问的授权信息,根据SecurityContextHolder中存储的用户信息来决定其是否有权限。
关于其他的过滤器可以通过debug查看

分析初体验流程

- 当用户提交账号密码过后,会到UsernamePassWordAutherticationFilter过滤器经过它过后会把这些信息封装成一个Authentication对象,这时最多只有用户名和密码,还没有权限
- 接着调用AuthenticationManager接口下面的ProviderManager实现类里面的authenticate()进行认证
- P的authenticate()里面又会调用DaoAuthenticationProvider里面的authenticate()进行认证
- D的authenticate()里面又会调用InMemoryUserDetailsManager里面的loadUserByUsername()查询用户,这个方法内部会根据用户名查询用户的信息和对应的权限,而这个方法是在内存当中去查询的用户信息,这肯定是不行的,正常情况下我们应该是在数据库里查询,所以后期这里需要修改,替换成自定义的接口从数据库查询数据
- 然后把查询出来的信息封装成一个UserDetails对象
- 通过PasswordEncoder对比UserDetails中的密码和Authentication的密码是否正确
- 如果正确就把UserDetails中的权限信息设置到Authentication对象中,最后返回Authentication对象给UsernamePasswordAuthenticationFilter,这里需要注意,我们没法在这个过滤器里面响应token给前端,所以后期需要改成自定义的一个接口
- 走到这里一切正常的话,就会把Authentication对象通过调用SecurityContextHolder.getContext().setAuthentication()储存,之后其他过滤器会通过SecurityCOntextHolder来获取当前的用户信息
- 总结:需要替换的地方就是UsernamePassWordAutherticationFilter和UserDetailsService
修改过后的流程图如下:

当然我们不能只考虑登录,还要考虑登录过后,怎么判断用户是否登录,因此还需要校验系统
比如:用户登录过后访问其他页面,怎么知道他是否已经登录了?他是否拥有访问这个页面的权利?
权限校验

所以这里需要自定义一个jwt认证过滤器,当用户发起请求到jwt认证过滤器,过滤器就需要做一些事情:
1.获取token
2.解析token
3.获取userid
4.封装Authentication对象存入SecurityContextHolder
之后其他过滤器就能从SecurityContextHolder中拿到用户的信息,就能知道他是否能够访问某个资源
这里思考一个问题:jwt认证过滤器通过解析token拿到了userid过后,怎么获取用户的信息呢?
ok,你们会说,根据id查数据库不就知道了?那么每一次请求都需要去访问数据库,遭得住不?
那肯定遭不住,所以需要引入缓存redis,从缓存里面拿用户的信息
那么,请问redis里面的用户信息是什么时候存进去的呢?
当用户登验证成功过后,不是要生产一个jwt么?在这个时候就可以把userid作为key,用户信息作为value存入reids
完整图:

总结
登录:
1.自定义UserDetailsService
* 实现从数据库查询数据
2.自定义登录接口
* 调用ProviderManager的authenticate()进行认证,如果认证通过生成jwt * 把用户信息存入redis
校验:
1.自定义jwt认证过滤器
* 获取token * 解析token获取其中的UserID * 从redis中获取用户信息 * 存入SecurityContextHolder中
准备工作
新建一个springboot项目
1.添加依赖
<!--redis依赖--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!--jackson依赖--> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> </dependency> <!--jwt依赖--> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency> <!--数据库依赖--> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> </dependency> <!--mybatisplus依赖--> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.1</version> </dependency>
2.引入各种类

基类
package cn.bs.securitystudy.pojo; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import java.io.Serializable; import java.util.Date; @Data @AllArgsConstructor @NoArgsConstructor @TableName("sec_user") public class User implements Serializable { /** * 主键 */ private Long id; /** * 用户名 */ private String userName; /** * 昵称 */ private String nickName; /** * 密码 */ private String password; /** * 邮箱 */ private String email; /** * 手机号 */ private String phonenumber; /** * 用户性别(0男,1女,2未知) */ private String gender; /** * 头像 */ private String avatar; /** * 用户类型(0管理员,1普通用户) */ private String userType; /** * 创建人的用户id */ private Long createBy; /** * 创建时间 */ private Date createTime; /** * 更新人 */ private Long updateBy; /** * 更新时间 */ private Date updateTime; /** * 账号状态(0正常 1停用) */ private String status; /** * 删除标志(0代表未删除,1代表已删除) */ private Integer delFlag; }
redis工具类
package cn.bs.securitystudy.util; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Component; import org.springframework.util.CollectionUtils; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.TimeUnit; @Component public final class RedisUtil { @Autowired private RedisTemplate redisTemplate; // =============================common============================ /** * 指定缓存失效时间 * * @param key 键 * @param time 时间(秒) */ public boolean expire(String key, long time) { try { if (time > 0) { redisTemplate.expire(key, time, TimeUnit.SECONDS); } return true; } catch (Exception e) { e.printStackTrace(); return false; } } /** * 根据key 获取过期时间 * * @param key 键 不能为null * @return 时间(秒) 返回0代表为永久有效 */ public long getExpire(String key) { return redisTemplate.getExpire(key, TimeUnit.SECONDS); } /** * 判断key是否存在 * * @param key 键 * @return true 存在 false不存在 */ public boolean hasKey(String key) { try { return redisTemplate.hasKey(key); } catch (Exception e) { e.printStackTrace(); return false; } } /** * 删除缓存 * * @param key 可以传一个值 或多个 */ @SuppressWarnings("unchecked") public void del(String... key) { if (key != null && key.length > 0) { if (key.length == 1) { redisTemplate.delete(key[0]); } else { redisTemplate.delete(CollectionUtils.arrayToList(key)); } } } // ============================String============================= /** * 普通缓存获取 * * @param key 键 * @return 值 */ public Object get(String key) { return key == null ? null : redisTemplate.opsForValue().get(key); } /** * 普通缓存放入 * * @param key 键 * @param value 值 * @return true成功 false失败 */ public boolean set(String key, Object value) { try { redisTemplate.opsForValue().set(key, value); return true; } catch (Exception e) { e.printStackTrace(); return false; } } /** * 普通缓存放入并设置时间 * * @param key 键 * @param value 值 * @param time 时间(秒) time要大于0 如果time小于等于0 将设置无限期 * @return true成功 false 失败 */ public boolean set(String key, Object value, long time) { try { if (time > 0) { redisTemplate.opsForValue().set(key, value, time, TimeUnit.SECONDS); } else { set(key, value); } return true; } catch (Exception e) { e.printStackTrace(); return false; } } /** * 递增 * * @param key 键 * @param delta 要增加几(大于0) */ public long incr(String key, long delta) { if (delta < 0) { throw new RuntimeException("递增因子必须大于0"); } return redisTemplate.opsForValue().increment(key, delta); } /** * 递减 * * @param key 键 * @param delta 要减少几(小于0) */ public long decr(String key, long delta) { if (delta < 0) { throw new RuntimeException("递减因子必须大于0"); } return redisTemplate.opsForValue().increment(key, -delta); } // ================================Map================================= /** * HashGet * * @param key 键 不能为null * @param item 项 不能为null */ public Object hget(String key, String item) { return redisTemplate.opsForHash().get(key, item); } /** * 获取hashKey对应的所有键值 * * @param key 键 * @return 对应的多个键值 */ public Map<Object, Object> hmget(String key) { return redisTemplate.opsForHash().entries(key); } /** * HashSet * * @param key 键 * @param map 对应多个键值 */ public boolean hmset(String key, Map<String, Object> map) { try { redisTemplate.opsForHash().putAll(key, map); return true; } catch (Exception e) { e.printStackTrace(); return false; } } /** * HashSet 并设置时间 * * @param key 键 * @param map 对应多个键值 * @param time 时间(秒) * @return true成功 false失败 */ public boolean hmset(String key, Map<String, Object> map, long time) { try { redisTemplate.opsForHash().putAll(key, map); if (time > 0) { expire(key, time); } return true; } catch (Exception e) { e.printStackTrace(); return false; } } /** * 向一张hash表中放入数据,如果不存在将创建 * * @param key 键 * @param item 项 * @param value 值 * @return true 成功 false失败 */ public boolean hset(String key, String item, Object value) { try { redisTemplate.opsForHash().put(key, item, value); return true; } catch (Exception e) { e.printStackTrace(); return false; } } /** * 向一张hash表中放入数据,如果不存在将创建 * * @param key 键 * @param item 项 * @param value 值 * @param time 时间(秒) 注意:如果已存在的hash表有时间,这里将会替换原有的时间 * @return true 成功 false失败 */ public boolean hset(String key, String item, Object value, long time) { try { redisTemplate.opsForHash().put(key, item, value); if (time > 0) { expire(key, time); } return true; } catch (Exception e) { e.printStackTrace(); return false; } } /** * 删除hash表中的值 * * @param key 键 不能为null* @param item 项 可以使多个 不能为null */ public void hdel(String key, Object... item) { redisTemplate.opsForHash().delete(key, item); } /** * 判断hash表中是否有该项的值 * * @param key 键 不能为null * @param item 项 不能为null * @return true 存在 false不存在 */ public boolean hHasKey(String key, String item) { return redisTemplate.opsForHash().hasKey(key, item); } /** * hash递增 如果不存在,就会创建一个 并把新增后的值返回 * * @param key 键 * @param item 项 * @param by 要增加几(大于0) */ public double hincr(String key, String item, double by) { return redisTemplate.opsForHash().increment(key, item, by); } /** * hash递减 * * @param key 键 * @param item 项 * @param by 要减少记(小于0) */ public double hdecr(String key, String item, double by) { return redisTemplate.opsForHash().increment(key, item, -by); } // ============================set============================= /** * 根据key获取Set中的所有值 * * @param key 键 */ public Set<Object> sGet(String key) { try { return redisTemplate.opsForSet().members(key); } catch (Exception e) { e.printStackTrace(); return null; } } /** * 根据value从一个set中查询,是否存在 * * @param key 键 * @param value 值 * @return true 存在 false不存在 */ public boolean sHasKey(String key, Object value) { try { return redisTemplate.opsForSet().isMember(key, value); } catch (Exception e) { e.printStackTrace(); return false; } } /** * 将数据放入set缓存 * * @param key 键 * @param values 值 可以是多个 * @return 成功个数 */ public long sSet(String key, Object... values) { try { return redisTemplate.opsForSet().add(key, values); } catch (Exception e) { e.printStackTrace(); return 0; } } /** * 将set数据放入缓存 * * @param key 键 * @param time 时间(秒) * @param values 值 可以是多个 * @return 成功个数 */ public long sSetAndTime(String key, long time, Object... values) { try { Long count = redisTemplate.opsForSet().add(key, values); if (time > 0) expire(key, time); return count; } catch (Exception e) { e.printStackTrace(); return 0; } } /** * 获取set缓存的长度* * * @param key 键 */ public long sGetSetSize(String key) { try { return redisTemplate.opsForSet().size(key); } catch (Exception e) { e.printStackTrace(); return 0; } } /** * 移除值为value的 * * @param key 键 * @param values 值 可以是多个 * @return 移除的个数 */ public long setRemove(String key, Object... values) { try { Long count = redisTemplate.opsForSet().remove(key, values); return count; } catch (Exception e) { e.printStackTrace(); return 0; } } // ===============================list================================= /** * 获取list缓存的内容 * * @param key 键 * @param start 开始 * @param end 结束 0 到 -1代表所有值 */ public List<Object> lGet(String key, long start, long end) { try { return redisTemplate.opsForList().range(key, start, end); } catch (Exception e) { e.printStackTrace(); return null; } } /** * 获取list缓存的长度 * * @param key 键 */ public long lGetListSize(String key) { try { return redisTemplate.opsForList().size(key); } catch (Exception e) { e.printStackTrace(); return 0; } } /** * 通过索引 获取list中的值 * * @param key 键 * @param index 索引 index>=0时, 0 表头,1 第二个元素,依次类推;index<0 * 时,-1,表尾,-2倒数第二个元素,依次类推 */ public Object lGetIndex(String key, long index) { try { return redisTemplate.opsForList().index(key, index); } catch (Exception e) { e.printStackTrace(); return null; } } /** * 将list放入缓存 * * @param key 键 * @param value 值 */ public boolean lSet(String key, Object value) { try { redisTemplate.opsForList().rightPush(key, value); return true; } catch (Exception e) { e.printStackTrace(); return false; } } /** * 将list放入缓存 * * @param key 键 * @param value 值 * @param time 时间(秒) */ public boolean lSet(String key, Object value, long time) { try { redisTemplate.opsForList().rightPush(key, value); if (time > 0) expire(key, time); return true; } catch (Exception e) { e.printStackTrace(); return false; } } /** * 将list放入缓存 * * @param key 键 * @param value 值 * @return */ public boolean lSet(String key, List<Object> value) { try { redisTemplate.opsForList().rightPushAll(key, value); return true; } catch (Exception e) { e.printStackTrace(); return false; } } /** * 将list放入缓存 * * @param key 键 * @param value 值 * @param time 时间(秒) * @return */ public boolean lSet(String key, List<Object> value, long time) { try { redisTemplate.opsForList().rightPushAll(key, value); if (time > 0) expire(key, time); return true; } catch (Exception e) { e.printStackTrace(); return false; } } /** * 根据索引修改list中的某条数据 * * @param key 键 * @param index 索引 * @param value 值 * @return */ public boolean lUpdateIndex(String key, long index, Object value) { try { redisTemplate.opsForList().set(key, index, value); return true; } catch (Exception e) { e.printStackTrace(); return false; } } /** * 移除N个值为value * * @param key 键 * @param count 移除多少个 * @param value 值 * @return 移除的个数 */ public long lRemove(String key, long count, Object value) { try { Long remove = redisTemplate.opsForList().remove(key, count, value); return remove; } catch (Exception e) { e.printStackTrace(); return 0; } } }
jwt工具类
package cn.bs.securitystudy.util; import io.jsonwebtoken.Claims; import io.jsonwebtoken.JwtBuilder; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import org.springframework.stereotype.Component; import javax.crypto.SecretKey; import javax.crypto.spec.SecretKeySpec; import java.util.Base64; import java.util.Date; import java.util.UUID; /** * JWT工具类 */ public class JwtUtil { //有效期为 public static final Long JWT_EXPIRE = 60 * 60 *1000L*24;// 一天 //设置秘钥明文 public static final String JWT_KEY = "cgb2202"; 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_EXPIRE; } long expMillis = nowMillis + ttlMillis; Date expDate = new Date(expMillis); return Jwts.builder() .setId(uuid) //唯一的ID .setSubject(subject) // 主题 可以是JSON数据 .setIssuer("bs") // 签发者 .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; } /** * 解析 * * @param jwt * @return * @throws Exception */ public static Claims parseJWT(String jwt) throws Exception { SecretKey secretKey = generalKey(); return Jwts.parser() .setSigningKey(secretKey) .parseClaimsJws(jwt) .getBody(); } }
web工具类
package cn.bs.securitystudy.util; import javax.servlet.http.HttpServletResponse; import java.io.IOException; public class WebUtils { /** * 将字符串渲染到客户端 * * @param response 渲染对象 * @param string 待渲染的字符串 * @return null */ public static String renderString(HttpServletResponse response, String string) { try { response.setStatus(200); response.setContentType("application/json"); response.setCharacterEncoding("utf-8"); response.getWriter().print(string); } catch (IOException e) { e.printStackTrace(); } return null; } }
RedisTemplate配置类
package cn.bs.securitystudy.config; import com.fasterxml.jackson.annotation.JsonAutoDetect; import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.annotation.PropertyAccessor; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator; 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.Jackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; @Configuration public class RedisTemplateConfig { //自定义一个RedisTemplate,框架直接去源码复制就行了,我们只需要改点东西 @Bean //抑制警告 @SuppressWarnings("all") public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) { //你可以把泛型改成<String,Object>,也可以不改,在使用的时候自己写 RedisTemplate<Object, Object> template = new RedisTemplate<>(); template.setConnectionFactory(redisConnectionFactory); //使用JSON格式序列化对象,对缓存数据key和value进行转换 Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class); //解决查询缓存转换异常的问题 ObjectMapper om = new ObjectMapper(); om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); om.activateDefaultTyping(LaissezFaireSubTypeValidator.instance , ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY); jackson2JsonRedisSerializer.setObjectMapper(om); //创建String的序列化方式 StringRedisSerializer stringRedisSerializer = new StringRedisSerializer(); //自定义RedisTemplate模板API的序列化方式为JSON //key采用String的序列化方式 template.setKeySerializer(stringRedisSerializer); //hash的key也采用String的序列化方式 template.setHashKeySerializer(stringRedisSerializer); //value序列化采用jackson template.setValueSerializer(jackson2JsonRedisSerializer); //hash的value序列化采用jackson template.setHashValueSerializer(jackson2JsonRedisSerializer); //载入properties template.afterPropertiesSet(); return template; } }
公共响应对象
package cn.bs.securitystudy.vo; import com.fasterxml.jackson.annotation.JsonInclude; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @Data @AllArgsConstructor @NoArgsConstructor //可以使当前实体类在返回前端的时候忽略字段属性为null的字段,使其为null字段不显示 @JsonInclude(JsonInclude.Include.NON_NULL) public class ResponseResult<T> { //状态码 private Integer code; //提示信息,如果有错误时,前端可以获取该字段进行提示 private String msg; //查询到的结果数据, private T data; }
3.创建数据库
表来自若依
CREATE TABLE `sec_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 '密码', `email` VARCHAR(64) DEFAULT NULL COMMENT '邮箱', `phonenumber` VARCHAR(32) DEFAULT NULL COMMENT '手机号', `gender` 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 '更新人的用户id', `update_time` DATETIME DEFAULT NULL COMMENT '更新时间', `status` CHAR(1) DEFAULT '0' COMMENT '账号状态(0正常 1停用)', `del_flag` INT(11) DEFAULT '0' COMMENT '删除标志(0代表未删除,1代表已删除)', PRIMARY KEY (`id`) ) ENGINE=INNODB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COMMENT='用户表'
添加yml配置信息
spring: datasource: url: jdbc:mysql:///securitydb?characterEncoding=utf-8&serverTimezone=UTC username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver
4.测试
1.新建一个Usermapper
public interface UserMapper extends BaseMapper<User> { }
2.主启动类设置包路径扫描
@SpringBootApplication @MapperScan("cn.bs.securitystudy.mapper") public class SecurityStudyApplication { public static void main(String[] args) { SpringApplication.run(SecurityStudyApplication.class, args); } }
3.随便测试一下
@Autowired private UserMapper userMapper; @Test void contextLoads() { User user = new User(); user.setUserName("z123456"); user.setNickName("老张"); user.setPassword("123456"); user.setEmail("123456@qq.com"); user.setPhonenumber("13123456789"); user.setGender("男"); userMapper.insert(user); List<User> users = userMapper.selectList(null); System.out.println(users); }
注意这里用的mybatis-plus,插入数据时它会自动生成id,该id是雪花算法生成的id,数据库id字段设置为bigint,所以属性的类型要用long,不然怕装不下,如果不想生成这么长的id就在属性上加个注解
@TableId(value = "id",type = IdType.AUTO)
这样自动生成的主键id就是正常位数。
数据库校验用户
根据之前的分析,这里需要自定义一个UserDetailsService来实现从数据库拿到用户的数据,所以新建一个
UserDetailsService实现类实现UserDetailsService

@Service public class UserDetailsServiceImpl implements UserDetailsService { @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { return null; } }
那么接下来就该写逻辑代码了
查询用户信息
UserDetailsServiceImpl
一般写法
@Service public class UserDetailsServiceImpl implements UserDetailsService { @Autowired private UserMapper userMapper; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { //查询用户信息 QueryWrapper<User> userQueryWrapper = new QueryWrapper<>(); userQueryWrapper.eq("user_name", username); User user = userMapper.selectOne(userQueryWrapper); //如果没有查询到用户就抛异常 if (user.equals(null)||user.equals("")) { throw new RuntimeException("用户名或者密码错误"); } //todo 查询用户权限 //把数据封装成UserDetails返回 return null; } }
高级写法
@Service public class UserDetailsServiceImpl implements UserDetailsService { @Autowired private UserMapper userMapper; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { //查询用户信息 LambdaQueryWrapper<User> userLambdaQueryWrapper = new LambdaQueryWrapper<>(); userLambdaQueryWrapper.eq(User::getUserName,username); User user = userMapper.selectOne(userLambdaQueryWrapper); //如果没有查询到用户就抛异常 if (Objects.isNull(user)){ throw new RuntimeException("用户名或者密码错误"); } //todo 查询用户权限 //把数据封装成UserDetails返回 return null; } }
这里需要一个UserDetails的返回值,所以现在新建一个类去实现UserDetails
因为这是用户登录信息,所以就在vo里面建个UserLogin实现UserDetails,注意这里要重写一堆方法,这里面的方法,在登录的时候,内部会自己去调用,由于现在并没有做权限,所以获取权限那块先空着
UserLogin
@Data @AllArgsConstructor @NoArgsConstructor public class UserLogin implements UserDetails { //要封装User信息,所以给个User 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; } }
接着去给返回值
UserDetailsServiceImpl
@Service public class UserDetailsServiceImpl implements UserDetailsService { @Autowired private UserMapper userMapper; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { //查询用户信息 LambdaQueryWrapper<User> userLambdaQueryWrapper = new LambdaQueryWrapper<>(); userLambdaQueryWrapper.eq(User::getUserName,username); User user = userMapper.selectOne(userLambdaQueryWrapper); //如果没有查询到用户就抛异常 if (Objects.isNull(user)){ throw new RuntimeException("用户名或者密码错误"); } //todo 查询用户权限 //把数据封装成UserDetails返回 return new UserLogin(user); } }
测试
由于现在我们并没有去修改默认的登录页面,所以测试的时候默认页面还可以用,启动服务器,访问之前的测试路径http://localhost:8080/test

不出意外会报错,应该说憋憋会报错

这是因为没有走PasswordEncoder校验,
它默认要求数据库中的密码格式为:{md5}password,它会根据md5加密方式去判断

一般来说,我们并不会用这种方式,所以需要替换PasswordEncoder
在这里,如果你想用没加密的原生密码测试,需要在密码那块加个
{noop}123456,noop的意思是无意义的,表示没有加密方式
记得保存,接下来测试就能成功了
修改加密方式
在实际开发当中,数据库里存的密码是不可能明文的,上面也说了一般来说不用PasswordEncoder
所以现在需要替换这个东西,一般我们使用的是SpringSecurity提供的BCryptPasswordEncoder
如何使用呢?
只需要把BCryptPasswordEncoder对象注入到Spring容器当中,SpringSecurity就会使用它来进行密码校验,因此这里需要定义一个SpringSecurity的配置类,它需要继承WebSecurityConfigurerAdapter
注意:在5.7的时候WebSecurityConfigurerAdapter已经被弃用了,如果是以前的版本,这么写

新版本就不用继承了

别乱放位置啊

现在可以来测试了,先不去改数据库的密码,测试登录,登录不上
然后去写个测试,生成加密后的密码存入数据库,再测试登录,就没问题了
@Autowired private BCryptPasswordEncoder bCryptPasswordEncoder; @Test void TestBCrypt(){ String encode = bCryptPasswordEncoder.encode("123456"); System.out.println(encode); }

JWT
什么是JWT
在介绍JWT之前,我们先来回顾一下利用token进行用户身份验证的流程:
- 客户端使用用户名和密码请求登录
- 服务端收到请求,验证用户名和密码
- 验证成功后,服务端会签发一个token,再把这个token返回给客户端
- 客户端收到token后可以把它存储起来,比如放到cookie中
- 客户端每次向服务端请求资源时需要携带服务端签发的token,可以在cookie或者header中携带
- 服务端收到请求,然后去验证客户端请求里面带着的token,如果验证成功,就向客户端返回请求数据
这种基于token的认证方式相比传统的session认证方式更节约服务器资源,并且对移动端和分布式更加友好。其优点如下:
- 支持跨域访问:cookie是无法跨域的,而token由于没有用到cookie(前提是将token放到请求头中),所以跨域后不会存在信息丢失问题
- 无状态:token机制在服务端不需要存储session信息,因为token自身包含了所有登录用户的信息,所以可以减轻服务端压力
- 更适用CDN:可以通过内容分发网络请求服务端的所有资料
- 更适用于移动端:当客户端是非浏览器平台时,cookie是不被支持的,此时采用token认证方式会简单很多
- 无需考虑CSRF:由于不再依赖cookie,所以采用token认证方式不会发生CSRF,所以也就无需考虑CSRF的防御
- 而JWT就是上述流程当中token的一种具体实现方式,其全称是JSON Web Token,官网地址:JSON Web Tokens - jwt.io
通俗地说,JWT的本质就是一个字符串,它是将用户信息保存到一个Json字符串中,然后进行编码后得到一个JWT token,并且这个JWT token带有签名信息,接收后可以校验是否被篡改,所以可以用于在各方之间安全地将信息作为Json对象传输。JWT的认证流程如下:
- 首先,前端通过Web表单将自己的用户名和密码发送到后端的接口,这个过程一般是一个POST请求。建议的方式是通过SSL加密的传输(HTTPS),从而避免敏感信息被嗅探
- 后端核对用户名和密码成功后,将包含用户信息的数据作为JWT的Payload,将其与JWT Header分别进行Base64编码拼接后签名,形成一个JWT Token,形成的JWT Token就是一个如同lll.zzz.xxx的字符串
- 后端将JWT Token字符串作为登录成功的结果返回给前端。前端可以将返回的结果保存在浏览器中,退出登录时删除保存的JWT Token即可
- 前端在每次请求时将JWT Token放入HTTP请求头中的Authorization属性中(解决XSS和XSRF问题)
- 后端检查前端传过来的JWT Token,验证其有效性,比如检查签名是否正确、是否过期、token的接收方是否是自己等等
- 验证通过后,后端解析出JWT Token中包含的用户信息,进行其他逻辑操作(一般是根据用户信息得到权限等),返回结果

JWT的作用
JWT最常用的地方就是在认证授权这一块,只要用户登录,之后的每一个前端请求都会包含JWT,后端在处理用户请求之前,都会先进行JWT安全校验,通过后才进行后续操作
JWT的组成
JWT由3个部分构成,用.隔开,就像下面这样
eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiJmNTJiZTRjYWZiMzg0MDRhYWQzYmJkODRjYmRhYTNlOSIsInN1YiI6IjEyMzQiLCJpc3MiOiJicyIsImlhdCI6MTY1NTEwNzQzMCwiZXhwIjoxNjU1MTkzODMwfQ.v6GmTU2mf4CAVi_dZUyNRpAyCETwddbR9ZZUg0EDE1E
反编译网站
这三部分分别是:
- Header //头信息
{ 'typ':'JWT',//token的类型 'alg':'HS256'//加密的算法 }
- Payload //载荷
{ "jti": "f52be4cafb38404aad3bbd84cbdaa3e9",//编号 "sub": "1234", "iss": "bs", "iat": 1655107430, "exp": 1655193830 }

请注意,默认情况下JWT是未加密的,因为只是采用base64算法,拿到JWT字符串后可以转换回原本的JSON数据,任何人都可以解读其内容,因此不要构建隐私信息字段,比如用户的密码一定不能保存到JWT中,以防止信息泄露。JWT只是适合在网络中传输一些非敏感的信息
- Signature
签名哈希部分是对上面两部分数据签名,需要使用base64编码后的header和payload数据,通过指定的算法生成哈希,以确保数据不会被篡改。首先,需要指定一个密钥(secret)。该密码仅仅为保存在服务器中,并且不能向用户公开。然后,使用header中指定的签名算法(默认情况下为HMAC SHA256)根据以下公式生成签名
HMACSHA256(base64UrlEncode(header)+"."+base64UrlEncode(payload),secret)
在计算出签名哈希后,JWT头,有效载荷和签名哈希的三个部分组合成一个字符串,每个部分用.分隔,就构成整个JWT对象
注意JWT每部分的作用,在服务端接收到客户端发送过来的JWT token之后:
- header和payload可以直接利用base64解码出原文,从header中获取哈希签名的算法,从payload中获取有效数据
- signature由于使用了不可逆的加密算法,无法解码出原文,它的作用是校验token有没有被篡改。服务端获取header中的加密算法之后,利用该算法加上secretKey对header、payload进行加密,比对加密后的数据和客户端发送过来的是否一致。注意secretKey只能保存在服务端,而且对于不同的加密算法其含义有所不同,一般对于MD5类型的摘要加密算法,secretKey实际上代表的是盐值
在实际开发中需要用下列手段来增加JWT的安全性:
- 因为JWT是在请求头中传递的,所以为了避免网络劫持,推荐使用HTTPS来传输,更加安全
- JWT的哈希签名的密钥是存放在服务端的,所以只要服务器不被攻破,理论上JWT是安全的。因此要保证服务器的安全
- JWT可以使用暴力穷举来破解,所以为了应对这种破解方式,可以定期更换服务端的哈希签名密钥(相当于盐值)。这样可以保证等破解结果出来了,你的密钥也已经换了
自定义登录接口
之前已经分析过我们需要自定义登陆接口,但是自定义的Controller会被Security拦截,比如写的测试接口,因此这里需要让Security对这个接口放行,让用户访问这个接口的时候不用登录也能访问。
在接口中通过AuthenticationManager的authenticate()来进行用户认证,所以需要在SecurityConfig中配置把AuthenticationManager注入容器。 认证成功的话要生成一个jwt,放入响应中返回。并且为了让用户下回请求时能通过jwt识别出具体的是哪个用户,我们需要把用户信息存入redis,可以把userid作为key。
接口放行
配置类再加个bean

老版写法
注:之后的老版写法和新版写法的区别都不再写类名
@Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http //基于token,不需要csrf,所以关闭 .csrf().disable() //不通Ssessio获取去SecurityContext .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS ).and() .authorizeRequests) // 把登录接口放行(允许匿名访问登录接口) .antMatchers("/login")anonyrousl() // 除了上述的接口可以匿名访问,其他地址的访问均需验证权限 .anyRequest().authenticatld(); }
新版写法
@Configuration public class SecurityConfig{ @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http //基于token,不需要csrf,所以关闭 .csrf().disable() //不通过session获取SecurityContext .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and() .authorizeRequests( authorize -> authorize // 请求放开 // 把登录接口放行(允许匿名访问登录接口) .antMatchers("/login").permitAll() // 除了上述的接口可以匿名访问,其他地址的访问均需验证权限 .anyRequest().authenticated()).build(); } }
contorller
//自定义登录接口 @RestController public class LoginController { @Autowired private LoginService loginService; @PostMapping("/login") public ResponseResult login(User user) { return loginService.login(user); } }
Service
public interface LoginService { //登录接口 ResponseResult login(User user); }
ServiceImpl
@Service public class LoginServiceImpl implements LoginService { @Override public ResponseResult login(User user) { //通过AuthenticationManager的authenticate方法进行认证 //如果认证没有通过,抛异常 //如果通过,就用userid生成一个jwt,jwt存入ResponseResult进行返回 //把完整的用户信息存入redis,userid作为key return null; } }
逻辑分析:
通过AuthenticationManager的authenticate方法进行认证
既然需要这个东西,所以我们需要去Security配置类里面,再去注入一个对象

老版写法
@Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); }
新版写法
@Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { return authenticationConfiguration.getAuthenticationManager(); }
完整代码
@Service public class LoginServiceImpl implements LoginService { @Autowired private AuthenticationManager authenticationManager; @Autowired private RedisUtil redisUtil; @Override public ResponseResult login(User user) { //通过下面这个实现类,手动把username和userpassword封装成Authentication对象 UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(user.getUserName(), user.getPassword()); //通过AuthenticationManager的authenticate方法进行认证 Authentication authenticate = authenticationManager.authenticate(usernamePasswordAuthenticationToken); //如果认证没有通过,抛异常 if (Objects.isNull(authenticate)){ throw new RuntimeException("没得这个人"); } //如果通过,就用userid生成一个jwt,jwt存入ResponseResult进行返回 //通过getPrincipal方法可以拿到,之前UserDetails封装的内容,这里知道是用户的登录信息,所以直接强转 UserLogin userLogin = (UserLogin) authenticate.getPrincipal(); String userid = userLogin.getUser().getId().toString(); String jwt = JwtUtil.createJWT(userid); HashMap hashMap = new HashMap<>(); hashMap.put("token",jwt); //把完整的用户信息存入redis,userid作为key redisUtil.set("login:"+userid,userLogin); return new ResponseResult(200,"登录成功",hashMap); } }
yml添加redis配置,启动redis,然后开始测试
测试接口

成功,redis也存入了数据

从redis拿数据判断
自定义jwt认证过滤器
继承OncePerRequestFilter保证请求过来只会经过一次filter
大家常识上都认为,一次请求本来就只过一次,为什么还要由此特别限定呢,实际上我们常识和实际的实现并不真的一样,经过一番查阅后,此方式是为了兼容不同的web container,特意而为之(jsr168),也就是说并不是所有的container都像我们期望的只过滤一次,servlet版本不同,表现也不同
@Component public class JwtAuthenticationTokenFilter extends OncePerRequestFilter { @Autowired private RedisUtil redisUtil; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { //获取token String token = request.getHeader("token"); //如果token为空就不让它执行if下面的代码了 if (StringUtils.isEmpty(token)) { //放行 //这里放行过后,后面还有filterSercurityInterceptor过滤器会去判断是否认证 filterChain.doFilter(request, response); //不让没通过filterSercurityInterceptor过滤器的请求再次去执行下面的代码,所以return return; } //解析token String userid; try { Claims claims = JwtUtil.parseJWT(token); userid = claims.getSubject(); } catch (Exception e) { throw new RuntimeException("不是真正的token"); } //从redis中获取用户信息 String reidsKey = "login:" + userid; UserLogin userLogin = (UserLogin) redisUtil.get(reidsKey); if(Objects.isNull(userLogin)){ throw new RuntimeException("你还没有登录"); } //把用户信息存入SecurityContextHolder //这里还是用Username..这个东西来封装成Authentication对象,不过和之前的只传账号,密码不一样,这里选择的是三个参数的重载方法 //好处在于,三个参数的方法最后会给把认证改成true,点源代码就能看见 UsernamePasswordAuthenticationToken userToken = new UsernamePasswordAuthenticationToken( userLogin,null //权限信息还没有获取,所以现在先传个null //todo 给权限 , null); SecurityContextHolder.getContext().setAuthentication(userToken); //放行 filterChain.doFilter(request, response); }
配置过滤器的位置
@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http //基于token,不需要csrf,所以关闭 .csrf().disable() //不通过session过去SecurityContext .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and() .authorizeRequests(authorize -> authorize // 请求放开 // 把登录接口放行(允许匿名访问登录接口) .antMatchers("/login").permitAll() // 除了上述的接口可以匿名访问,其他地址的访问均需验证权限 .anyRequest().authenticated()) //配置自定义的JwtAuthenticationToken这个过滤器的位置 .addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class).build(); }
退出登录
- 从SecurityContextHolder中拿到userid
- 根据userid从redis中移除信息
这里不需要从SecurityContextHolder中移出信息,因为如果登出过后,下一次登录进来的请求它会经过一次JwtAuthenticationTokenFilter过滤器,然后触发没有登录

到了这里根本从redis中拿不到信息,所以后面的代码不会走,也不会存信息到SecurityContextHolder中
Controller
@RestController @RequestMapping("/user") public class LoginController { @Autowired private LoginService loginService; //自定义登录接口 @PostMapping("/login") public ResponseResult login(User user) { return loginService.login(user); } //退出登录 @GetMapping("/logout") public ResponseResult logout() { return loginService.logout(); } }
Service
public interface LoginService { //登录接口 ResponseResult login(User user); //退出登录 ResponseResult logout(); }
ServiceImpl
//退出登录 @Override public ResponseResult logout() { //获取SecurityContextHolder中的userid UsernamePasswordAuthenticationToken authentication = (UsernamePasswordAuthenticationToken) SecurityContextHolder.getContext().getAuthentication(); UserLogin userLogin = (UserLogin) authentication.getPrincipal(); Long id = userLogin.getUser().getId(); //从redis中删除信息 redisUtil.del("login:"+id); return new ResponseResult(200,"注销成功"); }
测试
先登录

请求头携带token访问logout退出登录
报错:

原因:
json序列化时,不仅是根据get方法来序列化的,而是实体类中所有的有返回值的方法都会将返回的值序列化,但是反序列化时是根据set方法来实现的,所以当实体类中有非get,set方法的方法有返回值时,反序列化时就会出错。
在整合security时自定义登录认证和权限认证时需要继承UserDetail,就必须有其他方法,这时候可以RedisTemplate的设置里加

om.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
所以去RedisTemplateConfig加个配置
再去测试,还是报错

说get请求不支持,然后你就开始疑问

我这里不就是写的GetMapping吗?咋会不支持?于是你开始尝试RequstMapping,结果还是不行,然后开始百度,发现都是没写对请求方式,你明明又是写对了的,于是开始自闭,开始摆烂
造成这个问题的原因在于,你访问logout的时候,它会自动在请求前面给你拼接一个login,请求方式就变成了下面这个,所以不管你怎么改都不行
http://localhost:8080/login?logout
解决办法:
在Controller前面加个路径就行了
当然你也可以在logout接口前面多写一个路径

最后再去配置类里放行

再次测试,成功

授权
不同的用户有不同的权限,即管理员可以看一些页面用一些功能,普通用户却不能
基本流程
前面已经说过了,当请求到了FilterSecurtyInterceptor这个拦截器这里,会被它拦截下来进行权限校验,FilterSecurityInterceptor会从SecurityContextHolder中获取Authentication,然后从这里面获取权限信息,判断当前用户是否拥有访问该资源的权限.
因此,之后我们就需要
- 把用户的权限信息也存入Authentication当中,
- 然后设置访问该资源所需要的权限就可以了
设置权限
首先去配置类使用这个注解,开启相关配置

@EnableGlobalMethodSecurity(prePostEnabled = true)
然后就可以在接口上使用下面这个注解设置权限了

@PreAuthorize("hasAuthority('xxx')")
比如我在测试接口上面加上了一个叫test的权限,之后如果用户没有test权限就不能访问这个接口
封装权限信息
所以现在就要给用户权限了,找到之前需要权限的地方

首先肯定是要先查询权限,这里先不做从数据库查询,为了测试,手动给权限
1.打开UserDetailsServiceImpl,手动给些权限用集合封装,当然你得包含test
//todo 查询用户权限 List<String> test = Arrays.asList("test","hello","world"); //把数据封装成UserDetails返回 return new UserLogin(user,test);
2.去UserLogin里面修改代码
首先声明一个权限属性Permission//权限 private List<String> permissions;
这样前面传进来的权限就可以通过构造函数赋值了
3.重写getAuthorities()方法,之前因为没有权限所以返回的时候null值,之后就可以通过调用这个方法拿到权限

注意,这个方法的返回值类型是GrantedAuthority接口,因此我们需要去找一个它的实现类来用,按ctr+alt再点一下就能看有哪些实现类

这里选择SimpleGrantedAuthority,因为它就是授权权限的基本具体实现
//获取权限 @Override public Collection<? extends GrantedAuthority> getAuthorities() { //通过调用SimpleGrantedAuthority的构造函数把permission丢进去,然后存入新的集合 List<SimpleGrantedAuthority> authorities = permissions.stream() .map(SimpleGrantedAuthority::new) .collect(Collectors.toList()); return authorities; }
要是看不懂stream流就用遍历
List<SimpleGrantedAuthority> authorities = new ArrayList<>(); for (String permission : permissions) { SimpleGrantedAuthority authority = new SimpleGrantedAuthority(permission); authorities.add(authority); }
如果这么写的话需要考虑一个问题,
就是登录过后下次访问(不是第一次访问)进来的时候,它已经有了权限,是不是不用再去通过getAuthorities()方法里面的流程封装了?可以直接返回它增加效率
所以这里就需要提前声明一个成员变量
@JsonIgnore private List<SimpleGrantedAuthority> authorities;
加这个注解的意义在于,redis为了安全考虑内部不会序列化SimpleGrantedAuthority这个对象,因此加上这个注解,让redis忽略它免得报错
@Data @AllArgsConstructor @NoArgsConstructor public class UserLogin implements UserDetails { //要封装User信息,所以给个User private User user; //权限 private List<String> permissions; public UserLogin(User user, List<String> permissions) { this.user = user; this.permissions = permissions; } @JsonIgnore private List<SimpleGrantedAuthority> authorities; //获取权限 @Override public Collection<? extends GrantedAuthority> getAuthorities() { if (authorities!=null){ return authorities; } //通过调用SimpleGrantedAuthority的构造函数把permission丢进去,然后存入新的集合 authorities = permissions.stream() .map(SimpleGrantedAuthority::new) .collect(Collectors.toList()); return authorities; }
现在权限已经封装好了,接下来就去JwtAuthenticationTokenFilter里面拿权限了

UsernamePasswordAuthenticationToken userToken = new UsernamePasswordAuthenticationToken( userLogin,null //权限信息还没有获取,所以现在先传个null //todo 给权限 , userLogin.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(userToken); //放行 filterChain.doFilter(request, response);
到了这里权限就搞完了,可以测试一下
测试
先登录

接着携带token访问test接口

修改权限再测一次
把test删掉


访问不了了
到这里测试完成
刚刚给用户的权限是写死了的,这肯定不行,我们要去从数据库拿数据才行,所以接下来搞
RBAC权限模型
RBAC模型(Role-Based Access Control:基于角色的访问控制)
目前最常用的权限模型
RBAC模型中的一些名词: User(用户):每个用户都有唯一的UID识别,并被授予不同的角色; Role(角色):不同角色具有不同权限; Permission(权限):访问权限; 用户-角色映射:用户和角色之间的映射关系; 角色-权限映射:角色和权限之间的映射关系。
说白了就是五张表

建表
表来自若依
用户表就用之前的
再插入一条数据
INSERT INTO `sec_user` VALUES (2, 'lw', '老王', '$2a$10$E9ac/PLHi1oLwzkT59JHxu4BRgx7hVd6ulGHMKW1h3g4AxJU6ojMq', '1234567@qq.com', '13223456789', '男', NULL, '1', NULL, NULL, NULL, NULL, '0', 0);
权限表(菜单表)
CREATE TABLE `sec_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='权限表';
插入数据
INSERT INTO `sec_menu` VALUES (1, '上传电影', '', '', '0', '0', 'sys:movie:up', '#', NULL, NULL, NULL, NULL, 0, NULL); INSERT INTO `sec_menu` VALUES (2, '看电影','','', '0', '0', 'sys:movie:see', '#', NULL, NULL, NULL, NULL, 0, NULL);
角色表
CREATE TABLE `sec_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='角色表';
插入数据
INSERT INTO `sec_role` VALUES (1, '管理员', 'admin', '0', 0, NULL, NULL, NULL, NULL, NULL); INSERT INTO `sec_role` VALUES (2, '用户', 'user', '0', 0, NULL, NULL, NULL, NULL, NULL);
角色权限表
CREATE TABLE `sec_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;
插入数据
INSERT INTO `sec_role_menu` VALUES (1, 1); INSERT INTO `sec_role_menu` VALUES (1, 2); INSERT INTO `sec_role_menu` VALUES (2, 2);
用户角色表
CREATE TABLE `sec_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;
插入数据
INSERT INTO `sec_user_role` VALUES (1, 1); INSERT INTO `sec_user_role` VALUES (2, 2);
测试
根据userid查询对应的角色和权限信息,状态必须可用 status=0
SELECT * FROM sec_user_role AS sur -- 通过用户id查询对应的角色信息 LEFT JOIN sec_role AS sr ON sur.role_id = sr.id -- 通过角色信息查看对应的权限列表 LEFT JOIN sec_role_menu AS srm ON sr.id = srm.role_id -- 通过权限列表查看对应的具体权限 LEFT JOIN sec_menu AS sm ON srm.menu_id = sm.id WHERE user_id = 1 AND sr.STATUS =0
创建权限表基类
@Data @AllArgsConstructor @NoArgsConstructor @TableName("sec_menu") public class SecMenu implements Serializable { private static final long serialVersionUID = -40335499183807601L; 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; }
根据Userid查询权限
新建一个SecMenuMapper

public interface SecMenuMapper extends BaseMapper<SecMenu> { //根据UserID查询权限 List<String> selectPermsByUserID(Long userid); }
再建一个mapper文件夹

然后用快捷键生成对应的xml

sql语句之前已经写好了,现在改来用
<mapper namespace="cn.bs.securitystudy.mapper.SecMenuMapper"> <select id="selectPermsByUserID" resultType="java.lang.String"> SELECT perms FROM sec_user_role AS sur -- 通过用户id查询对应的角色信息 LEFT JOIN sec_role AS sr ON sur.role_id = sr.id -- 通过角色信息查看对应的权限列表 LEFT JOIN sec_role_menu AS srm ON sr.id = srm.role_id -- 通过权限列表查看对应的具体权限 LEFT JOIN sec_menu AS sm ON srm.menu_id = sm.id WHERE user_id = #{userid} AND sr.STATUS =0 </select> </mapper>
然后yml加配置
mybatis-plus: mapper-locations: classpath*:/mapper/*.xml
测试
@Test void selectPermsByUserID(){ List<String> strings = secMenuMapper.selectPermsByUserID(1L); System.out.println(strings); }
没有问题,接下来正式放入代码当中
从数据库查询权限

@Autowired private SecMenuMapper secMenuMapper; List<String> perms = secMenuMapper.selectPermsByUserID(user.getId()); //把数据封装成UserDetails返回 return new UserLogin(user,perms);
修改测试接口的权限

测试
先登录

携带token访问测试接口,成功

自定义异常处理
不管是认证失败还是授权失败,都需要给前端返回商量好的json串,这样前端才能对响应进行统一的处理,不然到时候前端的小姐姐看到你返回的数据头皮发麻,决死你
Security异常处理
在SpringSecurity中,如果在认证或者授权的时候出现异常会被ExceptionTranslationFilter捕获,然后它会去判断到底是认证失败还是授权失败的异常
认证失败的异常:封装成AuthenticationException然后调用AuthenticationEntryPoint的方法去进行异常处理
授权失败的异常:封装成AccessDeniedException然后调用AccessDeniedHandler的方法去进行异常处理
因此如果需要自定义异常处理,只需要去自定义AuthenticationEntryPoint和AccessDeniedHandler然后给Security配置好就行了
自定义认证失败异常处理
新建一个AuthenticationEntryPointImpl

这里需要重写commence方法,通过它就能自定义异常了
1.首先创建一个响应结果对象,它需要两个参数,一个整数一个字符串
整数:代表响应的状态码,我们可以用自带的HttpStatus状态码来设置,它包含枚举和方法,如果这里需要的是整数所以调用方法,它返回的就是整数
字符串:代表响应的信息,你想写什么就写什么
2.把弄好了的响应结果对象装换为json串,这里用的Gson,随便你用什么转
3.通过WebUtils工具类,丢给前端
//自定义认证失败异常 @Component public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { ResponseResult responseResult = new ResponseResult(HttpStatus.UNAUTHORIZED.value(),"用户认证失败,你怕是个假人"); String json = new Gson().toJson(responseResult); //处理认证异常 WebUtils.renderString(response,json); } }
自定义授权失败异常处理
新建一个AccessDeniedHandlerImpl
流程和上面一样
//自定义授权失败异常 @Component public class AccessDeniedHandlerImpl implements AccessDeniedHandler { @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { ResponseResult responseResult = new ResponseResult(HttpStatus.FORBIDDEN.value(), "我建议你买个会员,因为你没有权限访问这个资源"); String s = new Gson().toJson(responseResult); WebUtils.renderString(response,s); } }
注入配置
为了让security识别我们的配置,因此和之前一样,需要去配置类里加点东西
//配置异认证常处理器 .exceptionHandling().authenticationEntryPoint(authenticationEntryPoint) //配置授权异常处理器 .accessDeniedHandler(accessDeniedHandler)
完整代码
@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http //基于token,不需要csrf,所以关闭 .csrf().disable() //不通过session过去SecurityContext .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and() .authorizeRequests(authorize -> authorize // 请求放开 // 把登录接口放行(允许匿名访问登录接口) .antMatchers("/user/login").permitAll() // 除了上述的接口可以匿名访问,其他地址的访问均需验证权限 .anyRequest().authenticated()) //配置自定义的JwtAuthenticationToken这个过滤器的位置 .addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class) //配置异认证常处理器 .exceptionHandling().authenticationEntryPoint(authenticationEntryPoint) //配置授权异常处理器 .accessDeniedHandler(accessDeniedHandler) .and() .build(); }
测试
先测认证失败

虽然认证失败了,但是响应的状态码还是200看着很舒服(说明服务器莫得问题),接着实时响应的状态就成了我们设置好了的401,信息也是自定义的
测试授权失败
去把接口的权限改了

结果也是我们想看的结果,舒服
跨域
规定:浏览器要求,在解析Ajax请求时,要求浏览器的路径与Ajax的请求的路径必须满足三个要求,则满足同源策略,可以访问服务器。
要求:协议、域名、端口号都相同,只要有一个不相同,那么都是非同源
在前后端分离的项目当中,前后端一般都不是同源的,因此憋憋会存在跨域的问题
由于这里用了security,请求过来过后,会经过它,所以不仅要SpringBoot要配置跨域,Security也要配置跨域
配置SpringBoot
通过SpringBoot配置跨域我这里提供三种,你喜欢什么就用什么
第一种:通过过滤器配置
@Configuration public class WebConfig { // 过滤器跨域配置 @Bean public CorsFilter corsFilter() { UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); CorsConfiguration config = new CorsConfiguration(); // 允许跨域的头部信息 config.addAllowedHeader("*"); // 允许跨域的方法 config.addAllowedMethod("*"); // 可访问的外部域 config.addAllowedOrigin("*"); // 需要跨域用户凭证(cookie、HTTP认证及客户端SSL证明等) //config.setAllowCredentials(true); //config.addAllowedOriginPattern("*"); // 跨域路径配置 source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } }
第二种实现 WebMvcConfigurer,重写 addCorsMappings 方法
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { //允许跨域的请求路径 registry.addMapping("/**") //允许跨域的域名 .allowedOrigins("*") //允许header能携带的信息 .allowedHeaders("*") //允许跨域的请求方法 .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") //允许跨域的时间,单位秒 .maxAge(3600); } }
第三种使用 @CrossOrigin 注解
@RestController public class TestController { @CrossOrigin @RequestMapping("/test") @PreAuthorize("hasAuthority('sys:movie:see')") public String Hello(){ return "你好,security"; } }
上面两种是全局,第三种需要在每一个接口都加注解,这里我用的第二种

配置Security

// 配置跨域访问(CORS) @Bean CorsConfigurationSource corsConfigurationSource() { UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", new CorsConfiguration().applyPermitDefaultValues()); return source; }
自带的其他权限校验

自定义权限校验
根据自带的方式,我们可以仿照写出自定义的权限校验规则
新建一个CustomCheck

//自定义校验规则 @Component("cc") public class CustomCheck { public final boolean hasAuthority(String authority) { //获取用户的权限 UserLogin principal = (UserLogin) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); List<String> permissions = principal.getPermissions(); //判断用户的权限是否包含传进来的权限 boolean contains = permissions.contains(authority); return contains; } }
@Component("cc"),括号中的cc是给这个bean对象取的名字,因为之后如果要用这个自定义的注解的话,需要去指定一下,不然security不知道

//使用自定义的权限校验规则 @RequestMapping("/test2") @PreAuthorize("@cc.hasAuthority('sys:movie:see')") public String Hello2(){ return "你好,security2"; }

过滤器链进行权限控制
新建一个接口

@RequestMapping("/test3") public String Hello3(){ return "你好,security3"; }
去配置里面给个权限

测试

代码Gitee地址:springSecurity: 这是一个security的学习项目