코드는 Github을 통해 확인할 수 있습니다.
JWT 로그인 방식은 CSRF와 REST API에 대한 지식이 요구 됩니다.
목차
- 화면 구성
- JWT(JSON Web Token) 소개
- JWT 한계
- 코드 분석 - REST API 구현과 JWT 구현을 분리해서 설명합니다.
화면 구성
메인 페이지
로그인 페이지
프로필 페이지(토큰의 만료를 확인하기 위해 10초로 설정했다.)
JWT(JSON Web Token) 소개
세션, 쿠키의 로그인 방식과 JWT의 가장 큰 차이점은 서버는 클라이언트의 상태를 완전히 저장하지 않는 무상태성(Stateless)을 유지할 수 있다는 점입니다.
세션을 사용하는 로그인 방식은 서버 메모리에 사용자 세션 값을 들고있으므로(서버가 아니라 DB에 넣더라도) 무상태성이라고 볼 수 없습니다.
하지만 JWT는 사용자가 로그인 시에 암호화 방식(대표적으로 공개키, 비밀키 방식인 RSA)으로 클라이언트에게 암호화된 토큰을 전달하게 되는데, 해당 토큰을 다시 서버에 전달해서 토큰을 복호화해서 풀어내는 방식을 사용하기 때문에 서버가 클라이언트의 상태를 저장하고 있지 않고 단순히 복호화만 하기 때문에 무상태성을 유지할 수 있습니다.
JWT와 SSR(Server Side Rendering)
JWT는 무상태성을 유지하는 인증 방식으로 클라이언트와 서버가 분리되어 있는 REST API에서 보통 사용합니다.
그래서 쿠키 세션 방식의 로그인을 구현한 프로그램은 REST API라고 불릴 수 없습니다.
그래서 Thymeleaf, JSP, Freemaker, Mustache 등의 템플릿 엔진에서는 JWT를 구현하지 않는 것이 좋습니다.
위의 템플릿 엔진들은 전부 서버에서 페이지를 결정해주는 SSR(Server Side Rendering) 방식을 사용하는데, 이 말은 스프링 Controller에서 View를 정하게 된다는 패러다임으로 클라이언트와 서버가 강결합 되어있는 개발 방식을 사용합니다.
그래서 JWT 예제를 찾아보면 위의 템플릿 엔진에서 만든 예제가 굉장히 드뭅니다.
또 여러 개발자들이 개발하는 환경에서 뒤죽박죽이 될 확률이 높습니다.
REST API는 페이지의 이동을 서버에 접근하지 않고 클라이언트에서만 온전히 이루어지도록 만들어야 합니다.
하지만 위의 템플릿 엔진들은 form으로 submit을 하든 location.href를 하든 전부 서버를 거쳐서 페이지를 렌더링해주는 방식인데, 페이지 이동을 위해 Controller를 갔다올때도 JWT 인증을 사용할 수 있지 않을까 하는 착각을 쉽게할 수 있기 때문입니다. 하지만 해당 과정에서 JWT의 기능을 이용할 수가 없습니다.
JWT는 일반적으로 클라이언트 LocalStorage 영역에 서버에서 받은 토큰을 보관합니다. 그리고 서버에 요청 시에는 LocalStorage에 있는 토큰을 빼내고 Request Header의 Authorization값에 토큰을 추가해서 요청하는 형태로 만드는데, 위의 템플릿 엔진들은 페이지 이동을 위한 Controller 요청 시 Request Header에 사용자의 Custom된 값을 추가할 수 없습니다.
페이지 이동이 아닌 Rest Api로 구축된 Api에는 ajax를 사용할 때는 Header에 값을 추가할 수 있습니다.
여러명이 개발하는 환경에서 Thymeleaf + REST API 방식을 이용한다면 페이지 이동과 API 정확하게 분리해야해서 아키텍처 설계가 또 필요하다는 단점이 존재합니다.
그래서 SSR 방식의 엔진을 사용하는 것 보다는 CSR(Client Side Rendering) 방식의 React, Vue를 사용하는 것이 JWT를 구축하기에는 더 깔끔합니다.
또한 Thymeleaf로 REST API와 JWT를 구현하면 Thymeleaf 영역에서 Security 태그 기능을 이용할 수 없습니다.
아래와 같이 ROLE_USER 권한이 있는 사람에게만 보여주는 div를 만들 수가 없습니다.
서버에서 권한을 체크해서 페이지를 이동한 것이 아니기 때문입니다.
<div sec:authorize="hasRole('ROLE_USER')" ></div>
그렇기 때문에 SSR 엔진에서는 세션 쿠키 방식의 로그인이나 OAuth2 방식의 로그인을 구현하는 것이 좋습니다.
결론을 내자면 아래와 같습니다.
SSR + REST API 사용 하지 않기 + 세션 쿠키 or OAuth2 로그인 방식 사용(CSRF 방어 필요, 세션 사용 시 클러스터링 등의 이슈 고려)
CSR + REST API 사용 + JWT(CSRF 방어 불필요, 클러스터링 불필요)
코드 분석
REST API 설정
위의 빨간색 테두리 영역은 REST API를 위한 설정이라고 볼 수 있습니다.
하나하나 살펴봅시다.
http.csrf().disable()
CSRF를 켜두면 서버는 클라이언트 영역에 CSRF 토큰을 보냅니다.
그리고 다시 클라이언트에서 서버로 CSRF 토큰이 전달되지 않으면 403 error를 뱉고 인가시켜주지 않습니다.
REST API에서는 CSRF 방어가 필요가 없고 더불어 CSRF 토큰을 주고 받을 필요가 없기 때문에 CSRF 설정을 해제합니다.
.addFilter(corsConfig.corsFilter())
REST API에서는 여러 서버를 운영하는 환경이다보니 SOP 뿐만아니라 CORS도 허용을 해야 여러 곳에서 접근이 가능하므로 CORS를 허용해줘야 합니다.
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
서버를 Stateless하게 유지합니다. 이걸 설정하면 Spring Security에서 세션을 만들지 않습니다. 만약에 켜둔다면 JWT Token으로 로그인하더라도 클라이언트에서 Token값을 서버에 전달하지 않더라도 세션 값으로 로그인이 됩니다.
.formLogin().disable()
formLogin 기능을 끈다고 해서 form 태그 내에 로그인 기능을 못쓴다는 것은 아닙니다.
formLogin을 끄면 초기 로그인 화면이 사라집니다.
그것보다 궁극적인 이유는 아래에 설명할 JWT의 기능을 만들기 위해서 입니다.
formLogin은 세션 로그인 방식에서 로그인을 자동처리 해준다는 장점이 존재했는데, JWT에서는 로그인 방식 내에 JWT 토큰을 생성하는 로직이 필요하기 때문에 로그인 과정을 수동으로 클래스를 만들어줘야 하기 때문에 formLogin 기능을 제외 합니다.
formLogin 기능 자체가 REST API에 반대되는 특징을 가지고 있습니다. formLogin의 defaultSuccessUrl 메소드로 로그인 성공 시 리다이렉트 할 주소를 입력하게 되는데 REST API에서는 서버가 페이지의 기능을 결정하면 안되기 때문에 결과적으로 필요하지 않은 formLogin은 disable합니다.
.httpBasic.disable()
httpBasic은 기본적으로 disable이지만 켜두면 위와 같이 알림창이 뜹니다.
쿠키와 세션을 이용한 방식이 아니라 request header에 id와 password값을 직접 날리는 방식이라 보안에 굉장히 취약합니다. REST API에서는 오로지 토큰 방식을 이용하기 때문에 보안에 취약한 httpBasic 방식은 해제한다고 보시면 됩니다.
JWT 로그인 과정
로그인 요청을 하는 ajax입니다.
/login이 컨트롤러에 매핑되는게 없는데 작동하는 이유는 스프링 Security가 해당 URI를 로그인 URI로 쓰고 있기 때문입니다. /login에는 username과 password 값을 담아서 요청 합니다.
그리고 해당 요청이 들어오면 UsernamePasswordAuthenticationFilter를 상속한 JwtAuthenticationFilter 클래스가 실행됩니다. JwtAuthenticationFilter 는 아래에 설정에 addFilter를 통해 넣어줬습니다.
그리고 그 안에서 JWT 토큰을 만들고 로그인 과정을 거칩니다.
JWT 설정을 위해서는 formLogin 기능을 빼고 위와 같이 사용자가 직접 필터 클래스를 만들어줘야 합니다.
세션 로그인 방식에서는 formLogin 기능에 loginProcessingUrl("/login_proc") 을 설정하면 자동으로 로그인 기능을 구현할 수 있었습니다.
JWT를 구축하기 위해선 formLogin에서 자동화했던 기능을 개발자가 직접 만들어줘야 합니다.
왜냐면 JWT에서는 로그인 흐름에 JWT 토큰을 발급하는 것을 추가해야 되기 때문에 위해서 forLogin 자동 처리 방식이 아닌 클래스를 수동으로 개발하는 과정이 필요합니다.
그 첫번째 과정은 아래의 Flow 중 1번인 AuthenticationFilter(여기서 UsernamePasswordAuthenticationFilter를 상속한 JwtAuthenticationFilter를 이용했습니다.)를 들어온 첫번째 과정이라고 보시면 됩니다.
그리고 해당 필터 클래스 안에서 2번인 UsernamePasswordAuthenticationToken을 발급 받아야 합니다.
UsernamePasswordAuthenticationToken를 만들어서 Token을 생성해줍니다.
formLogin 방식에서는 설정에서 loginProcessingUrl("/login_proc")를 호출하면 자동으로 loadUserByUsername가 실행되면서 로그인이 되었습니다.
JWT 방식에선 loadUserByUsername를 수동으로 호출해야 하기 때문에 아래와 같이 authenticate 메소드를 실행합니다.
authenticate 메소드는 위의 그림에서 3번인 AuthenticationManager가 실행합니다.
authenticate() 함수가 호출 되면 위의 그림에서 Authentication Provider가 UserDetailsService의 loadUserByUsername를 호출합니다.
그리고 UserDetails를 리턴해주면 미리 만들었던 UsernamePasswordAuthenticationToken의 두번째 파라미터(Credential Password)와 UserDetails(DB에서 가져온 값)의 getPassword() 함수로 비교해서 동일하면 Authentication 객체를 만들어서 로그인이 되는 원리입니다.
결과적으로 Authentication Manger를 이용하면 Authenticaton Provider를 직접 구현해서 값을 전달하지 않아도 Authentication 객체가 만들어진다는게 특징입니다.
Authentication 객체가 성공적으로 만들어 졌다면 아래의 Success 메소드가 수행되고 응답 값에 JWT 토큰을 만들어주고 사용자에게 전달해줍니다.
JWT 로그인 후 인가 과정
AuthorizationFilter는 로그인이 아닌 사용자가 페이지 이동 시 인가 처리를 받기 위한 필터 입니다.
쿠키 세션 처리 방식으로 로그인을 구현하면 쿠키가 항상 서버로 전달된다는 특징 때문에 자동 로그인 처리가 가능했지만 JWT에서는 클라이언트에서 API를 요청할 때마다 JWT 토큰 값을 매번 확인해야하므로 BasicAuthenticationFilter를 구현해서 매 페이지 요청마다 아래처럼 토큰을 확인해줘야 합니다.
아래 블로그는 자바와 스프링에서 사용되는 기술의 정의, 효율에 대한 내용을 많이 담고있습니다
방문하셔서 읽어보시기를 추천드립니다
출처 : https://stir.tistory.com/275