mirror of
https://github.com/multipleof4/ntfy.git
synced 2026-10-17 00:47:54 +00:00
Phase 3+4
This commit is contained in:
+86
@@ -8,11 +8,13 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/urfave/cli/v2"
|
||||||
"github.com/urfave/cli/v2/altsrc"
|
"github.com/urfave/cli/v2/altsrc"
|
||||||
"heckel.io/ntfy/v2/db"
|
"heckel.io/ntfy/v2/db"
|
||||||
"heckel.io/ntfy/v2/db/pg"
|
"heckel.io/ntfy/v2/db/pg"
|
||||||
|
"heckel.io/ntfy/v2/mail"
|
||||||
"heckel.io/ntfy/v2/server"
|
"heckel.io/ntfy/v2/server"
|
||||||
"heckel.io/ntfy/v2/user"
|
"heckel.io/ntfy/v2/user"
|
||||||
"heckel.io/ntfy/v2/util"
|
"heckel.io/ntfy/v2/util"
|
||||||
@@ -32,6 +34,11 @@ var flagsUser = append(
|
|||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file", "H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file", "H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "database-url", Aliases: []string{"database_url"}, EnvVars: []string{"NTFY_DATABASE_URL"}, Usage: "PostgreSQL connection string for database-backed stores"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "database-url", Aliases: []string{"database_url"}, EnvVars: []string{"NTFY_DATABASE_URL"}, Usage: "PostgreSQL connection string for database-backed stores"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "base-url", Aliases: []string{"base_url", "B"}, EnvVars: []string{"NTFY_BASE_URL"}, Usage: "externally visible base URL for this host (e.g. https://ntfy.sh)"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-addr", Aliases: []string{"smtp_sender_addr"}, EnvVars: []string{"NTFY_SMTP_SENDER_ADDR"}, Usage: "SMTP server address (host:port) for outgoing emails"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-user", Aliases: []string{"smtp_sender_user"}, EnvVars: []string{"NTFY_SMTP_SENDER_USER"}, Usage: "SMTP user (if e-mail sending is enabled)"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-pass", Aliases: []string{"smtp_sender_pass"}, EnvVars: []string{"NTFY_SMTP_SENDER_PASS"}, Usage: "SMTP password (if e-mail sending is enabled)"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-from", Aliases: []string{"smtp_sender_from"}, EnvVars: []string{"NTFY_SMTP_SENDER_FROM"}, Usage: "SMTP sender address (if e-mail sending is enabled)"}),
|
||||||
)
|
)
|
||||||
|
|
||||||
var cmdUser = &cli.Command{
|
var cmdUser = &cli.Command{
|
||||||
@@ -98,6 +105,32 @@ Example:
|
|||||||
|
|
||||||
You may set the NTFY_PASSWORD environment variable to pass the new password or NTFY_PASSWORD_HASH to pass
|
You may set the NTFY_PASSWORD environment variable to pass the new password or NTFY_PASSWORD_HASH to pass
|
||||||
directly the bcrypt hash. This is useful if you are updating users via scripts.
|
directly the bcrypt hash. This is useful if you are updating users via scripts.
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "password-reset",
|
||||||
|
Aliases: []string{"reset"},
|
||||||
|
Usage: "Generates a password reset link for a user",
|
||||||
|
UsageText: "ntfy user password-reset [--send-email] USERNAME",
|
||||||
|
Action: execUserPasswordReset,
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.BoolFlag{Name: "send-email", Aliases: []string{"e"}, Usage: "also email the reset link to the user's primary email"},
|
||||||
|
},
|
||||||
|
Description: `Generate a password reset link for the given user and print it to stdout.
|
||||||
|
|
||||||
|
The user completes the reset by opening the link in a browser and choosing a new password;
|
||||||
|
the admin never learns or chooses the new password. The link is single-use and expires after
|
||||||
|
one hour. This is an admin override of the self-service reset flow -- unlike self-service, it
|
||||||
|
does not require the user to have a verified primary email (the token is bound to the user).
|
||||||
|
|
||||||
|
With --send-email, the link is additionally emailed to the user's primary email address (this
|
||||||
|
requires SMTP to be configured and the user to have a verified primary email).
|
||||||
|
|
||||||
|
Requires base-url to be configured so an absolute link can be generated.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
ntfy user password-reset phil # Print a reset link for user phil
|
||||||
|
ntfy user password-reset --send-email phil # Print and email the reset link
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -286,6 +319,59 @@ func execUserChangePass(c *cli.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func execUserPasswordReset(c *cli.Context) error {
|
||||||
|
username := c.Args().Get(0)
|
||||||
|
sendEmail := c.Bool("send-email")
|
||||||
|
baseURL := strings.TrimSuffix(c.String("base-url"), "/")
|
||||||
|
if username == "" {
|
||||||
|
return errors.New("username expected, type 'ntfy user password-reset --help' for help")
|
||||||
|
} else if username == userEveryone || username == user.Everyone {
|
||||||
|
return errors.New("username not allowed")
|
||||||
|
} else if baseURL == "" {
|
||||||
|
return errors.New("base-url must be configured to generate a reset link")
|
||||||
|
}
|
||||||
|
manager, err := createUserManager(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u, err := manager.User(username)
|
||||||
|
if errors.Is(err, user.ErrUserNotFound) {
|
||||||
|
return fmt.Errorf("user %s does not exist", username)
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Resolve the primary email up front if we need to send -- fail before creating a token
|
||||||
|
var primaryEmail string
|
||||||
|
if sendEmail {
|
||||||
|
primaryEmail, err = manager.PrimaryEmail(u.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if primaryEmail == "" {
|
||||||
|
return fmt.Errorf("user %s has no primary email; cannot send reset link (omit --send-email to just print it)", username)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The reset token is bound to the user, not an email -- so this works even with no SMTP
|
||||||
|
token, err := manager.CreateMagicLink(user.MagicLinkKindPasswordReset, u.ID, "", time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
link := baseURL + "/account/password/reset/" + token
|
||||||
|
fmt.Fprintln(c.App.Writer, link)
|
||||||
|
if sendEmail {
|
||||||
|
sender := mail.NewSender(&mail.Config{
|
||||||
|
SMTPAddr: c.String("smtp-sender-addr"),
|
||||||
|
SMTPUser: c.String("smtp-sender-user"),
|
||||||
|
SMTPPass: c.String("smtp-sender-pass"),
|
||||||
|
From: c.String("smtp-sender-from"),
|
||||||
|
})
|
||||||
|
if err := sender.SendPasswordReset(primaryEmail, link); err != nil {
|
||||||
|
return fmt.Errorf("failed to send reset email to %s: %w", primaryEmail, err)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(c.App.ErrWriter, "reset link emailed to %s\n", primaryEmail)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func execUserChangeRole(c *cli.Context) error {
|
func execUserChangeRole(c *cli.Context) error {
|
||||||
username := c.Args().Get(0)
|
username := c.Args().Get(0)
|
||||||
role := user.Role(c.Args().Get(1))
|
role := user.Role(c.Args().Get(1))
|
||||||
|
|||||||
@@ -122,6 +122,49 @@ func TestCLI_User_Delete(t *testing.T) {
|
|||||||
require.Contains(t, err.Error(), "user phil does not exist")
|
require.Contains(t, err.Error(), "user phil does not exist")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCLI_User_PasswordReset(t *testing.T) {
|
||||||
|
s, conf, port := newTestServerWithAuth(t)
|
||||||
|
defer test.StopServer(t, s, port)
|
||||||
|
|
||||||
|
app, stdin, stdout, _ := newTestApp()
|
||||||
|
stdin.WriteString("mypass\nmypass")
|
||||||
|
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
||||||
|
|
||||||
|
// Prints a working-looking reset link when base-url is set
|
||||||
|
app, _, stdout, _ = newTestApp()
|
||||||
|
require.Nil(t, runUserCommand(app, conf, "--base-url=https://ntfy.example.com", "password-reset", "phil"))
|
||||||
|
require.Contains(t, stdout.String(), "https://ntfy.example.com/account/password/reset/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCLI_User_PasswordReset_NoBaseURL(t *testing.T) {
|
||||||
|
s, conf, port := newTestServerWithAuth(t)
|
||||||
|
defer test.StopServer(t, s, port)
|
||||||
|
|
||||||
|
app, stdin, _, _ := newTestApp()
|
||||||
|
stdin.WriteString("mypass\nmypass")
|
||||||
|
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
||||||
|
|
||||||
|
app, _, _, _ = newTestApp()
|
||||||
|
err := runUserCommand(app, conf, "password-reset", "phil")
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "base-url")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCLI_User_PasswordReset_SendEmailNoPrimary(t *testing.T) {
|
||||||
|
s, conf, port := newTestServerWithAuth(t)
|
||||||
|
defer test.StopServer(t, s, port)
|
||||||
|
|
||||||
|
app, stdin, _, _ := newTestApp()
|
||||||
|
stdin.WriteString("mypass\nmypass")
|
||||||
|
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
||||||
|
|
||||||
|
// --send-email requires a primary email; phil has none
|
||||||
|
app, _, _, _ = newTestApp()
|
||||||
|
err := runUserCommand(app, conf, "--base-url=https://ntfy.example.com", "password-reset", "--send-email", "phil")
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "no primary email")
|
||||||
|
}
|
||||||
|
|
||||||
func newTestServerWithAuth(t *testing.T) (s *server.Server, conf *server.Config, port int) {
|
func newTestServerWithAuth(t *testing.T) (s *server.Server, conf *server.Config, port int) {
|
||||||
configFile := filepath.Join(t.TempDir(), "server-dummy.yml")
|
configFile := filepath.Join(t.TempDir(), "server-dummy.yml")
|
||||||
require.Nil(t, os.WriteFile(configFile, []byte(""), 0600)) // Dummy config file to avoid lookup of real server.yml
|
require.Nil(t, os.WriteFile(configFile, []byte(""), 0600)) // Dummy config file to avoid lookup of real server.yml
|
||||||
|
|||||||
@@ -1948,6 +1948,23 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
|||||||
|
|
||||||
## Not released yet
|
## Not released yet
|
||||||
|
|
||||||
|
### ntfy server v2.25.0 (UNRELEASED)
|
||||||
|
|
||||||
|
This release adds **password reset** via email, and reworks email verification to use durable,
|
||||||
|
link-based magic links (replacing the old in-memory 6-digit codes). Email stays optional at
|
||||||
|
signup; a user can reset their password only once they have a verified "primary" (recovery)
|
||||||
|
email. All of this rides on the existing SMTP configuration -- no new config flag.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
* Add password reset via emailed magic link, with a "Forgot password?" link on the login page and a `ntfy user password-reset` CLI command for admins
|
||||||
|
* Rework email verification to use durable, single-use, expiring magic links instead of in-memory 6-digit codes, and add a "primary" (recovery) email with verified/unverified state in the account UI
|
||||||
|
* Auto-send a verification link to the billing email after a Stripe checkout, so paying users can set up password recovery
|
||||||
|
|
||||||
|
**Bug fixes + maintenance:**
|
||||||
|
|
||||||
|
* Generate access tokens, IDs, and magic-link tokens with a cryptographically secure RNG (`crypto/rand`) instead of a clock-seeded PRNG
|
||||||
|
|
||||||
### ntfy Android v1.25.x (UNRELEASED)
|
### ntfy Android v1.25.x (UNRELEASED)
|
||||||
|
|
||||||
This release makes the "connection lost" alert configurable and turns it off by default. Folks did not like it and many reached out
|
This release makes the "connection lost" alert configurable and turns it off by default. Folks did not like it and many reached out
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ var (
|
|||||||
errHTTPBadRequestEmailVerificationCodeInvalid = &errHTTP{40051, http.StatusBadRequest, "invalid request: email verification link invalid or expired", "", nil}
|
errHTTPBadRequestEmailVerificationCodeInvalid = &errHTTP{40051, http.StatusBadRequest, "invalid request: email verification link invalid or expired", "", nil}
|
||||||
errHTTPBadRequestEmailAddressNotVerified = &errHTTP{40052, http.StatusBadRequest, "invalid request: email address not verified", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil}
|
errHTTPBadRequestEmailAddressNotVerified = &errHTTP{40052, http.StatusBadRequest, "invalid request: email address not verified", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil}
|
||||||
errHTTPBadRequestAnonymousEmailNotAllowed = &errHTTP{40053, http.StatusBadRequest, "invalid request: anonymous email sending is not allowed", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil}
|
errHTTPBadRequestAnonymousEmailNotAllowed = &errHTTP{40053, http.StatusBadRequest, "invalid request: anonymous email sending is not allowed", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil}
|
||||||
|
errHTTPBadRequestResetLinkInvalid = &errHTTP{40054, http.StatusBadRequest, "invalid request: password reset link invalid or expired", "", nil}
|
||||||
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
|
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
|
||||||
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil}
|
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil}
|
||||||
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}
|
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}
|
||||||
|
|||||||
+24
-16
@@ -80,19 +80,20 @@ type handleFunc func(http.ResponseWriter, *http.Request, *visitor) error
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
// If changed, don't forget to update Android App and auth_sqlite.go
|
// If changed, don't forget to update Android App and auth_sqlite.go
|
||||||
topicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No /!
|
topicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No /!
|
||||||
topicPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}$`) // Regex must match JS & Android app!
|
topicPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}$`) // Regex must match JS & Android app!
|
||||||
externalTopicPathRegex = regexp.MustCompile(`^/[^/]+\.[^/]+/[-_A-Za-z0-9]{1,64}$`) // Extended topic path, for web-app, e.g. /example.com/mytopic
|
externalTopicPathRegex = regexp.MustCompile(`^/[^/]+\.[^/]+/[-_A-Za-z0-9]{1,64}$`) // Extended topic path, for web-app, e.g. /example.com/mytopic
|
||||||
webAppEmailVerifyRegex = regexp.MustCompile(`^/account/email/verify/[-_A-Za-z0-9]+$`) // Magic-link landing (served by the web app)
|
webAppEmailVerifyRegex = regexp.MustCompile(`^/account/email/verify/[-_A-Za-z0-9]+$`) // Magic-link landing (served by the web app)
|
||||||
jsonPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/json$`)
|
webAppPasswordResetRegex = regexp.MustCompile(`^/account/password/reset/[-_A-Za-z0-9]+$`) // Password-reset landing (served by the web app)
|
||||||
ssePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/sse$`)
|
jsonPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/json$`)
|
||||||
rawPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/raw$`)
|
ssePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/sse$`)
|
||||||
wsPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/ws$`)
|
rawPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/raw$`)
|
||||||
authPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`)
|
wsPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/ws$`)
|
||||||
publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`)
|
authPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`)
|
||||||
updatePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}$`)
|
publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`)
|
||||||
clearPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}/(read|clear)$`)
|
updatePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}$`)
|
||||||
sequenceIDRegex = topicRegex
|
clearPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}/(read|clear)$`)
|
||||||
|
sequenceIDRegex = topicRegex
|
||||||
|
|
||||||
webConfigPath = "/config.js"
|
webConfigPath = "/config.js"
|
||||||
webManifestPath = "/manifest.webmanifest"
|
webManifestPath = "/manifest.webmanifest"
|
||||||
@@ -119,7 +120,10 @@ var (
|
|||||||
apiAccountEmailVerifyPath = "/v1/account/email/verify"
|
apiAccountEmailVerifyPath = "/v1/account/email/verify"
|
||||||
apiAccountEmailPrimaryPath = "/v1/account/email/primary"
|
apiAccountEmailPrimaryPath = "/v1/account/email/primary"
|
||||||
apiAccountEmailResendPath = "/v1/account/email/resend"
|
apiAccountEmailResendPath = "/v1/account/email/resend"
|
||||||
webAppEmailVerifyPathPrefix = "/account/email/verify/" // Browser landing route; raw token appended
|
apiAccountPasswordResetRequestPath = "/v1/account/password/reset/request"
|
||||||
|
apiAccountPasswordResetPath = "/v1/account/password/reset"
|
||||||
|
webAppEmailVerifyPathPrefix = "/account/email/verify/" // Browser landing route; raw token appended
|
||||||
|
webAppPasswordResetPathPrefix = "/account/password/reset/" // Browser landing route; raw token appended
|
||||||
apiAccountBillingPortalPath = "/v1/account/billing/portal"
|
apiAccountBillingPortalPath = "/v1/account/billing/portal"
|
||||||
apiAccountBillingWebhookPath = "/v1/account/billing/webhook"
|
apiAccountBillingWebhookPath = "/v1/account/billing/webhook"
|
||||||
apiAccountBillingSubscriptionPath = "/v1/account/billing/subscription"
|
apiAccountBillingSubscriptionPath = "/v1/account/billing/subscription"
|
||||||
@@ -626,6 +630,10 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
|||||||
return s.ensureUser(s.withAccountSync(s.handleAccountEmailSetPrimary))(w, r, v)
|
return s.ensureUser(s.withAccountSync(s.handleAccountEmailSetPrimary))(w, r, v)
|
||||||
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountEmailResendPath {
|
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountEmailResendPath {
|
||||||
return s.ensureUser(s.ensureEmailsEnabled(s.handleAccountEmailResend))(w, r, v)
|
return s.ensureUser(s.ensureEmailsEnabled(s.handleAccountEmailResend))(w, r, v)
|
||||||
|
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountPasswordResetRequestPath {
|
||||||
|
return s.ensureEmailsEnabled(s.limitRequests(s.handleAccountPasswordResetRequest))(w, r, v) // Unauthenticated
|
||||||
|
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountPasswordResetPath {
|
||||||
|
return s.ensureEmailsEnabled(s.limitRequests(s.handleAccountPasswordReset))(w, r, v) // Unauthenticated
|
||||||
} else if r.Method == http.MethodPost && apiWebPushPath == r.URL.Path {
|
} else if r.Method == http.MethodPost && apiWebPushPath == r.URL.Path {
|
||||||
return s.ensureWebPushEnabled(s.limitRequests(s.handleWebPushUpdate))(w, r, v)
|
return s.ensureWebPushEnabled(s.limitRequests(s.handleWebPushUpdate))(w, r, v)
|
||||||
} else if r.Method == http.MethodDelete && apiWebPushPath == r.URL.Path {
|
} else if r.Method == http.MethodDelete && apiWebPushPath == r.URL.Path {
|
||||||
@@ -668,8 +676,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
|||||||
return s.limitRequests(s.authorizeTopicRead(s.handleSubscribeWS))(w, r, v)
|
return s.limitRequests(s.authorizeTopicRead(s.handleSubscribeWS))(w, r, v)
|
||||||
} else if r.Method == http.MethodGet && authPathRegex.MatchString(r.URL.Path) {
|
} else if r.Method == http.MethodGet && authPathRegex.MatchString(r.URL.Path) {
|
||||||
return s.limitRequests(s.authorizeTopicRead(s.handleTopicAuth))(w, r, v)
|
return s.limitRequests(s.authorizeTopicRead(s.handleTopicAuth))(w, r, v)
|
||||||
} else if r.Method == http.MethodGet && webAppEmailVerifyRegex.MatchString(r.URL.Path) {
|
} else if r.Method == http.MethodGet && (webAppEmailVerifyRegex.MatchString(r.URL.Path) || webAppPasswordResetRegex.MatchString(r.URL.Path)) {
|
||||||
return s.ensureWebEnabled(s.handleWebAppIndex)(w, r, v) // Magic-link landing page (client-side route)
|
return s.ensureWebEnabled(s.handleWebAppIndex)(w, r, v) // Magic-link landing pages (client-side routes)
|
||||||
} else if r.Method == http.MethodGet && (topicPathRegex.MatchString(r.URL.Path) || externalTopicPathRegex.MatchString(r.URL.Path)) {
|
} else if r.Method == http.MethodGet && (topicPathRegex.MatchString(r.URL.Path) || externalTopicPathRegex.MatchString(r.URL.Path)) {
|
||||||
return s.ensureWebEnabled(s.handleTopic)(w, r, v)
|
return s.ensureWebEnabled(s.handleTopic)(w, r, v)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const (
|
|||||||
syncTopicAccountSyncEvent = "sync"
|
syncTopicAccountSyncEvent = "sync"
|
||||||
tokenExpiryDuration = 72 * time.Hour // Extend tokens by this much
|
tokenExpiryDuration = 72 * time.Hour // Extend tokens by this much
|
||||||
emailVerificationTokenExpiry = 24 * time.Hour // Magic-link lifetime for email verification
|
emailVerificationTokenExpiry = 24 * time.Hour // Magic-link lifetime for email verification
|
||||||
|
passwordResetTokenExpiry = time.Hour // Magic-link lifetime for password reset (higher-privilege -> shorter)
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
@@ -771,6 +772,77 @@ func (s *Server) enqueueEmailVerification(userID, email string) error {
|
|||||||
return s.mailSender.SendEmailVerification(email, link)
|
return s.mailSender.SendEmailVerification(email, link)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleAccountPasswordResetRequest starts a password reset (POST /v1/account/password/reset/request,
|
||||||
|
// unauthenticated). It resolves the identifier (username or primary email) to at most one account
|
||||||
|
// and emails a reset link to that account's primary email. The response is always a uniform 200,
|
||||||
|
// regardless of whether anything matched, so it cannot be used to probe for accounts.
|
||||||
|
func (s *Server) handleAccountPasswordResetRequest(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
req, err := readJSONWithLimit[apiAccountPasswordResetRequest](r.Body, jsonBodyBytesLimit, false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Rate limit via the shared per-visitor account-creation bucket (no new limiter/config)
|
||||||
|
if !v.AccountCreationAllowed() {
|
||||||
|
return errHTTPTooManyRequestsLimitAccountCreation
|
||||||
|
}
|
||||||
|
v.AccountCreated() // Consume a token on every request (including no-match), to throttle probing
|
||||||
|
identifier := strings.TrimSpace(req.Identifier)
|
||||||
|
if identifier != "" && s.config.BaseURL != "" {
|
||||||
|
if userID, email, ok := s.resolveResetTarget(identifier); ok {
|
||||||
|
token, err := s.userManager.CreateMagicLink(user.MagicLinkKindPasswordReset, userID, "", passwordResetTokenExpiry)
|
||||||
|
if err != nil {
|
||||||
|
logvr(v, r).Tag(tagAccount).Err(err).Warn("Failed to create password reset token")
|
||||||
|
} else {
|
||||||
|
link := s.config.BaseURL + webAppPasswordResetPathPrefix + token
|
||||||
|
logvr(v, r).Tag(tagAccount).Field("user_id", userID).Info("Sending password reset link")
|
||||||
|
if err := s.mailSender.SendPasswordReset(email, link); err != nil {
|
||||||
|
logvr(v, r).Tag(tagAccount).Err(err).Warn("Failed to send password reset email")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logvr(v, r).Tag(tagAccount).Debug("Password reset requested for unknown identifier (uniform response)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveResetTarget resolves a reset identifier to a single account and its primary email.
|
||||||
|
// The identifier is tried first as a username, then as a primary email address. It returns
|
||||||
|
// ok=false if no account with a primary email matches (reset requires a verified primary email).
|
||||||
|
func (s *Server) resolveResetTarget(identifier string) (userID string, email string, ok bool) {
|
||||||
|
if u, err := s.userManager.User(identifier); err == nil && u != nil {
|
||||||
|
if primary, perr := s.userManager.PrimaryEmail(u.ID); perr == nil && primary != "" {
|
||||||
|
return u.ID, primary, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if uid, err := s.userManager.UserIDByPrimaryEmail(identifier); err == nil {
|
||||||
|
return uid, identifier, true
|
||||||
|
}
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAccountPasswordReset performs the reset (POST /v1/account/password/reset, unauthenticated):
|
||||||
|
// it validates the token and sets the new password. Existing access tokens stay valid.
|
||||||
|
func (s *Server) handleAccountPasswordReset(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
req, err := readJSONWithLimit[apiAccountPasswordResetConfirmRequest](r.Body, jsonBodyBytesLimit, false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if req.Token == "" {
|
||||||
|
return errHTTPBadRequestResetLinkInvalid
|
||||||
|
} else if req.Password == "" {
|
||||||
|
return errHTTPBadRequest
|
||||||
|
}
|
||||||
|
err = s.userManager.ResetPassword(req.Token, req.Password)
|
||||||
|
if errors.Is(err, user.ErrMagicLinkNotFound) {
|
||||||
|
return errHTTPBadRequestResetLinkInvalid
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
logvr(v, r).Tag(tagAccount).Info("Password reset performed")
|
||||||
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
|
}
|
||||||
|
|
||||||
// convertEmailAddress checks the email address against the user's verified email list.
|
// convertEmailAddress checks the email address against the user's verified email list.
|
||||||
// If smtp-sender-verify is false (default), the email is passed through as-is for
|
// If smtp-sender-verify is false (default), the email is passed through as-is for
|
||||||
// backwards compatibility. If true, the user must be authenticated and the email must be
|
// backwards compatibility. If true, the user must be authenticated and the email must be
|
||||||
|
|||||||
@@ -173,6 +173,89 @@ func TestAccount_Email_SetPrimaryCollision(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// verifyEmailFor runs the full add->click flow so the user ends up with a verified primary email.
|
||||||
|
func verifyEmailFor(t *testing.T, s *Server, mailer *captureMailer, auth map[string]string, email string) {
|
||||||
|
require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", fmt.Sprintf(`{"email":"%s"}`, email), auth).Code)
|
||||||
|
token := tokenFromLink(t, mailer.verifyLinks[email], "https://ntfy.example.com/account/email/verify/")
|
||||||
|
require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, token), nil).Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// canLogin returns true if username/password authenticates (via the token-create endpoint).
|
||||||
|
func canLogin(t *testing.T, s *Server, username, password string) bool {
|
||||||
|
rr := request(t, s, "POST", "/v1/account/token", "", map[string]string{"Authorization": util.BasicAuth(username, password)})
|
||||||
|
return rr.Code == 200
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccount_PasswordReset_ByUsername(t *testing.T) {
|
||||||
|
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||||
|
s, mailer, auth := newEmailTestServer(t, databaseURL)
|
||||||
|
defer s.closeDatabases()
|
||||||
|
verifyEmailFor(t, s, mailer, auth, "ben@example.com")
|
||||||
|
|
||||||
|
// Request reset by username
|
||||||
|
rr := request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"ben"}`, nil)
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
token := tokenFromLink(t, mailer.resetLinks["ben@example.com"], "https://ntfy.example.com/account/password/reset/")
|
||||||
|
|
||||||
|
// Confirm with a new password
|
||||||
|
rr = request(t, s, "POST", "/v1/account/password/reset", fmt.Sprintf(`{"token":"%s","password":"brandnew"}`, token), nil)
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
require.True(t, canLogin(t, s, "ben", "brandnew"))
|
||||||
|
require.False(t, canLogin(t, s, "ben", "ben"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccount_PasswordReset_ByEmail(t *testing.T) {
|
||||||
|
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||||
|
s, mailer, auth := newEmailTestServer(t, databaseURL)
|
||||||
|
defer s.closeDatabases()
|
||||||
|
verifyEmailFor(t, s, mailer, auth, "ben@example.com")
|
||||||
|
|
||||||
|
rr := request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"ben@example.com"}`, nil)
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
token := tokenFromLink(t, mailer.resetLinks["ben@example.com"], "https://ntfy.example.com/account/password/reset/")
|
||||||
|
rr = request(t, s, "POST", "/v1/account/password/reset", fmt.Sprintf(`{"token":"%s","password":"brandnew"}`, token), nil)
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
require.True(t, canLogin(t, s, "ben", "brandnew"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccount_PasswordReset_UnknownIdentifierUniform(t *testing.T) {
|
||||||
|
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||||
|
s, mailer, _ := newEmailTestServer(t, databaseURL)
|
||||||
|
defer s.closeDatabases()
|
||||||
|
|
||||||
|
// Unknown identifier still returns a uniform 200, and no email is sent
|
||||||
|
rr := request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"ghost"}`, nil)
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
require.Empty(t, mailer.resetLinks)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccount_PasswordReset_NoPrimaryEmailNoSend(t *testing.T) {
|
||||||
|
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||||
|
s, mailer, _ := newEmailTestServer(t, databaseURL)
|
||||||
|
defer s.closeDatabases()
|
||||||
|
|
||||||
|
// ben exists but has no verified primary email -> uniform 200, nothing sent
|
||||||
|
rr := request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"ben"}`, nil)
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
require.Empty(t, mailer.resetLinks)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccount_PasswordReset_InvalidToken(t *testing.T) {
|
||||||
|
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||||
|
s, _, _ := newEmailTestServer(t, databaseURL)
|
||||||
|
defer s.closeDatabases()
|
||||||
|
|
||||||
|
rr := request(t, s, "POST", "/v1/account/password/reset", `{"token":"nope","password":"brandnew"}`, nil)
|
||||||
|
require.Equal(t, 400, rr.Code)
|
||||||
|
require.Equal(t, 40054, toHTTPError(t, rr.Body.String()).Code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestAccount_Email_AddDuplicateVerified(t *testing.T) {
|
func TestAccount_Email_AddDuplicateVerified(t *testing.T) {
|
||||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||||
s, mailer, auth := newEmailTestServer(t, databaseURL)
|
s, mailer, auth := newEmailTestServer(t, databaseURL)
|
||||||
|
|||||||
@@ -237,10 +237,40 @@ func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWr
|
|||||||
if err := s.updateSubscriptionAndTier(r, v, u, tier, sess.Customer.ID, sub.ID, string(sub.Status), string(interval), sub.CurrentPeriodEnd, sub.CancelAt); err != nil {
|
if err := s.updateSubscriptionAndTier(r, v, u, tier, sess.Customer.ID, sub.ID, string(sub.Status), string(interval), sub.CurrentPeriodEnd, sub.CancelAt); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// Offer email recovery: auto-send a verification link to the billing email (best-effort).
|
||||||
|
if sess.CustomerDetails != nil {
|
||||||
|
s.maybeEnqueueBillingEmailVerification(r, v, u.ID, sess.CustomerDetails.Email)
|
||||||
|
}
|
||||||
http.Redirect(w, r, s.config.BaseURL+accountPath, http.StatusSeeOther)
|
http.Redirect(w, r, s.config.BaseURL+accountPath, http.StatusSeeOther)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maybeEnqueueBillingEmailVerification sends an email-verification link to a paying user's
|
||||||
|
// billing email, so they can use it for password recovery -- but only if they have no verified
|
||||||
|
// email yet and the billing email is not already the recovery email on another account. On a
|
||||||
|
// collision (or any other skip), the generic "no recovery email set" warning on the account page
|
||||||
|
// nudges the user to add one. This is best-effort: failures are logged, never surfaced.
|
||||||
|
func (s *Server) maybeEnqueueBillingEmailVerification(r *http.Request, v *visitor, userID, billingEmail string) {
|
||||||
|
if s.mailSender == nil || s.config.BaseURL == "" || billingEmail == "" || !emailAddressRegex.MatchString(billingEmail) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emails, err := s.userManager.Emails(userID)
|
||||||
|
if err != nil {
|
||||||
|
logvr(v, r).Tag(tagStripe).Err(err).Warn("Failed to load emails for billing verification")
|
||||||
|
return
|
||||||
|
} else if len(emails) > 0 {
|
||||||
|
return // User already has a verified email -- don't nag
|
||||||
|
}
|
||||||
|
if _, err := s.userManager.UserIDByPrimaryEmail(billingEmail); err == nil {
|
||||||
|
logvr(v, r).Tag(tagStripe).Debug("Billing email is primary on another account, skipping auto-verification")
|
||||||
|
return // Collision: skip + let the generic no-recovery-email warning nudge instead
|
||||||
|
}
|
||||||
|
logvr(v, r).Tag(tagStripe).Field("email", billingEmail).Info("Sending verification link to billing email")
|
||||||
|
if err := s.enqueueEmailVerification(userID, billingEmail); err != nil {
|
||||||
|
logvr(v, r).Tag(tagStripe).Err(err).Warn("Failed to enqueue billing email verification")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// handleAccountBillingSubscriptionUpdate updates an existing Stripe subscription to a new price, and updates
|
// handleAccountBillingSubscriptionUpdate updates an existing Stripe subscription to a new price, and updates
|
||||||
// a user's tier accordingly. This endpoint only works if there is an existing subscription.
|
// a user's tier accordingly. This endpoint only works if there is an existing subscription.
|
||||||
func (s *Server) handleAccountBillingSubscriptionUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
func (s *Server) handleAccountBillingSubscriptionUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
//go:build !nopayments
|
||||||
|
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stripe/stripe-go/v74"
|
||||||
|
"heckel.io/ntfy/v2/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stripeCheckoutMock wires up a testStripeAPI for a successful checkout of user u, with the given
|
||||||
|
// billing email on the session's CustomerDetails.
|
||||||
|
func stripeCheckoutMock(u *user.User, billingEmail string) *testStripeAPI {
|
||||||
|
m := &testStripeAPI{}
|
||||||
|
m.On("GetSession", "SOMETOKEN").Return(&stripe.CheckoutSession{
|
||||||
|
ClientReferenceID: u.ID,
|
||||||
|
Customer: &stripe.Customer{ID: "acct_5555"},
|
||||||
|
Subscription: &stripe.Subscription{ID: "sub_1234"},
|
||||||
|
CustomerDetails: &stripe.CheckoutSessionCustomerDetails{Email: billingEmail},
|
||||||
|
}, nil)
|
||||||
|
m.On("GetSubscription", "sub_1234").Return(&stripe.Subscription{
|
||||||
|
ID: "sub_1234",
|
||||||
|
Status: stripe.SubscriptionStatusActive,
|
||||||
|
CurrentPeriodEnd: 123456789,
|
||||||
|
Items: &stripe.SubscriptionItemList{
|
||||||
|
Data: []*stripe.SubscriptionItem{
|
||||||
|
{Price: &stripe.Price{ID: "price_1234", Recurring: &stripe.PriceRecurring{Interval: stripe.PriceRecurringIntervalMonth}}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil)
|
||||||
|
m.On("UpdateCustomer", "acct_5555", mock.Anything).Return(&stripe.Customer{}, nil)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCheckoutEmailTestServer(t *testing.T, databaseURL string) (*Server, *captureMailer, *user.User) {
|
||||||
|
c := newTestConfigWithAuthFile(t, databaseURL)
|
||||||
|
c.StripeSecretKey = "secret key"
|
||||||
|
c.BaseURL = "https://ntfy.example.com"
|
||||||
|
c.SMTPSenderAddr = "localhost:25"
|
||||||
|
c.SMTPSenderFrom = "noreply@example.com"
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
mailer := newCaptureMailer()
|
||||||
|
s.mailSender = mailer
|
||||||
|
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||||
|
ID: "ti_123", Code: "starter", StripeMonthlyPriceID: "price_1234", MessageLimit: 100, MessageExpiryDuration: time.Hour,
|
||||||
|
}))
|
||||||
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||||
|
u, err := s.userManager.User("phil")
|
||||||
|
require.Nil(t, err)
|
||||||
|
return s, mailer, u
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPayments_Checkout_SendsBillingEmailVerification(t *testing.T) {
|
||||||
|
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||||
|
s, mailer, u := newCheckoutEmailTestServer(t, databaseURL)
|
||||||
|
defer s.closeDatabases()
|
||||||
|
s.stripe = stripeCheckoutMock(u, "billing@example.com")
|
||||||
|
|
||||||
|
rr := request(t, s, "GET", "/v1/account/billing/subscription/success/SOMETOKEN", "", nil)
|
||||||
|
require.Equal(t, 303, rr.Code)
|
||||||
|
|
||||||
|
// A verification link was auto-sent to the billing email; clicking it verifies + sets primary
|
||||||
|
link := mailer.verifyLinks["billing@example.com"]
|
||||||
|
require.NotEmpty(t, link)
|
||||||
|
token := tokenFromLink(t, link, "https://ntfy.example.com/account/email/verify/")
|
||||||
|
require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, token), nil).Code)
|
||||||
|
|
||||||
|
emails, err := s.userManager.Emails(u.ID)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, []string{"billing@example.com"}, emails)
|
||||||
|
primary, err := s.userManager.PrimaryEmail(u.ID)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "billing@example.com", primary)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPayments_Checkout_SkipsBillingEmailWhenAlreadyVerified(t *testing.T) {
|
||||||
|
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||||
|
s, mailer, u := newCheckoutEmailTestServer(t, databaseURL)
|
||||||
|
defer s.closeDatabases()
|
||||||
|
s.stripe = stripeCheckoutMock(u, "billing@example.com")
|
||||||
|
|
||||||
|
// User already has a verified email -> no auto-send on checkout
|
||||||
|
require.Nil(t, s.userManager.AddEmail(u.ID, "existing@example.com"))
|
||||||
|
|
||||||
|
rr := request(t, s, "GET", "/v1/account/billing/subscription/success/SOMETOKEN", "", nil)
|
||||||
|
require.Equal(t, 303, rr.Code)
|
||||||
|
require.Empty(t, mailer.verifyLinks)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPayments_Checkout_SkipsBillingEmailWhenPrimaryElsewhere(t *testing.T) {
|
||||||
|
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||||
|
s, mailer, u := newCheckoutEmailTestServer(t, databaseURL)
|
||||||
|
defer s.closeDatabases()
|
||||||
|
s.stripe = stripeCheckoutMock(u, "billing@example.com")
|
||||||
|
|
||||||
|
// The billing email is already the recovery email on another account -> skip
|
||||||
|
require.Nil(t, s.userManager.AddUser("alice", "alice", user.RoleUser, false))
|
||||||
|
alice, err := s.userManager.User("alice")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Nil(t, s.userManager.AddEmail(alice.ID, "billing@example.com"))
|
||||||
|
require.Nil(t, s.userManager.SetPrimaryEmail(alice.ID, "billing@example.com"))
|
||||||
|
|
||||||
|
rr := request(t, s, "GET", "/v1/account/billing/subscription/success/SOMETOKEN", "", nil)
|
||||||
|
require.Equal(t, 303, rr.Code)
|
||||||
|
require.Empty(t, mailer.verifyLinks)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -238,6 +238,19 @@ type apiAccountEmailVerifyRequest struct {
|
|||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// apiAccountPasswordResetRequest is the body of the (unauthenticated) reset-request endpoint.
|
||||||
|
// The identifier is a username or a primary email address.
|
||||||
|
type apiAccountPasswordResetRequest struct {
|
||||||
|
Identifier string `json:"identifier"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// apiAccountPasswordResetConfirmRequest is the body of the (unauthenticated) reset-confirm
|
||||||
|
// endpoint, submitted from the set-new-password landing page.
|
||||||
|
type apiAccountPasswordResetConfirmRequest struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
type apiAccountTier struct {
|
type apiAccountTier struct {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|||||||
@@ -239,6 +239,71 @@ func TestUser_MagicLink_Reaper(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUser_MagicLink_ResetPassword(t *testing.T) {
|
||||||
|
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
|
||||||
|
a := newTestManager(t, newManager, PermissionDenyAll)
|
||||||
|
require.Nil(t, a.AddUser("phil", "oldpass", RoleUser, false))
|
||||||
|
phil, err := a.User("phil")
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
raw, err := a.CreateMagicLink(MagicLinkKindPasswordReset, phil.ID, "", time.Hour)
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
// Old password works before reset
|
||||||
|
_, err = a.Authenticate("phil", "oldpass")
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
require.Nil(t, a.ResetPassword(raw, "newpass"))
|
||||||
|
|
||||||
|
// New password works, old does not
|
||||||
|
_, err = a.Authenticate("phil", "newpass")
|
||||||
|
require.Nil(t, err)
|
||||||
|
_, err = a.Authenticate("phil", "oldpass")
|
||||||
|
require.ErrorIs(t, err, ErrUnauthenticated)
|
||||||
|
|
||||||
|
// Token is single-use
|
||||||
|
require.ErrorIs(t, a.ResetPassword(raw, "againpass"), ErrMagicLinkNotFound)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUser_MagicLink_ResetPassword_WrongKindRejected(t *testing.T) {
|
||||||
|
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
|
||||||
|
a := newTestManager(t, newManager, PermissionDenyAll)
|
||||||
|
require.Nil(t, a.AddUser("phil", "oldpass", RoleUser, false))
|
||||||
|
phil, err := a.User("phil")
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
// An email-verification token must not be usable for password reset...
|
||||||
|
verifyToken := addVerifyLink(t, a, phil.ID, "phil@example.com", time.Hour)
|
||||||
|
require.ErrorIs(t, a.ResetPassword(verifyToken, "newpass"), ErrMagicLinkNotFound)
|
||||||
|
|
||||||
|
// ...and a reset token must not be usable for email verification
|
||||||
|
resetToken, err := a.CreateMagicLink(MagicLinkKindPasswordReset, phil.ID, "", time.Hour)
|
||||||
|
require.Nil(t, err)
|
||||||
|
_, err = a.VerifyEmail(resetToken)
|
||||||
|
require.ErrorIs(t, err, ErrMagicLinkNotFound)
|
||||||
|
|
||||||
|
// Old password unchanged
|
||||||
|
_, err = a.Authenticate("phil", "oldpass")
|
||||||
|
require.Nil(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUser_MagicLink_ResetPassword_Expired(t *testing.T) {
|
||||||
|
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
|
||||||
|
a := newTestManager(t, newManager, PermissionDenyAll)
|
||||||
|
require.Nil(t, a.AddUser("phil", "oldpass", RoleUser, false))
|
||||||
|
phil, err := a.User("phil")
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
raw, err := a.CreateMagicLink(MagicLinkKindPasswordReset, phil.ID, "", -time.Minute)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.ErrorIs(t, a.ResetPassword(raw, "newpass"), ErrMagicLinkNotFound)
|
||||||
|
_, err = a.Authenticate("phil", "oldpass")
|
||||||
|
require.Nil(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestUser_MagicLink_UserIDByPrimaryEmail_NotFound(t *testing.T) {
|
func TestUser_MagicLink_UserIDByPrimaryEmail_NotFound(t *testing.T) {
|
||||||
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
|
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
|
||||||
a := newTestManager(t, newManager, PermissionDenyAll)
|
a := newTestManager(t, newManager, PermissionDenyAll)
|
||||||
|
|||||||
@@ -1696,6 +1696,37 @@ func (a *Manager) VerifyEmail(rawToken string) (*MagicLink, error) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResetPassword consumes a password-reset magic link, identified by its raw token: after
|
||||||
|
// validating the token (kind + expiry), it sets the user's password and deletes the link in one
|
||||||
|
// transaction. Existing access tokens are intentionally left valid (only the password changes).
|
||||||
|
// Returns ErrMagicLinkNotFound if the token is invalid, expired, or not a reset token.
|
||||||
|
func (a *Manager) ResetPassword(rawToken, password string) error {
|
||||||
|
m, err := a.MagicLinkByHash(hashToken(rawToken))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if m.Kind != MagicLinkKindPasswordReset || time.Now().Unix() > m.Expires {
|
||||||
|
return ErrMagicLinkNotFound
|
||||||
|
}
|
||||||
|
u, err := a.UserByID(m.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
hash, err := a.maybeHashPassword(password, false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return db.ExecTx(a.db, func(tx *sql.Tx) error {
|
||||||
|
if err := a.changePasswordHashTx(tx, u.Name, hash); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(a.queries.deleteMagicLinkByHash, m.TokenHash); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// deleteExpiredMagicLinks removes magic links whose expiry has passed. Expiry is also enforced
|
// deleteExpiredMagicLinks removes magic links whose expiry has passed. Expiry is also enforced
|
||||||
// on read, so this is purely housekeeping to bound table growth; it runs from the reaper loop.
|
// on read, so this is purely housekeeping to bound table growth; it runs from the reaper loop.
|
||||||
func (a *Manager) deleteExpiredMagicLinks() error {
|
func (a *Manager) deleteExpiredMagicLinks() error {
|
||||||
|
|||||||
@@ -27,6 +27,21 @@
|
|||||||
"login_title": "Sign in to your ntfy account",
|
"login_title": "Sign in to your ntfy account",
|
||||||
"login_form_button_submit": "Sign in",
|
"login_form_button_submit": "Sign in",
|
||||||
"login_link_signup": "Sign up",
|
"login_link_signup": "Sign up",
|
||||||
|
"login_link_forgot_password": "Forgot password?",
|
||||||
|
"login_reset_dialog_title": "Reset password",
|
||||||
|
"login_reset_dialog_description": "Enter your username or email address. If an account exists, we'll email a link to reset your password.",
|
||||||
|
"login_reset_dialog_identifier_label": "Username or email",
|
||||||
|
"login_reset_dialog_button_submit": "Send reset link",
|
||||||
|
"login_reset_dialog_sent": "If an account exists, we've emailed a link to reset your password. Please check your inbox.",
|
||||||
|
"reset_password_title": "Set a new password",
|
||||||
|
"reset_password_form_password": "New password",
|
||||||
|
"reset_password_form_confirm": "Confirm new password",
|
||||||
|
"reset_password_form_button_submit": "Set password",
|
||||||
|
"reset_password_form_passwords_no_match": "Passwords do not match",
|
||||||
|
"reset_password_form_error_invalid": "This reset link is invalid or has expired. Please request a new one.",
|
||||||
|
"reset_password_success_title": "Password changed",
|
||||||
|
"reset_password_success_description": "Your password has been changed. You can now sign in with your new password.",
|
||||||
|
"reset_password_button_login": "Sign in",
|
||||||
"login_disabled": "Login is disabled",
|
"login_disabled": "Login is disabled",
|
||||||
"action_bar_show_menu": "Show menu",
|
"action_bar_show_menu": "Show menu",
|
||||||
"action_bar_logo_alt": "ntfy logo",
|
"action_bar_logo_alt": "ntfy logo",
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
accountEmailVerifyUrl,
|
accountEmailVerifyUrl,
|
||||||
accountEmailPrimaryUrl,
|
accountEmailPrimaryUrl,
|
||||||
accountEmailResendUrl,
|
accountEmailResendUrl,
|
||||||
|
accountPasswordResetRequestUrl,
|
||||||
|
accountPasswordResetUrl,
|
||||||
accountPasswordUrl,
|
accountPasswordUrl,
|
||||||
accountPhoneUrl,
|
accountPhoneUrl,
|
||||||
accountPhoneVerifyUrl,
|
accountPhoneVerifyUrl,
|
||||||
@@ -397,6 +399,32 @@ class AccountApi {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// requestPasswordReset starts the (unauthenticated) reset flow. The identifier is a username or
|
||||||
|
// primary email. The server always responds uniformly, regardless of whether an account matched.
|
||||||
|
async requestPasswordReset(identifier) {
|
||||||
|
const url = accountPasswordResetRequestUrl(config.base_url);
|
||||||
|
console.log(`[AccountApi] Requesting password reset ${url}`);
|
||||||
|
await fetchOrThrow(url, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
identifier,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetPassword performs the (unauthenticated) reset from the set-new-password landing page.
|
||||||
|
async resetPassword(token, password) {
|
||||||
|
const url = accountPasswordResetUrl(config.base_url);
|
||||||
|
console.log(`[AccountApi] Resetting password ${url}`);
|
||||||
|
await fetchOrThrow(url, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
token,
|
||||||
|
password,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async deleteEmail(email) {
|
async deleteEmail(email) {
|
||||||
const url = accountEmailUrl(config.base_url);
|
const url = accountEmailUrl(config.base_url);
|
||||||
console.log(`[AccountApi] Deleting email ${url}`);
|
console.log(`[AccountApi] Deleting email ${url}`);
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export const accountEmailUrl = (baseUrl) => `${baseUrl}/v1/account/email`;
|
|||||||
export const accountEmailVerifyUrl = (baseUrl) => `${baseUrl}/v1/account/email/verify`;
|
export const accountEmailVerifyUrl = (baseUrl) => `${baseUrl}/v1/account/email/verify`;
|
||||||
export const accountEmailPrimaryUrl = (baseUrl) => `${baseUrl}/v1/account/email/primary`;
|
export const accountEmailPrimaryUrl = (baseUrl) => `${baseUrl}/v1/account/email/primary`;
|
||||||
export const accountEmailResendUrl = (baseUrl) => `${baseUrl}/v1/account/email/resend`;
|
export const accountEmailResendUrl = (baseUrl) => `${baseUrl}/v1/account/email/resend`;
|
||||||
|
export const accountPasswordResetRequestUrl = (baseUrl) => `${baseUrl}/v1/account/password/reset/request`;
|
||||||
|
export const accountPasswordResetUrl = (baseUrl) => `${baseUrl}/v1/account/password/reset`;
|
||||||
|
|
||||||
export const validUrl = (url) => url.match(/^https?:\/\/.+/);
|
export const validUrl = (url) => url.match(/^https?:\/\/.+/);
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import Login from "./Login";
|
|||||||
import Signup from "./Signup";
|
import Signup from "./Signup";
|
||||||
import Account from "./Account";
|
import Account from "./Account";
|
||||||
import EmailVerify from "./EmailVerify";
|
import EmailVerify from "./EmailVerify";
|
||||||
|
import PasswordReset from "./PasswordReset";
|
||||||
import initI18n from "../app/i18n"; // Translations!
|
import initI18n from "../app/i18n"; // Translations!
|
||||||
import prefs from "../app/Prefs";
|
import prefs from "../app/Prefs";
|
||||||
import RTLCacheProvider from "./RTLCacheProvider";
|
import RTLCacheProvider from "./RTLCacheProvider";
|
||||||
@@ -65,6 +66,7 @@ const App = () => {
|
|||||||
<Route path={routes.login} element={<Login />} />
|
<Route path={routes.login} element={<Login />} />
|
||||||
<Route path={routes.signup} element={<Signup />} />
|
<Route path={routes.signup} element={<Signup />} />
|
||||||
<Route path={routes.emailVerify} element={<EmailVerify />} />
|
<Route path={routes.emailVerify} element={<EmailVerify />} />
|
||||||
|
<Route path={routes.passwordReset} element={<PasswordReset />} />
|
||||||
<Route element={<Layout />}>
|
<Route element={<Layout />}>
|
||||||
<Route path={routes.app} element={<AllSubscriptions />} />
|
<Route path={routes.app} element={<AllSubscriptions />} />
|
||||||
<Route path={routes.account} element={<Account />} />
|
<Route path={routes.account} element={<Account />} />
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Typography, TextField, Button, Box, IconButton, InputAdornment } from "@mui/material";
|
import {
|
||||||
|
Typography,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
Box,
|
||||||
|
IconButton,
|
||||||
|
InputAdornment,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogContentText,
|
||||||
|
DialogActions,
|
||||||
|
} from "@mui/material";
|
||||||
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
|
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
|
||||||
import { NavLink } from "react-router-dom";
|
import { NavLink } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -17,6 +29,7 @@ const Login = () => {
|
|||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [resetOpen, setResetOpen] = useState(false);
|
||||||
|
|
||||||
const handleSubmit = async (event) => {
|
const handleSubmit = async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -100,7 +113,13 @@ const Login = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
<Box sx={{ width: "100%" }}>
|
<Box sx={{ width: "100%" }}>
|
||||||
{/* This is where the password reset link would go */}
|
{config.enable_reset_password && (
|
||||||
|
<div style={{ float: "left" }}>
|
||||||
|
<Button variant="text" onClick={() => setResetOpen(true)} sx={{ textTransform: "none", p: 0, minWidth: 0 }}>
|
||||||
|
{t("login_link_forgot_password")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{config.enable_signup && (
|
{config.enable_signup && (
|
||||||
<div style={{ float: "right" }}>
|
<div style={{ float: "right" }}>
|
||||||
<NavLink to={routes.signup} variant="body1">
|
<NavLink to={routes.signup} variant="body1">
|
||||||
@@ -110,8 +129,67 @@ const Login = () => {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
<ForgotPasswordDialog open={resetOpen} onClose={() => setResetOpen(false)} />
|
||||||
</AvatarBox>
|
</AvatarBox>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ForgotPasswordDialog collects a username/email and asks the server to email a reset link. The
|
||||||
|
// response is uniform, so the dialog always shows the same "if an account exists" confirmation.
|
||||||
|
const ForgotPasswordDialog = (props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [identifier, setIdentifier] = useState("");
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [sent, setSent] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
setSending(true);
|
||||||
|
await accountApi.requestPasswordReset(identifier);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[Login] Password reset request failed`, e);
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
setSent(true); // Uniform outcome regardless of success/failure (enumeration-safe)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={props.open} onClose={props.onClose}>
|
||||||
|
<DialogTitle>{t("login_reset_dialog_title")}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
{sent ? (
|
||||||
|
<DialogContentText>{t("login_reset_dialog_sent")}</DialogContentText>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<DialogContentText>{t("login_reset_dialog_description")}</DialogContentText>
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
margin="dense"
|
||||||
|
label={t("login_reset_dialog_identifier_label")}
|
||||||
|
type="text"
|
||||||
|
value={identifier}
|
||||||
|
onChange={(ev) => setIdentifier(ev.target.value.trim())}
|
||||||
|
fullWidth
|
||||||
|
variant="standard"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
{sent ? (
|
||||||
|
<Button onClick={props.onClose}>{t("common_close")}</Button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button onClick={props.onClose}>{t("common_cancel")}</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={sending || identifier === ""}>
|
||||||
|
{t("login_reset_dialog_button_submit")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default Login;
|
export default Login;
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Typography, TextField, Button, Box } from "@mui/material";
|
||||||
|
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
|
||||||
|
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
|
||||||
|
import { useParams, NavLink } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import accountApi from "../app/AccountApi";
|
||||||
|
import AvatarBox from "./AvatarBox";
|
||||||
|
import routes from "./routes";
|
||||||
|
|
||||||
|
// PasswordReset is the magic-link landing page for setting a new password. There is no
|
||||||
|
// pre-validation: the form renders directly and an invalid/expired token surfaces as an error on
|
||||||
|
// submit. The raw token is stripped from the URL on load (kept out of history / Referer).
|
||||||
|
const PasswordReset = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { token: tokenParam } = useParams();
|
||||||
|
const token = useRef(tokenParam);
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirm, setConfirm] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Strip the token from the URL bar immediately (keep it out of history / Referer)
|
||||||
|
window.history.replaceState(null, "", routes.login);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (password !== confirm) {
|
||||||
|
setError(t("reset_password_form_passwords_no_match"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setSending(true);
|
||||||
|
setError("");
|
||||||
|
await accountApi.resetPassword(token.current, password);
|
||||||
|
setDone(true);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[PasswordReset] Reset failed`, e);
|
||||||
|
setError(t("reset_password_form_error_invalid"));
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
return (
|
||||||
|
<AvatarBox>
|
||||||
|
<CheckCircleOutlineIcon color="success" sx={{ fontSize: 48, mb: 1 }} />
|
||||||
|
<Typography sx={{ typography: "h6" }}>{t("reset_password_success_title")}</Typography>
|
||||||
|
<Typography sx={{ mt: 1, textAlign: "center" }}>{t("reset_password_success_description")}</Typography>
|
||||||
|
<Button component={NavLink} to={routes.login} variant="contained" sx={{ mt: 2 }}>
|
||||||
|
{t("reset_password_button_login")}
|
||||||
|
</Button>
|
||||||
|
</AvatarBox>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AvatarBox>
|
||||||
|
<Typography sx={{ typography: "h6" }}>{t("reset_password_title")}</Typography>
|
||||||
|
<Box component="form" onSubmit={handleSubmit} noValidate sx={{ mt: 1 }}>
|
||||||
|
<TextField
|
||||||
|
margin="dense"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
name="password"
|
||||||
|
label={t("reset_password_form_password")}
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(ev) => setPassword(ev.target.value.trim())}
|
||||||
|
autoComplete="new-password"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
margin="dense"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
name="confirm"
|
||||||
|
label={t("reset_password_form_confirm")}
|
||||||
|
type="password"
|
||||||
|
id="confirm"
|
||||||
|
value={confirm}
|
||||||
|
onChange={(ev) => setConfirm(ev.target.value.trim())}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
<Button type="submit" fullWidth variant="contained" disabled={sending || password === "" || confirm === ""} sx={{ mt: 2, mb: 2 }}>
|
||||||
|
{t("reset_password_form_button_submit")}
|
||||||
|
</Button>
|
||||||
|
{error && (
|
||||||
|
<Box sx={{ mb: 1, display: "flex", flexGrow: 1, justifyContent: "center" }}>
|
||||||
|
<WarningAmberIcon color="error" sx={{ mr: 1 }} />
|
||||||
|
<Typography sx={{ color: "error.main" }}>{error}</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</AvatarBox>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PasswordReset;
|
||||||
@@ -8,6 +8,7 @@ const routes = {
|
|||||||
account: "/account",
|
account: "/account",
|
||||||
settings: "/settings",
|
settings: "/settings",
|
||||||
emailVerify: "/account/email/verify/:token",
|
emailVerify: "/account/email/verify/:token",
|
||||||
|
passwordReset: "/account/password/reset/:token",
|
||||||
subscription: "/:topic",
|
subscription: "/:topic",
|
||||||
subscriptionExternal: "/:baseUrl/:topic",
|
subscriptionExternal: "/:baseUrl/:topic",
|
||||||
forSubscription: (subscription) => {
|
forSubscription: (subscription) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user