diff --git a/.fides/db_dataset.yml b/.fides/db_dataset.yml index a805b7f15cd..3d82c72dddd 100644 --- a/.fides/db_dataset.yml +++ b/.fides/db_dataset.yml @@ -3449,6 +3449,20 @@ dataset: data_categories: [system.operations] - name: username data_categories: [user.account.username] + - name: fides_user_email_verification + fields: + - name: created_at + data_categories: [system.operations] + - name: hashed_token + data_categories: [system.operations] + - name: id + data_categories: [system.operations] + - name: salt + data_categories: [system.operations] + - name: updated_at + data_categories: [system.operations] + - name: user_id + data_categories: [user.unique_id] - name: fides_user_password_reset fields: - name: created_at diff --git a/changelog/8180-email-verification-flow.yaml b/changelog/8180-email-verification-flow.yaml new file mode 100644 index 00000000000..413cdae4451 --- /dev/null +++ b/changelog/8180-email-verification-flow.yaml @@ -0,0 +1,4 @@ +type: Added +description: Added self-service email verification flow for admin users via a new "verify your email" CTA banner. +pr: 8180 +labels: ["db-migration"] diff --git a/clients/admin-ui/cypress/e2e/email-verification.cy.ts b/clients/admin-ui/cypress/e2e/email-verification.cy.ts new file mode 100644 index 00000000000..5395e93f67c --- /dev/null +++ b/clients/admin-ui/cypress/e2e/email-verification.cy.ts @@ -0,0 +1,225 @@ +import { stubPlus } from "cypress/support/stubs"; + +import { STORAGE_ROOT_KEY } from "~/constants"; + +type UserOverrides = { + email_address?: string | null; + email_verified_at?: string | null; + password_login_enabled?: boolean | null; +}; + +// Mirrors the canonical `cy.login()` pattern from cypress/support/commands.ts: +// writes the persisted redux auth state to localStorage via the cypress command +// queue (not via `onBeforeLoad`) and stubs the user-permission endpoint, then +// allows test-by-test overrides on the user fields the banner cares about. +const loginAs = (overrides: UserOverrides = {}) => { + cy.fixture("login.json").then((body) => { + const authState = { + user: { ...body.user_data, ...overrides }, + token: body.token_data.access_token, + }; + cy.window().then((win) => { + win.localStorage.setItem( + STORAGE_ROOT_KEY, + JSON.stringify({ auth: JSON.stringify(authState) }), + ); + }); + }); + cy.intercept("/api/v1/user/*/permission", { + fixture: "user-management/permissions.json", + }).as("getUserPermission"); +}; + +const baseLoginUserId = "123"; // matches login.json fixture +const baseLoginEmail = "cypress-user@ethyca.com"; + +const stubLoggedInRequests = () => { + stubPlus(true); + cy.intercept("GET", "/api/v1/system", { body: [] }); +}; + +const snoozeKeyFor = (email: string | null) => + `fides:email-verification-banner-snooze:${baseLoginUserId}:${email ?? "none"}`; + +describe("Email verification banner", () => { + beforeEach(() => { + stubLoggedInRequests(); + }); + + it("does not render when the user's email is already verified", () => { + cy.intercept("GET", "/api/v1/messaging/email-invite/status", { + body: { enabled: true }, + }).as("getEmailInviteStatus"); + loginAs({ + email_address: baseLoginEmail, + email_verified_at: "2026-01-02T00:00:00.000Z", + }); + cy.visit("/"); + cy.getByTestId("Home"); + cy.get("[data-testid^='email-verification-banner-']").should("not.exist"); + }); + + it("does not render when email invites are disabled", () => { + cy.intercept("GET", "/api/v1/messaging/email-invite/status", { + body: { enabled: false }, + }).as("getEmailInviteStatus"); + loginAs({ email_address: baseLoginEmail, email_verified_at: null }); + cy.visit("/"); + cy.getByTestId("Home"); + cy.get("[data-testid^='email-verification-banner-']").should("not.exist"); + }); + + it("does not render for SSO-only users (password_login_enabled=false)", () => { + cy.intercept("GET", "/api/v1/messaging/email-invite/status", { + body: { enabled: true }, + }).as("getEmailInviteStatus"); + loginAs({ + email_address: baseLoginEmail, + email_verified_at: null, + password_login_enabled: false, + }); + cy.visit("/"); + cy.getByTestId("Home"); + cy.get("[data-testid^='email-verification-banner-']").should("not.exist"); + }); + + it("prompts unverified users and dispatches a verification email on click", () => { + cy.intercept("GET", "/api/v1/messaging/email-invite/status", { + body: { enabled: true }, + }).as("getEmailInviteStatus"); + cy.intercept("POST", "/api/v1/user/request-email-verification", { + body: { + detail: + "If your account is eligible, a verification email has been sent.", + }, + }).as("requestEmailVerification"); + + loginAs({ email_address: baseLoginEmail, email_verified_at: null }); + cy.visit("/"); + cy.getByTestId("Home"); + cy.getByTestId("email-verification-banner-unverified").should("be.visible"); + cy.getByTestId("email-verification-banner-send-btn").click(); + cy.wait("@requestEmailVerification"); + cy.getByTestId("email-verification-banner-sent").should("be.visible"); + }); + + it("routes no-email users to the profile edit page with #email_address", () => { + cy.intercept("GET", "/api/v1/messaging/email-invite/status", { + body: { enabled: true }, + }).as("getEmailInviteStatus"); + cy.intercept("/api/v1/user/*", { + fixture: "user-management/user.json", + }).as("getUser"); + + loginAs({ email_address: null, email_verified_at: null }); + cy.visit("/"); + cy.getByTestId("Home"); + cy.getByTestId("email-verification-banner-no-email").should("be.visible"); + cy.getByTestId("email-verification-banner-add-email-btn").click(); + cy.location("pathname").should( + "eq", + `/user-management/profile/${baseLoginUserId}`, + ); + cy.location("hash").should("eq", "#email_address"); + // The form should auto-focus the email field; we don't strictly need to + // assert focus (jsdom/cypress can be flaky for focus assertions across + // smooth-scroll timing) but verify it rendered. + cy.getByTestId("input-email_address"); + }); + + it("hides the banner when dismissed and re-shows it after the snooze TTL expires", () => { + cy.intercept("GET", "/api/v1/messaging/email-invite/status", { + body: { enabled: true }, + }).as("getEmailInviteStatus"); + + loginAs({ email_address: baseLoginEmail, email_verified_at: null }); + cy.visit("/"); + cy.getByTestId("Home"); + cy.getByTestId("email-verification-banner-unverified") + .find(".ant-alert-close-icon") + .click(); + cy.get("[data-testid^='email-verification-banner-']").should("not.exist"); + + // Reload — still snoozed. + cy.reload(); + cy.getByTestId("Home"); + cy.get("[data-testid^='email-verification-banner-']").should("not.exist"); + + // Fast-forward the snooze timestamp to 8 days ago and reload. + cy.window().then((win) => { + const eightDaysAgo = Date.now() - 8 * 24 * 60 * 60 * 1000; + win.localStorage.setItem( + snoozeKeyFor(baseLoginEmail), + String(eightDaysAgo), + ); + }); + cy.reload(); + cy.getByTestId("Home"); + cy.getByTestId("email-verification-banner-unverified").should("be.visible"); + }); + + it("invalidates the snooze when the user's email_address changes", () => { + cy.intercept("GET", "/api/v1/messaging/email-invite/status", { + body: { enabled: true }, + }).as("getEmailInviteStatus"); + + // Snooze the banner for the original email. + loginAs({ email_address: baseLoginEmail, email_verified_at: null }); + cy.visit("/"); + cy.getByTestId("Home"); + cy.getByTestId("email-verification-banner-unverified") + .find(".ant-alert-close-icon") + .click(); + cy.get("[data-testid^='email-verification-banner-']").should("not.exist"); + + // Now simulate the user editing their email — banner should reappear + // because the snooze key is keyed on (userId, email_address). + loginAs({ + email_address: "new-email@ethyca.com", + email_verified_at: null, + }); + cy.visit("/"); + cy.getByTestId("Home"); + cy.getByTestId("email-verification-banner-unverified").should("be.visible"); + }); +}); + +describe("Verify email landing page", () => { + it("verifies the email and redirects to the dashboard on success", () => { + cy.fixture("login.json").then((body) => { + cy.intercept("POST", "/api/v1/user/verify-email-with-token", body).as( + "verifyEmail", + ); + }); + cy.intercept("/api/v1/user/*/permission", { + fixture: "user-management/permissions.json", + }).as("getUserPermission"); + cy.intercept("GET", "/api/v1/system", { body: [] }); + + cy.visit("/verify-email?username=cypress-user&token=valid-token"); + cy.wait("@verifyEmail").then((interception) => { + expect(interception.request.body).to.eql({ + username: "cypress-user", + token: "valid-token", + }); + }); + cy.location("pathname").should("eq", "/"); + }); + + it("shows an error state when the token is rejected", () => { + cy.intercept("POST", "/api/v1/user/verify-email-with-token", { + statusCode: 400, + body: { detail: "Invalid or expired verification token." }, + }).as("verifyEmail"); + + cy.visit("/verify-email?username=cypress-user&token=bad"); + cy.wait("@verifyEmail"); + cy.getByTestId("verify-email-error").should("be.visible"); + cy.getByTestId("back-to-login-btn").should("be.visible"); + }); + + it("shows an error state when the URL is missing parameters", () => { + cy.visit("/verify-email"); + cy.getByTestId("verify-email-error").should("be.visible"); + }); +}); diff --git a/clients/admin-ui/src/features/auth/auth.slice.ts b/clients/admin-ui/src/features/auth/auth.slice.ts index 1bf611d33a0..62776cc26ef 100644 --- a/clients/admin-ui/src/features/auth/auth.slice.ts +++ b/clients/admin-ui/src/features/auth/auth.slice.ts @@ -141,6 +141,22 @@ const authApi = baseApi.injectEndpoints({ body: { username, token, new_password }, }), }), + requestEmailVerification: build.mutation<{ detail: string }, void>({ + query: () => ({ + url: "user/request-email-verification", + method: "POST", + }), + }), + verifyEmailWithToken: build.mutation< + LoginResponse, + { username: string; token: string } + >({ + query: ({ username, token }) => ({ + url: "user/verify-email-with-token", + method: "POST", + body: { username, token }, + }), + }), }), }); @@ -153,5 +169,7 @@ export const { useGetAuthenticationMethodsQuery, useForgotPasswordMutation, useResetPasswordWithTokenMutation, + useRequestEmailVerificationMutation, + useVerifyEmailWithTokenMutation, } = authApi; export const { reducer } = authSlice; diff --git a/clients/admin-ui/src/features/common/EmailVerificationBanner.tsx b/clients/admin-ui/src/features/common/EmailVerificationBanner.tsx new file mode 100644 index 00000000000..9f63457b31c --- /dev/null +++ b/clients/admin-ui/src/features/common/EmailVerificationBanner.tsx @@ -0,0 +1,187 @@ +import { Alert, Button, useMessage } from "fidesui"; +import { useRouter } from "next/router"; +import { useEffect, useMemo, useState } from "react"; +import { useSelector } from "react-redux"; + +import { + selectUser, + useRequestEmailVerificationMutation, +} from "~/features/auth"; +import { getErrorMessage } from "~/features/common/helpers"; +import { USER_MANAGEMENT_ROUTE } from "~/features/common/nav/routes"; +import { useGetEmailInviteStatusQuery } from "~/features/messaging/messaging.slice"; +import { RTKErrorResult } from "~/types/errors/api"; + +const SNOOZE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days +const SNOOZE_KEY_PREFIX = "fides:email-verification-banner-snooze"; + +const buildSnoozeKey = ( + userId: string, + emailAddress: string | null | undefined, +) => `${SNOOZE_KEY_PREFIX}:${userId}:${emailAddress ?? "none"}`; + +const isSnoozeActive = (key: string): boolean => { + if (typeof window === "undefined") { + return false; + } + try { + const raw = window.localStorage.getItem(key); + if (!raw) { + return false; + } + const snoozedAt = Number.parseInt(raw, 10); + if (Number.isNaN(snoozedAt)) { + return false; + } + return Date.now() - snoozedAt < SNOOZE_TTL_MS; + } catch { + // localStorage can throw in private-mode/SSR — treat as no snooze. + return false; + } +}; + +const writeSnooze = (key: string) => { + if (typeof window === "undefined") { + return; + } + try { + window.localStorage.setItem(key, String(Date.now())); + } catch { + // No-op if storage isn't available. + } +}; + +const EmailVerificationBanner = () => { + const router = useRouter(); + const user = useSelector(selectUser); + const { data: emailInviteStatus } = useGetEmailInviteStatusQuery(undefined, { + skip: !user || Boolean(user.email_verified_at), + }); + const [requestEmailVerification, { isLoading: isRequesting }] = + useRequestEmailVerificationMutation(); + const message = useMessage(); + const [requestSent, setRequestSent] = useState(false); + const [dismissed, setDismissed] = useState(null); + + const snoozeKey = useMemo( + () => (user ? buildSnoozeKey(user.id, user.email_address) : null), + [user], + ); + + // Resolve snooze state on the client (after mount) to avoid SSR mismatches. + useEffect(() => { + if (!snoozeKey) { + setDismissed(false); + return; + } + setDismissed(isSnoozeActive(snoozeKey)); + }, [snoozeKey]); + + if (!user) { + return null; + } + + if (user.email_verified_at) { + return null; + } + + if (!emailInviteStatus?.enabled) { + return null; + } + + // Suppress for SSO-only users (mirrors fidesplus' service-level gating): + // they can't use password recovery, so verification has no value for them. + if (user.password_login_enabled === false) { + return null; + } + + // Haven't resolved snooze yet (first paint) or snooze is active. + if (dismissed !== false) { + return null; + } + + const handleDismiss = () => { + if (snoozeKey) { + writeSnooze(snoozeKey); + } + setDismissed(true); + }; + + const hasEmail = Boolean(user.email_address); + + const handleSend = async () => { + try { + await requestEmailVerification().unwrap(); + setRequestSent(true); + } catch (error) { + const errorMsg = getErrorMessage( + error as RTKErrorResult["error"], + "Could not send verification email. Please try again.", + ); + message.error(errorMsg); + } + }; + + const handleAddEmail = () => { + router.push(`${USER_MANAGEMENT_ROUTE}/profile/${user.id}#email_address`); + }; + + if (requestSent) { + return ( + + ); + } + + if (!hasEmail) { + return ( + + Add email + + } + data-testid="email-verification-banner-no-email" + /> + ); + } + + return ( + + Send verification email + + } + data-testid="email-verification-banner-unverified" + /> + ); +}; + +export default EmailVerificationBanner; diff --git a/clients/admin-ui/src/features/user-management/UserForm.tsx b/clients/admin-ui/src/features/user-management/UserForm.tsx index 9ef1382384f..45f4dc6be4f 100644 --- a/clients/admin-ui/src/features/user-management/UserForm.tsx +++ b/clients/admin-ui/src/features/user-management/UserForm.tsx @@ -6,13 +6,15 @@ import { Form, Icons, Input, + InputRef, Switch, Tag, + Tooltip, Typography, useMessage, } from "fidesui"; import { useRouter } from "next/router"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import DeleteUserModal from "user-management/DeleteUserModal"; import { useAppDispatch, useAppSelector } from "~/app/hooks"; @@ -81,6 +83,44 @@ const UserForm = ({ onSubmit, initialValues, canEditNames }: UserFormProps) => { const [form] = Form.useForm(); const [isSubmitting, setIsSubmitting] = useState(false); const [deleteModalOpen, setDeleteModalOpen] = useState(false); + const emailWrapperRef = useRef(null); + const emailInputRef = useRef(null); + const [emailHighlight, setEmailHighlight] = useState(false); + + // Deep-link support: when navigated to with `#email_address`, scroll the + // email field into view, focus it, and visually accent it briefly. Lets + // the email-verification banner CTA point users straight at the field. + useEffect(() => { + if (typeof window === "undefined") { + return undefined; + } + if (window.location.hash !== "#email_address") { + return undefined; + } + emailWrapperRef.current?.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + // Slight delay so the scroll finishes before we steal focus. + const focusTimer = window.setTimeout(() => { + emailInputRef.current?.focus(); + }, 300); + setEmailHighlight(true); + // Strip the hash so reloads don't re-trigger the accent. + window.history.replaceState( + null, + "", + window.location.pathname + window.location.search, + ); + const highlightTimer = window.setTimeout( + () => setEmailHighlight(false), + 3000, + ); + return () => { + window.clearTimeout(focusTimer); + window.clearTimeout(highlightTimer); + }; + }, []); // Watch form fields for reactive UI Form.useWatch([], form); @@ -131,6 +171,17 @@ const UserForm = ({ onSubmit, initialValues, canEditNames }: UserFormProps) => { passwordLoginEnabled, ); + // Email verification status tag visibility: + // - Verified → always show; pure status info, no action implied. + // - Not verified + messaging enabled → show; the banner provides the CTA + // elsewhere, so the tag is informational and actionable in context. + // - Not verified + messaging disabled → hide; otherwise the user sees a + // status they can't act on (no verification flow is available), which + // sends them looking for a button that doesn't exist. + const emailVerifiedAt = activeUser?.email_verified_at ?? null; + const showEmailVerificationTag = + !isNewUser && (Boolean(emailVerifiedAt) || inviteUsersViaEmail); + const handleSubmit = async (values: FormValues) => { setIsSubmitting(true); try { @@ -240,19 +291,50 @@ const UserForm = ({ onSubmit, initialValues, canEditNames }: UserFormProps) => { data-testid="input-username" /> - - - + + Email address + {showEmailVerificationTag && + (emailVerifiedAt ? ( + + + Verified + + + ) : ( + + Not verified + + ))} + + } + rules={[ + { required: true, message: "Email address is required" }, + { type: "email", message: "Please enter a valid email address" }, + ]} + > + + + { {Component === Login || Component === LoginWithOIDC || - Component === ForgotPassword ? ( - // Only the login page is accessible while logged out. If there is - // a use case for more unprotected routes, Next has a guide for - // per-page layouts: + Component === ForgotPassword || + Component === VerifyEmail ? ( + // Pages accessible while logged out. If this OR chain keeps + // growing, a refactor to Next's per-page layouts should be + // considered, so each page opts in/out of ProtectedRoute on + // its own: // https://nextjs.org/docs/basic-features/layouts#per-page-layouts ) : ( @@ -71,6 +75,7 @@ const MyApp = ({ Component, pageProps }: AppProps) => { minWidth={0} overflow="hidden" > + diff --git a/clients/admin-ui/src/pages/verify-email.tsx b/clients/admin-ui/src/pages/verify-email.tsx new file mode 100644 index 00000000000..ac11003a1d4 --- /dev/null +++ b/clients/admin-ui/src/pages/verify-email.tsx @@ -0,0 +1,150 @@ +import Head from "common/Head"; +import Image from "common/Image"; +import { Button, Card, Flex, Spin, Typography } from "fidesui"; +import type { NextPage } from "next"; +import { useRouter } from "next/router"; +import { useEffect, useState } from "react"; +import { useDispatch, useSelector } from "react-redux"; + +import { + login, + selectToken, + useVerifyEmailWithTokenMutation, +} from "~/features/auth"; +import { RouterLink } from "~/features/common/nav/RouterLink"; + +type VerifyState = "pending" | "success" | "error" | "missing-params"; + +const parseQuery = (query: Record) => { + const { username: rawUsername, token: rawToken } = query; + return { + username: typeof rawUsername === "string" ? rawUsername : undefined, + token: typeof rawToken === "string" ? rawToken : undefined, + }; +}; + +const VerifyEmail: NextPage = () => { + const router = useRouter(); + const dispatch = useDispatch(); + const token = useSelector(selectToken); + const [verifyEmailWithToken] = useVerifyEmailWithTokenMutation(); + const [state, setState] = useState("pending"); + + useEffect(() => { + if (!router.isReady) { + return undefined; + } + const { username, token: verificationToken } = parseQuery(router.query); + if (!username || !verificationToken) { + setState("missing-params"); + return undefined; + } + // Fire the verification once on mount. We intentionally don't depend on + // verifyEmailWithToken in the deps array — RTK Query hook tuples are + // stable references in practice and we only want this to run once. + let cancelled = false; + (async () => { + try { + const result = await verifyEmailWithToken({ + username, + token: verificationToken, + }).unwrap(); + if (cancelled) { + return; + } + dispatch(login(result)); + setState("success"); + } catch { + if (cancelled) { + return; + } + setState("error"); + } + })(); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [router.isReady, router.query.username, router.query.token]); + + useEffect(() => { + if (state === "success" && token) { + router.replace("/"); + } + }, [state, token, router]); + + let titleText = "Verifying your email…"; + if (state === "success") { + titleText = "Email verified"; + } else if (state === "error") { + titleText = "Verification failed"; + } else if (state === "missing-params") { + titleText = "Invalid verification link"; + } + + return ( + + + +
+ + Fides logo + + {titleText} + + + {state === "pending" && ( + + + + Please wait while we verify your email address. + + + )} + {state === "success" && ( + + + Your email has been verified. Redirecting you to the + dashboard… + + + )} + {(state === "error" || state === "missing-params") && ( + + + {state === "missing-params" + ? "This verification link is missing required information." + : "This verification link is invalid or has expired. Please sign in and request a new one."} + + + + + + )} + + + + +
+
+ ); +}; + +export default VerifyEmail; diff --git a/src/fides/api/alembic/migrations/versions/xx_2026_05_12_1000_835de27d8c76_add_email_verification_flow.py b/src/fides/api/alembic/migrations/versions/xx_2026_05_12_1000_835de27d8c76_add_email_verification_flow.py new file mode 100644 index 00000000000..4abc8f92f24 --- /dev/null +++ b/src/fides/api/alembic/migrations/versions/xx_2026_05_12_1000_835de27d8c76_add_email_verification_flow.py @@ -0,0 +1,66 @@ +"""add email verification token table + +Revision ID: 835de27d8c76 +Revises: c8d4e2f6a9b1 +Create Date: 2026-05-12 10:00:00.000000 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "835de27d8c76" +down_revision = "c8d4e2f6a9b1" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "fides_user_email_verification", + sa.Column("id", sa.String(length=255), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=True, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=True, + ), + sa.Column("user_id", sa.String(), nullable=False), + sa.Column("hashed_token", sa.String(), nullable=False), + sa.Column("salt", sa.String(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint( + ["user_id"], + ["fidesuser.id"], + ondelete="CASCADE", + ), + ) + op.create_index( + op.f("ix_fides_user_email_verification_id"), + "fides_user_email_verification", + ["id"], + unique=False, + ) + op.create_index( + op.f("ix_fides_user_email_verification_user_id"), + "fides_user_email_verification", + ["user_id"], + unique=True, + ) + + +def downgrade(): + op.drop_index( + op.f("ix_fides_user_email_verification_user_id"), + table_name="fides_user_email_verification", + ) + op.drop_index( + op.f("ix_fides_user_email_verification_id"), + table_name="fides_user_email_verification", + ) + op.drop_table("fides_user_email_verification") diff --git a/src/fides/api/db/base.py b/src/fides/api/db/base.py index 1413101c96d..577b70a06d8 100644 --- a/src/fides/api/db/base.py +++ b/src/fides/api/db/base.py @@ -34,6 +34,9 @@ from fides.api.models.experience_notices import ExperienceNotices from fides.api.models.fides_cloud import FidesCloud from fides.api.models.fides_user import FidesUser +from fides.api.models.fides_user_email_verification import ( + FidesUserEmailVerification, +) from fides.api.models.fides_user_invite import FidesUserInvite from fides.api.models.fides_user_password_reset import FidesUserPasswordReset from fides.api.models.fides_user_permissions import FidesUserPermissions diff --git a/src/fides/api/email_templates/get_email_template.py b/src/fides/api/email_templates/get_email_template.py index 1b015b0dd18..368747cf81f 100644 --- a/src/fides/api/email_templates/get_email_template.py +++ b/src/fides/api/email_templates/get_email_template.py @@ -11,6 +11,7 @@ CONSENT_REQUEST_VERIFICATION_TEMPLATE, CORRESPONDENCE, EMAIL_ERASURE_REQUEST_FULFILLMENT, + EMAIL_VERIFICATION, EXTERNAL_USER_WELCOME, MANUAL_TASK_DIGEST, PASSWORD_RESET, @@ -180,6 +181,8 @@ def get_email_template( # pylint: disable=too-many-return-statements, too-many- return template_env.get_template(USER_INVITE) if action_type == MessagingActionType.PASSWORD_RESET: return template_env.get_template(PASSWORD_RESET) + if action_type == MessagingActionType.EMAIL_VERIFICATION: + return template_env.get_template(EMAIL_VERIFICATION) if action_type == MessagingActionType.EXTERNAL_USER_WELCOME: return template_env.get_template(EXTERNAL_USER_WELCOME) if action_type == MessagingActionType.MANUAL_TASK_DIGEST: diff --git a/src/fides/api/email_templates/template_names.py b/src/fides/api/email_templates/template_names.py index c01ded33915..c2f676856f1 100644 --- a/src/fides/api/email_templates/template_names.py +++ b/src/fides/api/email_templates/template_names.py @@ -11,6 +11,7 @@ TEST_MESSAGE_TEMPLATE = "test_message.html" USER_INVITE = "user_invite.html" PASSWORD_RESET = "password_reset.html" +EMAIL_VERIFICATION = "email_verification.html" EXTERNAL_USER_WELCOME = "external_user_welcome.html" MANUAL_TASK_DIGEST = "manual_task_digest.html" CORRESPONDENCE = "correspondence.html" diff --git a/src/fides/api/email_templates/templates/email_verification.html b/src/fides/api/email_templates/templates/email_verification.html new file mode 100644 index 00000000000..63f5c48981b --- /dev/null +++ b/src/fides/api/email_templates/templates/email_verification.html @@ -0,0 +1,13 @@ + + + + + Verify Your Email + + +
+

Please verify your Fides account email address to enable account recovery features such as self-service password reset.

+

This link will expire in {{ttl_minutes}} minutes. If you did not request this verification, you can safely ignore this email.

+
+ + diff --git a/src/fides/api/models/event_audit.py b/src/fides/api/models/event_audit.py index c019cc9846d..d9478a30f83 100644 --- a/src/fides/api/models/event_audit.py +++ b/src/fides/api/models/event_audit.py @@ -72,6 +72,11 @@ class EventAuditType(str, EnumType): password_reset_completed = "password_reset.completed" password_reset_token_expired = "password_reset.token_expired" + # Email Verification + email_verification_requested = "email_verification.requested" + email_verification_completed = "email_verification.completed" + email_verification_token_expired = "email_verification.token_expired" + # Correspondence correspondence_sent = "correspondence.sent" correspondence_delivered = "correspondence.delivered" diff --git a/src/fides/api/models/fides_user_email_verification.py b/src/fides/api/models/fides_user_email_verification.py new file mode 100644 index 00000000000..8a2225df5cb --- /dev/null +++ b/src/fides/api/models/fides_user_email_verification.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +from sqlalchemy import Column, DateTime, ForeignKey, String +from sqlalchemy.ext.declarative import declared_attr +from sqlalchemy.orm import Session + +from fides.api.cryptography.cryptographic_util import ( + generate_salt, + hash_credential_with_salt, +) +from fides.api.db.base_class import Base +from fides.config import CONFIG + + +class FidesUserEmailVerification(Base): + """Stores hashed email verification tokens for the self-service email verification flow.""" + + @declared_attr + def __tablename__(self) -> str: + return "fides_user_email_verification" + + user_id = Column( + String, + ForeignKey("fidesuser.id", ondelete="CASCADE"), + unique=True, + nullable=False, + index=True, + ) + hashed_token = Column(String, nullable=False) + salt = Column(String, nullable=False) + + @classmethod + def hash_token(cls, token: str, encoding: str = "UTF-8") -> tuple[str, str]: + """Hash a verification token with a generated salt.""" + salt = generate_salt() + hashed_token = hash_credential_with_salt( + token.encode(encoding), + salt.encode(encoding), + ) + return hashed_token, salt + + @classmethod + def create_or_replace( + cls, db: Session, *, user_id: str, token: str + ) -> FidesUserEmailVerification: + """Create a verification record, replacing any existing one for this user.""" + existing = cls.get_by(db, field="user_id", value=user_id) + if existing: + existing.delete(db) + + hashed_token, salt = cls.hash_token(token) + return super().create( + db, + data={ + "user_id": user_id, + "hashed_token": hashed_token, + "salt": salt, + }, + ) + + def token_valid(self, token: str, encoding: str = "UTF-8") -> bool: + """Verify that the provided token matches the stored hash.""" + if self.salt is None: + return False + + token_hash = hash_credential_with_salt( + token.encode(encoding), + self.salt.encode(encoding), + ) + return token_hash == self.hashed_token + + def is_expired(self) -> bool: + """Check if the verification token has expired.""" + current_time_utc = datetime.now(timezone.utc) + + if not self.created_at: + return True + + ttl_minutes = CONFIG.security.email_verification_token_ttl_minutes + expiration_datetime = self.created_at + timedelta(minutes=ttl_minutes) + return current_time_utc > expiration_datetime diff --git a/src/fides/api/schemas/messaging/messaging.py b/src/fides/api/schemas/messaging/messaging.py index d590e2758ef..d287d6b0cae 100644 --- a/src/fides/api/schemas/messaging/messaging.py +++ b/src/fides/api/schemas/messaging/messaging.py @@ -93,6 +93,7 @@ class MessagingActionType(StrEnum): PRIVACY_REQUEST_REVIEW_APPROVE = "privacy_request_review_approve" USER_INVITE = "user_invite" PASSWORD_RESET = "password_reset" + EMAIL_VERIFICATION = "email_verification" EXTERNAL_USER_WELCOME = "external_user_welcome" MANUAL_TASK_DIGEST = "manual_task_digest" TEST_MESSAGE = "test_message" @@ -226,6 +227,14 @@ class PasswordResetBodyParams(BaseModel): ttl_minutes: int +class EmailVerificationBodyParams(BaseModel): + """Body params required to send an email verification email""" + + username: str + verification_token: str + ttl_minutes: int + + class ExternalUserWelcomeBodyParams(BaseModel): """Body params required to send a welcome email to external users""" @@ -266,6 +275,7 @@ class FidesopsMessage( ErrorNotificationBodyParams, UserInviteBodyParams, PasswordResetBodyParams, + EmailVerificationBodyParams, ExternalUserWelcomeBodyParams, ] ] = None @@ -286,6 +296,7 @@ def validate_body_params_match_action_type(self) -> "FidesopsMessage": MessagingActionType.PRIVACY_REQUEST_ERROR_NOTIFICATION: ErrorNotificationBodyParams, MessagingActionType.USER_INVITE: UserInviteBodyParams, MessagingActionType.PASSWORD_RESET: PasswordResetBodyParams, + MessagingActionType.EMAIL_VERIFICATION: EmailVerificationBodyParams, MessagingActionType.EXTERNAL_USER_WELCOME: ExternalUserWelcomeBodyParams, MessagingActionType.MANUAL_TASK_DIGEST: ManualTaskDigestBodyParams, } diff --git a/src/fides/api/schemas/user.py b/src/fides/api/schemas/user.py index 1e1b9b1e75b..761d816c17d 100644 --- a/src/fides/api/schemas/user.py +++ b/src/fides/api/schemas/user.py @@ -192,6 +192,13 @@ def validate_new_password(cls, password: str) -> str: return UserCreate.validate_password(decoded_password) +class EmailVerificationConfirm(FidesSchema): + """Request body for confirming an email verification with a token""" + + username: str + token: str + + class DisabledReason(Enum): """Reasons for why a user is disabled""" diff --git a/src/fides/api/service/messaging/message_dispatch_service.py b/src/fides/api/service/messaging/message_dispatch_service.py index fefd9840baf..45f1b011ac5 100644 --- a/src/fides/api/service/messaging/message_dispatch_service.py +++ b/src/fides/api/service/messaging/message_dispatch_service.py @@ -20,6 +20,7 @@ AccessRequestCompleteBodyParams, ConsentEmailFulfillmentBodyParams, EmailForActionType, + EmailVerificationBodyParams, ErasureRequestBodyParams, ErrorNotificationBodyParams, ExternalUserWelcomeBodyParams, @@ -224,6 +225,7 @@ def dispatch_message( ErasureRequestBodyParams, UserInviteBodyParams, PasswordResetBodyParams, + EmailVerificationBodyParams, ErrorNotificationBodyParams, ExternalUserWelcomeBodyParams, ManualTaskDigestBodyParams, @@ -545,6 +547,19 @@ def _build_email( # pylint: disable=too-many-return-statements, too-many-branch } ), ) + if action_type == MessagingActionType.EMAIL_VERIFICATION: + base_template = get_email_template(action_type) + return EmailForActionType( + subject="Verify your Fides email address", + body=base_template.render( + { + "admin_ui_url": config_proxy.admin_ui.url, + "username": body_params.username, + "verification_token": body_params.verification_token, + "ttl_minutes": body_params.ttl_minutes, + } + ), + ) if action_type == MessagingActionType.EXTERNAL_USER_WELCOME: # Generate display name for personalization display_name = body_params.username diff --git a/src/fides/api/v1/endpoints/user_endpoints.py b/src/fides/api/v1/endpoints/user_endpoints.py index ad734b48e4f..16c358f6a59 100644 --- a/src/fides/api/v1/endpoints/user_endpoints.py +++ b/src/fides/api/v1/endpoints/user_endpoints.py @@ -53,6 +53,7 @@ from fides.api.schemas.oauth import AccessToken from fides.api.schemas.user import ( DisabledReason, + EmailVerificationConfirm, UserCreate, UserCreateResponse, UserForcePasswordReset, @@ -1006,3 +1007,65 @@ def reset_password_with_token( expires_at=expires_at.isoformat(), ), ) + + +@router.post( + urls.USER_REQUEST_EMAIL_VERIFICATION, + dependencies=[Security(verify_oauth_client)], + status_code=HTTP_200_OK, +) +@fides_limiter.limit(CONFIG.security.auth_rate_limit) +def request_email_verification( + *, + request: Request, + current_user: FidesUser = Depends(get_current_user), + user_service: UserService = Depends(get_user_service), +) -> Dict: + """ + Initiates a self-service email verification flow for the authenticated user. + Always returns 200; an email is only dispatched if the user is eligible and + messaging is configured. + """ + user_service.request_email_verification(current_user) + return { + "detail": "If your account is eligible, a verification email has been sent." + } + + +@router.post( + urls.USER_VERIFY_EMAIL_WITH_TOKEN, + status_code=HTTP_200_OK, + response_model=UserLoginResponse, +) +@fides_limiter.limit(CONFIG.security.auth_rate_limit) +def verify_email_with_token( + *, + request: Request, + config: FidesConfig = Depends(get_config), + data: EmailVerificationConfirm, + user_service: UserService = Depends(get_user_service), +) -> UserLoginResponse: + """ + Verifies a user's email using a valid, single-use verification token. + Auto-logs the user in on success and returns login credentials. + """ + try: + user, access_code = user_service.verify_email_with_token( + data.username, data.token + ) + except FidesError as exc: + raise HTTPException( + status_code=HTTP_400_BAD_REQUEST, + detail=str(exc), + ) from exc + + expire_minutes = config.security.oauth_access_token_expire_minutes + expires_at = datetime.now(timezone.utc) + timedelta(minutes=expire_minutes) + return UserLoginResponse( + user_data=user, + token_data=AccessToken( + access_token=access_code, + expires_in=expire_minutes * 60, + expires_at=expires_at.isoformat(), + ), + ) diff --git a/src/fides/common/urn_registry.py b/src/fides/common/urn_registry.py index d315404f2f4..51b7d9b6a8b 100644 --- a/src/fides/common/urn_registry.py +++ b/src/fides/common/urn_registry.py @@ -232,6 +232,8 @@ USER_REINVITE = "/user/{user_id}/reinvite" USER_FORGOT_PASSWORD = "/user/forgot-password" USER_RESET_PASSWORD_WITH_TOKEN = "/user/reset-password-with-token" +USER_REQUEST_EMAIL_VERIFICATION = "/user/request-email-verification" +USER_VERIFY_EMAIL_WITH_TOKEN = "/user/verify-email-with-token" SYSTEM_MANAGER = "/user/{user_id}/system-manager" SYSTEM_MANAGER_DETAIL = "/user/{user_id}/system-manager/{system_key}" diff --git a/src/fides/config/security_settings.py b/src/fides/config/security_settings.py index 44940b2bb24..0ea25fb5601 100644 --- a/src/fides/config/security_settings.py +++ b/src/fides/config/security_settings.py @@ -184,6 +184,13 @@ class SecuritySettings(FidesSettings): le=60, ) + email_verification_token_ttl_minutes: int = Field( + default=30, + description="The time in minutes for which a self-service email verification token is valid. Must be between 15 and 60.", + ge=15, + le=60, + ) + bastion_server_host: Optional[str] = Field( default=None, description="An optional field to store the bastion server host" ) diff --git a/src/fides/service/user/user_service.py b/src/fides/service/user/user_service.py index ddda3050706..f969353d639 100644 --- a/src/fides/service/user/user_service.py +++ b/src/fides/service/user/user_service.py @@ -10,9 +10,13 @@ from fides.api.models.client import ClientDetail from fides.api.models.event_audit import EventAudit, EventAuditStatus, EventAuditType from fides.api.models.fides_user import FidesUser +from fides.api.models.fides_user_email_verification import ( + FidesUserEmailVerification, +) from fides.api.models.fides_user_invite import FidesUserInvite from fides.api.models.fides_user_password_reset import FidesUserPasswordReset from fides.api.schemas.messaging.messaging import ( + EmailVerificationBodyParams, MessagingActionType, PasswordResetBodyParams, UserInviteBodyParams, @@ -349,3 +353,152 @@ def reset_password_with_token( ) return user, access_code + + def request_email_verification(self, user: FidesUser) -> None: + """ + Initiates a self-service email verification flow for the given user. + + Silently no-ops if the user has no email, is already verified, is + disabled, or messaging is not configured. This endpoint is called by + an authenticated user, so we do not need to obscure user existence, + but we do still want to avoid leaking configuration state. + """ + if not user.email_address: + logger.debug("Email verification requested for user without email address") + return + + if user.email_verified_at: + logger.debug("Email verification requested for already-verified user") + return + + if user.disabled: + logger.debug("Email verification requested for disabled user") + return + + if not self.messaging_service.is_email_invite_enabled(): + logger.debug( + "Email verification requested but email messaging is not configured" + ) + return + + verification_token = str(uuid.uuid4()) + FidesUserEmailVerification.create_or_replace( + self.db, user_id=user.id, token=verification_token + ) + + ttl_minutes = self.config.security.email_verification_token_ttl_minutes + + try: + dispatch_message( + self.db, + action_type=MessagingActionType.EMAIL_VERIFICATION, + to_identity=Identity(email=user.email_address), + service_type=self.config_proxy.notifications.notification_service_type, + message_body_params=EmailVerificationBodyParams( + username=user.username, + verification_token=verification_token, + ttl_minutes=ttl_minutes, + ), + ) + EventAudit.create( + self.db, + data={ + "event_type": EventAuditType.email_verification_requested, + "user_id": user.id, + "resource_type": "user", + "resource_identifier": user.id, + "description": "Email verification requested", + "status": EventAuditStatus.succeeded, + }, + ) + except Exception: + logger.exception("Failed to dispatch email verification email") + EventAudit.create( + self.db, + data={ + "event_type": EventAuditType.email_verification_requested, + "user_id": user.id, + "resource_type": "user", + "resource_identifier": user.id, + "description": "Email verification email dispatch failed", + "status": EventAuditStatus.failed, + }, + ) + # Don't raise — surface a generic success to the caller + + logger.info("Email verification flow initiated") + + def verify_email_with_token( + self, username: str, token: str + ) -> Tuple[FidesUser, str]: + """ + Validates an email verification token and marks the user's email as verified. + + Returns a tuple of (user, access_code) on success — the user is auto-logged-in. + + Raises: + FidesError: If the token is invalid, expired, or the user is not found. + """ + user = FidesUser.get_by(self.db, field="username", value=username) + if not user: + raise FidesError("Invalid or expired verification token.") + + matching_verification = FidesUserEmailVerification.get_by( + self.db, field="user_id", value=user.id + ) + if not matching_verification: + raise FidesError("Invalid or expired verification token.") + + # Check expiry before token_valid (O(1) vs hash computation) + if matching_verification.is_expired(): + EventAudit.create( + self.db, + data={ + "event_type": EventAuditType.email_verification_token_expired, + "user_id": user.id, + "resource_type": "user", + "resource_identifier": user.id, + "description": "Email verification token expired", + "status": EventAuditStatus.failed, + }, + ) + matching_verification.delete(self.db) + raise FidesError("Invalid or expired verification token.") + + if not matching_verification.token_valid(token): + raise FidesError("Invalid or expired verification token.") + + user.update( + self.db, + data={"email_verified_at": datetime.now(timezone.utc)}, + ) + + # Delete the verification token (single-use) + matching_verification.delete(self.db) + + EventAudit.create( + self.db, + data={ + "event_type": EventAuditType.email_verification_completed, + "user_id": user.id, + "resource_type": "user", + "resource_identifier": user.id, + "description": "Email verified via self-service verification", + "status": EventAuditStatus.succeeded, + }, + ) + + # Perform login + client = self.perform_login( + self.config.security.oauth_client_id_length_bytes, + self.config.security.oauth_client_secret_length_bytes, + user, + ) + + logger.info("Creating login access token") + access_code = client.create_access_code_jwe( + get_encryption_key(), + token_expire_minutes=self.config.security.oauth_access_token_expire_minutes, + ) + + return user, access_code diff --git a/tests/fides/ops/api/v1/endpoints/test_email_verification.py b/tests/fides/ops/api/v1/endpoints/test_email_verification.py new file mode 100644 index 00000000000..e0d4427dcfa --- /dev/null +++ b/tests/fides/ops/api/v1/endpoints/test_email_verification.py @@ -0,0 +1,442 @@ +import json +from datetime import datetime, timedelta, timezone +from typing import Generator +from unittest import mock +from uuid import uuid4 + +import pytest +from starlette.status import HTTP_200_OK, HTTP_400_BAD_REQUEST, HTTP_401_UNAUTHORIZED +from starlette.testclient import TestClient + +from fides.api.cryptography.schemas.jwt import ( + JWE_ISSUED_AT, + JWE_PAYLOAD_CLIENT_ID, + JWE_PAYLOAD_SCOPES, +) +from fides.api.models.client import ClientDetail +from fides.api.models.event_audit import EventAudit, EventAuditType +from fides.api.models.fides_user import FidesUser +from fides.api.models.fides_user_email_verification import FidesUserEmailVerification +from fides.api.models.fides_user_permissions import FidesUserPermissions +from fides.api.oauth.jwt import generate_jwe +from fides.api.oauth.roles import VIEWER +from fides.api.schemas.messaging.messaging import MessagingActionType +from fides.common.urn_registry import V1_URL_PREFIX +from fides.config import CONFIG + +REQUEST_EMAIL_VERIFICATION_URL = V1_URL_PREFIX + "/user/request-email-verification" +VERIFY_EMAIL_WITH_TOKEN_URL = V1_URL_PREFIX + "/user/verify-email-with-token" + + +def _auth_header_for(user: FidesUser, db) -> dict: + """Build an Authorization header for the given user, creating a client if needed.""" + if not user.client: + ClientDetail.create_client_and_secret( + db, + CONFIG.security.oauth_client_id_length_bytes, + CONFIG.security.oauth_client_secret_length_bytes, + scopes=[], + roles=user.permissions.roles if user.permissions else [], + user_id=user.id, + ) + db.refresh(user) + payload = { + JWE_PAYLOAD_SCOPES: [], + JWE_PAYLOAD_CLIENT_ID: user.client.id, + JWE_ISSUED_AT: datetime.now().isoformat(), + } + jwe = generate_jwe(json.dumps(payload), CONFIG.security.app_encryption_key) + return {"Authorization": "Bearer " + jwe} + + +class TestRequestEmailVerification: + @pytest.fixture(scope="function") + def unverified_user(self, db) -> Generator: + """User with an email but unverified.""" + user = FidesUser.create( + db=db, + data={ + "username": "unverified_user", + "email_address": "unverified@example.com", + "password": "Testpassword1!", + "disabled": False, + }, + ) + FidesUserPermissions.create( + db=db, + data={"user_id": user.id, "roles": [VIEWER]}, + ) + yield user + try: + user.delete(db) + except Exception: + pass + + @pytest.fixture(scope="function") + def verified_user(self, db) -> Generator: + """User with a verified email.""" + user = FidesUser.create( + db=db, + data={ + "username": "already_verified", + "email_address": "verified@example.com", + "password": "Testpassword1!", + "disabled": False, + }, + ) + user.email_verified_at = datetime.now(timezone.utc) + user.save(db) + FidesUserPermissions.create( + db=db, + data={"user_id": user.id, "roles": [VIEWER]}, + ) + yield user + try: + user.delete(db) + except Exception: + pass + + @pytest.fixture(scope="function") + def emailless_user(self, db) -> Generator: + """User with no email address.""" + user = FidesUser.create( + db=db, + data={ + "username": "no_email_user", + "password": "Testpassword1!", + "disabled": False, + }, + ) + FidesUserPermissions.create( + db=db, + data={"user_id": user.id, "roles": [VIEWER]}, + ) + yield user + try: + user.delete(db) + except Exception: + pass + + def test_request_email_verification_unauthenticated_rejected( + self, api_client: TestClient + ): + """Endpoint requires authentication.""" + response = api_client.post(REQUEST_EMAIL_VERIFICATION_URL) + assert response.status_code in (HTTP_401_UNAUTHORIZED, 403) + + def test_request_email_verification_dispatches_for_unverified( + self, db, api_client: TestClient, unverified_user + ): + """Authenticated unverified user with messaging enabled gets an email.""" + headers = _auth_header_for(unverified_user, db) + with ( + mock.patch( + "fides.service.messaging.messaging_service.MessagingService.is_email_invite_enabled", + return_value=True, + ), + mock.patch( + "fides.service.user.user_service.dispatch_message" + ) as mock_dispatch, + ): + response = api_client.post(REQUEST_EMAIL_VERIFICATION_URL, headers=headers) + assert response.status_code == HTTP_200_OK + assert "verification email" in response.json()["detail"] + mock_dispatch.assert_called_once() + call_kwargs = mock_dispatch.call_args + assert call_kwargs[1]["action_type"] == MessagingActionType.EMAIL_VERIFICATION + + def test_request_email_verification_skipped_for_already_verified( + self, db, api_client: TestClient, verified_user + ): + """Already-verified user gets a 200 but no email dispatched.""" + headers = _auth_header_for(verified_user, db) + with ( + mock.patch( + "fides.service.messaging.messaging_service.MessagingService.is_email_invite_enabled", + return_value=True, + ), + mock.patch( + "fides.service.user.user_service.dispatch_message" + ) as mock_dispatch, + ): + response = api_client.post(REQUEST_EMAIL_VERIFICATION_URL, headers=headers) + assert response.status_code == HTTP_200_OK + mock_dispatch.assert_not_called() + + def test_request_email_verification_skipped_for_no_email( + self, db, api_client: TestClient, emailless_user + ): + """User without an email gets a 200 but no email dispatched.""" + headers = _auth_header_for(emailless_user, db) + with ( + mock.patch( + "fides.service.messaging.messaging_service.MessagingService.is_email_invite_enabled", + return_value=True, + ), + mock.patch( + "fides.service.user.user_service.dispatch_message" + ) as mock_dispatch, + ): + response = api_client.post(REQUEST_EMAIL_VERIFICATION_URL, headers=headers) + assert response.status_code == HTTP_200_OK + mock_dispatch.assert_not_called() + + def test_request_email_verification_skipped_when_messaging_unconfigured( + self, db, api_client: TestClient, unverified_user + ): + """Messaging disabled → no email dispatched.""" + headers = _auth_header_for(unverified_user, db) + with ( + mock.patch( + "fides.service.messaging.messaging_service.MessagingService.is_email_invite_enabled", + return_value=False, + ), + mock.patch( + "fides.service.user.user_service.dispatch_message" + ) as mock_dispatch, + ): + response = api_client.post(REQUEST_EMAIL_VERIFICATION_URL, headers=headers) + assert response.status_code == HTTP_200_OK + mock_dispatch.assert_not_called() + + def test_request_email_verification_creates_token( + self, db, api_client: TestClient, unverified_user + ): + """A verification token record is created in the DB.""" + headers = _auth_header_for(unverified_user, db) + with ( + mock.patch( + "fides.service.messaging.messaging_service.MessagingService.is_email_invite_enabled", + return_value=True, + ), + mock.patch("fides.service.user.user_service.dispatch_message"), + ): + api_client.post(REQUEST_EMAIL_VERIFICATION_URL, headers=headers) + + record = FidesUserEmailVerification.get_by( + db, field="user_id", value=unverified_user.id + ) + assert record is not None + record.delete(db) + + def test_request_email_verification_skipped_for_disabled_user( + self, db, api_client: TestClient, unverified_user + ): + """Disabled user gets a 200 but no email dispatched.""" + unverified_user.disabled = True + unverified_user.save(db) + headers = _auth_header_for(unverified_user, db) + with ( + mock.patch( + "fides.service.messaging.messaging_service.MessagingService.is_email_invite_enabled", + return_value=True, + ), + mock.patch( + "fides.service.user.user_service.dispatch_message" + ) as mock_dispatch, + ): + response = api_client.post(REQUEST_EMAIL_VERIFICATION_URL, headers=headers) + assert response.status_code == HTTP_200_OK + mock_dispatch.assert_not_called() + + def test_request_email_verification_audits_failure_on_dispatch_exception( + self, db, api_client: TestClient, unverified_user + ): + """A dispatch exception is swallowed; endpoint still returns 200 and a + failed audit event is written.""" + headers = _auth_header_for(unverified_user, db) + with ( + mock.patch( + "fides.service.messaging.messaging_service.MessagingService.is_email_invite_enabled", + return_value=True, + ), + mock.patch( + "fides.service.user.user_service.dispatch_message", + side_effect=Exception("boom"), + ), + ): + response = api_client.post(REQUEST_EMAIL_VERIFICATION_URL, headers=headers) + assert response.status_code == HTTP_200_OK + + failed = ( + db.query(EventAudit) + .filter_by( + event_type=EventAuditType.email_verification_requested.value, + user_id=unverified_user.id, + status="failed", + ) + .first() + ) + assert failed is not None + + def test_request_email_verification_replaces_existing_token( + self, db, api_client: TestClient, unverified_user + ): + """Requesting a new verification replaces the old token.""" + token1 = str(uuid4()) + FidesUserEmailVerification.create_or_replace( + db, user_id=unverified_user.id, token=token1 + ) + + headers = _auth_header_for(unverified_user, db) + with ( + mock.patch( + "fides.service.messaging.messaging_service.MessagingService.is_email_invite_enabled", + return_value=True, + ), + mock.patch("fides.service.user.user_service.dispatch_message"), + ): + api_client.post(REQUEST_EMAIL_VERIFICATION_URL, headers=headers) + + records = ( + db.query(FidesUserEmailVerification) + .filter_by(user_id=unverified_user.id) + .all() + ) + assert len(records) == 1 + assert not records[0].token_valid(token1) + records[0].delete(db) + + +class TestVerifyEmailWithToken: + @pytest.fixture(scope="function") + def user_with_verification_token(self, db) -> Generator: + """Create an unverified user with a valid verification token.""" + user = FidesUser.create( + db=db, + data={ + "username": "verify_target", + "email_address": "verify_target@example.com", + "password": "Testpassword1!", + "disabled": False, + }, + ) + FidesUserPermissions.create( + db=db, + data={"user_id": user.id, "roles": [VIEWER]}, + ) + + token = str(uuid4()) + FidesUserEmailVerification.create_or_replace(db, user_id=user.id, token=token) + + yield user, token + try: + user.delete(db) + except Exception: + pass + + def test_verify_email_with_valid_token( + self, db, api_client: TestClient, user_with_verification_token + ): + """A valid token verifies the email and returns a login response.""" + user, token = user_with_verification_token + assert user.email_verified_at is None + + response = api_client.post( + VERIFY_EMAIL_WITH_TOKEN_URL, + json={"username": "verify_target", "token": token}, + ) + assert response.status_code == HTTP_200_OK + data = response.json() + assert "user_data" in data + assert "token_data" in data + assert data["user_data"]["username"] == "verify_target" + assert data["user_data"]["email_verified_at"] is not None + + db.refresh(user) + assert user.email_verified_at is not None + + # Single-use: token deleted + record = FidesUserEmailVerification.get_by(db, field="user_id", value=user.id) + assert record is None + + # Completion audit event recorded + completed = ( + db.query(EventAudit) + .filter_by( + event_type=EventAuditType.email_verification_completed.value, + user_id=user.id, + ) + .first() + ) + assert completed is not None + + def test_verify_email_with_invalid_token(self, api_client: TestClient): + """Unknown username/token returns a generic 400.""" + response = api_client.post( + VERIFY_EMAIL_WITH_TOKEN_URL, + json={"username": "nonexistent_user", "token": "invalid-token"}, + ) + assert response.status_code == HTTP_400_BAD_REQUEST + assert "Invalid or expired" in response.json()["detail"] + + def test_verify_email_with_no_verification_record(self, db, api_client: TestClient): + """User exists but has no verification record → generic 400 (no enumeration).""" + user = FidesUser.create( + db=db, + data={ + "username": "no_record_user", + "email_address": "no_record@example.com", + "password": "Testpassword1!", + "disabled": False, + }, + ) + FidesUserPermissions.create( + db=db, + data={"user_id": user.id, "roles": [VIEWER]}, + ) + try: + response = api_client.post( + VERIFY_EMAIL_WITH_TOKEN_URL, + json={"username": "no_record_user", "token": "anything"}, + ) + assert response.status_code == HTTP_400_BAD_REQUEST + assert "Invalid or expired" in response.json()["detail"] + finally: + user.delete(db) + + def test_verify_email_with_wrong_plaintext_token( + self, db, api_client: TestClient, user_with_verification_token + ): + """User and verification record exist, but the plaintext token doesn't + match the stored hash → generic 400.""" + _user, _real_token = user_with_verification_token + response = api_client.post( + VERIFY_EMAIL_WITH_TOKEN_URL, + json={"username": "verify_target", "token": "completely-wrong-token"}, + ) + assert response.status_code == HTTP_400_BAD_REQUEST + assert "Invalid or expired" in response.json()["detail"] + + def test_verify_email_with_expired_token( + self, db, api_client: TestClient, user_with_verification_token + ): + """An expired token returns a generic 400 and writes an audit event.""" + user, token = user_with_verification_token + + record = FidesUserEmailVerification.get_by(db, field="user_id", value=user.id) + record.created_at = datetime.now(timezone.utc) - timedelta(hours=24) + record.save(db) + + response = api_client.post( + VERIFY_EMAIL_WITH_TOKEN_URL, + json={"username": "verify_target", "token": token}, + ) + assert response.status_code == HTTP_400_BAD_REQUEST + assert "Invalid or expired" in response.json()["detail"] + + expired_event = ( + db.query(EventAudit) + .filter_by( + event_type=EventAuditType.email_verification_token_expired.value, + user_id=user.id, + ) + .first() + ) + assert expired_event is not None + + # Expired token row deleted + record_after = FidesUserEmailVerification.get_by( + db, field="user_id", value=user.id + ) + assert record_after is None