NestJS: NAVER,KAKAO 소셜로그인 Passport로 구현하기

2024. 2. 21. 21:36NestJS

소셜로그인을 구성함에 앞서 먼저 KAKAO와 네이버 개발자 센터에 특정 데이터 사용에 대한 권한을 부여받아야 한다.

 네이버 개발자센터

https://developers.naver.com/main/

 

NAVER Developers

네이버 오픈 API들을 활용해 개발자들이 다양한 애플리케이션을 개발할 수 있도록 API 가이드와 SDK를 제공합니다. 제공중인 오픈 API에는 네이버 로그인, 검색, 단축URL, 캡차를 비롯 기계번역, 음

developers.naver.com

 

카카오 개발자센터

https://developers.kakao.com/

 

Kakao Developers

카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.

developers.kakao.com

우선 구현에 앞서 사용자 편리성을 제공하는 소셜로그인의 전체 구도를 살펴보고자 한다.

 

- 소셜로그인 데이터 사용 권한에 대한 중요성

사진에서 7번 과정은 프로필 정보를 kakao와 네이버에서 요청을 받게됩니다.

이때 소셜로그인 정책에 따라 서비스에 필요한 필수 정보만을 데이터로 제공을 받습니다.

소셜로그인 사용성에 대한 접근을 할 때 특정 데이터에 대한 권한 과정이 이루어진다.

 

- OAuth 2.0

이를 도입하기 위해 Passport 패키지를 반영하였으며 다른 선택지로 HttpService를 사용하는 구현하는 방식이 있었으나 오류 핸들링이 세부적으로 필요합니다.

또한 서비스에 종속성이 발생하게 되는데 만약에 저희 서비스가 다운되거나 응답 시간이 길어지면 서비스 전체에 영향을 미칠 수 있습니다.

구글,네이버와 같은 다양한 플랫폼의 특정한 사용자 데이터가 접근하기 위해 저희 서비스에서 사용자의 접근 권한을 위임 받을 수 있는 표준 프로토콜입니다.

 

- Passport: Node.js 기반의 웹 서비스에서 사용되는 인증 미들웨어

NestJS는 Passport를 통합하여 서비스에서 간편하게 사용할 수 있도록 지원하고 있습니다.PassportStrategy 클래스를 상속받아 구현됩니다.

OAuth 소셜로그인 제공을 위해 서비스에서 사용할 Passport 인증 미들웨어를 구현합니다.

인증 미들웨어를 제공하여 손쉽게 사용자를 인증할 수 있게 해주는 패키지입니다.

 

KAKAOstrategy.ts

import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { PassportStrategy } from "@nestjs/passport";
import { Profile, Strategy } from "passport-kakao";

@Injectable()
export class KakaoStrategy extends PassportStrategy(Strategy, "kakao") {
    constructor(private readonly configService: ConfigService) {
        super({
            clientID: configService.get<string>("KAKAO_CLIENT_ID"),
            clientSecret: configService.get<string>("KAKAO_CLIENT_SECRET"),
            callbackURL: `${configService.get<string>(
                "LOCAL",
            )}`,
            scope: ["account_email", "profile_nickname"],
        });
    }

    async validate(
        accessToken: string,
        refreshToken: string,
        profile: Profile,
    ) {
        return {
            accessToken,
            refreshToken,
            email: profile._json.kakao_account.email,
            nickname: profile.displayName,
        };
    }
}

strategy.ts에서는 성공적으로 인증된 후 사용자 프로필을 처리하기 위한 validate메서드를 정의하고 있다.

 

KAKAOcontroller.ts

import { Controller, Get, Query, Req, Res, UseGuards } from "@nestjs/common";
import { KakaoService } from "./kakao.service";
import { AuthGuard } from "@nestjs/passport";
import { ApiTags } from "@nestjs/swagger";
import { ConfigService } from "@nestjs/config";

interface IOAuthUser {
    user: {
        name: string;
        email: string;
        password: string;
    };
}

@ApiTags("카카오 소셜로그인")
@Controller("auth")
export class KakaoController {
    constructor(
        private readonly kakaoService: KakaoService,
        private readonly configService: ConfigService,
    ) {}

    @Get("kakao/success")
    async kakaoLoginOk(
        @Query("accessToken") accessToken: string,
        @Query("refreshToken") refreshToken: string,
        @Res() res: any,
    ) {
        res.cookie("accessToken", accessToken);
        res.cookie("refreshToken", refreshToken);
        //redirect할 본인 페이지 주소확인
        res.redirect(`${this.configService.get<string>("LOCAL")}/index.html`);
    }

    // 카카오 로그인할 event(button, 이미지 생성후 api를 통해 진행)
    @UseGuards(AuthGuard("kakao"))
    @Get("kakao")
    async kakaoLogin(): Promise<void> {}

    @UseGuards(AuthGuard("kakao"))
    @Get("/kakao/callback")
    async loginKakao(@Req() req: Request & IOAuthUser, @Res() res: Response) {
        this.kakaoService.OAuthLogin({ req, res });
    }
}

async kakaoLogin() {} 같은 경우에는  카카오 로그인 버튼이나 이미지를 통해 호출되는 메서드인데 실제 로그인은 Passport의 kakao전략에 의해 처리가 된다.

@UseGuards 데이코레이터를 특정한 HTTP 요청을 처리하는 컨트롤러 메서드나 핸들러 함수에 대한 Guard를 적용하는데 kakao를 사용하는 Passport의 인증 가드를 적용하고 있습니다. 해당 라우트 핸들러에 대한 요청이 카카오 소셜 로그인을 통해 이뤄져야 합니다.

async loginKakao(@Req() req: Request & IOAuthUser, @Res() res: Response) 같은 경우에는 카카오 소셜 로그인 콜백 경로를 처리하는 메서드입니다. IOAuthUser 인터페이스로 타입 정의된 req 객체를 이용하여 사용자 정보를 추출하고 있습니다. kakaoService.OAuthLogin({ req, res });를 통하여 실제 로그인 처리를 Service측에서 수행하고 있습니다.

KAKAOservice.ts

import { BadRequestException, Injectable } from "@nestjs/common";
import { UserService } from "src/user/user.service";
import { JwtService } from "@nestjs/jwt";
import { ConfigService } from "@nestjs/config";

@Injectable()
export class KakaoService {
    constructor(
        private readonly userService: UserService,
        private readonly jwtService: JwtService,
        private readonly configService: ConfigService,
    ) {}

    async OAuthLogin({ req, res }) {
        // 1.회원조회

        if (!req.user || !req.user.email) {
            // 유효한 사용자 정보가 없는 경우에 대한 예외 처리
            return false;
        }

        const userEmail = req.user.email;

        let user = await this.userService.findUserByEmail(userEmail);

        if (!user) {
            user = await this.userService.create({
                ...req.user,
                name: req.user.nickname,
            });
        }

        if (!user) {
            throw new BadRequestException(
                "사용자를 생성하거나 찾지 못했습니다.",
            );
        }

        const refreshToken = this.generateRefreshToken(user.id);
        await this.userService.update(user.id, {
            currentRefreshToken: refreshToken,
        });
        // 코드를 만드는거 1, 코드가 일치하는지 1
        const accessToken = this.generateAccessToken(user.id);
        res.cookie("access_token", accessToken, { httpOnly: true });
        if (user) {
            res.redirect(
                `${this.configService.get<string>(
                    "LOCAL",
                )}/api/auth/kakao/success?accessToken=${accessToken}&refreshToken=${refreshToken}`,
            );
        } else {
            res.redirect(
                `${this.configService.get<string>(
                    "LOCAL",
                )}/api/auth/login/failure`,
            );
        }
    }

    private generateAccessToken(id: number) {
        const payload = { userId: id };

        const accessToken = this.jwtService.sign(payload, {
            secret: this.configService.get<string>("JWT_ACCESS_TOKEN_SECRET"),
            expiresIn: this.configService.get<string>("JWT_ACCESS_TOKEN_EXP"),
        });

        return accessToken;
    }

    private generateRefreshToken(id: number) {
        const payload = { userId: id };

        const refreshToken = this.jwtService.sign(payload, {
            secret: this.configService.get<string>("JWT_REFRESH_TOKEN_SECRET"),
            expiresIn: this.configService.get<string>("JWT_REFRESH_TOKEN_EXP"),
        });

        return refreshToken;
    }
}

controller.ts에서 받은 req와 res 매개변수를 통하여 userRepository에 req.user에 대한 정보와 해당 유저아이디 값에 RefreshToken을 자체적으로 생산하여 userRepository의 해당 RefreshToken값을 update하는 방식으로 진행되는 것을 확인 할 수 있습니다.

그리고 이에 대한 반환 값을 return하는 대신에 res.redirect를 활용하여 해당 API로 redirect를 시키게 된다.

이러한 방식은 naver도 동일하게 진행이 된다. naver도 참고자료로 제공합니다.

NAVERstrategy.ts

import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { PassportStrategy } from "@nestjs/passport";
import { Profile, Strategy } from "passport-naver-v2";

@Injectable()
export class NaverStrategy extends PassportStrategy(Strategy, "naver") {
    constructor(private readonly configService: ConfigService) {
        super({
            clientID: configService.get<string>("NAVER_CLIENT_ID"),
            clientSecret: configService.get<string>("NAVER_CLIENT_SECRET"),
            callbackURL: `${configService.get<string>(
                "LOCAL",
            )}/api/auth/naver/callback`,
        });
    }

    async validate(
        accessToken: string,
        refreshToken: string,
        profile: Profile,
    ) {
        return {
            name: profile.name,
            email: profile.email,
            nickname: profile.nickname,
        };
    }
}

NAVERcontroller.ts

import { Controller, Get, Query, Req, Res, UseGuards } from "@nestjs/common";
import { NaverService } from "./naver.service";
import { AuthGuard } from "@nestjs/passport";
import { ApiTags } from "@nestjs/swagger";
import { ConfigService } from "@nestjs/config";

interface IOAuthUser {
    user: {
        name: string;
        email: string;
        password: string;
    };
}

@ApiTags("네이버 소셜로그인")
@Controller("auth")
export class NaverController {
    constructor(
        private readonly naverService: NaverService,
        private readonly configService: ConfigService,
    ) {}

    @Get("naver/success")
    async naverOk(
        @Query("accessToken") accessToken: string,
        @Query("refreshToken") refreshToken: string,
        @Res() res: any,
    ) {
        res.cookie("accessToken", accessToken);
        res.cookie("refreshToken", refreshToken);
        res.redirect(`${this.configService.get<string>("LOCAL")}/index.html`);
    }

    @UseGuards(AuthGuard("naver"))
    @Get("naver")
    async naverLogin(): Promise<void> {}

    @UseGuards(AuthGuard("naver"))
    @Get("/naver/callback")
    async loginNaver(@Req() req: Request & IOAuthUser, @Res() res: Response) {
        this.naverService.OAuthLogin({ req, res });
    }
}

NAVERservice.ts

import { BadRequestException, Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { JwtService } from "@nestjs/jwt";
import { UserService } from "src/user/user.service";

@Injectable()
export class NaverService {
    constructor(
        private readonly userService: UserService,
        private readonly jwtService: JwtService,
        private readonly configService: ConfigService,
    ) {}

    async OAuthLogin({ req, res }) {
        if (!req.user || !req.user.email) {
            // 유효한 사용자 정보가 없는 경우에 대한 예외 처리
            return false;
        }
        const userEmail = req.user.email;
        const userNickName = req.user.nickname;
        let user = await this.userService.findUserByEmail(userEmail);

        if (!user) {
            user = await this.userService.create({
                ...req.user,
                name: userNickName,
            });
        }

        if (!user) {
            throw new BadRequestException(
                "사용자를 생성하거나 찾지 못했습니다.",
            );
        }

        const refreshToken = this.generateRefreshToken(user.id);
        await this.userService.update(user.id, {
            currentRefreshToken: refreshToken,
        });

        const accessToken = this.generateAccessToken(user.id);
        res.cookie("access_token", accessToken, { httpOnly: true });

        if (user) {
            res.redirect(
                `${this.configService.get<string>(
                    "LOCAL",
                )}/api/auth/naver/success?accessToken=${accessToken}&refreshToken=${refreshToken}`, //받아주는 페이지 만들어야함
            );
        } else {
            res.redirect(
                `${this.configService.get<string>(
                    "LOCAL",
                )}/api/auth/login/failure`,
            );
        }
    }

    private generateRefreshToken(id: number) {
        const payload = { userId: id };

        const refreshToken = this.jwtService.sign(payload, {
            secret: this.configService.get<string>("JWT_REFRESH_TOKEN_SECRET"),
            expiresIn: this.configService.get<string>("JWT_REFRESH_TOKEN_EXP"),
        });

        return refreshToken;
    }

    /// access 토큰 발급 (private)
    private generateAccessToken(id: number) {
        const payload = { userId: id };
        const accessToken = this.jwtService.sign(payload, {
            secret: this.configService.get<string>("JWT_ACCESS_TOKEN_SECRET"),
            expiresIn: this.configService.get<string>("JWT_ACCESS_TOKEN_EXP"),
        });

        return accessToken;
    }
}

그런데 service 측에서 redirect를 API url를 통해 넘겨주게되면 API측에 accessToken과 refreshToken이 노출되는 문제가 생긴다. 이러한 문제는 이 정보가 서버 로그, 프록시 로그, 브라우저 히스토리 등에 저장될 수 있기에 쉽게 노출이 된다는 문제점이 있다.

이러한 문제는 저의 개인적인 해결방법을 생각해 봤을 때 지금 진행하고 있는 프로젝트에서는 Redis사용하지는 않았지만 추후 개인 프로젝트나 다른 프로젝트에 Redis의 메모리 기반의 데이터 스토어로 세션 관리를 할 수 있기 때문에 이를 통해 해결해 볼 생각이다.

'NestJS' 카테고리의 다른 글

NestJS: AWS S3 이미지 업로드 구현  (0) 2024.02.21
[NestJS] SSE를 활용한 알림 기능 구현  (0) 2024.01.15