이전 글까지는 Authentication을 준비하는 단계였습니다. 이번글에서는 JWT
를 이용한 회원가입과 로그인 과정 및 사용자 인증(Authentication)** 과정을 다루어 보도록 하겠습니다.
참고 🔍
forFeature()
메서드를 사용하여 현재 scope(User)에서 어떤 repository를 등록할 것 인지 결정합니다.// src/user/user.module.ts
import { Module } from '@nestjs/common';
import { UserService } from './user.service';
import { UserController } from './user.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entity/user.entity';
@Module({
imports: [TypeOrmModule.forFeature([User])],
providers: [UserService],
controllers: [UserController],
})
export class UserModule {}
UsersRepository
를 @InjectRepository()
decorator를 사용하여 UserService
안에 inject 할 수 있습니다.// src/user/user.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entity/user.entity';
@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
private readonly usersRepository: Repository<User>,
) {}
}
참고 🔍
$ npm i class-validator class-transformer
// src/main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
await app.listen(3000);
}
bootstrap();
// src/user/dtos/create-user.dto.ts
import { IsEmail, IsString } from 'class-validator';
export class CreateUserRequestDto {
@IsEmail()
username: string;
@IsString()
password: string;
@IsString()
name: string;
}
save()
method를 통해 database에 object를 insert 해줍니다. 그전에 등록할 계정이 이미 존재한다면 error를 반환합니다.// src/user/user.service.ts
import { ForbiddenException, HttpException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CreateUserRequestDto } from './dtos/create-user.dto';
import { User } from './entity/user.entity';
@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
private readonly usersRepository: Repository<User>,
) {}
async create(data: CreateUserRequestDto) {
const isExist = await this.usersRepository.findOne({
username: data.username,
});
if (isExist) {
throw new ForbiddenException({
statusCode: HttpStatus.FORBIDDEN,
message: [`이미 등록된 사용자입니다.`],
error: 'Forbidden',
});
}
try {
await this.usersRepository.save(data);
} catch (error) {
return {
...error,
};
}
return {
statusCode: HttpStatus.CREATED,
};
}
}
POST
요청으로 body에 사용자를 생성할 데이터를 http://localhost:{PORT}/user
로 받습니다.UserService
instance를 inject 합니다.// src/user/user.controller.ts
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { CreateUserRequestDto } from './dtos/create-user.dto';
import { UserService } from './user.service';
@Controller('user')
export class UserController {
constructor(private readonly userService: UserService) {}
@Post()
@HttpCode(HttpStatus.CREATED)
create(@Body() data: CreateUserRequestDto) {
return this.userService.create(data);
}
}
사용자 등록하기 위해 DB에 Insert 되기 전, 비밀번호를 hashing 하는 작업을 해보도록 하겠습니다.
$ npm install bcrypt
TypeORM Listeners and Subscribers
TypeORM - Listeners and Subscribers 를 참고하시면 TypeORM에 특별한 기능이 있습니다.
Any of your entities can have methods with custom logic that listen to specific entity events. You must mark those methods with special decorators depending on what event you want to listen to.
모든 Entity들은 특정 entity event를 기다리고 있는 사용자 정의 로직 메서드를 가질 수 있습니다.
저는 @BeforeInsert()
라는 데코레이터를 사용하여, 사용자를 등록하여 DB에 Insert 되기 전, 비밀번호를 hashing 하는 작업을 해보도록 하겠습니다.
// src/user/entity/user.entity.ts
// ...
@Entity({ name: 'users' })
export class User {
// ...
@BeforeInsert()
async hashPassword(): Promise<void> {
try {
this.password = await bcrypt.hash(this.password, 10);
} catch (e) {
console.log(e);
throw new InternalServerErrorException();
}
}
}
아이디와 비밀번호로 로그인을 받으면 우리의 서버에서 계정 정보를 검증하고 맞다면 sigined 토큰을 발급 해줍니다. (토큰에는 중요한 개인정보가 담기면 안됩니다! 그저 발급한 토큰이 우리의 서버에서 정상적으로 발급된 토큰임을 증명하기 위함입니다.)
(나중에 사용자는 인증이 필요한 End point에 요청하기 위해 발급받은 토큰을 이용하게 됩니다.)
$ npm i jsonwebtoken
$ nest generate module jwt
$ nest generate service jwt
그리고 JWT Module을 option으로 privateKey를 받는 동적모듈로 만들기 위해 option interface와 di token 값(jwt constant
)을 만들어줍니다.
참고 🔍
export interface JwtModuleOptions {
privateKey: string;
}
export const CONFIG_OPTIONS = 'CONFIG_OPTIONS';
@Global()
decorator로 전역에서 사용할 수 있도록 하고, forRoot()
static method로 Dynamic Module을 반환하도록 합니다.@Module({})
@Global()
export class JwtModule {
static forRoot(options: JwtModuleOptions): DynamicModule {
return {
module: JwtModule,
exports: [JwtService],
providers: [
{
provide: CONFIG_OPTIONS,
useValue: options,
},
JwtService,
],
};
}
}
AppModule
에 JWTModule
을 import 하도록 합시다. privateKey
는 JWT 생성과 검증에 필요한 비밀키를 지정합니다. (예: mYsEcReTkEy0011)// src/app.module.ts
@Module({
imports: [
JwtModule.forRoot({
privateKey: process.env.PRIVATE_KEY, // JWT private Key 아무거나.
}),
]
})
// ...
JwtService
에는 토큰 발행을 담당하는 sign
method를 정의해줍니다. 토큰에는 userId
(uuid)를 담아서 줍니다.import { Inject, Injectable } from '@nestjs/common';
import * as jwt from 'jsonwebtoken';
import { CONFIG_OPTIONS } from './jwt.constants';
import { JwtModuleOptions } from './jwt.interface';
@Injectable()
export class JwtService {
constructor(
@Inject(CONFIG_OPTIONS) private readonly options: JwtModuleOptions,
) {}
// 로그인 성공하면 token을 만들어 보냄
sign(userId: string): string {
return jwt.sign({ id: userId }, this.options.privateKey);
}
}
id와 password를 받아 사용자를 확인하는 기능을 만듭니다.
참고 🔍
// src/user/entity/user.entity.ts
@Entity({ name: 'users' })
export class User {
// ...
async checkPassword(inputPassword: string): Promise<boolean> {
try {
return await bcrypt.compare(inputPassword, this.password);
} catch (error) {
console.log(error);
throw new InternalServerErrorException({
...error.response,
});
}
}
}
export class SignInRequestDto {
username: string;
password: string;
}
export class SignInResponseDto {
statusCode: number;
token?: string;
error?: string;
message?: string;
}
// src/user/user.service.ts
@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
private readonly jwtService: JwtService,
) {}
// ...
async signIn({
username,
password,
}: SignInRequestDto): Promise<SignInResponseDto> {
try {
const user = await this.userRepository.findOne({ username });
if (!user) {
throw new NotFoundException({
error: 'Not Found',
message: ['사용자를 찾지 못했습니다.'],
});
}
const passwordCorrect = await user.checkPassword(password);
if (!passwordCorrect) {
throw new BadRequestException({
error: 'Bad Request',
message: ['비밀번호가 틀렸습니다.'],
});
}
const token = this.jwtService.sign(user.id);
return {
statusCode: 201,
token,
};
} catch (error) {
return {
statusCode: error.status,
...error.response,
};
}
}
}
username
과 password
를 받습니다.@Controller('users')
export class UserController {
constructor(private readonly userService: UserService) {}
@Post('/sign-in')
signIn(@Body() data: SignInRequestDto): Promise<SignInResponseDto> {
return this.userService.signIn(data);
}
}
로그인된 사용자가 인증이 필요한 서버의 어느 한 End point로 요청하기위해 발행된 토큰을 request header의 x-jwt
라는 이름으로 담아 요청합니다.
이 때 우리는 모든 요청에 대해 header를 검사하는 JwtMiddleware
를 만들어 줍니다.
verify
method를 추가해줍니다.// src/jwt/jwt.service.ts
@Injectable()
export class JwtService {
// ...
verify(token: string) {
return jwt.verify(token, this.options.privateKey);
}
}
// src/user/user.service.ts
@Injectable()
export class UserService {
// ...
async findById(id: string): Promise<User> {
return await this.userRepository.findOne(id);
}
}
x-jwt
토큰을 검증하여 request object에 'user': User
를 추가합니다.next()
method를 호출하여 다음 함수로 넘어갑니다. (header에 x-jwt
이 없다면 user를 추가하지 않고 다음으로 넘어갑니다.)// src/jwt/jwt.middleware.ts
@Injectable()
export class JwtMiddleware implements NestMiddleware {
constructor(
private readonly userService: UserService,
private readonly jwtService: JwtService,
) {}
async use(req: Request, res: Response, next: NextFunction) {
if ('x-jwt' in req.headers) {
const token = req.headers['x-jwt'];
const decoded = this.jwtService.verify(token.toString());
if (typeof decoded === 'object' && decoded.hasOwnProperty('id')) {
try {
const user = await this.userService.findById(decoded['id']);
req['user'] = user;
} catch (err) {
console.log(err);
}
}
}
next();
}
}
// src/app.module.ts
@Module({
// ...
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(JwtMiddleware)
.forRoutes({ path: '/', method: RequestMethod.ALL });
}
}
참고 🔍
$ nest generate guard auth auth
@Injectable()
export class AuthGuard implements CanActivate {
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
const request = context.switchToHttp().getRequest();
const user: User = request['user'];
if (!user) return false;
return true;
}
}
@UserGuards()
decorator를 사용하여 우리가 만든 AuthGuard
를 적용해줍니다.Get Me!
를 받고, 검증에 실패하면 UnAuthorized Error
를 받습니다.// src/user/user.controller.ts
@Controller('users')
export class UserController {
// ...
@Get('/me')
@UseGuards(AuthGuard)
getMe() {
return 'Get Me!';
}
}
참고 🔍
user
를 넣었었습니다. 이를 next()
method로 다음으로 넘어온 함수에서 사용하기 위해 Custom Decorator로 정의한 @AuthUser()
를 사용합니다.// src/auth/auth-user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const AuthUser = createParamDecorator(
(data: unknown, context: ExecutionContext) => {
const request = context.switchToHttp().getRequest();
const user = request['user'];
return user;
},
);
참고 🔍
사용자 인증 / 인가 부분은 많은 기술 내용들과 작업들을 담고 있기 때문에 글을 쓰면서도 아직 부족하다는 느낌을 많이 받았습니다. 추후에 수정을 하면서 채워나가도록 하겠습니다...
༼;´༎ຶ + ༎ຶ༽