本篇要介紹如何在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"/>
將AuthenticationManager
的AuthenticationProvider
設為自訂的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 Milosevic的REST Security with JWT using Java and Spring Security。
不過到此其實對Spring Security還不是很熟,如有錯誤還不吝指證。
如果本篇文章有幫助到你,請幫忙點一點側邊或上面的Google廣告,謝謝。
沒有留言:
張貼留言