完善独立匿名密码恢复页面
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-16 13:06:15 +08:00
parent a8a65170d3
commit 581e70b2ca
10 changed files with 624 additions and 46 deletions

View File

@ -233,12 +233,12 @@ func requestMatchesHTTPSAuthority(r *http.Request, configured string) bool {
if r == nil {
return false
}
expectedHost, _, ok := configuredHTTPSAuthority(configured)
expectedHost, expectedPort, ok := configuredHTTPSAuthority(configured)
if !ok {
return false
}
requestHost, _, ok := normalizeAuthority(r.Host, "443")
return ok && requestHost == expectedHost
requestHost, requestPort, ok := normalizeAuthority(r.Host, "443")
return ok && requestHost == expectedHost && requestPort == expectedPort
}
func configuredHTTPSAuthority(value string) (string, string, bool) {

View File

@ -63,6 +63,34 @@ func TestPublicRecoveryPageIsServedOnlyFromMailDomain(t *testing.T) {
}
}
func TestPublicRecoveryPageRequiresConfiguredHTTPSPort(t *testing.T) {
configurePublicRecoveryRouteTest(t)
config.Instance.HttpsPort = 8443
handler, err := newPublicRecoveryHandler()
if err != nil {
t.Fatal(err)
}
allowed := httptest.NewRecorder()
handler.ServeHTTP(allowed, publicRecoveryRequest(http.MethodGet, "https://mx.example.com:8443/rest/"))
if allowed.Code != http.StatusOK {
t.Fatalf("configured HTTPS port returned %d, want %d", allowed.Code, http.StatusOK)
}
for _, target := range []string{
"https://mx.example.com/rest/",
"https://mx.example.com:9443/rest/",
} {
t.Run(target, func(t *testing.T) {
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, publicRecoveryRequest(http.MethodGet, target))
if recorder.Code != http.StatusNotFound {
t.Fatalf("unexpected recovery page status %d for %q", recorder.Code, target)
}
})
}
}
func TestPublicRecoveryLoginEntryRedirectsOnlyFromWebDomain(t *testing.T) {
configurePublicRecoveryRouteTest(t)
handler, err := newPublicRecoveryHandler()

43
frontend/src/main-app.js Normal file
View File

@ -0,0 +1,43 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import { router } from './router'
import { useGlobalStatusStore } from './stores/useGlobalStatusStore'
import './styles/element-services.css'
import './assets/main.css'
const isReservedResetPath = (pathname) => {
let decoded = pathname.trim()
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
const next = decodeURIComponent(decoded)
if (next === decoded) break
decoded = next
} catch {
break
}
}
const normalized = decoded.replace(/\/+$/, '').toLowerCase()
return normalized === '/reset' || normalized.startsWith('/reset/')
}
// Hash History 会将 /reset#/ 解析为普通首页,启动前先切断这个回退路径。
if (isReservedResetPath(window.location.pathname)) {
window.history.replaceState({}, document.title, '/')
window.location.hash = '#/reset-blocked'
}
const app = createApp(App)
const pinia = createPinia()
const globalStatus = useGlobalStatusStore(pinia)
app.use(pinia)
router.onError((error) => {
console.error('Router navigation failed', error)
globalStatus.setStartupError('页面加载失败,请刷新后重试')
})
app.use(router)
app.mount('#app')

View File

@ -1,43 +1,5 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import {router} from './router'
import {useGlobalStatusStore} from './stores/useGlobalStatusStore'
import './styles/element-services.css'
import './assets/main.css'
const isReservedResetPath = (pathname) => {
let decoded = pathname.trim()
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
const next = decodeURIComponent(decoded)
if (next === decoded) break
decoded = next
} catch {
break
}
}
const normalized = decoded.replace(/\/+$/, '').toLowerCase()
return normalized === '/reset' || normalized.startsWith('/reset/')
if (window.location.pathname === '/rest' || window.location.pathname === '/rest/') {
import('./public-recovery.js')
} else {
import('./main-app.js')
}
// Hash History 会把 /reset#/ 解析成普通首页,启动前先切断这个回退路径。
if (isReservedResetPath(window.location.pathname)) {
window.history.replaceState({}, document.title, '/')
window.location.hash = '#/reset-blocked'
}
const app = createApp(App)
const pinia = createPinia()
const globalStatus = useGlobalStatusStore(pinia)
app.use(pinia)
router.onError((error) => {
console.error('Router navigation failed', error)
globalStatus.setStartupError('页面加载失败,请刷新后重试')
})
app.use(router)
app.mount('#app')

View File

@ -0,0 +1,4 @@
import { createApp } from 'vue'
import PublicPasswordReset from './views/PublicPasswordReset/PublicPasswordReset.vue'
createApp(PublicPasswordReset).mount('#app')

View File

@ -0,0 +1,26 @@
const publicRecoveryAPIBase = '/rest/api/recovery'
export async function postPublicRecovery(endpoint, payload) {
const response = await fetch(`${publicRecoveryAPIBase}/${endpoint}`, {
method: 'POST',
credentials: 'omit',
cache: 'no-store',
referrerPolicy: 'no-referrer',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
})
let result = null
try {
result = await response.json()
} catch {
result = null
}
return {
ok: response.ok,
data: result
}
}

View File

@ -49,7 +49,7 @@ export function usePage() {
}
const goForgotPassword = () => {
router.push({ name: "forgot-password" })
window.location.assign('/public-password-recovery')
}
return {

View File

@ -0,0 +1,218 @@
:global(html),
:global(body),
:global(#app) {
width: 100%;
min-height: 100%;
margin: 0;
}
:global(body) {
color: #172033;
background:
radial-gradient(circle at 0% 0%, rgba(37, 99, 235, 0.16), transparent 34%),
linear-gradient(135deg, #eef4ff, #f8fafc 55%, #edf5ff);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
:global(*) {
box-sizing: border-box;
}
.public-recovery-page {
display: grid;
min-height: 100vh;
min-height: 100dvh;
padding: 24px;
place-items: center;
}
.public-recovery-card {
width: min(100%, 520px);
padding: clamp(28px, 5vw, 42px);
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(148, 163, 184, 0.3);
border-radius: 24px;
box-shadow: 0 24px 70px rgba(30, 64, 175, 0.16);
}
.public-recovery-header {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 28px;
}
.public-recovery-logo {
width: 46px;
height: 46px;
border-radius: 14px;
object-fit: contain;
box-shadow: 0 6px 16px rgba(37, 99, 235, 0.2);
}
.public-recovery-eyebrow {
margin: 0 0 3px;
color: #2563eb;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.08em;
}
h1,
h2 {
margin: 0;
color: #111827;
}
h1 {
font-size: clamp(23px, 4vw, 28px);
line-height: 1.25;
}
.public-recovery-form {
display: grid;
gap: 10px;
}
.public-recovery-copy,
.public-recovery-complete p {
margin: 0 0 10px;
color: #526174;
font-size: 14px;
line-height: 1.65;
}
label {
margin-top: 6px;
color: #344156;
font-size: 14px;
font-weight: 700;
}
input {
width: 100%;
min-height: 46px;
padding: 11px 13px;
color: #172033;
font: inherit;
background: #f8fafc;
border: 1px solid #cbd5e1;
border-radius: 12px;
outline: none;
transition: border-color 0.16s ease, box-shadow 0.16s ease;
}
input:focus {
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.14);
}
input:disabled,
button:disabled {
cursor: not-allowed;
opacity: 0.65;
}
.public-recovery-status {
margin: 4px 0 0;
padding: 10px 12px;
color: #475569;
font-size: 13px;
line-height: 1.5;
background: #f1f5f9;
border-radius: 10px;
}
.public-recovery-status.success {
color: #166534;
background: #f0fdf4;
}
.public-recovery-status.error {
color: #b42318;
background: #fff1f2;
}
.public-recovery-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 18px;
}
.single-action {
justify-content: flex-end;
}
button {
min-height: 44px;
padding: 0 18px;
color: #fff;
font: inherit;
font-weight: 700;
cursor: pointer;
background: #2563eb;
border: 1px solid #2563eb;
border-radius: 12px;
transition: background 0.16s ease, transform 0.16s ease;
}
button:not(:disabled):hover {
background: #1d4ed8;
transform: translateY(-1px);
}
.text-button {
color: #2563eb;
background: transparent;
border-color: transparent;
}
.text-button:not(:disabled):hover {
background: #eff6ff;
}
.public-recovery-complete {
display: grid;
justify-items: center;
gap: 13px;
padding: 12px 0 4px;
text-align: center;
}
.public-recovery-complete h2 {
font-size: 22px;
}
.public-recovery-success-icon {
display: grid;
width: 66px;
height: 66px;
color: #fff;
font-size: 34px;
font-weight: 800;
background: #16a34a;
border-radius: 50%;
place-items: center;
}
@media (max-width: 520px) {
.public-recovery-page {
padding: 14px;
}
.public-recovery-card {
padding: 24px 20px;
border-radius: 20px;
}
.public-recovery-actions {
align-items: stretch;
flex-direction: column-reverse;
}
.public-recovery-actions button {
width: 100%;
}
}

View File

@ -0,0 +1,186 @@
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { isValidPasswordLength } from '@/utils/password'
import { postPublicRecovery } from '@/utils/publicRecoveryHttp'
const requestHash = '#/forgot-password'
const resetHashPrefix = '#/reset-password/'
function currentResetToken() {
const hash = window.location.hash
if (!hash.startsWith(resetHashPrefix)) {
return ''
}
const encodedToken = hash.slice(resetHashPrefix.length)
if (!encodedToken || encodedToken.includes('/')) {
return ''
}
try {
const token = decodeURIComponent(encodedToken)
return token.length <= 512 ? token : ''
} catch {
return ''
}
}
function loginURL() {
const value = document.querySelector('#app')?.dataset.loginUrl
if (!value) {
return '/#/login'
}
return value
}
export function usePage() {
const page = ref('request')
const loading = ref(false)
const submitted = ref(false)
const available = ref(false)
const requiresRecoveryCode = ref(false)
const errorMessage = ref('')
const resetToken = ref('')
const form = reactive({
identifier: '',
newPassword: '',
confirmPassword: '',
recoveryCode: ''
})
const passwordMismatch = computed(() => form.confirmPassword !== '' && form.newPassword !== form.confirmPassword)
const canSubmitReset = computed(() => (
form.newPassword !== '' &&
form.confirmPassword !== '' &&
!passwordMismatch.value &&
isValidPasswordLength(form.newPassword) &&
(!requiresRecoveryCode.value || form.recoveryCode.trim() !== '')
))
const resetRequestForm = () => {
page.value = 'request'
submitted.value = false
available.value = false
requiresRecoveryCode.value = false
errorMessage.value = ''
resetToken.value = ''
form.newPassword = ''
form.confirmPassword = ''
form.recoveryCode = ''
}
const loadRoute = async () => {
const token = currentResetToken()
if (!token) {
if (window.location.hash !== requestHash) {
window.history.replaceState({}, document.title, `${window.location.pathname}${window.location.search}${requestHash}`)
}
resetRequestForm()
return
}
page.value = 'reset'
submitted.value = false
available.value = false
requiresRecoveryCode.value = false
errorMessage.value = ''
resetToken.value = token
form.newPassword = ''
form.confirmPassword = ''
form.recoveryCode = ''
loading.value = true
try {
const result = await postPublicRecovery('requirements', { token })
if (resetToken.value !== token || page.value !== 'reset') {
return
}
if (!result.ok || !result.data) {
errorMessage.value = '恢复链接无效或已失效,请重新申请。'
return
}
requiresRecoveryCode.value = result.data.requires_recovery_code === true
available.value = true
} catch {
if (resetToken.value === token && page.value === 'reset') {
errorMessage.value = '暂时无法验证恢复链接,请稍后重试。'
}
} finally {
if (resetToken.value === token) {
loading.value = false
}
}
}
const requestReset = async () => {
if (loading.value || !form.identifier.trim()) {
return
}
loading.value = true
errorMessage.value = ''
try {
await postPublicRecovery('begin', { identifier: form.identifier.trim() })
submitted.value = true
} catch {
submitted.value = true
} finally {
loading.value = false
}
}
const completeReset = async () => {
if (loading.value || !canSubmitReset.value || !resetToken.value) {
return
}
loading.value = true
errorMessage.value = ''
try {
const result = await postPublicRecovery('complete', {
token: resetToken.value,
new_password: form.newPassword,
recovery_code: requiresRecoveryCode.value ? form.recoveryCode.trim() : ''
})
if (!result.ok || result.data?.reset !== true) {
errorMessage.value = '重置失败。恢复链接可能已失效,请重新申请。'
return
}
resetToken.value = ''
form.newPassword = ''
form.confirmPassword = ''
form.recoveryCode = ''
page.value = 'success'
} catch {
errorMessage.value = '重置失败。恢复链接可能已失效,请重新申请。'
} finally {
loading.value = false
}
}
const goLogin = () => window.location.assign(loginURL())
const goRequest = () => window.location.assign(`${window.location.pathname}${window.location.search}${requestHash}`)
onMounted(() => {
void loadRoute()
window.addEventListener('hashchange', loadRoute)
})
onBeforeUnmount(() => {
window.removeEventListener('hashchange', loadRoute)
resetToken.value = ''
})
return {
page,
loading,
submitted,
available,
requiresRecoveryCode,
errorMessage,
form,
passwordMismatch,
canSubmitReset,
requestReset,
completeReset,
goLogin,
goRequest
}
}

View File

@ -0,0 +1,111 @@
<template>
<main class="public-recovery-page">
<section class="public-recovery-card" aria-labelledby="public-recovery-title">
<header class="public-recovery-header">
<img class="public-recovery-logo" src="/smail-logo.png" alt="Smail" />
<div>
<p class="public-recovery-eyebrow">Smail 安全恢复</p>
<h1 id="public-recovery-title">{{ page === 'request' ? '找回登录密码' : '重设登录密码' }}</h1>
</div>
</header>
<form v-if="page === 'request'" class="public-recovery-form" @submit.prevent="requestReset">
<p class="public-recovery-copy">输入账号或完整邮箱。若该账号允许外部邮箱恢复,系统会向已验证的安全邮箱发送一次性恢复链接。</p>
<label for="public-recovery-identifier">账号或完整邮箱</label>
<input
id="public-recovery-identifier"
v-model="form.identifier"
autocomplete="username"
maxlength="320"
placeholder="例如:name@example.com"
:disabled="loading"
required
/>
<p v-if="submitted" class="public-recovery-status success" role="status">请求已受理。若信息匹配,请检查已验证的外部安全邮箱。</p>
<div class="public-recovery-actions">
<button type="button" class="text-button" :disabled="loading" @click="goLogin">返回登录</button>
<button type="submit" :disabled="loading || !form.identifier.trim()">{{ loading ? '正在提交…' : '发送恢复链接' }}</button>
</div>
</form>
<section v-else-if="page === 'reset'" class="public-recovery-form" aria-live="polite">
<p class="public-recovery-copy">请设置新的登录密码。为保护账号安全,此链接仅可使用一次。</p>
<p v-if="loading" class="public-recovery-status">正在验证恢复链接…</p>
<template v-else-if="available">
<label for="public-recovery-password">新密码</label>
<input
id="public-recovery-password"
v-model="form.newPassword"
type="password"
autocomplete="new-password"
minlength="8"
maxlength="12"
placeholder="8 至 12 位密码"
/>
<label for="public-recovery-confirm-password">确认新密码</label>
<input
id="public-recovery-confirm-password"
v-model="form.confirmPassword"
type="password"
autocomplete="new-password"
minlength="8"
maxlength="12"
placeholder="再次输入新密码"
/>
<p v-if="passwordMismatch" class="public-recovery-status error">两次输入的新密码不一致。</p>
<template v-if="requiresRecoveryCode">
<label for="public-recovery-code">离线恢复码</label>
<input
id="public-recovery-code"
v-model="form.recoveryCode"
type="password"
autocomplete="one-time-code"
maxlength="64"
placeholder="输入未使用的离线恢复码"
/>
</template>
<p v-if="errorMessage" class="public-recovery-status error">{{ errorMessage }}</p>
<div class="public-recovery-actions">
<button type="button" class="text-button" :disabled="loading" @click="goRequest">重新申请</button>
<button type="button" :disabled="loading || !canSubmitReset" @click="completeReset">{{ loading ? '正在重置…' : '确认重置' }}</button>
</div>
</template>
<template v-else>
<p class="public-recovery-status error">{{ errorMessage || '恢复链接无效或已失效,请重新申请。' }}</p>
<div class="public-recovery-actions single-action">
<button type="button" @click="goRequest">重新申请恢复链接</button>
</div>
</template>
</section>
<section v-else class="public-recovery-complete" aria-live="polite">
<div class="public-recovery-success-icon" aria-hidden="true">✓</div>
<h2>密码已重置</h2>
<p>请使用新密码重新登录系统。</p>
<button type="button" @click="goLogin">前往登录</button>
</section>
</section>
</main>
</template>
<script setup>
import { usePage } from './PublicPasswordReset.js'
const {
page,
loading,
submitted,
available,
requiresRecoveryCode,
errorMessage,
form,
passwordMismatch,
canSubmitReset,
requestReset,
completeReset,
goLogin,
goRequest
} = usePage()
</script>
<style scoped src="./PublicPasswordReset.css"></style>