인증 구현하기

리린·2021년 8월 4일
0

node.js

목록 보기
10/16

세션 vs 토큰

  1. 세션:
  • 세션 저장소에서 세션을 조회하고 로그인 여부를 결정하여 작업을 처리하고 응답한다.
  • 메모리, 디스크, 데이터베이스 등을 사용한다.
  • 서버 확장이 번거로워질 수 있다.(모든 서버끼리 같은 세션을 공유해야 함)
  1. 토큰:
  • 로그인 이후 서버가 만들어주는 문자열.
  • 토큰에는 사용자의 로그인 정보 + 서버에서 발급되었음을 증명하는 서명이 들어있다.
  • 해싱 알고리즘을 통해 만들어진다. (HMAC SHA256 또는 RSA SHA256 알고리즘이 사용된다 )
  • 사용방법:
    유저의 로그인(-서버의 토큰발급)
    유저의 API 요청(발급받은 토큰과 함께 요청)
    (서버에서 유저의 토큰 검사)
    (결과에 따라 다른 값을 전송)

User 스키마/ 모델 만들기

  1. 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; 
  1. 해시 만드는 모듈 설치하기
  • 콘솔
    yarn add bcrypt
  1. 모델 메서드 만들기
  • 모델 메서드란?
    모델에서 사용할 수 있는 함수
  • 종류
    인스턴스 메서드(모델을 통해 만든 문서 인스턴스에서 사용할 수 있는 함수 )
const user = new User({username: 'velopert'})
user.setPassword('mypass123')

스태틱 메서드( 모델에서 바로 사용할 수 있는 함수) 로 나뉨

const user = User.findByUsername('velopert')
  1. 인스턴스 메서드
  • 화살표 함수로 구현 x
    (this를 사용하여 구현해야 하므로 /헷갈리니까 설명 참조)
  • 코드
import mongoose, {Schema} from 'mongoose';
import bcrypt from 'bcrypt';

const UserSchema = new Schema({
    username: String, 
    hashedPassword: String, 
})

//인스턴스 메서드 - this는 문서 인스턴스(UserSchema)를 가리킴 
UserSchema.methods.setPassword=async
function(password){
    const hash = await bcrypt.hash(password, 10);
    this.hashedPassword = hash;
}

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

// 스태틱 메서드 -this는 모델(User)을 가리킴 
UserSchema.statics.findByUsername= 
function(username) {
    return this.findOne({ username});
}

const User = mongoose.model('User', UserSchema);
export default User; 

회원 인증 API 만들기

  • src/api/auth/auth.ctrl.js
import Joi from 'joi';
import User from '../../models/user';

/**
 POST /api/auth/register
 {
     username: 'velopert'
     password: 'mypass123
 }
*/

export const register = async (ctx) => {
  //회원가입
  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;
      return;
    }
    const user = new User({
      username,
    });
    await user.setPassword(password);
    await user.save();
    //응답할 데이터에서 hashedPassword 필드 제거
    ctx.body = user.serialize();
  } catch (e) {
    ctx.throw(500, e);
  }
};

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);
    //계정이 존재하지 않으면 에러 처리
    if (!user) {
      ctx.status = 41;
      return;
    }
    const valid = await user.checkPassword(password);
    //잘못된 비밀번호
    if (!valid) {
      ctx.status = 401;
      return;
    }
    ctx.body = user.serialize();
  } catch (e) {
    ctx.throw(500, e);
  }
};
  • models/user.js 수정
    (hashedPassword 데이터를 json으로 변환 후 delete를 통해 해당 필드를 지워주는 작업 메서드에 추가하기 )
UserSchema.methods.serialize = function () {
  const data = this.toJSON();
  delete data.hashedPassword;
  return data;
};

토큰 발급 및 검증하기

  1. jsonwebtoken 모듈 설치
  • 콘솔:
    yarn add jsonwebtoken
  1. 비밀키 설정하기
    .env 파일
PORT=4001
MONGO_URI=mongodb://localhost:27017/blog
JWT_SECRET=안녕하세요
  1. 토큰 발급 메서드
UserSchema.methods.generateToken = function () {
  const token = jwt.sign(
    {
      _id: this.id,
      username: this.username,
    },
    process.env_JWT_SECRET,
    { expiresIn: '7d' },
  );
  return token;
};
  1. 사용자에게 토큰 발급하기

1)localStorage 혹은 sessionStorage

  • XSS 위험성: 악성 스크립트를 삽입한다면 쉽게 토큰 탈취 가능

2) 브라우저 쿠키에 담기

  • XSS 위험성
    -> httpOnly 속성 활성화하면 자바스크립트를 통해 쿠키 조회 불가능
    -> 악성 스크립트로부터 안전해짐
  • CSRF 위험성 : 토큰을 쿠키에 담으면 사용자가 서버로 요청할 때마다 무조건 토큰이 전달되는 점을 이용하여 사용자도 모르게 원하지 않는 API요청을 하게 만듬
    ->그러나, CSRF 토큰 사용 및 Referer 검증 방식으로 막기 가능

즉, 2번이 낫다.

  1. models/user.js 에서 토큰 발급기 추가하기
UserSchema.methods.generateToken = function () {
  const token = jwt.sign(
    {
      _id: this.id,
      username: this.username,
    },
    process.env.JWT_SECRET,
    { expiresIn: '7d' },
  );
  return token;
};
  1. auth.ctrl.js 에서 jwt 토큰 사용하기
import Joi from 'joi';
import User from '../../models/user';

/**
 POST /api/auth/register
 {
     username: 'velopert'
     password: 'mypass123
 }
*/

export const register = async (ctx) => {
  //회원가입
  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;
      return;
    }
    const user = new User({
      username,
    });
    await user.setPassword(password);
    await user.save();
    //응답할 데이터에서 hashedPassword 필드 제거
    ctx.body = user.serialize();

    //jwt 토큰
    const token = user.generateToken();
    ctx.cookies.set('access_token', token, {
      maxAge: 100 * 60 * 60 * 24 * 7,
      httpOnly: true,
    });
  } catch (e) {
    ctx.throw(500, e);
  }
};

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);
    //계정이 존재하지 않으면 에러 처리
    if (!user) {
      ctx.status = 41;
      return;
    }
    const valid = await user.checkPassword(password);
    //잘못된 비밀번호
    if (!valid) {
      ctx.status = 401;
      return;
    }
    ctx.body = user.serialize();
    //jwt 토큰
    const token = user.generateToken();
    ctx.cookies.set('access_token', token, {
      maxAge: 100 * 60 * 60 * 24 * 7,
      httpOnly: true,
    });
  } catch (e) {
    ctx.throw(500, e);
  }
};

토큰 검증하기

  1. lib/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 수정
  • 코드
require('dotenv').config();
import Koa from 'koa';
import Router from 'koa-router';
import bodyParser from 'koa-bodyparser';
import mongoose from 'mongoose';

import api from './api';
import jwtMiddleware from './lib/jwtMiddleware';

//비구조화 할당을 통해 process.env 내부 값에 대한 레퍼런스 만들기
const { PORT, MONGO_URI } = process.env;

mongoose
  .connect(MONGO_URI, { useNewUrlParser: true, useFindAndModify: false })
  .then(() => {
    console.log('connected to MongoDB');
  })
  .catch((e) => {
    console.error(e);
  });

const app = new Koa();
const router = new Router();

//라우터 설정
router.use('/api', api.routes());

// 라우터 적용 전에 bodyParser 적용
app.use(bodyParser());
app.use(jwtMiddleware);

// app 인스턴스에 라우터 적용
app.use(router.routes()).use(router.allowedMethods());

//[PST가 지정되어 있지 않다면 4000을 사용
const port = PORT || 4000;

app.listen(port, () => {
  console.log('Listening to port %d', port);
});
  • src/api/auth/auth.ctrl.js

export const check = async (ctx) => {
  //로그인 상태 구현
  const { user } = ctx.state;
  if (!user) {
    //로그인 중 아님
    ctx.status = 401;
    return;
  }
  ctx.body = user;
};
/*
export const logout = async (ctx) => {
  //로그아웃
};
*/

토큰 재발급 기능

  1. src/lib/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);
    // 이후 미들웨어에서 사용할 수 있도록 state에 넣어주기
    ctx.state.user = {
      _id: decoded._id,
      username: decoded.username,
    };
    //토큰의 남은 유효기간이 3.5일 미만이면 재발급
    const now = Math.flooer(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,
      });
    }
    console.log(decoded);
    return next();
  } catch (e) {
    //토큰 검증 실패
    return next();
  }
};

export default jwtMiddleware;
profile
개발자지망생

0개의 댓글