mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-15 06:09:07 +02:00
feat: add ability to force email verification (#1837)
This commit is contained in:
@@ -17,10 +17,12 @@ const ActorType = "EmailVerification"
|
||||
|
||||
const (
|
||||
// MethodIssue replaces the outstanding verification state for a user
|
||||
MethodIssue = "issue"
|
||||
methodConsume = "consume"
|
||||
methodDiscard = "discard"
|
||||
methodRestore = "restore"
|
||||
MethodIssue = "issue"
|
||||
methodConsume = "consume"
|
||||
methodDiscard = "discard"
|
||||
methodRestore = "restore"
|
||||
methodCancelChange = "cancelChange"
|
||||
methodPendingChange = "pendingChange"
|
||||
)
|
||||
|
||||
type consumeStatus string
|
||||
@@ -32,7 +34,14 @@ const (
|
||||
|
||||
// State is the persisted verification state for one user
|
||||
type State struct {
|
||||
TokenHash string
|
||||
TokenHash string
|
||||
Email string
|
||||
ReplacesEmail string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// EmailChange describes an email change that is waiting for the user to confirm it
|
||||
type EmailChange struct {
|
||||
Email string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
@@ -68,11 +77,35 @@ func (a *emailVerificationActor) Invoke(ctx context.Context, method string, data
|
||||
return nil, a.discard(ctx, data)
|
||||
case methodRestore:
|
||||
return nil, a.restore(ctx, data)
|
||||
case methodCancelChange:
|
||||
return nil, a.cancelChange(ctx)
|
||||
default:
|
||||
return nil, common.ErrUnsupportedActorMethod{Method: method}
|
||||
}
|
||||
}
|
||||
|
||||
// Peek implements actor.ActorPeek
|
||||
func (a *emailVerificationActor) Peek(ctx context.Context, method string, _ actor.Envelope) (any, error) {
|
||||
if method != methodPendingChange {
|
||||
return nil, common.ErrUnsupportedActorMethod{Method: method}
|
||||
}
|
||||
|
||||
state, err := a.client.GetState(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error retrieving actor state: %w", err)
|
||||
}
|
||||
|
||||
// Only an unexpired email change is pending, a token for the current email is not
|
||||
if state.ReplacesEmail == "" || state.ExpiresAt.Before(time.Now()) {
|
||||
return EmailChange{}, nil
|
||||
}
|
||||
|
||||
return EmailChange{
|
||||
Email: state.Email,
|
||||
ExpiresAt: state.ExpiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *emailVerificationActor) issue(ctx context.Context, data actor.Envelope) error {
|
||||
state, err := decodeState(data, MethodIssue)
|
||||
if err != nil {
|
||||
@@ -134,6 +167,23 @@ func (a *emailVerificationActor) discard(ctx context.Context, data actor.Envelop
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *emailVerificationActor) cancelChange(ctx context.Context) error {
|
||||
state, err := a.client.GetState(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error retrieving actor state: %w", err)
|
||||
}
|
||||
if state.ReplacesEmail == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
err = a.client.DeleteState(ctx)
|
||||
if err != nil && !errors.Is(err, actor.ErrStateNotFound) {
|
||||
return fmt.Errorf("error deleting actor state: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *emailVerificationActor) restore(ctx context.Context, data actor.Envelope) error {
|
||||
state, err := decodeState(data, methodRestore)
|
||||
if err != nil {
|
||||
|
||||
@@ -10,8 +10,15 @@ import (
|
||||
"github.com/pocket-id/pocket-id/backend/internal/authz"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
)
|
||||
|
||||
// emailChangeDto describes an email change that waits for the user to confirm the new address
|
||||
type emailChangeDto struct {
|
||||
Email string `json:"email"`
|
||||
ExpiresAt datatype.DateTime `json:"expiresAt"`
|
||||
}
|
||||
|
||||
type handler struct {
|
||||
service *Service
|
||||
appConfig appconfig.AppConfigResolver
|
||||
@@ -64,3 +71,44 @@ func (h *handler) verify(c *gin.Context) error {
|
||||
c.Status(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
// pendingEmailChange godoc
|
||||
// @Summary Get pending email change
|
||||
// @Description Get the email change of the currently authenticated user that still has to be confirmed through the emailed link
|
||||
// @Tags Users
|
||||
// @Produce json
|
||||
// @Success 200 {object} emailChangeDto
|
||||
// @Success 204 "No pending email change"
|
||||
// @Router /api/users/me/email-change [get]
|
||||
func (h *handler) pendingEmailChange(c *gin.Context) error {
|
||||
pending, err := h.service.PendingEmailChange(c.Request.Context(), authz.PrincipalFrom(c).UserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if pending == nil {
|
||||
c.Status(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, emailChangeDto{
|
||||
Email: pending.Email,
|
||||
ExpiresAt: datatype.DateTime(pending.ExpiresAt),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// cancelEmailChange godoc
|
||||
// @Summary Cancel pending email change
|
||||
// @Description Cancel the pending email change of the currently authenticated user, so its verification link stops working
|
||||
// @Tags Users
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/users/me/email-change [delete]
|
||||
func (h *handler) cancelEmailChange(c *gin.Context) error {
|
||||
err := h.service.CancelEmailChange(c.Request.Context(), authz.PrincipalFrom(c).UserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package emailverification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -16,8 +17,8 @@ type Dependencies struct {
|
||||
DB *gorm.DB
|
||||
Actors francishost.Host
|
||||
|
||||
Users UserProvider
|
||||
EmailSender EmailSender
|
||||
ScimSync ScimSyncScheduler
|
||||
AppConfig appconfig.AppConfigResolver
|
||||
AppURL string
|
||||
}
|
||||
@@ -33,15 +34,27 @@ func New(deps Dependencies) (*Module, error) {
|
||||
return nil, fmt.Errorf("error registering the %s actor: %w", ActorType, err)
|
||||
}
|
||||
|
||||
service := newService(deps.DB, deps.Actors.Service(), deps.Users, deps.EmailSender, deps.AppURL)
|
||||
service := newService(deps.DB, deps.Actors.Service(), deps.EmailSender, deps.ScimSync, deps.AppURL)
|
||||
return &Module{
|
||||
service: service,
|
||||
handler: newHandler(service, deps.AppConfig),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Send issues a new verification token for the user's current email and emails the verification link
|
||||
func (m *Module) Send(ctx context.Context, dbConfig *appconfig.AppConfigModel, userID string) error {
|
||||
return m.service.Send(ctx, dbConfig, userID)
|
||||
}
|
||||
|
||||
// RequestEmailChange emails a verification link to a new address, which replaces the user's current email once the link is used
|
||||
func (m *Module) RequestEmailChange(ctx context.Context, dbConfig *appconfig.AppConfigModel, userID, newEmail string) error {
|
||||
return m.service.RequestEmailChange(ctx, dbConfig, userID, newEmail)
|
||||
}
|
||||
|
||||
// RegisterRoutes mounts the email verification endpoints
|
||||
func (m *Module) RegisterRoutes(r *authz.Router, sendRateLimit, verifyRateLimit gin.HandlerFunc) {
|
||||
r.POST("/users/me/send-email-verification", authz.AccountWrite, sendRateLimit, httpserver.Handle(m.handler.send))
|
||||
r.POST("/users/me/verify-email", authz.AccountWrite, verifyRateLimit, httpserver.Handle(m.handler.verify))
|
||||
r.GET("/users/me/email-change", authz.AccountRead, httpserver.Handle(m.handler.pendingEmailChange))
|
||||
r.DELETE("/users/me/email-change", authz.AccountWrite, httpserver.Handle(m.handler.cancelEmailChange))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package emailverification
|
||||
|
||||
import (
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
)
|
||||
|
||||
// IsPending reports whether the user must verify their email before they can use their account
|
||||
func IsPending(cfg *appconfig.AppConfigModel, user *model.User) bool {
|
||||
return cfg.EmailVerification.String() == appconfig.EmailVerificationRequired && CanBePending(user)
|
||||
}
|
||||
|
||||
// CanBePending reports whether the user would have to verify their email if verification were required
|
||||
func CanBePending(user *model.User) bool {
|
||||
return !user.IsAdmin && !user.EmailVerified
|
||||
}
|
||||
|
||||
// DefersEmailChange reports whether the user's own email change has to be confirmed before it replaces their current email
|
||||
func DefersEmailChange(cfg *appconfig.AppConfigModel, user *model.User) bool {
|
||||
return cfg.EmailVerification.String() == appconfig.EmailVerificationRequired && !user.IsAdmin && user.EmailVerified && user.Email != nil
|
||||
}
|
||||
@@ -17,36 +17,52 @@ import (
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
const tokenLifetime = 24 * time.Hour
|
||||
|
||||
type UserProvider interface {
|
||||
GetUser(ctx context.Context, userID string) (model.User, error)
|
||||
}
|
||||
// TokenLifetime is how long a verification link stays valid
|
||||
const TokenLifetime = 24 * time.Hour
|
||||
|
||||
type EmailSender interface {
|
||||
SendEmailVerification(ctx context.Context, dbConfig *appconfig.AppConfigModel, userFullName, userEmail, verificationLink string) error
|
||||
}
|
||||
|
||||
// ScimSyncScheduler schedules a SCIM sync after a confirmed email change replaced a user's address
|
||||
type ScimSyncScheduler interface {
|
||||
ScheduleSync(ctx context.Context)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
actors *actor.Service
|
||||
users UserProvider
|
||||
emailSender EmailSender
|
||||
scimSync ScimSyncScheduler
|
||||
appURL string
|
||||
}
|
||||
|
||||
func newService(db *gorm.DB, actors *actor.Service, users UserProvider, emailSender EmailSender, appURL string) *Service {
|
||||
func newService(db *gorm.DB, actors *actor.Service, emailSender EmailSender, scimSync ScimSyncScheduler, appURL string) *Service {
|
||||
return &Service{
|
||||
db: db,
|
||||
actors: actors,
|
||||
users: users,
|
||||
emailSender: emailSender,
|
||||
scimSync: scimSync,
|
||||
appURL: appURL,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) loadUser(ctx context.Context, userID string) (model.User, error) {
|
||||
var user model.User
|
||||
err := s.db.
|
||||
WithContext(ctx).
|
||||
Where("id = ?", userID).
|
||||
First(&user).
|
||||
Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return model.User{}, apperror.UserNotFound()
|
||||
}
|
||||
return user, err
|
||||
}
|
||||
|
||||
// Send emails a verification link for the user's current email
|
||||
func (s *Service) Send(ctx context.Context, dbConfig *appconfig.AppConfigModel, userID string) error {
|
||||
user, err := s.users.GetUser(ctx, userID)
|
||||
user, err := s.loadUser(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -54,28 +70,76 @@ func (s *Service) Send(ctx context.Context, dbConfig *appconfig.AppConfigModel,
|
||||
return apperror.UserEmailNotSet()
|
||||
}
|
||||
|
||||
return s.issue(ctx, dbConfig, user, State{Email: *user.Email})
|
||||
}
|
||||
|
||||
// RequestEmailChange emails a verification link to a new address, which replaces the user's current email once the link is used
|
||||
// Until then the current email stays in effect, so a mistyped address never restricts the account
|
||||
func (s *Service) RequestEmailChange(ctx context.Context, dbConfig *appconfig.AppConfigModel, userID, newEmail string) error {
|
||||
user, err := s.loadUser(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if user.Email == nil {
|
||||
return apperror.UserEmailNotSet()
|
||||
}
|
||||
|
||||
return s.issue(ctx, dbConfig, user, State{Email: newEmail, ReplacesEmail: *user.Email})
|
||||
}
|
||||
|
||||
// PendingEmailChange returns the email change the user still has to confirm, or nil if there is none
|
||||
func (s *Service) PendingEmailChange(ctx context.Context, userID string) (*EmailChange, error) {
|
||||
response, err := s.actors.Peek(ctx, ActorType, userID, methodPendingChange, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading pending email change: %w", err)
|
||||
}
|
||||
if response == nil {
|
||||
return nil, errors.New("email verification actor returned an empty response")
|
||||
}
|
||||
|
||||
var pending EmailChange
|
||||
err = response.Decode(&pending)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error decoding pending email change: %w", err)
|
||||
}
|
||||
if pending.Email == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &pending, nil
|
||||
}
|
||||
|
||||
// CancelEmailChange drops a pending email change, so its verification link stops working
|
||||
func (s *Service) CancelEmailChange(ctx context.Context, userID string) error {
|
||||
_, err := s.actors.Invoke(ctx, ActorType, userID, methodCancelChange, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error canceling email change: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// issue stores a new verification token for the state's email and emails the verification link to that address
|
||||
// Issuing replaces any outstanding token, so only the most recent link works
|
||||
func (s *Service) issue(ctx context.Context, dbConfig *appconfig.AppConfigModel, user model.User, state State) error {
|
||||
token, err := utils.GenerateRandomAlphanumericString(32)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Persist the token hash in the email verification actor for this user
|
||||
state := State{
|
||||
TokenHash: utils.CreateSha256Hash(token),
|
||||
Email: *user.Email,
|
||||
ExpiresAt: time.Now().Add(tokenLifetime),
|
||||
}
|
||||
state.TokenHash = utils.CreateSha256Hash(token)
|
||||
state.ExpiresAt = time.Now().Add(TokenLifetime)
|
||||
_, err = s.actors.Invoke(ctx, ActorType, user.ID, MethodIssue, state)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error issuing email verification token: %w", err)
|
||||
}
|
||||
|
||||
// Send the email verification message to the user
|
||||
// Send the email verification message to the address being verified
|
||||
err = s.emailSender.SendEmailVerification(
|
||||
ctx,
|
||||
dbConfig,
|
||||
user.FullName(),
|
||||
*user.Email,
|
||||
state.Email,
|
||||
s.appURL+"/verify-email?token="+token,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -108,16 +172,33 @@ func (s *Service) Verify(ctx context.Context, userID, token string) error {
|
||||
return apperror.InvalidEmailVerificationToken()
|
||||
}
|
||||
|
||||
// Update the user's email_verified field in the database
|
||||
// We are querying by both user ID and email to ensure that the email has not changed since the token was issued
|
||||
update := s.db.
|
||||
// Mark the email as verified
|
||||
// A verified account is no longer deleted for missing the signup verification deadline
|
||||
updates := map[string]any{
|
||||
"email_verified": true,
|
||||
"email_verification_deadline": nil,
|
||||
"updated_at": new(datatype.DateTime(time.Now())),
|
||||
}
|
||||
query := s.db.
|
||||
WithContext(ctx).
|
||||
Model(&model.User{}).
|
||||
Where("id = ? AND email = ?", userID, result.State.Email).
|
||||
Updates(map[string]any{
|
||||
"email_verified": true,
|
||||
"updated_at": new(datatype.DateTime(time.Now())),
|
||||
})
|
||||
Where("id = ?", userID)
|
||||
|
||||
// A token for an email change also moves the new address into place, unless the email was changed another way in the meantime
|
||||
// A token for the current email only applies while the email is still the one the token was issued for
|
||||
isEmailChange := result.State.ReplacesEmail != ""
|
||||
if isEmailChange {
|
||||
updates["email"] = result.State.Email
|
||||
query = query.Where("email = ?", result.State.ReplacesEmail)
|
||||
} else {
|
||||
query = query.Where("email = ?", result.State.Email)
|
||||
}
|
||||
|
||||
update := query.Updates(updates)
|
||||
if errors.Is(update.Error, gorm.ErrDuplicatedKey) {
|
||||
// Another account took the new address while the change was waiting for confirmation
|
||||
return apperror.AlreadyInUse("email")
|
||||
}
|
||||
if update.Error != nil {
|
||||
// If the database update fails, restore the token in the actor to allow the user to retry verification
|
||||
s.restoreAfterDatabaseFailure(ctx, userID, result.State)
|
||||
@@ -127,6 +208,11 @@ func (s *Service) Verify(ctx context.Context, userID, token string) error {
|
||||
return apperror.InvalidEmailVerificationToken()
|
||||
}
|
||||
|
||||
// Service providers have to learn about the new address
|
||||
if isEmailChange && s.scimSync != nil {
|
||||
s.scimSync.ScheduleSync(ctx)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -15,20 +15,11 @@ import (
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
)
|
||||
|
||||
type testUserProvider struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func (p testUserProvider) GetUser(ctx context.Context, userID string) (model.User, error) {
|
||||
var user model.User
|
||||
err := p.db.WithContext(ctx).Where("id = ?", userID).First(&user).Error
|
||||
return user, err
|
||||
}
|
||||
|
||||
type testEmailSender struct {
|
||||
err error
|
||||
sent []sentVerificationEmail
|
||||
@@ -61,7 +52,7 @@ func newServiceForTest(t *testing.T, emailSender *testEmailSender) (*Service, *l
|
||||
var service *Service
|
||||
host := testutils.NewActorHostForTest(t, func(t *testing.T, host *local.Host) {
|
||||
require.NoError(t, host.RegisterActor(ActorType, NewActor))
|
||||
service = newService(db, host.Service(), testUserProvider{db: db}, emailSender, "https://id.example.test")
|
||||
service = newService(db, host.Service(), emailSender, nil, "https://id.example.test")
|
||||
})
|
||||
require.NotNil(t, service)
|
||||
|
||||
@@ -129,6 +120,22 @@ func TestVerifyConsumesTokenAndMarksBoundAddressVerified(t *testing.T) {
|
||||
require.ErrorIs(t, host.GetState(t.Context(), ActorType, user.ID, &state), actor.ErrStateNotFound)
|
||||
}
|
||||
|
||||
func TestVerifyClearsSignupDeadline(t *testing.T) {
|
||||
emailSender := &testEmailSender{}
|
||||
service, _, db := newServiceForTest(t, emailSender)
|
||||
user := createTestUser(t, db, "user-deadline", "user@example.test")
|
||||
require.NoError(t, db.Model(&user).Update("email_verification_deadline", datatype.DateTime(time.Now().Add(time.Hour))).Error)
|
||||
|
||||
require.NoError(t, service.Send(t.Context(), &appconfig.AppConfigModel{}, user.ID))
|
||||
token := verificationTokenFromEmail(t, emailSender.sent[0])
|
||||
require.NoError(t, service.Verify(t.Context(), user.ID, token))
|
||||
|
||||
var updated model.User
|
||||
require.NoError(t, db.Where("id = ?", user.ID).First(&updated).Error)
|
||||
require.True(t, updated.EmailVerified)
|
||||
require.Nil(t, updated.EmailVerificationDeadline)
|
||||
}
|
||||
|
||||
func TestVerifyRejectsTokenAfterAddressChanges(t *testing.T) {
|
||||
emailSender := &testEmailSender{}
|
||||
service, host, db := newServiceForTest(t, emailSender)
|
||||
@@ -255,3 +262,107 @@ func TestSendDiscardsTokenWhenEmailDeliveryFails(t *testing.T) {
|
||||
var state State
|
||||
require.ErrorIs(t, host.GetState(t.Context(), ActorType, user.ID, &state), actor.ErrStateNotFound)
|
||||
}
|
||||
|
||||
func TestRequestEmailChangeSendsLinkToNewAddressAndKeepsCurrentEmail(t *testing.T) {
|
||||
emailSender := &testEmailSender{}
|
||||
service, _, db := newServiceForTest(t, emailSender)
|
||||
user := createTestUser(t, db, "user-change", "old@example.test")
|
||||
require.NoError(t, db.Model(&user).Update("email_verified", true).Error)
|
||||
|
||||
require.NoError(t, service.RequestEmailChange(t.Context(), &appconfig.AppConfigModel{}, user.ID, "new@example.test"))
|
||||
require.Len(t, emailSender.sent, 1)
|
||||
require.Equal(t, "new@example.test", emailSender.sent[0].userEmail)
|
||||
|
||||
pending, err := service.PendingEmailChange(t.Context(), user.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, pending)
|
||||
require.Equal(t, "new@example.test", pending.Email)
|
||||
|
||||
var unchanged model.User
|
||||
require.NoError(t, db.Where("id = ?", user.ID).First(&unchanged).Error)
|
||||
require.Equal(t, "old@example.test", *unchanged.Email)
|
||||
require.True(t, unchanged.EmailVerified)
|
||||
}
|
||||
|
||||
func TestVerifyEmailChangeReplacesEmail(t *testing.T) {
|
||||
emailSender := &testEmailSender{}
|
||||
service, _, db := newServiceForTest(t, emailSender)
|
||||
user := createTestUser(t, db, "user-confirm", "old@example.test")
|
||||
|
||||
require.NoError(t, service.RequestEmailChange(t.Context(), &appconfig.AppConfigModel{}, user.ID, "new@example.test"))
|
||||
token := verificationTokenFromEmail(t, emailSender.sent[0])
|
||||
require.NoError(t, service.Verify(t.Context(), user.ID, token))
|
||||
|
||||
var updated model.User
|
||||
require.NoError(t, db.Where("id = ?", user.ID).First(&updated).Error)
|
||||
require.Equal(t, "new@example.test", *updated.Email)
|
||||
require.True(t, updated.EmailVerified)
|
||||
|
||||
pending, err := service.PendingEmailChange(t.Context(), user.ID)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, pending)
|
||||
}
|
||||
|
||||
func TestVerifyEmailChangeRejectsTokenAfterEmailChangedElsewhere(t *testing.T) {
|
||||
emailSender := &testEmailSender{}
|
||||
service, _, db := newServiceForTest(t, emailSender)
|
||||
user := createTestUser(t, db, "user-admin-changed", "old@example.test")
|
||||
|
||||
require.NoError(t, service.RequestEmailChange(t.Context(), &appconfig.AppConfigModel{}, user.ID, "new@example.test"))
|
||||
token := verificationTokenFromEmail(t, emailSender.sent[0])
|
||||
|
||||
// An admin sets a different email before the user confirms their change, and that one must win
|
||||
require.NoError(t, db.Model(&user).Update("email", "admin-set@example.test").Error)
|
||||
|
||||
err := service.Verify(t.Context(), user.ID, token)
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeEmailVerificationTokenInvalid))
|
||||
|
||||
var updated model.User
|
||||
require.NoError(t, db.Where("id = ?", user.ID).First(&updated).Error)
|
||||
require.Equal(t, "admin-set@example.test", *updated.Email)
|
||||
}
|
||||
|
||||
func TestVerifyEmailChangeRejectsAddressTakenInTheMeantime(t *testing.T) {
|
||||
emailSender := &testEmailSender{}
|
||||
service, _, db := newServiceForTest(t, emailSender)
|
||||
user := createTestUser(t, db, "user-race", "old@example.test")
|
||||
|
||||
require.NoError(t, service.RequestEmailChange(t.Context(), &appconfig.AppConfigModel{}, user.ID, "new@example.test"))
|
||||
token := verificationTokenFromEmail(t, emailSender.sent[0])
|
||||
createTestUser(t, db, "user-taker", "new@example.test")
|
||||
|
||||
err := service.Verify(t.Context(), user.ID, token)
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeAlreadyInUse))
|
||||
|
||||
var unchanged model.User
|
||||
require.NoError(t, db.Where("id = ?", user.ID).First(&unchanged).Error)
|
||||
require.Equal(t, "old@example.test", *unchanged.Email)
|
||||
}
|
||||
|
||||
func TestCancelEmailChangeOnlyDropsPendingChange(t *testing.T) {
|
||||
emailSender := &testEmailSender{}
|
||||
service, host, db := newServiceForTest(t, emailSender)
|
||||
user := createTestUser(t, db, "user-cancel", "old@example.test")
|
||||
|
||||
t.Run("cancels a pending change and invalidates its link", func(t *testing.T) {
|
||||
require.NoError(t, service.RequestEmailChange(t.Context(), &appconfig.AppConfigModel{}, user.ID, "new@example.test"))
|
||||
token := verificationTokenFromEmail(t, emailSender.sent[len(emailSender.sent)-1])
|
||||
|
||||
require.NoError(t, service.CancelEmailChange(t.Context(), user.ID))
|
||||
|
||||
pending, err := service.PendingEmailChange(t.Context(), user.ID)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, pending)
|
||||
require.True(t, apperror.IsCode(service.Verify(t.Context(), user.ID, token), apperror.CodeEmailVerificationTokenInvalid))
|
||||
})
|
||||
|
||||
t.Run("keeps a verification of the current email", func(t *testing.T) {
|
||||
require.NoError(t, service.Send(t.Context(), &appconfig.AppConfigModel{}, user.ID))
|
||||
|
||||
require.NoError(t, service.CancelEmailChange(t.Context(), user.ID))
|
||||
|
||||
var state State
|
||||
require.NoError(t, host.GetState(t.Context(), ActorType, user.ID, &state))
|
||||
require.Equal(t, "old@example.test", state.Email)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user