【フロントエンド学習記録】RailsとEmber.jsでログイン機能の実装

前置き

最近、フロントエンドの学習を頑張っています。

仕事ではサーバーサイドの開発をすることのほうがたまたま多かったこともあって、元々苦手だった JavaScript の学習とフロントエンドの開発に距離ができてしまっていました。

3月から少しずつ仕事でもフロントエンドに触れる機会が出てきて、どのようにキャッチアップしていくか迷っていたのですが、RailsAPIモードとフロントエンドのフレームワークを組み合わせてブログアプリを作りながら必要な知識を学んでいく形で取り組んでいます。

今回、フロントエンドのフレームワークは Ember.js を使っていますが、フレームワーク固有の知識だけでなくフロントエンド開発のベースとなる部分の知識を理解しながら取り組むことを意識してやっているため、ブログに学習記録をまとめることにしてみました。

前提として、フロントエンド開発歴が浅めの私が学んでいく過程の記録であり、実際の業務アプリの開発ではもっと応用しないといけない内容が多く含まれることになると思います。

ログイン機能の実装

要件

  • ユーザーはメールアドレスとパスワードでログインができる
  • ログインしていないユーザーはブログ一覧と詳細画面のみアクセスできる
  • ログインしているユーザーは管理画面からブログの投稿・更新・削除ができる
  • ログアウトすることができる

アクセストークンを利用する

今回、RailsAPI モードでフロントエンドはフレームワークが異なるため、セッションベースの認証ではなくアクセストークンを利用して認証します。 そのために JWT を使いました。

Rails で JWT の仕組みを使いたい場合、ruby-jwt という gem があるため、これを利用してアクセストークンを発行できるようにします。

github.com

JWT について簡単にまとめておくと「JSON Web Token」の略でJSON形式のトークンを用いて、クライアントとサーバー間で認証や情報交換を行うための仕組みです。

JWTが発行するトークンは以下の3つに分かれていて

<ヘッダ>.<ペイロード>.<署名>

例を挙げるとこんな感じです。

eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxfQ.q39x-wtLH11PSL9XE6F_HbvK81DbSOP4ipCUdKFP0lz

それぞれの詳細を以下にまとめてみました。

ヘッダ

トークンのタイプや使用されている署名アルゴリズムの情報を持っている。

eyJhbGciOiJIUzI1NiJ9
{"alg": "HS256",  "typ": "JWT"}

ペイロード

任意の情報を指定することが可能。 この部分にユーザー認証に必要な情報を入れる。

eyJ1c2VyX2lkIjoxfQ
{"user_id"=>1}

署名

署名情報が入っている。 トークンが変更されていないか確認するために使用される。

q39x-wtLH11PSL9XE6F_HbvK81DbSOP4ipCUdKFP0lz

この情報が漏れると誰でもトークンの発行と検証ができてしまうため、Rails.application.secret_key_baseを使う。

ruby-jwt の使い方

署名アルゴリズムはデフォルトで HS256 が指定されているため、ペイロードと署名部分を設定することでトークンの発行が可能です。

# JWT.encode メソッドを使ってトークンを発行する
 JWT.encode(payload, secret_key)
 
 # JWT.decode メソッドを使って発行したトークンを確認する
 JWT.decode(token, secret_key)
 
 # 不正なトークンや署名情報が指定されると例外が発生する
 > JWT.decode("abc", "secret")
 (ember-blog):1:in `<main>': Not enough or too many segments (JWT::DecodeError)

実装内容

まず、メールアドレスとパスワードでユーザーを認証してアクセストークンを発行する部分です。

# app/controllers/tokens_controller.rb

class TokensController < ApplicationController
  rescue_from ActiveRecord::RecordInvalid do |e|
    render json: { errors: e.record.errors }, status: :unprocessable_content
  end

  def create
    user = User.find_by(email: params[:email])

    if user&.authenticate(params[:password])
      data  = { user_id: user.id }
      token = JWT.encode(data, Rails.application.secret_key_base)

      render json: { token: }, status: :ok
    else
      head :unauthorized
    end
  end
end
  • create アクションで、送られてきたメールアドレスでユーザーを検索
  • ユーザーが見つかった場合は、送られてきたパスワードによる認証を authenticate メソッドで行う。
    • authenticate メソッドは、 User クラスにhas_secure_password と記述した際に自動で追加される。
  • 引数で受け取ったパスワードをハッシュ化して、その結果が User オブジェクト内部に保存されている digest と一致するかを調べる
  • メールアドレスに対応するユーザーが存在しなかった時のために authenticate メソッドの呼び出しではぼっち演算子を使う。
  • メールアドレスとパスワードで認証できた場合、 JWT のアクセストークンを発行する

続いて、フロントエンドからのリクエストに対して、認証されているユーザーかをアクセストークンを用いてチェックできるようにします。

# app/controllers/application_controller.rb

class ApplicationController < ActionController::API
  private

  def verify_token
    auth_header = request.headers["Authorization"]

    unless auth_header
      return head :unauthorized
    end

    token = auth_header.split(" ").last

    begin
      JWT.decode(token, Rails.application.secret_key_base)
    rescue JWT::DecodeError
      head :forbidden
    end
  end
end

auth_header

"Bearer eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxfQ.q39x-wtLH11PSL9XE6F_HbvK81DbSOP4ipCUdKFP0lz"

のようになっているため .split(" ").last をして token 変数に格納しています。

ruby-jwt の使い方」で見た通り、不正なトークンや署名情報が指定されると例外が発生するため、例外処理で対応しています。

verify_token メソッドは以下のように使います。

# app/controllers/posts_controller.rb

class PostsController < ApplicationController
  before_action :verify_token, only: [ :create, :update, :destroy ]

次はEmber.js側での実装です。 Ember.js側ではユーザーが認証されているかどうかについて、Rails側で発行されたアクセストークンで判断します。

ユーザーのログイン状態のような持続的な状態を扱う場合は service/以下にアプリケーション全体で再利用可能なロジックや状態を保持するためのオブジェクトとして定義します。

Rails 側で発行されたアクセストークンをローカルストレージに保存・取得・削除するための関数を定義して呼び出せるようにします。

// web/app/services/session.js


  storeToken(token) {
    this.token = token;
    localStorage.setItem('token', token);
  }

  restoreToken() {
    this.token = localStorage.getItem('token');
  }

  deleteToken() {
    this.token = null;
    localStorage.removeItem('token');
  }

  get isLogedIn() {
    return !!this.token;
  }

これを例えばログインする際に以下のように利用します。

// web/app/controllers/login.js

import Controller from '@ember/controller';
import { action } from '@ember/object';
import { service } from '@ember/service'; // Service を import する

export default class LoginController extends Controller {
  @service router;
  @service session;

  @action
  async createToken(event) {
    event.preventDefault();

    const response = await fetch('http://localhost:3000/token', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },

      body: JSON.stringify({
        email: this.model.email,
        password: this.model.password,
      }),
    });

    if (!response.ok) {
      this.model.error = 'Login failed';
    } else {
      const json = await response.json();
      this.session.storeToken(json.token); // アクセストークンをローカルストレージに保存

      this.router.transitionTo('admin.posts');
    }
  }
}

メールアドレスとパスワードでログインを実行し、レスポンスがOKだった場合にローカルストレージにアクセストークンを保存しています。

続いて web/app/routes/application.jsにページリロードや直リンクでもローカルストレージに保存されたトークンを読み込んで this.session.token に復元されるようにします。

// web/app/routes/application.js

import Route from '@ember/routing/route';
import { service } from '@ember/service';

export default class ApplicationRoute extends Route {
  @service session;

  beforeModel() {
    this.session.restoreToken();
  }
}

その上で認証が必要な API リクエストにおいて this.session.token をヘッダに付与することで、再ログイン不要でセッションを継続できます。

export default class AdminPostsNewController extends Controller {
  @service router;
  @service toast;
  @service session;

  @action
  async createPost(event) {
    event.preventDefault();
    const response = await fetch('http://localhost:3000/posts', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${this.session.token}`, // アクセストークンを含める
      },

      body: JSON.stringify({
        post: {
          title: this.model.title,
          body: this.model.body,
        },
      }),
    });

ログイン済みのユーザーのみアクセスできる画面は以下のようにアクセストークンが存在しているかをチェックして制御します。

// web/app/routes/admin.js

import Route from '@ember/routing/route';
import { service } from '@ember/service';

export default class AdminRoute extends Route {
  @service router;
  @service session;

  beforeModel() {
    if (!this.session.isLogedIn) {
      this.router.transitionTo('index');
    }
  }
}

最後にログアウトする際は、アクセストークンを削除することで対応します。

// web/app/controllers/admin/posts/index.js

@action
logout() {
    this.session.deleteToken();

    this.router.transitionTo('login');

まとめ

RailsとEmber.jsでログイン機能の実装を通してSPAにおける基本的な トークンベース認証の流れをまとめてみました。

セキュリティ面を考えると十分な実装とは言えませんが、基礎となる考え方はこの実装を通じて学ぶことができたと感じています。

このブログではロジック部分を中心にまとめましたが、HTMLのマークアップもかなり苦手で JavaScript 同様に学び中です・・・!

ブログアプリを作ってみることを通じてフロントエンドの学びだけでなく、これまで自分が主に取り組んでいたRailsにおける開発にも新たな学びを得ることができているため、今後しばらく気がむく限り学んだ内容をブログに書いていこうと思います。