Skip to content

Commit

Permalink
Email should be case-insensitive during login
Browse files Browse the repository at this point in the history
Ensure email is stored in the database as lowercase and ensure all authentication operations normalize to lowercase

resolves #1084
  • Loading branch information
paustint committed Nov 20, 2024
1 parent aa8a999 commit 2dffe90
Show file tree
Hide file tree
Showing 6 changed files with 43 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
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 2dffe90

Please sign in to comment.