Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Be/feature/#458 kakao OAuth #459

Merged
merged 64 commits into from
Jan 12, 2024
Merged

Be/feature/#458 kakao OAuth #459

merged 64 commits into from
Jan 12, 2024

Conversation

kimyu0218
Copy link
Collaborator

@kimyu0218 kimyu0218 commented Jan 10, 2024

변경 사항

  • OAuth 관련 파일 작성 src/auth
  • 에러 인터셉터 리팩토링
  • 리프레시 토큰을 저장하기 위해 redis 추가

고민과 해결 과정

우리 프로젝트에서 카카오톡으로 타로 결과 공유하는 서비스를 제공하고 있기 때문에 카카오 로그인부터 도입하기로 결정했다. 추후에 구글과 네이버 로그인도 추가할 예정이다.

학습정리

고민사항

가장 고민이었던 부분이 "액세스 토큰과 리프레시 토큰을 어디에다 저장해야 하는가" 였다. 챗지피티한테 열심히 물어봤지만, 구현에 정답은 없었다.

- 액세스 토큰 : 쿠키
- 리프레시 토큰 : redis

현재 위와 같이 구현한 상태다. 하지만 쿠키에다 넣어주면 보안상 매우 취약하다. OAuth에서 접근할 수 있는 자원을 제한하고 있긴 하지만 악의적인 사용자가 액세스 토큰을 탈취해서 자원을 열람할 수 있기 때문이다.

이러한 취약점을 보완하기 위해 httpOnly 설정을 해줬다. httpOnly 설정을 해주면 스크립트를 통한 쿠키 조회가 불가능하다. (악의적인 사용자는 물론, 일반 사용자도 쿠키를 조회할 수 없지만, 무조건 백엔드 서버를 거치는 게 보안상 안전하다고 판단했다!) xss는 해결됐지만 평문으로 전송되기 때문에 패킷을 캡처하는 악의적인 사용자에게 노출될 수 가능성도 있다. secure 설정을 통해 암호화된 통신에서만 사용하도록 해줬다.

액세스 토큰도 외부에 노출되어선 안되는 값이지만 리프레시 토큰은 더 강력한 파워를 갖고 있다. (액세스 토큰은 금방 만료되기 때문에 외부에 노출되어도 만료되면 무용지물이다.) 그래서 리프레시 토큰은 redis에 저장하고, 클라이언트에게 전송하지 않도록 구현했다. (어차피 클라이언트가 자신의 액세스 토큰을 스크립트로 추출할 수 없기 때문에 갖고 있을 필요가 없다.)

컨트롤러

@Controller('oauth')
export class AuthController {
  private readonly isProd: boolean;
  constructor(
    private readonly configService: ConfigService,
    private readonly kakaoAuthService: KakaoAuthService,
  ) {
    this.isProd = this.configService.get('ENV') === 'PROD';
  }

  // 카카오 로그인
  @Get('login/kakao')
  async kakaoLogin(@Req() req: Request, @Res() res: Response): Promise<void> {
    ...
  }

  // 로그아웃
  @UseGuards(AuthGuard)
  @Get('logout')
  async kakaoLogout(@Req() req: any, @Res() res: Response): Promise<void> {
   ...
  }
}

현재 카카오 로그인 밖에 없기 때문에 두 라우트에 대해서만 작성해줬다. /oauth/login/kakao는 카카오 로그인을, /oauth 로그아웃은 전체 OAuth 로그인에 대한 로그아웃을 의미한다.

  • 카카오 로그인

kakaoLogin은 인가코드를 요청하고 리다이렉트 되면 타게 된다. 인가코드가 들어 있다면 kakaoAuthService를 요청하여 로그인 과정을 수행한다. 로그인에 성공하면 jwt 토큰을 받게 되는데 이를 쿠키에 붙여 전달한다. 쿠키는 나중에 인증 가드에서 유효성을 검사하여 인증 여부를 확인하는 데 사용된다.

@Get('login/kakao')
  async kakaoLogin(@Req() req: Request, @Res() res: Response): Promise<void> {
    if (req.params.error) { // 인증 코드 발급에 실패한 경우
      throw new UnauthorizedException(ERR_MSG.OAUTH_KAKAO_AUTH_CODE_FAILED);
    }
    // 인증 코드를 바탕으로 로그인 수행
    const jwt: string = await this.kakaoAuthService.loginOAuth(req.params.code);
    res.cookie('magicconch', jwt, {
      httpOnly: true,
      secure: this.isProd,
      sameSite: 'lax',
    });
    res.sendStatus(200);
  }
  • 로그아웃

kakaoLogin과 달리 OAuth를 제공하는 모든 서비스에 대해 로그아웃을 수행한다. 인증 가드에서 쿠키 내용을 파싱하여 req.user에 붙여주게 되는데, 해당 내용을 바탕으로 어떤 서비스의 로그아웃을 수행해야 하는지 판단한다.

@UseGuards(AuthGuard)
  @Get('logout')
  async kakaoLogout(@Req() req: any, @Res() res: Response): Promise<void> {
    const user: JwtPayloadDto = req.user; // AuthGuard에서 파싱된 내용
    switch (user.providerId) {
      case PROVIDER_ID.KAKAO: // 카카오 로그아웃
        await this.kakaoAuthService.logoutOAuth(user);
        break;
      case PROVIDER_ID.NAVER: // 네이버 로그아웃
        break;
      case PROVIDER_ID.GOOGLE: // 구글 로그아웃
        break;
    }
    // 쿠키를 제거하여 우리 서비스에서도 로그아웃하도록 구현
    res.clearCookie('magicconch', {
      httpOnly: true,
      secure: this.isProd,
      sameSite: 'lax',
    });
    res.sendStatus(200);
  }

서비스

AuthServiceKakaoAuthService 를 작성해줬다. 처음엔 AuthService에 모든 내용을 작성했는데 무려 250줄에 육박했다. 추후에 구글, 네이버 로그인까지 추가되면 하나의 파일에 너무 많은 코드가 들어갈 것 같아 플랫폼 별로 파일을 분리해주었다.

  • AuthService

공통으로 사용하는 로직이 들어있는 서비스다. KakaoAuthService에서 해당 클래스를 상속하여 이용한다.

@Injectable()
export class AuthService {
  // OAuth 서비스를 사용하기 위해 필요한 변수
  clientId: string;
  redirectUri: string;
  clientSecret: string;
  ttl: number;
  ...

  // 신규 회원인 경우, 회원가입 수행
  async signup(
    providerId: number,
    profile: ProfileDto,
    token: OAuthTokenDto,
  ): Promise<string> {
    /* 
      - 리프레시 토큰 캐시에 저장
      - 사용자 정보 추가
    */
  }

  // 기존 회원인 경우, 로그인 수행
  async login(
    id: string,
    providerId: number,
    profile: ProfileDto,
    token: OAuthTokenDto,
  ): Promise<string> {
    /* 
      - 리프레시 토큰 캐시에 저장
      - 사용자 정보 갱신
    */
  }

  ...
}
  • KakaoAuthService

카카오 관련 OAuth 서비스를 정의한 클래스다. REST API를 이용하여 토큰을 발급받고, 갱신하고, 사용자 프로필을 조회한다.

@Injectable()
export class KakaoAuthService extends AuthService { // AuthService 상속!!
  constructor(
    readonly membersService: MembersService,
    readonly jwtService: JwtService,
    readonly configService: ConfigService,
  ) {
    super(membersService, jwtService, configService);
    this.init(PROVIDER_NAME.KAKAO); // 카카오 로그인에 필요한 환경변수 설정
  }

  // 로그인
  async loginOAuth(code: string): Promise<string> {
    // 1. 토큰 발급 받기
    const token: KakaoTokenDto = await this.requestToken(code);

    // 2. 사용자 정보 요청하기
    const profile: ProfileDto = await this.geUser(token.access_token ?? '');

    // 이메일과 플랫폼을 기준으로 사용자 조회
    const member: Member | null = await this.membersService.findByEmail(
      profile.email,
      PROVIDER_ID.KAKAO,
    );
    /* 
      - 기존 회원인 경우, 로그인 수행
      - 신규 회원인 경우, 회원가입 수행
    */
  }

  // 로그아웃
  async logoutOAuth(user: JwtPayloadDto): Promise<boolean> {
    // 1. 액세스 토큰 정보 조회
    const tokenInfo: KakaoAccessTokenInfoDto | null =
      await this.getAccessTokenInfo(user.accessToken);
    // 유효한 액세스 토큰인 경우 로그아웃 수행
    if (tokenInfo) {
      return this.requestLogout(user.accessToken);
    }

    // 2. 액세스 토큰이 만료된 경우, 리프레시 토큰으로 토큰 갱신
    const key: CacheKey = { email: user.email, providerId: user.providerId };
    const keyString: string = JSON.stringify(key);
    const refreshToken: string | undefined =
      await this.cacheManager.get<string>(keyString);
    const newToken: KakaoTokenDto = await this.refreshToken(refreshToken ?? '');

    // 3. 새로운 액세스 토큰으로 로그아웃 수행
    return this.requestLogout(newToken.access_token);
  }

  /* 아래는 카카오 REST API 요청*/

  // 토큰 발급
  private async requestToken(code: string): Promise<KakaoTokenDto> { ... }

  // 토큰 갱신
  private async refreshToken(refreshToken: string): Promise<KakaoTokenDto> { ... }

  // 사용자 프로필 조회
  private async getUser(accessToken: string): Promise<ProfileDto> { ... }

  // 액세스 토큰 정보 조회
  private async getAccessTokenInfo(
    accessToken: string,
  ): Promise<KakaoAccessTokenInfoDto | null> { ... }

  // 로그아웃 요청
  private async requestLogout(accessToken: string): Promise<boolean> { ... }
  ...
}

가드

사용자 인증 여부를 확인하기 위한 가드를 작성했다. JwtService를 이용하여 토큰을 검증하고, 토큰 정보를 req에 붙여서 전달한다.

@Injectable()
export class AuthGuard implements CanActivate {
  constructor(private jwtService: JwtService) {}

  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    const req: any = context.switchToHttp().getRequest(); // req 객체 가져오기
    const token: string | null = req.cookies.magicconch; // 쿠키 추출하기

    // 1. 쿠키 없는 경우, 로그인하지 않은 사용자
    if (!token) {
      throw new UnauthorizedException(ERR_MSG.JWT_NOT_FOUND);
    }
    // 2. 쿠키 검증 후, 유효한 jwt 토큰에 대해 req에 정보 저장
    const decoded: JwtPayloadDto = this.verifyToken(token);
    req.user = decoded;
    return true;
  }
  ...
}

redis (for 호스트서버)

개발 환경에서는 localhost로 띄우고, 호스트 서버에서는 docker 컨테이너로 띄우기로 결정했다.

sudo docker pull
sudo docker run -d --name redis-prod --network backend redis

redis 이미지를 pull 받고, redis-prod라는 이름의 컨테이너로 실행해줬다.

(선택) 테스트 결과

카카오 로그인이 제대로 동작하는지 확인하기 위해 Postman을 이용했다.

image
공식 문서를 바탕으로 쿼리 파라미터를 올바르게 작성해준다.

image
로그인에 성공하면 위와 같이 쿠키가 붙게 된다. (개발환경에서 테스트 중이기 때문에 secure가 활성화되지 않았다)

쿠키가 부착된 경우 쿠키가 부착되지 않은 경우
image image

토큰 발급을 위해 요청 바디에 넣어줘야 하는 필드들을 클래스로 정의
토큰을 성공적으로 발급 받았을 때 존재하는 필드들을 클래스로 정의
- 사용자 정보를 성공적으로 가져왔을 때, kakao_account 안에 들어있는
  정보를 별도의 클래스로 정의
- 동의항목으로 설정한 필드에 대해서만 작성
- 멤버를 생성하는 create() 메서드를 호출할 때 사용하는 dto 정의
- fromKakao라는 static 메서드를 이용하여 dto를 생성하도록 구현
- METHODS: fetch의 method 필드에 넣어주는 HTTP 메서드값
- CONTENT_TYPE
- OAUTH_URL : OAuth REST API URL
- 사용자의 회원가입 여부를 식별하기 위해 email 필드 추가
- 리프레시 토큰 DB에 저장 (최대 길이가 명시되어 있지 않아 text 타입으로
  설정)
@kimyu0218 kimyu0218 added this to the version 1.0.0 milestone Jan 10, 2024
@kimyu0218 kimyu0218 self-assigned this Jan 10, 2024
- OAuth를 위한 서비스 정의
- 멤버 서비스를 주입받아 사용자 create, select, update 작업 수행 가능
- loginKakao : 인가코드로 카카오 로그인 수행
  - getKakaoToken을 호출하여 액세스 토큰과 리프레시 토큰 발급 받음
  - getOIDCuserInfo를 호출하여 사용자 정보 조회
  - 조회한 사용자 정보를 바탕으로 이미 가입한 회원인지 확인
    - 이미 존재하는 회원인 경우, 리프레시 토큰 갱신
    - 신규 회원인 경우, 회원가입 진행
- logoutKakao : 카카오 로그아웃 수행
액세스 토큰 값과 만료 시간을 저장하는 객체 정의
- OAuth를 위한 컨트롤러 정의
- 현재 로그인, 로그아웃만 정의, 추후에 탈퇴 부분 추가 예정
Auth 서비스에서 멤버 서비스를 이용
-> imports, providers에 멤버 관련 내용 추가
추후 사용하지 않는 에러 메시지 삭제 예정
추후 사용하지 않는 url 삭제 예정
- findByEmail : 이메일로 사용자 조회
- updateRefreshToken : 리프레시 토큰 갱신
Copy link

cloudflare-workers-and-pages bot commented Jan 10, 2024

Deploying with  Cloudflare Pages  Cloudflare Pages

Latest commit: f5f6419
Status: ✅  Deploy successful!
Preview URL: https://2ff687f1.web09-magicconch.pages.dev
Branch Preview URL: https://be-feature--458-kakao-oauth.web09-magicconch.pages.dev

View logs

- JwtPayloadDto : jwt를 만들 때 들어가는 필드
- KakaoAccessTokenInfo : 카카오 로그인에서 액세스 토큰에 대한 정보를
  조회할 때 응답 바디
- KakaoRefreshTokenDto : 카카오 로그인에서 토큰을 갱신할 때 요청 바디
- RequestKakaoTokenDto : 카카오 로그인에서 토큰을 발급할 때 요청 바디
- OAuthTokenDto : 추후 구글, 네이버 로그인이 추가되었을 때 사용 예정
  - 플랫폼 별로 static 메서드 구현하여 dto 생성 계획
- ProfileDto : 기존 서비스에서 조회하여 사용하는 항목
- 카카오 계정의 경우, 카카오 메일이 아닌 이메일을 사용할 수 있음
- 카카오 계정의 메일이 네이버 계정의 메일과 겹칠 수 있기 때문에 유일하지
  않음
- 대신 providerId를 추가하여 사용자 정보를 제공하는 리소스 서버 코드를
  추가함

ex.  { email: [email protected], providerId: 0 }
     { email: [email protected], providerId: 1 }
     -> 이메일은 같지만 providerId가 다르므로 서로 다른 회원임
- 사용자 정보를 갱신할 때 필요한 dto
- 로그인할 때 조회한 프로필 정보와 DB의 프로필 정보가 다르다면 갱신
  필요 (+ 리프레시 토큰)
사용자 레코드 생성 시, 어느 플랫폼의 OAuth를 이용했는지 명시 필요
- 이메일 기준으로 조회하되, 이메일 중복이 있을 수 있으므로
  providerId까지 함께 검색하도록 수정
- refreshToken만 갱신하지말고 바뀐 사용자 프로필도 갱신하도록 수정
- 카카오 로그인 중 발생할 수 있는 에러 메시지 정의
- 구글, 네이버 등 플랫폼 구분하는 상수 정의
@kimyu0218 kimyu0218 linked an issue Jan 11, 2024 that may be closed by this pull request
@kimyu0218 kimyu0218 marked this pull request as ready for review January 11, 2024 11:52
redis를 이용하는 CacheConfigModule
redis의 키를 위한 인터페이스 작성
redis에 refresh token을 저장하기 때문에 더 이상 데이터베이스의 refresh
token을 저장할 필요가 없음
auth 서비스에서 캐시 사용할 수 있도록 import
- 로그아웃 요청 시, access token이 만료되는 경우가 발생할 수 있음
- 데이터베이스에서 refresh token을 조회하는 대신, 캐시에서 조회하도록
  변경
- AppModule에서 registerAsync()로 JwtModule 등록
- ChatModule이나 AuthModule에서는 registerAsync() 없이 JwtModule만
  import 하여 사용
email과 providerId를 key로 하여 refresh token 저장
- sameSite 옵션은 쿠키를 어떤 상황에서 전송해야 하는지 제어하는 옵션
- strict는 동일한 도메인에서만 쿠키 전송 가능
- lax는 외부 도메인의 GET 요청에 대해서만 쿠키 전송 가능
- 프론트엔드의 도메인과 백엔드의 도메인이 다르기 때문에 lax를 붙여줘야
  함
application/x-www-form-urlencoded 요청을 보낼 때는 URLSearchParams
형태의 body를 보내줘야 함
- OIDC 사용자 정보 조회 API가 동작하지 않아서 사용자 조회 API로 변경함
- 이에 따라 응답 바디가 변경되어 사용자 API에 맞게 적절하게 수정함
- 인가 코드를 파라미터에 저장하는 줄 알았으나 쿼리 파라미터였음
- param 대신 query를 이용하도록 수정함
서비스 앞에 붙은 @InjectRespository() 제거
- application/x-www-form-urlencoded 사용 시, 문자열이 아니라
  URLSearchParams 형태로 전송해야 함
  -> JSON.stringify() 대신 URLSearchParams를 넣어주도록 변경
- OIDC 사용자 정보 조회가 동작하지 않아 사용자 정보 조회를 이용하도록
  변경
- 헤더 설정 중에 발견한 오타 수정
- JwtModule을 동적으로 로드하도록 도와주는 모듈
- @global() 데코레이터를 이용하여 전역으로 사용할 수 있도록 설정
- 수정 전에는 @global() 데코레이터를 사용하지 않아 전역으로 사용되지
  않았음
- 최상위 모듈에 JwtConfigModule을 가져오도록 수정하면 다른 모듈에서는
  JwtConfigModule을 가져오지 않아도 에러가 발생하지 않음
@Doosies
Copy link
Collaborator

Doosies commented Jan 12, 2024

👍 👍

@kimyu0218 kimyu0218 merged commit 577a360 into dev Jan 12, 2024
1 check passed
@kimyu0218 kimyu0218 deleted the BE/feature/#458-kakao-OAuth branch January 12, 2024 12:57
@HeoJiye HeoJiye mentioned this pull request Feb 17, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

✅ kakao OAuth 구현
3 participants