跳转至

第19章:API认证,第二部分

现在你已经实现了API认证,你的测试和iOS应用都不再工作了。在这一章中,你将学习说明新的认证要求所需的技术。

Note

你必须在你的项目中设置和配置好PostgreSQL。如果你还需要这样做,请按照第6章 配置数据库"的步骤进行。

更新测试

在上一章中,你更新了测试,以确保它们能被编译。然而,许多测试不会通过,因为你已经保护了你的API中的所有路由。

首先,打开Models+Testable.swift,在文件的顶部,添加:

import Vapor

这使得编译器可以看到用于密码散列的Bcrypt函数。接下来,将User扩展中的create(name:username:on:)替换为以下内容:

// 1
static func create(
  name: String = "Luke",
  username: String? = nil,
  on database: Database
) throws -> User {
  let createUsername: String
  // 2
  if let suppliedUsername = username {
    createUsername = suppliedUsername
  // 3
  } else {
    createUsername = UUID().uuidString
  }

  // 4
  let password = try Bcrypt.hash("password")
  let user = User(
    name: name,
    username: createUsername,
    password: password)
  try user.save(on: database).wait()
  return user
}

以下是你改变的内容:

  1. 使username参数成为一个可选的字符串,默认为nil
  2. 如果提供了一个用户名,就使用它。
  3. 如果没有提供用户名,使用UUID创建一个新的、随机的用户名。这将确保用户名是唯一的,符合迁移的要求。
  4. 哈希密码并创建一个用户。

在终端,运行以下程序:

# 1
docker rm  -f postgres-test
# 2
docker run --name postgres-test -e POSTGRES_DB=vapor-test \
  -e POSTGRES_USER=vapor_username \
  -e POSTGRES_PASSWORD=vapor_password \
  -p 5433:5432 -d postgres

下面是这个的作用:

  1. 停止并删除测试的PostgreSQL容器,如果它存在的话,这样你就可以从一个新的数据库开始。
  2. 按照第11章"测试"中的描述,再次运行测试容器。

如果你现在运行测试,它们会崩溃,因为对任何认证路由的调用都会失败。你需要为这些请求提供认证。

停止测试,打开Application+Testable.swift并替换:

import XCTVapor
import App

为以下内容:

@testable import App
@testable import XCTVapor

这使你能够使用TokenUserXCTApplicationTester。接下来,在文件的底部,插入:

// 1
extension XCTApplicationTester {
  // 2
  public func login(
    user: User
  ) throws -> Token {
    // 3
    var request = XCTHTTPRequest(
      method: .POST,
      url: .init(path: "/api/users/login"),
      headers: [:],
      body: ByteBufferAllocator().buffer(capacity: 0)
    )
    // 4
    request.headers.basicAuthorization = 
      .init(username: user.username, password: "password")
    // 5
    let response = try performTest(request: request)
    // 6
    return try response.content.decode(Token.self)
  }
}

下面是这个新功能的作用:

  1. XCTApplicationTester添加一个扩展,Vapor的测试包装器围绕Application
  2. 定义一个登录方法,接收User并返回Token
  3. 创建一个测试POST请求到/api/users/login--登录URL--在需要时使用空值。
  4. 使用VaporBasicAuthorization帮助器设置HTTP基本认证头。

Note

这里的密码必须是纯文本,而不是来自User的散列密码。

  1. 发送请求以获得响应。
  2. 将响应解码为Token并返回结果。

接下来,在XCTApplicationTester扩展的底部,添加一个新的方法来使用你刚刚创建的登录方法:

// 1
@discardableResult
public func test(
  _ method: HTTPMethod,
  _ path: String,
  headers: HTTPHeaders = [:],
  body: ByteBuffer? = nil,
  loggedInRequest: Bool = false,
  loggedInUser: User? = nil,
  file: StaticString = #file,
  line: UInt = #line,
  beforeRequest: (inout XCTHTTPRequest) throws -> () = { _ in },
  afterResponse: (XCTHTTPResponse) throws -> () = { _ in }
) throws -> XCTApplicationTester {
  // 2
  var request = XCTHTTPRequest(
    method: method,
    url: .init(path: path),
    headers: headers,
    body: body ?? ByteBufferAllocator().buffer(capacity: 0)
  )

  // 3
  if (loggedInRequest || loggedInUser != nil) {
    let userToLogin: User
    // 4
    if let user = loggedInUser {
      userToLogin = user
    } else {
      userToLogin = User(
        name: "Admin", 
        username: "admin", 
        password: "password")
    }

    // 5
    let token = try login(user: userToLogin)
    // 6
    request.headers.bearerAuthorization = 
      .init(token: token.value)
  }

  // 7
  try beforeRequest(&request)

  // 8
  do {
    let response = try performTest(request: request)
    try afterResponse(response)
  } catch {
    XCTFail("\(error)", file: (file), line: line)
    throw error
  }
  return self
}

以下是新方法的细节:

  1. 增加一个新方法,重复现有的app.test(_:_:beforeRequest:afterResponse:),你在测试中使用。这个新方法增加了loggedInRequestloggedInUser作为参数。你使用这些参数来告诉你的测试发送一个授权头或使用一个指定的用户,根据需要。
  2. 创建一个测试中使用的请求。
  3. 确定这个请求是否需要认证。
  4. 计算出要使用的用户。

Note

这需要你知道用户的密码。由于你测试中的所有用户都有密码password,这不是一个问题。如果没有指定用户,使用admin

  1. 使用你之前创建的login(user:)获得一个令牌。
  2. 在测试请求中添加承载授权头,使用从登录中获取的令牌值。
  3. beforeRequest(_:)应用到请求中。
  4. 获取响应并应用afterResponse(_:)。捕捉任何错误,测试失败。这与标准的app.test(_:_:beforeRequest:afterResponse:)方法相同。

打开AcronymTests.swift,在testAcronymCanBeSavedWithAPI()中,在开头加入以下内容:

let user = try User.create(on: app.db)

这样就创建了一个用户,在测试中使用。

接下来,改变对app.test(_:_:beforeRequest:afterResponse:)的调用,使用你刚刚创建的用户:

// 1
try app.test(
  .POST, 
  acronymsURI, 
  loggedInUser: user,
  beforeRequest: { request in
    try request.content.encode(createAcronymData)
  }, 
  afterResponse: { response in
    let receivedAcronym = 
      try response.content.decode(Acronym.self)
    XCTAssertEqual(receivedAcronym.short, acronymShort)
    XCTAssertEqual(receivedAcronym.long, acronymLong)
    XCTAssertNotNil(receivedAcronym.id)
    // 2
    XCTAssertEqual(receivedAcronym.$user.id, user.id)

    try app.test(.GET, acronymsURI, 
      afterResponse: { allAcronymsResponse in
        let acronyms = 
          try allAcronymsResponse.content.decode([Acronym].self)
        XCTAssertEqual(acronyms.count, 1)
        XCTAssertEqual(acronyms[0].short, acronymShort)
        XCTAssertEqual(acronyms[0].long, acronymLong)
        XCTAssertEqual(acronyms[0].id, receivedAcronym.id)
        // 3
        XCTAssertEqual(acronyms[0].$user.id, user.id)
    })
})

所做的改变是:

  1. loggedInUser传入创建的用户,使用你的新辅助函数验证创建首字母缩写的请求。
  2. 添加一个检查,以确保创建的首字母缩写的用户ID与用于验证创建首字母缩写请求的用户ID相匹配。
  3. 增加一个检查,以确保返回的首字母缩写的用户ID与用于验证创建首字母缩写请求的用户ID相匹配。

testUpdatingAnAcronym()中,将用户传入发送请求帮助器:

try app.test(.PUT, 
  "\(acronymsURI)\(acronym.id!)", 
  loggedInUser: newUser, 
  beforeRequest: { request in
    try request.content.encode(updatedAcronymData)
  })

testDeletingAnAcronym()中,发送DELETE请求时设置loggedInRequest

try app.test(
  .DELETE, 
  "\(acronymsURI)\(acronym.id!)",
  loggedInRequest: true)

接下来,在testGettingAnAcronymsUser()中,将解码后的用户类型改为User.Public

let acronymsUser = try response.content.decode(User.Public.self)

由于该应用程序不再在请求中返回用户的密码,你必须将解码类型改为User.Public

接下来,在testAcronymsCategories()中,将两个POST请求改为以下内容:

try app.test(
  .POST, 
  "\(acronymsURI)\(acronym.id!)/categories/\(category.id!)", 
  loggedInRequest: true)
try app.test(
  .POST, 
  "\(acronymsURI)\(acronym.id!)/categories/\(category2.id!)", 
  loggedInRequest: true)

最后,将DELETE替换为以下内容:

try app.test(
  .DELETE, 
  "\(acronymsURI)\(acronym.id!)/categories/\(category.id!)", 
  loggedInRequest: true)

这些请求现在使用一个认证用户。

打开CategoryTests.swift,修改testCategoryCanBeSavedWithAPI(),以使用认证的请求:

try app.test(.POST, categoriesURI, loggedInRequest: true, 
  beforeRequest: { request in
    try request.content.encode(category)
}, afterResponse: { response in
  let receivedCategory = 
    try response.content.decode(Category.self)
  XCTAssertEqual(receivedCategory.name, categoryName)
  XCTAssertNotNil(receivedCategory.id)

  try app.test(.GET, categoriesURI, 
    afterResponse: { response in
      let categories = 
        try response.content.decode([App.Category].self)
      XCTAssertEqual(categories.count, 1)
      XCTAssertEqual(categories[0].name, categoryName)
      XCTAssertEqual(categories[0].id, receivedCategory.id)
    })
})

接下来,在testGettingACategoriesAcronymsFromTheAPI()中,用以下内容替换两个POST请求,以使用一个认证用户:

try app.test(
  .POST, 
  "/api/acronyms/\(acronym.id!)/categories/\(category.id!)", 
  loggedInRequest: true)
try app.test(
  .POST, 
  "/api/acronyms/\(acronym2.id!)/categories/\(category.id!)", 
  loggedInRequest: true)

现在,打开UserTests.swift。首先,将testUsersCanBeRetrievedFromAPI()中的请求从:

let users = try response.content.decode([User].self)

修改为以下内容:

let users = try response.content.decode([User.Public].self)

这就把解码类型改为User.Public。更新断言,以说明管理用户的情况:

XCTAssertEqual(users.count, 3)
XCTAssertEqual(users[1].name, usersName)
XCTAssertEqual(users[1].username, usersUsername)
XCTAssertEqual(users[1].id, user.id)

接下来,在testUserCanBeSavedWithAPI()中,将正文替换为:

let user = User(
  name: usersName, 
  username: usersUsername, 
  password: "password")

// 1
try app.test(.POST, usersURI, loggedInRequest: true, 
  beforeRequest: { req in
    try req.content.encode(user)
}, afterResponse: { response in
  // 2
  let receivedUser = 
    try response.content.decode(User.Public.self)
  XCTAssertEqual(receivedUser.name, usersName)
  XCTAssertEqual(receivedUser.username, usersUsername)
  XCTAssertNotNil(receivedUser.id)

  try app.test(.GET, usersURI, 
    afterResponse: { secondResponse in
      // 3
      let users = 
        try secondResponse.content.decode([User.Public].self)
      // 4
      XCTAssertEqual(users.count, 2)
      XCTAssertEqual(users[1].name, usersName)
      XCTAssertEqual(users[1].username, usersUsername)
      XCTAssertEqual(users[1].id, receivedUser.id)
    })
})

所做的改变是:

  1. 设置loggedInRequest,这样创建用户的请求就可以工作。
  2. 将响应解码为User.Public
  3. 将第二个响应解码为User.Public的一个数组。
  4. 更新断言,以考虑到管理用户的情况。

最后,更新testGettingASingleUserFromTheAPI()中的请求:

let receivedUser = try response.content.decode(User.Public.self)

这将解码类型改为User.Public,因为响应不再包含用户的密码。构建并运行测试;它们应该全部通过。

更新iOS应用程序

由于API现在需要认证,iOS应用程序不能再创建缩略语。就像测试一样,iOS应用程序必须被更新以适应认证的路线。启动器TILiOS项目已经更新,在启动时显示一个新的LoginTableViewController。该项目还包含一个Token的模型,与TIL Vapor应用的基础模型相同。最后,"创建用户"视图现在接受一个密码。

在发送请求之前,请确保你的TIL Vapor应用程序正在运行。

登录

打开AppDelegate.swift。在application(_:didFinishLaunchingWithOptions:)中,应用程序检查新的Auth对象是否有令牌。如果没有令牌,它就启动登录屏幕;否则,它就正常显示缩略语表。

打开Auth.swift。从AppDelegate调用的令牌检查,使用TIL-API-KEY键在钥匙串中寻找令牌。当你在Auth中设置一个令牌时,它会将该令牌保存在钥匙链中。Auth+Keychain.swift为你简化了与钥匙链的互动。

Auth的底部,创建一个新的方法来登录用户:

func login(
  username: String,
  password: String,
  completion: @escaping (AuthResult) -> Void
) {
  // 2
  let path = "http://localhost:8080/api/users/login"
  guard let url = URL(string: path) else {
    fatalError("Failed to convert URL")
  }
  // 3
  guard
    let loginString = "\(username):\(password)"
      .data(using: .utf8)?
      .base64EncodedString()
  else {
    fatalError("Failed to encode credentials")
  }

  // 4
  var loginRequest = URLRequest(url: url)
  // 5
  loginRequest.addValue(
    "Basic \(loginString)",
    forHTTPHeaderField: "Authorization")
  loginRequest.httpMethod = "POST"

  // 6
  let dataTask = URLSession.shared
    .dataTask(with: loginRequest) { data, response, _ in
      // 7
      guard
        let httpResponse = response as? HTTPURLResponse,
        httpResponse.statusCode == 200,
        let jsonData = data
      else {
        completion(.failure)
        return
      }

      do {
        // 8
        let token = try JSONDecoder()
          .decode(Token.self, from: jsonData)
        // 9
        self.token = token.value
        completion(.success)
      } catch {
        // 10
        completion(.failure)
      }
    }
  // 11
  dataTask.resume()
}

以下是新方法的作用:

  1. 声明一个方法来登录一个用户。它接受用户的用户名、密码和一个完成处理程序作为参数。
  2. 构建登录请求的URL
  3. 为标题创建用户证书的Base64编码表示。
  4. 为登录用户的请求创建一个URLRequest
  5. HTTP Basic认证添加必要的标头,并将HTTP方法设置为POST
  6. 创建一个新的URLSessionDataTask来发送请求。
  7. 确保响应是有效的,状态代码为200并包含一个正文。
  8. 将响应体解码为Token
  9. 将收到的令牌保存为Auth令牌。
  10. 捕捉任何错误,并在failure的情况下调用完成处理程序。
  11. 启动数据任务来发送请求。

打开LoginTableViewController.swift。当用户点击Login时,应用程序调用loginTapped(_:)。在loginTapped(_:)的末尾,添加以下内容:

// 1
Auth().login(username: username, password: password) { result in
  switch result {
  case .success:
    DispatchQueue.main.async {
      let appDelegate =
        UIApplication.shared.delegate as? AppDelegate
      // 2
      appDelegate?.window?.rootViewController =
        UIStoryboard(name: "Main", bundle: Bundle.main)
        .instantiateInitialViewController()
    }
  case .failure:
    let message =
      "Could not login. Check your credentials and try again"
    // 3
    ErrorPresenter.showError(message: message, on: self)
  }
}

下面是这个的作用:

  1. 创建一个Auth的实例并调用login(username:password:completion:)
  2. 如果登录成功,加载Main.storyboard以显示缩写表。
  3. 如果登录失败,使用ErrorPresenter显示警告。

建立并运行。当应用程序启动时,它会显示登录屏幕。输入管理证书并点击Login

img

该应用程序会登录并带你到主缩略语表。

打开Auth.swift,在logout()中添加以下实现:

// 1
token = nil
DispatchQueue.main.async {
  guard let applicationDelegate =
    UIApplication.shared.delegate as? AppDelegate else {
      return
  }
  // 2
  let rootController =
    UIStoryboard(name: "Login", bundle: Bundle.main)
      .instantiateViewController(
        withIdentifier: "LoginNavigation")
  applicationDelegate.window?.rootViewController =
    rootController
}

下面是这个的作用:

  1. 删除任何现有的令牌。
  2. 加载Login.storyboard并切换到登录界面。

建立并运行。由于你已经登录了,应用程序会带你到主缩略语视图。切换到用户标签,然后点击注销。应用程序会返回到登录界面。

创建模型

启动项目简化了CreateAcronymTableViewController,因为你在创建首字母缩写时不再需要提供一个用户。打开ResourceRequest.swift。在save(_:completion:)之前var urlRequest = URLRequest(url: resourceURL)添加以下内容:

// 1
guard let token = Auth().token else {
  // 2
  Auth().logout()
  return
}

下面是这个的作用:

  1. Auth服务中获取令牌。
  2. 如果令牌不存在,调用logout(),因为用户需要再次登录以获得一个新的令牌。

接下来,在urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")下添加:

urlRequest.addValue(
  "Bearer \(token)",
  forHTTPHeaderField: "Authorization")

这将使用Authorization头将令牌添加到请求中。

最后,将dataTask(with:completionHandler:)的completionHandler里面的guard子句替换为以下内容:

guard let httpResponse = response as? HTTPURLResponse else {
  completion(.failure(.noData))
  return
}
guard 
  httpResponse.statusCode == 200, 
  let jsonData = data 
else {
  if httpResponse.statusCode == 401 {
    Auth().logout()
  }
  completion(.failure(.noData))
  return
}

这将检查失败的状态代码。如果响应返回401 Unauthorized,这意味着令牌是无效的。将用户注销以触发一个新的登录序列。

建立并运行,再次登录。点击+,你会看到新的创建缩略语页面,没有用户选项:

img

填写表格并点击保存来创建首字母缩写。你还可以创建用户和类别。注意,"创建用户"流程现在包括一个新的模型CreateUser。应用程序将这个模型发送到API,因为它包含密码属性。

缩略语请求

你仍然需要为首字母缩写的请求添加认证。打开AcronymRequest.swift,在update(with:completion:)中,在var urlRequest = URLRequest(url: resource)前添加以下内容:

guard let token = Auth().token else {
  Auth().logout()
  return
}

ResourceRequest一样,它从Auth获得令牌,如果有错误,则调用logout()。在urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")之后添加:

urlRequest.addValue(
  "Bearer \(token)",
  forHTTPHeaderField: "Authorization")

这将把令牌添加到Authorization头中。接下来,将dataTask(with:completionHandler:)中的guard条款替换为以下内容:

guard let httpResponse = response as? HTTPURLResponse else {
  completion(.failure(.noData))
  return
}
guard 
  httpResponse.statusCode == 200, 
  let jsonData = data 
else {
  if httpResponse.statusCode == 401 {
    Auth().logout()
  }
  completion(.failure(.noData))
  return
}

如果令牌无效,这将调用logout()。接下来改变delete(),在请求中添加认证。在该方法的开头添加:

guard let token = Auth().token else {
  Auth().logout()
  return
}

接下来,在urlRequest.httpMethod = "DELETE"后面添加以下内容:

urlRequest.addValue(
  "Bearer \(token)",
  forHTTPHeaderField: "Authorization")

最后,在add(category:completion:)之前的let url = ...中,获取令牌:

guard let token = Auth().token else {
  Auth().logout()
  return
}

接下来,在urlRequest.httpMethod = "POST"之后,在请求中加入令牌:

urlRequest.addValue(
  "Bearer \(token)",
  forHTTPHeaderField: "Authorization")

最后,替换dataTask(with:completionHandler:)中的guard子句,以便在响应返回401 Unauthorized时将用户注销:

guard let httpResponse = response as? HTTPURLResponse else {
  completion(.failure(.invalidResponse))
  return
}
guard httpResponse.statusCode == 201 else {
  if httpResponse.statusCode == 401 {
    Auth().logout()
  }
  completion(.failure(.invalidResponse))
  return
}

建立和运行。你现在可以删除和编辑缩略语,并为其添加类别。

接下来去哪?

在本章中,你学到了如何更新你的测试,以使用HTTP基本认证获得一个令牌,并在适当的测试中使用该令牌。你还更新了配套的iOS应用程序,以便与你的认证的API一起工作。

目前,只有通过认证的用户才能在API中创建缩写。然而,网站仍然是开放的,任何人都可以做任何事情 在下一章中,你将学习如何将认证应用到Web前端。你将学习认证API和网站之间的区别,以及如何使用cookiessession