Spring Security(二)OAuth2认证详解及自定义异常处理

Spring Security & Oauth2系列:
 
Spring Security(一) 源码分析及认证流程
Spring Security(二)OAuth2认证详解及自定义异常处理

1、OAuth2.0 简介

OAuth 2.0是用于授权的行业标准协议。OAuth 2.0为简化客户端开发提供了特定的授权流,包括Web应用、桌面应用、移动端应用等。

1.1 OAuth2.0 相关名词解释

  • Resource owner(资源拥有者):拥有该资源的最终用户,他有访问资源的账号密码;
  • Resource server(资源服务器):拥有受保护资源的服务器,如果请求包含正确的访问令牌,可以访问资源;
  • Client(客户端):访问资源的客户端,会使用访问令牌去获取资源服务器的资源,可以是浏览器、移动设备或者服务器;
  • Authorization server(认证服务器):用于认证用户的服务器,如果客户端认证通过,发放访问资源服务器的令牌。

1.2 四种授权模式

  • Authorization Code(授权码模式):正宗的OAuth2的授权模式,客户端先将用户导向认证服务器,登录后获取授权码,然后进行授权,最后根据授权码获取访问令牌;
  • Implicit(简化模式):和授权码模式相比,取消了获取授权码的过程,直接获取访问令牌;
  • Resource Owner Password Credentials(密码模式):客户端直接向用户获取用户名和密码,之后向认证服务器获取访问令牌;
  • Client Credentials(客户端模式):客户端直接通过客户端认证(比如client_id和client_secret)从认证服务器获取访问令牌。

1.3 、OAuth2框架

Spring Security提供了OAuth 2.0 完整支持,主要包括:

  • OAuth 2.0核心 - spring-security-oauth2-core.jar:包含为OAuth 2.0授权框架和OpenID Connect Core 1.0提供支持的核心类和接口;
  • OAuth 2.0客户端 - spring-security-oauth2-client.jar:Spring Security对OAuth 2.0授权框架和OpenID Connect Core 1.0的客户端支持;
  • OAuth 2.0 JOSE - spring-security-oauth2-jose.jar:包含Spring Security对JOSE(Javascript对象签名和加密)框架的支持。框架旨在提供安全地传输双方之间的权利要求的方法。它由一系列规范构建:
    JSON Web令牌(JWT)
    JSON Web签名(JWS)
    JSON Web加密(JWE)
    JSON Web密钥(JWK)

要使用OAuth2,需要引入spring-security-oauth2模块,通过之前源码分析,Spring 通过OAuth2ImportSelector类对Oauth2.0进行支持,当引入oauth2模块,Spring会自动启用 OAuth2 客户端配置 OAuth2ClientConfiguration。

1.4 OAuth 2.0客户端提供功能

OAuth 2.0客户端功能为OAuth 2.0授权框架中定义的客户端角色提供支持。
可以使用以下主要功能:

  • 授权代码授予
  • 客户凭证授权
  • Servlet环境的WebClient扩展(用于发出受保护的资源请求)

HttpSecurity.oauth2Client()提供了许多用于自定义OAuth 2.0 Client的配置选项。

@EnableWebSecurity
public class OAuth2ClientSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .oauth2Client()
            .clientRegistrationRepository(this.clientRegistrationRepository())
            .authorizedClientRepository(this.authorizedClientRepository())
            .authorizedClientService(this.authorizedClientService())
            .authorizationCodeGrant()
            .authorizationRequestRepository(this.authorizationRequestRepository())
            .authorizationRequestResolver(this.authorizationRequestResolver())
            .accessTokenResponseClient(this.accessTokenResponseClient());
    }
}

2、OAuth 2.0 认证服务

Spring Security OAuth2 实现了OAuth 2.0授权服务,简化了程序员对OAuth 2.0的实现,仅需要简单配置OAuth 2.0认证参数即可快速实现认证授权功能。

2.1 Spring Security OAuth2 提供的程序实现

Spring Security OAuth2 中的提供者角色实际上是在授权服务和资源服务之间分配的,使用Spring Security OAuth2,您可以选择将它们拆分到两个应用程序中,并具有多个共享的资源服务授权服务。

2.1.1 授权服务

对令牌的请求由Spring MVC控制器端点处理,对受保护资源的访问由标准Spring Security请求过滤器处理。为了实现OAuth 2.0授权服务器,Spring Security过滤器链中需要以下端点:

2.1.2 资源服务

要实现OAuth 2.0资源服务器,需要以下过滤器:

2.2 集成 OAuth 2.0 认证授权及资源管理

2.2.1 项目准备

  • 引入依赖
<dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <!-- ... other dependency elements ... -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-security</artifactId>
    </dependency>

    <dependency>
      <groupId>org.springframework.cloud</groupId>
      <artifactId>spring-cloud-starter-oauth2</artifactId>
    </dependency>

2.2.1 配置授权服务

在配置授权服务器时,必须考虑客户端用于从最终用户获取访问令牌的授予类型(例如,授权代码,用户凭据,刷新令牌)。服务器的配置用于提供客户端详细信息服务和令牌服务的实现,并全局启用或禁用该机制的某些方面。但是请注意,可以为每个客户端专门配置权限,使其能够使用某些授权机制和访问授权。也就是说,仅因为您的提供程序配置为支持“客户端凭据”授予类型,并不意味着授权特定的客户端使用该授予类型。
使用@EnableAuthorizationServer注解开启Oauth2认证。

@EnableAuthorizationServer批注用于配置OAuth 2.0授权服务器机制以及任何@Beans实现的机制AuthorizationServerConfigurer(有一个便捷的适配器实现,其中包含空方法)。以下功能委托给由Spring创建并传递到的单独的配置器AuthorizationServerConfigurer:

  • ClientDetailsServiceConfigurer:定义客户端详细信息服务的配置程序。可以初始化客户详细信息,或者您可以仅引用现有商店。
  • AuthorizationServerSecurityConfigurer:定义令牌端点上的安全约束。
  • AuthorizationServerEndpointsConfigurer:定义授权和令牌端点以及令牌服务。

提供者配置的一个重要方面是将授权代码提供给OAuth客户端的方式(在授权代码授予中)。OAuth客户端通过将最终用户定向到授权页面来获得授权码,用户可以在该页面上输入她的凭据,从而导致从提供者授权服务器重定向回带有授权码的OAuth客户端。

源码清单:

@Configuration
@EnableAuthorizationServer
public class Oauth2ServerConfig extends AuthorizationServerConfigurerAdapter {

  @Autowired
  private PasswordEncoder passwordEncoder;

  @Autowired
  private AuthenticationManager authenticationManager;

  @Autowired
  private UserService userService;

  /**
   * 自定义授权服务配置
   * 使用密码模式需要配置
   */
  @Override
  public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
    endpoints.authenticationManager(authenticationManager)
        .userDetailsService(userService);
  }

  /**
   * 配置认证客户端
   * @param clients
   * @throws Exception
   */
  @Override
  public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
    //自定义客户端配置
  }

  /**
   * 自定义授权令牌端点的安全约束
   * @param security
   * @throws Exception
   */
  @Override
  public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
    //自定义安全约束
    //....
  }
}

2.2.1.1 授权服务配置

AuthorizationServerEndpointsConfigurer 定义授权和令牌端点以及令牌服务。

endpoints.tokenStore(tokenStore)//自定义令牌存储策略
                //默认除密码模式外,所有授权模式均支持,密码模式需要显示注入authenticationManager开启
                .authenticationManager(authenticationManager)
                .userDetailsService(userDetailServiceImpl)//自定义用户密码加载服务
                .tokenGranter(tokenGranter)//定义控制授权
                .exceptionTranslator(webResponseExceptionTranslator);//自定义异常解析

2.2.1.2 客户端加载策略配置

ClientDetailsServiceConfigurer(从您的回调AuthorizationServerConfigurer)可以用来定义一个内存中或JDBC实现客户的细节服务。客户的重要属性是:

  • clientId:(必填)客户端ID。
  • secret:(对于受信任的客户端是必需的)客户端密钥(如果有)。
  • scope:客户端的范围受到限制。如果范围未定义或为空(默认值),则客户端不受范围的限制。
  • authorizedGrantTypes:授权客户使用的授权类型。默认值为空。
  • authorities:授予客户端的权限(常规的Spring Security权限)。

可以通过直接访问底层存储(例如的情况下为数据库表JdbcClientDetailsService)或通过ClientDetailsManager接口(这两种实现都ClientDetailsService可以实现)来更新正在运行的应用程序中的客户端详细信息。

  • 内存加载客户端配置,直接通过ClientDetailsServiceConfigurer添加客户端配置
@Override
  public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
    clients.inMemory()
        .withClient("admin")//配置client_id
        .secret(passwordEncoder.encode("admin123456"))//配置client_secret
        .accessTokenValiditySeconds(3600)//配置访问token的有效期
        .refreshTokenValiditySeconds(864000)//配置刷新token的有效期
        .redirectUris("http://www.baidu.com")//配置redirect_uri,用于授权成功后跳转
        .scopes("all")//配置申请的权限范围
        .authorizedGrantTypes("authorization_code","password","client_credentials","refresh_token");//配置grant_type,表示授权类型
  }
  • 自定义ClientDetailsService,redis+jdbc方式加载客户端缓存
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.withClientDetails(redisClientDetailsService);
        redisClientDetailsService.loadAllClientToCache();//
    }

    @Service
    public class RedisClientDetailsService extends JdbcClientDetailsService {
    //继承JdbcClientDetailsService,扩展redis缓存加载客户端,优先从缓存获取客户端配置,缓存没有再从数据库加载

2.2.1.3 令牌管理策略

AuthorizationServerTokenServices定义了管理OAuth 2.0令牌所需的操作。在开发过程需要注意:

  • 创建访问令牌后,必须存储身份验证,以便接受访问令牌的资源以后可以引用它。
  • 访问令牌用于加载用于授权其创建的身份验证。

在创建AuthorizationServerTokenServices实现时,您可能需要考虑使用DefaultTokenServices,可以使用插入许多策略来更改访问令牌的格式和存储。默认情况下,它会通过随机值创建令牌,并处理所有其他事务(除了将令牌委派给的令牌的持久性)TokenStore。默认存储是内存中的实现。

  • InMemoryTokenStore对于单个服务器,默认设置非常合适(例如,低流量,并且在发生故障的情况下不与备份服务器进行热交换)。大多数项目都可以从此处开始,并且可以在开发模式下以这种方式运行,以轻松启动没有依赖性的服务器。

  • JdbcTokenStore是JDBC版本的同样的事情,它存储在关系数据库中令牌数据。如果可以在服务器之间共享数据库,请使用JDBC版本;如果只有一个,则可以扩展同一服务器的实例;如果有多个组件,则可以使用Authorization and Resources Server。要使用,JdbcTokenStore您需要在类路径上使用“ spring-jdbc”。

  • 存储的JSON Web令牌(JWT) 版本将有关授权的所有数据编码到令牌本身中(因此根本没有后端存储,这是一个很大的优势)。一个缺点是您不能轻易地撤销访问令牌,因此通常授予它们的期限很短,并且撤销是在刷新令牌处进行的。另一个缺点是,如果您在令牌中存储了大量用户凭证信息,则令牌会变得很大。JwtTokenStore是不是一个真正的“存储”在这个意义上,它不坚持任何数据,但它起着翻译令牌值和认证信息相同的角色DefaultTokenServices

2.2.1.4 自定义定义UserService实现UserDetailsService

@Component
public class UserService implements UserDetailsService {

  @Autowired
  private QtAdminService qtAdminService;


  @Override
  public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    String clientId = "admin";
    UserDto userDto = qtAdminService.loadUserByUsername(username);
    if (userDto == null) {
      throw new UsernameNotFoundException(MessageConstant.USERNAME_PASSWORD_ERROR);
    }
    userDto.setClientId(clientId);
    SecurityUser securityUser = new SecurityUser(userDto);
    if (!securityUser.isEnabled()) {
      throw new DisabledException(MessageConstant.ACCOUNT_DISABLED);
    } else if (!securityUser.isAccountNonLocked()) {
      throw new LockedException(MessageConstant.ACCOUNT_LOCKED);
    } else if (!securityUser.isAccountNonExpired()) {
      throw new AccountExpiredException(MessageConstant.ACCOUNT_EXPIRED);
    } else if (!securityUser.isCredentialsNonExpired()) {
      throw new CredentialsExpiredException(MessageConstant.CREDENTIALS_EXPIRED);
    }
    return securityUser;
  }
}

2.2.1.5 定义令牌端点上的安全约束

在对请求授权的端点进行访问之前需要对授权信息中传递的客户端信息进行认证,客户端认证通过后才会访问授权端点。根据授权参数传递方式不同,对客户端进行认证的Filter也可能不一样:

  • 请求/oauth/token的,如果配置支持allowFormAuthenticationForClients的,且url中有client_id和client_secret的会走ClientCredentialsTokenEndpointFilter
  • 请求/oauth/token的,如果没有支持allowFormAuthenticationForClients或者有支持但是url中没有client_id和client_secret的,走BasicAuthenticationFilter认证

可以AuthorizationServerSecurityConfigurer添加客户端信息验证策略

@Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        security.tokenKeyAccess("permitAll()")
                .checkTokenAccess("isAuthenticated()")
                .addTokenEndpointAuthenticationFilter(customBasicAuthenticationFilter);//添加自定义客户端验证策略
    }

//客户端验证策略控制
public void configure(HttpSecurity http) throws Exception {
    this.frameworkEndpointHandlerMapping();
    if (this.allowFormAuthenticationForClients) {
      this.clientCredentialsTokenEndpointFilter(http);
    }

    Iterator var2 = this.tokenEndpointAuthenticationFilters.iterator();

    while(var2.hasNext()) {
      Filter filter = (Filter)var2.next();
      http.addFilterBefore(filter, BasicAuthenticationFilter.class);
    }

    http.exceptionHandling().accessDeniedHandler(this.accessDeniedHandler);
  }

  private ClientCredentialsTokenEndpointFilter clientCredentialsTokenEndpointFilter(HttpSecurity http) {
    ClientCredentialsTokenEndpointFilter clientCredentialsTokenEndpointFilter = new ClientCredentialsTokenEndpointFilter(this.frameworkEndpointHandlerMapping().getServletPath("/oauth/token"));
    clientCredentialsTokenEndpointFilter.setAuthenticationManager((AuthenticationManager)http.getSharedObject(AuthenticationManager.class));
    OAuth2AuthenticationEntryPoint authenticationEntryPoint = new OAuth2AuthenticationEntryPoint();
    authenticationEntryPoint.setTypeName("Form");
    authenticationEntryPoint.setRealmName(this.realm);
    clientCredentialsTokenEndpointFilter.setAuthenticationEntryPoint(authenticationEntryPoint);
    clientCredentialsTokenEndpointFilter = (ClientCredentialsTokenEndpointFilter)this.postProcess(clientCredentialsTokenEndpointFilter);
    http.addFilterBefore(clientCredentialsTokenEndpointFilter, BasicAuthenticationFilter.class);
    return clientCredentialsTokenEndpointFilter;
  }

  private ClientDetailsService clientDetailsService() {
    return (ClientDetailsService)((HttpSecurity)this.getBuilder()).getSharedObject(ClientDetailsService.class);
  }

  private FrameworkEndpointHandlerMapping frameworkEndpointHandlerMapping() {
    return (FrameworkEndpointHandlerMapping)((HttpSecurity)this.getBuilder()).getSharedObject(FrameworkEndpointHandlerMapping.class);
  }

  public void addTokenEndpointAuthenticationFilter(Filter filter) {
    this.tokenEndpointAuthenticationFilters.add(filter);
  }

2.2.2 添加SpringSecurity配置

允许认证相关路径的访问及表单登录

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

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

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

  @Override
  public void configure(HttpSecurity http) throws Exception {
    http.csrf()
        .disable()
        .authorizeRequests()
        .antMatchers("/oauth/**", "/login/**", "/logout/**")
        .permitAll()
        .anyRequest()
        .authenticated()
        .and()
        .formLogin()
        .permitAll();
  }
}

2.2.3 Oauth2 验证

启动应用,进行Oauth2 认证服务进行验证
Oauth2 密码模式验证

  • 使用密码请求该地址获取访问令牌:http://localhost:10001/oauth/token
  • 使用Basic认证通过client_id和client_secret构造一个Authorization头信息;


    认证客户端头信息
  • 在body中添加以下参数信息,通过POST请求获取访问令牌;


    密码模式获取令牌
{
    "access_token": "a690d4e6-185f-4d1d-bc62-0067bd8b6ec9",
    "token_type": "bearer",
    "refresh_token": "55a04005-e2d9-44df-99df-01b57429d424",
    "expires_in": 3599,
    "scope": "all"
}

2.3、Spring Security oauth2 授权认证核心源码分析

OAuth2 授权认证大致可以分为两步:

  • 客户端认证Filter拦截/oauth/token请求,对授权参数传递的client_id和client_secret进行认证,认证通过继续访问/oauth/token端点;
  • /oauth/token端点进行授权认证。

2.3.1 /oauth/token 认证核心处理流程图

2.3.2 TokenEndpoint(/oauth/token) 认证源码分析

@RequestMapping(
    value = {"/oauth/token"},
    method = {RequestMethod.POST}
  )
  public ResponseEntity<OAuth2AccessToken> postAccessToken(Principal principal, @RequestParam Map<String, String> parameters) throws HttpRequestMethodNotSupportedException {
    if (!(principal instanceof Authentication)) {
      throw new InsufficientAuthenticationException("There is no client authentication. Try adding an appropriate authentication filter.");
    } else {
      //1. 获取clientId
      String clientId = this.getClientId(principal);
      //2. 根据客户端id加载客户端信息
      ClientDetails authenticatedClient = this.getClientDetailsService().loadClientByClientId(clientId);
      //3. 根据客户端信息和请求参数组装TokenRequest
      TokenRequest tokenRequest = this.getOAuth2RequestFactory().createTokenRequest(parameters, authenticatedClient);
      //4. 有没有传clientId验证
      if (clientId != null && !clientId.equals("") && !clientId.equals(tokenRequest.getClientId())) {
        throw new InvalidClientException("Given client ID does not match authenticated client");
      } else {
        if (authenticatedClient != null) {
          //5. 授权范围scope校验
          this.oAuth2RequestValidator.validateScope(tokenRequest, authenticatedClient);
        }
        //6. grant_type是否存在值,对应四种授权模式和刷新token
        if (!StringUtils.hasText(tokenRequest.getGrantType())) {
          throw new InvalidRequestException("Missing grant type");
        //是否简化模式
        } else if (tokenRequest.getGrantType().equals("implicit")) {
          throw new InvalidGrantException("Implicit grant type not supported from token endpoint");
        } else {
          //是否是授权码模式
          if (this.isAuthCodeRequest(parameters) && !tokenRequest.getScope().isEmpty()) {
            this.logger.debug("Clearing scope of incoming token request");
            tokenRequest.setScope(Collections.emptySet());
          }
         //是否刷新令牌
          if (this.isRefreshTokenRequest(parameters)) {
            tokenRequest.setScope(OAuth2Utils.parseParameterList((String)parameters.get("scope")));
          }
          //7. 授权控制,并返回AccessToken
          OAuth2AccessToken token = this.getTokenGranter().grant(tokenRequest.getGrantType(), tokenRequest);
          if (token == null) {
            throw new UnsupportedGrantTypeException("Unsupported grant type: " + tokenRequest.getGrantType());
          } else {
            return this.getResponse(token);
          }
        }
      }
    }
  }

2.4 资源服务器

2.4.1 资源服务器配置

资源服务器(可以与授权服务器或单独的应用程序相同)提供受OAuth2令牌保护的资源。Spring OAuth提供了实现此保护的Spring Security身份验证过滤器。您可以@EnableResourceServer在@Configuration类上将其打开,并使用进行配置(根据需要)ResourceServerConfigurer。可以配置以下功能:

  • tokenServices:定义令牌服务(的实例ResourceServerTokenServices)的bean 。
  • resourceId:资源的ID(可选,但建议使用,并且将由auth服务器验证(如果存在))。
  • 资源服务器的其他扩展点(例如,tokenExtractor用于从传入请求中提取令牌)
  • 请求受保护资源的匹配器(默认为全部)
  • 受保护资源的访问规则(默认为普通的“已认证”)
  • HttpSecuritySpring Security中配置程序允许的受保护资源的其他自定义

@EnableResourceServer注释添加类型的过滤器OAuth2AuthenticationProcessingFilter 自动Spring Security的过滤器链。
代码清单:

@Configuration
@EnableResourceServer
public class Oauth2SourceConfig {

   //配置资源url保护策略
  @Override
  public void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
        .anyRequest()
        .authenticated()
        .and()
        .requestMatchers()
        .antMatchers("/user/**");//配置需要保护的资源路径
  }
  
 //自定义资源保护令牌策略
 public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
       resources.tokenStore(tokenStore);
  }
}

2.4.2 使用令牌获取受保护资源

2.4.3 源码分析

2.4.3.1 OAuth2AuthenticationProcessingFilter

资源服务认证入口Filter

public class OAuth2AuthenticationProcessingFilter implements Filter, InitializingBean {
  //省略......
  public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
    boolean debug = logger.isDebugEnabled();
    HttpServletRequest request = (HttpServletRequest)req;
    HttpServletResponse response = (HttpServletResponse)res;

    try {
      //1. 从BearerTokenExtractor 获取Authentication 信息
      Authentication authentication = this.tokenExtractor.extract(request);
      if (authentication == null) {
        if (this.stateless && this.isAuthenticated()) {
          if (debug) {
            logger.debug("Clearing security context.");
          }

          SecurityContextHolder.clearContext();
        }

        if (debug) {
          logger.debug("No token in request, will continue chain.");
        }
      } else {
        request.setAttribute(OAuth2AuthenticationDetails.ACCESS_TOKEN_VALUE, authentication.getPrincipal());
        if (authentication instanceof AbstractAuthenticationToken) {
          AbstractAuthenticationToken needsDetails = (AbstractAuthenticationToken)authentication;
          needsDetails.setDetails(this.authenticationDetailsSource.buildDetails(request));
        }
        //2. OAuth2AuthenticationManager 进行token认证
        Authentication authResult = this.authenticationManager.authenticate(authentication);
        if (debug) {
          logger.debug("Authentication success: " + authResult);
        }
        //3. 将认证结果放置SecurityContextHolder上下文
        this.eventPublisher.publishAuthenticationSuccess(authResult);
        SecurityContextHolder.getContext().setAuthentication(authResult);
      }
    } catch (OAuth2Exception var9) {
      SecurityContextHolder.clearContext();
      if (debug) {
        logger.debug("Authentication request failed: " + var9);
      }

      this.eventPublisher.publishAuthenticationFailure(new BadCredentialsException(var9.getMessage(), var9), new PreAuthenticatedAuthenticationToken("access-token", "N/A"));
      this.authenticationEntryPoint.commence(request, response, new InsufficientAuthenticationException(var9.getMessage(), var9));
      return;
    }

    chain.doFilter(request, response);
  }

  //省略......
}

2.4.3.2 BearerTokenExtractor

从请求 Header中获取token

protected String extractHeaderToken(HttpServletRequest request) {
    Enumeration headers = request.getHeaders("Authorization");

    String value;
    do {
      if (!headers.hasMoreElements()) {
        return null;
      }

      value = (String)headers.nextElement();
    } while(!value.toLowerCase().startsWith("Bearer".toLowerCase()));

    String authHeaderValue = value.substring("Bearer".length()).trim();
    request.setAttribute(OAuth2AuthenticationDetails.ACCESS_TOKEN_TYPE, value.substring(0, "Bearer".length()).trim());
    int commaIndex = authHeaderValue.indexOf(44);
    if (commaIndex > 0) {
      authHeaderValue = authHeaderValue.substring(0, commaIndex);
    }

    return authHeaderValue;
  }

2.4.3.3 OAuth2AuthenticationManager

资源服务认证token校验实现
程序片段:

public Authentication authenticate(Authentication authentication) throws AuthenticationException {
    if (authentication == null) {
      throw new InvalidTokenException("Invalid token (token not found)");
    } else {
      String token = (String)authentication.getPrincipal();
      //1. 从验证token存储介质获取请求传递的Access Token获取对应的验证信息
      OAuth2Authentication auth = this.tokenServices.loadAuthentication(token);
      if (auth == null) {
        throw new InvalidTokenException("Invalid token: " + token);
      } else {
        //2. 验证token并加载验证信息
        Collection<String> resourceIds = auth.getOAuth2Request().getResourceIds();
        if (this.resourceId != null && resourceIds != null && !resourceIds.isEmpty() && !resourceIds.contains(this.resourceId)) {
          throw new OAuth2AccessDeniedException("Invalid token does not contain resource id (" + this.resourceId + ")");
        } else {
          this.checkClientDetails(auth);
          if (authentication.getDetails() instanceof OAuth2AuthenticationDetails) {
            OAuth2AuthenticationDetails details = (OAuth2AuthenticationDetails)authentication.getDetails();
            if (!details.equals(auth.getDetails())) {
              details.setDecodedDetails(auth.getDetails());
            }
          }

          auth.setDetails(authentication.getDetails());
          auth.setAuthenticated(true);
          return auth;
        }
      }
    }
  }

3、OAuth2 扩展

3.1 自定义异常处理

3.1.1 自定义授权端点处理异常

授权服务器中的错误处理使用标准的Spring MVC功能,即@ExceptionHandler端点本身中的方法。但是其原生的异常信息可能与我们实际使用的异常处理不一致,需要进行转义。可以自定义WebResponseExceptionTranslator,想授权端点提供异常处理,这是更改响应异常处理的最佳方法。

//省略
@Autowired
  private WebResponseExceptionTranslator webResponseExceptionTranslator;

  /**
   * 自定义授权服务配置
   * 使用密码模式需要配置
   */
  @Override
  public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
    endpoints.authenticationManager(authenticationManager)
        .userDetailsService(userService)
        .exceptionTranslator(webResponseExceptionTranslator);//

  }
//省略

/**
 * 实现WebResponseExceptionTranslator接口,自定义授权端点异常处理
 */
@Component
public class CustomOAuth2WebResponseExceptionTranslator implements WebResponseExceptionTranslator {
    private ThrowableAnalyzer throwableAnalyzer = new DefaultThrowableAnalyzer();

    @Override
    public ResponseEntity<OAuth2Exception> translate(Exception e) throws Exception {
        Throwable[] causeChain = this.throwableAnalyzer.determineCauseChain(e);
        Exception ase = (OAuth2Exception)this.throwableAnalyzer.getFirstThrowableOfType(
            OAuth2Exception.class, causeChain);
        if (ase != null) {
            return this.handleOAuth2Exception((OAuth2Exception)ase);
        }
        ase = (AuthenticationException)this.throwableAnalyzer.getFirstThrowableOfType(
            AuthenticationException.class, causeChain);
        if (ase != null) {
            return this.handleOAuth2Exception(new UnauthorizedException(e.getMessage(), e));
        }
        ase = (AccessDeniedException)this.throwableAnalyzer.getFirstThrowableOfType(
            AccessDeniedException.class, causeChain);
        if (ase instanceof AccessDeniedException) {
            return this.handleOAuth2Exception(new ForbiddenException(ase.getMessage(), ase));
        }
        ase = (HttpRequestMethodNotSupportedException)this.throwableAnalyzer.getFirstThrowableOfType(HttpRequestMethodNotSupportedException.class, causeChain);
        if(ase instanceof HttpRequestMethodNotSupportedException){
            return this.handleOAuth2Exception(new MethodNotAllowed(ase.getMessage(), ase));
        }

        return this.handleOAuth2Exception(new UnsupportedResponseTypeException("服务内部错误", e));
    }

    private ResponseEntity<OAuth2Exception> handleOAuth2Exception(OAuth2Exception e) throws IOException {
        int status = e.getHttpErrorCode();
        HttpHeaders headers = new HttpHeaders();
        headers.set("Cache-Control", "no-store");
        headers.set("Pragma", "no-cache");
        if (status == HttpStatus.UNAUTHORIZED.value() || e instanceof InsufficientScopeException) {
            headers.set("WWW-Authenticate", String.format("%s %s", "Bearer", e.getSummary()));
        }
        CustomOauthException exception = new CustomOauthException(e.getMessage(),e);
        ResponseEntity<OAuth2Exception> response = new ResponseEntity(exception, headers, HttpStatus.valueOf(status));
        return response;
    }
//省略

3.1.2 自定义匿名用户访问无权限资源时的异常

当访问未纳入Oauth2保护资源或者访问授权端点时客户端验证失败,抛出异常,AuthenticationEntryPoint. Commence(..)就会被调用。这个对应的代码在ExceptionTranslationFilter中,当ExceptionTranslationFilter catch到异常后,就会间接调用AuthenticationEntryPoint。默认使用LoginUrlAuthenticationEntryPoint处理异常,当抛出依次LoginUrlAuthenticationEntryPoint会将异常呈现给授权服务器默认的Login视图。

  • 访问未纳入Oauth2资源管理的接口
    当访问未纳入Oauth2资源管理的接口时,因为应用接入安全框架,因此依旧会进行权限验证,当用户无权访问时会有ExceptionTranslationFilter 拦截异常并将异常呈现到默认的登录视图提示用户登录:


    未受保护资源异常
  • 调用授权端点,客户端校验失败
    当调用授权端点(/oauth/token)时,根据前面的源码我们知道在授权认证前,会先通过客户端验证Filter进行客户端验证,当客户端验证失败会抛出异常并由ExceptionTranslationFilter 拦截,将异常呈现给默认的登录视图:

    客户端验证失败

源码分析:

//顶层授权认证异常处理Point 
package org.springframework.security.web;

import ...

public interface AuthenticationEntryPoint {
    void commence(HttpServletRequest var1, HttpServletResponse var2, AuthenticationException var3) throws IOException, ServletException;
}

当ExceptionTranslationFilter catch到异常后,就会间接调用AuthenticationEntryPoint。

package org.springframework.security.web.access;

import ...
public class ExceptionTranslationFilter extends GenericFilterBean {
  //省略......
  public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
    HttpServletRequest request = (HttpServletRequest)req;
    HttpServletResponse response = (HttpServletResponse)res;

    try {
      chain.doFilter(request, response);
      this.logger.debug("Chain processed normally");
    } catch (IOException var9) {
      throw var9;
    } catch (Exception var10) {
      Throwable[] causeChain = this.throwableAnalyzer.determineCauseChain(var10);
      RuntimeException ase = (AuthenticationException)this.throwableAnalyzer.getFirstThrowableOfType(AuthenticationException.class, causeChain);
      if (ase == null) {
        ase = (AccessDeniedException)this.throwableAnalyzer.getFirstThrowableOfType(AccessDeniedException.class, causeChain);
      }

      if (ase == null) {
        if (var10 instanceof ServletException) {
          throw (ServletException)var10;
        }

        if (var10 instanceof RuntimeException) {
          throw (RuntimeException)var10;
        }

        throw new RuntimeException(var10);
      }

      if (response.isCommitted()) {
        throw new ServletException("Unable to handle the Spring Security Exception because the response is already committed.", var10);
      }
      //异常处理,间接调用AuthenticationEntryPoint.commence
      this.handleSpringSecurityException(request, response, chain, (RuntimeException)ase);
    }

  }

  //省略......

  private void handleSpringSecurityException(HttpServletRequest request, HttpServletResponse response, FilterChain chain, RuntimeException exception) throws IOException, ServletException {
    if (exception instanceof AuthenticationException) {
      this.logger.debug("Authentication exception occurred; redirecting to authentication entry point", exception);
      this.sendStartAuthentication(request, response, chain, (AuthenticationException)exception);
    } else if (exception instanceof AccessDeniedException) {
      Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
      if (!this.authenticationTrustResolver.isAnonymous(authentication) && !this.authenticationTrustResolver.isRememberMe(authentication)) {
        this.logger.debug("Access is denied (user is not anonymous); delegating to AccessDeniedHandler", exception);
        this.accessDeniedHandler.handle(request, response, (AccessDeniedException)exception);
      } else {
        this.logger.debug("Access is denied (user is " + (this.authenticationTrustResolver.isAnonymous(authentication) ? "anonymous" : "not fully authenticated") + "); redirecting to authentication entry point", exception);
        this.sendStartAuthentication(request, response, chain, new InsufficientAuthenticationException(this.messages.getMessage("ExceptionTranslationFilter.insufficientAuthentication", "Full authentication is required to access this resource")));
      }
    }

  }

////异常处理,间接调用AuthenticationEntryPoint.commence
  protected void sendStartAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, AuthenticationException reason) throws ServletException, IOException {
    SecurityContextHolder.getContext().setAuthentication((Authentication)null);
    this.requestCache.saveRequest(request, response);
    this.logger.debug("Calling Authentication entry point.");
    this.authenticationEntryPoint.commence(request, response, reason);
  }

  //省略......

//默认的异常处理,会将异常呈现给默认的Login视图
package org.springframework.security.web.authentication;

import ...

public class LoginUrlAuthenticationEntryPoint implements AuthenticationEntryPoint, InitializingBean {
  //省略...

  public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
    String redirectUrl = null;
    if (this.useForward) {
      if (this.forceHttps && "http".equals(request.getScheme())) {
        redirectUrl = this.buildHttpsRedirectUrlForRequest(request);
      }

      if (redirectUrl == null) {
        String loginForm = this.determineUrlToUseForThisRequest(request, response, authException);
        if (logger.isDebugEnabled()) {
          logger.debug("Server side forward to: " + loginForm);
        }

        RequestDispatcher dispatcher = request.getRequestDispatcher(loginForm);
        dispatcher.forward(request, response);
        return;
      }
    } else {
      redirectUrl = this.buildRedirectUrlToLoginPage(request, response, authException);
    }

    this.redirectStrategy.sendRedirect(request, response, redirectUrl);
  }

  //省略

默认的视图呈现异常肯定不符合我们实际的应用,因此需要多此类异常进行自定义处理。

package com.easy.mall.exception;

import ...

@Component
@AllArgsConstructor
public class CustomAuthExceptionEntryPoint implements AuthenticationEntryPoint {

  @Override
  public void commence(HttpServletRequest httpServletRequest,
      HttpServletResponse response, AuthenticationException e)
      throws IOException, ServletException {
    response.setCharacterEncoding(StandardCharsets.UTF_8.name());
    response.setContentType(MediaType.APPLICATION_JSON_VALUE);
    CommonResult<String> result = CommonResult.failed();
    result.setCode(HttpStatus.HTTP_UNAUTHORIZED);
    if (e != null) {
      result.setMessage("unauthorized");
      result.setData(e.getMessage());
    }
    response.setStatus(HttpStatus.HTTP_UNAUTHORIZED);
    PrintWriter printWriter = response.getWriter();
    printWriter.append(JSONObject.toJSONString(result));
  }
}

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

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

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

  @Override
  public void configure(HttpSecurity http) throws Exception {
    http.csrf()
        .disable()
        .authorizeRequests()
        .antMatchers("/oauth/**", "/login/**", "/logout/**")
        .permitAll()
        .anyRequest()
        .authenticated()
        .and()
        .formLogin()
        .permitAll();
    //web 安全控制添加注册自定义的错误处理
    http.exceptionHandling().authenticationEntryPoint(new CustomAuthExceptionEntryPoint());
  }
}
  • 自定义异常处理后的效果
客户端验证失败
访问未纳入OAuth2受保护资源接口

3.1.3 自定义受OAuth2令牌保护的资源认证失败异常

受OAuth2令牌保护的资源无权限访问异常时,异常由原生的Oauth2authenticationentrypoint处理,但是其原生的异常信息可能与我们实际使用的异常处理不一致,需要进行转义。

  • 原生的异常信息响应:
{
    "error": "invalid_token",
    "error_description": "Invalid access token: 1"
}
  • 自定义异常
@Configuration
@EnableResourceServer
public class Oauth2SourceConfig extends ResourceServerConfigurerAdapter {

  @Override
  public void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
        .anyRequest()
        .authenticated()
        .and()
        .requestMatchers()
        .antMatchers("/user/**");//配置需要保护的资源路径
  }

  @Override
  public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
    resources.authenticationEntryPoint(new CustomAuthExceptionEntryPoint());//自定义受令牌保护资源服务异常处理
  }
}
  • 自定义异常处理效果


{
    "code": 401,
    "data": "Invalid access token: 1",
    "message": "unauthorized"
}

3.1.4 密码认证自定义异常信息

思路

通过上一章源码分析知道,Oauth2.0账号密码认证交由AuthenticationManager进行处理,其认证链路AuthenticationManager->ProviderManager->AuthenticationProvider->AbstractUserDetailsAuthenticationProvider->DaoAuthenticationProvider,其中AbstractUserDetailsAuthenticationProvider和DaoAuthenticationProvider类是密码认证得实际处理类,当密码认证异常时,其返回得异常信息并不满足我们实际业务需求。需要根据业务需求进行重构。重构代码主要是重新实现:

  • AbstractUserDetailsAuthenticationProvider.authenticate
  • DaoAuthenticationProvider.additionalAuthenticationChecks

代码清单:

  1. CustomAuthenticationProvider 继承DaoAuthenticationProvider,重写密码认证逻辑
@Slf4j
public class CustomAuthenticationProvider extends DaoAuthenticationProvider {

  private UserDetailsChecker preAuthenticationChecks = new CustomAuthenticationProvider.DefaultPreAuthenticationChecks();
  private UserDetailsChecker postAuthenticationChecks = new CustomAuthenticationProvider.DefaultPostAuthenticationChecks();
  private boolean forcePrincipalAsString = false;

  public CustomAuthenticationProvider() {
  }

  private UserCache userCache = new NullUserCache();


  /**
   * 重新密码校验实现-自定义异常处理
   * @param userDetails
   * @param authentication
   * @throws AuthenticationException
   */
  @Override
  protected void additionalAuthenticationChecks(UserDetails userDetails,
      UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
    if (authentication.getCredentials() == null) {
      this.logger.debug("Authentication failed: no credentials provided");
      throw new BadCredentialsException(Resources.getMessage("STATUSCODE_21004","can not get credentials."));
    } else {
      String presentedPassword = authentication.getCredentials().toString();
      PasswordEncoder passwordEncoder = SpringContextUtil.getBean("bCryptPasswordEncoder");
      if (!passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
        this.logger.debug("Authentication failed: password does not match stored value");
        throw new BadCredentialsException(Resources.getMessage("STATUSCODE_21001","username or password error."));
      }
    }
  }

  /**
   * 重新认证核心方法-自定义返回异常
   * @param authentication
   * @return
   * @throws AuthenticationException
   */
  @Override
  public Authentication authenticate(Authentication authentication) throws AuthenticationException {

    Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication, () -> {
      return this.messages.getMessage("CustomAuthenticationProvider.onlySupports", "Only UsernamePasswordAuthenticationToken is supported");
    });
    String username = authentication.getPrincipal() == null ? "NONE_PROVIDED" : authentication.getName();
    boolean cacheWasUsed = true;
    UserDetails user = this.userCache.getUserFromCache(username);
    if (user == null) {
      cacheWasUsed = false;

      try {
        user = this.retrieveUser(username, (UsernamePasswordAuthenticationToken)authentication);
      } catch (UsernameNotFoundException var6) {
        this.logger.debug("User '" + username + "' not found");
        if (this.hideUserNotFoundExceptions) {
          throw new BadCredentialsException(Resources.getMessage("STATUSCODE_21000","account not exist!"));
        }

        throw var6;
      }

      Assert.notNull(user, "retrieveUser returned null - a violation of the interface contract");
    }

    try {
      this.preAuthenticationChecks.check(user);
      this.additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken)authentication);
    } catch (AuthenticationException var7) {
      if (!cacheWasUsed) {
        throw var7;
      }

      cacheWasUsed = false;
      user = this.retrieveUser(username, (UsernamePasswordAuthenticationToken)authentication);
      this.preAuthenticationChecks.check(user);
      this.additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken)authentication);
    }

    this.postAuthenticationChecks.check(user);
    if (!cacheWasUsed) {
      this.userCache.putUserInCache(user);
    }

    Object principalToReturn = user;
    if (this.forcePrincipalAsString) {
      principalToReturn = user.getUsername();
    }

    return this.createSuccessAuthentication(principalToReturn, authentication, user);

  }

  private class DefaultPostAuthenticationChecks implements UserDetailsChecker {
    private DefaultPostAuthenticationChecks() {
    }

    public void check(UserDetails user) {
      if (!user.isCredentialsNonExpired()) {
        log.debug("User account credentials have expired");
        throw new CredentialsExpiredException(Resources.getMessage("STATUSCODE_21005","user credentials have expired."));
      }
    }
  }

  private class DefaultPreAuthenticationChecks implements UserDetailsChecker {
    private DefaultPreAuthenticationChecks() {
    }

    public void check(UserDetails user) {
      if (!user.isAccountNonLocked()) {
        CustomAuthenticationProvider.this.logger.debug("User account is locked");
        throw new LockedException(Resources.getMessage("STATUSCODE_21006","user account is locked."));
      } else if (!user.isEnabled()) {
        CustomAuthenticationProvider.this.logger.debug("User account is disabled");
        throw new DisabledException(Resources.getMessage("STATUSCODE_21002","user is disabled."));
      } else if (!user.isAccountNonExpired()) {
        CustomAuthenticationProvider.this.logger.debug("User account is expired");
        throw new AccountExpiredException(Resources.getMessage("STATUSCODE_21008","user account has expired."));
      }
    }
  }

}

3.1.5 Security自定义异常分析总结

根据上述一系列源码分析,我们知道Security是通过一系列Filter过滤链实现授权认证,不同情况和场景其过滤链不一样,因此当出现异常也通常由不同的异常处理器进行处理,因此需要针对不同情况进行自定义处理。

附录

  • 构造Basic Auth认证头
 /**
   * 构造Basic Auth认证头信息
   * 
   * @return
   */
  private String getHeader() {
    String auth = APP_KEY + ":" + SECRET_KEY;
    byte[] encodedAuth = Base64.encodeBase64(auth.getBytes(Charset.forName("US-ASCII")));
    String authHeader = "Basic " + new String(encodedAuth);
    return authHeader;
  }
  1. 修改WEB安全配置,引入自定义密码认证处理器
@Configuration
@EnableWebSecurity
@Import({CustomAuthenticationEntryPoint.class, CustomAccessDeniedHandler.class})
public class SecurityBrowserConfig extends WebSecurityConfigurerAdapter {

    //省略

    /**
     * 定义自定义密码认证处理器
     * @return
     */
    @Bean
    CustomAuthenticationProvider customAuthenticationProvider() {
        CustomAuthenticationProvider customAuthenticationProvider = new CustomAuthenticationProvider();
        customAuthenticationProvider.setUserDetailsService(userDetailsService());
        return customAuthenticationProvider;
    }

    /**
     * 将自定义密码认证处理器注册到AuthenticationManager
     * @return
     * @throws Exception
     */
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {

        ProviderManager manager = new ProviderManager(
            Arrays.asList(customAuthenticationProvider()));
        return manager;
    }

}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容