forked from alterminal/alterminal
fixup
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user