package oidc import ( "errors" "fmt" "log" "net/http" "net/url" "strings" "gorm.io/gorm" "alterminal/internal/application" "alterminal/internal/auth" ) // scopeDescriptions 為同意頁上各 scope 的人類可讀說明。 var scopeDescriptions = map[string]string{ "openid": "確認您的身分(取得登入狀態)", "profile": "讀取您的顯示名稱與帳號", "email": "讀取您的電子郵件地址", "offline_access": "您離線時持續存取(換發新權杖)", } // authorizeRequest 為 /authorize 的請求參數(RFC 6749 §4.1.1 與 OIDC // Core §3.1.2.1 的授權請求參數;GET query 與同意表單 POST 共用)。 type authorizeRequest struct { ResponseType string ClientID string RedirectURI string Scope string State string Nonce string CodeChallenge string CodeChallengeMethod string } // authorizeRequestFromValues 由 query 或表單值還原請求參數。 func authorizeRequestFromValues(v url.Values) authorizeRequest { return authorizeRequest{ ResponseType: v.Get("response_type"), ClientID: v.Get("client_id"), RedirectURI: v.Get("redirect_uri"), Scope: v.Get("scope"), State: v.Get("state"), Nonce: v.Get("nonce"), CodeChallenge: v.Get("code_challenge"), CodeChallengeMethod: v.Get("code_challenge_method"), } } // values 重建請求的原始參數(同意表單的隱藏欄位與登入後返回時使用)。 func (req authorizeRequest) values() url.Values { v := url.Values{} set := func(k, s string) { if s != "" { v.Set(k, s) } } set("response_type", req.ResponseType) set("client_id", req.ClientID) set("redirect_uri", req.RedirectURI) set("scope", req.Scope) set("state", req.State) set("nonce", req.Nonce) set("code_challenge", req.CodeChallenge) set("code_challenge_method", req.CodeChallengeMethod) return v } // query 回傳重建的授權請求 query 字串(不含 ?)。 func (req authorizeRequest) query() string { return req.values().Encode() } // redirectError 為可安全重導回 redirect_uri 的授權請求錯誤(RFC 6749 // §4.1.2.1:凡 client_id 與 redirect_uri 可確認者,錯誤以重導回傳)。 type redirectError struct { Code string Description string } // validateAuthorizeRequest 驗證授權請求並載入應用程式註冊資料。驗證 // 順序刻意安排:client_id 與 redirect_uri 無法確認時呼叫方必須直接 // 顯示錯誤頁、不得重導(RFC 6749 §4.1.2.1,防止授權請求做為開放 // 重導向器);redirect_uri 通過精確比對(§3.1.2.3,字串相等不正规化) // 後,其餘錯誤才以 redirectError 重導回 RP。 func validateAuthorizeRequest(db *gorm.DB, req authorizeRequest) (*application.Application, *redirectError, error) { app, err := application.GetByClientID(db, req.ClientID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil, fmt.Errorf("未知或不存在的 client_id %q", req.ClientID) } return nil, nil, err } if !app.RedirectURIs.Contains(req.RedirectURI) { return nil, nil, fmt.Errorf("redirect_uri 未註冊於 client_id %s", req.ClientID) } if req.ResponseType != "code" { return app, &redirectError{Code: "unsupported_response_type", Description: "僅支援 response_type=code"}, nil } if !app.GrantTypes.Contains(application.GrantAuthorizationCode) { return app, &redirectError{Code: "unauthorized_client", Description: "應用程式未啟用授權碼流程"}, nil } // scope:必含 openid(OIDC Core §3.1.2.1),且每個請求的 scope 皆 // 鈙於應用程式註冊範圍。 if !scopeHas(req.Scope, "openid") { return app, &redirectError{Code: "invalid_scope", Description: "scope 必須包含 openid"}, nil } for _, s := range strings.Fields(req.Scope) { if !scopeHas(app.Scope, s) { return app, &redirectError{Code: "invalid_scope", Description: "scope " + s + " 未授權此應用程式"}, nil } } // PKCE(RFC 7636 §4.2、§4.3):code_challenge_method 僅允許 S256 // (plain 不安全,本服務不接受,亦不採規格的 plain 預設——省略 // method 視同無效)。公開式 Client 無 client secret 可驗,PKCE 為 // 必要防護。 switch { case req.CodeChallengeMethod != "" && req.CodeChallengeMethod != "S256": return app, &redirectError{Code: "invalid_request", Description: "code_challenge_method 僅支援 S256"}, nil case req.CodeChallengeMethod == "S256" && req.CodeChallenge == "": return app, &redirectError{Code: "invalid_request", Description: "code_challenge 不可為空"}, nil case app.IsPublic() && req.CodeChallenge == "": return app, &redirectError{Code: "invalid_request", Description: "公開式 Client 必須使用 PKCE"}, nil case req.CodeChallenge != "" && req.CodeChallengeMethod == "": return app, &redirectError{Code: "invalid_request", Description: "提供 code_challenge 時必須指定 code_challenge_method=S256"}, nil } return app, nil, nil } // AuthorizeHandler 處理 /authorize(RFC 6749 §4.1.1 授權碼流程的授權 // 端點):GET 驗證請求後依登入與同意狀態發碼或顯示同意頁,POST 處理 // 同意頁的決定。 func AuthorizeHandler(db *gorm.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: handleAuthorizeGet(db, w, r) case http.MethodPost: handleAuthorizePost(db, w, r) default: auth.WriteError(w, http.StatusMethodNotAllowed, "不支援的方法") } } } // handleAuthorizeGet 處理 GET /authorize。 func handleAuthorizeGet(db *gorm.DB, w http.ResponseWriter, r *http.Request) { req := authorizeRequestFromValues(r.URL.Query()) app, rerr, err := validateAuthorizeRequest(db, req) if !authorizeValidated(w, r, req, rerr, err) { return } s, ok := authorizeSession(db, w, r, req) if !ok { return } // 已同意的 scope 涵蓋本次請求時靜默通過,直接發碼;否則顯示同意頁。 c, err := GetConsent(db, s.UserID, app.ID) switch { case errors.Is(err, gorm.ErrRecordNotFound): // 首次授權,顯示同意頁 case err != nil: log.Printf("authorize: %v", err) http.Error(w, "內部錯誤", http.StatusInternalServerError) return case scopeCovered(c.Scope, req.Scope): issueCodeAndRedirect(db, w, r, req, app, s) return } renderConsentPage(w, r, http.StatusOK, req, app, s, "") } // handleAuthorizePost 處理 POST /authorize(同意頁決定)。 func handleAuthorizePost(db *gorm.DB, w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, "無法解析表單內容", http.StatusBadRequest) return } req := authorizeRequestFromValues(r.PostForm) app, rerr, err := validateAuthorizeRequest(db, req) if !authorizeValidated(w, r, req, rerr, err) { return } // POST 期間 Session 失效時,以原始參數重建 GET 回到授權流程開頭 // (會再導向登入頁),不直接渲染需要登入脈絡的同意頁。 s, ok := authorizeSession(db, w, r, req) if !ok { return } if !auth.VerifyCSRF(r) { renderConsentPage(w, r, http.StatusForbidden, req, app, s, "表單驗證失敗,請重新操作") return } switch r.PostFormValue("decision") { case "allow": if err := SaveConsent(db, s.UserID, app.ID, req.Scope); err != nil { log.Printf("authorize: %v", err) http.Error(w, "內部錯誤", http.StatusInternalServerError) return } issueCodeAndRedirect(db, w, r, req, app, s) case "deny": // 使用者拒絕授權(RFC 6749 §4.1.2.1 access_denied)。 redirectAuthorizeError(w, r, req, "access_denied", "使用者拒絕授權") default: renderConsentPage(w, r, http.StatusBadRequest, req, app, s, "請選擇同意或拒絕") } } // authorizeValidated 統一處理驗證結果:無法確認 client/redirect_uri 的 // 錯誤直接顯示 400 錯誤頁(不重導);可重導的錯誤回到 redirect_uri。 // 回傳是否繼續後續流程。 func authorizeValidated(w http.ResponseWriter, r *http.Request, req authorizeRequest, rerr *redirectError, err error) bool { if err != nil { log.Printf("authorize: %v", err) http.Error(w, "授權請求無效:"+err.Error(), http.StatusBadRequest) return false } if rerr != nil { redirectAuthorizeError(w, r, req, rerr.Code, rerr.Description) return false } return true } // authorizeSession 檢查使用者 Session:有效回傳 (session, true);未登入 // 時 303 導向 /login?next=<完整授權請求 URL> 後回傳 (nil, false);查詢 // 錯誤回 500。POST 同意表單時改為 303 導回重建的 GET /authorize, // 讓流程重新從登入檢查開始。 func authorizeSession(db *gorm.DB, w http.ResponseWriter, r *http.Request, req authorizeRequest) (*auth.Session, bool) { c, err := r.Cookie(auth.CookieName) if errors.Is(err, http.ErrNoCookie) { authorizeLoginRedirect(w, r, req) return nil, false } s, err := auth.GetSession(db, c.Value) if errors.Is(err, auth.ErrSessionExpired) { authorizeLoginRedirect(w, r, req) return nil, false } if err != nil { log.Printf("authorize: %v", err) http.Error(w, "內部錯誤", http.StatusInternalServerError) return nil, false } return s, true } // authorizeLoginRedirect 依請求方法把使用者送往登入頁:GET 直接以原始 // URI 為 next;POST 以表單參數重建 query,讓登入後回到等效的 GET。 func authorizeLoginRedirect(w http.ResponseWriter, r *http.Request, req authorizeRequest) { next := "/authorize?" + req.query() if r.Method == http.MethodGet { next = r.URL.RequestURI() } http.Redirect(w, r, "/login?next="+url.QueryEscape(next), http.StatusSeeOther) } // consentPageData 為同意頁的模板資料。Params 保存原始授權請求參數, // 模板以隱藏欄位逐項帶回 POST /authorize。IsAdmin/Username/Email/CSRF // 供 layout 側欄版面使用(與其他已登入頁面一致)。 type consentPageData struct { Error string Username string Email string IsAdmin bool CSRF string AppName string Scopes []scopeItem Params url.Values } // scopeItem 為同意頁清單中的單一 scope 及其說明。 type scopeItem struct { Scope string Description string } // renderConsentPage 輸出授權同意頁;每次輸出都輪替 CSRF token。 func renderConsentPage(w http.ResponseWriter, r *http.Request, status int, req authorizeRequest, app *application.Application, s *auth.Session, errMsg string) { token, err := auth.NewCSRFToken(w, r) if err != nil { log.Printf("csrf token: %v", err) http.Error(w, "內部錯誤", http.StatusInternalServerError) return } scopes := make([]scopeItem, 0, 4) for _, s := range normalizeScope(req.Scope) { scopes = append(scopes, scopeItem{Scope: s, Description: scopeDescriptions[s]}) } auth.RenderHTML(w, status, auth.ConsentTmpl, consentPageData{ Error: errMsg, Username: s.User.Username, Email: s.User.Email, IsAdmin: s.User.Role == auth.RoleAdmin, CSRF: token, AppName: app.Name, Scopes: scopes, Params: req.values(), }) } // issueCodeAndRedirect 產生授權碼並 302 重導回 redirect_uri(附加 code // 與原 state;RFC 6749 §4.1.2 與 §3.1.2 的回呼格式)。 func issueCodeAndRedirect(db *gorm.DB, w http.ResponseWriter, r *http.Request, req authorizeRequest, app *application.Application, s *auth.Session) { _, code, err := NewAuthorizationCode(db, app.ID, s.UserID, req.RedirectURI, strings.Join(normalizeScope(req.Scope), " "), req.Nonce, req.CodeChallenge, req.CodeChallengeMethod, s.CreatedAt) if err != nil { log.Printf("authorize: %v", err) http.Error(w, "內部錯誤", http.StatusInternalServerError) return } redirectWithParams(w, r, req, "code", code) } // redirectAuthorizeError 以 302 將錯誤重導回 redirect_uri(RFC 6749 // §4.1.2.1:error、error_description 與原 state)。 func redirectAuthorizeError(w http.ResponseWriter, r *http.Request, req authorizeRequest, code, description string) { redirectWithParams(w, r, req, "error", code, "error_description", description) } // redirectWithParams 在 redirect_uri 既有 query 之外附加 key/value 對 // (值成對出現:key1, val1, key2, val2),state 非空時一併回填,最後 // 302 重導。 func redirectWithParams(w http.ResponseWriter, r *http.Request, req authorizeRequest, kv ...string) { u, err := url.Parse(req.RedirectURI) if err != nil { log.Printf("authorize: 解析 redirect_uri: %v", err) http.Error(w, "內部錯誤", http.StatusInternalServerError) return } q := u.Query() for i := 0; i+1 < len(kv); i += 2 { q.Set(kv[i], kv[i+1]) } if req.State != "" { q.Set("state", req.State) } u.RawQuery = q.Encode() http.Redirect(w, r, u.String(), http.StatusFound) }