譯見|構建使用者管理微服務(五):使用 JWT 令牌和 Spring Security 來實現身份驗證

DaoCloud發表於2019-03-04

譯見|構建使用者管理微服務(五):使用 JWT 令牌和 Spring Security 來實現身份驗證

往期「譯見」系列文章在賬號分享中持續連載,敬請檢視~

在往期「譯見」系列的文章中,我們已經建立了業務邏輯、資料訪問層和前端控制器, 但是忽略了對身份進行驗證。隨著 Spring Security 成為實際意義上的標準, 將會在在構建 Java web 應用程式的身份驗證和授權時使用到它。在構建使用者管理微服務系列的第五部分中, 將帶您探索 Spring Security 是如何同 JWT 令牌一起使用的。


有關 Token | 船長導語

諸如 Facebook,Github,Twitter 等大型網站都在使用基於 Token 的身份驗證。相比傳統的身份驗證方法,Token 的擴充套件性更強,也更安全,非常適合用在 Web 應用或者移動應用上。我們將 Token 翻譯成令牌,也就意味著,你能依靠這個令牌去通過一些關卡,來實現驗證。實施 Token 驗證的方法很多,JWT 就是相關標準方法中的一種。


關於 JWT 令牌


JSON Web TOKEN(JWT)是一個開放的標準 (RFC 7519), 它定義了一種簡潔且獨立的方式, 讓在各方之間的 JSON 物件安全地傳輸資訊。而經過數字簽名的資訊也可以被驗證和信任。

JWT 的應用越來越廣泛, 而因為它是輕量級的,你也不需要有一個用來驗證令牌的認證伺服器。與 OAuth 相比, 這有利有弊。如果 JWT 令牌被截獲,它可以用來模擬使用者, 也無法防範使用這個被截獲的令牌繼續進行身份驗證。

真正的 JWT 令牌看起來像下面這樣:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiJsYXN6bG9fQVRfc3ByaW5ndW5pX0RPVF9jb20iLCJuYW1lIjoiTMOhc3psw7MgQ3NvbnRvcyIsImFkbWluIjp0cnVlfQ.
XEfFHwFGK0daC80EFZBB5ki2CwrOb7clGRGlzchAD84複製程式碼


JWT 令牌的第一部分是令牌的 header , 用於標識令牌的型別和對令牌進行簽名的演算法。


{
 "alg": "HS256", "typ": "JWT"
 
}複製程式碼


第二部分是 JWT 令牌的 payload 或它的宣告。這兩者是有區別的。Payload 可以是任意一組資料, 它甚至可以是明文或其他 (嵌入 JWT)的資料。而宣告則是一組標準的欄位。


{ 
 "sub": "laszlo_AT_springuni_DOT_com", "name": "László Csontos", "admin": true
 
}複製程式碼


第三部分是由演算法產生的、由 JWT 的 header 表示的簽名。


建立和驗證 JWT 令牌


有相當多的第三方庫可用於操作 JWT 令牌。而在本文中, 我使用了 JJWT。


<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.7.0</version>
</dependency>複製程式碼


採用 JwtTokenService 使 JWT 令牌從身份驗證例項中建立, 並將 JWTs 解析回身份驗證例項。


public class JwtTokenServiceImpl implements JwtTokenService {  

private static final String AUTHORITIES = "authorities";  

static final String SECRET = "ThisIsASecret";

  @Override  
public String createJwtToken(Authentication authentication, int minutes) {
    Claims claims = Jwts.claims()
        .setId(String.valueOf(IdentityGenerator.generate()))
        .setSubject(authentication.getName())
        .setExpiration(new Date(currentTimeMillis() + minutes * 60 * 1000))
        .setIssuedAt(new Date());

    String authorities = authentication.getAuthorities()
        .stream()
        .map(GrantedAuthority::getAuthority)
        .map(String::toUpperCase)
        .collect(Collectors.joining(","));

    claims.put(AUTHORITIES, authorities);    

return Jwts.builder()
        .setClaims(claims)
        .signWith(HS512, SECRET)
        .compact();
  }

  @Override  
public Authentication parseJwtToken(String jwtToken) throws AuthenticationException {    
try {
      Claims claims = Jwts.parser()
            .setSigningKey(SECRET)
            .parseClaimsJws(jwtToken)
            .getBody();      
return JwtAuthenticationToken.of(claims);
    } catch (ExpiredJwtException | SignatureException e) {      
throw new BadCredentialsException(e.getMessage(), e);
    } catch (UnsupportedJwtException | MalformedJwtException e) {      
throw new AuthenticationServiceException(e.getMessage(), e);
    } catch (IllegalArgumentException e) {      
throw new InternalAuthenticationServiceException(e.getMessage(), e);
    }
  }

}複製程式碼


根據實際的驗證,parseClaimsJws () 會引發各種異常。在 parseJwtToken () 中, 引發的異常被轉換回 AuthenticationExceptions。雖然 JwtAuthenticationEntryPoint 能將這些異常轉換為各種 HTTP 的響應程式碼, 但它也只是重複 DefaultAuthenticationFailureHandler 來以 http 401 (未經授權) 響應。


登入和身份驗證過程


基本上, 認證過程有兩個短語, 讓後端將服務用於單頁面 web 應用程式。


登入時建立 JWT 令牌


第一次登入變完成啟動, 且在這一過程中, 將建立一個 JWT 令牌並將其傳送回客戶端。這些是通過以下請求完成的:


POST /session
{   
  "username": "laszlo_AT_sprimguni_DOT_com",
   "password": "secret"
}複製程式碼

成功登入後, 客戶端會像往常一樣向其他端點傳送後續請求, 並在授權的 header 中提供本地快取的 JWT 令牌。


Authorization: Bearer <JWT token>複製程式碼

譯見|構建使用者管理微服務(五):使用 JWT 令牌和 Spring Security 來實現身份驗證


正如上面的步驟所講, LoginFilter 開始進行登入過程。而Spring Security 的內建 UsernamePasswordAuthenticationFilter 被延長, 來讓這種情況發生。這兩者之間的唯一的區別是, UsernamePasswordAuthenticationFilter 使用表單引數來捕獲使用者名稱和密碼, 相比之下, LoginFilter 將它們視做 JSON 物件。


import org.springframework.security.authentication.*;
import org.springframework.security.core.*;
import org.springframework.security.web.authentication.*;

public class LoginFilter extends UsernamePasswordAuthenticationFilter {  
private static final String LOGIN_REQUEST_ATTRIBUTE = "login_request";

  ...

  @Override  
public Authentication attemptAuthentication(
      HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {    
try {
      LoginRequest loginRequest =
          objectMapper.readValue(request.getInputStream(), LoginRequest.class);

      request.setAttribute(LOGIN_REQUEST_ATTRIBUTE, loginRequest);      
return super.attemptAuthentication(request, response);
    } catch (IOException ioe) {      
throw new InternalAuthenticationServiceException(ioe.getMessage(), ioe);
    } finally {
      request.removeAttribute(LOGIN_REQUEST_ATTRIBUTE);
    }
  }

  @Override  
protected String obtainUsername(HttpServletRequest request) {    
return toLoginRequest(request).getUsername();
  }

  @Override  
protected String obtainPassword(HttpServletRequest request) {    
return toLoginRequest(request).getPassword();
  }  
private LoginRequest toLoginRequest(HttpServletRequest request) {    return (LoginRequest)request.getAttribute(LOGIN_REQUEST_ATTRIBUTE);
  }

}複製程式碼


處理登陸過程的結果將在之後分派給一個 AuthenticationSuccessHandler 和 AuthenticationFailureHandler。


兩者都相當簡單。DefaultAuthenticationSuccessHandler 呼叫 JwtTokenService 發出一個新的令牌, 然後將其傳送回客戶端。


public class DefaultAuthenticationSuccessHandler implements AuthenticationSuccessHandler {  

private static final int ONE_DAY_MINUTES = 24 * 60;  

private final JwtTokenService jwtTokenService;  
private final ObjectMapper objectMapper;  

public DefaultAuthenticationSuccessHandler(
      JwtTokenService jwtTokenService, ObjectMapper objectMapper) {    
this.jwtTokenService = jwtTokenService;    
this.objectMapper = objectMapper;
  }

  @Override  
public void onAuthenticationSuccess(
      HttpServletRequest request, HttpServletResponse response, Authentication authentication)      
throws IOException {

    response.setContentType(APPLICATION_JSON_VALUE);

    String jwtToken = jwtTokenService.createJwtToken(authentication, ONE_DAY_MINUTES);
    objectMapper.writeValue(response.getWriter(), jwtToken);
  }

}複製程式碼


以下是它的對應, DefaultAuthenticationFailureHandler, 只是傳送回一個 http 401 錯誤訊息。


public class DefaultAuthenticationFailureHandler implements AuthenticationFailureHandler {  

private static final Logger LOGGER =
      LoggerFactory.getLogger(DefaultAuthenticationFailureHandler.class);  

private final ObjectMapper objectMapper;  

public DefaultAuthenticationFailureHandler(ObjectMapper objectMapper) {    
this.objectMapper = objectMapper;
  }

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

    LOGGER.warn(exception.getMessage());

    HttpStatus httpStatus = translateAuthenticationException(exception);

    response.setStatus(httpStatus.value());
    response.setContentType(APPLICATION_JSON_VALUE);

    writeResponse(response.getWriter(), httpStatus, exception);
  }  
protected HttpStatus translateAuthenticationException(AuthenticationException exception) {    
return UNAUTHORIZED;
  }  
protected void writeResponse(
      Writer writer, HttpStatus httpStatus, AuthenticationException exception) throws IOException {

    RestErrorResponse restErrorResponse = RestErrorResponse.of(httpStatus, exception);
    objectMapper.writeValue(writer, restErrorResponse);
  }

}複製程式碼



處理後續請求


在客戶端登陸後, 它將在本地快取 JWT 令牌, 並在前面討論的後續請求中傳送反回。

譯見|構建使用者管理微服務(五):使用 JWT 令牌和 Spring Security 來實現身份驗證


對於每個請求, JwtAuthenticationFilter 通過 JwtTokenService 驗證接收到的 JWT令牌。


public class JwtAuthenticationFilter extends OncePerRequestFilter {  

private static final Logger LOGGER =
      LoggerFactory.getLogger(JwtAuthenticationFilter.class);  

private static final String AUTHORIZATION_HEADER = "Authorization";  
private static final String TOKEN_PREFIX = "Bearer";  

private final JwtTokenService jwtTokenService;  

public JwtAuthenticationFilter(JwtTokenService jwtTokenService) {    
this.jwtTokenService = jwtTokenService;
  }

  @Override  
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
      FilterChain filterChain) throws ServletException, IOException {

    Authentication authentication = getAuthentication(request);    
if (authentication == null) {
      SecurityContextHolder.clearContext();
      filterChain.doFilter(request, response);     
 return;
    }    
 
 try {
      SecurityContextHolder.getContext().setAuthentication(authentication);
      filterChain.doFilter(request, response);
    } finally {
      SecurityContextHolder.clearContext();
    }
  }  private Authentication getAuthentication(HttpServletRequest request) {
    String authorizationHeader = request.getHeader(AUTHORIZATION_HEADER);    if (StringUtils.isEmpty(authorizationHeader)) {
      LOGGER.debug("Authorization header is empty.");      
return null;
    }    if (StringUtils.substringMatch(authorizationHeader, 0, TOKEN_PREFIX)) {
      LOGGER.debug("Token prefix {} in Authorization header was not found.", TOKEN_PREFIX);      
            return null;
    }

    String jwtToken = authorizationHeader.substring(TOKEN_PREFIX.length() + 1);    try {      
      return jwtTokenService.parseJwtToken(jwtToken);
    } catch (AuthenticationException e) {
      LOGGER.warn(e.getMessage());      
      return null;
    }
  }

}複製程式碼


如果令牌是有效的, 則會例項化 JwtAuthenticationToken, 並執行執行緒的 SecurityContext。而由於恢復的 JWT 令牌包含唯一的 ID 和經過身份驗證的使用者的許可權, 因此無需與資料庫聯絡以再次獲取此資訊。


public class JwtAuthenticationToken extends AbstractAuthenticationToken {  

private static final String AUTHORITIES = "authorities"; 
 
private final long userId;  

private JwtAuthenticationToken(long userId, Collection<? extends GrantedAuthority> authorities) {    
super(authorities);    
this.userId = userId;
  }

  @Override  
public Object getCredentials() {    
return null;
  }

  @Override  
public Long getPrincipal() {    
return userId;
  }  /**   * Factory method for creating a new {@code {@link JwtAuthenticationToken}}.   * @param claims JWT claims   * @return a JwtAuthenticationToken   */
  
public static JwtAuthenticationToken of(Claims claims) {    
long userId = Long.valueOf(claims.getSubject());

    Collection<GrantedAuthority> authorities =
        Arrays.stream(String.valueOf(claims.get(AUTHORITIES)).split(","))
            .map(String::trim)
            .map(String::toUpperCase)
            .map(SimpleGrantedAuthority::new)
            .collect(Collectors.toSet());

    JwtAuthenticationToken jwtAuthenticationToken = new JwtAuthenticationToken(userId, authorities);

    Date now = new Date();
    Date expiration = claims.getExpiration();
    Date notBefore = claims.getNotBefore();
    jwtAuthenticationToken.setAuthenticated(now.after(notBefore) && now.before(expiration));    return jwtAuthenticationToken;
  }

}複製程式碼



在這之後, 它由安全框架決定是否允許或拒絕請求。


Spring Security 在 Java EE 世界中有競爭者嗎?


雖然這不是這篇文章的主題, 但我想花一分鐘的時間來談談。如果我不得不在一個 JAVA EE 應用程式中完成所有這些?Spring Security 真的是在 JAVA 中實現身份驗證和授權的黃金標準嗎?


讓我們做個小小的研究!


JAVA EE 8 指日可待,他將在 2017 年年底釋出,我想看看它是否會是 Spring Security 一個強大的競爭者。我發現 JAVA EE 8 將提供 JSR-375 , 這應該會緩解 JAVA EE 應用程式的安全措施的發展。它的參考實施被稱為 Soteira, 是一個相對新的 github 專案。那就是說, 現在的答案是真的沒有這樣的一個競爭者。


但這項研究是不完整的,並沒有提到 Apache Shiro。雖然我從未使用過,但我聽說這算是更為簡單的 Spring Security。讓它更 JWT 令牌 一起使用也不是不可能。從這個角度來看,Apache Shiro 是算 Spring Security 的一個的有可比性的替代品


下期預告:構建使用者管理微服務(六):新增持久 JWT 令牌的身份驗證

原文連結:https://www.springuni.com/user-management-microservice-part-5


相關文章