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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user