TIL 22-05-17

thisisyjin·2022년 5월 17일
0

TIL 📝

목록 보기
52/113

React.js

Today I Learned ... react.js

🙋‍♂️ Reference Book

🙋‍ My Dev Blog


CH 23. JWT를 통한 회원 인증 시스템 구현

  • JWT (JSON Web Token)
  • posts API에 회원인증 시스템 구현

JWT

= JSON Web Token.

  • 데이터가 JSON으로 이루어져 있는 토큰.
  • 두 개체가 안전하게 정보를 주고받을 수 있도록 웹 표준으로 정의된 기술.
  • 사용자 로그인 상태를 처리하는 두가지 인증 방식이 존재함.

세션기반 인증

  • 서버가 사용자가 로그인중임을 기억하고 있음.
  • 세션 저장소에 사용자의 정보를 조회하고, 세션 id를 발급.
    -> 주로 브라우저의 쿠키에 저장함.
  • 사용자가 다른 요청을 할 때마다 서버는 세션을 조회한 후, 로그인 여부를 결정하여 응답함.
  • 👎 단점 - 서버 확장이 번거로움. 서버의 인스턴스가 여러개가 된다면, 세션 전용 데이터베이스를 만들어야 함.

토큰기반 인증

  • 토큰 = 로그인 이후 서버가 만들어주는 문자열

  • 토큰 안에는 사용자의 로그인 정보가 들어있고, 해당 정보가 서버에서 발급되었다는 서명이 들어있음.

  • 서명 데이터는 해싱(Hashing) 알고리즘을 통해 만들어짐.

    🙋‍♂️ 해싱 함수

    임의의 길이의 데이터를 고정된 길이의 데이터로 매핑하는 함수이다.
    해시 함수에 의해 얻어지는 값은 해시 값, 해시 코드, 해시 체크섬 또는 간단하게 해시라고 한다.

  • 서버에서 만들어준 토큰은 서명이 있끼 때문에 무결성이 보장됨. (정보가 변경 or 위조되지 않음)
  • 사용자가 로그인을 하면 서버에서 토큰을 발급해주고,
  • 사용자가 다른 요청을 할때 발급받은 토큰과 함께 요청하게 됨.
  • 서버는 해당 토큰이 유효한지 검사하고, 응답함.
  • 👍 장점 - 서버에서 로그인 정보를 기억하기 위해 사용하는 리소스가 적다.
    -> 사용자가 로그인 정보가 담긴 토큰을 지니고 있으므로.
  • 서버의 확장성이 매우 높다.
    -> 서버의 인스턴스가 늘어나도 서버끼리 사용자 로그인 상태를 공유하지 X.
  • 인증 시스템 구현 간편

JWT 인증 구현

User 스키마/모델 생성

  • 사용자 정보를 MongoDB에 담고 조회
  • 스키마로는 계정명 / 비밀번호가 필요.
  • 비밀번호의 경우에는 보안상 중요하므로,
    단방향 해싱 함수를 지원해주는 bcrypt 라이브러리로 안전하게 저장.

단, 비밀번호는 mongoDB에 저장되기 전에 (save함수로 저장) 해싱 처리가 되어야 함.

/src/models/user.js 생성

import mongoose, { Schema } from 'mongoose';

// 스키마 생성
const UserSchema = new Schema({
  username: String,
  hashedPassword: String,
});

// 모델 생성
const User = mongoose.model('User', UserSchema);
export default User;

bcrypt 라이브러리 설치

$ yarn add bcrypt

모델 메서드 생성

🙋‍♀️ 모델 메서드란?

  • 모델에서 사용할 수 있는 함수.
  • 1) 인스턴스 메서드 = 모델을 통해 만든 문서 인스턴스에서 사용할 수 있는 함수
const user = new User({username: 'yjin'});
user.setPassword('1234');   // user은 문서 인스턴스 = new Model({doc})
  • 2) 스태틱 메서드 = 모델에서 바로 사용 가능한 함수
const user = User.findByUsername('yjin');  

1. 인스턴스 메서드 생성

models/user.js 수정

import mongoose, { Schema } from 'mongoose';
import bcrypt from 'bcrypt';

// 스키마 생성
const UserSchema = new Schema({
  username: String,
  hashedPassword: String,
});

// 🔻 인스턴스 메서드 작성 시 화살표 함수는 ❌ (this 바인딩 때문)
UserSchema.methods.setPassword = async function (password) {
  const hash = await bcrypt.hash(password, 10);
  this.hashedPassword = hash;  // 여기서 this는 문서 인스턴스를 가리킴.
};

UserSchema.methods.checkPassword = async function (password) {
  const result = await bcrypt.compare(password, this.hashedPassword);
  return result;
};

// 모델 생성
const User = mongoose.model('User', UserSchema);
export default User;

setPassword

  • 비밀번호를 매개변수로 받아서 계정의 hashedPassword 값을 설정해줌.

checkPassword

  • 매개변수로 받은 비밀번호가 해당 계정의 비밀번호와 일치하는지 검증

-> 둘다 async/await 함수로 작성해줌.


✅ bcrypt의 hash() 함수와 compare함수

  • hash(플레인 텍스트, salt)
    -> 숫자 10은 salt로, 높을 수록 암호화 연산이 증가. (하지만 암호화하는데 속도가 느려짐)

  • compare(플레인 텍스트, 해시값)

2. 스태틱 메서드 생성

  • findByUsername 메서드
    -> username 으로 특정 데이터를 찾게 해줌.

models/user.js 수정

UserSchema.statics.findByUsername = function (username) {
  return this.findOne({ username }); // 👈 username: username인 문서를 찾기.
};

-> 스태틱 함수에서의 this는 모델을 가리킴. (=User)

📌 Model.findOne()

  • 조건을 만족하는 문서를 찾아줌.
  • 만약 문서의 특정 필드만 보고싶다면 두번째 인자로 넣어줌.
  • 공식 매뉴얼 참고

회원 인증 API

새로운 라우트 정의

  1. src/api/auth 디렉터리를 생성하고, auth.ctrl.js 파일을 먼저 만든다.
    -> 컨트롤러 파일이므로, 라우트 처리함수(=미들웨어)를 모아놓은 파일임.

auth.ctrl.js

export const register = async (ctx) => {
  // 회원 가입 (등록)
};

export const login = async (ctx) => {
  // 로그인
};

export const check = async (ctx) => {
  // 로그인 상태 확인
};

export const logout = async (ctx) => {
  // 로그아웃
};
  1. src/api/auth 디렉터리에 index.js를 생성해준다.
    -> auth 라우터 생성.
import Router from 'koa-router';
import * as authCtrl from './auth.ctrl';

const auth = new Router();

auth.post('/register', authCtrl.register);
auth.post('/login', authCtrl.login);
auth.get('/check', authCtrl.check);
auth.post('/logout', authCtrl.logout);

export default auth;
  • 회원가입, 로그인, 로그아웃은 모두 보안상 중요하므로 POST 메서드를 사용해야 한다.
    -> GET 방식은 uri의 쿼리스트링으로 요청이 다 보이므로 안됨.
  1. src/api/index.js 에서
    api 라우터에 auth 라우터를 불러와 적용한다.
import Router from 'koa-router';
import posts from './posts';
import auth from './auth/index';

const api = new Router();

api.use('/posts', posts.routes());
api.use('/auth', auth.routes());

export default api;

1. 회원가입 구현

src/api/auth/auth.ctrl.js 수정
-> register API

// 1. 회원 가입 (등록)
export const register = async (ctx) => {
  // request body 검증
  const schema = Joi.object().keys({
    username: Joi.string().alphanum().min(3).max(20).required(),
    password: Joi.string().required(),
  });

  const result = schema.validate(ctx.request.body);

  if (result.error) {
    ctx.status = 400;
    ctx.body = result.error;
    return;
  }

  const { username, password } = ctx.request.body;
  try {
    // username 이미 존재하는지 체크
    const exists = await User.findByUsername(username);
    if (exists) {
      ctx.status = 409; // conflict
      return;
    }

    const user = new User({
      username,
    });
    await user.setPassword(password);
    await user.save(); // DB에 저장

    const data = user.toJSON();
    delete data.hashedPassword;

    ctx.body = data;
  } catch (e) {
    ctx.throw(500, e);
  }
};
  1. Joi 라이브러리로 검증
    -> schema 객체 생성 (데이터타입 유효성을 담은 객체)
    -> schema.validate(ctx.request.body)로 검사함
    -> result.error 필드가 존재하면 에러가 있는 것. -> 400 에러 발생시킴

  2. username 이미 존재하는지 검사
    -> User 모델의 스태틱 메서드인 findByUsername이 true면 이미 존재하는 아이디임.
    -> 409 에러 발생시킴

  3. user 문서 인스턴스 생성
    -> new Model({문서}) 해서 문서 인스턴스인 user 만듬.
    -> user.setPassword로 인스턴스 메서드 사용.
    -> this.hashedPassword를 설정하는 메서드임. (해시처리해서)

  4. DB에 등록 - user.save()

  5. ctx.body에 응답할 데이터에서 비밀번호는 제외해야 함.
    -> user 객체를 수정하려면 user.toJSON()을 해서 JSON 으로 바꾼 뒤에 수정해야함.
    -> 객체의 특정 필드를 제거하려면 delte 키워드를 사용함.

-> 추후에도 사용해야 하니, models/user.js에 인스턴스 메서드로 추가해주자.

UserSchema.methods.serialize = function () {
  const data = this.toJSON();
  delete data.hashedPassword;
  return data;
};
  1. ctx.body = data;를 해서 응답함
  • 마지막으로, postman으로 테스트를 해보자.

http://localhost:4000/api/auth/register 에 POST 요청을 한다.
request body는 위와 같이 username과 password를 입력한다.

-> 반드시 response body에는 password가 제외되어 있어야 함.

  • mongoDB compass

    -> 'blog' DB에 users 컬렉션이 추가되었다.

+) 만약, 같은 username으로 한번 더 register(POST)을 요청하면 409 에러가 발생한다.


2. 로그인 구현

auth.ctrl.js 수정
-> login API

// 2. 로그인
export const login = async (ctx) => {
  const { username, password } = ctx.request.body;

  // username, password 없으면 에러
  if (!username || !password) {
    ctx.status = 401; // Unauthorized
    return;
  }

  try {
    const user = await User.findByUsername(username);
    // username이 존재하지 않으면 에러
    if (!user) {
      ctx.status = 401;
      return;
    }
    const valid = await user.checkPassword(password);
    // password 불일치시 에러
    if (!valid) {
      ctx.staus = 401;
      return;
    }
    ctx.body = user.serialize();
  } catch (e) {
    ctx.throw(500, e);
  }
};

serialize() 메서드

  • 표준 URL 인코딩 표기법으로 텍스트 문자열을 생성함.
  1. username, password값이 없으면 에러처리

  2. 스태틱 메서드인 findByUsername을 통해 사용자 데이터를 찾음
    -> 만약 없으면 에러처리

  3. 인스턴스 메서드인 checkPassword로 비밀번호 일치 여부 확인
    -> bcrypt.compare로 플레인 텍스트(=password)와 해시값(=hashedPassword)을 비교
    -> 일치하면 true 반환
    -> 아닌경우 401 에러 발생

  4. user.serialize()를 한 후 응답함.
    -> hashedPassword 필드를 제외시킴.

  • postman 에서 로그인 구현해보기.

-> http://localhost:4000/api/auth/login POST 요청.
-> request body는 위와 같이 작성.

-> 만약 틀린 비밀번호라면 위와 같이 401 에러 발생.


3. 토큰 발급 및 인증

  • 클라이언트 측에서 로그인 정보를 지닐 수 있도록 서버에서 토큰을 발급해줌.
  • JWT 토큰을 만들기 위해서는 jsonwebtoken 모듈이 필요.
$ yarn add jsonwebtoken

비밀키 설정

  • .env 파일을 열어서 JWT 토큰 생성시 사용할 비밀키를 만듬.
  • 문자열로 아무거나 입력하면 됨.

TIP - 터미널에 다음 명령어를 치면 랜덤 문자열을 만들어줌.

$ openssl rand -hex 64

랜덤 값을 복사해 .env 파일에서 JWT_SECRET 값으로 설정.

비밀키는 외부에 공개되면 누구든지 맘대로 JWT 토큰을 발급할 수 있어 위험함.

토큰 발급

  • /models/user.js 에서 generateToken 이라는 인스턴스 메서드 생성.
UserSchema.methods.generateToken = function () {
  const token = jwt.sign(
    {
      _id: this.id,
      username: this.username,
    },
    process.env.JWT_SECRET,
    {
      expiresIn: '7d',
    },
  );
  return token;
};
  • generateToken 메서드로 로그인 성공시 사용자에게 토큰을 발급해줌.

✅ 참고 - jwt(jsonwebtoken)의 함수

  • jwt.sign = 토큰 발급
jwt.sign(payload, secretKey, options);
  • jwt.verify = 토큰 인증(확인)
jwt.verify(token, secretKey);

🔺 사용자가 토큰을 사용하는 방법

  1. 브라우저의 localStorage나 sessionStorage 를 이용함.
    -> 편리하고 구현도 쉽지만, XSS (크로스 사이트 스크립팅)에 취약함.
  1. 쿠키에 담아서 사용함.
  • httpOnly 속성 사용시 악성 스크립트로부터는 안전하지만, CSRF 라는 공격에 취약해짐.
    -> 그러나 CSRF 토큰 사용 및 Referer 검증 등의 방식으로 제대로 막을 수 있음.

auth.ctrl.js 수정
-> register, login API

const token = user.generateToken();
    ctx.cookies.set('access_token', token, {
      maxAge: 1000 * 60 * 60 * 24 * 7, // 7d
      httpOnly: true,
    });

-> ctx.body = user.serialize() 아래줄에 적어줌.
(즉, try문의 최하단에 적어줌)

cookies.set() 메서드

  • mdn문서
  • 첫번째 인자로는 쿠키명(=이름)이 들어가고
  • 두번째 인자로는 넣어줄 데이터가 들어가고
    (여기서는 user.generateToken(), 즉 jwt.sign()으로 만들어진 토큰이 들어감)
  • 세번째 인자로는 details들이 들어감. (maxAge는 최대 유효기간)
  • postman을 통해 로그인 요청 후 response 헤더에 Set-cookie 라는 헤더가 보임.

+) 좌측 Cookies 탭 에서 access_token 이라는 이름의 쿠키가 생성된 것을 알 수 있음.

토큰 검증하기

  • 사용자의 토큰을 확인 한 후, 검증하는 작업.
  • 미들웨어를 통해 처리함.
  1. src/lib 디렉터리를 만들고, jwtMiddleware.js 파일을 생성.

jwtMiddleware.js

import jwt from 'jsonwebtoken';

const jwtMiddleware = (ctx, next) => {
  const token = ctx.cookies.get('access_token');
  if (!token) return next(); // 토큰이 없을 때
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    console.log(decoded);
    return next();
  } catch (e) {
    return next();
  }
};

export default jwtMiddleware;
  1. main.js 수정 - 미들웨어 적용
  • 위에서 만든 jwtMiddleware를 적용.
import jwtMiddleware from './lib/jwtMiddleware';

  ...

// 라우터 적용 전에 미들웨어를 적용해야 함
app.use(bodyParser());
// 🔻 추가
app.use(jwtMiddleware);

  ...

  1. Postman에서 /api/auth/check 경로에 GET 요청을 함.

iat : 이 토큰이 언제 생성되었는지.
exp: 이 토큰이 언제 만료되는지.

  • 아직 check API 구현을 안해서 404 에러가 뜸.
  • 그대신 아래와 같이 console.log(decoded) 에 의해 jwt.verify()의 결과가 출력됨.
    -> 이 값을 이후 미들웨어에서 사용할 수 있도록 ctx.state에 넣어주면 됨.

🔺 참고 - jwt.verify

jwt.verify(token, secretKey);
  • 참고로 Joi 라이브러리의 validate 함수(=검증) 과는 다르니 잘 구분하자.

  1. ctx.state에 넣어줌
    jwtMiddleware.js 수정
import jwt from 'jsonwebtoken';

const jwtMiddleware = (ctx, next) => {
  const token = ctx.cookies.get('access_token');
  if (!token) return next(); // 토큰이 없을 때
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    
    // 🔻 추가
    ctx.state.user = {
      _id: decoded._id,
      username: decoded.username,
    };
    
    console.log(decoded);
    return next();
  } catch (e) {
    return next();
  }
};

export default jwtMiddleware;

  1. auth.ctrl.jscheck API 구현
// 3. 로그인 상태 확인
export const check = async (ctx) => {
  const { user } = ctx.state;
  // 로그인중이 아닐 때
  if (!user) {
    ctx.status = 401;
    return;
  }
  ctx.body = user;
};
  1. 마지막으로, postman으로 GET 요청을 해보자.

토큰 재발급

  • jwtMiddleware에서 jwt.verify()된 값, 즉 decoded에서 exp는 만료일을 알려주는 값이였다.

  • 만약 exp가 3.5일 미만이라면, 토큰을 새롭게 재발급해주는 기능을 구현해보자.

jwtMiddleware.js 수정

import jwt from 'jsonwebtoken';
import User from '../models/user';

const jwtMiddleware = async (ctx, next) => {
  const token = ctx.cookies.get('access_token');
  if (!token) return next(); // 토큰이 없을 때
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    ctx.state.user = {
      _id: decoded._id,
      username: decoded.username,
    };
	// 🔻 추가 - exp가 3.5일 미만이면 토큰 새로 발급
    const now = Math.floor(Date.now() / 1000);
    if (decoded.exp - now < 60 * 60 * 24 * 3.5) {
      const user = await User.findById(decoded._id);
      const token = user.generateToken(); // 토큰 새로 발급
      ctx.cookies.set('access_token', token, {
        maxAge: 1000 * 60 * 60 * 24 * 7, // 7d
        httpOnly: true,
      });
    }
    return next();
  } catch (e) {
    return next();
  }
};

export default jwtMiddleware;
  • 우선, async/await 함수로 바꿔줘야 한다.
  • User(모델)을 가져와서 스태틱 메서드인 findById와 인스턴스 메서드인 generateToken을 사용함.

❗️ 왜 Math.floor(Date.now() / 1000)를 했나?

  • ms가 아닌 초단위로 보기 위해
    -> exp: 1653366898 와 같이 sec 단위임! 단위 통일시켜줘야함.
  • 참고로, 로그인시 (auth.ctrl.js의 login API) 토큰을 처음 발급함.
  • 그 이후에는 이제 미들웨어에서 exp가 3.5일 미만일때 새로 토큰을 발급해주는 것.

4. 로그아웃 기능 구현

  • 로그아웃시, 쿠키를 지워주기만 하면 됨.
  • ctx.cookies.set('access-token')만 해주면 됨.
    -> 두번째 인자에 원래 값을 적어주면 쿠키 생성해주고,
    위처럼 두번째 인자를 안주면 해당 이름을 가진 쿠키를 삭제해줌.

auth.ctrl.js 수정

// 4. 로그아웃
export const logout = async (ctx) => {
  ctx.cookies.set('access_token');
  ctx.status = 204;
};

마지막으로,
확인을 위해 http://localhost:4000/api/auth/logout 로 POST 요청을 해보자.


다음 포스팅

  • posts API에 회원인증 시스템 도입
  • username / tags로 포스트 필터링
profile
기억은 한계가 있지만, 기록은 한계가 없다.

0개의 댓글