https://docs.spring.io/spring-security/reference/servlet/architecture.html

스프링 부트는 톰캣 Servlet 컨테이너 위에서 작동한다.
클라이언트 요청 -> 서블릿 필터 -> 컨트롤러
이런식으로 접근하는데 DelegatingFilter 를 Bean에 등록시켜서 이 요청을 Spring Security가 가로챈다
이러면 클라이언트의 요청을 Security 필터에서 확인하고 응답하게 된다.
이때 자동으로 구현된 Security 필터들이 여러개 있는데
UsernamePasswordAuthenticationFilter 라는 필터는 폼 로그인 시 아이디/패스워드 검증 로직이 구현되어 있다. 하지만 REST API 서버로 구현하고 있다.
http.formLogin((auth) -> auth.disable());
SecurityConfig에서 폼 로그인 방식을 막았기 때문에 필터가 작동을 안 한다. 그래서 직접 커스텀으로 구현해야 한다.

1) 외부에서 로그인 요청이 들어온다.
2) AuthenticationFilter 회원 검증
3) UserRepository가 DB에서 USER 데이터를 가져온다.
4) DB 데이터를 UserDetailsService가 UserDetails로 반환한다.
5) Authentication Manager가 로그인 검증.
6) successfulAuth의 메소드를 통해 JWTUtil에서 토큰을 발급.
UsernamePasswordAuthenticationFilter를 대신할 필터를 먼저 만들어야 한다.
필터로 사용할 클래스를 하나 만들고 상속 받자.

ctrl + space 누르면 필요한 메서드들을 오버라이드할 수 있다.
LoginFilter.java
package com.example.classicHub.jwt;
import java.io.IOException;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
public class LoginFilter extends UsernamePasswordAuthenticationFilter{
private final AuthenticationManager authenticationManager; // 검증 매니저
/** 생성자 **/
public LoginFilter(AuthenticationManager authenticationManager) {
this.authenticationManager = authenticationManager;
}
// 검증 과정
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
// 클라이언트 요청에서 email, password 추출
String email = request.getParameter("email"); // 기존의 username을 안 쓰고 param 값을 email로 할거임
String password = obtainPassword(request);
// 넘길 토큰
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(email, password, null);
// authenticationManager 가 검증 진행
return authenticationManager.authenticate(authToken);
}
// 로그인 성공시 실행하는 메소드 (여기서 JWT 발급)
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
Authentication authResult) throws IOException, ServletException {
System.out.println("com.example.classicHub.jwt.LoginFilter : 로그인 성공");
}
// 로그인 실패시 실행하는 메소드
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
AuthenticationException failed) throws IOException, ServletException {
System.out.println("com.example.classicHub.jwt.LoginFilter : 로그인 실패");
}
}
검증 과정 중 HttpServletRequest 요청에서 username과 password를 obtain 메소드로 값을 찾을 수 있다. 나는 username 대신 email을 사용할 것이기 때문에 request.getParameter를 사용해서 email param을 찾을 수 있었다.
이 내용들을 UsernamePasswordAuthenticationToken 객체 생성자에 담아서 authenticationManager 한테 검증 요청으로 넘기면 된다.
authenticationManager는 생성자 주입 방식으로 등록했다.
SecurityConfig.java
private final AuthenticationConfiguration authenticationConfiguration;
/** 생성자 주입 **/
public SecurityConfig(AuthenticationConfiguration authenticationConfiguration) {
this.authenticationConfiguration = authenticationConfiguration;
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception{
return configuration.getAuthenticationManager();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
// csrf disable
http.csrf((auth) -> auth.disable());
// Form 로그인 방식 disable
http.formLogin((auth) -> auth.disable());
// http basic 인증 방식 disable
http.httpBasic((auth) -> auth.disable());
// 경로별 인가 작업
http.authorizeHttpRequests((auth) -> auth
.requestMatchers("/login", "/","/join").permitAll() // 이 경로에 대해서는 모든 권한을 허가한다.
.requestMatchers("/admin").hasRole("ADMIN") // 이 경로에 대해서는 해당 권한이 있는 사용자만 접근을 허가한다.
.anyRequest().authenticated() // 그 외 경로에는 로그인된 사용자만 권한을 허가한다.
);
// 커스텀 필터 추가
http.addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration)), UsernamePasswordAuthenticationFilter.class);
// 세션 설정
http.sessionManagement((session) -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // JWT를 통한 인증/인가를 위해서 세션을 STATELESS 상태로 설정.
);
return http.build();
}
Security filter chain 부분에 UsernamePasswordAuthenticationFilter를 대신할 커스텀 필터를 추가한다.
아까 로그인 필터를 만들면서 생성자 부분에 AuthenticationManager가 매개변수로 필요했으니 SecurityConfig에 AuthenticationManager를 @Bean으로 등록 시킨다. 그런데 매니저를 AuthenticationConfiguration 얘가 반환한다. 그래서 SecurityConfig에 생성자 주입으로 등록시켜 놨다.
UserRepository.java
User findByEmail(String email); // 해당 이메일로 가입한 USER를 반환
DB에서 이메일로 찾아서 유저 엔티티를 가져올 수 있게 쿼리를 하나 만들었다.
CustomUserDetailsService
@Service
public class CustomUserDetailsService implements UserDetailsService{
private final UserRepository userRepository;
/** 생성자 **/
public CustomUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
User user = userRepository.findByEmail(email); // 이메일로 해당 이메일을 사용하는 유저를 검색
if(user != null) { // USER가 있으면
return new CustomUserDetails(user); // CustomUserDetails를 반환
}
return null;
}
}
UserDetailsService를 상속해주면 부모의 메소드를 반드시 구현해야한다.
이것도 ctrl + space로 자동 오버라이딩 해주고 UserDetails를 반환하는 loadUserByUsername을 구현해주자.
CustomUserDetails.java
@Data
public class CustomUserDetails implements UserDetails{
private final User user;
/** 생성자 **/
public CustomUserDetails(User user) {
this.user = user;
}
/** role 반환 **/
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collection = new ArrayList<>();
collection.add(new GrantedAuthority() {
@Override
public String getAuthority() {
return user.getRole();
}
});
return collection;
}
/** 비밀번호 반환 **/
@Override
public String getPassword() {
return user.getPassword();
}
/** 아이디 반환 **/
@Override
public String getUsername() {
return user.getEmail();
}
public String getEmail() {
return user.getEmail();
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
UserDetails를 상속 받은 CustomUserDetails 클래스를 하나 만들었다.
이 친구도 부모의 메소드를 오버라이딩하고 구현 해주자.
postman으로 form-data로 로그인 요청을 보내봤다.

로그인 로직을 구현했다!