Skip to content

Commit

Permalink
email confirmation flow
Browse files Browse the repository at this point in the history
  • Loading branch information
alexeh committed Oct 14, 2024
1 parent 22416f7 commit 248c4f7
Show file tree
Hide file tree
Showing 6 changed files with 96 additions and 38 deletions.
16 changes: 16 additions & 0 deletions api/src/modules/auth/authentication.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { AuthenticationService } from '@api/modules/auth/authentication.service'
import { SignUp } from '@api/modules/auth/strategies/sign-up.strategy';
import { CommandBus } from '@nestjs/cqrs';
import { RequestPasswordRecoveryCommand } from '@api/modules/auth/commands/request-password-recovery.command';
import { EmailConfirmation } from '@api/modules/auth/strategies/email-update.strategy';

@Controller()
@UseInterceptors(ClassSerializerInterceptor)
Expand Down Expand Up @@ -86,6 +87,21 @@ export class AuthenticationController {
);
}

@UseGuards(AuthGuard(EmailConfirmation))
@TsRestHandler(authContract.confirmEmail)
async confirmEmail(@GetUser() user: User): Promise<ControllerResponse> {
return tsRestHandler(
authContract.confirmEmail,
async ({ query: { newEmail } }) => {
await this.authService.confirmEmail(user, newEmail);
return {
body: null,
status: HttpStatus.OK,
};
},
);
}

@Public()
@TsRestHandler(authContract.validateToken)
async validateToken(): Promise<ControllerResponse> {
Expand Down
8 changes: 8 additions & 0 deletions api/src/modules/auth/authentication.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { ResetPasswordJwtStrategy } from '@api/modules/auth/strategies/reset-pas
import { JwtManager } from '@api/modules/auth/services/jwt.manager';
import { SignUpStrategy } from '@api/modules/auth/strategies/sign-up.strategy';
import { PasswordManager } from '@api/modules/auth/services/password.manager';
import { EmailConfirmationJwtStrategy } from '@api/modules/auth/strategies/email-update.strategy';

@Module({
imports: [
Expand Down Expand Up @@ -56,6 +57,13 @@ import { PasswordManager } from '@api/modules/auth/services/password.manager';
},
inject: [UsersService, ApiConfigService],
},
{
provide: EmailConfirmationJwtStrategy,
useFactory: (users: UsersService, config: ApiConfigService) => {
return new EmailConfirmationJwtStrategy(users, config);
},
inject: [UsersService, ApiConfigService],
},
],
exports: [UsersModule, AuthenticationService, JwtManager],
})
Expand Down
9 changes: 9 additions & 0 deletions api/src/modules/auth/authentication.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,4 +139,13 @@ export class AuthenticationService {
new SendEmailConfirmationEmailCommand(user, newEmail, origin),
);
}

async confirmEmail(user: User, newEmail: string): Promise<void> {
const existingUser = await this.usersService.findByEmail(newEmail);
if (existingUser) {
throw new ConflictException(`Email already in use`);
}
user.email = newEmail;
await this.usersService.saveUser(user);
}
}
39 changes: 39 additions & 0 deletions api/src/modules/auth/strategies/email-update.strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { UsersService } from '@api/modules/users/users.service';
import { ApiConfigService } from '@api/modules/config/app-config.service';
import { TOKEN_TYPE_ENUM } from '@shared/schemas/auth/token-type.schema';
import { Request } from 'express';

export type JwtPayload = { id: string };

export const EmailConfirmation = 'email-confirmation';

@Injectable()
export class EmailConfirmationJwtStrategy extends PassportStrategy(
Strategy,
EmailConfirmation,
) {
constructor(
private readonly userService: UsersService,
private readonly config: ApiConfigService,
) {
const { secret } = config.getJWTConfigByType(
TOKEN_TYPE_ENUM.EMAIL_CONFIRMATION,
);
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: secret,
});
}

async validate(req: Request, payload: JwtPayload) {
const { id } = payload;
const user = await this.userService.findOneBy(id);
if (!user) {
throw new UnauthorizedException();
}
return user;
}
}
60 changes: 23 additions & 37 deletions api/test/integration/users/users-email-update.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,19 @@ import { usersContract } from '@shared/contracts/users.contract';
import { ROLES } from '@shared/entities/users/roles.enum';
import { MockEmailService } from '../../utils/mocks/mock-email.service';
import { IEmailServiceToken } from '@api/modules/notifications/email/email-service.interface';
import { JwtManager } from '@api/modules/auth/services/jwt.manager';
import { User } from '@shared/entities/users/user.entity';
import { authContract } from '@shared/contracts/auth.contract';

describe('Users ME (e2e)', () => {
let testManager: TestManager;
let jwt: JwtManager;
let emailService: MockEmailService;

beforeAll(async () => {
testManager = await TestManager.createTestManager();
emailService = testManager.getModule<MockEmailService>(IEmailServiceToken);
jwt = testManager.getModule<JwtManager>(JwtManager);
});

beforeEach(async () => {
Expand Down Expand Up @@ -87,56 +92,37 @@ describe('Users ME (e2e)', () => {
.mocks()
.createUser({ email: '[email protected]', role: ROLES.PARTNER });

const { jwtToken, password: oldPassword } =
await testManager.logUserIn(user);
const newPassword = 'newPassword';
const { emailUpdateToken } = await jwt.signEmailUpdateToken(user.id);
const newEmail = '[email protected]';
const response = await testManager
.request()
.patch(usersContract.updatePassword.path)
.send({ password: oldPassword, newPassword })
.set('Authorization', `Bearer ${jwtToken}`);
.get(authContract.confirmEmail.path)
.query({ newEmail })
.set('Authorization', `Bearer ${emailUpdateToken}`);

expect(response.status).toBe(200);
expect(response.body.data.id).toEqual(user.id);

const { jwtToken: noToken } = await testManager.logUserIn({
...user,
password: oldPassword,
});
expect(noToken).toBeUndefined();

const { jwtToken: newToken } = await testManager.logUserIn({
...user,
password: newPassword,
});

expect(newToken).toBeDefined();
const userWithUpdatedEmail = await testManager
.getDataSource()
.getRepository(User)
.findOneBy({ email: newEmail });
expect(userWithUpdatedEmail.id).toEqual(user.id);
});
it('should fail if the email confirmation token is not authorized', async () => {
it('should fail if the new email is already in use', async () => {
const user = await createUser(testManager.getDataSource(), {
email: '[email protected]',
role: ROLES.PARTNER,
});

const { jwtToken } = await testManager.logUserIn(user);
const newName = 'newName';
const response = await testManager
.request()
.patch(usersContract.updateMe.path)
.send({ name: newName })
.set('Authorization', `Bearer ${jwtToken}`);
expect(response.status).toBe(201);
expect(response.body.data.id).toEqual(user.id);
expect(response.body.data.name).toEqual(newName);
const { emailUpdateToken } = await jwt.signEmailUpdateToken(user.id);

// Previous token should work after updating the user's email
const userMeResponse = await testManager
const response = await testManager
.request()
.get('/users/me')
.set('Authorization', `Bearer ${jwtToken}`);
.get(authContract.confirmEmail.path)
.query({ newEmail: user.email })
.set('Authorization', `Bearer ${emailUpdateToken}`);

expect(userMeResponse.status).toBe(200);
expect(userMeResponse.body.data.name).toEqual(newName);
expect(response.status).toBe(409);
expect(response.body.errors[0].title).toBe('Email already in use');
});
});
});
2 changes: 1 addition & 1 deletion shared/contracts/auth.contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export const authContract = contract.router({
query: z.object({ newEmail: z.string().email() }),
path: "/authentication/confirm-email",
responses: {
201: null,
200: null,
},
},
});

0 comments on commit 248c4f7

Please sign in to comment.