diff --git a/.zcode/plans/plan-sess_19b16e30-771d-4984-86df-9e55b06b287b.md b/.zcode/plans/plan-sess_19b16e30-771d-4984-86df-9e55b06b287b.md new file mode 100644 index 0000000..0fa2f15 --- /dev/null +++ b/.zcode/plans/plan-sess_19b16e30-771d-4984-86df-9e55b06b287b.md @@ -0,0 +1,100 @@ +# 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)。 \ No newline at end of file diff --git a/README.md b/README.md index 4aba2de..60a89dc 100644 --- a/README.md +++ b/README.md @@ -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 核心功能依下方 Roadmap 推進。 +> **狀態:開發中。** 目前完成專案骨架(HTTP 服務、資料庫連線、健康檢查)、使用者帳號(CLI 建帳與重設密碼、argon2id 密碼雜湊)、登入/登出(HTML 登入頁+JSON API、Session Cookie)、應用程式與金鑰管理頁,以及 OIDC 核心(Discovery、Authorization Code Flow+PKCE、Token 端點、UserInfo、Refresh Token 輪替),其餘功能依下方 Roadmap 推進。 ## 特色 @@ -32,19 +32,22 @@ | 端點 | 方法 | 說明 | 狀態 | | --- | --- | --- | --- | | `/health` | GET | 健康檢查(含資料庫連線檢測) | ✅ 已完成 | -| `/.well-known/openid-configuration` | GET | OIDC Discovery 文件 | 🚧 規劃中 | -| `/.well-known/jwks.json` | GET | Token 簽署用公開金鑰(JWKS) | 🚧 規劃中 | -| `/login` | POST | 使用者登入(JSON API 與 HTML 表單提交) | ✅ 已完成 | -| `/login` | GET | 使用者登入頁(HTML 表單,供 `/authorize` 導向) | ✅ 已完成 | +| `/.well-known/openid-configuration` | GET | OIDC Discovery 文件(端點與能力中繼資料,`Cache-Control: max-age=3600`) | ✅ 已完成 | +| `/.well-known/jwks.json` | GET | Token 簽署用公開金鑰(JWKS,僅發佈使用中金鑰,`Cache-Control: max-age=3600`) | ✅ 已完成 | +| `/login` | POST | 使用者登入(JSON API 與 HTML 表單提交;表單支援 `next` 返回路徑) | ✅ 已完成 | +| `/login` | GET | 使用者登入頁(HTML 表單,供 `/authorize` 導向並以 `next` 攜回授權請求) | ✅ 已完成 | | `/logout` | POST | 使用者登出(HTML 表單與 JSON API,冪等) | ✅ 已完成 | | `/static/*` | GET | 靜態檔(Tailwind 建置輸出的 CSS,`go:embed` 內嵌) | ✅ 已完成 | -| `/authorize` | GET | 授權端點(Authorization Code Flow) | 🚧 規劃中 | -| `/token` | POST | 權杖端點(換發 Access / ID / Refresh Token) | 🚧 規劃中 | -| `/userinfo` | GET/POST | 以 Access Token 取得使用者資訊 | 🚧 規劃中 | +| `/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) | ✅ 已完成 | +| `/admin/applications/{id}` | GET | 編輯應用程式頁(預填既有註冊內容;client_id 唯讀,僅 admin) | ✅ 已完成 | +| `/admin/applications/{id}` | POST | 更新應用程式註冊內容(表單+CSRF,PRG;client_id 與 secret 不變,改為公開式時清除 secret 雜湊) | ✅ 已完成 | | `/admin/applications/{id}/secret` | POST | 輪替 client secret(舊 secret 立即失效,明文僅顯示一次) | ✅ 已完成 | | `/admin/applications/{id}/delete` | POST | 刪除應用程式註冊(表單+CSRF,PRG) | ✅ 已完成 | | `/admin/keys` | GET | 金鑰管理頁(kid、狀態、輪替操作;僅 admin) | ✅ 已完成 | @@ -73,7 +76,7 @@ docker run -d --name alterminal-db \ ### 啟動服務 ```bash -go run . +go run ./cmd/alterminal # 服務啟動於 http://localhost:8080 ``` @@ -87,16 +90,16 @@ curl http://localhost:8080/health ### 編譯執行檔 ```bash -go build -o alterminal . +go build -o alterminal ./cmd/alterminal ./alterminal # 服務啟動於 http://localhost:8080 ``` -- HTML 模板與 Tailwind 建置輸出(`main.css`)皆以 `go:embed` 內嵌,產出為**單一執行檔**,部署時不需連同 `templates/`、`assets/` 一併安裝 +- HTML 模板與 Tailwind 建置輸出(`main.css`)皆以 `go:embed` 內嵌,產出為**單一執行檔**,部署時不需連同模板與樣式檔一併安裝 - 依賴全為純 Go(PostgreSQL 驅動採 pgx,無 CGO),可直接交叉編譯。部署至 Linux 伺服器: ```bash -CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o alterminal . +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o alterminal ./cmd/alterminal # ARM 伺服器改用 GOARCH=arm64 ``` @@ -119,7 +122,7 @@ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o alterminal . ### 建立使用者帳號 ```bash -go run . create-account -username alice -email alice@example.com -name "Alice" +go run ./cmd/alterminal create-account -username alice -email alice@example.com -name "Alice" 輸入密碼: ******** 再次輸入密碼: ******** 帳號建立成功:id=1 username=alice email=alice@example.com role=user @@ -139,7 +142,7 @@ go run . create-account -username alice -email alice@example.com -name "Alice" 管理用密碼重設(不驗證舊密碼)。密碼更新與 Session 撤銷於**同一資料庫交易**內完成,成功後該帳號所有 Session 立即失效——對 SSO Provider 而言,若密碼重設(例如帳號外洩的處置)後既有 Session 仍繼續有效,重設便失去意義: ```bash -go run . update-password -username alice +go run ./cmd/alterminal update-password -username alice 輸入密碼: ******** 再次輸入密碼: ******** 密碼更新成功:id=1 username=alice(已撤銷 2 個 Session) @@ -205,21 +208,21 @@ curl -i -X POST http://localhost:8080/logout \ ### 登入頁面 -瀏覽器開啟 即為 HTML 登入頁(Go `html/template`,模板位於 `templates/`,以 `go:embed` 打進執行檔): +瀏覽器開啟 即為 HTML 登入頁(Go `html/template`,模板位於 `internal/auth/templates/`,以 `go:embed` 打進執行檔): - 表單提交(`application/x-www-form-urlencoded`)與 JSON API 共用同一套帳密驗證與 Session 流程 - 表單附 double-submit CSRF token(Cookie 與隱藏欄位比對),不符時回 `403` 並重新輸出表單 - 帳密錯誤時重繪表單(`401`),保留帳號輸入並顯示錯誤訊息 -- 登入成功採 PRG 模式:`303` 導向 `/login`,持有效 Session 時該頁顯示帳號資訊(帳號、Email、Session 到期時間),並套用側邊導覽版面(`templates/layout.html`,之後的頁面可重用):桌面版側欄固定展開,手機版以純 CSS checkbox 開合(CSP 不允許 JavaScript),側欄頁尾為使用者資訊與「登出」按鈕(`POST /logout`,同樣受 CSRF 驗證保護) +- 登入成功採 PRG 模式:`303` 導向 `/login`,持有效 Session 時該頁顯示帳號資訊(帳號、Email、Session 到期時間),並套用側邊導覽版面(`internal/auth/templates/layout.html`,之後的頁面可重用):桌面版側欄固定展開,手機版以純 CSS checkbox 開合(CSP 不允許 JavaScript),側欄頁尾為使用者資訊與「登出」按鈕(`POST /logout`,同樣受 CSRF 驗證保護) ### 前端樣式(Tailwind CSS) -頁面樣式使用 Tailwind CSS v4。模板(`templates/*.html`)直接寫 utility class,樣式進入點在 `assets/css/input.css`(含 `@theme` 自訂品牌色與中文字型),建置輸出 `assets/css/main.css` 已提交並以 `go:embed` 內嵌,經 `/static/css/main.css` 提供——**一般開發與部署不需 Node**。 +頁面樣式使用 Tailwind CSS v4。模板(`internal/auth/templates/*.html`)直接寫 utility class,樣式進入點在 `internal/auth/assets/css/input.css`(含 `@theme` 自訂品牌色與中文字型),建置輸出 `internal/auth/assets/css/main.css` 已提交並以 `go:embed` 內嵌,經 `/static/css/main.css` 提供——**一般開發與部署不需 Node**。 調整樣式後重新建置: ```bash -tools/tailwindcss -i assets/css/input.css -o assets/css/main.css --minify +tools/tailwindcss -i internal/auth/assets/css/input.css -o internal/auth/assets/css/main.css --minify ``` Tailwind 為官方 [standalone CLI](https://tailwindlabs.github.io/tailwindcss/)(版本見 `tools/tailwindcss-version.txt`,`tools/` 不納入版本控制),首次取得方式: @@ -241,7 +244,7 @@ chmod +x tools/tailwindcss | `DB_PASSWORD` | 資料庫密碼 | `postgres` | ✅ | | `DB_NAME` | 資料庫名稱 | `alterminal` | ✅ | | `PORT` | 服務監聽埠號 | `8080` | 🚧 規劃中 | -| `ISSUER` | OIDC Issuer URL(對外完整網址,須含 scheme,不可帶尾斜線) | `http://localhost:8080` | 🚧 規劃中 | +| `ISSUER` | OIDC Issuer URL(對外完整網址,須含 scheme,不可帶尾斜線;簽入 token 的 `iss` claim 與 Discovery 的 `issuer` 欄位須完全一致) | `http://localhost:8080` | ✅ | ## 授權流程(Authorization Code Flow + PKCE) @@ -266,6 +269,28 @@ sequenceDiagram 之後其他 RP 發起授權時,因瀏覽器 Session 仍有效,使用者無須再次輸入帳密,即為單一登入(SSO)。 +### /authorize + +- 參數:`response_type=code`、`client_id`、`redirect_uri`、`scope`(必含 `openid` 且不得超出應用程式註冊範圍)、`state`、`nonce`、`code_challenge`+`code_challenge_method=S256`(RFC 7636;公開式 Client 必須提供,不支援 `plain`) +- `client_id`/`redirect_uri` 無法確認時直接回 `400` 錯誤頁、不重導(RFC 6749 §4.1.2.1,防止作為開放重導向器);其餘錯誤以 `302` 重導回 `redirect_uri?error=...&state=...` +- 未登入:`303` 導向 `/login?next=<完整授權請求>`,登入成功後回到本端點繼續(`next` 經站內路徑驗證,阻擋 open redirect) +- 同意頁:首次授權顯示應用程式名稱與 scope 清單(表單+CSRF);同意記錄為 scope 聯集,之後請求範圍未擴大時靜默通過,範圍擴大時再次詢問;拒絕以 `access_denied` 重導回 RP +- 授權碼:32 bytes 亂數(資料庫僅存 SHA-256 雜湊)、效期 5 分鐘、一次性,發行當下的 redirect URI/scope/nonce/PKCE challenge/auth_time 隨碼凍結供兌換時逐項比對 + +### /token + +- 僅接受 `POST`+`application/x-www-form-urlencoded`(RFC 6749 §2.3.1) +- Client 認證:HTTP Basic(`client_id:client_secret`)或表單欄位(`client_secret_post`);機密式 Client 必驗 secret(argon2id 比對),公開式 Client 以 client_id 識別、由 PKCE 承擔防護;認證失敗回 `401 invalid_client` +- `grant_type=authorization_code`:逐項比對授權碼綁定內容(client、redirect_uri、PKCE `S256(verifier)`);重用授權碼除回 `invalid_grant` 外並撤銷其發行的所有 refresh token +- `grant_type=refresh_token`:兌換即輪替(舊 token 作廢、發新 token),scope 僅可縮小不可擴大;偵測到重用已輪替的 token 時撤銷該使用者於該應用程式的全部 refresh token(OAuth 2.0 Security BCP) +- 回應:`access_token`(JWT/RS256,效期 15 分鐘、自包含不落庫)、`id_token`(scope 含 `openid` 時;含 `iss`/`sub`/`aud`/`nonce`/`auth_time` 及依 scope 的 `name`/`preferred_username`/`email`/`email_verified`)、`refresh_token`(scope 含 `offline_access` 時,效期 30 天)、`scope`(正規化排序形式);成功與錯誤回應皆附 `Cache-Control: no-store` + +### /userinfo + +- Bearer Access Token 取自 `Authorization` 標頭(RFC 6750;POST 亦接受表單 `access_token` 欄位) +- 驗證:RS256 簽章(header `kid` 對應 `signing_keys`,拒絕其他 `alg`)、`iss`、`exp`;無效回 `401`+`WWW-Authenticate: Bearer error="invalid_token"`,缺少 `openid` scope 回 `403 insufficient_scope` +- 回應依授權 scope:`sub` 恆有;`profile` 加 `name`/`preferred_username`;`email` 加 `email`/`email_verified`(OIDC Core §5.4) + ## 資料模型 Access Token 採用自包含的 JWT,不落庫儲存;其餘狀態儲存於 PostgreSQL(GORM 自動遷移)。 @@ -273,50 +298,48 @@ 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、grant types、scope、confidential/public) | 🚧 模型與管理頁已完成(`Application`:argon2id secret 雜湊、redirect URI 格式驗證;`/admin/applications` 註冊/編輯/輪替/刪除),註冊 API 規劃中 | | `sessions` | 使用者瀏覽器 Session(SSO 核心,HttpOnly Cookie,效期 24 小時) | ✅ 已完成 | -| `authorization_codes` | 授權碼(一次性、短時效、綁定 PKCE challenge) | 🚧 規劃中 | -| `refresh_tokens` | Refresh Token(支援輪替與撤銷偵測) | 🚧 規劃中 | -| `signing_keys` | RSA 簽章金鑰(供 JWKS 輪替) | 🚧 模型與管理頁已完成(`internal/jwk`:PKCS#8 儲存、RFC 7638 kid、RFC 7517 JWK/JWKS 公開形式;`/admin/keys` 產生/退休),JWKS 端點與輪替排程規劃中 | +| `authorization_codes` | 授權碼(SHA-256 雜湊儲存、一次性、效期 5 分鐘、凍結授權當下的 redirect URI/scope/nonce/PKCE challenge/auth_time) | ✅ 已完成 | +| `refresh_tokens` | Refresh Token(SHA-256 雜湊儲存、效期 30 天、兌換即輪替、重用時整鏈撤銷) | ✅ 已完成 | +| `consents` | 使用者×應用程式的同意記錄(scope 聯集;同範圍靜默通過) | ✅ 已完成 | +| `signing_keys` | RSA 簽章金鑰(供 JWKS 輪替) | ✅ 模型與管理頁已完成(`internal/jwk`:PKCS#8 儲存、RFC 7638 kid、RFC 7517 JWK/JWKS 公開形式;`/admin/keys` 產生/退休;`/.well-known/jwks.json` 已上線),輪替排程規劃中 | ## Roadmap - [x] 專案骨架:chi 路由、GORM + PostgreSQL 連線、`/health` 健康檢查 - [x] 使用者系統:註冊、登入/登出、密碼重設(撤銷 Session)、密碼雜湊(argon2id)、瀏覽器 Session -- [ ] Client 管理:RP 註冊 API(管理頁 `/admin/applications` 已完成:註冊、client_secret 發配與輪替、刪除,僅 admin) -- [ ] 金鑰管理:RSA 金鑰產生、`/.well-known/jwks.json`、金鑰輪替 -- [ ] OIDC Discovery:`/.well-known/openid-configuration` -- [ ] Authorization Code Flow + PKCE(`/authorize`) -- [ ] Token 端點:Access Token(JWT)、ID Token、Refresh Token 簽發與驗證 -- [ ] UserInfo 端點(`/userinfo`) -- [ ] Refresh Token 輪替與撤銷 +- [ ] Client 管理:RP 註冊 API(管理頁 `/admin/applications` 已完成:註冊、編輯、client_secret 發配與輪替、刪除,僅 admin) +- [x] 金鑰管理:RSA 金鑰產生、`/.well-known/jwks.json`、金鑰輪替(手動:產生新鑰後退休舊鑰;自動排程規劃中) +- [x] OIDC Discovery:`/.well-known/openid-configuration` +- [x] Authorization Code Flow + PKCE(`/authorize`,含同意頁與首次同意後記住) +- [x] Token 端點:Access Token(JWT/RS256)、ID Token、Refresh Token 簽發與驗證 +- [x] UserInfo 端點(`/userinfo`) +- [x] Refresh Token 輪替與撤銷(重用偵測、整鏈撤銷) - [ ] RP-Initiated Logout(`/logout`) - [ ] Client Credentials Grant - [ ] Front-Channel / Back-Channel Logout(跨 RP 單一登出) - [ ] 管理 API 與簡易管理介面 -## 專案結構(目標) +## 專案結構 ``` alterminal/ -├── main.go # 程式進入點、路由裝配 -├── db.go # 資料庫連線與自動遷移 -├── user.go # 使用者帳號(Account)模型與 argon2id 密碼雜湊 -├── createaccount.go # create-account CLI 子指令(建立使用者帳號) -├── updatepassword.go # update-password CLI 子指令(重設密碼並撤銷 Session) -├── login.go # POST /login(JSON API 與表單共用流程) -├── loginpage.go # GET /login 登入頁(html/template + CSRF) -├── logout.go # POST /logout(Session 刪除與 Cookie 清除) -├── adminkeys.go # /admin/keys 金鑰管理頁(僅 admin:產生/退休) -├── notfound.go # 自訂 404 頁(chi NotFound handler) -├── session.go # 瀏覽器 Session 模型與管理 -├── static.go # /static/ 靜態檔服務(go:embed) -├── templates/ # HTML 模板(Tailwind utility class) -├── assets/css/ # Tailwind 進入點(input.css)與建置輸出(main.css) +├── cmd/alterminal/ +│ └── main.go # 程式進入點、路由裝配、CLI 分派 └── internal/ - ├── auth/ # 使用者認證、Session、密碼雜湊 - ├── client/ # RP Client 註冊與驗證 - ├── oidc/ # OIDC 核心:authorize / token / userinfo / logout + ├── auth/ # 認證領域與非管理頁 HTTP:User/Session 模型、 + │ │ # argon2id 密碼雜湊、登入/登出、模板與 CSRF、 + │ │ # 靜態檔、自訂 404 + │ ├── templates/ # HTML 模板(Tailwind utility class) + │ └── assets/css/ # Tailwind 進入點(input.css)與建置輸出(main.css) + ├── application/ # Application 模型(RP 註冊與驗證) + ├── store/ # 資料庫連線與自動遷移 + ├── admin/ # 管理頁:/admin/keys 金鑰、/admin/applications 應用程式 + ├── cli/ # create-account / update-password 子指令 + ├── testdb/ # 整合測試共用資料庫(alterminal_test) ├── jwk/ # 簽章金鑰與 JWKS - └── httpx/ # 共用 HTTP 工具(錯誤回應、middleware) + └── oidc/ # OIDC 核心端點與憑證模型:Discovery、/authorize + # (同意頁)、/token、/userinfo、JWKS,以及 + # authorization_codes/refresh_tokens/consents ``` diff --git a/assets/css/main.css b/assets/css/main.css deleted file mode 100644 index 167f3ef..0000000 --- a/assets/css/main.css +++ /dev/null @@ -1,2 +0,0 @@ -/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-duration:initial;--tw-ease:initial;--tw-outline-style:solid}}}@layer theme{:root,:host{--font-sans:system-ui, -apple-system, "PingFang TC", "Microsoft JhengHei", sans-serif;--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-700:oklch(55.5% .163 48.998);--color-emerald-400:oklch(76.5% .177 163.223);--color-emerald-500:oklch(69.6% .17 162.48);--color-emerald-700:oklch(50.8% .118 165.612);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-700:oklch(48.8% .243 264.376);--color-violet-400:oklch(70.2% .183 293.541);--color-violet-500:oklch(60.6% .25 292.717);--color-violet-700:oklch(49.1% .27 292.581);--color-neutral-100:oklch(97% 0 none);--color-neutral-200:oklch(92.2% 0 none);--color-neutral-300:oklch(87% 0 none);--color-neutral-400:oklch(70.8% 0 none);--color-neutral-500:oklch(55.6% 0 none);--color-neutral-600:oklch(43.9% 0 none);--color-neutral-700:oklch(37.1% 0 none);--color-neutral-800:oklch(26.9% 0 none);--color-neutral-900:oklch(20.5% 0 none);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-3xl:48rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wide:.025em;--leading-tight:1.25;--radius-lg:.5rem;--radius-xl:.75rem;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-brand:#0071e3;--color-brand-strong:#0077ed}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.fixed{position:fixed}.inset-0{inset:0}.inset-y-0{inset-block:0}.top-4{top:calc(var(--spacing) * 4)}.left-0{left:0}.left-4{left:calc(var(--spacing) * 4)}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.m-0{margin:0}.m-4{margin:calc(var(--spacing) * 4)}.mx-auto{margin-inline:auto}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mb-1{margin-bottom:var(--spacing)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-3\.5{margin-bottom:calc(var(--spacing) * 3.5)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.block{display:block}.flex{display:flex}.hidden{display:none}.inline-block{display:inline-block}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.size-6{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.size-9{width:calc(var(--spacing) * 9);height:calc(var(--spacing) * 9)}.size-11{width:calc(var(--spacing) * 11);height:calc(var(--spacing) * 11)}.min-h-screen{min-height:100vh}.w-72{width:calc(var(--spacing) * 72)}.w-full{width:100%}.max-w-3xl{max-width:var(--container-3xl)}.max-w-88{max-width:calc(var(--spacing) * 88)}.min-w-0{min-width:0}.flex-1{flex:1}.shrink-0{flex-shrink:0}.-translate-x-full{--tw-translate-x:-100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.cursor-pointer{cursor:pointer}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-neutral-100>:not(:last-child)){border-color:var(--color-neutral-100)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-emerald-500\/40{border-color:#00bb7f66}@supports (color:color-mix(in lab, red, red)){.border-emerald-500\/40{border-color:color-mix(in oklab, var(--color-emerald-500) 40%, transparent)}}.border-neutral-200{border-color:var(--color-neutral-200)}.border-neutral-300{border-color:var(--color-neutral-300)}.border-red-300{border-color:var(--color-red-300)}.bg-amber-500\/10{background-color:#f99c001a}@supports (color:color-mix(in lab, red, red)){.bg-amber-500\/10{background-color:color-mix(in oklab, var(--color-amber-500) 10%, transparent)}}.bg-blue-500\/10{background-color:#3080ff1a}@supports (color:color-mix(in lab, red, red)){.bg-blue-500\/10{background-color:color-mix(in oklab, var(--color-blue-500) 10%, transparent)}}.bg-brand{background-color:var(--color-brand)}.bg-brand\/10{background-color:#0071e31a}@supports (color:color-mix(in lab, red, red)){.bg-brand\/10{background-color:color-mix(in oklab, var(--color-brand) 10%, transparent)}}.bg-emerald-500\/10{background-color:#00bb7f1a}@supports (color:color-mix(in lab, red, red)){.bg-emerald-500\/10{background-color:color-mix(in oklab, var(--color-emerald-500) 10%, transparent)}}.bg-neutral-100{background-color:var(--color-neutral-100)}.bg-neutral-500\/10{background-color:#7373731a}@supports (color:color-mix(in lab, red, red)){.bg-neutral-500\/10{background-color:color-mix(in oklab, var(--color-neutral-500) 10%, transparent)}}.bg-neutral-900\/40{background-color:#17171766}@supports (color:color-mix(in lab, red, red)){.bg-neutral-900\/40{background-color:color-mix(in oklab, var(--color-neutral-900) 40%, transparent)}}.bg-red-500\/10{background-color:#fb2c361a}@supports (color:color-mix(in lab, red, red)){.bg-red-500\/10{background-color:color-mix(in oklab, var(--color-red-500) 10%, transparent)}}.bg-transparent{background-color:#0000}.bg-violet-500\/10{background-color:#8d54ff1a}@supports (color:color-mix(in lab, red, red)){.bg-violet-500\/10{background-color:color-mix(in oklab, var(--color-violet-500) 10%, transparent)}}.bg-white{background-color:var(--color-white)}.p-4{padding:calc(var(--spacing) * 4)}.p-8{padding:calc(var(--spacing) * 8)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-6{padding-block:calc(var(--spacing) * 6)}.text-center{text-align:center}.text-left{text-align:left}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[15px\]{font-size:15px}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.text-amber-700{color:var(--color-amber-700)}.text-blue-700{color:var(--color-blue-700)}.text-brand{color:var(--color-brand)}.text-emerald-700{color:var(--color-emerald-700)}.text-neutral-400{color:var(--color-neutral-400)}.text-neutral-500{color:var(--color-neutral-500)}.text-neutral-600{color:var(--color-neutral-600)}.text-neutral-700{color:var(--color-neutral-700)}.text-neutral-900{color:var(--color-neutral-900)}.text-red-600{color:var(--color-red-600)}.text-violet-700{color:var(--color-violet-700)}.text-white{color:var(--color-white)}.uppercase{text-transform:uppercase}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-200{--tw-duration:.2s;transition-duration:.2s}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.peer-checked\:block:is(:where(.peer):checked~*){display:block}.peer-checked\:hidden:is(:where(.peer):checked~*){display:none}.peer-checked\:translate-x-0:is(:where(.peer):checked~*){--tw-translate-x:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-focus-visible\:outline-2:is(:where(.peer):focus-visible~*){outline-style:var(--tw-outline-style);outline-width:2px}.peer-focus-visible\:outline-offset-2:is(:where(.peer):focus-visible~*){outline-offset:2px}.peer-focus-visible\:outline-brand:is(:where(.peer):focus-visible~*){outline-color:var(--color-brand)}@media (hover:hover){.hover\:bg-brand-strong:hover{background-color:var(--color-brand-strong)}.hover\:bg-neutral-100:hover{background-color:var(--color-neutral-100)}.hover\:bg-red-50:hover{background-color:var(--color-red-50)}}.focus\:border-transparent:focus{border-color:#0000}.focus\:outline-2:focus{outline-style:var(--tw-outline-style);outline-width:2px}.focus\:outline-offset-1:focus{outline-offset:1px}.focus\:outline-brand:focus{outline-color:var(--color-brand)}@media (min-width:48rem){.md\:hidden{display:none}.md\:hidden\!{display:none!important}.md\:translate-x-0{--tw-translate-x:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.md\:p-10{padding:calc(var(--spacing) * 10)}.md\:pl-72{padding-left:calc(var(--spacing) * 72)}}@media (prefers-color-scheme:dark){:where(.dark\:divide-neutral-700\/60>:not(:last-child)){border-color:#40404099}@supports (color:color-mix(in lab, red, red)){:where(.dark\:divide-neutral-700\/60>:not(:last-child)){border-color:color-mix(in oklab, var(--color-neutral-700) 60%, transparent)}}.dark\:border-neutral-600{border-color:var(--color-neutral-600)}.dark\:border-neutral-700{border-color:var(--color-neutral-700)}.dark\:border-red-500\/60{border-color:#fb2c3699}@supports (color:color-mix(in lab, red, red)){.dark\:border-red-500\/60{border-color:color-mix(in oklab, var(--color-red-500) 60%, transparent)}}.dark\:bg-brand\/25{background-color:#0071e340}@supports (color:color-mix(in lab, red, red)){.dark\:bg-brand\/25{background-color:color-mix(in oklab, var(--color-brand) 25%, transparent)}}.dark\:bg-neutral-800{background-color:var(--color-neutral-800)}.dark\:bg-neutral-900{background-color:var(--color-neutral-900)}.dark\:text-amber-400{color:var(--color-amber-400)}.dark\:text-blue-200{color:var(--color-blue-200)}.dark\:text-blue-400{color:var(--color-blue-400)}.dark\:text-emerald-400{color:var(--color-emerald-400)}.dark\:text-neutral-100{color:var(--color-neutral-100)}.dark\:text-neutral-300{color:var(--color-neutral-300)}.dark\:text-neutral-400{color:var(--color-neutral-400)}.dark\:text-neutral-500{color:var(--color-neutral-500)}.dark\:text-red-400{color:var(--color-red-400)}.dark\:text-violet-400{color:var(--color-violet-400)}.dark\:shadow-black\/40{--tw-shadow-color:#0006}@supports (color:color-mix(in lab, red, red)){.dark\:shadow-black\/40{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 40%, transparent) var(--tw-shadow-alpha), transparent)}}@media (hover:hover){.dark\:hover\:bg-neutral-700:hover{background-color:var(--color-neutral-700)}.dark\:hover\:bg-neutral-700\/60:hover{background-color:#40404099}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-neutral-700\/60:hover{background-color:color-mix(in oklab, var(--color-neutral-700) 60%, transparent)}}.dark\:hover\:bg-red-500\/10:hover{background-color:#fb2c361a}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-red-500\/10:hover{background-color:color-mix(in oklab, var(--color-red-500) 10%, transparent)}}}}}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid} \ No newline at end of file diff --git a/adminapplications.go b/internal/admin/adminapplications.go similarity index 60% rename from adminapplications.go rename to internal/admin/adminapplications.go index 58f655c..1e4c0ce 100644 --- a/adminapplications.go +++ b/internal/admin/adminapplications.go @@ -1,7 +1,8 @@ -package main +package admin import ( "errors" + "fmt" "log" "net/http" "strconv" @@ -9,6 +10,9 @@ import ( "github.com/go-chi/chi/v5" "gorm.io/gorm" + + "alterminal/internal/application" + "alterminal/internal/auth" ) // adminApplicationRow 為應用程式管理頁表格的單列視圖。 @@ -25,7 +29,7 @@ type adminApplicationRow struct { } // newAdminApplicationRows 將應用程式模型轉為表格視圖。純函式,便於單元測試。 -func newAdminApplicationRows(apps []Application) []adminApplicationRow { +func newAdminApplicationRows(apps []application.Application) []adminApplicationRow { rows := make([]adminApplicationRow, 0, len(apps)) for _, a := range apps { grants := make([]string, len(a.GrantTypes)) @@ -61,7 +65,7 @@ type applicationForm struct { // newApplicationForm 回傳註冊表單的預設狀態:機密式、勾選授權碼流程。 func newApplicationForm() applicationForm { - return applicationForm{Type: string(ClientConfidential), GrantAuthCode: true} + return applicationForm{Type: string(application.ClientConfidential), GrantAuthCode: true} } // applicationFormFromPost 由已解析的表單還原視圖狀態。類型限選單兩值, @@ -73,16 +77,38 @@ func applicationFormFromPost(r *http.Request) applicationForm { RedirectURIs: r.PostFormValue("redirect_uris"), Scope: r.PostFormValue("scope"), } - if f.Type != string(ClientPublic) { - f.Type = string(ClientConfidential) + if f.Type != string(application.ClientPublic) { + f.Type = string(application.ClientConfidential) } for _, g := range r.PostForm["grant_types"] { - switch GrantType(g) { - case GrantAuthorizationCode: + switch application.GrantType(g) { + case application.GrantAuthorizationCode: f.GrantAuthCode = true - case GrantRefreshToken: + case application.GrantRefreshToken: f.GrantRefresh = true - case GrantClientCredentials: + case application.GrantClientCredentials: + f.GrantClientCred = true + } + } + return f +} + +// applicationFormFromApp 由既有註冊資料預填表單狀態(GET 編輯頁), +// redirect URI 以每行一個還原為 textarea 內容。 +func applicationFormFromApp(a *application.Application) applicationForm { + f := applicationForm{ + Name: a.Name, + Type: string(a.Type), + RedirectURIs: strings.Join(a.RedirectURIs, "\n"), + Scope: a.Scope, + } + for _, g := range a.GrantTypes { + switch g { + case application.GrantAuthorizationCode: + f.GrantAuthCode = true + case application.GrantRefreshToken: + f.GrantRefresh = true + case application.GrantClientCredentials: f.GrantClientCred = true } } @@ -102,16 +128,16 @@ func (f applicationForm) redirectURIList() []string { } // grantTypeList 依核取狀態列出要啟用的 grant type。 -func (f applicationForm) grantTypeList() []GrantType { - var gts []GrantType +func (f applicationForm) grantTypeList() []application.GrantType { + var gts []application.GrantType if f.GrantAuthCode { - gts = append(gts, GrantAuthorizationCode) + gts = append(gts, application.GrantAuthorizationCode) } if f.GrantRefresh { - gts = append(gts, GrantRefreshToken) + gts = append(gts, application.GrantRefreshToken) } if f.GrantClientCred { - gts = append(gts, GrantClientCredentials) + gts = append(gts, application.GrantClientCredentials) } return gts } @@ -148,9 +174,24 @@ type adminApplicationNewPageData struct { Secret *secretPanel // 非空時顯示一次性成果面板(註冊) } -// adminApplicationsPageHandler 處理 GET /admin/applications:列出已註冊 +// adminApplicationEditPageData 為編輯應用程式頁的模板資料。ClientID 與 +// 建立時間為唯讀顯示(client_id 不可變更);Success 於更新成功後 PRG +// 回本頁時顯示(?saved=1)。 +type adminApplicationEditPageData struct { + Error string + Success string // PRG 後的成功訊息;空字串表示不顯示 + Username string // 側欄頁尾使用者資訊 + Email string + CSRF string // 編輯表單的 CSRF token + ID uint // 表單 action 的路徑參數 + ClientID string // 唯讀顯示(不可變更) + Created string // 建立時間顯示 + Form applicationForm // 表單狀態(重繪時保留輸入) +} + +// ApplicationsPageHandler 處理 GET /admin/applications:列出已註冊 // 應用程式,僅管理員可存取;註冊表單獨立於 /admin/applications/new。 -func adminApplicationsPageHandler(db *gorm.DB) http.HandlerFunc { +func ApplicationsPageHandler(db *gorm.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { s, ok := requireAdmin(db, w, r) if !ok { @@ -160,9 +201,9 @@ func adminApplicationsPageHandler(db *gorm.DB) http.HandlerFunc { } } -// adminApplicationNewPageHandler 處理 GET /admin/applications/new:顯示 +// ApplicationNewPageHandler 處理 GET /admin/applications/new:顯示 // 註冊表單(預設值),僅管理員可存取。 -func adminApplicationNewPageHandler(db *gorm.DB) http.HandlerFunc { +func ApplicationNewPageHandler(db *gorm.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { s, ok := requireAdmin(db, w, r) if !ok { @@ -175,14 +216,14 @@ func adminApplicationNewPageHandler(db *gorm.DB) http.HandlerFunc { // renderAdminApplicationNewPage 輸出註冊頁;errMsg 非空時以指定 status // 重繪表單並顯示錯誤(此時 form 保留使用者輸入);secret 非空時顯示一次 // 性成果面板(機密式含明文 client secret)。頁面不查詢列表,不需資料庫。 -func renderAdminApplicationNewPage(w http.ResponseWriter, r *http.Request, status int, s *Session, errMsg string, form applicationForm, secret *secretPanel) { - token, err := newCSRFToken(w, r) +func renderAdminApplicationNewPage(w http.ResponseWriter, r *http.Request, status int, s *auth.Session, errMsg string, form applicationForm, secret *secretPanel) { + token, err := auth.NewCSRFToken(w, r) if err != nil { log.Printf("admin applications: %v", err) http.Error(w, "內部錯誤", http.StatusInternalServerError) return } - renderHTML(w, status, adminApplicationNewTmpl, adminApplicationNewPageData{ + auth.RenderHTML(w, status, auth.AdminApplicationNewTmpl, adminApplicationNewPageData{ Error: errMsg, Username: s.User.Username, Email: s.User.Email, @@ -192,23 +233,68 @@ func renderAdminApplicationNewPage(w http.ResponseWriter, r *http.Request, statu }) } +// ApplicationEditPageHandler 處理 GET /admin/applications/{id}:顯示編輯 +// 表單(預填既有註冊內容),僅管理員可存取。帶 ?saved=1 時顯示儲存成功 +// 訊息(Update 成功後 PRG 回本頁)。 +func ApplicationEditPageHandler(db *gorm.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + s, ok := requireAdmin(db, w, r) + if !ok { + return + } + app, ok := applicationByID(w, r, db, s) + if !ok { + return + } + var success string + if r.URL.Query().Get("saved") == "1" { + success = "已儲存變更" + } + renderAdminApplicationEditPage(w, r, http.StatusOK, s, "", success, app, applicationFormFromApp(app)) + } +} + +// renderAdminApplicationEditPage 輸出編輯頁;errMsg 非空時以指定 status +// 重繪表單並顯示錯誤(此時 form 保留使用者輸入),success 非空時顯示 +// PRG 後的成功訊息。a 僅取 ID、client_id 與建立時間(皆不受 Update 的 +// 驗證失敗影響)。頁面無一次性資料,不需資料庫。 +func renderAdminApplicationEditPage(w http.ResponseWriter, r *http.Request, status int, s *auth.Session, errMsg, success string, a *application.Application, form applicationForm) { + token, err := auth.NewCSRFToken(w, r) + if err != nil { + log.Printf("admin applications: %v", err) + http.Error(w, "內部錯誤", http.StatusInternalServerError) + return + } + auth.RenderHTML(w, status, auth.AdminApplicationEditTmpl, adminApplicationEditPageData{ + Error: errMsg, + Success: success, + Username: s.User.Username, + Email: s.User.Email, + CSRF: token, + ID: a.ID, + ClientID: a.ClientID, + Created: a.CreatedAt.Local().Format("2006-01-02 15:04:05 MST"), + Form: form, + }) +} + // renderAdminApplicationsPage 查詢應用程式並輸出管理列表頁;errMsg 非空時 // 以指定 status 重繪頁面並顯示錯誤,secret 非空時顯示一次性明文面板 // (輪替)。s 供側欄頁尾顯示使用者資訊。新註冊的排前。 -func renderAdminApplicationsPage(w http.ResponseWriter, r *http.Request, db *gorm.DB, status int, s *Session, errMsg string, secret *secretPanel) { - var apps []Application +func renderAdminApplicationsPage(w http.ResponseWriter, r *http.Request, db *gorm.DB, status int, s *auth.Session, errMsg string, secret *secretPanel) { + var apps []application.Application if err := db.Order("created_at DESC").Find(&apps).Error; err != nil { log.Printf("admin applications: %v", err) http.Error(w, "內部錯誤", http.StatusInternalServerError) return } - token, err := newCSRFToken(w, r) + token, err := auth.NewCSRFToken(w, r) if err != nil { log.Printf("csrf token: %v", err) http.Error(w, "內部錯誤", http.StatusInternalServerError) return } - renderHTML(w, status, adminApplicationsTmpl, adminApplicationsPageData{ + auth.RenderHTML(w, status, auth.AdminApplicationsTmpl, adminApplicationsPageData{ Error: errMsg, Username: s.User.Username, Email: s.User.Email, @@ -218,10 +304,10 @@ func renderAdminApplicationsPage(w http.ResponseWriter, r *http.Request, db *gor }) } -// adminApplicationsCreateHandler 處理 POST /admin/applications/new:驗證並 +// ApplicationsCreateHandler 處理 POST /admin/applications/new:驗證並 // 儲存新註冊。成功時直接渲染註冊頁(200)顯示 client_id 與明文 client // secret——secret 只在本次回應出現,重新整理後即無法再查看,故不適用 PRG。 -func adminApplicationsCreateHandler(db *gorm.DB) http.HandlerFunc { +func ApplicationsCreateHandler(db *gorm.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { s, ok := requireAdmin(db, w, r) if !ok { @@ -231,12 +317,12 @@ func adminApplicationsCreateHandler(db *gorm.DB) http.HandlerFunc { renderAdminApplicationNewPage(w, r, http.StatusBadRequest, s, "無法解析表單內容", newApplicationForm(), nil) return } - if !verifyCSRF(r) { + if !auth.VerifyCSRF(r) { renderAdminApplicationNewPage(w, r, http.StatusForbidden, s, "表單驗證失敗,請重新整理頁面後再試", newApplicationForm(), nil) return } form := applicationFormFromPost(r) - app, secret, err := NewApplication(form.Name, ClientType(form.Type), form.redirectURIList(), form.grantTypeList(), form.Scope) + app, secret, err := application.NewApplication(form.Name, application.ClientType(form.Type), form.redirectURIList(), form.grantTypeList(), form.Scope) if err != nil { renderAdminApplicationNewPage(w, r, http.StatusBadRequest, s, err.Error(), form, nil) return @@ -252,10 +338,47 @@ func adminApplicationsCreateHandler(db *gorm.DB) http.HandlerFunc { } } -// adminApplicationsRotateSecretHandler 處理 POST /admin/applications/{id}/secret: +// ApplicationUpdateHandler 處理 POST /admin/applications/{id}:驗證並儲存 +// 編輯後的註冊內容——client_id 與 client secret 不在此變更(輪替另經 +// /{id}/secret);由機密式改為公開式時一併清除 secret 雜湊。成功後 PRG +// 回編輯頁顯示成功訊息(編輯無一次性資料,適用 PRG)。 +func ApplicationUpdateHandler(db *gorm.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + s, ok := requireAdmin(db, w, r) + if !ok { + return + } + // 先載入應用程式:本頁的錯誤重繪需要 client_id 等唯讀欄位。 + app, ok := applicationByID(w, r, db, s) + if !ok { + return + } + if err := r.ParseForm(); err != nil { + renderAdminApplicationEditPage(w, r, http.StatusBadRequest, s, "無法解析表單內容", "", app, applicationFormFromApp(app)) + return + } + if !auth.VerifyCSRF(r) { + renderAdminApplicationEditPage(w, r, http.StatusForbidden, s, "表單驗證失敗,請重新整理頁面後再試", "", app, applicationFormFromApp(app)) + return + } + form := applicationFormFromPost(r) + if err := app.Update(form.Name, application.ClientType(form.Type), form.redirectURIList(), form.grantTypeList(), form.Scope); err != nil { + renderAdminApplicationEditPage(w, r, http.StatusBadRequest, s, err.Error(), "", app, form) + return + } + if err := db.Save(app).Error; err != nil { + log.Printf("admin applications: %v", err) + renderAdminApplicationEditPage(w, r, http.StatusInternalServerError, s, "應用程式儲存失敗,請稍後再試", "", app, form) + return + } + http.Redirect(w, r, fmt.Sprintf("/admin/applications/%d?saved=1", app.ID), http.StatusSeeOther) + } +} + +// ApplicationsRotateSecretHandler 處理 POST /admin/applications/{id}/secret: // 輪替機密式 Client 的 client secret(舊 secret 立即失效),並同面板直接 // 渲染一次性明文;公開式 Client 不持有 secret,回 409。 -func adminApplicationsRotateSecretHandler(db *gorm.DB) http.HandlerFunc { +func ApplicationsRotateSecretHandler(db *gorm.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { s, ok := requireAdmin(db, w, r) if !ok { @@ -265,7 +388,7 @@ func adminApplicationsRotateSecretHandler(db *gorm.DB) http.HandlerFunc { renderAdminApplicationsPage(w, r, db, http.StatusBadRequest, s, "無法解析表單內容", nil) return } - if !verifyCSRF(r) { + if !auth.VerifyCSRF(r) { renderAdminApplicationsPage(w, r, db, http.StatusForbidden, s, "表單驗證失敗,請重新整理頁面後再試", nil) return } @@ -293,10 +416,10 @@ func adminApplicationsRotateSecretHandler(db *gorm.DB) http.HandlerFunc { } } -// adminApplicationsDeleteHandler 處理 POST /admin/applications/{id}/delete: +// ApplicationsDeleteHandler 處理 POST /admin/applications/{id}/delete: // 刪除應用程式註冊(連同其 client_id/secret 一併失效),成功後 PRG 導回 // 管理頁。 -func adminApplicationsDeleteHandler(db *gorm.DB) http.HandlerFunc { +func ApplicationsDeleteHandler(db *gorm.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { s, ok := requireAdmin(db, w, r) if !ok { @@ -306,7 +429,7 @@ func adminApplicationsDeleteHandler(db *gorm.DB) http.HandlerFunc { renderAdminApplicationsPage(w, r, db, http.StatusBadRequest, s, "無法解析表單內容", nil) return } - if !verifyCSRF(r) { + if !auth.VerifyCSRF(r) { renderAdminApplicationsPage(w, r, db, http.StatusForbidden, s, "表單驗證失敗,請重新整理頁面後再試", nil) return } @@ -326,13 +449,13 @@ func adminApplicationsDeleteHandler(db *gorm.DB) http.HandlerFunc { // applicationByID 依路徑參數 {id} 查詢應用程式;id 格式錯誤或查無資料時 // 以 404 重繪管理頁(訊息「應用程式不存在」),其他錯誤以 500 重繪。 // 回傳應用程式與是否繼續處理。 -func applicationByID(w http.ResponseWriter, r *http.Request, db *gorm.DB, s *Session) (*Application, bool) { +func applicationByID(w http.ResponseWriter, r *http.Request, db *gorm.DB, s *auth.Session) (*application.Application, bool) { id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 64) if err != nil { renderAdminApplicationsPage(w, r, db, http.StatusNotFound, s, "應用程式不存在", nil) return nil, false } - var a Application + var a application.Application switch err := db.First(&a, id).Error; { case errors.Is(err, gorm.ErrRecordNotFound): renderAdminApplicationsPage(w, r, db, http.StatusNotFound, s, "應用程式不存在", nil) diff --git a/adminapplications_test.go b/internal/admin/adminapplications_test.go similarity index 56% rename from adminapplications_test.go rename to internal/admin/adminapplications_test.go index b8e9027..2fda621 100644 --- a/adminapplications_test.go +++ b/internal/admin/adminapplications_test.go @@ -1,4 +1,4 @@ -package main +package admin import ( "fmt" @@ -12,17 +12,23 @@ import ( "time" "github.com/go-chi/chi/v5" + + "alterminal/internal/application" + "alterminal/internal/auth" + "alterminal/internal/testdb" ) -// 未帶 Session Cookie 的請求在 requireAdmin 即導向 /login,不觸及資料庫, +// 未帶 auth.Session Cookie 的請求在 requireAdmin 即導向 /login,不觸及資料庫, // 因此 handler 可傳入 nil db。 func TestAdminApplicationsHandlersRequireLogin(t *testing.T) { handlers := map[string]http.HandlerFunc{ - "GET 列表": adminApplicationsPageHandler(nil), - "GET 註冊頁": adminApplicationNewPageHandler(nil), - "POST 註冊": adminApplicationsCreateHandler(nil), - "POST 輪替": adminApplicationsRotateSecretHandler(nil), - "POST 刪除": adminApplicationsDeleteHandler(nil), + "GET 列表": ApplicationsPageHandler(nil), + "GET 註冊頁": ApplicationNewPageHandler(nil), + "POST 註冊": ApplicationsCreateHandler(nil), + "GET 編輯頁": ApplicationEditPageHandler(nil), + "POST 更新": ApplicationUpdateHandler(nil), + "POST 輪替": ApplicationsRotateSecretHandler(nil), + "POST 刪除": ApplicationsDeleteHandler(nil), } for name, h := range handlers { t.Run(name, func(t *testing.T) { @@ -39,17 +45,17 @@ func TestAdminApplicationsHandlersRequireLogin(t *testing.T) { } func TestNewAdminApplicationRows(t *testing.T) { - apps := []Application{ + apps := []application.Application{ { - ID: 1, ClientID: "cid-a", Name: "官方網站", Type: ClientConfidential, - RedirectURIs: RedirectURIs{"https://a.example.com/cb", "https://a.example.com/alt"}, - GrantTypes: GrantTypes{GrantAuthorizationCode, GrantRefreshToken}, + ID: 1, ClientID: "cid-a", Name: "官方網站", Type: application.ClientConfidential, + RedirectURIs: application.RedirectURIs{"https://a.example.com/cb", "https://a.example.com/alt"}, + GrantTypes: application.GrantTypes{application.GrantAuthorizationCode, application.GrantRefreshToken}, Scope: "openid offline_access", CreatedAt: time.Now(), }, { - ID: 2, ClientID: "cid-b", Name: "行動 App", Type: ClientPublic, - RedirectURIs: RedirectURIs{"com.example.app:/cb"}, - GrantTypes: GrantTypes{GrantAuthorizationCode}, + ID: 2, ClientID: "cid-b", Name: "行動 App", Type: application.ClientPublic, + RedirectURIs: application.RedirectURIs{"com.example.app:/cb"}, + GrantTypes: application.GrantTypes{application.GrantAuthorizationCode}, CreatedAt: time.Now(), }, } @@ -79,7 +85,7 @@ func TestNewAdminApplicationRows(t *testing.T) { func TestNewApplicationFormDefaults(t *testing.T) { f := newApplicationForm() - if f.Type != string(ClientConfidential) { + if f.Type != string(application.ClientConfidential) { t.Errorf("預設類型應為 confidential,得到 %q", f.Type) } if !f.GrantAuthCode || f.GrantRefresh || f.GrantClientCred { @@ -111,7 +117,7 @@ func TestApplicationFormFromPost(t *testing.T) { if got := f.redirectURIList(); !reflect.DeepEqual(got, wantURIs) { t.Errorf("redirectURIList = %v, want %v(每行一個、去空白、略過空行)", got, wantURIs) } - wantGrants := []GrantType{GrantRefreshToken, GrantClientCredentials} + wantGrants := []application.GrantType{application.GrantRefreshToken, application.GrantClientCredentials} if got := f.grantTypeList(); !reflect.DeepEqual(got, wantGrants) { t.Errorf("grantTypeList = %v, want %v", got, wantGrants) } @@ -122,11 +128,37 @@ func TestApplicationFormFromPostInvalidType(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/admin/applications", strings.NewReader("name=A&type=webapp")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - if f := applicationFormFromPost(req); f.Type != string(ClientConfidential) { + if f := applicationFormFromPost(req); f.Type != string(application.ClientConfidential) { t.Errorf("非法類型應回復 confidential,得到 %q", f.Type) } } +// applicationFormFromApp:預填既有註冊資料(redirect URI 每行一個)。 +func TestApplicationFormFromApp(t *testing.T) { + a := &application.Application{ + Name: "官方網站", + Type: application.ClientConfidential, + RedirectURIs: application.RedirectURIs{"https://a.example.com/cb", "com.example.app:/cb"}, + GrantTypes: application.GrantTypes{application.GrantAuthorizationCode, application.GrantRefreshToken, application.GrantClientCredentials}, + Scope: "openid profile offline_access", + } + f := applicationFormFromApp(a) + if f.Name != "官方網站" || f.Type != "confidential" || f.Scope != "openid profile offline_access" { + t.Errorf("基本欄位預填不符:%+v", f) + } + if f.RedirectURIs != "https://a.example.com/cb\ncom.example.app:/cb" { + t.Errorf("RedirectURIs 應以換行分隔預填,得到 %q", f.RedirectURIs) + } + if !f.GrantAuthCode || !f.GrantRefresh || !f.GrantClientCred { + t.Errorf("核取狀態預填不符:%+v", f) + } + // 預填後再以 redirectURIList 解析應還原為原清單(textarea 往返)。 + want := []string{"https://a.example.com/cb", "com.example.app:/cb"} + if got := f.redirectURIList(); !reflect.DeepEqual(got, want) { + t.Errorf("redirectURIList = %v, want %v", got, want) + } +} + // renderAdminApplicationsPage 需要資料庫,模板輸出直接以假資料渲染測試。 func TestAdminApplicationsTemplate(t *testing.T) { data := adminApplicationsPageData{ @@ -141,11 +173,13 @@ func TestAdminApplicationsTemplate(t *testing.T) { }, } rec := httptest.NewRecorder() - renderHTML(rec, http.StatusOK, adminApplicationsTmpl, data) + auth.RenderHTML(rec, http.StatusOK, auth.AdminApplicationsTmpl, data) body := rec.Body.String() for _, want := range []string{ "應用程式管理", // 標題 `href="/admin/applications/new"`, // 註冊新應用程式按鈕(獨立頁) + `href="/admin/applications/9"`, // 編輯連結(機密式) + `href="/admin/applications/5"`, // 編輯連結(公開式) `value="token-A"`, // CSRF 隱藏欄位 `action="/admin/applications/9/secret"`, // 機密式的輪替表單 `action="/admin/applications/9/delete"`, // 刪除表單 @@ -182,7 +216,7 @@ func TestAdminApplicationNewTemplate(t *testing.T) { Form: newApplicationForm(), } rec := httptest.NewRecorder() - renderHTML(rec, http.StatusOK, adminApplicationNewTmpl, data) + auth.RenderHTML(rec, http.StatusOK, auth.AdminApplicationNewTmpl, data) body := rec.Body.String() for _, want := range []string{ "註冊新應用程式", // 標題 @@ -215,7 +249,7 @@ func TestAdminApplicationNewTemplateSecretPanel(t *testing.T) { Secret: &secretPanel{Name: "官方網站", ClientID: "cid-conf", Secret: "plain-secret-value"}, } rec := httptest.NewRecorder() - renderHTML(rec, http.StatusOK, adminApplicationNewTmpl, data) + auth.RenderHTML(rec, http.StatusOK, auth.AdminApplicationNewTmpl, data) body := rec.Body.String() for _, want := range []string{"已註冊", "只顯示這一次", "cid-conf", "plain-secret-value"} { if !strings.Contains(body, want) { @@ -235,7 +269,7 @@ func TestAdminApplicationNewTemplatePublicPanel(t *testing.T) { Secret: &secretPanel{Name: "行動 App", ClientID: "cid-pub", Public: true}, } rec := httptest.NewRecorder() - renderHTML(rec, http.StatusOK, adminApplicationNewTmpl, data) + auth.RenderHTML(rec, http.StatusOK, auth.AdminApplicationNewTmpl, data) body := rec.Body.String() for _, want := range []string{"行動 App 已註冊", "PKCE", "cid-pub"} { if !strings.Contains(body, want) { @@ -249,6 +283,71 @@ func TestAdminApplicationNewTemplatePublicPanel(t *testing.T) { } } +// 編輯頁模板:唯讀欄位(client_id)、預填表單與送出目標;無一次性面板。 +func TestAdminApplicationEditTemplate(t *testing.T) { + data := adminApplicationEditPageData{ + Username: "alice", Email: "alice@example.com", CSRF: "token-E", + ID: 9, ClientID: "cid-conf", Created: "2026-10-02 12:00:00 +08:00", + Form: applicationFormFromApp(&application.Application{ + Name: "官方網站", + Type: application.ClientConfidential, + RedirectURIs: application.RedirectURIs{"https://app.example.com/cb"}, + GrantTypes: application.GrantTypes{application.GrantAuthorizationCode, application.GrantRefreshToken}, + Scope: "openid offline_access", + }), + } + rec := httptest.NewRecorder() + auth.RenderHTML(rec, http.StatusOK, auth.AdminApplicationEditTmpl, data) + body := rec.Body.String() + for _, want := range []string{ + "編輯應用程式", // 標題 + `action="/admin/applications/9"`, // 表單送回本頁 + `value="token-E"`, // CSRF 隱藏欄位 + "cid-conf", // client_id 唯讀顯示 + "2026-10-02 12:00:00 +08:00", // 建立時間(html/template 將 + 轉義) + `value="官方網站"`, // 名稱預填 + `>https://app.example.com/cb`, // redirect URI 預填 + `value="openid offline_access"`, // scope 預填 + "checked", // 已啟用 grant type 的核取狀態 + "儲存變更", // 送出按鈕 + `href="/admin/applications" aria-current="page"`, // 導覽(目前頁同管理頁) + `href="/admin/keys"`, + `href="/login"`, + `action="/logout"`, + "alice@example.com", + } { + if !strings.Contains(body, want) { + t.Errorf("編輯頁缺少 %s", want) + } + } + for _, absent := range []string{ + "只顯示這一次", // 編輯無一次性面板 + "已儲存變更", // 未帶 ?saved=1 時不出現成功訊息 + `action="/admin/applications/new"`, // 不應送回註冊頁 + } { + if strings.Contains(body, absent) { + t.Errorf("編輯頁不應出現 %s", absent) + } + } +} + +// PRG(?saved=1)後的編輯頁顯示成功訊息。 +func TestAdminApplicationEditTemplateSaved(t *testing.T) { + data := adminApplicationEditPageData{ + Username: "alice", Email: "alice@example.com", CSRF: "token-E", + ID: 9, ClientID: "cid-conf", Success: "已儲存變更", + Form: applicationFormFromApp(&application.Application{ + Name: "官方網站", Type: application.ClientConfidential, + RedirectURIs: application.RedirectURIs{"https://app.example.com/cb"}, + }), + } + rec := httptest.NewRecorder() + auth.RenderHTML(rec, http.StatusOK, auth.AdminApplicationEditTmpl, data) + if body := rec.Body.String(); !strings.Contains(body, "已儲存變更") { + t.Error("帶 Success 時應顯示成功訊息") + } +} + // 輪替成功的一次性明文面板(渲染於管理列表頁)。 func TestAdminApplicationsTemplateRotatePanel(t *testing.T) { data := adminApplicationsPageData{ @@ -256,7 +355,7 @@ func TestAdminApplicationsTemplateRotatePanel(t *testing.T) { Secret: &secretPanel{Name: "官方網站", ClientID: "cid-conf", Secret: "plain-secret-value", Rotated: true}, } rec := httptest.NewRecorder() - renderHTML(rec, http.StatusOK, adminApplicationsTmpl, data) + auth.RenderHTML(rec, http.StatusOK, auth.AdminApplicationsTmpl, data) body := rec.Body.String() for _, want := range []string{"已輪替", "只顯示這一次", "cid-conf", "plain-secret-value"} { if !strings.Contains(body, want) { @@ -271,7 +370,7 @@ func TestAdminApplicationsTemplateEmpty(t *testing.T) { Username: "alice", Email: "alice@example.com", CSRF: "token-A", } rec := httptest.NewRecorder() - renderHTML(rec, http.StatusOK, adminApplicationsTmpl, data) + auth.RenderHTML(rec, http.StatusOK, auth.AdminApplicationsTmpl, data) body := rec.Body.String() if !strings.Contains(body, "尚無應用程式") { t.Error("應顯示空狀態提示") @@ -291,49 +390,51 @@ func TestAdminApplicationsTemplateEmpty(t *testing.T) { var secretInBody = regexp.MustCompile(`>([A-Za-z0-9_-]{43})<`) func TestAdminApplicationsIntegration(t *testing.T) { - db := newTestDB(t) + db := testdb.New(t) - admin := &User{Username: "appadmin", Email: "appadmin@example.com", Role: RoleAdmin} + admin := &auth.User{Username: "appadmin", Email: "appadmin@example.com", Role: auth.RoleAdmin} if err := admin.SetPassword("sup3r-secret"); err != nil { t.Fatal(err) } if err := db.Create(admin).Error; err != nil { t.Fatal(err) } - member := &User{Username: "appuser", Email: "appuser@example.com", Role: RoleUser} + member := &auth.User{Username: "appuser", Email: "appuser@example.com", Role: auth.RoleUser} if err := member.SetPassword("sup3r-secret"); err != nil { t.Fatal(err) } if err := db.Create(member).Error; err != nil { t.Fatal(err) } - adminSess, err := createSession(db, admin.ID) + adminSess, err := auth.CreateSession(db, admin.ID) if err != nil { t.Fatal(err) } - memberSess, err := createSession(db, member.ID) + memberSess, err := auth.CreateSession(db, member.ID) if err != nil { t.Fatal(err) } r := chi.NewRouter() - r.Get("/admin/applications", adminApplicationsPageHandler(db)) - r.Get("/admin/applications/new", adminApplicationNewPageHandler(db)) - r.Post("/admin/applications/new", adminApplicationsCreateHandler(db)) - r.Post("/admin/applications/{id}/secret", adminApplicationsRotateSecretHandler(db)) - r.Post("/admin/applications/{id}/delete", adminApplicationsDeleteHandler(db)) + r.Get("/admin/applications", ApplicationsPageHandler(db)) + r.Get("/admin/applications/new", ApplicationNewPageHandler(db)) + r.Post("/admin/applications/new", ApplicationsCreateHandler(db)) + r.Get("/admin/applications/{id}", ApplicationEditPageHandler(db)) + r.Post("/admin/applications/{id}", ApplicationUpdateHandler(db)) + r.Post("/admin/applications/{id}/secret", ApplicationsRotateSecretHandler(db)) + r.Post("/admin/applications/{id}/delete", ApplicationsDeleteHandler(db)) appCount := func(t *testing.T) int64 { t.Helper() var n int64 - if err := db.Model(&Application{}).Count(&n).Error; err != nil { + if err := db.Model(&application.Application{}).Count(&n).Error; err != nil { t.Fatal(err) } return n } t.Run("非 admin 存取回 403", func(t *testing.T) { - for _, path := range []string{"/admin/applications", "/admin/applications/new"} { + for _, path := range []string{"/admin/applications", "/admin/applications/new", "/admin/applications/1"} { rec := httptest.NewRecorder() r.ServeHTTP(rec, adminGet(path, memberSess)) if rec.Code != http.StatusForbidden { @@ -420,14 +521,14 @@ func TestAdminApplicationsIntegration(t *testing.T) { } secret1 = m[1] - var app Application + var app application.Application if err := db.First(&app).Error; err != nil { t.Fatal(err) } if app.Name != "官方網站" || app.IsPublic() || !app.CheckSecret(secret1) { t.Errorf("儲存的應用程式與表單輸入不符或 secret 驗證失敗:%+v", app) } - if !app.GrantTypes.Contains(GrantRefreshToken) { + if !app.GrantTypes.Contains(application.GrantRefreshToken) { t.Errorf("應啟用 refresh_token,得到 %v", app.GrantTypes) } if !strings.Contains(body, app.ClientID) { @@ -459,7 +560,7 @@ func TestAdminApplicationsIntegration(t *testing.T) { } }) - var publicApp Application + var publicApp application.Application t.Run("註冊公開式應用程式顯示 PKCE 面板", func(t *testing.T) { rec := postForm(t, "/admin/applications/new", url.Values{ "name": {"行動 App"}, @@ -476,7 +577,7 @@ func TestAdminApplicationsIntegration(t *testing.T) { if strings.Contains(body, "只顯示這一次") { t.Fatal("公開式無 client secret,不應顯示明文警告") } - if err := db.Where("type = ?", ClientPublic).First(&publicApp).Error; err != nil { + if err := db.Where("type = ?", application.ClientPublic).First(&publicApp).Error; err != nil { t.Fatal(err) } if !strings.Contains(body, publicApp.ClientID) { @@ -488,8 +589,8 @@ func TestAdminApplicationsIntegration(t *testing.T) { }) t.Run("輪替機密式 secret", func(t *testing.T) { - var conf Application - if err := db.Where("type = ?", ClientConfidential).First(&conf).Error; err != nil { + var conf application.Application + if err := db.Where("type = ?", application.ClientConfidential).First(&conf).Error; err != nil { t.Fatal(err) } rec := postForm(t, fmt.Sprintf("/admin/applications/%d/secret", conf.ID), url.Values{}) @@ -501,7 +602,7 @@ func TestAdminApplicationsIntegration(t *testing.T) { t.Fatal("輪替後應顯示新的明文 client secret") } - var reloaded Application + var reloaded application.Application if err := db.First(&reloaded, conf.ID).Error; err != nil { t.Fatal(err) } @@ -551,4 +652,176 @@ func TestAdminApplicationsIntegration(t *testing.T) { t.Fatal("應顯示應用程式不存在") } }) + + // 以下編輯流程子測試:此時資料庫僅剩註冊時輪替過一次 secret 的機密式 + // 應用程式(公開式已於前述子測試刪除)。 + confidential := func(t *testing.T) application.Application { + t.Helper() + var a application.Application + if err := db.Where("type = ?", application.ClientConfidential).First(&a).Error; err != nil { + t.Fatal(err) + } + return a + } + + t.Run("編輯頁預填既有註冊內容", func(t *testing.T) { + conf := confidential(t) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, adminGet(fmt.Sprintf("/admin/applications/%d", conf.ID), adminSess)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + for _, want := range []string{ + fmt.Sprintf(`action="/admin/applications/%d"`, conf.ID), + conf.ClientID, // 唯讀顯示 + `value="` + conf.Name + `"`, // 名稱預填 + conf.RedirectURIs[0], // redirect URI 預填 + } { + if !strings.Contains(body, want) { + t.Errorf("編輯頁缺少 %s", want) + } + } + }) + + t.Run("編輯不存在的應用程式回 404", func(t *testing.T) { + rec := httptest.NewRecorder() + r.ServeHTTP(rec, adminGet("/admin/applications/99999", adminSess)) + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", rec.Code) + } + if !strings.Contains(rec.Body.String(), "應用程式不存在") { + t.Fatal("應顯示應用程式不存在") + } + }) + + t.Run("編輯儲存後 PRG 並更新資料庫", func(t *testing.T) { + conf := confidential(t) + rec := postForm(t, fmt.Sprintf("/admin/applications/%d", conf.ID), url.Values{ + "name": {"官方網站 2.0"}, + "type": {"confidential"}, + "redirect_uris": {"https://app.example.com/oidc/callback\nhttps://alt.example.com/cb"}, + "grant_types": {"authorization_code", "refresh_token", "client_credentials"}, + "scope": {"openid profile email offline_access"}, + }) + if rec.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303, body = %s", rec.Code, rec.Body.String()) + } + if want := fmt.Sprintf("/admin/applications/%d?saved=1", conf.ID); rec.Header().Get("Location") != want { + t.Fatalf("Location = %q, want %q", rec.Header().Get("Location"), want) + } + + var reloaded application.Application + if err := db.First(&reloaded, conf.ID).Error; err != nil { + t.Fatal(err) + } + if reloaded.Name != "官方網站 2.0" || reloaded.ClientID != conf.ClientID { + t.Errorf("名稱應更新且 client_id 不變:%+v", reloaded) + } + if len(reloaded.RedirectURIs) != 2 || !reloaded.RedirectURIs.Contains("https://alt.example.com/cb") { + t.Errorf("RedirectURIs 應更新,得到 %v", reloaded.RedirectURIs) + } + if !reloaded.GrantTypes.Contains(application.GrantClientCredentials) { + t.Errorf("GrantTypes 應更新,得到 %v", reloaded.GrantTypes) + } + if reloaded.ClientSecretHash != conf.ClientSecretHash { + t.Error("編輯不應更動 client secret 雜湊") + } + }) + + t.Run("PRG 後的編輯頁顯示成功訊息與新值", func(t *testing.T) { + conf := confidential(t) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, adminGet(fmt.Sprintf("/admin/applications/%d?saved=1", conf.ID), adminSess)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + for _, want := range []string{"已儲存變更", `value="官方網站 2.0"`, "https://alt.example.com/cb"} { + if !strings.Contains(body, want) { + t.Errorf("儲存後的編輯頁缺少 %s", want) + } + } + }) + + t.Run("編輯驗證失敗回 400 保留輸入且不寫入", func(t *testing.T) { + conf := confidential(t) + rec := postForm(t, fmt.Sprintf("/admin/applications/%d", conf.ID), url.Values{ + "name": {"壞 URI 練習"}, + "type": {"confidential"}, + "redirect_uris": {"http://app.example.com/cb"}, // 非 loopback 的 http + }) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + if !strings.Contains(body, "loopback") { + t.Fatal("應顯示 redirect URI 驗證錯誤") + } + if !strings.Contains(body, `value="壞 URI 練習"`) { + t.Fatal("重繪時應保留已輸入的名稱") + } + var reloaded application.Application + if err := db.First(&reloaded, conf.ID).Error; err != nil { + t.Fatal(err) + } + if reloaded.Name != "官方網站 2.0" { + t.Errorf("驗證失敗不應寫入,名稱 = %q", reloaded.Name) + } + }) + + t.Run("改為公開式清除 secret 並停用輪替", func(t *testing.T) { + conf := confidential(t) + edit := func(t *testing.T, typ string) { + t.Helper() + rec := postForm(t, fmt.Sprintf("/admin/applications/%d", conf.ID), url.Values{ + "name": {"官方網站 2.0"}, + "type": {typ}, + "redirect_uris": {"https://app.example.com/oidc/callback"}, + }) + if rec.Code != http.StatusSeeOther { + t.Fatalf("改為 %s status = %d, body = %s", typ, rec.Code, rec.Body.String()) + } + } + + edit(t, "public") + var pub application.Application + if err := db.First(&pub, conf.ID).Error; err != nil { + t.Fatal(err) + } + if !pub.IsPublic() || pub.ClientSecretHash != "" { + t.Fatalf("改為公開式後應清除 secret 雜湊:%+v", pub) + } + + // 公開式不持有 secret,輪替回 409。 + rec := postForm(t, fmt.Sprintf("/admin/applications/%d/secret", conf.ID), url.Values{}) + if rec.Code != http.StatusConflict { + t.Fatalf("公開式輪替 status = %d, want 409", rec.Code) + } + + // 改回機密式:雜湊不應復活,須重新輪替取得新 secret。 + edit(t, "confidential") + var back application.Application + if err := db.First(&back, conf.ID).Error; err != nil { + t.Fatal(err) + } + if back.IsPublic() || back.ClientSecretHash != "" { + t.Fatalf("改回機密式不應復活舊 secret 雜湊:%+v", back) + } + rec = postForm(t, fmt.Sprintf("/admin/applications/%d/secret", conf.ID), url.Values{}) + if rec.Code != http.StatusOK { + t.Fatalf("改回機密式後輪替 status = %d, body = %s", rec.Code, rec.Body.String()) + } + m := secretInBody.FindStringSubmatch(rec.Body.String()) + if m == nil { + t.Fatal("輪替後應顯示新的明文 client secret") + } + var rotated application.Application + if err := db.First(&rotated, conf.ID).Error; err != nil { + t.Fatal(err) + } + if !rotated.CheckSecret(m[1]) { + t.Error("重新輪替的新 client secret 應可驗證") + } + }) } diff --git a/adminkeys.go b/internal/admin/adminkeys.go similarity index 85% rename from adminkeys.go rename to internal/admin/adminkeys.go index db59cd5..c964a70 100644 --- a/adminkeys.go +++ b/internal/admin/adminkeys.go @@ -1,4 +1,4 @@ -package main +package admin import ( "errors" @@ -10,6 +10,7 @@ import ( "github.com/go-chi/chi/v5" "gorm.io/gorm" + "alterminal/internal/auth" "alterminal/internal/jwk" ) @@ -56,18 +57,18 @@ func newAdminKeyRows(keys []jwk.SigningKey) (rows []adminKeyRow, active int) { return rows, active } -// requireAdmin 驗證請求來自持有效 Session 的管理員:未登入或 Session +// requireAdmin 驗證請求來自持有效 auth.Session 的管理員:未登入或 auth.Session // 過期時導向 /login(登入後可再試),已登入但非管理員回 403。 -// 回傳 Session(含 User)與是否繼續處理。 -func requireAdmin(db *gorm.DB, w http.ResponseWriter, r *http.Request) (*Session, bool) { - c, err := r.Cookie(sessionCookieName) +// 回傳 auth.Session(含 auth.User)與是否繼續處理。 +func requireAdmin(db *gorm.DB, w http.ResponseWriter, r *http.Request) (*auth.Session, bool) { + c, err := r.Cookie(auth.CookieName) if err != nil { http.Redirect(w, r, "/login", http.StatusSeeOther) return nil, false } - s, err := getSession(db, c.Value) + s, err := auth.GetSession(db, c.Value) switch { - case errors.Is(err, ErrSessionExpired): + case errors.Is(err, auth.ErrSessionExpired): http.Redirect(w, r, "/login", http.StatusSeeOther) return nil, false case err != nil: @@ -75,7 +76,7 @@ func requireAdmin(db *gorm.DB, w http.ResponseWriter, r *http.Request) (*Session http.Error(w, "內部錯誤", http.StatusInternalServerError) return nil, false } - if s.User.Role != RoleAdmin { + if s.User.Role != auth.RoleAdmin { log.Printf("admin: 非 admin 存取(user=%q)", s.User.Username) http.Error(w, "需要管理員權限", http.StatusForbidden) return nil, false @@ -83,9 +84,9 @@ func requireAdmin(db *gorm.DB, w http.ResponseWriter, r *http.Request) (*Session return s, true } -// adminKeysPageHandler 處理 GET /admin/keys:列出簽章金鑰(kid、演算法、 +// KeysPageHandler 處理 GET /admin/keys:列出簽章金鑰(kid、演算法、 // 建立時間、狀態)與產生/退休表單,僅管理員可存取。 -func adminKeysPageHandler(db *gorm.DB) http.HandlerFunc { +func KeysPageHandler(db *gorm.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { s, ok := requireAdmin(db, w, r) if !ok { @@ -98,21 +99,21 @@ func adminKeysPageHandler(db *gorm.DB) http.HandlerFunc { // renderAdminKeysPage 查詢金鑰並輸出管理頁;errMsg 非空時以指定 status // 重繪頁面並顯示錯誤(表單驗證失敗等)。s 供側欄頁尾顯示使用者資訊。 // 使用中的金鑰排前、新者在前。 -func renderAdminKeysPage(w http.ResponseWriter, r *http.Request, db *gorm.DB, status int, s *Session, errMsg string) { +func renderAdminKeysPage(w http.ResponseWriter, r *http.Request, db *gorm.DB, status int, s *auth.Session, errMsg string) { var keys []jwk.SigningKey if err := db.Order("retired_at IS NULL DESC, created_at DESC").Find(&keys).Error; err != nil { log.Printf("admin keys: %v", err) http.Error(w, "內部錯誤", http.StatusInternalServerError) return } - token, err := newCSRFToken(w, r) + token, err := auth.NewCSRFToken(w, r) if err != nil { log.Printf("csrf token: %v", err) http.Error(w, "內部錯誤", http.StatusInternalServerError) return } rows, active := newAdminKeyRows(keys) - renderHTML(w, status, adminKeysTmpl, adminKeysPageData{ + auth.RenderHTML(w, status, auth.AdminKeysTmpl, adminKeysPageData{ Error: errMsg, Username: s.User.Username, Email: s.User.Email, @@ -122,9 +123,9 @@ func renderAdminKeysPage(w http.ResponseWriter, r *http.Request, db *gorm.DB, st }) } -// adminKeysCreateHandler 處理 POST /admin/keys:產生並儲存新的 RSA +// KeysCreateHandler 處理 POST /admin/keys:產生並儲存新的 RSA // 簽章金鑰,成功後 PRG 導回管理頁。 -func adminKeysCreateHandler(db *gorm.DB) http.HandlerFunc { +func KeysCreateHandler(db *gorm.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { s, ok := requireAdmin(db, w, r) if !ok { @@ -134,7 +135,7 @@ func adminKeysCreateHandler(db *gorm.DB) http.HandlerFunc { renderAdminKeysPage(w, r, db, http.StatusBadRequest, s, "無法解析表單內容") return } - if !verifyCSRF(r) { + if !auth.VerifyCSRF(r) { renderAdminKeysPage(w, r, db, http.StatusForbidden, s, "表單驗證失敗,請重新整理頁面後再試") return } @@ -153,10 +154,10 @@ func adminKeysCreateHandler(db *gorm.DB) http.HandlerFunc { } } -// adminKeysRetireHandler 處理 POST /admin/keys/{id}/retire:退休金鑰。 +// KeysRetireHandler 處理 POST /admin/keys/{id}/retire:退休金鑰。 // 最後一把使用中金鑰不可退休(否則將無金鑰可簽發 JWT);已退休或不存在 // 的金鑰以錯誤訊息重繪頁面。 -func adminKeysRetireHandler(db *gorm.DB) http.HandlerFunc { +func KeysRetireHandler(db *gorm.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { s, ok := requireAdmin(db, w, r) if !ok { @@ -166,7 +167,7 @@ func adminKeysRetireHandler(db *gorm.DB) http.HandlerFunc { renderAdminKeysPage(w, r, db, http.StatusBadRequest, s, "無法解析表單內容") return } - if !verifyCSRF(r) { + if !auth.VerifyCSRF(r) { renderAdminKeysPage(w, r, db, http.StatusForbidden, s, "表單驗證失敗,請重新整理頁面後再試") return } diff --git a/adminkeys_test.go b/internal/admin/adminkeys_test.go similarity index 80% rename from adminkeys_test.go rename to internal/admin/adminkeys_test.go index b0756ad..1cc3445 100644 --- a/adminkeys_test.go +++ b/internal/admin/adminkeys_test.go @@ -1,4 +1,4 @@ -package main +package admin import ( "fmt" @@ -9,19 +9,19 @@ import ( "time" "github.com/go-chi/chi/v5" - "gorm.io/driver/postgres" - "gorm.io/gorm" + "alterminal/internal/auth" "alterminal/internal/jwk" + "alterminal/internal/testdb" ) -// 未帶 Session Cookie 的請求在 requireAdmin 即導向 /login,不觸及資料庫, +// 未帶 auth.Session Cookie 的請求在 requireAdmin 即導向 /login,不觸及資料庫, // 因此 handler 可傳入 nil db。 func TestAdminKeysHandlersRequireLogin(t *testing.T) { handlers := map[string]http.HandlerFunc{ - "GET 列表": adminKeysPageHandler(nil), - "POST 產生": adminKeysCreateHandler(nil), - "POST 退休": adminKeysRetireHandler(nil), + "GET 列表": KeysPageHandler(nil), + "POST 產生": KeysCreateHandler(nil), + "POST 退休": KeysRetireHandler(nil), } for name, h := range handlers { t.Run(name, func(t *testing.T) { @@ -79,7 +79,7 @@ func TestAdminKeysTemplate(t *testing.T) { }, } rec := httptest.NewRecorder() - renderHTML(rec, http.StatusOK, adminKeysTmpl, data) + auth.RenderHTML(rec, http.StatusOK, auth.AdminKeysTmpl, data) body := rec.Body.String() for _, want := range []string{ "金鑰管理", // 標題 @@ -112,7 +112,7 @@ func TestAdminKeysTemplateLastActive(t *testing.T) { Keys: []adminKeyRow{{ID: 7, Kid: "kid-only", Algorithm: "RS256", Active: true, LastActive: true}}, } rec := httptest.NewRecorder() - renderHTML(rec, http.StatusOK, adminKeysTmpl, data) + auth.RenderHTML(rec, http.StatusOK, auth.AdminKeysTmpl, data) body := rec.Body.String() if strings.Contains(body, "/retire") { t.Error("唯一使用中金鑰不應出現退休表單") @@ -129,49 +129,19 @@ func TestAdminKeysTemplateNoActiveWarning(t *testing.T) { Keys: []adminKeyRow{{ID: 7, Kid: "kid-old", Algorithm: "RS256"}}, } rec := httptest.NewRecorder() - renderHTML(rec, http.StatusOK, adminKeysTmpl, data) + auth.RenderHTML(rec, http.StatusOK, auth.AdminKeysTmpl, data) if !strings.Contains(rec.Body.String(), "目前沒有使用中的金鑰") { t.Error("無使用中金鑰時應顯示警告") } } // --- 整合測試:需要本機 PostgreSQL,連不上時跳過 --- - -// newTestDB 連線本機 PostgreSQL 並準備專用的 alterminal_test 資料庫 -// (與開發資料庫 alterminal 隔離),供整合測試使用。 -func newTestDB(t *testing.T) *gorm.DB { - t.Helper() - admin, err := gorm.Open(postgres.Open(fmt.Sprintf( - "host=%s port=%s user=%s password=%s dbname=postgres sslmode=disable TimeZone=UTC", - envOr("DB_HOST", "localhost"), envOr("DB_PORT", "5432"), - envOr("DB_USER", "postgres"), envOr("DB_PASSWORD", "postgres"), - )), &gorm.Config{}) - if err != nil { - t.Skipf("本機 PostgreSQL 不可用,跳過整合測試:%v", err) - } - if err := admin.Exec("CREATE DATABASE alterminal_test").Error; err != nil && !strings.Contains(err.Error(), "already exists") { - t.Skipf("無法建立測試資料庫:%v", err) - } - t.Setenv("DB_NAME", "alterminal_test") - db, err := openDB() - if err != nil { - t.Skipf("連線測試資料庫失敗:%v", err) - } - t.Cleanup(func() { - if sqlDB, err := db.DB(); err == nil { - sqlDB.Close() - } - }) - if err := db.Exec("TRUNCATE users, sessions, signing_keys, applications RESTART IDENTITY CASCADE").Error; err != nil { - t.Fatalf("清空測試資料失敗:%v", err) - } - return db -} +// 測試資料庫(alterminal_test)的準備見 internal/testdb。 func csrfCookieOf(t *testing.T, rec *httptest.ResponseRecorder) *http.Cookie { t.Helper() for _, c := range rec.Result().Cookies() { - if c.Name == csrfCookieName { + if c.Name == auth.CSRFCookieName { return c } } @@ -179,18 +149,18 @@ func csrfCookieOf(t *testing.T, rec *httptest.ResponseRecorder) *http.Cookie { return nil } -// adminGet 建立帶 Session Cookie 的 GET 請求(管理頁共用)。 -func adminGet(path string, sess *Session) *http.Request { +// adminGet 建立帶 auth.Session Cookie 的 GET 請求(管理頁共用)。 +func adminGet(path string, sess *auth.Session) *http.Request { req := httptest.NewRequest(http.MethodGet, path, nil) - req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: sess.ID}) + req.AddCookie(&http.Cookie{Name: auth.CookieName, Value: sess.ID}) return req } -// adminPost 建立帶 Session Cookie(與可選 CSRF Cookie)的表單 POST 請求。 -func adminPost(path, body string, sess *Session, csrf *http.Cookie) *http.Request { +// adminPost 建立帶 auth.Session Cookie(與可選 CSRF Cookie)的表單 POST 請求。 +func adminPost(path, body string, sess *auth.Session, csrf *http.Cookie) *http.Request { req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: sess.ID}) + req.AddCookie(&http.Cookie{Name: auth.CookieName, Value: sess.ID}) if csrf != nil { req.AddCookie(csrf) } @@ -198,35 +168,35 @@ func adminPost(path, body string, sess *Session, csrf *http.Cookie) *http.Reques } func TestAdminKeysIntegration(t *testing.T) { - db := newTestDB(t) + db := testdb.New(t) - admin := &User{Username: "keyadmin", Email: "keyadmin@example.com", Role: RoleAdmin} + admin := &auth.User{Username: "keyadmin", Email: "keyadmin@example.com", Role: auth.RoleAdmin} if err := admin.SetPassword("sup3r-secret"); err != nil { t.Fatal(err) } if err := db.Create(admin).Error; err != nil { t.Fatal(err) } - member := &User{Username: "keyuser", Email: "keyuser@example.com", Role: RoleUser} + member := &auth.User{Username: "keyuser", Email: "keyuser@example.com", Role: auth.RoleUser} if err := member.SetPassword("sup3r-secret"); err != nil { t.Fatal(err) } if err := db.Create(member).Error; err != nil { t.Fatal(err) } - adminSess, err := createSession(db, admin.ID) + adminSess, err := auth.CreateSession(db, admin.ID) if err != nil { t.Fatal(err) } - memberSess, err := createSession(db, member.ID) + memberSess, err := auth.CreateSession(db, member.ID) if err != nil { t.Fatal(err) } r := chi.NewRouter() - r.Get("/admin/keys", adminKeysPageHandler(db)) - r.Post("/admin/keys", adminKeysCreateHandler(db)) - r.Post("/admin/keys/{id}/retire", adminKeysRetireHandler(db)) + r.Get("/admin/keys", KeysPageHandler(db)) + r.Post("/admin/keys", KeysCreateHandler(db)) + r.Post("/admin/keys/{id}/retire", KeysRetireHandler(db)) t.Run("非 admin 存取回 403", func(t *testing.T) { rec := httptest.NewRecorder() diff --git a/application.go b/internal/application/application.go similarity index 79% rename from application.go rename to internal/application/application.go index 45fdd31..143042e 100644 --- a/application.go +++ b/internal/application/application.go @@ -1,13 +1,16 @@ -package main +package application import ( "errors" "fmt" "net/url" + "sort" "strings" "time" "gorm.io/gorm" + + "alterminal/internal/auth" ) // ClientType 為 OAuth 2.0 Client 類型(RFC 6749 §2.1):confidential 能 @@ -52,6 +55,17 @@ var supportedScopes = map[string]bool{ // defaultScope 為註冊時未指定 scope 的預設值。 const defaultScope = "openid profile email" +// ScopesSupported 回傳支援的 scope 清單(已排序),供 Discovery 端點的 +// scopes_supported 發佈(與本套件的註冊驗證共用同一份清單)。 +func ScopesSupported() []string { + out := make([]string, 0, len(supportedScopes)) + for s := range supportedScopes { + out = append(out, s) + } + sort.Strings(out) + return out +} + // RedirectURIs 為已註冊的 redirect URI 清單(JSON 陣列儲存)。RFC 6749 // §3.1.2.3 要求端點比對時與註冊值完全相同(字串相等,不做正規化), // 故以字串清單逐一比對。 @@ -102,28 +116,34 @@ func (a *Application) IsPublic() bool { return a.Type == ClientPublic } -// NewApplication 建立新的應用程式註冊:先驗證內容,再產生全域唯一的 -// client_id;機密式 Client 另產生 client secret,明文僅經回傳值交付一 -// 次,呼叫方應立即提供給應用程式管理者,不得儲存明文。grantTypes 為 -// 空時預設僅 authorization_code;scope 為空時預設「openid profile email」。 -func NewApplication(name string, typ ClientType, redirectURIs []string, grantTypes []GrantType, scope string) (*Application, string, error) { - a := &Application{ - Name: strings.TrimSpace(name), - Type: typ, - RedirectURIs: append(RedirectURIs{}, redirectURIs...), // 保證非 nil,序列化為 [] 而非 null - GrantTypes: grantTypes, - Scope: strings.TrimSpace(scope), - } +// fill 套用註冊表單欄位並補上預設值(grantTypes 空時預設僅 +// authorization_code;scope 空時預設「openid profile email」)後驗證, +// 供 NewApplication 與 Update 共用。驗證失敗時 a 可能已被部分修改, +// 呼叫方不應將其儲存。 +func (a *Application) fill(name string, typ ClientType, redirectURIs []string, grantTypes []GrantType, scope string) error { + a.Name = strings.TrimSpace(name) + a.Type = typ + a.RedirectURIs = append(RedirectURIs{}, redirectURIs...) // 保證非 nil,序列化為 [] 而非 null + a.GrantTypes = grantTypes + a.Scope = strings.TrimSpace(scope) if len(a.GrantTypes) == 0 { a.GrantTypes = GrantTypes{GrantAuthorizationCode} } if a.Scope == "" { a.Scope = defaultScope } - if err := a.Validate(); err != nil { + return a.Validate() +} + +// NewApplication 建立新的應用程式註冊:先驗證內容,再產生全域唯一的 +// client_id;機密式 Client 另產生 client secret,明文僅經回傳值交付一 +// 次,呼叫方應立即提供給應用程式管理者,不得儲存明文。 +func NewApplication(name string, typ ClientType, redirectURIs []string, grantTypes []GrantType, scope string) (*Application, string, error) { + a := &Application{} + if err := a.fill(name, typ, redirectURIs, grantTypes, scope); err != nil { return nil, "", err } - id, err := newRandomToken(16) + id, err := auth.NewToken(16) if err != nil { return nil, "", fmt.Errorf("generate client id: %w", err) } @@ -137,6 +157,21 @@ func NewApplication(name string, typ ClientType, redirectURIs []string, grantTyp return a, secret, nil } +// Update 以新的註冊內容更新既有應用程式:client_id 為公開識別碼,已 +// 嵌入各 RP 的設定,不可變更;client secret 亦不受影響(輪替另經 +// GenerateSecret)。由機密式改為公開式時一併清除既有 secret 雜湊—— +// 舊 secret 隨型別切換立即失效,日後改回機密式也不會復活,須重新輪替 +// 取得新 secret。驗證失敗時 a 可能已被部分修改,呼叫方不應將其儲存。 +func (a *Application) Update(name string, typ ClientType, redirectURIs []string, grantTypes []GrantType, scope string) error { + if err := a.fill(name, typ, redirectURIs, grantTypes, scope); err != nil { + return err + } + if a.IsPublic() { + a.ClientSecretHash = "" + } + return nil +} + // Validate 檢查註冊內容:名稱與類型必填、grant type 受支援且組合合法 // (client_credentials 僅限機密式 Client(RFC 6749 §4.4.3)、 // refresh_token 須伴隨授權碼流程)、使用授權碼流程時至少註冊一個格式 @@ -194,11 +229,11 @@ func (a *Application) GenerateSecret() (string, error) { if a.IsPublic() { return "", errors.New("公開式 Client 不持有 client secret") } - secret, err := newRandomToken(32) + secret, err := auth.NewToken(32) if err != nil { return "", fmt.Errorf("generate client secret: %w", err) } - hash, err := hashPassword(secret) + hash, err := auth.HashPassword(secret) if err != nil { return "", fmt.Errorf("hash client secret: %w", err) } @@ -212,7 +247,7 @@ func (a *Application) CheckSecret(secret string) bool { if a.IsPublic() { return false } - ok, err := verifyPassword(secret, a.ClientSecretHash) + ok, err := auth.VerifyPassword(secret, a.ClientSecretHash) return err == nil && ok } @@ -255,10 +290,10 @@ func validateRedirectURI(raw string) error { return nil } -// getApplicationByClientID 以 client_id 查詢應用程式,供 /authorize、 +// GetByClientID 以 client_id 查詢應用程式,供 /authorize、 // /token 驗證 Client 身分;查無資料時回傳包裹 gorm.ErrRecordNotFound // 的錯誤(以 errors.Is 判斷)。 -func getApplicationByClientID(db *gorm.DB, clientID string) (*Application, error) { +func GetByClientID(db *gorm.DB, clientID string) (*Application, error) { var a Application if err := db.Where("client_id = ?", clientID).First(&a).Error; err != nil { return nil, fmt.Errorf("query application: %w", err) diff --git a/application_test.go b/internal/application/application_test.go similarity index 66% rename from application_test.go rename to internal/application/application_test.go index 28be401..08ae835 100644 --- a/application_test.go +++ b/internal/application/application_test.go @@ -1,11 +1,14 @@ -package main +package application import ( "errors" + "fmt" + "os" "reflect" "strings" "testing" + "gorm.io/driver/postgres" "gorm.io/gorm" ) @@ -168,6 +171,87 @@ func TestApplicationSecretRotation(t *testing.T) { } } +func TestApplicationUpdate(t *testing.T) { + a, secret, err := NewApplication("舊名稱", ClientConfidential, + []string{"https://old.example.com/cb"}, + []GrantType{GrantAuthorizationCode}, "openid") + if err != nil { + t.Fatal(err) + } + oldID := a.ClientID + if err := a.Update(" 新名稱 ", ClientConfidential, + []string{"https://new.example.com/cb", "https://alt.example.com/cb"}, + []GrantType{GrantAuthorizationCode, GrantRefreshToken}, "openid profile offline_access"); err != nil { + t.Fatal("Update: ", err) + } + if a.Name != "新名稱" { + t.Errorf("名稱應更新並去除首尾空白,得到 %q", a.Name) + } + if a.ClientID != oldID { + t.Errorf("client_id 不可因更新而變更:%q → %q", oldID, a.ClientID) + } + wantURIs := RedirectURIs{"https://new.example.com/cb", "https://alt.example.com/cb"} + if !reflect.DeepEqual(a.RedirectURIs, wantURIs) { + t.Errorf("RedirectURIs = %v, want %v", a.RedirectURIs, wantURIs) + } + if !a.GrantTypes.Contains(GrantRefreshToken) { + t.Errorf("GrantTypes 應更新,得到 %v", a.GrantTypes) + } + if !a.CheckSecret(secret) { + t.Error("更新註冊內容不應影響既有 client secret") + } +} + +func TestApplicationUpdateDefaultsAndInvalid(t *testing.T) { + a, _, err := NewApplication("示範應用", ClientConfidential, []string{"https://a.example.com/cb"}, nil, "") + if err != nil { + t.Fatal(err) + } + // grant type 與 scope 留空時沿用註冊時的預設行為。 + if err := a.Update("更新後", ClientConfidential, []string{"https://a.example.com/cb"}, nil, ""); err != nil { + t.Fatal("Update: ", err) + } + if !reflect.DeepEqual(a.GrantTypes, GrantTypes{GrantAuthorizationCode}) { + t.Errorf("未指定 grant type 應預設 authorization_code,得到 %v", a.GrantTypes) + } + if a.Scope != defaultScope { + t.Errorf("未指定 scope 應預設 %q,得到 %q", defaultScope, a.Scope) + } + if err := a.Update("示範應用", ClientConfidential, []string{"http://a.example.com/cb"}, nil, ""); err == nil { + t.Error("非法 redirect URI 的 Update 應回傳錯誤") + } +} + +func TestApplicationUpdateToPublicClearsSecret(t *testing.T) { + a, secret, err := NewApplication("後端服務", ClientConfidential, []string{"https://a.example.com/cb"}, nil, "") + if err != nil { + t.Fatal(err) + } + if err := a.Update("後端服務", ClientPublic, []string{"https://a.example.com/cb"}, nil, ""); err != nil { + t.Fatal("Update: ", err) + } + if !a.IsPublic() { + t.Error("更新為公開式後 IsPublic() 應為 true") + } + if a.ClientSecretHash != "" { + t.Errorf("改為公開式應清除 secret 雜湊,得到 %q", a.ClientSecretHash) + } + if a.CheckSecret(secret) { + t.Error("改為公開式後舊 client secret 應失效") + } + // 改回機密式:雜湊不應復活,須以 GenerateSecret 重新輪替。 + if err := a.Update("後端服務", ClientConfidential, []string{"https://a.example.com/cb"}, nil, ""); err != nil { + t.Fatal("Update 回機密式: ", err) + } + if a.ClientSecretHash != "" || a.CheckSecret(secret) { + t.Error("由公開式改回機密式不應復活舊 secret,須重新輪替") + } + newSecret, err := a.GenerateSecret() + if err != nil || !a.CheckSecret(newSecret) { + t.Error("改回機密式後應可重新輪替取得有效 secret") + } +} + func TestApplicationCheckSecretMalformedHash(t *testing.T) { for _, hash := range []string{"", "not-a-phc-hash", "$argon2id$v=19$incomplete"} { a := &Application{Type: ClientConfidential, ClientSecretHash: hash} @@ -242,6 +326,53 @@ func TestValidateRedirectURI(t *testing.T) { // --- 整合測試:需要本機 PostgreSQL,連不上時跳過 --- +// envOrTest 讀取環境變數,空值時回傳 fallback(與 store.EnvOr 同邏輯; +// 測試不可匯入 internal/store——其 AutoMigrate 匯入本套件,會形成測試循環)。 +func envOrTest(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +// newTestDB 連線本機 PostgreSQL 並準備專用的 alterminal_test 資料庫 +// (與開發資料庫 alterminal 隔離),僅遷移與清空 applications 資料表 +// (本套件測試不涉及其他模型)。 +func newTestDB(t *testing.T) *gorm.DB { + t.Helper() + admin, err := gorm.Open(postgres.Open(fmt.Sprintf( + "host=%s port=%s user=%s password=%s dbname=postgres sslmode=disable TimeZone=UTC", + envOrTest("DB_HOST", "localhost"), envOrTest("DB_PORT", "5432"), + envOrTest("DB_USER", "postgres"), envOrTest("DB_PASSWORD", "postgres"), + )), &gorm.Config{}) + if err != nil { + t.Skipf("本機 PostgreSQL 不可用,跳過整合測試:%v", err) + } + if err := admin.Exec("CREATE DATABASE alterminal_test").Error; err != nil && !strings.Contains(err.Error(), "already exists") { + t.Skipf("無法建立測試資料庫:%v", err) + } + db, err := gorm.Open(postgres.Open(fmt.Sprintf( + "host=%s port=%s user=%s password=%s dbname=alterminal_test sslmode=disable TimeZone=UTC", + envOrTest("DB_HOST", "localhost"), envOrTest("DB_PORT", "5432"), + envOrTest("DB_USER", "postgres"), envOrTest("DB_PASSWORD", "postgres"), + )), &gorm.Config{TranslateError: true}) + if err != nil { + t.Skipf("連線測試資料庫失敗:%v", err) + } + t.Cleanup(func() { + if sqlDB, err := db.DB(); err == nil { + sqlDB.Close() + } + }) + if err := db.AutoMigrate(&Application{}); err != nil { + t.Fatalf("遷移測試資料表失敗:%v", err) + } + if err := db.Exec("TRUNCATE applications RESTART IDENTITY CASCADE").Error; err != nil { + t.Fatalf("清空測試資料失敗:%v", err) + } + return db +} + func TestApplicationPersistence(t *testing.T) { db := newTestDB(t) a, secret, err := NewApplication("示範應用", ClientConfidential, @@ -255,7 +386,7 @@ func TestApplicationPersistence(t *testing.T) { t.Fatalf("建立應用程式失敗:%v", err) } - got, err := getApplicationByClientID(db, a.ClientID) + got, err := GetByClientID(db, a.ClientID) if err != nil { t.Fatalf("以 client_id 查詢失敗:%v", err) } @@ -285,7 +416,7 @@ func TestApplicationPersistence(t *testing.T) { t.Errorf("重複的 client_id 應回 gorm.ErrDuplicatedKey,得到 %v", err) } - if _, err := getApplicationByClientID(db, "no-such-client"); !errors.Is(err, gorm.ErrRecordNotFound) { + if _, err := GetByClientID(db, "no-such-client"); !errors.Is(err, gorm.ErrRecordNotFound) { t.Errorf("查無 client_id 應回 gorm.ErrRecordNotFound,得到 %v", err) } } diff --git a/assets/css/input.css b/internal/auth/assets/css/input.css similarity index 100% rename from assets/css/input.css rename to internal/auth/assets/css/input.css diff --git a/internal/auth/assets/css/main.css b/internal/auth/assets/css/main.css new file mode 100644 index 0000000..e42c075 --- /dev/null +++ b/internal/auth/assets/css/main.css @@ -0,0 +1,2 @@ +/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-duration:initial;--tw-ease:initial;--tw-outline-style:solid}}}@layer theme{:root,:host{--font-sans:system-ui, -apple-system, "PingFang TC", "Microsoft JhengHei", sans-serif;--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-700:oklch(55.5% .163 48.998);--color-emerald-400:oklch(76.5% .177 163.223);--color-emerald-500:oklch(69.6% .17 162.48);--color-emerald-700:oklch(50.8% .118 165.612);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-700:oklch(48.8% .243 264.376);--color-violet-400:oklch(70.2% .183 293.541);--color-violet-500:oklch(60.6% .25 292.717);--color-violet-700:oklch(49.1% .27 292.581);--color-neutral-100:oklch(97% 0 none);--color-neutral-200:oklch(92.2% 0 none);--color-neutral-300:oklch(87% 0 none);--color-neutral-400:oklch(70.8% 0 none);--color-neutral-500:oklch(55.6% 0 none);--color-neutral-600:oklch(43.9% 0 none);--color-neutral-700:oklch(37.1% 0 none);--color-neutral-800:oklch(26.9% 0 none);--color-neutral-900:oklch(20.5% 0 none);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-3xl:48rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wide:.025em;--leading-tight:1.25;--radius-lg:.5rem;--radius-xl:.75rem;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-brand:#0071e3;--color-brand-strong:#0077ed}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.fixed{position:fixed}.inset-0{inset:0}.inset-y-0{inset-block:0}.top-4{top:calc(var(--spacing) * 4)}.left-0{left:0}.left-4{left:calc(var(--spacing) * 4)}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.m-0{margin:0}.m-4{margin:calc(var(--spacing) * 4)}.mx-auto{margin-inline:auto}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mb-1{margin-bottom:var(--spacing)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-3\.5{margin-bottom:calc(var(--spacing) * 3.5)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.block{display:block}.flex{display:flex}.hidden{display:none}.inline-block{display:inline-block}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.size-6{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.size-9{width:calc(var(--spacing) * 9);height:calc(var(--spacing) * 9)}.size-11{width:calc(var(--spacing) * 11);height:calc(var(--spacing) * 11)}.min-h-screen{min-height:100vh}.w-72{width:calc(var(--spacing) * 72)}.w-full{width:100%}.max-w-3xl{max-width:var(--container-3xl)}.max-w-88{max-width:calc(var(--spacing) * 88)}.min-w-0{min-width:0}.flex-1{flex:1}.shrink-0{flex-shrink:0}.-translate-x-full{--tw-translate-x:-100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.cursor-pointer{cursor:pointer}.list-none{list-style-type:none}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-neutral-100>:not(:last-child)){border-color:var(--color-neutral-100)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-emerald-500\/40{border-color:#00bb7f66}@supports (color:color-mix(in lab, red, red)){.border-emerald-500\/40{border-color:color-mix(in oklab, var(--color-emerald-500) 40%, transparent)}}.border-neutral-200{border-color:var(--color-neutral-200)}.border-neutral-300{border-color:var(--color-neutral-300)}.border-red-300{border-color:var(--color-red-300)}.bg-amber-500\/10{background-color:#f99c001a}@supports (color:color-mix(in lab, red, red)){.bg-amber-500\/10{background-color:color-mix(in oklab, var(--color-amber-500) 10%, transparent)}}.bg-blue-500\/10{background-color:#3080ff1a}@supports (color:color-mix(in lab, red, red)){.bg-blue-500\/10{background-color:color-mix(in oklab, var(--color-blue-500) 10%, transparent)}}.bg-brand{background-color:var(--color-brand)}.bg-brand\/10{background-color:#0071e31a}@supports (color:color-mix(in lab, red, red)){.bg-brand\/10{background-color:color-mix(in oklab, var(--color-brand) 10%, transparent)}}.bg-emerald-500\/10{background-color:#00bb7f1a}@supports (color:color-mix(in lab, red, red)){.bg-emerald-500\/10{background-color:color-mix(in oklab, var(--color-emerald-500) 10%, transparent)}}.bg-neutral-100{background-color:var(--color-neutral-100)}.bg-neutral-500\/10{background-color:#7373731a}@supports (color:color-mix(in lab, red, red)){.bg-neutral-500\/10{background-color:color-mix(in oklab, var(--color-neutral-500) 10%, transparent)}}.bg-neutral-900\/40{background-color:#17171766}@supports (color:color-mix(in lab, red, red)){.bg-neutral-900\/40{background-color:color-mix(in oklab, var(--color-neutral-900) 40%, transparent)}}.bg-red-500\/10{background-color:#fb2c361a}@supports (color:color-mix(in lab, red, red)){.bg-red-500\/10{background-color:color-mix(in oklab, var(--color-red-500) 10%, transparent)}}.bg-transparent{background-color:#0000}.bg-violet-500\/10{background-color:#8d54ff1a}@supports (color:color-mix(in lab, red, red)){.bg-violet-500\/10{background-color:color-mix(in oklab, var(--color-violet-500) 10%, transparent)}}.bg-white{background-color:var(--color-white)}.p-0{padding:0}.p-4{padding:calc(var(--spacing) * 4)}.p-8{padding:calc(var(--spacing) * 8)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-3\.5{padding-inline:calc(var(--spacing) * 3.5)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-6{padding-block:calc(var(--spacing) * 6)}.text-center{text-align:center}.text-left{text-align:left}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[15px\]{font-size:15px}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.text-amber-700{color:var(--color-amber-700)}.text-blue-700{color:var(--color-blue-700)}.text-brand{color:var(--color-brand)}.text-emerald-700{color:var(--color-emerald-700)}.text-neutral-400{color:var(--color-neutral-400)}.text-neutral-500{color:var(--color-neutral-500)}.text-neutral-600{color:var(--color-neutral-600)}.text-neutral-700{color:var(--color-neutral-700)}.text-neutral-900{color:var(--color-neutral-900)}.text-red-600{color:var(--color-red-600)}.text-violet-700{color:var(--color-violet-700)}.text-white{color:var(--color-white)}.uppercase{text-transform:uppercase}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-200{--tw-duration:.2s;transition-duration:.2s}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.peer-checked\:block:is(:where(.peer):checked~*){display:block}.peer-checked\:hidden:is(:where(.peer):checked~*){display:none}.peer-checked\:translate-x-0:is(:where(.peer):checked~*){--tw-translate-x:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-focus-visible\:outline-2:is(:where(.peer):focus-visible~*){outline-style:var(--tw-outline-style);outline-width:2px}.peer-focus-visible\:outline-offset-2:is(:where(.peer):focus-visible~*){outline-offset:2px}.peer-focus-visible\:outline-brand:is(:where(.peer):focus-visible~*){outline-color:var(--color-brand)}@media (hover:hover){.hover\:bg-brand-strong:hover{background-color:var(--color-brand-strong)}.hover\:bg-neutral-100:hover{background-color:var(--color-neutral-100)}.hover\:bg-red-50:hover{background-color:var(--color-red-50)}}.focus\:border-transparent:focus{border-color:#0000}.focus\:outline-2:focus{outline-style:var(--tw-outline-style);outline-width:2px}.focus\:outline-offset-1:focus{outline-offset:1px}.focus\:outline-brand:focus{outline-color:var(--color-brand)}@media (min-width:40rem){.sm\:flex-row{flex-direction:row}}@media (min-width:48rem){.md\:hidden{display:none}.md\:hidden\!{display:none!important}.md\:translate-x-0{--tw-translate-x:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.md\:p-10{padding:calc(var(--spacing) * 10)}.md\:pl-72{padding-left:calc(var(--spacing) * 72)}}@media (prefers-color-scheme:dark){:where(.dark\:divide-neutral-700\/60>:not(:last-child)){border-color:#40404099}@supports (color:color-mix(in lab, red, red)){:where(.dark\:divide-neutral-700\/60>:not(:last-child)){border-color:color-mix(in oklab, var(--color-neutral-700) 60%, transparent)}}.dark\:border-neutral-600{border-color:var(--color-neutral-600)}.dark\:border-neutral-700{border-color:var(--color-neutral-700)}.dark\:border-red-500\/60{border-color:#fb2c3699}@supports (color:color-mix(in lab, red, red)){.dark\:border-red-500\/60{border-color:color-mix(in oklab, var(--color-red-500) 60%, transparent)}}.dark\:bg-brand\/25{background-color:#0071e340}@supports (color:color-mix(in lab, red, red)){.dark\:bg-brand\/25{background-color:color-mix(in oklab, var(--color-brand) 25%, transparent)}}.dark\:bg-neutral-800{background-color:var(--color-neutral-800)}.dark\:bg-neutral-900{background-color:var(--color-neutral-900)}.dark\:text-amber-400{color:var(--color-amber-400)}.dark\:text-blue-200{color:var(--color-blue-200)}.dark\:text-blue-400{color:var(--color-blue-400)}.dark\:text-emerald-400{color:var(--color-emerald-400)}.dark\:text-neutral-100{color:var(--color-neutral-100)}.dark\:text-neutral-300{color:var(--color-neutral-300)}.dark\:text-neutral-400{color:var(--color-neutral-400)}.dark\:text-neutral-500{color:var(--color-neutral-500)}.dark\:text-red-400{color:var(--color-red-400)}.dark\:text-violet-400{color:var(--color-violet-400)}.dark\:shadow-black\/40{--tw-shadow-color:#0006}@supports (color:color-mix(in lab, red, red)){.dark\:shadow-black\/40{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 40%, transparent) var(--tw-shadow-alpha), transparent)}}@media (hover:hover){.dark\:hover\:bg-neutral-700:hover{background-color:var(--color-neutral-700)}.dark\:hover\:bg-neutral-700\/60:hover{background-color:#40404099}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-neutral-700\/60:hover{background-color:color-mix(in oklab, var(--color-neutral-700) 60%, transparent)}}.dark\:hover\:bg-red-500\/10:hover{background-color:#fb2c361a}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-red-500\/10:hover{background-color:color-mix(in oklab, var(--color-red-500) 10%, transparent)}}}}}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid} \ No newline at end of file diff --git a/internal/auth/dbtest_test.go b/internal/auth/dbtest_test.go new file mode 100644 index 0000000..de26690 --- /dev/null +++ b/internal/auth/dbtest_test.go @@ -0,0 +1,39 @@ +package auth + +import ( + "fmt" + "os" + + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +// envOrTest 讀取環境變數,空值時回傳 fallback(與 store.EnvOr 同邏輯; +// 測試不可匯入 internal/store——其 AutoMigrate 匯入本套件,會形成測試循環)。 +func envOrTest(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +// openTestDB 連線 DB_* 環境變數指定的資料庫並遷移 users、sessions 資料表, +// 供 login/logout 整合測試使用(測試自行建立資料並於 t.Cleanup 清理)。 +func openTestDB() (*gorm.DB, error) { + dsn := fmt.Sprintf( + "host=%s port=%s user=%s password=%s dbname=%s sslmode=disable TimeZone=UTC", + envOrTest("DB_HOST", "localhost"), + envOrTest("DB_PORT", "5432"), + envOrTest("DB_USER", "postgres"), + envOrTest("DB_PASSWORD", "postgres"), + envOrTest("DB_NAME", "alterminal"), + ) + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{TranslateError: true}) + if err != nil { + return nil, err + } + if err := db.AutoMigrate(&User{}, &Session{}); err != nil { + return nil, fmt.Errorf("auto migrate: %w", err) + } + return db, nil +} diff --git a/login.go b/internal/auth/login.go similarity index 71% rename from login.go rename to internal/auth/login.go index 4c3536a..c6f2966 100644 --- a/login.go +++ b/internal/auth/login.go @@ -1,4 +1,4 @@ -package main +package auth import ( "encoding/json" @@ -13,8 +13,19 @@ import ( "gorm.io/gorm" ) -// sessionCookieName 為存放 Session ID 的 Cookie 名稱。 -const sessionCookieName = "alterminal_session" +// CookieName 為存放 Session ID 的 Cookie 名稱。 +const CookieName = "alterminal_session" + +// SafeNext 檢查登入成功後的返回路徑:僅接受站內路徑——以 / 開頭且不 +// 以 // 開頭(協定相對 URL 會導向外部網站,構成 open redirect),不 +// 合格或未提供者一律回 /。/authorize 導向登入時以 next 攜帶完整授權 +// 請求(OIDC Core §3.1.2.2)。 +func SafeNext(next string) string { + if strings.HasPrefix(next, "/") && !strings.HasPrefix(next, "//") { + return next + } + return "/" +} // loginRequest 為 POST /login 的請求欄位(JSON 與表單共用)。 type loginRequest struct { @@ -50,10 +61,10 @@ type loginResponse struct { ExpiresAt time.Time `json:"expires_at"` } -// loginHandler 處理 POST /login,依 Content-Type 分流:application/json 走 +// LoginHandler 處理 POST /login,依 Content-Type 分流:application/json 走 // API 流程(回 JSON),表單走瀏覽器流程(回 HTML)。兩者共用帳密驗證與 // Session 建立;帳密錯誤一律回 401,不洩漏帳號是否存在。 -func loginHandler(db *gorm.DB) http.HandlerFunc { +func LoginHandler(db *gorm.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ct := r.Header.Get("Content-Type") var isForm bool @@ -63,33 +74,36 @@ func loginHandler(db *gorm.DB) http.HandlerFunc { strings.HasPrefix(ct, "multipart/form-data"): isForm = true default: - writeError(w, http.StatusUnsupportedMediaType, "Content-Type 須為 application/json 或表單") + WriteError(w, http.StatusUnsupportedMediaType, "Content-Type 須為 application/json 或表單") return } r.Body = http.MaxBytesReader(w, r.Body, 64<<10) var in loginRequest + // next 為表單流程的登入後返回路徑(JSON API 流程不適用)。 + next := "/" if isForm { if err := r.ParseForm(); err != nil { - renderLoginPage(w, r, http.StatusBadRequest, "無法解析表單內容", "") + renderLoginPage(w, r, http.StatusBadRequest, "無法解析表單內容", "", "/") return } - if !verifyCSRF(r) { - renderLoginPage(w, r, http.StatusForbidden, "表單驗證失敗,請重新整理頁面後再試", "") + if !VerifyCSRF(r) { + renderLoginPage(w, r, http.StatusForbidden, "表單驗證失敗,請重新整理頁面後再試", "", "/") return } in = loginRequest{Username: r.PostFormValue("username"), Password: r.PostFormValue("password")} + next = SafeNext(r.PostFormValue("next")) } else if err := json.NewDecoder(r.Body).Decode(&in); err != nil { - writeError(w, http.StatusBadRequest, "無法解析請求內容") + WriteError(w, http.StatusBadRequest, "無法解析請求內容") return } fail := func(status int, msg string) { if isForm { - renderLoginPage(w, r, status, msg, in.Username) + renderLoginPage(w, r, status, msg, in.Username, next) return } - writeError(w, status, msg) + WriteError(w, status, msg) } if err := in.validate(); err != nil { fail(http.StatusBadRequest, err.Error()) @@ -107,7 +121,7 @@ func loginHandler(db *gorm.DB) http.HandlerFunc { return } - s, err := createSession(db, u.ID) + s, err := CreateSession(db, u.ID) if err != nil { log.Printf("login: %v", err) fail(http.StatusInternalServerError, "內部錯誤") @@ -116,18 +130,19 @@ func loginHandler(db *gorm.DB) http.HandlerFunc { setSessionCookie(w, r, s) if isForm { - // PRG:以 303 導向帳號首頁 / 顯示已登入狀態,避免重新整理重複送出表單。 - http.Redirect(w, r, "/", http.StatusSeeOther) + // PRG:以 303 導向登入前的返回路徑(無 next 時為帳號首頁 /) + // 顯示已登入狀態,避免重新整理重複送出表單。 + http.Redirect(w, r, next, http.StatusSeeOther) return } - writeJSON(w, http.StatusOK, loginResponse{User: newPublicUser(u), ExpiresAt: s.ExpiresAt}) + WriteJSON(w, http.StatusOK, loginResponse{User: newPublicUser(u), ExpiresAt: s.ExpiresAt}) } } // setSessionCookie 將 Session ID 寫入 HttpOnly Cookie(表單與 API 流程共用)。 func setSessionCookie(w http.ResponseWriter, r *http.Request, s *Session) { http.SetCookie(w, &http.Cookie{ - Name: sessionCookieName, + Name: CookieName, Value: s.ID, Path: "/", Expires: s.ExpiresAt, @@ -144,7 +159,7 @@ var ErrInvalidCredentials = errors.New("帳號或密碼錯誤") // dummyPasswordHash 供查無帳號時使用:對它做一次完整的 argon2 比對, // 讓回應時間與真實驗證一致,避免以時間差枚舉有效帳號。 var dummyPasswordHash = sync.OnceValues(func() (string, error) { - return hashPassword("alterminal-timing-equalizer") + return HashPassword("alterminal-timing-equalizer") }) // authenticateUser 以 username 查詢使用者並驗證密碼。 @@ -153,7 +168,7 @@ func authenticateUser(db *gorm.DB, username, password string) (*User, error) { err := db.Where("username = ?", username).First(&u).Error if errors.Is(err, gorm.ErrRecordNotFound) { h, _ := dummyPasswordHash() - verifyPassword(password, h) // 結果丟棄,僅為消耗同等運算時間 + VerifyPassword(password, h) // 結果丟棄,僅為消耗同等運算時間 return nil, ErrInvalidCredentials } if err != nil { @@ -177,14 +192,14 @@ func newPublicUser(u *User) publicUser { } } -// writeJSON 以 JSON 寫出回應。 -func writeJSON(w http.ResponseWriter, status int, v any) { +// WriteJSON 以 JSON 寫出回應。 +func WriteJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) json.NewEncoder(w).Encode(v) } -// writeError 寫出 {"error": ...} 格式的錯誤回應。 -func writeError(w http.ResponseWriter, status int, msg string) { - writeJSON(w, status, map[string]string{"error": msg}) +// WriteError 寫出 {"error": ...} 格式的錯誤回應。 +func WriteError(w http.ResponseWriter, status int, msg string) { + WriteJSON(w, status, map[string]string{"error": msg}) } diff --git a/login_test.go b/internal/auth/login_test.go similarity index 93% rename from login_test.go rename to internal/auth/login_test.go index b0f7710..778dba8 100644 --- a/login_test.go +++ b/internal/auth/login_test.go @@ -1,4 +1,4 @@ -package main +package auth import ( "encoding/json" @@ -51,9 +51,9 @@ func TestNewRandomToken(t *testing.T) { wantLen := (n*8 + 5) / 6 // base64url 無填充的編碼長度 seen := make(map[string]bool) for i := 0; i < 100; i++ { - token, err := newRandomToken(n) + token, err := NewToken(n) if err != nil { - t.Fatal("newRandomToken: ", err) + t.Fatal("NewToken: ", err) } if len(token) != wantLen { t.Fatalf("n=%d token 長度 = %d, want %d", n, len(token), wantLen) @@ -68,7 +68,7 @@ func TestNewRandomToken(t *testing.T) { // 無效請求應在查詢資料庫前就回應,因此 handler 可以傳入 nil db 進行測試。 func TestLoginHandlerRejectsInvalidInput(t *testing.T) { - h := loginHandler(nil) + h := LoginHandler(nil) plainReq := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(`{"username":"alice","password":"sup3r-secret"}`)) jsonReq := func(body string) *http.Request { @@ -120,11 +120,11 @@ func TestNewPublicUserOmitsPasswordHash(t *testing.T) { // 表單登入成功後以 303 導向帳號首頁 /,而非停留在 /login。 func TestLoginHandlerFormSuccessRedirectsHome(t *testing.T) { - db, err := openDB() + db, err := openTestDB() if err != nil { t.Skipf("資料庫不可用,略過整合測試: %v", err) } - suffix, err := newRandomToken(6) + suffix, err := NewToken(6) if err != nil { t.Fatal(err) } @@ -141,16 +141,16 @@ func TestLoginHandlerFormSuccessRedirectsHome(t *testing.T) { }) body := "csrf_token=token-A&username=" + u.Username + "&password=sup3r-secret" - req := formPost(body, &http.Cookie{Name: csrfCookieName, Value: "token-A"}) + req := formPost(body, &http.Cookie{Name: CSRFCookieName, Value: "token-A"}) rec := httptest.NewRecorder() - loginHandler(db)(rec, req) + LoginHandler(db)(rec, req) if rec.Code != http.StatusSeeOther { t.Fatalf("status = %d, want 303, body = %s", rec.Code, rec.Body.String()) } if loc := rec.Header().Get("Location"); loc != "/" { t.Fatalf("Location = %q, want /", loc) } - if !strings.Contains(rec.Header().Get("Set-Cookie"), sessionCookieName) { + if !strings.Contains(rec.Header().Get("Set-Cookie"), CookieName) { t.Fatalf("登入成功應設定 Session Cookie, Set-Cookie = %v", rec.Header().Values("Set-Cookie")) } } diff --git a/internal/auth/loginnext_test.go b/internal/auth/loginnext_test.go new file mode 100644 index 0000000..e69fa4d --- /dev/null +++ b/internal/auth/loginnext_test.go @@ -0,0 +1,108 @@ +package auth + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// SafeNext 僅接受站內路徑,阻擋外站與協定相對 URL(open redirect)。 +func TestSafeNext(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"站內路徑", "/authorize?client_id=x", "/authorize?client_id=x"}, + {"未提供", "", "/"}, + {"外站絕對 URL", "https://evil.example/phish", "/"}, + {"協定相對 URL", "//evil.example", "/"}, + {"相對路徑", "admin/keys", "/"}, + {"僅 scheme", "javascript:alert(1)", "/"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := SafeNext(tt.in); got != tt.want { + t.Fatalf("SafeNext(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +// GET /login?next=... 應在表單保留 next;已登入時導向 next 而非 /。 +func TestLoginPageNext(t *testing.T) { + next := "/authorize%3Fclient_id%3Dabc" // 已編碼的 query 值 + + t.Run("表單含隱藏 next 欄位", func(t *testing.T) { + rec := httptest.NewRecorder() + LoginPageHandler(nil)(rec, httptest.NewRequest(http.MethodGet, "/login?next="+next, nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), `name="next"`) { + t.Fatalf("登入表單應保留 next 隱藏欄位: %s", rec.Body.String()) + } + }) + + t.Run("未帶 next 時不出現隱藏欄位", func(t *testing.T) { + rec := httptest.NewRecorder() + LoginPageHandler(nil)(rec, httptest.NewRequest(http.MethodGet, "/login", nil)) + if strings.Contains(rec.Body.String(), `name="next"`) { + t.Fatal("無 next 時不需要隱藏欄位") + } + }) +} + +// 表單登入成功後導向 next;惡意的 next 一律回到 /。 +func TestLoginHandlerFormNextRedirect(t *testing.T) { + db, err := openTestDB() + if err != nil { + t.Skipf("資料庫不可用,略過整合測試: %v", err) + } + suffix, err := NewToken(6) + if err != nil { + t.Fatal(err) + } + u := &User{Username: "next-" + suffix, Email: "next-" + suffix + "@example.com"} + if err := u.SetPassword("sup3r-secret"); err != nil { + t.Fatal(err) + } + if err := db.Create(u).Error; err != nil { + t.Fatalf("create user: %v", err) + } + t.Cleanup(func() { + db.Delete(&Session{}, "user_id = ?", u.ID) + db.Delete(&User{}, u.ID) + }) + + login := func(next string) *httptest.ResponseRecorder { + body := "csrf_token=token-A&username=" + u.Username + "&password=sup3r-secret" + if next != "" { + body += "&next=" + next + } + rec := httptest.NewRecorder() + LoginHandler(db)(rec, formPost(body, &http.Cookie{Name: CSRFCookieName, Value: "token-A"})) + return rec + } + + t.Run("合法 next 導向原路徑", func(t *testing.T) { + rec := login("%2Fauthorize%3Fclient_id%3Dabc") + if rec.Code != http.StatusSeeOther { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if loc := rec.Header().Get("Location"); loc != "/authorize?client_id=abc" { + t.Fatalf("Location = %q, want /authorize?client_id=abc", loc) + } + }) + + t.Run("惡意 next 導向首頁", func(t *testing.T) { + rec := login("https%3A%2F%2Fevil.example") + if rec.Code != http.StatusSeeOther { + t.Fatalf("status = %d", rec.Code) + } + if loc := rec.Header().Get("Location"); loc != "/" { + t.Fatalf("Location = %q, want /", loc) + } + }) +} diff --git a/loginpage.go b/internal/auth/loginpage.go similarity index 58% rename from loginpage.go rename to internal/auth/loginpage.go index 3f35c0c..2613e96 100644 --- a/loginpage.go +++ b/internal/auth/loginpage.go @@ -1,4 +1,4 @@ -package main +package auth import ( "crypto/subtle" @@ -19,19 +19,22 @@ var ( loginTmpl = template.Must(template.ParseFS(templateFS, "templates/login.html")) // 已登入頁與管理頁透過 layout.html(側邊導覽欄版面)組合:layout 為 // 第一個(根)模板,頁面模板僅定義 title/content 等區塊覆寫之, - // 故 Execute 仍輸出版面本身。應用程式相關頁面另解析 secretpanel.html - // 的一次性成果面板區塊。 - loggedInTmpl = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/loggedin.html")) - adminKeysTmpl = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/adminkeys.html")) - adminApplicationsTmpl = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/adminapplications.html", "templates/secretpanel.html")) - adminApplicationNewTmpl = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/adminapplicationnew.html", "templates/secretpanel.html")) - notFoundTmpl = template.Must(template.ParseFS(templateFS, "templates/notfound.html")) + // 故 Execute 仍輸出版面本身。註冊頁與管理列表頁另解析 secretpanel.html + // 的一次性成果面板;編輯頁無一次性面板,不在解析之列。 + loggedInTmpl = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/loggedin.html")) + AdminKeysTmpl = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/adminkeys.html")) + AdminApplicationsTmpl = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/adminapplications.html", "templates/secretpanel.html")) + AdminApplicationNewTmpl = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/adminapplicationnew.html", "templates/secretpanel.html")) + AdminApplicationEditTmpl = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/adminapplicationedit.html")) + // 授權同意頁供 oidc 套件的 /authorize 使用,與管理頁同以 layout 組合。 + ConsentTmpl = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/consent.html")) + notFoundTmpl = template.Must(template.ParseFS(templateFS, "templates/notfound.html")) ) -// csrfCookieName 為登入表單 double-submit CSRF 防護的 Cookie 名稱: +// CSRFCookieName 為登入表單 double-submit CSRF 防護的 Cookie 名稱: // token 同時存在 Cookie 與表單隱藏欄位,送出時兩者必須相符。 const ( - csrfCookieName = "alterminal_csrf" + CSRFCookieName = "alterminal_csrf" csrfTTL = time.Hour ) @@ -39,6 +42,7 @@ const ( type loginPageData struct { Error string // 驗證失敗訊息;空字串表示不顯示 Username string // 驗證失敗時保留使用者輸入的帳號 + Next string // 登入成功後的返回路徑(如 /authorize 請求),空表示 / CSRF string // 表單隱藏欄位用 CSRF token,與 Cookie 成對輪替 } @@ -52,15 +56,18 @@ type loggedInPageData struct { CSRF string // 登出表單隱藏欄位用 CSRF token,與 Cookie 成對輪替 } -// loginPageHandler 處理 GET /login(POST /login 的瀏覽器入口):登入頁 -// 僅供未登入者使用——持有效 Session 時導向帳號首頁 /,否則顯示登入表單。 -func loginPageHandler(db *gorm.DB) http.HandlerFunc { +// LoginPageHandler 處理 GET /login(POST /login 的瀏覽器入口):登入頁 +// 僅供未登入者使用——持有效 Session 時導向 next 指定的返回路徑(無則 +// 帳號首頁 /),否則顯示登入表單。next 由 /authorize 於導向登入時 +// 攜入(OIDC Core §3.1.2.2)。 +func LoginPageHandler(db *gorm.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - if c, err := r.Cookie(sessionCookieName); err == nil { - _, err = getSession(db, c.Value) + next := SafeNext(r.URL.Query().Get("next")) + if c, err := r.Cookie(CookieName); err == nil { + _, err = GetSession(db, c.Value) switch { case err == nil: - http.Redirect(w, r, "/", http.StatusSeeOther) + http.Redirect(w, r, next, http.StatusSeeOther) return case errors.Is(err, ErrSessionExpired): // Session 過期,顯示登入表單 @@ -70,13 +77,13 @@ func loginPageHandler(db *gorm.DB) http.HandlerFunc { return } } - renderLoginPage(w, r, http.StatusOK, "", "") + renderLoginPage(w, r, http.StatusOK, "", "", next) } } -// accountPageHandler 處理 GET /(帳號首頁):持有效 Session 顯示已登入 +// AccountPageHandler 處理 GET /(帳號首頁):持有效 Session 顯示已登入 // 狀態(含登出表單),否則顯示登入表單。 -func accountPageHandler(db *gorm.DB) http.HandlerFunc { +func AccountPageHandler(db *gorm.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { renderAccountPage(w, r, db, http.StatusOK, "") } @@ -86,8 +93,8 @@ func accountPageHandler(db *gorm.DB) http.HandlerFunc { // 狀態(含登出表單),否則顯示登入表單。errMsg 非空時顯示於輸出的頁面, // 供登出表單驗證失敗等錯誤以指定 status 重繪目前狀態。 func renderAccountPage(w http.ResponseWriter, r *http.Request, db *gorm.DB, status int, errMsg string) { - if c, err := r.Cookie(sessionCookieName); err == nil { - s, err := getSession(db, c.Value) + if c, err := r.Cookie(CookieName); err == nil { + s, err := GetSession(db, c.Value) switch { case err == nil: renderLoggedInPage(w, r, status, s, errMsg) @@ -100,19 +107,19 @@ func renderAccountPage(w http.ResponseWriter, r *http.Request, db *gorm.DB, stat return } } - renderLoginPage(w, r, status, errMsg, "") + renderLoginPage(w, r, status, errMsg, "", "") } // renderLoggedInPage 輸出已登入狀態頁;每次輸出都輪替 CSRF token, // 供登出表單 double-submit 驗證。 func renderLoggedInPage(w http.ResponseWriter, r *http.Request, status int, s *Session, errMsg string) { - token, err := newCSRFToken(w, r) + token, err := NewCSRFToken(w, r) if err != nil { log.Printf("csrf token: %v", err) http.Error(w, "內部錯誤", http.StatusInternalServerError) return } - renderHTML(w, status, loggedInTmpl, loggedInPageData{ + RenderHTML(w, status, loggedInTmpl, loggedInPageData{ Error: errMsg, Username: s.User.Username, Email: s.User.Email, @@ -123,24 +130,25 @@ func renderLoggedInPage(w http.ResponseWriter, r *http.Request, status int, s *S } // renderLoginPage 輸出登入表單頁;每次輸出都輪替 CSRF token 並重設對應 Cookie。 -func renderLoginPage(w http.ResponseWriter, r *http.Request, status int, errMsg, username string) { - token, err := newCSRFToken(w, r) +// next 為登入成功後的返回路徑,以隱藏欄位隨表單保留。 +func renderLoginPage(w http.ResponseWriter, r *http.Request, status int, errMsg, username, next string) { + token, err := NewCSRFToken(w, r) if err != nil { log.Printf("csrf token: %v", err) http.Error(w, "內部錯誤", http.StatusInternalServerError) return } - renderHTML(w, status, loginTmpl, loginPageData{Error: errMsg, Username: username, CSRF: token}) + RenderHTML(w, status, loginTmpl, loginPageData{Error: errMsg, Username: username, Next: next, CSRF: token}) } -// newCSRFToken 產生新 CSRF token 並設定對應 Cookie,與表單隱藏欄位成對。 -func newCSRFToken(w http.ResponseWriter, r *http.Request) (string, error) { - token, err := newRandomToken(32) +// NewCSRFToken 產生新 CSRF token 並設定對應 Cookie,與表單隱藏欄位成對。 +func NewCSRFToken(w http.ResponseWriter, r *http.Request) (string, error) { + token, err := NewToken(32) if err != nil { return "", err } http.SetCookie(w, &http.Cookie{ - Name: csrfCookieName, + Name: CSRFCookieName, Value: token, Path: "/", MaxAge: int(csrfTTL.Seconds()), @@ -151,9 +159,9 @@ func newCSRFToken(w http.ResponseWriter, r *http.Request) (string, error) { return token, nil } -// verifyCSRF 以 constant-time 比對表單隱藏欄位與 Cookie 中的 CSRF token。 -func verifyCSRF(r *http.Request) bool { - c, err := r.Cookie(csrfCookieName) +// VerifyCSRF 以 constant-time 比對表單隱藏欄位與 Cookie 中的 CSRF token。 +func VerifyCSRF(r *http.Request) bool { + c, err := r.Cookie(CSRFCookieName) if err != nil || c.Value == "" { return false } @@ -161,9 +169,9 @@ func verifyCSRF(r *http.Request) bool { return token != "" && subtle.ConstantTimeCompare([]byte(token), []byte(c.Value)) == 1 } -// renderHTML 以 text/html 輸出模板;模板執行錯誤僅記錄(此時表頭已送出)。 +// RenderHTML 以 text/html 輸出模板;模板執行錯誤僅記錄(此時表頭已送出)。 // CSP 停用外部資源載入(樣式僅允許本站 /static/),表單僅可送出到本站。 -func renderHTML(w http.ResponseWriter, status int, tmpl *template.Template, data any) { +func RenderHTML(w http.ResponseWriter, status int, tmpl *template.Template, data any) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'self'; form-action 'self'") w.WriteHeader(status) diff --git a/loginpage_test.go b/internal/auth/loginpage_test.go similarity index 91% rename from loginpage_test.go rename to internal/auth/loginpage_test.go index b80d09f..aee0414 100644 --- a/loginpage_test.go +++ b/internal/auth/loginpage_test.go @@ -1,4 +1,4 @@ -package main +package auth import ( "net/http" @@ -19,7 +19,7 @@ func formPost(body string, cookie *http.Cookie) *http.Request { // 未帶 Session Cookie 時不會查詢資料庫,因此 handler 可以傳入 nil db。 func TestLoginPageRendersForm(t *testing.T) { - h := loginPageHandler(nil) + h := LoginPageHandler(nil) rec := httptest.NewRecorder() h(rec, httptest.NewRequest(http.MethodGet, "/login", nil)) if rec.Code != http.StatusOK { @@ -36,7 +36,7 @@ func TestLoginPageRendersForm(t *testing.T) { t.Fatalf("登入表單缺少 %s", want) } } - if !strings.Contains(rec.Header().Get("Set-Cookie"), csrfCookieName) { + if !strings.Contains(rec.Header().Get("Set-Cookie"), CSRFCookieName) { t.Fatal("輸出表單時應設定 CSRF Cookie") } } @@ -57,8 +57,8 @@ func TestRenderLoggedInPageSidebar(t *testing.T) { for _, want := range []string{ "alert(1)") + http.StatusUnauthorized, "帳號或密碼錯誤", "", "/") if rec.Code != http.StatusUnauthorized { t.Fatalf("status = %d, want 401", rec.Code) } @@ -100,7 +100,7 @@ func TestRenderLoginPageEscapesPrefill(t *testing.T) { } func TestVerifyCSRF(t *testing.T) { - cookie := &http.Cookie{Name: csrfCookieName, Value: "token-A"} + cookie := &http.Cookie{Name: CSRFCookieName, Value: "token-A"} tests := []struct { name string req *http.Request @@ -114,8 +114,8 @@ func TestVerifyCSRF(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := verifyCSRF(tt.req); got != tt.want { - t.Fatalf("verifyCSRF() = %v, want %v", got, tt.want) + if got := VerifyCSRF(tt.req); got != tt.want { + t.Fatalf("VerifyCSRF() = %v, want %v", got, tt.want) } }) } @@ -123,8 +123,8 @@ func TestVerifyCSRF(t *testing.T) { // 表單流程的錯誤路徑都在查詢資料庫前回應,可用 nil db 測試。 func TestLoginHandlerFormRejections(t *testing.T) { - h := loginHandler(nil) - cookie := &http.Cookie{Name: csrfCookieName, Value: "token-A"} + h := LoginHandler(nil) + cookie := &http.Cookie{Name: CSRFCookieName, Value: "token-A"} t.Run("CSRF 不符回 403 表單", func(t *testing.T) { rec := httptest.NewRecorder() diff --git a/logout.go b/internal/auth/logout.go similarity index 82% rename from logout.go rename to internal/auth/logout.go index 24f1666..d74bfab 100644 --- a/logout.go +++ b/internal/auth/logout.go @@ -1,4 +1,4 @@ -package main +package auth import ( "log" @@ -8,11 +8,11 @@ import ( "gorm.io/gorm" ) -// logoutHandler 處理 POST /logout,依 Content-Type 分流(與登入一致): +// LogoutHandler 處理 POST /logout,依 Content-Type 分流(與登入一致): // 表單走瀏覽器流程(需通過 CSRF 驗證,失敗時以 403 重繪目前狀態頁), // JSON 走 API 流程。登出為冪等操作——查無 Session 亦視為成功;資料庫 // 刪除失敗僅記錄,仍清除 Cookie 並回應成功(Session 最遲於效期到期失效)。 -func logoutHandler(db *gorm.DB) http.HandlerFunc { +func LogoutHandler(db *gorm.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ct := r.Header.Get("Content-Type") var isForm bool @@ -22,7 +22,7 @@ func logoutHandler(db *gorm.DB) http.HandlerFunc { strings.HasPrefix(ct, "multipart/form-data"): isForm = true default: - writeError(w, http.StatusUnsupportedMediaType, "Content-Type 須為 application/json 或表單") + WriteError(w, http.StatusUnsupportedMediaType, "Content-Type 須為 application/json 或表單") return } @@ -31,14 +31,14 @@ func logoutHandler(db *gorm.DB) http.HandlerFunc { renderAccountPage(w, r, db, http.StatusBadRequest, "無法解析表單內容") return } - if !verifyCSRF(r) { + if !VerifyCSRF(r) { renderAccountPage(w, r, db, http.StatusForbidden, "表單驗證失敗,請重新整理頁面後再試") return } } - if c, err := r.Cookie(sessionCookieName); err == nil { - if err := deleteSession(db, c.Value); err != nil { + if c, err := r.Cookie(CookieName); err == nil { + if err := DeleteSession(db, c.Value); err != nil { log.Printf("logout: %v", err) } } @@ -57,7 +57,7 @@ func logoutHandler(db *gorm.DB) http.HandlerFunc { // setSessionCookie 對稱,屬性一致以免因 Path 或 Secure 差異清不掉)。 func clearSessionCookie(w http.ResponseWriter, r *http.Request) { http.SetCookie(w, &http.Cookie{ - Name: sessionCookieName, + Name: CookieName, Value: "", Path: "/", MaxAge: -1, diff --git a/logout_test.go b/internal/auth/logout_test.go similarity index 78% rename from logout_test.go rename to internal/auth/logout_test.go index 4e83820..f8829c0 100644 --- a/logout_test.go +++ b/internal/auth/logout_test.go @@ -1,4 +1,4 @@ -package main +package auth import ( "errors" @@ -11,7 +11,7 @@ import ( // sessionCookieCleared 檢查回應是否以 Max-Age=0 清除 Session Cookie。 func sessionCookieCleared(rec *httptest.ResponseRecorder) bool { for _, sc := range rec.Header().Values("Set-Cookie") { - if strings.HasPrefix(sc, sessionCookieName+"=") && strings.Contains(sc, "Max-Age=0") { + if strings.HasPrefix(sc, CookieName+"=") && strings.Contains(sc, "Max-Age=0") { return true } } @@ -20,7 +20,7 @@ func sessionCookieCleared(rec *httptest.ResponseRecorder) bool { // 以下拒絕路徑皆不觸及資料庫,可用 nil db 測試。 func TestLogoutHandlerJSONWithoutCookie(t *testing.T) { - h := logoutHandler(nil) + h := LogoutHandler(nil) req := httptest.NewRequest(http.MethodPost, "/logout", nil) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() @@ -34,7 +34,7 @@ func TestLogoutHandlerJSONWithoutCookie(t *testing.T) { } func TestLogoutHandlerRejectsUnsupportedContentType(t *testing.T) { - h := logoutHandler(nil) + h := LogoutHandler(nil) req := httptest.NewRequest(http.MethodPost, "/logout", strings.NewReader("x=1")) req.Header.Set("Content-Type", "text/plain") rec := httptest.NewRecorder() @@ -48,10 +48,10 @@ func TestLogoutHandlerRejectsUnsupportedContentType(t *testing.T) { } func TestLogoutHandlerFormCSRFRejections(t *testing.T) { - h := logoutHandler(nil) + h := LogoutHandler(nil) t.Run("CSRF 不符回 403 並重繪登入表單", func(t *testing.T) { - cookie := &http.Cookie{Name: csrfCookieName, Value: "token-A"} + cookie := &http.Cookie{Name: CSRFCookieName, Value: "token-A"} rec := httptest.NewRecorder() h(rec, formPost("csrf_token=token-B", cookie)) if rec.Code != http.StatusForbidden { @@ -63,7 +63,7 @@ func TestLogoutHandlerFormCSRFRejections(t *testing.T) { if !strings.Contains(rec.Body.String(), "表單驗證失敗") { t.Fatal("應顯示 CSRF 錯誤訊息") } - if !strings.Contains(rec.Header().Get("Set-Cookie"), csrfCookieName) { + if !strings.Contains(rec.Header().Get("Set-Cookie"), CSRFCookieName) { t.Fatal("重繪表單時應輪替 CSRF Cookie") } }) @@ -77,7 +77,7 @@ func TestLogoutHandlerFormCSRFRejections(t *testing.T) { }) t.Run("表單無法解析回 400", func(t *testing.T) { - cookie := &http.Cookie{Name: csrfCookieName, Value: "token-A"} + cookie := &http.Cookie{Name: CSRFCookieName, Value: "token-A"} rec := httptest.NewRecorder() h(rec, formPost("csrf_token=%zz", cookie)) if rec.Code != http.StatusBadRequest { @@ -90,11 +90,11 @@ func TestLogoutHandlerFormCSRFRejections(t *testing.T) { } func TestLogoutIntegration(t *testing.T) { - db, err := openDB() + db, err := openTestDB() if err != nil { t.Skipf("資料庫不可用,略過整合測試: %v", err) } - suffix, err := newRandomToken(6) + suffix, err := NewToken(6) if err != nil { t.Fatal(err) } @@ -111,14 +111,14 @@ func TestLogoutIntegration(t *testing.T) { }) t.Run("已登入首頁含登出表單", func(t *testing.T) { - s, err := createSession(db, u.ID) + s, err := CreateSession(db, u.ID) if err != nil { t.Fatal(err) } req := httptest.NewRequest(http.MethodGet, "/", nil) - req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: s.ID}) + req.AddCookie(&http.Cookie{Name: CookieName, Value: s.ID}) rec := httptest.NewRecorder() - accountPageHandler(db)(rec, req) + AccountPageHandler(db)(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } @@ -131,14 +131,14 @@ func TestLogoutIntegration(t *testing.T) { }) t.Run("已登入者造訪 /login 導向 /", func(t *testing.T) { - s, err := createSession(db, u.ID) + s, err := CreateSession(db, u.ID) if err != nil { t.Fatal(err) } req := httptest.NewRequest(http.MethodGet, "/login", nil) - req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: s.ID}) + req.AddCookie(&http.Cookie{Name: CookieName, Value: s.ID}) rec := httptest.NewRecorder() - loginPageHandler(db)(rec, req) + LoginPageHandler(db)(rec, req) if rec.Code != http.StatusSeeOther { t.Fatalf("status = %d, want 303, body = %s", rec.Code, rec.Body.String()) } @@ -148,14 +148,14 @@ func TestLogoutIntegration(t *testing.T) { }) t.Run("表單登出刪除 Session 並導向 /login", func(t *testing.T) { - s, err := createSession(db, u.ID) + s, err := CreateSession(db, u.ID) if err != nil { t.Fatal(err) } - req := formPost("csrf_token=token-A", &http.Cookie{Name: csrfCookieName, Value: "token-A"}) - req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: s.ID}) + req := formPost("csrf_token=token-A", &http.Cookie{Name: CSRFCookieName, Value: "token-A"}) + req.AddCookie(&http.Cookie{Name: CookieName, Value: s.ID}) rec := httptest.NewRecorder() - logoutHandler(db)(rec, req) + LogoutHandler(db)(rec, req) if rec.Code != http.StatusSeeOther { t.Fatalf("status = %d, want 303, body = %s", rec.Code, rec.Body.String()) } @@ -165,39 +165,39 @@ func TestLogoutIntegration(t *testing.T) { if !sessionCookieCleared(rec) { t.Fatalf("應清除 Session Cookie, Set-Cookie = %v", rec.Header().Values("Set-Cookie")) } - if _, err := getSession(db, s.ID); !errors.Is(err, ErrSessionExpired) { - t.Fatalf("登出後 getSession() = %v, want ErrSessionExpired", err) + if _, err := GetSession(db, s.ID); !errors.Is(err, ErrSessionExpired) { + t.Fatalf("登出後 GetSession() = %v, want ErrSessionExpired", err) } }) t.Run("重複登出冪等", func(t *testing.T) { - req := formPost("csrf_token=token-A", &http.Cookie{Name: csrfCookieName, Value: "token-A"}) - req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "already-deleted"}) + req := formPost("csrf_token=token-A", &http.Cookie{Name: CSRFCookieName, Value: "token-A"}) + req.AddCookie(&http.Cookie{Name: CookieName, Value: "already-deleted"}) rec := httptest.NewRecorder() - logoutHandler(db)(rec, req) + LogoutHandler(db)(rec, req) if rec.Code != http.StatusSeeOther { t.Fatalf("status = %d, want 303", rec.Code) } }) t.Run("JSON 登出回 204", func(t *testing.T) { - s, err := createSession(db, u.ID) + s, err := CreateSession(db, u.ID) if err != nil { t.Fatal(err) } req := httptest.NewRequest(http.MethodPost, "/logout", nil) req.Header.Set("Content-Type", "application/json") - req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: s.ID}) + req.AddCookie(&http.Cookie{Name: CookieName, Value: s.ID}) rec := httptest.NewRecorder() - logoutHandler(db)(rec, req) + LogoutHandler(db)(rec, req) if rec.Code != http.StatusNoContent { t.Fatalf("status = %d, want 204, body = %s", rec.Code, rec.Body.String()) } if !sessionCookieCleared(rec) { t.Fatal("應清除 Session Cookie") } - if _, err := getSession(db, s.ID); !errors.Is(err, ErrSessionExpired) { - t.Fatalf("登出後 getSession() = %v, want ErrSessionExpired", err) + if _, err := GetSession(db, s.ID); !errors.Is(err, ErrSessionExpired) { + t.Fatalf("登出後 GetSession() = %v, want ErrSessionExpired", err) } }) } diff --git a/notfound.go b/internal/auth/notfound.go similarity index 54% rename from notfound.go rename to internal/auth/notfound.go index 85f8764..29a3d00 100644 --- a/notfound.go +++ b/internal/auth/notfound.go @@ -1,10 +1,10 @@ -package main +package auth import "net/http" -// notFoundHandler 回應自訂 404 頁,作為 chi 的 NotFound handler:僅在 +// NotFoundHandler 回應自訂 404 頁,作為 chi 的 NotFound handler:僅在 // 沒有任何路由匹配時觸發(如 /static/ 下不存在的檔案由檔案伺服器 // 自行回應純文字 404),且不分方法——POST 到未知路徑同樣輸出本頁。 -func notFoundHandler(w http.ResponseWriter, r *http.Request) { - renderHTML(w, http.StatusNotFound, notFoundTmpl, nil) +func NotFoundHandler(w http.ResponseWriter, r *http.Request) { + RenderHTML(w, http.StatusNotFound, notFoundTmpl, nil) } diff --git a/notfound_test.go b/internal/auth/notfound_test.go similarity index 93% rename from notfound_test.go rename to internal/auth/notfound_test.go index d37a1fa..2ecd959 100644 --- a/notfound_test.go +++ b/internal/auth/notfound_test.go @@ -1,4 +1,4 @@ -package main +package auth import ( "net/http" @@ -11,7 +11,7 @@ import ( func TestNotFoundHandlerRendersPage(t *testing.T) { rec := httptest.NewRecorder() - notFoundHandler(rec, httptest.NewRequest(http.MethodGet, "/no-such-page", nil)) + NotFoundHandler(rec, httptest.NewRequest(http.MethodGet, "/no-such-page", nil)) if rec.Code != http.StatusNotFound { t.Fatalf("status = %d, want 404", rec.Code) } @@ -36,7 +36,7 @@ func TestRouterNotFoundUsesCustomPage(t *testing.T) { // chi 的 NotFound 不分方法:GET 與 POST 到未匹配路徑都應輸出自訂頁。 r := chi.NewRouter() r.Get("/login", func(w http.ResponseWriter, r *http.Request) {}) - r.NotFound(notFoundHandler) + r.NotFound(NotFoundHandler) for _, method := range []string{http.MethodGet, http.MethodPost} { rec := httptest.NewRecorder() diff --git a/session.go b/internal/auth/session.go similarity index 77% rename from session.go rename to internal/auth/session.go index f8ced64..33e3c65 100644 --- a/session.go +++ b/internal/auth/session.go @@ -1,4 +1,4 @@ -package main +package auth import ( "crypto/rand" @@ -28,9 +28,9 @@ const sessionTTL = 24 * time.Hour // ErrSessionExpired 表示 Session 不存在或已過期。 var ErrSessionExpired = errors.New("session 不存在或已過期") -// newRandomToken 產生 n bytes 加密安全亂數的 base64url 字串(無填充; +// NewToken 產生 n bytes 加密安全亂數的 base64url 字串(無填充; // n=32 時為 43 字元),供 Session ID 與 CSRF token 共用。 -func newRandomToken(n int) (string, error) { +func NewToken(n int) (string, error) { b := make([]byte, n) if _, err := rand.Read(b); err != nil { return "", fmt.Errorf("read random: %w", err) @@ -38,10 +38,10 @@ func newRandomToken(n int) (string, error) { return base64.RawURLEncoding.EncodeToString(b), nil } -// createSession 為使用者建立新 Session,順帶刪除所有已過期 Session +// CreateSession 為使用者建立新 Session,順帶刪除所有已過期 Session // (最佳清除,失敗不影響登入結果)。 -func createSession(db *gorm.DB, userID uint) (*Session, error) { - id, err := newRandomToken(32) +func CreateSession(db *gorm.DB, userID uint) (*Session, error) { + id, err := NewToken(32) if err != nil { return nil, err } @@ -53,17 +53,17 @@ func createSession(db *gorm.DB, userID uint) (*Session, error) { return s, nil } -// deleteSession 以 ID 刪除 Session(登出用)。查無該 Session 不視為 +// DeleteSession 以 ID 刪除 Session(登出用)。查無該 Session 不視為 // 錯誤,讓登出維持冪等。 -func deleteSession(db *gorm.DB, id string) error { +func DeleteSession(db *gorm.DB, id string) error { if err := db.Delete(&Session{}, "id = ?", id).Error; err != nil { return fmt.Errorf("delete session: %w", err) } return nil } -// getSession 以 ID 查詢效期內的 Session(含所屬使用者)。 -func getSession(db *gorm.DB, id string) (*Session, error) { +// GetSession 以 ID 查詢效期內的 Session(含所屬使用者)。 +func GetSession(db *gorm.DB, id string) (*Session, error) { var s Session err := db.Preload("User").Where("id = ? AND expires_at > ?", id, time.Now()).First(&s).Error if errors.Is(err, gorm.ErrRecordNotFound) { diff --git a/static.go b/internal/auth/static.go similarity index 83% rename from static.go rename to internal/auth/static.go index c0e4fe2..ee625d3 100644 --- a/static.go +++ b/internal/auth/static.go @@ -1,4 +1,4 @@ -package main +package auth import ( "embed" @@ -9,9 +9,9 @@ import ( //go:embed assets var assetsFS embed.FS -// staticHandler 以 /static/ 前綴提供 assets 內的靜態檔案 +// StaticHandler 以 /static/ 前綴提供 assets 內的靜態檔案 // (Tailwind 建置輸出的 CSS 等),並允許瀏覽器快取。 -func staticHandler() http.Handler { +func StaticHandler() http.Handler { sub, err := fs.Sub(assetsFS, "assets") if err != nil { panic(err) // embed 路徑固定,僅防呆 diff --git a/static_test.go b/internal/auth/static_test.go similarity index 94% rename from static_test.go rename to internal/auth/static_test.go index 60a5711..b1e7a7d 100644 --- a/static_test.go +++ b/internal/auth/static_test.go @@ -1,4 +1,4 @@ -package main +package auth import ( "net/http" @@ -8,7 +8,7 @@ import ( ) func TestStaticHandlerServesCSS(t *testing.T) { - h := staticHandler() + h := StaticHandler() req := httptest.NewRequest(http.MethodGet, "/static/css/main.css", nil) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) @@ -27,7 +27,7 @@ func TestStaticHandlerServesCSS(t *testing.T) { } func TestStaticHandlerRejectsTraversal(t *testing.T) { - h := staticHandler() + h := StaticHandler() // FileServer 以路徑對應 embed FS,目錄外不存在任何檔案,穿越應得到 404。 req := httptest.NewRequest(http.MethodGet, "/static/../main.go", nil) req.URL.Path = "/static/../main.go" diff --git a/internal/auth/templates/adminapplicationedit.html b/internal/auth/templates/adminapplicationedit.html new file mode 100644 index 0000000..abfcea2 --- /dev/null +++ b/internal/auth/templates/adminapplicationedit.html @@ -0,0 +1,110 @@ +{{/* 編輯應用程式頁(僅 admin)。以 layout.html(側邊導覽欄版面)為根模板 + 組合渲染:本檔僅定義區塊,不應單獨解析執行。導覽覆寫同管理列表頁 + (「應用程式管理」標記 aria-current)。表單預填既有註冊內容,驗證失敗 + 重繪時保留輸入(含核取方塊);client_id 為公開識別碼、已嵌入各 RP + 設定而不可變更,與建立時間一併唯讀顯示;輪替 client secret 另經列表 + 頁。儲存成功後 PRG 回本頁以 ?saved=1 顯示成功訊息(編輯無一次性 + 資料)。 */}} +{{define "title"}}編輯應用程式 - alterminal{{end}} + +{{define "navitems"}} +
  • + + + 帳號資訊 + +
  • +
  • + + + 金鑰管理 + +
  • +
  • + + + 應用程式管理 + +
  • +{{end}} + +{{define "content"}} +
    +
    +
    +

    編輯應用程式

    +

    更新接入 OIDC 的應用程式(Relying Party)註冊內容

    +
    + + + 返回列表 + +
    + {{if .Error}}{{end}} + {{if .Success}}

    {{.Success}}

    {{end}} + +
    +
    +
    client_id(不可變更)
    +
    {{.ClientID}}
    +
    +
    +
    建立時間
    +
    {{.Created}}
    +
    +
    + +
    + +
    + + +
    +
    + + +

    改為公開式將清除既有 client secret(立即失效);由公開式改回機密式後,請於列表頁輪替取得新 secret。

    +
    +
    + + +

    須為絕對 URI;http 僅允許 localhost/127.0.0.1/::1,其餘請使用 https;原生應用可用自訂 scheme(如 com.example.app:/cb)。

    +
    +
    + 允許的 grant type +
    + + + +
    +

    全不勾選時視為僅 authorization_code。

    +
    +
    + + +

    空格分隔,可用:openid、profile、email、offline_access;留空使用預設 openid profile email(offline_access 須勾選 refresh_token)。

    +
    + +
    +
    +{{end}} diff --git a/templates/adminapplicationnew.html b/internal/auth/templates/adminapplicationnew.html similarity index 100% rename from templates/adminapplicationnew.html rename to internal/auth/templates/adminapplicationnew.html diff --git a/templates/adminapplications.html b/internal/auth/templates/adminapplications.html similarity index 94% rename from templates/adminapplications.html rename to internal/auth/templates/adminapplications.html index f96648a..0edb588 100644 --- a/templates/adminapplications.html +++ b/internal/auth/templates/adminapplications.html @@ -1,7 +1,8 @@ {{/* 應用程式管理頁(僅 admin)。以 layout.html(側邊導覽欄版面)為根模板 組合渲染:本檔僅定義區塊,不應單獨解析執行;並引用 secretpanel.html 的一次性成果面板。導覽覆寫為「帳號資訊+金鑰管理+應用程式管理」 - (後者標記 aria-current),側欄頁尾沿用版面預設。註冊表單獨立於 + (後者標記 aria-current),側欄頁尾沿用版面預設。每列提供編輯(連往 + /admin/applications/{id})、輪替 secret 與刪除操作;註冊表單獨立於 /admin/applications/new(本頁按鈕進入);輪替成功時於 POST 回應直接 顯示明文 client secret(僅此一次,故不採 PRG)。 */}} {{define "title"}}應用程式管理 - alterminal{{end}} @@ -86,6 +87,8 @@ {{.Scope}} {{.CreatedAt}} + 編輯 {{if .Confidential}}
    diff --git a/templates/adminkeys.html b/internal/auth/templates/adminkeys.html similarity index 100% rename from templates/adminkeys.html rename to internal/auth/templates/adminkeys.html diff --git a/internal/auth/templates/consent.html b/internal/auth/templates/consent.html new file mode 100644 index 0000000..5d31dcc --- /dev/null +++ b/internal/auth/templates/consent.html @@ -0,0 +1,34 @@ +{{/* 授權同意頁(/authorize)。以 layout.html(側邊導覽欄版面)為根模板 + 組合渲染,本檔僅定義區塊。使用者已登入(Session 有效)才會看到本 + 頁:顯示發起授權的應用程式名稱與其要求的 scope 清單,送出同意或 + 拒絕。原始授權請求的每個參數以隱藏欄位原封帶回 POST /authorize。 */}} +{{define "title"}}授權存取 - alterminal{{end}} + +{{define "content"}} +
    +

    授權存取

    +

    + 應用程式 {{.AppName}} 要求以下權限: +

    + {{if .Error}}{{end}} +
      + {{range .Scopes}} +
    • + {{.Scope}} + {{.Description}} +
    • + {{end}} +
    + + {{range $k, $vs := .Params}}{{range $vs}}{{end}}{{end}} + +
    + + +
    + +

    同意後,之後來自同一應用程式且範圍相同的授權請求將不再詢問。

    +
    +{{end}} diff --git a/templates/layout.html b/internal/auth/templates/layout.html similarity index 100% rename from templates/layout.html rename to internal/auth/templates/layout.html diff --git a/templates/loggedin.html b/internal/auth/templates/loggedin.html similarity index 100% rename from templates/loggedin.html rename to internal/auth/templates/loggedin.html diff --git a/templates/login.html b/internal/auth/templates/login.html similarity index 95% rename from templates/login.html rename to internal/auth/templates/login.html index 0b14313..ec6852b 100644 --- a/templates/login.html +++ b/internal/auth/templates/login.html @@ -14,6 +14,7 @@ {{if .Error}}{{end}}
    + {{if ne .Next "/"}}{{end}} diff --git a/templates/notfound.html b/internal/auth/templates/notfound.html similarity index 100% rename from templates/notfound.html rename to internal/auth/templates/notfound.html diff --git a/templates/secretpanel.html b/internal/auth/templates/secretpanel.html similarity index 100% rename from templates/secretpanel.html rename to internal/auth/templates/secretpanel.html diff --git a/user.go b/internal/auth/user.go similarity index 88% rename from user.go rename to internal/auth/user.go index 973e67a..fd9b3d7 100644 --- a/user.go +++ b/internal/auth/user.go @@ -1,4 +1,4 @@ -package main +package auth import ( "crypto/rand" @@ -21,8 +21,8 @@ const ( RoleUser Role = "user" ) -// valid 回傳角色是否為允許的值。 -func (r Role) valid() bool { +// Valid 回傳角色是否為允許的值。 +func (r Role) Valid() bool { return r == RoleAdmin || r == RoleUser } @@ -42,7 +42,7 @@ type User struct { // SetPassword 以 argon2id 雜湊密碼並寫入 PasswordHash。 func (u *User) SetPassword(password string) error { - hash, err := hashPassword(password) + hash, err := HashPassword(password) if err != nil { return err } @@ -52,7 +52,7 @@ func (u *User) SetPassword(password string) error { // CheckPassword 回傳密碼是否與 PasswordHash 相符;雜湊格式無效時一律視為不相符。 func (u *User) CheckPassword(password string) bool { - ok, err := verifyPassword(password, u.PasswordHash) + ok, err := VerifyPassword(password, u.PasswordHash) return err == nil && ok } @@ -65,8 +65,8 @@ const ( argon2KeyLen = 32 ) -// hashPassword 產生格式如 $argon2id$v=19$m=19456,t=2,p=1$$ 的 PHC 字串。 -func hashPassword(password string) (string, error) { +// HashPassword 產生格式如 $argon2id$v=19$m=19456,t=2,p=1$$ 的 PHC 字串。 +func HashPassword(password string) (string, error) { salt := make([]byte, argon2SaltLen) if _, err := rand.Read(salt); err != nil { return "", fmt.Errorf("read salt: %w", err) @@ -79,8 +79,8 @@ func hashPassword(password string) (string, error) { ), nil } -// verifyPassword 解析 PHC 字串並以 constant-time 比對重算結果。 -func verifyPassword(password, encoded string) (bool, error) { +// VerifyPassword 解析 PHC 字串並以 constant-time 比對重算結果。 +func VerifyPassword(password, encoded string) (bool, error) { parts := strings.Split(encoded, "$") if len(parts) != 6 || parts[1] != "argon2id" { return false, errors.New("malformed password hash") diff --git a/user_test.go b/internal/auth/user_test.go similarity index 89% rename from user_test.go rename to internal/auth/user_test.go index 48f5365..7eedd5b 100644 --- a/user_test.go +++ b/internal/auth/user_test.go @@ -1,4 +1,4 @@ -package main +package auth import ( "strings" @@ -45,13 +45,13 @@ func TestCheckPasswordMalformedHash(t *testing.T) { func TestRoleValid(t *testing.T) { for _, r := range []Role{RoleAdmin, RoleUser} { - if !r.valid() { - t.Errorf("Role(%q).valid() = false, want true", r) + if !r.Valid() { + t.Errorf("Role(%q).Valid() = false, want true", r) } } for _, r := range []Role{"", "Admin", "superuser", "root"} { - if r.valid() { - t.Errorf("Role(%q).valid() = true, want false", r) + if r.Valid() { + t.Errorf("Role(%q).Valid() = true, want false", r) } } } diff --git a/createaccount.go b/internal/cli/createaccount.go similarity index 87% rename from createaccount.go rename to internal/cli/createaccount.go index 34b6914..6763192 100644 --- a/createaccount.go +++ b/internal/cli/createaccount.go @@ -1,4 +1,4 @@ -package main +package cli import ( "errors" @@ -12,10 +12,13 @@ import ( "golang.org/x/term" "gorm.io/gorm" + + "alterminal/internal/auth" + "alterminal/internal/store" ) -// runCommand 分派 CLI 子指令;不帶任何參數時 main 直接啟動 HTTP 伺服器。 -func runCommand(args []string) { +// Run 分派 CLI 子指令;不帶任何參數時 main 直接啟動 HTTP 伺服器。 +func Run(args []string) { switch args[0] { case "create-account": if err := runCreateAccount(args[1:]); err != nil { @@ -46,7 +49,7 @@ func runCreateAccount(args []string) error { Username: strings.TrimSpace(*username), Email: strings.TrimSpace(*email), Name: strings.TrimSpace(*name), - Role: Role(strings.TrimSpace(*role)), + Role: auth.Role(strings.TrimSpace(*role)), } if err := in.validate(); err != nil { return err @@ -56,12 +59,12 @@ func runCreateAccount(args []string) error { return err } - db, err := openDB() + db, err := store.Open() if err != nil { return fmt.Errorf("database: %w", err) } - u := &User{Username: in.Username, Email: in.Email, Name: in.Name, EmailVerified: *emailVerified, Role: in.Role} + u := &auth.User{Username: in.Username, Email: in.Email, Name: in.Name, EmailVerified: *emailVerified, Role: in.Role} if err := u.SetPassword(pw); err != nil { return fmt.Errorf("hash password: %w", err) } @@ -77,7 +80,7 @@ type accountInput struct { Username string Email string Name string - Role Role + Role auth.Role } var usernamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) @@ -106,9 +109,9 @@ func (in *accountInput) validate() error { return errors.New("name 長度不可超過 255") } if in.Role == "" { - in.Role = RoleUser // 未指定時預設一般使用者 + in.Role = auth.RoleUser // 未指定時預設一般使用者 } - if !in.Role.valid() { + if !in.Role.Valid() { return errors.New("role 僅接受 admin 或 user") } return nil @@ -162,15 +165,15 @@ func readHiddenLine(prompt string) ([]byte, error) { // createUser 將帳號寫入資料庫;寫入前預查提供友善的重複錯誤, // 寫入時再以唯一索引(gorm.ErrDuplicatedRows)兜底並發情境。 -func createUser(db *gorm.DB, u *User) error { +func createUser(db *gorm.DB, u *auth.User) error { var count int64 - if err := db.Model(&User{}).Where("username = ?", u.Username).Count(&count).Error; err != nil { + if err := db.Model(&auth.User{}).Where("username = ?", u.Username).Count(&count).Error; err != nil { return fmt.Errorf("query username: %w", err) } if count > 0 { return fmt.Errorf("username %q 已被使用", u.Username) } - if err := db.Model(&User{}).Where("email = ?", u.Email).Count(&count).Error; err != nil { + if err := db.Model(&auth.User{}).Where("email = ?", u.Email).Count(&count).Error; err != nil { return fmt.Errorf("query email: %w", err) } if count > 0 { diff --git a/createaccount_test.go b/internal/cli/createaccount_test.go similarity index 96% rename from createaccount_test.go rename to internal/cli/createaccount_test.go index ef709d5..d6f6fff 100644 --- a/createaccount_test.go +++ b/internal/cli/createaccount_test.go @@ -1,9 +1,11 @@ -package main +package cli import ( "errors" "strings" "testing" + + "alterminal/internal/auth" ) func TestAccountInputValidate(t *testing.T) { @@ -14,8 +16,8 @@ func TestAccountInputValidate(t *testing.T) { }{ {"最小欄位", accountInput{Username: "alice", Email: "alice@example.com"}, ""}, {"含顯示名稱", accountInput{Username: "alice", Email: "alice@example.com", Name: "Alice"}, ""}, - {"role 為 admin", accountInput{Username: "alice", Email: "alice@example.com", Role: RoleAdmin}, ""}, - {"role 為 user", accountInput{Username: "alice", Email: "alice@example.com", Role: RoleUser}, ""}, + {"role 為 admin", accountInput{Username: "alice", Email: "alice@example.com", Role: auth.RoleAdmin}, ""}, + {"role 為 user", accountInput{Username: "alice", Email: "alice@example.com", Role: auth.RoleUser}, ""}, {"role 為空", accountInput{Username: "alice", Email: "alice@example.com"}, ""}, {"role 不允許的值", accountInput{Username: "alice", Email: "alice@example.com", Role: "superuser"}, "role"}, {"role 為 Admin(大寫)", accountInput{Username: "alice", Email: "alice@example.com", Role: "Admin"}, "role"}, diff --git a/updatepassword.go b/internal/cli/updatepassword.go similarity index 86% rename from updatepassword.go rename to internal/cli/updatepassword.go index 628f826..a32ca6c 100644 --- a/updatepassword.go +++ b/internal/cli/updatepassword.go @@ -1,4 +1,4 @@ -package main +package cli import ( "errors" @@ -7,6 +7,9 @@ import ( "strings" "gorm.io/gorm" + + "alterminal/internal/auth" + "alterminal/internal/store" ) // runUpdatePassword 解析旗標並重設指定帳號的密碼。此為管理用指令, @@ -27,7 +30,7 @@ func runUpdatePassword(args []string) error { return err } - db, err := openDB() + db, err := store.Open() if err != nil { return fmt.Errorf("database: %w", err) } @@ -48,8 +51,8 @@ func runUpdatePassword(args []string) error { } // findUserByUsername 以帳號查詢使用者,查無時回傳可讀的錯誤。 -func findUserByUsername(db *gorm.DB, username string) (*User, error) { - var u User +func findUserByUsername(db *gorm.DB, username string) (*auth.User, error) { + var u auth.User err := db.Where("username = ?", username).First(&u).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fmt.Errorf("username %q 不存在", username) @@ -62,13 +65,13 @@ func findUserByUsername(db *gorm.DB, username string) (*User, error) { // updateUserPassword 於單一交易內寫入新密碼雜湊並刪除該使用者所有 // Session,回傳撤銷的 Session 數;交易確保密碼與 Session 不會只更新一半。 -func updateUserPassword(db *gorm.DB, u *User) (int64, error) { +func updateUserPassword(db *gorm.DB, u *auth.User) (int64, error) { var revoked int64 err := db.Transaction(func(tx *gorm.DB) error { if err := tx.Model(u).Update("password_hash", u.PasswordHash).Error; err != nil { return fmt.Errorf("update password: %w", err) } - res := tx.Where("user_id = ?", u.ID).Delete(&Session{}) + res := tx.Where("user_id = ?", u.ID).Delete(&auth.Session{}) if res.Error != nil { return fmt.Errorf("delete sessions: %w", res.Error) } diff --git a/updatepassword_test.go b/internal/cli/updatepassword_test.go similarity index 98% rename from updatepassword_test.go rename to internal/cli/updatepassword_test.go index 793e916..2bea055 100644 --- a/updatepassword_test.go +++ b/internal/cli/updatepassword_test.go @@ -1,4 +1,4 @@ -package main +package cli import ( "strings" diff --git a/internal/oidc/authorize.go b/internal/oidc/authorize.go new file mode 100644 index 0000000..7eea622 --- /dev/null +++ b/internal/oidc/authorize.go @@ -0,0 +1,347 @@ +package oidc + +import ( + "errors" + "fmt" + "log" + "net/http" + "net/url" + "strings" + + "gorm.io/gorm" + + "alterminal/internal/application" + "alterminal/internal/auth" +) + +// scopeDescriptions 為同意頁上各 scope 的人類可讀說明。 +var scopeDescriptions = map[string]string{ + "openid": "確認您的身分(取得登入狀態)", + "profile": "讀取您的顯示名稱與帳號", + "email": "讀取您的電子郵件地址", + "offline_access": "您離線時持續存取(換發新權杖)", +} + +// authorizeRequest 為 /authorize 的請求參數(RFC 6749 §4.1.1 與 OIDC +// Core §3.1.2.1 的授權請求參數;GET query 與同意表單 POST 共用)。 +type authorizeRequest struct { + ResponseType string + ClientID string + RedirectURI string + Scope string + State string + Nonce string + CodeChallenge string + CodeChallengeMethod string +} + +// authorizeRequestFromValues 由 query 或表單值還原請求參數。 +func authorizeRequestFromValues(v url.Values) authorizeRequest { + return authorizeRequest{ + ResponseType: v.Get("response_type"), + ClientID: v.Get("client_id"), + RedirectURI: v.Get("redirect_uri"), + Scope: v.Get("scope"), + State: v.Get("state"), + Nonce: v.Get("nonce"), + CodeChallenge: v.Get("code_challenge"), + CodeChallengeMethod: v.Get("code_challenge_method"), + } +} + +// values 重建請求的原始參數(同意表單的隱藏欄位與登入後返回時使用)。 +func (req authorizeRequest) values() url.Values { + v := url.Values{} + set := func(k, s string) { + if s != "" { + v.Set(k, s) + } + } + set("response_type", req.ResponseType) + set("client_id", req.ClientID) + set("redirect_uri", req.RedirectURI) + set("scope", req.Scope) + set("state", req.State) + set("nonce", req.Nonce) + set("code_challenge", req.CodeChallenge) + set("code_challenge_method", req.CodeChallengeMethod) + return v +} + +// query 回傳重建的授權請求 query 字串(不含 ?)。 +func (req authorizeRequest) query() string { + return req.values().Encode() +} + +// redirectError 為可安全重導回 redirect_uri 的授權請求錯誤(RFC 6749 +// §4.1.2.1:凡 client_id 與 redirect_uri 可確認者,錯誤以重導回傳)。 +type redirectError struct { + Code string + Description string +} + +// validateAuthorizeRequest 驗證授權請求並載入應用程式註冊資料。驗證 +// 順序刻意安排:client_id 與 redirect_uri 無法確認時呼叫方必須直接 +// 顯示錯誤頁、不得重導(RFC 6749 §4.1.2.1,防止授權請求做為開放 +// 重導向器);redirect_uri 通過精確比對(§3.1.2.3,字串相等不正规化) +// 後,其餘錯誤才以 redirectError 重導回 RP。 +func validateAuthorizeRequest(db *gorm.DB, req authorizeRequest) (*application.Application, *redirectError, error) { + app, err := application.GetByClientID(db, req.ClientID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil, fmt.Errorf("未知或不存在的 client_id %q", req.ClientID) + } + return nil, nil, err + } + if !app.RedirectURIs.Contains(req.RedirectURI) { + return nil, nil, fmt.Errorf("redirect_uri 未註冊於 client_id %s", req.ClientID) + } + if req.ResponseType != "code" { + return app, &redirectError{Code: "unsupported_response_type", Description: "僅支援 response_type=code"}, nil + } + if !app.GrantTypes.Contains(application.GrantAuthorizationCode) { + return app, &redirectError{Code: "unauthorized_client", Description: "應用程式未啟用授權碼流程"}, nil + } + + // scope:必含 openid(OIDC Core §3.1.2.1),且每個請求的 scope 皆 + // 鈙於應用程式註冊範圍。 + if !scopeHas(req.Scope, "openid") { + return app, &redirectError{Code: "invalid_scope", Description: "scope 必須包含 openid"}, nil + } + for _, s := range strings.Fields(req.Scope) { + if !scopeHas(app.Scope, s) { + return app, &redirectError{Code: "invalid_scope", Description: "scope " + s + " 未授權此應用程式"}, nil + } + } + + // PKCE(RFC 7636 §4.2、§4.3):code_challenge_method 僅允許 S256 + // (plain 不安全,本服務不接受,亦不採規格的 plain 預設——省略 + // method 視同無效)。公開式 Client 無 client secret 可驗,PKCE 為 + // 必要防護。 + switch { + case req.CodeChallengeMethod != "" && req.CodeChallengeMethod != "S256": + return app, &redirectError{Code: "invalid_request", Description: "code_challenge_method 僅支援 S256"}, nil + case req.CodeChallengeMethod == "S256" && req.CodeChallenge == "": + return app, &redirectError{Code: "invalid_request", Description: "code_challenge 不可為空"}, nil + case app.IsPublic() && req.CodeChallenge == "": + return app, &redirectError{Code: "invalid_request", Description: "公開式 Client 必須使用 PKCE"}, nil + case req.CodeChallenge != "" && req.CodeChallengeMethod == "": + return app, &redirectError{Code: "invalid_request", Description: "提供 code_challenge 時必須指定 code_challenge_method=S256"}, nil + } + return app, nil, nil +} + +// AuthorizeHandler 處理 /authorize(RFC 6749 §4.1.1 授權碼流程的授權 +// 端點):GET 驗證請求後依登入與同意狀態發碼或顯示同意頁,POST 處理 +// 同意頁的決定。 +func AuthorizeHandler(db *gorm.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + handleAuthorizeGet(db, w, r) + case http.MethodPost: + handleAuthorizePost(db, w, r) + default: + auth.WriteError(w, http.StatusMethodNotAllowed, "不支援的方法") + } + } +} + +// handleAuthorizeGet 處理 GET /authorize。 +func handleAuthorizeGet(db *gorm.DB, w http.ResponseWriter, r *http.Request) { + req := authorizeRequestFromValues(r.URL.Query()) + app, rerr, err := validateAuthorizeRequest(db, req) + if !authorizeValidated(w, r, req, rerr, err) { + return + } + s, ok := authorizeSession(db, w, r, req) + if !ok { + return + } + + // 已同意的 scope 涵蓋本次請求時靜默通過,直接發碼;否則顯示同意頁。 + c, err := GetConsent(db, s.UserID, app.ID) + switch { + case errors.Is(err, gorm.ErrRecordNotFound): + // 首次授權,顯示同意頁 + case err != nil: + log.Printf("authorize: %v", err) + http.Error(w, "內部錯誤", http.StatusInternalServerError) + return + case scopeCovered(c.Scope, req.Scope): + issueCodeAndRedirect(db, w, r, req, app, s) + return + } + renderConsentPage(w, r, http.StatusOK, req, app, s, "") +} + +// handleAuthorizePost 處理 POST /authorize(同意頁決定)。 +func handleAuthorizePost(db *gorm.DB, w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "無法解析表單內容", http.StatusBadRequest) + return + } + req := authorizeRequestFromValues(r.PostForm) + app, rerr, err := validateAuthorizeRequest(db, req) + if !authorizeValidated(w, r, req, rerr, err) { + return + } + + // POST 期間 Session 失效時,以原始參數重建 GET 回到授權流程開頭 + // (會再導向登入頁),不直接渲染需要登入脈絡的同意頁。 + s, ok := authorizeSession(db, w, r, req) + if !ok { + return + } + + if !auth.VerifyCSRF(r) { + renderConsentPage(w, r, http.StatusForbidden, req, app, s, "表單驗證失敗,請重新操作") + return + } + switch r.PostFormValue("decision") { + case "allow": + if err := SaveConsent(db, s.UserID, app.ID, req.Scope); err != nil { + log.Printf("authorize: %v", err) + http.Error(w, "內部錯誤", http.StatusInternalServerError) + return + } + issueCodeAndRedirect(db, w, r, req, app, s) + case "deny": + // 使用者拒絕授權(RFC 6749 §4.1.2.1 access_denied)。 + redirectAuthorizeError(w, r, req, "access_denied", "使用者拒絕授權") + default: + renderConsentPage(w, r, http.StatusBadRequest, req, app, s, "請選擇同意或拒絕") + } +} + +// authorizeValidated 統一處理驗證結果:無法確認 client/redirect_uri 的 +// 錯誤直接顯示 400 錯誤頁(不重導);可重導的錯誤回到 redirect_uri。 +// 回傳是否繼續後續流程。 +func authorizeValidated(w http.ResponseWriter, r *http.Request, req authorizeRequest, rerr *redirectError, err error) bool { + if err != nil { + log.Printf("authorize: %v", err) + http.Error(w, "授權請求無效:"+err.Error(), http.StatusBadRequest) + return false + } + if rerr != nil { + redirectAuthorizeError(w, r, req, rerr.Code, rerr.Description) + return false + } + return true +} + +// authorizeSession 檢查使用者 Session:有效回傳 (session, true);未登入 +// 時 303 導向 /login?next=<完整授權請求 URL> 後回傳 (nil, false);查詢 +// 錯誤回 500。POST 同意表單時改為 303 導回重建的 GET /authorize, +// 讓流程重新從登入檢查開始。 +func authorizeSession(db *gorm.DB, w http.ResponseWriter, r *http.Request, req authorizeRequest) (*auth.Session, bool) { + c, err := r.Cookie(auth.CookieName) + if errors.Is(err, http.ErrNoCookie) { + authorizeLoginRedirect(w, r, req) + return nil, false + } + s, err := auth.GetSession(db, c.Value) + if errors.Is(err, auth.ErrSessionExpired) { + authorizeLoginRedirect(w, r, req) + return nil, false + } + if err != nil { + log.Printf("authorize: %v", err) + http.Error(w, "內部錯誤", http.StatusInternalServerError) + return nil, false + } + return s, true +} + +// authorizeLoginRedirect 依請求方法把使用者送往登入頁:GET 直接以原始 +// URI 為 next;POST 以表單參數重建 query,讓登入後回到等效的 GET。 +func authorizeLoginRedirect(w http.ResponseWriter, r *http.Request, req authorizeRequest) { + next := "/authorize?" + req.query() + if r.Method == http.MethodGet { + next = r.URL.RequestURI() + } + http.Redirect(w, r, "/login?next="+url.QueryEscape(next), http.StatusSeeOther) +} + +// consentPageData 為同意頁的模板資料。Params 保存原始授權請求參數, +// 模板以隱藏欄位逐項帶回 POST /authorize。IsAdmin/Username/Email/CSRF +// 供 layout 側欄版面使用(與其他已登入頁面一致)。 +type consentPageData struct { + Error string + Username string + Email string + IsAdmin bool + CSRF string + AppName string + Scopes []scopeItem + Params url.Values +} + +// scopeItem 為同意頁清單中的單一 scope 及其說明。 +type scopeItem struct { + Scope string + Description string +} + +// renderConsentPage 輸出授權同意頁;每次輸出都輪替 CSRF token。 +func renderConsentPage(w http.ResponseWriter, r *http.Request, status int, req authorizeRequest, app *application.Application, s *auth.Session, errMsg string) { + token, err := auth.NewCSRFToken(w, r) + if err != nil { + log.Printf("csrf token: %v", err) + http.Error(w, "內部錯誤", http.StatusInternalServerError) + return + } + scopes := make([]scopeItem, 0, 4) + for _, s := range normalizeScope(req.Scope) { + scopes = append(scopes, scopeItem{Scope: s, Description: scopeDescriptions[s]}) + } + auth.RenderHTML(w, status, auth.ConsentTmpl, consentPageData{ + Error: errMsg, + Username: s.User.Username, + Email: s.User.Email, + IsAdmin: s.User.Role == auth.RoleAdmin, + CSRF: token, + AppName: app.Name, + Scopes: scopes, + Params: req.values(), + }) +} + +// issueCodeAndRedirect 產生授權碼並 302 重導回 redirect_uri(附加 code +// 與原 state;RFC 6749 §4.1.2 與 §3.1.2 的回呼格式)。 +func issueCodeAndRedirect(db *gorm.DB, w http.ResponseWriter, r *http.Request, req authorizeRequest, app *application.Application, s *auth.Session) { + _, code, err := NewAuthorizationCode(db, app.ID, s.UserID, req.RedirectURI, strings.Join(normalizeScope(req.Scope), " "), req.Nonce, req.CodeChallenge, req.CodeChallengeMethod, s.CreatedAt) + if err != nil { + log.Printf("authorize: %v", err) + http.Error(w, "內部錯誤", http.StatusInternalServerError) + return + } + redirectWithParams(w, r, req, "code", code) +} + +// redirectAuthorizeError 以 302 將錯誤重導回 redirect_uri(RFC 6749 +// §4.1.2.1:error、error_description 與原 state)。 +func redirectAuthorizeError(w http.ResponseWriter, r *http.Request, req authorizeRequest, code, description string) { + redirectWithParams(w, r, req, "error", code, "error_description", description) +} + +// redirectWithParams 在 redirect_uri 既有 query 之外附加 key/value 對 +// (值成對出現:key1, val1, key2, val2),state 非空時一併回填,最後 +// 302 重導。 +func redirectWithParams(w http.ResponseWriter, r *http.Request, req authorizeRequest, kv ...string) { + u, err := url.Parse(req.RedirectURI) + if err != nil { + log.Printf("authorize: 解析 redirect_uri: %v", err) + http.Error(w, "內部錯誤", http.StatusInternalServerError) + return + } + q := u.Query() + for i := 0; i+1 < len(kv); i += 2 { + q.Set(kv[i], kv[i+1]) + } + if req.State != "" { + q.Set("state", req.State) + } + u.RawQuery = q.Encode() + http.Redirect(w, r, u.String(), http.StatusFound) +} diff --git a/internal/oidc/authorize_test.go b/internal/oidc/authorize_test.go new file mode 100644 index 0000000..17d21ab --- /dev/null +++ b/internal/oidc/authorize_test.go @@ -0,0 +1,235 @@ +// 外部測試套件:見 jwks_test.go 開頭說明。 +package oidc_test + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "alterminal/internal/oidc" +) + +// client_id 或 redirect_uri 無法確認時不得重導(RFC 6749 §4.1.2.1), +// 直接回 400 錯誤頁。 +func TestAuthorizeRejectsWithoutRedirect(t *testing.T) { + e := newTestEnv(t) + h := oidc.AuthorizeHandler(e.db) + + t.Run("未知 client_id", func(t *testing.T) { + q := authorizeQuery(e.app, "openid", "", "", "") + q = strings.Replace(q, url.QueryEscape(e.app.ClientID), url.QueryEscape("no-such-client"), 1) + rec := getAuthorize(h, q, e.sessionCookie()) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + if rec.Header().Get("Location") != "" { + t.Fatalf("不得重導: %s", rec.Header().Get("Location")) + } + }) + + t.Run("redirect_uri 未註冊", func(t *testing.T) { + q := authorizeQuery(e.app, "openid", "", "", "") + q = strings.Replace(q, url.QueryEscape(e.app.RedirectURIs[0]), url.QueryEscape("https://evil.example/cb"), 1) + rec := getAuthorize(h, q, e.sessionCookie()) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + if rec.Header().Get("Location") != "" { + t.Fatalf("不得重導: %s", rec.Header().Get("Location")) + } + }) +} + +// redirect_uri 可確認後,其餘請求錯誤以 302 重導回 RP,附 error 與 +// 原 state(RFC 6749 §4.1.2.1)。 +func TestAuthorizeRedirectsParameterErrors(t *testing.T) { + e := newTestEnv(t) + h := oidc.AuthorizeHandler(e.db) + redirectURI := e.app.RedirectURIs[0] + + tests := []struct { + name string + query string + wantErrCode string + wantRedirect string + }{ + { + "response_type 不支援", + "response_type=token&client_id=" + e.app.ClientID + "&redirect_uri=" + url.QueryEscape(redirectURI) + "&scope=openid&state=xyz", + "unsupported_response_type", + redirectURI, + }, + { + "scope 缺 openid", + authorizeQuery(e.app, "profile email", "xyz", "", ""), + "invalid_scope", + redirectURI, + }, + { + "scope 超出註冊範圍", + authorizeQuery(e.app, "openid profile email offline_access unknown-scope", "xyz", "", ""), + "invalid_scope", + redirectURI, + }, + { + "code_challenge_method=plain", + "response_type=code&client_id=" + e.app.ClientID + "&redirect_uri=" + url.QueryEscape(redirectURI) + + "&scope=openid&state=xyz&code_challenge=whatever&code_challenge_method=plain", + "invalid_request", + redirectURI, + }, + { + "有 challenge 未指定 method", + "response_type=code&client_id=" + e.app.ClientID + "&redirect_uri=" + url.QueryEscape(redirectURI) + + "&scope=openid&state=xyz&code_challenge=whatever", + "invalid_request", + redirectURI, + }, + { + "公開式 Client 未使用 PKCE", + authorizeQuery(e.pub, "openid", "xyz", "", ""), + "invalid_request", + e.pub.RedirectURIs[0], + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rec := getAuthorize(h, tt.query, e.sessionCookie()) + if rec.Code != http.StatusFound { + t.Fatalf("status = %d, want 302, body = %s", rec.Code, rec.Body.String()) + } + loc := redirectLocation(t, rec) + if base := loc.Scheme + "://" + loc.Host + loc.Path; base != tt.wantRedirect { + t.Fatalf("Location 基準 URL = %q, want %q", base, tt.wantRedirect) + } + if got := loc.Query().Get("error"); got != tt.wantErrCode { + t.Errorf("error = %q, want %q", got, tt.wantErrCode) + } + if got := loc.Query().Get("state"); got != "xyz" { + t.Errorf("state 應原樣回填, got %q", got) + } + }) + } +} + +// 未登入時導向 /login,next 攜帶完整授權請求(OIDC Core §3.1.2.2)。 +func TestAuthorizeRedirectsToLoginWhenNotLoggedIn(t *testing.T) { + e := newTestEnv(t) + q := authorizeQuery(e.app, "openid profile", "xyz", "n-1", "") + + rec := getAuthorize(oidc.AuthorizeHandler(e.db), q) // 不帶 Session Cookie + if rec.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303", rec.Code) + } + loc := redirectLocation(t, rec) + if loc.Path != "/login" { + t.Fatalf("應導向 /login, got %q", loc) + } + next, err := url.QueryUnescape(loc.Query().Get("next")) + if err != nil { + t.Fatalf("next 未編碼: %v", err) + } + if !strings.HasPrefix(next, "/authorize?") || !strings.Contains(next, "state=xyz") || !strings.Contains(next, "nonce=n-1") { + t.Fatalf("next 應為完整 /authorize URL: %q", next) + } +} + +// 首次授權顯示同意頁;同意後記住,同範圍的後續請求靜默通過;範圍 +// 擴大時再次詢問。 +func TestAuthorizeConsentFlow(t *testing.T) { + e := newTestEnv(t) + h := oidc.AuthorizeHandler(e.db) + + t.Run("首次顯示同意頁", func(t *testing.T) { + rec := getAuthorize(h, authorizeQuery(e.app, "openid profile", "xyz", "", ""), e.sessionCookie()) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + body := rec.Body.String() + for _, want := range []string{"授權存取", "機密式測試應用", "openid", "profile", `value="allow"`, `value="deny"`} { + if !strings.Contains(body, want) { + t.Errorf("同意頁缺少 %q", want) + } + } + // offline_access 未請求,不應出現於說明清單。 + if strings.Count(body, "offline_access") != 0 { + t.Error("未請求的 scope 不應顯示") + } + }) + + t.Run("同意後發碼並記住", func(t *testing.T) { + loc, code := consentAllow(t, e, authorizeQuery(e.app, "openid profile", "xyz", "", "")) + if base := loc.Scheme + "://" + loc.Host + loc.Path; base != e.app.RedirectURIs[0] { + t.Fatalf("Location 基準 URL = %q, want %q", base, e.app.RedirectURIs[0]) + } + if loc.Query().Get("state") != "xyz" { + t.Errorf("state 應原樣回填, got %q", loc.Query().Get("state")) + } + if len(code) < 20 { + t.Errorf("code 長度 %d 過短", len(code)) + } + }) + + t.Run("同範圍再請求靜默通過", func(t *testing.T) { + rec := getAuthorize(h, authorizeQuery(e.app, "openid", "s2", "", ""), e.sessionCookie()) + if rec.Code != http.StatusFound { + t.Fatalf("status = %d, want 302, body = %s", rec.Code, rec.Body.String()) + } + loc := redirectLocation(t, rec) + if loc.Query().Get("code") == "" || loc.Query().Get("state") != "s2" { + t.Fatalf("應直接發碼: %s", loc) + } + }) + + t.Run("範圍擴大再次詢問", func(t *testing.T) { + rec := getAuthorize(h, authorizeQuery(e.app, "openid email", "s3", "", ""), e.sessionCookie()) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200(應再顯示同意頁), body = %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "email") { + t.Fatal("同意頁應顯示新請求的 scope") + } + }) + + t.Run("拒絕授權回 access_denied", func(t *testing.T) { + q := authorizeQuery(e.pub, "openid", "xyz", "", "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM") + rec := getAuthorize(h, q, e.sessionCookie()) + if rec.Code != http.StatusOK { + t.Fatalf("公開式首授權也應先顯示同意頁, status = %d", rec.Code) + } + rec = postAuthorize(h, q, "deny", e.sessionCookie(), csrfCookieOf(t, rec)) + if rec.Code != http.StatusFound { + t.Fatalf("status = %d, want 302", rec.Code) + } + loc := redirectLocation(t, rec) + if loc.Query().Get("error") != "access_denied" || loc.Query().Get("state") != "xyz" { + t.Fatalf("應回 access_denied 與原 state: %s", loc) + } + // 拒絕不應記錄同意:再次請求仍顯示同意頁。 + rec = getAuthorize(h, q, e.sessionCookie()) + if rec.Code != http.StatusOK { + t.Fatalf("拒絕後不應記住, status = %d, want 200", rec.Code) + } + }) + + t.Run("CSRF 不符回 403", func(t *testing.T) { + q := authorizeQuery(e.app, "openid email", "", "", "") // email 尚未同意 + rec := getAuthorize(h, q, e.sessionCookie()) + csrf := csrfCookieOf(t, rec) + // Cookie 保持正確值,但表單送出不符的 token。 + form, _ := url.ParseQuery(q) + form.Set("decision", "allow") + form.Set("csrf_token", "wrong-token") + req := httptest.NewRequest(http.MethodPost, "/authorize", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(e.sessionCookie()) + req.AddCookie(csrf) + rec = httptest.NewRecorder() + h(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", rec.Code) + } + }) +} diff --git a/internal/oidc/discovery.go b/internal/oidc/discovery.go new file mode 100644 index 0000000..bd6e940 --- /dev/null +++ b/internal/oidc/discovery.go @@ -0,0 +1,63 @@ +package oidc + +import ( + "fmt" + "net/http" + + "alterminal/internal/application" + "alterminal/internal/auth" +) + +// discoveryMaxAge 為 Discovery 文件的建議快取秒數,與 JWKS 一致:內容 +// 僅在部署設定變更時改變。 +const discoveryMaxAge = 3600 + +// discoveryDocument 為 OIDC Discovery 文件(OIDC Discovery 1.0 §3)。 +// 本服務僅支援授權碼流程(RFC 6749 §4.1.1)與 refresh token grant +// (§6);subject type 僅 public(sub 對使用者恆為同一值)。 +type discoveryDocument struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + UserInfoEndpoint string `json:"userinfo_endpoint"` + JWKSURI string `json:"jwks_uri"` + ScopesSupported []string `json:"scopes_supported"` + ResponseTypesSupported []string `json:"response_types_supported"` + ResponseModesSupported []string `json:"response_modes_supported"` + GrantTypesSupported []string `json:"grant_types_supported"` + SubjectTypesSupported []string `json:"subject_types_supported"` + IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"` + TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"` + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` + ClaimsSupported []string `json:"claims_supported"` +} + +// DiscoveryHandler 處理 GET /.well-known/openid-configuration:發佈本 +// 服務的 OIDC 端點與能力中繼資料,供 RP 以標準方式取得組態。issuer 於 +// main 讀取 ISSUER 環境變數後注入——issuer 字串須與簽入 token 的 iss +// claim 完全一致(OIDC Core §3.1.3.7 的 issuer 驗證)。 +func DiscoveryHandler(issuer string) http.HandlerFunc { + doc := discoveryDocument{ + Issuer: issuer, + AuthorizationEndpoint: issuer + "/authorize", + TokenEndpoint: issuer + "/token", + UserInfoEndpoint: issuer + "/userinfo", + JWKSURI: issuer + "/.well-known/jwks.json", + ScopesSupported: application.ScopesSupported(), + ResponseTypesSupported: []string{"code"}, + ResponseModesSupported: []string{"query"}, + GrantTypesSupported: []string{"authorization_code", "refresh_token"}, + SubjectTypesSupported: []string{"public"}, + IDTokenSigningAlgValuesSupported: []string{"RS256"}, + TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "client_secret_post", "none"}, + CodeChallengeMethodsSupported: []string{"S256"}, + ClaimsSupported: []string{ + "sub", "iss", "aud", "exp", "iat", "auth_time", "nonce", + "name", "preferred_username", "email", "email_verified", + }, + } + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", discoveryMaxAge)) + auth.WriteJSON(w, http.StatusOK, doc) + } +} diff --git a/internal/oidc/discovery_test.go b/internal/oidc/discovery_test.go new file mode 100644 index 0000000..8a90f34 --- /dev/null +++ b/internal/oidc/discovery_test.go @@ -0,0 +1,62 @@ +// 外部測試套件:見 jwks_test.go 開頭說明。 +package oidc_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "alterminal/internal/oidc" +) + +// Discovery 文件應揭露本服務的全部端點與能力。 +func TestDiscoveryHandler(t *testing.T) { + rec := httptest.NewRecorder() + oidc.DiscoveryHandler(testIssuer)(rec, httptest.NewRequest(http.MethodGet, "/.well-known/openid-configuration", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if cc := rec.Header().Get("Cache-Control"); cc != "public, max-age=3600" { + t.Errorf("Cache-Control = %q, want public, max-age=3600", cc) + } + + var doc map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil { + t.Fatalf("解析 Discovery 文件: %v", err) + } + endpoints := map[string]string{ + "issuer": testIssuer, + "authorization_endpoint": testIssuer + "/authorize", + "token_endpoint": testIssuer + "/token", + "userinfo_endpoint": testIssuer + "/userinfo", + "jwks_uri": testIssuer + "/.well-known/jwks.json", + } + for field, want := range endpoints { + got, _ := doc[field].(string) + if got != want { + t.Errorf("%s = %q, want %q", field, got, want) + } + } + lists := map[string][]string{ + "scopes_supported": {"email", "offline_access", "openid", "profile"}, + "response_types_supported": {"code"}, + "grant_types_supported": {"authorization_code", "refresh_token"}, + "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"}, + } + for field, want := range lists { + got, _ := doc[field].([]any) + if len(got) != len(want) { + t.Errorf("%s = %v, want %v", field, got, want) + continue + } + for i, w := range want { + if got[i] != w { + t.Errorf("%s[%d] = %v, want %v", field, i, got[i], w) + } + } + } +} diff --git a/internal/oidc/helpers_test.go b/internal/oidc/helpers_test.go new file mode 100644 index 0000000..ed800de --- /dev/null +++ b/internal/oidc/helpers_test.go @@ -0,0 +1,321 @@ +// 外部測試套件(與 jwks_test.go 同理):oidc 模型由 store 遷移,內部 +// 測試套件匯入 testdb 會形成循環。 +package oidc_test + +import ( + "crypto" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "gorm.io/gorm" + + "alterminal/internal/application" + "alterminal/internal/auth" + "alterminal/internal/jwk" + "alterminal/internal/oidc" + "alterminal/internal/testdb" +) + +// testIssuer 為測試用 issuer(與本機 http 開發環境一致)。 +const testIssuer = "http://localhost:8080" + +// testEnv 打包端點整合測試的共用物件:簽章金鑰、使用者與 Session、 +// 一個機密式與一個公開式應用程式。 +type testEnv struct { + db *gorm.DB + key *jwk.SigningKey + user *auth.User + session *auth.Session + app *application.Application // 機密式:openid profile email offline_access + secret string // app 的 client secret 明文 + pub *application.Application // 公開式:預設 grant 與 scope +} + +// newTestEnv 建立測試環境(含所有相依資料列)。 +func newTestEnv(t *testing.T) *testEnv { + t.Helper() + db := testdb.New(t) + + key := mustNewKey(t, false) + if err := db.Create(key).Error; err != nil { + t.Fatal("建立測試金鑰: ", err) + } + + user := &auth.User{ + Username: "oidc-test", Email: "oidc-test@example.com", + Name: "測試使用者", EmailVerified: true, + } + if err := db.Create(user).Error; err != nil { + t.Fatal("建立測試使用者: ", err) + } + session, err := auth.CreateSession(db, user.ID) + if err != nil { + t.Fatal("建立測試 Session: ", err) + } + + app, secret, err := application.NewApplication( + "機密式測試應用", application.ClientConfidential, + []string{"https://rp.example/callback"}, + []application.GrantType{application.GrantAuthorizationCode, application.GrantRefreshToken}, + "openid profile email offline_access", + ) + if err != nil { + t.Fatal("建立測試應用程式: ", err) + } + if err := db.Create(app).Error; err != nil { + t.Fatal("建立測試應用程式: ", err) + } + + pub, _, err := application.NewApplication( + "公開式測試應用", application.ClientPublic, + []string{"http://localhost:3000/cb"}, + nil, "", // fill 會補預設值:grant 僅 authorization_code、scope 為 openid profile email + ) + if err != nil { + t.Fatal("建立公開式測試應用: ", err) + } + if err := db.Create(pub).Error; err != nil { + t.Fatal("建立公開式測試應用: ", err) + } + + return &testEnv{db: db, key: key, user: user, session: session, app: app, secret: secret, pub: pub} +} + +// sessionCookie 回傳環境使用者的 Session Cookie。 +func (e *testEnv) sessionCookie() *http.Cookie { + return &http.Cookie{Name: auth.CookieName, Value: e.session.ID} +} + +// authorizeQuery 組出對指定應用程式的授權請求 query(redirect URI 取 +// 第一個註冊值)。challenge 為空時不帶 PKCE 參數。 +func authorizeQuery(app *application.Application, scope, state, nonce, challenge string) string { + v := url.Values{} + v.Set("response_type", "code") + v.Set("client_id", app.ClientID) + v.Set("redirect_uri", app.RedirectURIs[0]) + v.Set("scope", scope) + if state != "" { + v.Set("state", state) + } + if nonce != "" { + v.Set("nonce", nonce) + } + if challenge != "" { + v.Set("code_challenge", challenge) + v.Set("code_challenge_method", "S256") + } + return v.Encode() +} + +// getAuthorize 對 GET /authorize 發出請求(可選帶 Cookie)並回傳記錄器。 +func getAuthorize(h http.HandlerFunc, query string, cookies ...*http.Cookie) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, "/authorize?"+query, nil) + for _, c := range cookies { + req.AddCookie(c) + } + rec := httptest.NewRecorder() + h(rec, req) + return rec +} + +// csrfCookieOf 從回應的 Set-Cookie 取得輪替後的 CSRF token。 +func csrfCookieOf(t *testing.T, rec *httptest.ResponseRecorder) *http.Cookie { + t.Helper() + for _, c := range rec.Result().Cookies() { + if c.Name == auth.CSRFCookieName { + return c + } + } + t.Fatal("回應未設定 CSRF Cookie") + return nil +} + +// hiddenFieldValue 由表單頁 HTML 取出指定隱藏欄位的 value( simplistic +// 剖析,僅供測試使用)。 +func hiddenFieldValue(t *testing.T, body, name string) string { + t.Helper() + marker := `name="` + name + `" value="` + i := strings.Index(body, marker) + if i < 0 { + t.Fatalf("頁面缺少隱藏欄位 %s", name) + } + rest := body[i+len(marker):] + return rest[:strings.Index(rest, `"`)] +} + +// postAuthorize 送出同意頁決定(帶原始授權參數與 CSRF),回傳記錄器。 +func postAuthorize(h http.HandlerFunc, query, decision string, cookies ...*http.Cookie) *httptest.ResponseRecorder { + form, err := url.ParseQuery(query) // 正確解碼一次,Encode 時再編碼 + if err != nil { + panic(err) + } + form.Set("decision", decision) + var csrf string + for _, c := range cookies { + if c.Name == auth.CSRFCookieName { + csrf = c.Value + } + } + form.Set("csrf_token", csrf) + req := httptest.NewRequest(http.MethodPost, "/authorize", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + for _, c := range cookies { + req.AddCookie(c) + } + rec := httptest.NewRecorder() + h(rec, req) + return rec +} + +// consentAllow 走授權流程取得授權碼,回傳 redirect Location 與其中的 +// code:scope 已同意過時直接使用靜默通過的 302,否則顯示同意頁後按 +// 同意。登入 Session 由 e 提供。 +func consentAllow(t *testing.T, e *testEnv, query string) (*url.URL, string) { + t.Helper() + h := oidc.AuthorizeHandler(e.db) + + rec := getAuthorize(h, query, e.sessionCookie()) + if rec.Code == http.StatusFound { + loc := redirectLocation(t, rec) + if code := loc.Query().Get("code"); code != "" { + return loc, code // 已同意,靜默通過 + } + t.Fatalf("未預期的 302(無 code): %s", loc) + } + if rec.Code != http.StatusOK { + t.Fatalf("同意頁 status = %d, want 200, body = %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "授權存取") { + t.Fatalf("應顯示同意頁: %s", rec.Body.String()) + } + csrf := csrfCookieOf(t, rec) + + rec = postAuthorize(h, query, "allow", e.sessionCookie(), csrf) + if rec.Code != http.StatusFound { + t.Fatalf("同意後 status = %d, want 302, body = %s", rec.Code, rec.Body.String()) + } + loc := redirectLocation(t, rec) + code := loc.Query().Get("code") + if code == "" { + t.Fatalf("redirect URI 缺少 code: %s", loc) + } + return loc, code +} + +// redirectLocation 解析 302/303 回應的 Location 標頭。 +func redirectLocation(t *testing.T, rec *httptest.ResponseRecorder) *url.URL { + t.Helper() + raw := rec.Header().Get("Location") + u, err := url.Parse(raw) + if err != nil { + t.Fatalf("解析 Location %q: %v", raw, err) + } + return u +} + +// postToken 對 POST /token 送出表單;basicID/basicSecret 非空時改用 +// HTTP Basic 認證。 +func postToken(h http.HandlerFunc, form url.Values, basicID, basicSecret string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, "/token", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + if basicID != "" { + req.SetBasicAuth(basicID, basicSecret) + } + rec := httptest.NewRecorder() + h(rec, req) + return rec +} + +// tokenErrorBody 解析 token 錯誤回應。 +type tokenErrorBody struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description"` +} + +// decodeTokenError 解析錯誤回應 JSON。 +func decodeTokenError(t *testing.T, rec *httptest.ResponseRecorder) tokenErrorBody { + t.Helper() + var e tokenErrorBody + if err := json.Unmarshal(rec.Body.Bytes(), &e); err != nil { + t.Fatalf("解析錯誤回應: %v, body = %s", err, rec.Body.String()) + } + return e +} + +// jwtParts 拆解 JWT 三段。 +func jwtParts(t *testing.T, token string) (header, payload []byte) { + t.Helper() + parts := strings.Split(token, ".") + if len(parts) != 3 { + t.Fatalf("JWT 應有三段: %s", token) + } + var err error + if header, err = base64.RawURLEncoding.DecodeString(parts[0]); err != nil { + t.Fatalf("解碼 header: %v", err) + } + if payload, err = base64.RawURLEncoding.DecodeString(parts[1]); err != nil { + t.Fatalf("解碼 payload: %v", err) + } + return header, payload +} + +// forgeJWT 以指定金鑰與自訂 header/claims 造出 JWT(供負面測試: +// alg 混淆、過期 claims 等)。 +func forgeJWT(t *testing.T, key *jwk.SigningKey, header map[string]string, claims any) string { + t.Helper() + priv, err := key.PrivateKey() + if err != nil { + t.Fatal(err) + } + hb, err := json.Marshal(header) + if err != nil { + t.Fatal(err) + } + pb, err := json.Marshal(claims) + if err != nil { + t.Fatal(err) + } + signingInput := base64.RawURLEncoding.EncodeToString(hb) + "." + base64.RawURLEncoding.EncodeToString(pb) + digest := sha256.Sum256([]byte(signingInput)) + sig, err := rsa.SignPKCS1v15(nil, priv, crypto.SHA256, digest[:]) + if err != nil { + t.Fatal(err) + } + return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig) +} + +// idTokenClaims 為測試斷言用的 ID token claims。 +type idTokenClaims struct { + Iss string `json:"iss"` + Sub string `json:"sub"` + Aud string `json:"aud"` + Exp int64 `json:"exp"` + Iat int64 `json:"iat"` + AuthTime int64 `json:"auth_time"` + Nonce string `json:"nonce"` + Name string `json:"name"` + Email string `json:"email"` + EmailVerf *bool `json:"email_verified"` +} + +// userInfoBody 為測試斷言用的 /userinfo 回應。 +type userInfoBody struct { + Sub string `json:"sub"` + Name string `json:"name"` + PreferredUsername string `json:"preferred_username"` + Email string `json:"email"` + EmailVerified *bool `json:"email_verified"` +} + +// subjectOf 回傳使用者 ID 的字串形式(與正式碼的 sub 生成一致)。 +func subjectOf(id uint) string { + return fmt.Sprintf("%d", id) +} diff --git a/internal/oidc/jwks.go b/internal/oidc/jwks.go new file mode 100644 index 0000000..7e88524 --- /dev/null +++ b/internal/oidc/jwks.go @@ -0,0 +1,46 @@ +// Package oidc 實作 OIDC 端點:/.well-known/jwks.json,以及之後的 +// Discovery、/authorize、/token 等,供 RP(Application)整合。 +package oidc + +import ( + "fmt" + "log" + "net/http" + + "gorm.io/gorm" + + "alterminal/internal/auth" + "alterminal/internal/jwk" +) + +// jwksMaxAge 為 JWKS 回應的建議快取秒數。金鑰輪替流程為「先產生並 +// 發佈新金鑰,舊金鑰退休前仍留在 JWKS 供已簽發的 token 驗證」,因此 +// RP 快取一小時並不影響驗證:快取期間內新舊金鑰皆可取得。 +const jwksMaxAge = 3600 + +// JWKSHandler 處理 GET /.well-known/jwks.json(RFC 7517 §5):發佈所有 +// 使用中簽章金鑰的公開 JWK,供 RP 驗證 ID Token/Access Token 的 +// 簽章。已退休金鑰不再發佈;無使用中金鑰時回應空的 keys 陣列。 +func JWKSHandler(db *gorm.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var keys []jwk.SigningKey + if err := db.Where("retired_at IS NULL").Order("created_at DESC").Find(&keys).Error; err != nil { + log.Printf("jwks: %v", err) + auth.WriteError(w, http.StatusInternalServerError, "內部錯誤") + return + } + set := jwk.JWKS{Keys: make([]jwk.JWK, 0, len(keys))} + for i := range keys { + k, err := keys[i].PublicJWK() + if err != nil { + // 單一金鑰的私鑰儲存毀損時跳過該金鑰並記錄待查,不讓整個 + // 端點失靈——其餘金鑰照常發佈,RP 仍可驗證其簽發的 token。 + log.Printf("jwks: 金鑰 %d(kid=%s)無法轉為公開 JWK: %v", keys[i].ID, keys[i].Kid, err) + continue + } + set.Keys = append(set.Keys, *k) + } + w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", jwksMaxAge)) + auth.WriteJSON(w, http.StatusOK, set) + } +} diff --git a/internal/oidc/jwks_test.go b/internal/oidc/jwks_test.go new file mode 100644 index 0000000..0978ffe --- /dev/null +++ b/internal/oidc/jwks_test.go @@ -0,0 +1,137 @@ +// 外部測試套件(package oidc_test):store 為遷移 OIDC 模型而匯入 +// oidc 套件,內部測試套件匯入 testdb(→ store → oidc)會形成循環。 +package oidc_test + +import ( + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "alterminal/internal/jwk" + "alterminal/internal/oidc" + "alterminal/internal/testdb" +) + +// jwksGet 對 handler 發出 GET /.well-known/jwks.json 並回傳回應記錄器。 +func jwksGet(h http.HandlerFunc) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + h(rec, httptest.NewRequest(http.MethodGet, "/.well-known/jwks.json", nil)) + return rec +} + +// mustNewKey 產生一把簽章金鑰;retired 為 true 時標記為已退休。 +func mustNewKey(t *testing.T, retired bool) *jwk.SigningKey { + t.Helper() + k, err := jwk.NewSigningKey() + if err != nil { + t.Fatal("NewSigningKey: ", err) + } + if retired { + now := time.Now() + k.RetiredAt = &now + } + return k +} + +func TestJWKSHandlerIntegration(t *testing.T) { + db := testdb.New(t) + + active1 := mustNewKey(t, false) + active2 := mustNewKey(t, false) + retired := mustNewKey(t, true) + for _, k := range []*jwk.SigningKey{active1, active2, retired} { + if err := db.Create(k).Error; err != nil { + t.Fatal("建立測試金鑰: ", err) + } + } + + h := oidc.JWKSHandler(db) + + t.Run("僅發佈使用中的金鑰", func(t *testing.T) { + rec := jwksGet(h) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body = %s", rec.Code, rec.Body.String()) + } + if ct := rec.Header().Get("Content-Type"); ct != "application/json; charset=utf-8" { + t.Errorf("Content-Type = %q, want application/json; charset=utf-8", ct) + } + if cc := rec.Header().Get("Cache-Control"); cc != "public, max-age=3600" { + t.Errorf("Cache-Control = %q, want public, max-age=3600", cc) + } + var set jwk.JWKS + if err := json.Unmarshal(rec.Body.Bytes(), &set); err != nil { + t.Fatalf("解析 JWKS: %v", err) + } + if len(set.Keys) != 2 { + t.Fatalf("keys = %d 把, want 2(退休金鑰不應發佈): %s", len(set.Keys), rec.Body.String()) + } + kids := map[string]bool{} + for _, k := range set.Keys { + kids[k.Kid] = true + if k.Kty != jwk.KeyTypeRSA || k.Use != jwk.KeyUseSig || k.Alg != jwk.AlgRS256 { + t.Errorf("kid %s 參數 = kty:%q use:%q alg:%q", k.Kid, k.Kty, k.Use, k.Alg) + } + if k.E != "AQAB" { + t.Errorf("kid %s e = %q, want AQAB", k.Kid, k.E) + } + n, err := base64.RawURLEncoding.DecodeString(k.N) + if err != nil || len(n) == 0 { + t.Errorf("kid %s 的 n 應為可解碼的非空 base64url: %q (err=%v)", k.Kid, k.N, err) + } + } + if !kids[active1.Kid] || !kids[active2.Kid] { + t.Errorf("應發佈兩把使用中金鑰 %q、%q,實際 %v", active1.Kid, active2.Kid, kids) + } + if kids[retired.Kid] { + t.Error("退休金鑰不應出現於 JWKS") + } + }) + + t.Run("GET 為冪等", func(t *testing.T) { + first, second := jwksGet(h), jwksGet(h) + if first.Body.String() != second.Body.String() { + t.Error("兩次 GET 的 JWKS 應相同") + } + }) +} + +// 空資料庫時 keys 為空陣列而非 null(RFC 7517 §5.1:keys 必要)。 +func TestJWKSHandlerEmpty(t *testing.T) { + db := testdb.New(t) + rec := jwksGet(oidc.JWKSHandler(db)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body = %s", rec.Code, rec.Body.String()) + } + if s := rec.Body.String(); s != "{\"keys\":[]}\n" && s != `{"keys":[]}` { + t.Fatalf("空 JWKS 應為 {\"keys\":[]},得到 %s", s) + } +} + +// 私鑰儲存毀損的金鑰被跳過,其餘金鑰照常發佈。 +func TestJWKSHandlerSkipsCorruptKey(t *testing.T) { + db := testdb.New(t) + + good := mustNewKey(t, false) + if err := db.Create(good).Error; err != nil { + t.Fatal("建立測試金鑰: ", err) + } + bad := &jwk.SigningKey{Kid: "corrupt-kid", Algorithm: jwk.AlgRS256, PrivateKeyPEM: "not a pem"} + if err := db.Create(bad).Error; err != nil { + t.Fatal("建立毀損金鑰: ", err) + } + + rec := jwksGet(oidc.JWKSHandler(db)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body = %s", rec.Code, rec.Body.String()) + } + var set jwk.JWKS + if err := json.Unmarshal(rec.Body.Bytes(), &set); err != nil { + t.Fatalf("解析 JWKS: %v", err) + } + if len(set.Keys) != 1 || set.Keys[0].Kid != good.Kid { + t.Fatalf("應僅發佈完好的 %q,得到 %s", good.Kid, rec.Body.String()) + } +} diff --git a/internal/oidc/jwt.go b/internal/oidc/jwt.go new file mode 100644 index 0000000..be37647 --- /dev/null +++ b/internal/oidc/jwt.go @@ -0,0 +1,216 @@ +package oidc + +import ( + "crypto" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "gorm.io/gorm" + + "alterminal/internal/application" + "alterminal/internal/auth" + "alterminal/internal/jwk" +) + +// jwtHeader 為 JWT 的 protected header(RFC 7515 §4.1;alg 固定 RS256, +// kid 對應 JWKS 的金鑰識別碼,typ 標示為 JWT)。 +type jwtHeader struct { + Alg string `json:"alg"` + Kid string `json:"kid"` + Typ string `json:"typ"` +} + +// AccessTokenClaims 為 Access Token(JWT profile,RFC 9068)的 claims: +// 自包含、不落庫,資源端點(/userinfo)以 JWKS 驗證簽章與效期。Sub 為 +// 使用者 ID 的字串形式(OIDC Core §2 的 sub claim),Aud 為 client_id。 +type AccessTokenClaims struct { + Iss string `json:"iss"` + Sub string `json:"sub"` + Aud string `json:"aud"` + Exp int64 `json:"exp"` + Iat int64 `json:"iat"` + Scope string `json:"scope,omitempty"` + ClientID string `json:"client_id,omitempty"` +} + +// idTokenClaims 為 ID Token 的 claims(OIDC Core §2)。Name/Email 等 +// 個人資料 claim 僅在授權 scope 含對應值時加入;Nonce 回填授權請求的 +// 原值供 RP 綁結(OIDC Core §3.1.3.7.4),AuthTime 為使用者本次 +// Session 的建立時間(§2 的 auth_time claim,單位秒)。 +type idTokenClaims struct { + Iss string `json:"iss"` + Sub string `json:"sub"` + Aud string `json:"aud"` + Exp int64 `json:"exp"` + Iat int64 `json:"iat"` + AuthTime int64 `json:"auth_time,omitempty"` + Nonce string `json:"nonce,omitempty"` + Name string `json:"name,omitempty"` + PreferredUsername string `json:"preferred_username,omitempty"` + Email string `json:"email,omitempty"` + EmailVerified *bool `json:"email_verified,omitempty"` +} + +// ErrInvalidToken 表示 Access Token 無效(格式、簽章、金鑰或效期不符)。 +var ErrInvalidToken = errors.New("access token 無效") + +// signJWT 以金鑰簽發 RS256 JWT:header 與 claims 各自 JSON 序列化為 +// 無填充 base64url,再對兩段連結值以 RSASSA-PKCS1-v1_5 + SHA-256 +// 簽章(RFC 7518 §3.3),輸出 header.payload.signature 三段。 +func signJWT(key *jwk.SigningKey, claims any) (string, error) { + priv, err := key.PrivateKey() + if err != nil { + return "", fmt.Errorf("parse signing key: %w", err) + } + header, err := json.Marshal(jwtHeader{Alg: jwk.AlgRS256, Kid: key.Kid, Typ: "JWT"}) + if err != nil { + return "", fmt.Errorf("marshal jwt header: %w", err) + } + payload, err := json.Marshal(claims) + if err != nil { + return "", fmt.Errorf("marshal jwt claims: %w", err) + } + signingInput := base64.RawURLEncoding.EncodeToString(header) + "." + base64.RawURLEncoding.EncodeToString(payload) + digest := sha256.Sum256([]byte(signingInput)) + sig, err := rsa.SignPKCS1v15(nil, priv, crypto.SHA256, digest[:]) + if err != nil { + return "", fmt.Errorf("sign jwt: %w", err) + } + return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig), nil +} + +// currentSigningKey 取最新的使用中簽章金鑰供簽發(輪替時新金鑰在前)。 +func currentSigningKey(db *gorm.DB) (*jwk.SigningKey, error) { + var k jwk.SigningKey + if err := db.Where("retired_at IS NULL").Order("created_at DESC").First(&k).Error; err != nil { + return nil, fmt.Errorf("query signing key: %w", err) + } + return &k, nil +} + +// subject 為使用者的 sub claim 值:使用者 ID 的十進位字串(OIDC Core +// §2 要求 sub 在 issuer 範圍內穩定且唯一)。 +func subject(userID uint) string { + return fmt.Sprintf("%d", userID) +} + +// IssueAccessToken 簽發 Access Token(效期 15 分鐘)。 +func IssueAccessToken(db *gorm.DB, issuer string, userID uint, app *application.Application, scope string) (string, error) { + key, err := currentSigningKey(db) + if err != nil { + return "", err + } + now := time.Now() + return signJWT(key, AccessTokenClaims{ + Iss: issuer, + Sub: subject(userID), + Aud: app.ClientID, + Exp: now.Add(accessTokenTTL).Unix(), + Iat: now.Unix(), + Scope: scope, + ClientID: app.ClientID, + }) +} + +// IssueIDToken 簽發 ID Token(效期 15 分鐘)。authTime 為使用者 +// Session 的建立時間;nonce 為授權請求攜帶的原值(無則空)。個人資料 +// claim 依授權 scope 決定(profile:name、preferred_username;email: +// email、email_verified——OIDC Core §5.4)。 +func IssueIDToken(db *gorm.DB, issuer string, u *auth.User, app *application.Application, scope, nonce string, authTime time.Time) (string, error) { + key, err := currentSigningKey(db) + if err != nil { + return "", err + } + now := time.Now() + claims := idTokenClaims{ + Iss: issuer, + Sub: subject(u.ID), + Aud: app.ClientID, + Exp: now.Add(idTokenTTL).Unix(), + Iat: now.Unix(), + AuthTime: authTime.Unix(), + Nonce: nonce, + } + if scopeHas(scope, "profile") { + claims.Name = u.Name + claims.PreferredUsername = u.Username + } + if scopeHas(scope, "email") { + claims.Email = u.Email + verified := u.EmailVerified + claims.EmailVerified = &verified + } + return signJWT(key, claims) +} + +// scopeHas 回傳 scope 集合是否包含 s。 +func scopeHas(scope, s string) bool { + for _, f := range strings.Fields(scope) { + if f == s { + return true + } + } + return false +} + +// VerifyAccessToken 驗證 Access Token 並回傳其 claims:拆解三段 JWT、 +// 拒絕非 RS256 的 alg(RFC 8725 §3.4 的演算法混淆防護)、以 header kid +// 對應的簽章金鑰驗章(金鑰輪替過渡期仍可查得已退休金鑰)、比對 issuer +// 與效期(OIDC Core §3.1.3.7 的 iss/exp 驗證項)。任何一項不符即回 +// ErrInvalidToken,不洩漏細節。 +func VerifyAccessToken(db *gorm.DB, issuer, token string) (*AccessTokenClaims, error) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "結構") + } + headerJSON, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "header") + } + var h jwtHeader + if err := json.Unmarshal(headerJSON, &h); err != nil { + return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "header") + } + if h.Alg != jwk.AlgRS256 || h.Kid == "" { + return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "alg") + } + + var key jwk.SigningKey + if err := db.Where("kid = ?", h.Kid).First(&key).Error; err != nil { + return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "kid") + } + priv, err := key.PrivateKey() + if err != nil { + return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "kid") + } + sig, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "signature") + } + digest := sha256.Sum256([]byte(parts[0] + "." + parts[1])) + if err := rsa.VerifyPKCS1v15(&priv.PublicKey, crypto.SHA256, digest[:], sig); err != nil { + return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "signature") + } + + payloadJSON, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "claims") + } + var claims AccessTokenClaims + if err := json.Unmarshal(payloadJSON, &claims); err != nil { + return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "claims") + } + if claims.Iss != issuer { + return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "issuer") + } + if claims.Exp <= time.Now().Unix() { + return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "exp") + } + return &claims, nil +} diff --git a/internal/oidc/jwt_test.go b/internal/oidc/jwt_test.go new file mode 100644 index 0000000..bc3a08e --- /dev/null +++ b/internal/oidc/jwt_test.go @@ -0,0 +1,112 @@ +// 外部測試套件:見 jwks_test.go 開頭說明。 +package oidc_test + +import ( + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "alterminal/internal/oidc" +) + +// IssueAccessToken 簽出的 token 應可由 VerifyAccessToken 驗回,且 claims +// 正確(iss/sub/aud/scope)。 +func TestAccessTokenRoundTrip(t *testing.T) { + e := newTestEnv(t) + token, err := oidc.IssueAccessToken(e.db, testIssuer, e.user.ID, e.app, "openid profile") + if err != nil { + t.Fatal("IssueAccessToken: ", err) + } + claims, err := oidc.VerifyAccessToken(e.db, testIssuer, token) + if err != nil { + t.Fatal("VerifyAccessToken: ", err) + } + if claims.Iss != testIssuer { + t.Errorf("iss = %q, want %q", claims.Iss, testIssuer) + } + if claims.Sub != subjectOf(e.user.ID) { + t.Errorf("sub = %q, want %q", claims.Sub, subjectOf(e.user.ID)) + } + if claims.Aud != e.app.ClientID || claims.ClientID != e.app.ClientID { + t.Errorf("aud/client_id = %q/%q, want %q", claims.Aud, claims.ClientID, e.app.ClientID) + } + if claims.Scope != "openid profile" { + t.Errorf("scope = %q", claims.Scope) + } + // header 應含正確的 alg 與 kid(RP 以 kid 對應 JWKS)。 + header, _ := jwtParts(t, token) + var h struct { + Alg string `json:"alg"` + Kid string `json:"kid"` + Typ string `json:"typ"` + } + if err := json.Unmarshal(header, &h); err != nil { + t.Fatal("解析 header: ", err) + } + if h.Alg != "RS256" || h.Kid != e.key.Kid || h.Typ != "JWT" { + t.Errorf("header = %+v", h) + } +} + +// 各種無效 token 都應回 ErrInvalidToken,不洩漏細節。 +func TestVerifyAccessTokenRejectsInvalid(t *testing.T) { + e := newTestEnv(t) + good, err := oidc.IssueAccessToken(e.db, testIssuer, e.user.ID, e.app, "openid") + if err != nil { + t.Fatal(err) + } + + rs256Header := func(kid string) map[string]string { + return map[string]string{"alg": "RS256", "kid": kid, "typ": "JWT"} + } + // 竄改 payload:改動第一個字元後以原簽章送出,驗章應失敗。 + parts := strings.Split(good, ".") + payloadBytes := []byte(parts[1]) + if payloadBytes[0] == 'e' { + payloadBytes[0] = 'e' + 1 + } else { + payloadBytes[0] = 'e' + } + parts[1] = string(payloadBytes) + tampered := strings.Join(parts, ".") + + tests := []struct { + name string + token string + }{ + {"非 JWT 結構", "not-a-jwt"}, + {"alg=none(演算法混淆)", forgeJWT(t, e.key, map[string]string{"alg": "none", "kid": e.key.Kid}, map[string]any{"iss": testIssuer, "exp": time.Now().Add(time.Hour).Unix()})}, + {"alg=HS256", forgeJWT(t, e.key, map[string]string{"alg": "HS256", "kid": e.key.Kid}, map[string]any{"iss": testIssuer, "exp": time.Now().Add(time.Hour).Unix()})}, + {"kid 不存在", forgeJWT(t, e.key, rs256Header("unknown-kid"), map[string]any{"iss": testIssuer, "exp": time.Now().Add(time.Hour).Unix()})}, + {"issuer 不符", forgeJWT(t, e.key, rs256Header(e.key.Kid), map[string]any{"iss": "https://other.example", "exp": time.Now().Add(time.Hour).Unix()})}, + {"已過期", forgeJWT(t, e.key, rs256Header(e.key.Kid), map[string]any{"iss": testIssuer, "exp": time.Now().Add(-time.Minute).Unix()})}, + {"竄改 payload", tampered}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := oidc.VerifyAccessToken(e.db, testIssuer, tt.token) + if !errors.Is(err, oidc.ErrInvalidToken) { + t.Fatalf("err = %v, want ErrInvalidToken", err) + } + }) + } +} + +// 已退休金鑰簽發的 token 在效期內仍應驗證成功(輪替過渡期,JWKS 同步 +// 發佈新舊金鑰的模型)。 +func TestVerifyAccessTokenRetiredKey(t *testing.T) { + e := newTestEnv(t) + token, err := oidc.IssueAccessToken(e.db, testIssuer, e.user.ID, e.app, "openid") + if err != nil { + t.Fatal(err) + } + now := time.Now() + if err := e.db.Model(e.key).Update("retired_at", now).Error; err != nil { + t.Fatal("退休金鑰: ", err) + } + if _, err := oidc.VerifyAccessToken(e.db, testIssuer, token); err != nil { + t.Fatalf("退休金鑰在效期內仍應可驗證: %v", err) + } +} diff --git a/internal/oidc/model.go b/internal/oidc/model.go new file mode 100644 index 0000000..238fd03 --- /dev/null +++ b/internal/oidc/model.go @@ -0,0 +1,280 @@ +package oidc + +import ( + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "sort" + "strings" + "time" + + "gorm.io/gorm" + + "alterminal/internal/auth" +) + +// 各種憑證的有效時間:授權碼為一次性短時效憑證(RFC 6749 §4.1.2 建議 +// 最長 10 分鐘,取 5 分鐘);Access/ID Token 15 分鐘為業界常見值; +// Refresh Token 30 天,兌換時輪替。 +const ( + authorizationCodeTTL = 5 * time.Minute + accessTokenTTL = 15 * time.Minute + idTokenTTL = 15 * time.Minute + refreshTokenTTL = 30 * 24 * time.Hour +) + +// sha256Token 回傳字串的 SHA-256 雜湊(無填充 base64url,43 字元)。 +// 授權碼與 refresh token 本身即高熵亂數,兌換時僅能以憑證值查詢、無 +// 其他鍵可用(不同於 client secret 以 client_id 為鍵後再做慢雜湊), +// 故以 SHA-256 作為可索引的確定性雜湊儲存;資料庫外洩時攻擊者亦無法 +// 還原明文憑證(RFC 6819 §5.2.2.1 的憑證儲存建議)。 +func sha256Token(s string) string { + sum := sha256.Sum256([]byte(s)) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} + +// normalizeScope 將空格分隔的 scope 拆解、去重並排序後回傳。比對與 +// 儲存皆使用正規化形式,避免「openid profile」與「profile openid」 +// 被視為不同集合。 +func normalizeScope(scope string) []string { + fields := strings.Fields(scope) + sort.Strings(fields) + seen := make(map[string]bool, len(fields)) + out := make([]string, 0, len(fields)) + for _, f := range fields { + if !seen[f] { + seen[f] = true + out = append(out, f) + } + } + return out +} + +// scopeCovered 回傳 requested 中的每個 scope 皆存在於 granted。 +func scopeCovered(granted, requested string) bool { + g := make(map[string]bool) + for _, s := range strings.Fields(granted) { + g[s] = true + } + for _, s := range strings.Fields(requested) { + if !g[s] { + return false + } + } + return true +} + +// AuthorizationCode 為授權碼流程的一次性憑證(RFC 6749 §4.1.2),對應 +// authorization_codes 資料表。CodeHash 為授權碼明文的 SHA-256,明文僅 +// 在發行當下出現於 redirect URI 一次;兌換後設定 UsedAt,之後再次兌換 +// 即為重用——除拒絕外並撤銷該碼發行的一切 refresh token。RedirectURI、 +// Scope、CodeChallenge 等發行當下的授權內容隨碼凍結,兌換時逐項比對。 +type AuthorizationCode struct { + ID uint `gorm:"primaryKey"` + CodeHash string `gorm:"uniqueIndex;size:43;not null"` + ApplicationID uint `gorm:"not null;index"` + UserID uint `gorm:"not null;index"` + RedirectURI string `gorm:"size:2048;not null"` + Scope string `gorm:"size:255;not null"` + Nonce string `gorm:"size:255;not null;default:''"` // OIDC Core §3.1.2.1 nonce,未提供為空 + CodeChallenge string `gorm:"size:255;not null;default:''"` // RFC 7636 §4.3 的 challenge(S256),未使用 PKCE 為空 + CodeChallengeMethod string `gorm:"size:16;not null;default:''"` // "S256" 或空字串 + AuthTime time.Time `gorm:"not null"` // 使用者 Session 建立時間(ID token auth_time 的依據,OIDC Core §2) + ExpiresAt time.Time `gorm:"not null"` + UsedAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +// NewAuthorizationCode 產生並儲存授權碼,回傳模型與明文——明文僅此一次, +// 呼叫方隨即放入 redirect URI,不得留存。authTime 為使用者 Session 的 +// 建立時間,隨碼保存供兌換時簽入 ID token。順帶刪除已過期的授權碼 +// (最佳清除,失敗不影響發碼)。 +func NewAuthorizationCode(db *gorm.DB, applicationID, userID uint, redirectURI, scope, nonce, codeChallenge, codeChallengeMethod string, authTime time.Time) (*AuthorizationCode, string, error) { + code, err := auth.NewToken(32) + if err != nil { + return nil, "", fmt.Errorf("generate code: %w", err) + } + ac := &AuthorizationCode{ + CodeHash: sha256Token(code), + ApplicationID: applicationID, + UserID: userID, + RedirectURI: redirectURI, + Scope: scope, + Nonce: nonce, + CodeChallenge: codeChallenge, + CodeChallengeMethod: codeChallengeMethod, + AuthTime: authTime, + ExpiresAt: time.Now().Add(authorizationCodeTTL), + } + if err := db.Create(ac).Error; err != nil { + return nil, "", fmt.Errorf("create authorization code: %w", err) + } + db.Where("expires_at < ?", time.Now()).Delete(&AuthorizationCode{}) + return ac, code, nil +} + +// GetAuthorizationCode 以授權碼明文(雜湊後)查詢對應資料列;查無資料 +// 時回傳包裹 gorm.ErrRecordNotFound 的錯誤(以 errors.Is 判斷)。 +func GetAuthorizationCode(db *gorm.DB, code string) (*AuthorizationCode, error) { + var ac AuthorizationCode + if err := db.Where("code_hash = ?", sha256Token(code)).First(&ac).Error; err != nil { + return nil, fmt.Errorf("query authorization code: %w", err) + } + return &ac, nil +} + +// ConsumeAuthorizationCode 以條件更新(used_at 仍為 NULL 且未過期)標記 +// 授權碼已兌換,回傳是否成功。條件更新保證並發的第二次兌換必然失敗 +// (RFC 6749 §4.1.2 的一次性要求;先查後寫在並發下會有競態)。 +func ConsumeAuthorizationCode(db *gorm.DB, id uint) (bool, error) { + now := time.Now() + res := db.Model(&AuthorizationCode{}). + Where("id = ? AND used_at IS NULL AND expires_at > ?", id, now). + Update("used_at", now) + if res.Error != nil { + return false, fmt.Errorf("consume authorization code: %w", res.Error) + } + return res.RowsAffected == 1, nil +} + +// RefreshToken 為換發新權杖的長效憑證(RFC 6749 §6),對應 +// refresh_tokens 資料表。TokenHash 為明文的 SHA-256。輪替模型為 +// 「兌換即作廢舊 token 並發行新 token」(OAuth 2.0 Security BCP +// §4.14.2):RotatedAt 標記已輪替、RevokedAt 標記已撤銷;兌換已輪替 +// 的 token 視為重用,撤銷該使用者於該應用程式的全部 refresh token。 +// AuthorizationID 記錄發行來源的授權碼,授權碼重用時據此撤銷。 +type RefreshToken struct { + ID uint `gorm:"primaryKey"` + TokenHash string `gorm:"uniqueIndex;size:43;not null"` + ApplicationID uint `gorm:"not null;index"` + UserID uint `gorm:"not null;index"` + AuthorizationID uint `gorm:"not null;index"` + Scope string `gorm:"size:255;not null"` + AuthTime time.Time `gorm:"not null"` // 沿用發行來源授權碼的值;來源授權碼到期清除後仍可簽發 ID token + ExpiresAt time.Time `gorm:"not null"` + RotatedAt *time.Time + RevokedAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +// NewRefreshToken 產生並儲存 refresh token,回傳模型與明文——明文僅在 +// token 回應中出現一次。順帶刪除已過期的 refresh token(最佳清除, +// 失敗不影響發行)。 +func NewRefreshToken(db *gorm.DB, applicationID, userID, authorizationID uint, scope string, authTime time.Time) (*RefreshToken, string, error) { + token, err := auth.NewToken(32) + if err != nil { + return nil, "", fmt.Errorf("generate refresh token: %w", err) + } + rt := &RefreshToken{ + TokenHash: sha256Token(token), + ApplicationID: applicationID, + UserID: userID, + AuthorizationID: authorizationID, + Scope: scope, + AuthTime: authTime, + ExpiresAt: time.Now().Add(refreshTokenTTL), + } + if err := db.Create(rt).Error; err != nil { + return nil, "", fmt.Errorf("create refresh token: %w", err) + } + db.Where("expires_at < ?", time.Now()).Delete(&RefreshToken{}) + return rt, token, nil +} + +// GetRefreshToken 以 refresh token 明文(雜湊後)查詢對應資料列;查無 +// 資料時回傳包裹 gorm.ErrRecordNotFound 的錯誤(以 errors.Is 判斷)。 +func GetRefreshToken(db *gorm.DB, token string) (*RefreshToken, error) { + var rt RefreshToken + if err := db.Where("token_hash = ?", sha256Token(token)).First(&rt).Error; err != nil { + return nil, fmt.Errorf("query refresh token: %w", err) + } + return &rt, nil +} + +// RotateRefreshToken 以條件更新(rotated_at 與 revoked_at 仍為 NULL 且 +// 未過期)標記 refresh token 已輪替,回傳是否成功;並發的重複兌換僅 +// 一個成功,失敗方即為重用。 +func RotateRefreshToken(db *gorm.DB, id uint) (bool, error) { + now := time.Now() + res := db.Model(&RefreshToken{}). + Where("id = ? AND rotated_at IS NULL AND revoked_at IS NULL AND expires_at > ?", id, now). + Update("rotated_at", now) + if res.Error != nil { + return false, fmt.Errorf("rotate refresh token: %w", res.Error) + } + return res.RowsAffected == 1, nil +} + +// RevokeRefreshTokensByAuthorization 撤銷指定授權碼發行的所有 refresh +// token(授權碼重用時的防護,RFC 6749 §4.1.2)。 +func RevokeRefreshTokensByAuthorization(db *gorm.DB, authorizationID uint) error { + if err := db.Model(&RefreshToken{}). + Where("authorization_id = ? AND revoked_at IS NULL", authorizationID). + Update("revoked_at", time.Now()).Error; err != nil { + return fmt.Errorf("revoke refresh tokens: %w", err) + } + return nil +} + +// RevokeRefreshTokensFor 撤銷使用者於指定應用程式的所有 refresh token +// (refresh token 重用偵測時的整鏈撤銷,OAuth 2.0 Security BCP §4.14.2)。 +func RevokeRefreshTokensFor(db *gorm.DB, userID, applicationID uint) error { + if err := db.Model(&RefreshToken{}). + Where("user_id = ? AND application_id = ? AND revoked_at IS NULL", userID, applicationID). + Update("revoked_at", time.Now()).Error; err != nil { + return fmt.Errorf("revoke refresh tokens: %w", err) + } + return nil +} + +// Consent 為使用者對應用程式的授權同意記錄,對應 consents 資料表: +// 同意頁首次同意後記住 scope 聯集,之後請求的 scope 全部涵蓋於已同意 +// 集合時靜默通過,不再顯示同意頁;請求範圍擴大時再次詢問。 +type Consent struct { + ID uint `gorm:"primaryKey"` + UserID uint `gorm:"not null;uniqueIndex:idx_consents_user_application,priority:1"` + ApplicationID uint `gorm:"not null;uniqueIndex:idx_consents_user_application,priority:2"` + Scope string `gorm:"size:255;not null"` + CreatedAt time.Time + UpdatedAt time.Time +} + +// GetConsent 查詢使用者對應用程式的同意記錄;查無資料時回傳包裹 +// gorm.ErrRecordNotFound 的錯誤(以 errors.Is 判斷)。 +func GetConsent(db *gorm.DB, userID, applicationID uint) (*Consent, error) { + var c Consent + if err := db.Where("user_id = ? AND application_id = ?", userID, applicationID).First(&c).Error; err != nil { + return nil, fmt.Errorf("query consent: %w", err) + } + return &c, nil +} + +// SaveConsent 記錄同意:首次建立,之後將新的 scope 併入既有聯集(同意 +// 頁勾選一律代表「允許全部請求的 scope」)。 +func SaveConsent(db *gorm.DB, userID, applicationID uint, scope string) error { + var c Consent + err := db.Where("user_id = ? AND application_id = ?", userID, applicationID).First(&c).Error + switch { + case errors.Is(err, gorm.ErrRecordNotFound): + c = Consent{ + UserID: userID, + ApplicationID: applicationID, + Scope: strings.Join(normalizeScope(scope), " "), + } + if err := db.Create(&c).Error; err != nil { + return fmt.Errorf("create consent: %w", err) + } + return nil + case err != nil: + return fmt.Errorf("query consent: %w", err) + } + merged := normalizeScope(c.Scope + " " + scope) + c.Scope = strings.Join(merged, " ") + if err := db.Save(&c).Error; err != nil { + return fmt.Errorf("update consent: %w", err) + } + return nil +} diff --git a/internal/oidc/model_test.go b/internal/oidc/model_test.go new file mode 100644 index 0000000..571af86 --- /dev/null +++ b/internal/oidc/model_test.go @@ -0,0 +1,64 @@ +package oidc + +import ( + "strings" + "testing" +) + +// normalizeScope 應拆解、去重並排序 scope。 +func TestNormalizeScope(t *testing.T) { + tests := []struct { + name string + scope string + want string + }{ + {"空字串", "", ""}, + {"多餘空白", " openid profile ", "openid profile"}, + {"去除重複", "profile openid profile", "openid profile"}, + {"排序", "email openid", "email openid"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := strings.Join(normalizeScope(tt.scope), " "); got != tt.want { + t.Fatalf("normalizeScope(%q) = %q, want %q", tt.scope, got, tt.want) + } + }) + } +} + +// scopeCovered 判斷請求 scope 是否全數涵蓋於已同意集合。 +func TestScopeCovered(t *testing.T) { + tests := []struct { + name string + granted string + requested string + want bool + }{ + {"完全相同", "openid profile", "openid profile", true}, + {"請求子集", "openid profile email", "openid email", true}, + {"請求超出", "openid", "openid email", false}, + {"完全無關", "openid", "profile", false}, + {"空請求", "openid", "", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := scopeCovered(tt.granted, tt.requested); got != tt.want { + t.Fatalf("scopeCovered(%q, %q) = %v, want %v", tt.granted, tt.requested, got, tt.want) + } + }) + } +} + +// sha256Token 輸出長度應為 43 字元(32 bytes 的 base64url)。 +func TestSha256Token(t *testing.T) { + got := sha256Token("test") + if len(got) != 43 { + t.Fatalf("SHA-256 base64url 長度 = %d, want 43", len(got)) + } + if sha256Token("test") != got { + t.Fatal("同一輸入應得相同雜湊") + } + if sha256Token("other") == got { + t.Fatal("不同輸入應得不同雜湊") + } +} diff --git a/internal/oidc/token.go b/internal/oidc/token.go new file mode 100644 index 0000000..b29d76d --- /dev/null +++ b/internal/oidc/token.go @@ -0,0 +1,345 @@ +package oidc + +import ( + "crypto/sha256" + "encoding/base64" + "errors" + "log" + "net/http" + "net/url" + "strings" + "time" + + "gorm.io/gorm" + + "alterminal/internal/application" + "alterminal/internal/auth" +) + +// tokenResponse 為 token 端點的成功回應(RFC 6749 §5.1;ID token 與 +// refresh token 僅在對應條件成立時出現——ID token 於簽發對象為使用者 +// 且 scope 含 openid 時、refresh token 於 scope 含 offline_access 時, +// OIDC Core §3.1.3.3)。 +type tokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + Scope string `json:"scope"` + IDToken string `json:"id_token,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` +} + +// tokenError 為 RFC 6749 §5.2 的錯誤回應格式。 +type tokenError struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description,omitempty"` +} + +// writeTokenError 輸出 token 端點錯誤;client 認證失敗(invalid_client) +// 回 401,其餘依規格回 400。 +func writeTokenError(w http.ResponseWriter, status int, code, description string) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Pragma", "no-cache") + if status == http.StatusUnauthorized { + // 以 Basic 認證的請求須提示 Basic(RFC 6749 §5.2),一律附上不影響。 + w.Header().Set("WWW-Authenticate", `Basic realm="alterminal"`) + } + auth.WriteJSON(w, status, tokenError{Error: code, ErrorDescription: description}) +} + +// TokenHandler 處理 POST /token(RFC 6749 §3.2):以授權碼(§4.1.3) +// 或 refresh token(§6)換發 Access/ID/Refresh Token。 +func TokenHandler(db *gorm.DB, issuer string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.Header().Set("Allow", http.MethodPost) + writeTokenError(w, http.StatusMethodNotAllowed, "invalid_request", "僅支援 POST") + return + } + if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/x-www-form-urlencoded") { + writeTokenError(w, http.StatusBadRequest, "invalid_request", "Content-Type 須為 application/x-www-form-urlencoded") + return + } + if err := r.ParseForm(); err != nil { + writeTokenError(w, http.StatusBadRequest, "invalid_request", "無法解析表單內容") + return + } + + app := authenticateTokenClient(db, w, r) + if app == nil { + return + } + + switch r.PostFormValue("grant_type") { + case "authorization_code": + tokenAuthorizationCode(db, issuer, w, r, app) + case "refresh_token": + tokenRefreshToken(db, issuer, w, r, app) + case "": + writeTokenError(w, http.StatusBadRequest, "invalid_request", "缺少 grant_type") + default: + writeTokenError(w, http.StatusBadRequest, "unsupported_grant_type", "不支援的 grant_type") + } + } +} + +// authenticateTokenClient 驗證 Client 身分(RFC 6749 §2.3.1):client +// 認證可經 HTTP Basic(推薦)或表單欄位,兩處同時出現的 client_id 必須 +// 一致(§3.2.1)。機密式 Client 必須提出正確的 client secret;公開式 +// Client 不持有 secret,僅以 client_id 識別(PKCE 承擔防護)。驗證失敗 +// 已寫出 401 回應並回傳 nil。 +func authenticateTokenClient(db *gorm.DB, w http.ResponseWriter, r *http.Request) *application.Application { + basicID, basicSecret, hasBasic := r.BasicAuth() + postID := r.PostFormValue("client_id") + postSecret := r.PostFormValue("client_secret") + // RFC 6749 §2.3.1 要求 Basic 中的 client_id/secret 先以表單編碼; + // 本服務產生的識別值僅含 base64url 字元,解碼失敗時退回原值以相容 + // 未編碼的實作。 + if basicID != "" { + if unescaped, err := url.QueryUnescape(basicID); err == nil { + basicID = unescaped + } + if unescaped, err := url.QueryUnescape(basicSecret); err == nil { + basicSecret = unescaped + } + } + if hasBasic && postID != "" && basicID != postID { + writeTokenError(w, http.StatusBadRequest, "invalid_request", "Basic 與表單的 client_id 不一致") + return nil + } + clientID := postID + if clientID == "" { + clientID = basicID + } + if clientID == "" { + writeTokenError(w, http.StatusUnauthorized, "invalid_client", "缺少 client_id") + return nil + } + + app, err := application.GetByClientID(db, clientID) + if err != nil { + // 查無 client 或查詢失敗一律 401,不洩漏 client 是否存在。 + if !errors.Is(err, gorm.ErrRecordNotFound) { + log.Printf("token: %v", err) + } + writeTokenError(w, http.StatusUnauthorized, "invalid_client", "client 認證失敗") + return nil + } + secret := postSecret + if hasBasic && secret == "" { + secret = basicSecret + } + if !app.IsPublic() && !app.CheckSecret(secret) { + writeTokenError(w, http.StatusUnauthorized, "invalid_client", "client 認證失敗") + return nil + } + return app +} + +// tokenAuthorizationCode 處理 grant_type=authorization_code(RFC 6749 +// §4.1.3):兌換一次性授權碼,逐項比對兌換條件後簽發權杖。 +func tokenAuthorizationCode(db *gorm.DB, issuer string, w http.ResponseWriter, r *http.Request, app *application.Application) { + if !app.GrantTypes.Contains(application.GrantAuthorizationCode) { + writeTokenError(w, http.StatusBadRequest, "unauthorized_client", "應用程式未啟用授權碼流程") + return + } + code := r.PostFormValue("code") + if code == "" { + writeTokenError(w, http.StatusBadRequest, "invalid_request", "缺少 code") + return + } + + ac, err := GetAuthorizationCode(db, code) + if err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + log.Printf("token: %v", err) + } + writeTokenError(w, http.StatusBadRequest, "invalid_grant", "授權碼無效") + return + } + if ac.UsedAt != nil { + // 授權碼重用:撤銷其發行的一切 refresh token(RFC 6749 §4.1.2 + // 一次性要求;OAuth 2.0 Security BCP §4.5.3.3 的防護)。 + if err := RevokeRefreshTokensByAuthorization(db, ac.ID); err != nil { + log.Printf("token: %v", err) + } + writeTokenError(w, http.StatusBadRequest, "invalid_grant", "授權碼無效") + return + } + if ac.ExpiresAt.Before(time.Now()) { + writeTokenError(w, http.StatusBadRequest, "invalid_grant", "授權碼已過期") + return + } + // 授權碼與 client 及 redirect_uri 的綁定逐項比對(RFC 6749 §4.1.3); + // 不符一律回 invalid_grant,不洩漏原因。 + if ac.ApplicationID != app.ID || r.PostFormValue("redirect_uri") != ac.RedirectURI { + writeTokenError(w, http.StatusBadRequest, "invalid_grant", "授權碼無效") + return + } + // PKCE(RFC 7636 §4.6):發碼時有 challenge 者,兌換必須提出比對 + // 相符的 code_verifier。 + if ac.CodeChallenge != "" { + verifier := r.PostFormValue("code_verifier") + if !validCodeVerifier(verifier) { + writeTokenError(w, http.StatusBadRequest, "invalid_request", "code_verifier 格式無效") + return + } + if pkceChallenge(verifier) != ac.CodeChallenge { + writeTokenError(w, http.StatusBadRequest, "invalid_grant", "PKCE 驗證失敗") + return + } + } + ok, err := ConsumeAuthorizationCode(db, ac.ID) + if err != nil { + log.Printf("token: %v", err) + writeTokenError(w, http.StatusInternalServerError, "", "") + return + } + if !ok { + // 並發兌換的輸家;勝者已完成撤銷防護,比照重用處理。 + writeTokenError(w, http.StatusBadRequest, "invalid_grant", "授權碼無效") + return + } + + var u auth.User + if err := db.First(&u, ac.UserID).Error; err != nil { + log.Printf("token: 查詢使用者 %d: %v", ac.UserID, err) + writeTokenError(w, http.StatusInternalServerError, "", "") + return + } + issueTokenResponse(db, issuer, w, &u, app, ac.Scope, ac.Nonce, ac.ID, ac.AuthTime) +} + +// tokenRefreshToken 處理 grant_type=refresh_token(RFC 6749 §6):以 +// refresh token 換發新權杖組,舊 token 立即輪替作廢;偵測到重用已輪替 +// 的 token 時撤銷該使用者於該應用程式的全部 refresh token。 +func tokenRefreshToken(db *gorm.DB, issuer string, w http.ResponseWriter, r *http.Request, app *application.Application) { + if !app.GrantTypes.Contains(application.GrantRefreshToken) { + writeTokenError(w, http.StatusBadRequest, "unauthorized_client", "應用程式未啟用 refresh_token") + return + } + token := r.PostFormValue("refresh_token") + if token == "" { + writeTokenError(w, http.StatusBadRequest, "invalid_request", "缺少 refresh_token") + return + } + + rt, err := GetRefreshToken(db, token) + if err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + log.Printf("token: %v", err) + } + writeTokenError(w, http.StatusBadRequest, "invalid_grant", "refresh token 無效") + return + } + if rt.RotatedAt != nil { + // 已輪替的 token 再次出現即為重用:整鏈撤銷(OAuth 2.0 + // Security BCP §4.14.2)。 + if err := RevokeRefreshTokensFor(db, rt.UserID, rt.ApplicationID); err != nil { + log.Printf("token: %v", err) + } + writeTokenError(w, http.StatusBadRequest, "invalid_grant", "refresh token 重用,相關權杖已撤銷") + return + } + if rt.RevokedAt != nil || rt.ExpiresAt.Before(time.Now()) { + writeTokenError(w, http.StatusBadRequest, "invalid_grant", "refresh token 已失效") + return + } + if rt.ApplicationID != app.ID { + writeTokenError(w, http.StatusBadRequest, "invalid_grant", "refresh token 無效") + return + } + + // RFC 6749 §6:請求可縮小 scope,不可擴大。 + scope := rt.Scope + if req := strings.TrimSpace(r.PostFormValue("scope")); req != "" { + if !scopeCovered(rt.Scope, req) { + writeTokenError(w, http.StatusBadRequest, "invalid_scope", "請求的 scope 超出原授權範圍") + return + } + scope = strings.Join(normalizeScope(req), " ") + } + + ok, err := RotateRefreshToken(db, rt.ID) + if err != nil { + log.Printf("token: %v", err) + writeTokenError(w, http.StatusInternalServerError, "", "") + return + } + if !ok { + writeTokenError(w, http.StatusBadRequest, "invalid_grant", "refresh token 無效") + return + } + + var u auth.User + if err := db.First(&u, rt.UserID).Error; err != nil { + log.Printf("token: 查詢使用者 %d: %v", rt.UserID, err) + writeTokenError(w, http.StatusInternalServerError, "", "") + return + } + issueTokenResponse(db, issuer, w, &u, app, scope, "", rt.AuthorizationID, rt.AuthTime) +} + +// issueTokenResponse 簽發權杖組並寫出成功回應:Access Token 必發;scope +// 含 openid 時簽發 ID token;scope 含 offline_access 時簽發 refresh +// token 並作廢舊授權碼鏈的後繼(由輪替模型保證單一現行 token)。 +// authorizationID 為本次授權鏈的源頭授權碼 ID,refresh token 沿用記錄。 +func issueTokenResponse(db *gorm.DB, issuer string, w http.ResponseWriter, u *auth.User, app *application.Application, scope, nonce string, authorizationID uint, authTime time.Time) { + access, err := IssueAccessToken(db, issuer, u.ID, app, scope) + if err != nil { + log.Printf("token: %v", err) + writeTokenError(w, http.StatusInternalServerError, "", "") + return + } + resp := tokenResponse{ + AccessToken: access, + TokenType: "Bearer", + ExpiresIn: int64(accessTokenTTL.Seconds()), + Scope: scope, + } + if scopeHas(scope, "openid") { + idToken, err := IssueIDToken(db, issuer, u, app, scope, nonce, authTime) + if err != nil { + log.Printf("token: %v", err) + writeTokenError(w, http.StatusInternalServerError, "", "") + return + } + resp.IDToken = idToken + } + if scopeHas(scope, "offline_access") { + _, plain, err := NewRefreshToken(db, app.ID, u.ID, authorizationID, scope, authTime) + if err != nil { + log.Printf("token: %v", err) + writeTokenError(w, http.StatusInternalServerError, "", "") + return + } + resp.RefreshToken = plain + } + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Pragma", "no-cache") + auth.WriteJSON(w, http.StatusOK, resp) +} + +// validCodeVerifier 檢查 code_verifier 格式(RFC 7636 §4.1): +// 43–128 個字元,僅含 [A-Za-z0-9-._~]。 +func validCodeVerifier(v string) bool { + if len(v) < 43 || len(v) > 128 { + return false + } + for _, c := range v { + switch { + case c >= 'A' && c <= 'Z', c >= 'a' && c <= 'z', c >= '0' && c <= '9': + case c == '-' || c == '.' || c == '_' || c == '~': + default: + return false + } + } + return true +} + +// pkceChallenge 計算 code_verifier 的 S256 challenge(RFC 7636 §4.2): +// BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))。 +func pkceChallenge(verifier string) string { + sum := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} diff --git a/internal/oidc/token_test.go b/internal/oidc/token_test.go new file mode 100644 index 0000000..21d1249 --- /dev/null +++ b/internal/oidc/token_test.go @@ -0,0 +1,378 @@ +// 外部測試套件:見 jwks_test.go 開頭說明。 +package oidc_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "alterminal/internal/oidc" + "alterminal/internal/testdb" +) + +// RFC 7636 附錄 B 的官方測試向量:code_verifier 與其 S256 challenge。 +const ( + testVerifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + testChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + wrongVerifier = "wJ-B4LdB4kNOXK32ONwPccn9YMHcGgnbHDB1jXtsCXc" // 格式合法但與 challenge 不符 +) + +// tokenBody 為成功回應的斷言結構。 +type tokenBody struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + Scope string `json:"scope"` + IDToken string `json:"id_token"` + RefreshToken string `json:"refresh_token"` +} + +// exchangeCode 兌換授權碼,回傳記錄器。basic=true 時以 HTTP Basic 認證 +// (表單不帶 client 欄位),否則以 client_secret_post 送出。 +func exchangeCode(h http.HandlerFunc, code, redirectURI, clientID, clientSecret, verifier string, basic bool) *httptest.ResponseRecorder { + form := url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "redirect_uri": {redirectURI}, + } + if verifier != "" { + form.Set("code_verifier", verifier) + } + if basic { + return postToken(h, form, clientID, clientSecret) + } + form.Set("client_id", clientID) + if clientSecret != "" { + form.Set("client_secret", clientSecret) + } + return postToken(h, form, "", "") +} + +// 完整兌換:機密式 Client + PKCE + Basic 認證,核發 Access/ID/Refresh +// Token,ID token 各 claim 依授權內容簽入(OIDC Core §3.1.3.3、§5.4)。 +func TestTokenAuthorizationCodeFull(t *testing.T) { + e := newTestEnv(t) + h := oidc.TokenHandler(e.db, testIssuer) + + _, code := consentAllow(t, e, authorizeQuery(e.app, "openid profile email offline_access", "xyz", "nonce-42", testChallenge)) + rec := exchangeCode(h, code, e.app.RedirectURIs[0], e.app.ClientID, e.secret, testVerifier, true) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body = %s", rec.Code, rec.Body.String()) + } + if cc := rec.Header().Get("Cache-Control"); cc != "no-store" { + t.Errorf("Cache-Control = %q, want no-store", cc) + } + + var body tokenBody + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal("解析回應: ", err) + } + if body.AccessToken == "" || body.TokenType != "Bearer" || body.ExpiresIn != 900 { + t.Errorf("access token 欄位不符: %+v", body) + } + if body.Scope != "email offline_access openid profile" { + t.Errorf("scope = %q(應為正規化排序形式)", body.Scope) + } + if body.IDToken == "" { + t.Fatal("scope 含 openid 應核發 id_token") + } + if body.RefreshToken == "" { + t.Fatal("scope 含 offline_access 應核發 refresh_token") + } + + _, payload := jwtParts(t, body.IDToken) + var idc idTokenClaims + if err := json.Unmarshal(payload, &idc); err != nil { + t.Fatal("解析 ID token: ", err) + } + if idc.Iss != testIssuer || idc.Aud != e.app.ClientID { + t.Errorf("iss/aud = %q/%q", idc.Iss, idc.Aud) + } + if idc.Sub != subjectOf(e.user.ID) { + t.Errorf("sub = %q, want %q", idc.Sub, subjectOf(e.user.ID)) + } + if idc.Nonce != "nonce-42" { + t.Errorf("nonce = %q, want nonce-42", idc.Nonce) + } + if idc.AuthTime == 0 { + t.Error("auth_time 應簽入 Session 建立時間") + } + if idc.Name != e.user.Name || idc.Email != e.user.Email || idc.EmailVerf == nil || !*idc.EmailVerf { + t.Errorf("profile/email claims 不符: %+v", idc) + } + + // 無 offline_access 的 scope 不應拿到 refresh token。 + _, code2 := consentAllow(t, e, authorizeQuery(e.app, "openid", "", "", testChallenge)) + rec = exchangeCode(h, code2, e.app.RedirectURIs[0], e.app.ClientID, e.secret, testVerifier, false) + if rec.Code != http.StatusOK { + t.Fatalf("第二次兌換 status = %d, body = %s", rec.Code, rec.Body.String()) + } + var body2 tokenBody + json.Unmarshal(rec.Body.Bytes(), &body2) + if body2.RefreshToken != "" { + t.Error("未請求 offline_access 不應核發 refresh_token") + } + if body2.IDToken == "" { + t.Error("scope 含 openid 應核發 id_token") + } +} + +// 公開式 Client 無 secret,以 PKCE 兌換(client_secret_post 欄位不送)。 +func TestTokenPublicClientPKCE(t *testing.T) { + e := newTestEnv(t) + h := oidc.TokenHandler(e.db, testIssuer) + + _, code := consentAllow(t, e, authorizeQuery(e.pub, "openid", "", "", testChallenge)) + rec := exchangeCode(h, code, e.pub.RedirectURIs[0], e.pub.ClientID, "", testVerifier, false) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body = %s", rec.Code, rec.Body.String()) + } + var body tokenBody + json.Unmarshal(rec.Body.Bytes(), &body) + if body.AccessToken == "" { + t.Fatal("應核發 access_token") + } +} + +// client 認證失敗與參數錯誤。 +func TestTokenClientAuthentication(t *testing.T) { + e := newTestEnv(t) + h := oidc.TokenHandler(e.db, testIssuer) + + t.Run("client secret 錯誤回 401 invalid_client", func(t *testing.T) { + _, code := consentAllow(t, e, authorizeQuery(e.app, "openid", "", "", testChallenge)) + rec := exchangeCode(h, code, e.app.RedirectURIs[0], e.app.ClientID, "wrong-secret", testVerifier, true) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + if got := decodeTokenError(t, rec).Error; got != "invalid_client" { + t.Errorf("error = %q, want invalid_client", got) + } + if rec.Header().Get("WWW-Authenticate") == "" { + t.Error("Basic 認證失敗應附 WWW-Authenticate") + } + }) + + t.Run("未知 client_id 回 401", func(t *testing.T) { + rec := exchangeCode(h, "any", e.app.RedirectURIs[0], "no-such", "x", "", false) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + }) + + t.Run("Basic 與表單 client_id 不一致", func(t *testing.T) { + form := url.Values{"grant_type": {"authorization_code"}, "code": {"x"}, "client_id": {e.app.ClientID}} + rec := postToken(h, form, "no-such", "secret") + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + if got := decodeTokenError(t, rec).Error; got != "invalid_request" { + t.Errorf("error = %q, want invalid_request", got) + } + }) + + t.Run("不支援的 grant_type", func(t *testing.T) { + form := url.Values{"grant_type": {"password"}, "client_id": {e.app.ClientID}, "client_secret": {e.secret}} + rec := postToken(h, form, "", "") + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + if got := decodeTokenError(t, rec).Error; got != "unsupported_grant_type" { + t.Errorf("error = %q", got) + } + }) + + t.Run("Content-Type 非 form 回 400", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/token", nil) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + }) +} + +// 授權碼兌換的條件比對與一次性(RFC 6749 §4.1.3)。 +func TestTokenCodeRedemptionErrors(t *testing.T) { + e := newTestEnv(t) + h := oidc.TokenHandler(e.db, testIssuer) + redirectURI := e.app.RedirectURIs[0] + + mustCode := func(t *testing.T) string { + _, code := consentAllow(t, e, authorizeQuery(e.app, "openid", "", "", testChallenge)) + return code + } + + t.Run("code_verifier 不符回 invalid_grant", func(t *testing.T) { + rec := exchangeCode(h, mustCode(t), redirectURI, e.app.ClientID, e.secret, wrongVerifier, true) + if got := decodeTokenError(t, rec).Error; got != "invalid_grant" { + t.Fatalf("error = %q, want invalid_grant, body = %s", got, rec.Body.String()) + } + }) + + t.Run("code_verifier 格式無效回 invalid_request", func(t *testing.T) { + rec := exchangeCode(h, mustCode(t), redirectURI, e.app.ClientID, e.secret, "short", true) + if got := decodeTokenError(t, rec).Error; got != "invalid_request" { + t.Fatalf("error = %q, want invalid_request", got) + } + }) + + t.Run("redirect_uri 與發碼時不符回 invalid_grant", func(t *testing.T) { + rec := exchangeCode(h, mustCode(t), "https://rp.example/other", e.app.ClientID, e.secret, testVerifier, true) + if got := decodeTokenError(t, rec).Error; got != "invalid_grant" { + t.Fatalf("error = %q, want invalid_grant", got) + } + }) + + t.Run("換別的 client 也回 invalid_grant", func(t *testing.T) { + rec := exchangeCode(h, mustCode(t), redirectURI, e.pub.ClientID, "", testVerifier, false) + if got := decodeTokenError(t, rec).Error; got != "invalid_grant" { + t.Fatalf("error = %q, want invalid_grant", got) + } + }) + + t.Run("不存在的 code", func(t *testing.T) { + rec := exchangeCode(h, "no-such-code", redirectURI, e.app.ClientID, e.secret, "", true) + if got := decodeTokenError(t, rec).Error; got != "invalid_grant" { + t.Fatalf("error = %q, want invalid_grant", got) + } + }) + + t.Run("重用撤銷其 refresh token", func(t *testing.T) { + code := mustCode(t) + form := url.Values{"grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {redirectURI}, "code_verifier": {testVerifier}} + rec := postToken(h, form, e.app.ClientID, e.secret) + if rec.Code != http.StatusOK { + t.Fatalf("首次兌換失敗: %s", rec.Body.String()) + } + var first tokenBody + json.Unmarshal(rec.Body.Bytes(), &first) + + // 同一碼再兌換:invalid_grant,且首次拿到的 refresh token 應被撤銷。 + rec = postToken(h, form, e.app.ClientID, e.secret) + if got := decodeTokenError(t, rec).Error; got != "invalid_grant" { + t.Fatalf("重用 error = %q, want invalid_grant", got) + } + refreshForm := url.Values{"grant_type": {"refresh_token"}, "refresh_token": {first.RefreshToken}} + rec = postToken(h, refreshForm, e.app.ClientID, e.secret) + if rec.Code != http.StatusBadRequest { + t.Fatalf("被撤銷的 refresh token 不應可用: %s", rec.Body.String()) + } + }) +} + +// Refresh token 輪替與重用整鏈撤銷(OAuth 2.0 Security BCP §4.14.2)。 +func TestTokenRefreshRotationAndReuse(t *testing.T) { + e := newTestEnv(t) + h := oidc.TokenHandler(e.db, testIssuer) + + // 取得一組含 offline_access 的權杖。 + _, code := consentAllow(t, e, authorizeQuery(e.app, "openid profile offline_access", "", "", testChallenge)) + rec := exchangeCode(h, code, e.app.RedirectURIs[0], e.app.ClientID, e.secret, testVerifier, true) + if rec.Code != http.StatusOK { + t.Fatalf("兌換失敗: %s", rec.Body.String()) + } + var first tokenBody + json.Unmarshal(rec.Body.Bytes(), &first) + + refresh := func(token, scope string) *httptest.ResponseRecorder { + form := url.Values{"grant_type": {"refresh_token"}, "refresh_token": {token}} + if scope != "" { + form.Set("scope", scope) + } + return postToken(h, form, e.app.ClientID, e.secret) + } + + t.Run("輪替發新權杖組", func(t *testing.T) { + rec := refresh(first.RefreshToken, "") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + var second tokenBody + json.Unmarshal(rec.Body.Bytes(), &second) + if second.AccessToken == "" || second.RefreshToken == "" || second.RefreshToken == first.RefreshToken { + t.Fatalf("應核發新的 access 與 refresh token: %+v", second) + } + if second.Scope != "offline_access openid profile" { + t.Errorf("scope 應沿用原授權: %q", second.Scope) + } + if second.IDToken == "" { + t.Error("原 scope 含 openid 應續發 id_token") + } + + // 舊 token 重用:invalid_grant,且整鏈(含新 token)撤銷。 + rec = refresh(first.RefreshToken, "") + if got := decodeTokenError(t, rec).Error; got != "invalid_grant" { + t.Fatalf("重用 error = %q, body = %s", got, rec.Body.String()) + } + rec = refresh(second.RefreshToken, "") + if rec.Code != http.StatusBadRequest { + t.Fatalf("重用偵測後整鏈應撤銷(新 token 亦不可用): %s", rec.Body.String()) + } + }) + + t.Run("scope 僅可縮小", func(t *testing.T) { + // 取一組原授權為「openid profile offline_access」的鏈。 + _, code := consentAllow(t, e, authorizeQuery(e.app, "openid profile offline_access", "", "", "")) + rec := postToken(h, url.Values{"grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {e.app.RedirectURIs[0]}}, e.app.ClientID, e.secret) + if rec.Code != http.StatusOK { + t.Fatalf("兌換失敗: %s", rec.Body.String()) + } + var body tokenBody + json.Unmarshal(rec.Body.Bytes(), &body) + + // 縮小為不含 profile:成功,新鏈的授權範圍即縮小後的值。 + rec = refresh(body.RefreshToken, "openid offline_access") + if rec.Code != http.StatusOK { + t.Fatalf("縮小 scope 應成功: %s", rec.Body.String()) + } + var narrowed tokenBody + json.Unmarshal(rec.Body.Bytes(), &narrowed) + if narrowed.Scope != "offline_access openid" { + t.Errorf("縮小後 scope = %q", narrowed.Scope) + } + + // 對縮小後的鏈再請求原範圍(含 profile)即為擴大:invalid_scope。 + rec = refresh(narrowed.RefreshToken, "openid profile offline_access") + if got := decodeTokenError(t, rec).Error; got != "invalid_scope" { + t.Fatalf("擴大 scope error = %q, want invalid_scope, body = %s", got, rec.Body.String()) + } + }) + + t.Run("過期 refresh token 回 invalid_grant", func(t *testing.T) { + _, code := consentAllow(t, e, authorizeQuery(e.app, "openid offline_access", "", "", "")) + rec := postToken(h, url.Values{"grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {e.app.RedirectURIs[0]}}, e.app.ClientID, e.secret) + var body tokenBody + json.Unmarshal(rec.Body.Bytes(), &body) + // 直接把最新一筆 refresh token 的效期改為過去。 + if err := e.db.Model(&oidc.RefreshToken{}). + Where("id = (SELECT MAX(id) FROM refresh_tokens)"). + Update("expires_at", time.Now().Add(-time.Minute)).Error; err != nil { + t.Fatal(err) + } + rec = refresh(body.RefreshToken, "") + if got := decodeTokenError(t, rec).Error; got != "invalid_grant" { + t.Fatalf("error = %q, want invalid_grant, body = %s", got, rec.Body.String()) + } + }) + + t.Run("未啟用 refresh grant 的應用回 unauthorized_client", func(t *testing.T) { + rec := postToken(h, url.Values{"grant_type": {"refresh_token"}, "refresh_token": {"x"}}, e.pub.ClientID, "") + if got := decodeTokenError(t, rec).Error; got != "unauthorized_client" { + t.Fatalf("error = %q, want unauthorized_client", got) + } + }) +} + +// 空資料庫時 token 端點仍應正常拒絕(不 panic)。 +func TestTokenHandlerEmptyDB(t *testing.T) { + db := testdb.New(t) + rec := postToken(oidc.TokenHandler(db, testIssuer), url.Values{"grant_type": {"authorization_code"}, "code": {"x"}, "client_id": {"nobody"}, "client_secret": {"s"}}, "", "") + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } +} diff --git a/internal/oidc/userinfo.go b/internal/oidc/userinfo.go new file mode 100644 index 0000000..2f4cc44 --- /dev/null +++ b/internal/oidc/userinfo.go @@ -0,0 +1,115 @@ +package oidc + +import ( + "errors" + "log" + "net/http" + "strconv" + "strings" + + "gorm.io/gorm" + + "alterminal/internal/auth" +) + +// UserInfoHandler 處理 GET/POST /userinfo(OIDC Core §5.3):以 Bearer +// Access Token 取得已授權的使用者 claims。token 取自 Authorization +// 標頭(RFC 6750 §2.1),POST 另接受表單的 access_token 欄位(§2.2)。 +func UserInfoHandler(db *gorm.DB, issuer string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet, http.MethodPost: + default: + w.Header().Set("Allow", "GET, POST") + writeBearerError(w, http.StatusMethodNotAllowed, "", "僅支援 GET 與 POST") + return + } + + token := bearerToken(r) + if token == "" { + writeBearerError(w, http.StatusUnauthorized, "", "缺少 Access Token") + return + } + if r.Method == http.MethodPost { + if err := r.ParseForm(); err != nil { + writeBearerError(w, http.StatusBadRequest, "invalid_request", "無法解析表單內容") + return + } + if t := r.PostFormValue("access_token"); t != "" { + token = t + } + } + + claims, err := VerifyAccessToken(db, issuer, token) + if err != nil { + if !errors.Is(err, ErrInvalidToken) { + log.Printf("userinfo: %v", err) + } + writeBearerError(w, http.StatusUnauthorized, "invalid_token", "Access Token 無效") + return + } + userID, err := strconv.ParseUint(claims.Sub, 10, 64) + if err != nil { + writeBearerError(w, http.StatusUnauthorized, "invalid_token", "Access Token 無效") + return + } + var u auth.User + if err := db.First(&u, userID).Error; err != nil { + log.Printf("userinfo: 查詢使用者 %d: %v", userID, err) + writeBearerError(w, http.StatusUnauthorized, "invalid_token", "Access Token 無效") + return + } + + // claims 依授權 scope 決定(OIDC Core §5.4):sub 恆有;profile + // 加 name 與 preferred_username;email 加 email 與 + // email_verified。Access Token 未含 openid scope(非授權碼流程 + // 核發)者不得存取(RFC 6750 insufficient_scope)。 + if !scopeHas(claims.Scope, "openid") { + writeBearerError(w, http.StatusForbidden, "insufficient_scope", "缺少 openid scope") + return + } + out := struct { + Sub string `json:"sub"` + Name string `json:"name,omitempty"` + PreferredUsername string `json:"preferred_username,omitempty"` + Email string `json:"email,omitempty"` + EmailVerified *bool `json:"email_verified,omitempty"` + }{Sub: claims.Sub} + if scopeHas(claims.Scope, "profile") { + out.Name = u.Name + out.PreferredUsername = u.Username + } + if scopeHas(claims.Scope, "email") { + out.Email = u.Email + verified := u.EmailVerified + out.EmailVerified = &verified + } + auth.WriteJSON(w, http.StatusOK, out) + } +} + +// bearerToken 剖析 Authorization: Bearer 標頭(RFC 6750 §2.1)。 +func bearerToken(r *http.Request) string { + h := r.Header.Get("Authorization") + const scheme = "bearer " + if len(h) < len(scheme) || !strings.EqualFold(h[:len(scheme)], scheme) { + return "" + } + return strings.TrimSpace(h[len(scheme):]) +} + +// writeBearerError 輸出 /userinfo 的 Bearer 錯誤,並以 +// WWW-Authenticate 標頭回報錯誤細節(RFC 6750 §3)。 +func writeBearerError(w http.ResponseWriter, status int, code, description string) { + if status != http.StatusBadRequest { + challenge := `Bearer realm="alterminal"` + if code != "" { + challenge += `, error="` + code + `"` + if description != "" { + challenge += `, error_description="` + description + `"` + } + } + w.Header().Set("WWW-Authenticate", challenge) + } + auth.WriteError(w, status, description) +} diff --git a/internal/oidc/userinfo_test.go b/internal/oidc/userinfo_test.go new file mode 100644 index 0000000..61a8dfb --- /dev/null +++ b/internal/oidc/userinfo_test.go @@ -0,0 +1,128 @@ +// 外部測試套件:見 jwks_test.go 開頭說明。 +package oidc_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "alterminal/internal/oidc" +) + +// getUserinfo 以 Bearer token 呼叫 /userinfo。 +func getUserinfo(h http.HandlerFunc, token string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, "/userinfo", nil) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + rec := httptest.NewRecorder() + h(rec, req) + return rec +} + +// obtainAccessToken 走完授權碼流程(PKCE)並回傳 access token。 +func obtainAccessToken(t *testing.T, e *testEnv, scope string) string { + t.Helper() + _, code := consentAllow(t, e, authorizeQuery(e.app, scope, "", "", testChallenge)) + form := url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "redirect_uri": {e.app.RedirectURIs[0]}, + "code_verifier": {testVerifier}, + } + rec := postToken(oidc.TokenHandler(e.db, testIssuer), form, e.app.ClientID, e.secret) + if rec.Code != http.StatusOK { + t.Fatalf("兌換失敗: %s", rec.Body.String()) + } + var body tokenBody + json.Unmarshal(rec.Body.Bytes(), &body) + return body.AccessToken +} + +// 有效 token 回依 scope 的 claims(OIDC Core §5.4)。 +func TestUserInfoClaims(t *testing.T) { + e := newTestEnv(t) + h := oidc.UserInfoHandler(e.db, testIssuer) + + t.Run("profile 與 email scope", func(t *testing.T) { + token := obtainAccessToken(t, e, "openid profile email") + rec := getUserinfo(h, token) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + var u userInfoBody + if err := json.Unmarshal(rec.Body.Bytes(), &u); err != nil { + t.Fatal("解析回應: ", err) + } + if u.Sub != subjectOf(e.user.ID) { + t.Errorf("sub = %q, want %q", u.Sub, subjectOf(e.user.ID)) + } + if u.Name != e.user.Name || u.PreferredUsername != e.user.Username { + t.Errorf("profile claims = %q/%q", u.Name, u.PreferredUsername) + } + if u.Email != e.user.Email || u.EmailVerified == nil || !*u.EmailVerified { + t.Errorf("email claims = %q/%v", u.Email, u.EmailVerified) + } + }) + + t.Run("僅 openid 不含個人資料 claims", func(t *testing.T) { + token := obtainAccessToken(t, e, "openid") + rec := getUserinfo(h, token) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d", rec.Code) + } + body := rec.Body.String() + if strings.Contains(body, `"email"`) || strings.Contains(body, `"name"`) { + t.Errorf("未授權的 scope 不應輸出對應 claim: %s", body) + } + }) +} + +// token 缺失、無效或授權不足時的錯誤回應(RFC 6750 §3)。 +func TestUserInfoErrors(t *testing.T) { + e := newTestEnv(t) + h := oidc.UserInfoHandler(e.db, testIssuer) + + t.Run("缺少 token 回 401 與 WWW-Authenticate", func(t *testing.T) { + rec := getUserinfo(h, "") + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + if wa := rec.Header().Get("WWW-Authenticate"); !strings.HasPrefix(wa, "Bearer") { + t.Errorf("WWW-Authenticate = %q", wa) + } + }) + + t.Run("無效 token 回 401 invalid_token", func(t *testing.T) { + rec := getUserinfo(h, "not-a-token") + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + if wa := rec.Header().Get("WWW-Authenticate"); !strings.Contains(wa, `error="invalid_token"`) { + t.Errorf("WWW-Authenticate = %q", wa) + } + }) + + t.Run("scope 無 openid 回 403", func(t *testing.T) { + // 手造僅 profile scope 的 token(正式流程必含 openid,此處模擬 + // 其他來源的 token)。 + token := forgeJWT(t, e.key, + map[string]string{"alg": "RS256", "kid": e.key.Kid, "typ": "JWT"}, + oidc.AccessTokenClaims{ + Iss: testIssuer, Sub: subjectOf(e.user.ID), Aud: e.app.ClientID, + Exp: time.Now().Add(time.Hour).Unix(), Iat: time.Now().Unix(), + Scope: "profile", ClientID: e.app.ClientID, + }) + rec := getUserinfo(h, token) + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403, body = %s", rec.Code, rec.Body.String()) + } + if wa := rec.Header().Get("WWW-Authenticate"); !strings.Contains(wa, "insufficient_scope") { + t.Errorf("WWW-Authenticate = %q", wa) + } + }) +} diff --git a/db.go b/internal/store/db.go similarity index 51% rename from db.go rename to internal/store/db.go index e033c9b..fd3ce05 100644 --- a/db.go +++ b/internal/store/db.go @@ -1,4 +1,4 @@ -package main +package store import ( "fmt" @@ -7,17 +7,20 @@ import ( "gorm.io/driver/postgres" "gorm.io/gorm" + "alterminal/internal/application" + "alterminal/internal/auth" "alterminal/internal/jwk" + "alterminal/internal/oidc" ) -func openDB() (*gorm.DB, error) { +func Open() (*gorm.DB, error) { dsn := fmt.Sprintf( "host=%s port=%s user=%s password=%s dbname=%s sslmode=disable TimeZone=UTC", - envOr("DB_HOST", "localhost"), - envOr("DB_PORT", "5432"), - envOr("DB_USER", "postgres"), - envOr("DB_PASSWORD", "postgres"), - envOr("DB_NAME", "alterminal"), + EnvOr("DB_HOST", "localhost"), + EnvOr("DB_PORT", "5432"), + EnvOr("DB_USER", "postgres"), + EnvOr("DB_PASSWORD", "postgres"), + EnvOr("DB_NAME", "alterminal"), ) // TranslateError 讓唯一鍵違規轉為 gorm.ErrDuplicatedKey,create-account 等指令以此辨識重複。 @@ -26,13 +29,16 @@ func openDB() (*gorm.DB, error) { return nil, err } - if err := db.AutoMigrate(&User{}, &Session{}, &jwk.SigningKey{}, &Application{}); err != nil { + if err := db.AutoMigrate( + &auth.User{}, &auth.Session{}, &jwk.SigningKey{}, &application.Application{}, + &oidc.AuthorizationCode{}, &oidc.RefreshToken{}, &oidc.Consent{}, + ); err != nil { return nil, fmt.Errorf("auto migrate: %w", err) } return db, nil } -func envOr(key, fallback string) string { +func EnvOr(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } diff --git a/internal/testdb/testdb.go b/internal/testdb/testdb.go new file mode 100644 index 0000000..9ecd44a --- /dev/null +++ b/internal/testdb/testdb.go @@ -0,0 +1,60 @@ +// Package testdb 準備整合測試專用的測試資料庫(與開發資料庫 +// alterminal 隔離),供需要完整資料表(users、sessions、 +// signing_keys、applications、authorization_codes、refresh_tokens、 +// consents)的套件測試使用。go test 以套件為單位並行執行,各套件 +// 取得各自的資料庫(alterminal_test_<套件名>),避免並行測試相互 +// TRUNCATE。oidc 套件的測試須以外部測試套件(package oidc_test) +// 匯入本套件——store 為遷移而匯入 oidc 模型,內部測試套件匯入本 +// 套件會形成循環。 +package testdb + +import ( + "fmt" + "path/filepath" + "runtime" + "strings" + "testing" + + "gorm.io/driver/postgres" + "gorm.io/gorm" + + "alterminal/internal/store" +) + +// New 連線本機 PostgreSQL,建立(若不存在)並遷移呼叫方套件專屬的 +// 測試資料庫、清空所有資料表後回傳連線;本機 PostgreSQL 不可用時 +// 跳過測試。 +func New(t *testing.T) *gorm.DB { + t.Helper() + // 以呼叫方(測試檔)所在目錄為套件識別,各套件一個資料庫。 + _, file, _, ok := runtime.Caller(1) + dbName := "alterminal_test" + if ok { + dbName += "_" + filepath.Base(filepath.Dir(file)) + } + admin, err := gorm.Open(postgres.Open(fmt.Sprintf( + "host=%s port=%s user=%s password=%s dbname=postgres sslmode=disable TimeZone=UTC", + store.EnvOr("DB_HOST", "localhost"), store.EnvOr("DB_PORT", "5432"), + store.EnvOr("DB_USER", "postgres"), store.EnvOr("DB_PASSWORD", "postgres"), + )), &gorm.Config{}) + if err != nil { + t.Skipf("本機 PostgreSQL 不可用,跳過整合測試:%v", err) + } + if err := admin.Exec(fmt.Sprintf("CREATE DATABASE %s", dbName)).Error; err != nil && !strings.Contains(err.Error(), "already exists") { + t.Skipf("無法建立測試資料庫:%v", err) + } + t.Setenv("DB_NAME", dbName) + db, err := store.Open() + if err != nil { + t.Skipf("連線測試資料庫失敗:%v", err) + } + t.Cleanup(func() { + if sqlDB, err := db.DB(); err == nil { + sqlDB.Close() + } + }) + if err := db.Exec("TRUNCATE users, sessions, signing_keys, applications, authorization_codes, refresh_tokens, consents RESTART IDENTITY CASCADE").Error; err != nil { + t.Fatalf("清空測試資料失敗:%v", err) + } + return db +} diff --git a/main.go b/main.go deleted file mode 100644 index c6d278e..0000000 --- a/main.go +++ /dev/null @@ -1,71 +0,0 @@ -package main - -import ( - "log" - "net/http" - "os" - - "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" -) - -func main() { - if len(os.Args) > 1 { - runCommand(os.Args[1:]) - return - } - - db, err := openDB() - if err != nil { - log.Fatal("database: ", err) - } - - r := chi.NewRouter() - - r.Use(middleware.Logger) - r.Use(middleware.Recoverer) - - r.Get("/", accountPageHandler(db)) - - r.Get("/users/{name}", func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("Hello, " + chi.URLParam(r, "name") + "!")) - }) - - r.Get("/login", loginPageHandler(db)) - r.Post("/login", loginHandler(db)) - r.Post("/logout", logoutHandler(db)) - - r.Get("/admin/keys", adminKeysPageHandler(db)) - r.Post("/admin/keys", adminKeysCreateHandler(db)) - r.Post("/admin/keys/{id}/retire", adminKeysRetireHandler(db)) - - r.Get("/admin/applications", adminApplicationsPageHandler(db)) - r.Get("/admin/applications/new", adminApplicationNewPageHandler(db)) - r.Post("/admin/applications/new", adminApplicationsCreateHandler(db)) - r.Post("/admin/applications/{id}/secret", adminApplicationsRotateSecretHandler(db)) - r.Post("/admin/applications/{id}/delete", adminApplicationsDeleteHandler(db)) - - r.Handle("/static/*", staticHandler()) - - r.Get("/health", func(w http.ResponseWriter, r *http.Request) { - sqlDB, err := db.DB() - if err != nil { - w.WriteHeader(http.StatusServiceUnavailable) - w.Write([]byte("db: " + err.Error())) - return - } - if err := sqlDB.Ping(); err != nil { - w.WriteHeader(http.StatusServiceUnavailable) - w.Write([]byte("db: " + err.Error())) - return - } - w.Write([]byte("ok")) - }) - - // 未匹配任何路由的路徑(不分方法)輸出自訂 404 頁。 - r.NotFound(notFoundHandler) - - if err := http.ListenAndServe(":8080", r); err != nil { - log.Fatal(err) - } -}