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

CRYP-56 Add feature login with email link #50

Merged
merged 1 commit into from
May 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 27 additions & 5 deletions app/apps/onebox/src/modules/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@ import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Request } from 'express';
import { User } from '../users/schemas/user.schema';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { LoginResponseDto } from './dto/login.reponse.dto';
import { LoginEmailDto, LoginWithTokenDto } from './dto/login.dto';
import {
LoginEmailResponseDto,
LoginResponseDto,
} from './dto/login.reponse.dto';
import { LocalAuthGuard } from './guards/local-auth.guard';

@ApiTags('Auth')
Expand All @@ -24,10 +27,29 @@ export class AuthController {
@Post('login')
@HttpCode(200)
@ApiOkResponse({ type: LoginResponseDto })
async login(
async login(@Req() req: Request): Promise<LoginResponseDto> {
return this.authService.login(req.user as User);
}

@ApiOperation({ summary: 'Login Using Email only' })
@Post('login-email')
@HttpCode(200)
@ApiOkResponse({ type: LoginEmailResponseDto })
async loginWithEmail(
@Req() req: Request,
@Body() body: LoginDto,
@Body() body: LoginEmailDto,
): Promise<LoginEmailResponseDto> {
return this.authService.loginWithEmail(body);
}

@ApiOperation({ summary: 'Login Using Token' })
@Post('login-token')
@HttpCode(200)
@ApiOkResponse({ type: LoginResponseDto })
async loginWithToken(
@Req() req: Request,
@Body() body: LoginWithTokenDto,
): Promise<LoginResponseDto> {
return this.authService.login(req.user as User);
return this.authService.loginWithToken(body);
}
}
5 changes: 4 additions & 1 deletion app/apps/onebox/src/modules/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { LocalStrategy } from './strategies/local.strategy';
import { DatabaseModule } from '@app/database';
import { UsersProviders } from '../users/users.providers';

@Module({
imports: [
Expand All @@ -18,9 +20,10 @@ import { LocalStrategy } from './strategies/local.strategy';
}),
UsersModule,
PassportModule,
DatabaseModule,
],
controllers: [AuthController],
providers: [AuthService, LocalStrategy, JwtStrategy],
providers: [AuthService, LocalStrategy, JwtStrategy, ...UsersProviders],
exports: [AuthService],
})
export class AuthModule {}
68 changes: 66 additions & 2 deletions app/apps/onebox/src/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,24 @@
import { ErrorCode } from '@app/global/global.error';
import { comparePassword } from '@app/utils/bcrypt.util';
import { Injectable } from '@nestjs/common';
import { sendEmail } from '@app/utils/email.sender';
import { renderTemplate } from '@app/utils/file-template';
import { generateUUID } from '@app/utils/uuidUtils';
import { Inject, Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Builder } from 'builder-pattern';
import { Model } from 'mongoose';
import { User } from '../users/schemas/user.schema';
import { UsersService } from '../users/users.service';
import { LoginResponseDto } from './dto/login.reponse.dto';
import { LoginEmailDto, LoginWithTokenDto } from './dto/login.dto';
import {
LoginEmailResponseDto,
LoginResponseDto,
} from './dto/login.reponse.dto';

@Injectable()
export class AuthService {
constructor(
@Inject('USER_MODEL') private readonly userModel: Model<User>,
private usersService: UsersService,
private jwtService: JwtService,
) {}
Expand All @@ -29,4 +39,58 @@ export class AuthService {
};
return new LoginResponseDto(this.jwtService.sign(payload));
}

async loginWithEmail(request: LoginEmailDto): Promise<LoginEmailResponseDto> {
const user = await this.usersService.findOne(request.email.toLowerCase());
if (!user) {
throw ErrorCode.ACCOUNT_NOT_FOUND.asException();
}

const loginToken = generateUUID();
const tokenExpire = Date.now() + 5 * 60 * 1000; // 5 minutes
await this.userModel.updateOne(
{ userId: user.userId },
{
emailLogin: {
token: loginToken,
expire: tokenExpire,
},
},
);
const encodedToken = Buffer.from(`${user.email}:${loginToken}`).toString(
'base64',
);
const linkEmailLogin = `https://${process.env.WEB_DOMAIN}/token-login?token=${encodedToken}`;
const emailBody = await renderTemplate(
'resources/email_template/token_login.html',
{
linkEmailLogin,
expire: new Date(tokenExpire).toUTCString(),
},
);
sendEmail(user.email, 'Login Confirmation', emailBody);
return Builder<LoginEmailResponseDto>().success(true).build();
}

async loginWithToken(request: LoginWithTokenDto): Promise<LoginResponseDto> {
const decodedToken = Buffer.from(request.token, 'base64').toString();
const [email, loginToken] = decodedToken.split(':');
const user = await this.usersService.findOne(email);
if (!user || !user.emailLogin) {
throw ErrorCode.WRONG_EMAIL_OR_TOKEN.asException();
}
if (user.emailLogin.token !== loginToken) {
throw ErrorCode.WRONG_EMAIL_OR_TOKEN.asException();
}
if (user.emailLogin.expire < Date.now()) {
throw ErrorCode.WRONG_EMAIL_OR_TOKEN.asException();
}
await this.userModel.updateOne(
{ userId: user.userId },
{
$unset: { emailLogin: '' },
},
);
return this.login(user);
}
}
17 changes: 16 additions & 1 deletion app/apps/onebox/src/modules/auth/dto/login.dto.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNotEmpty, IsEmail } from 'class-validator';
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';

export class LoginDto {
@ApiProperty()
Expand All @@ -13,3 +13,18 @@ export class LoginDto {
@IsNotEmpty()
password: string;
}

export class LoginEmailDto {
@ApiProperty()
@IsString()
@IsEmail()
@IsNotEmpty()
email: string;
}

export class LoginWithTokenDto {
@ApiProperty()
@IsString()
@IsNotEmpty()
token: string;
}
5 changes: 5 additions & 0 deletions app/apps/onebox/src/modules/auth/dto/login.reponse.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,8 @@ export class LoginResponseDto {
this.accessToken = accessToken;
}
}

export class LoginEmailResponseDto {
@ApiResponseProperty()
success: boolean;
}
6 changes: 6 additions & 0 deletions app/apps/onebox/src/modules/users/schemas/user.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ export class User {
token: string;
expire: number;
};

@Prop({ type: Object })
emailLogin: {
token: string;
expire: number;
};
}

export const UserSchema = SchemaFactory.createForClass(User);
1 change: 1 addition & 0 deletions app/libs/global/src/global.error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export class ErrorCode {
401002,
'Wrong email or password',
);
static WRONG_EMAIL_OR_TOKEN = new ErrorCode(401003, 'Wrong email or token');

// Forbidden - Client is authorized but doesn't have permission to perform action
static FORBIDDEN = new ErrorCode(403001, 'Forbidden');
Expand Down
109 changes: 109 additions & 0 deletions app/resources/email_template/token_login.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Document</title>
</head>
<body>
<table align="center">
<tbody>
<tr>
<td>
<table
align="center"
style="
width: 100%;
margin: 0;
padding: 0;
background-color: #ffffff;
width: 700px;
border-radius: 5px;
border: solid 1px #ebebeb;
margin-left: auto;
margin-right: auto;
"
>
<tbody>
<tr style="height: 131px; text-align: center">
<td style="padding: 0; border-bottom: solid 1px #ebebeb">
<a href="https://crypitor.com/" target="_blank">
<img
src="https://crypitor-asset.s3.ap-southeast-1.amazonaws.com/Artboard+2+copy.png"
width="300"/>

</a>
</td>
</tr>
<tr>
<td style="padding: 24px 67px 46px">
<div style="font-size: 17px; font-weight: 500">
Hello,
</div>
<div
style="
margin-top: 24px;
margin-bottom: 24px;
font-size: 17px;
line-height: 30px;
"
>
You have requested to login to your account.
<br>
Please click on the button below to complete your login.
<br>
Your request is valid for 5 minutes and will be expired at {{ expire }}.
</div>
<div style="text-align: center">
<a
href="{{ linkEmailLogin }}"
target="_blank"
style="
font-size: 18px;
font-weight: 600;
display: inline-block;
background-color: #0052FF;
text-align: center;
color: white;
text-decoration: none;
border-radius: 5px;
padding: 16px 24px;
"
>
Login now
</a>
</div>
<div
style="
margin-top: 24px;
margin-bottom: 24px;
font-size: 17px;
line-height: 30px;
"
>
If you did not perform this action, please contact us immediately at <a href="mailto:[email protected]">[email protected]</a>
</div>
</td>
</tr>
<tr>
<td
style="
text-align: center;
color: #878787;
font-size: 11px;
padding-bottom: 29px;
line-height: 20px;
"
>
© 2024 All rights reserved
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>