はじめに
フロントエンドにNext.js、バックエンドにRailsを使用した構成で、Cookieを使った認証時に発生した問題とその解決方法をメモとしてまとめました!
結論
結論を先に言うと
Next.jsのApp Routerを使用した場合、サーバーサイド(Rails)へのリクエストはNode.jsから送信されるため、クライアント側で保持されているCookieが正しく含まれないことがあります
これを解決するためには、cookies()関数など使用してサーバーサイドでCookieを取得し、リクエストヘッダーに設定する必要がありました
問題
- フロントエンドでログイン後、データを取得しようとすると、認証エラー(401 Unauthorized)が返却される
- Postmanでは正常に動作する
- RSpecでの認証に関するテストはパスしている
サーバー側の処理
関係箇所のみ抜粋(Deviseなどのgemは使用していません)
class SessionsController < ApplicationController
skip_before_action :authenticate_user!, only: %i[create]
def create
user = User.find_by(account_name: session_params[:account_name])
if user&.authenticate(session_params[:password])
session[:user_id] = user.id
head :ok
else
render json: { message: 'アカウント名とパスワードの組み合わせが不正です' }, status: :unauthorized
end
end
def destroy
reset_session
render json: { message: 'ログアウトしました' }, status: :ok
end
private
def session_params
params.require(:session).permit(:account_name, :password)
end
end
このコードでは、アカウント名とパスワードを受け取り、認証に成功すればセッションにuser_id
を保存し、Cookieを返すようになっています
class ApplicationController < ActionController::API
include ActionController::Cookies
before_action :authenticate_user!
private
def current_user
@current_user ||= User.find_by(id: session[:user_id])
end
def authenticate_user!
render_unauthorized_error('ログインしてください') unless current_user
end
end
共通の前処理として:authenticate_user!
を設定し、current_user
メソッドで認証確認を行なっています!
原因調査
現状、ログイン後にメモの一覧取得APIを叩くと、401 Unauthorized
エラーが返ってくることが分かっています
まずは、サーバー側のログを見てみます!
サーバー側のログ確認
Started GET “/memos” for XXX.XXX.XX.X at 2024-11-02 15:47:39 +0900
Processing by MemosController#index as */*
Parameters: {“memo”=>{}}
[1m[36mUser Load (44.0ms)[0m [1m[34mSELECT `users`.* FROM `users` WHERE `users`.`id` IS NULL LIMIT 1[0m
↳ app/controllers/application_controller.rb:9:in `current_user’
Filter chain halted as :authenticate_user! rendered or redirected
Completed 401 Unauthorized in 104ms (Views: 3.8ms | ActiveRecord: 50.7ms (1 query, 0 cached) | GC: 0.0ms)
ユーザーIDがNULL
となっているため、認証エラーが発生しているみたいです
フロント側から渡ってきたCookieが空である可能性がありそうです
Cookieの確認
current_user
メソッドの前にリクエストヘッダーのCookieをログに出力して確認してみます
def authenticate_user!
logger.debug "Cookie Header: #{request.headers['Cookie']}"
render_unauthorized_error('ログインしてください') unless current_user
end
結果:
Cookie Header:
リクエストヘッダーのCookieはやっぱり空でした
ここから2つの可能性が考えられそうです
- ログイン時にサーバー側できちんとCookieを返していない
- ブラウザ側でCookieが保持されていない
サーバーでのCookieの返却を確認
ログイン時にCookieが返されているかを確認するため、またログを出力するようにします!
愚直にベタベタ書いてきます笑
def create
user = User.find_by(account_name: session_params[:account_name])
if user&.authenticate(session_params[:password])
session[:user_id] = user.id
logger.debug "ログイン時:バックエンドのクッキー"
cookies.each { |key, value| logger.debug "Cookie #{key}: #{value}" }
logger.debug "ログイン時:バックエンドのセッション"
session.each { |key, value| logger.debug "Session #{key}: #{value}" }
head :ok
else
render json: { message: 'アカウント名とパスワードの組み合わせが不正です' }, status: :unauthorized
end
end
結果:
ログイン時:バックエンドのクッキー
Cookie_Header: _progaku_archive_session=...
ログイン時:バックエンドのセッション
Session session_id: ...
Session user_id: 3
サーバー側で正しくCookieとセッションが生成されていることは確認できました!
ブラウザにCookieが保持されているか?
ブラウザの検証ツールで確認したところ、ログイン直後はCookieが保持されていることも確認できました
注意
SameSiteをnoneにしてクロスドメインでのリクエスト許可しているのにもかかわらずSecureをTrueにしていませんが開発環境だからです。本番ではSecureをTrueにしてHttps通信時のみCookieを使用するように制限してください!
どういうこと!?
この辺りから、思考が停止し始めました笑
サーバー側でCookie、Sessionも問題なく生成され、ブラウザ側にも保持されている
にも関わらずリクエスト時には渡ってこない
何故??
念の為Fetch APIの設定確認
const response = await fetch(`${process.env.NEXT_PUBLIC_API}/memos`, {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
});
credentials: 'include'
を設定してCookieを受け付け、リクエストに含める設定にはなっています。
ちなみに include
はクロスオリジン間でもCookieなどの資格情報を含めるオプションです。
Postmanの動作確認
Cookie Header: _progaku_archive_session=eZLeQLjfX2urWEA2mdVw3%2Bh...(以下省略)
やはりPostmanでは問題なくCookieが渡ってきてmemoも取得できてしまいました
リロードでCookieが消える問題
検証ツールで色々試していると、ログイン後、リロードするとCookieが消えてしまうことに気づきました
Cookieの期限が適切にヘッダーに含まれてない?それともCORS周りの設定が起因している?
わからなかったので、とりあえずHTTPの全てのHeaderをログに出力して
ログイン時と、メモ取得時とで比較して確認することにしました。
ログイン時とメモ取得時のHeaderログ比較
各処理内に下記のコードを追加
request.headers.each do |key, value|
# ヘッダーのキーが HTTP_ で始まるものだけを出力する
if key.start_with?('HTTP_') || key == 'CONTENT_TYPE' || key == 'CONTENT_LENGTH'
logger.debug "#{key}: #{value}"
end
end
ログイン時
=== Request Headers ===
HTTP_HOST: localhost:8080
HTTP_CONNECTION: keep-alive
CONTENT_LENGTH: 79
HTTP_SEC_CH_UA_PLATFORM: "macOS"
HTTP_USER_AGENT: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36
HTTP_SEC_CH_UA: "Chromium";v="130", "Google Chrome";v="130", "Not?A_Brand";v="99"
CONTENT_TYPE: application/json
HTTP_SEC_CH_UA_MOBILE: ?0
HTTP_ACCEPT: */*
HTTP_ORIGIN: http://localhost:3000
HTTP_SEC_FETCH_SITE: cross-site
HTTP_SEC_FETCH_MODE: cors
HTTP_SEC_FETCH_DEST: empty
HTTP_REFERER: http://localhost:3000/
HTTP_ACCEPT_ENCODING: gzip, deflate, br, zstd
HTTP_ACCEPT_LANGUAGE: ja,en-US;q=0.9,en;q=0.8
HTTP_COOKIE: _progaku_archive_session=96jfLN3gVfkjxnpaC%2FpJGNqYL4CvOz8v5CcfL6CP67St4Pa8DoYumNXxt43%2B%2B%2FnNNCvmQt%2BYtUEEOBXb3YH0JIJ36Z51JVvkymsokFvGTaWJjtLmqr%2F%2F3dhTqNmXrWW2evweRmd1HwrcrsdT2Cp%2B6tgpPqnoiLt2hv2FRWBsYPdImqtwOW0PG5Mf4T9j8IFSo7SGVGGwMyJLGGxak89uyg%3D%3D--DAQiHiIvY%2FOLbWuw--swdD3Hnyfd04UtqhM%2BkFlQ%3D%3D
HTTP_VERSION: HTTP/1.1
========================
メモ取得時
=== Request Headers ===
HTTP_HOST: localhost:8080
HTTP_CONNECTION: keep-alive
CONTENT_TYPE: application/json
HTTP_ACCEPT: */*
HTTP_ACCEPT_LANGUAGE: *
HTTP_SEC_FETCH_MODE: cors
HTTP_USER_AGENT: node
HTTP_ACCEPT_ENCODING: br, gzip, deflate
HTTP_VERSION: HTTP/1.1
========================
ここであることに気づきました
User-Agentが違う
User-Agentは簡単にいうとクライアントの情報になります
ログイン時は、ブラウザからリクエストが送られています
HTTP_USER_AGENT: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36
でもメモ取得時は
HTTP_USER_AGENT: node
nodeとなっていました
User-Agentのnodeって?
Node.jsのことのようです
何故とこうなるのか調べると
Next.jsでApp Routerを使用するとサーバーサイドでレンダリングが行われ、Node.jsがリクエストを送信される仕様になっているようです
解決方法
credentials: 'include',
この設定だけでは、サーバーサイド(Node)側ではブラウザのCookieを参照できないため、Cookieを含めてのリクエストが正常に行われていなかったということだと考えられます(間違っていたらすみません)
そのためCookieのヘッダーを明示的に指定してあげる必要があるようです。
NextでブラウザのCookieを取得する関数は下記に載っていたので
このようにCookie関数で取得して、設定するようにしました。
export async function getMemos() {
const cookieHeader = cookies().toString();
const response = await fetch(`${process.env.NEXT_PUBLIC_API}/memos`, {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Cookie: cookieHeader,
},
});
これで再度実行すると
Started GET "/memos" for XXX.XXX.XX.X at 2024-11-03 16:42:28 +0900
Processing by MemosController#index as */*
Parameters: {"memo"=>{}}
=== Request Headers ===
User-Agent: node
HTTP_HOST: localhost:8080
HTTP_CONNECTION: keep-alive
CONTENT_TYPE: application/json
HTTP_COOKIE: _progaku_archive_session=eZLeQLjfX2urWEA2mdVw3%2BhUAPykM%2B5NpC6%2B2efjdpisVzXAgwLJfPzgbPE0AXRntsZ%2B53ZM7uQIrKLVljKPZbeQ7YU%2FapsCFpKU8E1QJ5K9cN0w0pG0MyE3HRRqSo%2BZ4JYzdOX7L%2F1ToOQrsphUDsx0aMx62PGiUIX7D%2FaA9wiXXfbNxMf8sc90jY8%2F%2BvIoodOUXFWXLp55dxcc3a2f4A%3D%3D--c2hJS4hYIkKBFprJ--YpkXKIGNqrAC365ERRV5bQ%3D%3D
HTTP_ACCEPT: */*
HTTP_ACCEPT_LANGUAGE: *
HTTP_SEC_FETCH_MODE: cors
HTTP_USER_AGENT: node
HTTP_ACCEPT_ENCODING: br, gzip, deflate
HTTP_VERSION: HTTP/1.1
========================
User Load (1.0ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 3 LIMIT 1
↳ app/controllers/application_controller.rb:33:in `current_user'
Memo Count (2.4ms) SELECT COUNT(*) FROM `memos`
↳ app/models/memo.rb:39:in `call'
Rendering memos/index.json.jbuilder
Memo Load (0.6ms) SELECT `memos`.* FROM `memos` ORDER BY `memos`.`id` DESC LIMIT 10
↳ app/views/memos/index.json.jbuilder:3
MemoTag Load (1.1ms) SELECT `memo_tags`.* FROM `memo_tags` WHERE `memo_tags`.`memo_id` IN (5840, 5839, 5838, 5837, 5836, 5835, 5834, 5833, 5832, 5831)
↳ app/views/memos/index.json.jbuilder:3
Tag Load (0.4ms) SELECT `tags`.* FROM `tags` WHERE `tags`.`id` = 233
↳ app/views/memos/index.json.jbuilder:3
Rendered memos/index.json.jbuilder (Duration: 6.5ms | GC: 1.2ms)
Completed 200 OK in 13ms (Views: 4.7ms | ActiveRecord: 5.5ms (5 queries, 0 cached) | GC: 1.2ms)
無事にCookieによるログイン認証を行い、メモを取得することができました!
まとめ
今回の問題では、Next.jsのApp Routerを使用する際、サーバーサイドでリクエストを送信するために、ブラウザのCookieが正しく含まれずに認証エラーが発生することがわかりました!
そして、この時のHeaderを見た感じ、CORS関連のHEADERが付与されてないので、POSTやDELETEした時CORSエラーが発生しそうですね・・・
その辺の設定もまたする必要がありそうです!
フレームワークの内部仕様など理解した上で使うことの大切さを身を持って感じました。