Monkey Penthouse라는 창업을 앞두고 있는 프로젝트의 백엔드를 맡게 되었고,
이 프로젝트의 메인 서비스가 될 어메니티/결제 기능 구현에 앞서
인증/인가 기능부터 구현하게 되었다.
Monkey Penthouse의 인증/인가 기능
- 회원가입
- 로컬 로그인
- 소셜 로그인 (카카오, 네이버, 애플)
- 이메일 찾기
- 비밀번호 수정
이 글에서는 이 프로젝트에서 인증/인가 기능을 어떻게 구현하였는 지에 대한 로직을 정리해놓으려고 한다.
JWT(JSON Web Token)
회원이 많아질수록 서버에 부하가 걸릴 수 있는 세션보다는 JWT을 사용하였다.
JWT는 인증에 필요한 정보를 암호화한 토큰을 의미한다. JWT는 header와 payload, signature로 구성되어있고,
페이로드에 인증에 필요한 정보와 유효기간을 담을 수 있으며, 이렇게 key-value 형식으로 이뤄진 정보들을 claim이라고 한다.
특정 해싱 알고리즘을 선택하여 JWT를 암호화할 수 있으며, 이 때 개발자가 임의로 정한 비밀키를 사용한다.
header와 payload는 단순히 인코딩된 값이지만, signature는 비밀키가 없으면 복호화가 불가능하다.
따라서 signature는 토큰의 위변조 여부를 확인하는 데 사용된다.
JWT 장점
가장 큰 장점으로는 서버에 인증 정보에 대한 세션과 같은 저장소가 필요 없어서 부하가 훨씬 덜 걸린다.
서버가 상태성을 갖지 않고도 회원을 인증/인가하고 필요한 서비스를 제공해줄 수가 있다.
OAuth와 같은 경우에는 토큰을 기반으로 다른 로그인 시스템에 접근 및 권한 공유도 가능하다.
JWT 단점
JWT 처리 자체의 비용이 있으므로 인증 요청이 많아질수록 네트워크 부하가 심해질 수 있다.
payload는 암호화가 되어있지 않으므로 유저의 critical한 정보를 담을 순 없다.
토큰을 탈취당하면 대처하기 어렵다는 큰 보안적 이슈가 있다.
보안 이슈를 해결하기 위한 Refresh Token
토큰 탈취의 가능성을 고려하여 토큰의 만료 시간을 짧게 설정해서 피해를 최소화할 수는 있다.
일반적인 만료 기간은 분에서 시간 단위인데, 이 때마다 사용자가 다시 로그인하지 않도록 하려면
추가적인 토큰 재발급 로직이 필요하다.
이 때 사용되는 것이 refresh token이며,
refresh token을 클라이언트에서 주면 서버에서 인증용 토큰(access token)을 재발급 해주는 방식으로
로그인 상태를 유지할 수가 있다.
클라이언트 / 서버 간의 JWT 로직
인증/인가 서비스를 위해 클라이언트와 서버가 다음과 같이 소통해야 한다.
1. 클라이언트에서 ID와 비밀번호를 넘기며 로그인 요청한다.
2. ID와 비밀번호의 유효성을 검증하고 access token과 refresh token을 발급한다. access token의 만료 기간은 15분, refresh token의 만료기간은 2주이다.
3. 클라이언트에서 서버로 인증/인가가 필요한 서비스를 요청시 access token을 보낸다.
4. 서버에서 access token을 통해 인증/인가를 마치고 서비스를 제공한다.
5. access token이 만료되면 클라이언트는 refresh token을 통해 access token 재발급 요청을 한다. access token이 만료되기 직전에 클라이언트에서 스스로 요청하거나, 만료되고 난 뒤 서버에서 401 에러 응답을 받게 되면 그 때 재발급 요청을 해도 된다.
6. 서버에서 refresh token의 유효성을 검증 후, access token과 refresh token을 재발급하여 클라이언트로 보내준다.
Spring Security
Spring Security는 Spring에서 사용자 인증/인가를 쉽게 할 수 있도록 제공하는 프레임워크이다.
기본적으로는 세션 기반 인증을 제공한다.
Spring Security에 대한 기본적인 설계 구조에 대한 설명은 https://spring.io/guides/topicals/spring-security-architecture 여기에서 확인할 수 있다.
Spring Security는 인증과 인가에 대해 서로 분리된 전략을 사용한다.
인증에 대해서는 AuthenticationManager가 가장 책임을 많이 지고 있다.
AuthenticationManager의 authenticate() 메서드는 input(ID/PW)이 유효한 principal인지 '인증'하는 역할을 수행한다.
가장 대표적인 AuthenticationManager의 구현체는 ProviderManager이다.
Provider Manager는 AuthenticationProvider 인스턴스들의 chain을 대표하고 있다. AuthenticationProvider는 주어진 Authentication은 지원하는 지 확인할 수 있는 메서드를 가진 AuthenticationManager라고 보면 된다.
즉, ProviderManager는 여러 개의 AuthenticationProvider를 관리함으로써 다양한 종류의 인증 로직을 chain 형식을 관리할 수 있다는 것이다.
ProviderManager는 자기들끼리 상속 관계를 이루며, 자식 ProviderManager가 특정 input에 대하여 어떤 AuthenticationProvider에서도 인증을 못한다면 부모 Provider Manager로 넘어간다. 만약 부모 객체도 이를 처리하지 못한다면 AuthenticationException이라는 RuntimeException이 발생한다.
이렇게 계층 구조를 이루게 되는 이유는 자식 객체들은 특정 리소스 그룹에 대해 고유한 인증 주체로 분리하고 그 자식 객체들이 공통적인 부모를 공유할 수 있도록 하기 위함이다.
HTTP로 소통하는 Spring 웹 서버에서는 클라이언트가 어플리케이션에 요청을 보내면
컨테이너는 요청 URI에 따라 어떤 필터와 어떤 서블릿으로 이를 처리할지 결정한다.
대부분의 경우 하나의 서블릿이 하나의 요청을 처리하지만
filter는 정해진 순서에 따라 chain의 형태로 연결되어있기 때문에 여러개의 filter들이 순서대로 요청을 처리하게 된다.
Spring Security는 이 체인에서 하나의 filter로 존재하며, 이 구현체의 타입은 FilterChainProxy이다.
Spring boot에서는 security filter가 하나의 bean으로 존재하며 모든 요청을 처리하기 위해 기본적으로 설치된다.
Spring Security는 하나의 filter이긴 하지만 그 안에는 추가적인 filter들이 존재하며 각자의 고유한 역할들이 정해져있다.
따라서 이 FilterChainProxy 객체의 고유한 이름은 SpringSecurityFilterChain이다.
사실 이 Spring SecurityFilterChain은 하나의 chain만 뿐만 아니라,
요청의 path에 따라 달리 매칭될 수 있는 여러 개의 chain으로 존재할 수 있다.
다만, 반드시 하나의 요청이 이들 중 하나의 chain에 매칭된다면
오직 이 하나의 chain만이 그 요청을 처리해야 하며 다른 chain으로 요청이 넘어갈 수는 없다.
이렇게 여러 개의 filter chain을 두면 리소스의 그룹에 따라서 다른 방식의 인증/인가를 처리할 수가 있게 된다.
(예시 - '/match1/**'에 대해서는 cookie-based, '/match2/**'에 대해서는 token-based)
우리는 이 chain을 커스텀하거나 아예 새로 만드는 방식으로 Spring Security를 사용할 수가 있다.
Spring Boot에서 기본적으로 사용하는 default fallback filter chain('/**' path와 매칭될 filter chain)을
그대로 fallback으로 사용하면서 새로운 filter chain을 만드려면
WebSecurityConfigurerAdapter(혹은 WebSecurityConfigurer) 타입의 bean을 추가하면 된다.
filter의 순서를 지정하고 싶다면 @Order를 사용할 수도 있다.
@Configuration
@Order(SecurityProperties.BASIC_AUTH_ORDER - 10)
public class ApplicationConfigurerAdapter extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.antMatcher("/match1/**")
...;
}
}
위와 같이 작성하면 fallback chain 앞에 하나의 새로운 filter chain이 더 생기는 것이다.
실제로 해당 chain이 어떤 필터를 거치는 지 모니터링하고 싶다면
아래와 같이 @EnableWebSecurity(debug = true)를 설정해주면 된다.
@Configuration
@EnableWebSecurity(debug = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
Spring Security의 기초적인 구조를 배웠으니 다음에 이어서
https://bcp0109.tistory.com/301
이 블로그 글을 참고하여 JWT 로그인 기능을 구현한 과정을 설명하겠다.
'Projects > Monkey Penthouse' 카테고리의 다른 글
에러 표준화 과정 2 - Custom Exception, Exception Handler (0) | 2022.01.16 |
---|---|
에러 표준화 과정 1 - Spring Validator (0) | 2022.01.16 |
Spring Security를 이용한 JWT 로그인 2 - 구현 과정 (0) | 2022.01.04 |