RailsAPIを使ったSPA構成でのログイン機能を実装する際のブラウザセッション保持

# はじめに
この記事では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
```
もし上記でうまく動作しない場合は下記を追加するとうまくいくかもしれません。
``` app/config/initializers/session_store.rb
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でのみ送信されるようにしています。ここはデプロイ時の設定により変更されるかと思います。

## さいごに
セッション管理の場合、実際にバックエンドが正しく動いているのか確認するのが地味に大変でした。ありがちな目的のデータを取得できるか？ということがわかれば良いわけではなく、ログインが成功した後にログインが必須な他の機能が使えるか？というのが主な確認ポイントだからです。
実際に稼働したコードも下記に載せておくので、参考になれば幸いです。
```sessions_helper.rb
# 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
```
```models/user.rb
# 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
```
```session_controller.rb
# 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
```