- 해당 과정에서는 클라이언트에서 로그인 정보를 전달하면, 데이터베이스에서 이를 조회하여 ‘인증’을 수행합니다. - 그리고 이 인증이 완료된 경우, JWT 토큰을 발급하여 인증이 된 사용자로 ‘인가(권한부여)’를 하는 프로세스입니다.
2. Spring Boot Security의 접근 토큰(Access Token) 발급 처리 과정
💡Spring Boot Security의 접근 토큰(Access Token) 발급 처리 과정
- 클라이언트의 호출 이후 내부적으로 처리되는 API Server의 과정에 대해 확인합니다.
1. 클라이언트의 API 호출 : 엔드포인트(api/v1/user/login) - 클라이언트는 사용자 아이디, 비밀번호를 기반으로 API 호출을 수행합니다.
2. 서버 내에서 해당 엔드포인트에 대한 감지 하여 이를 처리합니다. : CustomAuthenticationFilter - CustomAuthenticationFilter 내에서 사전에 지정한 엔드포인트를 통해서 수행처리 합니다.
3. 감지 이후 Filter에서 우선적으로 감지하여 해당 엔드포인트로 호출되는 정보를 조회합니다. : JwtAuthorizationFilter - 3.1. JWT가 필요가 없는 URL인 경우는 다음 필터를 수행하도록 처리가 됩니다. - 3.2. JWT가 필요한 경우는 JWT에 대한 존재 및 유효성을 검증합니다.
4. 사용자 아이디/비밀번호를 감지하여 전달합니다.: CustomAuthenticationFilter - CustomAuthenticationFilter 내에서 사용자 아이디/비밀번호 값을 CustomAuthenticationProvider로 전달을 합니다. 5. 전달받은 값을 통해 데이터베이스 내의 사용자 정보를 조회하여 사용자 여부를 확인합니다. : CustomAuthenticationProvider - 데이터베이스를 호출하여 사용자를 조회하여 실제 사용자 여부인지 여부를 성공(CustomAuthSuccessHandler)과 실패(CustomAuthFailureHandler)로 전달을 합니다.
6. 이전 과정에서 사용자 여부가 비교되어서 상황에 따른 처리를 수행합니다. - 6.1. 성공 시, 조회된 사용자 정보와 토큰을 발급하여 클라이언트에게 전달합니다. - 6.2. 실패 시, 클라이언트에게 오류 코드와 오류 메시지를 전달합니다.
https://adjh54.tistory.com/576
💡 [참고] 해당 프로세스에 대해 상세히 궁금하시면 아래의 글에 대해 참고하시면 도움이 됩니다.
- 사용자 인증 후 서버에서 발급이 되며, 클라이언트가 보호된 리소스에 접근할 때 사용을 합니다. 이는 암호화된 문자열 형태로 구성이 되어 있습니다. - 일반적으로 JWT(JSON Web Token) 형식으로 발급되며, 헤더, 페이로드, 서명으로 구성이 됩니다.
특징
설명
짧은 유효 기간
보안을 위해 일반적으로 짧은 유효 기간(예: 15분~1시간)을 가집니다.
클라이언트 저장
주로 클라이언트 측(브라우저 등)에 저장되어 요청 시 함께 전송됩니다.
무상태성
서버에 상태를 저장하지 않아 확장성이 좋습니다.
자체 포함
사용자 정보와 권한 등을 토큰 자체에 포함하고 있습니다.
재사용성
여러 서비스나 API에서 동일한 토큰으로 인증할 수 있습니다.
2. 갱신 토큰(Refresh Token)
💡 갱신 토큰(Refresh Token)
- 접근 토큰(Access Token)이 만료되었을 때 ‘새로운 접근 토큰을 발급받기 위해 사용되는 토큰’입니다. - 이 갱신 토큰을 통해 리소스의 접근은 불가능합니다. 접근 토큰에 비해 더 긴 유효시간을 가지며, 보안 강화를 위해서 사용됩니다.
특징
설명
긴 유효 기간
접근 토큰보다 더 오래 유효하여 사용자 경험을 개선합니다.
보안 강화
접근 토큰의 유효 기간을 짧게 유지하면서도 로그인 상태를 오래 유지할 수 있게 합니다.
서버 측 저장
일반적으로 데이터베이스에 저장되어 관리됩니다.
재발급 과정
접근 토큰 만료 시 갱신 토큰을 사용해 새로운 접근 토큰을 발급받습니다.
[ 더 알아보기 ] 💡 갱신 토큰(Refresh Token) 또한 만료가 되면 어떻게 되는 걸까?
- 갱신 토큰이 만료되면 사용자는 다시 로그인을 해야 합니다. 이는 보안을 위한 조치입니다. 일반적으로 갱신 토큰의 유효시간은 접근 토큰보다 훨씬 깁니다. 일반적인 갱신토큰이 30분 ~ 1시간을 가진다면 갱신 토큰의 경우는 2주 또는 1개월이 됩니다.
💡 갱신 토큰(Refresh Token)을 사용하지 않고 접근 토큰(Access Token)을 길게 사용하면 되는 것 아닐까?
- 접근 토큰을 사용하지 않고 접근 토큰을 길게 가져가면, 보안 위험이 있습니다. 유효기간이 길어짐에 따라 탈취 시 오랫동안 악용될 수 있습니다. 또한, 사용자의 권한 변경이나 토큰 폐기가 어려워집니다.
💡 갱신 토큰이 2주 또는 1개월이 된다면, 로그인이 2주 또는 1개월 동안 유지된다는 것 아닌가? 이거 위험하지 않아?
- 아닙니다. 갱신 토큰은 ‘접근 토큰을 새로 발급받기 위한 용도로만 사용이 됩니다.’ 이를 직접적으로 사용자의 로그인에서 사용되지는 않습니다. - 서버 측 제어: 서버는 보안 정책에 따라 갱신 토큰의 사용을 제한하거나 무효화할 수 있습니다. 예를 들어, 일정 횟수 이상 사용되면 갱신 토큰을 무효화할 수 있습니다. - 동적 보안 관리: 서버는 보안상의 이유로 언제든지 갱신 토큰을 무효화할 수 있습니다. 이는 의심스러운 활동이 감지되거나 사용자의 권한이 변경될 때 유용합니다.
3) Client와 API Server 간의 로그인, 리소스 처리
1. 로그인 수행 이후 리소스 요청까지의 처리 관계
💡로그인 수행 이후 리소스 요청까지의 처리 관계
1. Client → API Server : 로그인 수행
- [POST] api/v1/user/login의 엔드포인트를 통해 사용자 아이디, 비밀번호로 API Server로 요청을 합니다.
2. API Server → Client : 로그인 수행 결과 반환
- 로그인 수행에 따른 결과값을 반환받습니다. - 사용자 정보, 접근 토큰, 갱신 토큰을 클라이언트에게 반환합니다.
3. Client : 접근 토큰, 갱신 토큰 저장
- 접근, 갱신 토큰을 localstorage 내에 저장해 두어 유지합니다. 4. Client → API Server : 리소스 접근 요청
- [POST] api/v1/user/user의 엔드포인트를 통해 API Server로 요청을 합니다. - 요청 시 Header 내에 Authorization, x-refresh-token 키 값에 접근/갱신 토큰을 함께 전송합니다.
2. 리소스 요청 이후 접근/리프레시 토큰을 확인하고 리소스를 접근 처리 관계
💡리소스 요청 이후 접근/리프레시 토큰을 확인하고 리소스를 접근 처리 관계
1. 클라이언트는 API Server로 리소스를 요청합니다. - 요청 시 Header 내에 Authorization로 접근 토큰(Access Token)을 보내며, x-refresh-token로 갱신 토큰(Refresh Token)을 함께 전달합니다.
2. Header 내에 Authorization, x-refresh-token를 확인하여 접근/갱신 토큰의 존재여부를 체크합니다. - 2.1. 토큰이 존재하면 다음 프로세스를 진행합니다. - 2.2. 토큰이 존재하지 않으면 “토큰이 존재하지 않습니다”라는 에러메시지를 클라이언트에게 전달합니다.
3. 접근 토큰(Access Token)의 유효성을 체크합니다. - 3.1. 접근 토큰이 유효하다면 다음 프로세스를 진행합니다. - 3.2. 접근 토큰이 존재하지 않으면 접근 토큰의 에러 정보를 확인합니다.
4. 접근 토큰(Access Token) 내에 전달하려는 사용자 정보를 확인합니다. 4.1. 사용자 정보가 존재한다면 다음 필터로 이동을 합니다. 4.2. 사용자 정보가 존재하지 않는 다면 에러 메시지를 클라이언트에게 전달합니다.
5. 접근 토큰(Access Token)에서 발생한 오류가 만료된 (TOKEN_EXPIRED) 오류인지를 체크합니다. - 5.1. 오류가 토큰이 만료된 오류 인 경우 다음 프로세스를 진행합니다. - 5.2. 오류가 토큰이 만료된 경우가 아닌 경우 에러 메시지를 클라이언트에게 전달합니다.
6. 리프레시 토큰(Refresh Token)이 유효한지 체크를 합니다. - 6.1. 리프레시 토큰이 유효하다면 접근 토큰을 갱신합니다. 갱신하여 재 생성된 접근 토큰을 반환합니다. - 6.2. 리프레시 토큰이 유효하지 않다면 에러 메시지를 클라이언트에게 전달합니다.
💡JwtAuthorizationFilter - 리소스를 접근하기 이전에 JWT에 대한 체크를 위한 JwtAuthorizationFilter가 수행이 됩니다. - 해당 과정에서 유효한 JWT를 확인하고 JWT가 만료되면 유효한 JWT를 발급하여 주는 프로세스가 포함되어 있습니다.
💡해당 필터에서는 아래의 과정으로 처리가 됩니다. 1. 클라이언트는 API Server로 리소스를 요청합니다. - 요청 시 Header 내에 Authorization로 접근 토큰(Access Token)을 보내며, x-refresh-token로 갱신 토큰(Refresh Token)을 함께 전달합니다.
2. Header 내에 Authorization, x-refresh-token를 확인하여 접근/갱신 토큰의 존재여부를 체크합니다. - 2.1. 토큰이 존재하면 다음 프로세스를 진행합니다. - 2.2. 토큰이 존재하지 않으면 “토큰이 존재하지 않습니다”라는 에러메시지를 클라이언트에게 전달합니다.
3. 접근 토큰(Access Token)의 유효성을 체크합니다. - 3.1. 접근 토큰이 유효하다면 다음 프로세스를 진행합니다. - 3.2. 접근 토큰이 존재하지 않으면 접근 토큰의 에러 정보를 확인합니다.
4. 접근 토큰(Access Token) 내에 전달하려는 사용자 정보를 확인합니다. - 4.1. 사용자 정보가 존재한다면 다음 필터로 이동을 합니다. - 4.2. 사용자 정보가 존재하지 않는 다면 에러 메시지를 클라이언트에게 전달합니다.
5. 접근 토큰(Access Token)에서 발생한 오류가 만료된 (TOKEN_EXPIRED) 오류인지를 체크합니다. - 5.1. 오류가 토큰이 만료된 오류 인 경우 다음 프로세스를 진행합니다. - 5.2. 오류가 토큰이 만료된 경우가 아닌 경우 에러 메시지를 클라이언트에게 전달합니다.
6. 리프레시 토큰(Refresh Token)이 유효한지 체크를 합니다. - 6.1. 리프레시 토큰이 유효하다면 접근 토큰을 갱신합니다. 갱신하여 재 생성된 접근 토큰을 반환합니다. - 6.2. 리프레시 토큰이 유효하지 않다면 에러 메시지를 클라이언트에게 전달합니다.
package com.adjh.springboot3security.config.filter;
import com.adjh.springboot3security.common.utils.TokenUtils;
import com.adjh.springboot3security.model.dto.UserDto;
import com.adjh.springboot3security.model.dto.ValidTokenDto;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtException;
import io.micrometer.common.util.StringUtils;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 지정한 URL 별 JWT 유효성 검증을 수행하며 직접적인 사용자 '인증'을 확인하는 필터 역할의 클래스입니다.
*
* @author : jonghoon
* @fileName : CustomAuthenticationFilter
* @since : 10/1/24
*/@Slf4j@ComponentpublicclassJwtAuthorizationFilterextendsOncePerRequestFilter{
privatestaticfinal String HTTP_METHOD_OPTIONS = "OPTIONS";
privatestaticfinal String ACCESS_TOKEN_HEADER_KEY = "Authorization";
privatestaticfinal String REFRESH_TOKEN_HEADER_KEY = "x-refresh-token";
privatestaticfinal List<String> WHITELIST_URLS = Arrays.asList(
"/api/v1/user/login",
"/api/v1/token/token",
"/user/login",
"/token/token"
);
@OverrideprotectedvoiddoFilterInternal(HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain chain)throws IOException, ServletException {
// [STEP1] 토큰이 필요하지 않는 API 호출 발생 혹은 토큰이 필요없는 HTTP Method OPTIONS 호출 시 : 아래 로직 처리 없이 다음 필터로 이동if (WHITELIST_URLS.contains(request.getRequestURI()) || HTTP_METHOD_OPTIONS.equalsIgnoreCase(request.getMethod())) {
chain.doFilter(request, response);
return; // 종료
}
try {
// [STEP2] Header 내에 Authorization, x-refresh-token를 확인하여 접근/갱신 토큰의 존재여부를 체크합니다.
String accessTokenHeader = request.getHeader(ACCESS_TOKEN_HEADER_KEY);
String refreshTokenHeader = request.getHeader(REFRESH_TOKEN_HEADER_KEY);
// [STEP2-1] 토큰이 존재하면 다음 프로세스를 진행합니다.if (StringUtils.isNotBlank(accessTokenHeader) || StringUtils.isNotBlank(refreshTokenHeader)) {
String paramAccessToken = TokenUtils.getHeaderToToken(accessTokenHeader);
String paramRefreshToken = TokenUtils.getHeaderToToken(refreshTokenHeader);
// [STEP3] 접근 토큰(Access Token)의 유효성을 체크합니다.
ValidTokenDto accTokenValidDto = TokenUtils.isValidToken(paramAccessToken);
// [STEP3-1] 접근 토큰이 유효하다면 다음 프로세스를 진행합니다.if (accTokenValidDto.isValid()) {
// [STEP4] 접근 토큰(Access Token)내에 전달하려는 사용자 정보를 확인합니다.// [STEP4-1] 사용자 정보가 존재한다면 다음 필터로 이동을 합니다.if (StringUtils.isNotBlank(TokenUtils.getClaimsToUserId(paramAccessToken))) {
chain.doFilter(request, response);
}
// [STEP4-2] 사용자 정보가 존재하지 않는 다면 에러 메시지를 클라이언트에게 전달합니다.else {
thrownew Exception("토큰 내에 사용자 아이디가 존재하지 않습니다");
}
}
// [STEP3-2] 접근 토큰이 존재하지 않으면 접근 토큰의 에러 정보를 확인합니다.else {
// [STEP5] 접근 토큰(Access Token)에서 발생한 오류가 만료된 (TOKEN_EXPIRED)오류 인지를 체크합니다.// [STEP5-1] 오류가 토큰이 만료된 오류 인 경우 다음 프로세스를 진행합니다.if (accTokenValidDto.getErrorName().equals("TOKEN_EXPIRED")) {
// [STEP6] 리프레시 토큰(Refresh Token)이 유효한지 체크를 합니다.// [STEP6-1] 리프레시 토큰이 유효하다면 접근 토큰을 갱신합니다. 갱신하여 재 생성된 접근 토큰을 반환합니다.if (TokenUtils.isValidToken(paramRefreshToken).isValid()) {
// Token 내에 사용자 정보를 추출하고 이를 기반으로 토큰을 생성합니다.
UserDto claimsToUserDto = TokenUtils.getClaimsToUserDto(paramRefreshToken, false);
System.out.println("claimsToUserDto :: " + claimsToUserDto);
String token = TokenUtils.generateJwt(claimsToUserDto); // 접근 토큰(AccessToken)을 새로 발급합니다.
sendToClientAccessToken(token, response); // 발급한 접근 토큰을 클라이언트에게 전달합니다.
chain.doFilter(request, response); // 리소스로 접근을 허용합니다.
}
// [STEP6-2] 리프레시 토큰이 유효하지 않다면 에러 메시지를 클라이언트에게 전달합니다.else {
thrownew Exception("재 로그인이 필요합니다.");
}
}
// [STEP5-2] 오류가 토큰이 만료된 경우가 아닌 경우 에러 메시지를 클라이언트에게 전달합니다.thrownew Exception("토큰이 유효하지 않습니다."); // 토큰이 유효하지 않은 경우
}
}
// [STEP2-2] 토큰이 존재하지 않으면 “토큰이 존재하지 않습니다”라는 에러메시지를 클라이언트에게 전달합니다.else {
thrownew Exception("토큰이 존재하지 않습니다."); // 토큰이 존재하지 않는 경우
}
}
// Token 내에 Exception 발생 하였을 경우 : 클라이언트에 응답값을 반환하고 종료합니다.catch (Exception e) {
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
PrintWriter printWriter = response.getWriter();
String jsonResponse = jwtTokenError(e);
printWriter.print(jsonResponse);
printWriter.flush();
printWriter.close();
}
}
/**
* JWT 내에 Exception 발생 시 JSON 형태의 예외 응답값을 구성하는 메서드
*
* @param e Exception
* @return String
*/private String jwtTokenError(Exception e){
ObjectMapper om = new ObjectMapper();
Map<String, Object> resultMap = new HashMap<>();
String resultMsg = "";
// [CASE1] JWT 기간 만료if (e instanceof ExpiredJwtException) {
resultMsg = "토큰 기간이 만료되었습니다.";
}
// [CASE2] JWT내에서 오류 발생 시elseif (e instanceof JwtException) {
resultMsg = "잘못된 토큰이 발급되었습니다.";
}
// [CASE3] 이외 JWT내에서 오류 발생else {
resultMsg = "OTHER TOKEN ERROR" + e;
}
// Custom Error Code 구성
resultMap.put("status", 403);
resultMap.put("code", "9999");
resultMap.put("message", resultMsg);
resultMap.put("reason", e.getMessage());
try {
return om.writeValueAsString(resultMap);
} catch (JsonProcessingException err) {
log.error("내부적으로 JSON Parsing Error 발생 " + err);
return"{}"; // 빈 JSON 객체를 반환
}
}
/**
* 클라이언트에게 접근 토큰을 전달합니다.
*
* @param token
* @param response
*/privatevoidsendToClientAccessToken(String token, HttpServletResponse response){
Map<String, Object> resultMap = new HashMap<>();
ObjectMapper om = new ObjectMapper();
resultMap.put("status", 401);
resultMap.put("failMsg", null);
resultMap.put("accessToken", token);
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
try {
PrintWriter printWriter = response.getWriter();
printWriter.write(om.writeValueAsString(resultMap));
printWriter.flush();
printWriter.close();
} catch (IOException e) {
log.error("[-] 결과값 생성에 실패하였습니다 : {}", e);
}
}
}
3. TokenUtils
💡TokenUtils
- 해당 유틸에서는 AccessToken, RefreshToken 등을 생성하고 유효성을 검사하는 유틸입니다.
- 클라이언트 측에서 JWT(JSON Web Token)를 사용한 인증 처리과정을 구현한 Axios 인스턴스입니다.
1. AxiosJwtInstance 생성 - 기본 URL, 타임아웃, 헤더 등의 설정을 포함한 Axios 인스턴스를 생성합니다. 2. 요청 인터셉터 : AxiosJwtInstance.interceptors.request - 서버로 요청을 보내기 전에 실행됩니다. - BLACK_LIST에 포함되지 않은 URL에 대해 로컬 스토리지에서 접근 토큰과 갱신 토큰을 가져와 요청 헤더에 추가합니다.
3. 응답 인터셉터 : AxiosJwtInstance.interceptors.response - 서버로부터 응답을 받은 후 실행됩니다. - 응답 상태 코드가 401이고 새로운 접근 토큰이 제공된 경우, 로컬 스토리지의 토큰을 갱신하고 요청을 재시도합니다.
import axios from'axios';
const BLACK_LIST = ['/api/v1/user/login']; // 토큰 처리를 제외할 목록/**
* Axios 인스턴스를 생성합니다.
* - 이 인스턴스는 기본 URL, 타임아웃, 헤더 등의 설정을 포함합니다.
*/const AxiosJwtInstance = axios.create({
baseURL: '<http://localhost:8080>',
timeout: 5000,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Credentials': true,
},
});
/**
* 요청 인터셉터 추가
* - 요청이 서버로 전송되기 전에 실행이 됩니다.
*/
AxiosJwtInstance.interceptors.request.use(
(config) => {
// BLACK_LIST 내에 요청 URL이 포함되는지 확인합니다.if (!BLACK_LIST.some((url) => config.url?.includes(url))) {
const accessToken = localStorage.getItem('accessToken'); // 접근 토큰을 로컬스토리지 내에서 가져옵니다.const refreshToken = localStorage.getItem('refreshToken'); // 리프레시 토큰을 로컬스토리지 내에서 가져옵니다.// 발급받은 토큰을 확인합니다.if (accessToken && refreshToken) {
// console.log('[+] 발급받은 토큰을 Header내에 추가합니다.');
config.headers['Authorization'] = `Bearer ${accessToken}`;
config.headers['x-refresh-token'] = `Bearer ${refreshToken}`;
}
}
return config;
},
(error) => {
console.log('[-] 요청 중 오류가 발생되었을때 수행이 됩니다. ', error);
returnPromise.reject(error);
},
);
/**
* 응답 인터셉터 추가
* - 서버로부터 응답을 받은 후, 그 응답이 then/catch 핸들러로 전달되기 전에 실행됩니다.
*/
AxiosJwtInstance.interceptors.response.use(
(response) => {
// console.log('[+] 응답이 정상적으로 수행된 경우 수행이 됩니다. ', response);// 상태코드가 401이고 accessToken이 존재하면, 토큰을 재발급 하고 API를 재 호출합니다if (response.data.status === 401 && response.data.accessToken) {
const responseConfig = response.config; // 요청에 대한 환경 정보를 다시 가져옵니다.localStorage.setItem('accessToken', response.data.accessToken); // 인증 토큰(access Token)을 재 갱신합니다.
responseConfig.headers['Authorization'] = response.data.accessToken; // API 재 호출 시 Authorization 값을 갱신하여 전달합니다.return AxiosJwtInstance(responseConfig); // API를 재 호출합니다.
}
return response;
},
(error) => {
console.log('[-] 응답이 실패한 경우 수행이 됩니다. ', error);
returnPromise.reject(error);
},
);
exportdefault AxiosJwtInstance;