JWT의 Spring Security

37461 단어 securityspringjwtjava
Spring Security의 기본 동작은 표준 웹 응용 프로그램에서 쉽게 사용할 수 있습니다.쿠키 기반 인증과 세션을 사용합니다.또한 CSRF 토큰을 자동으로 처리하여 브로커 공격을 방지합니다.대부분의 경우, 사용자는 특정한 루트에 권한 수여 권한을 설정하기만 하면 됩니다. 이것은 데이터베이스에서 사용자를 검색하는 방법입니다. 단지 이것뿐입니다.
다른 한편, REST API를 하나만 구축하면 외부 서비스나 SPA/모바일 애플리케이션에서 사용되므로 전체 세션이 필요하지 않을 수 있습니다.다음은 JWT(JSON Web 토큰) - 작은 디지털 서명 토큰입니다.필요한 모든 정보를 영패에 저장할 수 있어 서버가 세션을 줄일 수 있다.
JWT는 서버가 사용자에게 권한을 부여할 수 있도록 각 HTTP 요청에 연결해야 합니다.영패를 어떻게 보내는지에 관해서는 몇 가지 옵션이 있다.예를 들어, URL 매개 변수로 또는 호스팅 모드를 사용하는 HTTP 라이센스 헤더:
Authorization: Bearer <token string>
JSON 웹 토큰에는
  • 헤더 - 일반적으로 영패 유형과 해시 알고리즘을 포함한다.
  • 유효 하중 - 일반적으로 사용자에 대한 데이터와 영패를 발급하는 데이터를 포함한다.
  • 서명 - 메시지를 보내는 동안 변경 사항이 없는지 확인하는 데 사용됩니다.
  • 예제 토큰


    라이센스 헤드의 JWT 토큰은 다음과 같습니다.
    Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJpc3MiOiJzZWN1cmUtYXBpIiwiYXVkIjoic2VjdXJlLWFwcCIsInN1YiI6InVzZXIiLCJleHAiOjE1NDgyNDI1ODksInJvbCI6WyJST0xFX1VTRVIiXX0.GzUPUWStRofrWI9Ctfv2h-XofGZwcOog9swtuqg1vSkA8kDWLcY3InVgmct7rq4ZU3lxI6CGupNgSazypHoFOA
    
    보시다시피, 제목, 성명, 서명 세 부분을 쉼표로 구분합니다.Header 및 payload는 Base64 인코딩된 JSON 객체입니다.

    제목:


    {
      "typ": "JWT",
      "alg": "HS512"
    }
    

    클레임/페이로드:


    {
      "iss": "secure-api",
      "aud": "secure-app",
      "sub": "user",
      "exp": 1548242589,
      "rol": [
        "ROLE_USER"
      ]
    }
    

    예제 응용 프로그램


    다음 예제에서는 두 개의 라우트를 포함하는 간단한 API를 만듭니다. 하나는 공개적이고 다른 하나는 승인된 사용자만 사용할 수 있습니다.
    페이지start.spring.io를 사용하여 응용 프로그램 프레임워크를 만들고 보안과 웹 의존 항목을 선택할 것입니다.나머지 옵션은 취향에 따라 달라집니다.

    JWT의 자바에 대한 지원은 라이브러리JJWT에서 제공하기 때문에pom에 다음과 같은 의존항을 추가해야 합니다.xml 파일:
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-api</artifactId>
        <version>0.10.5</version>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-impl</artifactId>
        <version>0.10.5</version>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-jackson</artifactId>
        <version>0.10.5</version>
        <scope>runtime</scope>
    </dependency>
    

    컨트롤러


    프로그램의 컨트롤러는 가능한 한 간단할 것입니다.사용자가 승인되지 않은 경우 메시지나 HTTP 403 오류 코드만 반환됩니다.
    @RestController
    @RequestMapping("/api/public")
    public class PublicController {
    
        @GetMapping
        public String getMessage() {
            return "Hello from public API controller";
        }
    }
    
    @RestController
    @RequestMapping("/api/private")
    public class PrivateController {
    
        @GetMapping
        public String getMessage() {
            return "Hello from private API controller";
        }
    }
    

    필터


    우선 JWT의 생성과 검증을 위해 다시 사용할 상수와 기본값을 정의합니다.
    주의: 프로그램 코드에 JWT 서명 키를 하드코어로 인코딩해서는 안 됩니다. (이 예에서는 이 점을 무시합니다.)환경 변수 또는 를 사용해야 합니다.속성 파일.이 밖에 열쇠는 적당한 길이가 필요하다.예를 들어 HS512 알고리즘에는 최소 512바이트 크기의 키가 필요합니다.
    public final class SecurityConstants {
    
        public static final String AUTH_LOGIN_URL = "/api/authenticate";
    
        // Signing key for HS512 algorithm
        // You can use the page http://www.allkeysgenerator.com/ to generate all kinds of keys
        public static final String JWT_SECRET = "n2r5u8x/A%D*G-KaPdSgVkYp3s6v9y$B&E(H+MbQeThWmZq4t7w!z%C*F-J@NcRf";
    
        // JWT token defaults
        public static final String TOKEN_HEADER = "Authorization";
        public static final String TOKEN_PREFIX = "Bearer ";
        public static final String TOKEN_TYPE = "JWT";
        public static final String TOKEN_ISSUER = "secure-api";
        public static final String TOKEN_AUDIENCE = "secure-app";
    
        private SecurityConstants() {
            throw new IllegalStateException("Cannot create instance of static util class");
        }
    }
    
    첫 번째 필터는 사용자 인증에 직접 사용됩니다.URL에서 사용자 이름과 암호 매개 변수를 확인하고 Spring 인증 관리자를 호출하여 인증합니다.
    사용자 이름과 암호가 올바르면 필터가 JWT 토큰을 만들고 HTTP 라이센스 헤더에서 반환합니다.
    public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
    
        private final AuthenticationManager authenticationManager;
    
        public JwtAuthenticationFilter(AuthenticationManager authenticationManager) {
            this.authenticationManager = authenticationManager;
    
            setFilterProcessesUrl(SecurityConstants.AUTH_LOGIN_URL);
        }
    
        @Override
        public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) {
            var username = request.getParameter("username");
            var password = request.getParameter("password");
            var authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
    
            return authenticationManager.authenticate(authenticationToken);
        }
    
        @Override
        protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response,
                                                FilterChain filterChain, Authentication authentication) {
            var user = ((User) authentication.getPrincipal());
    
            var roles = user.getAuthorities()
                .stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.toList());
    
            var signingKey = SecurityConstants.JWT_SECRET.getBytes();
    
            var token = Jwts.builder()
                .signWith(Keys.hmacShaKeyFor(signingKey), SignatureAlgorithm.HS512)
                .setHeaderParam("typ", SecurityConstants.TOKEN_TYPE)
                .setIssuer(SecurityConstants.TOKEN_ISSUER)
                .setAudience(SecurityConstants.TOKEN_AUDIENCE)
                .setSubject(user.getUsername())
                .setExpiration(new Date(System.currentTimeMillis() + 864000000))
                .claim("rol", roles)
                .compact();
    
            response.addHeader(SecurityConstants.TOKEN_HEADER, SecurityConstants.TOKEN_PREFIX + token);
        }
    }
    
    두 번째 필터는 모든 HTTP 요청을 처리하고 올바른 토큰이 있는 헤더가 있는지 확인합니다.예를 들어 영패가 만료되지 않았거나 서명 키가 정확하면
    토큰이 유효하면 필터는 Spring의 보안 컨텍스트에 인증 데이터를 추가합니다.
    public class JwtAuthorizationFilter extends BasicAuthenticationFilter {
    
        private static final Logger log = LoggerFactory.getLogger(JwtAuthorizationFilter.class);
    
        public JwtAuthorizationFilter(AuthenticationManager authenticationManager) {
            super(authenticationManager);
        }
    
        @Override
        protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                        FilterChain filterChain) throws IOException, ServletException {
            var authentication = getAuthentication(request);
            if (authentication == null) {
                filterChain.doFilter(request, response);
                return;
            }
    
            SecurityContextHolder.getContext().setAuthentication(authentication);
            filterChain.doFilter(request, response);
        }
    
        private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request) {
            var token = request.getHeader(SecurityConstants.TOKEN_HEADER);
            if (StringUtils.isNotEmpty(token) && token.startsWith(SecurityConstants.TOKEN_PREFIX)) {
                try {
                    var signingKey = SecurityConstants.JWT_SECRET.getBytes();
    
                    var parsedToken = Jwts.parser()
                        .setSigningKey(signingKey)
                        .parseClaimsJws(token.replace("Bearer ", ""));
    
                    var username = parsedToken
                        .getBody()
                        .getSubject();
    
                    var authorities = ((List<?>) parsedToken.getBody()
                        .get("rol")).stream()
                        .map(authority -> new SimpleGrantedAuthority((String) authority))
                        .collect(Collectors.toList());
    
                    if (StringUtils.isNotEmpty(username)) {
                        return new UsernamePasswordAuthenticationToken(username, null, authorities);
                    }
                } catch (ExpiredJwtException exception) {
                    log.warn("Request to parse expired JWT : {} failed : {}", token, exception.getMessage());
                } catch (UnsupportedJwtException exception) {
                    log.warn("Request to parse unsupported JWT : {} failed : {}", token, exception.getMessage());
                } catch (MalformedJwtException exception) {
                    log.warn("Request to parse invalid JWT : {} failed : {}", token, exception.getMessage());
                } catch (SignatureException exception) {
                    log.warn("Request to parse JWT with invalid signature : {} failed : {}", token, exception.getMessage());
                } catch (IllegalArgumentException exception) {
                    log.warn("Request to parse empty or null JWT : {} failed : {}", token, exception.getMessage());
                }
            }
    
            return null;
        }
    }
    

    보안 구성


    구성의 마지막 부분은 Spring 보안 자체입니다.구성은 간단합니다. 세부사항만 설정하면 됩니다.
  • 암호 인코더 - 우리의 예는 bcrypt

  • CORS 구성
  • 인증 관리자 - 우리의 예에서는 간단한 메모리 인증이지만 실제 생활에서는 UserDetailsService 같은 것이 필요합니다.
  • 어떤 단점이 안전하고 공개적으로 사용할 수 있는지 설정
  • 보안 상하문에 필터 2개 추가
  • 세션 관리 비활성화 – 세션이 필요하지 않으므로 세션 쿠키 생성이 차단됩니다.
  • @EnableWebSecurity
    @EnableGlobalMethodSecurity(securedEnabled = true)
    public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
    
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http.cors().and()
                .csrf().disable()
                .authorizeRequests()
                .antMatchers("/api/public").permitAll()
                .anyRequest().authenticated()
                .and()
                .addFilter(new JwtAuthenticationFilter(authenticationManager()))
                .addFilter(new JwtAuthorizationFilter(authenticationManager()))
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS);
        }
    
        @Override
        public void configure(AuthenticationManagerBuilder auth) throws Exception {
            auth.inMemoryAuthentication()
                .withUser("user")
                .password(passwordEncoder().encode("password"))
                .authorities("ROLE_USER");
        }
    
        @Bean
        public PasswordEncoder passwordEncoder() {
            return new BCryptPasswordEncoder();
        }
    
        @Bean
        public CorsConfigurationSource corsConfigurationSource() {
            final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
            source.registerCorsConfiguration("/**", new CorsConfiguration().applyPermitDefaultValues());
    
            return source;
        }
    }
    

    테스트


    API 공개 요청


    GET http://localhost:8080/api/public
    
    HTTP/1.1 200 
    X-Content-Type-Options: nosniff
    X-XSS-Protection: 1; mode=block
    Cache-Control: no-cache, no-store, max-age=0, must-revalidate
    Pragma: no-cache
    Expires: 0
    X-Frame-Options: DENY
    Content-Type: text/plain;charset=UTF-8
    Content-Length: 32
    Date: Sun, 13 Jan 2019 12:22:14 GMT
    
    Hello from public API controller
    
    Response code: 200; Time: 18ms; Content length: 32 bytes
    

    사용자 인증


    POST http://localhost:8080/api/authenticate?username=user&password=password
    
    HTTP/1.1 200 
    Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJpc3MiOiJzZWN1cmUtYXBpIiwiYXVkIjoic2VjdXJlLWFwcCIsInN1YiI6InVzZXIiLCJleHAiOjE1NDgyNDYwNzUsInJvbCI6WyJST0xFX1VTRVIiXX0.yhskhWyi-PgIluYY21rL0saAG92TfTVVVgVT1afWd_NnmOMg__2kK5lcna3lXzYI4-0qi9uGpI6Ul33-b9KTnA
    X-Content-Type-Options: nosniff
    X-XSS-Protection: 1; mode=block
    Cache-Control: no-cache, no-store, max-age=0, must-revalidate
    Pragma: no-cache
    Expires: 0
    X-Frame-Options: DENY
    Content-Length: 0
    Date: Sun, 13 Jan 2019 12:21:15 GMT
    
    <Response body is empty>
    
    Response code: 200; Time: 167ms; Content length: 0 bytes
    

    토큰을 사용하여 개인 API 요청


    GET http://localhost:8080/api/private
    Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJpc3MiOiJzZWN1cmUtYXBpIiwiYXVkIjoic2VjdXJlLWFwcCIsInN1YiI6InVzZXIiLCJleHAiOjE1NDgyNDI1ODksInJvbCI6WyJST0xFX1VTRVIiXX0.GzUPUWStRofrWI9Ctfv2h-XofGZwcOog9swtuqg1vSkA8kDWLcY3InVgmct7rq4ZU3lxI6CGupNgSazypHoFOA
    
    HTTP/1.1 200 
    X-Content-Type-Options: nosniff
    X-XSS-Protection: 1; mode=block
    Cache-Control: no-cache, no-store, max-age=0, must-revalidate
    Pragma: no-cache
    Expires: 0
    X-Frame-Options: DENY
    Content-Type: text/plain;charset=UTF-8
    Content-Length: 33
    Date: Sun, 13 Jan 2019 12:22:48 GMT
    
    Hello from private API controller
    
    Response code: 200; Time: 12ms; Content length: 33 bytes
    

    토큰이 없는 개인 API 요청


    유효한 JWT 없이 보안 엔드포인트를 호출하면 HTTP 403 메시지가 표시됩니다.
    GET http://localhost:8080/api/private
    
    HTTP/1.1 403 
    X-Content-Type-Options: nosniff
    X-XSS-Protection: 1; mode=block
    Cache-Control: no-cache, no-store, max-age=0, must-revalidate
    Pragma: no-cache
    Expires: 0
    X-Frame-Options: DENY
    Content-Type: application/json;charset=UTF-8
    Transfer-Encoding: chunked
    Date: Sun, 13 Jan 2019 12:27:25 GMT
    
    {
      "timestamp": "2019-01-13T12:27:25.020+0000",
      "status": 403,
      "error": "Forbidden",
      "message": "Access Denied",
      "path": "/api/private"
    }
    
    Response code: 403; Time: 28ms; Content length: 125 bytes
    

    결론


    이 문서의 목적은 Spring Security에서 JWTs를 사용하는 정확한 방법을 보여주는 것이 아닙니다.이것은 어떻게 실제 응용 프로그램에서 실현되는지의 예이다.또한 영패 갱신, 실효 등 내용이 매우 적기 때문에 나는 이 화제를 깊이 토론하고 싶지 않다. 그러나 나는 장래에 이런 화제를 토론할 것이다.
    tl;dr 이 예시 API의 전체 소스 코드를 my GitHub repository 에서 찾을 수 있습니다.

    좋은 웹페이지 즐겨찾기