修复无效恢复链接展示
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-16 13:38:02 +08:00
parent 6146ddba5d
commit d8867dfc4a
4 changed files with 75 additions and 6 deletions

View File

@ -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) {

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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, /恢复链接无效或已失效,请重新申请。/)
})