Home iOS & Swift Books Server-Side Swift with Vapor

19
API Authentication, Part 2 Written by Tim Condon

Now that you’ve implemented API authentication, neither your tests nor the iOS application work any longer. In this chapter, you’ll learn the techniques needed to account for the new authentication requirements.

Note: You must have PostgreSQL set up and configured in your project. If you still need to do this, follow the steps in Chapter 6, “Configuring a Database”.

Updating the tests

In the previous chapter, you updated the tests to ensure they compile. However, many of the tests won’t pass as you’ve protected all the routes in your API.

First, open Models+Testable.swift and, at the top of the file, add:

import Vapor

This allows the compiler to see the Bcrypt function used for password hashing. Next, replace create(name:username:on:) in the User extension with the following:

// 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
}

Here’s what you changed:

  1. Make the username parameter an optional string that defaults to nil.
  2. If a username is supplied, use it.
  3. If a username isn’t supplied, create a new, random one using UUID. This ensures the username is unique as required by the migration.
  4. Hash the password and create a user.

In Terminal, run the following:

# 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

Here’s what this does:

  1. Stop and remove the test PostgreSQL container, if it exists, so you start with a fresh database.
  2. Run the test container again as described in Chapter 11, “Testing”.

If you run the tests now, they crash since calls to any authenticated routes fail. You need to provide authentication for these requests.

Stop the tests, open Application+Testable.swift and replace:

import XCTVapor
import App

with the following:

@testable import App
@testable import XCTVapor

This enables you to use Token, User and XCTApplicationTester. Next, at the bottom of the file, insert:

// 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)
  }
}

Here’s what the new function does:

  1. Add an extension to XCTApplicationTester, Vapor’s test wrapper around Application.
  2. Define a log in method that takes User and returns Token.
  3. Create a test POST request to /api/users/login — the log in URL — with empty values where needed.
  4. Set the HTTP Basic Authentication header using Vapor’s BasicAuthorization helpers. Note: The password here must be plaintext text, not the hashed password from User.
  5. Send the request to get the response.
  6. Decode the response to Token and return the result.

Next, at the bottom of the XCTApplicationTester extension, add a new method to use the log in method you just created:

// 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
}

Here’s the details for the new method:

  1. Add a new method that duplicates the existing app.test(_:_:beforeRequest:afterResponse:) you use in tests. This new method adds loggedInRequest and loggedInUser as parameters. You use these to tell your tests to send an Authorization header or use a specified user, as required.
  2. Create a request to use in the test.
  3. Determine if this request requires authentication.
  4. Work out the user to use. Note: This requires you to know the user’s password. As all the users in your tests have the password “password”, this isn’t an issue. If no user is specified, use “admin”.
  5. Get a token using login(user:), which you created earlier.
  6. Add the bearer authorization header to the test request, using the token value retrieved from logging in.
  7. Apply beforeRequest(_:) to the request.
  8. Get the response and apply afterResponse(_:). Catch any errors and fail the test. This is the same as the standard app.test(_:_:beforeRequest:afterResponse:) method.

Open AcronymTests.swift and, in testAcronymCanBeSavedWithAPI(), add the following at the beginning:

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

This creates a user to use in the test.

Next, change the call to app.test(_:_:beforeRequest:afterResponse:) to use the user you just created:

// 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)
    })
})

The changes made were:

  1. Pass in the created user for loggedInUser to authenticated the create acronym request using your new helper function.
  2. Add a check to ensure the created acronym’s user ID matches the ID of the user used to authenticate the create acronym request.
  3. Add a check to ensure the returned acronym’s user ID matches the ID of the user used to authenticate the create acronym request.

In testUpdatingAnAcronym(), pass the user into the send request helper:

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

In testDeletingAnAcronym(), set loggedInRequest when sending the DELETE request:

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

Next, in testGettingAnAcronymsUser(), change the decoded user type to User.Public:

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

Since the app no longer returns users’ passwords in requests, you must change the decode type to User.Public.

Next, in testAcronymsCategories() replace the two POST requests with the following:

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

Finally, replace the DELETE with the following:

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

These requests now use an authenticated user.

Open CategoryTests.swift and change testCategoryCanBeSavedWithAPI() to use an authenticated request:

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)
    })
})

Next, in testGettingACategoriesAcronymsFromTheAPI(), replace the two POST requests with the following to use an authenticated user:

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)

Now, open UserTests.swift. First, change the request in testUsersCanBeRetrievedFromAPI() from:

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

to the following:

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

This changes the decode type to User.Public. Update the assertions to account for the admin user:

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

Next, in testUserCanBeSavedWithAPI(), replace the body with:

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)
    })
})

The changes made were:

  1. Set loggedInRequest so the create user request works.
  2. Decode the response to User.Public.
  3. Decode the second response to an array of User.Public.
  4. Update the assertions to take account of the admin user.

Finally, update the request in testGettingASingleUserFromTheAPI():

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

This changes the decode type to User.Public as the response no longer contains the user’s password. Build and run the tests; they should all pass.

Updating the iOS application

With the API now requiring authentication, the iOS Application can no longer create acronyms. Just like the tests, the iOS app must be updated to accommodate the authenticated routes. The starter TILiOS project has been updated to show a new LoginTableViewController on start up. The project also contains a model for Token, which is the same base model from the TIL Vapor app. Finally, the “create user” view now accepts a password.

Logging in

Open AppDelegate.swift. In application(_:didFinishLaunchingWithOptions:), the application checks the new Auth object for a token. If there’s no token, it launches the login screen; otherwise, it displays the acronyms table as normal.

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
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
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
}

Creating models

The starter project simplifies CreateAcronymTableViewController as you no longer have to provide a user when creating an acronym. Open ResourceRequest.swift. In save(_:completion:) before var urlRequest = URLRequest(url: resourceURL) add the following:

// 1
guard let token = Auth().token else {
  // 2
  Auth().logout()
  return
}
urlRequest.addValue(
  "Bearer \(token)",
  forHTTPHeaderField: "Authorization")
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
}

Acronym requests

You still need to add authentication to acronym requests. Open AcronymRequest.swift and in update(with:completion:), before var urlRequest = URLRequest(url: resource) add the following:

guard let token = Auth().token else {
  Auth().logout()
  return
}
urlRequest.addValue(
  "Bearer \(token)",
  forHTTPHeaderField: "Authorization")
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
}
guard let token = Auth().token else {
  Auth().logout()
  return
}
urlRequest.addValue(
  "Bearer \(token)",
  forHTTPHeaderField: "Authorization")
guard let token = Auth().token else {
  Auth().logout()
  return
}
urlRequest.addValue(
  "Bearer \(token)",
  forHTTPHeaderField: "Authorization")
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
}

Where to go from here?

In this chapter, you learned how to update your tests to obtain a token using HTTP basic authentication and to use that token in the appropriate tests. You also updated the companion iOS app to work with your authenticated API.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.

Have feedback to share about the online reading experience? If you have feedback about the UI, UX, highlighting, or other features of our online readers, you can send them to the design team with the form below:

© 2021 Razeware LLC

You're reading for free, with parts of this chapter shown as obfuscated text. Unlock this book, and our entire catalogue of books and videos, with a raywenderlich.com Professional subscription.

Unlock Now

To highlight or take notes, you’ll need to own this book in a subscription or purchased by itself.