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

@@ -111,6 +111,7 @@ func (b *Builder) Build() (*probodconfig.FullConfig, error) {
InvitationConfirmationTokenValidity: b.resolver.getEnvIntOrDefault("PROBOD_AUTH_INVITATION_TOKEN_VALIDITY", 3600),
PasswordResetTokenValidity: b.resolver.getEnvIntOrDefault("PROBOD_AUTH_PASSWORD_RESET_TOKEN_VALIDITY", 3600),
MagicLinkTokenValidity: b.resolver.getEnvIntOrDefault("PROBOD_AUTH_MAGIC_LINK_TOKEN_VALIDITY", 900),
EmailConfirmationTokenValidity: b.resolver.getEnvIntOrDefault("PROBOD_AUTH_EMAIL_CONFIRMATION_TOKEN_VALIDITY", 3600),
Cookie: probodconfig.CookieConfig{
Name: b.resolver.getEnv("PROBOD_AUTH_COOKIE_NAME"),
Domain: b.resolver.getEnv("PROBOD_AUTH_COOKIE_DOMAIN"),

View File

@@ -175,6 +175,7 @@ func TestBuilder_Build_Defaults(t *testing.T) {
assert.Equal(t, 3600, cfg.Probod.Auth.InvitationConfirmationTokenValidity)
assert.Equal(t, 3600, cfg.Probod.Auth.PasswordResetTokenValidity)
assert.Equal(t, 900, cfg.Probod.Auth.MagicLinkTokenValidity)
assert.Equal(t, 3600, cfg.Probod.Auth.EmailConfirmationTokenValidity)
assert.Empty(t, cfg.Probod.Auth.Cookie.Name)
assert.Empty(t, cfg.Probod.Auth.Cookie.Domain)
assert.Equal(t, 24, cfg.Probod.Auth.Cookie.Duration)
@@ -331,6 +332,7 @@ func TestBuilder_Build_CustomValues(t *testing.T) {
env["PROBOD_AUTH_INVITATION_TOKEN_VALIDITY"] = "7200"
env["PROBOD_AUTH_PASSWORD_RESET_TOKEN_VALIDITY"] = "1800"
env["PROBOD_AUTH_MAGIC_LINK_TOKEN_VALIDITY"] = "600"
env["PROBOD_AUTH_EMAIL_CONFIRMATION_TOKEN_VALIDITY"] = "43200"
env["PROBOD_AUTH_COOKIE_DOMAIN"] = ".example.com"
env["PROBOD_AUTH_COOKIE_DURATION"] = "48"
// SAML
@@ -466,6 +468,7 @@ func TestBuilder_Build_CustomValues(t *testing.T) {
assert.Equal(t, 7200, cfg.Probod.Auth.InvitationConfirmationTokenValidity)
assert.Equal(t, 1800, cfg.Probod.Auth.PasswordResetTokenValidity)
assert.Equal(t, 600, cfg.Probod.Auth.MagicLinkTokenValidity)
assert.Equal(t, 43200, cfg.Probod.Auth.EmailConfirmationTokenValidity)
assert.Equal(t, ".example.com", cfg.Probod.Auth.Cookie.Domain)
assert.Equal(t, 48, cfg.Probod.Auth.Cookie.Duration)
// SAML

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)

View File

@@ -139,6 +139,7 @@ func New() *Implm {
InvitationConfirmationTokenValidity: 3600,
PasswordResetTokenValidity: 3600,
MagicLinkTokenValidity: 900,
EmailConfirmationTokenValidity: 3600,
SAML: SAMLConfig{
SessionDuration: 604800,
CleanupIntervalSeconds: 86400,
@@ -569,6 +570,7 @@ func (impl *Implm) Run(
InvitationTokenValidity: time.Duration(impl.cfg.Auth.InvitationConfirmationTokenValidity) * time.Second,
PasswordResetTokenValidity: time.Duration(impl.cfg.Auth.PasswordResetTokenValidity) * time.Second,
MagicLinkTokenValidity: time.Duration(impl.cfg.Auth.MagicLinkTokenValidity) * time.Second,
EmailConfirmationTokenValidity: time.Duration(impl.cfg.Auth.EmailConfirmationTokenValidity) * time.Second,
SessionDuration: time.Duration(impl.cfg.Auth.Cookie.Duration) * time.Hour,
Bucket: impl.cfg.AWS.Bucket,
TokenSecret: impl.cfg.Auth.Cookie.Secret,

View File

@@ -32,6 +32,7 @@ type AuthConfig struct {
InvitationConfirmationTokenValidity int `json:"invitation-confirmation-token-validity"`
PasswordResetTokenValidity int `json:"password-reset-token-validity"`
MagicLinkTokenValidity int `json:"magic-link-token-validity"`
EmailConfirmationTokenValidity int `json:"email-confirmation-token-validity"`
SAML SAMLConfig `json:"saml"`
Google OIDCProviderConfig `json:"google,omitzero"`
Microsoft OIDCProviderConfig `json:"microsoft,omitzero"`

View File

@@ -17,6 +17,9 @@ extend type Mutation {
@authentication(required: NONE)
verifyEmail(input: VerifyEmailInput!): VerifyEmailPayload
@authentication(required: OPTIONAL)
resendVerificationEmail(
input: ResendVerificationEmailInput!
): ResendVerificationEmailPayload @authentication(required: NONE)
changePassword(input: ChangePasswordInput!): ChangePasswordPayload
@authentication(required: PRESENT) @sessionOnly
changeEmail(input: ChangeEmailInput!): ChangeEmailPayload
@@ -102,6 +105,10 @@ input VerifyEmailInput {
token: String!
}
input ResendVerificationEmailInput {
email: EmailAddr!
}
input ChangePasswordInput {
currentPassword: String!
newPassword: String!
@@ -152,6 +159,10 @@ type VerifyEmailPayload {
success: Boolean!
}
type ResendVerificationEmailPayload {
success: Boolean!
}
type ChangePasswordPayload {
success: Boolean!
}

View File

@@ -40,6 +40,15 @@ func (r *mutationResolver) SignIn(ctx context.Context, input types.SignInInput)
return nil, gqlutils.Internal(ctx)
}
if !identity.EmailAddressVerified {
return nil, &gqlerror.Error{
Message: iam.NewEmailNotVerifiedError().Error(),
Extensions: map[string]any{
"code": "EMAIL_NOT_VERIFIED",
},
}
}
session := authn.SessionFromContext(ctx)
switch {
@@ -311,6 +320,19 @@ func (r *mutationResolver) VerifyEmail(ctx context.Context, input types.VerifyEm
}, nil
}
// ResendVerificationEmail is the resolver for the resendVerificationEmail field.
func (r *mutationResolver) ResendVerificationEmail(ctx context.Context, input types.ResendVerificationEmailInput) (*types.ResendVerificationEmailPayload, error) {
err := r.iam.AccountService.ResendVerificationEmail(ctx, input.Email)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot resend verification email", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.ResendVerificationEmailPayload{
Success: true,
}, nil
}
// ChangePassword is the resolver for the changePassword field.
func (r *mutationResolver) ChangePassword(ctx context.Context, input types.ChangePasswordInput) (*types.ChangePasswordPayload, error) {
identity := authn.IdentityFromContext(ctx)