diff --git a/backend/internal/handler/passkey_handler.go b/backend/internal/handler/passkey_handler.go index 3bb4481b3b..9ffc920e0d 100644 --- a/backend/internal/handler/passkey_handler.go +++ b/backend/internal/handler/passkey_handler.go @@ -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") diff --git a/backend/internal/handler/passkey_handler_test.go b/backend/internal/handler/passkey_handler_test.go new file mode 100644 index 0000000000..0197ffa05d --- /dev/null +++ b/backend/internal/handler/passkey_handler_test.go @@ -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) +} diff --git a/backend/internal/server/middleware/audit_log.go b/backend/internal/server/middleware/audit_log.go index aa74888e9a..30f399eea3 100644 --- a/backend/internal/server/middleware/audit_log.go +++ b/backend/internal/server/middleware/audit_log.go @@ -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, diff --git a/backend/internal/server/middleware/audit_log_test.go b/backend/internal/server/middleware/audit_log_test.go index 4892fc6d0b..3ff14cd914 100644 --- a/backend/internal/server/middleware/audit_log_test.go +++ b/backend/internal/server/middleware/audit_log_test.go @@ -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) { diff --git a/backend/internal/service/audit_log.go b/backend/internal/service/audit_log.go index 02b85682d7..46a59013e0 100644 --- a/backend/internal/service/audit_log.go +++ b/backend/internal/service/audit_log.go @@ -19,6 +19,7 @@ const ( // AuditAuthMethodJWT / AuditAuthMethodAdminAPIKey 与 auth 中间件写入的 auth_method 对齐。 AuditAuthMethodJWT = "jwt" AuditAuthMethodAdminAPIKey = "admin_api_key" + AuditAuthMethodPasskey = "passkey" // auditRequestBodyMaxBytes 请求体脱敏后入库的最大长度(字节),超出截断。 auditRequestBodyMaxBytes = 16 * 1024 diff --git a/backend/internal/service/passkey.go b/backend/internal/service/passkey.go index 70e6ca741e..7458d2f7b1 100644 --- a/backend/internal/service/passkey.go +++ b/backend/internal/service/passkey.go @@ -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, } } diff --git a/backend/internal/service/passkey_test.go b/backend/internal/service/passkey_test.go index 0abb9de15a..c5f8ff3cf0 100644 --- a/backend/internal/service/passkey_test.go +++ b/backend/internal/service/passkey_test.go @@ -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) +} diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index c775e2ee77..e4d9096607 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -284,6 +284,7 @@ type PublicSettings struct { PasswordResetEnabled bool InvitationCodeEnabled bool TotpEnabled bool // TOTP 双因素认证 + PasskeyEnabled bool LoginAgreementEnabled bool LoginAgreementMode string LoginAgreementUpdatedAt string