背景 我们在做系统开发的时候,都少不了权限这块,认证授权对于系统安全、隐私合规尤其重要,最常见权限模型是RBAC(用户角色权限)。 我们将整体授权类型划分为三级,依据不同等级的授权,来控制授权的最小的颗粒度,今天来讲下前两个,数据权限放到后续。一级权限:访问权限二级权限:菜单、按钮权限三级权限:数据权限解决方案 市面上做鉴权框架不少,我们今天讲下Shiro,它比较轻量级,使用起来比较简单。 其基本功能点如下图所示: Authentication:身份认证登录,验证用户是不是拥有相应的身份;Authorization:授权,即权限验证,验证某个已认证的用户是否拥有某个权限;即判断用户是否能做事情,常见的如:验证某个用户是否拥有某个角色。或者细粒度的验证某个用户对某个资源是否具有某个权限;SessionManagement:会话管理,即用户登录后就是一次会话,在没有退出之前,它的所有信息都在会话中;会话可以是普通JavaSE环境的,也可以是如Web环境的;Cryptography:加密,保护数据的安全性,如密码加密存储到数据库,而不是明文存储;WebSupport:Web支持,可以非常容易的集成到Web环境;Caching:缓存,比如用户登录后,其用户信息、拥有的角色权限不必每次去查,这样可以提高效率;Concurrency:shiro支持多线程应用的并发验证,即如在一个线程中开启另一个线程,能把权限自动传播过去;Testing:提供测试支持;RunAs:允许一个用户假装为另一个用户(如果他们允许)的身份进行访问;RememberMe:记住我,这个是非常常见的功能,即一次登录后,下次再来的话不用登录了。 记住一点,Shiro不会去维护用户、维护权限;这些需要我们自己去设计提供;然后通过相应的接口注入给Shiro即可。 接下来我们分别从外部和内部来看看Shiro的架构,对于一个好的框架,从外部来看应该具有非常简单易于使用的API,且API契约明确;从内部来看的话,其应该有一个可扩展的架构,即非常容易插入用户自定义实现,因为任何框架都不能满足所有需求。 首先,我们从外部来看Shiro吧,即从应用程序角度的来观察如何使用Shiro完成工作。如下图: 可以看到:应用代码直接交互的对象是Subject,也就是说Shiro的对外API核心就是S其每个API的含义: Subject:主体,代表了当前用户,这个用户不一定是一个具体的人,与当前应用交互的任何东西都是Subject,如网络爬虫,机器人等;即一个抽象概念;所有Subject都绑定到SecurityManager,与Subject的所有交互都会委托给SecurityM可以把Subject认为是一个门面;SecurityManager才是实际的执行者; SecurityManager:安全管理器;即所有与安全有关的操作都会与SecurityManager交互;且它管理着所有S可以看出它是Shiro的核心,它负责与后边介绍的其他组件进行交互,如果学习过SpringMVC,你可以把它看成DispatcherServlet前端控制器; Realm:域,Shiro从Realm获取安全数据(如用户、角色、权限),就是说SecurityManager要验证用户身份,那么它需要从Realm获取相应的用户进行比较以确定用户身份是否合法;也需要从Realm得到用户相应的角色权限进行验证用户是否能进行操作;可以把Realm看成DataSource,即安全数据源。 也就是说对于我们而言,最简单的一个Shiro应用:应用代码通过Subject来进行认证和授权,而Subject又委托给SecurityM我们需要给Shiro的SecurityManager注入Realm,从而让SecurityManager能得到合法的用户及其权限进行判断。 从以上也可以看出,Shiro不提供维护用户权限,而是通过Realm让开发人员自己注入。工程实施 springbootshirojwtredis 引入maven依赖!shiroshiro。version1。9。1shiro。versionjavajwt。version3。11。0javajwt。versionshiroredis。version3。1。0shiroredis。versiondependencygroupIdorg。springframework。bootgroupIdspringbootstarterdataredisartifactIddependency!JWTdependencygroupIdcom。auth0groupIdjavajwtartifactIdversion{javajwt。version}versiondependency!shirodependencygroupIdorg。apache。shirogroupIdshirospringbootstarterartifactIdversion{shiro。version}versiondependency!shiroredisdependencygroupIdorg。crazycakegroupIdshiroredisartifactIdversion{shiroredis。version}versionexclusionsexclusiongroupIdorg。apache。shirogroupIdshirocoreartifactIdexclusionexclusionsdependency 配置文件(ShiroConfig)Slf4jConfigurationpublicclassShiroConfig{ResourceprivateLettuceConnectionFactorylettuceConnectionFAutowiredprivateEResourceprivateJeecgBaseConfigjeecgBaseCFilterChain定义说明1、一个URL可以配置多个Filter,使用逗号分隔2、当设置多个过滤器时,全部验证通过,才视为通过3、部分过滤器可指定参数,如perms,rolesBean(shiroFilterFactoryBean)publicShiroFilterFactoryBeanshiroFilter(SecurityManagersecurityManager){CustomShiroFilterFactoryBeanshiroFilterFactoryBeannewCustomShiroFilterFactoryBean();shiroFilterFactoryBean。setSecurityManager(securityManager);拦截器MapString,StringfilterChainDefinitionMapnewLinkedHashMapString,String();支持yml方式,配置拦截排除if(jeecgBaseConfig!nulljeecgBaseConfig。getShiro()!null){StringshiroExcludeUrlsjeecgBaseConfig。getShiro()。getExcludeUrls();if(oConvertUtils。isNotEmpty(shiroExcludeUrls)){String〔〕permissionUrlshiroExcludeUrls。split(,);for(Stringurl:permissionUrl){filterChainDefinitionMap。put(url,anon);}}}登录接口排除filterChainDefinitionMap。put(login,anon);添加自己的过滤器并且取名为jwtMapString,FilterfilterMapnewHashMapString,Filter(1);filterMap。put(jwt,newJwtFilter());shiroFilterFactoryBean。setFilters(filterMap);!过滤链定义,从上向下顺序执行,一般将放在最为下边filterChainDefinitionMap。put(,jwt);未授权界面返回JSONshiroFilterFactoryBean。setUnauthorizedUrl(403);shiroFilterFactoryBean。setLoginUrl(403);shiroFilterFactoryBean。setFilterChainDefinitionMap(filterChainDefinitionMap);returnshiroFilterFactoryB}Bean(securityManager)publicDefaultWebSecurityManagersecurityManager(ShiroRealmmyRealm){DefaultWebSecurityManagersecurityManagernewDefaultWebSecurityManager();securityManager。setRealm(myRealm);DefaultSubjectDAOsubjectDAOnewDefaultSubjectDAO();DefaultSessionStorageEvaluatordefaultSessionStorageEvaluatornewDefaultSessionStorageEvaluator();defaultSessionStorageEvaluator。setSessionStorageEnabled(false);subjectDAO。setSessionStorageEvaluator(defaultSessionStorageEvaluator);securityManager。setSubjectDAO(subjectDAO);自定义缓存实现,使用redissecurityManager。setCacheManager(redisCacheManager());returnsecurityM}下面的代码是添加注解支持returnBeanDependsOn(lifecycleBeanPostProcessor)publicDefaultAdvisorAutoProxyCreatordefaultAdvisorAutoProxyCreator(){DefaultAdvisorAutoProxyCreatordefaultAdvisorAutoProxyCreatornewDefaultAdvisorAutoProxyCreator();defaultAdvisorAutoProxyCreator。setProxyTargetClass(true);解决重复代理问题github994添加前缀判断不匹配任何AdvisordefaultAdvisorAutoProxyCreator。setUsePrefix(true);defaultAdvisorAutoProxyCreator。setAdvisorBeanNamePrefix(noadvisor);returndefaultAdvisorAutoProxyC}BeanpublicstaticLifecycleBeanPostProcessorlifecycleBeanPostProcessor(){returnnewLifecycleBeanPostProcessor();}BeanpublicAuthorizationAttributeSourceAdvisorauthorizationAttributeSourceAdvisor(DefaultWebSecurityManagersecurityManager){AuthorizationAttributeSourceAdvisoradvisornewAuthorizationAttributeSourceAdvisor();advisor。setSecurityManager(securityManager);}cacheManager缓存redis实现使用的是shiroredis开源插件returnpublicRedisCacheManagerredisCacheManager(){log。info((1)创建缓存管理器RedisCacheManager);RedisCacheManagerredisCacheManagernewRedisCacheManager();redisCacheManager。setRedisManager(redisManager());redis中针对不同用户缓存(此处的id需要对应user实体中的id字段,用于唯一标识)redisCacheManager。setPrincipalIdFieldName(id);用户权限信息缓存时间redisCacheManager。setExpire(200000);returnredisCacheM}配置shiroredisManager使用的是shiroredis开源插件returnBeanpublicIRedisManagerredisManager(){log。info((2)创建RedisManager,连接Redis。。);IRedisMredis单机支持,在集群为空,或者集群无机器时候使用if(lettuceConnectionFactory。getClusterConfiguration()nulllettuceConnectionFactory。getClusterConfiguration()。getClusterNodes()。isEmpty()){RedisManagerredisManagernewRedisManager();redisManager。setHost(lettuceConnectionFactory。getHostName());redisManager。setPort(lettuceConnectionFactory。getPort());redisManager。setDatabase(lettuceConnectionFactory。getDatabase());redisManager。setTimeout(0);if(!StringUtils。isEmpty(lettuceConnectionFactory。getPassword())){redisManager。setPassword(lettuceConnectionFactory。getPassword());}managerredisM}else{redis集群支持,优先使用集群配置RedisClusterManagerredisManagernewRedisClusterManager();SetHostAndPortportSetnewHashSet();lettuceConnectionFactory。getClusterConfiguration()。getClusterNodes()。forEach(nodeportSet。add(newHostAndPort(node。getHost(),node。getPort())));if(oConvertUtils。isNotEmpty(lettuceConnectionFactory。getPassword())){JedisClusterjedisClusternewJedisCluster(portSet,2000,2000,5,lettuceConnectionFactory。getPassword(),newGenericObjectPoolConfig());redisManager。setPassword(lettuceConnectionFactory。getPassword());redisManager。setJedisCluster(jedisCluster);}else{JedisClusterjedisClusternewJedisCluster(portSet);redisManager。setJedisCluster(jedisCluster);}managerredisM}}} 认证授权(ShiroRealm)ComponentSlf4jpublicclassShiroRealmextendsAuthorizingRealm{LazyResourceprivateCommonAPIcommonALazyResourceprivateRedisUtilredisU必须重写此方法,不然Shiro会报错Overridepublicbooleansupports(AuthenticationTokentoken){returntokeninstanceofJwtT}权限信息认证(包括角色以及权限)是用户访问controller的时候才进行验证(redis存储的此处权限信息)触发检测用户权限时才会调用此方法,例如checkRole,checkPermissionparamprincipals身份信息returnAuthorizationInfo权限信息OverrideprotectedAuthorizationInfodoGetAuthorizationInfo(PrincipalCollectionprincipals){log。debug(Shiro权限认证开始〔roles、permissions〕);Sif(principals!null){LoginUsersysUser(LoginUser)principals。getPrimaryPrincipal();usernamesysUser。getUsername();}SimpleAuthorizationInfoinfonewSimpleAuthorizationInfo();设置用户拥有的角色集合,比如admin,testSetStringroleSetcommonApi。queryUserRoles(username);System。out。println(roleSet。toString());info。setRoles(roleSet);设置用户拥有的权限集合,比如sys:role:add,sys:user:addSetStringpermissionSetcommonApi。queryUserAuths(username);info。addStringPermissions(permissionSet);System。out。println(permissionSet);log。info(Shiro权限认证成功);}用户信息认证是在用户进行登录的时候进行验证(不存redis)也就是说验证用户输入的账号和密码是否正确,错误抛出异常paramauth用户登录的账号密码信息return返回封装了用户信息的AuthenticationInfo实例throwsAuthenticationExceptionOverrideprotectedAuthenticationInfodoGetAuthenticationInfo(AuthenticationTokenauth)throwsAuthenticationException{log。debug(Shiro身份认证开始doGetAuthenticationInfo);Stringtoken(String)auth。getCredentials();if(tokennull){HttpServletRequestreqSpringContextUtils。getHttpServletRequest();log。info(身份认证失败IP地址:oConvertUtils。getIpAddrByRequest(req),URL:req。getRequestURI());thrownewAuthenticationException(token为空!);}校验token有效性LoginUserloginUtry{loginUserthis。checkUserTokenIsEffect(token);}catch(AuthenticationExceptione){JwtUtil。responseError(SpringContextUtils。getHttpServletResponse(),401,e。getMessage());e。printStackTrace();}returnnewSimpleAuthenticationInfo(loginUser,token,getName());}校验token的有效性paramtokenpublicLoginUsercheckUserTokenIsEffect(Stringtoken)throwsAuthenticationException{解密获得username,用于和数据库进行对比StringusernameJwtUtil。getUsername(token);if(usernamenull){thrownewAuthenticationException(token非法无效!);}查询用户信息log。debug(校验token是否有效checkUserTokenIsEffecttoken);LoginUserloginUserTokenUtils。getLoginUser(username,commonApi,redisUtil);LoginUserloginUsercommonApi。getUserByName(username);if(loginUsernull){thrownewAuthenticationException(用户不存在!);}判断用户状态if(loginUser。getStatus()!1){thrownewAuthenticationException(账号已被锁定,请联系管理员!);}校验token是否超时失效或者账号密码是否错误if(!jwtTokenRefresh(token,username,loginUser。getPassword())){thrownewAuthenticationException(CommonConstant。TOKENISINVALIDMSG);}returnloginU}JWTToken刷新生命周期(实现:用户在线操作不掉线功能)1、登录成功后将用户的JWT生成的Token作为k、v存储到cache缓存里面(这时候k、v值一样),缓存有效期设置为Jwt有效时间的2倍2、当该用户再次请求时,通过JWTFilter层层校验之后会进入到doGetAuthenticationInfo进行身份验证3、当该用户这次请求jwt生成的token值已经超时,但该token对应cache中的k还是存在,则表示该用户一直在操作只是JWT的token失效了,程序会给token对应的k映射的v值重新生成JWTToken并覆盖v值,该缓存生命周期重新计算4、当该用户这次请求jwt在生成的token值已经超时,并在cache中不存在对应的k,则表示该用户账户空闲超时,返回用户信息已失效,请重新登录。注意:前端请求Header中设置Authorization保持不变,校验有效性以缓存中的token为准。用户过期时间Jwt有效时间2。paramuserNameparampassWordreturnpublicbooleanjwtTokenRefresh(Stringtoken,StringuserName,StringpassWord){StringcacheTokenString。valueOf(redisUtil。get(CommonConstant。PREFIXUSERTOKENtoken));if(oConvertUtils。isNotEmpty(cacheToken)){校验token有效性if(!JwtUtil。verify(cacheToken,userName,passWord)){StringnewAuthorizationJwtUtil。sign(userName,passWord);设置超时时间redisUtil。set(CommonConstant。PREFIXUSERTOKENtoken,newAuthorization);redisUtil。expire(CommonConstant。PREFIXUSERTOKENtoken,JwtUtil。EXPIRETIME21000);log。debug(用户在线操作,更新token保证不掉线jwtTokenRefreshtoken);}}redis中不存在此TOEKN,说明token非法返回}清除当前用户的权限认证缓存paramprincipals权限信息OverridepublicvoidclearCache(PrincipalCollectionprincipals){super。clearCache(principals);}} 授权token(JwtToken)publicclassJwtTokenimplementsAuthenticationToken{privatestaticfinallongserialVersionUID1L;privateSpublicJwtToken(Stringtoken){this。}OverridepublicObjectgetPrincipal(){}OverridepublicObjectgetCredentials(){}} 鉴权登录拦截器(JwtFilter)Description:鉴权登录拦截器Slf4jpublicclassJwtFilterextendsBasicHttpAuthenticationFilter{默认开启跨域设置privatebooleanallowOpublicJwtFilter(){}执行登录认证paramrequestparamresponseparammappedValuereturnOverrideprotectedbooleanisAccessAllowed(ServletRequestrequest,ServletResponseresponse,ObjectmappedValue){try{executeLogin(request,response);}catch(Exceptione){JwtUtil。responseError(response,401,CommonConstant。TOKENISINVALIDMSG);}}OverrideprotectedbooleanexecuteLogin(ServletRequestrequest,ServletResponseresponse)throwsException{HttpServletRequesthttpServletRequest(HttpServletRequest)StringtokenhttpServletRequest。getHeader(CommonConstant。XACCESSTOKEN);JwtTokenjwtTokennewJwtToken(token);提交给realm进行登入,如果错误他会抛出异常并被捕获getSubject(request,response)。login(jwtToken);如果没有抛出异常则代表登入成功,返回}对跨域提供支持OverrideprotectedbooleanpreHandle(ServletRequestrequest,ServletResponseresponse)throwsException{HttpServletRequesthttpServletRequest(HttpServletRequest)HttpServletResponsehttpServletResponse(HttpServletResponse)if(allowOrigin){httpServletResponse。setHeader(HttpHeaders。ACCESSCONTROLALLOWORIGIN,httpServletRequest。getHeader(HttpHeaders。ORIGIN));允许客户端请求方法httpServletResponse。setHeader(HttpHeaders。ACCESSCONTROLALLOWMETHODS,GET,POST,OPTIONS,PUT,DELETE);允许客户端提交的HeaderStringrequestHeadershttpServletRequest。getHeader(HttpHeaders。ACCESSCONTROLREQUESTHEADERS);if(StringUtils。isNotEmpty(requestHeaders)){httpServletResponse。setHeader(HttpHeaders。ACCESSCONTROLALLOWHEADERS,requestHeaders);}允许客户端携带凭证信息(是否允许发送Cookie)httpServletResponse。setHeader(HttpHeaders。ACCESSCONTROLALLOWCREDENTIALS,true);}跨域时会首先发送一个option请求,这里我们给option请求直接返回正常状态if(RequestMethod。OPTIONS。name()。equalsIgnoreCase(httpServletRequest。getMethod())){httpServletResponse。setStatus(HttpStatus。OK。value());}returnsuper。preHandle(request,response);}} Controller入口使用RequestMapping(valueadd,methodRequestMethod。POST)RequiresPermissions(user:add)RequiresRoles(admin)publicResultadd(RequestBodyJSONObjectjsonObject)