iOS用户数据安全之--Keychain和Touch ID

原文 How To Secure iOS User Data: The Keychain and Touch ID
使用登录页是一个保护程序中用户数据安全的好方法,你可以使用 iOS 中自带的 Keychain 来确保他们的数据安全。Apple 也提供了另一层保护,那就是 Touch ID 。Touch ID 储存指纹信息在A7以及更新的芯片中的安全区域。
这一起意味着你可以安心的把处理登录信息的任务交给 Keychain 和(或者) Touch ID。

Touch ID需要真机调试,Keychain可以在模拟器中使用。

Getting Started

请下载这个教程的初始项目;

这是一个基础的记事本应用,使用CoreData来储存用户的笔记。 storyboard上有一个登录页,用户可以填入用户名和密码,剩下的页面也已经彼此连上准备使用。
编译运行,可以看到,你的应用看起来是这样的:

现在,点击 Login 按钮轻易的dismisses视图,然后显示笔记列表。你也可以创建一个新的笔记在这个界面。点击 Logout 带你回到登录页。如果应用被切到后台它将立即回到登录页;通过这样的方式保护数据。这是通过设置 info.plist 的 Application does not run in background 为 YES 实现的。
在你做任何事之前,你需要修改 Bundle identifier,分配一个适当的 Team 。
在 Project Navigator 中选择 TouchMeIn ,然后选择 ToumenIn target 。在 General 一栏中使用自己的域名修改 Bundle Identifier 。使用 reverse-domain-notation 例如 com.raywenderich.TouchMeIn 。
然后,从 Team 菜单选择一个 你的开发者账号关联的 team,像这样:

Logging? NO. Log IN.

接下来,你将为项目添加验证用户的账号的能力,数据是硬编码的值。

打开 LoginViewController.swift 然后在 managedObjectContext 下面添加常量:

1
2
let usernameKey = "batman"
let passwordKey = "Hello bruce!"

这是一个简易的硬编码用户名和密码,你将用它来和用户提供的做对比。

loginAction(_:) 下面添加如下方法:

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

上述方法拿用户输入的账户和你早已定义的常量做对比。

然后,使用下列代码替换loginAction(_:)方法的内容:

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

上述代码调用了 checkLogin(username:password:), 如果账户正确,将 dismisses 登录页。

编译运行,输入乎用户名 batman 密码 Hello Bruce! 然后点击 Login 按钮,登录页将如预料中一样 dismiss。

While this simple approach to authentication seems to work, it’s not terribly secure, as credentials stored as strings can easily be compromised by curious hackers with the right tools and training. As a best practice, passwords should NEVER be stored directly in the app.
虽然这种简单的认证方法似乎很有效,但它并不是非常安全,因为存储在字符串中的凭证很容易受到好奇的黑客使用合适的工具尝试攻击。作为最佳实践,密码**绝不应该**直接存储在应用程序中

接下来一步是添加 Keychain 封装类到你的应用。

Rapper? No. Wrapper.

在初始项目中,你可以找到你已经下载的 KeychainPasswordItem.swift 文件,这个类来自 Apple’s sample code 中的 GenericKeychain

在资源文件夹, 把 KeychainPasswordItem.swift 拖进项目,像这样:

在提示中,确认 Copy items if neededTouchMeIn target 是选中的:

编译运行确认没有错误,很好,现在你可以在你的应用中使用 Keychain 了。

Keychain, Meet Password. Password, Meet Keychain

使用 Keychain ,首先你要储存一个用户名密码,然后呢,比对用户提供的账号和 Keychain 的是否相等。

你需要跟踪用户是否已经创建了账号以便你将登录按钮上的文本从 “Create”改为 “Login” ,你也可以储存用户默认的用户名,这样不用每次都从 Keychain 中访问。

Keychain 需要一些必要的配置来正确的储存你的应用信息。 你需要配置一个 serviceName 和 一个 可选的 accessGrop 。 你将添加一个结构体来储存他们的值。

打开 LoginViewController.swift ,在文件的顶端,imports 的下面,加上这个结构体:

1
2
3
4
5
// Keychain Configuration
struct KeychainConfiguration {
static let serviceName = "TouchMeIn"
static let accessGroup: String? = nil
}

然后删除下面的两行:

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

在这个地方加入下面的代码:

1
2
3
4
5
var passwordItems: [KeychainPasswordItem] = []
let createLoginButtonTag = 0
let loginButtonTag = 1

@IBOutlet weak var loginButton: UIButton!

passwordItems 一个装 KeychainPasswordItem 类型的数组。接下来的两个常量用来标记这个登录按钮是用来创建账户还是登录;loginButton 的 outlet 将用来根据状态更新相应的标题。

打开 Main.storyboard 选择 Login View Controller Scene,按住 Ctrl 键同时从 Login View Controller拖一根线到登录按钮,像下面这样:

在弹出的列表中选择 loginButton :

接下来当按钮被点击时你需要处理以下两种情况:如果用户没有创建账号,按钮上的文本将显示 “Create”,否则按钮将显示 “Login” 。你还需要对比输入的账户和 keychain 中保存的。

打开 LoginViewController.swift ,用以下代码覆盖 loginAction(_:) 中的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
@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. 如果用户名或者密码中有一项为空,弹出警告,并且 return 。
  2. Dismiss 键盘,如果它可见的话。
  3. 如果登录按钮的 tagcreateLoginButtonTag,然后开始创建一个新账户。
  4. 接下来 ,从 UserDefaults 读取 hasLoginKey 的值,它标记了 Keychain 中是否有密码保存。 如果 username 的输入框不用空且 hasLoginKey 标记了没有账户保存,那接下来你要保存 usernameUserDefaults
  5. 使用 serviceNamenewAccountName(username)创建一个 KeychainPasswordItem。使用 Swift 的错误处理,如过遇到问题将 catch 掉。
  6. 然后设置 UserDefaults 中的 hasLoginKeytrue 来标记密码已保存。设置登录按钮的 tagloginButtonTag 来改变按钮上的文本,它将在用户下次运行应用的时候提示用户登录,而不是创建账户。 最后 dismiss loginView。
  7. 如果用户想登陆 (标记为 loginButtonTag),你可以调用 checkLogin 来验证用户提供的账户,如果正确,dismiss 登录页。
  8. 如果账户验证不通过,弹窗通知用户。

接下来使用 下列代码替换 checkLogin(username:password:) 方法的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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 的状态给按钮设置适当的文本和tag。

viewDidLoad() 中,调用 super 的下面加入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 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,还要隐藏包含来“Start by creating a username and password“提示的 createInfoLabel。如果你没有一个已存的账户,设置按钮的文本为 Create 然后显示 createInfoLabel 给用户。
  3. 最后将 UserDefaults 中储存的用户名回填回输入框,给用户提供一点方便。

编译运行,输入你想要的用户名密码, 然后点击 Create

现在点击 Logout 然后使用相同的用户名和密码进行登录,你将看到笔记列表。

点击 Logout ,然后再次尝试登录,这次使用不同的密码然后点击登陆,你将看到下面的提示:

恭喜,你完成了使用 Keychain 做验证,下一步 Touch ID。

Touching You, Touching Me

需设置支持

在这一节,你讲添加 Touch ID 到你的项目配合 Keychain 。 Keychain并不是 Touch ID 正常工作的必要条件,它是一个好的解决方案,当Touch ID失败时或者设备不支持时。

打开 Images.xcassets
打开 Resources 文件夹,找到 Touch-icon-lg.png,``Touch-icon-lg@2x.pngTouch-icon-lg@3x.png 选中,然后把这三项全部拖入 Images.xcassets 。Xcode 知道他们是相同的图片,只是分辨率不同:

打开 Main.storyboard ,从 Object Library 拖动一个 ButtonLogin View Controller Scene ,贴在 Create Info Label 底部,加到 Stack View 中。你可以打开 Document Outline ,打开三角形,确认 ButtonStack View 中。它看起来应该是这样的:

如果你需要回顾 Stack Views的知识,可以查阅 Jawwad Ahmad 写的 UIStackView Tutorial: Introducing Stack Views

使用 Attributes Inspector 调整按钮的属性,如下:

  • 设置 Type 为 Custom 。
  • Title 清空。
  • 设置 Image 为 Touch-icon-lg 。

完成之后,按钮的属性应该是这样的:

在确保新加的按钮为选中的状态下,点击 storyboard 底部的 layout bar 上的 Add New Constraints ,设置约束如下:

  • Width 为 66。
  • Height 为 67。

界面现在看起来是这样的:

仍然是在 Main.storyboard 上 ,打开 Assistant Editor,确保可见。

现在,按住 Ctrl 键 ,把按钮加到 LoginViewController.swift 中 ,接在其他属性的下面,像这样:

在弹框中的 Name项中填入 touchIDButton ,点击 Connect:

这样创建了一个 outlet ,在设备没有 Touch ID 可用时隐藏按钮。
现在你需要给按钮添加事件。
按住 Ctrl 拖动相同的按钮到 LoginViewController.swift 中 checkLogin(username:password:):方法的顶部:

在弹窗中,把 Connection 项改为 Action ,设置 Name 为 touchIDLoginAction ,设置 Type 为 UIbutton ,然后点击 Connect:

编译运行确认无误,此时可以在模拟器中运行,因为还没有添加Touch ID 的支持。现在将要开始了。

Adding Local Authentication

实现 Touch ID 就像 引入 Local Authentication 库和调用几个方法一样简单。
Local Authentication 文档中这样说到:

“The Local Authentication framework provides facilities for requesting authentication from users with specified security policies.”
Local Authentication 库通过指定的安全策略为用户提供方便的请求认证。

本例中指定的策略将是你的用户的手指~

在 Xcode 的 Project Navigator 鼠标右键点击 TouchMeIn 组文件夹,点击 New File… ,选择 iOS 下的 Swift File ,点击下一步,确认 TouchMeIn target为选中,保存为 TouchIDAuthentication.swift,点击 Create

打开 TouchIDAuthentication.swift, 在 引入 Foundation 的下面加加入:

1
import LocalAuthentication

创建一个新的类:

1
2
3
class TouchIDAuth {

}

现在你需要引入 LAcontext 类。
在大括号中加入:

1
let context = LAContext()

上文引入一个身份验证上下文,它是 Local Authentication 中的主角,现在需要一个方法来看看用户设备或者模拟器中的 Touch ID是否可用。

创建下面的方法,返回一个 Bool 支持 Touch ID 的话。

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

打开 LoginViewController.swift 。

创建一个属性引入你刚刚创建的类。

1
let touchMe = TouchIDAuth()

viewDidLoad() 中加入:

1
touchIDButton.isHidden = !touchMe.canEvaluatePolicy()

这里使用了 canEvaluatePolicy(_:error:) 来检查设备是否可以实现 Touch ID 认证,如果可以,显示Touch ID按钮,否则就隐藏。

编译运行在模拟器上,你会看到 Touch ID 的 logo 是隐藏的。现在你再编译在一台可以 Touch ID 的真机上,会发现,按钮时显示的。

Putting Touch ID to Work

回到 TouchIDAuthentication.swift 添加一个验证用户的方法,在 TouchIDAuth 类的底部,创建以下方法 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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验证,当执行完成这个方法回调 block 。
  4. 在回调的 block 中,先处理成功的情况。 默认情况下,验证发生在 private 线程,你需要回到住现在来更新UI。 如果验证通过,你将 dismisses 登陆页。

一会儿来处理错误。

选择 LoginViewController.swift 滑到 touchIDLoginAction(_:)
添加代码,使它变成这样:

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

如果用户验证通过,就可以 dismiss 登陆页了。

你可以在你的设备上编译运行,但是等等,如果你没有设置 Touch ID ,或者使用的错误的手指?让我们来处理这个问题。

继续编译运行,看看是否一切正常。

Dealing with Errors

Local Authentication 的重要一步是 响应错误。所以库中引入了一个 LAError 类型。也可能是第二步 canEvaluatePolicy 得到的错误。 你可以弹出警告展示错误信息给用户。你需要从 TouchIDAuth 传递一个消息到 LoginViewController 。幸运的是你有一个完成的回调,你可以使用它传递一个可选的信息。
回到 TouchIDAuthentication.swift ,更新 authenticateUser 方法。
插入一个可选的 message ,用在得到错误信息时,传递出去。

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

找到 //TODO: 替换 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 1
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. 定义一个 string 来接受消息。
  2. 使用 switch 语句为每种错误的情况设置合适的错误提示信息。
  3. 如果任务失败,你显示一个通用的警告框,练习中,你需要对特定的错误代码进行处理,包括:
    • LAError.touchIDNotAvailable touch ID 不可用。
    • LAError.passcodeNotSet 没有启动 Touch ID必要的 passcode。
    • LAError.touchIDNotEnrolled 没有指纹存储。
  4. 在 completion 闭包中传递消息。

iOS 会在相关的警告中响应 LAError.passcodeNotSetLAError.touchIDNotEnrolled

还有一个错误要处理。guard 中,else 只有return.

1
completion("Touch ID not available")

最后更新一个下成功的情况,这个需要返回 nil, 代表没有错误。

1
completion(nil)

完成后,方法应该是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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(_:) 成这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@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. 我们添加一个尾随闭包(trailing closure)来传递可选消息,如果 Touch ID 正常工作, 没有消息。
  2. 使用 if let 展开错误消息,显示警告。
  3. 不用修改,如果没有错误消息,你可以 dismiss 登陆页。

编译运行在一个可以指纹识别的设备上,试一下使用 Touch ID 登陆。