Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type PasskeyHandler struct {
|
||||
passkeys *service.PasskeyService
|
||||
authService *service.AuthService
|
||||
settingSvc *service.SettingService
|
||||
}
|
||||
|
||||
func NewPasskeyHandler(
|
||||
passkeys *service.PasskeyService,
|
||||
authService *service.AuthService,
|
||||
settingService *service.SettingService,
|
||||
) *PasskeyHandler {
|
||||
return &PasskeyHandler{
|
||||
passkeys: passkeys,
|
||||
authService: authService,
|
||||
settingSvc: settingService,
|
||||
}
|
||||
}
|
||||
|
||||
type passkeyOptionsResponse struct {
|
||||
SessionToken string `json:"session_token"`
|
||||
Options any `json:"options"`
|
||||
}
|
||||
|
||||
type passkeyFinishRequest struct {
|
||||
SessionToken string `json:"session_token" binding:"required"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Credential json.RawMessage `json:"credential" binding:"required"`
|
||||
}
|
||||
|
||||
type passkeyBeginLoginRequest struct {
|
||||
// TurnstileToken 承载阿里云验证码的 captchaVerifyParam(复用既有请求字段名)
|
||||
TurnstileToken string `json:"turnstile_token"`
|
||||
TencentCaptchaTicket string `json:"tencent_captcha_ticket"`
|
||||
TencentCaptchaRandstr string `json:"tencent_captcha_randstr"`
|
||||
}
|
||||
|
||||
type passkeyRenameRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
}
|
||||
|
||||
// passkeyPasswordRequest carries the account password that gates passkey
|
||||
// enrollment and revocation. Binding errors are tolerated: a missing or
|
||||
// malformed body yields an empty password, which the service rejects with a
|
||||
// precise PASSWORD_REQUIRED error.
|
||||
type passkeyPasswordRequest struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func bindPasskeyPassword(c *gin.Context) string {
|
||||
var req passkeyPasswordRequest
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
return req.Password
|
||||
}
|
||||
|
||||
const passkeyFinishBodyMaxBytes = 64 * 1024
|
||||
|
||||
// BeginLogin starts a usernameless, discoverable-credential login ceremony.
|
||||
func (h *PasskeyHandler) BeginLogin(c *gin.Context) {
|
||||
if !h.requirePasskeysEnabled(c) {
|
||||
return
|
||||
}
|
||||
var req passkeyBeginLoginRequest
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
if err := h.authService.VerifyActionCaptchaIfEnabled(c.Request.Context(), service.CaptchaProof{
|
||||
TurnstileToken: req.TurnstileToken,
|
||||
TencentTicket: req.TencentCaptchaTicket,
|
||||
TencentRandstr: req.TencentCaptchaRandstr,
|
||||
}, ip.GetClientIP(c)); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
assertion, token, err := h.passkeys.BeginLogin(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, passkeyOptionsResponse{SessionToken: token, Options: assertion})
|
||||
}
|
||||
|
||||
// FinishLogin validates a passkey assertion and creates a normal Sub2API token
|
||||
// session. User verification is mandatory, so a successful passkey assertion
|
||||
// already supplies phishing-resistant multi-factor authentication and does not
|
||||
// enter the separate TOTP challenge flow.
|
||||
func (h *PasskeyHandler) FinishLogin(c *gin.Context) {
|
||||
if !h.requirePasskeysEnabled(c) {
|
||||
return
|
||||
}
|
||||
req, ok := bindPasskeyFinishRequest(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
credentialRequest := cloneRequestWithJSON(c.Request, req.Credential)
|
||||
user, err := h.passkeys.FinishLogin(c.Request.Context(), req.SessionToken, credentialRequest)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
if err = h.ensureBackendModeAllowsUser(c.Request.Context(), user); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
middleware2.SetAuditActor(c, user.ID, user.Email)
|
||||
c.Set("auth_method", service.AuditAuthMethodPasskey)
|
||||
h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID)
|
||||
respondWithTokenPair(c, h.authService, user)
|
||||
}
|
||||
|
||||
func (h *PasskeyHandler) BeginRegistration(c *gin.Context) {
|
||||
if !h.requirePasskeysEnabled(c) {
|
||||
return
|
||||
}
|
||||
subject, ok := middleware2.GetAuthSubjectFromContext(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "User not authenticated")
|
||||
return
|
||||
}
|
||||
creation, token, err := h.passkeys.BeginRegistration(c.Request.Context(), subject.UserID, bindPasskeyPassword(c))
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, passkeyOptionsResponse{SessionToken: token, Options: creation})
|
||||
}
|
||||
|
||||
func (h *PasskeyHandler) FinishRegistration(c *gin.Context) {
|
||||
if !h.requirePasskeysEnabled(c) {
|
||||
return
|
||||
}
|
||||
subject, ok := middleware2.GetAuthSubjectFromContext(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "User not authenticated")
|
||||
return
|
||||
}
|
||||
req, valid := bindPasskeyFinishRequest(c)
|
||||
if !valid {
|
||||
return
|
||||
}
|
||||
credentialRequest := cloneRequestWithJSON(c.Request, req.Credential)
|
||||
credential, err := h.passkeys.FinishRegistration(
|
||||
c.Request.Context(),
|
||||
subject.UserID,
|
||||
req.SessionToken,
|
||||
req.Name,
|
||||
credentialRequest,
|
||||
)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, credential)
|
||||
}
|
||||
|
||||
func (h *PasskeyHandler) List(c *gin.Context) {
|
||||
subject, ok := middleware2.GetAuthSubjectFromContext(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "User not authenticated")
|
||||
return
|
||||
}
|
||||
credentials, err := h.passkeys.List(c.Request.Context(), subject.UserID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, credentials)
|
||||
}
|
||||
|
||||
func (h *PasskeyHandler) Rename(c *gin.Context) {
|
||||
subject, credentialID, ok := passkeyMutationTarget(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req passkeyRenameRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Name) == "" {
|
||||
response.BadRequest(c, "Passkey name is required")
|
||||
return
|
||||
}
|
||||
if err := h.passkeys.Rename(c.Request.Context(), subject.UserID, credentialID, req.Name); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{"success": true})
|
||||
}
|
||||
|
||||
func (h *PasskeyHandler) Delete(c *gin.Context) {
|
||||
subject, credentialID, ok := passkeyMutationTarget(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.passkeys.Delete(c.Request.Context(), subject.UserID, credentialID, bindPasskeyPassword(c)); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{"success": true})
|
||||
}
|
||||
|
||||
func (h *PasskeyHandler) requirePasskeysEnabled(c *gin.Context) bool {
|
||||
if h.settingSvc == nil {
|
||||
response.ErrorFrom(c, service.ErrPasskeysDisabled)
|
||||
return false
|
||||
}
|
||||
enabled, err := h.settingSvc.PasskeyEnabled(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return false
|
||||
}
|
||||
if !enabled {
|
||||
response.ErrorFrom(c, service.ErrPasskeysDisabled)
|
||||
}
|
||||
return enabled
|
||||
}
|
||||
|
||||
func (h *PasskeyHandler) ensureBackendModeAllowsUser(ctx context.Context, user *service.User) error {
|
||||
if err := ensureLoginUserActive(user); err != nil {
|
||||
return err
|
||||
}
|
||||
if h.settingSvc == nil || !h.settingSvc.IsBackendModeEnabled(ctx) || user.IsAdmin() {
|
||||
return nil
|
||||
}
|
||||
return infraerrors.Forbidden("BACKEND_MODE_ADMIN_ONLY", "Backend mode is active. Only admin login is allowed.")
|
||||
}
|
||||
|
||||
func bindPasskeyFinishRequest(c *gin.Context) (*passkeyFinishRequest, bool) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, passkeyFinishBodyMaxBytes)
|
||||
var req passkeyFinishRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || len(req.Credential) == 0 {
|
||||
response.BadRequest(c, "Invalid passkey response")
|
||||
return nil, false
|
||||
}
|
||||
return &req, true
|
||||
}
|
||||
|
||||
func cloneRequestWithJSON(original *http.Request, payload []byte) *http.Request {
|
||||
request := original.Clone(original.Context())
|
||||
request.Body = io.NopCloser(bytes.NewReader(payload))
|
||||
request.ContentLength = int64(len(payload))
|
||||
request.Header = original.Header.Clone()
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
return request
|
||||
}
|
||||
|
||||
func passkeyMutationTarget(c *gin.Context) (middleware2.AuthSubject, int64, bool) {
|
||||
subject, ok := middleware2.GetAuthSubjectFromContext(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "User not authenticated")
|
||||
return middleware2.AuthSubject{}, 0, false
|
||||
}
|
||||
credentialID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil || credentialID <= 0 {
|
||||
response.BadRequest(c, "Invalid passkey ID")
|
||||
return middleware2.AuthSubject{}, 0, false
|
||||
}
|
||||
return subject, credentialID, true
|
||||
}
|
||||
Reference in New Issue
Block a user