팀 프로젝트에서 로그인 인증이 필요한 API와 로그인이 필요 없는 API를 구분해야 하는 상황이 발생했습니다.
SecurityConfig에서 모든 경로를 .permitAll()로 설정하여 인증 로직이 생략되고 Controller로 바로 넘어감.Authentication를 호출하여 인증 및 사용자 정보를 확인.package com.zerobase.user.config;
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig {
// JWTUtil 주입
private final JWTUtil jwtUtil;
// AuthenticationManager가 인자로 받을 AuthenticationConfiguration 객체 생성자 주입
private final AuthenticationConfiguration authenticationConfiguration;
private final UserRepository userRepository;
private final ProfileRepository profileRepository;
private final RefreshRepository refreshRepository;
private final CookieUtil cookieUtil;
// AuthenticationManager Bean 등록
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// CSRF 비활성화
http.csrf().disable();
// Form 로그인 방식 비활성화
http.formLogin().disable();
// HTTP Basic 인증 방식 비활성화
http.httpBasic().disable();
// 경로별 접근 권한 설정
http.authorizeHttpRequests(auth -> auth
.requestMatchers("api/**", "/login", "/logout", "/", "/join", "/reissue", "internal/**").permitAll()
.requestMatchers("/admin").hasRole("ADMIN")
.anyRequest().authenticated()
);
// 필터 추가
http.addFilterBefore(new JWTFilter(userRepository, jwtUtil), LoginFilter.class);
http.addFilterAt(
new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil,
refreshRepository, userRepository, profileRepository, cookieUtil),
UsernamePasswordAuthenticationFilter.class);
http.addFilterBefore(new CustomLogoutFilter(jwtUtil, refreshRepository),
LogoutFilter.class);
// 세션 설정
http.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
return http.build();
}
}
기존 코드는 로그인 기능이든 비로그인 기능이든 ("api/**").permitAll()로 처리하여 .anyRequest().authenticated()에서 인증 정보를 검증하지 않고 컨트롤러로 넘어가는 구조였습니다.
package com.zerobase.user.controller;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/users")
public class UserController {
private final UserService userService;
private final ProfileService profileService;
private final UserRepository userRepository;
private final ReissueService reissueService;
private UserEntity getCurrentUser(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) {
throw new BizException(UNAUTHORIZED_ERROR);
}
CustomUserDetails customUserDetails = (CustomUserDetails) authentication.getPrincipal();
return userRepository.findByEmail(customUserDetails.getUsername())
.orElseThrow(() -> new BizException(USER_NOT_FOUND_ERROR));
}
@PatchMapping("/Info")
public ResponseEntity<?> editUserInfo(@RequestBody @Valid EditUserInfoDTO editUserInfoDTO, Authentication authentication) {
UserEntity currentUser = getCurrentUser(authentication);
userService.editUserInfoProcess(editUserInfoDTO, currentUser);
return ResponseEntity.status(OK).body(ResponseMessage.success());
}
// ...이하 생략
}
컨트롤러 코드에 private final UserRepository userRepository; 리포지토리가 있는 것도 이상했고,
private UserEntity getCurrentUser(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) {
throw new BizException(UNAUTHORIZED_ERROR);
}
CustomUserDetails customUserDetails = (CustomUserDetails) authentication.getPrincipal();
return userRepository.findByEmail(customUserDetails.getUsername())
.orElseThrow(() -> new BizException(USER_NOT_FOUND_ERROR));
}
인증 부분도 컨트롤러에서 하면 좋지 않을 것 같다라는 팀원의 의견을 반영해 리팩토링을 시작했습니다!
먼저 getCurrentUser 메서드를 AuthenticationUtil 유틸 클래스로 따로 빼주어 컨트롤러에서 해당 코드를 정리했습니다.
package com.zerobase.user.util;
import com.zerobase.user.jwt.CustomUserDetails;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
@Component
public class AuthenticationUtil {
private UserEntity getCurrentUser(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) {
throw new BizException(UNAUTHORIZED_ERROR);
}
CustomUserDetails customUserDetails = (CustomUserDetails) authentication.getPrincipal();
return userRepository.findByEmail(customUserDetails.getUsername())
.orElseThrow(() -> new BizException(USER_NOT_FOUND_ERROR));
}
}
처음엔 이렇게 수정해주었지만, 이렇게 해도 인증에 대한 예외 처리를 필터에서 하지 않으면 컨트롤러까지 넘어와서 컨트롤러에서 예외 처리를 해야 하는 건 마찬가지였습니다.
컨트롤러에서 인증에 대한 예외 처리를 하지 않기 위해 로그인 기능을 처리하는 컨트롤러에서 Authentication authentication를 파라미터에서 제외해주었습니다.
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/users")
public class UserController {
private final UserService userService;
private final ProfileService profileService;
private final ReissueService reissueService;
@PatchMapping("/Info")
public ResponseEntity<?> editUserInfo(@RequestBody @Valid EditUserInfoDTO editUserInfoDTO) {
UserEntity currentUser = getCurrentUser(authentication);// 컴파일 에러
userService.editUserInfoProcess(editUserInfoDTO, currentUser);
return ResponseEntity.status(OK).body(ResponseMessage.success());
}
// ...이하 생략
}
컨트롤러 파라미터 부분에서 Authentication authentication을 제거하니 로그인 요청 컨트롤러 모든 부분에서 UserEntity currentUser = getCurrentUser(authentication); 이 코드 부분에서 컴파일 오류가 발생했습니다.
그래서 컨트롤러에서 이 부분의 코드도 모두 삭제해주었습니다. 이제 컨트롤러에서 인증을 처리하는 코드 부분이 없으니, 인증 처리 오류를 필터에서 어떻게 처리할지 고민했습니다.
package com.zerobase.user.jwt;
@RequiredArgsConstructor
public class JWTFilter extends OncePerRequestFilter {
private final UserRepository userRepository;
private final JWTUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// 헤더에서 access 키에 담긴 토큰을 꺼냄
String accessToken = request.getHeader("access");
// 토큰이 없다면 다음 필터로 넘김
if (accessToken == null || accessToken.isEmpty()) {
filterChain.doFilter(request, response);
return;
}
// 토큰 만료 여부 확인, 만료 시 다음 필터로 넘기지 않음
try {
jwtUtil.isExpired(accessToken);
} catch (MalformedJwtException e) {
ResponseUtil.setJsonResponse(response, SC_UNAUTHORIZED, INVALID_TOKEN_FORMAT_ERROR);
return;
} catch (ExpiredJwtException e) {
ResponseUtil.setJsonResponse(response, SC_UNAUTHORIZED, EXPIRED_TOKEN_ERROR);
return;
}
// 토큰이 access인지 확인
String category = jwtUtil.getCategory(accessToken);
if (!category.equals("access")) {
ResponseUtil.setJsonResponse(response, SC_UNAUTHORIZED, NOT_ACCESS_TOKEN_ERROR);
return;
}
// 토큰에서 이메일과 역할 획득
String email = jwtUtil.getEmail(accessToken);
String roleString = jwtUtil.getRole(accessToken);
Role role;
try {
// roleString을 Role enum으로 변환
role = Role.fromString(roleString);
} catch (IllegalArgumentException e) {
System.out.println("Invalid role");
filterChain.doFilter(request, response);
return;
}
Optional<UserEntity> optionalUserEntity = userRepository.findByEmail(email);
if (optionalUserEntity.isEmpty()) {
filterChain.doFilter(request, response);
return;
}
// UserEntity를 생성하여 값 설정
UserEntity userEntity = UserEntity.builder()
.username(optionalUserEntity.get().getUsername())
.password(optionalUserEntity.get().getPassword())
.email(email)
.role(role)
.build();
// UserDetails에 회원 정보 객체 담기
CustomUserDetails customUserDetails = new CustomUserDetails(userEntity);
// 스프링 시큐리티 인증 토큰 생성
Authentication authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null,
customUserDetails.getAuthorities());
// 세션에 사용자 등록
SecurityContextHolder.getContext().setAuthentication(authToken);
filterChain.doFilter(request, response);
}
}
회원가입 후 로그인하면 토큰을 주는데, 로그인 기능은 토큰이 필요하고 사용자가 API 요청 시 토큰을 보내줍니다. 즉, 토큰을 보내주면 사용자 인증 정보를 등록해둡니다.
// 세션에 사용자 등록
SecurityContextHolder.getContext().setAuthentication(authToken);
비로그인 기능은 토큰이 필요 없으므로 사용자 인증 정보를 등록하는 부분을 생략합니다.
// 토큰이 없다면 다음 필터로 넘김
if (accessToken == null || accessToken.isEmpty()) {
filterChain.doFilter(request, response);
return;
}
사용자 인증 정보를 등록하는 부분이 없으므로 다음 필터로 요청을 넘깁니다.
package com.zerobase.user.config;
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig {
// JWTUtil 주입
private final JWTUtil jwtUtil;
// AuthenticationManager가 인자로 받을 AuthenticationConfiguration 객체 생성자 주입
private final AuthenticationConfiguration authenticationConfiguration;
private final UserRepository userRepository;
private final ProfileRepository profileRepository;
private final RefreshRepository refreshRepository;
private final CookieUtil cookieUtil;
private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; // 추가된 부분
// AuthenticationManager Bean 등록
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// CSRF 비활성화
http.csrf().disable();
// Form 로그인 방식 비활성화
http.formLogin().disable();
// HTTP Basic 인증 방식 비활성화
http.httpBasic().disable();
// 수정된 예외 처리 구성
http.exceptionHandling()
.authenticationEntryPoint(customAuthenticationEntryPoint);
// 경로별 접근 권한 설정
http.authorizeHttpRequests(auth -> auth
// 공통
.requestMatchers("/", "/internal/**").permitAll()
// 로그인, 로그아웃, 회원가입, 재발급
.requestMatchers("/login", "/logout", "/join", "/reissue").permitAll()
// 사용자 기능
.requestMatchers(POST, "/api/v1/users/reset-password", "/api/v1/users").permitAll()
.requestMatchers(GET, "/api/v1/users/*", "/api/v1/users/*/profile", "/api/v1/users/duplicate").permitAll()
// 인증이 필요한 요청
.anyRequest().authenticated()
);
http.addFilterBefore(new JWTFilter(userRepository, jwtUtil), LoginFilter.class);
http.addFilterAt(
new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil,
refreshRepository, userRepository, profileRepository, cookieUtil),
UsernamePasswordAuthenticationFilter.class);
http.addFilterBefore(new CustomLogoutFilter(jwtUtil, refreshRepository),
LogoutFilter.class);
// 세션 설정
http.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
return http.build();
}
}
http.addFilterBefore(new JWTFilter(userRepository, jwtUtil), LoginFilter.class);
필터처리 순서를 보면 로그인보다 JWTFilter를 먼저 검사합니다. 즉, 사용자가 API 요청을 하면 JWTFilter를 먼저 거칩니다. 여기서 비로그인 기능은 토큰이 필요 없어서 요청에 포함하지 않고 보내주기 때문에 SecurityContextHolder.getContext().setAuthentication(authToken); 인증 정보가 없어서
http.authorizeHttpRequests(auth -> auth
// 공통
.requestMatchers("/", "/internal/**").permitAll()
// 로그인, 로그아웃, 회원가입, 재발급
.requestMatchers("/login", "/logout", "/join", "/reissue").permitAll()
// 사용자 기능
.requestMatchers(POST, "/api/v1/users/reset-password", "/api/v1/users").permitAll()
.requestMatchers(GET, "/api/v1/users/*", "/api/v1/users/*/profile", "/api/v1/users/duplicate").permitAll()
// 인증이 필요한 요청
.anyRequest().authenticated()
);
비로그인 기능들은 .anyRequest().authenticated()의 사용자 인증 정보에 대한 검증 로직을 피하게 만들어야 합니다.
그렇지 않으면 인증 오류 발생!
비로그인 API들은 .permitAll()로 처리하고, 로그인 기능들은 .anyRequest().authenticated()를 거쳐서 사용자 인증에 대한 검증을 해주어야 합니다.
여기서 인증에 대한 오류가 발생하면, Spring Security가 내부적으로 인증 예외를 발생시킵니다. 이를 활용하여 CustomAuthenticationEntryPoint를 추가하였고, 필터에서 인증 실패 시 발생하는 예외를 한 곳에서 처리하도록 하였습니다.
package com.zerobase.user.exception;
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) {
ResponseUtil.setJsonResponse(response, SC_UNAUTHORIZED, UNAUTHORIZED_ERROR);
}
}
SecurityConfig에 해당 authenticationEntryPoint를 등록했습니다.
private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
// 수정된 예외 처리 구성
http.exceptionHandling((exceptionHandling) -> exceptionHandling
.authenticationEntryPoint(customAuthenticationEntryPoint));
이렇게 설정하면, 인증 오류가 발생할 때 Spring Security가 AuthenticationException을 감지하고, 자동으로 CustomAuthenticationEntryPoint의 commence 메서드를 호출하여 인증 실패에 대한 응답을 처리하게 됩니다.
인증 실패 시 클라이언트에게 일관된 응답을 제공하기 위해 ResponseUtil 클래스를 사용합니다.
package com.zerobase.user.util;
public class ResponseUtil {
private static final ObjectMapper objectMapper = new ObjectMapper();
/**
* 공통 JSON 응답을 설정하는 메서드
*/
public static void setJsonResponse(HttpServletResponse response, int statusCode,
LoginSuccessDTO loginSuccessDTO) throws IOException {
response.setStatus(statusCode);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
String jsonResponse = objectMapper.writeValueAsString(
ResponseMessage.success(loginSuccessDTO));
PrintWriter writer = response.getWriter();
writer.write(jsonResponse);
writer.flush();
}
public static void setJsonResponse(HttpServletResponse response, int statusCode,
ErrorCode errorCode) {
response.setStatus(statusCode);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
String jsonResponse;
try {
jsonResponse = objectMapper.writeValueAsString(ResponseMessage.fail(errorCode));
PrintWriter writer = response.getWriter();
writer.write(jsonResponse);
writer.flush();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
이렇게 해서 기존 컨트롤러에서 처리하던 인증 오류에 대한 처리를 필터 단에서 통일된 방식으로 처리하도록 리팩토링하였습니다.
AuthenticationEntryPoint 상세 설명AuthenticationEntryPoint는 Spring Security에서 인증 실패 시 호출되는 인터페이스로, 인증되지 않은 사용자에게 어떻게 응답할지를 결정합니다.
AuthenticationEntryPoint의 commence 메서드를 호출합니다.AuthenticationException 객체와 함께 HttpServletRequest, HttpServletResponse 객체가 전달됩니다.commence 메서드는 인증 실패에 대한 적절한 응답을 생성하여 클라이언트에게 반환합니다.commence 메서드의 역할commence 메서드를 구현하여 인증 실패 시의 응답을 커스터마이징할 수 있습니다.SecurityContextHolder에 인증 정보가 없는 상태에서 요청이 전달됩니다.AuthenticationException이 발생합니다.ExceptionTranslationFilter가 이 예외를 감지하고, 설정된 AuthenticationEntryPoint로 예외 처리를 위임합니다.CustomAuthenticationEntryPoint의 commence 메서드가 호출되어 클라이언트에게 인증 실패에 대한 응답을 반환합니다.CustomAuthenticationEntryPoint를 통해 일관된 응답을 제공하게 되었습니다.이쪽 부분에 대한 추가 설명으로, AuthenticationEntryPoint를 활용함으로써 인증 실패 시의 응답 처리를 통합하고, 보안 관련 예외에 대한 관리가 용이해졌습니다.