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

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/ | [email protected]:XXXXX-XXX-1234.git

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

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

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

git remote add heroku [email protected]: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

iOS 的用户认证:使用Swift和Ruby on Rail_第1张图片

根据提示创建免费账号,然后选择 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 fields
  if self.signupNameTextField.isFirstResponder() {
    self.signupNameTextField.resignFirstResponder()
  }

  if self.signupEmailTextField.isFirstResponder() {
    self.signupEmailTextField.resignFirstResponder()
  }

  if self.signupPasswordTextField.isFirstResponder() {
    self.signupPasswordTextField.resignFirstResponder()
  }

  // start activity indicator
  self.activityIndicatorView.hidden = false

  // validate presence of all required parameters
  if 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 header
  let httpRequest = httpHelper.buildRequest("signup", method: "POST",
    authType: HTTPRequestAuthType.HTTPBasicAuth)

  // 2. Password is encrypted with the API key
  let encrypted_password = AESCrypt.encrypt(userPassword, password: HTTPHelper.API_AUTH_PASSWORD)

  // 3. Send the request Body
  httpRequest.HTTPBody = "{\"full_name\":\"\(userName)\",\"email\":\"\(userEmail)\",\"password\":\"\(encrypted_password)\"}".dataUsingEncoding(NSUTF8StringEncoding)

  // 4. Send the request
  httpHelper.sendRequest(httpRequest, completion: {(data:NSData!, error:NSError!) in
    if 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 path
    let requestURL = NSURL(string: "\(HTTPHelper.BASE_URL)/\(path)")
    var request = NSMutableURLRequest(URL: requestURL!)

    // Set HTTP request method and Content-Type
    request.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 app
    switch 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 header
      let 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 Keychain
      if let userToken = KeychainAccess.passwordForAccount("Auth_Token", service: "KeyChainService") as String? {
        // Set Authorization header
        request.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 task
  let session = NSURLSession.sharedSession()
  let task = session.dataTaskWithRequest(request) { (data: NSData!, response: NSURLResponse!, error: NSError!) in
    if error != nil {
      dispatch_async(dispatch_get_main_queue(), { () -> Void in completion(data, error) }) return } dispatch_async(dispatch_get_main_queue(), { () -> Void in if 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 task task.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 fields
  if self.signinEmailTextField.isFirstResponder() {
    self.signinEmailTextField.resignFirstResponder()
  }

  if self.signinPasswordTextField.isFirstResponder() {
    self.signinPasswordTextField.resignFirstResponder()
  }

  // display activity indicator
  self.activityIndicatorView.hidden = false

  // validate presense of required parameters
  if 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 Body
  let 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 error
    if error != nil {
      let errorMessage = self.httpHelper.getErrorMessage(error)
      self.displayAlertMessage("Error", alertDescription: errorMessage as String)

      return
    }

    // hide activity indicator and update userLoggedInFlag
    self.activityIndicatorView.hidden = true
    self.updateUserLoggedInFlag()

    var jsonerror:NSError?
    let responseDict = NSJSONSerialization.JSONObjectWithData(data,
      options: NSJSONReadingOptions.AllowFragments, error:&jsonerror) as! NSDictionary
    var stopBool : Bool

    // save API AuthToken and ExpiryDate in Keychain
    self.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 flag
  let defaults = NSUserDefaults.standardUserDefaults()
  defaults.setObject("loggedIn", forKey: "userLoggedIn")
  defaults.synchronize()
}

func saveApiTokenInKeychain(tokenDict:NSDictionary) {
  // Store API AuthToken and AuthToken expiry date in KeyChain
  tokenDict.enumerateKeysAndObjectsUsingBlock({ (dictKey, dictObj, stopBool) -> Void in var myKey = dictKey as! String var myObj = dictObj as! String if 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 expired
    let 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 data
    if shouldFetchNewData {
      shouldFetchNewData = false
      self.setNavigationItems()
      loadSelfieData()
    }

    // logout and ask user to sign in again if token is expired
    if comparision != NSComparisonResult.OrderedAscending {
      self.logoutBtnTapped()
}

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

func loadSelfieData () {
  // Create HTTP request and set request Body
  let httpRequest = httpHelper.buildRequest("get_photos", method: "GET",
    authType: HTTPRequestAuthType.HTTPTokenAuth)

  // Send HTTP request to load existing selfie
  httpHelper.sendRequest(httpRequest, completion: {(data:NSData!, error:NSError!) in
    // Display error
    if 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 selfies
      if jsonDataArray != nil {
        for imageDataDict in jsonDataArray {
          var selfieImgObj = SelfieImage()

          selfieImgObj.imageTitle = imageDataDict.valueForKey("title") as! String
          selfieImgObj.imageId = imageDataDict.valueForKey("random_id") as! String
          selfieImgObj.imageThumbnailURL = imageDataDict.valueForKey("image_url") as! String

          self.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 header
  request.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 cell
  var rowIndex = self.dataArray.count - (indexPath.row + 1)
  var selfieRowObj = self.dataArray[rowIndex] as SelfieImage

  cell.backgroundColor = UIColor.blackColor()
  cell.selfieTitle.text = selfieRowObj.imageTitle

  var imgURL: NSURL = NSURL(string: selfieRowObj.imageThumbnailURL)!

  // Download an NSData representation of the image at the URL
  let request: NSURLRequest = NSURLRequest(URL: imgURL)
  NSURLConnection.sendAsynchronousRequest(request, queue: NSOperationQueue.mainQueue(),
    completionHandler: {(response: NSURLResponse!,data: NSData!,error: NSError!) -> Void in
      if 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 window
  self.dismissViewControllerAnimated(true, completion: nil)

  var image:UIImage

  // fetch the selected image
  if 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 caption
  if let composeVC: ComposeViewController = self.storyboard?.instantiateViewControllerWithIdentifier("ComposeViewController") as? ComposeViewController {
    composeVC.composeDelegate = self
    composeVC.thumbImg = image

    // set the navigation controller of compose view controlle
    let composeNavVC = UINavigationController(rootViewController: composeVC)

    // present compose view controller
    self.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 = thumbImg
  self.automaticallyAdjustsScrollViewInsets = false
  self.activityIndicatorView.layer.cornerRadius = 10

  setNavigationItems()
}

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

然后,将 postBtnTapped() 修改为:

func postBtnTapped() {
  // resign the keyboard for text view
  self.titleTextView.resignFirstResponder()
  self.activityIndicatorView.hidden = false

  // Create Multipart Upload request
  var 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 error
    if 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! NSDictionary

    var selfieImgObjNew = SelfieImage()

    selfieImgObjNew.imageTitle = jsonDataDict.valueForKey("title") as! String
    selfieImgObjNew.imageId = jsonDataDict.valueForKey("random_id") as! String
    selfieImgObjNew.imageThumbnailURL = jsonDataDict.valueForKey("image_url") as! String

    self.composeDelegate.reloadCollectionViewWithSelfie(selfieImgObjNew)
    self.activityIndicatorView.hidden = true
    self.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 NSMutableURLRequest

  let bodyParams : NSMutableData = NSMutableData()

  // build and format HTTP body with data
  // prepare for multipart form uplaod

  let boundaryString = "--\(boundary)\r\n"
  let boundaryData = boundaryString.dataUsingEncoding(NSUTF8StringEncoding) as NSData!
  bodyParams.appendData(boundaryData)

  // set the parameter name
  let imageMeteData = "Content-Disposition: attachment; name=\"image\"; filename=\"photo\"\r\n".dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)
  bodyParams.appendData(imageMeteData!)

  // set the content type
  let fileContentType = "Content-Type: application/octet-stream\r\n\r\n".dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)
  bodyParams.appendData(fileContentType!)

  // add the actual image data
  bodyParams.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 image
  let 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 = bodyParams
  return 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 Object
  var rowIndex = self.dataArray.count - (indexPath.row + 1)
  var selfieRowObj = self.dataArray[rowIndex] as SelfieImage

  pushDetailsViewControllerWithSelfieObject(selfieRowObj)
}

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

func pushDetailsViewControllerWithSelfieObject(selfieRowObj:SelfieImage!) {
  // instantiate detail view controller
  if let detailVC = self.storyboard?.instantiateViewControllerWithIdentifier("DetailViewController") as? DetailViewController {
    detailVC.editDelegate = self
    detailVC.selfieCustomObj = selfieRowObj

    // push detail view controller to the navigation stack
    self.navigationController?.pushViewController(detailVC, animated: true)
  }
}

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

override func viewDidLoad() {
  super.viewDidLoad()

  self.activityIndicatorView.layer.cornerRadius = 10
  self.detailTitleLbl.text = self.selfieCustomObj.imageTitle
  var imgURL = NSURL(string: self.selfieCustomObj.imageThumbnailURL)

  // Download an NSData representation of the image at the URL
  let request = NSURLRequest(URL: imgURL!)

  NSURLConnection.sendAsynchronousRequest(request, queue: NSOperationQueue.mainQueue(), 
      completionHandler: {(response: NSURLResponse!,data: NSData!,error: NSError!) -> Void in
    if 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 indicator
  self.activityIndicatorView.hidden = false

  // Create HTTP request and set request Body
  let 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 error
    if error != nil {
      let errorMessage = self.httpHelper.getErrorMessage(error)
      self.displayAlertMessage("Error", alertDescription: errorMessage as String)

      return
    }

    self.editDelegate.deleteSelfieObjectFromList(self.selfieCustomObj)
    self.activityIndicatorView.hidden = true
    self.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 view shouldFetchNewData = true self.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 Token
  if let userToken = KeychainAccess.passwordForAccount("Auth_Token", service: "KeyChainService") {
    KeychainAccess.deletePasswordForAccount(userToken, account: "Auth_Token", service: "KeyChainService")
  }

  // clear API Auth Expiry
  if 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,on,Ruby,Rails)