웹 사이트를 만들 때 그 무엇보다도 중요한 것이 하나 있다.
허가되지 않은 사람이 특정 영역에 접근하지 않게 하는 것
그것을 방지하기 위해 여러 방식이 있지만, 여기서는 jwt 토큰을 이용해 유요한 토큰인지 확인 한 후 접근을 할 수 있도록 간단하게 만들어 보겠다.
authorication(인가)도 있긴 한데 뭔지 몰?루 인증만 잘 만들어두면 되지 않을까?
여기서는 이전에 만들어둔 Users를 사용하도록 하겠다.
TypeORM과 바로 연동이 가능하게 하는 이유이다.
JWT를 사용하기 때문에 npm install --save @nestjs/jwt, yarn add @nestjs/jwt --save로 패키지를 설치하자.
Module을 사용하려면 기본적으로 controller와 service가 필요하다.
nest가 제공해주는 명령어로 간단하게 만들어두자.
nest g module auth
nest g controller auth
nest g service auth
그리고 env를 설정해주자.
앞에서도 설명했다시피, 루트 디렉터리에 위치한다.
단순히 JWT 인증을 하는 비밀키를 JWT_SECRET=this_is_my_secret로 설정하였다.
실 사용에는 복잡한 인증키를 사용하도록!
모듈은 아래와 같이 설정한다.
@Module({
imports: [
ConfigModule.forRoot( {
envFilePath:['.development.env']
}), // env를 가져오기 위한 import
JwtModule.register({
global: true,
secret: process.env.JWT_SECRET, // jwt secret을 가져온다.
signOptions: { expiresIn: '60s' },
}),
],
controllers: [AuthController],
providers: [AuthService],
})
export class AuthModule {}
Auth 모듈 전체에서 UsersService를 참고해야한다. 즉 의존성이 존재하게 된다.
이를 위해 Nest에서는 좋은 기능을 넣어줬는데, 바로 exports이다.
이를 쓰면 손쉽게 의존성 주입이 가능하다는 소리! 그래서 의존성 주입이 뭐냐고 씹덕아(대충 'A가 B를 참고한다' 정도로만 이해하자)
기존에 있던 UsersModule에서 exports로 UsersService를 내보내자.
// users.module.ts
@Module({
... ,
exports: [UsersService] // auth에서 사용
})
export class UsersModule {}
그리고 회원가입 시 입력한 ID로 비밀번호 및 여러가지를 찾기 위해 아래의 서비스 함수를 하나 추가하였다.
// users.service.ts
findOneWithId(id: string): Promise<User> {
return this.userRepository.findOne({
where: {
id: id,
}
});
}
일단 서비스를 작성한다.
//auth.service.ts
@Injectable()
export class AuthService {
constructor(
private usersService: UsersService,
private jwtService: JwtService
) {}
async signIn(id: string, pass: string): Promise<any> {
if (id === undefined || id === '') {
throw new BadRequestException();
} // id가 비어있으면 HttpRequest.BAD_REQUEST
const user = await this.usersService.findPasswordWithId(id);
if (user?.password !== pass) {
throw new UnauthorizedException();
} // 비밀번호가 다르면 Unauthorizaed
// password와 같은 항목은 password에, 나머지는 result에 저장
const { password, ...result } = user;
// ========== jwt 토큰을 만드는 과정 ==========
const payload = { sub: user.uid, id: user.id, nickname: user.nickname, email: user.email };
return {
access_token: await this.jwtService.signAsync(payload),
}
// =========================================
}
async checkValidate(payload: string): Promise<any> {
// Secret이 어디있자? 하는데 "모듈 설정"에 secret을 '이것'으로 사용하겠다고
// 미리 알려줬다.
return this.jwtService.verifyAsync(payload)
}
}
이때, UsersService를 못찾는다고 오류가 발생하면 정상이다.
AuthModule로 가서 UserModule을 import 해주자.
@Module({
imports: [
... ,
UsersModule,
],
...
})
export class AuthModule {}
서비스까지 만들어놨고, 컨트롤러의 라우터를 추가해보자.
/auth/login으로 post를 보낼 경우 jwt 토큰을 발급하도록 하자.
// auth.controller.ts
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {} // service 객체 생성
@HttpCode(HttpStatus.OK)
@Post('login')
siginIn(@Body() siginInDto: LoginDto) {
return this.authService.signIn(siginInDto.id, siginInDto.password);
}
}
// login dto
export class LoginDto {
@IsString()
readonly id: string
@IsString()
readonly password: string
}

토큰이 정상적으로 발급된다.
이후에 토큰을 보낼땐 Bearer를 붙여서 보내는 것을 잊지 말자.
컨트롤러에서 토큰을 검사하는 것은 매우 비효율적이다.
Guard를 만들어 컨트롤러 앞단에서 들어가게 할지 거부할지 결정하도록 하자.
// auth.guard.ts
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private jwtService: JwtService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
// 헤더로부터 토큰 가져옴.
// 헤더의 필드는 Authorization
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException();
}
try {
const payload = await this.jwtService.verifyAsync(
token,
{
secret: process.env.JWT_SECRET // 공식 docs에는 키를 따로 만들었지만, 우리는 env를 쓴다.
}
);
// 가드에서 request에 payload를 추가해 줄 수 있다.
request['user'] = payload;
} catch {
throw new UnauthorizedException();
}
return true;
}
private extractTokenFromHeader(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}
이제 이 가드를 라우터에 연결해주어야 한다.
라우터를 하나 만들어서 테스트를 해보자.
// auth.controller.ts
@UseGuards(AuthGuard)
@Get('profile')
getProfile(@Req() req) {
return req.user
}
이제 토큰을 이용해서 사용자의 프로필을 받아오도록 하자!
여기서는 앞에서 받은 토큰을 이용한다.

user service에서 비밀번호는 제외하고 주도록 설정하였다.
따라서 비밀번호를 제외한 데이터와 발급일, 만료일이 설정되어있음을 볼 수 있다.
토큰 인증은 하나 만들어놓기만 하면 여러 곳에서 사용 할 가능성이 매우 높다.
따라서 이 Auth 모듈에서 다른 모듈이 AuthService를 이용할 수 있도록 해주어야 한다.
AuthModule에서 AuthService를 export하게 해주자.
// auth.module.ts
@Module({
... ,
exports: [AuthService],
})
export class AuthModule {}
이제 AuthModule을 import하는 모듈들은 전부 AuthService를 사용할 수 있게 되었다.
하지만 이것으로 만족해도 될까?
분명 목표는 Global하게 적용하는 것인데 말이다.
AppModule, 즉 최상위 모듈을 수정하자.
// app.module.ts
@Module({
... ,
providers: [
... ,
{
provide: APP_GUARD, // 전역 가드로 쓸건데
useClass: AuthGuard, // 이걸 등록해줘
}
],
})
export class AppModule implements NestModule {}
전역 가드로 생성하면 한 가지 골치 아픈 점이 생긴다.
바로 모든 라우터에 전역 가드가 적용된다!
이를 해결하기 위해서 공식 문서에 방법이 적혀있다.
아래의 코드는 함수의 metadata에 isPublic: true라는 <키, 밸류> 쌍을 추가해준다.
export const IS_PUBLIC_KEY = 'isPublic'
export const SkipAuth = () => SetMetadata(IS_PUBLIC_KEY, true) // 이것으로 Guard를 무시할 수 있게 한다.
이제 이를 사용하면 아래와 같다.
@SkipAuth() // ① : IS_PUBLIC_KEY 메타데이터 추가
@Get()
// @UseGuard() // ② : 2개 쓰면 큰일난다. GlobalGuard→RouterGuard 2번 걸리기 때문.
findAll() {
return [];
}
메타데이터만 추가한다고 Nest에서 알아서 음 이건 true니 pass해야겠군 하진 않는다.
가드에 pass, non-pass 하는 로직을 작성해주어야 한다.
가드의 canActivate 메소드 최상단에 아래의 코드를 추가한다.
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]); // 메타데이터를 확인해서 true인지 확인
if (isPublic) { // IS_PUBLIC_KEY 가 선언되어있고 true라면?
return true;
}
reflector: 메타데이터 및 어노테이션과 관련된 작업을 수행하는 유틸리티 클래스
메타데이터? 어디서 들어본 것 같지 않는가?
바로 위에서 말한 SetMetadata이다.
메타데이터를 읽거나 수정할 수 있다고 한다.
getAllAndOverride: Reflector 인스턴스의 getAllAndOverride 메소드를 호출
원형은 getAllAndOverride<T = any>(key: string | symbol, metatypes: any[]): T;이다.
여기서 getHandler()는 라우터, getClass()는 컨트롤러이다. 즉 라우터나 컨트롤러에 해당 메타데이터가 존재한다면 해당 값을 반환하겠다는 뜻.
AuthController는 아래와 같이 작성된다.
// auth.controller.ts
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@SkipAuth()
@HttpCode(HttpStatus.OK)
@Post('login')
siginIn(@Body() siginInDto: LoginDto) {
return this.authService.signIn(siginInDto.id, siginInDto.password);
}
// GlobalGuard와 RouterGuard가 순차적으로 작동,
// GlobalGuard에서 처리된 값이 RouterGuard로 들어감.
// @UseGuards(AuthGuard)
@Get('profile')
getProfile(@Req() req) {
return req.user
}
}
요청을 보내면 잘 동작할 것이다!