Clean Authentication with iOS

Something I've struggled with for a long time has been trying to figure out the best possible architecture for handling authentication in iOS. There are several requirements that need to be met in order for this to feel "clean":

  1. If a user starts the app and they aren't authenticated, they should see the primary authentication view. Otherwise, continue using the app normally.
  2. Requests that fail because of server-side token invalidation should also present the authentication view.
  3. Storing the current user and its access token in memory should only be in one location for the entire app.
  4. None of the authentication-related code should pollute the app delegate.

According to [Apple's documentation], the tab bar controller always acts as a wrapper to navigation controllers. We can start with a rudimentary example of a tabbed navigation-style application, with our wrapping tab bar controller called MainTabBarController:

main tab bar controller

Instead of deciding whether or not to show authentication-related screens in the application delegate, it would be far better to put this in the tab bar controller's loading method. Most of the reusable authentication code (such as logging in and out, storing access tokens, and so on) can be put into another class, AuthenticationSingleton.

class MainTabBarController: UITabBarController {  
     let authentication = AuthenticationSingleton.shared
     var authNavController: UINavigationController!

     override func viewDidLoad() {
         super.viewDidLoad()

         // Now refresh the current user to make sure our token is still valid.
         if authentication.isLoggedIn {
             authentication.refreshCurrentUser()
         } else {
             displayAuthentication(animated: true)
         }

         // Listen for auth events.
         let center = NotificationCenter.default
         center.addObserver(self, selector: #selector(didLogIn), name: .userLoggedIn, object: nil)
         center.addObserver(self, selector: #selector(didLogOut), name: .userLoggedOut, object: nil)
    }

    private func displayAuthentication(animated: Bool) {
        let authStoryboard = UIStoryboard(name: "Authentication", bundle: nil)
        authNavController = authStoryboard.instantiateInitialViewController() as! UINavigationController

        present(authNavController, animated: animated, completion: nil)
    }

    private func didLogIn() {
        selectedIndex = 0
    }

    private func didLogOut() {
        displayAuthentication(animated: true)
    }
}

Right out of the gate, this satisfies a lot of requirements! It's easy to understand, and it gives us a high-level overview of what's happening. AuthenticationSingleton is a singleton class, so any authentication-related calls won't have unnecessary memory overhead.

Let's dive in a bit to AuthenticationSingleton and see what's going on under the hood. All network calls are made with Alamofire.

import Alamofire  
import SwiftyJSON

class AuthenticationSingleton {  
    // Private initializer for singleton
    fileprivate init() {}
    static let shared = AuthenticationSingleton()

    // Store access token in user defaults
    var accessToken: String? {
        get {
            return UserDefaults.standard.string(forKey: Settings.AccessTokenKey)
        }

        set {
            UserDefaults.standard.set(newValue, forKey: Settings.AccessTokenKey)
        }
    }

    var currentUser: User!
    var isLoggedIn: Bool {
        return accessToken != nil
    }

    func login(username: String, password: String, success: ((Void) -> Void)? = nil, failure: ((Error) -> Void)? = nil) {
        Alamofire.request(AuthenticationRouter.login(username, password)).responseJSON { response in
            switch response.result {
            case .success(let data):
                let json = JSON(data)
                accessToken = json["jwt"].stringValue
                currentUser = User(json: json["user"])
                NotificationCenter.default.post(name: .userLoggedIn, object: nil)

                success?()
            case .failure(let error):
                failure?(error)
            }
        }
    }

    func logout() {
        accessToken = nil
        currentUser = nil
        NotificationCenter.default.post(name: .userLoggedOut, object: nil)
    }

    func refreshCurrentUser() {
        // Network here call to sync local data with server data
    }
}

A couple notes about this:

  1. Storing a sensitive access token in UserDefaults is probably not a good idea. I'd recommend using the iOS keychain with a library like KeychainSwift.
  2. AuthenticationRouter.login refers to a standard Alamofire router, which we can define for use here:
typealias Parameters = [String: String]

enum AuthenticationRouter: URLRequestConvertible {  
    case login(String, String)
    case register(Parameters)

    var path: String {
        switch self {
        case .login:
            return "/sessions"
        case .register:
            return "/users"
        }
    }

    var parameters: Parameters? {
        switch self {
        case .login(let username, let password):
            return ["session": ["username": username, "password": password]]
        case .register(let registrationParams):
            return ["user": registrationParams]
        }
    }

    var method: Alamofire.HTTPMethod {
        return .post
    }

    func asURLRequest() throws -> URLRequest {
        let url = URL(string: API.WebURLString) // Defined in Constants.swift
        var urlRequest = URLRequest(url: url!.appendingPathComponent(path))
        urlRequest.httpMethod = method.rawValue
        let encoding = Alamofire.URLEncoding.default

        return try encoding.encode(urlRequest, with: parameters)
    }
}