SpringSecurity-前后端分离

1. 简介

image-20240802125633105

Spring Security是Spring家族中的一个安全管理框架。相比于另外一个安全框架Shiro,它提供了更丰富的功能,社区资源也比Shiro丰富。

一般来说中大型的项目都是使用SpringSecurity来做安全框架,小项目用Shiro的比较多。相比于SpringSecurity,Shiro的上手更加简单。

一般Web应用需要进行认证和授权。

  • 认证:验证当前访问系统的是不是本系统的用户,并且要确认具体是哪个用户。
  • 授权:经过认证后判断当前用户是否有权限进行某个操作。

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

本文案例所使用环境:

  • SpringBoot:3.2.0
  • jjwt:0.12.5
  • fastjson:1.2.33
  • SpringSecurity:6.2.0

2. 快速入门

2.1 准备工作

我们要先搭建一个简单的SpringBoot工程。

依赖pom.xml

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.2.0</version>
</parent>
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
    </dependency>
    <dependency>
        <groupId>org.apache.logging.log4j</groupId>
        <artifactId>log4j-api</artifactId>
    </dependency>
</dependencies>

配置yaml

server:
  port: 8888

启动类

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

写一个Controller,来验证项目是否可以正常访问。

@RestController
public class HelloController {
    @GetMapping("/hello")
    public String sayHello() {
        return "Hello";
    }
}

启动项目后,访问localhost:8888/hello,页面成功返回”Hello“,说明项目配置成功。

2.2 引入Security

引入SpringSecurity的依赖

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

引入依赖后,启动项目,我们再次访问localhost:8888/hello

image-20240802131233863

我们发现,页面被重定向到了一个登陆页面。这就是Security所做的。

在这个页面上,默认用户名为:user,密码会输出在控制台上。必须成功登陆后才能对接口进行访问。

image-20240802131532251

我们输入用户名,密码成功登陆后,成功访问到了/hello接口。


3. 认证

3.1 登陆校验流程

image-20240802132617279

3.2 原理初探

3.2.1 完整流程

SpringSecurity的原理其实就是一个过滤器链,内部包含了提供各种功能的过滤器。

image-20240802133902996

图中只展示了核心过滤器,其他的非核心过滤器并没有在图中展示。

UsernamePasswordAuthenticationFilter:负责处理我们在登陆页面填写了用户名密码后的登陆请求。入门案例的认证工作注意由它负责。

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

FilterSecurityInterceptor:负责权限校验的过滤器。

完整的SpringSecurity的过滤器链中,共有16个过滤器。

image-20240802135315235

3.2.2 认证流程详解

image-20240802142459069

概念:

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

3.3 认证实现

3.3.1 思路分析

登陆

我们可以发现SpringSecurity在InMemoryUserDetailsManager中到内存中根据用户名查询对应的信息,而我们需要到数据库中查询对应的信息,所以我们就可以实现UserDetailsService来进行完成自己所需要的方式。

此外,如果验证通过了,应该响应回去token。我们可以通过自己定义controller进行实现,用户直接将用户名和密码提交到我们定义的controller中,然后在controller中调用ProviderManager。也就是取代掉UsernamePasswordAuthenticationFilter

image-20240802150040185

校验

登陆完成后,我们该如何判断用户是否已经登陆了呢?

这时,我们就需要自己定义一个过滤器。

image-20240802145535780

3.3.2 准备工作

引入依赖

<!--springboot web-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--lombok-->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
</dependency>
<!--log4j-->
<dependency>
    <groupId>org.apache.logging.log4j</groupId>
    <artifactId>log4j-api</artifactId>
</dependency>
<!--security-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!--redis-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--jwt-->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.12.5</version>
</dependency>
<!--fastjson-->
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>1.2.33</version>
</dependency>
<!--mysql-->
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>8.0.33</version>
</dependency>
<!--mybatis plus-->
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
    <version>3.5.7</version>
</dependency>

配置文件yaml

server:
  port: 8888

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost3306/security_study?characterEncoding=true&serverTimezone=UTC
    username: root
    password: 密码
  data:
    redis:
      database: 0
      host: localhost
      port: 6379
      lettuce:
        pool:
          max-active: 8
          max-wait: -1ms
          max-idle: 8
          min-idle: 0

Redis使用FastJson序列化

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 (t == null) {
            return new byte[0];
        }
        return JSON.toJSONString(t,
                SerializerFeature.WriteClassName).getBytes(DEFAULT_CHARSET);
    }
    @Override
    public T deserialize(byte[] bytes) throws SerializationException {
        if (bytes == null || bytes.length <= 0) {
            return null;
        }
        String str = new String(bytes, DEFAULT_CHARSET);
        return JSON.parseObject(str, clazz);
    }
    protected JavaType getJavaType(Class<?> clazz) {
        return TypeFactory.defaultInstance().constructType(clazz);
    }
}

Redis配置类

@Configuration
public class RedisConfig {
    @Bean
    @SuppressWarnings(value = {"unchecked", "rawtypes"})
    public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<Object, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);
        FastJsonRedisSerializer serializer = new FastJsonRedisSerializer(Object.class);
        // 使用StringRedisSerializer来序列化和反序列化redis的key值
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(serializer);
        // Hash的key也采用StringRedisSerializer的序列化方式
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setHashValueSerializer(serializer);
        template.afterPropertiesSet();
        return template;
    }
}

Redis工具类

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

    // 缓存基本的对象,Integer、String、实体类等
    public <T> void setCacheObject(final String key, final T value) {
        redisTemplate.opsForValue().set(key, value);
    }
    // 缓存基本的对象,Integer、String、实体类等
    public <T> void setCacheObject(final String key, final T value, final
    Integer timeout, final TimeUnit timeUnit) {
        redisTemplate.opsForValue().set(key, value, timeout, timeUnit);
    }
    // 设置有效时间
    public boolean expire(final String key, final long timeout) {
        return expire(key, timeout, TimeUnit.SECONDS);
    }
    // 设置有效时间
    public boolean expire(final String key, final long timeout, final TimeUnit unit) {
        return redisTemplate.expire(key, timeout, unit);
    }
    // 获得缓存的基本对象。
    public <T> T getCacheObject(final String key) {
        ValueOperations<String, T> operation = redisTemplate.opsForValue();
        return operation.get(key);
    }
    // 删除单个对象
    public boolean deleteObject(final String key) {
        return redisTemplate.delete(key);
    }
    // 删除集合对象
    public long deleteObject(final Collection collection) {
        return redisTemplate.delete(collection);
    }
    // 缓存List数据
    public <T> long setCacheList(final String key, final List<T> dataList) {
        Long count = redisTemplate.opsForList().rightPushAll(key, dataList);
        return count == null ? 0 : count;
    }
    // 获得缓存的list对象
    public <T> List<T> getCacheList(final String key) {
        return redisTemplate.opsForList().range(key, 0, -1);
    }
    // 缓存Set
    public <T> BoundSetOperations<String, T> setCacheSet(final String key, final
    Set<T> dataSet) {
        BoundSetOperations<String, T> setOperation =
                redisTemplate.boundSetOps(key);
        Iterator<T> it = dataSet.iterator();
        while (it.hasNext()) {
            setOperation.add(it.next());
        }
        return setOperation;
    }
    // 获得缓存的set
    public <T> Set<T> getCacheSet(final String key) {
        return redisTemplate.opsForSet().members(key);
    }
    // 缓存Map
    public <T> void setCacheMap(final String key, final Map<String, T> dataMap) {
        if (dataMap != null) {
            redisTemplate.opsForHash().putAll(key, dataMap);
        }
    }
    // 获得缓存的Map
    public <T> Map<String, T> getCacheMap(final String key) {
        return redisTemplate.opsForHash().entries(key);
    }
    // 往Hash中存入数据
    public <T> void setCacheMapValue(final String key, final String hKey, final
    T value) {
        redisTemplate.opsForHash().put(key, hKey, value);
    }
    // 获取Hash中的数据
    public <T> T getCacheMapValue(final String key, final String hKey) {
        HashOperations<String, String, T> opsForHash =
                redisTemplate.opsForHash();
        return opsForHash.get(key, hKey);
    }
    // 删除Hash中的数据
    public void delCacheMapValue(final String key, final String hkey) {
        HashOperations hashOperations = redisTemplate.opsForHash();
        hashOperations.delete(key, hkey);
    }
    // 获取多个Hash中的数据
    public <T> List<T> getMultiCacheMapValue(final String key, final
    Collection<Object> hKeys) {
        return redisTemplate.opsForHash().multiGet(key, hKeys);
    }
    // 获得缓存的基本对象列表
    public Collection<String> keys(final String pattern) {

        return redisTemplate.keys(pattern);
    }
}

统一响应

@Data
@NoArgsConstructor
@AllArgsConstructor
public class R<T> {
    private Integer code;
    private String message;
    private T data;
}

public class ResultUtil {
    public static final Integer SUCCESS = 200;  // 成功
    public static final Integer ERROR = 200; // 错误
    public static final Integer NOT_LOGIN = 777;  // 未登陆
    public static final Integer NOT_PERMISSION = 888; // 未授权
    public static final Integer PASSWORD_ERROR = 999; // 密码错误
    public static final Integer USERNAME_ERROR = 000; // 用户名错误

    public static R success(String message){
        return result(SUCCESS,message,null);
    }
    public static <T> R<T> success(String message,T data){
        return result(SUCCESS,message,data);
    }
    public static R error(String message){
        return result(ERROR,message,null);
    }
    public static <T> R<T> error(String message,T data){
        return result(ERROR,message,data);
    }
    public static <T> R<T> error(Integer code, String message){
        return result(code,message,null);
    }
    public static <T> R<T> error(Integer code, String message,T data){
        return result(code,message,data);
    }
    public static <T> R<T> result(int code, String message, T data){
        return new R<>(code,message,data);
    }
}

JwtUtil

public class JWTUtil {
    // 有效时间
    public static final Long JWT_TTL = 60 * 60 * 1000L;
    // 加密算法
    private final static SecureDigestAlgorithm<SecretKey, SecretKey> ALGORITHM = Jwts.SIG.HS256;
    // 私钥
    // 应该大于等于 256位(长度32及以上的字符串),并且是随机的字符串
    private final static String SECRET = "mangomangomangomangomangomangomango";
    // 秘钥实例
    public static final SecretKey KEY = Keys.hmacShaKeyFor(SECRET.getBytes());
    // 主题
    public static final String SUBJECT = "Peripherals";

    /***
     * @author shw
     * @date 2024/8/2 16:05
     * @return java.lang.String
     * 生成JWT
     */
    public static String createJWT(String subject) {
        JwtBuilder jwtBuilder = getJwtBuilder(subject, null, getUUID());
        return jwtBuilder.compact();
    }
    public static String createJWT(String subject,Long ttlMillis) {
        JwtBuilder jwtBuilder = getJwtBuilder(subject, ttlMillis, getUUID());
        return jwtBuilder.compact();
    }
    public static String createJWT(String id,String subject,Long ttlMillis){
        JwtBuilder jwtBuilder = getJwtBuilder(subject, ttlMillis, id);
        return jwtBuilder.compact();
    }
    private static JwtBuilder getJwtBuilder(String userId, Long ttlMillis, String uuid) {
        long nowMillis = System.currentTimeMillis();
        Date now = new Date(nowMillis);
        if (ttlMillis==null){
            ttlMillis = JWTUtil.JWT_TTL;
        }
        long expMillis = nowMillis + ttlMillis;
        Date expDate = new Date(expMillis);
        JwtBuilder builder = Jwts.builder();
        builder
                .header()  // 头部信息
                .add("typ","JWT")
                .add("alg","HS256")
                .and()
                // 设置自定义负载信息 payload
                .claim("userid",userId)
                .id(uuid)  // 令牌ID
                .expiration(expDate)  // 过期时间
                .issuedAt(now)  // 签发时间
                .issuer("mango")  // 签发人
                .subject(SUBJECT)   // 主题
                .signWith(KEY,ALGORITHM);  // 签名
        return builder;
    }
    /***
     * @author shw
     * @date 2024/8/5 10:06
     * @return {@link Jws< Claims>}
     * @description 解析Token
     */
    public static Jws<Claims> parseClaim(String token) {
        return Jwts.parser()
                .verifyWith(KEY)
                .build()
                .parseSignedClaims(token);
    }
    public static JwsHeader parseHeader(String token) {
        return parseClaim(token).getHeader();
    }
    public static Claims parsePayload(String token) {
        return parseClaim(token).getPayload();
    }
    // 生成Token ID
    public static String getUUID(){
        String id = UUID.randomUUID().toString().replaceAll("-","");
        return id;
    }
}

定义User实体类

@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName
@TableName("sys_user")
public class User implements Serializable {
    @Serial
    private static final long serialVersionUID = -3706149795086043246L;
		@TableId
    private Long id;
    private String userName;  // 用户名
    private String nickName;  // 姓名
    private String password;  // 密码
    private String status;  // 0 正常  1 禁用
    private String email;
    private String phoneNumber;
    private String sex;  // 0男 1女 2未知
    private String avatar;  // 头像
    private String userType;  // 0 管理员 1 普通用户
    private Long createBy; // 创建人的用户ID
    private Date createTime;  // 创建时间
    private Long updateBy; // 更新人
    private Date updateTime; // 更新时间
    private Integer delFlag; // 删除标志 0 未删除   1 已删除
}

创建Mapper接口

public interface UserMapper extends BaseMapper<User> {
}

配置Mapper扫描

@MapperScan("com.mango.mapper")
@SpringBootApplication
public class Main8888 {
    public static void main(String[] args) {
        SpringApplication.run(Main8888.class, args);
    }
}

数据库 创建sys_user表

create table `sys_user` (
	id BIGINT(20) not null auto_increment,
	user_name varchar(64) not null DEFAULT 'NULL',
	nick_name varchar(64) not null DEFAULT 'NULL',
	password varchar(64) not null DEFAULT 'NULL',
	status char(1) DEFAULT('0'),
	email varchar(64) DEFAULT(NULL),
	phone_number varchar(32) DEFAULT(NULL),
	sex char(1) not null DEFAULT('1'),
	avatar varchar(128) DEFAULT(NULL),
	user_type char(1) not null DEFAULT('1'),
	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'),
	primary key(id)
)ENGINE=INNODB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
3.3.3 用户认证核心代码实现

因为UserDetailsServiceloadUserByUsername方法需要返回一个UserDetails类型的数据。UserDetails为一个接口类型,所以我们定义一个LoginUser来实现UserDetails接口。

LoginUser

@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginUser implements UserDetails, Serializable {

    @Serial
    private static final long serialVersionUID = 4012278606316008413L;

    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 "0".equals(user.getStatus());   // 用户是否启用
    }
}

MyUserDetailsService

@Service
public class MyUserDetailsService implements UserDetailsService {

    @Resource
    private UserMapper userMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws RuntimeException {
        // 查询用户信息
        QueryWrapper<User> queryWrapper = new QueryWrapper<>();
        queryWrapper.eq("user_name", username);
        User user = userMapper.selectOne(queryWrapper);
        if (user == null) {
            throw new RuntimeException("用户名不存在");
        }
        // 用户存在 数据封装为UserDetails
        LoginUser loginUser = new LoginUser(user);

        // TODO 查询用户的权限信息

        return loginUser;
    }
}

启动程序,进行测试

访问http://localhost:8888/hello,跳转到了登陆页面,此时我们输入数据库存储的用户名和密码mango,密码123456。点击登录,会发现登录失败了。看控制台输出,出现了异常。

image-20240803105526811

这时因为,目前security会默认对密码进行加密解密,加密后会在密码签名加上一个表示,如{xxx}123456,来表示加密或者非加密。

其中,{noop}表示密码明文存储,所以我们在密码前加上这个前缀,即{noop}123456,然后再次进行登录进行测试,密码仍然输入123456。可以发现,登录成功了。

3.3.4 密码加密存储

实际项目中,我们不会把密码明文存储在数据库中。

默认使用的PasswordEncoder要求数据库中的密码格式为:{id}password。它会根据id去判断密码的加密方式。但是我们一般不会采用这种方式。所以就需要替换PasswordEncoder

我们一般使用SpringSecurity为我们提供的BCryptPasswordEncoder

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

我们可以定义一个SpringSecurity到配置类,这个配置类加上@EnableWebSecurity@Configuration

SecurityConfig

@EnableWebSecurity  // 开启Spring Security的功能 代替了 implements WebSecurityConfigurerAdapter
@Configuration
public class SecurityConfig {
    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
}

BCryptPasswordEncoder加密进行测试

@Test
public void test(){
  	BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
    // 测试密码加密
    String p1 = bCryptPasswordEncoder.encode("123456");
    String p2 = bCryptPasswordEncoder.encode("123456");
    System.out.println(p1);  // $2a$10$WmdLaJsJAKbXPAwNcBjl2O0B0Bv62cC0BJ6/9uIUEemeKPBgwJJB2
    System.out.println(p2);  // $2a$10$iPaPNldhfcdpSGBeswc6vuLov2b1IIbpm9OWguan07AACDPw4Q1Mm
  	
  	// 测试密码校验  传入 1.明文密码(用户输入)  2.加密密码(数据库中密码)
    boolean matches = bCryptPasswordEncoder.matches("123456", "$2a$10$WmdLaJsJAKbXPAwNcBjl2O0B0Bv62cC0BJ6/9uIUEemeKPBgwJJB2");  // true

}

可以看到,虽然两次输入的内容相同,都是123456,但是加密后的结果是不同的。这是因为在加密的时候,其内部使用的随机盐。

在用户注册时,我们就应该使用该方法对用户传入的密码进行加密,然后将加密后的密码存入数据库中。

配置了加密方法后,在UserDetailsService返回UserDetails对象后,AbstractUserDetailsAuthenticationProvider就会使用该加密方法将UserDetails中数据库查询到的密码与Authentication中用户输入密码进行匹配校验。

3.3.5 登陆接口

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

在这个接口中,我们通过AuthenticationManagerauthenticate方法来进行用户认证,所以需要在SecurityConfig中把AuthenticationManager注入容器。我们需要将用户名和密码封装为Authentication对象传入authenticate方法来进行认证。Authentication是接口类型,所以我们要使用它的实现类UsernamePasswordAuthenticationToken

调用AuthenticationManager的方法进行认证,如果认证通过则生成JWT,返回给前端。并且为了让用户下回请求时通过JWT识别出具体的是哪个用户,我们需要把用户信息存入Redis,可以把用户ID作为key。

在SecurityConfig中放行Login登陆接口

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
      			// 关闭CSRF
            .csrf(csrf->csrf.disable())  
            // 不通过session获取SecurityContext
            .sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            // 登陆接口允许匿名访问 除了登陆接口全部需要鉴权认证
            .authorizeRequests(authorize->{authorize.requestMatchers("/user/login").anonymous().anyRequest().authenticated();});
  return http.build();
}

因为在自定义登录接口时,需要调用AuthenticationManager,所以需要在SecurityConfig将其放入容器中。

// 配置AuthenticationManager,供登陆接口使用
@Bean
public AuthenticationManager authenticationManager() {
    DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
    daoAuthenticationProvider.setUserDetailsService(myUserDetailsService);
    daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());
    return new ProviderManager(daoAuthenticationProvider);
}

自定义登录接口

@RestController
public class LoginController {

    @Resource
    private LoginService loginService;

    @RequestMapping(value = "/user/login", method = RequestMethod.POST)
    public R login(User user) {
        return loginService.login(user);
    }
}

LoginService

@Service
public class LoginServiceImpl implements LoginService {
    @Resource
    private AuthenticationManager authenticationManager;
    @Autowired
    private RedisCache redisCache;

    @Override
    public R login(User user) {
        // 调用Authentication的authenticate方法进行认证
        // 第1个参数:用户名,第2个参数:密码
        Authentication authentication = null;
        try {
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getUserName(), user.getPassword());
            authentication = authenticationManager.authenticate(authenticationToken);
        } catch (Exception e) {
            if (e instanceof BadCredentialsException){
                // 密码错误 认证失败
                return ResultUtil.error("密码错误");
            }else {
                return ResultUtil.error(e.getMessage());
            }
        }
        // 如果认证未通过,返回错误信息
        if (authentication == null) {
            return ResultUtil.error(ResultUtil.PASSWORD_ERROR,"用户名或密码错误");
        }
        // 认证通过,则根据userid生成JWT  JWT存入返回结果 返回给前端
        LoginUser principal = (LoginUser) authentication.getPrincipal();
        String id = principal.getUser().getId().toString();
        String jwt = JWTUtil.createJWT(id);
        // 将完整的用户信息存入Redis userid作为key
        redisCache.setCacheObject("login:"+id,principal);
        // 返回结果
        Map<String,String> result = new HashMap<>();
        result.put("token",jwt);
        return ResultUtil.success("登陆成功",result);
    }
}

Authentication的方法:

  • getDetails:存储有关身份验证请求的其他详细信息,能是IP地址、证书序列号等
  • getPrincipal:被认证的主体的身份
  • getCredentials:证明主体的凭据,通常是一个密码
3.3.6 认证过滤器

认证过滤器实现

@Component
public class TokenFilter extends OncePerRequestFilter {

    @Resource
    private RedisCache redisCache;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException,RuntimeException {
        // 获取token
        String token = request.getHeader("token");
        if (token == null || StringUtils.isBlank(token)) {
            // token为空时放行,交给Security的其他过滤器进行处理
            filterChain.doFilter(request,response);
        }
        // 解析Token
        Claims claims = null;
        try {
            claims = JWTUtil.parsePayload(token);
        } catch (Exception e) {
            if (e instanceof ExpiredJwtException) {
                throw e;
            }
            // token 非法 过期
            throw new RuntimeException("token非法");
        }
        String userid = String.valueOf(claims.get("userid"));
        // 从redis中获取用户信息
        LoginUser loginUser = redisCache.getCacheObject("login:" + userid);
        // 用户信息存入SecurityContextHolder
        if (loginUser == null) {
            throw new RuntimeException("用户未登陆");
        }
        SecurityContext context = SecurityContextHolder.getContext();
        // 参数1:用户信息  2:凭证信息,密码  3:权限信息
        // TODO 权限信息
        UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(loginUser, null, null);
        context.setAuthentication(authentication);
        filterChain.doFilter(request,response);
    }
}

配置认证过滤器

SecurityConfigsecurityFilterChain方法中进行配置。将过滤器配置在UsernamePasswordAuthenticationFilter过滤器之前。

// 配置认证过滤器,指定 自定义过滤器 以及 添加在哪个过滤器之前
http.addFilterBefore(tokenInterceptor, UsernamePasswordAuthenticationFilter.class);
3.3.7 退出登陆

我们只需要定义一个退出登陆接口,然后获取SecurityContextHolder中的认证信息,删除Redis中对应的数据即可。我们并不需要删除SecurityContextHolder中的用户信息,因为每次请求都要走TokenFilter,退出之后,下次请求SecurityContextHolder中的内容就是空的了。

注意,退出登陆功能也要携带token。

LoginController中定义一个logout方法,然后调用LoginService中的logout方法。在LoginServiceImpl中,logout的方法实现如下:

public R logout() {
    // 获取SecurityContextHolder中的用户ID
    SecurityContext context = SecurityContextHolder.getContext();
    LoginUser principal = (LoginUser)context.getAuthentication().getPrincipal();
    Long id = principal.getUser().getId();
    boolean del = redisCache.deleteObject("login:" + id);
    if (del){
        return ResultUtil.success("退出成功");
    }else {
        return ResultUtil.error("退出失败");
    }
}

4. 授权

4.1 授权系统的作用

例如一个学校图书馆的管理系统,如果是普通学生登录就能看到借书还书相关的功能,不可能让他看到并且去使用添加书籍信息,删除书籍信息等功能。但是如果是一个图书馆管理员的账号登录了,应该就能看到并使用添加书籍信息,删除书籍信息等功能。

总结起来就是不同的用户可以使用不同的功能。这就是权限系统要去实现的效果。

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

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

4.2 授权基本流程

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

所以我们在项目中只需要把当前登陆用户的权限信息也存入Authentication

然后设置我们的资源所需要的权限即可。

4.3 授权实现

4.3.1 限制访问资源所需权限

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

但是要使用它,我们需要先开启相关配置。在SecurityConfig类上添加注解:

@EnableGlobalMethodSecurity(prePostEnabled = true)

配置好后,我们就可以使用对应的一些注解了,如@PreAuthrize,这个注解就是在访问之前进行权限进行校验。如:

@PreAuthorize("hasAuthority('test')") // 设置访问资源所需要的权限
@GetMapping("/hello")
public String sayHello(String name) {
    return "Hello";
}

hasAuthority更适用于只需要判断一个权限的场景

hasAnyAuthority则更适用于需要判断多个权限的场景

如:

@PreAuthorize(“hasAuthority(‘ROLE_ADMIN’)”)

@PreAuthorize(“hasAnyAuthority(‘ROLE_ADMIN’, ‘ROLE_USER’)”)

4.3.2 封装权限信息

我们应该在UserDetailsService中,在从数据库查询到用户信息后,应该把用户的权限信息也封装到UserDetails中返回。

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

我们之前定义了UserDetails的实现类LoginUser,想要让其能封装权限信息就要对其进行修改,在LoginUser中增加如下内容:

private User user;  // 存储数据库查询到到用户信息
private List<String> permissions;  // 接收数据库中查询到到权限信息

private List<SimpleGrantedAuthority> authorities;

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

// 返回权限信
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
    if (Collections.isEmpty(authorities)){
        authorities = permissions.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
    }
    return authorities;
}

UserDetailsService中将查询到的用户信息封装到UserDetais中,也就是LoginUser中,这里我们为了测试,先将权限信息写死了。

@Override
public UserDetails loadUserByUsername(String username) throws RuntimeException {
    // 查询用户信息
    QueryWrapper<User> queryWrapper = new QueryWrapper<>();
    queryWrapper.eq("user_name", username);
    User user = userMapper.selectOne(queryWrapper);
    if (user == null) {
        throw new RuntimeException("用户名不存在");
    }
    // 用户存在 数据封装为UserDetails
    // TODO 查询用户的权限信息
    List<String> list = Arrays.asList("test", "admin");
    LoginUser loginUser = new LoginUser(user,list);
    return loginUser;
}

注意:我们会在登陆认证成功后将LoginUser存入Redis中,但是Redis默认为了安全考虑,它不会存储SimpleGrantedAuthority信息,所以我们不需要对List<SimpleGrantedAuthority>进行序列化存储。只需要存储List<String> permissions就可以了,这样就要从Redis中反序列化获得LoginUser对象后,将List<String> permissions再转换为List<SimpleGrantedAuthority>

通过添加@JsonField(serialize = false)来设置字段不需要进行序列化存储到Redis中。

@JSONField(serialize = false)
private List<SimpleGrantedAuthority> authorities;

TokenFilter中设置从Redis中查询到用户后,封装到Authentication中的权限信息。

SecurityContext context = SecurityContextHolder.getContext();
// 参数1:用户信息  2:凭证信息,密码  3:权限信息
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());  // 封装权限信息 loginUser.getAuthorities();
context.setAuthentication(authentication);
4.3.3 从数据库中查询权限信息

RBAC权限模型

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

准备工作

数据库中创建相关权限信息表

image-20240807093805461

# 权限表
create table sys_menu(
	id bigint(20) not null auto_increment,
	menu_name varchar(64) not null DEFAULT(NULL) COMMENT '菜单名',
	path VARCHAR(200) DEFAULT(null) COMMENT '路由地址',
	component VARCHAR(255) DEFAULT(null) COMMENT '组件路径',
	visible char(1) DEFAULT('0') COMMENT '菜单状态(0显示  1隐藏)',
	`status` char(1) DEFAULT('0') COMMENT '菜单状态(0正常 1停用)',
	perms VARCHAR(100) DEFAULT(null) COMMENT '权限标识',
	icon VARCHAR(100) DEFAULT('#') COMMENT '菜单图吧',
	create_by bigint(20) DEFAULT(null),
	create_time datetime DEFAULT(null),
	update_by BIGINT(20) DEFAULT(null),
	update_time DATETIME DEFAULT(null),
	del_flag int(11) DEFAULT('0') COMMENT '是否删除(0未删除  1已删除)',
	remark VARCHAR(500) DEFAULT(null) COMMENT '备注',
	primary key(id)
)ENGINE=InnoDB auto_increment=2 DEFAULT CHARSET=utf8mb4;

# 角色表
CREATE TABLE `sys_role` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '角色ID',
  `name` varchar(128) DEFAULT(NULL) COMMENT '角色名称',
  `role_key` varchar(100) DEFAULT(NULL) COMMENT '角色权限字符串',
  `status` char(1) DEFAULT('0') COMMENT '角色状态(0正常 1停用)',
  `del_flag` INT(1) DEFAULT('0')  COMMENT '删除标识',
  `create_by` BIGINT(20) DEFAULT(null) COMMENT '创建者',
  `create_time` datetime DEFAULT(NULL) COMMENT '创建时间',
  `update_by` BIGINT(20) DEFAULT(NULL) COMMENT '更新者',
  `update_time` datetime DEFAULT(NULL) COMMENT '更新时间',
  `remark` varchar(500) DEFAULT NULL COMMENT '备注',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COMMENT='角色信息表';

# 用户角色表
create table sys_user_role(
	user_id bigint(200) not null comment '用户id',
	role_id bigint(200) not null comment '角色id',
	primary key(user_id,role_id)
)ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

# 角色权限表
create table sys_role_menu(
	role_id bigint(200) not null comment '角色id',
	menu_id bigint(200) not null comment '权限id',
	primary key(role_id,menu_id)
)ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

# 角色表中插入数据
insert into sys_role(id,name,role_key,status,del_flag) 
value(1,'CEO','ceo','0',0),(2,'Coder','coder','0',0);

# 权限表中插入数据
insert into sys_menu(id,menu_name,path,component,visible,status,perms)
value(1,'部门管理','dept','system/dept/index',0,0,'system:dept:list'),
    (2,'测试','test','system/dept/test',0,0,'system:dept:test');

# 用户角色表中插入数据
insert into sys_user_role(user_id, role_id) values(2,1);

# 角色权限表插入数据
insert into sys_role_menu(role_id, menu_id) 
value(1,1),(1,2);

使用SQL进行查询权限测试

-- 根据userid查询 perms 对应的 role 和 menu都必须是正常状态的
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 rm.menu_id = m.id
where r.status = 0 and m.status=0 and ur.user_id = 2;

创建Menu实体类

@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName("sys_menu")
public class Menu implements Serializable {
    @Serial
    private static final long serialVersionUID = -6684679612152415882L;
    @TableId
    private Long id;
    private String menuName;
    private String path;
    private String component;
    private String visible;
    private String status;
    private String perms;
    private String icon;
    private Long createBy;
    private Date createTime;
    private Long updateBy;
    private Date updateTime;
    private Integer delFlag;
    private String remark;
}

创建MenuMapper接口和对应的MenuMapper.xml

MenuMapper接口

public interface MenuMapper extends BaseMapper<Menu> {
    List<String> getMenusByUserId(Long userId);
}

MenuMapper.xml

<mapper namespace="com.mango.mapper.MenuMapper">
    <select id="getMenusByUserId" parameterType="long" resultType="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 rm.menu_id = m.id
        where r.status = 0 and m.status=0 and ur.user_id = #{userId};
    </select>
</mapper>

修改MyUserDetailsService,从数据库中查询权限信息,增加如下代码

@Resource
private MenuMapper menuMapper;

 // 用户存在 数据封装为UserDetails
List<String> menus = menuMapper.getMenusByUserId(user.getId());
LoginUser loginUser = new LoginUser(user,menus);

修改HelloController中接口的访问权限设置

@PreAuthorize("hasAuthority('system:dept:list')") // 设置访问资源所需要的权限
@GetMapping("/hello")
public String sayHello(String name) {
    return "Hello";
}

此时启动项目,登陆成功后,访问localhost:8888/hello可以正常访问,因为用户拥有system:dept:list权限。


5. 自定义失败处理

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

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

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

如果是授权过程中出现的异常会被封装成AccessDeniedException然后调用AccessDeniedHandler对象的方法去进行异常处理所以如果我们需要自定义异常处理,我们只需要自定义AuthenticationEntryPoint和AccessDeniedHandler然后配置给SpringSecurity即可。

自定义认证失败处理实现类

@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        response.setCharacterEncoding("UTF-8");
        response.setContentType("text/html; charset=utf-8");
        try(PrintWriter writer = response.getWriter()) {
            writer.print(JSON.toJSONString(ResultUtil.error("认证失败")));
            writer.flush();
        }catch (Exception e){
            e.printStackTrace();
        }
    }
}

自定义鉴权失败处理实现类

@Component
public class MyAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        response.setCharacterEncoding("UTF-8");
        response.setContentType("text/html; charset=utf-8");
        try(PrintWriter writer = response.getWriter()) {
            writer.print(JSON.toJSONString(ResultUtil.error("权限不足")));
            writer.flush();
        }catch (Exception e){
            e.printStackTrace();
        }
    }
}

配置给SpringSecurity

securityFilterChain方法中,使用HttpSecurity进行配置。

@Resource
private AuthenticationEntryPointImpl authenticationEntryPoint;
@Resource
private MyAccessDeniedHandler myAccessDeniedHandler;

// 配置认证失败处理器 配置鉴权失败处理器
http.exceptionHandling(
        exception-> {
    			exception.authenticationEntryPoint(authenticationEntryPoint)
      						 .accessDeniedHandler(myAccessDeniedHandler);
        }
);

6. 跨域

浏览器处于安全的考虑,使用XMLHttpRequest对象发起HTTP请求时必须遵循守同源策略,否则就是跨域的HTTP请求,默认情况下是被禁止的。同源策略要求源相同才能正常进行通信,即协议、域名、端口号都完全一致。

前后端分离项目,前端项目和后端项目一般都是不同源的,所以肯定会存在跨域请求的问题。

所以我们就要处理一下,让前端能进行跨域请求。

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

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

配置SpringSecurity允许跨域(本文所使用的为SpringSecurity6.2.0不再需要该配置)

securityFilterChain方法中,使用HttpSecurity进行配置。

http.cors();

注意:在新版本的SpringSecurity中,SpringSecurity不再需要配置跨域,全部交给SpringMVC进行解决即可!


7. 小问题总结

7.1 其他权限校验方法

我们前面都是使用@PreAuthorize注解,然后在在其中使用的是hasAuthority方法进行校验。SpringSecurity还为我们提供了其它方法,例如:hasAnyAuthorityhasRolehasAnyRole等。

这里我们先不急着去介绍这些方法,我们先去理解hasAuthority的原理,然后再去学习其他方法你就更容易理解,而不是死记硬背区别。并且我们也可以选择定义校验方法,实现我们自己的校验逻辑。

hasAuthority方法实际是执行到了SecurityExpressionRoothasAuthority,只要断点调试既可知道它内部的校验原理.
它内部其实是调用authenticationgetAuthorities方法获取用户的权限列表。然后判断我们存入的方法参数数据是否在权限列表中。

  • hasAnyAuthority方法可以传入多个权限,只有用户有其中任意一个权限都可以访问对应资源。

  • hasRole要求有对应的角色才可以访问,但是它内部会把我们传入的参教拼接上ROLE_后再去比较。所以这种情况下要用户对应的权限也要有ROLE_这个前缀才可以。

  • hasAnyRole有任意的角色就可以访可。它内部也会把我们传入的参数拼接上ROLE_后再去比较。所以这种情况下要用用户对应的权限也要有ROLE_这个前缀才可以。

7.2 自定义权限校验方法

很多项目中,我们需要使用自定义的权限校验方法。如,我们可能将权限标识符写为sys:*:*,使用*标识通配。

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

新建MyExpressionRoot,并放入IOC容器。定义一个方法,来进行权限的判断。

@Component
public class MyExpressionRoot {
    public boolean hasAuthority(String authority) {
        // 获取当前用户的权限
        SecurityContext context = SecurityContextHolder.getContext();
        Authentication authentication = context.getAuthentication();
        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
        // 判断用户权限集合中是否存在authority
        List<String> collect = authorities.stream().map(GrantedAuthority::getAuthority).collect(Collectors.toList());
        boolean contains = collect.contains(authority);
        return contains;
    }
}

在进行权限校验时,使用我们自定义的权限校验方法

@PreAuthorize("@myExpressionRoot.hasAuthority('system:dept:list')")

@myExpressionRoot.hasAuthority('system:dept:list')为SPEL表达式。

@后跟Bean中的对象名称,如我们定义的类为MyExpressionRoot,那么就应该为@myExpressionRoot。后面直接调用我们自定义类中的方法即可。

7.3 基于配置的权限控制

除了使用注解方法之外,我们也可以在配置类中使用配置的方式对资源进行权限控制。

在SecurityConfig中进行配置,如

http
  .authorizeRequests(authorize->{
      authorize.requestMatchers("/user/login").anonymous()
              .requestMatchers("/hello").hasAuthority("system:dept:list")  // 配置访问权限
              .anyRequest().authenticated();
  });

7.4 CSRF

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

SpringSecurity去方式CSRF攻击的方式就是通过csrf_token。后端会生成一个csrf_token,前端发起请求的时候需要携带这个csrf_token,后端会有过滤器进行校验,如果没有携带或者是伪造的就不允许访问。

image-20240807163712768

我们可以发现CSRF攻击依靠的是cookie中所携带的认证信息。但是在前后端分离的项目中,我们的认证信息其实是token,而token并不是存储在cookie中,并且需要前端代码去把token设置到请求头中才可以,所以CSRF攻击也就不用担心了。

所以,在SpringSecurity中关闭了CSRF。

 http.csrf(csrf->csrf.disable());

7.5 认证成功/失败处理器

认证成功处理器

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

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

注意:该案例不能在我们前面的案例上继续进行,因为前面我们使用的方法已经不会在经过UsernamePasswordAuthenticationFilter了。因为我们在LoginServiceImpl中直接调用了AuthenticationManager进行认证。

自定义认证成功处理器,我们实现AuthenticationSuccessHandler接口,并将该类放入到IOC容器即可。

@Component
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        System.out.println("测试");
    }
}

然后在SecurityConfig中进行配置。

@Resource
private MyAuthenticationSuccessHandler myAuthenticationSuccessHandler;

http.formLogin(auth -> auth.successHandler(myAuthenticationSuccessHandler));

认证失败处理器

自定义认证失败处理器,与自定义认证成功处理器逻辑基本相同。

自定义认证失败处理器需要继承AuthenticationFailureHandler接口,实现其方法,并将该类放入IOC容器。然后在SpringSecurityConfig中进行配置。

@Resource
private MyAuthenticationFailHandler myAuthenticationFailHandler;

http.formLogin(auth->auth.failureHandler(myAuthenticationFailHandler));

7.6 登出成功处理器

自定义登出成功处理器,与自定义认证成功/失败处理器逻辑基本相同。

自定义登录成功处理器,需要继承LogoutSuccessHandler接口,实现其方法,并将该类放入IOC容器。然后在SpringSecurityConfig中进行配置。

@Resource
private MyLogoutSuccessHandler myLogoutSuccessHandler;

http.logout().logoutSuccessHandler(myLogoutSuccessHandler);
  • 5
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

mango1698

你的鼓励是我最大的动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值