基于Security的认证授权框架简易实现

1、快速入门

1.1、准备工作

搭建简单的SpringBoot工程

设置父工程并添加依赖

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.5.7</version>
</parent>

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

创建启动类

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

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

创建控制层Controller

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class HelloController {

    @RequestMapping("/hello")
    public String hello() {
        return "hello";
    }
}

到这一部分,先测试下项目能不能正常启动并进行路径请求。如果成功即可继续向下操作。

1.2、引入SpringSecurity

在SpringBoot项目中使用SpringSecurity,只需要引入相应依赖即可。这里没有显示版本,是因为有父工程的存在。

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

引入依赖后在尝试去访问之前的接口会自动跳转到一个由SpringSecurity管理的默认页面,只有登录后才可以对接口进行访问。

换言之,就是访问路径后会跳到登录界面而不是直接访问路径里面的数据。

2、认证

2.1、登录校验流程

1-前端→携带用户名密码访问登录接口→服务器
2-服务器:与数据库中的用户名和密码进行校验,如果正确,将用户名/用户id生成一个jwt
3-服务器→把jwt进行响应→前端
4-前端→登录后访问其它请求需要在请求头中携带token→服务器
5-服务器:获取请求头中的token进行解析,获取userId
6-服务器:根据用户id获取用户相关信息,如果有权限则允许访问相关资源
7-服务器→访问目标,进行相应→前端

2.2、原理初探

2.2.1、SpringSecurity核心流程

对我来说算是核心了。。。

SpringSecurity的原理其实就是一个过滤器链,内部包含了提供各种功能的过滤器。以下只显示核心过滤器过程:ps:一共有15个默认过滤器

请求→...→UsernamePasswordAuthenticationFilter→...→ExceptionTranslationFilter→FilterSecurityInterceptor→API;

API→FilterSecurityInterceptor→ExceptionTranslationFilter→...→UsernamePasswordAuthenticationFilter→...→响应

解释:
UsernamePasswordAuthenticationFilter:负责处理在登录界面填写的用户名密码后的登录请求。

ExceptionTranslationFilter:处理过滤器链中抛出的任何AccessDeniedException和Authentication。

FilterSecurityInterceptor:负责权限校验的过滤器。
Authentication接口:它的实现类,表示当前访问系统的用户,封装了用户相关信息。
AuthenticationManager接口:定义了认证Authentication的方法。
UserDetailsService接口:加载用户特定数据的核心接口,里面定义了一个根据用户查询用户信息的方法。
UserDetails接口:提供核心用户信息,通过UserDetailsService根据用户名获取处理的用户信息封装成UserDetails对象返回。然后这些信息封装到Authentication对象中。

2.3、解决问题

2.3.1、思路分析

登录

  • 自定义登录接口:

    • 调用ProviderManager的方法进行认证,如果认证通过,生成jwt

    • 把用户信息存入redis中;

  • 自定义UserDetailsService

    • 在这个实现中查询数据库

校验

  • 定义jwt认证过滤器

    • 获取token并解析获取其中的userId

    • 从redis中获取用户信息,存入SecurityContextHolder中。

2.3.2、准备工作

添加依赖

<!-- redis依赖 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

<!-- fastjson依赖 -->
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>1.2.33</version>
</dependency>

<!-- jwt依赖 -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>

添加Redis相关配置

2.3.2.1、utils工具包

自定义序列化类

主要为了解决 Redis 默认序列化方式为 JDK 序列化时,可能存在的可读性较差、存储空间较大、兼容性较差等问题,而选择使用 FastJson 库进行更为灵活和高效的序列化和反序列化操作。

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.parser.ParserConfig;
import com.alibaba.fastjson.serializer.SerializerFeature;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.type.TypeFactory;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.SerializationException;

import java.nio.charset.Charset;

/**
 * 自定义序列化类
 * @param <T>
 */
public class FastJsonRedisSerializer<T> implements RedisSerializer<T> {

    public static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8");

    private Class<T> clazz;

    static {
        ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
    }

    public FastJsonRedisSerializer(Class<T> clazz) {
        super();
        this.clazz = clazz;
    }

    @Override
    public byte[] serialize(T t) throws SerializationException {
        if (null == t) {
            return new byte[0];
        }
        return JSON.toJSONString(t, SerializerFeature.WriteClassName).getBytes(DEFAULT_CHARSET);
    }

    @Override
    public T deserialize(byte[] bytes) throws SerializationException {
        if (null == bytes || bytes.length <= 0) {
            return null;
        }
        String str = new String(bytes, DEFAULT_CHARSET);
        return (T) JSON.parseObject(str, clazz);
    }

    protected JavaType getJavaType(Class<?> clazz) {
        return TypeFactory.defaultInstance().constructType(clazz);
    }
}

JwtUtil工具类

用于jwt生成token,jwt本质是一种签名。

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

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

/**
 * JWT工具类
 */
public class JwtUtil {

    // 有效期
    public static final Long JWT_TTL = 60 * 60 * 1000L;
    // 设置秘钥明文
    public static final String JWT_KEY = "hashiqi";

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

    /**
     * 生成jwt
     * @param subject token中要存放的数据(json格式)
     * @return 返回jwt
     */
    public static String createJwt(String subject) {
        JwtBuilder builder = getJwtBuilder(subject, null, getUUID()); // 设置过期时间
        return builder.compact();
    }

    /**
     * 生成jwt
     * @param subject token中要存放的数据(json格式)
     * @param ttlMillis 设置token超时时间
     * @return 返回jwt
     */
    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 (null == ttlMillis) {
            ttlMillis = JwtUtil.JWT_TTL;
        }
        long expMillis = nowMillis + ttlMillis;
        Date expDate = new Date(expMillis);
        return Jwts.builder()
                .setId(uuid)    // 唯一ID
                .setSubject(subject)    // 主体,JSON数据
                .setIssuer("hashiqi")   // 签发者
                .setIssuedAt(now)       // 签发时间
                .signWith(signatureAlgorithm, secretKey)    // 1-使用HS256对称加密算法签名,2-秘钥
                .setExpiration(expDate);

    }

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

    /**
     * 生成加密后的秘钥 secretKey
     * @return 返回SecretKey
     */
    public static SecretKey generalKey() {
        byte[] encodeKey = Base64.getDecoder().decode(JwtUtil.JWT_KEY);
        return new SecretKeySpec(encodeKey, 0, encodeKey.length, "AES");
    }

    /**
     * 解析token
     * @param jwt JWT
     * @return 返回数据
     */
    public static Claims parseJwt(String jwt) throws Exception {
        SecretKey secretKey = generalKey();
        return Jwts.parser()
                .setSigningKey(secretKey)
                .parseClaimsJws(jwt)
                .getBody();
    }
}

RedisCache工具类

主要用于封装一些redis对数据操作的方法。

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.*;
import org.springframework.stereotype.Component;

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

@SuppressWarnings(value = {"unchecked", "rawtypes"})
@Component
public class RedisCache {

    @Autowired
    public RedisTemplate redisTemplate;

    /**
     * 缓存的基本对象。Integer String 实体类
     *
     * @param key  缓存的键值
     * @param value 缓存的值
     * @param <T>
     * @return    缓存的对象
     */
    public <T> ValueOperations<String, T> setCacheObject(String key, T value){
        ValueOperations<String, T> operations = redisTemplate.opsForValue();
        operations.set(key, value);
        return operations;
    }

    /**
     *
     * @param key  缓存的键值
     * @param value  缓存的值
     * @param timeout  时间
     * @param timeUnit  时间颗粒度
     * @param <T>
     * @return   缓存的对象
     */
    public <T> ValueOperations<String, T> setCacheObject(String key, T value, Integer timeout, TimeUnit timeUnit){
        ValueOperations<String, T> operations = redisTemplate.opsForValue();
        operations.set(key, value, timeout, timeUnit);
        return operations;
    }

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

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


    /**
     * 删除集合对象
     *
     * @param collection
     */
    public void deleteObject(Collection collection){
        redisTemplate.delete(collection);
    }


    /**
     * 缓存list数据
     *
     * @param key    缓存的键值
     * @param dataList    带缓存的list数据
     * @param <T>
     * @return    缓存的对象
     */
    public <T> ListOperations<String, T> setCacheList(String key, List<T> dataList){
        ListOperations<String, T> listOperations = redisTemplate.opsForList();
        if (dataList != null) {
            int size = dataList.size();
            for (int i = 0; i < size; i++) {
                listOperations.leftPush(key, dataList.get(i));
            }
        }
        return listOperations;
    }


    /**
     *  获得缓存的list对象
     *
     * @param key  缓存的键值
     * @param <T>
     * @return   缓存键值对应的集合数据
     */
    public  <T> List<T> getCacheList(String key){
        List<T> list = new ArrayList<>();
        ListOperations<String, T> listOperations = redisTemplate.opsForList();
        Long size = listOperations.size(key);

        for (int i = 0; i < size; i++) {
            list.add(listOperations.index(key, i));
        }
        return list;
    }

    /**
     * 缓存Set
     *
     * @param key 缓存键值
     * @param dataSet 缓存的数据
     * @return 缓存数据的对象
     */
    public <T> BoundSetOperations<String, T> setCacheSet(String key, 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(String key) {
        Set<T> dataSet = new HashSet<T>();
        BoundSetOperations<String, T> operation = redisTemplate.boundSetOps(key);
        dataSet = operation.members();
        return dataSet;
    }

    /**
     * 缓存Map
     *
     * @param key
     * @param dataMap
     * @return
     */
    public <T> HashOperations<String, String, T> setCacheMap(String key, Map<String, T> dataMap) {
        HashOperations hashOperations = redisTemplate.opsForHash();
        if (null != dataMap)
        {
            for (Map.Entry<String, T> entry : dataMap.entrySet())
            {
                hashOperations.put(key, entry.getKey(), entry.getValue());
            }
        }
        return hashOperations;
    }

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

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

WebUtils工具类

 用于将字符串输出到客户端的 HTTP 响应中。通常用于在 Web 开发中,将 JSON 字符串作为响应返回给前端。通过设置相应的状态码、类型和字符编码,以及将字符串写入输出流的方式,实现将字符串渲染给客户端的功能。

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

2.3.2.2、config配置包

RedisConfig配置类

将工具包中的FastJsonRedisSerializer类进行聚合,通过调用此类的方法,对redis的key以及value进行序列化和反序列化。

import com.hashiqi.utils.FastJsonRedisSerializer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
public class RedisConfig {

    @Bean
    @SuppressWarnings(value = {"unchecked", "rawtypes"})
    public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<Object, Object> template = new RedisTemplate<>();
        //使用fastjson序列化
        FastJsonRedisSerializer fastJsonRedisSerializer = new FastJsonRedisSerializer(Object.class);
        // value值的序列化采用fastJsonRedisSerializer
        template.setValueSerializer(fastJsonRedisSerializer);
        template.setHashValueSerializer(fastJsonRedisSerializer);
        // key的序列化采用StringRedisSerializer
        template.setKeySerializer(new StringRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setConnectionFactory(redisConnectionFactory);
        return template;
    }
}

2.3.2.3、entity包

ResponseResult统一结果集类

为了和前端进行规范性操作而设置的。

import com.fasterxml.jackson.annotation.JsonInclude;

@JsonInclude(JsonInclude.Include.NON_NULL)
public class ResponseResult<T> {

    // 状态码
    private Integer code;
    // 提示信息
    private String message;
    // 数据
    private T data;

    public ResponseResult(Integer code, String message) {
        this.code = code;
        this.message = message;
    }

    public ResponseResult(Integer code, T data) {
        this.code = code;
        this.data = data;
    }
    
    public ResponseResult(Integer code, String message, T data) {
        this.code = code;
        this.message = message;
        this.data = data;
    }

    public Integer getCode() {
        return code;
    }

    public void setCode(Integer code) {
        this.code = code;
    }

    public String getMessage() {
        return message;
    }

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

    public T getData() {
        return data;
    }

    public void setData(T data) {
        this.data = data;
    }
}

User实体类

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

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

@Data
@AllArgsConstructor
@NoArgsConstructor
public class User implements Serializable {
    
    private static final long serialVersionUID = -1L;
    
    // 主键
    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;
    // 更新人的用户id
    private Long updateBy;
    // 更新时间
    private Date updateTime;
    // 删除标志(0:未删除;1:已删除)
    private Integer is_deleted;
}

此时这里可以将创建者、创建时间、最后更新者以及最后更新时间属性抽离出,单独形成一个基类,通过其他类去继承该基类,为什么这么做,是因为每个类里面基本上都包含这四个属性。设置逻辑删除也可以加入其中。

2.3.3、实现

2.3.3.1、数据库校验用户

通过之前分析,可以自定义一个UserDetailsService,让SpringSecurity使用UserDetailsService。通过UserDetailsService可以从数据库中查询用户名和密码。

创建数据库用户表

CREATE TABLE `user`  (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `user_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '用户名',
  `nick_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '昵称',
  `password` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '密码',
  `status` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '0' COMMENT '账号状态(0:正常;1:停用)',
  `email` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '邮箱',
  `phone_number` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '手机号',
  `sex` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '用户性别(0:男;1:女;2:未知)',
  `avatar` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '头像',
  `user_type` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '1' COMMENT '用户类型(0:管理员;1:普通用户)',
  `create_by` bigint(20) NULL DEFAULT NULL COMMENT '创建人的用户id',
  `create_time` timestamp(0) NULL DEFAULT NULL COMMENT '创建时间',
  `update_by` bigint(20) NULL DEFAULT NULL COMMENT '更新人',
  `update_time` timestamp(0) NULL DEFAULT NULL COMMENT '更新时间',
  `is_deleted` int(11) NULL DEFAULT 0 COMMENT '删除标志(0:未删除;1:已删除)',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;

引入MybatisPlus和mysql驱动依赖

<!-- mp依赖 和 mysql驱动 -->
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>3.4.1</version>
</dependency>
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
</dependency>

在YML中配置数据库信息

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/authoritydemo
    username: root
    password: 123456

定义mapper接口

这里可以直接在接口上加入@Mapper,当然也可以在启动类或配置类中加上相应注解。

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.hashiqi.entity.User;

public interface UserMapper extends BaseMapper<User> {
}

修改User实体类

在类名上加@TableName(value = "user"),id字段上加@TableId

配置mapper扫描

在启动类上加上@MapperScan("com.hashiqi.mapper")
或
自己创建一个配置类

添加junit依赖作为测试所用

<!-- junit依赖 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
</dependency>

开始测试下MP是否可以用

import com.hashiqi.mapper.UserMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
public class MapperTest {

    @Autowired
    private UserMapper userMapper;

    @Test
    void testUserMapper() {
        System.out.println(userMapper.selectList(null));
    }
}

在entity包中创建LoginUser类

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;

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

创建service.impl包,在里面创建UserDetailsServiceImpl类

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.hashiqi.entity.LoginUser;
import com.hashiqi.entity.User;
import com.hashiqi.mapper.UserMapper;
import org.springframework.beans.factory.annotation.Autowired;
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 org.springframework.util.ObjectUtils;

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserMapper userMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 查询用户信息
        User user = userMapper.selectOne(new LambdaQueryWrapper<User>().eq(User::getUserName, username));
        System.err.println(user);
        // 判断用户是否为空,如果空,则抛出异常
        if (ObjectUtils.isEmpty(user)) {
            throw new RuntimeException("用户名或者密码错误");
        }
        // TODO 查询对应的权限信息
        // 如果不为空,则将数据封装成UserDetails返回
        return new LoginUser(user);
    }
}

写到这里就可以进行测试,测试成功则继续往下操作。

2.3.3.2、密码加密存储

数据库中密码一般不用明文存储。

默认使用的PasswordEncoder要求数据库中的密码格式为:{id}password【比如举例:password={noop}123】。它会根据id去判断密码的加密方式。但一般不会采用这种方式。所以就需要替换PasswordEncoder。

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

所以可以定义一个SpringSecurity的配置类,SpringSecurity要求这个配置类要继承WebSecurityConfigurerAdapter。

在配置包中加入该配置

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

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

进行测试

@Test
void testBCryptPasswordEncoder() {
    BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
    // $2a$10$KiEnHppAS.am7dpjrpHEEOm0lKeluQfKCxf83Zg3N4GIJ2RXCiMui
    //        System.out.println(passwordEncoder.matches("1234", "$2a$10$KiEnHppAS.am7dpjrpHEEOm0lKeluQfKCxf83Zg3N4GIJ2RXCiMui"));
    String encode = passwordEncoder.encode("123");
    //        String encode2 = passwordEncoder.encode("1234");
    System.out.println(encode);
    //        System.out.println(encode2);
}

2.3.3.3、登录接口

接下来需要自定义登录接口,然后让SpringSecurity对这个接口放行,让用户访问这个接口的时候不用登录也能访问。

通过AuthenticationManager的authenticate方法来进行用户认证,需要在SpringSecurity中配置,将AuthenticationManager注入容器。

认证成功后,要生成一个jwt,放入响应体中进行返回,并且为了让用户下回请求时能通过jwt识别出具体是哪个用户,需要将用户信息放入redis中,将用户id作为key。

控制层Controller

import com.hashiqi.entity.ResponseResult;
import com.hashiqi.entity.User;
import com.hashiqi.service.LoginService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/user")
public class LoginController {

    @Autowired
    private LoginService loginService;

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

Service业务接口层

import com.hashiqi.entity.ResponseResult;
import com.hashiqi.entity.User;

public interface LoginService {

    // 登录
    ResponseResult login(User user);
}

Service业务接口实现层

import com.hashiqi.entity.LoginUser;
import com.hashiqi.entity.ResponseResult;
import com.hashiqi.entity.User;
import com.hashiqi.service.LoginService;
import com.hashiqi.utils.JwtUtil;
import com.hashiqi.utils.RedisCache;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;

import java.util.HashMap;
import java.util.Map;
import java.util.Objects;

@Service
public class LoginServiceImpl implements LoginService {

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private RedisCache redisCache;

    /**
     * 登录
     * @param user 用户名、密码
     * @return 返回响应体
     */
    @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("登录失败");
        }
        // 如果认证通过了,使用userId生成一个jwt,放入ResponseResult中返回
        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,userId作为key
        redisCache.setCacheObject("login::" + userId, loginUser);
        return new ResponseResult(200, "登录成功", map);
    }
}

在SecurityConfig类中加入以下代码

@Override
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
    return super.authenticationManagerBean();
}

@Override
protected void configure(HttpSecurity http) throws Exception {
    http
        // 关闭csrf,因为如果是分布式系统,则不适合session
        .csrf().disable()
        // 不通过Session获取SecurityContext
        .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        .and()
        .authorizeRequests()
        // 对于登录接口,允许匿名访问【即未登录状态下可以访问 .permitAll()代表都可以访问】
        .antMatchers("/user/login").anonymous()
        // 除上面之外的所有请求全部需要鉴权认证
        .anyRequest().authenticated();
}

2.3.3.4、认证过滤器

需要定义一个过滤器,这个过滤器会去获取请求头中的token,对token进行解析取出其中的userId。

使用userId去redis中获取对应的LoginUser对象。

然后封装Authentication对象存入SecurityContextHolder。

import com.hashiqi.entity.LoginUser;
import com.hashiqi.utils.JwtUtil;
import com.hashiqi.utils.RedisCache;
import io.jsonwebtoken.Claims;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Objects;

@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
        
        // TODO 获取权限信息封装到Authentication中
        
        UsernamePasswordAuthenticationToken authenticationToken
                = new UsernamePasswordAuthenticationToken(loginUser, null, null);
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        // 放行
        filterChain.doFilter(request, response);
    }
}

现在只是将过滤器放到了容器里面,还没进行配置,接下来就是进行配置处理。

在原来的config包中的SecurityConfig中加入以下代码

@Autowired
private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

在configure方法中加入以下代码

// 在哪个过滤器之前添加其他过滤器
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);

最后进行测试即可,测试成功,可以继续向下操作。

2.3.3.5、退出登录

只需要定义一个退出登录接口,然后获取SecurityContextHolder中的认证信息,删除redis中对应的数据即可。

Controller控制层

在LoginController中加入接口

// 退出登录
@RequestMapping("/logout")
public ResponseResult logout() {
    return loginService.logout();
}

Service业务接口和实现层

// 退出登录
ResponseResult logout();
/**
 * 退出登录
 * @return 返回响应体
 */
@Override
public ResponseResult logout() {
    // 获取SecurityContextHolder中的用户id
    UsernamePasswordAuthenticationToken authentication = (UsernamePasswordAuthenticationToken) SecurityContextHolder.getContext().getAuthentication();
    LoginUser loginUser = (LoginUser) authentication.getPrincipal();
    Long userId = loginUser.getUser().getId();
    // 删除redis中的值
    redisCache.deleteObject("login::" + userId);
    return new ResponseResult(200, "注销成功");
}

3、授权

3.1、授权系统的作用

就比如说一个阅览室,如果是普通用户登录就能看到借书还书相关功能,不会让其看到并且去使用添加、删除、修改书籍等信息功能,但是如果是管理员登录,即可看到普通用户看不到的功能。

换言之,不同用户可以使用不同功能,这就是权限系统要去实现的效果。

不能只依赖前端去判断用户的权限来选择显示哪些菜单,哪些按钮。因为如果只是这样,有人知道对应功能的接口地址就可以不通过前端,直接去发送请求来实现相关功能操作。

所以要在后台进行用户权限的判断,判断当前用户是否有相应的权限,必须基于所需权限才能进行相应的操作。

3.2、授权基本流程

在SpringSecurity中,会使用默认的FilterSecurityInterceptor来进行检验权限,在FilterSecurityInterceptor中会从SecurityContextHolder获取其中的Authentication,然后获取其中的权限信息。当前用户是否拥有访问当前资源所需的权限。

所以在项目中只需要把当前登录用户的权限信息也存入Authentication,然后设置资源所需要的权限即可。

3.3、授权实现

3.3.1、限制访问资源所需权限

SpringSecurity提供了基于注解的权限控制方案,这也是项目中主要采用的方式,可以使用注解去指定访问对应的资源所需的权限。

使用它需要先开启相关配置。在SecurityConfig配置类上加上

@EnableGlobalMethodSecurity(prePostEnabled = true)

然后就可以使用对应的注解@PreAuthorize。

@RestController
public class HelloController {

    @RequestMapping("/hello")
    @PreAuthorize("hasAuthority('test')")
    public String getMessage() {
        return "hello";
    }
}

3.3.2、封装权限信息

写UserDetailsServiceImpl时,在查询出用户后还要获取相应的权限信息,封装到UserDetails中返回。

在这之前,先直接把权限信息写死封装到UserDetails中进行测试。

之前定义了UserDetails的实现类LoginUser,想要让其封装权限信息就要对其进行修改。

@Data
@NoArgsConstructor
public class LoginUser implements UserDetails {

    private User user;

    private List<String> permissions;

    public LoginUser(User user, List<String> permissions) {
        this.user = user;
        this.permissions = permissions;
    }

    // JSONField是为了避免redis将其进行序列化
    // 存储SpringSecurity所需要的权限信息集合
    @JSONField(serialize = false)
    private List<SimpleGrantedAuthority> authorities;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        if (null != authorities) {
            return authorities;
        }
        // 把permissions中String类型的权限信息封装成SimpleGrantedAuthority对象
//        List<GrantedAuthority> newList = new ArrayList<>();
//        for (String permission : permissions) {
//            SimpleGrantedAuthority authority = new SimpleGrantedAuthority(permission);
//            newList.add(authority);
//        }
        authorities = permissions.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
        return authorities;
    }

    @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

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.hashiqi.entity.LoginUser;
import com.hashiqi.entity.User;
import com.hashiqi.mapper.UserMapper;
import org.springframework.beans.factory.annotation.Autowired;
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 org.springframework.util.ObjectUtils;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserMapper userMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 查询用户信息
        User user = userMapper.selectOne(new LambdaQueryWrapper<User>().eq(User::getUserName, username));
        System.err.println(user);
        // 判断用户是否为空,如果空,则抛出异常
        if (ObjectUtils.isEmpty(user)) {
            throw new RuntimeException("用户名或者密码错误");
        }
        // 查询对应的权限信息,添加到LoginUser中
        List<String> list = new ArrayList<>(Arrays.asList("test", "admin"));
        // 如果不为空,则将数据封装成UserDetails返回
        return new LoginUser(user, list);
    }
}

JwtAuthenticationTokenFilter

import com.hashiqi.entity.LoginUser;
import com.hashiqi.utils.JwtUtil;
import com.hashiqi.utils.RedisCache;
import io.jsonwebtoken.Claims;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Objects;

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

3.3.3、从数据库查询权限信息

3.3.3.1、RBAC权限模型

RBAC权限模型(Role-Based Access Control):基于角色的权限控制,这是目前最常见的通用权限模型。

用户表、用户角色关联表、角色表、角色权限关联表、权限【菜单】表

3.3.3.2、准备工作

菜单权限表

CREATE TABLE `menu`  (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `menu_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '菜单名',
  `path` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '路由地址',
  `component` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '组件路径',
  `visible` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '0' COMMENT '菜单状态(0:显示;1:隐藏)',
  `status` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '0' COMMENT '菜单状态(0:正常;1:停用)',
  `perms` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '权限标识',
  `icon` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '#' COMMENT '菜单图标',
  `create_by` bigint(20) NULL DEFAULT NULL COMMENT '创建者',
  `create_time` datetime(0) NULL DEFAULT NULL COMMENT '创建时间',
  `update_by` bigint(20) NULL DEFAULT NULL COMMENT '最后更新者',
  `update_time` datetime(0) NULL DEFAULT NULL COMMENT '最后更新时间',
  `remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '备注',
  `is_deleted` int(11) NULL DEFAULT 0 COMMENT '是否已删除(0:未删除;1:已删除)',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;

角色表

CREATE TABLE `role`  (
  `id` bigint(20) NOT NULL COMMENT '主键',
  `name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '名称',
  `role_key` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '角色权限字符串',
  `status` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '0' COMMENT '角色状态(0:正常;1:停用)',
  `create_by` bigint(20) NULL DEFAULT NULL COMMENT '创建者',
  `create_time` datetime(0) NULL DEFAULT NULL COMMENT '创建时间',
  `update_by` bigint(20) NULL DEFAULT NULL COMMENT '最后更新者',
  `update_time` datetime(0) NULL DEFAULT NULL COMMENT '最后更新时间',
  `remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '备注',
  `is_deleted` int(1) NULL DEFAULT 0 COMMENT '是否已删除(0:未删除;1:已删除)',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;

用户角色关联表

CREATE TABLE `user_role`  (
  `id` bigint(20) NOT NULL COMMENT '主键',
  `user_id` bigint(20) NOT NULL COMMENT '用户id',
  `role_id` bigint(20) NOT NULL COMMENT '角色id',
  PRIMARY KEY (`id`, `user_id`, `role_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;

角色菜单关联表

CREATE TABLE `role_menu`  (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `role_id` bigint(20) NOT NULL COMMENT '角色id',
  `menu_id` bigint(20) NOT NULL COMMENT '菜单id',
  PRIMARY KEY (`id`, `role_id`, `menu_id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;

用户表之前已经存在了。

菜单实体类

import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

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

@Data
@AllArgsConstructor
@NoArgsConstructor
@TableName(value = "menu")
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Menu implements Serializable {

    private static final long serialVersionUID = -1L;

    // 主键
    @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;
    // 备注
    private String remark;
    // 是否已删除(0:未删除;1:已删除)
    private Integer isDeleted;
}

3.3.3.3、代码实现

只需要根据用户id去查询到其所对应的权限信息即可。

所以先定义一个mapper,其中提供一个方法可以根据userId查询权限信息。

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.hashiqi.entity.Menu;

import java.util.List;

public interface MenuMapper extends BaseMapper<Menu> {

    List<String> selectPermsByUserId(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.hashiqi.mapper.MenuMapper">

    <select id="selectPermsByUserId" resultType="java.lang.String">
        SELECT
            DISTINCT m.perms
        FROM
            user_role ur
            LEFT JOIN role r on ur.role_id = r.id
            LEft JOIN role_menu rm on r.id = rm.role_id
            LEFT JOIN menu m on rm.menu_id = m.id
        WHERE
            user_id = #{userId}
            AND r.status = 0
            AND m.status = 0
    </select>
</mapper>

放到测试类中进行测试即可。

现在要做的就是将UserDetailsServiceImpl类中写死的数据变成动态数据。

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.hashiqi.entity.LoginUser;
import com.hashiqi.entity.User;
import com.hashiqi.mapper.MenuMapper;
import com.hashiqi.mapper.UserMapper;
import org.springframework.beans.factory.annotation.Autowired;
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 org.springframework.util.ObjectUtils;

import java.util.List;

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserMapper userMapper;
    
    @Autowired
    private MenuMapper menuMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 查询用户信息
        User user = userMapper.selectOne(new LambdaQueryWrapper<User>().eq(User::getUserName, username));
        System.err.println(user);
        // 判断用户是否为空,如果空,则抛出异常
        if (ObjectUtils.isEmpty(user)) {
            throw new RuntimeException("用户名或者密码错误");
        }
        // 查询对应的权限信息,添加到LoginUser中
        // List<String> list = new ArrayList<>(Arrays.asList("test", "admin"));
        List<String> list = menuMapper.selectPermsByUserId(user.getId());
        // 如果不为空,则将数据封装成UserDetails返回
        return new LoginUser(user, list);
    }
}

4、自定义失败处理

期望在认证失败或者授权失败的情况下也能和接口一样返回相同结构的json,这样可以让前端对响应进行统一的处理,要实现这个功能需要知道SpringSecurity的异常处理机制。

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

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

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

所以如果需要自定义异常处理,只需要自定义AuthenticationEntryPoint和AccessDeniedHandler,然后配置给SpringSecurity即可。

自定义实现类

处理认证失败

import com.alibaba.fastjson.JSON;
import com.hashiqi.entity.ResponseResult;
import com.hashiqi.utils.WebUtils;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
        ResponseResult result = new ResponseResult(HttpStatus.UNAUTHORIZED.value(), "用户认证失败,请重新登录");
        String json = JSON.toJSONString(result);
        // 处理异常
        WebUtils.renderString(response, json);
    }
}

处理授权失败

import com.alibaba.fastjson.JSON;
import com.hashiqi.entity.ResponseResult;
import com.hashiqi.utils.WebUtils;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

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

配置给SpringSecurity

可以使用HttpSecurity对象的方法去配置。

import com.hashiqi.filter.JwtAuthenticationTokenFilter;
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.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
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.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

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

    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Autowired
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
    
    @Autowired
    private AuthenticationEntryPoint authenticationEntryPoint;
    
    @Autowired
    private AccessDeniedHandler accessDeniedHandler;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                // 关闭csrf,因为如果是分布式系统,则不适合session
                .csrf().disable()
                // 不通过Session获取SecurityContext
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                // 对于登录接口,允许匿名访问【即未登录状态下可以访问】
                .antMatchers("/user/login").anonymous()
                // 除上面之外的所有请求全部需要鉴权认证
                .anyRequest().authenticated();

        // 在哪个过滤器之前添加其他过滤器
        http
                .addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
        
        // 配置异常处理器
        http
                .exceptionHandling()
                // 配置认证失败处理器
                .authenticationEntryPoint(authenticationEntryPoint)
                // 配置授权失败处理器
                .accessDeniedHandler(accessDeniedHandler);
    }
}

做到这里即可进行测试认证和授权失败的场景。

5、跨域问题

前端向后端发起请求时,必须保证协议、域名、端口号完全一致才可以进行正常通信。

所以需要进行处理,让前端能进行跨域请求。

先对SpringBoot配置,进行跨域请求

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class CorsConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        // 设置允许跨域的路径
        registry.addMapping("/**")
                // 设置允许跨域请求的域名
                .allowedOriginPatterns("*")
                // 是否允许cookie
                .allowCredentials(true)
                // 设置允许的请求方式
                .allowedMethods("GET", "POST", "DELETE", "PUT")
                // 设置允许的header属性
                .allowedHeaders("*")
                // 跨域允许时间
                .maxAge(3600);
    }
}

开启SpringSecurity的跨域访问

由于资源都会受到SpringSecurity的保护,所以想要跨域访问还要让SpringSecurity允许跨域访问。

import com.hashiqi.filter.JwtAuthenticationTokenFilter;
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.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
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.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

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

    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Autowired
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

    @Autowired
    private AuthenticationEntryPoint authenticationEntryPoint;

    @Autowired
    private AccessDeniedHandler accessDeniedHandler;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                // 关闭csrf,因为如果是分布式系统,则不适合session
                .csrf().disable()
                // 不通过Session获取SecurityContext
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                // 对于登录接口,允许匿名访问【即未登录状态下可以访问】
                .antMatchers("/user/login").anonymous()
                // 除上面之外的所有请求全部需要鉴权认证
                .anyRequest().authenticated();

        // 在哪个过滤器之前添加其他过滤器
        http
                .addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);

        // 配置异常处理器
        http
                .exceptionHandling()
                // 配置认证失败处理器
                .authenticationEntryPoint(authenticationEntryPoint)
                // 配置授权失败处理器
                .accessDeniedHandler(accessDeniedHandler);

        // 允许跨域
        http
                .cors();
    }
}

 结束~~

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值