인증(Authentication)
- 인증이란 식별 가능한 정보 (이름 , 이메일)를 이용하여 서비스에 등록 유저의 신원 입증하는 과정이다.
- 즉 나의 서비스에 등록된 사용자에게만 서비스를 제공한다는 뜻으로 간단히 이해하자.
인가(Authorization)
- 인증만 가지고는 서비스를 운영하기에는 무리가 있다.
- 인증을 한 사용자에게 모든 서비스를 제공하게 된다면?
- 내가 작성한 글이 다른 사람에 의해서 수정되거나 삭제될 수 있다.
- 따라서 인증된 사용자가 접근하려는 자원에 대한 권한이 있는지 확인하는 절차가 필요할 것이다.
- 또한 인가는 항상 앞에 인증이라는 선행 프로세스가 필요하다.(인증하지 않은 유저의 권한을 알 수 없기 때문에)
그렇다면 우리는 인증(로그인)을 하기 위해 어떠한 방식들을 사용할까?
세션 - 쿠키 방식
- 이번 글에는 해당 방식만 알아볼 것이다.토큰 (jwt 토큰) 방식
- 바로가기다른 채널을 통해 인증 (OAuth)
세션 - 쿠키 방식
해당 방식의 핵심은 사용자의 정보를 세션에 저장하여 서버에서 관리한다는 것이다.
인증 흐름은 다음 그림과 같다.(출처)
- 클라이언트가 서버로 로그인 요청을 보낸다.
- 서버는 클라이언트가 보낸 (id, pw)를 확인한다.
- (4 포함) 요청 정보가 유효하면 세션 ID를 생성한다.
- 클라이언트는 응답으로 받은 세션을 쿠키에 저장한다.
- 클라이언트가 인증이 필요한 요청을 할 때마다 헤더에 쿠키 실어서 보낸다.
- 서버는 쿠키를 확인하여 사용자를 식별합니다.
- 사용자에게 맞는 데이터를 넘겨줍니다.
spring security 에서는 기본적으로 세션 - 쿠키 방식을 사용하고 있다.
시큐리티 작동방식은 다음과 같다.
Spring security를 설정하게 된다면 DispatcherServlet에 도달하기 전에 서블릿 Filter 구현체에게 걸릴 것이다.
여기서 Spring security는 앞서 말한 FiterChain들을 Servlet Container 기반의 필터 위에서
동작하기 위해 중간 연결을 위한 DelegatingFilterProxy를 사용한다.
따라서 DelegatingFilterProxy는 IOC 컨테이너에서 관리하는 빈이 아닌
표준 서블릿 필터를 구현하고 있으며 내부적으로는 요청을 위임할 (FilterChainProxy)을 가지고 있다.
그림으로는 위와 같다.
그럼 디버그를 찍어보면서 알아보자.
DelegatingFilterProxy
자 다음과 같이 DelegatingFilterProxy 은 Servlet Container 기반의 필터 위에서 동작하기 위해서 중간 역할만 하고
FilterChainProxy에게 요청 처리를 위임하고 있다.
그렇다면 FilterChainProxy는 무엇을 할까?
FilterChainProxy 역시 처리를 위임하기 위한 SecurityFilterChain을 들고 있다.
여기서 SecurityFilterChain 하나만 존재하지 않고 여러 개 존재할 수 있다.
그래서 코드를 보면 List 형태인걸 볼 수 있다.
즉 설정에 따라 필터를 추가하거나 삭제할 수 있는 것이다.
해당 설정은 WebSecurityConfigurerAdapter을 이용하여 쉽게 설정할 수 있다.
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
// TODO
}
}
그렇다면 이제 Spring Security Filter을 알아보자!
제가 소개하는 순서대로 Filter 가 동작하며 하지만 일반적으로 스프링 시큐리티 필터의 순서를 무조건 알 필요 없다고 합니다!
하지만 알고 있으면 유익할 때가 있다고 하네요?? 모든 필터 종류와 동작 순서가 궁금하신 분은 레퍼런스를 참조해주세요
우리가 알아볼 필터는 다음과 같습니다.
- SecurityContextPersistenceFilter
- UsernamePasswordAuthenticationFilter
SecurityContextPersistenceFilter
해당 필터는 말 그대로 SecurityContext을 영속화하는 필터입니다.
SecurityContextRepository 인터페이스를 이용하여 영속화를 진행합니다.
기본 설정으로는 HttpSessionSecurityContextRepository 구현체를 이용합니다.
( HttpSession의 Attribute에 SecurityContext 가 저장됩니다.)
코드로 보면 이해가 됩니다.
public class SecurityContextPersistenceFilter extends GenericFilterBean {
static final String FILTER_APPLIED = "__spring_security_scpf_applied";
private SecurityContextRepository repo; // 해당 친구를 이용하여 영속화를 진행합니다.
//나머지 생략
}
실제로 기본 설정일 때 HttpSessionSecurityContextRepository 구현체를 사용하는지 확인하기 위해서는
디버그 모드를 통해 확인하면 됩니다.
이처럼 디버그 모드로 HttpSessionSecurityContextRepository를 사용하는 것을 확인할 수 있습니다.
HttpSessionSecurityContextRepository에 대해서는 더 깊게 들어가지는 않겠습니다.
다시 돌아와서 SecurityContextPersistenceFilter는 영속화 만 하는 게 아니라
요청에 따라 repo에서(기본 설정에서는 Session 이 되겠죠?) SecurityContext를 꺼내서
SecurityContextHolder에 넣어서 요청 전반에 걸쳐 SecurityContext를 사용할 수 있게 해 준다.
여기서 의문은 요청 전반에 걸쳐 인증 객체를 꺼내올 수 있을까? 라는 의문이 든다.
해당 의문의 해답은 SecurityContextHolder 가 ThreadLocal을 이용하여 SecurityContext을 담기 때문이다.
쓰레드 풀 환경에 있어서 ThreadLocal 을 사용후 데이터 사용이 끝나면 삭제를 해주어야 한다고 합니다.
따라서 Spring Security 의 FilterChainProxy 는 SecurityContext 항상 정리한다고 합니다.
(ThreadLocal에 대해 모른다면 해당 글을 참고하면 될 것 같다.)
SecurityContextHolder.getContext().getAuthentication();
따라서 우리는 다음과 같이 요청 전반에서 SecurityContext를 가져와서 사용할 수 있다.
(SecurityContext는 Authentication을 SecurityContextHolder에 담기 위한 래퍼 클래스입니다.)
UsernamePasswordAuthenticationFilter
해당 필터는 뜻 그대로 아이디, 패스워드 기반으로 인증을 담당하는 필터입니다.
해당 필터가 중요한 이유는 AbstractAuthenticationProcessingFilter 기점으로 확장 및 변경 포인트를 이해해야 합니다.
먼저 UsernamePasswordAuthenticationFilter부터 천천히 따라가 봅시다.
여기서 중요한 부분은 UsernamePasswordAuthenticationToken을 만들어서 AuthenticationManager 연결시키고 있습니다.
AuthenticationManager는 인터페이스이며 실제 구현체는 ProviderManager 입니다.
그렇다면 ProviderManager.authenticate()에서는
어떠한 일을 할까요?
빨간 박스를 자세히 보시면 for 문을 통해 등록된 Providres를 순회하면서
지금 넘어온 Authentication(UsernamePasswordAuthenticationToken)을 처리할 수 있는
AuthenticationProvider를 찾고 있는 걸 볼 수 있습니다.
우리는 별다른 설정을 하지 않았으므로 UsernamePasswordAuthenticationToken을 처리할 수 있는
AuthenticationProvider의 실제 구현체는 DaoAuthenticationProvider입니다.
자 여기까지 보았을 때 해당 구조는 인증방법을 변경하기 매우 좋은 구조입니다.
왜냐하면 커스텀 Authentication을 처리할 수 있는 AuthenticationProvider 만 등록한다면
인증방식을 변경할 수 있는 구조이기 때문입니다.
따라서 나만의 AuthenticationProvider를 등록하기 위해선 당연히 해당 인터페이스의 규약에 맞게 구현을 해야겠죠?
다시 ProviderManager를 살펴보죠!
앞서 Authentication을 처리할 수 있는 AuthenticationProvider 찾아서 인증을 위임하여 진행하고 있습니다.
그렇다면 DaoAuthenticationProvider.authenticate()을 확인해보죠
중요 로직은 네모 박스로 친 두 곳인 거 같습니다.
하나씩 살펴보죠 retrieveUser()는
다음과 같은데 UserDetailsService 가져와서 loadUserByUsername()을 호출하고 있군요
UserDetailsService는
스프링 시큐리티를 사용해보신 분이라면 한 번쯤 보았을 것 같습니다.
해당 서비스는 로그인 폼으로 넘어온 ID 값을 우리 DB와 매칭 하여 UserDetails라는 객체로 받기 위해 존재하는데요
그럼 이런 생각이 들 수 있을 것 같습니다. 왜? 내가 정의한 Entity로 받게 하지 못하는가?
해당 이유는 서비스마다 회원이 가지고 있는 프로퍼티들과 값이 다르기 때문에
시큐리티 입장에서는 통일성 있는 객체를 받기 위해라고 생각하면 될 것 같습니다.
따라서 괴리감을 메우기 위해서 UserDetailsService을 구현해야 하겠죠?
@Service
@RequiredArgsConstructor
public class MemberService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Member member = memberRepository.findByEmail(username);
if(member == null){
throw new UsernameNotFoundException(username);
}
return new UserAccount(member);
}
}
이렇게 UserDetailsService를 구현하게 된다면 retrieveUser() 내부에서 호출하는
loadUserByUsername() 메서드는 저희가 구현한 MemberService.loadUserByUsername()가 호출됩니다.
이제 비교할 객체(UserDetails)를 받았으니 UsernamePasswordAuthenticationToken과 비교를 해야 할 것 같습니다.
additionalAuthenticationChecks() 메서드를 살펴보죠
빨간 박스를 보시면 각 객체의 패스워드를 비교하고 있는데
유심히 보셔야 할 부분은 바로 passwordEncoder를 사용하고 있습니다.
spring security에서는 PasswordEncoder를 사용을 강제하는데요.
따라서 DB에 저장될 때 암호화된 문자열이 들어가 있어야 합니다(중요!)
다시 돌아와서
서비스가 어떠한 암호화 방식을 사용할 건지 빈으로 등록해줘야 합니다.
@Bean
public PasswordEncoder passwordEncoderParser(){
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
별다른 설정 없이 만들게 되면 bcrypt 암호화 방식을 사용합니다.
이제 비교를 통해 인증 실패와 인증 성공이 나뉩니다.
여기까지 중요 필터 두 가지를 살펴보았는데요
레퍼런스를 참고하여 글을 작성했습니다.
해당 본문에 오류가 존재한다면 댓글에 남겨주시면 반영하도록 하겠습니다.
감사합니다.
'Spring > security' 카테고리의 다른 글
SpringSecurity Test 코드 작성하기 (2) | 2021.05.26 |
---|---|
JWT 토큰 (0) | 2021.05.06 |