This commit is contained in:
2026-10-03 12:37:38 +08:00
parent bcf3d3769c
commit 5fc1755c17
22 changed files with 1845 additions and 100 deletions
+2
View File
@@ -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"},
+17 -7
View File
@@ -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")
+360
View File
@@ -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
}
+441
View File
@@ -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("確認後應完成登出")
}
}