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 } // 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(DefaultPermissionSnapshot()) if err != nil { return AccountDTO{}, err } account := model.ExternalAdminAccount{ AppCode: input.AppCode, LinkedAppUserID: linkedUser.UserID, Username: input.Username, PasswordHash: passwordHash, PermissionsJSON: permissionsJSON, 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, 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 }