package api import ( "errors" "net/http" "strings" "time" "github.com/gin-gonic/gin" "oci-portal/internal/model" "oci-portal/internal/service" ) type authHandler struct { svc *service.AuthService logs *service.SystemLogService } type loginRequest struct { // 字段长度上限防超长输入撑爆登录守卫与 bcrypt(S-03) Username string `json:"username" binding:"required,max=64"` Password string `json:"password" binding:"required,max=128"` // 两步验证码;账号已启用 TOTP 时必填,缺失返回 428 TotpCode string `json:"totpCode" binding:"max=8"` } // login 校验用户名密码(与可选 TOTP)后签发 JWT。 // // @Summary 登录 // @Tags 认证 // @Accept json // @Produce json // @Param body body loginRequest true "登录凭据" // @Success 200 {object} tokenResponse "token 与 expiresAt" // @Failure 401 {object} errorResponse "凭据错误" // @Failure 428 {object} totpRequiredResponse "需要两步验证码(totpRequired=true)" // @Router /api/v1/auth/login [post] func (h *authHandler) login(c *gin.Context) { var req loginRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } start := time.Now() token, expires, err := h.svc.Login(c.Request.Context(), req.Username, req.Password, requestIP(c), req.TotpCode) if errors.Is(err, service.ErrTotpRequired) { // 密码已通过,引导前端弹出二次验证输入;不算失败不留痕 c.JSON(http.StatusPreconditionRequired, gin.H{"error": err.Error(), "totpRequired": true}) return } if errors.Is(err, service.ErrPasswordLoginDisabled) { h.recordLogin(c, req.Username, http.StatusForbidden, start) c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) return } if errors.Is(err, service.ErrLoginLocked) { h.recordLogin(c, req.Username, http.StatusTooManyRequests, start) c.JSON(http.StatusTooManyRequests, gin.H{"error": err.Error()}) return } if errors.Is(err, service.ErrInvalidCredentials) { h.recordLogin(c, req.Username, http.StatusUnauthorized, start) c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) return } if err != nil { respondError(c, err) return } h.recordLogin(c, req.Username, http.StatusOK, start) c.JSON(http.StatusOK, gin.H{"token": token, "expiresAt": expires}) } // logout 把当前令牌拉黑至自然过期(secured 组内,写请求由系统日志中间件留痕)。 // // @Summary 登出 // @Tags 认证 // @Success 204 "令牌已吊销" // @Security BearerAuth // @Router /api/v1/auth/logout [post] func (h *authHandler) logout(c *gin.Context) { token, ok := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer ") if ok && token != "" { h.svc.Logout(token) } c.Status(http.StatusNoContent) } // recordLogin 显式记录一次登录成败——登录不在 secured 组内, // 系统日志中间件覆盖不到;登录失败是安全关键事件,必须留痕。 func (h *authHandler) recordLogin(c *gin.Context, username string, status int, start time.Time) { errMsg := "" if status == http.StatusUnauthorized { errMsg = "用户名、密码或验证码错误" } else if status == http.StatusTooManyRequests { errMsg = "连续失败已锁定" } else if status == http.StatusForbidden { errMsg = "密码登录已禁用" } h.logs.Record(model.SystemLog{ Username: username, Method: http.MethodPost, Path: requestPath(c), Status: status, DurationMs: time.Since(start).Milliseconds(), ClientIP: requestIP(c), UserAgent: truncateLogField(c.Request.UserAgent(), 256), ErrMsg: errMsg, }) }