fix: P0-07 prevent login bypassing TOTP verification

- Add RequiresTOTP, TempToken, UserID fields to LoginResponse
- Add isTOTPRequiredForLogin() to check if TOTP is needed after password
- Add VerifyTOTPAfterPasswordLogin() for completing login with TOTP
- Login() now checks if TOTP is required after password verification

When user has TOTP enabled and device is not trusted:
- Login returns {requires_totp: true, user_id: <id>} instead of token
- Frontend should prompt for TOTP code
- Frontend calls VerifyTOTPAfterPasswordLogin to complete login

Note: Frontend changes are required to handle the new login flow.
The TempToken field is reserved for future use.
This commit is contained in:
2026-04-18 14:19:15 +08:00
parent ca7ba5ccdf
commit 4acd19f420

View File

@@ -117,10 +117,16 @@ type UserInfo struct {
}
type LoginResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int64 `json:"expires_in"`
User *UserInfo `json:"user"`
AccessToken string `json:"access_token,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
ExpiresIn int64 `json:"expires_in,omitempty"`
User *UserInfo `json:"user,omitempty"`
// RequiresTOTP 指示登录需要额外的TOTP验证当设备未信任时
RequiresTOTP bool `json:"requires_totp,omitempty"`
// TempToken 临时令牌用于TOTP验证阶段短生命周期不可用于常规API
TempToken string `json:"temp_token,omitempty"`
// UserID 当RequiresTOTP为true时返回用于后续TOTP验证
UserID int64 `json:"user_id,omitempty"`
}
type LogoutRequest struct {
@@ -751,6 +757,16 @@ func (s *AuthService) Login(ctx context.Context, req *LoginRequest, ip string) (
_ = s.cache.Delete(ctx, attemptKey)
}
// P0-07 安全修复检查是否需要TOTP验证用户启用了TOTP且设备未信任
if s.isTOTPRequiredForLogin(ctx, user, req.DeviceID) {
// 返回RequiresTOTP指示前端需要完成TOTP验证
// 前端应调用 /auth/login/totp-verify 接口完成验证
return &LoginResponse{
RequiresTOTP: true,
UserID: user.ID,
}, nil
}
s.bestEffortUpdateLastLogin(ctx, user.ID, ip, "password")
s.cacheUserInfo(ctx, user)
s.writeLoginLog(ctx, &user.ID, domain.LoginTypePassword, ip, true, "")
@@ -766,6 +782,55 @@ func (s *AuthService) Login(ctx context.Context, req *LoginRequest, ip string) (
return s.generateLoginResponse(ctx, user, req.Remember)
}
// isTOTPRequiredForLogin 检查登录是否需要TOTP验证
// 条件用户启用了TOTP且尝试登录的设备未信任
func (s *AuthService) isTOTPRequiredForLogin(ctx context.Context, user *domain.User, deviceID string) bool {
if user == nil {
return false
}
// 检查用户是否启用了TOTP
if !user.TOTPEnabled || strings.TrimSpace(user.TOTPSecret) == "" {
return false
}
// 检查设备是否已信任
if deviceID != "" && s.deviceService != nil {
device, err := s.deviceService.GetDeviceByDeviceID(ctx, user.ID, deviceID)
if err == nil && device.IsTrusted {
// 设备已信任,检查信任是否过期
if device.TrustExpiresAt == nil || device.TrustExpiresAt.After(time.Now()) {
return false // 设备已信任且未过期不需要TOTP
}
}
}
return true // 需要TOTP验证
}
// VerifyTOTPAfterPasswordLogin 完成密码登录后的TOTP验证
// 当用户启用了TOTP但设备未信任时密码登录会返回RequiresTOTP=true
// 前端需要调用此接口完成TOTP验证以获取令牌
func (s *AuthService) VerifyTOTPAfterPasswordLogin(ctx context.Context, userID int64, totpCode, deviceID string) (*LoginResponse, error) {
if s == nil {
return nil, errors.New("auth service is not initialized")
}
user, err := s.userRepo.GetByID(ctx, userID)
if err != nil {
return nil, errors.New("用户不存在")
}
if err := s.ensureUserActive(user); err != nil {
return nil, err
}
// 验证TOTP
if err := s.VerifyTOTP(ctx, userID, totpCode, deviceID); err != nil {
return nil, err
}
// TOTP验证成功返回完整登录响应
return s.generateLoginResponseWithoutRemember(ctx, user)
}
func (s *AuthService) RefreshToken(ctx context.Context, refreshToken string) (*LoginResponse, error) {
if s == nil || s.jwtManager == nil || s.userRepo == nil {
return nil, errors.New("auth service is not fully configured")