Stouter
Stouter

백엔드 개발자입니다.

편리하지만, 안전해야하는 인증 과정

SleepyWoods 서비스는 웹 게임 기반의 SNS로 개인화된 경험 및 데이터가 잘 지켜지는 것이 중요하다. 편리하지만, 안전하기도 한 회원 인증 서비스를 구현하기 위해 고민했던 부분을 정리해보자.

로그인 과정은 서버에게 위임하자!

WHY??

  • 각 소셜 사이트들의 OAuth 2.0 인증 방식들에는 보통 다음과 같은 정보가 필요하다. 예시로 Naver의 OAuth URL을 가져와 봤다.
    1
    2
    3
    4
    5
    6
    7
    
    const naverOauthUrl = `https://nid.naver.com/oauth2.0/token?
      grant_type=authorization_code&
    	client_id=${process.env.NAVER_OAUTH_CLIENT_ID}&
    	client_secret=${process.env.NAVER_OAUTH_SECRET_KEY}&
    	redirect_uri=${process.env.SERVER_URL}/user/callback/naver&
    	code=${code}&
    	state=RANDOM_STATE`;
    
  • 공통적으로 우리가 미리 해당 서비스에 Application을 등록하며 받은 Client_Id, Secret_Key가 필요하다.
  • 만약 우리가 해당 부분을 Client에서 처리했다면, 불필요한 유출이 있을 수 있지 않을까?
  • OAuth 2.0 방식은 대부분 공통적인 구조를 지니고 있다. 최대한 추상화해서 간결히 표현해보자!!


Login API

  • 현재 우리는 소셜 로그인 버튼 클릭 시 다음과 같은 API 요청을 서버로 보낸다.
    • GET /user/login?social={naver | kakao | google}
  • 서버의 controller에서는 해당 소셜 사이트로 redirection 시켜준다.
1
2
3
4
5
6
7
8
9
10
11
12
13
// user.controller.ts
@Get('login')
  loginRedirect(
    @Query('social', socialPlatformValidationPipe) social: socialPlatform,
    @Res() res: Response
  ): void {
    const socialOauthUrl = {
      [socialPlatform.NAVER]: `https://nid.naver.com/oauth2.0/authorize?response_type=code&client_id=${process.env.NAVER_OAUTH_CLIENT_ID}&redirect_uri=${process.env.SERVER_URL}/user/callback/naver&state=RANDOM_STATE`,
      [socialPlatform.KAKAO]: `https://kauth.kakao.com/oauth/authorize?client_id=${process.env.KAKAO_REST_API_KEY}&redirect_uri=${process.env.SERVER_URL}/user/callback/kakao&response_type=code`,
      [socialPlatform.GOOGLE]: `https://accounts.google.com/o/oauth2/v2/auth?scope=https://www.googleapis.com/auth/userinfo.profile&response_type=code&redirect_uri=${process.env.SERVER_URL}/user/callback/google&client_id=${process.env.GOOGLE_OAUTH_CLIENT_ID}`,
    };
    res.redirect(socialOauthUrl[social]);
}

소셜 로그인

Untitled

소셜 로그인 이후

  • 유저가 해당 사이트에서 로그인을 진행한 후에, 우리가 설정해둔 Callback_URL을 통해 서버의 해당 부분으로 돌아온다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// user.controller.ts
@Get('callback/:social')
  async socialLogin(
    @Query('code') code: string,
    @Param('social') social: socialPlatform,
    @Res() res: Response
  ): Promise<void> {

		// 1단계 : 해당 사이트에서 우리가 필요한 유저 정보를 받아오기.
    const accessToken = await this.authService.socialOauth(social, code);
    const userSocialProfile = await this.authService.socialProfileSearch(
      social,
      accessToken
    );
							.
							.
							.
}
  • 유저가 해당 사이트에서 받아온 Code를 통해, 우리는 해당 사이트에서 유저의 AccessToken을 받아올 수 있으며, 해당 토큰으로 우리가 필요한 유저 정보(userSocialProfile)를 받아올 수 있다.


socialCallback의 역할은 어디까지 일까?

우리가 해야할 일

  1. 소셜 사이트에서 유저 정보 받아오기
  2. 로그인 인지 회원가입인 지 구별하기 (DB를 조회해보자.)
  3. 로그인 처리 (JWT를 통한 토큰 생성 방식)
  4. 현재 서버로 인해 이리저리 끌려다닌(?) 유저를 우리 서비스의 적절한 페이지로 보내주기.
    • 기존 유저는 메인 페이지.
    • 신규 유저는 추가 정보 입력 페이지(signup).

우리가 하지 말아야할 일

  1. 로그인 시, DB에 유저 정보 삽입
    • 우리는 nicknamecharacterName을 추가로 받아야한다.
    • nicknamecharacterName을 받은 후에 DB에 한번 에 삽입하는 것이 좋지 않을까?
    • 해당 부분은 signup API로 분리하자!


고려할 점

회원가입을 진행 중인 유저가 socialCallback에서 토큰을 발급하고, 닉네임을 설정 안하고 다른 페이지로 이동하면 어떡할까??

  1. socialCallback에서 신규 유저의 경우, 토큰을 발급하지 않는 건 어떨까? 가입이 완료되지 않은 유저에게 토큰을 발행한다는 것이 위험한 것 같다.
    • 그렇다면, signup API를 요청하는 부분에서 이 유저가 소셜 로그인으로 인증을 받은 유저인 지 확인할 수 없다. 어찌됐던 소셜 로그인으로 인증을 받은 사람은 맞으니, socialCallback에서는 인증 완료의 스티커(토큰)를 붙여주는 방향이 맞는 것 같다.
  2. 토큰을 확인하는 과정에서, DB 조회를 통해 해당 유저가 기존 유저인 지, 가입 중인 유저인 지를 판단해주면 되지 않을까?
    • 그렇다면, 1번 케이스를 커버하기 위해서 모든 유저의 토큰 확인 로직에 DB 조회가 추가되는데, 이는 바람직한 방향일까?
  3. Jwt 토큰의 Payload 부분에 이를 구분할 수 있도록 표시해주면 어떨까?? 신규 유저의 경우 임시 토큰을 쥐어준다는 느낌으로 접근해도 좋을 것 같다!

만약 익명의 유저가 signup API를 악용하여, 무분별한 가입을 왕창 하면 어떡할까??

  1. 이를 커버하기 위해 우리는 소셜 로그인 로직과 signup API 간의 일종의 연결성을 부여해야한다. 소셜 인증을 받지 않으면 signup API를 받을 수 없도록 하자!! 토큰이 좋은 도구가 될 수 있을 것 같다.

우리는 socialCallback에서 signup 페이지로 redirect 시켜주고 있다. 우리가 socialCallback에서 알아낸 유저 정보들 (id, email, social 등) 을 어떻게 Client로 전달하지??

  1. 각각을 쿠키에 구워주는 방식은 어떨까? 클라이언트에선 이를 받자마자 signup Page에서 삭제해주고 로컬 변수에 지니고 있는 것은?
    • 클라이언트가 로컬 변수에 지니고 있는 값이 신뢰 가능한 값인지 서버에서 인증할 수 없다.
  2. JWT 토큰의 Payload 부분에 넣자! 이는 임의로 변경할 수도 없으며, 소셜 인증의 의미도 갖고 있다!!


socialCallback (Login)

  1. socialCallback에서 유저 정보를 받아오고, 이를 통해 JWT 토큰을 생성한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@Get('callback/:social')
  async socialCallback(
    @Query('code') code: string,
    @Param('social') social: socialPlatform,
    @Res() res: Response
  ): Promise<void> {

    // 1단계 : 소셜에서 유저 정보 받아오기
    const accessToken = await this.authService.socialOauth(social, code);
    const userSocialProfile = await this.authService.socialProfileSearch(
      social,
      accessToken
    );

    // 2단계 : 우리 유저 인지 확인하기
    const userData = await this.userService.findUser({
      id: userSocialProfile.id,
      social: social,
    });

		// 3단계 : 유저 정보를 토대로 JWT accessToken을 생성하기.
    const jwt = await this.authService.jwtTokenGenerator({
      id: userSocialProfile.id,
      social,
      nickname: userData?.nickname,
      characterName: userData?.characterName,
    });
    res.cookie('accessToken', jwt.accessToken);

    if (!userData) {
      //신규 유저
      res.redirect(process.env.CLIENT_URL + '/signup');
    } else {
      // 기존 유저
      res.redirect(process.env.CLIENT_URL);
    }
  }
  • 핵심 로직은 3단계에 있다. 우리는 jwt의 payload에 유저 정보를 넣어주는데, nicknamecharacterName을 넣어준다. 만약 신규 유저라면 해당 부분에 undefined값이 들어갈 것이다. 우리는 이를 통해 위에서 고려한 1번 케이스 (가입이 완료 되지 않았으나 토큰을 가지고 있는 유저들) 예외를 구분할 수 있다.


Signup API

  • signup API 를 보자. looseGuard 가 해당 API를 감싸고 있는데, 이는 다음장에서 자세히 설명할 예정이다. 단순히 설명하자면, accessToken을 지닌 유저만 signup 할 수 있도록 감싼 것이다.
  • accessToken 의 payload 에서 idsocial 값을 받아온다. 이는 guard에서 가공해서 requestuser 필드에 넣어준다.
  • FE에서 보낸 signupData를 통해 우리는 nicknamecharacterName을 알아내고, userData를 구성하여 DB에 넣어준다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Post()
  @UseGuards(AuthGuard('looseGuard'))
  async signUp(
    @Body('signupData', ValidationPipe, characterNameValidationPipe)
    signupData: signupDataDto,
    @Req() req: any,
    @Res() res: Response
  ) {
		// 1단계 : DB에 유저 정보를 삽입 : 회원가입
    const { id, social }: UserDataDto = req.user;
    await this.userService.createUser({
      id,
      social,
      nickname: signupData['nickname'],
      characterName: signupData['characterName'],
    });

		// 2단계 : 토큰을 재생성
    const jwt = await this.authService.jwtTokenGenerator({
      id,
      social,
      nickname: signupData['nickname'],
      characterName: signupData['characterName'],
    });

    res.cookie('accessToken', jwt.accessToken);
    res.status(200).send('회원가입 완료!!');
  }
  • DB 삽입이 끝난 후엔, jwt 토큰을 재생성해준다. 이번엔 nickname과 characterName이 제대로 들어갔을 것이다. 이로써 우리는 회원가입을 마친다.