일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | |
7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 | 22 | 23 | 24 | 25 | 26 | 27 |
28 | 29 | 30 |
- 일급 컬렉션
- spring security
- lombok
- 일급 객체
- synchronized
- OAuth 2.0
- builder
- Volatile
- Dependency Injection
- Spring
- Google OAuth
- java
- nestjs
- middleware
- factory
- Today
- Total
HJW's IT Blog
[Spring Security] OAuth2.0 + JWT 로그인 구조에 Form Login 통합하기 본문
이전 포스팅에서 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
를 사용하여 로그인을 처리하게 된다.
기본적인 흐름은 다음과 같다.
- Filter Chain 에 formLogin() 을 등록한다. 이는 명시적으로
UsernamePasswordAuthenticationFilter
를 사용한다는 것과 같다. UsernamePasswordAuthenticationFilter
는 내부적으로AuthenticationManager
에 사용자의 username 과 password 를 전달한다AuthenticationManager
는AuthenticationProvider
를 상속받은DaoAuthenticationProvider
를 이용해 개발자가 Bean 으로 등록한UserDetailsService
및PasswordEncoder
를 사용하여 인증을 한다.- 이때 이 Provider 는 내부적으로,
UserDetailsService.loadUserByUsername(...)
과PasswordEncoder.matches(rawPassword, encodedPassword)
에 대한 비교를 수행한다.
- 이때 이 Provider 는 내부적으로,
코드
우선, 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
UserDetailsService
와 PasswordEncoder
를 등록했다면, 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)
)
흐름 정리
- 사용자의 Form Login 요청
.formLogin()
으로 등록된UsernamePasswordAuthenticationFilter
가 지정된URI
의 요청을 가로챔UsernamePasswordAuthenticationFilter
는AuthenticationManager
에게username
과password
를 추출하여 전달한다.AuthenticationManager
는 정의된UserDetailsService
를 통해UserDetails
혹은 개발자가 정의한CustomUserDetails
객체를 불러오고,AuthenticationProvider
의 기본 구현체인DaoAuthenticationProvider
를 사용하여 사용자가 입력한 정보와, DB의 정보를 정의된PasswordEncoder
를 통해 확인한다.- 위의 모든 과정을 통과했다면, 정의된
successHandler
가 동작한다. 이 예시에선,accessToken
과refreshToken
을 생성하는데, 이때, 이전에UserDetailsService
에서 불러온CustomUserDetails
를 활용하기 때문에 DB 접근은 일어나지 않는다. - 이후 인증이 필요한 페이지에 접근시, Client 는 Access Token 을 헤더에 포함하여 요청하며, 이 Access Token의 유효성을, 정의된
JwtAuthenticationFilter
를 통해 이루어 진다. - 유효하다면, SecurityContextHolder 에 해당 Authentication 객체를 등록한다.
'SPRING' 카테고리의 다른 글
Spring Batch 시스템에서 JdbcTemplate와 중복 데이터 처리 효율화 (0) | 2025.04.22 |
---|---|
Spring 테스트 격리와 트랜잭션: @Sql 삽입 데이터가 사라지는 이유 (0) | 2025.04.01 |
Spring Security 기반 OAuth 2.0 & JWT 구현하기 (0) | 2025.03.25 |
JPA 다건 조회 시 프록시 객체가 포함되는 원인 분석 - feat. Persistence Context 출력하기 (0) | 2025.03.12 |
Batch Fetching + Pagination으로 N + 1 해결하기 (0) | 2025.03.04 |