forked from alterminal/alterminal
348 lines
13 KiB
Go
348 lines
13 KiB
Go
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)
|
||
}
|