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

9.8 KiB
Raw Permalink Blame History

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 字元)供索引查詢,明文只在回應中出現一次:

// 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)。