dramaling-vocab-learning/docs/04_technical/security.md

11 KiB
Raw Permalink Blame History

安全性實作指南

🔒 安全性總覽

DramaLing 遵循 OWASP 安全標準,實施多層防護策略。

🛡️ 認證與授權

1. NextAuth 配置

// lib/auth.ts
import { NextAuthOptions } from 'next-auth'
import CredentialsProvider from 'next-auth/providers/credentials'
import GoogleProvider from 'next-auth/providers/google'
import { createClient } from '@supabase/supabase-js'
import bcrypt from 'bcryptjs'

export const authOptions: NextAuthOptions = {
  providers: [
    CredentialsProvider({
      name: 'credentials',
      credentials: {
        email: { label: "Email", type: "email" },
        password: { label: "Password", type: "password" }
      },
      async authorize(credentials) {
        if (!credentials?.email || !credentials?.password) {
          throw new Error('Missing credentials')
        }

        const supabase = createClient(
          process.env.NEXT_PUBLIC_SUPABASE_URL!,
          process.env.SUPABASE_SERVICE_ROLE_KEY!
        )

        const { data: user } = await supabase
          .from('users')
          .select('*')
          .eq('email', credentials.email)
          .single()

        if (!user || !await bcrypt.compare(credentials.password, user.password)) {
          throw new Error('Invalid credentials')
        }

        return {
          id: user.id,
          email: user.email,
          name: user.name,
        }
      }
    }),
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    })
  ],
  session: {
    strategy: 'jwt',
    maxAge: 30 * 24 * 60 * 60, // 30 days
  },
  jwt: {
    secret: process.env.NEXTAUTH_SECRET,
  },
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        token.userId = user.id
      }
      return token
    },
    async session({ session, token }) {
      if (session.user) {
        session.user.id = token.userId as string
      }
      return session
    },
  },
}

2. 密碼安全

// utils/password.ts
import bcrypt from 'bcryptjs'
import { z } from 'zod'

// 密碼驗證規則
export const passwordSchema = z.string()
  .min(8, 'Password must be at least 8 characters')
  .regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
  .regex(/[a-z]/, 'Password must contain at least one lowercase letter')
  .regex(/[0-9]/, 'Password must contain at least one number')
  .regex(/[^A-Za-z0-9]/, 'Password must contain at least one special character')

// 密碼雜湊
export async function hashPassword(password: string): Promise<string> {
  return bcrypt.hash(password, 12)
}

// 密碼驗證
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
  return bcrypt.compare(password, hash)
}

🔐 API 安全

1. Rate Limiting

// middleware/rateLimit.ts
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'

const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL!,
  token: process.env.UPSTASH_REDIS_REST_TOKEN!,
})

const ratelimit = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(10, '10 s'), // 10 requests per 10 seconds
})

export async function rateLimitMiddleware(req: Request) {
  const ip = req.headers.get('x-forwarded-for') ?? 'anonymous'
  const { success, limit, reset, remaining } = await ratelimit.limit(ip)

  if (!success) {
    return new Response('Too Many Requests', {
      status: 429,
      headers: {
        'X-RateLimit-Limit': limit.toString(),
        'X-RateLimit-Remaining': remaining.toString(),
        'X-RateLimit-Reset': new Date(reset).toISOString(),
      },
    })
  }

  return null
}

2. CORS 配置

// next.config.js
module.exports = {
  async headers() {
    return [
      {
        source: '/api/:path*',
        headers: [
          { key: 'Access-Control-Allow-Credentials', value: 'true' },
          { key: 'Access-Control-Allow-Origin', value: process.env.ALLOWED_ORIGIN || '*' },
          { key: 'Access-Control-Allow-Methods', value: 'GET,DELETE,PATCH,POST,PUT' },
          { key: 'Access-Control-Allow-Headers', value: 'Authorization, Content-Type' },
        ],
      },
    ]
  },
}

3. API 路由保護

// app/api/flashcards/route.ts
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { rateLimitMiddleware } from '@/middleware/rateLimit'

export async function POST(req: Request) {
  // Rate limiting
  const rateLimitResponse = await rateLimitMiddleware(req)
  if (rateLimitResponse) return rateLimitResponse

  // 認證檢查
  const session = await getServerSession(authOptions)
  if (!session) {
    return new Response('Unauthorized', { status: 401 })
  }

  // 輸入驗證
  const body = await req.json()
  const validation = flashcardSchema.safeParse(body)

  if (!validation.success) {
    return new Response(JSON.stringify({ errors: validation.error.errors }), {
      status: 400,
      headers: { 'Content-Type': 'application/json' },
    })
  }

  // 處理請求...
}

🧹 輸入驗證與消毒

1. XSS 防護

// utils/sanitize.ts
import DOMPurify from 'isomorphic-dompurify'

export function sanitizeHTML(html: string): string {
  return DOMPurify.sanitize(html, {
    ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
    ALLOWED_ATTR: ['href', 'target'],
  })
}

// 使用範例
export function FlashcardContent({ content }: { content: string }) {
  return (
    <div
      dangerouslySetInnerHTML={{
        __html: sanitizeHTML(content)
      }}
    />
  )
}

2. SQL Injection 防護

// 使用參數化查詢Supabase/Prisma 自動處理)
// ❌ 錯誤:直接字串串接
const query = `SELECT * FROM users WHERE email = '${email}'`

// ✅ 正確:使用參數化查詢
const { data } = await supabase
  .from('users')
  .select('*')
  .eq('email', email) // 自動參數化

3. 檔案上傳安全

// utils/fileUpload.ts
import { z } from 'zod'

const ALLOWED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/webp']
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB

export const fileSchema = z.object({
  name: z.string(),
  size: z.number().max(MAX_FILE_SIZE, 'File too large'),
  type: z.enum(ALLOWED_FILE_TYPES as [string, ...string[]]),
})

export async function validateFile(file: File) {
  // 檢查檔案類型
  if (!ALLOWED_FILE_TYPES.includes(file.type)) {
    throw new Error('Invalid file type')
  }

  // 檢查檔案大小
  if (file.size > MAX_FILE_SIZE) {
    throw new Error('File too large')
  }

  // 檢查檔案內容Magic Number
  const buffer = await file.arrayBuffer()
  const bytes = new Uint8Array(buffer)

  const signatures = {
    jpeg: [0xFF, 0xD8, 0xFF],
    png: [0x89, 0x50, 0x4E, 0x47],
  }

  // 驗證檔案簽名...
  return true
}

🔑 環境變數安全

1. 環境變數分離

# .env.local (開發環境)
NEXT_PUBLIC_APP_URL=http://localhost:3000

# .env.production (生產環境)
NEXT_PUBLIC_APP_URL=https://dramaling.com

# .env.vault (加密儲存敏感資料)
SUPABASE_SERVICE_ROLE_KEY=encrypted:xxx

2. Secrets 管理

// utils/secrets.ts
export function getSecret(key: string): string {
  const value = process.env[key]

  if (!value) {
    throw new Error(`Missing required environment variable: ${key}`)
  }

  // 生產環境檢查
  if (process.env.NODE_ENV === 'production') {
    if (value.includes('test') || value.includes('example')) {
      throw new Error(`Invalid production value for ${key}`)
    }
  }

  return value
}

🛑 錯誤處理安全

1. 安全的錯誤訊息

// utils/errorHandler.ts
export function sanitizeError(error: unknown): { message: string; code: string } {
  // 生產環境:隱藏詳細錯誤
  if (process.env.NODE_ENV === 'production') {
    console.error('Internal error:', error) // 記錄到伺服器日誌

    return {
      message: 'An error occurred. Please try again later.',
      code: 'INTERNAL_ERROR',
    }
  }

  // 開發環境:顯示詳細錯誤
  if (error instanceof Error) {
    return {
      message: error.message,
      code: 'DEV_ERROR',
    }
  }

  return {
    message: 'Unknown error',
    code: 'UNKNOWN',
  }
}

🔍 安全標頭

// middleware.ts
export function middleware(request: NextRequest) {
  const response = NextResponse.next()

  // Security headers
  response.headers.set('X-Frame-Options', 'DENY')
  response.headers.set('X-Content-Type-Options', 'nosniff')
  response.headers.set('X-XSS-Protection', '1; mode=block')
  response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')
  response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()')

  // Content Security Policy
  response.headers.set(
    'Content-Security-Policy',
    "default-src 'self'; " +
    "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://apis.google.com; " +
    "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
    "font-src 'self' https://fonts.gstatic.com; " +
    "img-src 'self' data: https: blob:; " +
    "connect-src 'self' https://*.supabase.co https://generativelanguage.googleapis.com;"
  )

  return response
}

📊 安全監控

1. 審計日誌

// utils/audit.ts
interface AuditLog {
  userId: string
  action: string
  resource: string
  timestamp: Date
  ip?: string
  userAgent?: string
}

export async function logAuditEvent(event: AuditLog) {
  await supabase.from('audit_logs').insert({
    ...event,
    timestamp: new Date().toISOString(),
  })
}

// 使用範例
await logAuditEvent({
  userId: session.user.id,
  action: 'DELETE_FLASHCARD',
  resource: `flashcard:${flashcardId}`,
  ip: request.headers.get('x-forwarded-for'),
  userAgent: request.headers.get('user-agent'),
})

2. 異常檢測

// utils/security/anomaly.ts
export async function detectAnomalies(userId: string) {
  // 檢查異常登入模式
  const recentLogins = await getRecentLogins(userId)

  // 檢查異常 API 使用
  const apiUsage = await getAPIUsage(userId)

  if (apiUsage.count > 1000) {
    await flagAccount(userId, 'EXCESSIVE_API_USAGE')
  }

  // 檢查異常數據存取
  const dataAccess = await getDataAccessPatterns(userId)

  if (dataAccess.uniqueIPs > 5) {
    await flagAccount(userId, 'MULTIPLE_IP_ACCESS')
  }
}

安全檢查清單

開發階段

  • 所有 API 路由都有認證檢查
  • 所有用戶輸入都經過驗證
  • 敏感數據都已加密
  • 實施 Rate Limiting
  • 設置安全標頭
  • 錯誤訊息不洩露敏感資訊

部署前

  • 移除所有 console.log
  • 更新所有依賴到最新安全版本
  • 執行安全掃描npm audit
  • 設置 HTTPS
  • 配置防火牆規則
  • 啟用監控和警報

定期檢查

  • 每週檢查安全日誌
  • 每月更新依賴
  • 每季進行安全審計
  • 定期備份數據
  • 測試災難恢復流程

🚨 事件響應計劃

安全事件處理流程

  1. 檢測 - 監控系統發現異常
  2. 評估 - 確定影響範圍
  3. 隔離 - 限制受影響系統
  4. 修復 - 修補漏洞
  5. 恢復 - 恢復正常運作
  6. 檢討 - 事後分析改進

緊急聯絡

  • 安全團隊security@dramaling.com
  • 24/7 監控monitor@dramaling.com
  • 法律顧問legal@dramaling.com