一、JWT
1.1 是什么?為什么要使用它?
互聯網服務器離不開用戶認證,一般流程是下面這樣。
1、用戶向服務器發送用戶名和密碼。
2、服務器驗證通過后,在當前對話(session)里面保存相關數據,比如用戶角色、登錄時間等等。
3、服務器向用戶返回一個 session_id,寫入用戶的 Cookie。
4、用戶隨后的每一次請求,都會通過 Cookie,將 session_id 傳回服務器。
5、服務器收到 session_id,找到前期保存的數據,由此得知用戶的身份。
這種模式的問題在於,擴展性(scaling)不好。單機當然沒有問題,如果是服務器集群,或者是跨域的服務導向架構,就要求 session 數據共享,每台服務器都能夠讀取 session。
舉例來說,A 網站和 B 網站是同一家公司的關聯服務。現在要求,用戶只要在其中一個網站登錄,再訪問另一個網站就會自動登錄,請問怎么實現?
一種解決方案是 session 數據持久化,寫入數據庫或別的持久層。各種服務收到請求后,都向持久層請求數據。這種方案的優點是架構清晰,缺點是工程量比較大。另外,持久層萬一掛了,就會單點失敗。
另一種方案是服務器索性不保存 session 數據了,所有數據都保存在客戶端,每次請求都發回服務器。JWT 就是這種方案的一個代表。
JSON Web Token(JWT)是一個非常輕巧的規范。這個規范允許我們使用JWT在用戶和服務器之間傳遞安全可靠的信息 。
1.2 JWT的結構
JWT包含了使用.分隔的三部分:
-
Header 頭部
1. 聲明類型,這里是JWT
2. 加密算法,自定義
-
Payload 載荷,就是有效數據,在官方文檔中(RFC7519),這里給了7個示例信息:
1. iss (issuer):表示簽發人
2. exp (expiration time):表示token過期時間
3. sub (subject):主題
4. aud (audience):受眾
5. nbf (Not Before):生效時間
6. iat (Issued At):簽發時間
7. jti (JWT ID):編號
-
Signature 簽名
是整個數據的認證信息。一般根據前兩步的數據,再加上服務的的密鑰secret(密鑰保存在服務端,不能泄露給客戶端),通過Header中配置的加密算法生成。用於驗證整個數據完整和可靠性。
1.3 JWT工作流程
下面是一個JWT的工作流程圖。模擬一下實際的流程是這樣的(假設受保護的API在/protected中)
1.用戶導航到登錄頁,輸入用戶名、密碼,進行登錄
2.服務器驗證登錄鑒權,如果用戶合法,根據用戶的信息和服務器的規則生成JWT Token
3.服務器將該token以json形式返回(不一定要json形式,這里說的是一種常見的做法)
4.用戶得到token,存在localStorage、cookie或其它數據存儲形式中。
5.以后用戶請求/protected中的API時,在請求的header中加入 Authorization: Bearer xxxx(token)。此處注意token之前有一個7字符長度的 Bearer
6.服務器端對此token進行檢驗,如果合法就解析其中內容,根據其擁有的權限和自己的業務邏輯給出對應的響應結果。
7.用戶取得結果

1.4 JWT 的幾個特點
(1)JWT 默認是不加密,但也是可以加密的。生成原始 Token 以后,可以用密鑰再加密一次。
(2)JWT 不加密的情況下,不能將秘密數據寫入 JWT。
(3)JWT 不僅可以用於認證,也可以用於交換信息。有效使用 JWT,可以降低服務器查詢數據庫的次數。
(4)JWT 的最大缺點是,由於服務器不保存 session 狀態,因此無法在使用過程中廢止某個 token,或者更改 token 的權限。也就是說,一旦 JWT 簽發了,在到期之前就會始終有效,除非服務器部署額外的邏輯。
(5)JWT 本身包含了認證信息,一旦泄露,任何人都可以獲得該令牌的所有權限。為了減少盜用,JWT 的有效期應該設置得比較短。對於一些比較重要的權限,使用時應該再次對用戶進行認證。
(6)為了減少盜用,JWT 不應該使用 HTTP 協議明碼傳輸,要使用 HTTPS 協議傳輸。
1.5 JWT存在的問題
JWT 也不是天衣無縫,由客戶端維護登錄狀態帶來的一些問題在這里依然存在,舉例如下:
-
續簽問題,這是被很多人詬病的問題之一,傳統的cookie+session的方案天然的支持續簽,但是jwt由於服務端不保存用戶狀態,因此很難完美解決續簽問題,如果引入redis,雖然可以解決問題,但是jwt也變得不倫不類了。
-
注銷問題,由於服務端不再保存用戶信息,所以一般可以通過修改secret來實現注銷,服務端secret修改后,已經頒發的未過期的token就會認證失敗,進而實現注銷,不過畢竟沒有傳統的注銷方便。
-
密碼重置,密碼重置后,原本的token依然可以訪問系統,這時候也需要強制修改secret。
-
基於第2點和第3點,一般建議不同用戶取不同secret。
二、SpringSecurity
Spring Security 是為基於Spring的應用程序提供聲明式安全保護的安全性框架。
一般來說,Web 應用的安全性包括用戶認證(Authentication)和用戶授權(Authorization)兩個部分。
用戶認證:指的是驗證某個用戶是否為系統中的合法主體,也就是說用戶能否訪問該系統。用戶認證一般要求用戶提供用戶名和密碼。系統通過校驗用戶名和密碼來完成認證過程。
用戶授權:指的是驗證某個用戶是否有權限執行某個操作。在一個系統中,不同用戶所具有的權限是不同的。比如對一個文件來說,有的用戶只能進行讀取,而有的用戶可以進行修改。一般來說,系統會為不同的用戶分配不同的角色,而每個角色則對應一系列的權限。
對於上面提到的兩種應用情景,Spring Security 框架都有很好的支持。
三、案例
3.1 導入依賴
<!--jwt--> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>${jjwt.version}</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>${jjwt.version}</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>${jjwt.version}</version> </dependency>
3.2 配置application.properties
JWT的主要配置
#jwt jwt: header: Authorization # 令牌前綴 token-start-with: Bearer # 必須使用最少88位的Base64對該令牌進行編碼 base64-secret: ZmQ0ZGI5NjQ0MDQwY2I4MjMxY2Y3ZmI3MjdhN2ZmMjNhODViOTg1ZGE0NTBjMGM4NDA5NzYxMjdjOWMwYWRmZTBlZjlhNGY3ZTg4Y2U3YTE1ODVkZDU5Y2Y3OGYwZWE1NzUzNWQ2YjFjZDc0NGMxZWU2MmQ3MjY1NzJmNTE0MzI= # 令牌過期時間 此處單位/毫秒 ,默認4小時,可在此網站生成 https://www.convertworld.com/zh-hans/time/milliseconds.html token-validity-in-seconds: 14400000 # 在線用戶key online-key: online-token # 驗證碼 code-key: code-key
3.3 實體類VO
JwtUser、AuthUse(登錄信息)
/** * JWT封裝VO */ @Getter @AllArgsConstructor public class JwtUser implements UserDetails { private final Long id; private final String username; private final String nickName; private final String sex; @JsonIgnore private final String password; private final String avatar; private final String email; private final String phone; private final String dept; private final String job; @JsonIgnore private final Collection<GrantedAuthority> authorities; private final boolean enabled; private Timestamp createTime; @JsonIgnore private final Date lastPasswordResetDate; @JsonIgnore @Override public boolean isAccountNonExpired() { return true; } @JsonIgnore @Override public boolean isAccountNonLocked() { return true; } @JsonIgnore @Override public boolean isCredentialsNonExpired() { return true; } @JsonIgnore @Override public String getPassword() { return password; } @Override public boolean isEnabled() { return enabled; } public Collection getRoles() { return authorities.stream().map(GrantedAuthority::getAuthority).collect(Collectors.toSet()); } }
/** * 登錄用戶VO */ @Getter @Setter public class AuthUser { @NotBlank private String username; @NotBlank private String password; private String code; private String uuid = ""; @Override public String toString() { return "{username=" + username + ", password= ******}"; } }
3.4 權限配置類
JwtAccessDeniedHandler:處理認證過的用戶訪問無權限資源
JwtAuthenticationEntryPoint:用來解決匿名用戶訪問無權限資源時的異常
TokenProvider:用於生成令牌,驗證等等一些操作
TokenFilter:Jwt 過濾器,驗證令牌token是否合法
/** * 認證過的用戶訪問無權限資源 */ @Component public class JwtAccessDeniedHandler implements AccessDeniedHandler { @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException { //當用戶在沒有授權的情況下訪問受保護的REST資源時,將調用此方法發送403 Forbidden響應 response.sendError(HttpServletResponse.SC_FORBIDDEN, accessDeniedException.getMessage()); } }
/** * 用來解決匿名用戶訪問無權限資源時的異常 * 禁止(代替默認)彈出登錄頁面,返回錯誤信息 */ @Component public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint,Serializable { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { // 當用戶嘗試訪問安全的REST資源而不提供任何憑據時,將調用此方法發送401 響應 response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException==null?"Unauthorized":authException.getMessage()); } }
/** * 用於生成令牌,驗證等等一些操作 */ @Slf4j @Component public class TokenProvider implements InitializingBean { private final SecurityProperties properties; private static final String AUTHORITIES_KEY = "auth"; private Key key; public TokenProvider(SecurityProperties properties) { this.properties = properties; } @Override public void afterPropertiesSet() { byte[] keyBytes = Decoders.BASE64.decode(properties.getBase64Secret()); this.key = Keys.hmacShaKeyFor(keyBytes); } public String createToken(Authentication authentication) { String authorities = authentication.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.joining(",")); long now = (new Date()).getTime(); Date validity = new Date(now + properties.getTokenValidityInSeconds()); return Jwts.builder() //主題 .setSubject(authentication.getName()) //自定義屬性 放入用戶擁有的權限 .claim(AUTHORITIES_KEY, authorities) //簽名算法和密鑰 .signWith(key, SignatureAlgorithm.HS512) //失效時間 .setExpiration(validity) .compact(); } Authentication getAuthentication(String token) { //解析token Claims claims = Jwts.parser() .setSigningKey(key) .parseClaimsJws(token) .getBody(); Collection<? extends GrantedAuthority> authorities = Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(",")) .map(SimpleGrantedAuthority::new) .collect(Collectors.toList()); User principal = new User(claims.getSubject(), "", authorities); return new UsernamePasswordAuthenticationToken(principal, token, authorities); } boolean validateToken(String authToken) { try { Jwts.parser().setSigningKey(key).parseClaimsJws(authToken); return true; } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) { log.info("Invalid JWT signature."); e.printStackTrace(); } catch (ExpiredJwtException e) { log.info("Expired JWT token."); e.printStackTrace(); } catch (UnsupportedJwtException e) { log.info("Unsupported JWT token."); e.printStackTrace(); } catch (IllegalArgumentException e) { log.info("JWT token compact of handler are invalid."); e.printStackTrace(); } return false; } public String getToken(HttpServletRequest request){ final String requestHeader = request.getHeader(properties.getHeader()); if (requestHeader != null && requestHeader.startsWith(properties.getTokenStartWith())) { return requestHeader.substring(7); } return null; } }
/** * Jwt 過濾器 * 驗證令牌token是否合法 */ @Slf4j public class TokenFilter extends GenericFilterBean { private final TokenProvider tokenProvider; TokenFilter(TokenProvider tokenProvider) { this.tokenProvider = tokenProvider; } @Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest; String token = resolveToken(httpServletRequest); String requestRri = httpServletRequest.getRequestURI(); // 驗證 token 是否存在 OnlineUser onlineUser = null; try { SecurityProperties properties = SpringContextHolder.getBean(SecurityProperties.class); OnlineUserService onlineUserService = SpringContextHolder.getBean(OnlineUserService.class); onlineUser = onlineUserService.getOne(properties.getOnlineKey() + token); } catch (ExpiredJwtException e) { log.error(e.getMessage()); } if (onlineUser != null && StringUtils.hasText(token) && tokenProvider.validateToken(token)) { Authentication authentication = tokenProvider.getAuthentication(token); SecurityContextHolder.getContext().setAuthentication(authentication); log.debug("set Authentication to security context for '{}', uri: {}", authentication.getName(), requestRri); } else { log.debug("no valid JWT token found, uri: {}", requestRri); } filterChain.doFilter(servletRequest, servletResponse); } private String resolveToken(HttpServletRequest request) { SecurityProperties properties = SpringContextHolder.getBean(SecurityProperties.class); String bearerToken = request.getHeader(properties.getHeader()); if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(properties.getTokenStartWith())) { return bearerToken.substring(7); } return null; } }
3.5 配置類和用戶服務類
UserDetailsServiceImpl:處理用戶信息
SecurityProperties:配置文件獲取類
SecurityConfig:主配置類
@Service("userDetailsService")
@Transactional(propagation = Propagation.SUPPORTS, readOnly = true, rollbackFor = Exception.class)
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserService userService;
private final RoleService roleService;
public UserDetailsServiceImpl(UserService userService, RoleService roleService) {
this.userService = userService;
this.roleService = roleService;
}
@Override
public UserDetails loadUserByUsername(String username){
UserDto user = userService.findByName(username);
if (user == null) {
throw new BadRequestException("賬號不存在");
} else {
if (!user.getEnabled()) {
throw new BadRequestException("賬號未激活");
}
return createJwtUser(user);
}
}
private UserDetails createJwtUser(UserDto user) {
return new JwtUser(
user.getId(),
user.getUsername(),
user.getNickName(),
user.getSex(),
user.getPassword(),
user.getAvatar(),
user.getEmail(),
user.getPhone(),
Optional.ofNullable(user.getDept()).map(DeptSmallDto::getName).orElse(null),
Optional.ofNullable(user.getJob()).map(JobSmallDto::getName).orElse(null),
roleService.mapToGrantedAuthorities(user),
user.getEnabled(),
user.getCreateTime(),
user.getLastPasswordResetTime()
);
}
}
/** * Jwt參數配置 */ @Data @Configuration @ConfigurationProperties(prefix = "jwt") public class SecurityProperties { /** Request Headers : Authorization */ private String header; /** 令牌前綴,最后留個空格 Bearer */ private String tokenStartWith; /** 必須使用最少88位的Base64對該令牌進行編碼 */ private String base64Secret; /** 令牌過期時間 此處單位/毫秒 */ private Long tokenValidityInSeconds; /** 在線用戶 key,根據 key 查詢 redis 中在線用戶的數據 */ private String onlineKey; /** 驗證碼 key */ private String codeKey; public String getTokenStartWith() { return tokenStartWith + " "; } }
@Configuration @EnableWebSecurity @EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true) public class SecurityConfig extends WebSecurityConfigurerAdapter { private final TokenProvider tokenProvider; private final CorsFilter corsFilter; private final JwtAuthenticationEntryPoint authenticationErrorHandler; private final JwtAccessDeniedHandler jwtAccessDeniedHandler; private final ApplicationContext applicationContext; public SecurityConfig(TokenProvider tokenProvider, CorsFilter corsFilter, JwtAuthenticationEntryPoint authenticationErrorHandler, JwtAccessDeniedHandler jwtAccessDeniedHandler, ApplicationContext applicationContext) { this.tokenProvider = tokenProvider; this.corsFilter = corsFilter; this.authenticationErrorHandler = authenticationErrorHandler; this.jwtAccessDeniedHandler = jwtAccessDeniedHandler; this.applicationContext = applicationContext; } @Bean GrantedAuthorityDefaults grantedAuthorityDefaults() { // 去除 ROLE_ 前綴 return new GrantedAuthorityDefaults(""); } @Bean public PasswordEncoder passwordEncoder() { // 密碼加密方式 return new BCryptPasswordEncoder(); } @Override protected void configure(HttpSecurity httpSecurity) throws Exception { // 搜尋匿名標記 url: @AnonymousAccess Map<RequestMappingInfo, HandlerMethod> handlerMethodMap = applicationContext.getBean(RequestMappingHandlerMapping.class).getHandlerMethods(); Set<String> anonymousUrls = new HashSet<>(); for (Map.Entry<RequestMappingInfo, HandlerMethod> infoEntry : handlerMethodMap.entrySet()) { HandlerMethod handlerMethod = infoEntry.getValue(); AnonymousAccess anonymousAccess = handlerMethod.getMethodAnnotation(AnonymousAccess.class); if (null != anonymousAccess) { anonymousUrls.addAll(infoEntry.getKey().getPatternsCondition().getPatterns()); } } httpSecurity // 禁用 CSRF .csrf().disable() .addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class) // 授權異常 .exceptionHandling() .authenticationEntryPoint(authenticationErrorHandler) .accessDeniedHandler(jwtAccessDeniedHandler) // 防止iframe 造成跨域 .and() .headers() .frameOptions() .disable() // 不創建會話 .and() .sessionManagement() .sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() // 靜態資源等等 .antMatchers( HttpMethod.GET, "/*.html", "/**/*.html", "/**/*.css", "/**/*.js", "/webSocket/**" ).permitAll() // swagger 文檔 .antMatchers("/swagger-ui.html").permitAll() .antMatchers("/swagger-resources/**").permitAll() .antMatchers("/webjars/**").permitAll() .antMatchers("/*/api-docs").permitAll() // 文件 .antMatchers("/avatar/**").permitAll() .antMatchers("/file/**").permitAll() // 阿里巴巴 druid .antMatchers("/druid/**").permitAll() // 放行OPTIONS請求 .antMatchers(HttpMethod.OPTIONS, "/**").permitAll() // 自定義匿名訪問所有url放行 : 允許匿名和帶權限以及登錄用戶訪問 .antMatchers(anonymousUrls.toArray(new String[0])).permitAll() // 所有請求都需要認證 .anyRequest().authenticated() .and().apply(securityConfigurerAdapter()); } private TokenConfigurer securityConfigurerAdapter() { return new TokenConfigurer(tokenProvider); } }
番外
有兩個和 JWT 相關的過濾器配置:
1. 登錄請求攔截器:UsernamePasswordAuthenticationFilter
在用戶的登錄的過濾器中校驗用戶是否登錄成功,如果登錄成功,則生成一個token返回給客戶端,登錄失敗則給前端一個登錄失敗的提示。
2. token驗證攔截器:BasicAuthenticationFilter 繼承 OncePerRequestFilter 繼承 GenericFilterBean。 自定義攔截器,繼承這三個任意一個。
第二個過濾器則是當其他請求發送來,校驗token的過濾器,如果校驗成功,就讓請求繼續執行。
Spring Security遠不止這些,還需繼續學習!!!
