mirror of
https://github.com/multipleof4/ntfy.git
synced 2026-10-08 21:05:21 +00:00
Phase 2, email verification rework, ui stuff
This commit is contained in:
+26
-67
@@ -6,17 +6,14 @@ import (
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
const (
|
||||
verifyCodeExpiry = 10 * time.Minute
|
||||
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
|
||||
@@ -27,33 +24,22 @@ type Config struct {
|
||||
From string // Sender email address
|
||||
}
|
||||
|
||||
// Sender sends emails and manages email verification codes
|
||||
// Sender sends emails via SMTP, including the magic-link emails for email verification and
|
||||
// password reset. Pending verification/reset state lives in the database (see user.Manager),
|
||||
// not in this struct.
|
||||
type Sender struct {
|
||||
config *Config
|
||||
codes map[string]verifyCode // Verification codes, keyed by email
|
||||
mu sync.Mutex
|
||||
closeChan chan struct{}
|
||||
}
|
||||
|
||||
type verifyCode struct {
|
||||
code string
|
||||
expires time.Time
|
||||
config *Config
|
||||
}
|
||||
|
||||
// NewSender creates a new mail Sender with the given SMTP config
|
||||
func NewSender(config *Config) *Sender {
|
||||
s := &Sender{
|
||||
config: config,
|
||||
codes: make(map[string]verifyCode),
|
||||
closeChan: make(chan struct{}),
|
||||
}
|
||||
go s.expireLoop()
|
||||
return s
|
||||
return &Sender{config: config}
|
||||
}
|
||||
|
||||
// Close stops the background expiry loop
|
||||
// Close is a no-op, kept so callers don't need to special-case the sender. The sender holds
|
||||
// no background goroutines (magic-link expiry is swept by the user.Manager reaper).
|
||||
func (s *Sender) Close() {
|
||||
close(s.closeChan)
|
||||
// Nothing to do
|
||||
}
|
||||
|
||||
// Addr returns the SMTP server address
|
||||
@@ -104,51 +90,24 @@ Content-Type: text/plain; charset="utf-8"
|
||||
return s.SendRaw(to, []byte(message))
|
||||
}
|
||||
|
||||
// SendVerification generates a random code, stores it in-memory, and sends a verification email
|
||||
func (s *Sender) SendVerification(to string) error {
|
||||
code := util.RandomString(verifyCodeLength)
|
||||
s.mu.Lock()
|
||||
s.codes[to] = verifyCode{
|
||||
code: code,
|
||||
expires: time.Now().Add(verifyCodeExpiry),
|
||||
}
|
||||
s.mu.Unlock()
|
||||
body := fmt.Sprintf("Your ntfy email verification code is: %s\n\nThis code expires in 10 minutes.", code)
|
||||
return s.Send(to, verifyCodeSubject, body)
|
||||
// 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 *Sender) 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)
|
||||
}
|
||||
|
||||
// CheckVerification checks if the code matches and hasn't expired. Removes the entry on success.
|
||||
func (s *Sender) CheckVerification(email, code string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
vc, ok := s.codes[email]
|
||||
if !ok || time.Now().After(vc.expires) || vc.code != code {
|
||||
return false
|
||||
}
|
||||
delete(s.codes, email)
|
||||
return true
|
||||
}
|
||||
// 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 *Sender) SendPasswordReset(to, link string) error {
|
||||
body := fmt.Sprintf(`Click the link below to set a new password for your ntfy account:
|
||||
|
||||
func (s *Sender) expireLoop() {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
s.expireVerificationCodes()
|
||||
case <-s.closeChan:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
%s
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
+2
-1
@@ -143,7 +143,7 @@ var (
|
||||
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}
|
||||
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}
|
||||
errHTTPBadRequestEmailVerificationCodeInvalid = &errHTTP{40051, http.StatusBadRequest, "invalid request: email verification link invalid or expired", "", nil}
|
||||
errHTTPBadRequestEmailAddressNotVerified = &errHTTP{40052, http.StatusBadRequest, "invalid request: email address not verified", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil}
|
||||
errHTTPBadRequestAnonymousEmailNotAllowed = &errHTTP{40053, http.StatusBadRequest, "invalid request: anonymous email sending is not allowed", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil}
|
||||
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
|
||||
@@ -156,6 +156,7 @@ var (
|
||||
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}
|
||||
errHTTPConflictEmailExists = &errHTTP{40907, http.StatusConflict, "conflict: email address already exists", "", nil}
|
||||
errHTTPConflictEmailPrimaryElsewhere = &errHTTP{40908, http.StatusConflict, "conflict: email address is the recovery email on another account", "", 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}
|
||||
errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", "", nil}
|
||||
|
||||
+47
-24
@@ -58,7 +58,7 @@ type Server struct {
|
||||
smtpServer *smtp.Server
|
||||
smtpServerBackend *smtpBackend
|
||||
smtpSender mailer
|
||||
mailSender *mail.Sender
|
||||
mailSender emailVerifier
|
||||
topics map[string]*topic
|
||||
visitors map[string]*visitor // ip:<ip> or user:<user>
|
||||
firebaseClient *firebaseClient
|
||||
@@ -80,9 +80,10 @@ type handleFunc func(http.ResponseWriter, *http.Request, *visitor) error
|
||||
|
||||
var (
|
||||
// If changed, don't forget to update Android App and auth_sqlite.go
|
||||
topicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No /!
|
||||
topicPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}$`) // Regex must match JS & Android app!
|
||||
externalTopicPathRegex = regexp.MustCompile(`^/[^/]+\.[^/]+/[-_A-Za-z0-9]{1,64}$`) // Extended topic path, for web-app, e.g. /example.com/mytopic
|
||||
topicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No /!
|
||||
topicPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}$`) // Regex must match JS & Android app!
|
||||
externalTopicPathRegex = regexp.MustCompile(`^/[^/]+\.[^/]+/[-_A-Za-z0-9]{1,64}$`) // Extended topic path, for web-app, e.g. /example.com/mytopic
|
||||
webAppEmailVerifyRegex = regexp.MustCompile(`^/account/email/verify/[-_A-Za-z0-9]+$`) // Magic-link landing (served by the web app)
|
||||
jsonPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/json$`)
|
||||
ssePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/sse$`)
|
||||
rawPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/raw$`)
|
||||
@@ -116,6 +117,9 @@ var (
|
||||
apiAccountPhoneVerifyPath = "/v1/account/phone/verify"
|
||||
apiAccountEmailPath = "/v1/account/email"
|
||||
apiAccountEmailVerifyPath = "/v1/account/email/verify"
|
||||
apiAccountEmailPrimaryPath = "/v1/account/email/primary"
|
||||
apiAccountEmailResendPath = "/v1/account/email/resend"
|
||||
webAppEmailVerifyPathPrefix = "/account/email/verify/" // Browser landing route; raw token appended
|
||||
apiAccountBillingPortalPath = "/v1/account/billing/portal"
|
||||
apiAccountBillingWebhookPath = "/v1/account/billing/webhook"
|
||||
apiAccountBillingSubscriptionPath = "/v1/account/billing/subscription"
|
||||
@@ -177,15 +181,16 @@ const (
|
||||
// subscriber (if configured).
|
||||
func New(conf *Config) (*Server, error) {
|
||||
var mailer mailer
|
||||
var mailSender *mail.Sender
|
||||
var emailSender emailVerifier // Stays untyped-nil when SMTP is unconfigured, so ensureEmailsEnabled gates correctly
|
||||
if conf.SMTPSenderAddr != "" {
|
||||
mailSender = mail.NewSender(&mail.Config{
|
||||
mailSender := mail.NewSender(&mail.Config{
|
||||
SMTPAddr: conf.SMTPSenderAddr,
|
||||
SMTPUser: conf.SMTPSenderUser,
|
||||
SMTPPass: conf.SMTPSenderPass,
|
||||
From: conf.SMTPSenderFrom,
|
||||
})
|
||||
mailer = &smtpSender{config: conf, sender: mailSender}
|
||||
emailSender = mailSender
|
||||
}
|
||||
var stripe stripeAPI
|
||||
if payments.Available && conf.StripeSecretKey != "" {
|
||||
@@ -291,7 +296,7 @@ func New(conf *Config) (*Server, error) {
|
||||
attachment: attachmentStore,
|
||||
firebaseClient: firebaseClient,
|
||||
smtpSender: mailer,
|
||||
mailSender: mailSender,
|
||||
mailSender: emailSender,
|
||||
topics: topics,
|
||||
userManager: userManager,
|
||||
messages: messages,
|
||||
@@ -611,12 +616,16 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
||||
return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberAdd)))(w, r, v)
|
||||
} else if r.Method == http.MethodDelete && r.URL.Path == apiAccountPhonePath {
|
||||
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 {
|
||||
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 {
|
||||
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 && apiWebPushPath == r.URL.Path {
|
||||
return s.ensureWebPushEnabled(s.limitRequests(s.handleWebPushUpdate))(w, r, v)
|
||||
} else if r.Method == http.MethodDelete && apiWebPushPath == r.URL.Path {
|
||||
@@ -659,12 +668,25 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
||||
return s.limitRequests(s.authorizeTopicRead(s.handleSubscribeWS))(w, r, v)
|
||||
} else if r.Method == http.MethodGet && authPathRegex.MatchString(r.URL.Path) {
|
||||
return s.limitRequests(s.authorizeTopicRead(s.handleTopicAuth))(w, r, v)
|
||||
} else if r.Method == http.MethodGet && webAppEmailVerifyRegex.MatchString(r.URL.Path) {
|
||||
return s.ensureWebEnabled(s.handleWebAppIndex)(w, r, v) // Magic-link landing page (client-side route)
|
||||
} 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 errHTTPNotFound
|
||||
}
|
||||
|
||||
// handleWebAppIndex serves the embedded web app's index for client-side (SPA) routes the
|
||||
// browser router resolves, such as the magic-link landing pages. Because these URLs carry a
|
||||
// one-time token in the path, 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) handleWebAppIndex(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("X-Robots-Tag", "noindex")
|
||||
r.URL.Path = webAppIndex
|
||||
return s.handleStatic(w, r, v)
|
||||
}
|
||||
|
||||
func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
r.URL.Path = webAppIndex
|
||||
return s.handleStatic(w, r, v)
|
||||
@@ -715,21 +737,22 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi
|
||||
|
||||
func (s *Server) configResponse() *apiConfigResponse {
|
||||
return &apiConfigResponse{
|
||||
BaseURL: "", // Will translate to window.location.origin
|
||||
AppRoot: s.config.WebRoot,
|
||||
EnableLogin: s.config.EnableLogin,
|
||||
RequireLogin: s.config.RequireLogin,
|
||||
EnableSignup: s.config.EnableSignup,
|
||||
EnablePayments: s.config.StripeSecretKey != "",
|
||||
EnableCalls: s.config.TwilioAccount != "",
|
||||
EnableEmails: s.config.SMTPSenderFrom != "",
|
||||
EnableEmailVerify: s.config.SMTPSenderVerify,
|
||||
EnableReservations: s.config.EnableReservations,
|
||||
EnableWebPush: s.config.WebPushPublicKey != "",
|
||||
BillingContact: s.config.BillingContact,
|
||||
WebPushPublicKey: s.config.WebPushPublicKey,
|
||||
DisallowedTopics: s.config.DisallowedTopics,
|
||||
ConfigHash: s.config.Hash(),
|
||||
BaseURL: "", // Will translate to window.location.origin
|
||||
AppRoot: s.config.WebRoot,
|
||||
EnableLogin: s.config.EnableLogin,
|
||||
RequireLogin: s.config.RequireLogin,
|
||||
EnableSignup: s.config.EnableSignup,
|
||||
EnablePayments: s.config.StripeSecretKey != "",
|
||||
EnableCalls: s.config.TwilioAccount != "",
|
||||
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,
|
||||
EnableWebPush: s.config.WebPushPublicKey != "",
|
||||
BillingContact: s.config.BillingContact,
|
||||
WebPushPublicKey: s.config.WebPushPublicKey,
|
||||
DisallowedTopics: s.config.DisallowedTopics,
|
||||
ConfigHash: s.config.Hash(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+136
-27
@@ -15,8 +15,9 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
syncTopicAccountSyncEvent = "sync"
|
||||
tokenExpiryDuration = 72 * time.Hour // Extend tokens by this much
|
||||
syncTopicAccountSyncEvent = "sync"
|
||||
tokenExpiryDuration = 72 * time.Hour // Extend tokens by this much
|
||||
emailVerificationTokenExpiry = 24 * time.Hour // Magic-link lifetime for email verification
|
||||
)
|
||||
|
||||
func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
@@ -168,6 +169,18 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
|
||||
if len(emails) > 0 {
|
||||
response.Emails = emails
|
||||
}
|
||||
primaryEmail, err := s.userManager.PrimaryEmail(u.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
response.PrimaryEmail = primaryEmail
|
||||
pendingEmails, err := s.userManager.PendingEmails(u.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(pendingEmails) > 0 {
|
||||
response.PendingEmails = pendingEmails
|
||||
}
|
||||
}
|
||||
} else {
|
||||
response.Username = user.Everyone
|
||||
@@ -615,74 +628,149 @@ func (s *Server) handleAccountPhoneNumberDelete(w http.ResponseWriter, r *http.R
|
||||
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()
|
||||
req, err := readJSONWithLimit[apiAccountEmailVerifyRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
req, err := readJSONWithLimit[apiAccountEmailRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if !emailAddressRegex.MatchString(req.Email) {
|
||||
return errHTTPBadRequestEmailAddressInvalid
|
||||
}
|
||||
// Check user is allowed to add emails
|
||||
if u == nil {
|
||||
return errHTTPUnauthorized
|
||||
} else if u.IsUser() && u.Tier != nil && u.Tier.EmailLimit == 0 {
|
||||
// Check user is allowed to add emails (the tier email limit gates the feature)
|
||||
if u.IsUser() && u.Tier != nil && u.Tier.EmailLimit == 0 {
|
||||
return errHTTPUnauthorized
|
||||
} else if u.IsUser() && u.Tier == nil && s.config.VisitorEmailLimitBurst == 0 {
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if util.Contains(emails, req.Email) {
|
||||
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() {
|
||||
return errHTTPTooManyRequestsLimitEmails
|
||||
}
|
||||
// Send verification email
|
||||
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Sending email verification")
|
||||
if err := s.mailSender.SendVerification(req.Email); err != nil {
|
||||
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Starting email verification")
|
||||
if err := s.enqueueEmailVerification(u.ID, req.Email); err != nil {
|
||||
return err
|
||||
}
|
||||
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 errHTTPBadRequestEmailVerificationCodeInvalid
|
||||
}
|
||||
m, err := s.userManager.VerifyEmail(req.Token)
|
||||
if errors.Is(err, user.ErrMagicLinkNotFound) {
|
||||
return errHTTPBadRequestEmailVerificationCodeInvalid
|
||||
} 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()
|
||||
req, err := readJSONWithLimit[apiAccountEmailAddRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
req, err := readJSONWithLimit[apiAccountEmailRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if !emailAddressRegex.MatchString(req.Email) {
|
||||
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")
|
||||
if err := s.userManager.AddEmail(u.ID, req.Email); err != nil {
|
||||
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Debug("Deleting email (verified or pending)")
|
||||
if err := s.userManager.RemoveEmail(u.ID, req.Email); err != nil {
|
||||
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())
|
||||
}
|
||||
|
||||
func (s *Server) handleAccountEmailDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
// handleAccountEmailSetPrimary marks an already-verified email as the user's primary (recovery)
|
||||
// email (POST /v1/account/email/primary).
|
||||
func (s *Server) handleAccountEmailSetPrimary(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
u := v.User()
|
||||
req, err := readJSONWithLimit[apiAccountEmailVerifyRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
req, err := readJSONWithLimit[apiAccountEmailRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !emailAddressRegex.MatchString(req.Email) {
|
||||
} else 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 {
|
||||
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.CreateMagicLink(user.MagicLinkKindEmailVerify, userID, email, emailVerificationTokenExpiry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
link := s.config.BaseURL + webAppEmailVerifyPathPrefix + token
|
||||
return s.mailSender.SendEmailVerification(email, link)
|
||||
}
|
||||
|
||||
// convertEmailAddress checks the email address against the user's verified email list.
|
||||
// If smtp-sender-verify is false (default), the email is passed through as-is for
|
||||
// backwards compatibility. If true, the user must be authenticated and the email must be
|
||||
@@ -721,9 +809,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 {
|
||||
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 == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
// captureMailer is a fake emailVerifier that records the magic links it is asked to send, so
|
||||
// tests can "click" them without a real SMTP server.
|
||||
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) Close() {}
|
||||
|
||||
// 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.mailSender = 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
|
||||
}
|
||||
|
||||
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"}, account.PendingEmails)
|
||||
require.Empty(t, account.Emails)
|
||||
require.Equal(t, "", account.PrimaryEmail)
|
||||
|
||||
// "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"}, account.Emails)
|
||||
require.Equal(t, "ben@example.com", account.PrimaryEmail)
|
||||
require.Empty(t, account.PendingEmails)
|
||||
})
|
||||
}
|
||||
|
||||
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"}, getAccount(t, s, auth).PendingEmails)
|
||||
|
||||
// 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, account.PendingEmails)
|
||||
require.Empty(t, account.Emails)
|
||||
})
|
||||
}
|
||||
|
||||
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", getAccount(t, s, auth).PrimaryEmail)
|
||||
|
||||
// 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"}, aliceAccount.Emails)
|
||||
require.Equal(t, "", aliceAccount.PrimaryEmail)
|
||||
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -1706,11 +1706,11 @@ func TestServer_AccountEmailVerify_UserWithoutTier(t *testing.T) {
|
||||
// Create a user without a tier
|
||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
||||
|
||||
// Verify email request should NOT return 401
|
||||
response := request(t, s, "PUT", "/v1/account/email/verify", `{"email":"ben@example.com"}`, map[string]string{
|
||||
// Starting email verification should NOT return 401
|
||||
response := request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, map[string]string{
|
||||
"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)
|
||||
})
|
||||
}
|
||||
@@ -1731,7 +1731,7 @@ func TestServer_AccountEmailVerify_UserWithoutTier_EmailLimitZero(t *testing.T)
|
||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
||||
|
||||
// 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"),
|
||||
})
|
||||
require.Equal(t, 401, response.Code)
|
||||
|
||||
@@ -20,6 +20,14 @@ type mailer interface {
|
||||
Counts() (total int64, success int64, failure int64)
|
||||
}
|
||||
|
||||
// emailVerifier sends the magic-link emails for email verification and password reset.
|
||||
// *mail.Sender implements it; tests inject a fake to capture the generated links.
|
||||
type emailVerifier interface {
|
||||
SendEmailVerification(to, link string) error
|
||||
SendPasswordReset(to, link string) error
|
||||
Close()
|
||||
}
|
||||
|
||||
type smtpSender struct {
|
||||
config *Config
|
||||
sender *mail.Sender
|
||||
|
||||
+25
-19
@@ -226,13 +226,16 @@ type apiAccountPhoneNumberAddRequest struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
type apiAccountEmailAddRequest struct {
|
||||
Email string `json:"email"`
|
||||
Code string `json:"code"`
|
||||
// apiAccountEmailVerifyRequest carries the raw magic-link token submitted (unauthenticated)
|
||||
// from the verification landing page.
|
||||
type apiAccountEmailVerifyRequest struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type apiAccountTier struct {
|
||||
@@ -292,6 +295,8 @@ type apiAccountResponse struct {
|
||||
Tokens []*apiAccountTokenResponse `json:"tokens,omitempty"`
|
||||
PhoneNumbers []string `json:"phone_numbers,omitempty"`
|
||||
Emails []string `json:"emails,omitempty"`
|
||||
PrimaryEmail string `json:"primary_email,omitempty"` // The verified recovery email, if set
|
||||
PendingEmails []string `json:"pending_emails,omitempty"` // Unverified addresses awaiting a magic-link click
|
||||
Tier *apiAccountTier `json:"tier,omitempty"`
|
||||
Limits *apiAccountLimits `json:"limits,omitempty"`
|
||||
Stats *apiAccountStats `json:"stats,omitempty"`
|
||||
@@ -304,21 +309,22 @@ type apiAccountReservationRequest struct {
|
||||
}
|
||||
|
||||
type apiConfigResponse struct {
|
||||
BaseURL string `json:"base_url"`
|
||||
AppRoot string `json:"app_root"`
|
||||
EnableLogin bool `json:"enable_login"`
|
||||
RequireLogin bool `json:"require_login"`
|
||||
EnableSignup bool `json:"enable_signup"`
|
||||
EnablePayments bool `json:"enable_payments"`
|
||||
EnableCalls bool `json:"enable_calls"`
|
||||
EnableEmails bool `json:"enable_emails"`
|
||||
EnableEmailVerify bool `json:"enable_email_verify"`
|
||||
EnableReservations bool `json:"enable_reservations"`
|
||||
EnableWebPush bool `json:"enable_web_push"`
|
||||
BillingContact string `json:"billing_contact"`
|
||||
WebPushPublicKey string `json:"web_push_public_key"`
|
||||
DisallowedTopics []string `json:"disallowed_topics"`
|
||||
ConfigHash string `json:"config_hash"`
|
||||
BaseURL string `json:"base_url"`
|
||||
AppRoot string `json:"app_root"`
|
||||
EnableLogin bool `json:"enable_login"`
|
||||
RequireLogin bool `json:"require_login"`
|
||||
EnableSignup bool `json:"enable_signup"`
|
||||
EnablePayments bool `json:"enable_payments"`
|
||||
EnableCalls bool `json:"enable_calls"`
|
||||
EnableEmails bool `json:"enable_emails"`
|
||||
EnableEmailVerify bool `json:"enable_email_verify"`
|
||||
EnableResetPassword bool `json:"enable_reset_password"`
|
||||
EnableReservations bool `json:"enable_reservations"`
|
||||
EnableWebPush bool `json:"enable_web_push"`
|
||||
BillingContact string `json:"billing_contact"`
|
||||
WebPushPublicKey string `json:"web_push_public_key"`
|
||||
DisallowedTopics []string `json:"disallowed_topics"`
|
||||
ConfigHash string `json:"config_hash"`
|
||||
}
|
||||
|
||||
type apiAccountBillingPrices struct {
|
||||
|
||||
+35
-56
@@ -7,18 +7,11 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// addVerifyLink generates a raw token, stores an email-verification magic link for it, and
|
||||
// returns the raw token so the test can "click" it via VerifyEmail.
|
||||
func addVerifyLink(t *testing.T, a *Manager, userID, email string, expires int64) string {
|
||||
raw := generateLinkToken()
|
||||
require.Nil(t, a.AddMagicLink(&MagicLink{
|
||||
TokenHash: hashToken(raw),
|
||||
Kind: MagicLinkKindEmailVerify,
|
||||
UserID: userID,
|
||||
Email: email,
|
||||
Expires: expires,
|
||||
Created: time.Now().Unix(),
|
||||
}))
|
||||
// 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.CreateMagicLink(MagicLinkKindEmailVerify, userID, email, ttl)
|
||||
require.Nil(t, err)
|
||||
return raw
|
||||
}
|
||||
|
||||
@@ -29,7 +22,7 @@ func TestUser_MagicLink_VerifyEmail_SetsPrimary(t *testing.T) {
|
||||
phil, err := a.User("phil")
|
||||
require.Nil(t, err)
|
||||
|
||||
raw := addVerifyLink(t, a, phil.ID, "phil@example.com", time.Now().Add(24*time.Hour).Unix())
|
||||
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)
|
||||
@@ -43,7 +36,7 @@ func TestUser_MagicLink_VerifyEmail_SetsPrimary(t *testing.T) {
|
||||
require.Equal(t, "", primary)
|
||||
|
||||
// Verify: the first verified email auto-becomes primary
|
||||
m, err := a.VerifyEmail(hashToken(raw))
|
||||
m, err := a.VerifyEmail(raw)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "phil@example.com", m.Email)
|
||||
|
||||
@@ -71,12 +64,12 @@ func TestUser_MagicLink_VerifyEmail_SecondStaysSecondary(t *testing.T) {
|
||||
phil, err := a.User("phil")
|
||||
require.Nil(t, err)
|
||||
|
||||
raw1 := addVerifyLink(t, a, phil.ID, "first@example.com", time.Now().Add(24*time.Hour).Unix())
|
||||
_, err = a.VerifyEmail(hashToken(raw1))
|
||||
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", time.Now().Add(24*time.Hour).Unix())
|
||||
_, err = a.VerifyEmail(hashToken(raw2))
|
||||
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
|
||||
@@ -100,16 +93,14 @@ func TestUser_MagicLink_PrimaryGlobalUniqueness(t *testing.T) {
|
||||
require.Nil(t, err)
|
||||
|
||||
// phil verifies shared@ first -> becomes his primary
|
||||
rawPhil := addVerifyLink(t, a, phil.ID, "shared@example.com", time.Now().Add(24*time.Hour).Unix())
|
||||
_, err = a.VerifyEmail(hashToken(rawPhil))
|
||||
_, 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
|
||||
rawBen := addVerifyLink(t, a, ben.ID, "shared@example.com", time.Now().Add(24*time.Hour).Unix())
|
||||
_, err = a.VerifyEmail(hashToken(rawBen))
|
||||
_, 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)
|
||||
@@ -120,7 +111,7 @@ func TestUser_MagicLink_PrimaryGlobalUniqueness(t *testing.T) {
|
||||
|
||||
// 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, which was a no-op anyway)
|
||||
// ...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)
|
||||
@@ -144,8 +135,8 @@ func TestUser_MagicLink_Expired(t *testing.T) {
|
||||
phil, err := a.User("phil")
|
||||
require.Nil(t, err)
|
||||
|
||||
raw := addVerifyLink(t, a, phil.ID, "phil@example.com", time.Now().Add(-time.Minute).Unix())
|
||||
_, err = a.VerifyEmail(hashToken(raw))
|
||||
raw := addVerifyLink(t, a, phil.ID, "phil@example.com", -time.Minute)
|
||||
_, err = a.VerifyEmail(raw)
|
||||
require.ErrorIs(t, err, ErrMagicLinkNotFound)
|
||||
|
||||
// Nothing got verified
|
||||
@@ -162,11 +153,11 @@ func TestUser_MagicLink_SingleUse(t *testing.T) {
|
||||
phil, err := a.User("phil")
|
||||
require.Nil(t, err)
|
||||
|
||||
raw := addVerifyLink(t, a, phil.ID, "phil@example.com", time.Now().Add(24*time.Hour).Unix())
|
||||
_, err = a.VerifyEmail(hashToken(raw))
|
||||
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(hashToken(raw))
|
||||
_, err = a.VerifyEmail(raw)
|
||||
require.ErrorIs(t, err, ErrMagicLinkNotFound)
|
||||
})
|
||||
}
|
||||
@@ -178,17 +169,17 @@ func TestUser_MagicLink_ReplaceOnReRequest(t *testing.T) {
|
||||
phil, err := a.User("phil")
|
||||
require.Nil(t, err)
|
||||
|
||||
raw1 := addVerifyLink(t, a, phil.ID, "phil@example.com", time.Now().Add(24*time.Hour).Unix())
|
||||
raw2 := addVerifyLink(t, a, phil.ID, "phil@example.com", time.Now().Add(24*time.Hour).Unix())
|
||||
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.MagicLinkByHash(hashToken(raw1))
|
||||
_, err = a.MagicLinkByToken(raw1)
|
||||
require.ErrorIs(t, err, ErrMagicLinkNotFound)
|
||||
|
||||
m, err := a.MagicLinkByHash(hashToken(raw2))
|
||||
m, err := a.MagicLinkByToken(raw2)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "phil@example.com", m.Email)
|
||||
})
|
||||
@@ -201,16 +192,10 @@ func TestUser_MagicLink_PasswordReset_RoundTrip(t *testing.T) {
|
||||
phil, err := a.User("phil")
|
||||
require.Nil(t, err)
|
||||
|
||||
raw := generateLinkToken()
|
||||
require.Nil(t, a.AddMagicLink(&MagicLink{
|
||||
TokenHash: hashToken(raw),
|
||||
Kind: MagicLinkKindPasswordReset,
|
||||
UserID: phil.ID,
|
||||
Expires: time.Now().Add(time.Hour).Unix(),
|
||||
Created: time.Now().Unix(),
|
||||
}))
|
||||
raw, err := a.CreateMagicLink(MagicLinkKindPasswordReset, phil.ID, "", time.Hour)
|
||||
require.Nil(t, err)
|
||||
|
||||
m, err := a.MagicLinkByHash(hashToken(raw))
|
||||
m, err := a.MagicLinkByToken(raw)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, MagicLinkKindPasswordReset, m.Kind)
|
||||
require.Equal(t, phil.ID, m.UserID)
|
||||
@@ -222,20 +207,14 @@ func TestUser_MagicLink_PasswordReset_RoundTrip(t *testing.T) {
|
||||
require.Equal(t, 0, len(pending))
|
||||
|
||||
// New request replaces the old token
|
||||
raw2 := generateLinkToken()
|
||||
require.Nil(t, a.AddMagicLink(&MagicLink{
|
||||
TokenHash: hashToken(raw2),
|
||||
Kind: MagicLinkKindPasswordReset,
|
||||
UserID: phil.ID,
|
||||
Expires: time.Now().Add(time.Hour).Unix(),
|
||||
Created: time.Now().Unix(),
|
||||
}))
|
||||
_, err = a.MagicLinkByHash(hashToken(raw))
|
||||
raw2, err := a.CreateMagicLink(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.DeleteMagicLink(hashToken(raw2)))
|
||||
_, err = a.MagicLinkByHash(hashToken(raw2))
|
||||
require.Nil(t, a.DeleteMagicLinkByToken(raw2))
|
||||
_, err = a.MagicLinkByToken(raw2)
|
||||
require.ErrorIs(t, err, ErrMagicLinkNotFound)
|
||||
})
|
||||
}
|
||||
@@ -247,14 +226,14 @@ func TestUser_MagicLink_Reaper(t *testing.T) {
|
||||
phil, err := a.User("phil")
|
||||
require.Nil(t, err)
|
||||
|
||||
expired := addVerifyLink(t, a, phil.ID, "expired@example.com", time.Now().Add(-time.Hour).Unix())
|
||||
valid := addVerifyLink(t, a, phil.ID, "valid@example.com", time.Now().Add(time.Hour).Unix())
|
||||
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.MagicLinkByHash(hashToken(expired))
|
||||
_, err = a.MagicLinkByToken(expired)
|
||||
require.ErrorIs(t, err, ErrMagicLinkNotFound)
|
||||
m, err := a.MagicLinkByHash(hashToken(valid))
|
||||
m, err := a.MagicLinkByToken(valid)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "valid@example.com", m.Email)
|
||||
})
|
||||
|
||||
+46
-6
@@ -1556,6 +1556,37 @@ func (a *Manager) SetPrimaryEmail(userID, email string) error {
|
||||
})
|
||||
}
|
||||
|
||||
// CreateMagicLink 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.
|
||||
func (a *Manager) CreateMagicLink(kind MagicLinkKind, userID, email string, ttl time.Duration) (string, error) {
|
||||
raw := generateLinkToken()
|
||||
now := time.Now()
|
||||
m := &MagicLink{
|
||||
TokenHash: hashToken(raw),
|
||||
Kind: kind,
|
||||
UserID: userID,
|
||||
Email: email,
|
||||
Expires: now.Add(ttl).Unix(),
|
||||
Created: now.Unix(),
|
||||
}
|
||||
if err := a.AddMagicLink(m); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return raw, 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))
|
||||
}
|
||||
|
||||
// DeleteMagicLinkByToken deletes a magic link identified by its raw token (single-use consume).
|
||||
func (a *Manager) DeleteMagicLinkByToken(rawToken string) error {
|
||||
return a.DeleteMagicLink(hashToken(rawToken))
|
||||
}
|
||||
|
||||
// AddMagicLink stores a pending magic link, replacing any existing link in the same scope:
|
||||
// for email_verify that is the (user_id, email) pair (one pending verification per address);
|
||||
// for password_reset that is the user_id (one active reset per account). The replace-delete and
|
||||
@@ -1606,12 +1637,21 @@ func (a *Manager) DeleteMagicLink(tokenHash string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// VerifyEmail consumes an email-verification magic link: 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. Returns the consumed link.
|
||||
func (a *Manager) VerifyEmail(tokenHash string) (*MagicLink, error) {
|
||||
// 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.deleteVerifyScope, 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. 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
|
||||
|
||||
+3
-10
@@ -1847,16 +1847,9 @@ func TestMigrationFrom7(t *testing.T) {
|
||||
require.Equal(t, "", primary)
|
||||
|
||||
// The new magic-link machinery works post-migration
|
||||
raw := generateLinkToken()
|
||||
require.Nil(t, a.AddMagicLink(&MagicLink{
|
||||
TokenHash: hashToken(raw),
|
||||
Kind: MagicLinkKindEmailVerify,
|
||||
UserID: "u_phil",
|
||||
Email: "new@example.com",
|
||||
Expires: time.Now().Add(24 * time.Hour).Unix(),
|
||||
Created: time.Now().Unix(),
|
||||
}))
|
||||
m, err := a.VerifyEmail(hashToken(raw))
|
||||
raw, err := a.CreateMagicLink(MagicLinkKindEmailVerify, "u_phil", "new@example.com", 24*time.Hour)
|
||||
require.Nil(t, err)
|
||||
m, err := a.VerifyEmail(raw)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "new@example.com", m.Email)
|
||||
|
||||
|
||||
@@ -3,8 +3,15 @@
|
||||
"common_save": "Save",
|
||||
"common_add": "Add",
|
||||
"common_back": "Back",
|
||||
"common_close": "Close",
|
||||
"common_copy_to_clipboard": "Copy to clipboard",
|
||||
"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_description": "The ntfy server has been updated. Please refresh the page.",
|
||||
"signup_title": "Create a ntfy account",
|
||||
@@ -216,18 +223,24 @@
|
||||
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
|
||||
"account_basics_phone_numbers_dialog_channel_call": "Call",
|
||||
"account_basics_emails_title": "Email addresses",
|
||||
"account_basics_emails_description": "For email notifications",
|
||||
"account_basics_emails_no_emails_yet": "No verified emails yet",
|
||||
"account_basics_emails_description": "For email notifications and password reset",
|
||||
"account_basics_emails_no_emails_yet": "No emails yet",
|
||||
"account_basics_emails_copied_to_clipboard": "Email address copied to clipboard",
|
||||
"account_basics_emails_primary_badge": "Primary",
|
||||
"account_basics_emails_unverified": "unverified",
|
||||
"account_basics_emails_set_primary": "Set as recovery email",
|
||||
"account_basics_emails_delete": "Remove",
|
||||
"account_basics_emails_cancel": "Cancel",
|
||||
"account_basics_emails_resend": "Resend verification email",
|
||||
"account_basics_emails_resent": "Verification email sent, check your inbox",
|
||||
"account_basics_emails_primary_elsewhere": "This email is the recovery email on another account",
|
||||
"account_basics_emails_no_recovery_warning": "No recovery email set. You will not be able to reset your password. Add and verify an email below, then set it as your recovery email.",
|
||||
"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. We will send a verification link to confirm it is yours.",
|
||||
"account_basics_emails_dialog_email_label": "Email address",
|
||||
"account_basics_emails_dialog_email_placeholder": "e.g. user@example.com",
|
||||
"account_basics_emails_dialog_verify_button": "Add email",
|
||||
"account_basics_emails_dialog_code_label": "Verification code",
|
||||
"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_emails_dialog_verify_button": "Send verification link",
|
||||
"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_cannot_edit_or_delete_provisioned_user": "A provisioned user cannot be edited or deleted",
|
||||
"account_usage_title": "Usage",
|
||||
"account_usage_of_limit": "of {{limit}}",
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
accountBillingSubscriptionUrl,
|
||||
accountEmailUrl,
|
||||
accountEmailVerifyUrl,
|
||||
accountEmailPrimaryUrl,
|
||||
accountEmailResendUrl,
|
||||
accountPasswordUrl,
|
||||
accountPhoneUrl,
|
||||
accountPhoneVerifyUrl,
|
||||
@@ -342,9 +344,11 @@ class AccountApi {
|
||||
});
|
||||
}
|
||||
|
||||
async verifyEmail(email) {
|
||||
const url = accountEmailVerifyUrl(config.base_url);
|
||||
console.log(`[AccountApi] Sending email verification ${url}`);
|
||||
// startEmailVerification begins adding an email: the server stores a pending verification and
|
||||
// emails a magic link. The address is not verified until the link is clicked.
|
||||
async startEmailVerification(email) {
|
||||
const url = accountEmailUrl(config.base_url);
|
||||
console.log(`[AccountApi] Starting email verification ${url}`);
|
||||
await fetchOrThrow(url, {
|
||||
method: "PUT",
|
||||
headers: withBearerAuth({}, session.token()),
|
||||
@@ -354,15 +358,41 @@ class AccountApi {
|
||||
});
|
||||
}
|
||||
|
||||
async addEmail(email, code) {
|
||||
const url = accountEmailUrl(config.base_url);
|
||||
console.log(`[AccountApi] Adding email with verification code ${url}`);
|
||||
// verifyEmailToken performs verification from the magic-link landing page. It is unauthenticated:
|
||||
// the token identifies the account, so this works even when clicked from a logged-out browser.
|
||||
async verifyEmailToken(token) {
|
||||
const url = accountEmailVerifyUrl(config.base_url);
|
||||
console.log(`[AccountApi] Verifying email token ${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()),
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// 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,
|
||||
code,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
+11
-1
@@ -51,7 +51,15 @@ export class EmailVerificationCodeInvalidError extends Error {
|
||||
static CODE = 40051; // errHTTPBadRequestEmailVerificationCodeInvalid
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +81,8 @@ export const throwAppError = async (response) => {
|
||||
throw new IncorrectPasswordError();
|
||||
} else if (error.code === EmailVerificationCodeInvalidError.CODE) {
|
||||
throw new EmailVerificationCodeInvalidError();
|
||||
} else if (error.code === EmailPrimaryElsewhereError.CODE) {
|
||||
throw new EmailPrimaryElsewhereError();
|
||||
} else if (error?.error) {
|
||||
throw new Error(`Error ${error.code}: ${error.error}`);
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ export const accountPhoneUrl = (baseUrl) => `${baseUrl}/v1/account/phone`;
|
||||
export const accountPhoneVerifyUrl = (baseUrl) => `${baseUrl}/v1/account/phone/verify`;
|
||||
export const accountEmailUrl = (baseUrl) => `${baseUrl}/v1/account/email`;
|
||||
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 validUrl = (url) => url.match(/^https?:\/\/.+/);
|
||||
|
||||
|
||||
+118
-101
@@ -2,6 +2,7 @@ import * as React from "react";
|
||||
import { useContext, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
CardActions,
|
||||
CardContent,
|
||||
Chip,
|
||||
@@ -38,6 +39,9 @@ import {
|
||||
import EditIcon from "@mui/icons-material/Edit";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
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 CelebrationIcon from "@mui/icons-material/Celebration";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
@@ -52,7 +56,7 @@ import UpgradeDialog from "./UpgradeDialog";
|
||||
import { AccountContext } from "./App";
|
||||
import DialogFooter from "./DialogFooter";
|
||||
import { Paragraph } from "./styles";
|
||||
import { EmailVerificationCodeInvalidError, IncorrectPasswordError, UnauthorizedError } from "../app/errors";
|
||||
import { EmailPrimaryElsewhereError, IncorrectPasswordError, UnauthorizedError } from "../app/errors";
|
||||
import { ProChip } from "./SubscriptionPopup";
|
||||
import session from "../app/Session";
|
||||
|
||||
@@ -359,7 +363,7 @@ const Emails = () => {
|
||||
const { account } = useContext(AccountContext);
|
||||
const [dialogKey, setDialogKey] = useState(0);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [snackOpen, setSnackOpen] = useState(false);
|
||||
const [snack, setSnack] = useState(""); // Non-empty shows a transient snackbar message
|
||||
const labelId = "prefVerifiedEmails";
|
||||
|
||||
const handleDialogOpen = () => {
|
||||
@@ -373,20 +377,34 @@ const Emails = () => {
|
||||
|
||||
const handleCopy = (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). The account list refreshes via the sync event.
|
||||
const runEmailAction = async (fn, errorMessage) => {
|
||||
try {
|
||||
await accountApi.deleteEmail(email);
|
||||
await fn();
|
||||
} catch (e) {
|
||||
console.log(`[Account] Error deleting email`, e);
|
||||
console.log(`[Account] Email action failed`, e);
|
||||
if (e instanceof UnauthorizedError) {
|
||||
await session.resetAndRedirect(routes.login);
|
||||
} else if (e instanceof EmailPrimaryElsewhereError) {
|
||||
setSnack(t("account_basics_emails_primary_elsewhere"));
|
||||
} else {
|
||||
setSnack(errorMessage ?? e.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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_email_verify) {
|
||||
return null;
|
||||
}
|
||||
@@ -407,35 +425,73 @@ const Emails = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const verifiedEmails = account?.emails ?? [];
|
||||
const pendingEmails = account?.pending_emails ?? [];
|
||||
const primaryEmail = account?.primary_email ?? "";
|
||||
const showNoRecoveryWarning = config.enable_reset_password && primaryEmail === "";
|
||||
|
||||
return (
|
||||
<Pref labelId={labelId} title={t("account_basics_emails_title")} description={t("account_basics_emails_description")}>
|
||||
<div aria-labelledby={labelId}>
|
||||
{account?.emails?.map((email) => (
|
||||
<Chip
|
||||
key={email}
|
||||
label={
|
||||
<Tooltip title={t("common_copy_to_clipboard")}>
|
||||
<span>{email}</span>
|
||||
{showNoRecoveryWarning && (
|
||||
<Alert severity="warning" sx={{ mb: 1 }}>
|
||||
{t("account_basics_emails_no_recovery_warning")}
|
||||
</Alert>
|
||||
)}
|
||||
<Stack spacing={0.5}>
|
||||
{verifiedEmails.map((email) => {
|
||||
const isPrimary = email === primaryEmail;
|
||||
return (
|
||||
<Box key={email} sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||
<Typography sx={{ flexGrow: 1, overflowWrap: "anywhere" }}>{email}</Typography>
|
||||
{isPrimary && <Chip size="small" color="primary" label={t("account_basics_emails_primary_badge")} />}
|
||||
{!isPrimary && (
|
||||
<Tooltip title={t("account_basics_emails_set_primary")}>
|
||||
<IconButton size="small" aria-label={t("account_basics_emails_set_primary")} onClick={() => handleSetPrimary(email)}>
|
||||
<StarBorderIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isPrimary && <StarIcon fontSize="small" color="primary" sx={{ mx: 0.5 }} />}
|
||||
<Tooltip title={t("common_copy_to_clipboard")}>
|
||||
<IconButton size="small" aria-label={t("common_copy_to_clipboard")} onClick={() => handleCopy(email)}>
|
||||
<ContentCopy fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t("account_basics_emails_delete")}>
|
||||
<IconButton size="small" aria-label={t("account_basics_emails_delete")} onClick={() => handleDelete(email)}>
|
||||
<DeleteOutlineIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{pendingEmails.map((email) => (
|
||||
<Box key={email} sx={{ display: "flex", alignItems: "center", gap: 0.5, opacity: 0.65 }}>
|
||||
<Typography sx={{ flexGrow: 1, overflowWrap: "anywhere" }}>
|
||||
{email} <em>({t("account_basics_emails_unverified")})</em>
|
||||
</Typography>
|
||||
<Tooltip title={t("account_basics_emails_resend")}>
|
||||
<IconButton size="small" aria-label={t("account_basics_emails_resend")} onClick={() => handleResend(email)}>
|
||||
<RefreshIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
}
|
||||
variant="outlined"
|
||||
onClick={() => handleCopy(email)}
|
||||
onDelete={() => handleDelete(email)}
|
||||
/>
|
||||
))}
|
||||
{!account?.emails && <em>{t("account_basics_emails_no_emails_yet")}</em>}
|
||||
<IconButton onClick={handleDialogOpen}>
|
||||
<Tooltip title={t("account_basics_emails_cancel")}>
|
||||
<IconButton size="small" aria-label={t("account_basics_emails_cancel")} onClick={() => handleDelete(email)}>
|
||||
<DeleteOutlineIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
))}
|
||||
{verifiedEmails.length === 0 && pendingEmails.length === 0 && <em>{t("account_basics_emails_no_emails_yet")}</em>}
|
||||
</Stack>
|
||||
<IconButton onClick={handleDialogOpen} aria-label={t("account_basics_emails_dialog_title")}>
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
<AddEmailDialog key={`addEmailDialog${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />
|
||||
<Portal>
|
||||
<Snackbar
|
||||
open={snackOpen}
|
||||
autoHideDuration={3000}
|
||||
onClose={() => setSnackOpen(false)}
|
||||
message={t("account_basics_emails_copied_to_clipboard")}
|
||||
/>
|
||||
<Snackbar open={snack !== ""} autoHideDuration={3000} onClose={() => setSnack("")} message={snack} />
|
||||
</Portal>
|
||||
</Pref>
|
||||
);
|
||||
@@ -446,18 +502,19 @@ const AddEmailDialog = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const [error, setError] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [verificationCodeSent, setVerificationCodeSent] = useState(false);
|
||||
const [sent, setSent] = useState(false);
|
||||
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 {
|
||||
setSending(true);
|
||||
await accountApi.verifyEmail(email);
|
||||
setVerificationCodeSent(true);
|
||||
await accountApi.startEmailVerification(email);
|
||||
setSent(true);
|
||||
} catch (e) {
|
||||
console.log(`[Account] Error sending email verification`, e);
|
||||
console.log(`[Account] Error starting email verification`, e);
|
||||
if (e instanceof UnauthorizedError) {
|
||||
await session.resetAndRedirect(routes.login);
|
||||
} else {
|
||||
@@ -468,81 +525,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 (
|
||||
<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>
|
||||
<DialogContent>
|
||||
<DialogContentText>{t("account_basics_emails_dialog_description")}</DialogContentText>
|
||||
{!verificationCodeSent && (
|
||||
<TextField
|
||||
margin="dense"
|
||||
label={t("account_basics_emails_dialog_email_label")}
|
||||
aria-label={t("account_basics_emails_dialog_email_label")}
|
||||
placeholder={t("account_basics_emails_dialog_email_placeholder")}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(ev) => setEmail(ev.target.value)}
|
||||
fullWidth
|
||||
variant="standard"
|
||||
/>
|
||||
)}
|
||||
{verificationCodeSent && (
|
||||
<TextField
|
||||
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"
|
||||
/>
|
||||
{sent ? (
|
||||
<DialogContentText>{t("account_basics_emails_dialog_check_inbox")}</DialogContentText>
|
||||
) : (
|
||||
<>
|
||||
<DialogContentText>{t("account_basics_emails_dialog_description")}</DialogContentText>
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
label={t("account_basics_emails_dialog_email_label")}
|
||||
aria-label={t("account_basics_emails_dialog_email_label")}
|
||||
placeholder={t("account_basics_emails_dialog_email_placeholder")}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(ev) => setEmail(ev.target.value)}
|
||||
fullWidth
|
||||
variant="standard"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogFooter status={error}>
|
||||
<Button onClick={handleCancel}>{verificationCodeSent ? t("common_back") : t("common_cancel")}</Button>
|
||||
<Button onClick={handleDialogSubmit} disabled={sending || !/^[^\s,;]+@[^\s,;]+$/.test(email)}>
|
||||
{!verificationCodeSent && t("account_basics_emails_dialog_verify_button")}
|
||||
{verificationCodeSent && t("account_basics_emails_dialog_check_verification_button")}
|
||||
</Button>
|
||||
{sent ? (
|
||||
<Button onClick={props.onClose}>{t("common_close")}</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>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -20,6 +20,7 @@ import Messaging from "./Messaging";
|
||||
import Login from "./Login";
|
||||
import Signup from "./Signup";
|
||||
import Account from "./Account";
|
||||
import EmailVerify from "./EmailVerify";
|
||||
import initI18n from "../app/i18n"; // Translations!
|
||||
import prefs from "../app/Prefs";
|
||||
import RTLCacheProvider from "./RTLCacheProvider";
|
||||
@@ -63,6 +64,7 @@ const App = () => {
|
||||
<Routes>
|
||||
<Route path={routes.login} element={<Login />} />
|
||||
<Route path={routes.signup} element={<Signup />} />
|
||||
<Route path={routes.emailVerify} element={<EmailVerify />} />
|
||||
<Route element={<Layout />}>
|
||||
<Route path={routes.app} element={<AllSubscriptions />} />
|
||||
<Route path={routes.account} element={<Account />} />
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
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, NavLink } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import accountApi from "../app/AccountApi";
|
||||
import AvatarBox from "./AvatarBox";
|
||||
import routes from "./routes";
|
||||
|
||||
// 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 [status, setStatus] = useState("verifying"); // "verifying" | "success" | "error"
|
||||
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("success");
|
||||
} catch (e) {
|
||||
console.log(`[EmailVerify] Verification failed`, e);
|
||||
setStatus("error");
|
||||
}
|
||||
})();
|
||||
}, [token]);
|
||||
|
||||
return (
|
||||
<AvatarBox>
|
||||
{status === "verifying" && (
|
||||
<>
|
||||
<CircularProgress sx={{ mb: 2 }} />
|
||||
<Typography sx={{ typography: "h6" }}>{t("email_verify_progress_title")}</Typography>
|
||||
</>
|
||||
)}
|
||||
{status === "success" && (
|
||||
<>
|
||||
<CheckCircleOutlineIcon color="success" sx={{ fontSize: 48, mb: 1 }} />
|
||||
<Typography sx={{ typography: "h6" }}>{t("email_verify_success_title")}</Typography>
|
||||
<Typography sx={{ mt: 1, textAlign: "center" }}>{t("email_verify_success_description")}</Typography>
|
||||
<Button component={NavLink} to={routes.account} variant="contained" sx={{ mt: 2 }}>
|
||||
{t("email_verify_button_account")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{status === "error" && (
|
||||
<>
|
||||
<ErrorOutlineIcon color="error" sx={{ fontSize: 48, mb: 1 }} />
|
||||
<Typography sx={{ typography: "h6" }}>{t("email_verify_error_title")}</Typography>
|
||||
<Typography sx={{ mt: 1, textAlign: "center" }}>{t("email_verify_error_description")}</Typography>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Button component={NavLink} to={routes.account} variant="contained">
|
||||
{t("email_verify_button_account")}
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</AvatarBox>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailVerify;
|
||||
@@ -7,6 +7,7 @@ const routes = {
|
||||
app: config.app_root,
|
||||
account: "/account",
|
||||
settings: "/settings",
|
||||
emailVerify: "/account/email/verify/:token",
|
||||
subscription: "/:topic",
|
||||
subscriptionExternal: "/:baseUrl/:topic",
|
||||
forSubscription: (subscription) => {
|
||||
|
||||
Reference in New Issue
Block a user