Spring Security OAuth2登录

Spring Security OAuth2登录

概述

OAuth 2.0 不是身份认证协议

什么是身份认证?身份认证是解决“你是谁?”的问题。身份认证会告诉应用当前用户是谁以及是否在使用此应用。实际中可能还会告诉你用户的名称,邮箱,手机号等。

如果对 OAuth 2.0 进行扩展,使得授权服务器和受保护资源发出的信息能够传达与用户以及他们的身份认证上下文有关的信息,我们就可以为客户端提供用于用户安全登录的所有信息。这种基于OAuth 2.0授权协议而构建的身份认证方式主要优点:

  • 用户在授权服务器上执行身份认证, 最终用户的原始凭据不会通过 OAuth 2.0 协议传送到客户端应用。
  • 允许用户在运行时执行同意决策。
  • 用户还可以将其他受保护 API 与他的身份信息的访问权限一起授权出去。通过一个调用,应用就可以知道用户是否已登录,如何称呼用户,用户的手机号,邮箱等。

本文我们将通过OAuth 2.0 授权码模式安全的传递授权服务用户信息,并登录到客户端应用。

本文您将学到:

  • 搭建基本的授权服务和客户端服务

  • 自定义授权服务器访问令牌,添加角色信息

  • 自定义授权服务器用户信息端点

  • 客户端服务使用GrantedAuthoritiesMapper做权限映射

  • 客户端服务自定义OAuth2UserService实现解析多层Json数据

OAuth2授权服务器

本节我们将使用Spring Authorization Server搭建一个授权服务器。除此之外我们还将会自定义access_token和自定义用户信息端点。

maven

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

<dependency>
  <groupId>org.springframework.security</groupId>
  <artifactId>spring-security-oauth2-authorization-server</artifactId>
  <version>0.3.1</version>
</dependency>

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-web</artifactId>
  <version>2.6.7</version>
</dependency>

配置

首先通过application.yml配置服务端口8080:

server:
  port: 8080


接下来我们将创建OAuth2ServerConfig配置类,定义OAuth2 授权服务所需特定Bean。首先我们注册一个OAuth2客户端:

@Bean
public RegisteredClientRepository registeredClientRepository() {
  RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
    .clientId("relive-client")
    .clientSecret("{noop}relive-client")
    .clientAuthenticationMethods(s -> {
      s.add(ClientAuthenticationMethod.CLIENT_SECRET_POST);
      s.add(ClientAuthenticationMethod.CLIENT_SECRET_BASIC);
    })
    .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
    .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
    .redirectUri("http://127.0.0.1:8070/login/oauth2/code/messaging-client-authorization-code")
    .scope(OidcScopes.PROFILE)
    .clientSettings(ClientSettings.builder()
                    .requireAuthorizationConsent(true)
                    .requireProofKey(false)
                    .build())
    .tokenSettings(TokenSettings.builder()
                   .accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED) 
                   .idTokenSignatureAlgorithm(SignatureAlgorithm.RS256)/
                   .accessTokenTimeToLive(Duration.ofSeconds(30 * 60))
                   .refreshTokenTimeToLive(Duration.ofSeconds(60 * 60))
                   .reuseRefreshTokens(true)
                   .build())
    .build();
  return new InMemoryRegisteredClientRepository(registeredClient);
}

以上将OAuth2客户端存储在内存中,如果您需要使用数据库持久化,请参考文章将JWT与Spring Security OAuth2结合使用。指定OAuth2客户端信息如下:

接下来让我们配置OAuth2授权服务其他默认配置,并对未认证的授权请求重定向到登录页面:

@Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
  OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);

  return http
    .exceptionHandling(exceptions -> exceptions.
                       authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login")))
    .build();
}


授权服务器token令牌格式使用JWT RFC 7519,所以我们需要用于令牌的签名密钥,让我们生成一个RSA密钥:

@Bean
public JWKSource<SecurityContext> jwkSource() {
  RSAKey rsaKey = Jwks.generateRsa();
  JWKSet jwkSet = new JWKSet(rsaKey);
  return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
}

static class Jwks {

  private Jwks() {
  }

  public static RSAKey generateRsa() {
    KeyPair keyPair = KeyGeneratorUtils.generateRsaKey();
    RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
    RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
    return new RSAKey.Builder(publicKey)
      .privateKey(privateKey)
      .keyID(UUID.randomUUID().toString())
      .build();
  }
}

static class KeyGeneratorUtils {

  private KeyGeneratorUtils() {
  }

  static KeyPair generateRsaKey() {
    KeyPair keyPair;
    try {
      KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
      keyPairGenerator.initialize(2048);
      keyPair = keyPairGenerator.generateKeyPair();
    } catch (Exception ex) {
      throw new IllegalStateException(ex);
    }
    return keyPair;
  }
}


接下来我们将自定义access_token 访问令牌,并在令牌中添加角色信息:

@Configuration(proxyBeanMethods = false)
public class AccessTokenCustomizerConfig {

    @Bean
    public OAuth2TokenCustomizer<JwtEncodingContext> tokenCustomizer() {
        return (context) -> {
            if (OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())) {
                context.getClaims().claims(claim -> {
                    claim.put("role", context.getPrincipal().getAuthorities().stream()
                            .map(GrantedAuthority::getAuthority).collect(Collectors.toSet()));
                });
            }
        };
    }
}

可以看到Spring Security为我们提供了OAuth2TokenCustomizer用于扩展令牌信息,我们从OAuth2TokenContext获取到当前用户信息,并从中提取Authorities权限信息添加到JWT的claim。


下面我们将创建Spring Security配置类,配置授权服务基本的认证能力。

@Configuration(proxyBeanMethods = false)
public class DefaultSecurityConfig {

    @Bean
    public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/userInfo")
                .access("hasAnyAuthority('SCOPE_profile')")
                .mvcMatchers("/userInfo")
                .access("hasAuthority('SCOPE_profile')")
                .anyRequest().authenticated()
                .and()
                .formLogin(Customizer.withDefaults())
                .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt);
        return http.build();
    }

    @Bean
    public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
        return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
    }

    @Bean
    public UserDetailsService users() {
        UserDetails user = User.withDefaultPasswordEncoder()
                .username("admin")
                .password("password")
                .roles("ADMIN")
                .build();
        return new InMemoryUserDetailsManager(user);
    }
}

在上述配置类中,我们做了以下几件事。1.启用Form认证方式;2.配置登录用户名密码;3.使用oauth2ResourceServer()配置JWT验证,并声明JwtDecoder;4.保护/userInfo端点需要profile权限进行访问。


此时我们还需要创建Controller类,用于提供给OAuth2客户端服务获取用户信息:

@RestController
public class UserInfoController {

    @PostMapping("/userInfo")
    public Map<String, Object> getUserInfo(@AuthenticationPrincipal Jwt jwt) {
        return Collections.singletonMap("data", jwt.getClaims());
    }
}

我们将用户信息使用以下JSON格式返回:

{
  "data":{
    "sub":"admin"
    ...
  }
}

OAuth2客户端服务

本节将使用Spring Security配置OAuth2客户端登录;并且我们将使用GrantedAuthoritiesMapper映射权限信息;还将通过自定义实现OAuth2UserService替换原有DefaultOAuth2UserService,用于解析多层JSON 用户信息数据。

maven

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-web</artifactId>
  <version>2.6.7</version>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-security</artifactId>
  <version>2.6.7</version>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-oauth2-client</artifactId>
  <version>2.6.7</version>  
</dependency>

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-thymeleaf</artifactId>
  <version>2.6.7</version>  
</dependency>

配置

首先我们指定客户端服务端口号8070,并配置OAuth2客户端相关信息:

server:
  port: 8070
  servlet:
    session:
      cookie:
        name: CLIENT-SESSION

spring:
  security:
    oauth2:
      client:
        registration:
          messaging-client-authorization-code:
            provider: client-provider
            client-id: relive-client
            client-secret: relive-client
            authorization-grant-type: authorization_code
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
            scope: profile
            client-name: messaging-client-authorization-code
        provider:
          client-provider:
            authorization-uri: http://127.0.0.1:8080/oauth2/authorize
            token-uri: http://127.0.0.1:8080/oauth2/token
            user-info-uri: http://127.0.0.1:8080/userInfo
            user-name-attribute: data.sub
            user-info-authentication-method: form


接下来配置Spring Security相关Bean,首先我们先启用Form表单认证和OAuth2登录能力:

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
  http.authorizeHttpRequests()
    .anyRequest()
    .authenticated()
    .and()
    .formLogin(from -> {
      from.defaultSuccessUrl("/home");
    })
    .oauth2Login(Customizer.withDefaults())
    .csrf().disable();
  return http.build();
}

这里我们指定认证成功后重定向到/home路径下。


下面我们使用GrantedAuthoritiesMapper映射用户权限:

@Bean
GrantedAuthoritiesMapper userAuthoritiesMapper() {
  //角色映射关系,授权服务器ADMIN角色对应客户端OPERATION角色
  Map<String, String> roleMapping = new HashMap<>();
  roleMapping.put("ROLE_ADMIN", "ROLE_OPERATION");
  return (authorities) -> {
    Set<GrantedAuthority> mappedAuthorities = new HashSet<>();
    authorities.forEach(authority -> {
      if (OAuth2UserAuthority.class.isInstance(authority)) {
        OAuth2UserAuthority oauth2UserAuthority = (OAuth2UserAuthority) authority;
        Map<String, Object> userAttributes = oauth2UserAuthority.getAttributes();
        List<String> role = (List) userAttributes.get("role");
        role.stream().map(roleMapping::get)
          .filter(StringUtils::hasText)
          .map(SimpleGrantedAuthority::new)
          .forEach(mappedAuthorities::add);
      }
    });
    return mappedAuthorities;
  };
}

上述将OAuth2授权服务ADMIN角色映射为客户端角色OPERATION。当然你同样可以扩展为数据库操作,那么需要你维护授权服务角色与客户端服务角色映射表,这里将不展开。

GrantedAuthoritiesMapper作为权限映射器在OAuth2登录,CAS登录,SAML和LDAP多方使用。

GrantedAuthoritiesMapperOAuth2LoginAuthenticationProvider中源码如下:

@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
    OAuth2LoginAuthenticationToken loginAuthenticationToken = (OAuth2LoginAuthenticationToken) authentication;
    //...省略部分源码
  
    /* map authorities */
    Collection<? extends GrantedAuthority> mappedAuthorities = this.authoritiesMapper
      .mapAuthorities(oauth2User.getAuthorities());
    /* map authorities */
  
    OAuth2LoginAuthenticationToken authenticationResult = new OAuth2LoginAuthenticationToken(
      loginAuthenticationToken.getClientRegistration(), loginAuthenticationToken.getAuthorizationExchange(),
      oauth2User, mappedAuthorities, accessToken, authorizationCodeAuthenticationToken.getRefreshToken());
    authenticationResult.setDetails(loginAuthenticationToken.getDetails());
    return authenticationResult;
}

所以当我们自定义实现GrantedAuthoritiesMapper后,OAuth2 登录成功后将映射后的权限信息存储在认证信息Authentication的子类OAuth2LoginAuthenticationToken中,在后续流程中需要时获取。


接下来将实现OAuth2UserService自定义DefaultJsonOAuth2UserService类。当然Spring Security提供了DefaultOAuth2UserService,那么为什么不使用它呢?原因很简单,首先让我们回顾授权服务器返回用户信息格式:

{
  "data":{
    "sub":"admin"
    ...
  }
}

不错,用户信息嵌套data字段中,而DefaultOAuth2UserService处理用户信息响应时并没有处理这个格式,以下是DefaultOAuth2UserService源码:

public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        Assert.notNull(userRequest, "userRequest cannot be null");
        if (!StringUtils.hasText(userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUri())) {
            OAuth2Error oauth2Error = new OAuth2Error("missing_user_info_uri", "Missing required UserInfo Uri in UserInfoEndpoint for Client Registration: " + userRequest.getClientRegistration().getRegistrationId(), (String)null);
            throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
        } else {
            String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
            if (!StringUtils.hasText(userNameAttributeName)) {
                OAuth2Error oauth2Error = new OAuth2Error("missing_user_name_attribute", "Missing required \"user name\" attribute name in UserInfoEndpoint for Client Registration: " + userRequest.getClientRegistration().getRegistrationId(), (String)null);
                throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
            } else {
                RequestEntity<?> request = (RequestEntity)this.requestEntityConverter.convert(userRequest);
               /* 获取用户信息 */  
              ResponseEntity<Map<String, Object>> response = this.getResponse(userRequest, request);
                //在这里直接获取响应体信息,默认此userAttributes包含相关用户信息,并没有解析多层JSON
                Map<String, Object> userAttributes = (Map)response.getBody();
               /* 获取用户信息 */  
                Set<GrantedAuthority> authorities = new LinkedHashSet();
                authorities.add(new OAuth2UserAuthority(userAttributes));
                OAuth2AccessToken token = userRequest.getAccessToken();
                Iterator var8 = token.getScopes().iterator();

                while(var8.hasNext()) {
                    String authority = (String)var8.next();
                    authorities.add(new SimpleGrantedAuthority("SCOPE_" + authority));
                }

                return new DefaultOAuth2User(authorities, userAttributes, userNameAttributeName);
            }
        }
    }

而最后创建DefaultOAuth2User时,你可能会收到以下错误信息

Missing attribute 'sub' in attributes

通过上面源码,Spring Security 所希望返回的用户信息格式:

{
  "sub":"admin",
  ...
}

但是实际中,我们开发时通常会统一返回响应格式。例如:

{
  "code":200,
  "message":"success",
  "data":{
    "sub":"admin",
    ...
  }
}


下面我们是我们通过以userNameAttributeName以 . 为分割符,提取用户信息实现,以下只展示部分代码,其余代码和DefaultOAuth2UserServicey源码相同。

首先我们新建工具类JsonHelper用于解析Json

@Slf4j
public class JsonHelper {
    private static final JsonHelper.MapTypeReference MAP_TYPE = new JsonHelper.MapTypeReference();

    private static ObjectMapper mapper;

    private JsonHelper() {
    }

    static {
        mapper = new ObjectMapper();
        mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
    }

    public static JsonNode getFirstNode(final JsonNode node, final String path) {
        JsonNode resultNode = null;
        if (path != null) {
            resultNode = getElement(node, path);
        }
        return resultNode;
    }

    public static JsonNode getElement(final JsonNode json, final String name) {
        if (json != null && name != null) {
            JsonNode node = json;
            for (String nodeName : name.split("\\.")) {
                if (node != null) {
                    if (nodeName.matches("\\d+")) {
                        node = node.get(Integer.parseInt(nodeName));
                    } else {
                        node = node.get(nodeName);
                    }
                }
            }
            if (node != null) {
                return node;
            }
        }
        return null;
    }


    public static Map<String, Object> parseMap(String json) {
        try {
            return mapper.readValue(json, MAP_TYPE);
        } catch (JsonProcessingException e) {
            log.error("Cannot convert json to map");
        }
        return null;
    }

    private static class MapTypeReference extends TypeReference<Map<String, Object>> {
        private MapTypeReference() {
        }
    }
}

新建DefaultJsonOAuth2UserService实现OAuth2UserService,添加多层JSON提取用户信息逻辑:

public class DefaultJsonOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
  
    //...
  
    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        //...省略部分代码
        RequestEntity<?> request = this.requestEntityConverter.convert(userRequest);
        ResponseEntity<JsonNode> response = getResponse(userRequest, request);
        JsonNode responseBody = response.getBody();

        //多层JSON提取用户信息属性
        Map<String, Object> userAttributes = new HashMap<>();
        if (userNameAttributeName.contains(".")) {
          String firstNodePath = userNameAttributeName.substring(0, userNameAttributeName.lastIndexOf("."));
          userAttributes = this.extractUserAttribute(responseBody, firstNodePath);
          userNameAttributeName = userNameAttributeName.substring(firstNodePath.length() + 1);
        } else {
          userAttributes = JsonHelper.parseMap(responseBody.toString());
        }

        //...省略部分代码
    }
}

如您需要参考详细代码,请查阅文末源码链接获取。


最后我们创建Controller类,使用thymeleaf引擎构建首页信息,不同权限信息看到首页列表结果不同:

@Controller
public class HomeController {

    private static Map<String, List<String>> articles = new HashMap<>();

    static {
        articles.put("ROLE_OPERATION", Arrays.asList("Java"));
        articles.put("ROLE_SYSTEM", Arrays.asList("Java", "Python", "C++"));
    }

    @GetMapping("/home")
    public String home(Authentication authentication, Model model) {
        String authority = authentication.getAuthorities().iterator().next().getAuthority();
        model.addAttribute("articles", articles.get(authority));
        return "home";
    }
}

测试

我们启动服务后,访问http://127.0.0.1:8070/login, 首先使用用户名密码登录,您将会看到:

form-login-home.png

之后我们退出登录使,用OAuth2 登录,您将会看到不同信息:

oauth2-login-home.png

结论

我们使用OAuth2.0 授权协议上构建身份认证证明是可行的。但是我们不能忽略在这之间的陷阱。

  1. 令牌本身并不传递有关身份认证事件的信息。令牌可能是直接颁发给客户端的,使用的是无须用户交互的 OAuth 2.0 客户端凭据模式。

  2. 客户端都无法从访问令牌中得到关于用户及其登录状态的信息。OAuth 2.0 访问令牌的目标受众是资源服务器。(在本文中我们使用JWT访问令牌,通过自定义访问令牌信息使客户端服务获取用户权限等信息,但是OAuth2.0 协议中并没有定义访问令牌格式,我们仅是使用了JWT的特性来做到这一点。)

  3. 客户端可以出示访问令牌给资源服务获取用户信息,所以很容易就认为只要拥有一个有效的访问令牌,就能证明用户已登录,这一思路仅在某些情况下是正确的,即用户在授权服务器上完成身份认证,刚生成访问令牌的时候。(因为访问令牌有效期可能远长与身份认证会话有效期)

  4. 基于OAuth2.0的用户信息API的最大问题是,不同身份提供者实现用户信息API必然不同。用户的唯一标识可能是“user_id",也可能是“sub”。

所以我们需要统一的OAuth2.0为基础的标准身份认证协议。OpenID Connect 是一个开放标准,它定义了一种使用 OAuth 2.0 执行用户身份认证的互通方式。这将在后续文章中介绍它。

与往常一样,本文中使用的源代码可在 GitHub 上获得。

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

推荐阅读更多精彩内容