spring boot实战之shiro

有很长一段时间都觉得自己添加个filter,基于RBAC模型,就能很轻松的实现权限控制,没必要引入shiro,spring-security这样的框架增加系统的复杂度。事实上也的确这样,如果你的需求仅仅是控制用户能否访问某个url,使用框架和自己实现filter效果基本一致,区别在于使用shiro和spring-security能够提供更多的扩展,集成了很多实用的功能,整体结构更加规范。
shiro和spring-security有哪些更多功能,这里不再展开,感兴趣的同学可以自行百度,我们这里以shiro为例,讲述spring-boot项目如何整合shiro实现权限控制。

1、添加maven依赖

<!--shiro-core -->
<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-core</artifactId>
    <version>1.3.2</version>
</dependency>
<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-spring</artifactId>
    <version>1.3.2</version>
</dependency>
<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-web</artifactId>
    <version>1.3.2</version>
</dependency>

<!-- 整合ehcache,减少数据库查询次数 -->
<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-ehcache</artifactId>
    <version>1.3.2</version>
</dependency>

2、添加shiro配置

创建ShiroConfigration.java

@Configuration
public class ShiroConfigration {
    private static final Logger logger = LoggerFactory.getLogger(ShiroConfigration.class);

    private static Map<String, String> filterChainDefinitionMap = new LinkedHashMap<String, String>();


    @Bean
    public SimpleCookie rememberMeCookie() {
        SimpleCookie simpleCookie = new SimpleCookie("rememberMe");
        simpleCookie.setMaxAge(7 * 24 * 60 * 60);//保存10天
        return simpleCookie;
    }

    /**
     * cookie管理对象;
     */
    @Bean
    public CookieRememberMeManager rememberMeManager() {
        logger.debug("ShiroConfiguration.rememberMeManager()");
        CookieRememberMeManager cookieRememberMeManager = new CookieRememberMeManager();
        cookieRememberMeManager.setCookie(rememberMeCookie());
        cookieRememberMeManager.setCipherKey(Base64.decode("kPv59vyqzj00x11LXJZTjJ2UHW48jzHN"));
        return cookieRememberMeManager;
    }


    @Bean(name = "lifecycleBeanPostProcessor")
    public LifecycleBeanPostProcessor getLifecycleBeanPostProcessor() {
        return new LifecycleBeanPostProcessor();
    }


    @Bean
    public FilterRegistrationBean filterRegistrationBean() {
        FilterRegistrationBean filterRegistration = new FilterRegistrationBean();
        DelegatingFilterProxy proxy = new DelegatingFilterProxy("shiroFilter");
        //  该值缺省为false,表示生命周期由SpringApplicationContext管理,设置为true则表示由ServletContainer管理
        proxy.setTargetFilterLifecycle(true);
        filterRegistration.setFilter(proxy);

        filterRegistration.setEnabled(true);
        //filterRegistration.addUrlPatterns("/*");// 可以自己灵活的定义很多,避免一些根本不需要被Shiro处理的请求被包含进来
        return filterRegistration;
    }

    @Bean
    public MyShiroRealm myShiroRealm() {
        MyShiroRealm myShiroRealm = new MyShiroRealm();
        return myShiroRealm;
    }
    
    @Bean(name="securityManager")  
    public DefaultWebSecurityManager securityManager() {  
        DefaultWebSecurityManager manager = new DefaultWebSecurityManager();  
        manager.setRealm(myShiroRealm()); 
        manager.setRememberMeManager(rememberMeManager());
        manager.setCacheManager(ehCacheManager());  
        return manager;  
    }  
    

    /**
     * ShiroFilterFactoryBean 处理拦截资源文件问题。
     * 注意:单独一个ShiroFilterFactoryBean配置是或报错的,以为在
     * 初始化ShiroFilterFactoryBean的时候需要注入:SecurityManager
     * <p>
     * Filter Chain定义说明
     * 1、一个URL可以配置多个Filter,使用逗号分隔
     * 2、当设置多个过滤器时,全部验证通过,才视为通过
     * 3、部分过滤器可指定参数,如perms,roles
     */
    @Bean(name = "shiroFilter")
    public ShiroFilterFactoryBean getShiroFilterFactoryBean() {
        logger.debug("ShiroConfigration.getShiroFilterFactoryBean()");
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
        // 必须设置 SecurityManager
        shiroFilterFactoryBean.setSecurityManager(securityManager());
        
        HashMap<String, javax.servlet.Filter> loginFilter = new HashMap<>();
        loginFilter.put("loginFilter", new LoginFilter());
        shiroFilterFactoryBean.setFilters(loginFilter);


        filterChainDefinitionMap.put("/login/submit", "anon");
        filterChainDefinitionMap.put("/logout", "anon");
        filterChainDefinitionMap.put("/img/**", "anon");
        filterChainDefinitionMap.put("/js/**", "anon");
        filterChainDefinitionMap.put("/css/**", "anon");
        filterChainDefinitionMap.put("/test/**", "anon");
        
        // 如果不设置默认会自动寻找Web工程根目录下的"/login.jsp"页面
        shiroFilterFactoryBean.setLoginUrl("/login");

        //配置记住我或认证通过可以访问的地址
        filterChainDefinitionMap.put("/", "user");
        //未授权界面;
        shiroFilterFactoryBean.setUnauthorizedUrl("/unauth");
        filterChainDefinitionMap.put("/**", "loginFilter");
        shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
        return shiroFilterFactoryBean;
    }   

    /**
     * shiro缓存管理器;
     * 需要注入对应的其它的实体类中:
     * 1、安全管理器:securityManager
     * 可见securityManager是整个shiro的核心;
     *
     * @return
     */
    @Bean
    public EhCacheManager ehCacheManager() {
        EhCacheManager cacheManager = new EhCacheManager();
        cacheManager.setCacheManagerConfigFile("classpath:ehcache-shiro.xml");
        return cacheManager;
    }

    @Bean
    public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(DefaultWebSecurityManager securityManager) {
        AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
        authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
        return authorizationAttributeSourceAdvisor;
    }

}

shiroFilter是配置的重点,

  • anon表示允许匿名访问
  • shiroFilterFactoryBean.setFilters(loginFilter)来设置自定义的过滤器,如本处设置了LoginFilter用于添加登录拦截
  • filterChainDefinitionMap.put("/**", "loginFilter");用于指定loginFilter的作用范围

3、添加自定义realm

创建类MyShiroRealm.java

public class MyShiroRealm extends AuthorizingRealm {
    private static final Logger logger = LoggerFactory.getLogger(MyShiroRealm.class);

    @Autowired
    private UserService userService;
    
    @Autowired
    private UserRoleService userRoleService;
    
    @Autowired
    private RoleService roleService;
    
    @Autowired
    private RolePermissionService rolePermissionService;

    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {


        //获取用户的输入的账号.
        String idObj = (String) token.getPrincipal();
        Integer id = NumberUtils.toInt(idObj);
        User user = userService.findById(id);

        if (user == null) {
            // 返回null的话,就会导致任何用户访问被拦截的请求时,都会自动跳转到unauthorizedUrl指定的地址
            return null;
        }

        SimpleAuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo(user.getId(),
                user.getPwd(), getName());

        return authenticationInfo;

    }

    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
            /*
         * 当没有使用缓存的时候,不断刷新页面的话,这个代码会不断执行,
         * 当其实没有必要每次都重新设置权限信息,所以我们需要放到缓存中进行管理;
         * 当放到缓存中时,这样的话,doGetAuthorizationInfo就只会执行一次了,
         * 缓存过期之后会再次执行。
         */
        logger.debug("权限配置-->MyShiroRealm.doGetAuthorizationInfo()");

        SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
        authorizationInfo.addRole("ACTUATOR");

        Integer userId = Integer.parseInt(principals.getPrimaryPrincipal().toString());
        //实际项目中,这里可以根据实际情况做缓存,如果不做,Shiro自己也是有时间间隔机制,2分钟内不会重复执行该方法

        Set<Integer> roleIds = userRoleService.findRoleIds(userId);
        Set<Role> roles = roleService.findByIds(roleIds);
        for(Role role : roles){
            authorizationInfo.addRole(role.getCode());
        }

        //设置权限信息.
        List<Permission> permissions = rolePermissionService.getPermissions(roleIds);
        Set<String> set = new HashSet<String>(permissions.size()*2);
        for(Permission permission : permissions){
            if(StringUtils.isNotBlank(permission.getCode())){
                set.add(permission.getCode());
            }
        }
        authorizationInfo.setStringPermissions(set);
        return authorizationInfo;
    }

}
  • doGetAuthenticationInfo用于验证用户账号信息,可根据具体业务来调整认证策略
  • doGetAuthorizationInfo用于获取用户拥有的角色和权限

4、创建登录拦截器

public class LoginFilter implements Filter {

    @Override
    public void destroy() {}

    @Override
    public void doFilter(ServletRequest request, ServletResponse response,FilterChain chain) throws IOException, ServletException {
        Subject currentUser = SecurityUtils.getSubject();
        if (!currentUser.isAuthenticated()) {
            HttpServletRequest req = (HttpServletRequest) request;
            HttpServletResponse res = (HttpServletResponse) response;
            AjaxResponseWriter.write(req, res, ServiceStatusEnum.UNLOGIN, "请登录");
            return;
        }
        chain.doFilter(request, response);
    }

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {}

}

public class AjaxResponseWriter {

    /**
     * 写回数据到前端
     * @param request
     * @param response
     * @param status {@link ServiceStatusEnum} 
     * @param message 返回的描述信息
     * @throws IOException
     */
    public static void write(HttpServletRequest request,HttpServletResponse response,ServiceStatusEnum status,String message) throws IOException{
        String contentType = "application/json";
        response.setContentType(contentType);
        response.setCharacterEncoding("UTF-8");
        response.setHeader("Access-Control-Allow-Credentials", "true");
        response.setHeader("Access-Control-Allow-Origin",request.getHeader("Origin"));
        
        Map<String, String> map = Maps.newLinkedHashMap();
        map.put("code", status.code);
        map.put("msg", message);
        String result = JacksonHelper.toJson(map);
        PrintWriter out = response.getWriter();
        try{
            out.print(result);
            out.flush();
        } finally {
            out.close();
        }
    }
}

/**
 * 全局性状态码
 * @author yangwk
 */
public enum ServiceStatusEnum {
    UNLOGIN("0001"), //未登录
    ILLEGAL_TOKEN("0002"),//非法的token
    ;
    public String code;
    
    private ServiceStatusEnum(String code){
        this.code = code;
    }
}
  • 用户登录状态拦截器,不允许匿名访问的url会经过该filter,如果未登录,则返回未登录提示(未登录处理可根据具体业务进行调整)

5、添加登录、退出功能

@Api(value="用户登录",tags={"用户登录"})
@RestController
public class LoginController {
    private static Logger logger = LoggerFactory.getLogger(LoginController.class);

    @Value("${server.session.timeout}")
    private String serverSessionTimeout;

    /**
     * 用户登录接口 通过用户名和密码进行登录
     */
    @ApiOperation(value = "用户登录接口 通过用户名和密码进行登录", notes = "用户登录接口 通过用户名和密码进行登录")
    @ApiImplicitParams({
            @ApiImplicitParam(paramType = "query", name = "username", value = "用户名", required = true, dataType = "String"),
            @ApiImplicitParam(paramType = "query", name = "pwd", value = "密码", required = true, dataType = "String"),
            @ApiImplicitParam(paramType = "query", name = "autoLogin", value = "自动登录", required = true, dataType = "boolean")})
    @RequestMapping(value = "/login/submit",method={RequestMethod.GET,RequestMethod.POST})
    public Map<String, String> subm(HttpServletRequest request,HttpServletResponse response,
            String username,String pwd,@RequestParam(value = "autoLogin", defaultValue = "false") boolean autoLogin) {
        Map<String, String> map = Maps.newLinkedHashMap();
        Subject currentUser = SecurityUtils.getSubject();
        User user = userService.findByUsername(username);
        if (user == null) {
            map.put("code", "-1");
            map.put("description", "账号不存在");
            return map;
        }
        if (user.getEnable() == 0) { //账号被禁用
            map.put("code", "-1");
            map.put("description", "账号已被禁用");
            return map;
        }

        String salt = user.getSalt();
        UsernamePasswordToken token = null;
        Integer userId = user.getId();
        token = new UsernamePasswordToken(userId.toString(),SaltMD5Util.encode(pwd, salt));
        token.setRememberMe(autoLogin);

        loginValid(map, currentUser, token);

        // 验证是否登录成功
        if (currentUser.isAuthenticated()) {
            map.put("code","1");
            map.put("description", "ok");
            map.put("id", String.valueOf(userId));
            map.put("username", user.getUsername());
            map.put("name", user.getName());
            map.put("compnay_id", String.valueOf(user.getCompanyId()));
            String uuidToken = UUID.randomUUID().toString();
            map.put("token", uuidToken);
            
            currentUser.getSession().setTimeout(NumberUtils.toLong(serverSessionTimeout, 1800)*1000);
            request.getSession().setAttribute("token",uuidToken );
        } else {
            map.put("code", "-1");
            token.clear();
        }
        return map;
    }
    
    @RequestMapping(value="logout",method=RequestMethod.GET)
        public Map<String, String> logout() {
            Map<String, String> map = Maps.newLinkedHashMap();
            Subject currentUser = SecurityUtils.getSubject();
            currentUser.logout();
            map.put("code", "logout");
            return map;
        }
    
    @RequestMapping(value="unauth",method=RequestMethod.GET)
        public Map<String, String> unauth() {
            Map<String, String> map = Maps.newLinkedHashMap();
            map.put("code", "403");
            map.put("msg", "你没有访问权限");
            return map;
        }

    private boolean loginValid(Map<String, String> map,Subject currentUser, UsernamePasswordToken token) {
        String username = null;
        if (token != null) {
            username = (String) token.getPrincipal();
        }

        try {
            // 在调用了login方法后,SecurityManager会收到AuthenticationToken,并将其发送给已配置的Realm执行必须的认证检查
            // 每个Realm都能在必要时对提交的AuthenticationTokens作出反应
            // 所以这一步在调用login(token)方法时,它会走到MyRealm.doGetAuthenticationInfo()方法中,具体验证方式详见此方法
            currentUser.login(token);
            return true;
        } catch (UnknownAccountException | IncorrectCredentialsException ex) {
            map.put("description", "账号或密码错误");
        } catch (LockedAccountException lae) {
            map.put("description","账户已锁定");
        } catch (ExcessiveAttemptsException eae) {
            map.put("description", "错误次数过多");
        } catch (AuthenticationException ae) {
            // 通过处理Shiro的运行时AuthenticationException就可以控制用户登录失败或密码错误时的情景
            map.put("description", "登录失败");
            logger.warn(String.format("对用户[%s]进行登录验证..验证未通过", username),ae);
        }
        return false;
    }
    
    @Autowired
    private UserService userService;
}
  • 以上代码是比较通用的登录、退出功能,如果没有特殊需求,可直接使用上述功能

6、在接口上添加权限限制

以UserController为例:

@ApiOperation(value="获取用户详细信息", notes="根据ID查找用户")
@ApiImplicitParam(paramType="query",name = "id", value = "用户ID", required = true,dataType="int")
@RequiresPermissions(value={"user:get"}) 
@RequestMapping(value="/get",method=RequestMethod.GET)
public User get(int id){
    User entity = userService.findById(id);
    entity.setPwd(null);
    entity.setSalt(null);
    return entity;
}

@ApiOperation(value="修改密码", notes="修改密码")
@ApiImplicitParams({
    @ApiImplicitParam(paramType = "query", name = "oldPwd", value = "旧密码", required = true, dataType = "String"),
    @ApiImplicitParam(paramType = "query", name = "pwd", value = "新密码", required = true, dataType = "String"),
    @ApiImplicitParam(paramType = "query", name = "confirmPwd", value = "新密码(确认)", required = true, dataType = "String")})
@RequiresPermissions(value={"user:reset-pwd"})
@RequestMapping(value="/reset-pwd",method=RequestMethod.POST)
public Return resetPwd(String oldPwd,String pwd,String confirmPwd){
    if(StringUtils.isBlank(oldPwd) || StringUtils.isBlank(pwd)
            || StringUtils.isBlank(confirmPwd) || !pwd.equals(confirmPwd)) {
        return Return.fail("非法参数");
    }
    
    Subject currentUser = SecurityUtils.getSubject();
    Integer userId=(Integer) currentUser.getPrincipal();
    User entity = userService.findById(userId);
    if(!entity.getPwd().equals(SaltMD5Util.encode(oldPwd, entity.getSalt()))){
        return Return.fail("原始密码错误");
    }
    return userService.changePwd(entity,pwd);
}
  • @RequiresPermissions 和 @RequiresRoles分别用于限制该方法可访问的权限和角色,两者如果同时使用,默认是“&”关系;两者的value参数都可以设置为数组,数组元素间的关系可以通过logical属性来设置,有Logical.AND,Logical.OR两个值可选择

小结

spring-boot整合shiro的步骤如下:

  1. 添加maven依赖
  2. 添加ShiroConfigration配置,指定shiro的核心配置
  3. 添加MyShiroRealm,指定账户认证策略和角色权限获取方式
  4. 添加LoginFilter,即登录拦截器
  5. 添加登录、退出功能
  6. 通过注解添加接口调用权限限制

权限控制基于RBAC模型,涉及的表有:用户(user)、角色(role)、用户角色关系(user_role)、权限(permission)、角色权限关系(role_permission),具体代码可参考github内的示例项目。

本人搭建好的spring boot web后端开发框架已上传至GitHub,欢迎吐槽!
https://github.com/q7322068/rest-base,已用于多个正式项目,当前可能因为版本问题不是很完善,后续持续优化,希望你能有所收获!

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

推荐阅读更多精彩内容