Files
2026-10-03 10:44:29 +08:00

348 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 (
"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)
}