从零开始 Spring Boot 9:Shiro
图源:简书 (jianshu.com)
Shiro 是一个权限管理组件,可以用它来实现Web应用的权限控制,本篇将介绍如何在Spring Boot的Web项目中使用Shiro 实现权限控制。
准备工作
在使用Shiro 前,需要先构建一个示例需要的基本Web 应用:
模块的划分可以参考:
- 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;
}
添加依赖
<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;
@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;
}
@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 {
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;
}
@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();
}
@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 {
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;
@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)获取最终的工程源码。
参考资料
|