diff --git a/cmd/user.go b/cmd/user.go index 8c65221d..d9d5b0fc 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -126,8 +126,6 @@ does not require the user to have a verified primary email (the token is bound t With --send-email, the link is additionally emailed to the user's primary email address (this requires SMTP to be configured and the user to have a verified primary email). -Requires base-url to be configured so an absolute link can be generated. - Example: ntfy user reset-pass phil # Print a reset link for user phil ntfy user reset-pass --send-email phil # Print and email the reset link @@ -290,7 +288,6 @@ func execUserDel(c *cli.Context) error { func execUserChangePass(c *cli.Context) error { username := c.Args().Get(0) password, hashed := os.LookupEnv("NTFY_PASSWORD_HASH") - if !hashed { password = os.Getenv("NTFY_PASSWORD") } diff --git a/cmd/user_test.go b/cmd/user_test.go index c5e1c44c..bde0fa1f 100644 --- a/cmd/user_test.go +++ b/cmd/user_test.go @@ -126,12 +126,12 @@ func TestCLI_User_ResetPass(t *testing.T) { s, conf, port := newTestServerWithAuth(t) defer test.StopServer(t, s, port) - app, stdin, stdout, _ := newTestApp() + app, stdin, _, _ := newTestApp() stdin.WriteString("mypass\nmypass") require.Nil(t, runUserCommand(app, conf, "add", "phil")) // Prints a working-looking reset link when base-url is set - app, _, stdout, _ = newTestApp() + app, _, stdout, _ := newTestApp() require.Nil(t, runUserCommand(app, conf, "--base-url=https://ntfy.example.com", "reset-pass", "phil")) require.Contains(t, stdout.String(), "https://ntfy.example.com/account/password/reset/") } diff --git a/user/magic_link_test.go b/user/magic_link_test.go deleted file mode 100644 index 4a2506c4..00000000 --- a/user/magic_link_test.go +++ /dev/null @@ -1,360 +0,0 @@ -package user - -import ( - "testing" - "time" - - "github.com/stretchr/testify/require" -) - -// addVerifyLink stores an email-verification magic link and returns the raw token so the test -// can "click" it via VerifyEmail. -func addVerifyLink(t *testing.T, a *Manager, userID, email string, ttl time.Duration) string { - raw, err := a.CreateMagicLink(MagicLinkKindEmailVerify, userID, email, ttl) - require.Nil(t, err) - return raw -} - -func TestUser_MagicLink_VerifyEmail_SetsPrimary(t *testing.T) { - forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { - a := newTestManager(t, newManager, PermissionDenyAll) - require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) - phil, err := a.User("phil") - require.Nil(t, err) - - raw := addVerifyLink(t, a, phil.ID, "phil@example.com", 24*time.Hour) - - // Before verifying: pending, not yet verified, no primary - pending, err := a.PendingEmails(phil.ID) - require.Nil(t, err) - require.Equal(t, []string{"phil@example.com"}, pending) - emails, err := a.Emails(phil.ID) - require.Nil(t, err) - require.Equal(t, 0, len(emails)) - primary, err := a.PrimaryEmail(phil.ID) - require.Nil(t, err) - require.Equal(t, "", primary) - - // Verify: the first verified email auto-becomes primary - m, err := a.VerifyEmail(raw) - require.Nil(t, err) - require.Equal(t, "phil@example.com", m.Email) - - emails, err = a.Emails(phil.ID) - require.Nil(t, err) - require.Equal(t, []string{"phil@example.com"}, emails) - primary, err = a.PrimaryEmail(phil.ID) - require.Nil(t, err) - require.Equal(t, "phil@example.com", primary) - pending, err = a.PendingEmails(phil.ID) - require.Nil(t, err) - require.Equal(t, 0, len(pending)) - - // Reset-by-email lookup resolves to the account - userID, err := a.UserIDByPrimaryEmail("phil@example.com") - require.Nil(t, err) - require.Equal(t, phil.ID, userID) - }) -} - -func TestUser_MagicLink_VerifyEmail_SecondStaysSecondary(t *testing.T) { - forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { - a := newTestManager(t, newManager, PermissionDenyAll) - require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) - phil, err := a.User("phil") - require.Nil(t, err) - - raw1 := addVerifyLink(t, a, phil.ID, "first@example.com", 24*time.Hour) - _, err = a.VerifyEmail(raw1) - require.Nil(t, err) - - raw2 := addVerifyLink(t, a, phil.ID, "second@example.com", 24*time.Hour) - _, err = a.VerifyEmail(raw2) - require.Nil(t, err) - - // Both verified, but primary is still the first - emails, err := a.Emails(phil.ID) - require.Nil(t, err) - require.Equal(t, []string{"first@example.com", "second@example.com"}, emails) - primary, err := a.PrimaryEmail(phil.ID) - require.Nil(t, err) - require.Equal(t, "first@example.com", primary) - }) -} - -func TestUser_MagicLink_PrimaryGlobalUniqueness(t *testing.T) { - forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { - a := newTestManager(t, newManager, PermissionDenyAll) - require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) - require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) - phil, err := a.User("phil") - require.Nil(t, err) - ben, err := a.User("ben") - require.Nil(t, err) - - // phil verifies shared@ first -> becomes his primary - _, err = a.VerifyEmail(addVerifyLink(t, a, phil.ID, "shared@example.com", 24*time.Hour)) - require.Nil(t, err) - primary, err := a.PrimaryEmail(phil.ID) - require.Nil(t, err) - require.Equal(t, "shared@example.com", primary) - - // ben verifies the same address -> allowed as secondary, but NOT his primary - _, err = a.VerifyEmail(addVerifyLink(t, a, ben.ID, "shared@example.com", 24*time.Hour)) - require.Nil(t, err) - emails, err := a.Emails(ben.ID) - require.Nil(t, err) - require.Equal(t, []string{"shared@example.com"}, emails) - primary, err = a.PrimaryEmail(ben.ID) - require.Nil(t, err) - require.Equal(t, "", primary) - - // Explicitly promoting ben's copy to primary collides with phil's - require.ErrorIs(t, a.SetPrimaryEmail(ben.ID, "shared@example.com"), ErrEmailPrimaryElsewhere) - // ...and phil keeps his primary (the failed promotion rolled back ben's clear) - primary, err = a.PrimaryEmail(phil.ID) - require.Nil(t, err) - require.Equal(t, "shared@example.com", primary) - }) -} - -func TestUser_MagicLink_SetPrimary_NotVerified(t *testing.T) { - forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { - a := newTestManager(t, newManager, PermissionDenyAll) - require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) - phil, err := a.User("phil") - require.Nil(t, err) - require.ErrorIs(t, a.SetPrimaryEmail(phil.ID, "nope@example.com"), ErrEmailNotFound) - }) -} - -func TestUser_MagicLink_Expired(t *testing.T) { - forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { - a := newTestManager(t, newManager, PermissionDenyAll) - require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) - phil, err := a.User("phil") - require.Nil(t, err) - - raw := addVerifyLink(t, a, phil.ID, "phil@example.com", -time.Minute) - _, err = a.VerifyEmail(raw) - require.ErrorIs(t, err, ErrMagicLinkNotFound) - - // Nothing got verified - emails, err := a.Emails(phil.ID) - require.Nil(t, err) - require.Equal(t, 0, len(emails)) - }) -} - -func TestUser_MagicLink_SingleUse(t *testing.T) { - forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { - a := newTestManager(t, newManager, PermissionDenyAll) - require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) - phil, err := a.User("phil") - require.Nil(t, err) - - raw := addVerifyLink(t, a, phil.ID, "phil@example.com", 24*time.Hour) - _, err = a.VerifyEmail(raw) - require.Nil(t, err) - // Second click: token already consumed - _, err = a.VerifyEmail(raw) - require.ErrorIs(t, err, ErrMagicLinkNotFound) - }) -} - -func TestUser_MagicLink_ReplaceOnReRequest(t *testing.T) { - forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { - a := newTestManager(t, newManager, PermissionDenyAll) - require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) - phil, err := a.User("phil") - require.Nil(t, err) - - raw1 := addVerifyLink(t, a, phil.ID, "phil@example.com", 24*time.Hour) - raw2 := addVerifyLink(t, a, phil.ID, "phil@example.com", 24*time.Hour) - - // Only one pending row remains; the old token no longer works - pending, err := a.PendingEmails(phil.ID) - require.Nil(t, err) - require.Equal(t, []string{"phil@example.com"}, pending) - _, err = a.MagicLinkByToken(raw1) - require.ErrorIs(t, err, ErrMagicLinkNotFound) - - m, err := a.MagicLinkByToken(raw2) - require.Nil(t, err) - require.Equal(t, "phil@example.com", m.Email) - }) -} - -func TestUser_MagicLink_PasswordReset_RoundTrip(t *testing.T) { - forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { - a := newTestManager(t, newManager, PermissionDenyAll) - require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) - phil, err := a.User("phil") - require.Nil(t, err) - - raw, err := a.CreateMagicLink(MagicLinkKindPasswordReset, phil.ID, "", time.Hour) - require.Nil(t, err) - - m, err := a.MagicLinkByToken(raw) - require.Nil(t, err) - require.Equal(t, MagicLinkKindPasswordReset, m.Kind) - require.Equal(t, phil.ID, m.UserID) - require.Equal(t, "", m.Email) // reset rows carry no email - - // Reset rows do not appear as pending emails - pending, err := a.PendingEmails(phil.ID) - require.Nil(t, err) - require.Equal(t, 0, len(pending)) - - // New request replaces the old token - raw2, err := a.CreateMagicLink(MagicLinkKindPasswordReset, phil.ID, "", time.Hour) - require.Nil(t, err) - _, err = a.MagicLinkByToken(raw) - require.ErrorIs(t, err, ErrMagicLinkNotFound) - - // Single use: deleting consumes it - require.Nil(t, a.DeleteMagicLinkByToken(raw2)) - _, err = a.MagicLinkByToken(raw2) - require.ErrorIs(t, err, ErrMagicLinkNotFound) - }) -} - -func TestUser_MagicLink_Reaper(t *testing.T) { - forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { - a := newTestManager(t, newManager, PermissionDenyAll) - require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) - phil, err := a.User("phil") - require.Nil(t, err) - - expired := addVerifyLink(t, a, phil.ID, "expired@example.com", -time.Hour) - valid := addVerifyLink(t, a, phil.ID, "valid@example.com", time.Hour) - - require.Nil(t, a.deleteExpiredMagicLinks()) - - _, err = a.MagicLinkByToken(expired) - require.ErrorIs(t, err, ErrMagicLinkNotFound) - m, err := a.MagicLinkByToken(valid) - require.Nil(t, err) - require.Equal(t, "valid@example.com", m.Email) - }) -} - -func TestUser_MagicLink_ResetPassword(t *testing.T) { - forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { - a := newTestManager(t, newManager, PermissionDenyAll) - require.Nil(t, a.AddUser("phil", "oldpass", RoleUser, false)) - phil, err := a.User("phil") - require.Nil(t, err) - - raw, err := a.CreateMagicLink(MagicLinkKindPasswordReset, phil.ID, "", time.Hour) - require.Nil(t, err) - - // Old password works before reset - _, err = a.Authenticate("phil", "oldpass") - require.Nil(t, err) - - require.Nil(t, a.ResetPassword(raw, "newpass")) - - // New password works, old does not - _, err = a.Authenticate("phil", "newpass") - require.Nil(t, err) - _, err = a.Authenticate("phil", "oldpass") - require.ErrorIs(t, err, ErrUnauthenticated) - - // Token is single-use - require.ErrorIs(t, a.ResetPassword(raw, "againpass"), ErrMagicLinkNotFound) - }) -} - -func TestUser_MagicLink_ResetPassword_WrongKindRejected(t *testing.T) { - forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { - a := newTestManager(t, newManager, PermissionDenyAll) - require.Nil(t, a.AddUser("phil", "oldpass", RoleUser, false)) - phil, err := a.User("phil") - require.Nil(t, err) - - // An email-verification token must not be usable for password reset... - verifyToken := addVerifyLink(t, a, phil.ID, "phil@example.com", time.Hour) - require.ErrorIs(t, a.ResetPassword(verifyToken, "newpass"), ErrMagicLinkNotFound) - - // ...and a reset token must not be usable for email verification - resetToken, err := a.CreateMagicLink(MagicLinkKindPasswordReset, phil.ID, "", time.Hour) - require.Nil(t, err) - _, err = a.VerifyEmail(resetToken) - require.ErrorIs(t, err, ErrMagicLinkNotFound) - - // Old password unchanged - _, err = a.Authenticate("phil", "oldpass") - require.Nil(t, err) - }) -} - -func TestUser_MagicLink_VerifyEmail_ProvisionedNoPrimary(t *testing.T) { - forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { - a := newTestManagerFromConfig(t, newManager, &Config{ - DefaultAccess: PermissionDenyAll, - ProvisionEnabled: true, - Users: []*User{ - {Name: "prov", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser}, - }, - }) - prov, err := a.User("prov") - require.Nil(t, err) - - // A provisioned user can verify an email (for notifications), but it must NOT become primary - _, err = a.VerifyEmail(addVerifyLink(t, a, prov.ID, "prov@example.com", time.Hour)) - require.Nil(t, err) - - emails, err := a.Emails(prov.ID) - require.Nil(t, err) - require.Equal(t, []string{"prov@example.com"}, emails) - primary, err := a.PrimaryEmail(prov.ID) - require.Nil(t, err) - require.Equal(t, "", primary) - }) -} - -func TestUser_MagicLink_ResetPassword_ProvisionedRejected(t *testing.T) { - forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { - // Provisioned users come from the config file (ProvisionEnabled), not AddUser - a := newTestManagerFromConfig(t, newManager, &Config{ - DefaultAccess: PermissionDenyAll, - ProvisionEnabled: true, - Users: []*User{ - {Name: "prov", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser}, - }, - }) - prov, err := a.User("prov") - require.Nil(t, err) - require.True(t, prov.Provisioned) - - // A reset token can be created, but consuming it must be rejected for a provisioned user - // (their password comes from the config file, like change-pass). - raw, err := a.CreateMagicLink(MagicLinkKindPasswordReset, prov.ID, "", time.Hour) - require.Nil(t, err) - require.ErrorIs(t, a.ResetPassword(raw, "newpass"), ErrProvisionedUserChange) - }) -} - -func TestUser_MagicLink_ResetPassword_Expired(t *testing.T) { - forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { - a := newTestManager(t, newManager, PermissionDenyAll) - require.Nil(t, a.AddUser("phil", "oldpass", RoleUser, false)) - phil, err := a.User("phil") - require.Nil(t, err) - - raw, err := a.CreateMagicLink(MagicLinkKindPasswordReset, phil.ID, "", -time.Minute) - require.Nil(t, err) - require.ErrorIs(t, a.ResetPassword(raw, "newpass"), ErrMagicLinkNotFound) - _, err = a.Authenticate("phil", "oldpass") - require.Nil(t, err) - }) -} - -func TestUser_MagicLink_UserIDByPrimaryEmail_NotFound(t *testing.T) { - forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { - a := newTestManager(t, newManager, PermissionDenyAll) - _, err := a.UserIDByPrimaryEmail("ghost@example.com") - require.ErrorIs(t, err, ErrUserNotFound) - }) -} diff --git a/user/manager_test.go b/user/manager_test.go index 7e05f5db..6bc0bccd 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -2897,3 +2897,355 @@ func TestStoreOtherAccessCount(t *testing.T) { require.Equal(t, 2, count) // ben's owner entry + everyone entry }) } + +// addVerifyLink stores an email-verification magic link and returns the raw token so the test +// can "click" it via VerifyEmail. +func addVerifyLink(t *testing.T, a *Manager, userID, email string, ttl time.Duration) string { + raw, err := a.CreateMagicLink(MagicLinkKindEmailVerify, userID, email, ttl) + require.Nil(t, err) + return raw +} + +func TestUser_MagicLink_VerifyEmail_SetsPrimary(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + raw := addVerifyLink(t, a, phil.ID, "phil@example.com", 24*time.Hour) + + // Before verifying: pending, not yet verified, no primary + pending, err := a.PendingEmails(phil.ID) + require.Nil(t, err) + require.Equal(t, []string{"phil@example.com"}, pending) + emails, err := a.Emails(phil.ID) + require.Nil(t, err) + require.Equal(t, 0, len(emails)) + primary, err := a.PrimaryEmail(phil.ID) + require.Nil(t, err) + require.Equal(t, "", primary) + + // Verify: the first verified email auto-becomes primary + m, err := a.VerifyEmail(raw) + require.Nil(t, err) + require.Equal(t, "phil@example.com", m.Email) + + emails, err = a.Emails(phil.ID) + require.Nil(t, err) + require.Equal(t, []string{"phil@example.com"}, emails) + primary, err = a.PrimaryEmail(phil.ID) + require.Nil(t, err) + require.Equal(t, "phil@example.com", primary) + pending, err = a.PendingEmails(phil.ID) + require.Nil(t, err) + require.Equal(t, 0, len(pending)) + + // Reset-by-email lookup resolves to the account + userID, err := a.UserIDByPrimaryEmail("phil@example.com") + require.Nil(t, err) + require.Equal(t, phil.ID, userID) + }) +} + +func TestUser_MagicLink_VerifyEmail_SecondStaysSecondary(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + raw1 := addVerifyLink(t, a, phil.ID, "first@example.com", 24*time.Hour) + _, err = a.VerifyEmail(raw1) + require.Nil(t, err) + + raw2 := addVerifyLink(t, a, phil.ID, "second@example.com", 24*time.Hour) + _, err = a.VerifyEmail(raw2) + require.Nil(t, err) + + // Both verified, but primary is still the first + emails, err := a.Emails(phil.ID) + require.Nil(t, err) + require.Equal(t, []string{"first@example.com", "second@example.com"}, emails) + primary, err := a.PrimaryEmail(phil.ID) + require.Nil(t, err) + require.Equal(t, "first@example.com", primary) + }) +} + +func TestUser_MagicLink_PrimaryGlobalUniqueness(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + ben, err := a.User("ben") + require.Nil(t, err) + + // phil verifies shared@ first -> becomes his primary + _, err = a.VerifyEmail(addVerifyLink(t, a, phil.ID, "shared@example.com", 24*time.Hour)) + require.Nil(t, err) + primary, err := a.PrimaryEmail(phil.ID) + require.Nil(t, err) + require.Equal(t, "shared@example.com", primary) + + // ben verifies the same address -> allowed as secondary, but NOT his primary + _, err = a.VerifyEmail(addVerifyLink(t, a, ben.ID, "shared@example.com", 24*time.Hour)) + require.Nil(t, err) + emails, err := a.Emails(ben.ID) + require.Nil(t, err) + require.Equal(t, []string{"shared@example.com"}, emails) + primary, err = a.PrimaryEmail(ben.ID) + require.Nil(t, err) + require.Equal(t, "", primary) + + // Explicitly promoting ben's copy to primary collides with phil's + require.ErrorIs(t, a.SetPrimaryEmail(ben.ID, "shared@example.com"), ErrEmailPrimaryElsewhere) + // ...and phil keeps his primary (the failed promotion rolled back ben's clear) + primary, err = a.PrimaryEmail(phil.ID) + require.Nil(t, err) + require.Equal(t, "shared@example.com", primary) + }) +} + +func TestUser_MagicLink_SetPrimary_NotVerified(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + require.ErrorIs(t, a.SetPrimaryEmail(phil.ID, "nope@example.com"), ErrEmailNotFound) + }) +} + +func TestUser_MagicLink_Expired(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + raw := addVerifyLink(t, a, phil.ID, "phil@example.com", -time.Minute) + _, err = a.VerifyEmail(raw) + require.ErrorIs(t, err, ErrMagicLinkNotFound) + + // Nothing got verified + emails, err := a.Emails(phil.ID) + require.Nil(t, err) + require.Equal(t, 0, len(emails)) + }) +} + +func TestUser_MagicLink_SingleUse(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + raw := addVerifyLink(t, a, phil.ID, "phil@example.com", 24*time.Hour) + _, err = a.VerifyEmail(raw) + require.Nil(t, err) + // Second click: token already consumed + _, err = a.VerifyEmail(raw) + require.ErrorIs(t, err, ErrMagicLinkNotFound) + }) +} + +func TestUser_MagicLink_ReplaceOnReRequest(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + raw1 := addVerifyLink(t, a, phil.ID, "phil@example.com", 24*time.Hour) + raw2 := addVerifyLink(t, a, phil.ID, "phil@example.com", 24*time.Hour) + + // Only one pending row remains; the old token no longer works + pending, err := a.PendingEmails(phil.ID) + require.Nil(t, err) + require.Equal(t, []string{"phil@example.com"}, pending) + _, err = a.MagicLinkByToken(raw1) + require.ErrorIs(t, err, ErrMagicLinkNotFound) + + m, err := a.MagicLinkByToken(raw2) + require.Nil(t, err) + require.Equal(t, "phil@example.com", m.Email) + }) +} + +func TestUser_MagicLink_PasswordReset_RoundTrip(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + raw, err := a.CreateMagicLink(MagicLinkKindPasswordReset, phil.ID, "", time.Hour) + require.Nil(t, err) + + m, err := a.MagicLinkByToken(raw) + require.Nil(t, err) + require.Equal(t, MagicLinkKindPasswordReset, m.Kind) + require.Equal(t, phil.ID, m.UserID) + require.Equal(t, "", m.Email) // reset rows carry no email + + // Reset rows do not appear as pending emails + pending, err := a.PendingEmails(phil.ID) + require.Nil(t, err) + require.Equal(t, 0, len(pending)) + + // New request replaces the old token + raw2, err := a.CreateMagicLink(MagicLinkKindPasswordReset, phil.ID, "", time.Hour) + require.Nil(t, err) + _, err = a.MagicLinkByToken(raw) + require.ErrorIs(t, err, ErrMagicLinkNotFound) + + // Single use: deleting consumes it + require.Nil(t, a.DeleteMagicLinkByToken(raw2)) + _, err = a.MagicLinkByToken(raw2) + require.ErrorIs(t, err, ErrMagicLinkNotFound) + }) +} + +func TestUser_MagicLink_Reaper(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + expired := addVerifyLink(t, a, phil.ID, "expired@example.com", -time.Hour) + valid := addVerifyLink(t, a, phil.ID, "valid@example.com", time.Hour) + + require.Nil(t, a.deleteExpiredMagicLinks()) + + _, err = a.MagicLinkByToken(expired) + require.ErrorIs(t, err, ErrMagicLinkNotFound) + m, err := a.MagicLinkByToken(valid) + require.Nil(t, err) + require.Equal(t, "valid@example.com", m.Email) + }) +} + +func TestUser_MagicLink_ResetPassword(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "oldpass", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + raw, err := a.CreateMagicLink(MagicLinkKindPasswordReset, phil.ID, "", time.Hour) + require.Nil(t, err) + + // Old password works before reset + _, err = a.Authenticate("phil", "oldpass") + require.Nil(t, err) + + require.Nil(t, a.ResetPassword(raw, "newpass")) + + // New password works, old does not + _, err = a.Authenticate("phil", "newpass") + require.Nil(t, err) + _, err = a.Authenticate("phil", "oldpass") + require.ErrorIs(t, err, ErrUnauthenticated) + + // Token is single-use + require.ErrorIs(t, a.ResetPassword(raw, "againpass"), ErrMagicLinkNotFound) + }) +} + +func TestUser_MagicLink_ResetPassword_WrongKindRejected(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "oldpass", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + // An email-verification token must not be usable for password reset... + verifyToken := addVerifyLink(t, a, phil.ID, "phil@example.com", time.Hour) + require.ErrorIs(t, a.ResetPassword(verifyToken, "newpass"), ErrMagicLinkNotFound) + + // ...and a reset token must not be usable for email verification + resetToken, err := a.CreateMagicLink(MagicLinkKindPasswordReset, phil.ID, "", time.Hour) + require.Nil(t, err) + _, err = a.VerifyEmail(resetToken) + require.ErrorIs(t, err, ErrMagicLinkNotFound) + + // Old password unchanged + _, err = a.Authenticate("phil", "oldpass") + require.Nil(t, err) + }) +} + +func TestUser_MagicLink_VerifyEmail_ProvisionedNoPrimary(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManagerFromConfig(t, newManager, &Config{ + DefaultAccess: PermissionDenyAll, + ProvisionEnabled: true, + Users: []*User{ + {Name: "prov", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser}, + }, + }) + prov, err := a.User("prov") + require.Nil(t, err) + + // A provisioned user can verify an email (for notifications), but it must NOT become primary + _, err = a.VerifyEmail(addVerifyLink(t, a, prov.ID, "prov@example.com", time.Hour)) + require.Nil(t, err) + + emails, err := a.Emails(prov.ID) + require.Nil(t, err) + require.Equal(t, []string{"prov@example.com"}, emails) + primary, err := a.PrimaryEmail(prov.ID) + require.Nil(t, err) + require.Equal(t, "", primary) + }) +} + +func TestUser_MagicLink_ResetPassword_ProvisionedRejected(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + // Provisioned users come from the config file (ProvisionEnabled), not AddUser + a := newTestManagerFromConfig(t, newManager, &Config{ + DefaultAccess: PermissionDenyAll, + ProvisionEnabled: true, + Users: []*User{ + {Name: "prov", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser}, + }, + }) + prov, err := a.User("prov") + require.Nil(t, err) + require.True(t, prov.Provisioned) + + // A reset token can be created, but consuming it must be rejected for a provisioned user + // (their password comes from the config file, like change-pass). + raw, err := a.CreateMagicLink(MagicLinkKindPasswordReset, prov.ID, "", time.Hour) + require.Nil(t, err) + require.ErrorIs(t, a.ResetPassword(raw, "newpass"), ErrProvisionedUserChange) + }) +} + +func TestUser_MagicLink_ResetPassword_Expired(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "oldpass", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + raw, err := a.CreateMagicLink(MagicLinkKindPasswordReset, phil.ID, "", -time.Minute) + require.Nil(t, err) + require.ErrorIs(t, a.ResetPassword(raw, "newpass"), ErrMagicLinkNotFound) + _, err = a.Authenticate("phil", "oldpass") + require.Nil(t, err) + }) +} + +func TestUser_MagicLink_UserIDByPrimaryEmail_NotFound(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + _, err := a.UserIDByPrimaryEmail("ghost@example.com") + require.ErrorIs(t, err, ErrUserNotFound) + }) +}