はじめに
この記事ではRailsAPIを使ったSPA構成(今回はNext.js)でのログイン機能を実装する際のブラウザセッション保持について話します。
Railsチュートリアルで作成したログイン機能をRailsAPIを使ったSPA構成へ変更する際に役立つかと思います。
Railsチュートリアルで作成したsessions.rbやヘルパーを持ち越してRailsAPIを使用すると、僕の場合は普通の開発環境でのPC環境と本番環境でのスマホ環境では問題なくログインができるのに、シークレットモードのPC開発環境ではログインができず、本番環境のPCからもログイン機能が使えないという不思議な事態に陥っていました。
しかも、関係あるコードにログ出力コードを付与してRailsログを見ると、ログインが成功しているのにその後のログイン必須のデータ取得ができていないという事態に陥りました。つまり、ログインが成功しているというログが出るのに、すぐその後にログインしていないのでデータが取得できないというログが現れるという不思議な状態です。
これはログインが成功しているが、セッションが保存されないために起きていました。
Deviseを使ったログイン認証に変えるべきなのか、フロント側の問題なのか等々の道のりを超えて、地味に時間がかかってしまいました。
最後にコードの全文を載せておくので参考にしていただけたらと思います。
Railsチュートリアルでのlog_inメソッド
RailsのMVCを使った方法の場合、基本的には
def log_in(user)
session[:user_id] = user.id
session[:session_token] = user.session_token
end
といった形でログインメソッドを作成すれば、ログイン情報のセッションがセッションストアにて暗号化された状態で保存されます。
そのためRailsチュートリアルなんかではこの方法でログインがうまくいきますが、RailsAPIを使ったSPA構成だと、フロントのブラウザ側にセッションがうまく保存されません。
RailsAPIでのlog_inメソッド
僕の場合は下記のように記述することで、うまくブラウザ側でのセッションが保持されました。
ログインが成功したときにクッキーが保存されるようにした形です。
def log_in(user)
Rails.logger.info "ログイン処理開始 - ユーザーID: #{user.id}"
# セッション情報の設定
session[:user_id] = user.id
session[:session_token] = user.session_token
# セッション情報のログ出力
Rails.logger.info "ログイン成功 - ユーザーID: #{user.id}, セッショントークン: #{user.session_token}"
# クッキーにセッション情報を保存
cookies.encrypted[:user_id] = {
value: user.id,
expires: 2.weeks.from_now,
httponly: true,
secure: Rails.env.production?,
same_site: :lax
}
cookies.encrypted[:session_token] = {
value: user.session_token,
expires: 2.weeks.from_now,
httponly: true,
secure: Rails.env.production?,
same_site: :lax
}
end
もし上記でうまく動作しない場合は下記を追加するとうまくいくかもしれません。
Rails.application.config.session_store :cookie_store, key: '_app_session', secure: Rails.env.production?, httponly: true, same_site: :lax
このコードは、セッションデータをクッキーに保存するように設定し、そのクッキーの挙動やセキュリティ設定を指定しています。
key: '_app_session'は任意の名前で大丈夫です。cookie_storeでセッションデータをクッキーストアに保存するようにしてhttponly: trueでXSS攻撃対策をしています。same_site: :laxでCSRF攻撃対策もしています。secure: Rails.env.production?では本番環境であればクッキーがHTTPSでのみ送信されるようにしています。ここはデプロイ時の設定により変更されるかと思います。
さいごに
セッション管理の場合、実際にバックエンドが正しく動いているのか確認するのが地味に大変でした。ありがちな目的のデータを取得できるか?ということがわかれば良いわけではなく、ログインが成功した後にログインが必須な他の機能が使えるか?というのが主な確認ポイントだからです。
実際に稼働したコードも下記に載せておくので、参考になれば幸いです。
# frozen_string_literal: true
module SessionsHelper
def log_in(user)
create_session(user)
store_cookies(user)
end
# ユーザーのセッションを永続的にする
def remember(user)
user.remember
cookies.permanent.signed[:user_id] =
{ value: user.id, httponly: true, secure: Rails.env.production?, same_site: :lax }
cookies.permanent[:remember_token] =
{ value: user.remember_token, httponly: true, secure: Rails.env.production?, same_site: :lax }
end
# 現在ログイン中のユーザーを返す(いる場合)
def current_user
if session_user_id
find_user_by_session
elsif cookie_user_id
find_user_by_cookie
end
end
# 渡されたユーザーがカレントユーザーであればtrueを返す
def current_user?(user)
Rails.logger.info "カレントユーザー確認 - ユーザーID: #{user&.id}"
result = user && user == current_user
Rails.logger.info "カレントユーザー確認結果: #{result}"
result
end
# ユーザーがログインしていればtrue、その他ならfalseを返す
def logged_in?
Rails.logger.info 'ログイン状態確認'
result = !current_user.nil?
Rails.logger.info "ログイン状態確認結果: #{result}"
result
end
# 永続的セッションを破棄する
def forget(user)
user.forget
cookies.delete(:user_id)
cookies.delete(:remember_token)
end
def log_out
Rails.logger.info "ログアウト処理開始 - カレントユーザーID: #{@current_user&.id}"
forget(current_user)
reset_session
@current_user = nil
Rails.logger.info 'ログアウト完了'
end
# アクセスしようとしたURLを保存する
def store_location
return unless request.get?
Rails.logger.info "アクセスしようとしたURLを保存: #{request.original_url}"
session[:forwarding_url] = request.original_url
end
end
private
def session_user_id
session[:user_id]
end
def cookie_user_id
cookies.encrypted[:user_id]
end
def find_user_by_session
user = User.find_by(id: session_user_id)
@current_user ||= user if user && valid_session_token?(user)
end
def valid_session_token?(user)
session[:session_token] == user.session_token
end
def find_user_by_cookie
user = User.find_by(id: cookie_user_id)
return unless user&.authenticated?(:remember, cookies[:remember_token])
log_in(user)
@current_user = user
end
def create_session(user)
session[:user_id] = user.id
session[:session_token] = user.session_token
end
def store_cookies(user)
store_encrypted_cookie(:user_id, user.id)
store_encrypted_cookie(:session_token, user.session_token)
end
def store_encrypted_cookie(name, value)
cookies.encrypted[name] = {
value:,
expires: 2.weeks.from_now,
httponly: true,
secure: Rails.env.production?,
same_site: :lax
}
end
# frozen_string_literal: true
class User < ApplicationRecord
has_many :records, dependent: :destroy
has_many :projects, dependent: :destroy
has_many :posts, dependent: :destroy
has_many :active_relationships, class_name: 'Relationship',
foreign_key: 'follower_id',
dependent: :destroy,
inverse_of: :follower
has_many :passive_relationships, class_name: 'Relationship',
foreign_key: 'followed_id',
dependent: :destroy,
inverse_of: :followed
has_many :following, through: :active_relationships, source: :followed
has_many :followers, through: :passive_relationships, source: :follower
attr_accessor :remember_token, :reset_token
# 大文字と小文字で複数メールアドレスを登録できないようにする。大抵のデータベースでは必要ない。
before_save :downcase_email
validates :name, presence: true, length: { maximum: 25 }
VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
validates :email, presence: true, length: { maximum: 255 },
format: { with: VALID_EMAIL_REGEX },
uniqueness: { case_sensitive: false }
has_secure_password # セキュアなパスワード機能を導入。ハッシュ値のログも見せない。
validates :password, presence: true, length: { minimum: 6 }, allow_nil: true
mount_uploader :avatar, AvatarUploader
# 永続的セッションのためにユーザーをデータベースに記憶する
def remember
self.remember_token = User.new_token
update_attribute(:remember_digest, User.digest(remember_token))
end
# ユーザーのログイン情報を破棄する
def forget
update_attribute(:remember_digest, nil)
end
# 渡された文字列のハッシュ値を返す
def self.digest(string)
cost = if ActiveModel::SecurePassword.min_cost
BCrypt::Engine::MIN_COST
else
BCrypt::Engine.cost
end
BCrypt::Password.create(string, cost:)
end
# ランダムなトークンを返す
def self.new_token
SecureRandom.urlsafe_base64
end
# セッションハイジャック防止のためにセッショントークンを返す(必要ない、もしくは無意味かもしれない)
def session_token
remember_digest || remember
end
# 渡されたトークンがダイジェストと一致したらtrueを返す
def authenticated?(attribute, token)
digest = send(:"#{attribute}_digest")
return false if digest.nil?
BCrypt::Password.new(digest).is_password?(token)
end
# パスワード再設定の属性を設定する
def create_reset_digest
self.reset_token = User.new_token
update_attribute(:reset_digest, User.digest(reset_token))
update_attribute(:reset_sent_at, Time.zone.now)
end
# パスワード再設定のメールを送信する
def send_password_reset_email
UserMailer.password_reset(self).deliver_now
end
# パスワード再設定の期限が切れている場合はtrueを返す
def password_reset_expired?
reset_sent_at < 2.hours.ago
end
# ユーザーのステータスフィードを返す
def feed
following_ids = "SELECT followed_id FROM relationships
WHERE follower_id = :user_id"
Post.where("user_id IN (#{following_ids})
OR user_id = :user_id", user_id: id)
.includes(:user, image_attachment: :blob)
end
# ユーザーをフォローする
def follow(other_user)
Rails.logger.info "ユーザー(ID: #{id})がユーザー(ID: #{other_user.id})をフォローしようとしています。"
active_relationships.create(followed_id: other_user.id)
Rails.logger.info "ユーザー(ID: #{id})がユーザー(ID: #{other_user.id})をフォローしました。"
end
# ユーザーをフォロー解除する
def unfollow(other_user)
Rails.logger.info "ユーザー(ID: #{id})がユーザー(ID: #{other_user.id})をフォロー解除しようとしています。"
# following.delete(other_user)
active_relationships.find_by(followed_id: other_user.id).destroy
Rails.logger.info "ユーザー(ID: #{id})がユーザー(ID: #{other_user.id})をフォロー解除しました。"
end
# 現在のユーザーが他のユーザーをフォローしていればtrueを返す
def following?(other_user)
following.include?(other_user)
end
private
# メールアドレスをすべて小文字にする
def downcase_email
self.email = email.downcase
end
end
# frozen_string_literal: true
module Api
module V1
class SessionsController < ApplicationController
include ActionController::Cookies
include SessionsHelper
def create
user = User.find_by(email: params[:session][:email].downcase)
if user&.authenticate(params[:session][:password])
reset_session
remember(user)
log_in(user)
render json: { message: 'ログインに成功しました。', user: user.as_json(only: %i[id email]) }, status: :ok
else
render json: { error: 'ログインに失敗しました。' }, status: :unprocessable_entity
end
end
def destroy
if logged_in?
log_out
render json: { message: 'ログアウトに成功しました。' }, status: :ok
else
render json: { error: 'ユーザーはログインしていません。' }, status: :unprocessable_entity
end
end
end
end
end