mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 13:18:18 +08:00
fix: harden passkey deployment readiness
This commit is contained in:
@@ -49,6 +49,8 @@ type passkeyRenameRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
}
|
||||
|
||||
const passkeyFinishBodyMaxBytes = 64 * 1024
|
||||
|
||||
// BeginLogin starts a usernameless, discoverable-credential login ceremony.
|
||||
func (h *PasskeyHandler) BeginLogin(c *gin.Context) {
|
||||
assertion, token, err := h.passkeys.BeginLogin(c.Request.Context())
|
||||
@@ -78,6 +80,8 @@ func (h *PasskeyHandler) FinishLogin(c *gin.Context) {
|
||||
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)
|
||||
}
|
||||
@@ -175,6 +179,7 @@ func (h *PasskeyHandler) ensureBackendModeAllowsUser(ctx context.Context, user *
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBindPasskeyFinishRequestRejectsOversizedBody(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
context, _ := gin.CreateTestContext(recorder)
|
||||
context.Request = httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/auth/passkey/login/finish",
|
||||
strings.NewReader(`{"credential":"`+strings.Repeat("x", passkeyFinishBodyMaxBytes)+`"}`),
|
||||
)
|
||||
context.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
_, ok := bindPasskeyFinishRequest(context)
|
||||
require.False(t, ok)
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
}
|
||||
@@ -124,6 +124,7 @@ var auditSensitiveReads = map[string]string{
|
||||
var auditActionOverrides = map[string]string{
|
||||
"POST /api/v1/auth/login": service.AuditActionLogin,
|
||||
"POST /api/v1/auth/login/2fa": service.AuditActionLogin2FA,
|
||||
"POST /api/v1/auth/passkey/login/finish": service.AuditActionLogin,
|
||||
"POST /api/v1/auth/register": service.AuditActionRegister,
|
||||
"POST /api/v1/auth/refresh": service.AuditActionTokenRefresh,
|
||||
"POST /api/v1/user/totp/step-up": service.AuditActionStepUpVerify,
|
||||
|
||||
@@ -146,6 +146,12 @@ func TestPromptAuditMutationAuditRoutesHaveStableActionsAndOmitBodies(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasskeyLoginAuditUsesCanonicalLoginActionAndOmitsCredentialBody(t *testing.T) {
|
||||
route := "POST /api/v1/auth/passkey/login/finish"
|
||||
require.Equal(t, service.AuditActionLogin, auditActionOverrides[route])
|
||||
require.Contains(t, auditBodyOmittedRoutes, route)
|
||||
}
|
||||
|
||||
// Ollama 会话保存的请求体整体就是浏览器 Cookie 明文,键级脱敏清单曾漏掉裸键
|
||||
// "session",必须走整体不入库路径,防止会话凭证长期留存在 audit_logs。
|
||||
func TestOllamaCloudUsageSessionRouteOmitsAuditBody(t *testing.T) {
|
||||
|
||||
@@ -19,6 +19,7 @@ const (
|
||||
// AuditAuthMethodJWT / AuditAuthMethodAdminAPIKey 与 auth 中间件写入的 auth_method 对齐。
|
||||
AuditAuthMethodJWT = "jwt"
|
||||
AuditAuthMethodAdminAPIKey = "admin_api_key"
|
||||
AuditAuthMethodPasskey = "passkey"
|
||||
|
||||
// auditRequestBodyMaxBytes 请求体脱敏后入库的最大长度(字节),超出截断。
|
||||
auditRequestBodyMaxBytes = 16 * 1024
|
||||
|
||||
@@ -371,7 +371,7 @@ func passkeySummary(record *PasskeyCredentialRecord) *PasskeyCredentialSummary {
|
||||
Name: record.Name,
|
||||
CreatedAt: record.CreatedAt,
|
||||
LastUsedAt: record.LastUsedAt,
|
||||
Backup: record.Credential.Flags.BackupEligible,
|
||||
Backup: record.Credential.Flags.BackupState,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -14,3 +15,15 @@ func TestNormalizePasskeyName(t *testing.T) {
|
||||
longName := strings.Repeat("密", maxPasskeyNameLength+10)
|
||||
require.Len(t, []rune(normalizePasskeyName(longName)), maxPasskeyNameLength)
|
||||
}
|
||||
|
||||
func TestPasskeySummaryReportsCurrentBackupState(t *testing.T) {
|
||||
record := &PasskeyCredentialRecord{
|
||||
Credential: webauthn.Credential{
|
||||
Flags: webauthn.CredentialFlags{BackupEligible: true},
|
||||
},
|
||||
}
|
||||
require.False(t, passkeySummary(record).Backup)
|
||||
|
||||
record.Credential.Flags.BackupState = true
|
||||
require.True(t, passkeySummary(record).Backup)
|
||||
}
|
||||
|
||||
@@ -284,6 +284,7 @@ type PublicSettings struct {
|
||||
PasswordResetEnabled bool
|
||||
InvitationCodeEnabled bool
|
||||
TotpEnabled bool // TOTP 双因素认证
|
||||
PasskeyEnabled bool
|
||||
LoginAgreementEnabled bool
|
||||
LoginAgreementMode string
|
||||
LoginAgreementUpdatedAt string
|
||||
|
||||
Reference in New Issue
Block a user