mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-14 13:49:03 +02:00
238 lines
7.7 KiB
Go
238 lines
7.7 KiB
Go
package emailverification
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/italypaleale/francis/actor"
|
|
"gorm.io/gorm"
|
|
|
|
"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"
|
|
)
|
|
|
|
// 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
|
|
emailSender EmailSender
|
|
scimSync ScimSyncScheduler
|
|
appURL string
|
|
}
|
|
|
|
func newService(db *gorm.DB, actors *actor.Service, emailSender EmailSender, scimSync ScimSyncScheduler, appURL string) *Service {
|
|
return &Service{
|
|
db: db,
|
|
actors: actors,
|
|
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.loadUser(ctx, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if user.Email == nil {
|
|
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.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 address being verified
|
|
err = s.emailSender.SendEmailVerification(
|
|
ctx,
|
|
dbConfig,
|
|
user.FullName(),
|
|
state.Email,
|
|
s.appURL+"/verify-email?token="+token,
|
|
)
|
|
if err != nil {
|
|
// If the email delivery fails, discard the token in the actor to avoid leaving a valid token in the system
|
|
s.discardAfterSendFailure(ctx, user.ID, state.TokenHash)
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) Verify(ctx context.Context, userID, token string) error {
|
|
// Consume the token in the email verification actor for this user
|
|
response, err := s.actors.Invoke(ctx, ActorType, userID, methodConsume, tokenRequest{
|
|
TokenHash: utils.CreateSha256Hash(token),
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("error consuming email verification token: %w", err)
|
|
}
|
|
|
|
var result consumeResponse
|
|
if response == nil {
|
|
return errors.New("email verification actor returned an empty response")
|
|
}
|
|
err = response.Decode(&result)
|
|
if err != nil {
|
|
return fmt.Errorf("error decoding email verification actor response: %w", err)
|
|
}
|
|
if result.Status != consumeOK {
|
|
return apperror.InvalidEmailVerificationToken()
|
|
}
|
|
|
|
// 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 = ?", 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)
|
|
return update.Error
|
|
}
|
|
if update.RowsAffected != 1 {
|
|
return apperror.InvalidEmailVerificationToken()
|
|
}
|
|
|
|
// Service providers have to learn about the new address
|
|
if isEmailChange && s.scimSync != nil {
|
|
s.scimSync.ScheduleSync(ctx)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) discardAfterSendFailure(parentCtx context.Context, userID, tokenHash string) {
|
|
ctx, cancel := context.WithTimeout(context.WithoutCancel(parentCtx), 10*time.Second)
|
|
defer cancel()
|
|
|
|
_, err := s.actors.Invoke(ctx, ActorType, userID, methodDiscard, tokenRequest{TokenHash: tokenHash})
|
|
if err != nil {
|
|
slog.ErrorContext(ctx, "Failed to discard email verification token after email delivery failed", slog.Any("error", err))
|
|
}
|
|
}
|
|
|
|
func (s *Service) restoreAfterDatabaseFailure(parentCtx context.Context, userID string, state State) {
|
|
ctx, cancel := context.WithTimeout(context.WithoutCancel(parentCtx), 10*time.Second)
|
|
defer cancel()
|
|
|
|
_, err := s.actors.Invoke(ctx, ActorType, userID, methodRestore, state)
|
|
if err != nil {
|
|
slog.ErrorContext(ctx, "Failed to restore email verification token after the database update failed", slog.Any("error", err))
|
|
}
|
|
}
|