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:
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -29,3 +29,12 @@ export class AuthTokens {
|
||||
@Field(() => AuthTokenPair)
|
||||
tokens: AuthTokenPair;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class PasswordResetToken {
|
||||
@Field(() => String)
|
||||
passwordResetToken: string;
|
||||
|
||||
@Field(() => Date)
|
||||
passwordResetTokenExpiresAt: Date;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType()
|
||||
export class ValidatePasswordResetToken {
|
||||
@Field(() => String)
|
||||
id: string;
|
||||
|
||||
@Field(() => String)
|
||||
email: string;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@ -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: {},
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
@ -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"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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)
|
||||
|
||||
Reference in New Issue
Block a user