Authentication 찍먹하기(3) - OAuth

SangHyeon Lee·2024년 9월 16일
0

시작하면서...

Authentication 찍먹하기 시리즈의 마지막이 될 OAuth이다.

Google의 OAuth를 사용한 경험을 기록한 것이기 때문에,
다른 기업의 것들과 세세한 부분에서 차이가 있을 수 있음을 알린다.

또한, OAuth로 얻은 사용자의 정보는 어플리케이션 서버가 갖기 때문에,
이를 이용해서 사용자에게 권한을 주고 로그인 시키기 위해서
JWT를 같이 사용하는 경우가 많다고 한다.

본 실습에서도 JWT를 같이 사용해 클라이언트에서 인증을 확인할 수 있도록 했다.

또한, 보다 현실적인 실습을 위해 엑세스 토큰과 리프레시 토큰을 사용했음을 참고하기 바란다.
권한과 관련해서 위의 토큰들을 db에 저장하지 않기 위해서도 JWT를 사용했다.

마지막으로, 실습에 사용한 OAuth버전은 2.0이다.

실습

모든 실습은 express를 통해 진행되었다.

이번에는 OAuth를 통해 얻은 사용자 정보를 DB에 저장하고,
이를 이용해서 토큰을 발행하는 방식으로 진행했기 때문에
MongoDB를 같이 사용하였다.

id token을 통해 유저 정보를 얻고,
access token을 통해 권한에 맞는 api를 사용하는 실습을 진행한다.
(권한을 규정하지는 않고 단순히 access token만을 사용했다.)

OAuth

기본 세팅

실습에 필요한 패키지들을 미리 설치하자.

node-fetch
dotenv
mongoose
jsonwebtoken
cookie-parser

우선 세상에서 가장 간단한 백엔드 코드부터 시작해보자.

import express from 'express';
import jwt from 'jsonwebtoken';
import dotenv from 'dotenv';
import cookieParser from 'cookie-parser';

dotenv.config();

const app = express();
app.use(express.json()); // oauth 사용을 위한 미들웨어. 데이터 통신에 사용.
app.use(cookieParser()); // 쿠키 작업을 위한 미들웨어

app.get('/', (req, res, next) => {
  console.log('와우');
});

OAuth 적용하기

⏹️ Google oauth로 넘어가기 전

코드 작성보다, Google API에서 OAuth관련 설정을 하는 것이 우선이다.
참조를 보고 설정을 마치고 client id, client secret를 .env파일에 저장하자.

편하게 사용하기 위해 google oauth url, access token url, token info url역시 .env파일에 저장했다.
이들은 다음과 같다.

GOOGLE_OAUTH_URL=https://accounts.google.com/o/oauth2/v2/auth
GOOGLE_ACCESS_TOKEN_URL=https://oauth2.googleapis.com/token
GOOGLE_TOKEN_INFO_URL=https://oauth2.googleapis.com/tokeninfo

또한, OAuth 인증을 통해 얻을 수 있는 유저 정보들을 지정해야 한다.

이들을 적용해서 코드를 작성하면 다음과 같다.

const GOOGLE_OAUTH_URL = process.env.GOOGLE_OAUTH_URL;
const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID;
const GOOGLE_OAUTH_SCOPES = [
  'https%3A//www.googleapis.com/auth/userinfo.email',
  'https%3A//www.googleapis.com/auth/userinfo.profile',
];

...

app.get('/', async (req, res, next) => {
  const state = 'some_state';
  const scope = GOOGLE_OAUTH_SCOPES.join(' ');
  const GOOGLE_OAUTH_CONTENT_SCREEN_URL = `${GOOGLE_OAUTH_URL}?client_id=${GOOGLE_CLIENT_ID}&redirect_uri=${GOOGLE_CALLBACK_URL}&access_type=offline&response_type=code&state=${state}&scope=${scope}`;
  res.redirect(GOOGLE_OAUTH_CONTENT_SCREEN_URL);
});

GOOGLE_OAUTH_CONTENT_SCREEN_URL을 통해
google oauth로그인 창이 있는 페이지로 리다이렉트 된다.

참고로, 이 페이지는 google api에서 oauth설정할 때 커스텀할 수 있다.

⏹️ Google oauth에서 넘어온 후

oauth인증을 완료하고 어플리케이션 페이지로 다시 돌아올 때,
유저의 정보를 얻을 api라우터를 다음과 같이 작성한다.

이 라우터를 통해 OAuth에 등록된 어플리케이션의 서버는 유저의 정보를 얻게 된다.
이 정보에는 id token, access token, refresh token등의 데이터가 들어있다.

const GOOGLE_CALLBACK_URL = 'http%3A//localhost:8000/google/callback';
const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET;
const GOOGLE_ACCESS_TOKEN_URL = process.env.GOOGLE_ACCESS_TOKEN_URL;

app.get('/google/callback', async (req, res) => {
  const { code, state } = req.query;
  
  const data = {
    code,
    client_id: GOOGLE_CLIENT_ID,
    client_secret: GOOGLE_CLIENT_SECRET,
    redirect_uri: 'http://localhost:8000/google/callback',
    grant_type: 'authorization_code',
  };
	
  // 구글에게 access_token관련 데이터를 요청하자.
  const response = await fetch(GOOGLE_ACCESS_TOKEN_URL, {
    method: 'POST',
    body: JSON.stringify(data),
  });

  const access_token_data = await response.json();
 
  const { id_token, refresh_token, access_token, expires_in } =
    access_token_data;
  console.log('id token: ', id_token);
  
  ...
});

이렇게 access token을 얻어 유저에게 권한을 부여해 api를 사용가능하게 허락한다.


⏹️ Id token을 활용해 유저의 정보를 얻어내기

하지만, 애플리케이션에서 유저의 정보가 필요한 경우들이 많다.
이를 위해 id token을 사용해 유저의 정보를 얻어내야 한다.

필요한 정보의 범위를 미리 지정했고, 받을 수 있는 정보들은 OAuth에서도 설정했다.

관련한 코드는 다음과 같다.

  ...
  // /google/callback 라우터 내부이다. 위 코드의 아랫 부분.
  const token_info_reponse = await fetch(
      `${process.env.GOOGLE_TOKEN_INFO_URL}?id_token=${id_token}`
    );
  const { email, name } = await token_info_reponse.json();

  ...

OAuth 활용하기

⏹️ 유저 정보 가져오고 DB연결하기

이제 유저의 정보를 통해 인증하거나 회원가입하는 동작을 만들어보자.

이를 위해선 어플리케이션이 DB를 통해 유저의 정보를 가지고 있어야 하므로
DB와의 연결이 필요할 것이다.

회원가입에 있어서는 간단하게 DB에 데이터를 생성하는 것으로 처리했다.

MongoDB관련 설정은 매우 간단하다.
IP허용을 해주고 사용할 곳의 URI를 가져와서 .env에 저장해주자.

코드는 아래와 같다.

/// MONGO DB 연결 및 스키마 설정
mongoose.connect(process.env.MONGO_URI);
const OAuthUserSchema = new mongoose.Schema({
  name: {
    type: String,
    unique: true,
    trim: true,
    require: [true, 'Please provide a name'],
    minlength: 3,
    maxlength: 56,
  },
  email: {
    type: String,
    match: [
      /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/,
      'Please provide a valid email.',
    ],
    unique: true,
  },
  password: {
    type: String,
    minlength: 6,
    required: false,
  },
});

const OAuthUser = mongoose.model('OAuthUser', OAuthUserSchema);

...

  ...
  // /google/callback 라우터 내부이다. 이전 코드의 아랫 부분.
  let user = await OAuthUser.findOne({ email }).select('-password');
  if (!user) {
    user = await OAuthUser.create({ email, name });
  }
  ...

⏹️ 인증 작업 하기

각종 토큰들을 얻어 냈으니,
jwt와 cookie를 이용해서 클라이언트에게 토큰을 전달하자.

먼저 로그인, 인증에 관련된 동작에 대해 실습해보자.

인증과 관련된 jwt생성 작업과 인증 라우터 코드는 다음과 같다.

...
// 유저 id로 토큰 만들기.
// 유저 정보를 사용하는 부분이라 스키마에 method를 연결해보았다.
OAuthUserSchema.methods.generateToken = (userId) => {
  const token = jwt.sign({ id: userId }, process.env.JWT_SECRET, {
    expiresIn: process.env.JWT_LIFETIME,
  });
  return token;
};
...

  ...
  // /google/callback 라우터 내부이다. 이전 코드의 아랫 부분.
  const token = user.generateToken(user._id);
  ...
  // cookie에 jwt토큰 넣어주기
  // 페이지 로딩 등 authentication에 사용
  res.cookie('idToken', token, { httpOnly: true }); 
  ...
  
// 인증 겸 유저 정보 요청을 위한 라우터
  app.get('/userLoad', (req, res, next) => {
    // 
    try {
      const idToken = req.cookies.idToken;
      if (!idToken) throw new Error('토큰이 없어유');
      if (jwt.verify(idToken, process.env.JWT_SECRET)) {
        res.json({ info: '유저정보' });
      } else {
        throw Error('토큰이 만료됨');
      }
    } catch (e) {
      res.status(400).json({ message: e.message });
    }
  });

⏹️ Access token으로 api 처리하기

이제 access token을 사용해서 권한이 부여된 유저가
api를 사용하는 상황에 대해 실습해보자.

...
const convertJwt = (token, expires_in) => {
  return expires_in
    ? jwt.sign({ token }, process.env.JWT_SECRET, {
        expiresIn: '1m',
      })
    : jwt.sign({ token }, process.env.JWT_SECRET, {
        expiresIn: '2m',
      });
};
...
  ...
  // /google/callback 라우터 내부이다. 이전 코드 부근.
  const accessToken = convertJwt(access_token, expires_in);
  ...
  
  // api사용하는 Authorization 사용 -- db나 서버에 저장하기 싫으니까 JWT로.
  res.cookie('accessToken', accessToken, {
    httpOnly: true,
    secure: true,
  }); 
  ...

...
// api처리하는 라우터.
app.get('/some-api', (req, res, next) => {
  try {
    const accessToken = req.cookies.accessToken;
    if (!accessToken) throw new Error('액세스 토큰이 없어용');
    if (jwt.verify(accessToken, process.env.JWT_SECRET)) {
      res.json({ data: 'api요청 결과' });
    } else {
      throw new Error('유효하지 않은 토큰');
    }
  } catch (e) {
    res.status(400).json({ message: e.message });
  }
});
...

⏹️ Refresh token 사용해보기

위의 부분만 알아도 충분하겠지만
refresh토큰을 사용하지 않아 찝찝했기에 사용방법을 알아내느라 시간을 조금 보냈다.

google oauth2.0에서는 token관련 fetch를 할 때,
refresh token관련된 속성들을 추가하면 된다고 한다.

밑의 코드는
refresh토큰에 대한 api안에 refresh token을 다시 발급받는 과정과
refresh token을 사용해서 access token을 새로 발급하는 과정을 모두 담고 있다.

사실, refresh token이 valid하면서 access token이 invalid한 경우와
둘 모두가 invalid한 경우를 나눠
전자에는 전달받은 refresh token을 사용해 access token을 새로 발급받는 작업을,
후저에는 refresh token을 google oauth로부터 새로 발급받고, access token도 새로 발급해주는 작업을 해야 함이 맞다.

실습 당시, refresh token을 갱신하는 방법을 알아내며 다른 일들에 치여 있었기에
맥락과 의도가 이상하지만, 과정을 연습한다는 측면에서만 봐주길 바란다.

...
  ...
  // /google/callback 라우터 내부이다. 이전 코드 부근.
  // api사용하는 Authorization 사용-- db나 서버에 저장하기 싫으니까 JWT로.
  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,
    secure: true,
  }); 


  // callback page 확인을 위해 메시지만 보냈다.
  res.send('구글 oauth callback url');
}
...

app.get('/refresh', async (req, res, next) => {
  // api사용에 쓰이는 Access token만료 시 refresh token으로 갱신.
  try {
    const refreshToken = req.cookies.refreshToken;
    console.log(jwt.decode(refreshToken));
    if (!refreshToken) throw new Error('리프레시 토큰이 없어용');
    if (jwt.verify(refreshToken, process.env.JWT_SECRET)) {
      // 밑의 작업은 refreshToken을 갱신하는 작업.
      // verify가 false일 때 하는 것이 원래는 맞다.
      const data = {
        client_id: GOOGLE_CLIENT_ID,
        client_secret: GOOGLE_CLIENT_SECRET,
        refresh_token: jwt.decode(refreshToken).token,
        grant_type: 'refresh_token',
      };
      const response = await fetch('https://oauth2.googleapis.com/token', {
        method: 'POST',
        body: JSON.stringify(data),
      });
	  
      // access token을 발급받는 동작.
      const access_token_data = await response.json();
      res.cookie(
        'accessToken',
        convertJwt(
          access_token_data.access_token,
          access_token_data.expires_in
        ),
        {
          httpOnly: true,
          secure: true,
        }
      );
    } else {
      throw new Error('유효하지 않은 토큰');
    }
  } catch (e) {
    res.status(400).json({ message: e.message });
  }
});

export default app;

후기

이번 회고는 많이 늦었다.
때문에 생생함이 많이 없다는 점이 아쉽다.

그래도 Authenticaion 시리즈는 마무리할 수 있어서 기쁘다.

다음에는 회고 작성을 뒤로 미루게 해준 여러 원인들 중 하나인
'MERN 스택'강좌 공부의 일부를 작성하고자 한다.

Regular하지는 못해도 Continuous한 회고록이 되도록 해보자.

참조

OAuth 2.0 implementation in Node.js
[oAuth2] Node Express로 google oAuth2 사용하기[2. 코드 작성]
[ORM] 📚 Mongoose 사용법 정리 (Node.js - MongoDB)

profile
회고할 가치가 있는 개발을 하자

0개의 댓글

관련 채용 정보