iT邦幫忙

2026 iThome 鐵人賽

DAY 26
0

上一篇我們把不合法的資料擋在外面了。

但是仔細想想,現在的 API 還有一個更根本的問題:

任何人只要知道網址,就可以對我們的資料做任何事。

隨便一個人打:

DELETE /tasks/1

資料就沒了,而且我們完全不知道是誰刪的。

今天就來認識 API 安全最基本的兩個概念。

Authentication 和 Authorization

這兩個字長得很像,但問的是不同的問題:

Authentication(驗證身分)
問的是:你是誰?

Authorization(授權)
問的是:你可以做什麼?

用進公司來想像:

刷卡進大門 → 證明「我是這間公司的人」= Authentication
卡片只能開 3 樓的門 → 決定「我可以去哪裡」= Authorization

順序是先驗證身分,再看權限。

如果沒有先知道你是誰,就沒辦法判斷你可以做什麼。

再把 Day 25 的 Validation 一起放進來比較,三個剛好是不同層次的問題:

Validation → 你傳的東西格式對不對?
Authentication → 你是誰?
Authorization → 你有沒有資格做這件事?

三個都要做,少一個都不行。
資料格式檢查得再完整,沒有身分驗證的話,任何人還是可以把資料刪光。

常見的身分驗證方式

先簡單認識幾種,知道有這些東西就好:

API Key
每個使用者(或每個程式)拿到一把金鑰,每次請求都帶上。
最簡單,常用在「程式對程式」的串接。

Session + Cookie
登入之後,伺服器記住這個人,瀏覽器用 Cookie 帶著 session id。
傳統網站很常見。

JWT(JSON Web Token)
登入後拿到一段 token,之後每次請求都帶著它。
前後端分離的專案很常用。

OAuth
像是「使用 Google 帳號登入」那種,把驗證交給第三方。

這次因為是入門,我們用最單純的 API Key 來實作,重點是先體會「有沒有身分驗證」的差別。

開始實作:API Key

目標是:

沒有帶金鑰的請求,一律不能新增、修改、刪除。

步驟 1:準備金鑰

先在程式最上面多匯入兩個東西:

import os
from functools import wraps

然後準備金鑰:

API_KEYS = {
    os.environ.get('USER_API_KEY', 'user-key-12345'): 'user',
    os.environ.get('ADMIN_API_KEY', 'admin-key-67890'): 'admin',
}

這裡用 os.environ.get() 從環境變數讀,讀不到才用預設值。

為什麼不直接寫死在程式裡?

因為程式通常會進版控(Git),金鑰寫在裡面就等於公開了。
正式的做法會放在環境變數或專門的設定管理工具,程式裡只留一個「讀取」的動作。

(我們現在寫預設值只是為了方便練習,真的要上線的話不能這樣。)

步驟 2:讓 Swagger 知道有這個機制

Flask-RESTX 可以在建立 Api 的時候設定:

authorizations = {
    'apikey': {
        'type': 'apiKey',
        'in': 'header',
        'name': 'X-API-KEY',
    }
}

api = Api(
    app,
    title='Task API',
    version='1.0',
    description='30 天鐵人賽練習用的作業管理 API',
    authorizations=authorizations,
)

這段的意思是:

我們的 API 會用一個叫做 X-API-KEY 的 Header 來驗證身分。

加上去之後,Swagger 首頁右上角會多出一顆 Authorize 按鈕,
而且需要金鑰的 API 右邊會出現一個小鎖頭。

https://ithelp.ithome.com.tw/upload/images/20261010/20183865f0aEtJMbqn.png

右上角的 Authorize 按鈕,POST、PUT、DELETE 右邊都有鎖頭,GET 沒有

步驟 3:寫一個檢查金鑰的 decorator

前面幾天我們一直在用 @api.route、@api.expect 這些 decorator,
現在換我們自己寫一個。

def require_api_key(role=None):
    """檢查 X-API-KEY,role 可以指定需要的身分"""
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            key = request.headers.get('X-API-KEY')

            # Authentication:你是誰?
            if not key or key not in API_KEYS:
                api.abort(401, "請提供有效的 X-API-KEY")

            # Authorization:你可以做這件事嗎?
            if role and API_KEYS[key] != role:
                api.abort(403, "你沒有權限執行這個操作")

            return func(*args, **kwargs)
        return wrapper
    return decorator

幾個重點:

request.headers.get('X-API-KEY')
從 Request 的 Header 取出金鑰。
Header 是 Request 裡面放「附加資訊」的地方,跟 Body 不一樣。

@wraps(func)
這行不能少。
它會把原本函式的名字和 docstring 複製到 wrapper 上。
少了它,Flask-RESTX 在產生 Swagger 文件的時候讀不到 docstring,
那幾個 API 的說明文字會整個消失(變成空白,不是變成別的東西)。

401 和 403 的差別

401 Unauthorized → 我不知道你是誰(沒帶金鑰、金鑰無效)
403 Forbidden → 我知道你是誰,但你不能做這件事

這是很多人會搞混的地方。
簡單記:401 是「你還沒證明身分」,403 是「你證明了,但不行」。

步驟 4:掛到 API 上

@api.route('/tasks')
class TaskList(Resource):
    @api.marshal_list_with(task_response)
    def get(self):
        """取得所有作業"""
        ...

    @api.doc(security='apikey')
    @api.expect(task_input, validate=True)
    @api.marshal_with(task_response, code=201)
    @api.response(400, '輸入資料有問題')
    @api.response(401, '沒有提供有效的金鑰')
    @require_api_key()
    def post(self):
        """新增一筆作業"""
        ...

@api.route('/tasks/<int:task_id>')
class TaskDetail(Resource):
    @api.doc(security='apikey')
    @api.response(204, '刪除成功')
    @api.response(403, '需要管理員權限')
    @require_api_key(role='admin')
    def delete(self, task_id):
        """刪除指定編號的作業(只有管理員可以)"""
        ...

這裡的設計是:

GET → 誰都可以看
POST、PUT → 要有金鑰(不管是 user 還是 admin)
DELETE → 只有 admin 可以

@api.doc(security='apikey') 是讓 Swagger 在這個 API 上顯示那個小鎖頭。

實際測試

重新啟動,打開 Swagger。

測試 1:沒帶金鑰就新增

先不要按 Authorize,直接用 POST /tasks 送資料:

Code:401

{
  "message": "請提供有效的 X-API-KEY"
}

沒有金鑰時新增失敗,回傳 401

https://ithelp.ithome.com.tw/upload/images/20261010/201838656Y9KWZJiVM.png

測試 2:帶一把錯的金鑰

如果金鑰亂打(例如 wrong-key),結果一樣是 401。

因為對 API 來說,「沒有金鑰」和「金鑰不對」都是同一件事:我不認識你。

測試 3:帶上正確的金鑰

按右上角的 Authorize,輸入:

user-key-12345

按 Authorize → Close

https://ithelp.ithome.com.tw/upload/images/20261010/20183865yfgQM5DOfL.png

在 Authorize 視窗輸入金鑰

關掉視窗之後會發現,剛才那些小鎖頭從「開著」變成「鎖上」了,代表 Swagger 之後會自動幫我們帶上這個 Header。

再送一次 POST /tasks:

這次成功了,回傳 201。

https://ithelp.ithome.com.tw/upload/images/20261010/20183865FtGEJyLV81.png

帶金鑰之後新增成功

測試 4:用一般使用者刪除

現在用 user-key-12345 去打 DELETE /tasks/{id}:

Code:403

{
  "message": "你沒有權限執行這個操作"
}

一般使用者刪除時回傳 403

https://ithelp.ithome.com.tw/upload/images/20261010/20183865JCytE43TLm.png

注意 Curl 那一區會看到 Swagger 確實有幫我們帶上:

  • H 'X-API-KEY: user-key-12345'

所以這次不是「沒帶金鑰」,而是「帶了但身分不夠」—— 剛好就是 401 和 403 的差別。

測試 5:換成管理員

重新按 Authorize(如果已經登入過,會看到 Logout 按鈕,先登出再輸入新的),改成:

admin-key-67890

再刪一次,這次就成功了,Code 是 204。

https://ithelp.ithome.com.tw/upload/images/20261010/20183865ThGINuIVgh.png

管理員刪除成功

測試 6:GET 不受影響

最後確認一下,GET /tasks 不帶任何金鑰還是可以正常查詢(200)。

因為我們沒有在 get() 上面加 @require_api_key()。

整理一下這六個測試:

GET 不帶金鑰 → 200
POST 不帶金鑰 → 401
POST 錯誤金鑰 → 401
POST user 金鑰 → 201
DELETE user 金鑰 → 403
DELETE admin 金鑰 → 204

這樣就把 Authentication 和 Authorization 的差別實際跑過一次了:

401 → 你是誰我不知道
403 → 我知道你是誰,但你不行

還有哪些事情要注意?

API Key 只是入門,實際上還有很多,先知道有這些:

一定要用 HTTPS
如果是 HTTP,金鑰在網路上是「明文」傳送的,被攔截就沒意義了。

密碼絕對不能明文存進資料庫
如果之後要做使用者註冊登入,密碼要用雜湊(hash)存。
Python 可以用 werkzeug.security 的 generate_password_hash 和 check_password_hash。

敏感資訊不要放在網址上
像 /login?password=1234 這種寫法,密碼會被記錄在瀏覽器歷史和伺服器 log 裡。
所以 Day 13 才會說,要傳的資料放在 Request Body 比較好。

金鑰和密碼不要進版控
記得加 .gitignore,設定用環境變數。

錯誤訊息不要洩漏太多
例如登入失敗時,回「帳號或密碼錯誤」就好,不要回「這個帳號不存在」,
不然等於幫攻擊者確認哪些帳號是存在的。

不要自己發明驗證機制
如果要做完整的登入,建議用成熟的套件(例如 Flask-JWT-Extended),
自己手寫加密或 token 很容易出錯。

今天認識了:

Authentication(你是誰)和 Authorization(你可以做什麼)的差別
用 API Key 實作最基本的身分驗證
401 和 403 的差別
自己寫 decorator,記得加 @wraps
用 Flask-RESTX 的 authorizations 讓 Swagger 也能測試
金鑰要放環境變數、要用 HTTPS、密碼要雜湊

不過安全這件事,不是只有「誰可以進來」而已。

就算是有權限的使用者,也可能在輸入的資料裡動手腳。

下一篇就來看一個很經典、而且到現在都還一直在發生的攻擊方式:SQL Injection。


上一篇
Day 25|使用者亂傳資料怎麼辦?
下一篇
Day 27| SQL Injection 是什麼?
系列文
從零開始的後端開發:用 Flask 實作 REST API,搞懂 API 與資料庫之間如何協作 共 29 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言