AFN中的鉴权

1. AFN 概览

作为 iOS 上最知名且最广泛使用的网络库,AFN 到底做了什么?

主要流程:

  1. 发起请求;
  2. 请求序列化;
  3. 安全策略应用;
  4. 响应序列化;
  5. 返回请求;

使用:

  1. 继承 AFHTTPSessionManager,创建自定义的请求对象;
  2. 设置安全策略;
  3. 发送 post、get 请求;
  4. 回调中处理结果;
类结构和请求流程

本文主要研究 AFN 的鉴权流程;

2. 鉴权的级别

鉴权按照级别分为会话级别的鉴权和任务级别的鉴权。会话建立之后,其鉴权适用于这个 Session 发出的所有 Task,而每个不同的 Task 则可能会发出不同类型的鉴权。两个级别最典型的代表是 TLS(HTTPS)鉴权和 Basic Challenge (基础鉴权)。HTTPS 鉴权通过单向验证或者双向验证的方式完成鉴权,而基础鉴权一般用于访问特定的文件或者预,需要输入用户名和密码;

常见鉴权级别的划分,以及在 iOS 中对应的常量:

认证方式

在 HTTPS 未完全普及之前,HTTP 采用基础认证、摘要认证、表单认证、Window 统一认证(NTLM/Kerberos)等方式来确认身份。但是这些认证方式都没有 HTTPS 中的 SSL/TLS 认证安全,所以随着 HTTPS 的普及,这些认证方法也基本不再使用;

3. AFN 鉴权级别的处理

iOS 中分为 Session 和 Task 级别的 didReceiveChallenge 方法,分别来执行上文中涉及到的会话级别的认证和任务级别的认证:

OverView
Determine the Appropriate Delegate Method

总结:

  1. Task 和 Session 的代理方法分别处理两个级别的鉴权逻辑;
  2. Task 如果未实现则会调用 Session 的代理方法;

官方文档:Handling an Authentication Challenge

而 AFN 通过 respondsToSelector 方法,根据业务层是否实现 sessionDidReceiveAuthenticationChallenge 这个 block 来决定是否需要调用 Session 级别的 didReceiveChallenge 方法,为用户自定义 Session 级别的鉴权预留了口子。如果用户没有实现,则将所有的鉴权类型在 Task 中来执行:

session的处理

最终流程走向:

安全级别

既然 Task 的代理方法未实现时,都会走到 Session 的代理方法,那为何 AFN 还要多此一举将所有的鉴权在 Task 的代理方法中进行呢?可能两个代理方法的调用场景和预期不一样?暂未验证,不保证正确性~~~

4. 自定义认证逻辑(基础认证)

基础认证即 realm 认证,当访问的网站添加了基础认证时,需要访问者提供用户名和密码,此时 challenge 中的对应的枚举类型为 NSURLAuthenticationMethodHTTPBasic

随着证书成本的降低, TLS 的普及,当前 HTTP 中都使用 TLS 认证,即:HTTPS。但是 AFN 仍然通过 authenticationChallengeHandler 保留了基础认证的逻辑的逻辑,从 AFN 的注释中可以了解到,AFN 会根据 handler 的 return 值来进行四种处理逻辑:

自定义认证逻辑
  1. return nil:此时该种类的认证逻辑全权交由业务上层处理,AFN 不负责 didReceiveChallenge 代理方法中 completionHandler(disposition, credential) 的调用,业务层需要自己调用。此种情况适用于业务层需要弹出交互 UI 供用户填写认证信息(用户名、密码)的场景,因为是异步的,所以必须业务层调用 completionHandler;

  2. return error:业务层处理此时该种类的认证时出错,AFN 直接调用completionHandler(cancel, nil) 进行认证的取消操作;

  3. return NSURLCredential:此时 AFN 会将 Credential(包含认证信息,如用户名和密码) 上传以供服务端认证。该场景适用于客户端拥有认证信息,不需要同用户进行交互的场景;

  4. return NSNumber:此时会根据 disposition 的类型进行处理,如果是 Default 且为单向认证中的客户端认证服务端,则会进入 securityPolicy 的认证逻辑,否则会直接取消或者拒绝认证;

如果未实现 authenticationChallengeHandler ,则 AFN 全权根据 securityPolicy 中的策略来处理认证。需要注意的是 AFN 只实现了单向认证并和锁定认证配合进行;

自定义认证处理时首先需要通过 challenge.protectionSpace.authenticationMethod 来判断 challenge 的类型是否是自己需要处理的类型;

基础认证相关文章:Nginx配置基础认证

5. 无效证书

AFN 中通过 allowInvalidCertificates 参数让上层决定是否信任无效证书,如过期、吊销、证书链无效等。在 AFN 接管鉴权流程时,存在两种场景:

  1. 单向认证(无锁定模式)

此时,在客户端对服务端进行校验时,如果 allowInvalidCertificates 为 NO,对鉴权逻辑影响不大。如果为 YES,则会无条件信任服务端的证书,其逻辑大概等同以下代码:

- (void)URLSession:(NSURLSession *)session
              task:(NSURLSessionTask *)task
didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge
 completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition disposition, NSURLCredential *credential))completionHandler {
    if ([challenge.protectionSpace.authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust]) {
        disposition = NSURLSessionAuthChallengeUseCredential;
        credential = [NSURLCredential credentialForTrust:challenge.protectionSpace.serverTrust];
        completionHandler(disposition, credential);
    } 
    ...其他代码...
}
  1. 锁定模式

此时如果 allowInvalidCertificates = YES,则会直接进入锁定认证的逻辑。如果为 NO,则先按照 securityPolicy 中既定的策略对服务端证书进行校验,如果失败则按照证书校验失败的结果返回,既定策略校验成功则继续锁定认证,其代码如下:

if (self.SSLPinningMode == AFSSLPinningModeNone) {
    // 非证书锁定认证(不需要业务层提供证书)
    // 如果允许无效证书则直接返回YES
    // 不允许无效证书,则根据上文中的policy走证书认证的流程
    return self.allowInvalidCertificates || AFServerTrustIsValid(serverTrust);
} else if (!self.allowInvalidCertificates && !AFServerTrustIsValid(serverTrust)) {
    // 需要进行证书有效性校验,且校验失败
    return NO;
}
...后面才开始锁定认证逻辑...

需要注意的是:

  • 是否验证域名和是否为证书锁定认证模式没有任何关系,只是 Apple 规定/建议,如果是自签名证书且要认证域名,则应当使用锁定证书的模式进行认证;

而 AFN 对 Apple 的该逻辑进行了实现;

具体注释:

AFN自签名证书注释

上述代码发生的情况:

  1. 存在域名且需要验证域名;
  2. allowInvalidCertificates = YES,即当前采用自签名证书,无需对信任链进行验证;
  3. 不需要进行锁定认证;

而 Apple 和 AFN 都规定,如果使用自签名证书,则应当将自己的锁定证书添加到信任链中,并采用锁定认证模式进行验证,否则就会走入上述代码,从而引发报错;

6. 单向认证

对于单项认证,AFN 只做了一个工作:是否验证域名。这个功能通过 validateDomainName 来设置,默认为 YES;

AFN 早期版本默认是 X509 认证,即只验签,不认证域名,存在证书被劫持/替换的风险,该重大漏洞被曝出后 AFN 对其进行了修复,这里不再赘述。

我们可以通过 SecTrustCopyPolicies 方法在 AFN 调用 SecTrustSetPolicies 之前获取 SecTrustRef 中默认的认证策略来确定 Apple 中的默认认证策略是否包含域名校验,代码如下:

CFArrayRef defaultPolicies = NULL;
// 默认策略
SecTrustCopyPolicies(serverTrust, &defaultPolicies);
// x509策略(不验证域名)
SecPolicyRef x509Policy = SecPolicyCreateBasicX509();

结果:

默认认证策略

x509 策略:

x509策略

总结:x509 策略相对简单,不验证域名,而默认的策略为 SSLPolicy 会校验请求的域名和证书的域名是否一致;

这里可以看到,Apple 在系统层面已经实现了大多数证书校验的逻辑,并不需要开发者去里学习复杂的证书体系和 OpenSSL 等工具;

7. 锁定认证

锁定认证的概念不再赘述,详见:iOS中的HTTPS认证 中的第三章节;

另外再重申,AFN 只实现了单向认证中的客户端对服务端证书的校验逻辑,所以 AFN 的锁定认证也是基于 [challenge.protectionSpace.authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust] == YES 这个前提的,即:仅支持单向认证下的锁定认证。

AFN 中的锁定认证可以用于

  1. 服务端使用了非正常的证书(如自签名证书、过期、吊销等):不想直接无脑信任服务端证书而不做任何校验,所以使用锁定认证来保证安全;
  2. 服务端使用正常的证书(CA下发):避免客户端证书链被污染,起到双重保障;

猜测第一种情况是 AFN 实现锁定认证的主要原因,因为 Apple 建议如果是自签名证书,最好不要直接信任,而是应当将自签名证书添加到信任锚点中,所以 AFN 基于这一点实现了锁定认证模式,AFN 中的注释如下:

AFN-自签名证书

当为第二种情况时,先对证书按照 Policy 进行校验,校验通过再进行锁定认证,代码如下:

if (self.SSLPinningMode == AFSSLPinningModeNone) {
    // 非证书锁定认证(不需要业务层提供证书)
    // 如果允许无效证书则直接返回YES
    // 不允许无效证书,则根据上文中的policy走证书认证的流程
    return self.allowInvalidCertificates || AFServerTrustIsValid(serverTrust);
} else if (!self.allowInvalidCertificates && !AFServerTrustIsValid(serverTrust)) {
    // 需要进行证书有效性校验,且校验失败
    return NO;
}
......锁定认证逻辑......

AFN 对锁定认证中的两种模式进行了实现:

  1. 证书认证(签名认证);
  2. 公钥认证;

8. 锁定认证-证书认证(AFSSLPinningModeCertificate)

代码:

// 1. 取出 App 内嵌证书
NSMutableArray *pinnedCertificates = [NSMutableArray array];
for (NSData *certificateData in self.pinnedCertificates) {
    [pinnedCertificates addObject:(__bridge_transfer id)SecCertificateCreateWithData(NULL, (__bridge CFDataRef)certificateData)];
}

// 2. 将内嵌证书设置成锚点,忽略系统锚点
SecTrustSetAnchorCertificates(serverTrust, (__bridge CFArrayRef)pinnedCertificates);

// 3. 使用新的锚点对证书进行校验
if (!AFServerTrustIsValid(serverTrust)) {
    return NO;
}

// obtain the chain after being validated, which *should* contain the pinned certificate in the last position (if it's the Root CA)
// 4. 获取校验完毕之后的信任链
NSArray *serverCertificates = AFCertificateTrustChainForServerTrust(serverTrust);

// 5. 逆序取出信任链中的证书,如果信任链中任意证书和任意内嵌证书相同,则校验通过
for (NSData *trustChainCertificate in [serverCertificates reverseObjectEnumerator]) {
    if ([self.pinnedCertificates containsObject:trustChainCertificate]) {
        return YES;
    }
}

return NO;
  1. 取出 App 内嵌证书;
  2. 将内嵌证书设置成锚点,忽略系统锚点;
  3. 使用新的锚点对证书进行校验;
  4. 获取校验完毕之后的信任链;
  5. 逆序取出信任链中的证书,如果信任链中任意证书和任意内嵌证书相同,则校验通过;

这里的关键点有几个:

  1. 设置锚点

调用 SecTrustSetAnchorCertificates() 设置锚点之后,如果不调用 SecTrustSetAnchorCertificatesOnly() ,此时会忽略原本的信任锚点,即系统内嵌的 Root CA 证书都会忽略。所以这就达到了只信任部分证书集合的目的,规避掉了用户的信任链被污染的情况,提高了安全性。

另外,自签名证书使用系统默认的锚点,也就是系统内置的 RootCA 证书校验时是无法成功的,设置内嵌证书称为锚点之后才能成功;

  1. 对比信任链

从代码可以看出,设置锚点并校验通过后,拿到了信任链。然后循环取出信任链中的证书,如果有一个证书存在于内嵌证书数组中,则校验通过;

那么,这里有两个问题:

  1. 校验成功的标准/流程是什么?
  2. 信任链是什么?

关于第一点:
证书校验成功的标准有几个:

  1. 叶子节点域名和请求的域名一致;
  2. 叶子节点到根节点之间层层签发,父节点的公钥对子节点的签名验签成功;
  3. 证书链的根节点存在于锚点证书列表中;

其中,第三个验证规则中,一般而言根节点的证书是 Root CA 的证书,被嵌入到系统或者浏览器中,这些证书是自签名的。但是验证的过程中不一定会对这个证书进行验签(使用证书中自身公钥去验签自身的签名),更多的应该是对比操作,即校验这些证书是否存在于系统的根证书库中。

因此,猜测 AFServerTrustIsValid 中的系统函数 SecTrustEvaluate 的最后一步的逻辑可能是:

  1. 默认情况下会去系统的根证书库中进行证书对比,如果被校验的证书的根节点存在于证书库中,则校验通过;
  2. 如果使用了 SecTrustSetAnchorCertificates 来重置锚点后,则该方法只校验证书链,而不去对比根证书;

因此,AFN 中才有了上述代码中逆序对比内嵌证书列表的操作:

逆序对比内嵌证书

关于第二点(信任链是什么):

AFN 中获取信任链的方法使用了 SecTrustGetCertificateCount() 函数,而该函数在调用之前,如果未对证书进行校验,则会先执行校验操作:

SecTrustGetCertificateCount

所以,能够生成信任链中必定是按照第一点中的两个规则校验成功的;

信任链的内容则是校验的过程,举个例子,对于只有一个 intermediate CA 和 一个 Root CA 参与签发的证书而言:

  1. 如果锚点设置成 intermediate CA 证书,则证书链包含两个证书:intermediate CA + User;
  2. 如果锚点证书直接设置成叶子证书(User),那么证书链就只包含一个证书:User;
  3. 如果使用系统内嵌的证书列表作为锚点,那么证书链就包含三个证书:Root CA + intermediate CA + User;

其中需要特别注意,调用 SecTrustEvaluateWithError()进行验证时,如果 intermediate CA 不存在与服务器传送过来的证书中(即服务器只发送了叶子证书),那么验签之前会先联网下载中间证书,所以这个校验过程最好异步进行;

这里,将上述三种情况的信任链打印出来:

  1. 系统内嵌证书作为锚点:
信任链-系统内嵌证书作为锚点
  1. intermediate CA 的证书作为锚点:
信任链-CA作为锚点
  1. 叶子证书作为锚点:
叶子证书作为锚点

总之,该信任链是经过认证的信任链节点集合,而不是服务器传送过来的证书中证书的个数或者节点;

所以,AFN 中第一次校验使用的是系统根证书作为锚点,对证书进行校验。重新设置锚点之后,使用的是内嵌证书作为锚点进行校验,只要验证成功,因为锚点证书不是验签而是对比,那么信任链中必定包含内嵌证书。

锁定模式中的证书锁定其实是签名锁定,即验证两个证书的签名是否一致。但是 AFN 中直接对比证书的二进制是否相同,本质上是一样的,对比二进制省去了签名的提取,但是效率可能会降低吧;

9. 锁定认证-公钥认证(AFSSLPinningModePublicKey)

锁定模式中,和证书公钥认证相对应的就是公钥认证。因为证书内容(如更新了有效期)一旦发生变化,签名必定发生变化,但是其包含的公钥不一定发生变化。比如用户可以使用自己的秘钥对多次申请同一个域名的证书。此时,对比签名的方式就需要在 App 中重新内嵌新的证书,而如果是对比公钥,则不需要。

总之,有几种方法来减少 App 中内嵌证书的更新:

  1. 尽量不要使用叶子节点作为锚点内嵌;
  2. 尽量使用中间 CA 证书作为锚点;
  3. 尽量多内嵌一些常用的中间 CA 证书;
  4. 尽量使用公钥锁定认证的模式;

公钥认证的逻辑相对简单:

case AFSSLPinningModePublicKey: {
    // 1. 获取服务器证书中的信任链
    NSUInteger trustedPublicKeyCount = 0;
    NSArray *publicKeys = AFPublicKeyTrustChainForServerTrust(serverTrust);

    // 2. 对比信任链和内嵌证书,有一个公钥相同则验证通过
    for (id trustChainPublicKey in publicKeys) {
        for (id pinnedPublicKey in self.pinnedPublicKeys) {
            if (AFSecKeyIsEqualToKey((__bridge SecKeyRef)trustChainPublicKey, (__bridge SecKeyRef)pinnedPublicKey)) {
                trustedPublicKeyCount += 1;
            }
        }
    }
    return trustedPublicKeyCount > 0;
}

步骤:

  1. 获取服务器证书中的信任链;
  2. 对比信任链和内嵌证书,有一个公钥相同则验证通过;

这里的信任链就是使用系统内置 RootCA 进行校验完成的证书链,一般包含 Root CA + Intermediate CA + User,三层。

只要信任链中有一个公钥存在于内嵌证书中,则证明这个证书是指定的 intermediate/Root CA 签发的,或者是叶子证书;

这种方法可以直接把叶子证书内嵌,这样就能精准对比证书。即使叶子证书过期,但只要使用的是同一个公钥签发的,那么就能对比成功。至于是否使用 intermediate CA 证书内嵌,这个需要自己去权衡;

10. 总结

AFN 做了什么:

  1. 封装方面:序列化、网络状态的获取、图片下载、图片缓存;
  2. 安全方面:HTTPS认证中,实现了 ATS 默认策略,同时新增了锁定认证的两种模式的实现、自签名证书的校验(Apple建议不要直接信任自签名证书,应当使用锁定模式来验证自签名证书);
  3. 其他方面:代码优化,如使用线程组分发请求等;

个人总结:

  1. AFN 早期使用过,但是后期随着 iOS 的成熟,业务场景变得复杂,很多公司都建立起了自己的网络框架,所以整体上对 AFN 的使用较少;
  2. 但是,很多公司的代码是参考 AFN 完成或者是部分使用 AFN 源文件的。我对其中的网络状态类、序列化类、鉴权使用较多;
  3. 序列化类就是将请求或者响应的参数按照 HTTP 协议进行序列化,比如 form-data、mimeType 、header 等。其中 AFN 只支持单张图片上传,因为其对于 form-data 的构建代码不是数组的形式。我再扩展对象为数组之后,按照 HTTP 协议,构建多个 form-data 从而实现了多张图片的上传;
  4. 再到后面,项目只使用到了请求序列化的源文件,因为项目中使用了 jce 协议,在请求时需要序列化,但是对响应进行解包时采用的是自己的逻辑,使用 C++ 来实现,所以并不需要响应序列化的源文件;
  5. 鉴权逻辑中,ATS 的鉴权逻辑基本能满足大部分 App 的需求,而 AFN 实现了 ATS 的默认逻辑,即域名检测+证书链验证+TLS最低协议版本认证+摘要算法最低版本认证。AFN 在此基础上还提供了更为安全的锁定认证模式,对其中的公钥锁定和证书摘要锁定进行了实现;
  6. 另外,早期服务端存在较多的自签名证书的情况,可以使用锁定认证的模式或者直接信任的模式来完成自签名证书的验证,AFN 也是支持的;
  7. AFN 的安全级别上,有全局的设置??
  8. TAF 只做会话层的鉴权,即没有实现 Task 的 didReceiveChallenge 代理方法;

即实现了下面几种逻辑:

  1. 非正常证书-无条件信任;
  2. 非正常证书-锁定认证;
  3. 证书正常校验-无锁定认证;
  4. 证书正常校验-锁定认证;
  5. 证书正常交验时是否校验域名;
  6. 业务层全权接管;
  7. 业务层提供凭证(用户名和密码);

上述总结不需要过于纠结,死抠逻辑和细节意义不大,只需要知道 AFN 实现的内容还挺多,挺全,在自己的网络框架中可以按需取用;

11. 一句话总结

AFN 中实现了 ATS 默认的域名认证,即需要校验域名、信任链、根证书是否存在于系统证书库中等逻辑。并且实现了更简单的 X509 认证供开发者选择。

与此同时,AFN 想要实现自签名证书的校验逻辑,但是 Apple 规定最好不要直接信任自签名证书,而是应当将自签名证书添加到锚点中进行校验。AFN 则针对这个建议进行了实现,即实现了锁定认证模式中的签名认证。如此,无论是自签名证书还是正常的证书,都可以添加锁定模式进行更安全的认证,防止锚点证书列表被污染带来的安全隐患。

另外,因为锁定认证中的签名锁定模式下,可能会存在证书频繁更新的问题,此时就需要发包来解决。所以,AFN 又对锁定认证中的公钥锁定进行了实现,只对比证书中的公钥,这就允许开发者使用同一个私钥来申请多个证书,配合一些证书的使用规范,可以最大程度的规避因为证书更新而导致的发包流程。

最后,AFN 还实现了基础认证的两种主要场景:如客户端需要通过和用户交互才能拿到用户名密码时,将基础认证的逻辑完全交给业务层处理。当客户端无需交互就能拿到用户名和密码时,AFN 提供了现成的方式供业务层使用。

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

推荐阅读更多精彩内容