Harden email verification resend against abuse

Add a per-address confirmation-email cooldown and disable the
resend/forgot-password submit buttons while the mutation is in
flight so callers cannot flood the mail queue or double-submit.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-07-28 10:35:12 +02:00
parent 5d0882778f
commit 82a62005f8
5 changed files with 66 additions and 12 deletions

View File

@@ -56,11 +56,12 @@ export default function ForgotPasswordPage() {
},
});
const [sendInstructions] = useMutation<ForgotPasswordPageMutation>(
sendInstructionsMutation,
);
const [sendInstructions, isSendingInstructions]
= useMutation<ForgotPasswordPageMutation>(sendInstructionsMutation);
const onSubmit = handleSubmit(({ email }) => {
if (isSendingInstructions) return;
sendInstructions({
variables: {
input: { email },
@@ -154,9 +155,9 @@ export default function ForgotPasswordPage() {
<Button
type="submit"
className="w-xs h-10 mx-auto mt-6"
disabled={formState.isSubmitting}
disabled={isSendingInstructions}
>
{formState.isSubmitting
{isSendingInstructions
? t("forgotPasswordPage.actions.sendingInstructions")
: t("forgotPasswordPage.actions.sendInstructions")}
</Button>

View File

@@ -57,11 +57,12 @@ export default function ResendVerificationEmailPage() {
},
});
const [resendVerificationEmail] = useMutation<ResendVerificationEmailPageMutation>(
resendVerificationEmailMutation,
);
const [resendVerificationEmail, isResending]
= useMutation<ResendVerificationEmailPageMutation>(resendVerificationEmailMutation);
const onSubmit = handleSubmit(({ email }) => {
if (isResending) return;
resendVerificationEmail({
variables: {
input: { email },
@@ -155,9 +156,9 @@ export default function ResendVerificationEmailPage() {
<Button
type="submit"
className="w-xs h-10 mx-auto mt-6"
disabled={formState.isSubmitting}
disabled={isResending}
>
{formState.isSubmitting
{isResending
? t("resendVerificationEmailPage.actions.sendingVerification")
: t("resendVerificationEmailPage.actions.sendVerification")}
</Button>

View File

@@ -100,7 +100,7 @@ func NewPresenter(baseURL string, fullName string) *Presenter {
}
const (
subjectConfirmEmail = "Confirm your email address"
SubjectConfirmEmail = "Confirm your email address"
subjectPasswordReset = "Reset your password"
subjectInvitation = "Invitation to join %s on Probo"
subjectDocumentApproval = "Action Required – Please review and approve %s compliance documents"
@@ -185,7 +185,7 @@ func (p *Presenter) RenderConfirmEmail(ctx context.Context, confirmationURLPath
textBody, htmlBody, err = renderEmail(confirmEmailTextTemplate, confirmEmailHTMLTemplate, data)
return subjectConfirmEmail, textBody, htmlBody, err
return SubjectConfirmEmail, textBody, htmlBody, err
}
func (p *Presenter) RenderPasswordReset(ctx context.Context, resetPasswordURLPath string, resetPasswordToken string) (subject string, textBody string, htmlBody *string, err error) {

View File

@@ -319,6 +319,39 @@ WHERE id = @id
return err
}
// ExistsByRecipientSubjectCreatedAfter reports whether an email with the given
// recipient and subject was created at or after createdAfter.
func ExistsByRecipientSubjectCreatedAfter(
ctx context.Context,
conn pg.Querier,
recipientEmail mail.Addr,
subject string,
createdAfter time.Time,
) (bool, error) {
q := `
SELECT EXISTS (
SELECT 1
FROM emails
WHERE recipient_email = @recipient_email
AND subject = @subject
AND created_at >= @created_after
)
`
args := pgx.StrictNamedArgs{
"recipient_email": recipientEmail.String(),
"subject": subject,
"created_after": createdAfter,
}
var exists bool
if err := conn.QueryRow(ctx, q, args).Scan(&exists); err != nil {
return false, fmt.Errorf("cannot check recent email: %w", err)
}
return exists, nil
}
func ResetStaleProcessingEmails(
ctx context.Context,
conn pg.Querier,

View File

@@ -76,6 +76,11 @@ var SupportedIdentityLocales = []string{
const (
TokenTypeEmailConfirmation = "email_confirmation"
// emailConfirmationResendCooldown is the minimum time between confirmation
// emails for the same address. Resend requests inside this window succeed
// without enqueueing another message (anti-enumeration + anti-abuse).
emailConfirmationResendCooldown = time.Minute
)
func NewAccountService(svc *Service) *AccountService {
@@ -243,6 +248,20 @@ func (s AccountService) ResendVerificationEmail(ctx context.Context, email mail.
return nil // Don't leak information about already-verified identities
}
recent, err := coredata.ExistsByRecipientSubjectCreatedAfter(
ctx,
tx,
identity.EmailAddress,
emails.SubjectConfirmEmail,
time.Now().Add(-emailConfirmationResendCooldown),
)
if err != nil {
return fmt.Errorf("cannot check recent confirmation email: %w", err)
}
if recent {
return nil // Cooldown: avoid flooding the recipient / mail queue
}
confirmationToken, err := statelesstoken.NewToken(
s.tokenSecret,
TokenTypeEmailConfirmation,