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