SpringSecurity

简介

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查看

分析初体验流程

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

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

JWT的作用

JWT最常用的地方就是在认证授权这一块,只要用户登录,之后的每一个前端请求都会包含JWT,后端在处理用户请求之前,都会先进行JWT安全校验,通过后才进行后续操作

JWT的组成

JWT由3个部分构成,用.隔开,就像下面这样

eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiJmNTJiZTRjYWZiMzg0MDRhYWQzYmJkODRjYmRhYTNlOSIsInN1YiI6IjEyMzQiLCJpc3MiOiJicyIsImlhdCI6MTY1NTEwNzQzMCwiZXhwIjoxNjU1MTkzODMwfQ.v6GmTU2mf4CAVi_dZUyNRpAyCETwddbR9ZZUg0EDE1E

反编译网站

JWT Token在线解析解码 - ToolTT在线工具箱

这三部分分别是:

  • 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之后:

  1. header和payload可以直接利用base64解码出原文,从header中获取哈希签名的算法,从payload中获取有效数据
  1. signature由于使用了不可逆的加密算法,无法解码出原文,它的作用是校验token有没有被篡改。服务端获取header中的加密算法之后,利用该算法加上secretKey对header、payload进行加密,比对加密后的数据和客户端发送过来的是否一致。注意secretKey只能保存在服务端,而且对于不同的加密算法其含义有所不同,一般对于MD5类型的摘要加密算法,secretKey实际上代表的是盐值
    在实际开发中需要用下列手段来增加JWT的安全性:
  1. 因为JWT是在请求头中传递的,所以为了避免网络劫持,推荐使用HTTPS来传输,更加安全
  1. JWT的哈希签名的密钥是存放在服务端的,所以只要服务器不被攻破,理论上JWT是安全的。因此要保证服务器的安全
  1. 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();
}

退出登录

  1. 从SecurityContextHolder中拿到userid
  1. 根据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,然后从这里面获取权限信息,判断当前用户是否拥有访问该资源的权限.

因此,之后我们就需要

  1. 把用户的权限信息也存入Authentication当中,
  1. 然后设置访问该资源所需要的权限就可以了

设置权限

首先去配置类使用这个注解,开启相关配置

@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的学习项目

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值