Compare commits
2 Commits
ef01c8a6c4
...
581e70b2ca
| Author | SHA1 | Date | |
|---|---|---|---|
| 581e70b2ca | |||
| a8a65170d3 |
@ -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
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
288
backend/listen/http_server/public_recovery.go
Normal file
288
backend/listen/http_server/public_recovery.go
Normal 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
|
||||
}
|
||||
186
backend/listen/http_server/public_recovery_test.go
Normal file
186
backend/listen/http_server/public_recovery_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
43
frontend/src/main-app.js
Normal 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')
|
||||
@ -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')
|
||||
|
||||
4
frontend/src/public-recovery.js
Normal file
4
frontend/src/public-recovery.js
Normal file
@ -0,0 +1,4 @@
|
||||
import { createApp } from 'vue'
|
||||
import PublicPasswordReset from './views/PublicPasswordReset/PublicPasswordReset.vue'
|
||||
|
||||
createApp(PublicPasswordReset).mount('#app')
|
||||
26
frontend/src/utils/publicRecoveryHttp.js
Normal file
26
frontend/src/utils/publicRecoveryHttp.js
Normal 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
|
||||
}
|
||||
}
|
||||
@ -49,7 +49,7 @@ export function usePage() {
|
||||
}
|
||||
|
||||
const goForgotPassword = () => {
|
||||
router.push({ name: "forgot-password" })
|
||||
window.location.assign('/public-password-recovery')
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
218
frontend/src/views/PublicPasswordReset/PublicPasswordReset.css
Normal file
218
frontend/src/views/PublicPasswordReset/PublicPasswordReset.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
186
frontend/src/views/PublicPasswordReset/PublicPasswordReset.js
Normal file
186
frontend/src/views/PublicPasswordReset/PublicPasswordReset.js
Normal 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
|
||||
}
|
||||
}
|
||||
111
frontend/src/views/PublicPasswordReset/PublicPasswordReset.vue
Normal file
111
frontend/src/views/PublicPasswordReset/PublicPasswordReset.vue
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user