• 글 삭제/수정은 작성자만 할 수 있어야 한다.
  • 미들웨어를 수정
  • /src/lib/checkLoggedIn.js를 생성
  • /src/api/posts/posts.ctrl.jscheckOwnPost, getPostById를 추가
export const checkOwnPost = (ctx, next) => {
  const { user, post } = ctx.state;
  if (post.user._id.toString() !== user._id) {
    ctx.status = 403;
    return;
  }
  return next();
};

export const getPostById = async (ctx, next) => {
  const { id } = ctx.params;
  if (!ObjectId.isValid(id)) {
    ctx.status = 400; // Bad Request
    return;
  }
  try {
    const post = await Post.findById(id);
    if (!post) {
      ctx.status = 404; // Not found
      return;
    }
    ctx.state.post = post;
    return next();
  } catch (e) {
    ctx.throw(500, e);
  }
  return next();
};
  • /src/api/posts/index.js에 아래와 같이 미들웨어 추가
posts.get('/', postsCtrl.list);
posts.post('/', checkLoggedIn, postsCtrl.write);

const post = new Router(); // /api/posts/:id
post.get('/', postsCtrl.read);
post.delete('/', checkLoggedIn, postsCtrl.checkOwnPost, postsCtrl.remove);
post.patch('/', checkLoggedIn, postsCtrl.checkOwnPost, postsCtrl.update);

posts.use('/:id', postsCtrl.getPostById, post.routes());
export default posts;

username/tags로 포스트 필터

  • 포스트 조회, 태그 조회
/* 포스트 목록 조회
GET /api/posts
/api/posts?username=jhl
/api/posts?tag=다리디리
*/
export const list = async ctx => {
  const page = parseInt(ctx.query.page | '1', 10);

  if (page < 1) {
    ctx.status = 400;
    return;
  }

  const { tag, username } = ctx.query;
  const query = {
    // username, tag가 유효할때만 객체 안에 해당 값을 넣겠다는 의미
    ...(username ? { 'user.username': username } : {}),
    ...(tag ? { tags: tag } : {}),
  };

  try {
    const posts = await Post.find(query)
      .sort({ _id: -1 })
      .limit(10)
      .skip((page - 1) * 10)
      .exec();
    const postCount = await Post.countDocuments(query).exec();
    ctx.set('Last-Page', Math.ceil(postCount / 10));
    ctx.body = posts
      .map(post => post.toJSON())
      .map(post => ({
        ...post,
        body:
          post.body.length < 200 ? post.body : `${post.body.slice(0, 200)}...`,
      }));
  } catch (e) {
    ctx.throw(500, e);
  }
};
  • 토큰 기반 인증 구현
  • hash를 만드는 함수와 has 검증을 하는 함수
$ yarn add bcrypt

모델 메소드 만들기

  • 모델에서 사용할 수 있는 함수
  • 인스턴스 메서드 모델을 통해 만든 문서 인스턴스에서 사용할 수 있는 함수
  • static 메소드로 모델에서 바로 사용할 수 있는 함수

instance method

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

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

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; // true or false
};

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

static method

UserSchema.statics.findByUsername = function(username) {
  return this.findOne({ username });
};

회원 인증 API 만들기

src/api/auth/auth.ctrl.js

import Joi from 'joi';
import User from '../../models/user';

/*
POST /api/auth/register
{
  username: 'zzz',
  password: 'zzz'
}
*/
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 = Joi.validate(ctx.request.body, schema);
  if (result.error) {
    ctx.status = 400;
    ctx.body = result.error;
    return;
  }
  const { username, password } = ctx.request.body;
  try {
    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();
    ctx.body = user.serialize(); // delete hashedPassword
  } catch (e) {
    ctx.throw(500, e);
  }
};

/*
POST /api/auth/login
{
  username: 'zzz'
  password: 'zzz'
}
*/
export const login = async ctx => {
  const { username, password } = ctx.request.body;

  if (!username || !password) {
    ctx.status = 401; // Unauthorized
    return;
  }

  try {
    const user = await User.findByUsername(username);
    if (!user) {
      ctx.status = 401;
      return;
    }

    const valid = await user.checkPassword(password);
    if (!valid) {
      ctx.status = 401;
      return;
    }
    ctx.body = user.serialize();
  } catch (e) {
    ctx.throw(500, e);
  }
};

export const check = async ctx => {};
export const logout = async ctx => {}; 

토큰 발급 및 검증

$ yarn add jsonwebtoken
$ openssl rand -hex 64
  • root의 .envJWT_SECRET값으로 설정
# .env
JWT_SECRET=dflkjdlkfdjflkdjflkjd
  • 위 키는 JWT 토큰의 서명을 만드는 과정에 사용되기 때문에 외부에 절대 공개되면 안됨
  • user 모델 파일에서 generateToken이라는 인스턴스 메서드 생성
UserSchema.methods.generateToken = function() {
  const token = jwt.sign(
    // 토큰안에 넣고 싶은 데이터
    {
      _id: this.id,
      username: this.username,
    },
    // JWT 암호
    process.env.JWT_SECRET,
    {
      expiresIn: '7d', // 7일동안 토큰 유효
    },
  );
  return token;
}; 
  • 회원가입과 로그인에 성공했을때 토큰을 사용자에게 전달
  • 사용자가 브라우저에서 토큰을 사용
    • localStorage, sessionStorage
      • 매우 편리하고 구현이 쉽지만, 쉽게 해킹이 가능 (XSS(Cross Site Scripting)
    • 브라우저 쿠키에 담아서 사용
      • httpOnly 속성을 활성화하면 자바스크립트등을 통해 쿠키를 조회할 수 없음
      • CSRF (Cross Site Request Forgery) 공격에 취약
      • 토큰을 쿠키에 담으면 사용자가 서버로 요청할때 무조건 토큰이 함께 전달
        • 내가 모르는 상황에서 글이 작성, 삭제, 탈퇴가 가능...
      • CSRF 토큰 사용 및 Referer 검증 등의 방식으로 제대로 막을 수 있는 반면
      • XSS는 보안장치를 적용해도 개발자가 놓칠 수 있는 다양한 취약점으로 공격 가능
    const token = user.generateToken();
    ctx.cookies.set('access_token', token, {
      maxAge: 1000 * 60 * 60 * 24 * 7, // 7days
      httpOnly: true,
    });
  • login, register에 다음과 같이 추가하면 access_token이 Header에서 확인

토큰 검증

  • 사용자의 토큰을 확인한 후 검증 작업
  • src/lib아래 다음과 같은 파일을 생성
import jwt from 'jsonwebtoken';

const jwtMiddleware = (ctx, next) => {
  const token = ctx.cookie.get('access_token');
  if (!token) return next(); // token is not exists
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    console.log(decoded);
    return next();
  } catch (e) {
    // fail token validation
    return next();
  }
};

export default jwtMiddleware;

토큰 재발급받기

  • 토큰의 만료 기간을 7일로 했는데 만료되면 재발급
  • generateToken에서 토큰 유효기간을 3일로 변경하고 아래 함수가 실행되는지 확인
    // 토큰의 남은 유효 기간이 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, // 7days
        httpOnly: true,
      });
    }

로그아웃

  • 로그아웃은 access_token을 지워주면 된다.
export const logout = async ctx => {
  ctx.cookies.set('access_token');
  ctx.status = 240; // No Content
};
  • JWT (Json Web Token)
  • 데이터가 JSON으로 이루어져 있는 토큰

세션 기반 인증 vs 토큰 기반 인증

  • 사용자의 로그인 상태를 서버에서 처리
    • 세션 기반 인증
    • 토큰 기반 인증

세션 기반 인증

  • 서버가 사용자의 로그인 상태를 기억
  • 사용자 > 로그인 > 서버 > 정보저장 > 세션 id 발급 > 요청 > 세션 조회 > 응답
  • 세션 id는 브라우저의 쿠키에 저장
  • 세션 저장소: 메모리, 디스크, 데이터베이스
  • 단점
    • 서버를 확장하기가 어려움
      • 서버의 인스턴스가 여러개가 되면 세션 공유가 필요해 별도의 데이터베이스를 만들어야 한다

토큰 기반 인증

  • 로그인 이후 서버가 만들어주는 문자열
    • 문자열에는 로그인 정보가 들어있고, 해당 정보가 서버에서 발급되었음을 증명하는 서명
  • 사용자 > 로그인 > 서버 > 토큰 발급 > 토큰과 함께 요청 > 토큰 유효성 검사 > 응답
  • 서명 데이터는 해싱 알고리즘을 통해 생성
    • HMAC SHA256, RSA, SHA256 알고리즘
  • 서버에서 만들어주기때문에 무결성이 보장
    • 정보가 변경되거나 위조되지 않음
  • API 요청할때마다 토큰을 함께 요청
  • 장점
    • 로그인 정보를 기억하기 위해 사용하는 리소스가 적다는 것
    • 확장성이 높다. (인스턴스끼리 로그인 상태를 공유할 필요가 없음)
  • Context API는 리액트 프로젝트에서 전역적으로 사용할 데이터가 있을 때 유용한 기능
  • 사용 예
    • 로그인 정보
    • 애프리케이션 환경 설정
    • 테마
    • redux, react-router, styled-components 의 라이브러리
  • 컴포넌트 간에 데이터를 props로 저달하기 때문에 여기저기서 필요한 데이터가 있을 때는 주로 최상위 컴포넌트인 App의 state에 넣어서 관리
  • 하지만 props으로 아주 ~ 먼 컴포넌트에 전달하려면 여러개의 컴포넌트를 지나가야 하니... 유지 보수성이 낮아질 가능성이 있음
  • 위 문제점을 해결하기 위해서 redux나 MobX 같은 상태 관리 라이브러리를 사용하면 전역 상태 관리를 더 편하게 처리
  • 리액트 v16.3 업데이트 이후에는 Context API가 많이 개선되었기 때문에 별도의 라이브러리 사용할 필요 없음
import { createContext } from 'react';

const ColorContext = createContext({ color: 'black' });

export default ColorContext; 
import ColorBox from './components/ColorBox';
<ColorBox></ColorBox>

Provider 이용하기

  • Provider를 사용하면 Context의 값을 변경할 수 있다.
  • 아래와 같이 컴포넌트 ColorBoxColorContext.Provider를 통해 color 값을 변경할 수 있다.
  • Provider를 사용할때는 value 값을 명시해줘야 제대로 작동
      <ColorContext.Provider value={{ color: 'red' }}>
        <div>
          <ColorBox></ColorBox>
        </div>
      </ColorContext.Provider>

동적으로 사용하기

  • context의 value에는 상태값, 함수를 전달할 수 있다.
  • 아래와 같이 state, actions을 따로 정의한 ColorContext를 정의
  • ColorProvider 를 정의
import React, { createContext, useState } from 'react';

// 객체에서 state, actions을 분리해서 정의하면 나중에 사용하기가 편함
const ColorContext = createContext({
  state: { color: 'black', subcolor: 'red' },
  actions: {
    setColor: () => {},
    setSubcolor: () => {}
  }
});

const ColorProvider = ({ children }) => {
  const [color, setColor] = useState('black');
  const [subcolor, setSubcolor] = useState('red');

  const value = {
    state: { color, subcolor },
    actions: { setColor, setSubcolor }
  };

  return (
    <ColorContext.Provider value={value}>{children}</ColorContext.Provider>
  );
};

const { Consumer: ColorConsumer } = ColorContext;
export { ColorProvider, ColorConsumer };
export default ColorContext;
import React from 'react';
import { ColorConsumer } from '../contexts/color';

const ColorBox = () => {
  return (
    <ColorConsumer>
      {(
        { state } // 객체 비구조화 할당 문법
      ) => (
        <>
          <div
            style={{
              width: '64px',
              height: '64px',
              background: state.color
            }}
          />
          <div
            style={{
              width: '32px',
              height: '32px',
              background: state.subcolor
            }}
          />
        </>
      )}
    </ColorConsumer>
  );
};

export default ColorBox;

사용하는 쪽에서는 아래와 같이

      <ColorProvider>
        <div>
          <ColorBox></ColorBox>
        </div>
      </ColorProvider>

색상 선택하는 컴포넌트 생성

  • Context에 들어있는 actions에 넣어준 함수를 호출하는 컴포넌트 생성
# SelectColor.js
import React from 'react';
import { ColorConsumer } from '../contexts/color';

const colors = [
  'red',
  'oragne',
  'yellow',
  'green',
  'blue',
  'indigo',
  'violoet'
];

const SelectColors = () => {
  return (
    <div>
      <h2>색상을 선택하세요.</h2>
      <ColorConsumer>
        {({ actions }) => (
          <div style={{ display: 'flex' }}>
            {colors.map(color => (
              <div
                key={color}
                style={{
                  background: color,
                  width: '24px',
                  height: '24px',
                  cursor: 'pointer'
                }}
                onClick={() => actions.setColor(color)}
                onContextMenu={e => {
                  e.preventDefault(); // 기존 오른쪽 메뉴가 뜨는 것을 방지
                  actions.setSubcolor(color);
                }}
              />
            ))}
          </div>
        )}
      </ColorConsumer>
      <hr />
    </div>
  );
};

export default SelectColors;
  • App.js에서는 SelectColors의 컴포넌트를 추가
      <ColorProvider>
        <div>
          <SelectColors></SelectColors>
          <ColorBox></ColorBox>
        </div>
      </ColorProvider>

useContext Hook 사용

  • 리액트 내장되어 있는 Hooks 중에서 useContext를 사용해보자
  • useContext Hook은 함수형 컴포넌트에서만 사용할 수 있다.
  • ColorBox를 아래와 같이 수정
import React, { useContext } from 'react';
import { ColorContext } from '../contexts/color';

const ColorBox = () => {
  const { state } = useContext(ColorContext);
  return (
    <>
      <div
        style={{
          width: '64px',
          height: '64px',
          background: state.color
        }}
      />
      <div
        style={{
          width: '32px',
          height: '32px',
          background: state.subcolor
        }}
      />
    </>
  );
};

export default ColorBox;

static contextType

  • 클래스형 컴포넌트에서 Context를 좀 더 쉽게 사용하기 위해서 static contextType을 정의
  • 기존에 SelectColors의 컴포넌트를 클래스형으로 변경
  • static contextType을 정의하면 클래스 메소드에서도 Context에 있는 함수 호출이 가능
  • 단점이라면 한 클래스에서 하나의 context만 사용할 수 있음
  • useContext를 사용하는게 더 권장됨
  • 아래가 기존
import React from 'react';
import { ColorConsumer } from '../contexts/color';

const colors = [
  'red',
  'oragne',
  'yellow',
  'green',
  'blue',
  'indigo',
  'violoet'
];

const SelectColors = () => {
  return (
    <div>
      <h2>색상을 선택하세요.</h2>
      <ColorConsumer>
        {({ actions }) => (
          <div style={{ display: 'flex' }}>
            {colors.map(color => (
              <div
                key={color}
                style={{
                  background: color,
                  width: '24px',
                  height: '24px',
                  cursor: 'pointer'
                }}
                onClick={() => actions.setColor(color)}
                onContextMenu={e => {
                  e.preventDefault(); // 기존 오른쪽 메뉴가 뜨는 것을 방지
                  actions.setSubcolor(color);
                }}
              />
            ))}
          </div>
        )}
      </ColorConsumer>
      <hr />
    </div>
  );
};

export default SelectColors;
  • 이후가 클래스 컴포넌트로 변경 후
import React, { Component } from 'react';
import ColorContext from '../contexts/color';

const colors = [
  'red',
  'oragne',
  'yellow',
  'green',
  'blue',
  'indigo',
  'violoet'
];

class SelectColors extends Component {
  static contextType = ColorContext;

  handleSetColor = color => {
    this.context.actions.setColor(color);
  };

  handleSetSubcolor = subcolor => {
    this.context.actions.setSubcolor(subcolor);
  };

  render() {
    return (
      <div>
        <h2>색상을 선택하세요.</h2>
        <div style={{ display: 'flex' }}>
          {colors.map(color => (
            <div
              key={color}
              style={{
                background: color,
                width: '24px',
                height: '24px',
                cursor: 'pointer'
              }}
              onClick={() => this.handleSetColor(color)}
              onContextMenu={e => {
                e.preventDefault(); // 기존 오른쪽 메뉴가 뜨는 것을 방지
                this.handleSetSubcolor(color);
              }}
            />
          ))}
        </div>
        <hr />
      </div>
    );
  }
}

export default SelectColors; 

로그인 사용자와 게스트 사용자를 조건을 통해서 element를 렌더링이 가능

function Greeting(props) {
  const isLoggedIn = props.isLoggedIn;
  if (isLoggedIn) {
    return <UserGreeting />;
  }
  return <GuestGreeting />;
}

ReactDOM.render(
  // Try changing to isLoggedIn={true}:
  <Greeting isLoggedIn={false} />,
  document.getElementById('root')
);

login/logout 버튼 변경하기 상태에 따라 LogoutButton, LoginButton element를 button에 넣고 화면에 출력

class LoginControl extends React.Component {
  constructor(props) {
    super(props);
    this.handleLoginClick = this.handleLoginClick.bind(this);
    this.handleLogoutClick = this.handleLogoutClick.bind(this);
    this.state = {isLoggedIn: false};
  }

  handleLoginClick() {
    this.setState({isLoggedIn: true});
  }

  handleLogoutClick() {
    this.setState({isLoggedIn: false});
  }

  render() {
    const isLoggedIn = this.state.isLoggedIn;
    let button;

    if (isLoggedIn) {
      button = <LogoutButton onClick={this.handleLogoutClick} />;
    } else {
      button = <LoginButton onClick={this.handleLoginClick} />;
    }

    return (
      <div>
        <Greeting isLoggedIn={isLoggedIn} />
        {button}
      </div>
    );
  }
}

ReactDOM.render(
  <LoginControl />,
  document.getElementById('root')
);

이렇게 분기하는게 좋을까? 분기를 해야하는 깊이가 커지면 모든 경우에 따라 처리를 해줘야 하겠구나. 두가지 조건이 있는 경우에는 두 조건의 조건부 렌더링을 해주는 컴포넌트(함수)를 만드는게 좋겠다.

+ Recent posts