diff --git a/apps/api/src/app/controllers/user.controller.ts b/apps/api/src/app/controllers/user.controller.ts index 4590c792..3f97db72 100644 --- a/apps/api/src/app/controllers/user.controller.ts +++ b/apps/api/src/app/controllers/user.controller.ts @@ -183,7 +183,10 @@ const getFullUserProfile = createRoute(routeDefinition.getFullUserProfile.valida const initPassword = createRoute(routeDefinition.initPassword.validators, async ({ body, user }, req, res) => { const { password } = body; - await setPasswordForUser(user.id, password); + const results = await setPasswordForUser(user.id, password); + if ('error' in results) { + throw new UserFacingError(results.error); + } sendJson(res, await userDbService.findUserWithIdentitiesById(user.id)); createUserActivityFromReq(req, res, { diff --git a/apps/api/src/app/routes/auth.routes.ts b/apps/api/src/app/routes/auth.routes.ts index a1eb351f..8f663d1b 100644 --- a/apps/api/src/app/routes/auth.routes.ts +++ b/apps/api/src/app/routes/auth.routes.ts @@ -38,7 +38,7 @@ routes.get('/csrf', LAX_AuthRateLimit, authController.routeDefinition.getCsrfTok routes.get('/session', LAX_AuthRateLimit, authController.routeDefinition.getSession.controllerFn()); // Init OAuth flow -routes.post('/signin/:provider', STRICT_AuthRateLimit, verifyCaptcha, authController.routeDefinition.signin.controllerFn()); +routes.post('/signin/:provider', STRICT_AuthRateLimit, authController.routeDefinition.signin.controllerFn()); // Login via OAuth or credentials routes.get('/callback/:provider', STRICT_AuthRateLimit, authController.routeDefinition.callback.controllerFn()); routes.post('/callback/:provider', STRICT_AuthRateLimit, verifyCaptcha, authController.routeDefinition.callback.controllerFn()); diff --git a/apps/jetstream-e2e/src/pageObjectModels/AuthenticationPage.model.ts b/apps/jetstream-e2e/src/pageObjectModels/AuthenticationPage.model.ts index eeb2ab6b..64193f48 100644 --- a/apps/jetstream-e2e/src/pageObjectModels/AuthenticationPage.model.ts +++ b/apps/jetstream-e2e/src/pageObjectModels/AuthenticationPage.model.ts @@ -228,7 +228,9 @@ export class AuthenticationPage { await this.goToPasswordReset(); await this.fillOutResetPasswordForm(email); - await expect(this.page.getByText('Check your email to continue the reset process.')).toBeVisible(); + await expect( + this.page.getByText('You will receive an email with instructions if an account exists and is eligible for password reset.') + ).toBeVisible(); // ensure email verification was sent await prisma.emailActivity.findFirstOrThrow({ where: { email, subject: { contains: 'Reset your password' } } }); diff --git a/apps/jetstream-e2e/src/tests/authentication/login2.spec.ts b/apps/jetstream-e2e/src/tests/authentication/login2.spec.ts index 2ee6ba0a..087c38ee 100644 --- a/apps/jetstream-e2e/src/tests/authentication/login2.spec.ts +++ b/apps/jetstream-e2e/src/tests/authentication/login2.spec.ts @@ -22,7 +22,9 @@ test.describe('Login 2', () => { await authenticationPage.goToPasswordReset(); await authenticationPage.fillOutResetPasswordForm(email); - await expect(page.getByText('Check your email to continue the reset process.')).toBeVisible(); + await expect( + page.getByText('You will receive an email with instructions if an account exists and is eligible for password reset.') + ).toBeVisible(); // ensure email verification was sent await prisma.emailActivity.findFirstOrThrow({ where: { email, subject: { contains: 'Reset your password' } } }); diff --git a/apps/jetstream/src/app/components/profile/Profile.tsx b/apps/jetstream/src/app/components/profile/Profile.tsx index 1f45cc5c..395d6532 100644 --- a/apps/jetstream/src/app/components/profile/Profile.tsx +++ b/apps/jetstream/src/app/components/profile/Profile.tsx @@ -121,7 +121,7 @@ export const Profile = () => { trackEvent(ANALYTICS_KEYS.settings_password_action, { action: 'set-password' }); } catch (ex) { fireToast({ - message: 'There was a problem setting your password. Try again or file a support ticket for assistance.', + message: ex.message || 'There was a problem resetting your password. Try again or file a support ticket for assistance.', type: 'error', }); rollbar.error('Settings: Error setting password', { stack: ex.stack, message: ex.message }); @@ -138,7 +138,7 @@ export const Profile = () => { }); } catch (ex) { fireToast({ - message: 'There was a problem resetting your password. Try again or file a support ticket for assistance.', + message: ex.message || 'There was a problem resetting your password. Try again or file a support ticket for assistance.', type: 'error', }); rollbar.error('Settings: Error resetting password', { stack: ex.stack, message: ex.message }); diff --git a/apps/landing/components/auth/LoginOrSignUp.tsx b/apps/landing/components/auth/LoginOrSignUp.tsx index 703f1528..6e9a9513 100644 --- a/apps/landing/components/auth/LoginOrSignUp.tsx +++ b/apps/landing/components/auth/LoginOrSignUp.tsx @@ -129,7 +129,54 @@ export function LoginOrSignUp({ action, providers, csrfToken }: LoginOrSignUpPro {action === 'login' ? 'Sign in' : 'Sign up'} +
+
+
+ + + {providers?.google.callbackUrl && } + +
+
+ + + {providers?.salesforce.callbackUrl && } + +
+
+ +
+
+ +
+
-
-
- - -
-
- - - - {providers?.google.callbackUrl && } - -
-
- - - - {providers?.salesforce.callbackUrl && } - -
-
-
-
diff --git a/apps/landing/components/auth/PasswordResetInit.tsx b/apps/landing/components/auth/PasswordResetInit.tsx index f98daa72..e0c8ba93 100644 --- a/apps/landing/components/auth/PasswordResetInit.tsx +++ b/apps/landing/components/auth/PasswordResetInit.tsx @@ -99,7 +99,12 @@ export function PasswordResetInit({ csrfToken }: PasswordResetInitProps) {

Reset Password

- {isSubmitted && } + {isSubmitted && ( + + )} {!isSubmitted && (
diff --git a/libs/auth/server/src/lib/auth.db.service.ts b/libs/auth/server/src/lib/auth.db.service.ts index 4b5e624e..0d084f13 100644 --- a/libs/auth/server/src/lib/auth.db.service.ts +++ b/libs/auth/server/src/lib/auth.db.service.ts @@ -345,16 +345,29 @@ export async function revokeAllUserSessions(userId: string, exceptId?: Maybe { - const user = await prisma.user.findFirst({ - where: { email }, + // 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({ + where: { email, password: { not: null } }, + take: 2, }); - if (!user) { + if (!user.length) { + throw new InvalidAction(); + } + + if (user.length > 1) { throw new InvalidAction(); } @@ -384,6 +404,7 @@ export const generatePasswordResetToken = async (email: string) => { const passwordResetToken = await prisma.passwordResetToken.create({ data: { + userId: user[0].id, email, expiresAt: addMinutes(new Date(), 10), }, @@ -394,28 +415,20 @@ export const generatePasswordResetToken = async (email: string) => { export const resetUserPassword = async (email: string, token: string, password: string) => { // if there is an existing token, delete it - const existingToken = await prisma.passwordResetToken.findUnique({ + const restToken = await prisma.passwordResetToken.findUnique({ where: { email_token: { email, token } }, }); - if (!existingToken) { + if (!restToken) { throw new InvalidOrExpiredResetToken(); } // delete token - we don't need it anymore and if we fail later, the user will need to reset again await prisma.passwordResetToken.delete({ - where: { email_token: { email, token: existingToken.token } }, - }); - - if (existingToken.expiresAt < new Date()) { - throw new InvalidOrExpiredResetToken(); - } - - const user = await prisma.user.findFirst({ - where: { email }, + where: { email_token: { email, token: restToken.token } }, }); - if (!user) { + if (restToken.expiresAt < new Date()) { throw new InvalidOrExpiredResetToken(); } @@ -427,11 +440,11 @@ export const resetUserPassword = async (email: string, token: string, password: passwordUpdatedAt: new Date(), }, where: { - id: user.id, + id: restToken.userId, }, }); - await revokeAllUserSessions(user.id); + await revokeAllUserSessions(restToken.userId); }; export const removePasswordFromUser = async (id: string) => { @@ -464,6 +477,7 @@ async function getUserAndVerifyPassword(email: string, password: string) { if (!UNSAFE_userWithPassword) { return { error: new InvalidCredentials() }; } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion if (await verifyPassword(password, UNSAFE_userWithPassword.password!)) { return { error: null, @@ -536,6 +550,7 @@ async function createUserFromProvider(providerUser: ProviderUser, provider: Oaut email: providerUser.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 userId: `${provider}|${providerUser.id}`, name: providerUser.name, emailVerified: providerUser.emailVerified, diff --git a/prisma/migrations/20241102174238_add_userid_lookup_password_reset/migration.sql b/prisma/migrations/20241102174238_add_userid_lookup_password_reset/migration.sql new file mode 100644 index 00000000..615869ec --- /dev/null +++ b/prisma/migrations/20241102174238_add_userid_lookup_password_reset/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - Added the required column `userId` to the `PasswordResetToken` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "PasswordResetToken" ADD COLUMN "userId" UUID NOT NULL; + +-- AddForeignKey +ALTER TABLE "PasswordResetToken" ADD CONSTRAINT "PasswordResetToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4a51bb6e..1579c004 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -8,30 +8,31 @@ datasource db { } model User { - id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid // TODO: we should deprecate this field - is there a purpose of having two user ids? // Might be nice to rename this to something like "legacyId" for reference - userId String @unique @db.VarChar + userId String @unique @db.VarChar // TODO: we want to make this unique - email String @db.VarChar - emailVerified Boolean @default(false) - password String? @db.VarChar - passwordUpdatedAt DateTime? - name String @db.VarChar - nickname String? @db.VarChar - picture String? @db.VarChar - appMetadata Json? @db.Json - preferences UserPreference? - salesforceOrgs SalesforceOrg[] - organizations JetstreamOrganization[] - identities AuthIdentity[] - authFactors AuthFactors[] - loginActivity LoginActivity[] - rememberdDevices RememberedDevice[] - lastLoggedIn DateTime? - deletedAt DateTime? - createdAt DateTime @default(now()) @db.Timestamp(6) - updatedAt DateTime @updatedAt + email String @db.VarChar + emailVerified Boolean @default(false) + password String? @db.VarChar + passwordUpdatedAt DateTime? + name String @db.VarChar + nickname String? @db.VarChar + picture String? @db.VarChar + appMetadata Json? @db.Json + preferences UserPreference? + salesforceOrgs SalesforceOrg[] + organizations JetstreamOrganization[] + identities AuthIdentity[] + authFactors AuthFactors[] + loginActivity LoginActivity[] + rememberdDevices RememberedDevice[] + passwordResetTokens PasswordResetToken[] + lastLoggedIn DateTime? + deletedAt DateTime? + createdAt DateTime @default(now()) @db.Timestamp(6) + updatedAt DateTime @updatedAt } model AuthFactors { @@ -49,10 +50,13 @@ model AuthFactors { } model PasswordResetToken { + userId String @db.Uuid email String token String @unique @default(dbgenerated("uuid_generate_v4()")) @db.Uuid expiresAt DateTime + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + @@unique([email, token]) }