[Next.js × Rails]deviseを使わずにユーザー認証をシンプルに自作してみた
はじめに
Railsのユーザー認証といえばdeviseですが、簡単にユーザー周りの機能が実装できてしまう代わりにブラックボックスな部分が多く、予期せぬ挙動やバグが起きることもあります。
deviseに頼らず、勉強も兼ねてユーザー認証の理解を深めたいと思い簡単に認証機能を作ってみました。
以下、「コラム 6.1. 認証システムを作りながら学ぶべき理由」より
十分な実績のある出来合いのシステムは“内部を謎だらけ”にしたまま使い続けられる傾向があり、仕組みを理解せずに積み上げられた複雑怪奇なデータモデルは、初心者はもちろんベテラン開発者ですら手が付けられなくなりがちです。すべての仕組みの内部を理解するのは難しいですが、認証システムはセキュリティとも密接していて、ちょっとしたミスがユーザーに大きな影響を与えます。このため、特にセキュリティや脆弱性に関わる重要な仕組みの学習こそ、作りながら学ぶことが大切なのです。
環境
- Next.js v14(App router)
- Rails7 API
今回採用する認証方式
セッションID + Cookie
- サーバー側でユーザー情報と紐づくセッションIDを発行する
- サーバー、ブラウザ間の通信時にHTTPヘッダーのCookieにセッションIDをつける
セッションIDを使う理由
"ユーザー情報をクライアントサイドに保存したくない"
- セッションベースではセッションIDのみをフロントで持ち、ユーザー情報はサーバー側で保管・取得を行うのに比べて、アクセストークンはトークンとしてフロント側でユーザー情報を持つことになる。
- アクセストークンでよく使われるJWTはbase64でエンコードされただけの文字列なのでデコードすると簡単に中身がわかってしまう。
- Railsの場合sessionメソッドを使うとcookieの中身が暗号化されてクライアント側に送られるため秘密鍵を知らない限り中身を確認したり改ざんすることはできなくなる。
(JWTも改ざんを検知することはできるが)
Cookieに保存する理由
"認証情報をWeb Storage APIに保存したくない"
- LocalStorageなどのWeb Storage APIはJavaScriptから簡単にアクセスできるためXSSのリスクが高まる。
- CookieもJavaScriptからのアクセスは可能だが、httpOnly属性をつけることでHTTPリクエストを送信する時しかアクセスできなくすることができる。
ちなみにブラウザ側の保存先の選択肢としてはこのくらいあるようです。
補足:認証方式の選択について
- あくまで今回は昔ながらの(?)安全と言われている手法をシンプルに試してみたかったためセッションIDを選んでいます。
- アクセストークンよりもセッションIDの方が安全というわけではないです。
(そもそも中身を見られたら困るのはセッションIDも変わらない) - ケースバイケースで、正しいものを正しいやり方で使うのが大事だと思います。
- 例えばセッションIDはサーバーのメモリにセッション情報を保存していくのに比べて、アクセストークンはクライアントサイドで認証情報を保管するという点は、アクセストークンはサーバーのメモリに依存しないためスケールしやすいということなので、ユーザーの多いサービスに向いています。
Rails側の前準備
データ準備
基本的にはRailsチュートリアルの6章に沿って以下を行っておきます。
・認証対象のユーザーを管理するモデルとテーブルとデータを追加しておく。
・特に今回ユーザーの認証にauthenticateなどを使うので、has_secure_passwordメソッドを認証用のモデルに追加する6.3章などは必須。
CORS設定
フロントエンドとバックエンドでオリジンが分かれる場合に通信できるようにします。
gem 'rack-cors' # デフォルトでコメントアウトされているかも
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を適宜読み込む必要があります。
gem "rails_same_site_cookie", "~> 0.1.8" # Secure属性をtrueにする。
module Application
class Application < Rails::Application
# ...
config.middleware.use ActionDispatch::Cookies # クライアントとのやりとりでCookieを扱えるようにする。
config.middleware.use ActionDispatch::Session::CookieStore # sessionメソッドを扱えるようにする(セッションデータを暗号化してCookieに保存する)
end
end
class ApplicationController < ActionController::API
include ActionController::Cookies # cookiesメソッドが定義されているモジュール
# ...
end
タスク
- ログインしてセッションIDを発行する
- Cookieに保存されたセッションIDでログイン状態をチェックする
- ログアウトしてセッションIDを破棄する
ログインしてセッションIDを発行する
フロントエンド
- ログインが完了したらユーザーのプロフィールページに遷移します。
- axiosを使う場合は、引数にオプションとして
{ withCredentials: true }
をつけることでCookieをリクエストのHTTPヘッダにつけて送ることができます。
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_password
のauthenticate
メソッドを使って認証しています。
(Railsチュートリアルとほぼ同じです。)-
has_secure_password
を認証対象とするモデルで呼んでおくことでユーザー登録(DB保存)時にpasswordがハッシュ化される。 -
authenticate
で、リクエストパラメーターのpasswordをハッシュ化した値とDBに保存されているハッシュ化されたpasswordが一致するかを確認する。
-
class Account < ApplicationRecord
has_secure_password
# ...
end
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 }
をつけます。
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検索に失敗する場合はユーザーがログイン中であることを確認できないため、何も情報は返しません。
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 }
をつけます。
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
で明示的にセッションを削除します。
def destroy
session.delete(:user_id)
@current_user = nil
end
ブラウザに保存されているCookie(開発者ツールApplicationタブ)
- ログイン中とログアウト後で、セッションIDのSizeが小さくなっていることがわかります。
ログイン中
ログアウト後
補足:セッションの有効期限について
今回作成した仕組みだけだとブラウザを閉じるたびにログイン状態が破棄されます。
Railsのsession
を使ったCookieはブラウザのメモリに保存されるためです。
今回はやることのスコープに入れていませんが、cookies
メソッドを使ってブラウザに永続的にユーザー情報を保存することもできます。
例)Railsチュートリアル、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とは、以下のような攻撃です。
- 銀行口座にログイン中(認証用のセッションIDがブラウザCookieに保存されている状態)にネットサーフィンを行う
- 攻撃者の用意したリンクやWebページを開いてしまう
- そのページにはユーザーから見えない状態で以下のformが埋め込まれており、ユーザーがHTMLを読み込むと同時に自動でユーザーのブラウザから銀行口座のサーバーに送金依頼のリクエストが送られてしまう
- ブラウザの特性上、リクエストにはCookieも自動で付与される
- サーバーはCookieを確認してログイン中のユーザーからのリクエストだと判断するため悪意のあるリクエストだとは気づかず、送金処理を実行する
<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を投げる
また、こういったセキュリティに関してはOWASP cheetsheetというコンテンツで、各カテゴリにおいてどう設計・実装すれば良いのかという指針にできるようなチートシートがあるようです。(以下はCSRFのページ)
まとめ
- deviseに頼ることでブラックボックス感が強かったユーザー認証を、自分の言葉でなんとなくの仕組みを説明できるくらいは理解できた。
- ユーザー認証についてはただ機能を実現させるだけでなく、セキュリティを考えながら使う技術や実装方法を自分で調べて選んでいかなければいけないので、そこに必要な知識としてHTTPやブラウザの仕様の理解が深まった。
- 「これをしたらこれが送られる」などを、HTTPヘッダやApplicationタブの中身を自分の目で見てちゃんと確かめると、自分の知識としてしっかり身につけられるし新しい気づきもある(本来は公式に書いてあるけど見逃してるようなこととか)
その他参考
Discussion