import axios from "axios";
import { store } from "@/redux/store";
import { authSlice } from "@/redux/auth/auth-slice";
import { ROLE } from "@/common/enum";
// Axios 인스턴스 생성
export const fetcher = axios.create({ withCredentials: true });
// Request 인터셉터 추가
fetcher.interceptors.request.use(
(config) => {
const token = store.getState().auth.accessToken;
if (token) {
config.headers["Authorization"] = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
},
);
// Response 인터셉터 추가
fetcher.interceptors.response.use((response) => {
const accessToken = response.headers["access-token"];
const isRefreshTokenInvalid = response.headers["refresh-token-invalid"];
const role = response.headers["role"];
// store.dispatch(authSlice.actions.setIsRefreshTokenValid(false));
/** Refresh Token 만료시 핸들링 **/
if (isRefreshTokenInvalid) {
store.dispatch(authSlice.actions.setAccessToken(""));
// store.dispatch(authSlice.actions.setIsRefreshTokenValid(true));
store.dispatch(authSlice.actions.setRole(ROLE.VISITOR));
}
/** Access Token 만료시 핸들링 **/
if (accessToken) {
store.dispatch(authSlice.actions.setAccessToken(accessToken));
store.dispatch(authSlice.actions.setRole(role));
}
// 응답 데이터 반환
return response.data;
});
// fetch custom
// https://stackoverflow.com/questions/44820568/set-default-header-for-every-fetch-request
api 통신에 필요한 axios를 사용했으며 백엔드에서 access token과 refresh token 검증을 위해 front에서 통신할 때마다 보내야하는데 모든 axios에 하나하나씩 달면 유지보수도 힘들기 때문에 Request를 보낼때(모든 api통신)인터셉터를 통해 Redux(메모리)에 저장한 access token을 Header값에 붙여서 보냅니다.
서버로부터 받은 응답의 경우도 header값에 access token과 refresh token 유효여부, 인가에 따른 페이지 접근권한을 위한 role정보를 받아서 refresh token이 유효하지 않는 경우 메모리에 있는 access token을 초기화시킵니다.
access token이 유효한 경우는 access token을 메모리에 저장합니다. 메모리에 저장하는 이유는 탈취에 위험이 전혀 없기 때문입니다. [CSRF, XSS 방지] (쿠키에 httpOnly와 samesite 처리하는 것도 또다른 방법이라고도 합니다)
login-check.tsx (jwt 검증)
"use client";
import { store } from "@/redux/store";
import React, { useEffect, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { EXCEPT_ROUTE } from "@/common/constants";
import { fetcher } from "@/common/fetcher";
import { useQuery } from "@tanstack/react-query";
import { useCookies } from "react-cookie";
const getAccessToken = async (): Promise<any> => {
return await fetcher.post<any>(
process.env.NEXT_PUBLIC_API_URL + "/getAccessToken",
);
};
export const LoginCheckWrapper = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
const router = useRouter();
const pathname = usePathname();
const [cookies, setCookie] = useCookies();
const [isTokenChecked, setIsTokenChecked] = useState(false); // 액세스 토큰 재발급 요청이 끝났다는 표시
const [redirectStep, setRedirectStep] = useState(""); // 리다이렉트 문제 해결을 위해 넣은 단계 (토큰 없이 /main 이동시 / 리다이렉트할 때 /main 페이지 내용이 잠깐 보이는 현상 방지)
/** 액세스 토큰 재발급 요청 **/
const { isFetching: isAccessTokenFetching } = useQuery({
queryKey: ["getAccessToken"],
queryFn: getAccessToken,
});
/** 특정 페이지로 바로 접근하는 경우 (새로고침, 직접 주소 입력) **/
useEffect(() => {
/* 엑세스 토큰 발급 요청 했을 때 **/
if (!isAccessTokenFetching) {
const token = store.getState().auth.accessToken;
const isRememberMe = cookies.REMEMBER;
const isLogin = cookies.LOGIN;
/* 리프레시 토큰이 만료 됐을 때 로그아웃 (엑세스 토큰 비정상 발급 + 토큰이 필요한 서버 주소인 경우) **/
if (token === "" && !EXCEPT_ROUTE.includes(pathname)) {
router.replace("/");
setRedirectStep("replace");
}
// 자동로그인 O, 엑세스토큰 정상발급(정상 리프레시토큰), 로그인페이지로 접근시 로그인 되었기 때문에 main 페이지로 이동
else if (isRememberMe === true && token !== "" && pathname === "/") {
router.replace("/main");
setCookie("LOGIN", true);
setRedirectStep("replace");
}
// 로그아웃 안 하고 나갔을 때 (토큰 값은 있어도 Login 상태가 아닌 경우)
else if (token !== "" && !EXCEPT_ROUTE.includes(pathname) && !isLogin) {
router.replace("/");
setRedirectStep("replace");
}
// 로그인을 한 상태의 경우 메인페이지 접속시 리다이렉트
else if (isLogin && pathname === "/" && token !== "") {
router.replace("/main");
setRedirectStep("replace");
}
setIsTokenChecked(true);
}
if (isTokenChecked && redirectStep === "replace") {
setRedirectStep("done");
}
}, [pathname, isAccessTokenFetching]);
/** 토큰 유효 체크가 안 됐을 때 **/
if (!isTokenChecked) {
return <></>;
}
/** 리다이렉트시 처리 **/
if (isTokenChecked && redirectStep === "done") {
return <>{children}</>;
}
/** 일반 페이지 접근 (리다이렉트 아닐 때) **/
if (isTokenChecked && redirectStep === "") {
return <>{children}</>;
}
};
경우의 수에 따라 처리 방법을 분기 시켜놨다.
리프레시 토큰이 만료시 처리
access token 요청처리하면서 fetcher를 통해 refresh token이 유효하지 않으면 access token에 값이 없기 때문에 분기 처리하고 그 이후에 로그인 페이지로 라우팅시킨다.
자동 로그인했을 때 처리 (자동로그인 설정, 리프레시 토큰 정상일 때 로그인페이지로 이동시)
메인페이지로 이동
로그아웃 안 하고 브라우저 끌 때 (자동로그인 미설정, 리프레시 토큰 정상일 때)
자동로그인 설정 안 해도 다시 브라우저 다시 키면 로그인한 상태인데 로그인할 때 쿠키에 login 했다는 표시를 세션단위로 남겨서 브라우저가 닫히면 사라지게 한다. 그 이후 로그인 페이지로 리다이렉트
로그인 한 상태로 로그인 페이지 접속
로그인을 한 상태이기 때문에 로그인페이지로 가면 메인페이지로 보내버린다
비동기로 처리하기 때문에 토큰 유효 체크가 안 됐을 땐 아무런 화면이 보이면 안 된다. 또한 리다이렉트가 완전이 끝나야 페이지가 보여야하는데 setRedirect로 redirect가 실행되었고 끝났다는 걸 따로 체크 안 하면 리다이렉트 하기 전 페이지가 잠깐 보이게 된다. (/main 접속시 0.5초정도 /main페이지 보였다가 /로 리다이렉트) 이걸 방지하기 위해서 따로 처리를 분리했다. 리다이렉트가 아닌 경우에도 처리를 해야하기 때문에 마지막 처럼 처리했다.
F5를 누르면 전체가 다시 렌더링 되는데 이 때 메모리에 있는 값도 날라가기 때문에 새로고침하면 위에 컴포넌트가 다시 렌더링 되면서 access token값을 다시 요청하게 된다.
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
UseGuard랑 Strategy은 세트입니다. Guard에서 통과한 이후에 정상인 경우 JwtStrategy의 super 검증을 거친 이후 validate를 통과해 controller를 실행시킵니다. validate return값에는 request 객체의 user 객체 안에 값이 들어가게 되기 때문에 필요한 정보가 있는 경우 return 해주면 됩니다.
auth.module.ts
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LocalStrategy } from './local.strategy';
import { JwtStrategy } from './jwt.strategy';
import { UsersModule } from '../users/users.module';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { jwtConstants } from './constants';
@Module({
imports: [
UsersModule,
PassportModule,
JwtModule.register({
secret: jwtConstants.secret,
signOptions: { expiresIn: '60s' },
}),
],
providers: [AuthService, LocalStrategy, JwtStrategy],
exports: [AuthService],
})
export class AuthModule {}
여기에서 jwtModule을 선언해야 jwtService를 이용한 verify작업이나 jwt 토큰 생성을 할 수 있습니다.
로그인 정보 확인해서 DB값하고 비교한 이후에 정상인 경우 Access Token하고 Refresh Token을 생성해서 Refresh Token의 경우는 cookie에 보안을 위해 httpOnly로 보냅니다. Access Token의 경우는 Header에 담아서 보냅니다. Refresh Token의 경우는 Access Token을 재발급하는 용도이기 때문에 사용자가 가지고 요청마다 같이 보내야 Access Token이 만료 됐을 때 Refresh Token을 검증해서 Access Token을 재발급해줍니다.
isPublic를 사용한 이유는 @isPublic() 어노테이션을 달면 jwtGuard가 따로 필요없는 메소드라고 선언하는 것입니다. (전역으로 jwtGuard를 사용하게끔 했기 때문에 회원가입이나 로그인 등에는 필요)
Refresh Token를 시크릿키로 복호화했을 때 유효하면 넘어가고 유효하지 않으면 유효하지 않다는 값을 Header로 보내 클라이언트에서는 로그아웃시킨다.
Refresh Token값하고 DB Refresh Token값하고 비교하는데 DB Refresh Token값을 따로 가지는 이유는 JWT의 경우 사용자에게 인증을 위임하기 때문에 서버에서는 강제로 로그아웃 시킬 수가 없다. 하지만 이 방법을 이용하면 강제 로그아웃을 시킬 수 있다. 다르면 Refresh Token이 유효하지 않다는 문구를 Header에 담아 보낸다. (이 방법을 쓰면 서버에서 세션관리하는 부담을 JWT의 장점인 사용자에게 부담을 넘기는 걸 다시 서버가 어느정도 안고 가게 된다. Trade Off라 어쩔 수 없음)
Access Token을 검증해 정상적이지 않으면 Refresh Token을 이용해 재발급해서 사용자에게 보낸다 Role의 경우는 어떤 페이지는 사용자가 접근할 수 있지만 어떤 페이지는 관리자만 접근할 수 있게끔 하기 위해 따로 담았다. (Refresh Token은 이미 위에서 검증해서 내려온 거이기 때문에 정상이라 이렇게 재발급하면 된다.)
main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import helmet from 'helmet';
import * as dotenv from 'dotenv';
import * as cookieParser from 'cookie-parser';
import { ConfigService } from '@nestjs/config';
import { FRONT_URL, PORT } from './common/constants';
dotenv.config();
// prettier-ignore
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const configService= app.get(ConfigService);
/** server setting **/
app.use(helmet()); // 보안 적용
app.use(cookieParser()); // 쿠키 라이브러리 사용
app.enableCors({ // cors 허용
origin: configService.get(FRONT_URL),
credentials: true,
exposedHeaders: ['Access-Token', 'Refresh-Token-Invalid', "Role"] // token header allow (header에서 parse하려면 필요)
});
await app.listen(configService.get(PORT)); // 포트 적용
}
bootstrap();
.env 파일은 API 서버 주소와 같은 환경변수들을 관리하는 파일이다. 다양한 .env 파일이 존재한다.
next dev 사용시 env 파일 적용 순서는 아래와 같다. (.env.development.local이 우선순위 가장 높음)
.env.development.local
.env.local
.env.development
.env
next build 사용시 env 파일 적용 순서는 아래와 같다. (.env.production.local이 우선순위 가장 높음)
.env.production.local
.env.local
.env.production
.env
내가 원하는 건 다양한 환경에 따른 환경변수 실행인데 next dev의 경우 기본적으로 development가 들어가고 next build 및 start의 경우는 produciton으로 자동 세팅이 된다. 환경 파일을 강제로 주입하려고 했는데 프레임워크에 반하는 것이기 때문에 하지 않기로 했다. 로컬에서 개발할 땐 env.local 파일을 사용하고 개발서버의 경우 env.development파일을 운영서버의 경우 env.production 파일이 injection 되었으면 좋겠는데 흠... 굳이 위에 있는 파일을 다 만들지 않는 게 좋을 거 같고 개발의 경우 .env.development를 운영의 경우는 env.production 즉, 2가지만 만들어서 혼동이 없게 하는게 맞는 거 같다.
infisical이라는 걸 통해 서버에서 env파일을 관리하고 injection해서 사용하게 할 수 있다.
서로 다른 도메인에 요청을 보낼때 요청에 Credentials 정보를 담아서 보낼지에 대한 여부입니다.
기본적으로 브라우저가 제공하는 요청 API 들은 별도의 옵션 없이 브라우저의 쿠키와 같은 인증과 관련된 데이터를 함부로 요청 데이터에 담지 않도록 되어있다. 이는 응답을 받을때도 마찬가지이다. 따라서 요청과 응답에 쿠키를 허용하고 싶을 경우, 이를 해결하기 위한 옵션이 바로 withCredentials 옵션을 넣어야한다
credential 정보가 포함된 요청은 아래와 같습니다.
쿠키를 첨부해서 보내는 요청
헤더에 Authorization 항목이 있는 요청
📝HttpOnly
자바스크립트로부터 쿠키 접근을 차단하여 XSS 공격 방지를 해준다
📝Secure
HTTPS를 통해서만 쿠키를 전송하여 MITM 공격 방지할 수 있다
📝SameSite
쿠키가 서로 다른 사이트에서 전송되지 않도록 제한 하는 속성으로 CSRF 공격을 방지하는데 사용됩니다.
Strict
쿠키가 같은 사이트 내에서만 전송됩니다. 외부 사이트에서 링크 클릭하여 들어오는 경우 쿠키가 전송되지 않습니다.
Lax
쿠키가 같은 사이트 내에서만 전송됩니다. 외부 사이트에서 gET 요청이 발생한 경우에는 쿠키가 전송 될 수 있습니다. → 예를 들면 다른 곳에서 쿠팡 링크로 들어왔을 때 어떤 검색어를 통해서 들어왔는지에 대한 쿠키에 대한 정보를 파악하기 위해 필요하다
None
쿠키가 모든 사이트 간 전송될 수 있습니다. 이 옵션을 사용할 때는 Secure 속성을 함께 사용해야합니다.
리소스 서버에게서 리소스 소유자의 보호된 자원을 획득할 때 사용되는 만료 기간이 있는 Token입니다
Refresh Token
Access Token 만료시 이를 갱신하기 위한 용도로 사용하는 Token입니다
📝OAuth 2.0의 4가지 역할
Resource Owner
사용자입니다.
Client
보호된 자원을 사용하려고 접근 요청을 하는 애플리케이션입니다. 예) 쇼핑몰 홈페이지
Resource Server
사용자의 보호된 자원을 호스팅하는 서버입니다. 예) 구글 사용자 정보 관리 서버
Authorization Server
권한 서버. 인증/인가를 수행하는 서버로 클라이언트의 접근 자격을 확인하고 Access Token을 발급하여 권한을 부여하는 역할을 수행합니다. 예) 사용자 서버
📝OAuth 2.0 추가 및 보완된 기능
1.0에 비해 구현이 쉬워졌습니다
웹, 앱 데스크톱 등 다양한 환경에서 지원이 가능해집낟
리프레시 토큰 개념이 추가 되었습니다
HTTPS를 통해 암호화를 강화시켰습니다
📝 OAuth 2.0 프로토콜
Authorization Code Grant
가장 많이 사용하고 기본이 되는 방식입니다. 로그인한 이후에 권한 부여 승인 코드를 전달 받습니다. 해당 코드는 일회용이며 해당 코드로 Access Token을 요청한 이후에 받아서 원하는 자원에 접근하게끔 사용합니다.
Implicit Grant
권한 부여 승인 코드 없이 바로 Access Token을 발급합니다. 비교적 간소화 되기 때문에 빠른 응답이 있지만 Refresh Token이 사용 불가능하며(?) 안전하게 저장하기 힘든 클라이언트(JS 사용 브라우저)에 최적화된 방식입니다. → 서버가 없다는 거 자체가 이해가 안 된다.
Resource Owner Password Credentials Grant
username, password를 전달해 Access Token을 받습니다. 또한 Refresh Token이 사용이 가능합니다. 이 방식은 권한서버, 리소스 서버, 클라이언트가 모두 같은 시스템에 속해 있을 때 사용되어야하는 방식입니다. → Third Part를 사용 안 하고 내가 직접 인증서버를 관리할 때를 의미하는 거 같다
Client Credentials Grant
검증된 클라이언트 서버(예를 들면 쇼핑몰 웹서버)에서 자기는 안전하다고 판단한 경우에 사용됩니다. 이 경우에는 Resource Server는 사용자의 정보가 들어있는 곳보다는 모든 로그가 쌓여있고 필터링이 따로 필요 없는 서버를 접근하기 위한 것이고 이미 요청한 서버는 안전하다고 판단하기 때문에 Access Token을 요청하면 바로 해당 서버의 Acces Token을 바로 발급해주는 것입니다. Refresh Token은 사용할 수 없습니다.
📝OIDC (Open ID Connect)
OAuth 2.0의 확장으로 Access Token으로 접근권한 뿐만 아니라 사용자 정보도 ID토큰으로 받아와 사용할 수 있습니다.
📝 개인적인 생각
위 프로토콜들이 이해가 안 되는게 많다 일단 로그인 처리가 되고 난 이후에 권한 부여 승인 코드를 따로 받는 것 그 이후에 그걸 가지고 Access Token을 굳이 또 요청해야하는 작업이 필요한 것일까? 로그인이 완료 되었으면 다 된 거 같은데 또한 Refresh Token을 못 쓴다는데 Refresh Token을 Access Token하고 같이 주면 되는 거 아닌가 싶다