로그인 기능은 거의 대부분의 애플리케이션에서 기본적으로 사용됩니다. 추가로 요즘은 웹이 아닌 모바일에서도 사용 가능하다는 장점과 Stateless 한 서버 구현을 위해 JWT를 사용하는 경우를 많이 볼 수 있는데요. 많이 사용되고, 또 주된 기능인만큼 꼭 한 번은 제대로 공부하고 싶어서 여러 자료를 참고하여 직접 구현하며 정리하는 내용입니다.
(현재 코드는 Security + JWT + JPA 를 사용하여 구현되어 있고, 이후 최종적으로 Redis를 사용한 토큰 재발급 과정까지 추가할 예정입니다.)
* 본 내용을 보기 전 참고하면 좋을 JWT, Spring Security 동작 원리, 구현된 코드 등은 포스팅 맨 아래 참고 자료로 링크를 첨부해놓았습니다. 도움이 되시길 바라며, 내용 중 잘못된 부분은 지적해주시면 수정하며 다시 공부하겠습니다.
Security + JWT의 기본이 되는 동작 원리입니다.
실제 구현된 코드를 통해 로그인 과정을 하나하나 살펴보도록 하겠습니다.
먼저 프로젝트 구조입니다. 프로젝트 구조는 개발하는 사람에 따라 다르기 때문에 참고 정도로만 보시면 될 듯합니다.
기본적인 Controller, Service, Entity 외 Spring Security와 JWT 관련하여 구현해야 하는 class들의 내용을 간략하게 설명하겠습니다.
JWT
- JwtAuthenticationFilter
: 클라이언트 요청 시 JWT 인증을 하기 위해 설치하는 Custom Filter로 UsernamePasswordAuthenticationFilter 이전에 실행됩니다. - JwtTokenProvider
: JWT 토큰 생성, 토큰 복호화 및 정보 추출, 토큰 유효성 검증의 기능이 구현된 클래스입니다.
Security
- WebSecurityConfig
: Secutiry 설정을 위한 class로 WebSecurityConfigurerAdapter를 상속받습니다. - CustomUserDetailsService
: 인증에 필요한 UserDetailsService interface의 loadUserByUsername 메서드를 구현하는 클래스로 loadUserByUsername 메서드를 통해 Database에 접근하여 사용자 정보를 가지고 옵니다. - SecurityUtil
: 클라이언트 요청 시 JwtAuthenticationFilter에서 인증되어 SecurityContextHolder에 저장된 Authentication 객체 정보를 가져오기 위해서 만든 클래스입니다.
public ResponseEntity<?> login(UserRequestDto.Login login) {
// 1. Login ID/PW 를 기반으로 Authentication 객체 생성
// 이때 authentication 는 인증 여부를 확인하는 authenticated 값이 false
UsernamePasswordAuthenticationToken authenticationToken = login.toAuthentication();
// 2. 실제 검증 (사용자 비밀번호 체크)이 이루어지는 부분
// authenticate 매서드가 실행될 때 CustomUserDetailsService 에서 만든 loadUserByUsername 메서드가 실행
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
// 3. 인증 정보를 기반으로 JWT 토큰 생성
UserResponseDto.TokenInfo tokenInfo = jwtTokenProvider.generateToken(authentication);
// TODO:: RefreshToken Redis 저장
return response.success(tokenInfo, "로그인에 성공했습니다.", HttpStatus.OK);
}
클라이언트의 요청이 컨트롤러를 거쳐 들어오게 되는 UserService class의 login 메서드입니다.
로그인 시 거치게 되는 단계는 크게 총 3 단계입니다.
- Login 요청으로 들어온 ID, PASSWORD 기반으로 Authentication 객체를 생성합니다.
- authenticate() 메서드를 통해 요청된 사용자에 대한 검증이 진행됩니다.
- 검증이 정상적으로 통과되었다면 인증된 authentication 객체를 기반으로 JWT 토큰을 생성합니다.
첫 번째 ID, PW 기반으로 Authentication 객체를 생성하는 과정입니다.
(UsernamePasswordAuthenticationToken 클래스는 AbstractAuthenticationToken 추상 클래스를 상속받았고, AbstractAuthenticationToken 추상 클래스는 Authentication 인터페이스를 구현한 구조입니다.)
UsernamePasswordAuthenticationToken class를 살펴보면 두 개의 생성자가 있는 것을 볼 수 있는데요. 여기서는 위에 있는 principal, credentials를 인자로 받는 생성자를 통한 객체가 생성됩니다.
이때 authenticated 값은 false로 해당 Authentication은 아직 인증되지 않았으며, 인증을 위해 만들어진 객체가 됩니다.
이렇게 인증을 위해 만들어진 Authentication 객체를 가지고 두 번째 과정인 실제 인증이 진행됩니다.
* 설명이 부족한 코드는 글 맨 하단에 깃허브에 구현된 코드를 참고 부탁드립니다.
ProviderManager 클래스의 authenticate() 메서드입니다.
스프링 시큐리티의 동작 원리에 의해 AuthenticationManager interface를 구현한 ProviderManager 클래스의 authenticate() 메서드가 동작합니다.
해당 메서드의 동작 과정을 보면 모든 Providers를 for문으로 돌리며 각 provider가 해당 인증을 할 수 있는지 여부를 supports 메서드로 확인합니다. 그리고 인증을 할 수 있는 provider를 발견하면 해당 provider의 authenticate() 메서드를 통해 인증을 진행합니다.
위 과정에서 해당 인증을 처리할 수 있는 provider로 결정된 클래스가 AbstractUserDetailsAuthenticationProvider class입니다.
결국 해당 클래스의 authenticate() 메서드를 통해 인증이 진행되고, 여기서 중요하게 봐야 하는 부분은 retrieveUser() 메서드입니다.
위 AbstractUserDetailsAuthenticationProvider class의 authenticate() 메서드 동작 중 실행되는 retrieveUser() 메서드는 해당 클래스를 상속받은 DaoAuthenticationProvider class에 구현되어 있습니다.
여기서 loadedUser 객체를 가져오기 위해 실행되는 loadUserByUsername() 메서드는 직접 구현이 필요한 부분인데요.
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UsersRepository usersRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return usersRepository.findByEmail(username)
.map(this::createUserDetails)
.orElseThrow(() -> new UsernameNotFoundException("해당하는 유저를 찾을 수 없습니다."));
}
// 해당하는 User 의 데이터가 존재한다면 UserDetails 객체로 만들어서 리턴
private UserDetails createUserDetails(Users users) {
return new User(users.getUsername(), users.getPassword(), users.getAuthorities());
}
}
UserDetailsService interface를 구현한 CustomUserDetailsService class를 통해 loadUserByUsername 메서드를 실제로 구현해줘야 합니다. 그래야 인증 과정에서 해당 메서드가 동작하며 데이터베이스와 연동하여 해당 username(아이디) 존재 여부가 점증됩니다.
다시 AbstractUserDetailsAuthenticationProvider class로 돌아오겠습니다. retrieveUser() 메서드가 실행되어 데이터베이스에 해당 유저가 있는지 확인을 마치면 아래 additionalAuthenticationChecks() 메서드에서는 해당 유저의 비밀번호 일치 여부를 확인하는데요.
additionalAuthenticationChecks() 메서드도 retrieveUser() 메서드와 마찬가지로 DaoAuthenticationProvider class에 구현되어 있습니다.
동작 과정을 보면 passwordEncoder.matches(presentedPassword, userDetails.getPassword()) 를 통해 해당 UserDetails 객체의 비밀번호가 일치하는지 여부를 확인하는 것을 볼 수 있습니다.
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
.
.
.
return createSuccessAuthentication(principalToReturn, authentication, user);
}
이렇게 비밀번호까지 확인되었다면 AbstractUserDetailsAuthenticationProvider class의 authenticate() 메서드는 최종적으로 createSuccessAuthentication() 메서드의 결과를 return 하는데요.
AbstractUserDetailsAuthenticationProvider에 구현된 createSuccessAuthentication() 메서드입니다.
UsernamePasswordAuthenticationToken result = new UsernamePasswordAuthenticationToken(principal,
authentication.getCredentials(), this.authoritiesMapper.mapAuthorities(user.getAuthorities()));
result.setDetails(authentication.getDetails());
this.logger.debug("Authenticated user");
return result;
이때 만들어지는 UsernamePasswordAuthenticationToken 객체가 바로 아래에
UsernamePasswordAuthenticationToken class의 아래 있는 생성자이며, 이때 authenticated 값은 true 가 되고 해당 객체는 인증 완료된 Authentication 객체가 됩니다.
(Service의 첫 번째 단계에서는 인증을 위해 위에 첫 번째 생성자를 통해 authenticated 값이 false인 객체가 생성되었습니다.)
여기까지가 Service 로직에서 JWT 생성 전까지 ID, PW 요청에 대해 인증을 하는 과정입니다.
전체 내용을 다시 조금 정리해보자면 아래와 같습니다.
- id, pw 기반의 로그인 요청이 들어옵니다.
- 요청된 id, pw를 가지고 UsernamdPasswordAuthenticationToken을 생성하여 인증을 진행합니다.
- 실제 인증을 위한 authenticate() 메서드가 실행되고, 해당 메서드는 AuthenticationManager interface를 구현한 ProviderManager class의 메서드입니다.
- ProviderManager의 authenticate() 메서드는 모든 provider 중에서 해당 인증을 처리할 수 있는 provider를 찾아 실제 인증 절차인 authenticate() 메서드를 실행시킵니다.
- 이때 provider가 AbstractUserDetailsAuthenticationProvider이고 authenticate() 메서드 로직 중 일부는 DaoAuthenticationProvider에 실제로 구현되어서 사용되고 있습니다.
- AbstractUserDetailsAuthenticationProvider의 authenticate() 메서드를 통해 로그인 요청된 id, pw에 대한 인증이 처리됩니다.
이어지는 포스팅에서는 인증 후 JWT 생성과 생성된 토큰을 가지고 api 요청을 했을 때 과정을 살펴보겠습니다.
https://wildeveloperetrain.tistory.com/58
함께 보면 좋은 자료
GitHub
참고 자료
'Programming > Spring Boot' 카테고리의 다른 글
security + jwt + redis 로그인 기능 구현 (최종) (17) | 2021.09.23 |
---|---|
spring security + JWT 로그인 기능 파헤치기 - 2 (4) | 2021.09.22 |
UsernameNotFoundException Not Working 이유 파헤치기 (0) | 2021.09.20 |
Spring Security 시큐리티 동작 원리 이해하기 - 2 (5) | 2021.09.10 |
Spring Security 시큐리티 동작 원리 이해하기 - 1 (0) | 2021.09.09 |