# 

# 04-1. 認證基礎

&gt; ⏱️ **閱讀時間：** 18 分鐘
&gt; 🎯 **難度：** ⭐⭐⭐ (進階)

---

## 🤔 一句話解釋

**認證（Authentication）是驗證「你是誰」，授權（Authorization）是決定「你能做什麼」。**

---

## 🔐 認證 vs 授權

```
┌─────────────────────────────────────────────────────────┐
│           認證 (Authentication)                         │
│                                                         │
│  「你是誰？」                                            │
│  ┌─────────────────────────────────────────────┐       │
│  │  使用者提供憑證（帳密、Token、憑證）          │       │
│  │            ↓                                │       │
│  │  系統驗證身份                                │       │
│  │            ↓                                │       │
│  │  確認：這是 John                             │       │
│  └─────────────────────────────────────────────┘       │
├─────────────────────────────────────────────────────────┤
│           授權 (Authorization)                          │
│                                                         │
│  「你能做什麼？」                                        │
│  ┌─────────────────────────────────────────────┐       │
│  │  John 要存取 /admin                          │       │
│  │            ↓                                │       │
│  │  檢查 John 的權限                            │       │
│  │            ↓                                │       │
│  │  John 是管理員嗎？→ 允許/拒絕                │       │
│  └─────────────────────────────────────────────┘       │
└─────────────────────────────────────────────────────────┘
```

---

## 🎯 常見認證方式

### 比較表

| 方式 | 適用場景 | 優點 | 缺點 |
|------|----------|------|------|
| **Session** | 傳統 Web | 安全、可撤銷 | 有狀態、擴展難 |
| **JWT** | API、SPA | 無狀態、可擴展 | 無法撤銷、較大 |
| **OAuth 2.0** | 第三方登入 | 標準化、安全 | 複雜 |
| **API Key** | 服務對服務 | 簡單 | 安全性較低 |

### 選擇建議

```
你的專案是...

純 API（給前端/App 用）
    └──▶ JWT &#43; Refresh Token

傳統 Web 應用
    └──▶ Session Cookie

需要第三方登入
    └──▶ OAuth 2.0

服務間通訊
    └──▶ API Key 或 mTLS
```

---

## 🔑 密碼處理

### 安裝密碼雜湊庫

```bash
pip install passlib[bcrypt]
```

### 密碼工具類別

```python
# app/core/security.py
from passlib.context import CryptContext

# 密碼雜湊設定
pwd_context = CryptContext(
    schemes=[&#34;bcrypt&#34;],
    deprecated=&#34;auto&#34;
)


def hash_password(password: str) -&gt; str:
    &#34;&#34;&#34;將密碼雜湊&#34;&#34;&#34;
    return pwd_context.hash(password)


def verify_password(plain_password: str, hashed_password: str) -&gt; bool:
    &#34;&#34;&#34;驗證密碼&#34;&#34;&#34;
    return pwd_context.verify(plain_password, hashed_password)
```

### 使用範例

```python
from app.core.security import hash_password, verify_password

# 註冊時雜湊密碼
hashed = hash_password(&#34;my_password&#34;)
print(hashed)
# $2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW

# 登入時驗證密碼
is_valid = verify_password(&#34;my_password&#34;, hashed)
print(is_valid)  # True

is_valid = verify_password(&#34;wrong_password&#34;, hashed)
print(is_valid)  # False
```

### 密碼強度驗證

```python
from pydantic import BaseModel, field_validator
import re


class PasswordValidator:
    &#34;&#34;&#34;密碼強度驗證器&#34;&#34;&#34;

    MIN_LENGTH = 8
    MAX_LENGTH = 128

    @classmethod
    def validate(cls, password: str) -&gt; str:
        errors = []

        if len(password) &lt; cls.MIN_LENGTH:
            errors.append(f&#34;密碼至少需要 {cls.MIN_LENGTH} 個字元&#34;)

        if len(password) &gt; cls.MAX_LENGTH:
            errors.append(f&#34;密碼不能超過 {cls.MAX_LENGTH} 個字元&#34;)

        if not re.search(r&#34;[A-Z]&#34;, password):
            errors.append(&#34;密碼需要包含至少一個大寫字母&#34;)

        if not re.search(r&#34;[a-z]&#34;, password):
            errors.append(&#34;密碼需要包含至少一個小寫字母&#34;)

        if not re.search(r&#34;\d&#34;, password):
            errors.append(&#34;密碼需要包含至少一個數字&#34;)

        if not re.search(r&#34;[!@#$%^&amp;*(),.?\&#34;:{}|&lt;&gt;]&#34;, password):
            errors.append(&#34;密碼需要包含至少一個特殊字元&#34;)

        if errors:
            raise ValueError(&#34;; &#34;.join(errors))

        return password


class UserCreate(BaseModel):
    username: str
    email: str
    password: str

    @field_validator(&#34;password&#34;)
    @classmethod
    def validate_password(cls, v: str) -&gt; str:
        return PasswordValidator.validate(v)
```

---

## 🔐 Basic Authentication

### 基本實作

```python
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials
import secrets

app = FastAPI()
security = HTTPBasic()


def verify_credentials(credentials: HTTPBasicCredentials = Depends(security)):
    &#34;&#34;&#34;驗證 Basic Auth 憑證&#34;&#34;&#34;
    # 從資料庫取得使用者（這裡簡化為固定值）
    correct_username = &#34;admin&#34;
    correct_password = &#34;secret&#34;

    # 使用 secrets.compare_digest 防止 timing attack
    username_correct = secrets.compare_digest(
        credentials.username.encode(&#34;utf8&#34;),
        correct_username.encode(&#34;utf8&#34;)
    )
    password_correct = secrets.compare_digest(
        credentials.password.encode(&#34;utf8&#34;),
        correct_password.encode(&#34;utf8&#34;)
    )

    if not (username_correct and password_correct):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail=&#34;Invalid credentials&#34;,
            headers={&#34;WWW-Authenticate&#34;: &#34;Basic&#34;},
        )

    return credentials.username


@app.get(&#34;/protected&#34;)
def protected_route(username: str = Depends(verify_credentials)):
    return {&#34;message&#34;: f&#34;Hello, {username}!&#34;}
```

### Basic Auth 特點

```
優點：
✅ 實作簡單
✅ 標準化（RFC 7617）
✅ 瀏覽器原生支援

缺點：
❌ 每次請求都要傳送帳密
❌ 需要 HTTPS
❌ 無法設定過期時間
❌ 登出困難

適用場景：
- 內部工具
- 簡單的 API
- 測試環境
```

---

## 🍪 Session 認證

### 安裝

```bash
pip install itsdangerous
```

### Session 管理

```python
from fastapi import FastAPI, Depends, HTTPException, Response, Request
from fastapi.security import APIKeyCookie
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
from pydantic import BaseModel

app = FastAPI()

# 設定
SECRET_KEY = &#34;your-super-secret-key&#34;
SESSION_COOKIE_NAME = &#34;session_id&#34;
SESSION_MAX_AGE = 3600  # 1 小時

# Session 序列化器
serializer = URLSafeTimedSerializer(SECRET_KEY)

# Cookie 安全設定
cookie_scheme = APIKeyCookie(name=SESSION_COOKIE_NAME, auto_error=False)


class SessionData(BaseModel):
    user_id: int
    username: str


def create_session(data: SessionData) -&gt; str:
    &#34;&#34;&#34;建立 Session Token&#34;&#34;&#34;
    return serializer.dumps(data.model_dump())


def get_session_data(token: str) -&gt; SessionData | None:
    &#34;&#34;&#34;解析 Session Token&#34;&#34;&#34;
    try:
        data = serializer.loads(token, max_age=SESSION_MAX_AGE)
        return SessionData(**data)
    except (BadSignature, SignatureExpired):
        return None


async def get_current_user(
    session_token: str | None = Depends(cookie_scheme)
) -&gt; SessionData:
    &#34;&#34;&#34;取得當前使用者&#34;&#34;&#34;
    if not session_token:
        raise HTTPException(status_code=401, detail=&#34;Not authenticated&#34;)

    session_data = get_session_data(session_token)
    if not session_data:
        raise HTTPException(status_code=401, detail=&#34;Invalid or expired session&#34;)

    return session_data


class LoginRequest(BaseModel):
    username: str
    password: str


@app.post(&#34;/login&#34;)
async def login(request: LoginRequest, response: Response):
    &#34;&#34;&#34;登入&#34;&#34;&#34;
    # 驗證帳密（簡化版本）
    if request.username != &#34;admin&#34; or request.password != &#34;password&#34;:
        raise HTTPException(status_code=401, detail=&#34;Invalid credentials&#34;)

    # 建立 Session
    session_data = SessionData(user_id=1, username=request.username)
    session_token = create_session(session_data)

    # 設定 Cookie
    response.set_cookie(
        key=SESSION_COOKIE_NAME,
        value=session_token,
        max_age=SESSION_MAX_AGE,
        httponly=True,      # JavaScript 無法存取
        secure=True,        # 只在 HTTPS 傳送
        samesite=&#34;lax&#34;,     # 防止 CSRF
    )

    return {&#34;message&#34;: &#34;Login successful&#34;}


@app.post(&#34;/logout&#34;)
async def logout(response: Response):
    &#34;&#34;&#34;登出&#34;&#34;&#34;
    response.delete_cookie(SESSION_COOKIE_NAME)
    return {&#34;message&#34;: &#34;Logout successful&#34;}


@app.get(&#34;/me&#34;)
async def get_me(user: SessionData = Depends(get_current_user)):
    &#34;&#34;&#34;取得當前使用者資訊&#34;&#34;&#34;
    return {&#34;user_id&#34;: user.user_id, &#34;username&#34;: user.username}
```

### Session 儲存選項

```python
# 選項 1: Cookie（如上）
# - 無狀態
# - 不需要資料庫

# 選項 2: Redis
import redis.asyncio as redis

redis_client = redis.from_url(&#34;redis://localhost:6379&#34;)

async def create_session_redis(user_id: int, data: dict) -&gt; str:
    session_id = secrets.token_urlsafe(32)
    await redis_client.setex(
        f&#34;session:{session_id}&#34;,
        SESSION_MAX_AGE,
        json.dumps(data)
    )
    return session_id

async def get_session_redis(session_id: str) -&gt; dict | None:
    data = await redis_client.get(f&#34;session:{session_id}&#34;)
    if data:
        return json.loads(data)
    return None

async def delete_session_redis(session_id: str):
    await redis_client.delete(f&#34;session:{session_id}&#34;)
```

---

## 🔑 API Key 認證

### 基本實作

```python
from fastapi import FastAPI, Depends, HTTPException, Security
from fastapi.security import APIKeyHeader, APIKeyQuery

app = FastAPI()

# API Key 可以從 Header 或 Query 參數取得
api_key_header = APIKeyHeader(name=&#34;X-API-Key&#34;, auto_error=False)
api_key_query = APIKeyQuery(name=&#34;api_key&#34;, auto_error=False)

# 有效的 API Keys（實際應該從資料庫讀取）
VALID_API_KEYS = {
    &#34;key_abc123&#34;: {&#34;name&#34;: &#34;Service A&#34;, &#34;scopes&#34;: [&#34;read&#34;, &#34;write&#34;]},
    &#34;key_xyz789&#34;: {&#34;name&#34;: &#34;Service B&#34;, &#34;scopes&#34;: [&#34;read&#34;]},
}


async def get_api_key(
    api_key_header: str | None = Security(api_key_header),
    api_key_query: str | None = Security(api_key_query),
) -&gt; str:
    &#34;&#34;&#34;驗證 API Key&#34;&#34;&#34;
    api_key = api_key_header or api_key_query

    if not api_key:
        raise HTTPException(
            status_code=401,
            detail=&#34;API Key required&#34;
        )

    if api_key not in VALID_API_KEYS:
        raise HTTPException(
            status_code=403,
            detail=&#34;Invalid API Key&#34;
        )

    return api_key


def require_scope(required_scope: str):
    &#34;&#34;&#34;檢查 API Key 是否有特定權限&#34;&#34;&#34;
    async def check_scope(api_key: str = Depends(get_api_key)):
        key_info = VALID_API_KEYS[api_key]
        if required_scope not in key_info[&#34;scopes&#34;]:
            raise HTTPException(
                status_code=403,
                detail=f&#34;Scope &#39;{required_scope}&#39; required&#34;
            )
        return key_info
    return check_scope


@app.get(&#34;/data&#34;)
async def read_data(key_info: dict = Depends(require_scope(&#34;read&#34;))):
    &#34;&#34;&#34;需要 read 權限&#34;&#34;&#34;
    return {&#34;data&#34;: &#34;some data&#34;, &#34;accessed_by&#34;: key_info[&#34;name&#34;]}


@app.post(&#34;/data&#34;)
async def write_data(key_info: dict = Depends(require_scope(&#34;write&#34;))):
    &#34;&#34;&#34;需要 write 權限&#34;&#34;&#34;
    return {&#34;message&#34;: &#34;Data written&#34;, &#34;by&#34;: key_info[&#34;name&#34;]}
```

### API Key 管理

```python
from sqlalchemy import Column, Integer, String, Boolean, DateTime
from datetime import datetime
import secrets


class APIKey(Base):
    &#34;&#34;&#34;API Key Model&#34;&#34;&#34;
    __tablename__ = &#34;api_keys&#34;

    id = Column(Integer, primary_key=True)
    key = Column(String(64), unique=True, index=True)
    name = Column(String(100))
    scopes = Column(String(500))  # JSON 或逗號分隔
    is_active = Column(Boolean, default=True)
    created_at = Column(DateTime, default=datetime.utcnow)
    last_used_at = Column(DateTime, nullable=True)
    expires_at = Column(DateTime, nullable=True)


def generate_api_key() -&gt; str:
    &#34;&#34;&#34;產生 API Key&#34;&#34;&#34;
    return f&#34;sk_{secrets.token_urlsafe(32)}&#34;


async def create_api_key(
    db: AsyncSession,
    name: str,
    scopes: list[str],
    expires_in_days: int | None = None
) -&gt; APIKey:
    &#34;&#34;&#34;建立 API Key&#34;&#34;&#34;
    from datetime import timedelta

    key = APIKey(
        key=generate_api_key(),
        name=name,
        scopes=&#34;,&#34;.join(scopes),
        expires_at=(
            datetime.utcnow() &#43; timedelta(days=expires_in_days)
            if expires_in_days else None
        )
    )
    db.add(key)
    await db.commit()
    return key


async def validate_api_key(db: AsyncSession, key: str) -&gt; APIKey | None:
    &#34;&#34;&#34;驗證 API Key&#34;&#34;&#34;
    from sqlalchemy import select

    stmt = select(APIKey).where(
        APIKey.key == key,
        APIKey.is_active == True
    )
    result = await db.execute(stmt)
    api_key = result.scalar_one_or_none()

    if not api_key:
        return None

    # 檢查是否過期
    if api_key.expires_at and api_key.expires_at &lt; datetime.utcnow():
        return None

    # 更新最後使用時間
    api_key.last_used_at = datetime.utcnow()
    await db.commit()

    return api_key
```

---

## ✅ 重點總結

### 認證方式選擇

| 方式 | 適用場景 |
|------|----------|
| Basic Auth | 簡單、內部工具 |
| Session | 傳統 Web 應用 |
| JWT | API、SPA、行動 App |
| API Key | 服務對服務 |
| OAuth 2.0 | 第三方登入 |

### 安全要點

1. **密碼永遠要雜湊**：使用 bcrypt 或 argon2
2. **使用 HTTPS**：保護傳輸中的憑證
3. **防止 timing attack**：使用 `secrets.compare_digest`
4. **設定 Cookie 安全屬性**：httponly, secure, samesite

---

## 🎤 面試這樣答

### Q: 認證和授權的差別是什麼？

**答案：**

&gt; - **認證（Authentication）**：驗證「你是誰」
&gt;   - 例如：登入時輸入帳號密碼
&gt;   - 結果：系統知道你是 John
&gt;
&gt; - **授權（Authorization）**：決定「你能做什麼」
&gt;   - 例如：檢查 John 是否有管理員權限
&gt;   - 結果：允許或拒絕存取資源
&gt;
&gt; 順序是先認證再授權：
&gt; 1. 先確認你是 John
&gt; 2. 再檢查 John 有什麼權限

---

**下一篇：** [04-2. JWT 認證](./04-2)

---

最後更新：2025-12-17


---

> 作者: luk  
> URL: https://yoru-karu-blog-lalaluk-52581ac5e0cef170a3c8922c19182ecb6f7bd604.gitlab.io/posts/tutorial/fastapi/04-1/  

