이전 글에서 Spring Security 개념에 대해서 정리했다면 이번 글은 Spring Security + JWT 를 적용해보고자 합니다. 앞서 말했다시피 학습과 병행하며 진행된 만큼 아직 개선한 여지가 있는 부분이 있습니다. 글을 읽으시다가 잘못 작성되거나 개선할 부분이 있다면 댓글로 남겨주시면 감사하겠습니다.🙌🏻
목차
1. 구현해보기
1.1 의존성 추가
1.2 Spring Security Config
1.3 JwtAuthorizationFilter
1.4 CustomAuthenticationProvider
1.5 CustomUserDetailsService
1.6 CustomAuthSuccessHandler
1.7 CustomAuthFailureHandler
2. 테스트
구현해보기
- Spring Boot 3.4.0
- JDK 17
- Build : Gradle
의존성 추가
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'
//jwt
implementation 'io.jsonwebtoken:jjwt:0.9.1'
implementation 'com.sun.xml.bind:jaxb-impl:4.0.1'
implementation 'com.sun.xml.bind:jaxb-core:4.0.1'
implementation 'javax.xml.bind:jaxb-api:2.4.0-b180830.0359'
}
Spring Security Config
1. SecurityFilterChain
Spring Security에서 제공하는 인증, 인가를 위한 필터들의 모임으로 사용하는 목적에 맞게 커스텀하여 필터 체인으로 포함시켜 사용할 수 있다.
1.1 SecurityContextPersistenceFilter
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // JWT 토큰을 사용으로 비활성화
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
.addFilterBefore(jwtAuthorizationFilter(), BasicAuthenticationFilter.class) //JWT Filter
//JWT 이용하여 인증
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// form 기반의 로그인을 비활성화
.formLogin(login -> login.disable())
// cumstom으로 구성한 필터 사용
.addFilterBefore(customAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class )
.httpBasic(withDefaults());
return http.build();
}
JwtAuthorizationFilter
API 요청이 들어왔을 때, JWT토큰 검증을 위해 Spring Security 필터 체인에 Custom Filter를 추가했습니다.
JwtAuthorizationFilter 는 토큰이 필요하지 않는 API를 제외하고는 모두 수행되며, Authorization 헤더를 통해 JWT의 유효성 검사를 진행합니다.
@Component
@Slf4j
public class JwtAuthorizationFilter extends OncePerRequestFilter {
private String Bearer = "Bearer ";
@Autowired
private JwtTokenUtil jwtTokenUtil;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 토큰이 필요하지 않는 API의 경우 다음 필터로 이동
if(request.getRequestURI().startsWith("/api/auth")) {
response.addHeader("X-Custom-Filter", "Public Access");
filterChain.doFilter(request, response);
return;
}
try {
// Client에서 API를 요청할 때 Header 확인
String token = request.getHeader(HttpHeaders.AUTHORIZATION);
if(token == null || "".equals(token)) {
log.error("Token is null");
setErrorResponse(response, ErrorCode.NOT_EXISTS_TOKEN,null);
}
token = token.substring(Bearer.length());
if(!jwtTokenUtil.isTokenValid(token)) {
setErrorResponse(response, ErrorCode.INVALID_TOKEN, null);
}
filterChain.doFilter(request,response);
} catch (Exception e) {
setErrorResponse(response,null, e);
}
}
public static void setErrorResponse(HttpServletResponse response, ErrorCode errorCode, Exception e) throws IOException {
response.setContentType("application/json;charset=UTF-8");
if(!"null".equals(e)) {
if(e instanceof ExpiredJwtException) {
errorCode = ErrorCode.EXPIRED_TOKEN;
} else if (e instanceof SignatureException | e instanceof JwtException) {
errorCode = ErrorCode.INVALID_TOKEN;
} else if (e instanceof NestedServletException || e instanceof Exception) {
errorCode = ErrorCode.INTERNAL_SERVER_ERROR;
}
}
PrintWriter writer = response.getWriter();
response.setStatus(errorCode.getStatus().value());
HashMap<String, String> jsonMap = new HashMap<>();
jsonMap.put("status", String.valueOf(errorCode.getStatus().value()));
jsonMap.put("message", errorCode.getMessage());
writer.print(JSONObject.toJSONString(jsonMap));
writer.flush();
writer.close();
}
}
CustomAuthenticationFilter
로그인 API인 /api/auth/login 으로 요청이 들어오면 해당 필터에 들어오게 되는데, 인증 요청을 처리하는 핵심 인터페이스로 실제 인증 로직을 실행하는 부분입니다.
@Slf4j
@Component
public class CustomAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
public CustomAuthenticationFilter(AuthenticationManager authenticationManager) {
super.setAuthenticationManager(authenticationManager);
}
// login 요청을 하면 실행되는 함수
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) {
log.info("1. CustomAuthenticationFilter");
UsernamePasswordAuthenticationToken authenticationToken = null;
try {
// request 파라미터로 임시 토큰 발급
ObjectMapper objectMapper = new ObjectMapper();
User user = objectMapper.readValue(request.getInputStream(), User.class);
authenticationToken = new UsernamePasswordAuthenticationToken(user.getUserId(), user.getPassword());
log.info("Principal : {}", authenticationToken.getPrincipal());
log.info("Credentials : {}", authenticationToken.getCredentials());
setDetails(request,authenticationToken);
} catch (Exception e) {
log.error(e.getMessage());
}
return this.getAuthenticationManager().authenticate(authenticationToken);
}
}
CustomAuthenticationProvider
전달받은 사용자의 아이디와 비밀번호를 기반으로 비즈니스 로직을 처리하여 사용자의 '인증'에 대해서 검증을 수행하는 클래스 입니다.
CustomAuthenticationFilter로부터 생성한 토큰을 통해 'CustomUserDetailsService'를 통해 데이터베이스 내에서 사용자 정보를 조회합니다.
@Slf4j
@RequiredArgsConstructor
public class CustomAuthenticationProvider implements AuthenticationProvider {
@Resource
private CustomUserDetailsService customUserDetailsService;
@NonNull
private PasswordEncoder passwordEncoder;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
log.info("2. CustomAuthenticationProvider");
String userId = (String) authentication.getPrincipal();
String password = (String) authentication.getCredentials();
//사용자 인증에 대해서 검증 수행
UserDetails userDetails = customUserDetailsService.loadUserByUsername(userId);
// DB정보랑 일치한 지 확인
if(!passwordEncoder.matches(password, userDetails.getPassword())){
throw new BadCredentialsException("User Info Mismatch");
}
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(userDetails.getUsername(), userDetails.getPassword(), userDetails.getAuthorities());
return authenticationToken;
}
@Override
public boolean supports(Class<?> authentication) {
return authentication.equals(UsernamePasswordAuthenticationToken.class);
}
}
CustomUserDetailsService
사용자 정보를 데이터베이스에서 가져오는 부분입니다.
@Service
@Slf4j
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
public CustomUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {
log.info("UserDetailsService {}" , userId);
User user = userRepository.findByUserId(userId)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + userId));
return new PrincipalDetails(user);
}
}
CustomAuthSuccessHandler
사용자의 '인증'에 대해 성공했을 경우 수행되는 Handler로 성공에 대한 사용자에게 반환값을 구성하여 전달하는 클래스입니다.
@Slf4j
@Configuration
public class CustomAuthSuccessHandler implements AuthenticationSuccessHandler {
@Autowired
private JwtTokenUtil jwtTokenUtil;
@Autowired
private JwtProperties jwtProperties;
@Autowired
private UserService userService;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
log.info("3.1 CustomAuthSuccessHandler");
String userId = (String) authentication.getPrincipal();
// JWT토큰 발급
String accessToken = jwtTokenUtil.generateAccessToken(userId);
String refreshToken = jwtTokenUtil.generateRefreshToken(userId);
// 사용자 refreshToken DB에 저장
userService.updateUserRefreshToken(userId, refreshToken);
// 응답규격 생성
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpServletResponse.SC_OK);
response.addHeader(jwtProperties.getHeaderString(), jwtProperties.getTokenPrefix()+accessToken);
HashMap<String, String> jsonMap = new HashMap<>();
jsonMap.put("status", String.valueOf(HttpServletResponse.SC_OK));
jsonMap.put("message", "정상 처리되었습니다.");
PrintWriter writer = response.getWriter();
writer.print(JSONObject.toJSONString(jsonMap));
writer.flush();
writer.close();
}
}
CustomAuthFailureHandler
사용자의 '인증'에 대해 실패했을 경우 수행되는 Handler로 실패에 대한 사용자에게 반환값을 구성하여 전달하는 클래스입니다.
@Slf4j
@Configuration
public class CustomAuthFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
log.info("3.2 CustomAuthFailureHandler");
response.setContentType("application/json;charset=UTF-8");
ErrorCode errorCode = null;
if(exception instanceof UsernameNotFoundException || exception instanceof BadCredentialsException){
errorCode = ErrorCode.RESOURCE_NOT_FOUND;
} else if (exception instanceof InternalAuthenticationServiceException || exception instanceof AuthenticationCredentialsNotFoundException) {
errorCode = ErrorCode.UNAUTHORIZED;
} else {
errorCode = ErrorCode.INTERNAL_SERVER_ERROR;
}
PrintWriter writer = response.getWriter();
response.setStatus(errorCode.getStatus().value());
HashMap<String, String> jsonMap = new HashMap<>();
jsonMap.put("status", String.valueOf(errorCode.getStatus().value()));
jsonMap.put("message", errorCode.getMessage());
writer.print(JSONObject.toJSONString(jsonMap));
writer.flush();
writer.close();
}
}
테스트
1. 회원가입
2. 로그인
case1) 일치한 사용자 정보 없음
case2) 정상로그인 - Headers에 AccessToken 값 확인가능
'정리 > Spring' 카테고리의 다른 글
Spring Security 란? (0) | 2024.12.10 |
---|---|
[Spring] DI & AOP (0) | 2021.09.24 |
[Spring] REST API (0) | 2021.09.09 |
[Spring] Spring MVC (0) | 2021.09.09 |
[Spring] Spring이란? (0) | 2021.09.09 |