Help us understand the problem. What is going on with this article?

CrossOrigin な SPA と Rails API 構成におけるcookie + session認証

この記事はClassi Advent Calendar 2019 1日目の記事です。

こんにちは。Classiの2019新卒エンジニアの原(@hxrxchang)です。
2年連続で1日目を飾っていた@kasaharuさんから、トップバッターを奪い取ってしまったことを後悔してます(初日は辛い)。

さて今回は、SPAとRails API間での認証認可で悩んだことをまとめて、共有したいと思います。

SPAの認証はtokenをlocalStorageに入れる??

2ヶ月ほど前、https://techracho.bpsinc.jp/hachi8833/2019_10_09/80851 の記事がバズっていました。内容を簡単にまとめると、「localStorageはJavaScriptで簡単にアクセスできしまうので(=XSSで格納されたデータを簡単に抜ける)、機密情報を格納するのはやめよう」というものでした。確かに、npmで様々なライブラリに依存している現代のフロントエンド開発に於いて、localStorageからtokenを抜き出すようなコードが混ざり混んでいる可能性は0ではないなと思いました。

それまでSPAの認証は、APIのアクセストークンをJavaScriptでlocalStorageやHttpOnlyではないcookieに格納したり取り出したりして、APIとの認証をするのが当たり前だと思っていました。

というのも、ローカル開発中でSPAのdev serverがlocalhost:4200、APIサーバーがlocalhost:3000というように別ポートで動いていたり、本番環境でSPAをホスティングしてるサーバーのオリジンと、APIサーバーのオリジンが違うと(要はCORSになっている時)、cookieでの認証はできないという思い込んでいました。

SPAでもMPAみたいにcookie + sessionで認証したい

ということで、SPAとJSONを吐き出すWEB API間(Cross Origin)で、MPA(multiple page application = ページ遷移ごとにHTMLを返すWEBアプリケーション)みたいに、cookie + sessionを使って認証をする方法を書いて行きたいと思います。
今回はSPAをAngular、APIをRuby on Railsで実装していきます。
リポジトリはこちらなのでよかったらご覧ください:bow:

アプリケーションの仕様としては、usernameとpasswordで認証を通ったら、/helloページに辿りつけるというものです。

demo.gif

APIの実装

最初にRailsでAPIの実装を進めます。
まず以下のようなUserモデルを作成しました。

user.rb
class User < ApplicationRecord
  has_secure_password

  validates :password, length: { in: 6..20 }
  validates :name, uniqueness: { case_sensitive: true }
  validates_format_of [:name, :password], :with => /\A[a-zA-Z0-9]+\z/
end

そしてcontrollerで認証の処理を行います。一意のユーザーネームとパスワードを合わせて認証をして、通ったらuser_idをsessionに格納するという流れです。

users_controller.rb
class UsersController < ApplicationController
  skip_before_action :check_is_signed_in, only: [:sign_in]

  def sign_in
    user = User.find_by(name: params[:name]).authenticate(params[:password])
    session[:user_id] = user.id
    render json: { message: "sign in success" }
  end
end

またコンテンツを返す想定であるHelloControllerでは、認証を通ってから実行されるようにapplication_controllerでsessionをチェックする処理を行っています。

application_controller.rb
class ApplicationController < ActionController::API
  before_action :check_is_signed_in

  def check_is_signed_in
    if !session[:user_id]
      render_401_error("unauthorized")
      return
    end

    @user = User.find(session[:user_id])
  end

  def render_401_error(error = nil)
    render json: { message: "unauthorized" }, status: 401
  end
end
hello_controller.rb
class HelloController < ApplicationController
  def index
    render json: { message: 'hello world' }
  end
end

この時点でのルーティングはこんな感じです。

routes.rb
Rails.application.routes.draw do
  scope :api, defaults: { format: :json } do
    post '/auth/sign-in', to: 'users#sign_in'
    get '/hello', to: 'hello#index'
  end
end

この時点で、rails consoleでユーザーを作成して、そのnameとpasswordを使うだけで認証できるようになっています。
Postmanを使って、post '/auth/sign-in'を叩くとcookieがセットされて、get '/hello'が叩けるようになっているはずです。

cookieがセットされてる↓
スクリーンショット 2019-12-01 2.51.23.png

Postmanからリクエストする分にはこれで動きますが、今回は4200番ポートで動いているAngularアプリケーションからリクエスト想定なので、CORSの設定をします。Railsではrack-corsが便利です。

Gemfileにrack-corsを追加し、config/application.rbを以下のように変更します。

config/application.rb
require_relative 'boot'

require 'rails/all'

Bundler.require(*Rails.groups)

module RailsSpaCookieSessionSample
  class Application < Rails::Application
    config.load_defaults 6.0

    config.middleware.insert_before 0, Rack::Cors do
      allow do
        origins "http://localhost:4200"
        resource "*",
          headers: :any,
          methods: [:get, :post, :options, :head]
      end
    end
  end
end

SPAと繋ぐ

次にAngularでSPAを実装してAPIと繋ぎこみます。

ログインページのcomponentから呼ばれる認証のサービスを以下のように実装します。

auth.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';

@Injectable({
  providedIn: 'root'
})
export class AuthService {
  constructor(private http: HttpClient) {}

  signIn(name: string, password: string): Observable<{message: string}> {
    return this.http.post<{message: string}>('http://localhost:3000/api/auth/sign-in', {
      name
      password
    });
  }
}

これでブラウザからAPIリクエストすることができました!!!!。。。。と思いきや、APIレスポンスは200なのにcookieがセットされていません。。

CORSでcookieを使えるようにするには

答えはMDNに書いてありました。https://developer.mozilla.org/ja/docs/Web/HTTP/CORS

CORSでcookieを使えるようにするには、ブラウザからのリクエストにはXMLHttpRequest.withCredentialsを、サーバーからのレスポンスにはAccess-Control-Allow-Credentialsをそれぞれtrueにする必要があります。

なので先ほどのサービスを以下のように変更します。

auth.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';

@Injectable({
  providedIn: 'root'
})
export class AuthService {
  constructor(private http: HttpClient) {}

  signIn(name: string, password: string): Observable<{message: string}> {
    const requestOptions = {
      withCredentials: true
    };
    return this.http.post<{message: string}>(
      'http://localhost:3000/api/auth/sign-in',
      {
        name,
        password
      },
      requestOptions
    );
  }
}

ここでは分かりやすいようにserviceでoptionを付与していますが、全部のAPIリクエストに書くのは面倒なので、interceptorを実装しました。
https://github.com/hxrxchang/rails-spa-cookie-session-sample/blob/master/frontend/angular/src/app/interceptors/with-credential-interceptor.ts

interceptorについては、Angular After Tutorialの記事が大変分かりやすいです(https://gitbook.lacolaco.net/angular-after-tutorial/season-3-httpclient-in-action/interceptors)。

Rails側は、rack-corsのcredentialsオプションでAccess-Control-Allow-Credentialsを有効にすることができます。

config/application.rb
require_relative 'boot'

require 'rails/all'

Bundler.require(*Rails.groups)

module RailsSpaCookieSessionSample
  class Application < Rails::Application
    config.load_defaults 6.0

    config.middleware.insert_before 0, Rack::Cors do
      allow do
        origins "http://localhost:4200"
        resource "*",
          headers: :any,
          methods: [:get, :post, :options, :head],
          credentials: true #この行を追加
      end
    end
  end
end

これでcookieをセットすることができました:tada::tada::tada:

cookie.gif

まとめ

https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials

資格情報を含む CORS リクエストにおいて、ブラウザーがレスポンスを JavaScript コードに公開するようにするためには、サーバー側 (Access-Control-Allow-Credentials ヘッダーを使用) とクライアント側 (XHR, Fetch Ajax リクエストの資格情報モードの設定) の両方が、資格情報を含むことを承認しなければなりません。

と書いてあるように、フロントとサーバー両方でcookieを送るための設定が必要だと覚えておけば良さそうですね。

詳しくはソースコードでご確認ください。

以上、Classi Advent Calendar 2019 1日目の記事でした。
明日はCREチームの@kozy4324さんです。

参考資料

https://techracho.bpsinc.jp/hachi8833/2019_10_09/80851
https://twitter.com/qsona/status/1182119914804400130 お二人のやりとりが非常に勉強になりました
https://developer.mozilla.org/ja/docs/Web/HTTP/CORS
https://gitbook.lacolaco.net/angular-after-tutorial/season-3-httpclient-in-action/interceptors
https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/withCredentials
https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials

classi
学校の先生・生徒・保護者向けのB2B2Cの学習支援Webサービス「Classi(クラッシー)」 を開発・運営している会社です。
https://classi.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした