shiro 使用
主要用途: 用户认证(登录、注销), 密码安全, 权限控制
1. 流程概述
shiro 框架的核心有两大部分:
- shiro 过滤器 (ShiroFilter)
- 安全管理器 (SecurityManager)
2.1 shiro 过滤器
shiro 过滤器负责拦截用户请求,设置登录地址、认证成功地址和未授权地址等。
对于其它路径可以按多种规则进行访问控制:
- anon 匿名访问,即所有匹配的请求路径都可以访问
- user 必须经过认证,或者通过 rememberMe 访问
- authc 必须经过认证,不能通过 rememberMe 访问
shiro 过滤器中有多个小的过滤器组成,其中比较重要的是 user 过滤器,它决定了如果 以 user 规则访问时,怎样可以放行,失败后该做什么
shiro 过滤器只负责请求拦截、成功失败时的跳转处理,具体如何验证、验证信息从哪儿来需要下面的安全管理器
2.2 安全管理器
安全管理器负责安全管理的各个方面,如缓存管理、会话管理、记住我管理、以及最重要的 Realm 领域管理
Realm - 领域,王国
用它来获取认证信息和权限信息
数据库
文件
LDAP 服务器
Realm 决定了认证信息以及授权信息从哪儿获取,验证密码的规则是什么,它有多种实现,通常,需要我们自定义一个基于数据库的实现
以Shiro与SpringBoot框架结合实现检查用户登录、权限过滤。
1.shiro的jar包依赖
<shiro.version>1.4.0</shiro.version>
<kaptcha.version>2.3.2</kaptcha.version>
<ehcache3.version>3.6.3</ehcache3.version>
<ehcache2.version>2.10.6</ehcache2.version>
<!--shiro依赖和缓存-->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-core</artifactId>
<version>${shiro.version}</version>
<exclusions>
<exclusion>
<artifactId>slf4j-api</artifactId>
<groupId>org.slf4j</groupId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>${shiro.version}</version>
</dependency>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-ehcache</artifactId>
<version>${shiro.version}</version>
<exclusions>
<exclusion>
<artifactId>slf4j-api</artifactId>
<groupId>org.slf4j</groupId>
</exclusion>
<exclusion>
<artifactId>ehcache-core</artifactId>
<groupId>net.sf.ehcache</groupId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>net.sf.ehcache</groupId>
<artifactId>ehcache</artifactId>
<version>${ehcache2.version}</version>
</dependency>
<dependency>
<groupId>org.ehcache</groupId>
<artifactId>ehcache</artifactId>
<version>${ehcache3.version}</version>
</dependency>
2.实现Shiro的配置类
过滤链中的默认的user过滤器处理不了Ajax请求,需要覆盖user过滤器,以实现Ajax请求。
首先给出重写的user过滤器
import org.apache.shiro.web.filter.authc.UserFilter;
import org.apache.shiro.web.util.WebUtils;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
public class ShiroAjaxUserFilter extends UserFilter {
private boolean isAjaxRequest(HttpServletRequest httpServletRequest) {
return httpServletRequest.getHeader("x-requested-with") != null
&& httpServletRequest.getHeader("x-requested-with").equalsIgnoreCase("XMLHttpRequest");
}
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
HttpServletRequest httpServletRequest = WebUtils.toHttp(request);
HttpServletResponse httpServletResponse = WebUtils.toHttp(response);
/**
* 如果是ajax请求则不进行跳转
*/
if (isAjaxRequest(httpServletRequest)) {
httpServletResponse.setHeader("sessionstatus", "timeout");
return false;
}
return super.onAccessDenied(request, response);
}
}
下边是Shiro的配置类
在安全管理器securityManager()方法中有一个参数ShiroDatabaseRealm 是自定义的一个Realm实现类,Relam类中能够获取认证信息、权限信息、设置密码匹配器。
在shiroFilter()方法中设置默认登录的URL、登录成功跳转的URL、没有权限跳转的URL。
在shiroFilter()方法中覆盖新的user过滤器。
对于其它路径可以按多种规则进行访问控制:
- anon 匿名访问,即所有匹配的请求路径都可以访问
- user 必须经过认证,或者通过 rememberMe 访问
- authc 必须经过认证,不能通过 rememberMe 访问
NONE_PERMISSION_RES是一个集合里边存储了所有不需要过滤的路径,包括登录路径,错误路径等,将里边所有路径标志为anon,即访问不需要权限。
将其他剩余路径标志为user。
/**
* shiro权限管理的配置
*/
@Configuration
public class ShiroConfig {
/**
* 安全管理器
*/
@Bean
public DefaultWebSecurityManager securityManager(ShiroDatabaseRealm shiroDatabaseRealm,
RememberMeManager rememberMeManager,
CacheManager cacheManager,
SessionManager sessionManager) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setRealm(shiroDatabaseRealm);
securityManager.setCacheManager(cacheManager);
securityManager.setRememberMeManager(rememberMeManager);
securityManager.setSessionManager(sessionManager);
return securityManager;
}
/**
* 会话管理器
*/
@Bean
public SessionManager sessionManager() {
return new ServletContainerSessionManager();
}
/**
* 缓存管理器
*/
@Bean
public CacheManager getCacheShiroManager(EhCacheManagerFactoryBean ehcache) {
EhCacheManager ehCacheManager = new EhCacheManager();
ehCacheManager.setCacheManager(ehcache.getObject());
return ehCacheManager;
}
/**
* rememberMe管理器, cipherKey生成见{@code Base64Test.java}
*/
@Bean
public CookieRememberMeManager rememberMeManager(SimpleCookie rememberMeCookie) {
CookieRememberMeManager manager = new CookieRememberMeManager();
manager.setCipherKey(Base64.decode("Z3VucwAAAAAAAAAAAAAAAA=="));
manager.setCookie(rememberMeCookie);
return manager;
}
/**
* 记住密码Cookie
*/
@Bean
public SimpleCookie rememberMeCookie() {
SimpleCookie simpleCookie = new SimpleCookie("rememberMe");
simpleCookie.setHttpOnly(true);
simpleCookie.setMaxAge(7 * 24 * 60 * 60);//7天
return simpleCookie;
}
/**
* Shiro的过滤器链
*/
@Bean
public ShiroFilterFactoryBean shiroFilter(DefaultWebSecurityManager securityManager) {
ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean();
shiroFilter.setSecurityManager(securityManager);
/**
* 默认的登陆访问url
*/
shiroFilter.setLoginUrl("/login");
/**
* 登陆成功后跳转的url
*/
shiroFilter.setSuccessUrl("/");
/**
* 没有权限跳转的url
*/
shiroFilter.setUnauthorizedUrl("/global/error");
/**
* 覆盖默认的user拦截器(默认拦截器解决不了ajax请求 session超时的问题,若有更好的办法请及时反馈作者)
*/
HashMap<String, Filter> myFilters = new HashMap<>();
myFilters.put("user", new ShiroAjaxUserFilter());
shiroFilter.setFilters(myFilters);
/**
* 配置shiro拦截器链
*
* anon 不需要认证
* authc 需要认证
* user 验证通过或RememberMe登录的都可以
*
* 当应用开启了rememberMe时,用户下次访问时可以是一个user,但不会是authc,因为authc是需要重新认证的
*
* 顺序从上到下,优先级依次降低
*
* api开头的接口,走rest api鉴权,不走shiro鉴权
*
*/
Map<String, String> hashMap = new LinkedHashMap<>();
for (String nonePermissionRe : NONE_PERMISSION_RES) {
hashMap.put(nonePermissionRe, "anon");
}
// 剩下的路径 user
hashMap.put("/**", "user");
shiroFilter.setFilterChainDefinitionMap(hashMap);
return shiroFilter;
}
/**
* Shiro生命周期处理器:
* 用于在实现了Initializable接口的Shiro bean初始化时调用Initializable接口回调(例如:UserRealm)
* 在实现了Destroyable接口的Shiro bean销毁时调用 Destroyable接口回调(例如:DefaultSecurityManager)
*/
@Bean
public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
return new LifecycleBeanPostProcessor();
}
/**
* 启用shrio授权注解拦截方式,AOP式方法级权限检查
*/
@Bean
@DependsOn("lifecycleBeanPostProcessor")
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(DefaultWebSecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor =
new AuthorizationAttributeSourceAdvisor();
authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
return authorizationAttributeSourceAdvisor;
}
}
3.在Login Controller中获取信息
在login controller中需要使用一个UsernamePasswordToken类型的token来存储用户名、密码、和remeberme ,token相当于一个令牌。
SecurityUtils.getSubject()得到subject, subject是一个非常重要的对象,包含了登录、注销、获取当前用户、检查角色、权限等操作的方法。
调用subject.login()方法将token作为参数会进入Shiro的安全管理器,并调用 Realm 中的 doGetAuthenticationInfo 方法。
@RequestMapping(value = "/login", method = RequestMethod.POST)
public String loginVali(String username, String password, String remember) {
Subject subject = SecurityUtils.getSubject();
// 1) TODO 准备 Token 数据
UsernamePasswordToken token = new UsernamePasswordToken(username, password.toCharArray());
// 2) TODO 开启“记住我”功能
if(remember!=null&&remember.equals("on")){
token.setRememberMe(true);
}
else {
token.setRememberMe(false);
}
// 3) TODO 利用 subject 进行登录
subject.login(token);
// 4) TODO 记录登录日志 (暂时不做)
return REDIRECT + "/";
}
4.实现Realm类
需要实现Realmedia中的三个方法
- doGetAuthenticationInfo()方法 ,在该方法中需要完成认证信息的准备。
- setCredentialsMatcher()方法,需要在该方法中重新实现一个CredentialsMatcher()类中的接口用来提供验证密码的正确性,然后将新的CredentialsMatcher对象当作参数调用父类的setCredentialsMatcher()方法。
- doGetAuthorizationInfo()方法,该方法中需要进行授权信息准备,准备好用户的权限信息,将来使用AOP进行权限过滤时会用到。
下边是自己实现的ShiroDatabaseRealm类
- doGetAuthenticationInfo()方法中返回了一个SimpleAuthenticationInfo对象SimpleAuthenticationInfo 表示认证信息
- principal 表示用户信息(一般就是一个用户对象,里面会包含角色信息)
- credentail 表示密码信息(从数据库获取,一般是加密后的)
- realmName表示当前的Realm的名字
- SimpleAuthenticationInfo第一个参数传递了一个ShiorUser对象而不是直接的User对象是因为在ShiorUser对象中多了一些存储权限路径等信息的属性。
- doGetAuthorizationInfo()方法返回一个SimpleAuthorizationInfo 对象,使用setRoles设置其角色集合,setStringPermissions设置权限集合(url访问列表)
@Component
@DependsOn({"userService", "deptService", "roleService"})
@Slf4j
public class ShiroDatabaseRealm extends AuthorizingRealm {
@Autowired
private UserService userService;
@Autowired
private DeptService deptService;
@Autowired
private RoleService roleService;
// TODO 1. 完成 认证信息 准备
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
// a. TODO 此 token 就是登录时封装的 token 对象
UsernamePasswordToken token1=(UsernamePasswordToken)token;
// b. TODO 可以利用 userService 中的方法获取 用户对象
User user = userService.selectByAccount(token1.getUsername());
// c. TODO 记得密码验证不需要在这里做,你要做的判断有两件事:
// d. TODO 第一,判断 数据库中该用户是否存在,不存在,则抛出 shiro 的 UnknownAccountException 异常
if(user==null) {
throw new UnknownAccountException();
}
// e. TODO 第二,判断 该用户是否被冻结,如被冻结,则抛出 shiro 的 LockedAccountException 异常
if(user.getStatus()=="FREEZE"){
throw new LockedAccountException();
}
// f. TODO 将数据库用户信息 User 对象转换 为 ShiroUser 对象
ShiroUser shiroUser=toShiroUser(user);
// g. TODO 将认证信息:ShiroUser 对象、数据库密码、realm 名称(通过 getName() 得到) 封装至 SimpleAuthenticationInfo 并返回
SimpleAuthenticationInfo root = new SimpleAuthenticationInfo(shiroUser, user.getPassword(), this.getName());
/*
注意
* 验证密码的操作,是 shiro 框架完成的,不需要主动调用,只需要提供密码验证的 CredentialsMatcher 对象
* 验证成功,进入 successUrl 验证失败进入 loginUrl
* 成功后会将 SimpleAuthenticationInfo 中的 principal 信息存入 session, 以后可以通过 Subject 对象获得
* 成功后还会做一些 RemeberMe cookie 的生成并返回操作,也无需我们干预
*/
return root;
}
/* ShiroUser 的作用
1. 用来保存认证信息中的用户数据,即 principal,将来存入 session,以便在登录期间使用
2. 其中除了用户数据,还包括了用户的角色信息,其属性都是根据需要自定义的
3. 为什么不直接用 User ? 是因为认证过程中的很多属性(包括将来页面要显示的属性) User 对象中没有,因此用 ShiroUser 来保存更多的信息
*/
private ShiroUser toShiroUser(User user) {
ShiroUser shiroUser = new ShiroUser();
shiroUser.setId(user.getUserId());
shiroUser.setAccount(user.getAccount());
shiroUser.setDeptId(user.getDeptId());
shiroUser.setDeptName(deptService.selectName(user.getDeptId()));
shiroUser.setName(user.getName());
shiroUser.setEmail(user.getEmail());
shiroUser.setAvatar(user.getAvatar());
String[] split = user.getRoleId().split(",");
List<Long> roleIds = new ArrayList<>();
List<String> roleNames = new ArrayList<>();
for (String s : split) {
Long roleId = Long.valueOf(s);
roleIds.add(roleId);
String roleName = roleService.selectName(roleId);
roleNames.add(roleName);
}
shiroUser.setRoleList(roleIds);
shiroUser.setRoleNames(roleNames);
log.debug("==============> user {} roles: {}", user.getName(), shiroUser.getRoleNames());
return shiroUser;
}
// TODO 2. 提供密码验证 CredentialsMatcher
@Override
public void setCredentialsMatcher(CredentialsMatcher credentialsMatcher) {
CredentialsMatcher matcher = new CredentialsMatcher() {
@Override
public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) {
// TODO token 是表单提交过来的数据, info 是在 doGetAuthenticationInfo 步骤返回的认证信息
UsernamePasswordToken token1=(UsernamePasswordToken)token;
char[] password = token1.getPassword();
String pass_user = new String(password);
String pass_databases = info.getCredentials().toString();
// TODO 使用 BCrypt 算法验证密码的正确性,返回值即表示验证是否通过
return BCrypt.checkpw(pass_user, pass_databases);
// 验证不通过会由框架抛出 IncorrectCredentialsException 异常
}
};
super.setCredentialsMatcher(matcher);
}
// TODO 3. 完成 授权信息 准备
// 当需要进行权限验证时,会调用 doGetAuthorizationInfo 获得当前用户(已认证)的权限信息,只会执行一次,放入缓存当中
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
ShiroUser shiroUser = (ShiroUser) principals.getPrimaryPrincipal();
// a. TODO 获取用户的所有 url 访问权限,可以通过 roleService.selectPermissionURL 获取一个角色的 url 访问列表
// 注意,一个用户可能会有多个角色
LinkedHashSet<String> set = new LinkedHashSet<>();
for (Long roleId : shiroUser.getRoleList()) {
List<String> strings = roleService.selectPermissionURL(roleId);
set.addAll(strings);
}
// b. TODO 准备一个 SimpleAuthorizationInfo 对象,应设置其角色集合,权限集合(url访问列表),并返回
/* 注意,这些集合中都是字符串表示的角色和权限
后续可以通过 Subject 对象中的相关方法来使用这里准备的数据,如:
* subject.hasRole(角色名) 判断用户是否有某个角色
* subject.isPermitted(权限名) 判断用户是否有某个权限
* 更多方法,参考 shiro 的 Subject 接口说明
*/
LinkedHashSet<String> roleNames = new LinkedHashSet<>();
roleNames.addAll(shiroUser.getRoleNames());
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
info.setRoles(roleNames);
info.setStringPermissions(set);
return info;
}
}
5.在AOP中进行权限过滤
在需要进行权限过滤的controller方法上加@Permission注解。
若该方法必须某个角色才能访问则@Permission(“角色名”);
- 得到subject
Subject subject = SecurityUtils.getSubject(); - 判断是否含有所有角色
subject.hasAllRoles() - 判断是否含有URL
subject.isPermitted()
/**
* 权限检查的aop
*/
// 0. TODO 打开 @Aspect 注解
@Aspect
@Component
@Order(200)
@Slf4j
public class ShiroPermissionAop {
// 1. TODO 控制器内需要权限控制的方法上都加了 @Permission 自定义注解,添加合适的切点
@Around("@annotation(cn.stylefeng.guns.core.common.annotion.Permission)")
public Object doPermission(ProceedingJoinPoint pjp) throws Throwable {
// a. 获取当前的请求路径(已实现)
String requestURI = getRequestURI();
// b. 获取当前的控制器方法对象(已实现)
Method method = getMethod(pjp);
// c. TODO 拿到方法的 Permission 注解,做进一步判断
Permission annotation= method.getAnnotation(Permission.class);
String[] roleNames = annotation.value();
// d. TODO 分支1,如果 Permission 上有角色,调用 checkRoles 进一步判断
if(annotation.value().length>0){
boolean pass= checkRoles(roleNames);;
if(!pass){
throw new NoPermissionException("没有权限");
}
}
// e. TODO 分支2,如果没有角色,那么进行所有路径匹配检查,调用 checkPermission 进一步判断
else{
boolean pass= checkPermission(requestURI);
if(!pass){
throw new NoPermissionException("没有权限");
}
}
// f. TODO 以上分支,检查通过,使用 pjp 放行, 不通过抛出 NoPermissionException
// 注意放行的话应该 将 pjp 执行目标方法的结果返回
return pjp.proceed();
}
private Method getMethod(ProceedingJoinPoint point) {
MethodSignature ms = (MethodSignature) point.getSignature();
return ms.getMethod();
}
private String getRequestURI() {
HttpServletRequest request = HttpContext.getRequest();
String requestURI = request.getRequestURI().replaceFirst(ConfigListener.getConf().get("contextPath"), "");
log.debug("===============> current uri: {}", requestURI);
return requestURI;
}
public boolean checkRoles(Object[] permissions) {
String[] roleNames = (String[]) permissions;
Subject subject = SecurityUtils.getSubject();
return subject.hasAllRoles(Arrays.asList(roleNames));
}
private boolean checkPermission(String requestURI) {
Subject subject = SecurityUtils.getSubject();
return subject.isPermitted(requestURI);
}
}