通过 debug 分析学习 Shiro 框架的登录认证流程

3,388 阅读5分钟

使用Shiro框架进行权限控制,在处理登录逻辑时我们一般是这样做的:

(1)在web.xml中使用filter指定shiro拦截哪些请求:

XHTML
     
        shiroFilter
        org.springframework.web.filter.DelegatingFilterProxy
        
            targetFilterLifecycle
            true
        
    
    
        shiroFilter
        *.html
        *.json
        *.jsp
        REQUEST
        FORWARD
    

(2)controller中使用Subject的login方法进行登录:

Java
     /**
     * 登录校验
     * 
     * @param username
     *            用户名
     * @param password
     *            密码
     * @param request
     * @param redirectAttributes
     * @return 重定向到主页
     */
    @RequestMapping("/user/user/check.html")
    public ModelAndView loginCheck(@RequestParam("username") String username, @RequestParam("password") String password,
            HttpServletRequest request, RedirectAttributes redirectAttributes) {
        String msg = ""; 
        
        Subject subject = SecurityUtils.getSubject();
        UsernamePasswordToken token = new UsernamePasswordToken(username, EncryptionUtil.sha256Hex(password));
        try {
            subject.login(token);
            
            UsrUser userBO = userManager.selectByName(username);
//          request.getSession().setAttribute("userBO", userBO); // 登录成功之后加入session中
            
            subject.getSession().setAttribute("USERNAME", userBO.getUsername());
            
            ModelAndView mAndView = new ModelAndView("redirect:/user/index.html");
            return mAndView;
        } catch (IncorrectCredentialsException e) {  
            msg = "登录密码错误. Password for account " + token.getPrincipal() + " was incorrect.";  
            System.out.println(msg);  
        } catch (ExcessiveAttemptsException e) {  
            msg = "登录失败次数过多";  
            System.out.println(msg);  
        } catch (LockedAccountException e) {  
            msg = "帐号已被锁定. The account for username " + token.getPrincipal() + " was locked.";  
            System.out.println(msg);  
        } catch (DisabledAccountException e) {  
            msg = "帐号已被禁用. The account for username " + token.getPrincipal() + " was disabled.";  
            System.out.println(msg);  
        } catch (ExpiredCredentialsException e) {  
            msg = "帐号已过期. the account for username " + token.getPrincipal() + "  was expired.";  
            System.out.println(msg);  
        } catch (UnknownAccountException e) {  
            msg = "帐号不存在. There is no user with username of " + token.getPrincipal();  
            System.out.println(msg);  
        } catch (UnauthorizedException e) {  
            msg = "您没有得到相应的授权!" + e.getMessage();  
            System.out.println(msg);  
        }  
        redirectAttributes.addFlashAttribute("error", "登录失败,请重新登录");
        return new ModelAndView("redirect:/user/user/login.html");
        
    }

(3)在自定义的Realm中通过doGetAuthenticationInfo方法将数据库中正确的账号、密码注入到Shiro管理:

Java
package cn.zifangsky.shiro;

import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

import javax.annotation.Resource;

import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.SimpleAuthenticationInfo;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;

import cn.zifangsky.manager.UsrUserManager;
import cn.zifangsky.model.UsrFunc;
import cn.zifangsky.model.UsrUser;
import cn.zifangsky.model.bo.UsrRoleBO;
import cn.zifangsky.model.bo.UsrUserBO;

public class CustomRealm extends AuthorizingRealm {

    @Resource(name = "usrUserManager")
    private UsrUserManager userManager;
    
    /**
     * 注入权限信息
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        SimpleAuthorizationInfo info = null;
        UsrUserBO userBO = (UsrUserBO) principalCollection.fromRealm(getName()).iterator().next();
        if(userBO != null){
            info = new SimpleAuthorizationInfo();
            List roleBOs = userBO.getUsrRoleBOs();
            if(roleBOs != null && roleBOs.size() > 0){
                List roleNames = new ArrayList<>();  //所有角色名
                Set funcCodes = new HashSet<>();  //所有权限的code集合
                
                for(UsrRoleBO roleBO : roleBOs){
                    roleNames.add(roleBO.getRolename());
                    
                    List funcs = roleBO.getFuncs();
                    if(funcs != null && funcs.size() > 0){
                        for(UsrFunc f : funcs){
                            funcCodes.add(f.getCode());
                        }
                    }
                }
                info.addRoles(roleNames);
                info.addStringPermissions(funcCodes);
            }
        }
        return info;
    }

    /**
     * 注入数据库中的认证信息
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        String username = (String) token.getPrincipal();  //获取用户名
        UsrUser userBO = userManager.selectByName(username);
        
        SimpleAuthenticationInfo info = null;
        if(userBO != null){
            info = new SimpleAuthenticationInfo(userBO, userBO.getPassword(), getName());
        }
        return info;
    }
    
}

从上面的代码可以看出,我们在controller中使用Subject的login方法将前台表单填写的账号密码传递到了shiro中,接着又在自定义的Realm中通过doGetAuthenticationInfo方法将数据库中正确的账号、密码注入到Shiro。那么在后面的流程中Shiro又是在哪里进行的密码校验工作呢?

下面,我将通过debug操作跟大家一起大致分析下:

在doGetAuthenticationInfo方法的某一行打上断点,开始单步调试

程序继续执行AuthenticatingRealm类的getAuthenticationInfo方法:

在这里,程序从568行开始继续执行,一直到578行调用当前类中的assertCredentialsMatch方法进行密码校验:

很显然,在controller中登录时,使用了UsernamePasswordToken也就是AuthenticationToken的子类来接收登录参数,同时在CustomRealm中的doGetAuthenticationInfo方法中返回值是AuthenticationInfo的子类——SimpleAuthenticationInfo

因此,现在我们就可以得知: 在这里的assertCredentialsMatch方法校验密码时,token参数包含了登录表单传递进来的待认证的信息,而info参数则是数据库中正确的账号信息

接着,在下面的代码中通过getCredentialsMatcher方法获取了一个CredentialsMatcher对象,然后再调用doCredentialsMatch方法来校验token和info里面的密码字段是否一样,如果一样则不做其他额外操作,不一样时则抛出异常。当然,我们可以在controller中捕捉到这个密码校验失败的异常

那么问题来了,在这里getCredentialsMatcher方法返回的那个CredentialsMatcher到底是从哪里传递进来的呢?

可以发现,当前类中的getCredentialsMatcher方法返回的是一个名为“credentialsMatcher”的属性,接着通过观察源码中的构造方法可以发现shiro给credentialsMatcher属性默认传递是一个 SimpleCredentialsMatcher对象

最后,我们再通过源码看看SimpleCredentialsMatcher这个类里面的doCredentialsMatch方法到底是如何来进行密码比较的:

可以看出,最终还是调用的SimpleCredentialsMatcher这个类里面的equals方法比较的两个密码字段。当然,在这个equals方法里面由于考虑的情况比较多,因此最终的判断逻辑也是比较复杂的

总结:

从上面的一系列的流程可以看出,整个登录流程中的密码校验逻辑其核心是: 在SimpleCredentialsMatcher这个类的doCredentialsMatch方法中分别将待认证的密码以及数据库中的正确密码分别从token和info中取出,再判断二者是否一样,如果不一样就返回false,最后在AuthenticatingRealm这个类的assertCredentialsMatch方法中抛出IncorrectCredentialsException异常

因此,在知道了完整的密码判断逻辑之后,同时我们又知道待认证的密码和数据库中的密码都是简单的字符串。那么我们完全可以在自定义Realm中复写父类AuthenticatingRealm的assertCredentialsMatch方法,直接获取token和info中的两个密码并比较,如果二者不一样同样抛出IncorrectCredentialsException异常即可

具体的代码如下:

Java
package cn.zifangsky.shiro;

import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

import javax.annotation.Resource;

import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.IncorrectCredentialsException;
import org.apache.shiro.authc.SimpleAuthenticationInfo;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;

import cn.zifangsky.manager.UsrUserManager;
import cn.zifangsky.model.UsrFunc;
import cn.zifangsky.model.UsrUser;
import cn.zifangsky.model.bo.UsrRoleBO;
import cn.zifangsky.model.bo.UsrUserBO;

public class CustomRealm extends AuthorizingRealm {

    @Resource(name = "usrUserManager")
    private UsrUserManager userManager;
    
    /**
     * 注入权限信息
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        SimpleAuthorizationInfo info = null;
        UsrUserBO userBO = (UsrUserBO) principalCollection.fromRealm(getName()).iterator().next();
        if(userBO != null){
            info = new SimpleAuthorizationInfo();
            List roleBOs = userBO.getUsrRoleBOs();
            if(roleBOs != null && roleBOs.size() > 0){
                List roleNames = new ArrayList<>();  //所有角色名
                Set funcCodes = new HashSet<>();  //所有权限的code集合
                
                for(UsrRoleBO roleBO : roleBOs){
                    roleNames.add(roleBO.getRolename());
                    
                    List funcs = roleBO.getFuncs();
                    if(funcs != null && funcs.size() > 0){
                        for(UsrFunc f : funcs){
                            funcCodes.add(f.getCode());
                        }
                    }
                }
                info.addRoles(roleNames);
                info.addStringPermissions(funcCodes);
            }
        }
        return info;
    }

    /**
     * 注入数据库中的认证信息
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        String username = (String) token.getPrincipal();  //获取用户名
        UsrUser userBO = userManager.selectByName(username);
        
        SimpleAuthenticationInfo info = null;
        if(userBO != null){
            info = new SimpleAuthenticationInfo(userBO, userBO.getPassword(), getName());
        }
        return info;
    }

    /**
     * 实际做密码校验的逻辑
     */
    @Override
    protected void assertCredentialsMatch(AuthenticationToken token, AuthenticationInfo info)
            throws AuthenticationException {
        //表单中的密码
        String tokenCredentials = String.valueOf((char[])token.getCredentials());
        //数据库中的密码
        String accountCredentials = (String)info.getCredentials();
        
        //认证失败,抛出密码密码不正确的异常
        if(!accountCredentials.equals(tokenCredentials)){
            String msg = "Submitted credentials for token [" + token + "] did not match the expected credentials.";
            throw new IncorrectCredentialsException(msg);
        }
    }
    
}

注:至于为什么从token中获取密码和从info中获取密码的方式不一样,大家在debug模式下自行观察token和info中的密码存放形式就明白了

至此,我的整个分析过程就结束了。感兴趣的同学可以自行使用debug模式观察下整个登录流程,用于加深映像!