iT邦幫忙

2026 iThome 鐵人賽

DAY 10
0
Modern Web

重寫一套比我還老的系統:21 歲的校園文字廣播系統系列 第 10 篇

Day 10|你走在路上最好是放心一點!我記住你了。

  • 分享至 

  • xImage
  •  

總之,我選了 session

在兩者的選擇中,最後還是選了 session。

而這次,我有了充分的理由不選JWT。

  1. 額外的傳輸成本

    JWT 除了作為登入憑證,也能在 Payload 中攜帶資訊。加上 Header 和 Signature,整個 Token 通常會比單純的 Session ID 胖上一些。

    當然,Payload 變長不代表簽章也會跟著變長;但放進去的資訊越多,每次請求要傳送的 Token 就可能越大。

    既然這次沒有非得把這些資訊帶在身上的需求,那這份額外的成本似乎也沒必要。

  2. 沒有跨服務的需求

    這次的新版數位校園,目前沒有讓其他服務獨立驗證 Token 的需求,也沒有什麼非得塞進 Token 的使用者資訊。

    如果只是要在前端顯示使用者身分,設置一個 /me 端點就能處理。當然,JWT 也可以搭配 /me,也不是只有跨服務才能用;只是對這次的專案來說,我實在找不到非用它不可的理由。

  3. 撤銷不易

    JWT 如果只靠簽章和到期時間驗證,發出去之後,就得等它自然過期;想提前作廢,還得另外設計撤銷機制。

    Stateless 是它的優勢,但在此刻卻變成了它的劣勢。

    我也曾試過在資料庫的 User 加一個 token_ver 欄位,並在 JWT 的 Payload 中另外放入 ver。

    驗證時比對兩者的版本是否匹配;想讓舊 Token 失效,就把資料庫中的 token_ver 加一。如此一來,舊版本便會對不上,也就達成了撤銷的效果。

    不過,這招通常會讓該使用者同一版本的 Token 一起失效。如果想單獨撤銷某一個 Token,還得另外設計。

    但這下好了,撤銷的問題解決了,但每次驗證又得查詢伺服器端狀態。原本看中的無狀態優勢,在我的實作裡也就沒了。

    既然都得維護伺服器端狀態,對這次的專案而言,直接用 Session 反而比較乾脆。

  4. 前端使用上的取捨

    JWT 的 Payload 能攜帶資訊,如果能直接讓 JS 讀取,感覺不用實在有點可惜。

    但如果想在前端直接取得、解碼整個 Token,就得讓 JS 能碰到它;這時候要怎麼保存 Token,以及發生 XSS 時憑證會不會被偷走,就成了另一個需要考慮的問題。

    當然,JWT 也完全可以放在 HttpOnly Cookie 裡,再透過 /me 取得前端需要的資料。只是這樣一來,對目前的新版數位校園而言,我又何必特地選 JWT 呢?

食之無味,棄之可惜。

兩個字!

雞肋!

雞肋!

呦你醒啦,正要叫你的說。

賴個床嘛,但剛剛那段我有聽到。

只能說,在放下對 Session 的偏見後,回頭看自己以前做過的作品,確實有不少情境,Session 反而可能是更合適的選擇。

所以,今天總算可以開始寫 Code 了吧?

可以是可以,不過要讓伺服器記住你,總得先想想 Session 要存在哪裡吧。

啊?你不是說選完就能開工了嗎?

對啊,這不就要開工了嗎?先來寫個存 Session 的資料表。

Table sessions {
  id uuid [pk]
  token_hash varchar [unique, not null]
  account_id uuid [ref: > accounts.id, not null]
  created_at timestamp [not null]
  last_used_at timestamp [not null]
  expires_at timestamp [not null]
  revoked_at timestamp
}

好先讓 Agent 把這個 Table 整合進去吧。

https://ithelp.ithome.com.tw/upload/images/20260924/20182031DxlNewC6N9.png

ok,看一下成果。

class SessionService:
    """Persist and manage hashed account-session tokens."""

    def __init__(self, session: AsyncSession) -> None:
        self.session = session

    async def create(
        self, *, account_id: UUID, token_hash: str, expires_at: datetime
    ) -> Session:
		    ...略
        return model

    async def get(self, session_id: UUID) -> Session:
        ...略
        return model

    async def get_by_token_hash(self, token_hash: str) -> Session:
        ...略
        return model

    async def get_active_by_token_hash(
        self, token_hash: str, *, now: Optional[datetime] = None
    ) -> Session:
        ...略
        return model

    async def list_for_account(
        self, account_id: UUID, *, limit: int = 100, offset: int = 0
    ) -> list[Session]:
        ...略
        return list(result)

    async def update(
        self,
        session_id: UUID,
        *,
        expires_at: datetime | object = _UNSET,
        last_used_at: datetime | object = _UNSET,
        revoked_at: Optional[datetime] | object = _UNSET,
    ) -> Session:
        ...略
        return model

    async def touch(self, session_id: UUID, *, used_at: Optional[datetime] = None) -> Session:
        return await self.update(
            session_id,
            last_used_at=_utc_naive(used_at)
            or datetime.now(timezone.utc).replace(tzinfo=None),
        )

    async def revoke(
        self, session_id: UUID, *, revoked_at: Optional[datetime] = None
    ) -> Session:
        return await self.update(
            session_id,
            revoked_at=_utc_naive(revoked_at)
            or datetime.now(timezone.utc).replace(tzinfo=None),
        )

    async def delete(self, session_id: UUID) -> None:
        ...略
        await _commit(self.session)

我看一下,嗯,有幾個函式是可以刪掉的,這樣也能減少後續 Agent 誤用的機會。

touch 要刪掉,delete 也要刪掉,get_by_token_hash 也刪掉吧。

為什麼要刪這三個?這三個不是各自有它們的功能嗎?

你說得對,它們確實各自有功能。

但資料庫「做得到」,不代表 Service 就一定要把每種操作都給出去。

先看 touch。在我們的設計中,每次更新 last_used_at 的同時,也需要更新 expires_at 來完成續期。既然這兩件事本來就應該一起發生,單獨提供一個只更新 last_used_at 的 touch,反而容易讓人誤以為呼叫它就完成了整個續期流程。

所以這裡先拿掉,需要時直接明確地更新兩個欄位。

再來是 delete。

我們已經有 revoke 可以讓一個 Session 失效,目前也沒有真的需要把 Session 紀錄直接從資料庫刪掉的業務需求。既然如此,就乾脆不要提供 delete,也省得之後維護時不小心用錯。

最後是 get_by_token_hash。

這個函式只要 token_hash 對得上,就會把 Session 找出來,不管它已經過期,還是早就被撤銷了。

但目前所有需要透過 Token 找 Session 的地方,我們要的其實都是「有效的 Session」。

既然已經有 get_active_by_token_hash 會順便檢查 revoked_at 和 expires_at,那目前就沒有留下前者的必要。

好複雜的想法。

其實也沒那麼複雜。

簡單來說,就是用不到的東西先不要留,能少一種用法,就少一種用錯的可能。

少做少錯?

是這樣沒錯。

好啦,Session 存的地方有了,Service 也整理完了。

這次真的可以來寫登入了。

https://ithelp.ithome.com.tw/upload/images/20260924/20182031mKf93uRKes.png

這邊我讓它先寫了 routes/auth.py,讓使用者可以進行基本的登入,然後寫 routes/user.py 讓使用者可以更改自己的密碼。

class AccountProfile(BaseModel):
    model_config = ConfigDict(from_attributes=True)

    id: UUID
    account: str
    position_id: UUID
    group_id: UUID
    display_name: str | None
    is_active: bool
    created_at: datetime
    updated_at: datetime

欸這契約好像怪怪的。

position_id 跟 group_id 對吧。

你給前端這個沒有用啊,人類不可讀阿。

確實,改一下。

class AccountProfile(BaseModel):
    model_config = ConfigDict(from_attributes=True)

    id: UUID
    account: str
    position_name: str
    group_name: str
    display_name: str | None
    is_active: bool
    created_at: datetime
    updated_at: datetime

這樣好多了。

async def get_current_principal(
    credentials: Annotated[
        HTTPAuthorizationCredentials | None, Depends(_bearer_scheme)
    ],
    database_session: Annotated[AsyncSession, Depends(get_session)],
) -> AuthenticatedPrincipal:
    """Resolve an active Bearer token to its enabled account and persisted session."""
    ...略
    await sessions.touch(current_session.id)
    return AuthenticatedPrincipal(
        account=account,
        session=current_session,
        position_name=position.name,
        group_name=group.name,
    )

等一下這裡怎麼有 sessions.touch,靠北只記得講,忘記刪了。

但也證明了我剛剛說要刪的決定是對的,真的很容易誤用。

算了改寫邏輯吧。

https://ithelp.ithome.com.tw/upload/images/20260924/20182031Jrddc2gtGo.png

歐?這裡 Agent 說只有滑動的 expires_at,要給我加一個 force_expires_at。

你本來是打算用 last_used_at 去計算對吧。

對,但它這樣做也行。

那一不做二不休,除了會隨著操作往後延的 expires_at,再給 Session 一條絕對不能超過的期限吧。

簡單來說,expires_at 負責回答「你多久沒出現,我就忘記你」;force_expires_at 則負責回答「就算你一直出現,我最晚什麼時候還是得叫你重新登入」。

這樣一來,Session 可以在使用者持續操作時續期,卻不會因此一路活到天荒地老。

所以你現在記住我了?

理論上,是。

理論上?

畢竟今天光是決定要怎麼記住你,就已經一路從 JWT 講到 Session、從資料表講到登入,再從滑動期限講到絕對期限了。

至於它實際跑起來,到底是不是真的記得住你——

明天再來驗收。

……所以你今天還是沒辦法保證?

沒關係,至少我已經用文字記住現在的我們了。


上一篇
Day 9|我去,不早說,cookie 被盜原來這麼簡單
下一篇
Day 11|來場酣暢淋漓的 Code Review吧,雖然不是我在 Code Review
系列文
重寫一套比我還老的系統:21 歲的校園文字廣播系統 共 20 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言