Backend

Nest.js 카카오 REST API 로그인 구현하기 | access token, refresh token

경아 (KyungA) 2024. 11. 8. 14:42
반응형

이전 게시물

 

access token, refresh token 토큰 기반 인증 방식 | JWT, HTTP header

카카오 로그인을 구현하면서 토큰 기반으로 로그인 상태를 유지시키는 기능을 구현해보려고 합니다. 이전에는 계속 세션 기반으로만 직접 구현해 봤고, 토큰 기반으로는 이미 구현된 로직을 활

kyung-a.tistory.com

 

이제 토큰 개념을 알았으니 카카오 로그인 API를 활용해서 로그인을 진행하고 자체 토큰을 발급해서 로그인 상태를 유지하는 로직을 짜보려고 합니다. 이 전에 제 고민들을 먼저 나열해 볼까 합니다
(카카오 API 시작하기에 대한 내용은 다루지 않고, 비즈니스 로직만 다루겠습니다)

 

 

구현 하면서 했던 고민


  1. 카카오 측에서 액세스, 리프레시 토큰을 발급해 주는데 내가 따로 발급을 할 필요가 있나?
  2. 카카오 토큰을 활용하게 된다면 이 토큰이 유효한지 확인하기 위해 매번 카카오 서버로 요청을 보내야 하는 건가?
  3. 액세스 토큰이 유효한지 확인하려면 값을 비교해야 하는 거 아닌가? 그렇니까 무조건 토큰을 서버에 저장해야 하는 거 아닌가?
  4. 토큰은 DB에서 어떻게 관리해야 하지?
  5. 액세스 토큰을 DB에 저장한다면 갱신할 때마다 update로 가야 하는가? 아님 기존 토큰 삭제 후 새로 create로 해야 하는가?
  6. 소셜 로그인 후 별도의 회원가입 절차를 가져가야 할까? 그런 서비스도 있고 아닌 서비스도 있는 것 같은데, 이게 운영적으로 어떤 장단점이 있어서 서비스마다 다른 걸까?
  7. passport를 쓸까? rest api로 이미 충분히 구현할 수 있는 것 같은데

등등... 이런 고민들이 있었고, 사실 정답이 없는 고민들 같은데... 하나하나 어떻게 해결했는지 적어보겠습니다

 

 

클라이언트 세팅


https://kauth.kakao.com/oauth/authorize?client_id=${REST_API_KEY}&redirect_uri=${REDIRECT_URI}&response_type=code&scope=account_email

클라이언트에서 [카카오 로그인] 버튼을 클릭하면 위 주소로 가게끔 세팅해줍니다. 카카오 로그인 페이지로 넘어가면 유저는 카카오 계정으로 로그인을 진행하면 됩니다. 그리고 로그인 완료가 되면, redirect_uri에 쓰인 주소로 리다이렉트까지 카카오 측에서 자동으로 진행됩니다.

 

 

서버


callbakc 함수

redirect_uri는 보통 우리 서버 주소가 될 것입니다. 추가적으로 처리해야 하는 로직들이 있기 때문인데요. 

  @Get('kakao/callback')
  async kakaoLoginCallback(@Query('code') code: string, @Res() res: Response) {
    try {
      const token = await this.authService.getKakaoAccessToken(code);
    } catch {
      return new HttpException('kakao login failed', 500);
    }
  }

Nest.js auth controller에 카카오에서 설정한 kakao/callback주소의 router를 만들어줍니다. 로그인에 성공하면 토큰을 받을 수 있는 code라는 key로 인가 코드를 반환해 줍니다. 이 code 값을 이용해 카카오 토큰을 받아와 유저 정보까지 받아오겠습니다. 먼저 토큰을 받을 getKakaoAccessToken이라는 함수를 만들어줍니다.

토큰 받기

  async getKakaoAccessToken(code: string): Promise<any> {
    const params = new URLSearchParams({
      grant_type: 'authorization_code',
      client_id: this.config.get('KAKAO_CLIENT_ID'),
      redirect_uri: this.config.get('KAKAO_CALLBACK_URL'),
      code: code,
    });

    try {
      const { data } = await firstValueFrom(
        this.http.post('https://kauth.kakao.com/oauth/token', params.toString(), {
          headers: {
            'Content-type': 'application/x-www-form-urlencoded',
          },
        }),
      );

      return await this.getKakaoUserInfo(data);
    } catch (error) {
      throw new HttpException('Failed to get kakao access token', HttpStatus.FORBIDDEN);
    }
  }

https://kauth.kakao.com/oauth/token 주소로 필요한 params를 담아서 요청하면 액세스 토큰, 리프레시 토큰, 만료시간 등등을 반환해줍니다. 이제 이 값들을 이용해서 유저 정보를 받아올 수 있도록 getKakaoUserInfo 함수를 작성해 줍니다.

유저 정보 받기

  async getKakaoUserInfo(data) {
    try {
      const {
        data: {
          id,
          kakao_account: {
            email,
            profile: { nickname },
          },
        },
      } = await firstValueFrom(
        this.http.get('https://kapi.kakao.com/v2/user/me', {
          headers: {
            Authorization: `Bearer ${data.access_token}`,
            'Content-type': 'application/x-www-form-urlencoded;',
          },
        }),
      );

      const user = await this.userService.user({ where: { email } });
      if (!user)
        await this.userService.createUser({
          email,
          name: nickname,
          snsId: String(id),
          snsType: 'kakao',
        });

      const refreshToken = await this.createRefreshToken(user.id);
      const accessToken = await this.createAccessToken(user.id);

      this.userService.createUserToken({
        user: {
          connect: {
            id: user.id,
          },
        },
        refreshToken: refreshToken,
      });

      return { accessToken, refreshToken };
    } catch (error) {
      throw new HttpException(error.message, HttpStatus.FORBIDDEN);
    }
  }

(코드 리팩토링 필요한 거 앎)
https://kapi.kakao.com/v2/user/me 주소로 헤더에 access token을 보냅니다. 그다음 받아온 email 값으로 DB에 이미 있다면 토큰만 발급, 없다면 DB에 유저 정보를 저장 후 토큰 발급을 진행합니다. 

여기서 제가 고민이었던 2가지가 등장하는데

  • 카카오 측에서 액세스, 리프레시 토큰을 발급해 주는데 내가 따로 발급을 할 필요가 있나?
  • 카카오 토큰을 활용하게 된다면 이 토큰이 유효한지 확인하기 위해 매번 카카오 서버로 요청을 보내야 하는 건가?

시니어 개발자분들께 물어본 결과, 카카오 측 서버를 지속적으로 왔다 갔다 하며 토큰을 확인하고, 갱신하는 건 좋지 못한 것 같고 (아무래도 호출 횟수, 비용, 외부 플랫폼 의존이 되는 거니까) 유저 정보를 받아오기 위한 목적으로만 카카오 토큰을 활용하는 걸로 하고, 우리 서비스에서 자체 토큰을 발급해 주는 게 좋겠다는 의견이 나왔습니다. 저도 이게 맞는 것 같다고 생각해서 1,2번 문제는 해결이 되었습니다

이제 액세스 토큰, 리프레시 토큰을 생성하는 로직입니다.

자체 액세스, 리프레시 토큰 생성

  async createAccessToken(userId: string) {
    const payload = { sub: userId };
    return jwt.sign(payload, process.env.JWT_SECRET_KEY, { expiresIn: '1h' });
  }

  async createRefreshToken(userId: string) {
    const payload = { sub: userId };
    return jwt.sign(payload, process.env.REFRESH_TOKEN_SECRET_KEY, { expiresIn: '7d' });
  }

jsonwebtoken 라이브러리를 활용했습니다. 여기서 payload는 좀 더 가다듬어야 할 것 같네요.. 흐음... 아무튼! 이렇게 생성해 줍니다

refresh token DB 저장

이제 여기서 제 4, 5번째 고민이 등장합니다.

  • 토큰은 DB에서 어떻게 관리해야 하지?
  • 액세스 토큰을 DB에 저장한다면 갱신할 때마다 update로 가야 하는가? 아님 기존 토큰 삭제 후 새로 create로 해야 하는가?

일단 본론부터 말하자면 액세스 토큰은 DB 또는 서버 쪽에 저장할 필요가 없습니다. 그저 클라이언트에서 1차적으로 보냈냐, 안 보냈냐 체크, 그다음은 2차로 만료가 되었느냐 안되었느냐만 체크하면 되는 거였습니다.

제가 저장 해야하는거 아닌가? 라고 생각했던 이유는 이 액세스 토큰이 유효한지 아닌지를 알려면 우리가 발급했던 토큰이 무엇인지를 알고 있어야지 비교할 수 있으니까 저장을 해야하는거 아닌가? 라고 생각했던겁니다. 근데 애초에... 이건 제가 토큰 정책을 몰라서 착각한 내용인 것 같고요.. 클라이언트에서 액세스 토큰을 안보내면 당연히 로그인 한 유저가 아니기에 에러를 반환하면 되는거고, 보냈다면 일단 로그인은 한 유저라는거기에 토큰 자체에 유저 정보, 만료 시간도 포함되어있으니까 만료시간 체크 후 만료가 안되었다면 토큰에 있던 유저 정보를 활용해서 DB에 조회를 하면 되는거였습니다.

그럼 refresh token도 똑같이 저장 안 하면 되는 걸까? 싶었는데요. 왜냐면 클라이언트 쪽에서 액세스 토큰에서 막히면 리프레시 토큰으로 다시 재요청을 보낼 거 기 때문이죠. 그러면 유저 정보를 조회하는 것도, 액세스 토큰을 재발급하는 것도 문제가 없는데 왜지..? 아니면 액세스 토큰, 리프레시 토큰에 들어갈 payload를 다르게 작성해줘야 하나?? 내가 정책을 뭔가 잘 못 알고 있나?? 하는 의문이 가득했는데요... 이리저리 찾아보니 이유가 있었습니다.

보안 문제가 가장 큰데, 신속하게 비활성화해야 할 때 활용하기 좋은 것 같습니다. 유저가 로그아웃을 했을 때 서버는 사용자가 로그아웃한 사실을 알아도 클라이언트 측에 저장되어있는 리프레시 토큰을 지울 수 없습니다 (물론 프론트에서 삭제 로직을 태우겠지만...) 그래서 사용자가 로그아웃을 했더라도 이를 악용해 클라이언트에서 남아있던 리프레시 토큰을 이용해 액세스 토큰을 발급받을 수 있는 경우가 생길 수 있습니다. 그래서 리프레시 토큰을 DB에 저장하고 클라이언트측에 리프레시 토큰으로 액세스 토큰 재발급을 요청할 땐 DB에 저장된 토큰이랑 비교해 보는 게 좋습니다.

  async getKakaoUserInfo(data) {
    try {
      // ...
      this.userService.createUserToken({
        user: {
          connect: {
            id: user.id,
          },
        },
        refreshToken: refreshToken,
      });

      return { accessToken, refreshToken };
    } catch (error) {
      // ....
    }
  }

그래서 getKakaoUserInfo 함수 마지막에 refresh token을 저장하는 로직이 있는 겁니다.

마지막 최종 완성된 카카오 콜백 함수입니다

  @Get('kakao/callback')
  async kakaoLoginCallback(@Query('code') code: string, @Res() res: Response) {
    try {
      const token = await this.authService.getKakaoAccessToken(code);

      res.cookie('accessToken', token.accessToken, {
        httpOnly: true,
      });
      res.cookie('refreshToken', token.refreshToken, {
        httpOnly: true,
      });

      return res.redirect(`${process.env.FRONT_URL}`);
    } catch {
      return new HttpException('kakao login failed', 500);
    }
  }

클라이언트 쪽에 토큰들을 cookie를 이용해서 내려주고 다시 우리의 서비스 프론트 페이지로 리다이렉트 될 수 있게끔 작성해 주면 끝입니다.

 

 

access token 확인 및 재발급


클라이언트에서 http header에 담긴 access token을 검사하는 로직은 Nest.js에서 미들웨어로 처리 가능합니다.

// jwt.middleware.ts

@Injectable()
export class JwtMiddleware implements NestMiddleware {
  use(req, res, next: () => void) {
    const accessToken = req.headers.authorization?.split(' ')[1];
    if (!accessToken) {
      throw new UnauthorizedException('Token missing');
    }
    
    try {
      const decoded = jwt.verify(accessToken, process.env.JWT_SECRET_KEY);
      req.userId = decoded.sub;
      next();
    } catch {
      res.status(401).json({ message: 'Invalid or expired access token' });
    }
  }
}

이렇게 작성을 해주고

// app.module.ts
@Module({
	// ...
})
export class AppModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(JwtMiddleware).forRoutes({ path: '/user/*', method: RequestMethod.ALL });
  }
}

app.module.ts 파일에 미들웨어를 추가해 줍니다. 저는 일단 user 라우터에 해당하는 모든 컨트롤러에 적용시켰습니다. 

  @Get('me')
  async getUser(@Req() req) {
    const user = await this.userService.user({ where: { id: req.userId } });
    const { password, ...otherData } = user;
    return otherData;
  }

userController에 해당 사용자 정보를 조회하는 로직입니다. 미들웨어에서 넘겨준 userId는 req으로 받을 수 있습니다.

이제 여기서 access token이 만료되었다면 userController까지 오진 않고 미들웨어에서 401 에러를 반환할 겁니다. 그러면 클라이언트에서 리프레시 토큰을 이용해 다시 요청을 하게 될 겁니다. (클라이언트는 이걸 axios 인터셉터를 활용해서 작성할 거임)

  @Post('refresh')
  async refresh(@Body() body: { refreshToken: string }) {
    return await this.authService.reissueRefreshToken(body.refreshToken);
  }

refresh token도 미들웨어로 처리하는지... 모르겠는데요(?) 저는 일단 클라이언트에서 401 에러를 받으면 /refresh API로 요청을 보내게끔 하려고 authController에 라우터를 만들었습니다  

 // 삭제
 async deleteRefreshToken(userId: string) {
    return await this.prisma.userToken.delete({ where: { userId } });
  }

// 조회
  async getRefreshToken(userId: string) {
    return await this.prisma.userToken.findUnique({ where: { userId } });
  }
  
 // 검사
 async reissueRefreshToken(token: string) {
    try {
      const { sub } = jwt.verify(token, process.env.REFRESH_TOKEN_SECRET_KEY) as { sub: string };

      const storedToken = await this.getRefreshToken(sub);
      if (!storedToken || storedToken.refreshToken !== token) {
        throw new UnauthorizedException('Invalid refresh token');
      }

      const accessToken = await this.createAccessToken(sub);
      await this.deleteRefreshToken(sub);
      const refreshToken = await this.createRefreshToken(sub);
      this.userService.createUserToken({
        user: {
          connect: {
            id: sub,
          },
        },
        refreshToken: refreshToken,
      });

      return { accessToken, refreshToken };
    } catch (error) {
      if (error.name === 'TokenExpiredError') {
        const decoded = jwt.decode(token);
        await this.deleteRefreshToken(decoded.sub as string);
      }
      throw new UnauthorizedException('Invalid or expired refresh token');
    }
  }

일단 리프레시 토큰이 만료되면 바로 catch문으로 넘어가서 DB에 있는 토큰 삭제 후 에러를 클라이언트에게 반환해 주고 끝납니다

토큰이 만료되지 않았다면, DB에 있는 토큰과 일치하는지 확인합니다. 일치하면 액세스 토큰을 재발급해주는데 이때 리프레시 토큰도 똑같이 재발급해줍니다. 이유는 이렇게 회전 방식으로 설계할 경우 탈취 위험을 줄일 수 있다고 해서입니다. 이렇게 하면 리프레시 토큰도 만료 기간이 지금 시점으로부터 또 늘어나는 거기 때문에 사실상 영원한 자동 로그인이 아닌가(?) 싶군요. 근데 어차피 토큰 값은 바뀌니까... 위험도는 덜할 것 같고요. 또한 DB에서 업데이트로 가야 할지, 기존 토큰은 삭제하고 새로 create로 해줘야 할지 고민했는데, 저는 후자를 택했습니다. 이유는..음... 그냥 이게 깔끔하지 않나... 하하... 

(그리고 요즘은 토큰을 redis에서 관리하는게 거의 기본이다시피 되는 것 같더라고요. 근데 지금 상황에선 redis까진 과한 것 같고, 제가 따라가긴 어려울 것 같아서 차근차근 적용해나가려고 합니다. 그러니 지금은 생략!)

 

 

회원가입, passport 사용 여부 차이


이제 여기까지 소셜 로그인, 로그인 상태 유지를 위한 토큰 발급, 재발급 로직들이 모두 완성되었습니다.
그러나 아직 해결하지 못한 고민 2가지가 남아있는데요

  • 소셜 로그인 후 별도의 회원가입 절차를 가져가야 할까? 그런 서비스도 있고 아닌 서비스도 있는 것 같은데, 이게 운영적으로 어떤 장단점이 있어서 서비스마다 다른 걸까?
  • passport를 쓸까? rest api로 이미 충분히 구현할 수 있는 것 같은데

별도의 회원가입 절차를 받을지 말지는... 사실 이건 기획 정책 파트라서 뭐가 좋을진 모르겠어요. 만약 사용자에게 추가 정보를 여러 개 받아야 한다면 당연히 필요하겠지만, 그게 아니라면 필요할까? 근데 꼭 정보만을 위해서가 차이인 걸까? 싶은 의문이 들더라고요.. 이건 제가 지금 당장 알 수 없으니 일단 패스.

passport 라이브러리를 쓸지 말지는... 이것도 장단점이 명확한데, 제가 직접 REST API로 개발한 이유는 passport를 쓰게 되면 중간중간에 생략된 기능들이 뭔지 이해하기 어렵더라고요. 그래서 공부 겸 풀어서 직접 써보자 싶어서 REST API를 써봤습니다. 근데 보안 관련 해서도 passport를 쓰는 게 더 이득인 것 같아요. 이건 나중에 마이그레이션 하는 걸로!

 

 

마무리


여기까지 정~말 오랜만에 백엔드에서, 그것도 Nest.js 프레임워크, 토큰 기반으로 소셜로그인을 구현해 봤습니다. 제가 마지막으로 소셜 로그인을 구현해 본 게 4~5년 전인데 그때는 제가 뭔갈 명확히 이해하고 개발한 게 아니라 그저 클론 코딩 강의에서 시키는 대로 따라친게 전부였습니다. 그때는 passport를 써서 미들웨어래니, 중간에 생략된 내가 볼 수 없는 로직들을 알 수가 없어서 굉장히 혼란스러웠는데.. 역시 연차가 해결해 준다고(?) 이젠 모든 프로세스가 이해가 가기 시작하더라고요. 웃긴 건 제가 3년 전에 token 기반으로 로그인을 구현하려다가 실패한 경험도 있답니다 ㅎㅎ 그땐 react hook에 대한 개념도 떨어져서 더더욱 구현하지 못하고 헤맸었죠... 그저 블로그에 써진 내용대로 안되면 다른 방법은 생각도 못하고, 생각할 줄도 몰랐기에 그냥 실패로 될 수밖에 없었습니다.

사실 내가 발전하고 있는가? 그냥 CRUD 코더 기계 같은데...라는 고민이 늘 있었는데. 이렇게 과거랑 비교해 보니 성장은 했나 봅니다... 그치만.. 뭔가.. 더 어려운 걸 해야 할 것 같은 압박...하하핳하하ㅏ 아무튼~ 이걸 토대로 제가 지도 기반 서비스로 하고 있는 myCafe 토이 프로젝트에도 적용해 볼까 합니다 ㅎㅎ

반응형