
Spring Security에서는 기본적으로 폼 기반의 UsernamePasswordAuthenticationFilter를 통해 로그인 요청을 처리합니다. 하지만 REST API를 사용한 인증에서는 JSON 형식으로 로그인 데이터를 전달해야 하는 경우가 많습니다. 이때 기본 필터를 그대로 사용할 수 없기 때문에, 커스텀 필터를 구현하여 JSON 데이터를 처리하는 방식으로 인증을 확장할 수 있습니다.
/**
* 스프링 시큐리티의 폼 기반의 UsernamePasswordAuthenticationFilter를 참고하여 만든 커스텀 필터
* 거의 구조가 같고, Type이 Json인 Login만 처리하도록 설정한 부분만 다르다. (커스텀 API용 필터 구현)
* Username : 회원 아이디 -> email로 설정
* "/login" 요청 왔을 때 JSON 값을 매핑 처리하는 필터
*/
public class CustomUsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
private static final String DEFAULT_LOGIN_REQUEST_URL = "/api/v1/users/login"; // "/login"으로 오는 요청을 처리
private static final String HTTP_METHOD = "POST"; // 로그인 HTTP 메소드는 POST
private static final String CONTENT_TYPE = "application/json"; // JSON 타입의 데이터로 오는 로그인 요청만 처리
private static final String USERNAME_KEY = "email"; // 회원 로그인 시 이메일 요청 JSON Key : "email"
private static final String PASSWORD_KEY = "password"; // 회원 로그인 시 비밀번호 요청 JSon Key : "password"
private static final AntPathRequestMatcher DEFAULT_LOGIN_PATH_REQUEST_MATCHER =
new AntPathRequestMatcher(DEFAULT_LOGIN_REQUEST_URL, HTTP_METHOD); // "/login" + POST로 온 요청에 매칭된다.
private final ObjectMapper objectMapper;
public CustomUsernamePasswordAuthenticationFilter(ObjectMapper objectMapper) {
super(DEFAULT_LOGIN_PATH_REQUEST_MATCHER); // 위에서 설정한 "login" + POST로 온 요청을 처리하기 위해 설정
this.objectMapper = objectMapper;
}
/**
* 인증 처리 메소드
*
* UsernamePasswordAuthenticationFilter와 동일하게 UsernamePasswordAuthenticationToken 사용
* StreamUtils를 통해 request에서 messageBody(JSON) 반환
* 요청 JSON Example
* {
* "email" : "zvyg1023@naver.com"
* "password" : "test123"
* }
* 꺼낸 messageBody를 objectMapper.readValue()로 Map으로 변환 (Key : JSON의 키 -> email, password)
* Map의 Key(email, password)로 해당 이메일, 패스워드 추출 후
* UsernamePasswordAuthenticationToken의 파라미터 principal, credentials에 대입
*
* AbstractAuthenticationProcessingFilter(부모)의 getAuthenticationManager()로 AuthenticationManager 객체를 반환 받은 후
* authenticate()의 파라미터로 UsernamePasswordAuthenticationToken 객체를 넣고 인증 처리
* (여기서 AuthenticationManager 객체는 ProviderManager -> SecurityConfig에서 설정)
*/
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException {
if(request.getContentType() == null || !request.getContentType().equals(CONTENT_TYPE) ) {
throw new AuthenticationServiceException("Authentication Content-Type not supported: " + request.getContentType());
}
String messageBody = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8);
Map<String, String> usernamePasswordMap = objectMapper.readValue(messageBody, Map.class);
String email = usernamePasswordMap.get(USERNAME_KEY);
String password = usernamePasswordMap.get(PASSWORD_KEY);
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(email, password);//principal 과 credentials 전달
return this.getAuthenticationManager().authenticate(authRequest);
}
}
필터 설정:
/api/v1/users/login 경로에 대해 POST 메소드로 들어오는 요청을 처리하도록 설정되었습니다. 또한, 요청의 Content-Type이 application/json인 경우에만 인증을 처리합니다. 이는 JSON 형식으로 사용자 인증 정보를 받아 처리하기 위해 커스터마이징된 부분입니다.인증 처리 (attemptAuthentication 메서드):
HttpServletRequest에서 들어온 요청 데이터를 StreamUtils로 읽어 JSON 형식의 문자열로 변환한 후, 이를 ObjectMapper를 사용해 Map으로 변환합니다. Map의 키는 JSON 필드(email, password)와 매핑되어 있습니다.UsernamePasswordAuthenticationToken에 넣어 AuthenticationManager를 통해 인증을 처리합니다. 이 AuthenticationManager는 Spring Security 설정 (SecurityConfig)에서 설정된 인증 관리 객체입니다.JSON 데이터 처리:
UsernamePasswordAuthenticationFilter는 HTML 폼 데이터를 처리하도록 되어 있습니다. 하지만 이 필터에서는 JSON 형식의 데이터를 처리할 수 있도록 ObjectMapper를 통해 요청 본문을 파싱하고 있습니다. 이를 통해 RESTful API 스타일의 로그인 처리 흐름을 구현할 수 있습니다.인증 요청이 커스텀 필터를 통과하면, 인증 서비스가 호출되어 사용자의 인증 정보를 확인하고 검증합니다. 이때 Spring Security의 UserDetailsService 인터페이스를 구현한 커스텀 로그인 서비스가 사용됩니다. 이 서비스는 주로 데이터베이스에서 사용자의 정보를 조회하고, 인증이 성공할 경우 Spring Security가 이해할 수 있는 UserDetails 객체를 반환합니다.
@Service
@RequiredArgsConstructor
public class LoginService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String email) {
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new AppException(EMAIL_NOT_FOUND, EMAIL_NOT_FOUND.getMessage()));
return org.springframework.security.core.userdetails.User.builder()
.username(user.getEmail())
.password(user.getPassword())
.roles(user.getUserRole().name())
.build();
}
}
UserDetailsService 구현:
LoginService는 UserDetailsService를 구현하며, loadUserByUsername(String email) 메서드를 오버라이드하여 사용자의 인증 정보를 로드합니다.사용자 정보 조회:
userRepository를 사용하여 데이터베이스에서 이메일을 기준으로 사용자를 조회합니다. 사용자가 존재하지 않는 경우, 커스텀 예외인 AppException을 발생시켜 적절한 오류 메시지를 반환합니다.AppException은 일반적인 오류 처리 방식으로, 이메일이 존재하지 않는 경우 이를 처리하는 로직입니다. EMAIL_NOT_FOUND는 사전에 정의된 ErrorCode를 사용할 수 있습니다.Spring Security의 UserDetails 객체 반환:
UserDetails 객체로 변환하여 반환합니다. User.builder()를 사용하여 사용자 이메일, 비밀번호, 역할 정보를 설정하고 UserDetails로 변환합니다.UserDetails 객체는 이후 Spring Security의 인증 로직에서 사용되어 로그인한 사용자의 권한 및 세션 관리를 처리하게 됩니다.비밀번호와 역할 정보 설정:
UserDetails 객체에는 사용자 비밀번호(password())와 역할 정보(roles())도 설정됩니다. 이는 Spring Security가 내부적으로 인증을 수행할 때, 제공된 비밀번호가 일치하는지 확인하고 사용자의 권한을 부여하는 데 사용됩니다.로그인 요청이 커스텀 JSON 로그인 필터를 통해 성공적으로 인증되면, 로그인 성공 핸들러(LoginSuccessHandler)가 실행됩니다. 이 핸들러는 SimpleUrlAuthenticationSuccessHandler를 상속받아 커스터마이징했으며, JWT 발급과 Redis를 사용한 세션 관리를 처리하는 역할을 합니다.
@RequiredArgsConstructor
public class LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final JwtService jwtService; // JWT 관련 로직을 처리하는 JwtService 객체 주입
private final RedisTemplate<String, String> redisTemplate; // Redis를 사용하여 RefreshToken을 저장하는 데 사용하는 RedisTemplate 주입
private final ObjectMapper objectMapper; // 응답을 JSON으로 변환하기 위한 ObjectMapper 주입
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException {
String email = extractUsername(authentication); // 인증 정보에서 email 추출
String accessToken = jwtService.createAccessToken(email); // JwtService의 createAccessToken을 사용하여 AccessToken 발급
String refreshToken = jwtService.createRefreshToken(); // JwtService의 createRefreshToken을 사용하여 RefreshToken 발급
// AccessToken과 RefreshToken을 응답 헤더에 추가하여 클라이언트로 전달
jwtService.sendAccessAndRefreshToken(response, accessToken, refreshToken);
// Redis에 RefreshToken 저장 (Key: "RT:" + email)
redisTemplate.opsForValue().set("RT:" + authentication.getName(), refreshToken);
// 응답 상태 코드 설정 (200 OK)
response.setStatus(HttpServletResponse.SC_OK);
response.setContentType("application/json"); // 응답 형식을 JSON으로 설정
response.setCharacterEncoding("utf-8");
// 클라이언트에 응답할 데이터를 UserLoginResponse 객체로 생성
UserLoginResponse userLoginResponse = new UserLoginResponse(email, accessToken, refreshToken);
// 성공 응답을 Response<UserLoginResponse>로 감싸서 처리
Response<UserLoginResponse> responseBody = Response.success(userLoginResponse);
// ObjectMapper를 사용하여 JSON으로 직렬화 후 클라이언트에 응답
objectMapper.writeValue(response.getWriter(), responseBody);
}
private String extractUsername(Authentication authentication) {
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
return userDetails.getUsername();
}
}
인증된 사용자 정보 추출:
extractUsername(authentication) 메서드를 통해 인증된 사용자의 이메일(Username)을 추출합니다. 이 정보는 JWT 생성에 사용됩니다.JWT 생성 및 전달:
jwtService를 사용하여 AccessToken과 RefreshToken을 생성합니다. AccessToken은 사용자 인증을 위해 클라이언트 측에서 사용되며, RefreshToken은 만료된 AccessToken을 갱신하는 데 사용됩니다. 두 토큰은 응답 헤더에 추가되어 클라이언트로 전달됩니다.Redis에 RefreshToken 저장:
RedisTemplate을 통해 RefreshToken을 "RT:email" 형식의 키로 저장합니다. 이 키를 사용하여 특정 사용자의 RefreshToken을 쉽게 관리할 수 있습니다.JSON 응답:
UserLoginResponse 객체로 사용자에게 응답할 데이터를 생성한 후, 이를 Response<UserLoginResponse> 객체로 감싸서 클라이언트에게 반환합니다. 이를 통해 클라이언트는 로그인 성공 시 사용자 정보와 발급된 토큰을 JSON 형식으로 받아볼 수 있습니다.
로그인 요청이 커스텀 JSON 로그인 필터에서 실패한 경우, 로그인 실패 핸들러(LoginFailureHandler)가 실행됩니다. 이 핸들러는 SimpleUrlAuthenticationFailureHandler를 상속받아 커스터마이징했으며, 인증 실패 시 클라이언트에게 적절한 오류 메시지와 상태 코드를 반환하는 역할을 합니다.
/**
* JWT 로그인 실패 시 처리하는 핸들러
* SimpleUrlAuthenticationFailureHandler를 상속받아서 구현
*/
@RequiredArgsConstructor
public class LoginFailureHandler extends SimpleUrlAuthenticationFailureHandler {
private final ObjectMapper objectMapper; // Java 객체를 JSON으로 변환하기 위한 ObjectMapper 인스턴스
/**
* 인증 실패 시 호출되는 메서드
* 인증이 실패하면 실패 이유를 ErrorResponse로 감싸서 클라이언트에 전달
*/
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) throws IOException {
// HTTP 응답 상태 코드를 400 (Bad Request)로 설정
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
response.setContentType("application/json"); // 응답의 Content-Type을 JSON 형식으로 설정
response.setCharacterEncoding("utf-8");
// 로그인 실패 시 사용할 ErrorResponse 객체 생성 (ErrorCode와 예외 메시지를 담음)
ErrorResponse errorResponse = new ErrorResponse(ErrorCode.LOGIN_FAILED, exception.getMessage());
// ErrorResponse 객체를 Response<ErrorResponse>로 감싸서 통일된 응답 형식으로 변환
Response<ErrorResponse> responseBody = Response.error("ERROR", errorResponse);
// ObjectMapper를 사용하여 Response 객체를 JSON으로 직렬화하고, 클라이언트에 응답으로 보냄
objectMapper.writeValue(response.getWriter(), responseBody);
}
}
인증 실패 처리:
onAuthenticationFailure() 메서드는 인증이 실패했을 때 실행됩니다. 이 메서드는 로그인 실패 원인을 클라이언트에게 알리고, 실패 이유를 JSON 형식으로 반환하는 역할을 합니다.오류 응답 구성:
ErrorResponse 객체를 생성하여 로그인 실패 시의 오류 코드를 ErrorCode.LOGIN_FAILED로 지정하고, 발생한 AuthenticationException의 메시지를 함께 전달합니다.통일된 응답 형식:
ErrorResponse 객체는 Response<ErrorResponse>로 감싸져서 클라이언트에게 반환됩니다. 이를 통해 클라이언트는 응답을 통일된 형식으로 받을 수 있으며, 상태 코드와 오류 메시지를 확인할 수 있습니다.
Reference