kra-oa/internal/biz/authentication.go

190 lines
7.3 KiB
Go

package biz
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}
}
func (uc *AuthenticationUsecase) recordLogin(ctx context.Context, attempt *LoginAttempt, status bool, message string, userID uint) {
_ = 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, _ := uc.security.Current(ctx)
if config != nil && config.LockEnable {
if locked, _ := uc.security.LoginLocked(ctx, attempt.Username); locked {
uc.recordLogin(ctx, attempt, false, "账号已锁定", 0)
return nil, &AccountLockedError{Minutes: config.LockDuration}
}
}
ipTTL := time.Hour
requireCaptcha := config == nil || config.CaptchaOpen == 0
if config != nil {
if config.CaptchaTimeout > 0 {
ipTTL = time.Duration(config.CaptchaTimeout) * time.Second
}
failures, _ := uc.security.EnsureLoginIPCounter(ctx, attempt.IP, ipTTL)
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 != nil && config.LockEnable {
lockTTL := time.Duration(config.LockDuration) * time.Minute
failures, _ := uc.security.IncrementLoginFailure(ctx, attempt.Username, lockTTL)
if int(failures) >= config.LockThreshold {
_ = uc.security.LockLogin(ctx, attempt.Username, lockTTL)
}
}
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 config != nil && config.PwdExpireEnable && config.PwdExpireDays > 0 && user.PasswordUpdatedAt != nil && time.Now().After((*user.PasswordUpdatedAt).AddDate(0, 0, config.PwdExpireDays)) {
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
}
// 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
}
user, err := uc.users.User(ctx, claims.ID)
if err != nil {
return nil, err
}
// IssueToken receives the remaining lifetime rather than zero so the new
// token expires at the same instant as the token it replaced.
remaining := time.Until(claims.ExpiresAt)
issued, err := uc.issuer.IssueToken(user, authorityID, claims.MustChangePwd, remaining)
if err != nil {
return nil, err
}
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.tokens.IsTokenDisabled(ctx, token)
if err != nil || disabled {
return nil, ErrTokenDisabled
}
claims, err := uc.issuer.ParseToken(token)
if err != nil {
return nil, err
}
result := &TokenAuthentication{Claims: claims}
if claims.BufferTime <= 0 || time.Until(claims.ExpiresAt) >= claims.BufferTime {
return result, nil
}
user := &User{ID: claims.ID, UUID: claims.UUID, Username: claims.Username, NickName: claims.NickName, AuthorityID: claims.AuthorityID, MustChangePassword: claims.MustChangePwd}
issued, err := uc.issuer.IssueToken(user, claims.AuthorityID, claims.MustChangePwd, 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
}