fix: P0-07 complete frontend TOTP login flow

Backend changes:
- Add VerifyTOTPAfterPasswordLogin handler in auth_handler.go
- Add route /auth/login/totp-verify in router.go

Frontend changes:
- Update TokenBundle type to include requires_totp and user_id fields
- Add TOTPVerifyRequest type for TOTP verification
- Add verifyTOTPAfterPasswordLogin() API function

New login flow when user has TOTP enabled:
1. loginByPassword returns {requires_totp: true, user_id: <id>}
2. Frontend prompts user for TOTP code
3. Frontend calls verifyTOTPAfterPasswordLogin({user_id, code})
4. If TOTP valid, full TokenBundle with tokens is returned
This commit is contained in:
2026-04-18 14:50:25 +08:00
parent 4acd19f420
commit 9d7abb8a46
4 changed files with 52 additions and 0 deletions

View File

@@ -16,6 +16,7 @@ import type {
SendEmailCodeRequest,
SendSmsCodeRequest,
TokenBundle,
TOTPVerifyRequest,
ValidateResetTokenResponse,
} from '@/types'
@@ -40,6 +41,11 @@ export function loginByPassword(data: LoginByPasswordRequest): Promise<TokenBund
return post<TokenBundle>('/auth/login', data, { auth: false, credentials: 'include' })
}
// Verify TOTP after password login when requires_totp is returned
export function verifyTOTPAfterPasswordLogin(data: TOTPVerifyRequest): Promise<TokenBundle> {
return post<TokenBundle>('/auth/login/totp-verify', data, { auth: false, credentials: 'include' })
}
export function loginByEmailCode(data: LoginByEmailCodeRequest): Promise<TokenBundle> {
return post<TokenBundle>('/auth/login/email-code', data, { auth: false, credentials: 'include' })
}

View File

@@ -15,6 +15,16 @@ export interface TokenBundle {
refresh_token?: string
expires_in: number
user: SessionUser
// TOTP required response (when user has TOTP enabled but device is not trusted)
requires_totp?: boolean
user_id?: number
}
// TOTP verification request after password login
export interface TOTPVerifyRequest {
user_id: number
code: string
device_id?: string
}
export interface OAuthProviderInfo {

View File

@@ -132,6 +132,41 @@ func (h *AuthHandler) Login(c *gin.Context) {
})
}
// VerifyTOTPAfterPasswordLogin 完成密码登录后的TOTP验证
// @Summary TOTP验证密码登录后
// @Description 当登录返回requires_totp=true时使用此接口完成TOTP验证
// @Tags 认证
// @Accept json
// @Produce json
// @Param request body TOTPVerifyRequest true "TOTP验证请求"
// @Success 200 {object} Response{data=service.LoginResponse} "验证成功"
// @Failure 400 {object} Response "请求参数错误"
// @Failure 401 {object} Response "TOTP验证失败"
// @Router /api/v1/auth/login/totp-verify [post]
func (h *AuthHandler) VerifyTOTPAfterPasswordLogin(c *gin.Context) {
var req struct {
UserID int64 `json:"user_id" binding:"required"`
Code string `json:"code" binding:"required"`
DeviceID string `json:"device_id"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
return
}
resp, err := h.authService.VerifyTOTPAfterPasswordLogin(c.Request.Context(), req.UserID, req.Code, req.DeviceID)
if err != nil {
handleError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"code": 0,
"message": "success",
"data": resp,
})
}
// Logout 用户登出
// @Summary 用户登出
// @Description 使当前 access_token 和 refresh_token 失效

View File

@@ -137,6 +137,7 @@ func (r *Router) Setup() *gin.Engine {
authGroup.POST("/register", r.rateLimitMiddleware.Register(), r.authHandler.Register)
authGroup.POST("/bootstrap-admin", r.rateLimitMiddleware.Register(), r.authHandler.BootstrapAdmin)
authGroup.POST("/login", r.rateLimitMiddleware.Login(), r.authHandler.Login)
authGroup.POST("/login/totp-verify", r.rateLimitMiddleware.Login(), r.authHandler.VerifyTOTPAfterPasswordLogin)
authGroup.POST("/refresh", r.rateLimitMiddleware.Refresh(), r.authHandler.RefreshToken)
authGroup.GET("/capabilities", r.authHandler.GetAuthCapabilities)