404 Not Found

404 Not Found


nginx

OAuth2 + JWT認証

Aliceは商品を購入するためにアカウントを作成する必要があり, Bobは管理画面にログインする必要があります。しかし, MegaShopにはまだ認証システムがなく, 誰でも管理画面にアクセスできます。Charlieはメール登録とGoogle/GitHubソーシャルログインの両方をサポートしつつ, APIのセキュリティも確保する必要があります。

1. 学ぶ内容


2. アーキテクトのある本当の話

(1) 課題:認証不在による重大なセキュリティ脆弱性

誰もがAliceのカートデータを読めます。Bobの管理画面には認証がありません。Charlieは攻撃者がリクエストを偽造してアイテムを削除できることを発見しました。MegaShopには認証システムが不可欠です。

(2) JWT + OAuth2の解決策

JWTステートレス認証 + OAuth 2.0ソーシャルログイン:セキュリティとユーザー体験のバランス:

TYPESCRIPT
// サーバー:ログイン後にJWTを発行
const token = jwt.sign({ userId: user.id, role: user.role }, secret, { expiresIn: '15m' })
setCookie(event, 'access-token', token, { httpOnly: true })

(3) メリット:安全性 + 利便性

AliceはGoogleサインインを使用し, Bobは管理者アカウントで管理画面にアクセスし, APIリクエストにはJWTが含まれ自動認証されます。


3. 認証方式の比較

(1) 3つの認証方式

項目 Session + Cookie JWT + Cookie JWT + localStorage
状態 ステートフル (サーバー側保存) ステートレス ステートレス
スケーラビリティ 🔴 共有セッションが必要 🟢 本質的に分散 🟢 分散
SSR互換性 ✅ Cookie自動送信 ✅ Cookie自動送信 ❌ 手動処理が必要
CSRFリスク 🔴 保護が必要 🟢 httpOnlyで保護 🟢 Cookieなし
XSSリスク 🟢 httpOnly 🟢 httpOnly 🔴 JSで読み取り可能
ログアウト ✅ セッション即時削除 ⚠️ ブラックリストが必要 ⚠️ ブラックリストが必要
💡 ヒント: MegaShopはJWT + httpOnly Cookieアプローチを使用します。ステートレスアーキテクチャは分散システムに適し, httpOnlyはXSSを防ぎ, CookieはSSRリクエストに自動的に含まれます。

(2) 認証フローシーケンス図

100%
sequenceDiagram
    participant A as Alice/ブラウザ
    participant N as Nuxt Server
    participant G as Google OAuth2
    participant DB as データベース

    A->>N: "Sign in with Google"をクリック
    N->>G: Google同意画面にリダイレクト
    G-->>A: ユーザーが許可
    A->>N: 認可コード付きコールバック
    N->>G: コードとユーザー情報を交換
    G-->>N: ユーザープロフィール (email, name)
    N->>DB: ユーザー作成/検索
    DB-->>N: ユーザーレコード
    N->>N: JWT署名 (access + refresh)
    N-->>A: httpOnly Cookieを設定
    A->>A: ダッシュボードにリダイレクト

4. JWTの発行と検証

(1) ▶ サンプル:JWTユーティリティ関数

TYPESCRIPT
// server/utils/jwt.ts
import jwt from 'jsonwebtoken'

const config = useRuntimeConfig()

interface JwtPayload {
  userId: number
  email: string
  role: 'customer' | 'admin'
}

export function signAccessToken(payload: JwtPayload): string {
  return jwt.sign(payload, config.jwtAccessSecret, { expiresIn: '15m' })
}

export function signRefreshToken(payload: JwtPayload): string {
  return jwt.sign(payload, config.jwtRefreshSecret, { expiresIn: '7d' })
}

export function verifyAccessToken(token: string): JwtPayload {
  return jwt.verify(token, config.jwtAccessSecret) as JwtPayload
}

export function verifyRefreshToken(token: string): JwtPayload {
  return jwt.verify(token, config.jwtRefreshSecret) as JwtPayload
}

出力:

TEXT
// 実行成功

(2) ▶ サンプル:メール登録API

TYPESCRIPT
// server/api/auth/register.post.ts
export default defineEventHandler(async (event) => {
  const { email, password, name } = await readBody(event)

  if (!email || !password) {
    throw createError({ statusCode: 400, message: 'Email and password required' })
  }

  // ユーザー存在チェック
  const existing = mockUsers.find(u => u.email === email)
  if (existing) {
    throw createError({ statusCode: 409, message: 'Email already registered' })
  }

  // ユーザー作成
  const user = {
    id: mockUsers.length + 1,
    email,
    name: name || 'Customer',
    password: await hashPassword(password),
    role: 'customer' as const
  }
  mockUsers.push(user)

  // トークン発行
  const payload = { userId: user.id, email: user.email, role: user.role }
  const accessToken = signAccessToken(payload)
  const refreshToken = signRefreshToken(payload)

  // httpOnly Cookieを設定
  setCookie(event, 'access-token', accessToken, {
    httpOnly: true, secure: true, sameSite: 'lax', maxAge: 900 // 15分
  })
  setCookie(event, 'refresh-token', refreshToken, {
    httpOnly: true, secure: true, sameSite: 'lax', maxAge: 604800 // 7日
  })

  return { user: { id: user.id, email: user.email, name: user.name, role: user.role } }
})

出力:

TEXT
// 実行成功

(3) ▶ サンプル:メールログインAPI

TYPESCRIPT
// server/api/auth/login.post.ts
export default defineEventHandler(async (event) => {
  const { email, password } = await readBody(event)

  const user = mockUsers.find(u => u.email === email)
  if (!user || !await verifyPassword(password, user.password)) {
    throw createError({ statusCode: 401, message: 'Invalid credentials' })
  }

  const payload = { userId: user.id, email: user.email, role: user.role }
  const accessToken = signAccessToken(payload)
  const refreshToken = signRefreshToken(payload)

  setCookie(event, 'access-token', accessToken, { httpOnly: true, secure: true, maxAge: 900 })
  setCookie(event, 'refresh-token', refreshToken, { httpOnly: true, secure: true, maxAge: 604800 })

  return { user: { id: user.id, email: user.email, name: user.name, role: user.role } }
})

出力:

TEXT
// 実行成功

5. OAuth 2.0ソーシャルログイン

(1) ▶ サンプル:Google OAuth 2.0ログイン

TYPESCRIPT
// server/api/auth/google.get.ts
export default defineEventHandler((event) => {
  const config = useRuntimeConfig()
  const redirectUri = `${config.public.apiBase}/auth/google/callback`
  const googleAuthUrl = `https://accounts.google.com/o/oauth2/v2/auth?` +
    `client_id=${config.googleClientId}&` +
    `redirect_uri=${redirectUri}&` +
    `response_type=code&` +
    `scope=openid email profile&` +
    `state=${generateState()}`

  return sendRedirect(event, googleAuthUrl)
})

出力:

TEXT
// 実行成功

(2) ▶ サンプル:Google OAuth 2.0コールバック処理

TYPESCRIPT
// server/api/auth/google/callback.get.ts
export default defineEventHandler(async (event) => {
  const config = useRuntimeConfig()
  const code = getQuery(event).code as string

  // 認可コードをトークンと交換
  const tokenResponse = await $fetch('https://oauth2.googleapis.com/token', {
    method: 'POST',
    body: {
      code,
      client_id: config.googleClientId,
      client_secret: config.googleClientSecret,
      redirect_uri: `${config.public.apiBase}/auth/google/callback`,
      grant_type: 'authorization_code'
    }
  })

  // Googleからユーザー情報を取得
  const userInfo = await $fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
    headers: { Authorization: `Bearer ${tokenResponse.access_token}` }
  })

  // ユーザー検索または作成
  let user = mockUsers.find(u => u.email === userInfo.email)
  if (!user) {
    user = {
      id: mockUsers.length + 1,
      email: userInfo.email,
      name: userInfo.name,
      avatar: userInfo.picture,
      provider: 'google',
      role: 'customer'
    }
    mockUsers.push(user)
  }

  // JWTトークン発行
  const payload = { userId: user.id, email: user.email, role: user.role }
  setCookie(event, 'access-token', signAccessToken(payload), { httpOnly: true, secure: true, maxAge: 900 })
  setCookie(event, 'refresh-token', signRefreshToken(payload), { httpOnly: true, secure: true, maxAge: 604800 })

  return sendRedirect(event, '/')
})

出力:

TEXT
// 実行成功

6. デュアルトークンローテーションメカニズム

(1) トークンのライフサイクル

トークン 有効期間 保存先 用途
Access Token 15分 httpOnly Cookie API認証
Refresh Token 7日 httpOnly Cookie Access Tokenの更新

(1) ▶ サンプル:リフレッシュトークンAPI

TYPESCRIPT
// server/api/auth/refresh.post.ts
export default defineEventHandler((event) => {
  const refreshToken = getCookie(event, 'refresh-token')

  if (!refreshToken) {
    throw createError({ statusCode: 401, message: 'No refresh token' })
  }

  try {
    const payload = verifyRefreshToken(refreshToken)
    const newAccessToken = signAccessToken({
      userId: payload.userId,
      email: payload.email,
      role: payload.role
    })

    setCookie(event, 'access-token', newAccessToken, {
      httpOnly: true, secure: true, sameSite: 'lax', maxAge: 900
    })

    return { message: 'Token refreshed' }
  } catch {
    // リフレッシュトークン期限切れ - ログインを強制
    setCookie(event, 'access-token', '', { maxAge: 0 })
    setCookie(event, 'refresh-token', '', { maxAge: 0 })
    throw createError({ statusCode: 401, message: 'Refresh token expired' })
  }
})

出力:

TEXT
// 実行成功

(2) ▶ サンプル:API認証ミドルウェア

TYPESCRIPT
// server/middleware/auth.ts
export default defineEventHandler((event) => {
  const url = getRequestURL(event)
  if (!url.pathname.startsWith('/api/admin') && !url.pathname.startsWith('/api/cart')) return

  const token = getCookie(event, 'access-token')
  if (!token) {
    throw createError({ statusCode: 401, message: 'Authentication required' })
  }

  try {
    const payload = verifyAccessToken(token)
    event.context.user = payload

    // 管理者専用ルート
    if (url.pathname.startsWith('/api/admin') && payload.role !== 'admin') {
      throw createError({ statusCode: 403, message: 'Admin access required' })
    }
  } catch {
    throw createError({ statusCode: 401, message: 'Invalid or expired token' })
  }
})

出力:

TEXT
// 実行成功

7. 総合例:MegaShop認証フロー

TYPESCRIPT
// nuxt.config.ts - runtimeConfigにJWTシークレットを設定
export default defineNuxtConfig({
  runtimeConfig: {
    jwtAccessSecret: process.env.JWT_ACCESS_SECRET || 'dev-access-secret',
    jwtRefreshSecret: process.env.JWT_REFRESH_SECRET || 'dev-refresh-secret',
    googleClientId: process.env.GOOGLE_CLIENT_ID,
    googleClientSecret: process.env.GOOGLE_CLIENT_SECRET,
    public: {
      apiBase: process.env.API_BASE || 'http://localhost:3000/api'
    }
  }
})
VUE
<!-- pages/login.vue -->
<template>
  <div class="login-page">
    <h1>Sign In to MegaShop</h1>

    <!-- メールログインフォーム -->
    <form @submit.prevent="loginWithEmail">
      <input v-model="email" type="email" placeholder="Email" required />
      <input v-model="password" type="password" placeholder="Password" required />
      <button type="submit">Sign In</button>
    </form>

    <!-- ソーシャルログイン -->
    <div class="social-login">
      <p>Or sign in with:</p>
      <a href="/api/auth/google" class="btn-google">Sign in with Google</a>
      <a href="/api/auth/github" class="btn-github">Sign in with GitHub</a>
    </div>
  </div>
</template>

<script setup lang="ts">
const email = ref('')
const password = ref('')

async function loginWithEmail() {
  await $fetch('/api/auth/login', {
    method: 'POST',
    body: { email: email.value, password: password.value }
  })
  navigateTo('/')
}
</script>

❓ よくある質問

Q JWTはCookieとlocalStorageのどちらに保存すべきですか?
A httpOnly Cookieをお勧めします。XSS攻撃によるトークン窃取を防ぎ, サーバー側レンダリング (SSR)で自動的に含まれます。localStorageはXSS攻撃に対して脆弱です。
Q なぜAccess Tokenの有効期間は15分だけですか?
A 短い有効期間はトークン漏洩時のリスクを軽減します。Refresh Tokenと組み合わせることでシームレスな更新が可能になり, ユーザーが頻繁にログインする必要がありません。
Q リフレッシュトークンが侵害された場合はどうすればよいですか?
A リフレッシュトークンローテーションを実装してください。リフレッシュトークンを使用して新しいアクセストークンを取得するたびに, 同時に新しいリフレッシュトークンも発行し, 古いものを即座に期限切れにします。
Q OAuth 2.0のstateパラメータの目的は何ですか?
A CSRF攻撃を防ぐためです。stateはランダムな文字列で, コールバック時に検証することで, リクエストがあなたのアプリケーションからのものであり, 攻撃者からのものでないことを確認します。
Q SSR時にCookieを読み取って認証するにはどうすればよいですか?
A useCookie('access-token')はSSR時はリクエストのCookieヘッダーから, クライアント側はdocument.cookieから読み取ります。両方で値は一貫しています。
Q JWTを自動的に失効できない場合はどうすればよいですか?
A 短い有効期間 (15分)が影響を最小限にします。即時失効が必要な場合は, トークンブラックリスト (Redisに保存)を維持し, ミドルウェアでブラックリストをチェックするオーバーヘッドは最小です。

📖 まとめ


📝 練習問題

  1. 基本問題 (難易度:⭐):メール登録・ログインAPIを実装し, httpOnly CookieでJWTを保存してください。
  2. 応用問題 (難易度:⭐⭐):リフレッシュトークンメカニズムを実装し, アクセストークン期限切れ時に自動更新され, ユーザーが気付かないようにしてください。
  3. チャレンジ (難易度:⭐⭐⭐):Google OAuth2ソーシャルログインを統合し, 完全な登録/ログイン/リフレッシュ/ログアウトのワークフローを実装してください。

---|

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%