사용자의 신원을 입증하는 과정
인증은 사용자를 확인하는 과정이다. 사용자가 어떤 ROLE을 부여 받았는지는 상관없이, ‘너가 내 사용자야?’를 묻는 것이다. 보통은 로그인을 통해 사용자를 판단한다. 인증을 구현하는 과정 중 가장 많이 사용되는 방법은 로그인이다. 로그인을 구현하는 방법은 여러 가지가 있다. 세션, 쿠키, jwt…등등 많은 방법이 있고, 각 방법마다 장단점이 존재한다. 각 방법에 대해서는 아래에서 찾아볼 예정이다.
결과적으로 인증은 ‘너가 내 사용자야?’를 묻는 것이라고 생각하면 된다.
사용자의 권한을 확인하는 과정
인증과는 다르게 사용자의 권한을 확인한다. 인증 후에 거치는 과정으로 ‘너가 뭘 할 수 있는데?’를 묻는 것과 같다. 너가 내 사용자인 건 알겠는데, 너가 신입사원인지, 사장인지 모르기 때문에 그걸 판단하는 것이다. 쉽게 예를 들면 카페를 갔을 때 사장, 알바, 손님이 존재하고, 인증은 너가 [사장, 알바, 손님] 중 한 명인지를 확인하는 것이다. ‘관리자 외 출입금지’인 곳을 들어가기 위해서는 [사장, 알바] 중 한 명이어야 가능한데, 이 과정을 인가라고 하는 것이다.
인가를 구현할 때는 정책적으로 많은 고민이 필요하다. 권한을 어떻게 나눠야할지에 대해서 계층적(hierarchy)으로 권한을 나눌지, 그룹(group)으로 분리해서 나눌지를 고민해봐야한다.
결과적으로 인가는 ‘너가 뭘 할 수 있는데?’를 묻는 것이라고 생각하면 된다.
스프링 시큐리티는 스프링 기반의 애플리케이션 보안(인증, 인가, 권한)을 담당하는 스프링 하위 프레임워크이다. 여러 옵션을 제공하고, 애너테이션 설정이 쉽다는 점, 보안(CSRF 공격, 세션 고정 공격 등)에 뛰어나서 개발자가 보안 관련 개발을 해야 하는 부담을 줄일 수 있다.
HTTPServletRequest 에게 ID, PW 정보가 전달된다. 이 때 넘어온 ID, PW을 AuthenticationFilter에서 유효성 검사를 진행한다.
유효성 검사 후 UsernamePasswordAuthenticationToken을 생성한다.
생성한 인증용 객체 토큰을 AuthenticationManager에게 전달한다.
토큰을 AuthenticationProvider에게 전달한다.
5 ~ 7.사용자 아이디를 UserDetailService에 보내고, ID를 통해 사용자 정보를 찾아 UserDetails 객체를 생성합니다. 이후 DB에 있는 사용자 정보를 가져와서 입력된 ID, PW과 UserDetails ID, PW을 비교해 실제 인증 처리를 합니다.
8~10. 인증이 완료되면 SecurityContextHolder에 Authentication을 저장합니다.
성공하면 AuthenticationSuccessHandler 실행
실패하면 AuthenticationFailureHandler 실행
쿠키(Cookie)
: 웹사이트 접속시 사용자의 개인 장치에 다운로드되고, 브라우저에 저장되는 작은 텍스트 파일.
- 쿠키 사용 방법
- 로그인 성공 시 서버에서 쿠키에 사용자 정보를 넣어준다.
- 클라이언트 측에서 다음 요청마다 쿠키를 서버에 같이 보내준다.
- 서버에서는 쿠키를 통해 로그인 여부, 유저 정보, 권한 등을 확인한다.
- 문제점
- 쿠키는 클라이언트에서 접근할 수 있기 때문에 값 변경, 탈취 등 위험이 있다.
- 유효시간을 짧게 가져간다고 해도 값 자체가 보이기 때문에 보안에 취약하다.
- 해결 방법
- 세션 로그인 → 클라이언트에서 접근할 수 없다.
세션(Session)
: 일정 시간동안 같은 사용자로부터 들어오는 요청을 하나의 상태를 보고 그 상태를 일정하게 유지하는 기술.
쿠키(Cookie) vs 세션(Session)
: 쿠키는 사용자 정보를 사용자 컴퓨터(클라이언트가 접근가능한)에 저장하지만,
세션은 서버측(클라이언트가 접근할 수 없는)에 저장.
- 세션 사용 방법
- 로그인을 성공하면 세션을 생성하고, 생성된 세션은 서버측 세션 저장소에 보관한다.
- 세션의 Key값(UUID)를 쿠키를 통해 사용자에게 전달하고, 사용자는 매 요청마다 쿠키에 Key값을 같이 보내준다.
- 서버에서 Key값을 통해 서버측 세션 저장소에 접근하고, 해당 세션에 저장된 사용자 정보를 읽어온다.
- 세션 Key값은 UUID로 생성되기 때문에 예측이 거의 불가능.
- 번외. UUID v4는 총 36개 문자(32개 문자 + 4개 하이픈)로 구성. 340,282,366,920,938,463,463,374,607,431,768,211,456 개의 uuid가 생성 가능.
- 문제점
- 세션을 직접 설정하기엔 어렵고, 복잡하다.
- 프레임워크를 통해 설정을 쉽게 하더라도, 동시 사용자 수가 많아진다면, 세션 수 또한 많아진다. 서버에 무리가 온다.
- 쿠키에 저장된 세션 Key값을 탈취하여 클라이언트인척 위장이 가능하다.
- 해결 방법: 서버에서 IP특정을 통해 해결할 수 있다.
- 서버측 세션 저장소에 보관되기 때문에 어플리케이션을 재실행하는 경우 메모리가 초기화되어 세션 정보가 사라진다.
- 해결 방법: 세션 저장소를 외부에 저장한다.
- DB를 세션 저장소로 사용: 사용자가 많아질수록 디스크 I/O 작업이 많아져 성능에 문제가 생길 수 있다.
- Redis를 세션 저장소로 사용: 인메모리 기반으로 속도와 성능 측면에서 장점을 가진다.
JWT
: JSON Web Token 의 줄임말로 JSON 객체로 정보를 주고 받을 때, 안전하게 전송하기 위한 방식
- JWT 구조 - Header, Payload, Signature
- Header
- 토큰 유형과 서명 알고리즘(HMAC, SHA256…)이 포함된다.
```protobuf
{
"typ": "JWT",
"alg": "HS256"
}
```
- Payload - 등록된 클레임, 개인 클레임
- 등록된 클레임: iss(발행자), exp(만료시간), sub(제목), aud(대상) 등…
- 개인 클레임: 사용자 지정 클레임 → 내가 넣고 싶은 정보
```protobuf
{
// 등록된 클레임
"iss": "chb2005.tistory.com",
"sub": "123456789",
"exp": "1659002265",
// 개인 클레임
"userName": "changbum",
"isAdmin": false
}
```
- Signature은 Header, Payload, Secret Key를 합쳐 암호화한 결과값
- 보안이 취약하여 Payload에 중요한 정보를 담을 수 없다.
- Header, Payload는 Base64 인코딩을 할 뿐, 암호화를 거치지 않기 때문에 토큰을 가지고 있다면 바로 알아낼 수 있다. 따라서 Payload에 중요한 정보를 담을 수 없다.
- Signature는 Header, Payload를 인코딩하고, Secret Key와 합친 후 이걸 암호화한다.
- 따라서 조작을 할 순 있지만, 비밀키가 없다면 Signature를 통해 위변조 여부를 확인할 수 있다. 하지만 Payload에 담긴 정보를 알아내는 것은 가능하기에 중요한 정보를 담을 수 없다.
- 토큰 자체를 탈취하여 사용할 수 있다.
- 해결방법: 토큰은 발급된 이후 서버에 저장되지 않기 때문에, 삭제를 할 수 없어서 유효시간을 부여하는 식으로 탈취 문제에 대해 대응한다.
- Refresh Token을 사용하여 Access Token이 탈취되었더라도, 토큰이 만료된 이후 탈취된 토큰을 더이상 사용하지 못하도록 한다.
- 토큰 유효기간을 짧게 하면 로그인을 자주 해야하고, 길게 잡으면 보안에 취약해진다.
- Refresh Token 사용 방법
- 처음 로그인 시 Access Token, Refresh Token을 발급한다. DB에 Refresh Token을 저장하고 클라이언트는 이 둘을 저장하여 다음 요청부터 헤더에 담아서 보낸다.
- Refresh Token은 긴 유효시간을 가지고, Access Token은 짧은 유효시간을 가진다.
- ex) Access Token 2시간, Refresh Token 2주
- 만약 Access Token 유효시간이 만료되면 같이 보내진 Refresh Token을 DB와 비교한 후에 일치하면 다시 Access Token을 재발급한다.
- 로그아웃 시 DB Refresh Token을 사용하여 사용 불가능하도록 한다.
- Refresh Token을 저장할 땐 세션 저장소와 같이 Redis를 사용하는 편이 일반적이다.
- Access Token 과 Refresh Token 이 모두 탈취된 경우
- 문제: 유효시간이 긴 Refresh Token이 탈취된 경우 만료시까지 Access Token을 계속
발급받아 인증이 가능하다.- 해결방법: Refresh Token Rotation 도입
- 기존에는 RT(Refresh Token)을 이용해 AT(Access Token)을 재발급하였지만, 이젠 AT를 갱신할 때 RT도 재발급받는 것이다. 재발급 받은 RT를 RDB(or Redis)에 업데이트한다.
- 이는 기존 사용자가 AT를 재발급할 때, RT도 업데이트되기 때문에 기존 RT를 탈취한 해커는 업데이트된 RT를 가질 수 없고, 서버측에서 기존 RT임을 인지하여 막을 수 있다.
- 하지만 해커가 먼저 새로운 RT를 발급받았을 경우 정상 사용자가 AT 재발급을 요청한다면 거절되고, 로그인을 다시 해야한다.
- 이 때 Redis를 이용하여 {key : value = RT : UserPK} 형태로 저장이 된다면, 현재 Redis에는RT(1) : User_A_PK 와 RT(해커) : User_A_PK가 있을 것이다. 정상 사용자 A에 대해 여러 개의 Refresh Token이 생성되는 경우 해커가 탈취한 Refresh Token의 유효 기간이 끝날 때까지 막을 방법이 없다.
- 따라서 위 구조를 다음과 같이 바꿔야 한다. {key : value = UserPK : RT} 로 바꾼다면, User 1명에게 지정된 RT는 1개로 제한된다.
- 이렇게 된다면, 해커가 먼저 새로운 RT를 발급받더라도, 정상 사용자의 RT가 잘못된 것을 인지하고, 로그인을 하게 되면 새로운 RT로 Redis의 정보가 업데이트 된다.
- 이외 발생할 수 있는 문제들
- 위의 문제들을 해결하기 위해 RTR 기법을 도입하여 Redis {key : value} 구조를 적절하게 설정하더라도, 사용자 A가 서비스에 접속하는 시간이 늦어진다면 그 시간동안 해커가 계속 새로운 RT를 발급받아 사용하게 될 것이고, 이를 서버측에서 인지할 수 없다.
- 이는 Stateless 특징을 가진 Token 기반 인증방식이 가지는 필연적인 문제인 것 같다.
- 물론 AT와 RT의 secret key를 서로 다르게 설정해야한다. (혹시나…)
- HTTP 요청 → WAS → 필터1 → 필터2 → 서블릿 → 컨트롤러
- Dispatcher Servlet 도달하기 전에 실행되는 것으로, Spring Context가 아닌 Web Context에 존재한다.
- Spring Security가 Spring MVC 밖에서 작동할 수 있는 이유이다.
- 모든 요청에 대해 처리해야 하는 로직을 구현하기에 적합하다.
- Request 와 Response 조작이 가능하다.
- HTTP 요청 → WAS → 서블릿 → 인터셉터1 → 인터셉터2 → 컨트롤러
- Spring MVC의 일부로, Spring Context 내부에서 작동하며 사전 처리, 로깅, 트랙잭션 관리에 적합하다.
- Request 와 Response 조작이 불가능하다.
- Spring Security 구조
- 보안 요구 사항을 처리하기 위해 설계된 복잡한 필터 체인을 가지고 있다.
- 인증 로직을 필터로 구현하면 위 체인에 통합된다.
- 인증은 요청 처리의 초기 단계에 이루어지며, Spring MVC의 DispatcherServlet에 도달하기 전에 실행되는 필터로 처리하는 것이 적합하다.
- Spring MVC에 한정된 실행
- Interceptor는 Spring MVC 내 Spring Context에 존재하기 때문에, Spring MVC를 통하지 않는 요청은 처리할 수 없다.
- 인증은 요청 처리의 초기 단계에 이루어져야 하기 때문에 Spring MVC 도달하기 전에 인증 완료되어야 하는 경우가 많다.
- Spring Security는 필터 체인으로 동작하기 때문에, Interceptor를 사용하는 경우 일관된 방식을 유지하기 어렵다.