railsとjwtでswiftアプリケーションのログイン機能を実装する


今更感がありますがswift案件に今年かかわり認証周りにもっといい方法は無いかと探していたときにjwtが良さそうだったのでrailsとswiftで実装しました。

当初はknockも使う予定でしたがsorceryとメソッド名が重複していたかだったかで上手く同居させることができなかったためあきらめました

動作環境

macOS
docker for mac
swift4
Xcode9.2

サンプルコード

アプリからログイン後にユーザのプロフィールを表示するサンプル
githubにコードおいてあります。

起動

rails

docker-compose up

$ cd auth-sample/auth-sample-server
$ docker-compose up

migration

$ docker exec authsampleserver_web_1 ./bin/rake db:migrate

swift

carthage

carthage updateしてプロジェクトをビルドしてrealmなどのライブラリが見つからないと言われたらcarthageでビルドされた各種フレームワークを追加してください。

$ cd auth-sample/auth-sample-client
$ carthage update --platform iOS

コード

railsのAPIモードは使ってません。
アプリのサービスだったとしてもほぼ管理画面が必要になったりするので、プロジェクトを分けたりするとデプロイに気を使ったり、モデル周りのコードが分散してしまったりで走り初めのサービスは同じリポジトリにコードがまとまっていたほうがメリットのほうが大きいためです。
実際にプロジェクトに導入するときはgrapeなどのgemを使って実装し直してもいいかもしれないです。

loginボタンを押下するとIBActionで設定されたloginメソッドが実行されます

LoginViewController.swift
class LoginViewController: UIViewController {

    @IBOutlet weak var emailField: UITextField!
    @IBOutlet weak var passwordField: UITextField!

    let loginURL: String = "http://localhost:3000/api/user_sessions"
    lazy var indicator: UIActivityIndicatorView = {
        let indicator = UIActivityIndicatorView()
        indicator.frame = CGRect(x: 0, y: 0, width: 100, height: 100)
        indicator.center = self.view.center
        indicator.hidesWhenStopped = true
        indicator.activityIndicatorViewStyle = .gray
        return indicator
    }()
    lazy var alertViewController: UIAlertController = {
        let alert: UIAlertController = UIAlertController(title: "エラー", message: "ログインに失敗しました", preferredStyle: .alert)
        let cancelAction: UIAlertAction = UIAlertAction(title: "OK", style: .cancel, handler: nil)
        alert.addAction(cancelAction)
        return alert
    }()

    @IBAction func login(_ sender: Any) {
        let param = ["email": self.emailField.text ?? "", "password": self.passwordField.text ?? ""]
        self.indicator.startAnimating()
        Alamofire.request(self.loginURL, method: .post, parameters: param, encoding: URLEncoding.default).responseJSON { [weak self] response in
            self?.indicator.stopAnimating()
            guard response.response?.statusCode == 200 else {
                if let alertViewController = self?.alertViewController {
                    self?.present(alertViewController, animated: true, completion: nil)
                }
                return
            }

            if let data = response.data {
                let decoder: JSONDecoder = JSONDecoder()
                if let user = try? decoder.decode(UserCodable.self, from: data) {
                    User.create(codable: user)
                    let sb = UIStoryboard(name: "Profile", bundle: nil)
                    if let vc = sb.instantiateInitialViewController(){
                        vc.modalTransitionStyle = UIModalTransitionStyle.crossDissolve
                        self?.present(vc, animated: true, completion: nil)
                    }
                }
            }
        }
    }
}

アプリからリクエストを受けたrailsのコードです。
user_idからjwtトークンを生成して返却します。
アプリはこのトークンを他のリクエスト時にHTTP headerのAuthorizationに追加してリクエストを行います。
jsonにuserオブジェクトを雑にぶん投げてますが本番ではやってはいけません、passwordなどのフィールドが含まれてるので絶対にフィルタリングしてください。

api/user_sessions_controller
class Api::UserSessionsController < Api::ApplicationController
  def create
    user = User.authenticate(params[:email], params[:password])

    if user
      token = Jwt::TokenProvider.(user_id: user.id)
      render json: {user: user, token: token}
    else
      render json: {error: 'Error description'}, status: 422
    end
  end
end

ユーザのプロフィールを取得するAPIです。

user_controller.rb
class Api::UsersController < Api::ApplicationController
  before_action :authenticate

  def show
    render json: current_user
  end
end
api/application_controller.rb
class Api::ApplicationController < ActionController::Base
  def authenticate
    render json: {errors: 'Unauthorized'}, status: 401 unless current_user
  end

  def current_user
    @current_user ||= Jwt::UserAuthenticator.(request.headers)
  end
end
service/jwt/user_authenticator.rb
module Jwt::UserAuthenticator
  extend self

  def call(request_headers)
    @request_headers = request_headers

    begin
      payload, header = Jwt::TokenDecryptor.(token)
      return User.find(payload['user_id'])
    rescue => e
      # log error here
      return nil
    end
  end

  def token
    @request_headers['Authorization'].split(' ').last
  end
end
service/jwt/token_decryptor.rb
module Jwt::TokenDecryptor
  extend self

  def call(token)
    decrypt(token)
  end

  private
  def decrypt(token)
    begin
      JWT.decode(token, Rails.application.secrets.secret_key_base)
    rescue 
       raise InvalidTokenError
    end
  end
end

class InvalidTokenError < StandardError; end;

注意点

JWTトークンが第三者に漏れてしまった時にそのユーザのトークンを無効にしないと不正アクセスし放題になってしまいますが、現在の実装方法だとトークンの無効化ができません。
理由はJWTトークンの発行にuser_idを使用しているためです。user_idは作成されたら変更しないものなのでこれをユーザモデルに紐付いたキーに変更しかつ有効期限をもたせるようなモデル設計に変える必要がありそうです。

この辺はユーザ作成のサンプルも作るのでその時に一緒に変更します。