Files
2026-10-03 12:37:38 +08:00

361 lines
13 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}