package system import ( "context" "errors" "fmt" "time" ) var ( ErrAccountLocked = errors.New("account locked") ErrCaptchaInvalid = errors.New("captcha invalid") ErrTokenIssue = errors.New("token issue failed") ErrLoginState = errors.New("login state failed") ) type UserDisabledError struct{ UserID uint } func (e *UserDisabledError) Error() string { return ErrUserDisabled.Error() } func (e *UserDisabledError) Unwrap() error { return ErrUserDisabled } type AccountLockedError struct{ Minutes int } func (e *AccountLockedError) Error() string { return ErrAccountLocked.Error() } func (e *AccountLockedError) Unwrap() error { return ErrAccountLocked } type LoginAttempt struct { Username string Password string CaptchaID string Captcha string IP string Agent string } type AuthenticationResult struct { User *User Token string ExpiresAt time.Time NeedChangePassword bool } type TokenAuthentication struct { Claims *AuthClaims Refreshed *IssuedToken } type AuthenticationUsecase struct { users *UserUsecase security *SecurityUsecase issuer TokenIssuer audit AuditRecordRepo } func NewAuthenticationUsecase(users *UserUsecase, security *SecurityUsecase, issuer TokenIssuer, audit AuditRecordRepo) *AuthenticationUsecase { return &AuthenticationUsecase{users: users, security: security, issuer: issuer, audit: audit} } // passwordExpired reports whether the security policy has aged out this user's // password. Both the credential login and the per-request token check consult // it, so the rule lives in one place. func passwordExpired(config *SecurityConfig, user *User) bool { if config == nil || user == nil || !config.PwdExpireEnable || config.PwdExpireDays <= 0 || user.PasswordUpdatedAt == nil { return false } return time.Now().After(user.PasswordUpdatedAt.AddDate(0, 0, config.PwdExpireDays)) } func (uc *AuthenticationUsecase) recordLogin(ctx context.Context, attempt *LoginAttempt, status bool, message string, userID uint) { if uc.audit == nil || attempt == nil { return } _ = uc.audit.RecordLogin(ctx, &LoginLog{Username: attempt.Username, IP: attempt.IP, Status: status, ErrorMessage: message, Agent: attempt.Agent, UserID: userID}) } func (uc *AuthenticationUsecase) Login(ctx context.Context, attempt *LoginAttempt) (*AuthenticationResult, error) { config, err := uc.security.Current(ctx) if err != nil || config == nil { return nil, fmt.Errorf("%w: security config unavailable", ErrLoginState) } if config.LockEnable { locked, lockErr := uc.security.LoginLocked(ctx, attempt.Username) if lockErr != nil { return nil, fmt.Errorf("%w: %v", ErrLoginState, lockErr) } if locked { uc.recordLogin(ctx, attempt, false, "账号已锁定", 0) return nil, &AccountLockedError{Minutes: config.LockDuration} } } ipTTL := time.Hour if config.CaptchaTimeout > 0 { ipTTL = time.Duration(config.CaptchaTimeout) * time.Second } failures, counterErr := uc.security.EnsureLoginIPCounter(ctx, attempt.IP, ipTTL) if counterErr != nil { return nil, fmt.Errorf("%w: %v", ErrLoginState, counterErr) } requireCaptcha := config.CaptchaOpen == 0 || failures > config.CaptchaOpen if requireCaptcha && !uc.security.VerifyCaptcha(ctx, attempt.CaptchaID, attempt.Captcha, true) { _, _ = uc.security.IncrementLoginIP(ctx, attempt.IP, ipTTL) uc.recordLogin(ctx, attempt, false, "验证码错误", 0) return nil, ErrCaptchaInvalid } user, err := uc.users.Login(ctx, attempt.Username, attempt.Password) if err != nil { _, _ = uc.security.IncrementLoginIP(ctx, attempt.IP, ipTTL) if config.LockEnable { lockTTL := time.Duration(config.LockDuration) * time.Minute failures, stateErr := uc.security.IncrementLoginFailure(ctx, attempt.Username, lockTTL) if stateErr != nil { return nil, fmt.Errorf("%w: %v", ErrLoginState, stateErr) } if int(failures) >= config.LockThreshold { if stateErr = uc.security.LockLogin(ctx, attempt.Username, lockTTL); stateErr != nil { return nil, fmt.Errorf("%w: %v", ErrLoginState, stateErr) } } } uc.recordLogin(ctx, attempt, false, "用户名不存在或者密码错误", 0) return nil, ErrInvalidCredentials } if user.Enable != 1 { _, _ = uc.security.IncrementLoginIP(ctx, attempt.IP, ipTTL) uc.recordLogin(ctx, attempt, false, "用户被禁止登录", user.ID) return nil, &UserDisabledError{UserID: user.ID} } uc.security.ClearLoginState(ctx, attempt.Username) if passwordExpired(config, user) { user.MustChangePassword = true } issued, err := uc.issuer.IssueToken(user, user.AuthorityID, user.MustChangePassword, 0) if err != nil { return nil, fmt.Errorf("%w: %v", ErrTokenIssue, err) } // Record a successful credential login immediately after issuing the JWT. // Multipoint-session persistence happens afterwards, so a Redis/cache // failure must not erase the successful-login audit event. uc.recordLogin(ctx, attempt, true, "登录成功", user.ID) if uc.security.UseMultipoint() { oldToken, cacheErr := uc.security.ActiveToken(ctx, user.Username) if cacheErr != nil { return nil, fmt.Errorf("%w: %v", ErrLoginState, cacheErr) } if cacheErr = uc.security.RotateActiveToken(ctx, user.Username, oldToken, issued.Value, issued.TTL); cacheErr != nil { if errors.Is(cacheErr, ErrTokenRevoke) { return nil, cacheErr } return nil, fmt.Errorf("%w: %v", ErrLoginState, cacheErr) } } return &AuthenticationResult{User: user, Token: issued.Value, ExpiresAt: issued.ExpiresAt, NeedChangePassword: user.MustChangePassword}, nil } func userHasAuthority(user *User, authorityID uint) bool { if user == nil || authorityID == 0 { return false } if user.AuthorityID == authorityID { return true } for _, authority := range user.Authorities { if authority.AuthorityID == authorityID { return true } } return false } func (uc *AuthenticationUsecase) currentTokenUser(ctx context.Context, claims *AuthClaims) (*User, error) { if claims == nil || claims.UUID == "" || claims.IssuedAt.IsZero() { return nil, ErrTokenDisabled } user, err := uc.users.UserByUUID(ctx, claims.UUID) if err != nil || user == nil || user.ID != claims.ID || user.Username != claims.Username { return nil, ErrTokenDisabled } if user.Enable != 1 { return nil, &UserDisabledError{UserID: user.ID} } if !userHasAuthority(user, claims.AuthorityID) { return nil, ErrTokenDisabled } passwordVersion := int64(0) if user.PasswordUpdatedAt != nil { passwordVersion = user.PasswordUpdatedAt.UnixNano() } if passwordVersion != claims.PasswordVersion { return nil, ErrTokenDisabled } config, err := uc.security.Current(ctx) if err != nil || config == nil { return nil, ErrTokenDisabled } if passwordExpired(config, user) { user.MustChangePassword = true } return user, nil } // SwitchAuthority changes the current user's active role and re-signs the // current claims. Role switching keeps the original JWT expiry; it // does not treat a role switch as a fresh login or rotate the multipoint // session. Keeping that distinction is important to the admin UI, which // replaces the token returned in the response but expects its original TTL. func (uc *AuthenticationUsecase) SwitchAuthority(ctx context.Context, claims *AuthClaims, authorityID uint) (*AuthenticationResult, error) { if claims == nil { return nil, ErrInvalidCredentials } if err := uc.users.SetUserAuthority(ctx, claims.ID, authorityID); err != nil { return nil, err } // Role switching signs the current claims again after replacing only the // authority ID. It must not reload mutable profile fields or reset JWT // timing/buffer claims as if this were a fresh login. issued, err := uc.issuer.ReissueToken(claims, authorityID) if err != nil { return nil, err } user := &User{ID: claims.ID, UUID: claims.UUID, Username: claims.Username, NickName: claims.NickName, AuthorityID: authorityID, MustChangePassword: claims.MustChangePwd} return &AuthenticationResult{User: user, Token: issued.Value, ExpiresAt: issued.ExpiresAt, NeedChangePassword: claims.MustChangePwd}, nil } func (uc *AuthenticationUsecase) AuthenticateToken(ctx context.Context, token string) (*TokenAuthentication, error) { // The blacklist is checked before parsing the JWT. Besides avoiding work, // this makes a revoked-but-expired token report the revocation reason (and // not the generic expiry message), which the frontend uses to decide whether // to clear a session. disabled, err := uc.security.TokenDisabled(ctx, token) if err != nil || disabled { return nil, ErrTokenDisabled } claims, err := uc.issuer.ParseToken(token) if err != nil { return nil, err } user, err := uc.currentTokenUser(ctx, claims) if err != nil { return nil, err } claims.NickName = user.NickName claims.MustChangePwd = user.MustChangePassword result := &TokenAuthentication{Claims: claims} if claims.BufferTime <= 0 || time.Until(claims.ExpiresAt) >= claims.BufferTime { return result, nil } issued, err := uc.issuer.IssueToken(user, claims.AuthorityID, user.MustChangePassword, 0) if err != nil { return result, nil } // Refreshing updates the active Redis token but does not blacklist the old JWT; // both tokens remain valid until their own expiry. Passing an empty old token // preserves that behavior while still updating the multipoint session. if err = uc.security.RotateActiveToken(ctx, claims.Username, "", issued.Value, issued.TTL); err != nil { return result, nil } result.Refreshed = issued return result, nil }