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:
@@ -56,11 +56,12 @@ export default function ForgotPasswordPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const [sendInstructions] = useMutation<ForgotPasswordPageMutation>(
|
const [sendInstructions, isSendingInstructions]
|
||||||
sendInstructionsMutation,
|
= useMutation<ForgotPasswordPageMutation>(sendInstructionsMutation);
|
||||||
);
|
|
||||||
|
|
||||||
const onSubmit = handleSubmit(({ email }) => {
|
const onSubmit = handleSubmit(({ email }) => {
|
||||||
|
if (isSendingInstructions) return;
|
||||||
|
|
||||||
sendInstructions({
|
sendInstructions({
|
||||||
variables: {
|
variables: {
|
||||||
input: { email },
|
input: { email },
|
||||||
@@ -154,9 +155,9 @@ export default function ForgotPasswordPage() {
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-xs h-10 mx-auto mt-6"
|
className="w-xs h-10 mx-auto mt-6"
|
||||||
disabled={formState.isSubmitting}
|
disabled={isSendingInstructions}
|
||||||
>
|
>
|
||||||
{formState.isSubmitting
|
{isSendingInstructions
|
||||||
? t("forgotPasswordPage.actions.sendingInstructions")
|
? t("forgotPasswordPage.actions.sendingInstructions")
|
||||||
: t("forgotPasswordPage.actions.sendInstructions")}
|
: t("forgotPasswordPage.actions.sendInstructions")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -57,11 +57,12 @@ export default function ResendVerificationEmailPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const [resendVerificationEmail] = useMutation<ResendVerificationEmailPageMutation>(
|
const [resendVerificationEmail, isResending]
|
||||||
resendVerificationEmailMutation,
|
= useMutation<ResendVerificationEmailPageMutation>(resendVerificationEmailMutation);
|
||||||
);
|
|
||||||
|
|
||||||
const onSubmit = handleSubmit(({ email }) => {
|
const onSubmit = handleSubmit(({ email }) => {
|
||||||
|
if (isResending) return;
|
||||||
|
|
||||||
resendVerificationEmail({
|
resendVerificationEmail({
|
||||||
variables: {
|
variables: {
|
||||||
input: { email },
|
input: { email },
|
||||||
@@ -155,9 +156,9 @@ export default function ResendVerificationEmailPage() {
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-xs h-10 mx-auto mt-6"
|
className="w-xs h-10 mx-auto mt-6"
|
||||||
disabled={formState.isSubmitting}
|
disabled={isResending}
|
||||||
>
|
>
|
||||||
{formState.isSubmitting
|
{isResending
|
||||||
? t("resendVerificationEmailPage.actions.sendingVerification")
|
? t("resendVerificationEmailPage.actions.sendingVerification")
|
||||||
: t("resendVerificationEmailPage.actions.sendVerification")}
|
: t("resendVerificationEmailPage.actions.sendVerification")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ func NewPresenter(baseURL string, fullName string) *Presenter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
subjectConfirmEmail = "Confirm your email address"
|
SubjectConfirmEmail = "Confirm your email address"
|
||||||
subjectPasswordReset = "Reset your password"
|
subjectPasswordReset = "Reset your password"
|
||||||
subjectInvitation = "Invitation to join %s on Probo"
|
subjectInvitation = "Invitation to join %s on Probo"
|
||||||
subjectDocumentApproval = "Action Required – Please review and approve %s compliance documents"
|
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)
|
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) {
|
func (p *Presenter) RenderPasswordReset(ctx context.Context, resetPasswordURLPath string, resetPasswordToken string) (subject string, textBody string, htmlBody *string, err error) {
|
||||||
|
|||||||
@@ -319,6 +319,39 @@ WHERE id = @id
|
|||||||
return err
|
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(
|
func ResetStaleProcessingEmails(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
conn pg.Querier,
|
conn pg.Querier,
|
||||||
|
|||||||
@@ -76,6 +76,11 @@ var SupportedIdentityLocales = []string{
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
TokenTypeEmailConfirmation = "email_confirmation"
|
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 {
|
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
|
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(
|
confirmationToken, err := statelesstoken.NewToken(
|
||||||
s.tokenSecret,
|
s.tokenSecret,
|
||||||
TokenTypeEmailConfirmation,
|
TokenTypeEmailConfirmation,
|
||||||
|
|||||||
Reference in New Issue
Block a user