@@ -0,0 +1,196 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user