【SpringSecurity】 一文搞定从入门到项目集成

0. 选型 & 简介

提到认证、授权、安全等关键词,当今比较热门的主要集中在以下两种

技术ShiroSpring Security
可靠性由Apache维护,稳定可靠由Spring团队维护,稳定可靠
社区支持活跃的社区,有大量文档和教程活跃的社区,有大量文档和教程
功能覆盖提供身份验证和授权功能提供身份验证和授权功能
配置复杂性相对较低,简单易用相对较高,配置较为复杂(springboot下极其丝滑,不是很复杂)
扩展性灵活的插件机制,易于扩展提供丰富的扩展点和可插拔的功能
整合性可与多种框架整合Spring框架的一部分,与Spring整合良好
性能性能较好,轻量级性能较好,提供缓存和优化选项
安全性提供基本的安全功能提供更多复杂的安全功能

其实springboot项目可以无脑选spring security 对于开发者而言并没有“重”多少,相反由于其本质是通过过滤器实现的玩法上会更加优雅,且对RESTFul的支持更好

基本概念:

通常的Web应用都需要进行认证和授权。

  • 认证:验证当前访问系统的是不是本系统的用户,并且要确认具体是哪个用户

  • 授权:经过认证后判断当前用户是否有权限进行某个操作

而认证和授权也是SpringSecurity作为安全框架的核心功能。

1. 快速入门

1.1 准备工作

先启动一个springboot模板

① 设置父工程 添加依赖

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.6</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>

② 创建启动类

@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

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

引入依赖后在尝试去访问之前的接口就会自动跳转到一个SpringSecurity的默认登陆页面,由于是拉取的远程服务器的静态资源,所以加载会慢一些,耐心等待即可
在这里插入图片描述
默认用户名是user,密码会输出在控制台。
在这里插入图片描述
必须登陆之后才能对接口进行访问。

2. 认证

2.1 登陆校验流程

在这里插入图片描述

2.2 原理初探

先从一个最基本的功能单元出发理解SpringSecurity的基本流程

2.2.1 SpringSecurity完整流程

SpringSecurity的原理其实就是一个过滤器链,内部包含了提供各种功能的过滤器。
在这里插入图片描述
图中只展示了核心过滤器,其它的非核心过滤器并没有在图中展示。

UsernamePasswordAuthenticationFilter: 负责处理用户名和密码的校验

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

FilterSecurityInterceptor:负责权限校验

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

调整启动类如下,断点调试:在这里插入图片描述
使用Evaluator,分析run.getBean(DefaultSecurityFilterChain.class)在这里插入图片描述即可查看所有的过滤器 在这里插入图片描述

@SpringBootApplication
public class SpringseedApplication {

    public static void main(String[] args) {
        ConfigurableApplicationContext run = SpringApplication.run(SpringseedApplication.class, args);
        System.out.println(1);
    }
}
run.getBean(DefaultSecurityFilterChain.class);

2.2.2 认证流程详解

概念速查:

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

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

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

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

2.3 解决问题

2.3.1 思路分析

登录
①自定义登录接口

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

  • 把用户信息存入redis中

  • 自定义UserDetailsService

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

校验:
①定义Jwt认证过滤器

  • 获取token

  • 解析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.0</version>
        </dependency>

② 添加Redis相关配置

自定义RedisTemplate序列化

import com.sticu.springseed.model.entity.user.LoginUser;
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 RedisConfig {

    @Bean
    public RedisTemplate<String, LoginUser> redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, LoginUser> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);

        Jackson2JsonRedisSerializer<LoginUser> jsonRedisSerializer = new Jackson2JsonRedisSerializer<>(LoginUser.class);
        StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();

        template.setKeySerializer(stringRedisSerializer);
        template.setValueSerializer(jsonRedisSerializer);
        template.setHashKeySerializer(stringRedisSerializer);
        template.setHashValueSerializer(jsonRedisSerializer);

        template.setDefaultSerializer(jsonRedisSerializer);
        template.afterPropertiesSet();
        return template;
    }
}

③ 响应类

import java.io.Serializable;
import lombok.Data;

/**
 * @author st
 */
@Data
public class BaseResponse<T> implements Serializable {

    /**
     * 错误码
     */
    private int code;

    /**
     * 响应数据
     */
    private T data;

    /**
     * 提示消息
     */
    private String message;

    public BaseResponse(int code, T data, String message) {
        this.code = code;
        this.data = data;
        this.message = message;
    }

    public BaseResponse(int code, T data) {
        this(code, data, "");
    }

    public BaseResponse(ErrorCode errorCode) {
        this(errorCode.getCode(), null, errorCode.getMessage());
    }
}

④工具类

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 JwtUtils {
    private JwtUtils() {
    }

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

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

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

    /**
     * 创建token
     *
     * @param subject   token中要存放的数据(json格式)
     * @param ttlMillis token超时时间
     * @return jwtString
     */
    public static String createJWT(String subject, Long ttlMillis) {
        // 设置过期时间
        JwtBuilder builder = getJwtBuilder(subject, ttlMillis, getUUID());
        return builder.compact();
    }

    /**
     * 创建token
     *
     * @param id 唯一标识
     * @param subject token携带的主题数据
     * @param ttlMillis 过期毫秒值
     * @return token
     */
    public static String createJWT(String id, String subject, Long ttlMillis) {
        JwtBuilder builder = getJwtBuilder(subject, ttlMillis, id);// 设置过期时间
        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()
                // 唯一的ID
                .setId(uuid)
                // 主题  可以是JSON数据
                .setSubject(subject)
                // 签发者
                .setIssuer("st")
                // 签发时间
                .setIssuedAt(now)
                //使用HS256对称加密算法签名, 第二个参数为秘钥
                .signWith(signatureAlgorithm, secretKey)
                .setExpiration(expDate);
    }

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

    /**
     * 解析
     *
     * @param jwt jwtString
     * @return Claims
     */
    public static Claims parseJWT(String jwt) {
        SecretKey secretKey = generalKey();
        return Jwts.parser()
                .setSigningKey(secretKey)
                .parseClaimsJws(jwt)
                .getBody();
    }
}

用户缓存接口

import com.sticu.springseed.model.entity.user.LoginUser;

public interface UserCache {
    void setLoginUser(LoginUser loginUser);

    LoginUser getLoginUser(LoginUser loginUser);

    Boolean removeLoginUser(LoginUser loginUser);
}

用户缓存实现类

import com.sticu.springseed.model.entity.user.LoginUser;
import com.sticu.springseed.service.UserCache;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Service;

import java.util.concurrent.TimeUnit;

@Service
public class UserCacheImpl implements UserCache {
    private static final String KEY_PREFIX = "login:";
    private static final int LOGIN_TIMEOUT_SECONDS = 60 * 60;
    private final RedisTemplate<String, LoginUser> redisTemplate;
    private final ValueOperations<String, LoginUser> valueOps;

    @Autowired
    public UserCacheImpl(RedisTemplate<String, LoginUser> redisTemplate) {
        this.redisTemplate = redisTemplate;
        this.valueOps = redisTemplate.opsForValue();
    }

    @Override
    public void setLoginUser(LoginUser loginUser) {
        String key = generateKey(loginUser);
        valueOps.set(key, loginUser, LOGIN_TIMEOUT_SECONDS, TimeUnit.SECONDS);
    }

    @Override
    public LoginUser getLoginUser(LoginUser loginUser) {
        String key = generateKey(loginUser);
        return valueOps.get(key);
    }

    @Override
    public Boolean removeLoginUser(LoginUser loginUser) {
        String key = generateKey(loginUser);
        return redisTemplate.delete(key);
    }

    private String generateKey(LoginUser loginUser) {
        return KEY_PREFIX + loginUser.getUser().getUserName();
    }
}

2.3.3 实现

2.3.3.0 准备工作

准备工作

我们先创建一个用户表,这个表结构参考了ruoyi框架的RBAC模型,可以兼容大多数的用户中心场景

Tips:什么是RBAC
RBAC,即角色基础访问控制(Role-Based Access Control),是一种安全访问控制模型。在RBAC中,权限的授予和管理是通过角色进行的。
简单来说,RBAC 的核心思想是:

  • 角色(Roles):定义用户在系统中的职责或角色,每个角色有特定的权限集合。
  • 权限(Permissions):确定用户能够执行的具体操作或访问的资源。
  • 用户(Users):被赋予一个或多个角色,从而拥有这些角色对应的权限。

通过将权限分配给角色,而不是直接分配给用户,RBAC简化了权限管理。当用户的职责发生变化时,只需调整其角色,而无需更改每个用户的权限设置。

总结起来,RBAC通过角色的方式更有效地管理和授予权限,提高了系统的安全性和可维护性。

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='用户表'

引入MybatisPuls和mysql驱动的依赖

        <!-- https://mvnrepository.com/artifact/com.baomidou/mybatis-plus-boot-starter -->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.5.2</version>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>

配置数据库信息

spring:
  datasource:
    url: jdbc:mysql://{数据库服务ip}:3306/{schema名}?characterEncoding=utf-8&serverTimezone=UTC
    username: {你的username}
    password: {你的password}
    driver-class-name: com.mysql.cj.jdbc.Driver

自动生成实体类、mapper、service等

可使用MybatisX
在这里插入图片描述

  • 实体类
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import java.io.Serializable;
import java.util.Date;
import lombok.Data;

/**
 * 用户表
 * @TableName sys_user
 */
@TableName(value ="sys_user")
@Data
public class User implements Serializable {
    /**
     * 主键
     */
    @TableId(type = IdType.AUTO)
    private Long sysUserId;

    /**
     * 用户名
     */
    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 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 Integer delFlag;

    @TableField(exist = false)
    private static final long serialVersionUID = 1L;
}

  • mapper
import com.sticu.springseed.model.entity.user.User;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;

/**
* @author st
* @description 针对表【sys_user(用户表)】的数据库操作Mapper
* @createDate 2024-01-14 15:26:39
* @Entity com.sticu.springseed.model.entity.user.User
*/
@Mapper
public interface UserMapper extends BaseMapper<User> {

}
  • service及实现类
import com.sticu.springseed.model.dto.user.UserAddRequest;
import com.sticu.springseed.model.entity.user.User;
import com.baomidou.mybatisplus.extension.service.IService;

/**
* @author songt
* @description 针对表【sys_user(用户表)】的数据库操作Service
* @createDate 2024-01-14 15:26:39
*/
public interface UserService extends IService<User> {

    long addUser(UserAddRequest userAddRequest);
}

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.sticu.springseed.model.entity.user.User;
import com.sticu.springseed.service.UserService;
import com.sticu.springseed.mapper.UserMapper;
import org.springframework.stereotype.Service;

/**
 * @author songt
 * @description 针对表【sys_user(用户表)】的数据库操作Service实现
 * @createDate 2024-01-14 15:26:39
 */
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User>
        implements UserService {

}

添加junit依赖

        <!-- test -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

测试MP是否能正常使用

/**
 * @Author 三更  B站: https://space.bilibili.com/663528522
 */
@SpringBootTest
public class MapperTest {

    @Autowired
    private UserMapper userMapper;

    @Test
    public void testUserMapper(){
        List<User> users = userMapper.selectList(null);
        System.out.println(users);
    }
}

表里随便插入一条数据,运行单测即可看到mybatis-plus配置成功了
在这里插入图片描述

2.3.3.1 注册接口

为了方便后续测试方便,我们先实现一个注册接口

  • controller
package com.sticu.springseed.controller;


import com.sticu.springseed.common.BaseResponse;
import com.sticu.springseed.model.dto.user.UserAddRequest;
import com.sticu.springseed.service.UserService;
import com.sticu.springseed.utils.ResultUtils;
import io.swagger.annotations.*;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/user")
@Api(tags = "用户管理", description = "用户相关接口")
public class UserController {
    private final UserService userService;

    @Autowired
    public UserController(UserService userService) {
        this.userService = userService;
    }

    @PostMapping
    @ApiOperation(value = "用户新增", notes = "用户新增接口")
    @ApiResponses({
            @ApiResponse(code = 200, message = "成功", response = BaseResponse.class),
            @ApiResponse(code = 400, message = "参数错误"),
            @ApiResponse(code = 500, message = "服务内部错误"),
    })
    @ApiResponse(code = 200, message = "成功", response = String.class)
    public BaseResponse<Long> addUser(@RequestBody UserAddRequest userAddRequest) {
        long userId = userService.addUser(userAddRequest);

        return ResultUtils.success(userId);
    }
}

  • service & impl
import com.sticu.springseed.model.dto.user.UserAddRequest;
import com.sticu.springseed.model.entity.user.User;
import com.baomidou.mybatisplus.extension.service.IService;

/**
* @author st
* @description 针对表【sys_user(用户表)】的数据库操作Service
* @createDate 2024-01-14 15:26:39
*/
public interface UserService extends IService<User> {

    long addUser(UserAddRequest userAddRequest);
}

import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.sticu.springseed.common.ErrorCode;
import com.sticu.springseed.model.dto.user.UserAddRequest;
import com.sticu.springseed.model.entity.user.User;
import com.sticu.springseed.service.UserService;
import com.sticu.springseed.mapper.UserMapper;
import com.sticu.springseed.utils.ThrowUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;

/**
 * @author st
 * @description 针对表【sys_user(用户表)】的数据库操作Service实现
 * @createDate 2024-01-14 15:26:39
 */
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User>
        implements UserService {

    @Override
    public long addUser(UserAddRequest userAddRequest) {
        String userName = userAddRequest.getUserName();
        String password = userAddRequest.getPassword();
        String checkPassword = userAddRequest.getCheckPassword();

        // 1. 校验
        ThrowUtils.throwIf(StringUtils.isAnyBlank(userName, password, checkPassword), ErrorCode.PARAMS_ERROR, "参数为空");
        ThrowUtils.throwIf(userName.length() < 4, ErrorCode.PARAMS_ERROR, "用户账号过短");
        ThrowUtils.throwIf(password.length() < 8 || checkPassword.length() < 8, ErrorCode.PARAMS_ERROR, "用户密码过短");

        // 密码和校验密码相同
        ThrowUtils.throwIf(!password.equals(checkPassword), ErrorCode.PARAMS_ERROR, "两次输入的密码不一致");
        synchronized (userName.intern()) {
            // 账户不能重复
            long count = this.baseMapper.selectCount(Wrappers.<User>lambdaQuery().eq(User::getUserName, userName));
            ThrowUtils.throwIf(count > 0, ErrorCode.PARAMS_ERROR, "账号重复");

            // 2. 加密
            String encryptPassword = new BCryptPasswordEncoder().encode(password);
            // 3. 插入数据
            User user = new User();
            BeanUtils.copyProperties(userAddRequest, user);
            user.setPassword(encryptPassword);
            boolean saveResult = this.save(user);
            ThrowUtils.throwIf(!saveResult, ErrorCode.SYSTEM_ERROR, "注册失败,数据库错误");

            return user.getSysUserId();
        }
    }
}

Tips: 这里的ThrowUtils是为了代码简洁所做的封装,实际上两种语义是等价的

ThrowUtils.throwIf(!password.equals(checkPassword), ErrorCode.PARAMS_ERROR, "两次输入的密码不一致");
// 上方的代码等价于下方的代码
if (!password.equals(checkPassword)){
	throw new BusinessException(ErrorCode.PARAMS_ERROR, "两次输入的密码不一致");
}

之后,为了测试,我们还需要放行一些接口,比如knife4j swagger , 还有刚刚完成的注册接口

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
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;

/**
 * @author st
 */
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                //关闭csrf
                .csrf().disable()
                //不通过Session获取SecurityContext
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                // 放行knife4j相关
                .antMatchers("/doc.html", "/swagger-ui.html", "/v3/api-docs", "/swagger-resources/**", "/swagger/**", "/webjars/**").anonymous()
                // 放行注册接口
                .antMatchers(HttpMethod.POST, "/user").anonymous()
                // 除上面外的所有请求全部需要鉴权认证
                .anyRequest().authenticated();
    }
}

btw: 注意!这里对 POST /user 进行了限制,良好的restful支持对于shiro来说简直是外星科技 :)

在这里插入图片描述
测试发现注册接口已经ready了,并且密码已被bcrypt加密
在这里插入图片描述

2.3.3.2 登录接口

到目前为止,我们仅配置了一个SecurityConfig,还不清楚SpringSecurity是如何来完成认证的,接下来通过登录接口理解;
在登录过程中,我们还需要两个Security框架中的Bean

  • PasswordEncoder :登录时的加密过程已经用到过了,这里我们注入PasswordEncoder后,是为了让Spring Security在后台处理将用户输入的密码与数据库中存储的加密密码进行比对的工作,无需显式编写加密逻辑。这确保了安全性,同时简化代码。
  • AuthenticationManager:当用户进行登录时,Spring Security会经过一系列的过滤器来验证用户提供的信息。如果验证通过,Spring Security会创建一个包含用户身份和权限信息的对象,这个对象被称为 Authentication。这个 Authentication 对象会被存储在系统中,以便后续的业务逻辑能够访问。AuthenticationManager 用于管理Authentication对象

所以,我们首先需要再配置类中注入这两个bean:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
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;

/**
 * @author st
 */
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                //关闭csrf
                .csrf().disable()
                //不通过Session获取SecurityContext
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                // 放行knife4j相关
                .antMatchers("/doc.html", "/swagger-ui.html", "/v3/api-docs", "/swagger-resources/**", "/swagger/**", "/webjars/**").anonymous()
                // 放行注册接口
                .antMatchers(HttpMethod.POST, "/user").anonymous()
                // 除上面外的所有请求全部需要鉴权认证
                .anyRequest().authenticated();
    }
    
    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
}

接下来,我们先完成一个登录接口,在之前的UserController中追加login

import com.sticu.springseed.common.BaseResponse;
import com.sticu.springseed.common.ErrorCode;
import com.sticu.springseed.model.dto.user.UserAddRequest;
import com.sticu.springseed.model.dto.user.UserLoginRequest;
import com.sticu.springseed.model.vo.LoginUserVO;
import com.sticu.springseed.service.UserService;
import com.sticu.springseed.utils.ResultUtils;
import com.sticu.springseed.utils.ThrowUtils;
import io.swagger.annotations.*;

import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/user")
@Api(tags = "用户管理", description = "用户相关接口")
public class UserController {
    private final UserService userService;

    @Autowired
    public UserController(UserService userService) {
        this.userService = userService;
    }

    @PostMapping("/login")
    @ApiOperation(value = "用户登录", notes = "用户登录接口")
    @ApiResponses({
            @ApiResponse(code = 200, message = "成功", response = BaseResponse.class),
            @ApiResponse(code = 400, message = "参数错误"),
            @ApiResponse(code = 500, message = "服务内部错误"),
    })
    @ApiResponse(code = 200, message = "成功", response = String.class)
    public BaseResponse<LoginUserVO> login(@RequestBody UserLoginRequest userLoginRequest) {
        ThrowUtils.throwIf(userLoginRequest == null, ErrorCode.PARAMS_ERROR);
        String userAccount = userLoginRequest.getUserAccount();
        String userPassword = userLoginRequest.getUserPassword();
        ThrowUtils.throwIf(StringUtils.isAnyBlank(userAccount, userPassword), ErrorCode.PARAMS_ERROR);

        return ResultUtils.success(userService.login(userAccount, userPassword));
    }

    @PostMapping
    @ApiOperation(value = "用户新增", notes = "用户新增接口")
    @ApiResponses({
            @ApiResponse(code = 200, message = "成功", response = BaseResponse.class),
            @ApiResponse(code = 400, message = "参数错误"),
            @ApiResponse(code = 500, message = "服务内部错误"),
    })
    @ApiResponse(code = 200, message = "成功", response = String.class)
    public BaseResponse<Long> addUser(@RequestBody UserAddRequest userAddRequest) {
        long userId = userService.addUser(userAddRequest);

        return ResultUtils.success(userId);
    }
}

再来实现service & impl,同样追加一个login

import com.sticu.springseed.model.dto.user.UserAddRequest;
import com.sticu.springseed.model.entity.user.User;
import com.baomidou.mybatisplus.extension.service.IService;
import com.sticu.springseed.model.vo.LoginUserVO;

/**
* @author songt
* @description 针对表【sys_user(用户表)】的数据库操作Service
* @createDate 2024-01-14 15:26:39
*/
public interface UserService extends IService<User> {

    long addUser(UserAddRequest userAddRequest);

    LoginUserVO login(String userAccount, String userPassword);
}

import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.sticu.springseed.common.ErrorCode;
import com.sticu.springseed.model.dto.user.UserAddRequest;
import com.sticu.springseed.model.entity.user.LoginUser;
import com.sticu.springseed.model.entity.user.User;
import com.sticu.springseed.model.vo.LoginUserVO;
import com.sticu.springseed.service.UserCache;
import com.sticu.springseed.service.UserService;
import com.sticu.springseed.mapper.UserMapper;
import com.sticu.springseed.utils.JwtUtils;
import com.sticu.springseed.utils.ThrowUtils;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.BeanUtils;
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.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;

import java.util.Objects;

/**
 * @author songt
 * @description 针对表【sys_user(用户表)】的数据库操作Service实现
 * @createDate 2024-01-14 15:26:39
 */
@Service
@Slf4j
public class UserServiceImpl extends ServiceImpl<UserMapper, User>
        implements UserService {

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private UserCache userCache;
    @Override
    public long addUser(UserAddRequest userAddRequest) {
        String userName = userAddRequest.getUserName();
        String password = userAddRequest.getPassword();
        String checkPassword = userAddRequest.getCheckPassword();

        // 1. 校验
        ThrowUtils.throwIf(StringUtils.isAnyBlank(userName, password, checkPassword), ErrorCode.PARAMS_ERROR, "参数为空");
        ThrowUtils.throwIf(userName.length() < 4, ErrorCode.PARAMS_ERROR, "用户账号过短");
        ThrowUtils.throwIf(password.length() < 8 || checkPassword.length() < 8, ErrorCode.PARAMS_ERROR, "用户密码过短");

        // 密码和校验密码相同
        ThrowUtils.throwIf(!password.equals(checkPassword), ErrorCode.PARAMS_ERROR, "两次输入的密码不一致");
        synchronized (userName.intern()) {
            // 账户不能重复
            long count = this.baseMapper.selectCount(Wrappers.<User>lambdaQuery().eq(User::getUserName, userName));
            ThrowUtils.throwIf(count > 0, ErrorCode.PARAMS_ERROR, "账号重复");

            // 2. 加密
            String encryptPassword = new BCryptPasswordEncoder().encode(password);
            // 3. 插入数据
            User user = new User();
            BeanUtils.copyProperties(userAddRequest, user);
            user.setPassword(encryptPassword);
            boolean saveResult = this.save(user);
            ThrowUtils.throwIf(!saveResult, ErrorCode.SYSTEM_ERROR, "注册失败,数据库错误");

            return user.getSysUserId();
        }
    }

    @Override
    public LoginUserVO login(String userAccount, String userPassword) {
        // 1. 校验
        ThrowUtils.throwIf(StringUtils.isAnyBlank(userAccount, userPassword), ErrorCode.PARAMS_ERROR, "参数为空");
        ThrowUtils.throwIf(userAccount.length() < 4, ErrorCode.PARAMS_ERROR, "账号错误");
        ThrowUtils.throwIf(userPassword.length() < 8, ErrorCode.PARAMS_ERROR,  "密码错误");

        // 2. 认证
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userAccount,userPassword);
        Authentication authenticate = authenticationManager.authenticate(authenticationToken);
        ThrowUtils.throwIf(Objects.isNull(authenticate), ErrorCode.PARAMS_ERROR, "用户名或密码错误");

        // 3. 生成Token
        LoginUser loginUser = (LoginUser) authenticate.getPrincipal();
        String userId = loginUser.getUser().getSysUserId().toString();
        String jwt = JwtUtils.createJWT(userId);

        //4. 缓存登录态
        userCache.setLoginUser(loginUser);

        return LoginUserVO.builder().token(jwt).build();
    }
}

这里主要做了三件事:

  • 认证:通过AuthenticationManager完成认证,稍后会具体解释如何实现的用户名密码的校验和信息的获取
  • 生成Token:生成一个携带userId的Token作为后续的认证凭证
  • 缓存登录态:缓存用户的详细信息敏感信息,方便后续业务逻辑使用

AuthenticationManager实现认证功能,还依赖于两个接口

  • UserDetails:封装用户信息的,除必须的权限信息以外,可自定义存储业务逻辑所需的信息
  • UserDetailsService:负责用户信息查询,从数据库中查询用户相关信息,封装到UserDetails的实现类对象中

具体的来完成两个实现类,首先是定义业务需要的用户信息

package com.sticu.springseed.model.entity.user;

import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
/**
 * @author st
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class LoginUser implements UserDetails {

    private User user;


    @JsonIgnore
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return null;
    }

    @JsonIgnore
    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @JsonIgnore
    @Override
    public String getUsername() {
        return user.getUserName();
    }

    @JsonIgnore
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @JsonIgnore
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @JsonIgnore
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @JsonIgnore
    @Override
    public boolean isEnabled() {
        return true;
    }
}

注意这里直接将整个User对象都喂了进来,也可以根据业务场景自定义,同时部分方法加了@JsonIgnore注解,这是防止将这些方法错误的序列化成了属性存入redis,可以取消这些注解看看会发生什么。此外getAuthorities返回的null,这里我们先跳过权限的部分,先优先实现认证部分

接下来定义查询相关实现类

import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.sticu.springseed.common.ErrorCode;
import com.sticu.springseed.model.entity.user.LoginUser;
import com.sticu.springseed.model.entity.user.User;
import com.sticu.springseed.service.UserService;
import com.sticu.springseed.utils.ThrowUtils;
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 java.util.Objects;

/**
 * @author st
 */
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
    private final UserService userService;
    @Autowired
    public UserDetailsServiceImpl(UserService userService) {
        this.userService = userService;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userService.getOne(Wrappers.<User>lambdaQuery()
                .eq(User::getUserName, username));
        // 校验
        ThrowUtils.throwIf(Objects.isNull(user), ErrorCode.PARAMS_ERROR, "用户名或密码错误");
        //TODO 根据用户查询权限信息 添加到LoginUser中
        
        //封装成UserDetails对象返回 
        return new LoginUser(user);
    }
}

同样,我们先封装基本信息,权限部分先跳过
还要放行一下/user/login接口

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                //关闭csrf
                .csrf().disable()
                //不通过Session获取SecurityContext
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                // 放行knife4j相关
                .antMatchers("/doc.html", "/swagger-ui.html", "/v3/api-docs", "/swagger-resources/**", "/swagger/**", "/webjars/**").anonymous()
                // 放行注册接口
                .antMatchers(HttpMethod.POST, "/user").anonymous()
                .antMatchers(HttpMethod.POST, "/user/login").anonymous()
                // 除上面外的所有请求全部需要鉴权认证
                .anyRequest().authenticated();
    }
    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
}

至此,我们可以调试一下看看,首先在controller处断点,可以看到前端输入的用户名密码,随请求进入到controller
在这里插入图片描述

之后在service login 方法中,authenticationmanager校验后打断点,此时如果校验通过后,会到达刚刚定义的UserDetailsServiceImpl中封装用户信息
在这里插入图片描述
在UserDetailsServiceImpl打断点调试,发现下一步执行会进入到UserDetailsServiceImpl,且传入的参数就是刚刚构造UsernamePasswordAuthenticationToken的用户名
在这里插入图片描述
回到login方法执行,发现认证通过,并且authenticate.principal就是刚刚注入的LoginUser类对象,可以看到user属性是刚刚查表获得的用户信息。
在这里插入图片描述
放行后,发现已经可以拿到token了
在这里插入图片描述
至此登录已经完成,接下来完成利用token进行其他接口的认证功能。

2.3.3.4 认证过滤器

Spring Security通过过滤器链处理了认证流程,使得用户身份的验证和获取变得简单而安全。这种方式允许开发者轻松地在业务逻辑中使用已认证用户的信息,而无需显式处理验证和用户信息的获取过程。

具体来说过滤器会从请求头中获取携带的 token,并对 token 进行解析。一旦解析成功,后续的业务逻辑如果需要用户信息,可以通过 token 中的 userId 到 Redis 中获取 LoginUser 对象。然后,将 LoginUser 对象封装成 Authentication 并存入 SecurityContextHolder。对于这个请求线程而言,后续的业务逻辑都可以方便地使用已解析的用户信息了。

实现一个过滤器:

package com.sticu.springseed.filter;

import com.sticu.springseed.common.ErrorCode;
import com.sticu.springseed.exception.BusinessException;
import com.sticu.springseed.model.entity.user.LoginUser;
import com.sticu.springseed.service.UserCache;
import com.sticu.springseed.utils.JwtUtils;
import com.sticu.springseed.utils.ThrowUtils;
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 UserCache userCache;

    @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 = JwtUtils.parseJWT(token);
            userid = claims.getSubject();
        } catch (Exception e) {
            e.printStackTrace();
            throw new BusinessException(ErrorCode.PARAMS_ERROR, "token非法");
        }
        //从redis中获取用户信息
        LoginUser loginUser = userCache.getLoginUser(userid);
        ThrowUtils.throwIf(Objects.isNull(loginUser), ErrorCode.PARAMS_ERROR, "用户未登录");

        //存入SecurityContextHolder
        //TODO 获取权限信息封装到Authentication中
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(loginUser,null,null);
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        
        //放行
        filterChain.doFilter(request, response);
    }
}

为了让过滤器生效,还需要在配置类中进行配置

package com.sticu.springseed.config;

import com.sticu.springseed.filter.JwtAuthenticationTokenFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
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.authentication.UsernamePasswordAuthenticationFilter;

/**
 * @author st
 */
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                //关闭csrf
                .csrf().disable()
                //不通过Session获取SecurityContext
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                // 放行knife4j相关
                .antMatchers("/doc.html", "/swagger-ui.html", "/v3/api-docs", "/swagger-resources/**", "/swagger/**", "/webjars/**").anonymous()
                // 放行注册接口
                .antMatchers(HttpMethod.POST, "/user").anonymous()
                .antMatchers(HttpMethod.POST, "/user/login").anonymous()
                // 除上面外的所有请求全部需要鉴权认证
                .anyRequest().authenticated();

        http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
    }
    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
}

这里我们注入了JwtAuthenticationTokenFilter,同时调用addFilterBefore方法将这个过滤器加在了UsernamePasswordAuthenticationFilter之前;
接下来可以断点调试一下,我们携带token访问接口
在这里插入图片描述
此处可以看到token已经可以正常传入过滤器,并且也可以从中正常解析到userId
在这里插入图片描述
再继续,可以看到loginUser已经从redis中正常读取出来
在这里插入图片描述
最后,发现authenticationToken也可以正常封装
在这里插入图片描述
再继续放行,发现过滤器可以正常放行,并且执行接口的业务逻辑拿到响应。至此认证功能已经实现。

2.3.3.4 退出登录

通常除登陆外,后端还需要实现登出接口,前端实现的登出只是清cookie和缓存,依然可以通过之前的token mock请求来访问后端接口。
登出接口需要获取SecurityContextHolder中的认证信息,并根据用户唯一标识(redis中的key)来删除redis中的对应数据。
首先新增一个登出接口

package com.sticu.springseed.controller;


import com.sticu.springseed.common.BaseResponse;
import com.sticu.springseed.common.ErrorCode;
import com.sticu.springseed.model.dto.user.UserAddRequest;
import com.sticu.springseed.model.dto.user.UserLoginRequest;
import com.sticu.springseed.model.vo.LoginUserVO;
import com.sticu.springseed.service.UserService;
import com.sticu.springseed.utils.ResultUtils;
import com.sticu.springseed.utils.ThrowUtils;
import io.swagger.annotations.*;

import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/user")
@Api(tags = "用户管理", description = "用户相关接口")
public class UserController {
    private final UserService userService;

    @Autowired
    public UserController(UserService userService) {
        this.userService = userService;
    }

    @PostMapping("/login")
    @ApiOperation(value = "用户登录", notes = "用户登录接口")
    @ApiResponses({
            @ApiResponse(code = 200, message = "成功", response = BaseResponse.class),
            @ApiResponse(code = 400, message = "参数错误"),
            @ApiResponse(code = 500, message = "服务内部错误"),
    })
    @ApiResponse(code = 200, message = "成功", response = String.class)
    public BaseResponse<LoginUserVO> login(@RequestBody UserLoginRequest userLoginRequest) {
        ThrowUtils.throwIf(userLoginRequest == null, ErrorCode.PARAMS_ERROR);
        String userAccount = userLoginRequest.getUserAccount();
        String userPassword = userLoginRequest.getUserPassword();
        ThrowUtils.throwIf(StringUtils.isAnyBlank(userAccount, userPassword), ErrorCode.PARAMS_ERROR);

        return ResultUtils.success(userService.login(userAccount, userPassword));
    }

    @PostMapping("/logout")
    @ApiOperation(value = "用户登出", notes = "用户登出接口")
    @ApiResponses({
            @ApiResponse(code = 200, message = "成功", response = BaseResponse.class),
            @ApiResponse(code = 400, message = "参数错误"),
            @ApiResponse(code = 500, message = "服务内部错误"),
    })
    @ApiResponse(code = 200, message = "成功", response = String.class)
    public BaseResponse<Boolean> logout() {
        return ResultUtils.success(userService.logout());
    }

    @PostMapping
    @ApiOperation(value = "用户新增", notes = "用户新增接口")
    @ApiResponses({
            @ApiResponse(code = 200, message = "成功", response = BaseResponse.class),
            @ApiResponse(code = 400, message = "参数错误"),
            @ApiResponse(code = 500, message = "服务内部错误"),
    })
    @ApiResponse(code = 200, message = "成功", response = String.class)
    public BaseResponse<Long> addUser(@RequestBody UserAddRequest userAddRequest) {
        long userId = userService.addUser(userAddRequest);

        return ResultUtils.success(userId);
    }
    @PutMapping
    @ApiOperation("更新用户信息")
    @ApiParam(name = "userId", value = "用户ID", required = true, example = "123")
    @ApiResponse(code = 200, message = "成功", response = String.class)
    public String updateUser(@RequestParam String userId) {
        return "ok";
    }

    @DeleteMapping
    @ApiOperation("删除用户")
    @ApiParam(name = "userId", value = "用户ID", required = true, example = "123")
    @ApiResponse(code = 200, message = "成功", response = String.class)
    public String deleteUser(@RequestParam String userId) {
        return "ok";
    }
}

之后实现对应的service

package com.sticu.springseed.service.impl;

import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.sticu.springseed.common.ErrorCode;
import com.sticu.springseed.model.dto.user.UserAddRequest;
import com.sticu.springseed.model.entity.user.LoginUser;
import com.sticu.springseed.model.entity.user.User;
import com.sticu.springseed.model.vo.LoginUserVO;
import com.sticu.springseed.service.UserCache;
import com.sticu.springseed.service.UserService;
import com.sticu.springseed.mapper.UserMapper;
import com.sticu.springseed.utils.JwtUtils;
import com.sticu.springseed.utils.ThrowUtils;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.BeanUtils;
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.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;

import java.util.Objects;

/**
 * @author songt
 * @description 针对表【sys_user(用户表)】的数据库操作Service实现
 * @createDate 2024-01-14 15:26:39
 */
@Service
@Slf4j
public class UserServiceImpl extends ServiceImpl<UserMapper, User>
        implements UserService {

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private UserCache userCache;
    @Override
    public long addUser(UserAddRequest userAddRequest) {
        String userName = userAddRequest.getUserName();
        String password = userAddRequest.getPassword();
        String checkPassword = userAddRequest.getCheckPassword();

        // 1. 校验
        ThrowUtils.throwIf(StringUtils.isAnyBlank(userName, password, checkPassword), ErrorCode.PARAMS_ERROR, "参数为空");
        ThrowUtils.throwIf(userName.length() < 4, ErrorCode.PARAMS_ERROR, "用户账号过短");
        ThrowUtils.throwIf(password.length() < 8 || checkPassword.length() < 8, ErrorCode.PARAMS_ERROR, "用户密码过短");

        // 密码和校验密码相同
        ThrowUtils.throwIf(!password.equals(checkPassword), ErrorCode.PARAMS_ERROR, "两次输入的密码不一致");
        synchronized (userName.intern()) {
            // 账户不能重复
            long count = this.baseMapper.selectCount(Wrappers.<User>lambdaQuery().eq(User::getUserName, userName));
            ThrowUtils.throwIf(count > 0, ErrorCode.PARAMS_ERROR, "账号重复");

            // 2. 加密
            String encryptPassword = new BCryptPasswordEncoder().encode(password);
            // 3. 插入数据
            User user = new User();
            BeanUtils.copyProperties(userAddRequest, user);
            user.setPassword(encryptPassword);
            boolean saveResult = this.save(user);
            ThrowUtils.throwIf(!saveResult, ErrorCode.SYSTEM_ERROR, "注册失败,数据库错误");

            return user.getSysUserId();
        }
    }

    @Override
    public LoginUserVO login(String userAccount, String userPassword) {
        // 1. 校验
        ThrowUtils.throwIf(StringUtils.isAnyBlank(userAccount, userPassword), ErrorCode.PARAMS_ERROR, "参数为空");
        ThrowUtils.throwIf(userAccount.length() < 4, ErrorCode.PARAMS_ERROR, "账号错误");
        ThrowUtils.throwIf(userPassword.length() < 8, ErrorCode.PARAMS_ERROR,  "密码错误");

        // 2. 认证
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userAccount,userPassword);
        Authentication authenticate = authenticationManager.authenticate(authenticationToken);
        ThrowUtils.throwIf(Objects.isNull(authenticate), ErrorCode.PARAMS_ERROR, "用户名或密码错误");

        // 3. 生成Token
        LoginUser loginUser = (LoginUser) authenticate.getPrincipal();
        String userId = loginUser.getUser().getSysUserId().toString();
        String jwt = JwtUtils.createJWT(userId);

        //4. 缓存登录态
        userCache.setLoginUser(loginUser);

        return LoginUserVO.builder().token(jwt).build();
    }

    @Override
    public Boolean logout() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        LoginUser loginUser = (LoginUser) authentication.getPrincipal();

        return userCache.removeLoginUser(loginUser);
    }
}

接下来进行测试
首先登录获取token
在这里插入图片描述
再携带token访问测试接口,发现可以访问
在这里插入图片描述
之后携带token访问登出接口

在这里插入图片描述
登出成功后使用原token,访问测试接口,此时token已失效
在这里插入图片描述
亦可到redis中查看,会发现用户已从缓存移除,至此登出接口已实现

3. 授权

3.0 权限系统的概念

权限系统的目的在于实现不同用户类型对系统功能的差异化访问。以教务系统为例,普通学生登陆后可查看并使用选课,查看成绩的功能,但无权修改成绩或课程信息。但如果是管理员账号登录,应具备录入、修改课程信息和学生成绩等高级功能。

总结起来,权限系统的核心就是令不同的用户可以使用不同的功能。

虽然仅通过前端去判断用户的权限来选择性的显示菜单、按钮依然可以实现类似的效果。但无法阻止用户绕过前端,通过mock接口请求访问相关功能。因此,后端对用户权限的严格控制是必要的。此外,现代前端框架也支持类似的路由守卫功能,可根据用户权限动态加载不同的组件路由,前后端都进行权限校验是当今项目中的常见做法。

3.1 授权的基本流程

SpringSecurity中的授权流程同认证类似,同样是在过滤器中实现的。实现校验的过滤器是FilterSecurityInterceptor,该过滤器会从SecurityContextHolder获取Authentication对象,从中获取权限信息,以判断当前用户是否具备访问所请求资源所需的权限。

所以在项目中,只需将当前登录用户的权限信息存入Authentication对象,并为不同资源(如controller或service方法)设置所需要的权限即可。
简而言之,操作分如下两步:

  1. 配置资源的访问权限
  2. 权限信息存入Authentication

3.2 实现

3.2.1 配置资源的访问权限

SpringSecurity支持通过注解的形式配置资源的访问权限,即给对应的资源(controller或方法)加上注解。但使用前需要开启相关配置:

package com.sticu.springseed;

import org.apache.ibatis.annotations.Mapper;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;

@SpringBootApplication
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SpringseedApplication {

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

之后即可使用相关注解,以测试controller为例
package com.sticu.springseed.controller;

import io.swagger.annotations.Api;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/hello")
@Api(tags = "测试", description = "用户相关接口")
public class HelloController {
  @GetMapping
  @PreAuthorize("hasAuthority('test')")
  public String hello(){
      return "hello";
  }
}

表示仅有‘test‘权限的用户可以访问该资源
继续携带token访问测试接口会发现接口已经无法访问了

这是全局异常处理器抛出的自定义错误相应格式,不是SpringSecurity抛出的

在这里插入图片描述
查看服务日志发现报错AccessDenied,看来限制生效了。如何将这种异常单独捕获处理后续会说明,此处暂不展开。

3.2.2 封装权限信息

在之前完成UserDetailsServiceImpl时,查询出用户后,还未获取其权限信息并封装到UserDetails中返回。
接下来先mock一个权限信息,写死到UserDetails中测试。

首先调整下LoginUser,这里追加两个属性:

  • permissions 用于存储数据库中的权限标识字符串,如oss:ListObjects
  • authorities 用于存储Spring Security需要的权限信息集合
    追加一个方法实现:
    重写authorities 的getter方法,利用permissions来构造authorities
package com.sticu.springseed.model.entity.user;

import com.alibaba.fastjson.annotation.JSONField;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;

/**
 * @author st
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class LoginUser implements UserDetails {

    private User user;

    private List<String> permissions;
    @JSONField(serialize = false)
    private List<GrantedAuthority> authorities;

    @JsonIgnore
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        if (authorities != null) {
            return authorities;
        }
        authorities = permissions.stream().
                map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
        return authorities;
    }

    @JsonIgnore
    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @JsonIgnore
    @Override
    public String getUsername() {
        return user.getUserName();
    }

    @JsonIgnore
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @JsonIgnore
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @JsonIgnore
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @JsonIgnore
    @Override
    public boolean isEnabled() {
        return true;
    }
}

之后来调整UserDetailsServiceImpl

package com.sticu.springseed.service.impl;

import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.sticu.springseed.common.ErrorCode;
import com.sticu.springseed.model.entity.user.LoginUser;
import com.sticu.springseed.model.entity.user.User;
import com.sticu.springseed.service.UserService;
import com.sticu.springseed.utils.ThrowUtils;
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 java.util.Arrays;
import java.util.List;
import java.util.Objects;

/**
 * @author st
 */
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
    private final UserService userService;
    @Autowired
    public UserDetailsServiceImpl(UserService userService) {
        this.userService = userService;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userService.getOne(Wrappers.<User>lambdaQuery()
                .eq(User::getUserName, username));
        // 校验
        ThrowUtils.throwIf(Objects.isNull(user), ErrorCode.PARAMS_ERROR, "用户名或密码错误");
        //TODO 根据用户查询权限信息 添加到LoginUser中
        List<String> permissions = Arrays.asList("test");

        //封装成UserDetails对象返回 
        return LoginUser.builder()
                .user(user)
                .permissions(permissions)
                .build();
    }
}

最后把authorities注入UsernamePasswordAuthenticationToken,

即在构造时注入如下:
new UsernamePasswordAuthenticationToken(loginUser,null,loginUser.getAuthorities());

package com.sticu.springseed.filter;

import com.sticu.springseed.common.ErrorCode;
import com.sticu.springseed.exception.BusinessException;
import com.sticu.springseed.model.entity.user.LoginUser;
import com.sticu.springseed.service.UserCache;
import com.sticu.springseed.utils.JwtUtils;
import com.sticu.springseed.utils.ThrowUtils;
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 UserCache userCache;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        //获取token
        String token = request.getHeader("Authorization");
        if (!StringUtils.hasText(token)) {
            //放行
            filterChain.doFilter(request, response);
            return;
        }
        //解析token
        String userid;
        try {
            Claims claims = JwtUtils.parseJWT(token);
            userid = claims.getSubject();
        } catch (Exception e) {
            e.printStackTrace();
            throw new BusinessException(ErrorCode.PARAMS_ERROR, "token非法");
        }
        //从redis中获取用户信息
        LoginUser loginUser = userCache.getLoginUser(userid);
        ThrowUtils.throwIf(Objects.isNull(loginUser), ErrorCode.PARAMS_ERROR, "用户未登录");

        //存入SecurityContextHolder
        //TODO 获取权限信息封装到Authentication中
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(loginUser,null,loginUser.getAuthorities());
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);

        //放行
        filterChain.doFilter(request, response);
    }
}

重新请求测试接口,可以调通
在这里插入图片描述

3.2.3 从数据库中获取权限信息

3.2.3.1 RBAC 权限控制模型

RBAC,即角色基础访问控制(Role-Based Access Control),是一种安全访问控制模型。在RBAC中,权限的授予和管理是通过角色进行的。

3.2.3.2 建表与自动生成准备

上文的认证部分仅涉及用户表,接下来补充鉴权相关表

-- 角色表
DROP TABLE IF EXISTS `sys_role`;
CREATE TABLE `sys_role`
(
    `id`          bigint(20) NOT NULL AUTO_INCREMENT,
    `name`        varchar(128) DEFAULT NULL COMMENT '角色名称',
    `role_key`    varchar(100) DEFAULT NULL COMMENT '角色权限字符串',
    `status`      char(1)      DEFAULT '0' COMMENT '角色状态 0-正常 1-停用',
    `remark`      varchar(500) DEFAULT NULL COMMENT '备注',
    `create_by`   bigint(200) DEFAULT NULL,
    `create_time` datetime     DEFAULT NULL,
    `update_by`   bigint(200) DEFAULT NULL,
    `update_time` datetime     DEFAULT NULL,
    `del_flag`    int(1) DEFAULT '0' COMMENT '逻辑删除 0-未删除 1-已删除',
    PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COMMENT='角色表';

-- 角色-用户关系表
DROP TABLE IF EXISTS `sys_user_role`;
CREATE TABLE `sys_user_role`
(
    `user_id` bigint(200) NOT NULL AUTO_INCREMENT COMMENT '用户id',
    `role_id` bigint(200) NOT NULL DEFAULT '0' COMMENT '角色id',
    PRIMARY KEY (`user_id`, `role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 菜单表
DROP TABLE IF EXISTS `sys_menu`;
CREATE TABLE `sys_menu`
(
    `id`          bigint(20) NOT NULL AUTO_INCREMENT,
    `menu_name`   varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '菜单名',
    `path`        varchar(200)         DEFAULT NULL COMMENT '路由地址',
    `component`   varchar(255)         DEFAULT NULL COMMENT '组件路径',
    `visible`     char(1)              DEFAULT '0' COMMENT '菜单状态 0-显示 1-隐藏',
    `status`      char(1)              DEFAULT '0' COMMENT '菜单状态 0-正常 1-停用',
    `perms`       varchar(100)         DEFAULT NULL COMMENT '权限标识',
    `icon`        varchar(100)         DEFAULT '#' COMMENT '菜单图标',
    `remark`      varchar(500)         DEFAULT NULL 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-已删除',
    PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COMMENT='菜单表';

-- 菜单角色关系表
DROP TABLE IF EXISTS `sys_role_menu`;
CREATE TABLE `sys_role_menu`
(
    `role_id` bigint(200) NOT NULL AUTO_INCREMENT COMMENT '角色ID',
    `menu_id` bigint(200) NOT NULL DEFAULT '0' COMMENT '菜单id',
    PRIMARY KEY (`role_id`, `menu_id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4;

生成menu的实体类、mapper、service等
在这里插入图片描述

3.2.3.3 代码实现

目前的需求是根据用户id查询到对应的权限信息,替换掉之前mock的permission

手动插入数据测试

INSERT INTO `spring-seed`.sys_menu (menu_name, path, component, visible, status, perms, icon, remark, create_by, create_time, update_by, update_time, del_flag) VALUES ('test', '/test', '/test', '0', '0', 'test', DEFAULT, null, null, null, null, null, DEFAULT)
INSERT INTO spring-seed.sys_role (id, name, role_key, status, remark, create_by, create_time, update_by, update_time, del_flag) VALUES (1, 'test', 'test', '0', null, null, null, null, null, 0);
INSERT INTO spring-seed.sys_role_menu (role_id, menu_id) VALUES (1, 1);
INSERT INTO `spring-seed`.sys_user_role (user_id, role_id) VALUES (2, 1)

实现查询权限信息的mapper

package com.sticu.springseed.mapper;

import com.sticu.springseed.model.entity.user.SysMenu;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;

import java.util.List;

/**
* @author st_dev
* @description 针对表【sys_menu(菜单表)】的数据库操作Mapper
* @createDate 2024-04-21 11:14:09
* @Entity com.sticu.springseed.model.entity.user.SysMenu
*/
public interface SysMenuMapper extends BaseMapper<SysMenu> {

    List<String> listPermissionsByUserId(Long id);
}

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.sticu.springseed.mapper.SysMenuMapper">

    <resultMap id="BaseResultMap" type="com.sticu.springseed.model.entity.user.SysMenu">
            <id property="id" column="id" jdbcType="BIGINT"/>
            <result property="menuName" column="menu_name" jdbcType="VARCHAR"/>
            <result property="path" column="path" jdbcType="VARCHAR"/>
            <result property="component" column="component" jdbcType="VARCHAR"/>
            <result property="visible" column="visible" jdbcType="CHAR"/>
            <result property="status" column="status" jdbcType="CHAR"/>
            <result property="perms" column="perms" jdbcType="VARCHAR"/>
            <result property="icon" column="icon" jdbcType="VARCHAR"/>
            <result property="remark" column="remark" jdbcType="VARCHAR"/>
            <result property="createBy" column="create_by" jdbcType="BIGINT"/>
            <result property="createTime" column="create_time" jdbcType="TIMESTAMP"/>
            <result property="updateBy" column="update_by" jdbcType="BIGINT"/>
            <result property="updateTime" column="update_time" jdbcType="TIMESTAMP"/>
            <result property="delFlag" column="del_flag" jdbcType="INTEGER"/>
    </resultMap>

    <sql id="Base_Column_List">
        id,menu_name,path,
        component,visible,status,
        perms,icon,remark,
        create_by,create_time,update_by,
        update_time,del_flag
    </sql>
    <select id="listPermissionsByUserId" resultType="java.lang.String">
        SELECT
            DISTINCT m.`perms`
        FROM
            sys_user_role ur
                LEFT JOIN `sys_role` r ON ur.`role_id` = r.`id`
                LEFT JOIN `sys_role_menu` rm ON ur.`role_id` = rm.`role_id`
                LEFT JOIN `sys_menu` m ON m.`id` = rm.`menu_id`
        WHERE
            user_id = 2/*#{userid}*/
          AND r.`status` = 0
          AND m.`status` = 0
    </select>
</mapper>

单独运行sql测试
在这里插入图片描述
最后替换掉之前的mock

package com.sticu.springseed.service.impl;

import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.sticu.springseed.common.ErrorCode;
import com.sticu.springseed.mapper.SysMenuMapper;
import com.sticu.springseed.model.entity.user.LoginUser;
import com.sticu.springseed.model.entity.user.User;
import com.sticu.springseed.service.UserService;
import com.sticu.springseed.utils.ThrowUtils;
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 java.util.Arrays;
import java.util.List;
import java.util.Objects;

/**
 * @author st
 */
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
    @Autowired
    private UserService userService;
    @Autowired
    private SysMenuMapper sysMenuMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userService.getOne(Wrappers.<User>lambdaQuery()
                .eq(User::getUserName, username));
        // 校验
        ThrowUtils.throwIf(Objects.isNull(user), ErrorCode.PARAMS_ERROR, "用户名或密码错误");

        List<String> permissions = sysMenuMapper.listPermissionsByUserId(user.getSysUserId());

        //封装成UserDetails对象返回 
        return LoginUser.builder()
                .user(user)
                .permissions(permissions)
                .build();
    }
}

此时再去请求测试接口,mock已替换为表中获取的权限信息

在这里插入图片描述
在这里插入图片描述
至此鉴权完成

4. 自定义失败处理

// 持续更新中

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值