NestJs - JWT 사용한 인증 & 인가 처리 (access token) 본문
미래의 나에게
jwt 패키지 설치 / bcrypt 패키지 설치
yarn add @nestjs/jwt passport-jwt
yarn add @nestjs/passport
yarn add --dev @types/passport-jwt
yarn add bcrypt
yarn add --dev @types/bcrypt
2023.02.10 - [코딩/알쓸코잡] - 인증(Authentication) & 인가(Authorization)
2023.01.30 - [코딩/알쓸코잡] - JWT (JSON Web Token)
이전 포스팅과 연결고리가 많은 포스팅이다.
JWT (Jason Web Token)
JWT 는 유저를 인증 / 식별하기 위한 토큰 기반의 인증이라고 볼 수 있다.
JWT 는 토큰 자체에 [사용자의 권한 정보 / 서비스를 사용하기 위한 정보] 가 포함되어 있는데,
이 JWT 는 서버가 아닌 '클라이언트'에게 저장되기 때문에 서버의 부담을 덜 수 있다는 장점을 가진다.
JWT 구조
JWT의 구조는 Header / Payload, Signature 세 가지로 이루어져있다.
- Header : JWT에서 사용할 타입(JWT)과 알고리즘 종류를 담겨 있다.
- Payload : 서버에서 첨부한 사용자 권한 정보와 데이터가 담겨 있다.
- Signature : Header의 인코딩 값과 Payload 의 인코딩 값을 합친 후 비밀키로 만든 뒤 생성한 값이 담겨 있다.
NestJS 에서 jwt 구현
인증 구현
먼저 최상단에 남겨둔 jwt패키지를 설치한 뒤,
auth.module.ts / resolver / service 파일을 생성한다.
1. resolver 에서 API 를 작성한다.
// auth.resolver.ts
@Mutation(() => String)// return으로 accessToken 을 받을 것이므로 return type 은 string
login(
@Args('email') email: string, // argument 로는 email과 password 를 받는다.
@Args('password') password: string, //
): Promise<string> { // DB 안에 해싱되어있는 비밀번호에 접근하는 과정이 필요하므로 promise
return this.authService.login({ email, password }); // module로부터 DI한 authService
}
// auth.service.ts
import { JwtService } from '@nestjs/jwt'; // jwt 사용을 위한 Import
import * as bcrypt from 'bcrypt'; // 전역에서 사용하기 위한 '*' 처리
@Injectable()
export class AuthService {
constructor(
private readonly jwtService: JwtService, // DI
private readonly usersService: UsersService, // DI
) {}
async login({ email, password }: IAuthServiceLogin): Promise<string> {
// 1. DB 내 저장된 email이 있는지 확인하는 로직
const user = await this.usersService.findOneByEmail({ email });
// email과 관련된 DB를 가져와 user 에 저장
// 2. 저장된 해당 email이 없다면 expcetion을 반환
if (!user) throw new UnprocessableEntityException('이메일이 없습니다.');
// 3. password 와 해쉬화되어 저장되어 있는 DB 내 user.password를 비교
const isAuth = await bcrypt.compare(password, user.password);
// 4. 비교결과가 같지 않다면 exception 반환
if (!isAuth) throw new UnprocessableEntityException('암호가 틀렸습니다.');
return this.getAccessToken({ user }); // 토큰 생성 함수 실행
}
getAccessToken({ user }: IAuthServiceGetAccessToken): string {
return this.jwtService.sign( // jwtService 내에 있는 함수인 sign을 실행
{ sub: user.id }, // sub, secret / expiresIn 에 해당정보를 저장한다.
{ secret: process.env.JWT_ACCESS_KEY, expiresIn: '1h' },
// 이때 secret 은 env에 저장해놓은 accessKey, expiresIn은 만료시간을 의미
);
}
}
* JwtService 의 sign 메서드에 대한 추가 설명
토큰 생성 메서드
jwt.sign(json data, secretKey, [options, callback])
json data : 유저의 정보가 담긴 payload 의미
secretKey : 서명된 JWT를 생성할 때 사용하는 키
option : 해싱 알고리즘 (기본적으로 HS256 해싱 알고리즘을 사용), 토큰 유효 기간 및 발행자 지정 가능
이제 완성된 resolver와 service를 합친다.
// auth.module.ts
@Module({
imports: [
// register에는 JWT토큰을 만들 때, 필요한 설정들을 넣어줄 수 있음
JwtModule.register({}),
TypeOrmModule.forFeature([ // user table을 조회하기 위함
User, //
]),
UsersModule, //
],
providers: [
AuthResolver, //
AuthService, //
UsersService, // email을 검증하는 과정에서 userService.findMail-- 을 사용했기 떄문에 추가됨
JwtService, //
],
})
export class AuthModule {}
// app.module.ts
@Module({
imports: [
AuthModule, // 추가
ProductsModule,
// 중략 //
여기까지 완료하게 되면, 플레이그라운드에서 jwt로 만들어진 인가를 위한 accessToken을 발급받을 수 있게 된다.
해당 이메일이 DB에 없을 시 오류 || 비밀번호가 맞지 않을 시, throw ConflictExpception
인가 구현
작성 전 data-flow
사용자의 인증이 필요한 경우
클라이언트는 발급받은 JWT를 Request Header에 실어 같이 보내고
backend 는 JWT을 받고 Guard를 통해 JWT strategy 를 실행하며 Secret Key 를 통해 JWT 를 디코딩한다.
JWT을 복호화한 후, 원하는 API 의 로직이 실행된 이후에 Response 됨
먼저 passport 라이브러리를 설치한다.
passport 인증 라이브러리로 JWT / 사용자 이름&암호를 확인하여 사용자를 인증하기 위해 사용하며
인증된 사용자에 대한 정보를 Request 객체에 담아줄 수 있다.
이전 만들어 둔 fetchUser 는 누구나 사용할 수 있는 API 이기에,
이번에는 로그인(accessToken을 보유)하고 있는 사용자만 활용할 수 있는 API를 만들어본다.
먼저 user.resolver.ts 에 이를 위한 API 형식을 간단히 작성해준다.
// users.resolver.ts
@UseGuards(AuthGuard('access')) // 이후 설명
@Query(() => String)
fetchLoginUser(): string {
console.log('인가에 성공했습니다.');
return '인가에 성공했습니다.';
}
PassportStrategy 구현
passport 라이브러리에서 제공되는 passportStrategy 를 extends 하여 전략패턴(strategy)으로 사용할 것이므로,
Auth 폴더 안에 strategy 폴더를 생성한 뒤 jwt-access.strategy 파일을 생성했다.
** super 를 사용하여 passportStrategy의 생성자함수를 호출하여 JWT값을 넘긴다.
** 이때 passportStrategy에는 accessToken 이 유효한지, 만료시간이 남았는지를 파악하는 로직이 있어 이를 활용하기 위함이다.
// jwt-access.strategy.ts
import { PassportStrategy } from '@nestjs/passport'; // passport
import { ExtractJwt, Strategy } from 'passport-jwt';
// PassportStrategy 상속 // 이때, 'access' 는 액세스를 위한 함수이므로 access라 이름 붙임
// 이때 이름은 user.resolver.ts AuthGuard 와 이름이 같아야 함 (아래에서 다룸)
export class JwtAccessStrategy extends PassportStrategy(Strategy, 'access') {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), // bearer 을 제외한 문자열 추출
secretOrKey: process.env.JWT_ACCESS_KEY, // 복호화할 키
});
}
validate(payload) { // 인증 성공 시 payload 열람하기 위한 validate
return {
email: payload.email, // 아래와 같음
id: payload.sub, // payload.sub 는 인증할 때 sub : id 로 담았던 id
};
}
}
// app.module.ts // DI
import { JwtAccessStrategy } from './apis/auth/strategy/jwt-access.strategy';
@module({
// 중략 //
providers: [
JwtAccessStrategy, //
],
})
@UseGuard 구현
GraphQL에서는 guard 를 사용하기 위해 추가적인 로직이 필요하다.
Guard 는 말 그대로 API 요청자가 우리 사용자가 맞는지 파악한 뒤
API 를 실행할 수 있도록 도와주는 역할을 한다.
먼저, auth 내 guard 폴더를 생성한 후, gql-auth.guard.ts 파일을 만들어준다.
// gql-auth.guard.ts
import { ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { AuthGuard } from '@nestjs/passport';
// GqlAuthAccessGuard 는 AuthGuard('access')를 상속받음
// GqlAuthAccessGuard 실행 후 AuthGuard 실행 (오버라이딩)
// AuthGuard('access')는 user.resolver.ts 에서 만들어준 Guard
export class GqlAuthAccessGuard extends AuthGuard('access') {
getRequest(context: ExecutionContext) {
const gqlContext = GqlExecutionContext.create(context);
// rest-api 용으로 들어오는 context 이기에 GraphQL 용으로 바꿔줌 (오버라이딩)
return gqlContext.getContext().req;// user.resolver.ts 파일이 해당 클래스로 리턴
}
}
이제 GqlAuthAccessGuard 를 사용할 수 있게 되었으니 기존 users.resolver.ts 파일을 수정한다.
// users.resolver.ts
// @UseGuards(AuthGuard('access'))
@UseGuards(GqlAuthAccessGuard) // 수정
@Query(() => String)
fetchLoginUser(): string {
console.log('인가에 성공했습니다.');
return '인가에 성공했습니다.';
}
여기까지 완료되면 GraphQL header 에 access 토큰을 담아보내면 올바르게 실행됨을 확인할 수 있다.
다만 아직 user 정보를 확인할 수 없기에 받아올 수 있도록 수정해본다.
user 정보가져오기
먼저 resolver.ts를 다음과 같이 수정한다.
// user.resolver.ts
@UseGuards(GqlAuthAccessGuard)
@Query(() => String)
fetchLoginUser(@Context() context: IContext): string { // 수정
console.log(context.req.user);
return '인가에 성공했습니다.';
}
이때 등장한 context 는 무엇일까.
잠시 resolver 함수에서 다루는 네 개의 인수에 대해 소개한다. (GraphQL)
- obj : query 타입 이전에 사용된 객체로 요즘은 사용되지 않는 인수다. (_ 를 통해 빈값으로 사용하기도 함)
- arg : GraphQL 쿼리의 필드에 제공된 인수. argument 의 약자로, 매개변수로 쓴다.
- info : 현재 query. schema 정보와 관련된 정보를 보유한다.
- context : 모든 resolver 함수에 전달되며, 현재 로그인한 사용자 / DB access 와 같은 중요 정보를 담는다.
- ** context 는 Request / Response / header / payload 등 에 대한 정보들을 담고 있다.
우리는 이를 타입스크립트에서 입맛대로 활용하기 위해, context 를 위한 인터페이스를 만든다.
전역에서 사용할 것이므로, commons 에서 context.ts 생성한다.
// commons/interfaces.context.ts
import { Request, Response } from 'express'; // 익스프레스에서 받는다.
export interface IAuthUser { // user 에 대한 interface
user?: { // 검증에 실패하는 경우도 존재하기에 user? 필수적이지 않는 타입으로 지정
id: string;
};
}
export interface IContext { // context 는 key:value 형식이므로 아래와 같이 설정한다.
req: Request & IAuthUser;
res: Response;
}
여기까지 완료되면 console 창에서
validate 내 존재하는 payload 값과 API 요청자의 id 값을 받아올 수 있게 된다.
'개발 > nest.js' 카테고리의 다른 글
NestJs - 소셜로그인 구현(google login) (0) | 2023.02.12 |
---|---|
NestJS - login process 구현 (refresh Token) (0) | 2023.02.12 |
Nest JS - 회원 API 구현 (hashing & ConflictException) (0) | 2023.02.10 |
NestJs - Join API 구현 (0) | 2023.02.09 |
NestJS - CRUD / TypeORM (0) | 2023.02.07 |