Java 및 Spring Security를 사용하는 JWT로 REST 보안
게시 됨: 2022-03-11보안
보안은 편의의 적이며 그 반대도 마찬가지입니다. 이 진술은 실제 집 입구에서 웹 뱅킹 플랫폼에 이르기까지 가상 또는 실제 시스템에 적용됩니다. 엔지니어는 주어진 사용 사례에 적합한 균형을 찾기 위해 끊임없이 노력하고 있으며 어느 한 쪽으로 치우쳐 있습니다. 일반적으로 새로운 위협이 나타나면 편리함에서 멀어지는 보안 쪽으로 이동합니다. 그런 다음 보안을 너무 낮추지 않고도 잃어버린 편의성을 복구할 수 있는지 확인합니다. 게다가 이 악순환은 영원히 계속된다.
간단한 Spring 보안 튜토리얼을 사용하여 실제 작동을 시연하면서 오늘 REST 보안 상태를 조사해 보겠습니다.
REST(Representational State Transfer의 약자) 서비스는 서비스를 설명하기 위한 WSDL이나 메시지 형식을 지정하기 위한 SOAP와 같이 방대한 사양과 복잡한 형식을 가진 웹 서비스에 대한 극히 단순화된 접근 방식으로 시작되었습니다. REST에는 그런 것이 없습니다. REST 서비스를 일반 텍스트 파일로 설명하고 JSON, XML 또는 일반 텍스트와 같이 원하는 메시지 형식을 다시 사용할 수 있습니다. REST 서비스의 보안에도 단순화된 접근 방식이 적용되었습니다. 정의된 표준은 사용자를 인증하는 특정 방법을 부과하지 않습니다.
REST 서비스는 많이 지정되지 않았지만 중요한 것은 상태가 없다는 것입니다. 이는 서버가 클라이언트 상태를 유지하지 않는다는 것을 의미하며 세션이 좋은 예입니다. 따라서 서버는 클라이언트가 만든 첫 번째 요청인 것처럼 각 요청에 응답합니다. 그러나 지금도 여전히 많은 구현에서 표준 웹사이트 아키텍처 설계에서 상속된 쿠키 기반 인증을 사용합니다. REST의 Stateless 접근 방식은 세션 쿠키를 보안 관점에서 부적절하게 만들지만 그럼에도 불구하고 여전히 널리 사용됩니다. 필요한 무국적자를 무시하는 것 외에도 단순화된 접근 방식은 예상되는 보안 상충 관계로 나타났습니다. 웹 서비스에 사용되는 WS-Security 표준과 비교할 때 REST 서비스를 만들고 사용하는 것이 훨씬 쉽기 때문에 편의성이 최고였습니다. 트레이드 오프는 매우 얇은 보안입니다. 세션 하이재킹 및 XSRF(교차 사이트 요청 위조)는 가장 일반적인 보안 문제입니다.
서버에서 클라이언트 세션을 제거하려는 시도에서 기본 또는 다이제스트 HTTP 인증과 같은 몇 가지 다른 방법이 때때로 사용되었습니다. 둘 다 Authorization
헤더를 사용하여 일부 인코딩(HTTP 기본) 또는 암호화(HTTP 다이제스트)가 추가된 사용자 자격 증명을 전송합니다. 물론 그들은 웹사이트에서 발견된 것과 동일한 결함을 가지고 있었습니다. 사용자 이름과 암호가 쉽게 되돌릴 수 있는 base64 인코딩으로 전송되기 때문에 HTTP Basic은 HTTPS를 통해 사용해야 했고 HTTP Digest는 안전하지 않은 것으로 입증된 구식 MD5 해싱을 강제로 사용했습니다.
마지막으로 일부 구현에서는 클라이언트를 인증하기 위해 임의의 토큰을 사용했습니다. 현재로서는 이 옵션이 최선인 것 같습니다. 제대로 구현되면 HTTP Basic, HTTP Digest 또는 세션 쿠키의 모든 보안 문제를 해결하고 사용이 간편하며 Stateless 패턴을 따릅니다.
그러나 이러한 임의의 토큰에는 관련된 표준이 거의 없습니다. 모든 서비스 제공자는 토큰에 무엇을 넣을지, 어떻게 인코딩 또는 암호화할지에 대한 아이디어를 가지고 있었습니다. 다른 공급자의 서비스를 사용하려면 사용된 특정 토큰 형식에 적응하기 위해 추가 설정 시간이 필요했습니다. 반면에 다른 방법(세션 쿠키, HTTP 기본 및 HTTP 다이제스트)은 개발자에게 잘 알려져 있으며 모든 장치의 거의 모든 브라우저에서 즉시 사용할 수 있습니다. 프레임워크와 언어는 이러한 방법을 사용할 준비가 되어 있으며 각각을 원활하게 처리할 수 있는 내장 함수가 있습니다.
JWT 인증
JWT(JSON Web Token에서 줄임말)는 REST 서비스뿐만 아니라 일반적으로 웹에서 인증하기 위해 토큰을 사용하기 위한 누락된 표준화입니다. 현재 RFC 7519로 초안 상태입니다. 강력하고 많은 정보를 전달할 수 있지만 크기가 비교적 작음에도 불구하고 여전히 사용하기 쉽습니다. 다른 토큰과 마찬가지로 JWT를 사용하여 ID 제공자와 서비스 제공자(반드시 동일한 시스템은 아님) 간에 인증된 사용자의 ID를 전달할 수 있습니다. 또한 인증 데이터와 같은 모든 사용자 클레임을 전달할 수 있으므로 서비스 공급자는 각 요청에 대한 사용자 역할 및 권한을 확인하기 위해 데이터베이스나 외부 시스템에 들어갈 필요가 없습니다. 해당 데이터는 토큰에서 추출됩니다.
JWT 보안이 작동하도록 설계된 방식은 다음과 같습니다.
- 클라이언트는 자격 증명을 자격 증명 공급자에게 보내 로그인합니다.
- 자격 증명 공급자가 자격 증명을 확인합니다. 모든 것이 정상이면 사용자 데이터를 검색하고 서비스에 액세스하는 데 사용할 사용자 세부 정보 및 권한이 포함된 JWT를 생성하며 JWT(무제한일 수 있음)의 만료 기간도 설정합니다.
- ID 제공자는 서명하고 필요한 경우 JWT를 암호화하고 자격 증명을 사용하여 초기 요청에 대한 응답으로 이를 클라이언트에 보냅니다.
- 클라이언트는 ID 제공자가 설정한 만료 시간에 따라 JWT를 제한적 또는 무제한으로 저장합니다.
- 클라이언트는 서비스 제공자에 대한 모든 요청에 대해 Authorization 헤더에 저장된 JWT를 보냅니다.
- 각 요청에 대해 서비스 공급자는
Authorization
헤더에서 JWT를 가져와서 필요한 경우 암호를 해독하고 서명을 확인하고 모든 것이 정상이면 사용자 데이터와 권한을 추출합니다. 이 데이터만을 기반으로 데이터베이스에서 추가 세부 정보를 조회하거나 ID 제공자에게 연락하지 않고 다시 클라이언트 요청을 수락하거나 거부할 수 있습니다. 유일한 요구 사항은 서비스가 서명을 확인하거나 암호화된 ID를 해독할 수 있도록 ID와 서비스 제공자가 암호화에 대해 동의해야 한다는 것입니다.
이 흐름은 뛰어난 유연성을 허용하는 동시에 여전히 안전하고 개발하기 쉬운 상태를 유지합니다. 이 접근 방식을 사용하면 서비스 제공자 클러스터에 새 서버 노드를 쉽게 추가할 수 있으며, 서명을 확인하고 공유 비밀 키를 제공하여 토큰을 해독하는 기능만으로 노드를 초기화할 수 있습니다. 세션 복제, 데이터베이스 동기화 또는 노드 간 통신이 필요하지 않습니다. 완전한 영광을 누리십시오.
JWT와 기타 임의 토큰 간의 주요 차이점은 토큰 콘텐츠의 표준화입니다. 또 다른 권장 접근 방식은 Bearer 체계를 사용하여 Authorization
헤더에 JWT 토큰을 보내는 것입니다. 헤더의 내용은 다음과 같아야 합니다.
Authorization: Bearer <token>
REST 보안 구현
REST 서비스가 예상대로 작동하려면 기존의 다중 페이지 웹 사이트와 약간 다른 승인 접근 방식이 필요합니다.
클라이언트가 보안 리소스를 요청할 때 로그인 페이지로 리디렉션하여 인증 프로세스를 트리거하는 대신 REST 서버는 요청 자체에서 사용 가능한 데이터(이 경우 JWT 토큰)를 사용하여 모든 요청을 인증합니다. 이러한 인증이 실패하면 리디렉션이 의미가 없습니다. REST API는 단순히 HTTP 코드 401(Unauthorized) 응답을 보내고 클라이언트는 무엇을 해야 하는지 알아야 합니다. 예를 들어 브라우저는 사용자가 사용자 이름과 암호를 제공할 수 있도록 동적 div를 표시합니다.
반면에, 기존의 다중 페이지 웹사이트에서 인증에 성공한 후 사용자는 HTTP 코드 301(영구적으로 이동됨)을 사용하여 일반적으로 홈 페이지로 리디렉션되거나 더 좋게는 사용자가 처음에 요청한 트리거 페이지로 리디렉션됩니다. 인증 과정. REST를 사용하면 다시 말이 안 됩니다. 대신 리소스가 전혀 보호되지 않은 것처럼 요청 실행을 계속하고 HTTP 코드 200(OK)과 예상 응답 본문을 반환합니다.
스프링 보안 예제
이제 Java와 Spring을 사용하여 JWT 토큰 기반 REST API를 구현하는 동시에 가능한 경우 Spring Security 기본 동작을 재사용하는 방법을 살펴보겠습니다.
예상대로 Spring Security 프레임워크에는 세션 쿠키, HTTP 기본 및 HTTP 다이제스트와 같은 "오래된" 인증 메커니즘을 처리하는 플러그인 클래스가 많이 제공됩니다. 그러나 JWT에 대한 기본 지원이 부족하고 작동하려면 손을 더럽힐 필요가 있습니다. 더 자세한 개요를 보려면 공식 Spring Security 문서를 참조해야 합니다.
이제 web.xml
에서 일반적인 Spring Security 필터 정의 를 시작하겠습니다.
<filter> <filter-name>springSecurityFilterChain</filter-name> <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class> </filter> <filter-mapping> <filter-name>springSecurityFilterChain</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>
Spring Security 필터의 이름은 Spring 구성의 나머지 부분이 기본적으로 작동하려면 정확히 springSecurityFilterChain
이어야 합니다.
다음은 보안과 관련된 Spring Bean의 XML 선언입니다. XML을 단순화하기 위해 루트 XML 요소에 xmlns="http://www.springframework.org/schema/security"
를 추가하여 기본 네임스페이스를 security
으로 설정합니다. 나머지 XML은 다음과 같습니다.
<global-method-security pre-post-annotations="enabled" /> (1) <http pattern="/api/login" security="none"/> (2) <http pattern="/api/signup" security="none"/> <http pattern="/api/**" entry-point-ref="restAuthenticationEntryPoint" create-session="stateless"> (3) <csrf disabled="true"/> (4) <custom-filter before="FORM_LOGIN_FILTER" ref="jwtAuthenticationFilter"/> (5) </http> <beans:bean class="com.toptal.travelplanner.security.JwtAuthenticationFilter"> (6) <beans:property name="authenticationManager" ref="authenticationManager" /> <beans:property name="authenticationSuccessHandler" ref="jwtAuthenticationSuccessHandler" /> (7) </beans:bean> <authentication-manager alias="authenticationManager"> <authentication-provider ref="jwtAuthenticationProvider" /> (8) </authentication-manager>
- (1) 이 줄에서 컨텍스트의 모든 스프링 빈에서
@PreFilter
, @PreAuthorize ,@PreAuthorize
,@PostAuthorize
주석을@PostFilter
합니다. - (2) 보안을 건너뛰기 위해 로그인 및 등록 끝점을 정의합니다. "익명"이라도 이 두 작업을 수행할 수 있어야 합니다.
- (3) 다음으로 진입점 참조 및 세션 생성을
stateless
로 설정하는 두 가지 중요한 구성을 추가하면서 모든 요청에 적용되는 필터 체인을 정의합니다(각 요청에 대해 토큰을 사용하므로 보안 목적으로 세션을 생성하지 않습니다). . - (4) 우리의 토큰은 csrf 보호에 면역이 되기 때문에
csrf
보호가 필요하지 않습니다. - (5) 다음으로, 양식 로그인 필터 바로 앞에 있는 Spring의 사전 정의된 필터 체인 내에 특수 인증 필터를 연결합니다.
- (6) 이 빈은 인증 필터의 선언입니다. Spring의
AbstractAuthenticationProcessingFilter
를 확장하기 때문에 속성을 연결하기 위해 XML로 선언해야 합니다(자동 연결은 여기에서 작동하지 않습니다). 필터가 하는 일에 대해서는 나중에 설명하겠습니다. - (7)
AbstractAuthenticationProcessingFilter
의 기본 성공 핸들러는 사용자를 성공 페이지로 리디렉션하기 때문에 REST 목적으로 충분하지 않습니다. 그것이 우리가 여기에 우리 자신을 설정한 이유입니다. - (8)
authenticationManager
에 의해 생성된 공급자의 선언은 사용자를 인증하기 위해 필터에서 사용됩니다.
이제 위의 XML에 선언된 특정 클래스를 구현하는 방법을 살펴보겠습니다. Spring이 우리를 위해 그것들을 배선할 것이라는 점에 유의하십시오. 우리는 가장 간단한 것부터 시작합니다.

RestAuthenticationEntryPoint.java
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { // This is invoked when user tries to access a secured REST resource without supplying any credentials // We should just send a 401 Unauthorized response because there is no 'login page' to redirect to response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized"); } }
위에서 설명한 것처럼 이 클래스는 인증 실패 시 HTTP 코드 401(Unauthorized)을 반환하여 기본 Spring의 리디렉션을 무시합니다.
JwtAuthenticationSuccessHandler.java
public class JwtAuthenticationSuccessHandler implements AuthenticationSuccessHandler { @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { // We do not need to do anything extra on REST authentication success, because there is no page to redirect to } }
이 간단한 재정의는 성공적인 인증의 기본 동작을 제거합니다(홈 또는 사용자가 요청한 다른 페이지로 리디렉션). AuthenticationFailureHandler
를 재정의할 필요가 없는지 궁금하다면 리디렉션 URL이 설정되지 않은 경우 기본 구현이 어느 곳에서도 리디렉션되지 않으므로 URL 설정을 피하면 됩니다. 이 정도면 충분합니다.
JwtAuthenticationFilter.java
public class JwtAuthenticationFilter extends AbstractAuthenticationProcessingFilter { public JwtAuthenticationFilter() { super("/**"); } @Override protected boolean requiresAuthentication(HttpServletRequest request, HttpServletResponse response) { return true; } @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { String header = request.getHeader("Authorization"); if (header == null || !header.startsWith("Bearer ")) { throw new JwtTokenMissingException("No JWT token found in request headers"); } String authToken = header.substring(7); JwtAuthenticationToken authRequest = new JwtAuthenticationToken(authToken); return getAuthenticationManager().authenticate(authRequest); } @Override protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { super.successfulAuthentication(request, response, chain, authResult); // As this authentication is in HTTP header, after success we need to continue the request normally // and return the response as if the resource was not secured at all chain.doFilter(request, response); } }
이 클래스는 JWT 인증 프로세스의 진입점입니다. 필터는 요청 헤더에서 JWT 토큰을 추출하고 인증을 주입된 AuthenticationManager
에 위임합니다. 토큰을 찾을 수 없으면 요청 처리를 중지하는 예외가 발생합니다. 또한 기본 Spring 흐름이 필터 체인을 중지하고 리디렉션을 진행하기 때문에 성공적인 인증을 위한 재정의가 필요합니다. 위에서 설명한 대로 응답 생성을 포함하여 체인을 완전히 실행해야 함을 명심하십시오.
JwtAuthenticationProvider.java
public class JwtAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider { @Autowired private JwtUtil jwtUtil; @Override public boolean supports(Class<?> authentication) { return (JwtAuthenticationToken.class.isAssignableFrom(authentication)); } @Override protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { } @Override protected UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { JwtAuthenticationToken jwtAuthenticationToken = (JwtAuthenticationToken) authentication; String token = jwtAuthenticationToken.getToken(); User parsedUser = jwtUtil.parseToken(token); if (parsedUser == null) { throw new JwtTokenMalformedException("JWT token is not valid"); } List<GrantedAuthority> authorityList = AuthorityUtils.commaSeparatedStringToAuthorityList(parsedUser.getRole()); return new AuthenticatedUser(parsedUser.getId(), parsedUser.getUsername(), token, authorityList); } }
이 클래스에서는 Spring의 기본 AuthenticationManager
를 사용하지만 실제 인증 프로세스를 수행하는 자체 AuthenticationProvider
를 삽입합니다. 이를 구현하기 위해 AbstractUserDetailsAuthenticationProvider
를 확장하여 인증 요청(이 경우 JwtAuthenticationToken
클래스에 래핑된 JWT 토큰)을 기반으로 UserDetails
를 반환하기만 하면 됩니다. 토큰이 유효하지 않으면 예외가 발생합니다. 그러나 이것이 유효하고 JwtUtil
에 의한 암호 해독이 성공하면 데이터베이스에 전혀 액세스하지 않고 사용자 세부 정보를 추출합니다( JwtUtil
클래스에서 정확히 어떻게 되는지 볼 수 있음). 역할을 포함하여 사용자에 대한 모든 정보는 토큰 자체에 포함됩니다.
JwtUtil.java
public class JwtUtil { @Value("${jwt.secret}") private String secret; /** * Tries to parse specified String as a JWT token. If successful, returns User object with username, id and role prefilled (extracted from token). * If unsuccessful (token is invalid or not containing all required user properties), simply returns null. * * @param token the JWT token to parse * @return the User object extracted from specified token or null if a token is invalid. */ public User parseToken(String token) { try { Claims body = Jwts.parser() .setSigningKey(secret) .parseClaimsJws(token) .getBody(); User u = new User(); u.setUsername(body.getSubject()); u.setId(Long.parseLong((String) body.get("userId"))); u.setRole((String) body.get("role")); return u; } catch (JwtException | ClassCastException e) { return null; } } /** * Generates a JWT token containing username as subject, and userId and role as additional claims. These properties are taken from the specified * User object. Tokens validity is infinite. * * @param u the user for which the token will be generated * @return the JWT token */ public String generateToken(User u) { Claims claims = Jwts.claims().setSubject(u.getUsername()); claims.put("userId", u.getId() + ""); claims.put("role", u.getRole()); return Jwts.builder() .setClaims(claims) .signWith(SignatureAlgorithm.HS512, secret) .compact(); } }
마지막으로 JwtUtil
클래스는 토큰을 User
객체로 구문 분석하고 User
객체에서 토큰을 생성하는 역할을 합니다. jjwt
라이브러리를 사용하여 모든 JWT 작업을 수행하기 때문에 간단합니다. 이 예에서는 단순히 사용자 이름, 사용자 ID 및 사용자 역할을 토큰에 저장합니다. 우리는 또한 더 임의적인 것을 저장하고 토큰 만료와 같은 더 많은 보안 기능을 추가할 수 있습니다. 토큰의 구문 분석은 위와 같이 AuthenticationProvider
에서 사용됩니다. generateToken()
메서드는 보안되지 않고 보안 검사를 트리거하지 않거나 요청에 토큰이 있어야 하는 로그인 및 등록 REST 서비스에서 호출됩니다. 결국 사용자를 기반으로 클라이언트에게 반환될 토큰을 생성합니다.
결론
이전의 표준화된 보안 접근 방식(세션 쿠키, HTTP 기본 및 HTTP 다이제스트)이 REST 서비스에서도 작동하지만 모두 더 나은 표준을 사용하여 피하는 것이 좋은 문제가 있습니다. JWT는 시간을 절약하기 위해 정시에 도착하며 가장 중요한 것은 IETF 표준이 되기에 매우 가깝습니다.
JWT의 주요 강점은 상태 비저장 방식으로 사용자 인증을 처리하므로 확장 가능한 방식으로 최신 암호화 표준으로 모든 것을 안전하게 유지하는 것입니다. 토큰 자체에 클레임(사용자 역할 및 권한)을 저장하면 요청을 발행하는 서버가 인증 데이터 소스에 액세스할 수 없는 분산 시스템 아키텍처에서 큰 이점을 얻을 수 있습니다.