feat: add ability to force email verification (#1837)

This commit is contained in:
Elias Schneider
2026-10-13 22:04:33 +02:00
committed by GitHub
parent eb8ea6bed2
commit 0ffeb170cf
81 changed files with 2310 additions and 263 deletions
+133 -19
View File
@@ -21,6 +21,7 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/backchannellogout"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/emailverification"
"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/storage"
@@ -36,9 +37,24 @@ type UserService struct {
scimSyncScheduler ScimSyncScheduler
backchannelLogout *backchannellogout.Service
fileStorage storage.FileStorage
emailVerification EmailVerificationSender
}
func NewUserService(db *gorm.DB, jwtService *JwtService, customClaimService *CustomClaimService, appImagesService *AppImagesService, scimSyncScheduler ScimSyncScheduler, backchannelLogout *backchannellogout.Service, fileStorage storage.FileStorage) *UserService {
// EmailVerificationSender emails verification links for a user's current email or for an email they want to change to
type EmailVerificationSender interface {
Send(ctx context.Context, dbConfig *appconfig.AppConfigModel, userID string) error
RequestEmailChange(ctx context.Context, dbConfig *appconfig.AppConfigModel, userID, newEmail string) error
}
// ownEmailChange records how a user's change of their own email is handled once the update is committed
type ownEmailChange struct {
// applied is set when the new email replaced the current one right away
applied bool
// deferred holds the new email when it only replaces the current one after the user confirms it
deferred *string
}
func NewUserService(db *gorm.DB, jwtService *JwtService, customClaimService *CustomClaimService, appImagesService *AppImagesService, scimSyncScheduler ScimSyncScheduler, backchannelLogout *backchannellogout.Service, fileStorage storage.FileStorage, emailVerification EmailVerificationSender) *UserService {
return &UserService{
db: db,
jwtService: jwtService,
@@ -47,6 +63,7 @@ func NewUserService(db *gorm.DB, jwtService *JwtService, customClaimService *Cus
scimSyncScheduler: scimSyncScheduler,
backchannelLogout: backchannelLogout,
fileStorage: fileStorage,
emailVerification: emailVerification,
}
}
@@ -498,6 +515,16 @@ func (s *UserService) UpdateUser(ctx context.Context, cfg *appconfig.AppConfigMo
}
}
// A user's own email change may have to be confirmed first, so that's decided before the update is applied
var change ownEmailChange
if updateOwnUser {
var err error
change, err = s.prepareOwnEmailChange(ctx, cfg, userID, &updatedUser, isLdapSync, tx)
if err != nil {
return model.User{}, err
}
}
user, err := s.UpdateUserInternal(ctx, cfg, userID, updatedUser, updateOwnUser, isLdapSync, tx)
if err != nil {
return model.User{}, err
@@ -516,9 +543,85 @@ func (s *UserService) UpdateUser(ctx context.Context, cfg *appconfig.AppConfigMo
s.backchannelLogout.NotifyUser(ctx, userID)
}
// The verified email stays in effect until the user confirms the new address through the emailed link
// The other changes are already saved, so the error tells the user that only the email change could not be started
if change.deferred != nil {
err = s.emailVerification.RequestEmailChange(ctx, cfg, userID, *change.deferred)
if err != nil {
return user, apperror.EmailChangeNotStarted(err)
}
}
// A user whose new email still restricts the account needs the verification link right away
if change.applied && emailverification.IsPending(cfg, &user) {
err = s.emailVerification.Send(ctx, cfg, userID)
if err != nil {
slog.WarnContext(ctx, "Failed to send the email verification message after an email change", slog.String("userID", userID), slog.Any("error", err))
}
}
return user, nil
}
// prepareOwnEmailChange decides how a user's change of their own email is handled
// A deferred change keeps the current email in the update, so the new address only takes effect once the user confirms it
func (s *UserService) prepareOwnEmailChange(ctx context.Context, cfg *appconfig.AppConfigModel, userID string, updatedUser *dto.UserCreateDto, isLdapSync bool, tx *gorm.DB) (ownEmailChange, error) {
var change ownEmailChange
if s.emailVerification == nil || updatedUser.Email == nil {
return change, nil
}
// Lock the user, so the decision below still holds when the update is applied
var user model.User
err := tx.
WithContext(ctx).
Where("id = ?", userID).
Clauses(clause.Locking{Strength: "UPDATE"}).
First(&user).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return change, apperror.UserNotFound()
}
if err != nil {
return change, err
}
// The update ignores an email the user may not edit, so there's nothing to verify then
emailChanged := user.Email == nil || *user.Email != *updatedUser.Email
if !emailChanged || !canEditProfile(cfg, &user, true, isLdapSync) {
return change, nil
}
if !emailverification.DefersEmailChange(cfg, &user) {
change.applied = true
return change, nil
}
// The address is checked now, so the user doesn't wait for a link to find out it's taken
taken, err := isEmailTaken(ctx, tx, user.ID, *updatedUser.Email)
if err != nil {
return change, err
}
if taken {
return change, apperror.AlreadyInUse("email")
}
change.deferred = updatedUser.Email
updatedUser.Email = user.Email
return change, nil
}
// canEditProfile reports whether an update may change the user's personal fields, such as their name and email
// LDAP users are managed by the directory and users may be barred from editing their own account, while the LDAP sync itself can always update them
func canEditProfile(cfg *appconfig.AppConfigModel, user *model.User, updateOwnUser bool, isLdapSync bool) bool {
if isLdapSync {
return true
}
isLdapUser := user.LdapID != nil && cfg.LdapEnabled.IsTrue()
return !isLdapUser && (!updateOwnUser || cfg.AllowOwnAccountEdit.IsTrue())
}
// UpdateUserInternal updates a user within an existing transaction
// It's exported for the LDAP sync, which reconciles users and groups in a single transaction of its own
func (s *UserService) UpdateUserInternal(ctx context.Context, cfg *appconfig.AppConfigModel, userID string, updatedUser dto.UserCreateDto, updateOwnUser bool, isLdapSync bool, tx *gorm.DB) (model.User, error) {
@@ -540,15 +643,10 @@ func (s *UserService) UpdateUserInternal(ctx context.Context, cfg *appconfig.App
return model.User{}, err
}
// Check if this is an LDAP user and LDAP is enabled
isLdapUser := user.LdapID != nil && cfg.LdapEnabled.IsTrue()
allowOwnAccountEdit := cfg.AllowOwnAccountEdit.IsTrue()
if !isLdapSync && (isLdapUser || (!allowOwnAccountEdit && updateOwnUser)) {
if !canEditProfile(cfg, &user, updateOwnUser, isLdapSync) {
// Restricted update: Only locale can be changed when:
// - User is from LDAP, OR
// - User is editing their own account but global setting disallows self-editing
// (Exception: LDAP sync operations can update everything)
user.Locale = updatedUser.Locale
} else {
// Full update: Allow updating all personal fields
@@ -573,6 +671,11 @@ func (s *UserService) UpdateUserInternal(ctx context.Context, cfg *appconfig.App
}
}
// A verified account is no longer deleted for missing the signup verification deadline
if user.EmailVerified {
user.EmailVerificationDeadline = nil
}
user.UpdatedAt = new(datatype.DateTime(time.Now()))
err = tx.
@@ -669,22 +772,20 @@ func (s *UserService) UpdateUserGroups(ctx context.Context, id string, userGroup
}
func (s *UserService) checkDuplicatedFields(ctx context.Context, user model.User, tx *gorm.DB) error {
if user.Email != nil {
taken, err := isEmailTaken(ctx, tx, user.ID, *user.Email)
if err != nil {
return err
}
if taken {
return apperror.AlreadyInUse("email")
}
}
var result struct {
Found bool
}
err := tx.
WithContext(ctx).
Raw(`SELECT EXISTS(SELECT 1 FROM users WHERE id != ? AND email = ?) AS found`, user.ID, user.Email).
First(&result).
Error
if err != nil {
return err
}
if result.Found {
return apperror.AlreadyInUse("email")
}
err = tx.
WithContext(ctx).
Raw(`SELECT EXISTS(SELECT 1 FROM users WHERE id != ? AND username = ?) AS found`, user.ID, user.Username).
First(&result).
@@ -740,3 +841,16 @@ func (s *UserService) DisableUserInternal(ctx context.Context, tx *gorm.DB, user
return nil
}
// isEmailTaken reports whether a user other than the given one already uses the email
func isEmailTaken(ctx context.Context, tx *gorm.DB, userID, email string) (bool, error) {
var result struct {
Found bool
}
err := tx.
WithContext(ctx).
Raw(`SELECT EXISTS(SELECT 1 FROM users WHERE id != ? AND email = ?) AS found`, userID, email).
First(&result).
Error
return result.Found, err
}
@@ -1,7 +1,9 @@
package service
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
@@ -34,6 +36,7 @@ func newTestUserService(t *testing.T) (*UserService, *UserGroupService) {
nil,
nil,
fileStorage,
nil,
)
groupService := NewUserGroupService(db, nil, nil)
@@ -204,3 +207,174 @@ func TestDeleteUserBumpsGroupUpdatedAt(t *testing.T) {
require.Empty(t, updated.Users)
require.True(t, updated.LastModified().After(past), "deleting a member must bump the group's UpdatedAt")
}
type recordingEmailVerificationSender struct {
sent []string
requested []string
requestErr error
}
func (r *recordingEmailVerificationSender) Send(_ context.Context, _ *appconfig.AppConfigModel, userID string) error {
r.sent = append(r.sent, userID)
return nil
}
func (r *recordingEmailVerificationSender) RequestEmailChange(_ context.Context, _ *appconfig.AppConfigModel, _, newEmail string) error {
r.requested = append(r.requested, newEmail)
return r.requestErr
}
func TestUpdateOwnEmailWithVerification(t *testing.T) {
// setup creates a user with the given verification state and an update that changes only their email
setup := func(t *testing.T, mode string, emailVerified bool) (*UserService, *recordingEmailVerificationSender, *appconfig.AppConfigModel, model.User, dto.UserCreateDto) {
t.Helper()
config := appconfig.NewTestConfig(nil)
config.EmailVerification = appconfig.AppConfigValue(mode)
userService, _ := newTestUserService(t)
sender := &recordingEmailVerificationSender{}
userService.emailVerification = sender
user, err := userService.CreateUser(t.Context(), config, dto.UserCreateDto{
Username: "changer",
Email: new("old@example.com"),
EmailVerified: emailVerified,
FirstName: "Email",
LastName: "Changer",
})
require.NoError(t, err)
update := dto.UserCreateDto{
Username: user.Username,
Email: new("new@example.com"),
FirstName: user.FirstName,
LastName: user.LastName,
}
return userService, sender, config, user, update
}
t.Run("a verified email stays in effect until the new address is confirmed", func(t *testing.T) {
userService, sender, config, user, update := setup(t, appconfig.EmailVerificationRequired, true)
updated, err := userService.UpdateUser(t.Context(), config, user.ID, update, true, false)
require.NoError(t, err)
require.Equal(t, "old@example.com", *updated.Email)
require.True(t, updated.EmailVerified)
require.Equal(t, []string{"new@example.com"}, sender.requested)
require.Empty(t, sender.sent)
})
t.Run("a deferred change to an address another user has is rejected right away", func(t *testing.T) {
userService, sender, config, user, update := setup(t, appconfig.EmailVerificationRequired, true)
_, err := userService.CreateUser(t.Context(), config, dto.UserCreateDto{Username: "other", Email: new("new@example.com"), FirstName: "Other"})
require.NoError(t, err)
_, err = userService.UpdateUser(t.Context(), config, user.ID, update, true, false)
require.True(t, apperror.IsCode(err, apperror.CodeAlreadyInUse))
require.Empty(t, sender.requested)
})
t.Run("an unverified email is replaced right away and the new one has to be verified", func(t *testing.T) {
userService, sender, config, user, update := setup(t, appconfig.EmailVerificationRequired, false)
updated, err := userService.UpdateUser(t.Context(), config, user.ID, update, true, false)
require.NoError(t, err)
require.Equal(t, "new@example.com", *updated.Email)
require.False(t, updated.EmailVerified)
require.Equal(t, []string{user.ID}, sender.sent)
require.Empty(t, sender.requested)
})
t.Run("the other fields are saved while the email change waits for confirmation", func(t *testing.T) {
userService, _, config, user, update := setup(t, appconfig.EmailVerificationRequired, true)
update.FirstName = "Renamed"
updated, err := userService.UpdateUser(t.Context(), config, user.ID, update, true, false)
require.NoError(t, err)
require.Equal(t, "Renamed", updated.FirstName)
require.Equal(t, "old@example.com", *updated.Email)
})
t.Run("a change link that can't be sent is reported apart from the saved fields", func(t *testing.T) {
userService, sender, config, user, update := setup(t, appconfig.EmailVerificationRequired, true)
sender.requestErr = errors.New("smtp unavailable")
update.FirstName = "Renamed"
updated, err := userService.UpdateUser(t.Context(), config, user.ID, update, true, false)
require.True(t, apperror.IsCode(err, apperror.CodeEmailChangeNotStarted))
require.Equal(t, "Renamed", updated.FirstName)
stored, err := userService.GetUser(t.Context(), user.ID)
require.NoError(t, err)
require.Equal(t, "Renamed", stored.FirstName)
require.Equal(t, "old@example.com", *stored.Email)
})
t.Run("no change link is sent when the user may not edit their own account", func(t *testing.T) {
userService, sender, config, user, update := setup(t, appconfig.EmailVerificationRequired, true)
config.AllowOwnAccountEdit = "false"
updated, err := userService.UpdateUser(t.Context(), config, user.ID, update, true, false)
require.NoError(t, err)
require.Equal(t, "old@example.com", *updated.Email)
require.Empty(t, sender.requested)
require.Empty(t, sender.sent)
})
t.Run("an admin changing another user's email replaces it right away", func(t *testing.T) {
userService, sender, config, user, update := setup(t, appconfig.EmailVerificationRequired, true)
update.EmailVerified = true
updated, err := userService.UpdateUser(t.Context(), config, user.ID, update, false, false)
require.NoError(t, err)
require.Equal(t, "new@example.com", *updated.Email)
require.Empty(t, sender.requested)
require.Empty(t, sender.sent)
})
t.Run("the email is replaced right away when verification is optional", func(t *testing.T) {
userService, sender, config, user, update := setup(t, appconfig.EmailVerificationOptional, true)
updated, err := userService.UpdateUser(t.Context(), config, user.ID, update, true, false)
require.NoError(t, err)
require.Equal(t, "new@example.com", *updated.Email)
require.False(t, updated.EmailVerified)
require.Empty(t, sender.sent)
require.Empty(t, sender.requested)
})
}
func TestMarkingEmailVerifiedClearsSignupDeadline(t *testing.T) {
config := appconfig.NewTestConfig(nil)
userService, _ := newTestUserService(t)
user, err := userService.CreateUser(t.Context(), config, dto.UserCreateDto{
Username: "pending",
Email: new("pending@example.com"),
FirstName: "Pending",
LastName: "User",
})
require.NoError(t, err)
deadline := datatype.DateTime(time.Now().Add(time.Hour))
require.NoError(t, userService.db.Model(&model.User{}).Where("id = ?", user.ID).Update("email_verification_deadline", deadline).Error)
updated, err := userService.UpdateUser(t.Context(), config, user.ID, dto.UserCreateDto{
Username: user.Username,
Email: user.Email,
EmailVerified: true,
FirstName: user.FirstName,
LastName: user.LastName,
}, false, false)
require.NoError(t, err)
require.True(t, updated.EmailVerified)
stored, err := userService.GetUser(t.Context(), user.ID)
require.NoError(t, err)
require.Nil(t, stored.EmailVerificationDeadline)
}