Firebase Authentication 実装ガイド
🚀 セットアップ手順
1. Firebase プロジェクト作成
# Firebase CLI インストール
npm install -g firebase-tools
# プロジェクト初期化
firebase login
firebase init auth
firebase init firestore
2. 環境変数設定
# .env.local
FIREBASE_API_KEY="AIzaSyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
FIREBASE_AUTH_DOMAIN="nekomata-print.firebaseapp.com"
FIREBASE_PROJECT_ID="nekomata-print"
FIREBASE_STORAGE_BUCKET="nekomata-print.appspot.com"
FIREBASE_MESSAGING_SENDER_ID="123456789012"
FIREBASE_APP_ID="1:123456789012:web:xxxxxxxxxxxxxxxxxx"
# サーバーサイド用
FIREBASE_SERVICE_ACCOUNT_KEY="path/to/serviceAccountKey.json"
📱 フロントエンド実装
Firebase SDK 設定
// lib/firebase.ts
import { initializeApp } from 'firebase/app'
import { getAuth, connectAuthEmulator } from 'firebase/auth'
import { getFirestore, connectFirestoreEmulator } from 'firebase/firestore'
const firebaseConfig = {
apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
}
const app = initializeApp(firebaseConfig)
export const auth = getAuth(app)
export const db = getFirestore(app)
// 開発環境用エミュレータ接続
if (process.env.NODE_ENV === 'development') {
connectAuthEmulator(auth, 'http://localhost:9099')
connectFirestoreEmulator(db, 'localhost', 8080)
}
認証フック実装
// hooks/useAuth.ts
import { useEffect, useState } from 'react'
import { onAuthStateChanged, User } from 'firebase/auth'
import { auth } from '@/lib/firebase'
export interface AuthUser extends User {
profile?: UserProfile
brandAccess?: BrandAccess
}
export function useAuth() {
const [user, setUser] = useState<AuthUser | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, async (firebaseUser) => {
if (firebaseUser) {
try {
// ユーザープロフィール取得
const profile = await getUserProfile(firebaseUser.uid)
const brandAccess = await getBrandAccess(firebaseUser.uid)
setUser({
...firebaseUser,
profile,
brandAccess
})
} catch (error) {
console.error('ユーザー情報取得エラー:', error)
setUser(firebaseUser as AuthUser)
}
} else {
setUser(null)
}
setLoading(false)
})
return unsubscribe
}, [])
return { user, loading }
}
ブランド別認証コンポーネント
// components/auth/BrandAuthModal.tsx
import { useState } from 'react'
import { signInWithEmailAndPassword, createUserWithEmailAndPassword } from 'firebase/auth'
import { auth } from '@/lib/firebase'
interface BrandAuthModalProps {
brand: Brand
isOpen: boolean
onClose: () => void
}
export function BrandAuthModal({ brand, isOpen, onClose }: BrandAuthModalProps) {
const [mode, setMode] = useState<'signin' | 'signup'>('signin')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const handleAuth = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
try {
let userCredential
if (mode === 'signin') {
userCredential = await signInWithEmailAndPassword(auth, email, password)
} else {
userCredential = await createUserWithEmailAndPassword(auth, email, password)
// 新規ユーザーの場合、プロフィール作成
await createUserProfile(userCredential.user.uid, {
email,
preferredBrand: brand,
createdAt: new Date()
})
}
// ブランドアクセス設定
await setBrandAccess(userCredential.user.uid, brand)
onClose()
} catch (error) {
console.error('認証エラー:', error)
alert('認証に失敗しました')
} finally {
setLoading(false)
}
}
return (
<div className={`brand-auth-modal brand-${brand} ${isOpen ? 'open' : ''}`}>
<div className="modal-content">
<h2>{getBrandConfig(brand).displayName} {mode === 'signin' ? 'ログイン' : '新規登録'}</h2>
<form onSubmit={handleAuth}>
<input
type="email"
placeholder="メールアドレス"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<input
type="password"
placeholder="パスワード"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<button type="submit" disabled={loading}>
{loading ? '処理中...' : (mode === 'signin' ? 'ログイン' : '登録')}
</button>
</form>
<button onClick={() => setMode(mode === 'signin' ? 'signup' : 'signin')}>
{mode === 'signin' ? '新規登録はこちら' : 'ログインはこちら'}
</button>
</div>
</div>
)
}
🖥️ サーバーサイド実装
Firebase Admin SDK
// lib/firebase-admin.ts
import admin from 'firebase-admin'
if (!admin.apps.length) {
admin.initializeApp({
credential: admin.credential.cert({
projectId: process.env.FIREBASE_PROJECT_ID,
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
}),
})
}
export const adminAuth = admin.auth()
export const adminDb = admin.firestore()
API認証ミドルウェア
// middleware/auth.ts
import { NextRequest } from 'next/server'
import { adminAuth } from '@/lib/firebase-admin'
export async function verifyAuthToken(request: NextRequest) {
const authHeader = request.headers.get('authorization')
if (!authHeader?.startsWith('Bearer ')) {
return null
}
const idToken = authHeader.split('Bearer ')[1]
try {
const decodedToken = await adminAuth.verifyIdToken(idToken)
// ブランドアクセス確認
const brandAccess = await getBrandAccess(decodedToken.uid)
return {
uid: decodedToken.uid,
email: decodedToken.email,
brandAccess
}
} catch (error) {
console.error('トークン検証エラー:', error)
return null
}
}
// API route での使用例
export async function POST(request: NextRequest) {
const user = await verifyAuthToken(request)
if (!user) {
return new Response('Unauthorized', { status: 401 })
}
// 認証済みユーザーの処理
return new Response(JSON.stringify({ success: true }))
}
🗄️ Firestore データ設計
セキュリティルール
// firestore.rules
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// ユーザープロフィール
match /users/{userId} {
allow read, write: if request.auth != null
&& request.auth.uid == userId;
// 管理者のみ全ユーザー読み取り可能
allow read: if request.auth != null
&& hasAdminRole(request.auth.uid);
}
// ブランドアクセス管理
match /brandAccess/{userId} {
allow read, write: if request.auth != null
&& request.auth.uid == userId;
}
// 注文履歴
match /orders/{orderId} {
allow read, write: if request.auth != null
&& request.auth.uid == resource.data.userId
&& hasValidBrandAccess(request.auth.uid, resource.data.brand);
}
// 管理者権限チェック関数
function hasAdminRole(uid) {
return get(/databases/$(database)/documents/admins/$(uid)).data.role == 'admin';
}
// ブランドアクセスチェック関数
function hasValidBrandAccess(uid, brand) {
return get(/databases/$(database)/documents/brandAccess/$(uid)).data[brand] == true;
}
}
}
データ構造例
// Firestore コレクション設計
interface UserProfile {
uid: string
email: string
displayName?: string
photoURL?: string
preferredBrand: Brand
createdAt: Timestamp
updatedAt: Timestamp
lastLoginAt: Timestamp
}
interface BrandAccess {
uid: string
neko: boolean // Nekomata アクセス権
tokinoe: boolean // TOKINOE アクセス権
dog: boolean // Dog アクセス権
activeBreand: Brand // 現在アクティブなブランド
updatedAt: Timestamp
}
interface OrderHistory {
orderId: string
userId: string
brand: Brand
items: OrderItem[]
totalAmount: number
status: OrderStatus
createdAt: Timestamp
}
🔄 状態管理統合
Zustand ストア実装
// store/authStore.ts
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface AuthState {
user: AuthUser | null
selectedBrand: Brand | null
loading: boolean
// アクション
setUser: (user: AuthUser | null) => void
setSelectedBrand: (brand: Brand) => void
setLoading: (loading: boolean) => void
signOut: () => Promise<void>
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
user: null,
selectedBrand: null,
loading: true,
setUser: (user) => set({ user }),
setSelectedBrand: (brand) => set({ selectedBrand: brand }),
setLoading: (loading) => set({ loading }),
signOut: async () => {
try {
await auth.signOut()
set({ user: null, selectedBrand: null })
} catch (error) {
console.error('ログアウトエラー:', error)
}
}
}),
{
name: 'auth-store',
partialize: (state) => ({
selectedBrand: state.selectedBrand
})
}
)
)
🧪 テスト実装
認証フローテスト
// __tests__/auth.test.ts
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { BrandAuthModal } from '@/components/auth/BrandAuthModal'
// Firebase Auth モック
jest.mock('@/lib/firebase', () => ({
auth: {
signInWithEmailAndPassword: jest.fn(),
createUserWithEmailAndPassword: jest.fn()
}
}))
describe('BrandAuthModal', () => {
it('ログインフォームが正しく表示される', () => {
render(<BrandAuthModal brand="neko" isOpen={true} onClose={() => {}} />)
expect(screen.getByText('Nekomata ログイン')).toBeInTheDocument()
expect(screen.getByPlaceholderText('メールアドレス')).toBeInTheDocument()
expect(screen.getByPlaceholderText('パスワード')).toBeInTheDocument()
})
it('認証が成功した場合onCloseが呼ばれる', async () => {
const mockSignIn = jest.fn().mockResolvedValue({ user: { uid: 'test-uid' } })
const onClose = jest.fn()
render(<BrandAuthModal brand="neko" isOpen={true} onClose={onClose} />)
fireEvent.change(screen.getByPlaceholderText('メールアドレス'), {
target: { value: 'test@example.com' }
})
fireEvent.change(screen.getByPlaceholderText('パスワード'), {
target: { value: 'password123' }
})
fireEvent.click(screen.getByText('ログイン'))
await waitFor(() => {
expect(onClose).toHaveBeenCalled()
})
})
})
🚀 デプロイ設定
Firebase Hosting
// firebase.json
{
"hosting": {
"public": "out",
"ignore": [
"firebase.json",
"**/.*",
"**/node_modules/**"
],
"rewrites": [
{
"source": "**",
"destination": "/index.html"
}
],
"headers": [
{
"source": "**/*.@(js|css)",
"headers": [
{
"key": "Cache-Control",
"value": "max-age=31536000"
}
]
}
]
},
"emulators": {
"auth": {
"port": 9099
},
"firestore": {
"port": 8080
},
"hosting": {
"port": 3000
}
}
}
最終更新: 2025-08-23 18:10:58 JST
実装ステータス: 準備完了 → Phase 1 開始可能