# 

# 04-3. OAuth 2.0

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

---

## 🤔 一句話解釋

**OAuth 2.0 是授權框架，讓第三方應用可以安全地存取使用者資源，而不需要知道使用者的密碼。**

---

## 🔍 OAuth 2.0 角色

```
┌─────────────────────────────────────────────────────────┐
│                   OAuth 2.0 角色                         │
├─────────────────────────────────────────────────────────┤
│                                                         │
│  Resource Owner（資源擁有者）                            │
│  └─ 就是「使用者」，擁有資源的人                          │
│                                                         │
│  Client（客戶端）                                        │
│  └─ 第三方應用程式，想要存取使用者資源                    │
│                                                         │
│  Authorization Server（授權伺服器）                      │
│  └─ 負責驗證身份、核發 Token                             │
│                                                         │
│  Resource Server（資源伺服器）                           │
│  └─ 存放使用者資源的伺服器                               │
│                                                         │
└─────────────────────────────────────────────────────────┘
```

### 實際例子

```
你想用「某 App」登入 Google 帳號

Resource Owner   = 你（使用者）
Client           = 某 App
Authorization Server = Google OAuth
Resource Server  = Google API（取得你的 email、姓名等）
```

---

## 🔄 授權流程

### Authorization Code Flow（最常用）

```
┌──────────────────────────────────────────────────────────────┐
│              Authorization Code Flow                          │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│  1. 使用者點擊「用 Google 登入」                              │
│     ┌──────┐                                                │
│     │ User │ ────────────────────────▶ ┌──────┐            │
│     └──────┘                           │Client│            │
│                                        └──┬───┘            │
│  2. 導向 Google 授權頁面                    │                │
│     ┌──────────────────────────────────────┘                │
│     │                                                        │
│     ▼                                                        │
│  3. ┌─────────────────┐                                     │
│     │ Google 授權頁面  │  ← 使用者登入並同意授權              │
│     └────────┬────────┘                                     │
│              │                                               │
│  4. 回傳 Authorization Code                                  │
│     ┌────────┘                                               │
│     │                                                        │
│     ▼                                                        │
│  5. ┌──────┐                    ┌────────────┐              │
│     │Client│ ── Code &#43; Secret ─▶│   Google   │              │
│     │      │ ◀── Access Token ──│Auth Server │              │
│     └──────┘                    └────────────┘              │
│                                                              │
│  6. 用 Access Token 存取 Google API                          │
│                                                              │
└──────────────────────────────────────────────────────────────┘
```

---

## 📦 安裝

```bash
pip install httpx          # HTTP 客戶端
pip install authlib        # OAuth 2.0 庫（可選）
```

---

## 🔧 Google OAuth 實作

### 1. 設定 Google Cloud Console

```
1. 前往 Google Cloud Console
2. 建立專案
3. 啟用 Google&#43; API
4. 建立 OAuth 2.0 憑證
5. 設定授權重導向 URI：http://localhost:8000/auth/google/callback
6. 記下 Client ID 和 Client Secret
```

### 2. 設定

```python
# app/core/config.py
from pydantic_settings import BaseSettings


class Settings(BaseSettings):
    # Google OAuth
    google_client_id: str
    google_client_secret: str
    google_redirect_uri: str = &#34;http://localhost:8000/auth/google/callback&#34;

    # JWT
    secret_key: str
    algorithm: str = &#34;HS256&#34;
    access_token_expire_minutes: int = 30

    class Config:
        env_file = &#34;.env&#34;


settings = Settings()
```

### 3. OAuth 工具

```python
# app/core/oauth.py
from urllib.parse import urlencode
import httpx
from app.core.config import settings


class GoogleOAuth:
    &#34;&#34;&#34;Google OAuth 2.0 工具&#34;&#34;&#34;

    AUTHORIZATION_URL = &#34;https://accounts.google.com/o/oauth2/v2/auth&#34;
    TOKEN_URL = &#34;https://oauth2.googleapis.com/token&#34;
    USERINFO_URL = &#34;https://www.googleapis.com/oauth2/v2/userinfo&#34;

    def __init__(self):
        self.client_id = settings.google_client_id
        self.client_secret = settings.google_client_secret
        self.redirect_uri = settings.google_redirect_uri

    def get_authorization_url(self, state: str | None = None) -&gt; str:
        &#34;&#34;&#34;取得授權 URL&#34;&#34;&#34;
        params = {
            &#34;client_id&#34;: self.client_id,
            &#34;redirect_uri&#34;: self.redirect_uri,
            &#34;response_type&#34;: &#34;code&#34;,
            &#34;scope&#34;: &#34;openid email profile&#34;,
            &#34;access_type&#34;: &#34;offline&#34;,  # 取得 refresh token
            &#34;prompt&#34;: &#34;consent&#34;,
        }

        if state:
            params[&#34;state&#34;] = state

        return f&#34;{self.AUTHORIZATION_URL}?{urlencode(params)}&#34;

    async def exchange_code(self, code: str) -&gt; dict:
        &#34;&#34;&#34;用 Authorization Code 換取 Token&#34;&#34;&#34;
        async with httpx.AsyncClient() as client:
            response = await client.post(
                self.TOKEN_URL,
                data={
                    &#34;client_id&#34;: self.client_id,
                    &#34;client_secret&#34;: self.client_secret,
                    &#34;code&#34;: code,
                    &#34;grant_type&#34;: &#34;authorization_code&#34;,
                    &#34;redirect_uri&#34;: self.redirect_uri,
                },
            )
            response.raise_for_status()
            return response.json()

    async def get_user_info(self, access_token: str) -&gt; dict:
        &#34;&#34;&#34;取得使用者資訊&#34;&#34;&#34;
        async with httpx.AsyncClient() as client:
            response = await client.get(
                self.USERINFO_URL,
                headers={&#34;Authorization&#34;: f&#34;Bearer {access_token}&#34;},
            )
            response.raise_for_status()
            return response.json()


google_oauth = GoogleOAuth()
```

### 4. OAuth 端點

```python
# app/api/routes/oauth.py
from fastapi import APIRouter, HTTPException, Depends
from fastapi.responses import RedirectResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
import secrets

from app.core.oauth import google_oauth
from app.core.security import create_access_token, create_refresh_token
from app.database import get_db
from app.models import User

router = APIRouter(prefix=&#34;/auth&#34;, tags=[&#34;oauth&#34;])


@router.get(&#34;/google/login&#34;)
async def google_login():
    &#34;&#34;&#34;導向 Google 登入頁面&#34;&#34;&#34;
    # 產生 state 防止 CSRF
    state = secrets.token_urlsafe(32)

    # 實際專案中應該將 state 存到 Session 或 Redis
    # 這裡簡化處理

    authorization_url = google_oauth.get_authorization_url(state=state)
    return RedirectResponse(url=authorization_url)


@router.get(&#34;/google/callback&#34;)
async def google_callback(
    code: str,
    state: str | None = None,
    db: AsyncSession = Depends(get_db)
):
    &#34;&#34;&#34;Google OAuth 回調&#34;&#34;&#34;
    try:
        # 1. 用 code 換取 token
        token_data = await google_oauth.exchange_code(code)
        access_token = token_data[&#34;access_token&#34;]

        # 2. 取得使用者資訊
        user_info = await google_oauth.get_user_info(access_token)

        # 3. 查詢或建立使用者
        user = await get_or_create_user(db, user_info)

        # 4. 建立我們自己的 JWT Token
        jwt_access_token = create_access_token(subject=user.id)
        jwt_refresh_token = create_refresh_token(subject=user.id)

        # 5. 返回 Token（實際專案可能會重導向到前端並帶上 token）
        return {
            &#34;access_token&#34;: jwt_access_token,
            &#34;refresh_token&#34;: jwt_refresh_token,
            &#34;token_type&#34;: &#34;bearer&#34;,
            &#34;user&#34;: {
                &#34;id&#34;: user.id,
                &#34;email&#34;: user.email,
                &#34;name&#34;: user.username,
            }
        }

    except Exception as e:
        raise HTTPException(status_code=400, detail=str(e))


async def get_or_create_user(db: AsyncSession, user_info: dict) -&gt; User:
    &#34;&#34;&#34;根據 OAuth 資訊取得或建立使用者&#34;&#34;&#34;
    email = user_info[&#34;email&#34;]

    # 查詢現有使用者
    stmt = select(User).where(User.email == email)
    result = await db.execute(stmt)
    user = result.scalar_one_or_none()

    if user:
        return user

    # 建立新使用者
    user = User(
        email=email,
        username=user_info.get(&#34;name&#34;, email.split(&#34;@&#34;)[0]),
        hashed_password=&#34;&#34;,  # OAuth 使用者不需要密碼
        is_active=True,
        oauth_provider=&#34;google&#34;,
        oauth_id=user_info[&#34;id&#34;],
    )
    db.add(user)
    await db.commit()
    await db.refresh(user)

    return user
```

---

## 🔐 GitHub OAuth

### GitHub OAuth 工具

```python
# app/core/oauth.py
class GitHubOAuth:
    &#34;&#34;&#34;GitHub OAuth 2.0 工具&#34;&#34;&#34;

    AUTHORIZATION_URL = &#34;https://github.com/login/oauth/authorize&#34;
    TOKEN_URL = &#34;https://github.com/login/oauth/access_token&#34;
    USERINFO_URL = &#34;https://api.github.com/user&#34;

    def __init__(self):
        self.client_id = settings.github_client_id
        self.client_secret = settings.github_client_secret
        self.redirect_uri = settings.github_redirect_uri

    def get_authorization_url(self, state: str | None = None) -&gt; str:
        &#34;&#34;&#34;取得授權 URL&#34;&#34;&#34;
        params = {
            &#34;client_id&#34;: self.client_id,
            &#34;redirect_uri&#34;: self.redirect_uri,
            &#34;scope&#34;: &#34;read:user user:email&#34;,
        }

        if state:
            params[&#34;state&#34;] = state

        return f&#34;{self.AUTHORIZATION_URL}?{urlencode(params)}&#34;

    async def exchange_code(self, code: str) -&gt; dict:
        &#34;&#34;&#34;用 Authorization Code 換取 Token&#34;&#34;&#34;
        async with httpx.AsyncClient() as client:
            response = await client.post(
                self.TOKEN_URL,
                data={
                    &#34;client_id&#34;: self.client_id,
                    &#34;client_secret&#34;: self.client_secret,
                    &#34;code&#34;: code,
                },
                headers={&#34;Accept&#34;: &#34;application/json&#34;},
            )
            response.raise_for_status()
            return response.json()

    async def get_user_info(self, access_token: str) -&gt; dict:
        &#34;&#34;&#34;取得使用者資訊&#34;&#34;&#34;
        async with httpx.AsyncClient() as client:
            # 取得基本資訊
            response = await client.get(
                self.USERINFO_URL,
                headers={
                    &#34;Authorization&#34;: f&#34;Bearer {access_token}&#34;,
                    &#34;Accept&#34;: &#34;application/json&#34;,
                },
            )
            response.raise_for_status()
            user_data = response.json()

            # 取得 email（可能需要額外請求）
            if not user_data.get(&#34;email&#34;):
                email_response = await client.get(
                    &#34;https://api.github.com/user/emails&#34;,
                    headers={
                        &#34;Authorization&#34;: f&#34;Bearer {access_token}&#34;,
                        &#34;Accept&#34;: &#34;application/json&#34;,
                    },
                )
                emails = email_response.json()
                primary_email = next(
                    (e[&#34;email&#34;] for e in emails if e[&#34;primary&#34;]),
                    None
                )
                user_data[&#34;email&#34;] = primary_email

            return user_data


github_oauth = GitHubOAuth()
```

---

## 🔄 多 Provider 整合

### 統一介面

```python
# app/core/oauth.py
from abc import ABC, abstractmethod


class OAuthProvider(ABC):
    &#34;&#34;&#34;OAuth Provider 抽象基類&#34;&#34;&#34;

    @abstractmethod
    def get_authorization_url(self, state: str | None = None) -&gt; str:
        pass

    @abstractmethod
    async def exchange_code(self, code: str) -&gt; dict:
        pass

    @abstractmethod
    async def get_user_info(self, access_token: str) -&gt; dict:
        pass


class OAuthManager:
    &#34;&#34;&#34;OAuth 管理器&#34;&#34;&#34;

    providers: dict[str, OAuthProvider] = {}

    @classmethod
    def register(cls, name: str, provider: OAuthProvider):
        cls.providers[name] = provider

    @classmethod
    def get(cls, name: str) -&gt; OAuthProvider:
        if name not in cls.providers:
            raise ValueError(f&#34;Unknown OAuth provider: {name}&#34;)
        return cls.providers[name]


# 註冊 providers
OAuthManager.register(&#34;google&#34;, GoogleOAuth())
OAuthManager.register(&#34;github&#34;, GitHubOAuth())
```

### 通用端點

```python
# app/api/routes/oauth.py
@router.get(&#34;/{provider}/login&#34;)
async def oauth_login(provider: str):
    &#34;&#34;&#34;導向 OAuth 登入頁面&#34;&#34;&#34;
    try:
        oauth_provider = OAuthManager.get(provider)
    except ValueError:
        raise HTTPException(status_code=400, detail=f&#34;Unknown provider: {provider}&#34;)

    state = secrets.token_urlsafe(32)
    authorization_url = oauth_provider.get_authorization_url(state=state)
    return RedirectResponse(url=authorization_url)


@router.get(&#34;/{provider}/callback&#34;)
async def oauth_callback(
    provider: str,
    code: str,
    state: str | None = None,
    db: AsyncSession = Depends(get_db)
):
    &#34;&#34;&#34;OAuth 回調&#34;&#34;&#34;
    try:
        oauth_provider = OAuthManager.get(provider)
    except ValueError:
        raise HTTPException(status_code=400, detail=f&#34;Unknown provider: {provider}&#34;)

    # 1. 換取 token
    token_data = await oauth_provider.exchange_code(code)
    access_token = token_data[&#34;access_token&#34;]

    # 2. 取得使用者資訊
    user_info = await oauth_provider.get_user_info(access_token)

    # 3. 取得或建立使用者
    user = await get_or_create_oauth_user(db, provider, user_info)

    # 4. 建立 JWT
    jwt_access_token = create_access_token(subject=user.id)
    jwt_refresh_token = create_refresh_token(subject=user.id)

    return {
        &#34;access_token&#34;: jwt_access_token,
        &#34;refresh_token&#34;: jwt_refresh_token,
        &#34;token_type&#34;: &#34;bearer&#34;
    }
```

---

## 🔗 帳號連結

### User Model 擴充

```python
from sqlalchemy import Column, Integer, String, Boolean, ForeignKey
from sqlalchemy.orm import relationship


class User(Base):
    &#34;&#34;&#34;使用者&#34;&#34;&#34;
    __tablename__ = &#34;users&#34;

    id = Column(Integer, primary_key=True)
    email = Column(String(100), unique=True, nullable=False)
    username = Column(String(50), unique=True, nullable=False)
    hashed_password = Column(String(255), nullable=True)  # OAuth 使用者可為空
    is_active = Column(Boolean, default=True)

    # OAuth 連結
    oauth_accounts = relationship(&#34;OAuthAccount&#34;, back_populates=&#34;user&#34;)


class OAuthAccount(Base):
    &#34;&#34;&#34;OAuth 帳號連結&#34;&#34;&#34;
    __tablename__ = &#34;oauth_accounts&#34;

    id = Column(Integer, primary_key=True)
    user_id = Column(Integer, ForeignKey(&#34;users.id&#34;), nullable=False)
    provider = Column(String(20), nullable=False)  # google, github, etc.
    provider_user_id = Column(String(100), nullable=False)
    access_token = Column(String(500), nullable=True)
    refresh_token = Column(String(500), nullable=True)

    user = relationship(&#34;User&#34;, back_populates=&#34;oauth_accounts&#34;)

    __table_args__ = (
        # 每個 provider 只能連結一次
        UniqueConstraint(&#34;provider&#34;, &#34;provider_user_id&#34;),
    )
```

### 連結現有帳號

```python
@router.post(&#34;/link/{provider}&#34;)
async def link_oauth_account(
    provider: str,
    code: str,
    current_user: User = Depends(get_current_active_user),
    db: AsyncSession = Depends(get_db)
):
    &#34;&#34;&#34;連結 OAuth 帳號到現有使用者&#34;&#34;&#34;
    oauth_provider = OAuthManager.get(provider)

    # 取得 OAuth 資訊
    token_data = await oauth_provider.exchange_code(code)
    user_info = await oauth_provider.get_user_info(token_data[&#34;access_token&#34;])

    # 檢查是否已被其他使用者連結
    stmt = select(OAuthAccount).where(
        OAuthAccount.provider == provider,
        OAuthAccount.provider_user_id == user_info[&#34;id&#34;]
    )
    existing = await db.execute(stmt)
    if existing.scalar_one_or_none():
        raise HTTPException(
            status_code=400,
            detail=&#34;This account is already linked to another user&#34;
        )

    # 建立連結
    oauth_account = OAuthAccount(
        user_id=current_user.id,
        provider=provider,
        provider_user_id=user_info[&#34;id&#34;],
        access_token=token_data.get(&#34;access_token&#34;),
        refresh_token=token_data.get(&#34;refresh_token&#34;),
    )
    db.add(oauth_account)
    await db.commit()

    return {&#34;message&#34;: f&#34;Successfully linked {provider} account&#34;}
```

---

## ✅ 重點總結

### OAuth 2.0 角色

| 角色 | 說明 |
|------|------|
| Resource Owner | 使用者 |
| Client | 你的應用程式 |
| Authorization Server | Google/GitHub OAuth |
| Resource Server | Google/GitHub API |

### 常見 Flow

| Flow | 適用場景 |
|------|----------|
| Authorization Code | Web 應用（後端） |
| PKCE | SPA、行動 App |
| Client Credentials | 服務對服務 |

### 實作要點

1. 使用 state 防止 CSRF
2. 安全儲存 Client Secret
3. 用 OAuth Token 取得使用者資訊
4. 建立自己的 JWT Token

---

## 🎤 面試這樣答

### Q: OAuth 2.0 的授權流程是什麼？

**答案：**

&gt; Authorization Code Flow（最常用）：
&gt;
&gt; 1. **使用者點擊登入** → 導向授權伺服器
&gt; 2. **使用者同意授權** → 授權伺服器回傳 Authorization Code
&gt; 3. **後端用 Code 換 Token** → 發送 Code &#43; Client Secret
&gt; 4. **取得 Access Token** → 用來呼叫 API
&gt; 5. **呼叫資源伺服器 API** → 取得使用者資料
&gt;
&gt; ```python
&gt; # 步驟 3-5 的簡化程式碼
&gt; token = await oauth.exchange_code(code)
&gt; user_info = await oauth.get_user_info(token[&#34;access_token&#34;])
&gt; ```

---

**上一篇：** [04-2. JWT 認證](./04-2)
**下一篇：** [04-4. 權限控制](./04-4)

---

最後更新：2025-12-17


---

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

