Files
alterminal/.zcode/plans/plan-sess_19b16e30-771d-4984-86df-9e55b06b287b.md
T
2026-10-03 10:44:29 +08:00

100 lines
9.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# OIDC 核心實作計畫:Discovery、/authorize、/token、/userinfo、Refresh Token
範圍(已確認):完整授權碼流程 + PKCE、refresh token 輪替與重用偵測、/userinfo;同意頁採「首次同意後記住」。全部新增程式碼以繁體中文註解並引用 RFC 條號,JSON 回應統一 `auth.WriteJSON`/`auth.WriteError`。
## 0. 關鍵前置:套件循環調整
- 新模型(見 §1)放 `internal/oidc`;`store.Open()` 的 AutoMigrate 加入它們 → `store` 匯入 `oidc`。
- `internal/oidc/jwks_test.go` 改為外部測試套件 `package oidc_test`(否則 `oidc(測試) → testdb → store → oidc` 形成循環)。helper(`jwksGet`、`mustNewKey`)在 `package oidc_test` 內跨檔共用不受影響;純邏輯測試(JWT、hash helper)可留 `package oidc` 內部測試,兩者可共存。
## 1. 新資料模型(internal/oidc/model.go)
授權碼與 refresh token 皆為高熵亂數、兌換時無 key 可查(不同于 client secret 有 client_id 可查),故存 **SHA-256 雜湊**(base64url,43 字元)供索引查詢,明文只在回應中出現一次:
```go
// AuthorizationCode:一次性授權碼,TTL 5 分鐘(RFC 6749 §4.1.2 建議最長 10 分鐘)
type AuthorizationCode struct {
ID uint; CodeHash string `gorm:"uniqueIndex;size:43;not null"`
ApplicationID, UserID uint `gorm:"not null;index"`
RedirectURI string; Scope string; Nonce string
CodeChallenge string; CodeChallengeMethod string // PKCE;public client 必有
ExpiresAt time.Time `gorm:"not null"`; UsedAt *time.Time
CreatedAt, UpdatedAt time.Time
}
// RefreshToken:TTL 30 天;RotatedAt=已輪替、RevokedAt=已撤銷
type RefreshToken struct {
ID uint; TokenHash string `gorm:"uniqueIndex;size:43;not null"`
ApplicationID, UserID uint; Scope string
ExpiresAt time.Time; RotatedAt *time.Time; RevokedAt *time.Time
CreatedAt, UpdatedAt time.Time
}
// Consent:使用者×應用 的已同意 scope 聯集,(user_id, application_id) 唯一索引
type Consent struct {
ID, UserID, ApplicationID uint; Scope string; CreatedAt, UpdatedAt time.Time
}
```
輔助函式:`sha256Token(s)`、`NewAuthorizationCode(db, ...)`(產碼+順帶刪過期碼,仿 `CreateSession` 的 best-effort 清理)、`NewRefreshToken(db, ...)`、`ConsumeAuthorizationCode(db, hash)`(`WHERE used_at IS NULL` 條件更新防 race,affected=0 視為重用)。
## 2. JWT 簽發/驗證(internal/oidc/jwt.go,純 stdlib)
沿用專案「零第三方 JWT 庫、手作 JWK」方針:`SignJWT(key *jwk.SigningKey, claims map[string]any) (string, error)` 組 `{"alg":"RS256","kid":...,"typ":"JWT"}` header+claims,`rsa.SignPKCS1v15(SHA-256)` 簽章,三段 base64url。驗證端 `VerifyAccessToken(db, issuer, token) (*AccessTokenClaims, error)`:alg 固定 RS256(拒絕 alg 混淆)、kid 查 `signing_keys`(含已退休者,供輪替過渡期驗證)、`rsa.VerifyPKCS1v15`、比對 iss/exp。
Claims:
- Access token(TTL 15 分鐘):`iss`、`sub`(user ID 字串)、`aud`(client_id)、`exp`、`iat`、`scope`、`client_id`。自包含不落庫(README 已定案)。
- ID token(TTL 15 分鐘):`iss`、`sub`、`aud`、`exp`、`iat`、`auth_time`(Session 建立時間)、`nonce`;依 scope 加 `name`/`preferred_username`(profile)、`email`/`email_verified`(email)。
## 3. Discovery(internal/oidc/discovery.go)
`DiscoveryHandler(issuer string) http.HandlerFunc`:issuer 於 main 以 `store.EnvOr("ISSUER", "http://localhost:8080")` 讀取並去尾斜線後**以參數注入**(便於測試、oidc 不匯入 store)。回應含 issuer、authorization/token/userinfo endpoint、jwks_uri、`scopes_supported`(經 `application` 套件新增 exported `ScopesSupported() []string`,單一真相來源)、`grant_types_supported: [authorization_code, refresh_token]`、`response_types_supported: [code]`、`subject_types_supported: [public]`、`id_token_signing_alg_values_supported: [RS256]`、`token_endpoint_auth_methods_supported: [client_secret_basic, client_secret_post, none]`、`code_challenge_methods_supported: [S256]`、`claims_supported`;`Cache-Control: public, max-age=3600`(與 JWKS 一致)。
## 4. /authorize(internal/oidc/authorize.go)+同意頁
`AuthorizeHandler(db *gorm.DB) http.HandlerFunc`,GET 與 POST 掛同一工廠:
**共用驗證**(query 或表單):`response_type=code`、`client_id` → `application.GetByClientID`、`redirect_uri` 精確比對 `RedirectURIs.Contains`(RFC 6749 §3.1.2.3)。**client/redirect_uri 無效 → 直接 400 錯誤頁,不重導**(RFC 6749 §4.1.2.1,防導向攻擊);其餘錯誤(scope 不含 openid(OIDC Core §3.1.2.1)、scope 超出 app 註冊、`code_challenge_method` 非 S256(RFC 7636 §4.2,不允許 plain)、public client 無 PKCE、app 未啟用授權碼 grant)→ 302 `redirect_uri?error=...&state=...`。
**GET 流程**:驗證通過後查 Session cookie——無效 → 303 `/login?next=<完整 /authorize URL>`(§7 的 login 改動);有效 → 查 Consent:請求 scope ⊆ 已同意 scope → 直接產碼 302;否則渲染同意頁。
**同意頁**:新模板 `internal/auth/templates/consent.html`(採 layout 側欄版面,資料含 Username/Email/CSRF/應用名稱/scope 人類可讀清單),由 auth 匯出 `ConsentTmpl`(仿 `AdminKeysTmpl` 慣例),oidc 以 `auth.RenderHTML` 渲染。表單以 hidden fields 帶全部原始參數,POST `/authorize`。
**POST 流程**:ParseForm → `auth.VerifyCSRF` → 重跑共用驗證與 Session 檢查 → `decision=allow` → upsert Consent(scope 聯集)+產碼 302;`decision=deny` → 302 `redirect_uri?error=access_denied&state=...`。產碼以 `url.Parse` 正確附加 query(redirect_uri 可能自帶 query)。
## 5. /token(internal/oidc/token.go)
`TokenHandler(db, issuer) http.HandlerFunc`,僅 POST、`application/x-www-form-urlencoded`。
**Client 認證**(RFC 6749 §2.3.1):Basic(`r.BasicAuth()`,對 client_id/secret 做相容性 QueryUnescape)或 body 的 `client_id`+`client_secret`;兩處 client_id 不一致 → invalid_request;confidential 驗 `CheckSecret` 失敗 → 401 `invalid_client`(Basic 時帶 `WWW-Authenticate: Basic`);public 不驗 secret。
**grant_type=authorization_code**:查碼(hash)→ 不存在/過期 → `invalid_grant`;`UsedAt != nil` 為重用 → `invalid_grant` 並撤銷該碼已發的 refresh token(OAuth Security BCP 重用防護);ApplicationID/redirect_uri 不符 → `invalid_grant`;PKCE:碼有 challenge 時驗 `BASE64URL(SHA256(verifier)) == challenge`;以條件更新標記 UsedAt 後簽發 access+ID token(+ scope 含 offline_access 且 app 有 refresh grant 時發 refresh token)。成功回應含 `Cache-Control: no-store`(RFC 6749 §5.1)。
**grant_type=refresh_token**:app 須有 refresh grant;查 token hash——撤銷/過期/不存在 → `invalid_grant`;`RotatedAt != nil` 為重用 → **撤銷該 user×app 全部 refresh token** 後 `invalid_grant`;有效 → 條件更新 `rotated_at` 後發新 refresh token(沿用原 scope)+新 access/ID token。
錯誤格式 `{"error": "...", "error_description": "..."}`(RFC 6749 §5.2),新增小 helper `writeTokenError`。
## 6. /userinfo(internal/oidc/userinfo.go)
`UserInfoHandler(db, issuer)`,GET/POST。Bearer token 取自 `Authorization` header(POST 亦接受 form `access_token`,RFC 6750);缺 token → 401+`WWW-Authenticate: Bearer`;`VerifyAccessToken` 失敗 → 401+`error="invalid_token"`;scope 無 openid → 403 `insufficient_scope`;成功回 `sub`+依 scope 的 `name`/`preferred_username`/`email`/`email_verified`。
## 7. auth 套件改動(login 支援 next)
- `GET /login?next=...`:next 驗證為站內路徑(以 `/` 開頭且不以 `//` 開頭,防 open redirect,不合格一律回 `/`);已登入時改導 next;`loginPageData` 加 `Next`、`login.html` 加 hidden input、失敗重繪保留。
- `POST /login` 表單流程成功後 303 導向 next(無 next 行為不變);JSON API 流程不動。
## 8. 路由與環境變數
`cmd/alterminal/main.go`:讀 `issuer := strings.TrimSuffix(store.EnvOr("ISSUER", "http://localhost:8080"), "/")`,註冊 `GET /.well-known/openid-configuration`、`GET|POST /authorize`、`POST /token`、`GET|POST /userinfo`。
`internal/store/db.go`:AutoMigrate 加入三個新模型(匯入 oidc)。`internal/testdb/testdb.go`:TRUNCATE 加 `authorization_codes, refresh_tokens, consents`。
## 9. 測試
- `internal/oidc`:整合測試 `package oidc_test` 用 `testdb.New(t)`——discovery(欄位與 issuer 前綴)、jwt(往返、竄改 payload、alg=none、過期、kid 不存在)、authorize(client/redirect_uri 無效不重導、參數錯誤重導帶 error+state、未登入 303 /login?next、同意/拒絕、同意後靜默通過、scope 擴大再詢問)、token(PKCE 兌換、code 重用撤銷、redirect_uri 不符、verifier 錯、Basic/post 認證、401 invalid_client、refresh 輪替、重用整鏈撤銷、過期)、userinfo(claims 依 scope、401/403)。table-driven+中文子測試名+`strings.Contains`,全套從 authorize→login→consent→token→userinfo 走通一個 e2e。
- `internal/auth`:login next 流程(成功導向、已登入導向 next、外站 next 導 `/`)。
- 既有測試不動語意,僅 `jwks_test.go` 改 package 宣告。
## 10. 收尾
- Tailwind rebuild:`tools/tailwindcss -i internal/auth/assets/css/input.css -o internal/auth/assets/css/main.css --minify`(consent.html 的 class 進產物;consent.html 盡量沿用既有 class)。
- README:端點表與 Roadmap 狀態更新、`ISSUER` 環境變數改為已使用並附說明。
- 驗證:`go build ./...`、`go vet ./...`、`go test ./...`(本機 PostgreSQL 存在時跑整合測試,否則 skip)。