npm i --save @nestjs/jwt
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
JwtModule.register({
secret: process.env.JWT_SECRET_KEY, // JWT Signature의 Secret 값 입력
signOptions: { expiresIn: process.env.JWT_EXPIRED }, // JWT 토큰의 만료시간 입력
}),
TypeOrmModule.forFeature([Users]),
PassportModule,
],
providers: [AuthService, JwtStrategy, UserService],
controllers: [AuthController],
})
export class AuthModule {}
클라이언트측에서 토큰을 저장하는 방식은 보안 문제가 많기 때문에
시크릿 키는 노출시키면 안되고
또한 페이로드에도 중요정보를 넣지 않는 것이 좋다.
// 컨트롤러
@Controller('/api/auth')
export class AuthController {
constructor(private authService: AuthService) {}
@Post('login')
async login(@Body() data: UserLoginDto, @Res() res: Response): Promise<any> {
const jwt = await this.authService.validateUser(data);
res.cookie('Authorization', `Bearer ${jwt.accessToken} `);
// return res.json(jwt);
}
jwt 토큰을 쿠키에 저장하는 방식을 사용
// service
async validateUser(data) {
// 로그인
const { email, password } = data;
const findUser = await this.userRepository.findOne({ where: { email } });
const validatePassword = await bcrypt.compare(password, findUser.password);
if (!findUser || !validatePassword) {
throw new HttpException(
'올바른 정보가 아닙니다.',
HttpStatus.UNAUTHORIZED,
);
}
const payload: Payload = {
id: findUser.id,
username: findUser.name,
};
return { accessToken: this.jwtService.sign(payload) };
}
페이로드를 인터페이스로 생성하여 타입 지정
interface Payload {
id:number,
username:string,
}
토큰에 대한 검증을 위한 Passport Strategy를 정의한다.
(참고로 Strategy는 Passport Middleware에서 사용하는 인증 전략이다.)
Node.js에서 (Nest도 Node.js를 기반으로 만들어짐) Authenticate(인증)를 적용할 때에, 편하게 사용할 수 있는 미들웨어이다. 마치 출입국자의 출입국 심사 인증을 하는 "여권(passport)"의 역할과 같은데, 클라이언트가 서버에 권한을 요청을 할 자격이 있는지 인증(검증)할 때에 "passport" 미들웨어를 사용한다. Nest는 이러한 토큰 인증(검증)에 있어서 passport의 사용을 권장하고 있다. 다양한 인증전략이 있으며, 본인은 쿠키전략을 사용
npm install --save @nestjs/passport
npm install --save passport-jwt
npm install --save @types/passport-jwt
// auth/jwt-strategy.ts
import {
HttpException,
HttpStatus,
Injectable,
} from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, VerifiedCallback } from 'passport-jwt';
import { AuthService } from './auth.service';
import { Payload } from './dto/payload';
import * as cookie from 'cookie';
require('dotenv').config();
const fromAuthCookie = function () {
return function (request) {
let token = null;
const cookies = cookie.parse(request.headers.cookie || '');
if (cookies) {
token = cookies['Authorization'];
}
return token.split(' ')[1];
};
};
// jwt가 저장된 cookie값을 가져오는 코드
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private authService: AuthService) {
super({
secretOrKey: process.env.JWT_SECRET_KEY, // 1
jwtFromRequest: fromAuthCookie(), // jwt
ignoreExpiration: false, // 2
});
}
async validate(payload: Payload, done: VerifiedCallback): Promise<any> {
const user = await this.authService.tokenValidateUser(payload);
if (!user) {
throw new HttpException('유저가 존재하지 않습니다.', HttpStatus.CONFLICT);
}
return done(null, user);
}
}
super로 인해 부모클래스에 접근
ignoreExpiration: true
: 토큰이 만료되었는지 검사를 하게 되는데 해당 속성을 ture로 설정하면 만료되더라도 바로 strategy에서 에러를 리턴하지 않도록 해준다. 만약 false로 설정해주게 되면, 이는 JWT가 만료되지 않았음을 보증하는 책임을 Passport 모듈에 위임하게 된다. 즉, 만약 만료된 JWT를 받았을 경우, request는 거부되고 401 Unauthorized response를 보낼 것이다.
VerifiedCallback을 사용하여 에러반환과 false를 인자로 담아준다.
(인증된 유저 객체가 없으므로 user를 반환하는 두 번째 인자는 "false"로 지정)
그리고 만약 유저 객체가 (user) 있다면 마찬가지로 VerifiedCallback을 반환하는데 이땐 에러를 나타내는 첫 번째 인자엔 null을, 두 번째엔 user를 반환해 준다.
PassportModule 을 꼭 등록!
일반적으로 node.js는 인증/인가의 작업에서 "미들웨어(MiddleWare)"를 활용한다.
하지만, Nest는 인가를 구현할 땐, "가드(Guard)"를 이용하도록 권장한다.
인증과 인가는 비슷하게 느낄 수도 있고, 실제로 연장선 상에 위치하지만 개별적으로 다른 의미를 지니고 있다.
인증과 같은 경우는 요청자가 자신이 누구인지 증명하는 과정이다. 우리가 여태껏 구현해왔던 것 처럼, 어떠한 요청마다 헤더에 JWT 토큰을 실어 보내고 이 토큰을 통해 요청자가 라우터에 접근 가능한 지 서버는 확인하게 된다. 이러한 검증 과정이 "인증(Authentication)"이라고 보면 된다.
반면에 "인가(Authorization)"는 인증을 통과한 유저가 (즉, 검증을 마친) 요청한 기능을 사용할 권한이 있는지를 판별하는 것을 말한다.
퍼미션, 롤, ACL 과 같은 개념을 사용하여 유저가 가지고 있는 속성으로 리소스(자원) 사용을 허용할 지 판별한다. 바로 이러한 인가과정을 "가드"를 통해 구현하는 것이 Nest의 방향성이다.
그런데 왜 "인가"는 "인증"처럼 "미들웨어(Middleware)"로 구현하지 않을까?
하지만 미들웨어는 실행 컨텍스트(ExecutionContext)에 접근하지 못한다. 단순히 자신의 일만 수행하고 next()를 호출한다. 즉, 다음에 어떤 핸들러가 실행될 지 알 수 없다. 이에 반해 가드는 실행 컨텍스트 인스턴스에 접근할 수 있어, 다음 실행될 작업을 정확히 알고 있다.
requset(요청)와 response(응답)의 정확한 지점에 이 가드를 삽입하여, 요청에 대한 거부, 혹은 승인을 할 수 있게 되는 것이다. 어쩌면 "가드" 또한 "미들웨어"의 역할과 한 부류라고 볼 수 있긴 하지만 특정 역할에 있어서는 조금 다르다고 볼 수 있다.
가드는 미들웨어 바로 이후에 실행되어 클라이언트의 요청을 넘겨받아 "2차 인증"을 한다고 봐도 무방하다.
// auth/jwt-guard.ts
import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Observable } from 'rxjs';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
async canActivate(context: ExecutionContext): Promise<any> {
try {
const result = await super.canActivate(context);
console.log('JwtAuthGuard - canActivate result:', result);
return result;
} catch (error) {
console.error('JwtAuthGuard - Error:', error);
throw error;
}
}
}
// controller.ts
@Get('/authenticate')
@UseGuards(JwtAuthGuard)
isAuthenticated(@User() user): any {
return user;
}
Guard내부에서 jwt-strategy 를 호출하는 로직이 있어 자동으로 접근한다.
request에 요청이 있으면 true or false를 반환해 검증을 완료한다.
먼저 사용자가 로그인 정보를 Body에 실어서 (@Body를 이용) 서버에 보낸다.
서버는 DB에서 해당 로그인 정보를 확인한다. (우리가 만들어준 AuthService의 validateUser()이 그 역할을 한다.)
확인(검증)이 되면 JWT 토큰을 발급한다. 컨트롤러의 login()메서드의 인자로 Response 객체를 받아온 것을 확인할 수 있을 것이다.(@Res이용)
발급된 JWT 토큰을 사용자가 받는다.
사용자는 인증이 필요한 요청마다 JWT 토큰을 헤더에 실어 보낸다. (해당 커스텀 해더 지정을 controller에서 res.cookie()를 통해 지정해줄 수 있다.)
서버는 사용자가 보낸 JWT 토큰을 복호화(디코딩)를 통해 검증한다.
검증이 완료되면 서버는 사용자에게 요청 데이터를 보내준다.