개인 프로젝트를 할때도, 회사에서 서비스를 만들때도, 가장 기본적인 기능이라고 한다면 아마 로그인, 회원가입 기능일것이다. 처음 로그인 기능에 대해 접했을땐, 그냥 아이디 비밀번호 체크하고 끝나는거 아니야? 라고 생각했다. 그래서, 로그인에 대한 전반적인 이해보다는 그냥 얼른 구현해서 아이디 비밀번호만 잘 저장해서 맞으면 ok, 틀리면 error처리를 해야지의 개념으로 생각을 했다.
프로젝트를 진행하다보니 문득 들은 생각들이 여러개가 있었다.
- 유저가 임의로 local storage에 있는 token을 바꾸면 어쩌지?
- admin user와 일반 user을 어디서 나눠서 누구에게 어떤 권한까지 부여를하지?
- 카카오나 네이버 로그인을 할 경우, 백엔드에서 회원가입을 처리는 하되, 추가로 토큰을 어디다 어떻게 처리하지?
이런 궁금증들에 대해 로그인에 대한 이해가 없다면 추가적인 권한 설정, 외부 API연결 등을 할때, 보안이나 권한에 크게 문제가 생길꺼라 생각했고, 한번 정리를 해보려 한다.
JsonWebToken의 약자로, 서명 된 URL-safe(URL로 이용할 수 있는 문자만 구성된)의 JSON이다. JWT는 서버와 클라이언트 간 정보를 주고 받을 때 Http 리퀘스트 헤더에 JSON토큰을 넣은 후 서버는 별도의 인증 과정 없이 헤더에 포함되어 있는 JWT 정보를 통해 인증한다.
JWT의 가장 큰 장점인 인증에 필요한 모든 정보는 토큰 자체에 포함하기 때문에 별도의 인정 저장소가 필요가 없다. JSON 웹 토큰이 사용되는 몇가지 이유가 있는데,
먼저 전반적인 프로세스를 이미지화 해보았다.
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,
};
},
},
};
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");
};
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: "아이디와 비밀번호를 다시 확인해주세요",
};
}
},
},
};
return false를 받지 않는 경우, 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파일에 넣어서 프로젝트를 진행했다.
return {
ok: true,
token,
};
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),
},
};
});
이렇게 기본적으로 로그인 구현까지 구현했다. 코딩을 하면서 느끼는 것은, 가볍게 접근을 해보면 구현하는게 어렵지 않지만, 전반적으로 이해를 하려고 하다보면 생각보다 깊이가 깊다. 로그인, 회원가입이 딱 대표적인 예라고 생각한다.
추가로 권한 설정과 소셜 로그인 기능도 알아볼껀데 이건 다음 포스팅에서 알아보자.
혹시 작성한 내용 중 잘못된 부분이 있다면 꼭 댓글로 알려주시길 부탁드립니다. 🙇♂️