保护 iOS 用户数据安全: Keychain 和 Touch ID

原文:How To Secure iOS User Data: The Keychain and Touch ID

作者:Tim Mitra

译者:kmyhy

更新说明:本教程由 Tim Mitra 升级至 Xcode 8.3.2 和 Swift 3.1。原文作者 Time Mitra。

用登录窗口来保护 app 对于保护用户数据来说是非常好的做法——你可以使用 iOS 中内置的 Keychain 来保证他们的数据安全。苹果也还通过 Touch ID 提供另外一层保护,这个功能从 iPhone 5s 开始,在 A7 以上的 CPU 中,Touch ID 将生物特征数据存储在一个安全的地方。

也就是说你现在可以轻松地将登录信息放到 Keychain 或者 Touch ID 中。在本文,你将从静态认证开始。然后使用 Keychain 保存和校验登录信息。再然后,是 Touch ID 的使用。

注意:Touch ID 需要真机才能调试,但 Keychain 可以在模拟器上使用。

开始

这里下载开始项目。

这是一个简单的记事本 app,使用了 Core Data 存储用户的备忘录。故事板中有一个登录视图,用户可以输入用户名和密码。app 的其它视图都是已经准备好的。

运行 app,你的 app 当前是这个样子:

保护 iOS 用户数据安全: Keychain 和 Touch ID

点击 Login 按钮,当前视图消失,列表视图显示——在这里,你还可以写新的备忘录。点击 Logout 则返回登录视图。如果 app 进入后台,它会立即回到登录视图,防止不经许可就能查看数据。这是通过将 Info.plist 中的 Application does not run in background 设置为 YES 来控制的。

在此之前,你应该修改 Bundle ID,并指定正确的 Team。

在项目导航器中选择 TouchMeIn,然后选择 TouchMeIn target。在 General 标签页,将 Bundle ID 修改为你自己的反域名——比如 com。raywenderich.TouchMenIn。然后,在 Team 栏,选择你的开发者账号所在的 team。

保护 iOS 用户数据安全: Keychain 和 Touch ID

做完这些,就可以编写代码了 :]

Logging?No,Log In。

在一开始,你要添加校验用户的功能——根据硬编码的值。

打开 LoginViewController.swift,在 managedObjectContext 变量下,添加下列常量:

let usernameKey = "batman"
let passwordKey = "Hello Bruce!"

我们将用户名和密码进行了硬编码,以便你可以校验用户输入的凭证。

在 loginAction(:) 下面添加方法:

func checkLogin(username: String, password: String) -> Bool {
  return username == usernameKey && password == passwordKey
}

这个方法判断用户输入的登录信息是否匹配我们指定的常量。

然后,替换 loginAction(:) 的代码:

if checkLogin(username: usernameTextField.text!, password: passwordTextField.text!) {
  performSegue(withIdentifier: "dismissLogin", sender: self)
}

这里调用了 checkLogin(username:password:),如果用户名密码正确则解散登录界面。

运行 app。输入用户名 batman、密码 Hello Bruce!,点击 Login 按钮。登录界面将消失。

当然这种方法虽然能够工作,但它是极度不安全的,因为账号密码是以字符串存储的,很容易被好奇的黑客用适当的工作和一些训练来攻破。最好永远不要在 app 中直接春粗密码。

接下来,我们将用 Keychain 来保存密码。关于 Keychain 的底层机制,请阅读 Chris Lowe 的 Basic Security in iOS 5 – Part 1 tutorial

然后我们来添加一个 Keychain 的封装类到 app。

是封装(wrapper),而不是说唱艺人(rapper)。

在这个 app 中,你会看到已经有一个 KeychainPasswordItem.swift 文件,这个类来自于苹果的示例代码GenericKeychain

在 Resources 文件夹,将 KeychainPasswordItem.swift 拖进项目中:

保护 iOS 用户数据安全: Keychain 和 Touch ID

在弹出窗口中,选择 Copy items if needed ,并勾上 TouchMeIn target :

保护 iOS 用户数据安全: Keychain 和 Touch ID

编译运行,看看是否有错误发生。一切正常?太好了——你可以在 app 使用 Keychain 了。

Keychain, Meet Password. Password, Meet Keychain

首先用 Keychain 来保存用户名密码,然后将用户输入的账号密码和 Keychain 中的进行比较,看二者是否匹配。

你必须记录用户是否已经创建了账号,这样才能将 Login 按钮上的文字从 Create 改变为 Login。你也可以将账号保存在 User Defaults,这样你就可以不必每次都经过 Keychain 来进行检查。

为了正确保存 app 的数据,Keychain 需要进行一定的配置。你需要添加一个 serviceName 和一个可选的 accessGroup。我们会新建一个结构来存储它们。

打开 LoginViewController.swift。在文件头部 import 语句后添加一个结构。

// Keychain Configuration
struct KeychainConfiguration {
  static let serviceName = "TouchMeIn"
  static let accessGroup: String? = nil
}

删除下列语句:

let usernameKey = "batman"
let passwordKey = "Hello Bruce!"

在同一个地方,添加下几句:

var passwordItems: [KeychainPasswordItem] = []
let createLoginButtonTag = 0
let loginButtonTag = 1

@IBOutlet weak var loginButton: UIButton!

passwordItems 是一个 KeychainPasswordItem 数组,用于放到 Keychain 中。后两个常量用于表示 Login 按钮是用来创建账号呢?还是用来登录。loginButton 出口会用于根据状态修改按钮标题。

打开 Main.storyboard 选择 Login View Controller Scene。右键,从 Login View Controller 拖到 Login 按钮:

保护 iOS 用户数据安全: Keychain 和 Touch ID

在弹出菜单中,选择 loginButton:

保护 iOS 用户数据安全: Keychain 和 Touch ID

然后,处理按钮被点击时的两种情况:如果用户还没有创建账户,按钮文本应该是 Create,否则应该是 Login。你需要检查用户输入的账号是否匹配 Keychain。

打开 LoginViewController.swift 将 loginAction(_:) 替换成:

  @IBAction func loginAction(_ sender: AnyObject) {
    // 1
    // Check that text has been entered into both the username and password fields.
    guard
      let newAccountName = usernameTextField.text,
      let newPassword = passwordTextField.text,
      !newAccountName.isEmpty &&
      !newPassword.isEmpty else {

        let alertView = UIAlertController(title: "Login Problem",
                                          message: "Wrong username or password.",
                                          preferredStyle:. alert)
        let okAction = UIAlertAction(title: "Foiled Again!", style: .default, handler: nil)
        alertView.addAction(okAction)
        present(alertView, animated: true, completion: nil)
        return
    }

    // 2
    usernameTextField.resignFirstResponder()
    passwordTextField.resignFirstResponder()

    // 3
    if sender.tag == createLoginButtonTag {

      // 4
      let hasLoginKey = UserDefaults.standard.bool(forKey: "hasLoginKey")
      if !hasLoginKey {
        UserDefaults.standard.setValue(usernameTextField.text, forKey: "username")
      }

      // 5
      do {

        // This is a new account, create a new keychain item with the account name.
        let passwordItem = KeychainPasswordItem(service: KeychainConfiguration.serviceName,
                                                account: newAccountName,
                                                accessGroup: KeychainConfiguration.accessGroup)

        // Save the password for the new item.
        try passwordItem.savePassword(newPassword)
      } catch {
        fatalError("Error updating keychain - \(error)")
      }

      // 6
      UserDefaults.standard.set(true, forKey: "hasLoginKey")
      loginButton.tag = loginButtonTag

      performSegue(withIdentifier: "dismissLogin", sender: self)

    } else if sender.tag == loginButtonTag {

      // 7
      if checkLogin(username: usernameTextField.text!, password: passwordTextField.text!) {
        performSegue(withIdentifier: "dismissLogin", sender: self)
      } else {
        // 8
        let alertView = UIAlertController(title: "Login Problem",
                                          message: "Wrong username or password.",
                                          preferredStyle: .alert)
        let okAction = UIAlertAction(title: "Foiled Again!", style: .default)
        alertView.addAction(okAction)
        present(alertView, animated: true, completion: nil)
      }
    }
  }

代码解释如下:

  1. 如果用户名或密码为空,弹出一个 alert 并退出方法。
  2. 如果键盘是弹状态,则解散它。
  3. 如果 Login 按钮的 tag 值为 createLoginButtonTag,继续创建新的登录账号。
  4. 从 User Defaults 中读取 hasLoginKey,这个值表明是否有一个密码保存在 Keychain 中。如果用户名文本框不为空,且 hasLoginKey 表示没有保存过密码,则将用户名保存到 UserDefaults。
  5. 用 serviceName、newAccountName(用户名)和 accessGroup 创建 KeychainPasswordItem。利用 Swift 的错误处理机制,我们将保存密码动作放在 try 关键字后进行。如果有错误发生,则进入 catch。
  6. 然后在 UserDefaults 中将 hasLoginKey 修改为 true,表明密码已经被保存在 keychain 中。将按钮的 tag 设置为 loginButtonTag,并修改按钮的标题。以便提示用户下次操作是登录,而不是创建账号。最后,解散登录视图。
  7. 如果用户是登录操作(tag 值为 loginButtonTag),调用 checkLogin 校验用户登录,如果匹配,解散登录视图。
  8. 如果用户登录失败,弹出一个 alert 提示用户。

注意:为什么不将密码和用户名一起保存到 User Defaults? 这可不是什么好主意,因为 User Defaults 是用 Plist 文件进行持久化的。这其实是一种 XML 文件,放在 app 的 Library 文件夹,任何能够访问这台设备的人都能看到这个文件。而 Keychain,使用了 3DES 加密。就算有人拿到了数据,也无法看懂。

然后,将 checkLogin(username:password:) 修改成:

  func checkLogin(username: String, password: String) -> Bool {

    guard username == UserDefaults.standard.value(forKey: "username") as? String else {
      return false
    }

    do {
      let passwordItem = KeychainPasswordItem(service: KeychainConfiguration.serviceName,
                                              account: username,
                                              accessGroup: KeychainConfiguration.accessGroup)
      let keychainPassword = try passwordItem.readPassword()
      return password == keychainPassword
    }
    catch {
      fatalError("Error reading password from keychain - \(error)")
    }

    return false
  }

这里,检查用户输入的用户名和 UserDefaults 中保存的用户名是否一致,然后将用户输入的密码和 keychain 中的密码进行比较。

现在,需要根据 hasLoginKey 的状态来修改按钮标题。

在 viewDidLoad() 方法中 super 语句之后添加:

    // 1
    let hasLogin = UserDefaults.standard.bool(forKey: "hasLoginKey")

    // 2
    if hasLogin {
      loginButton.setTitle("Login", for: .normal)
      loginButton.tag = loginButtonTag
      createInfoLabel.isHidden = true
    } else {
      loginButton.setTitle("Create", for: .normal)
      loginButton.tag = createLoginButtonTag
      createInfoLabel.isHidden = false
    }

    // 3
    if let storedUsername = UserDefaults.standard.value(forKey: "username") as? String {
      usernameTextField.text = storedUsername
    }

分段解释如下:

  1. 首先检查 hasLoginKey 值,看看是否这个用户已经保存有一个密码。
  2. 如果有,将按钮标题改成 Login,tag 修改为 loginButtonTag,然后隐藏 createInfoLabel——它包含了 “Start by creating a username and password“ 文本字样。如果否,则将按钮文字改为 Create,将 createInfoLabel 显示给用户。
  3. 最后,将 UserDefaults 中保存的用户名显示在文本框中,给用户提供一些便利。

运行 app。输入用户名、密码,点击 Create。

注意:如果你忘记将登录按钮连接到出口上,你会看到一个致命错误:unexpectedly found nil while unwrapping an Optional value。如果这样,请根据上面的步骤连接好出口。

现在点击 Logout,用相同的用户名密码登录——你会看到备忘录列表显示出来了。

点击 Logout,再次登录——这次,输入不一样的密码点击 Login。你会看到 alert 出来了:

保护 iOS 用户数据安全: Keychain 和 Touch ID

恭喜你——你已经用 Keychain 保存了密码。接下来是 Touch ID。

Touching You, Touching Me

注意:为了测试 Touch ID,你必须在支持 Touch ID 的真机上运行 app。在写到这里的时候,所有 A7 以上 CPU 并拥有 Touch ID 硬件的设备都支持这个功能。

在这一部分,我们将在项目中添加 Touch ID 功能并使用 Keychain。对于 Touch ID 来说,keychain 并不是必须的,但是实现一个在 Touch ID 认证失败或设备部支持时的可选认证方式是一种非常好的做法。

打开 Images.xcassets。

打开先前下载的项目中的 Resources 文件夹。找到 Touch-icon-lg.png、Touch-icon-lg@2x.png 和 Touch-icon-lg@3x.png, 将它们一起拖到 Images.xcassets 中,这样 Xcode 会把它们看成是相同的图片,只是分辨率不同而已:

保护 iOS 用户数据安全: Keychain 和 Touch ID

打开 Main.storyboard 拖一个 UIButton 到 Login View Controller 上,放到 Stack View 的 Create Info Label 的下方。你可以打开 Document Outline,展开小三角,确认 Button 确实位于 Stack View 中,如下图所示:

保护 iOS 用户数据安全: Keychain 和 Touch ID

如果你需要回顾一下 Stack View,请看一眼 Jawwad Ahmad 的 UIStackView Tutorial: Introducing Stack Views

在属性面板中,调整按钮的属性:

  1. Type 设为 Custom。
  2. Title 设为空。
  3. Image 设为 Touch-icon-lg。

做完后按钮属性应该是这个样子:

保护 iOS 用户数据安全: Keychain 和 Touch ID

继续选中新按钮,点击故事板底部的 Add New Constraints 按钮,如下图所示设置约束:

保护 iOS 用户数据安全: Keychain 和 Touch ID

  • 宽:66
  • 高:67

你的视图看起来是这个样子:

保护 iOS 用户数据安全: Keychain 和 Touch ID

仍然是 Main.storyboard。打开助手编辑器,显示 LoginViewController.swift 文件。

右键,从新加的按钮上拖一条线到 LoginViewController.swift:

保护 iOS 用户数据安全: Keychain 和 Touch ID

在弹出窗口中,将 Name 设置为 touchIDButton,然后点 Connect:

保护 iOS 用户数据安全: Keychain 和 Touch ID

这创建了一个出口,这样,当设备不支持 Touch ID 时,你就可以用于隐藏该按钮了。

然后要为按钮添加一个 Action。

右键,从这颗按钮拖一条线到 LoginViewController.swift 的 checkLogin(username:password:) 上:

保护 iOS 用户数据安全: Keychain 和 Touch ID

在弹出窗口中,将 Connection 修改为 Action,将 Name 设置为 touchIDLoginAction,Type 设置为 UIButton。点击 Connect。

保护 iOS 用户数据安全: Keychain 和 Touch ID

编译运行,看是否有错误发生。到现在为止你都可以在模拟器上运行,因为还没有对 Touch ID 进行支持的。现在我们就来做这一步。

添加 Local Authentication

实现 Touch ID 的第一步是导入 Local Authentication 框架,并调用一系列简单而强大的方法。

根据 Local Authentication 文档的描述:

“Local Authentication 框架提供了根据指定安全策略向用户请求授权的能力。”

指定安全策略在我们的例子中,就是用户的生物特征——即指纹:]

在项目导航器中,右键点击 TouchMeIn 文件组,选择 New File…。选择 iOS/Swift File,点击 Next。文件名命名为 TouchIDAuthentication.swift,勾选 TouchMeIn target,点击 Create。

打开 TouchIDAuthentication.swift 添加 import 语句:

import LocalAuthentication

定义一个新类:

class TouchIDAuth {

}

现在,我们需要使用 LAContext 类了。

在类体(两个花括号之间)添加代码:

let context = LAContext()

context 对象引用了一个 authentication context,这是 Local Authentication 的主要角色。你需要用一个函数检查在用户设备或真机上是否支持 Touch ID。

新增方法,返回一个 Bool 表示 Touch ID 是否被支持。

func canEvaluatePolicy() -> Bool {
  return context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)
}

打开 LoginViewController.swift。

再次增加一个属性,引用刚刚的这个新类。

let touchMe = TouchIDAuth()

在 viewDidLoad() 方法底部添加:

touchIDButton.isHidden = !touchMe.canEvaluatePolicy()

我们用 canEvaluatePolicy(_:error:) 来判断设备是否支持 Touch ID。如果 true,显示 Touch ID 按钮,否则隐藏。

在模拟器是运行 app,你会发现 Touch ID 按钮是隐藏的。在一台支持 Touch ID 的真机上运行,你会发现 Touch ID 按钮是显示的。

使用 Touch ID

返回 TouchIDAuthentication.swift ,添加一个函数允许用户登录。在 TouchIDAuth 类底部,创建函数:

func authenticateUser(completion: @escaping () -> Void) { // 1
  // 2
  guard canEvaluatePolicy() else {
    return
  }

  // 3
  context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics,
    localizedReason: "Logging in with Touch ID") { (success, evaluateError) in
      // 4
      if success {
        DispatchQueue.main.async {
          // User authenticated successfully, take appropriate action
          completion()
        }
      } else {
        // TODO: deal with LAError cases
      }
  }
}

代码解释如下:

  1. authenticateUser(completion:) 方法的参数是一个完成回调,以闭包的形式回到 LoginViewController。
  2. 用 canEvaluatePolicy() 判断设备是否具备 Touch ID 能力。
  3. 如果设备支持 Touch ID,用 evaluatePolicy(_:localizedReason:reply:) 方法开始策略计算——也就是提示用于进行 Touch ID 认证。evaluatePolicy(_:localizedReason:reply:) 方法有一个回调块,在计算完成后执行。
  4. 在回调块中,首先处理成功的情况。默认策略的计算是在一个私有线程中进行,因此我们的代码要切换到主线程中进行,以便刷新 UI。如果授权成功,执行 segue,解散登录视图。

对于错误的处理待会儿在回来处理。

回到 LoginViewController.swift ,通过滚动或者跳转工具找到 touchIDLoginAction(_:) 。

将这个 Action 方法实现成如下所示:

  @IBAction func touchIDLoginAction(_ sender: UIButton) {
    touchMe.authenticateUser() { [weak self] in
      self?.performSegue(withIdentifier: "dismissLogin", sender: self)
    }
  }

如果用户认证通过,你可以解散登录界面。

可以在真机上 build & run 了……且慢,如果你没有在设备上创建 Touch ID 怎么办? 如果你使用了错误的手指怎么办?让我们来看看。

build & run,看看是否一切 OK。

错误处理

Local Authentication 的一个重要内容就是错误处理,这个框架中包含了一个 LAError 的类型。在调用 canEvaluatePolicy 方法时也有可能会得到一个错误。我们会弹出一个 alert 显示用户发生了什么。你需要从 TouchIDAuth 类传递消息给 LoginViewController。幸运的是你已经拥有了一个 completion 回调,你可以用它来传递一个 optional 的消息。

回到 TouchIDAuthentication.swift 修改 authenticateUser 函数。

为函数增加一个 optional 的 message 参数,当发生错误时通过它进行传递。

func authenticateUser(completion: @escaping (String?) -> Void) {

找到 // TODO: 一句,将它换成 switch case 语句:

            let message: String

            // 2
            switch evaluateError {
            // 3
            case LAError.authenticationFailed?:
              message = "There was a problem verifying your identity."
            case LAError.userCancel?:
              message = "You pressed cancel."
            case LAError.userFallback?:
              message = "You pressed password."
            default:
              message = "Touch ID may not be configured"
            }
            // 4
            completion(message)

代码解释如下:

  1. 用一个字符串变量保存错误信息。
  2. 对失败的情况进行处理。我们使用了 switch 语句,将每种错误用适当的消息来描述,然后呈现给用户。
  3. 如果是认证失败,显示普通警告。在生产环境中,还需要处理另外几个错误代码,包括:

    • LAError.touchIDNotAvailable: 设备不支持 Touch ID-compatible。
    • LAError.passcodeNotSet: 还没有为 Touch ID 设置密码。
    • LAError.touchIDNotEnrolled: 没有存入指纹。
  4. 将 message 传递给 completion 块。

iOS 会针对 LAError.passcodeNotSet 和 LAError.touchIDNotEnrolled 呈现专门的 alerts。

还有一个错误需要处理。在 guard 语句的 else 块 return 之前加入:

completion("Touch ID not available")

最后一件事情是修改我们的成功回调。在 completion 调用中传入 nil 参数,表示没有任何错误。在第一个 success 块中加入 nil。

completion(nil)

完整的函数看起来是这样的:

  func authenticateUser(completion: @escaping (String?) -> Void) {

    guard canEvaluatePolicy() else {
      completion("Touch ID not available")
      return
    }

    context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics,
      localizedReason: "Logging in with Touch ID") { (success, evaluateError) in
        if success {
          DispatchQueue.main.async {
            completion(nil)
          }
        } else {

          let message: String

          switch evaluateError {
          case LAError.authenticationFailed?:
            message = "There was a problem verifying your identity."
          case LAError.userCancel?:
            message = "You pressed cancel."
          case LAError.userFallback?:
            message = "You pressed password."
          default:
            message = "Touch ID may not be configured"
          }

          completion(message)
        }
    }
  }

回到 LoginViewController.swift 修改 touchIDLoginAction(_:) 方法:

  @IBAction func touchIDLoginAction(_ sender: UIButton) {

    // 1
    touchMe.authenticateUser() { message in

      // 2
      if let message = message {
        // if the completion is not nil show an alert
        let alertView = UIAlertController(title: "Error",
                                          message: message,
                                          preferredStyle: .alert)
        let okAction = UIAlertAction(title: "Darn!", style: .default)
        alertView.addAction(okAction)
        self.present(alertView, animated: true)

      } else {
        // 3
        self.performSegue(withIdentifier: "dismissLogin", sender: self)
      }
    }
  }
  1. 添加了一个尾随闭包并传入一个可空的 message 参数。如果 Touch ID 正常,则 message 为空。

  2. 用 if let 对 message 进行解包操作,然后显示 alert。

  3. 这部分没有改动。如果 message 为空,解散登录界面。

在真机上运行,试一下用 Touch ID 进行登录。

因为 LAContext 处理大部分脏活累活,实现 Touch ID 显得非常简单。作为福利,你可以在同一个 app 中用 Keychain 和 Touch ID 进行认证,已解决某些用户没有支持 Touch ID 设备的情况。

注意:如果你想测试 Touch ID 出错的情况,你可以用不正确的指纹进行登录。试错 5 次,Touch ID 会被关闭,需要重启输入密码开启。这避免了陌生人侵入设备上的其它应用。你可以通过”设置\Touch ID 与密码“来重新开启 Touch Id。

结束

这里下载完成后的示例 app。

本教程中的 LoginViewController 为需要管理用户凭证的 app 提供了一个起点。

你也可以创建新的 view controller,或者修改已有的 LoginViewController,允许用户随时修改密码。这对于 Touch ID 来说是不需要的,因为用户的指纹一生中都不会有太多改变。但是,你可以提供一种修改 keychain 的方法,当用户修改新密码之前提示用户输入他的当前密码。

你可以在苹果官方的iOS 10 Security Guide中看一下如何保护你的 iOS app。

有任何问题和意见,请在下面留言!

上一篇:Mac OSX Java 编译时乱码问题


下一篇:K3日志定时备份