OAuth2 + JWT認証
Aliceは商品を購入するためにアカウントを作成する必要があり, Bobは管理画面にログインする必要があります。しかし, MegaShopにはまだ認証システムがなく, 誰でも管理画面にアクセスできます。Charlieはメール登録とGoogle/GitHubソーシャルログインの両方をサポートしつつ, APIのセキュリティも確保する必要があります。
1. 学ぶ内容
- 認証アーキテクチャ設計:Session, JWT, Cookieアプローチの比較
- JWT実装:発行/検証 + httpOnly Cookieの安全な保存
- OAuth 2.0統合:Google/GitHubソーシャルログインフロー
- リフレッシュトークンメカニズム:Access Token + Refresh Tokenのデュアルトークンローテーション
- MegaShop認証の実践:Alice登録・ログイン + Bob (管理者)OAuth2
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) 認証フローシーケンス図
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に保存)を維持し, ミドルウェアでブラックリストをチェックするオーバーヘッドは最小です。
📖 まとめ
- MegaShopはJWT + httpOnly Cookieを使用:ステートレス, XSS耐性, SSRフレンドリー
- デュアルトークンローテーション:Access Token (15分)+ Refresh Token (7日), シームレスな更新
- OAuth 2.0フロー:Googleにリダイレクト → ユーザー認可 → コールバックでトークン交換 → ユーザー作成 → JWT発行
- サーバーミドルウェア認証:CookieからJWTを読み取り → 検証 → event.context.userに注入
- すべてのキーはプライベート
runtimeConfigフィールドに保存し, クライアントに絶対に露出させない
📝 練習問題
- 基本問題 (難易度:⭐):メール登録・ログインAPIを実装し,
httpOnlyCookieでJWTを保存してください。 - 応用問題 (難易度:⭐⭐):リフレッシュトークンメカニズムを実装し, アクセストークン期限切れ時に自動更新され, ユーザーが気付かないようにしてください。
- チャレンジ (難易度:⭐⭐⭐):Google OAuth2ソーシャルログインを統合し, 完全な登録/ログイン/リフレッシュ/ログアウトのワークフローを実装してください。
---|



