Files
superidou c22669c31d 0.11.0: 旗舰版功能补齐(License/登录记录/会话管理/角色管理/门户定时任务/模型配额/输出脱敏/供应链扫描/记忆管理/AI助手/真实概览)
- 新增迁移 000031-000034(登录日志/角色/模型配额/记忆)
- 新增包: license/memory/modelquota/assistant,扫描引擎
- 全部功能后端+前端+端到端验证通过(25 包单测)
2026-08-13 11:37:18 +08:00

423 lines
15 KiB
Go

package identity
import (
"context"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"strconv"
"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
}
// LoginLog 是一条登录尝试记录。
type LoginLog struct {
ID string `json:"id"`
Kind string `json:"kind"`
Login string `json:"login"`
Success bool `json:"success"`
IP *string `json:"ip,omitempty"`
UserAgent string `json:"user_agent"`
Reason string `json:"reason"`
CreatedAt time.Time `json:"created_at"`
}
// RecordLoginLog 记录一次登录尝试(成功或失败)。
func (r *Repository) RecordLoginLog(ctx context.Context, kind Kind, login string, success bool, ip, userAgent, reason string) error {
if r.pool == nil {
return ErrUnavailable
}
id, err := platformid.NewUUID()
if err != nil {
return err
}
ipValue := strings.TrimSpace(ip)
_, err = r.pool.Exec(ctx, `INSERT INTO gateway.login_logs(id,kind,login,success,ip,user_agent,reason) VALUES($1,$2,$3,$4,nullif($5,'')::inet,$6,$7)`,
id, string(kind), login, success, ipValue, truncateText(userAgent, 256), truncateText(reason, 64))
if err != nil {
return fmt.Errorf("%w: %v", ErrUnavailable, err)
}
return nil
}
// ListLoginLogs 查询登录记录(按时间倒序)。
func (r *Repository) ListLoginLogs(ctx context.Context, kind Kind, login string, limit int) ([]LoginLog, error) {
if r.pool == nil {
return nil, ErrUnavailable
}
if limit < 1 || limit > 500 {
limit = 50
}
query := `SELECT id::text,kind,login,success,ip::text,user_agent,reason,created_at FROM gateway.login_logs WHERE kind=$1`
args := []any{string(kind)}
if login != "" {
args = append(args, login)
query += ` AND login=$` + strconv.Itoa(len(args))
}
query += ` ORDER BY created_at DESC LIMIT $` + strconv.Itoa(len(args)+1)
args = append(args, limit)
rows, err := r.pool.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
defer rows.Close()
items := []LoginLog{}
for rows.Next() {
var item LoginLog
var ip *string
if err := rows.Scan(&item.ID, &item.Kind, &item.Login, &item.Success, &ip, &item.UserAgent, &item.Reason, &item.CreatedAt); err != nil {
return nil, err
}
if ip != nil && *ip != "" {
item.IP = ip
}
items = append(items, item)
}
return items, rows.Err()
}
func truncateText(value string, limit int) string {
value = strings.TrimSpace(value)
if len(value) <= limit {
return value
}
return value[:limit]
}
// CountIdentities 统计管理员与门户账号总数(License 账号数管控用)。
func (r *Repository) CountIdentities(ctx context.Context, total *int) error {
if r.pool == nil {
return ErrUnavailable
}
err := r.pool.QueryRow(ctx, `SELECT (SELECT count(*) FROM gateway.admin_accounts) + (SELECT count(*) FROM gateway.portal_users)`).Scan(total)
if err != nil {
return fmt.Errorf("%w: %v", ErrUnavailable, err)
}
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
}