[JWT] 로그인 구현 part1(feat. react, graphQL, prisma)

Sungho Kim·2022년 9월 28일
0

React

목록 보기
1/7
post-thumbnail

✏️ 시작하며,

개인 프로젝트를 할때도, 회사에서 서비스를 만들때도, 가장 기본적인 기능이라고 한다면 아마 로그인, 회원가입 기능일것이다. 처음 로그인 기능에 대해 접했을땐, 그냥 아이디 비밀번호 체크하고 끝나는거 아니야? 라고 생각했다. 그래서, 로그인에 대한 전반적인 이해보다는 그냥 얼른 구현해서 아이디 비밀번호만 잘 저장해서 맞으면 ok, 틀리면 error처리를 해야지의 개념으로 생각을 했다.

프로젝트를 진행하다보니 문득 들은 생각들이 여러개가 있었다.

  • 유저가 임의로 local storage에 있는 token을 바꾸면 어쩌지?
  • admin user와 일반 user을 어디서 나눠서 누구에게 어떤 권한까지 부여를하지?
  • 카카오나 네이버 로그인을 할 경우, 백엔드에서 회원가입을 처리는 하되, 추가로 토큰을 어디다 어떻게 처리하지?

이런 궁금증들에 대해 로그인에 대한 이해가 없다면 추가적인 권한 설정, 외부 API연결 등을 할때, 보안이나 권한에 크게 문제가 생길꺼라 생각했고, 한번 정리를 해보려 한다.

⛳️ 순서

  1. JWT를 이용해서 로그인 구현 (이번 페이지에서 다룰 내용)
  2. hook을 사용해서 보안과 권한 설정
  3. 소셜 로그인 구현

📖 사전지식

🧐 JWT란?

JsonWebToken의 약자로, 서명 된 URL-safe(URL로 이용할 수 있는 문자만 구성된)의 JSON이다. JWT는 서버와 클라이언트 간 정보를 주고 받을 때 Http 리퀘스트 헤더에 JSON토큰을 넣은 후 서버는 별도의 인증 과정 없이 헤더에 포함되어 있는 JWT 정보를 통해 인증한다.

🙂 JWT의 장점

JWT의 가장 큰 장점인 인증에 필요한 모든 정보는 토큰 자체에 포함하기 때문에 별도의 인정 저장소가 필요가 없다. JSON 웹 토큰이 사용되는 몇가지 이유가 있는데,

  • 토큰 자체에 사용자의 권한 정보나 서비스를 사용하기 위한 정보가 포함되어 있음(self-contained)
  • URL 파라미터와 헤더로 사용
  • 디버깅 및 관리가 용이
  • 트래픽에 대한 부당미 적음
  • 내장된 만료
  • 독립성이 보장 등이다.

🙁 JWT의 단점

  • 토큰은 클라이언트에 저장되어 데이터베이스에서 사용자 정보를 조작하더라도 토큰에 직접 적용할 수 없다.
  • 비상태 애플리케이션에서 토큰은 거의 모든 요청에 대해 전송되므로, 데이터 트래픽 크기에 영향을 미칠 수 있다.

🤔 어떻게 작동할까

먼저 전반적인 프로세스를 이미지화 해보았다.

  1. 클라이언트에서 아이디와 비밀번호를 입력받아 백앤드로 보낸다
  2. 백엔드에서 DB에 있는 정보와 맞는지 대조를 해본다.
  3. 아이디와 비밀번호가 맞는 경우, true를 리턴한다 (아닐경우 로그인이 안됨)
  4. jwt를 통해 토큰을 발행한다.
  5. 토큰을 서버로 받아서
  6. 클라이언트로 넘긴다
  7. local Storage와 http header에 token을 저장한다.

🔎 하나하나 알아보자

login.js(react.js)

import { gql, useMutation } from "@apollo/client";
import { useForm } from "react-hook-form";
import { logUserIn } from "../../apollo";
import { useState } from "react";


const LOGIN_MUTATION = gql`
  mutation login($username: String!, $password: String!) {
    login(username: $username, password: $password) {
      ok
      token
      error
    }
  }
`;

function Login() {
  const location = useLocation();
  const {
    register,
    handleSubmit,
    errors,
    formState,
    getValues,
    setError,
    clearErrors,
  } = useForm({
    mode: "onChange",
    defaultValues: {
      username: location?.state?.username || "",
      password: location?.state?.password || "",
    },
  });
  const onCompleted = (data) => {
    const {
      login: { ok, error, token },
    } = data;
    if (!ok) {
      return setError("result", {
        message: error,
      });
    }
    if (token) {
      logUserIn(token);
    }
  };
  const [login, { loading }] = useMutation(LOGIN_MUTATION, {
    onCompleted,
  });
  const onSubmitValid = (data) => {
    if (loading) {
      return;
    }
    const { username, password } = getValues();
    login({
      variables: { username, password },
    });
  };
  const clearLoginError = () => {
    clearErrors("result");
  };
  return (
    <AuthLayout>
      <PageTitle title="Login" />
      <FormBox>
        <form onSubmit={handleSubmit(onSubmitValid)}>
          <Input
            ref={register({
              required: "Username is required",
              minLength: {
                value: 5,
                message: "Username should be longer than 5 chars.",
              },
            })}
            onChange={clearLoginError}
            name="username"
            type="text"
            placeholder="Username"
            hasError={Boolean(errors?.username?.message)}
          />
          <FormError message={errors?.username?.message} />
          <Input
            ref={register({
              required: "Password is required.",
            })}
            onChange={clearLoginError}
            name="password"
            type="password"
            placeholder="Password"
            hasError={Boolean(errors?.password?.message)}
          />
          <FormError message={errors?.password?.message} />
          <Button
            type="submit"
            value={loading ? "Loading..." : "Log in"}
            disabled={!formState.isValid || loading}
          />
          <FormError message={errors?.result?.message} />
        </form>        
      </FormBox>
      
    </AuthLayout>
  );
}
export default Login;

login.resolver(prisma client)

import client from "../../client";
import bcrypt from "bcrypt";
import jwt from "jsonwebtoken";

export default {
  Mutation: {
    login: async (_, { username, password }) => {
      //find user with args.username
      const user = await client.user.findFirst({
        where: {
          AND: [{ username }, { loginMethod: "email" }],
        },
      });
      if (!user) {
        return {
          ok: false,
          error: "아이디와 비밀번호를 다시 확인해주세요",
        };
      }
      //check password with args.password
      const passwordOk = await bcrypt.compare(password, user.password);
      if (!passwordOk) {
        return {
          ok: false,
          error: "아이디와 비밀번호를 다시 확인해주세요",
        };
      }
      // issue a token and send it to the user
      const token = await jwt.sign({ id: user.id }, process.env.SECRET_KEY);
      return {
        ok: true,
        token,
      };
    },
  },
};

📌 Step 1. 리액트에서 아이디와 비밀번호 받기

import { gql, useMutation } from "@apollo/client";
import { useForm } from "react-hook-form";
import { logUserIn } from "../../apollo";
import { useState } from "react";


const LOGIN_MUTATION = gql`
  mutation login($username: String!, $password: String!) {
    login(username: $username, password: $password) {
      ok
      token
      error
    }
  }
`;

function Login() {
  const location = useLocation();
  const {
    register,
    handleSubmit,
    errors,
    formState,
    getValues,
    setError,
    clearErrors,
  } = useForm({
    mode: "onChange",
    defaultValues: {
      username: location?.state?.username || "",
      password: location?.state?.password || "",
    },
  });
  const [login, { loading }] = useMutation(LOGIN_MUTATION, {
    onCompleted,
  });
  const onSubmitValid = (data) => {
    if (loading) {
      return;
    }
    const { username, password } = getValues();
    login({
      variables: { username, password },
    });
  };
  const clearLoginError = () => {
    clearErrors("result");
  };
  
  1. 리액트에서 use-react-form을 통해 인풋을 입력받는다
  2. use mutation(graphQL)을 통해 back-end로 인풋값을 보낸다.

📌 Step 2. Prsima Client를 이용해서 아이디 비밀번호 유효성 체크

import client from "../../client";
import bcrypt from "bcrypt";
import jwt from "jsonwebtoken";

export default {
  Mutation: {
    login: async (_, { username, password }) => {
      //find user with args.username
      const user = await client.user.findFirst({
        where: {
          AND: [{ username }, { loginMethod: "email" }],
        },
      });
      if (!user) {
        return {
          ok: false,
          error: "아이디와 비밀번호를 다시 확인해주세요",
        };
      }
      //check password with args.password
      const passwordOk = await bcrypt.compare(password, user.password);
      if (!passwordOk) {
        return {
          ok: false,
          error: "아이디와 비밀번호를 다시 확인해주세요",
        };
      }
      
    },
  },
};
  1. username, password를 받는다
  2. prima client로 DB에 있는 아이디와 비밀번호를 대조한다
  3. 아이디가 없거나, 아이디, 비밀번호가 맞지 않는 경우, false를 리턴

📌 Step 3. true값을 받는다

return false를 받지 않는 경우, jwt에 접근을 한다.

📌 Step 4. jwt 토큰을 발행해서 클라이언트에 보낸다

// issue a token and send it to the user
      const token = await jwt.sign({ id: user.id }, process.env.SECRET_KEY);

jwt에서 SECRET_KEY는 상당히 중요하다. 보안을 생각해서 env파일에 넣어서 프로젝트를 진행했다.

📌 Step 5, 6. Issue된 토큰을 받아서 클라이언트로 보내준다.

      return {
        ok: true,
        token,
      };

📌 Step 7. 받은 토큰을 local Storage와 http header에 저장한다.

apollo.js


const TOKEN = "authorization";

export const isLoggedInVar = makeVar(Boolean(localStorage.getItem(TOKEN)));

export const logUserIn = (token) => {
  localStorage.setItem(TOKEN, token);
  isLoggedInVar(true);
};

const authLink = setContext((_, { headers }) => {
  return {
    headers: {
      ...headers,
      authorization: localStorage.getItem(TOKEN),
    },
  };
});

✏️ 마치며..

이렇게 기본적으로 로그인 구현까지 구현했다. 코딩을 하면서 느끼는 것은, 가볍게 접근을 해보면 구현하는게 어렵지 않지만, 전반적으로 이해를 하려고 하다보면 생각보다 깊이가 깊다. 로그인, 회원가입이 딱 대표적인 예라고 생각한다.

추가로 권한 설정과 소셜 로그인 기능도 알아볼껀데 이건 다음 포스팅에서 알아보자.

혹시 작성한 내용 중 잘못된 부분이 있다면 꼭 댓글로 알려주시길 부탁드립니다. 🙇‍♂️

profile
공유하고 나누는걸 좋아하는 개발자

0개의 댓글