From 5d0882778f0dddd86429ca947e2427ef7d2f5e6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Tue, 28 Jul 2026 10:15:25 +0200 Subject: [PATCH] Gate password sign-in on email verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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é --- apps/console/src/_locales/en-US.json | 11 ++ apps/console/src/_locales/fr-FR.json | 28 +++ .../iam/auth/ResendVerificationEmailPage.tsx | 180 ++++++++++++++++++ .../iam/auth/sign-in/PasswordSignInPage.tsx | 15 +- apps/console/src/routes.tsx | 6 + .../charts/probo/templates/deployment.yaml | 2 + contrib/helm/charts/probo/values.yaml | 2 + e2e/console/email_verification_test.go | 111 +++++++++++ e2e/internal/testutil/client.go | 149 ++++++++++++--- pkg/bootstrap/builder.go | 1 + pkg/bootstrap/builder_test.go | 3 + pkg/iam/account_service.go | 54 +++++- pkg/iam/auth_service.go | 2 +- pkg/iam/errors.go | 10 + pkg/iam/service.go | 71 +++---- pkg/probod/probod.go | 2 + pkg/probodconfig/auth_config.go | 1 + .../api/connect/v1/graphql/session.graphql | 11 ++ .../api/connect/v1/session_resolvers.go | 22 +++ 19 files changed, 621 insertions(+), 60 deletions(-) create mode 100644 apps/console/src/pages/iam/auth/ResendVerificationEmailPage.tsx create mode 100644 e2e/console/email_verification_test.go diff --git a/apps/console/src/_locales/en-US.json b/apps/console/src/_locales/en-US.json index aabda8013..3eabecb88 100644 --- a/apps/console/src/_locales/en-US.json +++ b/apps/console/src/_locales/en-US.json @@ -1287,6 +1287,17 @@ "sent": { "title": "Check your email", "description": "We've sent password reset instructions to your email address", "didNotReceive": "Didn't receive the email?" }, "actions": { "tryAgain": "Try again", "backToLogin": "Back to login", "sendingInstructions": "Sending instructions...", "sendInstructions": "Send reset instructions" } }, + "resendVerificationEmailPage": { + "pageTitle": "Verify your email", + "title": "Verify your email", + "description": "Your email address has not been verified yet. Enter your email and we'll send you a new verification link", + "alreadyVerified": "Already verified?", + "messages": { "verificationSent": "Verification email sent" }, + "errors": { "requestFailed": "Request failed", "sendVerification": "Failed to send verification email" }, + "fields": { "email": "Email", "emailPlaceholder": "name@example.com" }, + "sent": { "title": "Check your email", "description": "We've sent a verification link to your email address", "didNotReceive": "Didn't receive the email?" }, + "actions": { "tryAgain": "Try again", "backToLogin": "Back to login", "sendingVerification": "Sending verification email...", "sendVerification": "Send verification email" } + }, "resetPasswordPage": { "pageTitle": "Reset password", "title": "Reset password", diff --git a/apps/console/src/_locales/fr-FR.json b/apps/console/src/_locales/fr-FR.json index 3551a4b7c..d54de3541 100644 --- a/apps/console/src/_locales/fr-FR.json +++ b/apps/console/src/_locales/fr-FR.json @@ -2072,6 +2072,34 @@ "sendInstructions": "Envoyer les instructions de réinitialisation" } }, + "resendVerificationEmailPage": { + "pageTitle": "Vérifiez votre e-mail", + "title": "Vérifiez votre e-mail", + "description": "Votre adresse e-mail n’a pas encore été vérifiée. Saisissez votre e-mail et nous vous enverrons un nouveau lien de vérification", + "alreadyVerified": "Déjà vérifié ?", + "messages": { + "verificationSent": "E-mail de vérification envoyé" + }, + "errors": { + "requestFailed": "La requête a échoué", + "sendVerification": "Échec de l’envoi de l’e-mail de vérification" + }, + "fields": { + "email": "E-mail", + "emailPlaceholder": "name@example.com" + }, + "sent": { + "title": "Consultez votre e-mail", + "description": "Nous avons envoyé un lien de vérification à votre adresse e-mail", + "didNotReceive": "Vous n’avez pas reçu l’e-mail ?" + }, + "actions": { + "tryAgain": "Réessayer", + "backToLogin": "Retour à la connexion", + "sendingVerification": "Envoi de l’e-mail de vérification...", + "sendVerification": "Envoyer l’e-mail de vérification" + } + }, "resetPasswordPage": { "pageTitle": "Réinitialiser le mot de passe", "title": "Réinitialiser le mot de passe", diff --git a/apps/console/src/pages/iam/auth/ResendVerificationEmailPage.tsx b/apps/console/src/pages/iam/auth/ResendVerificationEmailPage.tsx new file mode 100644 index 000000000..f41d8827c --- /dev/null +++ b/apps/console/src/pages/iam/auth/ResendVerificationEmailPage.tsx @@ -0,0 +1,180 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { formatError } from "@probo/helpers"; +import { usePageTitle } from "@probo/hooks"; +import { Button, Field, useToast } from "@probo/ui"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useMutation } from "react-relay"; +import { Link, useSearchParams } from "react-router"; +import { graphql } from "relay-runtime"; +import { z } from "zod"; + +import type { ResendVerificationEmailPageMutation } from "#/__generated__/iam/ResendVerificationEmailPageMutation.graphql"; +import { useFormWithSchema } from "#/hooks/useFormWithSchema"; + +const resendVerificationEmailMutation = graphql` + mutation ResendVerificationEmailPageMutation($input: ResendVerificationEmailInput!) { + resendVerificationEmail(input: $input) { + success + } + } +`; + +const schema = z.object({ + email: z.email(), +}); + +export default function ResendVerificationEmailPage() { + const { toast } = useToast(); + const { t } = useTranslation(); + const [searchParams] = useSearchParams(); + + usePageTitle(t("resendVerificationEmailPage.pageTitle")); + + const [emailSent, setEmailSent] = useState(); + const { register, handleSubmit, formState } = useFormWithSchema(schema, { + defaultValues: { + email: searchParams.get("email") ?? "", + }, + }); + + const [resendVerificationEmail] = useMutation( + resendVerificationEmailMutation, + ); + + const onSubmit = handleSubmit(({ email }) => { + resendVerificationEmail({ + variables: { + input: { email }, + }, + onError: (e: Error) => { + toast({ + title: t("resendVerificationEmailPage.errors.requestFailed"), + description: e.message, + variant: "error", + }); + }, + onCompleted: (_, e) => { + if (e) { + toast({ + title: t("resendVerificationEmailPage.errors.requestFailed"), + description: formatError( + t("resendVerificationEmailPage.errors.sendVerification"), + e, + ), + variant: "error", + }); + return; + } + + toast({ + title: t("common.success"), + description: t("resendVerificationEmailPage.messages.verificationSent"), + variant: "success", + }); + setEmailSent(true); + }, + }); + }); + + return emailSent + ? ( +
+
+

{t("resendVerificationEmailPage.sent.title")}

+

+ {t("resendVerificationEmailPage.sent.description")} +

+
+ +
+

+ {t("resendVerificationEmailPage.sent.didNotReceive")} + {" "} + +

+
+ +
+

+ {t("resendVerificationEmailPage.alreadyVerified")} + {" "} + + {t("resendVerificationEmailPage.actions.backToLogin")} + +

+
+
+ ) + : ( +
+
+

{t("resendVerificationEmailPage.title")}

+

+ {t("resendVerificationEmailPage.description")} +

+
+ +
void onSubmit(e)} className="space-y-4"> + + + + + +
+

+ {t("resendVerificationEmailPage.alreadyVerified")} + {" "} + + {t("resendVerificationEmailPage.actions.backToLogin")} + +

+
+
+ ); +} diff --git a/apps/console/src/pages/iam/auth/sign-in/PasswordSignInPage.tsx b/apps/console/src/pages/iam/auth/sign-in/PasswordSignInPage.tsx index 98523ce13..aeb0d08dd 100644 --- a/apps/console/src/pages/iam/auth/sign-in/PasswordSignInPage.tsx +++ b/apps/console/src/pages/iam/auth/sign-in/PasswordSignInPage.tsx @@ -18,12 +18,12 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import { formatError } from "@probo/helpers"; +import { formatError, type GraphQLError } from "@probo/helpers"; import { Button, Field, IconChevronLeft, useToast } from "@probo/ui"; import type { FormEventHandler } from "react"; import { useTranslation } from "react-i18next"; import { useMutation } from "react-relay"; -import { Link, matchPath, useLocation } from "react-router"; +import { Link, matchPath, useLocation, useNavigate } from "react-router"; import { graphql } from "relay-runtime"; import type { PasswordSignInPageMutation } from "#/__generated__/iam/PasswordSignInPageMutation.graphql"; @@ -41,6 +41,7 @@ const signInMutation = graphql` export default function PasswordSignInPage() { const location = useLocation(); + const navigate = useNavigate(); const postAuthRedirectUrl = usePostAuthRedirectUrl(); const { t } = useTranslation(); @@ -73,6 +74,16 @@ export default function PasswordSignInPage() { }, onCompleted: (_, error) => { if (error) { + const errors = Array.isArray(error) ? error : [error]; + const emailNotVerified = errors.some( + e => (e as GraphQLError).extensions?.code === "EMAIL_NOT_VERIFIED", + ); + if (emailNotVerified) { + const search = new URLSearchParams({ email: emailValue }).toString(); + void navigate(`/auth/resend-verification-email?${search}`); + return; + } + toast({ title: t("common.error"), description: formatError( diff --git a/apps/console/src/routes.tsx b/apps/console/src/routes.tsx index 54ce82248..c9f4515e8 100644 --- a/apps/console/src/routes.tsx +++ b/apps/console/src/routes.tsx @@ -85,6 +85,12 @@ const routes = [ path: "verify-email", Component: lazy(() => import("./pages/iam/auth/VerifyEmailPage")), }, + { + path: "resend-verification-email", + Component: lazy( + () => import("./pages/iam/auth/ResendVerificationEmailPage"), + ), + }, { path: "activate-account", Component: lazy( diff --git a/contrib/helm/charts/probo/templates/deployment.yaml b/contrib/helm/charts/probo/templates/deployment.yaml index 139b67e15..edc800bd3 100644 --- a/contrib/helm/charts/probo/templates/deployment.yaml +++ b/contrib/helm/charts/probo/templates/deployment.yaml @@ -122,6 +122,8 @@ spec: value: {{ .Values.probo.auth.disableSignup | quote }} - name: PROBOD_AUTH_INVITATION_TOKEN_VALIDITY value: {{ .Values.probo.auth.invitationTokenValidity | quote }} + - name: PROBOD_AUTH_EMAIL_CONFIRMATION_TOKEN_VALIDITY + value: {{ .Values.probo.auth.emailConfirmationTokenValidity | quote }} - name: PROBOD_AUTH_COOKIE_NAME value: {{ .Values.probo.auth.cookieName | quote }} - name: PROBOD_AUTH_COOKIE_DOMAIN diff --git a/contrib/helm/charts/probo/values.yaml b/contrib/helm/charts/probo/values.yaml index 7cb0ad6e1..f55f26d3a 100644 --- a/contrib/helm/charts/probo/values.yaml +++ b/contrib/helm/charts/probo/values.yaml @@ -225,6 +225,8 @@ probo: auth: disableSignup: false invitationTokenValidity: 3600 + # Email confirmation token validity in seconds (default: 3600 = 1 hour) + emailConfirmationTokenValidity: 3600 cookieName: "SSID" cookieDomain: "probo.example.com" # REQUIRED: Generate with openssl rand -base64 32 diff --git a/e2e/console/email_verification_test.go b/e2e/console/email_verification_test.go new file mode 100644 index 000000000..e19d16edb --- /dev/null +++ b/e2e/console/email_verification_test.go @@ -0,0 +1,111 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package console_test + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/e2e/internal/testutil" +) + +func TestEmailVerification_PasswordSignInRequiresVerifiedEmail(t *testing.T) { + t.Parallel() + + client := testutil.NewUnauthenticatedClient(t) + + uniqueID := fmt.Sprintf("%d", time.Now().UnixNano()) + email := fmt.Sprintf("unverified-%s@e2e.probo.test", uniqueID) + password := "TestPassword123!" + fullName := fmt.Sprintf("Unverified User %s", uniqueID) + + const signUpMutation = ` + mutation($input: SignUpInput!) { + signUp(input: $input) { + identity { id } + } + } + ` + + var signUpResult struct { + SignUp struct { + Identity struct { + ID string `json:"id"` + } `json:"identity"` + } `json:"signUp"` + } + + err := client.ExecuteConnect(signUpMutation, map[string]any{ + "input": map[string]any{ + "email": email, + "password": password, + "fullName": fullName, + }, + }, &signUpResult) + require.NoError(t, err, "signUp should succeed for unverified identity") + require.NotEmpty(t, signUpResult.SignUp.Identity.ID) + + client.SignOut() + + err = client.SignIn(email, password) + testutil.RequireErrorCode(t, err, "EMAIL_NOT_VERIFIED") + + client.ResendVerificationEmail(email) + + token := client.GetEmailConfirmationToken(email) + require.NotEmpty(t, token) + + const verifyMutation = ` + mutation($input: VerifyEmailInput!) { + verifyEmail(input: $input) { + success + } + } + ` + + var verifyResult struct { + VerifyEmail struct { + Success bool `json:"success"` + } `json:"verifyEmail"` + } + + err = client.ExecuteConnect(verifyMutation, map[string]any{ + "input": map[string]any{ + "token": token, + }, + }, &verifyResult) + require.NoError(t, err, "verifyEmail should succeed") + assert.True(t, verifyResult.VerifyEmail.Success) + + err = client.SignIn(email, password) + require.NoError(t, err, "signIn should succeed after email verification") +} + +func TestEmailVerification_ResendIsEnumerationSafe(t *testing.T) { + t.Parallel() + + client := testutil.NewUnauthenticatedClient(t) + + client.ResendVerificationEmail(fmt.Sprintf("missing-%d@e2e.probo.test", time.Now().UnixNano())) +} diff --git a/e2e/internal/testutil/client.go b/e2e/internal/testutil/client.go index f197f5365..bcc5e1168 100644 --- a/e2e/internal/testutil/client.go +++ b/e2e/internal/testutil/client.go @@ -127,6 +127,9 @@ func (c *Client) setupTestUser() { // Sign up c.userID = c.signUp(email, password, fullName) + // Confirm email so password sign-in works for later re-authentication. + c.verifyEmail(c.GetEmailConfirmationToken(email)) + // Create organization (this makes the user an OWNER) orgName := fmt.Sprintf("Test Org %s", uniqueID) c.organizationID = c.createOrganization(orgName) @@ -197,28 +200,7 @@ func (c *Client) signUp(email, password, fullName string) gid.GID { } func (c *Client) signIn(email string, password string) { - const query = ` - mutation($input: SignInInput!) { - signIn(input: $input) { - identity { id } - } - } - ` - - var result struct { - SignIn struct { - Identity struct { - ID string `json:"id"` - } `json:"identity"` - } `json:"signIn"` - } - - err := c.ExecuteConnect(query, map[string]any{ - "input": map[string]any{ - "email": email, - "password": password, - }, - }, &result) + err := c.SignIn(email, password) require.NoError(c.T, err, "signIn mutation failed") } @@ -417,6 +399,129 @@ func (c *Client) getActivationToken(email string) string { return c.pollForLinkToken(fmt.Sprintf("to:%s subject:\"Invitation to join\"", email)) } +func (c *Client) GetEmailConfirmationToken(email string) string { + c.T.Helper() + + return c.pollForLinkToken(fmt.Sprintf("to:%s subject:\"Confirm your email address\"", email)) +} + +func (c *Client) verifyEmail(token string) { + const query = ` + mutation($input: VerifyEmailInput!) { + verifyEmail(input: $input) { + success + } + } + ` + + var result struct { + VerifyEmail struct { + Success bool `json:"success"` + } `json:"verifyEmail"` + } + + err := c.ExecuteConnect(query, map[string]any{ + "input": map[string]any{ + "token": token, + }, + }, &result) + require.NoError(c.T, err, "verifyEmail mutation failed") + require.True(c.T, result.VerifyEmail.Success, "verifyEmail should succeed") +} + +func (c *Client) ResendVerificationEmail(email string) { + c.T.Helper() + + const query = ` + mutation($input: ResendVerificationEmailInput!) { + resendVerificationEmail(input: $input) { + success + } + } + ` + + var result struct { + ResendVerificationEmail struct { + Success bool `json:"success"` + } `json:"resendVerificationEmail"` + } + + err := c.ExecuteConnect(query, map[string]any{ + "input": map[string]any{ + "email": email, + }, + }, &result) + require.NoError(c.T, err, "resendVerificationEmail mutation failed") + require.True(c.T, result.ResendVerificationEmail.Success, "resendVerificationEmail should succeed") +} + +func (c *Client) SignOut() { + c.T.Helper() + + const query = ` + mutation { + signOut { + success + } + } + ` + + var result struct { + SignOut struct { + Success bool `json:"success"` + } `json:"signOut"` + } + + err := c.ExecuteConnect(query, nil, &result) + require.NoError(c.T, err, "signOut mutation failed") +} + +// SignIn attempts password sign-in and returns any GraphQL/transport error. +func (c *Client) SignIn(email string, password string) error { + c.T.Helper() + + const query = ` + mutation($input: SignInInput!) { + signIn(input: $input) { + identity { id } + } + } + ` + + var result struct { + SignIn struct { + Identity struct { + ID string `json:"id"` + } `json:"identity"` + } `json:"signIn"` + } + + return c.ExecuteConnect(query, map[string]any{ + "input": map[string]any{ + "email": email, + "password": password, + }, + }, &result) +} + +// NewUnauthenticatedClient returns a Connect client with no session cookie. +func NewUnauthenticatedClient(t testing.TB) *Client { + t.Helper() + + jar, err := cookiejar.New(nil) + require.NoError(t, err, "cannot create cookie jar") + + return &Client{ + T: t, + baseURL: GetBaseURL(), + mailpitBaseURL: GetMailpitBaseURL(), + httpClient: &http.Client{ + Jar: jar, + Timeout: 30 * time.Second, + }, + } +} + // pollForLinkToken polls mailpit for a message matching searchQuery and // returns the first "token" query parameter found among its links. func (c *Client) pollForLinkToken(searchQuery string) string { diff --git a/pkg/bootstrap/builder.go b/pkg/bootstrap/builder.go index 48a2925b4..18fe63918 100644 --- a/pkg/bootstrap/builder.go +++ b/pkg/bootstrap/builder.go @@ -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"), diff --git a/pkg/bootstrap/builder_test.go b/pkg/bootstrap/builder_test.go index 32b23e1dd..a6c65a102 100644 --- a/pkg/bootstrap/builder_test.go +++ b/pkg/bootstrap/builder_test.go @@ -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 diff --git a/pkg/iam/account_service.go b/pkg/iam/account_service.go index 4a493f0b7..2ccd08cd6 100644 --- a/pkg/iam/account_service.go +++ b/pkg/iam/account_service.go @@ -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, diff --git a/pkg/iam/auth_service.go b/pkg/iam/auth_service.go index 2e1bd0717..df9c24479 100644 --- a/pkg/iam/auth_service.go +++ b/pkg/iam/auth_service.go @@ -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 { diff --git a/pkg/iam/errors.go b/pkg/iam/errors.go index 50845d836..34f3a0971 100644 --- a/pkg/iam/errors.go +++ b/pkg/iam/errors.go @@ -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 { diff --git a/pkg/iam/service.go b/pkg/iam/service.go index d55f9d542..11221e856 100644 --- a/pkg/iam/service.go +++ b/pkg/iam/service.go @@ -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) diff --git a/pkg/probod/probod.go b/pkg/probod/probod.go index 0d8f193d5..d44c4cb8a 100644 --- a/pkg/probod/probod.go +++ b/pkg/probod/probod.go @@ -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, diff --git a/pkg/probodconfig/auth_config.go b/pkg/probodconfig/auth_config.go index 44bc3e9a8..344cd7819 100644 --- a/pkg/probodconfig/auth_config.go +++ b/pkg/probodconfig/auth_config.go @@ -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"` diff --git a/pkg/server/api/connect/v1/graphql/session.graphql b/pkg/server/api/connect/v1/graphql/session.graphql index d04298e1d..62775b77d 100644 --- a/pkg/server/api/connect/v1/graphql/session.graphql +++ b/pkg/server/api/connect/v1/graphql/session.graphql @@ -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! } diff --git a/pkg/server/api/connect/v1/session_resolvers.go b/pkg/server/api/connect/v1/session_resolvers.go index 405c65ef1..ce320caea 100644 --- a/pkg/server/api/connect/v1/session_resolvers.go +++ b/pkg/server/api/connect/v1/session_resolvers.go @@ -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)