BodySense 认证完整流程
BodySense 项目的完整认证流程:注册、登录、Token 刷新、注销、密码重置、邮箱验证的全链路设计。
#type / concept
#status / growing
#tech / dev / backend
[!info] related notes
- 知识地图: BodySense 项目 MOC
- JWT: JWT 认证中间件
- Redis: Redis 缓存实践
- 安全: Red Flag 检测
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 的优势:
- 内置盐:每个哈希自带随机盐,相同密码产生不同哈希
- 可调 cost:随硬件升级增大 cost,保持安全性
- 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:
- 用户输入邮箱
- 生成重置 Token,存 Redis,发邮件
- 用户点击链接,输入新密码
- 验证 Token,更新密码,注销所有会话
Q: 怎么防止 Token 被盗用?
A:
- HTTPS:防止传输中被截获
- 短期 Access Token:泄露影响窗口小
- Refresh Token 旋转:旧 Token 立即失效
- Token 黑名单:注销时立即失效
- 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)