197 lines
7.0 KiB
Go
197 lines
7.0 KiB
Go
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,
|
|
})
|
|
}
|