Skip to content

Commit

Permalink
Merge pull request #1085 from jetstreamapp/bug/case-insensitive-email…
Browse files Browse the repository at this point in the history
…-1084

Email should be case-insensitive during login
  • Loading branch information
paustint authored Nov 20, 2024
2 parents aa8a999 + 70d60a5 commit ca580e8
Show file tree
Hide file tree
Showing 7 changed files with 47 additions and 12 deletions.
8 changes: 4 additions & 4 deletions apps/api/src/app/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,14 +94,14 @@ export const routeDefinition = {
action: z.literal('login'),
csrfToken: z.string(),
captchaToken: z.string().nullish(),
email: z.string().email().min(5).max(255),
email: z.string().email().min(5).max(255).toLowerCase(),
password: z.string().min(8).max(255),
}),
z.object({
action: z.literal('register'),
csrfToken: z.string(),
captchaToken: z.string().nullish(),
email: z.string().email().min(5).max(255),
email: z.string().email().min(5).max(255).toLowerCase(),
name: z.string().min(1).max(255).trim(),
password: z.string().min(8).max(255),
}),
Expand Down Expand Up @@ -147,15 +147,15 @@ export const routeDefinition = {
requestPasswordReset: {
controllerFn: () => requestPasswordReset,
validators: {
body: z.object({ captchaToken: z.string().nullish(), email: z.string(), csrfToken: z.string() }),
body: z.object({ captchaToken: z.string().nullish(), email: z.string().toLowerCase(), csrfToken: z.string() }),
hasSourceOrg: false,
},
},
validatePasswordReset: {
controllerFn: () => validatePasswordReset,
validators: {
body: z.object({
email: z.string().email(),
email: z.string().email().toLowerCase(),
token: z.string(),
password: z.string(),
csrfToken: z.string(),
Expand Down
4 changes: 4 additions & 0 deletions apps/jetstream-e2e/src/pageObjectModels/OrganizationsPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ export class OrganizationsPage {

const salesforcePage = await salesforcePagePromise;

// Sometimes SFDC clears the values from the form if they are typed in too quickly
// eslint-disable-next-line playwright/no-wait-for-timeout
await salesforcePage.waitForTimeout(1000);

await salesforcePage.getByLabel('Username').click();
await salesforcePage.getByLabel('Username').fill(username);

Expand Down
8 changes: 8 additions & 0 deletions apps/jetstream-e2e/src/tests/authentication/login1.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ test.describe('Login 1', () => {
await authenticationPage.fillOutLoginForm(email, password);
await page.waitForURL(`**/app`);
await expect(page.url()).toContain('/app');
await playwrightPage.logout();
await expect(page.getByTestId('home-hero-container')).toBeVisible();
});

await test.step('Email address should be case-insensitive', async () => {
await authenticationPage.fillOutLoginForm(email.toUpperCase(), password);
await page.waitForURL(`**/app`);
await expect(page.url()).toContain('/app');
});
});
});
8 changes: 6 additions & 2 deletions apps/jetstream-e2e/src/tests/authentication/login2.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ test.describe('Login 2', () => {
await playwrightPage.logout();

await authenticationPage.goToPasswordReset();
await authenticationPage.fillOutResetPasswordForm(email);
// Use uppercase email to ensure case insensitivity
await authenticationPage.fillOutResetPasswordForm(email.toUpperCase());
await expect(
page.getByText('You will receive an email with instructions if an account exists and is eligible for password reset.')
).toBeVisible();
Expand All @@ -34,7 +35,10 @@ test.describe('Login 2', () => {

await authenticationPage.fillOutResetPasswordVerifyForm(password, password);

await authenticationPage.loginAndVerifyEmail(email, password);
await expect(page.getByText('Login with your new password')).toBeVisible();

// Use uppercase email to ensure case insensitivity
await authenticationPage.loginAndVerifyEmail(email.toUpperCase(), password);

await authenticationPage.page.waitForURL(`**/app`);
});
Expand Down
5 changes: 5 additions & 0 deletions apps/jetstream-e2e/src/tests/authentication/login3.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,5 +59,10 @@ test.describe('Login 3', () => {
await authenticationPage.fillOutSignUpForm(email, 'test person', password, password);
await expect(page.getByText('This email is already registered.')).toBeVisible();
});

await test.step('Attempt to register with same email address, using email with uppercase', async () => {
await authenticationPage.fillOutSignUpForm(email.toUpperCase(), 'test person', password, password);
await expect(page.getByText('This email is already registered.')).toBeVisible();
});
});
});
4 changes: 4 additions & 0 deletions apps/jetstream-e2e/src/utils/database-validation.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,22 @@ import { prisma } from '@jetstream/api-config';
import { SessionData } from '@jetstream/auth/types';

export async function verifyEmailLogEntryExists(email: string, subject: string) {
email = email.toLowerCase();
await prisma.emailActivity.findFirstOrThrow({ where: { email, subject: { contains: subject } } });
}

export async function getPasswordResetToken(email: string) {
email = email.toLowerCase();
return await prisma.passwordResetToken.findFirst({ where: { email, expiresAt: { gt: new Date() } } });
}

export async function hasPasswordResetToken(email: string, token: string) {
email = email.toLowerCase();
return (await prisma.passwordResetToken.count({ where: { email, token } })) > 0;
}

export async function getUserSessionByEmail(email: string) {
email = email.toLowerCase();
const session = await prisma.sessions.findFirstOrThrow({
where: {
sess: {
Expand Down
22 changes: 16 additions & 6 deletions libs/auth/server/src/lib/auth.db.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ async function findUserByProviderId(provider: OauthProviderType, providerAccount
}

async function findUsersByEmail(email: string) {
email = email.toLowerCase();
return prisma.user.findMany({
select: userSelect,
where: { email },
Expand Down Expand Up @@ -384,6 +385,7 @@ export async function setPasswordForUser(id: string, password: string) {
}

export const generatePasswordResetToken = async (email: string) => {
email = email.toLowerCase();
// NOTE: There could be duplicate users with the same email, but only one with a password set
// These users were migrated from Auth0, but we do not support this as a standard path
const user = await prisma.user.findMany({
Expand Down Expand Up @@ -422,6 +424,7 @@ export const generatePasswordResetToken = async (email: string) => {
};

export const resetUserPassword = async (email: string, token: string, password: string) => {
email = email.toLowerCase();
// if there is an existing token, delete it
const restToken = await prisma.passwordResetToken.findUnique({
where: { email_token: { email, token } },
Expand Down Expand Up @@ -478,6 +481,7 @@ export const removePasswordFromUser = async (id: string) => {
};

async function getUserAndVerifyPassword(email: string, password: string) {
email = email.toLowerCase();
const UNSAFE_userWithPassword = await prisma.user.findFirst({
select: { id: true, password: true },
where: { email, password: { not: null } },
Expand Down Expand Up @@ -515,6 +519,7 @@ async function getUserAndVerifyPassword(email: string, password: string) {
}

async function migratePasswordFromAuth0(email: string, password: string) {
email = email.toLowerCase();
// If the user has a linked social identity, we have no way to confirm 100% that this is the correct account
// since we allowed same email on multiple accounts with Auth0
const userWithoutSocialIdentities = await prisma.user.findFirst({
Expand Down Expand Up @@ -590,10 +595,11 @@ export async function removeIdentityFromUser(userId: string, provider: OauthProv
}

async function createUserFromProvider(providerUser: ProviderUser, provider: OauthProviderType) {
const email = providerUser.email?.toLowerCase();
return prisma.user.create({
select: userSelect,
data: {
email: providerUser.email,
email,
// TODO: do we really get any benefit from storing this userId like this?
// TODO: only reason I can think of is user migration since the id is a UUID so we need to different identifier
// TODO: this is nice as we can identify which identity is primary without joining the identity table - but could solve in other ways
Expand All @@ -608,7 +614,7 @@ async function createUserFromProvider(providerUser: ProviderUser, provider: Oaut
type: 'oauth',
provider: provider,
providerAccountId: providerUser.id,
email: providerUser.email,
email,
name: providerUser.name,
emailVerified: providerUser.emailVerified,
username: providerUser.username,
Expand All @@ -630,6 +636,7 @@ async function createUserFromProvider(providerUser: ProviderUser, provider: Oaut

async function updateIdentityAttributesFromProvider(userId: string, providerUser: ProviderUser, provider: OauthProviderType) {
try {
const email = providerUser.email?.toLowerCase();
const existingProfile = await prisma.authIdentity.findUniqueOrThrow({
select: {
isPrimary: true,
Expand All @@ -650,7 +657,7 @@ async function updateIdentityAttributesFromProvider(userId: string, providerUser
});

const skipUpdate =
existingProfile.email === providerUser.email &&
existingProfile.email === email &&
existingProfile.name === providerUser.name &&
existingProfile.emailVerified === providerUser.emailVerified &&
existingProfile.username === providerUser.username &&
Expand All @@ -672,7 +679,7 @@ async function updateIdentityAttributesFromProvider(userId: string, providerUser
data: {
provider: provider,
providerAccountId: providerUser.id,
email: providerUser.email,
email,
name: providerUser.name,
emailVerified: providerUser.emailVerified,
username: providerUser.username,
Expand All @@ -695,7 +702,7 @@ async function updateIdentityAttributesFromProvider(userId: string, providerUser
data: {
provider: provider,
providerAccountId: providerUser.id,
email: providerUser.email,
email,
name: providerUser.name,
emailVerified: providerUser.emailVerified,
username: providerUser.username,
Expand All @@ -715,6 +722,7 @@ async function updateIdentityAttributesFromProvider(userId: string, providerUser
}

async function createUserFromUserInfo(email: string, name: string, password: string) {
email = email.toLowerCase();
const passwordHash = await hashPassword(password);
return prisma.$transaction(async (tx) => {
// Create initial user
Expand Down Expand Up @@ -831,7 +839,9 @@ export async function handleSignInOrRegistration(
isNewUser = true;
}
} else if (providerType === 'credentials') {
const { action, email, password } = payload;
const { action, password } = payload;
const email = payload.email.toLowerCase();

if (!password) {
throw new InvalidCredentials();
}
Expand Down

0 comments on commit ca580e8

Please sign in to comment.