필터의 순서
요청이 들어올때 JWT 필터를 먼저 거칠 것인가 시큐리티 필터를 먼저 거칠 것인가?
시큐리티의 동작 과정
요청이 들어오면 그림과 같이 AuthenticationFilter 를 거쳐 UsernamePasswordAuthenticationToken 을 만들고 이를 이용하여 그 다음 과정을 쭉쭉 거치게 됩니다. 결론적으로는 SecurityContextHolder 에 인증이 완료된 유저네임과 패스워드가 저장되게 되죠. 그렇다면 JWT 필터는 어떤가요?
JWT 필터
JWT 필터도 별반 다르지 않습니다. 요청이 들어오면 요청 헤더에 들어있는 토큰을 꺼내어 토큰 검증을 거칩니다. 유효한 토큰이라면 아래와 같이 시큐리티에 건내줄 수 있습니다.
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(user, accessToken, new ArrayList<>());
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
Java
복사
결론은요?
요청이 들어오면 JWT 필터를 거치고 시큐리티 필터를 거치도록 하였습니다. 토큰을 먼저 꺼내보고 유효하지 않은 경우 아예 접근 자체를 못하게 하기 위해서였습니다.
시큐리티 커스텀
시큐리티에서 제공하는 form 로그인을 사용하지 않고, 로그인 리퀘스트로 받은 값을 그대로 사용하려면 시큐리티를 커스텀 해야 합니다. 그래서 시큐리티의 동작과정에서 보이는 클래스들을 아래와 같이 수정하였습니다.
CustomAuthenticationFilter.java
•
로그인 리퀘스트를 통해 아이디와 비밀번호를 받습니다.
•
이를 이용하여 UsernamePasswordAuthenticationToken 을 만듭니다.
@Slf4j
public class CustomAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
public CustomAuthenticationFilter(final AuthenticationManager authenticationManager) {
super.setAuthenticationManager(authenticationManager);
}
@Override
public Authentication attemptAuthentication (final HttpServletRequest request, final HttpServletResponse response) throws AuthenticationException {
final UsernamePasswordAuthenticationToken authenticationToken;
try {
final LoginRequest loginRequest = new ObjectMapper().readValue(request.getInputStream(), LoginRequest.class);
log.info("====================================================");
log.info("사용자 아이디: " + loginRequest.getEmail() + " 비밀번호: " + loginRequest.getPassword());
log.info("====================================================");
authenticationToken = new UsernamePasswordAuthenticationToken(loginRequest.getEmail(), loginRequest.getPassword());
} catch (IOException exception) {
throw new org.springframework.security.authentication.AuthenticationServiceException("사용자 인증에 실패하였습니다.", exception);
}
setDetails(request, authenticationToken);
return this.getAuthenticationManager().authenticate(authenticationToken);
}
}
Java
복사
CustomAuthenticationProvider.java
•
UsernamePasswordAuthenticationToken 으로부터 아이디와 비밀번호를 조회합니다.
•
CustomUserDetailsService 를 사용하여 아이디로 사용자를 조회하고, 존재한다면 이를 이용하여 새로운 UsernamePasswordAuthenticationToken 을 생성합니다.
@RequiredArgsConstructor
@Slf4j
public class CustomAuthenticationProvider implements AuthenticationProvider {
private final CustomUserDetailsService customUserDetailsService;
private final BCryptPasswordEncoder bCryptPasswordEncoder;
@Override
public Authentication authenticate(Authentication authentication) {
final UsernamePasswordAuthenticationToken token = (UsernamePasswordAuthenticationToken) authentication;
// AuthenticationFilter 에서 생성된 토큰으로 부터 아이디와 비밀번호 조회
final String email = token.getName();
final String password = (String) token.getCredentials();
// UserDetailsService 를 통해 DB 에서 아이디로 사용자 조회
final CustomUserDetails userDetails;
try {
userDetails = (CustomUserDetails) customUserDetailsService.loadUserByUsername(email);
} catch (Exception e) {
throw new RuntimeException(e);
}
if (!bCryptPasswordEncoder.matches(password, userDetails.getPassword())) {
throw new BadCredentialsException("비밀번호가 일치하지 않습니다.");
}
return new UsernamePasswordAuthenticationToken(userDetails, password, userDetails.getAuthorities());
}
@Override
public boolean supports(Class<?> authentication) {
return authentication.equals(UsernamePasswordAuthenticationToken.class);
}
}
Java
복사
왜 UsernamePasswordAuthenticationToken 에 userDetails 을 넣나요?
인텔리제이에서 UsernamePasswordAuthenticationToken 을 한번 찾아가보면, 아래와같이 나옵니다.
파라미터를 보면 principal 은 Object 입니다. 그래서 유저의 아이디나 이메일같이 유저의 특정값이 아닌, 유저를 넣어주었습니다. 이 principal 은 아래와 같이 인증이 완료된 유저의 인증 정보를 꺼내올 때 사용할 수 있습니다.
public Long getLoginUserIndex() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
Object principal = authentication.getPrincipal();
Long userId = ((User) principal).getId();
return userId;
}
Java
복사
유저의 어떤 정보가 필요할 지 몰라서 User 자체를 넣어준 것이죠
CustomUserDetailsService.java
•
토큰을 통해 찾아온 이메일을 이용하여 특정 유저가 존재하는지 확인합니다.
•
만약 유저가 존재한다면, 이를 CustomUserDetails 에 넣어 반환 합니다.
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String email) {
Optional<User> user = userRepository.findByEmail(email);
if (user.isEmpty()) {
throw new UsernameNotFoundException("해당 유저를 찾을 수 없습니다. 유저 이메일: " + email);
}
return new CustomUserDetails(user.get());
}
}
Java
복사
CustomUserDetails.java
•
사용자를 저장합니다.
public record CustomUserDetails(User user) implements UserDetails {
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Collections.singleton(new SimpleGrantedAuthority("ROLE_MEMBER"));
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getEmail();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
Java
복사