npm i passport passport-jwt @nestjs/passport @nestjs/jwt
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { JwtStrategy } from './jwt.strategy';
@Module({
imports: [
ConfigModule,
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET'),
signOptions: { expiresIn: configService.get<number>('ACCESS_EXPIRE') },
}),
inject: [ConfigService],
}),
],
providers: [JwtStrategy],
exports: [PassportModule, JwtModule],
})
export class AuthModule {}
import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
canActivate(context: ExecutionContext) {
// Custom authentication logic
return super.canActivate(context);
}
handleRequest(err, user, info) {
// Handle any authentication errors
if (err || !user) {
throw err || new UnauthorizedException();
}
return user;
}
}
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, ExtractJwt } from 'passport-jwt';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
private configService: ConfigService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get<string>('JWT_SECRET'),
});
}
async validate(payload: any) {
// Handle token payload
return { ...payload };
}
}
api 요청이 들어왔을 때 위 Guard파이프는 다음의 순서로 동작한다.
위 과정에서 유의해야할 점은, 1~3번까지는 Nestjs의 기본 request 파이프라인 상에 있기때문에 throw한 error들이 자동으로 걸러진다. 하지만, 4번의 handleRequest 메소드는 request 파이프라인에 속하지 않으며 여기서 throw한 error는 서버를 충돌시키므로 별도의 처리가 필요하다.
또, handleRequest의 인자로 들어오는 값들은 다음과 같다.
즉, 인가 과정에 문제가 없었다면 err는 null, info는 undefined가 들어오며 req.user의 값이 있다면 user에 false 대신 해당 값이 들어온다.
refresh token을 이용한 인가는 access token이 유효하지 않는 경우 refresh token을 검사하고 새로 유효한 access token을 발급해줘야한다. 위 코드에서 토큰에 대한 유효성 검사가 이뤄지는 곳은 AuthGuard.canActivate인데, 해당 코드 부분은 @nestjs/passport 패키지 안에 감춰져 있다. 따라서, AuthGuard를 다시한번 오버라이드하거나 아니면 그보다 앞의 단계에서 필요한 검사를 하는 수 밖에 없다. 여기서는 오버라이드를 위해 클래스를 추가하기보다 JwtAuthGuard.canActivate에서 토큰에 대한 인가 작업을 미리 하기로 한다.
const req: Request = context.switchToHttp().getRequest();
const res: Response = context.switchToHttp().getResponse();
const isValid = await this.validateTokens(req, res);
if (isValid) {
//Valid token...passing to passport
super.canActivate(context);
return true;
}
// Invalid token...throwing error
throw new UnauthorizedException('Invalid tokens');
혹시 access token이 새로 발급될 때 다음 단계인 AuthGuard.canActivate에서 authorization 헤더를 검사할 것이기 때문에 헤더 수정을 위해, 그리고 이를 클라이언트에 전달하기 위해 Request와 Response 객체가 필요하다.
async validateTokens(req: Request, res: Response) {
const [type, accessToken] = req.get('authorization')?.split(' ')[1];
if (type?.toUpperCase() !== 'BEARER' || accessToken === undefined) return false;
const payload = this.authService.verifyToken(accessToken, 'access');
if (!payload.isValid) return true;
const { refreshToken } = req.cookies;
if (refreshToken === undefined) return false;
const isRefreshValid = await this.userSessionService.verifySession(payload.userId, refreshToken);
if (!isRefreshValid) return false;
const newToken = await this.authService.createAccessToken(payload);
const expire = this.configService.get<number>('ACCESS_COOKIE');
res.cookie('accessToken', newToken, { maxAge: expire });
req.headers.authorization = `Bearer ${newToken}`;
return true;
}
참고자료
https://docs.nestjs.com/guards
https://docs.nestjs.com/recipes/passport