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: implement token refresh and logout #54

Merged
merged 1 commit into from
Jun 14, 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
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# JWT AUTH
ACCESS_TOKEN_SECRET=3uper**3ecret**you**may**never**guess
ACCESS_TOKEN_EXPIRATION=3600s
REFRESH_TOKEN_SECRET=replace**it**once**stolen**or**leaked
REFRESH_TOKEN_EXPIRATION=604800s

# DATABASE
DB_TYPE=postgres
Expand Down
2 changes: 2 additions & 0 deletions deployments/compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ services:
environment:
- ACCESS_TOKEN_SECRET=${ACCESS_TOKEN_SECRET}
- ACCESS_TOKEN_EXPIRATION=${ACCESS_TOKEN_EXPIRATION}
- REFRESH_TOKEN_SECRET=${REFRESH_TOKEN_SECRET}
- REFRESH_TOKEN_EXPIRATION=${REFRESH_TOKEN_EXPIRATION}
- DB_HOST=postgresql
- DB_USERNAME=${DB_USERNAME}
- DB_PASSWORD=${DB_PASSWORD}
Expand Down
15 changes: 15 additions & 0 deletions src/migrations/1718393905518-Add_Refresh_Token_To_User.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

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

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "users" ADD "refresh_token" character varying(255)`,
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "refresh_token"`);
}
}
22 changes: 20 additions & 2 deletions src/modules/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { SignupDto } from './dto/signup.dto';
import { UserService } from '@modules/user/user.service';
import { IRequest } from '@modules/user/user.interface';
import { NoAuth } from '@modules/common/decorator/no-auth.decorator';
import { TokenRefreshDto } from './dto/token-refresh.dto';

@Controller('api/auth')
@ApiTags('authentication')
Expand All @@ -22,7 +23,7 @@ export class AuthController {
@ApiResponse({ status: 401, description: 'Unauthorized' })
async login(@Body() dto: LoginDto): Promise<any> {
const user = await this.authService.validateUser(dto);
return this.authService.createToken(user);
return this.authService.createTokenPair(user);
}

@Post('signup')
Expand All @@ -32,7 +33,24 @@ export class AuthController {
@ApiResponse({ status: 401, description: 'Unauthorized' })
async signup(@Body() signupDto: SignupDto): Promise<any> {
const user = await this.userService.create(signupDto);
return this.authService.createToken(user);
return this.authService.createTokenPair(user);
}

@Post('refresh')
@NoAuth()
@ApiResponse({ status: 201, description: 'Successful Token Refresh' })
@ApiResponse({ status: 400, description: 'Bad Request' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
async refresh(@Body() dto: TokenRefreshDto): Promise<any> {
const user = await this.authService.validateRefreshToken(dto);
return this.authService.createTokenPair(user);
}

@Post('logout')
@ApiResponse({ status: 201, description: 'Successful Logout' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
async logout(@Request() request: IRequest): Promise<any> {
return await this.userService.deleteRefreshToken(request.user?.id);
}

@Get('me')
Expand Down
51 changes: 42 additions & 9 deletions src/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { UserService } from '@modules/user/user.service';
import { IUser } from '@modules/user/user.interface';
import { LoginDto } from './dto/login.dto';
import { JwtPayload } from './passport/jwt.strategy';
import { TokenRefreshDto } from './dto/token-refresh.dto';

@Injectable()
export class AuthService {
Expand All @@ -17,31 +18,63 @@ export class AuthService {
) {}

async validateUser(dto: LoginDto): Promise<IUser> {
const user = await this.userService.findByEmail(dto.email);
let user = await this.userService.findByEmail(dto.email);
if (!user) {
throw new UnauthorizedException('User not found.');
}

const isPasswordValid = Hash.compare(dto.password, user.password);
if (!isPasswordValid) {
throw new UnauthorizedException('Wrong password.');
throw new UnauthorizedException('Invalid password');
}

// Exclude() decorator handles this already,
// but just to be sure
const { password, ...result } = user;
delete user.password;

return result;
return user;
}

async createToken(user: IUser) {
async createTokenPair(user: IUser) {
const payload: JwtPayload = {
sub: user.id,
};

const accessToken = this.jwtService.sign(payload);
const refreshToken = this.jwtService.sign(payload, {
secret: this.configService.get<string>('REFRESH_TOKEN_SECRET'),
expiresIn: this.configService.get<string>('REFRESH_TOKEN_EXPIRATION'),
});
const expiresIn = this.configService.get('ACCESS_TOKEN_EXPIRATION');

await this.userService.updateRefreshToken(user.id, refreshToken);

return {
accessToken: this.jwtService.sign(payload),
expiresIn: this.configService.get('ACCESS_TOKEN_EXPIRATION'),
accessToken,
refreshToken,
expiresIn,
};
}

async validateRefreshToken(dto: TokenRefreshDto): Promise<IUser> {
const { sub, exp } = this.jwtService.verify<JwtPayload>(dto.refreshToken, {
secret: this.configService.get<string>('REFRESH_TOKEN_SECRET'),
});

const user = await this.userService.findOne(sub);

const isMatchedRefreshToken = Hash.compare(
dto.refreshToken,
user.refreshToken,
);
if (!isMatchedRefreshToken) {
throw new UnauthorizedException('Invalid refresh token');
}

// exp is in second
const isExpiredRefreshToken = Date.now() > exp * 1000;
if (isExpiredRefreshToken) {
throw new UnauthorizedException('Expired refresh token');
}

return user;
}
}
10 changes: 10 additions & 0 deletions src/modules/auth/dto/token-refresh.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString } from 'class-validator';

export class TokenRefreshDto {
@ApiProperty({
required: true,
})
@IsString()
refreshToken: string;
}
2 changes: 2 additions & 0 deletions src/modules/common/transformer/password.transformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import { ValueTransformer } from 'typeorm';
import { Hash } from '../../../utils/hash.util';

export class PasswordTransformer implements ValueTransformer {
// Hash password when saving to database
to(value: string) {
return Hash.make(value);
}

// Get hashed password as is
from(value: string) {
return value;
}
Expand Down
2 changes: 2 additions & 0 deletions src/modules/main/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import { CaslModule } from '@modules/infrastructure/casl/casl.module';
validationSchema: Joi.object({
ACCESS_TOKEN_SECRET: Joi.string().min(16).required(),
ACCESS_TOKEN_EXPIRATION: Joi.string().alphanum().default('900s'),
REFRESH_TOKEN_SECRET: Joi.string().min(16).required(),
REFRESH_TOKEN_EXPIRATION: Joi.string().alphanum().default('86400s'),
DB_TYPE: Joi.string().valid('postgres', 'mysql').default('postgres'),
DB_HOST: Joi.string().hostname().required(),
DB_PORT: Joi.number().integer().min(1).max(65535).default(5432),
Expand Down
9 changes: 9 additions & 0 deletions src/modules/user/user.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,15 @@ export class User {
@Exclude()
password: string;

@Column({
name: 'refresh_token',
length: 255,
transformer: new PasswordTransformer(),
nullable: true,
})
@Exclude()
refreshToken: string;

@ManyToMany(() => Role, { eager: true })
@JoinTable({
name: 'users_roles',
Expand Down
12 changes: 12 additions & 0 deletions src/modules/user/user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,4 +149,16 @@ export class UserService {
async delete(id: number) {
return await this.userRepository.delete(id);
}

async updateRefreshToken(id: number, refreshToken: string) {
return await this.userRepository.update(id, {
refreshToken,
});
}

async deleteRefreshToken(id: number) {
return await this.userRepository.manager.connection
.query(`UPDATE users SET refresh_token = NULL WHERE id = $1`, [id])
.catch((e) => console.log(e));
}
}
Loading