JWT
常見的認證機制
HTTP Basic Auth
HTTP Basic Auth簡單點說就是每次請求API時都提供用戶的username和password。簡言之,Basic Auth是配合RESTful API 使用的最簡單的認證方式,只需提供用戶名密碼即可,但由於有把用戶名密碼暴露給第三方客戶端的風險,在生產環境下被使用的越來越少。因此,在開發對外開放的RESTful API時,盡量避免采用HTTP Basic Auth。
Cookie Auth
Cookie認證機制就是為一次請求認證在服務端創建一個Session對象,同時在客戶端的瀏覽器端創建了一個Cookie對象;通過客戶端帶上來Cookie對象來與服務器端的session對象匹配來實現狀態管理的。默認的,當我們關閉瀏覽器的時候,cookie會被刪除。但可以通過修改cookie 的expire time使cookie在一定時間內有效。

OAuth
OAuth(開放授權,Open Authorization)是一個開放的授權標准,允許用戶讓第三方應用訪問該用戶在某一web服務上存儲的私密的資源(如照片,視頻,聯系人列表),而無需將用戶名和密碼提供給第三方應用。如網站通過微信、微博登錄等,主要用於第三方登錄。
OAuth允許用戶提供一個令牌,而不是用戶名和密碼來訪問他們存放在特定服務提供者的數據。每一個令牌授權一個特定的第三方系統(例如,視頻編輯網站)在特定的時段(例如,接下來的2小時內)內訪問特定的資源(例如僅僅是某一相冊中的視頻)。這樣,OAuth讓用戶可以授權第三方網站訪問他們存儲在另外服務提供者的某些特定信息,而非所有內容。
下面是OAuth2.0的流程:

這種基於OAuth的認證機制適用於個人消費者類的互聯網產品,如社交類APP等應用,但是不太適合擁有自有認證權限管理的企業應用。
缺點:過重。
Token Auth
使用基於 Token 的身份驗證方法,在服務端不需要存儲用戶的登錄記錄。大概的流程是這樣的:
-
客戶端使用用戶名跟密碼請求登錄
-
服務端收到請求,去驗證用戶名與密碼
-
驗證成功后,服務端會簽發一個 Token,再把這個 Token 發送給客戶端
-
客戶端收到 Token 以后可以把它存儲起來,比如放在 Cookie 里
-
客戶端每次向服務端請求資源的時候需要帶着服務端簽發的 Token
-
服務端收到請求,然后去驗證客戶端請求里面帶着的 Token,如果驗證成功,就向客戶端返回請求的數據

比第一種方式更安全,比第二種方式更節約服務器資源,比第三種方式更加輕量。
具體,Token Auth的優點(Token機制相對於Cookie機制又有什么好處呢?):
-
支持跨域訪問: Cookie是不允許垮域訪問的,這一點對Token機制是不存在的,前提是傳輸的用戶認證信息通過HTTP頭傳輸.
-
無狀態(也稱:服務端可擴展行):Token機制在服務端不需要存儲session信息,因為Token 自身包含了所有登錄用戶的信息,只需要在客戶端的cookie或本地介質存儲狀態信息.
-
更適用CDN: 可以通過內容分發網絡請求你服務端的所有資料(如:javascript,HTML,圖片等),而你的服務端只要提供API即可.
-
去耦: 不需要綁定到一個特定的身份驗證方案。Token可以在任何地方生成,只要在你的API被調用的時候,你可以進行Token生成調用即可.
-
更適用於移動應用: 當你的客戶端是一個原生平台(iOS, Android,Windows 10等)時,Cookie是不被支持的(你需要通過Cookie容器進行處理),這時采用Token認證機制就會簡單得多。
-
CSRF:因為不再依賴於Cookie,所以你就不需要考慮對CSRF(跨站請求偽造)的防范。
-
性能: 一次網絡往返時間(通過數據庫查詢session信息)總比做一次HMACSHA256計算的Token驗證和解析要費時得多.
-
不需要為登錄頁面做特殊處理: 如果你使用Protractor 做功能測試的時候,不再需要為登錄頁面做特殊處理.
-
基於標准化:你的API可以采用標准化的 JSON Web Token (JWT). 這個標准已經存在多個后端庫(.NET, Ruby, Java,Python, PHP)和多家公司的支持(如:Firebase,Google, Microsoft).
JWT簡介
什么是JWT
JSON Web Token(JWT)是一個開放的行業標准(RFC 7519),它定義了一種簡介的、自包含的協議格式,用於在通信雙方傳遞json對象,傳遞的信息經過數字簽名可以被驗證和信任。JWT可以使用HMAC算法或使用RSA的公鑰/私鑰對來簽名,防止被篡改。
官網: https://jwt.io/
標准: https://tools.ietf.org/html/rfc7519
JWT令牌的優點:
-
jwt基於json,非常方便解析。
-
可以在令牌中自定義豐富的內容,易擴展。
-
通過非對稱加密算法及數字簽名技術,JWT防止篡改,安全性高。
-
資源服務使用JWT可不依賴認證服務即可完成授權。
缺點:
- JWT令牌較長,占存儲空間比較大。
JWT組成
一個JWT實際上就是一個字符串,它由三部分組成,頭部、載荷與簽名。
頭部(Header)
頭部用於描述關於該JWT的最基本的信息,例如其類型(即JWT)以及簽名所用的算法(如HMAC SHA256或RSA)等。這也可以被表示成一個JSON對象。
{
"alg": "HS256",
"typ": "JWT"
}
-
typ:是類型。 -
alg:簽名的算法,這里使用的算法是HS256算法
我們對頭部的json字符串進行BASE64編碼(網上有很多在線編碼的網站),編碼后的字符串如下:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
Base64是一種基於64個可打印字符來表示二進制數據的表示方法。由於2的6次方等於64,所以每6個比特為一個單元,對應某個可打印字符。三個字節有24個比特,對應於4個Base64單元,即3個字節需要用4個可打印字符來表示。JDK 中提供了非常方便的 BASE64Encoder 和 BASE64Decoder,用它們可以非常方便的完成基於 BASE64 的編碼和解碼。
負載(Payload)
第二部分是負載,就是存放有效信息的地方。這個名字像是特指飛機上承載的貨品,這些有效信息包含三個部分:
- 標准中注冊的聲明(建議但不強制使用)
iss: jwt簽發者
sub: jwt所面向的用戶
aud: 接收jwt的一方
exp: jwt的過期時間,這個過期時間必須要大於簽發時間
nbf: 定義在什么時間之前,該jwt都是不可用的.
iat: jwt的簽發時間
jti: jwt的唯一身份標識,主要用來作為一次性token,從而回避重放攻擊。
- 公共的聲明
公共的聲明可以添加任何的信息,一般添加用戶的相關信息或其他業務需要的必要信息.但不建議添加敏感信息,因為該部分在客戶端可解密.
- 私有的聲明
私有聲明是提供者和消費者所共同定義的聲明,一般不建議存放敏感信息,因為base64是對稱解密的,意味着該部分信息可以歸類為明文信息。
這個指的就是自定義的claim。比如下面那個舉例中的name都屬於自定的claim。這些claim跟JWT標准規定的claim區別在於:JWT規定的claim,JWT的接收方在拿到JWT之后,都知道怎么對這些標准的claim進行驗證(還不知道是否能夠驗證);而private claims不會驗證,除非明確告訴接收方要對這些claim進行驗證以及規則才行。
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
}
其中sub是標准的聲明,name是自定義的聲明(公共的或私有的)
然后將其進行base64編碼,得到Jwt的第二部分:
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkphbWVzIiwiYWRtaW4iOnRydWV9
提示:聲明中不要放一些敏感信息。
簽證、簽名(signature)
jwt的第三部分是一個簽證信息,這個簽證信息由三部分組成:
-
header (base64后的)
-
payload (base64后的)
-
secret(鹽,一定要保密)
這個部分需要base64加密后的header和base64加密后的payload使用.連接組成的字符串,然后通過header中聲明的加密方式進行加鹽secret組合加密,然后就構成了jwt的第三部分:
8HI-Lod0ncfVDnbKIPJJqLH998duF9DSDGkx3gRPNVI
將這三部分用.連接成一個完整的字符串,構成了最終的jwt:
eyJhbGciOiJIUzI1NiIsInR9cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.8HI-Lod0ncfVDnbKIPJJqLH998duF9DSDGkx3gRPNVI
注意:secret是保存在服務器端的,jwt的簽發生成也是在服務器端的,secret就是用來進行jwt的簽發和jwt的驗證,所以,它就是你服務端的私鑰,在任何場景都不應該流露出去。一旦客戶端得知這個secret, 那就意味着客戶端是可以自我簽發jwt了。
JJWT簡介
什么是JJWT
JJWT是一個提供端到端的JWT創建和驗證的Java庫。永遠免費和開源(Apache License,版本2.0),JJWT很容易使用和理解。它被設計成一個以建築為中心的流暢界面,隱藏了它的大部分復雜性。
規范官網:https://jwt.io/
SpringSecurity整合JWT
SpringSecurity整合JWT最終達到的效果:
當用戶首次登錄的時候,輸入用戶名和密碼走正常的登錄邏輯,到數據庫中根據用戶名找到用戶的密碼信息,然后比對密碼是否匹配。若匹配,先將這個用戶存入Security的安全上下文holder中,然后利用JWT工具類生成一個token,返回給客戶端。
接下來,用戶每次請求,都需要在請求頭header中攜帶一個token,這個token首先會進入我們自定義的Jwt登錄認證過濾器,從請求頭中獲取token,利用Jwt工具類解析token,如果能獲取到用戶名,則用戶認證成功,執行下一個過濾器。否則用自定義的拒絕訪問類返回權限不足,或者直接用自定義的認證失敗類返回token失效或者請先登錄。
Application.properties配置
# 應用名稱
spring.application.name=springsecurityforjwt
# 應用服務 WEB 訪問端口
server.port=8080
# Jwt鹽
jwt.secret=******
# 請求頭的key value為token
jwt.tokenHeader=Authorization
# 過期時間7天
jwt.expiration=604800
# token的頭部
jwt.tokenHead=Bearer
# 配置freemarker視圖的位置
spring.freemarker.template-loader-path=classpath:/templates/
# 配置freemarker后綴
spring.freemarker.suffix=.ftl
spring.freemarker.charset=utf-8
JWT工具類
package com.zc.springsecurityforjwt.utils;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.util.StringUtils;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
public class JwtTokenUtil {
private static final String CLAIM_KEY_USERNAME="sub";
private static final String CLAIM_KEY_CREATED="created";
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.tokenHead}")
private String tokenHead;
@Value("${jwt.expiration}")
private long expiration;
public String getSecret() {
return secret;
}
public String getTokenHead() {
return tokenHead;
}
public long getExpiration() {
return expiration;
}
/**
* 生成token
* @param claims claims
* @return token
*/
private String generateToken(Map<String,Object> claims)
{
return Jwts.builder()
.setClaims(claims)
.setExpiration(generateExpirationDate())
.signWith(SignatureAlgorithm.HS256,secret)
.compact();
}
/**
* 根據用戶信息生成token
*/
public String generateToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
claims.put(CLAIM_KEY_USERNAME, userDetails.getUsername());
claims.put(CLAIM_KEY_CREATED, new Date());
return generateToken(claims);
}
/**
* 從token中獲取負載
* @param token token
* @return claims
*/
public Claims getClaimsFromToken(String token)
{
Claims claims = null;
try {
claims = Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody();
} catch (Exception e) {
throw new RuntimeException("JWT格式驗證失敗:{}");
}
return claims;
}
/**
* 從token中獲取登錄用戶名
* @param token
* @return
*/
public String getUsernameFromToken(String token)
{
String username;
try {
Claims claims = getClaimsFromToken(token);
username = claims.getSubject();
} catch (Exception e) {
username = null;
}
return username;
}
/**
* 生成過期時間
* @return expiration
*/
public Date generateExpirationDate() {
return new Date(System.currentTimeMillis()+expiration*1000);
}
/**
* 驗證token是否還有效
* @param token
*客戶端傳入的token
* @param userDetails 從數據庫中查詢出來的用戶信息
*/
public boolean validateToken(String token, UserDetails userDetails)
{
String username = getUsernameFromToken(token);
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
}
/**
* 判斷token是否已經失效
*/
public boolean isTokenExpired(String token) {
Date expiredDate = getExpiredDateFromToken(token);
return expiredDate.before(new Date());
}
/**
* 從token中獲取過期時間
*/
public Date getExpiredDateFromToken(String token) {
return getClaimsFromToken(token).getExpiration();
}
/**
* 當原來的token沒過期時是可以刷新的
*
* @param oldToken 帶tokenHead的token
*/
public String refreshHeadToken(String oldToken) {
if(StringUtils.isEmpty(oldToken)){
return null;
}
String token = oldToken.substring(tokenHead.length());
if(StringUtils.isEmpty(token)){
return null;
}
//token校驗不通過
Claims claims = getClaimsFromToken(token);
if(claims==null){
return null;
}
//如果token已經過期,不支持刷新
if(isTokenExpired(token)){
return null;
}else {
claims.put(CLAIM_KEY_CREATED, new Date());
return generateToken(claims);
}
}
}
Jwt登錄認證過濾器JwtAuthenticationFilter
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private UserService userService;
@Value("${jwt.tokenHeader}")
private String tokenHeader;
@Value("${jwt.tokenHead}")
private String tokenHead;
@Autowired
private JwtTokenUtil jwtTokenUtil;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
String authHeader=request.getHeader(tokenHeader);
System.out.println(authHeader);
//存在token
if (null!=authHeader&&authHeader.startsWith(tokenHead))
{
String authToken=authHeader.substring(tokenHead.length());
System.out.println(authToken);
String username = jwtTokenUtil.getUsernameFromToken(authToken);
System.out.println("token中解析到的用戶名為:"+username);
//token存在但是未登錄
if (null!=username&&null==SecurityContextHolder.getContext().getAuthentication())
{
//登錄
User user = userService.findUserByUsername(username);
System.out.println(user);
//判斷token是否有效
if (jwtTokenUtil.validateToken(authToken,user))
{
UsernamePasswordAuthenticationToken authenticationToken=
new UsernamePasswordAuthenticationToken(user,null, null);
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
}
}
chain.doFilter(request,response);
}
}
拒絕方法異常RestfulAccessDeniedHandler
@Component
public class RestfulAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) throws IOException, ServletException {
response.setCharacterEncoding("utf-8");
response.setContentType("application/json");
PrintWriter writer = response.getWriter();
RespBean error = RespBean.error("權限不足,聯系管理員!");
writer.write(new ObjectMapper().writeValueAsString(error));
error.setCode(403);
writer.flush();
writer.close();
}
}
認證失敗異常RestfulAuthorizationEntryPoint
/**
* 當未登錄或者token失效時訪問接口自定義的返回結果
*/
@Component
public class RestfulAuthorizationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
response.setCharacterEncoding("utf-8");
response.setContentType("application/json");
PrintWriter writer = response.getWriter();
RespBean bean = RespBean.error("請先登錄!");
bean.setCode(401);
writer.write(new ObjectMapper().writeValueAsString(bean));
writer.flush();
writer.close();
}
}
SpringSecurity安全配置類
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserService userService;
@Autowired
private JwtAuthenticationFilter jwtAuthenticationFilter;
@Autowired
private RestfulAccessDeniedHandler restfulAccessDeniedHandler;
@Autowired
private RestfulAuthorizationEntryPoint restfulAuthorizationEntryPoint;
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/toLogin","/login","/static/**","/webjars/**");
}
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/login","/static/**","wabjars/**","swagger-resources/**","/v2/api-doc/**").permitAll()
.and()
.authorizeRequests()
.anyRequest().authenticated()
.and()
//開啟跨域訪問
.cors()
.and()
//使用jwt,禁用csrf保護
.csrf().disable()
//關閉session存儲
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.headers().cacheControl();
//配置自定義過濾器 添加jwt登錄授權過濾器
//在過濾器UsernamePasswordAuthenticationFilter之前
http.addFilterBefore(jwtAuthenticationFilter,UsernamePasswordAuthenticationFilter.class);
//添加自定義未授權未登錄結果返回
http.exceptionHandling()
.accessDeniedHandler(restfulAccessDeniedHandler)
.authenticationEntryPoint(restfulAuthorizationEntryPoint);
}
@Bean
public JwtTokenUtil tokenUtil(){
return new JwtTokenUtil();
}
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
}
Controller
@RestController
public class LoginController {
@Autowired
UserService userService;
@PostMapping("/login")
public RespBean login(User user, HttpServletRequest request)
{
return userService.login(user,request);
}
@GetMapping("/user/info")
public User getUserInfo(Principal principal)
{
if (null==principal)
return null;
String username = principal.getName();
User user = userService.findUserByUsername(username);
user.setPassword(null);
return user;
}
}
Service實現類
@Service("userService")
public class UserServiceImpl implements UserService{
@Autowired
private UserMapper userMapper;
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private JwtTokenUtil jwtTokenUtil;
@Override
public User findUserByUsername(String username) {
System.out.println("findUserByUsername");
User user = userMapper.findUserByUsername(username);
return user;
}
@Override
public RespBean login(User user, HttpServletRequest request) {
//登錄
User userDetails = userMapper.findUserByUsername(user.getUsername());
System.out.println("登錄用戶為:"+user);
if (null==userDetails||passwordEncoder.matches(user.getPassword(),userDetails.getPassword()))
return RespBean.error("用戶名或者密碼不正確");
if (!userDetails.isEnabled())
return RespBean.error("賬戶被禁止使用,請聯系管理員");
//更新security登錄用戶對象
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken=
new UsernamePasswordAuthenticationToken(userDetails,null,userDetails.getAuthorities());
String token=jwtTokenUtil.generateToken(userDetails);
Map<String,Object> map=new HashMap<>();
map.put("token",token);
map.put("tokenHead",jwtTokenUtil.getTokenHead());
return RespBean.success("登錄成功",map);
}
}
Postman測試
用戶首次進行訪問登錄接口,攜帶username和password以post方式進行提交。服務器端返回一個token。
因為后端采用的PostMapping所以注意提交方式。

用戶訪問后端其他接口,需要在請求頭中加上token。
因為后端采用的GetMapping所以注意提交方式。

