微服務架構中的身份驗證問題 :JSON Web Tokens( JWT)

本文翻譯自:http://www.svlada.com/jwt-token-authentication-with-spring-boot/

場景介紹

軟件安全是一件很負責的問題,由於微服務系統中每個服務都要處理安全問題,所以在微服務場景下會更加複雜,一般我們會四種面向微服務系統的身份驗證方案。
在傳統的單體架構中,單個服務保存所有的用戶數據,可以校驗用戶,並在認證成功後創建HTTP會話。在微服務架構中,用戶是在和服務集合交互,每一個用戶都有可能需要知道請求的用戶是誰。一種簡單的方案是在微服務中,採用與單體系統中相同的模式,但問題是如何讓所有的服務訪問用戶的數據
解決這個問題大致2個思路:(1)使用共享數據庫時,更新數據庫表會成爲一個難題,因爲所有的服務必須同時升級以便能夠對接修改後的表解構;(2)將相同的數據分發給所有的服務時,當某個用戶已經被認證,如何讓每個服務都知曉這個狀態是個問題。

方案1:單點登錄(SSO)方案, 採用單點登錄方案,意味着每個面向用戶的服務都必須與認證服務交互,這會產生大量非常瑣碎的網絡流量,同時這個防範實現起來也相當的複雜。在其他方面,選擇SSO方案安全性會很好,用戶登錄狀態是不透明的,可防止攻擊者從狀態中推斷任何有用的信息。

方案2:分佈式會話方案,原理主要是將關於用戶信息存儲在共享內存中,並通常由用戶會話作爲key來實現簡單的分佈式哈希映射。當用戶訪問微服務時,用戶數據可以從共享存儲中獲取。該方案的另外一個優點就是用戶登錄狀態不是透明的。當使用分佈式數據庫時,它也是一個高度可用且可可擴展的解決方案。這種方案的優點是在於共享存儲需要一定保護機制,因此需要通過安全鏈接來訪問,這時解決方案的實現就通常具有相當高的負責性了。

方案3:token客戶端令牌方案,此令牌在客戶端生成,由身份驗證服務進行簽名,並且必須包含足夠的信息,以便可以在所有微服務中建立用戶身份。令牌會附加到每一個請求上,爲微服務提供身份驗證。這種解決方案安全性相對較好,但身份驗證註銷是一個大大的問題,緩解這種情況的方法可以使用短期令牌access_token 和頻繁檢查認證服務器等。對於客戶端令牌的編碼方案,可以使用 JSON Web Tokens( JWT), 它足夠簡單且支持程度也比較好。

方案4:客戶端令牌與API網關結合,這個方案意味着所有的請求都通過網關,從而有效地隱藏了微服務。在請求時,網關將原始用戶令牌轉換爲內部會話(session)ID令牌。在這種情況下,註銷就不在是個大大的問題, 因爲網關在註銷時可以撤銷用戶的令牌。這種方案雖然支持程度比較好,但是實現起來還是可能相當的複雜。

個人推薦方案:客戶端令牌(JWT) + API網關結合的方案,因爲這個方案通常使用起來比較容易,且性能也不錯。SSO方案雖然能滿足需求,但儘量避免使用,若分佈式會話方案所需要的相關技術已經應用在你的場景中,那麼這個方案也是比較有趣的。在考慮方案的時候,應該考慮註銷的重要性。

api網關,參考這篇,
http://geek.csdn.net/news/detail/104715
http://www.dockerinfo.net/773.html

JWT介紹

這篇文章將會知指導你如何用spring boot實現JWT的授權。
文章將會涉及到下面2個方面:
1. Ajax 授權
2. JWT token 授權

前提

請在你細讀本篇文章的時候,先看看Github 上的簡單項目:https://github.com/svlada/springboot-security-jwt
這個項目是使用H2 內存數據庫來存儲簡單的用戶信息。爲了讓事情變的更加簡單,我已經配置了spring boot在自動加載Application自動啓動的時候,已經創建了數據設備和配置了spring boot的相關配置(/ jwt-demo / src / main /resources/ data.sql)。

先預覽一下下面的項目結構:
+—main
| +—java
| | —com
| | —svlada
| | +—common
| | +—entity
| | +—profile
| | | —endpoint
| | +—security
| | | +—auth
| | | | +—ajax
| | | | —jwt
| | | | +—extractor
| | | | —verifier
| | | +—config
| | | +—endpoint
| | | +—exceptions
| | | —model
| | | —token
| | —user
| | +—repository
| | —service
| —resources
| +—static
| —templates

Ajax 授權

當我們在談論Ajax授權的時候,我們通常會聯想到的是用戶是通過JSON 的方式提供憑證,並以XMLHttpRequest 的方式發送的場景。
在本教程的第一部分,Ajax實現身份驗證是遵循Spring Security 框架的標準模式。
下面列表的東西,將是要我們去實現的:
1. AjaxLoginProcessingFilter
2. AjaxAuthenticationProvider
3. AjaxAwareAuthenticationSuccessHandler
4. AjaxAwareAuthenticationFailureHandler
5. RestAuthenticationEntryPoint
6. WebSecurityConfig

在我們實現這些細節的時候,讓我們細看一下,下面的 request/response 的授權流程。

Ajax 授權請求的例子

身份驗證API允許用戶傳入憑據,來獲得身份驗證令牌token。
在我們的例子中,客戶端啓動驗證過程通過調用身份驗證API(/API/auth/login)。
我們可以寫這樣的Http 請求:

POST /api/auth/login HTTP/1.1  
Host: localhost:9966  
X-Requested-With: XMLHttpRequest  
Content-Type: application/json  
Cache-Control: no-cache

{
    "username": "[email protected]",
    "password": "test1234"
}

終端可以用CURL:

curl -X POST -H "X-Requested-With: XMLHttpRequest" -H "Content-Type: application/json" -H "Cache-Control: no-cache" -d '{  
    "username": "[email protected]",
    "password": "test1234"
}' "http://localhost:9966/api/auth/login"

Ajax 授權相應的例子

如果客戶端請求的憑證被通過,授權API將會返回Http響應包括下面的一些細節:

1. Http 狀態 "200 OK"
2. 帶有 JWT的access_toke 和 refresh_token 都會在 response body中被包含了。

JWT Refresh token

用來獲取新的 access_token, 刷新token 可以用這樣的API來處理:/api/auth/token.(刷新可以用來防止access_token 的過期)

獲取的HTTP 響應格式:

{
  "token": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJzdmxhZGFAZ21haWwuY29tIiwic2NvcGVzIjpbIlJPTEVfQURNSU4iLCJST0xFX1BSRU1JVU1fTUVNQkVSIl0sImlzcyI6Imh0dHA6Ly9zdmxhZGEuY29tIiwiaWF0IjoxNDcyMDMzMzA4LCJleHAiOjE0NzIwMzQyMDh9.41rxtplFRw55ffqcw1Fhy2pnxggssdWUU8CDOherC0Kw4sgt3-rw_mPSWSgQgsR0NLndFcMPh7LSQt5mkYqROQ",

  "refreshToken": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJzdmxhZGFAZ21haWwuY29tIiwic2NvcGVzIjpbIlJPTEVfUkVGUkVTSF9UT0tFTiJdLCJpc3MiOiJodHRwOi8vc3ZsYWRhLmNvbSIsImp0aSI6IjkwYWZlNzhjLTFkMmUtNDg2OS1hNzdlLTFkNzU0YjYwZTBjZSIsImlhdCI6MTQ3MjAzMzMwOCwiZXhwIjoxNDcyMDM2OTA4fQ.SEEG60YRznBB2O7Gn_5X6YbRmyB3ml4hnpSOxqkwQUFtqA6MZo7_n2Am2QhTJBJA1Ygv74F2IxiLv0urxGLQjg"
}

JWT Access Token

JWT訪問令牌可用於身份驗證和授權:
1. 身份驗證是由驗證JWT訪問令牌簽名。如果簽名是有效的,訪問API請求的資源是理所當然。
2. 授權是通過查找特權JWT scope屬性的值來判斷。(譯者:scope,一般會是版本號或平臺,安卓,ios,wap等或不同的系統的id, 具體看自家的場景和需求).

解碼JWT token有三個部分:Header(請求頭), Claims(要求) and Signature(簽名)

Header

{
    "alg": "HS512"
}

Claims(要求):

{
  "sub": "[email protected]",
  "scopes": [
    "ROLE_ADMIN",
    "ROLE_PREMIUM_MEMBER"
  ],
  "iss": "http://svlada.com",
  "iat": 1472033308,
  "exp": 1472034208
}

簽名base64 encoded)

41rxtplFRw55ffqcw1Fhy2pnxggssdWUU8CDOherC0Kw4sgt3-rw_mPSWSgQgsR0NLndFcMPh7LSQt5mkYqROQ  

JWT Refresh Token

Refresh token 是長壽令牌用於請求新的訪問令牌。Refresh token過期時間是超過access_token的過期時間。
在本次教程中,我們將使用 jti 聲稱來維持黑名單,或撤銷令牌列表。JWT ID(jti) 聲稱被 RFC7519 定義了,目的是爲了唯一地標識單個刷新令牌。

解碼刷新令牌有三個部分: Header(請求頭), Claims(要求), Signature(簽名) 如下所示:
Header:

{
  "alg": "HS512"
}

Claims:

{
  "sub": "[email protected]",
  "scopes": [
    "ROLE_REFRESH_TOKEN"
  ],
  "iss": "http://svlada.com",
  "jti": "90afe78c-1d2e-4869-a77e-1d754b60e0ce",
  "iat": 1472033308,
  "exp": 1472036908
}

Signature (base64 encoded)

SEEG60YRznBB2O7Gn_5X6YbRmyB3ml4hnpSOxqkwQUFtqA6MZo7_n2Am2QhTJBJA1Ygv74F2IxiLv0urxGLQjg  

AjaxLoginProcessingFilter( Ajax 登錄處理過濾器)

首先,需要繼承AbstractAuthenticationProcessingFilter, 目的是爲了提供一般常用的Ajax 身份驗證請求。反序列化JSON和基本驗證的主要任務都是在的。AjaxLoginProcessingFilter#attemptAuthentication這個方法裏完成的。在成功驗證JSON的主要檢驗邏輯是委託給AjaxAuthenticationProvider類實現。

在一個成功校驗中, AjaxLoginProcessingFilter#successfulAuthentication 方法會被調用。
在一個失敗的檢驗中,AjaxLoginProcessingFilter#unsuccessfulAuthentication 方法被調用。

public class AjaxLoginProcessingFilter extends AbstractAuthenticationProcessingFilter {  
    private static Logger logger = LoggerFactory.getLogger(AjaxLoginProcessingFilter.class);

    private final AuthenticationSuccessHandler successHandler;
    private final AuthenticationFailureHandler failureHandler;

    private final ObjectMapper objectMapper;

    public AjaxLoginProcessingFilter(String defaultProcessUrl, AuthenticationSuccessHandler successHandler, 
            AuthenticationFailureHandler failureHandler, ObjectMapper mapper) {
        super(defaultProcessUrl);
        this.successHandler = successHandler;
        this.failureHandler = failureHandler;
        this.objectMapper = mapper;
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
            throws AuthenticationException, IOException, ServletException {
        if (!HttpMethod.POST.name().equals(request.getMethod()) || !WebUtil.isAjax(request)) {
            if(logger.isDebugEnabled()) {
                logger.debug("Authentication method not supported. Request method: " + request.getMethod());
            }
            throw new AuthMethodNotSupportedException("Authentication method not supported");
        }

        LoginRequest loginRequest = objectMapper.readValue(request.getReader(), LoginRequest.class);

        if (StringUtils.isBlank(loginRequest.getUsername()) || StringUtils.isBlank(loginRequest.getPassword())) {
            throw new AuthenticationServiceException("Username or Password not provided");
        }

        UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(loginRequest.getUsername(), loginRequest.getPassword());

        return this.getAuthenticationManager().authenticate(token);
    }

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
            Authentication authResult) throws IOException, ServletException {
        successHandler.onAuthenticationSuccess(request, response, authResult);
    }

    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
            AuthenticationException failed) throws IOException, ServletException {
        SecurityContextHolder.clearContext();
        failureHandler.onAuthenticationFailure(request, response, failed);
    }
}

AjaxAuthenticationProvider

AjaxAuthenticationProvider類的責任是:
1. 對用戶憑證與 數據庫、LDAP或其他系統用戶數據,進行驗證。
2. 如果用戶名和密碼不匹配數據庫中的記錄,身份驗證異常將會被拋出。
3. 創建用戶上下文,你需要一些你需要的用戶數據來填充(例如 用戶名 和用戶密碼)
4. 在成功驗證委託創建JWT令牌的是在* AjaxAwareAuthenticationSuccessHandler* 中實現。

@Component
public class AjaxAuthenticationProvider implements AuthenticationProvider {  
    private final BCryptPasswordEncoder encoder;
    private final DatabaseUserService userService;

    @Autowired
    public AjaxAuthenticationProvider(final DatabaseUserService userService, final BCryptPasswordEncoder encoder) {
        this.userService = userService;
        this.encoder = encoder;
    }

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        Assert.notNull(authentication, "No authentication data provided");

        String username = (String) authentication.getPrincipal();
        String password = (String) authentication.getCredentials();

        User user = userService.getByUsername(username).orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));

        if (!encoder.matches(password, user.getPassword())) {
            throw new BadCredentialsException("Authentication Failed. Username or Password not valid.");
        }

        if (user.getRoles() == null) throw new InsufficientAuthenticationException("User has no roles assigned");

        List<GrantedAuthority> authorities = user.getRoles().stream()
                .map(authority -> new SimpleGrantedAuthority(authority.getRole().authority()))
                .collect(Collectors.toList());

        UserContext userContext = UserContext.create(user.getUsername(), authorities);

        return new UsernamePasswordAuthenticationToken(userContext, null, userContext.getAuthorities());
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return (UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication));
    }
}

AjaxAwareAuthenticationSuccessHandler

我們將實現AuthenticationSuccessHandler接口時,稱爲客戶端已成功進行身份驗證。
AjaxAwareAuthenticationSuccessHandler AuthenticationSuccessHandler接口的類是我們的自定義實現。這個類的責任是添加JSON載荷包含JWT訪問和刷新令牌到HTTP響應的body。

@Component
public class AjaxAwareAuthenticationSuccessHandler implements AuthenticationSuccessHandler {  
    private final ObjectMapper mapper;
    private final JwtTokenFactory tokenFactory;

    @Autowired
    public AjaxAwareAuthenticationSuccessHandler(final ObjectMapper mapper, final JwtTokenFactory tokenFactory) {
        this.mapper = mapper;
        this.tokenFactory = tokenFactory;
    }

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
            Authentication authentication) throws IOException, ServletException {
        UserContext userContext = (UserContext) authentication.getPrincipal();

        JwtToken accessToken = tokenFactory.createAccessJwtToken(userContext);
        JwtToken refreshToken = tokenFactory.createRefreshToken(userContext);

        Map<String, String> tokenMap = new HashMap<String, String>();
        tokenMap.put("token", accessToken.getToken());
        tokenMap.put("refreshToken", refreshToken.getToken());

        response.setStatus(HttpStatus.OK.value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        mapper.writeValue(response.getWriter(), tokenMap);

        clearAuthenticationAttributes(request);
    }

    /**
     * Removes temporary authentication-related data which may have been stored
     * in the session during the authentication process..
     * 
     */
    protected final void clearAuthenticationAttributes(HttpServletRequest request) {
        HttpSession session = request.getSession(false);

        if (session == null) {
            return;
        }

        session.removeAttribute(WebAttributes.AUTHENTICATION_EXCEPTION);
    }
}

讓我們關注一下如何創建JWT訪問令牌。在本教程中,我們使用Java JWT, Stormpath這個人創建的庫。

<dependency>  
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>${jjwt.version}</version>
</dependency>  

我們已經創建了工廠類(JwtTokenFactory)分離令牌創建邏輯。
方法JwtTokenFactory # createAccessJwtToken創建簽署了JWT訪問令牌。
方法JwtTokenFactory # createRefreshToken創建簽署了JWT刷新令牌。

@Component
public class JwtTokenFactory {  
    private final JwtSettings settings;

    @Autowired
    public JwtTokenFactory(JwtSettings settings) {
        this.settings = settings;
    }

    /**
     * Factory method for issuing new JWT Tokens.
     * 
     * @param username
     * @param roles
     * @return
     */
    public AccessJwtToken createAccessJwtToken(UserContext userContext) {
        if (StringUtils.isBlank(userContext.getUsername())) 
            throw new IllegalArgumentException("Cannot create JWT Token without username");

        if (userContext.getAuthorities() == null || userContext.getAuthorities().isEmpty()) 
            throw new IllegalArgumentException("User doesn't have any privileges");

        Claims claims = Jwts.claims().setSubject(userContext.getUsername());
        claims.put("scopes", userContext.getAuthorities().stream().map(s -> s.toString()).collect(Collectors.toList()));

        DateTime currentTime = new DateTime();

        String token = Jwts.builder()
          .setClaims(claims)
          .setIssuer(settings.getTokenIssuer())
          .setIssuedAt(currentTime.toDate())
          .setExpiration(currentTime.plusMinutes(settings.getTokenExpirationTime()).toDate())
          .signWith(SignatureAlgorithm.HS512, settings.getTokenSigningKey())
        .compact();

        return new AccessJwtToken(token, claims);
    }

    public JwtToken createRefreshToken(UserContext userContext) {
        if (StringUtils.isBlank(userContext.getUsername())) {
            throw new IllegalArgumentException("Cannot create JWT Token without username");
        }

        DateTime currentTime = new DateTime();

        Claims claims = Jwts.claims().setSubject(userContext.getUsername());
        claims.put("scopes", Arrays.asList(Scopes.REFRESH_TOKEN.authority()));

        String token = Jwts.builder()
          .setClaims(claims)
          .setIssuer(settings.getTokenIssuer())
          .setId(UUID.randomUUID().toString())
          .setIssuedAt(currentTime.toDate())
          .setExpiration(currentTime.plusMinutes(settings.getRefreshTokenExpTime()).toDate())
          .signWith(SignatureAlgorithm.HS512, settings.getTokenSigningKey())
        .compact();

        return new AccessJwtToken(token, claims);
    }
}

AjaxAwareAuthenticationFailureHandler

AjaxAwareAuthenticationFailureHandle 是AjaxLoginProcessingFilter調用身份驗證失敗時,被調用的函數。
你可以設計基於異常類型特定的錯誤消息身份驗證過程中發生的異常。

@Component
public class AjaxAwareAuthenticationFailureHandler implements AuthenticationFailureHandler {  
    private final ObjectMapper mapper;

    @Autowired
    public AjaxAwareAuthenticationFailureHandler(ObjectMapper mapper) {
        this.mapper = mapper;
    }   

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
            AuthenticationException e) throws IOException, ServletException {

        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);

        if (e instanceof BadCredentialsException) {
            mapper.writeValue(response.getWriter(), ErrorResponse.of("Invalid username or password", ErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED));
        } else if (e instanceof JwtExpiredTokenException) {
            mapper.writeValue(response.getWriter(), ErrorResponse.of("Token has expired", ErrorCode.JWT_TOKEN_EXPIRED, HttpStatus.UNAUTHORIZED));
        } else if (e instanceof AuthMethodNotSupportedException) {
            mapper.writeValue(response.getWriter(), ErrorResponse.of(e.getMessage(), ErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED));
        }

        mapper.writeValue(response.getWriter(), ErrorResponse.of("Authentication failed", ErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED));
    }
}

JWT Authentication

基於身份驗證模式的token(身份令牌),已經成爲近年來非常流行的驗證模式,他相比較於session/cookie, token能提供更加重要的好處。
1. CORS。
2. 不需要CSRF的保護。
3. 更好的和移動端進行集成。
4. 減少了授權服務器的負載。
5. 不再需要分佈式會話的存儲。
有一些交互操作會用這種方式需要權衡的地方:
1. 更容易受到XSS攻擊
2. 訪問令牌可以包含過時的授權聲明(e。g當一些用戶權限撤銷)
3. 在claims 的數在曾長的時候,Access token 也能在一定程度上增長。
4. 文件下載API難以實現的。
5. 無狀態和撤銷是互斥的。

在本文中,我們將探討如何JWT的可以用於基於令牌的身份驗證。
JWT 的授權流程是非常的簡單的:
1. 用戶可以向授權服務器提供憑證來獲取刷新和訪問令牌(refresh_token 和 access_token).
2. 用戶發送每一個請求去訪問受保護的資源的時候都需要access token 做爲參數。
3. 問令牌是簽名,幷包含用戶身份(例如,用戶id)和授權聲明。
這是非常重要的,注意到授權聲明將包含Access token. 爲什麼他是如此之重要?很好,讓我們來先說說授權聲明吧(例如:在數據庫中用戶權限)被改變了,在Access token 還有效的這個時間週期裏,這些更改不會生效,直到新的訪問令牌。在更多的情況下,這並不是一個很大的問題,因爲Access token 擁有很短的生命週期。 否則就是不透明的標記模式。

在我們探索實現細節之前,讓我們先看看一個被保護的資源的API的簡單請求。

Signed request to protected API resource

當發送 access token時,下面的一些樣式將會被使用,Bearer。 在我們的例子中,header 的名字(),我們將會使用 X-Authorization.
Raw HTTP request:

GET /api/me HTTP/1.1  
Host: localhost:9966  
X-Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJzdmxhZGFAZ21haWwuY29tIiwic2NvcGVzIjpbIlJPTEVfQURNSU4iLCJST0xFX1BSRU1JVU1fTUVNQkVSIl0sImlzcyI6Imh0dHA6Ly9zdmxhZGEuY29tIiwiaWF0IjoxNDcyMzkwMDY1LCJleHAiOjE0NzIzOTA5NjV9.Y9BR7q3f1npsSEYubz-u8tQ8dDOdBcVPFN7AIfWwO37KyhRugVzEbWVPO1obQlHNJWA0Nx1KrEqHqMEjuNWo5w  
Cache-Control: no-cache 

CURL:

curl -X GET -H "X-Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJzdmxhZGFAZ21haWwuY29tIiwic2NvcGVzIjpbIlJPTEVfQURNSU4iLCJST0xFX1BSRU1JVU1fTUVNQkVSIl0sImlzcyI6Imh0dHA6Ly9zdmxhZGEuY29tIiwiaWF0IjoxNDcyMzkwMDY1LCJleHAiOjE0NzIzOTA5NjV9.Y9BR7q3f1npsSEYubz-u8tQ8dDOdBcVPFN7AIfWwO37KyhRugVzEbWVPO1obQlHNJWA0Nx1KrEqHqMEjuNWo5w" -H "Cache-Control: no-cache" "http://localhost:9966/api/me"  

讓我們來看一下下面的細節,下面的一些組件,我們需要實現JWT 的身份驗證:
1. JwtTokenAuthenticationProcessingFilter
2. JwtAuthenticationProvider
3. SkipPathRequestMatcher
4. JwtHeaderTokenExtractor
5. BloomFilterTokenVerifier
6. WebSecurityConfig

JwtTokenAuthenticationProcessingFilter

JwtTokenAuthenticationProcessingFilter 過濾器 被應用到了每一個API(/api/**) 異常的刷新令牌端點(/api/auth/token)以及 login 點(/api/auth/login)。
這個過濾器擁有一下的一些職責:
1. 檢查訪問令牌在X-Authorization頭。如果發現訪問令牌的頭,委託認證JwtAuthenticationProvider否則拋出身份驗證異常
2. 調用成功或失敗策略基於由JwtAuthenticationProvider執行身份驗證過程的結果

確保chain.doFilter(request, response) 被調用,成功的驗證了身份。你想在下一個處理器中,優先處理這些請求, 因爲最後一個過濾器 FilterSecurityInterceptor#doFilter
會響應的實際調用方法是在Controller 中的處理訪問API 資源的方法。

public class JwtTokenAuthenticationProcessingFilter extends AbstractAuthenticationProcessingFilter {  
    private final AuthenticationFailureHandler failureHandler;
    private final TokenExtractor tokenExtractor;

    @Autowired
    public JwtTokenAuthenticationProcessingFilter(AuthenticationFailureHandler failureHandler, 
            TokenExtractor tokenExtractor, RequestMatcher matcher) {
        super(matcher);
        this.failureHandler = failureHandler;
        this.tokenExtractor = tokenExtractor;
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
            throws AuthenticationException, IOException, ServletException {
        String tokenPayload = request.getHeader(WebSecurityConfig.JWT_TOKEN_HEADER_PARAM);
        RawAccessJwtToken token = new RawAccessJwtToken(tokenExtractor.extract(tokenPayload));
        return getAuthenticationManager().authenticate(new JwtAuthenticationToken(token));
    }

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
            Authentication authResult) throws IOException, ServletException {
        SecurityContext context = SecurityContextHolder.createEmptyContext();
        context.setAuthentication(authResult);
        SecurityContextHolder.setContext(context);
        chain.doFilter(request, response);
    }

    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
            AuthenticationException failed) throws IOException, ServletException {
        SecurityContextHolder.clearContext();
        failureHandler.onAuthenticationFailure(request, response, failed);
    }
}

JwtHeaderTokenExtractor

JwtHeaderTokenExtractor 是一個非常簡單的類,通常用來擴展來處理身份檢驗的處理。
你可以擴展TokenExtractor 接口 和 提供你常用的一些實現。例如從URL中提取標記。

@Component
public class JwtHeaderTokenExtractor implements TokenExtractor {  
    public static String HEADER_PREFIX = "Bearer ";

    @Override
    public String extract(String header) {
        if (StringUtils.isBlank(header)) {
            throw new AuthenticationServiceException("Authorization header cannot be blank!");
        }

        if (header.length() < HEADER_PREFIX.length()) {
            throw new AuthenticationServiceException("Invalid authorization header size.");
        }
        return header.substring(HEADER_PREFIX.length(), header.length());
    }
}

JwtAuthenticationProvider

JwtAuthenticationProvider 擁有一下的一些職責:
1. 驗證 access token 的簽名
2. 從訪問令牌中提取身份和授權聲明和使用它們來創建UserContext
3. 如果訪問令牌是畸形的,過期的或者只是如果令牌不簽署與適當的簽名密鑰身份驗證就會拋出異常

@Component
public class JwtAuthenticationProvider implements AuthenticationProvider {  
    private final JwtSettings jwtSettings;

    @Autowired
    public JwtAuthenticationProvider(JwtSettings jwtSettings) {
        this.jwtSettings = jwtSettings;
    }

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        RawAccessJwtToken rawAccessToken = (RawAccessJwtToken) authentication.getCredentials();

        Jws<Claims> jwsClaims = rawAccessToken.parseClaims(jwtSettings.getTokenSigningKey());
        String subject = jwsClaims.getBody().getSubject();
        List<String> scopes = jwsClaims.getBody().get("scopes", List.class);
        List<GrantedAuthority> authorities = scopes.stream()
                .map(authority -> new SimpleGrantedAuthority(authority))
                .collect(Collectors.toList());

        UserContext context = UserContext.create(subject, authorities);

        return new JwtAuthenticationToken(context, context.getAuthorities());
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return (JwtAuthenticationToken.class.isAssignableFrom(authentication));
    }
}

SkipPathRequestMatcher

JwtTokenAuthenticationProcessingFilter 過濾器被配置爲跳過這個點:/api/auth/login 和 /api/auth/token . 這三個通過 SkipPathRequestMatcher 實現 RequestMatcher 接口來實現。

public class SkipPathRequestMatcher implements RequestMatcher {  
    private OrRequestMatcher matchers;
    private RequestMatcher processingMatcher;

    public SkipPathRequestMatcher(List<String> pathsToSkip, String processingPath) {
        Assert.notNull(pathsToSkip);
        List<RequestMatcher> m = pathsToSkip.stream().map(path -> new AntPathRequestMatcher(path)).collect(Collectors.toList());
        matchers = new OrRequestMatcher(m);
        processingMatcher = new AntPathRequestMatcher(processingPath);
    }

    @Override
    public boolean matches(HttpServletRequest request) {
        if (matchers.matches(request)) {
            return false;
        }
        return processingMatcher.matches(request) ? true : false;
    }
}

WebSecurityConfig

WebSecurityConfig 類 繼承 WebSecurityConfigurerAdapter 去提供常用的security configuration .
下面的Beans 類被配置和實例化了:
1. AjaxLoginProcessingFilter
2. JwtTokenAuthenticationProcessingFilter
3. AuthenticationManager
4. BCryptPasswordEncoder
同時,在 WebSecurityConfig#configure(HttpSecurity http) 方法中,我們將配置樣式去定義 被保護/非被保護的API節點。請注意,我們已經不能用CSRF保護了,因爲我們並沒有使用Cookies.

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {  
    public static final String JWT_TOKEN_HEADER_PARAM = "X-Authorization";
    public static final String FORM_BASED_LOGIN_ENTRY_POINT = "/api/auth/login";
    public static final String TOKEN_BASED_AUTH_ENTRY_POINT = "/api/**";
    public static final String TOKEN_REFRESH_ENTRY_POINT = "/api/auth/token";

    @Autowired private RestAuthenticationEntryPoint authenticationEntryPoint;
    @Autowired private AuthenticationSuccessHandler successHandler;
    @Autowired private AuthenticationFailureHandler failureHandler;
    @Autowired private AjaxAuthenticationProvider ajaxAuthenticationProvider;
    @Autowired private JwtAuthenticationProvider jwtAuthenticationProvider;

    @Autowired private TokenExtractor tokenExtractor;

    @Autowired private AuthenticationManager authenticationManager;

    @Autowired private ObjectMapper objectMapper;

    @Bean
    protected AjaxLoginProcessingFilter buildAjaxLoginProcessingFilter() throws Exception {
        AjaxLoginProcessingFilter filter = new AjaxLoginProcessingFilter(FORM_BASED_LOGIN_ENTRY_POINT, successHandler, failureHandler, objectMapper);
        filter.setAuthenticationManager(this.authenticationManager);
        return filter;
    }

    @Bean
    protected JwtTokenAuthenticationProcessingFilter buildJwtTokenAuthenticationProcessingFilter() throws Exception {
        List<String> pathsToSkip = Arrays.asList(TOKEN_REFRESH_ENTRY_POINT, FORM_BASED_LOGIN_ENTRY_POINT);
        SkipPathRequestMatcher matcher = new SkipPathRequestMatcher(pathsToSkip, TOKEN_BASED_AUTH_ENTRY_POINT);
        JwtTokenAuthenticationProcessingFilter filter 
            = new JwtTokenAuthenticationProcessingFilter(failureHandler, tokenExtractor, matcher);
        filter.setAuthenticationManager(this.authenticationManager);
        return filter;
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    protected void configure(AuthenticationManagerBuilder auth) {
        auth.authenticationProvider(ajaxAuthenticationProvider);
        auth.authenticationProvider(jwtAuthenticationProvider);
    }

    @Bean
    protected BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
        .csrf().disable() // We don't need CSRF for JWT based authentication
        .exceptionHandling()
        .authenticationEntryPoint(this.authenticationEntryPoint)

        .and()
            .sessionManagement()
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS)

        .and()
            .authorizeRequests()
                .antMatchers(FORM_BASED_LOGIN_ENTRY_POINT).permitAll() // Login end-point
                .antMatchers(TOKEN_REFRESH_ENTRY_POINT).permitAll() // Token refresh end-point
                .antMatchers("/console").permitAll() // H2 Console Dash-board - only for testing
        .and()
            .authorizeRequests()
                .antMatchers(TOKEN_BASED_AUTH_ENTRY_POINT).authenticated() // Protected API End-points
        .and()
            .addFilterBefore(buildAjaxLoginProcessingFilter(), UsernamePasswordAuthenticationFilter.class)
            .addFilterBefore(buildJwtTokenAuthenticationProcessingFilter(), UsernamePasswordAuthenticationFilter.class);
    }
}

BloomFilterTokenVerifier

這是虛擬類。你應該實現自己的TokenVerifier檢查撤銷令牌。

@Component
public class BloomFilterTokenVerifier implements TokenVerifier {  
    @Override
    public boolean verify(String jti) {
        return true;
    }
}

結論

我在網絡上聽到有人竊竊私語,失去JWT令牌就像失去你的房子的鑰匙。所以要小心。

補充

在上述講到的JWT使用的方案中,j w t無法做到註銷操作。爲此我們未來填補JWT在這方面的缺陷,我們引入了redis。當用戶註銷時,可以把還在時效內的token放到redis中,並設置redis該token正確的失效時間。當用戶token訪問時,先查詢redis,看是否存在註銷的token,有就重新登陸。

一、JWT認證方式的實現方式 :
1.客戶端不需要持有密鑰,由服務端通過密鑰生成Token。
2.客戶端登錄時通過賬號和密碼到服務端進行認證,認證通過後,服務端通過持有的密鑰生成Token,Token中一般包含失效時長和用戶唯一標識,如用戶ID,服務端返回Token給客戶端。
3.客戶端保存服務端返回的Token。
4.客戶端進行業務請求時在Head的Authorization字段裏面放置Token,如:
Authorization: Bearer Token
5.服務端對請求的Token進行校驗,並通過Redis查找Token是否存在,主要是爲了解決用戶註銷,但Token還在時效內的問題,如果Token在Redis中存在,則說明用戶已註銷;如果Token不存在,則校驗通過。
6.服務端可以通過從Token取得的用戶唯一標識進行相關權限的校驗,並把此用戶標識賦予到請求參數中,業務可通過此用戶標識進行業務處理。
7.用戶註銷時,服務端需要把還在時效內的Token保存到Redis中,並設置正確的失效時長。
這裏寫圖片描述

二、在實際環境中如何使用JWT
1.Web應用程序
在令牌過期前刷新令牌。如設置令牌的過期時間爲一個星期,每次用戶打開Web應用程序,服務端每隔一小時生成一個新令牌。如果用戶一個多星期沒有打開應用,他們將不得不再次登錄。
2.移動應用程序
大多數移動應用程序用戶只進行一次登錄,定期刷新令牌可以使用戶長期不用登錄。
但如果用戶的手機丟失,則可提供一種方式由用戶決定撤銷哪個設備的令牌。當然,這就需要服務端記錄設備的名稱,例如“maryo的iPad”。然後用戶可以去申請並撤銷獲得“maryo的iPad”。當用戶修改密碼時需要服務端把原Token保存到Redis上,使其失效。
爲了防止Token被竊取,最好把JWT和HTTPS結合起來使用。

三、如何實現安全認證與權限的結合
服務端生成的Token中需要包含用戶唯一標識,這樣用戶進行業務請求時,服務端通過附帶的Token獲取用戶唯一標識,通過此標識進行權限檢查。

四、更換Token
爲了解決高併發訪問時更換Token, 有可能造成用舊的Token的訪問失敗。 在緩存中不保存Token,而是保存一個計數,每次更換Token時,計數加1,這個計數的值會跟用戶ID一起加密後保存在新生成的Token中,返回給用戶,用戶每次訪問時攜帶這個Token。驗證用戶Token時,用Token中的計數與緩存中保存的計數比較,如果差值範圍在1~2之間就認爲Token有效,這樣即使在併發訪問時,更換Token,計數值雖然不等,但在規定的差值範圍內,也被認爲有效,這樣就解決了上面的Token失效問題。

參考

I don’t see the point in Revoking or Blacklisting JWT
Spring Security Architecture - Dave Syer
Invalidating JWT
Secure and stateless JWT implementation
Learn JWT
Opaque access tokens and cloud foundry
The unspoken vulnerability of JWTS
How To Control User Identity Within Micro-services
Why Does OAuth v2 Have Both Access and Refresh Tokens?RFC-6749
Are breaches of JWT-based servers more damaging?

https://github.com/svlada/springboot-security-jwt/tree/master/src
https://stormpath.com/blog/token-auth-for-java
https://stormpath.com/blog/fun-with-java-spring-boot-token-management
https://github.com/nielsutrecht/jwt-angular-spring
http://www.svlada.com/jwt-token-authentication-with-spring-boot/
https://github.com/svlada/springboot-security-jwt/tree/master/src
https://spring.io/guides/tutorials/spring-boot-oauth2/
https://stormpath.com/blog/fun-with-java-spring-boot-token-management

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章