前言
需要把Web應用做成無狀態的,即服務器端無狀態,就是說服務器端不會存儲像會話這種東西,而是每次請求時access_token進行資源訪問。這里我們將使用 JWT 1,基於散列的消息認證碼,使用一個密鑰和一個消息作為輸入,生成它們的消息摘要。該密鑰只有服務端知道。訪問時使用該消息摘要進行傳播,服務端然后對該消息摘要進行驗證。
認證步驟
- 客戶端第一次使用用戶名密碼訪問認證服務器,服務器驗證用戶名和密碼,認證成功,使用用戶密鑰生成JWT並返回
- 之后每次請求客戶端帶上JWT
- 服務器對JWT進行驗證
自定義 jwt 攔截器
/** * oauth2攔截器,現在改為 JWT 認證 */
public class OAuth2Filter extends FormAuthenticationFilter {
/** * 設置 request 的鍵,用來保存 認證的 userID, */
private final static String USER_ID = "USER_ID";
@Resource
private JwtUtils jwtUtils;
/** * logger */
private static final Logger LOGGER = LoggerFactory.getLogger(OAuth2Filter.class);
/** * shiro權限攔截核心方法 返回true允許訪問resource, * * @param request * @param response * @param mappedValue * @return */
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
String token = getRequestToken((HttpServletRequest) request);
try {
// 檢查 token 有效性
//ExpiredJwtException JWT已過期
//SignatureException JWT可能被篡改
Jwts.parser().setSigningKey(jwtUtils.getSecret()).parseClaimsJws(token).getBody();
} catch (Exception e) {
// 身份驗證失敗,返回 false 將進入onAccessDenied 判斷是否登陸。
onLoginFail(response);
return false;
}
Long userId = getUserIdFromToken(token);
// 存入到 request 中,在后面的業務處理中可以使用
request.setAttribute(USER_ID, userId);
return true;
}
/** * 當訪問拒絕時是否已經處理了; * 如果返回true表示需要繼續處理; * 如果返回false表示該攔截器實例已經處理完成了,將直接返回即可。 * * @param request * @param response * @return * @throws Exception */
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
if (isLoginRequest(request, response)) {
if (isLoginSubmission(request, response)) {
return executeLogin(request, response);
} else {
return true;
}
} else {
onLoginFail(response);
return false;
}
}
/** * 鑒定失敗,返回錯誤信息 * @param token * @param e * @param request * @param response * @return */
@Override
protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response) {
try {
((HttpServletResponse) response).setStatus(HttpStatus.BAD_REQUEST.value());
response.getWriter().print("賬號活密碼錯誤");
} catch (IOException e1) {
LOGGER.error(e1.getMessage(), e1);
}
return false;
}
/** * token 認證失敗 * * @param response */
private void onLoginFail(ServletResponse response) {
HttpServletResponse httpResponse = (HttpServletResponse) response;
((HttpServletResponse) response).setStatus(HttpStatus.UNAUTHORIZED.value());
try {
response.getWriter().print("沒有權限,請聯系管理員授權");
} catch (IOException e) {
LOGGER.error(e.getMessage(), e);
}
}
/** * 獲取請求的token */
private String getRequestToken(HttpServletRequest httpRequest) {
//從header中獲取token
String token = httpRequest.getHeader(jwtUtils.getHeader());
//如果header中不存在token,則從參數中獲取token
if (StringUtils.isBlank(token)) {
return httpRequest.getParameter(jwtUtils.getHeader());
}
if (StringUtils.isBlank(token)) {
// 從 cookie 獲取 token
Cookie[] cookies = httpRequest.getCookies();
if (null == cookies || cookies.length == 0) {
return null;
}
for (Cookie cookie : cookies) {
if (cookie.getName().equals(jwtUtils.getHeader())) {
token = cookie.getValue();
break;
}
}
}
return token;
}
/** * 根據 token 獲取 userID * * @param token token * @return userId */
private Long getUserIdFromToken(String token) {
if (StringUtils.isBlank(token)) {
throw new KCException("無效 token", HttpStatus.UNAUTHORIZED.value());
}
Claims claims = jwtUtils.getClaimByToken(token);
if (claims == null || jwtUtils.isTokenExpired(claims.getExpiration())) {
throw new KCException(jwtUtils.getHeader() + "失效,請重新登錄", HttpStatus.UNAUTHORIZED.value());
}
return Long.parseLong(claims.getSubject());
}
}
將自定義shiro攔截器,設置到 ShiroFilterFactoryBean
中,然后將需要進行權限驗證的 path
進行設置攔截過濾。
登陸
@PostMapping("/login")
@ApiOperation("系統登陸")
public ResponseEntity<String> login(@RequestBody SysUserLoginForm userForm) {
String kaptcha = ShiroUtils.getKaptcha(Constants.KAPTCHA_SESSION_KEY);
if (!userForm.getCaptcha().equalsIgnoreCase(kaptcha)) {
throw new KCException("驗證碼不正確!");
}
UsernamePasswordToken token = new UsernamePasswordToken(userForm.getUsername(), userForm.getPassword());
Subject currentUser = SecurityUtils.getSubject();
currentUser.login(token);
//賬號鎖定
if (getUser().getStatus() == SysConstant.SysUserStatus.LOCK) {
throw new KCException("賬號已被鎖定,請聯系管理員");
}
// 登陸成功后直接返回 token ,然后后續放到 header 中認證
return ResponseEntity.status(HttpStatus.OK).body(jwtUtils.generateToken(getUserId()));
}
JwtUtils
我前面給 jwt 設置了三個參數
# jwt 配置
jwt:
# 加密密鑰
secret: 61D73234C4F93E03074D74D74D1E39D9 #blog.wuwii.com
# token有效時長
expire: 7 # 7天,單位天
# token 存在 header 中的參數
header: token
jwt 工具類的編寫
@ConfigurationProperties(prefix = "jwt")
@Component
public class JwtUtils {
/** * logger */
private Logger logger = LoggerFactory.getLogger(JwtUtils.class);
/** * 密鑰 */
private String secret;
/** * 有效期限 */
private int expire;
/** * 存儲 token */
private String header;
/** * 生成jwt token * * @param userId 用戶ID * @return token */
public String generateToken(long userId) {
Date nowDate = new Date();
return Jwts.builder()
.setHeaderParam("typ", "JWT")
// 后續獲取 subject 是 userid
.setSubject(userId + "")
.setIssuedAt(nowDate)
.setExpiration(DateUtils.addDays(nowDate, expire))
// 這里我采用的是 HS512 算法
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}
/** * 解析 token, * 利用 jjwt 提供的parser傳入秘鑰, * * @param token token * @return 數據聲明 Map<String, Object> */
public Claims getClaimByToken(String token) {
try {
return Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody();
} catch (Exception e) {
return null;
}
}
/** * token是否過期 * * @return true:過期 */
public boolean isTokenExpired(Date expiration) {
return expiration.before(new Date());
}
public String getSecret() {
return secret;
}
public void setSecret(String secret) {
this.secret = secret;
}
public int getExpire() {
return expire;
}
public void setExpire(int expire) {
this.expire = expire;
}
public String getHeader() {
return header;
}
public void setHeader(String header) {
this.header = header;
}
}
總結
由於 JWT 這種方式,服務端不需要保存任何狀態,所以服務端不需要使用 session 保存用戶信息,單元測試也比較方便,雖然中間轉碼解碼會消耗一些性能,但是影響不大,還比較方便的應用在 SSO 2。