學習在 Spring Boot 中整合 Spring Security 和 JWT ,實作無狀态登入,可做為前後端分離時的解決方案,技術上沒問題,但實際上還是推薦使用 OAuth2 中的 password 模式。
1 登入概述
1.1 有狀态登入
有狀态服務,即服務端需要記錄每次會話的用戶端資訊,進而識别用戶端身份,根據使用者身份進行請求的處理,如 Tomcat 中的 Session 。例如:使用者登入後,我們把使用者的資訊儲存在服務端 session 中,并且給使用者一個 cookie 值,記錄對應的 session ,然後下次請求,使用者攜帶 cookie 值來(這一步由浏覽器自動完成),我們就能識别到對應 session ,進而找到使用者的資訊。這種方式目前來看最友善,但是也有一些缺陷,如下:
- 服務端儲存大量資料,增加服務端壓力。
- 服務端儲存使用者狀态,不支援叢集化部署。
1.2 無狀态登入
微服務叢集中的每個服務,對外提供的都使用 RESTful 風格的接口。而 RESTful 風格的一個最重要的規範就是:服務的無狀态性,即:
- 服務端不儲存任何用戶端請求者資訊。
- 用戶端的每次請求必須具備自描述資訊,通過這些資訊識别用戶端身份。
優勢:
- 用戶端請求不依賴服務端的資訊,多次請求不需要必須通路到同一台伺服器。
- 服務端的叢集和狀态對用戶端透明。
- 服務端可以任意的遷移和伸縮(可以友善的進行叢集化部署)。
- 減小服務端存儲壓力。
1.3 無狀态登入的流程
無狀态登入的流程:
- 首先用戶端發送賬戶名/密碼到服務端進行認證。
- 認證通過後,服務端将使用者資訊加密并且編碼成一個 token ,傳回給用戶端。
- 以後用戶端每次發送請求,都需要攜帶認證的 token 。
- 服務端對用戶端發送來的 token 進行解密,判斷是否有效,并且擷取使用者登入資訊。
2 JWT 概述
2.1 JWT 簡介
JWT (Json Web Token),是一種 JSON 風格的輕量級的授權和身份認證規範,可實作無狀态、分布式的 Web 應用授權。官網:https://jwt.io/
![](https://img.laitimes.com/img/__Qf2AjLwojIjJCLyojI0JCLicmbwxCdh1mcvZ2LcV2Zh1Wa9M3clN2byBXLzN3btg3P3pVdC5GT6V1VOhmUX9EbG1mWpJFVNFTWq1EaORUTthGVZJTW65EeNR0T1EEROtmU65UNRdFT3Z1MMBjVtJmaONjY2FFWaVXNTlVdsdUYq50MiV3YXJGcOJzY2lTeMZTTINGMShUYvwlbj5yZtlmbkN3YuQnclZnbvN2Ztl2Lc9CX6MHc0RHaiojIsJye.jpg)
JWT 作為一種規範,并沒有和某一種語言綁定在一起,常用的 Java 實作是 GitHub 上的開源項目
jjwt
,位址如下:https://github.com/jwtk/jjwt
2.2 JWT 資料格式
JWT 包含三部分資料:
-
:頭部,通常頭部有兩部分資訊:Header
- 聲明類型,這裡是 JWT 。
- 加密算法,自定義。
-
:載荷,就是有效資料,在官方文檔中(RFC7519),這裡給了 7 個示例資訊:Payload
- iss (issuer):表示簽發人。
- exp (expiration time):表示token過期時間。
- sub (subject):主題。
- aud (audience):閱聽人。
- nbf (Not Before):生效時間。
- iat (Issued At):簽發時間。
- jti (JWT ID):編号。
-
:簽名,是整個資料的認證資訊。一般根據前兩步的資料,再加上服務端的密鑰 secret (密鑰儲存在服務端,不能洩露給用戶端),通過 Header 中配置的加密算法生成,用于驗證整個資料的完整性和可靠性。Signature
比如,生成的資料格式:
eyJhbGciOiJIUzUxMiJ9.eyJhdXRob3JpdGllcyI6IlJPTEVfdXNlciwiLCJzdWIiOiJ1c2VyIiwiZXhwIjoxNTc0NzczNTkyfQ.FuPIltzXi5j14t_gSL1GoIMUZxTHKK0FvB3gds6eTZFDkQr1ZxWVxdqZ5YFbCxdkwQ_VXtPK-GgcW5Kzzx3wvw
注意,這裡的資料通過
.
隔開成了三部分,分别對應前面提到的三部分:
- Header :頭部(聲明類型、加密算法),采用 Base64 編碼,如:
。eyJhbGciOiJIUzUxMiJ9
- Payload :載荷,就是有效資料,采用 Base64 編碼,如:
eyJhdXRob3JpdGllcyI6IlJPTEVfdXNlciwiLCJzdWIiOiJ1c2VyIiwiZXhwIjoxNTc0NzczNTkyfQ
- Signature :簽名,如:
。FuPIltzXi5j14t_gSL1GoIMUZxTHKK0FvB3gds6eTZFDkQr1ZxWVxdqZ5YFbCxdkwQ_VXtPK-GgcW5Kzzx3wvw
2.3 JWT 互動流程
- 應用程式或用戶端向授權伺服器請求授權。
- 擷取到授權後,授權伺服器會向應用程式傳回通路令牌。
- 應用程式使用通路令牌來通路受保護資源(如 API )。
因為 JWT 簽發的 token 中已經包含了使用者的身份資訊,并且每次請求都會攜帶,這樣服務端就無需儲存使用者資訊,甚至無需去資料庫查詢,這樣就完全符合了 RESTful 的無狀态規範。
2.4 JWT 問題
說了這麼多, JWT 也不是天衣無縫,由用戶端維護登入狀态帶來的一些問題在這裡依然存在,如下:
- 續簽問題,這是被很多人诟病的問題之一,傳統的 cookie + session 的方案天然的支援續簽,但是 JWT 由于服務端不儲存使用者狀态,是以很難完美解決續簽問題,如果引入 Redis ,雖然可以解決問題,但是 JWT 也變得不倫不類了。
- 登出問題,由于服務端不再儲存使用者資訊,是以一般可以通過修改 secret 來實作登出,服務端 secret 修改後,已經頒發的未過期的 token 就會認證失敗,進而實作登出,不過畢竟沒有傳統的登出友善。
- 密碼重置,密碼重置後,原本的 token 依然可以通路系統,這時候也需要強制修改 secret 。
- 基于第 2 點和第 3 點,一般建議不同使用者取不同 secret 。
3 實戰
3.1 建立工程
建立 Spring Boot 項目
spring-boot-springsecurity-jwt
,添加
Web/Spring Security
依賴,如下:
之後手動在 pom 檔案中添加
jjwt
依賴,最終的依賴如下:
<dependencies>
<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>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
3.2 建立接口
建立實體類
User
實作
UserDetails
接口,如下:
public class User implements UserDetails {
private String username;
private String password;
private List<GrantedAuthority> authorities;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getPassword() {
return password;
}
@Override
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;
}
public void setUsername(String username) {
this.username = username;
}
public void setPassword(String password) {
this.password = password;
}
public void setAuthorities(List<GrantedAuthority> authorities) {
this.authorities = authorities;
}
}
建立
HelloController
,如下:
@RestController
public class HelloController {
@GetMapping("/hello")
public String hello() {
return "hello";
}
@GetMapping("/admin/hello")
public String admin() {
return "hello admin";
}
@GetMapping("/user/hello")
public String user() {
return "hello user";
}
}
3.3 配置過濾器
這裡主要配置兩個過濾器:
- 使用者登入的過濾器
// 過濾器1:使用者登入的過濾器,在使用者的登入的過濾器中校驗使用者是否登入成功,
// 如果登入成功,則生成一個 token 傳回給用戶端,登入失敗則給前端一個登入失敗的提示
public class JwtLoginFilter extends AbstractAuthenticationProcessingFilter {
public JwtLoginFilter(String defaultFilterProcessesUrl, AuthenticationManager authenticationManager) {
super(new AntPathRequestMatcher(defaultFilterProcessesUrl));
setAuthenticationManager(authenticationManager);
}
@Override
public Authentication attemptAuthentication(HttpServletRequest req, HttpServletResponse httpServletResponse) throws AuthenticationException, IOException {
// 這裡隻支援 JSON 的登入方式
// 如果想表單方式也支援,可參考 spring-boot-springsecurity-loginbyjson 中的 MyAuthenticationFilter
// 擷取輸入參數,如 {"username":"user","password":"123456"}
User user = new ObjectMapper().readValue(req.getInputStream(), User.class);
// 進行登入校驗,如果校驗成功,會到 successfulAuthentication 的回調中,否則到 unsuccessfulAuthentication 的回調中
return getAuthenticationManager().authenticate(new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword()));
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse resp, FilterChain chain, Authentication authResult) throws IOException, ServletException {
// 擷取登入使用者的角色
Collection<? extends GrantedAuthority> authorities = authResult.getAuthorities();
StringBuffer sb = new StringBuffer();
for (GrantedAuthority authority : authorities) {
sb.append(authority.getAuthority()).append(",");
}
// 生成 token 并傳回
// 資料格式:分 3 部分用 . 隔開,如:eyJhbGciOiJIUzUxMiJ9.eyJhdXRob3JpdGllcyI6IlJPTEVfdXNlciwiLCJzdWIiOiJ1c2VyIiwiZXhwIjoxNTc0NzczNTkyfQ.FuPIltzXi5j14t_gSL1GoIMUZxTHKK0FvB3gds6eTZFDkQr1ZxWVxdqZ5YFbCxdkwQ_VXtPK-GgcW5Kzzx3wvw
// 1.Header:頭部(聲明類型、加密算法),采用 Base64 編碼,如:eyJhbGciOiJIUzUxMiJ9
// 2.Payload:載荷,就是有效資料,采用 Base64 編碼,如:eyJhdXRob3JpdGllcyI6IlJPTEVfdXNlciwiLCJzdWIiOiJ1c2VyIiwiZXhwIjoxNTc0NzczNTkyfQ
// 3.Signature:簽名,是整個資料的認證資訊。一般根據前兩步的資料,再加上服務的的密鑰 secret (密鑰儲存在服務端,不能洩露給用戶端),通過 Header 中配置的加密算法生成。用于驗證整個資料完整和可靠性。
String jwt = Jwts.builder()
.claim("authorities", sb) // 配置使用者角色
.setSubject(authResult.getName()) // 配置主題
.setExpiration(new Date(System.currentTimeMillis() + 60 * 60 * 1000)) // 配置過期時間
.signWith(SignatureAlgorithm.HS512, "[email protected]") // 配置加密算法和密鑰
.compact();
resp.setContentType("application/json;charset=utf-8");
Map<String, String> map = new HashMap<>();
map.put("token", jwt);
map.put("msg", "登入成功");
PrintWriter out = resp.getWriter();
out.write(new ObjectMapper().writeValueAsString(map));
out.flush();
out.close();
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest req, HttpServletResponse resp, AuthenticationException failed) throws IOException, ServletException {
resp.setContentType("application/json;charset=utf-8");
Map<String, String> map = new HashMap<>();
map.put("msg", "登入失敗");
PrintWriter out = resp.getWriter();
out.write(new ObjectMapper().writeValueAsString(map));
out.flush();
out.close();
}
}
- 校驗 token 的過濾器
// 過濾器2:當其他請求發送來,校驗 token 的過濾器,如果校驗成功,就讓請求繼續執行
// 請求時注意認證方式選擇 Bearer Token
public class JwtFilter extends GenericFilterBean {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) servletRequest;
// 擷取 token ,注意擷取方式要跟前台傳的方式保持一緻
// 這裡請求時注意認證方式選擇 Bearer Token,會用 header 傳遞
String jwtToken = req.getHeader("authorization");
// 注意 "[email protected]" 要與生成 token 時的保持一緻
Jws<Claims> jws = Jwts.parser().setSigningKey("[email protected]")
.parseClaimsJws(jwtToken.replace("Bearer", ""));
Claims claims = jws.getBody();
// 擷取使用者名
String username = claims.getSubject();
// 擷取使用者角色,注意 "authorities" 要與生成 token 時的保持一緻
List<GrantedAuthority> authorities = AuthorityUtils.commaSeparatedStringToAuthorityList((String) claims.get("authorities"));
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, null, authorities);
SecurityContextHolder.getContext().setAuthentication(token);
filterChain.doFilter(servletRequest, servletResponse);
}
}
3.4 配置 Spring Security
新增
SecurityConfig
配置類,如下:
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
PasswordEncoder passwordEncoder() {
// return NoOpPasswordEncoder.getInstance();// 密碼不加密
return new BCryptPasswordEncoder();// 密碼加密
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// 在記憶體中配置2個使用者
/*auth.inMemoryAuthentication()
.withUser("admin").password("123456").roles("admin")
.and()
.withUser("user").password("123456").roles("user");// 密碼不加密*/
auth.inMemoryAuthentication()
.withUser("admin").password("$2a$10$fB2UU8iJmXsjpdk6T6hGMup8uNcJnOGwo2.QGR.e3qjIsdPYaS4LO").roles("admin")
.and()
.withUser("user").password("$2a$10$3TQ2HO/Xz1bVHw5nlfYTBON2TDJsQ0FMDwAS81uh7D.i9ax5DR46q").roles("user");// 密碼加密
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/admin/**").hasRole("admin")
.antMatchers("/user/**").access("hasAnyRole('user','admin')")
.antMatchers(HttpMethod.POST, "/login").permitAll()
.anyRequest().authenticated()
.and()
.addFilterBefore(new JwtLoginFilter("/login", authenticationManager()), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(new JwtFilter(), UsernamePasswordAuthenticationFilter.class)
.csrf().disable();
}
}
3.5 測試
項目啟動之後,用
Postman
完成測試。
取出 token 的第 1 部分, Base64 解碼得到 Header ,如下:
取出 token 的第 2 部分, Base64 解碼得到 Payload ,如下:
因為 Base64 是一種編碼方案,并不是加密方案,是以不建議将使用者的敏感資訊放在 token 中。
最後拿着上述 token 通路
/user/hello
,可正常通路。注意:認證方式 Authorization 選擇 Bearer Token 。
- Spring Boot 教程合集(微信左下方閱讀全文可直達)。
- Spring Boot 教程合集示例代碼:https://github.com/cxy35/spring-boot-samples
- 本文示例代碼:https://github.com/cxy35/spring-boot-samples/tree/master/spring-boot-security/spring-boot-springsecurity-jwt
掃碼關注微信公衆号 程式員35 ,擷取最新技術幹貨,暢聊 #程式員的35,35的程式員# 。獨立站點:https://cxy35.com