Skip to content

Commit

Permalink
Merge pull request #54 from crazyoptimist/feat/token-refresh-and-logout
Browse files Browse the repository at this point in the history
feat: implement token refresh and logout
  • Loading branch information
crazyoptimist authored Jun 14, 2024
2 parents 40a208c + e9d6c9b commit 73a1834
Show file tree
Hide file tree
Showing 10 changed files with 116 additions and 11 deletions.
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));
}
}

0 comments on commit 73a1834

Please sign in to comment.