진행중인 사이드 프로젝트에서 기획이 끝나서 화면기획서가 나오고,
디자인쪽도 피그마로 UI/UX 디자인을 시작하고,
개발쪽도 DB ERD 설계, 서버설계, Stg서버 등등.. 기본적인 개발하기 위한 준비를 마치고
온보딩, 메인페이지 부터 드디어 Api 작업에 들어갔다.
막상 개발은 큰 어려움이 없을텐데, 역시나 처음해보는 기획이나 DB/서버 설계에서
시간이 많이 걸리고, 제대로 처음 해보는거라서 어렵더라..
따로 Local 이메일 로그인은 제외하고 구글 / 카카오 / 네이버 로그인 세가지를 넣기로 했다.
NestJS에서 소셜 로그인을 붙히기 위해서는 순서에 대한 이해가 필요하다.
하나씩 차근차근 살펴보자.
소셜 로그인 순서
내가 이해한 순서를 이해하기 쉽게 그림으로 만들어 보았다. (디자인 감각 Zero ㅠ)
1. 페이지 요청: 사용자가 소셜 로그인 페이지에 접속한다.
2. 페이지 응답: 소셜 로그인 HTML 페이지를 웹/앱 브라우저에 반환한다.
3. 구글 로그인 요청: 사용자가 구글 로그인 버튼을 클릭하면 http://localhost:3456/user/login/google 주소로 GET 요청을 보낸다.
4. 구글 -> 웹/앱 브라우저: 구글 로그인 페이지에서 사용자가 인증을 하면, 구글은 시크릿코드를 반환한다.
5. controller -> 구글: 시크릿코드를 이용해서 구글에 access_token과 refresh_token을 요청한다.
6. 구글 -> controller: 구글은 JWT와 user profile을 반환한다.
7. controller -> service: JWT와 user profile을 반환한다.
8. service -> repository: 이미 가입된 유저인지 체크하고, 미가입 유저면 DB에 쌓는다.
9. repository -> service: 새로 만든 유저정보 or 기존 유저정보 데이터를 반환한다.
10. service: 넘겨받은 유저정보로 JWT access_token과 refresh_token을 생성한다.
11. service -> repository: refresh_token을 user DB에 쌓는다.
12. service -> controller : JWT access_token과 refresh_token을 반환한다.
13. controller -> 웹/앱 브라우저: JWT access_token과 refresh_token을 반환한다.
이후 토큰만료 체크해서 refresh_token으로 토큰만료 체크해서 새로운 access_token 발급받는데
해당 내용은 다음 포스팅에서 진행할 예정이다.
위 흐름을 제대로 이해하는데 좀 헤맸지만, 이것만 이해하면 끝이나 다름없다.
Google, Naver, Kakao - secret key 발급
이제 각각의 사이트에서 시크릿 키를 발급 받아야한다.
이때 유저의 정보를 추가로 받아올지 정할 수 있는데
나는 이메일, 프사, 닉네임 세가지를 공통으로 추가했다.
https://console.developers.google.com/apis
https://developers.naver.com/main/
승인된 자바스크립트 원본 : http://localhost:3456
승인된 리디렉션 URI : http://localhost:3456/auth/google/callback
나는 이런식으로 콜백 url을 구성 했는데 각자 알아서 url을 추가해 주면된다.
각각의 사이트에서 Client ID 와 Client Secret 를 생성을 한 후
.env 파일에 다음과 같이 넣어준다.
이제 준비는 끝났으니 로직을 짜러 가보자.
Code
// JWT, passport 라이브러리 설치
npm install @nestjs/jwt
npm install @nestjs/passport;
// 각각 스트래티지를 지원하는 의존성 패키지 설치
npm install passport-google-oauth20
npm install passport-kakao
npm install passport-naver
// ts 버전 추가 설치
npm install --dev @types/passport-google-oauth20
npm install --dev @types/passport-kakao
npm install --dev @types/passport-naver
해당 라이브러리를 모두 설치해준다.
yarn을 쓰면 yarn add 로 설치해주면 된다.
이제 각각의 strategy 파일 코드를 보자.
// jwt-social-google.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Profile, Strategy, VerifyCallback } from 'passport-google-oauth20';
import { ConfigService } from 'src/config/config.service';
@Injectable()
export class JwtGoogleStrategy extends PassportStrategy(Strategy, 'google') {
//UseGuards의 이름과 동일해야함
constructor(private readonly configService: ConfigService) {
// console.log(configService)
super({
//자식의 constructor를 부모의 constructor에 넘기는 방법은 super를 사용하면 된다.
clientID: configService.get('GOOGLE_ID'), //.env파일에 들어있음
clientSecret: configService.get('GOOGLE_SECRET'), //.env파일에 들어있음
callbackURL: 'http://localhost:3456/auth/google/callback', //.env파일에 들어있음
scope: ['email', 'profile'],
});
}
authorizationParams(): { [key: string]: string } {
return {
access_type: 'offline',
prompt: 'consent',
};
}
async validate(
accessToken: string,
refreshToken: string,
profile: Profile,
done: VerifyCallback,
) {
console.log('success');
console.log(accessToken);
console.log(refreshToken);
try {
const { name, emails, photos } = profile;
const user = {
email: emails[0].value,
firstName: name.familyName,
lastName: name.givenName,
photo: photos[0].value,
};
done(null, user);
} catch (error) {
done(error);
}
}
}
// jwt-social-kakao.strategy.ts
import { ConsoleLogger, Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Profile, Strategy } from 'passport-kakao';
import { ConfigService } from 'src/config/config.service';
@Injectable()
export class JwtKakaoStrategy extends PassportStrategy(Strategy, 'kakao') {
constructor(private readonly configService: ConfigService) {
super({
clientID: configService.get('KAKAO_ID'), //.env파일에 들어있음
clientSecret: configService.get('KAKAO_SECRET'), //.env파일에 들어있음
callbackURL: 'http://localhost:3456/auth/kakao/callback', //.env파일에 들어있음
// scope: ["account_email", "profile_nickname"],
});
}
async validate(
accessToken: string,
refreshToken: string,
profile: Profile,
done: (error: any, user?: any, info?: any) => void,
) {
try {
console.log(profile);
const { _json } = profile;
const user = {
email: _json.kakao_account.email,
nickname: _json.properties.nickname,
photo: _json.properties.profile_image,
};
done(null, user);
} catch (error) {
done(error);
}
}
}
// jwt-social-naver.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Profile, Strategy } from 'passport-naver';
import { ConfigService } from 'src/config/config.service';
@Injectable()
export class JwtNaverStrategy extends PassportStrategy(Strategy, 'naver') {
constructor(private readonly configService: ConfigService) {
super({
clientID: configService.get('NAVER_ID'), //.env파일에 들어있음
clientSecret: configService.get('NAVER_SECRET'), //.env파일에 들어있음
callbackURL: 'http://localhost:3456/auth/naver/callback', //.env파일에 들어있음
});
}
async validate(
accessToken: string,
refreshToken: string,
profile: Profile,
done: (error: any, user?: any, info?: any) => void,
) {
try {
console.log(profile);
const { _json } = profile;
const user = {
email: _json.email,
nickname: _json.nickname,
photo: _json.profile_image,
};
done(null, user);
} catch (error) {
done(error);
}
}
}
이렇게 구글, 카카오, 네이버 strategy 파일을 추가해 주자.
나는 ConfigModule 로컬 라이브러리를 사용하는데
외부 라이브러리를 사용하게 될 경우 import 경로를 맞게 바꿔주면 된다.
그리고 각각에 strategy 파일마다 validate() 부분이 다른게 보일텐데
이는 구글, 카카오, 네이버에서 넘겨주는 데이터 폼이 약간씩 달라서 그렇다.
// auth.controller.ts
//-----------------------구글 로그인-----------------------------//
@Get('/user/login/google')
@UseGuards(GoogleAuthGuard)
async googleAuth(@Req() _req: Request) {
}
/* Get Google Auth Callback */
@Get('/auth/google/callback')
@UseGuards(AuthGuard('google'))
async googleAuthCallback(
@Req() req: GoogleRequest,
@Res() res: Response, // : Promise<GoogleLoginAuthOutputDto>
) {
// return res.send(user);
return this.authService.googleLogin(req, res);
}
//-----------------------카카오 로그인-----------------------------//
@Get('/user/login/kakao')
@UseGuards(AuthGuard('kakao'))
async kakaoAuth(@Req() _req: Request) {
}
/* Get kakao Auth Callback */
@Get('/auth/kakao/callback')
@UseGuards(AuthGuard('kakao'))
async kakaoAuthCallback(
@Req() req: KakaoRequest,
// @Res({ passthrough: true }) res: Response,
@Res() res: Response, // : Promise<KakaoLoginAuthOutputDto>
) {
const { user } = req;
console.log(user);
return res.send(user);
// return this.authService.kakaoLogin(req, res);
}
//-----------------------네이버 로그인-----------------------------//
@Get('/user/login/naver')
@UseGuards(AuthGuard('naver'))
async naverAuth(@Req() _req: Request) {
}
/* Get naver Auth Callback */
@Get('/auth/naver/callback')
@UseGuards(AuthGuard('naver'))
async naverAuthCallback(
@Req() req: NaverRequest,
@Res() res: Response, // : Promise<NaverLoginAuthOutputDto>
) {
const { user } = req;
console.log(user);
return res.send(user);
// return this.authService.naverLogin(req, res);
}
}
구글 이랑 카카오,네이버랑 return 값이 다를텐데
카카오, 네이버는 테스트를 위해서 return 값을 임시로 이렇게 해둔것이라 테스트가 끝나면 구글처럼 해주면 된다.
return res.send(user) 로 구성하면 service부 없이도
http://localhost:3456/user/login/naver -> 네이버 로그인하면
콜백 url로 위 사진처럼 내가 정해놓은 유저 데이터가 출력될 것이다.
여기서 혹시나 안되면 env파일, config 설정, Auth module, 네이버 개발자센터 콜백 url
해당 4가지 항목을 잘 체크해보면 아마 해결될 것이다.
(4가지 체크하라는건 슬프게도 경험담... 따흑)
이제 여기까지 잘 따라왔다면 DB에 데이터를 쌓아보자.
// auth.service.ts
async googleLogin(
req: GoogleRequest,
res: Response,
// googleLoginAuthInputDto, // : Promise<GoogleLoginAuthOutputDto>
) {
try {
req.user.nickname = req.user.firstName + req.user.lastName;
const { user } = req;
delete user.lastName;
delete user.firstName;
user.type = 'google';
// 유저 중복 검사
let findUser = await this.userQueryRepository.findUser(user);
// 없는 유저면 DB에 유저정보 저장
if (!findUser) {
const uuid = generateUUID();
findUser = await this.userQueryRepository.createUser(user, uuid);
}
console.log(findUser);
// 구글 가입이 되어 있는 경우 accessToken 및 refreshToken 발급
const findUserPayload = { id: findUser.id };
const eid_access_token = jwt.sign(
findUserPayload,
this.configService.get('JWT_ACCESS_TOKEN_SECRET_KEY'),
{
expiresIn: this.configService.get('JWT_ACCESS_TOKEN_EXPIRATION_TIME'),
},
);
const eid_refresh_token = jwt.sign(
{},
this.configService.get('JWT_REFRESH_TOKEN_SECRET_KEY'),
{
expiresIn: this.configService.get('JWT_REFRESH_TOKEN_EXPIRATION_TIME'),
audience: String(findUser.id),
},
);
/* refreshToken 필드 업데이트 */
findUser.eid_refresh_token = eid_refresh_token;
await this.userQueryRepository.save(findUser);
// 쿠키 설정
const now = new Date();
now.setDate(now.getDate() + +this.configService.get('JWT_REFRESH_TOKEN_EXPIRATION_DATE'));
res.cookie('eid_refresh_token', eid_refresh_token, {
expires: now,
httpOnly: true,
secure: process.env.NODE_ENV === 'production' ? true : false,
sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax',
});
return {
ok: true,
eid_access_token,
};
} catch (error) {
return { ok: false, error: '구글 로그인 인증을 실패 하였습니다.' };
}
}
토큰 전까지만 우선 보자.
토큰, 쿠키는 다음 포스팅에서 다룰것이다.
구글은 lastname, firstname을 따로 넘겨줘서 네이버, 카카오처럼 "닉네임"으로 합쳐주었다.
Repository에서 이미 있는 유저인지 체크를 하고,
가입된 유저가 아니면 유저DB를 쌓아준다.
나는 uuid 베이스로 DB를 설계해놔서 uuid도 추가해 주었다.
// user.query.repository.ts
async findUser(user): Promise<UserEntity> {
return await this.repository.findOne({
where: { email: user.email, name: user.nickname, type: user.type },
});
}
async createUser(user, uuid): Promise<UserEntity> {
return await this.repository.save({
uuid: uuid,
email: user.email,
name: user.nickname,
profile_image: user.photo,
type: user.type,
});
}
async save(UserEntity): Promise<UserEntity> {
return await this.repository.save(UserEntity);
}
user repository에는 별거없다.
아까 auth.service에서 넘긴 데이터 처리만 이뤄진다.
중복 유저는 email, name, type 세가지로 비교한다.
이제 기본적인 코드는 끝이다.
코드를 실행시키고 http://localhost:3456/user/login/google 에 접속하자.
여기서 계정을 누르면 콜백url로 넘어가면서 auth 코드로 넘어갈 것이다.
첫 로그인이니 제대로 DB에 쌓였나 확인을 해보자.
아주 잘 쌓였다 ㅎㅎ
이제 네이버, 카카오도 동일한 방법으로 진행을 해주면 된다.
access_token과 fresh_token에 대해서
[Nest.JS] 구글, 네이버, 카카오 소셜로그인 구현 - 2편 에서 포스팅 할 예정이다.
'Web > NestJS' 카테고리의 다른 글
[Nest.JS] 구글, 네이버, 카카오 소셜로그인 구현 - 2 (1) | 2023.11.04 |
---|---|
[Nest.JS] Nest.JS에서 cache-manager를 활용한 캐싱 방법 (0) | 2023.09.17 |
[Nest.JS] Slack에 다양한 통계데이터 알림 자동화 (0) | 2023.07.02 |
[Nest.JS] TypeOrm에서 페이지네이션 구현 (last_id, size) (0) | 2023.05.05 |
[Nest.JS] DTO vs Interface (0) | 2023.04.23 |