반응형
이번 포스트에서는 Jwt 인증 필터를 생성하고 Spring Security의 필터체인(FilterChain)에 연결하자
코드를 사용할때 import는 시리즈 2편에 작성된 구성을 확인해 보고 사용하는 것을 추천한다.(버전이 안맞아서 오류가 생기는 경우가 존재)👇🏻👇🏻
1. Jwt 인증 필터 생성 - JwtAuthorizationFilter
1-1. JwtAuthorizationFilter 클래스 작성하기
- 이 코드는 Spring Security에서 사용되는 JWT 인증 필터를 정의하고 있다. 이 필터는 클라이언트의 요청이 서버에 도달하기 전에 실행되어 JWT 토큰의 유효성을 검사하고, 해당 토큰에 기반한 사용자 인증을 수행한다.
- 아래 작성한 JwtAuthorizationFilter 코드는 OncePerRequestFilter를 상속받아 요청당 한 번만 이 필터가 실행되도록 한다.
/**
* 지정한 URL별 JWT의 유효성 검증을 수행하며 직접적인 사용자 인증을 확인합니다.
*/
@Slf4j
@RequiredArgsConstructor
public class JwtAuthorizationFilter extends OncePerRequestFilter {
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(
HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain
) throws ServletException, IOException {
// 1. 토큰이 필요하지 않은 API URL에 대해서 배열로 구성한다.
List<String> list = Arrays.asList(
"/user/login", // 로그인 페이지의 URL을 추가합니다.
"/login", // 로그인 페이지의 URL을 추가합니다.
"/css/**",
"/js/**",
"/images/**"
);
// 2. 토큰이 필요하지 않은 API URL의 경우 -> 로직 처리없이 다음 필터로 이동한다.
if (list.contains(request.getRequestURI())) {
filterChain.doFilter(request, response);
return;
}
// 3. OPTIONS 요청일 경우 -> 로직 처리 없이 다음 필터로 이동
if (request.getMethod().equalsIgnoreCase("OPTIONS")) {
filterChain.doFilter(request, response);
return;
}
// [STEP.1] Client에서 API를 요청할때 쿠키를 확인한다.
Cookie[] cookies = request.getCookies();
String token = null;
if (cookies != null) {
for (Cookie cookie : cookies) {
if ("jwt".equals(cookie.getName())) {
token = cookie.getValue();
break;
}
}
}
try {
// [STEP.2-1] 쿠키 내에 토큰이 존재하는 경우
if (token != null && !token.equalsIgnoreCase("")) {
// [STEP.2-2] 쿠키 내에있는 토큰이 유효한지 여부를 체크한다.
if (TokenUtils.isValidToken(token)) {
// [STEP.2-3] 추출한 토큰을 기반으로 사용자 아이디를 반환받는다.
String loginId = TokenUtils.getUserIdFromToken(token);
log.debug("[+] loginId Check: " + loginId);
// [STEP.2-4] 사용자 아이디가 존재하는지에 대한 여부를 체크한다.
if (loginId != null && !loginId.equalsIgnoreCase("")) {
UserDetails userDetails = userDetailsService.loadUserByUsername(loginId);
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response);
} else {
throw new ProfileApplicationException(ErrorCode.USER_NOT_FOUND);
}
}
// [STEP.2-5] 토큰이 유효하지 않은 경우
else {
throw new ProfileApplicationException(ErrorCode.TOKEN_NOT_VALID);
}
}
// [STEP.3] 토큰이 존재하지 않는 경우
else {
throw new ProfileApplicationException(ErrorCode.TOKEN_NOT_FOUND);
}
} catch (Exception e) {
// 로그 메시지 생성
String logMessage = jsonResponseWrapper(e).getString("message");
log.error(logMessage, e); // 로그에만 해당 메시지를 출력합니다.
// 클라이언트에게 전송할 고정된 메시지
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
PrintWriter printWriter = response.getWriter();
JSONObject jsonObject = new JSONObject();
jsonObject.put("error", true);
jsonObject.put("message", "로그인 에러");
printWriter.print(jsonObject);
printWriter.flush();
printWriter.close();
}
}
/**
* 토큰 관련 Exception 발생 시 예외 응답값 구성
*/
private JSONObject jsonResponseWrapper(Exception e) {
String resultMessage = "";
// JWT 토큰 만료
if (e instanceof ExpiredJwtException) {
resultMessage = "TOKEN Expired";
}
// JWT 허용된 토큰이 아님
else if (e instanceof SignatureException) {
resultMessage = "TOKEN SignatureException Login";
}
// JWT 토큰내에서 오류 발생 시
else if (e instanceof JwtException) {
resultMessage = "TOKEN Parsing JwtException";
}
// 이외 JTW 토큰내에서 오류 발생
else {
resultMessage = "OTHER TOKEN ERROR";
}
HashMap<String, Object> jsonMap = new HashMap<>();
jsonMap.put("status", 401);
jsonMap.put("code", "9999");
jsonMap.put("message", resultMessage);
jsonMap.put("reason", e.getMessage());
JSONObject jsonObject = new JSONObject(jsonMap);
log.error(resultMessage, e);
return jsonObject;
}
}
1-2. 멤버 변수 소개
- userDetailsService는 Spring Security의 UserDetailsService 인터페이스를 구현한 서비스를 주입받는다. (커스텀하게 내맘대로 만든것이 아니라 UserDetailsService 인터페이스를 구현해서 만들었다는 것을 꼭 알아야 한다.)
- 이 서비스 코드는 사용자의 세부 정보를 로드하는 데 사용된다.
1-3. doFilterInternal 메서드
- 이 메서드는 필터의 주요 로직을 포함하며, 요청이 들어올 때마다 실행된다.
- 토큰이 필요하지 않은 API URL을 정의하고, 해당 URL에 대한 요청이 들어오면 다음 필터로 이동한다. OPTIONS 요청의 경우에도 다음 필터로 이동한다.
- 클라이언트의 쿠키에서 "jwt"라는 이름의 쿠키를 찾아 JWT 토큰을 가져온다. 만약 토큰이 유효한 경우, 토큰에서 사용자 ID를 추출하고, 해당 ID를 사용하여 사용자의 세부 정보를 로드한다. 그런 다음, 인증 토큰을 생성하고 SecurityContext에 설정한다.
- 토큰이 유효하지 않거나 존재하지 않는 경우, 예외를 발생시킨다. 예외가 발생하면, 클라이언트에 JSON 형식의 오류 응답을 반환한다.
@Override
protected void doFilterInternal(
HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain
) throws ServletException, IOException {
// 1. 토큰이 필요하지 않은 API URL에 대해서 배열로 구성한다.
List<String> list = Arrays.asList(
"/user/login", // 로그인 페이지의 URL을 추가합니다.
"/login", // 로그인 페이지의 URL을 추가합니다.
"/css/**",
"/js/**",
"/images/**"
);
// 2. 토큰이 필요하지 않은 API URL의 경우 -> 로직 처리없이 다음 필터로 이동한다.
if (list.contains(request.getRequestURI())) {
filterChain.doFilter(request, response);
return;
}
// 3. OPTIONS 요청일 경우 -> 로직 처리 없이 다음 필터로 이동
if (request.getMethod().equalsIgnoreCase("OPTIONS")) {
filterChain.doFilter(request, response);
return;
}
// [STEP.1] Client에서 API를 요청할때 쿠키를 확인한다.
Cookie[] cookies = request.getCookies();
String token = null;
if (cookies != null) {
for (Cookie cookie : cookies) {
if ("jwt".equals(cookie.getName())) {
token = cookie.getValue();
break;
}
}
}
try {
// [STEP.2-1] 쿠키 내에 토큰이 존재하는 경우
if (token != null && !token.equalsIgnoreCase("")) {
// [STEP.2-2] 쿠키 내에있는 토큰이 유효한지 여부를 체크한다.
if (TokenUtils.isValidToken(token)) {
// [STEP.2-3] 추출한 토큰을 기반으로 사용자 아이디를 반환받는다.
String loginId = TokenUtils.getUserIdFromToken(token);
log.debug("[+] loginId Check: " + loginId);
// [STEP.2-4] 사용자 아이디가 존재하는지에 대한 여부를 체크한다.
if (loginId != null && !loginId.equalsIgnoreCase("")) {
UserDetails userDetails = userDetailsService.loadUserByUsername(loginId);
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response);
} else {
throw new ProfileApplicationException(ErrorCode.USER_NOT_FOUND);
}
}
// [STEP.2-5] 토큰이 유효하지 않은 경우
else {
throw new ProfileApplicationException(ErrorCode.TOKEN_NOT_VALID);
}
}
// [STEP.3] 토큰이 존재하지 않는 경우
else {
throw new ProfileApplicationException(ErrorCode.TOKEN_NOT_FOUND);
}
} catch (Exception e) {
// 로그 메시지 생성
String logMessage = jsonResponseWrapper(e).getString("message");
log.error(logMessage, e); // 로그에만 해당 메시지를 출력합니다.
// 클라이언트에게 전송할 고정된 메시지
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
PrintWriter printWriter = response.getWriter();
JSONObject jsonObject = new JSONObject();
jsonObject.put("error", true);
jsonObject.put("message", "로그인 에러");
printWriter.print(jsonObject);
printWriter.flush();
printWriter.close();
}
}
1-4. jsonResponseWrapper 메서드:
- 발생한 예외에 따라 적절한 오류 메시지를 설정하고, 이를 JSON 객체로 변환하여 반환한다.
- JWT 토큰의 만료, 서명 오류, 파싱 오류 등 다양한 예외 유형을 처리한다.
/**
* 토큰 관련 Exception 발생 시 예외 응답값 구성
*/
private JSONObject jsonResponseWrapper(Exception e) {
String resultMessage = "";
// JWT 토큰 만료
if (e instanceof ExpiredJwtException) {
resultMessage = "TOKEN Expired";
}
// JWT 허용된 토큰이 아님
else if (e instanceof SignatureException) {
resultMessage = "TOKEN SignatureException Login";
}
// JWT 토큰내에서 오류 발생 시
else if (e instanceof JwtException) {
resultMessage = "TOKEN Parsing JwtException";
}
// 이외 JTW 토큰내에서 오류 발생
else {
resultMessage = "OTHER TOKEN ERROR";
}
HashMap<String, Object> jsonMap = new HashMap<>();
jsonMap.put("status", 401);
jsonMap.put("code", "9999");
jsonMap.put("message", resultMessage);
jsonMap.put("reason", e.getMessage());
JSONObject jsonObject = new JSONObject(jsonMap);
log.error(resultMessage, e);
return jsonObject;
}
1-5. 작성한 JwtAuthorizationFilter 필터의 역할
JwtAuthorizationFilter는 클라이언트의 요청이 서버의 리소스에 도달하기 전에 JWT 토큰의 유효성을 검사하고, 해당 토큰을 기반으로 사용자를 인증하는 중요한 역할을 한다. 유효하지 않은 토큰 또는 인증되지 않은 요청의 경우, 적절한 오류 응답을 클라이언트에 반환하여 요청을 거부한다.
2. Filter 클래스 설정 - CustomAuthenticationFilter
2-1. CustomAuthenticationFilter 클래스 작성하기
- CustomAuthenticationFilter 클래스는 Spring Security의 UsernamePasswordAuthenticationFilter 클래스를 상속받아 사용자 정의 인증 필터를 구현한 것이다. 이 필터는 사용자의 로그인 요청을 처리하고, 인증을 시도한다.
@Slf4j
public class CustomAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
public CustomAuthenticationFilter(AuthenticationManager authenticationManager) {
super(authenticationManager);
}
/**
* 이 메서드는 사용자가 로그인을 시도할 때 호출된다.
* HTTP 요청에서 사용자 이름과 비밀번호를 추출하여 UsernamePasswordAuthenticationToken 객체를 생성하고, 이를 AuthenticationManager에 전달하여 인증을 시도한다.
* 인증이 성공하면 인증된 사용자의 정보와 권한을 담은 Authentication 객체를 반환하고, 인증이 실패하면 AuthenticationException을 던진다.
*/
@Override
public Authentication attemptAuthentication(
HttpServletRequest request,
HttpServletResponse response
) throws AuthenticationException {
UsernamePasswordAuthenticationToken authRequest;
try {
authRequest = getAuthRequest(request);
setDetails(request, authRequest);
} catch (Exception e) {
throw new ProfileApplicationException(ErrorCode.BUSINESS_EXCEPTION_ERROR);
}
// Authentication 객체를 반환한다.
return this.getAuthenticationManager().authenticate(authRequest);
}
/**
* 이 메서드는 HTTP 요청에서 사용자 이름과 비밀번호를 추출하여 UsernamePasswordAuthenticationToken 객체를 생성하는 역할을 한다.
* HTTP 요청의 입력 스트림에서 JSON 형태의 사용자 이름과 비밀번호를 읽어 UserDto 객체를 생성하고, 이를 기반으로 UsernamePasswordAuthenticationToken 객체를 생성한다.
*/
private UsernamePasswordAuthenticationToken getAuthRequest(
HttpServletRequest request
) throws Exception {
try {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
objectMapper.configure(JsonParser.Feature.AUTO_CLOSE_SOURCE, true);
UserDto user = objectMapper.readValue(request.getInputStream(), UserDto.class);
log.debug("1.CustomAuthenticationFilter :: loginId: " + user.loginId() + "userPw: " + user.password());
/**
* ID, PW를 기반으로 UsernamePasswordAuthenticationToken 토큰을 발급한다.
* UsernamePasswordAuthenticationToken 객체가 처음 생성될 때 authenticated 필드는 기본적으로 false로 설정된다.
*/
return new UsernamePasswordAuthenticationToken(user.loginId(), user.password());
} catch (UsernameNotFoundException e) {
throw new UsernameNotFoundException(e.getMessage());
} catch (Exception e) {
throw new ProfileApplicationException(ErrorCode.IO_ERROR);
}
}
}
2-2. 클래스 설명
- CustomAuthenticationFilter 클래스는 사용자가 로그인 폼을 통해 제출한 사용자 이름과 비밀번호를 가지고 인증을 시도한다.
- 인증이 성공하면, 인증된 사용자의 정보와 권한을 담은 Authentication 객체를 생성하여 SecurityContext에 저장한다.
- 이 필터는 /user/login 엔드포인트로 들어오는 요청을 처리한다.
2-3. 생성자
- AuthenticationManager 객체를 인자로 받아 부모 클래스의 생성자에 전달한다. 이 AuthenticationManager는 인증을 처리하는 데 사용된다.
public CustomAuthenticationFilter(AuthenticationManager authenticationManager) {
super(authenticationManager);
}
2-4. attemptAuthentication 메서드 설명
- 이 메서드는 사용자가 로그인을 시도할 때 호출된다. HTTP 요청에서 사용자 이름과 비밀번호를 추출하여 UsernamePasswordAuthenticationToken 객체를 생성하고, 이를 AuthenticationManager에 전달하여 인증을 시도한다.
- 만약 인증이 성공하면 인증된 사용자의 정보와 권한을 담은 Authentication 객체를 반환하고, 인증이 실패하면 AuthenticationException을 발생시킨다.
@Override
public Authentication attemptAuthentication(
HttpServletRequest request,
HttpServletResponse response
) throws AuthenticationException {
UsernamePasswordAuthenticationToken authRequest;
try {
authRequest = getAuthRequest(request);
setDetails(request, authRequest);
} catch (Exception e) {
throw new ProfileApplicationException(ErrorCode.BUSINESS_EXCEPTION_ERROR);
}
// Authentication 객체를 반환한다.
return this.getAuthenticationManager().authenticate(authRequest);
}
2-5. getAuthRequest 메서드 설명
- HTTP 요청에서 사용자 이름과 비밀번호를 추출하여 UsernamePasswordAuthenticationToken 객체를 생성하는 역할을 한다.
- HTTP 요청의 입력 스트림에서 JSON 형태의 사용자 이름과 비밀번호를 읽어 UserDto 객체를 생성하고, 이를 기반으로 UsernamePasswordAuthenticationToken 객체를 생성한다.
private UsernamePasswordAuthenticationToken getAuthRequest(
HttpServletRequest request
) throws Exception {
try {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
objectMapper.configure(JsonParser.Feature.AUTO_CLOSE_SOURCE, true);
UserDto user = objectMapper.readValue(request.getInputStream(), UserDto.class);
log.debug("1.CustomAuthenticationFilter :: loginId: " + user.loginId() + "userPw: " + user.password());
/**
* ID, PW를 기반으로 UsernamePasswordAuthenticationToken 토큰을 발급한다.
* UsernamePasswordAuthenticationToken 객체가 처음 생성될 때 authenticated 필드는 기본적으로 false로 설정된다.
*/
return new UsernamePasswordAuthenticationToken(user.loginId(), user.password());
} catch (UsernameNotFoundException e) {
throw new UsernameNotFoundException(e.getMessage());
} catch (Exception e) {
throw new ProfileApplicationException(ErrorCode.IO_ERROR);
}
}
2-6. UsernamePasswordAuthenticationToken 필터가 하는 역할
- 요약하자면, CustomAuthenticationFilter 클래스는 사용자의 로그인 요청을 처리하고 인증을 시도하는 사용자 정의 필터 클래스다. 사용자가 제출한 사용자 이름과 비밀번호를 기반으로 인증 토큰을 생성하고, 이를 AuthenticationManager에 전달하여 인증을 시도한다.
3. 필터에서 사용하는 2개의 토큰 설명
UsernamePasswordAuthenticationToken과 JWT 토큰은 Spring Security에서 인증과 관련된 두 가지 다른 구성 요소다.
3-1. UsernamePasswordAuthenticationToken
- 역할
- 이 토큰은 스프링 시큐리티의 핵심 인증 메커니즘에서 중요한 역할을 한다. 사용자가 로그인을 시도할 때, 이 토큰은 제출된 아이디와 비밀번호로 생성된다.
- 인증 과정에서 AuthenticationManager에 전달되며, 인증이 성공하면 사용자의 정보와 권한이 담긴 Authentication 객체로 변환된다.
- 사용방식
- 이 토큰은 인증 과정 중에만 사용된다. 인증이 완료되면, SecurityContext에 저장되어 애플리케이션 전반에서 현재 인증된 사용자의 정보를 쉽게 참조할 수 있게 해준다.
- 이 토큰은 인증 과정 중에만 사용된다. 인증이 완료되면, SecurityContext에 저장되어 애플리케이션 전반에서 현재 인증된 사용자의 정보를 쉽게 참조할 수 있게 해준다.
3-2. JWT (JSON Web Token)
- 역할
- JWT는 사용자 세션을 상태 없이 관리하기 위한 토큰이다. 로그인이 성공하면, 서버는 사용자 정보와 권한을 담은 JWT를 생성해 클라이언트에게 반환한다.
- 클라이언트는 이 토큰을 이후의 모든 요청에 포함시켜 서버로 보내고, 서버는 이를 검증하여 사용자의 인증 상태와 권한을 확인한다.
- 사용방식
- JWT는 인증된 사용자의 세션을 유지하는 데 사용되며, 클라이언트와 서버 간의 모든 요청에 포함된다.
- 서버는 이 토큰을 통해 별도의 세션 저장소 없이도 사용자의 세션 정보를 관리할 수 있다.
3-3. 차이점 및 필터 체인에서의 사용
- UsernamePasswordAuthenticationToken:
- 스프링 시큐리티의 인증 프로세스에서 임시로 사용되는 객체다.
- 서버 내부에서만 사용되며, 클라이언트에게 전송되지 않는다.
- 인증 과정이 끝나면 SecurityContext에 저장되어 필터 체인의 다른 부분에서도 참조할 수 있다.
- JWT:
- 사용자의 인증 상태를 유지하기 위한 지속적인 토큰이다.
- 클라이언트와 서버 간에 주고받으며, 클라이언트는 이를 저장해 두었다가 요청 시마다 포함하여 보낸다.
- 필터 체인에서 JWT는 검증되고 사용된다. 로그인 후 생성되어 클라이언트에 반환되며, 이후 요청에 포함되어 필터 체인에서 검증된다.
요약하면, UsernamePasswordAuthenticationToken은 Spring Security의 인증 프로세스에서 임시로 사용되는 객체이며, JWT는 사용자의 인증 상태를 유지하기 위한 토큰이다. 두 개념은 서로 다른 목적과 사용 사례를 가지고 있다.
다음 포스트를 통해 시큐리티 인증을 통과했을때와 실패했을때 동작할 Handler를 구성해 보자👇🏻👇🏻
반응형
'Spring Security' 카테고리의 다른 글
Spring Boot 3 & Security 6 시리즈: JWT 검증 인터셉터 작성하기 (6편) (0) | 2023.08.07 |
---|---|
Spring Boot 3 & Security 6 시리즈: AuthenticationProvider, 인증 핸들러 구현하기 (5편) (0) | 2023.08.07 |
Spring Boot 3 & Security 6 시리즈: WebConfig 클래스 작성 (3편) (0) | 2023.08.07 |
Spring Boot 3 & Security 6 시리즈: SecurityConfig 클래스 작성하기 (2편) (4) | 2023.08.07 |
Spring Boot 3 & Security 6 시리즈: JWT 로그인 폼 구현 (1편) (0) | 2023.08.07 |