작성 이유
지금까지 Spring Security 프레임워크를 사용하지 않고 로그인, 인증, 인가를 구현했었다. 상당히 어려웠었는데, Spring Security 를 학습하면서 Spring Security 가 얼마나 잘 짜여진 보안 / 인증관련 프레임워크인지 알게됐고, 복기하기 위해 작성하게 되었다. 기록 시작 !
Authentication ?
사전적 의미로 Authentication 은 인증이라는 의미를 가지고있다. 애플리케이션의 특정 자원에 접근하기 위해선 권한이 있어야하고, 사용자는 권한을 얻기 위해 인증 정보를 확인받아야한다. 최종적으로 인증이 완료되면 Authentication 객체에는 유저 정보 + 권한 정보가 담겨져있는 상태가 되는데, 이건 아래에서 좀 더 적어보도록 하고..
아래 이미지는 Form Login 시 흐름을 나타내는데, (OAuth Login 아님) 흐름을 요약하자면,
- 클라이언트에서 서버로 User 정보를 전송하며 최초 로그인 요청 발생
- Form Login 방식이니까 UserPasswordAuthenticationFilter 에서 UsernamePasswordAuthenticationToken 객체 생성
- Authentication 은 인터페이스이기 때문에 구현체인 UsernamePasswordAuthenticationToken 객체가 생성된다.
- 인증을 준비하는 단계이기 때문에 클라이언트로부터 받아온 정보 외엔 권한 정보가 비어있는 상태이다. 아직 인증이 안됬으니까 !
- UsernamePasswordAuthenticationToken 은 너무 기니까 편의상 Authentication 타입 객체라고 기록하자..
- 이제 생성된 Authentication 타입 객체를 인자로 해서 AuthenticationManager 에게 보낸다.
- AuthenticationManager 는 직접 인증 처리를 하지않고, 적절한 AuthenticationProvider 에게 인증 처리를 위임한다.
- 내부코드를 보면 알겠지만 Manager 에서 for 문을 통해 적절한 Provider 를 찾고 그 Provider 에게 Authentication 타입 객체를 보내는 로직이 존재한다.
- AuthenticationProvider 에서는 loadUserByUsername(username); 메서드를 통해 실제 인증 처리를 한다.
- Provider 에서 UserDetailsService 에서 제공하는 loadUserByUsername(username) 메서드를 호출한다.
- UserDetailsService 의 loadUserByUsername(username) 메서드 반환 시 [유저 정보 + 권한 정보] 를 담은 UserDetails 타입 객체를 다시 AuthenticationProvider 로 반환한다.
- AuthenticationProvider 는 UserDetails 타입 객체를 받아 비밀번호 유효성 검사 등을 거친 뒤, UserDetails 타입 객체에서 [유저 정보 + 권한 정보] 를 가져와 Authentication 타입 객체 생성자를 통해 Authentication 타입 객체를 초기화한다.
- 이후 Provider -> Manager -> Filter (UserPasswordAuthenticationFilter) 를 거쳐 SecurityContext 에 Authentication 타입 객체가 성공적으로 저장된다 !
한 단계씩 뜯어보기
먼저 Filter 를 거쳐 Authentication 객체를 생성하는 과정부터 살펴보자. 클라이언트에서 ID (username), password (credential) 를 입력해 최초 로그인 요청이 오면 Filter 에서 Authentication 객체를 생성한다. 인증을 준비하는 단계로 클라이언트로부터 받아온 정보 외엔 권한 정보가 비어있는 상태. 권한은 아직 인증이 안됬으니까 없는거고 principal 은 아이디, credential 은 비밀번호라고 생각하자.
아래는 AbstractAuthenticationProcessingFilter 인터페이스 코드인데, UsernameFilter 클래스는 ProcessingFilter 인터페이스를 상속받고 있다.최초 로그인 요청이 왔을 때 UsernameFilter 클래스에서 요청을 바로 가로채는게 아니라, AbstractAuthenticationProcessingFilter 인터페이스에서 요청을 가로채는데, 이후 UsernameAuthenticationFilter 에서 정의된 attemptAuthentication(request, response); 메서드가 호출된다.
비로소 UsernamePasswordAuthenticationFilter 로 진입했다. 클라이언트로부터 받은 유저 정보를 가지고 알맞은 Authentication 타입 객체인 UsernamePasswordAuthenticationToken 을 생성한다. 생성 후 해당 객체의 인증을 위해 Manager 로 보내는 로직을 확인할 수 있다.
위의 this.getAuthenticationManager() 어쩌고 구문을 보기 전에, AuthenticationManager, AuthenticationProvider 에 대해 짚고가자. AuthenticationManger 는 인터페이스이며 Authentication 타입을 반환하는 authenticate() 메서드 하나만을 제공한다.
AuthenticationProvider 또한 인터페이스이며 역시 Authentication 타입을 반환하는 authenticate() 메서드를 제공하고, supports() 메서드는 AuthenticationManager에 등록된 Provider 구현체들을 확인하면서 인증 처리를 위임하기 위해 적절한 Provider 를 찾아내는 메서드이다.
참고) AuthenticationManager 에 AuthenticationProvider 구현체 등록 ?
Spring Security 는 기본적으로 DaoAuthenticationProvider 라는 구현체를 Provider 로 사용한다. 기본 Provider 구현체 대신 직접커스텀한 Provider 를 따로 구현해서 사용할 수 있는데, Security Context 를 초기화하는 과정에서 Bean 으로 등록된 Provider 를 모두 AuthenticationProvider 에 등록하기 때문에 Bean 객체로 등록하기만하면 커스텀 Provider 를 사용할 수 있다 !
이제 AuthenticationManager 에 등록된 알맞은 Provider 를 어떻게 찾아내는지 알아보자. authenticate() 라는 메서드가 하나 있으니까 이 메서드를 구현해 사용하는 구현체를 확인해보면, ProviderManager 클래스에서 AuthenticationManager 의 authenticate() 메서드를 구현해 사용하고 있는 것을 확인할 수 있고 for() 반복문을 활용해 Provider 를 찾는 로직도 확인할 수 있다. 반복문을 통해 AuthenticationManager 에 등록된 Provider 내부에 구현된 supports() 메서드를 호출하고 값이 True 인 Provider 를 찾고 해당 Provider 에게 인증 요청을 위임한다.
나는 DaoAuthenticationProvider 기본 Provider 를 사용하지 않고 AuthenticationProvider 를 커스텀한 CustomeProvider 구현체 클래스들을 사용했다. 따라서 AuthenticationManager 에 등록하기 위해 @Component 를 활용해서 CustomProvider 객체를 Bean 객체로 설정했고, 코드를 보면 supports() 메서드가 오버라이딩 된 것을 확인할 수 있다. 즉, 등록된 Provider 구현체를 돌면서 위의 코드 toTest 변수에 담긴 Authentication 구현체의 타입과 비교하고 True 를 반환한 Provider 에게 인증 요청을 위임한 것이다.
다시 ProviderManager 클래스의 authenticate() 메서드로 돌아오자.
인증 처리를 위임할 Provider 를 찾았다면 해당 Provider 의 authenticate() 메서드를 실행한다.
드디어 실제 인증 처리를 담당하는 로직으로 들어왔다. 우리는 이제 Filter 로 반환할 Authentication 구현체를 채워넣으면 된다.
loadUserByUsername() 메서드는 실제 인증처리를 담당하고 반환타입으로 UserDetailsService 인터페이스의 구현체를 반환하게 되는데, 구현체로는 AccountContext 객체 타입으로 반환하도록 로직을 작성했다. 아래 코드는 AccountContext 클래스 코드이다.
AccountContext 는 UserDetails 타입이라고 해놓고 왜 UserDetails 을 구현하는 것이 아닌 User 라는 객체를 상속하는걸까 ?
아래 코드에서 알 수 있듯이, User 객체가 UserDetails 인터페이스를 구현하고 있는 구현체이기 때문이다. 사용자는 Spring Security 에서 미리 만들어둔 User 구현체를 통해 손쉽게 유저 정보와 유저 권한 정보를 셋팅해 Provider 로 반환하면 된다.
다시 UserDetailsSerivce 인터페이스의 loadUserByUsername(username) 호출 부분으로 돌아오자. loadUserByUsername(username) 메서드를 오버라이딩하기 위해 UserDetailsService 인터페이스를 구현한 커스텀 UserDetailsService 클래스를 생성했다. 이제 이 구현체에서 loadUserByUsername() 메서드 내부 로직을 재정의해서 DB 에 접근하고, 사용자 정보를 가져와 유효성 검사를 거친 뒤 UserDetails 타입 객체를 Provider 로 반환하면 된다.
먼저, 클라이언트로부터 받아온 username 을 통해 DB 에서 해당 username 과 일치하는 유저 (여기서는 Account) 가 있는지 확인한다.
있다면, 해당 유저의 권한 정보를 List<GrantedAuthority> 타입에 넣어주고, 해당 유저 정보와 권한 정보를 UserDetails 타입의 구현체 인 AccountContext 객체에 담아 반환한다. GrantedAuthority 는 인터페이스인데 User 구현체를 확인해보면 생성자에 Collection 타입으로 GrantedAuthority 인터페이스를 사용하는 것을 확인할 수 있고, 나는 List 자료구조를 사용해 생성자 매개변수로 넘겨줬다.
UserDetails 타입 구현체 AccountContext 가 Provider 로 반환되면 PasswordEncoder 를 활용해 비밀번호 유효성을 체크한다.
그리고 Filter 로 반환해야하는 Authentication 구현체인 UsernamePasswordAuthenticationToken 구현체 생성자를 통해 UserDetails 구현체 정보를 매개변수로 넣고 셋팅한 뒤 Manager 로 반환한다.
Provider 반환 결과인 Manager result 변수를 확인해보면 principal 에는 유저 정보, authorities 에는 권한 정보 등 최초 Authentication 타입 구현체에 없던 정보들이 저장돼있는 것을 확인할 수 있다. 마지막으로 Filter 에서는 인증이 완료된 Authentication 타입 객체를 Security Context 에 저장하고, Security Context 는 Http Session 에 저장하면서 인증이 최종 완료된다 !
인증 아키텍처 한 눈에 보기
Reference
Spring Security 의 내부 코드를 디버깅하며 찾아가는 과정을 거치니 좀 더 이해가 잘 되는 것 같기도 하고.. 아직 남의 코드보는 연습이 덜 되서 눈이 아프기도하다. 좀 더 학습하고 다음 포스팅으로 인가 정책, 인증 시 Custom 객체를 왜 만들어 사용했는지 작성해보도록 하자 👍
'SPRING' 카테고리의 다른 글
Custom Exception 활용법 (0) | 2023.06.10 |
---|---|
DTO 반환에 대해 (4) | 2022.10.13 |
[Spring] GitHub OAuth2.0 구현하기 (웹 버전) (2) | 2022.05.23 |
Spring @Validation 을 통한 검증, 예외처리 도전기 (0) | 2022.04.16 |