chore(init): 初始化解忧商行项目并完成 Phase 0 基座

This commit is contained in:
yml2213
2026-10-30 11:46:10 +08:00
commit 8d9481c251
689 changed files with 138703 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
# Auth Module
手机号短信登录、JWT、验证码限流。
+337
View File
@@ -0,0 +1,337 @@
package auth
import (
"context"
"errors"
"net/http"
"strings"
"hfb_sys/backend/internal/sitecode"
"hfb_sys/backend/pkg/response"
"github.com/gin-gonic/gin"
)
type InvitationBinder interface {
BindInvitation(ctx context.Context, inviteeID uint64, code string, siteID uint64, siteCode ...string) error
}
type Handler struct {
service *Service
invitationBinder InvitationBinder
}
func (h *Handler) SetInvitationBinder(binder InvitationBinder) {
h.invitationBinder = binder
}
type SendSMSRequest struct {
Phone string `json:"phone" binding:"required"`
CaptchaID string `json:"captcha_id" binding:"required"`
CaptchaCode string `json:"captcha_code" binding:"required"`
SiteCode string `json:"site_code"`
}
type LoginRequest struct {
Phone string `json:"phone" binding:"required"`
Code string `json:"code" binding:"required,len=6"`
TrackCode string `json:"track_code"`
InviteCode string `json:"invite_code"`
}
type RefreshRequest struct {
RefreshToken string `json:"refresh_token" binding:"required"`
}
type PasswordLoginRequest struct {
Phone string `json:"phone" binding:"required"`
Password string `json:"password" binding:"required,min=8,max=20"`
}
type SetPasswordRequest struct {
Password string `json:"password" binding:"required,min=8,max=20"`
OldPassword string `json:"old_password"`
}
type RegisterRequest struct {
Phone string `json:"phone" binding:"required"`
Code string `json:"code" binding:"required,len=6"`
Password string `json:"password" binding:"required,min=8,max=20"`
TrackCode string `json:"track_code"`
InviteCode string `json:"invite_code"`
}
type ResetPasswordRequest struct {
Phone string `json:"phone" binding:"required"`
Code string `json:"code" binding:"required,len=6"`
NewPassword string `json:"new_password" binding:"required,min=8,max=20"`
}
func NewHandler(service *Service) *Handler {
return &Handler{service: service}
}
func (h *Handler) Captcha(c *gin.Context) {
item, err := h.service.Captcha(c.Request.Context())
if err != nil {
writeAuthError(c, err)
return
}
response.OK(c, item)
}
// SendSMS 发送短信验证码
// @Summary 发送短信验证码
// @Description 发送登录验证码到指定手机号
// @Tags 认证
// @Accept json
// @Produce json
// @Param request body SendSMSRequest true "手机号"
// @Success 200 {object} response.Body "发送成功"
// @Failure 400 {object} response.Body "请求参数错误"
// @Failure 500 {object} response.Body "服务器错误"
// @Router /auth/sms/send [post]
func (h *Handler) SendSMS(c *gin.Context) {
var req SendSMSRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "手机号和图形验证码不能为空")
return
}
siteCode := sitecode.FromRequest(c)
if siteCode == "" {
siteCode = strings.TrimSpace(req.SiteCode)
}
domain := strings.TrimSpace(firstNonEmpty(
c.GetHeader("X-Distribution-Domain"),
c.Query("distribution_domain"),
c.Query("domain"),
))
if siteCode == "" && domain == "" {
host := sitecode.ExtractHost(c.Request.Host)
if host != "" && !sitecode.IsLocalHost(host) {
domain = host
}
}
if err := h.service.SendSMSCode(c.Request.Context(), strings.TrimSpace(req.Phone), strings.TrimSpace(req.CaptchaID), strings.TrimSpace(req.CaptchaCode), siteCode, domain); err != nil {
writeAuthError(c, err)
return
}
response.OK(c, gin.H{"phone": PublicPhone(req.Phone), "expires_in": 300})
}
func firstNonEmpty(items ...string) string {
for _, item := range items {
if strings.TrimSpace(item) != "" {
return item
}
}
return ""
}
// Login 短信验证码登录
// @Summary 短信验证码登录
// @Description 使用手机号和验证码登录
// @Tags 认证
// @Accept json
// @Produce json
// @Param request body LoginRequest true "登录信息"
// @Success 200 {object} response.Body "登录成功,返回用户信息和 token"
// @Failure 400 {object} response.Body "请求参数错误"
// @Failure 401 {object} response.Body "验证码错误"
// @Failure 500 {object} response.Body "服务器错误"
// @Router /auth/sms/login [post]
func (h *Handler) Login(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "手机号和验证码不能为空")
return
}
siteCode := sitecode.FromRequest(c)
result, err := h.service.LoginWithSMS(c.Request.Context(), strings.TrimSpace(req.Phone), strings.TrimSpace(req.Code), strings.TrimSpace(req.TrackCode), siteCode)
if err != nil {
writeAuthError(c, err)
return
}
if h.invitationBinder != nil && strings.TrimSpace(req.InviteCode) != "" {
_ = h.invitationBinder.BindInvitation(c.Request.Context(), result.User.ID, strings.TrimSpace(req.InviteCode), 0, siteCode)
}
setAccessCookie(c, result.Tokens.AccessToken)
response.OK(c, result)
}
// Refresh 刷新访问令牌
// @Summary 刷新访问令牌
// @Description 使用 refresh_token 获取新的 access_token
// @Tags 认证
// @Accept json
// @Produce json
// @Param request body RefreshRequest true "刷新令牌"
// @Success 200 {object} response.Body "刷新成功,返回新的 token"
// @Failure 400 {object} response.Body "请求参数错误"
// @Failure 401 {object} response.Body "refresh_token 无效"
// @Router /auth/refresh [post]
func (h *Handler) Refresh(c *gin.Context) {
var req RefreshRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "refresh_token 不能为空")
return
}
tokens, err := h.service.RefreshToken(c.Request.Context(), req.RefreshToken)
if err != nil {
if errors.Is(err, ErrDependencyUnavailable) {
response.ServiceUnavailable(c, "用户认证服务暂时不可用")
return
}
response.Unauthorized(c, "刷新令牌无效或已过期")
return
}
setAccessCookie(c, tokens.AccessToken)
response.OK(c, tokens)
}
// Logout 登出
// @Summary 登出
// @Description 用户登出(客户端需清除本地 token)
// @Tags 认证
// @Accept json
// @Produce json
// @Success 200 {object} response.Body "登出成功"
// @Security BearerAuth
// @Router /auth/logout [post]
func (h *Handler) Logout(c *gin.Context) {
userID, ok := currentUserID(c)
if !ok {
response.Unauthorized(c, "缺少用户上下文")
return
}
if err := h.service.Logout(c.Request.Context(), userID); err != nil {
writeAuthError(c, err)
return
}
clearAccessCookie(c)
response.OK(c, gin.H{"logged_out": true})
}
func (h *Handler) PasswordLogin(c *gin.Context) {
var req PasswordLoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "手机号和密码不能为空,密码长度 8-20 位")
return
}
result, err := h.service.LoginWithPassword(c.Request.Context(), strings.TrimSpace(req.Phone), req.Password, c.ClientIP())
if err != nil {
writeAuthError(c, err)
return
}
setAccessCookie(c, result.Tokens.AccessToken)
response.OK(c, result)
}
func (h *Handler) Register(c *gin.Context) {
var req RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "手机号、验证码和密码不能为空,密码长度 8-20 位")
return
}
siteCode := sitecode.FromRequest(c)
result, err := h.service.RegisterWithPassword(c.Request.Context(), strings.TrimSpace(req.Phone), strings.TrimSpace(req.Code), req.Password, strings.TrimSpace(req.TrackCode), siteCode)
if err != nil {
writeAuthError(c, err)
return
}
if h.invitationBinder != nil && strings.TrimSpace(req.InviteCode) != "" {
_ = h.invitationBinder.BindInvitation(c.Request.Context(), result.User.ID, strings.TrimSpace(req.InviteCode), 0, siteCode)
}
setAccessCookie(c, result.Tokens.AccessToken)
response.OK(c, result)
}
func (h *Handler) SetPassword(c *gin.Context) {
var req SetPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "密码不能为空,长度 8-20 位")
return
}
userID, ok := currentUserID(c)
if !ok {
response.Unauthorized(c, "缺少用户上下文")
return
}
if err := h.service.SetPassword(c.Request.Context(), userID, req.Password, req.OldPassword); err != nil {
writeAuthError(c, err)
return
}
response.OK(c, gin.H{"updated": true})
}
func (h *Handler) ResetPassword(c *gin.Context) {
var req ResetPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "手机号、验证码和新密码不能为空")
return
}
if err := h.service.ResetPassword(c.Request.Context(), strings.TrimSpace(req.Phone), strings.TrimSpace(req.Code), req.NewPassword); err != nil {
writeAuthError(c, err)
return
}
response.OK(c, gin.H{"reseted": true})
}
func writeAuthError(c *gin.Context, err error) {
response.RecordError(c, err)
switch {
case errors.Is(err, ErrDependencyUnavailable):
response.ServiceUnavailable(c, "数据库或 Redis 未连接")
case errors.Is(err, ErrInvalidPhone):
response.BadRequest(c, "手机号格式不正确")
case errors.Is(err, ErrCaptchaInvalid):
response.BadRequest(c, "图形验证码错误或已过期")
case errors.Is(err, ErrCodeRateLimited):
response.Error(c, http.StatusTooManyRequests, "rate_limited", "验证码发送过于频繁")
case errors.Is(err, ErrSMSSendFailed):
response.Error(c, http.StatusBadGateway, "sms_send_failed", "验证码发送失败,请稍后重试")
case errors.Is(err, ErrCodeInvalid):
response.BadRequest(c, "验证码错误或已过期")
case errors.Is(err, ErrUserDisabled):
response.Error(c, http.StatusForbidden, "user_disabled", "用户已被冻结")
case errors.Is(err, ErrInvalidPassword):
response.BadRequest(c, "手机号或密码错误")
case errors.Is(err, ErrInvalidOldPassword):
response.BadRequest(c, "原密码错误")
case errors.Is(err, ErrPasswordNotSet):
response.BadRequest(c, "未设置密码,请使用短信验证码登录后设置密码")
case errors.Is(err, ErrPasswordTooWeak):
response.BadRequest(c, "密码长度应为 8-20 位")
case errors.Is(err, ErrLoginLocked):
response.Error(c, http.StatusTooManyRequests, "login_locked", "登录失败次数过多,请 15 分钟后再试")
case errors.Is(err, ErrUserAlreadyExists):
response.BadRequest(c, "该手机号已注册,请直接登录或使用短信验证码登录")
default:
response.Error(c, http.StatusInternalServerError, "internal_error", "服务暂时不可用")
}
}
func currentUserID(c *gin.Context) (uint64, bool) {
val, exists := c.Get("user_id")
if !exists {
return 0, false
}
userID, ok := val.(uint64)
return userID, ok
}
func setAccessCookie(c *gin.Context, token string) {
c.SetSameSite(http.SameSiteLaxMode)
c.SetCookie("hfb_user_access", token, 2*60*60, "/api", "", requestUsesHTTPS(c), true)
}
func clearAccessCookie(c *gin.Context) {
c.SetSameSite(http.SameSiteLaxMode)
c.SetCookie("hfb_user_access", "", -1, "/api", "", requestUsesHTTPS(c), true)
}
func requestUsesHTTPS(c *gin.Context) bool {
return c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https")
}
+164
View File
@@ -0,0 +1,164 @@
package auth
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
const (
tokenTypeAccess = "access"
tokenTypeRefresh = "refresh"
)
var ErrInvalidToken = errors.New("invalid token")
// TokenValidationError 仅携带可安全记录的失败类别,不包含原始令牌或签名信息。
type TokenValidationError struct {
Reason string
err error
}
func (e *TokenValidationError) Error() string {
return "token validation failed: " + e.Reason
}
func (e *TokenValidationError) Unwrap() error {
return e.err
}
// AuthFailureReason 让 HTTP 鉴权层可在不依赖具体认证包的情况下读取失败类别。
func (e *TokenValidationError) AuthFailureReason() string {
return e.Reason
}
// TokenFailureReason 返回适合写入日志的令牌校验失败类别。
func TokenFailureReason(err error) string {
var validationErr *TokenValidationError
if errors.As(err, &validationErr) && validationErr.Reason != "" {
return validationErr.Reason
}
return "invalid"
}
type JWTManager struct {
secret []byte
accessTTL time.Duration
refreshTTL time.Duration
signingMethod jwt.SigningMethod
}
type Claims struct {
UserID uint64 `json:"uid"`
Phone string `json:"phone"`
TokenType string `json:"typ"`
SubjectType string `json:"sub_type"`
TokenVersion int64 `json:"ver,omitempty"`
jwt.RegisteredClaims
}
type TokenPair struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
ExpiresInSeconds int64 `json:"expires_in"`
}
func NewJWTManager(secret string) *JWTManager {
return &JWTManager{
secret: []byte(secret),
accessTTL: 2 * time.Hour,
refreshTTL: 14 * 24 * time.Hour,
signingMethod: jwt.SigningMethodHS256,
}
}
func (m *JWTManager) GeneratePair(userID uint64, phone string) (TokenPair, error) {
return m.GenerateSubjectPair(userID, phone, "user")
}
func (m *JWTManager) GenerateSubjectPair(userID uint64, subject string, subjectType string) (TokenPair, error) {
return m.GenerateSubjectPairWithVersion(userID, subject, subjectType, 0)
}
func (m *JWTManager) GenerateSubjectPairWithVersion(userID uint64, subject string, subjectType string, tokenVersion int64) (TokenPair, error) {
accessToken, err := m.generate(userID, subject, subjectType, tokenTypeAccess, tokenVersion, m.accessTTL)
if err != nil {
return TokenPair{}, err
}
refreshToken, err := m.generate(userID, subject, subjectType, tokenTypeRefresh, tokenVersion, m.refreshTTL)
if err != nil {
return TokenPair{}, err
}
return TokenPair{
AccessToken: accessToken,
RefreshToken: refreshToken,
TokenType: "Bearer",
ExpiresInSeconds: int64(m.accessTTL.Seconds()),
}, nil
}
func (m *JWTManager) Parse(tokenText, expectedType string) (*Claims, error) {
return m.ParseSubject(tokenText, expectedType, "")
}
func (m *JWTManager) ParseSubject(tokenText, expectedType string, expectedSubjectType string) (*Claims, error) {
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenText, claims, func(token *jwt.Token) (any, error) {
if token.Method != m.signingMethod {
return nil, newTokenValidationError("signing_method")
}
return m.secret, nil
})
if err != nil {
return nil, newTokenValidationError(jwtFailureReason(err))
}
if !token.Valid {
return nil, newTokenValidationError("invalid")
}
if claims.TokenType != expectedType {
return nil, newTokenValidationError("token_type")
}
if expectedSubjectType != "" && claims.SubjectType != expectedSubjectType {
return nil, newTokenValidationError("subject_type")
}
return claims, nil
}
func newTokenValidationError(reason string) error {
return &TokenValidationError{Reason: reason, err: ErrInvalidToken}
}
func jwtFailureReason(err error) string {
switch {
case errors.Is(err, jwt.ErrTokenExpired):
return "expired"
case errors.Is(err, jwt.ErrTokenSignatureInvalid):
return "signature_invalid"
case errors.Is(err, jwt.ErrTokenMalformed):
return "malformed"
case errors.Is(err, jwt.ErrTokenNotValidYet):
return "not_valid_yet"
default:
return "invalid"
}
}
func (m *JWTManager) generate(userID uint64, subject string, subjectType string, tokenType string, tokenVersion int64, ttl time.Duration) (string, error) {
now := time.Now()
claims := Claims{
UserID: userID,
Phone: subject,
TokenType: tokenType,
SubjectType: subjectType,
TokenVersion: tokenVersion,
RegisteredClaims: jwt.RegisteredClaims{
Subject: subject,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
},
}
token := jwt.NewWithClaims(m.signingMethod, claims)
return token.SignedString(m.secret)
}
+62
View File
@@ -0,0 +1,62 @@
package auth
import (
"testing"
"time"
)
func TestParseSubjectReportsSafeFailureReason(t *testing.T) {
manager := NewJWTManager("test-jwt-secret-for-failure-reason")
pair, err := manager.GenerateSubjectPairWithVersion(1, "admin", "admin", 1)
if err != nil {
t.Fatalf("GenerateSubjectPairWithVersion() error = %v", err)
}
tests := []struct {
name string
manager *JWTManager
token string
tokenType string
want string
}{
{
name: "签名不匹配",
manager: NewJWTManager("another-jwt-secret-for-failure-reason"),
token: pair.AccessToken,
tokenType: "access",
want: "signature_invalid",
},
{
name: "令牌类型不匹配",
manager: manager,
token: pair.AccessToken,
tokenType: "refresh",
want: "token_type",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := tt.manager.ParseSubject(tt.token, tt.tokenType, "admin")
if err == nil {
t.Fatal("ParseSubject() error = nil")
}
if got := TokenFailureReason(err); got != tt.want {
t.Fatalf("TokenFailureReason() = %q, want %q", got, tt.want)
}
})
}
t.Run("令牌已过期", func(t *testing.T) {
expiredManager := NewJWTManager("test-jwt-secret-for-expired-token")
expiredManager.accessTTL = -time.Minute
expiredPair, err := expiredManager.GenerateSubjectPairWithVersion(1, "admin", "admin", 1)
if err != nil {
t.Fatalf("GenerateSubjectPairWithVersion() error = %v", err)
}
_, err = expiredManager.ParseSubject(expiredPair.AccessToken, "access", "admin")
if got := TokenFailureReason(err); got != "expired" {
t.Fatalf("TokenFailureReason() = %q, want %q", got, "expired")
}
})
}
+168
View File
@@ -0,0 +1,168 @@
package auth
import (
"context"
"errors"
"regexp"
"strings"
"time"
"hfb_sys/backend/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var trackCodePattern = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`)
func sanitizeTrackCode(code string) string {
s := strings.TrimSpace(code)
if len(s) == 0 || len(s) > 64 || !trackCodePattern.MatchString(s) {
return ""
}
return s
}
type UserRepository struct {
db *gorm.DB
}
func NewUserRepository(db *gorm.DB) *UserRepository {
return &UserRepository{db: db}
}
func (r *UserRepository) FindByID(ctx context.Context, id uint64) (*model.User, error) {
var user model.User
if err := r.db.WithContext(ctx).First(&user, id).Error; err != nil {
return nil, err
}
return &user, nil
}
// FindActiveForToken 校验用户仍可用且令牌版本未被撤销。
func (r *UserRepository) FindActiveForToken(ctx context.Context, id uint64, tokenVersion int64) (*model.User, error) {
if r == nil || r.db == nil {
return nil, ErrDependencyUnavailable
}
var user model.User
if err := r.db.WithContext(ctx).
Select("id, phone, status, token_version").
First(&user, id).Error; err != nil {
return nil, err
}
if user.Status != "active" {
return nil, ErrUserDisabled
}
if user.TokenVersion != tokenVersion {
return nil, ErrTokenVersionMismatch
}
return &user, nil
}
func (r *UserRepository) UpdateProfile(ctx context.Context, id uint64, nickname string, avatarURL string) (*model.User, error) {
if err := r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", id).Updates(map[string]any{
"nickname": nickname,
"avatar_url": avatarURL,
}).Error; err != nil {
return nil, err
}
return r.FindByID(ctx, id)
}
func (r *UserRepository) resolveSiteID(ctx context.Context, siteCode string) uint64 {
siteCode = strings.TrimSpace(siteCode)
if siteCode == "" {
return 0
}
var ds model.DistributionSite
if err := r.db.WithContext(ctx).Where("site_code = ? AND status = 'active'", siteCode).Select("id").First(&ds).Error; err == nil {
return ds.ID
}
return 0
}
func (r *UserRepository) FindOrCreateByPhone(ctx context.Context, phone string, trackCode string, siteCode ...string) (*model.User, error) {
now := time.Now()
code := sanitizeTrackCode(trackCode)
var siteID uint64
if len(siteCode) > 0 {
siteID = r.resolveSiteID(ctx, siteCode[0])
}
user := model.User{
Phone: phone,
Nickname: "用户" + phone[len(phone)-4:],
RealnameStatus: "unverified",
RiskStatus: "normal",
CreditScore: 100,
Status: "active",
RegisterTrackCode: code,
DistributionSiteID: siteID,
LastLoginAt: &now,
}
err := r.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "phone"}},
DoUpdates: clause.AssignmentColumns([]string{"last_login_at", "updated_at"}),
}).Create(&user).Error
if err != nil {
return nil, err
}
var found model.User
if err := r.db.WithContext(ctx).Where("phone = ?", phone).First(&found).Error; err != nil {
return nil, err
}
return &found, nil
}
func (r *UserRepository) FindByPhone(ctx context.Context, phone string) (*model.User, error) {
var user model.User
if err := r.db.WithContext(ctx).Where("phone = ?", phone).First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
func (r *UserRepository) SetPassword(ctx context.Context, userID uint64, hash string) error {
return r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", userID).Updates(map[string]any{
"password_hash": hash,
"token_version": gorm.Expr("token_version + 1"),
}).Error
}
// RevokeTokens 使当前用户的所有 access/refresh token 立即失效。
func (r *UserRepository) RevokeTokens(ctx context.Context, userID uint64) error {
return r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", userID).
UpdateColumn("token_version", gorm.Expr("token_version + 1")).Error
}
func (r *UserRepository) RegisterWithPassword(ctx context.Context, phone string, hash string, trackCode string, siteCode ...string) (*model.User, error) {
now := time.Now()
code := sanitizeTrackCode(trackCode)
var siteID uint64
if len(siteCode) > 0 {
siteID = r.resolveSiteID(ctx, siteCode[0])
}
user := model.User{
Phone: phone,
PasswordHash: hash,
Nickname: "用户" + phone[len(phone)-4:],
RealnameStatus: "unverified",
RiskStatus: "normal",
CreditScore: 100,
Status: "active",
RegisterTrackCode: code,
DistributionSiteID: siteID,
LastLoginAt: &now,
}
err := r.db.WithContext(ctx).Create(&user).Error
if err != nil {
return nil, err
}
return &user, nil
}
func IsNotFound(err error) bool {
return errors.Is(err, gorm.ErrRecordNotFound)
}
+469
View File
@@ -0,0 +1,469 @@
package auth
import (
"context"
"crypto/rand"
"errors"
"fmt"
"math/big"
"regexp"
"strings"
"time"
"hfb_sys/backend/internal/captcha"
smsprovider "hfb_sys/backend/internal/integrations/sms"
"hfb_sys/backend/internal/logging"
"hfb_sys/backend/internal/model"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
)
var (
ErrDependencyUnavailable = errors.New("dependency unavailable")
ErrInvalidPhone = errors.New("invalid phone")
ErrCaptchaInvalid = errors.New("captcha invalid")
ErrCodeRateLimited = errors.New("sms code rate limited")
ErrCodeInvalid = errors.New("sms code invalid")
ErrSMSSendFailed = errors.New("sms send failed")
ErrUserDisabled = errors.New("user disabled")
ErrInvalidPassword = errors.New("invalid password")
ErrInvalidOldPassword = errors.New("invalid old password")
ErrPasswordNotSet = errors.New("password not set")
ErrPasswordTooWeak = errors.New("password too weak")
ErrLoginLocked = errors.New("login locked")
ErrUserAlreadyExists = errors.New("user already exists")
ErrTokenVersionMismatch = errors.New("user token version mismatch")
)
const (
smsLoginCooldown = 60 * time.Second
smsLoginHourlyLimit = 5
smsLoginHourlyWindow = time.Hour
smsCaptchaTTL = 3 * time.Minute
passwordMinLength = 8
passwordMaxLength = 20
loginFailureTTL = 15 * time.Minute
loginLockTTL = 15 * time.Minute
loginMaxFailureCount = 5
)
// SiteSMSProviderResolver 用于根据分销站点标识解析专属 SMS Provider
type SiteSMSProviderResolver interface {
ResolveSMSProvider(ctx context.Context, siteCode, domain string) (smsprovider.Provider, error)
}
type Service struct {
users *UserRepository
redis *redis.Client
jwt *JWTManager
sms smsprovider.Provider
smsResolver SiteSMSProviderResolver
log *zap.Logger
}
type LoginResult struct {
User *model.User `json:"user"`
Tokens TokenPair `json:"tokens"`
}
func NewService(users *UserRepository, redis *redis.Client, jwt *JWTManager, sms smsprovider.Provider, log *zap.Logger) *Service {
return &Service{users: users, redis: redis, jwt: jwt, sms: sms, log: log}
}
func (s *Service) SetSiteSMSResolver(resolver SiteSMSProviderResolver) {
s.smsResolver = resolver
}
func (s *Service) Captcha(ctx context.Context) (*captcha.Item, error) {
if s.redis == nil {
return nil, ErrDependencyUnavailable
}
item, err := captcha.Generate(ctx, s.redis, "auth:sms", smsCaptchaTTL)
if errors.Is(err, captcha.ErrDependencyUnavailable) {
return nil, ErrDependencyUnavailable
}
return item, err
}
func (s *Service) SendSMSCode(ctx context.Context, phone string, captchaID string, captchaCode string, siteContext ...string) error {
if s.redis == nil {
return ErrDependencyUnavailable
}
if !isPhone(phone) {
return ErrInvalidPhone
}
cooldownKey := "sms:cooldown:login:" + phone
exists, err := s.redis.Exists(ctx, cooldownKey).Result()
if err != nil {
return err
}
if exists > 0 {
return ErrCodeRateLimited
}
hourlyKey := "sms:hourly:login:" + phone
hourlyCount, err := s.redis.Get(ctx, hourlyKey).Int()
if err != nil && !errors.Is(err, redis.Nil) {
return err
}
if hourlyCount >= smsLoginHourlyLimit {
return ErrCodeRateLimited
}
if err := captcha.Verify(ctx, s.redis, "auth:sms", captchaID, captchaCode); err != nil {
if errors.Is(err, captcha.ErrDependencyUnavailable) {
return ErrDependencyUnavailable
}
if errors.Is(err, captcha.ErrInvalid) {
return ErrCaptchaInvalid
}
return err
}
code, err := randomDigits(6)
if err != nil {
return err
}
provider := s.sms
siteCode := ""
domain := ""
if len(siteContext) > 0 {
siteCode = strings.TrimSpace(siteContext[0])
}
if len(siteContext) > 1 {
domain = strings.TrimSpace(siteContext[1])
}
if s.smsResolver != nil && (siteCode != "" || domain != "") {
customProvider, resolveErr := s.smsResolver.ResolveSMSProvider(ctx, siteCode, domain)
if resolveErr != nil {
if s.log != nil {
s.log.Warn("解析分销站点独立短信服务商失败,将回退至主站默认通道",
zap.String("site_code", siteCode),
zap.String("domain", domain),
zap.Error(resolveErr),
)
}
} else if customProvider != nil {
provider = customProvider
}
}
if provider == nil {
return ErrDependencyUnavailable
}
if err := provider.SendLoginCode(ctx, phone, code); err != nil {
if s.log != nil {
fields := []zap.Field{
zap.String("module", "auth"),
zap.String("phone", PublicPhone(phone)),
zap.String("site_code", siteCode),
zap.Error(err),
}
if requestID := logging.RequestIDFromContext(ctx); requestID != "" {
fields = append(fields, zap.String("request_id", requestID))
}
s.log.Warn("短信验证码发送失败", fields...)
}
if smsprovider.IsProviderRateLimited(err) {
pipe := s.redis.TxPipeline()
pipe.Set(ctx, cooldownKey, "1", smsLoginCooldown)
pipe.Set(ctx, hourlyKey, smsLoginHourlyLimit, smsLoginHourlyWindow)
if _, pipeErr := pipe.Exec(ctx); pipeErr != nil {
return pipeErr
}
return ErrCodeRateLimited
}
return ErrSMSSendFailed
}
pipe := s.redis.TxPipeline()
pipe.Set(ctx, codeKey(phone), code, 5*time.Minute)
pipe.Set(ctx, cooldownKey, "1", smsLoginCooldown)
pipe.Incr(ctx, hourlyKey)
pipe.Expire(ctx, hourlyKey, smsLoginHourlyWindow)
pipe.Incr(ctx, "sms:daily:login:"+phone)
pipe.Expire(ctx, "sms:daily:login:"+phone, 24*time.Hour)
if _, err := pipe.Exec(ctx); err != nil {
return err
}
return nil
}
func (s *Service) LoginWithSMS(ctx context.Context, phone string, code string, trackCode string, siteCode ...string) (LoginResult, error) {
if s.redis == nil || s.users == nil {
return LoginResult{}, ErrDependencyUnavailable
}
if !isPhone(phone) {
return LoginResult{}, ErrInvalidPhone
}
stored, err := s.redis.Get(ctx, codeKey(phone)).Result()
if errors.Is(err, redis.Nil) || stored != code {
return LoginResult{}, ErrCodeInvalid
}
if err != nil {
return LoginResult{}, err
}
user, err := s.users.FindOrCreateByPhone(ctx, phone, trackCode, siteCode...)
if err != nil {
return LoginResult{}, err
}
if user.Status != "active" {
return LoginResult{}, ErrUserDisabled
}
tokens, err := s.generateUserTokenPair(user)
if err != nil {
return LoginResult{}, err
}
_ = s.redis.Del(ctx, codeKey(phone)).Err()
return LoginResult{User: user, Tokens: tokens}, nil
}
func (s *Service) RefreshToken(ctx context.Context, refreshToken string) (TokenPair, error) {
if s.jwt == nil || s.users == nil {
return TokenPair{}, ErrDependencyUnavailable
}
claims, err := s.jwt.ParseSubject(refreshToken, tokenTypeRefresh, "user")
if err != nil {
return TokenPair{}, err
}
user, err := s.users.FindActiveForToken(ctx, claims.UserID, claims.TokenVersion)
if err != nil {
return TokenPair{}, err
}
return s.generateUserTokenPair(user)
}
func (s *Service) generateUserTokenPair(user *model.User) (TokenPair, error) {
if s.jwt == nil {
return TokenPair{}, ErrDependencyUnavailable
}
return s.jwt.GenerateSubjectPairWithVersion(user.ID, user.Phone, "user", user.TokenVersion)
}
func (s *Service) Logout(ctx context.Context, userID uint64) error {
if s.jwt == nil || s.users == nil {
return ErrDependencyUnavailable
}
return s.users.RevokeTokens(ctx, userID)
}
func codeKey(phone string) string {
return "sms:code:login:" + phone
}
func isPhone(phone string) bool {
return regexp.MustCompile(`^1[3-9]\d{9}$`).MatchString(phone)
}
func randomDigits(length int) (string, error) {
result := make([]byte, length)
for i := range result {
n, err := rand.Int(rand.Reader, big.NewInt(10))
if err != nil {
return "", err
}
result[i] = byte('0' + n.Int64())
}
return string(result), nil
}
func PublicPhone(phone string) string {
if len(phone) < 7 {
return phone
}
return fmt.Sprintf("%s****%s", phone[:3], phone[len(phone)-4:])
}
func (s *Service) LoginWithPassword(ctx context.Context, phone, password, clientIP string) (LoginResult, error) {
if s.redis == nil || s.users == nil {
return LoginResult{}, ErrDependencyUnavailable
}
if !isPhone(phone) {
return LoginResult{}, ErrInvalidPhone
}
locked, err := s.redis.Exists(ctx, loginLockRedisKey(phone, clientIP)).Result()
if err != nil {
return LoginResult{}, err
}
if locked > 0 {
return LoginResult{}, ErrLoginLocked
}
user, err := s.users.FindByPhone(ctx, phone)
if IsNotFound(err) {
// 用户不存在也计失败次数,避免该接口被用于探测手机号是否已注册
_ = recordLoginFailure(ctx, s.redis, phone, clientIP)
return LoginResult{}, ErrInvalidPassword
}
if err != nil {
return LoginResult{}, err
}
if user.PasswordHash == "" {
return LoginResult{}, ErrPasswordNotSet
}
if checkPasswordHash(password, user.PasswordHash) != nil {
_ = recordLoginFailure(ctx, s.redis, phone, clientIP)
return LoginResult{}, ErrInvalidPassword
}
if user.Status != "active" {
return LoginResult{}, ErrUserDisabled
}
_ = clearLoginFailure(ctx, s.redis, phone, clientIP)
tokens, err := s.generateUserTokenPair(user)
if err != nil {
return LoginResult{}, err
}
return LoginResult{User: user, Tokens: tokens}, nil
}
func (s *Service) SetPassword(ctx context.Context, userID uint64, password, oldPassword string) error {
if !isValidPassword(password) {
return ErrPasswordTooWeak
}
user, err := s.users.FindByID(ctx, userID)
if err != nil {
return err
}
// 已设置过密码时必须校验旧密码,避免会话被他人借用后直接改密锁定原号主
if user.PasswordHash != "" {
if checkPasswordHash(oldPassword, user.PasswordHash) != nil {
return ErrInvalidOldPassword
}
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
return s.users.SetPassword(ctx, userID, string(hash))
}
func (s *Service) ResetPassword(ctx context.Context, phone, code, newPassword string) error {
if s.redis == nil || s.users == nil {
return ErrDependencyUnavailable
}
if !isPhone(phone) {
return ErrInvalidPhone
}
stored, err := s.redis.Get(ctx, codeKey(phone)).Result()
if errors.Is(err, redis.Nil) || stored != code {
return ErrCodeInvalid
}
if err != nil {
return err
}
if !isValidPassword(newPassword) {
return ErrPasswordTooWeak
}
user, err := s.users.FindByPhone(ctx, phone)
if err != nil {
return err
}
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
if err := s.users.SetPassword(ctx, user.ID, string(hash)); err != nil {
return err
}
_ = s.redis.Del(ctx, codeKey(phone)).Err()
return nil
}
func (s *Service) RegisterWithPassword(ctx context.Context, phone, code, password string, trackCode string, siteCode ...string) (LoginResult, error) {
if s.redis == nil || s.users == nil {
return LoginResult{}, ErrDependencyUnavailable
}
if !isPhone(phone) {
return LoginResult{}, ErrInvalidPhone
}
stored, err := s.redis.Get(ctx, codeKey(phone)).Result()
if errors.Is(err, redis.Nil) || stored != code {
return LoginResult{}, ErrCodeInvalid
}
if err != nil {
return LoginResult{}, err
}
if !isValidPassword(password) {
return LoginResult{}, ErrPasswordTooWeak
}
// 已注册的号不允许通过注册流程覆盖密码,避免"注册即改密"接管账号
existing, err := s.users.FindByPhone(ctx, phone)
if err == nil && existing != nil {
return LoginResult{}, ErrUserAlreadyExists
}
if err != nil && !IsNotFound(err) {
return LoginResult{}, err
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return LoginResult{}, err
}
user, err := s.users.RegisterWithPassword(ctx, phone, string(hash), trackCode, siteCode...)
if err != nil {
// 并发场景下仍可能因唯一索引冲突,统一按"已注册"处理
return LoginResult{}, ErrUserAlreadyExists
}
if user.Status != "active" {
return LoginResult{}, ErrUserDisabled
}
tokens, err := s.generateUserTokenPair(user)
if err != nil {
return LoginResult{}, err
}
_ = s.redis.Del(ctx, codeKey(phone)).Err()
return LoginResult{User: user, Tokens: tokens}, nil
}
func isValidPassword(password string) bool {
return len(password) >= passwordMinLength && len(password) <= passwordMaxLength
}
func checkPasswordHash(password, hash string) error {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
}
func recordLoginFailure(ctx context.Context, rdb *redis.Client, phone, clientIP string) error {
key := loginFailureRedisKey(phone, clientIP)
count, err := rdb.Incr(ctx, key).Result()
if err != nil {
return err
}
_ = rdb.Expire(ctx, key, loginFailureTTL).Err()
if count >= int64(loginMaxFailureCount) {
_ = rdb.Set(ctx, loginLockRedisKey(phone, clientIP), "1", loginLockTTL).Err()
}
return nil
}
func clearLoginFailure(ctx context.Context, rdb *redis.Client, phone, clientIP string) error {
return rdb.Del(ctx, loginFailureRedisKey(phone, clientIP), loginLockRedisKey(phone, clientIP)).Err()
}
func loginFailureRedisKey(phone, clientIP string) string {
return fmt.Sprintf("user:login:fail:%s:%s", clientIP, phone)
}
func loginLockRedisKey(phone, clientIP string) string {
return fmt.Sprintf("user:login:lock:%s:%s", clientIP, phone)
}
@@ -0,0 +1,104 @@
package auth
import (
"context"
"testing"
"time"
smsprovider "hfb_sys/backend/internal/integrations/sms"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
)
type recordProvider struct {
sentPhones []string
}
func (p *recordProvider) SendLoginCode(ctx context.Context, phone string, code string) error {
p.sentPhones = append(p.sentPhones, phone)
return nil
}
func (p *recordProvider) SendTemplate(ctx context.Context, req smsprovider.TemplateRequest) error {
p.sentPhones = append(p.sentPhones, req.Phone)
return nil
}
type mockResolver struct {
customProvider smsprovider.Provider
calledSites []string
}
func (m *mockResolver) ResolveSMSProvider(ctx context.Context, siteCode, domain string) (smsprovider.Provider, error) {
m.calledSites = append(m.calledSites, siteCode)
if siteCode == "site_custom" {
return m.customProvider, nil
}
return nil, nil
}
func TestSendSMSCode_SiteResolutionAndFallback(t *testing.T) {
rdb := redis.NewClient(&redis.Options{
Addr: "127.0.0.1:16379",
DB: 14,
})
ctx := t.Context()
if err := rdb.Ping(ctx).Err(); err != nil {
t.Skipf("跳过测试:未检测到测试 Redis: %v", err)
}
t.Cleanup(func() {
_ = rdb.FlushDB(context.Background()).Err()
_ = rdb.Close()
})
_ = rdb.FlushDB(ctx)
defaultProv := &recordProvider{}
customProv := &recordProvider{}
resolver := &mockResolver{customProvider: customProv}
svc := NewService(nil, rdb, nil, defaultProv, zap.NewNop())
svc.SetSiteSMSResolver(resolver)
// 1. 无分站标识 -> 使用默认主站 provider
if err := rdb.Set(ctx, "captcha:auth:sms:cap1", "ABCD", time.Minute).Err(); err != nil {
t.Fatal(err)
}
phone1 := "13800138001"
err := svc.SendSMSCode(ctx, phone1, "cap1", "ABCD")
if err != nil {
t.Fatalf("SendSMSCode failed: %v", err)
}
if len(defaultProv.sentPhones) != 1 || defaultProv.sentPhones[0] != phone1 {
t.Fatalf("expected default provider to send, got %v", defaultProv.sentPhones)
}
if len(customProv.sentPhones) != 0 {
t.Fatalf("custom provider should not be called without site")
}
// 2. 携带自定义短信分站标识 -> 使用 custom provider
if err := rdb.Set(ctx, "captcha:auth:sms:cap2", "EFGH", time.Minute).Err(); err != nil {
t.Fatal(err)
}
phone2 := "13800138002"
err = svc.SendSMSCode(ctx, phone2, "cap2", "EFGH", "site_custom")
if err != nil {
t.Fatalf("SendSMSCode with custom site failed: %v", err)
}
if len(customProv.sentPhones) != 1 || customProv.sentPhones[0] != phone2 {
t.Fatalf("expected custom provider to send, got %v", customProv.sentPhones)
}
// 3. 携带未配置短信分站标识 -> 优雅降级到 default provider
if err := rdb.Set(ctx, "captcha:auth:sms:cap3", "JKLM", time.Minute).Err(); err != nil {
t.Fatal(err)
}
phone3 := "13800138003"
err = svc.SendSMSCode(ctx, phone3, "cap3", "JKLM", "site_fallback")
if err != nil {
t.Fatalf("SendSMSCode with fallback site failed: %v", err)
}
if len(defaultProv.sentPhones) != 2 || defaultProv.sentPhones[1] != phone3 {
t.Fatalf("expected default provider to handle fallback, got %v", defaultProv.sentPhones)
}
}
@@ -0,0 +1,63 @@
package auth
import (
"errors"
"testing"
"hfb_sys/backend/internal/database"
"hfb_sys/backend/internal/model"
)
func TestUserTokenVersionRevokesAccessAndRefreshTokens(t *testing.T) {
db := database.NewTestDB()
user := model.User{Phone: "13900000001", Status: "active", TokenVersion: 1}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("创建用户失败:%v", err)
}
repo := NewUserRepository(db)
manager := NewJWTManager("test-secret")
pair, err := manager.GenerateSubjectPairWithVersion(user.ID, user.Phone, "user", 1)
if err != nil {
t.Fatalf("生成令牌失败:%v", err)
}
_, err = repo.FindActiveForToken(t.Context(), user.ID, 1)
if err != nil {
t.Fatalf("有效令牌校验失败:%v", err)
}
if err := db.Model(&model.User{}).Where("id = ?", user.ID).Updates(map[string]any{
"status": "frozen",
"token_version": 1,
}).Error; err != nil {
t.Fatalf("冻结用户失败:%v", err)
}
_, err = repo.FindActiveForToken(t.Context(), user.ID, 0)
if !errors.Is(err, ErrUserDisabled) {
t.Fatalf("冻结用户校验错误 = %v, want %v", err, ErrUserDisabled)
}
service := NewService(repo, nil, manager, nil, nil)
_, err = service.RefreshToken(t.Context(), pair.RefreshToken)
if !errors.Is(err, ErrUserDisabled) {
t.Fatalf("冻结用户刷新错误 = %v, want %v", err, ErrUserDisabled)
}
// 解冻后版本仍然不同,冻结前签发的 token 不能恢复使用。
if err := db.Model(&model.User{}).Where("id = ?", user.ID).Updates(map[string]any{
"status": "active",
"token_version": 2,
}).Error; err != nil {
t.Fatalf("解冻用户失败:%v", err)
}
_, err = repo.FindActiveForToken(t.Context(), user.ID, 0)
if !errors.Is(err, ErrTokenVersionMismatch) {
t.Fatalf("旧令牌版本校验错误 = %v, want %v", err, ErrTokenVersionMismatch)
}
_, err = repo.FindActiveForToken(t.Context(), user.ID, 2)
if err != nil {
t.Fatalf("新令牌版本校验失败:%v", err)
}
}