Firebase Auth
ライブラリ
Firebase Auth
概要
Firebase Authは、Googleが提供する包括的な認証サービスで、モバイルおよびWebアプリケーション向けに最適化されています。50,000 MAU(月間アクティブユーザー)までの無料ティアを提供し、リアルタイムアプリケーションやプロトタイプ開発に優れています。2025年でも開発者体験の簡易性に定評があり、Google Cloud Platform(GCP)エコシステムとの深い統合により企業レベルの拡張性を実現しています。ただし、中規模~エンタープライズ企業では、ベンダーロックインの懸念から代替案の検討も増加している現状があります。
詳細
Firebase Auth 2025は、iOS、Android、Web、Flutter、Unity等の幅広いプラットフォームでSDKを提供する、業界標準の認証プラットフォームです。Email/パスワード、電話番号認証、匿名認証に加え、Google、Facebook、Apple、GitHub、Microsoft、Twitter、Yahoo等の20以上のソーシャルログインプロバイダーを標準でサポートします。SAML(Web)およびOpenID Connectカスタムプロバイダーにも対応し、多要素認証(SMS-based 2FA)、リアルタイムセッション管理、ユーザープロファイル管理を統合的に提供します。
主な特徴
- 包括的プラットフォーム対応: iOS、Android、Flutter、Web、C++、Unityの完全SDK
- 豊富なソーシャルログイン: 20以上のOAuthプロバイダーとカスタムプロバイダー作成
- スケーラブルな無料ティア: 50,000 MAUまで完全無料、SMS認証は従量課金
- リアルタイム同期: Google Cloudインフラによる高速認証状態同期
- 多要素認証: SMS-based 2FAとTOTP対応の包括的セキュリティ
- GCPエコシステム統合: Firebase Database、Cloud Functions、Analytics等との連携
メリット・デメリット
メリット
- 圧倒的な開発速度でモバイルアプリの認証システムを数分で実装可能
- 無料ティア(50,000 MAU)により初期コストなしで本格的な認証システム構築
- Google Cloud Platformの強力なインフラでグローバルスケールに対応
- リアルタイムデータベースとの統合により即座にユーザー情報同期
- 豊富なSDKとUIライブラリで各プラットフォームに最適化された体験
- Firebase Consoleによる視覚的なユーザー管理とアナリティクス機能
デメリット
- 完全クラウドベースのためセルフホスト不可でベンダーロックインのリスク
- ユーザー数増加に伴い予期しないコスト(特にSMS認証:$0.01-$0.34/message)
- カスタム認証フローの制約が多く標準パターンからの逸脱が困難
- 厳格なデータ保護要件やオンプレミス必須の企業では利用不可
- 企業向け機能(詳細なRBAC、監査ログ、カスタムSLA)が限定的
- PostgreSQLやMySQLなど他のデータベースとの直接統合が複雑
参考ページ
書き方の例
基本的なセットアップ
# Firebase SDKのインストール
npm install firebase
# 追加パッケージ(React用)
npm install react-firebase-hooks
# 環境変数設定用(オプション)
npm install dotenv
Firebase初期設定とAuth初期化
// lib/firebase.ts
import { initializeApp, getApps } from 'firebase/app'
import { getAuth, connectAuthEmulator } from 'firebase/auth'
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,
}
// Firebase Appの初期化(重複初期化を防ぐ)
const app = getApps().length > 0 ? getApps()[0] : initializeApp(firebaseConfig)
// Firebase Auth初期化
export const auth = getAuth(app)
// 開発環境でのエミュレーター設定(オプション)
if (process.env.NODE_ENV === 'development' && !auth.config.emulator) {
connectAuthEmulator(auth, 'http://localhost:9099')
}
export default app
Email/パスワード認証とユーザー登録
// hooks/useAuth.ts
import { useState } from 'react'
import {
createUserWithEmailAndPassword,
signInWithEmailAndPassword,
signOut,
sendEmailVerification,
User
} from 'firebase/auth'
import { auth } from '@/lib/firebase'
export function useAuth() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const signUp = async (email: string, password: string) => {
setLoading(true)
setError(null)
try {
const userCredential = await createUserWithEmailAndPassword(auth, email, password)
const user = userCredential.user
// メール確認の送信
await sendEmailVerification(user)
return {
user,
message: '確認メールを送信しました。メールをご確認ください。'
}
} catch (error: any) {
setError(error.message)
throw error
} finally {
setLoading(false)
}
}
const signIn = async (email: string, password: string) => {
setLoading(true)
setError(null)
try {
const userCredential = await signInWithEmailAndPassword(auth, email, password)
if (!userCredential.user.emailVerified) {
throw new Error('メールアドレスが確認されていません。確認メールをご確認ください。')
}
return userCredential.user
} catch (error: any) {
setError(error.message)
throw error
} finally {
setLoading(false)
}
}
const logout = async () => {
try {
await signOut(auth)
} catch (error: any) {
setError(error.message)
throw error
}
}
return {
signUp,
signIn,
logout,
loading,
error
}
}
ソーシャルログイン(Google、GitHub)の実装
// components/SocialAuth.tsx
'use client'
import { useState } from 'react'
import {
signInWithPopup,
GoogleAuthProvider,
GithubAuthProvider,
UserCredential
} from 'firebase/auth'
import { auth } from '@/lib/firebase'
export function SocialAuth() {
const [loading, setLoading] = useState<string | null>(null)
const handleGoogleSignIn = async () => {
setLoading('google')
try {
const provider = new GoogleAuthProvider()
provider.addScope('profile')
provider.addScope('email')
const result = await signInWithPopup(auth, provider)
const credential = GoogleAuthProvider.credentialFromResult(result)
const token = credential?.accessToken
console.log('Google Access Token:', token)
console.log('User:', result.user)
return result.user
} catch (error: any) {
console.error('Google sign-in error:', error)
throw error
} finally {
setLoading(null)
}
}
const handleGitHubSignIn = async () => {
setLoading('github')
try {
const provider = new GithubAuthProvider()
provider.addScope('repo')
const result = await signInWithPopup(auth, provider)
const credential = GithubAuthProvider.credentialFromResult(result)
const token = credential?.accessToken
console.log('GitHub Access Token:', token)
console.log('User:', result.user)
return result.user
} catch (error: any) {
console.error('GitHub sign-in error:', error)
throw error
} finally {
setLoading(null)
}
}
return (
<div className="space-y-3">
<button
onClick={handleGoogleSignIn}
disabled={loading !== null}
className="w-full flex items-center justify-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
>
{loading === 'google' ? (
'Googleでログイン中...'
) : (
<>
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Googleでログイン
</>
)}
</button>
<button
onClick={handleGitHubSignIn}
disabled={loading !== null}
className="w-full flex items-center justify-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
>
{loading === 'github' ? (
'GitHubでログイン中...'
) : (
<>
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
GitHubでログイン
</>
)}
</button>
</div>
)
}
電話番号認証とreCAPTCHA設定
// components/PhoneAuth.tsx
'use client'
import { useState, useEffect } from 'react'
import {
signInWithPhoneNumber,
RecaptchaVerifier,
ConfirmationResult,
PhoneAuthProvider,
signInWithCredential
} from 'firebase/auth'
import { auth } from '@/lib/firebase'
export function PhoneAuth() {
const [phoneNumber, setPhoneNumber] = useState('')
const [verificationCode, setVerificationCode] = useState('')
const [confirmationResult, setConfirmationResult] = useState<ConfirmationResult | null>(null)
const [loading, setLoading] = useState(false)
const [recaptchaVerifier, setRecaptchaVerifier] = useState<RecaptchaVerifier | null>(null)
useEffect(() => {
// reCAPTCHA Verifierの初期化
const verifier = new RecaptchaVerifier(auth, 'recaptcha-container', {
size: 'normal',
callback: () => {
console.log('reCAPTCHA認証完了')
},
'expired-callback': () => {
console.log('reCAPTCHA期限切れ')
}
})
setRecaptchaVerifier(verifier)
return () => {
verifier.clear()
}
}, [])
const sendVerificationCode = async () => {
if (!recaptchaVerifier) return
setLoading(true)
try {
const confirmation = await signInWithPhoneNumber(auth, phoneNumber, recaptchaVerifier)
setConfirmationResult(confirmation)
alert('認証コードを送信しました。SMSをご確認ください。')
} catch (error: any) {
console.error('電話番号認証エラー:', error)
alert('認証コードの送信に失敗しました: ' + error.message)
recaptchaVerifier.clear()
} finally {
setLoading(false)
}
}
const confirmVerificationCode = async () => {
if (!confirmationResult) return
setLoading(true)
try {
const userCredential = await confirmationResult.confirm(verificationCode)
console.log('電話番号認証成功:', userCredential.user)
alert('認証に成功しました!')
} catch (error: any) {
console.error('認証コード確認エラー:', error)
alert('認証コードが正しくありません: ' + error.message)
} finally {
setLoading(false)
}
}
return (
<div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow-md">
<h2 className="text-2xl font-bold mb-6 text-center">電話番号認証</h2>
{!confirmationResult ? (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">
電話番号
</label>
<input
type="tel"
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value)}
placeholder="+81 90-1234-5678"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<p className="text-sm text-gray-500 mt-1">
国際形式で入力してください(例:+81901234567)
</p>
</div>
<div id="recaptcha-container" className="flex justify-center"></div>
<button
onClick={sendVerificationCode}
disabled={loading || !phoneNumber}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{loading ? '送信中...' : '認証コードを送信'}
</button>
</div>
) : (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">
認証コード(6桁)
</label>
<input
type="text"
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value)}
placeholder="123456"
maxLength={6}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<button
onClick={confirmVerificationCode}
disabled={loading || verificationCode.length !== 6}
className="w-full bg-green-600 text-white py-2 px-4 rounded-md hover:bg-green-700 disabled:opacity-50"
>
{loading ? '認証中...' : '認証を完了'}
</button>
<button
onClick={() => {
setConfirmationResult(null)
setVerificationCode('')
}}
className="w-full text-gray-600 py-2 px-4 border border-gray-300 rounded-md hover:bg-gray-50"
>
電話番号を変更
</button>
</div>
)}
</div>
)
}
認証状態管理とプロテクトされたルート
// components/AuthProvider.tsx
'use client'
import { createContext, useContext, useEffect, useState } from 'react'
import { User, onAuthStateChanged } from 'firebase/auth'
import { auth } from '@/lib/firebase'
interface AuthContextType {
user: User | null
loading: boolean
}
const AuthContext = createContext<AuthContextType>({
user: null,
loading: true
})
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, (user) => {
setUser(user)
setLoading(false)
})
return () => unsubscribe()
}, [])
return (
<AuthContext.Provider value={{ user, loading }}>
{children}
</AuthContext.Provider>
)
}
export const useAuthContext = () => useContext(AuthContext)
// components/ProtectedRoute.tsx
import { useRouter } from 'next/navigation'
import { useEffect } from 'react'
import { useAuthContext } from './AuthProvider'
interface ProtectedRouteProps {
children: React.ReactNode
redirectTo?: string
}
export function ProtectedRoute({
children,
redirectTo = '/login'
}: ProtectedRouteProps) {
const { user, loading } = useAuthContext()
const router = useRouter()
useEffect(() => {
if (!loading && !user) {
router.push(redirectTo)
}
}, [user, loading, router, redirectTo])
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-blue-600"></div>
</div>
)
}
if (!user) {
return null
}
return <>{children}</>
}
ユーザープロファイル管理とIDトークン取得
// components/UserProfile.tsx
'use client'
import { useState, useEffect } from 'react'
import {
updateProfile,
updateEmail,
updatePassword,
sendEmailVerification,
deleteUser,
User
} from 'firebase/auth'
import { useAuthContext } from './AuthProvider'
export function UserProfile() {
const { user } = useAuthContext()
const [displayName, setDisplayName] = useState('')
const [email, setEmail] = useState('')
const [newPassword, setNewPassword] = useState('')
const [loading, setLoading] = useState(false)
const [idToken, setIdToken] = useState<string | null>(null)
useEffect(() => {
if (user) {
setDisplayName(user.displayName || '')
setEmail(user.email || '')
// IDトークンを取得
user.getIdToken().then(setIdToken)
}
}, [user])
const handleUpdateProfile = async () => {
if (!user) return
setLoading(true)
try {
await updateProfile(user, {
displayName: displayName
})
alert('プロファイルを更新しました!')
} catch (error: any) {
console.error('プロファイル更新エラー:', error)
alert('更新に失敗しました: ' + error.message)
} finally {
setLoading(false)
}
}
const handleUpdateEmail = async () => {
if (!user) return
setLoading(true)
try {
await updateEmail(user, email)
await sendEmailVerification(user)
alert('メールアドレスを更新しました。確認メールをご確認ください。')
} catch (error: any) {
console.error('メール更新エラー:', error)
alert('更新に失敗しました: ' + error.message)
} finally {
setLoading(false)
}
}
const handleUpdatePassword = async () => {
if (!user || !newPassword) return
setLoading(true)
try {
await updatePassword(user, newPassword)
setNewPassword('')
alert('パスワードを更新しました!')
} catch (error: any) {
console.error('パスワード更新エラー:', error)
alert('更新に失敗しました: ' + error.message)
} finally {
setLoading(false)
}
}
const handleDeleteAccount = async () => {
if (!user) return
if (!confirm('アカウントを削除しますか?この操作は取り消せません。')) {
return
}
setLoading(true)
try {
await deleteUser(user)
alert('アカウントを削除しました。')
} catch (error: any) {
console.error('アカウント削除エラー:', error)
alert('削除に失敗しました: ' + error.message)
} finally {
setLoading(false)
}
}
if (!user) {
return <div>認証が必要です</div>
}
return (
<div className="max-w-2xl mx-auto p-6 space-y-6">
<h1 className="text-2xl font-bold mb-6">プロファイル設定</h1>
{/* ユーザー情報表示 */}
<div className="bg-gray-50 p-4 rounded-lg">
<h3 className="font-semibold mb-2">ユーザー情報</h3>
<div className="space-y-2 text-sm">
<p><strong>UID:</strong> {user.uid}</p>
<p><strong>作成日:</strong> {user.metadata.creationTime}</p>
<p><strong>最終サインイン:</strong> {user.metadata.lastSignInTime}</p>
<p><strong>メール確認:</strong> {user.emailVerified ? '済み' : '未確認'}</p>
<p><strong>プロバイダー:</strong> {user.providerData.map(p => p.providerId).join(', ')}</p>
</div>
</div>
{/* 表示名更新 */}
<div className="p-4 border rounded-lg">
<h3 className="font-semibold mb-2">表示名</h3>
<div className="flex gap-2">
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="表示名"
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
onClick={handleUpdateProfile}
disabled={loading}
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:opacity-50"
>
更新
</button>
</div>
</div>
{/* メールアドレス更新 */}
<div className="p-4 border rounded-lg">
<h3 className="font-semibold mb-2">メールアドレス</h3>
<div className="flex gap-2">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="メールアドレス"
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
onClick={handleUpdateEmail}
disabled={loading}
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:opacity-50"
>
更新
</button>
</div>
</div>
{/* パスワード更新 */}
<div className="p-4 border rounded-lg">
<h3 className="font-semibold mb-2">パスワード変更</h3>
<div className="flex gap-2">
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="新しいパスワード"
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
onClick={handleUpdatePassword}
disabled={loading || !newPassword}
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:opacity-50"
>
変更
</button>
</div>
</div>
{/* IDトークン表示 */}
{idToken && (
<div className="p-4 bg-gray-50 rounded-lg">
<h3 className="font-semibold mb-2">IDトークン</h3>
<textarea
value={idToken}
readOnly
rows={4}
className="w-full px-3 py-2 text-xs font-mono bg-white border border-gray-300 rounded-md"
/>
</div>
)}
{/* 危険な操作 */}
<div className="p-4 border border-red-200 bg-red-50 rounded-lg">
<h3 className="font-semibold text-red-800 mb-2">危険な操作</h3>
<button
onClick={handleDeleteAccount}
disabled={loading}
className="bg-red-600 text-white px-4 py-2 rounded-md hover:bg-red-700 disabled:opacity-50"
>
アカウントを削除
</button>
<p className="text-sm text-red-600 mt-2">
この操作は取り消せません。慎重に操作してください。
</p>
</div>
</div>
)
}
多要素認証(MFA)の実装
// components/MultiFactorAuth.tsx
'use client'
import { useState, useEffect } from 'react'
import {
multiFactor,
TotpMultiFactorGenerator,
TotpSecret,
PhoneAuthProvider,
PhoneMultiFactorGenerator,
RecaptchaVerifier
} from 'firebase/auth'
import { useAuthContext } from './AuthProvider'
import { auth } from '@/lib/firebase'
export function MultiFactorAuth() {
const { user } = useAuthContext()
const [totpSecret, setTotpSecret] = useState<TotpSecret | null>(null)
const [verificationCode, setVerificationCode] = useState('')
const [qrCodeUrl, setQrCodeUrl] = useState('')
const [enrolledFactors, setEnrolledFactors] = useState<any[]>([])
const [loading, setLoading] = useState(false)
useEffect(() => {
if (user) {
loadEnrolledFactors()
}
}, [user])
const loadEnrolledFactors = () => {
if (!user) return
const multiFactorUser = multiFactor(user)
setEnrolledFactors(multiFactorUser.enrolledFactors)
}
const enrollTOTP = async () => {
if (!user) return
setLoading(true)
try {
const multiFactorUser = multiFactor(user)
const session = await multiFactorUser.getSession()
// TOTP秘密鍵を生成
const totpSecret = await TotpMultiFactorGenerator.generateSecret(session)
setTotpSecret(totpSecret)
// QRコードURLを生成
const qrCodeUrl = totpSecret.generateQrCodeUrl(
user.email || '[email protected]',
'My App'
)
setQrCodeUrl(qrCodeUrl)
} catch (error: any) {
console.error('TOTP enrollment error:', error)
alert('MFA設定でエラーが発生しました: ' + error.message)
} finally {
setLoading(false)
}
}
const verifyAndFinalizeTOTP = async () => {
if (!totpSecret || !verificationCode) return
setLoading(true)
try {
const multiFactorUser = multiFactor(user!)
// TOTP認証情報を作成
const multiFactorAssertion = TotpMultiFactorGenerator.assertionForEnrollment(
totpSecret,
verificationCode
)
// MFAを最終化
await multiFactorUser.enroll(multiFactorAssertion, 'TOTP App')
alert('TOTPによる多要素認証が有効になりました!')
setTotpSecret(null)
setVerificationCode('')
setQrCodeUrl('')
loadEnrolledFactors()
} catch (error: any) {
console.error('TOTP verification error:', error)
alert('認証コードが正しくありません: ' + error.message)
} finally {
setLoading(false)
}
}
const unenrollFactor = async (factorUid: string) => {
if (!user) return
if (!confirm('この多要素認証を無効にしますか?')) {
return
}
setLoading(true)
try {
const multiFactorUser = multiFactor(user)
await multiFactorUser.unenroll(factorUid)
alert('多要素認証を無効にしました。')
loadEnrolledFactors()
} catch (error: any) {
console.error('Unenroll error:', error)
alert('無効化でエラーが発生しました: ' + error.message)
} finally {
setLoading(false)
}
}
if (!user) {
return <div>認証が必要です</div>
}
return (
<div className="max-w-2xl mx-auto p-6 space-y-6">
<h1 className="text-2xl font-bold mb-6">多要素認証(MFA)</h1>
{/* 現在の設定状況 */}
<div className="p-4 bg-gray-50 rounded-lg">
<h3 className="font-semibold mb-2">現在の設定</h3>
{enrolledFactors.length === 0 ? (
<p className="text-gray-600">多要素認証は設定されていません</p>
) : (
<div className="space-y-2">
{enrolledFactors.map((factor) => (
<div key={factor.uid} className="flex items-center justify-between p-2 bg-white rounded border">
<div>
<p className="font-medium">{factor.displayName || factor.factorId}</p>
<p className="text-sm text-gray-500">登録日: {factor.enrollmentTime}</p>
</div>
<button
onClick={() => unenrollFactor(factor.uid)}
disabled={loading}
className="text-red-600 hover:text-red-800 text-sm disabled:opacity-50"
>
無効化
</button>
</div>
))}
</div>
)}
</div>
{/* TOTP設定 */}
{!totpSecret ? (
<div className="p-4 border rounded-lg">
<h3 className="font-semibold mb-2">認証アプリによる2段階認証</h3>
<p className="text-gray-600 mb-4">
Google Authenticator、Microsoft Authenticator等の認証アプリを使用してセキュリティを強化します。
</p>
<button
onClick={enrollTOTP}
disabled={loading}
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{loading ? '設定中...' : '認証アプリを設定'}
</button>
</div>
) : (
<div className="p-4 border rounded-lg">
<h3 className="font-semibold mb-4">認証アプリの設定</h3>
<div className="space-y-4">
<div>
<p className="text-sm text-gray-600 mb-2">
1. 認証アプリで以下のQRコードをスキャンしてください:
</p>
{qrCodeUrl && (
<div className="text-center">
<img
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(qrCodeUrl)}`}
alt="QR Code"
className="mx-auto border"
/>
</div>
)}
</div>
<div>
<p className="text-sm text-gray-600 mb-2">
2. 認証アプリに表示された6桁のコードを入力してください:
</p>
<div className="flex gap-2">
<input
type="text"
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value)}
placeholder="123456"
maxLength={6}
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
onClick={verifyAndFinalizeTOTP}
disabled={loading || verificationCode.length !== 6}
className="bg-green-600 text-white px-4 py-2 rounded-md hover:bg-green-700 disabled:opacity-50"
>
{loading ? '確認中...' : '確認'}
</button>
</div>
</div>
<button
onClick={() => {
setTotpSecret(null)
setVerificationCode('')
setQrCodeUrl('')
}}
className="text-gray-600 text-sm hover:text-gray-800"
>
キャンセル
</button>
</div>
</div>
)}
</div>
)
}