在前后端分離的項目中,登錄策略也有不少,不過 JWT 算是目前比較流行的一種解決方案了,本文就和大家來分享一下如何將 Spring Security 和 JWT 結合在一起使用,進而實現前后端分離時的登錄解決方案。
1 無狀態登錄
1.1 什么是有狀態?
有狀態服務,即服務端需要記錄每次會話的客戶端信息,從而識別客戶端身份,根據用戶身份進行請求的處理,典型的設計如Tomcat中的Session。例如登錄:用戶登錄后,我們把用戶的信息保存在服務端session中,並且給用戶一個cookie值,記錄對應的session,然后下次請求,用戶攜帶cookie值來(這一步有瀏覽器自動完成),我們就能識別到對應session,從而找到用戶的信息。這種方式目前來看最方便,但是也有一些缺陷,如下:
- 服務端保存大量數據,增加服務端壓力
- 服務端保存用戶狀態,不支持集群化部署
1.2 什么是無狀態
微服務集群中的每個服務,對外提供的都使用RESTful風格的接口。而RESTful風格的一個最重要的規范就是:服務的無狀態性,即:
- 服務端不保存任何客戶端請求者信息
- 客戶端的每次請求必須具備自描述信息,通過這些信息識別客戶端身份
那么這種無狀態性有哪些好處呢?
- 客戶端請求不依賴服務端的信息,多次請求不需要必須訪問到同一台服務器
- 服務端的集群和狀態對客戶端透明
- 服務端可以任意的遷移和伸縮(可以方便的進行集群化部署)
- 減小服務端存儲壓力
1.3.如何實現無狀態
無狀態登錄的流程:
- 首先客戶端發送賬戶名/密碼到服務端進行認證
- 認證通過后,服務端將用戶信息加密並且編碼成一個token,返回給客戶端
- 以后客戶端每次發送請求,都需要攜帶認證的token
- 服務端對客戶端發送來的token進行解密,判斷是否有效,並且獲取用戶登錄信息
1.4 JWT
1.4.1 簡介
JWT,全稱是Json Web Token, 是一種JSON風格的輕量級的授權和身份認證規范,可實現無狀態、分布式的Web應用授權:
JWT 作為一種規范,並沒有和某一種語言綁定在一起,常用的Java 實現是GitHub 上的開源項目 jjwt,地址如下:https://github.com/jwtk/jjwt
1.4.2 JWT數據格式
JWT包含三部分數據:
Header:頭部,通常頭部有兩部分信息:
- 聲明類型,這里是JWT
- 加密算法,自定義
我們會對頭部進行Base64Url編碼(可解碼),得到第一部分數據。
Payload:載荷,就是有效數據,在官方文檔中(RFC7519),這里給了7個示例信息:
- iss (issuer):表示簽發人
- exp (expiration time):表示token過期時間
- sub (subject):主題
- aud (audience):受眾
- nbf (Not Before):生效時間
- iat (Issued At):簽發時間
- jti (JWT ID):編號
這部分也會采用Base64Url編碼,得到第二部分數據。
Signature:簽名,是整個數據的認證信息。一般根據前兩步的數據,再加上服務的的密鑰secret(密鑰保存在服務端,不能泄露給客戶端),通過Header中配置的加密算法生成。用於驗證整個數據完整和可靠性。
生成的數據格式如下圖:
注意,這里的數據通過 .
隔開成了三部分,分別對應前面提到的三部分,另外,這里數據是不換行的,圖片換行只是為了展示方便而已。
1.4.3 JWT交互流程
流程圖:
步驟翻譯:
- 應用程序或客戶端向授權服務器請求授權
- 獲取到授權后,授權服務器會向應用程序返回訪問令牌
- 應用程序使用訪問令牌來訪問受保護資源(如API)
因為JWT簽發的token中已經包含了用戶的身份信息,並且每次請求都會攜帶,這樣服務的就無需保存用戶信息,甚至無需去數據庫查詢,這樣就完全符合了RESTful的無狀態規范。
1.5 JWT 存在的問題
說了這么多,JWT 也不是天衣無縫,由客戶端維護登錄狀態帶來的一些問題在這里依然存在,舉例如下:
- 續簽問題,這是被很多人詬病的問題之一,傳統的cookie+session的方案天然的支持續簽,但是jwt由於服務端不保存用戶狀態,因此很難完美解決續簽問題,如果引入redis,雖然可以解決問題,但是jwt也變得不倫不類了。
- 注銷問題,由於服務端不再保存用戶信息,所以一般可以通過修改secret來實現注銷,服務端secret修改后,已經頒發的未過期的token就會認證失敗,進而實現注銷,不過畢竟沒有傳統的注銷方便。
- 密碼重置,密碼重置后,原本的token依然可以訪問系統,這時候也需要強制修改secret。
- 基於第2點和第3點,一般建議不同用戶取不同secret。
2 實戰
說了這么久,接下來我們就來看看這個東西到底要怎么用?
2.1 環境搭建
首先我們來創建一個Spring Boot項目,創建時需要添加Spring Security依賴,創建完成后,添加 jjwt
依賴,完整的pom.xml文件如下:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
然后在項目中創建一個簡單的 User 對象實現 UserDetails 接口,如下:
public class User implements UserDetails {
private String username;
private String password;
private List<GrantedAuthority> authorities;
public String getUsername() {
return username;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
//省略getter/setter
}
這個就是我們的用戶對象,先放着備用,再創建一個HelloController,內容如下:
@RestController
public class HelloController {
@GetMapping("/hello")
public String hello() {
return "hello jwt !";
}
@GetMapping("/admin")
public String admin() {
return "hello admin !";
}
}
HelloController 很簡單,這里有兩個接口,設計是 /hello
接口可以被具有 user 角色的用戶訪問,而 /admin
接口則可以被具有 admin 角色的用戶訪問。
2.2 JWT 過濾器配置
接下來提供兩個和 JWT 相關的過濾器配置:
- 一個是用戶登錄的過濾器,在用戶的登錄的過濾器中校驗用戶是否登錄成功,如果登錄成功,則生成一個token返回給客戶端,登錄失敗則給前端一個登錄失敗的提示。
- 第二個過濾器則是當其他請求發送來,校驗token的過濾器,如果校驗成功,就讓請求繼續執行。
這兩個過濾器,我們分別來看,先看第一個:
public class JwtLoginFilter extends AbstractAuthenticationProcessingFilter {
protected JwtLoginFilter(String defaultFilterProcessesUrl, AuthenticationManager authenticationManager) {
super(new AntPathRequestMatcher(defaultFilterProcessesUrl));
setAuthenticationManager(authenticationManager);
}
@Override
public Authentication attemptAuthentication(HttpServletRequest req, HttpServletResponse resp) throws AuthenticationException, IOException, ServletException {
User user = new ObjectMapper().readValue(req.getInputStream(), User.class);
return getAuthenticationManager().authenticate(new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword()));
}
@Override
protected void successfulAuthentication(HttpServletRequest req, HttpServletResponse resp, FilterChain chain, Authentication authResult) throws IOException, ServletException {
Collection<? extends GrantedAuthority> authorities = authResult.getAuthorities();
StringBuffer as = new StringBuffer();
for (GrantedAuthority authority : authorities) {
as.append(authority.getAuthority())
.append(",");
}
String jwt = Jwts.builder()
.claim("authorities", as)//配置用戶角色
.setSubject(authResult.getName())
.setExpiration(new Date(System.currentTimeMillis() + 10 * 60 * 1000))
.signWith(SignatureAlgorithm.HS512,"sang@123")
.compact();
resp.setContentType("application/json;charset=utf-8");
PrintWriter out = resp.getWriter();
out.write(new ObjectMapper().writeValueAsString(jwt));
out.flush();
out.close();
}
protected void unsuccessfulAuthentication(HttpServletRequest req, HttpServletResponse resp, AuthenticationException failed) throws IOException, ServletException {
resp.setContentType("application/json;charset=utf-8");
PrintWriter out = resp.getWriter();
out.write("登錄失敗!");
out.flush();
out.close();
}
}
關於這個類,我說如下幾點:
- 自定義 JwtLoginFilter 繼承自 AbstractAuthenticationProcessingFilter,並實現其中的三個默認方法。
- attemptAuthentication方法中,我們從登錄參數中提取出用戶名密碼,然后調用AuthenticationManager.authenticate()方法去進行自動校驗。
- 第二步如果校驗成功,就會來到successfulAuthentication回調中,在successfulAuthentication方法中,將用戶角色遍歷然后用一個
,
連接起來,然后再利用Jwts去生成token,按照代碼的順序,生成過程一共配置了四個參數,分別是用戶角色、主題、過期時間以及加密算法和密鑰,然后將生成的token寫出到客戶端。 - 第二步如果校驗失敗就會來到unsuccessfulAuthentication方法中,在這個方法中返回一個錯誤提示給客戶端即可。
再來看第二個token校驗的過濾器:
public class JwtFilter extends GenericFilterBean {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) servletRequest;
String jwtToken = req.getHeader("authorization");
System.out.println(jwtToken);
Claims claims = Jwts.parser().setSigningKey("sang@123").parseClaimsJws(jwtToken.replace("Bearer",""))
.getBody();
String username = claims.getSubject();//獲取當前登錄用戶名
List<GrantedAuthority> authorities = AuthorityUtils.commaSeparatedStringToAuthorityList((String) claims.get("authorities"));
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, null, authorities);
SecurityContextHolder.getContext().setAuthentication(token);
filterChain.doFilter(req,servletResponse);
}
}
關於這個過濾器,我說如下幾點:
- 首先從請求頭中提取出 authorization 字段,這個字段對應的value就是用戶的token。
- 將提取出來的token字符串轉換為一個Claims對象,再從Claims對象中提取出當前用戶名和用戶角色,創建一個UsernamePasswordAuthenticationToken放到當前的Context中,然后執行過濾鏈使請求繼續執行下去。
如此之后,兩個和JWT相關的過濾器就算配置好了。
2.3 Spring Security 配置
接下來我們來配置 Spring Security,如下:
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication().withUser("admin")
.password("123").roles("admin")
.and()
.withUser("sang")
.password("456")
.roles("user");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/hello").hasRole("user")
.antMatchers("/admin").hasRole("admin")
.antMatchers(HttpMethod.POST, "/login").permitAll()
.anyRequest().authenticated()
.and()
.addFilterBefore(new JwtLoginFilter("/login",authenticationManager()),UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(new JwtFilter(),UsernamePasswordAuthenticationFilter.class)
.csrf().disable();
}
}
- 簡單起見,這里我並未對密碼進行加密,因此配置了NoOpPasswordEncoder的實例。
- 簡單起見,這里並未連接數據庫,我直接在內存中配置了兩個用戶,兩個用戶具備不同的角色。
- 配置路徑規則時,
/hello
接口必須要具備 user 角色才能訪問,/admin
接口必須要具備 admin 角色才能訪問,POST 請求並且是/login
接口則可以直接通過,其他接口必須認證后才能訪問。 - 最后配置上兩個自定義的過濾器並且關閉掉csrf保護。
2.4 測試
做完這些之后,我們的環境就算完全搭建起來了,接下來啟動項目然后在 POSTMAN 中進行測試,如下:
登錄成功后返回的字符串就是經過 base64url 轉碼的token,一共有三部分,通過一個 .
隔開,我們可以對第一個 .
之前的字符串進行解碼,即Header,如下:
再對兩個 .
之間的字符解碼,即 payload:
可以看到,我們設置信息,由於base64並不是加密方案,只是一種編碼方案,因此,不建議將敏感的用戶信息放到token中。
接下來再去訪問 /hello
接口,注意認證方式選擇 Bearer Token,Token值為剛剛獲取到的值,如下:
可以看到,訪問成功。
總結
這就是 JWT 結合 Spring Security 的一個簡單用法,講真,如果實例允許,類似的需求我還是推薦使用 OAuth2 中的 password 模式。
不知道大伙有沒有看懂呢?如果沒看懂,松哥還有一個關於這個知識點的視頻教程,如下:
如何獲取這個視頻教程呢?很簡單,將本文轉發到一個超過100人的微信群中(QQ群不算,松哥是群主的微信群也不算,群要為Java方向),或者多個微信群中,只要累計人數達到100人即可,然后加松哥微信,截圖發給松哥即可獲取資料。