티스토리 뷰
해당 예제의 github : https://github.com/digda5624/spring_security_study
의 login package 를 참고하자.
포스팅기준으로는 JWT Login 을 작성했으나 예제에는 Session 로그인 또한 존재한다.
기존에 spring security 의 흐름을 살펴보았다.
spring security filter 의 요소들을 살펴보면 다음과 같은 구조이다.
그림에서도 알 수 있듯이 인증과 관련된 내용은 USernamePasswordAuthenticationFilter에서 진행한다고 생각하면 된다.
따라서 우리는 AuthenticationManager, SuccessHandler 와 FailerHandler 를 재정의를 하면 되겠다.
spring security 의 경우 특별한 것이 있는 것이 아니고 요청에 따라서 요청 내용을 검사 가공해준다. 서블릿 필터와 기능이 동일하다는 것이다.
Spring Security 를 사용하게 되면 default 로 Login Form 을 사용해서 인증을 하게 되는데 이를 해지하고 나만의 AuthenticationFilter 를 만들자.
따라서 인증 단계에서 핵심적으로 필요한 내용은 다음과 같다.
1. 자신이 정한 로그인 정책에 맞는 Authentication Filter 작성
2. Authentication Filter 가 인증을 위임할 Authentication Manager
3. 실질적인 인증을 맡아줄 Authentication Provider (Manager 가 다시 한번 위임한다.)
클래스 (Class) | 역할 |
AuthenticationFilter (인증 필터 관련) | 인증이 필요한 객체 생성 및 인증을 Authentication Manager 에게 위임 |
AuthenticationManager | 실질적으로 인증을 담당하는 AuthenticationProvider 를 관리 |
AuthenticationProvider | 실질적인 인증을 담당 |
Authentication | 인증완료 된 객체, 혹은 인증 대상 객체 |
Principal (의미론적) | 쉽게 말해 User (인증을 받게 될 주체) |
credentials (의미론적) | 쉽게 말해 비밀번호 |
UserDetailsService | UserInformation 가공을 위한 Supporting class |
UserDetails | UserInformation 을 정의 |
기존에 프로젝트에서는 JWT 를 사용하기에 JWT방식의 로그인을 demo project에 적용했다.
JWT Authentication Filter 를 작성할 때 UsernamePasswordAuthenticationFilter 를 상속 받아 사용했고, spring security 에서 제공하는 다른 AuthenticationFilter 를 사용해도 된다.
결국 AutheticationFilter 들의 핵심은 attemptAuthentication() 이고 이를 @override 하자.
코드는 다음과 같다.
@Slf4j
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
public JwtAuthenticationFilter(AuthenticationManager authenticationManager) {
setAuthenticationManager(authenticationManager);
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
// 인증을 위한 객체를 만든다.
// 전송이 오면 AuthenticationFilter로 먼저 요청이 오게 되고, 아이디와 비밀번호를 기반으로 Token 을 발급해야한다.
// 현재 principal 을 loginId 로 놓고 유효성을 검사하게 된다.
log.info("JwtAuthenticationFilter {} {}", request.getMethod(), request.getRequestURL());
ObjectMapper objectMapper = new ObjectMapper();
try {
final User user = objectMapper.readValue(request.getInputStream(), User.class);
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(user.getName(), user.getPassword());
return getAuthenticationManager().authenticate(authRequest);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
JWT 필터에서 하는 역할은 요청 request 의 Body를 분석하여 인증을 해야할 객체를 만들고 이 객체를 앞서 말한 Manager 에게 인증을 위임하게 된다. 해당 코드에서 살펴보면 UsernamePasswordAuthenticationToken 이 인증 객체이고 이를 Manager 에게 넘기는 것을 확인할 수 있다.
핵심 부분은 getAuthenticationManager().authenticate() 부분이다. 또한 생성자에서 직접 set Manager 를 하게 되는데, 직접 설정하지 않으면 default 로 다른 매니저가 들어가게 된다.
결국 AuthenticationManager 의 경우에도 Provider 쪽에 인증을 위임하게 되지만 살펴볼 필요가 있다.
AuthentcationManager 는 interface 로 다양한 구현체가 있지만 사실상 ProviderManager 를 사용하게 된다.
결국 핵심은 ProviderManager 가 어떤식으로 AuthenticationProvider 에게 인증을 위임 받는지만 살피면 되겠다.
ProviderManager의 authenticate 을 살펴보면
public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean {
//...
private List<AuthenticationProvider> providers = Collections.emptyList();
public ProviderManager(AuthenticationProvider... providers) {
this(Arrays.asList(providers), null);
}
//...
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Class<? extends Authentication> toTest = authentication.getClass();
// ...
for (AuthenticationProvider provider : getProviders()) {
// AuthenticationProvider 의 정책과 맞는지 확인 하는 부분
if (!provider.supports(toTest)) {
continue;
}
if (logger.isTraceEnabled()) {
logger.trace(LogMessage.format("Authenticating request with %s (%d/%d)",
provider.getClass().getSimpleName(), ++currentPosition, size));
}
try {
// 위임하는 부분
result = provider.authenticate(authentication);
if (result != null) {
copyDetails(authentication, result);
break;
}
}
// ...
}
}
}
즉 ProviderManager 는 그대로 사용하되 AuthenticationProvider 를 사용자 정의 하여 ProviderManager 에게 제공을 하면 된다.
ProviderManager 의 경우 실질적인 인증을 담당하게 되므로 이곳에서 dataBase 접근을 통한 id, password 확인이 이루어 지게 된다.
@RequiredArgsConstructor
public class JwtAuthenticationProvider implements AuthenticationProvider {
// repository 접근 및 인증완료 객체 생성을 위한 userDetailsService
private final UserDetailsService userDetailsService;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String userName = (String) authentication.getPrincipal();
String password = (String) authentication.getCredentials();
MyUserDetails userDetails = (MyUserDetails) userDetailsService.loadUserByUsername(userName);
if(!userDetails.getPassword().equals(password)){
throw new RuntimeException("password 틀립니다");
}
return new UsernamePasswordAuthenticationToken(userDetails.getUser(), null, userDetails.getAuthorities());
}
@Override
public boolean supports(Class<?> authentication) {
// 넘어올 인증 객체에 대한 Class 정보 true 이어야지 해당 클래스의 인증을 작업한다.
return authentication.equals(UsernamePasswordAuthenticationToken.class);
}
}
인자로 넘어온 Authentication 객체 즉, 인증을 받아야하는 객체에서 id(principal), password(credentials) 을 넘겨 받은 후
비밀번호 아이디 확인 작업이 끝난 후 인증이 완료된 객체인 UserPasswordAuthenticationToken 을 발급하게 된다.
UserDetailsService 의 경우 정말 말그대로 User에 대한 정보를 로딩을 Support 해주는 클래스이다. 살펴보면
다음과 같음을 알 수 있다. 우리는 UserRepository 를 접근해서 User 에 대한 내용을 UserDetails 객체에 담은 후 return 하면 된다.
@RequiredArgsConstructor
@Component
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
@Override
public MyUserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// Exception 을 Handling 할 수도 있지만 예제에서 생략
User user = userRepository.findByName(username)
.orElseThrow(() -> new RuntimeException("존재하지 않는 유저"));
return new MyUserDetails(user);
}
}
실제로도 UserRepository 에서 User 조회후 해당 user 정보를 통해 UserDetails 를 만드는 것을 살필 수 있다.
/**
* userDetails 를 바탕으로 인증을 하게 되므로 MyUserDetails를 구현하여야 한다.
*/
@Getter
@RequiredArgsConstructor
public class MyUserDetails implements UserDetails {
private final User user;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> authorities = new ArrayList<>();
GrantedAuthority grantedAuthority = () -> user.getRole().toString();
authorities.add(grantedAuthority);
return authorities;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getName();
}
@Override
public boolean isAccountNonExpired() {
return false;
}
@Override
public boolean isAccountNonLocked() {
return false;
}
@Override
public boolean isCredentialsNonExpired() {
return false;
}
@Override
public boolean isEnabled() {
return false;
}
}
자 결국 이를 통해서 UserDetails 의 정보를 통해 인증을 완료하게 되면, 앞서 본 AuthenticationProvider 에서 인증이 완료된 객체인 UsernamePasswordAuthenticationToken을 발급하게 된다.
이 인증 정보를 통해서 Filter 에서는 Login Success Handler 를 호출 하게 되는데 이는 다음 포스팅에서 알아보자.
결국 Spring Security 의 인증 흐름은 다음과 같다.
1. Spring Security Filter Chain 에서 인증 필터(로그인 필터) 로의 접근
2. 인증 필터는 인증을 AuthenticationManager 를 통해 AuthenticationProvider 에게 위임하고 인증이 필요한 객체를 생성해 넘김
3. 넘어온 인증필요 객체를 UserDetailsService 를 통해 UserDetails 를 작성하고 Principal, Credentials 와 비교하여 올바른 유저인지 확인 한다. 올바른 유저가 맞다면 인증 완료 객체를 발급하고 다시 인증 필터에게 넘겨준다.
4. Exception 이 없었다면 필터는 LoginSuccessHandler를 호출하고 실패하게 될 경우 별도의 Exception Handler 를 호출하게 된다.
주의 할 점은 이 부분들은 서블릿 Filter 단에서 일어나는 작업이므로 Exception Handler 를 통한 Listening 은 불가하다.
실제로 이런 저런 제약사항으로 인해서 팀원 이모씨께서는 Login 은 Filter를 사용하지 않았다...
'개발일지' 카테고리의 다른 글
엑셀 파일 유연하게 읽어보기 (0) | 2022.11.07 |
---|---|
batch insert 와 JPA 의 한계 (2) | 2022.11.04 |
선착순 동시성 문제 해결하기 낙관적 락, 비관적 락 (1) | 2022.11.03 |
다형성과 캡슐화, Spring DI 를 사용해 유연한 정책 변화 만들기 (0) | 2022.11.02 |
[Spring Security + AOP] 로그를 찍어보자 - 로그인 인증 (2) (1) | 2022.11.02 |
- Total
- Today
- Yesterday