이번에 개인 프로젝트에 로그인, 회원가입 등을 구현하면서 내용을 정리했다.
인증(Authentication)이란 보통 아이디, 비밀번호를 통해서 도메인에 등록된 사용자임을 확인하는 과정이다.
회원가입한 뒤 로그인하는 과정이라고 볼 수 있다.
인가(Authorization)란 한 번 인증된 사용자에 대해서 권한을 검증하는 과정을 의미한다.
대부분의 서비스가 로그인한 뒤 로그아웃 전까지 다시 로그인을 하지 않아도 서비스의 기능을 사용할 수 있도록 인가 과정을 거친다.
쉽게 얘기하면 인증은 누구인가를 확인하고 인가는 무엇을 할 수 있는가를 결정한다.
전통적으로는 세션-쿠키 방식이 사용됐지만 최근에는 JWT(JSON Web Token)를 많이 사용하는 추세인 것 같다.
JWT란 토큰 기반의 인증/인가 기술이다.
우선적으로 얘기할 건 세션-쿠키 방식이 오래된 건 맞지만 뒤쳐지는 기술은 아니다.
각각의 장단점이 있고 서비스의 성격에 맞춰 선택하면 된다.
그렇다면 JWT는 세션-쿠키와 어떤 점이 다르고 왜 JWT가 주로 사용될까?
세션-쿠키 방식에서 로그인을 하면 서버는 메모리에 세션 정보를 저장하고 세션 ID를 브라우저 쿠키에 전달한다.
그 이후에 온 요청에 대해서 세션 ID를 메모리의 세션 정보와 대조하는 과정을 통해 인가 처리를 했다.
하지만 세션 정보를 메모리에 저장하기 때문에 따라오는 문제점들이 있다.
예전에 '클라이언트는 상태를 잘 관리하는 게 목적이고 서버는 상태를 갖지 않도록 하는 게 목적이다.'라는 말을 어디서 들은 것 같은데... 갑자기 생각났다.
아무튼 이런 문제때문에 서버에서 관리하지 않고 클라이언트에 전달하여 서버측의 부담을 줄일 수 있게 나온 방법이 JWT다.
JWT에 대해서 자세하게 알아보기 전에 인증/인가 프로세스가 어떻게 되는지 먼저 알아보자.
프로세스를 코드로 따라가보자.
프로젝트의 기술 스택은 React
와 Nest
를 사용했다.
import { UserSignupRequest, UserResponse } from "@/types";
export const signUp = async (requestData: UserSignupRequest): Promise<UserResponse> => {
const response = await fetch("http://localhost:3000/auth/signup", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(requestData),
});
if (!response.ok) {
throw Error("회원가입 실패");
}
return response.json();
};
클라이언트에서 보내는 회원가입 요청이다.
requestData
에는 간단하게 email
, name
, password
만 넣었다.
async signup(createUserDto: CreateUserDto) {
const { email, name, password } = createUserDto;
const user = await this.userRepository.findOne({ where: { email } });
if (user) {
throw new BadRequestException('이미 가입한 이메일입니다.');
}
const hash = await bcrypt.hash(
password,
this.configService.get<number>('HASH_ROUNDS') as number,
);
await this.userRepository.save({ email, name, password: hash });
return this.userRepository.findOne({ where: { email } });
}
서버에서 controller
로직은 생략하고 service
로직만 살펴보면 클라이언트로 부터 받은 정보를 받아 email
중복 검사, 비밀번호 암호화, 데이터 베이스에 저장한 뒤 가입된 사용자를 반환한다.
이때, 사용자의 모든 정보를 반환하면 비밀번호 또한 반환되기 때문에 응답에 실어보내지 않도록 처리를 해줘야 한다.
Nestjs에서는 Entity의 password
필드에 @Exclude({toPlainOnly: true,})
옵션을 넣어줘서 응답에 포함되지 않도록 설정할 수 있다.
로그인으로 넘어가기 전에 JWT에 대해 설명하면,
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30
JWT 토큰은 위와 같이 생겼다.
.
을 기준으로 헤더, 페이로드, 서명 세 부분으로 이뤄져 있다.
처음에 세션-쿠키 방식을 보면서 서버측의 부담을 줄이기 위해 나온 방법이라고 했지만 더 나은 방법이 아닌 서비스의 성격에 맞게 선택하는 문제라고 얘기했다.
JWT는 서버측에서 세션 정보를 저장, 관리하지 않도록 하는 장점을 가지고 있지만 서버에서 저장하지 않기 때문에 오는 단점 또한 갖고 있다.
서버에 세션 정보를 저장하고 잘 관리될 경우, 서버측에 제어권이 있다는 말과 같다.
제어권이 있으면, 서버에서 의도적으로 세션 정보를 삭제하여 로그인을 해지할 수 있다.
하지만 JWT는 서버에 정보를 갖고 있지 않기 때문에 서버에서 제어할 수 없다.
단점을 보완하는 방법으로 수명이 짧은 엑세스 토큰과 수명이 긴 리프레시 토큰을 발급하는 방법이 있다.
서버에서는 두 가지 토큰을 만들고 리프레시 토큰의 상응값을 캐싱하거나 데이터베이스에 저장한다.
사용자의 엑세스 토큰이 수명이 다하면 리프레시 토큰을 대조하여 새 엑세스 토큰을 발급한다.
이 과정을 통해 엑세스 토큰이 탈취될 경우 리프레시 토큰을 제거하여 엑세스 토큰을 재발급하지 못하게 막을 수도 있다.
하지만 엑세스 토큰이 살아있는 동안 차단할 수 있는 방법이 없다는 한계가 있다.
로그인 과정은 클라이언트 측에서 Request Body에 email
과 password
를 보내면 서버에서 검증한 뒤 엑세스 토큰과 리프레시 토큰을 생성한다.
리프레시 토큰은 httpOnly
, 엑세스 토큰은 응답의 바디에 실어 보낸다.
클라이언트 측에서는 엑세스 토큰을 Authorization
헤더에 넣어 관리한다.
'서버측에서 어떻게 쿠키에 저장할 수 있을까?'라는 의문이 있었다.
왜냐하면 쿠키는 브라우저 영역이기 때문이다.
알고 보니 서버에서 직접 저장하는 건 아니고 HTTP 응답 헤더에 쿠키를 포함시켜서 브라우저가 해당 쿠키를 저장하도록 하는 거라고 한다.
로그인 과정 시에 Authorization
헤더에 베이직 토큰으로 보내는 경우도 있던데, 사실 어떤 게 더 좋을지 모르겠다.
바디에 JSON 형태로 보내는 게 더 통상적으로 사용하는 것 같아서 이 방법을 사용했다.
'헤더에 베이직 토큰 형태로 보내면 더 안전하지 않을까?'라고 생각도 했는데, 이것도 노출되기 때문에 보안상 더 뛰어난 것도 아닌듯하다.
@Post('login')
async login(
@Body() loginUserDto: LoginUserDto,
@Res({ passthrough: true }) response: Response,
) {
const { refreshToken, accessToken } =
await this.authService.login(loginUserDto);
response.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: this.configService.get('ENV') === 'prod',
sameSite: 'strict',
maxAge: 24 * 60 * 60 * 1000,
});
return { accessToken };
}
서버의 controller
로직을 살펴보면 service
로직에서 받은 리프레시 토큰을 쿠키에 저장하고, 엑세스 토큰을 반환한다.
import { login } from "@/api/users/login";
import { LoginResponse } from "@/types";
import { useMutation, useQueryClient } from "@tanstack/react-query";
export const useLoginMutation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ email, password }: { email: string; password: string }) =>
login({ email, password }),
onSuccess: (data: LoginResponse) => {
queryClient.setQueryData(["accessToken"], data.accessToken);
},
onError: (error: Error) => {
console.log(error);
},
});
};
클라이언트 측에서는 서버로 부터 받은 엑세스 토큰을 받아서 리액트 쿼리를 통해 캐싱했다.
axios
는 axios.defaults.headers.common['Authorization']
을 통해서 바로 전역 헤더 설정을 할 수 있는데, fetch
는 안 되는 것 같아서 캐싱을 하고 추후 요청 시에 헤더에 설정하도록 했다.
엑세스 토큰이 만료되면 리프래쉬 토큰을 통해 재발급 받는다.
말은 쉬운데 엑세스 토큰이 만료됐는지 어떻게 알 수 있을까?
만료 시간을 클라이언트 측에서 보낸다 한들, 일일히 측정할 수도 없는 노릇아닌가?!
import { useQueryClient } from "@tanstack/react-query";
import { refreshToken } from "./refreshToken";
const customFetch = async (url: string, options: RequestInit = {}) => {
const queryClient = useQueryClient();
const accessToken = queryClient.getQueryData<string>(["accessToken"]);
const headers = {
...options.headers,
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
};
const newOptions = { ...options, headers };
const response = await fetch(url, newOptions);
if (response.status === 401) {
const retryResponse = await refreshToken(url, newOptions);
return retryResponse;
}
return response;
};
위와 같은 customFetch
함수를 만들어서 서버의 응답이 401일 경우 토큰을 재발급하는 API 요청을 보내도록 설정할 수 있다.
하지만 위 함수는 동작하지 않는다.
눈치가 빠른 사람은 이상한 점을 느꼈겠지만 리액트 쿼리에서 토큰값을 가져오기 위해서는 useQueryClient
훅을 사용해야 하는데, 일반 함수 내부에서는 훅을 사용할 수 없다.
axios
의 전역 헤더 설정 기능이 얼마나 유용한지 새삼 깨닫게 된다...
전역 상태 관리 라이브러리로 관리하는 방법도 있지만 axios
를 쓰기로 했다.
import { UserLoginRequest, LoginResponse } from "@/types";
import { authClient } from "../common";
import axios from "axios";
export const login = async (requestData: UserLoginRequest): Promise<LoginResponse> => {
try {
const response = await authClient.post<LoginResponse>("/auth/login", requestData);
const { accessToken } = response.data;
axios.defaults.headers.common["Authorization"] = `Bearer ${accessToken}`;
return response.data;
} catch (error: any) {
const serverMessage = error.response?.data?.message;
throw new Error(serverMessage || "이메일 또는 비밀번호가 일치하지 않습니다.");
}
};
우선 기존 로그인 함수를 axios
를 사용하도록 바꿨다.
로그인에 성공하면 전역 헤더 설정으로 토큰을 포함하도록 했다.
여기서 authClient
는 인가가 필요하지 않은, 인증받기 전의 요청에 대해서 요청을 보내는 인스턴스다.
import axios from "axios";
const apiClient = axios.create({
baseURL: "http://localhost:3000",
withCredentials: true,
});
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
const originRequest = error.config;
if (error.response.status === 401 && !originRequest._retry) {
originRequest._retry = true;
try {
const { data } = await axios.post(
"http://localhost:3000/auth/refresh",
{},
{ withCredentials: true }
);
const { accessToken } = data;
axios.defaults.headers.common["Authorization"] = `Bearer ${accessToken}`;
originRequest.headers.Authorization = `Bearer ${accessToken}`;
return apiClient(originRequest);
} catch (refreshError) {
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
이후 요청부터는 apiClient
로 보내게 된다.
apiClient
에 interceptor
를 설정하여 토큰이 만료되어 서버로 부터 401 에러를 반환받을 경우 토큰을 재발급하는 요청을 보내고 다시 엑세스 토큰을 설정해서 재요청을 보내도록 했다.
로그인 로직이 이렇게 복잡했었나...
처음에는 JWT 탄생 배경, 정보, 한계 등을 설명하고 간략하게 로직으로 설명하려고 했다.
음... 돌이켜보니 처음 생각한대로 한 것 같지만, 중간에 XSS, CSRF 공격 등 파생되는 개념도 많았고 세션-쿠키 방식도 해볼까하다가 내용이 너무 방대해지는 것 같아서 잘랐다.
사용자의 정보를 보낼 때, 서버에서 토큰을 발행하고 응답할 때 찾아보는 정보마다 달라서 혼란스러웠다.
엑세스 토큰을 재발급할 때에도 서버에서 AuthGard
를 통해서 엑세스 토큰의 유효 기간이 만료됐는지 확인하고 재발급하는 방법도 있는 것 같고... 사용자의 정보를 베이직 토큰으로 보내는 방법 등 여러 방법이 존재했다.
강백호가 레이업슛을 풋내기슛이라고 생각하듯, 로그인 기능은 서비스에서 기본적인 기능이라 생각했는데, 생각보다 복잡하고 모르는 내용이 많아서 약간의 좌절감을 맛봤다.
왜 프로그래밍은 알려고 들춰보면 모르는 게 더 생겨날까? ㅎㅎ