Spring Security学习笔记

SpringSecurity

一、SpringSecurity

1. SpringSecurity简介

安全框架概述

什么是安全框架? 解决系统安全问题的框架。如果没有安全框架,我们需要手动处理每个资源的访问控制,非常麻烦。使用安全框架,我们可以通过配置的方式实现对资源的访问限制。

常用安全框架

  • Spring Security: Spring家族一员。是一个能够为基于Spring的企业应用系统提供声明式的安全访问控制解决方案的安全框架。它提供了一组可以在 Spring 应用上下文中配置的 Bean,充分利用了 Spring IoC,DI(控制反转Inversion of Control, DI:Dependency Injecton 依赖注入)和 AOP (面向切面编程)功能,为应用系统提供声明式的安全访问控制功能,减少了为企业系统安全控制编写大量重复代码的工作。
  • Apache Shiro: 一个功能强大且易于使用的Java安全框架,提供了认证、授权、加密和会话管理。

SpringSecurity概述

Spring Security是一个高度自定义的安全框架。利用 Spring loC/DI和AOP功能,为系统提供了声明式安全访问控制功能,减少了为系统安全而编写大量重复代码的工作。使用 Spring Secruity 的原因有很多,但大部分都是发现了javaEE的 Servlet 规范或 EJB 规范中的安全功能缺乏典型企业应用场景。同时认识到他们在 WAR 或 EAR 级别无法移植。因此如果你更换服务器环境,还有大量工作去重新配置你的应用程序。使用 Spring Seurity解决了这些问题,也为你提供许多其他有用的、可定制的安全功能。正如你可能知道的两个应用程序的两个主要区域是“认证和“授权”(或者访问控制)。这两点也是 Spring Security 重要核心功能。"认证”,是建立一个他声明的主体的过程(一个“主体”一般是指用户,设备或一些可以在你的应用程序中执行动作的其他系统),通俗点说就是系统认为用户是否能登录。“授权”指确定一个主体是否允许在你的应用程序执行一个动作的过程。通俗点讲就是系统判断用户是否有权限去做某些事情。

2. 快速入门

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.3.7.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.gzb</groupId>
    <artifactId>springsecuritydemo</artifactId>
    <version>0.0.1-SNAPSHOT</version>

    <properties>
        <java.version>1.8</java.version>
    </properties>
    <dependencies>
        <!--SpringSecurity-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <!--Spring Web-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

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

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <image>
                        <builder>paketobuildpacks/builder-jammy-base:latest</builder>
                    </image>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

LoginController

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
public class LoginController {
    @RequestMapping("login")
    public String login() {
        System.out.println("执行登录方法");
        return "redirect:main.html";
    }
}

login.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>登录</title>
</head>
<body>
    <form action="/login" method="post">
        用户名:<input type="text" name="username" /> <br/>
        密码:<input type="password" name="password" /> <br/>
        <input type="submit" value="登录">
    </form>
</body>
</html>

main.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
登录成功!
</body>
</html>

浏览器访问localhost:8080/login.html

1

输入用户名 user,密码位于控制台

1

登录成功后,即可看到我们编写的login.html的登录页面

3. UserDetailService

UserDetailsService是Spring Security框架中的一个核心接口,用于从特定的数据源(通常是数据库或其他存储系统)加载用户详细信息。该接口的主要目的是将应用程序的用户数据与Spring Security的用户模型集成,以便进行身份验证和授权。

UserDetailsService接口定义了一个方法:

public interface UserDetailsService {
    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

其中,loadUserByUsername方法接受一个用户名作为参数,并返回一个实现了UserDetails接口的对象。如果找不到与给定用户名匹配的用户,通常会抛出UsernameNotFoundException

下面是UserDetails接口的主要方法:

public interface UserDetails extends Serializable {
    Collection<? extends GrantedAuthority> getAuthorities();
    String getPassword();
    String getUsername();
    boolean isAccountNonExpired();
    boolean isAccountNonLocked();
    boolean isCredentialsNonExpired();
    boolean isEnabled();
}

通常,UserDetails的实现类是User,这是一个简单的POJO(Plain Old Java Object)类,表示用户的详细信息。User类实现了UserDetails接口,并提供了一些构造函数和方法来设置用户属性。

使用UserDetailsService的主要步骤如下:

  1. 实现UserDetailsService接口:创建一个类实现UserDetailsService接口,重写loadUserByUsername方法,根据用户名从数据源中加载用户详细信息。

    public class CustomUserDetailsService implements UserDetailsService {
        @Override
        public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
            // 从数据库或其他数据源中加载用户信息
            // ...
        }
    }
    
  2. 返回UserDetails对象:在loadUserByUsername方法中,创建并返回一个实现了UserDetails接口的对象,通常是User类的实例。

    public class CustomUserDetailsService implements UserDetailsService {
        @Override
        public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
            // 从数据库中查询用户信息
            // User是UserDetails的实现类
            User user = userRepository.findByUsername(username)
                    .orElseThrow(() -> new UsernameNotFoundException("User not found with username: " + username));
    
            return user;
        }
    }
    
  3. 配置Spring Security使用自定义的UserDetailsService:在Spring Security的配置中,指定使用自定义的UserDetailsService实现类。

    @Configuration
    public class SecurityConfig extends WebSecurityConfigurerAdapter {
        @Autowired
        private UserDetailsService userDetailsService;
    
        @Override
        protected void configure(AuthenticationManagerBuilder auth) throws Exception {
            auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
        }
    
        // 其他配置省略...
    }
    

通过实现和配置UserDetailsService,Spring Security能够与应用程序的用户数据进行集成,使得身份验证和授权流程能够有效地使用自定义的用户详细信息。

4. PasswordEncoder

PasswordEncoder是Spring Security中用于密码加密和验证的接口。密码加密是一种重要的安全措施,目的是在存储或传输用户密码时,确保密码不以明文形式存储,从而提高系统的安全性。PasswordEncoder提供了密码加密和密码匹配的方法。

下面是PasswordEncoder接口的主要方法:

public interface PasswordEncoder {
    String encode(CharSequence rawPassword);
    boolean matches(CharSequence rawPassword, String encodedPassword);
}
  1. encode(CharSequence rawPassword)方法

    • 该方法接受一个未加密的密码(rawPassword)并返回其加密后的表示形式。
    • 在用户注册或密码更新时,原始密码通常通过这个方法进行加密,然后将加密后的密码存储在数据库中。
  2. matches(CharSequence rawPassword, String encodedPassword)方法

    • 该方法用于检查提供的未加密密码是否与存储的加密密码匹配。
    • 在用户登录时,用户输入的密码通常通过这个方法与存储在数据库中的加密密码进行比较,以验证用户的身份。

在Spring Security中,有几个实现了PasswordEncoder接口的类。其中,最常见的是BCryptPasswordEncoderPasswordEncoderFactories

  • BCryptPasswordEncoder
    BCryptPasswordEncoder使用BCrypt哈希算法进行密码加密,这是一种适用于密码存储的强大哈希算法。在大多数情况下,推荐使用BCryptPasswordEncoder来加密密码。

    PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
    String encodedPassword = passwordEncoder.encode("rawPassword");
    boolean matches = passwordEncoder.matches("rawPassword", encodedPassword);
    
  • PasswordEncoderFactories
    PasswordEncoderFactories是一个工厂类,可以用于根据密码存储格式选择合适的PasswordEncoder实现。它支持多种密码存储格式,包括BCrypt、SCrypt、PBKDF2等。

    PasswordEncoder passwordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
    String encodedPassword = passwordEncoder.encode("rawPassword");
    boolean matches = passwordEncoder.matches("rawPassword", encodedPassword);
    

在Spring Security配置中,通常需要配置PasswordEncoder以确保正确的密码加密和验证。这可以通过PasswordEncoder的Bean定义来实现。

5. 自定义登录逻辑

注入PasswordEncoder

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
public class SecurityConfig {
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

实现UserDetailsService

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 1. 查询数据库判断用户是否存在,如果不存在则抛出UsernameNotFoundException异常
        if (!"admin".equals(username)) {
            throw new UsernameNotFoundException("用户名不存在!");
        }
        // 2. 把查询出来的密码(注册时已经加密过)进行解析,或者直接把密码放入构造方法
        String password = passwordEncoder.encode("admin");
        return new User(username, password,
                AuthorityUtils.commaSeparatedStringTOAuthorityList("admin,normal"));
    }
}

浏览器输入 localhost:8080/login.html

SpringSecurity 提供的登录页面输入账号admin密码admin即可登录成功

6. 自定义登录页面

修改配置类继承WebSecurityConfigurerAdapter

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

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 表单提交
        http.formLogin()
                // 当发现/login时认为是登录,必须和表单提交的地址一样,去执行UserDetailsServiceImpl
                .loginProcessingUrl("/login")
                // 自定义登录页面
                .loginPage("/login.html")
                // 登录成功后跳转页面,Post请求
                .successForwardUrl("/toMain");

        // 授权认证
        http.authorizeRequests()
                // login.html 不需要认证
                .antMatchers("/login.html").permitAll()
                // 所有请求都必须被认证,必须登录之后别访问
                .anyRequest().authenticated();

        // 关闭csrf防护
        http.csrf().disable();
    }
}

LoginController响应登录成功的请求到main.html

@Controller
public class LoginController {
    @PostMapping("toMain")
    public String login() {
        System.out.println("执行登录方法");
        return "redirect:main.html";
    }
}

7. 失败跳转

error.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
操作失败,请重新登录 <a href="/login.html">跳转</a>
</body>
</html>

修改配置类

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 表单提交
        http.formLogin()
                // 当发现/login时认为是登录,必须和表单提交的地址一样,去执行UserDetailsServiceImpl
                .loginProcessingUrl("/login")
                // 自定义登录页面
                .loginPage("/login.html")
                // 登录成功后跳转页面,Post请求
                .successForwardUrl("/toMain")
                修改处1:登录失败后跳转页面,Post请求
                .failureForwardUrl("/toError");


        // 授权认证
        http.authorizeRequests()
                // login.html 不需要认证
                .antMatchers("/login.html").permitAll()
                修改处2:error.html 不需要认证
                .antMatchers("/error.html").permitAll()
                // 所有请求都必须被认证,必须登录之后别访问
                .anyRequest().authenticated();

        // 关闭csrf防护
        http.csrf().disable();
    }
}

处理登录失败后的Post请求

@PostMapping("toError")
public String error() {
    System.out.println("登录失败");
    return "redirect:error.html";
}

此时如果用户名或者密码输入错误,就会跳转到自定义的error.html页面

8. UsernamePasswordAuthenticationFilter

UsernamePasswordAuthenticationFilter是Spring Security框架中的一个过滤器,用于处理基于用户名和密码的身份验证请求。它负责从登录请求中提取用户名和密码,然后尝试进行身份验证。如果验证成功,它将生成一个Authentication对象,并将其传递给Spring Security的认证管理器(AuthenticationManager)。

下面是UsernamePasswordAuthenticationFilter的一些关键属性和行为:

  1. 过滤器的默认URL路径
    UsernamePasswordAuthenticationFilter默认监听/login路径,即当用户尝试登录时,Spring Security会使用这个过滤器来处理请求。

  2. HTTP POST方法
    通常,UsernamePasswordAuthenticationFilter期望身份验证请求使用HTTP的POST方法提交。用户将用户名和密码通过表单或其他方式发送到服务器。

  3. 用户名和密码参数的默认名称
    默认情况下,UsernamePasswordAuthenticationFilter从请求中读取用户名参数(username)和密码参数(password)。你可以通过配置来更改这些默认值。

  4. 身份验证失败处理器
    如果身份验证失败,UsernamePasswordAuthenticationFilter会调用AuthenticationFailureHandler来处理失败的情况。这样可以进行一些自定义的操作,例如记录失败的尝试次数、返回自定义错误消息等。

  5. 身份验证成功处理器
    如果身份验证成功,UsernamePasswordAuthenticationFilter会调用AuthenticationSuccessHandler来处理成功的情况。这允许你执行一些自定义的操作,例如生成令牌、设置会话信息等。

使用UsernamePasswordAuthenticationFilter的配置通常在WebSecurityConfigurerAdapterconfigure(HttpSecurity http)方法中完成,如下所示:

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests()
                .antMatchers("/public/**").permitAll()
                .anyRequest().authenticated()
                .and()
            .formLogin()
                .loginPage("/login")
                .permitAll()
                .and()
            .logout()
                .permitAll();

        http.addFilterBefore(authenticationFilter(), UsernamePasswordAuthenticationFilter.class);
    }

    private UsernamePasswordAuthenticationFilter authenticationFilter() throws Exception {
        UsernamePasswordAuthenticationFilter filter = new UsernamePasswordAuthenticationFilter();
        filter.setAuthenticationManager(authenticationManagerBean());
        filter.setAuthenticationSuccessHandler(successHandler());
        filter.setAuthenticationFailureHandler(failureHandler());
        filter.setFilterProcessesUrl("/login");
        return filter;
    }

    private AuthenticationSuccessHandler successHandler() {
        return new CustomAuthenticationSuccessHandler();
    }

    private AuthenticationFailureHandler failureHandler() {
        return new CustomAuthenticationFailureHandler();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder);
    }
}

在上述配置中,我们通过http.addFilterBefore(authenticationFilter(), UsernamePasswordAuthenticationFilter.class)将自定义的UsernamePasswordAuthenticationFilter添加到Spring Security的过滤器链中。authenticationFilter()方法负责配置和返回UsernamePasswordAuthenticationFilter的实例,其中包括设置认证管理器、成功处理器、失败处理器等。

9. 设置表单的用户名和密码参数

默认的参数为username和password

此处修改为username123和password123

login.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>登录</title>
</head>
<body>
    <form action="/login" method="post">
        用户名:<input type="text" name="username123" /> <br/>
        密码:<input type="password" name="password123" /> <br/>
        <input type="submit" value="登录">
    </form>
</body>
</html>

SecurityConfig

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 表单提交
        http.formLogin()
                登录用户名参数要求和html中的一致
                .usernameParameter("username123")
                登录密码参数要求和html中的一致
                .passwordParameter("password123")
                // 当发现/login时认为是登录,必须和表单提交的地址一样,去执行UserDetailsServiceImpl
                .loginProcessingUrl("/login")
                // 自定义登录页面
                .loginPage("/login.html")
                // 登录成功后跳转页面,Post请求
                .successForwardUrl("/toMain")
                // 登录失败后跳转页面,Post请求
                .failureForwardUrl("/toError");


        // 授权认证
        http.authorizeRequests()
                // login.html 不需要认证
                .antMatchers("/login.html").permitAll()
                // error.html 不需要认证
                .antMatchers("/error.html").permitAll()
                // 所有请求都必须被认证,必须登录之后别访问
                .anyRequest().authenticated();

        // 关闭csrf防护
        http.csrf().disable();
    }
}

10. 自定义登录成功后处理器

自定义类实现AuthenticationSuccessHandler

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

public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    private final String url;

    public MyAuthenticationSuccessHandler(String url) {
        this.url = url;
    }

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request,
                                        HttpServletResponse response,
                                        Authentication authentication) throws IOException {
        User user = (User) authentication.getPrincipal();
        // 用户名
        System.out.println(user.getUsername());
        // 密码(打印出来为null)
        System.out.println(user.getPassword());
        // 权限
        System.out.println(user.getAuthorities());
        response.sendRedirect(url);
    }
}

修改配置类

当用户登录成功后跳转页面到百度

@Override
protected void configure(HttpSecurity http) throws Exception {
    // 表单提交
    http.formLogin()
            // 登录用户名参数要求和html中的一致
            .usernameParameter("username123")
            // 登录密码参数要求和html中的一致
            .passwordParameter("password123")
            // 当发现/login时认为是登录,必须和表单提交的地址一样,去执行UserDetailsServiceImpl
            .loginProcessingUrl("/login")
            // 自定义登录页面
            .loginPage("/login.html")
            // 登录成功后跳转页面,Post请求
            // .successForwardUrl("/toMain")
            登录成功后处理器,不能和successForwardUrl共存
            .successHandler(new MyAuthenticationSuccessHandler("https://www.baidu.com"))
            // 登录失败后跳转页面,Post请求
            .failureForwardUrl("/toError");


    // 授权认证
    http.authorizeRequests()
            // login.html 不需要认证
            .antMatchers("/login.html").permitAll()
            // error.html 不需要认证
            .antMatchers("/error.html").permitAll()
            // 所有请求都必须被认证,必须登录之后别访问
            .anyRequest().authenticated();

    // 关闭csrf防护
    http.csrf().disable();
}

11. 自定义登录失败后处理器

自定义类实现AuthenticationFailureHandler

import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;

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

public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {
    private final String url;

    public MyAuthenticationFailureHandler(String url) {
        this.url = url;
    }

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        response.sendRedirect(url);
    }
}

修改配置类,当登录失败后跳转到error.html

@Override
protected void configure(HttpSecurity http) throws Exception {
    // 表单提交
    http.formLogin()
            // 登录用户名参数要求和html中的一致
            .usernameParameter("username123")
            // 登录密码参数要求和html中的一致
            .passwordParameter("password123")
            // 当发现/login时认为是登录,必须和表单提交的地址一样,去执行UserDetailsServiceImpl
            .loginProcessingUrl("/login")
            // 自定义登录页面
            .loginPage("/login.html")
            // 登录成功后跳转页面,Post请求
            // .successForwardUrl("/toMain")
            // 登录成功后处理器,不能和successForwardUrl共存
            .successHandler(new MyAuthenticationSuccessHandler("https://www.baidu.com"))
            // 登录失败后跳转页面,Post请求
            // .failureForwardUrl("/toError");
            登录失败后处理器,不能和failureForwardUrl共存
            .failureHandler(new MyAuthenticationFailureHandler("/error.html"));

    // 授权认证
    http.authorizeRequests()
            // login.html 不需要认证
            .antMatchers("/login.html").permitAll()
            // error.html 不需要认证
            .antMatchers("/error.html").permitAll()
            // 所有请求都必须被认证,必须登录之后别访问
            .anyRequest().authenticated();

    // 关闭csrf防护
    http.csrf().disable();
}

12. 内置访问控制方法

ExpressionUrlAuthorizationConfigurer 是 Spring Security 中用于配置基于表达式的 URL 访问控制的类。该类提供了一些方法,用于定义针对特定 URL 的访问规则,以及指定哪些角色或权限可以访问这些 URL。下面是其中几个访问控制方法的解释:

  1. permitAll

    • permitAll 方法允许所有用户对指定的 URL 进行访问,不需要进行身份验证。
    • 示例:.antMatchers("/public/**").permitAll()
  2. denyAll

    • denyAll 方法拒绝所有用户对指定的 URL 进行访问。
    • 示例:.antMatchers("/private/**").denyAll()
  3. anonymous

    • anonymous 方法允许匿名用户对指定的 URL 进行访问,即未进行身份验证的用户。
    • 示例:.antMatchers("/public/**").anonymous()
  4. authenticated

    • authenticated 方法要求用户进行身份验证,即用户必须登录才能访问指定的 URL。
    • 示例:.antMatchers("/secured/**").authenticated()
  5. fullyAuthenticated

    • fullyAuthenticated 方法要求用户是完全认证的,通常表示用户不仅仅是登录,还需要通过额外的身份验证(例如,双因素身份验证)。
    • 示例:.antMatchers("/high-security/**").fullyAuthenticated()
  6. rememberMe

    • rememberMe 方法允许通过“记住我”功能进行身份验证的用户访问指定的 URL。
    • 示例:.antMatchers("/remember-me/**").rememberMe()

这些方法可以通过链式调用的方式组合在一起,以定义更复杂的访问规则。例如:

http.authorizeRequests()
    .antMatchers("/public/**").permitAll()
    .antMatchers("/private/**").denyAll()
    .antMatchers("/secured/**").authenticated()
    .antMatchers("/high-security/**").fullyAuthenticated()
    .antMatchers("/remember-me/**").rememberMe();

上述代码表示:

  • /public/**:允许所有用户访问。
  • /private/**:拒绝所有用户访问。
  • /secured/**:要求用户进行身份验证。
  • /high-security/**:要求用户是完全认证的。
  • /remember-me/**:允许通过“记住我”功能进行身份验证的用户访问。

13. 在配置类中进行资源的访问控制

13.1 权限判断

在SecurityConfig配置类中

// 授权认证
http.authorizeRequests()
        // .antMatchers("/main1.html").hasAuthority("admiN")
    	.antMatchers("/main1.html").hasAnyAuthority("admin", "admiN")
        // login.html 不需要认证
        .antMatchers("/login.html").permitAll()
        // error.html 不需要认证
        .antMatchers("/error.html").permitAll()
        // 所有请求都必须被认证,必须登录之后别访问
        .anyRequest().authenticated();

.antMatchers("/main1.html").hasAuthority("admiN")表示必须有admiN权限才能访问main1.html

.antMatchers("/main1.html").hasAnyAuthority("admin", "admiN")表示有admin或者admiN权限其中之一就可以访问main1.html

注意:权限是区分大小写的!

13.2 角色判断

在UserDetailsServiceImpl中为登录用户赋予角色

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    // 1. 查询数据库判断用户是否存在,如果不存在则抛出UsernameNotFoundException异常
    if (!"admin".equals(username)) {
        throw new UsernameNotFoundException("用户名不存在!");
    }
    // 2. 把查询出来的密码(注册时已经加密过)进行解析,或者直接把密码放入构造方法
    String password = passwordEncoder.encode("admin");
    return new User(username, password,
            AuthorityUtils.commaSeparatedStringTOAuthorityList("admin,normal,ROLE_abc"));
}

ROLE_abc必须以ROLE_开头,这样才表示角色

在SecurityConfig中

// 授权认证
http.authorizeRequests()
        // .antMatchers("/main1.html").hasRole("abc")
    	.antMatchers("/main1.html").hasAnyRole("abc", "ABC")
        // login.html 不需要认证
        .antMatchers("/login.html").permitAll()
        // error.html 不需要认证
        .antMatchers("/error.html").permitAll()
        // 所有请求都必须被认证,必须登录之后别访问
        .anyRequest().authenticated();

.antMatchers("/main1.html").hasRole("abc")表示用户是abc角色才有权利访问main1.html

.antMatchers("/main1.html").hasAnyRole("abc", "ABC")表示用户是abc或者ABC角色才有权限访问main1.html

注意:角色名称区分大小写!

13.3 IP地址判断
// 授权认证
http.authorizeRequests()
        .antMatchers("/main1.html").hasIpAddress("127.0.0.1")

.antMatchers("/main1.html").hasIpAddress("127.0.0.1")IP地址为127.0.0.1才可以访问main1.html

14. 自定义处理403异常

自定义类实现AccessDeniedHandler接口并加入Spring容器

@Component
public class MyAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        PrintWriter writer = response.getWriter();
        writer.write("对不起,你没有权限访问");
        writer.flush();
        writer.close();
    }
}

在配置类中进行配置

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Autowired
    private MyAccessDeniedHandler myAccessDeniedHandler;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 表单提交
        http.formLogin()
                // 登录用户名参数要求和html中的一致
                .usernameParameter("username123")
                // 登录密码参数要求和html中的一致
                .passwordParameter("password123")
                // 当发现/login时认为是登录,必须和表单提交的地址一样,去执行UserDetailsServiceImpl
                .loginProcessingUrl("/login")
                // 自定义登录页面
                .loginPage("/login.html")
                // 登录成功后跳转页面,Post请求
                .successForwardUrl("/toMain")
                // 登录失败后跳转页面,Post请求
                .failureForwardUrl("/toError");
        // 授权认证
        http.authorizeRequests()
                .antMatchers("/main1.html").hasRole("abC")
                // login.html 不需要认证
                .antMatchers("/login.html").permitAll()
                // error.html 不需要认证
                .antMatchers("/error.html").permitAll()
                // 所有请求都必须被认证,必须登录之后别访问
                .anyRequest().authenticated();

        // 关闭csrf防护
        http.csrf().disable();

        // 自定义处理403异常
        http.exceptionHandling().accessDeniedHandler(myAccessDeniedHandler);

    }
}

http.exceptionHandling().accessDeniedHandler(myAccessDeniedHandler)将自己定义的异常处理类生效

15. 使用注解进行资源的访问控制

15.1 @Secured

在启动类上加上注解 @EnableGlobalMethodSecurity(securedEnabled = true)

@SpringBootApplication
@EnableGlobalMethodSecurity(securedEnabled = true)
public class SpringSecurityDemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(SpringSecurityDemoApplication.class, args);
    }
}

在对应的controller方法上加上注解@Secured("ROLE_abC")进行角色校验,其中的角色必须以ROLE_开头

@PostMapping("toMain")
@Secured("ROLE_abC")
public String login() {
    System.out.println("执行登录方法");
    return "redirect:main.html";
}

这份代码中用户必须具有角色abc才能访问该url,否则会报500服务器内部错误

15.2 @PreAuthorize/@PostAuthorize

在启动类上加上注解并设置注解的参数prePostEnabled = true

@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)

@PreAuthroize注解的值是一个access表达式,常见的如下

@PreAuthorize("hasRole('abc')")
@PreAuthorize("hasAuthority('admin')")

其中对于角色的访问不需要以ROLE_开头,这一点与@Secured不同

@PostAuthorize在实际中使用的较少,一般使用@PreAuthorize在用户访问前进行权限校验即可

16. RememberMe功能实现

Spring Security 中Remember Me 为 “记住我” 功能,用户只需要在登录时添加 remember-me 复选框,取值为 true。Spring Security 会自动把用户信息存储到数据源中,以后就可以不登录进行访问。

1. 添加依赖

Spring Security 实现 Remember Me 功能时底层实现依赖 Spring-JDBC,所以需要导入 Spring-JDBC。以后多使用 MyBatis 框架而很少直接导入 Spring-JDBC,所以此处导入 MyBatis 启动器同时还需要添加 MySQL 驱动

<!--MyBatis-->
<dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    <version>2.2.2</version>
</dependency>
<!--MySQL驱动-->
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
</dependency>

2.在配置文件中设置数据库相关配置信息

spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/security?useUnicode=true&serverTimezone=Asia/Shanghai&characterEncoding=utf8&autoReconnect=true&useSSL=false&allowMultiQueries=true&useAffectedRows=true
spring.datasource.username=root
spring.datasource.password=root

3.修改login.html文件

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>登录</title>
</head>
<body>
    <form action="/login" method="post">
        用户名:<input type="text" name="username123" /> <br/>
        密码:<input type="password" name="password123" /> <br/>
        记住我:<input type="checkbox" name="remember-me" value="true"/> <br/>
        <input type="submit" value="登录">
    </form>
</body>
</html>

注意:记住我这个复选框的name必须为 remember-me (默认)或者在SecurityConfig的配置类中进行配置自定义的参数名

4.修改SecurityConfig配置类

import com.gzb.handler.MyAccessDeniedHandler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl;
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;

import javax.sql.DataSource;

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Autowired
    private MyAccessDeniedHandler myAccessDeniedHandler;

    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    private PersistentTokenRepository persistentTokenRepository;

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        // 记住我(新增的一段配置)
        http.rememberMe()
                // 失效时间,单位秒(默认失效时间为两周)
                .tokenValiditySeconds(60)
                // 复选框参数名称,默认为 remember-me
                //.rememberMeParameter()
                .userDetailsService(userDetailsService)
                // 持久层对象
                .tokenRepository(persistentTokenRepository);

    }


    // 向容器中注册一个PersistentTokenRepository对象(Remember Me功能需要)
    @Bean
    public PersistentTokenRepository persistentTokenRepository(DataSource dataSource) {
        JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
        jdbcTokenRepository.setDataSource(dataSource);
        // 自动建表,第一次启动时需要,第二次启动注释掉,否则会报错
        jdbcTokenRepository.setCreateTableOnStartup(true);
        return jdbcTokenRepository;
    }

}

17. 退出登录

修改SecurityConfig配置类

// 退出登录
http.logout()
        // 退出登录跳转页面
        .logoutSuccessUrl("/login.html");

logoutSuccessUrl("/login.html")设置退出登录成功后的跳转页面

18. CSRF

在刚开始学习Spring Security时,在配置类中一直存在这样一行代码 http.csrf().disable如果没有这行代码将导致用户无法被认证。这行代码的含义是:关闭 csrf 防护

什么是CSRF

CSRF(Cross-site request forgery) 跨站请求伪造,也被称为 “OneClick Attack” 或者 Session Riding。通过伪造用户请求访问受信任站点的非法请求访问。

跨域:只要网络协议、IP地址、端口号中任何一个不相同就是跨域请求。

客户端与服务进行交互时,由于 HTTP 协议本身是无状态的协议,所以引入了 cookie 进行记录客户端身份。在 cookie 中会存放 session id 用来标识客户端身份的。在跨域的情况下,session id 可能被第三方恶意劫持,通过这个 session id 向服务端发起请求时,服务端会认为这个请求是合法的,可能发生很多意想不到的事情。

Spring Security中的CSRF

从Spring Security4开始 CSRF 防护默认开启。默认会拦截请求进行CSRF处理。CSRF为了保证不是其他第三方网站访问,要求访问时携带参数名为 _csrf 值为 token(token在服务端产生)的内容,如果 token 和服务端的 token 匹配成功,则正常访问。

二、OAuth2协议

1. 简介

第三方认证方案最主要是解决认证协议的通用标准问题,因为要实现跨系统认证,各系统之间要遵循一定的接口协议。

OAuth协议为用户资源的授权提供了一个安全的、开放而简易的标准。同时,任何第三方都可以使用 OAuth 认证服务,任何服务提供商都可以实现自身的 OAuth 认证服务。因而 OAuth 是开放的。业界提供了 OAuth 的多种实现比如 PHP、JavaScript、Java、Ruby等各种语言开发包,大大节约了程序员的时间,因而 OAuth 是简易的。互联网很多服务如Open API,很多大公司比如Google、Yahoo、Microsoft等都提供了 OAuth 认证服务,这些都足以说明 OAuth 标准逐渐成为开放资源授权的标准。

OAuth 协议目前发展到2.0版本,1.0版本过于复杂,2.0版本已经得到广泛应用。

OAuth2.0认证流程:

a

角色:

在OAuth 2.0协议中,有一些关键的角色,这些角色在不同的授权流程中扮演不同的角色。以下是OAuth 2.0协议中的一些主要角色:

  1. 资源所有者(Resource Owner):资源所有者是能够授权访问其受保护资源的实体,通常是最终用户。
  2. 客户端(Client):客户端是请求访问受保护资源的应用程序。它可能是一个Web应用、移动应用、桌面应用或后端服务等。
  3. 授权服务器(Authorization Server):授权服务器是负责验证资源所有者、处理客户端的授权请求并颁发访问令牌的服务器。在OAuth 2.0中,授权服务器通常与身份验证服务器合并,但它们的职责是不同的。
  4. 资源服务器(Resource Server):资源服务器托管受保护的资源,它是需要访问令牌以验证访问权限的实际资源。

常用术语:

  • 客户凭证(client credentials):客户端的clientid和密码用于认证用户
  • 令牌(token):授权服务器在接收客户端请求后,颁发的访问令牌
  • 作用域(scope):客户请求访问令牌,由资源拥有者额外指定的细分权限(permission)

令牌类型:

  • 授权码:仅用于授权码授权类型,用于交换获取访问令牌和刷新令牌
  • 访问令牌:用于代表一个用户或服务直接去访问受保护的资源
  • 刷新令牌:用于去授权服务器获取一个刷新访问令牌
  • BearerToken:不管谁拿到Token都可以访问资源,类似现金
  • Proof of Possession(PoP):可以校验 client 是否对 Token 有明确的拥有权

特点:

  1. 优点:
    • 更安全,客户端不接触用户密码,服务端更易于集中保护
    • 广泛应用并被持续采用
    • 短寿命和封装的token
    • 资源服务器和授权服务器解耦
    • 集中式授权,简化客户端
    • HTTP/JSON友好,易于请求和传递token
    • 考虑多种客户端架构场景
    • 客户可以具有不同的信任级别
  2. 缺点:
    • 协议框架太宽泛,造成各种实现的兼容性和互操作性差
    • 不是一个认证协议,本身并不能告诉你任何用户信息

2. 授权模式

OAuth 2.0(开放授权)是一种用于授权的开放标准,用于通过第三方应用程序访问用户的资源,而无需将用户的凭据传递给第三方应用程序。OAuth 2.0 定义了四种授权模式,每种模式都适用于不同的使用场景。这四种模式分别是:

授权码模式(Authorization Code Grant):

  • 流程:

    1. 用户访问客户端,客户端将用户重定向到授权服务器。
    2. 用户在授权服务器上进行身份验证,并授予客户端访问权限。
    3. 授权服务器将用户重定向回客户端,同时提供一个授权码。
    4. 客户端使用授权码向授权服务器请求访问令牌。
    5. 授权服务器验证授权码,并向客户端提供访问令牌。
  • 特点:

    • 客户端从授权服务器获取的是授权码,而不是直接的访问令牌。
    • 隐藏了用户的凭证,增强了安全性。
    • 适用于客户端能够安全地保护授权码的情况,例如,服务器端应用。

简化模式(Implicit Grant):

  • 流程:

    1. 用户访问客户端,客户端将用户重定向到授权服务器。
    2. 用户在授权服务器上进行身份验证,并授予客户端访问权限。
    3. 授权服务器将用户重定向回客户端,同时提供访问令牌。
    4. 客户端直接使用提供的访问令牌,没有授权码的中间步骤。
  • 特点:

    • 客户端直接获得访问令牌,减少了一次请求。
    • 适用于移动端或浏览器端的应用,其中存储和保护授权码较为困难。

密码模式(Resource Owner Password Credentials Grant):

  • 流程:

    1. 用户向客户端提供用户名和密码。
    2. 客户端使用用户提供的用户名和密码直接向授权服务器请求访问令牌。
    3. 授权服务器验证用户名和密码,然后提供访问令牌。
  • 特点:

    • 用户的凭证直接传递给客户端,因此要确保客户端的安全性。
    • 不推荐在不可信的客户端使用,更适用于受信任的应用,例如,移动应用内的资源所有者身份验证。

客户端模式(Client Credentials Grant):

  • 流程:

    1. 客户端直接向授权服务器请求访问令牌,不涉及用户身份验证。
    2. 授权服务器验证客户端的身份,并提供访问令牌。
  • 特点:

    • 适用于客户端自身需要访问资源,而不是代表用户访问资源的情况,如后台服务间的通信。
    • 不涉及用户,直接通过客户端的身份进行认证。

选择使用哪种授权模式取决于应用的类型、安全需求以及使用场景。不同的模式有各自的优劣和适用范围。

3. 授权码模式

引入依赖

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.3.7.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.gzb</groupId>
    <artifactId>spring-security-OAuth2-demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>

    <properties>
        <java.version>1.8</java.version>
        <spring-cloud.version>Greenwich.SR2</spring-cloud.version>
    </properties>
    <dependencies>
        <!--OAuth2.0-->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-OAuth2</artifactId>
        </dependency>
        <!--Spring Security-->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-security</artifactId>
        </dependency>
        <!--Web-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <image>
                        <builder>paketobuildpacks/builder-jammy-base:latest</builder>
                    </image>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

授权服务器

/**
 * 授权服务器
 */
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
    @Autowired
    private PasswordEncoder passwordEncoder;
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
                // 配置client-id
                .withClient("admin")
                // 配置client-secret
                .secret(passwordEncoder.encode("abcd"))
                // 配置token有效期(单位秒)
                .accessTokenValiditySeconds(3600)
                // 配置 redirect-uri, 用于授权成功后跳转
                .redirectUris("https://www.baidu.com")
                // 配置申请的权限范围
                .scopes("all")
                // 配置grant_type,表示授权类型
                .authorizedGrantTypes("authorization_code");
    }
}

资源服务器

/**
 * 资源服务器
 */
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .anyRequest()
                .authenticated()
                .and()
                .requestMatchers()
                .antMatchers("/user/**");
    }
}

Spring Security 配置类

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable();
        http.authorizeRequests()
                .antMatchers("/OAuth/**", "/login/**", "/logout/**")
                .permitAll()
                .anyRequest()
                .authenticated();
        http.formLogin()
                .permitAll();
    }
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }   
}

其他

自定义UserDetails

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;

public class User implements UserDetails {
    private final String username;
    private final String password;
    private Collection<? extends GrantedAuthority> authorities;

    public User(String username, String password, Collection<? extends GrantedAuthority> authorities) {
        this.username = username;
        this.password = password;
        this.authorities = authorities;
    }

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

    @Override
    public String getPassword() {
        return password;
    }

    @Override
    public String getUsername() {
        return username;
    }

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

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

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

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

自定义UserDetailsService

import com.gzb.pojo.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

@Service
public class UserService implements UserDetailsService {
    @Autowired
    private PasswordEncoder passwordEncoder;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        System.out.println("用户登录了");
        String password = passwordEncoder.encode("admin");
        return new User("admin", password, AuthorityUtils.commaSeparatedStringTOAuthorityList(
                "admin,ROLE_abc"));
    }
}

UserController

import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/user")
public class UserController {
    /**
     * 获取当前登录用户
     * @param authentication
     * @return
     */
    @RequestMapping("/getCurrentUser")
    public Object getCurrentUser(Authentication authentication) {
        return authentication.getPrincipal();
    }
}

4. 测试授权码模式

1.在浏览器输入URL:

http://localhost:8080/OAuth/authorize?response_type=code&client_id=admin&redirect_uri=https://www.baidu.com&scope=all

2.输入写死的用户名和密码

a

3.选择Approve并点击Authorize

a

4.授权码位于url中

a

5.向http://localhost:8080/OAuth/token发送Post请求获取token

填写写死在内存中的客户端用户名和密码

a

请求体中填写对应的请求参数

a

请求发送成功后即可拿到token

a

{
    "access_token": "908c833b-88ad-4f51-83eb-73f4c0c88893",
    "token_type": "bearer",
    "expires_in": 3599,
    "scope": "all"
}

6.使用获取到的token访问资源

http://localhost:8080/user/getCurrentUser

填写之前获取到的token

a

即可得到正确的响应结果

{
    "username": "admin",
    "password": "$2a$10$h42.9mcewbQXjggYNjjBU.wTUzYwiWnpZXijB0xVSvfSN3WGr/lFi",
    "authorities": [
        {
            "authority": "admin"
        },
        {
            "authority": "ROLE_abc"
        }
    ],
    "enabled": true,
    "credentialsNonExpired": true,
    "accountNonExpired": true,
    "accountNonLocked": true
}

5. 密码模式

授权服务器

/**
 * 授权服务器
 */
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
    @Autowired
    private PasswordEncoder passwordEncoder;

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private UserDetailsService userDetailsService;

    /**
     * 密码模式所需配置
     * @param endpoints
     * @throws Exception
     */
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.authenticationManager(authenticationManager)
                .userDetailsService(userDetailsService);
    }

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
                // 配置client-id
                .withClient("admin")
                // 配置client-secret
                .secret(passwordEncoder.encode("abcd"))
                // 配置token有效期(单位秒)
                .accessTokenValiditySeconds(3600)
                // 配置 redirect-uri, 用于授权成功后跳转
                .redirectUris("https://www.baidu.com")
                // 配置申请的权限范围
                .scopes("all")
                // 配置grant_type,表示授权类型
//                .authorizedGrantTypes("authorization_code");
                // 密码模式
                .authorizedGrantTypes("password");
    }
}

Spring Security 配置类

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    // 向 Spring 容器中注入AuthenticationManager
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable();

        http.authorizeRequests()
                .antMatchers("/OAuth/**", "/login/**", "/logout/**")
                .permitAll()
                .anyRequest()
                .authenticated();

        http.formLogin()
                .permitAll();
    }

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

6. 测试密码模式

1.填写客户端的用户名和密码

a

2.填写用户自定义逻辑的用户名和密码

a

3.发送请求获取token

{
    "access_token": "b44d8c9a-c556-46ab-af23-95380a3ade9c",
    "token_type": "bearer",
    "expires_in": 3599,
    "scope": "all"
}

4.使用token去访问定义的接口http://localhost:8080/user/getCurrentUser得到响应结果

{
    "username": "admin",
    "password": "$2a$10$JEfAPHL/WwRUoacX6tsDre0gFwuIa3x5BAx6zLYty1pyoJMHBkKe6",
    "authorities": [
        {
            "authority": "admin"
        },
        {
            "authority": "ROLE_abc"
        }
    ],
    "enabled": true,
    "accountNonExpired": true,
    "credentialsNonExpired": true,
    "accountNonLocked": true
}

7. 通过 Redis 存储 token令牌

引入依赖

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

向容器中注入RedisTokenStore对象

@Configuration
public class RedisConfig {
    @Bean
    public TokenStore tokenStore(RedisConnectionFactory redisConnectionFactory) {
        return new RedisTokenStore(redisConnectionFactory);
    }
}

授权服务器中使用RedisTokenStore

/**
 * 授权服务器
 */
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
    @Autowired
    private PasswordEncoder passwordEncoder;

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    private TokenStore tokenStore;

    /**
     * 密码模式所需配置
     * @param endpoints
     * @throws Exception
     */
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.authenticationManager(authenticationManager)
                .userDetailsService(userDetailsService)
                // 使用redis存储token
                .tokenStore(tokenStore);
    }

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
                // 配置client-id
                .withClient("admin")
                // 配置client-secret
                .secret(passwordEncoder.encode("abcd"))
                // 配置token有效期(单位秒)
                .accessTokenValiditySeconds(3600)
                // 配置 redirect-uri, 用于授权成功后跳转
                .redirectUris("https://www.baidu.com")
                // 配置申请的权限范围
                .scopes("all")
                // 配置grant_type,表示授权类型
//                .authorizedGrantTypes("authorization_code");
                // 密码模式
                .authorizedGrantTypes("password");
    }
}

配置完成后,使用密码模式获取到的 token 令牌会存储在 Redis 中

a

三、JWT

1. 常见的认证机制

HTTP Basic Auth

HTTP Basic Auth简单点说明就是每次请求API时都提供用户的username和password,简言之,Basic Auth是配合RESTfulAPI 使用的最简单的认证方式,只需提供用户名密码即可,但由于有把用户名密码暴露给第三方客户端的风险,在生产环境下被使用的越来越少。因此,在开发对外开放的RESTful APl时,尽量避免采用HTTP Basic Auth.

Cookie Auth

Cookie认证机制就是为一次请求认证在服务端创建一个Session对象,同时在客户端的浏览器端创建了一个Cookie对象;通过客户端带上来Cookie对象来与服务器端的session对象匹配来实现状态管理的。默认的,当我们关闭浏览器的时候,cookie会被删除。但可以通过修改cookie 的expire time使cookie在一定时间内有效。

OAuth

OAuth (开放授权,Open Authorization) 是一个开放的授权标准,允许用户让第三方应用访问该用户在某一web服务上存储的私密的资源(如照片,视频,联系人列表),而无需将用户名和密码提供给第三方应用。如网站通过微信、微博登录等,主要用于第三方登录。
OAuth允许用户提供一个令牌,而不是用户名和密码来访问他们存放在特定服务提供者的数据。每一个令牌授权个特定的第三方系统(例如,视频编辑网站)在特定的时段(例如,接下来的2小时内内访问特定的资源(例如仅仅是某一相册中的视频) 。这样,OAuth让用户可以授权第三方网站访问他们存储在另外服务提供者的某些特定信息,而非所有内容。

Token Auth

使用基于 Token 的身份验证方法,在服务端不需要存储用户的登录记录。大概的流程是这样的:

  1. 客户端使用用户名跟密码请求登录
  2. 服务端收到请求,去验证用户名与密码
  3. 验证成功后,服务端会签发一个 Token,再把这个 Token 发送给客户端
  4. 客户端收到 Token 以后可以把它存储起来,比如放在 Cookie 里
  5. 客户端每次向服务端请求资源的时候需要带着服务端签发的 Token
  6. 服务端收到请求,然后去验证客户端请求里面带着的 Token,如果验证成功,就向客户端返回请求的数据

比第一种方式更安全,比第二种方式更节约服务器资源,比第三种方式更加轻量。
具体,Token Auth的优点 (Token机制相对于Cookie机制又有什么好处呢? ) :

  1. 支持跨域访问: Cookie是不允许垮域访问的,这一点对Token机制是不存在的,前提是传输的用户认证信息通过HTTP头传输.
  2. 无状态(也称:服务端可扩展行):Token机制在服务端不需要存储session信息,因为Token 自身包含了所有登录用户的信息,只需要在客户端的cookie或本地介质存储状态信息.
  3. 更适用CDN:可以通过内容分发网络请求你服务端的所有资料(如: javasript,HTML,图片等),而你的服务端只要提供API即可.
  4. 去耦:不需要绑定到一个特定的身份验证方案。Token可以在任何地方生成,只要在你的API被调用的时候,你可以进行Token生成调用即可
  5. 更话用于移动应用: 当你的客户端是一个原生平台 (iOS,Android,Windows 10等)时,Cookie是不被支持的 (你需要通过Cookie容器进行处理) ,这时采用Token认证机制就会简单得多。
  6. CSRF:因为不再依赖于Cookie,所以你就不需要考虑对CSRF (跨站请求伪造) 的防范。
  7. 性能: 一次网络往返时间(通过数据库查询session信息) 总比做一次HMACSHA256计算的Token验证和解析要费时得多.
  8. 不需要为登录页面做特殊处理: 如果你使用Protractor 做功能测试的时候,不再需要为登录页面做特殊处理
  9. 基于标准化:你的API可以采用标准化的 JSON Web Token (JWT).这个标准已经存在多个后端库 (.NET, RubyJava,Python,PHP) 和多家公司的支持 (如: Firebase,Google, Microsoft).

2. JWT 简介

JWT代表JSON Web Token。它是一种开放标准(RFC 7519),用于在网络应用和服务之间传递声明(Claims)的紧凑、自包含的方式。通常用于身份验证和信息交换。

结构:

JWT由三部分组成,用点号分隔开来:

  1. Header(头部):包含了令牌的元数据和加密算法,通常包括两部分:token 类型和所使用的加密算法。

  2. Payload(负载):也称为Claims,包含了有关身份信息的声明,比如用户ID、权限等。Claims分为三种类型:注册声明、公开声明和私有声明。注册声明包含一些预定义的声明,而公开声明和私有声明是用户自定义的声明。

  3. Signature(签名):由前两部分经过加密算法加密后的字符串,用于验证令牌的真实性和完整性。通常是使用base64编码的Header和Payload通过一定算法(如HMAC、RSA等)计算出来的哈希值。

工作原理:

当用户通过认证登录后,服务器会生成一个JWT并将其发送给客户端,客户端将JWT保存(通常是在本地存储或者在HTTP请求头中发送),并在后续的请求中发送该令牌给服务器。服务器通过解析JWT来验证用户的身份和权限,而无需再次查询数据库。

优点:

  • 轻量级和自包含:JWT本身携带了所有认证和授权所需的信息,无需在服务器存储会话信息。
  • 跨平台和跨语言:由于基于标准化的JSON格式,JWT在不同系统间易于传递和解析。
  • 安全性:通过签名验证JWT的真实性和完整性,确保数据不被篡改。

注意事项:

  • 不适合存储敏感信息:尽管JWT的Payload可以加密,但最好不要在其中存储敏感信息,因为Payload是Base64编码的,可以被解码。

3. JWT的生成与解析

引入依赖

<!--jjwt-->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.0</version>
</dependency>

生成token

@Test
void generateToken() {
    // 当前系统时间
    long now = System.currentTimeMillis();
    // 过期时间 1 分钟
    long exp = now + 60 * 1000;
    // 创建JwtBuilder对象
    JwtBuilder jwtBuilder = Jwts.builder()
            // 声明的标识{"jti":"8888"}
            .setId("8888")
            // 主体,用户{"sub":"Rose"}
            .setSubject("Rose")
            // 创建日期{"ita":"xxx"}
            .setIssuedAt(new Date())
        	// 设置过期时间
            .setExpiration(new Date(exp))
            .signWith(SignatureAlgorithm.HS256, "abc123")
            // 自定义申明
            .claim("roles", "admin")
            .claim("logo", "xxx.jpg");
    // 获取jwt的token
    String token = jwtBuilder.compact();
    System.out.println(token);
    System.out.println("==========================");
    String[] split = token.split("\\.");
    System.out.println(Base64Codec.BASE64.decodeToString(split[0]));
    System.out.println(Base64Codec.BASE64.decodeToString(split[1]));
    // 无法解密
    System.out.println(Base64Codec.BASE64.decodeToString(split[2]));
}

解析token

@Test
void parseJwt() {
    String token = "eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiI4ODg4Iiwic3ViIjoiUm9zZSIsImlhdCI6MTcwNTQxMzI3OSwiZXhwIjoxNzA1NDEzMzM5LCJyb2xlcyI6ImFkbWluIiwibG9nbyI6Inh4eC5qcGcifQ.NAyowr8zImnDF2Xj9Xs5SOEy1sTbQJsaJhmaUAyKtOE";
    // 解析token获取负载中声明的对象
    Claims claims = Jwts.parser()
            .setSigningKey("abc123")
            .parseClaimsJws(token)
            .getBody();
    System.out.println("id = " + claims.getId());
    System.out.println("subject = " + claims.getSubject());
    System.out.println("issueAt = " + claims.getIssuedAt());
    SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
    System.out.println("签发时间 = " + sdf.format(claims.getIssuedAt()));
    System.out.println("过期时间 = " + sdf.format(claims.getExpiration()));
    System.out.println("当前时间 = " + sdf.format(new Date()));
    System.out.println("==========自定义申明============");
    System.out.println("roles = " + claims.get("roles"));
    System.out.println("logo = " + claims.get("logo"));

}

输出结果

id = 8888
subject = Rose
issueAt = Tue Jan 16 21:54:39 CST 2024
签发时间 = 2024-01-16 21:54:39
过期时间 = 2024-01-16 21:55:39
当前时间 = 2024-01-16 21:54:59
==========自定义申明============
roles = admin
logo = xxx.jpg

4. Spring Security OAuth2 整合 JWT

引入依赖

<!--jjwt-->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.0</version>
</dependency>

向容器中注入JwtTokenStore

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.JwtTokenStore;

@Configuration
public class JwtTokenStoreConfig {
    @Bean
    public TokenStore tokenStore() {
        return new JwtTokenStore(jwtAccessTokenConverter());
    }

    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter() {
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        // 配置生成jwt所需的秘钥
        converter.setSigningKey("test_key");
        return converter;
    }
}

修改授权服务器的配置

/**
 * 授权服务器
 */
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
    @Autowired
    private PasswordEncoder passwordEncoder;

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    private TokenStore tokenStore;

    @Autowired
    private JwtAccessTokenConverter jwtAccessTokenConverter;


    /**
     * 密码模式所需配置
     * @param endpoints
     * @throws Exception
     */
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.authenticationManager(authenticationManager)
                .userDetailsService(userDetailsService)
                // 使用jwt存储token的策略
                .tokenStore(tokenStore)
                .accessTokenConverter(jwtAccessTokenConverter);
    }

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
                // 配置client-id
                .withClient("admin")
                // 配置client-secret
                .secret(passwordEncoder.encode("abcd"))
                // 配置token有效期(单位秒)
                .accessTokenValiditySeconds(3600)
                // 配置 redirect-uri, 用于授权成功后跳转
                .redirectUris("https://www.baidu.com")
                // 配置申请的权限范围
                .scopes("all")
                // 配置grant_type,表示授权类型
//                .authorizedGrantTypes("authorization_code");
                // 密码模式
                .authorizedGrantTypes("password");
    }
}

使用密码模式获取token令牌得到token令牌

{
    "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MDU0MTc5NDAsInVzZXJfbmFtZSI6ImFkbWluIiwiYXV0aG9yaXRpZXMiOlsiUk9MRV9hYmMiLCJhZG1pbiJdLCJqdGkiOiIxNGZjOWQxYi04ZjU3LTQwMzUtODAyMC0wYWRiMDhhNTMxN2YiLCJjbGllbnRfaWQiOiJhZG1pbiIsInNjb3BlIjpbImFsbCJdfQ.eYyDTNYrttwg392j9_GbaUpJdX_ckdhIfBd0wW6BME0",
    "token_type": "bearer",
    "expires_in": 3599,
    "scope": "all",
    "jti": "14fc9d1b-8f57-4035-8020-0adb08a5317f"
}

在 jwt.io 网站上输入秘钥 test_key 得到

{
  "exp": 1705417940,
  "user_name": "admin",
  "authorities": [
    "ROLE_abc",
    "admin"
  ],
  "jti": "14fc9d1b-8f57-4035-8020-0adb08a5317f",
  "client_id": "admin",
  "scope": [
    "all"
  ]
}

5. JWT 内容增强

自定义TokenEnhancer

import org.springframework.security.oauth2.common.DefaultOAuth2AccessToken;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.security.oauth2.provider.token.TokenEnhancer;
import org.springframework.stereotype.Component;

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

@Component
public class JwtTokenEnhancer implements TokenEnhancer {
    @Override
    public OAuth2AccessToken enhance(OAuth2AccessToken oAuth2AccessToken, OAuth2Authentication oAuth2Authentication) {
        Map<String, Object> info = new HashMap<>();
        info.put("enhanceKey", "增强内容");
        ((DefaultOAuth2AccessToken)oAuth2AccessToken).setAdditionalInformation(info);
        return oAuth2AccessToken;
    }
}

修改授权服务器配置

/**
 * 授权服务器
 */
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
    @Autowired
    private PasswordEncoder passwordEncoder;

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    private TokenStore tokenStore;

    @Autowired
    private JwtAccessTokenConverter jwtAccessTokenConverter;

    @Autowired
    private JwtTokenEnhancer jwtTokenEnhancer;

    /**
     * 密码模式所需配置
     * @param endpoints
     * @throws Exception
     */
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        // 配置JWT内容增强器
        TokenEnhancerChain chain = new TokenEnhancerChain();
        List<TokenEnhancer> enhancers = new ArrayList<>();
        enhancers.add(jwtTokenEnhancer);
        enhancers.add(jwtAccessTokenConverter);
        chain.setTokenEnhancers(enhancers);

        endpoints.authenticationManager(authenticationManager)
                .userDetailsService(userDetailsService)
                // 存储存储token策略
                .tokenStore(tokenStore)
                .accessTokenConverter(jwtAccessTokenConverter)
                // token增强
                .tokenEnhancer(chain);
    }

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
                // 配置client-id
                .withClient("admin")
                // 配置client-secret
                .secret(passwordEncoder.encode("abcd"))
                // 配置token有效期(单位秒)
                .accessTokenValiditySeconds(3600)
                // 配置 redirect-uri, 用于授权成功后跳转
                .redirectUris("https://www.baidu.com")
                // 配置申请的权限范围
                .scopes("all")
                // 配置grant_type,表示授权类型
//                .authorizedGrantTypes("authorization_code");
                // 密码模式
                .authorizedGrantTypes("password");
    }
}

使用密码模式获取token令牌得到token令牌

{
    "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJhZG1pbiIsInNjb3BlIjpbImFsbCJdLCJleHAiOjE3MDU0MTg3OTYsImF1dGhvcml0aWVzIjpbIlJPTEVfYWJjIiwiYWRtaW4iXSwianRpIjoiMDMwNTk0ZTctMjc2Mi00Njk3LWI4OTQtOGNhN2NlNTA5YWNlIiwiZW5oYW5jZUtleSI6IuWinuW8uuWGheWuuSIsImNsaWVudF9pZCI6ImFkbWluIn0.zujkBqVgdl_gp0u0nxXa14o0ZTLGqSAtN2a6zIwvsKk",
    "token_type": "bearer",
    "expires_in": 3599,
    "scope": "all",
    "enhanceKey": "增强内容",
    "jti": "030594e7-2762-4697-b894-8ca7ce509ace"
}

在 jwt.io 网站上输入秘钥 test_key 得到

{
  "user_name": "admin",
  "scope": [
    "all"
  ],
  "exp": 1705418796,
  "authorities": [
    "ROLE_abc",
    "admin"
  ],
  "jti": "030594e7-2762-4697-b894-8ca7ce509ace",
  "enhanceKey": "增强内容",
  "client_id": "admin"
}

6. 使用jjwt解析 JWT 中的内容

/**
 * 获取当前登录用户
 */
@RequestMapping("/getCurrentUser")
public Object getCurrentUser(HttpServletRequest request) {
    String header = request.getHeader("Authorization");
    String token = header.substring(header.indexOf("bearer") + 7);
    return Jwts.parser()
            .setSigningKey("test_key".getBytes(StandardCharsets.UTF_8))
            .parseClaimsJws(token)
            .getBody();
}

先使用密码模式请求接口获取令牌

{
    "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJhZG1pbiIsInNjb3BlIjpbImFsbCJdLCJleHAiOjE3MDU0MTkzNjgsImF1dGhvcml0aWVzIjpbIlJPTEVfYWJjIiwiYWRtaW4iXSwianRpIjoiNGU3YTg2MjQtMzk0Mi00ZTI1LWE4YTEtNDMxYWIyNzE0Mzg2IiwiZW5oYW5jZUtleSI6IuWinuW8uuWGheWuuSIsImNsaWVudF9pZCI6ImFkbWluIn0.qNF-Fz6WZk5PaiIeqVqDbtbPQXZD1s-7299poLOU548",
    "token_type": "bearer",
    "expires_in": 3599,
    "scope": "all",
    "enhanceKey": "增强内容",
    "jti": "4e7a8624-3942-4e25-a8a1-431ab2714386"
}

使用令牌去访问 http://localhost:8080/user/getCurrentUser

a

注意 请求头中 Authorization的value值必须为bearer + 空格 + Token令牌

最后得到结果

{
    "user_name": "admin",
    "scope": [
        "all"
    ],
    "exp": 1705419368,
    "authorities": [
        "ROLE_abc",
        "admin"
    ],
    "jti": "4e7a8624-3942-4e25-a8a1-431ab2714386",
    "enhanceKey": "增强内容",
    "client_id": "admin"
}
  • 10
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值