SpringSecurity+JWT登入認證
1. 簡要
之前學習的認證方式,在伺服器驗證通過後,會在當前對話session中儲存資料,然後向客戶端返回一個session_id存在客戶端Cookie中,但是這種模式會存在問題就是擴充套件性不好,對於單機還好,如果是伺服器叢集,就需要實現session共享,保證每臺伺服器都能讀取session。
而今天要學的JWT(JSON Web Token)是目前比較留校的一種跨域認證方案,在前後端分離專案中應用的比較多。具體JWT的概念可以學習阮一峰大神的這篇文章:JSON Web Token 入門教程 - 阮一峰
俺今天是要使用SpringSecurity實現JWT的認證,前後端分離,使用JSON互動。
2. 設計思路
首先是登入認證:
- 前端POST請求,將使用者名稱和密碼以JSON的形式傳送請求
/jwt/login
- 自定義一個
JWTAuthenticationFilter
,進行提取request中的引數,封裝為一個UsernampasswordAuthenticationToken
給AuthenticationManager
進行認證 -
AuthenticationManager
從自定義的Service中查詢使用者資訊,判斷賬號密碼是否正確 - 認證成功則生成JWT Token給客戶端
- 認證失敗則返回錯誤
然後是請求時的Token校驗:
因為使用JWT,所以不需要用到session,每一個請求都是需要攜帶Token,伺服器根據Token進行驗證,因此這裡我們還需要一個自定義的Filter,用來攔截任意請求
- 一個請求發起
- 被自定義的Filter攔截,進行token驗證
- 將驗證成功的token生成
Authentication
物件存入SecurityContext
上下文,表示驗證完成 - 後續再進行許可權的驗證
3. 環境搭建
<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.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.2.2</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>5.1.48</version> <scope>runtime</scope> </dependency> <!-- 引入jaxb-api包 --> <dependency> <groupId>javax.xml.bind</groupId> <artifactId>jaxb-api</artifactId> <version>2.3.0</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </dependency> </dependencies>
這裡需要注意的一個包是:jaxb-api
匯入這個包是因為使用JJWT這個包時會報一個異常:
java.lang.ClassNotFoundException: javax.xml.bind.DatatypeConverter at java.base/jdk.internal.loader
匯入這個包就可以解決了。
Springboot配置mysql和mybatis
spring:
datasource:
username: root
password: root
url: jdbc:mysql://localhost:3306/security
driver-class-name: com.mysql.jdbc.Driver
mybatis:
mapper-locations:
- classpath:mapper/*.xml
type-aliases-package: com.zzy.jwt.pojo
4. 原始碼實現
-
自定義
JWTAuthenticationFilter
public class JWTAuthenticationFilter extends AbstractAuthenticationProcessingFilter { /** * 通過構造器注入攔截的Url,請求方法沒有限制 * @param antPathRequestUrl url */ public JWTAuthenticationFilter(String antPathRequestUrl) { super(new AntPathRequestMatcher(antPathRequestUrl, "POST")); } @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException { // 從JSON中讀取請求引數 User user = new ObjectMapper().readValue(request.getInputStream(), User.class); // 獲取使用者名稱和密碼 String username = user.getUsername(); String password = user.getPassword(); // 構造Token,使用UsernamepasswordAuthenticationToken UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, password); // 設定一些客戶端IP等資訊 setDetails(request, token); // 交給AuthenticationManager進行認證 return this.getAuthenticationManager().authenticate(token); } protected void setDetails(HttpServletRequest request, UsernamePasswordAuthenticationToken authRequest) { authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request)); } @Override protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { ObjectMapper objectMapper = new ObjectMapper(); Response myResponse = new Response(); // 設定header response.setHeader("Content-Type", "application/json;charset=utf-8"); User user = (User) authResult.getPrincipal(); // 生成Token返回 String token = JWTUtil.generate(user); myResponse.setStatusCode(HttpStatus.OK.value()); myResponse.setMsg("登入成功!"); myResponse.setData("Bearer " + token); response.setStatus(HttpStatus.OK.value()); response.getWriter().write(objectMapper.writeValueAsString(myResponse)); } /** * 重寫認證失敗後的處理方法 * @param request * @param response * @param failed * @throws IOException * @throws ServletException */ protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException { ObjectMapper objectMapper = new ObjectMapper(); Response myResponse = new Response(); myResponse.setStatusCode(HttpStatus.UNAUTHORIZED.value()); if (failed instanceof LockedException) { myResponse.setMsg("賬號被鎖定!"); } else if (failed instanceof CredentialsExpiredException) { myResponse.setMsg("使用者密碼過期!"); } else if (failed instanceof AccountExpiredException) { myResponse.setMsg("使用者賬號過期!"); } else if (failed instanceof DisabledException) { myResponse.setMsg("使用者賬號被禁用!"); } else if (failed instanceof BadCredentialsException) { myResponse.setMsg("使用者賬號或密碼錯誤!"); } response.setContentType("application/json;charset=utf-8"); response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.getWriter().write(objectMapper.writeValueAsString(myResponse)); }
自定義登入認證過濾器,這次攔截的url由構造器進行設定,重寫
attemptAuthentication
方法邏輯與之前的自定義認證一樣,都是從請求中獲取使用者名稱和密碼等引數,封裝為一個Token,我這裡直接使用了官方的UsernamepasswordAuthenticationToken,然後交給AuthenticationManager
去認證。 這裡的認證成功與失敗沒有使用自定義類的形式實現,而是以重寫
successfulAuthentication
方法和unsuccessfulAuthentication
方法實現,認證成功後生成一個JWT Token返回,這裡直接將user物件作為JWT的playload儲存,實際中應以實際情況寫入的。認證失敗則返回JSON字串錯誤。 需要與之前的驗證碼認證區分開的是,這裡前後端是以JSON傳遞的,所以request接收到的是一個json字串的資料,返回時也是json資料,相同的地方是Provider還是呼叫UserDetailsService的loadUserByUsername方法去驗證使用者。
在這裡也是自定義了一個Response類,用來構造返回資訊,主要就是設定狀態,返回資訊,以及資料。
-
定義UserDetail類和UserDetailsService
UserDetail類:
@Data @JsonIgnoreProperties(ignoreUnknown = true) public class User implements UserDetails, Serializable { private static final long serialVersionUID = -1384206000980290583L; private int id; private String username; private String password; private String email; private boolean enabled; @Override public String toString() { return "User{" + "id=" + id + ", username='" + username + '\'' + ", password='" + password + '\'' + ", email='" + email + '\'' + '}'; } @Override @JsonDeserialize(using = CustomAuthorityDeserializer.class) public Collection<? extends GrantedAuthority> getAuthorities() { List<SimpleGrantedAuthority> authorities = new ArrayList<>(); authorities.add(new SimpleGrantedAuthority("ROLE_ROOT")); return authorities; } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return enabled; } }
User類沒有太多變動,和之前一樣,因為還沒涉及到授權部分,這裡暫時先寫死許可權位
ROLE_ROOT
.有兩個錯誤可能會出現:
-
com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException: Unrecognized field "accountNonExpired" (class com.zzy.jwt.pojo.User), not marked as ignorable
這是因為我們user類中一些欄位在資料庫中是沒有的,需要在類上加上一個註解:
@JsonIgnoreProperties(ignoreUnknown = true)
解決方案來源於這個部落格:jackson json轉bean忽略沒有的欄位 not marked as ignorable
-
com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of org.springframework.security.core.GrantedAuthority
這是因為我們在構造JWT Token時候是將整個user物件寫入的,user類中getAuthorities方法導致的,需要在getAuthorities方法上加上一個註解
@JsonDeserialize(using = CustomAuthorityDeserializer.class)
並定義一個類:CustomAuthorityDeserializer
具體解決方式可以參考這個博文:Cannot construct instance of org.springframework.security.core.GrantedAuthority的錯誤解決
UserDetailsService:
@Service public class UserService implements UserDetailsService { @Autowired private UserMapper userMapper; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user = userMapper.findUserByUsername(username); if (user == null) { throw new UsernameNotFoundException("User: " + username + " not exist!"); } return user; } }
Provider通過呼叫UserService的方法loadUserByUsername去認證使用者,這裡的方法裡通過使用者名稱查詢資料庫。
當然這個UserService是我們自定義的實現UserDetailsService的,在後面我們的Security配置中要設定為這個Service
資料庫操作相關的比較簡單,具體再看gitee上的原始碼吧,哈哈。
-
-
JWT工具類
public class JWTUtil { // 金鑰 private final static String secretKey = "Lucas"; // 過期時間設定為5min private final static Duration expiration = Duration.ofMinutes(10); /** * 生成Token * @param user * @return */ public static String generate(User user) { // 過期時間 Date expiryDate = new Date(System.currentTimeMillis() + expiration.toMillis()); try { return Jwts.builder() .setSubject(new ObjectMapper().writeValueAsString(user)) // 將username放進JWT .setIssuedAt(new Date()) // 設定jwt簽發時間 .setExpiration(expiryDate) // 設定jwt過期時間 .signWith(SignatureAlgorithm.HS512, secretKey) // 設定加密演算法和金鑰 .compact(); } catch (Exception e) { return null; } } /** * 解析Token * @param token * @return */ public static Claims parse(String token) { try { return Jwts.parser() .setSigningKey(secretKey) .parseClaimsJws(token) .getBody(); } catch (Exception e) { return null; } } }
這裡只是簡單的構造,一個生成Token的方法,一個解析Token的方法,金鑰設定為字串"Lucas", 過期時間設定為10分鐘。
更加細緻的工具方法暫未去實現,先湊合著用著。
其實,到了這裡我們就差將我們自定義的Filter和Provider新增到SpringSecurity的主配置中,就實現了認證了,但我們這次用的是JWT方式,伺服器端並沒有記錄登入使用者的資訊,因此我們需要多一個過濾器,用來攔截請求,驗證Token的,如果請求攜帶了Token,並且token裡的資訊是正確的,我們才將Authentication設定已認證,讓SpringSecurity過濾器鏈去做後續的鑑權啥的。
-
自定義Token攔截器
SpringSecurity預設的基礎配置中沒有提供對
Bearer Authentication
處理的過濾器,但是提供了處理Basic Authentication
的過濾器BasicAuthenticationFilter
,我們還是可以模仿它實現自己的Filterpublic class JWTAuthenticationRequestFilter extends OncePerRequestFilter { private UserDetailsService userDetailsService; public JWTAuthenticationRequestFilter(UserDetailsService userDetailsService) { this.userDetailsService = userDetailsService; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { // 校驗Token Authentication authentication = getAuthentication(request); if (authentication == null) { filterChain.doFilter(request, response); return; } // 認證成功, 儲存到SecurityContext上下文中 SecurityContextHolder.getContext().setAuthentication(authentication); filterChain.doFilter(request, response); } private Authentication getAuthentication(HttpServletRequest request) throws JsonProcessingException { // 判斷是否有Token,拿到Token String header = request.getHeader("Authorization"); if (header == null || !header.startsWith("Bearer ")) { return null; } String token = header.split(" ")[1]; System.out.println("token = " + token); // 通過Token解析 Claims claims = JWTUtil.parse(token); if (claims == null) { return null; } User user = (User) new ObjectMapper().readValue(claims.getSubject(), User.class); // 開始驗證 // 通過使用者名稱查詢資料庫,確認是否存在該使用者 UserDetails userDetails = null; try { userDetails = userDetailsService.loadUserByUsername(user.getUsername()); } catch (UsernameNotFoundException e) { return null; } // 校驗Token的密碼與資料庫中使用者密碼是否一致 if (!userDetails.getPassword().equals(user.getPassword())) { return null; } // 構造Authentication物件 return new UsernamePasswordAuthenticationToken(userDetails.getUsername(), userDetails.getPassword(), userDetails.getAuthorities()); } }
簡單說明下這裡的邏輯:
- 對於任意的請求,都會被該過濾器攔截,首先會判斷是否攜帶了token,這裡設定是請求頭
Authentication
中放置Token - 接著對Token進行解析,拿出我們存放進去的user類物件
- 呼叫userService呼叫loadUserByUsername去查詢資料庫,確定是否存在該使用者
- 校驗資料庫中的使用者密碼是否與Token中的使用者密碼資訊一致,如果一致則構造一個可信的
Authentication
物件,這裡是UsernamepasswordAuthenticationToken
;如果不一致,則返回null - 若Token校驗不通過,則放行,SpringSecurity後續的過濾器會處理掉他的。
- 若Token校驗成功,則將校驗物件
Authentication
放入SpringContext上下文中
- 對於任意的請求,都會被該過濾器攔截,首先會判斷是否攜帶了token,這裡設定是請求頭
-
自定義SpringSecurity中的異常
前面一些處理報異常時並沒有返回到客戶端,我們還需要自定義SpringSecurity的全域性異常處理,返回JSON資料給到客戶端。
具體的操作可以學習小胖哥這篇博文:Spring Security 實戰乾貨:自定義異常處理
許可權相關異常處理:
public class SimpleAccessDeniedHandler implements AccessDeniedHandler { @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { Response myResponse = new Response(); myResponse.setStatusCode(HttpServletResponse.SC_FORBIDDEN); myResponse.setMsg("沒有許可權!"); response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.setCharacterEncoding("utf-8"); response.setContentType(MediaType.APPLICATION_JSON_VALUE); response.getWriter().write(new ObjectMapper().writeValueAsString(myResponse)); } }
認證相關異常處理:
public class SimpleAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { Response myResponse = new Response(); myResponse.setStatusCode(HttpServletResponse.SC_UNAUTHORIZED); myResponse.setMsg(authException.getMessage()); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setCharacterEncoding("utf-8"); response.setContentType(MediaType.APPLICATION_JSON_VALUE); response.getWriter().write(new ObjectMapper().writeValueAsString(myResponse)); } }
-
配置
我們還是自定義一個配置類繼承
SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity>
,配置我們自定義的Filter@Component public class JWTLoginConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> { @Autowired private UserService userService; @Override public void configure(HttpSecurity http) throws Exception { // 配置Filter JWTAuthenticationFilter jwtAuthenticationFilter = new JWTAuthenticationFilter("/jwt/login"); JWTAuthenticationRequestFilter jwtAuthenticationRequestFilter = new JWTAuthenticationRequestFilter(userService); // 配置AuthenticationManager jwtAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class)); // 新增Filter http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); http.addFilterBefore(jwtAuthenticationRequestFilter, UsernamePasswordAuthenticationFilter.class); } }
注意的點:
- 這裡雖說是一個配置類,但是最後是要併入SpringSecurity的主配置的,這裡用的註解是
@Component
,能讓我們在主配置類中進行注入。 - 自定義的認證Filter一定要設定
AuthenticationManager
,否則找不到對應的Provider來處理。
主配置類:
@Configuration public class MySecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private JWTLoginConfig jwtLoginConfig; @Autowired private UserService userService; @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Override public void configure(AuthenticationManagerBuilder builder) throws Exception { builder.userDetailsService(userService).passwordEncoder(passwordEncoder()); } @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers("/hello") .hasRole("ROOT") .anyRequest() .authenticated() .and() .apply(jwtLoginConfig) .and() .csrf() .disable(); // 自定義異常處理 http.exceptionHandling().accessDeniedHandler(new SimpleAccessDeniedHandler()).authenticationEntryPoint(new SimpleAuthenticationEntryPoint()); // 前後端分離是STATELESS,session使用該策略 http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS); } }
這裡我們自定義了UserDetailService方法,因此需要配置上
builder.userDetailsService(userService).passwordEncoder(passwordEncoder());
,密碼編碼器不可少。然後自定義異常的話就得使用
http.exceptionHandling().accessDeniedHandler(new SimpleAccessDeniedHandler()).authenticationEntryPoint(new SimpleAuthenticationEntryPoint());
將我們自定義的類傳入最後是將session策略設定為了STATELESS.
- 這裡雖說是一個配置類,但是最後是要併入SpringSecurity的主配置的,這裡用的註解是
5. 測試
我簡單寫了一個介面/hello
@RestController
public class UserController {
@GetMapping("/hello")
public String hello() {
return "hello jwt!";
}
}
通過Postman進行測試驗證:
-
訪問
/jwt/login
登入返回了一個Token,拿著這個Token去做請求
-
訪問
/hello
在發起請求時,在header部分新增一個key為Authentication,value為登入請求時返回的Token,才能正常訪問,獲得回覆。
-
當Token過期時,再去訪問
/hello
返回了401的錯誤,這是咱們自定義的異常處理器
SimpleAuthenticationEntryPoint
返回了資訊 -
我們在定義User類時預設寫死了許可權為
ROLE_ROOT
,那當我們將/hello
的訪問許可權修改為ROLE_USER
時去訪問http.authorizeRequests().antMatchers("/hello").hasRole("USER")
返回了403錯誤,這是自定義異常處理器
SimpleAccessDeniedHandler
返回的資訊
6. 原始碼地址
歡迎訪問 Lucas-張 / SpringSecurity