美文网首页
从源码角度分析Shiro的验证过程

从源码角度分析Shiro的验证过程

作者: 久仰96 | 来源:发表于2019-07-23 23:33 被阅读0次

    背景

    • 我们这个项目是前后端分离的架构。由于前端在一次退出登录时,存在同一用户多次登录的情况,导致退出登录失败!存在Redis服务器中的SessionId被删除,也就不能再尝试退出登录了。
    • 但是更想不到的是,自此以后,不论账号密码对不对都报:"Realm [" com.cx.shiro.MyShiroRealm "] was unable to find account data for the " + "submitted AuthenticationToken [" + token + "]"。而且我在本地服务器测试账号密码都正确的情况下没出现这个问题,那接下来就是通过服务器的日志进行排查了。

    排查思路

    1. 查看服务器日志,只发现执行了一次查询,然后再无下文。退出登录出错日志倒是很多,但是这并不影响我们排查登录出错的接口。因为我把Redis服务器的相关缓存清空了。
    2. 在本地服务器重现这个错误;
    3. 从这个错误调试从后往前查看一遍,再从前往后排查;
    4. 定位导致出错的代码。

    具体解决

    1. 从错误中我们可以得知,该账号不存在。所以我在我本地测试的时候输入一个数据库中并没有的账号,果然重现了这个错误。
    2. 接着启动debug模式,来一步步进行调试:
      先看一波shiro验证涉及的主要类图:


      image.png
      image.png
      image.png

      再来一波方法调用图:绿色的是接口,蓝色的是类:


      image.png

    首先在登录操作的代码上打上断点:

       @RequestMapping(value = "/login.do",method = RequestMethod.POST)
       @ResponseBody
       @ApiOperation(value = "登录接口" ,notes = "根据用户账号密码登录" ,httpMethod = "POST")
        public ServerResponse Login(@Param("employeeId") String employeeId, @Param("password") String password) throws ParseException {
            Subject subject = SecurityUtils.getSubject();
            UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken(employeeId,password);
            usernamePasswordToken.setRememberMe(true);
            try{
                subject.login(usernamePasswordToken);  // 打断点的位置
            }catch(UnknownAccountException e){
               return ServerResponse.createByErrorMessage(e.getMessage());
            }catch (IncorrectCredentialsException e){
                return ServerResponse.createByErrorMessage(e.getMessage());
            }catch (LockedAccountException e){
                return ServerResponse.createByErrorMessage(e.getMessage());
            }catch (AuthenticationException e){
                return ServerResponse.createByErrorMessage("账户验证失败");
            }
    

    subject.login()实际调用的是DelefatingSubject中的login()方法:

    public void login(AuthenticationToken token) throws AuthenticationException {
            this.clearRunAsIdentitiesInternal();  // 如果session存在,则清除掉原有的session
            Subject subject = this.securityManager.login(this, token);//真正login的调用方法
            //以下代码省略
            ...
    

    接securityManaget.login()这个方法调用了DefaultSecurityManager.login(Subject subject, AuthenticationToken token)这个方法,接着往下看:

    public Subject login(Subject subject, AuthenticationToken token) throws AuthenticationException {
            AuthenticationInfo info;
            try {
                info = this.authenticate(token); //实际进行验证的方法
            } catch (AuthenticationException var7) {  // 抛出验证失败的Exception
                AuthenticationException ae = var7;
    
                try {
                    this.onFailedLogin(token, ae, subject);
                } catch (Exception var6) {
                    if (log.isInfoEnabled()) {
                        log.info("onFailedLogin method threw an exception.  Logging and propagating original AuthenticationException.", var6);
                    }
                }
    
                throw var7;
            }
    
            Subject loggedIn = this.createSubject(token, info, subject);
            this.onSuccessfulLogin(token, info, loggedIn);
            return loggedIn;
        }
    

    那我们下一步就是查看这个真正进行用户验证的方法:
    它在AuthenticatingSecurityManager.class中调用了 authenticate(AuthenticationToken token)方法

     public AuthenticationInfo authenticate(AuthenticationToken token) throws AuthenticationException {
            return this.authenticator.authenticate(token);
        }
    

    this.authenticator.authenticate(token)方法实际上是调用了 AbstractAuthenticator这个抽象类的authenticate方法。

    public final AuthenticationInfo authenticate(AuthenticationToken token) throws AuthenticationException {
            if (token == null) {
                throw new IllegalArgumentException("Method argumet (authentication token) cannot be null.");
            } else {
                log.trace("Authentication attempt received for token [{}]", token);
    
                AuthenticationInfo info;
                try {
                    info = this.doAuthenticate(token);//这里调用验证的方法
                    if (info == null) {//这里可以知道info == null
                        //这个就是我们要找的错误
                        String msg = "No account information found for authentication token [" + token + "] by this " + "Authenticator instance.  Please check that it is configured correctly.";
                        throw new AuthenticationException(msg);
                    }
                } catch (Throwable var8) {
                    AuthenticationException ae = null;
                    if (var8 instanceof AuthenticationException) {
                        ae = (AuthenticationException)var8;
                    }
    
                    if (ae == null) {
                        String msg = "Authentication failed for token submission [" + token + "].  Possible unexpected " + "error? (Typical or expected login exceptions should extend from AuthenticationException).";
                        ae = new AuthenticationException(msg, var8);
                    }
    
                    try {
                        this.notifyFailure(token, ae);
                    } catch (Throwable var7) {
                        if (log.isWarnEnabled()) {
                            String msg = "Unable to send notification for failed authentication attempt - listener error?.  Please check your AuthenticationListener implementation(s).  Logging sending exception and propagating original AuthenticationException instead...";
                            log.warn(msg, var7);
                        }
                    }
    
                    throw ae;
                }
    
                log.debug("Authentication successful for token [{}].  Returned account [{}]", token, info);
                this.notifySuccess(token, info);
                return info;
            }
        }
    

    this.doAuthenticate(token)调用的是ModularRealmAuthenticator.doAuthenticate(AuthenticationToken authenticationToken)方法

    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);
        }
    

    这里如果你配置了多个Realm就调用

    doMultiRealmAuthentication(realms, authenticationToken),
    

    如果配置了一个Realm就调用

    doSingleRealmAuthentication((Realm)realms.iterator().next(), authenticationToken)
    

    这里我只说配置一个Realm的情况,多个Realm的情况有很多种,下次另开一篇来具体分析。doSingleRealmAuthentication((Realm)realms.iterator().next(), authenticationToken)是在ModularRealmAuthenticator中调用的

    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 {
                //这个是验证方法,Realm是一个接口
                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;
                }
            }
        }
    

    接着调用AuthenticatingRealm中的getAuthenticationInfo()方法

    public final AuthenticationInfo getAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
            //先从缓存中尝试拿到info
            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);
            }
    

    AuthenticatingRealm中这是一个抽象方法,而我们自定义的Realm就是继承该方法的,并且重写了这个方法

     protected abstract AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken var1) throws AuthenticationException;
    

    最后我们看一下我们自定义的Realm里面重写的这个方法

     //认证
        @Override
        protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
            //拿到账号
            String employeeId = (String) authenticationToken.getPrincipal();
            //拿到密码
            String password = new String((char[]) authenticationToken.getCredentials());
            //使用MD5进行加密
            password = MD5Util.MD5Encode(password);
            LOGGER.error("password"+ password);
            //从数据库中拿到对应的员工的数据
            Employee employee = employeeService.selectEmployeeById(employeeId);
            //就是这个,错误的根源
            if((employee == null)||!employee.getPassword().equals(password)){
                return  null;
            }
            SimpleAuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo(employee,password,getName());
            //盐值
          authenticationInfo.setCredentialsSalt(ByteSource.Util.bytes(PropertiesUtil.getProperty("password.salt")));
            return authenticationInfo;
        }
    

    经过我们上面的分析,我们知道返回的AuthenticationInfo是null。再看看我们自定义的Realm中的doGetAuthenticationInfo()方法,我们可以知道:

    1. employee找不到,为null;
    2. employee的password跟MD5加密后的密码不同。
      我查看服务器日志,发现employee是存在的,不为null,那就只有通过MD5加密后的密码和数据库中的密码不一致的情况了。
      所以我把密码加密后的密码和数据库中的密码打印出来,发现真的不一样,而且很奇怪的就是盐值让我改了。什么?盐值让我改了?什么时候的事,我没有!


      image.png

    * 解决办法:把盐值改回来!把盐值改回来!把盐值改回来!给我气的啊!

    但是为什么会出现本地测试没问题,线上测试有问题呢!我觉得是:

    • 由于我设置session缓存的时间是一天,所以在这一天内,缓存的session不会消失。也就是我redis服务器在这一天内一直都有这个session,但是线上服务器的session被删了,没错,因为退出登录其实是成功的!但是返回出错,这个问题需要再解决一下。

    总结

    • 由于自己的手贱,花了好久的时间才解决的这个bug。
    • shiro的源码还是很容易懂的,建议新手可以读一读,有很多很好的设计。

    相关文章

      网友评论

          本文标题:从源码角度分析Shiro的验证过程

          本文链接:https://www.haomeiwen.com/subject/xqqolctx.html