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
+55 -5
View File
@@ -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
}
+15 -2
View File
@@ -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
}
+110 -24
View File
@@ -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)
})
}