diff --git a/backend/controllers/security.go b/backend/controllers/security.go index 1c6d170..1983e12 100644 --- a/backend/controllers/security.go +++ b/backend/controllers/security.go @@ -2,8 +2,8 @@ package controllers import ( "crypto/rand" - "encoding/json" "encoding/hex" + "encoding/json" "errors" "net" "net/http" @@ -720,15 +720,19 @@ func PublicLinkPasswordRecoveryRequirements(w http.ResponseWriter, req *http.Req return } runtime := currentSecurityRuntime() + available := false requiresRecoveryCode := false if runtime.recovery != nil { var err error requiresRecoveryCode, err = runtime.recovery.PublicLinkRequiresRecoveryCode(input.Token) - if err != nil { - requiresRecoveryCode = false + if err == nil { + available = true } } - writePublicRecoveryJSON(w, http.StatusOK, map[string]bool{"requires_recovery_code": requiresRecoveryCode}) + writePublicRecoveryJSON(w, http.StatusOK, map[string]bool{ + "available": available, + "requires_recovery_code": requiresRecoveryCode, + }) } func writePublicRecoveryJSON(w http.ResponseWriter, status int, value any) { diff --git a/backend/controllers/security_public_response_test.go b/backend/controllers/security_public_response_test.go index effd3e8..d99b88f 100644 --- a/backend/controllers/security_public_response_test.go +++ b/backend/controllers/security_public_response_test.go @@ -112,13 +112,25 @@ func enablePublicRecoveryForTest(t *testing.T) { old := config.Instance oldManager := config.CurrentManager() config.SetRuntimeManager(nil) - config.Instance = &config.Config{SetupState: config.SetupStateReady, WebDomain: "example.com", ExternalRecoveryEnabled: true} + config.Instance = &config.Config{SetupState: config.SetupStateReady, Domain: "mail.example.com", WebDomain: "example.com", ExternalRecoveryEnabled: true} t.Cleanup(func() { config.Instance = old config.SetRuntimeManager(oldManager) }) } +func invokePublicLinkRequirements(t *testing.T, token string) (int, map[string]bool, http.Header) { + t.Helper() + request := httptest.NewRequest(http.MethodPost, "https://mail.example.com/rest/api/recovery/requirements", strings.NewReader(`{"token":"`+token+`"}`)) + recorder := httptest.NewRecorder() + PublicLinkPasswordRecoveryRequirements(recorder, request) + result := map[string]bool{} + if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil { + t.Fatalf("invalid requirements response: %v; body=%s", err, recorder.Body.String()) + } + return recorder.Code, result, recorder.Header() +} + func jsonShape(value any) []string { paths := make([]string, 0) var walk func(string, any) @@ -234,3 +246,44 @@ func TestWebAuthnRecoveryBeginResponsesDoNotRevealAccountState(t *testing.T) { assertPublicResponseEquivalent(t, baselineStatus, baseline, baselineShape, status, current, shape) } } + +func TestPublicLinkPasswordRecoveryRequirementsExposeOnlyLinkAvailability(t *testing.T) { + enablePublicRecoveryForTest(t) + recoveryService, _, _, user, _ := newPublicSecurityFixture(t) + var token string + recoveryService.SetPublicLinkDelivery(func(_ string, value string) error { + token = value + return nil + }) + if err := recoveryService.BeginPublicLink(user.Account); err != nil { + t.Fatal(err) + } + if token == "" { + t.Fatal("public recovery delivery did not receive a token") + } + + previous := currentSecurityRuntime() + ConfigureSecurityRuntime(previous.verification, previous.external, recoveryService, previous.credentials) + t.Cleanup(func() { + ConfigureSecurityRuntime(previous.verification, previous.external, previous.recovery, previous.credentials) + }) + + status, valid, headers := invokePublicLinkRequirements(t, token) + if status != http.StatusOK { + t.Fatalf("valid token status = %d, want %d", status, http.StatusOK) + } + if !valid["available"] || valid["requires_recovery_code"] { + t.Fatalf("valid token requirements = %#v", valid) + } + if headers.Get("Cache-Control") != "no-store" { + t.Fatalf("cache policy = %q", headers.Get("Cache-Control")) + } + + status, invalid, _ := invokePublicLinkRequirements(t, "invalid-token") + if status != http.StatusOK { + t.Fatalf("invalid token status = %d, want %d", status, http.StatusOK) + } + if invalid["available"] || invalid["requires_recovery_code"] || len(invalid) != 2 { + t.Fatalf("invalid token requirements = %#v", invalid) + } +} diff --git a/frontend/src/views/PublicPasswordReset/PublicPasswordReset.js b/frontend/src/views/PublicPasswordReset/PublicPasswordReset.js index 49854bc..a55b351 100644 --- a/frontend/src/views/PublicPasswordReset/PublicPasswordReset.js +++ b/frontend/src/views/PublicPasswordReset/PublicPasswordReset.js @@ -94,7 +94,7 @@ export function usePage() { if (resetToken.value !== token || page.value !== 'reset') { return } - if (!result.ok || !result.data) { + if (!result.ok || result.data?.available !== true) { errorMessage.value = '恢复链接无效或已失效,请重新申请。' return } diff --git a/test/frontend/public-password-reset.test.mjs b/test/frontend/public-password-reset.test.mjs new file mode 100644 index 0000000..708b44f --- /dev/null +++ b/test/frontend/public-password-reset.test.mjs @@ -0,0 +1,12 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const script = readFileSync(new URL('../../frontend/src/views/PublicPasswordReset/PublicPasswordReset.js', import.meta.url), 'utf8') +const template = readFileSync(new URL('../../frontend/src/views/PublicPasswordReset/PublicPasswordReset.vue', import.meta.url), 'utf8') + +test('匿名恢复页仅在后端明确确认链接可用时显示密码表单', () => { + assert.match(script, /!result\.ok \|\| result\.data\?\.available !== true/) + assert.match(template, /v-else-if="available"/) + assert.match(template, /恢复链接无效或已失效,请重新申请。/) +})