炊きたてのご飯が食べたい

定時に帰れるっていいね。自宅勤務できるっていいね。子どもと炊きたてのご飯が食べられる。アクトインディでは積極的にエンジニアを募集中です。

( Swift2 ) Alamofire で有効期限付きの認証用トークンを利用する( refresh token を利用した token 更新)


Alamofire を利用して有効期限付きの token を利用した API call の実装例を紹介したいと思います。 token を http ヘッダーリクエストに含めるのは簡単ですが、トークン の有効期限が切れた際に、リクエストを保持して、リフレッシュトークン を利用してトークンを更新する方法が中々見当たらなかったので記事にしたいと思います。

前回書いた API クライアントをベースに話を進めたいと思います。

また、サーバー側の API は作成済みで以下の仕様となっていることを前提に書きたいと思います。

前提条件

リクエスト

  • token は httpヘッダーの Authorization に Bearer を指定してリクエストする
  • 有効期限が切れた token は refresh token を利用して更新を行うことができる
    • refresh token を利用した token の更新は API /tokens に POST を行って更新する
    • POST する際のリクエストは ["grant_type": "refresh_token", "refresh_token": "リフレッシュトークン文字列"] とする

レスポンス

  • 有効期限が切れた token でリクエストした場合 API は 有効期限切れのエラーを返す
    • http ステータスは 401 を返却する
    • エラーメッセージは body に { "message": "invalid_token" } を入れて返却する

実装内容

  1. token を API リクエストの http ヘッダー に一律で設定する
  2. token 情報を保持する Entity と refresh token を利用した token の更新処理を行う Endpoint を定義する
  3. リクエストを保持 & token の有効期限が切れていたら refresh token を利用して token の更新を行う Model を作成する
    1. リクエストをキャッシュする仕組みを実装する
    2. 401 , invalid_token のエラーを判別する
    3. invalid_token であれば refresh token を利用して token の更新を行う。

1. token を API リクエストの http ヘッダー に一律で設定する

API をコールする際に http のリクエストヘッダーに都度 token を設定する方法を採用しています。リクエストの度に token を設定する場合は、前回の記事((Swift2) Alamofire の upload 用 API クライアントを作成する)で作成した APIProtocol.swift の extension で headers にデフォルトで token を設定するように変更を加えます。以下のような設定を行うことで、リクエストの度に、アプリ内で保持している token を取得して利用することができるようになります。

protocol APIProtocol {
    ... 省略

    var headers: [String: String]? { get }
    
    ... 省略
}
extension APIProtocol {
    ... 省略

    var headers: [String: String]? {
        // getToken は token を取得するメソッドです。 Realm や CoreData を利用して保管している場合など、ケースに合わせて token を取得する処理を記述します。
        guard let token = self.getToken() else {
            return nil
        }
        return ["Authorization": "Bearer \(token)"]
    }

    ... 省略
}

2. token 情報を保持する Entity と refresh token を利用した token の更新処理を行う Endpoint を定義する

ObjectMapper の Mappable を採用した Entity を作成します。

import ObjectMapper

class LoginEntity: Mappable {
    dynamic var token = ""
    dynamic var refreshToken = ""

    required init?(_ map: Map) {}

    func mapping(map: Map) {
        self.token <- map["token"]
        self.refreshToken <- map["refresh_token"]
    }
}

Swift2 Alamofire + ObjectMapper で API クライアントを作成する で作成した Endpoint.swift に token を更新するリクエストを定義します。

import Alamofire
import ObjectMapper

class Endpoint {
    enum Login: RequestProtocol {
        typealias ResponseType = LoginEntity

        case Refresh(refreshToken: String)
        
        var method: Alamofire.Method {
            return .POST
        }
        
        var path: String {
            return "/tokens"
        }
        
        var parameters: [String : AnyObject]? {
            switch self {
            case .Refresh(let refreshToken):
                return ["grant_type": "refresh_token", "refresh_token": refreshToken]
            }
        }
    }
}

3. リクエストを保持 & token の有効期限が切れていたら refresh token を利用して token の更新を行う Model を作成する

A のリクエストを行って、 token が切れていたら更新を行って、再度 A をリクエストできるように、リクエストを管理する Model を作成します。( AuthorizationModel.swift )

1. リクエストをキャッシュする仕組みを実装する

AuthorizationModel.swift

import Alamofire

class AuthorizationModel {
    static let sharedInstance = AuthorizationModel()
    
    private typealias CachedTask = Void -> Void
    private var cachedTasks = Array<CachedTask>()
    
    func call<T: RequestProtocol, V where T.ResponseType == V>(request: T, completion: (Result<V, NSError>) -> Void) {
        let cachedTask: CachedTask = { [weak self] in
            guard let strongSelf = self else {
                return
            }
            strongSelf.call(request, completion: completion)
        }

        API.call(request) { response in
            switch response {
            case .Success(let json):
                completion(.Success(json))
            case .Failure(let error):
                // ここにリフレッシュトークンを更新する処理を書く
                completion(.Failure(error))
            }
        }
    }
    
    func call<T: UploadProtocol, V where T.ResponseType == V>(request: T, completion: (Result<V, NSError>) -> Void) {
        ... upload メソッドも上記と同様の記述で対応できるので省略
    }
}

cachedTasks に リクエスト request: Tとリクエスト完了後のコールバックの completion completion: (Result<V, NSError>) -> Void を複数保持するようにします。

API.call(request) で一度 API を call して 401 , invalid token だったら case .Failure(let error): に処理が入ってくるので、ここでリフレッシュトークンを利用した token の更新処理を行います。

2. 401 , invalid_token のエラーを判別する

前回の記事(Swift2 Alamofire + ObjectMapper で API クライアントを作成する)では API クラスを以下のように実装していました。

  • API.swift
class API {
    class func call<T: RequestProtocol, V where T.ResponseType == V>(request: T, completion: (Result<V, NSError>) -> Void) {
        Alamofire.request(request)
            .validate(statusCode: 200..<300)
            .validate(contentType: ["application/json"])
            .responseJSON { response in
                switch response.result {
                case .Success(let json):
                    completion(request.fromJson(json))
                case .Failure(let error):
                    completion(.Failure(error))
                }
        }
    }
}

.validate(statusCode: 200..<300) で http ステータスコードが 200〜299 までを Success として、それ以外を Failure とするようにしていますが Failure の値は completion: (Result<V, NSError>) なので NSError 型となり、 API サーバーから返却される { "message": "invalid_token" } の body メッセージを解析することができません。そのため .validate を 200...401 まで広げ request.fromJson(json) の処理の中で再度 Success or Failure を処理するように実装を変更します。

  • API.swift
class API {
    class func call<T: RequestProtocol, V where T.ResponseType == V>(request: T, completion: (Result<V, NSError>) -> Void) {
        Alamofire.request(request)
            .validate(statusCode: 200...401)
            .validate(contentType: ["application/json"])
            .responseJSON { response in
                switch response.result {
                case .Success(let json):
                    completion(request.fromJson(json, statusCode: response.response?.statusCode)) // 引数に statusCode を追加
                case .Failure(let error):
                    completion(.Failure(error))
                }
        }
    }
}
  • APIProtocol.swift
protocol APIProtocol {
    ... 省略
    
    func fromJson(json: AnyObject, statusCode: Int?) -> Result<ResponseType, NSError>

    ... 省略
}

extension APIProtocol {
    ... 省略

    func fromJson(json: AnyObject, statusCode: Int?) -> Result<ResponseType, NSError> {
        guard let statusCode = statusCode else {
            return .Failure(NSErrorType.System.error)
        }
        switch statusCode {
        case (200..<300):
            guard let value = json as? ResponseType else {
                return .Failure(NSErrorType.System.error)
            }
            return .Success(value)
        case 401:
            return .Failure(NSErrorType.Unauthorization(json).error)
        default:
            return .Failure(NSErrorType.System.error)
        }
    }
}

extension APIProtocol where ResponseType: Mappable {
    func fromJson(json: AnyObject, statusCode: Int?) -> Result<ResponseType, NSError> {
        guard let statusCode = statusCode else {
            return .Failure(NSErrorType.System.error)
        }
        switch statusCode {
        case (200..<300):
            guard let value = Mapper<ResponseType>().map(json) else {
                return .Failure(NSErrorType.System.error)
            }
            return .Success(value)
        case 401:
            return .Failure(NSErrorType.Unauthorization(json).error)
        default:
            return .Failure(NSErrorType.System.error)
        }
    }
}

401 エラーの際に body のメッセージを解析して NSError を返却する為の enum NSErrorType を定義します。

enum NSErrorType {
    case System
    case Unauthorization(AnyObject)
    
    var code: Int {
        switch self {
        case .System:
            return 0
        case .Unauthorization:
            return 401
        }
    }
    
    var descriptionKey: String {
        return "エラー"
    }
    
    var recoverySuggestionErrorKey: String {
        switch self {
        case .System:
            return "接続環境の良いところで再度お試しください"
        case .Unauthorization(let errorJson):
            guard let errorMessage = self.getErrorMessage(errorJson) else {
                return NSErrorType.System.recoverySuggestionErrorKey
            }
            return errorMessage
    }
    
    var error: NSError {
        let errorInfo = [ NSLocalizedDescriptionKey: self.descriptionKey, NSLocalizedRecoverySuggestionErrorKey: self.recoverySuggestionErrorKey ]
        let error = NSError(domain: "com.example.app", code: self.code, userInfo: errorInfo)
        return error
    }
    
    /**
     token の有効期限が切れていた場合のレスポンスは以下のような Json です
     { "message": "invalid_token" }
    */
    private func getErrorMessage(error: AnyObject) -> String? {
        guard let message = dictionary["message"] else {
            return nil
        }
        return message
    }
}

これで 401 エラーの際は NSError の code に 401 , メッセージに invalid_token が含まれる NSError が .Failure として処理されるようになりました。

3. invalid_token であれば refresh token を利用して token の更新を行う。

最後の総仕上げとして、「1」でコメントアウトにしていた refresh token を利用した token の更新処理を実装してみます。

import Alamofire
import SVProgressHUD

/*
 使用例
 AuthorizationModel.sharedInstance.call(Endpoint.User.Find(id: 1)) { response in
    switch response {
    case .Success(let result):
        print("success \(result)")
    case .Failure(let error):
        print("failure \(error)")
    }
 } 
 */

// http://stackoverflow.com/questions/28733256/alamofire-how-to-handle-errors-globally を参考に実装
/**
 AuthorizationModel の call メソッドを利用することで、トークンの有効期限が切れていた場合は
 リフレッシュトークンを利用してトークンを更新したリクエストができるようになります。
*/
class AuthorizationModel {
    static let sharedInstance = AuthorizationModel()
    
    private typealias CachedTask = Void -> Void
    private var cachedTasks = Array<CachedTask>()
    private var isRefreshing = false
    
    func call<T: RequestProtocol, V where T.ResponseType == V>(request: T, completion: (Result<V, NSError>) -> Void) {
        let cachedTask: CachedTask = { [weak self] in
            guard let strongSelf = self else {
                return
            }
            strongSelf.call(request, completion: completion)
        }

        // token の更新中の場合は、リクエストをキャッシュして終了させる
        // token の更新が完了した際に cachedTasks の中身が実行される
        if self.isRefreshing {
            self.cachedTasks.append(cachedTask)
            return nil
        }

        return API.call(request) { response in
            switch response {
            case .Success(let json):
                completion(.Success(json))
            case .Failure(let error):
                // NSError のメッセージが invalid_token の場合、リフレッシュトークンの処理を行う
                if error.localizedRecoverySuggestion == "invalid_token" {
                    self.cachedTasks.append(cachedTask)
                    self.refreshTokens()
                    return
                }
                completion(.Failure(error))
            }
        }
    }
    
    func call<T: UploadProtocol, V where T.ResponseType == V>(request: T, completion: (Result<V, NSError>) -> Void) {
        ... upload メソッドも上記と同様の記述で対応できるので省略
    }

    
    func refreshTokens() {
        self.isRefreshing = true

        // refresh token を Realm や CoreData や変数などから取得してセットする        
        API.call(Endpoint.Login.Refresh(refreshToken: refreshToken)) { response in
            self.isRefreshing = false
            switch response {
            case .Success(let resutl):
                // result の結果に更新された token が入っているので token を Realm や CoreData, 変数などにセットする
                let cachedTasksCopy = self.cachedTasks
                self.cachedTasks.removeAll()
                cachedTasksCopy.forEach { $0() }
            case .Failure(let error):
                // エラー時の処理を書く
            }
        }
    }    
}

参考にした記事