Phase 3+4

This commit is contained in:
binwiederhier
2026-06-12 14:23:32 -04:00
parent 30dd4840a2
commit 33ae31055c
19 changed files with 812 additions and 18 deletions
+86
View File
@@ -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))
+43
View File
@@ -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
+17
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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)
} }
+72
View File
@@ -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
+83
View File
@@ -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)
+30
View File
@@ -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 {
+114
View File
@@ -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)
})
}
+13
View File
@@ -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"`
+65
View File
@@ -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)
+31
View File
@@ -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 {
+15
View File
@@ -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",
+28
View File
@@ -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}`);
+2
View File
@@ -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?:\/\/.+/);
+2
View File
@@ -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 />} />
+80 -2
View File
@@ -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;
+105
View File
@@ -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;
+1
View File
@@ -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) => {