first commit

This commit is contained in:
2026-10-03 10:44:29 +08:00
parent f373cb8d37
commit bcf3d3769c
58 changed files with 4313 additions and 487 deletions
+347
View File
@@ -0,0 +1,347 @@
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)
}