GH-3245 Change password from settings page (#3538)

* GH-3245 add passwordResetToken and passwordResetTokenExpiresAt column on user entity

* Add password reset token expiry delay env variable

* Add generatePasswordResetToken mutation resolver

* Update .env.sample file on server

* Add password reset token and expiry migration script

* Add validate password reset token query and a dummy password update (WIP) resolver

* Fix bug in password reset token generate

* add update password mutation

* Update name and add email password reset link

* Add change password UI on settings page

* Add reset password route on frontend

* Add reset password form UI

* sign in user on password reset

* format code

* make PASSWORD_RESET_TOKEN_EXPIRES_IN optional

* add email template for password reset

* Improve error message

* Rename methods and DTO to improve naming

* fix formatting of backend code

* Update change password component

* Update password reset via token component

* update graphql files

* spelling fix

* Make password-reset route authless on frontend

* show token generation wait time

* remove constant from .env.example

* Add PASSWORD_RESET_TOKEN_EXPIRES_IN in docs

* refactor emails module in reset password

* update Graphql generated file

* update email template of password reset

* add space between date and text

* update method name

* fix lint issues

* remove unused code, fix indentation, and email link color

* update test file for auth and token service

* Fix ci: build twenty-emails when running tests

---------

Co-authored-by: martmull <martmull@hotmail.fr>
This commit is contained in:
Deepak Kumar
2024-01-25 14:58:48 +05:30
committed by GitHub
parent 21f342c5ea
commit 46f0eb522f
37 changed files with 1015 additions and 11 deletions

View File

@ -2,6 +2,8 @@ import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import {
BadRequestException,
ForbiddenException,
InternalServerErrorException,
NotFoundException,
UseGuards,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
@ -15,8 +17,13 @@ import { Workspace } from 'src/core/workspace/workspace.entity';
import { AuthWorkspace } from 'src/decorators/auth-workspace.decorator';
import { User } from 'src/core/user/user.entity';
import { ApiKeyTokenInput } from 'src/core/auth/dto/api-key-token.input';
import { ValidatePasswordResetToken } from 'src/core/auth/dto/validate-password-reset-token.entity';
import { TransientToken } from 'src/core/auth/dto/transient-token.entity';
import { UserService } from 'src/core/user/services/user.service';
import { ValidatePasswordResetTokenInput } from 'src/core/auth/dto/validate-password-reset-token.input';
import { UpdatePasswordViaResetTokenInput } from 'src/core/auth/dto/update-password-via-reset-token.input';
import { EmailPasswordResetLink } from 'src/core/auth/dto/email-password-reset-link.entity';
import { InvalidatePassword } from 'src/core/auth/dto/invalidate-password.entity';
import { ApiKeyToken, AuthTokens } from './dto/token.entity';
import { TokenService } from './services/token.service';
@ -150,4 +157,47 @@ export class AuthResolver {
args.expiresAt,
);
}
@UseGuards(JwtAuthGuard)
@Mutation(() => EmailPasswordResetLink)
async emailPasswordResetLink(
@AuthUser() { email }: User,
): Promise<EmailPasswordResetLink> {
const resetToken =
await this.tokenService.generatePasswordResetToken(email);
return await this.tokenService.sendEmailPasswordResetLink(
resetToken,
email,
);
}
@Mutation(() => InvalidatePassword)
async updatePasswordViaResetToken(
@Args() args: UpdatePasswordViaResetTokenInput,
): Promise<InvalidatePassword> {
const { id } = await this.tokenService.validatePasswordResetToken(
args.passwordResetToken,
);
assert(id, 'User not found', NotFoundException);
const { success } = await this.authService.updatePassword(
id,
args.newPassword,
);
assert(success, 'Password update failed', InternalServerErrorException);
return await this.tokenService.invalidatePasswordResetToken(id);
}
@Query(() => ValidatePasswordResetToken)
async validatePasswordResetToken(
@Args() args: ValidatePasswordResetTokenInput,
): Promise<ValidatePasswordResetToken> {
return this.tokenService.validatePasswordResetToken(
args.passwordResetToken,
);
}
}

View File

@ -0,0 +1,9 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class EmailPasswordResetLink {
@Field(() => Boolean, {
description: 'Boolean that confirms query was dispatched',
})
success: boolean;
}

View File

@ -0,0 +1,9 @@
import { ObjectType, Field } from '@nestjs/graphql';
@ObjectType()
export class InvalidatePassword {
@Field(() => Boolean, {
description: 'Boolean that confirms query was dispatched',
})
success: boolean;
}

View File

@ -0,0 +1,11 @@
import { ArgsType, Field } from '@nestjs/graphql';
import { IsEmail, IsNotEmpty } from 'class-validator';
@ArgsType()
export class PasswordResetTokenInput {
@Field(() => String)
@IsNotEmpty()
@IsEmail()
email: string;
}

View File

@ -29,3 +29,12 @@ export class AuthTokens {
@Field(() => AuthTokenPair)
tokens: AuthTokenPair;
}
@ObjectType()
export class PasswordResetToken {
@Field(() => String)
passwordResetToken: string;
@Field(() => Date)
passwordResetTokenExpiresAt: Date;
}

View File

@ -0,0 +1,16 @@
import { ArgsType, Field } from '@nestjs/graphql';
import { IsNotEmpty, IsString } from 'class-validator';
@ArgsType()
export class UpdatePasswordViaResetTokenInput {
@Field(() => String)
@IsNotEmpty()
@IsString()
passwordResetToken: string;
@Field(() => String)
@IsNotEmpty()
@IsString()
newPassword: string;
}

View File

@ -0,0 +1,9 @@
import { ObjectType, Field } from '@nestjs/graphql';
@ObjectType()
export class UpdatePassword {
@Field(() => Boolean, {
description: 'Boolean that confirms query was dispatched',
})
success: boolean;
}

View File

@ -0,0 +1,10 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class ValidatePasswordResetToken {
@Field(() => String)
id: string;
@Field(() => String)
email: string;
}

View File

@ -0,0 +1,11 @@
import { ArgsType, Field } from '@nestjs/graphql';
import { IsNotEmpty, IsString } from 'class-validator';
@ArgsType()
export class ValidatePasswordResetTokenInput {
@Field(() => String)
@IsNotEmpty()
@IsString()
passwordResetToken: string;
}

View File

@ -8,6 +8,7 @@ import { FileUploadService } from 'src/core/file/services/file-upload.service';
import { Workspace } from 'src/core/workspace/workspace.entity';
import { User } from 'src/core/user/user.entity';
import { EnvironmentService } from 'src/integrations/environment/environment.service';
import { EmailService } from 'src/integrations/email/email.service';
import { AuthService } from './auth.service';
import { TokenService } from './token.service';
@ -51,6 +52,10 @@ describe('AuthService', () => {
provide: EnvironmentService,
useValue: {},
},
{
provide: EmailService,
useValue: {},
},
],
}).compile();

View File

@ -10,6 +10,8 @@ import { HttpService } from '@nestjs/axios';
import FileType from 'file-type';
import { Repository } from 'typeorm';
import { v4 } from 'uuid';
import { render } from '@react-email/components';
import { PasswordUpdateNotifyEmail } from 'twenty-emails';
import { FileFolder } from 'src/core/file/interfaces/file-folder.interface';
@ -30,6 +32,8 @@ import { WorkspaceManagerService } from 'src/workspace/workspace-manager/workspa
import { getImageBufferFromUrl } from 'src/utils/image';
import { FileUploadService } from 'src/core/file/services/file-upload.service';
import { EnvironmentService } from 'src/integrations/environment/environment.service';
import { EmailService } from 'src/integrations/email/email.service';
import { UpdatePassword } from 'src/core/auth/dto/update-password.entity';
import { TokenService } from './token.service';
@ -52,6 +56,7 @@ export class AuthService {
private readonly userRepository: Repository<User>,
private readonly httpService: HttpService,
private readonly environmentService: EnvironmentService,
private readonly emailService: EmailService,
) {}
async challenge(challengeInput: ChallengeInput) {
@ -241,4 +246,50 @@ export class AuthService {
},
};
}
async updatePassword(
userId: string,
newPassword: string,
): Promise<UpdatePassword> {
const user = await this.userRepository.findOneBy({ id: userId });
assert(user, 'User not found', NotFoundException);
const isPasswordValid = PASSWORD_REGEX.test(newPassword);
assert(isPasswordValid, 'Password too weak', BadRequestException);
const isPasswordSame = await compareHash(newPassword, user.passwordHash);
assert(!isPasswordSame, 'Password cannot be repeated', BadRequestException);
const newPasswordHash = await hashPassword(newPassword);
await this.userRepository.update(userId, {
passwordHash: newPasswordHash,
});
const emailTemplate = PasswordUpdateNotifyEmail({
userName: `${user.firstName} ${user.lastName}`,
email: user.email,
link: this.environmentService.getFrontBaseUrl(),
});
const html = render(emailTemplate, {
pretty: true,
});
const text = render(emailTemplate, {
plainText: true,
});
this.emailService.send({
from: `${this.environmentService.getEmailFromName()} <${this.environmentService.getEmailFromAddress()}>`,
to: user.email,
subject: 'Your Password Has Been Successfully Changed',
text,
html,
});
return { success: true };
}
}

View File

@ -6,6 +6,7 @@ import { EnvironmentService } from 'src/integrations/environment/environment.ser
import { RefreshToken } from 'src/core/refresh-token/refresh-token.entity';
import { User } from 'src/core/user/user.entity';
import { JwtAuthStrategy } from 'src/core/auth/strategies/jwt.auth.strategy';
import { EmailService } from 'src/integrations/email/email.service';
import { TokenService } from './token.service';
@ -28,6 +29,10 @@ describe('TokenService', () => {
provide: EnvironmentService,
useValue: {},
},
{
provide: EmailService,
useValue: {},
},
{
provide: getRepositoryToken(User, 'core'),
useValue: {},

View File

@ -1,4 +1,5 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
InternalServerErrorException,
@ -9,23 +10,35 @@ import {
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { addMilliseconds } from 'date-fns';
import crypto from 'crypto';
import { addMilliseconds, differenceInMilliseconds, isFuture } from 'date-fns';
import ms from 'ms';
import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken';
import { Repository } from 'typeorm';
import { Request } from 'express';
import { ExtractJwt } from 'passport-jwt';
import { render } from '@react-email/render';
import { PasswordResetLinkEmail } from 'twenty-emails';
import {
JwtAuthStrategy,
JwtPayload,
} from 'src/core/auth/strategies/jwt.auth.strategy';
import { assert } from 'src/utils/assert';
import { ApiKeyToken, AuthToken } from 'src/core/auth/dto/token.entity';
import {
ApiKeyToken,
AuthToken,
PasswordResetToken,
} from 'src/core/auth/dto/token.entity';
import { EnvironmentService } from 'src/integrations/environment/environment.service';
import { User } from 'src/core/user/user.entity';
import { RefreshToken } from 'src/core/refresh-token/refresh-token.entity';
import { Workspace } from 'src/core/workspace/workspace.entity';
import { ValidatePasswordResetToken } from 'src/core/auth/dto/validate-password-reset-token.entity';
import { EmailService } from 'src/integrations/email/email.service';
import { InvalidatePassword } from 'src/core/auth/dto/invalidate-password.entity';
import { EmailPasswordResetLink } from 'src/core/auth/dto/email-password-reset-link.entity';
@Injectable()
export class TokenService {
@ -37,6 +50,7 @@ export class TokenService {
private readonly userRepository: Repository<User>,
@InjectRepository(RefreshToken, 'core')
private readonly refreshTokenRepository: Repository<RefreshToken>,
private readonly emailService: EmailService,
) {}
async generateAccessToken(userId: string): Promise<AuthToken> {
@ -312,4 +326,149 @@ export class TokenService {
}
}
}
async generatePasswordResetToken(email: string): Promise<PasswordResetToken> {
const user = await this.userRepository.findOneBy({
email,
});
assert(user, 'User not found', NotFoundException);
const expiresIn = this.environmentService.getPasswordResetTokenExpiresIn();
assert(
expiresIn,
'PASSWORD_RESET_TOKEN_EXPIRES_IN constant value not found',
InternalServerErrorException,
);
if (
user.passwordResetToken &&
user.passwordResetTokenExpiresAt &&
isFuture(user.passwordResetTokenExpiresAt)
) {
assert(
false,
`Token has been already generated. Please wait for ${ms(
differenceInMilliseconds(
user.passwordResetTokenExpiresAt,
new Date(),
),
{
long: true,
},
)} to generate again.`,
BadRequestException,
);
}
const plainResetToken = crypto.randomBytes(32).toString('hex');
const hashedResetToken = crypto
.createHash('sha256')
.update(plainResetToken)
.digest('hex');
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
await this.userRepository.update(user.id, {
passwordResetToken: hashedResetToken,
passwordResetTokenExpiresAt: expiresAt,
});
return {
passwordResetToken: plainResetToken,
passwordResetTokenExpiresAt: expiresAt,
};
}
async sendEmailPasswordResetLink(
resetToken: PasswordResetToken,
email: string,
): Promise<EmailPasswordResetLink> {
const user = await this.userRepository.findOneBy({
email,
});
assert(user, 'User not found', NotFoundException);
const frontBaseURL = this.environmentService.getFrontBaseUrl();
const resetLink = `${frontBaseURL}/reset-password/${resetToken.passwordResetToken}`;
const emailData = {
link: resetLink,
duration: ms(
differenceInMilliseconds(
resetToken.passwordResetTokenExpiresAt,
new Date(),
),
{
long: true,
},
),
};
const emailTemplate = PasswordResetLinkEmail(emailData);
const html = render(emailTemplate, {
pretty: true,
});
const text = render(emailTemplate, {
plainText: true,
});
this.emailService.send({
from: `${this.environmentService.getEmailFromName()} <${this.environmentService.getEmailFromAddress()}>`,
to: email,
subject: 'Action Needed to Reset Password',
text,
html,
});
return { success: true };
}
async validatePasswordResetToken(
resetToken: string,
): Promise<ValidatePasswordResetToken> {
const hashedResetToken = crypto
.createHash('sha256')
.update(resetToken)
.digest('hex');
const user = await this.userRepository.findOneBy({
passwordResetToken: hashedResetToken,
});
assert(user, 'Token is invalid', NotFoundException);
const tokenExpiresAt = user.passwordResetTokenExpiresAt;
assert(
tokenExpiresAt && isFuture(tokenExpiresAt),
'Token has expired. Please regenerate',
NotFoundException,
);
return {
id: user.id,
email: user.email,
};
}
async invalidatePasswordResetToken(
userId: string,
): Promise<InvalidatePassword> {
const user = await this.userRepository.findOneBy({
id: userId,
});
assert(user, 'User not found', NotFoundException);
await this.userRepository.update(user.id, {
passwordResetToken: '',
passwordResetTokenExpiresAt: undefined,
});
return { success: true };
}
}

View File

@ -68,6 +68,14 @@ export class User {
})
defaultWorkspace: Workspace;
@Field({ nullable: true })
@Column({ nullable: true })
passwordResetToken: string;
@Field({ nullable: true })
@Column({ nullable: true })
passwordResetTokenExpiresAt: Date;
@OneToMany(() => RefreshToken, (refreshToken) => refreshToken.user, {
cascade: true,
})

View File

@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddPasswordResetToken1704825571702 implements MigrationInterface {
name = 'AddPasswordResetToken1704825571702';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."user" ADD "passwordResetToken" character varying`,
);
await queryRunner.query(
`ALTER TABLE "core"."user" ADD "passwordResetTokenExpiresAt" TIMESTAMP`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."user" DROP COLUMN "passwordResetTokenExpiresAt"`,
);
await queryRunner.query(
`ALTER TABLE "core"."user" DROP COLUMN "passwordResetToken"`,
);
}
}

View File

@ -266,6 +266,12 @@ export class EnvironmentService {
return this.configService.get<string | undefined>('OPENROUTER_API_KEY');
}
getPasswordResetTokenExpiresIn(): string {
return (
this.configService.get<string>('PASSWORD_RESET_TOKEN_EXPIRES_IN') ?? '5m'
);
}
getInactiveDaysBeforeEmail(): number | undefined {
return this.configService.get<number | undefined>(
'WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION',

View File

@ -172,6 +172,10 @@ export class EnvironmentVariables {
@IsString()
SENTRY_DSN?: string;
@IsDuration()
@IsOptional()
PASSWORD_RESET_TOKEN_EXPIRES_IN?: number;
@CastToPositiveNumber()
@IsNumber()
@ValidateIf((env) => env.WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION > 0)