Files
sub2api/backend/internal/handler/passkey_handler.go
T
feeeei 26e0a89323 人机验证增加阿里云验证码 2.0
沿用腾讯天御验证码引入的多服务商模型:aliyun_captcha_enabled 作为独立
开关,与 Cloudflare Turnstile、腾讯天御三方互斥(保存校验 + 运行时
CAPTCHA_PROVIDER_CONFLICT)。后台「安全与认证」合并为单张人机验证卡片:
总开关 + 服务商单选(Turnstile / 腾讯天御 / 阿里云),选中即启用该家并
关闭其它,落库仍是三个独立开关键,由前端映射保证互斥。

阿里云侧同时支持 aliyun 中国站与国际站(alibabacloud.com):两站前端脚本、
region 取值与服务端 API 完全一致,仅账号与 AccessKey 相互独立,因此由
「服务地域」决定线路即可——中国内地走 captcha.cn-shanghai.aliyuncs.com,
非中国内地(新加坡)走 captcha.ap-southeast-1.aliyuncs.com,AccessKey
取自持有该实例的账号,无需在配置中区分站点。

- AliyunCaptchaService 对称 TencentCaptchaService:服务端校验走官方 SDK
  VerifyIntelligentCaptcha,调用异常按 fail-closed 拦截,与 Turnstile
  网络错误行为对称;保存设置时真实探测 AK/SK 有效性
- 保护面对齐腾讯扩展入口:VerifyTencentCaptchaIfEnabled 通用化为
  VerifyActionCaptchaIfEnabled,OAuth 登录启动、passkey 登录在阿里云
  启用时同样拦截;Turnstile 维持既有覆盖不扩大
- 前端 AliyunCaptchaWidget 为表单内预验证按钮(popup 模式),同时暴露
  verify() 供 OAuth 启动、passkey 等动作入口程序化弹窗;未预验证直接
  提交时弹窗兜底。SDK 按钮绑定异步完成,弹窗未出现前按 tick 重试触发,
  并轮询弹窗可见性识别用户关闭
- captchaVerifyParam 复用 turnstile_token 请求字段提交;公开设置下发
  aliyun_captcha_enabled / scene_id / prefix / region
- CSP 放行验证码 CDN:script-src/style-src 加 *.alicdn.com
2026-08-04 20:57:15 +08:00

273 lines
8.0 KiB
Go

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
}