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<String, String> 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<String, UserInfo> userInfoMap = new HashMap<String, UserInfo>();

    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<String> userRoles = new HashSet<String>();
        Set<String> userResources = new HashSet<String>();

        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<String> userResources = new HashSet<String>();
    /* 用户角色信息 */
    Set<String> userRoles = new HashSet<String>();
}

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. 在页面上加标签:
<shiro:hasPermission name="admin">
</shiro>

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

推荐阅读:

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 221,695评论 6 515
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 94,569评论 3 399
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 168,130评论 0 360
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 59,648评论 1 297
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 68,655评论 6 397
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 52,268评论 1 309
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,835评论 3 421
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,740评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 46,286评论 1 318
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,375评论 3 340
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,505评论 1 352
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 36,185评论 5 350
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,873评论 3 333
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,357评论 0 24
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,466评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,921评论 3 376
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,515评论 2 359

推荐阅读更多精彩内容