从零开始 Spring Boot 9:Shiro
Shiro
是一个权限管理组件,可以用它来实现Web应用的权限控制,本篇将介绍如何在Spring Boot的Web项目中使用Shiro
实现权限控制。
准备工作
在使用Shiro
前,需要先构建一个示例需要的基本Web
应用:
- 从头创建一个新的基于
Spring Boot
的Web项目,并添加基本的依赖,可以参考从零开始Spring Boot 1:快速构建 - 魔芋红茶’s blog (icexmoon.cn)。 - 创建数据库,可以使用learn_spring_boot/books.sql (github.com)。
- 添加数据库依赖和配置,可以参考从零开始 Spring Boot 4:Mybatis Plus - 魔芋红茶’s blog (icexmoon.cn)。
- 利用
Mybatis Plus
自动生成框架代码,可以参考从零开始 Spring Boot 7:生成框架代码 - 魔芋红茶’s blog (icexmoon.cn)。
模块的划分可以参考:
- 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
配置,以将我们设置好的Realm
和SessionManager
添加到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
中添加Realm
和SessionManager
以外,还可以在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
可以访问书籍列表和添加书籍。
这是我的接口测试文档:
- https://docs.apipost.cn/preview/2475843295275e3d/c84f0c92cb6cef2a
OK,就到这里了,谢谢阅读。
可以从learn_spring_boot (github.com)获取最终的工程源码。