Gate password sign-in on email verification

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é <emile@probo.com>
This commit is contained in:
Émile Ré
2026-07-28 10:15:25 +02:00
parent bb9fb22913
commit 5d0882778f
19 changed files with 621 additions and 60 deletions

View File

@@ -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?" }, "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" } "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": { "resetPasswordPage": {
"pageTitle": "Reset password", "pageTitle": "Reset password",
"title": "Reset password", "title": "Reset password",

View File

@@ -2072,6 +2072,34 @@
"sendInstructions": "Envoyer les instructions de réinitialisation" "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": { "resetPasswordPage": {
"pageTitle": "Réinitialiser le mot de passe", "pageTitle": "Réinitialiser le mot de passe",
"title": "Réinitialiser le mot de passe", "title": "Réinitialiser le mot de passe",

View File

@@ -0,0 +1,180 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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<boolean>();
const { register, handleSubmit, formState } = useFormWithSchema(schema, {
defaultValues: {
email: searchParams.get("email") ?? "",
},
});
const [resendVerificationEmail] = useMutation<ResendVerificationEmailPageMutation>(
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
? (
<div className="space-y-6 w-full max-w-md mx-auto pt-8">
<div className="space-y-2 text-center">
<h1 className="text-3xl font-bold">{t("resendVerificationEmailPage.sent.title")}</h1>
<p className="text-txt-tertiary">
{t("resendVerificationEmailPage.sent.description")}
</p>
</div>
<div className="text-center">
<p className="text-sm text-txt-tertiary">
{t("resendVerificationEmailPage.sent.didNotReceive")}
{" "}
<button
onClick={() => setEmailSent(false)}
className="underline text-txt-primary hover:text-txt-secondary"
>
{t("resendVerificationEmailPage.actions.tryAgain")}
</button>
</p>
</div>
<div className="text-center">
<p className="text-sm text-txt-tertiary">
{t("resendVerificationEmailPage.alreadyVerified")}
{" "}
<Link
to="/auth/login"
className="underline text-txt-primary hover:text-txt-secondary"
>
{t("resendVerificationEmailPage.actions.backToLogin")}
</Link>
</p>
</div>
</div>
)
: (
<div className="space-y-6 w-full max-w-md mx-auto pt-8">
<div className="space-y-2 text-center">
<h1 className="text-3xl font-bold">{t("resendVerificationEmailPage.title")}</h1>
<p className="text-txt-tertiary">
{t("resendVerificationEmailPage.description")}
</p>
</div>
<form onSubmit={e => void onSubmit(e)} className="space-y-4">
<Field
label={t("resendVerificationEmailPage.fields.email")}
type="email"
placeholder={t("resendVerificationEmailPage.fields.emailPlaceholder")}
{...register("email")}
required
error={formState.errors.email?.message}
/>
<Button
type="submit"
className="w-xs h-10 mx-auto mt-6"
disabled={formState.isSubmitting}
>
{formState.isSubmitting
? t("resendVerificationEmailPage.actions.sendingVerification")
: t("resendVerificationEmailPage.actions.sendVerification")}
</Button>
</form>
<div className="text-center">
<p className="text-sm text-txt-tertiary">
{t("resendVerificationEmailPage.alreadyVerified")}
{" "}
<Link
to="/auth/login"
className="underline text-txt-primary hover:text-txt-secondary"
>
{t("resendVerificationEmailPage.actions.backToLogin")}
</Link>
</p>
</div>
</div>
);
}

View File

@@ -18,12 +18,12 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE. // SOFTWARE.
import { formatError } from "@probo/helpers"; import { formatError, type GraphQLError } from "@probo/helpers";
import { Button, Field, IconChevronLeft, useToast } from "@probo/ui"; import { Button, Field, IconChevronLeft, useToast } from "@probo/ui";
import type { FormEventHandler } from "react"; import type { FormEventHandler } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation } from "react-relay"; 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 { graphql } from "relay-runtime";
import type { PasswordSignInPageMutation } from "#/__generated__/iam/PasswordSignInPageMutation.graphql"; import type { PasswordSignInPageMutation } from "#/__generated__/iam/PasswordSignInPageMutation.graphql";
@@ -41,6 +41,7 @@ const signInMutation = graphql`
export default function PasswordSignInPage() { export default function PasswordSignInPage() {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate();
const postAuthRedirectUrl = usePostAuthRedirectUrl(); const postAuthRedirectUrl = usePostAuthRedirectUrl();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -73,6 +74,16 @@ export default function PasswordSignInPage() {
}, },
onCompleted: (_, error) => { onCompleted: (_, error) => {
if (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({ toast({
title: t("common.error"), title: t("common.error"),
description: formatError( description: formatError(

View File

@@ -85,6 +85,12 @@ const routes = [
path: "verify-email", path: "verify-email",
Component: lazy(() => import("./pages/iam/auth/VerifyEmailPage")), Component: lazy(() => import("./pages/iam/auth/VerifyEmailPage")),
}, },
{
path: "resend-verification-email",
Component: lazy(
() => import("./pages/iam/auth/ResendVerificationEmailPage"),
),
},
{ {
path: "activate-account", path: "activate-account",
Component: lazy( Component: lazy(

View File

@@ -122,6 +122,8 @@ spec:
value: {{ .Values.probo.auth.disableSignup | quote }} value: {{ .Values.probo.auth.disableSignup | quote }}
- name: PROBOD_AUTH_INVITATION_TOKEN_VALIDITY - name: PROBOD_AUTH_INVITATION_TOKEN_VALIDITY
value: {{ .Values.probo.auth.invitationTokenValidity | quote }} value: {{ .Values.probo.auth.invitationTokenValidity | quote }}
- name: PROBOD_AUTH_EMAIL_CONFIRMATION_TOKEN_VALIDITY
value: {{ .Values.probo.auth.emailConfirmationTokenValidity | quote }}
- name: PROBOD_AUTH_COOKIE_NAME - name: PROBOD_AUTH_COOKIE_NAME
value: {{ .Values.probo.auth.cookieName | quote }} value: {{ .Values.probo.auth.cookieName | quote }}
- name: PROBOD_AUTH_COOKIE_DOMAIN - name: PROBOD_AUTH_COOKIE_DOMAIN

View File

@@ -225,6 +225,8 @@ probo:
auth: auth:
disableSignup: false disableSignup: false
invitationTokenValidity: 3600 invitationTokenValidity: 3600
# Email confirmation token validity in seconds (default: 3600 = 1 hour)
emailConfirmationTokenValidity: 3600
cookieName: "SSID" cookieName: "SSID"
cookieDomain: "probo.example.com" cookieDomain: "probo.example.com"
# REQUIRED: Generate with openssl rand -base64 32 # REQUIRED: Generate with openssl rand -base64 32

View File

@@ -0,0 +1,111 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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()))
}

View File

@@ -127,6 +127,9 @@ func (c *Client) setupTestUser() {
// Sign up // Sign up
c.userID = c.signUp(email, password, fullName) 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) // Create organization (this makes the user an OWNER)
orgName := fmt.Sprintf("Test Org %s", uniqueID) orgName := fmt.Sprintf("Test Org %s", uniqueID)
c.organizationID = c.createOrganization(orgName) 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) { func (c *Client) signIn(email string, password string) {
const query = ` err := c.SignIn(email, password)
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)
require.NoError(c.T, err, "signIn mutation failed") 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)) 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 // pollForLinkToken polls mailpit for a message matching searchQuery and
// returns the first "token" query parameter found among its links. // returns the first "token" query parameter found among its links.
func (c *Client) pollForLinkToken(searchQuery string) string { func (c *Client) pollForLinkToken(searchQuery string) string {

View File

@@ -111,6 +111,7 @@ func (b *Builder) Build() (*probodconfig.FullConfig, error) {
InvitationConfirmationTokenValidity: b.resolver.getEnvIntOrDefault("PROBOD_AUTH_INVITATION_TOKEN_VALIDITY", 3600), InvitationConfirmationTokenValidity: b.resolver.getEnvIntOrDefault("PROBOD_AUTH_INVITATION_TOKEN_VALIDITY", 3600),
PasswordResetTokenValidity: b.resolver.getEnvIntOrDefault("PROBOD_AUTH_PASSWORD_RESET_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), 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{ Cookie: probodconfig.CookieConfig{
Name: b.resolver.getEnv("PROBOD_AUTH_COOKIE_NAME"), Name: b.resolver.getEnv("PROBOD_AUTH_COOKIE_NAME"),
Domain: b.resolver.getEnv("PROBOD_AUTH_COOKIE_DOMAIN"), Domain: b.resolver.getEnv("PROBOD_AUTH_COOKIE_DOMAIN"),

View File

@@ -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.InvitationConfirmationTokenValidity)
assert.Equal(t, 3600, cfg.Probod.Auth.PasswordResetTokenValidity) assert.Equal(t, 3600, cfg.Probod.Auth.PasswordResetTokenValidity)
assert.Equal(t, 900, cfg.Probod.Auth.MagicLinkTokenValidity) 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.Name)
assert.Empty(t, cfg.Probod.Auth.Cookie.Domain) assert.Empty(t, cfg.Probod.Auth.Cookie.Domain)
assert.Equal(t, 24, cfg.Probod.Auth.Cookie.Duration) 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_INVITATION_TOKEN_VALIDITY"] = "7200"
env["PROBOD_AUTH_PASSWORD_RESET_TOKEN_VALIDITY"] = "1800" env["PROBOD_AUTH_PASSWORD_RESET_TOKEN_VALIDITY"] = "1800"
env["PROBOD_AUTH_MAGIC_LINK_TOKEN_VALIDITY"] = "600" 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_DOMAIN"] = ".example.com"
env["PROBOD_AUTH_COOKIE_DURATION"] = "48" env["PROBOD_AUTH_COOKIE_DURATION"] = "48"
// SAML // SAML
@@ -466,6 +468,7 @@ func TestBuilder_Build_CustomValues(t *testing.T) {
assert.Equal(t, 7200, cfg.Probod.Auth.InvitationConfirmationTokenValidity) assert.Equal(t, 7200, cfg.Probod.Auth.InvitationConfirmationTokenValidity)
assert.Equal(t, 1800, cfg.Probod.Auth.PasswordResetTokenValidity) assert.Equal(t, 1800, cfg.Probod.Auth.PasswordResetTokenValidity)
assert.Equal(t, 600, cfg.Probod.Auth.MagicLinkTokenValidity) 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, ".example.com", cfg.Probod.Auth.Cookie.Domain)
assert.Equal(t, 48, cfg.Probod.Auth.Cookie.Duration) assert.Equal(t, 48, cfg.Probod.Auth.Cookie.Duration)
// SAML // SAML

View File

@@ -120,7 +120,7 @@ func (s AccountService) ChangeEmail(ctx context.Context, identityID gid.GID, req
confirmationToken, err := statelesstoken.NewToken( confirmationToken, err := statelesstoken.NewToken(
s.tokenSecret, s.tokenSecret,
TokenTypeEmailConfirmation, TokenTypeEmailConfirmation,
24*time.Hour, s.emailConfirmationTokenValidity,
EmailConfirmationData{IdentityID: identityID, Email: req.NewEmail}, EmailConfirmationData{IdentityID: identityID, Email: req.NewEmail},
) )
if err != nil { 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( func (s *AccountService) ListPendingInvitations(
ctx context.Context, ctx context.Context,
userID gid.GID, userID gid.GID,

View File

@@ -388,7 +388,7 @@ func (s AuthService) CreateIdentityWithPassword(
confirmationToken, err := statelesstoken.NewToken( confirmationToken, err := statelesstoken.NewToken(
s.tokenSecret, s.tokenSecret,
TokenTypeEmailConfirmation, TokenTypeEmailConfirmation,
24*time.Hour, s.emailConfirmationTokenValidity,
EmailConfirmationData{IdentityID: identity.ID, Email: identity.EmailAddress}, EmailConfirmationData{IdentityID: identity.ID, Email: identity.EmailAddress},
) )
if err != nil { if err != nil {

View File

@@ -108,6 +108,16 @@ func (e ErrEmailAlreadyVerified) Error() string {
return e.message 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 } type ErrIdentityNotFound struct{ IdentityID gid.GID }
func NewIdentityNotFoundError(identityID gid.GID) error { func NewIdentityNotFoundError(identityID gid.GID) error {

View File

@@ -50,23 +50,24 @@ import (
type ( type (
Service struct { Service struct {
pg *pg.Client pg *pg.Client
fm *filemanager.Service fm *filemanager.Service
hp *passwdhash.Profile hp *passwdhash.Profile
dummyHash []byte dummyHash []byte
baseURL string baseURL string
tokenSecret string tokenSecret string
disableSignup bool disableSignup bool
invitationTokenValidity time.Duration invitationTokenValidity time.Duration
passwordResetTokenValidity time.Duration passwordResetTokenValidity time.Duration
magicLinkTokenValidity time.Duration magicLinkTokenValidity time.Duration
sessionDuration time.Duration emailConfirmationTokenValidity time.Duration
bucket string sessionDuration time.Duration
compliancePortalBaseDomain string bucket string
certManager *certmanager.Service compliancePortalBaseDomain string
certificate *x509.Certificate certManager *certmanager.Service
privateKey *rsa.PrivateKey certificate *x509.Certificate
logger *log.Logger privateKey *rsa.PrivateKey
logger *log.Logger
AccountService *AccountService AccountService *AccountService
OrganizationService *OrganizationService OrganizationService *OrganizationService
@@ -88,6 +89,7 @@ type (
InvitationTokenValidity time.Duration InvitationTokenValidity time.Duration
PasswordResetTokenValidity time.Duration PasswordResetTokenValidity time.Duration
MagicLinkTokenValidity time.Duration MagicLinkTokenValidity time.Duration
EmailConfirmationTokenValidity time.Duration
SessionDuration time.Duration SessionDuration time.Duration
Bucket string Bucket string
TokenSecret string TokenSecret string
@@ -154,23 +156,24 @@ func NewService(
} }
svc := &Service{ svc := &Service{
pg: pgClient, pg: pgClient,
fm: fm, fm: fm,
hp: hp, hp: hp,
dummyHash: mustHashDummy(hp), dummyHash: mustHashDummy(hp),
baseURL: cfg.BaseURL.String(), baseURL: cfg.BaseURL.String(),
tokenSecret: cfg.TokenSecret, tokenSecret: cfg.TokenSecret,
disableSignup: cfg.DisableSignup, disableSignup: cfg.DisableSignup,
invitationTokenValidity: cfg.InvitationTokenValidity, invitationTokenValidity: cfg.InvitationTokenValidity,
passwordResetTokenValidity: cfg.PasswordResetTokenValidity, passwordResetTokenValidity: cfg.PasswordResetTokenValidity,
magicLinkTokenValidity: cfg.MagicLinkTokenValidity, magicLinkTokenValidity: cfg.MagicLinkTokenValidity,
sessionDuration: cfg.SessionDuration, emailConfirmationTokenValidity: cfg.EmailConfirmationTokenValidity,
bucket: cfg.Bucket, sessionDuration: cfg.SessionDuration,
compliancePortalBaseDomain: cfg.CompliancePortalBaseDomain, bucket: cfg.Bucket,
certManager: cfg.CertManager, compliancePortalBaseDomain: cfg.CompliancePortalBaseDomain,
certificate: cfg.Certificate, certManager: cfg.CertManager,
privateKey: cfg.PrivateKey, certificate: cfg.Certificate,
logger: cfg.Logger, privateKey: cfg.PrivateKey,
logger: cfg.Logger,
} }
svc.AccountService = NewAccountService(svc) svc.AccountService = NewAccountService(svc)

View File

@@ -139,6 +139,7 @@ func New() *Implm {
InvitationConfirmationTokenValidity: 3600, InvitationConfirmationTokenValidity: 3600,
PasswordResetTokenValidity: 3600, PasswordResetTokenValidity: 3600,
MagicLinkTokenValidity: 900, MagicLinkTokenValidity: 900,
EmailConfirmationTokenValidity: 3600,
SAML: SAMLConfig{ SAML: SAMLConfig{
SessionDuration: 604800, SessionDuration: 604800,
CleanupIntervalSeconds: 86400, CleanupIntervalSeconds: 86400,
@@ -569,6 +570,7 @@ func (impl *Implm) Run(
InvitationTokenValidity: time.Duration(impl.cfg.Auth.InvitationConfirmationTokenValidity) * time.Second, InvitationTokenValidity: time.Duration(impl.cfg.Auth.InvitationConfirmationTokenValidity) * time.Second,
PasswordResetTokenValidity: time.Duration(impl.cfg.Auth.PasswordResetTokenValidity) * time.Second, PasswordResetTokenValidity: time.Duration(impl.cfg.Auth.PasswordResetTokenValidity) * time.Second,
MagicLinkTokenValidity: time.Duration(impl.cfg.Auth.MagicLinkTokenValidity) * 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, SessionDuration: time.Duration(impl.cfg.Auth.Cookie.Duration) * time.Hour,
Bucket: impl.cfg.AWS.Bucket, Bucket: impl.cfg.AWS.Bucket,
TokenSecret: impl.cfg.Auth.Cookie.Secret, TokenSecret: impl.cfg.Auth.Cookie.Secret,

View File

@@ -32,6 +32,7 @@ type AuthConfig struct {
InvitationConfirmationTokenValidity int `json:"invitation-confirmation-token-validity"` InvitationConfirmationTokenValidity int `json:"invitation-confirmation-token-validity"`
PasswordResetTokenValidity int `json:"password-reset-token-validity"` PasswordResetTokenValidity int `json:"password-reset-token-validity"`
MagicLinkTokenValidity int `json:"magic-link-token-validity"` MagicLinkTokenValidity int `json:"magic-link-token-validity"`
EmailConfirmationTokenValidity int `json:"email-confirmation-token-validity"`
SAML SAMLConfig `json:"saml"` SAML SAMLConfig `json:"saml"`
Google OIDCProviderConfig `json:"google,omitzero"` Google OIDCProviderConfig `json:"google,omitzero"`
Microsoft OIDCProviderConfig `json:"microsoft,omitzero"` Microsoft OIDCProviderConfig `json:"microsoft,omitzero"`

View File

@@ -17,6 +17,9 @@ extend type Mutation {
@authentication(required: NONE) @authentication(required: NONE)
verifyEmail(input: VerifyEmailInput!): VerifyEmailPayload verifyEmail(input: VerifyEmailInput!): VerifyEmailPayload
@authentication(required: OPTIONAL) @authentication(required: OPTIONAL)
resendVerificationEmail(
input: ResendVerificationEmailInput!
): ResendVerificationEmailPayload @authentication(required: NONE)
changePassword(input: ChangePasswordInput!): ChangePasswordPayload changePassword(input: ChangePasswordInput!): ChangePasswordPayload
@authentication(required: PRESENT) @sessionOnly @authentication(required: PRESENT) @sessionOnly
changeEmail(input: ChangeEmailInput!): ChangeEmailPayload changeEmail(input: ChangeEmailInput!): ChangeEmailPayload
@@ -102,6 +105,10 @@ input VerifyEmailInput {
token: String! token: String!
} }
input ResendVerificationEmailInput {
email: EmailAddr!
}
input ChangePasswordInput { input ChangePasswordInput {
currentPassword: String! currentPassword: String!
newPassword: String! newPassword: String!
@@ -152,6 +159,10 @@ type VerifyEmailPayload {
success: Boolean! success: Boolean!
} }
type ResendVerificationEmailPayload {
success: Boolean!
}
type ChangePasswordPayload { type ChangePasswordPayload {
success: Boolean! success: Boolean!
} }

View File

@@ -40,6 +40,15 @@ func (r *mutationResolver) SignIn(ctx context.Context, input types.SignInInput)
return nil, gqlutils.Internal(ctx) 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) session := authn.SessionFromContext(ctx)
switch { switch {
@@ -311,6 +320,19 @@ func (r *mutationResolver) VerifyEmail(ctx context.Context, input types.VerifyEm
}, nil }, 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. // ChangePassword is the resolver for the changePassword field.
func (r *mutationResolver) ChangePassword(ctx context.Context, input types.ChangePasswordInput) (*types.ChangePasswordPayload, error) { func (r *mutationResolver) ChangePassword(ctx context.Context, input types.ChangePasswordInput) (*types.ChangePasswordPayload, error) {
identity := authn.IdentityFromContext(ctx) identity := authn.IdentityFromContext(ctx)