Spring Security
简介
Spring Security 是 Spring 家族中的一个安全管理框架,相比于另一个安全框架Shiro,它提供了更丰富的功能,社区资源也比Shiro丰富
一般来说中大型的项目都是使用SpringSecurity来做安全框架,小项目用Shiro的比较多,因为相比与SpringSecurity,Shiro的上手更加简单
一般Web应用需要进行认证和授权
认证:验证当前访问系统的是不是本系统的用户,并且要确认具体是哪个用户
授权:经过认证后判断当前用户是否有权限进行某个操作
而认证和授权也是SpringSecurity作为安全框架的核心功能
快速入门
我们需要搭建一个简单的SpringBoot工程,引入SpringSecurity依赖
<parent>
<artifactId>spring-boot-starter-parent</artifactId>
<groupId>org.springframework.boot</groupId>
<version>2.5.0</version>
</parent>
<dependencies>
<!--引入SpringSecurity启动器依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<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>
默认效果:在引入SpringSecurity启动器依赖后访问原来的某个后端资源会出现一个默认的登录界面Please sign in
(默认跳转/login
资源)
- 默认用户名是user,密码在启动时会在控制台输出
- 登出访问
/logout
资源
认证
登录校验流程
1、前端携带用户名密码访问登录接口
2、服务端去和数据库中的用户名和密码进行校验
3、如果正确,服务端使用用户名/用户名id,生成一个jwt并响应给前端
4、前端在登录后访问其它请求需要在请求头中携带token
5、后端获取请求头中的token进行解析,获取userID
6、后端根据用户id获取用户相关信息,如果有权限允许访问相关资源,访问目标资源,响应给前端
SpringSecurity完整流程
SpringSecurity的原理其实就是一个过滤器链,内部包含了提供各种功能的过滤器
这里我们可以看看入门案例中的过滤器
UsernamePasswordAuthenticationFilter
负责处理我们在登陆页面填写了用户名密码后的登陆请求
入门案例的认证工作主要由它负责
ExceptionTranslationFilter
处理过滤器链中抛出的任何AccessDeniedException和AuthenticationException异常
FilterSecurityInterceptor
负责权限校验(授权)的过滤器
认证流程
1、提交用户名和密码到UsernamePasswordAuthenticationFilter类,封装Authentication对象,此时只有用户名和密码信息,还未授权
- 调用AuthenticationManager的
authenticate
方法进行认证
2、AuthenticationManager接口:定义了认证Authentication的方法
实现类为ProviderManager
- 在此类中调用DaoAuthenticationProvider的
authenticate
方法进行认证
3、AbstractUserDetailsAuthenticationProvider抽象类:实现类为DaoAuthenticationProvider
- 在此类中调用了UserDetailsService接口的
loadUserByUsername
方法查询用户
4、UserDetailsService接口:实现类为InMemoryUserDetailsManager
- 在此类中根据用户名去查询对应的用户及这个用户的权限信息,此类是在内存中查找信息,之后将对应用户信息包括权限信息封装成UserDetails对象返回给Provider
5、通过PasswordEncoder对比UserDetails中的密码和Authentication的密码是否正确,如果正确就把UserDetails中的权限信息设置到Authentication对象中
6、如果成功返回Authentication对象最后调用SecurityContextHolder.getContext().setAuthentication
方法存储该对象
设计思路
前端请求头携带token信息到服务器,应该添加一个Jwt认证过滤器检查用户id,根据userid查询用户的权限信息
为了减少每次访问给数据库带来的查询压力,我们应该将权限信息放入redis中,userid作为key,用户信息作为value存入redis
登录:
自定义登录接口
- 调用ProviderManager的方法进行认证,如果认证通过生成jwt
- 把用户信息存入redis
自定义UserDetailsService
- 在这个实现类中去查询数据库
校验:
- 定义jwt认证过滤器
- 获取token,解析token,获取其中的userid
- 从redis中获取用户信息
- 存入SecurityContextHolder
准备工作
依赖
<!--redis依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--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>
<!--mybatisplus依赖-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.3</version>
</dependency>
<!--mysql依赖-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
序列化工具类
package com.os467.springsecuritydemo.utils;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.serializer.SerializerFeature;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.type.TypeFactory;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.SerializationException;
import com.alibaba.fastjson.parser.ParserConfig;
import java.nio.charset.Charset;
/**
* Redis使用FastJson序列化
*
* @author sg
*/
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);
}
protected JavaType getJavaType(Class<?> clazz)
{
return TypeFactory.defaultInstance().constructType(clazz);
}
}
Redis配置类
package com.os467.springsecuritydemo.config;
import com.os467.springsecuritydemo.utils.FastJsonRedisSerializer;
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;
}
}
响应类
import com.fasterxml.jackson.annotation.JsonInclude;
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ResponseResult<T> {
/**
* 状态码
*/
private Integer code;
/**
* 提示信息,如果有错误时,前端可以获取该字段进行提示
*/
private String msg;
/**
* 查询到的结果数据,
*/
private T data;
public ResponseResult(Integer code, String msg) {
this.code = code;
this.msg = msg;
}
public ResponseResult(Integer code, T data) {
this.code = code;
this.data = data;
}
public Integer getCode() {
return code;
}
public void setCode(Integer code) {
this.code = code;
}
public String getMsg() {
return msg;
}
public void setMsg(String msg) {
this.msg = msg;
}
public T getData() {
return data;
}
public void setData(T data) {
this.data = data;
}
public ResponseResult(Integer code, String msg, T data) {
this.code = code;
this.msg = msg;
this.data = data;
}
}
JWT工具类
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 JwtUtil {
//有效期为
public static final Long JWT_TTL = 60 * 60 *1000L;// 60 * 60 *1000 一个小时
//设置秘钥明文
public static final String JWT_KEY = "osz";
public static String getUUID(){
String token = UUID.randomUUID().toString().replaceAll("-", "");
return token;
}
/**
* 生成jtw
* @param subject token中要存放的数据(json格式)
* @return
*/
public static String createJWT(String subject) {
JwtBuilder builder = getJwtBuilder(subject, null, getUUID());// 设置过期时间
return builder.compact();
}
/**
* 生成jtw
* @param subject token中要存放的数据(json格式)
* @param ttlMillis token超时时间
* @return
*/
public static String createJWT(String subject, Long ttlMillis) {
JwtBuilder builder = getJwtBuilder(subject, ttlMillis, getUUID());// 设置过期时间
return builder.compact();
}
private static JwtBuilder getJwtBuilder(String subject, Long ttlMillis, String uuid) {
SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
SecretKey secretKey = generalKey();
long nowMillis = System.currentTimeMillis();
Date now = new Date(nowMillis);
if(ttlMillis==null){
ttlMillis=JwtUtil.JWT_TTL;
}
long expMillis = nowMillis + ttlMillis;
Date expDate = new Date(expMillis);
return Jwts.builder()
.setId(uuid) //唯一的ID
.setSubject(subject) // 主题 可以是JSON数据
.setIssuer("osz") // 签发者
.setIssuedAt(now) // 签发时间
.signWith(signatureAlgorithm, secretKey) //使用HS256对称加密算法签名, 第二个参数为秘钥
.setExpiration(expDate);
}
/**
* 创建token
* @param id
* @param subject
* @param ttlMillis
* @return
*/
public static String createJWT(String id, String subject, Long ttlMillis) {
JwtBuilder builder = getJwtBuilder(subject, ttlMillis, id);// 设置过期时间
return builder.compact();
}
public static void main(String[] args) throws Exception {
String jwt = createJWT("1234567");
System.out.println(jwt);
Claims claims = parseJWT("eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiIzOGIxZTU2MDZlOWU0YTE5OTQ5ZjczMGM2YTEzNWI2MyIsInN1YiI6IjEyMzQ1NjciLCJpc3MiOiJvc3oiLCJpYXQiOjE2NjM1MTA3MTAsImV4cCI6MTY2MzUxNDMxMH0.OQNf0sAE9ZMu0wYgkMliXvjxMie2mN0_zVcQ2ZnPAQQ");
String subject = claims.getSubject();
System.out.println(subject);
}
/**
* 生成加密后的秘钥 secretKey
* 用于JWT签名时的秘钥
* @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();
}
}
Redis工具类
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.BoundSetOperations;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Component;
import java.util.*;
import java.util.concurrent.TimeUnit;
@SuppressWarnings(value = { "unchecked", "rawtypes" })
@Component
public class RedisCache
{
@Autowired
public RedisTemplate redisTemplate;
/**
* 缓存基本的对象,Integer、String、实体类等
*
* @param key 缓存的键值
* @param value 缓存的值
*/
public <T> void setCacheObject(final String key, final T value)
{
redisTemplate.opsForValue().set(key, value);
}
/**
* 缓存基本的对象,Integer、String、实体类等
*
* @param key 缓存的键值
* @param value 缓存的值
* @param timeout 时间
* @param timeUnit 时间颗粒度
*/
public <T> void setCacheObject(final String key, final T value, final Integer timeout, final TimeUnit timeUnit)
{
redisTemplate.opsForValue().set(key, value, timeout, timeUnit);
}
/**
* 设置有效时间
*
* @param key Redis键
* @param timeout 超时时间
* @return true=设置成功;false=设置失败
*/
public boolean expire(final String key, final long timeout)
{
return expire(key, timeout, TimeUnit.SECONDS);
}
/**
* 设置有效时间
*
* @param key Redis键
* @param timeout 超时时间
* @param unit 时间单位
* @return true=设置成功;false=设置失败
*/
public boolean expire(final String key, final long timeout, final TimeUnit unit)
{
return redisTemplate.expire(key, timeout, unit);
}
/**
* 获得缓存的基本对象。
*
* @param key 缓存键值
* @return 缓存键值对应的数据
*/
public <T> T getCacheObject(final String key)
{
ValueOperations<String, T> operation = redisTemplate.opsForValue();
return operation.get(key);
}
/**
* 删除单个对象
*
* @param key
*/
public boolean deleteObject(final String key)
{
return redisTemplate.delete(key);
}
/**
* 删除集合对象
*
* @param collection 多个对象
* @return
*/
public long deleteObject(final Collection collection)
{
return redisTemplate.delete(collection);
}
/**
* 缓存List数据
*
* @param key 缓存的键值
* @param dataList 待缓存的List数据
* @return 缓存的对象
*/
public <T> long setCacheList(final String key, final List<T> dataList)
{
Long count = redisTemplate.opsForList().rightPushAll(key, dataList);
return count == null ? 0 : count;
}
/**
* 获得缓存的list对象
*
* @param key 缓存的键值
* @return 缓存键值对应的数据
*/
public <T> List<T> getCacheList(final String key)
{
return redisTemplate.opsForList().range(key, 0, -1);
}
/**
* 缓存Set
*
* @param key 缓存键值
* @param dataSet 缓存的数据
* @return 缓存数据的对象
*/
public <T> BoundSetOperations<String, T> setCacheSet(final String key, final Set<T> dataSet)
{
BoundSetOperations<String, T> setOperation = redisTemplate.boundSetOps(key);
Iterator<T> it = dataSet.iterator();
while (it.hasNext())
{
setOperation.add(it.next());
}
return setOperation;
}
/**
* 获得缓存的set
*
* @param key
* @return
*/
public <T> Set<T> getCacheSet(final String key)
{
return redisTemplate.opsForSet().members(key);
}
/**
* 缓存Map
*
* @param key
* @param dataMap
*/
public <T> void setCacheMap(final String key, final Map<String, T> dataMap)
{
if (dataMap != null) {
redisTemplate.opsForHash().putAll(key, dataMap);
}
}
/**
* 获得缓存的Map
*
* @param key
* @return
*/
public <T> Map<String, T> getCacheMap(final String key)
{
return redisTemplate.opsForHash().entries(key);
}
/**
* 往Hash中存入数据
*
* @param key Redis键
* @param hKey Hash键
* @param value 值
*/
public <T> void setCacheMapValue(final String key, final String hKey, final T value)
{
redisTemplate.opsForHash().put(key, hKey, value);
}
/**
* 获取Hash中的数据
*
* @param key Redis键
* @param hKey Hash键
* @return Hash中的对象
*/
public <T> T getCacheMapValue(final String key, final String hKey)
{
HashOperations<String, String, T> opsForHash = redisTemplate.opsForHash();
return opsForHash.get(key, hKey);
}
/**
* 删除Hash中的数据
*
* @param key
* @param hkey
*/
public void delCacheMapValue(final String key, final String hkey)
{
HashOperations hashOperations = redisTemplate.opsForHash();
hashOperations.delete(key, hkey);
}
/**
* 获取多个Hash中的数据
*
* @param key Redis键
* @param hKeys Hash键集合
* @return Hash对象集合
*/
public <T> List<T> getMultiCacheMapValue(final String key, final Collection<Object> hKeys)
{
return redisTemplate.opsForHash().multiGet(key, hKeys);
}
/**
* 获得缓存的基本对象列表
*
* @param pattern 字符串前缀
* @return 对象列表
*/
public Collection<String> keys(final String pattern)
{
return redisTemplate.keys(pattern);
}
}
响应工具类
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;
}
}
实体类
import java.io.Serializable;
import java.util.Date;
/**
* 用户表(User)实体类
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@TableName(value = "sys_user")
public class User implements Serializable {
private static final long serialVersionUID = -40356785423868312L;
/**
* 主键
*/
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 delFlag;
}
数据库校验用户
创建用户表
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=2 DEFAULT CHARSET=utf8mb4 COMMENT='用户表'
创建UserMapper继承BaseMapper<User>
在用户类上加TableName指定查询表名
在启动类上加MapperScan指定Mapper包
修改默认安全流程
实现UserDetailsService接口
实现UserDetailsService接口,使得SpringSecurity不再使用默认的实现类
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//查询用户信息
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(User::getUserName,username);
User user = userMapper.selectOne(queryWrapper);
//如果没有查询到用户,就抛出异常
if (Objects.isNull(user)){
throw new RuntimeException("用户名或者密码错误");
}
//TODO 查询对应的权限信息
//把数据封装成UserDetails返回
return new LoginUser(user);
}
}
注意:如果要测试,需要往用户表中写入用户数据,并且如果想让用户密码是明文存储,需要在密码前加{noop}
密码加密存储
实际项目中我们不会把密码明文存储在数据库中
默认使用的PasswordEncoder要求数据库中的密码格式为:{id}password,它会根据id去判断密码的加密方式,但是我们一般不会使用这种方式,所以需要替换PasswordEncoder
BCryptPasswordEncoder
我们一般使用SpringSecurity为我们提供的BCryptPasswordEncoder
将BCryptPasswordEncoder对象注入spring容器,SpringSecurity就会使用该PasswordEncoder来进行密码校验
定义SpringSecurity配置类,SpringSecurity要求这个配置类继承WebSecurityConfigurerAdapter
@Configuration
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public BCryptPasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
}
测试BCryptPasswordEncoder
@Test
public void testBCryptPasswordEncoder(){
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
String encode = passwordEncoder.encode("1234");
String encode1 = passwordEncoder.encode("1234");
System.out.println(encode);
System.out.println(encode1);
//检验用户密码是否正确
boolean matches = passwordEncoder.matches("1234",
"$2a$10$mI.pAL44nXZMQI4GndFrhuDPz9NfyyzvV0UGrgwqpUoduwZQ6vpF6");
System.out.println(matches);
}
使用BCrypt强哈希方法来加密,不可逆,此类每次进行加密的盐值均不相同,也就是说每次相同的明文加密后的密文均不相同,这样就能保证密码尽量小的被测出来
此时数据库中存储的应当是加密后的密文
登录接口(认证)
自定义登录接口,让SpringSecurity对这个接口放行,让用户访问这个接口的时候不用登录也能访问
修改核心配置类
authenticationManagerBean
在SpringSecurity配置类中重写authenticationManagerBean
方法,暴露AuthenticationManager接口
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
configure
重写configure方法,指定需要配置安全认证的路径
@Override
protected void configure(HttpSecurity http) throws Exception {
//关闭csrf
http.csrf().disable()
//不通过Session获取SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
//对于登录接口允许匿名访问
.antMatchers("/user/login").anonymous()
//除上面外的所有请求都要权限认证
.anyRequest().authenticated();
}
实现LoginService
@Service
public class LoginServiceImpl implements LoginService {
@Autowired
private RedisCache redisCache;
@Autowired
private AuthenticationManager authenticationManager;
/**
* 获取authenticate进行用户认证
* @param user
* @return
*/
@Override
public ResponseResult login(User user) {
//AuthenticationManager进行用户认证
//封装authentication对象
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getUserName(),user.getPassword());
Authentication authenticate = authenticationManager.authenticate(authenticationToken);
//认证未通过,生成提示
if (Objects.isNull(authenticate)){
throw new RuntimeException("登录失败");
}
//认证通过,根据userid生成jwt
LoginUser loginUser = (LoginUser)authenticate.getPrincipal();
String userid = loginUser.getUser().getId().toString();
//生成jwt
String jwt = JwtUtil.createJWT(userid);
//将jwt存放入map
Map<String,String> map = new HashMap<>();
map.put("token",jwt);
//将用户信息存入redis,userid为key
redisCache.setCacheObject("login:"+userid,loginUser);
return new ResponseResult(200,"登录成功",map);
}
认证过滤器
这里我们不使用servlet的Filter接口,使用spring提供的OncePerRequestFilter,来确保一次请求不重复经过此过滤器
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Autowired
private RedisCache redisCache;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
//获取token
String token = request.getHeader("token");
//判断token是否为空
if (!StringUtils.hasText(token)) {
//没有token直接放行
filterChain.doFilter(request, response);
//这里必须return,因为目标请求获取资源后返回响应时会回到这一行
return;
}
//解析token
String userid;
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);
if (Objects.isNull(loginUser)){
throw new RuntimeException("用户未登录");
}
//TODO 获取权限信息封装到authentication中
UsernamePasswordAuthenticationToken authentication
= new UsernamePasswordAuthenticationToken(loginUser,null,null);
//存入SecurityContextHolder
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response);
}
}
配置过滤器位置
在SpringSecurity核心配置类中设置,需要聚合一个过滤器的实例
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class)
退出登录
获取到SecurityContextHolder中的认证信息(userid),删除redis中的数据
@RequestMapping("/user/logout")
public ResponseResult logout(){
return loginService.logout();
}
service层
从当前请求的SecurityContextHolder中获取用户信息,根据用户id删除redis中的用户数据
@Override
public ResponseResult logout() {
//获取SecurityContextHolder中的用户信息
UsernamePasswordAuthenticationToken authentication
= (UsernamePasswordAuthenticationToken)SecurityContextHolder.getContext().getAuthentication();
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
Long userid = loginUser.getUser().getId();
//删除redis中的值
redisCache.deleteObject("login:"+userid);
return new ResponseResult(200,"注销成功");
}
授权
实现不同的用户可以使用不同的功能,这就是权限系统要去实现的效果
不能只在前端判断用户权限来选择显示哪些菜单按钮,因为如果有人知道了对应功能的接口地址,就可以不通过前端,直接发送请求来完成相关操作
基本流程
在SpringSecurity中,会使用默认的FilterSecurityInterceptor来进行权限校验
在FilterSecurityInterceptor中会从SecurityContextHolder中获取其中的Authentication,然后获取其中的权限信息,判断当前用户是否拥有访问当前资源所需的权限
因此我们需要将当前登录用户的权限信息存入Authentication中,然后设置我们的资源需要的权限即可
限制访问资源所需权限
SpringSecurity为我们提供了基于注解的权限控制方案,这也是我们项目中主要采用的方式,可以通过注解去指定访问资源所需的权限
开启相关配置
在SpringSecurity配置类上加上此注解
@EnableGlobalMethodSecurity(prePostEnabled = true)
@PreAuthorize
声明这个方法所需要的权限表达式
- 设置此资源需要拥有test权限才能访问
@PreAuthorize("hasAnyAuthority('test')")
@RequestMapping("/test")
public String test(){
return "test";
}
修改LoginUser类
提供一个含权限信息集合的有参构造方法
重写getAuthorities
方法,封装权限信息集合
这里的权限信息集合中可以存放GrantedAuthority的子类,我们选择SimpleGrantedAuthority,将权限集合作为私有属性存放在类中,可以避免多次转换permissions
@JSONField(serialize = false) 防止redis序列化此字段
private User user;
private List<String> permissions;
@JSONField(serialize = false)
private List<SimpleGrantedAuthority> authorities;
public LoginUser(User user,List<String> permissions){
this.user = user;
this.permissions = permissions;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
//如果成员变量不为空直接返回
if (authorities != null){
return authorities;
}
//把permission中String类型的权限信息封装成它的一个实现类
authorities = permissions.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
return authorities;
}
这里先写死UserDetailsServiceImpl中的权限信息封装
//TODO 查询对应的权限信息
List<String> list = new ArrayList<>(Arrays.asList("test","admin"));
//把数据封装成UserDetails返回
return new LoginUser(user,list);
将权限信息封装到authentication中,第三个参数调用loginUser.getAuthorities()
方法获取权限信息
UsernamePasswordAuthenticationToken authentication
= new UsernamePasswordAuthenticationToken(loginUser,null,loginUser.getAuthorities());
此时就可以测试访问test资源
流程总结
配置核心配置类,拦截器
编写LoginServiceImpl,UserDetailsServiceImpl
==UserDetailsServiceImpl==
- 用于封装认证后用户信息
- 要加service注解
- 要实现UserDetailsService,替换默认的实现类
authenticationManager
- 一般用于登录接口的身份认证
- ==必须在配置类中重写authenticationManagerBean()方法==,弃用默认的认证类ProviderManager
- 发起身份认证的出发点接口,需要修改配置弃用默认认证流程
authenticate
方法,开始认证,需要接收一个身份认证令牌对象
BCryptPasswordEncoder
- 需要在配置类中配置组件,替换原有的解码器
configure
- 配置http信息,请求的拦截,是否允许匿名访问等
JwtAuthenticationTokenFilter
- 继承OncePerRequestFilter
- 对请求继续拦截验证token,没有token则直接放行
从数据库查询权限信息
RBAC权限模型
RBAC权限模型(Roie-Based Access Control)即:基于角色的权限控制,这是目前最常被并发开发者使用也是相对易用,通用的权限模型
用户表 ↔ 用户角色关联表 ↔ 角色表 ↔ 角色权限关联表 ↔ 权限表
建表准备
CREATE DATABASE /*!32312 IF NOT EXISTS*/`sg_security` /*!40100 DEFAULT CHARACTER SET utf8mb4 */;
USE `my_blog`;
/*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;
根据userid查询 perms 对应的role 和menu 都必须是正常状态的
SQL语句关系
SELECT DISTINCT m.`perms` FROM
sys_user_role ur
LEFT JOIN
`sys_role` r ON ur.`role_id` = r.`id`
LEFT JOIN
`sys_role_menu` rm ON ur.`role_id` = rm.`role_id`
LEFT JOIN
`sys_menu` m ON m.`id` = rm.`menu_id`
WHERE
user_id = 2
AND
r.`status` = 0
菜单表(权限)
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)实体类
*
* @author makejava
* @since 2021-11-24 15:30:08
*/
@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;
}
代码实现
自定义mybatis多表查询方法
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.os467.springsecuritydemo.mapper.MenuMapper">
<select id="selectPermsByUserId" resultType="java.lang.String">
SELECT DISTINCT m.`perms` FROM
sys_user_role ur
LEFT JOIN
`sys_role` r ON ur.`role_id` = r.`id`
LEFT JOIN
`sys_role_menu` rm ON ur.`role_id` = rm.`role_id`
LEFT JOIN
`sys_menu` m ON m.`id` = rm.`menu_id`
WHERE
user_id = #{userid}
AND
r.`status` = 0
</select>
</mapper>
在UserDetailsServiceImpl中添加代码段
//从数据库中搜索授权信息,存入用户认证信息中
List<String> list = menuMapper.selectPermsByUserId(user.getId());
LoginUser loginUser = new LoginUser(user,list);
自定义失败处理
我们还希望在认证失败或者授权失败的情况下也返回json信息给前端,这样可以让前端响应进行统一的处理
使用SpringSecurity的异常处理机制来实现
在SpringSecurity中,如果我们在认证或授权的过程中出现了异常会被ExceptionTranslationFilter捕获
在ExceptionTranslationFilter中会去判断是认证失败还是授权失败出现的异常
如果是认证过程中出现的异常会被封装成AuthenticationException然后调用AuthenticationEntryPoint对象的方法去进行异常处理
如果是授权过程中出现的异常会被封装成AccessDeniedException然后调用AuthenticationEntryPoint对象的方法去进行异常处理
如果我们要自定义异常处理,我们只需要自定义AuthenticationEntryPoint和AccessDeniedHandler然后配置给SpringSecurity即可
自定义实现类
认证异常
实现AuthenticationEntryPoint接口,重写方法
@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
}
}
工具类
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;
}
}
封装错误信息响应类
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
ResponseResult result = new ResponseResult(HttpStatus.UNAUTHORIZED.value(),"用户认证失败,请重新登录");
String json = JSON.toJSONString(result);
//处理异常
WebUtils.renderString(response,json);
}
授权异常
实现AccessDeniedHandler接口,重写方法
@Component
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) throws IOException, ServletException {
ResponseResult result = new ResponseResult(HttpStatus.FORBIDDEN.value(), "您的权限不足");
String json = JSON.toJSONString(result);
//处理异常
WebUtils.renderString(response,json);
}
}
修改配置
最后在核心配置类的config方法中添加两个异常处理器
@Autowired
private AuthenticationEntryPoint authenticationEntryPoint;
@Autowired
private AccessDeniedHandler accessDeniedHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
//....
//配置异常处理器
http.exceptionHandling()
//配置认证失败处理器
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler);
}
其它细节问题
跨域处理
浏览器出于安全的考虑,使用XMLHttpRequest对象发起HTTP请求时必须遵循同源策略,否则就是跨域的HTTP请求,默认情况下是被禁止的,同源策略要求源相同才能正常进行通信,即协议、域名、端口完全一致
前后端分离项目,前端和后端项目一般是不同源的,所以肯定会存在跨域调用的问题
所以就要处理下,让前端能进行跨域请求
先对SpringBoot配置,允许跨域请求
@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中也要开启
在配置类config方法中开启
//允许跨域
http.cors();
其它权限校验方法
我们前面都是使用**@PreAuthorize注解**,然后在在其中使用的是hasAuthority
方法进行校验
SpringSecurity还为我们提供了其它方法例如: hasAnyAuthority, hasRole,hasAnyRole等
hasAuthority原理
hasAuthority
方法实际是执行到了SecurityExpressionRoot的hasAuthority
内部通过调用authentication的getAuthorities方法获取用户的权限列表
然后转换成一个权限集合,在和我们存入方法的权限参数做一个比较,看看是否包含这个权限
hasAnyAuthority
方法:可以传入多个参数,只要用户拥有其中任意一个权限即可访问hasRole
方法:要求有对应角色才可以访问,它的内部会将我们传入的参数拼接上**ROLE_**后再去比较,所以这个情况下用户权限也需要有这个前缀才能访问hasAnyRole
方法:有任意的角色即可访问,其它同hasRole
自定义权限校验方法
我们也可以使用自定义的权限校验方法,在@PreAuthorize
注解中使用我们自己的方法
使用spEL(Spring Expression Language)表达式,它是spring3.x版本的新特性
作用:支持在运行时操作和查询对象,其语法类似统一的EL语言
通过@
ex.调用函数
的方法来实现自定义方法的调用
在SPEL表达式中使用
@ex
相当于获取容器中bean的名字为ex
的对象
@PreAuthorize("@ex.hasAuthority('system:dept:list')")
@RequestMapping("/testCors")
public ResponseResult testCors(){
return new ResponseResult(200,"响应成功",null);
}
自定义校验方法
添加**@Component(“ex”)**注解,添加到spring容器中
@Component("ex")
public class MyExpressionRoot {
public boolean hasAuthority(String authority){
//获取当前用户的权限
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
LoginUser loginUser = (LoginUser)authentication.getPrincipal();
List<String> permissions = loginUser.getPermissions();
//判断用户权限集合中是否存在authority
return permissions.contains(authority);
}
}
基于配置类的权限控制
在核心配置类中的config方法配置
http.authorizeRequests().antMatchers("/testCors").hasAnyAuthority("system:dept:list")
CSRF
CSRF是指跨站请求伪造(Cross-site request forgery),是web常见的攻击之一
简单地说,是攻击者通过一些技术手段欺骗用户的浏览器去访问一个自己曾经认证过的网站并执行一些操作
SpringSecurity去防止CSRF攻击的方式就是通过csrf_token,后端会生成一个csrf_token,前端发起请求的时候需要携带这个csrf_token,后端会有过滤器进行校验,如果没有携带或者是伪造的就不允许访问
我们可以发现CSRF攻击依靠的是cookie中所携带的认证信息,但是在前后端分离项目中我们的认证信息其实是token,而token是存储在cookie中,并且需要前端代码去吧token设置到请求头中才可以,所以CSRF攻击也不用担心了
CSRF攻击只是借用了cookie,不能获取其中的token信息,因此没法设置请求头中的token信息
其它安全方案
认证成功处理器
实际上在UsernamePasswordAuthenticationFilter进行登录认证的时候,如果登录成功了是会调用AuthenticationSuccessHandler
的方法进行认证成功后的处理,AuthenticationSuccessHandler就是登录成功处理器
我们也可以自定义一个认证成功处理器
前提是我们依旧使用UsernamePasswordAuthenticationFilter
之前在配置类的config方法中重写了父类方法
super.configure(http)
,没有使用到认证表单因此认证成功处理器是无效的
我的认证成功处理器
@Component
public class MySuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {
System.out.println("认证成功了!");
}
}
修改配置类
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin().successHandler(successHandler);
http.authorizeRequests().anyRequest().authenticated();
}
认证失败处理器
实际上在UsernamePasswordAuthenticationFilter进行登录认证的时候,如果认证失败了是会调用AuthenticationFailureHandler的方法进行认证失败后的处理的,AuthenticationFailureHandler就是登录失败处理器
@Component
public class MyFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
System.out.println("认证失败了");
}
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()
//配置认证成功处理器
.successHandler(successHandler)
//配置认证失败处理器
.failureHandler(failureHandler);
http.authorizeRequests().anyRequest().authenticated();
}
注销成功处理器
@Component
public class MyLogoutSuccessHandler implements LogoutSuccessHandler {
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
System.out.println("注销成功");
}
}
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private AuthenticationSuccessHandler successHandler;
@Autowired
private AuthenticationFailureHandler failureHandler;
@Autowired
private LogoutSuccessHandler logoutSuccessHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()
//配置认证成功处理器
.successHandler(successHandler)
//配置认证失败处理器
.failureHandler(failureHandler);
http.logout()
//配置注销成功处理器
.logoutSuccessHandler(logoutSuccessHandler);
http.authorizeRequests().anyRequest().authenticated();
}
}
我们的方案
前面的项目中我们是通过自己定义的LoginService接口来调用AuthenticationManager中的一个认证方法
然后重写了UserDetailsService来实现查询数据库认证,最后使用jwt工具生成了token并且将用户信息存入了redis中
其它方案
使用了框架原始的UsernamePasswordAuthenticationFilter,重写了UserDetailsService接口
但是这样还是不符合我们使用token的要求,因此使用了认证成功处理器
在登录成功处理器中去生成token,并且将用户信息存入redis中
缺点:只进行了用户名和密码的校验,如果要添加一个验证码的校验就需要在此过滤器前面再加一个验证码过滤器
转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以邮件至 1300452403@qq.com