OAuth API 구현하기

silver·2025년 5월 8일

백엔드

목록 보기
1/3
post-thumbnail

OAuth란 로그인/회원가입과 같은 권한 인증 작업을 구글,카카오,네이버 등과 같은 신뢰할 수 있는 대형 서비스가 대행해주는 시스템이다. 구글이나 네이버와 같이 OAuth 서비스에 로그인이 되어있는 상태라면 사용자가 직접 아이디,비밀번호 등을 입력하지 않고 로그인/회원가입을 할 수 있게 해준다.

OAuth 절차

사용자가 OAuth 로그인 버튼을 클릭
-> 프론트엔드에서 백엔드에 OAuth url을 요청
-> 백엔드가 OAuth url로 리다이렉트
-> 사용자가 OAuth 서비스 페이지에서 로그인
-> 백엔드의 callback API를 호출
-> 백엔드가 OAuth에 AccessToken을 요청
-> OAuth가 accessToken을 응답
-> AccessToken을 사용해서 백엔드가 OAuth에 회원 정보를 요청
-> OAuth가 회원 정보를 응답
-> 백엔드에서 회원 정보를 가지고 회원가입 또는 로그인 처리(토큰을 발행하고 로그인 성공 후 이동할 페이지로 리다이렉트)

구현

Passport

Passport라는 라이브러리를 사용하면 위 절차에서 회원 정보를 받아오는 부분까지 처리해준다. 하지만 나는 전체 프로세스를 이해하기 위해 처음엔 Passport없이 구현해본 다음 Passport를 적용했다.

OAuth url 리다이렉트 및 OAuth 서비스에 로그인

먼저 사용자를 OAuth 로그인 Url로 리다이렉트 시켜야 한다.
OAuth 로그인 Url에는 각각 OAuth client id와 사용자가 로그인 한 다음 호출할 Callback API의 엔드포인트,state가 쿼리 파라미터로 입력되어야 한다. state에 대해선 밑에서 설명할 예정이다.

AuthService


export default class AuthService {
  private SNS_AUTH_URLS = {
    google: `https://accounts.google.com/o/oauth2/auth?client_id=${process.env.GOOGLE_CLIENT_ID}&redirect_uri=${process.env.GOOGLE_REDIRECT_URI}&response_type=code&scope=${this.scopes}&access_type=offline&prompt=consent`,
    kakao: `https://kauth.kakao.com/oauth/authorize?client_id=${process.env.KAKAO_CLIENT_ID}&redirect_uri=${process.env.KAKAO_REDIRECT_URI}&response_type=code`,
    naver: `https://nid.naver.com/oauth2.0/authorize?client_id=${process.env.NAVER_CLIENT_ID}&redirect_uri=${process.env.NAVER_REDIRECT_URI}&response_type=code`,
  };
  
  constructor(private userRepository:UserRepository){}
	
  getSnsLoginUrl(provider: keyof typeof this.SNS_AUTH_URLS, state: string) {
    if (!this.SNS_AUTH_URLS[provider]) {
      throw new Error('지원하지 않는 로그인 제공자입니다.');
    }
    
	return this.SNS_AUTH_URLS[provider]+`&state=${state}`;
  };
  
generateState(data: { userType: string }) {
    const stateObj = {
      userType: data.userType,
    };
    const token = jwt.sign(stateObj, process.env.OAUTH_STATE_SECRET!, {
      expiresIn: '5m',
    });
    return encodeURIComponent(token);
  }

  decodeState(state: string) {
    const decoded = jwt.verify(
      decodeURIComponent(state),
      process.env.OAUTH_STATE_SECRET!,
    ) as JwtPayload;
    return {
      userType: decoded.userType,
    };
  }
}

AuthController


export default class AuthController {
	constructor(private authService:AuthService){}
    
  	  snsLogin = async (req: Request, res: Response) => {
    const { provider } = req.params; // 로그인 제공자 (google, kakao, naver)
    const { userType } = req.query;
    if (!userType || (userType !== 'customer' && userType !== 'mover')) {
      return res.status(400).json({ message: '유효하지 않은 사용자 타입입니다.' });
    }

    const state = this.authService.generateState({ userType: userType as string });
    const loginUrl = this.authService.getSnsLoginUrl(provider as OauthTypes, state);
    return res.redirect(loginUrl);
  };
}

Callback API로 리다이렉트 & AccessToken 요청 및 응답

OAuth 페이지에서 로그인을 성공했다면 Url에 포함된 redirectUri(callback API)로 이동(호출)하게 되는데 이때 Code와 앞에서 OAuth login url에 포함시켰던 State가 쿼리 파라미터로 입력된다.

code

여기서 Code는 OAuth 서비스에게 사용자의 AccessToken을 요청할 때 사용된다.

state

Callback API URI의 쿼리 파라미터에 state가 포함되는데 이 state는 CSRF 공격을 막기 위한 목적으로 사용하는 것이기 때문에 암호화하거나 해당 서버에서 발급한 것인지 여부를 확인할 수 있어야 한다. 나는 JWT를 사용했다.

*CSRF는 쿠키가 자동으로 전송되는 시스템을 악용하는 공격이기 때문에 만약 쿠키를 사용하지 않고 요청 header에 포함시키는 방식을 쓰고 있다면 걱정하지 않아도 된다.

참고 : CSRF(Cross-Site Request Forgery)공격과 방어

Callback API

먼저 state를 검증한 다음 이상이 없다면 clientId,clientSecret,code 등 필요한 값들을 쿼리 파라미터에 추가해서 accessToken을 요청한다.

액세스 토큰 주의사항

OAuth 프로바이더가 제공하는 AccessToken은 OAuth API를 호출하는 용도이다. 하지만 이를 서비스에서 프론트와 백엔드 간에 인증을 위해 사용하는 경우가 많다고 한다.

하지만 이는 잘못된 방식이다. 해당 토큰은 내 서버에서 발행한 것이 아니기 때문에 토큰을 검증할 때 JWT Secret이 일치하지 않아 검증에 실패하기 때문이다.

만약 해당 AccessToken을 사용해서 인증을 하고 있었다면 JWT 검증이 제대로 이루어지지 않고 있었다는 뜻으로 토큰 검증 과정을 확인해봐야 한다.

회원 정보 요청 및 응답 / 로그인,회원가입 처리

앞에서 전달받은 AccessToken을 header의 Authorization에 포함시켜서 OAuth provider에게 회원 정보를 요청한다. 그리고 전달 받은 회원 정보를 통해 회원가입 또는 로그인을 처리한다. 나는 email을 id로 사용했기 때문에 전달 받은 email로 DB를 조회했다.

조회했을 때 나올 수 있는 결과는 다음과 같다.
1.해당 OAuth로 이미 가입된 경우
=>해당 회원의 정보를 바탕으로 토큰을 발행하고 응답하면 끝이난다.
2.동일한 email로 OAuth가 아닌 해당 앱에 직접 가입한 경우
=>DB의 소셜 로그인 테이블에 해당 소셜 로그인 정보를 추가한다.
3.OAuth,직접 가입 모두 하지 않은 경우
=>해당 이메일로 회원을 추가한다.

OAuth 관련 기능은 아니기 때문에 회원을 생성하거나 찾는 예시 코드는 포함하지 않았다.
Service 레이어 코드는 handleNaverCallback만 포함했다. 구글,카카오의 경우도 비슷한 방식으로 정보를 요청하면 된다.

AuthService.handleNaverCallback


async handleNaverCallback(code: string, state:string, provider: string, type: LowercaseUserType): Promise<any> {
    try {
      // 네이버 토큰 요청
      const tokenResponse = await axios.post(
        'https://nid.naver.com/oauth2.0/token',
        new URLSearchParams({
          grant_type: 'authorization_code',
          client_id: process.env.NAVER_CLIENT_ID as string,
          client_secret: process.env.NAVER_CLIENT_SECRET as string,
          code,
        }).toString(),
        {
          headers: {
            'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8',
          },
        },
      );

      const { access_token } = tokenResponse.data;

      const userInfoResponse = await axios.get('https://openapi.naver.com/v1/nid/me', {
        headers: {
          Authorization: `Bearer ${access_token}`,
        },
      });

      const naverUserInfo = userInfoResponse.data.response;
      const userInfo = {
        email: naverUserInfo.email,
        phoneNumber: naverUserInfo.mobile,
        name: naverUserInfo.name,
        provider,
        providerId: naverUserInfo.id,
      };
// findOrCreateUser 메서드는 액세스 토큰과 리프레쉬 토큰, 사용자 정보를 리턴한다.
      const response = await this.findOrCreateUser(userInfo, type);
      return response;
    } catch (error: any) {
      console.error('Naver OAuth 오류', error);
      if (axios.isAxiosError(error) && error.response) {
        console.error('네이버 응답 오류:', error.response.data);
      }
      if (error.message === 'wrong type') throw error;
      throw new Error('Naver 로그인 중 오류가 발생했습니다.');
    }
  }

AuthController.oAuthCallback


oAuthCallback = async (req: Request, res: Response) => {
    const { provider } = req.params;
    const { code, state } = req.query;

    if (!code || typeof code !== 'string') {
      return res.status(400).json({ message: '인증 코드가 없습니다.' });
    }

    let userType: LowercaseUserType = 'customer';

      if (state && typeof state === 'string') {
      try {
        const decodedState = this.authService.decodeState(state);
        userType = decodedState.userType as LowercaseUserType;
      } catch (error) {
        console.error('상태 정보 디코딩 실패:', error);
        return res.status(400).json({ message: '유효하지 않은 상태 정보입니다.' });
      }
    }

    try {
      let token;

      switch (provider) {
        case 'naver': {
          const naverResult = await this.authService.handleNaverCallback(
            code,
            state,
            provider,
            userType
          );
          token = naverResult.tokens;
          break;
        }
        // ... 그 외 OAuth callback을 처리
        default:
          return res.status(400).json({ message: '지원하지 않는 로그인 제공자입니다.' });
      }
      const { accessToken, refreshToken } = token;

      this.setAccessToken(res, accessToken);
      this.setRefreshToken(res, refreshToken);
      res.redirect(this.REDIRECT_URL_ON_SUCCESS[userType]);
    } catch (error: any) {
      if (error.message === 'wrong type')
        return res.redirect(`${this.REDIRECT_URL_ON_FAIL[userType]}${this.FAIL_QUERY[userType]}`);
      return res.status(500).json({ message: '로그인 중 오류 발생' });
    }
  };

Passport 적용

앞서서 설명한 것처럼 Passport 라이브러리를 사용하면 로그인 URL로 리다이렉트하는 작업부터 사용자의 정보를 받아오는 부분까지 대신 처리해준다.

passport 관련 라이브러리 설치
=>app.ts에 passport 초기화
=>각 OAuth 별 Strategy 설정
=>OAuth API에 passport 적용
=>OAuth Callback API에 passport 적용

관련 라이브러리 설치

npm install passport passport-google-oauth20 passport-naver passport-kakao

타입스크립트를 쓰고 있다면 @types도 설치해줘야 한다.

npm install --D @types/passport @types/passport-google-oauth20 @types/passport-naver @types/passport-kakao

passport 초기화 & OAuth Strategy 설정

passport 초기화는 서버 애플리케이션을 초기화하는 파일(ex:app.ts)에서 초기화 해준다.

그리고 각 OAuth별로 Strategy를 설정해준다. Strategy의 첫번째 인자로는 OAuth 인증에 필요한 변수들을 객체 형태로 입력해준다. 그리고 두번째 인자에는 passport가 전달해주는 데이터들을 처리하는 콜백함수를 입력한다.

(accessToken,refreshToken,profile,done)이 파라미터로 주어진다.
앞에 두 토큰은 OAuth의 api를 호출할 때 사용할 토큰이다.
그리고 Profile은 사용자의 정보가 담겨있다.
done은 passport.authenticate에 값을 넘길 때 사용한다.

이미 사용자의 프로필을 요청해서 passport가 가져오지만 여기에 빠진 정보가 있을 수 있기 때문에 전달받은 토큰을 활용해서 OAuth에 정보를 요청한 다음 done을 통해 전달하면 된다.
done에는 첫번째로 error를 두번째로 사용자 정보를 입력한다.

app.ts

const app = express();

app.use(passport.initialize());
setupNaverStrategy();
// 그 외 strategy도 설정

import axios from 'axios';
import passport from 'passport';
import { Strategy as NaverStrategy } from 'passport-naver';

export const setupNaverStrategy = () => {
  passport.use(
    'naver',
    new NaverStrategy(
      {
        clientID: process.env.NAVER_CLIENT_ID!,
        clientSecret: process.env.NAVER_CLIENT_SECRET!,
        callbackURL: process.env.NAVER_REDIRECT_URI!,
      },
      async (accessToken, refreshToken, profile, done) => {
        try {
          const response = await axios.get('https://openapi.naver.com/v1/nid/me', {
            headers: {
              Authorization: `Bearer ${accessToken}`,
            },
          });
          const naverProfile = response.data.response;

          const userInfo = {
            provider: 'naver',
            providerId: naverProfile.id,
            email: naverProfile.email,
            name: naverProfile.name,
            phoneNumber: naverProfile.mobile,
          };

          return done(null, userInfo);
        } catch (error) {
          return done(error);
        }
      },
    ),
  );
};

OAuth API에 passport 적용

passport.authenticate에 첫번째 인자로 provider('kakao','naver','google' 등)을 입력하고 두번째 인자로 URL에 포함시킬 쿼리파라미터를 입력한다. 그러면 미들웨어를 반환하는데 뒤에 (req,res,next)를 입력해서 해당 미들웨어를 실행시킨다.


  snsLogin = async (req: Request, res: Response, next?: NextFunction) => {
    const { provider } = req.params; // 로그인 제공자 (google, kakao, naver)
    const { userType } = req.query;
    if (!userType || (userType !== 'customer' && userType !== 'mover')) {
      return res.status(400).json({ message: '유효하지 않은 사용자 타입입니다.' });
    }

    const state = this.authService.generateState({ userType: userType as string });
    const scope =
      provider === 'google' ? [this.GOOGLE_SCOPE, 'email', 'profile'] : ['email', 'profile'];

    passport.authenticate(provider, { state, scope })(req, res, next);
  };

Callback API에 passport 적용

Callback API에서 passport.authenticate를 통해 Strategy에서 전달받은 데이터를 사용해 처리한다.

이 과정에서 수정한 점이 있었는데 만약 중간에 문제가 발생했을 때 res.status.json으로 응답을 하면 현재 서버의 Callback API로 리다이렉트된 상태이기 때문에 사용자가 raw JSON 텍스트를 보게 된다.
그렇기 때문에 json으로 응답하는 것이 아니라 프론트엔드의 URL로 리다이렉트 시켜야 한다.
프론트엔드에서 쿼리 파라미터를 통해 에러 메시지를 표시하는 기능을 구현해놨기 때문에 쿼리 파라미터를 적용해서 리다이렉트 시키도록 설정했다.

Callback API,OAuth 최초 호출 구분

Callback API와 OAuth를 최초 호출할 때 모두 passport.authenticate를 사용하는데 passport는 이 두가지를 어떻게 구분할까?
OAuth 인증 과정에서 최초 사용자가 로그인을 하면 콜백 API에 쿼리 파라미터로 code가 입력되기 때문에
passport는 query파라미터에 code의 존재 여부를 통해 최초 OAuth 호출인지 Callback API 호출인지 구분한다.


oAuthCallback = async (req: Request, res: Response, next?: NextFunction) => {
    const { provider } = req.params;
    const { state } = req.query;

    let userType: LowercaseUserType = 'customer';

    if (!state) {
      return this.redirectToError(userType, this.FAIL_QUERY.invalidRequest, res);
    }

    if (typeof state === 'string') {
      try {
        const decodedState = this.authService.decodeState(state);
        userType = decodedState.userType as LowercaseUserType;
      } catch (error) {
        console.error('상태 정보 디코딩 실패:', error);
        return this.redirectToError(userType, this.FAIL_QUERY.invalidRequest, res);
      }
    }

    passport.authenticate(provider, { session: false }, async (error: any, userInfo: any) => {
      const oppositeUserType = userType === 'customer' ? 'mover' : 'customer';
      if (error || !userInfo) {
        return this.redirectToError(userType, this.FAIL_QUERY.common, res);
      }
      try {
        const response = await this.authService.findOrCreateUser(userInfo, userType);
        if (!response) return this.redirectToError(userType, this.FAIL_QUERY.common, res);
        const { accessToken, refreshToken } = response.tokens;

        this.setAccessToken(res, accessToken);
        this.setRefreshToken(res, refreshToken);
        return res.redirect(this.REDIRECT_URL_ON_SUCCESS[userType]);
      } catch (error: any) {
        if (error.message === 'wrong type') {
          return this.redirectToError(oppositeUserType, this.FAIL_QUERY[userType], res);
        }
        return this.redirectToError(userType, this.FAIL_QUERY.common, res);
      }
    })(req, res, next);
  };

0개의 댓글