村長讓小王給村裏各系統來一套SSO方案作整合,隔壁的陳家村流行使用Session+認證中心方法,但小王想嘗試點新鮮的,因而想到了JWT方案,那JWT是啥呢?JavaWebToken
簡稱JWT,就是一個字符串,由點號鏈接,能夠Encoded和Decoded進行明文和密文轉換,結構以下:git
頭部,聲明和簽名,頭部(header
)說明加密算法、類型等,聲明(payload
)內容如帳號密碼信息或須要傳輸的內容,簽名(signature
)即對聲明進行加密生成的簽名,用於防篡改。這樣,SSO就無需認證中心了,也無需服務端進行服務端session
存儲,甚至不使用cookie
傳輸,無CSRF風險,每次request
攜帶上這個token
,服務方經過認證便可,鏈路簡單,僅需各服務使用統一私鑰和驗證算法便可。github
聽完小王的SSO方案,村長略顯興奮,看小王才堪大用,因而再提出一項要求,讓來套權限控制方案,有了前面的經驗,小王也想到了SpringSecurity
,但得讓村長滿意,必須有些不同凡響,因而說了他的Shiro
方案,村長認真的點點頭,高興地表示能夠出面協助解決找對象的問題。在此,咱們也來研究下小王的這套技術,說不定還能夠解決一些生活問題。redis
準備: Idea201902/JDK11/ZK3.5.5/Gradle5.4.1/RabbitMQ3.7.13/Mysql8.0.11/Lombok0.26/Erlang21.2/postman7.5.0/Redis3.2/RocketMQ4.5.2算法
難度:新手--戰士--老兵--大師spring
目標:1.模擬商城系統,實現服務間SSO 2.使用JWT+Shiro實現權限管理sql
步驟:數據庫
1.系統總體框架不變,增長admin
模塊,做爲sso認證和權限管理服務,總體思路:首次請求,進行DB用戶信息驗證,經過後生成一個jwtToken
,並獲取各種權限,再次訪問,則請求頭帶上這個jwtToken
,服務端僅進行token
校驗,並刷新Token
有效期。apache
2.幾個shiro的核心對象:json
Principal 主體身份標識,必須具備惟一性,如用戶名、手機號、郵箱地址等,一個主體能夠有多個身份,可是必須有一個主身份(Primary Principal);跨域
Subject 請求主體,一個登陸用戶,一個請求等,在程序中任何地方均可以經過SecurityUtils.getSubject()獲取到當前的subject,subject中又能夠獲取到Principal;
credential 憑證信息,只有主體知道的安全信息,如密碼等;
SecurityManager 權限控制中心,全部請求最終基本上都經過它來代理轉發,通常咱們程序中不須要直接跟他打交道;
Realm 認證領域,不一樣的數據源使用不一樣的認證領域,好比從DB取信息對比的能夠叫DbRealm ,從Redis取緩存信息對比認證的叫RedisRealm,通常狀況下咱們對每種數據源定義一個Realm,其中包含了比對器(Matcher);
authenticator 認證器,主體進行認證最終經過authenticator進行的;
authorizer 受權器,主體進行受權最終經過authorizer進行的;
3.由於要用到JWT,也作個簡要說明,使用了auth0包,主要在 com.biao.mall.admin.util.JwtUtils
中,其中方法包含生成JwtToken,加解密,簽名等,比較清晰。
1 public class JwtUtils { 2 3 /** 4 * 得到token中的信息無需secret解密也能得到 5 * @return token中包含的簽發時間 6 */ 7 public static LocalDateTime getIssueAt(String token){ 8 DecodedJWT jwt = JWT.decode(token); 9 return TimeUtil.convert2LocalTime(jwt.getIssuedAt()); 10 } 11 12 /** 13 * 得到token中的信息無需secret解密也能得到 14 * @return token中包含的用戶名 15 */ 16 public static String getUsername(String token){ 17 DecodedJWT jwt = JWT.decode(token); 18 return jwt.getClaim("username").asString(); 19 } 20 21 /** 22 * 生成簽名,expireTime後過時 23 * @param username 用戶名 24 * @param expireTime 過時時間s 25 * @return 加密的token 26 */ 27 public static String sign(String username, String salt, long expireTime) { 28 Date date = new Date(System.currentTimeMillis()+expireTime*1000); 29 Algorithm algorithm= Algorithm.HMAC256(salt); 30 // 31 return JWT.create() 32 .withClaim("username",username) 33 .withExpiresAt(date) 34 .withIssuedAt(new Date()) 35 .sign(algorithm); 36 } 37 38 /** 39 * token是否過時 40 * @return true:過時 41 */ 42 public static boolean isTokenExpired(String token){ 43 Date now = Calendar.getInstance().getTime(); 44 DecodedJWT jwt = JWT.decode(token); 45 return jwt.getExpiresAt().before(now); 46 } 47 48 /** 49 * 生成隨機鹽,長度32位 50 * @return 51 */ 52 public static String generateSalt(){ 53 SecureRandomNumberGenerator secureRandom = new SecureRandomNumberGenerator(); 54 String hex = secureRandom.nextBytes(16).toHex(); 55 return hex; 56 } 57 58 }
JWT加解密示例請看這裏:https://jwt.io/#debugger-io
4.基礎組件com.biao.mall.admin.service.UserService
,也比較簡單清晰,「加密鹽」,即對加密對象加入的一些干擾數據,增長複雜度,要注意加解密的鹽要一致:
@Service public class UserService { private final static Logger lgger = LoggerFactory.getLogger(UserService.class); //加密用戶信息的鹽 private static final String encryptSalt = "510fdb7f28534fb584af25697826c203"; private StringRedisTemplate stringRedisTemplate; @Autowired public UserService(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate = stringRedisTemplate; } public String generateJwtToken(String username){ //加密JWT的鹽 String salt = "0805c99fd2634c80b2cde8c7e4124468"; //redis緩存salt stringRedisTemplate.opsForValue().set("token:"+username, salt, 3600, TimeUnit.SECONDS); return JwtUtils.sign(username,salt,60*60);//生成jwt token,設置過時時間爲1小時 } /* * 獲取上次token生成時的salt值和登陸用戶信息*/ public UserDto getJwtToken(String username) { // String salt = "9723612f53"; //從數據庫或者緩存中取出jwt token生成時用的salt String salt = stringRedisTemplate.opsForValue().get("token:"+username); UserDto userDto = this.getUserInfo(username); userDto.setSalt(salt); return userDto; } /** * 獲取數據庫中保存的用戶信息,主要是加密後的密碼.這裏省去了DB操做,直接生成了用戶信息 * @param username * @return */ public UserDto getUserInfo(String username){ UserDto user = new UserDto(); user.setUserId(1L); user.setUsername("admin"); //模擬對密碼加密 user.setEncryptPwd(new Sha256Hash("admin123",encryptSalt).toHex()); lgger.debug("UserService: [{}]",user.toString()); return user; } /**清除token信息*/ public void deleteLogInfo(String username){ // 刪除數據庫或者緩存中保存的salt // stringRedisTemplate.delete("token:"+username); } /**獲取用戶角色列表,強烈建議從緩存中獲取*/ public List<String> getUserRoles(Long userId){ //模擬admin角色 return Arrays.asList("admin"); } }
5.配置類 com.biao.mall.admin.conf.ShiroConf
功能就是:
@Configuration public class ShiroConf { /**註冊shiro的Filter 攔截請求*/ @Bean public FilterRegistrationBean<Filter> filterRegistrationBean(SecurityManager securityManager, UserService userService) throws Exception { FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>(); filterRegistrationBean.setFilter((Filter) Objects.requireNonNull(this.shiroFilter(securityManager, userService).getObject())); filterRegistrationBean.addInitParameter("targetFilterLifecycle","true"); //bean注入開啓異步方式 filterRegistrationBean.setAsyncSupported(true); filterRegistrationBean.setEnabled(true); filterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST); return filterRegistrationBean; } /**設置過濾器,將自定義的Filter加入*/ @Bean(name = "shiroFilter") public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager, UserService userService) { ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean(); //必需屬性,指定一個SecurityManager的實例, factoryBean.setSecurityManager(securityManager); Map<String,Filter> filterMap = factoryBean.getFilters(); filterMap.put("authcToken",this.createAuthFilter(userService)); filterMap.put("anyRole",this.createRolesFilter()); factoryBean.setFilters(filterMap); factoryBean.setFilterChainDefinitionMap(this.shiroFilterChainDefinition().getFilterChainMap()); return factoryBean; } @Bean protected ShiroFilterChainDefinition shiroFilterChainDefinition() { DefaultShiroFilterChainDefinition chainDefinition = new DefaultShiroFilterChainDefinition(); chainDefinition.addPathDefinition("/login", "noSessionCreation,anon"); chainDefinition.addPathDefinition("/logout", "noSessionCreation,authcToken[permissive]"); chainDefinition.addPathDefinition("/image/**", "anon"); //只容許admin或manager角色的用戶訪問 chainDefinition.addPathDefinition("/admin/**", "noSessionCreation,authcToken,anyRole[admin,manager]"); chainDefinition.addPathDefinition("/**", "noSessionCreation,authcToken"); return chainDefinition; } /**注意不要加@Bean註解,否則spring會自動註冊成filter*/ private AnyRolesAuthorizationFilter createRolesFilter() { return new AnyRolesAuthorizationFilter(); } /**注意不要加@Bean註解,否則spring會自動註冊成filter*/ private JwtAuthFilter createAuthFilter(UserService userService) { return new JwtAuthFilter(userService); } /**初始化authenticator*/ @Bean public Authenticator authenticator(UserService userService){ ModularRealmAuthenticator authenticator = new ModularRealmAuthenticator(); authenticator.setRealms(Arrays.asList(this.jwtShiroRealm(userService),this.dbShiroRealm(userService))); //若是有多個Realms才須要指定realm匹配策略 authenticator.setAuthenticationStrategy(new FirstSuccessfulStrategy()); return authenticator; } /**DB認證的realm*/ @Bean("dbRealm") public Realm dbShiroRealm(UserService userService){ DbShiroRealm dbShiroRealm = new DbShiroRealm(userService); return dbShiroRealm; } /**JWT 認證的realm*/ @Bean("jwtRealm") public Realm jwtShiroRealm(UserService userService) { JWTShiroRealm myShiroRealm = new JWTShiroRealm(userService); return myShiroRealm; } /**禁用session,不保存用戶狀態,每次請求都從新認證, * 要徹底禁用session,需使用下面的filter來實現*/ @Bean protected SessionStorageEvaluator sessionStorageEvaluator(){ DefaultWebSessionStorageEvaluator sessionStorageEvaluator = new DefaultWebSessionStorageEvaluator(); sessionStorageEvaluator.setSessionStorageEnabled(false); return sessionStorageEvaluator; } }
從以上內容並結合其餘部分可整理出shiro內部組件關係圖,或者說大體的處理流程:
6.而後咱們看首次登陸流程,從com.biao.mall.admin.controller.AdminController
開始,看其核心部分:
@PostMapping(value = "/login") public ResponseEntity<Void> login(@RequestBody UserDto loginInfo, HttpServletRequest request, HttpServletResponse response){ //獲取請求主體 Subject subject = SecurityUtils.getSubject(); try { //將用戶請求參數封裝 UsernamePasswordToken token = new UsernamePasswordToken(loginInfo.getUsername(), loginInfo.getPassword()); /**直接提交給Shiro處理,進入內部驗證,若是驗證失敗,返回AuthenticationException,若是經過,就將所有認證信息關聯到 * 此Subject上,subject.getPrincipal()將非空,且subject.isAuthenticated()爲True*/ subject.login(token); logger.info(">>AdminController.login OK!"); UserDto user = (UserDto) subject.getPrincipal(); String newToken = userService.generateJwtToken(user.getUsername()); //寫入響應信息返回 response.setHeader("x-auth-token", newToken); return ResponseEntity.ok().build(); } catch (AuthenticationException e) { // 若是校驗失敗,shiro會拋出異常,返回客戶端失敗 logger.error("User {} login fail, Reason:{}", loginInfo.getUsername(), e.getMessage()); return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } catch (Exception e) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } } @GetMapping("/logout") public ResponseEntity logout(){ Subject subject = SecurityUtils.getSubject(); if (subject.getPrincipals() != null){ UserDto userDto = (UserDto) subject.getPrincipals().getPrimaryPrincipal(); userService.deleteLogInfo(userDto.getUsername()); } //務必不能少 SecurityUtils.getSubject().logout(); return ResponseEntity.ok().build(); }
先封裝一個UsernamePasswordToken,此類實現接口HostAuthenticationToken和RememberMeAuthenticationToken,前一個接口用於記住認證請求的HostName或IP,後一個接口用於實現跨session的「記住密碼」功能,另外一細節是此類用char[]而不是String來存pwd,爲啥?由於String是不可變的,會放到常量池中,留存較長時間,某些場合如memory dump時,可直接被輸出訪問。username/password模式認證場景最爲常見,故shiro特地設計了UsernamePasswordToken來使用的。重點是如下一行:
subject.login(token);
就能將認證工做交給shiro去處理:進入內部自動驗證,若是驗證失敗,返回AuthenticationException;若是經過,就將所有認證信息關聯到此Subject上,subject.getPrincipal()將非空,且subject.isAuthenticated()爲True。 最後是若是驗證成功,將生成一個newToken,並寫入響應的頭。
7.再進一步,看shiro如何內部自動驗證:shiro調用已註冊的Authenticator,Authenticator自動選擇對應的Realm。Realm的實現通常直接繼承AuthorizingRealm便可:
public class DbShiroRealm extends AuthorizingRealm { private final Logger logger = LoggerFactory.getLogger(JWTShiroRealm.class); //生產環境鹽值不可硬編碼在代碼中,注意與前面設置的一致 private static final String encrySalt = "510fdb7f28534fb584af25697826c203";//對比登陸信息的salt private UserService userService; public DbShiroRealm(UserService userService) { this.userService = userService; this.setCredentialsMatcher(new HashedCredentialsMatcher(Sha256Hash.ALGORITHM_NAME)); } @Override public boolean supports(AuthenticationToken token){ logger.info(">>DbShiroRealm.supports"); return token instanceof UsernamePasswordToken; } /**權限*/ @Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo(); //獲取主身份標識 UserDto userDto = (UserDto) principals.getPrimaryPrincipal(); //獲取權限角色 List<String> roles = userDto.getRoles(); if (roles == null){ roles = userService.getUserRoles(userDto.getUserId()); userDto.setRoles(roles); } if (roles != null){ simpleAuthorizationInfo.addRoles(roles); } return simpleAuthorizationInfo; } /**認證*/ @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { UsernamePasswordToken usernamePasswordToken = (UsernamePasswordToken) token; String userName = usernamePasswordToken.getUsername(); UserDto userDto = userService.getUserInfo(userName); if (userDto == null){ throw new AuthenticationException("userName or pwd error!"); } return new SimpleAuthenticationInfo(userDto,userDto.getEncryptPwd(), ByteSource.Util.bytes(encrySalt),"dbRealm"); } }
方法之一 :supports(AuthenticationToken token),即根據token判斷此Authenticator是否使用該realm,
@Override public boolean supports(AuthenticationToken token){ return token instanceof UsernamePasswordToken; }
方法之二:doGetAuthorizationInfo,作權限處理,需注意這裏兩次使用了roles獲取邏輯,由於Shiro默認不會緩存角色信息,因此這裏調用service的方法獲取角色,且強烈建議service中從緩存中獲取。
/**權限*/ @Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo(); //獲取主身份標識 UserDto userDto = (UserDto) principals.getPrimaryPrincipal(); //獲取權限角色 List<String> roles = userDto.getRoles(); if (roles == null){ roles = userService.getUserRoles(userDto.getUserId()); userDto.setRoles(roles); } if (roles != null){ simpleAuthorizationInfo.addRoles(roles); } return simpleAuthorizationInfo; }
方法之三:doGetAuthenticationInfo,作認證,此處是首次認證,故強轉爲UsernamePasswordToken,再去DB中使用userService.getUserInfo(userName)取得存儲的帳戶信息,最後構形成SimpleAuthenticationInfo扔給shiro。
/**認證*/ @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { UsernamePasswordToken usernamePasswordToken = (UsernamePasswordToken) token; String userName = usernamePasswordToken.getUsername(); UserDto userDto = userService.getUserInfo(userName); if (userDto == null){ throw new AuthenticationException("userName or pwd error!"); } return new SimpleAuthenticationInfo(userDto,userDto.getEncryptPwd(), ByteSource.Util.bytes(encrySalt),"dbRealm"); }
那到底是如何對比的呢?最後是落到了HashedCredentialsMatcher
頭上,並使用Hash算法,由於這個user/pwd比對比較簡單固定,因此shiro已經有了matcher,直接引用便可!至此,首次登陸認證結束!
public DbShiroRealm(UserService userService) { this.userService = userService; this.setCredentialsMatcher(new HashedCredentialsMatcher(Sha256Hash.ALGORITHM_NAME)); }
8.非首次登陸,先是com.biao.mall.admin.filter.JwtAuthFilter
處理,事實上不管哪次請求,都會通過這個Filter處理:
@Slf4j public class JwtAuthFilter extends AuthenticatingFilter { private final Logger logger = LoggerFactory.getLogger(JwtAuthFilter.class); private static final int tokenRefreshInterval = 300; private UserService userService; public JwtAuthFilter(UserService userService){ this.userService = userService; this.setLoginUrl("/login"); } @Override protected boolean preHandle(ServletRequest request,ServletResponse response) throws Exception { logger.info("JwtAuthFilter.preHandle"); HttpServletRequest httpServletRequest = WebUtils.toHttp(request); //對於OPTION請求作攔截,不作token校驗 if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())){ return false; } return super.preHandle(request,response); } @Override protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) throws Exception { logger.info("JwtAuthFilter.createToken"); String jwtToken = this.getAuthzHeader(request); if (StringUtils.isNotBlank(jwtToken) && !JwtUtils.isTokenExpired(jwtToken)){ return new JWTToken(jwtToken); } return null; } private String getAuthzHeader(ServletRequest request) { logger.info("JwtAuthFilter.getAuthzHeader"); HttpServletRequest httpServletRequest = WebUtils.toHttp(request); String header = httpServletRequest.getHeader("x-auth-token"); return StringUtils.remove(header,"Bearer"); } //cors 跨域設置 private void fillCorsHeader(HttpServletRequest toHttp, HttpServletResponse httpServletResponse) { httpServletResponse.setHeader("Access-control-Allow-Origin",toHttp.getHeader("Origin")); httpServletResponse.setHeader("Access-Control-Allow-Methods","GET,POST,OPTIONS,HEAD"); httpServletResponse.setHeader("Access-Control-Allow-Headers",toHttp.getHeader("Access-Control-Request-Headers")); } @Override protected boolean isAccessAllowed(ServletRequest request,ServletResponse response,Object mappedValue){ logger.info(">>JwtAuthFilter.isAccessAllowed"); if (this.isLoginRequest(request,response)){ return true; } Boolean afterFiltered = (Boolean) request.getAttribute("anyRolesAuthFilter.FILTERED"); if (BooleanUtils.isTrue(afterFiltered)){ return true; } boolean allowed = false; try{ allowed = executeLogin(request,response); }catch (IllegalStateException e){ logger.error("Not found any token"); }catch (Exception e){ logger.error("Error occurs when login",e); } return allowed || super.isPermissive(mappedValue); } //isAccessAllowed返回 false進入此方法 @Override protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception { HttpServletResponse httpServletResponse = WebUtils.toHttp(response); httpServletResponse.setCharacterEncoding("UTF-8"); httpServletResponse.setContentType("application/json;charset=UTF-8"); httpServletResponse.setStatus(HttpStatus.SC_NON_AUTHORITATIVE_INFORMATION); this.fillCorsHeader(WebUtils.toHttp(request),httpServletResponse); return false; } @Override protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response){ logger.error("Validate token fail, token:{}, error:{}",token.toString(),e.getMessage()); return false; } @Override protected boolean onLoginSuccess(AuthenticationToken token, Subject subject, ServletRequest request, ServletResponse response){ HttpServletResponse httpServletResponse = WebUtils.toHttp(response); String newToken = null; if (token instanceof JWTToken){ JWTToken jwtToken = (JWTToken) token; UserDto userDto = (UserDto) subject.getPrincipals().getPrimaryPrincipal(); boolean shouldRefresh = this.shouldTokenRefresh(JwtUtils.getIssueAt(jwtToken.getToken())); if (shouldRefresh){ newToken = userService.generateJwtToken(userDto.getUsername()); } } if (StringUtils.isNotBlank(newToken)){ httpServletResponse.setHeader("x-auth-token",newToken); } return true; } private boolean shouldTokenRefresh(LocalDateTime issueAt) { // LocalDateTime issueTime = LocalDateTime.ofInstant(issueAt.toInstant(), ZoneId.systemDefault()); return LocalDateTime.now().minusSeconds(tokenRefreshInterval).isAfter(issueAt); } @Override protected void postHandle(ServletRequest request,ServletResponse response){ this.fillCorsHeader(WebUtils.toHttp(request),WebUtils.toHttp(response)); request.setAttribute("jwtShiroFilter.FILTERED", true); } }
展開,isAccessAllowed見名知意,邏輯:若是是首次,經過;若是已FILTERED,經過;若是都不是,則調用父類executeLogin方法,跟進一下,這裏面再調用subject.login(token),其實就是前面首次登陸邏輯了!父類會在請求進入攔截器後調用該方法,返回true則繼續,返回false則會調用onAccessDenied()。不經過時,還會調用了isPermissive()方法。
1 @Override 2 protected boolean isAccessAllowed(ServletRequest request,ServletResponse response,Object mappedValue){ 3 if (this.isLoginRequest(request,response)){ 4 return true; 5 } 6 Boolean afterFiltered = (Boolean) request.getAttribute("anyRolesAuthFilter.FILTERED"); 7 if (BooleanUtils.isTrue(afterFiltered)){ 8 return true; 9 } 10 boolean allowed = false; 11 try{ 12 allowed = executeLogin(request,response); 13 }catch (IllegalStateException e){ 14 logger.error("Not found any token"); 15 }catch (Exception e){ 16 logger.error("Error occurs when login",e); 17 } 18 return allowed || super.isPermissive(mappedValue); 19 }
關於父類的isPermissive()方法:對參數進行搜索,看是否有PERMISSIVE = "permissive"字符串,
protected boolean isPermissive(Object mappedValue) { if(mappedValue != null) { String[] values = (String[]) mappedValue; return Arrays.binarySearch(values, PERMISSIVE) >= 0; } return false; }
那爲啥要加上"||super.isPermissive(mappedValue)
",由於好比/logout
請求,就能繼續處理,這裏也對應了前面ShiroFilterChainDefinition中的:
chainDefinition.addPathDefinition("/logout", "noSessionCreation,authcToken[permissive]");
這種場景一樣適用於其餘未登陸,但又能夠操做的場景,好比只是閱讀內容不作評論,或者查詢操做等。 來看方法createToken,
1 @Override 2 protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) throws Exception { 3 String jwtToken = this.getAuthzHeader(request); 4 if (StringUtils.isNotBlank(jwtToken) && !JwtUtils.isTokenExpired(jwtToken)){ 5 return new JWTToken(jwtToken); 6 } 7 return null; 8 }
重寫了父類的方法,使用咱們本身定義的Token類,提交給shiro。這個方法返回null的話會直接拋出異常,進入isAccessAllowed()的異常處理邏輯 。
9.再看方法:onLoginSuccess,若是Login認證成功,會進入該方法,等同於用戶名密碼登陸成功,這裏還判斷了是否要刷新Token,爲啥要刷新token?由於每一個token都有設置過時時間,刷新,可防止舊token被非法使用,若是是安全性要求高的系統,能夠在update類操做後就刷新token,下降風險。
@Override protected boolean onLoginSuccess(AuthenticationToken token, Subject subject, ServletRequest request, ServletResponse response){ HttpServletResponse httpServletResponse = WebUtils.toHttp(response); String newToken = null; if (token instanceof JWTToken){ JWTToken jwtToken = (JWTToken) token; UserDto userDto = (UserDto) subject.getPrincipal(); boolean shouldRefresh = this.shouldTokenRefresh(JwtUtils.getIssueAt(jwtToken.getToken())); if (shouldRefresh){ newToken = userService.generateJwtToken(userDto.getUsername()); } } if (StringUtils.isNotBlank(newToken)){ httpServletResponse.setHeader("x-auth-token",newToken); } return true; }
另外一方法:onLoginFailure,若是調用shiro的Login認證失敗,會回調這個方法,這裏直接返回false,由於邏輯放到了onAccessDenied()中,
@Override protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response){ logger.error("Validate token fail, token:{}, error:{}",token.toString(),e.getMessage()); return false; }
若是調用shiro的login認證失敗,會回調這個方法,這裏咱們什麼都不作,由於邏輯放到了onAccessDenied()中。
10.關於自定義的:com.biao.mall.admin.dto.JWTToken
,很簡單,略,
//@Data public class JWTToken implements HostAuthenticationToken { private static final long serialVersionUID = 8765431346463134621L; private String token; private String host; public JWTToken(String token,String host){ this.token = token; this.host = host; } public JWTToken(String token){ //借用全變量構造函數 this(token,null); } public void setToken(String token) { this.token = token; } public void setHost(String host) { this.host = host; } public String getToken() { return this.token; } public String getHost() { return this.host; } /**注意這裏的重寫方法,後續使用中,以此處返回值爲準*/ @Override public Object getPrincipal() { return this.token; } /**注意這裏的重寫方法,後續使用中,以此處返回值爲準*/ @Override public Object getCredentials() { return this.token; } @Override public String toString(){ return token + ':' + host; } }
既然shiro將JWTToken交給Realm處理,先看會使用到的 com.biao.mall.admin.conf.JWTShiroRealm
/** * @Classname JWTShiroRealm 自定義身份認證 * * 基於HMAC( 散列消息認證碼)的控制域 * @Description TODO * @Author xiexiaobiao * @Date 2019-09-05 22:48 * @Version 1.0 **/ public class JWTShiroRealm extends AuthorizingRealm { private static final Logger logger = LoggerFactory.getLogger(JWTShiroRealm.class); private UserService userService; public JWTShiroRealm(UserService userService) { this.userService = userService; this.setCredentialsMatcher(new JWTCredentialsMatcher()); } @Override public boolean supports(AuthenticationToken token){ logger.debug("token instanceof JWTToken >> {}", (token instanceof JWTToken)); return (token instanceof JWTToken); } //首次登陸已經處理權限角色,故這裏不需處理 @Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { return new SimpleAuthorizationInfo(); } // @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { JWTToken jwtToken = (JWTToken) token; String tokenStr = jwtToken.getToken(); UserDto userDto = userService.getJwtToken(JwtUtils.getUsername(tokenStr)); if (userDto == null){ throw new AuthenticationException("token expired ,please login"); } return new SimpleAuthenticationInfo(userDto,userDto.getSalt(),"jwtRealm"); } }
這裏能夠經過和DbShiroRealm對比分析:supports方法看此realm是否匹配,符合才進入處理
@Override public boolean supports(AuthenticationToken token){ logger.debug("token instanceof JWTToken >> {}", (token instanceof JWTToken)); return (token instanceof JWTToken); }
看相同名稱的doGetAuthorizationInfo方法:首次登陸已經處理權限角色,故這裏不需處理,JWTtoken中也不包含角色信息。
@Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { return new SimpleAuthorizationInfo(); }
看另外一相同名稱的doGetAuthenticationInfo方法:取得token後,直接交給jwtRealm處理。
@Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { JWTToken jwtToken = (JWTToken) token; String tokenStr = jwtToken.getToken(); UserDto userDto = userService.getJwtToken(JwtUtils.getUsername(tokenStr)); if (userDto == null){ throw new AuthenticationException("token expired ,please login"); } SimpleAuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo(userDto,userDto.getSalt(),"jwtRealm"); return authenticationInfo; }
同理,jwtRealm要指定Matcher,這裏的jwtRealm,經過構造函數指定了JWTCredentialsMatcher,
public JWTShiroRealm(UserService userService) { this.userService = userService; this.setCredentialsMatcher(new JWTCredentialsMatcher()); }
既然使用到了CredentialsMatcher,看定義,用指定的算法作匹配驗證:
public class JWTCredentialsMatcher implements CredentialsMatcher { private final Logger logger = LoggerFactory.getLogger(JWTCredentialsMatcher.class); @Override public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) { String tokenStr = (String) token.getCredentials(); Object stored = info.getCredentials(); String salt = stored.toString(); UserDto userDto = (UserDto) info.getPrincipals().getPrimaryPrincipal(); try{ Algorithm algorithm = Algorithm.HMAC256(salt); JWTVerifier verifier = JWT.require(algorithm) .withClaim("username",userDto.getUsername()) .build(); verifier.verify(tokenStr); return true; }catch (JWTVerificationException e){ logger.error("Token Error:{}", e.getMessage()); } return false; } }
至此,非首次登陸邏輯也結束了!
11.說了這麼多,彷佛還沒說到角色咋回事,先看前面的ShiroFilterChainDefinition內容:
chainDefinition.addPathDefinition("/admin/**", "noSessionCreation,authcToken,anyRole[admin,manager]");
chainDefinition.addPathDefinition("/**", "noSessionCreation,authcToken");
shiro中是經過AuthorizationFilter來進行角色過濾,邏輯就是在請求進入這個filter後,shiro會調用全部配置的Realm獲取用戶的角色信息,而後和Filter中配置的角色作對比,匹配就能夠經過,也就是各realm中的doGetAuthorizationInfo方法返回的AuthorizationInfo對象,注意默認的Filter只提供‘並’比對,好比‘Role[admin,manager]’即表示要具有admin和manager角色,上面的'authcToken'即表示要經過用戶認證,項目中自定義了AnyRolesAuthorizationFilter,故‘anyRole[admin,manager]’表示要具有admin或manager角色,其實,shiro還提供了註解模式,好比@RequiresRoles("admin"),即表示須要admin角色:
@RequiresRoles("admin") @GetMapping("/test") public ResponseEntity test(){ return null; }
再來看AnyRolesAuthorizationFilter,重寫了isAccessAllowed方法,其中實現了role的‘或’比對,
public class AnyRolesAuthorizationFilter extends AuthorizationFilter { @Override protected void postHandle(ServletRequest request, ServletResponse response){ request.setAttribute("anyRolesAuthFilter.FILTERED", true); } @Override protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception { Boolean afterFiltered = (Boolean) request.getAttribute("anyRolesAuthFilter.FILTERED"); if (BooleanUtils.isTrue(afterFiltered)){ return true; } Subject subject = getSubject(request,response); String[] rolesArray = (String[]) mappedValue; //沒有角色限制,有權限訪問 if (rolesArray == null || rolesArray.length == 0 ){ return true; } for (String role : rolesArray ) { if (subject.hasRole(role)){ return true; } } return false; } @Override protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws IOException { HttpServletResponse httpServletResponse = WebUtils.toHttp(response); httpServletResponse.setCharacterEncoding("UTF-8"); httpServletResponse.setContentType("application/json;charset=utf-8"); httpServletResponse.setStatus(HttpStatus.SC_UNAUTHORIZED); return false; } }
提一下session禁用:由於用了jwt的訪問認證,因此要把默認session支持關掉,前面conf中經過sessionStorageEvaluator禁用,還須要加上如下配置,由於有些請求,並無經過認證但也能夠繼續訪問,所以這裏對全部URL作設置;
chainDefinition.addPathDefinition("/**", "noSessionCreation,authcToken");
12.SSO改造:即在admin模塊設計一個專門的登陸認證服務,供其餘服務RPC調用,具體在com.biao.mall.admin.service.AuthServiceImpl,其餘服務使用filter或interceptor,過濾後直接調用此方法,二次登陸,能夠在各自服務內實現,後續我再完善。
@Override public String loginAuth(UserDto loginInfo) { Subject subject = SecurityUtils.getSubject(); try{ UsernamePasswordToken token = new UsernamePasswordToken(loginInfo.getUsername(),loginInfo.getPassword()); subject.login(token); UserDto userDto = (UserDto) subject.getPrincipals().getPrimaryPrincipal(); String newToken = userService.generateJwtToken(userDto.getUsername()); return newToken; } catch (AuthenticationException e) { logger.error("User {} loginAuth fail, Reason:{}", loginInfo.getUsername(), e.getMessage()); } catch (Exception e) { logger.error("User {} loginAuth fail, Reason:{}", loginInfo.getUsername(), e.getMessage()); } return null; }
13.終於到了測試了,寫的都快暈了,啓動:ZK-->Redis-->Rocket-->Stock-->Business-->Logistic-->Admin, 模擬login:
提交後,得到JWT:
作個jwt合法驗證, 若是填寫錯誤的jwt加密鹽:
填寫正確的salt後:
輸入錯誤的username和pwd,會提示:
2019-09-07 18:49:27.509 ERROR 15816 --- [nio-8087-exec-4] c.b.m.admin.controller.AdminController : User admin login fail, Reason:No account information found for authentication token [org.apache.shiro.authc.UsernamePasswordToken - admin, rememberMe=false] by this Authenticator instance. Please check that it is configured correctly.
14.二次登陸測試權限,controller中寫兩個測試URL,並配上角色權限要求:
@RequiresRoles("manager") @GetMapping("/manager") public ResponseEntity test(HttpServletRequest request, HttpServletResponse response){ return ResponseEntity.ok(request.getHeader("x-auth-token")); } @RequiresRoles("admin") @GetMapping("/admin") public ResponseEntity test2(HttpServletRequest request, HttpServletResponse response){ return ResponseEntity.ok(request.getHeader("x-auth-token")); }
首次訪問生成JWT:
攜帶正確的JWT訪問,但無"manager"權限狀況:
攜帶正確的JWT訪問,有"admin"權限:
15.項目代碼地址:其中的day12 https://github.com/xiexiaobiao/dubbo-project.git
1.JWT的優缺點:JWT不只可用於認證,還可用於信息交換,優勢就是簡單,保存在客戶端,可減輕服務端負載,最大缺點就是服務器無狀態,因此在使用期間,沒法取消或更改token權限,即jwt一旦簽發,有效期內將一直有效。另外,jwt自己包含身份驗證信息,一旦泄漏,將可非法得到token的全部權限。
2.shiro比較springSecurity:shiro優勢就是輕量級,徹底不依賴spring,適用於常見的權限管理場景,springSecurity對spring整合較好,實現了一些組件功能。不少概念二者相通或近似,springSecurity更爲複雜。
3.權限控制使用攔截器Interceptor也是能夠的,
4.本項目代碼,參考了他人簡書上博文代碼,省得重複造輪子,
個人我的公衆號: