813 lines
29 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
}
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.AppCode = normalizeAppCode(input.AppCode)
input.Username = normalizeUsername(input.Username)
input.IP = truncateText(strings.TrimSpace(input.IP), 64)
input.UserAgent = truncateText(strings.TrimSpace(input.UserAgent), 512)
// Rate limiting runs before any MySQL lookup, login-log write or bcrypt work.
// This includes syntactically valid attempts with missing/invalid fields, so
// attackers cannot bypass distributed counters by varying malformed identities.
if err := s.consumeLoginRateLimit(ctx, input); err != nil {
return LoginResult{}, err
}
if s.db == nil {
return LoginResult{}, errors.New("admin mysql is not configured")
}
if !validAppCode(input.AppCode) || !validUsername(input.Username) || input.Password == "" {
security.CheckPassword(s.dummyHash, input.Password)
return LoginResult{}, ErrInvalidCredentials
}
app, err := s.findActiveApp(ctx, input.AppCode)
if err != nil {
// Keep unknown/disabled App, unknown account and wrong password indistinguishable.
security.CheckPassword(s.dummyHash, input.Password)
if errors.Is(err, sql.ErrNoRows) {
s.writeUnknownLoginLog(ctx, input, "invalid_credentials")
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("app_code = ? AND username = ?", input.AppCode, 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: input.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)
if account.Status != model.ExternalAdminStatusActive {
authErr = ErrInvalidCredentials
return tx.Create(&model.ExternalAdminLoginLog{AccountID: &accountID, AppCode: input.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: input.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: input.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: input.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) consumeLoginRateLimit(ctx context.Context, input LoginInput) error {
if s.limiter == nil {
return ErrLoginLimiterFailed
}
appDigest := loginRateLimitDigest(input.AppCode)
ipDigest := loginRateLimitDigest(input.IP)
identityDigest := loginRateLimitDigest(input.AppCode + "\x00" + input.Username)
rules := []cache.FixedWindowRule{
// The system key is intentionally independent of request App. It is the
// non-bypassable circuit breaker across tenant/IP/account rotations.
{Key: "rate:" + loginLimiterClusterTag + ":system", Limit: s.cfg.LoginRateLimit.SystemLimit},
{Key: "rate:" + loginLimiterClusterTag + ":app:" + appDigest, Limit: s.cfg.LoginRateLimit.AppLimit},
{Key: "rate:" + loginLimiterClusterTag + ":ip:" + ipDigest, Limit: s.cfg.LoginRateLimit.IPLimit},
{Key: "rate:" + loginLimiterClusterTag + ":identity:" + identityDigest, Limit: s.cfg.LoginRateLimit.IdentityLimit},
}
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
}
_ = s.db.WithContext(ctx).Create(&model.ExternalAdminLoginLog{
AppCode: input.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
}