網頁

2018/3/14

Spring MVC RESTful API 整合Spring Security及JWT

本篇要介紹如何在Spring MVC整合Spring Security透過JWT機制來存取RESTful API。


本篇已廢棄-20220825。



首先建立一個Spring MVC + Spring Security的專案,本範例會以此為基礎來整合JWT (JSON Web Tokens)

注意本範例是下圖步驟4,5,6的驗證部分,並不包括步驟1,2,3登入獲取JWT token的過程。


JWT所使用的套件為Java JWT(jjwt),Maven dependency如下

<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt -->
<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt</artifactId>
  <version>0.9.0</version>
</dependency>

將jjwt的Maven dependency加入專案的pom.xml來匯入套件。

pom.xml

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>idv.matt</groupId>
  <artifactId>springmvc</artifactId>
  <packaging>war</packaging>
  <version>0.0.1-SNAPSHOT</version>
  <name>springmvc Maven Webapp</name>
  <url>http://maven.apache.org</url>
  <dependencies>
    <dependency>
      <groupId>junit</groupId>
      <artifactId>junit</artifactId>
      <version>3.8.1</version>
      <scope>test</scope>
    </dependency>
    <!-- https://mvnrepository.com/artifact/org.springframework/spring-webmvc -->
    <dependency>
      <groupId>org.springframework</groupId>
      <artifactId>spring-webmvc</artifactId>
      <version>4.3.14.RELEASE</version>
    </dependency>
    <!-- Spring Security -->
    <dependency>
      <groupId>org.springframework.security</groupId>
      <artifactId>spring-security-web</artifactId>
      <version>4.2.4.RELEASE</version>
    </dependency>
    <dependency>
      <groupId>org.springframework.security</groupId>
      <artifactId>spring-security-config</artifactId>
      <version>4.2.4.RELEASE</version>
    </dependency>
    <!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt -->
    <dependency>
      <groupId>io.jsonwebtoken</groupId>
      <artifactId>jjwt</artifactId>
      <version>0.9.0</version>
    </dependency>
  </dependencies>
  <build>
    <finalName>springmvc</finalName>
  </build>
</project>

接著修改src/main/resources下Spring Security的配置檔applicationContext.xml。修改為存取RESTful API時所需要進行的JWT驗證。

applicationContext.xml

<?xml version="1.0" encoding="UTF-8"?>
<beans:beans
    xmlns:beans="http://www.springframework.org/schema/beans"
    xmlns:security="http://www.springframework.org/schema/security"
 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
 xsi:schemaLocation="
        http://www.springframework.org/schema/beans 
        http://www.springframework.org/schema/beans/spring-beans.xsd
        http://www.springframework.org/schema/security 
        http://www.springframework.org/schema/security/spring-security.xsd">
        
  <security:http pattern="/api/**" entry-point-ref="restAuthenticationEntryPoint" create-session="stateless">
    <security:intercept-url pattern="/api/**" access="hasRole('ROLE_ADMIN')"/>
    <security:csrf disabled="true"/>
    <security:custom-filter before="FORM_LOGIN_FILTER" ref="jwtAuthenticationFilter"/>
  </security:http>
  
  <beans:bean id="jwtAuthenticationFilter" class="idv.matt.security.JwtAuthenticationFilter">
    <beans:constructor-arg type="java.lang.String">
      <beans:value>/api/**</beans:value>
    </beans:constructor-arg>
    <beans:property name="authenticationManager" ref="authenticationManager" />
    <beans:property name="authenticationSuccessHandler" ref="jwtAuthenticationSuccessHandler" />
  </beans:bean>
  
  <beans:bean id="restAuthenticationEntryPoint" class="idv.matt.security.RestAuthenticationEntryPoint"/>
  <beans:bean id="jwtAuthenticationSuccessHandler" class="idv.matt.security.JwtAuthenticationSuccessHandler"/>
  <beans:bean id="jwtAuthenticationProvider" class="idv.matt.security.JwtAuthenticationProvider"/>
  
  <security:authentication-manager alias="authenticationManager">
    <security:authentication-provider ref="jwtAuthenticationProvider"/>
  </security:authentication-manager>
  
</beans:beans>

<security:http>pattern="/api/**"設定代表只要是/api/下面的url request都必須通過驗證,例如下面的url request都會經過驗證。(預設使用AntPathMatcher進行url pattern的匹配)。

  • http://localhost:8080/[應用程式名稱]/api/hello
  • http://localhost:8080/[應用程式名稱]/api/foo
  • http://localhost:8080/[應用程式名稱]/api/bar/baz

entry-point-ref="restAuthenticationEntryPoint"設定驗證進入點(應該說是驗證失敗會被送去的地方)。因為Spring Security預設驗證未通過時會重新導向首頁,但因為這邊存取的是RESTful API,所以驗證失敗時不需要導向首頁,只要直接返回 HTTP Status 401 - Unauthorized 錯誤,因此改用自訂的RestAuthenticationEntryPoint類別來處理。

設定create-session="stateless"來停用session,因為JWT的特色是無狀態性(statelsess),所以不需建立session。

<security:intercept-url pattern="/api/**" access="hasRole('ROLE_ADMIN')"/>是設定有哪些角色可以存取/api/下的資源。這邊只有ROLE_ADMIN可以存取,如果不屬於指定的角色則會返回 HTTP Status 403 - Access is denied

Spring Security預設CSFR防護是開啟的,因為JWT不透過傳送夾帶cookies的請求來驗證,所以設定<security:csrf disabled="true"/>將CSFR防護關閉。

<security:custom-filter before="FORM_LOGIN_FILTER" ref="jwtAuthenticationFilter"/>用來把自訂的Filter插入Filter堆疊中的指定位置,這邊將JwtAuthenticationFilter插在FORM_LOGIN_FILTER(UsernamePasswordAuthenticationFilter)之前。


<beans:bean id="jwtAuthenticationFilter" class="idv.matt.security.JwtAuthenticationFilter">JwtAuthenticationFilter注入容器,並設定成員變數。

<security:authentication-provider ref="jwtAuthenticationProvider"/>AuthenticationManagerAuthenticationProvider設為自訂的JwtAuthenticationProvider


Spring Security驗證時,會將Authentication交由AuthenticationManager處理,而AuthenticationManager會再轉交負責實際驗證任務的AuthenticationProvider來處理。流程大致如下:

                url request
                     |
                     v
    +-------AuthenticationFilter--------+
    |                |                  |
    |                V                  |
    |      AuthenticationManager        |
    |                |                  |
    |                V                  |
    |      AuthenticationProvider       |
    |                |                  |
    |                V                  |
    |      AuthenticationSuccessHandler |
    |                |                  |
    |                V                  |
    +-------AuthenticationFilter--------+
                     |
                     V
                  Resource

此範例需新增以下類別檔:

  • idv.matt.security.JwtAuthenticationFilter
  • idv.matt.security.JwtAuthenticationProvider
  • idv.matt.security.JwtAuthenticationSuccessHandler
  • idv.matt.security.JwtAuthenticationToken
  • idv.matt.security.JwtTokenMalformedException
  • idv.matt.security.JwtTokenMissingException
  • idv.matt.security.JwtUtil
  • idv.matt.security.RestAuthenticationEntryPoint
  • idv.matt.security.User
  • idv.matt.controller.api.MyAPIController

JwtAuthenticationFilter.java

package idv.matt.security;

import java.io.IOException;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;

public class JwtAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

  protected JwtAuthenticationFilter(String defaultFilterProcessesUrl) {
    super(defaultFilterProcessesUrl);
  }

  @Override
  protected boolean requiresAuthentication(HttpServletRequest request, HttpServletResponse response) {
      return true;
  }
  
  @Override
  public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
    throws AuthenticationException, IOException, ServletException {
    System.out.println("JwtAuthenticationFilter.attemptAuthentication()...");
    
    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); // 以Authentication封裝token

    return getAuthenticationManager().authenticate(authRequest); // 將Authentication交給AuthenticationManager進行驗證
  }

  @Override
  protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult)
          throws IOException, ServletException {
      super.successfulAuthentication(request, response, chain, authResult);
      System.out.println("JwtAuthenticationFilter.successfulAuthentication()...");
    
    chain.doFilter(request, response); // 驗證成功直接通過Filter導向資源位置
  }
}

JwtAuthenticationProvider.java

package idv.matt.security;

import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.authentication.dao.AbstractUserDetailsAuthenticationProvider;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;

import idv.matt.security.User;

public class JwtAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {

  @Override
  public boolean supports(Class<?> authentication) {
    System.out.println("JwtAuthenticationProvider.supports()...");
      return (JwtAuthenticationToken.class.isAssignableFrom(authentication));
  }
  
  @Override
  protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication)
    throws AuthenticationException {
    System.out.println("JwtAuthenticationProvider.additionalAuthenticationChecks()...");
    // 此方法用來做驗證, 但JWT在從token時就必須驗證, 所以這邊不用做任何事
  }

  @Override
  protected UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication)
    throws AuthenticationException {
    System.out.println("JwtAuthenticationProvider.retrieveUser()...");
    
    // authentication物件從AuthenticationManager傳過來
    JwtAuthenticationToken jwtAuthenticationToken = (JwtAuthenticationToken) authentication;
    String token = jwtAuthenticationToken.getToken();

    User parsedUser = JwtUtil.parseToken(token);
    if (parsedUser == null) {
        throw new JwtTokenMalformedException("JWT token is not valid");
    }

    return parsedUser;
  }

}

JwtAuthenticationSuccessHandler.java

package idv.matt.security;

import java.io.IOException;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;

public class JwtAuthenticationSuccessHandler implements AuthenticationSuccessHandler{

  @Override
  public void onAuthenticationSuccess(HttpServletRequest arg0, HttpServletResponse arg1, Authentication arg2)
    throws IOException, ServletException {
    System.out.println("JwtAuthenticationSuccessHandler.onAuthenticationSuccess()...");
    // 因為預設的AuthenticationSuccessHandler通過驗證後會重新導向到發出請求時的頁面, 所以這邊要複寫掉不做任何事
  }

}

JwtAuthenticationToken.java

package idv.matt.security;

import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;

public class JwtAuthenticationToken extends UsernamePasswordAuthenticationToken{
  private static final long serialVersionUID = 1L;
  private String token;

  public JwtAuthenticationToken(String token) {
      super(null, null);
      this.token = token;
  }

  public String getToken() {
      return token;
  }
  
}

JwtTokenMalformedException.java

package idv.matt.security;

import org.springframework.security.core.AuthenticationException;

public class JwtTokenMalformedException extends AuthenticationException {
  private static final long serialVersionUID = 1L;

  public JwtTokenMalformedException(String msg) {
    super(msg);
  }

}

JwtTokenMissingException.java

package idv.matt.security;

import org.springframework.security.core.AuthenticationException;

public class JwtTokenMissingException extends AuthenticationException {
  private static final long serialVersionUID = 1L;

  public JwtTokenMissingException(String msg) {
    super(msg);
  }

}

JwtUtil.java

package idv.matt.security;


import java.util.List;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;

import idv.matt.security.User;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;

public class JwtUtil {
//  @Value("${jwt.secret}")
  private static final String secret = "MTIz";// Base64 encode "123"

  /**
   * 解析JWT token, 成功回傳使用者資訊, 失敗回傳null
   */
  public static User parseToken(String token) {
    try {
      
      // Claims就是JWT的payload部分
      // setSigningKey(String base64EncodedKeyBytes)只吃base64編碼的字串, 傳入無法base64解碼的字串會發生錯誤
      Claims body = Jwts.parser()
              .setSigningKey(secret)
              .parseClaimsJws(token)
              .getBody();
              
      String username = (String) body.get("name");
      String password = (String) body.get("password");
      String role = (String) body.get("role");
      
      // 以下設定會影響Spring Security是否讓此帳號通過驗證
      boolean enabled = true;               // 此帳號是否啟用
      boolean accountNonExpired = true;     // 此帳號是否未過期
      boolean credentialsNonExpired = true; // 此憑證是否過期
      boolean accountNonLocked = true;      // 此帳號是否鎖住
      
      List<GrantedAuthority> authorityList = AuthorityUtils.commaSeparatedStringToAuthorityList(role); // 取得角色權限
      
      User user = new User(username, password, enabled, accountNonExpired, credentialsNonExpired, accountNonLocked, authorityList);

      return user;

    } catch (JwtException | ClassCastException e) {
      return null;
    } catch (Exception e) {
      return null;
    }
  }

  /**
   * 產生 JWT token, payload中裝載name, password, role
   */
  public String generateToken(User u) {
    Claims claims = Jwts.claims().setSubject("");
    claims.put("name", u.getUsername());
    claims.put("password", u.getPassword());
    claims.put("role", u.getRole());

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

RestAuthenticationEntryPoint.java

package idv.matt.security;

import java.io.IOException;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;

public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint{

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

    response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized");
  }
}

User.java

package idv.matt.security;

import java.util.Collection;

import org.springframework.security.core.GrantedAuthority;

public class User extends org.springframework.security.core.userdetails.User{
  private static final long serialVersionUID = 1L;
  
  public User(String username, String password, boolean enabled, boolean accountNonExpired,
    boolean credentialsNonExpired, boolean accountNonLocked, Collection<? extends GrantedAuthority> authorities) {
    super(username, password, enabled, accountNonExpired, credentialsNonExpired, accountNonLocked, authorities);
  }

  private String role;
  private String token;
  
  public String getRole() {
    return role;
  }
  public void setRole(String role) {
    this.role = role;
  }
  public String getToken() {
    return token;
  }
  public void setToken(String token) {
    this.token = token;
  }

}

MyAPIController.java

package idv.matt.controller.api;

import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping(value="/api")
public class MyAPIController {
  
  @GetMapping(value="/hello")
  public String hello(){
    System.out.println("MyAPIController.hello()");
    
    Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
    System.out.println(principal.toString());
    
    return "hello";
  }
  
}

專案目錄結構如下:

到此便完成了全部設定,接著準備測試。測試前要準備可以通過驗證的token,根據程式中的設定,正確token的內容如下

header:{
  "alg": "HS512",
  "typ": "JWT"
},
payload:{
  "name": "matt",
  "password": "matt",
  "role": "ROLE_ADMIN"
},
signature:HMACSHA512(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  "123"
)

payload中一定要包含name,password和role,原因請看JwtUtil.parseToken(String token)

可以利用JWT官網的工具來幫你產生,產生後的token為:

eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoibWF0dCIsInBhc3N3b3JkIjoibWF0dCIsInJvbGUiOiJST0xFX0FETUlOIn0.Cj9CQgbtGNRF0TjLHEwNelPTGzQmBJrRsEizdBLBeO9cmmaPeMqAclstkXNjKc8A2oGnjXbbFW8xxlNzooCBoQ

發送的request如下,使用Postman來測試。

Request URL::http://localhost:8080/springmvc/api/hello
Request Method:GET
Request Header:
Authorization:Bearer eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoibWF0dCIsInBhc3N3b3JkIjoibWF0dCIsInJvbGUiOiJST0xFX0FETUlOIn0.Cj9CQgbtGNRF0TjLHEwNelPTGzQmBJrRsEizdBLBeO9cmmaPeMqAclstkXNjKc8A2oGnjXbbFW8xxlNzooCBoQ

發送請求後開始驗證。


從以上確實通過驗證且取得/api/hello的資源。

可以試著改變產生token簽章的密碼,例如改為456,則產生的token即無法通過驗證。因為和server端的簽章密碼不匹配。

本範例主要是參考Dejan MilosevicREST Security with JWT using Java and Spring Security

不過到此其實對Spring Security還不是很熟,如有錯誤還不吝指證。

如果本篇文章有幫助到你,請幫忙點一點側邊或上面的Google廣告,謝謝。



沒有留言:

張貼留言