不是水文!没有人这样教过 Spring Security 和 OAuth 2.0

愿天堂没有BUG

共 5432字,需浏览 11分钟

 ·

2022-01-13 16:35

在学习定制 Spring Security的过程中,阅读了 Spring Security 的官方文档、《Spring Security in action》和《OAuth 2 in action》后,并结合源码摸清了 Spring Security 的工作流程,把这些知识梳理成了图片和文字,花了我足足一个月,真没想到 Spring Security 也有这么多内容。

🌊0 概览

0.1 流程分析

0.1.1 UserPasswordAuthentication 流程

假设要访问的接口为 /private,登录接口为 /login,登录页面为 login.html

  1. 首先,用户向其未授权的资源 /private 发出未经身份验证的请求

  2. 当用户提交他们的用户名和密码时,UsernamePasswordAuthenticationFilter 通过从 HttpServletRequest 中提取用户名和密码来创建一个 UsernamePasswordAuthenticationToken

  3. 接下来,将 UsernamePasswordAuthenticationToken 传入 AuthenticationManager 进行身份验证

  4. 给 ProviderManager 配置 AuthenticationProvider 的子类 DaoAuthenticationProvider

  5. DaoAuthenticationProvider 通过 UserDetailsService 的子类 JDBCUserDetailManager 中查找 UserDetails

  6. JDBCUserDetailManager 在数据库中查找用户并返回其用户信息

  7. 如果找到用户,PasswordEncoder 会比对用户请求发来的用户信息和从数据库读取的用户信息,若验证成功到第 8 步验证失败到第 10 步

  8. 用户信息会被放进 UsernamePasswordAuthenticationToken 并返回,继续到第 9 步

  9. 将 UsernamePasswordAuthenticationToken 放进 SecurityContextHolder

  10. 通过抛出 AccessDeniedException 指示未经身份验证的请求被拒绝,继续到第 11 步

  11. 使用配置的 AuthenticationEntryPoint 将重定向发送到登录页面 Location:/login,继续到第 12 步

  12. 浏览器将请求它被重定向到的登录页面 GET /login,继续到第 13 步

  13. 返回登录页面 login.html

0.1.2 Basic HTTP Authentication 流程

  1. 首先,用户向其未授权的资源 /private 发出未经身份验证的请求

  2. 当用户提交他们的用户名和密码时,BasicAuthenticationFilter 通过从 HttpServletRequest 中提取用户名和密码来创建一个 UsernamePasswordAuthenticationToken

  3. 接下来,将 UsernamePasswordAuthenticationToken 传入 AuthenticationManager 进行身份验证,身份验证过程和 UserPasswordAuthentication 的类似。若验证成功则到第 4 步若验证失败则到第 6 步

  4. 将 Authentication 存到 SecurityContextHolder,继续到第 5 步

  5. 调用 RememberMeServices.loginSuccess ,如果 remember me 没有配置,则是一个空操作;BasicAuthenticationFilter 调用 FilterChain.doFilter(request,response) 以继续其余的应用程序逻辑

  6. 清空 SecurityContextHolder;调用 RememberMeServices.loginFail,如果 remember me 没有配置,则是一个空操作;FilterSecurityInterceptor 通过抛出 AccessDeniedException 指示未经身份验证的请求被拒绝,继续到第 7 步

  7. AuthenticationEntryPoint 被调用以触发 WWW-Authenticate 被再次发送

0.1.3 授权流程

  1. FilterSecurityInterceptor 从 SecurityContextHolder 获取 Authentication

  2. FilterSecurityInterceptor 将接收到的 HttpServletRequest、HttpServletResponse 和 FilterChain 创建一个 FilterInvocation

  3. FilterSecurityInterceptor 将 FilterInvocation 传递给 SecurityMetadataSource 以获取多个 ConfigAttribute

  4. FilterSecurityInterceptor 将 Authentication、FilterInvocation 和 ConfigAttributes 传递给 AccessDecisionManager,如果访问已经授权,FilterSecurityInterceptor 继续执行 FilterChain,否则继续到第 5 步

  5. 抛出 AccessDeniedException

0.1.4 OAuth2 流程

Resource Server (使用 JWT)

  1. 用户向其未授权的资源 /private 发出未经身份验证的请求

  2. 当用户提交 bearer token 时,BearerTokenAuthenticationFilter 通过从 HttpServletRequest 中提取令牌来创建一个 BearerTokenAuthenticationToken

  3. HttpServletRequest 传递给 AuthenticationManagerResolver,后者选择 AuthenticationManager,BearerTokenAuthenticationToken 传入 AuthenticationManager 进行认证

  4. ProviderManager 被配置去使用 AuthenticationProvider 的子类 JwtAuthenticationProvider

  5. JwtAuthenticationProvider 使用 JwtDecoder 解码、校验 Jwt

  6. JwtAuthenticationProvider 使用 JwtAuthenticationConverter 将 Jwt 转换为 GrantedAuthority 的集合,若验证成功则到第 7 步若验证失败则到第 9 步

  7. 返回的 Authentication 是 JwtAuthenticationToken,并且有一个主体,它是由配置的 JwtDecoder 返回的 Jwt,继续到第 8 步

  8. 返回的 JwtAuthenticationToken 将被放到 SecurityContextHolder,BearerTokenAuthenticationFilter 调用 FilterChain.doFilter(request,response) 以继续其余的应用程序逻辑

  9. FilterSecurityInterceptor 通过抛出 AccessDeniedException 表示未经身份验证的请求被拒绝,继续到第 10 步

  10. SecurityContextHolder 被清空,调用 AuthenticationEntryPoint 触发 WWW-Authenticate 头再次发送

Client

重定向
  1. resource owner 在浏览器中发出请求 GET / oauth2 / authorization / {registrationId}

  2. OAuth2AuthorizationRequestRedirectFilter 将 registrationId 作为参数传入并调用 ClientRegistrationRepository 接口的findByRegistrationId() 方法,findByRegistrationId() 返回 ClientRegistration

  3. OAuth2AuthorizationRequestRedirectFilter 根据 ClientRegistration 生成 OAuth2AuthorizationRequest,并调用 AuthorizationRequestRepository 的 saveAuthorizationRequest() 方法跨会话共享 OAuth2AuthorizationRequest

  4. OAuth2AuthorizationRequestRedirectFilter 从 ClientRegistration 生成一个 URL 发送到 Authorization Server 的 Authorization 端点,Authorization Server 返回登录页面

预认证处理
  1. resource owner 在登陆页面提交用户信息并发送登录请求

  2. OAuth2LoginAuthenticationFilter 从请求中分析来自 Authorization Server 的授权响应,并生成 OAuth2AuthorizationResponse

  3. OAuth2LoginAuthenticationFilter 调用 AuthorizationRequestRepository 接口的 loadAuthorizationRequest() 方法来获得 OAuth2AuthorizationRequest

  4. OAuth2LoginAuthenticationFilter 将 registrationId 作为参数传入并调用 ClientRegistrationRepository 接口的findByRegistrationId() 方法,findByRegistrationId() 返回 ClientRegistration

认证流程
  1. OAuth2LoginAuthenticationFilter 生成包含 OAuth2AuthorizationRequest、OAuth2AuthorizationResponse 和 ClientRegistration 的 OAuth2LoginAuthenticationToken

  2. OAuth2LoginAuthenticationProvider 通过调用 OAuth2AccessTokenResponseClient 接口的 getTokenResponse() 方法从 Authorization Server 的 Token 端点获取 Access Token

  3. OAuth2LoginAuthenticationProvider 通过调用 OAuth2UserService 接口的 loadUser() 方法从 Authorization Server 的 Authorization 端点获取用户信息

  4. OAuth2LoginAuthenticationProvider 生成 OAuth2LoginAuthenticationToken 返回认证结果

认证后处理
  1. OAuth2LoginAuthenticationFilter 根据认证结果生成 OAuth2AnthenticationToken 并设置在SecurityContext中

  2. OAuth2LoginAuthenticationFilter 根据认证结果生成 OAuth2AuthorizedClient ,调用OAuth2AuthorizedClientService 的 saveAuthorizedClinet() 方法,将 OAuth2AuthorizedClient 保存在任意类可访问的区域

0.2 Spring Security 自带 filter 执行顺序

绿框内的为本文涉及的过滤器

0.3 文章结构

0.6 主要依赖

<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-securityartifactId>
dependency>
复制代码

🌊1 管理用户

1.1 使用 UserDetails 描述用户

1.1.1 UserDetails 的定义

public interface UserDetails extends Serializable {
// 返回用户凭证
String getUsername();
String getPassword();

// 返回用户权限列表
Collection getAuthorities();

// 管理用户状态
// 如果不需要实现以下功能,可以让这些方法都返回 true
boolean isAccountNonExpired();
boolean isAccountNonLocked();
boolean isCredentialsNonExpired();
boolean isEnabled();
}
复制代码

1.1.2 GrantedAuthority 的定义

public interface GrantedAuthority extends Serializable {
String getAuthority();
}
复制代码

1.1.3 GrantedAuthority 的实例化

// SimpleGrantedAuthority 是 GrantedAuthority 的一个基础实现,以字符串形式描述权限
GrantedAuthority g2 = new SimpleGrantedAuthority("READ");
// 或者使用 lambda 表达式
GrantedAuthority g1 = () -> "READ";
复制代码

1.1.4 实现 UserDetails

根据单一职责原则,一个类只有一个职责,所以当用户类仅作为验证,则为 ”单原则“,若用户类不仅用于验证,同时还代表数据库中的一个实体类则为多职责

单职责

  • 继承 Userdetails

    public class DummyUser implements UserDetails {
    @Override
    public String getUsername() {
    return "bill";
    }

    @Override
    public String getPassword() {
    return "12345";
    }

    @Override
    public Collection getAuthorities() {
    return List.of(() -> "READ");
    }

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

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

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

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

    }
    复制代码
  • 使用 User 类的静态方法

    不需要自定义 Userdetails 的话就直接使用 User 类的静态方法

    • 例子

      // 至少要提供 username 和 password,且 username 不能为空串
      // 此处 User.withUsername("bill") 返回的是 User.UserBuilder 的实例(见下方“原理”)
      UserDetails u = User.withUsername("bill")
      .password("12345")
      .authorities("read", "write")
      .accountExpired(false)
      .disabled(true)
      .build();
      复制代码
    • 原理

      User.UserBuilder builder1 = User.withUsername("bill");
      UserDetails u1 = builder1
      .password("12345")
      .authorities("read", "write")
      .passwordEncoder(p -> encode(p))
      .accountExpired(false)
      .disabled(true)
      .build();
      复制代码

多职责

  • 继承 Userdetails 并使用装饰器模式

    • 数据库实体类

      public class MyUser {
      private Long id;
      private String username;
      private String password;
      private String authority;

      // 忽略 getters and setters
      }
      复制代码
    • 创建有两个职责的用户类

      public class SecurityUser implements UserDetails {
      private final MyUser user;

      public SecurityUser(MyUser user) {
      this.user = user;
      }

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

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

      @Override
      public Collection getAuthorities() {
      return List.of(() -> user.getAuthority());
      }

      // 忽略代码
      }
      复制代码

1.2 使用 JDBCUserdetailsManager 管理用户

1.2.1 UserDetailsService 的定义

public interface UserDetailsService {
// UsernameNotFoundException 是运行时异常,继承自 AuthenticationException(所有验证过程异常的父类)
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
复制代码

1.2.2 UserDetailsManager 的定义

public interface UserDetailsManager extends UserDetailsService {
void createUser(UserDetails user);
void updateUser(UserDetails user);
void deleteUser(String username);
void changePassword(String oldPassword, String newPassword);
boolean userExists(String username);
}
复制代码

1.2.3 在配置类中 JDBCUserdetailsManager

  • @Configuration
    public class ProjectConfig {
    @Bean
    public UserDetailsService userDetailsService(DataSource dataSource) {
    return new JdbcUserDetailsManager(dataSource);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
    return NoOpPasswordEncoder.getInstance();
    }
    }
    复制代码
  • 若想修改默认的数据库查询语句

    @Bean
    public UserDetailsService userDetailsService(DataSource dataSource) {
    String usersByUsernameQuery = "select username, password, enabled from users where username = ?";
    String authsByUserQuery = "select username, authority from spring.authorities where username = ?";
    var userDetailsManager = new JdbcUserDetailsManager(dataSource);
    userDetailsManager.setUsersByUsernameQuery(usersByUsernameQuery);
    userDetailsManager.setAuthoritiesByUsernameQuery(authsByUserQuery);
    return userDetailsManager;
    }
    复制代码

🌊2 处理密码

2.1 实现 PasswordEncoder

2.1.1 PasswordEncoder 的定义

public interface PasswordEncoder {
// 返回编码结果
String encode(CharSequence rawPassword);

// 比对密码
boolean matches(CharSequence rawPassword, String encodedPassword);

// 默认为 false,如果重写改成返回 true,已经编码过的密码会被再一次编码,以达到更安全的目的
default boolean upgradeEncoding(String encodedPassword) {
return false;
}
}
复制代码

2.1.2 使用 SHA-512 实现 PasswordEncoder

public class Sha512PasswordEncoder implements PasswordEncoder {
@Override
public String encode(CharSequence rawPassword) {
return hashWithSHA512(rawPassword.toString());
}

@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
String hashedPassword = encode(rawPassword);
return encodedPassword.equals(hashedPassword);
}

private String hashWithSHA512(String input) {
StringBuilder result = new StringBuilder();
try {
MessageDigest md = MessageDigest.getInstance("SHA-512");
byte [] digested = md.digest(input.getBytes());
for (int i = 0; i < digested.length; i++) {
result.append(Integer.toHexString(0xFF & digested[i]));
}
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("Bad algorithm");
}
return result.toString();
}
}
复制代码

2.2 使用 PasswordEncoder 子类

2.2.1 Pbkdf2PasswordEncoder

使用 PBKDF2 算法

PasswordEncoder p = new Pbkdf2PasswordEncoder();

// 参数:用于加密的密钥
PasswordEncoder p = new Pbkdf2PasswordEncoder("secret");

// 第一个参数:用于加密的密钥
// 第二个参数:给密码编码的迭代次数,默认值为 185000
// 第三个参数:哈希的长度,默认值为 256
// 后面两个参数影响编码结果的强度
PasswordEncoder p = new Pbkdf2PasswordEncoder("secret", 185000, 256);
复制代码

2.2.2 BCryptPasswordEncoder

使用 bcrypt 算法

PasswordEncoder p = new BCryptPasswordEncoder();

// 参数会影响哈希操作的迭代次数,若 参数 = n,则 迭代次数 = 2 ^ n (n >= 4 && n <= 31)
PasswordEncoder p = new BCryptPasswordEncoder(4);
复制代码

2.2.3 SCryptPasswordEncoder

使用 scrypt 算法

PasswordEncoder p = new SCryptPasswordEncoder();
PasswordEncoder p = new SCryptPasswordEncoder(16384, 8, 1, 32, 64);
复制代码

2.2.4 DelegatingPasswordEncoder

将 <加密算法名>-实例存储到键值对中

创建实例

@Configuration
public class ProjectConfig {
// Omitted code
@Bean
public PasswordEncoder passwordEncoder() {
Map encoders = new HashMap<>();
encoders.put("noop", NoOpPasswordEncoder.getInstance());
encoders.put("bcrypt", new BCryptPasswordEncoder());
encoders.put("scrypt", new SCryptPasswordEncoder());

// 第一个参数:默认的加密算法
/*
基于哈希的前缀,DelegatingPassword-Encoder 使用相应的 PasswordEncoder 实现来匹配密码,
例如 {bcrypt}$2a$10$xn3LI/AjqicFYZFruSwve.681477XaVNaUQbr1gioaWPn4t1KsnmG,
前缀为 {bcrypt} 所以使用 BCryptPasswordEncoder
*/

return new DelegatingPasswordEncoder("bcrypt", encoders);
}
}
复制代码

使用 PasswordEncoderFactories

// DelegatingPasswordEncoder 的实现,默认使用 bcrypt 算法编码
PasswordEncoder passwordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
复制代码

2.2.5 Spring Security Crypto module (SSCM)

Spring Security Crypto 模块提供对对称加密、密钥生成和密码编码的支持

KeyGenerators

KeyGenerators 类提供了许多方便的工厂方法来构造不同类型的密钥生成器

  • StringKeyGenerator

    生成字符串形式的密钥

    • StringKeyGenerator keyGenerator = KeyGenerators.string();

      String salt = keyGenerator.generateKey();
      复制代码
    • public interface StringKeyGenerator {
      // 创建一个 8 字节的密钥,并将其编码为十六进制字符串
      String generateKey();
      }
      复制代码
    • 定义

    • 实例化

  • BytesKeyGenerator

    生成字节[] 形式的密钥

    • KeyGenerators.shared(int length)

      生成的 BytesKeyGenerator 的实例在输入不变时,每次调用 generateKey() 都产生相同的结果

    • KeyGenerators.secureRandom()

      生成的 BytesKeyGenerator 的实例在输入不变时,每次调用 generateKey() 都产生不同的结果

    • BytesKeyGenerator keyGenerator = KeyGenerators.shared(16);

      byte [] key = keyGenerator.generateKey();
      int keyLength = keyGenerator.getKeyLength();
      复制代码
    • BytesKeyGenerator keyGenerator = KeyGenerators.secureRandom();
      // 如果想自己设定密钥的长度,可以在 secureRandom 方法中传入参数
      // BytesKeyGenerator keyGenerator = KeyGenerators.secureRandom(16);

      byte [] key = keyGenerator.generateKey();
      int keyLength = keyGenerator.getKeyLength();
      复制代码
    • public interface BytesKeyGenerator {
      // 以字节数返回密钥长度的方法
      // 默认生成 8 字节长度的密钥
      int getKeyLength();

      byte[] generateKey();
      }
      复制代码
    • 定义

    • 实例化

      可以使用 KeyGenerators.secureRandom() 和 KeyGenerators.shared(int length) 生成 BytesKeyGenerator 实例

Encryptor

Encryptors 类提供用于构造对称加密器的工厂方法

  • BytesEncryptor

    以字节 [] 形式加密数据

    • Encryptors.stronger()

      使用基于 Galois/Counter Mode (GCM) 操作模式的 256-bit AES 算法

    • Encryptors.standard()

      使用基于密码块链接 (CBC) 的 256-bit AES 算法,这被认为是一种较弱的方法(与 stronger() 比较)

    • String salt = KeyGenerators.string().generateKey();
      String password = "secret";
      String valueToEncrypt = "HELLO";

      BytesEncryptor e = Encryptors.stronger(password, salt);
      byte [] encrypted = e.encrypt(valueToEncrypt.getBytes());
      byte [] decrypted = e.decrypt(encrypted);
      复制代码
    • String salt = KeyGenerators.string().generateKey();
      String password = "secret";
      String valueToEncrypt = "HELLO";

      BytesEncryptor e = Encryptors.standard(password, salt);
      byte [] encrypted = e.encrypt(valueToEncrypt.getBytes());
      byte [] decrypted = e.decrypt(encrypted);
      复制代码
    • public interface BytesEncryptor {
      byte[] encrypt(byte[] byteArray);
      byte[] decrypt(byte[] encryptedByteArray);
      }
      复制代码
    • 定义

    • 实例化

      可以使用 Encryptors.standard() 和 Encryptors.stronger() 生成 BytesEncryptor 的实例

  • TextEncryptor

    加密文本字符串

    • Encryptors.text()

      使用 Encryptors.standard() 方法来管理加密操作 若输入不变,重复调用 encrypt() 方法产生不同的结果

    • Encryptors.delux()

      使用 Encryptors.stronger() 方法来管理加密操作 若输入不变,重复调用 encrypt() 方法产生不同的结果

    • Encryptors.queryableText()

      若输入不变,重复调用 encrypt() 方法产生相同的结果

    • String salt = KeyGenerators.string().generateKey();
      String password = "secret";
      String valueToEncrypt = "HELLO";

      TextEncryptor e = Encryptors.text(password, salt);
      String encrypted = e.encrypt(valueToEncrypt);
      String decrypted = e.decrypt(encrypted);
      复制代码
    • String salt = KeyGenerators.string().generateKey();
      String password = "secret";
      String valueToEncrypt = "HELLO";

      TextEncryptor e = Encryptors.delux(password, salt);
      String encrypted = e.encrypt(valueToEncrypt);
      String decrypted = e.decrypt(encrypted);
      复制代码
    • String salt = KeyGenerators.string().generateKey();
      String password = "secret";
      String valueToEncrypt = "HELLO";

      TextEncryptor e = Encryptors.queryableText(password, salt);
      String encrypted1 = e.encrypt(valueToEncrypt);
      String encrypted2 = e.encrypt(valueToEncrypt);
      // 这里 encrypted1 等于 encrypted2
      复制代码
    • public interface TextEncryptor {
      String encrypt(String text);
      String decrypt(String encryptedText);
      }
      复制代码
    • 定义

    • 实例化

🌊3 用户验证

3.1 实现 AuthenticationProvider

3.1.1 Authentication 的定义

Authentication 接口表示身份验证请求事件并保存请求访问应用程序的实体的详细信息

public interface Authentication extends Principal, Serializable {
// 返回已验证请求的授予权限集合。
Collection getAuthorities();
// 返回认证过程中需要的密钥
Object getCredentials();
// 返回关于请求的更多信息
Object getDetails();
Object getPrincipal();
// 认证结束返回 true,认证还在进行中则返回 false
boolean isAuthenticated();
void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}
复制代码

3.1.2 AuthenticationProvider 的定义

public interface AuthenticationProvider {
Authentication authenticate(Authentication authentication) throws AuthenticationException;

// 如果当前 AuthenticationProvider 支持参数内的类型,则可以实现此方法以返回 true
boolean supports(Class authentication);
}
复制代码

3.1.3 实现

步骤

  • 创建一个实现 AuthenticationProvider 接口的类

  • 重新 supports(Class c) 和 authenticate(Authentication a)

  • 在 SpringBoot 配置文件中注册第一步创建的类实例

代码

  • @Component
    public class CustomAuthenticationProvider implements AuthenticationProvider {
    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Override
    public Authentication authenticate(Authentication authentication) {
    String username = authentication.getName();
    String password = authentication.getCredentials().toString();
    UserDetails u = userDetailsService.loadUserByUsername(username);
    if (passwordEncoder.matches(password, u.getPassword())) {
    return new UsernamePasswordAuthenticationToken(username, password, u.getAuthorities());
    } else {
    // 验证失败时要抛出 AuthenticationException 异常
    throw new BadCredentialsException("Something went wrong!");
    }
    }

    // 如果接收的 Authentication 的对象不被你的 AuthenticationProvider 实现支持,则返回 false
    @Override
    public boolean supports(Class authenticationType) {
    return authenticationType.equals(UsernamePasswordAuthenticationToken.class);
    }
    }
    复制代码
  • @Configuration
    public class ProjectConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private AuthenticationProvider authenticationProvider;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) {
    auth.authenticationProvider(authenticationProvider);
    }
    // 省略代码
    }
    复制代码

3.2 实现 SecurityContext

3.2.1 SecurityContext

当 AuthenticationManager 完成验证,Authentication 实例将会存进 SecurityContext

定义

public interface SecurityContext extends Serializable {
Authentication getAuthentication();
void setAuthentication(Authentication authentication);
}
复制代码

管理 Authentication 对象的策略

  • 使用 SecurityContextHolder.setStrategyName() 设置策略

    @Configuration
    public class ProjectConfig {
    @Bean
    public InitializingBean initializingBean() {
    return () -> SecurityContextHolder.setStrategyName(
    SecurityContextHolder.MODE_THREADLOCAL);
    }
    }
    复制代码
  • 三种策略

    • 特点

    • 有线程安全问题

    • 执行方法的线程和处理请求的线程不一样

    • 注意

    • @GetMapping("/bye")
      @Async
      public void goodbye() {
      SecurityContext context = SecurityContextHolder.getContext();
      String username = context.getAuthentication().getName();
      // do something with the username
      }
      复制代码
    • 开启异步需要在配置类添加 @EnableAsync 注解

      @Configuration
      @EnableAsync
      public class ProjectConfig {
      @Bean
      public InitializingBean initializingBean() {
      // 使用 SecurityContextHolder.setStrategyName() 设置策略
      return () -> SecurityContextHolder.setStrategyName(
      SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
      }
      }
      复制代码
    • Spring 框架本身创建的线程能使这个策略成功(如以上所示)

    • 如果是你自己的代码中创建了线程,会出现 SecurityContextHolder.getContext() 返回 null 的情况,因为框架并不知道你创建的这个线程,如果想让自己创建的线程也能使这个策略成功,那么请看接下来介绍的装饰器类

    • 特点

    • 实例化

    • 使用 ThreadLocal 管理,因此每个线程存储各自的 Authentication 对象

    • 从 SecurityContextHolder 获得 SecurityContext 实例

      @GetMapping("/hello")
      public String hello() {
      SecurityContext context = SecurityContextHolder.getContext();
      Authentication a = context.getAuthentication();
      return "Hello, " + a.getName() + "!";
      }
      复制代码
    • Spring在方法的参数中注入 Authentication 值

      @GetMapping("/hello")
      public String hello(Authentication a) {
      return "Hello, " + a.getName() + "!";
      }
      复制代码
    • MODE_THREADLOCAL

      SecurityContext 默认的策略

    • MODE_INHERITABLETHREADLOCAL

      每个线程存储各自的 Authentication 对象,若当前线程 A 创建了一个新线程 B,那么 A 的 Authentication 对象会被复制到 B 中

    • MODE_GLOBAL

      所有线程共享 Authentication 对象

  • 使用装饰类处理异步

    • 特点

    • 用于定时任务

    • 实现了 ScheduledExecutorService

    • 装饰 ScheduledExecutorService 对象

    • 它将每个 Runnable 包装在 DelegatingSecurityContextRunnable 中,并将每个 Callable 包装在 DelegatingSecurityContextCallable 中

    • 特点

    • 应用

      @GetMapping("/hola")
      public String hola() throws Exception {
      Callable task = () -> {
      SecurityContext context = SecurityContextHolder.getContext();
      return context.getAuthentication().getName();
      };
      ExecutorService e = Executors.newCachedThreadPool();
      e = new DelegatingSecurityContextExecutorService(e);
      try {
      return "Hola, " + e.submit(task).get() + "!";
      } finally {
      e.shutdown();
      }
      }
      复制代码
    • 实现了 ExecutorService

    • 装饰 ExecutorService 对象

    • 它将每个 Runnable 包装在 DelegatingSecurityContextRunnable 中,并将每个 Callable 包装在 DelegatingSecurityContextCallable 中

    • 特点

    • 应用

      @GetMapping("/ciao")
      public String ciao() throws Exception {
      Callable task = () -> {
      SecurityContext context = SecurityContextHolder.getContext();
      return context.getAuthentication().getName();
      };

      ExecutorService e = Executors.newCachedThreadPool();
      try {
      var contextTask = new DelegatingSecurityContextCallable<>(task);
      return "Ciao, " + e.submit(contextTask).get() + "!";
      } finally {
      e.shutdown();
      }
      }
      复制代码
    • 继承自 Callable

    • 装饰 Callable 对象

    • 在调用 Callable 之前将用于设置 SecurityContext 的逻辑来包装 Callable,然后在调用完成后移除 SecurityContext

    • 特点

    • 应用

      @GetMapping("/ciao")
      public String ciao() throws Exception {
      Runnable task = () -> {
      SecurityContext context = SecurityContextHolder.getContext();
      return context.getAuthentication().getName();
      };

      ExecutorService e = Executors.newCachedThreadPool();
      try {
      var contextTask = new DelegatingSecurityContextRunnable(task);
      return "Ciao, " + e.submit(contextTask).get() + "!";
      } finally {
      e.shutdown();
      }
      }
      复制代码
    • 继承自 Runnable

    • 装饰 Runnable 对象

    • 在调用 Runnable 之前将用于设置 SecurityContext 的逻辑来包装 Runnable,然后在调用完成后移除 SecurityContext

    • DelegatingSecurityContextRunnable

    • DelegatingSecurityContextCallable

    • DelegatingSecurityContextExecutorService

    • DelegatingSecurityContextScheduledExecutorService

3.3 HTTP Basic authentication

3.3.1 配置 HTTP Basic

@Configuration
public class ProjectConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.httpBasic(c -> {
// 配置域名
c.realmName("OTHER");
});

http.authorizeRequests().anyRequest().authenticated();
}
}
复制代码

3.3.2 AuthenticationEntryPoint

特点

  • AuthenticationEntryPoint 用来定义验证失败时的操作

  • 在 Spring Security 架构中,它由一个名为 ExceptionTranslationManager 的组件直接使用,该组件处理过滤器链中抛出的任何 AccessDenied-Exception 和 AuthenticationException

实例化

  • public class CustomEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(
    HttpServletRequest httpServletRequest,
    HttpServletResponse httpServletResponse,
    AuthenticationException e)

    throws IOException, ServletException
    {
    httpServletResponse.addHeader("message", "Luke, I am your father!");
    httpServletResponse.sendError(HttpStatus.UNAUTHORIZED.value());
    }
    }
    复制代码
  • 在配置类的 HTTP Basic 方法中

    • @Override
      protected void configure(HttpSecurity http) throws Exception {
      http.httpBasic(c -> {
      c.realmName("OTHER");
      c.authenticationEntryPoint(new CustomEntryPoint());
      });
      http.authorizeRequests()
      .anyRequest()
      .authenticated();
      }
      复制代码

3.4 form-based login authentication

提供一个登录表格给用户输入验证信息

3.4.1 配置 form-based login

@Configuration
public class ProjectConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin();
http.authorizeRequests().anyRequest().authenticated();
}
}
复制代码

3.4.2 实现 AuthenticationSuccessHandler

用于自定义验证成功后的逻辑

@Component
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(
HttpServletRequest httpServletRequest,
HttpServletResponse httpServletResponse,
Authentication authentication)

throws IOException
{
// 以下代码的逻辑是验证成功后如果用户有读权限就重定向到其他页面
var authorities = authentication.getAuthorities();
var auth = authorities.stream()
.filter(a -> a.getAuthority().equals("read"))
.findFirst();
if (auth.isPresent()) {
httpServletResponse.sendRedirect("/home");
} else {
httpServletResponse.sendRedirect("/error");
}
}
}
复制代码

3.4.3 实现 AuthenticationFailureHandler

用于自定义验证失败后的逻辑

@Component
public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(
HttpServletRequest httpServletRequest,
HttpServletResponse httpServletResponse,
AuthenticationException e)
{
// 在响应的请求头添加信息
httpServletResponse.setHeader("failed", LocalDateTime.now().toString());
}
}
复制代码

3.4.4 在配置类中注册 handler 对象

@Configuration
public class ProjectConfig extends WebSecurityConfigurerAdapter {
@Autowired
private CustomAuthenticationSuccessHandler authenticationSuccessHandler;
@Autowired
private CustomAuthenticationFailureHandler authenticationFailureHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()
.successHandler(authenticationSuccessHandler)
.failureHandler(authenticationFailureHandler);

http.authorizeRequests()
.anyRequest().authenticated();
}
}
复制代码

3.4.5 如果想使用 http 请求来登录,那么需要同时支持 HTTP Basic 验证

@Override
protected void configure(HttpSecurity http) throws Exception {
// 同时支持 HTTP Basic 验证 和 form-based login 验证
http.formLogin()
.successHandler(authenticationSuccessHandler)
.failureHandler(authenticationFailureHandler)
.and()
.httpBasic();

http.authorizeRequests().anyRequest().authenticated();
}
复制代码

🌊4 配置授权

4.1 设置权限或角色

4.1.1 设置权限

var user1 = User.withUsername("john")
.password("12345")
.authorities("READ","WRITE")
.build();
复制代码

4.1.2 设置角色

使用 authorities(String... authorities) 方法

注意:使用这个方法配置角色需要在角色的字符串前加前缀 ROLE_

var user1 = User.withUsername("john")
.password("12345")
.authorities("ROLE_ADMIN")
.build();
复制代码

使用 roles(String... roles) 方法

var user1 = User.withUsername("john")
.password("12345")
.roles("ADMIN")
.build();
复制代码

4.2 权限限制

4.2.1 基于用户权限

有以下三种方式

  • hasAuthority(String authority)

    • 只有拥有 authority 权限的用户才能调用终端

    • 特点

    • 应用

      @Configuration
      public class ProjectConfig extends WebSecurityConfigurerAdapter {
      // Omitted code
      @Override
      protected void configure(HttpSecurity http) throws Exception {
      http.httpBasic();
      http.authorizeRequests()
      .anyRequest() // 端点限制
      .hasAuthority("WRITE"); // 权限限制
      }
      }
      复制代码
  • hasAnyAuthority(String... authorities)

    • 用户拥有 authorities 中至少一个权限才能调用终端

    • 特点

    • 应用

      @Configuration
      public class ProjectConfig extends WebSecurityConfigurerAdapter {
      // Omitted code
      @Override
      protected void configure(HttpSecurity http) throws Exception {
      http.httpBasic();
      http.authorizeRequests()
      .anyRequest() // 端点限制
      .hasAnyAuthority("WRITE", "READ"); // 权限限制
      }
      }
      复制代码
  • access(String attribute)

    • 使用 Spring Expression Language (SpEL),灵活支持更复杂的权限

    • 特点

    • 应用

      @Configuration
      public class ProjectConfig extends WebSecurityConfigurerAdapter {
      @Override
      protected void configure(HttpSecurity http) throws Exception {
      http.httpBasic();
      String expression = "hasAuthority('read') and !hasAuthority('delete')";
      http.authorizeRequests()
      .anyRequest() // 端点限制
      .access(expression); // 权限限制
      }
      }
      复制代码

4.2.2 基于用户角色

有以下三种方式

  • hasRole(String role)

    • 只有拥有 role 角色的用户才能调用终端

    • 特点

    • 应用

      @Configuration
      public class ProjectConfig extends WebSecurityConfigurerAdapter {
      // Omitted code
      @Override
      protected void configure(HttpSecurity http) throws Exception {
      http.httpBasic();
      http.authorizeRequests()
      .anyRequest() // 端点限制
      .hasRole("ADMIN"); // 权限限制
      }
      }
      复制代码
  • hasAnyRole(String... roles)

    • 用户拥有 roles 中至少一个权限才能调用终端

    • 特点

  • access()

    • 使用 Spring Expression Language (SpEL),灵活支持更复杂的角色

    • 特点

4.2.3 允许所有

@Configuration
public class ProjectConfig extends WebSecurityConfigurerAdapter {
// Omitted code
@Override
protected void configure(HttpSecurity http) throws Exception {
http.httpBasic();
http.authorizeRequests()
.anyRequest() // 端点限制
.permitAll(); // 权限限制
}
}
复制代码

4.2.4 拒绝所有

@Configuration
public class ProjectConfig extends WebSecurityConfigurerAdapter {
// Omitted code
@Override
protected void configure(HttpSecurity http) throws Exception {
http.httpBasic();
http.authorizeRequests()
.anyRequest() // 端点限制
.denyAll(); // 权限限制
}
}
复制代码

4.2.5 允许已经通过验证的用户

@Configuration
public class ProjectConfig extends WebSecurityConfigurerAdapter {
// Omitted code
@Override
protected void configure(HttpSecurity http) throws Exception {
http.httpBasic();
http.authorizeRequests()
.anyRequest() // 端点限制
.authenticated(); // 权限限制
}
}
复制代码

4.3 接口限制

MVC matchers

  • 特点

    • 可以使用 MVC 的路径表达式来选择接口

    • mvcMatchers("/hello") 相当于 mvcMatchers("/hello") 且 mvcMatchers("/hello/") ,mvc 会自动把 /hello/ 也保护上

    • 如果路径中有参数,那么有且只有一个参数可以使用正则表达式

  • 方法

    • mvcMatchers(HttpMethod method, String... patterns) 需要指定 HTTP 方法

    • mvcMatchers(String... patterns) 会自动应用上所有 HTTP 方法

  • 应用

    @Configuration
    public class ProjectConfig extends WebSecurityConfigurerAdapter {
    // Omitted code

    // 该应用的接口定义
    // /a using the HTTP method GET
    // /a using the HTTP method POST
    // /a/b using the HTTP method GET
    // /a/b/c using the HTTP method GET
    // /a/b/c/d/{param} using the HTTP method GET

    @Override
    protected void configure(HttpSecurity http) throws Exception {
    http.httpBasic();
    http.authorizeRequests()
    // 如果要访问使用了 GET 方法的接口 /a ,用户需要先验证才能访问
    .mvcMatchers(HttpMethod.GET, "/a")
    .authenticated()

    // 所有人都可以访问使用了 POST 方法的接口 /a
    .mvcMatchers(HttpMethod.POST, "/a")
    .permitAll()

    // 以 /a/ 开头的接口都可以访问,* 代表单个路径名
    .mvcMatchers( "/a/*")
    .permitAll()

    // 以 /a/b/ 开头的接口需要验证后才能访问,** 代表匹配任意数量的路径名
    .mvcMatchers( "/a/b/**")
    .authenticated()

    // 如果路径中有参数,参数可以使用正则表达式
    .mvcMatchers("/a/b/c/d/{param:^[0-9]*$}")
    .permitAll()

    // 其余的所有接口都一律不允许被访问
    .anyRequest()
    .denyAll();

    // 因为还没设置 csrf,所以先关闭它,不然会影响 POST 请求
    http.csrf().disable();
    }
    }
    复制代码

Ant matchers

  • 特点

    • 使用 Ant 表达式作为路径来选择接口

    • 用法和 MVC matchers 一样

    • mvcMatchers("/hello") 仅仅相当于 mvcMatchers("/hello"),不会自动保护上 /hello/

    • 如果路径中有参数,那么有且只有一个参数可以使用正则表达式

  • 方法

    • antMatchers(HttpMethod method, String patterns)

    • antMatchers(String patterns) 会自动应用上所有 HTTP 方法

    • antMatchers(HttpMethod method) 等于 antMatchers(httpMethod, “/**”)

regex matchers

  • 特点

    • 使用正则表达式 (regex) 作为路径来选择接口

  • 方法

    • regexMatchers(HttpMethod method, String regex)

    • regexMatchers(String regex) 会自动应用上所有 HTTP 方法

  • 应用

    @Configuration
    public class ProjectConfig extends WebSecurityConfigurerAdapter {
    // 省略代码
    @Override
    protected void configure(HttpSecurity http) throws Exception {
    http.httpBasic();
    http.authorizeRequests()
    .regexMatchers(".*/(us|uk|ca)+/(en|fr).*")
    .authenticated()
    .anyRequest()
    .hasAuthority("premium");
    }
    }
    复制代码

🌊5 实现过滤器

5.1 实现 Filter 类

5.1.1 实现 Filter 接口

public class MyFilter implements Filter {
@Override
public void doFilter(
ServletRequest servletRequest,
ServletResponse servletResponse,
FilterChain filterChain)

throws IOException, ServletException
{
// 写上你想要的操作

// 将过滤器加入过滤链中
filterChain.doFilter(request, response);
}
}
复制代码

5.1.2 继承 OncePerRequestFilter

当你继承 Spring Security 中已有的过滤器类,你可以拥有一些有用的功能,这样就可以让代码尽可能简单

特点

  • Spring Security 不能保证过滤链内的过滤器在单次请求中只被调用一次,而这个过滤器类可以保证自己单次请求中只被调用一次

  • CorsFilter 是其子类(CorsFilter 将在下节讲到)

方法

  • void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) 用于实现过滤逻辑

  • protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) 和 doFilter 的定义一样,但是该方法可以保证该过滤链中的单次请求只被调用一次

  • protected boolean shouldNotFilter(HttpServletRequest request) 可以决定对于特定请求要不要使用该过滤器过滤这个请求,默认该过滤器会过滤所有请求

  • protected boolean shouldNotFilterAsyncDispatch() 默认该过滤器不会过滤异步请求

  • protected boolean shouldNotFilterErrorDispatch() 默认该过滤器不会过滤错误调度请求

应用

public class MyFilter extends OncePerRequestFilter {

// 仅支持 HTTP 请求
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)

throws ServletException, IOException
{
// 这里写你要实现的逻辑
filterChain.doFilter(request, response);
}
}
复制代码

5.2 添加自己的类实例

5.2.1 在目标过滤器之前加入自定义过滤器

addFilterBefore() 方法

  • 参数

    • 参数一:你创建的、想加入过滤器链的过滤器

    • 参数二:目标过滤器的 class 对象

  • 使用

    @Configuration
    public class ProjectConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
    http.addFilterBefore(
    new MyFilter(),
    BasicAuthenticationFilter.class)
    .authorizeRequests()
    .anyRequest().permitAll();
    }
    }
    复制代码

5.2.2 在目标过滤器之后加入自定义过滤器

http.addFilterAfter() 方法

  • 参数

    • 参数一:你创建的、想加入过滤器链的过滤器

    • 参数二:目标过滤器的 class 对象

  • 使用

    @Configuration
    public class ProjectConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
    http.addFilterAfter(
    new MyFilter(),
    BasicAuthenticationFilter.class)
    .authorizeRequests()
    .anyRequest().permitAll();
    }
    }
    复制代码

5.2.3 在目标过滤器的位置加入自定义过滤器

http.addFilterAt() 方法

  • 特点

    • 在目标过滤器的位置加入自定义过滤器并不是取代目标过滤器

    • 如果过滤链中同一个位置有多个过滤器,那么 Spring Security 并不能保证这多个过滤器的执行顺序

  • 参数

    • 参数一:你创建的、想加入过滤器链的过滤器

    • 参数二:目标过滤器的 class 对象

  • 使用

    @Configuration
    public class ProjectConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
    http.addFilterAt(
    new MyFilter(),
    BasicAuthenticationFilter.class)
    .authorizeRequests()
    .anyRequest().permitAll();
    }
    }
    复制代码

🌊6 配置 CSRF 和 CORS

6.1 CSRF

// 因为字数限制,后续补充

6.2 CORS

6.2.1 什么是 CORS

  • 默认情况下,浏览器不允许请求与当前加载的网站不同来源的地址,而浏览器可以使用 CORS 机制来放宽这个严格的策略,并允许在某些情况下在不同来源之间进行请求

6.2.2 CORS 的工作原理

CORS 的机制基于 HTTP 请求头

以下为比较重要的请求头

  • Access-Control-Allow-Origin 指定可以访问你域上资源的外部域(源)

  • Access-Control-Allow-Methods 在你希望允许访问不同域的情况下,指定可以访问你的域的 HTTP 方法,比如只允许 HTTP GET 方法调用某个接口

  • Access-Control-Allow-Headers 添加你可以在特定请求中使用哪些请求头的限制

6.2.3 配置 CORS

使用 @CrossOrigin 注解

  • 特点

    • 该注解放在接口方法上

  • 应用

    @PostMapping("/test")
    @ResponseBody
    @CrossOrigin("http://localhost:8080")
    // @CrossOrigin({"example.com", "example.org"}) // 可以设置多个来源
    // 可以用 @CrossOrigin 的 allowedHeaders 属性设置请求头
    // 可以用 @CrossOrigin 的 methods 属性设置请求方法
    public String test() {
    logger.info("Test method called");
    return "HELLO";
    }
    复制代码

使用 CorsConfigurer

  • 特点

    • 在配置类里面可以设置整个应用的 CORS

  • 应用

    @Configuration
    public class ProjectConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
    // 调用 cors() 方法设置 CORS
    http.cors(c -> {
    // CorsConfigurationSource 为 HTTP 请求返回 CorsConfiguration
    CorsConfigurationSource source = request -> {
    // CorsConfiguration 指明允许哪些来源、请求方法和请求头
    CorsConfiguration config = new CorsConfiguration();
    // 至少要指明来源和请求方法,否则会拒绝请求
    config.setAllowedOrigins(List.of("example.com", "example.org"));
    config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
    return config;
    };
    c.configurationSource(source);
    });

    http.csrf().disable();
    http.authorizeRequests()
    .anyRequest()
    .permitAll();
    }
    }
    复制代码

------ 阶段性实战一 ------

详细下载代码,打开其中的 ssia-ch11 文件夹

🌊7 集成 OAuth 2

7.1 是什么

基于 HTTP 的协议

7.2 原理

7.2.1 成员

protected resource

  • protected resource 可通过 HTTP 服务器获得

  • 需要验证接收到的 token 并确定是否以及如何为请求提供服务

  • 在 OAuth 架构中,protected resource 对是否接受令牌有最终决定权

resource owner

  • resource owner 是有权将访问权限委托给 client 的实体

  • 与 OAuth 系统的其他部分不同,resource owner 不是一个软件,而是使用客户端软件访问他们控制的东西的人

  • resource owner 使用 Web 浏览器与 authorization server 交互(浏览器通常被当作用户的代理)

  • resource owner 也可以使用 Web 浏览器与 client 交互

client

  • client 是一种尝试代表资源所有者访问受保护资源的软件,它使用 OAuth 来获取该访问权限

  • 它不需要解析 token 的信息,它只是把 token 当成不透明的字符串来用

  • client 可以是网络应用程序、本地程序或者设置是在浏览器里面的 JavaScript 程序

authorization server

  • authorization server 是一个 HTTP 服务器,它充当 OAuth 系统的中心组件

  • authorization server 对 resource owner 和 client 进行身份验证,提供允许 resource owner 对 client 进行授权的机制,并向 client 颁发 token

7.2.2 组件

access tokens

  • 由 authorization server 产生

  • 代表 client 请求的访问权限、授权 client 的 resource owner 以及在该授权期间授予的权限的组合

  • token 是不透明的

scopes

  • scopes 代表受保护资源的一组权限,由 protected resource 定义

  • client 可以请求特定的 scopes

  • authorization server 可以允许 resource owner 在其请求期间向给定 client 授予或拒绝特定 scopes

refresh tokens

  • 由 authorization server 产生

  • token 是不透明的

  • refresh tokens 可使客户端能够缩小其访问范围,比如,如果客户端被授予范围 A、B 和 C,但它知道它只需要范围 A 来进行特定调用,则它可以使用 refresh tokens 来请求仅适用于范围 A 的 access tokens

  • OAuth 2.0 中 access tokens 可以设置过期时间,当 access tokens 过期时,client 使用 refresh tokens 向 authorization server 请求新的 access tokens(不用通过 resource owner),因此 refresh tokens 永远不会被发送到 protected resource

authorization grants

  • 是使用 OAuth 协议授予 client 访问 resource server 的权限的方法,如果成功,最终会导致客户端获得 token

7.2.3 成员和组件之间的交互方式

  • back channel

    在浏览器不参与的情况下两个组件通过 HTTP 通信

  • front channel

    Front-channel 通信是一种使用HTTP请求通过中间的Web浏览器在两个系统之间进行间接通信的方法

7.2.4 授权模式(authorization grant types)

authentication code grant type(最常用)

authentication code 是临时凭证,用来代表 resource owner 对 client 的委托

  1. resource owner 通过 client 向 protected resource 发起请求

  2. 当 client 需要 access toekn 时,用请求将 resource owner 发送到 authorization server

  • 来自 client 的响应如下所示(对应总图的 2.1 流程)

    HTTP/1.1 302 Moved Temporarily
    x-powered-by: Express
    Location: http://localhost:9001/authorize?response_type=code&scope=foo&client
    _id=oauth-client-1&redirect_uri=http%3A%2F%2Flocalhost%3A9000%2Fcallback&
    state=Lwt50DDQKUB8U7jtfLQCVGDL9cnmwHH1
    Vary: Accept
    Content-Type: text/html; charset=utf-8
    Content-Length: 444
    Date: Fri, 31 Jul 2015 20:50:19 GMT
    Connection: keep-alive
    复制代码
  • 重定向到浏览器会导致浏览器向 authorization server 发送 HTTP GET(对应总图的 2.2 流程)

    GET /authorize?response_type=code&scope=foo&client_id=oauth-client
    -1&redirect_uri=http%3A%2F%2Flocalhost%3A9000%
    2Fcallback&state=Lwt50DDQKUB8U7jtfLQCVGDL9cnmwHH1 HTTP/1.1
    Host: localhost:9001
    User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.10; rv:39.0)
    Gecko/20100101 Firefox/39.0
    Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
    Referer: http://localhost:9000/
    Connection: keep-alive
    复制代码
  1. resource owner 直接向 authorization server 验证身份,而不通过 client,这样能够保护用户不必与 client 共享他们的凭据

    OAuth 不规定身份验证的技术,因此可以自己选择(这篇文章的话是使用 Spring Security 实现身份验证)

  1. resource owner 选择将他们的部分权限委托给 client,authorization server 有许多不同的选项来完成这项工作

  • 如下图为实际委托权限给 client 的过程

  • 许多 authorization server 允许存储此授权决定以备将来使用,以后同一 client 对相同访问的请求将不会以交互方式(如上面的图片)提示用户

  • authorization server 甚至可以根据内部策略(例如 client 白名单或黑名单)覆盖最终用户的决定

  1. authorization server 将用户重定向回 client

  • authorization server 将用户代理重定向回 client(如总图 5.1) 这里的 code 是被称为 authorization code 的一次性凭证,它代表用户授权决策的结果

    HTTP 302 Found
    Location: http://localhost:9000/oauth_callback?code=8V1pr0rJ&state=Lwt50DDQKU
    B8U7jtfLQCVGDL9cnmwHH1
    复制代码
  • 浏览器向 client 发出以下请求(如总图 5.2)

    GET /callback?code=8V1pr0rJ&state=Lwt50DDQKUB8U7jtfLQCVGDL9cnmwHH1 HTTP/1.1
    Host: localhost:9000
    复制代码
  1. client 将获得的 code 等信息(如下所示)发送到 authorization server 的 token endpoint

    POST /token
    Host: localhost:9001
    Accept: application/json
    Content-type: application/x-www-form-encoded
    Authorization: Basic b2F1dGgtY2xpZW50LTE6b2F1dGgtY2xpZW50LXNlY3JldC0x
    grant_type=authorization_code&
    redirect_uri=http%3A%2F%2Flocalhost%3A9000%2Fcallback&code=8V1pr0rJ
    复制代码

  1. authorization server 验证 client 的凭证(上一步的 Authorization 开头的信息)的有效性,接着验证 code (上一步的 code 信息)的有效性,并且确保发出此请求的客户端与发出原始请求的客户端相同,如果都通过,则返回 access token 给 client

  • 此令牌在 HTTP 响应中作为 JSON 对象返回

    HTTP 200 OK
    Date: Fri, 31 Jul 2015 21:19:03 GMT
    Content-type: application/json
    {
    “access_token”: “987tghjkiu6trfghjuytrghj”,
    “token_type”: “Bearer”
    }
    复制代码
  • 这个 HTTP 响应也可以包括 refresh token 及其额外的信息(如 token 的 scopes 和过期时间)

  1. client 使用 access toekn 访问 protected resource

password grant type

  1. client 收集 resource owner 的用户名和密码

  2. 发送请求到 authorization server 获取 access token

    POST /token
    Host: localhost:9001
    Accept: application/json
    Content-type: application/x-www-form-encoded
    Authorization: Basic b2F1dGgtY2xpZW50LTE6b2F1dGgtY2xpZW50LXNlY3JldC0x
    grant_type=password&scope=foo%20bar&username=alice&password=secret
    复制代码
  3. 最后使用 access token 访问 protected resource

client credentials grant type

  1. 为了获取 access token,client 向 authorization server 发送请求

    POST /token
    Host: localhost:9001
    Accept: application/json
    Content-type: application/x-www-form-encoded
    Authorization: Basic b2F1dGgtY2xpZW50LTE6b2F1dGgtY2xpZW50LXNlY3JldC0x
    grant_type=client_credentials&scope=foo%20bar
    复制代码
  2. 最后使用 access token 访问 protected resource

如何选择合适的授权模式(authorization grant types)

7.3 搭建 client

7.3.1 依赖

<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-oauth2-clientartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-securityartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
dependency>
复制代码

7.3.2 设置配置类

@Configuration
public class ProjectConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
// 将 OAuth2LoginAuthenticationFilter 加入过滤链中
http.oauth2Login();

http.authorizeRequests()
.anyRequest()
.authenticated();
}
}
复制代码

7.3.3 实现 ClientRegistration

ClientRegistration 是什么

ClientRegistration 代表 client,包含 client id 和 密钥、授权模式、重定向 URI、scopes 等信息

创建 ClientRegistration 实例

提供以下两种方式

  • 创建对象

    @Configuration
    public class ProjectConfig extends WebSecurityConfigurerAdapter {
    private ClientRegistration clientRegistration() {
    ClientRegistration cr = ClientRegistration.withRegistrationId("github")
    .clientId("a7553955a0c534ec5e6b")
    .clientSecret("1795b30b425ebb79e424afa51913f1c724da0dbb")
    .scope(new String[]{"read:user"})
    .authorizationUri("https://github.com/login/oauth/authorize")
    .tokenUri("https://github.com/login/oauth/access_token")
    .userInfoUri("https://api.github.com/user")
    .userNameAttributeName("id")
    .clientName("GitHub")
    .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
    .redirectUriTemplate("{baseUrl}/{action}/oauth2/code/{registrationId}")
    .build();
    return cr;
    }

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

    http.authorizeRequests()
    .anyRequest()
    .authenticated();
    }
    }
    复制代码
  • 使用 CommonOAuth2Provider CommonOAuth2Provider 提供已经预设好合理默认值的常用客户端构造器,常用的有(FACEBOOK、GITHUB、GOOGLE、OKTA)

    @Configuration
    public class ProjectConfig extends WebSecurityConfigurerAdapter {
    private ClientRegistration clientRegistration() {
    return CommonOAuth2Provider.GITHUB
    .getBuilder("github")
    .clientId("a7553955a0c534ec5e6b")
    .clientSecret("1795b30b425ebb79e424afa51913f1c724da0dbb")
    .build();
    }

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

    http.authorizeRequests()
    .anyRequest()
    .authenticated();
    }
    }
    复制代码

7.3.4 实现 ClientRegistrationRepository

什么是 ClientRegistrationRepository

用来通过注册 id 查找 ClientRegistration

实现方式

  • 以 @Bean 方式注入

    @Configuration
    public class ProjectConfig extends WebSecurityConfigurerAdapter {
    @Bean
    public ClientRegistrationRepository clientRepository() {
    var c = clientRegistration();
    // InMemoryClientRegistrationRepository 是 ClientRegistrationRepository 的子类
    return new InMemoryClientRegistrationRepository(c);
    }

    private ClientRegistration clientRegistration() {
    return CommonOAuth2Provider.GITHUB.getBuilder("github")
    .clientId("a7553955a0c534ec5e6b")
    .clientSecret("1795b30b425ebb79e424afa51913f1c724da0dbb")
    .build();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
    http.oauth2Login();
    http.authorizeRequests()
    .anyRequest().authenticated();
    }
    }
    复制代码
  • 使用 Customizer 配置

    @Configuration
    public class ProjectConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
    http.oauth2Login(c -> {
    c.clientRegistrationRepository(clientRepository());
    });

    http.authorizeRequests()
    .anyRequest()
    .authenticated();
    }

    private ClientRegistrationRepository clientRepository() {
    var c = clientRegistration();
    return new InMemoryClientRegistrationRepository(c);
    }

    private ClientRegistration clientRegistration() {
    return CommonOAuth2Provider.GITHUB.getBuilder("github")
    .clientId("a7553955a0c534ec5e6b")
    .clientSecret("1795b30b425ebb79e424afa51913f1c724da0dbb")
    .build();
    }
    }
    复制代码

7.3.5 使用 application.properties 简化配置

这个方法不需要实现 ClientRegistration 和 ClientRegistrationRepository,Spring Boot 会基于 application.properties 帮你自动设置好

  • application.properties 文件 有两种情况

    • 使用常用的 client

      spring.security.oauth2.client.registration.github.client-id=a7553955a0c534ec5e6b
      spring.security.oauth2.client.registration.github.client-secret=1795b30b425ebb79e424afa51913f1c724da0dbb
      复制代码
    • 使用自定义 client 我们需要使用以 spring.security.oauth2.client.provider 开头的属性组指定 authorization server 的详细信息

      spring.security.oauth2.client.provider.myprovider.authorization-uri=
      spring.security.oauth2.client.provider.myprovider.token-uri=
      复制代码
  • 配置类

    @Configuration
    public class ProjectConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
    http.oauth2Login();

    http.authorizeRequests()
    .anyRequest()
    .authenticated();
    }
    }
    复制代码

7.4 搭建 Authorization server

7.4.1 管理用户

@Configuration
// 继承 WebSecurityConfigurerAdapter 以访问 AuthenticationManager 实例
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public UserDetailsService uds() {
var uds = new InMemoryUserDetailsManager();
var u = User.withUsername("john")
.password("12345")
.authorities("read")
.build();
uds.createUser(u);
return uds;
}

@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}

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

// 配置 form-login authentication
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin();
}
}
复制代码

7.4.2 两种部署方式

authorization server 和 resource server 部署的方式需要相同

1 远程检查令牌

图中的 Resource server 和 Protected resource 是一个意思

  • 依赖

    <dependency>
    <groupId>org.springframework.bootgroupId>
    <artifactId>spring-boot-starter-securityartifactId>
    dependency>
    <dependency>
    <groupId>org.springframework.bootgroupId>
    <artifactId>spring-boot-starter-webartifactId>
    dependency>
    <dependency>
    <groupId>org.springframework.cloudgroupId>
    <artifactId>spring-cloud-starter-oauth2artifactId>
    dependency>

    <dependencyManagement>
    <dependencies>
    <dependency>
    <groupId>org.springframework.cloudgroupId>
    <artifactId>spring-cloud-dependenciesartifactId>
    <version>Hoxton.SR1version>
    <type>pomtype>
    <scope>importscope>
    dependency>
    dependencies>
    dependencyManagement>
    复制代码
  • 设置配置类

    @Configuration
    @EnableAuthorizationServer
    public class AuthServerConfig extends AuthorizationServerConfigurerAdapter {
    // 注入 AuthenticationManager
    @Autowired
    private AuthenticationManager authenticationManager;

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
    // 设置 AuthenticationManager
    endpoints.authenticationManager(authenticationManager);
    }

    // 方法一:使用 InMemoryClientDetailsService 配置 client
    // @Override
    // public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
    // // 创建 ClientDetailsService 实例
    // var service = new InMemoryClientDetailsService();
    // // 创建 ClientDetails 实例
    // var cd = new BaseClientDetails();
    // cd.setClientId("client");
    // cd.setClientSecret("secret");
    // cd.setScope(List.of("read"));
    // cd.setAuthorizedGrantTypes(List.of("password"));
    // // 将 ClientDetails 实例添加到 InMemoryClientDetailsService
    // service.setClientDetailsStore(Map.of("client", cd));
    // // 配置 ClientDetailsService 以供我们的 authorization server 使用
    // clients.withClientDetails(service);
    // }

    // 方法二:在内存中配置 ClientDetails
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
    clients.inMemory()
    .withClient("client")
    .secret("secret")
    // 配置 password grant type
    .authorizedGrantTypes("password")
    .scopes("read");

    // 如果有多个 client,为了安全,请让每个 client 的名称和密码都不相同
    clients.inMemory()
    .withClient("client1")
    .secret("secret")
    // 配置 authorization code grant type
    .authorizedGrantTypes("authorization_code")
    .scopes("read")
    .redirectUris("http://localhost:9090/home")
    .and()
    .withClient("client2")
    .secret("secret2")
    // 一个 client 可配置多个授权方式
    .authorizedGrantTypes("authorization_code", "password", "refresh_token")
    .scopes("read")
    .redirectUris("http://localhost:9090/home");
    }

    // 指定我们可以调用 check_token 端点的条件
    public void configure(AuthorizationServerSecurityConfigurer security) {
    // 只有验证成功后才能调用 check_token 端点(这个端点是给 Resource server 调用的)
    security.checkTokenAccess("isAuthenticated()");
    }
    }
    复制代码

2 authorization server 和 resource server 共享数据库

这里将 authorization server 、 resource server 以及数据库都放在同一台主机

  • 依赖

    <dependency>
    <groupId>org.springframework.bootgroupId>
    <artifactId>spring-boot-starter-securityartifactId>
    dependency>
    <dependency>
    <groupId>org.springframework.bootgroupId>
    <artifactId>spring-boot-starter-webartifactId>
    dependency>
    <dependency>
    <groupId>org.springframework.cloudgroupId>
    <artifactId>spring-cloud-starter-oauth2artifactId>
    dependency>
    <dependency>
    <groupId>org.springframework.bootgroupId>
    <artifactId>spring-boot-starter-jdbcartifactId>
    dependency>
    <dependency>
    <groupId>mysqlgroupId>
    <artifactId>mysql-connector-javaartifactId>
    dependency>
    复制代码
  • 创建数据库 后面 resource server 就不用再创建了,因为是共用的

    CREATE TABLE IF NOT EXISTS `oauth_access_token` (
    `token_id` varchar(255) NOT NULL,
    `token` blob,
    `authentication_id` varchar(255) DEFAULT NULL,
    `user_name` varchar(255) DEFAULT NULL,
    `client_id` varchar(255) DEFAULT NULL,
    `authentication` blob,
    `refresh_token` varchar(255) DEFAULT NULL,
    PRIMARY KEY (`token_id`));

    CREATE TABLE IF NOT EXISTS `oauth_refresh_token` (
    `token_id` varchar(255) NOT NULL,
    `token` blob,
    `authentication` blob,
    PRIMARY KEY (`token_id`));
    复制代码
  • 在 application.properties 中配置数据源

    spring.datasource.url=jdbc:mysql://localhost/spring?useLegacyDatetimeCode=false&serverTimezone=UTC
    spring.datasource.username=root
    spring.datasource.password=
    spring.datasource.initialization-mode=always
    复制代码
  • 设置配置类

    @Configuration
    @EnableAuthorizationServer
    public class AuthServerConfig extends AuthorizationServerConfigurerAdapter {
    @Autowired
    private AuthenticationManager authenticationManager;

    // 注入我们在 application.properties 中配置的数据源
    @Autowired
    private DataSource dataSource;

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
    clients.inMemory()
    .withClient("client")
    .secret("secret")
    .authorizedGrantTypes("password", "refresh_token")
    .scopes("read");
    }

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
    endpoints.authenticationManager(authenticationManager)
    // 配置 tokenStore
    .tokenStore(tokenStore());
    }

    // 创建一个 JdbcTokenStore 实例,通过 application.properties 文件中配置的数据源提供对数据库的访问
    @Bean
    public TokenStore tokenStore() {
    return new JdbcTokenStore(dataSource);
    }
    }
    复制代码

7.5 搭建 Resource server

7.5.1 两种部署方式

1 远程检查令牌

  • 依赖

    <dependency>
    <groupId>org.springframework.bootgroupId>
    <artifactId>spring-boot-starter-oauth2-resource-serverartifactId>
    dependency>
    <dependency>
    <groupId>org.springframework.bootgroupId>
    <artifactId>spring-boot-starter-webartifactId>
    dependency>
    <dependency>
    <groupId>org.springframework.cloudgroupId>
    <artifactId>spring-cloud-starter-oauth2artifactId>
    dependency>

    <dependencyManagement>
    <dependencies>
    <dependency>
    <groupId>org.springframework.cloudgroupId>
    <artifactId>spring-cloud-dependenciesartifactId>
    <version>Hoxton.SR1version>
    <type>pomtype>
    <scope>importscope>
    dependency>
    dependencies>
    dependencyManagement>
    复制代码
  • 向 Resource server 添加凭证

    @Configuration
    @EnableAuthorizationServer
    public class AuthServerConfig extends AuthorizationServerConfigurerAdapter {
    // Omitted code
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
    clients.inMemory()
    .withClient("client")
    .secret("secret")
    .authorizedGrantTypes("password", "refresh_token")
    .scopes("read")
    .and()
    .withClient("resourceserver")
    .secret("resourceserversecret");
    }
    }
    复制代码

2 authorization server 和 resource server 共享数据库

  • 依赖

    <dependency>
    <groupId>org.springframework.bootgroupId>
    <artifactId>spring-boot-starter-oauth2-resource-serverartifactId>
    dependency>
    <dependency>
    <groupId>org.springframework.bootgroupId>
    <artifactId>spring-boot-starter-webartifactId>
    dependency>
    <dependency>
    <groupId>org.springframework.cloudgroupId>
    <artifactId>spring-cloud-starter-oauth2artifactId>
    dependency>
    <dependency>
    <groupId>org.springframework.bootgroupId>
    <artifactId>spring-boot-starter-jdbcartifactId>
    dependency>
    <dependency>
    <groupId>mysqlgroupId>
    <artifactId>mysql-connector-javaartifactId>
    dependency>
    复制代码
  • 配置 application.properties

    server.port=9090
    spring.datasource.url=jdbc:mysql://localhost/spring
    spring.datasource.username=root
    spring.datasource.password=
    复制代码
  • 设置配置类

    @Configuration
    @EnableResourceServer
    public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
    // 注入我们在 application.properties 中配置的数据源
    @Autowired
    private DataSource dataSource;

    @Override
    public void configure(ResourceServerSecurityConfigurer resources) {
    // 配置 tokenStore
    resources.tokenStore(tokenStore());
    }

    // 创建基于已注入的数据源的 JdbcTokenStore 实例
    @Bean
    public TokenStore tokenStore() {
    return new JdbcTokenStore(dataSource);
    }
    }
    复制代码

🌊8 集成 JWT 和加密签名

这个集成是在集成了 OAuth2 前提下进行的

8.1 JWT

8.1.1 什么是 JWT

JWT 即 JSON Web Token 是一种 JSON 风格的轻量级授权和身份认证规范,可实现无状态、分布式的 Web 应用授权

8.1.2 特点

  • JWT 包含三个部分:header 和 body 用 JSON 表示,并且它们是 Base64 编码的,第三部分是 signature ,使用加密算法生成,该算法使用 header 和 body 作为输入

  • 当 JWT 被签名,我们称之为 JWS ( JSON Web Token Signed )

  • 如果 toekn 被加密,我们也称其为 JWE ( JSON Web Token Encrypted )

8.2 使用对称加密算法签名 token

只是给 token 签名,而不是给整个 token 加密

8.2.1 职责

8.2.2 实现 authorization server

这里 authorization server 和 resource server 使用同一个加密密钥

依赖

<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-securityartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
dependency>
<dependency>
<groupId>org.springframework.cloudgroupId>
<artifactId>spring-cloud-starter-oauth2artifactId>
dependency>
复制代码

设置配置类

@Configuration
@EnableAuthorizationServer
public class AuthServerConfig extends AuthorizationServerConfigurerAdapter {
// 从 application.properties 文件中获取对称密钥的值
@Value("${jwt.key}")
private String jwtKey;

@Autowired
private AuthenticationManager authenticationManager;

@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
.withClient("client")
.secret("secret")
.authorizedGrantTypes("password", "refresh_token")
.scopes("read");
}

@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
endpoints.authenticationManager(authenticationManager)
// 配置 tokenStore 和 accessTokenConverter
.tokenStore(tokenStore())
.accessTokenConverter(jwtAccessTokenConverter());
}

@Bean
public TokenStore tokenStore() {
// 使用与之关联的 AccessTokenConverter 创建 TokenStore
return new JwtTokenStore(jwtAccessTokenConverter());
}

@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
var converter = new JwtAccessTokenConverter();
// 设置对称密钥
converter.setSigningKey(jwtKey);
return converter;
}
}
复制代码

在 application.properties 存储对称密钥(在实际开发时不建议把敏感数据放在这里,因为不安全)

jwt.key=MjWP5L7CiD
复制代码

配置用户管理

@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public UserDetailsService uds() {
var uds = new InMemoryUserDetailsManager();
var u = User.withUsername("john")
.password("12345")
.authorities("read")
.build();
uds.createUser(u);
return uds;
}

@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}

@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
复制代码

8.2.3 实现 resource server

和使用对称加密的配置上唯一改变的是 JwtAccessToken-Converter 对象的定义

依赖

<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-oauth2-resource-serverartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
dependency>
<dependency>
<groupId>org.springframework.cloudgroupId>
<artifactId>spring-cloud-starter-oauth2artifactId>
dependency>
复制代码

编辑配置类

@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
// 从 application.properties 注入密钥值
@Value("${jwt.key}")
private String jwtKey;

@Override
public void configure(ResourceServerSecurityConfigurer resources) {
// 配置 tokenStore
resources.tokenStore(tokenStore());
}

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

@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
var converter = new JwtAccessTokenConverter();
converter.setSigningKey(jwtKey);
return converter;
}
}
复制代码

编辑 application.properties

在 application.properties 存储对称密钥(在实际开发时不建议把敏感数据放在这里,因为不安全)

jwt.key=MjWP5L7CiD
复制代码

8.3 使用非对称加密算法签名 token

8.3.1 职责

8.3.2 什么是非对称密钥对

非对称密钥对包含两个密钥,一个是 private key,用来给 token 签名;一个是 public key,用来验证签名

8.3.3 生成密钥

可以使用 keytool 或 OpenSSL 生成非对称密钥对(以下使用 keytool ,因为装了 JDK 就有这个工具了)

生成 private key

# 文件名为 ssia.jks
# 密码为 ssia123,这个密码用来保护 private key
# 别名为 ssia
# 密钥生成算法为 RSA
keytool -genkeypair -alias ssia -keyalg RSA -keypass ssia123 -keystore ssia.jks -storepass ssia123
复制代码

生成 public key

keytool -list -rfc --keystore ssia.jks | openssl x509 -inform pem -pubkey
# 输入以上信息后会有提示输入密码,这时填 private key 的密码 ssia123
# 输完密码后会显示以下 public key 信息
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAijLqDcBHwtnsBw+WFSzG
VkjtCbO6NwKlYjS2PxE114XWf9H2j0dWmBu7NK+lV/JqpiOi0GzaLYYf4XtCJxTQ
DD2CeDUKczcd+fpnppripN5jRzhASJpr+ndj8431iAG/rvXrmZt3jLD3v6nwLDxz
pJGmVWzcV/OBXQZkd1LHOK5LEG0YCQ0jAU3ON7OZAnFn/DMJyDCky994UtaAYyAJ
7mr7IO1uHQxsBg7SiQGpApgDEK3Ty8gaFuafnExsYD+aqua1Ese+pluYnQxuxkk2
Ycsp48qtUv1TWp+TH3kooTM6eKcnpSweaYDvHd/ucNg8UDNpIqynM1eS7KpffKQm
DwIDAQAB
-----END PUBLIC KEY-----
复制代码

8.3.4 实现 authorization server

依赖

和 8.2.2 的依赖一样

编辑 application.properties

在实际开发时不建议把敏感数据放在这里,因为不安全

# 用来设置 JwtTokenStore
password=ssia123
privateKey=ssia.jks
alias=ssia
复制代码

编辑配置类

@Configuration
@EnableAuthorizationServer
public class AuthServerConfig extends AuthorizationServerConfigurerAdapter {
// 注入 private key 密码
@Value("${password}")
private String password;

// 注入 private key 类路径
@Value("${privateKey}")
private String privateKey;

// 注入 private key 名称
@Value("${alias}")
private String alias;

@Autowired
private AuthenticationManager authenticationManager;

// Omitted code

@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
var converter = new JwtAccessTokenConverter();
// 从类路径读取 private key
KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(
new ClassPathResource(privateKey),
password.toCharArray()
);
// 使用 KeyStoreKeyFactory 对象检索密钥对并将密钥对设置到 JwtAccessTokenConverter 对象
converter.setKeyPair(keyStoreKeyFactory.getKeyPair(alias));
return converter;
}
}
复制代码

8.3.5 实现 resource server

resource server 用 public key 验证 token 的签名

依赖

和 8.2.3 的依赖一样

编辑 application.properties

server.port=9090
publicKey=publicKey=-----BEGIN PUBLIC KEY-----MIIBIjANBghk太长了省略…-----END PUBLIC KEY-----
复制代码

编辑配置类

@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
// 注入 public key
@Value("${publicKey}")
private String publicKey;

@Override
public void configure(ResourceServerSecurityConfigurer resources) {
resources.tokenStore(tokenStore());
}

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

@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
var converter = new JwtAccessTokenConverter();
// 设置 public key,token store 使用 public key 验证 token
converter.setVerifierKey(publicKey);
return converter;
}
}
复制代码

8.3.6 使用端点暴露 public key

在前面的实现中,我们将 public key 和 private key 分别放在了 resource server 和 authorization server 中,如果密钥对发生变化,那么 resource server 将无法验证 token ,于是我们应该把 public key 和 private key 都放在 authorization server,使用端点暴露 public key ,使 resource server 能通过端点访问 public key。这样做的好处是可以定期更改密钥对,以此增加安全性,同时不影响 token 的验证

职责

修改 authorization server

修改 8.3.4 的配置类

@Configuration
@EnableAuthorizationServer
public class AuthServerConfig extends AuthorizationServerConfigurerAdapter {
// Omitted code

@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
.withClient("client")
.secret("secret")
.authorizedGrantTypes("password", "refresh_token")
.scopes("read")
.and()
// 添加代表 resource server 的 client 凭证,用于 resource server 来访问
.withClient("resourceserver")
.secret("resourceserversecret");
}

@Override
public void configure(AuthorizationServerSecurityConfigurer security) {
// authorization server 默认提供了一个暴露 public key 的端点 /oauth/token_key ,但是默认情况下是不允许访问的,需要进行以下设置才能被访问
// 配置 authorization server 给 public key 暴露端点,只用通过 client 验证的才能访问
security.tokenKeyAccess("isAuthenticated()");
}
}
复制代码

修改 resource server

修改 8.3.5 的 application.properties

server.port=9090
security.oauth2.resource.jwt.key-uri=http://localhost:8080/oauth/token_key
security.oauth2.client.client-id=resourceserver
security.oauth2.client.client-secret=resourceserversecret
复制代码

因为现在 resource server 从端点 /oauth/token_key 获取 public key,所以现在不需要设置配置类,配置类可以置空

@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
}
复制代码

8.4 自定义 JWT

8.4.1 JWT 中默认包括的细节

默认情况下,token 通常存储 Basic authorization 所需的所有详细信息

{
"exp": 1582581543,
"user_name": "john",
"authorities": [
"read"
],
"jti": "8e208653-79cf-45dd-a702-f6b694b417e7",
"client_id": "client",
"scope": [
"read"
]
}
复制代码

8.4.2 配置 authorization server 给 token 添加自定义信息

实现 TokenEnhancer

public class CustomTokenEnhancer implements TokenEnhancer {
@Override
public OAuth2AccessToken enhance(
OAuth2AccessToken oAuth2AccessToken,
OAuth2Authentication oAuth2Authentication)
{
var token = new DefaultOAuth2AccessToken(oAuth2AccessToken);
// 将我们想添加的信息放进键值对里面,这里我们添加的自定义信息是时区
Map info = Map.of("generatedInZone", ZoneId.systemDefault().toString());
token.setAdditionalInformation(info);
return token;
}
}
复制代码

将自定义的 TokenEnhancer 配置进 authorization server

@Configuration
@EnableAuthorizationServer
public class AuthServerConfig extends AuthorizationServerConfigurerAdapter {
// Omitted code

@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
// 将我们自定义的 TokenEnhancer 添加进 list 中
var tokenEnhancers = List.of(new CustomTokenEnhancer(), jwtAccessTokenConverter());
// 将 TokenEnhancer 的 List 添加进链中
tokenEnhancerChain.setTokenEnhancers(tokenEnhancers);
endpoints
.authenticationManager(authenticationManager)
.tokenStore(tokenStore())
.tokenEnhancer(tokenEnhancerChain);
}
}
复制代码

修改后得到的 token

{
"user_name": "john",
"scope": [
"read"
],
"generatedInZone": "Europe/Bucharest",
"exp": 1582591525,
"authorities": [
"read"
],
"jti": "0c39ace4-4991-40a2-80ad-e9fdeb14f9ec",
"client_id": "client"
}
复制代码

8.4.3 配置 resource server 从 token 读取自定义信息

继承 JwtAccessTokenConverter

AccessTokenConverter 是将 token 转换为 Authentication 的类

public class AdditionalClaimsAccessTokenConverter extends JwtAccessTokenConverter {
@Override
public OAuth2Authentication extractAuthentication(Map map) {
var authentication = super.extractAuthentication(map);
// 往 authentication 添加自定义信息
authentication.setDetails(map);
return authentication;
}
}
复制代码

将自定义的 JwtAccessTokenConverter 配置进 resource server

@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
// Omitted code

@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
var converter = new AdditionalClaimsAccessTokenConverter();
converter.setVerifierKey(publicKey);
return converter;
}
}
复制代码

一个测试有没更改 token 成功的方法是在 conreoller 内通过 HTTP 响应返回自定义添加的信息

@RestController
public class HelloController {
@GetMapping("/hello")
public String hello(OAuth2Authentication authentication) {
OAuth2AuthenticationDetails details = (OAuth2AuthenticationDetails) authentication.getDetails();
return "Hello! " + details.getDecodedDetails();
}
}
复制代码

------ 阶段性实战二 ------

详细下载代码,打开其中的 ssia-ch18-ex1 和 ssia-ch18-ex2 文件夹

🌊尾声

学习的时候最还是要看书,网上的博客文章虽然有用,但只是起到辅助作用,就好像以前读书时就算有很多有用的辅导书,也还是要看课本。由于本文作者能力有限,本文有什么纰漏和错误,烦请读者们在评论区指出。最后希望本文能够帮助到你,觉得有用就点个赞,感谢观看!

🌊参考

文字参考

[1]Laurențiu Spilcă.Spring Security in Action[M].America: Manning, 2020.

[2]Justin Richer, Antonio Sanso.OAuth2 in Action[M].America: Manning, 2017.

[3]Rod Johnson.Spring Security Reference[EB/OL].docs.spring.io/spring-secu…, 2020.

[4]Spring Team.Spring Security Source Code[DB]

图片参考

原文链接:https://juejin.cn/post/7036297405326688287


浏览 132
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报