mirror of
https://github.com/multipleof4/ntfy.git
synced 2026-10-08 21:05:21 +00:00
Merge pull request #1785 from binwiederhier/password-reset
Password reset
This commit is contained in:
+88
-3
@@ -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,12 +34,17 @@ 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{
|
||||||
Name: "user",
|
Name: "user",
|
||||||
Usage: "Manage/show users",
|
Usage: "Manage/show users",
|
||||||
UsageText: "ntfy user [list|add|remove|change-pass|change-role] ...",
|
UsageText: "ntfy user [list|add|remove|change-pass|reset-pass|change-role] ...",
|
||||||
Flags: flagsUser,
|
Flags: flagsUser,
|
||||||
Before: initConfigFileInputSourceFunc("config", flagsUser, initLogFunc),
|
Before: initConfigFileInputSourceFunc("config", flagsUser, initLogFunc),
|
||||||
Category: categoryServer,
|
Category: categoryServer,
|
||||||
@@ -98,6 +105,30 @@ 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: "reset-pass",
|
||||||
|
Aliases: []string{"rp"},
|
||||||
|
Usage: "Generates a password reset link for a user",
|
||||||
|
UsageText: "ntfy user reset-pass [--send-email] USERNAME",
|
||||||
|
Action: execUserResetPass,
|
||||||
|
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).
|
||||||
|
|
||||||
|
Example:
|
||||||
|
ntfy user reset-pass phil # Print a reset link for user phil
|
||||||
|
ntfy user reset-pass --send-email phil # Print and email the reset link
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -257,7 +288,6 @@ func execUserDel(c *cli.Context) error {
|
|||||||
func execUserChangePass(c *cli.Context) error {
|
func execUserChangePass(c *cli.Context) error {
|
||||||
username := c.Args().Get(0)
|
username := c.Args().Get(0)
|
||||||
password, hashed := os.LookupEnv("NTFY_PASSWORD_HASH")
|
password, hashed := os.LookupEnv("NTFY_PASSWORD_HASH")
|
||||||
|
|
||||||
if !hashed {
|
if !hashed {
|
||||||
password = os.Getenv("NTFY_PASSWORD")
|
password = os.Getenv("NTFY_PASSWORD")
|
||||||
}
|
}
|
||||||
@@ -286,6 +316,61 @@ func execUserChangePass(c *cli.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func execUserResetPass(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 reset-pass --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
|
||||||
|
} else if u.Provisioned {
|
||||||
|
return fmt.Errorf("user %s is provisioned in the config file; its password cannot be reset", username)
|
||||||
|
}
|
||||||
|
// 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.AddMagicLink(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))
|
||||||
@@ -313,7 +398,7 @@ func execUserHash(c *cli.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
hash, err := user.HashPassword(password)
|
hash, err := user.HashPassword(password, user.DefaultUserPasswordBcryptCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to hash password: %w", err)
|
return fmt.Errorf("failed to hash password: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,6 +122,69 @@ 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_ResetPass(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"))
|
||||||
|
|
||||||
|
// 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", "reset-pass", "phil"))
|
||||||
|
require.Contains(t, stdout.String(), "https://ntfy.example.com/account/password/reset/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCLI_User_ResetPass_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, "reset-pass", "phil")
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "base-url")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCLI_User_ResetPass_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", "reset-pass", "--send-email", "phil")
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "no primary email")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCLI_User_ResetPass_ProvisionedRejected(t *testing.T) {
|
||||||
|
s, conf, port := newTestServerWithAuth(t)
|
||||||
|
defer test.StopServer(t, s, port)
|
||||||
|
|
||||||
|
// Seed a provisioned user into the auth database via config provisioning
|
||||||
|
m, err := user.NewSQLiteManager(conf.AuthFile, "", &user.Config{
|
||||||
|
ProvisionEnabled: true,
|
||||||
|
Users: []*user.User{
|
||||||
|
{Name: "provuser", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: user.RoleUser},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Nil(t, m.Close())
|
||||||
|
|
||||||
|
app, _, _, _ := newTestApp()
|
||||||
|
err = runUserCommand(app, conf, "--base-url=https://ntfy.example.com", "reset-pass", "provuser")
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "provisioned")
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
+6
-3
@@ -1047,9 +1047,12 @@ configured for `ntfy.sh`):
|
|||||||
```
|
```
|
||||||
|
|
||||||
By default, any user (including anonymous users) can send email notifications to any address. To require email
|
By default, any user (including anonymous users) can send email notifications to any address. To require email
|
||||||
address verification, set `smtp-sender-verify` to `true`. When enabled, anonymous users cannot send emails,
|
address verification, set `smtp-sender-verify` to `true`. When enabled, anonymous users cannot send emails, and
|
||||||
and authenticated users can only send to email addresses they have verified in their account settings. Users can
|
authenticated users can only send to *literal* email addresses they have verified in their account settings.
|
||||||
also use `yes`/`true`/`1` as the `X-Email` value to send to their first verified address.
|
|
||||||
|
Regardless of this setting, a logged-in user can pass `yes`/`true`/`1` as the `X-Email` value to send to their primary
|
||||||
|
verified address (falling back to their first verified address if no primary is designated). `smtp-sender-verify` only
|
||||||
|
governs whether arbitrary literal addresses are allowed.
|
||||||
|
|
||||||
Please also refer to the [rate limiting](#rate-limiting) settings below, specifically `visitor-email-limit-burst`
|
Please also refer to the [rate limiting](#rate-limiting) settings below, specifically `visitor-email-limit-burst`
|
||||||
and `visitor-email-limit-burst`. Setting these conservatively is necessary to avoid abuse.
|
and `visitor-email-limit-burst`. Setting these conservatively is necessary to avoid abuse.
|
||||||
|
|||||||
+8
-6
@@ -1,6 +1,6 @@
|
|||||||
# Privacy policy
|
# Privacy policy
|
||||||
|
|
||||||
**Last updated:** March 31, 2026
|
**Last updated:** June 15, 2026
|
||||||
|
|
||||||
This privacy policy describes how ntfy ("we", "us", or "our") collects, uses, and handles your information
|
This privacy policy describes how ntfy ("we", "us", or "our") collects, uses, and handles your information
|
||||||
when you use the ntfy.sh service, web app, and mobile applications (Android and iOS).
|
when you use the ntfy.sh service, web app, and mobile applications (Android and iOS).
|
||||||
@@ -19,8 +19,9 @@ If you create an account on ntfy.sh, we collect:
|
|||||||
|
|
||||||
- **Username** - A unique identifier you choose
|
- **Username** - A unique identifier you choose
|
||||||
- **Password** - Stored as a secure bcrypt hash (we never store your plaintext password)
|
- **Password** - Stored as a secure bcrypt hash (we never store your plaintext password)
|
||||||
- **Email address** - If you subscribe to a paid plan (for billing purposes via Stripe), or if you add a verified
|
- **Email address** - If you add an email address to your account for account recovery and password resets, for use
|
||||||
email address for use with the email notification feature
|
with the email notification feature, or if you subscribe to a paid plan (for billing purposes via Stripe). Email
|
||||||
|
addresses you add to your account are verified by sending a confirmation link.
|
||||||
- **Phone number** - Only if you enable the phone call notification feature (verified via SMS/call)
|
- **Phone number** - Only if you enable the phone call notification feature (verified via SMS/call)
|
||||||
|
|
||||||
You can use ntfy without creating an account. Anonymous usage is fully supported.
|
You can use ntfy without creating an account. Anonymous usage is fully supported.
|
||||||
@@ -77,9 +78,10 @@ Your phone number is shared with Twilio to deliver these services. Twilio's
|
|||||||
|
|
||||||
### Amazon SES (email delivery)
|
### Amazon SES (email delivery)
|
||||||
|
|
||||||
If you use the email notification feature (`X-Email` header), we use Amazon Simple Email Service (SES) to
|
If you use the email notification feature (`X-Email` header), or when ntfy sends account-related emails (email
|
||||||
deliver emails. The recipient email address and message content are transmitted through Amazon's infrastructure.
|
address verification and password reset links), we use Amazon Simple Email Service (SES) to deliver emails. The
|
||||||
Amazon's [privacy policy](https://aws.amazon.com/privacy/) applies.
|
recipient email address and message content are transmitted through Amazon's infrastructure. Amazon's
|
||||||
|
[privacy policy](https://aws.amazon.com/privacy/) applies.
|
||||||
|
|
||||||
### Stripe (payments)
|
### Stripe (payments)
|
||||||
|
|
||||||
|
|||||||
+8
-3
@@ -3251,8 +3251,13 @@ You can forward messages to e-mail by specifying an address in the header. This
|
|||||||
you'd like to persist longer, or to blast-notify yourself on all possible channels.
|
you'd like to persist longer, or to blast-notify yourself on all possible channels.
|
||||||
|
|
||||||
Usage is easy: Simply pass the `X-Email` header (or any of its aliases: `X-E-mail`, `Email`, `E-mail`, `Mail`, or `e`).
|
Usage is easy: Simply pass the `X-Email` header (or any of its aliases: `X-E-mail`, `Email`, `E-mail`, `Mail`, or `e`).
|
||||||
Only one e-mail address is supported. If the server has [`smtp-sender-verify`](config.md#e-mail-notifications) enabled (ntfy.sh has this enabled),
|
Only one e-mail address is supported.
|
||||||
you can also pass `yes`, `true`, or `1` to send to your first verified email address.
|
|
||||||
|
If you are logged in and have a verified email address on your account, you can pass `yes`, `true`, or `1` instead of an
|
||||||
|
address to send to your **primary email address** (the one marked primary in the web app's
|
||||||
|
[Account section](https://ntfy.sh/account)); if you haven't designated a primary, it falls back to your first verified
|
||||||
|
address. This works regardless of the [`smtp-sender-verify`](config.md#e-mail-notifications) setting -- that setting only
|
||||||
|
controls whether *literal* addresses must already be verified on your account.
|
||||||
|
|
||||||
ntfy allows anonymous email sending (if enabled), so the rate limiting is pretty strict (see [limitations](#limitations)). In the
|
ntfy allows anonymous email sending (if enabled), so the rate limiting is pretty strict (see [limitations](#limitations)). In the
|
||||||
default configuration, you get **16 e-mails per visitor** (IP address) and then after that one per hour. On top of
|
default configuration, you get **16 e-mails per visitor** (IP address) and then after that one per hour. On top of
|
||||||
@@ -3702,7 +3707,7 @@ all the supported fields:
|
|||||||
| `icon` | - | *string* | `https://example.com/icon.png` | URL to use as notification [icon](#icons) |
|
| `icon` | - | *string* | `https://example.com/icon.png` | URL to use as notification [icon](#icons) |
|
||||||
| `filename` | - | *string* | `file.jpg` | File name of the attachment |
|
| `filename` | - | *string* | `file.jpg` | File name of the attachment |
|
||||||
| `delay` | - | *string* | `30min`, `9am` | Timestamp or duration for delayed delivery |
|
| `delay` | - | *string* | `30min`, `9am` | Timestamp or duration for delayed delivery |
|
||||||
| `email` | - | *e-mail address or 'yes'* | `phil@example.com` or `yes` | E-mail address for e-mail notifications, or `yes` to use first verified address |
|
| `email` | - | *e-mail address or 'yes'* | `phil@example.com` or `yes` | E-mail address for e-mail notifications, or `yes` to use your primary verified address |
|
||||||
| `call` | - | *phone number or 'yes'* | `+1222334444` or `yes` | Phone number to use for [voice call](#phone-calls) |
|
| `call` | - | *phone number or 'yes'* | `+1222334444` or `yes` | Phone number to use for [voice call](#phone-calls) |
|
||||||
| `sequence_id` | - | *string* | `my-sequence-123` | Sequence ID for [updating/deleting notifications](#updating-deleting-notifications) |
|
| `sequence_id` | - | *string* | `my-sequence-123` | Sequence ID for [updating/deleting notifications](#updating-deleting-notifications) |
|
||||||
|
|
||||||
|
|||||||
@@ -1948,6 +1948,26 @@ 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 work is probably not useful for self-hosters, but it hopefully will be useful for me,
|
||||||
|
since I do have to reset emails on a regular basis.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
* Add password reset via emailed magic link, with a "Forgot password" link on the login page and a `ntfy user reset-pass` 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" email (used for account recovery and as the `X-Email: yes` target) with verified/unverified state in the account UI
|
||||||
|
|
||||||
|
**Bug fixes + maintenance:**
|
||||||
|
|
||||||
|
* Generate access tokens, IDs, and magic-link tokens with a cryptographically secure RNG (`crypto/rand`) instead of a clock-seeded PRNG
|
||||||
|
* `X-Email: yes` (also `true`/`1`) now sends to your primary verified email regardless of the `smtp-sender-verify` setting (previously it was rejected unless verification was enabled); it requires being logged in with a verified address
|
||||||
|
|
||||||
### 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,4 +1,4 @@
|
|||||||
package server
|
package mail
|
||||||
|
|
||||||
import (
|
import (
|
||||||
_ "embed" // required by go:embed
|
_ "embed" // required by go:embed
|
||||||
@@ -6,66 +6,24 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"mime"
|
"mime"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"heckel.io/ntfy/v2/log"
|
|
||||||
"heckel.io/ntfy/v2/mail"
|
|
||||||
"heckel.io/ntfy/v2/model"
|
"heckel.io/ntfy/v2/model"
|
||||||
"heckel.io/ntfy/v2/util"
|
"heckel.io/ntfy/v2/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
type mailer interface {
|
var (
|
||||||
Send(v *visitor, m *model.Message, to string) error
|
//go:embed "mailer_emoji_map.json"
|
||||||
Counts() (total int64, success int64, failure int64)
|
emojisJSON string
|
||||||
}
|
|
||||||
|
|
||||||
type smtpSender struct {
|
// emojiMap maps ntfy tag names to emoji, parsed once from the embedded JSON in init
|
||||||
config *Config
|
emojiMap map[string]string
|
||||||
sender *mail.Sender
|
)
|
||||||
success int64
|
|
||||||
failure int64
|
|
||||||
mu sync.Mutex
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *smtpSender) Send(v *visitor, m *model.Message, to string) error {
|
func init() {
|
||||||
return s.withCount(v, m, func() error {
|
if err := json.Unmarshal([]byte(emojisJSON), &emojiMap); err != nil {
|
||||||
message, err := formatMail(s.config.BaseURL, v.ip.String(), s.sender.From(), to, m)
|
panic("mail: invalid embedded emoji map: " + err.Error())
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
ev := logvm(v, m).
|
|
||||||
Tag(tagEmail).
|
|
||||||
Fields(log.Context{
|
|
||||||
"email_via": s.sender.Addr(),
|
|
||||||
"email_user": s.sender.User(),
|
|
||||||
"email_to": to,
|
|
||||||
})
|
|
||||||
if ev.IsTrace() {
|
|
||||||
ev.Field("email_body", message).Trace("Sending email")
|
|
||||||
}
|
|
||||||
ev.Info("Sending email")
|
|
||||||
return s.sender.SendRaw(to, []byte(message))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *smtpSender) Counts() (total int64, success int64, failure int64) {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
return s.success + s.failure, s.success, s.failure
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *smtpSender) withCount(v *visitor, m *model.Message, fn func() error) error {
|
|
||||||
err := fn()
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
if err != nil {
|
|
||||||
logvm(v, m).Err(err).Debug("Sending mail failed")
|
|
||||||
s.failure++
|
|
||||||
} else {
|
|
||||||
s.success++
|
|
||||||
}
|
}
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatMail(baseURL, senderIP, from, to string, m *model.Message) (string, error) {
|
func formatMail(baseURL, senderIP, from, to string, m *model.Message) (string, error) {
|
||||||
@@ -78,10 +36,7 @@ func formatMail(baseURL, senderIP, from, to string, m *model.Message) (string, e
|
|||||||
message := m.Message
|
message := m.Message
|
||||||
trailer := ""
|
trailer := ""
|
||||||
if len(m.Tags) > 0 {
|
if len(m.Tags) > 0 {
|
||||||
emojis, tags, err := toEmojis(m.Tags)
|
emojis, tags := toEmojis(m.Tags)
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if len(emojis) > 0 {
|
if len(emojis) > 0 {
|
||||||
subject = strings.Join(emojis, " ") + " " + subject
|
subject = strings.Join(emojis, " ") + " " + subject
|
||||||
}
|
}
|
||||||
@@ -126,16 +81,7 @@ This message was sent by {ip} at {time} via {topicURL}`
|
|||||||
return body, nil
|
return body, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
func toEmojis(tags []string) (emojisOut []string, tagsOut []string) {
|
||||||
//go:embed "mailer_emoji_map.json"
|
|
||||||
emojisJSON string
|
|
||||||
)
|
|
||||||
|
|
||||||
func toEmojis(tags []string) (emojisOut []string, tagsOut []string, err error) {
|
|
||||||
var emojiMap map[string]string
|
|
||||||
if err = json.Unmarshal([]byte(emojisJSON), &emojiMap); err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
tagsOut = make([]string, 0)
|
tagsOut = make([]string, 0)
|
||||||
emojisOut = make([]string, 0)
|
emojisOut = make([]string, 0)
|
||||||
for _, t := range tags {
|
for _, t := range tags {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package server
|
package mail
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
+80
-94
@@ -10,82 +10,94 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"heckel.io/ntfy/v2/log"
|
"heckel.io/ntfy/v2/log"
|
||||||
"heckel.io/ntfy/v2/util"
|
"heckel.io/ntfy/v2/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
verifyCodeExpiry = 10 * time.Minute
|
tagMail = "mail"
|
||||||
verifyCodeLength = 6
|
|
||||||
verifyCodeSubject = "ntfy email verification"
|
emailVerificationSubject = "Verify your email for ntfy"
|
||||||
|
passwordResetSubject = "Reset your ntfy password"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config holds the SMTP configuration for the mail sender
|
// Config holds the SMTP configuration for the mail sender
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
BaseURL string // ntfy base URL, used to build topic URLs in notification emails
|
||||||
SMTPAddr string // SMTP server address (host:port)
|
SMTPAddr string // SMTP server address (host:port)
|
||||||
SMTPUser string // SMTP auth username
|
SMTPUser string // SMTP auth username
|
||||||
SMTPPass string // SMTP auth password
|
SMTPPass string // SMTP auth password
|
||||||
From string // Sender email address
|
From string // Sender email address
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sender sends emails and manages email verification codes
|
// Sender sends all of ntfy's outgoing email: notification emails (the email-on-publish feature)
|
||||||
type Sender struct {
|
// as well as the magic-link emails for email verification and password reset. realSender is the
|
||||||
config *Config
|
// SMTP-backed implementation; tests inject a fake.
|
||||||
codes map[string]verifyCode // Verification codes, keyed by email
|
type Sender interface {
|
||||||
mu sync.Mutex
|
SendNotification(to string, m *model.Message, senderIP string) error
|
||||||
closeChan chan struct{}
|
NotificationCounts() (total int64, success int64, failure int64)
|
||||||
|
SendEmailVerification(to, link string) error
|
||||||
|
SendPasswordReset(to, link string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type verifyCode struct {
|
// realSender is the SMTP-backed implementation of Sender. Pending verification/reset state lives
|
||||||
code string
|
// in the database (see user.Manager), not in this struct.
|
||||||
expires time.Time
|
type realSender struct {
|
||||||
|
config *Config
|
||||||
|
success int64
|
||||||
|
failure int64
|
||||||
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSender creates a new mail Sender with the given SMTP config
|
// NewSender creates a new mail Sender with the given SMTP config
|
||||||
func NewSender(config *Config) *Sender {
|
func NewSender(config *Config) Sender {
|
||||||
s := &Sender{
|
return &realSender{config: config}
|
||||||
config: config,
|
|
||||||
codes: make(map[string]verifyCode),
|
|
||||||
closeChan: make(chan struct{}),
|
|
||||||
}
|
|
||||||
go s.expireLoop()
|
|
||||||
return s
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close stops the background expiry loop
|
// SendNotification formats a ntfy message into a notification email and sends it via SMTP. It
|
||||||
func (s *Sender) Close() {
|
// tracks success/failure counts, exposed via Counts (used for the server stats).
|
||||||
close(s.closeChan)
|
func (s *realSender) SendNotification(to string, m *model.Message, senderIP string) error {
|
||||||
}
|
message, err := formatMail(s.config.BaseURL, senderIP, s.config.From, to, m)
|
||||||
|
|
||||||
// Addr returns the SMTP server address
|
|
||||||
func (s *Sender) Addr() string {
|
|
||||||
return s.config.SMTPAddr
|
|
||||||
}
|
|
||||||
|
|
||||||
// User returns the SMTP username
|
|
||||||
func (s *Sender) User() string {
|
|
||||||
return s.config.SMTPUser
|
|
||||||
}
|
|
||||||
|
|
||||||
// From returns the sender email address
|
|
||||||
func (s *Sender) From() string {
|
|
||||||
return s.config.From
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendRaw sends a raw email message via SMTP
|
|
||||||
func (s *Sender) SendRaw(to string, message []byte) error {
|
|
||||||
host, _, err := net.SplitHostPort(s.config.SMTPAddr)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.count(false)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var auth smtp.Auth
|
log.Tag(tagMail).Field("email_to", to).Debug("Sending notification email")
|
||||||
if s.config.SMTPUser != "" {
|
err = s.sendRaw(to, []byte(message))
|
||||||
auth = smtp.PlainAuth("", s.config.SMTPUser, s.config.SMTPPass, host)
|
s.count(err == nil)
|
||||||
}
|
return err
|
||||||
return smtp.SendMail(s.config.SMTPAddr, auth, s.config.From, []string{to}, message)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send sends a plain text email via SMTP
|
// NotificationCounts returns the number of notification emails sent, broken down into total, success and failure
|
||||||
func (s *Sender) Send(to, subject, body string) error {
|
func (s *realSender) NotificationCounts() (total int64, success int64, failure int64) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.success + s.failure, s.success, s.failure
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendEmailVerification sends an email containing a magic link to verify ownership of the
|
||||||
|
// recipient address. The link carries a one-time token validated against the database.
|
||||||
|
func (s *realSender) SendEmailVerification(to, link string) error {
|
||||||
|
body := fmt.Sprintf(`Click the link below to verify this email address for your ntfy account:
|
||||||
|
|
||||||
|
%s
|
||||||
|
|
||||||
|
This link expires in 24 hours. If you did not request this, you can safely ignore this email.`, link)
|
||||||
|
return s.send(to, emailVerificationSubject, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendPasswordReset sends an email containing a magic link to set a new password. The link
|
||||||
|
// carries a one-time token validated against the database.
|
||||||
|
func (s *realSender) SendPasswordReset(to, link string) error {
|
||||||
|
body := fmt.Sprintf(`Click the link below to set a new password for your ntfy account:
|
||||||
|
|
||||||
|
%s
|
||||||
|
|
||||||
|
This link expires in 1 hour. If you did not request this, you can safely ignore this email -- your password will not change.`, link)
|
||||||
|
return s.send(to, passwordResetSubject, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// send sends a plain text email via SMTP
|
||||||
|
func (s *realSender) send(to, subject, body string) error {
|
||||||
date := time.Now().UTC().Format(time.RFC1123Z)
|
date := time.Now().UTC().Format(time.RFC1123Z)
|
||||||
encodedSubject := mime.BEncoding.Encode("utf-8", subject)
|
encodedSubject := mime.BEncoding.Encode("utf-8", subject)
|
||||||
message := `From: ntfy <{from}>
|
message := `From: ntfy <{from}>
|
||||||
@@ -100,55 +112,29 @@ Content-Type: text/plain; charset="utf-8"
|
|||||||
message = strings.ReplaceAll(message, "{date}", date)
|
message = strings.ReplaceAll(message, "{date}", date)
|
||||||
message = strings.ReplaceAll(message, "{subject}", encodedSubject)
|
message = strings.ReplaceAll(message, "{subject}", encodedSubject)
|
||||||
message = strings.ReplaceAll(message, "{body}", body)
|
message = strings.ReplaceAll(message, "{body}", body)
|
||||||
log.Tag("mail").Field("email_to", to).Debug("Sending email")
|
log.Tag(tagMail).Field("email_to", to).Debug("Sending email")
|
||||||
return s.SendRaw(to, []byte(message))
|
return s.sendRaw(to, []byte(message))
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendVerification generates a random code, stores it in-memory, and sends a verification email
|
// sendRaw sends a raw email message via SMTP
|
||||||
func (s *Sender) SendVerification(to string) error {
|
func (s *realSender) sendRaw(to string, message []byte) error {
|
||||||
code := util.RandomString(verifyCodeLength)
|
host, _, err := net.SplitHostPort(s.config.SMTPAddr)
|
||||||
s.mu.Lock()
|
if err != nil {
|
||||||
s.codes[to] = verifyCode{
|
return err
|
||||||
code: code,
|
|
||||||
expires: time.Now().Add(verifyCodeExpiry),
|
|
||||||
}
|
}
|
||||||
s.mu.Unlock()
|
var auth smtp.Auth
|
||||||
body := fmt.Sprintf("Your ntfy email verification code is: %s\n\nThis code expires in 10 minutes.", code)
|
if s.config.SMTPUser != "" {
|
||||||
return s.Send(to, verifyCodeSubject, body)
|
auth = smtp.PlainAuth("", s.config.SMTPUser, s.config.SMTPPass, host)
|
||||||
|
}
|
||||||
|
return smtp.SendMail(s.config.SMTPAddr, auth, s.config.From, []string{to}, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckVerification checks if the code matches and hasn't expired. Removes the entry on success.
|
func (s *realSender) count(ok bool) {
|
||||||
func (s *Sender) CheckVerification(email, code string) bool {
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
vc, ok := s.codes[email]
|
if ok {
|
||||||
if !ok || time.Now().After(vc.expires) || vc.code != code {
|
s.success++
|
||||||
return false
|
} else {
|
||||||
}
|
s.failure++
|
||||||
delete(s.codes, email)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Sender) expireLoop() {
|
|
||||||
ticker := time.NewTicker(time.Minute)
|
|
||||||
defer ticker.Stop()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ticker.C:
|
|
||||||
s.expireVerificationCodes()
|
|
||||||
case <-s.closeChan:
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Sender) expireVerificationCodes() {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
now := time.Now()
|
|
||||||
for email, vc := range s.codes {
|
|
||||||
if now.After(vc.expires) {
|
|
||||||
delete(s.codes, email)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -71,7 +71,7 @@ const (
|
|||||||
DefaultVisitorEmailLimitReplenish = time.Hour
|
DefaultVisitorEmailLimitReplenish = time.Hour
|
||||||
DefaultVisitorTopicCreationLimitBurst = 100
|
DefaultVisitorTopicCreationLimitBurst = 100
|
||||||
DefaultVisitorTopicCreationLimitReplenish = time.Minute
|
DefaultVisitorTopicCreationLimitReplenish = time.Minute
|
||||||
DefaultVisitorAccountCreationLimitBurst = 3
|
DefaultVisitorAccountCreationLimitBurst = 6 // Shared by signup and password-reset requests (same per-visitor bucket)
|
||||||
DefaultVisitorAccountCreationLimitReplenish = 24 * time.Hour
|
DefaultVisitorAccountCreationLimitReplenish = 24 * time.Hour
|
||||||
DefaultVisitorAuthFailureLimitBurst = 30
|
DefaultVisitorAuthFailureLimitBurst = 30
|
||||||
DefaultVisitorAuthFailureLimitReplenish = time.Minute
|
DefaultVisitorAuthFailureLimitReplenish = time.Minute
|
||||||
|
|||||||
+4
-2
@@ -143,9 +143,10 @@ var (
|
|||||||
errHTTPBadRequestTemplateFileInvalid = &errHTTP{40048, http.StatusBadRequest, "invalid request: template file invalid", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
errHTTPBadRequestTemplateFileInvalid = &errHTTP{40048, http.StatusBadRequest, "invalid request: template file invalid", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||||
errHTTPBadRequestSequenceIDInvalid = &errHTTP{40049, http.StatusBadRequest, "invalid request: sequence ID invalid", "https://ntfy.sh/docs/publish/#updating-deleting-notifications", nil}
|
errHTTPBadRequestSequenceIDInvalid = &errHTTP{40049, http.StatusBadRequest, "invalid request: sequence ID invalid", "https://ntfy.sh/docs/publish/#updating-deleting-notifications", nil}
|
||||||
errHTTPBadRequestEmailAddressInvalid = &errHTTP{40050, http.StatusBadRequest, "invalid request: invalid e-mail address", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil}
|
errHTTPBadRequestEmailAddressInvalid = &errHTTP{40050, http.StatusBadRequest, "invalid request: invalid e-mail address", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil}
|
||||||
errHTTPBadRequestEmailVerificationCodeInvalid = &errHTTP{40051, http.StatusBadRequest, "invalid request: email verification code invalid or expired", "", nil}
|
errHTTPBadRequestEmailVerificationLinkInvalid = &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}
|
||||||
@@ -156,6 +157,7 @@ var (
|
|||||||
errHTTPConflictProvisionedUserChange = &errHTTP{40905, http.StatusConflict, "conflict: cannot change or delete provisioned user", "", nil}
|
errHTTPConflictProvisionedUserChange = &errHTTP{40905, http.StatusConflict, "conflict: cannot change or delete provisioned user", "", nil}
|
||||||
errHTTPConflictProvisionedTokenChange = &errHTTP{40906, http.StatusConflict, "conflict: cannot change or delete provisioned token", "", nil}
|
errHTTPConflictProvisionedTokenChange = &errHTTP{40906, http.StatusConflict, "conflict: cannot change or delete provisioned token", "", nil}
|
||||||
errHTTPConflictEmailExists = &errHTTP{40907, http.StatusConflict, "conflict: email address already exists", "", nil}
|
errHTTPConflictEmailExists = &errHTTP{40907, http.StatusConflict, "conflict: email address already exists", "", nil}
|
||||||
|
errHTTPConflictEmailPrimaryElsewhere = &errHTTP{40908, http.StatusConflict, "conflict: email address is the primary email on another account", "", nil}
|
||||||
errHTTPGonePhoneVerificationExpired = &errHTTP{41001, http.StatusGone, "phone number verification expired or does not exist", "", nil}
|
errHTTPGonePhoneVerificationExpired = &errHTTP{41001, http.StatusGone, "phone number verification expired or does not exist", "", nil}
|
||||||
errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations", nil}
|
errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||||
errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", "", nil}
|
errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", "", nil}
|
||||||
@@ -165,7 +167,7 @@ var (
|
|||||||
errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions", "https://ntfy.sh/docs/publish/#limitations", nil}
|
errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||||
errHTTPTooManyRequestsLimitTotalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations", nil}
|
errHTTPTooManyRequestsLimitTotalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||||
errHTTPTooManyRequestsLimitAttachmentBandwidth = &errHTTP{42905, http.StatusTooManyRequests, "limit reached: daily bandwidth reached", "https://ntfy.sh/docs/publish/#limitations", nil}
|
errHTTPTooManyRequestsLimitAttachmentBandwidth = &errHTTP{42905, http.StatusTooManyRequests, "limit reached: daily bandwidth reached", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||||
errHTTPTooManyRequestsLimitAccountCreation = &errHTTP{42906, http.StatusTooManyRequests, "limit reached: too many accounts created", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit
|
errHTTPTooManyRequestsLimitAccountActions = &errHTTP{42906, http.StatusTooManyRequests, "limit reached: too many account requests", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit
|
||||||
errHTTPTooManyRequestsLimitReservations = &errHTTP{42907, http.StatusTooManyRequests, "limit reached: too many topic reservations for this user", "", nil}
|
errHTTPTooManyRequestsLimitReservations = &errHTTP{42907, http.StatusTooManyRequests, "limit reached: too many topic reservations for this user", "", nil}
|
||||||
errHTTPTooManyRequestsLimitMessages = &errHTTP{42908, http.StatusTooManyRequests, "limit reached: daily message quota reached", "https://ntfy.sh/docs/publish/#limitations", nil}
|
errHTTPTooManyRequestsLimitMessages = &errHTTP{42908, http.StatusTooManyRequests, "limit reached: daily message quota reached", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||||
errHTTPTooManyRequestsLimitAuthFailure = &errHTTP{42909, http.StatusTooManyRequests, "limit reached: too many auth failures", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit
|
errHTTPTooManyRequestsLimitAuthFailure = &errHTTP{42909, http.StatusTooManyRequests, "limit reached: too many auth failures", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit
|
||||||
|
|||||||
+62
-39
@@ -57,8 +57,7 @@ type Server struct {
|
|||||||
unixListener net.Listener
|
unixListener net.Listener
|
||||||
smtpServer *smtp.Server
|
smtpServer *smtp.Server
|
||||||
smtpServerBackend *smtpBackend
|
smtpServerBackend *smtpBackend
|
||||||
smtpSender mailer
|
mailer mail.Sender
|
||||||
mailSender *mail.Sender
|
|
||||||
topics map[string]*topic
|
topics map[string]*topic
|
||||||
visitors map[string]*visitor // ip:<ip> or user:<user>
|
visitors map[string]*visitor // ip:<ip> or user:<user>
|
||||||
firebaseClient *firebaseClient
|
firebaseClient *firebaseClient
|
||||||
@@ -93,8 +92,13 @@ var (
|
|||||||
clearPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}/(read|clear)$`)
|
clearPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}/(read|clear)$`)
|
||||||
sequenceIDRegex = topicRegex
|
sequenceIDRegex = topicRegex
|
||||||
|
|
||||||
webConfigPath = "/config.js"
|
webAppConfigPath = "/config.js"
|
||||||
webManifestPath = "/manifest.webmanifest"
|
webAppManifestPath = "/manifest.webmanifest"
|
||||||
|
webAppEmailVerifyPathPrefix = "/account/email/verify/" // Browser landing route; raw token appended
|
||||||
|
webAppEmailVerifyRegex = regexp.MustCompile(`^/account/email/verify/[-_A-Za-z0-9]+$`) // Magic-link landing (served by the web app)
|
||||||
|
webAppPasswordResetPathPrefix = "/account/password/reset/" // Browser landing route; raw token appended
|
||||||
|
webAppPasswordResetRegex = regexp.MustCompile(`^/account/password/reset/[-_A-Za-z0-9]+$`) // Password-reset landing (served by the web app)
|
||||||
|
|
||||||
accountPath = "/account"
|
accountPath = "/account"
|
||||||
matrixPushPath = "/_matrix/push/v1/notify"
|
matrixPushPath = "/_matrix/push/v1/notify"
|
||||||
metricsPath = "/metrics"
|
metricsPath = "/metrics"
|
||||||
@@ -116,6 +120,10 @@ var (
|
|||||||
apiAccountPhoneVerifyPath = "/v1/account/phone/verify"
|
apiAccountPhoneVerifyPath = "/v1/account/phone/verify"
|
||||||
apiAccountEmailPath = "/v1/account/email"
|
apiAccountEmailPath = "/v1/account/email"
|
||||||
apiAccountEmailVerifyPath = "/v1/account/email/verify"
|
apiAccountEmailVerifyPath = "/v1/account/email/verify"
|
||||||
|
apiAccountEmailPrimaryPath = "/v1/account/email/primary"
|
||||||
|
apiAccountEmailResendPath = "/v1/account/email/resend"
|
||||||
|
apiAccountPasswordResetRequestPath = "/v1/account/password/reset/request"
|
||||||
|
apiAccountPasswordResetPath = "/v1/account/password/reset"
|
||||||
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"
|
||||||
@@ -176,16 +184,15 @@ const (
|
|||||||
// New instantiates a new Server. It creates the cache and adds a Firebase
|
// New instantiates a new Server. It creates the cache and adds a Firebase
|
||||||
// subscriber (if configured).
|
// subscriber (if configured).
|
||||||
func New(conf *Config) (*Server, error) {
|
func New(conf *Config) (*Server, error) {
|
||||||
var mailer mailer
|
var sender mail.Sender
|
||||||
var mailSender *mail.Sender
|
|
||||||
if conf.SMTPSenderAddr != "" {
|
if conf.SMTPSenderAddr != "" {
|
||||||
mailSender = mail.NewSender(&mail.Config{
|
sender = mail.NewSender(&mail.Config{
|
||||||
|
BaseURL: conf.BaseURL,
|
||||||
SMTPAddr: conf.SMTPSenderAddr,
|
SMTPAddr: conf.SMTPSenderAddr,
|
||||||
SMTPUser: conf.SMTPSenderUser,
|
SMTPUser: conf.SMTPSenderUser,
|
||||||
SMTPPass: conf.SMTPSenderPass,
|
SMTPPass: conf.SMTPSenderPass,
|
||||||
From: conf.SMTPSenderFrom,
|
From: conf.SMTPSenderFrom,
|
||||||
})
|
})
|
||||||
mailer = &smtpSender{config: conf, sender: mailSender}
|
|
||||||
}
|
}
|
||||||
var stripe stripeAPI
|
var stripe stripeAPI
|
||||||
if payments.Available && conf.StripeSecretKey != "" {
|
if payments.Available && conf.StripeSecretKey != "" {
|
||||||
@@ -290,8 +297,7 @@ func New(conf *Config) (*Server, error) {
|
|||||||
webPush: wp,
|
webPush: wp,
|
||||||
attachment: attachmentStore,
|
attachment: attachmentStore,
|
||||||
firebaseClient: firebaseClient,
|
firebaseClient: firebaseClient,
|
||||||
smtpSender: mailer,
|
mailer: sender,
|
||||||
mailSender: mailSender,
|
|
||||||
topics: topics,
|
topics: topics,
|
||||||
userManager: userManager,
|
userManager: userManager,
|
||||||
messages: messages,
|
messages: messages,
|
||||||
@@ -443,9 +449,6 @@ func (s *Server) Stop() {
|
|||||||
if s.smtpServer != nil {
|
if s.smtpServer != nil {
|
||||||
s.smtpServer.Close()
|
s.smtpServer.Close()
|
||||||
}
|
}
|
||||||
if s.mailSender != nil {
|
|
||||||
s.mailSender.Close()
|
|
||||||
}
|
|
||||||
if s.attachment != nil {
|
if s.attachment != nil {
|
||||||
s.attachment.Close()
|
s.attachment.Close()
|
||||||
}
|
}
|
||||||
@@ -542,7 +545,7 @@ func (s *Server) handleError(w http.ResponseWriter, r *http.Request, v *visitor,
|
|||||||
|
|
||||||
func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
if r.Method == http.MethodGet && r.URL.Path == "/" && s.config.WebRoot == "/" {
|
if r.Method == http.MethodGet && r.URL.Path == "/" && s.config.WebRoot == "/" {
|
||||||
return s.ensureWebEnabled(s.handleRoot)(w, r, v)
|
return s.ensureWebEnabled(s.handleWebApp)(w, r, v)
|
||||||
} else if r.Method == http.MethodHead && r.URL.Path == "/" {
|
} else if r.Method == http.MethodHead && r.URL.Path == "/" {
|
||||||
return s.ensureWebEnabled(s.handleEmpty)(w, r, v)
|
return s.ensureWebEnabled(s.handleEmpty)(w, r, v)
|
||||||
} else if r.Method == http.MethodGet && r.URL.Path == apiHealthPath {
|
} else if r.Method == http.MethodGet && r.URL.Path == apiHealthPath {
|
||||||
@@ -551,9 +554,9 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
|||||||
return s.ensureAdmin(s.handleVersion)(w, r, v)
|
return s.ensureAdmin(s.handleVersion)(w, r, v)
|
||||||
} else if r.Method == http.MethodGet && r.URL.Path == apiConfigPath {
|
} else if r.Method == http.MethodGet && r.URL.Path == apiConfigPath {
|
||||||
return s.handleConfig(w, r, v)
|
return s.handleConfig(w, r, v)
|
||||||
} else if r.Method == http.MethodGet && r.URL.Path == webConfigPath {
|
} else if r.Method == http.MethodGet && r.URL.Path == webAppConfigPath {
|
||||||
return s.ensureWebEnabled(s.handleWebConfig)(w, r, v)
|
return s.ensureWebEnabled(s.handleWebConfig)(w, r, v)
|
||||||
} else if r.Method == http.MethodGet && r.URL.Path == webManifestPath {
|
} else if r.Method == http.MethodGet && r.URL.Path == webAppManifestPath {
|
||||||
return s.ensureWebPushEnabled(s.handleWebManifest)(w, r, v)
|
return s.ensureWebPushEnabled(s.handleWebManifest)(w, r, v)
|
||||||
} else if r.Method == http.MethodGet && r.URL.Path == apiUsersPath {
|
} else if r.Method == http.MethodGet && r.URL.Path == apiUsersPath {
|
||||||
return s.ensureAdmin(s.handleUsersGet)(w, r, v)
|
return s.ensureAdmin(s.handleUsersGet)(w, r, v)
|
||||||
@@ -611,12 +614,20 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
|||||||
return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberAdd)))(w, r, v)
|
return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberAdd)))(w, r, v)
|
||||||
} else if r.Method == http.MethodDelete && r.URL.Path == apiAccountPhonePath {
|
} else if r.Method == http.MethodDelete && r.URL.Path == apiAccountPhonePath {
|
||||||
return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberDelete)))(w, r, v)
|
return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberDelete)))(w, r, v)
|
||||||
} else if r.Method == http.MethodPut && r.URL.Path == apiAccountEmailVerifyPath {
|
|
||||||
return s.ensureUser(s.ensureEmailsEnabled(s.withAccountSync(s.handleAccountEmailVerify)))(w, r, v)
|
|
||||||
} else if r.Method == http.MethodPut && r.URL.Path == apiAccountEmailPath {
|
} else if r.Method == http.MethodPut && r.URL.Path == apiAccountEmailPath {
|
||||||
return s.ensureUser(s.ensureEmailsEnabled(s.withAccountSync(s.handleAccountEmailAdd)))(w, r, v)
|
return s.ensureUser(s.ensureEmailsEnabled(s.withAccountSync(s.handleAccountEmailAdd)))(w, r, v)
|
||||||
|
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountEmailVerifyPath {
|
||||||
|
return s.ensureEmailsEnabled(s.limitRequests(s.handleAccountEmailVerify))(w, r, v) // No ensureUser: clicked from a mail client, possibly logged out
|
||||||
} else if r.Method == http.MethodDelete && r.URL.Path == apiAccountEmailPath {
|
} else if r.Method == http.MethodDelete && r.URL.Path == apiAccountEmailPath {
|
||||||
return s.ensureUser(s.ensureEmailsEnabled(s.withAccountSync(s.handleAccountEmailDelete)))(w, r, v)
|
return s.ensureUser(s.ensureEmailsEnabled(s.withAccountSync(s.handleAccountEmailDelete)))(w, r, v)
|
||||||
|
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountEmailPrimaryPath {
|
||||||
|
return s.ensureUser(s.withAccountSync(s.handleAccountEmailSetPrimary))(w, r, v)
|
||||||
|
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountEmailResendPath {
|
||||||
|
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 {
|
||||||
@@ -659,17 +670,30 @@ 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) || webAppPasswordResetRegex.MatchString(r.URL.Path)) {
|
||||||
|
return s.ensureWebEnabled(s.handleWebAppNoIndex)(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)
|
||||||
}
|
}
|
||||||
return errHTTPNotFound
|
return errHTTPNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
// handleWebApp serves the embedded web app's index for client-side (SPA) routes that the
|
||||||
|
// browser router resolves, so the app shell loads and the client-side router takes over.
|
||||||
|
func (s *Server) handleWebApp(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
r.URL.Path = webAppIndex
|
r.URL.Path = webAppIndex
|
||||||
return s.handleStatic(w, r, v)
|
return s.handleStatic(w, r, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleWebAppNoIndex serves the web app index for the magic-link landing pages, whose path
|
||||||
|
// carries a one-time token. The response is marked no-referrer (so the token can't leak to third
|
||||||
|
// parties via the Referer header) and noindex (so it never gets indexed).
|
||||||
|
func (s *Server) handleWebAppNoIndex(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||||
|
w.Header().Set("X-Robots-Tag", "noindex")
|
||||||
|
return s.handleWebApp(w, r, v)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleTopic(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
func (s *Server) handleTopic(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
unifiedpush := readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see PUT/POST too!
|
unifiedpush := readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see PUT/POST too!
|
||||||
if unifiedpush {
|
if unifiedpush {
|
||||||
@@ -678,8 +702,7 @@ func (s *Server) handleTopic(w http.ResponseWriter, r *http.Request, v *visitor)
|
|||||||
_, err := io.WriteString(w, `{"unifiedpush":{"version":1}}`+"\n")
|
_, err := io.WriteString(w, `{"unifiedpush":{"version":1}}`+"\n")
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
r.URL.Path = webAppIndex
|
return s.handleWebApp(w, r, v)
|
||||||
return s.handleStatic(w, r, v)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleEmpty(_ http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
func (s *Server) handleEmpty(_ http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
||||||
@@ -715,21 +738,21 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi
|
|||||||
|
|
||||||
func (s *Server) configResponse() *apiConfigResponse {
|
func (s *Server) configResponse() *apiConfigResponse {
|
||||||
return &apiConfigResponse{
|
return &apiConfigResponse{
|
||||||
BaseURL: "", // Will translate to window.location.origin
|
BaseURL: "", // Will translate to window.location.origin
|
||||||
AppRoot: s.config.WebRoot,
|
AppRoot: s.config.WebRoot,
|
||||||
EnableLogin: s.config.EnableLogin,
|
EnableLogin: s.config.EnableLogin,
|
||||||
RequireLogin: s.config.RequireLogin,
|
RequireLogin: s.config.RequireLogin,
|
||||||
EnableSignup: s.config.EnableSignup,
|
EnableSignup: s.config.EnableSignup,
|
||||||
EnablePayments: s.config.StripeSecretKey != "",
|
EnablePayments: s.config.StripeSecretKey != "",
|
||||||
EnableCalls: s.config.TwilioAccount != "",
|
EnableCalls: s.config.TwilioAccount != "",
|
||||||
EnableEmails: s.config.SMTPSenderFrom != "",
|
EnableEmails: s.config.SMTPSenderFrom != "",
|
||||||
EnableEmailVerify: s.config.SMTPSenderVerify,
|
EnableResetPassword: s.config.SMTPSenderFrom != "" && s.config.BaseURL != "", // Reset links need SMTP + an absolute base-url
|
||||||
EnableReservations: s.config.EnableReservations,
|
EnableReservations: s.config.EnableReservations,
|
||||||
EnableWebPush: s.config.WebPushPublicKey != "",
|
EnableWebPush: s.config.WebPushPublicKey != "",
|
||||||
BillingContact: s.config.BillingContact,
|
BillingContact: s.config.BillingContact,
|
||||||
WebPushPublicKey: s.config.WebPushPublicKey,
|
WebPushPublicKey: s.config.WebPushPublicKey,
|
||||||
DisallowedTopics: s.config.DisallowedTopics,
|
DisallowedTopics: s.config.DisallowedTopics,
|
||||||
ConfigHash: s.config.Hash(),
|
ConfigHash: s.config.Hash(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -946,7 +969,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess
|
|||||||
if s.firebaseClient != nil && firebase {
|
if s.firebaseClient != nil && firebase {
|
||||||
go s.sendToFirebase(v, m)
|
go s.sendToFirebase(v, m)
|
||||||
}
|
}
|
||||||
if s.smtpSender != nil && email != "" {
|
if s.mailer != nil && email != "" {
|
||||||
go s.sendEmail(v, m, email)
|
go s.sendEmail(v, m, email)
|
||||||
}
|
}
|
||||||
if s.config.TwilioAccount != "" && call != "" {
|
if s.config.TwilioAccount != "" && call != "" {
|
||||||
@@ -1108,7 +1131,7 @@ func (s *Server) sendToFirebase(v *visitor, m *model.Message) {
|
|||||||
|
|
||||||
func (s *Server) sendEmail(v *visitor, m *model.Message, email string) {
|
func (s *Server) sendEmail(v *visitor, m *model.Message, email string) {
|
||||||
logvm(v, m).Tag(tagEmail).Field("email", email).Info("Sending email to %s", email)
|
logvm(v, m).Tag(tagEmail).Field("email", email).Info("Sending email to %s", email)
|
||||||
if err := s.smtpSender.Send(v, m, email); err != nil {
|
if err := s.mailer.SendNotification(email, m, v.ip.String()); err != nil {
|
||||||
logvm(v, m).Tag(tagEmail).Field("email", email).Err(err).Warn("Unable to send email to %s: %v", email, err.Error())
|
logvm(v, m).Tag(tagEmail).Field("email", email).Err(err).Warn("Unable to send email to %s: %v", email, err.Error())
|
||||||
minc(metricEmailsPublishedFailure)
|
minc(metricEmailsPublishedFailure)
|
||||||
return
|
return
|
||||||
@@ -1208,7 +1231,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *model.Message) (cache bo
|
|||||||
if email != "" && !emailAddressRegex.MatchString(email) && !toBool(email) {
|
if email != "" && !emailAddressRegex.MatchString(email) && !toBool(email) {
|
||||||
return false, false, "", "", "", false, "", errHTTPBadRequestEmailAddressInvalid
|
return false, false, "", "", "", false, "", errHTTPBadRequestEmailAddressInvalid
|
||||||
}
|
}
|
||||||
if s.smtpSender == nil && email != "" {
|
if s.mailer == nil && email != "" {
|
||||||
return false, false, "", "", "", false, "", errHTTPBadRequestEmailDisabled
|
return false, false, "", "", "", false, "", errHTTPBadRequestEmailDisabled
|
||||||
}
|
}
|
||||||
call = readParam(r, "x-call", "call")
|
call = readParam(r, "x-call", "call")
|
||||||
|
|||||||
+273
-55
@@ -15,8 +15,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
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
|
||||||
|
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 {
|
||||||
@@ -27,14 +29,17 @@ func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *
|
|||||||
} else if u != nil {
|
} else if u != nil {
|
||||||
return errHTTPUnauthorized // Cannot create account from user context
|
return errHTTPUnauthorized // Cannot create account from user context
|
||||||
}
|
}
|
||||||
if !v.AccountCreationAllowed() {
|
if !v.AccountActionAllowed() {
|
||||||
return errHTTPTooManyRequestsLimitAccountCreation
|
return errHTTPTooManyRequestsLimitAccountActions
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
newAccount, err := readJSONWithLimit[apiAccountCreateRequest](r.Body, jsonBodyBytesLimit, false)
|
newAccount, err := readJSONWithLimit[apiAccountCreateRequest](r.Body, jsonBodyBytesLimit, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if newAccount.Email != "" && !emailAddressRegex.MatchString(newAccount.Email) {
|
||||||
|
return errHTTPBadRequestEmailAddressInvalid
|
||||||
|
}
|
||||||
if existingUser, _ := s.userManager.User(newAccount.Username); existingUser != nil {
|
if existingUser, _ := s.userManager.User(newAccount.Username); existingUser != nil {
|
||||||
return errHTTPConflictUserExists
|
return errHTTPConflictUserExists
|
||||||
}
|
}
|
||||||
@@ -45,7 +50,17 @@ func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *
|
|||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
v.AccountCreated()
|
v.AccountActionPerformed()
|
||||||
|
// If an email was provided and email sending is configured, start verification (best-effort).
|
||||||
|
// The address becomes the primary email on verify (the new account has no primary yet); a
|
||||||
|
// failure to send must not fail signup, so we only log it.
|
||||||
|
if newAccount.Email != "" && s.mailer != nil {
|
||||||
|
if u, err := s.userManager.User(newAccount.Username); err != nil {
|
||||||
|
logvr(v, r).Tag(tagAccount).Err(err).Warn("Failed to load new user for email verification")
|
||||||
|
} else if err := s.enqueueEmailVerification(u.ID, newAccount.Email); err != nil {
|
||||||
|
logvr(v, r).Tag(tagAccount).Err(err).Warn("Failed to send signup email verification")
|
||||||
|
}
|
||||||
|
}
|
||||||
return s.writeJSON(w, newSuccessResponse())
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,13 +175,29 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
|
|||||||
response.PhoneNumbers = phoneNumbers
|
response.PhoneNumbers = phoneNumbers
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if s.mailSender != nil {
|
if s.mailer != nil {
|
||||||
emails, err := s.userManager.Emails(u.ID)
|
emails, err := s.userManager.Emails(u.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(emails) > 0 {
|
primaryEmail, err := s.userManager.PrimaryEmail(u.ID)
|
||||||
response.Emails = emails
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pendingEmails, err := s.userManager.PendingEmails(u.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Combine verified (with primary flag) and pending (unverified) into one list
|
||||||
|
emailInfos := make([]*apiAccountEmailInfo, 0, len(emails)+len(pendingEmails))
|
||||||
|
for _, email := range emails {
|
||||||
|
emailInfos = append(emailInfos, &apiAccountEmailInfo{Address: email, Primary: email == primaryEmail})
|
||||||
|
}
|
||||||
|
for _, email := range pendingEmails {
|
||||||
|
emailInfos = append(emailInfos, &apiAccountEmailInfo{Address: email, Pending: true})
|
||||||
|
}
|
||||||
|
if len(emailInfos) > 0 {
|
||||||
|
response.Emails = emailInfos
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -615,83 +646,254 @@ func (s *Server) handleAccountPhoneNumberDelete(w http.ResponseWriter, r *http.R
|
|||||||
return s.writeJSON(w, newSuccessResponse())
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAccountEmailVerify(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
// handleAccountEmailAdd starts email verification (PUT /v1/account/email): it generates a
|
||||||
|
// magic-link token, stores a pending verification, and emails the link. The address is NOT
|
||||||
|
// added to the verified list until the user clicks the link (handleAccountEmailVerify).
|
||||||
|
func (s *Server) handleAccountEmailAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
u := v.User()
|
u := v.User()
|
||||||
req, err := readJSONWithLimit[apiAccountEmailVerifyRequest](r.Body, jsonBodyBytesLimit, false)
|
req, err := readJSONWithLimit[apiAccountEmailRequest](r.Body, jsonBodyBytesLimit, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
} else if !emailAddressRegex.MatchString(req.Email) {
|
} else if !emailAddressRegex.MatchString(req.Email) {
|
||||||
return errHTTPBadRequestEmailAddressInvalid
|
return errHTTPBadRequestEmailAddressInvalid
|
||||||
}
|
}
|
||||||
// Check user is allowed to add emails
|
// Check user is allowed to add emails (the tier email limit gates the feature)
|
||||||
if u == nil {
|
if u.IsUser() && u.Tier != nil && u.Tier.EmailLimit == 0 {
|
||||||
return errHTTPUnauthorized
|
|
||||||
} else if u.IsUser() && u.Tier != nil && u.Tier.EmailLimit == 0 {
|
|
||||||
return errHTTPUnauthorized
|
return errHTTPUnauthorized
|
||||||
} else if u.IsUser() && u.Tier == nil && s.config.VisitorEmailLimitBurst == 0 {
|
} else if u.IsUser() && u.Tier == nil && s.config.VisitorEmailLimitBurst == 0 {
|
||||||
return errHTTPUnauthorized
|
return errHTTPUnauthorized
|
||||||
}
|
}
|
||||||
// Check if email already exists
|
// Reject if already verified on this account (pending re-requests are fine -- they replace)
|
||||||
emails, err := s.userManager.Emails(u.ID)
|
emails, err := s.userManager.Emails(u.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
} else if util.Contains(emails, req.Email) {
|
} else if util.Contains(emails, req.Email) {
|
||||||
return errHTTPConflictEmailExists
|
return errHTTPConflictEmailExists
|
||||||
}
|
}
|
||||||
// Check email rate limit (counts against the user's email quota)
|
// Rate limit (counts against the user's email quota)
|
||||||
if !v.EmailAllowed() {
|
if !v.EmailAllowed() {
|
||||||
return errHTTPTooManyRequestsLimitEmails
|
return errHTTPTooManyRequestsLimitEmails
|
||||||
}
|
}
|
||||||
// Send verification email
|
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Starting email verification")
|
||||||
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Sending email verification")
|
if err := s.enqueueEmailVerification(u.ID, req.Email); err != nil {
|
||||||
if err := s.mailSender.SendVerification(req.Email); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return s.writeJSON(w, newSuccessResponse())
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAccountEmailAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
// handleAccountEmailVerify performs verification from the (unauthenticated) landing page
|
||||||
|
// (POST /v1/account/email/verify): it validates the raw token, adds the address to the user's
|
||||||
|
// verified emails, and -- if the user has no primary yet -- promotes it. No auth is required;
|
||||||
|
// the token binds the action to a user, so the click works from a logged-out mail client.
|
||||||
|
func (s *Server) handleAccountEmailVerify(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
req, err := readJSONWithLimit[apiAccountEmailVerifyRequest](r.Body, jsonBodyBytesLimit, false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if req.Token == "" {
|
||||||
|
return errHTTPBadRequestEmailVerificationLinkInvalid
|
||||||
|
}
|
||||||
|
m, err := s.userManager.VerifyEmail(req.Token)
|
||||||
|
if errors.Is(err, user.ErrMagicLinkNotFound) {
|
||||||
|
return errHTTPBadRequestEmailVerificationLinkInvalid
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
logvr(v, r).Tag(tagAccount).Field("email", m.Email).Info("Email verified")
|
||||||
|
// Refresh the verified user's other sessions. The request is unauthenticated (v.User() is
|
||||||
|
// usually nil), so resolve the user from the token row and publish to their sync topic.
|
||||||
|
s.publishSyncEventForUserIDAsync(v, m.UserID)
|
||||||
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAccountEmailDelete removes an email address, whether verified or still pending
|
||||||
|
// (DELETE /v1/account/email). Removing the primary leaves the account with no primary.
|
||||||
|
func (s *Server) handleAccountEmailDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
u := v.User()
|
u := v.User()
|
||||||
req, err := readJSONWithLimit[apiAccountEmailAddRequest](r.Body, jsonBodyBytesLimit, false)
|
req, err := readJSONWithLimit[apiAccountEmailRequest](r.Body, jsonBodyBytesLimit, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
} else if !emailAddressRegex.MatchString(req.Email) {
|
} else if !emailAddressRegex.MatchString(req.Email) {
|
||||||
return errHTTPBadRequestEmailAddressInvalid
|
return errHTTPBadRequestEmailAddressInvalid
|
||||||
} else if !s.mailSender.CheckVerification(req.Email, req.Code) {
|
|
||||||
return errHTTPBadRequestEmailVerificationCodeInvalid
|
|
||||||
}
|
}
|
||||||
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Adding email as verified")
|
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Debug("Deleting email (verified or pending)")
|
||||||
if err := s.userManager.AddEmail(u.ID, req.Email); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.writeJSON(w, newSuccessResponse())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleAccountEmailDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
|
||||||
u := v.User()
|
|
||||||
req, err := readJSONWithLimit[apiAccountEmailVerifyRequest](r.Body, jsonBodyBytesLimit, false)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !emailAddressRegex.MatchString(req.Email) {
|
|
||||||
return errHTTPBadRequestEmailAddressInvalid
|
|
||||||
}
|
|
||||||
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Debug("Deleting verified email")
|
|
||||||
if err := s.userManager.RemoveEmail(u.ID, req.Email); err != nil {
|
if err := s.userManager.RemoveEmail(u.ID, req.Email); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// Also drop any pending verification for the address (no-op if there is none)
|
||||||
|
if err := s.userManager.DeleteEmailVerification(u.ID, req.Email); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return s.writeJSON(w, newSuccessResponse())
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
}
|
}
|
||||||
|
|
||||||
// convertEmailAddress checks the email address against the user's verified email list.
|
// handleAccountEmailSetPrimary marks an already-verified email as the user's primary (recovery)
|
||||||
// If smtp-sender-verify is false (default), the email is passed through as-is for
|
// email (POST /v1/account/email/primary).
|
||||||
// backwards compatibility. If true, the user must be authenticated and the email must be
|
func (s *Server) handleAccountEmailSetPrimary(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
// in their verified list. "yes"/"true"/"1" resolves to the first verified email.
|
u := v.User()
|
||||||
func (s *Server) convertEmailAddress(u *user.User, email string) (string, *errHTTP) {
|
req, err := readJSONWithLimit[apiAccountEmailRequest](r.Body, jsonBodyBytesLimit, false)
|
||||||
if !s.config.SMTPSenderVerify {
|
if err != nil {
|
||||||
if toBool(email) {
|
return err
|
||||||
return "", errHTTPBadRequestEmailAddressInvalid
|
} else if !emailAddressRegex.MatchString(req.Email) {
|
||||||
|
return errHTTPBadRequestEmailAddressInvalid
|
||||||
|
}
|
||||||
|
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Setting primary email")
|
||||||
|
err = s.userManager.SetPrimaryEmail(u.ID, req.Email)
|
||||||
|
if errors.Is(err, user.ErrEmailPrimaryElsewhere) {
|
||||||
|
return errHTTPConflictEmailPrimaryElsewhere
|
||||||
|
} else if errors.Is(err, user.ErrEmailNotFound) {
|
||||||
|
return errHTTPBadRequestEmailAddressNotVerified
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAccountEmailResend re-sends a pending email verification (POST /v1/account/email/resend).
|
||||||
|
func (s *Server) handleAccountEmailResend(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
u := v.User()
|
||||||
|
req, err := readJSONWithLimit[apiAccountEmailRequest](r.Body, jsonBodyBytesLimit, false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if !emailAddressRegex.MatchString(req.Email) {
|
||||||
|
return errHTTPBadRequestEmailAddressInvalid
|
||||||
|
}
|
||||||
|
// Only resend for an address that is actually pending on this account
|
||||||
|
pending, err := s.userManager.PendingEmails(u.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if !util.Contains(pending, req.Email) {
|
||||||
|
return errHTTPBadRequestEmailAddressInvalid
|
||||||
|
}
|
||||||
|
if !v.EmailAllowed() {
|
||||||
|
return errHTTPTooManyRequestsLimitEmails
|
||||||
|
}
|
||||||
|
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Resending email verification")
|
||||||
|
if err := s.enqueueEmailVerification(u.ID, req.Email); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
// enqueueEmailVerification generates a magic-link token for the given address, stores the
|
||||||
|
// pending verification (replacing any existing one), and emails the link. Shared by the add,
|
||||||
|
// resend, signup, and Stripe paths. Requires base-url to build an absolute link.
|
||||||
|
func (s *Server) enqueueEmailVerification(userID, email string) error {
|
||||||
|
if s.config.BaseURL == "" {
|
||||||
|
return errHTTPInternalErrorMissingBaseURL
|
||||||
|
}
|
||||||
|
token, err := s.userManager.AddMagicLink(user.MagicLinkKindEmailVerify, userID, email, emailVerificationTokenExpiry)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
link := s.config.BaseURL + webAppEmailVerifyPathPrefix + token
|
||||||
|
return s.mailer.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.AccountActionAllowed() {
|
||||||
|
return errHTTPTooManyRequestsLimitAccountActions
|
||||||
|
}
|
||||||
|
v.AccountActionPerformed() // 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.resolveResetPasswordTarget(identifier); ok {
|
||||||
|
token, err := s.userManager.AddMagicLink(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.mailer.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())
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveResetPasswordTarget resolves a reset identifier (username or primary email) to a single account
|
||||||
|
// and its primary email. It applies the reset policy on top of the lookup: provisioned users are
|
||||||
|
// excluded, and ok=false is returned unless the account has a verified primary email (reset
|
||||||
|
// requires one, and that is where the link is sent).
|
||||||
|
func (s *Server) resolveResetPasswordTarget(identifier string) (userID string, email string, ok bool) {
|
||||||
|
u, err := s.userManager.UserByEmailOrUsername(identifier)
|
||||||
|
if err != nil || u == nil || u.Provisioned {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
primary, err := s.userManager.PrimaryEmail(u.ID)
|
||||||
|
if err != nil || primary == "" {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return u.ID, primary, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) || errors.Is(err, user.ErrProvisionedUserChange) {
|
||||||
|
return errHTTPBadRequestResetLinkInvalid // Generic 400 (provisioned users can't be reset; don't leak that)
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
logvr(v, r).Tag(tagAccount).Info("Password reset performed")
|
||||||
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertEmailAddress resolves the X-Email value to the address ntfy should send to.
|
||||||
|
//
|
||||||
|
// "yes"/"true"/"1" resolves to the user's primary verified address -- or, if no primary is
|
||||||
|
// designated (e.g. a provisioned user), the first verified address (alphabetically). This is
|
||||||
|
// independent of smtp-sender-verify: it only requires an authenticated user with a verified
|
||||||
|
// address, since it means "send to my own email".
|
||||||
|
//
|
||||||
|
// A literal address is sent as-is when smtp-sender-verify is false (the default, backwards
|
||||||
|
// compatible); when true, the address must be one the user has verified.
|
||||||
|
func (s *Server) convertEmailAddress(u *user.User, email string) (string, *errHTTP) {
|
||||||
|
if toBool(email) {
|
||||||
|
if u == nil {
|
||||||
|
return "", errHTTPBadRequestAnonymousEmailNotAllowed
|
||||||
|
} else if s.userManager == nil {
|
||||||
|
return "", errHTTPBadRequestEmailAddressNotVerified
|
||||||
|
}
|
||||||
|
primary, err := s.userManager.PrimaryEmail(u.ID)
|
||||||
|
if err != nil {
|
||||||
|
return "", errHTTPInternalError
|
||||||
|
} else if primary != "" {
|
||||||
|
return primary, nil
|
||||||
|
}
|
||||||
|
// No primary designated -> fall back to the first verified address, if any
|
||||||
|
emails, err := s.userManager.Emails(u.ID)
|
||||||
|
if err != nil {
|
||||||
|
return "", errHTTPInternalError
|
||||||
|
} else if len(emails) > 0 {
|
||||||
|
return emails[0], nil
|
||||||
|
}
|
||||||
|
return "", errHTTPBadRequestEmailAddressNotVerified
|
||||||
|
}
|
||||||
|
// A literal address
|
||||||
|
if !s.config.SMTPSenderVerify {
|
||||||
return email, nil
|
return email, nil
|
||||||
} else if u == nil {
|
} else if u == nil {
|
||||||
return "", errHTTPBadRequestAnonymousEmailNotAllowed
|
return "", errHTTPBadRequestAnonymousEmailNotAllowed
|
||||||
@@ -701,11 +903,6 @@ func (s *Server) convertEmailAddress(u *user.User, email string) (string, *errHT
|
|||||||
emails, err := s.userManager.Emails(u.ID)
|
emails, err := s.userManager.Emails(u.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", errHTTPInternalError
|
return "", errHTTPInternalError
|
||||||
} else if len(emails) == 0 {
|
|
||||||
return "", errHTTPBadRequestEmailAddressNotVerified
|
|
||||||
}
|
|
||||||
if toBool(email) {
|
|
||||||
return emails[0], nil
|
|
||||||
} else if util.Contains(emails, email) {
|
} else if util.Contains(emails, email) {
|
||||||
return email, nil
|
return email, nil
|
||||||
}
|
}
|
||||||
@@ -721,9 +918,30 @@ func (s *Server) publishSyncEventAsync(v *visitor) {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// publishSyncEvent publishes a sync message to the user's sync topic
|
// publishSyncEvent publishes a sync message to the authenticated user's sync topic
|
||||||
func (s *Server) publishSyncEvent(v *visitor) error {
|
func (s *Server) publishSyncEvent(v *visitor) error {
|
||||||
u := v.User()
|
return s.publishSyncEventForUser(v, v.User())
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishSyncEventForUserIDAsync publishes a sync event to the sync topic of the user with the
|
||||||
|
// given ID, resolving the user first. Used by the unauthenticated email-verify handler, where
|
||||||
|
// the request visitor has no associated user but the token identifies the account to refresh.
|
||||||
|
func (s *Server) publishSyncEventForUserIDAsync(v *visitor, userID string) {
|
||||||
|
go func() {
|
||||||
|
u, err := s.userManager.UserByID(userID)
|
||||||
|
if err != nil {
|
||||||
|
logv(v).Err(err).Trace("Error loading user for sync event")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.publishSyncEventForUser(v, u); err != nil {
|
||||||
|
logv(v).Err(err).Trace("Error publishing to user's sync topic")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishSyncEventForUser publishes a sync message to the given user's sync topic, using v as
|
||||||
|
// the publishing visitor (for rate-limit accounting). No-op if the user has no sync topic.
|
||||||
|
func (s *Server) publishSyncEventForUser(v *visitor, u *user.User) error {
|
||||||
if u == nil || u.SyncTopic == "" {
|
if u == nil || u.SyncTopic == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,452 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"heckel.io/ntfy/v2/model"
|
||||||
|
"heckel.io/ntfy/v2/user"
|
||||||
|
"heckel.io/ntfy/v2/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
// captureMailer is a fake mailer that records the magic links it is asked to send, so tests can
|
||||||
|
// "click" them without a real SMTP server. The notification side is a no-op.
|
||||||
|
type captureMailer struct {
|
||||||
|
verifyLinks map[string]string // email -> verification link
|
||||||
|
resetLinks map[string]string // email -> reset link
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCaptureMailer() *captureMailer {
|
||||||
|
return &captureMailer{verifyLinks: map[string]string{}, resetLinks: map[string]string{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *captureMailer) SendEmailVerification(to, link string) error {
|
||||||
|
c.verifyLinks[to] = link
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *captureMailer) SendPasswordReset(to, link string) error {
|
||||||
|
c.resetLinks[to] = link
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *captureMailer) SendNotification(to string, m *model.Message, senderIP string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *captureMailer) NotificationCounts() (total int64, success int64, failure int64) {
|
||||||
|
return 0, 0, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// newEmailTestServer creates a server with email sending "enabled" (SMTP + base-url configured)
|
||||||
|
// and a capturing mailer injected, plus a tier-less user "ben" logged in via basic auth.
|
||||||
|
func newEmailTestServer(t *testing.T, databaseURL string) (*Server, *captureMailer, map[string]string) {
|
||||||
|
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||||
|
conf.SMTPSenderAddr = "localhost:25"
|
||||||
|
conf.SMTPSenderFrom = "noreply@example.com"
|
||||||
|
conf.BaseURL = "https://ntfy.example.com"
|
||||||
|
s := newTestServer(t, conf)
|
||||||
|
mailer := newCaptureMailer()
|
||||||
|
s.mailer = mailer
|
||||||
|
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
||||||
|
auth := map[string]string{"Authorization": util.BasicAuth("ben", "ben")}
|
||||||
|
return s, mailer, auth
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAccount(t *testing.T, s *Server, auth map[string]string) *apiAccountResponse {
|
||||||
|
rr := request(t, s, "GET", "/v1/account", "", auth)
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
account, err := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
|
||||||
|
require.Nil(t, err)
|
||||||
|
return account
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifiedAddrs / pendingAddrs / primaryAddr extract the addresses from the structured email
|
||||||
|
// list returned by GET /v1/account, so assertions stay readable.
|
||||||
|
func verifiedAddrs(account *apiAccountResponse) []string {
|
||||||
|
addrs := make([]string, 0)
|
||||||
|
for _, e := range account.Emails {
|
||||||
|
if !e.Pending {
|
||||||
|
addrs = append(addrs, e.Address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return addrs
|
||||||
|
}
|
||||||
|
|
||||||
|
func pendingAddrs(account *apiAccountResponse) []string {
|
||||||
|
addrs := make([]string, 0)
|
||||||
|
for _, e := range account.Emails {
|
||||||
|
if e.Pending {
|
||||||
|
addrs = append(addrs, e.Address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return addrs
|
||||||
|
}
|
||||||
|
|
||||||
|
func primaryAddr(account *apiAccountResponse) string {
|
||||||
|
for _, e := range account.Emails {
|
||||||
|
if e.Primary {
|
||||||
|
return e.Address
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func tokenFromLink(t *testing.T, link, prefix string) string {
|
||||||
|
require.True(t, strings.HasPrefix(link, prefix), "link %q missing prefix %q", link, prefix)
|
||||||
|
return strings.TrimPrefix(link, prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccount_Email_AddVerifySetsPrimary(t *testing.T) {
|
||||||
|
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||||
|
s, mailer, auth := newEmailTestServer(t, databaseURL)
|
||||||
|
defer s.closeDatabases()
|
||||||
|
|
||||||
|
// Start verification
|
||||||
|
rr := request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, auth)
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
// Pending, not yet verified, no primary
|
||||||
|
account := getAccount(t, s, auth)
|
||||||
|
require.Equal(t, []string{"ben@example.com"}, pendingAddrs(account))
|
||||||
|
require.Empty(t, verifiedAddrs(account))
|
||||||
|
require.Equal(t, "", primaryAddr(account))
|
||||||
|
|
||||||
|
// "Click" the captured link (unauthenticated POST)
|
||||||
|
token := tokenFromLink(t, mailer.verifyLinks["ben@example.com"], "https://ntfy.example.com/account/email/verify/")
|
||||||
|
rr = request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, token), nil)
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
// Now verified + primary, no longer pending
|
||||||
|
account = getAccount(t, s, auth)
|
||||||
|
require.Equal(t, []string{"ben@example.com"}, verifiedAddrs(account))
|
||||||
|
require.Equal(t, "ben@example.com", primaryAddr(account))
|
||||||
|
require.Empty(t, pendingAddrs(account))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccount_Email_VerifyInvalidToken(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/email/verify", `{"token":"doesnotexist"}`, nil)
|
||||||
|
require.Equal(t, 400, rr.Code)
|
||||||
|
require.Equal(t, 40051, toHTTPError(t, rr.Body.String()).Code)
|
||||||
|
|
||||||
|
// Empty token also rejected
|
||||||
|
rr = request(t, s, "POST", "/v1/account/email/verify", `{"token":""}`, nil)
|
||||||
|
require.Equal(t, 400, rr.Code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccount_Email_DeletePending(t *testing.T) {
|
||||||
|
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||||
|
s, _, auth := newEmailTestServer(t, databaseURL)
|
||||||
|
defer s.closeDatabases()
|
||||||
|
|
||||||
|
require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, auth).Code)
|
||||||
|
require.Equal(t, []string{"ben@example.com"}, pendingAddrs(getAccount(t, s, auth)))
|
||||||
|
|
||||||
|
// Deleting the pending address clears it (no verification ever happened)
|
||||||
|
require.Equal(t, 200, request(t, s, "DELETE", "/v1/account/email", `{"email":"ben@example.com"}`, auth).Code)
|
||||||
|
account := getAccount(t, s, auth)
|
||||||
|
require.Empty(t, pendingAddrs(account))
|
||||||
|
require.Empty(t, verifiedAddrs(account))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccount_Email_Resend(t *testing.T) {
|
||||||
|
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||||
|
s, mailer, auth := newEmailTestServer(t, databaseURL)
|
||||||
|
defer s.closeDatabases()
|
||||||
|
|
||||||
|
require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, auth).Code)
|
||||||
|
firstLink := mailer.verifyLinks["ben@example.com"]
|
||||||
|
require.NotEmpty(t, firstLink)
|
||||||
|
|
||||||
|
// Resend issues a fresh link (the old one is replaced)
|
||||||
|
require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/resend", `{"email":"ben@example.com"}`, auth).Code)
|
||||||
|
require.NotEqual(t, firstLink, mailer.verifyLinks["ben@example.com"])
|
||||||
|
|
||||||
|
// The old token no longer verifies; the new one does
|
||||||
|
oldToken := tokenFromLink(t, firstLink, "https://ntfy.example.com/account/email/verify/")
|
||||||
|
require.Equal(t, 400, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, oldToken), nil).Code)
|
||||||
|
newToken := tokenFromLink(t, mailer.verifyLinks["ben@example.com"], "https://ntfy.example.com/account/email/verify/")
|
||||||
|
require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, newToken), nil).Code)
|
||||||
|
|
||||||
|
// Resending for a non-pending address is rejected
|
||||||
|
require.Equal(t, 400, request(t, s, "POST", "/v1/account/email/resend", `{"email":"never@example.com"}`, auth).Code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccount_Email_SetPrimaryCollision(t *testing.T) {
|
||||||
|
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||||
|
s, mailer, auth := newEmailTestServer(t, databaseURL)
|
||||||
|
defer s.closeDatabases()
|
||||||
|
|
||||||
|
// ben verifies shared@ -> becomes his primary
|
||||||
|
require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", `{"email":"shared@example.com"}`, auth).Code)
|
||||||
|
benToken := tokenFromLink(t, mailer.verifyLinks["shared@example.com"], "https://ntfy.example.com/account/email/verify/")
|
||||||
|
require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, benToken), nil).Code)
|
||||||
|
require.Equal(t, "shared@example.com", primaryAddr(getAccount(t, s, auth)))
|
||||||
|
|
||||||
|
// alice verifies the same address -> allowed as secondary, but it is not her primary
|
||||||
|
require.Nil(t, s.userManager.AddUser("alice", "alice", user.RoleUser, false))
|
||||||
|
aliceAuth := map[string]string{"Authorization": util.BasicAuth("alice", "alice")}
|
||||||
|
require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", `{"email":"shared@example.com"}`, aliceAuth).Code)
|
||||||
|
aliceToken := tokenFromLink(t, mailer.verifyLinks["shared@example.com"], "https://ntfy.example.com/account/email/verify/")
|
||||||
|
require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, aliceToken), nil).Code)
|
||||||
|
aliceAccount := getAccount(t, s, aliceAuth)
|
||||||
|
require.Equal(t, []string{"shared@example.com"}, verifiedAddrs(aliceAccount))
|
||||||
|
require.Equal(t, "", primaryAddr(aliceAccount))
|
||||||
|
|
||||||
|
// alice trying to promote it to primary collides with ben's
|
||||||
|
rr := request(t, s, "POST", "/v1/account/email/primary", `{"email":"shared@example.com"}`, aliceAuth)
|
||||||
|
require.Equal(t, 409, rr.Code)
|
||||||
|
require.Equal(t, 40908, toHTTPError(t, rr.Body.String()).Code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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_EmailLookalikeUsernameDoesNotShadow(t *testing.T) {
|
||||||
|
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||||
|
s, mailer, auth := newEmailTestServer(t, databaseURL)
|
||||||
|
defer s.closeDatabases()
|
||||||
|
|
||||||
|
// Account A (the email owner): user "ben" with verified primary email "phil@example.com"
|
||||||
|
verifyEmailFor(t, s, mailer, auth, "phil@example.com")
|
||||||
|
|
||||||
|
// Account B (the squatter): a different account whose USERNAME looks like A's email, with
|
||||||
|
// its own, different verified primary email
|
||||||
|
require.Nil(t, s.userManager.AddUser("phil@example.com", "squatterpass", user.RoleUser, false))
|
||||||
|
squatter, err := s.userManager.User("phil@example.com")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Nil(t, s.userManager.AddEmail(squatter.ID, "squatter@example.com"))
|
||||||
|
require.Nil(t, s.userManager.SetPrimaryEmail(squatter.ID, "squatter@example.com"))
|
||||||
|
|
||||||
|
// Reset by the ambiguous identifier: the verified email must win over the look-alike username
|
||||||
|
rr := request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"phil@example.com"}`, nil)
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
require.NotEmpty(t, mailer.resetLinks["phil@example.com"]) // sent to the email owner (account A)
|
||||||
|
require.Empty(t, mailer.resetLinks["squatter@example.com"]) // NOT the username squatter (account B)
|
||||||
|
|
||||||
|
// The token resets account A (ben); the squatter's password is untouched
|
||||||
|
token := tokenFromLink(t, mailer.resetLinks["phil@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")) // account A was reset
|
||||||
|
require.True(t, canLogin(t, s, "phil@example.com", "squatterpass")) // account B unaffected
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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_Signup_WithEmail_SendsVerification(t *testing.T) {
|
||||||
|
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||||
|
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||||
|
conf.EnableSignup = true
|
||||||
|
conf.SMTPSenderAddr = "localhost:25"
|
||||||
|
conf.SMTPSenderFrom = "noreply@example.com"
|
||||||
|
conf.BaseURL = "https://ntfy.example.com"
|
||||||
|
s := newTestServer(t, conf)
|
||||||
|
mailer := newCaptureMailer()
|
||||||
|
s.mailer = mailer
|
||||||
|
defer s.closeDatabases()
|
||||||
|
|
||||||
|
// Sign up with an optional email -> account created and a verification link sent
|
||||||
|
rr := request(t, s, "POST", "/v1/account", `{"username":"emma","password":"emmapass","email":"emma@example.com"}`, nil)
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
link := mailer.verifyLinks["emma@example.com"]
|
||||||
|
require.NotEmpty(t, link)
|
||||||
|
|
||||||
|
// Verifying the link makes it the (first) primary email
|
||||||
|
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)
|
||||||
|
account := getAccount(t, s, map[string]string{"Authorization": util.BasicAuth("emma", "emmapass")})
|
||||||
|
require.Equal(t, []string{"emma@example.com"}, verifiedAddrs(account))
|
||||||
|
require.Equal(t, "emma@example.com", primaryAddr(account))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccount_Signup_WithoutEmail_NoSend(t *testing.T) {
|
||||||
|
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||||
|
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||||
|
conf.EnableSignup = true
|
||||||
|
conf.SMTPSenderAddr = "localhost:25"
|
||||||
|
conf.SMTPSenderFrom = "noreply@example.com"
|
||||||
|
conf.BaseURL = "https://ntfy.example.com"
|
||||||
|
s := newTestServer(t, conf)
|
||||||
|
mailer := newCaptureMailer()
|
||||||
|
s.mailer = mailer
|
||||||
|
defer s.closeDatabases()
|
||||||
|
|
||||||
|
// No email -> account created, nothing sent
|
||||||
|
require.Equal(t, 200, request(t, s, "POST", "/v1/account", `{"username":"emma","password":"emmapass"}`, nil).Code)
|
||||||
|
require.Empty(t, mailer.verifyLinks)
|
||||||
|
|
||||||
|
// Invalid email -> rejected
|
||||||
|
rr := request(t, s, "POST", "/v1/account", `{"username":"otto","password":"ottopass","email":"not-an-email"}`, nil)
|
||||||
|
require.Equal(t, 400, rr.Code)
|
||||||
|
require.Equal(t, 40050, toHTTPError(t, rr.Body.String()).Code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccount_Email_ProvisionedPrimary(t *testing.T) {
|
||||||
|
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||||
|
hash, err := user.HashPassword("provpass", user.DefaultUserPasswordBcryptCost)
|
||||||
|
require.Nil(t, err)
|
||||||
|
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||||
|
conf.SMTPSenderAddr = "localhost:25"
|
||||||
|
conf.SMTPSenderFrom = "noreply@example.com"
|
||||||
|
conf.BaseURL = "https://ntfy.example.com"
|
||||||
|
conf.AuthUsers = []*user.User{{Name: "prov", Hash: hash, Role: user.RoleUser}}
|
||||||
|
s := newTestServer(t, conf)
|
||||||
|
mailer := newCaptureMailer()
|
||||||
|
s.mailer = mailer
|
||||||
|
defer s.closeDatabases()
|
||||||
|
auth := map[string]string{"Authorization": util.BasicAuth("prov", "provpass")}
|
||||||
|
|
||||||
|
// A provisioned user's first verified email becomes their primary (used by X-Email: yes;
|
||||||
|
// password reset stays blocked separately for provisioned users)
|
||||||
|
verifyEmailFor(t, s, mailer, auth, "prov@example.com")
|
||||||
|
account := getAccount(t, s, auth)
|
||||||
|
require.Equal(t, []string{"prov@example.com"}, verifiedAddrs(account))
|
||||||
|
require.Equal(t, "prov@example.com", primaryAddr(account))
|
||||||
|
|
||||||
|
// Verify a second address and explicitly set it primary -> allowed, star moves
|
||||||
|
verifyEmailFor(t, s, mailer, auth, "prov2@example.com")
|
||||||
|
rr := request(t, s, "POST", "/v1/account/email/primary", `{"email":"prov2@example.com"}`, auth)
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
account = getAccount(t, s, auth)
|
||||||
|
require.Equal(t, "prov2@example.com", primaryAddr(account))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccount_PasswordReset_ProvisionedUserNoSend(t *testing.T) {
|
||||||
|
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||||
|
// Provision a user via config (AuthUsers), with email sending enabled
|
||||||
|
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||||
|
conf.SMTPSenderAddr = "localhost:25"
|
||||||
|
conf.SMTPSenderFrom = "noreply@example.com"
|
||||||
|
conf.BaseURL = "https://ntfy.example.com"
|
||||||
|
conf.AuthUsers = []*user.User{
|
||||||
|
{Name: "prov", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: user.RoleUser},
|
||||||
|
}
|
||||||
|
s := newTestServer(t, conf)
|
||||||
|
mailer := newCaptureMailer()
|
||||||
|
s.mailer = mailer
|
||||||
|
defer s.closeDatabases()
|
||||||
|
|
||||||
|
// Give the provisioned user a verified primary email anyway
|
||||||
|
prov, err := s.userManager.User("prov")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.True(t, prov.Provisioned)
|
||||||
|
require.Nil(t, s.userManager.AddEmail(prov.ID, "prov@example.com"))
|
||||||
|
require.Nil(t, s.userManager.SetPrimaryEmail(prov.ID, "prov@example.com"))
|
||||||
|
|
||||||
|
// Reset request by username and by email -> uniform 200, but no email sent (can't reset)
|
||||||
|
require.Equal(t, 200, request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"prov"}`, nil).Code)
|
||||||
|
require.Equal(t, 200, request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"prov@example.com"}`, nil).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) {
|
||||||
|
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||||
|
s, mailer, auth := newEmailTestServer(t, databaseURL)
|
||||||
|
defer s.closeDatabases()
|
||||||
|
|
||||||
|
require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, auth).Code)
|
||||||
|
token := tokenFromLink(t, mailer.verifyLinks["ben@example.com"], "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)
|
||||||
|
|
||||||
|
// Adding the same already-verified address is a conflict
|
||||||
|
rr := request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, auth)
|
||||||
|
require.Equal(t, 409, rr.Code)
|
||||||
|
require.Equal(t, 40907, toHTTPError(t, rr.Body.String()).Code)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -78,7 +78,8 @@ func TestAccount_Signup_LimitReached(t *testing.T) {
|
|||||||
s := newTestServer(t, conf)
|
s := newTestServer(t, conf)
|
||||||
defer s.closeDatabases()
|
defer s.closeDatabases()
|
||||||
|
|
||||||
for i := 0; i < 3; i++ {
|
// Burst is DefaultVisitorAccountCreationLimitBurst (shared with password-reset requests)
|
||||||
|
for i := 0; i < 6; i++ {
|
||||||
rr := request(t, s, "POST", "/v1/account", fmt.Sprintf(`{"username":"phil%d", "password":"mypass"}`, i), nil)
|
rr := request(t, s, "POST", "/v1/account", fmt.Sprintf(`{"username":"phil%d", "password":"mypass"}`, i), nil)
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
}
|
}
|
||||||
@@ -131,7 +132,8 @@ func TestAccount_Signup_Rate_Limit(t *testing.T) {
|
|||||||
conf.EnableSignup = true
|
conf.EnableSignup = true
|
||||||
s := newTestServer(t, conf)
|
s := newTestServer(t, conf)
|
||||||
|
|
||||||
for i := 0; i < 3; i++ {
|
// Burst is DefaultVisitorAccountCreationLimitBurst (shared with password-reset requests)
|
||||||
|
for i := 0; i < 6; i++ {
|
||||||
rr := request(t, s, "POST", "/v1/account", fmt.Sprintf(`{"username":"phil%d", "password":"mypass"}`, i), nil)
|
rr := request(t, s, "POST", "/v1/account", fmt.Sprintf(`{"username":"phil%d", "password":"mypass"}`, i), nil)
|
||||||
require.Equal(t, 200, rr.Code, "failed on iteration %d", i)
|
require.Equal(t, 200, rr.Code, "failed on iteration %d", i)
|
||||||
}
|
}
|
||||||
@@ -149,7 +151,7 @@ func TestAccount_Get_Anonymous(t *testing.T) {
|
|||||||
conf.VisitorAttachmentTotalSizeLimit = 5123
|
conf.VisitorAttachmentTotalSizeLimit = 5123
|
||||||
conf.AttachmentFileSizeLimit = 512
|
conf.AttachmentFileSizeLimit = 512
|
||||||
s := newTestServer(t, conf)
|
s := newTestServer(t, conf)
|
||||||
s.smtpSender = &testMailer{}
|
s.mailer = &testMailer{}
|
||||||
defer s.closeDatabases()
|
defer s.closeDatabases()
|
||||||
|
|
||||||
rr := request(t, s, "GET", "/v1/account", "", nil)
|
rr := request(t, s, "GET", "/v1/account", "", nil)
|
||||||
|
|||||||
@@ -54,8 +54,8 @@ func (s *Server) execManager() {
|
|||||||
receivedMailTotal, receivedMailSuccess, receivedMailFailure = s.smtpServerBackend.Counts()
|
receivedMailTotal, receivedMailSuccess, receivedMailFailure = s.smtpServerBackend.Counts()
|
||||||
}
|
}
|
||||||
var sentMailTotal, sentMailSuccess, sentMailFailure int64
|
var sentMailTotal, sentMailSuccess, sentMailFailure int64
|
||||||
if s.smtpSender != nil {
|
if s.mailer != nil {
|
||||||
sentMailTotal, sentMailSuccess, sentMailFailure = s.smtpSender.Counts()
|
sentMailTotal, sentMailSuccess, sentMailFailure = s.mailer.NotificationCounts()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Users
|
// Users
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ func (s *Server) ensureCallsEnabled(next handleFunc) handleFunc {
|
|||||||
|
|
||||||
func (s *Server) ensureEmailsEnabled(next handleFunc) handleFunc {
|
func (s *Server) ensureEmailsEnabled(next handleFunc) handleFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
if s.mailSender == nil || s.userManager == nil {
|
if s.mailer == nil || s.userManager == nil {
|
||||||
return errHTTPNotFound
|
return errHTTPNotFound
|
||||||
}
|
}
|
||||||
return next(w, r, v)
|
return next(w, r, v)
|
||||||
|
|||||||
@@ -237,10 +237,41 @@ 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).
|
||||||
|
// Provisioned users can't reset their password, so recovery setup doesn't apply to them.
|
||||||
|
if sess.CustomerDetails != nil && !u.Provisioned {
|
||||||
|
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.mailer == 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.mailer = 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
+137
-24
@@ -264,6 +264,27 @@ func TestServer_StaticSites(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServer_WebApp_MagicLinkLandingPagesNoIndexHeaders(t *testing.T) {
|
||||||
|
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||||
|
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||||
|
|
||||||
|
// Magic-link landing pages carry a one-time token in the path, so the response must not
|
||||||
|
// leak the token via the Referer header and must not be indexed
|
||||||
|
for _, path := range []string{"/account/email/verify/sometoken", "/account/password/reset/sometoken"} {
|
||||||
|
rr := request(t, s, "GET", path, "", nil)
|
||||||
|
require.Equal(t, 200, rr.Code, path)
|
||||||
|
require.Equal(t, "no-referrer", rr.Header().Get("Referrer-Policy"), path)
|
||||||
|
require.Equal(t, "noindex", rr.Header().Get("X-Robots-Tag"), path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordinary web app routes do not set these headers
|
||||||
|
rr := request(t, s, "GET", "/", "", nil)
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
require.Empty(t, rr.Header().Get("Referrer-Policy"))
|
||||||
|
require.Empty(t, rr.Header().Get("X-Robots-Tag"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestServer_WebEnabled(t *testing.T) {
|
func TestServer_WebEnabled(t *testing.T) {
|
||||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||||
conf := newTestConfig(t, databaseURL)
|
conf := newTestConfig(t, databaseURL)
|
||||||
@@ -740,7 +761,7 @@ func TestServer_PublishMessageInHeaderWithNewlines(t *testing.T) {
|
|||||||
func TestServer_PublishInvalidTopic(t *testing.T) {
|
func TestServer_PublishInvalidTopic(t *testing.T) {
|
||||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||||
s.smtpSender = &testMailer{}
|
s.mailer = &testMailer{}
|
||||||
response := request(t, s, "PUT", "/docs", "fail", nil)
|
response := request(t, s, "PUT", "/docs", "fail", nil)
|
||||||
require.Equal(t, 40010, toHTTPError(t, response.Body.String()).Code)
|
require.Equal(t, 40010, toHTTPError(t, response.Body.String()).Code)
|
||||||
})
|
})
|
||||||
@@ -1231,7 +1252,7 @@ func TestServer_StatsResetter_MessageLimiter_EmailsLimiter(t *testing.T) {
|
|||||||
|
|
||||||
c := newTestConfigWithAuthFile(t, databaseURL)
|
c := newTestConfigWithAuthFile(t, databaseURL)
|
||||||
s := newTestServer(t, c)
|
s := newTestServer(t, c)
|
||||||
s.smtpSender = &testMailer{}
|
s.mailer = &testMailer{}
|
||||||
|
|
||||||
// Publish some messages, and check stats
|
// Publish some messages, and check stats
|
||||||
for i := 0; i < 3; i++ {
|
for i := 0; i < 3; i++ {
|
||||||
@@ -1315,18 +1336,20 @@ func TestServer_DailyMessageQuotaFromDatabase(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type testMailer struct {
|
type testMailer struct {
|
||||||
count int
|
count int
|
||||||
mu sync.Mutex
|
lastTo string
|
||||||
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *testMailer) Send(v *visitor, m *model.Message, to string) error {
|
func (t *testMailer) SendNotification(to string, m *model.Message, senderIP string) error {
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
defer t.mu.Unlock()
|
defer t.mu.Unlock()
|
||||||
t.count++
|
t.count++
|
||||||
|
t.lastTo = to
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *testMailer) Counts() (total int64, success int64, failure int64) {
|
func (t *testMailer) NotificationCounts() (total int64, success int64, failure int64) {
|
||||||
return 0, 0, 0
|
return 0, 0, 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1336,6 +1359,16 @@ func (t *testMailer) Count() int {
|
|||||||
return t.count
|
return t.count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *testMailer) LastTo() string {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
return t.lastTo
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *testMailer) SendEmailVerification(to, link string) error { return nil }
|
||||||
|
|
||||||
|
func (t *testMailer) SendPasswordReset(to, link string) error { return nil }
|
||||||
|
|
||||||
func TestServer_PublishTooManyRequests_Defaults(t *testing.T) {
|
func TestServer_PublishTooManyRequests_Defaults(t *testing.T) {
|
||||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||||
@@ -1461,7 +1494,7 @@ func TestServer_PublishTooManyRequests_ShortReplenish(t *testing.T) {
|
|||||||
func TestServer_PublishTooManyEmails_Defaults(t *testing.T) {
|
func TestServer_PublishTooManyEmails_Defaults(t *testing.T) {
|
||||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||||
s.smtpSender = &testMailer{}
|
s.mailer = &testMailer{}
|
||||||
for i := 0; i < 16; i++ {
|
for i := 0; i < 16; i++ {
|
||||||
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{
|
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{
|
||||||
"E-Mail": "test@example.com",
|
"E-Mail": "test@example.com",
|
||||||
@@ -1481,7 +1514,7 @@ func TestServer_PublishTooManyEmails_Replenish(t *testing.T) {
|
|||||||
c := newTestConfig(t, databaseURL)
|
c := newTestConfig(t, databaseURL)
|
||||||
c.VisitorEmailLimitReplenish = 500 * time.Millisecond
|
c.VisitorEmailLimitReplenish = 500 * time.Millisecond
|
||||||
s := newTestServer(t, c)
|
s := newTestServer(t, c)
|
||||||
s.smtpSender = &testMailer{}
|
s.mailer = &testMailer{}
|
||||||
for i := 0; i < 16; i++ {
|
for i := 0; i < 16; i++ {
|
||||||
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{
|
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{
|
||||||
"E-Mail": "test@example.com",
|
"E-Mail": "test@example.com",
|
||||||
@@ -1509,7 +1542,7 @@ func TestServer_PublishTooManyEmails_Replenish(t *testing.T) {
|
|||||||
func TestServer_PublishDelayedEmail_Fail(t *testing.T) {
|
func TestServer_PublishDelayedEmail_Fail(t *testing.T) {
|
||||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||||
s.smtpSender = &testMailer{}
|
s.mailer = &testMailer{}
|
||||||
response := request(t, s, "PUT", "/mytopic", "fail", map[string]string{
|
response := request(t, s, "PUT", "/mytopic", "fail", map[string]string{
|
||||||
"E-Mail": "test@example.com",
|
"E-Mail": "test@example.com",
|
||||||
"Delay": "20 min",
|
"Delay": "20 min",
|
||||||
@@ -1546,7 +1579,7 @@ func TestServer_PublishEmailNoMailer_Fail(t *testing.T) {
|
|||||||
func TestServer_PublishEmailAddressInvalid(t *testing.T) {
|
func TestServer_PublishEmailAddressInvalid(t *testing.T) {
|
||||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||||
s.smtpSender = &testMailer{}
|
s.mailer = &testMailer{}
|
||||||
addresses := []string{
|
addresses := []string{
|
||||||
"test@example.com, other@example.com",
|
"test@example.com, other@example.com",
|
||||||
"invalidaddress",
|
"invalidaddress",
|
||||||
@@ -1572,7 +1605,7 @@ func TestServer_PublishEmailVerify_VerifiedAddress(t *testing.T) {
|
|||||||
conf := newTestConfigWithAuthFile(t, databaseURL)
|
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||||
conf.SMTPSenderVerify = true
|
conf.SMTPSenderVerify = true
|
||||||
s := newTestServer(t, conf)
|
s := newTestServer(t, conf)
|
||||||
s.smtpSender = &testMailer{}
|
s.mailer = &testMailer{}
|
||||||
defer s.closeDatabases()
|
defer s.closeDatabases()
|
||||||
|
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||||
@@ -1602,7 +1635,7 @@ func TestServer_PublishEmailVerify_BoolValue(t *testing.T) {
|
|||||||
conf := newTestConfigWithAuthFile(t, databaseURL)
|
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||||
conf.SMTPSenderVerify = true
|
conf.SMTPSenderVerify = true
|
||||||
s := newTestServer(t, conf)
|
s := newTestServer(t, conf)
|
||||||
s.smtpSender = &testMailer{}
|
s.mailer = &testMailer{}
|
||||||
defer s.closeDatabases()
|
defer s.closeDatabases()
|
||||||
|
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||||
@@ -1628,17 +1661,97 @@ func TestServer_PublishEmailVerify_BoolValue(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_PublishEmailVerify_BoolValue_NoVerify(t *testing.T) {
|
func TestServer_PublishEmailVerify_BoolValueUsesPrimary(t *testing.T) {
|
||||||
|
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||||
|
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||||
|
conf.SMTPSenderVerify = true
|
||||||
|
s := newTestServer(t, conf)
|
||||||
|
mailer := &testMailer{}
|
||||||
|
s.mailer = mailer
|
||||||
|
defer s.closeDatabases()
|
||||||
|
|
||||||
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||||
|
u, err := s.userManager.User("phil")
|
||||||
|
require.Nil(t, err)
|
||||||
|
// Two verified emails; the primary is NOT the alphabetically-first one
|
||||||
|
require.Nil(t, s.userManager.AddEmail(u.ID, "aaa@example.com"))
|
||||||
|
require.Nil(t, s.userManager.AddEmail(u.ID, "zzz@example.com"))
|
||||||
|
require.Nil(t, s.userManager.SetPrimaryEmail(u.ID, "zzz@example.com"))
|
||||||
|
|
||||||
|
// "yes" must resolve to the primary email, not emails[0] (alphabetically first)
|
||||||
|
response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{
|
||||||
|
"Email": "yes",
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
require.Equal(t, "zzz@example.com", mailer.LastTo())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishEmailVerify_BoolValueNoVerifyUsesPrimary(t *testing.T) {
|
||||||
|
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||||
|
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||||
|
// smtp-sender-verify intentionally left false (the default)
|
||||||
|
s := newTestServer(t, conf)
|
||||||
|
mailer := &testMailer{}
|
||||||
|
s.mailer = mailer
|
||||||
|
defer s.closeDatabases()
|
||||||
|
|
||||||
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||||
|
u, err := s.userManager.User("phil")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Nil(t, s.userManager.AddEmail(u.ID, "aaa@example.com"))
|
||||||
|
require.Nil(t, s.userManager.AddEmail(u.ID, "zzz@example.com"))
|
||||||
|
require.Nil(t, s.userManager.SetPrimaryEmail(u.ID, "zzz@example.com"))
|
||||||
|
|
||||||
|
// Even with smtp-sender-verify off, "yes" resolves to the user's primary verified address
|
||||||
|
response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{
|
||||||
|
"Email": "yes",
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
require.Equal(t, "zzz@example.com", mailer.LastTo())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishEmailVerify_BoolValueAnonymousRejected(t *testing.T) {
|
||||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||||
s.smtpSender = &testMailer{}
|
s.mailer = &testMailer{}
|
||||||
|
|
||||||
// "yes" without smtp-sender-verify should fail with invalid address
|
// "yes" requires an authenticated user (it means "my primary"); anonymous is rejected
|
||||||
response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{
|
response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{
|
||||||
"Email": "yes",
|
"Email": "yes",
|
||||||
})
|
})
|
||||||
require.Equal(t, 400, response.Code)
|
require.Equal(t, 400, response.Code)
|
||||||
require.Equal(t, 40050, toHTTPError(t, response.Body.String()).Code)
|
require.Equal(t, 40053, toHTTPError(t, response.Body.String()).Code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishEmailVerify_BoolValueProvisionedUsesPrimary(t *testing.T) {
|
||||||
|
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||||
|
hash, err := user.HashPassword("provpass", user.DefaultUserPasswordBcryptCost)
|
||||||
|
require.Nil(t, err)
|
||||||
|
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||||
|
conf.AuthUsers = []*user.User{{Name: "prov", Hash: hash, Role: user.RoleUser}}
|
||||||
|
s := newTestServer(t, conf)
|
||||||
|
mailer := &testMailer{}
|
||||||
|
s.mailer = mailer
|
||||||
|
defer s.closeDatabases()
|
||||||
|
|
||||||
|
prov, err := s.userManager.User("prov")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Nil(t, s.userManager.AddEmail(prov.ID, "aaa@example.com"))
|
||||||
|
require.Nil(t, s.userManager.AddEmail(prov.ID, "zzz@example.com"))
|
||||||
|
require.Nil(t, s.userManager.SetPrimaryEmail(prov.ID, "zzz@example.com"))
|
||||||
|
|
||||||
|
// A provisioned user's "yes" resolves to their chosen primary, not the alphabetically-first
|
||||||
|
response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{
|
||||||
|
"Email": "yes",
|
||||||
|
"Authorization": util.BasicAuth("prov", "provpass"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
require.Equal(t, "zzz@example.com", mailer.LastTo())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1647,7 +1760,7 @@ func TestServer_PublishEmailVerify_Anonymous(t *testing.T) {
|
|||||||
conf := newTestConfigWithAuthFile(t, databaseURL)
|
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||||
conf.SMTPSenderVerify = true
|
conf.SMTPSenderVerify = true
|
||||||
s := newTestServer(t, conf)
|
s := newTestServer(t, conf)
|
||||||
s.smtpSender = &testMailer{}
|
s.mailer = &testMailer{}
|
||||||
defer s.closeDatabases()
|
defer s.closeDatabases()
|
||||||
|
|
||||||
// Anonymous user should be rejected
|
// Anonymous user should be rejected
|
||||||
@@ -1664,7 +1777,7 @@ func TestServer_PublishEmailVerify_NoVerifiedEmails(t *testing.T) {
|
|||||||
conf := newTestConfigWithAuthFile(t, databaseURL)
|
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||||
conf.SMTPSenderVerify = true
|
conf.SMTPSenderVerify = true
|
||||||
s := newTestServer(t, conf)
|
s := newTestServer(t, conf)
|
||||||
s.smtpSender = &testMailer{}
|
s.mailer = &testMailer{}
|
||||||
defer s.closeDatabases()
|
defer s.closeDatabases()
|
||||||
|
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||||
@@ -1682,7 +1795,7 @@ func TestServer_PublishEmailVerify_NoVerifiedEmails(t *testing.T) {
|
|||||||
func TestServer_PublishEmailVerify_Disabled_Backwards_Compatible(t *testing.T) {
|
func TestServer_PublishEmailVerify_Disabled_Backwards_Compatible(t *testing.T) {
|
||||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||||
s.smtpSender = &testMailer{}
|
s.mailer = &testMailer{}
|
||||||
|
|
||||||
// Without smtp-sender-verify, any email address should work (backwards compatible)
|
// Without smtp-sender-verify, any email address should work (backwards compatible)
|
||||||
response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{
|
response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{
|
||||||
@@ -1706,11 +1819,11 @@ func TestServer_AccountEmailVerify_UserWithoutTier(t *testing.T) {
|
|||||||
// Create a user without a tier
|
// Create a user without a tier
|
||||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
||||||
|
|
||||||
// Verify email request should NOT return 401
|
// Starting email verification should NOT return 401
|
||||||
response := request(t, s, "PUT", "/v1/account/email/verify", `{"email":"ben@example.com"}`, map[string]string{
|
response := request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, map[string]string{
|
||||||
"Authorization": util.BasicAuth("ben", "ben"),
|
"Authorization": util.BasicAuth("ben", "ben"),
|
||||||
})
|
})
|
||||||
// The request will fail (SMTP not available), but it must NOT be a 401
|
// The request may fail (SMTP not available), but it must NOT be a 401
|
||||||
require.NotEqual(t, 401, response.Code)
|
require.NotEqual(t, 401, response.Code)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1731,7 +1844,7 @@ func TestServer_AccountEmailVerify_UserWithoutTier_EmailLimitZero(t *testing.T)
|
|||||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
||||||
|
|
||||||
// Should be rejected with 401 since email sending is disabled
|
// Should be rejected with 401 since email sending is disabled
|
||||||
response := request(t, s, "PUT", "/v1/account/email/verify", `{"email":"ben@example.com"}`, map[string]string{
|
response := request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, map[string]string{
|
||||||
"Authorization": util.BasicAuth("ben", "ben"),
|
"Authorization": util.BasicAuth("ben", "ben"),
|
||||||
})
|
})
|
||||||
require.Equal(t, 401, response.Code)
|
require.Equal(t, 401, response.Code)
|
||||||
@@ -2139,7 +2252,7 @@ func TestServer_PublishAsJSON_WithEmail(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
mailer := &testMailer{}
|
mailer := &testMailer{}
|
||||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||||
s.smtpSender = mailer
|
s.mailer = mailer
|
||||||
body := `{"topic":"mytopic","message":"A message","email":"phil@example.com"}`
|
body := `{"topic":"mytopic","message":"A message","email":"phil@example.com"}`
|
||||||
response := request(t, s, "PUT", "/", body, nil)
|
response := request(t, s, "PUT", "/", body, nil)
|
||||||
require.Equal(t, 200, response.Code)
|
require.Equal(t, 200, response.Code)
|
||||||
|
|||||||
+46
-20
@@ -185,6 +185,7 @@ type apiAccessResetRequest struct {
|
|||||||
type apiAccountCreateRequest struct {
|
type apiAccountCreateRequest struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
|
Email string `json:"email"` // Optional; if set (and SMTP configured), a verification link is sent
|
||||||
}
|
}
|
||||||
|
|
||||||
type apiAccountPasswordChangeRequest struct {
|
type apiAccountPasswordChangeRequest struct {
|
||||||
@@ -226,13 +227,29 @@ type apiAccountPhoneNumberAddRequest struct {
|
|||||||
Code string `json:"code"` // Only set when adding a phone number
|
Code string `json:"code"` // Only set when adding a phone number
|
||||||
}
|
}
|
||||||
|
|
||||||
type apiAccountEmailVerifyRequest struct {
|
// apiAccountEmailRequest carries an email address for the add/delete/set-primary/resend
|
||||||
|
// endpoints (all of which identify an email by address in the JSON body).
|
||||||
|
type apiAccountEmailRequest struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type apiAccountEmailAddRequest struct {
|
// apiAccountEmailVerifyRequest carries the raw magic-link token submitted (unauthenticated)
|
||||||
Email string `json:"email"`
|
// from the verification landing page.
|
||||||
Code string `json:"code"`
|
type apiAccountEmailVerifyRequest struct {
|
||||||
|
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 {
|
||||||
@@ -271,6 +288,15 @@ type apiAccountReservation struct {
|
|||||||
Everyone string `json:"everyone"`
|
Everyone string `json:"everyone"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// apiAccountEmailInfo describes one email address on the account, as returned by GET /v1/account.
|
||||||
|
// Verified addresses have pending=false; exactly one verified address may be primary (the
|
||||||
|
// recovery email). Pending addresses are awaiting a magic-link click and are never primary.
|
||||||
|
type apiAccountEmailInfo struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
Primary bool `json:"primary,omitempty"`
|
||||||
|
Pending bool `json:"pending,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type apiAccountBilling struct {
|
type apiAccountBilling struct {
|
||||||
Customer bool `json:"customer"`
|
Customer bool `json:"customer"`
|
||||||
Subscription bool `json:"subscription"`
|
Subscription bool `json:"subscription"`
|
||||||
@@ -291,7 +317,7 @@ type apiAccountResponse struct {
|
|||||||
Reservations []*apiAccountReservation `json:"reservations,omitempty"`
|
Reservations []*apiAccountReservation `json:"reservations,omitempty"`
|
||||||
Tokens []*apiAccountTokenResponse `json:"tokens,omitempty"`
|
Tokens []*apiAccountTokenResponse `json:"tokens,omitempty"`
|
||||||
PhoneNumbers []string `json:"phone_numbers,omitempty"`
|
PhoneNumbers []string `json:"phone_numbers,omitempty"`
|
||||||
Emails []string `json:"emails,omitempty"`
|
Emails []*apiAccountEmailInfo `json:"emails,omitempty"`
|
||||||
Tier *apiAccountTier `json:"tier,omitempty"`
|
Tier *apiAccountTier `json:"tier,omitempty"`
|
||||||
Limits *apiAccountLimits `json:"limits,omitempty"`
|
Limits *apiAccountLimits `json:"limits,omitempty"`
|
||||||
Stats *apiAccountStats `json:"stats,omitempty"`
|
Stats *apiAccountStats `json:"stats,omitempty"`
|
||||||
@@ -304,21 +330,21 @@ type apiAccountReservationRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type apiConfigResponse struct {
|
type apiConfigResponse struct {
|
||||||
BaseURL string `json:"base_url"`
|
BaseURL string `json:"base_url"`
|
||||||
AppRoot string `json:"app_root"`
|
AppRoot string `json:"app_root"`
|
||||||
EnableLogin bool `json:"enable_login"`
|
EnableLogin bool `json:"enable_login"`
|
||||||
RequireLogin bool `json:"require_login"`
|
RequireLogin bool `json:"require_login"`
|
||||||
EnableSignup bool `json:"enable_signup"`
|
EnableSignup bool `json:"enable_signup"`
|
||||||
EnablePayments bool `json:"enable_payments"`
|
EnablePayments bool `json:"enable_payments"`
|
||||||
EnableCalls bool `json:"enable_calls"`
|
EnableCalls bool `json:"enable_calls"`
|
||||||
EnableEmails bool `json:"enable_emails"`
|
EnableEmails bool `json:"enable_emails"`
|
||||||
EnableEmailVerify bool `json:"enable_email_verify"`
|
EnableResetPassword bool `json:"enable_reset_password"`
|
||||||
EnableReservations bool `json:"enable_reservations"`
|
EnableReservations bool `json:"enable_reservations"`
|
||||||
EnableWebPush bool `json:"enable_web_push"`
|
EnableWebPush bool `json:"enable_web_push"`
|
||||||
BillingContact string `json:"billing_contact"`
|
BillingContact string `json:"billing_contact"`
|
||||||
WebPushPublicKey string `json:"web_push_public_key"`
|
WebPushPublicKey string `json:"web_push_public_key"`
|
||||||
DisallowedTopics []string `json:"disallowed_topics"`
|
DisallowedTopics []string `json:"disallowed_topics"`
|
||||||
ConfigHash string `json:"config_hash"`
|
ConfigHash string `json:"config_hash"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type apiAccountBillingPrices struct {
|
type apiAccountBillingPrices struct {
|
||||||
|
|||||||
+7
-5
@@ -66,7 +66,7 @@ type visitor struct {
|
|||||||
subscriptionLimiter *util.FixedLimiter // Fixed limiter for active subscriptions (ongoing connections)
|
subscriptionLimiter *util.FixedLimiter // Fixed limiter for active subscriptions (ongoing connections)
|
||||||
topicCreationLimiter *rate.Limiter // Rate limiter for inserting new topics into the in-memory topic map
|
topicCreationLimiter *rate.Limiter // Rate limiter for inserting new topics into the in-memory topic map
|
||||||
bandwidthLimiter *util.RateLimiter // Limiter for attachment bandwidth downloads
|
bandwidthLimiter *util.RateLimiter // Limiter for attachment bandwidth downloads
|
||||||
accountLimiter *rate.Limiter // Rate limiter for account creation, may be nil
|
accountLimiter *rate.Limiter // Rate limiter for account actions (signup, password-reset requests), may be nil
|
||||||
authLimiter *rate.Limiter // Limiter for incorrect login attempts, may be nil
|
authLimiter *rate.Limiter // Limiter for incorrect login attempts, may be nil
|
||||||
firebase time.Time // Next allowed Firebase message
|
firebase time.Time // Next allowed Firebase message
|
||||||
seen time.Time // Last seen time of this visitor (needed for removal of stale visitors)
|
seen time.Time // Last seen time of this visitor (needed for removal of stale visitors)
|
||||||
@@ -280,8 +280,9 @@ func (v *visitor) AuthFailed() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AccountCreationAllowed returns true if a new account can be created
|
// AccountActionAllowed returns true if a rate-limited account action (signup or password-reset
|
||||||
func (v *visitor) AccountCreationAllowed() bool {
|
// request) is currently allowed for this visitor
|
||||||
|
func (v *visitor) AccountActionAllowed() bool {
|
||||||
v.mu.RLock() // limiters could be replaced!
|
v.mu.RLock() // limiters could be replaced!
|
||||||
defer v.mu.RUnlock()
|
defer v.mu.RUnlock()
|
||||||
if v.accountLimiter == nil || (v.accountLimiter != nil && v.accountLimiter.Tokens() < 1) {
|
if v.accountLimiter == nil || (v.accountLimiter != nil && v.accountLimiter.Tokens() < 1) {
|
||||||
@@ -290,8 +291,9 @@ func (v *visitor) AccountCreationAllowed() bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// AccountCreated decreases the account limiter. This is to be called after an account was created.
|
// AccountActionPerformed decreases the account limiter. This is to be called after a rate-limited
|
||||||
func (v *visitor) AccountCreated() {
|
// account action (signup or password-reset request).
|
||||||
|
func (v *visitor) AccountActionPerformed() {
|
||||||
v.mu.RLock() // limiters could be replaced!
|
v.mu.RLock() // limiters could be replaced!
|
||||||
defer v.mu.RUnlock()
|
defer v.mu.RUnlock()
|
||||||
if v.accountLimiter != nil {
|
if v.accountLimiter != nil {
|
||||||
|
|||||||
+293
-2
@@ -40,6 +40,7 @@ const (
|
|||||||
DefaultUserPasswordBcryptCost = 10
|
DefaultUserPasswordBcryptCost = 10
|
||||||
DefaultAccessCacheEnabled = false
|
DefaultAccessCacheEnabled = false
|
||||||
DefaultAccessCacheReloadInterval = 87 * time.Second
|
DefaultAccessCacheReloadInterval = 87 * time.Second
|
||||||
|
DefaultExpiredMagicLinkReapInterval = time.Hour // How often expired email-verify/password-reset links are swept
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -72,6 +73,9 @@ func newManager(d *db.DB, queries queries, config *Config) (*Manager, error) {
|
|||||||
if config.AccessCacheReloadInterval <= 0 {
|
if config.AccessCacheReloadInterval <= 0 {
|
||||||
config.AccessCacheReloadInterval = DefaultAccessCacheReloadInterval
|
config.AccessCacheReloadInterval = DefaultAccessCacheReloadInterval
|
||||||
}
|
}
|
||||||
|
if config.ExpiredMagicLinkReapInterval <= 0 {
|
||||||
|
config.ExpiredMagicLinkReapInterval = DefaultExpiredMagicLinkReapInterval
|
||||||
|
}
|
||||||
manager := &Manager{
|
manager := &Manager{
|
||||||
config: config,
|
config: config,
|
||||||
db: d,
|
db: d,
|
||||||
@@ -91,6 +95,7 @@ func newManager(d *db.DB, queries queries, config *Config) (*Manager, error) {
|
|||||||
go manager.asyncAccessCacheReloadLoop(manager.config.AccessCacheReloadInterval)
|
go manager.asyncAccessCacheReloadLoop(manager.config.AccessCacheReloadInterval)
|
||||||
}
|
}
|
||||||
go manager.asyncQueueWriteLoop(manager.config.QueueWriterInterval)
|
go manager.asyncQueueWriteLoop(manager.config.QueueWriterInterval)
|
||||||
|
go manager.asyncExpiredMagicLinkReapLoop(manager.config.ExpiredMagicLinkReapInterval)
|
||||||
return manager, nil
|
return manager, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +133,25 @@ func (a *Manager) asyncAccessCacheReloadLoop(interval time.Duration) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// asyncExpiredMagicLinkReapLoop periodically deletes expired email-verification and
|
||||||
|
// password-reset links so the user_magic_link table does not accumulate dead rows. Expiry is
|
||||||
|
// already enforced on read, so this is housekeeping only; it replaces the old in-memory
|
||||||
|
// expireLoop that lived in mail.Sender.
|
||||||
|
func (a *Manager) asyncExpiredMagicLinkReapLoop(interval time.Duration) {
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-a.quit:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := a.deleteExpiredMagicLinks(); err != nil {
|
||||||
|
log.Tag(tag).Err(err).Warn("Reaping expired magic links failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Authenticate checks username and password and returns a User if correct, and the user has not been
|
// Authenticate checks username and password and returns a User if correct, and the user has not been
|
||||||
// marked as deleted. The method returns in constant-ish time, regardless of whether the user exists or
|
// marked as deleted. The method returns in constant-ish time, regardless of whether the user exists or
|
||||||
// the password is correct or incorrect.
|
// the password is correct or incorrect.
|
||||||
@@ -494,6 +518,19 @@ func (a *Manager) UserByID(id string) (*User, error) {
|
|||||||
return a.readUser(rows)
|
return a.readUser(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UserByEmailOrUsername resolves an identifier to a single user, trying it first as a primary
|
||||||
|
// email address and then as a username. A verified, owned email takes precedence over a
|
||||||
|
// freely-chosen username, so a look-alike username cannot shadow the email's real owner. Returns
|
||||||
|
// ErrUserNotFound if neither matches.
|
||||||
|
func (a *Manager) UserByEmailOrUsername(identifier string) (*User, error) {
|
||||||
|
if userID, err := a.UserIDByPrimaryEmail(identifier); err == nil {
|
||||||
|
if u, err := a.UserByID(userID); err == nil {
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return a.User(identifier)
|
||||||
|
}
|
||||||
|
|
||||||
// userByToken returns the user with the given token if it exists and is not expired, or ErrUserNotFound otherwise
|
// userByToken returns the user with the given token if it exists and is not expired, or ErrUserNotFound otherwise
|
||||||
func (a *Manager) userByToken(token string) (*User, error) {
|
func (a *Manager) userByToken(token string) (*User, error) {
|
||||||
rows, err := a.db.Query(a.queries.selectUserByToken, token, time.Now().Unix())
|
rows, err := a.db.Query(a.queries.selectUserByToken, token, time.Now().Unix())
|
||||||
@@ -630,7 +667,7 @@ func (a *Manager) maybeHashPassword(password string, hashed bool) (string, error
|
|||||||
}
|
}
|
||||||
return password, nil
|
return password, nil
|
||||||
}
|
}
|
||||||
return hashPassword(password, a.config.BcryptCost)
|
return HashPassword(password, a.config.BcryptCost)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authorize returns nil if the given user has access to the given topic using the desired
|
// Authorize returns nil if the given user has access to the given topic using the desired
|
||||||
@@ -1451,12 +1488,266 @@ func (a *Manager) AddEmail(userID, email string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveEmail deletes a verified email address from the user with the given user ID
|
// RemoveEmail deletes a verified email address from the user with the given user ID.
|
||||||
|
// Removing the primary email leaves the account with no primary -- there is deliberately
|
||||||
|
// no auto-promotion of another verified address; the user is nudged to pick a new one.
|
||||||
func (a *Manager) RemoveEmail(userID, email string) error {
|
func (a *Manager) RemoveEmail(userID, email string) error {
|
||||||
_, err := a.db.Exec(a.queries.deleteEmail, userID, email)
|
_, err := a.db.Exec(a.queries.deleteEmail, userID, email)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PrimaryEmail returns the user's primary (recovery) email address, or an empty string if
|
||||||
|
// the user has not designated one.
|
||||||
|
func (a *Manager) PrimaryEmail(userID string) (string, error) {
|
||||||
|
var email sql.NullString
|
||||||
|
err := a.db.ReadOnly().QueryRow(a.queries.selectPrimaryEmail, userID).Scan(&email)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", nil
|
||||||
|
} else if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return email.String, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserIDByPrimaryEmail returns the ID of the (at most one) account for which the given address
|
||||||
|
// is the primary email. Returns ErrUserNotFound if no account claims it as primary. Used by the
|
||||||
|
// password-reset request flow to resolve an email identifier to a single account.
|
||||||
|
func (a *Manager) UserIDByPrimaryEmail(email string) (string, error) {
|
||||||
|
var userID string
|
||||||
|
err := a.db.ReadOnly().QueryRow(a.queries.selectUserIDByPrimary, email).Scan(&userID)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", ErrUserNotFound
|
||||||
|
} else if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return userID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PendingEmails returns the user's unverified (pending) email addresses, i.e. addresses with
|
||||||
|
// an outstanding email-verification magic link.
|
||||||
|
func (a *Manager) PendingEmails(userID string) ([]string, error) {
|
||||||
|
rows, err := a.db.ReadOnly().Query(a.queries.selectPendingEmails, string(MagicLinkKindEmailVerify), userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
emails := make([]string, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var email string
|
||||||
|
if err := rows.Scan(&email); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
emails = append(emails, email)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return emails, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPrimaryEmail marks a verified email address as the user's primary (recovery) email,
|
||||||
|
// clearing any previous primary in the same transaction. Returns ErrEmailNotFound if the
|
||||||
|
// address is not verified on the account, or ErrEmailPrimaryElsewhere if it is already the
|
||||||
|
// primary email on another account (enforced by the global partial unique index).
|
||||||
|
func (a *Manager) SetPrimaryEmail(userID, email string) error {
|
||||||
|
return db.ExecTx(a.db, func(tx *sql.Tx) error {
|
||||||
|
if _, err := tx.Exec(a.queries.updateEmailClearPrimary, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
res, err := tx.Exec(a.queries.updateEmailSetPrimary, userID, email)
|
||||||
|
if err != nil {
|
||||||
|
if isUniqueConstraintError(err) {
|
||||||
|
return ErrEmailPrimaryElsewhere
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
affected, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected == 0 {
|
||||||
|
return ErrEmailNotFound // Address not verified on this account
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddMagicLink generates a fresh magic-link token of the given kind, stores it (hashed, replacing
|
||||||
|
// any existing link in the same scope), and returns the RAW token for use in the emailed link.
|
||||||
|
// Only the hash is persisted; the raw token is never stored. email is the address being verified
|
||||||
|
// for email_verify, and "" for password_reset.
|
||||||
|
//
|
||||||
|
// The scope replaced is, for email_verify, the (user_id, email) pair (one pending verification per
|
||||||
|
// address); for password_reset, the user_id (one active reset per account). The replace-delete and
|
||||||
|
// the insert run in one transaction so a re-request atomically supersedes the old token.
|
||||||
|
func (a *Manager) AddMagicLink(kind MagicLinkKind, userID, email string, ttl time.Duration) (string, error) {
|
||||||
|
token := generateLinkToken()
|
||||||
|
now := time.Now()
|
||||||
|
m := &MagicLink{
|
||||||
|
TokenHash: hashToken(token),
|
||||||
|
Kind: kind,
|
||||||
|
UserID: userID,
|
||||||
|
Email: email,
|
||||||
|
Expires: now.Add(ttl).Unix(),
|
||||||
|
Created: now.Unix(),
|
||||||
|
}
|
||||||
|
err := db.ExecTx(a.db, func(tx *sql.Tx) error {
|
||||||
|
switch m.Kind {
|
||||||
|
case MagicLinkKindEmailVerify:
|
||||||
|
if _, err := tx.Exec(a.queries.deleteMagicLinkEmailVerify, string(MagicLinkKindEmailVerify), m.UserID, m.Email); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case MagicLinkKindPasswordReset:
|
||||||
|
if _, err := tx.Exec(a.queries.deleteMagicLinkResetPassword, string(MagicLinkKindPasswordReset), m.UserID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return ErrInvalidArgument
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(a.queries.insertMagicLink, m.TokenHash, string(m.Kind), m.UserID, nullString(m.Email), m.Expires, m.Created); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MagicLinkByToken looks up a magic link by its raw token (hashing it first). See MagicLinkByHash.
|
||||||
|
func (a *Manager) MagicLinkByToken(rawToken string) (*MagicLink, error) {
|
||||||
|
return a.MagicLinkByHash(hashToken(rawToken))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MagicLinkByHash looks up a magic link by the hex SHA-256 of its raw token, returning
|
||||||
|
// ErrMagicLinkNotFound if none exists. Callers must assert the returned Kind matches the flow
|
||||||
|
// they serve and check Expires themselves.
|
||||||
|
func (a *Manager) MagicLinkByHash(tokenHash string) (*MagicLink, error) {
|
||||||
|
var m MagicLink
|
||||||
|
var kind string
|
||||||
|
var email sql.NullString
|
||||||
|
err := a.db.ReadOnly().QueryRow(a.queries.selectMagicLinkByHash, tokenHash).Scan(&m.TokenHash, &kind, &m.UserID, &email, &m.Expires, &m.Created)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrMagicLinkNotFound
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m.Kind = MagicLinkKind(kind)
|
||||||
|
m.Email = email.String
|
||||||
|
return &m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteMagicLinkByToken deletes a magic link identified by its raw token (single-use consume).
|
||||||
|
// Used to enforce single use after a reset is performed (email verification deletes the row
|
||||||
|
// inside VerifyEmail's transaction).
|
||||||
|
func (a *Manager) DeleteMagicLinkByToken(rawToken string) error {
|
||||||
|
_, err := a.db.Exec(a.queries.deleteMagicLinkByHash, hashToken(rawToken))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteEmailVerification removes any pending email verification for (userID, email). Used when
|
||||||
|
// an unverified (pending) address is cancelled/deleted from the account.
|
||||||
|
func (a *Manager) DeleteEmailVerification(userID, email string) error {
|
||||||
|
_, err := a.db.Exec(a.queries.deleteMagicLinkEmailVerify, string(MagicLinkKindEmailVerify), userID, email)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyEmail consumes an email-verification magic link, identified by its raw token: after
|
||||||
|
// validating the token (kind + expiry), it deletes the link, adds the address to the user's
|
||||||
|
// verified emails, and -- if the user has no primary email yet and the address is not already
|
||||||
|
// primary on another account -- promotes the new address to primary. All mutations run in one
|
||||||
|
// transaction. A primary collision simply leaves the address verified but non-primary. Provisioned
|
||||||
|
// users never get a primary (the recovery email is meaningless for them -- they can't reset).
|
||||||
|
// Returns the consumed link.
|
||||||
|
func (a *Manager) VerifyEmail(rawToken string) (*MagicLink, error) {
|
||||||
|
tokenHash := hashToken(rawToken)
|
||||||
|
m, err := a.MagicLinkByHash(tokenHash)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if m.Kind != MagicLinkKindEmailVerify || time.Now().Unix() > m.Expires {
|
||||||
|
return nil, ErrMagicLinkNotFound
|
||||||
|
}
|
||||||
|
err = db.ExecTx(a.db, func(tx *sql.Tx) error {
|
||||||
|
// Single use: delete the link, then add the (idempotent) verified address
|
||||||
|
if _, err := tx.Exec(a.queries.deleteMagicLinkByHash, tokenHash); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(a.queries.insertEmailIgnore, m.UserID, m.Email); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Promote to primary only if the user has none yet and the address is globally free.
|
||||||
|
// We check with SELECTs rather than catching a unique violation, because Postgres aborts
|
||||||
|
// the whole transaction on any constraint error (which would undo the verified-email add).
|
||||||
|
var primary sql.NullString
|
||||||
|
err := tx.QueryRow(a.queries.selectPrimaryEmail, m.UserID).Scan(&primary)
|
||||||
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if primary.String != "" {
|
||||||
|
return nil // User already has a primary -- leave it
|
||||||
|
}
|
||||||
|
// If the address is already another account's primary, leave it a verified secondary here
|
||||||
|
var ownerUserID string
|
||||||
|
if err = tx.QueryRow(a.queries.selectUserIDByPrimary, m.Email).Scan(&ownerUserID); err == nil {
|
||||||
|
return nil // Address is primary elsewhere -> not promoted
|
||||||
|
} else if !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return err // Real query error
|
||||||
|
}
|
||||||
|
// Address is globally free -> promote it to this user's primary
|
||||||
|
if _, err := tx.Exec(a.queries.updateEmailSetPrimary, m.UserID, m.Email); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
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, newPassword 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
|
||||||
|
}
|
||||||
|
if u.Provisioned {
|
||||||
|
return ErrProvisionedUserChange // Provisioned users get their password from the config file, not reset
|
||||||
|
}
|
||||||
|
hash, err := HashPassword(newPassword, a.config.BcryptCost)
|
||||||
|
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
|
||||||
|
// on read, so this is purely housekeeping to bound table growth; it runs from the reaper loop.
|
||||||
|
func (a *Manager) deleteExpiredMagicLinks() error {
|
||||||
|
_, err := a.db.Exec(a.queries.deleteExpiredMagicLinks, time.Now().Unix())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (a *Manager) readEmail(rows *sql.Rows) (string, error) {
|
func (a *Manager) readEmail(rows *sql.Rows) (string, error) {
|
||||||
var email string
|
var email string
|
||||||
if !rows.Next() {
|
if !rows.Next() {
|
||||||
|
|||||||
@@ -217,9 +217,23 @@ const (
|
|||||||
postgresDeletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = $1 AND phone_number = $2`
|
postgresDeletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = $1 AND phone_number = $2`
|
||||||
|
|
||||||
// Email queries
|
// Email queries
|
||||||
postgresSelectEmailsQuery = `SELECT email FROM user_email WHERE user_id = $1 ORDER BY email`
|
postgresSelectEmailsQuery = `SELECT email FROM user_email WHERE user_id = $1 ORDER BY email`
|
||||||
postgresInsertEmailQuery = `INSERT INTO user_email (user_id, email) VALUES ($1, $2)`
|
postgresInsertEmailQuery = `INSERT INTO user_email (user_id, email) VALUES ($1, $2)`
|
||||||
postgresDeleteEmailQuery = `DELETE FROM user_email WHERE user_id = $1 AND email = $2`
|
postgresInsertEmailIgnoreQuery = `INSERT INTO user_email (user_id, email) VALUES ($1, $2) ON CONFLICT (user_id, email) DO NOTHING`
|
||||||
|
postgresDeleteEmailQuery = `DELETE FROM user_email WHERE user_id = $1 AND email = $2`
|
||||||
|
postgresSelectPrimaryEmailQuery = `SELECT email FROM user_email WHERE user_id = $1 AND is_primary`
|
||||||
|
postgresSelectUserIDByPrimaryQuery = `SELECT user_id FROM user_email WHERE email = $1 AND is_primary`
|
||||||
|
postgresUpdateEmailSetPrimaryQuery = `UPDATE user_email SET is_primary = TRUE WHERE user_id = $1 AND email = $2`
|
||||||
|
postgresUpdateEmailClearPrimaryQuery = `UPDATE user_email SET is_primary = FALSE WHERE user_id = $1 AND is_primary`
|
||||||
|
|
||||||
|
// Magic link queries (email verification + password reset)
|
||||||
|
postgresInsertMagicLinkQuery = `INSERT INTO user_magic_link (token_hash, kind, user_id, email, expires, created) VALUES ($1, $2, $3, $4, $5, $6)`
|
||||||
|
postgresSelectMagicLinkByHashQuery = `SELECT token_hash, kind, user_id, email, expires, created FROM user_magic_link WHERE token_hash = $1`
|
||||||
|
postgresDeleteMagicLinkByHashQuery = `DELETE FROM user_magic_link WHERE token_hash = $1`
|
||||||
|
postgresDeleteVerifyScopeQuery = `DELETE FROM user_magic_link WHERE kind = $1 AND user_id = $2 AND email = $3`
|
||||||
|
postgresDeleteResetScopeQuery = `DELETE FROM user_magic_link WHERE kind = $1 AND user_id = $2`
|
||||||
|
postgresSelectPendingEmailsQuery = `SELECT email FROM user_magic_link WHERE kind = $1 AND user_id = $2 ORDER BY email`
|
||||||
|
postgresDeleteExpiredMagicLinksQuery = `DELETE FROM user_magic_link WHERE expires < $1`
|
||||||
|
|
||||||
// Billing queries
|
// Billing queries
|
||||||
postgresUpdateBillingQuery = `
|
postgresUpdateBillingQuery = `
|
||||||
@@ -306,7 +320,19 @@ var postgresQueries = queries{
|
|||||||
deletePhoneNumber: postgresDeletePhoneNumberQuery,
|
deletePhoneNumber: postgresDeletePhoneNumberQuery,
|
||||||
selectEmails: postgresSelectEmailsQuery,
|
selectEmails: postgresSelectEmailsQuery,
|
||||||
insertEmail: postgresInsertEmailQuery,
|
insertEmail: postgresInsertEmailQuery,
|
||||||
|
insertEmailIgnore: postgresInsertEmailIgnoreQuery,
|
||||||
deleteEmail: postgresDeleteEmailQuery,
|
deleteEmail: postgresDeleteEmailQuery,
|
||||||
|
selectPrimaryEmail: postgresSelectPrimaryEmailQuery,
|
||||||
|
selectUserIDByPrimary: postgresSelectUserIDByPrimaryQuery,
|
||||||
|
updateEmailSetPrimary: postgresUpdateEmailSetPrimaryQuery,
|
||||||
|
updateEmailClearPrimary: postgresUpdateEmailClearPrimaryQuery,
|
||||||
|
insertMagicLink: postgresInsertMagicLinkQuery,
|
||||||
|
selectMagicLinkByHash: postgresSelectMagicLinkByHashQuery,
|
||||||
|
deleteMagicLinkByHash: postgresDeleteMagicLinkByHashQuery,
|
||||||
|
deleteMagicLinkEmailVerify: postgresDeleteVerifyScopeQuery,
|
||||||
|
deleteMagicLinkResetPassword: postgresDeleteResetScopeQuery,
|
||||||
|
selectPendingEmails: postgresSelectPendingEmailsQuery,
|
||||||
|
deleteExpiredMagicLinks: postgresDeleteExpiredMagicLinksQuery,
|
||||||
updateBilling: postgresUpdateBillingQuery,
|
updateBilling: postgresUpdateBillingQuery,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,8 +75,21 @@ const (
|
|||||||
CREATE TABLE IF NOT EXISTS user_email (
|
CREATE TABLE IF NOT EXISTS user_email (
|
||||||
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
||||||
email TEXT NOT NULL,
|
email TEXT NOT NULL,
|
||||||
|
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
PRIMARY KEY (user_id, email)
|
PRIMARY KEY (user_id, email)
|
||||||
);
|
);
|
||||||
|
CREATE UNIQUE INDEX idx_user_email_primary_user ON user_email (user_id) WHERE is_primary;
|
||||||
|
CREATE UNIQUE INDEX idx_user_email_primary_addr ON user_email (email) WHERE is_primary;
|
||||||
|
CREATE TABLE IF NOT EXISTS user_magic_link (
|
||||||
|
token_hash TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
||||||
|
email TEXT,
|
||||||
|
expires BIGINT NOT NULL,
|
||||||
|
created BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (token_hash)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_magic_link_user_kind ON user_magic_link (user_id, kind);
|
||||||
CREATE TABLE IF NOT EXISTS schema_version (
|
CREATE TABLE IF NOT EXISTS schema_version (
|
||||||
store TEXT PRIMARY KEY,
|
store TEXT PRIMARY KEY,
|
||||||
version INT NOT NULL
|
version INT NOT NULL
|
||||||
@@ -89,7 +102,7 @@ const (
|
|||||||
|
|
||||||
// Schema table management queries for Postgres
|
// Schema table management queries for Postgres
|
||||||
const (
|
const (
|
||||||
postgresCurrentSchemaVersion = 7
|
postgresCurrentSchemaVersion = 8
|
||||||
postgresSelectSchemaVersionQuery = `SELECT version FROM schema_version WHERE store = 'user'`
|
postgresSelectSchemaVersionQuery = `SELECT version FROM schema_version WHERE store = 'user'`
|
||||||
postgresInsertSchemaVersionQuery = `INSERT INTO schema_version (store, version) VALUES ('user', $1)`
|
postgresInsertSchemaVersionQuery = `INSERT INTO schema_version (store, version) VALUES ('user', $1)`
|
||||||
)
|
)
|
||||||
@@ -102,11 +115,30 @@ const (
|
|||||||
PRIMARY KEY (user_id, email)
|
PRIMARY KEY (user_id, email)
|
||||||
);
|
);
|
||||||
`
|
`
|
||||||
|
|
||||||
|
// 7 -> 8: primary (recovery) email + magic-link table for verification/reset.
|
||||||
|
// No backfill -- existing verified emails stay non-primary.
|
||||||
|
postgresMigrate7To8UpdateQueries = `
|
||||||
|
ALTER TABLE user_email ADD COLUMN is_primary BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
CREATE UNIQUE INDEX idx_user_email_primary_user ON user_email (user_id) WHERE is_primary;
|
||||||
|
CREATE UNIQUE INDEX idx_user_email_primary_addr ON user_email (email) WHERE is_primary;
|
||||||
|
CREATE TABLE IF NOT EXISTS user_magic_link (
|
||||||
|
token_hash TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
||||||
|
email TEXT,
|
||||||
|
expires BIGINT NOT NULL,
|
||||||
|
created BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (token_hash)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_magic_link_user_kind ON user_magic_link (user_id, kind);
|
||||||
|
`
|
||||||
postgresUpdateSchemaVersionQuery = `UPDATE schema_version SET version = $1 WHERE store = 'user'`
|
postgresUpdateSchemaVersionQuery = `UPDATE schema_version SET version = $1 WHERE store = 'user'`
|
||||||
)
|
)
|
||||||
|
|
||||||
var postgresMigrations = map[int]func(db *sql.DB) error{
|
var postgresMigrations = map[int]func(db *sql.DB) error{
|
||||||
6: postgresMigrateFrom6,
|
6: postgresMigrateFrom6,
|
||||||
|
7: postgresMigrateFrom7,
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupPostgres(db *sql.DB) error {
|
func setupPostgres(db *sql.DB) error {
|
||||||
@@ -141,6 +173,16 @@ func postgresMigrateFrom6(db *sql.DB) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func postgresMigrateFrom7(db *sql.DB) error {
|
||||||
|
if _, err := db.Exec(postgresMigrate7To8UpdateQueries); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(postgresUpdateSchemaVersionQuery, 8); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func setupNewPostgres(db *sql.DB) error {
|
func setupNewPostgres(db *sql.DB) error {
|
||||||
if _, err := db.Exec(postgresCreateTablesQueries); err != nil {
|
if _, err := db.Exec(postgresCreateTablesQueries); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
+29
-3
@@ -214,9 +214,23 @@ const (
|
|||||||
sqliteDeletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = ? AND phone_number = ?`
|
sqliteDeletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = ? AND phone_number = ?`
|
||||||
|
|
||||||
// Email queries
|
// Email queries
|
||||||
sqliteSelectEmailsQuery = `SELECT email FROM user_email WHERE user_id = ? ORDER BY email`
|
sqliteSelectEmailsQuery = `SELECT email FROM user_email WHERE user_id = ? ORDER BY email`
|
||||||
sqliteInsertEmailQuery = `INSERT INTO user_email (user_id, email) VALUES (?, ?)`
|
sqliteInsertEmailQuery = `INSERT INTO user_email (user_id, email) VALUES (?, ?)`
|
||||||
sqliteDeleteEmailQuery = `DELETE FROM user_email WHERE user_id = ? AND email = ?`
|
sqliteInsertEmailIgnoreQuery = `INSERT INTO user_email (user_id, email) VALUES (?, ?) ON CONFLICT (user_id, email) DO NOTHING`
|
||||||
|
sqliteDeleteEmailQuery = `DELETE FROM user_email WHERE user_id = ? AND email = ?`
|
||||||
|
sqliteSelectPrimaryEmailQuery = `SELECT email FROM user_email WHERE user_id = ? AND is_primary = 1`
|
||||||
|
sqliteSelectUserIDByPrimaryQuery = `SELECT user_id FROM user_email WHERE email = ? AND is_primary = 1`
|
||||||
|
sqliteUpdateEmailSetPrimaryQuery = `UPDATE user_email SET is_primary = 1 WHERE user_id = ? AND email = ?`
|
||||||
|
sqliteUpdateEmailClearPrimaryQuery = `UPDATE user_email SET is_primary = 0 WHERE user_id = ? AND is_primary = 1`
|
||||||
|
|
||||||
|
// Magic link queries (email verification + password reset)
|
||||||
|
sqliteInsertMagicLinkQuery = `INSERT INTO user_magic_link (token_hash, kind, user_id, email, expires, created) VALUES (?, ?, ?, ?, ?, ?)`
|
||||||
|
sqliteSelectMagicLinkByHashQuery = `SELECT token_hash, kind, user_id, email, expires, created FROM user_magic_link WHERE token_hash = ?`
|
||||||
|
sqliteDeleteMagicLinkByHashQuery = `DELETE FROM user_magic_link WHERE token_hash = ?`
|
||||||
|
sqliteDeleteVerifyScopeQuery = `DELETE FROM user_magic_link WHERE kind = ? AND user_id = ? AND email = ?`
|
||||||
|
sqliteDeleteResetScopeQuery = `DELETE FROM user_magic_link WHERE kind = ? AND user_id = ?`
|
||||||
|
sqliteSelectPendingEmailsQuery = `SELECT email FROM user_magic_link WHERE kind = ? AND user_id = ? ORDER BY email`
|
||||||
|
sqliteDeleteExpiredMagicLinksQuery = `DELETE FROM user_magic_link WHERE expires < ?`
|
||||||
|
|
||||||
// Billing queries
|
// Billing queries
|
||||||
sqliteUpdateBillingQuery = `
|
sqliteUpdateBillingQuery = `
|
||||||
@@ -302,7 +316,19 @@ var sqliteQueries = queries{
|
|||||||
deletePhoneNumber: sqliteDeletePhoneNumberQuery,
|
deletePhoneNumber: sqliteDeletePhoneNumberQuery,
|
||||||
selectEmails: sqliteSelectEmailsQuery,
|
selectEmails: sqliteSelectEmailsQuery,
|
||||||
insertEmail: sqliteInsertEmailQuery,
|
insertEmail: sqliteInsertEmailQuery,
|
||||||
|
insertEmailIgnore: sqliteInsertEmailIgnoreQuery,
|
||||||
deleteEmail: sqliteDeleteEmailQuery,
|
deleteEmail: sqliteDeleteEmailQuery,
|
||||||
|
selectPrimaryEmail: sqliteSelectPrimaryEmailQuery,
|
||||||
|
selectUserIDByPrimary: sqliteSelectUserIDByPrimaryQuery,
|
||||||
|
updateEmailSetPrimary: sqliteUpdateEmailSetPrimaryQuery,
|
||||||
|
updateEmailClearPrimary: sqliteUpdateEmailClearPrimaryQuery,
|
||||||
|
insertMagicLink: sqliteInsertMagicLinkQuery,
|
||||||
|
selectMagicLinkByHash: sqliteSelectMagicLinkByHashQuery,
|
||||||
|
deleteMagicLinkByHash: sqliteDeleteMagicLinkByHashQuery,
|
||||||
|
deleteMagicLinkEmailVerify: sqliteDeleteVerifyScopeQuery,
|
||||||
|
deleteMagicLinkResetPassword: sqliteDeleteResetScopeQuery,
|
||||||
|
selectPendingEmails: sqliteSelectPendingEmailsQuery,
|
||||||
|
deleteExpiredMagicLinks: sqliteDeleteExpiredMagicLinksQuery,
|
||||||
updateBilling: sqliteUpdateBillingQuery,
|
updateBilling: sqliteUpdateBillingQuery,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -88,9 +88,23 @@ const (
|
|||||||
CREATE TABLE IF NOT EXISTS user_email (
|
CREATE TABLE IF NOT EXISTS user_email (
|
||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
email TEXT NOT NULL,
|
email TEXT NOT NULL,
|
||||||
|
is_primary INT NOT NULL DEFAULT (0),
|
||||||
PRIMARY KEY (user_id, email),
|
PRIMARY KEY (user_id, email),
|
||||||
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
CREATE UNIQUE INDEX idx_user_email_primary_user ON user_email (user_id) WHERE is_primary = 1;
|
||||||
|
CREATE UNIQUE INDEX idx_user_email_primary_addr ON user_email (email) WHERE is_primary = 1;
|
||||||
|
CREATE TABLE IF NOT EXISTS user_magic_link (
|
||||||
|
token_hash TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
email TEXT,
|
||||||
|
expires INT NOT NULL,
|
||||||
|
created INT NOT NULL,
|
||||||
|
PRIMARY KEY (token_hash),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_magic_link_user_kind ON user_magic_link (user_id, kind);
|
||||||
CREATE TABLE IF NOT EXISTS schemaVersion (
|
CREATE TABLE IF NOT EXISTS schemaVersion (
|
||||||
id INT PRIMARY KEY,
|
id INT PRIMARY KEY,
|
||||||
version INT NOT NULL
|
version INT NOT NULL
|
||||||
@@ -107,7 +121,7 @@ const (
|
|||||||
|
|
||||||
// Schema version table management for SQLite
|
// Schema version table management for SQLite
|
||||||
const (
|
const (
|
||||||
sqliteCurrentSchemaVersion = 7
|
sqliteCurrentSchemaVersion = 8
|
||||||
sqliteInsertSchemaVersionQuery = `INSERT INTO schemaVersion VALUES (1, ?)`
|
sqliteInsertSchemaVersionQuery = `INSERT INTO schemaVersion VALUES (1, ?)`
|
||||||
sqliteUpdateSchemaVersionQuery = `UPDATE schemaVersion SET version = ? WHERE id = 1`
|
sqliteUpdateSchemaVersionQuery = `UPDATE schemaVersion SET version = ? WHERE id = 1`
|
||||||
sqliteSelectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
|
sqliteSelectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
|
||||||
@@ -236,6 +250,26 @@ const (
|
|||||||
);
|
);
|
||||||
`
|
`
|
||||||
|
|
||||||
|
// 7 -> 8: primary (recovery) email + magic-link table for verification/reset.
|
||||||
|
// No backfill -- existing verified emails stay non-primary, so the ALTER cannot
|
||||||
|
// conflict and no old notification address becomes a recovery channel.
|
||||||
|
sqliteMigrate7To8UpdateQueries = `
|
||||||
|
ALTER TABLE user_email ADD COLUMN is_primary INT NOT NULL DEFAULT (0);
|
||||||
|
CREATE UNIQUE INDEX idx_user_email_primary_user ON user_email (user_id) WHERE is_primary = 1;
|
||||||
|
CREATE UNIQUE INDEX idx_user_email_primary_addr ON user_email (email) WHERE is_primary = 1;
|
||||||
|
CREATE TABLE IF NOT EXISTS user_magic_link (
|
||||||
|
token_hash TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
email TEXT,
|
||||||
|
expires INT NOT NULL,
|
||||||
|
created INT NOT NULL,
|
||||||
|
PRIMARY KEY (token_hash),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_magic_link_user_kind ON user_magic_link (user_id, kind);
|
||||||
|
`
|
||||||
|
|
||||||
// 5 -> 6
|
// 5 -> 6
|
||||||
sqliteMigrate5To6UpdateQueries = `
|
sqliteMigrate5To6UpdateQueries = `
|
||||||
PRAGMA foreign_keys=off;
|
PRAGMA foreign_keys=off;
|
||||||
@@ -339,6 +373,7 @@ var (
|
|||||||
4: sqliteMigrateFrom4,
|
4: sqliteMigrateFrom4,
|
||||||
5: sqliteMigrateFrom5,
|
5: sqliteMigrateFrom5,
|
||||||
6: sqliteMigrateFrom6,
|
6: sqliteMigrateFrom6,
|
||||||
|
7: sqliteMigrateFrom7,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -493,3 +528,16 @@ func sqliteMigrateFrom6(sqlDB *sql.DB) error {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sqliteMigrateFrom7(sqlDB *sql.DB) error {
|
||||||
|
log.Tag(tag).Info("Migrating user database schema: from 7 to 8")
|
||||||
|
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
|
||||||
|
if _, err := tx.Exec(sqliteMigrate7To8UpdateQueries); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 8); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package user
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -2827,3 +2828,387 @@ func TestStoreOtherAccessCount(t *testing.T) {
|
|||||||
require.Equal(t, 2, count) // ben's owner entry + everyone entry
|
require.Equal(t, 2, count) // ben's owner entry + everyone entry
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// addVerifyLink stores an email-verification magic link and returns the raw token so the test
|
||||||
|
// can "click" it via VerifyEmail.
|
||||||
|
func addVerifyLink(t *testing.T, a *Manager, userID, email string, ttl time.Duration) string {
|
||||||
|
raw, err := a.AddMagicLink(MagicLinkKindEmailVerify, userID, email, ttl)
|
||||||
|
require.Nil(t, err)
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUser_MagicLink_VerifyEmail_SetsPrimary(t *testing.T) {
|
||||||
|
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
|
||||||
|
a := newTestManager(t, newManager, PermissionDenyAll)
|
||||||
|
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
|
||||||
|
phil, err := a.User("phil")
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
raw := addVerifyLink(t, a, phil.ID, "phil@example.com", 24*time.Hour)
|
||||||
|
|
||||||
|
// Before verifying: pending, not yet verified, no primary
|
||||||
|
pending, err := a.PendingEmails(phil.ID)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, []string{"phil@example.com"}, pending)
|
||||||
|
emails, err := a.Emails(phil.ID)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 0, len(emails))
|
||||||
|
primary, err := a.PrimaryEmail(phil.ID)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "", primary)
|
||||||
|
|
||||||
|
// Verify: the first verified email auto-becomes primary
|
||||||
|
m, err := a.VerifyEmail(raw)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "phil@example.com", m.Email)
|
||||||
|
|
||||||
|
emails, err = a.Emails(phil.ID)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, []string{"phil@example.com"}, emails)
|
||||||
|
primary, err = a.PrimaryEmail(phil.ID)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "phil@example.com", primary)
|
||||||
|
pending, err = a.PendingEmails(phil.ID)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 0, len(pending))
|
||||||
|
|
||||||
|
// Reset-by-email lookup resolves to the account
|
||||||
|
userID, err := a.UserIDByPrimaryEmail("phil@example.com")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, phil.ID, userID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUser_MagicLink_VerifyEmail_SecondStaysSecondary(t *testing.T) {
|
||||||
|
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
|
||||||
|
a := newTestManager(t, newManager, PermissionDenyAll)
|
||||||
|
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
|
||||||
|
phil, err := a.User("phil")
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
raw1 := addVerifyLink(t, a, phil.ID, "first@example.com", 24*time.Hour)
|
||||||
|
_, err = a.VerifyEmail(raw1)
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
raw2 := addVerifyLink(t, a, phil.ID, "second@example.com", 24*time.Hour)
|
||||||
|
_, err = a.VerifyEmail(raw2)
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
// Both verified, but primary is still the first
|
||||||
|
emails, err := a.Emails(phil.ID)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, []string{"first@example.com", "second@example.com"}, emails)
|
||||||
|
primary, err := a.PrimaryEmail(phil.ID)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "first@example.com", primary)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUser_MagicLink_PrimaryGlobalUniqueness(t *testing.T) {
|
||||||
|
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
|
||||||
|
a := newTestManager(t, newManager, PermissionDenyAll)
|
||||||
|
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
|
||||||
|
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
|
||||||
|
phil, err := a.User("phil")
|
||||||
|
require.Nil(t, err)
|
||||||
|
ben, err := a.User("ben")
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
// phil verifies shared@ first -> becomes his primary
|
||||||
|
_, err = a.VerifyEmail(addVerifyLink(t, a, phil.ID, "shared@example.com", 24*time.Hour))
|
||||||
|
require.Nil(t, err)
|
||||||
|
primary, err := a.PrimaryEmail(phil.ID)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "shared@example.com", primary)
|
||||||
|
|
||||||
|
// ben verifies the same address -> allowed as secondary, but NOT his primary
|
||||||
|
_, err = a.VerifyEmail(addVerifyLink(t, a, ben.ID, "shared@example.com", 24*time.Hour))
|
||||||
|
require.Nil(t, err)
|
||||||
|
emails, err := a.Emails(ben.ID)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, []string{"shared@example.com"}, emails)
|
||||||
|
primary, err = a.PrimaryEmail(ben.ID)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "", primary)
|
||||||
|
|
||||||
|
// Explicitly promoting ben's copy to primary collides with phil's
|
||||||
|
require.ErrorIs(t, a.SetPrimaryEmail(ben.ID, "shared@example.com"), ErrEmailPrimaryElsewhere)
|
||||||
|
// ...and phil keeps his primary (the failed promotion rolled back ben's clear)
|
||||||
|
primary, err = a.PrimaryEmail(phil.ID)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "shared@example.com", primary)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUser_MagicLink_SetPrimary_NotVerified(t *testing.T) {
|
||||||
|
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
|
||||||
|
a := newTestManager(t, newManager, PermissionDenyAll)
|
||||||
|
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
|
||||||
|
phil, err := a.User("phil")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.ErrorIs(t, a.SetPrimaryEmail(phil.ID, "nope@example.com"), ErrEmailNotFound)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUser_MagicLink_Expired(t *testing.T) {
|
||||||
|
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
|
||||||
|
a := newTestManager(t, newManager, PermissionDenyAll)
|
||||||
|
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
|
||||||
|
phil, err := a.User("phil")
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
raw := addVerifyLink(t, a, phil.ID, "phil@example.com", -time.Minute)
|
||||||
|
_, err = a.VerifyEmail(raw)
|
||||||
|
require.ErrorIs(t, err, ErrMagicLinkNotFound)
|
||||||
|
|
||||||
|
// Nothing got verified
|
||||||
|
emails, err := a.Emails(phil.ID)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 0, len(emails))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUser_MagicLink_SingleUse(t *testing.T) {
|
||||||
|
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
|
||||||
|
a := newTestManager(t, newManager, PermissionDenyAll)
|
||||||
|
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
|
||||||
|
phil, err := a.User("phil")
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
raw := addVerifyLink(t, a, phil.ID, "phil@example.com", 24*time.Hour)
|
||||||
|
_, err = a.VerifyEmail(raw)
|
||||||
|
require.Nil(t, err)
|
||||||
|
// Second click: token already consumed
|
||||||
|
_, err = a.VerifyEmail(raw)
|
||||||
|
require.ErrorIs(t, err, ErrMagicLinkNotFound)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUser_MagicLink_ReplaceOnReRequest(t *testing.T) {
|
||||||
|
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
|
||||||
|
a := newTestManager(t, newManager, PermissionDenyAll)
|
||||||
|
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
|
||||||
|
phil, err := a.User("phil")
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
raw1 := addVerifyLink(t, a, phil.ID, "phil@example.com", 24*time.Hour)
|
||||||
|
raw2 := addVerifyLink(t, a, phil.ID, "phil@example.com", 24*time.Hour)
|
||||||
|
|
||||||
|
// Only one pending row remains; the old token no longer works
|
||||||
|
pending, err := a.PendingEmails(phil.ID)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, []string{"phil@example.com"}, pending)
|
||||||
|
_, err = a.MagicLinkByToken(raw1)
|
||||||
|
require.ErrorIs(t, err, ErrMagicLinkNotFound)
|
||||||
|
|
||||||
|
m, err := a.MagicLinkByToken(raw2)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "phil@example.com", m.Email)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUser_MagicLink_PasswordReset_RoundTrip(t *testing.T) {
|
||||||
|
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
|
||||||
|
a := newTestManager(t, newManager, PermissionDenyAll)
|
||||||
|
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
|
||||||
|
phil, err := a.User("phil")
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
raw, err := a.AddMagicLink(MagicLinkKindPasswordReset, phil.ID, "", time.Hour)
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
m, err := a.MagicLinkByToken(raw)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, MagicLinkKindPasswordReset, m.Kind)
|
||||||
|
require.Equal(t, phil.ID, m.UserID)
|
||||||
|
require.Equal(t, "", m.Email) // reset rows carry no email
|
||||||
|
|
||||||
|
// Reset rows do not appear as pending emails
|
||||||
|
pending, err := a.PendingEmails(phil.ID)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 0, len(pending))
|
||||||
|
|
||||||
|
// New request replaces the old token
|
||||||
|
raw2, err := a.AddMagicLink(MagicLinkKindPasswordReset, phil.ID, "", time.Hour)
|
||||||
|
require.Nil(t, err)
|
||||||
|
_, err = a.MagicLinkByToken(raw)
|
||||||
|
require.ErrorIs(t, err, ErrMagicLinkNotFound)
|
||||||
|
|
||||||
|
// Single use: deleting consumes it
|
||||||
|
require.Nil(t, a.DeleteMagicLinkByToken(raw2))
|
||||||
|
_, err = a.MagicLinkByToken(raw2)
|
||||||
|
require.ErrorIs(t, err, ErrMagicLinkNotFound)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUser_MagicLink_Reaper(t *testing.T) {
|
||||||
|
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
|
||||||
|
a := newTestManager(t, newManager, PermissionDenyAll)
|
||||||
|
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
|
||||||
|
phil, err := a.User("phil")
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
expired := addVerifyLink(t, a, phil.ID, "expired@example.com", -time.Hour)
|
||||||
|
valid := addVerifyLink(t, a, phil.ID, "valid@example.com", time.Hour)
|
||||||
|
|
||||||
|
require.Nil(t, a.deleteExpiredMagicLinks())
|
||||||
|
|
||||||
|
_, err = a.MagicLinkByToken(expired)
|
||||||
|
require.ErrorIs(t, err, ErrMagicLinkNotFound)
|
||||||
|
m, err := a.MagicLinkByToken(valid)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "valid@example.com", m.Email)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUser_MagicLink_ReaperLoop proves the background reap goroutine actually runs on its
|
||||||
|
// configured interval: an expired link inserted into a manager with a tiny reap interval is
|
||||||
|
// deleted without anyone calling deleteExpiredMagicLinks directly. Mirrors the loop-coverage
|
||||||
|
// pattern of TestAccessCacheReloadInterval_PicksUpExternalWrite.
|
||||||
|
func TestUser_MagicLink_ReaperLoop(t *testing.T) {
|
||||||
|
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
|
||||||
|
a := newTestManagerFromConfig(t, newManager, &Config{
|
||||||
|
DefaultAccess: PermissionDenyAll,
|
||||||
|
BcryptCost: bcrypt.MinCost,
|
||||||
|
ExpiredMagicLinkReapInterval: 25 * time.Millisecond,
|
||||||
|
})
|
||||||
|
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
|
||||||
|
phil, err := a.User("phil")
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
expired := addVerifyLink(t, a, phil.ID, "expired@example.com", -time.Hour)
|
||||||
|
valid := addVerifyLink(t, a, phil.ID, "valid@example.com", time.Hour)
|
||||||
|
|
||||||
|
// The background loop (not a direct call) must reap the expired link within a few intervals
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
_, err := a.MagicLinkByToken(expired)
|
||||||
|
return errors.Is(err, ErrMagicLinkNotFound)
|
||||||
|
}, 2*time.Second, 10*time.Millisecond, "reaper loop never deleted the expired magic link")
|
||||||
|
|
||||||
|
// The unexpired link must survive
|
||||||
|
m, err := a.MagicLinkByToken(valid)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "valid@example.com", m.Email)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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.AddMagicLink(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.AddMagicLink(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_VerifyEmail_ProvisionedGetsPrimary(t *testing.T) {
|
||||||
|
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
|
||||||
|
a := newTestManagerFromConfig(t, newManager, &Config{
|
||||||
|
DefaultAccess: PermissionDenyAll,
|
||||||
|
ProvisionEnabled: true,
|
||||||
|
Users: []*User{
|
||||||
|
{Name: "prov", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
prov, err := a.User("prov")
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
// A provisioned user's first verified email becomes their primary, just like a regular user
|
||||||
|
// (the primary is also the X-Email: yes target; password reset stays blocked separately).
|
||||||
|
_, err = a.VerifyEmail(addVerifyLink(t, a, prov.ID, "prov@example.com", time.Hour))
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
emails, err := a.Emails(prov.ID)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, []string{"prov@example.com"}, emails)
|
||||||
|
primary, err := a.PrimaryEmail(prov.ID)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "prov@example.com", primary)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUser_MagicLink_ResetPassword_ProvisionedRejected(t *testing.T) {
|
||||||
|
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
|
||||||
|
// Provisioned users come from the config file (ProvisionEnabled), not AddUser
|
||||||
|
a := newTestManagerFromConfig(t, newManager, &Config{
|
||||||
|
DefaultAccess: PermissionDenyAll,
|
||||||
|
ProvisionEnabled: true,
|
||||||
|
Users: []*User{
|
||||||
|
{Name: "prov", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
prov, err := a.User("prov")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.True(t, prov.Provisioned)
|
||||||
|
|
||||||
|
// A reset token can be created, but consuming it must be rejected for a provisioned user
|
||||||
|
// (their password comes from the config file, like change-pass).
|
||||||
|
raw, err := a.AddMagicLink(MagicLinkKindPasswordReset, prov.ID, "", time.Hour)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.ErrorIs(t, a.ResetPassword(raw, "newpass"), ErrProvisionedUserChange)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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.AddMagicLink(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) {
|
||||||
|
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
|
||||||
|
a := newTestManager(t, newManager, PermissionDenyAll)
|
||||||
|
_, err := a.UserIDByPrimaryEmail("ghost@example.com")
|
||||||
|
require.ErrorIs(t, err, ErrUserNotFound)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
+53
-15
@@ -73,6 +73,27 @@ type TokenUpdate struct {
|
|||||||
LastOrigin netip.Addr
|
LastOrigin netip.Addr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MagicLinkKind discriminates the two link-token flows stored in the user_magic_link table.
|
||||||
|
type MagicLinkKind string
|
||||||
|
|
||||||
|
// Magic link kinds
|
||||||
|
const (
|
||||||
|
MagicLinkKindEmailVerify MagicLinkKind = "email_verify"
|
||||||
|
MagicLinkKindPasswordReset MagicLinkKind = "password_reset"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MagicLink is a pending, single-use link token -- either an email verification or a
|
||||||
|
// password reset, distinguished by Kind. The raw token travels in the emailed link;
|
||||||
|
// only its TokenHash (hex SHA-256) is persisted.
|
||||||
|
type MagicLink struct {
|
||||||
|
TokenHash string
|
||||||
|
Kind MagicLinkKind
|
||||||
|
UserID string
|
||||||
|
Email string // Address being verified for email_verify; empty (NULL) for password_reset
|
||||||
|
Expires int64
|
||||||
|
Created int64
|
||||||
|
}
|
||||||
|
|
||||||
// Prefs represents a user's configuration settings
|
// Prefs represents a user's configuration settings
|
||||||
type Prefs struct {
|
type Prefs struct {
|
||||||
Language *string `json:"language,omitempty"`
|
Language *string `json:"language,omitempty"`
|
||||||
@@ -245,18 +266,19 @@ const (
|
|||||||
|
|
||||||
// Config holds the configuration for the user Manager
|
// Config holds the configuration for the user Manager
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Filename string // Database filename, e.g. "/var/lib/ntfy/user.db" (SQLite)
|
Filename string // Database filename, e.g. "/var/lib/ntfy/user.db" (SQLite)
|
||||||
DatabaseURL string // Database connection string (PostgreSQL)
|
DatabaseURL string // Database connection string (PostgreSQL)
|
||||||
StartupQueries string // Queries to run on startup, e.g. to create initial users or tiers (SQLite only)
|
StartupQueries string // Queries to run on startup, e.g. to create initial users or tiers (SQLite only)
|
||||||
DefaultAccess Permission // Default permission if no ACL matches
|
DefaultAccess Permission // Default permission if no ACL matches
|
||||||
ProvisionEnabled bool // Hack: Enable auto-provisioning of users and access grants, disabled for "ntfy user" commands
|
ProvisionEnabled bool // Hack: Enable auto-provisioning of users and access grants, disabled for "ntfy user" commands
|
||||||
Users []*User // Predefined users to create on startup
|
Users []*User // Predefined users to create on startup
|
||||||
Access map[string][]*Grant // Predefined access grants to create on startup (username -> []*Grant)
|
Access map[string][]*Grant // Predefined access grants to create on startup (username -> []*Grant)
|
||||||
Tokens map[string][]*Token // Predefined users to create on startup (username -> []*Token)
|
Tokens map[string][]*Token // Predefined users to create on startup (username -> []*Token)
|
||||||
QueueWriterInterval time.Duration // Interval for the async queue writer to flush stats and token updates to the database
|
QueueWriterInterval time.Duration // Interval for the async queue writer to flush stats and token updates to the database
|
||||||
BcryptCost int // Cost of generated passwords; lowering makes testing faster
|
BcryptCost int // Cost of generated passwords; lowering makes testing faster
|
||||||
AccessCacheEnabled bool // Enables the in-memory ACL cache (high volume servers only)
|
AccessCacheEnabled bool // Enables the in-memory ACL cache (high volume servers only)
|
||||||
AccessCacheReloadInterval time.Duration // Reload interval for access cache, relevant for ACL writes from CLI
|
AccessCacheReloadInterval time.Duration // Reload interval for access cache, relevant for ACL writes from CLI
|
||||||
|
ExpiredMagicLinkReapInterval time.Duration // Interval for sweeping expired email-verify/password-reset links
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error constants used by the package
|
// Error constants used by the package
|
||||||
@@ -275,6 +297,8 @@ var (
|
|||||||
ErrPhoneNumberExists = errors.New("phone number already exists")
|
ErrPhoneNumberExists = errors.New("phone number already exists")
|
||||||
ErrEmailNotFound = errors.New("email not found")
|
ErrEmailNotFound = errors.New("email not found")
|
||||||
ErrEmailExists = errors.New("email already exists")
|
ErrEmailExists = errors.New("email already exists")
|
||||||
|
ErrEmailPrimaryElsewhere = errors.New("email is the primary email on another account")
|
||||||
|
ErrMagicLinkNotFound = errors.New("magic link not found")
|
||||||
ErrProvisionedUserChange = errors.New("cannot change or delete provisioned user")
|
ErrProvisionedUserChange = errors.New("cannot change or delete provisioned user")
|
||||||
ErrProvisionedTokenChange = errors.New("cannot change or delete provisioned token")
|
ErrProvisionedTokenChange = errors.New("cannot change or delete provisioned token")
|
||||||
)
|
)
|
||||||
@@ -350,9 +374,23 @@ type queries struct {
|
|||||||
deletePhoneNumber string
|
deletePhoneNumber string
|
||||||
|
|
||||||
// Email queries
|
// Email queries
|
||||||
selectEmails string
|
selectEmails string
|
||||||
insertEmail string
|
insertEmail string
|
||||||
deleteEmail string
|
insertEmailIgnore string // Idempotent insert (ON CONFLICT DO NOTHING) used inside VerifyEmail
|
||||||
|
deleteEmail string
|
||||||
|
selectPrimaryEmail string
|
||||||
|
selectUserIDByPrimary string
|
||||||
|
updateEmailSetPrimary string
|
||||||
|
updateEmailClearPrimary string
|
||||||
|
|
||||||
|
// Magic link queries (email verification + password reset)
|
||||||
|
insertMagicLink string
|
||||||
|
selectMagicLinkByHash string
|
||||||
|
deleteMagicLinkByHash string
|
||||||
|
deleteMagicLinkEmailVerify string // Delete pending email_verify rows for (user_id, email)
|
||||||
|
deleteMagicLinkResetPassword string // Delete the active password_reset row for user_id
|
||||||
|
selectPendingEmails string // Pending (unverified) email addresses for a user
|
||||||
|
deleteExpiredMagicLinks string
|
||||||
|
|
||||||
// Billing queries
|
// Billing queries
|
||||||
updateBilling string
|
updateBilling string
|
||||||
|
|||||||
+22
-4
@@ -1,7 +1,9 @@
|
|||||||
package user
|
package user
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -9,6 +11,11 @@ import (
|
|||||||
"heckel.io/ntfy/v2/util"
|
"heckel.io/ntfy/v2/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// linkTokenLength is the length of a raw magic-link token. At 48 base62 characters
|
||||||
|
// it carries ~285 bits of entropy, well above the ~256-bit target, so the tokens
|
||||||
|
// need no brute-force cap -- just expiry and single-use.
|
||||||
|
const linkTokenLength = 48
|
||||||
|
|
||||||
var (
|
var (
|
||||||
allowedUsernameRegex = regexp.MustCompile(`^[-_.+@a-zA-Z0-9]+$`) // Does not include Everyone (*)
|
allowedUsernameRegex = regexp.MustCompile(`^[-_.+@a-zA-Z0-9]+$`) // Does not include Everyone (*)
|
||||||
allowedTopicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No '*'
|
allowedTopicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No '*'
|
||||||
@@ -67,12 +74,23 @@ func GenerateToken() string {
|
|||||||
return util.RandomLowerStringPrefix(tokenPrefix, tokenLength)
|
return util.RandomLowerStringPrefix(tokenPrefix, tokenLength)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HashPassword hashes the given password using bcrypt with the configured cost
|
// generateLinkToken returns a fresh high-entropy raw token for a magic link
|
||||||
func HashPassword(password string) (string, error) {
|
// (email verification or password reset). The raw token is carried in the emailed
|
||||||
return hashPassword(password, DefaultUserPasswordBcryptCost)
|
// link; only its hashToken digest is persisted.
|
||||||
|
func generateLinkToken() string {
|
||||||
|
return util.RandomString(linkTokenLength)
|
||||||
}
|
}
|
||||||
|
|
||||||
func hashPassword(password string, cost int) (string, error) {
|
// hashToken returns the hex-encoded SHA-256 digest of a raw magic-link token.
|
||||||
|
// Tokens are stored hashed so a database read cannot yield working links; a high-entropy
|
||||||
|
// token makes a fast (unsalted) hash sufficient, unlike a password.
|
||||||
|
func hashToken(raw string) string {
|
||||||
|
sum := sha256.Sum256([]byte(raw))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashPassword hashes the given password using bcrypt with the given cost
|
||||||
|
func HashPassword(password string, cost int) (string, error) {
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), cost)
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), cost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|||||||
+6
-6
@@ -176,7 +176,7 @@ func TestHashPassword(t *testing.T) {
|
|||||||
password := "test-password-123"
|
password := "test-password-123"
|
||||||
|
|
||||||
// Hash the password
|
// Hash the password
|
||||||
hash, err := HashPassword(password)
|
hash, err := HashPassword(password, DefaultUserPasswordBcryptCost)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.NotEmpty(t, hash)
|
require.NotEmpty(t, hash)
|
||||||
|
|
||||||
@@ -187,12 +187,12 @@ func TestHashPassword(t *testing.T) {
|
|||||||
require.True(t, strings.HasPrefix(hash, "$2a$"))
|
require.True(t, strings.HasPrefix(hash, "$2a$"))
|
||||||
|
|
||||||
// Hash the same password again - should produce different hash
|
// Hash the same password again - should produce different hash
|
||||||
hash2, err := HashPassword(password)
|
hash2, err := HashPassword(password, DefaultUserPasswordBcryptCost)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.NotEqual(t, hash, hash2, "Same password should produce different hashes (salt)")
|
require.NotEqual(t, hash, hash2, "Same password should produce different hashes (salt)")
|
||||||
|
|
||||||
// Empty password should still work
|
// Empty password should still work
|
||||||
emptyHash, err := HashPassword("")
|
emptyHash, err := HashPassword("", DefaultUserPasswordBcryptCost)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.NotEmpty(t, emptyHash)
|
require.NotEmpty(t, emptyHash)
|
||||||
require.Nil(t, ValidPasswordHash(emptyHash, DefaultUserPasswordBcryptCost))
|
require.Nil(t, ValidPasswordHash(emptyHash, DefaultUserPasswordBcryptCost))
|
||||||
@@ -202,15 +202,15 @@ func TestHashPassword_WithCost(t *testing.T) {
|
|||||||
password := "test-password"
|
password := "test-password"
|
||||||
|
|
||||||
// Test with different costs
|
// Test with different costs
|
||||||
hash4, err := hashPassword(password, 4)
|
hash4, err := HashPassword(password, 4)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.True(t, strings.HasPrefix(hash4, "$2a$04$"))
|
require.True(t, strings.HasPrefix(hash4, "$2a$04$"))
|
||||||
|
|
||||||
hash10, err := hashPassword(password, 10)
|
hash10, err := HashPassword(password, 10)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.True(t, strings.HasPrefix(hash10, "$2a$10$"))
|
require.True(t, strings.HasPrefix(hash10, "$2a$10$"))
|
||||||
|
|
||||||
hash12, err := hashPassword(password, 12)
|
hash12, err := HashPassword(password, 12)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.True(t, strings.HasPrefix(hash12, "$2a$12$"))
|
require.True(t, strings.HasPrefix(hash12, "$2a$12$"))
|
||||||
|
|
||||||
|
|||||||
+26
-10
@@ -2,20 +2,19 @@ package util
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
crand "crypto/rand"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
"math/rand"
|
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
@@ -30,8 +29,6 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
random = rand.New(rand.NewSource(time.Now().UnixNano()))
|
|
||||||
randomMutex = sync.Mutex{}
|
|
||||||
sizeStrRegex = regexp.MustCompile(`(?i)^(\d+)([gmkb])?$`)
|
sizeStrRegex = regexp.MustCompile(`(?i)^(\d+)([gmkb])?$`)
|
||||||
errInvalidPriority = errors.New("invalid priority")
|
errInvalidPriority = errors.New("invalid priority")
|
||||||
noQuotesRegex = regexp.MustCompile(`^[-_./:@a-zA-Z0-9]+$`)
|
noQuotesRegex = regexp.MustCompile(`^[-_./:@a-zA-Z0-9]+$`)
|
||||||
@@ -144,14 +141,33 @@ func RandomLowerStringPrefix(prefix string, length int) string {
|
|||||||
return randomStringPrefixWithCharset(prefix, length, randomStringLowerCaseCharset)
|
return randomStringPrefixWithCharset(prefix, length, randomStringLowerCaseCharset)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// randomStringPrefixWithCharset builds a random string from charset using crypto/rand.
|
||||||
|
// We use rejection sampling (dropping the few highest byte values that would skew the
|
||||||
|
// distribution) so every character is uniformly distributed -- important because these
|
||||||
|
// strings back security tokens (access tokens, magic-link tokens, IDs), not just labels.
|
||||||
func randomStringPrefixWithCharset(prefix string, length int, charset string) string {
|
func randomStringPrefixWithCharset(prefix string, length int, charset string) string {
|
||||||
randomMutex.Lock() // Who would have thought that random.Intn() is not thread-safe?!
|
n := length - len(prefix)
|
||||||
defer randomMutex.Unlock()
|
if n <= 0 {
|
||||||
b := make([]byte, length-len(prefix))
|
return prefix[:length]
|
||||||
for i := range b {
|
|
||||||
b[i] = charset[random.Intn(len(charset))]
|
|
||||||
}
|
}
|
||||||
return prefix + string(b)
|
result := make([]byte, n)
|
||||||
|
limit := 256 - (256 % len(charset)) // reject byte values >= limit to avoid modulo bias
|
||||||
|
buf := make([]byte, n)
|
||||||
|
for i := 0; i < n; {
|
||||||
|
if _, err := crand.Read(buf); err != nil {
|
||||||
|
panic("crypto/rand failed: " + err.Error()) // Should never happen on a sane system
|
||||||
|
}
|
||||||
|
for _, c := range buf {
|
||||||
|
if i >= n {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if int(c) < limit {
|
||||||
|
result[i] = charset[int(c)%len(charset)]
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return prefix + string(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidRandomString returns true if the given string matches the format created by RandomString
|
// ValidRandomString returns true if the given string matches the format created by RandomString
|
||||||
|
|||||||
@@ -25,6 +25,30 @@ func TestRandomString(t *testing.T) {
|
|||||||
require.NotEqual(t, s1, s2)
|
require.NotEqual(t, s1, s2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRandomString_CSPRNG guards the crypto/rand-backed generator: every character must come
|
||||||
|
// from the expected charset (rejection sampling correctness) and a large batch must be unique
|
||||||
|
// (no clock-seeded PRNG collapsing to a predictable stream).
|
||||||
|
func TestRandomString_CSPRNG(t *testing.T) {
|
||||||
|
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
charCounts := make(map[rune]int)
|
||||||
|
for i := 0; i < 5000; i++ {
|
||||||
|
s := RandomString(48)
|
||||||
|
require.Equal(t, 48, len(s))
|
||||||
|
require.False(t, seen[s], "duplicate random string generated")
|
||||||
|
seen[s] = true
|
||||||
|
for _, c := range s {
|
||||||
|
require.Contains(t, charset, string(c))
|
||||||
|
charCounts[c]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Every charset character should appear at least once across 5000*48 draws; a heavily
|
||||||
|
// biased or broken generator would leave gaps.
|
||||||
|
for _, c := range charset {
|
||||||
|
require.Greater(t, charCounts[c], 0, "character %q never appeared", string(c))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFileExists(t *testing.T) {
|
func TestFileExists(t *testing.T) {
|
||||||
filename := filepath.Join(t.TempDir(), "somefile.txt")
|
filename := filepath.Join(t.TempDir(), "somefile.txt")
|
||||||
require.Nil(t, os.WriteFile(filename, []byte{0x25, 0x86}, 0600))
|
require.Nil(t, os.WriteFile(filename, []byte{0x25, 0x86}, 0600))
|
||||||
|
|||||||
@@ -3,12 +3,20 @@
|
|||||||
"common_save": "Save",
|
"common_save": "Save",
|
||||||
"common_add": "Add",
|
"common_add": "Add",
|
||||||
"common_back": "Back",
|
"common_back": "Back",
|
||||||
|
"common_close": "Close",
|
||||||
"common_copy_to_clipboard": "Copy to clipboard",
|
"common_copy_to_clipboard": "Copy to clipboard",
|
||||||
"common_refresh": "Refresh",
|
"common_refresh": "Refresh",
|
||||||
|
"email_verify_progress_title": "Verifying your email...",
|
||||||
|
"email_verify_success_title": "Email verified",
|
||||||
|
"email_verify_success_description": "Your email address has been verified and added to your account.",
|
||||||
|
"email_verify_error_title": "Verification failed",
|
||||||
|
"email_verify_error_description": "This verification link is invalid or has expired. You can request a new one from your account settings.",
|
||||||
|
"email_verify_button_account": "Go to account",
|
||||||
"version_update_available_title": "New version available",
|
"version_update_available_title": "New version available",
|
||||||
"version_update_available_description": "The ntfy server has been updated. Please refresh the page.",
|
"version_update_available_description": "The ntfy server has been updated. Please refresh the page.",
|
||||||
"signup_title": "Create a ntfy account",
|
"signup_title": "Create a ntfy account",
|
||||||
"signup_form_username": "Username",
|
"signup_form_username": "Username",
|
||||||
|
"signup_form_email": "Email (optional, for account recovery)",
|
||||||
"signup_form_password": "Password",
|
"signup_form_password": "Password",
|
||||||
"signup_form_confirm_password": "Confirm password",
|
"signup_form_confirm_password": "Confirm password",
|
||||||
"signup_form_button_submit": "Sign up",
|
"signup_form_button_submit": "Sign up",
|
||||||
@@ -20,6 +28,23 @@
|
|||||||
"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",
|
||||||
|
"reset_password_request_title": "Reset password",
|
||||||
|
"reset_password_request_description": "Enter your username or email address. If an account exists, a link to reset your password will be emailed.",
|
||||||
|
"reset_password_request_primary_required": "This only works if you already added a primary email address and verified it.",
|
||||||
|
"reset_password_request_identifier_label": "Username or email",
|
||||||
|
"reset_password_request_button_submit": "Send reset link",
|
||||||
|
"reset_password_sent_title": "Check your inbox",
|
||||||
|
"reset_password_sent_description": "If an account exists, a link to reset your password has been emailed.",
|
||||||
|
"reset_password_back_to_login": "Back to sign-in",
|
||||||
|
"reset_password_disabled": "Password reset is disabled",
|
||||||
|
"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_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.",
|
||||||
"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",
|
||||||
@@ -216,18 +241,26 @@
|
|||||||
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
|
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
|
||||||
"account_basics_phone_numbers_dialog_channel_call": "Call",
|
"account_basics_phone_numbers_dialog_channel_call": "Call",
|
||||||
"account_basics_emails_title": "Email addresses",
|
"account_basics_emails_title": "Email addresses",
|
||||||
"account_basics_emails_description": "For email notifications",
|
"account_basics_emails_description": "For email notifications and password reset",
|
||||||
"account_basics_emails_no_emails_yet": "No verified emails yet",
|
"account_basics_emails_no_emails_yet": "No emails yet",
|
||||||
"account_basics_emails_copied_to_clipboard": "Email address copied to clipboard",
|
"account_basics_emails_copied_to_clipboard": "Email address copied to clipboard",
|
||||||
|
"account_basics_emails_chip_actions_primary": "Primary address, used as your default email address. Click for actions.",
|
||||||
|
"account_basics_emails_chip_actions_verified": "Can be used for notifications. Click for actions.",
|
||||||
|
"account_basics_emails_chip_actions_unverified": "Unverified address, check your inbox to verify. Click for actions.",
|
||||||
|
"account_basics_emails_unverified": "unverified",
|
||||||
|
"account_basics_emails_set_primary": "Set as primary email",
|
||||||
|
"account_basics_emails_delete": "Remove address",
|
||||||
|
"account_basics_emails_resend": "Resend verification email",
|
||||||
|
"account_basics_emails_resent": "Verification email sent, check your inbox",
|
||||||
|
"account_basics_emails_primary_elsewhere": "This email address is used as the primary address on another account",
|
||||||
|
"account_basics_emails_no_recovery_warning": "Add at least one email address to ensure you can recover your account if you lose your password.",
|
||||||
|
"account_basics_emails_no_primary_warning": "Add a primary email address to ensure you can recover your account if you lose your password.",
|
||||||
"account_basics_emails_dialog_title": "Add email address",
|
"account_basics_emails_dialog_title": "Add email address",
|
||||||
"account_basics_emails_dialog_description": "To receive email notifications, you need to add and verify at least one email address. A verification code will be sent to your email.",
|
"account_basics_emails_dialog_description": "Enter an email address to add it to your account. A verification link will be sent to confirm it is yours.",
|
||||||
"account_basics_emails_dialog_email_label": "Email address",
|
"account_basics_emails_dialog_email_label": "Email address",
|
||||||
"account_basics_emails_dialog_email_placeholder": "e.g. user@example.com",
|
"account_basics_emails_dialog_email_placeholder": "e.g. user@example.com",
|
||||||
"account_basics_emails_dialog_verify_button": "Add email",
|
"account_basics_emails_dialog_verify_button": "Send verification link",
|
||||||
"account_basics_emails_dialog_code_label": "Verification code",
|
"account_basics_emails_dialog_check_inbox": "Check your inbox and click the verification link to confirm this email address. It will appear as unverified until you do.",
|
||||||
"account_basics_emails_dialog_code_placeholder": "e.g. 123456",
|
|
||||||
"account_basics_emails_dialog_code_invalid": "Verification code is invalid or expired",
|
|
||||||
"account_basics_emails_dialog_check_verification_button": "Confirm",
|
|
||||||
"account_basics_cannot_edit_or_delete_provisioned_user": "A provisioned user cannot be edited or deleted",
|
"account_basics_cannot_edit_or_delete_provisioned_user": "A provisioned user cannot be edited or deleted",
|
||||||
"account_usage_title": "Usage",
|
"account_usage_title": "Usage",
|
||||||
"account_usage_of_limit": "of {{limit}}",
|
"account_usage_of_limit": "of {{limit}}",
|
||||||
@@ -236,9 +269,10 @@
|
|||||||
"account_basics_tier_title": "Account type",
|
"account_basics_tier_title": "Account type",
|
||||||
"account_basics_tier_description": "Your account's power level",
|
"account_basics_tier_description": "Your account's power level",
|
||||||
"account_basics_tier_admin": "Admin",
|
"account_basics_tier_admin": "Admin",
|
||||||
"account_basics_tier_admin_suffix_with_tier": "(with {{tier}} tier)",
|
"account_basics_tier_admin_suffix_with_tier": "with {{tier}} tier",
|
||||||
"account_basics_tier_admin_suffix_no_tier": "(no tier)",
|
"account_basics_tier_admin_suffix_no_tier": "no tier",
|
||||||
"account_basics_tier_basic": "Basic",
|
"account_basics_tier_basic": "Basic",
|
||||||
|
"account_basics_tier_provisioned": "Provisioned",
|
||||||
"account_basics_tier_free": "Free",
|
"account_basics_tier_free": "Free",
|
||||||
"account_basics_tier_interval_monthly": "monthly",
|
"account_basics_tier_interval_monthly": "monthly",
|
||||||
"account_basics_tier_interval_yearly": "annually",
|
"account_basics_tier_interval_yearly": "annually",
|
||||||
@@ -286,7 +320,6 @@
|
|||||||
"account_upgrade_dialog_tier_features_calls_other": "{{calls}} daily phone calls",
|
"account_upgrade_dialog_tier_features_calls_other": "{{calls}} daily phone calls",
|
||||||
"account_upgrade_dialog_tier_features_no_calls": "No phone calls",
|
"account_upgrade_dialog_tier_features_no_calls": "No phone calls",
|
||||||
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} per file",
|
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} per file",
|
||||||
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} total storage",
|
|
||||||
"account_upgrade_dialog_tier_price_per_month": "month",
|
"account_upgrade_dialog_tier_price_per_month": "month",
|
||||||
"account_upgrade_dialog_tier_price_billed_monthly": "{{price}} per year. Billed monthly.",
|
"account_upgrade_dialog_tier_price_billed_monthly": "{{price}} per year. Billed monthly.",
|
||||||
"account_upgrade_dialog_tier_price_billed_yearly": "{{price}} billed annually. Save {{save}}.",
|
"account_upgrade_dialog_tier_price_billed_yearly": "{{price}} billed annually. Save {{save}}.",
|
||||||
@@ -418,7 +451,6 @@
|
|||||||
"error_boundary_button_copy_stack_trace": "Copy stack trace",
|
"error_boundary_button_copy_stack_trace": "Copy stack trace",
|
||||||
"error_boundary_button_reload_ntfy": "Reload ntfy",
|
"error_boundary_button_reload_ntfy": "Reload ntfy",
|
||||||
"error_boundary_stack_trace": "Stack trace",
|
"error_boundary_stack_trace": "Stack trace",
|
||||||
"error_boundary_gathering_info": "Gather more info …",
|
|
||||||
"error_boundary_unsupported_indexeddb_title": "Private browsing not supported",
|
"error_boundary_unsupported_indexeddb_title": "Private browsing not supported",
|
||||||
"error_boundary_unsupported_indexeddb_description": "The ntfy web app needs IndexedDB to function, and your browser does not support IndexedDB in private browsing mode.<br/><br/>While this is unfortunate, it also doesn't really make a lot of sense to use the ntfy web app in private browsing mode anyway, because everything is stored in the browser storage. You can read more about it <githubLink>in this GitHub issue</githubLink>, or talk to us on <discordLink>Discord</discordLink> or <matrixLink>Matrix</matrixLink>.",
|
"error_boundary_unsupported_indexeddb_description": "The ntfy web app needs IndexedDB to function, and your browser does not support IndexedDB in private browsing mode.<br/><br/>While this is unfortunate, it also doesn't really make a lot of sense to use the ntfy web app in private browsing mode anyway, because everything is stored in the browser storage. You can read more about it <githubLink>in this GitHub issue</githubLink>, or talk to us on <discordLink>Discord</discordLink> or <matrixLink>Matrix</matrixLink>.",
|
||||||
"web_push_subscription_expiring_title": "Notifications will be paused",
|
"web_push_subscription_expiring_title": "Notifications will be paused",
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import {
|
|||||||
accountBillingSubscriptionUrl,
|
accountBillingSubscriptionUrl,
|
||||||
accountEmailUrl,
|
accountEmailUrl,
|
||||||
accountEmailVerifyUrl,
|
accountEmailVerifyUrl,
|
||||||
|
accountEmailPrimaryUrl,
|
||||||
|
accountEmailResendUrl,
|
||||||
|
accountPasswordResetRequestUrl,
|
||||||
|
accountPasswordResetUrl,
|
||||||
accountPasswordUrl,
|
accountPasswordUrl,
|
||||||
accountPhoneUrl,
|
accountPhoneUrl,
|
||||||
accountPhoneVerifyUrl,
|
accountPhoneVerifyUrl,
|
||||||
@@ -65,11 +69,12 @@ class AccountApi {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(username, password) {
|
async create(username, password, email) {
|
||||||
const url = accountUrl(config.base_url);
|
const url = accountUrl(config.base_url);
|
||||||
const body = JSON.stringify({
|
const body = JSON.stringify({
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
|
email: email || "",
|
||||||
});
|
});
|
||||||
console.log(`[AccountApi] Creating user account ${url}`);
|
console.log(`[AccountApi] Creating user account ${url}`);
|
||||||
await fetchOrThrow(url, {
|
await fetchOrThrow(url, {
|
||||||
@@ -342,9 +347,11 @@ class AccountApi {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async verifyEmail(email) {
|
// startEmailVerification begins adding an email: the server stores a pending verification and
|
||||||
const url = accountEmailVerifyUrl(config.base_url);
|
// emails a magic link. The address is not verified until the link is clicked.
|
||||||
console.log(`[AccountApi] Sending email verification ${url}`);
|
async startEmailVerification(email) {
|
||||||
|
const url = accountEmailUrl(config.base_url);
|
||||||
|
console.log(`[AccountApi] Starting email verification ${url}`);
|
||||||
await fetchOrThrow(url, {
|
await fetchOrThrow(url, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: withBearerAuth({}, session.token()),
|
headers: withBearerAuth({}, session.token()),
|
||||||
@@ -354,15 +361,67 @@ class AccountApi {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async addEmail(email, code) {
|
// verifyEmailToken performs verification from the magic-link landing page. It is unauthenticated:
|
||||||
const url = accountEmailUrl(config.base_url);
|
// the token identifies the account, so this works even when clicked from a logged-out browser.
|
||||||
console.log(`[AccountApi] Adding email with verification code ${url}`);
|
async verifyEmailToken(token) {
|
||||||
|
const url = accountEmailVerifyUrl(config.base_url);
|
||||||
|
console.log(`[AccountApi] Verifying email token ${url}`);
|
||||||
await fetchOrThrow(url, {
|
await fetchOrThrow(url, {
|
||||||
method: "PUT",
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
token,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// resendEmailVerification re-sends the magic link for a pending (unverified) address.
|
||||||
|
async resendEmailVerification(email) {
|
||||||
|
const url = accountEmailResendUrl(config.base_url);
|
||||||
|
console.log(`[AccountApi] Resending email verification ${url}`);
|
||||||
|
await fetchOrThrow(url, {
|
||||||
|
method: "POST",
|
||||||
headers: withBearerAuth({}, session.token()),
|
headers: withBearerAuth({}, session.token()),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
email,
|
email,
|
||||||
code,
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// setPrimaryEmail marks an already-verified address as the primary (recovery) email.
|
||||||
|
async setPrimaryEmail(email) {
|
||||||
|
const url = accountEmailPrimaryUrl(config.base_url);
|
||||||
|
console.log(`[AccountApi] Setting primary email ${url}`);
|
||||||
|
await fetchOrThrow(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: withBearerAuth({}, session.token()),
|
||||||
|
body: JSON.stringify({
|
||||||
|
email,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-6
@@ -31,11 +31,11 @@ export class TopicReservedError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AccountCreateLimitReachedError extends Error {
|
export class AccountActionLimitReachedError extends Error {
|
||||||
static CODE = 42906; // errHTTPTooManyRequestsLimitAccountCreation
|
static CODE = 42906; // errHTTPTooManyRequestsLimitAccountActions
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super("Account creation limit reached");
|
super("Account action limit reached");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +51,15 @@ export class EmailVerificationCodeInvalidError extends Error {
|
|||||||
static CODE = 40051; // errHTTPBadRequestEmailVerificationCodeInvalid
|
static CODE = 40051; // errHTTPBadRequestEmailVerificationCodeInvalid
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super("Email verification code invalid or expired");
|
super("Email verification link invalid or expired");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EmailPrimaryElsewhereError extends Error {
|
||||||
|
static CODE = 40908; // errHTTPConflictEmailPrimaryElsewhere
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super("Email address is the recovery email on another account");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,12 +75,14 @@ export const throwAppError = async (response) => {
|
|||||||
throw new UserExistsError();
|
throw new UserExistsError();
|
||||||
} else if (error.code === TopicReservedError.CODE) {
|
} else if (error.code === TopicReservedError.CODE) {
|
||||||
throw new TopicReservedError();
|
throw new TopicReservedError();
|
||||||
} else if (error.code === AccountCreateLimitReachedError.CODE) {
|
} else if (error.code === AccountActionLimitReachedError.CODE) {
|
||||||
throw new AccountCreateLimitReachedError();
|
throw new AccountActionLimitReachedError();
|
||||||
} else if (error.code === IncorrectPasswordError.CODE) {
|
} else if (error.code === IncorrectPasswordError.CODE) {
|
||||||
throw new IncorrectPasswordError();
|
throw new IncorrectPasswordError();
|
||||||
} else if (error.code === EmailVerificationCodeInvalidError.CODE) {
|
} else if (error.code === EmailVerificationCodeInvalidError.CODE) {
|
||||||
throw new EmailVerificationCodeInvalidError();
|
throw new EmailVerificationCodeInvalidError();
|
||||||
|
} else if (error.code === EmailPrimaryElsewhereError.CODE) {
|
||||||
|
throw new EmailPrimaryElsewhereError();
|
||||||
} else if (error?.error) {
|
} else if (error?.error) {
|
||||||
throw new Error(`Error ${error.code}: ${error.error}`);
|
throw new Error(`Error ${error.code}: ${error.error}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ export const accountPhoneUrl = (baseUrl) => `${baseUrl}/v1/account/phone`;
|
|||||||
export const accountPhoneVerifyUrl = (baseUrl) => `${baseUrl}/v1/account/phone/verify`;
|
export const accountPhoneVerifyUrl = (baseUrl) => `${baseUrl}/v1/account/phone/verify`;
|
||||||
export const accountEmailUrl = (baseUrl) => `${baseUrl}/v1/account/email`;
|
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 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?:\/\/.+/);
|
||||||
|
|
||||||
|
|||||||
+205
-129
@@ -2,6 +2,7 @@ import * as React from "react";
|
|||||||
import { useContext, useState } from "react";
|
import { useContext, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
|
Box,
|
||||||
CardActions,
|
CardActions,
|
||||||
CardContent,
|
CardContent,
|
||||||
Chip,
|
Chip,
|
||||||
@@ -31,13 +32,19 @@ import {
|
|||||||
DialogContent,
|
DialogContent,
|
||||||
TextField,
|
TextField,
|
||||||
IconButton,
|
IconButton,
|
||||||
|
Menu,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
|
ListItemIcon,
|
||||||
|
ListItemText,
|
||||||
DialogContentText,
|
DialogContentText,
|
||||||
useTheme,
|
useTheme,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import EditIcon from "@mui/icons-material/Edit";
|
import EditIcon from "@mui/icons-material/Edit";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||||
|
import StarIcon from "@mui/icons-material/Star";
|
||||||
|
import StarBorderIcon from "@mui/icons-material/StarBorder";
|
||||||
|
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||||
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
|
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
|
||||||
import CelebrationIcon from "@mui/icons-material/Celebration";
|
import CelebrationIcon from "@mui/icons-material/Celebration";
|
||||||
import CloseIcon from "@mui/icons-material/Close";
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
@@ -52,7 +59,7 @@ import UpgradeDialog from "./UpgradeDialog";
|
|||||||
import { AccountContext } from "./App";
|
import { AccountContext } from "./App";
|
||||||
import DialogFooter from "./DialogFooter";
|
import DialogFooter from "./DialogFooter";
|
||||||
import { Paragraph } from "./styles";
|
import { Paragraph } from "./styles";
|
||||||
import { EmailVerificationCodeInvalidError, IncorrectPasswordError, UnauthorizedError } from "../app/errors";
|
import { EmailPrimaryElsewhereError, IncorrectPasswordError, UnauthorizedError } from "../app/errors";
|
||||||
import { ProChip } from "./SubscriptionPopup";
|
import { ProChip } from "./SubscriptionPopup";
|
||||||
import session from "../app/Session";
|
import session from "../app/Session";
|
||||||
|
|
||||||
@@ -263,22 +270,23 @@ const AccountType = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The account type is a base label ("Admin", "Basic", "Free", or the tier name) plus an optional
|
||||||
|
// qualifier chip (admin tier status, or the billing interval).
|
||||||
let accountType;
|
let accountType;
|
||||||
|
let qualifierChip;
|
||||||
if (account.role === Role.ADMIN) {
|
if (account.role === Role.ADMIN) {
|
||||||
const tierSuffix = account.tier
|
accountType = t("account_basics_tier_admin");
|
||||||
? t("account_basics_tier_admin_suffix_with_tier", {
|
qualifierChip = account.tier
|
||||||
tier: account.tier.name,
|
? t("account_basics_tier_admin_suffix_with_tier", { tier: account.tier.name })
|
||||||
})
|
|
||||||
: t("account_basics_tier_admin_suffix_no_tier");
|
: t("account_basics_tier_admin_suffix_no_tier");
|
||||||
accountType = `${t("account_basics_tier_admin")} ${tierSuffix}`;
|
|
||||||
} else if (!account.tier) {
|
} else if (!account.tier) {
|
||||||
accountType = config.enable_payments ? t("account_basics_tier_free") : t("account_basics_tier_basic");
|
accountType = config.enable_payments ? t("account_basics_tier_free") : t("account_basics_tier_basic");
|
||||||
} else {
|
} else {
|
||||||
accountType = account.tier.name;
|
accountType = account.tier.name;
|
||||||
if (account.billing?.interval === SubscriptionInterval.MONTH) {
|
if (account.billing?.interval === SubscriptionInterval.MONTH) {
|
||||||
accountType += ` (${t("account_basics_tier_interval_monthly")})`;
|
qualifierChip = t("account_basics_tier_interval_monthly");
|
||||||
} else if (account.billing?.interval === SubscriptionInterval.YEAR) {
|
} else if (account.billing?.interval === SubscriptionInterval.YEAR) {
|
||||||
accountType += ` (${t("account_basics_tier_interval_yearly")})`;
|
qualifierChip = t("account_basics_tier_interval_yearly");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,6 +298,8 @@ const AccountType = () => {
|
|||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
{accountType}
|
{accountType}
|
||||||
|
{qualifierChip && <Chip size="small" label={qualifierChip} sx={{ ml: 1 }} />}
|
||||||
|
{account.provisioned && <Chip size="small" label={t("account_basics_tier_provisioned")} sx={{ ml: 1 }} />}
|
||||||
{account.billing?.paid_until && !account.billing?.cancel_at && (
|
{account.billing?.paid_until && !account.billing?.cancel_at && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
title={t("account_basics_tier_paid_until", {
|
title={t("account_basics_tier_paid_until", {
|
||||||
@@ -359,9 +369,24 @@ const Emails = () => {
|
|||||||
const { account } = useContext(AccountContext);
|
const { account } = useContext(AccountContext);
|
||||||
const [dialogKey, setDialogKey] = useState(0);
|
const [dialogKey, setDialogKey] = useState(0);
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [snackOpen, setSnackOpen] = useState(false);
|
const [snack, setSnack] = useState(""); // Non-empty shows a transient snackbar message
|
||||||
|
const [menuAnchor, setMenuAnchor] = useState(null); // Chip element the actions menu is anchored to
|
||||||
|
const [menuEmail, setMenuEmail] = useState(null); // The email the open menu acts on
|
||||||
const labelId = "prefVerifiedEmails";
|
const labelId = "prefVerifiedEmails";
|
||||||
|
|
||||||
|
const openMenu = (ev, email) => {
|
||||||
|
setMenuAnchor(ev.currentTarget);
|
||||||
|
setMenuEmail(email);
|
||||||
|
};
|
||||||
|
const closeMenu = () => {
|
||||||
|
setMenuAnchor(null);
|
||||||
|
setMenuEmail(null);
|
||||||
|
};
|
||||||
|
const runMenuAction = (fn) => {
|
||||||
|
closeMenu();
|
||||||
|
fn(menuEmail.address);
|
||||||
|
};
|
||||||
|
|
||||||
const handleDialogOpen = () => {
|
const handleDialogOpen = () => {
|
||||||
setDialogKey((prev) => prev + 1);
|
setDialogKey((prev) => prev + 1);
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
@@ -373,21 +398,37 @@ const Emails = () => {
|
|||||||
|
|
||||||
const handleCopy = (email) => {
|
const handleCopy = (email) => {
|
||||||
copyToClipboard(email);
|
copyToClipboard(email);
|
||||||
setSnackOpen(true);
|
setSnack(t("account_basics_emails_copied_to_clipboard"));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (email) => {
|
// runEmailAction wraps an account API call with the shared error handling (redirect on
|
||||||
|
// unauthorized, surface a message otherwise). On success it refetches the account so the email
|
||||||
|
// list reflects the change immediately, rather than waiting for the async sync event.
|
||||||
|
const runEmailAction = async (fn, errorMessage) => {
|
||||||
try {
|
try {
|
||||||
await accountApi.deleteEmail(email);
|
await fn();
|
||||||
|
await accountApi.sync();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(`[Account] Error deleting email`, e);
|
console.log(`[Account] Email action failed`, e);
|
||||||
if (e instanceof UnauthorizedError) {
|
if (e instanceof UnauthorizedError) {
|
||||||
await session.resetAndRedirect(routes.login);
|
await session.resetAndRedirect(routes.login);
|
||||||
|
} else if (e instanceof EmailPrimaryElsewhereError) {
|
||||||
|
setSnack(t("account_basics_emails_primary_elsewhere"));
|
||||||
|
} else {
|
||||||
|
setSnack(errorMessage ?? e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!config.enable_email_verify) {
|
const handleDelete = (email) => runEmailAction(() => accountApi.deleteEmail(email));
|
||||||
|
const handleSetPrimary = (email) => runEmailAction(() => accountApi.setPrimaryEmail(email));
|
||||||
|
const handleResend = (email) =>
|
||||||
|
runEmailAction(async () => {
|
||||||
|
await accountApi.resendEmailVerification(email);
|
||||||
|
setSnack(t("account_basics_emails_resent"));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!config.enable_emails) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,35 +448,105 @@ const Emails = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const emails = account?.emails ?? [];
|
||||||
|
// Verified addresses, primary always first
|
||||||
|
const verifiedEmails = emails.filter((e) => !e.pending).sort((a, b) => (b.primary ? 1 : 0) - (a.primary ? 1 : 0));
|
||||||
|
const pendingEmails = emails.filter((e) => e.pending);
|
||||||
|
const primaryEmail = verifiedEmails.find((e) => e.primary)?.address ?? "";
|
||||||
|
// Recovery nudges (skipped for provisioned users -- they can't reset, and the Add-email dialog
|
||||||
|
// explains the limitation): prompt for a first email when there are none, or for a primary when
|
||||||
|
// there are emails but none is primary.
|
||||||
|
const recoveryRelevant = config.enable_reset_password && !account?.provisioned;
|
||||||
|
const hasNoEmails = verifiedEmails.length === 0 && pendingEmails.length === 0;
|
||||||
|
const showNoEmailWarning = recoveryRelevant && hasNoEmails;
|
||||||
|
const showNoPrimaryWarning = recoveryRelevant && !hasNoEmails && primaryEmail === "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pref labelId={labelId} title={t("account_basics_emails_title")} description={t("account_basics_emails_description")}>
|
<Pref labelId={labelId} alignTop title={t("account_basics_emails_title")} description={t("account_basics_emails_description")}>
|
||||||
<div aria-labelledby={labelId}>
|
<div aria-labelledby={labelId}>
|
||||||
{account?.emails?.map((email) => (
|
<Box sx={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 0.75 }}>
|
||||||
<Chip
|
{verifiedEmails.map((email) => (
|
||||||
key={email}
|
<Chip
|
||||||
label={
|
key={email.address}
|
||||||
<Tooltip title={t("common_copy_to_clipboard")}>
|
icon={email.primary ? <StarIcon /> : undefined}
|
||||||
<span>{email}</span>
|
label={
|
||||||
</Tooltip>
|
<Tooltip
|
||||||
}
|
title={email.primary ? t("account_basics_emails_chip_actions_primary") : t("account_basics_emails_chip_actions_verified")}
|
||||||
variant="outlined"
|
>
|
||||||
onClick={() => handleCopy(email)}
|
<span>{email.address}</span>
|
||||||
onDelete={() => handleDelete(email)}
|
</Tooltip>
|
||||||
/>
|
}
|
||||||
))}
|
variant="outlined"
|
||||||
{!account?.emails && <em>{t("account_basics_emails_no_emails_yet")}</em>}
|
onClick={(ev) => openMenu(ev, email)}
|
||||||
<IconButton onClick={handleDialogOpen}>
|
onDelete={() => handleDelete(email.address)}
|
||||||
<AddIcon />
|
sx={email.primary ? { "& .MuiChip-icon": { color: "#fbc02d" } } : undefined}
|
||||||
</IconButton>
|
/>
|
||||||
|
))}
|
||||||
|
{pendingEmails.map((email) => (
|
||||||
|
<Chip
|
||||||
|
key={email.address}
|
||||||
|
label={
|
||||||
|
<Tooltip title={t("account_basics_emails_chip_actions_unverified")}>
|
||||||
|
<span>
|
||||||
|
{email.address} <em>({t("account_basics_emails_unverified")})</em>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={(ev) => openMenu(ev, email)}
|
||||||
|
onDelete={() => handleDelete(email.address)}
|
||||||
|
sx={{ opacity: 0.7 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{verifiedEmails.length === 0 && pendingEmails.length === 0 && <em>{t("account_basics_emails_no_emails_yet")}</em>}
|
||||||
|
<IconButton onClick={handleDialogOpen} aria-label={t("account_basics_emails_dialog_title")}>
|
||||||
|
<AddIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
{showNoEmailWarning && (
|
||||||
|
<Alert severity="warning" sx={{ mt: 1 }}>
|
||||||
|
{t("account_basics_emails_no_recovery_warning")}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{showNoPrimaryWarning && (
|
||||||
|
<Alert severity="warning" sx={{ mt: 1 }}>
|
||||||
|
{t("account_basics_emails_no_primary_warning")}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<Menu anchorEl={menuAnchor} open={Boolean(menuAnchor)} onClose={closeMenu}>
|
||||||
|
<MenuItem onClick={() => runMenuAction(handleCopy)}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<ContentCopy fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText>{t("common_copy_to_clipboard")}</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
{menuEmail && !menuEmail.pending && !menuEmail.primary && (
|
||||||
|
<MenuItem onClick={() => runMenuAction(handleSetPrimary)}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<StarBorderIcon fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText>{t("account_basics_emails_set_primary")}</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
{menuEmail && menuEmail.pending && (
|
||||||
|
<MenuItem onClick={() => runMenuAction(handleResend)}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<RefreshIcon fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText>{t("account_basics_emails_resend")}</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
<MenuItem onClick={() => runMenuAction(handleDelete)}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<DeleteOutlineIcon fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText>{t("account_basics_emails_delete")}</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
<AddEmailDialog key={`addEmailDialog${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />
|
<AddEmailDialog key={`addEmailDialog${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />
|
||||||
<Portal>
|
<Portal>
|
||||||
<Snackbar
|
<Snackbar open={snack !== ""} autoHideDuration={3000} onClose={() => setSnack("")} message={snack} />
|
||||||
open={snackOpen}
|
|
||||||
autoHideDuration={3000}
|
|
||||||
onClose={() => setSnackOpen(false)}
|
|
||||||
message={t("account_basics_emails_copied_to_clipboard")}
|
|
||||||
/>
|
|
||||||
</Portal>
|
</Portal>
|
||||||
</Pref>
|
</Pref>
|
||||||
);
|
);
|
||||||
@@ -446,18 +557,21 @@ const AddEmailDialog = (props) => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [code, setCode] = useState("");
|
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [verificationCodeSent, setVerificationCodeSent] = useState(false);
|
const [sent, setSent] = useState(false);
|
||||||
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
|
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
|
||||||
|
|
||||||
const verifyEmail = async () => {
|
// handleSubmit starts verification: the server emails a magic link. The pending address shows
|
||||||
|
// up in the account list as "(unverified)" once the account refreshes.
|
||||||
|
const handleSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
setSending(true);
|
setSending(true);
|
||||||
await accountApi.verifyEmail(email);
|
setError(""); // Clear any error from a previous attempt
|
||||||
setVerificationCodeSent(true);
|
await accountApi.startEmailVerification(email);
|
||||||
|
await accountApi.sync(); // Refresh so the new "(unverified)" address shows up immediately
|
||||||
|
setSent(true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(`[Account] Error sending email verification`, e);
|
console.log(`[Account] Error starting email verification`, e);
|
||||||
if (e instanceof UnauthorizedError) {
|
if (e instanceof UnauthorizedError) {
|
||||||
await session.resetAndRedirect(routes.login);
|
await session.resetAndRedirect(routes.login);
|
||||||
} else {
|
} else {
|
||||||
@@ -468,81 +582,41 @@ const AddEmailDialog = (props) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const checkVerifyEmail = async () => {
|
|
||||||
try {
|
|
||||||
setSending(true);
|
|
||||||
await accountApi.addEmail(email, code);
|
|
||||||
props.onClose();
|
|
||||||
} catch (e) {
|
|
||||||
console.log(`[Account] Error confirming email verification`, e);
|
|
||||||
if (e instanceof UnauthorizedError) {
|
|
||||||
await session.resetAndRedirect(routes.login);
|
|
||||||
} else if (e instanceof EmailVerificationCodeInvalidError) {
|
|
||||||
setError(t("account_basics_emails_dialog_code_invalid"));
|
|
||||||
} else {
|
|
||||||
setError(e.message);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setSending(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDialogSubmit = async () => {
|
|
||||||
if (!verificationCodeSent) {
|
|
||||||
await verifyEmail();
|
|
||||||
} else {
|
|
||||||
await checkVerifyEmail();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancel = () => {
|
|
||||||
if (verificationCodeSent) {
|
|
||||||
setVerificationCodeSent(false);
|
|
||||||
setCode("");
|
|
||||||
} else {
|
|
||||||
props.onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
|
<Dialog open={props.open} onClose={props.onClose} fullScreen={fullScreen}>
|
||||||
<DialogTitle>{t("account_basics_emails_dialog_title")}</DialogTitle>
|
<DialogTitle>{t("account_basics_emails_dialog_title")}</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogContentText>{t("account_basics_emails_dialog_description")}</DialogContentText>
|
{sent ? (
|
||||||
{!verificationCodeSent && (
|
<DialogContentText>{t("account_basics_emails_dialog_check_inbox")}</DialogContentText>
|
||||||
<TextField
|
) : (
|
||||||
margin="dense"
|
<>
|
||||||
label={t("account_basics_emails_dialog_email_label")}
|
<DialogContentText>{t("account_basics_emails_dialog_description")}</DialogContentText>
|
||||||
aria-label={t("account_basics_emails_dialog_email_label")}
|
<TextField
|
||||||
placeholder={t("account_basics_emails_dialog_email_placeholder")}
|
autoFocus
|
||||||
type="email"
|
margin="dense"
|
||||||
value={email}
|
label={t("account_basics_emails_dialog_email_label")}
|
||||||
onChange={(ev) => setEmail(ev.target.value)}
|
aria-label={t("account_basics_emails_dialog_email_label")}
|
||||||
fullWidth
|
placeholder={t("account_basics_emails_dialog_email_placeholder")}
|
||||||
variant="standard"
|
type="email"
|
||||||
/>
|
value={email}
|
||||||
)}
|
onChange={(ev) => setEmail(ev.target.value)}
|
||||||
{verificationCodeSent && (
|
fullWidth
|
||||||
<TextField
|
variant="standard"
|
||||||
margin="dense"
|
/>
|
||||||
label={t("account_basics_emails_dialog_code_label")}
|
</>
|
||||||
aria-label={t("account_basics_emails_dialog_code_label")}
|
|
||||||
placeholder={t("account_basics_emails_dialog_code_placeholder")}
|
|
||||||
type="text"
|
|
||||||
value={code}
|
|
||||||
onChange={(ev) => setCode(ev.target.value)}
|
|
||||||
fullWidth
|
|
||||||
inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }}
|
|
||||||
variant="standard"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogFooter status={error}>
|
<DialogFooter status={error}>
|
||||||
<Button onClick={handleCancel}>{verificationCodeSent ? t("common_back") : t("common_cancel")}</Button>
|
{sent ? (
|
||||||
<Button onClick={handleDialogSubmit} disabled={sending || !/^[^\s,;]+@[^\s,;]+$/.test(email)}>
|
<Button onClick={props.onClose}>{t("common_close")}</Button>
|
||||||
{!verificationCodeSent && t("account_basics_emails_dialog_verify_button")}
|
) : (
|
||||||
{verificationCodeSent && t("account_basics_emails_dialog_check_verification_button")}
|
<>
|
||||||
</Button>
|
<Button onClick={props.onClose}>{t("common_cancel")}</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={sending || !/^[^\s,;]+@[^\s,;]+$/.test(email)}>
|
||||||
|
{t("account_basics_emails_dialog_verify_button")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
@@ -604,22 +678,24 @@ const PhoneNumbers = () => {
|
|||||||
return (
|
return (
|
||||||
<Pref labelId={labelId} title={t("account_basics_phone_numbers_title")} description={t("account_basics_phone_numbers_description")}>
|
<Pref labelId={labelId} title={t("account_basics_phone_numbers_title")} description={t("account_basics_phone_numbers_description")}>
|
||||||
<div aria-labelledby={labelId}>
|
<div aria-labelledby={labelId}>
|
||||||
{account?.phone_numbers?.map((phoneNumber) => (
|
<Box sx={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 0.75 }}>
|
||||||
<Chip
|
{account?.phone_numbers?.map((phoneNumber) => (
|
||||||
label={
|
<Chip
|
||||||
<Tooltip title={t("common_copy_to_clipboard")}>
|
label={
|
||||||
<span>{phoneNumber}</span>
|
<Tooltip title={t("common_copy_to_clipboard")}>
|
||||||
</Tooltip>
|
<span>{phoneNumber}</span>
|
||||||
}
|
</Tooltip>
|
||||||
variant="outlined"
|
}
|
||||||
onClick={() => handleCopy(phoneNumber)}
|
variant="outlined"
|
||||||
onDelete={() => handleDelete(phoneNumber)}
|
onClick={() => handleCopy(phoneNumber)}
|
||||||
/>
|
onDelete={() => handleDelete(phoneNumber)}
|
||||||
))}
|
/>
|
||||||
{!account?.phone_numbers && <em>{t("account_basics_phone_numbers_no_phone_numbers_yet")}</em>}
|
))}
|
||||||
<IconButton onClick={handleDialogOpen}>
|
{!account?.phone_numbers && <em>{t("account_basics_phone_numbers_no_phone_numbers_yet")}</em>}
|
||||||
<AddIcon />
|
<IconButton onClick={handleDialogOpen}>
|
||||||
</IconButton>
|
<AddIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
</div>
|
</div>
|
||||||
<AddPhoneNumberDialog key={`addPhoneNumberDialog${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />
|
<AddPhoneNumberDialog key={`addPhoneNumberDialog${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />
|
||||||
<Portal>
|
<Portal>
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ import Messaging from "./Messaging";
|
|||||||
import Login from "./Login";
|
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 PasswordReset from "./PasswordReset";
|
||||||
|
import PasswordResetRequest from "./PasswordResetRequest";
|
||||||
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";
|
||||||
@@ -63,6 +66,9 @@ const App = () => {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<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.passwordResetRequest} element={<PasswordResetRequest />} />
|
||||||
|
<Route path={routes.passwordReset} element={<PasswordReset />} />
|
||||||
|
<Route path={routes.emailVerify} element={<EmailVerify />} />
|
||||||
<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 />} />
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Typography, Button, Box, CircularProgress } from "@mui/material";
|
||||||
|
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
|
||||||
|
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
|
||||||
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import accountApi from "../app/AccountApi";
|
||||||
|
import AvatarBox from "./AvatarBox";
|
||||||
|
import routes from "./routes";
|
||||||
|
|
||||||
|
// Verification states for the email-verify landing page
|
||||||
|
const STATUS_VERIFYING = "verifying";
|
||||||
|
const STATUS_SUCCESS = "success";
|
||||||
|
const STATUS_ERROR = "error";
|
||||||
|
|
||||||
|
// EmailVerify is the magic-link landing page for email verification. It performs the verification
|
||||||
|
// via a POST (the GET that loads this page has no side effects, so link prefetchers / scanners
|
||||||
|
// cannot consume the single-use token). The raw token is stripped from the URL on load to keep
|
||||||
|
// it out of browser history and Referer headers.
|
||||||
|
const EmailVerify = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { token } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [status, setStatus] = useState(STATUS_VERIFYING);
|
||||||
|
const ran = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (ran.current) {
|
||||||
|
return; // Guard against double-invoke (e.g. React StrictMode) consuming the token twice
|
||||||
|
}
|
||||||
|
ran.current = true;
|
||||||
|
// Strip the token from the URL immediately (keep it out of history / Referer)
|
||||||
|
window.history.replaceState(null, "", routes.account);
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
await accountApi.verifyEmailToken(token);
|
||||||
|
setStatus(STATUS_SUCCESS);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[EmailVerify] Verification failed`, e);
|
||||||
|
setStatus(STATUS_ERROR);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AvatarBox>
|
||||||
|
{status === STATUS_VERIFYING && (
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
|
<CircularProgress size={24} />
|
||||||
|
<Typography sx={{ typography: "h6" }}>{t("email_verify_progress_title")}</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{status === STATUS_SUCCESS && (
|
||||||
|
<>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
|
<CheckCircleOutlineIcon color="success" sx={{ fontSize: 28 }} />
|
||||||
|
<Typography sx={{ typography: "h6" }}>{t("email_verify_success_title")}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography sx={{ mt: 1, textAlign: "center" }}>{t("email_verify_success_description")}</Typography>
|
||||||
|
<Button onClick={() => navigate(routes.account)} variant="contained" sx={{ mt: 2 }}>
|
||||||
|
{t("email_verify_button_account")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === STATUS_ERROR && (
|
||||||
|
<>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
|
<ErrorOutlineIcon color="error" sx={{ fontSize: 28 }} />
|
||||||
|
<Typography sx={{ typography: "h6" }}>{t("email_verify_error_title")}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography sx={{ mt: 1, textAlign: "center" }}>{t("email_verify_error_description")}</Typography>
|
||||||
|
<Button onClick={() => navigate(routes.account)} variant="contained" sx={{ mt: 2 }}>
|
||||||
|
{t("email_verify_button_account")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AvatarBox>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EmailVerify;
|
||||||
@@ -100,7 +100,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" }}>
|
||||||
|
<NavLink to={routes.passwordResetRequest} variant="body1">
|
||||||
|
{t("login_link_forgot_password")}
|
||||||
|
</NavLink>
|
||||||
|
</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">
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
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, useNavigate } 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 navigate = useNavigate();
|
||||||
|
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();
|
||||||
|
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>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
|
<CheckCircleOutlineIcon color="success" sx={{ fontSize: 28 }} />
|
||||||
|
<Typography sx={{ typography: "h6" }}>{t("reset_password_success_title")}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography sx={{ mt: 1, textAlign: "center" }}>{t("reset_password_success_description")}</Typography>
|
||||||
|
<Button onClick={() => navigate(routes.login)} variant="contained" sx={{ mt: 2 }}>
|
||||||
|
{t("login_form_button_submit")}
|
||||||
|
</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 === "" || 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;
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { TextField, Button, Box, Typography } from "@mui/material";
|
||||||
|
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
|
||||||
|
import { NavLink } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import accountApi from "../app/AccountApi";
|
||||||
|
import AvatarBox from "./AvatarBox";
|
||||||
|
import routes from "./routes";
|
||||||
|
|
||||||
|
// PasswordResetRequest is the standalone "request a password reset" page, reached from the login page.
|
||||||
|
// It collects a username/email and asks the server to email a reset link. The response is uniform,
|
||||||
|
// so the page always shows the same confirmation. Completing the reset happens on the separate
|
||||||
|
// PasswordReset landing page that the emailed link points to.
|
||||||
|
const PasswordResetRequest = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [identifier, setIdentifier] = useState("");
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [sent, setSent] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
setSending(true);
|
||||||
|
await accountApi.requestPasswordReset(identifier);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[PasswordResetRequest] Request failed`, e);
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
setSent(true); // Uniform outcome regardless of success/failure (enumeration-safe)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!config.enable_reset_password) {
|
||||||
|
return (
|
||||||
|
<AvatarBox>
|
||||||
|
<Typography sx={{ typography: "h6" }}>{t("reset_password_disabled")}</Typography>
|
||||||
|
<Typography sx={{ mt: 2 }}>
|
||||||
|
<NavLink to={routes.login} variant="body1">
|
||||||
|
{t("reset_password_back_to_login")}
|
||||||
|
</NavLink>
|
||||||
|
</Typography>
|
||||||
|
</AvatarBox>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sent) {
|
||||||
|
return (
|
||||||
|
<AvatarBox>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
|
<CheckCircleOutlineIcon color="success" sx={{ fontSize: 28 }} />
|
||||||
|
<Typography sx={{ typography: "h6" }}>{t("reset_password_sent_title")}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography sx={{ mt: 1, textAlign: "center" }}>{t("reset_password_sent_description")}</Typography>
|
||||||
|
<Typography sx={{ mt: 2, mb: 4 }}>
|
||||||
|
<NavLink to={routes.login} variant="body1">
|
||||||
|
{t("reset_password_back_to_login")}
|
||||||
|
</NavLink>
|
||||||
|
</Typography>
|
||||||
|
</AvatarBox>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AvatarBox>
|
||||||
|
<Typography sx={{ typography: "h6" }}>{t("reset_password_request_title")}</Typography>
|
||||||
|
<Box component="form" onSubmit={handleSubmit} noValidate sx={{ mt: 1 }}>
|
||||||
|
<Typography sx={{ mt: 1 }}>{t("reset_password_request_description")}</Typography>
|
||||||
|
<Typography sx={{ mt: 1, mb: 1.5, fontWeight: "bold" }}>{t("reset_password_request_primary_required")}</Typography>
|
||||||
|
<TextField
|
||||||
|
margin="dense"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
id="identifier"
|
||||||
|
label={t("reset_password_request_identifier_label")}
|
||||||
|
name="identifier"
|
||||||
|
value={identifier}
|
||||||
|
onChange={(ev) => setIdentifier(ev.target.value.trim())}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Button type="submit" fullWidth variant="contained" disabled={sending || identifier === ""} sx={{ mt: 2, mb: 2 }}>
|
||||||
|
{t("reset_password_request_button_submit")}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
{config.enable_login && (
|
||||||
|
<Typography sx={{ mb: 4 }}>
|
||||||
|
<NavLink to={routes.login} variant="body1">
|
||||||
|
{t("reset_password_back_to_login")}
|
||||||
|
</NavLink>
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</AvatarBox>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PasswordResetRequest;
|
||||||
@@ -9,12 +9,13 @@ import accountApi from "../app/AccountApi";
|
|||||||
import AvatarBox from "./AvatarBox";
|
import AvatarBox from "./AvatarBox";
|
||||||
import session from "../app/Session";
|
import session from "../app/Session";
|
||||||
import routes from "./routes";
|
import routes from "./routes";
|
||||||
import { AccountCreateLimitReachedError, UserExistsError } from "../app/errors";
|
import { AccountActionLimitReachedError, UserExistsError } from "../app/errors";
|
||||||
|
|
||||||
const Signup = () => {
|
const Signup = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [confirm, setConfirm] = useState("");
|
const [confirm, setConfirm] = useState("");
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
@@ -24,7 +25,7 @@ const Signup = () => {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const user = { username, password };
|
const user = { username, password };
|
||||||
try {
|
try {
|
||||||
await accountApi.create(user.username, user.password);
|
await accountApi.create(user.username, user.password, email);
|
||||||
const token = await accountApi.login(user);
|
const token = await accountApi.login(user);
|
||||||
console.log(`[Signup] User signup for user ${user.username} successful, token is ${token}`);
|
console.log(`[Signup] User signup for user ${user.username} successful, token is ${token}`);
|
||||||
await session.store(user.username, token);
|
await session.store(user.username, token);
|
||||||
@@ -33,7 +34,7 @@ const Signup = () => {
|
|||||||
console.log(`[Signup] Signup for user ${user.username} failed`, e);
|
console.log(`[Signup] Signup for user ${user.username} failed`, e);
|
||||||
if (e instanceof UserExistsError) {
|
if (e instanceof UserExistsError) {
|
||||||
setError(t("signup_error_username_taken", { username: e.username }));
|
setError(t("signup_error_username_taken", { username: e.username }));
|
||||||
} else if (e instanceof AccountCreateLimitReachedError) {
|
} else if (e instanceof AccountActionLimitReachedError) {
|
||||||
setError(t("signup_error_creation_limit_reached"));
|
setError(t("signup_error_creation_limit_reached"));
|
||||||
} else {
|
} else {
|
||||||
setError(e.message);
|
setError(e.message);
|
||||||
@@ -64,6 +65,18 @@ const Signup = () => {
|
|||||||
onChange={(ev) => setUsername(ev.target.value.trim())}
|
onChange={(ev) => setUsername(ev.target.value.trim())}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
{config.enable_emails && (
|
||||||
|
<TextField
|
||||||
|
margin="dense"
|
||||||
|
fullWidth
|
||||||
|
id="email"
|
||||||
|
label={t("signup_form_email")}
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(ev) => setEmail(ev.target.value.trim())}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<TextField
|
<TextField
|
||||||
margin="dense"
|
margin="dense"
|
||||||
required
|
required
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ import config from "../app/config";
|
|||||||
import { shortUrl } from "../app/utils";
|
import { shortUrl } from "../app/utils";
|
||||||
|
|
||||||
const routes = {
|
const routes = {
|
||||||
|
app: config.app_root,
|
||||||
login: "/login",
|
login: "/login",
|
||||||
signup: "/signup",
|
signup: "/signup",
|
||||||
app: config.app_root,
|
|
||||||
account: "/account",
|
account: "/account",
|
||||||
settings: "/settings",
|
settings: "/settings",
|
||||||
|
passwordResetRequest: "/reset-password",
|
||||||
|
passwordReset: "/account/password/reset/:token",
|
||||||
|
emailVerify: "/account/email/verify/:token",
|
||||||
subscription: "/:topic",
|
subscription: "/:topic",
|
||||||
subscriptionExternal: "/:baseUrl/:topic",
|
subscriptionExternal: "/:baseUrl/:topic",
|
||||||
forSubscription: (subscription) => {
|
forSubscription: (subscription) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user