Files
easyai-ai-gateway/apps/api/internal/oidcsession/login_transaction.go
T
chengcheng c0296dbf06 fix(identity): 支持平台用户显式登录 AI Gateway
修复多租户身份配置将所有人类登录都强制解释为租户上下文的问题。Web 现在提供平台与租户两个受控入口,API 严格绑定 context_type、tid、issuer、application 和 subject,并为平台用户建立独立本地投影与可刷新会话。\n\n风险:新增会话身份列保持旧会话可读,新会话一律使用严格约束;未改变租户数据隔离和 API Key 行为。\n\n验证:Go 全量测试与 go vet 通过;PostgreSQL 平台投影、会话和安全事件集成测试通过;前端 lint、141 项测试与生产 build 通过;OpenAPI 生成和迁移安全测试通过。
2026-07-31 14:24:40 +08:00

116 lines
3.9 KiB
Go

package oidcsession
import (
"crypto/rand"
"encoding/base64"
"errors"
"net/url"
"strings"
"time"
"github.com/google/uuid"
)
const LoginTransactionCookieName = "easyai_gateway_oidc_login"
var loginTransactionAAD = []byte("easyai-gateway/oidc-login-transaction/v1")
type LoginTransaction struct {
State string `json:"state"`
Nonce string `json:"nonce"`
PKCEVerifier string `json:"pkceVerifier"`
ReturnTo string `json:"returnTo"`
ContextType string `json:"contextType"`
TenantHint string `json:"tenantHint,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
func NewLoginTransaction(returnTo string, now time.Time) (LoginTransaction, error) {
return NewLoginTransactionWithContext(returnTo, "tenant", "", now)
}
func NewLoginTransactionWithTenantHint(returnTo, tenantHint string, now time.Time) (LoginTransaction, error) {
return NewLoginTransactionWithContext(returnTo, "tenant", tenantHint, now)
}
func NewLoginTransactionWithContext(
returnTo, contextType, tenantHint string,
now time.Time,
) (LoginTransaction, error) {
if !ValidReturnTo(returnTo) {
return LoginTransaction{}, errors.New("returnTo must be a same-origin relative path")
}
contextType = strings.TrimSpace(contextType)
tenantHint = strings.TrimSpace(tenantHint)
if contextType != "platform" && contextType != "tenant" {
return LoginTransaction{}, errors.New("contextType must be platform or tenant")
}
if contextType == "platform" && tenantHint != "" {
return LoginTransaction{}, errors.New("platform context cannot bind a tenant hint")
}
if tenantHint != "" && uuid.Validate(tenantHint) != nil {
return LoginTransaction{}, errors.New("tenantHint must be a UUID")
}
state, err := randomBase64URL(32)
if err != nil {
return LoginTransaction{}, err
}
nonce, err := randomBase64URL(32)
if err != nil {
return LoginTransaction{}, err
}
verifier, err := randomBase64URL(32)
if err != nil {
return LoginTransaction{}, err
}
return LoginTransaction{
State: state, Nonce: nonce, PKCEVerifier: verifier,
ReturnTo: returnTo, ContextType: contextType,
TenantHint: tenantHint, CreatedAt: now.UTC(),
}, nil
}
func (c *Cipher) EncodeLoginTransaction(transaction LoginTransaction) (string, error) {
payload, err := c.SealJSON(transaction, loginTransactionAAD)
if err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(payload), nil
}
func (c *Cipher) DecodeLoginTransaction(encoded string, now time.Time) (LoginTransaction, error) {
payload, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(encoded))
if err != nil {
return LoginTransaction{}, errors.New("OIDC login transaction is invalid")
}
var transaction LoginTransaction
if err := c.OpenJSON(payload, loginTransactionAAD, &transaction); err != nil {
return LoginTransaction{}, err
}
if transaction.State == "" || transaction.Nonce == "" || transaction.PKCEVerifier == "" || !ValidReturnTo(transaction.ReturnTo) ||
transaction.ContextType != "platform" && transaction.ContextType != "tenant" ||
transaction.ContextType == "platform" && transaction.TenantHint != "" ||
transaction.TenantHint != "" && uuid.Validate(transaction.TenantHint) != nil ||
transaction.CreatedAt.IsZero() || now.Before(transaction.CreatedAt.Add(-time.Minute)) || !now.Before(transaction.CreatedAt.Add(10*time.Minute)) {
return LoginTransaction{}, errors.New("OIDC login transaction has expired or is invalid")
}
return transaction, nil
}
func ValidReturnTo(value string) bool {
value = strings.TrimSpace(value)
if value == "" || !strings.HasPrefix(value, "/") || strings.HasPrefix(value, "//") || strings.Contains(value, "\\") {
return false
}
parsed, err := url.Parse(value)
return err == nil && !parsed.IsAbs() && parsed.Host == ""
}
func randomBase64URL(size int) (string, error) {
value := make([]byte, size)
if _, err := rand.Read(value); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(value), nil
}