JWT (feat. Spring Security, Spring Boot 3)
현재 Spring Boot 3이 기본으로 설정됨에 따라, jwt도 버전이 업그레이드 됨에 따라 그에 맞게 JWT로그인 방식을 전체적으로 수정해야했다. 아래는 Spring Boot3, jwt (0.11.5) 버전에 맞도록 구현한 JWT로그인 방식을 정리하였다.
먼저 JWT의 구조를 살펴보겠다.
JWT (Json Web Token)
JWT 토큰은 Base64로 인코딩하여 아래와 같은 문자열 형식을 사용한다.
(Header)xxxxxxxxx.(Payload)xxxxxxxxx.(Signature)xxxxxxxxx
JWT 토큰은 크게 세 가지로 나눌 수 있다.
Header
- alg : 인코딩에 사용되는 알고리즘 종류
- 현재, si-ai프로젝트에는 Keys.hmacShakeyFor() 를 통해
- HMAC-SHA알고리즘을 적용하도록 하였다.
- typ : 토큰의 타입 (JWT)
Payload
- 토큰에 담을 정보가 들어감
- Json쌍으로 구성
- 등록된 클레임 : 토큰의 기본정보를 담기 위한 이름이 정해져있는 정보
- iss : 토큰 발급자
- sub : 토큰 제목
- aud : 토큰 대상자
- exp : 토큰 만료 시간
- iat : 토큰 발급 시간
- nbf : 토큰 활성화 시간
- 공개 클레임
- 비공개 클레임
- 서비스에서 공유하기 위한 정보를 담은 클레임
Signature
- 유효성을 검증하기 위한 부분
- 헤더의 인코딩 값과 페이로드의 인코딩 값을 합친 후 토큰을 생성할 때 사용된 Secret Key를 통해 암호화하여 생성
JWT는 아래와 같은 동작으로 이루어진다.
- (클라이언트 → 서버) 로그인 요청
- (서버) DB에서 ID와 비밀번호 대조 후 일치여부 확인
- (서버) 일치 시 암호화된 토큰 생성
- (서버 → 클라이언트) 응답으로 토큰 반환
- (클라이언트) 클라이언트에 토큰 저장
- (클라이언트 → 서버) 인가 API 요청 시 헤더에 토큰을 포함시켜 요청
- (서버) 토큰을 복호화하여 유효성 검증
- (서버 → 클라이언트) 검증이 완료되었다면 API 로직 처리 후 응답을 받음
Spring Security
스프링 프레임워크에서는 인증 및 권한 부여로 리소스 사용을 컨트롤 할 수 있는 Spring Security를 제공한다.
위 프레임워크를 사용하면, 보안처리를 자체적으로 구현하지 않아도 쉽게 필요한 기능을 구현할 수 있다.
Spring Security는 스프링의 DispatcherServlet 앞단에 Filter형태로 위치한다.
Dispatcher로 넘어가기 전에 Filter가 요청을 가로채서 클라이언트의 리소스 접근 권한을 확인하고
권한이 없다면, 인증 요청화면으로 리다이렉트한다.
위 그림에서 클라이언트가 리소스에 대한 접근 권한이 없을 때 처리를 담당하는 필터는
UsernamePasswordAuthenticationFilter다.
인증 권한이 없을 때 오류를 Json으로 내려주기 위해 필터가 실행되기 전 처리가 필요하다.
API 인증 및 권한 부여를 위한 작업 순서
- 회원 가입 & 로그인 API 구현
- 리소스 접근 가능한 ROLE_USER 권한을 가입 회원에게 부여
- Spring Security 설정에서 ROLE_USER 권한을 가지면 접근 가능하도록 세팅
- 권한이 있는 회원이 로그인 성공하면 리소스 접근 가능한 JWT 토큰 발급
- 해당 회원은 권한이 필요한 API 접근 시 JWT 보안 토큰을 사용
위와 같이 접근 제한이 필요한 API에는 보안 토큰을 통해서 이 유저가 권한이 있는지 여부를 Spring Security를 통해 체크하고 리소스를 요청할 수 있도록 구성할 수 있다.
인증 및 인가를 구현할 때 사용되는 개념
- Filter Chain
- Spring Security는 다양한 Filter들의 체인으로 구성
- 이 Fitler Chain은 Request를 가로챈 후 일련의 절차를 처리
- UsernamePasswordAuthenticationFilter는 사용자가 제출한 인증정보 처리
- UsernamePasswordAuthenticationToken 생성
- UsernamePasswordAuthenticationFilter는 UsernamePasswordAuthenticationToken을 생성하여 AuthenticationManager에게 전달
- 이 토큰에는 사용자가 제출한 인증정보가 포함되어있음
- AuthenticationManager
- AuthenticationManager는 실제 인증을 수행
- 여러 AuthenticationProvider들을 사용
- AuthenticationProvider
- 각각의 Provider들은 특정 유형의 인증을 처리
- ex) DaoAuthenticationProvider는 사용자 정보를 DB에 가져와 인증 수행
- PasswordEncoder
- 인증과 인가에서 사용될 패스워드의 인코딩 방식을 지정
- UserDetailsService
- AuthenticationProvider는 UserDetailService를 사용하여 사용자 정보를 가져옴
- UserDetailService는 사용자의 아이디(이메일)를 받아 loadByUsername을 호출하여 사용자의 UserDetails를 반환
- UserDetails
- UserDetails에는 사용자 아이디(이메일), 비밀번호, 권한 등이 포함
- Authentication 객체 생성
- 인증에 성공하면, AuthenticationProvider는 Authentication객체를 생성하여 AuthenticationManager에게 반환
- Authentication 객체에는 사용자의 세부 정보와 권한이 포함
- SecurityContextHolder
- 현재 실행중인 스레드에 대한 SecurityContext를 제공
- SecurityContext
- 현재 사용자의 Authentication이 저장되어있음
- 애플리케이션은 SecurityContextHolder를 통해 사용자의 권한을 확인하고 인가 결정을 함
시큐러티 예외처리 구현
- ExceptionTranslationFilter
- 해당 Filter는 예외를 Spring Security 예외로 변환하고
- 적절한 AuthenticationEntryPoint 또는 AccessDeniedHandler를 호출한다.
- 이 Filter는 AuthenticationEntryPoint , AccessDeniedHandler를 주입받아 사용
- AuthenticationEntryPoint
- 인증되지 않은 요청에 대한 처리 담당
- 일반적으로 로그인 페이지로 리다이렉션 하거나 401 응답 반환
- AccessDeniedHandler
- 인가되지 않은 요청(권한 부족)에 대한 처리를 담당
- 일반적으로 403 Forbidden 응답을 반환한다.
따라서 Spring Security 예외 처리 흐름은 다음과 같다.
- 요청이 들어온다.
- FilterChain에서 예외가 발생한다.
- ExceptionTranslationFilter가 예외를 캐치하고, Spring Security 예외로 변환한다.
- AuthenticationEntryPoint 또는 AccessDeniedHandler가 호출되어 예외를 처리한다.
위를 코드로 구현해보자.
Spring Security
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
...
private final CustomAuthenticationEntryPoint authenticationEntryPoint;
private final CustomAccessDeniedHandler accessDeniedHandler;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
...
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(e -> e
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler)
)
...
;
return http.build();
}
}
Custom AuthenticationEntryPoint
AuthenticationEntryPoint 인터페이스는
인증되지 않은 사용자가 인증이 필요한 요청 엔드포인트로 접근하려 할 때
발생하는 401 Unauthorized 예외를 핸들링 할 수 있도록 도와준다.
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final HandlerExceptionResolver resolver;
public CustomAuthenticationEntryPoint(@Qualifier("handlerExceptionResolver") HandlerExceptionResolver resolver) {
this.resolver = resolver;
}
@Override
public void commence(@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull AuthenticationException authException) {
resolver.resolveException(request, response, null, authException);
}
}
commence 메서드는 인증되지 않은 요청이 발생했을 때 호출된다.
여기에서 예외를 처리하는 것이 아닌 HandlerExceptionResolver로 넘긴다.
관련 내용은 아래에서 자세하게 다룬다.
Custom AccessDeniedHandler
AccessDeniedHandler 인터페이스는
권한이 없는 사용자가 권한이 필요한 요청 엔드포인트로 접근하려 할 때
발생하는 403 Forbidden 예외를 핸들링 할 수 있도록 도와준다.
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
private final HandlerExceptionResolver resolver;
public CustomAccessDeniedHandler(@Qualifier("handlerExceptionResolver") HandlerExceptionResolver resolver) {
this.resolver = resolver;
}
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
resolver.resolveException(request, response, null, accessDeniedException);
}
}
handle 메서드는 권한이 없는 요청이 발생했을 때 호출된다.
AccessDeniedHandler와 마찬가지로 여기에서 예외를 처리하는 것이 아닌 HandlerExceptionResolver로 넘긴다.
HandlerExceptionResovler
HandlerExceptionResolver는 Spring Security의 영역이 아닌 Spring MVC 영역에 속해있는 컴포넌트이다.
Spring MVC에서 HandlerExceptionResolver는 DispatcherServlet의 HandlerExceptionResolver 체인(예외 처리 체인)에 등록되어 있다. 이 체인은 컨트롤러에서 발생한 예외를 처리하는 역할을 한다.
따라서 각 커스텀 구현한 AuthenticationEntryPoint와 AccessDeniedHandler에서 HandlerExceptionResolver를 호출하여 컨트롤러에서 예외를 처리할 수 있도록 한다.
resolver.resolveException(request, response, null, authException);
참고로 위 코드처럼 handler를 null로 반환하면 다음 ExceptionResolver를 찾아서 실행한다.
만약 처리할 수 없는 ExceptionResolver가 없으면 예외 처리가 안되고, 기존에 발생한 예외를 서블릿 밖으로 던진다.
우리는 null을 반환하여 @RestControllerAdvice와 @ExceptionHandler를 사용하는 클래스에서 예외를 처리하도록 한다.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler
public Response<ExceptionDto> handleAuthenticationException(AuthenticationException e) {
ExceptionDto dto = new ExceptionDto(ErrorCode.NOT_VALID_TOKEN, ErrorCode.NOT_VALID_TOKEN.getMessage());
return Response.fail(dto);
}
@ExceptionHandler
public Response<ExceptionDto> handleAccessDeniedException(AccessDeniedException e) {
ExceptionDto dto = new ExceptionDto(ErrorCode.ACCESS_DENIED, ErrorCode.ACCESS_DENIED.getMessage());
return Response.fail(dto);
}
}
클라이언트에게 예외를 반환할 때 필요한 정보만 담을 수 있도록 별도로 DTO를 생성했다.
참조 링크 : https://backend-jaamong.tistory.com/169
CustomUserDetails
Spring Security의 UserDetails 인터페이스를 구현하여 사용자 정보를 Security Context에서 사용할 수 있도록 제공
CustomUserInfoDto 객체를 기반으로 사용자 인증 및 권한 정보를 설정
- getAuthorities: 사용자 권한 목록을 반환 사용자의 역할(Role)을 기반으로 SimpleGrantedAuthority 객체를 생성
- member.getRole()를 이용해 역할을 가져오고 "ROLE_" 접두사와 결합하여 SimpleGrantedAuthority로 변환
- 최종적으로 권한 목록을 반환
@Getter
@RequiredArgsConstructor
public class CustomUserDetails implements UserDetails {
private final CustomUserInfoDto user;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
List<String> roles = new ArrayList<>();
roles.add("ROLE_" + user.getRole().toString());
return roles.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
}
CustomUserDetailsService
Spring Security에서 사용자 인증 정보를 로드하는 서비스
database에서 사용자 정보를 조회하여 'UserDetails' 객체로 변환
loadUserByUsername
- 사용자 ID(이메일)를 이용해 database에서 User 객체 조회
- 사용자가 존재하지 않으면 UsernameNotFoundException 예외 발생
- User 객체를 CustomUserInfoDto 객체로 변환 후 CustomUserDetails 객체로 감싸서 반환
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
private final ModelMapper mapper;
@Override
@Transactional(readOnly = true)
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
User user = userRepository.findByEmail(email)
.orElseThrow(()-> new UsernameNotFoundException("해당 사용자를 찾을 수 없습니다. :" + email));
CustomUserInfoDto dto = CustomUserInfoDto.of(user);
return new CustomUserDetails(dto);
}
}
JwtAuthFilter
- doFilterInternal
- Authorization 헤더 추출: HTTP 요청 헤더에서 Authorization 헤더 값을 추출
- JWT 존재 여부 확인: Authorization 헤더가 존재하고 "Bearer "로 시작하는지 확인
- 존재하면 JWT 토큰 추출
- JWT 유효성 검증: jwtUtils 객체를 사용하여 토큰의 유효성 검증
- 유효하면 AccessToken인지 검증
- UserDetails 로드
- CustomUserDetailsService를 사용하여 사용자 ID(이메일)로 UserDetails 객체 로드
- Security Context 설정
- UserDetails 객체가 null이 아닌 경우 UsernamePasswordAuthenticationToken 객체를 생성하여 사용자 인증 정보 설정
- 현재 요청의 SecurityContext에 인증 정보 설정
- 다음 필터로 요청을 전달
SecurityConfig
- AUTH_WHITELIST: 인증 없이 접근 가능한 엔드포인트 목록(로그인, 회원가입, 스웨거 UI 등)
- filterChain 메서드
- CSRF, CORS 설정: CSRF 보호 비활성화, CORS 설정을 기본값으로 설정
- 세션 관리: 세션을 사용하지 않도록 설정. JWT를 사용하기 위함
- FormLogin, BasicHttp 비활성화
- JwtAuthFilter 추가: UsernamePasswordAuthenticationFilter 앞에 JwtAuthFilter를 추가하여 JWT를 통한 인증 처리
- 예외 처리 핸들러 설정: 인증 실패 및 접근 거부 예외를 처리하는 핸들러 설정
- 권한 규칙 작성
- 화이트 리스트에 있는 경로는 누구나 접근할 수 있도록 허용
- 나머지 모든 경로는 @PreAuthorize 등의 메서드 수준 보안을 사용하여 접근을 제어