Gate password sign-in on email verification

Unverified password identities were able to open sessions after
signing out. Reject sign-in with EMAIL_NOT_VERIFIED and add a
resend-confirmation flow so users can complete verification.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-07-28 10:15:25 +02:00
parent bb9fb22913
commit 5d0882778f
19 changed files with 621 additions and 60 deletions

View File

@@ -120,7 +120,7 @@ func (s AccountService) ChangeEmail(ctx context.Context, identityID gid.GID, req
confirmationToken, err := statelesstoken.NewToken(
s.tokenSecret,
TokenTypeEmailConfirmation,
24*time.Hour,
s.emailConfirmationTokenValidity,
EmailConfirmationData{IdentityID: identityID, Email: req.NewEmail},
)
if err != nil {
@@ -226,6 +226,58 @@ func (s AccountService) VerifyEmail(ctx context.Context, token string) error {
)
}
func (s AccountService) ResendVerificationEmail(ctx context.Context, email mail.Addr) error {
return s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
identity := &coredata.Identity{}
if err := identity.LoadByEmail(ctx, tx, email); err != nil {
if err == coredata.ErrResourceNotFound {
return nil // Don't leak information about non-existent identities
}
return fmt.Errorf("cannot load identity: %w", err)
}
if identity.EmailAddressVerified {
return nil // Don't leak information about already-verified identities
}
confirmationToken, err := statelesstoken.NewToken(
s.tokenSecret,
TokenTypeEmailConfirmation,
s.emailConfirmationTokenValidity,
EmailConfirmationData{IdentityID: identity.ID, Email: identity.EmailAddress},
)
if err != nil {
return fmt.Errorf("cannot generate confirmation token: %w", err)
}
emailPresenter := emails.NewPresenter(s.baseURL, identity.FullName)
subject, textBody, htmlBody, err := emailPresenter.RenderConfirmEmail(ctx, "/auth/verify-email", confirmationToken)
if err != nil {
return fmt.Errorf("cannot render confirmation email: %w", err)
}
confirmationEmail := coredata.NewEmail(
identity.FullName,
identity.EmailAddress,
subject,
textBody,
htmlBody,
nil,
)
if err := confirmationEmail.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot insert confirmation email: %w", err)
}
return nil
},
)
}
func (s *AccountService) ListPendingInvitations(
ctx context.Context,
userID gid.GID,

View File

@@ -388,7 +388,7 @@ func (s AuthService) CreateIdentityWithPassword(
confirmationToken, err := statelesstoken.NewToken(
s.tokenSecret,
TokenTypeEmailConfirmation,
24*time.Hour,
s.emailConfirmationTokenValidity,
EmailConfirmationData{IdentityID: identity.ID, Email: identity.EmailAddress},
)
if err != nil {

View File

@@ -108,6 +108,16 @@ func (e ErrEmailAlreadyVerified) Error() string {
return e.message
}
type ErrEmailNotVerified struct{ message string }
func NewEmailNotVerifiedError() error {
return &ErrEmailNotVerified{"email address not verified"}
}
func (e ErrEmailNotVerified) Error() string {
return e.message
}
type ErrIdentityNotFound struct{ IdentityID gid.GID }
func NewIdentityNotFoundError(identityID gid.GID) error {

View File

@@ -50,23 +50,24 @@ import (
type (
Service struct {
pg *pg.Client
fm *filemanager.Service
hp *passwdhash.Profile
dummyHash []byte
baseURL string
tokenSecret string
disableSignup bool
invitationTokenValidity time.Duration
passwordResetTokenValidity time.Duration
magicLinkTokenValidity time.Duration
sessionDuration time.Duration
bucket string
compliancePortalBaseDomain string
certManager *certmanager.Service
certificate *x509.Certificate
privateKey *rsa.PrivateKey
logger *log.Logger
pg *pg.Client
fm *filemanager.Service
hp *passwdhash.Profile
dummyHash []byte
baseURL string
tokenSecret string
disableSignup bool
invitationTokenValidity time.Duration
passwordResetTokenValidity time.Duration
magicLinkTokenValidity time.Duration
emailConfirmationTokenValidity time.Duration
sessionDuration time.Duration
bucket string
compliancePortalBaseDomain string
certManager *certmanager.Service
certificate *x509.Certificate
privateKey *rsa.PrivateKey
logger *log.Logger
AccountService *AccountService
OrganizationService *OrganizationService
@@ -88,6 +89,7 @@ type (
InvitationTokenValidity time.Duration
PasswordResetTokenValidity time.Duration
MagicLinkTokenValidity time.Duration
EmailConfirmationTokenValidity time.Duration
SessionDuration time.Duration
Bucket string
TokenSecret string
@@ -154,23 +156,24 @@ func NewService(
}
svc := &Service{
pg: pgClient,
fm: fm,
hp: hp,
dummyHash: mustHashDummy(hp),
baseURL: cfg.BaseURL.String(),
tokenSecret: cfg.TokenSecret,
disableSignup: cfg.DisableSignup,
invitationTokenValidity: cfg.InvitationTokenValidity,
passwordResetTokenValidity: cfg.PasswordResetTokenValidity,
magicLinkTokenValidity: cfg.MagicLinkTokenValidity,
sessionDuration: cfg.SessionDuration,
bucket: cfg.Bucket,
compliancePortalBaseDomain: cfg.CompliancePortalBaseDomain,
certManager: cfg.CertManager,
certificate: cfg.Certificate,
privateKey: cfg.PrivateKey,
logger: cfg.Logger,
pg: pgClient,
fm: fm,
hp: hp,
dummyHash: mustHashDummy(hp),
baseURL: cfg.BaseURL.String(),
tokenSecret: cfg.TokenSecret,
disableSignup: cfg.DisableSignup,
invitationTokenValidity: cfg.InvitationTokenValidity,
passwordResetTokenValidity: cfg.PasswordResetTokenValidity,
magicLinkTokenValidity: cfg.MagicLinkTokenValidity,
emailConfirmationTokenValidity: cfg.EmailConfirmationTokenValidity,
sessionDuration: cfg.SessionDuration,
bucket: cfg.Bucket,
compliancePortalBaseDomain: cfg.CompliancePortalBaseDomain,
certManager: cfg.CertManager,
certificate: cfg.Certificate,
privateKey: cfg.PrivateKey,
logger: cfg.Logger,
}
svc.AccountService = NewAccountService(svc)