함께 성장하는 프로독학러

Memo_app 04. 인증(authentication) - 로그인, 로그아웃, 로그인 확인 구현 본문

Programming/tutorials

Memo_app 04. 인증(authentication) - 로그인, 로그아웃, 로그인 확인 구현

프로독학러 2018. 6. 26. 01:18

안녕하세요, 프로독학러 입니다.


이번 포스팅에서는 저번 포스팅에 이어 인증(로그인, 로그아웃, 로그인 확인)을 구현해보도록 하겠습니다.

(인증에는 회원가입, 로그인, 로그아웃, 로그인 확인의 4가지 기능이 있습니다)


*본 튜토리얼은 Velopert 님의 'React.js Codelab 2016' 을 기반으로 되었습니다.

(여러 모듈들의 버전업에 따라 작성방법이 조금씩 달라진 코드를 버전에 맞게 수정하고, 제가 튜토리얼을 따라함에 있어 이해가 쉽지 않았던 부분에 설명을 추가하는 방식으로 진행합니다.)

* Velopert 님의 원본 튜토리얼을 보고싶으신 분들은 아래의 링크를 참고해 주세요.

<React.js codelab 2016 - Velopert>


0. intro - 쿠키와 세션


인증 기능을 구현하는데 있어서 중요한 개념은 쿠키와 세션입니다.

간단히 두 개념이 어떤차이가 있는지 알아보고, 인증 기능에서 어떻게 사용될지 생각해 보도록 하겠습니다.


  • 쿠키(cookie) : 브라우저, 사용자의 컴퓨터(FRONT-END) 에 저장되는 데이터를 의미. 클라이언트 사이드에 있기 때문에 속도가 빠름
  • 세션(session) : '시간' 을 의미. 이 시간은 사용자와 서버(BACK-END)의 통신이 지속되는 시간을 의미함. 사용자와 서버간의 통신이 끊기면 세션도 만료됨. 서버에 접속한 사용자별로 고유한 ID를 가진 세션이 생성됨. 서버측에 저장되기 때문에 보안이 쿠키에 비해 뛰어남.


쿠키와 세션이 하는 역할은 사실 비슷합니다. 세션을 쿠키로 저장해서 사용하기 때문입니다.

우리가 구현할 인증에서 쿠키와 세션의 역할을 간단히 말하자면, 세션은 실질적 연결을 의미하고, 쿠키는 연결 정보를 저장하는 역할을 수행합니다.

즉, 세션의 확인을 통해 로그인이 되었는지 확인할 수 있고, 세션의 내용을 기반으로 로그인 여부와 정보를 쿠키에 저장하여 사용하는 것입니다.

* 쿠키에 로그인 정보를 저장할 때는 base64 로 인코딩하여 사용할 것입니다. 이에 익숙하지 않으신 분들은 아래의 링크를 참조해주세요.

<base64 인코드, 디코드 메소드 btoa, atob>


1. 로그인


이번 포스팅에서 가장 먼저 구현해 볼 내용은 로그인입니다.

먼저 그림을 통해 어떤 방식으로 로그인이 작동하는지 살펴보도록 하겠습니다.



가장 먼저 BACK-END 의 API 를 살펴보도록 하겠습니다.

API 의 역할은 req.body 로 들어온 데이터를 DB에 조회해 입력받은 아이디가 있는지 검사하고, 있다면 비밀번호(hash)가 일치하는지 확인하는 역할을 수행합니다.

(검사 결과에 따라 다른 값을 res.body 로 전달합니다. 에러객체 혹은 성공객체)

두 번째로 살펴볼 것은 FRONT-END 의 Redux 입니다.

리덕스는 액션타입을 가진 액션 객체를 dispatch 함수로 전달받아 액션타입에 따라 리듀서가 state를 변경합니다.

이 때, thunk 라는 미들웨어를 사용하는데, 이 thunk 함수는 axios 를 이용하여 API 와 통신을 주고받고, 그 결과에 따라 다른 dispatch 함수를 실행합니다. 

세 번째로 살펴볼 것은 FRONT-END 의 컨테이너 컴포넌트인 Login 입니다. ('/login' 경로로 접근했을 때 렌더링될 컴포넌트)

Login 는 리덕스와 연결되어 thunk 함수와 리덕스 state를 사용할 수 있습니다.

그리고 내부적으로 thunk 함수를 사용하는 메소드를 정의하여 Authentication 컴포넌트로 전달합니다.

네 번째로 살펴볼 것은 FRONT-END 의 Authentication 컴포넌트입니다.

이 컴포넌트는 회원가입 ,로그인 뷰를 렌더링하는 컴포넌트입니다.

또한 thunk 함수의 인자로 들어갈 username 과 password 를 결정하여 thunk 함수를 실행시킵니다.


데이터가 흐르는 과정은 위의 그림의 숫자와 관련이 있습니다.


1. Redux 에서 정의한 thunk 함수와 state 값을 컨테이너 컴포넌트(Login)에 전달(연결) 합니다.

2. Login 컴포넌트 내에서 thunk 함수를 실행하는 메소드를 정의하여 Authentication 컴포넌트로 전달합니다.

3. Authentication 컴포넌트에서 username 값과 password 값을 결정하여 thunk 함수를 실행합니다.

4. API 로 데이터를 전달합니다.

5. API 에서 DB 의 데이터에 접근하여 오류가 있는지 확인하고 결과에 따라 다른 JSON 파일을 response 합니다.(오류가 없다면 세션에 로그인 정보 저장)

6. 5로 부터 전달받은 데이터를 통해 리덕스 state 를 변경하고, 변경된 값을 Login 컨테이너 컴포넌트에 전달합니다.

7. 로그인에 성공했다는 state(this.props.status === 'SUCCESS') 를 전달 받으면 브라우저 쿠키에 값 저장


* 쿠키는 사용자의 브라우저에 저장되는 내용이고 session 은 서버측에 저장되는 내용(사용자마다 고유한 ID를 갖는 세션이 존재)입니다. 로그인을 하게되면, BACK-END 에서 세션을 저장하고, FRONT-END 에서는 그 값을 쿠키에 암호화하여 저장합니다.(base64 인코딩)


1-1) BACK-END API 구현


서버측의 API 먼저 작성해 보도록 하겠습니다.

API 의 역할은 DB 와 통신하여 입력받은 아이디가 accounts 콜렉션에 있는지 확인하고, 있다면 입력받은 비밀번호와 DB 상의 salt 를 이용하여 만든 hash 값을 DB 에 저장된 password 값과 비교해 올바른 로그인 요청인지 확인하는 것입니다.

(이상이 있다면 HTTP status 와 함께 에러 객체를 리턴합니다)


(./server/routes/authentication.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
/*
    ACCOUNT SIGNIN: POST /api/account/signin
    BODY SAMPLE: { "username": "test", "password": "test" }
    ERROR CODES:
        1: PASSWORD IS NOT STRING
        2: THERE IS NO USER
        3: PASSWORD IS NOT CORRECT
*/
router.post('/signin', (req, res) => {
    // 비밀번호 데이터 타입 검사 (문자열인지 아닌지)
    if(typeof req.body.password !== "string") {
        return res.status(401).json({
            error: "PASSWORD IS NOT STRING",
            code: 1
        });
    }
 
    // FIND THE USER BY USERNAME
    // Model.findOne 메소드로 username 이 같은 DB 검색 (첫번째 인자 : Query)
    Account.findOne({ username: req.body.username}, (err, account) => {
        if(err) throw err;
 
        // CHECK ACCOUNT EXISTANCY
        // 검색 결과가 존재하지 않는 경우
        if(!account) {
            return res.status(401).json({
                error: "THERE IS NO USER",
                code: 2
            });
        }
 
        // CHECK WHETHER THE PASSWORD IS VALID
        // 유저검색 결과가 있으면 검사 salt값으로 해쉬
        const validate = hasher({password:req.body.password, salt:account.salt}, function(err, pass, salt, hash){
          // 입력한 비밀번호를 이용해 만는 해쉬와 DB에 저장된 비밀번호가 같을 경우
          if(hash === account.password){
            let session = req.session;
            session.loginInfo = {
                _id: account._id,
                username: account.username
            };
 
            // RETURN SUCCESS
            return res.json({
                success: true
            });
          }else{
            // 다른 경우
            return res.status(401).json({
                error: "PASSWORD IS NOT CORRECT",
                code: 3
            });
          }
        });
 
    });
});
cs


기존의 router.post('/signin', ...) 부분을 위와 같이 수정합니다.

위의 코드는 POST 방식으로 /api/account/signin 으로 들어오는 요청에 대한 API 이며, req.body 로 들어오는 데이터의 모양은 다음과 같습니다.

{ "username": "...", "password": "..." }


코드의 11 번째 줄에서 비밀번호로 들어온 값의 데이터 타입을 검사합니다.

만약 들어온 비밀번호의 값이 문자열이 아니라면 HTTP status 401 과 함께 에러 객체를 리스폰스합니다.

* HTTP status 401 은 권한 없음을 의미합니다. (권한 없음, 인증 안 됨)


코드의 20 번째 줄에서 Account 모델을 이용하여 DB(accounts 콜렉션) 에서 username 에 해당하는 정보(도큐먼트)가 있는지 확인합니다.

findOne 메소드는 DB 를 조회하는 메소드이며, 첫 번째 인자는 Query 객체를 의미합니다.

여기서는 입력받은 username 값으로 도큐먼트를 조회합니다. 

DB 조회의 마지막 인자(여기서는 두 번째 인자)는 콜백함수로, 콜백함수의 첫 번째 인자는 error 를, 두 번째 인자는 검색결과를 의미합니다.


만약 오류가 있다면 err 를 throw 합니다. (21번째 줄)

코드의 25 번째 줄은 DB 에 조회된 도큐먼트가 없는 경우이며, 이때는 HTTP status 401 과 함께 에러 객체를 내보냅니다.


위의 코드까지 skip 됐다면 DB 에 username 과 같은 도큐먼트가 있다는 뜻이므로 비밀번호를 비교해 보아야합니다.

회원가입을 할때 우리는 pbkdf2-password (비밀번호 보안모듈) 을 이용하여 비밀번호를 직접 저장하지 않고 salt 와 hash 값을 저장했습니다.

따라서 입력받은 비밀번호와 DB에 저장된 salt 값을 이용하여 hash 를 생성하여 DB 의 password 값과 비교합니다. (코드의 34 번째 줄)

만약 hash 값이 일치한다면 세션에 로그인 정보를 기록하고(37 번째 줄), 성공 객체를 리턴합니다. (44 번째 줄)

* 세션에 저장한 정보는 로그인을 확인하는 용도로 사용됩니다. (특정시간이 지나면 자동 만료)

만약 47 번째 줄과 같이 생성된 hash 값과 DB에 저장된 password 값이 일치하지 않는다면 HTTP status 401 과 에러 객체를 리스폰스합니다.

* pbkdf2-password 의 사용법에 익숙하지 않으신 분들은 아래의 링크를 참조해 주세요.

<pbkdf2-password 보안 모듈>


1-2) Redux 구현


FRONT-END 의 Redux 를 구현해 보겠습니다.


1-2-1) ActionTypes.js


액션타입에 login 관련 타입을 추가해 줍니다. (export 되는 값은 문자열)


(./src/actions/ActionTypes.js)

1
2
3
4
// Login
export const AUTH_LOGIN = "AUTH_LOGIN";
export const AUTH_LOGIN_SUCCESS = "AUTH_LOGIN_SUCCESS";
export const AUTH_LOGIN_FAILURE = "AUTH_LOGIN_FAILURE";
cs


1-2-2) authentication.js 


액션생성자 함수와 thunk 를 정의하는 파일입니다.


(./src/actions/authentication.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
...
import {
    AUTH_REGISTER,
    AUTH_REGISTER_SUCCESS,
    AUTH_REGISTER_FAILURE,
    AUTH_LOGIN,
    AUTH_LOGIN_SUCCESS,
    AUTH_LOGIN_FAILURE
} from './ActionTypes';
...
/* LOGIN */
export function loginRequest(username, password) {
  return (dispatch) => {
      // Inform Login API is starting
      dispatch(login());
 
      // API REQUEST
      return axios.post('/api/account/signin', { username, password })
      .then((response) => {
          // SUCCEED
          dispatch(loginSuccess(username));
      }).catch((error) => {
          // FAILED
          dispatch(loginFailure());
      });
  };
}
 
export function login() {
    return {
        type: AUTH_LOGIN
    };
}
 
export function loginSuccess(username) {
    return {
        type: AUTH_LOGIN_SUCCESS,
        username
    };
}
 
export function loginFailure() {
    return {
        type: AUTH_LOGIN_FAILURE
    };
}
cs


코드의 6-8 번째 줄에서 로그인 관련 ActionTypes 를 불러옵니다.


29, 35, 42 번째 줄은 모두 액션생성자 함수로 액션객체를 리턴합니다. 순서대로 진행중, 성공, 실패를 알리는 action 객체를 반환하며, 성공 시 리턴되는 action 객체에는 username 필드에 값으로 액션생성자 함수의 인자로 들어온 값이 들어옵니다.


12 번째 줄은 thunk 함수입니다.

loginRequest 함수의 인자로 username, password 값이 들어옵니다. 

loginRequest 함수가 실행 되면 제일 먼저 코드의 15 번째 줄과 같이 로그인이 시작됨을 알리는 action 객체를 리듀서로 보냅니다.

그리고 axios 를 통해 BACK-END API 와 통신합니다. (18 번째 줄)

POST 방식으로 '/api/account/signin' 으로 통신하며 req.body 로 thunk 함수의 인자로 들어온 username, password 를 이용하여 객체를 만들어 전송합니다.

전송이 완료된 후 성공하면 19 번째 줄과 같이 성공을 알리는 action 객체(36 번째 줄에서 리턴한 객체)를 리듀서로 전달하고,

실패하면 실패를 의미하는 action 객체를 리듀서로 전달합니다.


1-2-3) reducer (authentication.js) 


리듀서는 dispatch 함수로 부터 전달받은 action 객체의 type 값에 따라 state 를 변경하는 함수입니다.


(./src/reducers/authentication.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
...
/* LOGIN */
    case types.AUTH_LOGIN:
        return {
          ...state,
          login : {
            status: 'WAITING'
          }
        }
    case types.AUTH_LOGIN_SUCCESS:
        return {
          ...state,
          login: {
              status: 'SUCCESS'
          },
          status: {
            ...state.status,
            isLoggedIn: true,
            currentUser: action.username
          }
        }
    case types.AUTH_LOGIN_FAILURE:
        return {
          ...state,
          login:{
            status: 'FAILURE'
          }
        }
...
cs


리듀서에 전달받은 action 객체의 type 값에 따라 state 를 변경하는 코드를 추가해 줍니다.


위의 코드에서는 switch 조건문과 spread operator 가 사용되었습니다.

* JS 의 switch 조건문에 익숙하지 않으신 분들은 아래의 링크를 참조해 주세요.

<javascript switch 조건문>

* ES6 의 spread operator 에 익숙하지 않으신 분들은 아래의 링크를 참조해 주세요.

<ES6, spread operator>


1-3) Login 컨테이너 컨포넌트 구현


'/login' 경로로 접근했을 때 렌더링 될 컴포넌트인 Login 컨테이너 컴포넌트를 만들어 보도록 하겠습니다.

(클라이언트 사이드 라우팅을 통해 렌더링 될 컴포넌트)

완성된 모습은 다음과 같습니다.



Login 컨테이너에서 Authentication 컴포넌트를 렌더링 할 것입니다.

이는 이전 포스팅에서 구현했던 내용인데, Authentication 컴포넌트는 상위 컴포넌트로부터 전달받는 mode props 의 값이 true 이면 로그인 뷰를 렌더링하고, false 이면 회원가입 뷰를 렌더링합니다.


containers 디렉토리에 Login.js 파일을 생성하겠습니다.


(./src/containers/Login)

1
2
3
4
5
6
7
8
9
10
11
12
13
import React, { Component } from 'react';
 
class Login extends Component {
    render() {
        return (
            <div>
                Login
            </div>
        );
    }
}
 
export default Login;
cs


containers 디렉토리의 index 파일에 Login 컨테이너를 import / export 해 줍니다.


(./src/containers/index.js)

1
2
3
4
import Register from './Register';
import Login from './Login';
 
export { Register, Login };
cs


클라이언트 라우팅 도구인 react-router-dom 을 이용하여 '/login' 경로로 들어왔을 때 Login 컨테이너를 렌더링 하도록 설정하도록 하겠습니다.


(./src/index.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter as Router, Route } from 'react-router-dom';
import { Register, Login } from 'containers'
 
import { Provider } from 'react-redux';
import { createStore, applyMiddleware } from 'redux';
import reducers from 'reducers';
import thunk from 'redux-thunk';
 
const store = createStore(reducers, applyMiddleware(thunk));
 
const title = 'Memo_App!';
 
ReactDOM.render(
  <Provider store={store}>
    <Router>
      <div>
        <Route path="/register" component={Register}/>
        <Route path="/login" component={Login}/>
      </div>
    </Router>
  </Provider>
  ,
  document.getElementById('root')
);
 
module.hot.accept();
cs


코드의 4 번째 줄에서 containers 디렉토리로 부터 Login 컨테이너를 import 했습니다.

20 번째 줄에서 'login' 경로로 접근했을때 Login 컨테이너를 렌더링하겠다고 라우트설정을 했습니다.

* 클라이언트 사이드 라우터인 react-router-dom 에 대해서 익숙치 않으신 분들은 아래 링크로 첨부된 카테고리의 글을 참고해주세요.

<react-router_v4 강좌>


1-4) Authentication 컴포넌트


Login 컨테이너에서 렌더링 될 Authetication 컴포넌트입니다.


(./src/components/Authentication.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
 
class Authentication extends Component {
    state = {
      username:"",
      password:""
    }
 
    handleChange = (e) => {
        let nextState = {};
        nextState[e.target.name= e.target.value;
        this.setState(nextState);
    }
 
    handleRegister = () => {
        let id = this.state.username;
        let pw = this.state.password;
 
        this.props.onRegister(id, pw).then(
            (result) => {
                if(!result) {
                    this.setState({
                        username: '',
                        password: ''
                    });
                }
            }
        );
    }
 
    render() {
        const inputBoxes = (
            <div>
                <div className="input-field col s12 username">
                    <label>Username</label>
                    <input
                    name="username"
                    type="text"
                    className="validate"
                    onChange={this.handleChange}
                    value={this.state.username}/>
                </div>
                <div className="input-field col s12">
                    <label>Password</label>
                    <input
                    name="password"
                    type="password"
                    className="validate"
                    onChange={this.handleChange}
                    value={this.state.password}/>
                </div>
            </div>
        );
 
        const loginView = (
            <div>
                <div className="card-content">
                    <div className="row">
                        {inputBoxes}
                        <a className="waves-effect waves-light btn">SUBMIT</a>
                    </div>
                </div>
 
 
                <div className="footer">
                    <div className="card-content">
                        <div className="right" >
                        New Here? <Link to="/register">Create an account</Link>
                        </div>
                    </div>
                </div>
 
            </div>
        );
 
        const registerView = (
            <div className="card-content">
                <div className="row">
                    {inputBoxes}
                    <a className="waves-effect waves-light btn"
                      onClick={this.handleRegister}>CREATE</a>
                </div>
            </div>
        );
        return (
          <div className="container auth">
              <Link className="logo" to="/">MEMOPAD</Link>
              <div className="card">
                  <div className="header blue white-text center">
                      <div className="card-content">{this.props.mode ? "LOGIN" : "REGISTER"}</div>
                  </div>
                  {this.props.mode ? loginView : registerView }
              </div>
          </div>
        );
    }
}
 
Authentication.propTypes = {
    mode: PropTypes.bool,
    onRegister: PropTypes.func
};
 
Authentication.defaultProps = {
    mode: true,
    onRegister: (id, pw) => { console.error("register function is not defined"); }
};
 
export default Authentication;
cs


이전 포스팅에서 로그인 뷰까지 구현 했으므로 코드는 그대로인 상태입니다.


1-5) Login 컨테이너 컴포넌트에서 Authentication 컴포넌트 렌더링


Login 컨테이너에서 Authentication 을 렌더링합니다.

이 때, Authentication 컴포넌트에 mode props 값을 true 로 전달합니다.

(Authentication 컴포넌트에서 전달받은 mode props 의 값이 true 여야 로그인 뷰를 렌더링 하도록 설정)


(./src/containers/Login.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import React, { Component } from 'react';
import { Authentication } from 'components';
 
class Login extends Component {
    render() {
        return (
            <div>
                <Authentication mode={true}/>
            </div>
        );
    }
}
 
export default Login;
cs


개발서버를 실행시켜(프로젝트 경로에서 npm run win_development 명령어를 통해) 제대로 작동하는지 확인해 보겠습니다.

* 개발서버를 실행할 때 MongoDB 서버도 열려있어야 합니다.



위와 같이 /login경로로 접근했을 때 Login 컨테이너가 제대로 렌더링 되는것을 알 수 있습니다.

('/' 경로의 클라이언트 사이드 라우팅은 아직 존재하지 않으므로 localhost:4000 으로 접속했을때 배경색만 보이는것은 정상입니다)


1-6) Login 컨페이너 컴포넌트에 Redux 연결


데이터를 주고받는 작업을 시작해 보도록하겠습니다.

Redux 에서 구현한 thunk 함수와 리덕스 state 를 Login 컨테이너 컴포넌트로 전달해주도록 하겠습니다.

(connect 를 통해 전달받은 props 처럼 사용가능)


(./src/containers/Login.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import React, { Component } from 'react';
import { Authentication } from 'components';
import { connect } from 'react-redux';
import { loginRequest } from 'actions/authentication';
 
class Login extends Component {
    render() {
        return (
            <div>
                <Authentication mode={true}/>
            </div>
        );
    }
}
 
const mapStateToProps = (state) => {
    return {
        status: state.authentication.login.status
    };
};
 
const mapDispatchToProps = (dispatch) => {
    return {
        loginRequest: (id, pw) => {
            return dispatch(loginRequest(id,pw));
        }
    };
};
 
 
export default connect(mapStateToProps, mapDispatchToProps)(Login);
cs


코드의 세 번째 줄에서 connect 함수를 불러왔습니다.

connect 함수를 통해 Login 컨테이너와 Redux 를 연결합니다.

이 때, mapStateToProps 와 mapDispatchToProps 로 리덕스 state 와 thunk 함수를 Login 컴포넌트로 들어온 props 처럼 사용할 수 있게 됩니다.


1-7) Login 컨테이너 컴포넌트에서 thunk 실행 메소드 정의, Authentication 컴포넌트로 전달


Redux 연결을 통해 Login 컴포넌트에서 thunk 를 프롭스처럼 사용할 수 있게 되었습니다. (this.props.loginRequest)

이 thunk 를 실행하는 메소드를 Login 컴포넌트 안에서 정의하여 Authentication 컴포넌트로 전달해 주도록하겠습니다.


(./src/containers/Login.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
import React, { Component } from 'react';
import { Authentication } from 'components';
import { connect } from 'react-redux';
import { loginRequest } from 'actions/authentication';
 
class Login extends Component {
 
    handleLogin = (id, pw) => {
        return this.props.loginRequest(id, pw).then(
            () => {
                if(this.props.status === "SUCCESS") {
                    // create session data
                    let loginData = {
                        isLoggedIn: true,
                        username: id
                    };
 
                    document.cookie = 'key=' + btoa(JSON.stringify(loginData));
 
                    Materialize.toast('Welcome, ' + id + '!'2000);
                    this.props.history.push('/');
                    return true;
                } else {
                    let $toastContent = $('<span style="color: #FFB4BA">Incorrect username or password</span>');
                    Materialize.toast($toastContent, 2000);
                    return false;
                }
            }
        );
    }
 
    render() {
        return (
            <div>
                <Authentication mode={true}
                  onLogin={this.handleLogin}/>
            </div>
        );
    }
}
 
const mapStateToProps = (state) => {
    return {
        status: state.authentication.login.status
    };
};
 
const mapDispatchToProps = (dispatch) => {
    return {
        loginRequest: (id, pw) => {
            return dispatch(loginRequest(id,pw));
        }
    };
};
 
 
export default connect(mapStateToProps, mapDispatchToProps)(Login);
cs


코드의 8~30 번째 줄에서 thunk 를 실행하는 메소드인 handleLogin 메소드를 정의 했습니다.

메소드의 내용은 thunk 를 실행한 뒤에 업데이트된 state (this.props.status) 값이 SUCCESS 이면 브라우저 쿠키에 로그인 데이터를 저장하고, 로그인 알림을 띄우고 메인 화면으로 이동시킵니다. 그리고 true 를 리턴합니다.

(쿠키에 저장할 때 객체를 만든 뒤, 해당 객체를 문자화(JSON.stringify 메소드) 시키고 base64 로 인코드한 뒤, 앞에 'key=' 를 붙여 저장합니다)

* 쿠키에 저장된 값을 이용하여 로그인 했는지 여부를 판단합니다.

* btoa 는 base64 로 인코드 하는 메소드입니다. 이에 대해 익숙치 않으신 분들은 아래의 링크를 참조해 주세요.

<base64 인코드, 디코드 메소드 btoa, atob>

만약 SUCCESS 가 아니면 로그인에 실패 했다는 메시지를 띄우고 false 를 리턴합니다.

* 메소드에서 리턴한 ture / false 값은 Authentication 컴포넌트의 비밀번호 입력창을 초기화 하는데 사용됩니다.


코드의 36 번째 줄에서 thunk 를 실행하는 메소드인 handleLogin 메소드를 Authentication 컴포넌트에 onLogin props 로 전달했습니다.


1-8) Authentication 컴포넌트에서 thunk 의 인자 결정하여 실행


Authentication 컴포넌트는 Login 컨테이너로부터 onLogin 속성으로 thunk 실행 메소드를 전달받았습니다.

Authentication 는 thunk 함수의 인자인 username 과 password 값을 결정해 실행하는 역할을 수행합니다.


이를 위해 input 창에 들어오는 값들을 Authentication 의 state(컴포넌트 state)로 설정하고, 이 state 를 통하여 onLogin 함수를 실행시키도록 하겠습니다. (회원가입과 비슷한 작업)


(./src/components/Authentication.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
 
class Authentication extends Component {
    state = {
      username:"",
      password:""
    }
 
    handleChange = (e) => {
        let nextState = {};
        nextState[e.target.name= e.target.value;
        this.setState(nextState);
    }
 
    handleRegister = () => {
        let id = this.state.username;
        let pw = this.state.password;
 
        this.props.onRegister(id, pw).then(
            (result) => {
                if(!result) {
                    this.setState({
                        username: '',
                        password: ''
                    });
                }
            }
        );
    }
 
    handleLogin = () => {
        let id = this.state.username;
        let pw = this.state.password;
 
        this.props.onLogin(id, pw).then(
            (success) => {
                if(!success) {
                    this.setState({
                        password: ''
                    });
                }
            }
        );
    }
 
    render() {
        const inputBoxes = (
            <div>
                <div className="input-field col s12 username">
                    <label>Username</label>
                    <input
                    name="username"
                    type="text"
                    className="validate"
                    onChange={this.handleChange}
                    value={this.state.username}/>
                </div>
                <div className="input-field col s12">
                    <label>Password</label>
                    <input
                    name="password"
                    type="password"
                    className="validate"
                    onChange={this.handleChange}
                    value={this.state.password}/>
                </div>
            </div>
        );
 
        const loginView = (
            <div>
                <div className="card-content">
                    <div className="row">
                        {inputBoxes}
                        <a className="waves-effect waves-light btn"
                          onClick={this.handleLogin}>SUBMIT</a>
                    </div>
                </div>
 
 
                <div className="footer">
                    <div className="card-content">
                        <div className="right" >
                        New Here? <Link to="/register">Create an account</Link>
                        </div>
                    </div>
                </div>
 
            </div>
        );
 
        const registerView = (
            <div className="card-content">
                <div className="row">
                    {inputBoxes}
                    <a className="waves-effect waves-light btn"
                      onClick={this.handleRegister}>CREATE</a>
                </div>
            </div>
        );
        return (
          <div className="container auth">
              <Link className="logo" to="/">MEMOPAD</Link>
              <div className="card">
                  <div className="header blue white-text center">
                      <div className="card-content">{this.props.mode ? "LOGIN" : "REGISTER"}</div>
                  </div>
                  {this.props.mode ? loginView : registerView }
              </div>
          </div>
        );
    }
}
 
Authentication.propTypes = {
    mode: PropTypes.bool,
    onRegister: PropTypes.func,
    onLogin: PropTypes.func
};
 
Authentication.defaultProps = {
    mode: true,
    onRegister: (id, pw) => { console.error("register function is not defined"); },
    onLogin: (id, pw) => { console.error("login function not defined"); }
};
 
export default Authentication;
cs


코드의 120, 126 번째 줄에서 Login 컨테이너로부터 전달받은 onLogin 프롭스의 propTypes 와 defaultProps 를 설정했습니다.


컨테이너 state 를 input 태그와 연동하여 (handleChange 메소드) thunk 실행 메소드에 들어갈 인자를 결정합니다.


thunk 를 실행하는 메소드는 코드의 33 번째 줄에서 정의했습니다. (handleLogin)

메소드를 실행하고 난 뒤 반환하는 값이 false 이면 비밀번호 입력창을 초기화 하는 코드를 추가 했습니다.


로그인 뷰의 SUBMIT 버튼을 누르면 handleLogin 메소드가 실행되도록 a 태그의 onClick 이벤트로 등록했습니다. (78 번째 줄)


여기까지 완료했다면 로그인 기능이 잘 구현되는지 확인해 보도록 하겠습니다.


npm run win_development

* mongoDB 서버도 열려있어야 합니다.


명령어를 프로젝트 파일 경로에서 입령해 dev 서버를 실행시키고, 브라우저를 통해 아래의 주소로 접속합니다. 


localhost:4000/login


회원가입을 했을 때 사용한 username 과 password 를 입력하고 SUBMIT 버튼을 눌러보세요.

어떤가요? 성공했다는 메시지와 함께 / 경로로 이동하나요? (해당 경로에는 어떤 화면도 렌더링 되지 않는 것이 정상)

'/register' 경로로 이동하거나 login 뷰에서 Create an account 버튼을 통해 회원가입 페이지로 이동해서 새로운 계정을 만들어보세요.

그리고 다시 로그인을 시도해 보세요.

어때요 제대로 작동하나요?


1-9) Authentication 에서 추가기능 구현


Authentication, 즉 로그인이나 회원가입을 할 때 비밀번호를 입력하는 창에서 엔터를 누르면 자동으로 회원가입 버튼이나 로그인 버튼이 눌린다면 좀 더 편리하겠죠?

그 기능을 구현해 보도록 하겠습니다.


(./src/components/Authentication.js)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
...
handleKeyPress = (e) => {
        if(e.charCode==13) {
            if(this.props.mode) {
                this.handleLogin();
            } else {
                this.handleRegister();
            }
        }
    }
...
    <input
    name="password"
    type="password"
    className="validate"
    onChange={this.handleChange}
    value={this.state.password}
    onKeyPress={this.handleKeyPress}/>
...
cs

password input 태그에 onKeyPress 이벤트를 주었습니다. (render->inputBoxes)
onKeyPress 이벤트는 해당 태그 안에서 어떤 키가 눌렸을 때 발생하는 이벤트입니다.

두 번째 줄에서 onKeyPress 이벤트에 등록할 handleKeyPress 메소드를 정의 했습니다.
인자로 들어온 e 는 이벤트 객체를 의미하며, e.charCode 는 사용자가 입력한 키의 코드 번호를 의미합니다.
e.charCode 가 13 이면 엔터키를 의미합니다. 
엔터키가 입력됐을 때 프롭스로 들어온 mode 가 true 이면 handleLogin 메소드를 실행하고 false 일 때는 handleRegister 를 실행합니다.
즉, 로그인 뷰 일때 password 인풋창 안에서 엔터키가 눌리면 로그인 thunk 를, 회원가입 뷰 일때 눌리면 회원가입 thunk 를 실행합니다.


2. 로그인 확인


이번에 구현해 볼 기능은 로그인 확인 기능입니다.

로그인 확인은 페이지가 새로고침 될 때 마다 현재 세션이 유효한지 체크하는 기능입니다.


2-1) 로그인 확인을 구현할 컨테이너 App


어플리케이션 안에서 새로고침은 어디서 일어날수 있을까요?

어떤 라우터(클라이언트 라우팅)에 걸리는 컴포넌트에 이 기능을 구현해야 할까요?

페이지의 새로고침은 어플리케이션의 어떤 부분에서도 일어날 수 있습니다. 

즉, 페이지의 모든 부분에서 작동하는 라우트가 필요합니다.

그 라우트의 경로는 바로 '/' 입니다.

모든 경로는 '/' 로 부터 시작되기 때문이죠.

('/login', '/register' 를 봐도 처음에 시작하는 문자는 '/' 이죠)


로그인 확인을 구현할 컴포넌트를 전역에서 작동하는 라우트인 '/' 에서 렌더링 하겠습니다.

그럼 어떤 컴포넌트가 렌더링 되야 할까요?



위의 그림과 같은 Header 컴포넌트는 어플리케이션의 어디에서든 존재합니다. 

(회원가입, 로그인 페이지를 제외하고)

그렇다면 '/' 라우터에서 App 컨테이너 컴포넌트를 렌더링하고, App 에서 Header 컴포넌트를 렌더링하도록 만들면 되겠군요.

(App 컨테이너에서 Header 컴포넌트를 렌더링할 때 회원가입, 로그인 페이지일때는 렌더링이 안되도록 설정하고요)


먼저 '/' 라우트에 렌더링 될 App 컨테이너를 생성해 보도록 하겠습니다.


(./src/containers/App.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
import React, { Component } from 'react';
 
class App extends Component {
    render(){
        return (
          <div>
            Header
          </div>
        );
    }
}
 
export default App;
cs


일단 Header 라는 문자를 렌더링하도록 설정했습니다.


containers 디렉토리의 index 에 App 을 추가해 주겠습니다.


(./src/containers/index.js)

1
2
3
4
5
import Register from './Register';
import Login from './Login';
import App from './App';
 
export { Register, Login, App };
cs


생성한 App 컨테이너를 '/' 라우터에 렌더링될 컴포넌트로 지정하겠습니다.


(./src/index.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter as Router, Route } from 'react-router-dom';
import { Register, Login, App } from 'containers'
 
import { Provider } from 'react-redux';
import { createStore, applyMiddleware } from 'redux';
import reducers from 'reducers';
import thunk from 'redux-thunk';
 
const store = createStore(reducers, applyMiddleware(thunk));
 
const title = 'Memo_App!';
 
ReactDOM.render(
  <Provider store={store}>
    <Router>
      <div>
        <Route path="/" component={App}/>
        <Route path="/register" component={Register}/>
        <Route path="/login" component={Login}/>
      </div>
    </Router>
  </Provider>
  ,
  document.getElementById('root')
);
 
module.hot.accept();
cs


코드의 4 번째 줄에서 App 컨테이너를 불러와 19 번째 줄에서 '/' 경로에 렌더링 되도록 설정했습니다.


그럼 서버를 재시작하여 어플리케이션의 전역에서 App 컨테이너의 렌더링에 적은 'Header' 가 잘 표시되는지 확인해 봅시다.





App 컨테이너의 렌더에서 적은 Header 글자가 어플리케이션 전역에서 잘 렌더링 되는것을 알 수 있습니다.

하지만 Header 는 register, login 뷰에서는 렌더링되면 안되므로 코드를 조금 변경해 주도록 합시다.


(.,/src/containers/App.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import React, { Component } from 'react';
 
class App extends Component {
    render(){
      /* Check whether current route is login or register using regex */
      let re = /(login|register)/;
      let isAuth = re.test(this.props.location.pathname);
 
      return (
        <div>
          {isAuth ? undefined : "Header"}
        </div>
      );
    }
}
 
export default App;
cs


코드의 6 번째 줄에서 정규표현식 패턴을 만들었습니다. 패턴의 내용은 login 혹은 register 라는 문자열 입니다.

7 번째 줄에서 위에서 만든 패턴을 url 에 검사하는 isAuth 를 만들었습니다.

url 에 login 이나 register 라는 글자가 있으면 isAuth 의 값은 참이 됩니다.

* 라우트에 렌더링되는 컴포넌트는 react-router-dom 에 의하여 history, location, match 프롭스를 전달받습니다. 이에 익숙하지 않으신 분들은 아래 링크의 3) 라우트 파라미터 읽기 부분을 참고해 주세요.

<리액트 라우터 Route 와 파라메터/쿼리>


코드의 11 번째 줄에서 isAuth 의 값에 따라 "Header" 라는 문자를 렌더링 할지 결정하는 코드를 적었습니다.

(ES6 의 삼항 조건 연산자를 이용)

* ES6 의 삼항 조건 연산자에 익숙하지 않으신 분들은 아래의 링크를 참조해 주세요.

<ES6 의 삼항 조건 연산자>


브라우저를 통해 다시 확인해 보세요. 

어떤가요? register / login 화면에서는 Header 라는 글자가 안 뜨고 '/' 에서만 제대로 렌더링되죠?


2-2) Header 컴포넌트


이제 App 에서 렌더링 될 Header 컴포넌트를 문자열이 아닌 제대로된 컴포넌트로 만들어 보도록 하겠습니다.

components 디렉토리에 Header.js 파일을 생성합니다.


(./src/components/Header.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import React, { Component } from 'react';
import { Link } from 'react-router-dom';
 
class Header extends Component {
 
    render() {
      const loginButton = (
          <li>
              <Link to="/login">
                  <i className="material-icons">vpn_key</i>
              </Link>
          </li>
      );
      const logoutButton = (
          <li>
              <a>
                  <i className="material-icons">lock_open</i>
              </a>
          </li>
      );
      return (
        <div>
          <nav>
              <div className="nav-wrapper blue darken-1">
                  <Link to ="/" className="brand-logo center">MEMOPAD</Link>
 
                  <ul>
                      <li><a onClick={this.toggleSearch}><i className="material-icons">search</i></a></li>
                  </ul>
 
                  <div className="right">
                      <ul>
                          { loginButton }
                          { logoutButton }
                      </ul>
                  </div>
              </div>
          </nav>
        </div>
      );
    }
}
 
export default Header;
cs


Materializecss 를 이용해 렌더링될 요소들을 디자인 했습니다.

이 때, 로그인 여부에 따라 Header 의 오른쪽 부분에 로그인 버튼을 보여주거나 로그아웃 버튼을 보여줄 것이므로 코드의 7, 14 번째 줄에서각각 따로 정의 했습니다. (react-router-dom 의 Link 컴포넌트를 이용해 해당 버튼을 누르면 새로고침없이 해당 뷰로 갈 수 있도록 했습니다)

일단은 Header 컴포넌트 내에서 로그인여부를 확인할 수 없기 때문에 33, 34 번째 줄에 로그인, 로그아웃 버튼을 렌더링 했습니다.


Header 컴포넌트를 components 인덱스에 추가해 줍니다.


(./src/components/index.js)

1
2
3
4
import Authentication from './Authentication';
import Header from './Header';
 
export { Authentication, Header };
cs


App 컨테이너에서 Header 컴포넌트를 렌더링하도록 하겠습니다.


(./src/containers/App.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import React, { Component } from 'react';
import { Header } from 'components';
 
class App extends Component {
    render(){
      /* Check whether current route is login or register using regex */
      let re = /(login|register)/;
      let isAuth = re.test(this.props.location.pathname);
 
      return (
        <div>
          {isAuth ? undefined : <Header /> }
        </div>
      );
    }
}
 
export default App;
cs


코드의 12 번째 줄에서 isAuth 값이 false 면(url 에 register/login 글자가 포함되어 있지 않다면) Header 컴포넌트를 렌더링하겠다고 했습니다.


서버를 실행시켜 확인해 보도록 하겠습니다.



Header 컴폰너트가 잘 렌더링 되는 것을 알 수 있습니다.

Header 컴포넌트에 있는 로그인 버튼을 한 번 눌러보세요. 제대로 '/login' 경로로 들어가지나요?

(안된다면 Header 컴포넌트의 Link 컴포넌트 부분을 확인해 보세요)


2-3) 로그인 확인 기능의 작동원리


2-1, 2-2 의 과정을 통해 어플리케이션의 전역에서 동작하는 App 컴포넌트를 만들었습니다.

이제 App 컴포넌트에 로그인 확인 기능을 구현해 보도록 하겠습니다.

로그인 확인 기능이 동작하는 원리는 다음 그림과 같습니다.



BACK-END API (GET: /api/account/getinfo) 에서는 요청이 들어오면 req.session.loginInfo 가 있는지 확인합니다. (로그인 성공시 세션에 저장한 객체) 확인 결과 없을 경우 에러 객체를, 있을 경우 성공객체를 리턴합니다.

FRONT-END Redux 의 리듀서는 전달받은 action 객체의 type 값에 따라 state 를 변경합니다. 로그인 확인이 성공하면 state.authetication.status.currentUser 값을 API 로부터 전달받은 객체의 username 값으로 변경하고, state.authetication.status.valid 값은 true 로 변경합니다.

App 컨테이너 컴폰넌트에 Redux 에서 정의한 thunk 함수와 state 를 연결합니다.

App 컴폰넌트는 어플리케이션의 전역에서 작동하는 함수로, 새로고침할 때 마다 thunk 를 실행합니다.

새로고침은 리액트 컴포넌트의 component life cycle 중 componentDidMount 에 해당합니다. (가장 처음 렌더링 됐을 때)

* 리액트의 component life cycle 에 대해서 익숙하지 않으신 분들은 아래의 링크를 참고해주세요.

<리액트의 component life cycle>


데이터의 이동은 다음과 같습니다.


1. 리덕스에서 정의한 thunk (BACK-END와 통신후 state 변경) 함수와 state 를 App 컴포넌트에 전달 (connect)

2. App 컴포넌트가 새로고침 될 때 (componentDidMount) thunk 를 실행

3. BACK-END 에서 세션을 확인 후 세션이 존재하면 세션을 값으로 하는 info 필드를 가진 객체를 리턴 (없다면 에러객체)

4. 리듀서에서 state 를 변경 

5. 세션이 유효하지 않다는 state 를 전달받으면 쿠키를 업데이트하고, 세션이 만료되었다고 알림


2-4) BACK-END API 구현


로그인 기능을 구현할 때, BACK-END API 에서 로그인에 성공하면 req.session.loginInfo 에 { _id: DB상의 _id값, username: 로그인 요청 username } 의 객체를 저장했습니다. (1-1 BACK-END API 구현에서)

따라서 세션확인 기능은 req.session.loginInfo 에 값이 있는지를 확인하면 됩니다.


(./server/routes/accounts.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/*
    GET CURRENT USER INFO GET /api/account/getInfo
    ERROR CODES:
        1: THERE IS NO LOGIN DATA
*/
router.get('/getinfo', (req, res) => {
    if(typeof req.session.loginInfo === "undefined") {
        return res.status(401).json({
            error: "THERE IS NO LOGIN DATA",
            code: 1
        });
    }
 
    res.json({ info: req.session.loginInfo });
});
cs


GET: /api/account/getInfo 로 BACK-END 에 접근하면 코드의 7 번째 줄과 같이 req.session.loginInfo 값이 있는지 확인합니다.

만약 없으면 HTTP status 401 과 함께 에러 객체를 리턴합니다.

* HTTP status 401 은 권한없음을 의미합니다. (인증필요, 인증 안됨)

req.session.loginInfo 값이 존재한다면 info 필드의 값으로 req.session.loginInfo 객체를 가진 객체를 리턴(리스폰스) 합니다.


2-5) Redux 구현


2-5-1) ActionType 추가


ActionTypes.js 에 로그인 확인 관련 액션타입을 추가해 줍니다.


(./src/actions/ActionTypes.js)

1
2
3
4
// Check sessions
export const AUTH_GET_STATUS = "AUTH_GET_STATUS";
export const AUTH_GET_STATUS_SUCCESS = "AUTH_GET_STATUS_SUCCESS";
export const AUTH_GET_STATUS_FAILURE = "AUTH_GET_STATUS_FAILURE";
cs


2-5-2) thunk 함수와 액션 생성자 함수 구현


thunk 함수는 백엔드 API 와 통신하고 그 결과에 따라 다른 액션객체를 리듀서에 전달하는 역할을 합니다.


(./src/actions/authetication.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
...
import {
    ...
    AUTH_GET_STATUS,
    AUTH_GET_STATUS_SUCCESS,
    AUTH_GET_STATUS_FAILURE
}
...
/* GET STATUS */
export function getStatusRequest() {
    return (dispatch) => {
        // inform Get Status API is starting
        dispatch(getStatus());
 
        return axios.get('/api/account/getInfo')
        .then((response) => {
            dispatch(getStatusSuccess(response.data.info.username)); //HTTP 틍신을 통해 username을 빋이옴
        }).catch((error) => {
            dispatch(getStatusFailure());
        });
    };
}
 
export function getStatus() {
    return {
        type: AUTH_GET_STATUS
    };
}
 
export function getStatusSuccess(username) {
    return {
        type: AUTH_GET_STATUS_SUCCESS,
        username
    };
}
 
export function getStatusFailure() {
    return {
        type: AUTH_GET_STATUS_FAILURE
    };
}
cs


코드의 4~6 번째 줄에서 액션타입을 import 합니다.


코드의 24, 30, 37 번째 줄은 액션생성자 함수로, 각각 정보확인 진행중, 성공, 실패를 알리는 객체를 리턴합니다.

정보확인 성공 객체를 리턴하는 함수는 인자로 username 을 받고, 리턴하는 액션객체의 username 필드에 인자로 들어온 값을 값(value)로 취합니다.


코드의 10 번째 줄은 thunk 함수로, 먼저 정보확인 요청을 진행한다는 액션객체를 리듀서로 보내고(13 번째 줄), 

그 결과가 성공 했을 때 action.username 값이 API 가 리턴한 객체의 username 인 객체를 리듀서로 보냅니다.

만약 에러 객체를 리턴 받으면 action.type 값이 "AUTH_GET_STATUS_FAILURE" 인 객체를 리듀서로 전달합니다.


2-5-3) 리듀서


리듀서는 dispatch 함수를 통해 전달받은 action 객체의 type 값에 따라 리덕스 state 를 다르게 변경합니다.


(./src/reducers/authetication.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/* CHECK SESSIONS */
    case types.AUTH_GET_STATUS:
        return {
          ...state,
          status: {
            ...state.staus,
            isLoggedIn: true
          }
        }
    case types.AUTH_GET_STATUS_SUCCESS:
        return {
          ...state,
          status: {
            ...state.status,
            valid: true,
            currentUser: action.username
          }
        }
    case types.AUTH_GET_STATUS_FAILURE:
        return {
          ...state,
          status: {
            ...state.status,
            valid: false,
            isLoggedIn: false
          }
        }
cs


switch 조건문에 case 를 추가해줍니다.

status.valid 는 세션이 유효할 때 ture 값을 가지고, 만료되었거나 비정상적이면 false 값을 가집니다.

status.isLoggedIn 키는 로그인 상태를 의미하는데, AUTH_GET_STATUS 때는 true 로 설정했습니다. (처음 세션 확인을 요청한 상태)

이는 세션확인 AJAX 요청(axios 를 통한 HTTP 통신)이 끝날 때까지 미세한 시간동안 깜빡이는 것을 방지하기 위해서 입니다.

(요청을 시작할 때 로그인 상태로 인식하게 하고, 유효하면 그대로 두고 유효하지 않으면 false 상태로 변경합니다)


2-6) App 컨테이너에 Redux 연결


App 컨테이너 컴포넌트에 Redux 를 연결해 위에서 정의한 thunk 함수와 리덕스 state 사용할 수 있도록 하겠습니다.


(./src/containers/App.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import React, { Component } from 'react';
import { Header } from 'components';
import { connect } from 'react-redux';
import { getStatusRequest } from 'actions/authentication';
 
class App extends Component {
    render(){
      /* Check whether current route is login or register using regex */
      let re = /(login|register)/;
      let isAuth = re.test(this.props.location.pathname);
 
      return (
        <div>
          {isAuth ? undefined : <Header /> }
        </div>
      );
    }
}
 
const mapStateToProps = (state) => {
    return {
        status: state.authentication.status
    };
};
 
const mapDispatchToProps = (dispatch) => {
    return {
        getStatusRequest: () => {
            return dispatch(getStatusRequest());
        }
    };
};
 
export default connect(mapStateToProps, mapDispatchToProps)(App);
cs


코드의 3 번째 줄에서 react-redux 의 connect 함수를 import 했습니다.

4 번째 줄에서 Redux 에서 구현한 thunk 인 getStatusRequest  함수를 import 하고, 20-34 번째 줄을 통해 thunk 함수와 Redux state 를 props 처럼 사용할수 있도록 connect 했습니다.


2-7) 페이지 새로고침 될 때 thunk 실행


App 컨테이너는 '/' 라우트에 렌더링되는 컨테이너로, 어플리케이션의 전역에서 작동하는 컴포넌트입니다.

이 App 컴포넌트가 새로고침 될 때 thunk (this.props.getStatusRequest) 함수가 실행되도록 코드를 작성해 보겠습니다.

* 페이지가 새로고침 완료된 후는 component lifecycle 중 componentDidMount 를 의미합니다. 이에 익숙하지 않으신 분들은 아래의 링크를 참조해 주세요.

<리액트의 component life cycle>


아래의 코드를 App 컨테이너의 class 안에 포함시켜 주세요. (render() 메소드 이전에 추가)


(./src/containers/App.js)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
componentDidMount() { //컴포넌트 렌더링이 맨 처음 완료된 이후에 바로 세션확인
      // get cookie by name
      function getCookie(name) {
          var value = "; " + document.cookie; 
          var parts = value.split("; " + name + "="); 
          if (parts.length == 2return parts.pop().split(";").shift();
      }
 
      // get loginData from cookie
      let loginData = getCookie('key');
 
      // if loginData is undefined, do nothing
      if(typeof loginData === "undefined"return;
 
      // decode base64 & parse json
      loginData = JSON.parse(atob(loginData));
 
      // if not logged in, do nothing
      if(!loginData.isLoggedIn) return;
 
      // page refreshed & has a session in cookie,
      // check whether this cookie is valid or not
      this.props.getStatusRequest().then(
          () => {
              // if session is not valid
              if(!this.props.status.valid) {
                  // logout the session
                  loginData = {
                      isLoggedIn: false,
                      username: ''
                  };
 
                  document.cookie='key=' + btoa(JSON.stringify(loginData));
 
                  // and notify
                  let $toastContent = $('<span style="color: #FFB4BA">Your session is expired, please log in again</span>');
                  Materialize.toast($toastContent, 4000);
              }
          }
      );
  }
cs


* 전체적인 코드의 내용은 모두 컴포넌트가 처음 렌더링 된 이후에 작동합니다.


코드의 3 번째 줄에서 함수 getCookie 를 정의 했습니다. getCookie 함수의 내용은 다음과 같습니다.

document.cookie 앞에 "; " 문자를 붙여 value 라는 변수에 담습니다.

그리고 value 값을 "; "+인자로 들어온 값+"=" 으로 나눠서 parts 변수에 담습니다.. (문자열을 split의 인자로 나누어 배열로 리턴)

만약 parts 의 길이가 2라면 (value 값 안에 "; "+인자로 들어온 값+"=" 가 있어서 나눠짐 / 없다면 길이가 1인 배열리턴) 마지막 원소를 제거하고 제거된 원소(리턴값)를 다시 ';' 로 나누고 나눈 배열중에서 앞의 원소를 제거한 리턴값을 리턴합니다. 

* split 메소드에 익숙하지 않으신 분들은 아래의 링크를 참조해주세요.

<문자열을 분해하는 split 메소드>

* pop(), shift() 메소드에 익숙하지 않으신 분들은 아래의 링크를 참조해 주세요. (리턴값이 체이닝 됩니다.)

<배열에 원소를 추가 및 제거하는 네 가지 방법, unshift(), push(), shift(), pop()>


* 위의 getCookie 함수를 예를 들면 다음과 같습니다.

1
2
3
4
5
6
7
8
9
var value = "...; key=abcdefg; date=2018-06-25; ...";
var parts = value.split("; key=");
// (2) ["...", "abcdefg; date=2018-06-25; ..."]
var result = parts.pop().split(";").shift();
// "abcdefg"
 
// parts.pop()의 리턴 값: "abcdefg; date=2018-06-25; ..."
// "abcdefg; date=2018-06-25; ..." 를 split(";") 한 리턴 값: (3) ["abcdefg", " date=2018-06-25", " ..."]
// ["abcdefg", " date=2018-06-25", " ..."] 를 shift() 한 리턴값: "abcdefg"
cs


즉, getCookie 함수는 인자로 들어오는 값의 내용을 알아내는 함수입니다. (위의 예제에서는 key 값의 내용을 알아냄)


코드의 10 번째 줄에서 getCookie 함수를 이용하여 key 에 할당된 값을 알아내 loginData 변수에 담았습니다.

(로그인할 때 로그인 정보객체를 base64 인코드하여 쿠키에 key= 에 할당한 바 있었습니다)


코드의 13 번째 줄에서 loginData 가 없으면 return 했습니다. (componentDidMount 종료)

이는 로그인한 적이 없다는 것을 의미합니다. (로그인할 때 로그인 정보객체를 base64 인코드하여 쿠키에 key= 에 할당)


코드의 16 번째 줄에서 loginData 를 base64 디코드 하였습니다. (인코드 돼 있는 상태의 데이터)

그리고 19 번째 줄에서 디코드된 데이터 (객체 isLoggedIn 필드를 가진) 의 isLoggedIn 의 값이 false 이면 return 합니다.

(componentDidMount 종료) (로그아웃 했거나 세션이 유효하지 않을 때 쿠키의 isLoggedIn 값을 false 로 설정)


위의 코드까지 실행됐는데 return 되지 않았다면(로그인을 한적 없지 않거나, 로그아웃하지 않거나/세션에 문제가 없을 경우) 23 번째 줄을 통해 세션을 확인합니다. 세션 확인은 리덕스에서 정의한 thunk 함수를 실행해 BACK-END 에서 세션의 유효성을 검사합니다.

thunk 를 통해 state 가 변경된 시점에서 (.then) 세션이 유효하지 않다면 (status.valid 의 값이 false) 쿠키에 key 값을 로그아웃 상태로 변경하고, 사용자에게 세션이 만료되었으니 다시 로그인하라고 메시지를 띄웁니다.


2-8) App 컨테이너에서 Header 컴포넌트로 로그인정보 전달


2-7) 까지 완료했다면 어플리케이션의 전역에서 새로고침 했을 때 세션의 유효성을 검사합니다.


현재 Header 컴포넌트의 오른쪽에는 로그인 버튼, 로그아웃 버튼이 표시되고 있습니다.



위와 같이 말이죠. 


하지만 로그인 여부에 따라 버튼을 하나씩만 보이게 해 보도록 하겠습니다.

로그인을 한 상태에서는 로그아웃 버튼이 보이게, 로그인을 하지 않은 상태에선 로그인 버튼을 보이게 말이죠.


이를 위해 App 컨네이너에서 Header 컴포넌트를 렌더링 할 때, 로그인 정보를 props 로 넘겨주고, Header 컴포넌트에서는 이를 이용해 어떤 버튼을 렌더링 할지 결정하도록 합니다.


(./src/containers/App.js)

1
2
3
4
5
...
<div>
    {isAuth ? undefined : <Header isLoggedIn={this.props.status.isLoggedIn}/> }
</div>
...
cs


App 컨테이너의 render() 메소드의 return 부분을 위와 같이 수정합니다.


(./src/components/Header.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import React, { Component } from 'react';
import { Link } from 'react-router-dom';
import PropTypes from 'prop-types';
 
class Header extends Component {
 
    render() {
      const loginButton = (
          <li>
              <Link to="/login">
                  <i className="material-icons">vpn_key</i>
              </Link>
          </li>
      );
      const logoutButton = (
          <li>
              <a>
                  <i className="material-icons">lock_open</i>
              </a>
          </li>
      );
      return (
        <div>
          <nav>
              <div className="nav-wrapper blue darken-1">
                  <Link to ="/" className="brand-logo center">MEMOPAD</Link>
 
                  <ul>
                      <li><a onClick={this.toggleSearch}><i className="material-icons">search</i></a></li>
                  </ul>
 
                  <div className="right">
                      <ul>
                        { this.props.isLoggedIn ? logoutButton : loginButton }
                      </ul>
                  </div>
              </div>
          </nav>
        </div>
      );
    }
}
 
Header.propTypes = {
    isLoggedIn: PropTypes.bool
};
 
Header.defaultProps = {
    isLoggedIn: false
};
 
export default Header;
 
cs


props 를 전달 받았으므로 propTypes 와 defaultProps 를 설정해 줍니다. (코드의 44~50 번째 줄) (PropTypes 불러오는 코드는 3 번째 줄)


코드의 34 번째 줄에서 전달받은 로그인 여부에 따라 logout 버튼을 렌더링하거나 login 버튼을 렌더링 할지 결정합니다.


로그인 상태에서 서버를 재시작하고 새로고침을 해 세션확인이 제대로 동작하는지, Header 컴포넌트에 로그인/로그아웃 버튼이 로그인여부에 따라 제대로 렌더링 되는지 확인해 봅시다.

어때요 잘 동작하나요?


* 로그인 확인 (세션을 통해)을 간단히 정리하자면...

로그인 하지 않았으면 쿠키에 key 값이 없다 

로그아웃하면 쿠키에 isLoggedIn 값이 false이다

(로그인 한적 없거나 로그아웃 했다면 쿠키를 통해 파악할 수 있다 - 굳이 속도가 느린 서버측의 세션을 확인할 필요없이)

두 가지 경우가 아니라면 실질적으로 로그인된 상태인지 확인해야하고 그 과정은 서버측의 세션을 체크하는것을 통해 이루어진다.

확인하고 만료되었으면 쿠키를 수정하고 다시 로그인하라고 알린다


3. 로그아웃


이번에 구현할 기능은 로그아웃 기능입니다.

위의 intro 에서 실질적으로 로그인여부를 확인할 수 있는것은 session 이라고 했습니다.

즉, session 에 로그인 정보가 기록되어 있으면 로그인되어 있는 상태입니다.


그렇다면 로그아웃을 하려면 어떡해야할까요?

방법은 생각보다 간단합니다. session 을 파괴하면 됩니다.



작동하는 원리는 위의 그림과 같습니다.


  1. 리덕스에서 정의한 thunk (내용은 API 와 통신하고 결과에 따라 state 변경) 와 state 를 App 컴포넌트에 전달(connect)하고,
  2. App 컴포넌트에서는 thunk 실행 메소드를 만들고 Header 컴포넌트로 전달, 
  3. Header 컴포넌트에서는 로그아웃 버튼을 통해 thunk 함수를 실행,
  4. (App 컴포넌트를 거쳐)
  5. BACK-END API 에서는 thunk 로부터 접근(POST: /api/account/logout)한 요청을 수행(세션파괴),
  6. 리덕스 스테이트는 state를 변경(state.authentication.status.crrentUser 값은 비우고 state.authetication.status.isLoggedIn 값은 false 로 변경)
  7. state 가 변경된 이후에 쿠키값을 변경 { isLoggedIn: false, username: '' }

3-1) BACK-END API 구현

가정먼저 로그아웃의 요청을 받아 세션을 파괴하는 역할을 하는 서버사이드 API 를 작성해 보도록 하겠습니다.

(./server/routes/account.js)
1
2
3
4
5
6
7
8
/*
    LOGOUT: POST /api/account/logout
*/
router.post('/logout', (req, res) => {
    // req.session.destroy() 메소드로 세션을 파괴
    req.session.destroy(err => { if(err) throw err; });
    return res.json({ sucess: true });
});
cs


POST: /api/account/logout 으로 들어온 HTTP 요청을 처리하는 라우터 입니다.

코드의 6 번째 줄의 req.session.destroy() 메소드를 이용하면 세션을 파괴할 수 있습니다.

세션을 파괴한 이후에 { success: ture } 객체 (JSON) 를 리턴합니다.


3-2) Redux 구현


FRONT-END 의 리덕스를 구현해 보도록 하겠습니다.


3-2-1) 액션타입 추가


가장 먼저 ActionTypes 에 로그아웃 관련 액션타입을 추가해 주겠습니다.


(./src/actions/ActionTypes.js)

1
2
3
...
// Logout
export const AUTH_LOGOUT = "AUTH_LOGOUT";
cs


3-2-2) thunk 및 액션생성자 함수 생성


HTTP 요청에 따라 다른 액션 객체를 리듀서에 보내는 역할을 하는 thunk 함수와 액션객체를 리턴하는 액션생성자들을 만들어 주겠습니다.


(./src/actions/authentication.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import {
    ...,
    AUTH_LOGOUT
} from './ActionTypes';
 
...
 
/* Logout */
export function logoutRequest() {
    return (dispatch) => {
        return axios.post('/api/account/logout')
        .then((response) => {
            dispatch(logout());
        });
    };
}
 
export function logout() {
    return {
        type: AUTH_LOGOUT
    };
}
cs


코드의 세 번째 줄과 같이 2-2-1) 에서 생성한 ActionType 을 import 해 줍니다.

코드의 18 번째 줄은 action.type 값이 "AUTH_LOGOUT" 인 객체를 리턴하는 액션생성자 함수입니다.

9 번째 줄은 thunk 함수로, axios 를 통해 BACK-END API 와 통신해 세션을 파괴한 이후에 액션생성자 함수를 실행하는 함수입니다.


3-2-3) 리듀서 (authentication.js)


dispatch 로 전달받은 action 객체의 type 값에 따라 state 를 변경하는 코드를 추가합니다.


(./src/reducers/authetication.js)

1
2
3
4
5
6
7
8
9
10
11
12
...
    /* LOGOUT */
    case types.AUTH_LOGOUT:
        return {
          ...state,
          status: {
            ...state.status,
            isLoggedIn: false,
            currentUser: ''
          }
        }
...
cs


리듀서가 전달받은 action 객체의 type 값이 "AUTH_LOGOUT" 이라면 state.authentication.status.isLoggedIn 값은 false 로, state.authentication.status.currentUser 의 값은 빈 문자열로 변경합니다.

* 로그인에 성공했을 때 authetication 리듀서는 state.authentication.status.isLoggedIn 값은 true 로, state.authentication.status.currentUser 의 값은 로그인 요청한 유저의 username 으로 변경했었습니다.


3-3) App 컴포넌트로 로그아웃 thunk 전달


App 컴포넌트에서 로그아웃을 동작 할 수 있도록 logoutRequest (thunk) 를 연결하겠습니다.


(./src/containers/App.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
...
import { getStatusRequest, logoutRequest } from 'actions/authentication';
 
...
 
const mapDispatchToProps = (dispatch) => {
    return {
        getStatusRequest: () => {
            return dispatch(getStatusRequest());
        },
        logoutRequest: () => {
            return dispatch(logoutRequest());
        }
    };
};
 
export default connect(mapStateToProps, mapDispatchToProps)(App);
cs


코드의 2 번째 줄에서 logoutRequest thunk 를 import 하여 11 번째 줄에서 mapDispatchToProps 를 하여 App 컨테이너에서 props 처럼 사용할 수 있도록 하였습니다.


3-4) App 컨테이너에서 thunk 실행 메소드 정의해서 Header 컴포넌트로 전달


App 컨테이너에서 Header 컴포넌트에서 사용할 (로그아웃 버튼을 클릭할 시) thunk 실행 메소드를 정의해 Header 컴포넌트에 onLogout props 로 넘겨줍니다.


(./src/containers/App.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
...
    handleLogout = () => {
        this.props.logoutRequest().then(
            () => {
                Materialize.toast('Good Bye!'2000);
 
                // EMPTIES THE SESSION
                let loginData = {
                    isLoggedIn: false,
                    username: ''
                };
 
                document.cookie = 'key=' + btoa(JSON.stringify(loginData));
            }
        );
    }
...
    render(){      
      /* Check whether current route is login or register using regex */
      let re = /(login|register)/;
      let isAuth = re.test(this.props.location.pathname);
 
      return (
        <div>
          {isAuth ? undefined : <Header isLoggedIn={this.props.status.isLoggedIn}
                                        onLogout={this.handleLogout}/> }
        </div>
      );
    }
...
cs


thunk 실행을 통해 서버의 세션을 파괴하고 리덕스 state 를 변경한 뒤에 사용자에게 'Good Bye!' 라는 알림창을 보냅니다. (5 번째 줄)

그리고 8~13 번째 줄에서 세션이 유효하지 않을 때와 마찬가지로 쿠키를 업데이트 해 줍니다.


25번째 줄에서 Header 컴포넌트로 handleLogout 메소드를 onLogout props 로 전달합니다.


3-5) Header 컴포넌트에서 전달받은 thunk 실행 메소드 사용


Header 컴포넌트에서 App 컨테이너로부터 전달받은 onLogout 메소드를 로그아웃 버튼의 onClick 이벤트로 지정하여 로그아웃 버튼이 눌렸을 때 로그아웃이 진행될 수 있도록 합니다.


(./src/components/Header.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
...
    const logoutButton = (
          <li>
              <a onClick={this.props.onLogout}>
                  <i className="material-icons">lock_open</i>
              </a>
          </li>
      );
...
Header.propTypes = {
    isLoggedIn: PropTypes.bool,
    onLogout: PropTypes.func
};
 
Header.defaultProps = {
    isLoggedIn: false,
    onLogout: () => { console.error("logout function not defined");}
};
...
cs


props 를 전달 받았으므로 위의 12, 17 번째 줄과 같이 propTypes 와 defaultProps 를 지정해줍니다.

그리고 전달받은 메소드를 4 번째 줄과 같이 로그아웃 버튼(a태그)의 onClick 메소드로 지정합니다.


여기까지...


회원가입을 제외한 인증의 나머지 기능을 구현해 보았습니다.

꽤 긴 포스팅이었고 여러 개념들이 복합적으로 있어서 어려우셨을 수도 있었지만(중구난방식의 설명도 한 몫했으리라 생각합니다...) 여기까지 잘 따라와 주셔서 감사합니다.


인증에서 쿠키와 세션을 통해 로그인 상태를 유지하고 확인 하는 등의 작업을 했다는것을 기억해 주시고, 쿠키와 세션이 어떻게 사용됐는지 정리하시면 여러분들의 어플리케이션을 만들때 도움이 많이 되리라 생각합니다.


다음 포스팅에서 부터는 메모 어플리케이션의 CRUD 를 작성해 보도록하겠습니다.


**참고 자료 (항상 감사드립니다)

<React.js codelab 2016 - Velopert>


*이 포스팅이 도움이 되셨다면 다녀가셨다는 표시로 공감 부탁드릴게요! (로그인 하지 않으셔도 공감은 가능합니다 ㅎㅎ)

Comments