Compare commits

...

2 Commits

Author SHA1 Message Date
581e70b2ca 完善独立匿名密码恢复页面
Some checks are pending
Security Checks / security (push) Waiting to run
2026-09-16 13:06:15 +08:00
a8a65170d3 新增主域名匿名密码恢复入口 2026-09-16 12:57:54 +08:00
13 changed files with 1134 additions and 64 deletions

View File

@ -316,7 +316,11 @@ func newHTTPHandler(cfg config.HTTPConfig) (http.Handler, error) {
if session.Instance == nil {
return nil, fmt.Errorf("session manager is unavailable")
}
handler = dbmigration.GuardHTTP(session.Instance.LoadAndSave(mux))
publicRecovery, err := newPublicRecoveryHandler()
if err != nil {
return nil, err
}
handler = dbmigration.GuardHTTP(publicRecoveryOrSessionHandler(publicRecovery, session.Instance.LoadAndSave(mux)))
} else {
mux.HandleFunc("/.well-known/", controllers.AcmeChallenge)
mux.HandleFunc("/api/ping", controllers.Ping)
@ -331,30 +335,39 @@ func redirectToHTTPS(w http.ResponseWriter, r *http.Request) {
http.Error(w, "HTTPS is required", http.StatusUpgradeRequired)
return
}
host := strings.TrimSpace(config.CurrentWebDomain())
if host == "" {
host, ok := configuredHTTPSRedirectHost(r)
if !ok {
w.Header().Set("Connection", "close")
http.Error(w, "HTTPS is required", http.StatusUpgradeRequired)
return
}
parsed, err := url.Parse("//" + host)
if err != nil || parsed.Hostname() == "" || parsed.User != nil ||
parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
w.Header().Set("Connection", "close")
http.Error(w, "HTTPS is required", http.StatusUpgradeRequired)
return
}
host = strings.TrimSuffix(parsed.Hostname(), ".")
if strings.Contains(host, ":") {
host = "[" + host + "]"
}
httpsPort := config.CurrentHTTPConfig().HTTPSPort
if httpsPort > 0 && httpsPort != 443 {
host = fmt.Sprintf("%s:%d", host, httpsPort)
}
target := "https://" + host + r.URL.EscapedPath()
if r.URL.RawQuery != "" {
target += "?" + r.URL.RawQuery
}
http.Redirect(w, r, target, http.StatusPermanentRedirect)
}
func configuredHTTPSRedirectHost(r *http.Request) (string, bool) {
if r == nil {
return "", false
}
requestHost, _, ok := normalizeAuthority(r.Host, "80")
if !ok {
return "", false
}
for _, configured := range []string{config.CurrentWebDomain(), config.CurrentMailDomains().Primary} {
host, port, valid := configuredHTTPSAuthority(configured)
if !valid || host != requestHost {
continue
}
if strings.Contains(host, ":") {
host = "[" + host + "]"
}
if port != "443" {
host = fmt.Sprintf("%s:%s", host, port)
}
return host, true
}
return "", false
}

View File

@ -54,6 +54,13 @@ func HttpsStart() error {
if err := router(mux); err != nil {
return err
}
publicRecovery, err := newPublicRecoveryHandler()
if err != nil {
return err
}
if session.Instance == nil {
return fmt.Errorf("session manager is unavailable")
}
nullLog := olog.New(&nullWrite{}, "", olog.Ldate)
httpsPort := 443
@ -75,7 +82,7 @@ func HttpsStart() error {
httpsServer = &http.Server{
TLSConfig: tlsConfig,
Addr: fmt.Sprintf(":%d", httpsPort),
Handler: securityHeaders(dbmigration.GuardHTTP(session.Instance.LoadAndSave(mux))),
Handler: securityHeaders(dbmigration.GuardHTTP(publicRecoveryOrSessionHandler(publicRecovery, session.Instance.LoadAndSave(mux)))),
ReadTimeout: time.Second * 90,
ReadHeaderTimeout: time.Second * 10,
WriteTimeout: time.Second * 90,

View File

@ -0,0 +1,288 @@
package http_server
import (
"bytes"
"fmt"
"html"
"io/fs"
"mime"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"git.pcux.cn/xuans/Smail/config"
"git.pcux.cn/xuans/Smail/controllers"
"git.pcux.cn/xuans/Smail/session"
)
const publicRecoveryPrefix = "/rest"
// newPublicRecoveryHandler 创建不经过主站会话和业务上下文的匿名恢复处理器。
// 该处理器只能由 publicRecoveryOrSessionHandler 在会话中间件之前调用。
func newPublicRecoveryHandler() (http.Handler, error) {
frontend, err := fs.Sub(local, "dist")
if err != nil {
return nil, fmt.Errorf("load embedded frontend for public recovery: %w", err)
}
return &publicRecoveryHandler{
frontend: frontend,
static: http.FileServer(http.FS(frontend)),
}, nil
}
type publicRecoveryHandler struct {
frontend fs.FS
static http.Handler
}
func (h *publicRecoveryHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r == nil || !controllers.PublicMailRecoveryEnabled() {
http.NotFound(w, r)
return
}
if !config.DevelopmentMode && requestScheme(r) != "https" {
http.NotFound(w, r)
return
}
path := normalizedPublicRecoveryPath(r.URL.Path)
if path == "/public-password-recovery" {
h.redirectFromWebLogin(w, r)
return
}
if !isPublicRecoveryMailHost(r) {
http.NotFound(w, r)
return
}
switch path {
case "/rest", "/rest/":
h.servePage(w, r)
case "/rest/api/recovery/begin":
h.serveAPI(w, r, controllers.BeginPublicLinkPasswordRecovery)
case "/rest/api/recovery/requirements":
h.serveAPI(w, r, controllers.PublicLinkPasswordRecoveryRequirements)
case "/rest/api/recovery/complete":
h.serveAPI(w, r, controllers.CompletePublicLinkPasswordRecovery)
default:
if isPublicRecoveryAssetPath(path) {
h.serveStatic(w, r)
return
}
http.NotFound(w, r)
}
}
func (h *publicRecoveryHandler) redirectFromWebLogin(w http.ResponseWriter, r *http.Request) {
if !isCurrentWebHost(r) {
http.NotFound(w, r)
return
}
target, ok := publicRecoveryBaseURL()
if !ok {
http.NotFound(w, r)
return
}
w.Header().Set("Cache-Control", "no-store")
http.Redirect(w, r, target, http.StatusFound)
}
func (h *publicRecoveryHandler) servePage(w http.ResponseWriter, r *http.Request) {
loginURL, ok := publicRecoveryLoginURL()
if !ok {
http.NotFound(w, r)
return
}
content, err := fs.ReadFile(h.frontend, "index.html")
if err != nil {
http.Error(w, "public recovery page is unavailable", http.StatusServiceUnavailable)
return
}
marker := []byte(`<div id="app"></div>`)
replacement := []byte(`<div id="app" data-login-url="` + html.EscapeString(loginURL) + `"></div>`)
if !bytes.Contains(content, marker) {
http.Error(w, "public recovery page is unavailable", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(bytes.Replace(content, marker, replacement, 1))
}
func (h *publicRecoveryHandler) serveStatic(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "public, max-age=86400")
h.static.ServeHTTP(w, r)
}
func (h *publicRecoveryHandler) serveAPI(w http.ResponseWriter, r *http.Request, handler func(http.ResponseWriter, *http.Request)) {
if !publicRecoveryAPIRequestAllowed(r) {
w.Header().Set("Cache-Control", "no-store")
http.Error(w, "invalid public recovery request", http.StatusBadRequest)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 16<<10)
handler(w, r)
}
// publicRecoveryOrSessionHandler 将匿名恢复路径从主站 Session 之前分流。
// 除匿名恢复页、静态资源和恢复 API 外,所有请求维持原有主站会话处理路径。
func publicRecoveryOrSessionHandler(publicRecovery, mainSite http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := normalizedPublicRecoveryPath(r.URL.Path)
if path == "/public-password-recovery" || isPublicRecoveryReservedPath(path) ||
(isPublicRecoveryMailHost(r) && isPublicRecoveryAssetPath(path)) {
publicRecovery.ServeHTTP(w, r)
return
}
mainSite.ServeHTTP(w, r)
})
}
func normalizedPublicRecoveryPath(value string) string {
path := strings.TrimSpace(value)
for range 2 {
decoded, err := url.PathUnescape(path)
if err != nil || decoded == path {
break
}
path = decoded
}
if path == "" {
return "/"
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return path
}
func isPublicRecoveryReservedPath(path string) bool {
return path == publicRecoveryPrefix || strings.HasPrefix(path, publicRecoveryPrefix+"/")
}
func isPublicRecoveryAssetPath(path string) bool {
return strings.HasPrefix(path, "/assets/") || path == "/smail-logo.png"
}
func publicRecoveryAPIRequestAllowed(r *http.Request) bool {
if r == nil || r.Method != http.MethodPost || hasPublicRecoveryCookie(r) {
return false
}
if strings.TrimSpace(r.Header.Get("X-CSRF-Token")) != "" ||
strings.EqualFold(strings.TrimSpace(r.Header.Get("Sec-Fetch-Site")), "cross-site") {
return false
}
mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil || !strings.EqualFold(mediaType, "application/json") {
return false
}
return publicRecoveryOriginsAllowed(r)
}
func hasPublicRecoveryCookie(r *http.Request) bool {
if r == nil {
return false
}
for _, cookie := range r.Cookies() {
name := strings.ToLower(strings.TrimSpace(cookie.Name))
if name == "smail_csrf" || name == "smail_recovery_context" || name == "smail_session" {
return true
}
if session.Instance != nil && strings.EqualFold(cookie.Name, session.Instance.Cookie.Name) {
return true
}
}
return false
}
func publicRecoveryOriginsAllowed(r *http.Request) bool {
for _, value := range []string{r.Header.Get("Origin"), r.Header.Get("Referer")} {
value = strings.TrimSpace(value)
if value != "" && !isPublicRecoveryOrigin(value) {
return false
}
}
return true
}
func isPublicRecoveryOrigin(value string) bool {
u, err := url.Parse(strings.TrimSpace(value))
if err != nil || u.User != nil || !strings.EqualFold(u.Scheme, "https") || u.Host == "" {
return false
}
expectedHost, expectedPort, ok := configuredHTTPSAuthority(config.CurrentMailDomains().Primary)
if !ok {
return false
}
originHost, originPort, ok := normalizeAuthority(u.Host, "443")
return ok && originHost == expectedHost && originPort == expectedPort
}
func isPublicRecoveryMailHost(r *http.Request) bool {
return requestMatchesHTTPSAuthority(r, config.CurrentMailDomains().Primary)
}
func isCurrentWebHost(r *http.Request) bool {
return requestMatchesHTTPSAuthority(r, config.CurrentWebDomain())
}
func requestMatchesHTTPSAuthority(r *http.Request, configured string) bool {
if r == nil {
return false
}
expectedHost, expectedPort, ok := configuredHTTPSAuthority(configured)
if !ok {
return false
}
requestHost, requestPort, ok := normalizeAuthority(r.Host, "443")
return ok && requestHost == expectedHost && requestPort == expectedPort
}
func configuredHTTPSAuthority(value string) (string, string, bool) {
defaultPort := strconv.Itoa(currentHTTPSPort())
return normalizeAuthority(value, defaultPort)
}
func currentHTTPSPort() int {
port := config.CurrentHTTPConfig().HTTPSPort
if port < 1 || port > 65535 {
return 443
}
return port
}
func publicRecoveryBaseURL() (string, bool) {
return configuredHTTPSURL(config.CurrentMailDomains().Primary, "/rest/", "")
}
func publicRecoveryLoginURL() (string, bool) {
return configuredHTTPSURL(config.CurrentWebDomain(), "/", "/login")
}
func configuredHTTPSURL(value, path, fragment string) (string, bool) {
parsed, err := url.Parse("//" + strings.TrimSpace(value))
if err != nil || parsed.User != nil || parsed.Hostname() == "" || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
return "", false
}
host := strings.TrimSuffix(strings.ToLower(parsed.Hostname()), ".")
if host == "" {
return "", false
}
port := parsed.Port()
if port == "" && currentHTTPSPort() != 443 {
port = strconv.Itoa(currentHTTPSPort())
}
if port != "" {
portNumber, err := strconv.Atoi(port)
if err != nil || portNumber < 1 || portNumber > 65535 {
return "", false
}
host = net.JoinHostPort(host, strconv.Itoa(portNumber))
} else if strings.Contains(host, ":") {
host = "[" + host + "]"
}
return (&url.URL{Scheme: "https", Host: host, Path: path, Fragment: fragment}).String(), true
}

View File

@ -0,0 +1,186 @@
package http_server
import (
"crypto/tls"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.pcux.cn/xuans/Smail/config"
)
func configurePublicRecoveryRouteTest(t *testing.T) {
t.Helper()
oldConfig := config.Instance
oldManager := config.CurrentManager()
oldDevelopment := config.DevelopmentMode
config.SetRuntimeManager(nil)
config.DevelopmentMode = false
config.Instance = &config.Config{
SetupState: config.SetupStateReady,
Domain: "mx.example.com",
WebDomain: "web.example.com",
ExternalRecoveryEnabled: true,
HttpsPort: 443,
}
t.Cleanup(func() {
config.Instance = oldConfig
config.SetRuntimeManager(oldManager)
config.DevelopmentMode = oldDevelopment
})
}
func publicRecoveryRequest(method, target string) *http.Request {
request := httptest.NewRequest(method, target, nil)
request.TLS = &tls.ConnectionState{}
return request
}
func TestPublicRecoveryPageIsServedOnlyFromMailDomain(t *testing.T) {
configurePublicRecoveryRouteTest(t)
handler, err := newPublicRecoveryHandler()
if err != nil {
t.Fatal(err)
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, publicRecoveryRequest(http.MethodGet, "https://mx.example.com/rest/"))
if recorder.Code != http.StatusOK {
t.Fatalf("mail recovery page returned %d", recorder.Code)
}
if !strings.Contains(recorder.Body.String(), `data-login-url="https://web.example.com/#/login"`) {
t.Fatal("mail recovery page did not contain the configured web login URL")
}
if recorder.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("recovery page cache policy = %q", recorder.Header().Get("Cache-Control"))
}
recorder = httptest.NewRecorder()
handler.ServeHTTP(recorder, publicRecoveryRequest(http.MethodGet, "https://web.example.com/rest/"))
if recorder.Code != http.StatusNotFound {
t.Fatalf("web domain recovery page returned %d, want 404", recorder.Code)
}
}
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()
if err != nil {
t.Fatal(err)
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, publicRecoveryRequest(http.MethodGet, "https://web.example.com/public-password-recovery"))
if recorder.Code != http.StatusFound || recorder.Header().Get("Location") != "https://mx.example.com/rest/" {
t.Fatalf("unexpected login entry redirect: status=%d location=%q", recorder.Code, recorder.Header().Get("Location"))
}
recorder = httptest.NewRecorder()
handler.ServeHTTP(recorder, publicRecoveryRequest(http.MethodGet, "https://evil.example/public-password-recovery"))
if recorder.Code != http.StatusNotFound {
t.Fatalf("foreign login entry returned %d, want 404", recorder.Code)
}
}
func TestPublicRecoveryAPIRejectsCookiesAndCrossSiteRequests(t *testing.T) {
configurePublicRecoveryRouteTest(t)
handler, err := newPublicRecoveryHandler()
if err != nil {
t.Fatal(err)
}
cases := []struct {
name string
prepare func(*http.Request)
}{
{name: "session cookie", prepare: func(request *http.Request) { request.AddCookie(&http.Cookie{Name: "smail_session", Value: "session"}) }},
{name: "csrf header", prepare: func(request *http.Request) { request.Header.Set("X-CSRF-Token", "csrf") }},
{name: "cross site", prepare: func(request *http.Request) { request.Header.Set("Sec-Fetch-Site", "cross-site") }},
{name: "foreign origin", prepare: func(request *http.Request) { request.Header.Set("Origin", "https://evil.example") }},
{name: "wrong content type", prepare: func(request *http.Request) { request.Header.Set("Content-Type", "text/plain") }},
}
for _, test := range cases {
t.Run(test.name, func(t *testing.T) {
request := publicRecoveryRequest(http.MethodPost, "https://mx.example.com/rest/api/recovery/begin")
request.Header.Set("Content-Type", "application/json")
test.prepare(request)
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("invalid public recovery request returned %d", recorder.Code)
}
})
}
}
func TestPublicRecoveryAPIUsesNoMainSessionHandler(t *testing.T) {
configurePublicRecoveryRouteTest(t)
publicRecovery, err := newPublicRecoveryHandler()
if err != nil {
t.Fatal(err)
}
mainCalls := 0
handler := publicRecoveryOrSessionHandler(publicRecovery, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mainCalls++
w.WriteHeader(http.StatusNoContent)
}))
request := publicRecoveryRequest(http.MethodPost, "https://mx.example.com/rest/api/recovery/begin")
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if mainCalls != 0 {
t.Fatal("public recovery request entered the main session handler")
}
if recorder.Header().Get("Set-Cookie") != "" {
t.Fatalf("public recovery request set a cookie: %q", recorder.Header().Get("Set-Cookie"))
}
}
func TestPublicRecoveryStaticAssetsBypassMainSessionHandler(t *testing.T) {
configurePublicRecoveryRouteTest(t)
publicRecovery, err := newPublicRecoveryHandler()
if err != nil {
t.Fatal(err)
}
mainCalls := 0
handler := publicRecoveryOrSessionHandler(publicRecovery, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mainCalls++
w.WriteHeader(http.StatusNoContent)
}))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, publicRecoveryRequest(http.MethodGet, "https://mx.example.com/smail-logo.png"))
if recorder.Code != http.StatusOK || mainCalls != 0 {
t.Fatalf("public recovery asset used main handler: status=%d calls=%d", recorder.Code, mainCalls)
}
}

View File

@ -15,7 +15,7 @@ func TestRedirectToHTTPSPreservesPathQueryAndPort(t *testing.T) {
config.Instance = &config.Config{WebDomain: "mail.example.com", HttpsPort: 8443}
t.Cleanup(func() { config.Instance = old })
request := httptest.NewRequest(http.MethodGet, "http://old.example/a%2Fb?x=1&y=2", nil)
request := httptest.NewRequest(http.MethodGet, "http://mail.example.com/a%2Fb?x=1&y=2", nil)
recorder := httptest.NewRecorder()
redirectToHTTPS(recorder, request)
@ -78,12 +78,38 @@ func TestRedirectToHTTPSAllowsConfiguredIPHost(t *testing.T) {
t.Cleanup(func() { config.Instance = old })
recorder := httptest.NewRecorder()
redirectToHTTPS(recorder, httptest.NewRequest(http.MethodGet, "http://old.example/Reset", nil))
redirectToHTTPS(recorder, httptest.NewRequest(http.MethodGet, "http://192.0.2.10/Reset", nil))
if location := recorder.Header().Get("Location"); recorder.Code != http.StatusPermanentRedirect || location != "https://192.0.2.10/Reset" {
t.Fatalf("unexpected IP redirect: status=%d location=%q", recorder.Code, location)
}
}
func TestRedirectToHTTPSKeepsConfiguredMailDomain(t *testing.T) {
old := config.Instance
config.Instance = &config.Config{Domain: "mx.example.com", WebDomain: "web.example.com", HttpsPort: 443}
t.Cleanup(func() { config.Instance = old })
request := httptest.NewRequest(http.MethodGet, "http://mx.example.com/rest/?next=1", nil)
recorder := httptest.NewRecorder()
redirectToHTTPS(recorder, request)
if recorder.Code != http.StatusPermanentRedirect || recorder.Header().Get("Location") != "https://mx.example.com/rest/?next=1" {
t.Fatalf("unexpected mail domain redirect: status=%d location=%q", recorder.Code, recorder.Header().Get("Location"))
}
}
func TestRedirectToHTTPSRejectsUnknownRequestHost(t *testing.T) {
old := config.Instance
config.Instance = &config.Config{Domain: "mx.example.com", WebDomain: "web.example.com", HttpsPort: 443}
t.Cleanup(func() { config.Instance = old })
recorder := httptest.NewRecorder()
redirectToHTTPS(recorder, httptest.NewRequest(http.MethodGet, "http://evil.example/rest/", nil))
if recorder.Code != http.StatusUpgradeRequired || recorder.Header().Get("Location") != "" {
t.Fatalf("unknown host was redirected: status=%d location=%q", recorder.Code, recorder.Header().Get("Location"))
}
}
func TestRedirectToHTTPSRejectsUnsafeConfiguredHost(t *testing.T) {
cases := []string{
"",

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>