前置き
最近、フロントエンドの学習を頑張っています。
仕事ではサーバーサイドの開発をすることのほうがたまたま多かったこともあって、元々苦手だった JavaScript の学習とフロントエンドの開発に距離ができてしまっていました。
3月から少しずつ仕事でもフロントエンドに触れる機会が出てきて、どのようにキャッチアップしていくか迷っていたのですが、Rails のAPIモードとフロントエンドのフレームワークを組み合わせてブログアプリを作りながら必要な知識を学んでいく形で取り組んでいます。
今回、フロントエンドのフレームワークは Ember.js を使っていますが、フレームワーク固有の知識だけでなくフロントエンド開発のベースとなる部分の知識を理解しながら取り組むことを意識してやっているため、ブログに学習記録をまとめることにしてみました。
前提として、フロントエンド開発歴が浅めの私が学んでいく過程の記録であり、実際の業務アプリの開発ではもっと応用しないといけない内容が多く含まれることになると思います。
ログイン機能の実装
要件
- ユーザーはメールアドレスとパスワードでログインができる
- ログインしていないユーザーはブログ一覧と詳細画面のみアクセスできる
- ログインしているユーザーは管理画面からブログの投稿・更新・削除ができる
- ログアウトすることができる
アクセストークンを利用する
今回、Rails は API モードでフロントエンドはフレームワークが異なるため、セッションベースの認証ではなくアクセストークンを利用して認証します。 そのために JWT を使いました。
Rails で JWT の仕組みを使いたい場合、ruby-jwt という gem があるため、これを利用してアクセストークンを発行できるようにします。
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における開発にも新たな学びを得ることができているため、今後しばらく気がむく限り学んだ内容をブログに書いていこうと思います。