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]);
}
|
소셜 로그인

소셜 로그인 이후
- 유저가 해당 사이트에서 로그인을 진행한 후에, 우리가 설정해둔
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의 역할은 어디까지 일까?
우리가 해야할 일
- 소셜 사이트에서 유저 정보 받아오기
- 로그인 인지 회원가입인 지 구별하기 (DB를 조회해보자.)
- 로그인 처리 (
JWT
를 통한 토큰 생성 방식)
- 현재 서버로 인해 이리저리 끌려다닌(?) 유저를 우리 서비스의 적절한 페이지로 보내주기.
- 기존 유저는 메인 페이지.
- 신규 유저는 추가 정보 입력 페이지(signup).
우리가 하지 말아야할 일
- 로그인 시, DB에 유저 정보 삽입
- 우리는
nickname
과 characterName
을 추가로 받아야한다.
nickname
과 characterName
을 받은 후에 DB에 한번 에 삽입하는 것이 좋지 않을까?
- 해당 부분은
signup
API로 분리하자!
고려할 점
회원가입을 진행 중인 유저가 socialCallback에서 토큰을 발급하고, 닉네임을 설정 안하고 다른 페이지로 이동하면 어떡할까??
socialCallback
에서 신규 유저의 경우, 토큰을 발급하지 않는 건 어떨까? 가입이 완료되지 않은 유저에게 토큰을 발행한다는 것이 위험한 것 같다.
- 그렇다면,
signup
API를 요청하는 부분에서 이 유저가 소셜 로그인으로 인증을 받은 유저인 지 확인할 수 없다. 어찌됐던 소셜 로그인으로 인증을 받은 사람은 맞으니, socialCallback
에서는 인증 완료의 스티커(토큰)를 붙여주는 방향이 맞는 것 같다.
- 토큰을 확인하는 과정에서, DB 조회를 통해 해당 유저가 기존 유저인 지, 가입 중인 유저인 지를 판단해주면 되지 않을까?
- 그렇다면, 1번 케이스를 커버하기 위해서 모든 유저의 토큰 확인 로직에 DB 조회가 추가되는데, 이는 바람직한 방향일까?
- Jwt 토큰의 Payload 부분에 이를 구분할 수 있도록 표시해주면 어떨까?? 신규 유저의 경우 임시 토큰을 쥐어준다는 느낌으로 접근해도 좋을 것 같다!
만약 익명의 유저가 signup API를 악용하여, 무분별한 가입을 왕창 하면 어떡할까??
- 이를 커버하기 위해 우리는 소셜 로그인 로직과
signup
API 간의 일종의 연결성을 부여해야한다. 소셜 인증을 받지 않으면 signup
API를 받을 수 없도록 하자!! 토큰이 좋은 도구가 될 수 있을 것 같다.
우리는 socialCallback에서 signup 페이지로 redirect 시켜주고 있다. 우리가 socialCallback에서 알아낸 유저 정보들 (id, email, social 등) 을 어떻게 Client로 전달하지??
- 각각을 쿠키에 구워주는 방식은 어떨까? 클라이언트에선 이를 받자마자 signup Page에서 삭제해주고 로컬 변수에 지니고 있는 것은?
- 클라이언트가 로컬 변수에 지니고 있는 값이 신뢰 가능한 값인지 서버에서 인증할 수 없다.
- JWT 토큰의 Payload 부분에 넣자! 이는 임의로 변경할 수도 없으며, 소셜 인증의 의미도 갖고 있다!!
socialCallback (Login)
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에 유저 정보를 넣어주는데,
nickname
과 characterName
을 넣어준다. 만약 신규 유저라면 해당 부분에 undefined
값이 들어갈 것이다. 우리는 이를 통해 위에서 고려한 1번 케이스 (가입이 완료 되지 않았으나 토큰을 가지고 있는 유저들) 예외를 구분할 수 있다.
Signup API
- signup API 를 보자.
looseGuard
가 해당 API를 감싸고 있는데, 이는 다음장에서 자세히 설명할 예정이다. 단순히 설명하자면, accessToken
을 지닌 유저만 signup 할 수 있도록 감싼 것이다.
accessToken
의 payload 에서 id
와 social
값을 받아온다. 이는 guard에서 가공해서 request
의 user
필드에 넣어준다.
- FE에서 보낸
signupData
를 통해 우리는 nickname
과 characterName
을 알아내고, 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이 제대로 들어갔을 것이다. 이로써 우리는 회원가입을 마친다.