本文源碼:Gitee·點這里
前言
本篇主要講述 Spring Security
如何結合 JWT
,實現無狀態下用戶登錄,使其滿足前后端分離及應用集群化部署要求。
什么是有狀態
有狀態服務是服務端記錄客戶端會話信息,即Session
信息。客戶端每次請求都會攜帶Session
信息,服務端以此來識別客戶端身份。而 Session
保存在服務端內存中的,不支持集群化部署。
當然 Spring
也給出了解決方案,即使用特殊方式將 Session
序列化存入到數據庫中,以實現會話共享,滿足集群化部署要求。詳細案例參見《SpringBoot 全家桶 | SpringSession + Redis實現會話共享》
什么是無狀態
無狀態服務即服務端不保存任何客戶端會話信息,而是由客戶端每次請求必須攜帶自描述信息,服務端通過這些信息來識別客戶端身份。JWT便是無狀態的一種實現標準
什么是JWT
JSON Web Token (JWT)是一個開放標準(RFC 7519
),它定義了一種緊湊的、自包含的方式,用于作為JSON對象在各方之間安全地傳輸信息。該信息可以被驗證和信任,因為它是數字簽名的。
JWT由三部分組成,這些部分由點(.)分隔,分別是:
- Header 標頭
- Payload 有效載體
- Signature 簽名
因此,JWT通常通常是這樣子的:xxxxx.yyyyy.zzzzz
更多詳細內容參見官網:JSON Web Tokens
JWT如何工作
下圖顯示了如何獲取JWT并將其用于訪問API或資源:
- 用程序或客戶端向授權服務器請求授權。
- 授權后,授權服務器會將訪問令牌返回給應用程序。
- 該應用程序使用訪問令牌來訪問受保護的資源(例如API)。
本案例使用框架
- Spring Security 《SpringBoot 全家桶 | SpringSecurity實戰》
- JWT
- Spring Data JPA 《SpringBoot 全家桶 | JPA實例詳解》
- MySQL
- Thymeleaf
完整代碼 - 碼云
Spring Security 集成 JWT
pom
文本引入io.jsonwebtoken.jjwt
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
增加JWT
工具類
public class JwtUtil {
public static final String TOKEN_HEADER = "Authorization";
public static final String TOKEN_PREFIX = "Bearer";
public static final long TTL = 2 * 60 * 60 * 1000;
public static final int TTL_COOKIE = 2 * 60 * 60;
private static final String SECRET_KEY = "http://www.lxweimin.com/u/1b5928185b73";
private static final String AUTHORITIES = "authorities";
/**
* 生成 token
*
* @param username
* @return
*/
public static String generateToken(String username) {
return generateToken(username, new ArrayList<>());
}
/**
* 生成 token
*
* @param username
* @param authorities
* @return
*/
public static String generateToken(String username, List<String> authorities) {
return Jwts.builder()
.setSubject(username) // 主題
.claim(AUTHORITIES, authorities)
.setIssuedAt(new Date()) // 發布時間
.setExpiration(new Date(System.currentTimeMillis() + TTL)) // 到期時間
.signWith(SignatureAlgorithm.HS256, SECRET_KEY) // 簽名
.compact();
}
/**
* 生成 token
*
* @param claims
* @return
*/
private static String generateToken(Claims claims) {
return Jwts.builder()
.setClaims(claims)
.setExpiration(new Date(System.currentTimeMillis() + TTL)) // 到期時間
.signWith(SignatureAlgorithm.HS256, SECRET_KEY) // 簽名
.compact();
}
/**
* 解析 token
*
* @param token
* @return
*/
public static Claims parseToken(String token) {
return Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token).getBody();
}
/**
* 獲取 username
*
* @param token
* @return
*/
public static String getUsername(String token) {
return parseToken(token).getSubject();
}
/**
* 獲取 username
*
* @param claims
* @return
*/
public static String getUsername(Claims claims) {
return claims.getSubject();
}
/**
* 是否過期
*
* @param token
* @return
*/
public static boolean isExpiration(String token) {
return parseToken(token).getExpiration().before(new Date());
}
/**
* 是否過期
*
* @param claims
* @return
*/
public static boolean isExpiration(Claims claims) {
return claims.getExpiration().before(new Date());
}
/**
* 獲取角色
*
* @param token
* @return
*/
public static List<String> getAuthorities(String token) {
return parseToken(token).get(AUTHORITIES, List.class);
}
/**
* 獲取角色
*
* @param claims
* @return
*/
public static List<String> getAuthorities(Claims claims) {
return claims.get(AUTHORITIES, List.class);
}
/**
* 刷新 token
*
* @param token
* @return
*/
public static String refreshToken(String token) {
return generateToken(parseToken(token));
}
}
Security
配置類增加JWT
配置
此處不詳細介紹Security
的配置,而是把重點放在集成JWT
上。(Security請參閱 《SpringBoot 全家桶 | SpringSecurity實戰》)
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final Logger log = LoggerFactory.getLogger(this.getClass());
@Resource
private UserDao userDao;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/css/**", "/js/**", "/", "/index", "/loginPage").permitAll() // 無需認證
.anyRequest().authenticated() // 其他請求都需要認證
;
http.addFilter(new JwtAuthorizationFilter(authenticationManager()));
http.formLogin() // 開啟登錄,如果沒有權限,就會跳轉到登錄頁
.loginPage("/loginPage") // 自定義登錄頁,默認/login(get請求)
.loginProcessingUrl("/login") // 登錄處理地址,默認/login(post請求)
.usernameParameter("inputEmail") // 自定義username屬性名,默認username
.passwordParameter("inputPassword") // 自定義password屬性名,默認password
.successHandler(loginSuccessHandler())
;
http.rememberMe() // 開啟記住我
.rememberMeParameter("rememberMe") // 自定義rememberMe屬性名
;
http.logout() // 開啟注銷
.logoutUrl("/logout") // 注銷處理路徑,默認/logout
.logoutSuccessUrl("/") // 注銷成功后跳轉路徑
.deleteCookies(TOKEN_HEADER) // 刪除cookie
;
http.csrf().disable(); // 禁止csrf
http.sessionManagement() // session管理
.sessionCreationPolicy(SessionCreationPolicy.STATELESS); // 無狀態,即不創建Session,也不使用SecurityContext獲取Session
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService())
.passwordEncoder(passwordEncoder());
}
@Bean
public UserDetailsService userDetailsService() {
return username -> {
xyz.zyl2020.securityjwt.entity.User user = userDao.findByUsernameOrEmail(username, username);
if (user == null) {
throw new UsernameNotFoundException("賬號或密碼錯誤!");
}
String[] roleCodeArray = user.getRoles().stream().map(Role::getCode).toArray(String[]::new);
return User.withUsername(user.getUsername())
.password(user.getPassword())
.authorities(roleCodeArray)
.build();
};
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationSuccessHandler loginSuccessHandler() {
return (request, response, authentication) -> {
List<String> authorities = new ArrayList<>();
if (!CollectionUtils.isEmpty(authentication.getAuthorities())) {
authorities.addAll(authentication.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.toList()));
}
String token = JwtUtil.generateToken(authentication.getName(), authorities);
// 將token添加到header中
response.setHeader(TOKEN_HEADER, JwtUtil.TOKEN_PREFIX + token);
// 將token添加到cookie中
Cookie cookie = new Cookie(TOKEN_HEADER, JwtUtil.TOKEN_PREFIX + token);
cookie.setPath("/");
cookie.setMaxAge(JwtUtil.TTL_COOKIE);
response.addCookie(cookie);
log.info("登錄成功,username: {}, token: {}", authentication.getName(), token);
};
}
}
-
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
將Session
管理配置為無狀態,這樣Security
便不會創建Session
了 -
loginSuccessHandler()
登錄成功處理器,用于登錄成功后,使用生成JWT
工具類生成token
,并將token
添加到header
和cookie
中(添加到cookie
的目標是為了兼容未做前后端分離的應用)給客戶端響應。 -
http.addFilter(new JwtAuthorizationFilter(authenticationManager()))
增加 JWT 授權過濾器,后面詳細介紹 -
http.logout().deleteCookies(TOKEN_HEADER)
用戶注銷后刪除token
JWT 授權過濾器
添加此過濾器的目的是客戶端每次請求時,驗證其令牌是否合法
public class JwtAuthorizationFilter extends BasicAuthenticationFilter {
public JwtAuthorizationFilter(AuthenticationManager authenticationManager) {
super(authenticationManager);
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
String headerToken = "";
// 從cookie中獲取token
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if (JwtUtil.TOKEN_HEADER.equals(cookie.getName()) && StringUtils.isNotBlank(cookie.getValue())) {
headerToken = cookie.getValue();
break;
}
}
}
// 從header中獲取token
if (StringUtils.isBlank(headerToken) && StringUtils.isNotBlank(request.getHeader(JwtUtil.TOKEN_HEADER))) {
headerToken = request.getHeader(JwtUtil.TOKEN_HEADER);
}
// 從參數中獲取token
if (StringUtils.isBlank(headerToken) && StringUtils.isNotBlank(request.getParameter(JwtUtil.TOKEN_HEADER))) {
headerToken = request.getParameter(JwtUtil.TOKEN_HEADER);
}
// 校驗token頭
if (StringUtils.isBlank(headerToken) || !headerToken.startsWith(JwtUtil.TOKEN_PREFIX)) {
chain.doFilter(request, response);
return;
}
// 解析token
String token = headerToken.substring(JwtUtil.TOKEN_PREFIX.length());
Claims claims = JwtUtil.parseToken(token);
// 校驗token是否過期
if (JwtUtil.isExpiration(claims)) {
chain.doFilter(request, response);
return;
}
String username = JwtUtil.getUsername(claims);
if (StringUtils.isBlank(username)) {
chain.doFilter(request, response);
return;
}
Collection<SimpleGrantedAuthority> authorities = new ArrayList<>();
for (String authority : JwtUtil.getAuthorities(claims)) {
authorities.add(new SimpleGrantedAuthority(authority));
}
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(username, null, authorities));
refreshToken(token, response);
super.doFilterInternal(request, response, chain);
}
/**
* 刷新token
*
* @param token
* @param response
*/
private void refreshToken(String token, HttpServletResponse response) {
token = JwtUtil.refreshToken(token);
// 將token添加到header中
response.setHeader(TOKEN_HEADER, JwtUtil.TOKEN_PREFIX + token);
// 將token添加到cookie中
Cookie cookie = new Cookie(TOKEN_HEADER, JwtUtil.TOKEN_PREFIX + token);
cookie.setPath("/");
cookie.setMaxAge(JwtUtil.TTL_COOKIE);
response.addCookie(cookie);
}
}
獲取客戶端令牌兼容了三種方式:
- 從
cookie
中獲取令牌,一般用于兼容未做前后端分離的應用 - 從
header
中獲取 - 從請求參數中獲取
令牌驗證通過后,解析其用戶名和權限,并將其添加至Security
上下文中。
最后刷新令牌,以保持用戶長時間活動時其令牌不會過期。