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:
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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": "模型权限"}},
|
||||
}},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user