Spring Security

目录

04、Spring Security从数据库中读取信息

pom依赖

application.properties 

User

1.每个实体类的属性名都不一样,怎么知道你哪个字段表示用户名,那个字段表示密码,Spring Security提供了统一的方法实现UserDetails 接口

2.先重写方法,再实现get和set方法

3.重写了自动的,记得删掉get方法

UserService 

当用户登录的时候,会自动调用到这个方法

UserMapper

UserMapper.xml

 05、Spring Security自定义登录接口

LoginController

1.AuthenticationManager 就是 Spring Security 中的认证器,这是一个接口,这个接口只有一个实现类,ProviderManager,用户登录时侯具体的校验工作,就是由它来完成的。

2.new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword());:拿到用户名和密码构建登录令牌

3.authenticationManager.authenticate(token);//这一步就是去执行登录

4.自定义登录接口要手动存信息进SecurityContextHolder

SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(token.getPrincipal(), null, token.getAuthorities()));

5.登录失败抛出的异常

SecurityConfig

1.注册LoginController的AuthenticationManager的认证器到spring中去

2.configure(HttpSecurity http)过滤登录接口

 06、Spring Security密码加密

内部源码:PasswordEncoder :Ctrl+h可以查看他的实现类

1.encode密码加密

2.matches(明文密码,加密密码)密码匹对:登录会自动帮你执行

3.upgradeEncoding密码升级

Security02ApplicationTests

测试BCryptPasswordEncoder加密:密码自带盐值

new BCryptPasswordEncoder(10);可以设置密码强度,加密起来慢,对于破解也慢了

 SecurityConfig

  06、Spring Security新的加密方法逐步替代原有的加密方法

内部源码:PasswordEncoderFactories

SecurityConfig

1.如果我们没有手动配置密码加密工具,那么默认PasswordEncoder,实际上是DelegatingPasswordEncoder

2.DelegatingPasswordEncoder 代理了所有密码加密器,他会根据密码的字符串格式,而选择一个合适的加密工具

3.例如数据库前缀{noop}

 

数据库可以多种加密方案共存

如何实现数据库统一类型密码加密-升级不同类型的密码

UserService

1.密码升级,实现UserDetailsPasswordService 接口

2.重新方法: *当用户登录的时候,会去自动检查当前用户密码是不是bcrypt,如果不是,则会自动进行密码升级,那么就会触发该方法

3.在这个方法种更新密码

UserService

 UserMapper.xml

升级同等类型的密码 

SecurityConfig

1.首先要有passwordEncoder 统一的密码加密类型

2.重写PasswordEncoderFactories加入@Bean改变密码强度


04、Spring Security从数据库中读取信息

pom依赖

<!--    Spring Security框架-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
<!--        mybatis-->
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>                                             <version>2.2.2</version>
        </dependency>
<!--        druid数据源-->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid</artifactId>
            <version>1.2.9</version>
        </dependency>
<!--        mysql连接数据库-->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>
<!--资源过滤-->
    <build>
        <resources>
            <resource>
                <directory>src/main/java</directory>
                <includes>
                    <include>**/*.xml</include>
                </includes>
            </resource>
            <resource>
                <directory>src/main/resources</directory>
            </resource>
        </resources>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

application.properties 

spring.datasource.username=root
spring.datasource.password=root
spring.datasource.url=jdbc:mysql:///student02?serverTimezone=Asia/Shanghai
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource

User

1.每个实体类的属性名都不一样,怎么知道你哪个字段表示用户名,那个字段表示密码,Spring Security提供了统一的方法实现UserDetails 接口

2.先重写方法,再实现get和set方法

3.重写了自动的,记得删掉get方法

public class User implements UserDetails {

    private Integer id;
    private String username;
    private String nickname;
    private String password;
    private Boolean enabled;
    private Integer role;

    public Integer getId() {
        return id;
    }

    public void setId(Integer id) {
        this.id = id;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getNickname() {
        return nickname;
    }

    public void setNickname(String nickname) {
        this.nickname = nickname;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    public void setEnabled(Boolean enabled) {
        this.enabled = enabled;
    }

    public Integer getRole() {
        return role;
    }

    public void setRole(Integer role) {
        this.role = role;
    }
===========================================
    /**
     * 返回用户的 角色/权限
     * @return
     */
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return null;
    }

    /**
     * 获取用户的密码:放回上边密码字段
     * @return
     */
    @Override
    public String getPassword() {
        return password;
    }

    /**
     * 获取用户名::放回上边用户名
     * @return
     */
    @Override
    public String getUsername() {
        return username;
    }

    /**
     * 账户是否没有过期
     * @return
     */
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    /**
     * 账户是否没有被锁定
     * @return
     */
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    /**
     * 密码是否没有过期
     * @return
     */
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    /**
     * 账户是否可用:如果上面有自动就如实放回
     * @return
     */
    @Override
    public boolean isEnabled() {
        return enabled;
    }
}

UserService 

当用户登录的时候,会自动调用到这个方法

@Service
public class UserService implements UserDetailsService {

    @Autowired
    UserMapper userMapper;

    /**
     * 根据用户名查询用户对象
     * 当用户登录的时候,会自动调用到这个方法
     * @param username 登录的时候,用户输入的用户名
     * @return
     * @throws UsernameNotFoundException
     */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userMapper.loadUserByUsername(username);
        if (user == null) {
            //说明用户名不存在,这个异常最终会被隐藏起来,转而抛出 BadCredentialsException
            throw new UsernameNotFoundException("用户不存在");
        }
        return user;
    }
}

UserMapper

@Mapper
public interface UserMapper {
    User loadUserByUsername(String username);
}

UserMapper.xml

<mapper namespace="com.qfedu.security02.mapper.UserMapper">

<select id="loadUserByUsername" resultType="com.qfedu.security02.model.User">
    select * from user where username=#{username};
    </select>
</mapper>

 05、Spring Security自定义登录接口

LoginController

1.AuthenticationManager 就是 Spring Security 中的认证器,这是一个接口,这个接口只有一个实现类,ProviderManager,用户登录时侯具体的校验工作,就是由它来完成的。

2.new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword());:拿到用户名和密码构建登录令牌

3.authenticationManager.authenticate(token);//这一步就是去执行登录

4.自定义登录接口要手动存信息进SecurityContextHolder

SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(token.getPrincipal(), null, token.getAuthorities()));

5.登录失败抛出的异常

@RestController
public class LoginController {

    /**
     * AuthenticationManager 就是 Spring Security 中的认证器,这是一个接口,这个接口只有一个实现类,ProviderManager,用户登录时侯具体的校验工作,就是由它来完成的。
     */
    @Autowired
    AuthenticationManager authenticationManager;

    /**
     * 不使用 Spring Security 自带的登录接口,而是自己写一个登录接口
     *
     * @param user
     * @return
     */
    @PostMapping("/login")
    public Map<String, Object> login(@RequestBody User user) {
        Map<String, Object> map = new HashMap<>();
        //构建登录令牌
        UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword());
        try {
            //这一步就是去执行登录,类似于 shiro 中的 subject.login 方法
            authenticationManager.authenticate(token);
            //将当前登录成功的用户信息存入到 SecurityContextHolder 中
            SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(token.getPrincipal(), null, token.getAuthorities()));
            map.put("status", "200");
            map.put("message", "登录成功");
        } catch (AuthenticationException exception) {
            //登录失败
            map.put("status", "500");
            if (exception instanceof BadCredentialsException) {
                map.put("message", "用户名或者密码写错,登录失败");
            } else if (exception instanceof UsernameNotFoundException) {
                //出于安全考虑,Spring Security 内部将 UsernameNotFoundException 隐藏起来了,转而抛出了 BadCredentialsException
            } else if (exception instanceof AccountExpiredException) {
                map.put("message", "账户过期,登录失败");
            } else if (exception instanceof CredentialsExpiredException) {
                map.put("message", "密码过期,登录失败");
            } else if (exception instanceof DisabledException) {
                map.put("message", "账户被禁用,登录失败");
            } else if (exception instanceof LockedException) {
                map.put("message", "账户被锁定,登录失败");
            }
        }
        return map;
    }

SecurityConfig

1.注册LoginController的AuthenticationManager的认证器到spring中去

2.configure(HttpSecurity http)过滤登录接口

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

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

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/login").permitAll()
                .anyRequest().authenticated()
                .and()
                //从 Spring Security 过滤器链中移除 CsrfFilter
                .csrf().disable();
    }

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

 06、Spring Security密码加密

内部源码:PasswordEncoder :Ctrl+h可以查看他的实现类

1.encode密码加密

2.matches(明文密码,加密密码)密码匹对:登录会自动帮你执行

3.upgradeEncoding密码升级

4.自带盐值,相同的明文加密成密码不同

public interface PasswordEncoder {

   String encode(CharSequence rawPassword);

   boolean matches(CharSequence rawPassword, String encodedPassword);

   default boolean upgradeEncoding(String encodedPassword) {
      return false;
   }

}

Security02ApplicationTests

测试BCryptPasswordEncoder加密:密码自带盐值

new BCryptPasswordEncoder(10);可以设置密码强度,加密起来慢,对于破解也慢了

@SpringBootTest
class Security02ApplicationTests {

    @Test
    void contextLoads() {
        BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(10);
        for (int i = 0; i < 10; i++) {
            System.out.println(encoder.encode("123"));
        }
    }

}

$2a$10$NqbXkorf.Df13lefBvZspuD9wHj9CH3KUAYQCOBvqDRyze8XPVck6
$2a$10$kFIsIVV1y0Zuvs1cggzqnuZ7Qzgq0whzf3ozf0iGmbAxvBi731mji
$2a$10$giZViF92vIsZF6jrlERRB.lJm2XcMU8H/u3wox3QWCBDy9aLukcGK
$2a$10$UtWGZsdlBiLatF1CGGFC.uXibKwqydjS1Nb0ob5fGuVZyHi//NjwK
$2a$10$.TsjcfVJ0RYfAkREUM.RE.yfKrPrskRWflDjyxbo83QjkOKzsmU2C
$2a$10$lDppXh3Z32Q3qlpKdpXB8OP6UKKwRPlNqRV1CBtJ/BWl8XEuHh1a6
$2a$10$jpEDlzw36HGaSibKJpeu.uAn0YU9BhhjFrgoLAd8MGE1bkdr7zMW2
$2a$10$is5sKcAajPNQTTfojjjJvuSlY7s1VAe5kcyDoa7pWCGcmwETKglVi
$2a$10$eMoBXb3slmXSl/OM9Rjg2O1AYKZQdOtECZzh01YwmhDLxQbBrcwgi
$2a$10$wZP9XblkBXVwMdV4UiXyKubOcEW0zWgxOJVVGsz5p69l9.C2TU5t.

 SecurityConfig

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

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

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/login").permitAll()
                .anyRequest().authenticated()
                .and()
                //从 Spring Security 过滤器链中移除 CsrfFilter
                .csrf().disable();
    }

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

  06、Spring Security新的加密方法逐步替代原有的加密方法

内部源码:PasswordEncoderFactories

public static PasswordEncoder createDelegatingPasswordEncoder() {
   String encodingId = "bcrypt";
   Map<String, PasswordEncoder> encoders = new HashMap<>();
   encoders.put(encodingId, new BCryptPasswordEncoder());
   encoders.put("ldap", new org.springframework.security.crypto.password.LdapShaPasswordEncoder());
   encoders.put("MD4", new org.springframework.security.crypto.password.Md4PasswordEncoder());
   encoders.put("MD5", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("MD5"));
   encoders.put("noop", org.springframework.security.crypto.password.NoOpPasswordEncoder.getInstance());
   encoders.put("pbkdf2", new Pbkdf2PasswordEncoder());
   encoders.put("scrypt", new SCryptPasswordEncoder());
   encoders.put("SHA-1", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("SHA-1"));
   encoders.put("SHA-256",
         new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("SHA-256"));
   encoders.put("sha256", new org.springframework.security.crypto.password.StandardPasswordEncoder());
   encoders.put("argon2", new Argon2PasswordEncoder());
//代理密码加密器
   return new DelegatingPasswordEncoder(encodingId, encoders);
}

SecurityConfig

1.如果我们没有手动配置密码加密工具,那么默认PasswordEncoder,实际上是DelegatingPasswordEncoder

2.DelegatingPasswordEncoder 代理了所有密码加密器,他会根据密码的字符串格式,而选择一个合适的加密工具

3.例如数据库前缀{noop}

    /**
     * 如果我们没有手动配置密码加密工具,那么默认PasswordEncoder,实际上是DelegatingPasswordEncoder
     *
     * DelegatingPasswordEncoder 代理了所有密码加密器,他会根据密码的字符串格式,而选择一个合适的加密工具
     *
     * noop
     *
     * @return
     */
//    @Bean
//    PasswordEncoder passwordEncoder() {
//        return new BCryptPasswordEncoder();
//    }

数据库可以多种加密方案共存

如何实现数据库统一类型密码加密-升级不同类型的密码

UserService

1.密码升级,实现UserDetailsPasswordService 接口

2.重新方法: *当用户登录的时候,会去自动检查当前用户密码是不是bcrypt,如果不是,则会自动进行密码升级,那么就会触发该方法

3.在这个方法种更新密码

@Service
public class UserService implements UserDetailsService, UserDetailsPasswordService {

    @Autowired
    UserMapper userMapper;

    /**
     * 根据用户名查询用户对象
     * 当用户登录的时候,会自动调用到这个方法
     * @param username 登录的时候,用户输入的用户名
     * @return
     * @throws UsernameNotFoundException
     */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userMapper.loadUserByUsername(username);
        if (user == null) {
            //说明用户名不存在,这个异常最终会被隐藏起来,转而抛出 BadCredentialsException
            throw new UsernameNotFoundException("用户不存在");
        }
        return user;
    }

/**
 * 当用户登录的时候,回去自动检查当前用户密码是不是bcrypt,如果不是,则会自动进行密码升级,那么
 * 就会触发该方法,在这个方法种,更新密码即可
 * @param user 当前用户密码
 * @param newPassword 重新处理后的密码
 * @return
 */
@Override
public UserDetails updatePassword(UserDetails user, String newPassword) {
    Integer i = userMapper.updatePassWordByUsernam(newPassword,user.getUsername());
    if (i == 1){
       User user1 = (User) user;
       user1.setPassword(newPassword);
       return user1;
    }
    return user;
}

UserService

@Mapper
public interface UserMapper {
    User loadUserByUsername(String username);

    Integer updatePassWordByUsernam(@Param("password") String newPassword, @Param("username")String username);
}

 UserMapper.xml

<mapper namespace="com.qfedu.security02.mapper.UserMapper">
    <update id="updatePassWordByUsername">
        update user set password = #{password} where username;
    </update>


    <select id="loadUserByUsername" resultType="com.qfedu.security02.model.User">
    select * from user where username=#{username};
    </select>
</mapper>

升级同等类型的密码 

SecurityConfig

1.首先要有passwordEncoder 统一的密码加密类型

2.重写PasswordEncoderFactories加入@Bean改变密码强度

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

@Bean
public static PasswordEncoder createDelegatingPasswordEncoder() {
    String encodingId = "bcrypt";
    Map<String, PasswordEncoder> encoders = new HashMap<>();
    encoders.put(encodingId, new BCryptPasswordEncoder(20));
    encoders.put("ldap", new org.springframework.security.crypto.password.LdapShaPasswordEncoder());
    encoders.put("MD4", new org.springframework.security.crypto.password.Md4PasswordEncoder());
    encoders.put("MD5", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("MD5"));
    encoders.put("noop", org.springframework.security.crypto.password.NoOpPasswordEncoder.getInstance());
    encoders.put("pbkdf2", new Pbkdf2PasswordEncoder());
    encoders.put("scrypt", new SCryptPasswordEncoder());
    encoders.put("SHA-1", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("SHA-1"));
    encoders.put("SHA-256",
            new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("SHA-256"));
    encoders.put("sha256", new org.springframework.security.crypto.password.StandardPasswordEncoder());
    encoders.put("argon2", new Argon2PasswordEncoder());
    return new DelegatingPasswordEncoder(encodingId, encoders);
}

 07、权限控制(核心功能)

1.HelloController:注明权限接口

  • 设置可以访问的角色@PreAuthorize("hasRole('ROLE_user')")

@RestController
public class HelloController {

    /**
     * 只要登录就能访问
     *
     * @return
     */
    @GetMapping("/hello")
    public String hello() {
        return "hello";
    }

    /**
     * 具备 user 角色或者 admin 角色,就能访问
     *
     * @return
     */
    @GetMapping("/user/hello")
    @PreAuthorize("hasRole('ROLE_user')")
    public String user() {
        return "hello user";
    }

    /**
     * 具备 admin 角色才能访问
     *
     * @return
     */
    @GetMapping("/admin/hello")
    @PreAuthorize("hasRole('ROLE_admin')")
    public String admin() {
        return "hello admin";
    }

2.SecurityConfig:设置权限

  • .antMatchers("/admin/**").hasRole("admin"),角色会自动加上ROLE_admin数据库自动要保持一致

  • .antMatchers("/user/**").hasAnyRole("admin","user")

@Override
protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
            .antMatchers("/login").permitAll()
            .antMatchers("/admin/**").hasRole("admin")
            .antMatchers("/user/**").hasAnyRole("admin","user")
            .anyRequest().authenticated()
            .and()
            //从 Spring Security 过滤器链中移除 CsrfFilter
            .csrf().disable();
}

 

 3.Role:角色实体类

package com.qfedu.security02.model;

public class Role {
    private Integer id;
    private String name;

    public Integer getId() {
        return id;
    }

    public void setId(Integer id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

4.user:在这里有获取用户角色/权限的方法getAuthorities()

public class User implements UserDetails {
    private Integer id;
    private String username;
    private String nickname;
    private String password;
    private Boolean enabled;
    private Integer role;

//    一个用户可能有不同的角色
    private List<Role> roles;

    public Boolean getEnabled() {
        return enabled;
    }

    public List<Role> getRoles() {
        return roles;
    }

    public void setRoles(List<Role> roles) {
        this.roles = roles;
    }

    public Integer getId() {
        return id;
    }

    public void setId(Integer id) {
        this.id = id;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getNickname() {
        return nickname;
    }

    public void setNickname(String nickname) {
        this.nickname = nickname;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    public void setEnabled(Boolean enabled) {
        this.enabled = enabled;
    }

    public Integer getRole() {
        return role;
    }

    public void setRole(Integer role) {
        this.role = role;
    }

    /**
     * 返回用户的 角色/权限
     * @return
     */
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
//        SimpleGrantedAuthority是他唯一的实现类
        List<SimpleGrantedAuthority> list = new ArrayList<>();
        return list;

//        return roles.stream().map(r -> new SimpleGrantedAuthority(r.getName())).collect(Collectors.toList());

    }

    /**
     * 获取用户的密码
     * @return
     */
    @Override
    public String getPassword() {
        return password;
    }

    /**
     * 获取用户名
     * @return
     */
    @Override
    public String getUsername() {
        return username;
    }

    /**
     * 账户是否没有过期
     * @return
     */
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    /**
     * 账户是否没有被锁定
     * @return
     */
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    /**
     * 密码是否没有过期
     * @return
     */
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    /**
     * 账户是否可用
     * @return
     */
    @Override
    public boolean isEnabled() {
        return enabled;
    }
}

 5.UserService:在登录的成功的时候拿到用户角色

@Autowired
UserMapper userMapper;

/**
 * 根据用户名查询用户对象
 * 当用户登录的时候,会自动调用到这个方法
 * @param username 登录的时候,用户输入的用户名
 * @return
 * @throws UsernameNotFoundException
 */
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    User user = userMapper.loadUserByUsername(username);
    if (user == null) {
        //说明用户名不存在,这个异常最终会被隐藏起来,转而抛出 BadCredentialsException
        throw new UsernameNotFoundException("用户不存在");
    }
    //如过查询到了用户,接下来就去查询用户的角色
    user.setRoles(userMapper.getUserRolesByUid(user.getId()));
    return user;
}

UserMapper

@Mapper
public interface UserMapper {
    User loadUserByUsername(String username);

    Integer updatePassWordByUsername(@Param("password") String newPassword, @Param("username")String username);

    List<Role> getUserRolesByUid(Integer uid);
}
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

LI JS@你猜啊

你的鼓励将是我创作的最大动力

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

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

打赏作者

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

抵扣说明:

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

余额充值