AI Gateway Go 0.10.0 源码快照 + 旗舰版需求规划报告

M0-M7 已完成:核心网关(身份/RBAC/TOTP/OIDC/SAML/Provider/配额/路由/内容策略/审计/定价)+ 资源市场(MCP/Skills/数字员工)。
含 22 个 PostgreSQL 迁移、管理端/门户端前端源码、OpenAPI 契约、部署 compose。

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
ben
2026-08-12 11:45:54 +08:00
commit 5759c1862e
807 changed files with 114727 additions and 0 deletions
+128
View File
@@ -0,0 +1,128 @@
package identity
import (
"errors"
"sort"
"time"
)
var (
ErrNotFound = errors.New("identity not found")
ErrUnavailable = errors.New("identity service unavailable")
)
type Kind string
const (
KindAdmin Kind = "admin"
KindPortal Kind = "portal"
)
type Account struct {
ID string
Kind Kind
Login string
DisplayName string
Role string
Permissions []string
PasswordHash string
AuthSource string
Active bool
FailedLogins int
LockedUntil *time.Time
TOTPEnabled bool
EncryptedTOTPSecret []byte
TOTPKekVersion *int
TOTPLastStep *int64
TOTPBackupCodes []byte
DepartmentID *string
DepartmentName string
CreatedAt time.Time
UpdatedAt time.Time
}
const (
PermissionIdentityManage = "identity:manage"
PermissionProviderRead = "provider:read"
PermissionProviderManage = "provider:manage"
PermissionAPIKeyRead = "api_key:read"
PermissionAPIKeyManage = "api_key:manage"
PermissionAuditRead = "audit:read"
PermissionUsageRead = "usage:read"
PermissionOutboxRead = "outbox:read"
PermissionOutboxManage = "outbox:manage"
PermissionContentPolicyRead = "content_policy:read"
PermissionContentPolicyManage = "content_policy:manage"
PermissionPricingRead = "pricing:read"
PermissionPricingManage = "pricing:manage"
PermissionPromptRead = "prompt:read"
PermissionPromptManage = "prompt:manage"
PermissionKnowledgeRead = "knowledge:read"
PermissionKnowledgeManage = "knowledge:manage"
PermissionToolRead = "tool:read"
PermissionToolManage = "tool:manage"
PermissionApplicationRead = "application:read"
PermissionApplicationManage = "application:manage"
PermissionNotificationRead = "notification:read"
PermissionNotificationManage = "notification:manage"
PermissionMCPServerRead = "mcp_server:read"
PermissionMCPServerManage = "mcp_server:manage"
PermissionSkillRead = "skill:read"
PermissionSkillManage = "skill:manage"
PermissionDigitalEmployeeRead = "digital_employee:read"
PermissionDigitalEmployeeManage = "digital_employee:manage"
PermissionMarketplaceRead = "marketplace:read"
PermissionMarketplaceManage = "marketplace:manage"
)
var rolePermissions = map[string][]string{
"superadmin": {"*"},
"operator": {
PermissionProviderRead, PermissionProviderManage,
PermissionAPIKeyRead, PermissionAPIKeyManage,
PermissionUsageRead,
PermissionOutboxRead, PermissionOutboxManage,
PermissionContentPolicyRead, PermissionContentPolicyManage,
PermissionPricingRead, PermissionPricingManage,
PermissionPromptRead, PermissionPromptManage,
PermissionKnowledgeRead, PermissionKnowledgeManage,
PermissionToolRead, PermissionToolManage,
PermissionApplicationRead, PermissionApplicationManage,
PermissionNotificationRead, PermissionNotificationManage,
PermissionMCPServerRead, PermissionMCPServerManage,
PermissionSkillRead, PermissionSkillManage,
PermissionDigitalEmployeeRead, PermissionDigitalEmployeeManage,
PermissionMarketplaceRead, PermissionMarketplaceManage,
},
"auditor": {PermissionProviderRead, PermissionAPIKeyRead, PermissionAuditRead, PermissionUsageRead, PermissionOutboxRead, PermissionContentPolicyRead, PermissionPricingRead, PermissionPromptRead, PermissionKnowledgeRead, PermissionToolRead, PermissionApplicationRead, PermissionNotificationRead, PermissionMCPServerRead, PermissionSkillRead, PermissionDigitalEmployeeRead, PermissionMarketplaceRead},
"member": {},
}
func EffectivePermissions(account Account) []string {
seen := make(map[string]struct{})
result := make([]string, 0, len(account.Permissions)+4)
for _, permissions := range [][]string{rolePermissions[account.Role], account.Permissions} {
for _, permission := range permissions {
if _, exists := seen[permission]; exists {
continue
}
seen[permission] = struct{}{}
result = append(result, permission)
}
}
sort.Strings(result)
return result
}
func HasPermission(account Account, required string) bool {
for _, permission := range EffectivePermissions(account) {
if permission == "*" || permission == required {
return true
}
}
return false
}
func (a Account) Locked(now time.Time) bool {
return a.LockedUntil != nil && a.LockedUntil.After(now)
}
+297
View File
@@ -0,0 +1,297 @@
package identity
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"regexp"
"strings"
"time"
"aigateway.local/core/internal/platform/apiresponse"
platformid "aigateway.local/core/internal/platform/id"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
var (
ErrDepartmentConflict = errors.New("department already exists")
ErrDepartmentInUse = errors.New("department is in use")
ErrDepartmentCycle = errors.New("department hierarchy cycle")
departmentCodePattern = regexp.MustCompile(`^[a-z][a-z0-9_-]{1,63}$`)
)
type Department struct {
ID string `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
Description string `json:"description"`
ParentID *string `json:"parent_id"`
ParentName string `json:"parent_name,omitempty"`
Active bool `json:"active"`
UserCount int `json:"user_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type departmentInput struct {
Code string `json:"code"`
Name string `json:"name"`
Description string `json:"description"`
ParentID *string `json:"parent_id"`
Active *bool `json:"active"`
}
func (h *ManagementHTTPHandler) listDepartments(writer http.ResponseWriter, request *http.Request) {
if _, ok := h.requirePermission(writer, request); !ok {
return
}
departments, err := h.service.repository.ListDepartments(request.Context())
if err != nil {
h.writeDepartmentError(writer, err)
return
}
apiresponse.OK(writer, departments)
}
func (h *ManagementHTTPHandler) createDepartment(writer http.ResponseWriter, request *http.Request) {
actor, ok := h.requirePermission(writer, request)
if !ok {
return
}
input, department, ok := decodeDepartment(writer, request)
if !ok {
return
}
_ = input
created, err := h.service.repository.CreateDepartment(request.Context(), department, actor.ID)
if err != nil {
h.writeDepartmentError(writer, err)
return
}
apiresponse.OK(writer, created)
}
func (h *ManagementHTTPHandler) updateDepartment(writer http.ResponseWriter, request *http.Request) {
actor, ok := h.requirePermission(writer, request)
if !ok {
return
}
_, department, ok := decodeDepartment(writer, request)
if !ok {
return
}
department.ID = request.PathValue("department_id")
updated, err := h.service.repository.UpdateDepartment(request.Context(), department, actor.ID)
if err != nil {
h.writeDepartmentError(writer, err)
return
}
apiresponse.OK(writer, updated)
}
func decodeDepartment(writer http.ResponseWriter, request *http.Request) (departmentInput, Department, bool) {
var input departmentInput
decoder := json.NewDecoder(http.MaxBytesReader(writer, request.Body, 1<<20))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&input); err != nil {
apiresponse.Error(writer, http.StatusBadRequest, "请求格式无效")
return input, Department{}, false
}
input.Code = strings.ToLower(strings.TrimSpace(input.Code))
input.Name = strings.TrimSpace(input.Name)
input.Description = strings.TrimSpace(input.Description)
if !departmentCodePattern.MatchString(input.Code) || input.Name == "" || len(input.Name) > 128 || len(input.Description) > 1024 {
apiresponse.Error(writer, http.StatusBadRequest, "部门代码、名称或描述格式无效")
return input, Department{}, false
}
var parentID *string
if input.ParentID != nil && strings.TrimSpace(*input.ParentID) != "" {
value := strings.TrimSpace(*input.ParentID)
parentID = &value
}
active := true
if input.Active != nil {
active = *input.Active
}
return input, Department{Code: input.Code, Name: input.Name, Description: input.Description, ParentID: parentID, Active: active}, true
}
func (h *ManagementHTTPHandler) writeDepartmentError(writer http.ResponseWriter, err error) {
switch {
case errors.Is(err, ErrNotFound):
apiresponse.Error(writer, http.StatusNotFound, "部门不存在")
case errors.Is(err, ErrDepartmentConflict):
apiresponse.Error(writer, http.StatusConflict, "部门代码已存在")
case errors.Is(err, ErrDepartmentCycle):
apiresponse.Error(writer, http.StatusConflict, "部门层级不能形成循环")
case errors.Is(err, ErrDepartmentInUse):
apiresponse.Error(writer, http.StatusConflict, "部门仍包含启用用户或启用子部门,不能停用")
case errors.Is(err, ErrUnavailable):
apiresponse.Error(writer, http.StatusServiceUnavailable, "部门服务暂不可用")
default:
apiresponse.Error(writer, http.StatusBadRequest, "部门操作失败")
}
}
func (r *Repository) ListDepartments(ctx context.Context) ([]Department, error) {
if r.pool == nil {
return nil, ErrUnavailable
}
rows, err := r.pool.Query(ctx, `
SELECT d.id::text, d.code, d.name, d.description, d.parent_id::text,
COALESCE(p.name, ''), d.active,
count(u.id) FILTER (WHERE u.active), d.created_at, d.updated_at
FROM gateway.departments d
LEFT JOIN gateway.departments p ON p.id = d.parent_id
LEFT JOIN gateway.portal_users u ON u.department_id = d.id
GROUP BY d.id, p.name
ORDER BY d.code`)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
defer rows.Close()
departments := make([]Department, 0)
for rows.Next() {
var department Department
if err := rows.Scan(&department.ID, &department.Code, &department.Name, &department.Description,
&department.ParentID, &department.ParentName, &department.Active, &department.UserCount,
&department.CreatedAt, &department.UpdatedAt); err != nil {
return nil, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
departments = append(departments, department)
}
return departments, mapRepositoryError(rows.Err())
}
func (r *Repository) GetDepartment(ctx context.Context, id string) (Department, error) {
if r.pool == nil {
return Department{}, ErrUnavailable
}
var department Department
err := r.pool.QueryRow(ctx, `
SELECT id::text, code, name, description, parent_id::text, active, created_at, updated_at
FROM gateway.departments WHERE id = $1`, id).Scan(
&department.ID, &department.Code, &department.Name, &department.Description,
&department.ParentID, &department.Active, &department.CreatedAt, &department.UpdatedAt,
)
return department, mapRepositoryError(err)
}
func (r *Repository) CreateDepartment(ctx context.Context, department Department, actorID string) (Department, error) {
id, err := platformid.NewUUID()
if err != nil {
return Department{}, err
}
department.ID = id
return r.storeDepartment(ctx, department, actorID, true)
}
func (r *Repository) UpdateDepartment(ctx context.Context, department Department, actorID string) (Department, error) {
return r.storeDepartment(ctx, department, actorID, false)
}
func (r *Repository) storeDepartment(ctx context.Context, department Department, actorID string, creating bool) (Department, error) {
if r.pool == nil {
return Department{}, ErrUnavailable
}
tx, err := r.pool.Begin(ctx)
if err != nil {
return Department{}, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
defer func() { _ = tx.Rollback(ctx) }()
if department.ParentID != nil {
var parentActive bool
if err := tx.QueryRow(ctx, `SELECT active FROM gateway.departments WHERE id = $1`, *department.ParentID).Scan(&parentActive); err != nil {
return Department{}, mapRepositoryError(err)
}
if !parentActive {
return Department{}, ErrDepartmentInUse
}
}
if !creating && department.ParentID != nil {
var cycle bool
if err := tx.QueryRow(ctx, `
WITH RECURSIVE descendants AS (
SELECT id FROM gateway.departments WHERE parent_id = $1
UNION ALL
SELECT d.id FROM gateway.departments d JOIN descendants x ON d.parent_id = x.id
)
SELECT $2::uuid = $1::uuid OR EXISTS (SELECT 1 FROM descendants WHERE id = $2)`,
department.ID, *department.ParentID).Scan(&cycle); err != nil {
return Department{}, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
if cycle {
return Department{}, ErrDepartmentCycle
}
}
if !creating && !department.Active {
var inUse bool
if err := tx.QueryRow(ctx, `
SELECT EXISTS (SELECT 1 FROM gateway.portal_users WHERE department_id = $1 AND active)
OR EXISTS (SELECT 1 FROM gateway.departments WHERE parent_id = $1 AND active)`, department.ID).Scan(&inUse); err != nil {
return Department{}, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
if inUse {
return Department{}, ErrDepartmentInUse
}
}
if creating {
err = tx.QueryRow(ctx, `
INSERT INTO gateway.departments (id, code, name, description, parent_id, active)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING created_at, updated_at`, department.ID, department.Code, department.Name,
department.Description, department.ParentID, department.Active).Scan(&department.CreatedAt, &department.UpdatedAt)
} else {
err = tx.QueryRow(ctx, `
UPDATE gateway.departments
SET code = $2, name = $3, description = $4, parent_id = $5,
active = $6, updated_at = clock_timestamp()
WHERE id = $1
RETURNING created_at, updated_at`, department.ID, department.Code, department.Name,
department.Description, department.ParentID, department.Active).Scan(&department.CreatedAt, &department.UpdatedAt)
}
if err != nil {
return Department{}, mapDepartmentError(err)
}
eventID, err := platformid.NewUUID()
if err != nil {
return Department{}, err
}
eventType := "department.updated"
if creating {
eventType = "department.created"
}
payload, _ := json.Marshal(map[string]any{"department_id": department.ID, "code": department.Code, "actor_id": actorID})
if _, err := tx.Exec(ctx, `
INSERT INTO gateway.outbox_events
(event_id, event_type, event_version, aggregate_type, aggregate_id, payload)
VALUES ($1, $2, 1, 'department', $3, $4)`, eventID, eventType, department.ID, payload); err != nil {
return Department{}, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
if err := tx.Commit(ctx); err != nil {
return Department{}, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
return department, nil
}
func mapDepartmentError(err error) error {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
var pgError *pgconn.PgError
if errors.As(err, &pgError) {
switch pgError.Code {
case "23505":
return ErrDepartmentConflict
case "23503", "23514":
return ErrDepartmentCycle
}
}
if err != nil {
return fmt.Errorf("%w: %v", ErrUnavailable, err)
}
return nil
}
+429
View File
@@ -0,0 +1,429 @@
package identity
import (
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"aigateway.local/core/internal/platform/apiresponse"
"aigateway.local/core/internal/platform/cryptox"
)
type HTTPHandler struct {
service *Service
mux *http.ServeMux
}
type loginRequest struct {
UserName string `json:"userName"`
Username string `json:"username"`
Account string `json:"account"`
Password string `json:"password"`
}
type totpLoginRequest struct {
TempToken string `json:"temp_token"`
Code string `json:"code"`
BackupCode string `json:"backup_code"`
}
type passwordRequest struct {
Password string `json:"password"`
}
type factorRequest struct {
Password string `json:"password"`
Code string `json:"code"`
BackupCode string `json:"backup_code"`
}
func NewHTTPHandler(service *Service) *HTTPHandler {
handler := &HTTPHandler{service: service, mux: http.NewServeMux()}
handler.mux.HandleFunc("POST /api/v1/admin/login", handler.login(KindAdmin))
handler.registerTOTP(KindAdmin, "/api/v1/admin")
handler.mux.HandleFunc("GET /api/v1/admin/whoami", handler.whoami(KindAdmin))
handler.mux.HandleFunc("POST /api/v1/admin/password", handler.changePassword(KindAdmin))
handler.mux.HandleFunc("POST /api/v1/admin/logout", handler.logout)
handler.mux.HandleFunc("GET /api/v1/admin/menus", handler.menus(KindAdmin))
handler.mux.HandleFunc("POST /api/v1/portal/login", handler.login(KindPortal))
handler.registerTOTP(KindPortal, "/api/v1/portal")
handler.mux.HandleFunc("GET /api/v1/portal/me", handler.whoami(KindPortal))
handler.mux.HandleFunc("POST /api/v1/portal/logout", handler.logout)
handler.mux.HandleFunc("GET /api/v1/portal/menus", handler.menus(KindPortal))
handler.registerOIDC()
return handler
}
func (h *HTTPHandler) changePassword(kind Kind) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
account, ok := h.requireAccount(writer, request, kind)
if !ok {
return
}
var input struct {
OldPassword string `json:"old_password"`
NewPassword string `json:"new_password"`
}
if !decodeJSON(writer, request, &input) {
apiresponse.Error(writer, http.StatusBadRequest, "请求格式无效")
return
}
if err := h.service.ChangePassword(request.Context(), account, input.OldPassword, input.NewPassword); err != nil {
h.writeIdentityError(writer, err)
return
}
apiresponse.OK(writer, map[string]bool{"changed": true})
}
}
func (h *HTTPHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
h.mux.ServeHTTP(writer, request)
}
func (h *HTTPHandler) login(kind Kind) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
// 防爆破:按 IP 的滑动窗口限流,超限返回 429(与账号锁定叠加)。
if !h.service.AllowLogin(request.Context(), ClientIP(request)) {
apiresponse.Error(writer, http.StatusTooManyRequests, "登录尝试过于频繁,请稍后再试")
return
}
var input loginRequest
decoder := json.NewDecoder(http.MaxBytesReader(writer, request.Body, 1<<20))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&input); err != nil {
apiresponse.Error(writer, http.StatusBadRequest, "请求格式无效")
return
}
login := strings.TrimSpace(input.UserName)
if login == "" {
login = strings.TrimSpace(input.Username)
}
if login == "" {
login = strings.TrimSpace(input.Account)
}
if login == "" || len(login) > 128 || len(input.Password) < 1 || len(input.Password) > 1024 {
apiresponse.Error(writer, http.StatusBadRequest, "账号或口令格式无效")
return
}
result, err := h.service.Login(request.Context(), kind, login, input.Password)
if err != nil {
h.writeIdentityError(writer, err)
return
}
apiresponse.OK(writer, map[string]any{
"token": result.Token, "refreshToken": "", "require_totp": result.RequireTOTP,
"temp_token": result.TempToken,
})
}
}
func (h *HTTPHandler) registerTOTP(kind Kind, prefix string) {
h.mux.HandleFunc("POST "+prefix+"/login/totp", h.completeTOTPLogin(kind))
h.mux.HandleFunc("GET "+prefix+"/totp/status", h.totpStatus(kind))
h.mux.HandleFunc("POST "+prefix+"/totp/setup", h.setupTOTP(kind))
h.mux.HandleFunc("POST "+prefix+"/totp/confirm", h.confirmTOTP(kind))
h.mux.HandleFunc("POST "+prefix+"/totp/disable", h.disableTOTP(kind))
h.mux.HandleFunc("POST "+prefix+"/totp/backup-codes/regenerate", h.regenerateBackupCodes(kind))
}
func (h *HTTPHandler) completeTOTPLogin(kind Kind) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
// 防爆破:TOTP 完成端点同样按 IP 限流。
if !h.service.AllowLogin(request.Context(), ClientIP(request)) {
apiresponse.Error(writer, http.StatusTooManyRequests, "登录尝试过于频繁,请稍后再试")
return
}
var input totpLoginRequest
if !decodeJSON(writer, request, &input) || strings.TrimSpace(input.TempToken) == "" || (strings.TrimSpace(input.Code) == "" && strings.TrimSpace(input.BackupCode) == "") {
apiresponse.Error(writer, http.StatusBadRequest, "请输入动态验证码或备用码")
return
}
result, err := h.service.CompleteTOTPLogin(request.Context(), kind, input.TempToken, input.Code, input.BackupCode)
if err != nil {
h.writeIdentityError(writer, err)
return
}
apiresponse.OK(writer, map[string]any{"token": result.Token, "refreshToken": "", "require_totp": false})
}
}
func (h *HTTPHandler) totpStatus(kind Kind) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
account, ok := h.requireAccount(writer, request, kind)
if !ok {
return
}
apiresponse.OK(writer, map[string]bool{"enabled": account.TOTPEnabled, "setup_pending": !account.TOTPEnabled && len(account.EncryptedTOTPSecret) > 0})
}
}
func (h *HTTPHandler) setupTOTP(kind Kind) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
account, ok := h.requireAccount(writer, request, kind)
if !ok {
return
}
var input passwordRequest
if !decodeJSON(writer, request, &input) || input.Password == "" {
apiresponse.Error(writer, http.StatusBadRequest, "请输入当前口令")
return
}
result, err := h.service.SetupTOTP(request.Context(), account, input.Password)
if err != nil {
h.writeIdentityError(writer, err)
return
}
apiresponse.OK(writer, map[string]string{"secret": result.Secret, "provisioning_uri": result.ProvisioningURI})
}
}
func (h *HTTPHandler) confirmTOTP(kind Kind) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
account, ok := h.requireAccount(writer, request, kind)
if !ok {
return
}
var input factorRequest
if !decodeJSON(writer, request, &input) || strings.TrimSpace(input.Code) == "" {
apiresponse.Error(writer, http.StatusBadRequest, "请输入动态验证码")
return
}
codes, err := h.service.ConfirmTOTP(request.Context(), account, input.Code)
if err != nil {
h.writeIdentityError(writer, err)
return
}
apiresponse.OK(writer, map[string]any{"enabled": true, "backup_codes": codes})
}
}
func (h *HTTPHandler) disableTOTP(kind Kind) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
account, ok := h.requireAccount(writer, request, kind)
if !ok {
return
}
var input factorRequest
if !decodeJSON(writer, request, &input) || input.Password == "" {
apiresponse.Error(writer, http.StatusBadRequest, "当前口令和验证因子不能为空")
return
}
if err := h.service.DisableTOTP(request.Context(), account, input.Password, input.Code, input.BackupCode); err != nil {
h.writeIdentityError(writer, err)
return
}
apiresponse.OK(writer, map[string]bool{"enabled": false})
}
}
func (h *HTTPHandler) regenerateBackupCodes(kind Kind) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
account, ok := h.requireAccount(writer, request, kind)
if !ok {
return
}
var input factorRequest
if !decodeJSON(writer, request, &input) || input.Password == "" {
apiresponse.Error(writer, http.StatusBadRequest, "当前口令和验证因子不能为空")
return
}
codes, err := h.service.RegenerateBackupCodes(request.Context(), account, input.Password, input.Code, input.BackupCode)
if err != nil {
h.writeIdentityError(writer, err)
return
}
apiresponse.OK(writer, map[string]any{"backup_codes": codes})
}
}
func (h *HTTPHandler) requireAccount(writer http.ResponseWriter, request *http.Request, kind Kind) (Account, bool) {
account, err := h.service.Authenticate(request.Context(), kind, request.Header.Get("Authorization"))
if err != nil {
h.writeIdentityError(writer, err)
return Account{}, false
}
return account, true
}
func decodeJSON(writer http.ResponseWriter, request *http.Request, target any) bool {
decoder := json.NewDecoder(http.MaxBytesReader(writer, request.Body, 1<<20))
decoder.DisallowUnknownFields()
return decoder.Decode(target) == nil
}
func (h *HTTPHandler) whoami(kind Kind) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
account, err := h.service.Authenticate(request.Context(), kind, request.Header.Get("Authorization"))
if err != nil {
h.writeIdentityError(writer, err)
return
}
roles := []string{"R_USER"}
if kind == KindAdmin {
roles = []string{"R_ADMIN"}
if account.Role == "superadmin" {
roles = []string{"R_SUPER"}
}
}
permissions := EffectivePermissions(account)
apiresponse.OK(writer, map[string]any{
"userId": account.ID, "userName": account.Login,
"displayName": account.DisplayName, "email": "",
"roles": roles, "buttons": permissions, "permissions": permissions, "role": account.Role,
})
}
}
func (h *HTTPHandler) logout(writer http.ResponseWriter, request *http.Request) {
if err := h.service.Logout(request.Context(), request.Header.Get("Authorization")); err != nil && !errors.Is(err, ErrInvalidSession) {
h.writeIdentityError(writer, err)
return
}
apiresponse.OK(writer, map[string]bool{"ok": true})
}
func (h *HTTPHandler) menus(kind Kind) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
account, err := h.service.Authenticate(request.Context(), kind, request.Header.Get("Authorization"))
if err != nil {
h.writeIdentityError(writer, err)
return
}
if kind == KindAdmin {
apiresponse.OK(writer, adminMenus(account))
return
}
apiresponse.OK(writer, portalMenus())
}
}
func (h *HTTPHandler) writeIdentityError(writer http.ResponseWriter, err error) {
var locked LockedError
switch {
case errors.As(err, &locked):
minutes := int(time.Until(locked.Until).Minutes()) + 1
apiresponse.Error(writer, http.StatusTooManyRequests, "账号已锁定,请在 "+(time.Duration(minutes)*time.Minute).String()+" 后重试")
case errors.Is(err, ErrInvalidCredentials):
apiresponse.Error(writer, http.StatusUnauthorized, "账号或口令错误")
case errors.Is(err, ErrInvalidSession), errors.Is(err, ErrNotFound):
apiresponse.Error(writer, http.StatusUnauthorized, "登录状态无效或已过期")
case errors.Is(err, ErrAccountDisabled):
apiresponse.Error(writer, http.StatusForbidden, "账号已被停用")
case errors.Is(err, ErrInvalidTOTP):
apiresponse.Error(writer, http.StatusUnauthorized, "动态验证码无效、已使用或备用码无效")
case errors.Is(err, ErrTOTPAlreadyEnabled):
apiresponse.Error(writer, http.StatusConflict, "两步验证已经启用")
case errors.Is(err, ErrTOTPNotEnabled), errors.Is(err, ErrTOTPSetupRequired):
apiresponse.Error(writer, http.StatusConflict, "两步验证尚未完成配置")
case errors.Is(err, cryptox.ErrKeyUnavailable):
apiresponse.Error(writer, http.StatusServiceUnavailable, "两步验证加密密钥不可用")
case errors.Is(err, ErrUnavailable):
apiresponse.Error(writer, http.StatusServiceUnavailable, "身份服务暂不可用")
default:
apiresponse.Error(writer, http.StatusInternalServerError, "身份服务处理失败")
}
}
func adminMenus(account Account) []map[string]any {
// 运行概览:首页仪表盘(叶子菜单,避免出现「运行概览>运行概览」同级冗余)。
menus := []map[string]any{
{"name": "Dashboard", "path": "/dashboard/console", "component": "/dashboard/console", "meta": map[string]any{"title": "运行概览", "icon": "ri:pie-chart-line", "fixedTab": true}},
}
// 网关接入:上游供应商、路由与凭据。
gatewayChildren := make([]map[string]any, 0, 4)
if HasPermission(account, PermissionProviderRead) || HasPermission(account, PermissionProviderManage) {
gatewayChildren = append(gatewayChildren, map[string]any{"name": "Providers", "path": "providers", "component": "/gateway/providers", "meta": map[string]any{"title": "模型供应商"}})
gatewayChildren = append(gatewayChildren, map[string]any{"name": "ModelRoutes", "path": "model-routes", "component": "/gateway/model-routes", "meta": map[string]any{"title": "模型路由"}})
}
if HasPermission(account, PermissionAPIKeyRead) || HasPermission(account, PermissionAPIKeyManage) {
gatewayChildren = append(gatewayChildren, map[string]any{"name": "APIKeys", "path": "api-keys", "component": "/gateway/api-keys", "meta": map[string]any{"title": "API Key"}})
}
if HasPermission(account, PermissionPricingRead) || HasPermission(account, PermissionPricingManage) {
gatewayChildren = append(gatewayChildren, map[string]any{"name": "ModelPrices", "path": "model-prices", "component": "/gateway/model-prices", "meta": map[string]any{"title": "模型价格"}})
}
if len(gatewayChildren) > 0 {
menus = append(menus, map[string]any{"name": "Gateway", "path": "/gateway", "component": "/index/index", "meta": map[string]any{"title": "网关接入", "icon": "ri:router-line"}, "children": gatewayChildren})
}
// 安全与审计:审计用量、内容策略与模型治理。
securityChildren := make([]map[string]any, 0, 3)
if HasPermission(account, PermissionAuditRead) || HasPermission(account, PermissionUsageRead) {
securityChildren = append(securityChildren, map[string]any{"name": "AuditUsage", "path": "audit-usage", "component": "/gateway/audit-usage", "meta": map[string]any{"title": "审计与用量"}})
}
if HasPermission(account, PermissionContentPolicyRead) || HasPermission(account, PermissionContentPolicyManage) {
securityChildren = append(securityChildren, map[string]any{"name": "ContentPolicies", "path": "content-policies", "component": "/gateway/content-policies", "meta": map[string]any{"title": "内容策略"}})
}
if HasPermission(account, PermissionKnowledgeRead) || HasPermission(account, PermissionKnowledgeManage) {
securityChildren = append(securityChildren, map[string]any{"name": "Governance", "path": "governance", "component": "/gateway/governance", "meta": map[string]any{"title": "模型治理"}})
}
if len(securityChildren) > 0 {
menus = append(menus, map[string]any{"name": "Security", "path": "/security", "component": "/index/index", "meta": map[string]any{"title": "安全与审计", "icon": "ri:shield-check-line"}, "children": securityChildren})
}
// AI 资产:Prompt、知识库、工具与 AI 应用。
assetsChildren := make([]map[string]any, 0, 4)
if HasPermission(account, PermissionPromptRead) || HasPermission(account, PermissionPromptManage) {
assetsChildren = append(assetsChildren, map[string]any{"name": "Prompts", "path": "prompts", "component": "/gateway/prompts", "meta": map[string]any{"title": "Prompt 资产"}})
}
if HasPermission(account, PermissionKnowledgeRead) || HasPermission(account, PermissionKnowledgeManage) {
assetsChildren = append(assetsChildren, map[string]any{"name": "Knowledge", "path": "knowledge", "component": "/gateway/knowledge", "meta": map[string]any{"title": "知识库"}})
}
if HasPermission(account, PermissionToolRead) || HasPermission(account, PermissionToolManage) {
assetsChildren = append(assetsChildren, map[string]any{"name": "Tools", "path": "tools", "component": "/gateway/tools", "meta": map[string]any{"title": "工具中心"}})
}
if HasPermission(account, PermissionApplicationRead) || HasPermission(account, PermissionApplicationManage) {
assetsChildren = append(assetsChildren, map[string]any{"name": "Applications", "path": "applications", "component": "/gateway/applications", "meta": map[string]any{"title": "AI 应用"}})
}
if len(assetsChildren) > 0 {
menus = append(menus, map[string]any{"name": "Assets", "path": "/assets", "component": "/index/index", "meta": map[string]any{"title": "AI 资产", "icon": "ri:box-3-line"}, "children": assetsChildren})
}
// 资源市场:MCP 服务器、Skills 与数字员工(旗舰版资源市场)。
marketChildren := make([]map[string]any, 0, 4)
if HasPermission(account, PermissionMarketplaceRead) || HasPermission(account, PermissionMarketplaceManage) {
marketChildren = append(marketChildren, map[string]any{"name": "Marketplace", "path": "overview", "component": "/gateway/marketplace", "meta": map[string]any{"title": "市场总览"}})
}
if HasPermission(account, PermissionMCPServerRead) || HasPermission(account, PermissionMCPServerManage) {
marketChildren = append(marketChildren, map[string]any{"name": "MCPServers", "path": "mcp-servers", "component": "/gateway/mcp-servers", "meta": map[string]any{"title": "MCP 服务器"}})
}
if HasPermission(account, PermissionSkillRead) || HasPermission(account, PermissionSkillManage) {
marketChildren = append(marketChildren, map[string]any{"name": "Skills", "path": "skills", "component": "/gateway/skills", "meta": map[string]any{"title": "Skills 技能"}})
}
if HasPermission(account, PermissionDigitalEmployeeRead) || HasPermission(account, PermissionDigitalEmployeeManage) {
marketChildren = append(marketChildren, map[string]any{"name": "DigitalEmployees", "path": "digital-employees", "component": "/gateway/digital-employees", "meta": map[string]any{"title": "数字员工"}})
}
if len(marketChildren) > 0 {
menus = append(menus, map[string]any{"name": "ResourceMarket", "path": "/resource-market", "component": "/index/index", "meta": map[string]any{"title": "资源市场", "icon": "ri:store-3-line"}, "children": marketChildren})
}
// 系统管理:账号权限、事件投递与通知。
systemChildren := make([]map[string]any, 0, 3)
if HasPermission(account, PermissionIdentityManage) {
systemChildren = append(systemChildren, map[string]any{"name": "User", "path": "user", "component": "/system/user", "meta": map[string]any{"title": "账号与权限"}})
}
if HasPermission(account, PermissionOutboxRead) || HasPermission(account, PermissionOutboxManage) {
systemChildren = append(systemChildren, map[string]any{"name": "Outbox", "path": "outbox", "component": "/gateway/outbox", "meta": map[string]any{"title": "事件投递"}})
}
if HasPermission(account, PermissionNotificationRead) || HasPermission(account, PermissionNotificationManage) {
systemChildren = append(systemChildren, map[string]any{"name": "Notifications", "path": "notifications", "component": "/gateway/notifications", "meta": map[string]any{"title": "通知中心"}})
}
if len(systemChildren) > 0 {
menus = append(menus, map[string]any{"name": "System", "path": "/system", "component": "/index/index", "meta": map[string]any{"title": "系统管理", "icon": "ri:user-3-line"}, "children": systemChildren})
}
return menus
}
func portalMenus() []map[string]any {
return []map[string]any{
{"name": "Portal", "path": "/portal", "component": "/index/index", "meta": map[string]any{"title": "AI 工作台", "icon": "ri:sparkling-line"}, "children": []map[string]any{
{"name": "PortalCatalog", "path": "catalog", "component": "/portal/catalog", "meta": map[string]any{"title": "资产目录", "fixedTab": true}},
{"name": "PortalMarketplace", "path": "marketplace", "component": "/portal/marketplace", "meta": map[string]any{"title": "资源市场"}},
{"name": "PortalPrompts", "path": "prompts", "component": "/portal/prompts", "meta": map[string]any{"title": "Prompt 广场"}},
{"name": "PortalUsage", "path": "usage", "component": "/portal/usage", "meta": map[string]any{"title": "我的用量"}},
{"name": "PortalAccess", "path": "access", "component": "/portal/access", "meta": map[string]any{"title": "模型权限"}},
}},
}
}
+415
View File
@@ -0,0 +1,415 @@
package identity
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"regexp"
"sort"
"strings"
"aigateway.local/core/internal/platform/apiresponse"
platformid "aigateway.local/core/internal/platform/id"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
var (
ErrIdentityConflict = errors.New("identity already exists")
permissionPattern = regexp.MustCompile(`^[a-z][a-z0-9_.:-]{2,127}$`)
)
type ManagementHTTPHandler struct {
service *Service
mux *http.ServeMux
}
type identityInput struct {
Login string `json:"login"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
Password *string `json:"password"`
Permissions []string `json:"permissions"`
Active *bool `json:"active"`
DepartmentID *string `json:"department_id"`
}
func NewManagementHTTPHandler(service *Service) *ManagementHTTPHandler {
h := &ManagementHTTPHandler{service: service, mux: http.NewServeMux()}
h.mux.HandleFunc("GET /api/v1/admin/identities/admins", h.list(KindAdmin))
h.mux.HandleFunc("POST /api/v1/admin/identities/admins", h.create(KindAdmin))
h.mux.HandleFunc("PUT /api/v1/admin/identities/admins/{identity_id}", h.update(KindAdmin))
h.mux.HandleFunc("GET /api/v1/admin/identities/portal-users", h.list(KindPortal))
h.mux.HandleFunc("POST /api/v1/admin/identities/portal-users", h.create(KindPortal))
h.mux.HandleFunc("PUT /api/v1/admin/identities/portal-users/{identity_id}", h.update(KindPortal))
h.mux.HandleFunc("GET /api/v1/admin/departments", h.listDepartments)
h.mux.HandleFunc("POST /api/v1/admin/departments", h.createDepartment)
h.mux.HandleFunc("PUT /api/v1/admin/departments/{department_id}", h.updateDepartment)
h.registerOIDC()
return h
}
func (h *ManagementHTTPHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
h.mux.ServeHTTP(writer, request)
}
func (h *ManagementHTTPHandler) list(kind Kind) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
if _, ok := h.requirePermission(writer, request); !ok {
return
}
accounts, err := h.service.repository.ListIdentities(request.Context(), kind)
if err != nil {
h.writeError(writer, err)
return
}
items := make([]map[string]any, 0, len(accounts))
for _, account := range accounts {
items = append(items, managementView(account))
}
apiresponse.OK(writer, items)
}
}
func (h *ManagementHTTPHandler) create(kind Kind) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
actor, ok := h.requirePermission(writer, request)
if !ok {
return
}
input, account, password, ok := h.decode(writer, request, kind, true)
if !ok {
return
}
_ = input
passwordHash, err := h.service.hasher.Hash(password)
if err != nil {
h.writeError(writer, err)
return
}
account.PasswordHash = passwordHash
created, err := h.service.repository.CreateIdentity(request.Context(), account, actor.ID)
if err != nil {
h.writeError(writer, err)
return
}
apiresponse.OK(writer, managementView(created))
}
}
func (h *ManagementHTTPHandler) update(kind Kind) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
actor, ok := h.requirePermission(writer, request)
if !ok {
return
}
_, account, password, ok := h.decode(writer, request, kind, false)
if !ok {
return
}
account.ID = request.PathValue("identity_id")
current, err := h.service.findByID(request.Context(), kind, account.ID)
if err != nil {
h.writeError(writer, err)
return
}
if kind == KindAdmin && actor.ID == current.ID && (account.Role != current.Role || !account.Active) {
apiresponse.Error(writer, http.StatusConflict, "不能停用自身账号或修改自身角色")
return
}
var passwordHash *string
if password != "" {
hash, err := h.service.hasher.Hash(password)
if err != nil {
h.writeError(writer, err)
return
}
passwordHash = &hash
}
updated, err := h.service.repository.UpdateIdentity(request.Context(), account, passwordHash, actor.ID)
if err != nil {
h.writeError(writer, err)
return
}
apiresponse.OK(writer, managementView(updated))
}
}
func (h *ManagementHTTPHandler) decode(writer http.ResponseWriter, request *http.Request, kind Kind, creating bool) (identityInput, Account, string, bool) {
var input identityInput
decoder := json.NewDecoder(http.MaxBytesReader(writer, request.Body, 1<<20))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&input); err != nil {
apiresponse.Error(writer, http.StatusBadRequest, "请求格式无效")
return input, Account{}, "", false
}
input.Login = strings.ToLower(strings.TrimSpace(input.Login))
input.DisplayName = strings.TrimSpace(input.DisplayName)
input.Role = strings.ToLower(strings.TrimSpace(input.Role))
if input.Role == "" {
if kind == KindPortal {
input.Role = "member"
} else {
input.Role = "operator"
}
}
if len(input.Login) < 2 || len(input.Login) > 128 || len(input.DisplayName) > 64 {
apiresponse.Error(writer, http.StatusBadRequest, "账号或显示名称格式无效")
return input, Account{}, "", false
}
if kind == KindAdmin && input.Role != "superadmin" && input.Role != "operator" && input.Role != "auditor" {
apiresponse.Error(writer, http.StatusBadRequest, "管理员角色无效")
return input, Account{}, "", false
}
if kind == KindPortal && input.Role != "member" {
apiresponse.Error(writer, http.StatusBadRequest, "门户角色无效")
return input, Account{}, "", false
}
permissions, err := normalizePermissions(input.Permissions)
if err != nil {
apiresponse.Error(writer, http.StatusBadRequest, err.Error())
return input, Account{}, "", false
}
password := ""
if input.Password != nil {
password = *input.Password
}
if creating && len(password) < 12 || password != "" && len(password) < 12 || len(password) > 1024 {
apiresponse.Error(writer, http.StatusBadRequest, "口令长度必须为 12 至 1024 个字符")
return input, Account{}, "", false
}
active := true
if input.Active != nil {
active = *input.Active
}
var departmentID *string
if input.DepartmentID != nil && strings.TrimSpace(*input.DepartmentID) != "" {
value := strings.TrimSpace(*input.DepartmentID)
department, err := h.service.repository.GetDepartment(request.Context(), value)
if err != nil || !department.Active {
apiresponse.Error(writer, http.StatusBadRequest, "所选部门不存在或已停用")
return input, Account{}, "", false
}
departmentID = &value
}
return input, Account{
Kind: kind, Login: input.Login, DisplayName: input.DisplayName,
Role: input.Role, Permissions: permissions, Active: active, DepartmentID: departmentID,
}, password, true
}
func (h *ManagementHTTPHandler) requirePermission(writer http.ResponseWriter, request *http.Request) (Account, bool) {
account, err := h.service.Authenticate(request.Context(), KindAdmin, request.Header.Get("Authorization"))
if err != nil {
apiresponse.Error(writer, http.StatusUnauthorized, "登录状态无效或已过期")
return Account{}, false
}
if !HasPermission(account, PermissionIdentityManage) {
apiresponse.Error(writer, http.StatusForbidden, "缺少身份管理权限")
return Account{}, false
}
return account, true
}
func (h *ManagementHTTPHandler) writeError(writer http.ResponseWriter, err error) {
switch {
case errors.Is(err, ErrNotFound):
apiresponse.Error(writer, http.StatusNotFound, "账号不存在")
case errors.Is(err, ErrIdentityConflict):
apiresponse.Error(writer, http.StatusConflict, "账号已存在")
case errors.Is(err, ErrUnavailable):
apiresponse.Error(writer, http.StatusServiceUnavailable, "身份管理服务暂不可用")
default:
apiresponse.Error(writer, http.StatusInternalServerError, "身份管理操作失败")
}
}
func normalizePermissions(values []string) ([]string, error) {
seen := make(map[string]struct{})
result := make([]string, 0, len(values))
for _, value := range values {
value = strings.ToLower(strings.TrimSpace(value))
if value != "*" && !permissionPattern.MatchString(value) {
return nil, fmt.Errorf("权限字符串 %q 格式无效", value)
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
sort.Strings(result)
return result, nil
}
func managementView(account Account) map[string]any {
return map[string]any{
"id": account.ID, "kind": account.Kind, "login": account.Login,
"display_name": account.DisplayName, "role": account.Role,
"permissions": account.Permissions, "effective_permissions": EffectivePermissions(account),
"active": account.Active, "auth_source": account.AuthSource,
"totp_enabled": account.TOTPEnabled, "locked_until": account.LockedUntil,
"created_at": account.CreatedAt, "updated_at": account.UpdatedAt,
"department_id": account.DepartmentID, "department_name": account.DepartmentName,
}
}
func (r *Repository) ListIdentities(ctx context.Context, kind Kind) ([]Account, error) {
if r.pool == nil {
return nil, ErrUnavailable
}
query := `
SELECT id::text, username, display_name, role, permissions, active,
totp_enabled, locked_until, 'local', created_at, updated_at
FROM gateway.admin_accounts ORDER BY lower(username)`
if kind == KindPortal {
query = `
SELECT u.id::text, u.account, u.name, u.role, u.permissions, u.active,
u.totp_enabled, u.locked_until, u.auth_source, u.created_at, u.updated_at,
u.department_id::text, COALESCE(d.name, '')
FROM gateway.portal_users u
LEFT JOIN gateway.departments d ON d.id = u.department_id
ORDER BY lower(u.account)`
}
rows, err := r.pool.Query(ctx, query)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
defer rows.Close()
accounts := make([]Account, 0)
for rows.Next() {
account := Account{Kind: kind}
arguments := []any{
&account.ID, &account.Login, &account.DisplayName, &account.Role,
&account.Permissions, &account.Active, &account.TOTPEnabled,
&account.LockedUntil, &account.AuthSource, &account.CreatedAt, &account.UpdatedAt,
}
if kind == KindPortal {
arguments = append(arguments, &account.DepartmentID, &account.DepartmentName)
}
if err := rows.Scan(arguments...); err != nil {
return nil, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
accounts = append(accounts, account)
}
return accounts, mapRepositoryError(rows.Err())
}
func (r *Repository) CreateIdentity(ctx context.Context, account Account, actorID string) (Account, error) {
if r.pool == nil {
return Account{}, ErrUnavailable
}
id, err := platformid.NewUUID()
if err != nil {
return Account{}, err
}
eventID, err := platformid.NewUUID()
if err != nil {
return Account{}, err
}
account.ID = id
tx, err := r.pool.Begin(ctx)
if err != nil {
return Account{}, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
defer func() { _ = tx.Rollback(ctx) }()
if account.Kind == KindAdmin {
err = tx.QueryRow(ctx, `
INSERT INTO gateway.admin_accounts
(id, username, display_name, role, permissions, password_hash, active)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING created_at, updated_at`, account.ID, account.Login, account.DisplayName,
account.Role, account.Permissions, account.PasswordHash, account.Active,
).Scan(&account.CreatedAt, &account.UpdatedAt)
} else {
account.AuthSource = "local"
err = tx.QueryRow(ctx, `
INSERT INTO gateway.portal_users
(id, account, name, role, permissions, password_hash, auth_source, active, department_id)
VALUES ($1, $2, $3, $4, $5, $6, 'local', $7, $8)
RETURNING created_at, updated_at`, account.ID, account.Login, account.DisplayName,
account.Role, account.Permissions, account.PasswordHash, account.Active, account.DepartmentID,
).Scan(&account.CreatedAt, &account.UpdatedAt)
}
if err != nil {
return Account{}, mapManagementError(err)
}
payload, _ := json.Marshal(map[string]any{"identity_id": account.ID, "kind": account.Kind, "actor_id": actorID})
if _, err := tx.Exec(ctx, `
INSERT INTO gateway.outbox_events
(event_id, event_type, event_version, aggregate_type, aggregate_id, payload)
VALUES ($1, 'identity.created', 1, 'identity', $2, $3)`, eventID, account.ID, payload); err != nil {
return Account{}, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
if err := tx.Commit(ctx); err != nil {
return Account{}, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
return account, nil
}
func (r *Repository) UpdateIdentity(ctx context.Context, account Account, passwordHash *string, actorID string) (Account, error) {
if r.pool == nil {
return Account{}, ErrUnavailable
}
eventID, err := platformid.NewUUID()
if err != nil {
return Account{}, err
}
tx, err := r.pool.Begin(ctx)
if err != nil {
return Account{}, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
defer func() { _ = tx.Rollback(ctx) }()
if account.Kind == KindAdmin {
err = tx.QueryRow(ctx, `
UPDATE gateway.admin_accounts
SET username = $2, display_name = $3, role = $4, permissions = $5,
active = $6, password_hash = COALESCE($7, password_hash),
updated_at = clock_timestamp()
WHERE id = $1
RETURNING totp_enabled, locked_until, created_at, updated_at`, account.ID, account.Login,
account.DisplayName, account.Role, account.Permissions, account.Active, passwordHash,
).Scan(&account.TOTPEnabled, &account.LockedUntil, &account.CreatedAt, &account.UpdatedAt)
} else {
account.AuthSource = "local"
err = tx.QueryRow(ctx, `
UPDATE gateway.portal_users
SET account = $2, name = $3, role = $4, permissions = $5,
active = $6, password_hash = COALESCE($7, password_hash), department_id = $8,
updated_at = clock_timestamp()
WHERE id = $1
RETURNING auth_source, totp_enabled, locked_until, created_at, updated_at`, account.ID, account.Login,
account.DisplayName, account.Role, account.Permissions, account.Active, passwordHash, account.DepartmentID,
).Scan(&account.AuthSource, &account.TOTPEnabled, &account.LockedUntil, &account.CreatedAt, &account.UpdatedAt)
}
if err != nil {
return Account{}, mapManagementError(err)
}
payload, _ := json.Marshal(map[string]any{"identity_id": account.ID, "kind": account.Kind, "actor_id": actorID})
if _, err := tx.Exec(ctx, `
INSERT INTO gateway.outbox_events
(event_id, event_type, event_version, aggregate_type, aggregate_id, payload)
VALUES ($1, 'identity.updated', 1, 'identity', $2, $3)`, eventID, account.ID, payload); err != nil {
return Account{}, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
if err := tx.Commit(ctx); err != nil {
return Account{}, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
return account, nil
}
func mapManagementError(err error) error {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
var pgError *pgconn.PgError
if errors.As(err, &pgError) && pgError.Code == "23505" {
return ErrIdentityConflict
}
if err != nil {
return fmt.Errorf("%w: %v", ErrUnavailable, err)
}
return nil
}
+818
View File
@@ -0,0 +1,818 @@
package identity
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"net"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"aigateway.local/core/internal/platform/apiresponse"
platformid "aigateway.local/core/internal/platform/id"
"github.com/jackc/pgx/v5"
)
const oidcMaxResponse = 2 << 20
// oidcMaxTokenLifetimeSeconds bounds how far an ID token's exp may sit past
// its iat, preventing long-lived or replayed tokens from being accepted.
const oidcMaxTokenLifetimeSeconds = 24 * 3600
var oidcCodePattern = regexp.MustCompile(`^[a-z][a-z0-9_-]{1,63}$`)
type OIDCProvider struct {
ID string `json:"id"`
Code string `json:"code"`
DisplayName string `json:"display_name"`
IssuerURL string `json:"issuer_url"`
ClientID string `json:"client_id"`
EncryptedCredentials []byte `json:"-"`
CredentialKEKVersion int `json:"-"`
RedirectURI string `json:"redirect_uri"`
PortalReturnURL string `json:"portal_return_url"`
Scopes []string `json:"scopes"`
AutoProvision bool `json:"auto_provision"`
DefaultDepartmentID *string `json:"default_department_id,omitempty"`
Enabled bool `json:"enabled"`
Revision int64 `json:"revision"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type oidcProviderInput struct {
Code string `json:"code"`
DisplayName string `json:"display_name"`
IssuerURL string `json:"issuer_url"`
ClientID string `json:"client_id"`
ClientSecret *string `json:"client_secret"`
RedirectURI string `json:"redirect_uri"`
PortalReturnURL string `json:"portal_return_url"`
Scopes []string `json:"scopes"`
AutoProvision bool `json:"auto_provision"`
DefaultDepartmentID *string `json:"default_department_id"`
Enabled bool `json:"enabled"`
}
type oidcCredentials struct {
ClientSecret string `json:"client_secret"`
}
type oidcChallenge struct{ ProviderID, Verifier, Nonce string }
type oidcExchange struct {
Token string `json:"token"`
}
type oidcDiscovery struct{ Issuer, AuthorizationEndpoint, TokenEndpoint, JWKSURI string }
func (h *ManagementHTTPHandler) registerOIDC() {
h.mux.HandleFunc("GET /api/v1/admin/identity-providers", h.listOIDCProviders)
h.mux.HandleFunc("POST /api/v1/admin/identity-providers", h.createOIDCProvider)
h.mux.HandleFunc("PUT /api/v1/admin/identity-providers/{provider_id}", h.updateOIDCProvider)
h.registerSAML()
}
func (h *HTTPHandler) registerOIDC() {
h.mux.HandleFunc("GET /api/v1/portal/sso/providers", h.listPublicOIDCProviders)
h.mux.HandleFunc("GET /api/v1/portal/sso/{provider_code}/start", h.startSSO)
h.mux.HandleFunc("GET /api/v1/portal/sso/{provider_code}/callback", h.callbackOIDC)
h.mux.HandleFunc("POST /api/v1/portal/sso/{provider_code}/callback", h.callbackSAML)
h.mux.HandleFunc("GET /api/v1/portal/sso/{provider_code}/metadata", h.samlMetadata)
h.mux.HandleFunc("POST /api/v1/portal/sso/exchange", h.exchangeOIDC)
}
func (h *ManagementHTTPHandler) listOIDCProviders(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requirePermission(w, r); !ok {
return
}
records, err := h.service.repository.ListOIDCProviders(r.Context(), false)
if err != nil {
h.writeError(w, err)
return
}
items := make([]map[string]any, 0, len(records))
for _, record := range records {
items = append(items, h.oidcView(record))
}
apiresponse.OK(w, items)
}
func (h *ManagementHTTPHandler) createOIDCProvider(w http.ResponseWriter, r *http.Request) {
actor, ok := h.requirePermission(w, r)
if !ok {
return
}
input, record, ok := h.decodeOIDC(w, r, true)
if !ok {
return
}
if err := h.setOIDCCredentials(&record, strings.TrimSpace(*input.ClientSecret)); err != nil {
h.writeError(w, err)
return
}
created, err := h.service.repository.CreateOIDCProvider(r.Context(), record, actor.ID)
if err != nil {
h.writeError(w, err)
return
}
apiresponse.OK(w, h.oidcView(created))
}
func (h *ManagementHTTPHandler) updateOIDCProvider(w http.ResponseWriter, r *http.Request) {
actor, ok := h.requirePermission(w, r)
if !ok {
return
}
input, record, ok := h.decodeOIDC(w, r, false)
if !ok {
return
}
record.ID = r.PathValue("provider_id")
replace := input.ClientSecret != nil
if replace {
if err := h.setOIDCCredentials(&record, strings.TrimSpace(*input.ClientSecret)); err != nil {
h.writeError(w, err)
return
}
}
updated, err := h.service.repository.UpdateOIDCProvider(r.Context(), record, actor.ID, replace)
if err != nil {
h.writeError(w, err)
return
}
apiresponse.OK(w, h.oidcView(updated))
}
func (h *ManagementHTTPHandler) decodeOIDC(w http.ResponseWriter, r *http.Request, creating bool) (oidcProviderInput, OIDCProvider, bool) {
var input oidcProviderInput
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
decoder.DisallowUnknownFields()
if decoder.Decode(&input) != nil {
apiresponse.Error(w, 400, "请求格式无效")
return input, OIDCProvider{}, false
}
input.Code = strings.ToLower(strings.TrimSpace(input.Code))
input.DisplayName = strings.TrimSpace(input.DisplayName)
input.ClientID = strings.TrimSpace(input.ClientID)
if !oidcCodePattern.MatchString(input.Code) || input.DisplayName == "" || input.ClientID == "" ||
(creating && input.ClientSecret == nil) || (input.ClientSecret != nil && strings.TrimSpace(*input.ClientSecret) == "") {
apiresponse.Error(w, 400, "身份源代码、名称、Client ID 或 Client Secret 无效")
return input, OIDCProvider{}, false
}
issuer, err := validateOIDCURL(r.Context(), input.IssuerURL, h.service.allowPrivateIdentityProvider)
if err != nil {
apiresponse.Error(w, 400, "Issuer URL 无效")
return input, OIDCProvider{}, false
}
redirectURI, err := validateAbsoluteURL(input.RedirectURI)
redirectURL, _ := url.Parse(redirectURI)
if err != nil || redirectURL.Fragment != "" {
apiresponse.Error(w, 400, "回调 URL 无效")
return input, OIDCProvider{}, false
}
returnURL, err := validateAbsoluteURL(input.PortalReturnURL)
if err != nil {
apiresponse.Error(w, 400, "门户返回 URL 无效")
return input, OIDCProvider{}, false
}
if len(input.Scopes) == 0 {
input.Scopes = []string{"openid", "profile", "email"}
}
input.Scopes, err = normalizeOIDCScopes(input.Scopes)
if err != nil {
apiresponse.Error(w, 400, "OIDC scopes 无效")
return input, OIDCProvider{}, false
}
if !contains(input.Scopes, "openid") {
apiresponse.Error(w, 400, "OIDC scopes 必须包含 openid")
return input, OIDCProvider{}, false
}
if input.DefaultDepartmentID != nil && *input.DefaultDepartmentID != "" {
department, err := h.service.repository.GetDepartment(r.Context(), *input.DefaultDepartmentID)
if err != nil || !department.Active {
apiresponse.Error(w, 400, "默认部门不存在或已停用")
return input, OIDCProvider{}, false
}
}
return input, OIDCProvider{Code: input.Code, DisplayName: input.DisplayName, IssuerURL: issuer, ClientID: input.ClientID,
RedirectURI: redirectURI, PortalReturnURL: returnURL, Scopes: input.Scopes, AutoProvision: input.AutoProvision,
DefaultDepartmentID: input.DefaultDepartmentID, Enabled: input.Enabled}, true
}
func (h *ManagementHTTPHandler) setOIDCCredentials(record *OIDCProvider, secret string) error {
payload, _ := json.Marshal(oidcCredentials{ClientSecret: secret})
encrypted, version, err := h.service.idpCipher.Encrypt(payload)
if err != nil {
return err
}
record.EncryptedCredentials, record.CredentialKEKVersion = encrypted, version
return nil
}
func (h *ManagementHTTPHandler) oidcView(record OIDCProvider) map[string]any {
configured := false
if plaintext, err := h.service.idpCipher.Decrypt(record.EncryptedCredentials, record.CredentialKEKVersion); err == nil {
var credentials oidcCredentials
configured = json.Unmarshal(plaintext, &credentials) == nil && credentials.ClientSecret != ""
}
return map[string]any{"id": record.ID, "code": record.Code, "display_name": record.DisplayName, "issuer_url": record.IssuerURL,
"client_id": record.ClientID, "secret_configured": configured, "redirect_uri": record.RedirectURI, "portal_return_url": record.PortalReturnURL,
"scopes": record.Scopes, "auto_provision": record.AutoProvision, "default_department_id": record.DefaultDepartmentID,
"enabled": record.Enabled, "revision": record.Revision, "credential_kek_version": record.CredentialKEKVersion}
}
func (h *HTTPHandler) listPublicOIDCProviders(w http.ResponseWriter, r *http.Request) {
records, err := h.service.repository.ListPublicIdentityProviders(r.Context())
if err != nil {
h.writeIdentityError(w, err)
return
}
apiresponse.OK(w, records)
}
func (h *HTTPHandler) startOIDC(w http.ResponseWriter, r *http.Request) {
p, err := h.service.repository.GetOIDCProviderByCode(r.Context(), r.PathValue("provider_code"))
if err != nil || !p.Enabled {
http.NotFound(w, r)
return
}
discovery, err := h.service.discoverOIDC(r.Context(), p)
if err != nil {
apiresponse.Error(w, 502, "身份源发现失败")
return
}
verifier, err := randomURLToken(48)
if err != nil {
h.writeIdentityError(w, ErrUnavailable)
return
}
nonce, err := randomURLToken(32)
if err != nil {
h.writeIdentityError(w, ErrUnavailable)
return
}
state, err := h.service.sessions.StoreOneTime(r.Context(), "oidc-state", oidcChallenge{ProviderID: p.ID, Verifier: verifier, Nonce: nonce}, 5*time.Minute)
if err != nil {
h.writeIdentityError(w, err)
return
}
challenge := sha256.Sum256([]byte(verifier))
target, _ := url.Parse(discovery.AuthorizationEndpoint)
query := target.Query()
query.Set("response_type", "code")
query.Set("client_id", p.ClientID)
query.Set("redirect_uri", p.RedirectURI)
query.Set("scope", strings.Join(p.Scopes, " "))
query.Set("state", state)
query.Set("nonce", nonce)
query.Set("code_challenge", base64.RawURLEncoding.EncodeToString(challenge[:]))
query.Set("code_challenge_method", "S256")
target.RawQuery = query.Encode()
http.Redirect(w, r, target.String(), http.StatusFound)
}
func (h *HTTPHandler) callbackOIDC(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("error") != "" {
apiresponse.Error(w, 401, "OIDC 登录被拒绝")
return
}
if r.URL.Query().Get("state") == "" || r.URL.Query().Get("code") == "" {
apiresponse.Error(w, 401, "OIDC 回调参数无效")
return
}
var challenge oidcChallenge
if err := h.service.sessions.ConsumeOneTime(r.Context(), "oidc-state", r.URL.Query().Get("state"), &challenge); err != nil {
apiresponse.Error(w, 401, "OIDC state 无效或已使用")
return
}
p, err := h.service.repository.GetOIDCProviderByCode(r.Context(), r.PathValue("provider_code"))
if err != nil || p.ID != challenge.ProviderID || !p.Enabled {
apiresponse.Error(w, 401, "OIDC 身份源无效")
return
}
discovery, err := h.service.discoverOIDC(r.Context(), p)
if err != nil {
apiresponse.Error(w, 502, "身份源发现失败")
return
}
credentials, err := h.service.oidcCredentials(p)
if err != nil {
apiresponse.Error(w, 503, "身份源凭据不可用")
return
}
form := url.Values{"grant_type": {"authorization_code"}, "code": {r.URL.Query().Get("code")}, "redirect_uri": {p.RedirectURI}, "code_verifier": {challenge.Verifier}}
req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, discovery.TokenEndpoint, strings.NewReader(form.Encode()))
if err != nil {
apiresponse.Error(w, 502, "OIDC token 交换失败")
return
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth(p.ClientID, credentials.ClientSecret)
response, err := h.service.oidcHTTPClient().Do(req)
if err != nil {
apiresponse.Error(w, 502, "OIDC token 交换失败")
return
}
defer response.Body.Close()
payload, _ := io.ReadAll(io.LimitReader(response.Body, oidcMaxResponse+1))
if response.StatusCode/100 != 2 || len(payload) > oidcMaxResponse {
apiresponse.Error(w, 502, "OIDC token 交换失败")
return
}
var tokens struct {
IDToken string `json:"id_token"`
}
if json.Unmarshal(payload, &tokens) != nil || tokens.IDToken == "" {
apiresponse.Error(w, 502, "OIDC 响应缺少 ID Token")
return
}
claims, err := h.service.verifyIDToken(r.Context(), discovery, p.ClientID, challenge.Nonce, tokens.IDToken)
if err != nil {
apiresponse.Error(w, 401, "OIDC ID Token 校验失败")
return
}
account, err := h.service.repository.ResolveExternalAccount(r.Context(), p, claims)
if err != nil {
h.writeIdentityError(w, err)
return
}
if !account.Active {
h.writeIdentityError(w, ErrAccountDisabled)
return
}
token, err := h.service.sessions.Create(r.Context(), principalFor(account))
if err != nil {
h.writeIdentityError(w, err)
return
}
exchange, err := h.service.sessions.StoreOneTime(r.Context(), "oidc-exchange", oidcExchange{Token: token}, time.Minute)
if err != nil {
h.writeIdentityError(w, err)
return
}
returnURL, _ := url.Parse(p.PortalReturnURL)
query := returnURL.Query()
query.Set("sso_code", exchange)
returnURL.RawQuery = query.Encode()
http.Redirect(w, r, returnURL.String(), http.StatusFound)
}
func (h *HTTPHandler) exchangeOIDC(w http.ResponseWriter, r *http.Request) {
var input struct {
Code string `json:"code"`
}
if !decodeJSON(w, r, &input) {
apiresponse.Error(w, 400, "请求格式无效")
return
}
var exchange oidcExchange
if h.service.sessions.ConsumeOneTime(r.Context(), "oidc-exchange", input.Code, &exchange) != nil {
apiresponse.Error(w, 401, "SSO 交换码无效或已使用")
return
}
apiresponse.OK(w, map[string]string{"token": exchange.Token, "refreshToken": ""})
}
type oidcClaims struct {
Issuer string `json:"iss"`
Subject string `json:"sub"`
Audience json.RawMessage `json:"aud"`
AuthorizedParty string `json:"azp"`
ExpiresAt int64 `json:"exp"`
IssuedAt int64 `json:"iat"`
NotBefore int64 `json:"nbf"`
Nonce string `json:"nonce"`
Email string `json:"email"`
PreferredUsername string `json:"preferred_username"`
Name string `json:"name"`
}
type externalProvider struct {
ID string
Code string
AuthSource string
AutoProvision bool
DefaultDepartmentID *string
}
type externalClaims struct {
Subject string
Email string
PreferredUsername string
Name string
}
func (s *Service) discoverOIDC(ctx context.Context, p OIDCProvider) (oidcDiscovery, error) {
target := strings.TrimRight(p.IssuerURL, "/") + "/.well-known/openid-configuration"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
return oidcDiscovery{}, err
}
response, err := s.oidcHTTPClient().Do(req)
if err != nil {
return oidcDiscovery{}, err
}
defer response.Body.Close()
payload, _ := io.ReadAll(io.LimitReader(response.Body, oidcMaxResponse+1))
var raw struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
JWKSURI string `json:"jwks_uri"`
}
if response.StatusCode/100 != 2 || len(payload) > oidcMaxResponse || json.Unmarshal(payload, &raw) != nil || raw.Issuer != p.IssuerURL {
return oidcDiscovery{}, errors.New("invalid discovery")
}
for _, value := range []string{raw.AuthorizationEndpoint, raw.TokenEndpoint, raw.JWKSURI} {
if _, err := validateOIDCURL(ctx, value, s.allowPrivateIdentityProvider); err != nil {
return oidcDiscovery{}, err
}
}
return oidcDiscovery{Issuer: raw.Issuer, AuthorizationEndpoint: raw.AuthorizationEndpoint, TokenEndpoint: raw.TokenEndpoint, JWKSURI: raw.JWKSURI}, nil
}
func (s *Service) oidcCredentials(p OIDCProvider) (oidcCredentials, error) {
plaintext, err := s.idpCipher.Decrypt(p.EncryptedCredentials, p.CredentialKEKVersion)
var c oidcCredentials
if err == nil {
err = json.Unmarshal(plaintext, &c)
}
return c, err
}
func (s *Service) oidcHTTPClient() *http.Client {
if s.oidcClient == nil {
s.oidcClient = newOIDCHTTPClient(s.allowPrivateIdentityProvider)
}
return s.oidcClient
}
func newOIDCHTTPClient(allowPrivate bool) *http.Client {
return &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
DialContext: safeOIDCDial(allowPrivate),
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 8 * time.Second,
MaxIdleConns: 32,
MaxIdleConnsPerHost: 8,
IdleConnTimeout: 90 * time.Second,
},
CheckRedirect: func(*http.Request, []*http.Request) error { return errors.New("redirect rejected") },
}
}
func (s *Service) verifyIDToken(ctx context.Context, d oidcDiscovery, clientID, nonce, token string) (oidcClaims, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return oidcClaims{}, errors.New("jwt format")
}
headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return oidcClaims{}, err
}
var header struct{ Alg, Kid string }
if json.Unmarshal(headerBytes, &header) != nil || header.Alg != "RS256" || header.Kid == "" {
return oidcClaims{}, errors.New("jwt header")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, d.JWKSURI, nil)
if err != nil {
return oidcClaims{}, err
}
response, err := s.oidcHTTPClient().Do(req)
if err != nil {
return oidcClaims{}, err
}
defer response.Body.Close()
payload, _ := io.ReadAll(io.LimitReader(response.Body, oidcMaxResponse+1))
var keys struct {
Keys []struct{ Kid, Kty, N, E string }
}
if response.StatusCode/100 != 2 || len(payload) > oidcMaxResponse || json.Unmarshal(payload, &keys) != nil {
return oidcClaims{}, errors.New("jwks")
}
var key *rsa.PublicKey
for _, j := range keys.Keys {
if j.Kid == header.Kid && j.Kty == "RSA" {
nBytes, nErr := base64.RawURLEncoding.DecodeString(j.N)
eBytes, eErr := base64.RawURLEncoding.DecodeString(j.E)
e := 0
for _, b := range eBytes {
e = e<<8 + int(b)
}
if nErr == nil && eErr == nil && len(nBytes) >= 256 && e >= 3 && e%2 == 1 {
key = &rsa.PublicKey{N: new(big.Int).SetBytes(nBytes), E: e}
}
}
}
if key == nil {
return oidcClaims{}, errors.New("key")
}
signature, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
return oidcClaims{}, err
}
digest := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
if rsa.VerifyPKCS1v15(key, crypto.SHA256, digest[:], signature) != nil {
return oidcClaims{}, errors.New("signature")
}
claimsBytes, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return oidcClaims{}, err
}
var claims oidcClaims
now := time.Now().Unix()
if s.now != nil {
now = s.now().Unix()
}
if json.Unmarshal(claimsBytes, &claims) != nil || claims.Issuer != d.Issuer || claims.Subject == "" || claims.Nonce != nonce ||
claims.ExpiresAt <= now-30 || claims.IssuedAt == 0 || claims.IssuedAt > now+30 || claims.NotBefore > now+30 ||
claims.ExpiresAt-claims.IssuedAt > oidcMaxTokenLifetimeSeconds {
return oidcClaims{}, errors.New("claims")
}
audiences, ok := parseAudience(claims.Audience)
if !ok || !contains(audiences, clientID) || (len(audiences) > 1 && claims.AuthorizedParty != clientID) ||
(claims.AuthorizedParty != "" && claims.AuthorizedParty != clientID) {
return oidcClaims{}, errors.New("audience")
}
return claims, nil
}
func (r *Repository) ListOIDCProviders(ctx context.Context, enabledOnly bool) ([]OIDCProvider, error) {
query := `SELECT id::text,code,display_name,issuer_url,client_id,encrypted_credentials,credential_kek_version,redirect_uri,portal_return_url,scopes,auto_provision,default_department_id::text,enabled,revision,created_at,updated_at FROM gateway.identity_providers WHERE kind='oidc'`
if enabledOnly {
query += " AND enabled"
}
query += " ORDER BY code"
rows, err := r.pool.Query(ctx, query)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
defer rows.Close()
items := []OIDCProvider{}
for rows.Next() {
var p OIDCProvider
if err := rows.Scan(&p.ID, &p.Code, &p.DisplayName, &p.IssuerURL, &p.ClientID, &p.EncryptedCredentials, &p.CredentialKEKVersion, &p.RedirectURI, &p.PortalReturnURL, &p.Scopes, &p.AutoProvision, &p.DefaultDepartmentID, &p.Enabled, &p.Revision, &p.CreatedAt, &p.UpdatedAt); err != nil {
return nil, err
}
items = append(items, p)
}
return items, mapRepositoryError(rows.Err())
}
func (r *Repository) GetOIDCProviderByCode(ctx context.Context, code string) (OIDCProvider, error) {
var p OIDCProvider
err := r.pool.QueryRow(ctx, `SELECT id::text,code,display_name,issuer_url,client_id,encrypted_credentials,credential_kek_version,redirect_uri,portal_return_url,scopes,auto_provision,default_department_id::text,enabled,revision,created_at,updated_at FROM gateway.identity_providers WHERE code=$1 AND kind='oidc'`, strings.ToLower(code)).Scan(&p.ID, &p.Code, &p.DisplayName, &p.IssuerURL, &p.ClientID, &p.EncryptedCredentials, &p.CredentialKEKVersion, &p.RedirectURI, &p.PortalReturnURL, &p.Scopes, &p.AutoProvision, &p.DefaultDepartmentID, &p.Enabled, &p.Revision, &p.CreatedAt, &p.UpdatedAt)
return p, mapRepositoryError(err)
}
func (r *Repository) CreateOIDCProvider(ctx context.Context, p OIDCProvider, actor string) (OIDCProvider, error) {
id, _ := platformid.NewUUID()
p.ID = id
return r.storeOIDCProvider(ctx, p, actor, true, true)
}
func (r *Repository) UpdateOIDCProvider(ctx context.Context, p OIDCProvider, actor string, replace bool) (OIDCProvider, error) {
return r.storeOIDCProvider(ctx, p, actor, false, replace)
}
func (r *Repository) storeOIDCProvider(ctx context.Context, p OIDCProvider, actor string, creating, replace bool) (OIDCProvider, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return p, ErrUnavailable
}
defer tx.Rollback(ctx)
if creating {
err = tx.QueryRow(ctx, `INSERT INTO gateway.identity_providers(id,code,kind,display_name,issuer_url,client_id,encrypted_credentials,credential_kek_version,redirect_uri,portal_return_url,scopes,auto_provision,default_department_id,enabled) VALUES($1,$2,'oidc',$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING revision,created_at,updated_at`, p.ID, p.Code, p.DisplayName, p.IssuerURL, p.ClientID, p.EncryptedCredentials, p.CredentialKEKVersion, p.RedirectURI, p.PortalReturnURL, p.Scopes, p.AutoProvision, p.DefaultDepartmentID, p.Enabled).Scan(&p.Revision, &p.CreatedAt, &p.UpdatedAt)
} else {
err = tx.QueryRow(ctx, `UPDATE gateway.identity_providers SET code=$2,display_name=$3,issuer_url=$4,client_id=$5,encrypted_credentials=CASE WHEN $14 THEN $6 ELSE encrypted_credentials END,credential_kek_version=CASE WHEN $14 THEN $7 ELSE credential_kek_version END,redirect_uri=$8,portal_return_url=$9,scopes=$10,auto_provision=$11,default_department_id=$12,enabled=$13,revision=revision+1,updated_at=clock_timestamp() WHERE id=$1 AND kind='oidc' RETURNING encrypted_credentials,credential_kek_version,revision,created_at,updated_at`, p.ID, p.Code, p.DisplayName, p.IssuerURL, p.ClientID, p.EncryptedCredentials, p.CredentialKEKVersion, p.RedirectURI, p.PortalReturnURL, p.Scopes, p.AutoProvision, p.DefaultDepartmentID, p.Enabled, replace).Scan(&p.EncryptedCredentials, &p.CredentialKEKVersion, &p.Revision, &p.CreatedAt, &p.UpdatedAt)
}
if err != nil {
return p, mapManagementError(err)
}
eventID, _ := platformid.NewUUID()
eventType := "identity_provider.updated"
if creating {
eventType = "identity_provider.created"
}
payload, _ := json.Marshal(map[string]any{"identity_provider_id": p.ID, "actor_id": actor})
if _, err = tx.Exec(ctx, `INSERT INTO gateway.outbox_events(event_id,event_type,event_version,aggregate_type,aggregate_id,payload) VALUES($1,$2,1,'identity_provider',$3,$4)`, eventID, eventType, p.ID, payload); err != nil {
return p, ErrUnavailable
}
if tx.Commit(ctx) != nil {
return p, ErrUnavailable
}
return p, nil
}
func (r *Repository) ResolveExternalAccount(ctx context.Context, p OIDCProvider, c oidcClaims) (Account, error) {
return r.resolveExternalAccount(ctx, externalProvider{
ID: p.ID, Code: p.Code, AuthSource: "oidc", AutoProvision: p.AutoProvision, DefaultDepartmentID: p.DefaultDepartmentID,
}, externalClaims{Subject: c.Subject, Email: c.Email, PreferredUsername: c.PreferredUsername, Name: c.Name})
}
func (r *Repository) resolveExternalAccount(ctx context.Context, p externalProvider, c externalClaims) (Account, error) {
var id string
err := r.pool.QueryRow(ctx, `SELECT id::text FROM gateway.portal_users WHERE identity_provider_id=$1 AND external_subject=$2`, p.ID, c.Subject).Scan(&id)
if err == nil {
return r.FindPortalByID(ctx, id)
}
if !errors.Is(err, pgx.ErrNoRows) {
return Account{}, ErrUnavailable
}
if !p.AutoProvision {
return Account{}, ErrNotFound
}
if p.DefaultDepartmentID != nil {
var active bool
if err := r.pool.QueryRow(ctx, `SELECT active FROM gateway.departments WHERE id=$1`, *p.DefaultDepartmentID).Scan(&active); err != nil || !active {
return Account{}, ErrUnavailable
}
}
login := strings.ToLower(strings.TrimSpace(c.Email))
if login == "" {
login = strings.ToLower(strings.TrimSpace(c.PreferredUsername))
}
if login == "" {
sum := sha256.Sum256([]byte(c.Subject))
login = p.Code + "_" + fmt.Sprintf("%x", sum[:6])
}
login = truncate(login, 128)
id, _ = platformid.NewUUID()
eventID, _ := platformid.NewUUID()
tx, err := r.pool.Begin(ctx)
if err != nil {
return Account{}, ErrUnavailable
}
defer tx.Rollback(ctx)
name := truncate(firstNonEmpty(c.Name, c.PreferredUsername, login), 64)
insert := func(candidate string) (bool, error) {
var insertedID string
err := tx.QueryRow(ctx, `INSERT INTO gateway.portal_users(id,account,name,role,permissions,password_hash,auth_source,external_subject,identity_provider_id,department_id,active) VALUES($1,$2,$3,'member','{}',NULL,$4,$5,$6,$7,true) ON CONFLICT DO NOTHING RETURNING id::text`, id, candidate, name, p.AuthSource, c.Subject, p.ID, p.DefaultDepartmentID).Scan(&insertedID)
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
return err == nil, err
}
inserted, err := insert(login)
if err != nil {
return Account{}, ErrUnavailable
}
if !inserted {
var existingID string
lookupErr := tx.QueryRow(ctx, `SELECT id::text FROM gateway.portal_users WHERE identity_provider_id=$1 AND external_subject=$2`, p.ID, c.Subject).Scan(&existingID)
if lookupErr == nil {
_ = tx.Rollback(ctx)
return r.FindPortalByID(ctx, existingID)
}
if !errors.Is(lookupErr, pgx.ErrNoRows) {
return Account{}, ErrUnavailable
}
sum := sha256.Sum256([]byte(p.Code + "|" + c.Subject))
login = truncate(login, 110) + "-" + fmt.Sprintf("%x", sum[:8])
inserted, err = insert(login)
}
if err != nil || !inserted {
return Account{}, ErrUnavailable
}
payload, _ := json.Marshal(map[string]any{"identity_id": id, "identity_provider_id": p.ID})
if _, err = tx.Exec(ctx, `INSERT INTO gateway.outbox_events(event_id,event_type,event_version,aggregate_type,aggregate_id,payload) VALUES($1,'identity.external_provisioned',1,'identity',$2,$3)`, eventID, id, payload); err != nil {
return Account{}, ErrUnavailable
}
if tx.Commit(ctx) != nil {
return Account{}, ErrUnavailable
}
return r.FindPortalByID(ctx, id)
}
func validateAbsoluteURL(raw string) (string, error) {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" || u.User != nil {
return "", errors.New("invalid url")
}
return u.String(), nil
}
func validateOIDCURL(ctx context.Context, raw string, allowPrivate bool) (string, error) {
value, err := validateAbsoluteURL(raw)
if err != nil {
return "", err
}
u, _ := url.Parse(value)
if u.RawQuery != "" || u.Fragment != "" || (!allowPrivate && u.Scheme != "https") {
return "", errors.New("invalid issuer url")
}
if !allowPrivate {
addresses, err := net.DefaultResolver.LookupIPAddr(ctx, u.Hostname())
if err != nil || len(addresses) == 0 {
return "", errors.New("resolve")
}
for _, a := range addresses {
if !publicIP(a.IP) {
return "", errors.New("blocked address")
}
}
}
u.Path = strings.TrimRight(u.Path, "/")
return u.String(), nil
}
func safeOIDCDial(allowPrivate bool) func(context.Context, string, string) (net.Conn, error) {
d := &net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second}
if allowPrivate {
return d.DialContext
}
return func(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, err
}
addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, err
}
for _, a := range addresses {
if !publicIP(a.IP) {
return nil, errors.New("blocked address")
}
}
if len(addresses) == 0 {
return nil, errors.New("resolve")
}
return d.DialContext(ctx, network, net.JoinHostPort(addresses[0].IP.String(), port))
}
}
func publicIP(ip net.IP) bool {
return ip != nil && !ip.IsPrivate() && !ip.IsLoopback() && !ip.IsLinkLocalUnicast() && !ip.IsLinkLocalMulticast() && !ip.IsMulticast() && !ip.IsUnspecified()
}
func randomURLToken(size int) (string, error) {
b := make([]byte, size)
_, err := rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b), err
}
func contains(values []string, target string) bool {
for _, v := range values {
if v == target {
return true
}
}
return false
}
func audienceContains(raw json.RawMessage, target string) bool {
values, ok := parseAudience(raw)
return ok && contains(values, target)
}
func parseAudience(raw json.RawMessage) ([]string, bool) {
var one string
if json.Unmarshal(raw, &one) == nil {
return []string{one}, one != ""
}
var many []string
if json.Unmarshal(raw, &many) != nil || len(many) == 0 {
return nil, false
}
for _, value := range many {
if value == "" {
return nil, false
}
}
return many, true
}
func normalizeOIDCScopes(values []string) ([]string, error) {
if len(values) > 16 {
return nil, errors.New("too many scopes")
}
seen := make(map[string]struct{}, len(values))
result := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || len(value) > 64 || strings.ContainsAny(value, " \t\r\n\"") {
return nil, errors.New("invalid scope")
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result, nil
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return "用户"
}
func truncate(value string, max int) string {
runes := []rune(value)
if len(runes) > max {
return string(runes[:max])
}
return value
}
+140
View File
@@ -0,0 +1,140 @@
package identity
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"math/big"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestVerifyIDTokenValidatesOIDCSecurityClaims(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
kid := "test-key"
jwks := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"keys": []map[string]string{{
"kid": kid,
"kty": "RSA",
"n": base64.RawURLEncoding.EncodeToString(key.N.Bytes()),
"e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.E)).Bytes()),
}}})
}))
defer jwks.Close()
now := time.Unix(1_800_000_000, 0)
service := &Service{
allowPrivateIdentityProvider: true,
oidcClient: newOIDCHTTPClient(true),
now: func() time.Time { return now },
}
discovery := oidcDiscovery{Issuer: "https://issuer.example", JWKSURI: jwks.URL}
baseClaims := map[string]any{
"iss": discovery.Issuer,
"sub": "subject-1",
"aud": "client-1",
"exp": now.Add(time.Minute).Unix(),
"iat": now.Unix(),
"nonce": "nonce-1",
}
if _, err := service.verifyIDToken(context.Background(), discovery, "client-1", "nonce-1", signTestIDToken(t, key, kid, baseClaims)); err != nil {
t.Fatalf("valid ID token rejected: %v", err)
}
tests := []struct {
name string
change func(map[string]any)
}{
{name: "wrong issuer", change: func(c map[string]any) { c["iss"] = "https://attacker.example" }},
{name: "wrong nonce", change: func(c map[string]any) { c["nonce"] = "other" }},
{name: "expired", change: func(c map[string]any) { c["exp"] = now.Add(-time.Minute).Unix() }},
{name: "issued in future", change: func(c map[string]any) { c["iat"] = now.Add(time.Minute).Unix() }},
{name: "missing subject", change: func(c map[string]any) { delete(c, "sub") }},
{name: "wrong audience", change: func(c map[string]any) { c["aud"] = "other-client" }},
{name: "multiple audiences without azp", change: func(c map[string]any) { c["aud"] = []string{"client-1", "other-client"} }},
{name: "wrong authorized party", change: func(c map[string]any) { c["aud"] = []string{"client-1", "other-client"}; c["azp"] = "other-client" }},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
claims := cloneClaims(baseClaims)
test.change(claims)
if _, err := service.verifyIDToken(context.Background(), discovery, "client-1", "nonce-1", signTestIDToken(t, key, kid, claims)); err == nil {
t.Fatal("expected ID token to be rejected")
}
})
}
multiple := cloneClaims(baseClaims)
multiple["aud"] = []string{"client-1", "other-client"}
multiple["azp"] = "client-1"
if _, err := service.verifyIDToken(context.Background(), discovery, "client-1", "nonce-1", signTestIDToken(t, key, kid, multiple)); err != nil {
t.Fatalf("valid multi-audience ID token rejected: %v", err)
}
}
func TestOIDCURLAndScopeValidation(t *testing.T) {
got, err := validateOIDCURL(context.Background(), "http://127.0.0.1:9090/issuer/", true)
if err != nil || got != "http://127.0.0.1:9090/issuer" {
t.Fatalf("private development issuer rejected: %q %v", got, err)
}
for _, raw := range []string{
"http://127.0.0.1:9090/issuer?tenant=1",
"http://127.0.0.1:9090/issuer#fragment",
"http://8.8.8.8/issuer",
} {
allowPrivate := raw != "http://8.8.8.8/issuer"
if _, err := validateOIDCURL(context.Background(), raw, allowPrivate); err == nil {
t.Fatalf("unsafe issuer accepted: %s", raw)
}
}
if _, err := validateAbsoluteURL("https://user:secret@example.com/callback"); err == nil {
t.Fatal("URL containing credentials was accepted")
}
scopes, err := normalizeOIDCScopes([]string{"openid", " profile ", "openid", "email"})
if err != nil || len(scopes) != 3 || scopes[0] != "openid" || scopes[1] != "profile" || scopes[2] != "email" {
t.Fatalf("unexpected normalized scopes: %#v %v", scopes, err)
}
for _, scopes := range [][]string{{"openid email"}, {"openid", ""}} {
if _, err := normalizeOIDCScopes(scopes); err == nil {
t.Fatalf("invalid scopes accepted: %#v", scopes)
}
}
}
func signTestIDToken(t *testing.T, key *rsa.PrivateKey, kid string, claims map[string]any) string {
t.Helper()
header, err := json.Marshal(map[string]string{"alg": "RS256", "kid": kid, "typ": "JWT"})
if err != nil {
t.Fatal(err)
}
payload, err := json.Marshal(claims)
if err != nil {
t.Fatal(err)
}
signingInput := base64.RawURLEncoding.EncodeToString(header) + "." + base64.RawURLEncoding.EncodeToString(payload)
digest := sha256.Sum256([]byte(signingInput))
signature, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:])
if err != nil {
t.Fatal(err)
}
return signingInput + "." + base64.RawURLEncoding.EncodeToString(signature)
}
func cloneClaims(source map[string]any) map[string]any {
result := make(map[string]any, len(source))
for key, value := range source {
result[key] = value
}
return result
}
+103
View File
@@ -0,0 +1,103 @@
package identity
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"strconv"
"strings"
)
const (
currentPBKDF2Iterations = 600_000
legacyPBKDF2Iterations = 120_000
maximumPBKDF2Iterations = 2_000_000
passwordDigestBytes = 32
)
var ErrInvalidPasswordHash = errors.New("invalid password hash")
type PasswordHasher struct{}
func (PasswordHasher) Hash(password string) (string, error) {
saltBytes := make([]byte, 16)
if _, err := rand.Read(saltBytes); err != nil {
return "", fmt.Errorf("generate password salt: %w", err)
}
return hashWithSalt(password, hex.EncodeToString(saltBytes), currentPBKDF2Iterations), nil
}
func (PasswordHasher) Verify(password, stored string) bool {
iterations, salt, expected, err := parsePasswordHash(stored)
if err != nil {
return false
}
actual := pbkdf2SHA256([]byte(password), []byte(salt), iterations, len(expected))
return subtle.ConstantTimeCompare(actual, expected) == 1
}
func (PasswordHasher) NeedsUpgrade(stored string) bool {
iterations, _, _, err := parsePasswordHash(stored)
return err == nil && iterations < currentPBKDF2Iterations
}
func hashWithSalt(password, salt string, iterations int) string {
digest := pbkdf2SHA256([]byte(password), []byte(salt), iterations, passwordDigestBytes)
return fmt.Sprintf("pbkdf2_sha256$%d$%s$%s", iterations, salt, hex.EncodeToString(digest))
}
func parsePasswordHash(stored string) (int, string, []byte, error) {
parts := strings.Split(stored, "$")
iterations := legacyPBKDF2Iterations
var salt, encodedDigest string
switch {
case len(parts) == 4 && parts[0] == "pbkdf2_sha256":
parsed, err := strconv.Atoi(parts[1])
if err != nil {
return 0, "", nil, ErrInvalidPasswordHash
}
iterations, salt, encodedDigest = parsed, parts[2], parts[3]
case len(parts) == 2:
salt, encodedDigest = parts[0], parts[1]
default:
return 0, "", nil, ErrInvalidPasswordHash
}
if iterations < 1 || iterations > maximumPBKDF2Iterations || salt == "" {
return 0, "", nil, ErrInvalidPasswordHash
}
digest, err := hex.DecodeString(encodedDigest)
if err != nil || len(digest) != passwordDigestBytes {
return 0, "", nil, ErrInvalidPasswordHash
}
return iterations, salt, digest, nil
}
func pbkdf2SHA256(password, salt []byte, iterations, keyLength int) []byte {
const hashLength = sha256.Size
blocks := (keyLength + hashLength - 1) / hashLength
result := make([]byte, 0, blocks*hashLength)
buffer := make([]byte, len(salt)+4)
copy(buffer, salt)
for block := 1; block <= blocks; block++ {
binary.BigEndian.PutUint32(buffer[len(salt):], uint32(block))
mac := hmac.New(sha256.New, password)
_, _ = mac.Write(buffer)
u := mac.Sum(nil)
t := append([]byte(nil), u...)
for round := 1; round < iterations; round++ {
mac.Reset()
_, _ = mac.Write(u)
u = mac.Sum(nil)
for index := range t {
t[index] ^= u[index]
}
}
result = append(result, t...)
}
return result[:keyLength]
}
+36
View File
@@ -0,0 +1,36 @@
package identity
import "testing"
func TestPasswordHasherMatchesPythonGatewayFormat(t *testing.T) {
stored := "pbkdf2_sha256$600000$00112233445566778899aabbccddeeff$afca0887b188255f525e15e30f5aa5a0b210a3e253bfaf9630411f0782bb6573"
hasher := PasswordHasher{}
if !hasher.Verify("correct horse battery staple", stored) {
t.Fatal("expected Python-compatible password to verify")
}
if hasher.Verify("wrong", stored) {
t.Fatal("wrong password must not verify")
}
}
func TestPasswordHasherAcceptsLegacyFormat(t *testing.T) {
stored := "abcd1234$a377be9840f0f48e6a0f3577f08a9e56e095561e1e313517e3d589739f8d6907"
hasher := PasswordHasher{}
if !hasher.Verify("legacy", stored) {
t.Fatal("expected legacy password to verify")
}
if !hasher.NeedsUpgrade(stored) {
t.Fatal("legacy password should require upgrade")
}
}
func TestHashRoundTrip(t *testing.T) {
hasher := PasswordHasher{}
stored, err := hasher.Hash("long-enough-password")
if err != nil {
t.Fatal(err)
}
if !hasher.Verify("long-enough-password", stored) {
t.Fatal("new hash did not verify")
}
}
+29
View File
@@ -0,0 +1,29 @@
package identity
import "testing"
func TestRoleAndDirectPermissionsAreMerged(t *testing.T) {
account := Account{Role: "auditor", Permissions: []string{PermissionProviderManage}}
if !HasPermission(account, PermissionProviderRead) || !HasPermission(account, PermissionProviderManage) {
t.Fatal("expected role and direct permissions to be merged")
}
if HasPermission(account, PermissionIdentityManage) {
t.Fatal("unexpected identity management permission")
}
}
func TestSuperadminWildcard(t *testing.T) {
if !HasPermission(Account{Role: "superadmin"}, "future_resource:future_action") {
t.Fatal("superadmin wildcard must cover future permissions")
}
}
func TestNormalizePermissions(t *testing.T) {
permissions, err := normalizePermissions([]string{"provider:read", " provider:read ", "future.feature:execute"})
if err != nil || len(permissions) != 2 {
t.Fatalf("unexpected normalized permissions: %#v, %v", permissions, err)
}
if _, err := normalizePermissions([]string{"INVALID PERMISSION"}); err == nil {
t.Fatal("expected invalid permission to be rejected")
}
}
+104
View File
@@ -0,0 +1,104 @@
package identity
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"net"
"net/http"
"strings"
"time"
"github.com/redis/go-redis/v9"
)
// LoginLimiter bounds login attempts per client IP with a Redis-backed
// sliding-window counter. It is defense-in-depth layered on top of the
// per-account lockout (repository.RecordFailure):
//
// - IP limiter: throttles credential-stuffing spread across many accounts
// from one source (returns HTTP 429).
// - Account lockout: stops repeated attempts on a single account.
//
// If Redis is unavailable or not configured the limiter fails OPEN (login
// proceeds, account lockout still applies) rather than locking every user out.
type LoginLimiter struct {
client *redis.Client
max int
window time.Duration
}
func NewLoginLimiter(client *redis.Client, max int, window time.Duration) *LoginLimiter {
return &LoginLimiter{client: client, max: max, window: window}
}
// Allow reports whether a login attempt from ip may proceed.
func (l *LoginLimiter) Allow(ctx context.Context, ip string) bool {
if l == nil || l.client == nil || l.max <= 0 || l.window <= 0 || ip == "" {
return true // not configured -> fail open
}
now := time.Now().UnixMilli()
// Member must be unique per attempt so ZADD appends instead of overwriting
// the score of an identical timestamp.
res, err := allowLoginScript.Run(ctx, l.client,
[]string{loginLimitKey(ip)},
now, l.window.Milliseconds(), l.max, uniqueMember(now),
int(l.window.Seconds())+60,
).Int64Slice()
if err != nil {
return true // Redis hiccup -> fail open
}
// Script returns {1, count} when limited, {0, count+1} when admitted.
return len(res) == 2 && res[0] == 0
}
func loginLimitKey(ip string) string {
return "gateway:login-limit:" + ip
}
func uniqueMember(now int64) string {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
return fmt.Sprintf("%d:%d", now, time.Now().UnixNano())
}
return fmt.Sprintf("%d:%s", now, hex.EncodeToString(b[:]))
}
// allowLoginScript atomically trims the window, counts entries, and (when
// under the limit) records the attempt and refreshes the key TTL.
//
// KEYS[1] = key
// ARGV[1] = now (ms) ARGV[2] = window (ms) ARGV[3] = max
// ARGV[4] = unique member ARGV[5] = TTL (s)
var allowLoginScript = redis.NewScript(`
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local max = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', key, '-inf', now - window)
local count = redis.call('ZCARD', key)
if count >= max then
return {1, count}
end
redis.call('ZADD', key, now, ARGV[4])
redis.call('EXPIRE', key, ARGV[5])
return {0, count + 1}
`)
// ClientIP extracts the caller's IP for login rate limiting. X-Forwarded-For
// is trusted here because nginx is the only ingress and overwrites the header
// on every proxy hop; the first value is the client address. Falls back to
// RemoteAddr for direct connections.
func ClientIP(r *http.Request) string {
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
if first := strings.TrimSpace(strings.Split(fwd, ",")[0]); first != "" {
return first
}
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
return host
}
+34
View File
@@ -0,0 +1,34 @@
package identity
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestClientIP(t *testing.T) {
cases := []struct {
name string
remoteAddr string
xfwd string
want string
}{
{"xfwd first value", "10.0.0.1:52341", "203.0.113.9, 10.0.0.2", "203.0.113.9"},
{"xfwd single", "10.0.0.1:52341", "198.51.100.7", "198.51.100.7"},
{"xfwd with spaces", "10.0.0.1:52341", " 192.0.2.5 ", "192.0.2.5"},
{"no xfwd falls back to remote", "203.0.113.9:8080", "", "203.0.113.9"},
{"no xfwd and no port", "[2001:db8::1]:443", "", "2001:db8::1"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", nil)
req.RemoteAddr = tc.remoteAddr
if tc.xfwd != "" {
req.Header.Set("X-Forwarded-For", tc.xfwd)
}
if got := ClientIP(req); got != tc.want {
t.Fatalf("ClientIP() = %q, want %q", got, tc.want)
}
})
}
}
+335
View File
@@ -0,0 +1,335 @@
package identity
import (
"context"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
platformid "aigateway.local/core/internal/platform/id"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type Repository struct {
pool *pgxpool.Pool
}
func NewRepository(pool *pgxpool.Pool) *Repository {
return &Repository{pool: pool}
}
func (r *Repository) FindAdminByLogin(ctx context.Context, login string) (Account, error) {
if r.pool == nil {
return Account{}, ErrUnavailable
}
var account Account
account.Kind = KindAdmin
err := r.pool.QueryRow(ctx, `
SELECT id::text, username, display_name, role, permissions, password_hash, active,
failed_logins, locked_until, totp_enabled, encrypted_totp_secret,
totp_kek_version, totp_last_step, totp_backup_codes
FROM gateway.admin_accounts
WHERE lower(username) = lower($1)`, strings.TrimSpace(login)).Scan(
&account.ID, &account.Login, &account.DisplayName, &account.Role, &account.Permissions,
&account.PasswordHash, &account.Active, &account.FailedLogins,
&account.LockedUntil, &account.TOTPEnabled, &account.EncryptedTOTPSecret,
&account.TOTPKekVersion, &account.TOTPLastStep, &account.TOTPBackupCodes,
)
return account, mapRepositoryError(err)
}
func (r *Repository) FindAdminByID(ctx context.Context, id string) (Account, error) {
if r.pool == nil {
return Account{}, ErrUnavailable
}
var account Account
account.Kind = KindAdmin
err := r.pool.QueryRow(ctx, `
SELECT id::text, username, display_name, role, permissions, password_hash, active,
failed_logins, locked_until, totp_enabled, encrypted_totp_secret,
totp_kek_version, totp_last_step, totp_backup_codes
FROM gateway.admin_accounts
WHERE id = $1`, id).Scan(
&account.ID, &account.Login, &account.DisplayName, &account.Role, &account.Permissions,
&account.PasswordHash, &account.Active, &account.FailedLogins,
&account.LockedUntil, &account.TOTPEnabled, &account.EncryptedTOTPSecret,
&account.TOTPKekVersion, &account.TOTPLastStep, &account.TOTPBackupCodes,
)
return account, mapRepositoryError(err)
}
func (r *Repository) FindPortalByLogin(ctx context.Context, login string) (Account, error) {
if r.pool == nil {
return Account{}, ErrUnavailable
}
var account Account
account.Kind = KindPortal
err := r.pool.QueryRow(ctx, `
SELECT id::text, account, name, role, permissions, COALESCE(password_hash, ''), auth_source, active,
failed_logins, locked_until, totp_enabled, encrypted_totp_secret,
totp_kek_version, totp_last_step, totp_backup_codes, department_id::text
FROM gateway.portal_users
WHERE lower(account) = lower($1)`, strings.TrimSpace(login)).Scan(
&account.ID, &account.Login, &account.DisplayName, &account.Role, &account.Permissions, &account.PasswordHash,
&account.AuthSource, &account.Active, &account.FailedLogins,
&account.LockedUntil, &account.TOTPEnabled, &account.EncryptedTOTPSecret,
&account.TOTPKekVersion, &account.TOTPLastStep, &account.TOTPBackupCodes, &account.DepartmentID,
)
return account, mapRepositoryError(err)
}
func (r *Repository) FindPortalByID(ctx context.Context, id string) (Account, error) {
if r.pool == nil {
return Account{}, ErrUnavailable
}
var account Account
account.Kind = KindPortal
err := r.pool.QueryRow(ctx, `
SELECT id::text, account, name, role, permissions, COALESCE(password_hash, ''), auth_source, active,
failed_logins, locked_until, totp_enabled, encrypted_totp_secret,
totp_kek_version, totp_last_step, totp_backup_codes, department_id::text
FROM gateway.portal_users
WHERE id = $1`, id).Scan(
&account.ID, &account.Login, &account.DisplayName, &account.Role, &account.Permissions, &account.PasswordHash,
&account.AuthSource, &account.Active, &account.FailedLogins,
&account.LockedUntil, &account.TOTPEnabled, &account.EncryptedTOTPSecret,
&account.TOTPKekVersion, &account.TOTPLastStep, &account.TOTPBackupCodes, &account.DepartmentID,
)
return account, mapRepositoryError(err)
}
func (r *Repository) RecordFailure(ctx context.Context, account Account, maximum int, lockDuration time.Duration) (*time.Time, error) {
if r.pool == nil {
return nil, ErrUnavailable
}
table := "gateway.admin_accounts"
if account.Kind == KindPortal {
table = "gateway.portal_users"
}
query := fmt.Sprintf(`
UPDATE %s
SET locked_until = CASE
WHEN failed_logins + 1 >= $2
THEN clock_timestamp() + make_interval(secs => $3)
ELSE locked_until
END,
failed_logins = CASE WHEN failed_logins + 1 >= $2 THEN 0 ELSE failed_logins + 1 END,
updated_at = clock_timestamp()
WHERE id = $1
RETURNING locked_until`, table)
var lockedUntil *time.Time
err := r.pool.QueryRow(ctx, query, account.ID, maximum, int64(lockDuration.Seconds())).Scan(&lockedUntil)
return lockedUntil, mapRepositoryError(err)
}
func (r *Repository) CompleteLogin(ctx context.Context, account Account, upgradedHash *string) error {
if r.pool == nil {
return ErrUnavailable
}
table := "gateway.admin_accounts"
if account.Kind == KindPortal {
table = "gateway.portal_users"
}
query := fmt.Sprintf(`
UPDATE %s
SET failed_logins = 0,
locked_until = NULL,
last_login = clock_timestamp(),
password_hash = COALESCE($2::text, password_hash),
updated_at = clock_timestamp()
WHERE id = $1`, table)
result, err := r.pool.Exec(ctx, query, account.ID, upgradedHash)
if err != nil {
return fmt.Errorf("%w: %v", ErrUnavailable, err)
}
if result.RowsAffected() != 1 {
return ErrNotFound
}
return nil
}
func (r *Repository) SetPassword(ctx context.Context, account Account, passwordHash string) error {
query := fmt.Sprintf(`UPDATE %s SET password_hash=$2, failed_logins=0, locked_until=NULL, updated_at=clock_timestamp() WHERE id=$1`, identityTable(account.Kind))
return expectOne(r.pool, ctx, query, account.ID, passwordHash)
}
func (r *Repository) SetTOTPSecret(ctx context.Context, account Account, encrypted []byte, version int) error {
query := fmt.Sprintf(`
UPDATE %s
SET encrypted_totp_secret = $2, totp_kek_version = $3,
totp_enabled = false, totp_last_step = NULL,
totp_backup_codes = '[]'::jsonb, totp_confirmed_at = NULL,
updated_at = clock_timestamp()
WHERE id = $1`, identityTable(account.Kind))
return expectOne(r.pool, ctx, query, account.ID, encrypted, version)
}
func (r *Repository) EnableTOTP(ctx context.Context, account Account, step int64, records []BackupCodeRecord) error {
payload, err := json.Marshal(records)
if err != nil {
return err
}
query := fmt.Sprintf(`
UPDATE %s
SET totp_enabled = true, totp_last_step = $2,
totp_backup_codes = $3::jsonb, totp_confirmed_at = clock_timestamp(),
updated_at = clock_timestamp()
WHERE id = $1 AND encrypted_totp_secret IS NOT NULL AND NOT totp_enabled
AND (totp_last_step IS NULL OR totp_last_step < $2)`, identityTable(account.Kind))
return expectOne(r.pool, ctx, query, account.ID, step, payload)
}
func (r *Repository) ConsumeTOTPStep(ctx context.Context, account Account, step int64) (bool, error) {
if r.pool == nil {
return false, ErrUnavailable
}
query := fmt.Sprintf(`
UPDATE %s SET totp_last_step = $2, updated_at = clock_timestamp()
WHERE id = $1 AND totp_enabled
AND (totp_last_step IS NULL OR totp_last_step < $2)`, identityTable(account.Kind))
result, err := r.pool.Exec(ctx, query, account.ID, step)
if err != nil {
return false, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
return result.RowsAffected() == 1, nil
}
func (r *Repository) ConsumeBackupCode(ctx context.Context, account Account, hash string) (bool, error) {
if r.pool == nil {
return false, ErrUnavailable
}
tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return false, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
defer func() { _ = tx.Rollback(ctx) }()
query := fmt.Sprintf(`SELECT totp_backup_codes FROM %s WHERE id = $1 AND totp_enabled FOR UPDATE`, identityTable(account.Kind))
var payload []byte
if err := tx.QueryRow(ctx, query, account.ID).Scan(&payload); err != nil {
return false, mapRepositoryError(err)
}
var records []BackupCodeRecord
if err := json.Unmarshal(payload, &records); err != nil {
return false, fmt.Errorf("invalid stored TOTP backup codes: %w", err)
}
found := -1
for index := range records {
if records[index].UsedAt == nil && subtle.ConstantTimeCompare([]byte(records[index].Hash), []byte(hash)) == 1 {
found = index
}
}
if found < 0 {
return false, nil
}
now := time.Now().UTC()
records[found].UsedAt = &now
payload, err = json.Marshal(records)
if err != nil {
return false, err
}
update := fmt.Sprintf(`UPDATE %s SET totp_backup_codes = $2::jsonb, updated_at = clock_timestamp() WHERE id = $1`, identityTable(account.Kind))
if _, err := tx.Exec(ctx, update, account.ID, payload); err != nil {
return false, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
if err := tx.Commit(ctx); err != nil {
return false, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
return true, nil
}
func (r *Repository) DisableTOTP(ctx context.Context, account Account) error {
query := fmt.Sprintf(`
UPDATE %s SET encrypted_totp_secret = NULL, totp_kek_version = NULL,
totp_enabled = false, totp_last_step = NULL,
totp_backup_codes = '[]'::jsonb, totp_confirmed_at = NULL,
updated_at = clock_timestamp()
WHERE id = $1 AND totp_enabled`, identityTable(account.Kind))
return expectOne(r.pool, ctx, query, account.ID)
}
func (r *Repository) ReplaceBackupCodes(ctx context.Context, account Account, records []BackupCodeRecord) error {
payload, err := json.Marshal(records)
if err != nil {
return err
}
query := fmt.Sprintf(`UPDATE %s SET totp_backup_codes = $2::jsonb, updated_at = clock_timestamp() WHERE id = $1 AND totp_enabled`, identityTable(account.Kind))
return expectOne(r.pool, ctx, query, account.ID, payload)
}
func identityTable(kind Kind) string {
if kind == KindPortal {
return "gateway.portal_users"
}
return "gateway.admin_accounts"
}
func expectOne(pool *pgxpool.Pool, ctx context.Context, query string, arguments ...any) error {
if pool == nil {
return ErrUnavailable
}
result, err := pool.Exec(ctx, query, arguments...)
if err != nil {
return fmt.Errorf("%w: %v", ErrUnavailable, err)
}
if result.RowsAffected() != 1 {
return ErrNotFound
}
return nil
}
func (r *Repository) CreateAdmin(ctx context.Context, login, displayName, role, passwordHash string) (string, error) {
if r.pool == nil {
return "", ErrUnavailable
}
id, err := platformid.NewUUID()
if err != nil {
return "", err
}
result, err := r.pool.Exec(ctx, `
INSERT INTO gateway.admin_accounts
(id, username, display_name, role, password_hash)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT DO NOTHING`, id, strings.ToLower(strings.TrimSpace(login)), displayName, role, passwordHash)
if err != nil {
return "", err
}
if result.RowsAffected() != 1 {
return "", errors.New("administrator already exists")
}
return id, nil
}
func (r *Repository) CreatePortalUser(ctx context.Context, login, displayName, passwordHash string) (string, error) {
if r.pool == nil {
return "", ErrUnavailable
}
id, err := platformid.NewUUID()
if err != nil {
return "", err
}
result, err := r.pool.Exec(ctx, `
INSERT INTO gateway.portal_users (id, account, name, password_hash, auth_source)
VALUES ($1, $2, $3, $4, 'local')
ON CONFLICT DO NOTHING`, id, strings.ToLower(strings.TrimSpace(login)), displayName, passwordHash)
if err != nil {
return "", err
}
if result.RowsAffected() != 1 {
return "", errors.New("portal user already exists")
}
return id, nil
}
func mapRepositoryError(err error) error {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return fmt.Errorf("%w: %v", ErrUnavailable, err)
}
return nil
}
+636
View File
@@ -0,0 +1,636 @@
package identity
import (
"context"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/xml"
"errors"
"io"
"net/http"
"net/url"
"strings"
"time"
"aigateway.local/core/internal/platform/apiresponse"
platformid "aigateway.local/core/internal/platform/id"
"github.com/beevik/etree"
"github.com/crewjam/saml"
"github.com/crewjam/saml/samlsp"
dsig "github.com/russellhaering/goxmldsig"
)
const samlMaxResponse = 4 << 20
type SAMLConfig struct {
MetadataURL string `json:"metadata_url"`
SPEntityID string `json:"sp_entity_id"`
ACSURL string `json:"acs_url"`
EmailAttribute string `json:"email_attribute"`
NameAttribute string `json:"name_attribute"`
}
type SAMLProvider struct {
ID string
Code string
DisplayName string
PortalReturnURL string
AutoProvision bool
DefaultDepartmentID *string
Enabled bool
Revision int64
Config SAMLConfig
CreatedAt time.Time
UpdatedAt time.Time
}
type samlProviderInput struct {
Code string `json:"code"`
DisplayName string `json:"display_name"`
MetadataURL string `json:"metadata_url"`
SPEntityID string `json:"sp_entity_id"`
ACSURL string `json:"acs_url"`
PortalReturnURL string `json:"portal_return_url"`
EmailAttribute string `json:"email_attribute"`
NameAttribute string `json:"name_attribute"`
AutoProvision bool `json:"auto_provision"`
DefaultDepartmentID *string `json:"default_department_id"`
Enabled bool `json:"enabled"`
}
type samlChallenge struct {
ProviderID string `json:"provider_id"`
RequestID string `json:"request_id"`
}
type samlMetadataCacheEntry struct {
Metadata *saml.EntityDescriptor
ExpiresAt time.Time
}
type PublicIdentityProvider struct {
Code string `json:"code"`
DisplayName string `json:"display_name"`
Kind string `json:"kind"`
}
func (h *ManagementHTTPHandler) registerSAML() {
h.mux.HandleFunc("GET /api/v1/admin/saml-providers", h.listSAMLProviders)
h.mux.HandleFunc("POST /api/v1/admin/saml-providers", h.createSAMLProvider)
h.mux.HandleFunc("PUT /api/v1/admin/saml-providers/{provider_id}", h.updateSAMLProvider)
}
func (h *ManagementHTTPHandler) listSAMLProviders(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requirePermission(w, r); !ok {
return
}
providers, err := h.service.repository.ListSAMLProviders(r.Context())
if err != nil {
h.writeError(w, err)
return
}
items := make([]map[string]any, 0, len(providers))
for _, provider := range providers {
items = append(items, samlView(provider))
}
apiresponse.OK(w, items)
}
func (h *ManagementHTTPHandler) createSAMLProvider(w http.ResponseWriter, r *http.Request) {
actor, ok := h.requirePermission(w, r)
if !ok {
return
}
provider, ok := h.decodeSAMLProvider(w, r)
if !ok {
return
}
created, err := h.service.repository.CreateSAMLProvider(r.Context(), provider, actor.ID)
if err != nil {
h.writeError(w, err)
return
}
apiresponse.OK(w, samlView(created))
}
func (h *ManagementHTTPHandler) updateSAMLProvider(w http.ResponseWriter, r *http.Request) {
actor, ok := h.requirePermission(w, r)
if !ok {
return
}
provider, ok := h.decodeSAMLProvider(w, r)
if !ok {
return
}
provider.ID = r.PathValue("provider_id")
updated, err := h.service.repository.UpdateSAMLProvider(r.Context(), provider, actor.ID)
if err != nil {
h.writeError(w, err)
return
}
apiresponse.OK(w, samlView(updated))
}
func (h *ManagementHTTPHandler) decodeSAMLProvider(w http.ResponseWriter, r *http.Request) (SAMLProvider, bool) {
var input samlProviderInput
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
decoder.DisallowUnknownFields()
if decoder.Decode(&input) != nil {
apiresponse.Error(w, http.StatusBadRequest, "请求格式无效")
return SAMLProvider{}, false
}
input.Code = strings.ToLower(strings.TrimSpace(input.Code))
input.DisplayName = strings.TrimSpace(input.DisplayName)
input.EmailAttribute = strings.TrimSpace(input.EmailAttribute)
input.NameAttribute = strings.TrimSpace(input.NameAttribute)
if !oidcCodePattern.MatchString(input.Code) || input.DisplayName == "" || len(input.DisplayName) > 128 {
apiresponse.Error(w, http.StatusBadRequest, "身份源代码或名称无效")
return SAMLProvider{}, false
}
metadataURL, err := validateOIDCURL(r.Context(), input.MetadataURL, h.service.allowPrivateIdentityProvider)
if err != nil {
apiresponse.Error(w, http.StatusBadRequest, "SAML metadata URL 无效")
return SAMLProvider{}, false
}
entityID, err := validateSAMLEntityID(input.SPEntityID)
if err != nil {
apiresponse.Error(w, http.StatusBadRequest, "SAML SP Entity ID 无效")
return SAMLProvider{}, false
}
acsURL, err := validateAbsoluteURL(input.ACSURL)
acs, _ := url.Parse(acsURL)
expectedACSSuffix := "/api/v1/portal/sso/" + input.Code + "/callback"
if err != nil || acs.Fragment != "" || !strings.HasSuffix(strings.TrimRight(acs.Path, "/"), expectedACSSuffix) {
apiresponse.Error(w, http.StatusBadRequest, "SAML ACS URL 无效")
return SAMLProvider{}, false
}
returnURL, err := validateAbsoluteURL(input.PortalReturnURL)
if err != nil {
apiresponse.Error(w, http.StatusBadRequest, "门户返回 URL 无效")
return SAMLProvider{}, false
}
if input.EmailAttribute == "" {
input.EmailAttribute = "email"
}
if input.NameAttribute == "" {
input.NameAttribute = "displayName"
}
if len(input.EmailAttribute) > 256 || len(input.NameAttribute) > 256 {
apiresponse.Error(w, http.StatusBadRequest, "SAML 属性名过长")
return SAMLProvider{}, false
}
if input.DefaultDepartmentID != nil && *input.DefaultDepartmentID != "" {
department, err := h.service.repository.GetDepartment(r.Context(), *input.DefaultDepartmentID)
if err != nil || !department.Active {
apiresponse.Error(w, http.StatusBadRequest, "默认部门不存在或已停用")
return SAMLProvider{}, false
}
}
provider := SAMLProvider{
Code: input.Code, DisplayName: input.DisplayName, PortalReturnURL: returnURL,
AutoProvision: input.AutoProvision, DefaultDepartmentID: input.DefaultDepartmentID, Enabled: input.Enabled,
Config: SAMLConfig{MetadataURL: metadataURL, SPEntityID: entityID, ACSURL: acsURL, EmailAttribute: input.EmailAttribute, NameAttribute: input.NameAttribute},
}
if input.Enabled {
if _, err := h.service.samlServiceProvider(r.Context(), provider); err != nil {
apiresponse.Error(w, http.StatusBadRequest, "SAML metadata 无法验证或缺少有效签名证书")
return SAMLProvider{}, false
}
}
return provider, true
}
func samlView(provider SAMLProvider) map[string]any {
return map[string]any{
"id": provider.ID, "kind": "saml", "code": provider.Code, "display_name": provider.DisplayName,
"metadata_url": provider.Config.MetadataURL, "sp_entity_id": provider.Config.SPEntityID,
"acs_url": provider.Config.ACSURL, "portal_return_url": provider.PortalReturnURL,
"email_attribute": provider.Config.EmailAttribute, "name_attribute": provider.Config.NameAttribute,
"auto_provision": provider.AutoProvision, "default_department_id": provider.DefaultDepartmentID,
"enabled": provider.Enabled, "revision": provider.Revision,
}
}
func (h *HTTPHandler) startSSO(w http.ResponseWriter, r *http.Request) {
kind, err := h.service.repository.GetIdentityProviderKind(r.Context(), r.PathValue("provider_code"))
if err != nil {
http.NotFound(w, r)
return
}
if kind == "saml" {
h.startSAML(w, r)
return
}
h.startOIDC(w, r)
}
func (h *HTTPHandler) startSAML(w http.ResponseWriter, r *http.Request) {
provider, err := h.service.repository.GetSAMLProviderByCode(r.Context(), r.PathValue("provider_code"))
if err != nil || !provider.Enabled {
http.NotFound(w, r)
return
}
sp, err := h.service.samlServiceProvider(r.Context(), provider)
if err != nil {
apiresponse.Error(w, http.StatusBadGateway, "SAML metadata 获取失败")
return
}
idpURL := sp.GetSSOBindingLocation(saml.HTTPRedirectBinding)
if idpURL == "" {
apiresponse.Error(w, http.StatusBadGateway, "SAML 身份源不支持 Redirect 登录")
return
}
authnRequest, err := sp.MakeAuthenticationRequest(idpURL, saml.HTTPRedirectBinding, saml.HTTPPostBinding)
if err != nil {
apiresponse.Error(w, http.StatusBadGateway, "SAML 登录请求生成失败")
return
}
relayState, err := h.service.sessions.StoreOneTime(r.Context(), "saml-state", samlChallenge{ProviderID: provider.ID, RequestID: authnRequest.ID}, 5*time.Minute)
if err != nil {
h.writeIdentityError(w, err)
return
}
target, err := authnRequest.Redirect(relayState, sp)
if err != nil {
apiresponse.Error(w, http.StatusBadGateway, "SAML 登录请求生成失败")
return
}
http.Redirect(w, r, target.String(), http.StatusFound)
}
func (h *HTTPHandler) callbackSAML(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, samlMaxResponse)
if err := r.ParseForm(); err != nil || r.PostForm.Get("SAMLResponse") == "" || r.PostForm.Get("RelayState") == "" || r.PostForm.Get("SAMLart") != "" {
apiresponse.Error(w, http.StatusBadRequest, "SAML 回调格式无效")
return
}
var challenge samlChallenge
if err := h.service.sessions.ConsumeOneTime(r.Context(), "saml-state", r.PostForm.Get("RelayState"), &challenge); err != nil {
apiresponse.Error(w, http.StatusUnauthorized, "SAML RelayState 无效或已使用")
return
}
provider, err := h.service.repository.GetSAMLProviderByCode(r.Context(), r.PathValue("provider_code"))
if err != nil || !provider.Enabled || provider.ID != challenge.ProviderID {
apiresponse.Error(w, http.StatusUnauthorized, "SAML 身份源无效")
return
}
sp, err := h.service.samlServiceProvider(r.Context(), provider)
if err != nil {
apiresponse.Error(w, http.StatusBadGateway, "SAML metadata 获取失败")
return
}
assertion, err := sp.ParseResponse(r, []string{challenge.RequestID})
if err != nil || assertion == nil || assertion.Subject == nil || assertion.Subject.NameID == nil || strings.TrimSpace(assertion.Subject.NameID.Value) == "" {
apiresponse.Error(w, http.StatusUnauthorized, "SAML 断言校验失败")
return
}
claimed, err := h.service.sessions.ClaimIdentifier(r.Context(), "saml-assertion", assertion.ID, 10*time.Minute)
if err != nil {
h.writeIdentityError(w, err)
return
}
if !claimed {
apiresponse.Error(w, http.StatusUnauthorized, "SAML 断言已使用")
return
}
subject := strings.TrimSpace(assertion.Subject.NameID.Value)
email := samlAttribute(assertion, provider.Config.EmailAttribute)
name := samlAttribute(assertion, provider.Config.NameAttribute)
account, err := h.service.repository.resolveExternalAccount(r.Context(), externalProvider{
ID: provider.ID, Code: provider.Code, AuthSource: "saml", AutoProvision: provider.AutoProvision, DefaultDepartmentID: provider.DefaultDepartmentID,
}, externalClaims{Subject: subject, Email: email, PreferredUsername: subject, Name: name})
if err != nil {
h.writeIdentityError(w, err)
return
}
if !account.Active {
h.writeIdentityError(w, ErrAccountDisabled)
return
}
token, err := h.service.sessions.Create(r.Context(), principalFor(account))
if err != nil {
h.writeIdentityError(w, err)
return
}
exchange, err := h.service.sessions.StoreOneTime(r.Context(), "oidc-exchange", oidcExchange{Token: token}, time.Minute)
if err != nil {
h.writeIdentityError(w, err)
return
}
returnURL, _ := url.Parse(provider.PortalReturnURL)
query := returnURL.Query()
query.Set("sso_code", exchange)
returnURL.RawQuery = query.Encode()
http.Redirect(w, r, returnURL.String(), http.StatusFound)
}
func (h *HTTPHandler) samlMetadata(w http.ResponseWriter, r *http.Request) {
provider, err := h.service.repository.GetSAMLProviderByCode(r.Context(), r.PathValue("provider_code"))
if err != nil {
http.NotFound(w, r)
return
}
sp, err := newSAMLServiceProvider(provider, nil, h.service.oidcHTTPClient())
if err != nil {
apiresponse.Error(w, http.StatusBadGateway, "SAML metadata 获取失败")
return
}
payload, err := xml.MarshalIndent(sp.Metadata(), "", " ")
if err != nil {
apiresponse.Error(w, http.StatusInternalServerError, "SAML SP metadata 生成失败")
return
}
w.Header().Set("Content-Type", "application/samlmetadata+xml; charset=utf-8")
w.Header().Set("X-Content-Type-Options", "nosniff")
_, _ = w.Write(append([]byte(xml.Header), payload...))
}
func (s *Service) samlServiceProvider(ctx context.Context, provider SAMLProvider) (*saml.ServiceProvider, error) {
metadata, err := s.fetchSAMLMetadata(ctx, provider.Config.MetadataURL)
if err != nil {
return nil, err
}
return newSAMLServiceProvider(provider, metadata, s.oidcHTTPClient())
}
func newSAMLServiceProvider(provider SAMLProvider, metadata *saml.EntityDescriptor, client *http.Client) (*saml.ServiceProvider, error) {
acsURL, err := url.Parse(provider.Config.ACSURL)
if err != nil {
return nil, err
}
metadataURL := *acsURL
metadataURL.Path = strings.TrimSuffix(metadataURL.Path, "/callback") + "/metadata"
metadataURL.RawQuery = ""
metadataURL.Fragment = ""
return &saml.ServiceProvider{
EntityID: provider.Config.SPEntityID, MetadataURL: metadataURL, AcsURL: *acsURL,
IDPMetadata: metadata, HTTPClient: client, AllowIDPInitiated: false,
SignatureVerifier: modernSAMLSignatureVerifier{},
}, nil
}
type modernSAMLSignatureVerifier struct{}
func (modernSAMLSignatureVerifier) VerifySignature(validationContext *dsig.ValidationContext, element *etree.Element) error {
if err := validateSAMLSignatureAlgorithms(element); err != nil {
return err
}
_, err := validationContext.Validate(element)
return err
}
func validateSAMLSignatureAlgorithms(element *etree.Element) error {
method := element.FindElement("./Signature/SignedInfo/SignatureMethod")
if method == nil {
return errors.New("SAML signature method missing")
}
switch method.SelectAttrValue("Algorithm", "") {
case dsig.RSASHA256SignatureMethod, dsig.RSASHA384SignatureMethod, dsig.RSASHA512SignatureMethod,
dsig.ECDSASHA256SignatureMethod, dsig.ECDSASHA384SignatureMethod, dsig.ECDSASHA512SignatureMethod:
default:
return errors.New("legacy or unsupported SAML signature algorithm")
}
allowedDigests := map[string]bool{
"http://www.w3.org/2001/04/xmlenc#sha256": true,
"http://www.w3.org/2001/04/xmldsig-more#sha384": true,
"http://www.w3.org/2001/04/xmlenc#sha512": true,
}
digests := element.FindElements("./Signature/SignedInfo/Reference/DigestMethod")
if len(digests) == 0 {
return errors.New("SAML digest method missing")
}
for _, digest := range digests {
if !allowedDigests[digest.SelectAttrValue("Algorithm", "")] {
return errors.New("legacy or unsupported SAML digest algorithm")
}
}
return nil
}
func (s *Service) fetchSAMLMetadata(ctx context.Context, rawURL string) (*saml.EntityDescriptor, error) {
target, err := validateOIDCURL(ctx, rawURL, s.allowPrivateIdentityProvider)
if err != nil {
return nil, err
}
now := time.Now()
s.samlMetadataMu.RLock()
cached, found := s.samlMetadata[target]
s.samlMetadataMu.RUnlock()
if found && now.Before(cached.ExpiresAt) {
return cached.Metadata, nil
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
return nil, err
}
response, err := s.oidcHTTPClient().Do(req)
if err != nil {
return nil, err
}
defer response.Body.Close()
payload, err := io.ReadAll(io.LimitReader(response.Body, oidcMaxResponse+1))
if err != nil || response.StatusCode/100 != 2 || len(payload) > oidcMaxResponse {
return nil, errors.New("invalid SAML metadata response")
}
metadata, err := samlsp.ParseMetadata(payload)
if err != nil {
return nil, err
}
if err := validateSAMLMetadata(ctx, metadata, s.allowPrivateIdentityProvider, now); err != nil {
return nil, err
}
expiresAt := now.Add(time.Minute)
if !metadata.ValidUntil.IsZero() && metadata.ValidUntil.Before(expiresAt) {
expiresAt = metadata.ValidUntil
}
s.samlMetadataMu.Lock()
if s.samlMetadata == nil {
s.samlMetadata = make(map[string]samlMetadataCacheEntry)
}
s.samlMetadata[target] = samlMetadataCacheEntry{Metadata: metadata, ExpiresAt: expiresAt}
s.samlMetadataMu.Unlock()
return metadata, nil
}
func validateSAMLMetadata(ctx context.Context, metadata *saml.EntityDescriptor, allowPrivate bool, now time.Time) error {
if metadata == nil || strings.TrimSpace(metadata.EntityID) == "" || len(metadata.IDPSSODescriptors) == 0 {
return errors.New("metadata has no IDP descriptor")
}
if !metadata.ValidUntil.IsZero() && !metadata.ValidUntil.After(now) {
return errors.New("metadata expired")
}
descriptor := metadata.IDPSSODescriptors[0]
redirectFound := false
for _, endpoint := range descriptor.SingleSignOnServices {
if endpoint.Binding == saml.HTTPRedirectBinding {
if _, err := validateOIDCURL(ctx, endpoint.Location, allowPrivate); err != nil {
return err
}
redirectFound = true
}
}
if !redirectFound {
return errors.New("metadata has no redirect SSO endpoint")
}
validSigningCertificate := false
for _, key := range descriptor.KeyDescriptors {
if key.Use != "" && key.Use != "signing" {
continue
}
for _, encoded := range key.KeyInfo.X509Data.X509Certificates {
der, err := base64.StdEncoding.DecodeString(strings.Join(strings.Fields(encoded.Data), ""))
if err != nil {
continue
}
certificate, err := x509.ParseCertificate(der)
if err == nil && !now.Before(certificate.NotBefore) && now.Before(certificate.NotAfter) {
validSigningCertificate = true
}
}
}
if !validSigningCertificate {
return errors.New("metadata has no currently valid signing certificate")
}
return nil
}
func validateSAMLEntityID(raw string) (string, error) {
value := strings.TrimSpace(raw)
if value == "" || len(value) > 512 {
return "", errors.New("invalid entity ID")
}
parsed, err := url.Parse(value)
if err != nil || parsed.Scheme == "" || parsed.Fragment != "" {
return "", errors.New("invalid entity ID")
}
if (parsed.Scheme == "http" || parsed.Scheme == "https") && (parsed.Hostname() == "" || parsed.User != nil) {
return "", errors.New("invalid entity ID")
}
return value, nil
}
func samlAttribute(assertion *saml.Assertion, name string) string {
for _, statement := range assertion.AttributeStatements {
for _, attribute := range statement.Attributes {
if attribute.Name != name && attribute.FriendlyName != name {
continue
}
for _, value := range attribute.Values {
if strings.TrimSpace(value.Value) != "" {
return strings.TrimSpace(value.Value)
}
}
}
}
return ""
}
func (r *Repository) GetIdentityProviderKind(ctx context.Context, code string) (string, error) {
var kind string
err := r.pool.QueryRow(ctx, `SELECT kind FROM gateway.identity_providers WHERE code=$1 AND enabled`, strings.ToLower(strings.TrimSpace(code))).Scan(&kind)
return kind, mapRepositoryError(err)
}
func (r *Repository) ListPublicIdentityProviders(ctx context.Context) ([]PublicIdentityProvider, error) {
rows, err := r.pool.Query(ctx, `SELECT code,display_name,kind FROM gateway.identity_providers WHERE enabled ORDER BY code`)
if err != nil {
return nil, ErrUnavailable
}
defer rows.Close()
providers := []PublicIdentityProvider{}
for rows.Next() {
var provider PublicIdentityProvider
if err := rows.Scan(&provider.Code, &provider.DisplayName, &provider.Kind); err != nil {
return nil, ErrUnavailable
}
providers = append(providers, provider)
}
return providers, mapRepositoryError(rows.Err())
}
func (r *Repository) ListSAMLProviders(ctx context.Context) ([]SAMLProvider, error) {
rows, err := r.pool.Query(ctx, `SELECT id::text,code,display_name,portal_return_url,auto_provision,default_department_id::text,enabled,revision,config,created_at,updated_at FROM gateway.identity_providers WHERE kind='saml' ORDER BY code`)
if err != nil {
return nil, ErrUnavailable
}
defer rows.Close()
providers := []SAMLProvider{}
for rows.Next() {
provider, err := scanSAMLProvider(rows)
if err != nil {
return nil, err
}
providers = append(providers, provider)
}
return providers, mapRepositoryError(rows.Err())
}
func (r *Repository) GetSAMLProviderByCode(ctx context.Context, code string) (SAMLProvider, error) {
return scanSAMLProvider(r.pool.QueryRow(ctx, `SELECT id::text,code,display_name,portal_return_url,auto_provision,default_department_id::text,enabled,revision,config,created_at,updated_at FROM gateway.identity_providers WHERE kind='saml' AND code=$1`, strings.ToLower(strings.TrimSpace(code))))
}
type rowScanner interface {
Scan(dest ...any) error
}
func scanSAMLProvider(row rowScanner) (SAMLProvider, error) {
var provider SAMLProvider
var configJSON []byte
if err := row.Scan(&provider.ID, &provider.Code, &provider.DisplayName, &provider.PortalReturnURL, &provider.AutoProvision, &provider.DefaultDepartmentID, &provider.Enabled, &provider.Revision, &configJSON, &provider.CreatedAt, &provider.UpdatedAt); err != nil {
return provider, mapRepositoryError(err)
}
if json.Unmarshal(configJSON, &provider.Config) != nil {
return provider, ErrUnavailable
}
return provider, nil
}
func (r *Repository) CreateSAMLProvider(ctx context.Context, provider SAMLProvider, actor string) (SAMLProvider, error) {
id, err := platformid.NewUUID()
if err != nil {
return provider, ErrUnavailable
}
provider.ID = id
return r.storeSAMLProvider(ctx, provider, actor, true)
}
func (r *Repository) UpdateSAMLProvider(ctx context.Context, provider SAMLProvider, actor string) (SAMLProvider, error) {
return r.storeSAMLProvider(ctx, provider, actor, false)
}
func (r *Repository) storeSAMLProvider(ctx context.Context, provider SAMLProvider, actor string, creating bool) (SAMLProvider, error) {
configJSON, err := json.Marshal(provider.Config)
if err != nil {
return provider, ErrUnavailable
}
tx, err := r.pool.Begin(ctx)
if err != nil {
return provider, ErrUnavailable
}
defer tx.Rollback(ctx)
if creating {
err = tx.QueryRow(ctx, `INSERT INTO gateway.identity_providers(id,code,kind,display_name,portal_return_url,auto_provision,default_department_id,enabled,config) VALUES($1,$2,'saml',$3,$4,$5,$6,$7,$8) RETURNING revision,created_at,updated_at`, provider.ID, provider.Code, provider.DisplayName, provider.PortalReturnURL, provider.AutoProvision, provider.DefaultDepartmentID, provider.Enabled, configJSON).Scan(&provider.Revision, &provider.CreatedAt, &provider.UpdatedAt)
} else {
err = tx.QueryRow(ctx, `UPDATE gateway.identity_providers SET code=$2,display_name=$3,portal_return_url=$4,auto_provision=$5,default_department_id=$6,enabled=$7,config=$8,revision=revision+1,updated_at=clock_timestamp() WHERE id=$1 AND kind='saml' RETURNING revision,created_at,updated_at`, provider.ID, provider.Code, provider.DisplayName, provider.PortalReturnURL, provider.AutoProvision, provider.DefaultDepartmentID, provider.Enabled, configJSON).Scan(&provider.Revision, &provider.CreatedAt, &provider.UpdatedAt)
}
if err != nil {
return provider, mapManagementError(err)
}
eventID, err := platformid.NewUUID()
if err != nil {
return provider, ErrUnavailable
}
eventType := "identity_provider.updated"
if creating {
eventType = "identity_provider.created"
}
payload, _ := json.Marshal(map[string]any{"identity_provider_id": provider.ID, "kind": "saml", "actor_id": actor})
if _, err = tx.Exec(ctx, `INSERT INTO gateway.outbox_events(event_id,event_type,event_version,aggregate_type,aggregate_id,payload) VALUES($1,$2,1,'identity_provider',$3,$4)`, eventID, eventType, provider.ID, payload); err != nil {
return provider, ErrUnavailable
}
if err := tx.Commit(ctx); err != nil {
return provider, ErrUnavailable
}
return provider, nil
}
+115
View File
@@ -0,0 +1,115 @@
package identity
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"math/big"
"testing"
"time"
"github.com/beevik/etree"
"github.com/crewjam/saml"
dsig "github.com/russellhaering/goxmldsig"
)
func TestValidateSAMLMetadataRequiresLiveSigningCertificate(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
certificate := makeTestCertificate(t, now.Add(-time.Hour), now.Add(time.Hour))
metadata := testIDPMetadata(certificate)
if err := validateSAMLMetadata(context.Background(), metadata, true, now); err != nil {
t.Fatalf("valid metadata rejected: %v", err)
}
metadata.IDPSSODescriptors[0].KeyDescriptors = nil
if err := validateSAMLMetadata(context.Background(), metadata, true, now); err == nil {
t.Fatal("metadata without signing certificate was accepted")
}
metadata = testIDPMetadata(makeTestCertificate(t, now.Add(-2*time.Hour), now.Add(-time.Hour)))
if err := validateSAMLMetadata(context.Background(), metadata, true, now); err == nil {
t.Fatal("expired signing certificate was accepted")
}
metadata = testIDPMetadata(certificate)
metadata.IDPSSODescriptors[0].SingleSignOnServices = nil
if err := validateSAMLMetadata(context.Background(), metadata, true, now); err == nil {
t.Fatal("metadata without redirect SSO endpoint was accepted")
}
}
func TestSAMLEntityIDAndAttributes(t *testing.T) {
if got, err := validateSAMLEntityID(" urn:example:gateway "); err != nil || got != "urn:example:gateway" {
t.Fatalf("valid URN entity ID rejected: %q %v", got, err)
}
for _, raw := range []string{"", "relative", "https://user:secret@example.com/sp", "urn:example:sp#fragment"} {
if _, err := validateSAMLEntityID(raw); err == nil {
t.Fatalf("invalid entity ID accepted: %q", raw)
}
}
assertion := &saml.Assertion{AttributeStatements: []saml.AttributeStatement{{Attributes: []saml.Attribute{{
FriendlyName: "mail", Name: "urn:oid:0.9.2342.19200300.100.1.3",
Values: []saml.AttributeValue{{Value: " user@example.com "}},
}}}}}
if got := samlAttribute(assertion, "mail"); got != "user@example.com" {
t.Fatalf("unexpected friendly-name attribute %q", got)
}
if got := samlAttribute(assertion, "urn:oid:0.9.2342.19200300.100.1.3"); got != "user@example.com" {
t.Fatalf("unexpected named attribute %q", got)
}
}
func TestSAMLSignatureAlgorithmPolicyRejectsSHA1(t *testing.T) {
document := etree.NewDocument()
if err := document.ReadFromString(`<Response><Signature><SignedInfo><SignatureMethod Algorithm="` + dsig.RSASHA256SignatureMethod + `"/><Reference><DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/></Reference></SignedInfo></Signature></Response>`); err != nil {
t.Fatal(err)
}
if err := validateSAMLSignatureAlgorithms(document.Root()); err != nil {
t.Fatalf("SHA-256 signature policy rejected: %v", err)
}
document.Root().FindElement("./Signature/SignedInfo/SignatureMethod").SelectAttr("Algorithm").Value = dsig.RSASHA1SignatureMethod
if err := validateSAMLSignatureAlgorithms(document.Root()); err == nil {
t.Fatal("SHA-1 signature algorithm was accepted")
}
document.Root().FindElement("./Signature/SignedInfo/SignatureMethod").SelectAttr("Algorithm").Value = dsig.RSASHA256SignatureMethod
document.Root().FindElement("./Signature/SignedInfo/Reference/DigestMethod").SelectAttr("Algorithm").Value = "http://www.w3.org/2000/09/xmldsig#sha1"
if err := validateSAMLSignatureAlgorithms(document.Root()); err == nil {
t.Fatal("SHA-1 digest algorithm was accepted")
}
}
func testIDPMetadata(certificate *x509.Certificate) *saml.EntityDescriptor {
return &saml.EntityDescriptor{
EntityID: "https://idp.example/metadata",
IDPSSODescriptors: []saml.IDPSSODescriptor{{
SSODescriptor: saml.SSODescriptor{RoleDescriptor: saml.RoleDescriptor{KeyDescriptors: []saml.KeyDescriptor{{
Use: "signing", KeyInfo: saml.KeyInfo{X509Data: saml.X509Data{X509Certificates: []saml.X509Certificate{{Data: base64.StdEncoding.EncodeToString(certificate.Raw)}}}},
}}}},
SingleSignOnServices: []saml.Endpoint{{Binding: saml.HTTPRedirectBinding, Location: "http://127.0.0.1:9091/sso"}},
}},
}
}
func makeTestCertificate(t *testing.T, notBefore, notAfter time.Time) *x509.Certificate {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "SAML test"},
NotBefore: notBefore, NotAfter: notAfter, KeyUsage: x509.KeyUsageDigitalSignature,
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
certificate, err := x509.ParseCertificate(der)
if err != nil {
t.Fatal(err)
}
return certificate
}
+362
View File
@@ -0,0 +1,362 @@
package identity
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"sync"
"time"
"aigateway.local/core/internal/platform/config"
"aigateway.local/core/internal/platform/cryptox"
)
var (
ErrInvalidCredentials = errors.New("invalid credentials")
ErrAccountDisabled = errors.New("account disabled")
ErrInvalidTOTP = errors.New("invalid or reused TOTP code")
ErrTOTPAlreadyEnabled = errors.New("TOTP is already enabled")
ErrTOTPNotEnabled = errors.New("TOTP is not enabled")
ErrTOTPSetupRequired = errors.New("TOTP setup is required")
)
const dummyPasswordHash = "pbkdf2_sha256$600000$00112233445566778899aabbccddeeff$afca0887b188255f525e15e30f5aa5a0b210a3e253bfaf9630411f0782bb6573"
type LockedError struct {
Until time.Time
}
func (e LockedError) Error() string {
return fmt.Sprintf("account locked until %s", e.Until.Format(time.RFC3339))
}
type LoginResult struct {
Token string
TempToken string
RequireTOTP bool
Account Account
}
type TOTPSetupResult struct {
Secret string
ProvisioningURI string
}
type Service struct {
repository *Repository
sessions *SessionStore
limiter *LoginLimiter
hasher PasswordHasher
config config.Auth
totpCipher cryptox.Cipher
idpCipher cryptox.Cipher
allowPrivateIdentityProvider bool
oidcClient *http.Client
samlMetadataMu sync.RWMutex
samlMetadata map[string]samlMetadataCacheEntry
now func() time.Time
}
func (s *Service) SetIdentityProviderCipher(cipher cryptox.Cipher, allowPrivate bool) {
s.idpCipher = cipher
s.allowPrivateIdentityProvider = allowPrivate
s.oidcClient = newOIDCHTTPClient(allowPrivate)
}
func NewService(repository *Repository, sessions *SessionStore, limiter *LoginLimiter, cfg config.Auth, totpCipher cryptox.Cipher) *Service {
return &Service{repository: repository, sessions: sessions, limiter: limiter, hasher: PasswordHasher{}, config: cfg, totpCipher: totpCipher, oidcClient: newOIDCHTTPClient(false), samlMetadata: make(map[string]samlMetadataCacheEntry), now: time.Now}
}
// AllowLogin reports whether a login attempt from ip may proceed. When the
// per-IP sliding-window limit is exceeded it returns false (caller responds 429).
func (s *Service) AllowLogin(ctx context.Context, ip string) bool {
return s.limiter == nil || s.limiter.Allow(ctx, ip)
}
func (s *Service) Login(ctx context.Context, kind Kind, login, password string) (LoginResult, error) {
account, err := s.findByLogin(ctx, kind, login)
if errors.Is(err, ErrNotFound) {
_ = s.hasher.Verify(password, dummyPasswordHash)
return LoginResult{}, ErrInvalidCredentials
}
if err != nil {
return LoginResult{}, err
}
if !account.Active {
return LoginResult{}, ErrAccountDisabled
}
if account.Locked(s.now()) {
return LoginResult{}, LockedError{Until: *account.LockedUntil}
}
if account.PasswordHash == "" || !s.hasher.Verify(password, account.PasswordHash) {
lockedUntil, recordErr := s.repository.RecordFailure(ctx, account, s.config.MaxFailures, s.config.LockDuration)
if recordErr != nil {
return LoginResult{}, recordErr
}
if lockedUntil != nil && lockedUntil.After(s.now()) {
return LoginResult{}, LockedError{Until: *lockedUntil}
}
return LoginResult{}, ErrInvalidCredentials
}
if account.TOTPEnabled {
principal := principalFor(account)
token, tokenErr := s.sessions.CreatePending(ctx, principal, s.config.TOTPChallengeTTL)
if tokenErr != nil {
return LoginResult{}, tokenErr
}
return LoginResult{TempToken: token, RequireTOTP: true, Account: account}, nil
}
var upgradedHash *string
if s.hasher.NeedsUpgrade(account.PasswordHash) {
hash, hashErr := s.hasher.Hash(password)
if hashErr != nil {
return LoginResult{}, hashErr
}
upgradedHash = &hash
}
principal := principalFor(account)
token, err := s.sessions.Create(ctx, principal)
if err != nil {
return LoginResult{}, err
}
if err := s.repository.CompleteLogin(ctx, account, upgradedHash); err != nil {
_ = s.sessions.Delete(ctx, "Bearer "+token)
return LoginResult{}, err
}
return LoginResult{Token: token, Account: account}, nil
}
func (s *Service) CompleteTOTPLogin(ctx context.Context, kind Kind, tempToken, code, backupCode string) (LoginResult, error) {
principal, err := s.sessions.AuthenticatePending(ctx, tempToken, kind)
if err != nil {
return LoginResult{}, err
}
account, err := s.findByID(ctx, kind, principal.SubjectID)
if err != nil {
return LoginResult{}, err
}
if !account.Active {
return LoginResult{}, ErrAccountDisabled
}
if account.Locked(s.now()) {
return LoginResult{}, LockedError{Until: *account.LockedUntil}
}
if !account.TOTPEnabled {
return LoginResult{}, ErrTOTPNotEnabled
}
valid, err := s.verifyAndConsumeFactor(ctx, account, code, backupCode)
if err != nil {
return LoginResult{}, err
}
if !valid {
lockedUntil, recordErr := s.repository.RecordFailure(ctx, account, s.config.MaxFailures, s.config.LockDuration)
if recordErr != nil {
return LoginResult{}, recordErr
}
if lockedUntil != nil && lockedUntil.After(s.now()) {
return LoginResult{}, LockedError{Until: *lockedUntil}
}
return LoginResult{}, ErrInvalidTOTP
}
token, err := s.sessions.Create(ctx, principalFor(account))
if err != nil {
return LoginResult{}, err
}
if err := s.repository.CompleteLogin(ctx, account, nil); err != nil {
_ = s.sessions.Delete(ctx, "Bearer "+token)
return LoginResult{}, err
}
_ = s.sessions.DeleteToken(ctx, tempToken)
return LoginResult{Token: token, Account: account}, nil
}
func (s *Service) SetupTOTP(ctx context.Context, account Account, password string) (TOTPSetupResult, error) {
if account.TOTPEnabled {
return TOTPSetupResult{}, ErrTOTPAlreadyEnabled
}
if !s.hasher.Verify(password, account.PasswordHash) {
return TOTPSetupResult{}, ErrInvalidCredentials
}
secret, err := GenerateTOTPSecret()
if err != nil {
return TOTPSetupResult{}, err
}
encrypted, version, err := s.totpCipher.Encrypt([]byte(secret))
if err != nil {
return TOTPSetupResult{}, err
}
if err := s.repository.SetTOTPSecret(ctx, account, encrypted, version); err != nil {
return TOTPSetupResult{}, err
}
return TOTPSetupResult{Secret: secret, ProvisioningURI: TOTPProvisioningURI(secret, account.Kind, account.Login)}, nil
}
func (s *Service) ConfirmTOTP(ctx context.Context, account Account, code string) ([]string, error) {
account, err := s.findByID(ctx, account.Kind, account.ID)
if err != nil {
return nil, err
}
if account.TOTPEnabled {
return nil, ErrTOTPAlreadyEnabled
}
secret, err := s.decryptTOTPSecret(account)
if err != nil {
return nil, err
}
step, valid := VerifyTOTP(secret, code, s.now())
if !valid {
return nil, ErrInvalidTOTP
}
codes, records, err := GenerateBackupCodes()
if err != nil {
return nil, err
}
if err := s.repository.EnableTOTP(ctx, account, step, records); err != nil {
return nil, err
}
return codes, nil
}
func (s *Service) DisableTOTP(ctx context.Context, account Account, password, code, backupCode string) error {
account, err := s.findByID(ctx, account.Kind, account.ID)
if err != nil {
return err
}
if !account.TOTPEnabled {
return ErrTOTPNotEnabled
}
if !s.hasher.Verify(password, account.PasswordHash) {
return ErrInvalidCredentials
}
valid, err := s.verifyAndConsumeFactor(ctx, account, code, backupCode)
if err != nil {
return err
}
if !valid {
return ErrInvalidTOTP
}
return s.repository.DisableTOTP(ctx, account)
}
func (s *Service) RegenerateBackupCodes(ctx context.Context, account Account, password, code, backupCode string) ([]string, error) {
account, err := s.findByID(ctx, account.Kind, account.ID)
if err != nil {
return nil, err
}
if !account.TOTPEnabled {
return nil, ErrTOTPNotEnabled
}
if !s.hasher.Verify(password, account.PasswordHash) {
return nil, ErrInvalidCredentials
}
valid, err := s.verifyAndConsumeFactor(ctx, account, code, backupCode)
if err != nil {
return nil, err
}
if !valid {
return nil, ErrInvalidTOTP
}
codes, records, err := GenerateBackupCodes()
if err != nil {
return nil, err
}
if err := s.repository.ReplaceBackupCodes(ctx, account, records); err != nil {
return nil, err
}
return codes, nil
}
func (s *Service) verifyAndConsumeFactor(ctx context.Context, account Account, code, backupCode string) (bool, error) {
if strings.TrimSpace(backupCode) != "" {
return s.repository.ConsumeBackupCode(ctx, account, HashBackupCode(backupCode))
}
secret, err := s.decryptTOTPSecret(account)
if err != nil {
return false, err
}
step, valid := VerifyTOTP(secret, code, s.now())
if !valid {
return false, nil
}
return s.repository.ConsumeTOTPStep(ctx, account, step)
}
func (s *Service) decryptTOTPSecret(account Account) (string, error) {
if len(account.EncryptedTOTPSecret) == 0 || account.TOTPKekVersion == nil {
return "", ErrTOTPSetupRequired
}
plaintext, err := s.totpCipher.Decrypt(account.EncryptedTOTPSecret, *account.TOTPKekVersion)
if err != nil {
return "", err
}
return string(plaintext), nil
}
func (s *Service) Authenticate(ctx context.Context, kind Kind, authorization string) (Account, error) {
principal, err := s.sessions.Authenticate(ctx, authorization, kind)
if err != nil {
return Account{}, err
}
var account Account
if kind == KindAdmin {
account, err = s.repository.FindAdminByID(ctx, principal.SubjectID)
} else {
account, err = s.repository.FindPortalByID(ctx, principal.SubjectID)
}
if errors.Is(err, ErrNotFound) {
return Account{}, ErrInvalidSession
}
if err != nil {
return Account{}, err
}
if !account.Active {
return Account{}, ErrAccountDisabled
}
return account, nil
}
func (s *Service) Logout(ctx context.Context, authorization string) error {
return s.sessions.Delete(ctx, authorization)
}
// ChangePassword updates the authenticated account password after verifying the
// current password. Externally provisioned portal accounts without a local
// password may set their first password without an old-password check.
func (s *Service) ChangePassword(ctx context.Context, account Account, oldPassword, newPassword string) error {
account, err := s.findByID(ctx, account.Kind, account.ID)
if err != nil {
return err
}
if account.PasswordHash != "" && !s.hasher.Verify(oldPassword, account.PasswordHash) {
return ErrInvalidCredentials
}
if len(newPassword) < 12 || len(newPassword) > 1024 {
return errors.New("new password must contain 12 to 1024 characters")
}
hash, err := s.hasher.Hash(newPassword)
if err != nil {
return err
}
return s.repository.SetPassword(ctx, account, hash)
}
func (s *Service) findByLogin(ctx context.Context, kind Kind, login string) (Account, error) {
if kind == KindAdmin {
return s.repository.FindAdminByLogin(ctx, login)
}
return s.repository.FindPortalByLogin(ctx, login)
}
func (s *Service) findByID(ctx context.Context, kind Kind, id string) (Account, error) {
if kind == KindAdmin {
return s.repository.FindAdminByID(ctx, id)
}
return s.repository.FindPortalByID(ctx, id)
}
func principalFor(account Account) Principal {
return Principal{Kind: account.Kind, SubjectID: account.ID, Login: account.Login, DisplayName: account.DisplayName, Role: account.Role}
}
+211
View File
@@ -0,0 +1,211 @@
package identity
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/redis/go-redis/v9"
)
var ErrInvalidSession = errors.New("invalid or expired session")
type Principal struct {
Kind Kind `json:"kind"`
SubjectID string `json:"subject_id"`
Login string `json:"login"`
DisplayName string `json:"display_name"`
Role string `json:"role,omitempty"`
Purpose string `json:"purpose"`
IssuedAt int64 `json:"issued_at"`
}
type SessionStore struct {
client *redis.Client
ttl time.Duration
}
func NewSessionStore(client *redis.Client, ttl time.Duration) *SessionStore {
return &SessionStore{client: client, ttl: ttl}
}
func (s *SessionStore) Create(ctx context.Context, principal Principal) (string, error) {
principal.Purpose = "session"
return s.create(ctx, principal, s.ttl)
}
func (s *SessionStore) CreatePending(ctx context.Context, principal Principal, ttl time.Duration) (string, error) {
principal.Purpose = "totp_pending"
return s.create(ctx, principal, ttl)
}
func (s *SessionStore) create(ctx context.Context, principal Principal, ttl time.Duration) (string, error) {
if s.client == nil {
return "", ErrUnavailable
}
random := make([]byte, 32)
if _, err := rand.Read(random); err != nil {
return "", err
}
token := base64.RawURLEncoding.EncodeToString(random)
principal.IssuedAt = time.Now().Unix()
payload, err := json.Marshal(principal)
if err != nil {
return "", err
}
if err := s.client.Set(ctx, sessionKey(token), payload, ttl).Err(); err != nil {
return "", fmt.Errorf("%w: %v", ErrUnavailable, err)
}
return token, nil
}
func (s *SessionStore) Authenticate(ctx context.Context, authorization string, expected Kind) (Principal, error) {
if s.client == nil {
return Principal{}, ErrUnavailable
}
token, ok := bearerToken(authorization)
if !ok {
return Principal{}, ErrInvalidSession
}
payload, err := s.client.Get(ctx, sessionKey(token)).Bytes()
if errors.Is(err, redis.Nil) {
return Principal{}, ErrInvalidSession
}
if err != nil {
return Principal{}, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
var principal Principal
if err := json.Unmarshal(payload, &principal); err != nil || principal.Kind != expected || principal.SubjectID == "" || principal.Purpose != "session" {
return Principal{}, ErrInvalidSession
}
return principal, nil
}
func (s *SessionStore) AuthenticatePending(ctx context.Context, token string, expected Kind) (Principal, error) {
principal, err := s.authenticateToken(ctx, token)
if err != nil || principal.Kind != expected || principal.SubjectID == "" || principal.Purpose != "totp_pending" {
return Principal{}, ErrInvalidSession
}
return principal, nil
}
func (s *SessionStore) authenticateToken(ctx context.Context, token string) (Principal, error) {
if s.client == nil {
return Principal{}, ErrUnavailable
}
payload, err := s.client.Get(ctx, sessionKey(strings.TrimSpace(token))).Bytes()
if errors.Is(err, redis.Nil) {
return Principal{}, ErrInvalidSession
}
if err != nil {
return Principal{}, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
var principal Principal
if err := json.Unmarshal(payload, &principal); err != nil {
return Principal{}, ErrInvalidSession
}
return principal, nil
}
func (s *SessionStore) DeleteToken(ctx context.Context, token string) error {
if s.client == nil {
return ErrUnavailable
}
if err := s.client.Del(ctx, sessionKey(strings.TrimSpace(token))).Err(); err != nil {
return fmt.Errorf("%w: %v", ErrUnavailable, err)
}
return nil
}
func (s *SessionStore) Delete(ctx context.Context, authorization string) error {
if s.client == nil {
return ErrUnavailable
}
token, ok := bearerToken(authorization)
if !ok {
return nil
}
if err := s.client.Del(ctx, sessionKey(token)).Err(); err != nil {
return fmt.Errorf("%w: %v", ErrUnavailable, err)
}
return nil
}
func (s *SessionStore) StoreOneTime(ctx context.Context, namespace string, value any, ttl time.Duration) (string, error) {
if s.client == nil {
return "", ErrUnavailable
}
random := make([]byte, 32)
if _, err := rand.Read(random); err != nil {
return "", err
}
token := base64.RawURLEncoding.EncodeToString(random)
payload, err := json.Marshal(value)
if err != nil {
return "", err
}
if err := s.client.Set(ctx, oneTimeKey(namespace, token), payload, ttl).Err(); err != nil {
return "", fmt.Errorf("%w: %v", ErrUnavailable, err)
}
return token, nil
}
func (s *SessionStore) ConsumeOneTime(ctx context.Context, namespace, token string, target any) error {
if s.client == nil {
return ErrUnavailable
}
payload, err := s.client.GetDel(ctx, oneTimeKey(namespace, strings.TrimSpace(token))).Bytes()
if errors.Is(err, redis.Nil) {
return ErrInvalidSession
}
if err != nil {
return fmt.Errorf("%w: %v", ErrUnavailable, err)
}
if err := json.Unmarshal(payload, target); err != nil {
return ErrInvalidSession
}
return nil
}
// ClaimIdentifier atomically claims a caller-provided replay identifier for
// the TTL. It is used for signed protocol message IDs, not bearer secrets.
func (s *SessionStore) ClaimIdentifier(ctx context.Context, namespace, identifier string, ttl time.Duration) (bool, error) {
if s.client == nil {
return false, ErrUnavailable
}
identifier = strings.TrimSpace(identifier)
if identifier == "" || len(identifier) > 512 {
return false, ErrInvalidSession
}
claimed, err := s.client.SetNX(ctx, oneTimeKey(namespace, identifier), "1", ttl).Result()
if err != nil {
return false, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
return claimed, nil
}
func bearerToken(authorization string) (string, bool) {
authorization = strings.TrimSpace(authorization)
if len(authorization) <= len("Bearer ") || !strings.EqualFold(authorization[:len("Bearer ")], "Bearer ") {
return "", false
}
token := strings.TrimSpace(authorization[len("Bearer "):])
return token, token != ""
}
func sessionKey(token string) string {
digest := sha256.Sum256([]byte(token))
return "gateway:session:v1:" + hex.EncodeToString(digest[:])
}
func oneTimeKey(namespace, token string) string {
digest := sha256.Sum256([]byte(token))
return "gateway:one-time:" + namespace + ":" + hex.EncodeToString(digest[:])
}
+118
View File
@@ -0,0 +1,118 @@
package identity
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"crypto/sha256"
"crypto/subtle"
"encoding/base32"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"net/url"
"strings"
"time"
)
const (
totpPeriod = int64(30)
totpDigits = 6
totpWindow = int64(1)
backupCodeCount = 10
backupCodeLength = 8
)
var backupAlphabet = []byte("23456789ABCDEFGHJKLMNPQRSTUVWXYZ")
type BackupCodeRecord struct {
Hash string `json:"hash"`
UsedAt *time.Time `json:"used_at,omitempty"`
}
func GenerateTOTPSecret() (string, error) {
secret := make([]byte, 20)
if _, err := rand.Read(secret); err != nil {
return "", err
}
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(secret), nil
}
func TOTPProvisioningURI(secret string, kind Kind, login string) string {
issuer := "AI Gateway"
label := issuer + ":" + string(kind) + ":" + login
query := url.Values{
"secret": []string{secret},
"issuer": []string{issuer},
"algorithm": []string{"SHA1"},
"digits": []string{fmt.Sprint(totpDigits)},
"period": []string{fmt.Sprint(totpPeriod)},
}
return "otpauth://totp/" + url.PathEscape(label) + "?" + query.Encode()
}
func VerifyTOTP(secret, code string, now time.Time) (int64, bool) {
code = strings.TrimSpace(code)
if len(code) != totpDigits {
return 0, false
}
step := now.Unix() / totpPeriod
for offset := -totpWindow; offset <= totpWindow; offset++ {
candidate, err := hotp(secret, step+offset, totpDigits)
if err == nil && subtle.ConstantTimeCompare([]byte(candidate), []byte(code)) == 1 {
return step + offset, true
}
}
return 0, false
}
func hotp(secret string, counter int64, digits int) (string, error) {
if counter < 0 || digits < 6 || digits > 8 {
return "", errors.New("invalid HOTP parameters")
}
decoded, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(strings.TrimSpace(secret)))
if err != nil {
return "", err
}
message := make([]byte, 8)
binary.BigEndian.PutUint64(message, uint64(counter))
mac := hmac.New(sha1.New, decoded)
_, _ = mac.Write(message)
digest := mac.Sum(nil)
offset := digest[len(digest)-1] & 0x0f
value := (uint32(digest[offset])&0x7f)<<24 |
uint32(digest[offset+1])<<16 |
uint32(digest[offset+2])<<8 |
uint32(digest[offset+3])
modulus := uint32(1)
for i := 0; i < digits; i++ {
modulus *= 10
}
return fmt.Sprintf("%0*d", digits, value%modulus), nil
}
func GenerateBackupCodes() ([]string, []BackupCodeRecord, error) {
codes := make([]string, 0, backupCodeCount)
records := make([]BackupCodeRecord, 0, backupCodeCount)
for range backupCodeCount {
random := make([]byte, backupCodeLength)
if _, err := rand.Read(random); err != nil {
return nil, nil, err
}
for index := range random {
random[index] = backupAlphabet[int(random[index])%len(backupAlphabet)]
}
raw := string(random)
code := raw[:4] + "-" + raw[4:]
codes = append(codes, code)
records = append(records, BackupCodeRecord{Hash: HashBackupCode(code)})
}
return codes, records, nil
}
func HashBackupCode(code string) string {
normalized := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(code), "-", ""))
digest := sha256.Sum256([]byte(normalized))
return hex.EncodeToString(digest[:])
}
+43
View File
@@ -0,0 +1,43 @@
package identity
import (
"testing"
"time"
)
func TestHOTPUsesRFC6238Vector(t *testing.T) {
// RFC 6238 SHA-1 shared secret, time 59 seconds, 8-digit expected value.
code, err := hotp("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", 59/30, 8)
if err != nil {
t.Fatal(err)
}
if code != "94287082" {
t.Fatalf("got %s", code)
}
}
func TestVerifyTOTPAcceptsWindow(t *testing.T) {
now := time.Unix(1_700_000_000, 0)
secret := "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
code, err := hotp(secret, now.Unix()/30-1, 6)
if err != nil {
t.Fatal(err)
}
step, ok := VerifyTOTP(secret, code, now)
if !ok || step != now.Unix()/30-1 {
t.Fatalf("step=%d ok=%v", step, ok)
}
}
func TestBackupCodeNormalization(t *testing.T) {
if HashBackupCode("abcd-2345") != HashBackupCode(" ABCD2345 ") {
t.Fatal("backup code normalization differs")
}
codes, records, err := GenerateBackupCodes()
if err != nil {
t.Fatal(err)
}
if len(codes) != 10 || len(records) != 10 || records[0].Hash != HashBackupCode(codes[0]) {
t.Fatal("invalid backup code generation")
}
}