shiro(4)- Realm(认证授权)

shiro安全控制目录

Realm充当了Shiro框架应用的安全数据之间的连接器,也就是说,当我们与应用的安全数据(例如订单查询功能)进行交互时,需要进行登录(认证)和授权(访问控制)。Shiro会协调的调用Realms,通过Realm查询数据完成认证和授权。

1. Realm的认证授权的流程

SecurityManager是Shiro的核心,协调和管理其他组件,确保他们之间的配合。而实际上,SecurityManager中的组件高度模块化,即SecurityManager实际上并没做太多事情,而是定义了一个算法骨架(可以理解为模板方法模式),提供大量的钩子方法,供我们完成自定义逻辑的实现。

若是实现自定义的Realm,我们一般要继承org.apache.shiro.realm.AuthorizingRealm类,去重写下面两个钩子方法。

//Authentication `[噢繁体kei神]` 认证;  Authorization `[额死乱贼神]` 授权;
public class UserAuthorizingRealm extends AuthorizingRealm{
    //认证方法
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authcToken) throws AuthenticationException {
    }
    //授权方法
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
    }
}

1.1 认证的流程

认证包含三步骤:

  1. 收集用户的身份信息,称为当事人(principal),以及身份的支持说明,称为证书(Credential)。
  2. 将当事人和证书(密码)提交给系统。
  3. 如果提交的证书(密码)与系统期望的该用户的身份(当事人)匹配,该用户就是被认为经过认证的,反之,是没有经过认证的。

该过程如下列代码所示:

//1. 接受提交的当事人和证书:
AuthenticationToken token =
new UsernamePasswordToken(username, password);
//2. 获取当前 Subject:
Subject currentUser = SecurityUtils.getSubject();
//3. 登录: 
currentUser.login(token);

在调用login方法后,SecurityManager会收到AuthenticationToken,并将其发送给已配置的Realm,执行必须的认证检查。每个Realm都能在必要时对已提交的AuthenticationToken做出反应,Realm进而可以抛出异常。而用户通过对Shiro运行时AuthenticationException做出反应,可以控制失败。

控制失败的登录:

//3. 登录:
try {
    currentUser.login(token);
} catch (IncorrectCredentialsException ice) {
    …
} catch (LockedAccountException lae) {
    …
}
…
catch (AuthenticationException ae) {…
} 

可以选择捕获AuthenticationException的一个子类,做出特定的响应。

AuthenticationException的子类.png

1.2 授权的流程

授权实际上就是访问控制—控制用户能够访问应用中的哪些内容,比如资源,Web页面等等。多数的执行访问控制是通过使用角色权限来完成的。也就是说,通常用户允许或者不允许做的事情是根据他们的角色和是否含有权限来完成的。继而通过检查这些角色和权限,应用程序就可以控制哪些功能时可以暴露的。

Subject API可以让我们很容易的执行角色和权限检查:

如何检查Subject被分配了某角色:

if ( subject.hasRole(“administrator”) ) {
    // 显示‘Create User’按钮 
} else {
    // 按钮置灰?
} 

如何检查Subject被分配了某权限:

if ( subject.isPermitted(“user:create”) ) {
    // 显示‘Create User’按钮 
} else {
    // 按钮置灰?
} 

角色权限参见 Shiro Permission 文档,汉化版本shiro权限标识符的用法

就像认证那样,上述调用最终会转向SecurityManager,他会咨询Realm做出自己的访问控制决定。即调用UserAuthorizingRealm#doGetAuthorizationInfo()授权方法。

总结,权限控制的四种方式:

  1. 在shiro-config.xml中的shiroFilter中追加过滤器链: /user/delete = perms["delete"]
  2. subject.hasRole(“admin”) 或 subject.isPermitted(“admin”):代码中调用判断是否含有这个角色或权限时。
  3. @RequiresRoles("admin"):方法上加注解进行权限角色控制时。
  4. [@shiro.hasPermission name = "admin"][/@shiro.hasPermission]:在页面上加shiro标签,即加载页面便进行权限判断。

详见:
1. shiro框架的四种权限控制方式
2. 授权和认证方法调用的时机

2. 源码实现

小胖有话说:有一些小伙伴会感到很惊奇,为什么简单调用subject.login(token);方法就实现了用户认证?其内部是什么构造呢?那小胖用最简单的语言概况一下源码过程吧。

1. 前端小伙伴送上来用户登录的详细信息。

使用的Ajax上送的JSON串,并且为了创建对象,将JSON转换为Map对象。

    @RequestMapping(value = "user/login")
    @ResponseBody
    public ResponseVo login(@RequestBody Map loginInfo, HttpServletRequest request) {
 ResponseVo resultMap = new ResponseVo();
        //用户名
        String username = StringUtils.trim(loginInfo.get("username"));
        //用户密码
        String password = loginInfo.get("password");
        //验证码
        String checkCode = StringUtils.trim(loginInfo.get("checkCode"));
   }
      //参数校验,就是判断下是否为空,这一步前端会进行正则判断,
      //后台可以通过JSR303验证(对象),或者手动校验。验证失败直接返回错误码
      //TODO
      //取出服务器保存的验证码
        String checkCodeServer = jedisCluster.get("checkCodeServer:" + username);
      //验证码校验
      //TODO
      //构造token对象
      UsernamePasswordToken token=new UsernamePasswordToken(username,passwordMD5,false);
    try {
         if (loginService.login(token)) {
                resultMap.setRetcode(200);
                resultMap.setMessage("登录成功!");
            }
    }catch (UnknownAccountException ex) {
            // 用户名没有找到。
            resultMap.setRetcode(400);
            resultMap.setMessage("此用户未注册,请先注册!");
        } catch (LockedAccountException ex) {
            // 密码验证失败次数过多
            resultMap.setRetcode(400);
            resultMap.setMessage("密码验证失败5次,请十分钟后再登录!");
        } catch (IncorrectCredentialsException ex) {
            // 用户名密码不匹配。
            resultMap.setRetcode(400);
            resultMap.setMessage(ex.getMessage());
        } catch (ConcurrentAccessException e) {
            resultMap.setRetcode(500);
            resultMap.setMessage(e.getMessage());
        } catch (AuthenticationException e) {// 其他的登录错误
            resultMap.setRetcode(400);
            resultMap.setMessage("登录失败 原因:" + e.getMessage() + "!");
        } catch (Exception e) {
            e.printStackTrace();
        }
        return resultMap;
    }

UsernamePasswordToken类:
rememberMe这个参数的设置,推荐看下这篇文章。

public class UsernamePasswordToken implements HostAuthenticationToken, RememberMeAuthenticationToken {
    private String username;
    private char[] password;
    private boolean rememberMe = false;
    private String host;

2. 请求到达service层

    public boolean login(UsernamePasswordToken token) {
        // 获取shiro Subject对象
        Subject subject = SecurityUtils.getSubject();

        // 执行登录 调用【重点】
        subject.login(token);
        // 验证是否成功登录的方法
        if (subject.isAuthenticated()) {
            //登录成功,设置session。
            Session session = subject.getSession();
            subject.getSession().setAttribute(userName, token.getUsername());
            return true;
        }
        return false;
    }

此时,在session中包含了principal对象。我们可以通过session.getAttribute(DefaultSubjectContext.PRINCIPALS_SESSION_KEY)获取simpleAuthenticationInfo的第一个参数的值。

---(华丽分割线,此时Shiro框架会调用自定义的Realm方法,进行登录校验)

3. 自定义Realm类——doGetAuthenticationInfo认证方法

这个方法翻译过来:去获取认证信息。也就是info呗,那和token有什么关系呢?

//执行login(token)方法后,最终会执行该方法进行认证【小伙伴们可以重写该方法,实现自己的逻辑。】一般是去数据库中查询用户信息。将其存放于info中。注意的一点是:token里面的password会和info中的credentials(证书)进行比较。其实就是完成了密码之间的比较。

public class UserAuthorizingRealm extends AuthorizingRealm {

    private static final Logger logger = LoggerFactory.getLogger(UserAuthorizingRealm.class);
    
    //模拟数据库
    private static Map userInfoMap = new HashMap();

    static {
        UserInfo userInfo = new UserInfo();
        userInfo.setUserName("tom123");
        userInfo.setPassword("123456");
        userInfo.setStatus("1");  //未禁用
        userInfoMap.put("tom123", userInfo);
    }

    /**
     * 认证回调函数,登录时使用
     *
     * @param token
     * @return
     * @throws AuthenticationException
     */
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        //token转换
        UsernamePasswordToken userToken = (UsernamePasswordToken) token;
        String username = userToken.getUsername();
        String password = new String(userToken.getPassword());
        UserInfo userInfo = userInfoMap.get(username);
        //校验账户是否存在
        if (userInfo == null) {
            throw new UnknownAccountException();
        }
        //校验用户是否禁用
        if (!"1".equals(userInfo.getStatus())) {
            throw new DisabledAccountException("该用户已禁用!");
        }
        //校验用户密码失败次数
        int failureTimes = userInfo.getFailureTimes(); //失败次数
        //当前时间-上传密码错误时间
        long betweenTime = 0;
        if (userInfo.getLastLoginTime() != null) {
            betweenTime = (new Date().getTime() - userInfo.getLastLoginTime().getTime()) / 1000 / 60;
        }
        if (failureTimes == 5 && betweenTime <= 10) {
            throw new LockedAccountException();
        }
        //校验密码
        if (!password.equals(userInfo.getPassword())) {
            String errMsg = "";
            //若是10分钟之内的,连续错误,更新数据库错误次数和时间,返回用户错误信息
            if (betweenTime <= 10) {
                userInfo.setFailureTimes(failureTimes + 1);
                userInfo.setLastLoginTime(new Date());
                //更新到数据库
            } else {
                //若是10分钟之外失败的
                userInfo.setFailureTimes(1);
                userInfo.setLastLoginTime(new Date());
                //更新到数据库中
            }
            errMsg = "用户名/账户错误,还有" + (5 - userInfo.getFailureTimes()) + "次机会";
            //认证失败的异常
            throw new IncorrectCredentialsException(errMsg);
        }
        //登录成功
        userInfo.setFailureTimes(0);
        userInfo.setLastLoginTime(new Date());
        //更新到数据库中

        Session session = SecurityUtils.getSubject().getSession();
        
        //在认证方法中,获取到授权信息

        UserAuthorizingInfo userAuthorizingInfo = new UserAuthorizingInfo();
        userAuthorizingInfo.setUserName(username);
        userAuthorizingInfo.setPassword(password);

        //授权
        Set userRoles = new HashSet();
        Set userResources = new HashSet();

        userRoles.add("super_admin");

        userResources.add("admin:get");
        userResources.add("admin:post");

        /**
         * 需要注意的是:用户和角色(n:n的关系,即需要有一张中间表。)
         * 角色和资源(n:n的关系,也需要有一张中间表。)
         * 我们可以得到userId,那么便可以借助中间表,获取到所有的[角色]信息。
         * 需要注意的是,若角色是[超级管理员]则加载所有的[资源]信息。
         * 每一个[角色]信息需要借助中间表获取到[资源]信息加载到[userResources]对象中
         */
        if (userRoles.size() < 1) {
            throw new AuthenticationException("该用户没有可用的角色,请联系管理员!");
        }

        userAuthorizingInfo.setUserRoles(userRoles);
        userAuthorizingInfo.setUserResources(userResources);
        SimpleAuthenticationInfo info = new SimpleAuthenticationInfo(userAuthorizingInfo, userInfo.getPassword(), getName());
        return info;
    }
    

    /**
     * 授权方法,每调用一次权限控制方法,都要调用该方法
     *
     * @param principals
     * @return
     */
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        UserAuthorizingInfo userAuthorizingInfo = (UserAuthorizingInfo) getAvailablePrincipal(principals);
        if (userAuthorizingInfo == null) {
            return null;
        }
        //进行授权
        SimpleAuthorizationInfo simpleAuthorInfo = new SimpleAuthorizationInfo();
        simpleAuthorInfo.addRoles(userAuthorizingInfo.getUserRoles());
        simpleAuthorInfo.addStringPermissions(userAuthorizingInfo.getUserResources());
        return simpleAuthorInfo;
    }
}

public class UserAuthorizingInfo implements Serializable {
    private static final long serialVersionUID = 432747289087751042L;

    private String userName;
    private String password;
    /* 用户资源信息 */
    Set userResources = new HashSet();
    /* 用户角色信息 */
    Set userRoles = new HashSet();
}

public class UserInfo {
    private String userName;
    private String password;
    //是否禁用;0-禁用;1-未禁用
    private String status;
    //失败次数
    private int failureTimes;
    //上次登录时间(数据库类型timestamp)
    private Date lastLoginTime;
}

需要注意的几点就是:

  1. doGetAuthenticationInfo(token)方法进行认证管理。
  2. doGetAuthorizationInfo(principals)方法进行权限管理。
  3. 【方法调用时机】授权和认证方法调用的时机。(必看)
  4. 【info构造方法】SimpleAuthenticationInfo对象的构造方法,principal (普瑞贼爆)主要对象;credentials密码,和tokenpassword进行校验。realmNameprincipal放入CachingRealm缓存中的key
 public SimpleAuthenticationInfo(Object principal, Object credentials, String realmName) {
        this.principals = new SimplePrincipalCollection(principal, realmName);
        this.credentials = credentials;
    }
  1. 【权限查询时机】需要注意,授权信息在doGetAuthenticationInfo方法中存入principal对象。【因为只会在login(token)调用一次】
  2. 【数据库的设计】关于【用户表】【角色表】【权限(资源)表】的设计。我们知道用户和角色之间是n:n的关系,故需要一个中间表【用户-角色表】(形成两个1:n的关系)。而【角色】和【权限(资源)表】也是n:n的关系,所有也需要一个中间表【角色-权限表】。

4. 认证结束,返回controller异常信息

将认证信息处理结果保存在DelegatingSubject对象的authenticated属性【boolean】中。

   if (subject.isAuthenticated()) {
            //登录成功,设置session。
            Session session = subject.getSession();
            subject.getSession().setAttribute(userName, token.getUsername());
            return true;
        }

以上便是完成认证操作和权限控制了。

而我们可以通过以下三种方法判断用户是否含有权限:

  1. subject.hasRole(“admin”) 或 subject.isPermitted(“admin”):自己去调用这个是否有什么角色或者是否有什么权限的时候;
subject.isPermitted(“admin”);
  1. 在方法上加注解:
    @RequiresPermissions(value = {"query_list"})
    @RequestMapping("/queryList")
    public ModelAndView queryList(@RequestBody Query query) {
    }
  1. 在页面上加标签:


每次调用上面几种方式进行权限控制的时候,都会调用AuthorizingRealm##getAuthorizationInfo(principals)方法,进行验证。该方法是模板方法模式,每次都会调用我们自定义的钩子方法doGetAuthorizationInfo(principals);获取权限信息。

推荐阅读:

1. Shiro权限控制在注解中的使用;
2. Shiro权限控制在JSP的应用;

你可能感兴趣的:(shiro(4)- Realm(认证授权))