Rails × GraphQL でAPI開発｜ログイン認証を行う

## はじめに

この記事では、GraphQL Ruby を使用した Rails アプリケーションでの認証フローの実装方法について解説します 🙋‍♂️
セッションモデルを活用した認証システムで、ユーザーのログイン・ログアウト機能を実装する方法を紹介します。

🔍 セットアップ方法は過去記事をご参考ください

https://qiita.com/PenPe/items/482ee4dc02f44fdc3983



## 認証フロー

GraphQL を使用した認証フローは以下のようになります。

```mermaid
sequenceDiagram
    Note over Client,Server: ログイン処理
    Client->>Server: loginMutation(email, password)
    activate Server
    Server->>Server: ユーザー認証
    alt 認証成功
        Server->>Server: セッションキー生成
        Server->>Server: Sessionモデルを作成
        Server->>Client: Cookie にセッションキーを保存
    else 認証失敗
        Server->>Client: エラーメッセージ返却
    end
    deactivate Server
    
    Note over Client,Server: ログアウト処理
    Client->>Server: logoutMutation()
    activate Server
    Server->>Server: Cookie からセッションキー削除
    deactivate Server
```

## 実装手順

### 1. CORS, Cookie 設定

事前対応として、フロントエンドからのリクエストを受け付けるようにしておきます。

```rubyconfig/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins '127.0.0.1:3000'

    resource '*',
      headers: :any,
      methods: [:get, :post, :put, :patch, :delete, :options, :head],
      headers: :any,
      credentials: true
  end
end
```

API Mode では `Cookie` が利用できないので、`Cookie` が利用できるように設定します。

```ruby:config/application.rb
module App
  class Application < Rails::Application
    # ...
    config.middleware.use ActionDispatch::Cookies # 追加
  end
end
```

```ruby:app/controllers/application_controller.rb
class ApplicationController < ActionController::API
  include ActionController::Cookies # 追加
end
```

### 2. BCryptのインストール

パスワードのハッシュ化や認証メソッド提供のため、事前に「BCrypt」をインストールしておいてください。

```ruby
gem 'bcrypt', '~> 3.1.7'
```

```
bundle install
```

### 3. モデルの準備

ユーザーモデルを作成します。

```ruby:app/models/user.rb
# == Schema Information
#
# Table name: users
#
#  id              :bigint           not null, primary key
#  email           :string(255)      not null
#  name            :string(255)      not null
#  password_digest :string(255)      not null
#  created_at      :datetime         not null
#  updated_at      :datetime         not null
#
class User < ApplicationRecord
  has_secure_password
  
  has_many :sessions, dependent: :destroy

  def create_session!
    sessions.create!(key: Session.generate_key)
  end

  def current_session
    sessions.last
  end
end
```

次にログイン状態を管理するセッションモデルを作成します。

```ruby:app/models/session.rb
# == Schema Information
#
# Table name: sessions
#
#  id         :bigint           not null, primary key
#  key        :string(255)      not null
#  created_at :datetime         not null
#  updated_at :datetime         not null
#  user_id    :bigint           not null
#
class Session < ApplicationRecord
  belongs_to :user

  validates :key, presence: true, uniqueness: true

  def self.generate_key
    SecureRandom.hex(20)
  end
end
```

### 4. ログインミューテーションの実装

ログイン処理を作成していきます。
`user.authenticate` でのユーザー認証成功時にセッションを作成し、セッションキーを `Cookie`に格納します。

```ruby:app/graphql/mutations/login_mutation.rb
module Mutations
  class LoginMutation < GraphQL::Schema::Mutation
    argument :email, String, required: true
    argument :password, String, required: true

    field :token, String, null: true
    field :user, Types::UserType, null: true

    def resolve(email:, password:)
      user = User.find_by(email:)

      if user && user.authenticate(password)
        user.create_session!
        token = user.current_session.key

        context[:cookies].signed[:sid] = {
          value: token,
          expires: 1.year.from_now,
          httponly: true,
          secure: true,
          same_site: :lax
        }

        {
          token: token,
          user: user,
        }
      else
        raise GraphQL::ExecutionError, "Invalid credentials"
      end
    end
  end
end
```

### 5. ログアウトミューテーションの実装

ログアウト処理を作成していきます。
こちらは単純に `cookie` を削除します。

```ruby:app/graphql/mutations/logout_mutation.rb
module Mutations
  class LogoutMutation < GraphQL::Schema::Mutation
    field :success, Boolean, null: false

    def resolve
      if context[:cookies].signed[:sid]
        context[:cookies].delete(:sid)

        { success: true }
      else
        { success: false }
      end
    end
  end
end
```

### 6. ミューテーションタイプに登録

作成したミューテーションを登録します。

```ruby:app/graphql/types/mutation_type.rb
module Types
  class MutationType < Types::BaseObject
    field :login, mutation: Mutations::LoginMutation
    field :logout, mutation: Mutations::LogoutMutation
  end
end
```

### 7. GraphQL コントローラーの設定

コンテキストで 「Cookie」 と 「ログイン中ユーザー情報」 が参照できるようにします。

```ruby:app/controllers/graphql_controller.rb
class GraphqlController < ApplicationController
  def execute
    # ...
    context = {
      cookies: cookies,
      current_user: current_user
    }
    # ...
  end

  private

  def current_user
    token = cookies.signed[:sid]
    return nil unless token.present?

    session = Session.find_by(key: token)
    session&.user
  end
end
```

### 8. ユーザータイプの定義

```ruby:app/graphql/types/user_type.rb
module Types
  class UserType < Types::BaseObject
    field :id, ID, null: false
    field :name, String, null: false
    field :email, String, null: false
    field :created_at, GraphQL::Types::ISO8601DateTime, null: false
    field :updated_at, GraphQL::Types::ISO8601DateTime, null: false

    field :posts, [Types::PostType], null: true

    def posts
      object.posts
    end
  end
end
```

### 9. 現在のユーザーを取得するクエリ

カレントユーザーを取得するクエリも合わせて作成しておきましょう。

```ruby:app/graphql/types/query_type.rb
module Types
  class QueryType < Types::BaseObject
    # ...
    
    field :current_user, Types::UserType, null: true

    def current_user
      context[:current_user]
    end
    
    # ...
  end
end
```

## 動作確認

以下のクエリでログイン、ログアウト、認証状態の確認ができます。

```graphql
# ログイン
mutation {
  login(email: "user@example.com", password: "password") {
    token
    user {
      id
      name
      email
    }
  }
}

# 現在のユーザー情報取得
query {
  currentUser {
    id
    name
    email
    posts {
      id
      title
    }
  }
}

# ログアウト
mutation {
  logout {
    success
  }
}
```

Cookieにも値が保存されていることを確認できました🙌

![スクリーンショット 2025-04-27 17.51.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2842674/9492343b-b85f-41cd-93df-f87c3a8a3661.png)

## まとめ

GraphQL Rubyを使用したRailsアプリケーションで、Sessionモデルを活用した認証システムを実装する方法を解説しました👌

認可処理についても後ほど別記事にて投稿予定です🙋‍♂️

---

## [補足] Next.js での認証方法

先ほど実装した認証機能を、Next.js アプリケーションで実装する方法を解説します。

### フロー

```mermaid
sequenceDiagram
    participant Client as Next.js クライアント
    participant Middleware as ミドルウェア
    participant Server as Rails GraphQL サーバー
    participant DB as データベース

    Note over Client,Server: 認証フロー
    Client->>Middleware: リクエスト送信
    Middleware->>Server: Cookieを含むリクエスト
    Server->>DB: ユーザー認証
    alt 認証成功
        DB-->>Server: ユーザー情報
        Server-->>Middleware: ユーザー情報
        Middleware-->>Client: アクセス許可
    else 認証失敗
        Middleware-->>Client: リダイレクト (ログインページ)
    end

    Note over Client,Server: ユーザー情報取得
    Client->>Server: CurrentUserクエリ
    Server->>DB: セッション確認
    alt セッション有効
        DB-->>Server: ユーザー情報
        Server-->>Client: ユーザー情報返却
    else セッション無効
        Server-->>Client: null
    end
```

### 1. GraphQL クエリの準備

まず、現在のユーザー情報を取得する `GraphQL` クエリを定義します。`Apollo Client`を使用して、サーバーから認証情報を取得します。
セットアップ方法については、以下の記事をご参考ください🙏

https://qiita.com/PenPe/items/a76ff014714f6f96ca27

```typescript:src/app/graphql/auth/queries.ts
import { gql } from '@apollo/client';

export const CURRENT_USER_QUERY = gql`
  query CurrentUser {
    currentUser {
      id
      email
      name
    }
  }
`;
```
### 2. ユーザー取得ロジックの実装

次にユーザー取得関数を作成します。

:::note warn
現在の実装では、`middleware` と `ServerComponent` の両方のコンテキストで動作するように設計しています。

最適化や改善の余地があれば、ご教示いただきたいです🙏
:::

```typescript:src/app/(auth)/fetcher.ts
import 'server-only';

import { NextRequest } from "next/server";
import { apolloClient } from "../graphql";
import { CURRENT_USER_QUERY } from "../graphql/auth/queries";
import { User } from "@/app/types";
import { cookies } from "next/headers";

export async function getCurrentUser(request?: NextRequest): Promise<User | null> {
  let cookieHeader: string | null = null;

  // NOTE: call from middleware
  if (request) {
    cookieHeader = request.headers.get('cookie');
  }
  // NOTE: call from Server Component
  else {
      const cookiesObj = await cookies();
      cookieHeader = cookiesObj.toString();
  }

  if (!cookieHeader) {
    return null;
  }

  const { data } = await apolloClient.query({
    query: CURRENT_USER_QUERY,
    context: {
      headers: {
        Cookie: cookieHeader,
      },
    },
  });
  return data.currentUser as User;
}

```

### 3. ミドルウェアの設定

特定のルートを認証済みユーザーのみにアクセス制限するミドルウェアを実装します。
以下の例では、未ログイン状態で `/account`　と `/dashboard` にアクセスすると、TOPページにリダイレクトされます。

```typescript:src/middleware.ts
import { NextResponse, type NextRequest } from 'next/server';
import { getCurrentUser } from './app/(auth)/fetcher';

const protectedRoutes = ['/account', '/dashboard'];

function isProtectedRoute(path: string): boolean {
  return protectedRoutes.some(route => {
    return path === route || path.startsWith(`${route}/`);
  });
}

export async function middleware(request: NextRequest) {
  const path = new URL(request.url).pathname;

  if (isProtectedRoute(path)) {
    const user = await getCurrentUser(request);

    if (!user) {
      return NextResponse.redirect(new URL('/', request.url));
    }
  }

  return NextResponse.next();
}

export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico).*)',
    '/((?!.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'
  ],
};
```

### 4. コンポーネントでの認証状態考慮

認証状態に基づいて異なる `UI` を表示します。

以下は、ユーザーの認証状態に応じて異なるボタンを表示するコンポーネントの一例です。

```tsx
import { getCurrentUser } from '@/app/(auth)/fetcher'
import Link from 'next/link'

export default async function PublicHeader() {
  const currentUser = await getCurrentUser();

  return (
    <>
      {currentUser ? (
        <Link href="/account">
          <button>マイページ</button>
        </Link>
      ) : (
        <Link href="/signin">
          <button>ログイン</button>
        </Link>
      )}
    </>
  )
}
```