Spring Security는 Spring 기반의 애플리케이션의 보안(인증과 권한, 인가 등)을 담당하는 스프링 하위 프레임워크이다.
Spring Security는 "인증"과 "권한"에 대한 부분을 Filter 흐름에 따라 처리하고 있다.
Filter는 요청이 Dispatcher Servlet으로 가기 전에 적용되므로 가장 먼저 요청을 받는다.
Spring Security는 보안과 관련해서 체계적으로 많은 옵션을 제공해주기 때문에 개발자 입장에서는 일일이 보안관련 로직을 작성하지 않아도 된다는 장점이 있다.
🤔 Servlet Filter에서 Spring Security의 Filter Chain으로 어떻게 넘어가는가?
그 역할을 하는 것이 바로 DelegationFilterProxy와 FilterChainProxy이다.
DelegationFilterProxy는 IoC Container에서 관리하는 빈이 아닌 표준 Servlet Filter를 구현하고 있으며, Servlet Container와 IoC Container를 연결하는 역할을 한다.
1) DelegatingFilterProxy가 Servlet filter chain을 통해 온 요청(Request)를 받는다.
2) Application context에서 SpringSecurityFilterChain 이름으로 생성된 Bean을 찾는다. (이 Bean이 바로 FilterChainProxy이다.)
3) Bean을 찾으면 SpringSecurityFilterChain으로 요청을 위임한다.
4) 각각의 Filter들에게 순서대로 요청을 chain 형식으로 넘기며 처리한다.
🤔 Security Filter에 직접 커스텀 한 Filter를 어떻게 등록하는가?
SecurityConfig클래스를 만들고, filterChain() 메서드를 통해 Filter Chain에 대한 전반적인 관리가 가능하다.
매개변수의 HttpSecurity가 해당 메서드에서 정의한 설정을 기반으로 Filter Chain을 생성하며, Application Context 초기화 시 진행된다.
인증(Authentication) - 해당 사용자가 본인이 맞는지를 확인하는 절차
인가(Authorization) - 인증된 사용자가 요청한 자원에 접근 가능한지를 결정하는 절차. 즉, 권한이 있는가
Spring Security는 기본적으로 인증 절차를 거친 후에 인가 절차를 진행하게 되며, 인가 과정에서 해당 리소스에 대한 접근 권한이 있는지 확인을 한다. Spring Security에서는 이러한 인증과 인가를 위해 Principal(아이디), Credential(비밀번호)를 사용한다.
-> 로그인 요청뿐만 아니라 모든 보호된 리소스에 대한 접근 요청도 인증 절차를 먼저 거친 후 인가 절차를 진행한다.
인증이 되지 않은 사용자는 보호된 리소스에 접근할 수 없으며, 일반적으로 로그인 페이지로 리다이렉트 된다.
만약 인증은 되었지만 인가 과정에서 권한이 없는 리소스에 접근하는 경우에는 401 Unauthorized 오류를 반환받게 된다.
사용자가 로그인 정보와 함께 인증 요청을 보낸다.
AuthenticationFilter가 요청을 가로채고, 가로챈 정보를 통해 UsernamePasswordAuthenticationToken의 인증용 객체를 생성한다.
AuthenticationManager의 구현체인 ProviderManager에게 생성한 UsernamePasswordToken 객체를 전달한다.
AuthenticationManager는 등록된 AuthenticationProvider를 조회하여 인증을 요구한다.
DB에서 사용자 인증정보를 가져와서 UserDetailsService에 사용자 정보를 넘겨준다.
넘겨받은 사용자 정보를 통해 DB에서 찾은 사용자 정보인 UserDetails 객체를 만든다.
AuthenticationProvider는 UserDetails를 넘겨받고 사용자 정보를 비교한다.
인증이 완료되면 권한 등의 사용자 정보를 담은 Authentication 객체를 반환한다.
다시 최초의 AuthenticationFitler에 Authentication 객체가 반환된다.
Authentication 객체를 SecurityContext에 저장한다.
최종적으로 SecurityContextHolder는 세션 영역에 있는 SecurityContext에 Authentication 객체를 저장한다.
사용자 정보를 저장한다는 것은 Spring Security가 전통적인 세션/쿠키 기반의 인증 방식을 사용한다는 것을 의미한다.
Authentication 객체는 SecurityContext에 저장되며, SecurityContextHolder를 통해 SecurityContext에 접근하고, SecurityContext를 통해 Authentication에 접근 할 수 있다.
이렇게 직접 접근하는 방법과 Controller 메서드의 파라미터 부분에 @AuthenticationPrincipal 애노테이션을 이용해서 로그인 세션 정보를 받아오는 방법도 있다.
UsernamePasswordAuthenticationToken은 Authentication을 implements한 AbstractAuthenticationToken의 하위 클래스로, User의 ID가 Principal 역할을 하고, Password가 Credential의 역할을 한다.
UsernamePasswordAuthenticationToken의 첫 번째 생성자는 인증 전의 객체를 생성하고, 두번째는 인증이 완료된 객체를 생성한다.
// 인증 완료 전의 객체 생성
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);
}
인증에 대한 부분은 AuthenticationManager를 통해서 처리하게 되는데, 실질적으로는 AuthenticationManager에 등록된 AuthenticationProvider에 의해 처리된다.
인증에 성공하면 객체를 생성하여 SecurityContext에 저장된다.
AuthenticationManager를 implements한 ProviderManager는 AuthenticationProvider를 구성하는 목록을 갖는다.
AuthenticationProvider에서는 실제 인증에 대한 부분을 처리하는데, 인증 전의 Authentication 객체를 AuthenticationManager에게 받아서 UserDetailsService에 넘기고 UserDetailsService에서 반환받은 UserDetails 객체와 비교하여 사용자의 정보가 모두 일치한다면 인증이 완료된 Authentication 객체를 반환하는 역할을 한다.
UserDetailsService는 UserDetails 객체를 반환하는 하나의 메서드만을 가지고 있는데, 일반적으로 이를 implements한 클래스에 UserRepository를 주입받아 DB와 연결하여 처리한다.
인증에 성공하여 생성된 UserDetails 객체는 Authentication객체를 구현한 UsernamePasswordAuthenticationToken을 생성하기 위해 사용한다. UserDetails를 implements하여 처리할 수 있다.
SecurityContextHolder는 보안 주체의 세부 정보를 포함하여 응용프로그램의 현재 보안 컨텍스트에 대한 세부 정보가 저장된다.
Authentication을 보관하는 역할을 하며, SecurityContext를 통해 Authentication을 저장하거나 꺼내올 수 있다.
SecurityContextHolder.getContext().setAuthentication(authentication);
SecurityContextHolder.getContext().getAuthentication(authentication);
GrantedAuthority는 현재 사용자가 가지고 있는 권한을 의미하며, ROLEADMIN이나 ROLE_USER와 같이 ROLE~ 형태로 사용된다. GrantedAuthority 객체는 UserDetailsService를 통해 불러올 수 있고, 특정 자원에 대한 권한이 있는지를 검사하여 접근 허용 여부를 걸정한다.
Spring Security에서 제공하는 보안 필터 중 하나로, 나는 LoginFilter라는 커스텀 필터를 생성해 UsernamePasswordAuthenticationFilter를 상속받아 구현하였다.
폼 기반의 로그인을 처리하며,HTTP POST요청을 통해 전송된 사용자의 아이디와 비밀번호를 기반으로 인증을 수행한다.
POST /login 요청으로 username과 password를 보내면, Spring Security에서 기본적으로 제공하는 UsernamePasswordAuthenticationFilter가 이 요청을 처리한다.
내부에서는 attemptAuthentication 메서드에서 요청으로 받은 username과 password를 확인 하고, UsernamePasswordAuthenticationToken을 생성 후, AuthenticationManager를 통해 인증 시도한다.
로그인 성공 시, successfulAuthentication 메서드에서 따로 JWT를 생성해서 응답으로 보내주었다.
GenericFilterBean은 서블릿 필터를 구현하기 위한 편의 클래스로, 로그아웃 커스텀 필터를 구현하기 위해 상속하였다.
GenericFilterBean은 로그아웃 필터와 같이 요청 전/후에 처리 로직을 삽입하고 싶을 때 유용하게 사용된다.
doFilter 내부에서 POST /logout으로 요청이 들어왔을 때, 쿠키에 있는 RefreshToken을 만료시키고 Redis에 있는 RefreshToken을 삭제하여 응답을 보내주었다. 이때 쿠키에 있는 RefreshToken의 값을 null로 바꾸어 보내주었다.
GenericFilterBean은 요청이 들어올 때마다 실행되며, 필터 체인에 연결된 모든 필터가 실행된다.
요청 처리 과정에서 특정 작업을 모든 요청에 대해 수행해야 할 때 적합하다.
단점은, 필터를 거쳐 들어온 요청을 다른 API에 리다이렉트 시켰을 경우, 이 요청은 필터 체인을 한 번 더 거치게 된다. 클라이언트는 한 번의 요청을 한 것 뿐이지만 흐름상 두 번의 요청을 보낸 것과 같다.
이러한 문제를 해결하기 위해 나온 것이 OncePerRequestFilter이다.
OncePerRequestFilter는 리다이렉트나 포워딩으로 인해 필터가 중복 실행되지 않도록 필터 체인에 플래그를 설정하여 이를 방지한다. 나는 JWTFilter를 구현할 때 상속 받아 구현하였다. 이렇게 하면, 요청당 JWT 검증 로직이 한 번만 실행되도록 보장할 수 있다.
JWT를 알아보기 전에 Cookie/Session/Token 인증 방식에 대해 알아보자.
쿠키는 브라우저가 종료되도, 만료시점이 지나지 않으면 삭제되지 않는다.
용량 제한이 있으며, 하나의 도메인 당 20개 하나의 쿠키 당 4KB이다.
세션은 브라우저 종료시 삭제된다. (기간 지정 가능)
세션은 서버가 허용하는 한 용량제한이 없다.
세션 기반 인증은 클라이언트로부터 요청을 받으면 클라이언트의 상태 정보를 저장하므로 Stateful한 구조를 가지고, 토큰 기반 인증은 상태 정보를 서버에 저장하지 않으므로 Stateless한 구조를 가진다.
1) 서버에 세션을 저장하기 때문에 사용자가 증가하면 서버에 과부하를 줄 수 있어 확장성이 낮다.
2) 해커가 훔친 쿠키를 이용해 요청을 보내면 서버는 올바른 사용자가 보낸 요청인지 알 수 없다.
쿠키는 Key-Valu 형식의 문자열 덩어리이다.
클라이언트가 어떠한 웹사이트를 방문할 경우, 그 사이트가 사용하고 있는 서버를 통해 클라이언트의 브라우저에 설치되는 작은 기록 정보 파일이다.
각 사용자마다 브라우저에 정보를 저장하니 고유 정보 식별이 가능하다.
1) 브라우저가 서버에 요청을 보낸다.
2) 서버는 클라이언트의 요청에 대한 응답을 작성할 때, 클라이언트 측에 저장하고 싶은 정보를 응답 헤더의 Set-Cooki에 담는다.
3) 이후 클라이언트는 요청을 보낼 때마다 매번 저장된 쿠키를 요청 헤더의 Cookie에 담아 보낸다.
4) 서버는 쿠키에 담긴 정보를 바탕으로 해당 요청의 클라이언트가 누군지 식별한다.
가장 큰 단점은 보안에 취약하다.
요청시 쿠키의 값을 그대로 보내기 때문에 유출 및 조작 당할 위험이 존재한다.
또한, 쿠키에는 용량 제한이 있어 많은 정보를 담을 수 없다.
이러한 쿠키의 보안적인 이슈 때문에, 세션은 비밀번호 등 클라이언트의 민감한 인증 정보를 브라우저가 아닌 서버 측에 저장하고 관리한다. 서버의 메모리에 저장하기도 하고, 서버의 로컬 파일이나 DB에 저장하기도 한다.
핵심은 사용자의 정보를 브라우저가 아닌 서버에서 모두 관리한다는 것이다.
1) 유저가 웹사이트에서 로그인하면 세션이 서버 메모리 혹은 DB에 사용자 정보를 저장한다.
이떄, 세션을 식별하기 위한 Session ID를 기준으로 정보를 저장한다.(Session ID를 Key로 가지는 Value형태로 저장)
2) 서버는 클라이언트의 요청에 대한 응답을 작성할 때, 클라이언트 측에 Session ID를 응답 헤더에 담는다.
3) 쿠키에 정보가 담겨있기 때문에 브라우저는 해당 사이트에 대한 모든 요청에 Session ID를 쿠키에 담아 전송한다.
4) 서버는 클라이언트가 보낸 Session ID와 서버에서 관리하는 Session ID를 비교하여 인증을 수행한다.
브라우저 상에 사용자의 정보가 아닌 Session ID만을 저장하지만 해커가 세션 ID 자체를 탈취한다면 클라이언트로 위장할 수 있다는 한계가 있다.
이는 서버에서 IP특정을 통해 해결 할 수는 있다.
토큰 기반 인증 시스템은 클라이언트가 서버에 접속을 하면 서버에서 해당 클라이언트에게 인증되었다는 의미로 "토큰"을 부여한다.
서버에 요청을 보낼 때 요청 헤더에 토큰을 담아 보낸다. 그러면 서버에서는 클라이언트로부터 받은 토큰을 서버에서 제공한 토큰과의 일치 여부를 체크하여 인증 과정을 처리하게 된다.
세션 인증 방식은 서버가 파일이나 DB에 세션 정보를 가지고 있어야 하고 이를 조회하는 과정이 필요하기 때문에 많은 오버헤드가 발생한다.
하지만 토큰은 세견과 달리 서버가 아닌 클라이언트에 저장되기 떄문에 메모리나 스토리지 등을 통해 세션을 관리했던 서버의 부담을 덜 수 있다.
토큰 자체에 데이터가 들어있기 떄문에 클라이언트에서 받아 위조되었는지를 판별만 하면 되기 때문이다.
1) 사용자가 로그인을 시도한다.
2) 로그인에 성공시 서버에서 클라이언트에게 토큰을 발급한다.
3) 클라이언트는 서버 측에서 전달받은 토큰을 쿠키나 스토리지에 저장해두고, 서버에 요청을 할 때마다 해당 토큰을 HTTP요청 헤더에 포함시켜 전달한다.
4) 서버는 전달받은 토큰을 검증하고 요청을 응답한다. 이때 토큰에 사용자의 정보가 담겨있기 때문에 서버에서 따로 조회하는 과정이 필요없다.
쿠키/세션과 다르게 토큰 자체의 데이터 길이가 길어 인증 요청이 많아질수록 네트워크 부하가 심해질 수 있다.
토큰을 탈취당하면 대처하기 어렵다. 따라서 사용 시간을 제한해서 설정하는 식으로 극복한다.
JWT란 인증에 필요한 정보들을 암호화시킨 JSON 토큰을 의미한다.
JWT는 보통 AccessToken과 RefreshToken가 있다.
서버로부터 발급 받은 이후, 클라이언트에서 LocalStorage나 Cookie등에 보관한다.
JWT는 JSON 데이터를 인코딩하여 직렬화한 것이며, 토큰 내부에는 위변조 방지를 위해 개인키를 통한 전자서명이 들어있다. 따라서 사용자가 JWT를 서버로 전송하면 서버는 서명을 검증하는 과정을 거치게 되며 검증이 완료되면 요청한 응답을 돌려준다.
AccessToken은 로컬 스토리지나 세션 스토리지에 저장하고, RefreshToken은 쿠키에 저장하고 보안 옵션을 최대로 걸어 접근을 막아서 탈취됐을 경우를 대비한다.
Redis에 AccessToken과 RefreshToken이 매핑되어 저장되어 있기 때문에 이전에 발급했던 AccessToken과 RefreshToken이 아닌 경우에는 AccessToken 재발급이 되지 않도록 추가 로직을 구현해두어야 된다.
JWT는 "."을 구분자로 나누어지는 세 가지 문자열의 조합이다.
구분자를 기준으로 Header, Payload, Signature를 의미한다.
JWT에서 사용할 타입과 해시 알고리즘의 종류가 담겨있다.
내용이라고도 하며 토큰에 담을 정보들이 존재하고, 보통은 유저를 구분하고자 하는 유저의 정보를 담는다.
여기서 담는 정보의 한 조각을 Claim이라고 한다.
1) Registered Calim - 등록된 클레임들은 서비스에서 필요한 정보들이 아닌, 토큰에 대한 정보들을 담기 위해 이미 지정된 클레임이다. 사용하는 것은 모두 선택적이다.
issuer(토큰 발급자), subject(토큰 제목), audience(토큰 대상자), expiration(토큰의 만료시간), issued at(토큰이 발급된 시간)등이 있다.
2) Public Claim - 특정 커뮤니티나 사용자 그룹에서 공통으로 사용할 수 있도록 정의된 클레임이다. 충돌이 방지된 이름이 있어야하기 때문에 클레임 이름을 url형식으로 짓는다.
{"https://example.com/claims/role": "admin"}
3) Private Claim - 양측간의 합의 하에 사용되는 클레임이다. 합의하에 설정하는 것이기 때문에 중복충돌을 주의해야 된다.
{ "username" : "KK" "email" ; "asdasd@gmail.com" }
Header와 Payload는 암호화를 한 것이 아닌 단순히 JSON 문자열을 base64로 인코딩한 것이다. 누구나 이 값을 디코딩하여 JSON에 어떤 내용이 들어있는지 확인 가능하다.
토큰을 사용하는 경우 이 토큰을 다른 사람이 위변조할 수 없어야 하므로, 헤더와 페이로드의 위변조 여부를 검증하기 위한 부분이 Signature이다.
헤더와 페이로드를 base64로 인코딩해서 만든 두 값을 마침표(.)로 이어 붙이고 헤더에서 alg로 지정된 알고리즘 HMAC SHA-256으로 인코딩하면 JWT 토큰의 세 번째 부분인 Signature가 완성된다.
토큰을 탈취해서 만료 시간을 늘리거나 토큰의 정보를 수정해도 Server의 SecretKey를 알지 못하면 유효한 JWT를 생성할 수 없다. (요청마다 JWT를 검증하는데 이때도 SecretKey를 사용해 서명을 확인하고 내부 값을 검증함)
JWT 또한 해커에게 토큰 탈취의 위험성이 있기 때문에 그대로 사용하는 것이 아닌 AccessToken과 RefreshToken으로 이중으로 나누어 인증을 하는 방식을 사용한다.
AccessToken과 RefreshToken은 둘 다 JWT이지만, 토큰이 어디에 저장되고 관리되느냐에 따른 사용 차이가 있다.
로그인에 성공시 서버는 AccessToken을 클라이언트에게 전달해준다. 실제로 유저의 정보가 담겨있다.
유효시간은 RefreshToken에 비해 짧게 설정된다.
새로운 AccessToken을 발급해주기 위해 사용되는 토큰으로 짧은 유효시간을 가지는 AccessToken에게 새로운 토큰을 발급해주기 위해 사용된다. 해당 토큰은 보통 DB에 유저 정보와 같이 저장된다.
접근 속도가 RDBMS보다 상대적으로 빠른 Redis에 주로 저장한다.
HTTP는 Stateless 특성을 가진다. 그렇기 때문에 사용자를 특정할 수 있는 어떠한 수단이 필요하다.
이를 위해서 세션 OR 토큰을 사용해 서버와 클라이언트 사이에서 값을 확인하고 사용자를 특정할 수 있다.
가장 큰 차이점은 서버까지 값을 저장하는가 클라이언트에만 저장 하는가이다.
세션은 SessionID와 함께 값을 서버에 저장해두고 요청마다 확인하지만 토큰은 전달받은 토큰을 검증하기만 하면 된다.
1) HTTP 헤더에 넣어 쉽게 전달 가능
2) 확장성 용이
토큰을 해석하는 알고리즘만 서버에 두면 되기에 MSA와 같은 분산 시스템에 적합하다.
3) JWT토큰 사용시 서버는 클라이언트의 상태를 유지할 필요가 없기 때문에 stateless하게 할 수 있다.
4) 인증에 필요한 정보가 토큰에 있기 때문에 별도의 저장소가 필요 없다.
보안성을 높이기 위해 RefreshToken을 사용하는 경우 별도의 저장소에 저장하면서 사용하는 경우도 있다.
1) 토큰의 정보가 클수록 네트워크에 부하를 줄 수 있다.
2) 페이로드는 암호화된 것이 아니기에 Base64 디코딩을 하면 내용을 볼 수 있다.
무엇보다 HTTP의 Stateless 특성을 위배한다. 서버에서는 클라이언트의 상태를 저장하지 않아야 되지만 세션 저장소라는 곳에 클라이언트의 상태를 저장하게 되므로 Stateful한 상태가 된다.
이 문제는 결국 확장성의 문제로 이어진다. 1번 서버에서 로그인한 사용자가 2번 서버로 요청하게 되면 2번 서버에서는 세션이 저장되어 있찌 않기 때문이다.
세션은 모든 인증 접오를 서버에서 관리하기 때문에 서버의 의존성이 높아 보안적 측면에서는 유리하다.
세션이 탈취되면 서버 측에서 해당 세션을 무효처리 하면 되기 때문이다.
토큰은 stateless한 특성으로 서버에서는 검증 알고리즘만 존재하기 때문에 토큰이 한 번 탈취 당하면 세션보다 복잡한 방식으로 해킹을 막아야 된다.
토큰의 가장 큰 장점은 확장성이다. 서버가 직접 인증 방식을 저장하지 않고 토큰 복호화 로직을 통해 인증처리를 하기 때문에 세션 불일치 문제로부터 자유롭다. 토큰 기반 인증 방식은 HTTP Stateless를 활용할 수 있고, 높은 확장성을 갖는다.
세션 방식은 서버의 요청을 처리하는데 별도의 작업을 해주지 않으면 세션 불일치가 발생한다.
스티키 서버, 세션 스토리지 등의 방식으로 외부에서 분리 작업을 해주어야 되는데 단일 책임 원칙을 벗어나는 문제가 있다.
CSRF는 Cross site Request forgery로 사이트간 위조 요청이다. 즉, 정상적인 사용자가 의도치 않은 위조요청을 보내는 것을 의미한다.
사용자가 NN이라는 은행 서비스에 로그인 된 상태로 공격자가 보낸 메일의 링크에 들어가면 NN서비스에 사용자로 위조된 의도하지 않은 요청이 날라가는 것입니다.
<img src = "http://NN.com/send/money?to=attacker_account&money=1000000000000000"/>
대충 뭐 이런식으로 이미지 누르면 저게 날아감
Spring Security에서는 csrf를 disable 하여도 좋다고 한다.
그 이유는 RestAPI를 이용한 서버라면, Session 기반 인증과는 다르게 stateless 하기 떄문에 서버에 인증 정보를 보관하지 않는다.
RestAPI에서는 권한이 필요한 요청을 위해서는 인증정보(JWT 등)를 포함시켜야 된다.
따라서 서버에 인증정보를 저장하지 않기 때문에 굳이 불필요한 csrf 코드들을 작성할 필요가 없다.
CORS란 HTTP 헤더를 이용하여 웹 애플리케이션에 대한 리소스를 다른 도메인에서 접근할 수 있도록 권한을 부여하는 정책이다.
쉽게 설명하면, 서버가 다른 도메인(주소, 프로토콜, 포트)에서 리소스에 접근하려는 요청을 허용할지를 결정하는 정책이다.
CORS를 통해 허용할 URL, HTTP 메서드, 요청 헤더, 응답 헤더(노출 헤더) 등을 설정할 수 있다.