🥷

[Next.js × Rails]deviseを使わずにユーザー認証をシンプルに自作してみた

2024/05/19に公開

はじめに

Railsのユーザー認証といえばdeviseですが、簡単にユーザー周りの機能が実装できてしまう代わりにブラックボックスな部分が多く、予期せぬ挙動やバグが起きることもあります。
deviseに頼らず、勉強も兼ねてユーザー認証の理解を深めたいと思い簡単に認証機能を作ってみました。

以下、「コラム 6.1. 認証システムを作りながら学ぶべき理由」より

十分な実績のある出来合いのシステムは“内部を謎だらけ”にしたまま使い続けられる傾向があり、仕組みを理解せずに積み上げられた複雑怪奇なデータモデルは、初心者はもちろんベテラン開発者ですら手が付けられなくなりがちです。すべての仕組みの内部を理解するのは難しいですが、認証システムはセキュリティとも密接していて、ちょっとしたミスがユーザーに大きな影響を与えます。このため、特にセキュリティや脆弱性に関わる重要な仕組みの学習こそ、作りながら学ぶことが大切なのです。

https://railstutorial.jp/chapters/modeling_users?version=5.1#cha-modeling_users

環境

  • Next.js v14(App router)
  • Rails7 API

今回採用する認証方式

  • サーバー側でユーザー情報と紐づくセッションIDを発行する
  • サーバー、ブラウザ間の通信時にHTTPヘッダーのCookieにセッションIDをつける

セッションIDを使う理由

"ユーザー情報をクライアントサイドに保存したくない"

  • セッションベースではセッションIDのみをフロントで持ち、ユーザー情報はサーバー側で保管・取得を行うのに比べて、アクセストークンはトークンとしてフロント側でユーザー情報を持つことになる。
  • アクセストークンでよく使われるJWTはbase64でエンコードされただけの文字列なのでデコードすると簡単に中身がわかってしまう。
  • Railsの場合sessionメソッドを使うとcookieの中身が暗号化されてクライアント側に送られるため秘密鍵を知らない限り中身を確認したり改ざんすることはできなくなる。
    (JWTも改ざんを検知することはできるが)

https://qiita.com/taigaskg/items/affb20b0ef89947ebae3
https://jwt.io/

Cookieに保存する理由

"認証情報をWeb Storage APIに保存したくない"

  • LocalStorageなどのWeb Storage APIはJavaScriptから簡単にアクセスできるためXSSのリスクが高まる。
  • CookieもJavaScriptからのアクセスは可能だが、httpOnly属性をつけることでHTTPリクエストを送信する時しかアクセスできなくすることができる。

https://techracho.bpsinc.jp/hachi8833/2024_04_05/80851

ちなみにブラウザ側の保存先の選択肢としてはこのくらいあるようです。
https://zenn.dev/kibe/articles/8ec80078e123a2

補足:認証方式の選択について

  • あくまで今回は昔ながらの(?)安全と言われている手法をシンプルに試してみたかったためセッションIDを選んでいます。
  • アクセストークンよりもセッションIDの方が安全というわけではないです。
    (そもそも中身を見られたら困るのはセッションIDも変わらない)
  • ケースバイケースで、正しいものを正しいやり方で使うのが大事だと思います。
  • 例えばセッションIDはサーバーのメモリにセッション情報を保存していくのに比べて、アクセストークンはクライアントサイドで認証情報を保管するという点は、アクセストークンはサーバーのメモリに依存しないためスケールしやすいということなので、ユーザーの多いサービスに向いています。

Rails側の前準備

データ準備

基本的にはRailsチュートリアルの6章に沿って以下を行っておきます。
・認証対象のユーザーを管理するモデルとテーブルとデータを追加しておく。
・特に今回ユーザーの認証にauthenticateなどを使うので、has_secure_passwordメソッドを認証用のモデルに追加する6.3章などは必須。
https://railstutorial.jp/chapters/modeling_users?version=5.1#sec-adding_a_secure_password

CORS設定

フロントエンドとバックエンドでオリジンが分かれる場合に通信できるようにします。

Gemfile
gem 'rack-cors' # デフォルトでコメントアウトされているかも
cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins 'https://127.0.0.1:3001' # フロントを動作させているオリジン

    resource '*',
      headers: :any,
      methods: [:get, :post, :put, :patch, :delete, :options, :head],
      credentials: true # 通信時にCookieを使う場合必須
  end
end

RailsのAPIモードでCookieを扱えるようにする

RailsをAPIモードにした場合デフォルトではCookieが扱えなくなるようで、必要なmiddlewareを適宜読み込む必要があります。

Gemfile
gem "rails_same_site_cookie", "~> 0.1.8" # Secure属性をtrueにする。
application.rb
module Application
  class Application < Rails::Application
    # ...
    config.middleware.use ActionDispatch::Cookies # クライアントとのやりとりでCookieを扱えるようにする。
    config.middleware.use ActionDispatch::Session::CookieStore # sessionメソッドを扱えるようにする(セッションデータを暗号化してCookieに保存する)
  end
end
application_controller.rb
class ApplicationController < ActionController::API
  include ActionController::Cookies # cookiesメソッドが定義されているモジュール
  # ...
end

タスク

  • ログインしてセッションIDを発行する
  • Cookieに保存されたセッションIDでログイン状態をチェックする
  • ログアウトしてセッションIDを破棄する

ログインしてセッションIDを発行する

フロントエンド

  • ログインが完了したらユーザーのプロフィールページに遷移します。
  • axiosを使う場合は、引数にオプションとして{ withCredentials: true }をつけることでCookieをリクエストのHTTPヘッダにつけて送ることができます。
page.tsx
const clickLogin = (event: React.FormEvent<HTMLFormElement>) => {
  event.preventDefault();

  axios.post(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/user/login`, loginFormInputs,
    { withCredentials: true }
  )
    .then((response) => {
      router.push(`../user/${response.data.id}`);
    })
    .catch((error) => {
      console.log(error);
    })
}

バックエンド

  • Railsのhas_secure_passwordauthenticateメソッドを使って認証しています。
    (Railsチュートリアルとほぼ同じです。)
    • has_secure_passwordを認証対象とするモデルで呼んでおくことでユーザー登録(DB保存)時にpasswordがハッシュ化される。
    • authenticateで、リクエストパラメーターのpasswordをハッシュ化した値とDBに保存されているハッシュ化されたpasswordが一致するかを確認する。
account.rb
class Account < ApplicationRecord
  has_secure_password
  # ...
end
sessions_controller.rb
def create
  user = Account.find_by(email: params[:email])

  if user&.authenticate(params[:password])
    session[:user_id] = user.id
    response = { message: 'authorized', id: user.id, name: user.name }
  else
    response = { error: 'login_unauthorized' }
  end

  render json: response
end

HTTPレスポンス(開発者ツールNetworkタブ > Headers)

  • session[:user_id]によってレスポンスヘッダーのset-cookieに暗号化されたセッションIDがつけられます。

Cookieに保存されたセッションIDでログイン状態をチェックする

フロントエンド

  • 画面レンダリングの際にuseEffectでログイン確認APIをコールしています。
  • リクエストヘッダにcookieをつけるため、ログイン時と同じくaxiosに{ withCredentials: true }をつけます。
page.tsx
const loggedIn = () => {
  axios.get(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/user/logged_in`,{
    withCredentials: true
  })
    .then((response) => {
      response.data.user && setIsLoggedIn(true);
    })
    .catch((error) => {
      console.error(error);
    })
}

// ...

useEffect(() => {
  loggedIn();
}, [])

バックエンド

  • リクエストヘッダのcookieにつけられたセッションIDを用いてDBからユーザー情報を検索し、存在した場合はユーザ情報を返します。
  • セッションIDが無い、もしくはDB検索に失敗する場合はユーザーがログイン中であることを確認できないため、何も情報は返しません。
sessions_controller.rb
def logged_in?
  @current_user ||= Account.find_by(id: session[:user_id])
  if @current_user
    render json: { logged_in: true, user: current_user }
  else
    render json: { logged_in: false, message: 'ユーザーが存在しません' }
  end
end

HTTPリクエスト & レスポンス(開発者ツールNetworkタブ > Cookies)

ログアウトしてセッションIDを破棄する

フロントエンド

  • ログアウト成功後はログイン画面に遷移します。
  • 他のタスクと同じくaxiosに{ withCredentials: true }をつけます。
page.tsx
const clickLogout = () => {
  axios.delete(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/user/logout`,{
    withCredentials: true
  })
    .then(() => {
      router.push(`../login`);
    })
    .catch((error) => {
      console.error(error);
    })
}

バックエンド

  • session.deleteで明示的にセッションを削除します。
sessions_controller.rb
  def destroy
    session.delete(:user_id)
    @current_user = nil
  end

ブラウザに保存されているCookie(開発者ツールApplicationタブ)

  • ログイン中とログアウト後で、セッションIDのSizeが小さくなっていることがわかります。

ログイン中

ログアウト後

補足:セッションの有効期限について

今回作成した仕組みだけだとブラウザを閉じるたびにログイン状態が破棄されます。
Railsのsessionを使ったCookieはブラウザのメモリに保存されるためです。
今回はやることのスコープに入れていませんが、cookiesメソッドを使ってブラウザに永続的にユーザー情報を保存することもできます。

例)Railsチュートリアル、Remember me機能の作成
https://railstutorial.jp/chapters/advanced_login?version=5.1#sec-remember_me

Cookieのセキュリティについて

今回はCookieにユーザー認証用のセッションIDを保存するため、セキュアにCookieを送受信する必要があります。
セキュアなCookieの通信について、まずMDNには以下の記述があります。

Cookie が安全に送信され、意図しない第三者やスクリプトからアクセスされないようにするには、 Secure 属性HttpOnly 属性の 2 つの方法があります。

今回のログイン機能で送受信されるCookieを開発ツールで見ると、Secure属性とHttpOnly属性がtrueになっているのがわかります。

2つの属性について、少し詳しく見てみます。

Secure属性

  • "rails_same_site_cookie"gemによってつけられています。
  • HTTPSの暗号化された通信でのみCookieが送受信されるようになります。
  • 盗聴された際にCookie情報を取得されてしまうのを防ぐことができます。
  • 今回で言うと、Next.jsとRailsのサーバーをそれぞれhttpsで立ち上げることで、Cookieが送信できます。

HttpOnly属性

  • Railsのsessionで作成したCookieは自動でHttpOnly属性がtrueになって送信されます。
  • サーバーへの送信時にのみCookieが送られるようになる設定です。
  • JavaScriptのDocument.cookie APIからアクセスできなくなるため、XSS対策になります。

例) 今回のログインでブラウザに保存されるCookie
HttpOnly属性がついているためブラウザからdocument.cookieで取得できない。

例) zennのページでブラウザに保存されるCookie
HttpOnly属性がついていないCookieはdocument.cookieで取得できる。

SameSite属性

次に、MDNにはSameSite属性について以下の記述があります。

SameSite 属性により、サーバーがサイト間リクエスト (ここでサイトは登録可能なドメインによって定義されます) と一緒に Cookie を送るべきではないことを要求することができます。これは、クロスサイトリクエストフォージェリー攻撃 (CSRF) に対していくらかの防御となります。

  • 今回、CookieのSameSite属性をLaxにしています。
  • フロントとバックエンドで分かれる構成ですが、現在ローカルではドメインを分けていないためひとまずSameSiteをNoneにせずとも通信ができています。
    例)
    フロント:https://127.0.0.1:3001/
    バックエンド:https://127.0.0.1:3443/
  • ただ、一般的にはこのような構成の場合サーバーやドメインが分かれることになると思うので、別途SameSite属性をNoneにする必要がある場合のCSRF対策を考える必要があります。

CSRF攻撃とは?

CSRFとは、以下のような攻撃です。

  1. 銀行口座にログイン中(認証用のセッションIDがブラウザCookieに保存されている状態)にネットサーフィンを行う
  2. 攻撃者の用意したリンクやWebページを開いてしまう
  3. そのページにはユーザーから見えない状態で以下のformが埋め込まれており、ユーザーがHTMLを読み込むと同時に自動でユーザーのブラウザから銀行口座のサーバーに送金依頼のリクエストが送られてしまう
  4. ブラウザの特性上、リクエストにはCookieも自動で付与される
  5. サーバーはCookieを確認してログイン中のユーザーからのリクエストだと判断するため悪意のあるリクエストだとは気づかず、送金処理を実行する
MDNより抜粋.html
<form action="https://bank.example.com/withdraw" method="POST">
  <input type="hidden" name="account" value="bob" />
  <input type="hidden" name="amount" value="1000000" />
  <input type="hidden" name="for" value="mallory" />
</form>
<script>
  window.addEventListener('DOMContentLoaded', (e) => { document.querySelector('form').submit(); }
</script>

CSRF対策について(案)

以下で主に3つの方法がわかりやすくまとめられていました。
ドメインが分かれる構成になる場合は検討してみてください。

  • SameSite属性をStrict, もしくはLaxにする。(今回の方法)
  • CSRFトークンを使う
  • 事前にPreflight requestを投げる

https://qiita.com/dadayama/items/a0022b8f5f453a188e19

また、こういったセキュリティに関してはOWASP cheetsheetというコンテンツで、各カテゴリにおいてどう設計・実装すれば良いのかという指針にできるようなチートシートがあるようです。(以下はCSRFのページ)
https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html

まとめ

  • deviseに頼ることでブラックボックス感が強かったユーザー認証を、自分の言葉でなんとなくの仕組みを説明できるくらいは理解できた。
  • ユーザー認証についてはただ機能を実現させるだけでなく、セキュリティを考えながら使う技術や実装方法を自分で調べて選んでいかなければいけないので、そこに必要な知識としてHTTPやブラウザの仕様の理解が深まった。
  • 「これをしたらこれが送られる」などを、HTTPヘッダやApplicationタブの中身を自分の目で見てちゃんと確かめると、自分の知識としてしっかり身につけられるし新しい気づきもある(本来は公式に書いてあるけど見逃してるようなこととか)

その他参考

https://developer.mozilla.org/ja/docs/Web/HTTP/Cookies#cookie_へのアクセス制限

https://developer.mozilla.org/ja/docs/Web/Security/Types_of_attacks

https://railsguides.jp/security.html#csrfへの対応策

https://qiita.com/silane1001/items/2adba867f2c4e60ca8e5

https://zenn.dev/yktakaha4/articles/study_csrf_attack

Discussion