Skip to content

feat: stronger email verification policies #867

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Apr 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/run-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ jobs:
- name: Setup application
run: bun install
- name: Install Chromium for Playwright
run: bunx playwright install --with-deps chromium
run: npx playwright install --with-deps chromium

- name: Start backend and frontend
env:
Expand Down
19 changes: 16 additions & 3 deletions packages/backend/src/api/v1/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,14 @@ auth.post("/signup", async (ctx: Context) => {

// user already owner of an org
if (payload.oldRole === "owner") {
const [user] = await sql`select * from account where email = ${email}`;
const [orgInvitation] =
await sql`select * from org_invitation where email = ${email} and org_id = ${payload.orgId as string}`;
if (!orgInvitation.emailVerified) {
// prevent malicious user from deleting other orgs
ctx.throw(403, "Email not verified");
}
const [user] =
await sql`select * from account where email = ${email} order by created_at desc limit 1`;
const [org] = await sql`select * from org where id = ${user.orgId}`;
await sql`delete from org where id = ${org.id}`;

Expand All @@ -202,7 +209,7 @@ auth.post("/signup", async (ctx: Context) => {
email,
orgId: payload.orgId as string,
role: payload.role as string,
verified: config.SKIP_EMAIL_VERIFY,
verified: true,
lastLoginAt: new Date(),
};

Expand All @@ -225,6 +232,12 @@ auth.post("/signup", async (ctx: Context) => {

// user is part of an org, but not owner
if (payload.oldRole && payload.oldRole !== "owner") {
// prevent malicious user from deleting other orgs
const [orgInvitation] =
await sql`select * from org_invitation where email = ${email} and org_id = ${payload.orgId as string}`;
if (!orgInvitation.emailVerified) {
ctx.throw(403, "Email not verified");
}
const [user] = await sql`select * from account where email = ${email}`;
await sql`delete from account where id = ${user.id}`;

Expand All @@ -234,7 +247,7 @@ auth.post("/signup", async (ctx: Context) => {
email,
orgId: payload.orgId as string,
role: payload.role as string,
verified: config.SKIP_EMAIL_VERIFY,
verified: true,
lastLoginAt: new Date(),
};

Expand Down
20 changes: 11 additions & 9 deletions packages/backend/src/api/v1/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@ import { checkAccess } from "@/src/utils/authorization";
import config from "@/src/utils/config";
import sql from "@/src/utils/db";
import Context from "@/src/utils/koa";
import { sendSlackMessage } from "@/src/utils/notifications";
import { jwtVerify } from "jose";
import Router from "koa-router";
import { hasAccess, roles } from "shared";
import { z } from "zod";
import { sanitizeEmail, signJWT } from "./auth/utils";
import { sendSlackMessage } from "@/src/utils/notifications";
import { recordAuditLog } from "./audit-logs/utils";
import { sanitizeEmail, signJWT } from "./auth/utils";

const users = new Router({
prefix: "/users",
Expand Down Expand Up @@ -111,18 +111,20 @@ users.get("/verify-email", async (ctx: Context) => {
payload: { email: string };
} = await jwtVerify(token, new TextEncoder().encode(process.env.JWT_SECRET));

// check if email is already verified
let verified;
{
const result = await sql`
select verified
const [account] = await sql`
select *
from account
where email = ${email}
`;
verified = result[0]?.verified;

const [orgInvitation] =
await sql`select * from org_invitation where email = ${account.email}`;
console.log(account, orgInvitation);
if (orgInvitation) {
await sql`update org_invitation set email_verified = true where id = ${orgInvitation.id}`;
}

if (verified) {
if (account.verified) {
ctx.body = { message: "Email already verified" };
return;
}
Expand Down
1 change: 1 addition & 0 deletions packages/db/0084.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
alter table org_invitation add column email_verified boolean default null;
1 change: 1 addition & 0 deletions packages/frontend/components/layout/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export default function Layout({ children }: { children: ReactNode }) {
"/request-password-reset",
"/reset-password",
"/auth",
"/verify-email",
].find((path) => router.pathname.startsWith(path));

const isSignupPage = router.pathname.startsWith("/signup");
Expand Down
1 change: 1 addition & 0 deletions packages/frontend/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ function IndexPage() {
if (!router.isReady) {
return;
}

if (!isSignedIn) {
router.replace("/login");
return;
Expand Down
150 changes: 107 additions & 43 deletions packages/frontend/pages/join.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@ import {
Title,
} from "@mantine/core";
import { modals } from "@mantine/modals";
import { IconAnalyze, IconAt, IconCheck, IconUser } from "@tabler/icons-react";
import {
IconAnalyze,
IconAt,
IconCheck,
IconMailCheck,
IconUser,
} from "@tabler/icons-react";
import { useEffect, useRef, useState } from "react";

import GoogleButton from "@/components/blocks/OAuth/GoogleButton";
Expand Down Expand Up @@ -63,6 +69,14 @@ function TeamFull({ orgName }: { orgName: string }) {
);
}

async function sendVerificationEmail(email: string, name: string) {
await fetcher.post("/users/send-verification", { arg: { email, name } });
notifications.show({
icon: <IconMailCheck size={18} />,
message: `Verification link sent to ${email}`,
});
}

export default function Join() {
const router = useRouter();
const { token } = router.query;
Expand Down Expand Up @@ -159,11 +173,11 @@ export default function Join() {
email: (val) => (/^\S+@\S+$/.test(val) ? null : "Invalid email"),
name: (val) => (val.length <= 2 ? "Your name that short :) ?" : null),
password: (val) =>
step === 2 && val.length < 6
step === 3 && val.length < 6
? "Password must be at least 6 characters"
: null,
confirmPassword: (val) =>
step === 2 && val !== form.values.password
step === 3 && val !== form.values.password
? "Passwords do not match"
: null,
},
Expand Down Expand Up @@ -198,17 +212,17 @@ export default function Join() {
});

auth.setJwt(authToken);
} catch (error) {
console.error(error);
} finally {
analytics.track("Join", { email, name, orgId });
notifications.show({
icon: <IconCheck size={18} />,
color: "teal",
message: `You have joined ${orgName}`,
});
analytics.track("Join", { email, name, orgId });
} catch (error) {
setLoading(false);
throw error;
}
setLoading(false);
}

const continueStep = async () => {
Expand All @@ -218,28 +232,41 @@ export default function Join() {
try {
if (step === 1) {
const { method, redirect } = await fetcher.post("/auth/method", {
arg: {
email,
},
arg: { email },
});

const mustVerify = !!joinData?.oldRole && method === "password";

if (mustVerify) {
await sendVerificationEmail(email, name);
setStep(2);
setLoading(false);
return;
}

if (method === "saml") {
await handleSignup({
email,
name,
redirectUrl: redirect,
});
setStep(4);
} else {
setStep(2);
setStep(3);
}
} else if (step === 2) {
await handleSignup({
email,
name,
password,
});

setStep(3);
} else if (step === 3) {
try {
await handleSignup({
email,
name,
password,
});
setStep(4);
} catch (error) {
alert("Email not verified");
}
}
} catch (error) {
console.error(error);
Expand All @@ -264,15 +291,15 @@ export default function Join() {

<Stack align="center" gap={50}>
<Stack align="center">
<IconAnalyze color={"#206dce"} size={60} />
<IconAnalyze color="#206dce" size={60} />
<Title order={2} fw={700} size={40} ta="center">
Join {orgName}
</Title>
</Stack>
<Paper radius="md" p="xl" withBorder miw={350}>
<form onSubmit={form.onSubmit(continueStep)}>
<Stack gap="lg">
{step < 3 && (
{step === 1 && (
<>
<TextInput
label="Full Name"
Expand All @@ -294,28 +321,6 @@ export default function Join() {
{...form.getInputProps("email")}
/>

{step === 2 && (
<>
<PasswordInput
label="Password"
autoComplete="new-password"
error={form.errors.password && "Invalid password"}
placeholder="Your password"
{...form.getInputProps("password")}
/>
<PasswordInput
label="Confirm Password"
autoComplete="new-password"
error={
form.errors.confirmPassword &&
"Passwords do not match"
}
placeholder="Your password"
{...form.getInputProps("confirmPassword")}
/>
</>
)}

<Button
size="md"
mt="md"
Expand All @@ -324,12 +329,71 @@ export default function Join() {
loading={loading}
disabled={!acknowledged}
>
{step === 2 ? "Confirm signup →" : "Continue →"}
Continue →
</Button>
</>
)}

{step === 2 && (
<Stack align="center" gap="lg">
<IconMailCheck size={48} stroke={1.2} color="#206dce" />
<Title order={3} ta="center">
Verify your email
</Title>
<Text ta="center" c="dimmed">
We’ve sent a verification link to {form.values.email}. Click
it, then come back here.
</Text>
<Button size="md" loading={loading} fullWidth type="submit">
I have verified →
</Button>
<Anchor
component="button"
size="sm"
onClick={async () => {
await sendVerificationEmail(
form.values.email,
form.values.name,
);
}}
>
Resend email
</Anchor>
</Stack>
)}

{step === 3 && (
<>
<PasswordInput
label="Password"
autoComplete="new-password"
error={form.errors.password && "Invalid password"}
placeholder="Your password"
{...form.getInputProps("password")}
/>
<PasswordInput
label="Confirm Password"
autoComplete="new-password"
error={
form.errors.confirmPassword && "Passwords do not match"
}
placeholder="Your password"
{...form.getInputProps("confirmPassword")}
/>

<Button
size="md"
mt="md"
type="submit"
fullWidth
loading={loading}
>
Confirm signup →
</Button>
</>
)}

{step === 4 && (
<>
<Confetti
recycle={false}
Expand All @@ -338,7 +402,7 @@ export default function Join() {
/>

<Stack align="center">
<IconAnalyze color={"#206dce"} size={60} />
<IconAnalyze color="#206dce" size={60} />
<Title order={2} fw={700} size={40} ta="center">
You're all set 🎉
</Title>
Expand Down
Loading