要想實現完整的先後端分離,安全這塊是繞不開的,這個系統主要功能就是動態restful api管理,此次實踐包含兩個模塊,基於springBoot + shiro
搭建的權限管理系統後臺bootshiro, angular5 + typeScript
編寫的前端管理usthe。(ps:考慮到我幼小的心靈和水平,大神誤噴啊^_^~)html
總的長這樣~
:
前端
基於angular5 + angular-cli + typeScript + rxjs + bootstrap + adminLTE
,踐行angular最佳實踐。
過程當中node,webpack等有用到過,但我不熟。。。java
基於springboot + apache shiro + mybatis
框架,restful風格api,自定義狀態碼,json-web-token,druid數據庫鏈接池,swagger文檔生成,redis存儲refreshtoken和動態祕鑰,maven,MD5單向加密和AES雙向等。。。node
這個nginx做爲反向代理服務器,解決了跨域請求的問題。另外一個nginx做爲angular應用服務器,tomcat做爲bootshiro的服務器。 webpack
反向代理的nginx.conf見: confnginx
流程長這樣~
git
詳細實現技術見另外一篇: docker學習github
對加密認證簽發,api動態權限,token過時刷新,先後端交互等等實現的細節,慢慢更。web
在用戶密碼登陸認證中,明文傳輸用戶輸入的密碼是不可取的。在沒有用https的狀況下,這裏須要對用戶密碼加密傳輸,保證即便密碼泄露也不影響。 這裏的先後端加密解密下圖:
redis
因爲介紹的是動態加密信息方案,這裏並不會涉及以後的JWT簽發等。
下面是實現細節:
angular 前端發送get動態祕鑰請求後會對對象進行監聽,在回調函數裏獲取後端返回的祕鑰後再進行加密處理,以後再發送登陸請求。在angular我把請求服務化了,下面的代碼片斷會有點凌亂。
// 調用獲取tokenKey祕鑰服務 this.loginService.getTokenKey().subscribe( data => { this.responseData = data; if (this.responseData.data.tokenKey !== undefined) { const tokenKey = this.responseData.data.tokenKey; // 調用服務,發送認證請求 this.loginService.login(this.appId, this.password, tokenKey).subscribe( data2 => { // 認證成功返回jwt this.responseData = data2; if (this.responseData.meta.code === 1003 && this.responseData.data.jwt != null) { this.authService.updateAuthorizationToken(this.responseData.data.jwt); this.authService.updateUid(this.appId); this.authService.updateUser(this.responseData.data.user); this.router.navigateByUrl('/index'); } else { this.msg = '用戶名密碼錯誤'; this.isDisabled = true; } }, error => { console.error(error); this.msg = error; this.isDisabled = true; } ); } } );
@Injectable() export class LoginService { constructor(private httpUtil: HttpUtil) { } getTokenKey() { const url = 'account/login?tokenKey=get'; // 先向後臺申請加密tokenKey tokenKey=get // const getKeyParam = new HttpParams().set('tokenKey', 'get'); return this.httpUtil.get(url); } login(appId: string, password: string, tokenKey: string) { const url = 'account/login'; tokenKey = CryptoJS.enc.Utf8.parse(tokenKey); password = CryptoJS.enc.Utf8.parse(password); password = CryptoJS.AES.encrypt(password, tokenKey, {mode: CryptoJS.mode.ECB, padding: CryptoJS.pad.Pkcs7}).toString(); console.log(password); const param = new HttpParams().append('appId', appId) .append('password', password) .append('methodName', 'login') .append('timestamp', new Date().toUTCString()); return this.httpUtil.post(url, param); } }
後端是在一個filter中對登陸註冊請求進行攔截,判斷其是正常登陸註冊仍是獲取動態加密祕鑰請求,正常認證就走shiro,判斷爲獲取祕鑰則生成16隨機碼默認AES加密祕鑰爲約定16位,小於16位會報錯
,將祕鑰以<遠程IP,祕鑰>的<key,value>形式存儲到redis,設置其有效時間爲5秒5秒看本身狀況不要太大也不要過短,設置有效期是爲了防止被其餘人截取到加密密碼冒充用戶的狀況,把風險降更低
。
// 判斷若爲獲取登陸註冊加密動態祕鑰請求 if (isPasswordTokenGet(request)) { //動態生成祕鑰,redis存儲祕鑰供以後祕鑰驗證使用,設置有效期5秒用完即丟棄 String tokenKey = CommonUtil.getRandomString(16); try { redisTemplate.opsForValue().set("PASSWORD_TOKEN_KEY_"+request.getRemoteAddr().toUpperCase(),tokenKey,5, TimeUnit.SECONDS); // 動態祕鑰response返回給前端 Message message = new Message(); message.ok(1000,"issued tokenKey success") .addData("tokenKey",tokenKey); RequestResponseUtil.responseWrite(JSON.toJSONString(message),response); }catch (Exception e) { LOGGER.warn(e.getMessage(),e); // 動態祕鑰response返回給前端 Message message = new Message(); message.ok(1000,"issued tokenKey fail"); RequestResponseUtil.responseWrite(JSON.toJSONString(message),response); } return false; }
// 建立認證信息,其中就有包括獲取redis中對應IP的動態祕鑰 private AuthenticationToken createPasswordToken(ServletRequest request) { Map<String ,String> map = RequestResponseUtil.getRequestParameters(request); String appId = map.get("appId"); String timestamp = map.get("timestamp"); String password = map.get("password"); String host = request.getRemoteAddr(); String tokenKey = redisTemplate.opsForValue().get("PASSWORD_TOKEN_KEY_"+host.toUpperCase()); return new PasswordToken(appId,password,timestamp,host,tokenKey); }
jwt是自包含的令牌,自包含即整個令牌已經包含本身的角色,權限,用戶信息等各類認證一個用戶的必要信息,這樣就不用後端根據用戶標識再去數據庫查詢對應用戶的角色權限等。
jwt包含頭信息,載荷信息,簽名信息三個部分:
Header //頭信息 { "alg": "HS256", //摘要算法 "typ": "JWT" //token類型 } payload //載荷信息 { "sub": "1234567890", //用戶標識,subject "name": "John Doe", //用戶名 "exp": "Mon Nov 13 15:28:41 CST 2018" //有效期 } verify signature //簽名信息 HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret )
詳細到官網jwt試一波吧,輸入對應信息能夠生成JWT
jwt簽發解析使用的是jjwt,maven導入以下:
<dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.0</version> </dependency>
jwt簽發解析工具類:
/* * * @Author tomsun28 * @Description * @Date 16:29 2018/3/8 */ public class JsonWebTokenUtil { private static final Logger LOGGER = LoggerFactory.getLogger(JsonWebTokenUtil.class); public static final String SECRET_KEY = "?::4343fdf4fdf6cvf):"; private static final ObjectMapper MAPPER = new ObjectMapper(); private static CompressionCodecResolver CODECRESOLVER = new DefaultCompressionCodecResolver(); /* * * @Description json web token 簽發 * @param id 令牌ID * @param subject 用戶ID * @param issuer 簽發人 * @param period 有效時間(毫秒) * @param roles 訪問主張-角色 * @param permissions 訪問主張-權限 * @param algorithm 加密算法 * @Return java.lang.String */ public static String issueJWT(String id,String subject, String issuer, Long period, String roles, String permissions, SignatureAlgorithm algorithm) { // 當前時間戳 Long currentTimeMillis = System.currentTimeMillis(); // 祕鑰 byte[] secreKeyBytes = DatatypeConverter.parseBase64Binary(SECRET_KEY); JwtBuilder jwtBuilder = Jwts.builder(); if (!StringUtils.isEmpty(id)) { jwtBuilder.setId(id); } if (!StringUtils.isEmpty(subject)) { jwtBuilder.setSubject(subject); } if (!StringUtils.isEmpty(issuer)) { jwtBuilder.setIssuer(issuer); } // 設置簽發時間 jwtBuilder.setIssuedAt(new Date(currentTimeMillis)); // 設置到期時間 if (null != period) { jwtBuilder.setExpiration(new Date(currentTimeMillis+period*1000)); } if (!StringUtils.isEmpty(roles)) { jwtBuilder.claim("roles",roles); } if (!StringUtils.isEmpty(permissions)) { jwtBuilder.claim("perms",permissions); } // 壓縮,可選GZIP jwtBuilder.compressWith(CompressionCodecs.DEFLATE); // 加密設置 jwtBuilder.signWith(algorithm,secreKeyBytes); return jwtBuilder.compact(); } /** * 解析JWT的Payload */ public static String parseJwtPayload(String jwt){ Assert.hasText(jwt, "JWT String argument cannot be null or empty."); String base64UrlEncodedHeader = null; String base64UrlEncodedPayload = null; String base64UrlEncodedDigest = null; int delimiterCount = 0; StringBuilder sb = new StringBuilder(128); for (char c : jwt.toCharArray()) { if (c == '.') { CharSequence tokenSeq = io.jsonwebtoken.lang.Strings.clean(sb); String token = tokenSeq!=null?tokenSeq.toString():null; if (delimiterCount == 0) { base64UrlEncodedHeader = token; } else if (delimiterCount == 1) { base64UrlEncodedPayload = token; } delimiterCount++; sb.setLength(0); } else { sb.append(c); } } if (delimiterCount != 2) { String msg = "JWT strings must contain exactly 2 period characters. Found: " + delimiterCount; throw new MalformedJwtException(msg); } if (sb.length() > 0) { base64UrlEncodedDigest = sb.toString(); } if (base64UrlEncodedPayload == null) { throw new MalformedJwtException("JWT string '" + jwt + "' is missing a body/payload."); } // =============== Header ================= Header header = null; CompressionCodec compressionCodec = null; if (base64UrlEncodedHeader != null) { String origValue = TextCodec.BASE64URL.decodeToString(base64UrlEncodedHeader); Map<String, Object> m = readValue(origValue); if (base64UrlEncodedDigest != null) { header = new DefaultJwsHeader(m); } else { header = new DefaultHeader(m); } compressionCodec = CODECRESOLVER.resolveCompressionCodec(header); } // =============== Body ================= String payload; if (compressionCodec != null) { byte[] decompressed = compressionCodec.decompress(TextCodec.BASE64URL.decode(base64UrlEncodedPayload)); payload = new String(decompressed, io.jsonwebtoken.lang.Strings.UTF_8); } else { payload = TextCodec.BASE64URL.decodeToString(base64UrlEncodedPayload); } return payload; } /** * 驗籤JWT * * @param jwt json web token */ public static JwtAccount parseJwt(String jwt, String appKey) { Claims claims = Jwts.parser() .setSigningKey(DatatypeConverter.parseBase64Binary(appKey)) .parseClaimsJws(jwt) .getBody(); JwtAccount jwtAccount = new JwtAccount(); jwtAccount.setTokenId(claims.getId());// 令牌ID jwtAccount.setAppId(claims.getSubject());// 客戶標識 jwtAccount.setIssuer(claims.getIssuer());// 簽發者 jwtAccount.setIssuedAt(claims.getIssuedAt());// 簽發時間 jwtAccount.setAudience(claims.getAudience());// 接收方 jwtAccount.setRoles(claims.get("roles", String.class));// 訪問主張-角色 jwtAccount.setPerms(claims.get("perms", String.class));// 訪問主張-權限 return jwtAccount; }
首先說明設計的這個安全體系是是RBAC(基於角色的權限訪問控制)受權模型,即用戶--角色--資源
,用戶不直接和權限打交道,角色擁有資源,用戶擁有這個角色就有權使用角色所用戶的資源。全部這裏沒有權限一說,簽發jwt裏面也就只有用戶所擁有的角色而沒有權限。
爲啥說是真正的restful風格集成,雖然說shiro對rest不友好但他自己是有支持rest集成的filter--HttpMethodPermissionFilter
,這個shiro rest的 風格攔截器,會自動根據請求方法構建權限字符串( GET=read,POST=create,PUT=update,DELETE=delete)
構建權限字符串;eg: /users=rest[user]
, 會 自動拼接出user:read,user:create,user:update,user:delete
」權限字符串進行權限匹配(全部都得匹配,isPermittedAll)。
可是這樣感受不利於基於jwt的角色的權限控制,在細粒度上驗權url(即支持get,post,delete鑑別)就更無法了(我的看法)。打個比方:咱們對一個用戶簽發的jwt寫入角色列(role_admin,role_customer)。對不一樣request請求:url="api/resource/",httpMethod="GET"
,url="api/resource",httpMethod="POST"
,在基於角色-資源的受權模型中,這兩個url相同的請求對HttpMethodPermissionFilter是一種請求,用戶對應的角色擁有的資源url="api/resource",只要請求的url是"api/resource",不論它的請求方式是什麼,都會斷定經過這個請求,這在restful風格的api中確定是不可取的,對同一資源有些角色可能只要查詢的權限而沒有修改增長的權限。
可能會說在jwt中再增長權限列就行了嘛,可是在基於用戶-資源的受權模型中,雖然能判別是不一樣的請求,可是太麻煩了,對每一個資源咱們都要設計對應的權限列而後再塞入到jwt中,對每一個用戶都要單獨受權資源這也是不可取的。
對shiro的改造這裏自定義了一些規則:
shiro過濾器鏈的url=url+"=="+httpMethod
eg:對於url="api/resource/",httpMethod="GET"
的資源,其拼接出來的過濾器鏈匹配url=api/resource==GET
這樣對相同的url而不一樣的訪問方式,會斷定爲不一樣的資源,即資源再也不簡單是url,而是url和httpMethod的組合。基於角色的受權模型中,角色所擁有的資源形式爲url+"=="+httpMethod
。
這裏改變了過濾器的過濾匹配url規則,重寫PathMatchingFilterChainResolver的getChain方法,增長對上述規則的url的支持。
/* * * @Author tomsun28 * @Description * @Date 21:12 2018/4/20 */ public class RestPathMatchingFilterChainResolver extends PathMatchingFilterChainResolver { private static final Logger LOGGER = LoggerFactory.getLogger(RestPathMatchingFilterChainResolver.class); public RestPathMatchingFilterChainResolver() { super(); } public RestPathMatchingFilterChainResolver(FilterConfig filterConfig) { super(filterConfig); } /* * * @Description 重寫filterChain匹配 * @Param [request, response, originalChain] * @Return javax.servlet.FilterChain */ @Override public FilterChain getChain(ServletRequest request, ServletResponse response, FilterChain originalChain) { FilterChainManager filterChainManager = this.getFilterChainManager(); if (!filterChainManager.hasChains()) { return null; } else { String requestURI = this.getPathWithinApplication(request); Iterator var6 = filterChainManager.getChainNames().iterator(); String pathPattern; boolean flag = true; String[] strings = null; do { if (!var6.hasNext()) { return null; } pathPattern = (String)var6.next(); strings = pathPattern.split("=="); if (strings.length == 2) { // 分割出url+httpMethod,判斷httpMethod和request請求的method是否一致,不一致直接false if (WebUtils.toHttp(request).getMethod().toUpperCase().equals(strings[1].toUpperCase())) { flag = false; } else { flag = true; } } else { flag = false; } pathPattern = strings[0]; } while(!this.pathMatches(pathPattern, requestURI) || flag); if (LOGGER.isTraceEnabled()) { LOGGER.trace("Matched path pattern [" + pathPattern + "] for requestURI [" + requestURI + "]. Utilizing corresponding filter chain..."); } if (strings.length == 2) { pathPattern = pathPattern.concat("==").concat(WebUtils.toHttp(request).getMethod().toUpperCase()); } return filterChainManager.proxy(originalChain, pathPattern); } } }
重寫PathMatchingFilter的路徑匹配方法pathsMatch(),加入httpMethod支持。
/* * * @Author tomsun28 * @Description 重寫過濾鏈路徑匹配規則,增長REST風格post,get.delete,put..支持 * @Date 23:37 2018/4/19 */ public abstract class BPathMatchingFilter extends PathMatchingFilter { public BPathMatchingFilter() { } /* * * @Description 重寫URL匹配 加入httpMethod支持 * @Param [path, request] * @Return boolean */ @Override protected boolean pathsMatch(String path, ServletRequest request) { String requestURI = this.getPathWithinApplication(request); // path: url==method eg: http://api/menu==GET 須要解析出path中的url和httpMethod String[] strings = path.split("=="); if (strings.length <= 1) { // 分割出來只有URL return this.pathsMatch(strings[0], requestURI); } else { // 分割出url+httpMethod,判斷httpMethod和request請求的method是否一致,不一致直接false String httpMethod = WebUtils.toHttp(request).getMethod().toUpperCase(); return httpMethod.equals(strings[1].toUpperCase()) && this.pathsMatch(strings[0], requestURI); } } }
這樣增長httpMethod的改造就完成了,重寫ShiroFilterFactoryBean使其使用改造後的chainResolver:RestPathMatchingFilterChainResolver
/* * * @Author tomsun28 * @Description rest支持的shiroFilterFactoryBean * @Date 21:35 2018/4/20 */ public class RestShiroFilterFactoryBean extends ShiroFilterFactoryBean { private static final Logger LOGGER = LoggerFactory.getLogger(RestShiroFilterFactoryBean.class); public RestShiroFilterFactoryBean() { super(); } @Override protected AbstractShiroFilter createInstance() throws Exception { LOGGER.debug("Creating Shiro Filter instance."); SecurityManager securityManager = this.getSecurityManager(); String msg; if (securityManager == null) { msg = "SecurityManager property must be set."; throw new BeanInitializationException(msg); } else if (!(securityManager instanceof WebSecurityManager)) { msg = "The security manager does not implement the WebSecurityManager interface."; throw new BeanInitializationException(msg); } else { FilterChainManager manager = this.createFilterChainManager(); RestPathMatchingFilterChainResolver chainResolver = new RestPathMatchingFilterChainResolver(); chainResolver.setFilterChainManager(manager); return new RestShiroFilterFactoryBean.SpringShiroFilter((WebSecurityManager)securityManager, chainResolver); } } private static final class SpringShiroFilter extends AbstractShiroFilter { protected SpringShiroFilter(WebSecurityManager webSecurityManager, FilterChainResolver resolver) { if (webSecurityManager == null) { throw new IllegalArgumentException("WebSecurityManager property cannot be null."); } else { this.setSecurityManager(webSecurityManager); if (resolver != null) { this.setFilterChainResolver(resolver); } } } } }
上面是一些核心的代碼片斷,更多請看項目代碼。
對用戶帳戶登陸註冊的過濾filter:PasswordFilter
/* * * @Author tomsun28 * @Description 基於 用戶名密碼 的認證過濾器 * @Date 20:18 2018/2/10 */ public class PasswordFilter extends AccessControlFilter { private static final Logger LOGGER = LoggerFactory.getLogger(PasswordFilter.class); private StringRedisTemplate redisTemplate; @Override protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) { Subject subject = getSubject(request,response); // 若是其已經登陸,再此發送登陸請求 if(null != subject && subject.isAuthenticated()){ return true; } // 拒絕,統一交給 onAccessDenied 處理 return false; } @Override protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception { // 判斷若爲獲取登陸註冊加密動態祕鑰請求 if (isPasswordTokenGet(request)) { //動態生成祕鑰,redis存儲祕鑰供以後祕鑰驗證使用,設置有效期5秒用完即丟棄 String tokenKey = CommonUtil.getRandomString(16); try { redisTemplate.opsForValue().set("PASSWORD_TOKEN_KEY_"+request.getRemoteAddr().toUpperCase(),tokenKey,5, TimeUnit.SECONDS); // 動態祕鑰response返回給前端 Message message = new Message(); message.ok(1000,"issued tokenKey success") .addData("tokenKey",tokenKey); RequestResponseUtil.responseWrite(JSON.toJSONString(message),response); }catch (Exception e) { LOGGER.warn(e.getMessage(),e); // 動態祕鑰response返回給前端 Message message = new Message(); message.ok(1000,"issued tokenKey fail"); RequestResponseUtil.responseWrite(JSON.toJSONString(message),response); } return false; } // 判斷是不是登陸請求 if(isPasswordLoginPost(request)){ AuthenticationToken authenticationToken = createPasswordToken(request); Subject subject = getSubject(request,response); try { subject.login(authenticationToken); //登陸認證成功,進入請求派發json web token url資源內 return true; }catch (AuthenticationException e) { LOGGER.warn(authenticationToken.getPrincipal()+"::"+e.getMessage(),e); // 返回response告訴客戶端認證失敗 Message message = new Message().error(1002,"login fail"); RequestResponseUtil.responseWrite(JSON.toJSONString(message),response); return false; }catch (Exception e) { LOGGER.error(e.getMessage(),e); // 返回response告訴客戶端認證失敗 Message message = new Message().error(1002,"login fail"); RequestResponseUtil.responseWrite(JSON.toJSONString(message),response); return false; } } // 判斷是否爲註冊請求,如果經過過濾鏈進入controller註冊 if (isAccountRegisterPost(request)) { return true; } // 以後添加對帳戶的找回等 // response 告知無效請求 Message message = new Message().error(1111,"error request"); RequestResponseUtil.responseWrite(JSON.toJSONString(message),response); return false; } private boolean isPasswordTokenGet(ServletRequest request) { // String tokenKey = request.getParameter("tokenKey"); String tokenKey = RequestResponseUtil.getParameter(request,"tokenKey"); return (request instanceof HttpServletRequest) && ((HttpServletRequest) request).getMethod().toUpperCase().equals("GET") && null != tokenKey && "get".equals(tokenKey); } private boolean isPasswordLoginPost(ServletRequest request) { // String password = request.getParameter("password"); // String timestamp = request.getParameter("timestamp"); // String methodName = request.getParameter("methodName"); // String appId = request.getParameter("appId"); Map<String ,String> map = RequestResponseUtil.getRequestParameters(request); String password = map.get("password"); String timestamp = map.get("timestamp"); String methodName = map.get("methodName"); String appId = map.get("appId"); return (request instanceof HttpServletRequest) && ((HttpServletRequest) request).getMethod().toUpperCase().equals("POST") && null != password && null != timestamp && null != methodName && null != appId && methodName.equals("login"); } private boolean isAccountRegisterPost(ServletRequest request) { // String uid = request.getParameter("uid"); // String methodName = request.getParameter("methodName"); // String username = request.getParameter("username"); // String password = request.getParameter("password"); Map<String ,String> map = RequestResponseUtil.getRequestParameters(request); String uid = map.get("uid"); String username = map.get("username"); String methodName = map.get("methodName"); String password = map.get("password"); return (request instanceof HttpServletRequest) && ((HttpServletRequest) request).getMethod().toUpperCase().equals("POST") && null != username && null != password && null != methodName && null != uid && methodName.equals("register"); } private AuthenticationToken createPasswordToken(ServletRequest request) { // String appId = request.getParameter("appId"); // String password = request.getParameter("password"); // String timestamp = request.getParameter("timestamp"); Map<String ,String> map = RequestResponseUtil.getRequestParameters(request); String appId = map.get("appId"); String timestamp = map.get("timestamp"); String password = map.get("password"); String host = request.getRemoteAddr(); String tokenKey = redisTemplate.opsForValue().get("PASSWORD_TOKEN_KEY_"+host.toUpperCase()); return new PasswordToken(appId,password,timestamp,host,tokenKey); } public void setRedisTemplate(StringRedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; } }
支持restful風格的jwt鑑權filter:BJwtFilter
/* * * @Author tomsun28 * @Description 支持restful url 的過濾鏈 JWT json web token 過濾器,無狀態驗證 * @Date 0:04 2018/4/20 */ public class BJwtFilter extends BPathMatchingFilter { private static final Logger LOGGER = LoggerFactory.getLogger(BJwtFilter.class); private StringRedisTemplate redisTemplate; private AccountService accountService; protected boolean isAccessAllowed(ServletRequest servletRequest, ServletResponse servletResponse, Object mappedValue) throws Exception { Subject subject = getSubject(servletRequest,servletResponse); // 判斷是否爲JWT認證請求 if ((null == subject || !subject.isAuthenticated()) && isJwtSubmission(servletRequest)) { AuthenticationToken token = createJwtToken(servletRequest); try { subject.login(token); // return this.checkRoles(subject,mappedValue) && this.checkPerms(subject,mappedValue); return this.checkRoles(subject,mappedValue); }catch (AuthenticationException e) { LOGGER.info(e.getMessage(),e); // 若是是JWT過時 if (e.getMessage().equals("expiredJwt")) { // 這裏初始方案先拋出令牌過時,以後設計爲在Redis中查詢當前appId對應令牌,其設置的過時時間是JWT的兩倍,此做爲JWT的refresh時間 // 當JWT的有效時間過時後,查詢其refresh時間,refresh時間有效即從新派發新的JWT給客戶端, // refresh也過時則告知客戶端JWT時間過時從新認證 // 當存儲在redis的JWT沒有過時,即refresh time 沒有過時 String appId = WebUtils.toHttp(servletRequest).getHeader("appId"); String jwt = WebUtils.toHttp(servletRequest).getHeader("authorization"); String refreshJwt = redisTemplate.opsForValue().get("JWT-SESSION-"+appId); if (null != refreshJwt && refreshJwt.equals(jwt)) { // 從新申請新的JWT // 根據appId獲取其對應所擁有的角色(這裏設計爲角色對應資源,沒有權限對應資源) String roles = accountService.loadAccountRole(appId); long refreshPeriodTime = 36000L; //seconds爲單位,10 hours String newJwt = JsonWebTokenUtil.issueJWT(UUID.randomUUID().toString(),appId, "token-server",refreshPeriodTime >> 2,roles,null, SignatureAlgorithm.HS512); // 將簽發的JWT存儲到Redis: {JWT-SESSION-{appID} , jwt} redisTemplate.opsForValue().set("JWT-SESSION-"+appId,newJwt,refreshPeriodTime, TimeUnit.SECONDS); Message message = new Message().ok(1005,"new jwt").addData("jwt",newJwt); RequestResponseUtil.responseWrite(JSON.toJSONString(message),servletResponse); return false; }else { // jwt時間失效過時,jwt refresh time失效 返回jwt過時客戶端從新登陸 Message message = new Message().error(1006,"expired jwt"); RequestResponseUtil.responseWrite(JSON.toJSONString(message),servletResponse); return false; } } // 其餘的判斷爲JWT錯誤無效 Message message = new Message().error(1007,"error Jwt"); RequestResponseUtil.responseWrite(JSON.toJSONString(message),servletResponse); return false; }catch (Exception e) { // 其餘錯誤 LOGGER.warn(servletRequest.getRemoteAddr()+"JWT認證"+e.getMessage(),e); // 告知客戶端JWT錯誤1005,需從新登陸申請jwt Message message = new Message().error(1007,"error jwt"); RequestResponseUtil.responseWrite(JSON.toJSONString(message),servletResponse); return false; } }else { // 請求未攜帶jwt 判斷爲無效請求 Message message = new Message().error(1111,"error request"); RequestResponseUtil.responseWrite(JSON.toJSONString(message),servletResponse); return false; } } protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception { Subject subject = getSubject(servletRequest,servletResponse); // 未認證的狀況 if (null == subject || !subject.isAuthenticated()) { // 告知客戶端JWT認證失敗需跳轉到登陸頁面 Message message = new Message().error(1006,"error jwt"); RequestResponseUtil.responseWrite(JSON.toJSONString(message),servletResponse); }else { // 已經認證但未受權的狀況 // 告知客戶端JWT沒有權限訪問此資源 Message message = new Message().error(1008,"no permission"); RequestResponseUtil.responseWrite(JSON.toJSONString(message),servletResponse); } // 過濾鏈終止 return false; } private boolean isJwtSubmission(ServletRequest request) { String jwt = RequestResponseUtil.getHeader(request,"authorization"); String appId = RequestResponseUtil.getHeader(request,"appId"); return (request instanceof HttpServletRequest) && !StringUtils.isEmpty(jwt) && !StringUtils.isEmpty(appId); } private AuthenticationToken createJwtToken(ServletRequest request) { Map<String,String> maps = RequestResponseUtil.getRequestHeaders(request); String appId = maps.get("appId"); String ipHost = request.getRemoteAddr(); String jwt = maps.get("authorization"); String deviceInfo = maps.get("deviceInfo"); return new JwtToken(ipHost,deviceInfo,jwt,appId); } // 驗證當前用戶是否屬於mappedValue任意一個角色 private boolean checkRoles(Subject subject, Object mappedValue){ String[] rolesArray = (String[]) mappedValue; return rolesArray == null || rolesArray.length == 0 || Stream.of(rolesArray).anyMatch(role -> subject.hasRole(role.trim())); } // 驗證當前用戶是否擁有mappedValue任意一個權限 private boolean checkPerms(Subject subject, Object mappedValue){ String[] perms = (String[]) mappedValue; boolean isPermitted = true; if (perms != null && perms.length > 0) { if (perms.length == 1) { if (!subject.isPermitted(perms[0])) { isPermitted = false; } } else { if (!subject.isPermittedAll(perms)) { isPermitted = false; } } } return isPermitted; } public void setRedisTemplate(StringRedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; } public void setAccountService(AccountService accountService) { this.accountService = accountService; } }
realm數據源,數據提供service,匹配matchs,自定義token,spring集成shiro配置等其餘詳見項目代碼。
最後項目實現了基於jwt的動態restful api權限認證。
對於登陸的用戶簽發其對應的jwt,咱們在jwt設置他的固定有效期時間,在有效期內用戶攜帶jwt訪問沒問題,當過有效期後jwt失效,用戶須要從新登陸獲取新的jwt。這個體驗不太好,好的體驗應該是:活躍的用戶應該在無感知的狀況下在jwt失效後獲取到新的jwt,攜帶這個新的jwt進行訪問,而長時間不活躍的用戶應該在jwt失效後須要進行從新的登陸認證。
這裏就涉及到了token的超時刷新問題,解決方案看圖:
在簽發有效期爲 t 時間的jwt後,把jwt用("JWT-SESSION-"+appId,jwt)的key-value形式存儲到redis中,有效期設置爲2倍的 t 。這樣jwt在有效期事後的 t 時間段內能夠申請刷新token。
還有個問題是用戶攜帶過時的jwt對後臺請求,在可刷新時間段內返回了新的jwt,應該在用戶無感知的狀況下返回請求的內容,而不是接收一個刷新的jwt。咱們是否是能夠在每次request請求回調的時候判斷返回的是否是刷新jwt,可是判斷是以後咱們是否放棄以前的用戶請求,若是不放棄,那是否是應該在最開始的用戶request請求前先保存這個請求,在以後的回調中若是是返回刷新jwt,咱們再攜帶這個新的jwt再請求一次保存好的request請求?但對於前端這麼大量的不一樣請求,這樣是否是太麻煩了?
這困擾了我好久哎,直到我用到了angualr的HttpInterceptor
哈哈哈哈哈哈哈哈哈哈哈哈哈哈。
angualr的HttpInterceptor
就是前端的攔截過濾器,發起請求會攔截處理,接收請求也會攔截處理。最大的好處對每次的原始request他都會完整的保存下來,咱們向後臺發生的request是他的clone。next.handle(request.clone)
繼承HttpInterceptor的AuthInterceptor,攔截response判斷是否爲refresh token,是則攜帶新token再次發起保存的request:
@Injectable() export class AuthInterceptor implements HttpInterceptor { constructor(private authService: AuthService, private router: Router) {} intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> { const authToken = this.authService.getAuthorizationToken(); const uid = this.authService.getUid(); let authReq: any; if (authToken != null && uid != null) { authReq = req.clone({ setHeaders: { 'authorization': authToken, 'appId': uid } }); } else { authReq = req.clone(); } console.log(authReq); return next.handle(authReq).pipe( mergeMap(event => { // 返回response if (event instanceof HttpResponse) { if (event.status === 200) { // 若返回JWT過時但refresh token未過時,返回新的JWT 狀態碼爲1005 if (event.body.meta.code === 1005) { const jwt = event.body.data.jwt; // 更新AuthorizationToken this.authService.updateAuthorizationToken(jwt); // clone request 從新發起請求 // retry(1); authReq = req.clone({ setHeaders: { 'authorization': jwt, 'appId': uid } }); return next.handle(authReq); } } if (event.status === 404) { // go to 404 html this.router.navigateByUrl('/404'); } if (event.status === 500) { // go to 500 html this.router.navigateByUrl('/500'); } } console.log(event); // 返回正常狀況的可觀察對象 return of(event); }), catchError(this.handleError) ); } private handleError(error: HttpErrorResponse) { if (error.error instanceof ErrorEvent) { // A client-side or network error occurred. Handle it accordingly. console.error('An error occurred:', error.error.message); } else { console.error( `Backend returned code ${error.status}, ` + `body was: ${error.error}`); } repeat(1); return new ErrorObservable('親請檢查網絡'); } }
後端簽發jwt時所作的:
/* * * @Description 這裏已經在 passwordFilter 進行了登陸認證 * @Param [] 登陸簽發 JWT * @Return java.lang.String */ @ApiOperation(value = "用戶登陸",notes = "POST用戶登陸簽發JWT") @PostMapping("/login") public Message accountLogin(HttpServletRequest request, HttpServletResponse response) { Map<String,String> params = RequestResponseUtil.getRequestParameters(request); String appId = params.get("appId"); // 根據appId獲取其對應所擁有的角色(這裏設計爲角色對應資源,沒有權限對應資源) String roles = accountService.loadAccountRole(appId); // 時間以秒計算,token有效刷新時間是token有效過時時間的2倍 long refreshPeriodTime = 36000L; String jwt = JsonWebTokenUtil.issueJWT(UUID.randomUUID().toString(),appId, "token-server",refreshPeriodTime >> 2,roles,null, SignatureAlgorithm.HS512); // 將簽發的JWT存儲到Redis: {JWT-SESSION-{appID} , jwt} redisTemplate.opsForValue().set("JWT-SESSION-"+appId,jwt,refreshPeriodTime, TimeUnit.SECONDS); AuthUser authUser = userService.getUserByAppId(appId); return new Message().ok(1003,"issue jwt success").addData("jwt",jwt).addData("user",authUser); }
後端refresh token時所作的:
protected boolean isAccessAllowed(ServletRequest servletRequest, ServletResponse servletResponse, Object mappedValue) throws Exception { Subject subject = getSubject(servletRequest,servletResponse); // 判斷是否爲JWT認證請求 if ((null == subject || !subject.isAuthenticated()) && isJwtSubmission(servletRequest)) { AuthenticationToken token = createJwtToken(servletRequest); try { subject.login(token); // return this.checkRoles(subject,mappedValue) && this.checkPerms(subject,mappedValue); return this.checkRoles(subject,mappedValue); }catch (AuthenticationException e) { LOGGER.info(e.getMessage(),e); // 若是是JWT過時 if (e.getMessage().equals("expiredJwt")) { // 這裏初始方案先拋出令牌過時,以後設計爲在Redis中查詢當前appId對應令牌,其設置的過時時間是JWT的兩倍,此做爲JWT的refresh時間 // 當JWT的有效時間過時後,查詢其refresh時間,refresh時間有效即從新派發新的JWT給客戶端, // refresh也過時則告知客戶端JWT時間過時從新認證 // 當存儲在redis的JWT沒有過時,即refresh time 沒有過時 String appId = WebUtils.toHttp(servletRequest).getHeader("appId"); String jwt = WebUtils.toHttp(servletRequest).getHeader("authorization"); String refreshJwt = redisTemplate.opsForValue().get("JWT-SESSION-"+appId); if (null != refreshJwt && refreshJwt.equals(jwt)) { // 從新申請新的JWT // 根據appId獲取其對應所擁有的角色(這裏設計爲角色對應資源,沒有權限對應資源) String roles = accountService.loadAccountRole(appId); long refreshPeriodTime = 36000L; //seconds爲單位,10 hours String newJwt = JsonWebTokenUtil.issueJWT(UUID.randomUUID().toString(),appId, "token-server",refreshPeriodTime >> 2,roles,null, SignatureAlgorithm.HS512); // 將簽發的JWT存儲到Redis: {JWT-SESSION-{appID} , jwt} redisTemplate.opsForValue().set("JWT-SESSION-"+appId,newJwt,refreshPeriodTime, TimeUnit.SECONDS); Message message = new Message().ok(1005,"new jwt").addData("jwt",newJwt); RequestResponseUtil.responseWrite(JSON.toJSONString(message),servletResponse); return false; }else { // jwt時間失效過時,jwt refresh time失效 返回jwt過時客戶端從新登陸 Message message = new Message().error(1006,"expired jwt"); RequestResponseUtil.responseWrite(JSON.toJSONString(message),servletResponse); return false; } } // 其餘的判斷爲JWT錯誤無效 Message message = new Message().error(1007,"error Jwt"); RequestResponseUtil.responseWrite(JSON.toJSONString(message),servletResponse); return false; }catch (Exception e) { // 其餘錯誤 LOGGER.warn(servletRequest.getRemoteAddr()+"JWT認證"+e.getMessage(),e); // 告知客戶端JWT錯誤1005,需從新登陸申請jwt Message message = new Message().error(1007,"error jwt"); RequestResponseUtil.responseWrite(JSON.toJSONString(message),servletResponse); return false; } }else { // 請求未攜帶jwt 判斷爲無效請求 Message message = new Message().error(1111,"error request"); RequestResponseUtil.responseWrite(JSON.toJSONString(message),servletResponse); return false; } }
。。。。。持續更新中。。。。
持續更新。。。。。。
分享一波阿里雲代金券快速上雲
轉載請註明 from tomsun28