Notice
suyeonme
[Nestjs, Axios] Access Token과 Refresh Token으로 인증 구현하기(클라이언트, 서버) 본문
프로그래밍👩🏻💻/기타
[Nestjs, Axios] Access Token과 Refresh Token으로 인증 구현하기(클라이언트, 서버)
suyeonme 2024. 6. 18. 12:32인증 과정
- 서버: 로그인 성공시, 서버에서 refresh token은 httpOnly 헤더에, access token은 클라이언트에 전송합니다.
- 클라이언트: access token을 로컬스토리지에 저장합니다. 이후 요청시 헤더에 access token을 추가해서 요청을 보냅니다.
- 클라이언트: access token의 기간이 만료되었다면(서버의 응답이 401인 경우) refresh token 갱신 요청을 보냅니다.
- 서버: refresh token을 검증한 뒤, access token, refresh token을 갱신합니다. 갱신된 access token을 클라이언트에 전송합니다.
- 클라이언트: 갱신된 access token을 로컬스토리지에 저장합니다.
유효기간 및 저장 위치
- Access Token: 30분~1시간, 클라이언트에서 헤더에 추가해서 요청
- Refresh Token: 1주~2주, 서버에서 httpOnly 헤더에 추가
서버 코드
서버는 Next.js로 구성된 프로젝트이다.
쿠키를 다루기위해서 먼저 라이브러리를 설치해야한다. (공식문서 참고)
$ pnpm i cookie-parser
$ pnpm i -D @types/cookie-parser
이후 main.ts에 쿠키 라이브러리를 적용한다. 나의 경우 CORS 에러가 발생하지않도록 CORS 관련된 설정도 함께 적용했다.
app.use(cookieParser());
app.enableCors({
origin: process.env.CORS_ORIGIN ? [process.env.CORS_ORIGIN] : [],
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS',
credentials: true,
allowedHeaders: 'Content-Type, Accept, Authorization',
});
refresh token은 서버에서 httpOnly 쿠키에 저장하므로 클라이언트에 보내지않아야한다. 따라서 응답 데이터에서 refresh token은 제외하고 access token을 보내도록 코드를 작성했다.
@Controller('auth')
@UseInterceptors(ClassSerializerInterceptor)
export class AuthController {
constructor(private authService: AuthService) {}
@UseGuards(LocalAuthGuard)
@Post('/signin')
async login(
@Request() _req: Req,
@Body() signinUserDto: SigninUserDto,
@Res({ passthrough: true }) res: Response,
) {
const { user, accessToken } = await this.authService.signin(signinUserDto);
res.cookie('refreshToken', user.refreshToken, {
httpOnly: true,
secure: true, // Ensure secure for HTTPS
sameSite: 'strict', // Prevent CSRF attack
maxAge: REFRESH_TOKEN_COOKIE_MAX_AGE, // 7 days
});
const copied: Omit<User, 'password' | 'refreshToken'> = { ...user };
/**@description @Exclude() 항목이 인터셉터에서 제거되지않으므로 수동으로 제거 */
if ('password' in copied && 'refreshToken' in copied) {
delete copied.password;
delete copied.refreshToken;
}
return { ...copied, accessToken };
}
}
httpOnly
- JavaScript로 접근할 수 없는 쿠키로, 클라이언트와 서버 간의 HTTP 요청을 통해서만 전송될 수 있다.
- 클라이언트측 스크립트는 쿠키에 접근이 불가하므로, XSS(Cross-Site Scripting) 공격으로부터 쿠키를 보호하는 데 유용하다.
sameSite
- strict: 쿠키는 동일한 사이트에서만 전송된다. 즉, 사용자가 사이트에서 탐색할 때만 쿠키가 전송된다. 다른 사이트에서 요청이 발생하면 쿠키는 전송되지 않는다.
- 위와 같은 이유로 CSRF(Cross-Site Request Forgery) 공격을 방지하는 데 도움이 된다.
응답데이터에서 refresh token 제거하기
아래와 같이 entity를 바로 반환하는 경우, Controller에 적용한 ClassSerializerInterceptor에 의해서 @Exclude() 필드가 정상적으로 제외된다.
// user.entity.ts
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
email: string;
@Column()
@Exclude()
password: string;
@Column()
username: string;
@Column({ type: 'varchar', nullable: true })
@Exclude()
refreshToken: string | null;
}
하지만 아래와 같이 바로 entity를 반환하지 않는 경우, @Exclude() 필드가 정상적으로 필터링되지 않았다. 따라서 수동으로 제외시켰다.
@UseGuards(LocalAuthGuard)
@Post('/signin')
async login(
@Request() _req: Req,
@Body() signinUserDto: SigninUserDto,
@Res({ passthrough: true }) res: Response,
) {
// Service에서 Entity를 바로 반환하지 않음
const { user, accessToken } = await this.authService.signin(signinUserDto);
...
const copied: Omit<User, 'password' | 'refreshToken'> = { ...user };
/**@description @Exclude() 항목이 인터셉터에서 제거되지않으므로 수동으로 제거 */
if ('password' in copied && 'refreshToken' in copied) {
delete copied.password;
delete copied.refreshToken;
}
return { ...copied, accessToken };
}
클라이언트 코드
React 프로젝트 기준 axios의 인터셉터를 이용해서 인증 로직을 구현하였다.
axios/axios.config.ts
axios의 요청, 응답 인터셉터를 다루는 로직을 별도의 모듈로 분리했다.
// axios.config.ts
export const createBaseAPI = (
baseUrl: string,
options: AxiosRequestConfig = {}
) => {
return axios.create({
baseURL: baseUrl,
timeout: REQUEST_TIMEOUT,
withCredentials: true,
headers: {
"Content-Type": "application/json",
},
...options,
});
};
/**
* @description 요청 인터셉터, 액세스토큰을 헤더에 추가합니다.
*/
export const addRequestInterceptors = (
instance: ReturnType<typeof axios.create>
) => {
instance.interceptors.request.use(
(config) => {
const accessToken = localStorage.getItem(ACCESS_TOKEN_KEY);
if (accessToken) {
config.headers["Authorization"] = `Bearer ${accessToken}`;
}
return config;
},
(error) => Promise.reject(error)
);
};
/**
* @description
* - 응답 인터셉터, 액세스토큰이 만료되었을 경우, 리프레시 토큰을 사용하여 재발급합니다.
* - 에러 발생시 에러 로그를 출력합니다.
*/
export const addResponseInterceptors = (
instance: ReturnType<typeof axios.create>
) => {
instance.interceptors.response.use(
(response) => {
const accessToken = response.data.data[ACCESS_TOKEN_KEY];
if (accessToken) {
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
}
return response;
},
async (error) => {
if (config.nodeEnv === "development") {
// 에러 로깅
errorMessageLogger(error);
}
// 액세스토큰 만료시 리프레시 토큰 재발급
const originalRequest = error.config as AxiosRequestConfig & {
_retry?: boolean;
};
if (error?.response?.status === 401 && !originalRequest?._retry) {
originalRequest._retry = true;
try {
const { data } = await instance.get("/auth/refresh");
const accessToken = data[ACCESS_TOKEN_KEY];
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
axios.defaults.headers.common[
"Authorization"
] = `Bearer ${accessToken}`;
return instance(originalRequest);
} catch (refreshError) {
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
};
axios/baseInstance.ts
/**
* @description 일반적인 API 요청시 사용하는 axios 인스턴스입니다.
*/
const baseInstance = createBaseAPI(APP_SERVER_BASE_URL);
addRequestInterceptors(baseInstance);
addResponseInterceptors(baseInstance);
export default baseInstance;
인스턴스를 사용하는 곳에서는 다음과 같이 사용하면 된다.
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
try {
const response = await signin(form);
const { data } = response;
if (data.statusCode === 201) {
handleResetForm();
router.push("/");
} else {
console.error(data.message);
}
} catch (error) {
/**@todo 에러 메세지 출력 */
}
}
'프로그래밍👩🏻💻 > 기타' 카테고리의 다른 글
Makefile이란? (0) | 2024.07.07 |
---|---|
.envrc 파일로 환경변수 자동으로 설정하기 (0) | 2024.07.07 |
[Fix] 모노레포 환경의 Vscode에서 Eslint가 동작하지않는 현상 (0) | 2024.05.05 |
JSON Web Token(JWT)이란? (1) | 2023.08.06 |
[MSA] 마이크로서비스(Mocroservice)란? (1) | 2022.12.31 |
Comments