888 lines
33 KiB
Go
888 lines
33 KiB
Go
package externaladmin
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"hyapp-admin-server/internal/appctx"
|
|
"hyapp-admin-server/internal/cache"
|
|
"hyapp-admin-server/internal/model"
|
|
"hyapp-admin-server/internal/modules/shared"
|
|
"hyapp-admin-server/internal/security"
|
|
|
|
mysqlDriver "github.com/go-sql-driver/mysql"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
const (
|
|
lastSeenWriteInterval = 5 * time.Minute
|
|
loginLimiterTimeout = 200 * time.Millisecond
|
|
loginLimiterClusterTag = "{external-login}"
|
|
)
|
|
|
|
type Service struct {
|
|
db *gorm.DB
|
|
userDB *sql.DB
|
|
cfg Config
|
|
dummyHash string
|
|
limiter FixedWindowLimiter
|
|
}
|
|
|
|
func NewService(db *gorm.DB, userDB *sql.DB, cfg Config) *Service {
|
|
if cfg.SessionTTL <= 0 {
|
|
cfg.SessionTTL = 12 * time.Hour
|
|
}
|
|
if cfg.MaxLoginFailures == 0 {
|
|
cfg.MaxLoginFailures = 5
|
|
}
|
|
if cfg.LockDuration <= 0 {
|
|
cfg.LockDuration = 15 * time.Minute
|
|
}
|
|
if cfg.LoginRateLimit.Window <= 0 {
|
|
cfg.LoginRateLimit.Window = 60 * time.Second
|
|
}
|
|
if cfg.LoginRateLimit.SystemLimit == 0 {
|
|
cfg.LoginRateLimit.SystemLimit = 300
|
|
}
|
|
if cfg.LoginRateLimit.AppLimit == 0 {
|
|
cfg.LoginRateLimit.AppLimit = 120
|
|
}
|
|
if cfg.LoginRateLimit.IPLimit == 0 {
|
|
cfg.LoginRateLimit.IPLimit = 30
|
|
}
|
|
if cfg.LoginRateLimit.IdentityLimit == 0 {
|
|
cfg.LoginRateLimit.IdentityLimit = 10
|
|
}
|
|
dummyHash, _ := security.HashPassword("external-login-dummy-password")
|
|
return &Service{db: db, userDB: userDB, cfg: cfg, dummyHash: dummyHash}
|
|
}
|
|
|
|
func (s *Service) ListApps(ctx context.Context) ([]App, error) {
|
|
if s.userDB == nil {
|
|
return nil, errors.New("user mysql is not configured")
|
|
}
|
|
rows, err := s.userDB.QueryContext(ctx, `
|
|
SELECT app_code, app_name, logo_url
|
|
FROM apps
|
|
WHERE status = 'active'
|
|
ORDER BY app_name ASC, app_code ASC`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
apps := []App{}
|
|
for rows.Next() {
|
|
var app App
|
|
if err := rows.Scan(&app.AppCode, &app.AppName, &app.LogoURL); err != nil {
|
|
return nil, err
|
|
}
|
|
apps = append(apps, app)
|
|
}
|
|
return apps, rows.Err()
|
|
}
|
|
|
|
func (s *Service) ResolveTarget(ctx context.Context, appCode string, target string) (LinkedUser, error) {
|
|
appCode = normalizeAppCode(appCode)
|
|
target = strings.TrimSpace(target)
|
|
if appCode == "" || target == "" || s.userDB == nil {
|
|
return LinkedUser{}, ErrInvalidInput
|
|
}
|
|
userID, matched, err := shared.ResolveExactUserID(ctx, s.userDB, appCode, target, time.Now().UTC().UnixMilli())
|
|
if err != nil {
|
|
return LinkedUser{}, err
|
|
}
|
|
if !matched {
|
|
return LinkedUser{}, ErrTargetNotFound
|
|
}
|
|
users, err := s.loadLinkedUsers(ctx, appCode, []int64{userID})
|
|
if err != nil {
|
|
return LinkedUser{}, err
|
|
}
|
|
user, ok := users[userID]
|
|
if !ok {
|
|
return LinkedUser{}, ErrTargetNotFound
|
|
}
|
|
// ResolveExactUserID keeps nickname fallback for generic admin forms. External-account creation
|
|
// accepts only an exact long/short/pretty ID so an ambiguous nickname can never bind credentials.
|
|
if !matchesExactIdentity(user, target) {
|
|
return LinkedUser{}, ErrTargetNotFound
|
|
}
|
|
return user, nil
|
|
}
|
|
|
|
func (s *Service) ListAccounts(ctx context.Context, input ListInput) (ListResult, error) {
|
|
if s.db == nil {
|
|
return ListResult{}, errors.New("admin mysql is not configured")
|
|
}
|
|
input.AppCode = normalizeAppCode(input.AppCode)
|
|
input.Page, input.PageSize = normalizePage(input.Page, input.PageSize)
|
|
query := s.db.WithContext(ctx).Model(&model.ExternalAdminAccount{}).Where("app_code = ?", input.AppCode)
|
|
if status := normalizeStatus(input.Status); status != "" {
|
|
query = query.Where("status = ?", status)
|
|
}
|
|
if keyword := strings.TrimSpace(input.Keyword); keyword != "" {
|
|
like := "%" + keyword + "%"
|
|
linkedUserID := int64(0)
|
|
if user, resolveErr := s.ResolveTarget(ctx, input.AppCode, keyword); resolveErr == nil {
|
|
linkedUserID = user.UserID
|
|
} else if userID, parseErr := strconv.ParseInt(keyword, 10, 64); parseErr == nil && userID > 0 {
|
|
linkedUserID = userID
|
|
}
|
|
if linkedUserID > 0 {
|
|
query = query.Where("username LIKE ? OR linked_app_user_id = ?", like, linkedUserID)
|
|
} else {
|
|
query = query.Where("username LIKE ?", like)
|
|
}
|
|
}
|
|
var total int64
|
|
if err := query.Count(&total).Error; err != nil {
|
|
return ListResult{}, err
|
|
}
|
|
accounts := []model.ExternalAdminAccount{}
|
|
if err := query.Order("updated_at_ms DESC, id DESC").Offset((input.Page - 1) * input.PageSize).Limit(input.PageSize).Find(&accounts).Error; err != nil {
|
|
return ListResult{}, err
|
|
}
|
|
userIDs := make([]int64, 0, len(accounts))
|
|
for _, account := range accounts {
|
|
userIDs = append(userIDs, account.LinkedAppUserID)
|
|
}
|
|
users, err := s.loadLinkedUsers(ctx, input.AppCode, userIDs)
|
|
if err != nil {
|
|
return ListResult{}, err
|
|
}
|
|
items := make([]AccountDTO, 0, len(accounts))
|
|
for _, account := range accounts {
|
|
items = append(items, accountDTO(account, users[account.LinkedAppUserID]))
|
|
}
|
|
return ListResult{Items: items, Page: input.Page, PageSize: input.PageSize, Total: total}, nil
|
|
}
|
|
|
|
func (s *Service) FindAccountByLinkedUser(ctx context.Context, appCode string, linkedUser LinkedUser) (*AccountDTO, error) {
|
|
if s.db == nil || linkedUser.UserID <= 0 {
|
|
return nil, ErrInvalidInput
|
|
}
|
|
var account model.ExternalAdminAccount
|
|
err := s.db.WithContext(ctx).Where("app_code = ? AND linked_app_user_id = ?", normalizeAppCode(appCode), linkedUser.UserID).First(&account).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dto := accountDTO(account, linkedUser)
|
|
return &dto, nil
|
|
}
|
|
|
|
func (s *Service) CreateAccount(ctx context.Context, input CreateInput) (AccountDTO, error) {
|
|
if s.db == nil {
|
|
return AccountDTO{}, errors.New("admin mysql is not configured")
|
|
}
|
|
input.AppCode = normalizeAppCode(input.AppCode)
|
|
input.Username = normalizeUsername(input.Username)
|
|
if input.AppCode == "" || !validUsername(input.Username) || input.CreatedByAdminID == 0 {
|
|
return AccountDTO{}, ErrInvalidInput
|
|
}
|
|
if err := validatePassword(input.Password); err != nil {
|
|
return AccountDTO{}, err
|
|
}
|
|
permissions, err := createPermissionSnapshot(input)
|
|
if err != nil {
|
|
return AccountDTO{}, err
|
|
}
|
|
// Login has no client-selected App. Check the normalized username globally before
|
|
// doing an owner-DB lookup or bcrypt work; the global unique index remains the
|
|
// authoritative race-safe guard if two administrators create it concurrently.
|
|
var existing model.ExternalAdminAccount
|
|
err = s.db.WithContext(ctx).Select("id").Where("username = ?", input.Username).Take(&existing).Error
|
|
if err == nil {
|
|
return AccountDTO{}, ErrAccountConflict
|
|
}
|
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return AccountDTO{}, err
|
|
}
|
|
if _, err := s.findActiveApp(ctx, input.AppCode); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return AccountDTO{}, ErrInvalidInput
|
|
}
|
|
return AccountDTO{}, err
|
|
}
|
|
linkedUser, err := s.ResolveTarget(ctx, input.AppCode, input.TargetUserID)
|
|
if err != nil {
|
|
return AccountDTO{}, err
|
|
}
|
|
passwordHash, err := security.HashPassword(input.Password)
|
|
if err != nil {
|
|
return AccountDTO{}, ErrInvalidInput
|
|
}
|
|
permissionsJSON, err := encodePermissions(permissions)
|
|
if err != nil {
|
|
return AccountDTO{}, err
|
|
}
|
|
account := model.ExternalAdminAccount{
|
|
AppCode: input.AppCode,
|
|
LinkedAppUserID: linkedUser.UserID,
|
|
Username: input.Username,
|
|
PasswordHash: passwordHash,
|
|
PermissionsJSON: permissionsJSON,
|
|
PermissionRevision: 1,
|
|
Status: model.ExternalAdminStatusActive,
|
|
PasswordChangeRequired: true,
|
|
CreatedByAdminID: input.CreatedByAdminID,
|
|
}
|
|
if err := s.db.WithContext(ctx).Create(&account).Error; err != nil {
|
|
if isDuplicateKey(err) {
|
|
return AccountDTO{}, ErrAccountConflict
|
|
}
|
|
return AccountDTO{}, err
|
|
}
|
|
return accountDTO(account, linkedUser), nil
|
|
}
|
|
|
|
func (s *Service) SetStatus(ctx context.Context, appCode string, id uint64, status string) (AccountDTO, error) {
|
|
appCode = normalizeAppCode(appCode)
|
|
status = normalizeStatus(status)
|
|
if id == 0 || status == "" {
|
|
return AccountDTO{}, ErrInvalidInput
|
|
}
|
|
var account model.ExternalAdminAccount
|
|
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND app_code = ?", id, appCode).First(&account).Error; err != nil {
|
|
return err
|
|
}
|
|
updates := map[string]any{"status": status}
|
|
if status == model.ExternalAdminStatusActive {
|
|
updates["failed_login_count"] = 0
|
|
updates["locked_until_ms"] = 0
|
|
}
|
|
if err := tx.Model(&account).Updates(updates).Error; err != nil {
|
|
return err
|
|
}
|
|
if status == model.ExternalAdminStatusDisabled {
|
|
return revokeAllSessions(tx, account.ID, "account_disabled", time.Now().UTC().UnixMilli())
|
|
}
|
|
return nil
|
|
})
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return AccountDTO{}, ErrAccountNotFound
|
|
}
|
|
if err != nil {
|
|
return AccountDTO{}, err
|
|
}
|
|
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&account).Error; err != nil {
|
|
return AccountDTO{}, err
|
|
}
|
|
users, err := s.loadLinkedUsers(ctx, appCode, []int64{account.LinkedAppUserID})
|
|
return accountDTO(account, users[account.LinkedAppUserID]), err
|
|
}
|
|
|
|
func (s *Service) ResetPassword(ctx context.Context, appCode string, id uint64, password string) (AccountDTO, error) {
|
|
appCode = normalizeAppCode(appCode)
|
|
if id == 0 {
|
|
return AccountDTO{}, ErrInvalidInput
|
|
}
|
|
if err := validatePassword(password); err != nil {
|
|
return AccountDTO{}, err
|
|
}
|
|
hash, err := security.HashPassword(password)
|
|
if err != nil {
|
|
return AccountDTO{}, ErrInvalidInput
|
|
}
|
|
var account model.ExternalAdminAccount
|
|
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND app_code = ?", id, appCode).First(&account).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Model(&account).Updates(map[string]any{
|
|
"password_hash": hash,
|
|
"password_change_required": true,
|
|
"failed_login_count": 0,
|
|
"locked_until_ms": 0,
|
|
}).Error; err != nil {
|
|
return err
|
|
}
|
|
return revokeAllSessions(tx, account.ID, "password_reset", time.Now().UTC().UnixMilli())
|
|
})
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return AccountDTO{}, ErrAccountNotFound
|
|
}
|
|
if err != nil {
|
|
return AccountDTO{}, err
|
|
}
|
|
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&account).Error; err != nil {
|
|
return AccountDTO{}, err
|
|
}
|
|
users, err := s.loadLinkedUsers(ctx, appCode, []int64{account.LinkedAppUserID})
|
|
return accountDTO(account, users[account.LinkedAppUserID]), err
|
|
}
|
|
|
|
func (s *Service) Login(ctx context.Context, input LoginInput) (LoginResult, error) {
|
|
input.Username = normalizeUsername(input.Username)
|
|
input.IP = truncateText(strings.TrimSpace(input.IP), 64)
|
|
input.UserAgent = truncateText(strings.TrimSpace(input.UserAgent), 512)
|
|
// The pre-resolution limiter uses only facts the server can trust: system, real
|
|
// client IP and the globally normalized username. AppCode is intentionally absent,
|
|
// so a forged tenant cannot select another account or evade any of these counters.
|
|
if err := s.consumeUnresolvedLoginRateLimit(ctx, input); err != nil {
|
|
return LoginResult{}, err
|
|
}
|
|
if s.db == nil {
|
|
return LoginResult{}, errors.New("admin mysql is not configured")
|
|
}
|
|
if !validUsername(input.Username) || input.Password == "" {
|
|
security.CheckPassword(s.dummyHash, input.Password)
|
|
return LoginResult{}, ErrInvalidCredentials
|
|
}
|
|
|
|
// The global unique index makes this indexed projection the sole tenant resolver.
|
|
// Do not accept a request/header App here: only the AppCode stored with the account
|
|
// may select owner data, create the session or populate audit rows.
|
|
var resolvedAccount model.ExternalAdminAccount
|
|
err := s.db.WithContext(ctx).Select("id", "app_code").Where("username = ?", input.Username).Take(&resolvedAccount).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
security.CheckPassword(s.dummyHash, input.Password)
|
|
s.writeUnknownLoginLog(ctx, input, "invalid_credentials")
|
|
return LoginResult{}, ErrInvalidCredentials
|
|
}
|
|
if err != nil {
|
|
return LoginResult{}, err
|
|
}
|
|
resolvedAccount.AppCode = normalizeAppCode(resolvedAccount.AppCode)
|
|
if !validAppCode(resolvedAccount.AppCode) {
|
|
security.CheckPassword(s.dummyHash, input.Password)
|
|
s.writeResolvedLoginLog(ctx, resolvedAccount.ID, resolvedAccount.AppCode, input, "invalid_account_app")
|
|
return LoginResult{}, ErrInvalidCredentials
|
|
}
|
|
// App quota is consumed in a second Redis call only after the indexed account
|
|
// resolution. System/IP/username rules are not repeated, so one attempt increments
|
|
// each layer exactly once while still containing a targeted tenant attack.
|
|
if err := s.consumeResolvedAppLoginRateLimit(ctx, resolvedAccount.AppCode); err != nil {
|
|
return LoginResult{}, err
|
|
}
|
|
app, err := s.findActiveApp(ctx, resolvedAccount.AppCode)
|
|
if err != nil {
|
|
// Keep disabled App, unknown account and wrong password indistinguishable.
|
|
security.CheckPassword(s.dummyHash, input.Password)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
s.writeResolvedLoginLog(ctx, resolvedAccount.ID, resolvedAccount.AppCode, input, "app_inactive")
|
|
return LoginResult{}, ErrInvalidCredentials
|
|
}
|
|
return LoginResult{}, err
|
|
}
|
|
|
|
nowMS := time.Now().UTC().UnixMilli()
|
|
var account model.ExternalAdminAccount
|
|
var session model.ExternalAdminSession
|
|
var sessionToken string
|
|
var csrfToken string
|
|
var authErr error
|
|
// Lock the account row while checking/updating the failure counter. Concurrent bad passwords
|
|
// must serialize on one counter; otherwise attackers could race requests below the lock threshold.
|
|
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND username = ?", resolvedAccount.ID, input.Username).First(&account).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
security.CheckPassword(s.dummyHash, input.Password)
|
|
authErr = ErrInvalidCredentials
|
|
return tx.Create(&model.ExternalAdminLoginLog{
|
|
AppCode: resolvedAccount.AppCode, Username: input.Username, IP: input.IP, UserAgent: input.UserAgent,
|
|
Status: "failed", Message: "invalid_credentials",
|
|
}).Error
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
accountID := account.ID
|
|
validPassword := security.CheckPassword(account.PasswordHash, input.Password)
|
|
accountAppCode := normalizeAppCode(account.AppCode)
|
|
if accountAppCode != resolvedAccount.AppCode {
|
|
// AppCode is not mutable through this module, but a concurrent/manual data
|
|
// correction between projection and row lock must not create a session whose
|
|
// App metadata and tenant scope came from different rows/snapshots.
|
|
authErr = ErrInvalidCredentials
|
|
return tx.Create(&model.ExternalAdminLoginLog{AccountID: &accountID, AppCode: accountAppCode, Username: input.Username, IP: input.IP, UserAgent: input.UserAgent, Status: "failed", Message: "account_app_changed"}).Error
|
|
}
|
|
account.AppCode = accountAppCode
|
|
if account.Status != model.ExternalAdminStatusActive {
|
|
authErr = ErrInvalidCredentials
|
|
return tx.Create(&model.ExternalAdminLoginLog{AccountID: &accountID, AppCode: account.AppCode, Username: input.Username, IP: input.IP, UserAgent: input.UserAgent, Status: "failed", Message: "account_disabled"}).Error
|
|
}
|
|
if account.LockedUntilMS > nowMS {
|
|
authErr = ErrInvalidCredentials
|
|
return tx.Create(&model.ExternalAdminLoginLog{AccountID: &accountID, AppCode: account.AppCode, Username: input.Username, IP: input.IP, UserAgent: input.UserAgent, Status: "failed", Message: "account_locked"}).Error
|
|
}
|
|
if !validPassword {
|
|
failedCount := account.FailedLoginCount
|
|
if account.LockedUntilMS > 0 && account.LockedUntilMS <= nowMS {
|
|
failedCount = 0
|
|
}
|
|
failedCount++
|
|
lockedUntilMS := int64(0)
|
|
message := "invalid_credentials"
|
|
if failedCount >= s.cfg.MaxLoginFailures {
|
|
lockedUntilMS = time.Now().UTC().Add(s.cfg.LockDuration).UnixMilli()
|
|
failedCount = 0
|
|
message = "failure_limit_locked"
|
|
}
|
|
if err := tx.Model(&account).Updates(map[string]any{"failed_login_count": failedCount, "locked_until_ms": lockedUntilMS}).Error; err != nil {
|
|
return err
|
|
}
|
|
authErr = ErrInvalidCredentials
|
|
return tx.Create(&model.ExternalAdminLoginLog{AccountID: &accountID, AppCode: account.AppCode, Username: input.Username, IP: input.IP, UserAgent: input.UserAgent, Status: "failed", Message: message}).Error
|
|
}
|
|
|
|
var tokenHash string
|
|
sessionToken, tokenHash, err = security.NewRefreshToken()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var csrfHash string
|
|
csrfToken, csrfHash, err = security.NewRefreshToken()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
session = model.ExternalAdminSession{
|
|
AccountID: account.ID, AppCode: account.AppCode, TokenHash: tokenHash, CSRFTokenHash: csrfHash,
|
|
IP: input.IP, UserAgent: input.UserAgent, ExpiresAtMS: time.Now().UTC().Add(s.cfg.SessionTTL).UnixMilli(), LastSeenAtMS: nowMS,
|
|
}
|
|
if err := tx.Create(&session).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Model(&account).Updates(map[string]any{"failed_login_count": 0, "locked_until_ms": 0, "last_login_at_ms": nowMS}).Error; err != nil {
|
|
return err
|
|
}
|
|
account.LastLoginAtMS = &nowMS
|
|
return tx.Create(&model.ExternalAdminLoginLog{AccountID: &accountID, AppCode: account.AppCode, Username: input.Username, IP: input.IP, UserAgent: input.UserAgent, Status: "success", Message: "login_success"}).Error
|
|
})
|
|
if err != nil {
|
|
return LoginResult{}, err
|
|
}
|
|
if authErr != nil {
|
|
return LoginResult{}, authErr
|
|
}
|
|
view, err := s.sessionView(ctx, account, app, csrfToken)
|
|
if err != nil {
|
|
_ = s.RevokeSession(ctx, session.ID, "profile_load_failed")
|
|
return LoginResult{}, err
|
|
}
|
|
return LoginResult{SessionToken: sessionToken, CSRFToken: csrfToken, View: view}, nil
|
|
}
|
|
|
|
func (s *Service) consumeUnresolvedLoginRateLimit(ctx context.Context, input LoginInput) error {
|
|
if s.limiter == nil {
|
|
return ErrLoginLimiterFailed
|
|
}
|
|
ipDigest := loginRateLimitDigest(input.IP)
|
|
identityDigest := loginRateLimitDigest(input.Username)
|
|
rules := []cache.FixedWindowRule{
|
|
// These keys are independent of App because no client-provided tenant value
|
|
// is trusted before the globally unique username resolves an account.
|
|
{Key: "rate:" + loginLimiterClusterTag + ":system", Limit: s.cfg.LoginRateLimit.SystemLimit},
|
|
{Key: "rate:" + loginLimiterClusterTag + ":ip:" + ipDigest, Limit: s.cfg.LoginRateLimit.IPLimit},
|
|
{Key: "rate:" + loginLimiterClusterTag + ":identity:" + identityDigest, Limit: s.cfg.LoginRateLimit.IdentityLimit},
|
|
}
|
|
return s.consumeLoginRateLimitRules(ctx, rules)
|
|
}
|
|
|
|
func (s *Service) consumeResolvedAppLoginRateLimit(ctx context.Context, appCode string) error {
|
|
if s.limiter == nil {
|
|
return ErrLoginLimiterFailed
|
|
}
|
|
rules := []cache.FixedWindowRule{{
|
|
Key: "rate:" + loginLimiterClusterTag + ":app:" + loginRateLimitDigest(normalizeAppCode(appCode)),
|
|
Limit: s.cfg.LoginRateLimit.AppLimit,
|
|
}}
|
|
return s.consumeLoginRateLimitRules(ctx, rules)
|
|
}
|
|
|
|
func (s *Service) consumeLoginRateLimitRules(ctx context.Context, rules []cache.FixedWindowRule) error {
|
|
limitCtx, cancel := context.WithTimeout(ctx, loginLimiterTimeout)
|
|
defer cancel()
|
|
decision, err := s.limiter.ConsumeFixedWindow(limitCtx, s.cfg.LoginRateLimit.Window, rules)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: %v", ErrLoginLimiterFailed, err)
|
|
}
|
|
if !decision.Allowed {
|
|
retryAfter := decision.RetryAfter
|
|
if retryAfter <= 0 {
|
|
retryAfter = s.cfg.LoginRateLimit.Window
|
|
}
|
|
return &LoginRateLimitError{RetryAfter: retryAfter}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func loginRateLimitDigest(value string) string {
|
|
digest := sha256.Sum256([]byte(value))
|
|
// 128 bits of the SHA-256 output keeps keys compact while retaining ample
|
|
// collision resistance; no App, IP or account identifier appears in Redis.
|
|
return hex.EncodeToString(digest[:16])
|
|
}
|
|
|
|
func (s *Service) Authenticate(ctx context.Context, rawToken string) (SessionPrincipal, error) {
|
|
if strings.TrimSpace(rawToken) == "" || s.db == nil {
|
|
return SessionPrincipal{}, ErrInvalidSession
|
|
}
|
|
nowMS := time.Now().UTC().UnixMilli()
|
|
var session model.ExternalAdminSession
|
|
err := s.db.WithContext(ctx).Preload("Account").Where("token_hash = ? AND revoked_at_ms = 0 AND expires_at_ms > ?", security.HashToken(rawToken), nowMS).First(&session).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return SessionPrincipal{}, ErrInvalidSession
|
|
}
|
|
if err != nil {
|
|
return SessionPrincipal{}, err
|
|
}
|
|
if session.Account.Status != model.ExternalAdminStatusActive || session.Account.AppCode != session.AppCode {
|
|
_ = s.RevokeSession(ctx, session.ID, "account_invalid")
|
|
return SessionPrincipal{}, ErrInvalidSession
|
|
}
|
|
// Login-time App validation is insufficient because an already authenticated
|
|
// browser can skip /auth/me and call a business route directly after operators
|
|
// disable the App. Re-read the indexed App row on every opaque-session auth.
|
|
// Only a confirmed missing/inactive App revokes the session; transient user DB
|
|
// failures fail closed for this request without destroying a recoverable session.
|
|
if _, err := s.findActiveApp(ctx, session.AppCode); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
_ = s.RevokeSession(ctx, session.ID, "app_disabled")
|
|
return SessionPrincipal{}, ErrInvalidSession
|
|
}
|
|
return SessionPrincipal{}, err
|
|
}
|
|
permissions, err := decodePermissions(session.Account.PermissionsJSON)
|
|
if err != nil {
|
|
_ = s.RevokeSession(ctx, session.ID, "permission_snapshot_invalid")
|
|
return SessionPrincipal{}, ErrInvalidSession
|
|
}
|
|
if nowMS-session.LastSeenAtMS >= lastSeenWriteInterval.Milliseconds() {
|
|
// last_seen is intentionally throttled so read-heavy external pages do not turn every request into a write.
|
|
_ = s.db.WithContext(ctx).Model(&model.ExternalAdminSession{}).Where("id = ? AND revoked_at_ms = 0", session.ID).Update("last_seen_at_ms", nowMS).Error
|
|
}
|
|
operatorID, err := operatorIDForAccount(session.Account.ID)
|
|
if err != nil {
|
|
_ = s.RevokeSession(ctx, session.ID, "account_id_out_of_range")
|
|
return SessionPrincipal{}, ErrInvalidSession
|
|
}
|
|
return SessionPrincipal{
|
|
SessionID: session.ID, AccountID: session.Account.ID, OperatorID: operatorID, AppCode: session.AppCode,
|
|
LinkedAppUserID: session.Account.LinkedAppUserID, Username: session.Account.Username,
|
|
Permissions: permissions, PasswordChangeRequired: session.Account.PasswordChangeRequired,
|
|
CSRFTokenHash: session.CSRFTokenHash, ExpiresAtMS: session.ExpiresAtMS,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) Me(ctx context.Context, principal SessionPrincipal, csrfToken string) (SessionView, error) {
|
|
var account model.ExternalAdminAccount
|
|
if err := s.db.WithContext(ctx).Where("id = ? AND app_code = ?", principal.AccountID, principal.AppCode).First(&account).Error; err != nil {
|
|
return SessionView{}, err
|
|
}
|
|
app, err := s.findActiveApp(ctx, principal.AppCode)
|
|
if err != nil {
|
|
return SessionView{}, err
|
|
}
|
|
return s.sessionView(ctx, account, app, csrfToken)
|
|
}
|
|
|
|
func (s *Service) ChangePassword(ctx context.Context, principal SessionPrincipal, input ChangePasswordInput) error {
|
|
if err := validatePassword(input.NewPassword); err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(input.CurrentPassword) == "" {
|
|
return ErrInvalidInput
|
|
}
|
|
hash, err := security.HashPassword(input.NewPassword)
|
|
if err != nil {
|
|
return ErrInvalidInput
|
|
}
|
|
var passwordErr error
|
|
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
var account model.ExternalAdminAccount
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND app_code = ?", principal.AccountID, principal.AppCode).First(&account).Error; err != nil {
|
|
return err
|
|
}
|
|
if !security.CheckPassword(account.PasswordHash, input.CurrentPassword) {
|
|
passwordErr = ErrInvalidCredentials
|
|
return nil
|
|
}
|
|
// This comparison must use the hash read under the account row lock. Otherwise
|
|
// two concurrent password changes could both compare against a stale hash and
|
|
// one request could clear password_change_required without changing the secret.
|
|
// Returning before both Updates calls also guarantees rejection neither clears
|
|
// the first-login gate nor revokes the account's other active sessions.
|
|
if security.CheckPassword(account.PasswordHash, input.NewPassword) {
|
|
passwordErr = ErrPasswordReused
|
|
return nil
|
|
}
|
|
if err := tx.Model(&account).Updates(map[string]any{
|
|
"password_hash": hash, "password_change_required": false,
|
|
"failed_login_count": 0, "locked_until_ms": 0,
|
|
}).Error; err != nil {
|
|
return err
|
|
}
|
|
nowMS := time.Now().UTC().UnixMilli()
|
|
return tx.Model(&model.ExternalAdminSession{}).
|
|
Where("account_id = ? AND id <> ? AND revoked_at_ms = 0", principal.AccountID, principal.SessionID).
|
|
Updates(map[string]any{"revoked_at_ms": nowMS, "revoke_reason": "password_changed"}).Error
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return passwordErr
|
|
}
|
|
|
|
func (s *Service) RevokeSession(ctx context.Context, sessionID uint64, reason string) error {
|
|
if sessionID == 0 || s.db == nil {
|
|
return nil
|
|
}
|
|
return s.db.WithContext(ctx).Model(&model.ExternalAdminSession{}).
|
|
Where("id = ? AND revoked_at_ms = 0", sessionID).
|
|
Updates(map[string]any{"revoked_at_ms": time.Now().UTC().UnixMilli(), "revoke_reason": strings.TrimSpace(reason)}).Error
|
|
}
|
|
|
|
func (s *Service) CreateOperationLog(ctx context.Context, log model.ExternalAdminOperationLog) error {
|
|
if s.db == nil {
|
|
return errors.New("admin mysql is not configured")
|
|
}
|
|
return s.db.WithContext(ctx).Create(&log).Error
|
|
}
|
|
|
|
func (s *Service) sessionView(ctx context.Context, account model.ExternalAdminAccount, app App, csrfToken string) (SessionView, error) {
|
|
users, err := s.loadLinkedUsers(ctx, account.AppCode, []int64{account.LinkedAppUserID})
|
|
if err != nil {
|
|
return SessionView{}, err
|
|
}
|
|
user, ok := users[account.LinkedAppUserID]
|
|
if !ok {
|
|
return SessionView{}, ErrTargetNotFound
|
|
}
|
|
permissions, err := decodePermissions(account.PermissionsJSON)
|
|
if err != nil {
|
|
return SessionView{}, err
|
|
}
|
|
return SessionView{
|
|
User: user,
|
|
Account: AccountSummary{ID: account.ID, Username: account.Username, Status: account.Status},
|
|
App: app, AppCode: account.AppCode, Permissions: permissions, Capabilities: capabilitiesFor(permissions),
|
|
PasswordChangeRequired: account.PasswordChangeRequired, CSRFToken: csrfToken,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) findActiveApp(ctx context.Context, appCode string) (App, error) {
|
|
if s.userDB == nil {
|
|
return App{}, errors.New("user mysql is not configured")
|
|
}
|
|
var app App
|
|
err := s.userDB.QueryRowContext(ctx, `SELECT app_code, app_name, logo_url FROM apps WHERE app_code = ? AND status = 'active' LIMIT 1`, normalizeAppCode(appCode)).Scan(&app.AppCode, &app.AppName, &app.LogoURL)
|
|
return app, err
|
|
}
|
|
|
|
func (s *Service) loadLinkedUsers(ctx context.Context, appCode string, userIDs []int64) (map[int64]LinkedUser, error) {
|
|
users := make(map[int64]LinkedUser, len(userIDs))
|
|
if len(userIDs) == 0 {
|
|
return users, nil
|
|
}
|
|
if s.userDB == nil {
|
|
return nil, errors.New("user mysql is not configured")
|
|
}
|
|
uniqueIDs := make([]int64, 0, len(userIDs))
|
|
seen := map[int64]struct{}{}
|
|
for _, userID := range userIDs {
|
|
if userID <= 0 {
|
|
continue
|
|
}
|
|
if _, ok := seen[userID]; ok {
|
|
continue
|
|
}
|
|
seen[userID] = struct{}{}
|
|
uniqueIDs = append(uniqueIDs, userID)
|
|
}
|
|
if len(uniqueIDs) == 0 {
|
|
return users, nil
|
|
}
|
|
placeholders := strings.TrimRight(strings.Repeat("?,", len(uniqueIDs)), ",")
|
|
nowMS := time.Now().UTC().UnixMilli()
|
|
args := []any{nowMS, nowMS, normalizeAppCode(appCode)}
|
|
for _, userID := range uniqueIDs {
|
|
args = append(args, userID)
|
|
}
|
|
rows, err := s.userDB.QueryContext(ctx, `
|
|
SELECT u.user_id,
|
|
COALESCE(u.current_display_user_id, ''),
|
|
COALESCE(u.default_display_user_id, ''),
|
|
COALESCE((
|
|
SELECT lease.display_user_id
|
|
FROM pretty_display_user_id_leases lease
|
|
WHERE lease.app_code = u.app_code AND lease.user_id = u.user_id
|
|
AND lease.status = 'active'
|
|
AND (COALESCE(lease.expires_at_ms, 0) = 0 OR lease.expires_at_ms > ?)
|
|
ORDER BY lease.created_at_ms DESC, lease.lease_id DESC LIMIT 1
|
|
), ''),
|
|
COALESCE((
|
|
SELECT pdi.pretty_id
|
|
FROM pretty_display_user_id_leases lease
|
|
JOIN pretty_display_ids pdi ON pdi.app_code = lease.app_code
|
|
AND pdi.assigned_lease_id = lease.lease_id AND pdi.status = 'assigned'
|
|
WHERE lease.app_code = u.app_code AND lease.user_id = u.user_id
|
|
AND lease.status = 'active'
|
|
AND (COALESCE(lease.expires_at_ms, 0) = 0 OR lease.expires_at_ms > ?)
|
|
ORDER BY lease.created_at_ms DESC, lease.lease_id DESC LIMIT 1
|
|
), ''),
|
|
COALESCE(u.username, ''), COALESCE(u.avatar, ''), COALESCE(u.status, '')
|
|
FROM users u
|
|
WHERE u.app_code = ? AND u.user_id IN (`+placeholders+`)`, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var user LinkedUser
|
|
if err := rows.Scan(&user.UserID, &user.DisplayUserID, &user.DefaultDisplayUserID, &user.PrettyDisplayUserID, &user.PrettyID, &user.Username, &user.Avatar, &user.Status); err != nil {
|
|
return nil, err
|
|
}
|
|
users[user.UserID] = user
|
|
}
|
|
return users, rows.Err()
|
|
}
|
|
|
|
func (s *Service) writeUnknownLoginLog(ctx context.Context, input LoginInput, message string) {
|
|
if s.db == nil {
|
|
return
|
|
}
|
|
// Empty app_code is the audit sentinel for an unresolved username. Persisting a
|
|
// request-supplied tenant here would incorrectly attribute an attack to a real App.
|
|
_ = s.db.WithContext(ctx).Create(&model.ExternalAdminLoginLog{
|
|
AppCode: "", Username: input.Username, IP: input.IP, UserAgent: input.UserAgent,
|
|
Status: "failed", Message: message,
|
|
}).Error
|
|
}
|
|
|
|
func (s *Service) writeResolvedLoginLog(ctx context.Context, accountID uint64, appCode string, input LoginInput, message string) {
|
|
if s.db == nil {
|
|
return
|
|
}
|
|
_ = s.db.WithContext(ctx).Create(&model.ExternalAdminLoginLog{
|
|
AccountID: &accountID, AppCode: normalizeAppCode(appCode), Username: input.Username,
|
|
IP: input.IP, UserAgent: input.UserAgent, Status: "failed", Message: message,
|
|
}).Error
|
|
}
|
|
|
|
func accountDTO(account model.ExternalAdminAccount, linkedUser LinkedUser) AccountDTO {
|
|
permissions, _ := decodePermissions(account.PermissionsJSON)
|
|
if linkedUser.UserID == 0 {
|
|
linkedUser.UserID = account.LinkedAppUserID
|
|
}
|
|
return AccountDTO{
|
|
ID: account.ID, AppCode: account.AppCode, Username: account.Username, Status: account.Status,
|
|
LinkedUser: linkedUser, Permissions: permissions, PermissionRevision: account.PermissionRevision, CreatedByAdminID: account.CreatedByAdminID,
|
|
CreatedAtMS: account.CreatedAtMS, UpdatedAtMS: account.UpdatedAtMS, LastLoginAtMS: account.LastLoginAtMS,
|
|
}
|
|
}
|
|
|
|
func revokeAllSessions(tx *gorm.DB, accountID uint64, reason string, nowMS int64) error {
|
|
return tx.Model(&model.ExternalAdminSession{}).Where("account_id = ? AND revoked_at_ms = 0", accountID).
|
|
Updates(map[string]any{"revoked_at_ms": nowMS, "revoke_reason": reason}).Error
|
|
}
|
|
|
|
func matchesExactIdentity(user LinkedUser, target string) bool {
|
|
target = strings.TrimSpace(target)
|
|
return target == strconv.FormatInt(user.UserID, 10) ||
|
|
target == user.DisplayUserID || target == user.DefaultDisplayUserID || target == user.PrettyDisplayUserID || target == user.PrettyID
|
|
}
|
|
|
|
func normalizePage(page int, pageSize int) (int, int) {
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
if pageSize < 1 {
|
|
pageSize = 20
|
|
}
|
|
if pageSize > 100 {
|
|
pageSize = 100
|
|
}
|
|
return page, pageSize
|
|
}
|
|
|
|
func normalizeAppCode(value string) string {
|
|
value = strings.ToLower(strings.TrimSpace(value))
|
|
if value == "" {
|
|
return ""
|
|
}
|
|
return appctx.Normalize(value)
|
|
}
|
|
|
|
func normalizeUsername(value string) string {
|
|
return strings.ToLower(strings.TrimSpace(value))
|
|
}
|
|
|
|
func normalizeStatus(value string) string {
|
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
case model.ExternalAdminStatusActive:
|
|
return model.ExternalAdminStatusActive
|
|
case model.ExternalAdminStatusDisabled:
|
|
return model.ExternalAdminStatusDisabled
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func validUsername(value string) bool {
|
|
if len(value) < 3 || len(value) > 64 {
|
|
return false
|
|
}
|
|
for _, char := range value {
|
|
if (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || char == '.' || char == '_' || char == '-' {
|
|
continue
|
|
}
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func validAppCode(value string) bool {
|
|
if len(value) == 0 || len(value) > 32 {
|
|
return false
|
|
}
|
|
for _, char := range value {
|
|
if (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || char == '_' || char == '-' {
|
|
continue
|
|
}
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func truncateText(value string, maxRunes int) string {
|
|
if maxRunes <= 0 {
|
|
return ""
|
|
}
|
|
runes := []rune(value)
|
|
if len(runes) <= maxRunes {
|
|
return value
|
|
}
|
|
return string(runes[:maxRunes])
|
|
}
|
|
|
|
func validatePassword(password string) error {
|
|
// Length alone would accept eight spaces. Such an account cannot complete the
|
|
// first-login flow because currentPassword is intentionally required to contain
|
|
// a non-whitespace character, so reject the unusable credential at every writer.
|
|
if strings.TrimSpace(password) == "" {
|
|
return ErrPasswordBlank
|
|
}
|
|
if len(password) < 8 || len(password) > 72 {
|
|
return fmt.Errorf("%w: password length must be between 8 and 72", ErrInvalidInput)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isDuplicateKey(err error) bool {
|
|
var mysqlErr *mysqlDriver.MySQLError
|
|
return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062
|
|
}
|