This commit is contained in:
2026-10-03 12:37:38 +08:00
parent bcf3d3769c
commit 5fc1755c17
22 changed files with 1845 additions and 100 deletions
+51 -5
View File
@@ -2,7 +2,7 @@
輕量級單一登入(SSO)服務,實作 [OpenID Connect](https://openid.net/connect/) 協定。alterminal 扮演 **OpenID Provider(OP / Identity Provider)**,讓多個應用程式(Relying Party, RP)透過標準協定完成身分認證,實現「登入一次,處處可用」。
> **狀態:開發中。** 目前完成專案骨架(HTTP 服務、資料庫連線、健康檢查)、使用者帳號(CLI 建帳與重設密碼、argon2id 密碼雜湊)、登入/登出(HTML 登入頁+JSON API、Session Cookie)、應用程式與金鑰管理頁,以及 OIDC 核心(Discovery、Authorization Code Flow+PKCE、Token 端點、UserInfo、Refresh Token 輪替),其餘功能依下方 Roadmap 推進。
> **狀態:開發中。** 目前完成專案骨架(HTTP 服務、資料庫連線、健康檢查)、使用者帳號(CLI 建帳與重設密碼、argon2id 密碼雜湊、網頁自助更新密碼)、登入/登出(HTML 登入頁+JSON API、Session Cookie)、應用程式與金鑰管理頁,以及 OIDC 核心(Discovery、Authorization Code Flow+PKCE、Token 端點、UserInfo、Refresh Token 輪替、RP-Initiated Logout),其餘功能依下方 Roadmap 推進。
## 特色
@@ -37,12 +37,14 @@
| `/login` | POST | 使用者登入(JSON API 與 HTML 表單提交;表單支援 `next` 返回路徑) | ✅ 已完成 |
| `/login` | GET | 使用者登入頁(HTML 表單,供 `/authorize` 導向並以 `next` 攜回授權請求) | ✅ 已完成 |
| `/logout` | POST | 使用者登出(HTML 表單與 JSON API,冪等) | ✅ 已完成 |
| `/logout` | GET | RP-Initiated Logout(`id_token_hint` 驗證、無 hint 或不符目前 Session 時顯示確認頁;`post_logout_redirect_uri` 精確比對註冊值後重導並回填 `state`) | ✅ 已完成 |
| `/password` | GET | 更新密碼頁(登入者自助變更;未登入導向 `/login?next=/password`) | ✅ 已完成 |
| `/password` | POST | 更新密碼(驗證目前密碼;表單+CSRF 與 JSON API。成功後於交易內撤銷全部 Session,再輪替目前瀏覽器的 Session——其他裝置立即登出) | ✅ 已完成 |
| `/static/*` | GET | 靜態檔(Tailwind 建置輸出的 CSS,`go:embed` 內嵌) | ✅ 已完成 |
| `/authorize` | GET | 授權端點(Authorization Code Flow+PKCE;未登入導向 `/login?next=...`,首次授權顯示同意頁) | ✅ 已完成 |
| `/authorize` | POST | 同意頁決定(同意記錄於 `consents`,同範圍之後靜默通過;拒絕回 `access_denied`) | ✅ 已完成 |
| `/token` | POST | 權杖端點(`authorization_code`+PKCE 與 `refresh_token` 兩種 grant;Basic/POST client 認證) | ✅ 已完成 |
| `/userinfo` | GET/POST | 以 Bearer Access Token 取得使用者 claims(依授權 scope) | ✅ 已完成 |
| `/logout` | GET | RP-Initiated Logout(OIDC:`id_token_hint`、`post_logout_redirect_uri` 等參數驗證) | 🚧 規劃中 |
| `/admin/applications` | GET | 應用程式管理頁(RP 註冊列表;僅 admin) | ✅ 已完成 |
| `/admin/applications/new` | GET | 註冊新應用程式頁(獨立表單頁;僅 admin) | ✅ 已完成 |
| `/admin/applications/new` | POST | 註冊應用程式(明文 client_secret 僅於本次回應顯示一次,表單+CSRF) | ✅ 已完成 |
@@ -204,7 +206,51 @@ curl -i -X POST http://localhost:8080/logout \
- **JSON 流程**:成功回 `204`,無回應內容
- **表單流程**(瀏覽器):需通過 double-submit CSRF 驗證(與登入表單同一機制),成功後 `303` 導向 `/login`(PRG);CSRF 不符回 `403` 並重繪目前狀態頁
- 資料庫刪除失敗僅記錄,仍完成 Cookie 清除(Session 最遲於效期到期自動失效)
- 跨應用程式單一登出(Front-/Back-Channel Logout)與 RP-Initiated Logout(`GET /logout`,含 OIDC 參數驗證)列於 Roadmap
- 跨應用程式單一登出(Front-/Back-Channel Logout)列於 Roadmap
### 更新密碼
`GET/POST /password` 為登入者自助更新密碼(`/` 帳號首頁有入口連結),須持有效 Session;未登入時表單流程導向 `/login?next=/password`,JSON 流程回 `401`。與 `/login`、`/logout` 相同依 `Content-Type` 分流(JSON 與表單+CSRF):
```bash
curl -i -X POST http://localhost:8080/password \
-H 'Content-Type: application/json' \
-b 'alterminal_session=<Session ID>' \
-d '{"current_password":"sup3r-secret","new_password":"n3w-secret!"}'
# => 204 No Content,Set-Cookie 下發輪替後的新 Session
```
- 驗證目前密碼無誤後,以 argon2id 重新雜湊新密碼(新 salt),密碼更新與撤銷該帳號**全部 Session** 於同一資料庫交易內完成(與 CLI `update-password` 共用邏輯)
- 交易成功後為目前瀏覽器重建 Session(輪替 Session ID,避免 fixation)——其他裝置立即登出、本瀏覽器保持登入
- 表單流程成功後 `303` 導回 `/password?saved=1` 顯示成功訊息(PRG);驗證失敗以對應狀態碼重繪表單,密碼欄位不回填
錯誤回應 JSON 流程為 `{"error": "..."}`(表單流程顯示於頁面):
| 狀態碼 | 情境 |
| --- | --- |
| `400` | 欄位缺漏、新密碼短於 8 字元、表單兩次輸入的新密碼不一致、新密碼與目前密碼相同 |
| `401` | 未登入(JSON 流程)或目前的密碼錯誤 |
| `403` | 表單 CSRF 驗證失敗 |
| `415` | `Content-Type` 非 `application/json` 或表單 |
### RP-Initiated Logout
`GET /logout` 實作 [OpenID Connect RP-Initiated Logout 1.0](https://openid.net/specs/openid-connect-rpinitiated-1_0.html)(`POST` 亦支援,§2 要求),端點發佈於 Discovery 的 `end_session_endpoint`。RP 將使用者導向本端點並攜帶參數:
| 參數 | 說明 |
| --- | --- |
| `id_token_hint` | 先前取得的 ID Token。本服務驗證簽章與 `iss`(不檢查 `exp`——§2 要求 session 存在或近期存在時接受過期值),其 `aud` 用於辨識發起登出的 client;`sub` 用於比對目前 Session |
| `post_logout_redirect_uri` | 登出後返回 URI,須與該 client 註冊的 post_logout_redirect_uris **精確比對**(§3:未註冊值 MUST NOT 重導),於管理頁「登出後返回 URI」欄位註冊 |
| `client_id` | 無 `id_token_hint` 時辨識 client 用(供驗證返回 URI);與 hint 併用時須與其 `aud` 相符(§2) |
| `state` | 原值回填於重導查詢字串(§2) |
行為要點:
- **確認頁**(§2 MUST):未提供 `id_token_hint`、或 hint 的 `sub` 不屬於目前 Session 時,先顯示「您確定要登出嗎?」確認頁(CSRF 保護),避免跨站偽造的強迫登出;hint 屬於目前 Session 時直接登出。本服務 ID token 不含 `sid` claim,以 `sub` 比對 Session 使用者近似判斷
- **重導**:通過驗證時 `303` 至 `post_logout_redirect_uri`(附 `state`);指定但未通過註冊比對時**不重導**(§3),登出仍完成並顯示已登出頁與說明;未指定時 `303` 導向 `/login`(與本站既有登出一致)
- **錯誤**(§4):hint 無效、`client_id` 與 `aud` 不符等硬錯誤回 `400`,不執行登出也不重導
- 未登入時造訪為冪等操作——仍清除 Cookie 並依驗證結果重導
- 不帶 RP 參數的 `POST /logout`(側欄登出表單、JSON API)行為不變,由 `auth.LogoutHandler` 處理
### 登入頁面
@@ -298,7 +344,7 @@ Access Token 採用自包含的 JWT,不落庫儲存;其餘狀態儲存於 Po
| 資料表 | 說明 | 狀態 |
| --- | --- | --- |
| `users` | 使用者帳號(帳號、Email、密碼雜湊) | ✅ 已完成(含 argon2id 密碼雜湊) |
| `applications` | 已註冊的 RP 應用程式(client_id、client secret 雜湊、redirect URIs、grant types、scope、confidential/public) | 🚧 模型與管理頁已完成(`Application`:argon2id secret 雜湊、redirect URI 格式驗證;`/admin/applications` 註冊/編輯/輪替/刪除),註冊 API 規劃中 |
| `applications` | 已註冊的 RP 應用程式(client_id、client secret 雜湊、redirect URIs、登出後返回 URIs、grant types、scope、confidential/public) | 🚧 模型與管理頁已完成(`Application`:argon2id secret 雜湊、redirect URI 格式驗證;`/admin/applications` 註冊/編輯/輪替/刪除),註冊 API 規劃中 |
| `sessions` | 使用者瀏覽器 Session(SSO 核心,HttpOnly Cookie,效期 24 小時) | ✅ 已完成 |
| `authorization_codes` | 授權碼(SHA-256 雜湊儲存、一次性、效期 5 分鐘、凍結授權當下的 redirect URI/scope/nonce/PKCE challenge/auth_time) | ✅ 已完成 |
| `refresh_tokens` | Refresh Token(SHA-256 雜湊儲存、效期 30 天、兌換即輪替、重用時整鏈撤銷) | ✅ 已完成 |
@@ -316,7 +362,7 @@ Access Token 採用自包含的 JWT,不落庫儲存;其餘狀態儲存於 Po
- [x] Token 端點:Access Token(JWT/RS256)、ID Token、Refresh Token 簽發與驗證
- [x] UserInfo 端點(`/userinfo`)
- [x] Refresh Token 輪替與撤銷(重用偵測、整鏈撤銷)
- [ ] RP-Initiated Logout(`/logout`)
- [x] RP-Initiated Logout(`/logout`:`id_token_hint` 驗證、確認頁、`post_logout_redirect_uri` 註冊比對與 `state` 回填)
- [ ] Client Credentials Grant
- [ ] Front-Channel / Back-Channel Logout(跨 RP 單一登出)
- [ ] 管理 API 與簡易管理介面