Spring Security和JWT

学习视频

B站2021-12-19Spring Security框架教程-Spring Security+JWT…

0.简介

0.1 JWT

JWT(JSON Web Token)的缩写,它是基于 RFC 7519 标准定义的一种可以安全传输的的JSON对象,由于使用了数字签名,所以是可信任和安全的。

原理

服务器认证以后,生成一个JSON对象,发回给用户。以后用户与服务器通信的时候都要发回这个JSON对象,服务器完全只靠这个对象认定用户身份。为了防止用户篡改数据,服务器会在生成这个对象的时候加上签名。

JWT的组成

  • JWT token的格式:header.payload.signature
  • header中用于存放签名的生成算法{“alg”: “HS512”}
  • payload中用于存放用户名、token的生成时间和过期时间{“sub”:”admin”,”created”:1489079981393,”exp”:1489684781}
  • signature为以header和payload生成的签名,一旦header和payload被篡改,验证将失败//secret为加密算法的密钥,只有服务器才知道,不能泄露给用户
    String signature = HMACSHA512(base64UrlEncode(header) + “.” +base64UrlEncode(payload),secret)

JWT使用

  • 用户调用登录接口,登录成功后获取到JWT的token;
  • 之后用户每次调用接口把JWT token放在http的header的authorization字段里(也可以放在cookie中,但是这样不能跨域);
  • 后台程序通过对Authorization头中信息的解码及数字签名校验来获取其中的用户信息,从而实现认证和授权。

JWT特点

  • JWT默认不加密,但也可以加密,eg生成原始token后使用密钥再次加密
  • 不加密时JWT不能写入秘密数据
  • 除了认证,JWT也可以用于交换信息。有效使用JWT跨域降低服务器查询数据库的次数
  • 缺点是一旦JWT签发,不能修改或废弃,到期前始终有效
  • 为减少盗用,JWT有效期应该设置比较短。因为JWT本身包含认证信息,一旦泄露。任何人都可以获得该令牌的所有权限;对于一些比较重要的权限,使用时应该对用户再次认证
  • 为减少盗用,JWT建议使用HTTPS而非HTTP明码传输
  • jwt是无状态的(区别于session有状态)

详细参考阮一峰的网络日志-JSON Web Token 入门教程

0.2 SpringSecurity

Spring Security是Spring的一个安全管理框架,一般用于中大型项目的安全框架。同类型竞品为Shiro,上手简单,常用于小型项目,不如Spring Security强大,且社区资源不够丰富。

核心功能:认证和授权(web应用)

  • 认证:验证当前访问系统的是否为本系统用户、具体是哪一个用户
  • 授权:认证通过后判断当前用户是否有权限进行某个操作

0.3 为什么要结合Spring Security和JWT

JWT是数字签名的,所以可以被验证和信任,以安全传输JSON对象信息。但是JWT只是解决了用户身份验证和授权的一部分,本身并不包括一个完整的安全解决方案。

Spring Security是一个强大的、可高度自定义的身份验证和访问控制框架,能够处理身份验证、授权、防止跨站点请求伪造(CSRF)、跨站点脚本(XSS)等安全问题。

两者服务于不同目的,结合使用可以利用Spring Security的强大功能和灵活性,使用JWT提供无状态的可扩展的认证机制。

1.快速入门

1.1 准备工作

搭建简单springboot工程

<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>
@RestController
public class HelloController {

@RequestMapping("/hello")
public String hello(){
return "hello";
}
}
@SpringBootApplication
public class SecurityApplication {
public static void main(String[] args) {
SpringApplication.run(SecurityApplication.class, args);
}
}

1.2 引入Spring Security

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>

此时再访问hello接口需要先登录,登录后才会返回请求结果,默认登录名user,密码输出到控制台

image-20230721182046460

2.认证

2.1 登录校验流程

image-20230721182239456

Q:为什么要使用JWT?

A:最原始的方式是用户每次请求都加上用户名和密码,这也是可行的,但是使用token更方便,主要是为了减少查库保持用户登录状态:

  • 不需要每个请求都让用户输入一遍密码;
  • 降低密码被截获的风险;
  • 降低服务器压力:如果请求中用户密码加盐,则每次请求验证密码流程都相对耗时,使用token则只有获取token的时候需要加密验证;
  • 降低数据库压力:token有效期内不需要频繁查询数据库来验证用户名密码是否正确.

2.2 原理

2.2.1 Spring Security完整流程

image-20230721190441331

Spring Security原理是一个过滤器链,内部包含了提供各种功能的过滤器。

  • UsernamePasswordAuthenticationFilter:负责处理登录页面填写了用户名和密码之后的登录请求。入门案例的认证工作主要由它负责。
  • ExceptionTranslationFilter:处理过滤器链中抛出的任何AccessDeniedExceptionAuthenticationException.
  • FilterSecurityInterceptor:负责权限校验的过滤器.

可以通过Debug查看当前系统中Spring Security过滤器链中有哪些过滤器以及它们的顺序:

方法:

image-20230721194314849
image-20230721194336984
image-20230721194357851

或者如下方法:

image-20230721194834557
image-20230721194958540

2.2.2 认证流程详解⭐

image-20211214151515385

Authentication接口:它的实现类表示当前访问系统的用户,封装了用户相关信息。

AuthenticationManager接口:定义了认证Authentication的方法。

UserDetailsAService接口:加载用户特定数据的核心接口,定义了一个根据用户名查询用户信息的方法。

UserDetails接口:提供用户核心信息。通过UserDetailsService根据用户名获取处理的用户信息要封装成UserDetails对象返回,然后将这些信息封装到Authentication对象中。

分析:

上述流程是Spring Security鉴权的默认流程,和jwt结合需要修改一些地方。

上述流程中,UserDetailsService从内存中查询用户密码(图中步骤5),但实际上需要从数据库中查询,所以这个方法需要重写。

另外jwt生成的token需要传递给前端,而经过Spring Security过滤器认证后的authentication信息在filter内部(图中步骤10),无法返回给前端,所以还需要自定义一个controller和service来处理登录结果,而不再使用默认的UsernamePasswordAuthenticationFilter.

整体逻辑如下:

image-20230724134519108

校验模块:

经过过滤器认证返回token后,前端每次再发起新请求就直接携带token信息:

image-20230724134728298

JWT认证过滤器获取到了userid后怎么获取完整的用户信息?

登录接口中认证通过后,除了生成jwt,还将用户信息存入redis中,后面用户通过token发起请求之后就可以根据token中解析的userid从redis查询拿到用户信息。

image-20230724145649578

为什么不把用户信息直接存储在token中,而是要从redis中获取?

  • 权限动态变化:因为jwt一旦签发,有效期内无法更改;所以如果用户权限已经变更,但旧的token没有过期的话用户就一直拥有旧的权限;
  • JWT大下:JWT大小有限,通常被存储在HTTP请求Header中的Authorization字段,太大可能超过请求头大小限制,且增加HTTP请求能耗;
  • 安全性问题:JWT信息能被解码,不建议存储敏感权限信息。
  • 分布式:如果是单机,则也可以把信息放在session中;但如果是分布式,可以放在redis中,因为session还要做信息共享。

注意:如果JWT被获取,那么攻击者可以使用JWT冒充用户身份。建议使用https发送jwt.

2.3 解决问题

2.3.1 思路分析⭐

登录

  • 自定义登录接口
    • 调用ProviderManager的方法进行认证,如果认证通过生成JWT
    • 把用户信息存储在redis中
  • 自定义UserDetailsService
    • 查询数据库

校验

  • 定义JWT认证过滤器
    • 获取token
    • 解析token,获取userid
    • 根据userid从redis中获取用户信息
    • 存入SecurityContextHolder

2.3.2 准备工作

1.添加依赖

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.83</version>
</dependency>
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>4.4.0</version>
</dependency>

2.添加redis相关配置

/**
* Redis使用fastjson序列化
* @param <T>
*/
public class FastJsonRedisSerializer<T> implements RedisSerializer<T> {

public static final Charset DEFAULT_CHARSET = StandardCharsets.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);
}
}
@Configuration
public class RedisConfig {
@Bean
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.setHashValueSerializer(new StringRedisSerializer());
template.setValueSerializer(serializer);

//hash
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(serializer);
return template;
}
}

3.响应类

package com.yuan.tokendemo.domain;

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, T data) {
this.code = code;
this.msg = msg;
this.data = 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;
}
/**
* set get方法 略
*/
}

4.工具类

package com.yuan.tokendemo.utils;
/**
* JWT工具类
*/
public class JwtUtil {

//有效期
public static final Long JWT_TTL = 60*60*1000L;
//设置密钥明文
public static final String JWT_KEY = "leviran20230801leviran20230801leviran20230801leviran20230801"; //至少32个字符

public static String getUUID(){
return UUID.randomUUID().toString().replaceAll("-","");
}

/**
* 生成jwt
* @param subject
* @return
*/
public static String createJWT(String subject){
JwtBuilder builder = getJwtBuilder(subject,null,getUUID());//设置过期时间
return builder.compact();
}
/**
* 生成jwt
* @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();
}

/**
* 创建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();
}


/**
*
* @param subject
* @param ttlMillis
* @param uuid
* @return
*/
private static JwtBuilder getJwtBuilder(String subject, Long ttlMillis, String uuid) {
SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
SecretKey secretKey = generateKey();
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("yuan") //签发者
.setIssuedAt(now) //签发时间
.signWith(secretKey,signatureAlgorithm) //使用HS256对称加密算法签名,第一个参数是密钥
.setExpiration(expDate);
}

/**
* 生成加密后的密钥 secretKey
* @return
*/
private static SecretKey generateKey() {
byte[] encodeKey = Base64.getDecoder().decode(JwtUtil.JWT_KEY);
SecretKey key = new SecretKeySpec(encodeKey,0,encodeKey.length,"HmacSHA256"); //jjwt新版本不支持AES
return key;
}

/**
* 解析
* @param jwt
* @return
*/
public static Claims parseJWT(String jwt) throws Exception{
SecretKey secretKey = generateKey();
// return Jwts.parser()
// .setSigningKey(secretKey)
// .parseClaimsJws(jwt)
// .getBody();
//todo 我改了
return Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(jwt)
.getBody();
}

public static void main(String[] args) throws Exception {
// String jwt = createJWT("123456");
// System.out.println(jwt);
String token = "eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiIzMTFmMWNiNzMxOGE0ZTYxYWQ1MTFhNjcyYTFiYzNlNSIsInN1YiI6IjEyMzQ1NiIsImlzcyI6Inl1YW4iLCJpYXQiOjE2OTA4NzI1MzgsImV4cCI6MTY5MDg3NjEzOH0.4RUkzWFmNEqkbNeDx7N9-Cgz7A43KpErcV0EQnMUTEI";
Claims claims = parseJWT(token);
System.out.println(claims);
}
}
@SuppressWarnings(value = {"unchecked","rawtypes"})
@Component
public class RedisCache {

@Autowired
public RedisTemplate redisTemplate;

/**
* 缓存基本的对象,Integer、String、实体类等
* @param key 缓存的键
* @param value 缓存的值
* @param <T>
*/
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 时间颗粒度
* @param <T>
*/
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 键
* @param timeout 超时时间
* @return true=设置成功
*/
public boolean expire(final String key,final long timeout){
return expire(key,timeout,TimeUnit.SECONDS);
}

/**
* 设置有效时间
* @param key
* @param timeout
* @param unit
* @return
*/
public boolean expire(final String key,final long timeout,final TimeUnit unit){
return redisTemplate.expire(key,timeout,unit);
}

/**
* 获得缓存的基本对象
* @param key
* @param <T>
*/
public <T> T getCacheObject(final String key){
ValueOperations<String,T> operation = redisTemplate.opsForValue();
return operation.get(key);
}

/**
* 删除单个对象
* @param key
* @return
*/
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 缓存的对象
* @param <T>
*/
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
* @param <T> 缓存键对应的数据
*/
public <T> List<T> getCacheList(final String key){
return redisTemplate.opsForList().range(key,0,-1);
}

/**
* 缓存set
* @param key
* @param dataSet
* @return 缓存数据的对象
* @param <T>
*/
public <T>BoundSetOperations<String,T> setCacheSet(final String key, final Set<T> dataSet){
BoundSetOperations<String,T> setOperations = redisTemplate.boundSetOps(key);
Iterator<T> it = dataSet.iterator();
while (it.hasNext()){
setOperations.add(it.next());
}
return setOperations;
}

/**
* 获得缓存的set
* @param key
* @return
* @param <T>
*/
public <T> Set<T> getCacheSet(final String key){
return redisTemplate.opsForSet().members(key);
}

/**
* 缓存map
* @param key
* @param dataMap
* @param <T>
*/
public <T> void setCacheMap(final String key, final Map<String,T> dataMap){
if (dataMap !=null){
redisTemplate.opsForHash().putAll(key,dataMap);
}
}

/**
* 获得缓存的map
* @param key
* @return
* @param <T>
*/
public <T> Map<String,T> getCacheMap(final String key){
return redisTemplate.opsForHash().entries(key);
}

/**
* 往hash中存入数据
* @param key
* @param hkey
* @param value
* @param <T>
*/
public <T> void setCacheMapValue(final String key, final String hkey,final T value){
redisTemplate.opsForHash().put(key,hkey,value);
}

/**
* 获取map值
* @param key
* @param hkey
* @return
* @param <T> hash中的对象
*/
public <T> T getCacheMapValue(final String key, final String hkey){
HashOperations<String,String,T> operations = redisTemplate.opsForHash();
return operations.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
* @param hkeys
* @return
* @param <T>
*/
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);
}
}
public class WebUtils {
/**
* 将字符串渲染到客户端
* @param response 渲染对象
* @param string 待渲染的字符串
* @return
*/
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;
}
}

5.实体类

@Data
@AllArgsConstructor
@NoArgsConstructor
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;
}

2.3.3 实现

2.3.3.1 数据库校验用户

创建数据库,导入依赖,application.yml配置数据库信息

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='用户表'
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.3</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
//定义mapper接口
@Mapper
public interface UserMapper extends BaseMapper<User> {
}

核心代码

创建一个UserDetailsService接口的实现类,从数据库中读取用户信息

package com.yuan.tokendemo.service.impl;

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
UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//根据username查询用户信息:
// 用户信息
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(User::getUserName,username);
User user = userMapper.selectOne(queryWrapper);
if(Objects.isNull(user)){
throw new RuntimeException("用户名或密码错误"); //其实这里和密码没关系
}
// todo 权限
// 返回出去
return new LoginUser(user);
}
}

因为UserDetailsService返回的是UserDetails类型的数据,所以需要定义一个类实现该接口,封装用户信息

@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;
}
}

注意:

数据库中如果要使用用户名+密码登录,需要在密码前加{noop}使之明文存储,否则校验失败。但实际生产环境肯定不能这样,需要将密码加密存储。

image-20211216123945882
2.3.3.2 密码加密存储
image-20230801124807636

默认使用的PasswordEncoder要求数据库中的密码格式为:{id}password。它会根据id去判断密码的加密方式(eg.id内容为noop,则说明是明文未加密,则直接拿{noop}后面的内容作为密码),但是一般我们使用SpringSecurity提供的BCryptPasswordEncoder来替换PasswordEncoder

步骤:

只需要使用把BCryptPasswordEncoder对象注入Spring容器中,SpringSecurity就会使用该PasswordEncoder来进行密码校验。

更新

旧版本配置:

定义一个SpringSecurity的配置类,配置类要继承WebSecurityConfigurerAdapter,重写configure方法;

新版本配置:

开发者不建议扩展WebSecurityConfigurerAdapter并覆盖用于配置 HttpSecurity 和 WebSecurity 的方法,而是鼓励用户转向基于组件的安全配置,直接声明两个类型为SecurityFilterChainWebSecurityCustomizer的 bean。

具体看CSDN,实现方式如下:

1.自定义一个SpringSecurity的配置类,添加@EnableWebSecurity注解

@EnableWebSecurity
@Configuration
public class SecurityConfig{
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
}
2.3.3.3 登录接口

Spring Security+JWT是对用户请求做认证和授权,但是登录接口需要放行,用户未登录也可以访问。

//定义登录接口
@RestController
public class LoginController {

@Autowired
private LoginServcie loginServcie;

@PostMapping("/user/login")
public ResponseResult login(@RequestBody User user){
return loginServcie.login(user);
}
}

在接口中我们通过AuthenticationManager的authenticate方法来进行用户认证,所以需要在SecurityConfig中配置把AuthenticationManager注入容器。

//放行登录接口
@EnableWebSecurity
@Configuration
@EnableMethodSecurity //默认(prePostEnabled=true)
public class SecurityConfig{

	//创建BCryptPasswordEncoder注入容器
	@Bean
	public PasswordEncoder passwordEncoder(){
		return new BCryptPasswordEncoder();
	}

	//新版获取AuthenticationManager方法
	@Bean
	public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception{
		return authConfig.getAuthenticationManager();
	}

	@Bean
	public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{
		http.
				//关闭csrf防止跨站伪造请求
				csrf().disable()
				//前后端分离项目不通过session获取SecurityContext
				.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
				.and()
				.authorizeHttpRequests(authz->authz
						//对于登录接口 允许匿名访问,即已登录不需要访问
						.requestMatchers("/user/login").anonymous()
						//除上面外所有请求全部需要认证
						.anyRequest().authenticated());

		return http.build();
	}

}

认证成功的话要生成一个jwt,放入响应中返回。并且为了让用户下回请求时能通过jwt识别出具体的是哪个用户,我们需要把用户信息存入redis,可以把用户id作为key。

@Service
public class LoginServiceImpl implements LoginService {

@Autowired
AuthenticationManager authenticationManager;

@Autowired
RedisCache redisCache;

@Override
public ResponseResult login(User user) {
//AuthenticationManager authenticate进行用户认证
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getUserName(),user.getPassword());
Authentication authenticate = authenticationManager.authenticate(authenticationToken);

//认证失败则给出提示信息
if(Objects.isNull(authenticate)){
throw new RuntimeException("登录失败");
}
//认证通过则生成jwt,存入redis
//在这里jwt根据userid获取(实际不建议,因为userid不变,旧的token也能解析出有效userid有风险)
LoginUser loginUser = (LoginUser) authenticate.getPrincipal();
String userId = loginUser.getUser().getId().toString();
String jwt = JwtUtil.createJWT(userId);
Map<String,String> map = new HashMap<>();
map.put("token",jwt);
//完整信息保存到redis
redisCache.setCacheObject("login:"+userId,loginUser);
return new ResponseResult(200,"登录成功",map);
}
}
2.3.3.4 认证过滤器

上面几步完成的事情是:用户携带用户名和密码登录时,去数据库查询是否有对应用户,如果有(认证通过)则签发一个token返回给用户。

那么接下来的用户请求不需要再输入用户名和密码,直接携带token访问即可。所以还需要定义认证过滤器,解析用户请求携带的token。

认证过滤器具体内容包括:

  • 解析用户携带token,判断是否合法,若不合法则拦截
  • 若token合法,则解析拿到key信息,使用key去redis里查询完整用户信息
  • 封装Authentication对象存入SecurityContextHolder,方便后面的过滤器获取认证信息
@Component
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");
if (!StringUtils.hasText(token)) {
//放行
filterChain.doFilter(request, response);
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("用户未登录");
}
//存入SecurityContextHolder
//获取权限信息封装到Authentication中
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginUser,null,loginUser.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
//放行
filterChain.doFilter(request, response);
}
}
image-20230802191128551
2.3.3.5 退出登录

用户登录产生了两种东西:jwt的token、redis中存储的登录用户信息。所以恢复这两个东西就可以恢复到登出状态。

  • JWT:jwt一旦签发,无法更改,没有到达jwt的过期时间的话是无法干预的;
  • Redis:redis是可以修改的,用户登出则删除redis中的用户相关信息,则即使有token也无法再使用了,因为token解析后无法从redis中查询任何信息。

另外redis的key不建议设置为用户id,因为不管什么时候签发的token,解析出来的userid都是一样的,也就是说用户登出后如果token1没有过期,token1之所以无法使用是因为redis中没有有效信息了,当用户在token1的有效期内再次登录时redis就有信息了,即使签发了新的token2,token1仍能正常使用。所以redis的key可以设置成uuid,每次根据token解析的uuid查询redis信息,而uuid和硬件等资源有关,每次生成的都不一样,那么token1解析出的key无法访问第二次登录存储的redis信息。

如果要用userid也可以,在redis中设置一个黑名单,用户登出后把原先的token放在黑名单中,过期后删除即可。

SecurityContextHolder是和线程绑定的。

代码:定义登出接口,删除redis信息

 @Service
 public class LoginServiceImpl implements LoginService {
     @Autowired
  RedisCache redisCache;
 ​
     @Override
     public ResponseResult logout() {
         //删掉redis中的用户信息
         //todo 但是这样用户重新登录之后,只要原先的token没有过期就还能使用
         Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
         LoginUser loginUser = (LoginUser) authentication.getPrincipal();
         redisCache.deleteObject("login:"+loginUser.getUser().getId());
         return new ResponseResult(200,"退出登录成功",null);
    }
 }

3.授权

Spring Security的授权和Sentinel不同,Sentinel强调的是基于规则的流量控制,例如拦截那些绕过网关直接访问服务的请求。Spring Security判断用户请求权限。

3.1 授权基本流程

使用默认的FilterSecurityInterceptor来进行权限校验,先从SecurityContextHolder中获取权限信息,在FilterSecurityIntercepter中做权限校验,校验通过则放行,跨域访问对应接口,校验失败则拦截请求。

image-20230801173654228

所以我们只需要把当前用户的权限信息存入authentication,然后设置访问资源所需权限即可。

3.2 授权实现

3.2.1 限制访问资源所需权限

首先要指明访问某资源需要的权限是什么。SpringSecurity为我们提供了基于注解的权限控制方案:

1.开启相关配置

 @EnableMethodSecurity //默认(prePostEnabled=true)
 public class SecurityConfig{
     //...
 }

2.使用对应注解@PreAuthorize

image-20230802193342278

3.2.2 封装权限信息

访问资源所需的权限已知,那么还需要知道用户拥有什么权限。SpringSecurity的@PreAuthorize中的函数会根据用户的权限判断是否能够访问该资源。

由前面可知,用户权限要放在UserDetails中,框架会自动调用。

1.LoginUser(UserDetails)添加权限字段

这里涉及到两个属性:

  • private List<String> permissions
  • private List<SimpleGrantedAuthority> authorities;

其实授权过程中获取用户权限信息时框架调用的是public Collection<? extends GrantedAuthority> getAuthorities(),即获取的是authorities,之所以还需要permissions是因为redis中无法存储GrantedAuthority,不符合设计规范会报错,但是权限信息又需要存储在redis中方便解析jwt时获取权限封装到Authentication中。

 @Data
 @NoArgsConstructor
 public class LoginUser implements UserDetails {
 ​
  private User user;
 ​
  //存储权限信息
  private List<String> permissions;
  //redis里可以存String,但是存储SimpleGrantedAuthority会报错,默认不允许存储这种类型,不安全??
 ​
  //存储SpringSecurity所需要的权限信息的集合
  //这个集合不需要存储在redis中,所以设置不被序列化到stream
  @JSONField(serialize = false)
  // @JsonIgnore
  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;
  }
  //把permissions中字符串类型的权限信息转换成GrantedAuthority对象存入authorities中
  authorities = permissions.stream()
  .map(SimpleGrantedAuthority::new)
  .collect(Collectors.toList());
  return authorities;
  }
  //...一堆override
 }
 ​

2.获取权限信息

上面只是在LoginUser中添加了权限字段,具体用户权限信息需要在UserDetailsServiceImpl中获取并封装到LoginUser。

这里的权限信息需要从数据库中查询。

image-20230802195251500

3.2.3 从数据库查询权限信息

3.2.3.1 RBAC权限模型

RBAC权限模型(Role-Based Access Control)即:基于角色的权限控制。这是目前最常被开发者使用也是相对易用、通用权限模型。image-20211222110249727

3.2.3.2 准备工作

 CREATE DATABASE /*!32312 IF NOT EXISTS*/`security` /*!40100 DEFAULT CHARACTER SET utf8mb4 */;
 ​
 USE `security`;
 ​
 /*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;
 ​
 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
  AND m.`status` = 0
 /**
  * 菜单表(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;
 }

3.2.3.3 代码实现

根据用户id查询权限信息

 public interface MenuMapper extends BaseMapper<Menu> {
  List<String> selectPermsByUserId(Long id);
 }
 <?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.yuan.tokendemo.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=2
        and r.status=0
        and m.status=0
     </select>
 </mapper>
 mybatis-plus:
  mapper-locations: classpath*:com/yuan/tokendemo/mapper/**/*.xml
  configuration:
    map-underscore-to-camel-case: true
image-20230802195251500

4.自定义失败处理

在认证失败或者是授权失败的情况下和接口一样返回相同结构的json,这样可以让前端能对响应进行统一的处理。

SpringSecurity异常处理机制:

在认证或者授权的过程中出现了异常会被ExceptionTranslationFilter捕获到。在ExceptionTranslationFilter中会去判断是认证失败还是授权失败出现的异常。

如果是认证过程中出现的异常会被封装成AuthenticationException然后调用AuthenticationEntryPoint对象的方法去进行异常处理。

如果是授权过程中出现的异常会被封装成AccessDeniedException然后调用AccessDeniedHandler对象的方法去进行异常处理。

区别SpringMVC的异常处理:

SpringMVC可以用AOP做异常处理,@RestControllerAdvice注解添加到异常处理类定义统一异常处理,controller层只需要抛出异常即可,不同类型的异常将由异常处理类中不同方法处理。但是这种只能处理controller抛出的异常,无法涉及filter的异常。

思路分析:

根据Spring Security异常处理机制,如果我们需要自定义异常处理,我们只需要自定义AuthenticationEntryPoint和AccessDeniedHandler然后配置给SpringSecurity即可。

步骤:

1.自定义异常处理实现类

授权异常

 @Component
 public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
     @Override
     public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
         ResponseResult result = new ResponseResult(HttpStatus.FORBIDDEN.value(), "权限不足");
         String json = JSON.toJSONString(result);
         WebUtils.renderString(response,json);
    }
 }

认证异常

 @Component
 public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
     @Override
     public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException)
             throws IOException, ServletException {
         ResponseResult result = new ResponseResult(HttpStatus.UNAUTHORIZED.value(), "认证失败,请重新登录");
         String json = JSON.toJSONString(result);
         //将字符串渲染到客户端
         WebUtils.renderString(response,json);
    }
 }

2.配置给Spring Security

 @EnableWebSecurity
 @Configuration
 @EnableMethodSecurity //默认(prePostEnabled=true)
 public class SecurityConfig{
     @Autowired
  private AuthenticationEntryPoint authenticationEntryPoint;
 ​
  @Autowired
  private AccessDeniedHandler accessDeniedHandler;
     
     @Bean
  public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{
  //...
 ​
  // 配置异常处理器
  http.exceptionHandling()
  //配置认证失败处理器
  .authenticationEntryPoint(authenticationEntryPoint)
  .accessDeniedHandler(accessDeniedHandler);
  return http.build();
  }

5.跨域

由于浏览器同源策略,只有协议+域名+端口完全一致才可以互相请求通信。

前后端分离开发模式中一般前后端都不同源,所以需要处理跨域访问的问题。

步骤:

1.配置SpringBoot,允许跨域请求

 @Configuration
 public class CorsConfig implements WebMvcConfigurer {
  @Override
  public void addCorsMappings(CorsRegistry registry) {
  //设置允许跨域的路径
  registry.addMapping("/**")
  //设置允许跨域请求的域名
  .allowedOriginPatterns("*")
  //是否允许cookie
  .allowCredentials(true)
  //设置允许的请求方式,也可以直接写字符串
  .allowedMethods(HttpMethod.GET.name(),HttpMethod.POST.name(),HttpMethod.DELETE.name(),HttpMethod.PUT.name())
  //设置允许的header属性
  .allowedHeaders("*")
  //跨域允许时间
  .maxAge(3600);
  }
 }

2.开启Spring Security的跨域访问

 @EnableWebSecurity
 @Configuration
 @EnableMethodSecurity //默认(prePostEnabled=true)
 public class SecurityConfig{
  @Bean
  public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{
         //...
         
  //允许跨域
  http.cors();
  return http.build();
  }
 }

6.补充

6.1 其他权限校验方法

前面的权限校验方法是添加注解@EnableMethodSecurity,然后使用@PreAuthorize注解,注解中调用hasAuthority方法校验。除此方法外还有hasAnyAuthorityhasRolehasAnyRole等,也可以自定义权限校验方法,自定义校验逻辑。

在此之前需要先了解权限校验原理,以hasAuthority为例(断点调试可知内部的校验原理):

hasAuthority方法实际执行了SecurityExpressionRoothasAuthority,内部调用authenticationgetAuthorities方法获取用户的权限列表,然后判断我们存入的方法参数数据是否在权限列表中。

 //  hasAnyAuthority方法可以传入多个权限,只有用户有其中任意一个权限都可以访问对应资源。
     @PreAuthorize("hasAnyAuthority('admin','test','system:dept:list')")
     public String hello(){
         return "hello";
    }
 ​
 //hasRole要求有对应的角色才可以访问,但是它内部会把我们传入的参数拼接上 **ROLE_** 后再去比较。所以这种情况下要用户对应的权限也要有 **ROLE_** 这个前缀才可以。
  @PreAuthorize("hasRole('system:dept:list')")
     public String hello(){
         return "hello";
    }
 ​
 //hasAnyRole 有任意的角色就可以访问。它内部也会把我们传入的参数拼接上 **ROLE_** 后再去比较。所以这种情况下要用户对应的权限也要有 **ROLE_** 这个前缀才可以。
  @PreAuthorize("hasAnyRole('admin','system:dept:list')")
     public String hello(){
         return "hello";
    }

6.2 自定义权限校验方法

自定义权限校验方法,在@preAuthorize注解中使用自定义的方法。

 /**
  * 定义自己的权限校验方法
  * 调用方式:@Component("exp_name")
  * @PreAuthority("@exp_name.function_name(params)")
  */
 @Component("mer") //SPEL表达式
 public class MyExpressionRoot {
 ​
  public boolean hasAuthority(String authority){
  //获取当前用户的权限
  LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
  List<String> permissions = loginUser.getPermissions();
  //判断是否在权限集合中
  return permissions.contains(authority);
  }
 }
     @GetMapping("/hello")
  // @PreAuthorize("hasAuthority('system:dept:list')")
  @PreAuthorize("@mer.hasAuthority('system:dept:list')")
  public String hello(){
  return "hello";
  }

也可以不在@Component注解中定义新的名字,直接使用类名调用,类名首字母小写

image-20230802144043018

6.3 基于配置的权限控制

image-20230802144813629

6.4 CSRF

CSRF是指跨站请求伪造(Cross-site request forgery),是web常见的攻击之一。CSDN

前后端不分离:

img

SpringSecurity防止CSRF攻击原理:

后端生成一个csrf_token,前端发起请求时需要携带csrf_token,后端过滤器进行校验,若未携带或伪造则不允许访问。

所以此时只有Browser用户发起的请求才能携带上csrf_token,Web(B)只携带cookie而没有csrf_token无法通过Web(A)的校验。

前后端分离决定了不会有CSRF攻击:

CSRF攻击依靠的是cookie中携带的认证信息,前后端分离项目中认证信息实际上是token,而token并不设置在cookie中,需要前端代码把token设置到请求头中才可以,效果等同于csrf_token,所以天然避免了CSRF攻击。

6.5 认证成功处理器

实际上在UsernamePasswordAuthenticationFilter进行登录认证的时候,如果登录成功了是会调用AuthenticationSuccessHandler的方法进行认证成功后的处理的。AuthenticationSuccessHandler就是登录成功处理器。

注意:之前的项目里自定义LoginController直接调用AuthenticationManager,绕过了UsernamePasswordAuthenticationFilter.

在Spring Security框架中默认调用UsernamePasswordAuthenticationFilter,而自定义的SecurityConfig里面没有http.formLogin();,这个方法是调用UsernamePasswordAuthenticationFilter的,没有的话就不调用,而是直接走controller代码。

我们也可以自己去自定义成功处理器进行成功后的相应处理。

 @Component
 public class SGSuccessHandler implements AuthenticationSuccessHandler {
 ​
     @Override
     public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
         //随便写下
         System.out.println("认证成功了");
    }
 }
 @Configuration
 public class SecurityConfig extends WebSecurityConfigurerAdapter {
 ​
     @Autowired
     private AuthenticationSuccessHandler successHandler;
 ​
     @Override
     protected void configure(HttpSecurity http) throws Exception {
         //配置进来
         http.formLogin().successHandler(successHandler);
 ​
         http.authorizeRequests().anyRequest().authenticated();
    }
 }
 ​

6.6 认证失败处理器

同理

实际上在UsernamePasswordAuthenticationFilter进行登录认证的时候,如果认证失败了是会调用AuthenticationFailureHandler的方法进行认证失败后的处理的。AuthenticationFailureHandler就是登录失败处理器。

我们也可以自己去自定义失败处理器进行失败后的相应处理。

 @Component
 public class SGFailureHandler implements AuthenticationFailureHandler {
     @Override
     public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
         System.out.println("认证失败了");
    }
 }
 @Configuration
 public class SecurityConfig extends WebSecurityConfigurerAdapter {
 ​
     @Autowired
     private AuthenticationSuccessHandler successHandler;
 ​
     @Autowired
     private AuthenticationFailureHandler failureHandler;
 ​
     @Override
     protected void configure(HttpSecurity http) throws Exception {
         http.formLogin()
 //               配置认证成功处理器
                .successHandler(successHandler)
 //               配置认证失败处理器
                .failureHandler(failureHandler);
 ​
         http.authorizeRequests().anyRequest().authenticated();
    }
 }
 ​

6.7 登出成功处理器

 @Component
 public class SGLogoutSuccessHandler 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();
     }
 }

6.8 其他认证方案

img-20230802001

eg.自定义UsernamePasswordAuthenticationFilter实现需求,不仅需要存储Authentication到SecurityContextHolder,还需要调用上述登录成功处理器,生成jwt并存储信息到redis。

这种方法校验用户名密码跨域,但是扩展性不太好,例如当需要验证码验证时,还需要在UsernamePasswordAuthenticationFilter前面再加一层过滤器来校验验证码。

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注