iOS 的用户认证:使用Swift和Ruby on Rails

原文:User Authentication on iOS with Ruby on Rails and Swift
作者:Subhransu
译者:kmyhy
Update:05/13/2015 Updated for Xcode 6.3 / Swift 1.2.

用户登录是大部分 iOS app 都需要的基本功能。无论你正在模仿 Instagram 还是 Facebook,你都需要一个登录/注册功能让用户能够使用这个 app。
对于手机 app 来说,通常需要将用户服务 API 暴露给 app,并让 app 将状态写入服务器。这听起来很简单,就像将大象装进屋里——你需要尽可能地保护用户的安全和隐私。

在本教程里,你将学习到:

  • 如何部署自己的 Ruby o Rails 应用到 Heroku
  • 创建一个 Swift app 与后台服务器交互,对用户进行认证
  • 保证 API 从设计到存储上的安全性
  • 上传自拍照

这是一个关于自拍的 app?没错,你需要做一些练习,现在年轻人都喜欢自拍,我们就来做一个允许用户登录并上传自拍照的 app。从嘟起嘴到瞪大双眼,用户能够安全地上传和管理自拍照,而无需担心他们的照片会泄露出去,从而成为另一个“迷因”(网红)牺牲品。
这里是 app 的演示视频:

Your browser does not support the video tag.

开始

首先要创建一个 Heroku 账号并部署 Rails 后端。

创建 Rails 应用

首先克隆一份 railssuth git 库。这是一个简单 Ruby on Rails app,包含了所有在 iOS app 中要用到的功能。你可以直接将它部署到 Heroku,这样就不需要在本地安装 Ruby on Rails 了。
打开终端程序,终端程序位于 Applications\Utilities\Terminal,然后在终端中输入:

git clone https://github.com/subhransu/railsauth

如果你的 Mac 未安装 git,你可以参考这篇文章。

创建 Heroku 账号

然后创建一个 Heroku 账号用于部署 Rails。
如果你已经有了 Heroku 账号,并且已经安装了 Heroku Toolbelt,你可以跳到下一节。
进入 heroku.com 网站,点击 Sign up for free 链接。

将 Rails 应用部署到 Heroku

打开终端,输入:

heroku login

输入你的 Heroku 邮箱账号和密码,回车。当提示到需要创建 SSK key 时,按 Y 回车。

Your Heroku account does not have a public ssh key uploaded.
Could not find an existing public key at ~/.ssh/id_rsa.pub
Would you like to generate one? [Yn] Y

然后终端窗口提示 “Authentication Successful” 信息。这表明基本的 setup 完成,你可以创建第一个 Heroku 应用。
在终端窗口中输入命令:

heroku create

注意你的 Heroku 应用和 git 库 URL,它们应当类似于这个样子:

Creating XXXXX-XXX-1234... done, stack is cedar
http://XXXXX-XXX-1234.herokuapp.com/ | git@heroku.com:XXXXX-XXX-1234.git

现在,回到前面克隆的 Rails 应用——已经快忘记它了吧?
在终端中,将目录切换至 railsauth 应用所在的目录:

cd ~/location/where/you/cloned/railsauth/directory

然后,为你的 Heroku 库添加一个远程分支,地址就是你上一步创建的 Heroku 应用的 git 地址。输入下列命令,注意将 url 占位符换成你自己的:

git remote add heroku git@heroku.com:XXXXX-XXX-1234.git

然后输入下列命令,将 railsauth 部署到 Heroku:

git push heroku master

当提示请允许连接 Heroku 时,输入 yes。

The authenticity of host 'heroku.com (50.19.85.132)' can't be established.
RSA key fingerprint is 8b:48:5e:67:0e:c9:16:47:32:f2:87:0c:1f:c8:60:ad.
Are you sure you want to continue connecting (yes/no)? yes

恭喜你!你成功将 Rails app 部署到了 Heroku。你可以通过下列命令测试:

heroku open

这将在浏览器中打开 Rails app。你会看到欢迎信息: “Glad to find you here!”

配置 Amazon S3 (Simple Storage Service)

我们将自拍照放到了 Amazon S3。这是一个开发者常用的主流文件存储服务。Web 开发者经常用 S3 服务存放他们的文件资源。

进入 Amazon Web Service (AWS) Portal,点击 Create a Free Account

根据提示创建免费账号,然后选择 I am a new user。

注意:可能需要提供你的信用卡信息。Amazon 有 12 个月的免费期,当然你只能使用 Free Tier (免费套餐)规定的功能。对于本教程和其它简单项目来说,免费套餐已经足够了。你可以随时在不想使用 S3 或其它 Amazon 服务时取消订阅。

然后,回到 Amazon Web Service (AWS) Portal,点击 Sign In。

点击 S3 管理控制台:

设置 Heroku 环境变量

你应该用环境变量来保存 key。永远不应该在代码中用硬编码来保存 key。

打开终端,依次设置如下变量。注意,用你自己的 AWS 密钥和 S3 bucket 名替换其中的占位符。

heroku config:set AWS_ACCESS_KEY_ID=<Your_AWS_Access_Key_Id>
heroku config:set AWS_SECRET_ACCESS_KEY=<Your_AWS_Secret_Key>
heroku config:set S3_BUCKET_NAME="yourname-railsauth-assets"

然后,你需要创建一个 API 用户名和密码,以防止 API 被别人访问。
你可以用这个密码生成器创建一个 64 位密码。
然后,在终端中,用以下命令创建 API 用户名和密码:

heroku config:set API_AUTH_NAME=<USERNAME> API_AUTH_PASSWORD=<PASSWORD>

举一个例子,你可以这样使用该命令:

heroku config:set API_AUTH_NAME=MYAPIADMINNAME API_AUTH_PASSWORD=20hWfR1QM75fFJ2mjQNHkslpEF9bXN0SiBzEqDB47QIxBmw9sTR9q0B7kiS16m7e

关于 API

现在,服务器建立好了,你可以在 Swift app 中使用下面 8 个 API:

  • Sign Up 注册
  • Sign In 登录
  • Get Token 获取 Token
  • Upload Photo 上传照片
  • Get Photos 读取照片
  • Delete Photo 删除照片
  • Reset Password 重置密码
  • Clear Token 清除 Token

前三个 API 实现了 HTTP 的 Basic 授权认证机制。其他 API 则需要用户名和加密 Token 才能访问。如果没有用户名和 Token,则任何人——包括你都无法直接访问这些 API。同时,这个 Token 是临时的,有时效性。
用户密码用 AES(Advanced Encryption Standard)算法进行加密。
这篇文档列出了这些 API 的详细介绍,包括请求格式和响应格式。

开始创建 Swift app

后台部分已经完成,现在开始编写 Swift。

本教程将以一个启动项目开始工作。
打开 Main.storyboard,你会看到 UI 部分已经就绪;这样我们就可以直接开始编写 Swift 了:

override func viewDidAppear(animated: Bool) {super.viewDidAppear(true)let defaults = NSUserDefaults.standardUserDefaults()if defaults.objectForKey("userLoggedIn") == nil {if let loginController = self.storyboard?.instantiateViewControllerWithIdentifier("ViewController") as? ViewController {self.navigationController?.presentViewController(loginController, animated: true, completion: nil)}}
}

这里,我们判断用户是否已经登录,如果未登录则提示用户登录。它通过检查存放在 NSUserDefaults 中的 userLoggedIn 变量来进行判断。

注意:因为 NSUserDefaults 中存放的东西在 app 重启后不会消失,因此永远不要用 NSDefaults 来存储敏感信息,比如用户邮箱或者密码。NSDefaults 位于 app 的 Library 目录,这个目录有可能被任何获取到设备的人所访问。因此,最好用它来存放非敏感信息比如偏好设置、临时变量,就像前面一样。

运行程序,你会看到登录界面:

static let API_AUTH_NAME = "<YOUR_HEROKU_API_ADMIN_NAME>"
static let API_AUTH_PASSWORD = "<YOUR_HEROKU_API_PASSWORD>"
static let BASE_URL = "https://XXXXX-XXX-1234.herokuapp.com/api"

注意 BASE_URL 以 /api 结尾。

然后,开始实现认证流程。

注册和登录

打开 ViewController.swift。将 signupBtnTapped(sender:) 方法代码修改为:

@IBAction func signupBtnTapped(sender: AnyObject) {// Code to hide the keyboards for text fieldsif self.signupNameTextField.isFirstResponder() {self.signupNameTextField.resignFirstResponder()}if self.signupEmailTextField.isFirstResponder() {self.signupEmailTextField.resignFirstResponder()}if self.signupPasswordTextField.isFirstResponder() {self.signupPasswordTextField.resignFirstResponder()}// start activity indicatorself.activityIndicatorView.hidden = false// validate presence of all required parametersif count(self.signupNameTextField.text) > 0 && count(self.signupEmailTextField.text) > 0 && count(self.signupPasswordTextField.text) > 0 {makeSignUpRequest(self.signupNameTextField.text, userEmail: self.signupEmailTextField.text, userPassword: self.signupPasswordTextField.text)} else {self.displayAlertMessage("Parameters Required", alertDescription: "Some of the required parameters are missing")}
}

这里,我们将 Text Field 弹出的键盘隐藏,然后检查所需的参数是否为空。然后调用 makeSignUpRequest(userName:userEmail:userPassword:) 方法进行注册。
makeSignUpRequest(userName:userEmail:userPassword:)方法实现如下:

func makeSignUpRequest(userName:String, userEmail:String, userPassword:String) {// 1. Create HTTP request and set request headerlet httpRequest = httpHelper.buildRequest("signup", method: "POST",authType: HTTPRequestAuthType.HTTPBasicAuth)// 2. Password is encrypted with the API keylet encrypted_password = AESCrypt.encrypt(userPassword, password: HTTPHelper.API_AUTH_PASSWORD)// 3. Send the request BodyhttpRequest.HTTPBody = "{\"full_name\":\"\(userName)\",\"email\":\"\(userEmail)\",\"password\":\"\(encrypted_password)\"}".dataUsingEncoding(NSUTF8StringEncoding)// 4. Send the requesthttpHelper.sendRequest(httpRequest, completion: {(data:NSData!, error:NSError!) inif error != nil {let errorMessage = self.httpHelper.getErrorMessage(error)self.displayAlertMessage("Error", alertDescription: errorMessage as String)return}self.displaSigninView()self.displayAlertMessage("Success", alertDescription: "Account has been created")})
}

接下来我们逐一分段解释这些代码:

  1. 用 buildRequest(_:method:authType:) 方法创建一个 NSMutableURLRequest 对象,并设置 HTTP 请求参数。buildRequest(_:method:authType:) 方法是一个工具方法,它的实现在 HTTPHelper 结构体中。
  2. 用 AES 对用户密码进行加密。在这个方法的第二个参数中,我们使用 API 密码作为加密密钥。
  3. 创建 JSON 请求体,在其中包含所有必要的参数和值。对于注册而言,我们需要知道用户的 full_name(用户名)、email(邮箱地址)、password(经过加密的密码)。
  4. 用 HTTPHelper 的 sendRequest(_:completion:) 方法创建一个 NSURLSessionDataTask, 向 Rails 服务器发起一个创建新用户的请求。当用户账号创建成功或者失败,用户都会收到相应的消息提示。

上面的代码使用到了 HTTPHelper.swift 中的两个工具函数:

  • buildRequest(_:method:authType:)
  • sendRequest(_:completion:)

让我们看一眼这两个方法的实现。打开 HTTPHelper.swift。

buildRequest(_:method:authType:) 用于创建一个 NSMutableURLRequest 对象,并设置它的 HTTP 参数。

func buildRequest(path: String!, method: String, authType: HTTPRequestAuthType,requestContentType: HTTPRequestContentType = HTTPRequestContentType.HTTPJsonContent, requestBoundary:String = "") -> NSMutableURLRequest {// 1. Create the request URL from pathlet requestURL = NSURL(string: "\(HTTPHelper.BASE_URL)/\(path)")var request = NSMutableURLRequest(URL: requestURL!)// Set HTTP request method and Content-Typerequest.HTTPMethod = method// 2. Set the correct Content-Type for the HTTP Request. This will be multipart/form-data for photo upload request and application/json for other requests in this appswitch requestContentType {case .HTTPJsonContent:request.addValue("application/json", forHTTPHeaderField: "Content-Type")case .HTTPMultipartContent:let contentType = "multipart/form-data; boundary=\(requestBoundary)"request.addValue(contentType, forHTTPHeaderField: "Content-Type")}// 3. Set the correct Authorization header.switch authType {case .HTTPBasicAuth:// Set BASIC authentication headerlet basicAuthString = "\(HTTPHelper.API_AUTH_NAME):\(HTTPHelper.API_AUTH_PASSWORD)"let utf8str = basicAuthString.dataUsingEncoding(NSUTF8StringEncoding)let base64EncodedString = utf8str?.base64EncodedStringWithOptions(NSDataBase64EncodingOptions(0))request.addValue("Basic \(base64EncodedString!)", forHTTPHeaderField: "Authorization")case .HTTPTokenAuth:// Retreieve Auth_Token from Keychainif let userToken = KeychainAccess.passwordForAccount("Auth_Token", service: "KeyChainService") as String? {// Set Authorization headerrequest.addValue("Token token=\(userToken)", forHTTPHeaderField: "Authorization")}}return request
}
  1. 创建 NSMutableURLRequest 对象,设定 HTTP 方法。
  2. 将 Content-Type 设为 application/json 或者 multipart/form-data,默认为application/json,这将告诉服务器请求体中是 JSON 数据。
  3. 设置 HTTP 头的 Authorization 字段,以保护你的 API 和用户数据。对于注册而言,我们应当设置为 HTTP Basic Authentication。当调用这个方法时,第三个参数传入的值将用于设置 Authorization HTTP 头。

Basic Authentication 是阻击 API 攻击的第一条防线,它用 API 用户名和 API 密码组成一个字符串,并编码成 Base64 编码以提供额外的保护。
除非用户拥有正确的用户名和密码,否则无法访问 API。

注意:尽管听起来挺安全,但 Basic Authentication 并不是最好的方法,因为有许多办法可以绕过它,但对于本教程而言,用这种方法就行了。

sendRequest(_:completion:) 方法用于创建 NSURLSession Task 对象,然后用该对象向服务器发送请求:

func sendRequest(request: NSURLRequest, completion:(NSData!, NSError!) -> Void) -> () {// Create a NSURLSession tasklet session = NSURLSession.sharedSession()let task = session.dataTaskWithRequest(request) { (data: NSData!, response: NSURLResponse!, error: NSError!) inif error != nil {dispatch_async(dispatch_get_main_queue(), { () -> Void incompletion(data, error)})return}dispatch_async(dispatch_get_main_queue(), { () -> Void inif let httpResponse = response as? NSHTTPURLResponse {if httpResponse.statusCode == 200 {completion(data, nil)} else {var jsonerror:NSError?if let errorDict = NSJSONSerialization.JSONObjectWithData(data, options: NSJSONReadingOptions.AllowFragments, error:&jsonerror) as? NSDictionary {let responseError : NSError = NSError(domain: "HTTPHelperError", code: httpResponse.statusCode, userInfo: errorDict as? [NSObject : AnyObject])completion(data, responseError)}}}})}// start the tasktask.resume()
}

运行程序,当 app 打开,点击 Don’t have an account yet? 按钮,创建一个新账号。

注意:如果请求失败,请检查 HTTPHelper.swift 中的 API_AUTH_NAME, API_AUTH_PASSWORD 和 BASE_URL 是否正确。

@IBAction func signinBtnTapped(sender: AnyObject) {// resign the keyboard for text fieldsif self.signinEmailTextField.isFirstResponder() {self.signinEmailTextField.resignFirstResponder()}if self.signinPasswordTextField.isFirstResponder() {self.signinPasswordTextField.resignFirstResponder()}// display activity indicatorself.activityIndicatorView.hidden = false// validate presense of required parametersif count(self.signinEmailTextField.text) > 0 && count(self.signinPasswordTextField.text) > 0 {makeSignInRequest(self.signinEmailTextField.text, userPassword: self.signinPasswordTextField.text)} else {self.displayAlertMessage("Parameters Required", alertDescription: "Some of the required parameters are missing")}
}

当所有的字段都不为空时,上述代码将调用 makeSignInRequest(userEmail:userPassword) 方法去发送登录请求。然后来实现 makeSignInRequest(userEmail:userPassword) 方法:

func makeSignInRequest(userEmail:String, userPassword:String) {// Create HTTP request and set request Bodylet httpRequest = httpHelper.buildRequest("signin", method: "POST",authType: HTTPRequestAuthType.HTTPBasicAuth)let encrypted_password = AESCrypt.encrypt(userPassword, password: HTTPHelper.API_AUTH_PASSWORD)httpRequest.HTTPBody = "{\"email\":\"\(self.signinEmailTextField.text)\",\"password\":\"\(encrypted_password)\"}".dataUsingEncoding(NSUTF8StringEncoding);httpHelper.sendRequest(httpRequest, completion: {(data:NSData!, error:NSError!) in// Display errorif error != nil {let errorMessage = self.httpHelper.getErrorMessage(error)self.displayAlertMessage("Error", alertDescription: errorMessage as String)return}// hide activity indicator and update userLoggedInFlagself.activityIndicatorView.hidden = trueself.updateUserLoggedInFlag()var jsonerror:NSError?let responseDict = NSJSONSerialization.JSONObjectWithData(data,options: NSJSONReadingOptions.AllowFragments, error:&jsonerror) as! NSDictionaryvar stopBool : Bool// save API AuthToken and ExpiryDate in Keychainself.saveApiTokenInKeychain(responseDict)})
}

这段代码和 makeSignUpRequest(userName:userEmail:userPassword:) 差不多,不同的是当用户成功登录后,会收到一个 api_token 和 authtoken_expiry 日期。
在后面的请求中,你需要用 api_authtoken 替换原来的 HTTP Basic Authentication。

实现两个方法,分别用于更新 NSUserDefaults 中的 userLoggedIn 变量,以及保存 API Token:

Implement the following methods that update the userLoggedIn flag in NSUserDefaults and save the API token respectively:
func updateUserLoggedInFlag() {// Update the NSUserDefaults flaglet defaults = NSUserDefaults.standardUserDefaults()defaults.setObject("loggedIn", forKey: "userLoggedIn")defaults.synchronize()
}func saveApiTokenInKeychain(tokenDict:NSDictionary) {// Store API AuthToken and AuthToken expiry date in KeyChaintokenDict.enumerateKeysAndObjectsUsingBlock({ (dictKey, dictObj, stopBool) -> Void invar myKey = dictKey as! Stringvar myObj = dictObj as! Stringif myKey == "api_authtoken" {KeychainAccess.setPassword(myObj, account: "Auth_Token", service: "KeyChainService")}if myKey == "authtoken_expiry" {KeychainAccess.setPassword(myObj, account: "Auth_Token_Expiry", service: "KeyChainService")}})self.dismissViewControllerAnimated(true, completion: nil)
}

api_authtoken 是敏感数据,你不能把它放到 NSUserDefaults 进行存储,因为那对于黑客来说简直就像是如探囊取物一样方便。所以,这里我们使用 keychain 来进行存储。

在 iOS 中,keychain 是一个加密容器,用于存储敏感数据。saveApiTokenInKeychain(tokenDict:) 方法使用了 keychain API,这样会对键值对进行加密存储。
解散当前视图后,app 就会显示出 SelfieCollectionViewController 视图。

来测试一把吧!运行 app,当登录成功之后,你会看到一个空白窗口。

显示已有的照片

打开 SelfieCollectionViewController.swift ,将 viewDidAppear(_:) 修改为:

override func viewDidAppear(animated: Bool) {super.viewDidAppear(true)let defaults = NSUserDefaults.standardUserDefaults()if defaults.objectForKey("userLoggedIn") == nil {if let loginController = self.storyboard?.instantiateViewControllerWithIdentifier("ViewController") as? ViewController {self.navigationController?.presentViewController(loginController, animated: true, completion: nil)}} else {// check if API token has expiredlet dateFormatter = NSDateFormatter()dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"let userTokenExpiryDate : String? = KeychainAccess.passwordForAccount("Auth_Token_Expiry", service: "KeyChainService")let dateFromString : NSDate? = dateFormatter.dateFromString(userTokenExpiryDate!)let now = NSDate()let comparision = now.compare(dateFromString!)// check if should fetch new dataif shouldFetchNewData {shouldFetchNewData = falseself.setNavigationItems()loadSelfieData()}// logout and ask user to sign in again if token is expiredif comparision != NSComparisonResult.OrderedAscending {self.logoutBtnTapped()
}

首先判断用户是否已经登录,如果不,或者 API token 过期,我们会提示用户进行登录。否则,调用 loadSelfieData()
然后实现 loadSelfieData() 方法:

func loadSelfieData () {// Create HTTP request and set request Bodylet httpRequest = httpHelper.buildRequest("get_photos", method: "GET",authType: HTTPRequestAuthType.HTTPTokenAuth)// Send HTTP request to load existing selfiehttpHelper.sendRequest(httpRequest, completion: {(data:NSData!, error:NSError!) in// Display errorif error != nil {let errorMessage = self.httpHelper.getErrorMessage(error)let errorAlert = UIAlertView(title:"Error", message:errorMessage as String, delegate:nil, cancelButtonTitle:"OK")errorAlert.show()return}var eror: NSError?if let jsonDataArray = NSJSONSerialization.JSONObjectWithData(data, options: NSJSONReadingOptions(0), error: &eror) as? NSArray! {// load the collection view with existing selfiesif jsonDataArray != nil {for imageDataDict in jsonDataArray {var selfieImgObj = SelfieImage()selfieImgObj.imageTitle = imageDataDict.valueForKey("title") as! StringselfieImgObj.imageId = imageDataDict.valueForKey("random_id") as! StringselfieImgObj.imageThumbnailURL = imageDataDict.valueForKey("image_url") as! Stringself.dataArray.append(selfieImgObj)}self.collectionView?.reloadData()}}})
}

这段代码用 GET 请求抓取用户照片。
当请求完成,它会遍历 JSON 对象数组并保存到 dataArray 属性,dataArray 会用于渲染 Collection View Cell 的图片和标题。

这里不再使用 HTTP Basic Authentication 方式调用 buildRequest(_:method:authType:requestContentType:requestBoundary:) 方法,而是使用 HTTP Token Authentication。这是通过 authType 参数指定的。

httpHelper.buildRequest("get_photos", method: "GET", authType: HTTPRequestAuthType.HTTPTokenAuth)

buildRequest(_:method:authType:requestContentType:requestBoundary:) 方法会从 keychain 读取 API auth token 并放入到 HTTP 头 Authorization 中。

// This is implemented in buildRequest method in HTTPHelper struct case .HTTPTokenAuth:
// Retreieve Auth_Token from Keychain
if let userToken = KeychainAccess.passwordForAccount("Auth_Token", service: "KeyChainService") as String? {// Set Authorization headerrequest.addValue("Token token=\(userToken)", forHTTPHeaderField: "Authorization")
}

运行程序。如果你之前至少登录过一次,你会看到如下界面。否则 app 会提示你进行登录。

override func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {let cell = collectionView.dequeueReusableCellWithReuseIdentifier(reuseIdentifier,forIndexPath: indexPath) as! SelfieCollectionViewCell// Configure the cellvar rowIndex = self.dataArray.count - (indexPath.row + 1)var selfieRowObj = self.dataArray[rowIndex] as SelfieImagecell.backgroundColor = UIColor.blackColor()cell.selfieTitle.text = selfieRowObj.imageTitlevar imgURL: NSURL = NSURL(string: selfieRowObj.imageThumbnailURL)!// Download an NSData representation of the image at the URLlet request: NSURLRequest = NSURLRequest(URL: imgURL)NSURLConnection.sendAsynchronousRequest(request, queue: NSOperationQueue.mainQueue(),completionHandler: {(response: NSURLResponse!,data: NSData!,error: NSError!) -> Void inif error == nil {var image = UIImage(data: data)dispatch_async(dispatch_get_main_queue(), {cell.selfieImgView.image = image})} else {println("Error: \(error.localizedDescription)")}})return cell
}

rowIndex 变量的使用用于将最近的照片放在上面,而将较早的照片放在下面。然后设置每个 cell 的标题和图片。它在主线程中通过异步方式下载远程图片。
你已经完成了将已有照片显示给用户的功能,但你至少要在服务器上有一张照片才能显示!

上传照片到服务器

当用户点击导航栏上的相机图标,会调用 cameraBtnTapped(_:)方法。这个方法调用 displayCameraControl() 方法显示一个 image picker controller。
在 SelfieCollectionViewController.swift 中找到 imagePickerController(_:didFinishPickingMediaWithInfo:) 方法,修改其代码为:

func imagePickerController(picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [NSObject : AnyObject]) {// dismiss the image picker controller windowself.dismissViewControllerAnimated(true, completion: nil)var image:UIImage// fetch the selected imageif picker.allowsEditing {image = info[UIImagePickerControllerEditedImage] as! UIImage} else {image = info[UIImagePickerControllerOriginalImage] as! UIImage}presentComposeViewControllerWithImage(image)
}

在这段代码中,我们读取用户选定的照片并调用 presentComposeViewControllerWithImage(_:) 方法。
imagePickerController(_:didFinishPickingMediaWithInfo:) 方法在用户选定某张图片后调用。然后实现 presentComposeViewControllerWithImage(_:) 方法为:

func presentComposeViewControllerWithImage(image:UIImage!) {// instantiate compose view controller to capture a captionif let composeVC: ComposeViewController = self.storyboard?.instantiateViewControllerWithIdentifier("ComposeViewController") as? ComposeViewController {composeVC.composeDelegate = selfcomposeVC.thumbImg = image// set the navigation controller of compose view controllelet composeNavVC = UINavigationController(rootViewController: composeVC)// present compose view controllerself.navigationController?.presentViewController(composeNavVC, animated: true, completion: nil)}
}

presentComposeViewControllerWithImage(_:) 方法会创建一个 ComposeViewController 并显示它。在 ComposeViewController 中,你需要让用户为每张照片添加一个标题。
在 SelfieCollectionViewController.swift 定义了几个 extension。这些 extension 实现了某些协议并按照不同的协议将相关方法分成独立的几组。例如, camera extension 包含了一系列与显示相机有关的方法和与 image picker 协议相关的方法。

打开 ComposeViewController.swift 将 viewDidLoad() 方法改为:

override func viewDidLoad() {super.viewDidLoad()// Do any additional setup after loading the view.self.titleTextView.becomeFirstResponder()self.thumbImgView.image = thumbImgself.automaticallyAdjustsScrollViewInsets = falseself.activityIndicatorView.layer.cornerRadius = 10setNavigationItems()
}

这里,我们将 thumbnail 图片设置为用户所选的照片,并要求用户输入标题。

然后,将 postBtnTapped() 修改为:

func postBtnTapped() {// resign the keyboard for text viewself.titleTextView.resignFirstResponder()self.activityIndicatorView.hidden = false// Create Multipart Upload requestvar imgData : NSData = UIImagePNGRepresentation(thumbImg)let httpRequest = httpHelper.uploadRequest("upload_photo", data: imgData, title: self.titleTextView.text)httpHelper.sendRequest(httpRequest, completion: {(data:NSData!, error:NSError!) in// Display errorif error != nil {let errorMessage = self.httpHelper.getErrorMessage(error)self.displayAlertMessage("Error", alertDescription: errorMessage as String)return}var eror: NSError?let jsonDataDict = NSJSONSerialization.JSONObjectWithData(data, options: NSJSONReadingOptions(0), error: &eror) as! NSDictionaryvar selfieImgObjNew = SelfieImage()selfieImgObjNew.imageTitle = jsonDataDict.valueForKey("title") as! StringselfieImgObjNew.imageId = jsonDataDict.valueForKey("random_id") as! StringselfieImgObjNew.imageThumbnailURL = jsonDataDict.valueForKey("image_url") as! Stringself.composeDelegate.reloadCollectionViewWithSelfie(selfieImgObjNew)self.activityIndicatorView.hidden = trueself.dismissViewControllerAnimated(true, completion: nil)})
}

这段代码用 uploadRequest(_:data:title:) 方法,而不用 buildRequest(_:method:authType:requestContentType:requestBoundary:) 方法来创建请求。
如果你在 HTTPHelper.swift 中查看 uploadRequest(path:data:title:) 方法,你会注意到它的实现与

func uploadRequest(path: String, data: NSData, title: String) -> NSMutableURLRequest {let boundary = "---------------------------14737809831466499882746641449"var request = buildRequest(path, method: "POST", authType: HTTPRequestAuthType.HTTPTokenAuth,requestContentType:HTTPRequestContentType.HTTPMultipartContent, requestBoundary:boundary) as NSMutableURLRequestlet bodyParams : NSMutableData = NSMutableData()// build and format HTTP body with data// prepare for multipart form uplaodlet boundaryString = "--\(boundary)\r\n"let boundaryData = boundaryString.dataUsingEncoding(NSUTF8StringEncoding) as NSData!bodyParams.appendData(boundaryData)// set the parameter namelet imageMeteData = "Content-Disposition: attachment; name=\"image\"; filename=\"photo\"\r\n".dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)bodyParams.appendData(imageMeteData!)// set the content typelet fileContentType = "Content-Type: application/octet-stream\r\n\r\n".dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)bodyParams.appendData(fileContentType!)// add the actual image databodyParams.appendData(data)let imageDataEnding = "\r\n".dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)bodyParams.appendData(imageDataEnding!)let boundaryString2 = "--\(boundary)\r\n"let boundaryData2 = boundaryString.dataUsingEncoding(NSUTF8StringEncoding) as NSData!bodyParams.appendData(boundaryData2)// pass the caption of the imagelet formData = "Content-Disposition: form-data; name=\"title\"\r\n\r\n".dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)bodyParams.appendData(formData!)let formData2 = title.dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)bodyParams.appendData(formData2!)let closingFormData = "\r\n".dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)bodyParams.appendData(closingFormData!)let closingData = "--\(boundary)--\r\n"let boundaryDataEnd = closingData.dataUsingEncoding(NSUTF8StringEncoding) as NSData!bodyParams.appendData(boundaryDataEnd)request.HTTPBody = bodyParamsreturn request
}

开头的几句应该是熟悉的——这里用 buildRequest(_:method:authType:requestContentType:requestBoundary:) 方法创建了一个 NSMutableURLRequest 并设置 Authorization HTTP 头。但是 buildRequest(_:method:authType:requestContentType:requestBoundary:) 方法与之前的版本相比新增了两个参数:

buildRequest(path, method: "POST", authType: HTTPRequestAuthType.HTTPTokenAuth, requestContentType:HTTPRequestContentType.HTTPMultipartContent, requestBoundary:boundary) as NSMutableURLRequest
  • requestContentType:HTTPRequestContentType.HTTPMultipartContent
  • requestBoundary:boundary

这里的 Content-Type 和别的请求中使用的 Content-Type 不同。 它没有使用 application/json,而是使用 multipart/form-data。也就是告诉服务器,请求体中包含了不只一段数据。每一段数据都以一个 boundary(分界符)分隔。
因此在后面的几行代码中,你会看到 boudary 被使用多次。
一般,服务器用 & 符号来分割请求参数和值。但是上传图片时,你发送的是二进制数据,数据中可能包含1至多个 & 符号,因此为了能够分隔参数,只能使用分解符来分隔所发送的数据。

打开 SelfieCollectionViewController.swift 将 reloadCollectionViewWithSelfie(_:) 方法修改为:

func reloadCollectionViewWithSelfie(selfieImgObject: SelfieImage) {self.dataArray.append(selfieImgObject)self.collectionView?.reloadData()
}

这会刷新 dataArray 数组并刷新 Collection View。
运行程序。上传一张照片,选一张好看点的!:]

注意:如果你使用模拟器调试,你可能是从相册中选择图片的。如果相册为空,打开 Safari,用 Google 搜索并找到合适的图片,在图片上按住鼠标左键弹出快捷菜单,选择 save the image。

删除照片

如果用户觉得照片上鼻子太亮或者牙齿上沾有东西怎么办?你需要提供一个方法,让用户能够删除所上传的照片。
打开 SelfieCollectionViewController.swift 将 collectionView(_:didSelectItemAtIndexPath:) 替换为:

override func collectionView(collectionView: UICollectionView, didSelectItemAtIndexPath indexPath: NSIndexPath) {// fetch the Selfie Image Objectvar rowIndex = self.dataArray.count - (indexPath.row + 1)var selfieRowObj = self.dataArray[rowIndex] as SelfieImagepushDetailsViewControllerWithSelfieObject(selfieRowObj)
}

这里用一个 SelfieImage 对象来调用 pushDetailsViewControllerWithSelfieObject(_:) 方法。这个方法实现如下:

func pushDetailsViewControllerWithSelfieObject(selfieRowObj:SelfieImage!) {// instantiate detail view controllerif let detailVC = self.storyboard?.instantiateViewControllerWithIdentifier("DetailViewController") as? DetailViewController {detailVC.editDelegate = selfdetailVC.selfieCustomObj = selfieRowObj// push detail view controller to the navigation stackself.navigationController?.pushViewController(detailVC, animated: true)}
}

这里从故事板中创建了一个 DetailViewController,并设置 selfieCustomObje 属性。当用户点击一张照片时,DetailViewController 显示。这时用户可以查看照片、删除不喜欢的照片。
打开 DetailViewController.swift 将 viewDidLoad() 修改为:

override func viewDidLoad() {super.viewDidLoad()self.activityIndicatorView.layer.cornerRadius = 10self.detailTitleLbl.text = self.selfieCustomObj.imageTitlevar imgURL = NSURL(string: self.selfieCustomObj.imageThumbnailURL)// Download an NSData representation of the image at the URLlet request = NSURLRequest(URL: imgURL!)NSURLConnection.sendAsynchronousRequest(request, queue: NSOperationQueue.mainQueue(), completionHandler: {(response: NSURLResponse!,data: NSData!,error: NSError!) -> Void inif error == nil {var image = UIImage(data: data)dispatch_async(dispatch_get_main_queue(), {self.detailThumbImgView.image = image})} else {println("Error: \(error.localizedDescription)")}})
}

这里设置了图片的标题,并异步加载了 Amazon S3 中存储的照片。当用户删除照片时,调用 deleteBtnTapped(_:) 方法。
deleteBtnTapped(_:) 方法实现如下:

@IBAction func deleteBtnTapped(sender: AnyObject) {// show activity indicatorself.activityIndicatorView.hidden = false// Create HTTP request and set request Bodylet httpRequest = httpHelper.buildRequest("delete_photo", method: "DELETE", authType: HTTPRequestAuthType.HTTPTokenAuth)httpRequest.HTTPBody = "{\"photo_id\":\"\(self.selfieCustomObj.imageId)\"}".dataUsingEncoding(NSUTF8StringEncoding);httpHelper.sendRequest(httpRequest, completion: {(data:NSData!, error:NSError!) in// Display errorif error != nil {let errorMessage = self.httpHelper.getErrorMessage(error)self.displayAlertMessage("Error", alertDescription: errorMessage as String)return}self.editDelegate.deleteSelfieObjectFromList(self.selfieCustomObj)self.activityIndicatorView.hidden = trueself.navigationController?.popToRootViewControllerAnimated(true)})
}

这里创建了一个 HTTP DELETE 请求,以从服务器删除某张照片。在完成块中,调用 deleteSelfieObjectFromList(_:)方法,这个方法从本地的照片列表中删除照片并刷新 Collection View。
打开 SelfieCollectionViewController.swift 添加两个方法:

// This is in the base SelfieCollectionViewController class implementation
func removeObject<T:Equatable>(inout arr:Array<T>, object:T) -> T? {if let indexOfObject = find(arr,object) {return arr.removeAtIndex(indexOfObject)}return nil
}
// This is in edit selfie extension
func deleteSelfieObjectFromList(selfieImgObject: SelfieImage) {if contains(self.dataArray, selfieImgObject) {removeObject(&self.dataArray, object: selfieImgObject)self.collectionView?.reloadData()}
}

第一个方法从数组中删除一个对象,第二个方法属于协议方法,该方法实现了删除本地照片并刷新 Collection View 的功能。

运行程序。删除最后一张照片——有句话怎么说的?真是往事不堪回首。这对于熊猫先生是一个好消息——因为自从他变成了搜索引擎优化的代名词后,他对自拍照的要求变得十分挑剔。

注销

打开 SelfieCollectionViewController.swift 将 logoutBtnTapped() 方法替换为:

func logoutBtnTapped() {clearLoggedinFlagInUserDefaults()clearDataArrayAndReloadCollectionView()clearAPITokensFromKeyChain()// Set flag to display Sign In viewshouldFetchNewData = trueself.viewDidAppear(true)
}

然后实现前面调到的 3 个方法:

// 1. Clears the NSUserDefaults flag
func clearLoggedinFlagInUserDefaults() {let defaults = NSUserDefaults.standardUserDefaults()defaults.removeObjectForKey("userLoggedIn")defaults.synchronize()
}// 2. Removes the data array
func clearDataArrayAndReloadCollectionView() {self.dataArray.removeAll(keepCapacity: true)self.collectionView?.reloadData()
}// 3. Clears API Auth token from Keychain
func clearAPITokensFromKeyChain () {// clear API Auth Tokenif let userToken = KeychainAccess.passwordForAccount("Auth_Token", service: "KeyChainService") {KeychainAccess.deletePasswordForAccount(userToken, account: "Auth_Token", service: "KeyChainService")}// clear API Auth Expiryif let userTokenExpiryDate = KeychainAccess.passwordForAccount("Auth_Token_Expiry", service: "KeyChainService") {KeychainAccess.deletePasswordForAccount(userTokenExpiryDate, account: "Auth_Token_Expiry", service: "KeyChainService")}
}

这些方法的用途分别是:

  1. 从 NSUserDefaults 中删除 userLoggedIn 变量。
  2. 清空 dataArray,刷新 CollectionView。这样当新用户登录到 app 后,他们不会看到缓存数据。
  3. 清除 keychain 中的 API auth token 和凭证。

logoutBtnTapped()还会在 API auth token 过期时触发,从而让用户重新登录获取新的 token。

运行程序,点击 Logout,你将返回到登录界面。

结语

教程使用的示例项目在此处下载。

恭喜你!你成功地在 Heroku 上创建了后台服务器,用于提供 API,配置 Amazon S3 bucket 用于存储用户的自拍照,并创建了一个 app 使用对应的服务让用户将照片上传到服务器。
毫无疑问,这个 app 能够将你的心情、精彩瞬间捕捉下来。你摆了一个表示胜利的 pose,不是吗?

请参考一下 OWASP 的 Authentication 速查表,它是一个免费的论述安全的参考资料。
感谢你阅读了这篇教程!如果你有任何疑问、评论,或者关于安全 API 设计或移动安全的特殊需求,请在下面留言,我将乐于提供帮助。

iOS 的用户认证:使用Swift和Ruby on Rail相关推荐

  1. Spring Security Oauth2 JWT 实现用户认证授权功能

    Spring Security Oauth2 JWT 一 用户认证授权 1. 需求分析 1.1 用户认证与授权 什么是用户身份认证? 用户身份认证即用户去访问系统资源时系统要求验证用户的身份信息,身份 ...

  2. 一篇文章告诉你企业签名对iOS游戏用户有多重要。

    近年来,随着智能手机的不断普及,打游戏已经成无敌了多数人消磨时间的首选,最火热的现在当属王者荣耀了,不仅可以锻炼自己的思维,甚至还有很多人代练赚钱.除了王者,市场上各种各样的游戏类APP层出不穷,因为 ...

  3. iOS网络——身份认证

    iOS网络身份认证 文档 URL Session Programming Guide中重要的类如下: 在Networking Overview--Making HTTP and HTTPS Reque ...

  4. Laravel7使用Auth进行用户认证

    laravel7 版本移除了 auth,大家都知道以前版本是直接使用 php artisan make:auth就可以使用,但是这版本不行了,那么要怎么弄呢?今天和大家说一下具体步骤. Laravel ...

  5. 「Django」rest_framework学习系列-用户认证

    用户认证: 1.项目下utils文件写auth.py文件 from rest_framework import exceptions from api import models from rest_ ...

  6. apache用户认证

    先创建一个"用户认证"目录(设为abc) [root@LAMPLINUX ~]# cd /data/www [root@LAMPLINUX www]# mkdir abc 进入ab ...

  7. 请求令牌 接口_时序图说明JWT用户认证及接口鉴权的细节

    JWT用户认证及接口鉴权的细节以及原理 一.回顾JWT的授权及鉴权流程 在笔者的上一篇文章中,已经为大家介绍了JWT以及其结构及使用方法.其授权与鉴权流程浓缩为以下两句话: 授权:使用可信用户信息(用 ...

  8. 基于 JWT + Refresh Token 的用户认证实践

    HTTP 是一个无状态的协议,一次请求结束后,下次在发送服务器就不知道这个请求是谁发来的了(同一个 IP 不代表同一个用户),在 Web 应用中,用户的认证和鉴权是非常重要的一环,实践中有多种可用方案 ...

  9. linux认证授权系统,linux高级操作系统用户认证与授权-20210323002921.doc-原创力文档...

    HYPERLINK "/" 长沙理工大学 <Linux高级操作系统>课程设计报告 基于Linux的用户认证与授权研究 廖正磊 学 院 计算机与通信工程 专业 计算机科学 ...

最新文章

  1. 《评人工智能如何走向新阶段》后记
  2. HAL——硬件抽象层读书笔记
  3. jtree和mysql_Jtable和JTree的写法示例代码
  4. 本次谈谈罕见的三方数据维度的cut-off切分,你肯定没遇过
  5. Linux 环境下安装 GitLab 与配置
  6. 面试官:谈谈你对Spring AOP的了解?请加上这些内容,绝对加分!
  7. 联想笔记本关闭锁定计算机,联想笔记本电脑键盘锁了怎么解开
  8. vrep和matlab,使用Matlab与V-REP联合仿真 - Play V-REP with Matlab
  9. wifi6路由器使用tcpdump抓包
  10. 云流化方案为水利数字孪生带来哪些新变化?
  11. 【小算法】求约数个数
  12. flask爱家租房项目开发(十)
  13. C语言winmain函数的参数,c++:谁调用了main/WinMain函数!
  14. 理解MySQL复制(Replication)
  15. 微信个人号机器人接口
  16. 10款Github上最火爆的国产开源项目
  17. Exception in thread “main“ java.lang.NoSuchMethodError: scala.Predef$
  18. 记录CentOS8 开机卡住的问题解决过程
  19. 【总目录4】C/C++、OpenCV、Qt、单片机总结大全
  20. 使用python来嗅探局域网内的QQ号码

热门文章

  1. centos7 trac安装
  2. 【JAVA面试】苏州同程旅游面试总结
  3. FTP的主动模式和被动模式工作原理
  4. 《百年中国文学史》狂人日记
  5. PHP一句话木马免杀(通过VirusTotal测试)
  6. SEO知识(总结土著游民)(1)
  7. 洛谷 P4859 已经没有什么好害怕的了 解题报告
  8. loadrunner11免费下载地址
  9. CCTech:测试同学如何参与codereview?
  10. Java核心技术·卷二·第一章笔记