Skip to content
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

Feat/migrate password reset token to app token table #5051

Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/twenty-front/src/generated/graphql.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -751,7 +751,9 @@ export type User = {
id: Scalars['UUID'];
lastName: Scalars['String'];
passwordHash?: Maybe<Scalars['String']>;
/** @deprecated field migrated into the AppTokens Table ref: https://github.com/twentyhq/twenty/issues/5021 */
passwordResetToken?: Maybe<Scalars['String']>;
/** @deprecated field migrated into the AppTokens Table ref: https://github.com/twentyhq/twenty/issues/5021 */
passwordResetTokenExpiresAt?: Maybe<Scalars['DateTime']>;
supportUserHash?: Maybe<Scalars['String']>;
updatedAt: Scalars['DateTime'];
Expand Down
1 change: 1 addition & 0 deletions packages/twenty-server/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@
},
"database:migrate": {
"executor": "nx:run-commands",
"dependsOn": ["build"],
"options": {
"cwd": "packages/twenty-server",
"commands": [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class MigratePasswordResetTokenToAppTokenTable1713457966698
implements MigrationInterface
{
name = 'MigratePasswordResetTokenToAppTokenTable1713457966698';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
INSERT INTO "core"."appToken" ("userId", "value", "expiresAt", "type")
SELECT "id", "passwordResetToken", "passwordResetTokenExpiresAt", 'PASSWORD_RESET_TOKEN'
FROM "core"."user"
WHERE "passwordResetToken" IS NOT NULL AND "passwordResetTokenExpiresAt" IS NOT NULL
`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DELETE FROM "core"."appToken"
WHERE type = 'PASSWORD_RESET_TOKEN'
`);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export enum AppTokenType {
RefreshToken = 'REFRESH_TOKEN',
CodeChallenge = 'CODE_CHALLENGE',
AuthorizationCode = 'AUTHORIZATION_CODE',
PasswordResetToken = 'PASSWORD_RESET_TOKEN',
}

@Entity({ name: 'appToken', schema: 'core' })
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
import { Test, TestingModule } from '@nestjs/testing';
import { JwtService } from '@nestjs/jwt';
import { getRepositoryToken } from '@nestjs/typeorm';
import {
BadRequestException,
InternalServerErrorException,
NotFoundException,
} from '@nestjs/common';

import crypto from 'crypto';

import { MoreThan, Repository } from 'typeorm';

import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import {
AppToken,
AppTokenType,
} from 'src/engine/core-modules/app-token/app-token.entity';
import { User } from 'src/engine/core-modules/user/user.entity';
import { JwtAuthStrategy } from 'src/engine/core-modules/auth/strategies/jwt.auth.strategy';
import { EmailService } from 'src/engine/integrations/email/email.service';
Expand All @@ -13,34 +25,48 @@ import { TokenService } from './token.service';

describe('TokenService', () => {
let service: TokenService;
let environmentService: EnvironmentService;
let userRepository: Repository<User>;
let appTokenRepository: Repository<AppToken>;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
TokenService,
{
provide: JwtService,
useValue: {},
useValue: {
sign: jest.fn().mockReturnValue('mock-jwt-token'),
},
},
{
provide: JwtAuthStrategy,
useValue: {},
},
{
provide: EnvironmentService,
useValue: {},
useValue: {
get: jest.fn().mockReturnValue('some-value'),
},
},
{
provide: EmailService,
useValue: {},
useValue: {
send: jest.fn(),
},
},
{
provide: getRepositoryToken(User, 'core'),
useValue: {},
useValue: {
findOneBy: jest.fn(),
},
},
{
provide: getRepositoryToken(AppToken, 'core'),
useValue: {},
useValue: {
findOne: jest.fn(),
save: jest.fn(),
},
},
{
provide: getRepositoryToken(Workspace, 'core'),
Expand All @@ -50,9 +76,143 @@ describe('TokenService', () => {
}).compile();

service = module.get<TokenService>(TokenService);
environmentService = module.get<EnvironmentService>(EnvironmentService);
userRepository = module.get(getRepositoryToken(User, 'core'));
appTokenRepository = module.get(getRepositoryToken(AppToken, 'core'));
});

it('should be defined', () => {
expect(service).toBeDefined();
});

describe('generatePasswordResetToken', () => {
it('should generate a new password reset token when no existing token is found', async () => {
const mockUser = { id: '1', email: 'test@example.com' } as User;
const expiresIn = '3600000'; // 1 hour in ms

jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(mockUser);
jest.spyOn(appTokenRepository, 'findOne').mockResolvedValue(null);
jest.spyOn(environmentService, 'get').mockReturnValue(expiresIn);
jest
.spyOn(appTokenRepository, 'save')
.mockImplementation(async (token) => token as AppToken);

const result = await service.generatePasswordResetToken(mockUser.email);

expect(userRepository.findOneBy).toHaveBeenCalledWith({
email: mockUser.email,
});
expect(appTokenRepository.findOne).toHaveBeenCalled();
expect(appTokenRepository.save).toHaveBeenCalled();
expect(result.passwordResetToken).toBeDefined();
expect(result.passwordResetTokenExpiresAt).toBeDefined();
});

it('should throw BadRequestException if an existing valid token is found', async () => {
const mockUser = { id: '1', email: 'test@example.com' } as User;
const mockToken = {
userId: '1',
type: AppTokenType.PasswordResetToken,
expiresAt: new Date(Date.now() + 10000), // expires 10 seconds in the future
} as AppToken;

jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(mockUser);
jest.spyOn(appTokenRepository, 'findOne').mockResolvedValue(mockToken);
jest.spyOn(environmentService, 'get').mockReturnValue('3600000');

await expect(
service.generatePasswordResetToken(mockUser.email),
).rejects.toThrow(BadRequestException);
});

it('should throw NotFoundException if no user is found', async () => {
jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(null);

await expect(
service.generatePasswordResetToken('nonexistent@example.com'),
).rejects.toThrow(NotFoundException);
});

it('should throw InternalServerErrorException if environment variable is not found', async () => {
const mockUser = { id: '1', email: 'test@example.com' } as User;

jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(mockUser);
jest.spyOn(environmentService, 'get').mockReturnValue(''); // No environment variable set

await expect(
service.generatePasswordResetToken(mockUser.email),
).rejects.toThrow(InternalServerErrorException);
});
});

describe('validatePasswordResetToken', () => {
it('should return user data for a valid and active token', async () => {
const resetToken = 'valid-reset-token';
const hashedToken = crypto
.createHash('sha256')
.update(resetToken)
.digest('hex');
const mockToken = {
userId: '1',
value: hashedToken,
type: AppTokenType.PasswordResetToken,
expiresAt: new Date(Date.now() + 10000), // Valid future date
};
const mockUser = { id: '1', email: 'user@example.com' };

jest
.spyOn(appTokenRepository, 'findOne')
.mockResolvedValue(mockToken as AppToken);
jest
.spyOn(userRepository, 'findOneBy')
.mockResolvedValue(mockUser as User);

const result = await service.validatePasswordResetToken(resetToken);

expect(appTokenRepository.findOne).toHaveBeenCalledWith({
where: {
value: hashedToken,
type: AppTokenType.PasswordResetToken,
expiresAt: MoreThan(new Date()),
},
});
expect(userRepository.findOneBy).toHaveBeenCalledWith({
id: mockToken.userId,
});
expect(result).toEqual({ id: mockUser.id, email: mockUser.email });
});

it('should throw NotFoundException if token is invalid or expired', async () => {
const resetToken = 'invalid-reset-token';

jest.spyOn(appTokenRepository, 'findOne').mockResolvedValue(null);

await expect(
service.validatePasswordResetToken(resetToken),
).rejects.toThrow(NotFoundException);
});

it('should throw NotFoundException if user does not exist for a valid token', async () => {
const resetToken = 'orphan-token';
const hashedToken = crypto
.createHash('sha256')
.update(resetToken)
.digest('hex');
const mockToken = {
userId: 'nonexistent-user',
value: hashedToken,
type: AppTokenType.PasswordResetToken,
expiresAt: new Date(Date.now() + 10000), // Valid future date
};

jest
.spyOn(appTokenRepository, 'findOne')
.mockResolvedValue(mockToken as AppToken);
jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(null);

await expect(
service.validatePasswordResetToken(resetToken),
).rejects.toThrow(NotFoundException);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ import { InjectRepository } from '@nestjs/typeorm';

import crypto from 'crypto';

import { addMilliseconds, differenceInMilliseconds, isFuture } from 'date-fns';
import { addMilliseconds, differenceInMilliseconds } from 'date-fns';
import ms from 'ms';
import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken';
import { Repository } from 'typeorm';
import { IsNull, MoreThan, Repository } from 'typeorm';
import { Request } from 'express';
import { ExtractJwt } from 'passport-jwt';
import { render } from '@react-email/render';
Expand Down Expand Up @@ -520,22 +520,24 @@ export class TokenService {
InternalServerErrorException,
);

if (
user.passwordResetToken &&
user.passwordResetTokenExpiresAt &&
isFuture(user.passwordResetTokenExpiresAt)
) {
const existingToken = await this.appTokenRepository.findOne({
where: {
userId: user.id,
type: AppTokenType.PasswordResetToken,
expiresAt: MoreThan(new Date()),
revokedAt: IsNull(),
},
});

if (existingToken) {
const timeToWait = ms(
differenceInMilliseconds(existingToken.expiresAt, new Date()),
{ long: true },
);

assert(
false,
`Token has been already generated. Please wait for ${ms(
differenceInMilliseconds(
user.passwordResetTokenExpiresAt,
new Date(),
),
{
long: true,
},
)} to generate again.`,
`Token has already been generated. Please wait for ${timeToWait} to generate again.`,
BadRequestException,
);
}
Expand All @@ -548,9 +550,11 @@ export class TokenService {

const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));

await this.userRepository.update(user.id, {
passwordResetToken: hashedResetToken,
passwordResetTokenExpiresAt: expiresAt,
await this.appTokenRepository.save({
userId: user.id,
value: hashedResetToken,
expiresAt,
type: AppTokenType.PasswordResetToken,
});

return {
Expand Down Expand Up @@ -615,19 +619,22 @@ export class TokenService {
.update(resetToken)
.digest('hex');

const user = await this.userRepository.findOneBy({
passwordResetToken: hashedResetToken,
const token = await this.appTokenRepository.findOne({
where: {
value: hashedResetToken,
type: AppTokenType.PasswordResetToken,
expiresAt: MoreThan(new Date()),
revokedAt: IsNull(),
},
});

assert(user, 'Token is invalid', NotFoundException);
assert(token, 'Token is invalid', NotFoundException);

const tokenExpiresAt = user.passwordResetTokenExpiresAt;
const user = await this.userRepository.findOneBy({
id: token.userId,
});

assert(
tokenExpiresAt && isFuture(tokenExpiresAt),
'Token has expired. Please regenerate',
NotFoundException,
);
assert(user, 'Token is invalid', NotFoundException);

return {
id: user.id,
Expand All @@ -644,10 +651,15 @@ export class TokenService {

assert(user, 'User not found', NotFoundException);

await this.userRepository.update(user.id, {
passwordResetToken: '',
passwordResetTokenExpiresAt: undefined,
});
await this.appTokenRepository.update(
{
userId,
type: AppTokenType.PasswordResetToken,
},
{
revokedAt: new Date(),
},
);

return { success: true };
}
Expand Down