zoukankan      html  css  js  c++  java
  • shiro学习总结笔记(混乱版)

    shiro登录参考链接

    https://juejin.cn/post/6880872387416588295

    https://juejin.cn/post/6881493214650433549

    shiro学习参考链接

    https://www.cnblogs.com/bingfengdev/p/13768829.html

    https://www.jianshu.com/p/c15da4e85734

    https://www.w3cschool.cn/shiro/co4m1if2.html

    关于加盐和MD5加密:

    https://juejin.cn/post/6879761189845991438

    shiro理解

    主体是subject,注入securityManagement来管理,在securityManagement中注入自己实现的Realm来获取用户信息进行验证相关操作

    在 shiro 中,用户需要提供 principals (身份)和 credentials(证明)给 shiro,从而应用能验证用户身份:

    principals:身份,即主体的标识属性,一般是用户名 / 密码 / 手机号。

    credentials:证明 / 凭证,即只有主体知道的安全值,如密码 / 数字证书等。

    最常见的 principalscredentials 组合就是用户名 / 密码了。

    SubjectRealm,分别是主体及验证主体的数据源。

    subject.login 方法进行登录,其会自动委托给 SecurityManager.login 方法进行登录;

    subject.logout 退出,其会自动委托给 SecurityManager.logout 方法退出。

    Realm:域,ShiroRealm 获取安全数据(如用户、角色、权限),就是说 SecurityManager 要验证用户身份,那么它需要从 Realm 获取相应的用户进行比较以确定用户身份是否合法;也需要从 Realm 得到用户相应的角色 / 权限进行验证用户是否能进行操作;可以把 Realm 看成 DataSource,即安全数据源。

    String getName(); //返回一个唯一的Realm名字
      boolean supports(AuthenticationToken token); //判断此Realm是否支持此类型的Token
      AuthenticationInfo getAuthenticationInfo(AuthenticationToken token)
      throws AuthenticationException;  //根据Token获取认证信息
    

    自定义Realm需要继承AuthorizingRealm 需要重写两个方法。doGetAuthenticationInfo方法是通过查询数据库的用户信息,返回一个SimpleAuthenticationInfo来实现登陆信息认证。

    protected void assertCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) throws AuthenticationException {
      CredentialsMatcher cm = this.getCredentialsMatcher();
      if (cm != null) {
          if (!cm.doCredentialsMatch(token, info)) {
              String msg = "Submitted credentials for token [" + token + "] did not match the expected credentials.";
              throw new IncorrectCredentialsException(msg);
          }
      } else {
          throw new AuthenticationException("A CredentialsMatcher must be configured in order to verify credentials during authentication.  If you do not wish for credentials to be examined, you can configure an " + AllowAllCredentialsMatcher.class.getName() + " instance.");
      }
    
    

    这是AuthenticatingRealm的一个方法,入参示是登陆时前端传过来的登陆信息和我们自定义Realm返回的信息。通过 this.getredentialMatcher() 获取到一个CredentialsMatcher对象,如果我们不设置的话,默认是使用的SimpleCredentialsMatcher。进入到doCredentialsMatch方法:

    protected boolean equals(Object tokenCredentials, Object accountCredentials) {
    if (log.isDebugEnabled()) {
     log.debug("Performing credentials equality check for tokenCredentials of type [" + tokenCredentials.getClass().getName() + " and accountCredentials of type [" + accountCredentials.getClass().getName() + "]");
    }
    
    if (this.isByteSource(tokenCredentials) && this.isByteSource(accountCredentials)) {
     if (log.isDebugEnabled()) {
         log.debug("Both credentials arguments can be easily converted to byte arrays.  Performing array equals comparison");
     }
    
     byte[] tokenBytes = this.toBytes(tokenCredentials);
     byte[] accountBytes = this.toBytes(accountCredentials);
     return MessageDigest.isEqual(tokenBytes, accountBytes);
    } else {
     return accountCredentials.equals(tokenCredentials);
    }
    }
    
    public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) {
    Object tokenCredentials = this.getCredentials(token);
    Object accountCredentials = this.getCredentials(info);
    return this.equals(tokenCredentials, accountCredentials);
    }
    

    项目数据库是存储的加密的密码,使用的是BCryptPasswordEncoder来进行hash加密的,是需要用他的BCryptPasswordEncoder::matches(String plainPass,String encodePass) 来验证密码,所以可以自己重写一个matcher,代码如下:

    public class CustomerCredentialsMatcher implements CredentialsMatcher {
    
    PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
    
    @Override
    public boolean doCredentialsMatch(AuthenticationToken authenticationToken, AuthenticationInfo authenticationInfo) {
    //前端传来的密码
    String currentPass = String.copyValueOf((char[]) authenticationToken.getCredentials());
    //数据库密码
    String dbPass = (String) authenticationInfo.getCredentials();
    return passwordEncoder.matches(currentPass,dbPass);
    }
    
    }
    

    在自己的Realm里设置一下我们自己的matcher:

    public class CustRealm extends AuthorizingRealm {
    ...
    @Override
    public void setCredentialsMatcher(CredentialsMatcher credentialsMatcher) {
      CustomerCredentialsMatcher customerCredentialsMatcher = new CustomerCredentialsMatcher();
      super.setCredentialsMatcher(customerCredentialsMatcher);
    }
    ...
    }
    

    自定义realm

    • ​ 授权;doGetAuthorizationInfo方法是在我们调用;SecurityUtils.getSubject().isPermitted()这个方法,授权后用户角色及权限会保存在缓存中的

    • 认证,登录,doGetAuthenticationInfo这个方法是在用户登录的时候调用的;也就是执行SecurityUtils.getSubject().login()的时候调用;(即:登录验证),验证通过后会用户保存在缓存中的

    doGetAuthenticationInfo方法里最后返回SimpleAuthenticationInfo对象,生成 AuthenticationInfo 信息,交给间接父类 AuthenticatingRealm 使用 CredentialsMatcher 进行判断密码是否匹配,需要传入:身份信息(用户名)、凭据(密文密码)、盐(username+salt),CredentialsMatcher 使用盐加密传入的明文密码和此处的密文密码进行匹配。

    new SimpleAuthenticationInfo(user, user.getPassWord(), credentialsSalt, this.getClass().getName());
    
    // 第三个参数一般可以是ByteSource.Util.bytes(shiroUser.getUserName()+shiroPasswordService.getPublicSalt())
    

    在父类AuthenticatingRealm中的getAuthenticationInfo中会调用自定义realmdoGetAuthenticationInfo方法获取对应的SimpleAuthenticationInfo对象,然后调用this.assertCredentialsMatch(token, info);来比对token里的用户信息和SimpleAuthenticationInfo中得到的数据库里用户信息,在该方法里调用getCredentialsMatcher方法去获取对应的匹配算法,然后执行cm.doCredentialsMatch(token, info)进行实际的比对

    AuthenticatingRealm中的getAuthenticationInfo方法在什么时候执行呢?(该方法的回调一般是通过subject.login(token)方法来实现的)

    AuthenticatingFilter在执行executeLogin方法的时候会执行AuthenticationToken token = this.createToken(request, response);来生成token,这里可以继承该filter来实现自定义的生成token的方法也可以使用默认的,然后调用

    Subject subject = this.getSubject(request, response);
    subject.login(token);
    

    subject.login(token)里会调用AuthenticatingRealm中的getAuthenticationInfo方法,在该方法里调用自定义realmdoGetAuthenticationInfo方法获取对应的SimpleAuthenticationInfo对象

    Shiro 拦截器机制

    image

    • ShiroFilter 是整个 Shiro 的入口点,用于拦截需要安全控制的请求进行处理

    • AdviceFilter 提供了 AOP 风格的支持,类似于 SpringMVC 中的 Interceptor:

      boolean preHandle(ServletRequest request, ServletResponse response) throws Exception
      void postHandle(ServletRequest request, ServletResponse response) throws Exception
      void afterCompletion(ServletRequest request, ServletResponse response, Exception exception) throws Exception;
      
      • preHandler:类似于 AOP 中的前置增强;在拦截器链执行之前执行;如果返回 true 则继续拦截器链;否则中断后续的拦截器链的执行直接返回;进行预处理(如基于表单的身份验证、授权)
      • postHandle:类似于 AOP 中的后置返回增强;在拦截器链执行完成后执行;进行后处理(如记录执行时间之类的);
      • afterCompletion:类似于 AOP 中的后置最终增强;即不管有没有异常都会执行;可以进行清理资源(如解除 Subject 与线程的绑定之类的);
    • AccessControlFilter 提供了访问控制的基础功能;比如是否允许访问/当访问拒绝时如何处理等:

      上边的AuthenticatingFilter继承的AuthenticationFilter父类就是继承的AccessControlFilter,在该抽象类里定义了getSubject方法通过SecurityUtils.getSubject();实现

      abstract boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception;
      boolean onAccessDenied(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception;
      abstract boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception;
      

      isAccessAllowed:表示是否允许访问;mappedValue 就是[urls]配置中拦截器参数部分,如果允许访问返回 true,否则 false;

      onAccessDenied:表示当访问拒绝时是否已经处理了;如果返回 true 表示需要继续处理;如果返回 false 表示该拦截器实例已经处理了,将直接返回即可。

      onPreHandle 会自动调用这两个方法决定是否继续处理;

      AccessControlFilter 还提供了如下方法用于处理如登录成功后/重定向到上一个请求:

      void setLoginUrl(String loginUrl) //身份验证时使用,默认/login.jsp
      String getLoginUrl()
      Subject getSubject(ServletRequest request, ServletResponse response) //获取Subject 实例
      boolean isLoginRequest(ServletRequest request, ServletResponse response)//当前请求是否是登录请求
      void saveRequestAndRedirectToLogin(ServletRequest request, ServletResponse response) throws IOException //将当前请求保存起来并重定向到登录页面
      void saveRequest(ServletRequest request) //将请求保存起来,如登录成功后再重定向回该请求
      void redirectToLogin(ServletRequest request, ServletResponse response) //重定向到登录页面
      

      如果我们想进行访问访问的控制就可以继承 AccessControlFilter;如果我们要添加一些通用数据我们可以直接继承 PathMatchingFilter

    拦截器链

    Shiro 对 Servlet 容器的 FilterChain 进行了代理,即 ShiroFilter 在继续 Servlet 容器的 Filter 链的执行之前,通过 ProxiedFilterChain 对 Servlet 容器的 FilterChain 进行了代理;即先走 Shiro 自己的 Filter 体系,然后才会委托给 Servlet 容器的 FilterChain 进行 Servlet 容器级别的 Filter 链执行;Shiro 的 ProxiedFilterChain 执行流程:1、先执行 Shiro 自己的 Filter 链;2、再执行 Servlet 容器的 Filter 链(即原始的 Filter)。

    ProxiedFilterChain 是通过 FilterChainResolver 根据配置文件中[urls]部分是否与请求的 URL 是否匹配解析得到的。

    FilterChain getChain(ServletRequest request, ServletResponse response, FilterChain originalChain);

    即传入原始的 chain 得到一个代理的 chain。

    Shiro 内部提供了一个路径匹配的 FilterChainResolver 实现:PathMatchingFilterChainResolver,其根据[urls]中配置的 url 模式(默认 Ant 风格)=拦截器链和请求的 url 是否匹配来解析得到配置的拦截器链的;而 PathMatchingFilterChainResolver 内部通过 FilterChainManager 维护着拦截器链,比如 DefaultFilterChainManager 实现维护着 url 模式与拦截器链的关系。因此我们可以通过 FilterChainManager 进行动态动态增加 url 模式与拦截器链的关系。

    这里可能就是对shiro的map里put的路径进行的拦截设计

    会话管理

    登录成功后使用 Subject.getSession() 即可获取会话;其等价于 Subject.getSession(true),即如果当前没有创建 Session 对象会创建一个;另外 Subject.getSession(false),如果当前没有创建 Session 则返回 null(不过默认情况下如果启用会话存储功能的话在创建 Subject 时会主动创建一个 Session)。

    如果是 Web 应用,每次进入 ShiroFilter 都会自动调用 session.touch() 来更新最后访问时间。

    Subject.logout() 时会自动调用 stop 方法来销毁会话。

    会话管理器

    顶层组件 SecurityManager 直接继承了 SessionManager,且提供了SessionsSecurityManager 实现直接把会话管理委托给相应的 SessionManager

    在 Servlet 容器中,默认使用 JSESSIONID Cookie 维护会话,且会话默认是跟容器绑定的;

    会话存储 / 持久化

    Shiro 提供 SessionDAO 用于会话的 CRUD,即 DAO(Data Access Object)模式实现:

    //如DefaultSessionManager在创建完session后会调用该方法;如保存到关系数据库/文件系统/NoSQL数据库;即可以实现会话的持久化;返回会话ID;主要此处返回的ID.equals(session.getId());
    Serializable create(Session session);
    //根据会话ID获取会话
    Session readSession(Serializable sessionId) throws UnknownSessionException;
    //更新会话;如更新会话最后访问时间/停止会话/设置超时时间/设置移除属性等会调用
    void update(Session session) throws UnknownSessionException;
    //删除会话;当会话过期/会话停止(如用户退出时)会调用
    void delete(Session session);
    //获取当前所有活跃用户,如果用户量多此方法影响性能
    Collection<Session> getActiveSessions();
    

    如果自定义实现 SessionDAO,继承 CachingSessionDAO 即可:

    Realm 缓存

    Shiro 提供了 CachingRealm,其实现了 CacheManagerAware 接口,提供了缓存的一些基础实现;另外 AuthenticatingRealmAuthorizingRealm 分别提供了对 AuthenticationInfoAuthorizationInfo 信息的缓存。

    总体流程的梳理

    1. //4. 获取当前主题
      Subject subject = SecurityUtils.getSubject();
      
    2. //5.根据登录对象身份凭证信息创建登录令牌
      UsernamePasswordToken token = new UsernamePasswordToken(username,password);
      
    3. 认证:

      subject.login(token);
      

      在该方法中经历了以下流程:

      进入了DelegatingSubject.login方法:

      在该方法中还是调用了securityManager的login方法,真正的认证操作还是由安全管理器对象securityManager执行。:

      // 1. 真正做认证的还是securityManager对象
      Subject subject = this.securityManager.login(this, token);
      

      接着进入到了进入到securityManager的login方法当中:

      在该方法里调用了authenticate(token)方法:

      //调用认证方法
                  info = this.authenticate(token);
      

      最终经过多层嵌套进入到了ModularRealmAuthenticator认证器对象的doAuthenticate方法

      public class ModularRealmAuthenticator extends AbstractAuthenticator {
          protected AuthenticationInfo doAuthenticate(AuthenticationToken authenticationToken) throws AuthenticationException {
              this.assertRealmsConfigured();
              Collection<Realm> realms = this.getRealms();
              return realms.size() == 1 ?
                      /**终于到了真正的认证逻辑*/               this.doSingleRealmAuthentication((Realm)realms.iterator().next(), authenticationToken) : this.doMultiRealmAuthentication(realms, authenticationToken);
          }
      }
      

      检验我们的Realms对象创建后,开始进入到doSingleRealmAuthentication方法当中进行认证操作

      protected AuthenticationInfo doSingleRealmAuthentication(Realm realm, AuthenticationToken token) {
          if (!realm.supports(token)) {
              String msg = "Realm [" + realm + "] does not support authentication token [" + token + "].  Please ensure that the appropriate Realm implementation is configured correctly or that the realm accepts AuthenticationTokens of this type.";
              throw new UnsupportedTokenException(msg);
          } else {
              //获取认证信息
              AuthenticationInfo info = realm.getAuthenticationInfo(token);
              if (info == null) {
                  String msg = "Realm [" + realm + "] was unable to find account data for the submitted AuthenticationToken [" + token + "].";
                  throw new UnknownAccountException(msg);
              } else {
                  return info;
              }
          }
      }
      

      getAuthenticationInfo()方法

      在这一步当中开始根据我们传入的令牌获取认证信息

      public abstract class AuthenticatingRealm extends CachingRealm implements Initializable {
      
          public final AuthenticationInfo getAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
              // 首先从缓存中获取
              AuthenticationInfo info = this.getCachedAuthenticationInfo(token);
              if (info == null) {
                  //缓存中没有,则从持久化数据中获取
                  info = this.doGetAuthenticationInfo(token);
                  log.debug("Looked up AuthenticationInfo [{}] from doGetAuthenticationInfo", info);
                  if (token != null && info != null) {
                      this.cacheAuthenticationInfoIfPossible(token, info);
                  }
              } else {
                  log.debug("Using cached authentication info [{}] to perform credentials matching.", info);
              }
      
              if (info != null) {
                  this.assertCredentialsMatch(token, info);
              } else {
                  log.debug("No AuthenticationInfo found for submitted AuthenticationToken [{}].  Returning null.", token);
              }
      
              return info;
          }
      }
      

      这里一般就是调用的自定义的realm里的doGetAuthenticationInfo方法,然后返回SimpleAuthenticationInfo(user, user.getPassWord(), credentialsSalt, this.getClass().getName());,接着回到了AuthenticatingRealmgetAuthenticationInfo方法中,调用assertCredentialsMatch方法开始校验用户凭证,在该方法里获取默认的或者自定义的匹配规则算法将token里的用户凭证和数据库里获取的进行匹配对比验证。

    总结:在SimpleAccountRealm对象中的doGetAuthenticationInfo方法中完成账户验证,在AuthenticatingRealmassertCredentialsMatch完成对用户凭证的校验。

    在redis中缓存的key的形式:

    shiro:cache:com.example.autohomingtest.config.MyShiroRealm.authenticationCache:username
    

    image

    image-20201009131904740

    三个键值分别对应认证信息缓存、授权信息缓存和会话信息缓存。

    登录成功之后看到cookie里存储了对应的token

    image

    但是这里的token不是以login_token开头的,因为在redisSessionDAO里设置了key的前缀和过期时间,并且注册了redisManagersessionIdGenerator,在sessionIdGenerator里定义了SessionId是以login_token开头的,而redisSessionDAO是在sessionManager()方法里注册的,但是最开始代码里并没有去注册sessionManager方法,所以token格式是上述形式,在securityManager方法里把sessionManager注册进去就可以了

    @Bean
    public SecurityManager securityManager(@Qualifier("myShiroRealm") MyShiroRealm myShiroRealm){
        DefaultWebSecurityManager manager = new DefaultWebSecurityManager();
        manager.setRealm(myShiroRealm);
        manager.setCacheManager(getCacheManager());
        // 这里是我自己增加的,不然sessionManager没有注册进去
        manager.setSessionManager(sessionManager());
        return manager;
    }
    

    结果截图:

    image

    注册:

    先生成一个盐值,然后将用户填写的明文密码和该盐值一起拼接成之后通过md5散列得到一个新的值,将这个值作为数据库中的用户密码,并将盐值记录到数据库中,这样每次用户登录,就是将数据库中的盐值和用户填写的密码明文(token里获取)拼接通过md5散列得到一个值,看该值是否和数据库获取到的密码密文一样,一样说明正确

    //三个参数分别对应密码明文、盐值、散列次数
    String salt = UUID.randomUUID().toString();
    Md5Hash md5Hash = new Md5Hash(user.getPassword(), salt,1024);
    log.debug("密文:"+md5Hash.toHex());
    log.debug("盐值:"+salt);
    user.setPassword(md5Hash.toHex());
    user.setSalt(salt);
    userMapper.insert(user);
    

    所谓的加盐就是在原密码的基础上,加上一段随机字符串。然后再加密。

    后端跨域问题

    新建一个过滤器实现WebMvcConfigurer接口即可:

    @Configuration
    public class CorsConfig implements WebMvcConfigurer {
    
        @Override
        public void addCorsMappings(CorsRegistry registry) {
            registry.addMapping("/**")
                    .allowedOriginPatterns("*")
                    .allowedMethods("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS")
                    .allowCredentials(true)
                    .maxAge(3600)
                    .allowedHeaders("*");
        }
    }
    

    前端跨域问题:

    image-20211020214656590

    vue.config.js文件里把上边的before: require('./mock/mock-server.js'),注释掉,并添加下边的代码

    更改.dev.development文件里的VUE_APP_BASE_API

    image-20211020214804750

    把utils文件夹里的request.js文件里的下边的code!=20000改为code!=200(这个看不同前端项目而定,如果这里不改,即使获取到了后台的代码,后台默认是200为正确的,这里前台判定是20000,前台就会报错,而不是返回后台的数据显示)

    image-20211020215023242

    什么时候创建的token,怎么从token里获取用户信息

    AuthenticatingFilter在执行executeLogin方法的时候会执行AuthenticationToken token = this.createToken(request, response);来生成token,这里可以继承该filter来实现自定义的生成token的方法也可以使用默认的,然后调用

    Subject subject = this.getSubject(request, response);
    subject.login(token);
    

    subject.login(token)里会调用AuthenticatingRealm中的getAuthenticationInfo方法,在该方法里调用自定义realmdoGetAuthenticationInfo方法获取对应的SimpleAuthenticationInfo对象

    而在本项目的代码里是在如下时机利用用户名和密码创建的token:

    Subject subject = SecurityUtils.getSubject();
    UsernamePasswordToken token = new UsernamePasswordToken(user.getUsername(), user.getPassword());
    
    subject.login(token);
    

    FormAuthenticationFilter类中通过用户名和密码作为参数创建token

    protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) {
        String username = this.getUsername(request);
        String password = this.getPassword(request);
        return this.createToken(username, password, request, response);
    }
    

    然后调用父类AuthenticatingFilter中的createToken方法:

    protected AuthenticationToken createToken(String username, String password, ServletRequest request, ServletResponse response) {
        boolean rememberMe = this.isRememberMe(request);
        String host = this.getHost(request);
        return this.createToken(username, password, rememberMe, host);
    }
    
        protected AuthenticationToken createToken(String username, String password, boolean rememberMe, String host) {
            return new UsernamePasswordToken(username, password, rememberMe, host);
        }
    

    最后生成的也是UsernamePasswordToken

    在本项目中登录的时候,将会话信息存储在redis中,并在用户认证通过后将会话Id以token的形式返回给用户。用户请求受保护资源时带上这个token,我们根据token信息去redis中获取用户的权限信息,从而做访问控制。

        /**
         * 登录方法
         * 在调用了login方法后,SecurityManager会收到AuthenticationToken,并将其发送给已配置的Realm执行必须的认证检查
         * 每个Realm都能在必要时对提交的AuthenticationTokens作出反应
         * @param user
         * @return
         * @throws AuthenticationException
         */
    @RequestMapping("/login")
    @ResponseBody
    public ResponseWrapper loginUser(@RequestBody User user) throws AuthenticationException {
        ResponseWrapper responseWrapper;
        boolean flags = authcService.login(user);
        if (flags){
            // 将会话信息存储在redis中,并在用户认证通过后将会话Id以token的形式返回给用户。用户请求受保护资源时带上这个token,我们根据token信息去redis中获取用户的权限信息,从而做访问控制。
            Serializable id = SecurityUtils.getSubject().getSession().getId();
            responseWrapper=ResponseWrapper.markSuccess();
            responseWrapper.setExtra("token",id);
        }else {
            responseWrapper = ResponseWrapper.markNoData();
        }
    
        return responseWrapper;
    }
    

    我的理解是这里的token存储的是redis里用户的权限信息对应的key,方便后面用户操作的时候通过这个token作为key从redis里获取权限信息
    image

    image
    image

    image

    当再次调用需要权限的URL时会调用doGetAuthorizationInfo方法:
    image

    但是这里有个问题,调用/main的url的时候向后台传递token,传的错误的token也没有报错,如果请求头不带token会跳转到未登录认证的url提示未登录认证

    如果是需要权限的URL比如说/manage

    如果请求头没有带token,会跳转到未授权的url提示未授权,如果请求头带的token是错误的token,会跳转到未登录认证的URL提示未登录认证,如果请求头是正确的token,但是没有相应的权限,也会跳转到未授权url,如果是过期的token,但是过期了也会跳转到未登录认证url提示未登录认证

    上述这两个问题已经解决:在MyshiroFilter里对OPTIONS请求放行,对于有token的不是直接返回true,而是交给父类去处理,不然就跳过了父类对token的判断处理

    在shiroConfig里定义了redis的key值的前缀

    // CACHE_KEY里是缓存AuthenticationInfo信息和AuthorizationInfo信息的缓存名称的前缀
    private static final String CACHE_KEY = "shiro:cache:";
    private static final String SESSION_KEY = "shiro:session:";
    

    并且配置了redisManager里的前缀和后缀的形式:

    redisCacheManager.setKeyPrefix(CACHE_KEY);
    // shiro-redis要求放在session里面的实体类必须有个id标识
    //这是组成redis中所存储数据的key的一部分,完整的key的形式:shiro:cache:com.example.autohomingtest.config.MyShiroRealm.authenticationCache:username
    redisCacheManager.setPrincipalIdFieldName("username");
    

    我的理解是前端传来的token是redis里的一个key,通过这个key可以获取到用户的信息比如说用户名,然后可以根据用户名去拼接对应的redis里存储的AuthenticationInfo信息和AuthorizationInfo信息的key,然后根据这些key获取对应的认证信息(SimpleAuthenticationInfo)和授权信息(SimpleAuthorizationInfo)

    存储的session:
    image

    将该值序列化,key就是login_token 开头的sessionId,value就是序列化后的值,然后设置到redis里去

    但是好像没有从redis里获取

    发现是从这里根据token也就是sessionId获取
    image
    image

    如果从ThreadLocal里获取不到对应的session,就从redis里去获取
    image

    image

    如果是不对的token,那么从redis里就获取不到对应的value值
    image

    如果是正确的token,从redis里获取到对应的值,然后将该值和key进行验证:
    image

    然后从redis里获取用户信息
    image

    从AuthenticationInfo里去获取,如果还是为空,就从redis里获取,最后得到用户信息:
    image

    不正确的token也能认证通过的原因是因为在配置跨域的时候,MyShiroFilter类里的isAccessAllowed方法里除了放行OPTIONS请求之外,对有token的请求都放行了:

    if (request.getAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID)!=null) {
        return true;
    }
    

    把这几行代码注释掉就可以了

    public class MyShiroFilter extends FormAuthenticationFilter {
        @Override
        protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
            if (request instanceof HttpServletRequest) {
                if (((HttpServletRequest)request).getMethod().toUpperCase().equals("OPTIONS")) {
                    return true;
                }
            }
            // if (request.getAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID)!=null) {
            //    return true;
            // }
            return super.isAccessAllowed(request, response, mappedValue);
        }
    }
    

    当请求头中错误的token时候,获取的session里的sessionId是空的
    image

    前端注意

    前端每次请求都要在请求头里添加token,以"Authorization"为key,token作为value,可以写在前端的请求拦截器里

    shiro 鉴权

    1、基于注解(开启注解需要在shiroConfig里重写AuthorizationAttributeSourceAdvisor类)

    @RequiresPermissions("user-home")  -- 访问此方法必须具有的权限
    @RequiresRoles("admin")  --  访问此方法必须具有的角色
    

    2、基于拦截器

        Map<String,String> filterMap = new LinkedHashMap<>();
       filterMap.put("/home","anon"); // 当前请求url地址可匿名访问
        // 具有某种权限才能访问
       filterMap.put("/user/client","perms[user-client]");
        // 具有某种角色才能访问
       filterMap.put("/user/admin","roles[admin]");
       filterMap.put("/user/**","authc"); // 当前请求地址必须认证之后可访问
        // 设置过滤器
    filterFactory.setFilterChainDefinitionMap(filterMap);
    

    shiro 两种鉴权方式的区别

    • 过滤器:如果权限信息不匹配 跳转到setUnauthorizedUrl地址
    • 注解;如果权限信息不匹配,抛出异常

    整体的搭建流程:

    1. 自定义Realm

      重写授权和认证方法,doGetAuthorizationInfo和doGetAuthenticationInfo方法,并且设置密码加密匹配算法,设置开启缓存。

      /**
       * @author :RealGang
       * @description:自定义权限匹配和密码匹配,认证用户,授权
       * @date : 2021/10/18 18:04
       */
      public class MyShiroRealm extends AuthorizingRealm {
          private final static Logger logger = LoggerFactory.getLogger(MyShiroRealm.class);
          /**
           * 延迟加载bean,解决缓存Cache不能正常使用;事务Transaction注解不能正常运行
           */
          @Autowired
          @Lazy
          private UserServiceImpl userService;
      
      
          public MyShiroRealm() {
              //设置凭证匹配器,修改为hash凭证匹配器
              HashedCredentialsMatcher myCredentialsMatcher = new HashedCredentialsMatcher();
              //设置算法
              myCredentialsMatcher.setHashAlgorithmName("md5");
              //散列次数
              myCredentialsMatcher.setHashIterations(1024);
              this.setCredentialsMatcher(myCredentialsMatcher);
              //开启缓存
              this.setCachingEnabled(true);
              this.setAuthenticationCachingEnabled(true);
              this.setAuthorizationCachingEnabled(true);
          }
      
          /**
           * @description: 授权;doGetAuthorizationInfo方法是在我们调用;SecurityUtils.getSubject().isPermitted()这个方法,授权后用户角色及权限会保存在缓存中的
           *  "@RequiresPermissions"这个注解其实就是在执行SecurityUtils.getSubject().isPermitted()
           *  授权
           *  这个方法在每次访问ShiroConfig里面配置的受保护资源时都会调用
           *  因此,需要做缓存
           * @param: principalCollection
           * @return: org.apache.shiro.authz.AuthorizationInfo
           * @author: RealGang
           * @date: 2021/10/19
           */
          @Override
          protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
              User user;
              Object object = principalCollection.getPrimaryPrincipal();
              // 这里用json转化为USER,因为可能从redis获取的用户信息反序列化不能强制转换为user报错
              if (object instanceof User) {
                  user = (User) object;
              } else {
                  user = JSON.parseObject(JSON.toJSON(object).toString(), User.class);
              }
              String username = user.getUsername();
              SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
      //        User user = (User) principalCollection.fromRealm(this.getClass().getName()).iterator().next();
              //查询数据库
              user = userService.findUserInfo(user.getUsername());
              logger.info("##################执行Shiro权限授权##################user info is:{}" + JSONObject.toJSONString(user));
              Set<String> userPermissions = new HashSet<String>();
              Set<String> userRoles = new HashSet<String>();
              for (Role role : user.getRoles()) {
                  userRoles.add(role.getRoleName());
                  List<Permission> rolePermissions = role.getPermissions();
                  for (Permission permission : rolePermissions) {
                      userPermissions.add(permission.getPermName());
                  }
              }
              //角色名集合
              info.setRoles(userRoles);
              //权限名集合,将权限放入shiro中,
              // 这里可以把url,按钮,菜单,api等当做资源来进行权限控制,从而对用户进行权限控制
              info.addStringPermissions(userPermissions);
      
              return info;
          }
      
          /**
           * @description: 认证,登录,doGetAuthenticationInfo这个方法是在用户登录的时候调用的;也就是执行SecurityUtils.getSubject().login()的时候调用;(即:登录验证),验证通过后会用户保存在缓存中的
           * @param: authenticationToken
           * @return: org.apache.shiro.authc.AuthenticationInfo
           * @author: RealGang
           * @date: 2021/10/19
           */
          @Override
          protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
              logger.info("##################执行Shiro登录认证##################");
              // 客户端传来的 username 和 password 会自动封装到 token,先根据 username 进行查询.如果返回 null,则表示用户名错误,直接 return null 即可,Shiro 会自动抛出 UnknownAccountException 异常。
      
              if(authenticationToken==null){
                  return null;
              }
              String principal = (String) authenticationToken.getPrincipal();
              //查询数据库
              User user = userService.findByUserName(principal);
              //放入shiro.调用CredentialsMatcher检验密码
              if (user != null) {
                  // 若存在,将此用户存放到登录认证info中,无需自己做密码对比,Shiro会为我们进行密码对比校验
      
                  // 第三个参数一般也可以是ByteSource.Util.bytes(shiroUser.getUserName()+shiroPasswordService.getPublicSalt())
                  // //由于shiro-redis插件需要从这个属性中获取id作为redis的key,所有这里传的是user而不是username
      //            return new SimpleAuthenticationInfo(user, user.getPassWord(), credentialsSalt, this.getClass().getName());
                  return new SimpleAuthenticationInfo(user, user.getPassword(), new CurrentSalt(user.getSalt()), this.getClass().getName());
              }
              return null;
          }
      }
      
    2. 添加Shiro自定义会话

      添加自定义会话ID生成器

      这里配置token以"login_token"开头的token也就是sessionId

      public class ShiroSessionIdGenerator implements SessionIdGenerator {
      
          /**
           *实现SessionId生成
           * @param session
           * @return
           */
          @Override
          public Serializable generateId(Session session) {
              Serializable sessionId = new JavaUuidSessionIdGenerator().generateId(session);
              return String.format("login_token_%s", sessionId);
          }
      }
      

      添加自定义会话管理器

      public class ShiroSessionManager extends DefaultWebSessionManager {
      
          //定义常量
          private static final String AUTHORIZATION = "Authorization";
          private static final String REFERENCED_SESSION_ID_SOURCE = "Stateless request";
          //重写构造器
          public ShiroSessionManager() {
              super();
              this.setDeleteInvalidSessions(true);
          }
      
          /**
           * 重写方法实现从请求头获取Token便于接口统一
           *      * 每次请求进来,
           *      Shiro会去从请求头找Authorization这个key对应的Value(Token)
           * @param request
           * @param response
           * @return
           */
          @Override
          public Serializable getSessionId(ServletRequest request, ServletResponse response) {
              String token = WebUtils.toHttp(request).getHeader(AUTHORIZATION);
              //如果请求头中存在token 则从请求头中获取token
              if (!StringUtils.isEmpty(token)) {
                  request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE, REFERENCED_SESSION_ID_SOURCE);
                  request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID, token);
                  request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_IS_VALID, Boolean.TRUE);
                  return token;
              } else {
                  // 这里禁用掉Cookie获取方式
                  return null;
                  // 否则按默认规则从cookie取sessionId
                  //return super.getSessionId(request, response);
              }
          }
      }
      
    3. 配置shiro:shiroConfig

      在该配置文件里主要是一个filterFactoryBean,该类里注册SecurityManager,并且可以设置一些自定义过滤器,然后设置过滤url规则,在securityManager方法里注入自定义的realm,并且注入自己重写的redisCacheManager和会话管理器sessionManager

      @Configuration
      public class ShiroConfig {
      
          // CACHE_KEY里是缓存AuthenticationInfo信息和AuthorizationInfo信息的缓存名称的前缀
          private static final String CACHE_KEY = "shiro:cache:";
          private static final String SESSION_KEY = "shiro:session:";
          private static final int EXPIRE = 18000;
          @Value("${spring.redis.host}")
          private String host;
          @Value("${spring.redis.port}")
          private int port;
          @Value("${spring.redis.timeout}")
          private int timeout;
      //    @Value("${spring.redis.password}")
      //    private String password;
      
          @Value("${spring.redis.jedis.pool.min-idle}")
          private int minIdle;
          @Value("${spring.redis.jedis.pool.max-idle}")
          private int maxIdle;
          @Value("${spring.redis.jedis.pool.max-active}")
          private int maxActive;
      
          //开启对shior注解的支持
          @Bean
          public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(org.apache.shiro.mgt.SecurityManager securityManager) {
              AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
              authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
              return authorizationAttributeSourceAdvisor;
          }
      
          /**
           * @description: 自定义过滤器 MyShiroRealm,我们的业务逻辑全部定义在这个 bean 中。
           * @param:
           * @return: com.example.autohomingtest.config.MyShiroRealm
           * @author: RealGang
           * @date: 2021/10/19
           */
          @Bean
          public MyShiroRealm myShiroRealm(){
              return new MyShiroRealm();
          }
      
      
          /**
           * @description: 将 myShiroRealm 注入到 DefaultWebSecurityManager bean 中,完成注册。
           * @param: myShiroRealm
           * @return: org.apache.shiro.web.mgt.DefaultWebSecurityManager
           * @author: RealGang
           * @date: 2021/10/19
           */
          @Bean
          public SecurityManager securityManager(@Qualifier("myShiroRealm") MyShiroRealm myShiroRealm){
              DefaultWebSecurityManager manager = new DefaultWebSecurityManager();
              manager.setRealm(myShiroRealm);
              manager.setCacheManager(redisCacheManager());
              // 这里是我自己增加的,不然sessionManager没有注册进去
              manager.setSessionManager(sessionManager());
              SecurityUtils.setSecurityManager(manager);
              return manager;
          }
      
          /**
           * @description: ShiroFilterFactoryBean,这是 Shiro 自带的一个 Filter 工厂实例,所有的认证和授权判断都是由这个 bean 生成的 Filter 对象来完成的,
           * 这就是 Shiro 框架的运行机制,开发者只需要定义规则,进行配置,具体的执行者全部由 Shiro 自己创建的 Filter 来完成。
           * @param: manager
           * @return: org.apache.shiro.spring.web.ShiroFilterFactoryBean
           * @author: RealGang
           * @date: 2021/10/19
           */
          @Bean
          public ShiroFilterFactoryBean filterFactoryBean(@Qualifier("securityManager") SecurityManager securityManager){
              ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();
              factoryBean.setSecurityManager(securityManager);
              Map<String, Filter> filterMap = factoryBean.getFilters();
              // 这里把自定义拦截OPTIONS请求的拦截器注册进来
              filterMap.put("authc",new MyShiroFilter());
              Map<String,String> map = new LinkedHashMap<>();
              /**
               * Shiro 内置过滤器,过滤链定义,从上向下顺序执行
               *  常用的过滤器:
               *      anon:无需认证(登录)可以访问
               *      authc:必须认证才可以访问
               *      user:只要登录过,并且记住了密码,如果设置了rememberMe的功能可以直接访问
               *      perms:该资源必须得到资源权限才可以访问
               *      role:该资源必须得到角色的权限才可以访问
               */
              map.put("/manage","perms[manage]");
              map.put("/administrator","roles[administrator]");
              //anon表示可以匿名访问
              map.put("/index", "anon");
              map.put("/login", "anon");
              map.put("/static/**", "anon");
              map.put("/user/testDb","anon");
              //authc表示需要登录
              map.put("/user/**","authc");
              map.put("/main","authc");
              factoryBean.setFilterChainDefinitionMap(map);
              //设置登录页面,覆盖默认的登录url,这里如果未认证会跳转到/unauthc这里来
              factoryBean.setLoginUrl("/unauthc");
              //未授权页面
              factoryBean.setUnauthorizedUrl("/unauthr");
              // 登录成功后要跳转的链接
              factoryBean.setSuccessUrl("/index");
              return factoryBean;
          }
      
      
          /**
           * 配置Redis管理器
           * @Attention 使用的是shiro-redis开源插件
           * @return
           */
          @Bean
          public RedisManager redisManager() {
              RedisManager redisManager = new RedisManager();
              redisManager.setHost(host);
              redisManager.setPort(port);
              redisManager.setTimeout(timeout);
      //        redisManager.setPassword(password);
              JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
              jedisPoolConfig.setMaxTotal(maxIdle+maxActive);
              jedisPoolConfig.setMaxIdle(maxIdle);
              jedisPoolConfig.setMinIdle(minIdle);
              redisManager.setJedisPoolConfig(jedisPoolConfig);
              return redisManager;
          }
      
      
          @Bean
          public RedisCacheManager redisCacheManager() {
              RedisCacheManager redisCacheManager = new RedisCacheManager();
              redisCacheManager.setRedisManager(redisManager());
              redisCacheManager.setKeyPrefix(CACHE_KEY);
              // shiro-redis要求放在session里面的实体类必须有个id标识
              //这是组成redis中所存储数据的key的一部分,完整的key的形式:shiro:cache:com.example.autohomingtest.config.MyShiroRealm.authenticationCache:username
              redisCacheManager.setPrincipalIdFieldName("username");
              return redisCacheManager;
          }
      
          /**
           * SessionID生成器
           *
           */
          @Bean
          public ShiroSessionIdGenerator sessionIdGenerator(){
              return new ShiroSessionIdGenerator();
          }
      
          /**
           * 配置RedisSessionDAO
           */
          @Bean
          public RedisSessionDAO redisSessionDAO() {
              RedisSessionDAO redisSessionDAO = new RedisSessionDAO();
              redisSessionDAO.setRedisManager(redisManager());
              redisSessionDAO.setSessionIdGenerator(sessionIdGenerator());
              redisSessionDAO.setKeyPrefix(SESSION_KEY);
              redisSessionDAO.setExpire(EXPIRE);
              return redisSessionDAO;
          }
      
          /**
           * 配置Session管理器
           * @Author Sans
           *
           */
          @Bean
          public SessionManager sessionManager() {
              ShiroSessionManager shiroSessionManager = new ShiroSessionManager();
              shiroSessionManager.setSessionDAO(redisSessionDAO());
              //禁用cookie
              shiroSessionManager.setSessionIdCookieEnabled(false);
              //禁用会话id重写
              // ession管理器的setSessionIdUrlRewritingEnabled(false)配置没有生效,导致没有认证直接访问受保护资源出现多次重定向的错误。将shiro版本切换为1.5.0后就解决了这个bug。
              shiroSessionManager.setSessionIdUrlRewritingEnabled(false);
              return shiroSessionManager;
          }
      }
      
    4. 解决跨域问题

      配置CorConfig:

      @Configuration
      public class CorsConfig implements WebMvcConfigurer {
      
          @Override
          public void addCorsMappings(CorsRegistry registry) {
              registry.addMapping("/**")
                      .allowedOriginPatterns("*")
                      .allowedMethods("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS")
                      .allowCredentials(true)
                      .maxAge(3600)
                      .allowedHeaders("*");
          }
      }
      

      配置MyShiroFilter过滤器,这里主要拦截OPTIONS请求,并且注册到上述的filterFactoryBean中去:

      public class MyShiroFilter extends FormAuthenticationFilter {
          @Override
          protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
              if (request instanceof HttpServletRequest) {
                  if (((HttpServletRequest)request).getMethod().toUpperCase().equals("OPTIONS")) {
                      return true;
                  }
              }
      //        if (request.getAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID)!=null) {
      //            return true;
      //        }
              return super.isAccessAllowed(request, response, mappedValue);
          }
      }
      
    5. 自定义盐值生成方法保证可以序列化(由于shiro当中的ByteSource没有实现序列化接口,缓存时会发生错误,因此,我们需要通过自定义ByteSource的方式实现这个接口):

      public class CurrentSalt implements ByteSource, Serializable {
          private static final long serialVersionUID = 125096758372084309L;
      
          private  byte[] bytes;
          private String cachedHex;
          private String cachedBase64;
      
          public CurrentSalt(){
          }
      
          public CurrentSalt(byte[] bytes) {
              this.bytes = bytes;
          }
      
          public CurrentSalt(char[] chars) {
              this.bytes = CodecSupport.toBytes(chars);
          }
      
          public CurrentSalt(String string) {
              this.bytes = CodecSupport.toBytes(string);
          }
      
          public CurrentSalt(ByteSource source) {
              this.bytes = source.getBytes();
          }
      
          public CurrentSalt(File file) {
              this.bytes = (new CurrentSalt.BytesHelper()).getBytes(file);
          }
      
          public CurrentSalt(InputStream stream) {
              this.bytes = (new CurrentSalt.BytesHelper()).getBytes(stream);
          }
      
          public static boolean isCompatible(Object o) {
              return o instanceof byte[] || o instanceof char[] || o instanceof String || o instanceof ByteSource || o instanceof File || o instanceof InputStream;
          }
      
          public void setBytes(byte[] bytes) {
              this.bytes = bytes;
          }
      
          @Override
          public byte[] getBytes() {
              return this.bytes;
          }
      
      
          @Override
          public String toHex() {
              if(this.cachedHex == null) {
                  this.cachedHex = Hex.encodeToString(this.getBytes());
              }
              return this.cachedHex;
          }
      
          @Override
          public String toBase64() {
              if(this.cachedBase64 == null) {
                  this.cachedBase64 = Base64.encodeToString(this.getBytes());
              }
      
              return this.cachedBase64;
          }
      
          @Override
          public boolean isEmpty() {
              return this.bytes == null || this.bytes.length == 0;
          }
          @Override
          public String toString() {
              return this.toBase64();
          }
      
          @Override
          public int hashCode() {
              return this.bytes != null && this.bytes.length != 0? Arrays.hashCode(this.bytes):0;
          }
      
          @Override
          public boolean equals(Object o) {
              if(o == this) {
                  return true;
              } else if(o instanceof ByteSource) {
                  ByteSource bs = (ByteSource)o;
                  return Arrays.equals(this.getBytes(), bs.getBytes());
              } else {
                  return false;
              }
          }
      
          private static final class BytesHelper extends CodecSupport {
              private BytesHelper() {
              }
      
              public byte[] getBytes(File file) {
                  return this.toBytes(file);
              }
      
              public byte[] getBytes(InputStream stream) {
                  return this.toBytes(stream);
              }
          }
      
      }
      
    6. 登录接口编写:

    @RequestMapping("/login")
    @ResponseBody
    public ResponseWrapper loginUser(@RequestBody User user) throws AuthenticationException {
        ResponseWrapper responseWrapper;
        boolean flags = authcService.login(user);
        if (flags){
            // 将会话信息存储在redis中,并在用户认证通过后将会话Id以token的形式返回给用户。用户请求受保护资源时带上这个token,我们根据token信息去redis中获取用户的权限信息,从而做访问控制。
            Serializable id = SecurityUtils.getSubject().getSession().getId();
            logger.debug("会话ID:"+id);
            responseWrapper=ResponseWrapper.markSuccess();
            responseWrapper.setExtra("token",id);
        }else {
            responseWrapper = ResponseWrapper.markNoData();
        }
    
        return responseWrapper;
    }
    

    在authService里的login方法里,通过用户名和密码生成UsernamePasswordToken然后调用subject.login(token)让shiro自己去处理:

    @Service
    public class AuthcServiceImpl implements AuthcService {
        @Override
        public boolean login(User user) throws AuthenticationException {
            if (user==null){
                return false;
            }
    
            if (user.getUsername()==null||"".equals(user.getUsername())){
                return false;
            }
    
            if (user.getPassword() == null || "".equals(user.getPassword())){
                return false;
            }
            Subject subject = SecurityUtils.getSubject();
            UsernamePasswordToken token = new UsernamePasswordToken(user.getUsername(), user.getPassword());
    
            subject.login(token);
            return true;
        }
    }
    

    前端获取到token之后可以放到vuex里去,每次向后台发送请求可以在拦截器里将该token添加到Header里的“Authorization"里去

  • 相关阅读:
    Android应用Activity、Dialog、PopWindow、Toast窗口添加机制及源码分析
    Android应用setContentView与LayoutInflater加载解析机制源码分析
    Android图片加载框架Fresco,Glide,Picasso对比分析
    Android加载外部APK资源
    https 真的安全吗,可以抓包吗,如何防止抓包吗
    Android常见的几种内存泄漏
    Android应用结构之LiveData
    Android Jetpack LiveData解析
    android使用socket实现简单的点对点通信
    如何分析解决Android ANR
  • 原文地址:https://www.cnblogs.com/RealGang/p/15433954.html
Copyright © 2011-2022 走看看