Allow logging in via email

This commit is contained in:
binwiederhier
2026-07-16 21:42:42 +02:00
parent b55e78a918
commit 24bc50b585
17 changed files with 452 additions and 199 deletions
+6
View File
@@ -2007,6 +2007,12 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
## Not released yet ## Not released yet
### ntfy server v2.27.0 (UNRELEASED)
**Features:**
* Allow logging in with your verified primary email address (in addition to your username), so a password reset no longer leaves you unable to sign in when you only remember the email you signed up with
### ntfy Android v1.25.2 (UNRELEASED) ### ntfy Android v1.25.2 (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
+3
View File
@@ -110,6 +110,7 @@ var (
apiUsersPath = "/v1/users" apiUsersPath = "/v1/users"
apiUsersAccessPath = "/v1/users/access" apiUsersAccessPath = "/v1/users/access"
apiAccountPath = "/v1/account" apiAccountPath = "/v1/account"
apiAccountLoginPath = "/v1/account/login"
apiAccountTokenPath = "/v1/account/token" apiAccountTokenPath = "/v1/account/token"
apiAccountPasswordPath = "/v1/account/password" apiAccountPasswordPath = "/v1/account/password"
apiAccountSettingsPath = "/v1/account/settings" apiAccountSettingsPath = "/v1/account/settings"
@@ -579,6 +580,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
return s.ensureUser(s.withAccountSync(s.handleAccountDelete))(w, r, v) return s.ensureUser(s.withAccountSync(s.handleAccountDelete))(w, r, v)
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountPasswordPath { } else if r.Method == http.MethodPost && r.URL.Path == apiAccountPasswordPath {
return s.ensureUser(s.handleAccountPasswordChange)(w, r, v) return s.ensureUser(s.handleAccountPasswordChange)(w, r, v)
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountLoginPath {
return s.ensureUser(s.withAccountSync(s.handleAccountLogin))(w, r, v)
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountTokenPath { } else if r.Method == http.MethodPost && r.URL.Path == apiAccountTokenPath {
return s.ensureUser(s.withAccountSync(s.handleAccountTokenCreate))(w, r, v) return s.ensureUser(s.withAccountSync(s.handleAccountTokenCreate))(w, r, v)
} else if r.Method == http.MethodPatch && r.URL.Path == apiAccountTokenPath { } else if r.Method == http.MethodPatch && r.URL.Path == apiAccountTokenPath {
+18
View File
@@ -268,6 +268,24 @@ func (s *Server) handleAccountPasswordChange(w http.ResponseWriter, r *http.Requ
return s.writeJSON(w, newSuccessResponse()) return s.writeJSON(w, newSuccessResponse())
} }
// handleAccountLogin authenticates a username-or-email + password (via the ensureUser wrapper's
// Basic Auth), mints a session token, and returns it together with the canonical username. Unlike
// the token endpoint (which exists to mint arbitrary API tokens), this endpoint's job is to log a
// user in, so it also reports who they are (the identifier they typed may be a primary email).
func (s *Server) handleAccountLogin(w http.ResponseWriter, r *http.Request, v *visitor) error {
u := v.User()
logvr(v, r).Tag(tagAccount).Info("Logging in user %s", u.Name)
token, err := s.userManager.CreateToken(u.ID, "", time.Now().Add(tokenExpiryDuration), v.IP(), false)
if err != nil {
return err
}
response := &apiAccountLoginResponse{
Token: token.Value,
Username: u.Name,
}
return s.writeJSON(w, response)
}
func (s *Server) handleAccountTokenCreate(w http.ResponseWriter, r *http.Request, v *visitor) error { func (s *Server) handleAccountTokenCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
req, err := readJSONWithLimit[apiAccountTokenIssueRequest](r.Body, jsonBodyBytesLimit, true) // Allow empty body! req, err := readJSONWithLimit[apiAccountTokenIssueRequest](r.Body, jsonBodyBytesLimit, true) // Allow empty body!
if err != nil { if err != nil {
+16
View File
@@ -224,6 +224,22 @@ func canLogin(t *testing.T, s *Server, username, password string) bool {
return rr.Code == 200 return rr.Code == 200
} }
func TestAccount_LoginByPrimaryEmail(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")
// Basic Auth works with either the username or the verified primary email
require.True(t, canLogin(t, s, "ben", "ben"))
require.True(t, canLogin(t, s, "ben@example.com", "ben"))
// ...but not with the wrong password or an unknown email
require.False(t, canLogin(t, s, "ben@example.com", "wrong"))
require.False(t, canLogin(t, s, "nobody@example.com", "ben"))
})
}
func TestAccount_PasswordReset_ByUsername(t *testing.T) { func TestAccount_PasswordReset_ByUsername(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) { forEachBackend(t, func(t *testing.T, databaseURL string) {
s, mailer, auth := newEmailTestServer(t, databaseURL) s, mailer, auth := newEmailTestServer(t, databaseURL)
+52
View File
@@ -55,6 +55,58 @@ func TestAccount_Signup_Success(t *testing.T) {
}) })
} }
func TestAccount_Login_Success(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
conf := newTestConfigWithAuthFile(t, databaseURL)
s := newTestServer(t, conf)
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "mypass", user.RoleUser, false))
u, err := s.userManager.User("phil")
require.Nil(t, err)
require.Nil(t, s.userManager.AddEmail(u.ID, "phil@example.com"))
require.Nil(t, s.userManager.SetPrimaryEmail(u.ID, "phil@example.com"))
// Login by username returns a token and the canonical username
rr := request(t, s, "POST", "/v1/account/login", "", map[string]string{
"Authorization": util.BasicAuth("phil", "mypass"),
})
require.Equal(t, 200, rr.Code)
resp, _ := util.UnmarshalJSON[apiAccountLoginResponse](io.NopCloser(rr.Body))
require.True(t, strings.HasPrefix(resp.Token, "tk_"))
require.Equal(t, "phil", resp.Username)
// The returned token actually authenticates
rr = request(t, s, "GET", "/v1/account", "", map[string]string{
"Authorization": util.BearerAuth(resp.Token),
})
require.Equal(t, 200, rr.Code)
// Login by primary email returns the canonical username, not the email that was typed
rr = request(t, s, "POST", "/v1/account/login", "", map[string]string{
"Authorization": util.BasicAuth("phil@example.com", "mypass"),
})
require.Equal(t, 200, rr.Code)
resp, _ = util.UnmarshalJSON[apiAccountLoginResponse](io.NopCloser(rr.Body))
require.True(t, strings.HasPrefix(resp.Token, "tk_"))
require.Equal(t, "phil", resp.Username)
})
}
func TestAccount_Login_InvalidCredentials(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
conf := newTestConfigWithAuthFile(t, databaseURL)
s := newTestServer(t, conf)
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "mypass", user.RoleUser, false))
rr := request(t, s, "POST", "/v1/account/login", "", map[string]string{
"Authorization": util.BasicAuth("phil", "wrongpass"),
})
require.Equal(t, 401, rr.Code)
})
}
func TestAccount_Signup_UserExists(t *testing.T) { func TestAccount_Signup_UserExists(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) { forEachBackend(t, func(t *testing.T, databaseURL string) {
conf := newTestConfigWithAuthFile(t, databaseURL) conf := newTestConfigWithAuthFile(t, databaseURL)
+8
View File
@@ -217,6 +217,14 @@ type apiAccountTokenResponse struct {
Provisioned bool `json:"provisioned,omitempty"` // True if this token was provisioned by the server config Provisioned bool `json:"provisioned,omitempty"` // True if this token was provisioned by the server config
} }
// apiAccountLoginResponse is the body of POST /v1/account/login: it authenticates a
// username-or-email + password, mints a session token, and returns the token together with the
// canonical username (which may differ from the identifier the user typed, e.g. a primary email).
type apiAccountLoginResponse struct {
Token string `json:"token"`
Username string `json:"username"`
}
type apiAccountPhoneNumberVerifyRequest struct { type apiAccountPhoneNumberVerifyRequest struct {
Number string `json:"number"` Number string `json:"number"`
Channel string `json:"channel"` Channel string `json:"channel"`
+26 -9
View File
@@ -153,24 +153,26 @@ func (a *Manager) asyncExpiredMagicLinkReapLoop(interval time.Duration) {
} }
} }
// Authenticate checks username and password and returns a User if correct, and the user has not been // Authenticate checks a login identifier (a username or a verified primary email) and password, and
// marked as deleted. The method returns in constant-ish time, regardless of whether the user exists or // returns a User if correct and not marked as deleted. The identifier is resolved in a single query
// the password is correct or incorrect. // via userByNameOrEmail, so a user can log in with either their username or their primary
func (a *Manager) Authenticate(username, password string) (*User, error) { // email. The method returns in constant-ish time (one query, one bcrypt compare), regardless of
if username == Everyone { // whether the identifier exists or the password is correct or incorrect.
func (a *Manager) Authenticate(identifier, password string) (*User, error) {
if identifier == Everyone {
return nil, ErrUnauthenticated return nil, ErrUnauthenticated
} }
user, err := a.User(username) user, err := a.userByNameOrEmail(identifier)
if err != nil { if err != nil {
log.Tag(tag).Field("user_name", username).Err(err).Trace("Authentication of user failed (1)") log.Tag(tag).Field("user_name", identifier).Err(err).Trace("Authentication of user failed (1)")
bcrypt.CompareHashAndPassword([]byte(userAuthIntentionalSlowDownHash), []byte("intentional slow-down to avoid timing attacks")) bcrypt.CompareHashAndPassword([]byte(userAuthIntentionalSlowDownHash), []byte("intentional slow-down to avoid timing attacks"))
return nil, ErrUnauthenticated return nil, ErrUnauthenticated
} else if user.Deleted { } else if user.Deleted {
log.Tag(tag).Field("user_name", username).Trace("Authentication of user failed (2): user marked deleted") log.Tag(tag).Field("user_name", identifier).Trace("Authentication of user failed (2): user marked deleted")
bcrypt.CompareHashAndPassword([]byte(userAuthIntentionalSlowDownHash), []byte("intentional slow-down to avoid timing attacks")) bcrypt.CompareHashAndPassword([]byte(userAuthIntentionalSlowDownHash), []byte("intentional slow-down to avoid timing attacks"))
return nil, ErrUnauthenticated return nil, ErrUnauthenticated
} else if err := bcrypt.CompareHashAndPassword([]byte(user.Hash), []byte(password)); err != nil { } else if err := bcrypt.CompareHashAndPassword([]byte(user.Hash), []byte(password)); err != nil {
log.Tag(tag).Field("user_name", username).Err(err).Trace("Authentication of user failed (3)") log.Tag(tag).Field("user_name", identifier).Err(err).Trace("Authentication of user failed (3)")
return nil, ErrUnauthenticated return nil, ErrUnauthenticated
} }
return user, nil return user, nil
@@ -532,6 +534,21 @@ func (a *Manager) UserByEmailOrUsername(identifier string) (*User, error) {
return a.User(identifier) return a.User(identifier)
} }
// userByNameOrEmail resolves a login identifier to a single user in one query, matching it
// against the username first and a verified primary email address second. This is the INVERSE
// precedence of UserByEmailOrUsername (used by password reset): at login a freely-chosen username
// must win over a look-alike primary email, so a user whose username happens to equal another
// account's email is not locked out of their own account. Because Authenticate still gates the match
// on a password check, returning the username owner here never grants access to the email owner's
// account. Returns ErrUserNotFound if neither matches.
func (a *Manager) userByNameOrEmail(identifier string) (*User, error) {
rows, err := a.db.Query(a.queries.selectUserByNameOrPrimaryEmail, identifier, identifier, identifier)
if err != nil {
return nil, err
}
return a.readUser(rows)
}
// 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())
+84 -74
View File
@@ -33,6 +33,15 @@ const (
LEFT JOIN tier t on t.id = u.tier_id LEFT JOIN tier t on t.id = u.tier_id
WHERE user_name = $1 WHERE user_name = $1
` `
postgresSelectUserByNameOrPrimaryEmailQuery = `
SELECT u.id, u.user_name, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, u.deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM "user" u
LEFT JOIN tier t on t.id = u.tier_id
WHERE u.user_name = $1
OR u.id = (SELECT user_id FROM user_email WHERE email = $2 AND is_primary)
ORDER BY CASE WHEN u.user_name = $3 THEN 0 ELSE 1 END
LIMIT 1
`
postgresSelectUserByTokenQuery = ` postgresSelectUserByTokenQuery = `
SELECT u.id, u.user_name, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, u.deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id SELECT u.id, u.user_name, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, u.deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM "user" u FROM "user" u
@@ -260,80 +269,81 @@ func postgresSelectAccessCacheUsersQuery(n int) string {
// NewPostgresManager creates a new Manager backed by a PostgreSQL database using an existing connection pool. // NewPostgresManager creates a new Manager backed by a PostgreSQL database using an existing connection pool.
var postgresQueries = queries{ var postgresQueries = queries{
selectUserByID: postgresSelectUserByIDQuery, selectUserByID: postgresSelectUserByIDQuery,
selectUserByName: postgresSelectUserByNameQuery, selectUserByName: postgresSelectUserByNameQuery,
selectUserByToken: postgresSelectUserByTokenQuery, selectUserByNameOrPrimaryEmail: postgresSelectUserByNameOrPrimaryEmailQuery,
selectUserByStripeCustomerID: postgresSelectUserByStripeCustomerIDQuery, selectUserByToken: postgresSelectUserByTokenQuery,
selectUsernames: postgresSelectUsernamesQuery, selectUserByStripeCustomerID: postgresSelectUserByStripeCustomerIDQuery,
selectUsers: postgresSelectUsersQuery, selectUsernames: postgresSelectUsernamesQuery,
selectUserCount: postgresSelectUserCountQuery, selectUsers: postgresSelectUsersQuery,
selectUserIDFromUsername: postgresSelectUserIDFromUsernameQuery, selectUserCount: postgresSelectUserCountQuery,
insertUser: postgresInsertUserQuery, selectUserIDFromUsername: postgresSelectUserIDFromUsernameQuery,
updateUserPass: postgresUpdateUserPassQuery, insertUser: postgresInsertUserQuery,
updateUserRole: postgresUpdateUserRoleQuery, updateUserPass: postgresUpdateUserPassQuery,
updateUserProvisioned: postgresUpdateUserProvisionedQuery, updateUserRole: postgresUpdateUserRoleQuery,
updateUserPrefs: postgresUpdateUserPrefsQuery, updateUserProvisioned: postgresUpdateUserProvisionedQuery,
updateUserStats: postgresUpdateUserStatsQuery, updateUserPrefs: postgresUpdateUserPrefsQuery,
updateUserStatsResetAll: postgresUpdateUserStatsResetAllQuery, updateUserStats: postgresUpdateUserStatsQuery,
updateUserTier: postgresUpdateUserTierQuery, updateUserStatsResetAll: postgresUpdateUserStatsResetAllQuery,
updateUserDeleted: postgresUpdateUserDeletedQuery, updateUserTier: postgresUpdateUserTierQuery,
deleteUser: postgresDeleteUserQuery, updateUserDeleted: postgresUpdateUserDeletedQuery,
deleteUserTier: postgresDeleteUserTierQuery, deleteUser: postgresDeleteUserQuery,
deleteUsersMarked: postgresDeleteUsersMarkedQuery, deleteUserTier: postgresDeleteUserTierQuery,
deleteUsersProvisioned: postgresDeleteUsersProvisionedQuery, deleteUsersMarked: postgresDeleteUsersMarkedQuery,
selectTopicPerms: postgresSelectTopicPermsQuery, deleteUsersProvisioned: postgresDeleteUsersProvisionedQuery,
selectAccessCacheAll: postgresSelectAccessCacheAllQuery, selectTopicPerms: postgresSelectTopicPermsQuery,
selectAccessCacheUsers: postgresSelectAccessCacheUsersQuery, selectAccessCacheAll: postgresSelectAccessCacheAllQuery,
selectUserAllAccess: postgresSelectUserAllAccessQuery, selectAccessCacheUsers: postgresSelectAccessCacheUsersQuery,
selectUserAccess: postgresSelectUserAccessQuery, selectUserAllAccess: postgresSelectUserAllAccessQuery,
selectUserReservations: postgresSelectUserReservationsQuery, selectUserAccess: postgresSelectUserAccessQuery,
selectUserReservationsCount: postgresSelectUserReservationsCountQuery, selectUserReservations: postgresSelectUserReservationsQuery,
selectUserReservationsOwner: postgresSelectUserReservationsOwnerQuery, selectUserReservationsCount: postgresSelectUserReservationsCountQuery,
selectUserHasReservation: postgresSelectUserHasReservationQuery, selectUserReservationsOwner: postgresSelectUserReservationsOwnerQuery,
selectOtherAccessCount: postgresSelectOtherAccessCountQuery, selectUserHasReservation: postgresSelectUserHasReservationQuery,
upsertUserAccess: postgresUpsertUserAccessQuery, selectOtherAccessCount: postgresSelectOtherAccessCountQuery,
deleteUserAccess: postgresDeleteUserAccessQuery, upsertUserAccess: postgresUpsertUserAccessQuery,
deleteUserAccessProvisioned: postgresDeleteUserAccessProvisionedQuery, deleteUserAccess: postgresDeleteUserAccessQuery,
deleteTopicAccess: postgresDeleteTopicAccessQuery, deleteUserAccessProvisioned: postgresDeleteUserAccessProvisionedQuery,
deleteAllAccess: postgresDeleteAllAccessQuery, deleteTopicAccess: postgresDeleteTopicAccessQuery,
selectToken: postgresSelectTokenQuery, deleteAllAccess: postgresDeleteAllAccessQuery,
selectTokens: postgresSelectTokensQuery, selectToken: postgresSelectTokenQuery,
selectTokenCount: postgresSelectTokenCountQuery, selectTokens: postgresSelectTokensQuery,
selectAllProvisionedTokens: postgresSelectAllProvisionedTokensQuery, selectTokenCount: postgresSelectTokenCountQuery,
upsertToken: postgresUpsertTokenQuery, selectAllProvisionedTokens: postgresSelectAllProvisionedTokensQuery,
updateToken: postgresUpdateTokenQuery, upsertToken: postgresUpsertTokenQuery,
updateTokenLastAccess: postgresUpdateTokenLastAccessQuery, updateToken: postgresUpdateTokenQuery,
deleteToken: postgresDeleteTokenQuery, updateTokenLastAccess: postgresUpdateTokenLastAccessQuery,
deleteProvisionedToken: postgresDeleteProvisionedTokenQuery, deleteToken: postgresDeleteTokenQuery,
deleteAllProvisionedTokens: postgresDeleteAllProvisionedTokensQuery, deleteProvisionedToken: postgresDeleteProvisionedTokenQuery,
deleteAllToken: postgresDeleteAllTokenQuery, deleteAllProvisionedTokens: postgresDeleteAllProvisionedTokensQuery,
deleteExpiredTokens: postgresDeleteExpiredTokensQuery, deleteAllToken: postgresDeleteAllTokenQuery,
deleteExcessTokens: postgresDeleteExcessTokensQuery, deleteExpiredTokens: postgresDeleteExpiredTokensQuery,
insertTier: postgresInsertTierQuery, deleteExcessTokens: postgresDeleteExcessTokensQuery,
selectTiers: postgresSelectTiersQuery, insertTier: postgresInsertTierQuery,
selectTierByCode: postgresSelectTierByCodeQuery, selectTiers: postgresSelectTiersQuery,
selectTierByPriceID: postgresSelectTierByPriceIDQuery, selectTierByCode: postgresSelectTierByCodeQuery,
updateTier: postgresUpdateTierQuery, selectTierByPriceID: postgresSelectTierByPriceIDQuery,
deleteTier: postgresDeleteTierQuery, updateTier: postgresUpdateTierQuery,
selectPhoneNumbers: postgresSelectPhoneNumbersQuery, deleteTier: postgresDeleteTierQuery,
insertPhoneNumber: postgresInsertPhoneNumberQuery, selectPhoneNumbers: postgresSelectPhoneNumbersQuery,
deletePhoneNumber: postgresDeletePhoneNumberQuery, insertPhoneNumber: postgresInsertPhoneNumberQuery,
selectEmails: postgresSelectEmailsQuery, deletePhoneNumber: postgresDeletePhoneNumberQuery,
insertEmail: postgresInsertEmailQuery, selectEmails: postgresSelectEmailsQuery,
insertEmailIgnore: postgresInsertEmailIgnoreQuery, insertEmail: postgresInsertEmailQuery,
deleteEmail: postgresDeleteEmailQuery, insertEmailIgnore: postgresInsertEmailIgnoreQuery,
selectPrimaryEmail: postgresSelectPrimaryEmailQuery, deleteEmail: postgresDeleteEmailQuery,
selectUserIDByPrimary: postgresSelectUserIDByPrimaryQuery, selectPrimaryEmail: postgresSelectPrimaryEmailQuery,
updateEmailSetPrimary: postgresUpdateEmailSetPrimaryQuery, selectUserIDByPrimary: postgresSelectUserIDByPrimaryQuery,
updateEmailClearPrimary: postgresUpdateEmailClearPrimaryQuery, updateEmailSetPrimary: postgresUpdateEmailSetPrimaryQuery,
insertMagicLink: postgresInsertMagicLinkQuery, updateEmailClearPrimary: postgresUpdateEmailClearPrimaryQuery,
selectMagicLinkByHash: postgresSelectMagicLinkByHashQuery, insertMagicLink: postgresInsertMagicLinkQuery,
deleteMagicLinkByHash: postgresDeleteMagicLinkByHashQuery, selectMagicLinkByHash: postgresSelectMagicLinkByHashQuery,
deleteMagicLinkEmailVerify: postgresDeleteVerifyScopeQuery, deleteMagicLinkByHash: postgresDeleteMagicLinkByHashQuery,
deleteMagicLinkResetPassword: postgresDeleteResetScopeQuery, deleteMagicLinkEmailVerify: postgresDeleteVerifyScopeQuery,
selectPendingEmails: postgresSelectPendingEmailsQuery, deleteMagicLinkResetPassword: postgresDeleteResetScopeQuery,
deleteExpiredMagicLinks: postgresDeleteExpiredMagicLinksQuery, selectPendingEmails: postgresSelectPendingEmailsQuery,
updateBilling: postgresUpdateBillingQuery, deleteExpiredMagicLinks: postgresDeleteExpiredMagicLinksQuery,
updateBilling: postgresUpdateBillingQuery,
} }
// NewPostgresManager creates a new Manager backed by a PostgreSQL database // NewPostgresManager creates a new Manager backed by a PostgreSQL database
+84 -74
View File
@@ -37,6 +37,15 @@ const (
LEFT JOIN tier t on t.id = u.tier_id LEFT JOIN tier t on t.id = u.tier_id
WHERE user = ? WHERE user = ?
` `
sqliteSelectUserByNameOrPrimaryEmailQuery = `
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM user u
LEFT JOIN tier t on t.id = u.tier_id
WHERE u.user = ?
OR u.id = (SELECT user_id FROM user_email WHERE email = ? AND is_primary = 1)
ORDER BY CASE WHEN u.user = ? THEN 0 ELSE 1 END
LIMIT 1
`
sqliteSelectUserByTokenQuery = ` sqliteSelectUserByTokenQuery = `
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM user u FROM user u
@@ -256,80 +265,81 @@ func sqliteSelectAccessCacheUsersQuery(n int) string {
} }
var sqliteQueries = queries{ var sqliteQueries = queries{
selectUserByID: sqliteSelectUserByIDQuery, selectUserByID: sqliteSelectUserByIDQuery,
selectUserByName: sqliteSelectUserByNameQuery, selectUserByName: sqliteSelectUserByNameQuery,
selectUserByToken: sqliteSelectUserByTokenQuery, selectUserByNameOrPrimaryEmail: sqliteSelectUserByNameOrPrimaryEmailQuery,
selectUserByStripeCustomerID: sqliteSelectUserByStripeCustomerIDQuery, selectUserByToken: sqliteSelectUserByTokenQuery,
selectUsernames: sqliteSelectUsernamesQuery, selectUserByStripeCustomerID: sqliteSelectUserByStripeCustomerIDQuery,
selectUsers: sqliteSelectUsersQuery, selectUsernames: sqliteSelectUsernamesQuery,
selectUserCount: sqliteSelectUserCountQuery, selectUsers: sqliteSelectUsersQuery,
selectUserIDFromUsername: sqliteSelectUserIDFromUsernameQuery, selectUserCount: sqliteSelectUserCountQuery,
insertUser: sqliteInsertUserQuery, selectUserIDFromUsername: sqliteSelectUserIDFromUsernameQuery,
updateUserPass: sqliteUpdateUserPassQuery, insertUser: sqliteInsertUserQuery,
updateUserRole: sqliteUpdateUserRoleQuery, updateUserPass: sqliteUpdateUserPassQuery,
updateUserProvisioned: sqliteUpdateUserProvisionedQuery, updateUserRole: sqliteUpdateUserRoleQuery,
updateUserPrefs: sqliteUpdateUserPrefsQuery, updateUserProvisioned: sqliteUpdateUserProvisionedQuery,
updateUserStats: sqliteUpdateUserStatsQuery, updateUserPrefs: sqliteUpdateUserPrefsQuery,
updateUserStatsResetAll: sqliteUpdateUserStatsResetAllQuery, updateUserStats: sqliteUpdateUserStatsQuery,
updateUserTier: sqliteUpdateUserTierQuery, updateUserStatsResetAll: sqliteUpdateUserStatsResetAllQuery,
updateUserDeleted: sqliteUpdateUserDeletedQuery, updateUserTier: sqliteUpdateUserTierQuery,
deleteUser: sqliteDeleteUserQuery, updateUserDeleted: sqliteUpdateUserDeletedQuery,
deleteUserTier: sqliteDeleteUserTierQuery, deleteUser: sqliteDeleteUserQuery,
deleteUsersMarked: sqliteDeleteUsersMarkedQuery, deleteUserTier: sqliteDeleteUserTierQuery,
deleteUsersProvisioned: sqliteDeleteUsersProvisionedQuery, deleteUsersMarked: sqliteDeleteUsersMarkedQuery,
selectTopicPerms: sqliteSelectTopicPermsQuery, deleteUsersProvisioned: sqliteDeleteUsersProvisionedQuery,
selectAccessCacheAll: sqliteSelectAccessCacheAllQuery, selectTopicPerms: sqliteSelectTopicPermsQuery,
selectAccessCacheUsers: sqliteSelectAccessCacheUsersQuery, selectAccessCacheAll: sqliteSelectAccessCacheAllQuery,
selectUserAllAccess: sqliteSelectUserAllAccessQuery, selectAccessCacheUsers: sqliteSelectAccessCacheUsersQuery,
selectUserAccess: sqliteSelectUserAccessQuery, selectUserAllAccess: sqliteSelectUserAllAccessQuery,
selectUserReservations: sqliteSelectUserReservationsQuery, selectUserAccess: sqliteSelectUserAccessQuery,
selectUserReservationsCount: sqliteSelectUserReservationsCountQuery, selectUserReservations: sqliteSelectUserReservationsQuery,
selectUserReservationsOwner: sqliteSelectUserReservationsOwnerQuery, selectUserReservationsCount: sqliteSelectUserReservationsCountQuery,
selectUserHasReservation: sqliteSelectUserHasReservationQuery, selectUserReservationsOwner: sqliteSelectUserReservationsOwnerQuery,
selectOtherAccessCount: sqliteSelectOtherAccessCountQuery, selectUserHasReservation: sqliteSelectUserHasReservationQuery,
upsertUserAccess: sqliteUpsertUserAccessQuery, selectOtherAccessCount: sqliteSelectOtherAccessCountQuery,
deleteUserAccess: sqliteDeleteUserAccessQuery, upsertUserAccess: sqliteUpsertUserAccessQuery,
deleteUserAccessProvisioned: sqliteDeleteUserAccessProvisionedQuery, deleteUserAccess: sqliteDeleteUserAccessQuery,
deleteTopicAccess: sqliteDeleteTopicAccessQuery, deleteUserAccessProvisioned: sqliteDeleteUserAccessProvisionedQuery,
deleteAllAccess: sqliteDeleteAllAccessQuery, deleteTopicAccess: sqliteDeleteTopicAccessQuery,
selectToken: sqliteSelectTokenQuery, deleteAllAccess: sqliteDeleteAllAccessQuery,
selectTokens: sqliteSelectTokensQuery, selectToken: sqliteSelectTokenQuery,
selectTokenCount: sqliteSelectTokenCountQuery, selectTokens: sqliteSelectTokensQuery,
selectAllProvisionedTokens: sqliteSelectAllProvisionedTokensQuery, selectTokenCount: sqliteSelectTokenCountQuery,
upsertToken: sqliteUpsertTokenQuery, selectAllProvisionedTokens: sqliteSelectAllProvisionedTokensQuery,
updateToken: sqliteUpdateTokenQuery, upsertToken: sqliteUpsertTokenQuery,
updateTokenLastAccess: sqliteUpdateTokenLastAccessQuery, updateToken: sqliteUpdateTokenQuery,
deleteToken: sqliteDeleteTokenQuery, updateTokenLastAccess: sqliteUpdateTokenLastAccessQuery,
deleteProvisionedToken: sqliteDeleteProvisionedTokenQuery, deleteToken: sqliteDeleteTokenQuery,
deleteAllProvisionedTokens: sqliteDeleteAllProvisionedTokensQuery, deleteProvisionedToken: sqliteDeleteProvisionedTokenQuery,
deleteAllToken: sqliteDeleteAllTokenQuery, deleteAllProvisionedTokens: sqliteDeleteAllProvisionedTokensQuery,
deleteExpiredTokens: sqliteDeleteExpiredTokensQuery, deleteAllToken: sqliteDeleteAllTokenQuery,
deleteExcessTokens: sqliteDeleteExcessTokensQuery, deleteExpiredTokens: sqliteDeleteExpiredTokensQuery,
insertTier: sqliteInsertTierQuery, deleteExcessTokens: sqliteDeleteExcessTokensQuery,
selectTiers: sqliteSelectTiersQuery, insertTier: sqliteInsertTierQuery,
selectTierByCode: sqliteSelectTierByCodeQuery, selectTiers: sqliteSelectTiersQuery,
selectTierByPriceID: sqliteSelectTierByPriceIDQuery, selectTierByCode: sqliteSelectTierByCodeQuery,
updateTier: sqliteUpdateTierQuery, selectTierByPriceID: sqliteSelectTierByPriceIDQuery,
deleteTier: sqliteDeleteTierQuery, updateTier: sqliteUpdateTierQuery,
selectPhoneNumbers: sqliteSelectPhoneNumbersQuery, deleteTier: sqliteDeleteTierQuery,
insertPhoneNumber: sqliteInsertPhoneNumberQuery, selectPhoneNumbers: sqliteSelectPhoneNumbersQuery,
deletePhoneNumber: sqliteDeletePhoneNumberQuery, insertPhoneNumber: sqliteInsertPhoneNumberQuery,
selectEmails: sqliteSelectEmailsQuery, deletePhoneNumber: sqliteDeletePhoneNumberQuery,
insertEmail: sqliteInsertEmailQuery, selectEmails: sqliteSelectEmailsQuery,
insertEmailIgnore: sqliteInsertEmailIgnoreQuery, insertEmail: sqliteInsertEmailQuery,
deleteEmail: sqliteDeleteEmailQuery, insertEmailIgnore: sqliteInsertEmailIgnoreQuery,
selectPrimaryEmail: sqliteSelectPrimaryEmailQuery, deleteEmail: sqliteDeleteEmailQuery,
selectUserIDByPrimary: sqliteSelectUserIDByPrimaryQuery, selectPrimaryEmail: sqliteSelectPrimaryEmailQuery,
updateEmailSetPrimary: sqliteUpdateEmailSetPrimaryQuery, selectUserIDByPrimary: sqliteSelectUserIDByPrimaryQuery,
updateEmailClearPrimary: sqliteUpdateEmailClearPrimaryQuery, updateEmailSetPrimary: sqliteUpdateEmailSetPrimaryQuery,
insertMagicLink: sqliteInsertMagicLinkQuery, updateEmailClearPrimary: sqliteUpdateEmailClearPrimaryQuery,
selectMagicLinkByHash: sqliteSelectMagicLinkByHashQuery, insertMagicLink: sqliteInsertMagicLinkQuery,
deleteMagicLinkByHash: sqliteDeleteMagicLinkByHashQuery, selectMagicLinkByHash: sqliteSelectMagicLinkByHashQuery,
deleteMagicLinkEmailVerify: sqliteDeleteVerifyScopeQuery, deleteMagicLinkByHash: sqliteDeleteMagicLinkByHashQuery,
deleteMagicLinkResetPassword: sqliteDeleteResetScopeQuery, deleteMagicLinkEmailVerify: sqliteDeleteVerifyScopeQuery,
selectPendingEmails: sqliteSelectPendingEmailsQuery, deleteMagicLinkResetPassword: sqliteDeleteResetScopeQuery,
deleteExpiredMagicLinks: sqliteDeleteExpiredMagicLinksQuery, selectPendingEmails: sqliteSelectPendingEmailsQuery,
updateBilling: sqliteUpdateBillingQuery, deleteExpiredMagicLinks: sqliteDeleteExpiredMagicLinksQuery,
updateBilling: sqliteUpdateBillingQuery,
} }
// NewSQLiteManager creates a new Manager backed by a SQLite database // NewSQLiteManager creates a new Manager backed by a SQLite database
+102
View File
@@ -2947,6 +2947,108 @@ func TestUser_MagicLink_PrimaryGlobalUniqueness(t *testing.T) {
}) })
} }
func TestManager_Authenticate_ByPrimaryEmail(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)
// phil verifies phil@example.com -> becomes his primary (recovery) email
_, err = a.VerifyEmail(addVerifyLink(t, a, phil.ID, "phil@example.com", 24*time.Hour))
require.Nil(t, err)
// Login by username still works
u, err := a.Authenticate("phil", "phil")
require.Nil(t, err)
require.Equal(t, "phil", u.Name)
// Login by primary email works and resolves to the same account
u, err = a.Authenticate("phil@example.com", "phil")
require.Nil(t, err)
require.Equal(t, "phil", u.Name)
// Login by primary email with the wrong password fails
u, err = a.Authenticate("phil@example.com", "wrong")
require.Nil(t, u)
require.Equal(t, ErrUnauthenticated, err)
// An unknown email fails
u, err = a.Authenticate("nobody@example.com", "phil")
require.Nil(t, u)
require.Equal(t, ErrUnauthenticated, err)
})
}
func TestManager_Authenticate_BySecondaryEmailDenied(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 -> his primary; ben verifies it too -> only secondary for ben
_, err = a.VerifyEmail(addVerifyLink(t, a, phil.ID, "shared@example.com", 24*time.Hour))
require.Nil(t, err)
_, err = a.VerifyEmail(addVerifyLink(t, a, ben.ID, "shared@example.com", 24*time.Hour))
require.Nil(t, err)
// Login by the shared address resolves to the primary owner (phil), never the secondary (ben)
u, err := a.Authenticate("shared@example.com", "phil")
require.Nil(t, err)
require.Equal(t, "phil", u.Name)
// ben's password must not authenticate via the shared address (it is not his primary)
u, err = a.Authenticate("shared@example.com", "ben")
require.Nil(t, u)
require.Equal(t, ErrUnauthenticated, err)
})
}
func TestManager_Authenticate_UsernameLookalikeEmailPrecedence(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
a := newTestManager(t, newManager, PermissionDenyAll)
// The collision: a squatter whose USERNAME is literally "phil@example.com" (usernames may
// contain '@' and '.'), and a different account (ben) that owns "phil@example.com" as its
// verified primary email. Both are reachable; nothing links usernames to email addresses.
require.Nil(t, a.AddUser("phil@example.com", "squatterpass", RoleUser, false))
require.Nil(t, a.AddUser("ben", "benpass", RoleUser, false))
ben, err := a.User("ben")
require.Nil(t, err)
_, err = a.VerifyEmail(addVerifyLink(t, a, ben.ID, "phil@example.com", 24*time.Hour))
require.Nil(t, err)
// Login resolves the ambiguous identifier username-FIRST (the ORDER BY CASE in the query):
// the squatter owns the login, and returns deterministically even though both rows match.
squatter, err := a.Authenticate("phil@example.com", "squatterpass")
require.Nil(t, err)
require.Equal(t, "phil@example.com", squatter.Name)
// Consequently the email owner's password does NOT authenticate via the colliding identifier
// at login, but the owner is not locked out: their real username still works.
u, err := a.Authenticate("phil@example.com", "benpass")
require.Nil(t, u)
require.Equal(t, ErrUnauthenticated, err)
u, err = a.Authenticate("ben", "benpass")
require.Nil(t, err)
require.Equal(t, "ben", u.Name)
// The inverse: password reset (UserByEmailOrUsername) resolves the SAME identifier email-FIRST,
// so the reset link goes to the verified email owner (ben), never the look-alike username. The
// two flows deliberately use opposite precedence.
loginUser, err := a.userByNameOrEmail("phil@example.com")
require.Nil(t, err)
require.Equal(t, "phil@example.com", loginUser.Name) // username owner (squatter)
resetUser, err := a.UserByEmailOrUsername("phil@example.com")
require.Nil(t, err)
require.Equal(t, "ben", resetUser.Name) // email owner
})
}
func TestUser_MagicLink_SetPrimary_NotVerified(t *testing.T) { func TestUser_MagicLink_SetPrimary_NotVerified(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
a := newTestManager(t, newManager, PermissionDenyAll) a := newTestManager(t, newManager, PermissionDenyAll)
+26 -25
View File
@@ -47,10 +47,10 @@ func (u *User) IsUser() bool {
// Auther is an interface for authentication and authorization // Auther is an interface for authentication and authorization
type Auther interface { type Auther interface {
// Authenticate checks username and password and returns a user if correct. The method // Authenticate checks a login identifier (username or verified primary email) and password
// returns in constant-ish time, regardless of whether the user exists or the password is // and returns a user if correct. The method returns in constant-ish time, regardless of
// correct or incorrect. // whether the identifier exists or the password is correct or incorrect.
Authenticate(username, password string) (*User, error) Authenticate(identifier, password string) (*User, error)
// 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
// permission. The user param may be nil to signal an anonymous user. // permission. The user param may be nil to signal an anonymous user.
@@ -338,27 +338,28 @@ var (
// queries holds the database-specific SQL queries // queries holds the database-specific SQL queries
type queries struct { type queries struct {
// User queries // User queries
selectUserByID string selectUserByID string
selectUserByName string selectUserByName string
selectUserByToken string selectUserByNameOrPrimaryEmail string
selectUserByStripeCustomerID string selectUserByToken string
selectUsernames string selectUserByStripeCustomerID string
selectUsers string selectUsernames string
selectUserCount string selectUsers string
selectUserIDFromUsername string selectUserCount string
insertUser string selectUserIDFromUsername string
updateUserPass string insertUser string
updateUserRole string updateUserPass string
updateUserProvisioned string updateUserRole string
updateUserPrefs string updateUserProvisioned string
updateUserStats string updateUserPrefs string
updateUserStatsResetAll string updateUserStats string
updateUserTier string updateUserStatsResetAll string
updateUserDeleted string updateUserTier string
deleteUser string updateUserDeleted string
deleteUserTier string deleteUser string
deleteUsersMarked string deleteUserTier string
deleteUsersProvisioned string deleteUsersMarked string
deleteUsersProvisioned string
// Access queries // Access queries
selectTopicPerms string // Direct-DB authorizeTopicAccess query; used when the in-memory cache is disabled selectTopicPerms string // Direct-DB authorizeTopicAccess query; used when the in-memory cache is disabled
+1
View File
@@ -26,6 +26,7 @@
"signup_error_username_taken": "Username {{username}} is already taken", "signup_error_username_taken": "Username {{username}} is already taken",
"signup_error_creation_limit_reached": "Account creation limit reached", "signup_error_creation_limit_reached": "Account creation limit reached",
"login_title": "Sign in to your ntfy account", "login_title": "Sign in to your ntfy account",
"login_form_username_label": "Username or email",
"login_form_button_submit": "Sign in", "login_form_button_submit": "Sign in",
"login_link_signup": "Sign up", "login_link_signup": "Sign up",
"login_link_forgot_password": "Forgot password", "login_link_forgot_password": "Forgot password",
+7 -3
View File
@@ -6,6 +6,7 @@ import {
accountEmailVerifyUrl, accountEmailVerifyUrl,
accountEmailPrimaryUrl, accountEmailPrimaryUrl,
accountEmailResendUrl, accountEmailResendUrl,
accountLoginUrl,
accountPasswordResetRequestUrl, accountPasswordResetRequestUrl,
accountPasswordResetUrl, accountPasswordResetUrl,
accountPasswordUrl, accountPasswordUrl,
@@ -47,8 +48,8 @@ class AccountApi {
} }
async login(user) { async login(user) {
const url = accountTokenUrl(config.base_url); const url = accountLoginUrl(config.base_url);
console.log(`[AccountApi] Checking auth for ${url}`); console.log(`[AccountApi] Logging in at ${url}`);
const response = await fetchOrThrow(url, { const response = await fetchOrThrow(url, {
method: "POST", method: "POST",
headers: withBasicAuth({}, user.username, user.password), headers: withBasicAuth({}, user.username, user.password),
@@ -57,7 +58,10 @@ class AccountApi {
if (!json.token) { if (!json.token) {
throw new Error(`Unexpected server response: Cannot find token`); throw new Error(`Unexpected server response: Cannot find token`);
} }
return json.token; // The identifier the user typed may be a primary email; login returns the canonical username
// so callers can store it and show the real username rather than whatever was typed. Fall back
// to the typed identifier if an older server omits the username, so the session still stores one.
return { token: json.token, username: json.username || user.username };
} }
async logout() { async logout() {
+10 -6
View File
@@ -42,15 +42,19 @@ afterEach(() => {
}); });
describe("AccountApi.login", () => { describe("AccountApi.login", () => {
it("POSTs basic auth to the token URL and returns the token", async () => { it("POSTs basic auth to the login URL and returns the token and canonical username", async () => {
fetchMock.mockResolvedValue(ok({ token: "tk_returned" })); // The typed identifier is an email; the login endpoint returns the canonical username in one
const token = await accountApi.login({ username: "phil", password: "secret" }); // request, so login() surfaces it (callers store it) rather than echoing what was typed.
fetchMock.mockResolvedValue(ok({ token: "tk_returned", username: "phil" }));
const result = await accountApi.login({ username: "phil@example.com", password: "secret" });
expect(result).toEqual({ token: "tk_returned", username: "phil" });
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(token).toBe("tk_returned");
const [url, options] = fetchMock.mock.calls[0]; const [url, options] = fetchMock.mock.calls[0];
expect(url).toBe("https://ntfy.sh/v1/account/token"); expect(url).toBe("https://ntfy.sh/v1/account/login");
expect(options.method).toBe("POST"); expect(options.method).toBe("POST");
expect(options.headers.Authorization).toBe(`Basic ${btoa("phil:secret")}`); expect(options.headers.Authorization).toBe(`Basic ${btoa("phil@example.com:secret")}`);
}); });
it("throws when the server response has no token", async () => { it("throws when the server response has no token", async () => {
+1
View File
@@ -23,6 +23,7 @@ export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/aut
export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic)); export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic));
export const webPushUrl = (baseUrl) => `${baseUrl}/v1/webpush`; export const webPushUrl = (baseUrl) => `${baseUrl}/v1/webpush`;
export const accountUrl = (baseUrl) => `${baseUrl}/v1/account`; export const accountUrl = (baseUrl) => `${baseUrl}/v1/account`;
export const accountLoginUrl = (baseUrl) => `${baseUrl}/v1/account/login`;
export const accountPasswordUrl = (baseUrl) => `${baseUrl}/v1/account/password`; export const accountPasswordUrl = (baseUrl) => `${baseUrl}/v1/account/password`;
export const accountTokenUrl = (baseUrl) => `${baseUrl}/v1/account/token`; export const accountTokenUrl = (baseUrl) => `${baseUrl}/v1/account/token`;
export const accountSettingsUrl = (baseUrl) => `${baseUrl}/v1/account/settings`; export const accountSettingsUrl = (baseUrl) => `${baseUrl}/v1/account/settings`;
+5 -5
View File
@@ -24,14 +24,14 @@ const Login = () => {
event.preventDefault(); event.preventDefault();
const user = { username, password }; const user = { username, password };
try { try {
const token = await accountApi.login(user); const { token, username: canonicalUsername } = await accountApi.login(user);
console.log(`[Login] User auth for user ${user.username} successful, token is ${token}`); console.log(`[Login] User auth for user ${user.username} successful, logged in as ${canonicalUsername}`);
await session.store(user.username, token); await session.store(canonicalUsername, token);
fadeReload(routes.app); fadeReload(routes.app);
} catch (e) { } catch (e) {
console.log(`[Login] User auth for user ${user.username} failed`, e); console.log(`[Login] User auth for user ${user.username} failed`, e);
if (e instanceof UnauthorizedError) { if (e instanceof UnauthorizedError) {
setError(t("Login failed: Invalid username or password")); setError(t("Login failed: Invalid username/email or password"));
} else { } else {
setError(e.message); setError(e.message);
} }
@@ -53,7 +53,7 @@ const Login = () => {
required required
fullWidth fullWidth
id="username" id="username"
label={t("signup_form_username")} label={t("login_form_username_label")}
name="username" name="username"
value={username} value={username}
onChange={(ev) => setUsername(ev.target.value.trim())} onChange={(ev) => setUsername(ev.target.value.trim())}
+3 -3
View File
@@ -28,9 +28,9 @@ const Signup = () => {
const user = { username, password }; const user = { username, password };
try { try {
await accountApi.create(user.username, user.password, email); await accountApi.create(user.username, user.password, email);
const token = await accountApi.login(user); const { token, username: canonicalUsername } = 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, logged in as ${canonicalUsername}`);
await session.store(user.username, token); await session.store(canonicalUsername, token);
fadeReload(routes.app); fadeReload(routes.app);
} catch (e) { } catch (e) {
console.log(`[Signup] Signup for user ${user.username} failed`, e); console.log(`[Signup] Signup for user ${user.username} failed`, e);