使用JWT的Spring Security - JakubLeško

banq發表於2019-01-14

Spring Security的預設行為很容易用於標準Web應用程式。它使用基於cookie的身份驗證和會話。此外,它會自動為您處理CSRF令牌(防止中間人攻擊)。在大多數情況下,您只需要為特定路由設定授權許可權,這是透過從資料庫中檢索使用者的方式實現的。

另一方面,如果您只構建一個將與外部服務或SPA /移動應用程式一起使用的REST API,則可能不需要完整會話Session。這是JWT (JSON Web令牌) 一個小型數字簽名令牌的用途。所有需要的資訊都可以儲存在令牌中,因此您的伺服器可以實現無會話(no httpsession)。

JWT需要附加到每個HTTP請求,以便伺服器可以授權您的使用者。有一些選項如何傳送令牌。例如,作為URL引數或使用Bearer架構的HTTP Authorization標頭:

Authorization: Bearer <token string>

SON Web Token包含三個主要部分:
  1. 標頭 - 通常包括令牌型別和雜湊演算法。
  2. 有效負載 - 通常包括有關使用者的資料以及為其頒發令牌的資料。
  3. 簽名 - 它用於驗證訊息是否在此過程中未被更改

示例令牌
授權標頭中的JWT令牌可能如下所示:

Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJpc3MiOiJzZWN1cmUtYXBpIiwiYXVkIjoic2VjdXJlLWFwcCIsInN1YiI6InVzZXIiLCJleHAiOjE1NDgyNDI1ODksInJvbCI6WyJST0xFX1VTRVIiXX0.GzUPUWStRofrWI9Ctfv2h-XofGZwcOog9swtuqg1vSkA8kDWLcY3InVgmct7rq4ZU3lxI6CGupNgSazypHoFOA


三個部分用逗號分隔 - 標頭,宣告和簽名。標頭和有效負載宣告是Base64編碼的JSON物件。

Header:

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

有效負載宣告:

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


示例應用
在下面的示例中,我們將建立一個包含2個路由的簡單API - 一個公開可用,一個僅授權使用者。
我們將使用頁面start.spring.io來建立我們的應用程式框架並選擇安全性和Web依賴項。其餘選項取決於您的喜好。
JWT對Java的支援由庫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簽名金鑰硬編碼到您的應用程式程式碼中(我們將在示例中暫時忽略它)。您應該使用環境變數或.properties檔案。此外,鍵需要有適當的長度。例如,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";
}

第一個過濾器將直接用於使用者身份驗證。它將從URL檢查使用者名稱和密碼引數,並呼叫Spring的身份驗證管理器來驗證它們。
如果使用者名稱和密碼正確,則filter將建立一個JWT令牌並在HTTP Authorization標頭中返回它。

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) throws AuthenticationException {
        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請求,並檢查是否存在具有正確令牌的Authorization標頭。例如,如果令牌未過期或簽名金鑰正確。
如果令牌有效,那麼過濾器會將身份驗證資料新增到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);
        var header = request.getHeader(SecurityConstants.TOKEN_HEADER);

        if (StringUtils.isEmpty(header) || !header.startsWith(SecurityConstants.TOKEN_PREFIX)) {
            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)) {
            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 Security本身。配置很簡單,我們需要設定一些細節:

  • 密碼編碼器 - 在我們的例子中是bcrypt
  • CORS配置
  • 身份驗證管理器 - 在我們的例子中簡單的記憶體身份驗證,但在現實生活中,你需要像UserDetailsS​​ervice這樣的東西
  • 設定哪些端點是安全的以及哪些端點是公開可用的
  • 將2個過濾器新增到安全上下文中
  • 禁用會話管理 - 我們不需要會話,因此這將阻止會話cookie的建立

@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

結論
本文的目的不是展示如何在Spring Security中使用JWT的正確方法。這是一個如何在現實應用程式中執行此操作的示例。

GitHub儲存庫中找到此示例API的完整原始碼。

相關文章