mirror of
https://github.com/multipleof4/ntfy.git
synced 2026-10-15 16:07:55 +00:00
Allow logging in via email
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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`;
|
||||||
|
|||||||
@@ -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())}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user