春天前销销售 - 保存一切。所有视频。所有书籍。 现在50%的折扣。

建立您的移动发展技能并保存!通过终极书籍和视频订阅,继续前进。作为春季前销销售的一部分,仅为149美元/年。

首页 iOS..& Swift Tutorials

钥匙扣服务 Swift密码的API教程

在此keychain教程中迅速在iOS上,您将学习如何与C语言API进行交互以在IOS Keychain中安全地存储密码。

4.7/5 14个评分

版本

  • 迅速 4.2,iOS 12,Xcode 10

Apple开发人员最重要的安全元素之一是 iOS..Keychain,一个专门的数据库,用于存储元数据和敏感信息。使用 钥匙链 是存储对您应用程序至关重要的小型数据的最佳方法,如秘密和密码。

与之互动是复杂的 钥匙链 直接,尤其是迅速。你必须使用 安全 框架,主要是在C的C.

有不同的swift包装器,允许您与之交互 钥匙链。苹果甚至提供一个叫做 GenericKeychain. 让你的生活更轻松。

虽然您可以轻松地使用第三方包装器与Apple提供的不友好的API接口,但是 钥匙扣服务 为开发人员工具腰带添加一个有价值的工具。

在本教程中,您将深入研究 钥匙链服务API 并学习如何创建自己的包装器,将其开发为iOS框架。

特别是,您将学习如何添加,修改,删除和搜索通用和Internet密码。此外,您将提供单元测试以验证您的代码是否正常工作。

入门

对于本教程,您将使用 SecureStore.,一个电位板iOS框架,您将在那里实现您的 钥匙链服务API.

首先下载初学项目使用 下载材料 本教程顶部或底部的按钮。一旦你下载了它,打开 securestore.xcodeproj. in Xcode.

为了让您重点关注,Starter项目有与实施您的包装器已经为您设置的一切相关。

项目的结构应该如下所示:

项目结构

你的包装器的代码生存在 SecureStore. group folder:

  • securestoreerror.swift.: Contains an enum, which represents all the possible errors your wrapper can deal with. Conforming to LocalizedError, SecureStore.Error provides localized messages describing the error and why it occurred.
  • SecurestoreQueryable.swift.: Defines a protocol with the same name as the file. SecureStore.Queryable forces the implementer to provide a query property defined as a dictionary typed as [String: Any]. Internally, your API only deals with those types of objects. More on that later.
  • SecureStore..swift.:定义您在本教程中实施的包装器。它提供了初始化程序和一堆存根方法,用于添加,更新,删除和检索密码 钥匙链. A consumer can create a wrapper’s instance by injecting some type that conforms to SecureStore.Queryable.
  • InternetProtocol.Swift.:代表您可以处理的所有可能的互联网协议值。
  • InternetauthenticationType.swift.:介绍您的包装器提供的身份验证机制。
笔记: 依赖注入 允许您编写展开和隔离功能的类。这是一个非常简单的概念的一点可怕的词。在本教程中,您将看到“注入”一词,在那里它是指将整个对象传递到初始化程序中。

还要与框架代码一起,您应该有两个其他文件夹: SecuStoreTess.testhost.。前者包含您将使用框架发货的单元测试。后者包含一个空的应用程序,您将用于测试框架API。

笔记:通常,要测试您在教程中写入的代码,请在模拟器中运行应用程序。您可以通过运行单元测试来验证您的代码是否正常运行。因此,项目中的测试主机应用程序将无法在模拟器中运行;相反,它用作对框架执行单元测试的容器。

在直接进入代码之前,请看看一些理论!

钥匙串服务概述

为什么要使用 钥匙链 over simpler solutions? Wouldn’t storing the user’s base-64 encoded password in UserDefaults be enough?

当然不!攻击者恢复以这种方式恢复密码是微不足道的。

钥匙扣服务 帮助您将物品或小块数据存储在加密数据库中,代表用户。

来自苹果的文件, SecKeychain 类代表一个数据库,而 SecKeychainItem 类代表一个项目。

钥匙扣服务 根据您正在运行的操作系统不同地操作。

在iOS中,应用程序可以访问单个 钥匙链 包括这一点 icloud keychain.。锁定和解锁设备自动锁定和解锁 钥匙链。这可以防止不需要的访问。此外,应用程序只能访问其自己的项目或与其所属的组共享的项目。

另一方面,麦克斯支持多个钥匙扣。您通常依赖于用户来管理这些 钥匙扣访问 应用程序并用默认钥匙串隐式工作。此外,您可以直接操纵钥匙链;例如,创建和管理严格私有的钥匙串。

当您想要存储诸如密码之类的秘密时,将其包装为钥匙串项。这是一个不透明类型,由两个部分组成:数据和一组属性。就在插入新项目之前, 钥匙扣服务 加密数据然后将其与其属性一起包装。

钥匙扣服务

Use attributes to identify and store metadata or to control access to your stored items. Specify attributes as the keys and values of a dictionary expressed as a CFDictionary. You can find a list of the available keys at 项目属性键和值。相应的值可以是字符串,数字,一些其他基本类型,或包装的常量 安全 framework.

钥匙扣服务 提供特殊的属性,允许您识别特定项目的类。在本教程中,您将使用两者 kSecClassGenericPasswordkSecClassInternetPassword 处理通用和Internet密码。

每个类仅支持特殊的属性。换句话说,并非所有属性都适用于特定项类。您可以在相关的情况下验证它们 项目类值文档.

笔记:除了操纵密码外,Apple还提供了与其他类型的项目相互作用,如证书,加密密钥和身份。那些分别由 kSecClassCertificate, kSecClassKeykSecClassIdentity classes.

潜入钥匙扣服务API

由于代码隐藏了来自意向用户的项目, 钥匙扣服务 提供一组C功能以与之交互。以下是您使用的API来操纵通用和Internet密码:

While the functions above operate with different parameters, they all return a result code expressed as an OSStatus. This is a 32-bit signed integer which can assume one of the values listed in 项目返回结果键.

Since OSStatus could be cryptic to understand, Apple provides an additional API called SecCopyErrorMessageString(_:_:) to obtain a human-readable string corresponding to these status codes.

笔记:除了添加,修改,删除或搜索特定的钥匙扣项之外,Apple还为导出和导入证书,键和标识甚至修改了项目的访问控制功能。如果您想了解更多信息,请查看文档 钥匙扣项目.

现在你有一个坚实的把握 钥匙扣服务,在下一节中,您将了解如何删除包装器提供的存根方法。

实施包装器的API

打开 SecureStore..swift. 和 add the following implementation inside setValue(_:for:):

// 1
guard let encodedPassword = value.data(using: .utf8) else {
  throw SecureStoreError.string2DataConversionError
}

// 2
var query = secureStoreQueryable.query
query[String(kSecAttrAccount)] = userAccount

// 3
var status = SecItemCopyMatching(query as CFDictionary, nil)
switch status {
// 4
case errSecSuccess:
  var attributesToUpdate: [String: Any] = [:]
  attributesToUpdate[String(kSecValueData)] = encodedPassword
  
  status = SecItemUpdate(query as CFDictionary,
                         attributesToUpdate as CFDictionary)
  if status != errSecSuccess {
    throw error(from: status)
  }
// 5
case errSecItemNotFound:
  query[String(kSecValueData)] = encodedPassword
  
  status = SecItemAdd(query as CFDictionary, nil)
  if status != errSecSuccess {
    throw error(from: status)
  }
default:
  throw error(from: status)
}

This method, as the name implies, allows storing a new password for a specific account. If it cannot update or add a password, it throws a SecureStore.Error.unhandledError, which specifies a localized description for it.

这是您的代码所做的:

  1. Check if it can encode the value to store into a Data type. If that’s not possible, it throws a conversion error.
  2. Ask the secureStoreQueryable instance for the query to execute and append the account you’re looking for.
  3. 返回与查询匹配的钥匙串项。
  4. If the query succeeds, it means a password for that account already exists. In this case, you replace the existing password’s value using secitemupdate(_:_ :).
  5. If it cannot find an item, the password for that account does not exist yet. You add the item by invoking SECITEMADD(_:_ :).

钥匙链服务API 使用核心基础类型。要使编译器快乐,必须从核心基础类型转换为SWIFT类型,反之亦然。

In the first case, since each key’s attribute is of type CFString, its usage as a key in a query dictionary requires a cast to String. However, the conversion from [String: Any] to CFDictionary enables you to invoke the C functions.

Now it’s time to retrieve your password. Scroll below the method you’ve just implemented and replace the implementation of getValue(for:) with the following:

// 1
var query = secureStoreQueryable.query
query[String(kSecMatchLimit)] = kSecMatchLimitOne
query[String(kSecReturnAttributes)] = kCFBooleanTrue
query[String(kSecReturnData)] = kCFBooleanTrue
query[String(kSecAttrAccount)] = userAccount

// 2
var queryResult: AnyObject?
let status = withUnsafeMutablePointer(to: &queryResult) {
  SecItemCopyMatching(query as CFDictionary, $0)
}

switch status {
// 3
case errSecSuccess:
  guard 
    let queriedItem = queryResult as? [String: Any],
    let passwordData = queriedItem[String(kSecValueData)] as? Data,
    let password = String(data: passwordData, encoding: .utf8)
    else {
      throw SecureStoreError.data2StringConversionError
  }
  return password
// 4
case errSecItemNotFound:
  return nil
default:
  throw error(from: status)
}

Given a specific account, this method retrieves the password associated with it. Again, if something goes wrong with the request, the code throws a SecureStore.Error.unhandledError.

以下是您刚刚添加的代码发生的内容:

  1. Ask secureStoreQueryable for the query to execute. Besides adding the account you’re interested in, this enriches the query with other attributes and their related values. In particular, you’re asking it to return a single result, to return all the attributes associated with that specific item and to give you back the unencrypted data as a result.
  2. Use secitemcopatmatching(_:_ :) to perform the search. On completion, queryResult will contain a reference to the found item, if available. withUnsafeMutablePointer(to:_:) gives you access to an UnsafeMutablePointer that you can use and modify inside the closure to store the result.
  3. If the query succeeds, it means that it found an item. Since the result is represented by a dictionary that contains all the attributes you’ve asked for, you need to extract the data first and then decode it into a Data type.
  4. If an item is not found, return a nil value.

为帐户添加或检索密码是不够的。您还需要集成一种方法来删除密码。

Find removeValue(for:) 和 add this implementation:

var query = secureStoreQueryable.query
query[String(kSecAttrAccount)] = userAccount

let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
  throw error(from: status)
}

To remove a password, you perform secitemdelete(_ :) specifying the account you’re looking for. If you successfully deleted the password or if no item was found, your job is done and you bail out. Otherwise, you throw an unhandled error in order to let the user know something went wrong.

但是,如果要删除与特定服务关联的所有密码,该怎么办?您的下一步是实现实现此目的的最终代码。

Find removeAllValues() 和 add the following code within its brackets:

let query = secureStoreQueryable.query
  
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
  throw error(from: status)
}

As you’ll notice, this method is similar to the previous one except for the query passed to the secitemdelete(_ :) function. In this case, you remove passwords independently from the user account.

最后,构建框架以验证所有内容是否正确编译。

连接点

All the work you’ve done so far enriches your wrapper with add, update, delete and retrieve capabilities. As is, you must create the wrapper with an instance of some type that conforms to SecureStore.Queryable.

由于您的第一个目标是通过通用和Internet密码处理,因此您的下一步是创建两种不同的配置,消费者可以创建和注入包装器。

首先,检查如何对通用密码进行撰写查询。

打开 SecurestoreQueryable.swift. 和 add the following code below the SecureStore.Queryable definition:

public struct GenericPasswordQueryable {
  let service: String
  let accessGroup: String?
  
  init(service: String, accessGroup: String? = nil) {
    self.service = service
    self.accessGroup = accessGroup
  }
}

GenericPasswordQueryable is a simple struct that accepts a service and an access group as String parameters.

下一页, add the following extension below the GenericPasswordQueryable definition:

extension GenericPasswordQueryable: SecureStoreQueryable {
  public var query: [String: Any] {
    var query: [String: Any] = [:]
    query[String(kSecClass)] = kSecClassGenericPassword
    query[String(kSecAttrService)] = service
    // Access group if target environment is not simulator
    #if !targetEnvironment(simulator)
    if let accessGroup = accessGroup {
      query[String(kSecAttrAccessGroup)] = accessGroup
    }
    #endif
    return query
  }
}

To conform to SecureStore.Queryable protocol, you must implement query as a property. The query represents the way your wrapper is able to perform the chosen functionality.

编组查询具有特定的键和值:

  • 这 item class, represented by the key kSecClass, has the value kSecClassGenericPassword since you’re dealing with generic passwords. This is how keychain infers that the data is secret and requires encryption.
  • kSecAttrService is set to the service parameter value that is injected with a new instance of GenericPasswordQueryable.
  • Finally, if your code is not running on a simulator, you also set kSecAttrAccessGroup key to the provided accessGroup value. This lets you share items among different apps with the same access group.

接下来,构建框架以确保一切正常工作。

笔记: For a keychain item of class kSecClassGenericPassword, the primary key is the combination of kSecAttrAccountkSecAttrService. In other words, the tuple allows you to uniquely identify a generic password in the 钥匙链.

你闪亮的新包装尚未完整!下一步是集成允许消费者与Internet密码进行交互的功能。

滚动到结尾 SecurestoreQueryable.swift. 并添加以下内容:

public struct InternetPasswordQueryable {
  let server: String
  let port: Int
  let path: String
  let securityDomain: String
  let internetProtocol: InternetProtocol
  let internetAuthenticationType: InternetAuthenticationType
}

InternetPasswordQueryable is a struct that helps you manipulate 互联网密码 在您的应用程序中 钥匙链.

Before conforming to SecureStore.Queryable, take a moment to understand how your API will work in this case.

If users want to deal with internet passwords, they create a new instance of InternetPasswordQueryable where internetProtocolinternetAuthenticationType properties are bound to specific domains.

下一页, add the following to below your InternetPasswordQueryable implementation:

extension InternetPasswordQueryable: SecureStoreQueryable {
  public var query: [String: Any] {
    var query: [String: Any] = [:]
    query[String(kSecClass)] = kSecClassInternetPassword
    query[String(kSecAttrPort)] = port
    query[String(kSecAttrServer)] = server
    query[String(kSecAttrSecurityDomain)] = securityDomain
    query[String(kSecAttrPath)] = path
    query[String(kSecAttrProtocol)] = internetProtocol.rawValue
    query[String(kSecAttrAuthenticationType)] = internetAuthenticationType.rawValue
    return query
  }
}

如通用密码所示,查询具有特定的键和值:

  • 这 item class, represented by the key kSecClass, has the value kSecClassInternetPassword, since you’re now interacting with internet passwords.
  • kSecAttrPort is set to the port parameter.
  • kSecAttrServer is set to the server parameter.
  • kSecAttrSecurityDomain is set to the securityDomain parameter.
  • kSecAttrPath is set to the path parameter.
  • kSecAttrProtocol is bound to the rawValue of the internetProtocol parameter.
  • Finally, kSecAttrAuthenticationType is bound to the rawValue of the internetAuthenticationType parameter.

同样,构建以查看Xcode是否正确编译。

笔记: For a keychain item of class kSecClassInternetPassword, the primary key is the combination of kSecAttrAccount, kSecAttrSecurityDomain, kSecAttrServer, kSecAttrProtocol, kSecAttrAuthenticationType, kSecAttrPortkSecAttrPath. In other words, those values allow you to uniquely identify an internet password in the 钥匙链.

现在是时候看到了所有辛勤工作的结果。可是等等!由于您未创建在模拟器上运行的应用程序,您将如何验证它?

这是单位测试到救援的地方。

测试行为

在本节中,您将了解如何为包装器集成单元测试。特别是,您将测试包装器暴露的功能。

笔记:如果您是新的单位测试,并且您想要探索主题,请查看我们的惊人 iOS.单元测试和UI测试教程.

创建类

要创建将包含所有单元测试的类,请单击 文件新的 文件… 并选择 iOS.资源单元测试案例类。在下一个屏幕上,指定类名 SecuStoreTess.,子类 XCTestCase. 并确保语言是 迅速。点击 下一页, 选择 SecuStoreTess. 组,验证您已选择的 SecuStoreTess. 目标复选框并单击 创造.

Xcode将提示一个对话框来创建目标-C桥接标题。点击 不要创造 跳过创作。

Objective-C桥接标题对话框

打开 SecurestoreTests.swift. 文件并删除卷曲括号中的所有代码。

下一页, add the following below the import XCTest statement:

@testable import SecureStore

这使得该单元测试对SecureStore框架中定义的类和方法的访问。

笔记:您可能会看到“没有这样的模块”错误。别担心,当您到达本教程本部分的结尾并执行测试时,错误会消失。

下一页, add the following properties at the top of SecuStoreTess.:

var secureStoreWithGenericPwd: SecureStore!
var secureStoreWithInternetPwd: SecureStore!

下一页, add a new setUp() method like this:

override func setUp() {
  super.setUp()
  
  let genericPwdQueryable =
    GenericPasswordQueryable(service: "someService")
  secureStoreWithGenericPwd =
    SecureStore(secureStoreQueryable: genericPwdQueryable)
  
  let internetPwdQueryable =
    InternetPasswordQueryable(server: "someServer",
                              port: 8080,
                              path: "somePath",
                              securityDomain: "someDomain",
                              internetProtocol: .https,
                              internetAuthenticationType: .httpBasic)
  secureStoreWithInternetPwd =
    SecureStore(secureStoreQueryable: internetPwdQueryable)
}

由于您测试了通用和Internet密码,因此您可以使用两种不同的配置创建包装器的两个实例。这些配置是您在上一节中开发的配置。

在您忘记之前,您将希望清除测试的拆除阶段的钥匙串状态,以便您下次开始新鲜。将此方法添加到类的末尾:

override func tearDown() {
  try? secureStoreWithGenericPwd.removeAllValues()
  try? secureStoreWithInternetPwd.removeAllValues()
  
  super.tearDown()
}

由于您应该独立隔离和执行每个测试,您将删除已有的所有密码 钥匙链。执行顺序无关紧要。

现在是时候为通用密码添加单元测试。

测试通用密码

Add the following code below tearDown():

// 1
func testSaveGenericPassword() {
  do {
    try secureStoreWithGenericPwd.setValue("pwd_1234", for: "genericPassword")
  } catch (let e) {
    XCTFail("Saving generic password failed with \(e.localizedDescription).")
  }
}

// 2
func testReadGenericPassword() {
  do {
    try secureStoreWithGenericPwd.setValue("pwd_1234", for: "genericPassword")
    let password = try secureStoreWithGenericPwd.getValue(for: "genericPassword")
    XCTAssertEqual("pwd_1234", password)
  } catch (let e) {
    XCTFail("Reading generic password failed with \(e.localizedDescription).")
  }
}

// 3
func testUpdateGenericPassword() {
  do {
    try secureStoreWithGenericPwd.setValue("pwd_1234", for: "genericPassword")
    try secureStoreWithGenericPwd.setValue("pwd_1235", for: "genericPassword")
    let password = try secureStoreWithGenericPwd.getValue(for: "genericPassword")
    XCTAssertEqual("pwd_1235", password)
  } catch (let e) {
    XCTFail("Updating generic password failed with \(e.localizedDescription).")
  }
}

// 4
func testRemoveGenericPassword() {
  do {
    try secureStoreWithGenericPwd.setValue("pwd_1234", for: "genericPassword")
    try secureStoreWithGenericPwd.removeValue(for: "genericPassword")
    XCTAssertNil(try secureStoreWithGenericPwd.getValue(for: "genericPassword"))
  } catch (let e) {
    XCTFail("Saving generic password failed with \(e.localizedDescription).")
  }
}


// 5
func testRemoveAllGenericPasswords() {
  do {
    try secureStoreWithGenericPwd.setValue("pwd_1234", for: "genericPassword")
    try secureStoreWithGenericPwd.setValue("pwd_1235", for: "genericPassword2")
    try secureStoreWithGenericPwd.removeAllValues()
    XCTAssertNil(try secureStoreWithGenericPwd.getValue(for: "genericPassword"))
    XCTAssertNil(try secureStoreWithGenericPwd.getValue(for: "genericPassword2"))
  } catch (let e) {
    XCTFail("Removing generic passwords failed with \(e.localizedDescription).")
  }
}

这里有很多事情,所以打破了它:

  1. testSaveGenericPassword() 方法验证它是否可以正确保存密码。
  2. testReadGenericPassword() 首先保存密码然后检索密码,检查它是否等于预期的密码。
  3. testUpdateGenericPassword() 验证为同一帐户保存不同的密码时,最新密码是预期检索后的密码。
  4. testRemoveGenericPassword() 测试它可以删除特定帐户的密码。
  5. Finally, testRemoveAllGenericPasswords checks that all the passwords related to a specific service are deleted from the 钥匙链.

Since your wrapper can throw exceptions, each catch block makes the tests fail if something goes wrong.

检查你的工作

现在是时候验证一切正常工作。选择 testhost. 作为您的Xcode项目的活动方案:

积极方案

命令U. 在键盘上(或选择 产品▸测试 在菜单中)执行单元测试。

笔记:您不需要在教程中正常运行应用程序。对于本教程,您可以通过执行单元测试检查您的代码。

显示测试导航器并等待测试执行。一旦他们完成了,你会期望所有五个测试都是绿色的。好的!

绿色测试

接下来,对Internet密码执行相同的操作。

滚动到课程的末尾,只是在最后一个卷曲支架之前添加以下内容:

func testSaveInternetPassword() {
  do {
    try secureStoreWithInternetPwd.setValue("pwd_1234", for: "internetPassword")
  } catch (let e) {
    XCTFail("Saving Internet password failed with \(e.localizedDescription).")
  }
}

func testReadInternetPassword() {
  do {
    try secureStoreWithInternetPwd.setValue("pwd_1234", for: "internetPassword")
    let password = try secureStoreWithInternetPwd.getValue(for: "internetPassword")
    XCTAssertEqual("pwd_1234", password)
  } catch (let e) {
    XCTFail("Reading internet password failed with \(e.localizedDescription).")
  }
}

func testUpdateInternetPassword() {
  do {
    try secureStoreWithInternetPwd.setValue("pwd_1234", for: "internetPassword")
    try secureStoreWithInternetPwd.setValue("pwd_1235", for: "internetPassword")
    let password = try secureStoreWithInternetPwd.getValue(for: "internetPassword")
    XCTAssertEqual("pwd_1235", password)
  } catch (let e) {
    XCTFail("Updating internet password failed with \(e.localizedDescription).")
  }
}

func testRemoveInternetPassword() {
  do {
    try secureStoreWithInternetPwd.setValue("pwd_1234", for: "internetPassword")
    try secureStoreWithInternetPwd.removeValue(for: "internetPassword")
    XCTAssertNil(try secureStoreWithInternetPwd.getValue(for: "internetPassword"))
  } catch (let e) {
    XCTFail("Removing internet password failed with \(e.localizedDescription).")
  }
}

func testRemoveAllInternetPasswords() {
  do {
    try secureStoreWithInternetPwd.setValue("pwd_1234", for: "internetPassword")
    try secureStoreWithInternetPwd.setValue("pwd_1235", for: "internetPassword2")
    try secureStoreWithInternetPwd.removeAllValues()
    XCTAssertNil(try secureStoreWithInternetPwd.getValue(for: "internetPassword"))
    XCTAssertNil(try secureStoreWithInternetPwd.getValue(for: "internetPassword2"))
  } catch (let e) {
    XCTFail("Removing internet passwords failed with \(e.localizedDescription).")
  }
}

Notice that the code above is identical to the one previously analyzed. You’ve just replaced the reference secureStoreWithGenericPwd with secureStoreWithInternetPwd.

选择 testhost. 作为活动方案,如果它尚未,并按 命令U. 在您的键盘上再次测试。现在所有测试都是通用和Internet密码,都应该是绿色的。

恭喜!您现在拥有工作,独立框架和单元测试。

然后去哪儿?

在本教程中,您将框架包装框架 钥匙链服务API 甚至集成的单元测试,以证明您的代码正常工作。惊人!

在我们的教程决定之后,您可以更进一步,与其他人共享或分发您的代码 为iOS创建一个框架.

您可以使用使用的项目的已完成版本 下载材料 本教程顶部或底部的按钮。

如果您想了解更多信息,请查看Apple的文档 钥匙扣服务.

值得注意的是 钥匙链 不限于密码。您可以存储信用卡数据或短音符等敏感信息。您还可以保存与您管理的加密密钥和证书等项目 证书,密钥和信任服务,进行安全和经过身份验证的数据事务。

你从中学到了什么?任何挥之不去的问题?想要分享沿途发生的事情?您可以在论坛中讨论它。到时候那里见!

平均评级

4.7/5

为此内容添加评级

14 ratings

更像这样的

贡献者

注释