273 lines
8.0 KiB
Go
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
|
||
|
|
}
|