로그인 시스템 구현 A-Z[사용자인증3]

hoon·2023년 7월 16일
0

이제 사용자가 회원가입을 완료하고 자신의 계정으로 로그인할 수 있도록 로그인 시스템을 구현해보자. 로그인 시스템은 회원가입 시스템과 밀접하게 연결되어 있다. 왜냐하면 사용자가 입력한 로그인 정보를 데이터베이스의 회원 정보와 비교하여, 그 정보가 유효한지 확인해야하기 때문이다. 따라서, 이번 단계에서는 사용자가 로그인 폼에 입력한 이메일과 비밀번호를 검증하고, 해당 정보가 올바르면 세션에 사용자 정보를 저장하는 과정을 살펴보도록 하자.

1. 클라이언트에서 로그인 정보 제출

우선 사용자는 로그인 폼을 통해 이메일과 비밀번호를 입력하고 제출한다. 이 과정에서 제출하는 이메일과 비밀번호가 사용자의 로그인 정보가 된다. 다음은 로그인시 사용자의 정보를 입력하고 제출하는 로직을 담당하는 LoginModal.jsx 파일이다.

// src/components/login/loginModal/LoginModal.jsx

import React, { useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';

import {
  hideLoginModal,
  loginModalToSignupModal,
} from '../../../store/modalSlice';
import { login } from '../../../store/authSlice.js';
import BaseModal from '../../common/BaseModal.jsx';
import EmailLogin from '../emailLogin/EmailLogin.jsx';

import {
  LoginModalContent,
  LoginModalText,
  SignupBtn,
  OrText,
} from './LoginModalStyle';
import KakaoLoginBtn from '../KakaoLoginBtn.jsx';

import {
  validateLoginEmail,
  validateLoginPassword,
} from '../../../utils/validation';
import { loginUser } from '../../../api/auth.js';

const LoginModal = () => {
  // 로그인 모달의 가시성 상태
  const isLoginModalVisible = useSelector(
    state => state.modal.isLoginModalVisible
  );
  const dispatch = useDispatch();

  // 로그인 모달을 숨기기
  const handleHideLoginModal = () => {
    dispatch(hideLoginModal());
  };

  // 로그인 모달에서 회원가입 모달로 전환
  const handleLoginModalToSignupModal = () => {
    dispatch(loginModalToSignupModal());
  };

  // 이메일과 비밀번호 입력값을 위한 상태
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  // 검증 에러들을 위한 상태
  const [emailErrors, setEmailErrors] = useState([]);
  const [passwordErrors, setPasswordErrors] = useState([]);
  const [serverLoginErrors, setServerLoginErrors] = useState('');

  // 이메일 입력값 변경을 처리
  const handleChangeEmail = event => {
    const emailValidationErrors = validateLoginEmail(event.target.value);
    setEmail(event.target.value);
    setEmailErrors(
      emailValidationErrors.length > 0 ? emailValidationErrors : []
    );
    setServerLoginErrors('');
  };

  // 비밀번호 입력값 변경을 처리
  const handleChangePassword = event => {
    const passwordValidationErrors = validateLoginPassword(event.target.value);
    setPassword(event.target.value);
    setPasswordErrors(
      passwordValidationErrors.length > 0 ? passwordValidationErrors : []
    );
    setServerLoginErrors('');
  };

  // 로그인 폼 제출을 처리
  const handleLoginSubmit = async event => {
    event.preventDefault();

    const emailValidationErrors = validateLoginEmail(email);
    const passwordValidationErrors = validateLoginPassword(password);

    setEmailErrors(
      emailValidationErrors.length > 0 ? emailValidationErrors : []
    );
    setPasswordErrors(
      passwordValidationErrors.length > 0 ? passwordValidationErrors : []
    );

    if (
      emailValidationErrors.length > 0 ||
      passwordValidationErrors.length > 0
    ) {
      return;
    }

    try {
      const response = await loginUser(email, password);
      const user = response.data.user;

      dispatch(login(user)); // 로그인 성공 액션을 디스패치, user 정보를 payload로 전달
      handleHideLoginModal(); // 로그인이 성공적으로 완료되면 모달을 숨김
      console.log('user', user)
    } catch (error) {
      const serverErrorMessages = error.response.data.message;
      if (error.response && error.response.status === 401) {
        // 이메일 또는 비밀번호가 일치하지 않음
        setServerLoginErrors(serverErrorMessages);
        console.log(error.response.data);
        console.log(serverErrorMessages);
      }

      if (error.response.status === 429) {
        // 로그인 요청이 너무 많이 감지됨
        setServerLoginErrors(serverErrorMessages);
        console.log(error.response.data.message);
      }
      console.error(error);
    }
  };

  return (
    <BaseModal
      isVisible={isLoginModalVisible}
      onClose={handleHideLoginModal}
      title='로그인 또는 회원가입'
    >
      <LoginModalContent onSubmit={handleLoginSubmit}>
        <LoginModalText>☕️ 카페골목에 오신 것을 환영합니다.</LoginModalText>
        <EmailLogin
          email={email}
          password={password}
          handleChangeEmail={handleChangeEmail}
          handleChangePassword={handleChangePassword}
          handleLoginSubmit={handleLoginSubmit}
          emailErrors={emailErrors}
          passwordErrors={passwordErrors}
          serverLoginErrors={serverLoginErrors}
        />
        <OrText>또는</OrText>
        <KakaoLoginBtn />
        <SignupBtn type='button' onClick={handleLoginModalToSignupModal}>
          카페골목 회원가입 하기
        </SignupBtn>
      </LoginModalContent>
    </BaseModal>
  );
};

export default LoginModal;

위 코드는 React와 Redux를 이용한 클라이언트 사이드에서의 로그인 정보 제출 과정을 보여준다. 이 코드에서 클라이언트가 로그인 정보를 제출하는 과정은 다음과 같다.

1-1. 사용자는 이메일과 비밀번호를 입력

const [email, setEmail] = useState('');
const [password, setPassword] = useState('');

각 입력값은 React의 useState 훅을 사용하여 상태로 관리된다.

1-2. 이메일과 비밀번호 유효성 검사

const handleChangeEmail = event => {
  const emailValidationErrors = validateLoginEmail(event.target.value);
  setEmail(event.target.value);
  setEmailErrors(
    emailValidationErrors.length > 0 ? emailValidationErrors : []
  );
  setServerLoginErrors('');
};

const handleChangePassword = event => {
  const passwordValidationErrors = validateLoginPassword(event.target.value);
  setPassword(event.target.value);
  setPasswordErrors(
    passwordValidationErrors.length > 0 ? passwordValidationErrors : []
  );
  setServerLoginErrors('');
};

사용자가 이메일 또는 비밀번호를 입력할 때마다 handleChangeEmail 또는 handleChangePassword 함수가 실행되어 이메일 또는 비밀번호 상태가 갱신된다. 이때, 입력값의 유효성을 검사하는 validateLoginEmail 또는 validateLoginPassword 함수가 호출되어 입력값의 유효성 검사를 수행한다.

1-3. 로그인 폼 제출하기

const handleLoginSubmit = async event => {
  event.preventDefault();

  const emailValidationErrors = validateLoginEmail(email);
  const passwordValidationErrors = validateLoginPassword(password);

  setEmailErrors(
    emailValidationErrors.length > 0 ? emailValidationErrors : []
  );
  setPasswordErrors(
    passwordValidationErrors.length > 0 ? passwordValidationErrors : []
  );

  if (
    emailValidationErrors.length > 0 ||
    passwordValidationErrors.length > 0
  ) {
    return;
  }

  try {
    const response = await loginUser(email, password);
    const user = response.data.user;

    dispatch(login(user)); // 로그인 성공 액션을 디스패치, user 정보를 payload로 전달
    handleHideLoginModal(); // 로그인이 성공적으로 완료되면 모달을 숨김
    console.log('user', user)
  } catch (error) {
    // 에러 처리...
  }
};

사용자가 로그인 폼을 제출하면 handleLoginSubmit 함수가 실행된다. 이 함수는 제출된 이메일과 비밀번호의 유효성을 최종적으로 확인하고, 유효성 검사를 통과한 경우 loginUser 함수를 호출하여 서버에 로그인 요청을 보낸다.

1-4. 서버에 로그인 요청 및 응답

const response = await loginUser(email, password);
const user = response.data.user;

dispatch(login(user)); // 로그인 성공 액션을 디스패치, user 정보를 payload로 전달
handleHideLoginModal(); // 로그인이 성공적으로 완료되면 모달을 숨김
console.log('user', user)

loginUser 함수는 서버에 로그인 요청을 보내고 응답을 받아와서 처리한다. 로그인이 성공적으로 이루어진 경우, 사용자 정보를 Redux 스토어에 저장하고, 로그인 모달을 숨긴다.

이렇게 클라이언트 사이드에서 로그인 정보가 제출되면, 해당 정보는 서버로 전달되어 사용자 인증이 이루어집니다.

2. 로그인 정보 검증

클라이언트 측에서 성공적으로 로그인 요청을 서버에 보냈다면, 서버는 로그인 정보 검증 -> 사용자 정보 확인 -> 세션에 사용자 정보 저장 -> 응답 전송의 단계를 거쳐 로그인이 진행된다. 다음은 이러한 로직을 담당하는 authContoller.js 파일의 로그인 login 함수에 대해서 알아보자.

우선, 사용자가 로그인 폼에서 제출한 이메일과 비밀번호가 올바른 형식인지 검증해야 한다. 이메일은 알맞은 형식에 맞춰져 있는지, 비밀번호는 사용자가 회원가입 때 설정한 비밀번호와 일치하는지 확인한다. 이런 검증은 서버 측에서 이루어져야하며, 이는 보안을 위해 필수적인 과정이다.

// controllers/authController.js

const bcrypt = require("bcrypt");
const passport = require("passport");
const { User } = require("../models");
const upload = require("../multerConfig");

const {
  validateSignupEmail,
  validateSignupPassword,
  validateSignupNickname,
  validateSignupPasswordConfirm,
  validateLoginEmail,
  validateLoginPassword,
} = require("../validations/validation.js");

// 로그인 처리
exports.login = async (req, res, next) => {
  // 로그인 입력 유효성 검사
  const { email, password } = req.body;
  const emailErrors = validateLoginEmail(email);
  const passwordErrors = validateLoginPassword(password);

  if (emailErrors.length > 0 || passwordErrors.length > 0) {
    return res.status(400).json({
      errors: {
        email: emailErrors,
        password: passwordErrors,
      },
    });
  }

  try {
    passport.authenticate("local", (err, user, info) => {
      if (err) {
        console.error(err);
        return next(err);
      }
      if (!user) {
        return res.status(401).json({
          message: "로그인 정보가 올바르지 않습니다. 다시 시도해 주세요.",
        });
      }
      return req.login(user, async (loginErr) => {
        if (loginErr) {
          console.error(loginErr);
          return next(loginErr);
        }

        const updatedUser = await User.findOne({ where: { id: user.id } });

        // 세션에 사용자 정보 저장
        req.session.user = {
          id: updatedUser.id,
          email: updatedUser.email,
          nickname: updatedUser.nickname,
          profileImage: updatedUser.profileImage,
        };

        return res.status(200).json({
          message: "로그인이 성공적으로 완료되었습니다.",
          user: req.session.user,
        });
      });
    })(req, res, next);
  } catch (error) {
    console.error(error);
    return res
      .status(500)
      .json({ message: "서버 내부 오류가 발생했습니다. 다시 시도해 주세요." });
  }
};

먼저, 사용자가 입력한 이메일과 비밀번호가 올바른 형식인지 서버에서 검증한다. 이메일과 비밀번호는 각각 validateLoginEmailvalidateLoginPassword 함수를 통해 유효성 검사가 이루어진다.

// 로그인 입력 유효성 검사
const { email, password } = req.body;
const emailErrors = validateLoginEmail(email);
const passwordErrors = validateLoginPassword(password);

if (emailErrors.length > 0 || passwordErrors.length > 0) {
  return res.status(400).json({
    errors: {
      email: emailErrors,
      password: passwordErrors,
    },
  });
}

만약 이메일 또는 비밀번호가 유효하지 않은 형식이라면, 서버는 400 상태 코드와 함께 유효성 검사 오류를 클라이언트에게 응답한다.

3. 사용자 정보 확인

검증이 완료되면, 데이터베이스에 저장된 사용자 정보와 제출된 로그인 정보를 비교한다. 즉, 이메일과 비밀번호가 모두 일치하는 사용자가 데이터베이스에 있는지 확인하는 로직을 거치는 것이다. 이 과정에서 비밀번호는 해시화되어 저장되어 있으므로, 사용자가 입력한 비밀번호를 같은 방식으로 해시화한 후에 비교해야 한다.

passport.authenticate("local", (err, user, info) => {
  if (err) {
    console.error(err);
    return next(err);
  }
  if (!user) {
    return res.status(401).json({
      message: "로그인 정보가 올바르지 않습니다. 다시 시도해 주세요.",
    });
  }

passport.authenticate 함수를 사용해 로컬 전략에 따라 사용자 인증을 시도한다. 이 함수는 인증이 성공적으로 이루어졌는지, 실패했는지, 그리고 실패한 경우 왜 실패했는지를 파악하고 이에 대한 적절한 응답을 생성한다.

4. 세션에 사용자 정보 저장

먼저, 로그인 시스템을 구현하기 위해서는 사용자 세션을 관리해야 한다. 세션 정보는 사용자가 로그인 상태를 유지할 수 있게 해주는 중요한 부분이기 때문이다.

아래는 프로젝트에서 세션을 설정하는 코드이다.

// app.js

const session = require("express-session");

const MySQLStore = require("express-mysql-session")(session);
const sessionStore = new MySQLStore(options);

app.use(
  session({
    resave: false, // 세션을 언제나 저장할지 정하는 옵션
    saveUninitialized: false, // 세션이 저장되기 전에 uninitialized 상태로 미리 만들어서 저장하는지 정하는 옵션
    key: "session_cookie_name",
    secret: process.env.COOKIE_SECRET,
    store: sessionStore, // 세션 스토어 지정
    cookie: {
      httpOnly: true, // 클라이언트에서 쿠키를 JavaScript로 제어할 수 없도록 설정하는 옵션
      secure: false, // true로 설정하면 https를 통해서만 쿠키가 전송
      maxAge: 30 * 24 * 60 * 60, // 쿠키 유효기간 설정 (30일)
    },
  })
);

session 함수는 세션에 대한 설정을 객체 형태로 받으며, resave, saveUninitialized, key, secret, store, cookie 등 다양한 옵션을 설정할 수 있다.

store 옵션은 세션 데이터를 저장하는 곳을 설정한다. 해당 프로젝트에서는 MySQL 데이터베이스에 세션 정보를 저장하도록 express-mysql-session 라이브러리를 사용했다.

cookie 옵션은 세션 쿠키에 대한 설정을 하며, httpOnly, secure, maxAge 등의 설정을 통해 쿠키의 보안과 유효기간을 관리할 수 있다.

이렇게 세션 설정을 통해 로그인한 사용자의 정보를 서버에서 안전하게 관리할 수 있다. 다음으로, 이 세션을 활용하여 사용자 로그인 시스템을 어떻게 구현하는지 알아보자.

사용자 정보가 일치하는 경우, 서버는 사용자를 로그인 상태로 설정하고, 사용자의 세션에 사용자 정보를 저장한다. 이렇게 하면 사용자가 브라우저를 닫거나, 다른 페이지로 이동해도 로그인 상태가 유지된다. 이후 사용자가 다른 요청을 보낼 때마다, 서버는 세션 정보를 확인하여 사용자의 로그인 상태를 판단한다.

return req.login(user, async (loginErr) => {
  if (loginErr) {
    console.error(loginErr);
    return next(loginErr);
  }

  const updatedUser = await User.findOne({ where: { id: user.id } });

  // 세션에 사용자 정보 저장
  req.session.user = {
    id: updatedUser.id,
    email: updatedUser.email,
    nickname: updatedUser.nickname,
    profileImage: updatedUser.profileImage,
  };

  return res.status(200).json({
    message: "로그인이 성공적으로 완료되었습니다.",
    user: req.session.user,
  });
});

인증에 성공한 경우, 사용자의 세션에 사용자 정보를 저장하고, 로그인에 성공했다는 메시지와 함께 사용자 정보를 클라이언트에게 응답한다.

5. 응답 전송

마지막으로, 로그인이 성공적으로 완료되었음을 클라이언트에게 알려준다. 이 때, 응답에는 세션에 저장된 사용자 정보를 포함시킬 수 있다. 만약 로그인이 실패했을 경우에는, 실패의 이유(예: 잘못된 이메일이나 비밀번호)를 클라이언트에게 알려준다.

catch (error) {
  console.error(error);
  return res
    .status(500)
    .json({ message: "서버 내부 오류가 발생했습니다. 다시 시도해 주세요." });
}

만약 이 과정에서 서버 내부 에러가 발생하면, 서버는 500 상태 코드와 함께 클라이언트에게 내부 에러가 발생했다는 메시지를 응답한다.

이렇게 간단한 웹 애플리케이션의 로그인 시스템을 구현할 수 있다. 이제 사용자는 자신의 계정으로 로그인하여 애플리케이션의 기능을 사용할 수 있게 되었다.

6. 테스트

이제 로그인 시스템을 테스트해보자.

비로그인 상태에서는 세션 테이블이 비어있는 것을 확인할 수 있다.

기존에 가입했던 ‘nojungbock@naver.com’로 로그인해보자

세션 테이블에 세션id, 만료기한, 사용자 data가 저장된 것을 확인할 수 있다. data에는 다음과 같은 정보가 저장되어있다.

{"cookie":{"originalMaxAge":2592000000,"expires":"2023-08-15T14:34:25.943Z","secure":false,"httpOnly":true,"path":"/"},"passport":{"user":90},"user":{"[id":90,"email":"nojungbock@naver.com](mailto:id%22:90,%22email%22:%22nojungbock@naver.com)","nickname":"노중복닉네임","profileImage":null}}
profile
프론트엔드 학습 과정을 기록하고 있습니다.

2개의 댓글

comment-user-thumbnail
2023년 7월 16일

잘봤습니다.

답글 달기
comment-user-thumbnail
2023년 7월 17일

저도 개발자인데 같이 교류 많이 해봐요 ㅎㅎ! 서로 화이팅합시다!

답글 달기