From 9d7abb8a4649f1060cdc53a466ca9c86c7bd4e3b Mon Sep 17 00:00:00 2001 From: long-agent Date: Sat, 18 Apr 2026 14:50:25 +0800 Subject: [PATCH] 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: } 2. Frontend prompts user for TOTP code 3. Frontend calls verifyTOTPAfterPasswordLogin({user_id, code}) 4. If TOTP valid, full TokenBundle with tokens is returned --- frontend/admin/src/services/auth.ts | 6 +++++ frontend/admin/src/types/auth.ts | 10 ++++++++ internal/api/handler/auth_handler.go | 35 ++++++++++++++++++++++++++++ internal/api/router/router.go | 1 + 4 files changed, 52 insertions(+) diff --git a/frontend/admin/src/services/auth.ts b/frontend/admin/src/services/auth.ts index e264db4..0c5576e 100644 --- a/frontend/admin/src/services/auth.ts +++ b/frontend/admin/src/services/auth.ts @@ -16,6 +16,7 @@ import type { SendEmailCodeRequest, SendSmsCodeRequest, TokenBundle, + TOTPVerifyRequest, ValidateResetTokenResponse, } from '@/types' @@ -40,6 +41,11 @@ export function loginByPassword(data: LoginByPasswordRequest): Promise('/auth/login', data, { auth: false, credentials: 'include' }) } +// Verify TOTP after password login when requires_totp is returned +export function verifyTOTPAfterPasswordLogin(data: TOTPVerifyRequest): Promise { + return post('/auth/login/totp-verify', data, { auth: false, credentials: 'include' }) +} + export function loginByEmailCode(data: LoginByEmailCodeRequest): Promise { return post('/auth/login/email-code', data, { auth: false, credentials: 'include' }) } diff --git a/frontend/admin/src/types/auth.ts b/frontend/admin/src/types/auth.ts index e6fe105..4efc552 100644 --- a/frontend/admin/src/types/auth.ts +++ b/frontend/admin/src/types/auth.ts @@ -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 { diff --git a/internal/api/handler/auth_handler.go b/internal/api/handler/auth_handler.go index 053c1a0..022eb5c 100644 --- a/internal/api/handler/auth_handler.go +++ b/internal/api/handler/auth_handler.go @@ -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 失效 diff --git a/internal/api/router/router.go b/internal/api/router/router.go index 08d0b18..9ffc5f3 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -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)