Require email verification for new signups and email changes#130
Open
hendriebeats wants to merge 9 commits into
Open
Require email verification for new signups and email changes#130hendriebeats wants to merge 9 commits into
hendriebeats wants to merge 9 commits into
Conversation
New signups will require email verification before login is allowed. This lays the groundwork by adding the necessary schema and types. - Add emailVerified boolean column to user table (DEFAULT TRUE so existing users are grandfathered in as verified) - Add user_email_verification table to store one-time tokens with expiry, rate-limit sentAt timestamp, and the email being verified - Add EmailVerificationToken type and generator to Types.hs - Add UserEmailVerificationT beam table to Database.hs - Set emailVerified = False when creating new users in Entity/User.hs
Implements the core backend functions needed to issue and validate email verification tokens, and adds the two transactional emails sent during verification flows. - Add createVerificationToken: invalidates any pending token for the user and issues a new 24-hour token - Add verifyEmailToken: validates token freshness and marks it used, then atomically updates user.email and sets emailVerified = true - Add checkVerificationRateLimit: returns seconds remaining in the 5-minute cooldown window so the API can enforce resend throttling - Add updateVerificationSentAt: refreshes sentAt on resend so the cooldown resets - Add Emails.EmailVerification: HTML email with verify button - Add Emails.EmailChangeNotification: plain informational notice sent to the old address when an email change is requested
Wires up the two new public endpoints needed for the email verification flow. - GET /verify_email?token=...: validates the token, sets emailVerified = true and updates the user's email, then redirects to /login?verified=1. Shows an expired-link page if the token is invalid or already used. - POST /resend_verification: looks up the unverified user by email, enforces the 5-minute resend cooldown (returns a countdown message on violation), then issues a fresh token and sends the verification email. Silently no-ops if the address has no pending verification to avoid leaking account existence. - Add getUnverifiedUserId helper to Entity.User for the resend lookup - Add verifyEmail.html (expired-link page) and signup/checkEmail.html (post-signup check-your-inbox partial)
New accounts were immediately logged in after signup, making it impossible to enforce verified emails. Now signup sends a verification email instead of creating a session. - Replace session creation and HX-Redirect with a call to createVerificationToken and Emails.EmailVerification.mail - Render the signup/checkEmail.html partial on success so the user sees "Check your inbox" instead of being redirected to /studies - Add HasUrl env constraint to the signup handler - Remove the welcome email (replaced by the verification email) - Add a note to the signup form: "You'll receive a verification email after signing up"
Users who registered but haven't verified their email were previously able to log in freely. This enforces the verification requirement at login time. - Extend getPasswordHash to return emailVerified alongside the hash - In the login handler, check emailVerified after a correct password; redirect to studies only if verified, otherwise re-render the form with an unverified error state - Add loginFormUnverified which passes unverified=true to the template - Login form now shows "Please verify your email" error, a resend button, and a 5-minute client-side countdown timer that disables the button after clicking to prevent spam - getLogin reads ?verified=1 and passes justVerified to login.html - login.html shows a <p-notification> success banner when justVerified is true (set after clicking the verification link)
Previously, a user could silently update their email to any address with no confirmation, risking typos and account takeover. - In putProfile, compare the submitted email to the current one; if it differs, defer the email swap and trigger a verification flow - Name changes still save immediately in both branches - Add updateUserName to Entity.User for name-only DB updates - On email change: send an informational notice to the old address and a verification link to the new address; the swap happens only when the link is clicked (via the existing verifyEmailToken path) - Profile form shows a 6-second notification: "Verification email sent to <new address>. Your email will update once confirmed."
- `updateVerificationSentAt` was never called: every resend goes through `createVerificationToken`, which replaces the token and sets a fresh `sentAt` at insert time. Remove the dead function. - Add a comment to `checkVerificationRateLimit` explaining that `Nothing` means both "no pending token" and "cooldown expired" — both allow a resend, so the caller treats them identically. - Add a TODO comment in the migration noting that `DEFAULT TRUE` should be changed to `FALSE` in a follow-up migration once existing users are grandfathered in.
- Replace nested <form> inside login form with a <button type="button"> carrying HTMX attrs directly; nested forms are invalid HTML and browsers silently discard the inner element, breaking the resend POST entirely - Add id="resend-result" to the not-found response div so HTMX can swap it into the correct target (the other two branches already had it) - Add id="email" to login form email input so <label for="email"> works - Fix profile form: add id attrs to both inputs; correct email label's for="name" typo to for="email" - Trim verbose checkVerificationRateLimit doc comment
Contributor
Author
|
Note: We'll have to update the DB migration. I set the value at "true" which would automatically verify everyone. Instead, it should migrate once & then be set to false (something like this) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
New users must verify their email before they can log in. Existing users are grandfathered as verified. Users who change their email on the profile page trigger a new verification flow — the old address stays active until the new one is confirmed.
Reviewer notes
DB schema
emailVerifiedadded touserwithDEFAULT TRUEso existing accounts aren't locked out. ATODOin the migration notes this default should eventually flip toFALSE; for now all inserts in application code explicitly passFalse.user_email_verificationtable tracks tokens — same plaintext pattern as password reset and session tokens already in the codebase.Token lifecycle
createVerificationTokendeletes any existing unused token for that user before inserting a new one — one pending token per user, always fresh on resend.verifyEmailTokenupdates bothuser.emailanduser.emailVerifiedatomically, then marks the token used; replaying a used/expired token is a no-op returningNothing.sentAtof the current pending row (reset each time a token is created), so it correctly reflects when the last email was actually sent.Login flow
getPasswordHashnow returns(UserId, PasswordHash, Bool)— theBoolisemailVerified. Password comparison happens first; the verified check only runs after a correct password, so error responses don't distinguish "wrong password" from "unverified".loginFormUnverifiedre-renders the form withunverified = True, which shows the resend button — the email is pre-filled so the HTMX POST can send it without a visible input.Resend UI
<button type="button">with HTMX attrs rather than a nested<form>(nested forms are invalid HTML and browsers silently discard the inner element).Profile / email change
updateUserruns as before. When it changes, only the name is updated immediately viaupdateUserName; the email swap waits until the verification link is clicked. The old address receives an informational notice; the new address receives the verification link.Signup
mRedirectis still read and passed through to the error-path form so the redirect is preserved across validation failures.