diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..cc013ab --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,71 @@ +# 發佈流程:push `v*` tag 或手動觸發(workflow_dispatch)時,交叉編譯各平台 +# 二進制並上傳到 Gitea Release。 +# +# token 一律使用倉庫 secret `ACTION_TOKEN`(自架 runner 不保證有自動簽發的 +# github.token,故 checkout 與發佈皆明確指定),需具備本倉庫 contents 讀寫權限。 +name: Release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + tag: + description: '要發佈的既有 tag(例如 v1.2.3)' + required: true + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + # tag push 與手動觸發都 checkout 指定 tag 的 commit + ref: ${{ inputs.tag || github.ref_name }} + token: ${{ secrets.ACTION_TOKEN }} + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + # 自架 runner 的 cache server 未必可用,關閉以免誤失敗 + cache: false + + - name: Build + env: + RELEASE_TAG: ${{ inputs.tag || github.ref_name }} + run: | + set -eu + echo "RELEASE_TAG=$RELEASE_TAG" >> "$GITHUB_ENV" + VERSION="${RELEASE_TAG#v}" + mkdir -p dist + # chi/pgx 皆為純 Go,關閉 CGO 交叉編譯出靜態執行檔;模板與 + # Tailwind 產物已提交且以 go:embed 內嵌,不需 Node。 + for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do + os=${target%/*} + arch=${target#*/} + staging="dist/alterminal_${VERSION}_${os}_${arch}" + mkdir -p "$staging" + CGO_ENABLED=0 GOOS=$os GOARCH=$arch \ + go build -trimpath -ldflags='-s -w' -o "$staging/alterminal" ./cmd/alterminal + tar -C "$staging" -czf "$staging.tar.gz" alterminal + rm -rf "$staging" + done + (cd dist && sha256sum *.tar.gz > checksums.txt) + ls -l dist + + - name: Publish to release + uses: https://gitea.com/actions/gitea-release-action@v1 + with: + token: ${{ secrets.ACTION_TOKEN }} + tag_name: ${{ env.RELEASE_TAG }} + name: ${{ env.RELEASE_TAG }} + files: dist/* + body: | + ### 下載 + + 依平台下載對應的 `alterminal_<版本>__.tar.gz`,並以 `checksums.txt` 驗證 SHA-256。 + + 為單一執行檔(模板與樣式已內嵌),設定 `DB_*` 環境變數後直接執行 `./alterminal`,部署細節見 README。 diff --git a/README.md b/README.md index 60a89dc..4640689 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 核心(Discovery、Authorization Code Flow+PKCE、Token 端點、UserInfo、Refresh Token 輪替),其餘功能依下方 Roadmap 推進。 +> **狀態:開發中。** 目前完成專案骨架(HTTP 服務、資料庫連線、健康檢查)、使用者帳號(CLI 建帳與重設密碼、argon2id 密碼雜湊、網頁自助更新密碼)、登入/登出(HTML 登入頁+JSON API、Session Cookie)、應用程式與金鑰管理頁,以及 OIDC 核心(Discovery、Authorization Code Flow+PKCE、Token 端點、UserInfo、Refresh Token 輪替、RP-Initiated Logout),其餘功能依下方 Roadmap 推進。 ## 特色 @@ -37,12 +37,14 @@ | `/login` | POST | 使用者登入(JSON API 與 HTML 表單提交;表單支援 `next` 返回路徑) | ✅ 已完成 | | `/login` | GET | 使用者登入頁(HTML 表單,供 `/authorize` 導向並以 `next` 攜回授權請求) | ✅ 已完成 | | `/logout` | POST | 使用者登出(HTML 表單與 JSON API,冪等) | ✅ 已完成 | +| `/logout` | GET | RP-Initiated Logout(`id_token_hint` 驗證、無 hint 或不符目前 Session 時顯示確認頁;`post_logout_redirect_uri` 精確比對註冊值後重導並回填 `state`) | ✅ 已完成 | +| `/password` | GET | 更新密碼頁(登入者自助變更;未登入導向 `/login?next=/password`) | ✅ 已完成 | +| `/password` | POST | 更新密碼(驗證目前密碼;表單+CSRF 與 JSON API。成功後於交易內撤銷全部 Session,再輪替目前瀏覽器的 Session——其他裝置立即登出) | ✅ 已完成 | | `/static/*` | GET | 靜態檔(Tailwind 建置輸出的 CSS,`go:embed` 內嵌) | ✅ 已完成 | | `/authorize` | GET | 授權端點(Authorization Code Flow+PKCE;未登入導向 `/login?next=...`,首次授權顯示同意頁) | ✅ 已完成 | | `/authorize` | POST | 同意頁決定(同意記錄於 `consents`,同範圍之後靜默通過;拒絕回 `access_denied`) | ✅ 已完成 | | `/token` | POST | 權杖端點(`authorization_code`+PKCE 與 `refresh_token` 兩種 grant;Basic/POST client 認證) | ✅ 已完成 | | `/userinfo` | GET/POST | 以 Bearer Access Token 取得使用者 claims(依授權 scope) | ✅ 已完成 | -| `/logout` | GET | RP-Initiated Logout(OIDC:`id_token_hint`、`post_logout_redirect_uri` 等參數驗證) | 🚧 規劃中 | | `/admin/applications` | GET | 應用程式管理頁(RP 註冊列表;僅 admin) | ✅ 已完成 | | `/admin/applications/new` | GET | 註冊新應用程式頁(獨立表單頁;僅 admin) | ✅ 已完成 | | `/admin/applications/new` | POST | 註冊應用程式(明文 client_secret 僅於本次回應顯示一次,表單+CSRF) | ✅ 已完成 | @@ -204,7 +206,51 @@ curl -i -X POST http://localhost:8080/logout \ - **JSON 流程**:成功回 `204`,無回應內容 - **表單流程**(瀏覽器):需通過 double-submit CSRF 驗證(與登入表單同一機制),成功後 `303` 導向 `/login`(PRG);CSRF 不符回 `403` 並重繪目前狀態頁 - 資料庫刪除失敗僅記錄,仍完成 Cookie 清除(Session 最遲於效期到期自動失效) -- 跨應用程式單一登出(Front-/Back-Channel Logout)與 RP-Initiated Logout(`GET /logout`,含 OIDC 參數驗證)列於 Roadmap +- 跨應用程式單一登出(Front-/Back-Channel Logout)列於 Roadmap + +### 更新密碼 + +`GET/POST /password` 為登入者自助更新密碼(`/` 帳號首頁有入口連結),須持有效 Session;未登入時表單流程導向 `/login?next=/password`,JSON 流程回 `401`。與 `/login`、`/logout` 相同依 `Content-Type` 分流(JSON 與表單+CSRF): + +```bash +curl -i -X POST http://localhost:8080/password \ + -H 'Content-Type: application/json' \ + -b 'alterminal_session=' \ + -d '{"current_password":"sup3r-secret","new_password":"n3w-secret!"}' +# => 204 No Content,Set-Cookie 下發輪替後的新 Session +``` + +- 驗證目前密碼無誤後,以 argon2id 重新雜湊新密碼(新 salt),密碼更新與撤銷該帳號**全部 Session** 於同一資料庫交易內完成(與 CLI `update-password` 共用邏輯) +- 交易成功後為目前瀏覽器重建 Session(輪替 Session ID,避免 fixation)——其他裝置立即登出、本瀏覽器保持登入 +- 表單流程成功後 `303` 導回 `/password?saved=1` 顯示成功訊息(PRG);驗證失敗以對應狀態碼重繪表單,密碼欄位不回填 + +錯誤回應 JSON 流程為 `{"error": "..."}`(表單流程顯示於頁面): + +| 狀態碼 | 情境 | +| --- | --- | +| `400` | 欄位缺漏、新密碼短於 8 字元、表單兩次輸入的新密碼不一致、新密碼與目前密碼相同 | +| `401` | 未登入(JSON 流程)或目前的密碼錯誤 | +| `403` | 表單 CSRF 驗證失敗 | +| `415` | `Content-Type` 非 `application/json` 或表單 | + +### RP-Initiated Logout + +`GET /logout` 實作 [OpenID Connect RP-Initiated Logout 1.0](https://openid.net/specs/openid-connect-rpinitiated-1_0.html)(`POST` 亦支援,§2 要求),端點發佈於 Discovery 的 `end_session_endpoint`。RP 將使用者導向本端點並攜帶參數: + +| 參數 | 說明 | +| --- | --- | +| `id_token_hint` | 先前取得的 ID Token。本服務驗證簽章與 `iss`(不檢查 `exp`——§2 要求 session 存在或近期存在時接受過期值),其 `aud` 用於辨識發起登出的 client;`sub` 用於比對目前 Session | +| `post_logout_redirect_uri` | 登出後返回 URI,須與該 client 註冊的 post_logout_redirect_uris **精確比對**(§3:未註冊值 MUST NOT 重導),於管理頁「登出後返回 URI」欄位註冊 | +| `client_id` | 無 `id_token_hint` 時辨識 client 用(供驗證返回 URI);與 hint 併用時須與其 `aud` 相符(§2) | +| `state` | 原值回填於重導查詢字串(§2) | + +行為要點: + +- **確認頁**(§2 MUST):未提供 `id_token_hint`、或 hint 的 `sub` 不屬於目前 Session 時,先顯示「您確定要登出嗎?」確認頁(CSRF 保護),避免跨站偽造的強迫登出;hint 屬於目前 Session 時直接登出。本服務 ID token 不含 `sid` claim,以 `sub` 比對 Session 使用者近似判斷 +- **重導**:通過驗證時 `303` 至 `post_logout_redirect_uri`(附 `state`);指定但未通過註冊比對時**不重導**(§3),登出仍完成並顯示已登出頁與說明;未指定時 `303` 導向 `/login`(與本站既有登出一致) +- **錯誤**(§4):hint 無效、`client_id` 與 `aud` 不符等硬錯誤回 `400`,不執行登出也不重導 +- 未登入時造訪為冪等操作——仍清除 Cookie 並依驗證結果重導 +- 不帶 RP 參數的 `POST /logout`(側欄登出表單、JSON API)行為不變,由 `auth.LogoutHandler` 處理 ### 登入頁面 @@ -298,7 +344,7 @@ Access Token 採用自包含的 JWT,不落庫儲存;其餘狀態儲存於 Po | 資料表 | 說明 | 狀態 | | --- | --- | --- | | `users` | 使用者帳號(帳號、Email、密碼雜湊) | ✅ 已完成(含 argon2id 密碼雜湊) | -| `applications` | 已註冊的 RP 應用程式(client_id、client secret 雜湊、redirect URIs、grant types、scope、confidential/public) | 🚧 模型與管理頁已完成(`Application`:argon2id secret 雜湊、redirect URI 格式驗證;`/admin/applications` 註冊/編輯/輪替/刪除),註冊 API 規劃中 | +| `applications` | 已註冊的 RP 應用程式(client_id、client secret 雜湊、redirect URIs、登出後返回 URIs、grant types、scope、confidential/public) | 🚧 模型與管理頁已完成(`Application`:argon2id secret 雜湊、redirect URI 格式驗證;`/admin/applications` 註冊/編輯/輪替/刪除),註冊 API 規劃中 | | `sessions` | 使用者瀏覽器 Session(SSO 核心,HttpOnly Cookie,效期 24 小時) | ✅ 已完成 | | `authorization_codes` | 授權碼(SHA-256 雜湊儲存、一次性、效期 5 分鐘、凍結授權當下的 redirect URI/scope/nonce/PKCE challenge/auth_time) | ✅ 已完成 | | `refresh_tokens` | Refresh Token(SHA-256 雜湊儲存、效期 30 天、兌換即輪替、重用時整鏈撤銷) | ✅ 已完成 | @@ -316,7 +362,7 @@ Access Token 採用自包含的 JWT,不落庫儲存;其餘狀態儲存於 Po - [x] Token 端點:Access Token(JWT/RS256)、ID Token、Refresh Token 簽發與驗證 - [x] UserInfo 端點(`/userinfo`) - [x] Refresh Token 輪替與撤銷(重用偵測、整鏈撤銷) -- [ ] RP-Initiated Logout(`/logout`) +- [x] RP-Initiated Logout(`/logout`:`id_token_hint` 驗證、確認頁、`post_logout_redirect_uri` 註冊比對與 `state` 回填) - [ ] Client Credentials Grant - [ ] Front-Channel / Back-Channel Logout(跨 RP 單一登出) - [ ] 管理 API 與簡易管理介面 diff --git a/internal/admin/adminapplications.go b/internal/admin/adminapplications.go index 1e4c0ce..ac5afa4 100644 --- a/internal/admin/adminapplications.go +++ b/internal/admin/adminapplications.go @@ -17,15 +17,16 @@ import ( // adminApplicationRow 為應用程式管理頁表格的單列視圖。 type adminApplicationRow struct { - ID uint - ClientID string - Name string - Type string // confidential / public - RedirectURIs string // 以換行分隔(模板以 whitespace-pre-line 呈現) - GrantTypes string // 以頓號分隔 - Scope string - CreatedAt string // 本地時間顯示 - Confidential bool // 機密式才可輪替 client secret + ID uint + ClientID string + Name string + Type string // confidential / public + RedirectURIs string // 以換行分隔(模板以 whitespace-pre-line 呈現) + PostLogoutRedirectURIs string // 同上;空時模板顯示 — + GrantTypes string // 以頓號分隔 + Scope string + CreatedAt string // 本地時間顯示 + Confidential bool // 機密式才可輪替 client secret } // newAdminApplicationRows 將應用程式模型轉為表格視圖。純函式,便於單元測試。 @@ -37,15 +38,16 @@ func newAdminApplicationRows(apps []application.Application) []adminApplicationR grants[i] = string(g) } rows = append(rows, adminApplicationRow{ - ID: a.ID, - ClientID: a.ClientID, - Name: a.Name, - Type: string(a.Type), - RedirectURIs: strings.Join(a.RedirectURIs, "\n"), - GrantTypes: strings.Join(grants, "、"), - Scope: a.Scope, - CreatedAt: a.CreatedAt.Local().Format("2006-01-02 15:04:05 MST"), - Confidential: !a.IsPublic(), + ID: a.ID, + ClientID: a.ClientID, + Name: a.Name, + Type: string(a.Type), + RedirectURIs: strings.Join(a.RedirectURIs, "\n"), + PostLogoutRedirectURIs: strings.Join(a.PostLogoutRedirectURIs, "\n"), + GrantTypes: strings.Join(grants, "、"), + Scope: a.Scope, + CreatedAt: a.CreatedAt.Local().Format("2006-01-02 15:04:05 MST"), + Confidential: !a.IsPublic(), }) } return rows @@ -54,13 +56,14 @@ func newAdminApplicationRows(apps []application.Application) []adminApplicationR // applicationForm 為註冊表單的視圖狀態:驗證失敗重繪時保留使用者輸入 // (含核取方塊),初次顯示(GET)採 newApplicationForm 的預設值。 type applicationForm struct { - Name string - Type string // confidential / public - RedirectURIs string // textarea 原始內容(每行一個 URI) - Scope string // 留空時使用預設 - GrantAuthCode bool - GrantRefresh bool - GrantClientCred bool + Name string + Type string // confidential / public + RedirectURIs string // textarea 原始內容(每行一個 URI) + PostLogoutRedirectURIs string // 同上(選填) + Scope string // 留空時使用預設 + GrantAuthCode bool + GrantRefresh bool + GrantClientCred bool } // newApplicationForm 回傳註冊表單的預設狀態:機密式、勾選授權碼流程。 @@ -72,10 +75,11 @@ func newApplicationForm() applicationForm { // 其餘一律回復為 confidential;grant type 僅接受已知值。 func applicationFormFromPost(r *http.Request) applicationForm { f := applicationForm{ - Name: r.PostFormValue("name"), - Type: r.PostFormValue("type"), - RedirectURIs: r.PostFormValue("redirect_uris"), - Scope: r.PostFormValue("scope"), + Name: r.PostFormValue("name"), + Type: r.PostFormValue("type"), + RedirectURIs: r.PostFormValue("redirect_uris"), + PostLogoutRedirectURIs: r.PostFormValue("post_logout_redirect_uris"), + Scope: r.PostFormValue("scope"), } if f.Type != string(application.ClientPublic) { f.Type = string(application.ClientConfidential) @@ -97,10 +101,11 @@ func applicationFormFromPost(r *http.Request) applicationForm { // 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, + Name: a.Name, + Type: string(a.Type), + RedirectURIs: strings.Join(a.RedirectURIs, "\n"), + PostLogoutRedirectURIs: strings.Join(a.PostLogoutRedirectURIs, "\n"), + Scope: a.Scope, } for _, g := range a.GrantTypes { switch g { @@ -115,11 +120,21 @@ func applicationFormFromApp(a *application.Application) applicationForm { return f } -// redirectURIList 解析 textarea 內容:每行一個 URI,去首尾空白(含瀏覽器 -// 送出的 \r)後略過空行。 +// redirectURIList 解析 redirect URI textarea 內容:每行一個 URI,去首尾 +// 空白(含瀏覽器送出的 \r)後略過空行。 func (f applicationForm) redirectURIList() []string { + return uriLines(f.RedirectURIs) +} + +// postLogoutURIList 解析登出後返回 URI textarea 內容(同 redirectURIList)。 +func (f applicationForm) postLogoutURIList() []string { + return uriLines(f.PostLogoutRedirectURIs) +} + +// uriLines 將 textarea 內容拆為非空行清單。 +func uriLines(raw string) []string { var uris []string - for _, line := range strings.Split(f.RedirectURIs, "\n") { + for _, line := range strings.Split(raw, "\n") { if u := strings.TrimSpace(line); u != "" { uris = append(uris, u) } @@ -322,7 +337,7 @@ func ApplicationsCreateHandler(db *gorm.DB) http.HandlerFunc { return } form := applicationFormFromPost(r) - app, secret, err := application.NewApplication(form.Name, application.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, form.postLogoutURIList()...) if err != nil { renderAdminApplicationNewPage(w, r, http.StatusBadRequest, s, err.Error(), form, nil) return @@ -362,7 +377,7 @@ func ApplicationUpdateHandler(db *gorm.DB) http.HandlerFunc { return } form := applicationFormFromPost(r) - if err := app.Update(form.Name, application.ClientType(form.Type), form.redirectURIList(), form.grantTypeList(), form.Scope); err != nil { + if err := app.Update(form.Name, application.ClientType(form.Type), form.redirectURIList(), form.grantTypeList(), form.Scope, form.postLogoutURIList()...); err != nil { renderAdminApplicationEditPage(w, r, http.StatusBadRequest, s, err.Error(), "", app, form) return } diff --git a/internal/application/application.go b/internal/application/application.go index 143042e..25f6ea0 100644 --- a/internal/application/application.go +++ b/internal/application/application.go @@ -99,16 +99,17 @@ func (g GrantTypes) Contains(gt GrantType) bool { // 與使用者密碼採同一套 argon2id 雜湊儲存,明文只在建立/輪替當下回傳 // 一次;公開式 Client 不持有 secret。 type Application struct { - ID uint `gorm:"primaryKey"` - ClientID string `gorm:"uniqueIndex;size:22;not null"` // 16 bytes 亂數的 base64url(公開識別碼,128 bits 熵已足夠) - Name string `gorm:"size:255;not null"` // 顯示名稱(授權頁顯示「以 ○○ 登入」等) - Type ClientType `gorm:"size:16;not null"` // confidential 或 public - ClientSecretHash string `gorm:"size:255;not null"` // argon2id PHC 字串;public 為空字串 - RedirectURIs RedirectURIs `gorm:"serializer:json;not null"` // 允許的 redirect URI(精確比對) - GrantTypes GrantTypes `gorm:"serializer:json;not null"` // 允許的 grant type - Scope string `gorm:"size:255;not null"` // 允許的 scope,空格分隔 - CreatedAt time.Time - UpdatedAt time.Time + ID uint `gorm:"primaryKey"` + ClientID string `gorm:"uniqueIndex;size:22;not null"` // 16 bytes 亂數的 base64url(公開識別碼,128 bits 熵已足夠) + Name string `gorm:"size:255;not null"` // 顯示名稱(授權頁顯示「以 ○○ 登入」等) + Type ClientType `gorm:"size:16;not null"` // confidential 或 public + ClientSecretHash string `gorm:"size:255;not null"` // argon2id PHC 字串;public 為空字串 + RedirectURIs RedirectURIs `gorm:"serializer:json;not null"` // 允許的 redirect URI(精確比對) + PostLogoutRedirectURIs RedirectURIs `gorm:"serializer:json"` // RP-Initiated Logout 的 post_logout_redirect_uri 白名單(精確比對);未註冊為空,不允許登出後重導 + GrantTypes GrantTypes `gorm:"serializer:json;not null"` // 允許的 grant type + Scope string `gorm:"size:255;not null"` // 允許的 scope,空格分隔 + CreatedAt time.Time + UpdatedAt time.Time } // IsPublic 回傳是否為公開式 Client(不持有 secret,授權流程必須使用 PKCE)。 @@ -118,12 +119,16 @@ func (a *Application) IsPublic() bool { // 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 { +// 供 NewApplication 與 Update 共用。postLogoutRedirectURIs 為選填的 +// RP-Initiated Logout 返回 URI(RP-Initiated Logout 1.0 §3.1 的 +// post_logout_redirect_uris 中繼資料),以 variadic 傳入——既有呼叫端 +// 不指定即維持空清單(不接受登出後重導)。驗證失敗時 a 可能已被部分 +// 修改,呼叫方不應將其儲存。 +func (a *Application) fill(name string, typ ClientType, redirectURIs, postLogoutRedirectURIs []string, grantTypes []GrantType, scope string) error { a.Name = strings.TrimSpace(name) a.Type = typ - a.RedirectURIs = append(RedirectURIs{}, redirectURIs...) // 保證非 nil,序列化為 [] 而非 null + a.RedirectURIs = append(RedirectURIs{}, redirectURIs...) // 保證非 nil,序列化為 [] 而非 null + a.PostLogoutRedirectURIs = append(RedirectURIs{}, postLogoutRedirectURIs...) // 同上(欄位可空,寫入 [] 便於編輯頁還原) a.GrantTypes = grantTypes a.Scope = strings.TrimSpace(scope) if len(a.GrantTypes) == 0 { @@ -138,9 +143,9 @@ func (a *Application) fill(name string, typ ClientType, redirectURIs []string, g // NewApplication 建立新的應用程式註冊:先驗證內容,再產生全域唯一的 // client_id;機密式 Client 另產生 client secret,明文僅經回傳值交付一 // 次,呼叫方應立即提供給應用程式管理者,不得儲存明文。 -func NewApplication(name string, typ ClientType, redirectURIs []string, grantTypes []GrantType, scope string) (*Application, string, error) { +func NewApplication(name string, typ ClientType, redirectURIs []string, grantTypes []GrantType, scope string, postLogoutRedirectURIs ...string) (*Application, string, error) { a := &Application{} - if err := a.fill(name, typ, redirectURIs, grantTypes, scope); err != nil { + if err := a.fill(name, typ, redirectURIs, postLogoutRedirectURIs, grantTypes, scope); err != nil { return nil, "", err } id, err := auth.NewToken(16) @@ -162,8 +167,8 @@ func NewApplication(name string, typ ClientType, redirectURIs []string, grantTyp // 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 { +func (a *Application) Update(name string, typ ClientType, redirectURIs []string, grantTypes []GrantType, scope string, postLogoutRedirectURIs ...string) error { + if err := a.fill(name, typ, redirectURIs, postLogoutRedirectURIs, grantTypes, scope); err != nil { return err } if a.IsPublic() { @@ -208,6 +213,13 @@ func (a *Application) Validate() error { } } } + // post_logout_redirect_uri 沿用 redirect URI 的格式規則(RP-Initiated + // Logout 1.0 §3.1 建議 https;http 僅 loopback 供本機開發)。 + for _, uri := range a.PostLogoutRedirectURIs { + if err := validateRedirectURI(uri); err != nil { + return fmt.Errorf("登出後返回 URI %q:%w", uri, err) + } + } if a.Scope == "" { return errors.New("scope 不可為空") } diff --git a/internal/application/application_test.go b/internal/application/application_test.go index 08ae835..197a9e5 100644 --- a/internal/application/application_test.go +++ b/internal/application/application_test.go @@ -420,3 +420,62 @@ func TestApplicationPersistence(t *testing.T) { t.Errorf("查無 client_id 應回 gorm.ErrRecordNotFound,得到 %v", err) } } + +func TestPostLogoutRedirectURIs(t *testing.T) { + t.Run("註冊並精確比對", func(t *testing.T) { + a, _, err := NewApplication("示範應用", ClientConfidential, + []string{"https://app.example.com/cb"}, nil, "", + "https://app.example.com/logged-out") + if err != nil { + t.Fatal(err) + } + if !a.PostLogoutRedirectURIs.Contains("https://app.example.com/logged-out") { + t.Error("註冊的登出後返回 URI 應精確比對成功") + } + // 與 redirect URI 不互通(RP-Initiated Logout 1.0 §3:僅比對 + // post_logout_redirect_uris 註冊值)。 + if a.PostLogoutRedirectURIs.Contains("https://app.example.com/cb") { + t.Error("redirect URI 不應混入登出後返回 URI 的比對") + } + }) + + t.Run("未註冊時為非 nil 空清單", func(t *testing.T) { + a, _, err := NewApplication("無返回", ClientConfidential, + []string{"https://app.example.com/cb"}, nil, "") + if err != nil { + t.Fatal(err) + } + if a.PostLogoutRedirectURIs == nil || len(a.PostLogoutRedirectURIs) != 0 { + t.Errorf("未指定應為非 nil 空清單(序列化為 []),得到 %v", a.PostLogoutRedirectURIs) + } + }) + + t.Run("格式驗證與 redirect URI 同規則", func(t *testing.T) { + if _, _, err := NewApplication("示範應用", ClientConfidential, + []string{"https://app.example.com/cb"}, nil, "", + "http://app.example.com/logged-out"); err == nil { + t.Error("非 loopback 的 http 登出後返回 URI 應被拒") + } + if _, _, err := NewApplication("示範應用", ClientConfidential, + []string{"https://app.example.com/cb"}, nil, "", + "https://app.example.com/logged-out#frag"); err == nil { + t.Error("含 fragment 的登出後返回 URI 應被拒") + } + }) + + t.Run("Update 可清空", func(t *testing.T) { + a, _, err := NewApplication("示範應用", ClientConfidential, + []string{"https://app.example.com/cb"}, nil, "", + "https://app.example.com/logged-out") + if err != nil { + t.Fatal(err) + } + if err := a.Update("示範應用", ClientConfidential, + []string{"https://app.example.com/cb"}, nil, ""); err != nil { + t.Fatal(err) + } + if len(a.PostLogoutRedirectURIs) != 0 { + t.Errorf("Update 未指定時應清空,得到 %v", a.PostLogoutRedirectURIs) + } + }) +} diff --git a/internal/auth/loginpage.go b/internal/auth/loginpage.go index 2613e96..6401e66 100644 --- a/internal/auth/loginpage.go +++ b/internal/auth/loginpage.go @@ -21,14 +21,21 @@ var ( // 第一個(根)模板,頁面模板僅定義 title/content 等區塊覆寫之, // 故 Execute 仍輸出版面本身。註冊頁與管理列表頁另解析 secretpanel.html // 的一次性成果面板;編輯頁無一次性面板,不在解析之列。 - loggedInTmpl = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/loggedin.html")) + loggedInTmpl = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/loggedin.html")) + // 更新密碼頁(登入者自助變更)以 layout 組合,密碼欄位不預填、 + // 無一次性面板。 + passwordTmpl = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/password.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")) + ConsentTmpl = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/consent.html")) + // 登出確認頁(layout 組合)與已登出頁(獨立頁,使用者已無 Session + // 脈絡)供 oidc 套件的 /logout(RP-Initiated Logout)使用。 + LogoutConfirmTmpl = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/logoutconfirm.html")) + LoggedOutTmpl = template.Must(template.ParseFS(templateFS, "templates/loggedout.html")) + notFoundTmpl = template.Must(template.ParseFS(templateFS, "templates/notfound.html")) ) // CSRFCookieName 為登入表單 double-submit CSRF 防護的 Cookie 名稱: diff --git a/internal/auth/logout.go b/internal/auth/logout.go index d74bfab..405b7eb 100644 --- a/internal/auth/logout.go +++ b/internal/auth/logout.go @@ -37,12 +37,7 @@ func LogoutHandler(db *gorm.DB) http.HandlerFunc { } } - if c, err := r.Cookie(CookieName); err == nil { - if err := DeleteSession(db, c.Value); err != nil { - log.Printf("logout: %v", err) - } - } - clearSessionCookie(w, r) + ClearSession(db, w, r) if isForm { // PRG:以 303 導向 /login 顯示登入表單,避免重新整理重複送出。 @@ -53,6 +48,19 @@ func LogoutHandler(db *gorm.DB) http.HandlerFunc { } } +// ClearSession 刪除資料庫中的 Session 並清除瀏覽器 Cookie,冪等——查無 +// Session 亦清除 Cookie;資料庫刪除失敗僅記錄不中斷(Session 最遲於效期 +// 到期失效)。供 LogoutHandler 與 oidc 套件的 RP-Initiated Logout 端點 +// 共用同一套清理邏輯。 +func ClearSession(db *gorm.DB, w http.ResponseWriter, r *http.Request) { + if c, err := r.Cookie(CookieName); err == nil { + if err := DeleteSession(db, c.Value); err != nil { + log.Printf("logout: %v", err) + } + } + clearSessionCookie(w, r) +} + // clearSessionCookie 以 Max-Age=0 清除瀏覽器的 Session Cookie(與 // setSessionCookie 對稱,屬性一致以免因 Path 或 Secure 差異清不掉)。 func clearSessionCookie(w http.ResponseWriter, r *http.Request) { diff --git a/internal/auth/password.go b/internal/auth/password.go new file mode 100644 index 0000000..524eb42 --- /dev/null +++ b/internal/auth/password.go @@ -0,0 +1,237 @@ +package auth + +import ( + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "net/url" + "strings" + + "gorm.io/gorm" +) + +// MinPasswordLen 為密碼最小長度,網頁更新流程與 CLI 帳號作業共用。 +const MinPasswordLen = 8 + +// passwordPageData 為更新密碼頁的模板資料。 +type passwordPageData struct { + Error string // 驗證失敗訊息;空字串表示不顯示 + Success string // PRG 成功訊息(?saved=1);空字串表示不顯示 + Username string // 側欄頁尾使用者資訊 + Email string + IsAdmin bool // admin 另顯示管理頁導覽連結 + CSRF string // 表單隱藏欄位用 CSRF token,與 Cookie 成對輪替 +} + +// PasswordPageHandler 處理 GET /password(更新密碼頁):持有效 Session 顯示 +// 表單,未登入導向 /login?next=/password(登入後返回)。?saved=1 為 PRG +// 的成功旗標,顯示成功訊息。 +func PasswordPageHandler(db *gorm.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + s, err := lookupSession(db, r) + if err != nil { + respondSessionError(w, r, err, true) + return + } + var success string + if r.URL.Query().Get("saved") == "1" { + success = "密碼已更新,其他裝置的登入已全部登出。" + } + renderPasswordPage(w, r, http.StatusOK, s, "", success) + } +} + +// PasswordChangeHandler 處理 POST /password,依 Content-Type 分流(與 +// 登入/登出一致):表單走瀏覽器流程(需通過 CSRF 驗證),JSON 走 API +// 流程。驗證目前密碼後更新密碼,並於單一交易內撤銷該使用者所有 +// Session(與 CLI update-password 同一套交易邏輯),再為目前瀏覽器重建 +// Session(輪替 Session ID,避免 fixation)——其他裝置立即登出,目前 +// 瀏覽器保持登入。成功後表單流程以 303 導回 /password?saved=1(PRG)。 +func PasswordChangeHandler(db *gorm.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ct := r.Header.Get("Content-Type") + var isForm bool + switch { + case strings.HasPrefix(ct, "application/json"): + case strings.HasPrefix(ct, "application/x-www-form-urlencoded"), + strings.HasPrefix(ct, "multipart/form-data"): + isForm = true + default: + WriteError(w, http.StatusUnsupportedMediaType, "Content-Type 須為 application/json 或表單") + return + } + + s, err := lookupSession(db, r) + if err != nil { + respondSessionError(w, r, err, isForm) + return + } + + r.Body = http.MaxBytesReader(w, r.Body, 64<<10) + var in passwordRequest + if isForm { + if err := r.ParseForm(); err != nil { + renderPasswordPage(w, r, http.StatusBadRequest, s, "無法解析表單內容", "") + return + } + if !VerifyCSRF(r) { + renderPasswordPage(w, r, http.StatusForbidden, s, "表單驗證失敗,請重新整理頁面後再試", "") + return + } + in = passwordRequest{ + CurrentPassword: r.PostFormValue("current_password"), + NewPassword: r.PostFormValue("new_password"), + Confirm: r.PostFormValue("confirm_password"), + } + } else if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + WriteError(w, http.StatusBadRequest, "無法解析請求內容") + return + } + + fail := func(status int, msg string) { + if isForm { + renderPasswordPage(w, r, status, s, msg, "") + return + } + WriteError(w, status, msg) + } + if err := in.validate(isForm); err != nil { + fail(http.StatusBadRequest, err.Error()) + return + } + if !s.User.CheckPassword(in.CurrentPassword) { + fail(http.StatusUnauthorized, "目前的密碼錯誤") + return + } + + if err := s.User.SetPassword(in.NewPassword); err != nil { + log.Printf("password: %v", err) + fail(http.StatusInternalServerError, "內部錯誤") + return + } + if _, err := UpdateUserPassword(db, &s.User); err != nil { + log.Printf("password: %v", err) + fail(http.StatusInternalServerError, "內部錯誤") + return + } + // 舊 Session 已隨交易撤銷,重建目前瀏覽器的 Session;失敗時密碼 + // 已更新,只能請使用者以新密碼重新登入。 + ns, err := CreateSession(db, s.User.ID) + if err != nil { + log.Printf("password: %v", err) + clearSessionCookie(w, r) + if isForm { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + WriteError(w, http.StatusInternalServerError, "密碼已更新,請重新登入") + return + } + setSessionCookie(w, r, ns) + + if isForm { + // PRG:以 303 導回本頁以 ?saved=1 顯示成功訊息,避免重新整理 + // 重複送出表單。 + http.Redirect(w, r, "/password?saved=1", http.StatusSeeOther) + return + } + w.WriteHeader(http.StatusNoContent) + } +} + +// passwordRequest 為 POST /password 的請求欄位(JSON 與表單共用); +// 表單流程另以 Confirm 重複輸入新密碼,JSON 流程由呼叫端自行確認。 +type passwordRequest struct { + CurrentPassword string `json:"current_password"` + NewPassword string `json:"new_password"` + Confirm string `json:"-"` // 僅表單流程的 confirm_password 欄位 +} + +// validate 檢查欄位:目前與新密碼不可為空、新密碼長度須達最小值且不得 +// 與目前密碼相同;表單流程另檢查確認欄位與新密碼一致。 +func (in *passwordRequest) validate(isForm bool) error { + if in.CurrentPassword == "" { + return errors.New("目前的密碼不可為空") + } + if in.NewPassword == "" { + return errors.New("新密碼不可為空") + } + if len(in.NewPassword) < MinPasswordLen { + return fmt.Errorf("密碼長度至少 %d 字元", MinPasswordLen) + } + if isForm && in.Confirm != in.NewPassword { + return errors.New("兩次輸入的新密碼不一致") + } + if in.NewPassword == in.CurrentPassword { + return errors.New("新密碼不可與目前的密碼相同") + } + return nil +} + +// UpdateUserPassword 於單一交易內寫入新密碼雜湊(須先 SetPassword)並 +// 刪除該使用者所有 Session,回傳撤銷的 Session 數;交易確保密碼與 +// Session 不會只更新一半。供網頁的更新密碼流程與 CLI update-password +// 共用同一套邏輯。 +func UpdateUserPassword(db *gorm.DB, u *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{}) + if res.Error != nil { + return fmt.Errorf("delete sessions: %w", res.Error) + } + revoked = res.RowsAffected + return nil + }) + return revoked, err +} + +// lookupSession 由 Cookie 查詢效期內 Session(含使用者);未帶 Cookie +// 與 Session 無效一併回 ErrSessionExpired,不洩漏差異。 +func lookupSession(db *gorm.DB, r *http.Request) (*Session, error) { + c, err := r.Cookie(CookieName) + if err != nil { + return nil, ErrSessionExpired + } + return GetSession(db, c.Value) +} + +// respondSessionError 依流程處理 lookupSession 的錯誤:未登入在表單 +// 流程導向 /login?next=/password(登入後返回原頁),JSON 流程回 401; +// 其餘錯誤記錄後回 500。 +func respondSessionError(w http.ResponseWriter, r *http.Request, err error, isForm bool) { + switch { + case errors.Is(err, ErrSessionExpired): + if isForm { + http.Redirect(w, r, "/login?next="+url.QueryEscape("/password"), http.StatusSeeOther) + return + } + WriteError(w, http.StatusUnauthorized, "須登入") + default: + log.Printf("password: %v", err) + http.Error(w, "內部錯誤", http.StatusInternalServerError) + } +} + +// renderPasswordPage 輸出更新密碼頁;每次輸出都輪替 CSRF token。密碼 +// 欄位一律不預填,驗證失敗重繪時也不保留輸入。 +func renderPasswordPage(w http.ResponseWriter, r *http.Request, status int, s *Session, errMsg, success string) { + token, err := NewCSRFToken(w, r) + if err != nil { + log.Printf("csrf token: %v", err) + http.Error(w, "內部錯誤", http.StatusInternalServerError) + return + } + RenderHTML(w, status, passwordTmpl, passwordPageData{ + Error: errMsg, + Success: success, + Username: s.User.Username, + Email: s.User.Email, + IsAdmin: s.User.Role == RoleAdmin, + CSRF: token, + }) +} diff --git a/internal/auth/password_test.go b/internal/auth/password_test.go new file mode 100644 index 0000000..744e36e --- /dev/null +++ b/internal/auth/password_test.go @@ -0,0 +1,357 @@ +package auth + +import ( + "errors" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +func TestPasswordRequestValidate(t *testing.T) { + tests := []struct { + name string + in passwordRequest + isForm bool + wantErr string // 空字串表示應通過 + }{ + {"最小欄位", passwordRequest{CurrentPassword: "sup3r-secret", NewPassword: "n3w-secret!"}, false, ""}, + {"表單確認欄位相符", passwordRequest{CurrentPassword: "sup3r-secret", NewPassword: "n3w-secret!", Confirm: "n3w-secret!"}, true, ""}, + {"缺目前密碼", passwordRequest{NewPassword: "n3w-secret!"}, false, "目前的密碼"}, + {"缺新密碼", passwordRequest{CurrentPassword: "sup3r-secret"}, false, "新密碼不可為空"}, + {"新密碼過短", passwordRequest{CurrentPassword: "sup3r-secret", NewPassword: "short"}, false, "密碼長度至少"}, + {"表單確認欄位不一致", passwordRequest{CurrentPassword: "sup3r-secret", NewPassword: "n3w-secret!", Confirm: "other-pass"}, true, "兩次輸入的新密碼不一致"}, + {"JSON 流程不檢查確認欄位", passwordRequest{CurrentPassword: "sup3r-secret", NewPassword: "n3w-secret!", Confirm: "other-pass"}, false, ""}, + {"新密碼與目前密碼相同", passwordRequest{CurrentPassword: "sup3r-secret", NewPassword: "sup3r-secret"}, false, "不可與目前的密碼相同"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.in.validate(tt.isForm) + if tt.wantErr == "" { + if err != nil { + t.Fatalf("validate() = %v, want nil", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("validate() = %v, want error containing %q", err, tt.wantErr) + } + }) + } +} + +// 未帶 Session Cookie 的路徑不會查詢資料庫,可用 nil db 測試。 +func TestPasswordHandlersRequireSession(t *testing.T) { + t.Run("GET 未登入導向 /login 並攜回 next", func(t *testing.T) { + rec := httptest.NewRecorder() + PasswordPageHandler(nil)(rec, httptest.NewRequest(http.MethodGet, "/password", nil)) + if rec.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303", rec.Code) + } + if loc := rec.Header().Get("Location"); loc != "/login?next=%2Fpassword" { + t.Fatalf("Location = %q, want /login?next=%%2Fpassword", loc) + } + }) + + t.Run("POST 表單未登入導向 /login", func(t *testing.T) { + rec := httptest.NewRecorder() + PasswordChangeHandler(nil)(rec, formPost("csrf_token=token-A", nil)) + if rec.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303", rec.Code) + } + if loc := rec.Header().Get("Location"); loc != "/login?next=%2Fpassword" { + t.Fatalf("Location = %q, want /login?next=%%2Fpassword", loc) + } + }) + + t.Run("POST JSON 未登入回 401", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/password", strings.NewReader(`{}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + PasswordChangeHandler(nil)(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + if !strings.Contains(rec.Body.String(), `"error"`) { + t.Fatalf("JSON 流程應回錯誤: %s", rec.Body.String()) + } + }) + + t.Run("不支援的 Content-Type 回 415", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/password", strings.NewReader("x=1")) + req.Header.Set("Content-Type", "text/plain") + rec := httptest.NewRecorder() + PasswordChangeHandler(nil)(rec, req) + if rec.Code != http.StatusUnsupportedMediaType { + t.Fatalf("status = %d, want 415", rec.Code) + } + }) +} + +// renderPasswordPage 不查詢資料庫,可直接以虛構 Session 測試表單輸出。 +func TestRenderPasswordPage(t *testing.T) { + rec := httptest.NewRecorder() + s := &Session{ + ID: "test-session", + User: User{Username: "alice", Email: "alice@example.com"}, + ExpiresAt: time.Now().Add(24 * time.Hour), + } + renderPasswordPage(rec, httptest.NewRequest(http.MethodGet, "/password", nil), http.StatusOK, s, "", "") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + body := rec.Body.String() + for _, want := range []string{ + `action="/password"`, + `name="csrf_token"`, + `name="current_password"`, + `name="new_password"`, + `name="confirm_password"`, + `autocomplete="current-password"`, + `autocomplete="new-password"`, + `minlength="8"`, + "{{.Form.RedirectURIs}}

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

+
+ + +

RP-Initiated Logout 的 post_logout_redirect_uri 白名單(精確比對,格式規則同 redirect URI);留空表示不接受登出後重導回應用程式。

+
允許的 grant type
diff --git a/internal/auth/templates/adminapplicationnew.html b/internal/auth/templates/adminapplicationnew.html index f2c3ce1..99bd0ec 100644 --- a/internal/auth/templates/adminapplicationnew.html +++ b/internal/auth/templates/adminapplicationnew.html @@ -75,6 +75,12 @@ class="w-full rounded-lg border border-neutral-300 px-3 py-2 font-mono text-xs dark:border-neutral-600 dark:bg-neutral-900">{{.Form.RedirectURIs}}

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

+
+ + +

RP-Initiated Logout 的 post_logout_redirect_uri 白名單(精確比對,格式規則同 redirect URI);留空表示不接受登出後重導回應用程式。

+
允許的 grant type
diff --git a/internal/auth/templates/adminapplications.html b/internal/auth/templates/adminapplications.html index 0edb588..0a6433b 100644 --- a/internal/auth/templates/adminapplications.html +++ b/internal/auth/templates/adminapplications.html @@ -64,6 +64,7 @@ client_id 類型 redirect URI + 登出後返回 URI grant type scope 建立時間 @@ -83,6 +84,7 @@ {{end}} {{.RedirectURIs}} + {{if .PostLogoutRedirectURIs}}{{.PostLogoutRedirectURIs}}{{else}}—{{end}} {{.GrantTypes}} {{.Scope}} {{.CreatedAt}} diff --git a/internal/auth/templates/loggedin.html b/internal/auth/templates/loggedin.html index 19c55ff..740cb60 100644 --- a/internal/auth/templates/loggedin.html +++ b/internal/auth/templates/loggedin.html @@ -17,6 +17,15 @@
Session 到期
{{.ExpiresAt}}
+

授權流程(/authorize)完成後,登入將自動導回應用程式。

{{end}} diff --git a/internal/auth/templates/loggedout.html b/internal/auth/templates/loggedout.html new file mode 100644 index 0000000..9cdc49f --- /dev/null +++ b/internal/auth/templates/loggedout.html @@ -0,0 +1,19 @@ + + + + + + +已登出 - alterminal + + + +
+

您已登出

+

單一登入服務

+ {{if .Warning}}{{end}} + 重新登入 +
+ + diff --git a/internal/auth/templates/logoutconfirm.html b/internal/auth/templates/logoutconfirm.html new file mode 100644 index 0000000..92d55da --- /dev/null +++ b/internal/auth/templates/logoutconfirm.html @@ -0,0 +1,28 @@ +{{/* 登出確認頁(GET /logout)。以 layout.html(側邊導覽欄版面)為根模板 + 組合渲染,本檔僅定義區塊。RP-Initiated Logout 1.0 §2:未提供 + id_token_hint、或提示的 ID token 不屬於目前 Session 時,OP 必須先 + 詢問 End-User 是否登出——使用者直接造訪 /logout 亦同。原始請求的 + 每個參數以隱藏欄位原封帶回 POST /logout(decision=logout 確認登出; + decision=cancel 維持登入並返回帳號首頁)。 */}} +{{define "title"}}登出確認 - alterminal{{end}} + +{{define "content"}} +
+

您確定要登出嗎?

+

+ {{if .AppName}}應用程式 {{.AppName}} 要求登出您在本服務的帳號。{{else}}登出將結束您在本服務的 Session。{{end}} +

+ {{if .Error}}{{end}} +
+ {{range $k, $vs := .Params}}{{range $vs}}{{end}}{{end}} + +
+ + +
+
+

登出後,所有以本帳號單一登入的應用程式都需要重新登入。

+
+{{end}} diff --git a/internal/auth/templates/password.html b/internal/auth/templates/password.html new file mode 100644 index 0000000..50d0e7e --- /dev/null +++ b/internal/auth/templates/password.html @@ -0,0 +1,69 @@ +{{/* 更新密碼頁(登入者自助變更,GET/POST /password)。以 layout.html + (側邊導覽欄版面)為根模板組合渲染,本檔僅定義區塊,不應單獨解析 + 執行。導覽覆寫版面預設:「帳號資訊」不標記 aria-current(本頁非 + 帳號首頁),admin 另顯示管理頁連結。密碼欄位一律不預填——驗證失敗 + 重繪亦同;成功後 PRG 回本頁以 ?saved=1 顯示成功訊息。 */}} +{{define "title"}}更新密碼 - alterminal{{end}} + +{{define "navitems"}} +
  • + + + 帳號資訊 + +
  • +{{if .IsAdmin}} +
  • + + + 金鑰管理 + +
  • +
  • + + + 應用程式管理 + +
  • +{{end}} +{{end}} + +{{define "content"}} +
    +

    更新密碼

    +

    驗證目前的密碼後設定新密碼

    + {{if .Error}}{{end}} + {{if .Success}}

    {{.Success}}

    {{end}} +
    + +
    + + +
    +
    + + +

    至少 8 字元。

    +
    +
    + + +
    + +
    +

    更新成功後,其他裝置的登入將全部登出;本瀏覽器會保持登入狀態。

    +
    +{{end}} diff --git a/internal/cli/createaccount.go b/internal/cli/createaccount.go index 6763192..d5e4b63 100644 --- a/internal/cli/createaccount.go +++ b/internal/cli/createaccount.go @@ -117,10 +117,9 @@ func (in *accountInput) validate() error { return nil } -const minPasswordLen = 8 - // resolvePassword 回傳帳號密碼:有 -password 旗標時直接使用, // 否則須於終端機以無回顯方式輸入兩次;非終端機環境不得省略旗標。 +// 最小長度採 auth.MinPasswordLen(與網頁更新密碼流程一致)。 func resolvePassword(flagPassword string) (string, error) { password := flagPassword if password == "" { @@ -133,8 +132,8 @@ func resolvePassword(flagPassword string) (string, error) { return "", err } } - if len(password) < minPasswordLen { - return "", fmt.Errorf("密碼長度至少 %d 字元", minPasswordLen) + if len(password) < auth.MinPasswordLen { + return "", fmt.Errorf("密碼長度至少 %d 字元", auth.MinPasswordLen) } return password, nil } diff --git a/internal/cli/updatepassword.go b/internal/cli/updatepassword.go index a32ca6c..db65929 100644 --- a/internal/cli/updatepassword.go +++ b/internal/cli/updatepassword.go @@ -42,7 +42,7 @@ func runUpdatePassword(args []string) error { if err := u.SetPassword(pw); err != nil { return fmt.Errorf("hash password: %w", err) } - revoked, err := updateUserPassword(db, u) + revoked, err := auth.UpdateUserPassword(db, u) if err != nil { return err } @@ -62,21 +62,3 @@ func findUserByUsername(db *gorm.DB, username string) (*auth.User, error) { } return &u, nil } - -// updateUserPassword 於單一交易內寫入新密碼雜湊並刪除該使用者所有 -// Session,回傳撤銷的 Session 數;交易確保密碼與 Session 不會只更新一半。 -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(&auth.Session{}) - if res.Error != nil { - return fmt.Errorf("delete sessions: %w", res.Error) - } - revoked = res.RowsAffected - return nil - }) - return revoked, err -} diff --git a/internal/oidc/discovery.go b/internal/oidc/discovery.go index bd6e940..b5930e8 100644 --- a/internal/oidc/discovery.go +++ b/internal/oidc/discovery.go @@ -20,6 +20,7 @@ type discoveryDocument struct { AuthorizationEndpoint string `json:"authorization_endpoint"` TokenEndpoint string `json:"token_endpoint"` UserInfoEndpoint string `json:"userinfo_endpoint"` + EndSessionEndpoint string `json:"end_session_endpoint"` // RP-Initiated Logout 1.0 §2.1:同時支援兩者時為 REQUIRED JWKSURI string `json:"jwks_uri"` ScopesSupported []string `json:"scopes_supported"` ResponseTypesSupported []string `json:"response_types_supported"` @@ -42,6 +43,7 @@ func DiscoveryHandler(issuer string) http.HandlerFunc { AuthorizationEndpoint: issuer + "/authorize", TokenEndpoint: issuer + "/token", UserInfoEndpoint: issuer + "/userinfo", + EndSessionEndpoint: issuer + "/logout", JWKSURI: issuer + "/.well-known/jwks.json", ScopesSupported: application.ScopesSupported(), ResponseTypesSupported: []string{"code"}, diff --git a/internal/oidc/jwt.go b/internal/oidc/jwt.go index be37647..0a4cff7 100644 --- a/internal/oidc/jwt.go +++ b/internal/oidc/jwt.go @@ -159,12 +159,12 @@ func scopeHas(scope, s string) bool { 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) { +// verifyJWTSignature 驗證 JWT 的外層結構與簽章:拆解三段、僅接受 RS256 +// 的 alg(RFC 8725 §3.4 的演算法混淆防護)、以 header kid 對應的簽章 +// 金鑰驗章(金鑰輪替過渡期仍可查得已退休金鑰),回傳解碼後的 claims +// JSON,供 VerifyAccessToken 與 verifyIDTokenHint 共用。任何一項不符即回 +// 包裹 ErrInvalidToken 的錯誤,不洩漏細節。 +func verifyJWTSignature(db *gorm.DB, token string) ([]byte, error) { parts := strings.Split(token, ".") if len(parts) != 3 { return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "結構") @@ -197,11 +197,21 @@ func VerifyAccessToken(db *gorm.DB, issuer, token string) (*AccessTokenClaims, e 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") } + return payloadJSON, nil +} + +// VerifyAccessToken 驗證 Access Token 並回傳其 claims:驗章同 +// verifyJWTSignature,另比對 issuer 與效期(OIDC Core §3.1.3.7 的 iss/ +// exp 驗證項)。任何一項不符即回 ErrInvalidToken,不洩漏細節。 +func VerifyAccessToken(db *gorm.DB, issuer, token string) (*AccessTokenClaims, error) { + payloadJSON, err := verifyJWTSignature(db, token) + if err != nil { + return nil, err + } var claims AccessTokenClaims if err := json.Unmarshal(payloadJSON, &claims); err != nil { return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "claims") diff --git a/internal/oidc/logout.go b/internal/oidc/logout.go new file mode 100644 index 0000000..cea85ca --- /dev/null +++ b/internal/oidc/logout.go @@ -0,0 +1,360 @@ +package oidc + +import ( + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "net/url" + "strings" + + "gorm.io/gorm" + + "alterminal/internal/application" + "alterminal/internal/auth" +) + +// RP-Initiated Logout(OpenID Connect RP-Initiated Logout 1.0):RP 將 +// 使用者導向 OP 的登出端點,請求結束 End-User 在 OP 的 Session。本檔實作 +// /logout 的 GET 與 POST(§2 要求兩種方法皆須支援);不帶 RP 參數的 +// POST(帳號頁側欄登出表單)與 JSON API 登出仍由 auth.LogoutHandler +// 處理,行為不變。 + +// logoutRequest 為 /logout 的 RP-Initiated Logout 請求參數(§2):GET +// query 與確認頁 POST 表單共用;Decision 僅用於確認頁表單的兩顆按鈕。 +type logoutRequest struct { + IDTokenHint string + PostLogoutRedirectURI string + ClientID string + State string + Decision string +} + +// logoutRequestFromValues 由 query 或表單值還原請求參數。 +func logoutRequestFromValues(v url.Values) logoutRequest { + return logoutRequest{ + IDTokenHint: v.Get("id_token_hint"), + PostLogoutRedirectURI: v.Get("post_logout_redirect_uri"), + ClientID: v.Get("client_id"), + State: v.Get("state"), + Decision: v.Get("decision"), + } +} + +// values 重建請求的原始參數(確認頁的隱藏欄位;Decision 不隱藏帶回)。 +func (req logoutRequest) values() url.Values { + v := url.Values{} + set := func(k, s string) { + if s != "" { + v.Set(k, s) + } + } + set("id_token_hint", req.IDTokenHint) + set("post_logout_redirect_uri", req.PostLogoutRedirectURI) + set("client_id", req.ClientID) + set("state", req.State) + return v +} + +// isRPInitiated 回傳參數集是否為 RP-Initiated Logout 請求(帶任一 RP +// 參數)。不帶者為本站既有登出(側欄表單、JSON API),委由 auth 套件。 +func isRPInitiated(v url.Values) bool { + for _, k := range []string{"id_token_hint", "post_logout_redirect_uri", "client_id", "state", "decision"} { + if v.Get(k) != "" { + return true + } + } + return false +} + +// logoutContext 攜帶請求驗證後的決策輸入:發起登出的應用程式(由 hint +// 的 aud 或 client_id 參數對應)、hint 的 sub,以及 post_logout_redirect_uri +// 是否通過註冊驗證。 +type logoutContext struct { + app *application.Application // 無法對應任何應用程式時為 nil + hintSub string // id_token_hint 的 sub;未提供 hint 時為空字串 + redirectOK bool // post_logout_redirect_uri 已精確比對通過註冊值 +} + +// resolveLogout 驗證請求並解析決策輸入: +// - id_token_hint 驗章與 iss(§2:OP MUST 驗證其為本 OP 所簽發;效期 +// 不檢查——§2 要求 RP 對應 session 存在(或近期存在)時應接受過期值)。 +// - 同時提供 client_id 時須與 hint 的 aud 相符(§2 MUST)。 +// - post_logout_redirect_uri 須與註冊的 post_logout_redirect_uris 精確 +// 比對通過(§3 MUST NOT 重導至未註冊值)——比對對象為 hint(aud)或 +// client_id 參數對應的應用程式;無 hint 時以 client_id 參數辨識 client +// (§2),查無此應用程式則不接受重導。 +// +// 硬錯誤(hint 無效、client_id 與 aud 不符)以 logoutError 回 400 且不 +// 執行登出(§4:偵測到請求錯誤時 MUST 不重導);查無 client_id 對應的 +// 應用程式為軟失敗——登出仍可進行(經使用者確認),僅不重導。 +func resolveLogout(db *gorm.DB, issuer string, req logoutRequest) (*logoutContext, *logoutError, error) { + ctx := &logoutContext{} + clientID := req.ClientID + if req.IDTokenHint != "" { + claims, err := verifyIDTokenHint(db, issuer, req.IDTokenHint) + if err != nil { + return nil, &logoutError{"id_token_hint 無效或非本服務簽發"}, nil + } + if clientID != "" && clientID != claims.Aud { + return nil, &logoutError{"client_id 與 id_token_hint 的 aud 不符"}, nil + } + clientID = claims.Aud + ctx.hintSub = claims.Sub + } + if clientID != "" { + app, err := application.GetByClientID(db, clientID) + switch { + case errors.Is(err, gorm.ErrRecordNotFound): + if req.IDTokenHint != "" { + // hint 指向的 client 已不存在:註冊資料已刪,請求無從驗證。 + return nil, &logoutError{"id_token_hint 對應的應用程式不存在"}, nil + } + case err != nil: + return nil, nil, err + default: + ctx.app = app + } + } + ctx.redirectOK = req.PostLogoutRedirectURI != "" && + ctx.app != nil && ctx.app.PostLogoutRedirectURIs.Contains(req.PostLogoutRedirectURI) + return ctx, nil, nil +} + +// logoutError 為不可繼續的請求錯誤:以 400 顯示錯誤頁,不執行登出、 +// 不重導(RP-Initiated Logout 1.0 §4)。 +type logoutError struct{ msg string } + +// LogoutHandler 處理 /logout:GET 與 RP 確認頁的 POST 走 RP-Initiated +// Logout 流程;不帶 RP 參數的 POST(側欄登出表單、JSON API)委由 +// auth.LogoutHandler 維持既有行為。 +func LogoutHandler(db *gorm.DB, issuer string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + handleLogoutGet(db, issuer, w, r) + case http.MethodPost: + handleLogoutPostDispatch(db, issuer, w, r) + default: + auth.WriteError(w, http.StatusMethodNotAllowed, "不支援的方法") + } + } +} + +// handleLogoutPostDispatch 分流 POST /logout:不帶 RP 參數者(側欄登出 +// 表單、JSON API)委由 auth.LogoutHandler 維持既有行為,其餘走 RP 確認 +// 流程。 +func handleLogoutPostDispatch(db *gorm.DB, issuer string, w http.ResponseWriter, r *http.Request) { + ct := r.Header.Get("Content-Type") + if !strings.HasPrefix(ct, "application/x-www-form-urlencoded") && + !strings.HasPrefix(ct, "multipart/form-data") { + auth.LogoutHandler(db)(w, r) // JSON 等非表單:既有 API 登出(含 415 檢查) + return + } + if err := r.ParseForm(); err != nil || !isRPInitiated(r.PostForm) { + auth.LogoutHandler(db)(w, r) // 表單解析失敗或非 RP 請求:既有表單登出(重繪 400 錯誤頁) + return + } + handleLogoutPost(db, issuer, w, r) +} + +// handleLogoutGet 處理 GET /logout(RP 導向或使用者直接造訪)。 +func handleLogoutGet(db *gorm.DB, issuer string, w http.ResponseWriter, r *http.Request) { + req := logoutRequestFromValues(r.URL.Query()) + ctx, lerr, err := resolveLogout(db, issuer, req) + if !logoutValidated(w, r, lerr, err) { + return + } + s, ok := logoutSession(db, w, r) + if !ok { + return + } + if s != nil && logoutNeedsConfirm(s, ctx) { + renderLogoutConfirmPage(w, r, http.StatusOK, req, ctx, s, "") + return + } + completeLogout(db, w, r, req, ctx) +} + +// handleLogoutPost 處理 POST /logout(確認頁決定,或 RP 直接以 POST 發起)。 +// decision 為 logout/cancel 時必來自本服務確認頁表單,須通過 CSRF 驗證; +// 無 decision(RP 直接 POST)視同 GET 的初次請求,走相同的確認判斷。 +func handleLogoutPost(db *gorm.DB, issuer string, w http.ResponseWriter, r *http.Request) { + req := logoutRequestFromValues(r.PostForm) + ctx, lerr, err := resolveLogout(db, issuer, req) + if !logoutValidated(w, r, lerr, err) { + return + } + s, ok := logoutSession(db, w, r) + if !ok { + return + } + switch req.Decision { + case "logout": + if !logoutCSRF(w, r, req, ctx, s) { + return + } + completeLogout(db, w, r, req, ctx) + case "cancel": + if !logoutCSRF(w, r, req, ctx, s) { + return + } + // 使用者選擇不登出:維持登入,回到帳號首頁。 + http.Redirect(w, r, "/", http.StatusSeeOther) + default: + if s != nil && logoutNeedsConfirm(s, ctx) { + renderLogoutConfirmPage(w, r, http.StatusOK, req, ctx, s, "") + return + } + completeLogout(db, w, r, req, ctx) + } +} + +// logoutCSRF 驗證確認表單的 CSRF;失敗時重繪確認頁(403)。無 Session +// 時無可保護的狀態(登出為冪等、重導目標已限註冊值),直接放行。 +func logoutCSRF(w http.ResponseWriter, r *http.Request, req logoutRequest, ctx *logoutContext, s *auth.Session) bool { + if s == nil || auth.VerifyCSRF(r) { + return true + } + renderLogoutConfirmPage(w, r, http.StatusForbidden, req, ctx, s, "表單驗證失敗,請重新操作") + return false +} + +// logoutValidated 統一處理驗證結果:硬錯誤顯示 400 錯誤頁(§4 不重導), +// 內部錯誤回 500。回傳是否繼續後續流程。 +func logoutValidated(w http.ResponseWriter, r *http.Request, lerr *logoutError, err error) bool { + switch { + case lerr != nil: + log.Printf("logout: %s", lerr.msg) + http.Error(w, "登出請求無效:"+lerr.msg, http.StatusBadRequest) + return false + case err != nil: + log.Printf("logout: %v", err) + http.Error(w, "內部錯誤", http.StatusInternalServerError) + return false + } + return true +} + +// logoutSession 查詢目前 Session:未登入或已過期回 (nil, true)(登出冪 +// 等,視同無 Session 續走流程);查詢錯誤回 500 並回 (nil, false),呼叫 +// 方應立即返回。 +func logoutSession(db *gorm.DB, w http.ResponseWriter, r *http.Request) (*auth.Session, bool) { + c, err := r.Cookie(auth.CookieName) + if err != nil { + return nil, true + } + s, err := auth.GetSession(db, c.Value) + if errors.Is(err, auth.ErrSessionExpired) { + return nil, true + } + if err != nil { + log.Printf("logout: %v", err) + http.Error(w, "內部錯誤", http.StatusInternalServerError) + return nil, false + } + return s, true +} + +// logoutNeedsConfirm 回傳是否須先經使用者確認:未提供 id_token_hint、 +// 或 hint 的 sub 不屬於目前 Session 的使用者時必須詢問(§2 MUST——本 +// 服務的 ID token 不含 sid claim,以 sub 比對 Session 使用者近似判斷 +// 「hint 是否屬於目前 session」)。帳號頁的每次登出本就由使用者點擊 +// 發起,直接造訪 /logout 亦同此路徑。 +func logoutNeedsConfirm(s *auth.Session, ctx *logoutContext) bool { + return ctx.hintSub == "" || ctx.hintSub != subject(s.UserID) +} + +// completeLogout 執行登出並完成回應:刪除 Session、清除 Cookie(冪等), +// 之後依驗證結果——通過者 303 重導 post_logout_redirect_uri 並附 state +// (§2);指定但未通過註冊驗證者不重導(§3 MUST NOT),顯示已登出頁 +// 與說明;未指定者 303 /login(與本站既有表單登出行為一致)。 +func completeLogout(db *gorm.DB, w http.ResponseWriter, r *http.Request, req logoutRequest, ctx *logoutContext) { + auth.ClearSession(db, w, r) + switch { + case ctx.redirectOK: + u, err := url.Parse(req.PostLogoutRedirectURI) + if err != nil { + log.Printf("logout: 解析 post_logout_redirect_uri: %v", err) + renderLoggedOutPage(w, r, "返回網址無效,無法導回應用程式") + return + } + if req.State != "" { + q := u.Query() + q.Set("state", req.State) + u.RawQuery = q.Encode() + } + http.Redirect(w, r, u.String(), http.StatusSeeOther) + case req.PostLogoutRedirectURI != "": + renderLoggedOutPage(w, r, "返回網址未經應用程式註冊,無法導回;您已登出本服務。") + default: + http.Redirect(w, r, "/login", http.StatusSeeOther) + } +} + +// logoutConfirmPageData 為登出確認頁的模板資料。Params 保存原始請求 +// 參數以隱藏欄位帶回 POST /logout(與同意頁同做法)。 +type logoutConfirmPageData struct { + Error string + Username string + Email string + IsAdmin bool + CSRF string + AppName string // 發起登出的應用程式;無法辨識時為空字串 + Params url.Values +} + +// renderLogoutConfirmPage 輸出登出確認頁;每次輸出都輪替 CSRF token。 +func renderLogoutConfirmPage(w http.ResponseWriter, r *http.Request, status int, req logoutRequest, ctx *logoutContext, 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 + } + appName := "" + if ctx.app != nil { + appName = ctx.app.Name + } + auth.RenderHTML(w, status, auth.LogoutConfirmTmpl, logoutConfirmPageData{ + Error: errMsg, + Username: s.User.Username, + Email: s.User.Email, + IsAdmin: s.User.Role == auth.RoleAdmin, + CSRF: token, + AppName: appName, + Params: req.values(), + }) +} + +// loggedOutPageData 為已登出頁的模板資料;Warning 在無法依 RP 請求導回 +// 應用程式時顯示原因(頁面獨立於側欄版面——使用者已登出,無帳號脈絡)。 +type loggedOutPageData struct { + Warning string +} + +// renderLoggedOutPage 輸出已登出頁。 +func renderLoggedOutPage(w http.ResponseWriter, r *http.Request, warning string) { + auth.RenderHTML(w, http.StatusOK, auth.LoggedOutTmpl, loggedOutPageData{Warning: warning}) +} + +// verifyIDTokenHint 驗證 RP-Initiated Logout 的 id_token_hint(§2:OP +// MUST 驗證其為本 OP 所簽發):拆解三段 JWT、僅接受 RS256(RFC 8725 +// §3.4)、以 header kid 對應金鑰驗章(含已退休金鑰)並比對 iss;不檢查 +// exp——§2 要求 RP(aud 對應的 client)的 session 存在或近期存在時應 +// 接受已過期的 ID token,登出提示通常在效期外送達。 +func verifyIDTokenHint(db *gorm.DB, issuer, token string) (*idTokenClaims, error) { + payloadJSON, err := verifyJWTSignature(db, token) + if err != nil { + return nil, err + } + var claims idTokenClaims + 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") + } + return &claims, nil +} diff --git a/internal/oidc/logout_test.go b/internal/oidc/logout_test.go new file mode 100644 index 0000000..ea500de --- /dev/null +++ b/internal/oidc/logout_test.go @@ -0,0 +1,441 @@ +// 外部測試套件(與 helpers_test.go 同理):oidc 模型由 store 遷移,內部 +// 測試套件匯入 testdb 會形成循環。涵蓋 RP-Initiated Logout 1.0 的 +// /logout:id_token_hint 驗證、確認頁、post_logout_redirect_uri 註冊比對 +// 與 state 回填,及不帶 RP 參數時對既有登出行為的委派。 +package oidc_test + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "alterminal/internal/application" + "alterminal/internal/auth" + "alterminal/internal/oidc" +) + +// postLogoutURI 為測試應用程式註冊的登出後返回 URI。 +const postLogoutURI = "https://rp.example/logged-out" + +// newLogoutEnv 建立已註冊登出後返回 URI 的測試環境。 +func newLogoutEnv(t *testing.T) *testEnv { + t.Helper() + e := newTestEnv(t) + e.app.PostLogoutRedirectURIs = application.RedirectURIs{postLogoutURI} + if err := e.db.Save(e.app).Error; err != nil { + t.Fatal("註冊登出後返回 URI: ", err) + } + return e +} + +// idTokenHint 為環境使用者簽發 ID token 供 id_token_hint 用。 +func idTokenHint(t *testing.T, e *testEnv) string { + t.Helper() + tok, err := oidc.IssueIDToken(e.db, testIssuer, e.user, e.app, "openid", "", e.session.CreatedAt) + if err != nil { + t.Fatal("簽發 ID token: ", err) + } + return tok +} + +// getLogout 對 GET /logout 發出請求(query 含前導 ?,可選帶 Cookie)。 +func getLogout(h http.HandlerFunc, query string, cookies ...*http.Cookie) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, "/logout"+query, nil) + for _, c := range cookies { + req.AddCookie(c) + } + rec := httptest.NewRecorder() + h(rec, req) + return rec +} + +// postLogoutForm 以表單送出 POST /logout(可選帶 Cookie)。 +func postLogoutForm(h http.HandlerFunc, form url.Values, cookies ...*http.Cookie) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, "/logout", 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 +} + +// sessionCookieCleared 檢查回應是否清除 Session Cookie(Max-Age<0)。 +func sessionCookieCleared(rec *httptest.ResponseRecorder) bool { + for _, c := range rec.Result().Cookies() { + if c.Name == auth.CookieName && c.MaxAge < 0 { + return true + } + } + return false +} + +// sessionAlive 回傳環境 Session 是否仍有效。 +func sessionAlive(t *testing.T, e *testEnv) bool { + t.Helper() + _, err := auth.GetSession(e.db, e.session.ID) + return err == nil +} + +// logoutQuery 組出 RP-Initiated Logout 的 GET query(含前導 ?)。 +func logoutQuery(hint, redirectURI, clientID, state string) string { + v := url.Values{} + set := func(k, s string) { + if s != "" { + v.Set(k, s) + } + } + set("id_token_hint", hint) + set("post_logout_redirect_uri", redirectURI) + set("client_id", clientID) + set("state", state) + return "?" + v.Encode() +} + +func TestLogoutDiscoveryEndSessionEndpoint(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) + } + // RP-Initiated Logout 1.0 §2.1:支援 Discovery 時須發佈 end_session_endpoint。 + if !strings.Contains(rec.Body.String(), `"end_session_endpoint":"`+testIssuer+`/logout"`) { + t.Fatalf("Discovery 應發佈 end_session_endpoint: %s", rec.Body.String()) + } +} + +func TestRPLogoutHintMatchingSessionRedirects(t *testing.T) { + e := newLogoutEnv(t) + h := oidc.LogoutHandler(e.db, testIssuer) + hint := idTokenHint(t, e) + + rec := getLogout(h, logoutQuery(hint, postLogoutURI, "", "st-123"), e.sessionCookie()) + if rec.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303, body = %s", rec.Code, rec.Body.String()) + } + // RP-Initiated Logout 1.0 §2:重導 post_logout_redirect_uri 並以 state + // 回填原值。 + loc := redirectLocation(t, rec) + if loc.String() != postLogoutURI+"?state=st-123" { + t.Fatalf("Location = %q, want %q?state=st-123", loc.String(), postLogoutURI) + } + if !sessionCookieCleared(rec) { + t.Fatal("應清除 Session Cookie") + } + if sessionAlive(t, e) { + t.Fatal("登出後 Session 應已刪除") + } +} + +func TestRPLogoutExpiredHintAccepted(t *testing.T) { + e := newLogoutEnv(t) + h := oidc.LogoutHandler(e.db, testIssuer) + // 過期的 ID token:登出提示常在效期外送達,RP-Initiated Logout 1.0 + // §2 要求 RP 對應 session 存在(或近期存在)時應接受。 + past := time.Now().Add(-time.Hour).Unix() + hint := forgeJWT(t, e.key, + map[string]string{"alg": "RS256", "kid": e.key.Kid, "typ": "JWT"}, + map[string]any{"iss": testIssuer, "sub": subjectOf(e.user.ID), "aud": e.app.ClientID, "exp": past, "iat": past}) + + rec := getLogout(h, logoutQuery(hint, postLogoutURI, "", ""), e.sessionCookie()) + if rec.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303, body = %s", rec.Code, rec.Body.String()) + } + if got := redirectLocation(t, rec).String(); got != postLogoutURI { + t.Fatalf("Location = %q, want %q", got, postLogoutURI) + } +} + +func TestRPLogoutHintOfOtherUserRequiresConfirmation(t *testing.T) { + e := newLogoutEnv(t) + h := oidc.LogoutHandler(e.db, testIssuer) + + other := &auth.User{Username: "logout-other", Email: "logout-other@example.com", Name: "他人"} + if err := e.db.Create(other).Error; err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + e.db.Delete(&auth.Session{}, "user_id = ?", other.ID) + e.db.Delete(&auth.User{}, other.ID) + }) + otherHint, err := oidc.IssueIDToken(e.db, testIssuer, other, e.app, "openid", "", time.Now()) + if err != nil { + t.Fatal(err) + } + + rec := getLogout(h, logoutQuery(otherHint, postLogoutURI, "", ""), 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(), "您確定要登出嗎") { + t.Fatal("應顯示登出確認頁") + } + if !sessionAlive(t, e) { + t.Fatal("未確認前不應登出") + } +} + +func TestRPLogoutWithoutHintConfirmationFlow(t *testing.T) { + e := newLogoutEnv(t) + h := oidc.LogoutHandler(e.db, testIssuer) + + // 無 id_token_hint:RP-Initiated Logout 1.0 §2 MUST 先詢問 End-User。 + rec := getLogout(h, logoutQuery("", postLogoutURI, e.app.ClientID, "st-9"), e.sessionCookie()) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200(確認頁), body = %s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + if !strings.Contains(body, "您確定要登出嗎") || !strings.Contains(body, e.app.Name) { + t.Fatalf("確認頁應顯示標題與應用程式名稱: %s", body) + } + // 原始參數以隱藏欄位帶回。 + if got := hiddenFieldValue(t, body, "post_logout_redirect_uri"); got != postLogoutURI { + t.Fatalf("隱藏欄位 post_logout_redirect_uri = %q, want %q", got, postLogoutURI) + } + if got := hiddenFieldValue(t, body, "state"); got != "st-9" { + t.Fatalf("隱藏欄位 state = %q, want st-9", got) + } + csrf := csrfCookieOf(t, rec) + + form := url.Values{ + "decision": {"logout"}, + "csrf_token": {csrf.Value}, + "client_id": {e.app.ClientID}, + "post_logout_redirect_uri": {postLogoutURI}, + "state": {"st-9"}, + } + rec = postLogoutForm(h, form, e.sessionCookie(), csrf) + if rec.Code != http.StatusSeeOther { + t.Fatalf("確認後 status = %d, want 303, body = %s", rec.Code, rec.Body.String()) + } + loc := redirectLocation(t, rec) + if loc.String() != postLogoutURI+"?state=st-9" { + t.Fatalf("Location = %q, want %q?state=st-9", loc.String(), postLogoutURI) + } + if sessionAlive(t, e) { + t.Fatal("確認後 Session 應已刪除") + } + + // 取消:維持登入,返回帳號首頁。 + s, err := auth.CreateSession(e.db, e.user.ID) + if err != nil { + t.Fatal(err) + } + e.session = s + rec2 := getLogout(h, logoutQuery("", postLogoutURI, e.app.ClientID, ""), e.sessionCookie()) + csrf2 := csrfCookieOf(t, rec2) + form2 := url.Values{ + "decision": {"cancel"}, + "csrf_token": {csrf2.Value}, + "client_id": {e.app.ClientID}, + "post_logout_redirect_uri": {postLogoutURI}, + } + rec2 = postLogoutForm(h, form2, e.sessionCookie(), csrf2) + if rec2.Code != http.StatusSeeOther { + t.Fatalf("取消 status = %d, want 303", rec2.Code) + } + if loc := redirectLocation(t, rec2).String(); loc != "/" { + t.Fatalf("取消後 Location = %q, want /", loc) + } + if !sessionAlive(t, e) { + t.Fatal("取消登出後 Session 應保持有效") + } +} + +func TestRPLogoutConfirmCSRFRequired(t *testing.T) { + e := newLogoutEnv(t) + h := oidc.LogoutHandler(e.db, testIssuer) + + form := url.Values{ + "decision": {"logout"}, + "csrf_token": {"wrong"}, + "client_id": {e.app.ClientID}, + "post_logout_redirect_uri": {postLogoutURI}, + } + rec := postLogoutForm(h, form, e.sessionCookie(), &http.Cookie{Name: auth.CSRFCookieName, Value: "right"}) + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403, body = %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "表單驗證失敗") { + t.Fatal("應重繪確認頁並顯示錯誤") + } + if !sessionAlive(t, e) { + t.Fatal("CSRF 失敗時不應登出") + } +} + +func TestRPLogoutUnregisteredRedirectRejected(t *testing.T) { + e := newLogoutEnv(t) + h := oidc.LogoutHandler(e.db, testIssuer) + hint := idTokenHint(t, e) + + // 未註冊的返回 URI(含湊巧是 redirect URI 者):RP-Initiated Logout + // 1.0 §3 MUST NOT 重導;登出仍完成並顯示說明。 + for _, uri := range []string{"https://evil.example/gotcha", e.app.RedirectURIs[0]} { + rec := getLogout(h, logoutQuery(hint, uri, "", ""), e.sessionCookie()) + if rec.Code != http.StatusOK { + t.Fatalf("uri = %s: status = %d, want 200(已登出頁), body = %s", uri, rec.Code, rec.Body.String()) + } + if loc := rec.Header().Get("Location"); loc != "" { + t.Fatalf("uri = %s: 不應重導,得到 Location = %s", uri, loc) + } + if !strings.Contains(rec.Body.String(), "您已登出") || !strings.Contains(rec.Body.String(), "未經應用程式註冊") { + t.Fatalf("uri = %s: 應顯示已登出頁與說明: %s", uri, rec.Body.String()) + } + if sessionAlive(t, e) { + t.Fatal("登出仍應執行") + } + // 下一輪以新 Session 測試(前輪已登出)。 + s, err := auth.CreateSession(e.db, e.user.ID) + if err != nil { + t.Fatal(err) + } + e.session = s + } +} + +func TestRPLogoutInvalidHintRejected(t *testing.T) { + e := newLogoutEnv(t) + h := oidc.LogoutHandler(e.db, testIssuer) + + // 以未註冊於本服務的金鑰簽署:kid 查無 → hint 無效(§2 OP MUST 驗證 + // 為本 OP 簽發;§4 錯誤時 MUST 不重導)。 + other := mustNewKey(t, false) + forged := forgeJWT(t, other, + map[string]string{"alg": "RS256", "kid": other.Kid, "typ": "JWT"}, + map[string]any{"iss": testIssuer, "sub": subjectOf(e.user.ID), "aud": e.app.ClientID}) + + rec := getLogout(h, logoutQuery(forged, postLogoutURI, "", ""), e.sessionCookie()) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String()) + } + if loc := rec.Header().Get("Location"); loc != "" { + t.Fatalf("錯誤時不應重導,得到 Location = %s", loc) + } + if !sessionAlive(t, e) { + t.Fatal("無效 hint 不應執行登出") + } + + // client_id 與 hint 的 aud 不符(§2 MUST 驗證相符)。 + hint := idTokenHint(t, e) + rec = getLogout(h, logoutQuery(hint, postLogoutURI, e.pub.ClientID, ""), e.sessionCookie()) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String()) + } + if !sessionAlive(t, e) { + t.Fatal("client_id 不符時不應執行登出") + } +} + +func TestRPLogoutIdempotentWithoutSession(t *testing.T) { + e := newLogoutEnv(t) + h := oidc.LogoutHandler(e.db, testIssuer) + hint := idTokenHint(t, e) + + // 無 Session:冪等完成,仍重導至已註冊的返回 URI。 + rec := getLogout(h, logoutQuery(hint, postLogoutURI, "", "s")) + if rec.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303, body = %s", rec.Code, rec.Body.String()) + } + if got := redirectLocation(t, rec).String(); got != postLogoutURI+"?state=s" { + t.Fatalf("Location = %q, want %q?state=s", got, postLogoutURI) + } + if !sessionCookieCleared(rec) { + t.Fatal("仍應清除(失效的)Session Cookie") + } +} + +func TestLogoutDirectVisit(t *testing.T) { + e := newLogoutEnv(t) + h := oidc.LogoutHandler(e.db, testIssuer) + + // 已登入直接造訪:無 hint,須先確認。 + rec := getLogout(h, "", e.sessionCookie()) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "您確定要登出嗎") { + t.Fatalf("已登入直接造訪應顯示確認頁, status = %d", rec.Code) + } + if !sessionAlive(t, e) { + t.Fatal("未確認前不應登出") + } + + // 未登入直接造訪:冪等,導向 /login(與既有登出行為一致)。 + rec = getLogout(h, "") + if rec.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303", rec.Code) + } + if loc := redirectLocation(t, rec).String(); loc != "/login" { + t.Fatalf("Location = %q, want /login", loc) + } +} + +func TestLogoutPostDelegatesLegacyBehavior(t *testing.T) { + e := newLogoutEnv(t) + h := oidc.LogoutHandler(e.db, testIssuer) + + // 側欄登出表單(僅 csrf_token):委由 auth.LogoutHandler,303 /login。 + rec := postLogoutForm(h, url.Values{"csrf_token": {"t"}}, + e.sessionCookie(), &http.Cookie{Name: auth.CSRFCookieName, Value: "t"}) + if rec.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303, body = %s", rec.Code, rec.Body.String()) + } + if loc := redirectLocation(t, rec).String(); loc != "/login" { + t.Fatalf("Location = %q, want /login", loc) + } + if sessionAlive(t, e) { + t.Fatal("表單登出應刪除 Session") + } + + // JSON API 登出:204。 + req := httptest.NewRequest(http.MethodPost, "/logout", nil) + req.Header.Set("Content-Type", "application/json") + rec2 := httptest.NewRecorder() + h(rec2, req) + if rec2.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204", rec2.Code) + } + if !sessionCookieCleared(rec2) { + t.Fatal("JSON 登出應清除 Session Cookie") + } + + // 不支援的 Content-Type:415(沿 auth.LogoutHandler 的檢查)。 + req = httptest.NewRequest(http.MethodPost, "/logout", strings.NewReader("x=1")) + req.Header.Set("Content-Type", "text/plain") + rec3 := httptest.NewRecorder() + h(rec3, req) + if rec3.Code != http.StatusUnsupportedMediaType { + t.Fatalf("status = %d, want 415", rec3.Code) + } +} + +func TestRPLogoutClientIDWithoutHintSoftFailsRedirect(t *testing.T) { + e := newLogoutEnv(t) + h := oidc.LogoutHandler(e.db, testIssuer) + + // 僅帶查無對應的 client_id:請求仍可進行(經確認頁),但不接受重導 + // ——無法確認返回網址的歸屬(RP-Initiated Logout 1.0 §3)。 + rec := getLogout(h, logoutQuery("", postLogoutURI, "no-such-client", ""), e.sessionCookie()) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "您確定要登出嗎") { + t.Fatalf("應顯示確認頁, status = %d, body = %s", rec.Code, rec.Body.String()) + } + csrf := csrfCookieOf(t, rec) + form := url.Values{ + "decision": {"logout"}, + "csrf_token": {csrf.Value}, + "client_id": {"no-such-client"}, + "post_logout_redirect_uri": {postLogoutURI}, + } + rec = postLogoutForm(h, form, e.sessionCookie(), csrf) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200(已登出頁), body = %s", rec.Code, rec.Body.String()) + } + if loc := rec.Header().Get("Location"); loc != "" { + t.Fatalf("查無 client 不得重導,得到 Location = %s", loc) + } + if sessionAlive(t, e) { + t.Fatal("確認後應完成登出") + } +}