BodySense 认证完整流程

BodySense 项目的完整认证流程:注册、登录、Token 刷新、注销、密码重置、邮箱验证的全链路设计。

#type / concept #status / growing #tech / dev / backend

[!info] related notes

BodySense 认证完整流程

认证流程总览

┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│   注册       │ ──► │   登录       │ ──► │   使用       │
│             │     │             │     │             │
│ 1. 验证邮箱  │     │ 1. 验证密码  │     │ 1. 带 Token  │
│ 2. 哈希密码  │     │ 2. 生成双Token│     │ 2. 中间件验证│
│ 3. 存储用户  │     │ 3. 返回响应  │     │ 3. 注入上下文│
└─────────────┘     └─────────────┘     └─────────────┘


                    ┌─────────────┐     ┌─────────────┐
                    │   刷新       │ ──► │   注销       │
                    │             │     │             │
                    │ 1. 验证 RT  │     │ 1. 加黑名单  │
                    │ 2. 生成新AT │     │ 2. 清除 RT   │
                    │ 3. 返回响应  │     │             │
                    └─────────────┘     └─────────────┘

1. 注册流程

完整代码

// handler/auth.go
func (h *AuthHandler) Register(c *gin.Context) {
    var req RegisterRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(400, gin.H{"error": "invalid request"})
        return
    }

    // 1. 检查邮箱是否已注册
    existing, _ := h.userRepo.GetByEmail(c.Request.Context(), req.Email)
    if existing != nil {
        c.JSON(409, gin.H{"error": "email already registered"})
        return
    }

    // 2. 密码强度验证
    if err := validatePassword(req.Password); err != nil {
        c.JSON(400, gin.H{"error": err.Error()})
        return
    }

    // 3. 哈希密码(bcrypt, cost=12)
    hashedPassword, err := bcrypt.GenerateFromPassword(
        []byte(req.Password),
        12,  // cost 因子,越高越慢,推荐 10-12
    )
    if err != nil {
        c.JSON(500, gin.H{"error": "internal error"})
        return
    }

    // 4. 创建用户
    user := &User{
        ID:           uuid.New(),
        Email:        req.Email,
        PasswordHash: string(hashedPassword),
        Name:         req.Name,
    }
    if err := h.userRepo.Create(c.Request.Context(), user); err != nil {
        c.JSON(500, gin.H{"error": "internal error"})
        return
    }

    // 5. 生成 Token 对
    tokens, err := h.generateTokenPair(user)
    if err != nil {
        c.JSON(500, gin.H{"error": "internal error"})
        return
    }

    // 6. 存储 Refresh Token 到数据库
    if err := h.saveRefreshToken(user.ID, tokens.RefreshToken); err != nil {
        c.JSON(500, gin.H{"error": "internal error"})
        return
    }

    c.JSON(201, gin.H{
        "user":         user.ToResponse(),
        "access_token": tokens.AccessToken,
        "expires_in":   3600 * 24 * 7,  // 7 天
    })
}

密码强度验证

func validatePassword(password string) error {
    if len(password) < 8 {
        return errors.New("password must be at least 8 characters")
    }
    if len(password) > 72 {  // bcrypt 限制
        return errors.New("password must be at most 72 characters")
    }

    hasUpper := false
    hasLower := false
    hasDigit := false
    for _, c := range password {
        switch {
        case unicode.IsUpper(c):
            hasUpper = true
        case unicode.IsLower(c):
            hasLower = true
        case unicode.IsDigit(c):
            hasDigit = true
        }
    }

    if !hasUpper || !hasLower || !hasDigit {
        return errors.New("password must contain uppercase, lowercase, and digit")
    }
    return nil
}

密码哈希为什么用 bcrypt

算法特点适用场景
MD5快,已破解❌ 不推荐
SHA256快,无盐❌ 不推荐
bcrypt慢,有盐,可调 cost✅ 推荐
argon2更慢,内存密集✅ 更安全

bcrypt 的优势

  1. 内置盐:每个哈希自带随机盐,相同密码产生不同哈希
  2. 可调 cost:随硬件升级增大 cost,保持安全性
  3. 72 字节限制:强制密码长度上限,防止 DoS

2. 登录流程

func (h *AuthHandler) Login(c *gin.Context) {
    var req LoginRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(400, gin.H{"error": "invalid request"})
        return
    }

    // 1. 查找用户
    user, err := h.userRepo.GetByEmail(c.Request.Context(), req.Email)
    if err != nil || user == nil {
        // 统一错误信息,防止邮箱枚举
        c.JSON(401, gin.H{"error": "invalid email or password"})
        return
    }

    // 2. 验证密码
    if err := bcrypt.CompareHashAndPassword(
        []byte(user.PasswordHash),
        []byte(req.Password),
    ); err != nil {
        // 密码错误,记录失败次数(防暴力破解)
        h.recordLoginFailure(user.ID)
        c.JSON(401, gin.H{"error": "invalid email or password"})
        return
    }

    // 3. 检查账户是否被锁定
    if h.isAccountLocked(user.ID) {
        c.JSON(423, gin.H{"error": "account locked, try again later"})
        return
    }

    // 4. 生成 Token 对
    tokens, err := h.generateTokenPair(user)
    if err != nil {
        c.JSON(500, gin.H{"error": "internal error"})
        return
    }

    // 5. 存储 Refresh Token
    if err := h.saveRefreshToken(user.ID, tokens.RefreshToken); err != nil {
        c.JSON(500, gin.H{"error": "internal error"})
        return
    }

    // 6. 更新最后登录时间
    h.userRepo.UpdateLastLogin(c.Request.Context(), user.ID)

    // 7. 清除登录失败计数
    h.clearLoginFailures(user.ID)

    c.JSON(200, gin.H{
        "user":         user.ToResponse(),
        "access_token": tokens.AccessToken,
        "expires_in":   3600 * 24 * 7,
    })
}

防暴力破解

const (
    maxLoginAttempts = 5
    lockoutDuration  = 15 * time.Minute
)

func (h *AuthHandler) recordLoginFailure(userID uuid.UUID) {
    key := fmt.Sprintf("login_failures:%s", userID.String())
    count, _ := h.redis.Incr(context.Background(), key).Result()
    if count == 1 {
        h.redis.Expire(context.Background(), key, lockoutDuration)
    }
}

func (h *AuthHandler) isAccountLocked(userID uuid.UUID) bool {
    key := fmt.Sprintf("login_failures:%s", userID.String())
    count, _ := h.redis.Get(context.Background(), key).Int64()
    return count >= maxLoginAttempts
}

func (h *AuthHandler) clearLoginFailures(userID uuid.UUID) {
    key := fmt.Sprintf("login_failures:%s", userID.String())
    h.redis.Del(context.Background(), key)
}

3. Token 刷新流程

func (h *AuthHandler) RefreshToken(c *gin.Context) {
    var req RefreshRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(400, gin.H{"error": "invalid request"})
        return
    }

    // 1. 验证 Refresh Token
    claims, err := auth.ValidateRefreshToken(h.jwtConfig, req.RefreshToken)
    if err != nil {
        c.JSON(401, gin.H{"error": "invalid refresh token"})
        return
    }

    // 2. 检查 Refresh Token 是否在数据库中(未被注销)
    stored, err := h.refreshTokenRepo.GetByUserID(c.Request.Context(), claims.UserID)
    if err != nil || stored.Token != req.RefreshToken {
        // 可能是被盗用的旧 Token,注销该用户所有 Token
        h.refreshTokenRepo.DeleteByUserID(c.Request.Context(), claims.UserID)
        c.JSON(401, gin.H{"error": "invalid refresh token"})
        return
    }

    // 3. 检查 Refresh Token 是否过期
    if stored.ExpiresAt.Before(time.Now()) {
        h.refreshTokenRepo.Delete(c.Request.Context(), stored.ID)
        c.JSON(401, gin.H{"error": "refresh token expired"})
        return
    }

    // 4. 获取用户信息
    user, err := h.userRepo.GetByID(c.Request.Context(), claims.UserID)
    if err != nil || user == nil {
        c.JSON(401, gin.H{"error": "user not found"})
        return
    }

    // 5. 生成新的 Token 对
    tokens, err := h.generateTokenPair(user)
    if err != nil {
        c.JSON(500, gin.H{"error": "internal error"})
        return
    }

    // 6. 更新 Refresh Token(旋转)
    h.refreshTokenRepo.Update(c.Request.Context(), stored.ID, tokens.RefreshToken)

    c.JSON(200, gin.H{
        "access_token": tokens.AccessToken,
        "expires_in":   3600 * 24 * 7,
    })
}

Refresh Token 旋转

为什么要旋转:每次刷新都生成新的 Refresh Token,旧的立即失效。这样即使旧 Token 被盗,也只能用一次。

RT1 (旧) → 刷新 → RT2 (新) + AT
RT1 立即失效
RT2 → 刷新 → RT3 (新) + AT
RT2 立即失效

4. 注销流程

func (h *AuthHandler) Logout(c *gin.Context) {
    // 1. 从 Context 获取当前 Access Token
    tokenString := c.GetString("access_token")

    // 2. 将 Access Token 加入黑名单(Redis)
    // TTL = Token 剩余有效期
    claims, _ := auth.Validate(h.jwtConfig, tokenString)
    ttl := time.Until(time.Unix(claims.ExpiresAt.Unix(), 0))
    if ttl > 0 {
        h.redis.Set(
            context.Background(),
            "token_blacklist:"+tokenString,
            "1",
            ttl,
        )
    }

    // 3. 删除 Refresh Token
    userID := c.GetString("user_id")
    uid, _ := uuid.Parse(userID)
    h.refreshTokenRepo.DeleteByUserID(c.Request.Context(), uid)

    c.JSON(200, gin.H{"message": "logged out"})
}

Token 黑名单

// 中间件中检查黑名单
func AuthMiddleware(jwtConfig JWTConfig, redis *redis.Client) gin.HandlerFunc {
    return func(c *gin.Context) {
        tokenString := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")

        // 检查黑名单
        exists, _ := redis.Exists(c.Request.Context(), "token_blacklist:"+tokenString).Result()
        if exists > 0 {
            c.JSON(401, gin.H{"error": "token revoked"})
            c.Abort()
            return
        }

        // 验证 Token
        claims, err := auth.Validate(jwtConfig, tokenString)
        if err != nil {
            c.JSON(401, gin.H{"error": "invalid token"})
            c.Abort()
            return
        }

        c.Set("user_id", claims.UserID.String())
        c.Set("access_token", tokenString)  // 保存用于注销
        c.Next()
    }
}

5. 密码重置流程

用户请求重置 → 生成重置 Token → 发送邮件 → 用户点击链接 → 验证 Token → 更新密码
// 1. 请求重置
func (h *AuthHandler) RequestPasswordReset(c *gin.Context) {
    var req struct {
        Email string `json:"email" binding:"required,email"`
    }
    c.ShouldBindJSON(&req)

    // 查找用户(不存在也返回成功,防止邮箱枚举)
    user, _ := h.userRepo.GetByEmail(c.Request.Context(), req.Email)
    if user == nil {
        c.JSON(200, gin.H{"message": "if email exists, reset link sent"})
        return
    }

    // 生成重置 Token
    resetToken := uuid.New().String()
    h.redis.Set(
        c.Request.Context(),
        "password_reset:"+resetToken,
        user.ID.String(),
        1*time.Hour,  // 1 小时过期
    )

    // 发送邮件(异步)
    go h.emailService.SendPasswordReset(req.Email, resetToken)

    c.JSON(200, gin.H{"message": "if email exists, reset link sent"})
}

// 2. 执行重置
func (h *AuthHandler) ResetPassword(c *gin.Context) {
    var req struct {
        Token    string `json:"token" binding:"required"`
        Password string `json:"password" binding:"required"`
    }
    c.ShouldBindJSON(&req)

    // 验证 Token
    userID, err := h.redis.Get(c.Request.Context(), "password_reset:"+req.Token).Result()
    if err != nil {
        c.JSON(400, gin.H{"error": "invalid or expired token"})
        return
    }

    // 验证密码强度
    if err := validatePassword(req.Password); err != nil {
        c.JSON(400, gin.H{"error": err.Error()})
        return
    }

    // 哈希新密码
    hashedPassword, _ := bcrypt.GenerateFromPassword([]byte(req.Password), 12)

    // 更新密码
    uid, _ := uuid.Parse(userID)
    h.userRepo.UpdatePassword(c.Request.Context(), uid, string(hashedPassword))

    // 删除重置 Token
    h.redis.Del(c.Request.Context(), "password_reset:"+req.Token)

    // 注销该用户所有 Refresh Token(强制重新登录)
    h.refreshTokenRepo.DeleteByUserID(c.Request.Context(), uid)

    c.JSON(200, gin.H{"message": "password reset successfully"})
}

6. Token 生成

type TokenPair struct {
    AccessToken  string
    RefreshToken string
    ExpiresIn    int64
}

func (h *AuthHandler) generateTokenPair(user *User) (*TokenPair, error) {
    now := time.Now()

    // Access Token (7 天)
    accessClaims := &auth.Claims{
        UserID: user.ID,
        Email:  user.Email,
        RegisteredClaims: jwt.RegisteredClaims{
            ExpiresAt: jwt.NewNumericDate(now.Add(7 * 24 * time.Hour)),
            IssuedAt:  jwt.NewNumericDate(now),
            Subject:   user.ID.String(),
        },
    }
    accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims)
    accessTokenString, err := accessToken.SignedString([]byte(h.jwtConfig.SecretKey))
    if err != nil {
        return nil, err
    }

    // Refresh Token (30 天)
    refreshClaims := &auth.RefreshClaims{
        UserID: user.ID,
        RegisteredClaims: jwt.RegisteredClaims{
            ExpiresAt: jwt.NewNumericDate(now.Add(30 * 24 * time.Hour)),
            IssuedAt:  jwt.NewNumericDate(now),
            Subject:   user.ID.String(),
        },
    }
    refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims)
    refreshTokenString, err := refreshToken.SignedString([]byte(h.jwtConfig.RefreshSecretKey))
    if err != nil {
        return nil, err
    }

    return &TokenPair{
        AccessToken:  accessTokenString,
        RefreshToken: refreshTokenString,
        ExpiresIn:    3600 * 24 * 7,
    }, nil
}

前端配合

Token 存储

// stores/auth.ts
const useAuthStore = create<AuthState>()(
  persist(
    (set, get) => ({
      accessToken: null,
      refreshToken: null,

      login: async (email, password) => {
        const res = await api.post('/auth/login', { email, password });
        set({
          accessToken: res.data.access_token,
          refreshToken: res.data.refresh_token,
        });
      },

      refreshAccessToken: async () => {
        const { refreshToken } = get();
        if (!refreshToken) return false;

        try {
          const res = await api.post('/auth/refresh', {
            refresh_token: refreshToken,
          });
          set({ accessToken: res.data.access_token });
          return true;
        } catch {
          get().logout();
          return false;
        }
      },

      logout: () => {
        api.post('/auth/logout').catch(() => {});
        set({ accessToken: null, refreshToken: null });
      },
    }),
    {
      name: 'auth-storage',
      partialize: (state) => ({
        accessToken: state.accessToken,
        refreshToken: state.refreshToken,
      }),
    },
  ),
);

Axios 拦截器

// api/client.ts
api.interceptors.request.use((config) => {
  const token = useAuthStore.getState().accessToken;
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

api.interceptors.response.use(
  (res) => res,
  async (error) => {
    const originalRequest = error.config;

    // 401 且不是刷新请求
    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;

      const refreshed = await useAuthStore.getState().refreshAccessToken();
      if (refreshed) {
        originalRequest.headers.Authorization =
          `Bearer ${useAuthStore.getState().accessToken}`;
        return api(originalRequest);
      }
    }

    return Promise.reject(error);
  },
);

安全检查清单

检查项状态说明
密码 bcrypt 哈希cost=12
密码强度验证8+ 字符,大小写+数字
防暴力破解5 次失败锁定 15 分钟
防邮箱枚举统一错误信息
Access Token 短期7 天
Refresh Token 旋转每次刷新生成新的
Token 黑名单Redis 存储,TTL 自动过期
HTTPS 必须Caddy 自动 HTTPS

常见面试问题

Q: 为什么用双 Token 而不是一个?

A:

  • Access Token 短期:泄露影响窗口小(7 天)
  • Refresh Token 长期:可以服务端注销,安全性更高
  • 分离关注点:Access Token 用于认证,Refresh Token 用于续期

Q: 密码忘了怎么找回?

A:

  1. 用户输入邮箱
  2. 生成重置 Token,存 Redis,发邮件
  3. 用户点击链接,输入新密码
  4. 验证 Token,更新密码,注销所有会话

Q: 怎么防止 Token 被盗用?

A:

  1. HTTPS:防止传输中被截获
  2. 短期 Access Token:泄露影响窗口小
  3. Refresh Token 旋转:旧 Token 立即失效
  4. Token 黑名单:注销时立即失效
  5. IP 绑定(可选):检测异常 IP

常见错误

错误信息泄露用户存在

// ❌ 不同错误返回不同信息
if user == nil {
    c.JSON(401, gin.H{"error": "user not found"})
} else {
    c.JSON(401, gin.H{"error": "wrong password"})
}

// ✅ 统一错误信息
c.JSON(401, gin.H{"error": "invalid email or password"})

忘记注销 Refresh Token

// ❌ 只注销 Access Token
func (h *AuthHandler) Logout(c *gin.Context) {
    // 黑名单 Access Token
    // ...
    // 忘记删除 Refresh Token!
}

// ✅ 两个都处理
func (h *AuthHandler) Logout(c *gin.Context) {
    // 黑名单 Access Token
    // ...
    // 删除 Refresh Token
    h.refreshTokenRepo.DeleteByUserID(c.Request.Context(), userID)
}

bcrypt cost 太低

// ❌ cost=4,太快,不安全
hash, _ := bcrypt.GenerateFromPassword([]byte(password), 4)

// ✅ cost=12,平衡安全和性能
hash, _ := bcrypt.GenerateFromPassword([]byte(password), 12)
创建于 2026/6/25 更新于 2026/6/25