基於Token的WEB後臺認證機制(會話機制)
幾種常用的認證機制
HTTP Basic Auth
HTTP Basic Auth簡單點說明就是每次請求API時都提供使用者的username和password,簡言之,Basic Auth是配合RESTful API 使用的最簡單的認證方式,只需提供使用者名稱密碼即可,但由於有把使用者名稱密碼暴露給第三方客戶端的風險,在生產環境下被使用的越來越少。因此,在開發對外開放的RESTful API時,儘量避免採用HTTP Basic Auth
OAuth
OAuth(開放授權)是一個開放的授權標準,允許使用者讓第三方應用訪問該使用者在某一web服務上儲存的私密的資源(如照片,視訊,聯絡人列表),而無需將使用者名稱和密碼提供給第三方應用。
OAuth允許使用者提供一個令牌,而不是使用者名稱和密碼來訪問他們存放在特定服務提供者的資料。每一個令牌授權一個特定的第三方系統(例如,視訊編輯網站)在特定的時段(例如,接下來的2小時內)內訪問特定的資源(例如僅僅是某一相簿中的視訊)。這樣,OAuth讓使用者可以授權第三方網站訪問他們儲存在另外服務提供者的某些特定資訊,而非所有內容
下面是OAuth2.0的流程:
這種基於OAuth的認證機制適用於個人消費者類的網際網路產品,如社交類APP等應用,但是不太適合擁有自有認證許可權管理的企業應用;
Cookie Auth
Cookie認證機制就是為一次請求認證在服務端建立一個Session物件,同時在客戶端的瀏覽器端建立了一個Cookie物件;通過客戶端帶上來Cookie物件來與伺服器端的session物件匹配來實現狀態管理的。預設的,當我們關閉瀏覽器的時候,cookie會被刪除。但可以通過修改cookie 的expire time使cookie在一定時間內有效;
Token Auth
Token Auth的優點
Token機制相對於Cookie機制又有什麼好處呢?
- 支援跨域訪問: Cookie是不允許垮域訪問的,這一點對Token機制是不存在的,前提是傳輸的使用者認證資訊通過HTTP頭傳輸.
- 無狀態(也稱:服務端可擴充套件行):Token機制在服務端不需要儲存session資訊,因為Token 自身包含了所有登入使用者的資訊,只需要在客戶端的cookie或本地介質儲存狀態資訊.
- 更適用CDN: 可以通過內容分發網路請求你服務端的所有資料(如:javascript,HTML,圖片等),而你的服務端只要提供API即可.
- 去耦: 不需要繫結到一個特定的身份驗證方案。Token可以在任何地方生成,只要在你的API被呼叫的時候,你可以進行Token生成呼叫即可.
- 更適用於移動應用: 當你的客戶端是一個原生平臺(iOS, Android,Windows 8等)時,Cookie是不被支援的(你需要通過Cookie容器進行處理),這時採用Token認證機制就會簡單得多。
- CSRF:因為不再依賴於Cookie,所以你就不需要考慮對CSRF(跨站請求偽造)的防範。
- 效能: 一次網路往返時間(通過資料庫查詢session資訊)總比做一次HMACSHA256計算 的Token驗證和解析要費時得多.
- 不需要為登入頁面做特殊處理: 如果你使用Protractor 做功能測試的時候,不再需要為登入頁面做特殊處理.
- 基於標準化:你的API可以採用標準化的 JSON Web Token (JWT). 這個標準已經存在多個後端庫(.NET, Ruby, Java,Python, PHP)和多家公司的支援(如:Firebase,Google, Microsoft).
基於JWT的Token認證機制實現
JSON Web Token(JWT)是一個非常輕巧的規範。這個規範允許我們使用JWT在使用者和伺服器之間傳遞安全可靠的資訊。其
JWT的組成
一個JWT實際上就是一個字串,它由三部分組成,頭部、載荷與簽名。
將頭部進行Base64加密構成JWT token第一部分;
載荷(payload)進行base64加密構成JWT token第二部分 ;
將頭部,荷載和簽證拼在一起,用HMAC SHA256加密,得到的字串為JWT token第三部分。
最後,將這三部分拼接在一起就是jwt,即: 頭部. 載荷. 簽名
頭部(Header)
JWT還需要一個頭部,頭部用於描述關於該JWT的最基本的資訊,例如其型別以及簽名所用的演算法等。這也可以被表示成一個JSON物件。
{
"typ": "JWT",
"alg": "HS256"
}
在頭部指明瞭簽名演算法是HS256演算法。
當然頭部也要進行BASE64編碼,編碼後的字串如下:
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
載荷(Payload)
{ "iss": "Online JWT Builder",
"iat": 1416797419,
"exp": 1448333419,
"aud": "www.example.com",
"sub": "[email protected]",
"GivenName": "Johnny",
"Surname": "Rocket",
"Email": "[email protected]",
"Role": [ "Manager", "Project Administrator" ]
}
- iss: 該JWT的簽發者,是否使用是可選的;
- sub: 該JWT所面向的使用者,是否使用是可選的;
- aud: 接收該JWT的一方,是否使用是可選的;
- exp(expires): 什麼時候過期,這裡是一個Unix時間戳,是否使用是可選的;
- iat(issued at): 在什麼時候簽發的(UNIX時間),是否使用是可選的;
其他還有: - nbf (Not Before):如果當前時間在nbf裡的時間之前,則Token不被接受;一般都會留一些餘地,比如幾分鐘;,是否使用是可選的;
將上面的JSON物件進行[base64編碼]可以得到下面的字串。這個字串我們將它稱作JWT的Payload(載荷)。
eyJpc3MiOiJKb2huIFd1IEpXVCIsImlhdCI6MTQ0MTU5MzUwMiwiZXhwIjoxNDQxNTk0NzIyLCJhdWQiOiJ3d3cuZXhhbXBsZS5jb20iLCJzdWIiOiJqcm9ja2V0QGV4YW1wbGUuY29tIiwiZnJvbV91c2VyIjoiQiIsInRhcmdldF91c2VyIjoiQSJ9
小知識:Base64是一種基於64個可列印字元來表示二進位制資料的表示方法。由於2的6次方等於64,所以每6個位元為一個單元,對應某個可列印字元。三個位元組有24個位元,對應於4個Base64單元,即3個位元組需要用4個可列印字元來表示。JDK 中提供了非常方便的 BASE64Encoder 和 BASE64Decoder,用它們可以非常方便的完成基於 BASE64 的編碼和解碼
簽名(Signature)
將上面的兩個編碼後的字串都用句號.連線在一起(頭部在前),就形成了:
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJmcm9tX3VzZXIiOiJCIiwidGFyZ2V0X3VzZXIiOiJBIn0
最後,我們將上面拼接完的字串用HS256演算法進行加密。在加密的時候,我們還需要提供一個金鑰(secret)。如果我們用mystar作為金鑰的話,那麼就可以得到我們加密後的內容:
rSWamyAYwuHCo7IFAgd1oRpSP7nzL7BF5t7ItqpKViM
最後將這一部分簽名也拼接在被簽名的字串後面,我們就得到了完整的JWT:
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJmcm9tX3VzZXIiOiJCIiwidGFyZ2V0X3VzZXIiOiJBIn0.rSWamyAYwuHCo7IFAgd1oRpSP7nzL7BF5t7ItqpKViM
那麼,在我們輸入賬號密碼通過資料庫校驗時,生成一個token,返回給前端,以後前端每次呼叫介面請求時,在報文頭中新增key-value,那麼URL中會帶上這串JWT字串:
前端每次呼叫介面請求時,在報文頭中新增key-value:
Authorization:eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJmcm9tX3VzZXIiOiJCIiwidGFyZ2V0X3VzZXIiOiJBIn0.rSWamyAYwuHCo7IFAgd1oRpSP7nzL7BF5t7ItqpKViM
認證過程
下面我們從一個例項來看如何運用JWT機制實現認證:
登入
- 第一次認證:第一次登入,使用者從瀏覽器輸入使用者名稱/密碼,提交後到伺服器的登入處理的Action層(Login Action);
- Login Action呼叫認證服務進行使用者名稱密碼認證,如果認證通過,Login Action層呼叫使用者資訊服務獲取使用者資訊(包括完整的使用者資訊及對應許可權資訊);
- 返回使用者資訊後,Login Action從配置檔案中獲取Token簽名生成的祕鑰資訊,進行Token的生成;
- 生成Token的過程中可以呼叫第三方的JWT Lib生成簽名後的JWT資料;
- 完成JWT資料簽名後,將其設定到COOKIE物件中,並重定向到首頁,完成登入過程;
請求認證
基於Token的認證機制會在每一次請求中都帶上完成簽名的Token資訊,這個Token資訊可能在COOKIE
中,也可能在HTTP的Authorization頭中;
- 客戶端(APP客戶端或瀏覽器)通過GET或POST請求訪問資源(頁面或呼叫API);
- 認證服務作為一個Middleware HOOK 對請求進行攔截,首先在cookie中查詢Token資訊,如果沒有找到,則在HTTP Authorization Head中查詢;
- 如果找到Token資訊,則根據配置檔案中的簽名加密祕鑰,呼叫JWT Lib對Token資訊進行解密和解碼;
- 完成解碼並驗證簽名通過後,對Token中的exp、nbf、aud等資訊進行驗證;
- 全部通過後,根據獲取的使用者的角色許可權資訊,進行對請求的資源的許可權邏輯判斷;
- 如果許可權邏輯判斷通過則通過Response物件返回;否則則返回HTTP 401;
對Token認證的五點認識
對Token認證機制有5點直接注意的地方:
- 一個Token就是一些資訊的集合;
- 在Token中包含足夠多的資訊,以便在後續請求中減少查詢資料庫的機率;
- 服務端需要對cookie和HTTP Authrorization Header進行Token資訊的檢查;
- 基於上一點,你可以用一套token認證程式碼來面對瀏覽器類客戶端和非瀏覽器類客戶端;
- 因為token是被簽名的,所以我們可以認為一個可以解碼認證通過的token是由我們系統發放的,其中帶的資訊是合法有效的;
token只儲存在客戶端,卻能在伺服器端實現登入驗證的原理:
如果使用者解密出頭部和荷載,更改了使用者資訊,伺服器校驗的時候用祕鑰和客戶端攜帶的頭部和荷載通過HMAC SHA256加密得到JWT token的第三部分與使用者攜帶的第三部分進行對比,不一樣則認為是無效的JWT token,HMAC SHA256加密是不可逆的,因為使用者不知道祕鑰所以無法更改第三部分 。
JWT token只儲存在客戶端,跟session不同,可以減少伺服器的壓力。
JWT的JAVA實現的工具類
package com.utils;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTCreationException;
import com.auth0.jwt.exceptions.JWTDecodeException;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.exceptions.TokenExpiredException;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.AccessToken;
import java.io.UnsupportedEncodingException;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
public class TokenUtil {
/**
* 加密金鑰
*/
private static final String SECRET = "432d2eb**************20dba3633";
/**
* 預設過期2小時
*/
private static final long EXPIRE_TIME = 2 * 60 * 60 * 1000;
private static final String JWT_ISSUER = "JWT";
private static final String ID_CLAIM = "id";
/**
* 生成token
*
* @param id 使用者id
* @param expireTime 過期時間(毫秒ms)
* @return java.lang.String
*/
public static String sign(Long id, long expireTime) {
try {
Map<String, Object> headerClaims = new HashMap<String, Object>(2);
headerClaims.put("alg", "HS256");
headerClaims.put("typ", "JWT");
long currentTimeMillis = System.currentTimeMillis();
Date expireDate = new Date(currentTimeMillis + expireTime);
// 附帶username資訊
return JWT.create()
.withHeader(headerClaims)
.withIssuer(JWT_ISSUER)
.withClaim(ID_CLAIM, id)
.withIssuedAt(new Date(currentTimeMillis))
.withExpiresAt(expireDate)
.sign(Algorithm.HMAC256(SECRET));
} catch (UnsupportedEncodingException e) {
return null;
} catch (JWTCreationException exception) {
return null;
} catch (Exception e) {
return null;
}
}
/**
* 生成簽名,60min後過期
*
* @param id 使用者id
* @return java.lang.String
*/
public static String sign(Long id) {
try {
Map<String, Object> headerClaims = new HashMap<String, Object>(2);
headerClaims.put("alg", "HS256");
headerClaims.put("typ", "JWT");
long currentTimeMillis = System.currentTimeMillis();
Date expireDate = new Date(currentTimeMillis + EXPIRE_TIME);
// 附帶username資訊
return JWT.create()
.withHeader(headerClaims)
.withIssuer(JWT_ISSUER)
.withClaim(ID_CLAIM, id)
.withExpiresAt(expireDate)
.sign(Algorithm.HMAC256(SECRET));
} catch (UnsupportedEncodingException e) {
return null;
} catch (Exception e) {
return null;
}
}
/**
* 驗證token
*
* @param token token值
* @return com.ucar.bean.AccessToken
*/
public static AccessToken verify(String token) {
AccessToken result = new AccessToken();
try {
JWTVerifier verifier = JWT.require(Algorithm.HMAC256(SECRET)).withIssuer(JWT_ISSUER).build();
DecodedJWT jwt = verifier.verify(token);
result.setVerify(Boolean.TRUE);
result.setId(jwt.getClaim(ID_CLAIM).asLong());
result.setSignDate(jwt.getIssuedAt());
result.setExpireDate(jwt.getExpiresAt());
result.setExpire(jwt.getExpiresAt().compareTo(new Date()) <= 0 ? true : false);
} catch (TokenExpiredException e) {
DecodedJWT jwt = JWT.decode(token);
result.setVerify(Boolean.TRUE);
result.setExpire(Boolean.TRUE);
result.setId(jwt.getClaim(ID_CLAIM).asLong());
result.setSignDate(jwt.getIssuedAt());
result.setExpireDate(jwt.getExpiresAt());
} catch (JWTVerificationException e) {
result.setVerify(Boolean.FALSE);
} catch (Exception e) {
result.setVerify(Boolean.FALSE);
}
return result;
}
/**
* 驗證token
*
* @param token token值
* @return boolean
*/
public static boolean verifyToken(String token) {
try {
JWTVerifier verifier = JWT.require(Algorithm.HMAC256(SECRET))
.withIssuer(JWT_ISSUER)
.build();
verifier.verify(token);
return true;
} catch (JWTVerificationException e) {
return false;
} catch (Exception e) {
return false;
}
}
/**
* 校驗token
*
* @param token token值
* @param id 使用者的id
* @return boolean
*/
public static boolean verify(String token, Long id) {
try {
JWTVerifier verifier = JWT.require(Algorithm.HMAC256(SECRET))
.withIssuer(JWT_ISSUER)
.withClaim(ID_CLAIM, id)
.build();
verifier.verify(token);
return true;
} catch (JWTVerificationException e) {
return false;
} catch (Exception e) {
return false;
}
}
/**
* 獲取使用者id
*
* @param token token值
* @return java.lang.Long
*/
public static Long getId(String token) {
try {
DecodedJWT jwt = JWT.decode(token);
return jwt.getClaim(ID_CLAIM).asLong();
} catch (JWTDecodeException e) {
return null;
} catch (Exception e) {
return null;
}
}
/**
* 獲取頒發時間
*
* @param token token值
* @return java.util.Date
*/
public static Date getIssuedDate(String token) {
try {
DecodedJWT jwt = JWT.decode(token);
return jwt.getIssuedAt();
} catch (JWTDecodeException e) {
return null;
} catch (Exception e) {
return null;
}
}
/**
* 獲取過期時間
*
* @param token token值
* @return java.util.Date
*/
public static Date getExpireDate(String token) {
try {
DecodedJWT jwt = JWT.decode(token);
return jwt.getExpiresAt();
} catch (JWTDecodeException e) {
return null;
} catch (Exception e) {
return null;
}
}
/**
* 判斷是否過期
*
* @param token token值
* @return boolean
*/
public static boolean isExpire(String token) {
try {
DecodedJWT jwt = JWT.decode(token);
return jwt.getExpiresAt().compareTo(new Date()) <= 0 ? true : false;
} catch (JWTDecodeException e) {
return true;
} catch (Exception e) {
return true;
}
}
public static void main(String[] args) {
String token = sign(1L, 60 * 1000 * 24 * 365);
//String token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJKRUVTRSIsImV4cCI6MTUyNjAxMjkyMywidXNlcklkIjoxLCJpYXQiOjE1MjYwMTI4NjN9.NKhWgl_L-TmZCOSOUzTaKQFYFfM7OrjG6O55BQ2Ts9M";
System.out.println(token);
AccessToken result = verify(token);
System.out.println(result.isVerify());
System.out.println(result.isExpire());
System.out.println(result.getSignDate());
System.out.println(result.getExpireDate());
System.out.println(result.getId());
System.out.println(verify(token, 1L));
System.out.println(getId(token));
}
}
然後,配置攔截器,實現每次請求的登入驗證,預設全部請求進行攔截,然後通過自定義註解實現不需要登入驗證。
package com.interceptor;
import com.alibaba.fastjson.JSONObject;
import com.AccessLogin;
import com.AccessToken;
import com.Constant;
import com.utils.TokenUtil;
import com.Result;
import org.apache.commons.lang.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
/**
* 對所有的api請求進行攔截,驗證請求頭中是否攜帶合法且未過期的 token
*
* @author 吳佰川([email protected])建立
* @version 1.0
* @date 2018/10/26 8:36
*/
public class TokenInterceptor implements HandlerInterceptor {
private static final Logger LOGGER = LoggerFactory.getLogger(TokenInterceptor.class);
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if(handler instanceof HandlerMethod){
//檢查是否有AccessLogin註釋,有則跳過認證
AccessLogin accessLogin = ((HandlerMethod) handler).getMethodAnnotation(AccessLogin.class);
// 沒有註釋不驗證
if (accessLogin != null && !accessLogin.required()) {
return true;
} else {
// 從 http 請求頭中取出 token及uid
String token = request.getHeader(Constant.REQUEST_HEADER_TOKEN);
//token不存在
if (!StringUtils.isEmpty(token)) {
AccessToken accessToken = TokenUtil.verify(token);
if (accessToken.isVerify()) {
boolean isExpire = accessToken.isExpire();
if (isExpire) {
responseMsg(response, "token invalid");
return false;
}
request.setAttribute(Constant.REQUEST_HEADER_UID, accessToken.getId());
return true;
} else {
responseMsg(response, "token wrong");
return false;
}
} else {
responseMsg(response, "token does not exist");
return false;
}
}
} else {
return true;
}
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object o, ModelAndView modelAndView) throws Exception {
}
@Override
public void afterCompletion(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, Exception e) throws Exception {
}
/**
* 請求不通過,返回錯誤資訊給客戶端
*
* @param response 返回response
* @param msg 返回資訊
* @return void
*/
private void responseMsg(HttpServletResponse response, String msg) throws IOException {
Result result = new Result();
result.setStatus(-3);
result.setMsg(msg);
response.setCharacterEncoding("utf-8");
response.setContentType("application/json; charset=utf-8");
String json = JSONObject.toJSONString(result);
PrintWriter out = response.getWriter();
out.print(json);
out.flush();
out.close();
}
}
package com.annotation;
import java.lang.annotation.*;
/**
* 登入自定義註解
*
*/
@Documented
@Inherited
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AccessLogin {
boolean required() default true;
}