用 FastAPI 手刻 JWT 登入與 防暴力破解 機制

別讓你的 API 裸奔!
這年頭用框架(像是 FastAPI 或 Flask)把一支 API 開起來,大概只需要 5 分鐘。但你知道嗎?只要你的網站一部署到公網上,不用半天,就會有一堆不知道哪來的機器人跟腳本,開始瘋狂戳你的 /login 端點,試圖暴力破解你的密碼。
與其依賴第三方的黑箱套件,今天我想聊聊我是如何在個人專案中,從零開始手刻一套 JWT 驗證 加上 IP 防暴力破解(Rate Limiting) 的防禦機制。
這不只是為了資安,更是後端工程師的一種浪漫(與控制欲)。
🛡️ 第一道防線:拒絕明碼,用 Bcrypt 加鹽雜湊
首先,密碼絕對不能明碼存在資料庫裡,這應該是業界常識了。在 FastAPI 裡面,我習慣用 passlib 這個套件來處理 bcrypt 加密。
Bcrypt 厲害的地方在於它自帶「加鹽(Salt)」機制,就算兩個使用者的密碼都是 123456,存進資料庫的亂碼也會長得完全不一樣,直接廢掉駭客的彩虹表攻擊。
from passlib.context import CryptContext
# 宣告加密上下文,指定使用 bcrypt
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password, hashed_password):
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password):
return pwd_context.hash(password)
🎟️ 第二道防線:JWT (JSON Web Token) 簽發
驗證完密碼後,我們不能每次都叫使用者重新輸入帳密,這時候就需要發一張「通行證」,也就是 JWT。
我使用了 jose 套件來簽發 Token,裡面會夾帶使用者的身分(sub)以及過期時間(exp)。
import datetime
from jose import jwt
SECRET_KEY = "你的超級機密字串" # 記得用環境變數 .env 藏好!
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
def create_access_token(data: dict):
to_encode = data.copy()
# 設定 30 分鐘後過期
expire = datetime.datetime.utcnow() + datetime.timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
# 簽發 Token
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
🛑 終極防線:手刻「防暴力破解」機制 (Rate Limiting)
這是我覺得整個登入系統最精華的地方!
如果駭客拿著密碼字典檔狂撞你的 API,就算密碼很複雜,伺服器的資源也會被耗盡。所以我們要寫一個邏輯:「只要同一個 IP 在短時間內登入失敗太多次,就直接送他一個 429 錯誤(Too Many Requests),並暫時封鎖他。」
為此,我先在 PostgreSQL 建了一個 LoginLogModel 來記錄每一次的登入嘗試:
class LoginLogModel(Base):
__tablename__ = "login_logs"
id = Column(Integer, primary_key=True, index=True)
ip_address = Column(String) # 紀錄來自哪個 IP
username_attempt = Column(String) # 嘗試登入的帳號
is_success = Column(Boolean) # 成功或失敗
attempt_time = Column(TIMESTAMP, server_default=func.now())
接著,在登入的 API 端點 /api/login 裡面,加上這段攔截邏輯:
@app.post("/api/login")
def login_for_access_token(request: Request, form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
client_ip = request.client.host
# 1. 抓出這個 IP 在過去 10 分鐘內的「失敗」次數
ten_mins_ago = datetime.datetime.utcnow() - datetime.timedelta(minutes=10)
failed_attempts = db.query(LoginLogModel).filter(
LoginLogModel.ip_address == client_ip,
LoginLogModel.is_success == False,
LoginLogModel.attempt_time >= ten_mins_ago
).count()
# 2. 如果失敗超過 3 次,直接擋掉,連密碼都不用比對了
if failed_attempts >= 3:
# 把這次被阻擋的嘗試也記錄下來
log = LoginLogModel(ip_address=client_ip, username_attempt=form_data.username, is_success=False)
db.add(log)
db.commit()
raise HTTPException(
status_code=429, # 429 Too Many Requests
detail=f"失敗次數過多 ({failed_attempts + 1})。請稍後再試。",
)
# 3. 驗證帳號密碼
admin = db.query(AdminModel).filter(AdminModel.username == form_data.username).first()
if not admin or not verify_password(form_data.password, admin.hashed_password):
# 密碼錯誤:寫入失敗日誌
log = LoginLogModel(ip_address=client_ip, username_attempt=form_data.username, is_success=False)
db.add(log)
db.commit()
raise HTTPException(status_code=401, detail="帳號或密碼錯誤")
# 4. 登入成功:寫入成功日誌並發放 JWT
log = LoginLogModel(ip_address=client_ip, username_attempt=form_data.username, is_success=True)
db.add(log)
db.commit()
access_token = create_access_token(data={"sub": admin.username})
return {"access_token": access_token, "token_type": "bearer"}
🍻 開發小插曲:不小心把自己鎖在門外
這套機制上線後非常有效,可以清楚在資料庫看到各種想測密碼的奇怪 IP 被 429 擋在外面。
但說個好笑的,在開發測試階段,因為我常常打錯密碼或重啟伺服器測試,結果這個防護機制盡責到把我自己的本機 IP 也給 Ban 掉了(笑)。
這也難怪大家在開發這類 Rate Limiting 功能時,常常得在程式碼裡加上一段 if client_ip == "127.x.x.x": return 的後門,或是乾脆先整段註解掉。
總而言之,自己把底層的認證與防禦邏輯刻過一遍,比起只會 call Firebase 或是 Auth0 的 API,你會對系統的安全邊界有更深刻的掌握。你的 API 做好防護了嗎?趕快去加上這道鎖吧!
