이번 포스팅은
https://mangkyu.tistory.com/76
해당 블로그의 좋은 Spring Security 글을 읽고, 이해한 내용을 풀어서 쉽게 설명하고, 다른 글들을 참고하여서 Spring Security의 동작원리에 대해서 설명해보겠다.
Spring Security를 공부하면서 느낀점이 참, 다형성을 활용하여서 유지보수는 물론이고, 확장에 참 용이하게 만들었다는 느낌이 들었다.
서론은 이만 줄이고, 시작해보도록 하겠다.
Spring Security는 인증과 인가를 담당하는 스프링 하위 프레임 워크이다.
그렇다면 인증과 인가가 무엇인가?
인증
유저가 누구인지 확인하는 절차, 로그인하는것
인가
유저가 요청하는 request를 실행할 수 있는 권한이 있는 유저인가를 확인하는 것. 로그인을 하더라도, role이 admin이 아니면 admin 작업을 실행할 수 없다.
Spring Security는 인증 절차를 거친후에 인가 절차를 진행한다.
이때 필요한 인증,인가에 대한 정보를 Principal을 아이디, Credential을 비밀번호로 사용한다.
Spring Security는 인증에 대한 부분을 Filter흐름에 따라 처리하고 있다.
그렇다면 Filter는 어떻게 작동하는가?
기본적으로 Filter는 Servlet에서의 필터를 말하며, Request & Response를 처리할때 실행되는 자바 클래스이다.
Request가 Client로부터 Spring Controller에 도달하기전에 한번
Response가 Spring Controller로부터 Client에게 전달되기 전에 한번
이렇게 실행이 된다.
이해를 쉽게 하기 위해서는 로그인 요청을 보냈을때부터, 하나하나 로직을 타가면서 이해를 해보도록하자.
클라이언트가 API 요청을 하면
Client -> Web server(Tomcat) -> Filter Chain -> Dispatcher Servlet -> Controller
이 순서로 동작하게 된다.
여기서 Filter Chain에다가 요청마다 Logging을 하는 Filter를 두었다면, 요청이 들어올때마다, Controller의 로직 성공 유무에 관계없이 요청을 로깅할 수 있을 것이다.
그렇다면 Spring Security Filter Chain은 무엇인가?
Spring Security에서는 Filter 인터페이스를 구현하여 보안에 필요한 작업들을 수행하는 필터를 제공한다.
우리가 많이 보았던, UsernamePasswordAuthenticationFilter도 여기에 속한다.
중요한것은, 이것은 Filter라는 것이다. 우리가 커스텀하여서 UsernamePasswordAuthenticationFilter의 구현체를 만들 수 있는데, 예를 들어, null이 불가능하게 한다던지 id가 10자리 이상 넘어가면 거절한다는지, 원하는 개발 환경에 맞춰 커스텀할 수 있게 인터페이스로 만들었다는것이 좋다.
그렇다면, Servlet filter에서 Spring Security의 filter chain으로 어떻게 넘어갈까?
DelegatingFilterProxy와 FilterChainProxy이다.
그림과 같이, Client의 요청을 받아 Servlet Filter Chain을 진행하던중 DelegatingFilterProxy객체에 다다르면, Application Context에서 SpringSecurityFilterChain이름으로 생성된 Bean을 찾는다.
그게 바로 FilterChainProxy이다.
해당 빈을 찾으면 SpringSecurityFilterChain으로 요청을 위임한다.
각각의 Filter들에게 순서대로 요청을 체인형식으로 넘기면서 처리한다.
그러면, 그림과 같이 LogoutFilter, UsernamePasswordAuthenticationFilter, Bean으로 등록된 FilterChainProxy등등은 Spring Security 라이브러리를 설치하면 기본적으로 등록이된다.
그럼 SpringSecurity의 아키텍쳐를 봐보자
처음에 약간 헷갈린게, HTTP Request가 들어온다고 치자, 그러면 AuthenticationFilter로 간다고 그림에 보여져 있다.
그래서 아 Filter Chain에서 AuthenticationFilter가 있는건가? 싶었는데 그건또 아니다. 그러면 이게 무슨말이냐면, AuthenticationFilter라는 구체적인 클래스가 존재하는게 아니고,
인증을 처리하는 여러 Filter에 존재하는 예를 들어, 우리가 많이 아는 UsernamePasswordAuthenticationFilter등을 범용적인 용어로 AuthenticationFilter라고 지칭한다.
고로 우리는 여기까지 요청이 들어왔을때 SpringSecurityChain에서 UsernamePasswordAuthenticationFilter까지 들어온 것을 확인할 수 있다.
@Log4j2
public class CustomAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
public CustomAuthenticationFilter(AuthenticationManager authenticationManager) {
super.setAuthenticationManager(authenticationManager);
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(request.getParameter("userEmail"), request.getParameter("userPw"));
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
}
출처: https://mangkyu.tistory.com/77 [MangKyu's Diary:티스토리]
UsernamePasswordAuthenticationFilter는 인터페이스다. 왜냐하면 그 이유는 앞에서 설명을 했다. id가 null인지 뭐 개발 환경에 따라서 조건에 맞춰서 개발하는 커스텀 구현체를 만들기 위해서이다.
attemptAuthentication메서드 부분을 봐보자, request에서 id,password를 가져오고 UsernamePasswordAuthenticationToken을 만들고 반환한다.
그러면, UsernamePasswordAuthenticationToken이 뭐냐?
public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken {
// 주로 사용자의 ID에 해당함
private final Object principal;
// 주로 사용자의 PW에 해당함
private Object credentials;
// 인증 완료 전의 객체 생성
public UsernamePasswordAuthenticationToken(Object principal, Object credentials) {
super(null);
this.principal = principal;
this.credentials = credentials;
setAuthenticated(false);
}
// 인증 완료 후의 객체 생성
public UsernamePasswordAuthenticationToken(Object principal, Object credentials,
Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
this.credentials = credentials;
super.setAuthenticated(true); // must use super, as we override
}
}
public abstract class AbstractAuthenticationToken implements Authentication, CredentialsContainer {
}
출처: https://mangkyu.tistory.com/76 [MangKyu's Diary:티스토리]
User의 Id가 Principal 역할을 하고 Password가 Credential역할을 한다.
첫번째 생성자는 인증 전의 객체를 생성하고, 두번째 생성자는 인증이 완료된 객체를 생성한다.
그러면 attemptAuthentication메서드에서는 당연히, 인증전 객체가 생성되는 것이다.
어? 그런데 UsernamePasswordAuthenticationToken을 보면 AbstractAuthenticationToken의 하위클래스인데 AbstractAuthenticationToken은 Authentication을 구현한 추상클래스이다.
그러면 Authentication이 뭐냐?
Authentication은 현재 접근하는 주체의 정보와 권한을 담는 인터페이스이다.
해당 Authentication객체는 SecurityContext에 저장되며, SecurityContextHolder를 통해서 SecurityContext에 접근하고, SecurityContext를 통해 Authentication에 접근할 수 있다.
public interface Authentication extends Principal, Serializable {
// 현재 사용자의 권한 목록을 가져옴
Collection<? extends GrantedAuthority> getAuthorities();
// credentials(주로 비밀번호)을 가져옴
Object getCredentials();
Object getDetails();
// Principal 객체를 가져옴.
Object getPrincipal();
// 인증 여부를 가져옴
boolean isAuthenticated();
// 인증 여부를 설정함
void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}
출처: https://mangkyu.tistory.com/76 [MangKyu's Diary:티스토리]
아 그러면 여기서 알 수 있다. 단순히 말하면 Authentication이 id,password를 담은 객체이고, 이걸 구현한게 UsernamePasswordAuthenticationToken이구나 라고 알 수 있다.
이렇게 AuthenticationFilter는 아직 인증이 되지 않은 UsernamePasswordAuthenticationToken을 AuthenticationManager에게 전달한다. AuthenticationManager는 실제로 인증을 처리할 여러개의 AuthenticationProvider를 가지고 있다.
그러면 AuthenticationManager는 뭐고, AuthenticationProvider는 뭐냐?
AuthenticationManager
public interface AuthenticationManager {
Authentication authenticate(Authentication authentication)
throws AuthenticationException;
}
출처: https://mangkyu.tistory.com/76 [MangKyu's Diary:티스토리]
인증에 대한 부분은 AuthenticationManager를 통해서 처리되는데, 실질적으로 처리되는 로직은 AuthenticationManager에 등록된 AuthenticationProvider에 의해서 처리된다. 인증이 성공하면 2번째 생성자를 통해서 인증이 성공한 객체를 생성하여 SecurityContext에 저장한다.
AuthenticationManager를 보면 인터페이스이다. 해당 인터페이스를 구현한 구현체가 ProviderManager인데 실제 인증과정에 대한 로직을 가지고 있는 AuthenticationProvider들을 List로 가지고 있어서 for문을 돌면서 모든 Provider를 조회하면서 authenticate처리를한다.
public class ProviderManager implements AuthenticationManager, MessageSourceAware,
InitializingBean {
public List<AuthenticationProvider> getProviders() {
return providers;
}
public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
Class<? extends Authentication> toTest = authentication.getClass();
AuthenticationException lastException = null;
Authentication result = null;
boolean debug = logger.isDebugEnabled();
//for문으로 모든 provider를 순회하여 처리하고 result가 나올 때까지 반복한다.
for (AuthenticationProvider provider : getProviders()) {
....
try {
result = provider.authenticate(authentication);
if (result != null) {
copyDetails(authentication, result);
break;
}
}
catch (AccountStatusException e) {
prepareException(e, authentication);
// SEC-546: Avoid polling additional providers if auth failure is due to
// invalid account status
throw e;
}
....
}
throw lastException;
}
}
출처: https://mangkyu.tistory.com/76 [MangKyu's Diary:티스토리]
그러면, 우리는 UsernamePasswordAuthenticationToken을 가지고 아이디와 패스워드가 맞는지 등을 확인할 Provider를 만들어야한다.
그런데 가만히 생각해보면, JWT를 사용할때나, 세션을 사용할때나, 혹은 개인 개발환경에 맞게 Provider들이 전부 다를것이다.
public interface AuthenticationProvider {
// 인증 전의 Authenticaion 객체를 받아서 인증된 Authentication 객체를 반환
Authentication authenticate(Authentication var1) throws AuthenticationException;
boolean supports(Class<?> var1);
}
출처: https://mangkyu.tistory.com/76 [MangKyu's Diary:티스토리]
그래서 AuthenticationiProvider가 인터페이스고 해당 authenticate메서드를 구현한 구현체를 우리가 만들어주면 된다.
@RequiredArgsConstructor
@Log4j2
public class CustomAuthenticationProvider implements AuthenticationProvider {
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
UsernamePasswordAuthenticationToken token = (UsernamePasswordAuthenticationToken) authentication;
// AuthenticaionFilter에서 생성된 토큰으로부터 아이디와 비밀번호를 조회함
String userEmail = token.getName();
}
@Override
public boolean supports(Class<?> authentication) {
return authentication.equals(UsernamePasswordAuthenticationToken.class);
}
}
출처: https://mangkyu.tistory.com/77 [MangKyu's Diary:티스토리]
SpringSecurity에서는 Username으로 DB에서 데이터를 조회한 다음에, 비밀번호의 일치 여부를 검사한다.
그렇기 때문에 먼저 UsernamePasswordToken으로 부터 아이디를 조회해야한다.
이렇게 AuthenticationProvider에서 id를 조회하면, UserDetailsService로 id를 넘겨줘서 id에 해당하는 실제 User객체를 가져와야한다.
public interface UserDetailsService {
UserDetails loadUserByUsername(String var1) throws UsernameNotFoundException;
}
출처: https://mangkyu.tistory.com/76 [MangKyu's Diary:티스토리]
UserDetailsService는 인터페이스이고, loadUserByUsername이라고 Username(id)를 통해서 UserDtatils라는 User객체를 반환하는 하나의 메서드만 있다.
import java.util.Collections;
@RequiredArgsConstructor
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetailsVO loadUserByUsername(String userEmail) {
return userRepository.findByUserEmail(userEmail).map(u -> new UserDetailsVO(u, Collections.singleton(new SimpleGrantedAuthority(u.getRole().getValue())))).orElseThrow(() -> new UserNotFoundException(userEmail));
}
}
출처: https://mangkyu.tistory.com/77 [MangKyu's Diary:티스토리]
우리는 userRepository를 주입받아서 userRepository에서 userEmail을 통해서 User객체를 찾은다음에 UserDetailsV0를 만들어서 반환한다.
어? 근데 갑자기 UserDetailsV0는 왜나온걸까?
UserDetailsService의 loadUserByUsername메서드의 반환형이 UserDetail이다.
public interface UserDetails extends Serializable {
Collection<? extends GrantedAuthority> getAuthorities();
String getPassword();
String getUsername();
boolean isAccountNonExpired();
boolean isAccountNonLocked();
boolean isCredentialsNonExpired();
boolean isEnabled();
}
출처: https://mangkyu.tistory.com/76 [MangKyu's Diary:티스토리]
UserDetails는 인터페이스로 User에대한 정보를 반환하는 메서드로 이루어져있다.
그렇다면, UserDetails를 구현한 구현체가 UserDetailsV0인 것이다.
@RequiredArgsConstructor
@Getter
public class UserDetailsVO implements UserDetails {
@Delegate
private final UserVO userVO;
private final Collection<? extends GrantedAuthority> authorities;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getPassword() {
return userVO.getUserPw();
}
@Override
public String getUsername() {
return userVO.getUserEmail();
}
@Override
public boolean isAccountNonExpired() {
return userVO.getIsEnable();
}
@Override
public boolean isAccountNonLocked() {
return userVO.getIsEnable();
}
@Override
public boolean isCredentialsNonExpired() {
return userVO.getIsEnable();
}
@Override
public boolean isEnabled() {
return userVO.getIsEnable();
}
}
출처: https://mangkyu.tistory.com/77 [MangKyu's Diary:티스토리]
해당 구현체를 통해서 User에대한 정보를 반환할 수 있다.
여기서도, 이제 다형성의 힘을 느낄수 있다. UserDetails에 보면 String getPassword(); String getUsername();등으로 이름과 Password등을 반환하는 메서드는 반드시 작성하게 되어있다.
그러나, 꼭 Password만 가지고 구분하는게 아니라, 예를들어, 개개인에게 OTP코드마냥 9821을 발급하고, 이 코드와 Password 두개가 동일해야 로그인이 가능하게 커스텀하고 싶을 수있다.
@RequiredArgsConstructor
@Getter
public class UserDetailsVO implements UserDetails {
@Delegate
//userV0의 필드에 String OTP가 있음
private final UserVO userVO;
private final Collection<? extends GrantedAuthority> authorities;
public String getOTP(){
return userV0.getOTP();
}
이런식으로 커스텀 할수 있는 확장성이 있다.
그러면 여기까지 정리해보면 우리는 인증이 아직 되지 않은, UsernamePasswordAuthenticationToken으로 부터, ID를 가져왔고, 해당 ID로 부터 DB에서 일치하는 User객체를 가져와서 UserV0를 가져왔다.
그럼 앞으로 해야하는 것은 간단하다. DB에서 가져온 UserV0와 토큰에서의 비밀번호를 비교하면 된다.
CustomAuthenticationProvider 전체 구현
@RequiredArgsConstructor
@Log4j2
public class CustomAuthenticationProvider implements AuthenticationProvider {
private final UserDetailsService userDetailsService;
private final BCryptPasswordEncoder passwordEncoder;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
UsernamePasswordAuthenticationToken token = (UsernamePasswordAuthenticationToken) authentication;
// AuthenticaionFilter에서 생성된 토큰으로부터 아이디와 비밀번호를 조회함
String userEmail = token.getName();
String userPw = (String) token.getCredentials();
// UserDetailsService를 통해 DB에서 아이디로 사용자 조회
UserDetailsVO userDetailsVO = (UserDetailsVO) userDetailsService.loadUserByUsername(userEmail);
if (!passwordEncoder.matches(userPw, userDetailsVO.getPassword())) {
throw new BadCredentialsException(userDetailsVO.getUsername() + "Invalid password");
}
return new UsernamePasswordAuthenticationToken(userDetailsVO, userPw, userDetailsVO.getAuthorities());
}
@Override
public boolean supports(Class<?> authentication) {
return authentication.equals(UsernamePasswordAuthenticationToken.class);
}
}
이제 아까는 String userEmail = token.getName()만 적었고, 나머지 로직을 완성하였다. UserDetailsService로부터 조회한 정보와 입력받은 비밀번호가 동일한지 확인 하였다. AuthenticationProvider도 인터페이스고, UserDetailsService로 부터 가져온 UserDetail도 인터페이스기때문에, 커스텀한 구현체를 적절하게 사용할 수 있다.
여기서는 비밀번호만 확인했지만, 아래와같이 OTP도 같이 확인 할 수 있다.
String Otp = token.getOtp();
if (!passwordEncoder.matches(userPw, userDetailsVO.getPassword()) && userDetailsV0.getOtp() != Otp) {
throw new BadCredentialsException(userDetailsVO.getUsername() + "Invalid password");
}
만약 모든 로직을 통과한다면, 인증이 된 UsernamePasswordAuthenticationToken을 발급하기위해서 파라미터가 3개인 생성자를 호출하여서 만들고 반환한다.
AuthenticationProvider를 통해 인증이 완료된 UsernamePasswordAuthenticationToken을 AuthenticationFilter로 반환하고, 해당 Authentication객체(토큰)을 SecurityContextHolder에 저장하면 인증과정이 끝나게 된다.
이렇게 인증과정이 끝난거고, 인가는 따로 적절히 로직을 만들어주면된다.
Controller로 요청이 들어오면 해당 토큰에서의 Role을 가져와서 admin이면 실행, 아니면 거절 이런식으로 로직을 완성시키면 된다.