从零开始 Spring Boot 9:Shiro

从零开始 Spring Boot 9:Shiro

spring boot

图源:简书 (jianshu.com)

Shiro是一个权限管理组件,可以用它来实现Web应用的权限控制,本篇将介绍如何在Spring Boot的Web项目中使用Shiro实现权限控制。

准备工作

在使用Shiro前,需要先构建一个示例需要的基本Web应用:

模块的划分可以参考:

  • book
    • book
  • user
    • user
    • user_role
    • role
    • role_permission
    • permission

自动创建的实体类最好手动添加上@TableId注解,否则某些数据库查询可能获取不到结果:

@Data
@EqualsAndHashCode(callSuper = false)
@TableName("book")
public class Book implements Serializable {

    private static final long serialVersionUID = 1L;

    @TableId(value = "id",type = IdType.AUTO)
    private Integer id;
    private String name;
    private String description;
    private Integer userId;
}

添加依赖

        <!-- Shiro整合Spring -->
        <dependency>
            <groupId>org.apache.shiro</groupId>
            <artifactId>shiro-spring</artifactId>
            <version>1.5.3</version>
        </dependency>

有多种shiro相关的starter可以添加,这里仅列举一种。

实现相关Service

权限管理中需要用到根据用户名查询用户信息,我们的这里的权限组织是一个用户包含多个身份,一个身份包含多个权限,所以需要实现最基本的用户信息查询相关的Service,这个不难,所以不一一列举,可以查看我的源码:learn_spring_boot (github.com)

配置Shiro

Realm

要让Shiro能够正常的鉴权和赋权,就需要实现一个Realm,具体可以继承AuthorizingRealm并实现两个抽象方法:

package cn.icexmoon.demo.books.system.shiro;

import cn.icexmoon.demo.books.user.entity.Permission;
import cn.icexmoon.demo.books.user.entity.Role;
import cn.icexmoon.demo.books.user.entity.User;
import cn.icexmoon.demo.books.user.service.IUserService;
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 org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.ObjectUtils;

public class CustomRealm extends AuthorizingRealm {
    @Autowired
    private IUserService userService;
    /**
     * 授权
     * @param principalCollection
     * @return
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {

        //获取登录用户名
        String name = (String) principalCollection.getPrimaryPrincipal();
        //查询用户名称
        User user = userService.getUserByName(name);
        //添加角色和权限
        SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
        for (Role role : user.getRoles()) {
            //添加角色
            simpleAuthorizationInfo.addRole(role.getName());
            //添加权限
            for (Permission permission : role.getPermissions()) {
                simpleAuthorizationInfo.addStringPermission(permission.getName());
            }
        }
        return simpleAuthorizationInfo;
    }

    /**
     * 身份认证
     * @param authenticationToken
     * @return
     * @throws AuthenticationException
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
        if (ObjectUtils.isEmpty(authenticationToken.getPrincipal())) {
            return null;
        }
        //获取用户信息
        String name = authenticationToken.getPrincipal().toString();
        User user = userService.getUserByName(name);
        if (user == null) {
            //这里返回后会报出对应异常
            return null;
        } else {
            //这里验证authenticationToken和simpleAuthenticationInfo的信息
            SimpleAuthenticationInfo simpleAuthenticationInfo = new SimpleAuthenticationInfo(name, user.getPassword(), getName());
            return simpleAuthenticationInfo;
        }
    }
}

doGetAuthenticationInfo方法用于登录时检查用户密码是否正确,既进行身份验证。doGetAuthorizationInfo方法用于给已登录的用户添加相关的角色和权限,及赋权。有了这个权限和角色关联后,就可以在Controller中使用Shiro的相关注解来进行权限控制。

这里主要工作是要在doGetAuthorizationInfo中根据我们的数据库和Service来添加权限和角色。

SessionManager

因为这里的示例应用是一个纯后台的应用,通过Restfull接口与客户端通信,也就是所谓的前后分离的系统,没有页面。而默认情况下Shiro是通过Cookie来存储和传递客户端令牌的,所以我们需要为Shiro添加一个自定义的SessionManager来通过HTTP请求的特定报文头来传递令牌。

package cn.icexmoon.demo.books.system.shiro;

import org.apache.shiro.web.servlet.ShiroHttpServletRequest;
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
import org.apache.shiro.web.util.WebUtils;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;

import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import java.io.Serializable;

public class CustomSessionManager extends DefaultWebSessionManager {

    private static final String HEADER_TOKEN = "token";

    private static final String REFERENCED_SESSION_ID_SOURCE = "Stateless request";

    public CustomSessionManager() {
        super();
    }

    @Override
    protected Serializable getSessionId(ServletRequest request, ServletResponse response) {
        String id = WebUtils.toHttp(request).getHeader(HEADER_TOKEN);
        if (!ObjectUtils.isEmpty(id)) {
            request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE, REFERENCED_SESSION_ID_SOURCE);
            request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID, id);
            request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_IS_VALID, Boolean.TRUE);
            return id;
        } else {
            return super.getSessionId(request, response);
        }
    }
}

getSessionId方法中尝试从指定报文头(token)获取令牌,如果获取到了,就写入ServletRequest的相应属性,Shiro就可以正常获取并进行后续处理。

网上也有一些做法是通过自己实现令牌,并替换Shiro默认的令牌实现的前后端分离的令牌分发和传递机制,相比之下通过SessionManager这种方式更为简单。

ShiroConfig

最后就是添加Shiro配置,以将我们设置好的RealmSessionManager添加到Shiro中:

package cn.icexmoon.demo.books.system.shiro;

import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.session.mgt.SessionManager;
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.HashMap;
import java.util.Map;

@Configuration
public class ShiroConfig {
    @Bean
    @ConditionalOnMissingBean
    public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
        DefaultAdvisorAutoProxyCreator defaultAAP = new DefaultAdvisorAutoProxyCreator();
        defaultAAP.setProxyTargetClass(true);
        return defaultAAP;
    }

    //权限管理,配置主要是Realm的管理认证
    @Bean
    public SecurityManager securityManager() {
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
        securityManager.setRealm(customRealm());
        securityManager.setSessionManager(sessionManager());
        return securityManager;
    }

    @Bean(name = "customRealm")
    public CustomRealm customRealm() {
        return new CustomRealm();
    }

    @Bean(name = "sessionManager")
    public SessionManager sessionManager() {
        return new CustomSessionManager();
    }

    //Filter工厂,设置对应的过滤条件和跳转条件
    @Bean
    public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) {
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
        shiroFilterFactoryBean.setSecurityManager(securityManager);
        Map<String, String> map = new HashMap<>();
        //登出
        map.put("/logout", "logout");
        //对所有用户认证
        map.put("/**", "authc");
        //登录
        shiroFilterFactoryBean.setLoginUrl("/login");
        //首页
        shiroFilterFactoryBean.setSuccessUrl("/index");
        //错误页面,认证不通过跳转
        shiroFilterFactoryBean.setUnauthorizedUrl("/error");
        shiroFilterFactoryBean.setFilterChainDefinitionMap(map);
        return shiroFilterFactoryBean;
    }

    //注入权限管理
    @Bean
    public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
        AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
        authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
        return authorizationAttributeSourceAdvisor;
    }
}

除了在securityManager中添加RealmSessionManager以外,还可以在shiroFilterFactoryBean方法中定义一系列路径权限和特殊路径,比如登录页、错误页等等,不过这些在前后端分离系统中似乎不是那么必要。

ExceptionHandler

使用Shiro后,如果Controller权限检查失败,就会抛出一个ShiroException异常,所以要让这个异常以客户端能看懂的方式返回,就需要添加一个异常处理器:

package cn.icexmoon.demo.books.system;

import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.ShiroException;
import org.apache.shiro.authc.IncorrectCredentialsException;
import org.apache.shiro.authc.LockedAccountException;
import org.apache.shiro.authc.UnknownAccountException;
import org.apache.shiro.authz.AuthorizationException;
import org.apache.shiro.authz.UnauthorizedException;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandle {
    @ExceptionHandler(ShiroException.class)
    public String doHandleShiroException(ShiroException se, Model model) {
        se.printStackTrace();
        Result result = new Result();
        result.setSuccess(false);
        if (se instanceof UnknownAccountException) {
            result.setMsg("该账户不存在");
        } else if (se instanceof LockedAccountException) {
            result.setMsg("该账户已锁定");
        } else if (se instanceof IncorrectCredentialsException) {
            result.setMsg("密码错误请重试");
        } else if (se instanceof UnauthorizedException) {
            result.setMsg("当前角色不能操作");
        } else if (se instanceof AuthorizationException) {
            result.setMsg("没有相应权限");
        } else {
            result.setMsg("操作失败请重试");
        }
        return result.toString();
    }
}

这里的Result是一个用于返回标准格式的工具类,这里不做赘述。

Controller

忙了半天后,我们终于可以使用Shiro了。

首先是添加一个登录用的HTTP处理器:

package cn.icexmoon.demo.books.user.controller;

import cn.icexmoon.demo.books.system.Login;
import cn.icexmoon.demo.books.system.Result;
import cn.icexmoon.demo.books.user.entity.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/login")
public class LoginController {
    @Autowired
    private Login login;

    @PostMapping("")
    public String login(@RequestBody User user) {
        Result result = login.checkAndLogin(user.getName(), user.getPassword());
        return result.toString();
    }
}

因为在Shiro配置中设置了shiroFilterFactoryBean.setLoginUrl("/login"),所以你不需要担心/login会被因为没有权限而阻拦。

这里的Login是我编写的一个使用Shiro进行验证并登录的Component

package cn.icexmoon.demo.books.system;

import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.UnknownAccountException;
import org.apache.shiro.authc.UsernamePasswordToken;
import org.apache.shiro.authz.AuthorizationException;
import org.apache.shiro.subject.Subject;
import org.springframework.stereotype.Component;
import org.springframework.util.ObjectUtils;


@Component
public class Login {
    /**
     * 根据用户名和密码检查身份并登录
     *
     * @param name
     * @param password
     * @return
     */
    public Result checkAndLogin(String name, String password) {
        Result result = new Result();
        if (ObjectUtils.isEmpty(name) || ObjectUtils.isEmpty(password)) {
            result.setSuccess(false);
            result.setMsg("用户名或密码为空。");
            return result;
        }
        //用户认证信息
        Subject subject = SecurityUtils.getSubject();
        UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken(
                name,
                password
        );
        try{
            subject.login(usernamePasswordToken);
        }
        catch (UnknownAccountException e){
            result.setSuccess(false);
            result.setMsg("账户不存在");
            return result;
        }
        catch (AuthenticationException e){
            result.setSuccess(false);
            result.setMsg("账号或密码错误");
            return result;
        }
        catch (AuthorizationException e){
            result.setSuccess(false);
            result.setMsg("没有权限");
            return result;
        }
        result.setData(subject.getSession().getId());
        return result;
    }
}

然后编写两个简单的功能用于验证:

  • /book,展示所有书籍。
  • /book/add,添加图书。
package cn.icexmoon.demo.books.book.controller;


import cn.icexmoon.demo.books.book.entity.Book;
import cn.icexmoon.demo.books.book.service.IBookService;
import cn.icexmoon.demo.books.system.Result;
import cn.icexmoon.demo.books.user.entity.User;
import cn.icexmoon.demo.books.user.service.IUserService;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authz.annotation.Logical;
import org.apache.shiro.authz.annotation.RequiresRoles;
import org.apache.shiro.subject.Subject;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import java.util.List;

/**
 * <p>
 * 前端控制器
 * </p>
 *
 * @author icexmoon
 * @since 2022-05-06
 */
@RestController
public class BookController {
    @Autowired
    private IBookService bookService;
    @Autowired
    private IUserService userService;


    @RequiresRoles(value = {"guest", "manager"}, logical = Logical.OR)
    @GetMapping("/book")
    public String listAllBooks() {
        Result result = new Result();
        List<Book> books = bookService.list();
        result.setData(books);
        return result.toString();
    }

    @RequiresRoles("manager")
    @PostMapping("/book/add")
    public String addBook(@RequestBody Book book) {
        //添加图书
        Subject subject = SecurityUtils.getSubject();
        String name = (String) subject.getPrincipal();
        User user = userService.getUserByName(name);
        book.setUserId(user.getId());
        bookService.save(book);
        Result result = new Result();
        result.setData(book.getId());
        result.setMsg("添加成功");
        return result.toString();
    }
}

展示所有图书管理员和访客都有权限,而添加图书就只能管理员。

需要注意的是,默认的@RequiresRoles中如果添加多个角色,就要求当前用户同时具备多个角色才可以访问,这是AND的关系,如果要使用OR,就需要这样设置:

@RequiresRoles(value = {"guest", "manager"}, logical = Logical.OR)

下面给数据库添加一些测试数据来验证一下。

你可以从learn_spring_boot (github.com)获取我的测试数据SQL。

lalala用户仅有访客角色,而icexmoon有访客和管理员两个角色。使用lalala登录后可以访问书籍列表,但是不能添加书籍,而icexmoon可以访问书籍列表和添加书籍。

image-20220506174039901

这是我的接口测试文档:

  • https://docs.apipost.cn/preview/2475843295275e3d/c84f0c92cb6cef2a

OK,就到这里了,谢谢阅读。

可以从learn_spring_boot (github.com)获取最终的工程源码。

参考资料

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值