HJW's IT Blog

[Spring Security] OAuth2.0 + JWT 로그인 구조에 Form Login 통합하기 본문

SPRING

[Spring Security] OAuth2.0 + JWT 로그인 구조에 Form Login 통합하기

kiki1875 2025. 3. 26. 12:00

이전 포스팅에서 OAuth2.0 과 JWT 를 이용한 로그인 기능을 구현하였다.

 

이제 여기에 form login 방식을 추가하여 두가지 방식의 로그인을 가능하게 만들어 볼 것이다.

UserDetails 와 UserDetailsService

Spring Security 의 Form Login 을 이해하기 위해선, Spring Security 가 제공하는 UserDetails 객체와 UserDetailsService 를 이해하고 넘어가야 한다.

 

한번 UserDetails 를 살펴보자

public interface UserDetails extends Serializable {  
  Collection<? extends GrantedAuthority> getAuthorities();  

  String getPassword();  

  String getUsername();  

  default boolean isAccountNonExpired() {  
    return true;  
  }  

  default boolean isAccountNonLocked() {  
    return true;  
  }  

  default boolean isCredentialsNonExpired() {  
    return true;  
  }  

  default boolean isEnabled() {  
    return true;  
  }  
}

 

UserDetails 는 인증된 사용자의 정보를 담는 인터페이스 이다. 즉, 인증 프로세스를 거친 후, 시스템 내에서 사용자를 표현하는 객체이다.

 

각 서비스의 사용자 는 모두 각기 다른 형태를 띄고 있을 것이다. 그러한 다른 객체들을 일관된 형태로 표현하여 Spring Security 에서 사용할 수 있도록 해주는 것이 이 인터페이스이다.

 

UserDetailsService 는 사용자의 정보를 로드하는 인터페이스 이다. Spring Security 가 우리 서비스의 사용자 를 인증하기 위해, 이를 UserDetails 로 변환하는 과정이 필요하다.

 

이것이 UserDetailsService 의 핵심이다. loadUserByUsername(String userame) 을 제공하여, 주어진 username 에 해당하는 사용자의 정보를 UserDetails 객체로 변환하도록 하는 인터페이스이다.

 

public interface UserDetailsService {  
  UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;  
}

Spring Security 와 Form Login

Spring Security 를 사용하면, Form Login 은 UsernamePasswordAuthenticationFilter 를 사용하여 로그인을 처리하게 된다.

 

기본적인 흐름은 다음과 같다.

  1. Filter Chain 에 formLogin() 을 등록한다. 이는 명시적으로 UsernamePasswordAuthenticationFilter 를 사용한다는 것과 같다.
  2. UsernamePasswordAuthenticationFilter 는 내부적으로 AuthenticationManager 에 사용자의 username 과 password 를 전달한다
  3. AuthenticationManagerAuthenticationProvider를 상속받은 DaoAuthenticationProvider 를 이용해 개발자가 Bean 으로 등록한 UserDetailsServicePasswordEncoder 를 사용하여 인증을 한다.
    • 이때 이 Provider 는 내부적으로, UserDetailsService.loadUserByUsername(...)PasswordEncoder.matches(rawPassword, encodedPassword) 에 대한 비교를 수행한다.

코드

우선, UserDetailsService 의 구현체를 작성해야 한다.

 

CustomUserDetailsService.java

public class CustomUserDetailsService implements UserDetailsService {  

  private final MemberRepository memberRepository;  
  @Override  
  public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {  

    Member member = memberRepository.findByEmail(email).orElseThrow(() -> new AuthException(  
        ErrorCode.USER_NOT_FOUND_BY_EMAIL));  

    return User.builder()  
        .username(member.getEmail())  
        .password(member.getPassword())  
        .authorities("ROLE_USER")  
        .build();  
  }  
}
  • 우리 서비스의 사용자 , 즉 Member 를 username(email) 로 조회한다.
  • 조회된 정보를 바탕으로 UserDetails 객체를 만들어 반환한다.

하지만, 인증시, Spring Security에서 제공하는 UserDetails 가 아닌 사용자 가 필요할 수도 있다.

 

그럴 경우, 직접 커스텀 해서 사용할 수 있다. 즉, 우리의 사용자 객체를 UserDetails 로 Wrapping 하는 것이다.

@Getter  
@RequiredArgsConstructor  
public class CustomUserDetails implements UserDetails {  

  private final Member member;  
  @Override  
  public Collection<? extends GrantedAuthority> getAuthorities() {  
    return List.of(new SimpleGrantedAuthority(member.getRole().name()));  
  }  

  @Override  
  public String getPassword() {  
    return member.getPassword();  
  }  

  @Override  
  public String getUsername() {  
    return member.getEmail();  
  }
  //... 나머지 메서드
}

@Service  
@RequiredArgsConstructor  
public class CustomUserDetailsService implements UserDetailsService {  

  private final MemberRepository memberRepository;  
  @Override  
  public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {  

    Member member = memberRepository.findByEmail(email).orElseThrow(() -> new AuthException(  
        ErrorCode.USER_NOT_FOUND_BY_EMAIL));  

    return new CustomUserDetails(member);  
  }  
}

 

이와 같이 커스텀 UserDetails 객체를 만듬으로써, Spring Security 와 비즈니스 도메인을 자연스럽게 연결할 수 있다.

 

PasswordEncoder

이제, 비밀번호 암호화, 검증에 사용한 PasswordEncoder 를 Bean 으로 등록 해주어야 한다.

@Bean  
public PasswordEncoder passwordEncoder(){  
  return new BCryptPasswordEncoder();  
}

 

AuthenticationManager

UserDetailsServicePasswordEncoder 를 등록했다면, AuthenticationManager 가 어떤 UserDetailsService , PasswordEncoder 를 사용할 지 명시해주어야 한다.

 

@Bean  
public AuthenticationManager authenticationManager(HttpSecurity http) throws Exception {  
  AuthenticationManagerBuilder builder = http.getSharedObject(AuthenticationManagerBuilder.class);  
  builder.userDetailsService(customUserDetailsService)  
      .passwordEncoder(passwordEncoder());  
  return builder.build();  
}
  • AuthenticationManagerBuilder 를 통해 AuthenticationManager 객체를 생성해 주면 된다.

FormLoginSuccessHandler.java

 

인증이 성공했다면, 우리의 어플리케이션은 JWT 토큰을 발급해주어야 한다.

public class FormLoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {  


  private final RefreshTokenRepository refreshTokenRepository;  
  @Override  
  public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,  
      Authentication authentication) throws IOException, ServletException {  

    CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal();  
    Member member = userDetails.getMember();  

    String accessToken = JwtUtil.generateAccessToken(member.getId(), member.getRole().name());  
    String refreshToken = JwtUtil.generateRefreshToken(member.getId());  

    refreshTokenRepository.save(new RefreshToken(member.getId(), refreshToken));  

    response.setHeader("Authorization", "Bearer " + accessToken);  
    response.setHeader("Refresh-Token", refreshToken);  

    response.setContentType("application/json; charset=UTF-8");  
    response.getWriter().write(new ObjectMapper().writeValueAsString(  
        Map.of("id", member.getId(), "email", member.getEmail(), "name", member.getName(), "role", member.getRole())  
    ));  
  }  
}
  • 이전의 filter 에서 CustomUserDetailsService 를 통해 이미 Authentication 객체에 Member 를 담았기 때문에, 추가로 DB를 조회하지 않아도 된다.

FilterChain 등록

이제 위에 정의된 클래스들을 필터 체인에 등록해야 한다.

.formLogin(form -> form  
    .loginProcessingUrl("/api/auth/login")  
    .usernameParameter("email")  
    .passwordParameter("password")  
    .successHandler(formLoginSuccessHandler)  
)

흐름 정리

  1. 사용자의 Form Login 요청
  2. .formLogin() 으로 등록된 UsernamePasswordAuthenticationFilter 가 지정된 URI 의 요청을 가로챔
  3. UsernamePasswordAuthenticationFilterAuthenticationManager 에게 usernamepassword 를 추출하여 전달한다.
  4. AuthenticationManager 는 정의된 UserDetailsService 를 통해 UserDetails 혹은 개발자가 정의한 CustomUserDetails 객체를 불러오고, AuthenticationProvider 의 기본 구현체인 DaoAuthenticationProvider 를 사용하여 사용자가 입력한 정보와, DB의 정보를 정의된 PasswordEncoder 를 통해 확인한다.
  5. 위의 모든 과정을 통과했다면, 정의된 successHandler 가 동작한다. 이 예시에선, accessTokenrefreshToken 을 생성하는데, 이때, 이전에 UserDetailsService 에서 불러온 CustomUserDetails 를 활용하기 때문에 DB 접근은 일어나지 않는다.
  6. 이후 인증이 필요한 페이지에 접근시, Client 는 Access Token 을 헤더에 포함하여 요청하며, 이 Access Token의 유효성을, 정의된 JwtAuthenticationFilter 를 통해 이루어 진다.
  7. 유효하다면, SecurityContextHolder 에 해당 Authentication 객체를 등록한다.