이번 글에선 refreshToken을 통해 만료된 accessToken을 재발급 받고 사용자의 로그인 상태를 유지하는 방법을 알아보자.
프론트엔드 코드는 ReactJS를 사용할 예정이고, 백엔드는 SpringBoot , NestJS를 사용할 예정이다.
(프론트엔드 개발자라면 ReactJS, 백엔드 개발자라면 본인이 사용하는 스택의 코드를 보면 된다!)
먼저 JWT 토큰에 대해선 모두 잘 알고있다 생각하고 간단히 설명을 하고 넘어가겠다.
(기본적으로 제가 작성하는 모든 글은 프로그래밍을 처음 공부하는 저에게 필요했던 내용들입니다.
과거의 제가 알고 싶었지만 찾지 못했던 내용들을 알게된 현재의 제가 과거의 저에게 설명한단 생각으로 쓰는 글입니다.
그러니 매우 쉬운 내용도 자세히 풀어서 설명되어 있을 수 있습니다.
아마 이번 글도 매우 길어질 지 모릅니다...)
1. JWT 토큰, AccessToken, RefreshToken 의 필요성
JWT (JSON Web Token), Refresh Token, 및 Access Token은 웹 기반 인증 및 권한 부여를 위해 사용된다.
- JWT (JSON Web Token):
- JSON 이란?
- 클라이언트와 서버 간의 HTTP 통신을 위한 텍스트 데이터 포맷
- 대부분의 프로그래밍 언어에서 사용 가능
- 키-값으로 이뤄진 순수한 텍스트
- JSON의 경우 키는 반드시 큰따옴표(작은 따옴표 사용 불가)로 묶어야 한다는 것
- 순수한 텍스트
- Token 이란?
- 프로그래밍에서 더 이상 나눌 수 없는 최소 요소
- JWT는 웹 토큰의 일종으로, 사용자의 인증 정보를 JSON 형식으로 저장하고, 서버와 클라이언트 간에 안전하게 정보를 전송하기 위해 사용된다.
- base64로 인코딩 된다. (인코딩이란 사람이 인지할 수 있는 문자(언어)를 약속된 규칙에 따라 컴퓨터가 이해하는 언어 (0과 1)로 이루어진 코드로 바꾸는 것)
- 구조 (Header + Payload + Signature)
- Header: 토큰의 타입(JWT)과 사용된 해시 알고리즘 정보
- Payload: 사용자의 인증 정보나 권한에 대한 정보가 들어있음. 예를 들면 사용자 ID, 유효 기간
- Signature: 헤더와 페이로드를 암호화한 서명으로, 토큰의 무결성을 보장
- JSON 이란?
- Access Token:
- Access Token은 사용자가 서버의 특정 리소스에 접근할 수 있는 권한을 부여하는 토큰
- 사용자가 로그인하면, 서버는 Access Token을 발급하는데 이 토큰은 보통 JWT 형식!
- 유효 기간이 짧은 편이며, 만료되면 새로 발급 받아야 함 → 이 과정에서 Refresh Token이 사용된다.
- Refresh Token:
- Refresh Token은 Access Token을 새로 발급 받기 위해 사용하는 토큰
- Access Token보다 긴 유효 기간을 가지며, 주로 Access Token이 만료되었을 때, 새로운 Access Token을 얻기 위해 사용됨.
- Refresh Token이 있으면, 사용자는 로그인 프로세스 없이 새로운 Access Token을 얻을 수 있다.
왜 Refresh Token이 필요할까?
- accessToken을 탈취당할 수 있다.
- 이런 경우 서버에서 토큰이 탈취당했는지 알 수 있는 방법이 없다. 그러므로 자주쓰이는 accessToken의 만료시간을 짧게 만들고, refresh token을 통해 계속 재발급받을 수 있도록 한다.
- 이러면 accessToken이 탈취당해도 만료시간이 짧기에 만료가 되어 탈취자의 토큰 사용을 막을 수 있다.
- 우리는 더 안전하게 하기 위해 accessToken을 쿠키에 담아서 전달하고, httpOnly 설정을 통해 js로 쿠키 수정도 막을 예정이다!
2. 로그인 , 토큰 재발급 과정
과정에 대한 설명을 해보겠다.
1. 로그인
- 유저의 email, pw를 통해 로그인을 요청한다.
2. 유저 검증
- 유저에게 받은 credentials를 통해 유저를 검증한다.
3. 쿠키로 accessToken 발급 및 redis에 refreshToken 저장
- 서버에서 클라이언트에게 받은 credentials를 검증해 accessToken을 발급한다.
- accessToken을 발급하는 방법은 여러가지이지만, 보안성을 위해 쿠키로 accessToken을 전달한다.
- httpOnly 옵션을 통해 js를 통한 토큰 접근을 막는다.
- refreshToken은 저장소에 저장한다
- 나는 redis를 사용했다. 유저의 id을 key, refreshToken을 value로 저장한다.
- 만료시간을 ttl로 정한다.
4. 유저가 요청을 보낸다.
- 쿠키에 accessToken이 있기에 유저 요청시 따로 해줄 것은 없다.
- 쿠키는 요청시 자동으로 포함된다.
5. Access Token 검증
- 유저에게 받은 accessToken을 검증해 인증/인가를 진행한다.
6. Access Token이 올바른 경우
- 유저의 요청에 대해 응답한다.
여기까진 괜찮은 부분이다.
만약 accessToken이 만료된 경우 토큰 재발급을 시도해야 한다.
6. Access Token이 올바르지 않은 경우
- 서버에서 401 에러를 반환한다.
7. 유저가 401에러를 받은 경우
- 401 에러를 받은 경우, axios.interceptors를 통해 재발급 요청을 보낸다. (자세한 코드는 아래 ReactJS 코드를 보면 된다.)
8. 유저가 401에러를 받은 경우, refreshToken을 검증한 뒤 새로운 accessToken을 반환한다.
- 서버에서 올바르지 않은 토큰(accessToken)을 받았지만 decode는 가능한다.
- 토큰 속 id 값을 뽑아낸 뒤 , id에 해당하는 refreshToken이 redis에 있는지 확인한다.
- 없으면 에러를 반환한다.
- refreshToken이 있다면 올바른지 검증하고 유저에게 새로운 accessToken을 반환한다.
- refreshToken이 올바르지 않은 경우 에러를 반환한다.
9. 재발급 받은 성공 시 새로운 accessToken을 클라이언트 쿠키에 넣어준다.
10. 재발급 받은 토큰을 사용해 재요청
- accessToken이 재발급됐다면 해당 토큰을 사용해 재요청을 보낸다.
- 어차피 쿠키에 넣어주니까 그냥 재요청을 보내면 된다.
- 쿠키 값은 클라이언트에서 요청을 보낼때 자동으로 함께 간다.
위는 토큰 재발급 성공 버전이다.
토큰 재발급에 실패하면?
9. 서버에서 refreshToken 검증 실패로 401 에러를 반환한다.
10. 로그아웃
- 재발급에 실패하면 로그아웃을 해야한다.
- 토큰 재발급에 실패하면 서버에서 로그아웃 요청을 보낸다. (쿠키를 지운다.)
- 프론트엔드에서 로그인 상태를 유지하고 있다면 상태를 변경해주면 된다.(난 상태를 관리했기에 이를 바꿔주는 로직을 추가했다.)
다음은 ReactJS, SpringBoot, NestJS를 통해 위 과정을 구현해보자.
본인이 필요한 부분만 골라서 보면 된다.
프론트엔드 개발자라면 ReactJS만, 백엔드라면 스택에 맞게 골라 보면 된다.
전체코드
https://github.com/HoyeongJeon/blog-code/tree/main/jwt-login
(모든 코드를 다 적을 순 없으니, 핵심코드만 적도록 하겠다.
이번 글의 목표는 refreshToken을 통한 토큰 재발급이다.
그러기에 회원가입 및 로그인을 할 때 중요한 기능(e.g 비밀번호 암호화)은 구현하지 않았다.
자세한 코드가 궁금한 사람은 깃허브에 들어가 살펴보길 바란다.)
3. ReactJS 코드
유저의 상태 관리를 위해 zustand를 사용했고, api 요청을 위해 axios를 사용했다.
// src/stores/auth.ts
import { create } from "zustand";
import { persist } from "zustand/middleware";
interface AuthState {
isLoggedIn: boolean;
login: () => void;
logout: () => void;
}
const useAuthStore = create<AuthState>()(
persist(
(set) => ({
isLoggedIn: false,
login: () => set({ isLoggedIn: true }),
logout: () => set({ isLoggedIn: false }),
}),
{
name: "auth-storage",
}
)
);
export { useAuthStore };
토큰 재발급에서 중요한 것은 axios이다.
다음 코드들을 분석해보자.
// src/api/api.ts
import axios, {
AxiosError,
AxiosInstance,
AxiosRequestConfig,
AxiosResponse,
} from "axios";
const BASE_URL = "http://localhost:8080";
export const createApi = (logoutFn: () => void) => {
const instance: AxiosInstance = axios.create({
baseURL: BASE_URL,
headers: {
"Content-Type": "application/json",
},
withCredentials: true,
});
let isRefreshing = false;
let failedQueue: any[] = [];
const processQueue = (
error: AxiosError | null,
token: string | null = null
) => {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
};
instance.interceptors.response.use(
(response) => {
return response;
},
async (error: AxiosError): Promise<AxiosError | AxiosResponse> => {
const originalRequest = error.config as AxiosRequestConfig & {
_retry?: boolean;
};
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then(() => {
return instance(originalRequest);
})
.catch((err) => {
return Promise.reject(err);
});
}
isRefreshing = true;
try {
console.log("토큰 재발급 요청");
const response = await instance.post("/auth/rotate", {});
if (response.status === 201) {
isRefreshing = false;
processQueue(null);
console.log("토큰 재발급 성공");
return instance(originalRequest);
}
} catch (refreshError) {
isRefreshing = false;
processQueue(refreshError as AxiosError);
console.log("토큰 재발급 실패");
await instance.post("/auth/logout", {});
logoutFn();
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
return {
get: <T = any>(
url: string,
config: AxiosRequestConfig = {}
): Promise<AxiosResponse<T>> => instance.get<T>(url, config),
post: <T = any>(
url: string,
data?: any,
config: AxiosRequestConfig = {}
): Promise<AxiosResponse<T>> => instance.post<T>(url, data, config),
put: <T = any>(
url: string,
data?: any,
config: AxiosRequestConfig = {}
): Promise<AxiosResponse<T>> => instance.put<T>(url, data, config),
delete: <T = any>(
url: string,
config: AxiosRequestConfig = {}
): Promise<AxiosResponse<T>> => instance.delete<T>(url, config),
};
};
코드를 이해해보자!
기본설정
const instance: AxiosInstance = axios.create({
baseURL: BASE_URL,
headers: {
"Content-Type": "application/json",
},
withCredentials: true,
});
- 모든 요청의 기본 URL 설정
- JSON 형식으로 컨텐츠 타입을 지정
- withCredentials: true 설정은 쿠키를 포함하는 설정이다.
토큰 재발급 관리
let isRefreshing = false;
let failedQueue: any[] = [];
- `isRefreshing` 은 토큰 재발급 중복을 방지하는 역할이다.
- `failedQueue` 는 토큰 재발급 도중 발생한 요청을 저장하는 큐이다.
여기서 어떻게 토큰 재발급 중복을 방지하는지 궁금할 수 있다.
먼저 각 코드에 대한 설명을 쭉 적고, 토큰 재발급 방지와 큐의 역할에 대해 알아보자.
큐 처리
const processQueue = (error: AxiosError | null, token: string | null = null) => {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
};
- 토큰 갱신 후에 큐에 대기하고 있던 요청들을 처리한다.
- 토큰 재발급에 성공하면 시도했던 요청을 다시 보낸다. 토큰 재발급에 실패하면 에러를 반환한다.
인터셉터 로직
instance.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
// 401 에러 처리 및 토큰 갱신 로직
...
}
);
인터셉터 로직에 대한 설명 이전에 큐(failedQueue)가 왜 있는지 다음 경우를 생각해보자.
- 여러 API 요청이 동시에 401 에러를 받는 경우
- 첫 번째 요청이 토큰 재발급을 시도하는 동안 다른 요청들이 들어오는 경우
큐가 없다면 어떻게 될까?
1. 토큰 재발급 중복 요청
- 여러 요청이 개별적으로 401 에러를 받으면 각 요청이 개별적으로 토큰 재발급을 시도할 수 있다.
- 이러면 서버에 불필요한 중복 요청이 되고 부하가 걸린다.(본의아닌 DDoS...)
2. Race Condition
- 여러 토큰 재발급 요청이 동시에 처리되면 어떤 토큰이 최종 유효한 토큰인지 보장하기 어려워진다!
- 일부 요청은 최종 토큰이 아닌 토큰으로 서버에 요청을 보내게 될 수 있다.
이런 문제들이 발생할 수 있다.
그래서 큐를 만들고 토큰 재발급시 요청이 들어오면 큐에 넣어놔 상기한 문제를 방지한다.
이제 인터셉터 로직을 봐보자!
if (error.response?.status === 401 && !originalRequest._retry)
- 401 에러가 발생했고, _retry가 false 일때 토큰 재발급을 요청한다.
- _retry가 뭘까?
- 처음 우리는 _retry를 설정하지 않았기에 null이나 false이다.
- 그러니까 위 조건을 통과한다.
originalRequest._retry = true;
- 조건을 통과했으면 토큰 재발급을 시도하게 된다. 재발급 무한루프를 막기위해 `originalRequest._retry = true`로 설정한다.
- 이제 동일한 요청이 다시 401에러를 받아도, _retry가 true이므로 재발급을 하지 않는다.
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
- 토큰을 재발급하는 도중 다른 요청이 들어올 수 있다.
- 이때 들어온 요청을 큐에 저장하고 대기시킨다.
- Promise를 반환해 요청을 중단하는 것!
.then(() => {
return instance(originalRequest);
})
- 토큰 갱신이 완료되면 원래 요청을 새 토큰으로 재시도한다.
토큰 재발급 실패
catch (refreshError) {
isRefreshing = false;
processQueue(refreshError as AxiosError);
console.log("토큰 재발급 실패");
await instance.post("/auth/logout", {});
logoutFn();
return Promise.reject(refreshError);
}
토큰 재발급에 실패할 수 있다.
이땐 큐를 비워준다.
토큰 재발급에 실패했으니 로그아웃도 시켜줘야 한다.
서버에 로그아웃 요청을 보내 쿠키를 지우자.
내가 작성한 코드에선 로그인 상태를 zustand로 관리한다.
zustand의 login 상태도 false로 변경해줘야 로그아웃이 완료된다.
이제 위 api.ts를 사용하기 위한 커스텀 훅(useApi.ts)를 만들어보자.
// src/api/useApi.ts
import { useAuthStore } from "../stores/auth";
import { createApi } from "./api";
export const useApi = () => {
const logout = useAuthStore((state) => state.logout);
return createApi(logout);
};
useAuthStore((state) => state.logout) 를 안에서 쓰면 안되나? 생각할 수 있다.
catch (refreshError) {
isRefreshing = false;
processQueue(refreshError as AxiosError);
console.log("토큰 재발급 실패");
useAuthStore((state) => state.logout)();
return Promise.reject(refreshError);
}
그러나 이렇게 쓰면 문제가 생길 수 있다.
`useAuthStore`는 리액트 훅이다. 리액트 훅은 컴포넌트의 최상위 레벨이나 다른 커스텀 훅 안에서만 사용해야 한다.
그러나 위 코드는 비동기 함수의 catch() 블록 안에서 사용하기에 리액트 훅 규칙을 위반하게 된다.
추가로 비동기 작업의 콜백 안에서 zustand 스토어에 접근해 상태를 변경시키면 콜백이 바뀐 상태가 아닌 오래된 클로저를 참조할 수 있다.
그러므로~!
외부에서 넣어주어야 한다!
그래서 어떻게 사용하라고?
import { useAuthStore } from "../../stores/auth";
import { useEffect } from "react";
import { useApi } from "../../api/useApi";
export default function Main() {
const logout = useAuthStore((state) => state.logout);
const api = useApi(); // 요렇게 불러와서!
const handleLogout = async () => {
await api.post("/auth/logout");
logout();
};
useEffect(() => {
const healthCheck = async () => {
try {
const response = await api.get("/auth/health"); // 요렇게 쓰면 된다!
console.log(response.data);
} catch (error) {
console.error("헬스체크에 실패했습니다.", error);
}
};
healthCheck();
}, []);
return (
<>
<h1>로그인 되었습니다.</h1>
<button onClick={handleLogout}>로그아웃</button>
</>
);
}
길고 긴 리액트 코드 설명이 끝났다!
이제 백엔드로 가보자~!
4. NestJS 코드
Controller
// src/auth/auth.controller.ts
import {
Body,
Controller,
Get,
Post,
Res,
UseGuards,
Req,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { LoginDto, SignupDto } from './dto/auth.dto';
import { Response } from 'express';
import { AuthGuard } from '@nestjs/passport';
import { Request } from 'express';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('login')
async login(
@Res({ passthrough: true }) response: Response,
@Body() loginDto: LoginDto,
) {
const accessToken = await this.authService.login(loginDto);
response.cookie('access_token', accessToken, {
httpOnly: true,
});
return true;
}
@Post('logout')
async logout(
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
) {
const accessToken = request.cookies?.['access_token'];
this.authService.logout(accessToken);
response.clearCookie('access_token');
return true;
}
@Post('signup')
async signup(@Body() signupDto: SignupDto) {
return this.authService.signup(signupDto);
}
@Post('rotate')
async rotate(
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
) {
const accessToken = request.cookies?.['access_token'];
if (!accessToken) {
return response.status(401).json({ message: '토큰이 없습니다.' });
}
const newAccessToken = await this.authService.rotate(accessToken);
response.cookie('access_token', newAccessToken, {
httpOnly: true,
});
return '토큰 갱신 완료';
}
@Get('health')
@UseGuards(AuthGuard('jwt'))
async health() {
return '건강합니다.';
}
}
1. 로그인
코드에 보이는
response.cookie('access_token', accessToken, {
httpOnly: true,
});
이 부분이 토큰을 쿠키로 삽입하는 코드이다.
먼저 로그인할 때 accessToken을 쿠키로 담아줬다.
refreshToken은 어떻게 다룰까?
// src/auth/auth.service.ts
async login(loginDto: LoginDto) {
const user = await this.userRepository.findOne({
where: {
email: loginDto.email,
},
});
const { accessToken, refreshToken } = await this.generateToken(
user.email,
user.id,
);
await this.redisClient.set(
`refresh_token:${user.id}`,
refreshToken,
'EX',
7 * 24 * 60 * 60, // 7일
);
return accessToken;
}
이렇게 refreshToken은 redis에 저장한다.
그 후 accessToken을 반환한다.
@Post('login')
async login(
@Res({ passthrough: true }) response: Response,
@Body() loginDto: LoginDto,
) {
const accessToken = await this.authService.login(loginDto);
response.cookie('access_token', accessToken, {
httpOnly: true,
});
return true;
}
컨트롤러에선 서비스에서 받은 accessToken을 유저의 cookie에 넣어준다.
2. 토큰 재발급
아래 부분이 토큰 재발급을 하는 코드이다.
자세히 알아보자.
먼저 컨트롤러에 토큰 재발급 요청이 들어온다.(클라이언트가 어떠한 요청에 401을 받고, "토큰 재발급 해주세요~" 라고 요청이 온 상태이다.)
@Post('rotate')
async rotate(
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
) {
const accessToken = request.cookies?.['access_token'];
if (!accessToken) {
return response.status(401).json({ message: '토큰이 없습니다.' });
}
const newAccessToken = await this.authService.rotate(accessToken);
response.cookie('access_token', newAccessToken, {
httpOnly: true,
});
return '토큰 갱신 완료';
}
accessToken이 있는지 확인하고, 있다면 토큰 재발급을 요청한다.
아래는 서비스 코드이다.(비즈니스 로직)
// src/auth/auth.service.ts
async rotate(expiredToken: string) {
try {
const decoded = this.jwtService.decode(expiredToken) as {
sub: number;
};
if (!decoded || !decoded.sub) {
throw new UnauthorizedException('토큰이 올바르지 않습니다.');
}
const storedRefreshToken = await this.redisClient.get(
`refresh_token:${decoded.sub}`,
);
if (!storedRefreshToken) {
throw new UnauthorizedException('토큰이 만료되었습니다.');
}
try {
this.jwtService.verify(storedRefreshToken);
} catch (error) {
await this.redisClient.del(`refresh_token:${decoded.sub}`);
throw new UnauthorizedException('유효하지 않은 리프레시 토큰입니다.');
}
const user = await this.userRepository.findOne({
where: {
id: decoded.sub,
},
});
const newAccessToken = await this.generateAccessToken(
user.email,
user.id,
);
return newAccessToken;
} catch (error) {
console.error(error);
return null;
}
}
아래 부분을 보자.
const decoded = this.jwtService.decode(expiredToken) as {
sub: number;
};
토큰이 만료됐지만 decode를 통해 토큰안에 있는 값을 뽑아낼 순 있다. (헷갈릴 수 있어서 다시 한번!! 만료된 토큰에서 id를 뽑아낸다.)
const storedRefreshToken = await this.redisClient.get(
`refresh_token:${decoded.sub}`,
);
이렇게 뽑아낸 id를 키로 가지고 있는 refresh_token이 redis에 있는지 확인한다.
(노파심에 말하지만, refresh_token을 저장할 때 `refresh_token:유저id` 로 저장을 해서 위와 같이 레디스 값을 검색한다.
저장하는 키 이름은 다들 편한대로 하면 된다. 유저id로 식별할 수 있으면 된다!)
`storedRefreshToken`에 대한 검증을 다 통과하면 사용자에게 새로운 access_token을 생성해 반환한다.
@Post('rotate')
async rotate(
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
) {
const accessToken = request.cookies?.['access_token'];
if (!accessToken) {
return response.status(401).json({ message: '토큰이 없습니다.' });
}
const newAccessToken = await this.authService.rotate(accessToken);
response.cookie('access_token', newAccessToken, {
httpOnly: true,
});
return '토큰 갱신 완료';
}
새로 발급한 `newAccessToken` 을 유저의 쿠키에 넣어준다!
이렇게 토큰 재발급에 대한 NestJS 코드 설명이 끝났다!
5. SpringBoot 코드
controller 코드를 시작으로 토큰 재발급 로직을 따라가보자.
package backend_springboot.domain.auth.api;
import backend_springboot.config.argumentresolver.AuthorizedMember;
import backend_springboot.config.interceptor.Authorization;
import backend_springboot.domain.auth.application.AuthService;
import backend_springboot.domain.auth.application.RotateAccessTokenService;
import backend_springboot.domain.auth.domain.entity.User;
import backend_springboot.domain.auth.dto.request.LoginRequest;
import backend_springboot.domain.auth.dto.request.SignupRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@Slf4j
@RestController
@RequestMapping("auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
private final RotateAccessTokenService rotateAccessTokenService;
@PostMapping("signup")
public void signup(@RequestBody SignupRequest signupRequest) {
System.out.println("signupRequest = " + signupRequest);
authService.signup(signupRequest);
}
@PostMapping("login")
public ResponseEntity<?> login(@RequestBody LoginRequest loginRequest) {
String accessToken = authService.login(loginRequest);
ResponseCookie cookie = ResponseCookie.from("access_token", accessToken)
.httpOnly(true)
.path("/")
.build();
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, cookie.toString())
.build();
}
@PostMapping("/rotate")
public ResponseEntity<?> rotate(@CookieValue("access_token") String accessToken) {
log.info("rotate accessToken = {}", accessToken);
String newAccessToken = authService.rotateAccessToken(accessToken);
log.info("newAccessToken = {}", newAccessToken);
ResponseCookie cookie = ResponseCookie.from("access_token", newAccessToken)
.httpOnly(true)
.path("/")
.build();
return ResponseEntity.created(null)
.header(HttpHeaders.SET_COOKIE, cookie.toString())
.build();
}
@Authorization
@GetMapping("/health")
public String health(@AuthorizedMember User user) {
return "건강합니다. - 스프링부트";
}
@PostMapping("/logout")
public ResponseEntity<?> logout() {
ResponseCookie cookie = ResponseCookie.from("access_token", "") // 값을 비움
.httpOnly(true)
.path("/")
.maxAge(0) // 쿠키를 즉시 만료
.build();
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, cookie.toString())
.body("로그아웃 성공!");
}
}
(스프링에서 인증/인가는 Spring Security로 구현해도 되지만, 현재 코드는 argumentResolver와 interceptor를 통해 인증/인가를 구현했다. 자세한 코드는 깃허브에서 확인할 수 있다.)
1. 로그인
@PostMapping("login")
public ResponseEntity<?> login(@RequestBody LoginRequest loginRequest) {
String accessToken = authService.login(loginRequest);
ResponseCookie cookie = ResponseCookie.from("access_token", accessToken)
.httpOnly(true)
.path("/")
.build();
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, cookie.toString())
.build();
}
쿠키에 발급받은 accessToken을 넣어준다.
refreshToken은 어떻게 저장할까?
// backend_springboot.domain.auth.application
public void saveRefreshToken(User user) {
long REFRESH_TOKEN_EXPIRATION = 60 * 60 * 24 * 7;
refreshTokenRepository.save(
user.getId(),
jwtService.provideRefreshToken(user),
REFRESH_TOKEN_EXPIRATION,
convertChronoUnitToTimeUnit(ChronoUnit.DAYS)
);
}
(설명을 위해 하드코딩한 부분들이 있다. 이런 부분은 흐린눈을 하고 넘어가주세요...)
refreshToken은 키 : userId, 값 : refreshToken으로 redis에 저장한다.
2. 토큰 재발급
아래 부분이 토큰 재발급을 하는 코드이다.
자세히 알아보자.
먼저 컨트롤러에 토큰 재발급 요청이 들어온다.(클라이언트가 어떠한 요청에 401을 받고, "토큰 재발급 해주세요~" 라고 요청이 온 상태이다.)
@PostMapping("/rotate")
public ResponseEntity<?> rotate(@CookieValue("access_token") String accessToken) {
String newAccessToken = rotateAccessTokenService.rotateAccessToken(accessToken);
ResponseCookie cookie = ResponseCookie.from("access_token", newAccessToken)
.httpOnly(true)
.path("/")
.build();
URI location = URI.create("/auth/rotate");
return ResponseEntity.created(location)
.header(HttpHeaders.SET_COOKIE, cookie.toString())
.build();
}
쿠키에 존재하는 `access_token`을 뽑아내고 newAccessToken을 발급받는다.
// RotateAccessTokenService
public String rotateAccessToken(String accessToken) {
Long userIdFromInvalidAccessToken = jwtService.extractUserIdFromInvalidAccessToken(accessToken);
User user = userRepository.findById(userIdFromInvalidAccessToken).orElseThrow();
String refreshToken = refreshTokenService.getRefreshToken(user.getId());
if (refreshToken == null) {
throw new IllegalArgumentException("Refresh token is not exist");
}
Long userId = extractUserIdFromRefreshToken(refreshToken);
validateRefreshToken(userId, refreshToken);
return jwtService.provideAccessToken(user);
}
아래 코드에서 userId를 뽑아낸다.
Long userId = jwtService.extractUserIdFromInvalidAccessToken(accessToken);
// JwtService
public Long extractUserIdFromInvalidAccessToken(String invalidAccessToken) {
try {
Claims claims = Jwts.parser()
.verifyWith(SECRET_KEY)
.build()
.parseSignedClaims(invalidAccessToken)
.getPayload();
String userId = claims.get("userId", String.class);
return Long.parseLong(userId);
} catch (ExpiredJwtException e) {
String userId = e.getClaims().get("userId", String.class);
return Long.parseLong(userId);
} catch (Exception e) {
throw new RuntimeException("Failed to extract userId from invalid token", e);
}
코드를 보면 알지만, 만료된 토큰에서 userId를 가져온다.
// RotateAccessTokenService
public String rotateAccessToken(String accessToken) {
Long userIdFromInvalidAccessToken = jwtService.extractUserIdFromInvalidAccessToken(accessToken);
User user = userRepository.findById(userIdFromInvalidAccessToken).orElseThrow();
String refreshToken = refreshTokenService.getRefreshToken(user.getId());
if (refreshToken == null) {
throw new IllegalArgumentException("Refresh token is not exist");
}
Long userId = extractUserIdFromRefreshToken(refreshToken);
validateRefreshToken(userId, refreshToken);
return jwtService.provideAccessToken(user);
}
userId를 뽑아냈으니, 해당 userId를 통해 user가 존재하는지 확인한다.
user가 존재한다면 userId를 통해 refreshToken을 뽑아낸다.
(레디스에 userId를 키로, refreshToken을 값으로 저장한다.
상세 코드는 깃허브에 들어가
backend_springboot.domain.auth.infrastructure.redis.repository에 RefreshTokenRedisRepository의 save() 메서드를 보면 된다.)
refreshToken에 대한 검증이 끝나면 새로운 accessToken을 발급한다.
// AuthController
@PostMapping("/rotate")
public ResponseEntity<?> rotate(@CookieValue("access_token") String accessToken) {
String newAccessToken = rotateAccessTokenService.rotateAccessToken(accessToken);
ResponseCookie cookie = ResponseCookie.from("access_token", newAccessToken)
.httpOnly(true)
.path("/")
.build();
URI location = URI.create("/auth/rotate");
return ResponseEntity.created(location)
.header(HttpHeaders.SET_COOKIE, cookie.toString())
.build();
}
`newAccessToken` 을 유저의 쿠키에 넣어 반환하면 끝이다!
이렇게 SpringBoot를 통한 토큰 재발급 로직을 알아보았다!!
참 길다.
토큰 재발급을 통한 로그인 유지하기는 언제나 나를 괴롭혔었다.
처음 개발을 공부할 때, 그 후 여러 프로젝트를 하면서 항상 애매하게 알았어서 구현을 잘 못했다.
이번 글을 쓰면서 여러 스택으로 구현해보니 이젠 좀 알거같다 ㅎ..
글을 보는 사람들이 이 글을 통해 딱 이해했으면 좋겠지만, 모두가 그럴 순 없으니!
궁금한 점이 있다면 댓글을 달아주고, 설명하며 틀린 내용이 있으면 지적해주길 바란다~!