Node.js 가볍게 배워보기(로그인 구현)

짜스의 하루 ·2024년 7월 11일

JWT 토큰

JWT (JSON Web Token)는 클라이언트와 서버간에 정보를 안전하게 전달하기 위한 간편 방법 중에 하나라고 한다.
헤더(Header), 페이로드(payload), 서명(sign) 세 부분으로 구성된 토큰이다. 헤더에는 토큰의 타입과 서명에 사용되는 hashing 알고리즘 정보가 담겨있고, 페이로드는 정보가 포함된다.
서버는 JWT를 생성하여 클라이언트에게 발급하고, 클라이언트는 JWT를 이용하여 인증 작업을 진행할 수 있다.

이러한 JWT기반의 인증은 JWT를 HTTP 헤더에 실어 서버가 클라이언트를 식별하는 방법이다.

JWT를 통한 인증 과정


간단하게 이렇게 이루어진다고 생각하면 된다.

사용자가 로그인 정보를 담아서 서버로 로그인을 요청하면, 서버는 전달받은 정보를 확인하여 올바른 사용자 정보의 경우, Access Token과 Refresh Token을 발급하게 된다.

여기서 Access Token과 Refresh Token이란?

  • Access Token : 사용자를 인증하고, 리소스에 접근할 권한을 부여하는 토큰
  • Refresh Token : Access Token의 갱신을 위한 Token으로 Access Token이 만료되었을 때, Refresh Token이 만료되지 않았다면, 이의 정보를 이용하여 Access Token을 발급 받을 수 있다

(어렵다 백엔드 ...)

JWT 생성해보기

npm install jsonwebtoken 을 설치하면 된다.
jwt를 생성하기 위한 secretKey를 설정해야 하는데, 이는 .env 파일에 저장하여 사용하는 것이 보편적이라고 한다.

secretKey를 설치하는 이유는 ?

JWT를 생성하고 검증하는ㄷ 있어서 가장 중요한 역할을 하게 된다
1 . secretKey를 사용하여 토큰을 서명함으로써, 토큰의 내용을 변경하거나 위조할 수 없도록 한다.
2 . 서버는 클라이언트로부터 받은 JWT를 검증하여 토큰이 변조되지 않았음을 확인하고, 이 과정에서 secretKey를 사용하여 서명을 한다.


나또한 이렇게 지정을 해주었다.

이제 JWT를 활용하여 로그인을 구현해보도록 하겠다

Login 구현 (JWT)

app.post('/login', async (req, res) => {
  try {
    const { email, password } = req.body;
    const user = await User.findOne({ email });

    if (!user) {
      console.log('이메일을 찾을 수 없습니다.');
      return res.status(404).send('이메일을 찾을 수 없습니다.');
    }

    const isMatch = await bcrypt.compare(password, user.password);
    if (!isMatch) {
      console.log('비밀번호가 일치하지 않습니다.');
      return res.status(400).send('비밀번호를 잘못 입력하였습니다.');
    }

    const token = jwt.sign({ id: user._id }, process.env.JWT_SECRET, {
      expiresIn: '1h',
    });
    res.type('application/json');
    return res.status(200).json({ token });
  } catch (e) {
    console.error('로그인 오류:', e.message);
    return res.status(500).send('Error logging in: ' + e.message);
  }
});
  • 우선 /login 경로에 대한 POST 요청을 처리하는 라우터 핸들러를 정의했다.
  • 요청 본문 측 클라이언트에서 받아온 body에서 email, password를 추출한다.
  • User 모델을 사용해서 데이터베이스에서 해당 이메일을 가지고 있는 사용자를 찾아온다.
    --> User를 이용해 사용자 존재 여부를 확인한다
  • 사용자가 있다면, 비밀번호를 비교하게 되는데, bcrypt 라이브러리를 사용하여 입력된 비밀번호와 데이터베이스에 저장된 해시된 비밀 번호를 비교한다
    (compare(password, user.password)).
  • 비밀번호까지 일치한다면, JWT 생성 을 하게 된다
const token = jwt.sign({ id: user._id }, process.env.JWT_SECRET, {
  expiresIn: '1h',
});

jsonwebtoken 라이브러리를 사용하여 JWT를 생성한다. 토큰은 사용자 ID(user._id)를 페이로드에 포함하고, 환경변수 JWT-SECRET를 사용하여 서명하게 된다. 토큰은 1시간동안 유효하다!

  • 응답 타입은 'application/json'으로 설정해주었으며,
  • 생성된 JWT를 JSON 형식으로 클라이언트에게 반환해주면 된다!

이후, 로그인 이후 인증이 필요한 라우트들에 loginAuth 미들웨어 적용한다.

app.use(loginAuth);

왜 ?
--> 보호된 라우트에 접근하려는 사용자가 유효한 인증 토큰을 가지고 있는지를 확인하기 위함이다. 간단하게 설명하자면, 사용자가 로그인하여 JWT토큰을 받았는지 확인을 하고, 그 토큰이 유효한지를 검증한다 (사용자가 인증된 상탱서만 특정 라우터에 접근할 수 있도록 한다)

토큰이 없는 사용자나 유효하지 않은 토큰을 가진 사용자가 보호된 리소스에 접근하지 못하도록 막을 수 있게 된다. 이를 통해 시스템의 무단 접근을 방지할 수 있다.

  • loginAuto.js
const jwt = require('jsonwebtoken');
const dotenv = require('dotenv');
dotenv.config();

module.exports = (req, res, next) => {
  const token =
    req.header('Authorization') &&
    req.header('Authorization').replace('Bearer ', '');
  if (!token) {
    return res.status(401).send('Access denied');
  }

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);

    req.user = decoded;
    next();
  } catch (error) {
    console.error('Invalid token:', error.message);
    // Content-Type을 application/json으로 설정하여 JSON 형식의 응답을 보냄
    res.type('application/json').status(400).json({ error: 'Invalid token' });
  }
};
  • 토큰 추출 :
    const token = req.header('Authorization') && req.header('Authorization').replace('Bearer ', '');
    요청 헤더에서 Authorization 헤더를 읽고, "Bearer" 접두사를 제거하여 토큰만 추출한다.
    (HTTP 요청에서 Authorization 헤더는 클라이언트가 서버에 인증 정보를 제공하는 데 사용, Bearer 토큰를 클라이언트는 JWT(또는 다른 토큰)를 서버에 전달하여 자신을 인증할 때 사용)
  • 토큰 검증 :
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    jwt.verify 함수를 사용하여 토큰을 검증한다. 검증에 성공하면, 디코딩된 토큰의 페이로드를 req.user에 저장한다.
app.use(loginAuth);

// 이후에 다른 라우트들 작성
app.get('/home', (req, res) => {
  res.send('This is a protected route');
});

이렇게 적용하게 되면, /home fkdnxmsms loginAuth 미들웨어가 적용된 보호된 라우트이다. (라우트에 접근하려면 유효한 JWT 토큰이 필요하다)
--> 간단하게 설명하면 회원가입을 완료하고, 로그인에 성공한 사람만 해당 페이지에 접속할 수 있는 것이다!

Login 구현 (클라이언트 코드)

import { Link } from 'react-router-dom';
import React, { useState } from 'react';
import { useForm } from 'react-hook-form';
import axios from 'axios';
import { useNavigate } from 'react-router-dom';

interface ILoginData {
  email: string;
  password: string;
}

const Login = () => {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<ILoginData>();

  const [message, setMessage] = useState<string>('');
  const navigate = useNavigate();

  const onSubmitLogin = async (data: ILoginData) => {
    try {
      const response = await axios.post(
        'http://localhost:3000/login',
        {
          email: data.email,
          password: data.password,
        },
        {
          headers: {
            'Content-Type': 'application/json',
            Accept: 'application/json',
          },
        }
      );

      const { token } = response.data;

      if (token) {
        sessionStorage.setItem('token', token);
        setMessage('로그인 성공!');
        setTimeout(() => {
          navigate('/home');
        }, 2000);
      } else {
        console.error('토큰이 없습니다.');
        setMessage('로그인 실패. 다시 시도하세요.');
      }
    } catch (error: any) {
      console.error('로그인 요청 실패:', error);
      if (error.response) {
        console.error('응답 데이터:', error.response.data);
        console.error('응답 상태 코드:', error.response.status);
        if (error.response.status === 401) {
          setMessage('인증에 실패했습니다. 다시 로그인하세요.');
        } else {
          setMessage('로그인 실패. 다시 시도하세요.');
        }
      }
    }
  };

  return (
    <div>
      <h1>로그인</h1>
      <form onSubmit={handleSubmit(onSubmitLogin)}>
        <div>
          <label htmlFor="email">이메일:</label>
          <input
            id="email"
            type="email"
            placeholder="이메일을 입력하세요"
            {...register('email', {
              required: '이메일을 입력하세요',
              pattern: {
                value: /^\S+@\S+$/i,
                message: '올바른 이메일 형식을 입력하세요',
              },
            })}
          />
          {errors.email && <p>{errors.email.message}</p>}
        </div>
        <div>
          <label htmlFor="password">비밀번호:</label>
          <input
            id="password"
            type="password"
            placeholder="비밀번호를 입력하세요"
            {...register('password', {
              required: '비밀번호를 입력하세요',
              minLength: {
                value: 6,
                message: '비밀번호는 최소 6글자 이상이어야 합니다.',
              },
            })}
          />
          {errors.password && <p>{errors.password.message}</p>}
        </div>
        <button type="submit">로그인</button>
      </form>
      <Link to="/signup">
        <button type="button">회원가입</button>
      </Link>
      {message && <p>{message}</p>}
    </div>
  );
};

export default Login;

간단하게 설명하자면,

  • axios.post : 서버에 POST 요청을 보낸다. 여기서는 백엔드 코드에서 정의했던 /login 엔드포인트에 사용자의 이메일과 비밀번호를 저장한다!

  • 성공적인 응답을 받은 토큰을 세션 스토리지에 저장하고, navigate 함수를 사용하여 '/home'페이지로 이동하게 된다.

여기서 중요한 점은, 로그인 한 사람만 /home 페이지에 접속할 수 있다라는 점이다

만약, 로그인을 하지 않은 사용자가 http://localhost:3001/home를 url로 타고 들어왔는데 들어가면 안되지 않을 까!

이때, privateRoute.tsx 컴포넌트를 만들어서

import React from 'react';
import { Navigate, Outlet } from 'react-router-dom';
import { isLoggedIn } from '../authUtils'; 

export default function PrivateRoute({
  userAuthentication,
}: {
  userAuthentication?: boolean;
}) {
  const isLogin = isLoggedIn();

  if (userAuthentication) {
    // 사용자 인증이 필요한 페이지일 경우
    if (!isLogin) return <Navigate to="/login" />;
    return <Outlet />;
  } else {
    // 사용자 인증이 필요하지 않은 페이지일 경우
    if (isLogin) return <Outlet />;
    return <Navigate to="/login" />;
  }
}

로그인 여부를 판단해서, 로그인을 안했을 경우(사용자 인증을 안했을 경우) -> 로그인 페이지로,
인증이 된 상태일 경우 -> /home 페이지로 이동할 수 있도록 !

여기서 사용자가 로그인 했는지 판단해주는 isLoggedIn 코드를 먼저 살펴보자면,

export function isLoggedIn() {
  const token = sessionStorage.getItem('token');
  return !!token;
}

export function login() {
  sessionStorage.setItem('token', 'true');
}

export function logout() {
  sessionStorage.removeItem('token');
}

sLoggedIn 함수

  • sessionStorage.getItem('token')을 통해 'token' 키에 저장된 값이 있는지 확인한다.
    값이 존재하면, token 변수에는 해당 값이 저장되게 된다.

login 함수

  • login 함수는 사용자를 로그인 상태로 설정하는 함수
  • sessionStorage.setItem('token', 'true')을 통해 'token'이라는 키에 'true'라는 값을 세션 스토리지에 저장 --> 로그인 한 상태임을 나타냄

logout 함수

  • sessionStorage.removeItem('token')을 통해 'token'이라는 키에 저장된 데이터를 세션 스토리지에서 제거한다.
  • 이는 사용자의 로그인 상태를 해제하는 역할을 한다!

이후

const App = () => {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/signup" element={<Signup />} />
        <Route path="/login" element={<Login />} />
        <Route path="/" element={<Login />} />
        <Route element={<PrivateRoute userAuthentication={true} />}>
          <Route path="/home" element={<Home />} />
        </Route>
      </Routes>
    </BrowserRouter>
  );
};

export default App;

라우터를 이렇게 설정해 주었다!
우선, /home 페이지만 인증을 한 상태일 때 이동이 가능하도록 처리를 해주었다.

이렇게 처리를 해주면,
아무리 http://localhost:3001/home 를 접속한다고 해도, 로그인이 안됬을 때에는 /login 페이지로 이동하게 될 것이다!


Logout 기능 구현 (클라이언트)

로그아웃 기능을 구현할 때 단순히 클라이언트의 로컬 저장소 (sessionStorage)에서 데이터를 제거하는 방식으로 구현하면 된다고 한다.

위에서 구현한 logout()을 사용하면 된다

const Home = () => {
  const navigate = useNavigate();
  const handleLogout = () => {
    logout(); // 로그아웃 함수 호출
    navigate('/');
  };

  return (
    <>
      <h1>메인화면 입니다.</h1>
      {isLoggedIn() && <button onClick={handleLogout}>Logout</button>}
    </>
  );
};

export default Home;

간단하게 home페이지에 Logout 버튼을 누르면 logout() 함수를 호출하고, logout() 함수에서 token을 제거해서 로그아웃 상태가 된다.

이후 -> / 로 이동해서 다시 로그인을 페이지를 보여주도록 하였다!

logout 버튼을 누르게 되면

다시 로그인 화면으로 이동! 하게 된다!

profile
2024. 01. 02 ~ 백앤드 공부 시작, 2024. 04.01 ~ 프론트 공부 시작

0개의 댓글