본문 바로가기

NestJS - login process 구현 (refresh Token) 본문

개발/nest.js

NestJS - login process 구현 (refresh Token)

자전하는명왕성 2023. 2. 12. 13:39

이전 포스팅에서는 accessToken 발급을 구현하여 인가하는 방법까지 다뤘다.

허나, accessToken 만으로는 사실 부족한데,

accessToken 은 해킹을 당했을 경우 보안에 취약할 뿐더러

유효기간이 짧은 토큰의 경우 그만큼 사용자에게 로그인(accessToken)을 강요하여 불편하다는 단점이 있기 때문이다.

 

따라서 이러한 점들을 보안하기 위해서 만들어진 것이 refreshToken.

refreshToken 은 로그인을 완료했을 때 accessToken 과 함께 발급되며 accessToken 보다 유효기간이 길다.

refreshToken 의 유효기간이 만료되면 사용자는 새로 로그인을 하게 되는데

이 또한 해킹의 가능성이 있기 때문에 적절한 유효기간 설정이 필요하다.

accessToken : jwt 내 payload 저장

refreshToken : cookie 내 저장

 

과정

중점만 보면 다음과 같다.

1. 클라이언트가 API 를 요청하면, 백엔드는 accessToken & refreshToken 을 발급하여 전달한다.

2. 이후 클라이언트의 accessToken 기간이 만료된 경우 백엔드는 refreshToken을 확인 후 accessToken을 새로 발급한다.

** 이때 새로 발급된 accessToken 을 restoreToken 이라고 한다.

3. 클라이언트는 발급받은 restoreToken 을 다시 백엔드에 전달하여 API 를 요청하고

4. 백엔드는 restoreToken 을 검증한 이후 요청한 API 를 실행시킨다.

 

RefreshToken 구현 (Login 에 refreshToken 쿠키에 저장)

위에서 언급한 바와 같이 accessToken 과 함께 refreshToken 을 발급하기로 한다.

 

그전에  graphQL로 들어온 req, res 를 API에서 사용할 수 있게끔 설정해주는 과정이 필요하다.

** res 를 통해 refreshToken을 쿠키에 저장할 것이기 때문

 

// app.module.ts

@Module({
  imports: [
    // 중략 // 
    GraphQLModule.forRoot<ApolloDriverConfig>({
      driver: ApolloDriver,
      autoSchemaFile: 'src/commons/graphql/schema.gql',
      context: ({ req, res }) => ({ req, res }), // graphQL 모듈 내 context
    }),
    TypeOrmModule.forRoot({
    // 중략 //
    }),
  ],
})
export class AppModule {}

 

 refreshToken은 context 안 cookie 에 들어가므로 context 인수를 auth.resolver.ts에 추가하여 service에 인자로 전달한다.

이후, auth.service.ts 에서 인자를 받은 context를 추가한다.

// auth.resolver.ts

@Mutation(() => String)
  login(
    @Args('email') email: string, //
    @Args('password') password: string, //
    @Context() context: IContext, // 추가
  ): Promise<string> {
    return this.authService.login({ email, password, context }); // context
  }
  
// auth.service.ts

  async login({email, password, context }: IAuthServiceLogin): Promise<string> {
    // 1.
    const user = await this.usersService.findOneByEmail({ email });

    // 2.
    if (!user) throw new UnprocessableEntityException('이메일이 없습니다.');

    // 3.
    const isAuth = await bcrypt.compare(password, user.password);
    if (!isAuth) throw new UnprocessableEntityException('암호가 틀렸습니다.');

    // 4. 추가
    this.setRefreshToken({ user, res: context.res }); // 응답으로 보내기에 context.res

    // 5.
    return this.getAccessToken({ user });
  }
  
  	// 리턴을 안하므로 void / cookie에 저장만 한다. cookie는 accessToken(jwt) 발급 시 자동으로 빨려들어감
    setRefreshToken({ user, res }: IAuthServiceSetRefreshToken): void { // interface는 user/res:Response 의 타입을 담음
    const refreshToken = this.jwtService.sign(
      { email: user.email, sub: user.id },
      { secret: process.env.JWT_REFRESH_KEY, expiresIn: '2w' }, // 만료일은 accessToken보다 길게
    );
    res.setHeader('Set-Cookie', `refreshToken=${refreshToken}`); // 쿠키에 refreshToken 저장
    		// 쿠키에서 확인할 시. cookie : refreshToken={refreshToken} 형식으로 보여짐
  
  }

 

이렇게 되면 refreshToken 이 쿠키에 저장되어 있음을 확인할 수 있다.

 

토큰재발급 API 구현

이제 accessToken 이 만료될 경우 refreshToken 을 통해 accessToken 재발행하는 과정을 추가해본다.

** 이때 재발행되는 토큰은 restoreAccessToken 이라고 위에서 설명했다.

 

재발급하는 API 를 간단하게 작성한다.

// auth.resolver.ts

@Mutation(()=> String)
  restoreAccessToken(
    @Context() contetxt : IContext,
  ): string {
    return this.authService.restoreAccessToken({user : Context.req.user})
  }

 

당연하게도 accessToken 재발급은, 우리 db내 사용자에 한해서만 사용할 수 있는 API 이므로

인증하기 위한 passport 과정과 guard과정이 필요하다.

jwt.refresh.strategy.ts 파일을 만들어 passport 에 사용될 내용을 추가해준다. 

 

passport 

// jwt-refresh.strategy.ts // passport 모듈

import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';

// accessToken 때와 같은 전략 패턴 
// refreshToken 을 검증하기 위한 과정
export class JwtRefreshStrategy extends PassportStrategy(Strategy, 'refresh') {
  constructor() {
    super({
    // super로 요청으로 받은 JWT & secretOrKey 전달
      jwtFromRequest: (req) => {
        const cookie = req.headers.cookie; // cookie에 저장되어 있는 refreshToken을 cookie 에 할당
        console.log(cookie);
        const refreshToken = cookie.replace('refreshToken=', ''); // 앞에 붙은 refreshToken 제거
        return refreshToken;
      }, // refreshToken
      secretOrKey: process.env.JWT_REFRESH_KEY,
    });
  }

// 인가에 성공 시 payload 에서 사용자 정보 반환
  validate(payload) {
    console.log(payload);
    return {
      email: payload.email,
      id: payload.sub,
    };
  }
}

 

이제 guard 를 적용시켜야 하는데, 기존에 accessToken을 위해 만들어둔 가드가 있기 때문에

이를 변형시켜 restoreToken에도 적용할 수 있게 수정해준다.

 

// gql.auth.guard.ts

// export class GqlAuthAccessGuard extends AuthGuard('access') { 
export const GqlAuthGuard = (name) => { // 수정
 return class GqlAuthAccessGuard extends AuthGuard(name) { // 수정 return 위치 변경
    getRequest(context: ExecutionContext) {
      const gqlContext = GqlExecutionContext.create(context);
      return gqlContext.getContext().req;
    }
  }
};

// auth.resolver.ts
 @UseGuards(GqlAuthGuard('refresh')) // 추가
  @Mutation(() => String)
  restoreAccessToken(@Context() context: IContext): string {
    return this.authService.restoreAccessToken({ user: context.req.user });
  }
  
// auth.service.ts // interface 는 context 객체로 받아야하기에 user : IAuthUser['user']
  restoreAccessToken({ user }: IAuthServiceRestoreAccessToken): string {
    return this.getAccessToken({ user });
  }
// ** 추가적으로 IAuthServiceGetAccesstoken 인터페이스의 경우
// user : User | IAuthUser['user'] 로 수정


// auth.module.ts // jwtRefreshStrategy DI

@Module({
  imports: [
    JwtModule.register({}), //
    TypeOrmModule.forFeature([
      User, //
    ]),
    UsersModule, //
  ],
  providers: [
    AuthResolver, //
    AuthService, //
    UsersService, //
    JwtService, //
    JwtAccessStrategy, //
    JwtRefreshStrategy, // 추가
  ],
})
export class AuthModule {}

여기서는 언급하지 않지만, access를 위한 UseGuards API를 GqlAuth('access')로 바꿔준다.

 

이렇게 되면 restoreAccessToken 발급이 준비되었다.

1. 유효시간이 만료된 경우 'unauthorized' 비인가 상태 오류 반환

 

2. 유효시간이 만료되지 않은 경우 인가 성공

 

Comments