475 lines
11 KiB
Markdown
475 lines
11 KiB
Markdown
# 安全性實作指南
|
||
|
||
## 🔒 安全性總覽
|
||
|
||
DramaLing 遵循 OWASP 安全標準,實施多層防護策略。
|
||
|
||
## 🛡️ 認證與授權
|
||
|
||
### 1. NextAuth 配置
|
||
|
||
```typescript
|
||
// 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. 密碼安全
|
||
|
||
```typescript
|
||
// 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
|
||
|
||
```typescript
|
||
// 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 配置
|
||
|
||
```typescript
|
||
// 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 路由保護
|
||
|
||
```typescript
|
||
// 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 防護
|
||
|
||
```typescript
|
||
// 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 防護
|
||
|
||
```typescript
|
||
// 使用參數化查詢(Supabase/Prisma 自動處理)
|
||
// ❌ 錯誤:直接字串串接
|
||
const query = `SELECT * FROM users WHERE email = '${email}'`
|
||
|
||
// ✅ 正確:使用參數化查詢
|
||
const { data } = await supabase
|
||
.from('users')
|
||
.select('*')
|
||
.eq('email', email) // 自動參數化
|
||
```
|
||
|
||
### 3. 檔案上傳安全
|
||
|
||
```typescript
|
||
// 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. 環境變數分離
|
||
|
||
```bash
|
||
# .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 管理
|
||
|
||
```typescript
|
||
// 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. 安全的錯誤訊息
|
||
|
||
```typescript
|
||
// 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',
|
||
}
|
||
}
|
||
```
|
||
|
||
## 🔍 安全標頭
|
||
|
||
```typescript
|
||
// 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. 審計日誌
|
||
|
||
```typescript
|
||
// 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. 異常檢測
|
||
|
||
```typescript
|
||
// 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 |