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

背景

  • 我们这个项目是前后端分离的架构。由于前端在一次退出登录时,存在同一用户多次登录的情况,导致退出登录失败!存在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 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的验证过程)