This commit is contained in:
parent
6146ddba5d
commit
d8867dfc4a
@ -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) {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
12
test/frontend/public-password-reset.test.mjs
Normal file
12
test/frontend/public-password-reset.test.mjs
Normal 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, /恢复链接无效或已失效,请重新申请。/)
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user