chore(init): 初始化解忧商行项目并完成 Phase 0 基座
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# Auth Module
|
||||
|
||||
手机号短信登录、JWT、验证码限流。
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user