package api import ( "bytes" "encoding/json" "errors" "net/http" "strconv" "time" "github.com/gin-gonic/gin" "oci-portal/internal/model" "oci-portal/internal/service" ) // passkeyHandler 处理通行密钥(WebAuthn)注册、登录与凭据管理接口。 type passkeyHandler struct { svc *service.PasskeyService auth *service.AuthService logs *service.SystemLogService } // registerBegin 生成注册 options;sessionId 需原样带回 finish。 // // @Summary 发起通行密钥注册 // @Tags 认证 // @Success 200 {object} passkeyOptionsResponse "sessionId 与 WebAuthn options" // @Failure 409 {object} errorResponse "面板地址未设置或数量达上限" // @Security BearerAuth // @Router /api/v1/auth/passkey/register/begin [post] func (h *passkeyHandler) registerBegin(c *gin.Context) { sid, opts, err := h.svc.BeginRegister(c.Request.Context(), c.GetString(usernameKey)) if err != nil { if errors.Is(err, service.ErrPasskeyNoAppURL) || errors.Is(err, service.ErrPasskeyLimit) { c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) return } respondError(c, err) return } c.JSON(http.StatusOK, gin.H{"sessionId": sid, "options": opts}) } // passkeyFinishRequest 是注册/登录 finish 的请求体;credential 为浏览器 // navigator.credentials 返回的 WebAuthn JSON,原样透传后端解析。 type passkeyFinishRequest struct { SessionId string `json:"sessionId" binding:"required,max=64"` Name string `json:"name" binding:"max=64"` Credential json.RawMessage `json:"credential" binding:"required"` } // registerFinish 校验注册响应并保存凭据;属敏感变更,换发新令牌。 // // @Summary 完成通行密钥注册 // @Tags 认证 // @Param body body passkeyFinishRequest true "sessionId、名称与凭据响应" // @Success 200 {object} tokenResponse "已注册,返回换发的新 token" // @Success 204 "已注册但新 token 签发失败,需重新登录" // @Failure 400 {object} errorResponse "请求体非法、会话过期或凭据校验失败" // @Security BearerAuth // @Router /api/v1/auth/passkey/register/finish [post] func (h *passkeyHandler) registerFinish(c *gin.Context) { var req passkeyFinishRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } err := h.svc.FinishRegister(c.Request.Context(), c.GetString(usernameKey), req.SessionId, req.Name, bytes.NewReader(req.Credential), tokenProofOf(c)) if err != nil { if errors.Is(err, service.ErrPasskeySession) || errors.Is(err, service.ErrPasskeyVerify) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } respondError(c, err) return } respondFreshToken(c, h.auth) } // list 列出当前账号的通行密钥。 // // @Summary 通行密钥列表 // @Tags 认证 // @Success 200 {object} itemsResponse[model.UserPasskey] "items" // @Security BearerAuth // @Router /api/v1/auth/passkeys [get] func (h *passkeyHandler) list(c *gin.Context) { items, err := h.svc.List(c.Request.Context(), c.GetString(usernameKey)) if err != nil { respondError(c, err) return } c.JSON(http.StatusOK, gin.H{"items": items}) } // remove 删除通行密钥;属敏感变更,换发新令牌。 // 密码登录禁用期间,最后一个免密登录方式不可删除(防自锁)。 // // @Summary 删除通行密钥 // @Tags 认证 // @Param id path int true "凭据 ID" // @Success 200 {object} tokenResponse "已删除,返回换发的新 token" // @Success 204 "已删除但新 token 签发失败,需重新登录" // @Failure 409 {object} errorResponse "密码登录已禁用且这是最后一个免密登录方式" // @Security BearerAuth // @Router /api/v1/auth/passkeys/{id} [delete] func (h *passkeyHandler) remove(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } if err := h.svc.Remove(c.Request.Context(), c.GetString(usernameKey), uint(id), tokenProofOf(c)); err != nil { if errors.Is(err, service.ErrLastIdentity) { c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) return } respondError(c, err) return } respondFreshToken(c, h.auth) } // loginBegin 生成登录断言 options(公开;无凭据也正常下发,不泄露账号状态)。 // // @Summary 发起通行密钥登录 // @Tags 认证 // @Success 200 {object} passkeyOptionsResponse "sessionId 与 WebAuthn options" // @Failure 409 {object} errorResponse "面板地址未设置" // @Router /api/v1/auth/passkey/login/begin [post] func (h *passkeyHandler) loginBegin(c *gin.Context) { sid, opts, err := h.svc.BeginLogin(c.Request.Context()) if err != nil { if errors.Is(err, service.ErrPasskeyNoAppURL) { c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) return } respondError(c, err) return } c.JSON(http.StatusOK, gin.H{"sessionId": sid, "options": opts}) } // loginFinish 校验断言并签发 JWT;凭 UV 豁免 TOTP。 // // @Summary 完成通行密钥登录 // @Tags 认证 // @Param body body passkeyFinishRequest true "sessionId 与断言响应(name 忽略)" // @Success 200 {object} tokenResponse "token 与 expiresAt" // @Failure 400 {object} errorResponse "请求体非法" // @Failure 401 {object} errorResponse "校验失败" // @Failure 429 {object} errorResponse "连续失败已锁定" // @Router /api/v1/auth/passkey/login/finish [post] func (h *passkeyHandler) loginFinish(c *gin.Context) { var req passkeyFinishRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } start := time.Now() token, expires, username, err := h.svc.FinishLogin( c.Request.Context(), req.SessionId, sessionMetaOf(c), bytes.NewReader(req.Credential)) if errors.Is(err, service.ErrLoginLocked) { h.recordLogin(c, username, http.StatusTooManyRequests, "连续失败已锁定", start) c.JSON(http.StatusTooManyRequests, gin.H{"error": err.Error()}) return } if err != nil { // 统一 401 文案,不区分会话过期/校验失败,防探测 h.recordLogin(c, username, http.StatusUnauthorized, service.ErrPasskeyVerify.Error(), start) c.JSON(http.StatusUnauthorized, gin.H{"error": service.ErrPasskeyVerify.Error()}) return } h.recordLogin(c, username, http.StatusOK, "", start) c.JSON(http.StatusOK, gin.H{"token": token, "expiresAt": expires}) } // recordLogin 记录通行密钥登录成败——公开端点不经系统日志中间件, // 登录失败是安全关键事件,必须留痕;失败时账号未知,username 为空。 func (h *passkeyHandler) recordLogin(c *gin.Context, username string, status int, errMsg string, start time.Time) { if h.logs == nil { return } 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, }) }