likes
comments
collection
share

基于Springboot3的权限系统设计上篇

作者站长头像
站长
· 阅读数 1

技术选型

  1. java版本 17+
  2. redis版本7
  3. springboot版本3+
  4. SpringSecurity版本 :由Springboot3指定
  5. mybatis
  6. jwt版本 0.9.1

上面的java版本在17以上即可,因为springboot3强制要求必须使用java17及以上。至于redis与MySQL版本,大家可以随意选择。

愿景与思路

愿景

我们希望设计一种不依赖Session的安全系统。

思路

用户身份的验证

用户第一次登录时根据用户名及密码去MySQL中查出对应的权限信息并做基础的账号密码验证,以确保用户输入信息的正确性。

用户信息的保存

我们将登录时拿到的用户及其权限信息作为value,生成随机UUID作为key,存入redis。缓解MySQL压力,提升一般请求的响应速度。

用户权限信息的处理方案

利用一步生成的随机UUID生成jwt作为token作为登陆成功的返回值丢给前端。


正常请求流程演示

  1. 用户发送登录请求(登录端口为开放端口)
  2. 后台拿到用户信息并查询数据库
  3. 后台用查出的信息做验证,生成用户全信息对象(包含用户权限)
  4. 后台将用户全信息对象以UUID为key存入redis
  5. 后台以上面生成的UUID为基础生成jwt
  6. 后台响应用户,将jwt返回给用户。
  7. 前端将jwt设为token放入请求头
  8. 用户请求需要权限的接口
  9. 后台收到请求后获取token
  10. 将token解码成uuid
  11. 根据uuid去redis查询出相应用户全信息对象
  12. 根据查询出的全信息对象中的权限信息做鉴权
  13. 发现用户具有权限则响应接口

总结

在上面的设想中,用户只能拿到jwt加密后的uuid,保证了系统的安全性。 将权限信息放入redis,缓解MySQL压力,将真实的用户信息存入MySQL,保证重要数据可靠

一些前置工作

相关的依赖

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>3.0.2</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.33</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
<!--        jwt-->        
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.9.1</version>
        </dependency>
<!--        jwt解码-->
        <dependency>
            <groupId>javax.xml.bind</groupId>
            <artifactId>jaxb-api</artifactId>
            <version>2.3.0</version>
        </dependency>
        <dependency>
            <groupId>com.sun.xml.bind</groupId>
            <artifactId>jaxb-impl</artifactId>
            <version>2.3.0</version>
        </dependency>
        <dependency>
            <groupId>com.sun.xml.bind</groupId>
            <artifactId>jaxb-core</artifactId>
            <version>2.3.0</version>
        </dependency>
        <dependency>
            <groupId>javax.activation</groupId>
            <artifactId>activation</artifactId>
            <version>1.1.1</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter-test</artifactId>
            <version>3.0.2</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

MySQL数据库准备工作

首先我们需要一张基础的用户表以及MySQL相关的配置

用户表

  CREATE TABLE `sys_user` (
  `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `user_name` VARCHAR(64) NOT NULL DEFAULT 'NULL' COMMENT '用户名',
  `nick_name` VARCHAR(64) NOT NULL DEFAULT 'NULL' COMMENT '昵称',
  `password` VARCHAR(64) NOT NULL DEFAULT 'NULL' COMMENT '密码',
  `status` CHAR(1) DEFAULT '0' COMMENT '账号状态(0正常 1停用)',
  `email` VARCHAR(64) DEFAULT NULL COMMENT '邮箱',
  `phonenumber` VARCHAR(32) DEFAULT NULL COMMENT '手机号',
  `sex` CHAR(1) DEFAULT NULL COMMENT '用户性别(0男,1女,2未知)',
  `avatar` VARCHAR(128) DEFAULT NULL COMMENT '头像',
  `user_type` CHAR(1) NOT NULL DEFAULT '1' COMMENT '用户类型(0管理员,1普通用户)',
  `create_by` BIGINT(20) DEFAULT NULL COMMENT '创建人的用户id',
  `create_time` DATETIME DEFAULT NULL COMMENT '创建时间',
  `update_by` BIGINT(20) DEFAULT NULL COMMENT '更新人',
  `update_time` DATETIME DEFAULT NULL COMMENT '更新时间',
  `del_flag` INT(11) DEFAULT '0' COMMENT '删除标志(0代表未删除,1代表已删除)',
  PRIMARY KEY (`id`)
) ENGINE=INNODB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COMMENT='用户表'

DAO

这下面多出一个字段是留给redis传输中用的,后面会解释

package com.zhiqi.springsecurity.Dao;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.stereotype.Repository;

import java.io.Serializable;
import java.util.Date;
import java.util.List;

@Data
@AllArgsConstructor
@NoArgsConstructor
@Repository
public class User implements Serializable {
    /**
     * 主键
     */
    private Long id;


    private List<String> permission;
    /**
     * 用户名
     */
    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张表,我们配置一下我们的数据库

spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
# 数据库类型必须选这个
spring.datasource.type=com.zaxxer.hikari.HikariDataSource
spring.datasource.url=jdbc:mysql://ip地址:端口/spring6
spring.datasource.username=root
spring.datasource.password=密码
#指定mapper.xml的位置
mybatis.mapper-locations=classpath:/mapper/*.xml
#开启驼峰映射
mybatis.configuration.map-underscore-to-camel-case=true

上面的MySQL基础工作做完后,我们就可以来简单测试一下,因为我们的项目最终要整合的东西有点多,所以我们分阶段来测试,防止产生复杂bug

写一个简单的mapper,根据id查用户

public interface UserMapper {
    User getUserById(Long userId);   
}
<?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.zhiqi.springsecurity.mapper.UserMapper">

    <select id="getUserById" resultType="com.zhiqi.springsecurity.Dao.User">
        select * from sys_user
            where id=#{userId}
    </select>

</mapper>

这里需要大家手动插入一个用户,大家自己去数据库弄一下,保证有一个用户可以拿来查就行了

基于Springboot3的权限系统设计上篇 在上面的测试类中我们注入userMapper来测试

@Autowired
UserMapper userMapper;
@Test
void contextLoads() {
    System.out.println(userMapper.getUserById(1001L));
}

Redis相关配置

接下来我们配置Redis

Redis相关的Properties配置

spring.data.redis.host=ip
spring.data.redis.port=端口
spring.data.redis.password=密码
spring.data.redis.database=0
spring.data.redis.lettuce.pool.max-active=8
spring.data.redis.lettuce.pool.min-idle=0
spring.data.redis.lettuce.pool.max-idle=8
spring.data.redis.lettuce.pool.max-wait=-1ms

RedisTemplate简单封装

我们一般会把Springboot带的RedisTemplate进行简单的封装,让使用更加简洁

这个东西属于轮子,大家第一次接触可以看一下,之后直接复制粘贴就行,基本都会这么封装,我给它放在一个新建的Utils包里

package com.zhiqi.springsecurity.Utils;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.BoundSetOperations;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Component;

import java.util.*;
import java.util.concurrent.TimeUnit;

@Component
public class MyRedisCache {
    @Autowired
    public RedisTemplate redisTemplate;

    /**
     * 缓存基本的对象,Integer、String、实体类等
     *
     * @param key 缓存的键值
     * @param value 缓存的值
     */
    public <T> void setCacheObject(final String key, final T value)
    {
        redisTemplate.opsForValue().set(key, value);
    }

    /**
     * 缓存基本的对象,Integer、String、实体类等
     *
     * @param key 缓存的键值
     * @param value 缓存的值
     * @param timeout 时间
     * @param timeUnit 时间颗粒度
     */
    public <T> void setCacheObject(final String key, final T value, final Integer timeout, final TimeUnit timeUnit)
    {
        redisTemplate.opsForValue().set(key, value, timeout, timeUnit);
    }

    /**
     * 设置有效时间
     *
     * @param key Redis键
     * @param timeout 超时时间
     * @return true=设置成功;false=设置失败
     */
    public boolean expire(final String key, final long timeout)
    {
        return expire(key, timeout, TimeUnit.SECONDS);
    }

    /**
     * 设置有效时间
     *
     * @param key Redis键
     * @param timeout 超时时间
     * @param unit 时间单位
     * @return true=设置成功;false=设置失败
     */
    public boolean expire(final String key, final long timeout, final TimeUnit unit)
    {
        return redisTemplate.expire(key, timeout, unit);
    }

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

    /**
     * 删除单个对象
     *
     * @param key
     */
    public boolean deleteObject(final String key)
    {
        return redisTemplate.delete(key);
    }

    /**
     * 删除集合对象
     *
     * @param collection 多个对象
     * @return
     */
    public long deleteObject(final Collection collection)
    {
        return redisTemplate.delete(collection);
    }

    /**
     * 缓存List数据
     *
     * @param key 缓存的键值
     * @param dataList 待缓存的List数据
     * @return 缓存的对象
     */
    public <T> long setCacheList(final String key, final List<T> dataList)
    {
        Long count = redisTemplate.opsForList().rightPushAll(key, dataList);
        return count == null ? 0 : count;
    }

    /**
     * 获得缓存的list对象
     *
     * @param key 缓存的键值
     * @return 缓存键值对应的数据
     */
    public <T> List<T> getCacheList(final String key)
    {
        return redisTemplate.opsForList().range(key, 0, -1);
    }

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

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

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

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

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

    /**
     * 获取Hash中的数据
     *
     * @param key Redis键
     * @param hKey Hash键
     * @return Hash中的对象
     */
    public <T> T getCacheMapValue(final String key, final String hKey)
    {
        HashOperations<String, String, T> opsForHash = redisTemplate.opsForHash();
        return opsForHash.get(key, hKey);
    }

    /**
     * 删除Hash中的数据
     *
     * @param key
     * @param hkey
     */
    public void delCacheMapValue(final String key, final String hkey)
    {
        HashOperations hashOperations = redisTemplate.opsForHash();
        hashOperations.delete(key, hkey);
    }

    /**
     * 获取多个Hash中的数据
     *
     * @param key Redis键
     * @param hKeys Hash键集合
     * @return Hash对象集合
     */
    public <T> List<T> getMultiCacheMapValue(final String key, final Collection<Object> hKeys)
    {
        return redisTemplate.opsForHash().multiGet(key, hKeys);
    }

    /**
     * 获得缓存的基本对象列表
     *
     * @param pattern 字符串前缀
     * @return 对象列表
     */
    public Collection<String> keys(final String pattern)
    {
        return redisTemplate.keys(pattern);
    }

}

Redis序列化配置

因为Redis在将数据保存到数据库时默认使用的序列化有问题,我们希望它放进数据库的内容为JSON格式,需要为它指定相应的序列化类

@Configuration
public class redisConfig {

    @Bean
    public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory){
        RedisTemplate<Object, Object> template = new RedisTemplate();
        //TODO 别的地方全是照抄源码,只有这里放入我们的JSON转换类
        template.setDefaultSerializer(new GenericJackson2JsonRedisSerializer());
        template.setConnectionFactory(redisConnectionFactory);
        return template;
    }


}

Redis测试

这里大家可以去测试一下Redis数据库是否正常。


jwt相关配置

jwt负责我们token的转码以及解码工作,我们需要进行简单的封装,同样是放在Utils包内,里面的JWT_KEY有效期大家可以自己设置,不过这里需要专门提一下,这个东西是为了能够在用户长时间登录后让用户下线重新登录的

不过鉴于jwt存在一定的安全隐患,我们会在存Redis时指定存入数据的有效时间,到期Redis内数据直接过期,从根本上杜绝了因为jwt带来的安全风险

所以我们这里给的有效时间需要与后面配Redis时给的有效时间保持同步,以免导致一个过期了一个没过期

package com.zhiqi.springsecurity.Utils;



import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;

import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;
import java.util.Date;
import java.util.UUID;


import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;

import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;
import java.util.Date;
import java.util.UUID;


public class JwtUtils {
    //有效期为
    public static final Long JWT_TTL = 3 * 60 * 60 *1000L;// 60 * 60 *1000  3个小时
    //设置秘钥明文
    public static final String JWT_KEY = "NcGoz5iQ";

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

    /**
     * 生成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= JwtUtils.JWT_TTL;
        }
        long expMillis = nowMillis + ttlMillis;
        Date expDate = new Date(expMillis);
        return Jwts.builder()
                .setId(uuid)              //唯一的ID
                .setSubject(subject)   // 主题  可以是JSON数据
                .setIssuer("zq")     // 签发者
                .setIssuedAt(now)      // 签发时间
                .signWith(signatureAlgorithm, secretKey) //使用HS256对称加密算法签名, 第二个参数为秘钥
                .setExpiration(expDate);
    }

    /**
     * 创建token
     * @param id
     * @param subject
     * @param ttlMillis
     * @return
     */
    public static String createJWT(String id, String subject, Long ttlMillis) {
        JwtBuilder builder = getJwtBuilder(subject, ttlMillis, id);// 设置过期时间
        return builder.compact();
    }

    public static void main(String[] args) throws Exception {
        String token = "eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiJjYWM2ZDVhZi1mNjVlLTQ0MDAtYjcxMi0zYWEwOGIyOTIwYjQiLCJzdWIiOiJzZyIsImlzcyI6InNnIiwiaWF0IjoxNjM4MTA2NzEyLCJleHAiOjE2MzgxMTAzMTJ9.JVsSbkP94wuczb4QryQbAke3ysBDIL5ou8fWsbt_ebg";
        Claims claims = parseJWT(token);
        System.out.println(claims);
    }

    /**
     * 生成加密后的秘钥 secretKey
     * @return
     */
    public static SecretKey generalKey() {
        byte[] encodedKey = Base64.getDecoder().decode(JwtUtils.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();
    }

}

其他准备工作

统一返回值

我们给前端的返回值格式最好统一一下,大家有自己写的可以用自己的,我这个是很久之前自己写的,有一点逻辑问题,我也懒得改,大家用我这个也可以,这个没什么特殊要求。

@Getter
@Repository
public class MyResult {


    private String state;
    private Integer stateId;

    private Object Data;


    private String message;



    public MyResult() {
    }

    public void setState(String state) {
        this.state = state;
    }

    public void setStateId(Integer stateId) {
        this.stateId = stateId;
    }

    public void setData(Object data) {
        Data = data;
    }

    public void setMessage(String message) {
        this.message = message;
    }

    public static MyResult OK() {
        MyResult myResult = new MyResult();
        myResult.setState("OK");
        myResult.setStateId(200);
        return myResult;
    }

    @Override
    public String toString() {
        return "MyResult{" +
                "state='" + state + ''' +
                ", stateId=" + stateId +
                ", Data=" + Data +
                ", message='" + message + ''' +
                '}';
    }

    public static MyResult BOOLEAN(Boolean b) {
        return b ? MyResult.OK() : MyResult.FAIL();
    }

    public static MyResult FAIL() {
        MyResult myResult = new MyResult();
        myResult.setState("FAIL");
        myResult.setStateId(200);
        return myResult;
    }

    public MyResult message(String message) {
        this.setMessage(message);
        return this;
    }
    public MyResult data(Object object) {
        this.setData(object);
        return this;
    }


}

完成登录验证

在这一步我们完有关登录的验证

UserDetailsService接口

这个类被SpringSecurity拿来做用户账号密码的校验

为什么我们要自己实现这个接口UserDetailsService

因为它的默认实现会从内存中去找所需的用户信息,也就是说SpringSecurity默认是自己找自己的搞的用户信息,但是我们想要的是它可以从我们的MySQL数据库里面去找相关的信息

所以我们需要自己去实现它并放进IOC,这样SpringSecurity就可以用我们给的UserDetailService来验证用户了。

代码实现

为了让这个实现看起来更清晰,我给它其名为CheckUser

@Component
public class CheckUser implements UserDetailsService {

    @Autowired
    UserMapper userMapper;

    //TODO 我们不能把存redis的工作放在这里,
    // 因为该类的本职工作应该是--->认证
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userMapper.getUserByName(username);
        Assert.notNull(user,"用户不存在!");

        user.setPermission(List.of("haha"));
        List<GrantedAuthority> grantedAuthorities = new ArrayList<>();
        user.getPermission().forEach(str -> {
             grantedAuthorities.add(new SimpleGrantedAuthority(str));
        });
        return new UserPro(user,grantedAuthorities);
    }
}

代码讲解

我们可以看到它给的默认方法给的参数是用户名,我们就先用它来查数据库。其实这里username的传入我们也可以控制,不过不是在这里,后面讲到时大家可以根据自己的需求来传。

user.setPermission(List.of("haha"));

这里我终于用到我们在设计这张用户表时多加的参数了,这里就可以给大家讲解它是什么了

它是设计用来存放用户权限信息的,当我们某些接口需要对应的权限才能访问时(从后端直接做鉴权,将指定等级的信息只发放给指定权限的用户),我在上面给了一个名为haha的权限,至于权限怎么绑定接口,后面讲到鉴权会详细说明,总之这一步我们直接不查MySQL,直接手动给一个权限。

List<GrantedAuthority> grantedAuthorities = new ArrayList<>(); 
user.getPermission().forEach(str ->{ 
        grantedAuthorities.add(new SimpleGrantedAuthority(str));
        });

GrantedAuthority是什么?

他是SpringSecurity自己用来存放权限信息的类型,我们上面自己的是String

SimpleGrantedAuthority是什么?

是上面GrantedAuthority的实现类,因为上面那个是个接口,只是最终返回值要的是GrantedAuthority的List,所以我们用GrantedAuthority接收。

为什么明明有了我们自己写的user权限字段的List还要搞个这个GrantedAuthority的List?

因为这个该死的GrantedAuthority的List没法序列化,存Redis不报错,重新转换回来时就会报错,转不回来,所以实际上SpringSecurity用的是GrantedAuthority,但为了数据传输,我们用自己写的User来传。

所以大家上面会看到,我们通过for循环把haha权限转换成了所需要的GrantedAuthority类型,并放入对应的List中。


UserPro

它是SpringSecurity存放用户以及权限信息的基本单元,同样是通过实现接口自己写的实现类

package com.zhiqi.springsecurity.Config.SpringSecurityConfig;


import com.zhiqi.springsecurity.Dao.User;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.stereotype.Repository;


import java.util.Collection;




public class UserPro extends org.springframework.security.core.userdetails.User {

    private com.zhiqi.springsecurity.Dao.User myUser;

    public UserPro(User myUser, Collection<? extends GrantedAuthority> authorities) {
        super(myUser.getUserName(), myUser.getPassword(), authorities);
        this.myUser = myUser;
    }

    public void setMyUser(User user){
        this.myUser = user;
    }
    public User getUser() {
        return this.myUser;
    }
}

为什么没有实现接口?

我们上面其实是继承了一个SpringSecurity自己的User类,因为这个类实现了我们应该实现的UserDetails接口,并做了一些基本的配置,帮我们省了很多事,我们直接继承它就相当于实现了UserDetails接口。下面是它的源码

public class User implements UserDetails, CredentialsContainer {

后面我们如果看到对于UserDetails的调用,我们就能知道,它实际上是调用了我们的这个UserPro

代码详解

我给它起名为UserPro,因为它里面除了包含我们自己User对象以外,还做了很多配置(继承的那个类帮忙配了,所以这里看不见),而且不同于我们自己的User,它是SpringSecurity自己使用的权限信息元对象

public UserPro(User myUser,
    Collection<? extends GrantedAuthority> authorities) {
    super(myUser.getUserName(), myUser.getPassword(), authorities);
    this.myUser = myUser;
}

结合上面UserCheck的最后一行代码来看

return new UserPro(user,grantedAuthorities);

我们用了这个构造器,传入一个自定义的user对象,再传入一个SpringSecurity所需的权限信息List,它调用父类构造器,这个父类构造器需要传入一个用户名,一个密码,还有一个权限信息list,这个父类构造器会帮我们创建完整的UserDetails对象,不过最后我们自己加的User字段则由我们自己赋值。

小总结

通过上面的代码我们已经完成了

  1. 用户账号密码的MySQL查找与验证,
  2. 完成了对权限信息的添加(手动添加,后面会改成查数据库)
  3. 对SpringSecurity所需源信息UserPro(UserDetails实现类)的创建。

账号校验前对SpringSecurity的基本配置

package com.zhiqi.springsecurity.Config.SpringSecurityConfig;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
public class MySpringSecurityConfig {

    @Autowired
    CheckUser userDetailService;

    @Autowired
    JwtAuthenticationFilter jwtAuthenticationFilter;


    /**
     * 设置存数据库时的加密方式,不允许明文密码存库
     * 该方法也被SpringSecurity拿来解密数据库中的password,以便验证用户身份
     */
    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    /**
     * 该Bean对象负责登录的账号密码认证工作
     */
    @Bean
    AuthenticationManager authenticationManager() {
        //TODO 创建一个DAO认证提供者
        DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
        daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());
        //TODO 放入我们的包含自定义认证方法的对象
        daoAuthenticationProvider.setUserDetailsService(userDetailService);
        //TODO 创建提供者的管理者,放入我们的提供者
        return new ProviderManager(daoAuthenticationProvider);
    }


    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        // 基于 token,不需要 csrf
        http
                .csrf().disable()
                // 开启跨域以便前端调用接口
                .cors()
                .and()
                .authorizeRequests()
                // 指定某些接口不需要通过验证即可访问。登录接口肯定是不需要认证的
                .requestMatchers("/user/login")
                .permitAll()
                // 这里意思是其它所有接口需要认证才能访问
                .anyRequest().authenticated()
                .and()
                // 基于 token,不需要 session
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                // cors security 解决方案
                ;

        http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }
}

对密码存储方式的配置

    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

这个东西本质上是一个加密工具,我们最初设想的是把密码直接明文存在数据库中,其实不应该这么做。这点SpringSecurity给我们了一个配置加密方式的点,我们只需要返回一个PasswordEncoder类型的加密工具即可,我们这里用了一个 BCryptPasswordEncoder,它也是SpringSecurity提供的,相对来讲安全一点。

这个地方因为我们设置了该加密方式,所以我们MySQL中自己写的那条数据的密码必须符合这种规范才行,所以我们通过测试,拿到加密后的密码

@Autowired
PasswordEncoder passwordEncoder;
@Test
void testMysql() throws Exception {
    String jack = passwordEncoder.encode("jack");
    System.out.println(jack);
}

我这里的密码设的是jack,大家自己随便设,我们看看控制台的加密结果

$2a$10$7rW.81G6NVDzdwEsPX8YTO.Vv5SVtzL9ovt9Lqbx03I1Va6SsQMfO

我们把数据库中的密码更新成加密后的新密码。

AuthenticationManager

这个上面的代码中说的很清楚,SpringSecurity中有一个提供者管理员,负责对用户身份校验方式提供者进行管理,而这个用户身份校验方式当然就是我们设计的这一套,查数据库,然后用我们的提供加密方式进行解密身份验证方案了,该Bean对象如上面我在注释中写的,负责对用户的认证工作

SecurityFilterChain

SpringSecurity的过滤器链,这个东西其实原理上很简单,就是当一个用户发来请求时,会先经过一系列的过滤器,比如我们熟知的基于Servlet的过滤器。而SpringSecurity本质上也是通过一个个过滤器来判断用户是否登录,是否具有指定的权限的,如果没有就直接不让请求就完了。

而上面的代码中我们就是在配置这个过滤器,比如上面对于登录接口的放行,如果登录接口都被拦截鉴权了,用户就没法访问了。

因为我们是基于数据库的密码认证强实现,不需要基于项目运行时内存中HttpSession来帮忙,所以我们直接关掉它。

http.addFilterBefore(jwtAuthenticationFilter, 
    UsernamePasswordAuthenticationFilter.class);

这段代码是我们待会要写的鉴权用过滤器,大家可以先注释掉它。

后续步骤展望

好了,到这这一步,我们终于配好了登录相关的一切功能,我们终于可以来测试登录了。

想想看,

  1. 用户请求登录的端口,发来一个user对象,包含账号密码。
  2. 我们去调用相关方法,这个方法会用我们的验证方案去查数据库,查到对应的账号密码,
  3. 然后对数据库里查出来的密码进行解密,
  4. 解密后比对用户发来的账号密码,
  5. 正确后我们就可以告诉用户登录成功了!

似乎缺了点什么,用户是登录成功了,那么假设用户后续请求一个被我们登录保护到的接口,它拿什么来告诉我们,他已经登录了呢?

也就是说,我们需要知道他是谁


jwt配合Redis确认用户身份

在此之前,我们先写一个简单的登录接口吧,毕竟总要有登录的Controller吧

@RestController()
@RequestMapping("user")
public class UserController {


    @Autowired
    LoginService loginService;

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

}

对应的Service接口

public interface LoginService {
    MyResult login(User user);
}

下面就是我们的重头戏,实现登录验证以及给用户生成身份凭证

@Service
public class LoginServiceImpl implements LoginService{

    @Autowired
    AuthenticationManager authenticationManager;

    @Autowired
    MyRedisCache redisCache;

    @Override
    public MyResult login(User user) {
        //先检验账号密码
        UserPro userDetail = getUserDetail(user);
        //TODO 创建一个用来验证用户身份的token
        String cliToken = "cliToken"+ UUID.randomUUID().toString().replace("-","");
        User user1 = userDetail.getUser();
        //TODO 以前端token为id放入redis中,避免频繁查mysql
        redisCache.setCacheObject(cliToken,user1,3, TimeUnit.HOURS);

        //TODO 以前端token为id生成jwt并返回给前端
        String jwt = JwtUtils.createJWT(cliToken);
        HashMap<String, Object> map = new HashMap<>();
        map.put("token",jwt);
        return MyResult.OK()
                .message("登陆成功!").
                data(map);
    }



    /**
     * 该方法验证传入的用户名以及密码是否正确
     * 如果正确则返回SpringSecurity的内部增强版用户对象
     * @param user
     * @return SpringSecurity的内部增强版用户对象
     */
    private UserPro getUserDetail(User user) {
        //TODO 根据用户名及密码创建一个符合SpringSecurity的验证对象
        UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken
                = new UsernamePasswordAuthenticationToken(user.getUserName(), user.getPassword());
        //TODO 使用我们的认证管理者来进行后台验证,
        // 其实还是调用我们自定义的UserDetailService进行验证,认证失败这个返回值就会直接变成空的
        Authentication authenticate = authenticationManager.authenticate(usernamePasswordAuthenticationToken);
        //TODO 出问题直接抛异常结束代码执行
        Assert.notNull(authenticate,"用户名或密码错误");
        //TODO 验证通过则抓出加强版的User对象,返回
        return (UserPro)authenticate.getPrincipal();
    }
}

其实上面这段代码我在注释中已经讲的很明白了,唯一需要说的就是这个我通过@AutoWired注入的authenticationManager,它究竟是谁呢?我们ctrl+alt点击上面注入的类名

基于Springboot3的权限系统设计上篇 注意看最后一个,我在上面说过,谁负责用户账号密码的校验来着?

基于Springboot3的权限系统设计上篇 还有这段代码也需要讲一下

redisCache.setCacheObject(cliToken,user1,3, TimeUnit.HOURS);

在存入Redis时,我设置了过期时间,保证与jwt的有效时间一致。

阶段测试

其实到这里,我们的登录验证工作就算完成了。我们来测试一下,不过要记得把前面说过的关于鉴权的代码注释掉哦

http.addFilterBefore(jwtAuthenticationFilter, 
    UsernamePasswordAuthenticationFilter.class);

我们启动项目,并向http://localhost:8080/user/login发送一个post登录请求,请求体里面我们带上如下参数,我在数据库中存的账号密码都是jack,所以我这里就这么写了,大家根据自己的情况填写加密前的账号跟密码,我们这里只带2个字段,能完成验证就行了。

{
    "userName":"jack",
    "password":"jack"
}

下面是我们请求的返回值。

{
    "state": "OK",
    "stateId": 200,
    "message": "登陆成功!",
    "data": {
        "token": "eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiI0ODQwODI2MWMzYWE0ODMwYTlmZmIyYjA2ZTgzY2JkNSIsInN1YiI6ImNsaVRva2VuZTUwNDJiNGEzOWY3NGMxMmFlMjY5ZTg2NGIwMmM2YzEiLCJpc3MiOiJ6cSIsImlhdCI6MTcwMDY1MTEzNywiZXhwIjoxNzAwNjYxOTM3fQ.P_nNkZxdHzoFE-DnYm50ixp_1bCclWqFmkQjGrzzxW4"
    }
}

大家可以去搜索一下jwt解密,尝试破解一下,我贴一下结果

{
  "jti": "48408261c3aa4830a9ffb2b06e83cbd5",
  "sub": "cliTokene5042b4a39f74c12ae269e864b02c6c1",
  "iss": "zq",
  "iat": 1700651137,
  "exp": 1700661937
}

可以看到,即便解密,依旧是一串与真实用户账号密码毫不相干的UUID,而且相同账号密码的token过期后每次重新生成的都不同


我们再去redis看一下放入的User对象,key就是上面的随机uuid,value则是包含了用户

  1. 账号jack
  2. 加密后的密码
  3. 权限信息haha

基于Springboot3的权限系统设计上篇


权限校验

我们只完成用户的登录是完全不够的,对于不同用户权益的分级,需要我们对接口进行鉴权

开启接口鉴权

在SpringSecurity中,要想开启接口鉴权,我们需要在主启动类上加上如下注解

@EnableGlobalMethodSecurity(prePostEnabled = true)

对于需要鉴权的方法上则要加上下面这个注解

@GetMapping("haha")
@PreAuthorize("hasAuthority('haha')")
public MyResult haha(){
    return MyResult.OK().message("hahaha~");
}

这里我写了一个haha方法,返回一个hahaha~,注解上我指明了要haha权限才能访问

编写鉴权逻辑

还记得我在去MySQL取用户信息时放入的权限List吗?

user.setPermission(List.of("haha"));
List<GrantedAuthority> grantedAuthorities = new ArrayList<>();
user.getPermission().forEach(str -> {
     grantedAuthorities.add(new SimpleGrantedAuthority(str));
});
return new UserPro(user,grantedAuthorities);

其实我在这一步就模拟放入了haha权限,接下来我们来写我上面让大家注释掉的 jwtAuthenticationFilter

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    //注入Redis工具类,方便我们待会从redis取用户信息
    @Autowired
    MyRedisCache myRedisCache;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        //从请求头里面取token
        String token = request.getHeader("token");
        //TODO 没有token则放行,由登录验证调用UserDetailsService去拦截
        if (token==null){
            filterChain.doFilter(request,response);
            return;
        }

        //TODO 如果有token则jwt解码
        String subject = null;
        try {
            subject = JwtUtils.parseJWT(token).getSubject();
        } catch (Exception e) {
            throw new RuntimeException("token非法!");
        }
        //TODO jwt 解码成功则去查redis,再次确认
        User user = myRedisCache.getCacheObject(subject);
        Assert.notNull(user,"请重新登录!");

        //TODO 拿到权限信息转换成指定合集
        List<GrantedAuthority> grantedAuthorities = user.getPermission()
                .stream().map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
        //TODO 传入信息生成验证token
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(
                        user.getUserName(),user.getPassword(),grantedAuthorities);

        //TODO 该方法才是最终存储权限信息的位置,
        // 放入SpringSecurity的验证对象
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        //TODO 此时已经有相关信息,则会一路进行身份以及权限验证,最终通过
        filterChain.doFilter(request,response);
    }


}

大致先讲一下上面的代码做了什么。

  1. 用户向需要鉴权的接口发请求

  2. 判断请求头中有没有token,如果没有则表示用户没有登录,我们直接放行,后面我们之前写的登录拦截器就会给他拦下来了。

  3. 好,如果有token,就解码,如果解码报错说明什么?说明用户伪造了token!直接抛出异常,这里说一下,在SpringSecurity中的所有它相关异常都会被SpringSecurity拦截,我们后面专门做处理,暂时想让SpringSecurity默认处理。

  4. 如果token解密完成没有问题,此时我们根据token去查Redis数据库,查到之后转换成User对象,如果转换的对象为空,则说明Redis中没有这个用户的数据,这说明什么?说明用户信息过期了,我们依旧抛出异常。它还是访问不到/haha.

  5. 如果成功转换User对象,我们就开始考虑做权限校验了,

  6. 我们在登录验证时就看到过这个UsernamePasswordAuthenticationToken,之前说过,这个是拿来存放需要验证的用户信息的对象,但此时它不仅需要账号密码,还需要权限信息,毕竟我们是要做接口鉴权的

  7. 让我们回到最初的那个SpringSecurity自己存放用户信息的UserPro,我们可以看到,人家存的这个权限信息可不是像咱么一样,随便搞个ArrayList就存了,而是一个专门存放GrantedAuthorityList(实际用了它的实现类SimpleGrantedAuthority),所以我上面取出这个User中的haha权限后还做了一系列的类型转换工作,最终生成了符合要求的List<GrantedAuthority>,并放入到了UsernamePasswordAuthenticationToken

  8. 剩下的就是SpringSecurity的工作了,我们只需要通过SpringSecurity提供的方法存入符合要求的鉴权对象,SpringSecurity就会拿我们的这个鉴权对象去跟我们从数据库中取到的信息进而生成的UserPro做校验

  9. 验证没成功他就直接拒绝用户请求了,我们不用操心,我们只需要在上面的方法验证通过后写上最后一句代码,dofilter放行

让jwt过滤器生效

最后要讲2个东西

  1. 上面的类我们继承了一个接口OncePerRequestFilter,这个过滤器在每次请求时都会执行,所以我们用它,毕竟鉴权每次都要做,除了我们百分百放行的登录接口,所以我们用了这个过滤器。
  2. 最后解释一下我们上面曾经注释掉的代码
http.addFilterBefore(jwtAuthenticationFilter, 
    UsernamePasswordAuthenticationFilter.class);

addFilterBefore,翻译一下就是添加在过滤器之前, 需要2个参数,

  1. 放什么过滤器?
  2. 放在哪个过滤器之前?

我们放了用户鉴权过滤器,放在了验证账号密码的过滤器之前。

这里解释了为什么我说请求头里面没有token可以直接放行,因为登录验证的拦截器会拦住它,也说明了为什么我们在做鉴权的UsernamePasswordAuthenticationToken中也要放账号密码,并非是多此一举,因为要想一路畅通不被后面的登录拦截到,就必须有账号密码(其实这句话是我的猜想,因为当时我在看鉴权源码时发现确实有方法会过一下登录验证,但没有debug进去看)。


测试一下,删掉redis中的数据

我们先尝试直接不登录请求haha

基于Springboot3的权限系统设计上篇 postman没有任何响应,浏览器请求试试

基于Springboot3的权限系统设计上篇

直接给拒绝了,我们登录后尝试一下。

基于Springboot3的权限系统设计上篇 拿到token,放进请求头再发

基于Springboot3的权限系统设计上篇

成功拿到hahah~


好了好了,上篇就先写到这里。 在下篇中我会详细的讲

  1. 用户权限模型RBAC的原理与实现,
  2. 异常的统一处理,
  3. 验证成功的统一处理
  4. 验证失败的统一处理

下篇地址

不管怎么样,在上面的逻辑中,我们已经成功打通了登录与鉴权的流程。但还不算登堂入室,如果你厌倦了写一个又一个的CRUD玩具项目,希望从一个编程基础技术栈的学习者转变成为真正拜入山门的开发者,写出真正可用的代码,就关注我催更吧。