springboot集成shiro
1.shiro的相关介绍
shiro是Apache下的一个开源项目,是一款简单易用的的安全框架,提供了认证、授权、加密、会话管理,与spring Security 一样都是做一个权限的安全框架,但是与Spring Security 相比,在于 Shiro 使用了比较简单易懂易于使用的授权方式。shiro属于轻量级框架,相对于security简单的多,也没有security那么复杂。所以我这里也是简单介绍一下shiro的使用。其主要模块如下:
Authentication:身份认证/登录,验证用户是不是拥有相应的身份;
Authorization:授权,即权限验证,验证某个已认证的用户是否拥有某个权限;即判断用户是否能做事情,常见的如:验证某个用户是否拥有某个角色。或者细粒度的验证某个用户对某个资源是否具有某个权限;
Session Manager:会话管理,即用户登录后就是一次会话,在没有退出之前,它的所有信息都在会话中;会话可以是普通JavaSE环境的,也可以是如Web环境的;
Cryptography:加密,保护数据的安全性,如密码加密存储到数据库,而不是明文存储;
Web Support:Web支持,可以非常容易的集成到Web环境;
Caching:缓存,比如用户登录后,其用户信息、拥有的角色/权限不必每次去查,这样可以提高效率;
Concurrency:shiro支持多线程应用的并发验证,即如在一个线程中开启另一个线程,能把权限自动传播过去;
Testing:提供测试支持;
Run As:允许一个用户假装为另一个用户(如果他们允许)的身份进行访问;
Remember Me:记住我,这个是非常常见的功能,即一次登录后,下次再来的话不用登录了。
记住一点,Shiro不会去维护用户、维护权限;这些需要我们自己去设计/提供;然后通过相应的接口注入给Shiro即可。
2.运行原理
1) Subject:主体,代表了当前“用户”。这个用户不一定是一个具体的人,与当前应用交互的任何东西都是 Subject,如网络爬虫,机器人等。所有 Subject 都绑定到 SecurityManager,与 Subject 的所有交互都会委托给 SecurityManager。我们可以把 Subject 认为是一个门面,SecurityManager 才是实际的执行者。
2) SecurityManager:安全管理器。即所有与安全有关的操作都会与 SecurityManager 交互,且它管理着所有 Subject。可以看出它是 Shiro 的核心,它负责与后边介绍的其他组件进行交互,如果学习过 SpringMVC,我们可以把它看成 DispatcherServlet 前端控制器。
3) Realm:域。Shiro 从 Realm 获取安全数据(如用户、角色、权限),就是说 SecurityManager 要验证用户身份,那么它需要从 Realm 获取相应的用户进行比较以确定用户身份是否合法,也需要从 Realm 得到用户相应的角色/权限进行验证用户是否能进行操作。我们可以把 Realm 看成 DataSource,即安全数据源。
3.Shiro 运行原理图2(Shiro 内部架构角度)如下:
1) Subject:主体,可以看到主体可以是任何与应用交互的“用户”。
2) SecurityManager:相当于 SpringMVC 中的 DispatcherServlet 或者 Struts2 中的 FilterDispatcher。它是 Shiro 的核心,所有具体的交互都通过 SecurityManager 进行控制。它管理着所有 Subject、且负责进行认证和授权、及会话、缓存的管理。
3) Authenticator:认证器,负责主体认证的,这是一个扩展点,如果用户觉得 Shiro 默认的不好,我们可以自定义实现。其需要认证策略(Authentication Strategy),即什么情况下算用户认证通过了。
4) Authrizer:授权器,或者访问控制器。它用来决定主体是否有权限进行相应的操作,即控制着用户能访问应用中的哪些功能。
5) Realm:可以有1个或多个 Realm,可以认为是安全实体数据源,即用于获取安全实体的。它可以是 JDBC 实现,也可以是 LDAP 实现,或者内存实现等。
6) SessionManager:如果写过 Servlet 就应该知道 Session 的概念,Session 需要有人去管理它的生命周期,这个组件就是 SessionManager。而 Shiro 并不仅仅可以用在 Web 环境,也可以用在如普通的 JavaSE 环境。
7) SessionDAO:DAO 大家都用过,数据访问对象,用于会话的 CRUD。我们可以自定义 SessionDAO 的实现,控制 session 存储的位置。如通过 JDBC 写到数据库或通过 jedis 写入 redis 中。另外 SessionDAO 中可以使用 Cache 进行缓存,以提高性能。
8) CacheManager:缓存管理器。它来管理如用户、角色、权限等的缓存的。因为这些数据基本上很少去改变,放到缓存中后可以提高访问的性能。
9) Cryptography:密码模块,Shiro 提高了一些常见的加密组件用于如密码加密/解密的。
在第五点上我们说了realm可以用户自定义实现,接下来我们就讲一下用户该如何在springboot下自定义实现realm
(1)首先在pom文件中引入相关联的依赖
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.6</version>
</dependency>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring-boot-web-starter</artifactId>
</dependency>
(2)自定义realm时要继承AuthorizingRealm类,重写其中的doGetAuthenticationInfo()方法和doGetAuthorizationInfo()方法,其中前者是用来登录验证其权限的,后者是对用户进行授权的,代码如下
public class CustomRealm extends AuthorizingRealm {
@Autowired
private UserService userService;
@Autowired
private RoleService roleService;
@Autowired
private MenuService menuService;
//告诉shiro如何根据获取到的用户信息中的密码和盐值来校验密码
{
//设置用于匹配密码的CredentialsMatcher
this.setCredentialsMatcher(new CustomCredentialsMatcher());
}
//定义如何获取用户信息的业务逻辑,给shiro做登录
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws
AuthenticationException {
UsernamePasswordToken upToken = (UsernamePasswordToken) token;
String username = upToken.getUsername();
// Null username is invalid
if (username == null) {
throw new AccountException("Null usernames are not allowed by this realm.");
}
User userDB = userService.findBy("username",username);
if (userDB == null) {
throw new UnknownAccountException("No account found for admin [" + username + "]");
}
byte[] salt = EncodeUtils.decodeHex(userDB.getSalt());
SimpleAuthenticationInfo
info = new SimpleAuthenticationInfo(userDB, userDB.getPassword(), getName());
if (userDB.getSalt() != null) {
info.setCredentialsSalt(ByteSource.Util.bytes(salt));
}
return info;
}
/**
* 此方法调用 hasRole,hasPermission的时候才会进行回调.
*
* 权限信息.(授权):
* 1、如果用户正常退出,缓存自动清空;
* 2、如果用户非正常退出,缓存自动清空;
* 3、如果我们修改了用户的权限,而用户不退出系统,修改的权限无法立即生效。
* (需要手动编程进行实现;放在service进行调用)
* 在权限修改后调用realm中的方法,realm已经由spring管理,所以从spring中获取realm实例,
* 调用clearCached方法;
* :Authorization 是授权访问控制,用于对用户进行的操作授权,证明该用户是否允许进行当前操作,如访问某个链接,某个资源文件等。
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals)
throws AuthenticationException {
/*
* 当没有使用缓存的时候,不断刷新页面的话,这个代码会不断执行,
* 当其实没有必要每次都重新设置权限信息,所以我们需要放到缓存中进行管理;
* 当放到缓存中时,这样的话,doGetAuthorizationInfo就只会执行一次了,
* 缓存过期之后会再次执行。
*/
log.info("后台权限校验-->CustomRealm.doGetAuthorizationInfo()");
SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
User user = (User) principals.getPrimaryPrincipal();
Set<String> menus = null;
if (user.getSystem()) {
menus = menuService.getAllMenuCode();
} else {
menus = menuService.findMenuCodeByUserId(user.getId());
}
authorizationInfo.setStringPermissions(menus);
Set<String> roles = roleService.getRolesByUserId(user.getId());
authorizationInfo.setRoles(roles);
return authorizationInfo;
}
/**
* 指定principalCollection 清除
*/
@Override public void clearCachedAuthorizationInfo(PrincipalCollection principalCollection) {
SimplePrincipalCollection principals = new SimplePrincipalCollection(
principalCollection, getName());
super.clearCachedAuthorizationInfo(principals);
}
}
其中在controller中要进行登录用户名密码的获取,注意如果要自己重写filter中的登录成功拦截器时,此时登录页面input标签传递参数必须是username和password,否则程序不会执行你的登录成功拦截。
public String loginPost(@Valid ValidAdmin validAdmin, BindingResult bindingResult,
RedirectAttributes redirectAttributes, HttpServletRequest request) {
if (bindingResult.hasErrors()) {
return "redirect:/admin/login";
}
String username = validAdmin.getUsername();
UsernamePasswordToken
token =
new UsernamePasswordToken(validAdmin.getUsername(), validAdmin.getPassword(), false);
Subject currentUser = SecurityUtils.getSubject();
try {
//在调用了login方法后,SecurityManager会收到AuthenticationToken,并将其发送给已配置的Realm执行必须的认证检查
//每个Realm都能在必要时对提交的AuthenticationTokens作出反应
//所以这一步在调用login(token)方法时,它会走到MyRealm.doGetAuthenticationInfo()方法中,具体验证方式详见此方法
log.info("对用户[" + username + "]进行登录验证..验证开始");
currentUser.login(token);
log.info("对用户[" + username + "]进行登录验证..验证通过");
} catch (UnknownAccountException uae) {
log.info("对用户[" + username + "]进行登录验证..验证未通过,未知账户");
redirectAttributes.addFlashAttribute("message", "未知账户");
} catch (IncorrectCredentialsException ice) {
log.info("对用户[" + username + "]进行登录验证..验证未通过,错误的凭证");
redirectAttributes.addFlashAttribute("message", "密码不正确");
} catch (LockedAccountException lae) {
log.info("对用户[" + username + "]进行登录验证..验证未通过,账户已锁定");
redirectAttributes.addFlashAttribute("message", "账户已锁定");
} catch (ExcessiveAttemptsException eae) {
log.info("对用户[" + username + "]进行登录验证..验证未通过,错误次数过多");
redirectAttributes.addFlashAttribute("message", "用户名或密码错误次数过多");
} catch (AuthenticationException ae) {
//通过处理Shiro的运行时AuthenticationException就可以控制用户登录失败或密码错误时的情景
log.info("对用户[" + username + "]进行登录验证..验证未通过");
ae.printStackTrace();
redirectAttributes.addFlashAttribute("message", "用户名或密码不正确");
}
//验证是否登录成功
if (currentUser.isAuthenticated()) {
log.info("用户[" + username + "]登录认证通过(这里可以进行一些认证通过后的一些系统参数初始化操作)");
return "redirect:/admin/home";
} else {
token.clear();
return "redirect:/admin/login";
}
}
3.实现自定义登录登出拦截
实现登录成功拦截,自定义表单拦截器
由onPreHandle方法为起点,先判断当前用户是否已经登陆,然后当前账号是否是当前用户最后登陆的,如果不是,则拒绝,onAccessDenied,重定向到登陆界面 auth认证,
protected boolean onPreHandle(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {
Subject subject=this.getSubject(request,response);
return subject.isAuthenticated() && isCurrentAccount(subject) ||onAccessDenied(request,response);
}
登陆走下面的方法,如果当前访问的路径是登陆路径,则执行登陆流程
protected boolean onPreHandle(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {
if (this.isLoginRequest(request, response)){
return this.executeLogin(request, response);
}
return super.onPreHandle(request, response, mappedValue);
}
public boolean executeLogin(ServletRequest request, ServletResponse response) {
AuthenticationToken token=this.createToken(request,response);
if (token==null){
String msg = "createToken method implementation returned null. A valid non-null AuthenticationToken must be created in order to execute a login attempt.";
throw new IllegalStateException(msg);
}else {
try {
Subject subject=this.getSubject(request,response);
// boolean existing = subject.getSession(false) != null;
// if (!existing){
// subject.getSession(true);
// }
subject.login(token);
return this.onLoginSuccess(token, subject, request, response);
} catch (AuthenticationException var5) {
return this.onLoginFailure(token, var5, request, response);
}
}
}
登录成功后执行onLoginSuccess,我们可以在此处进行登录成功后的自定义操作,我这里地登录日志进行了存储
protected boolean onLoginSuccess(AuthenticationToken token, Subject subject, ServletRequest request,
ServletResponse response) throws Exception {
log.info("进入保存方法");
// 当前登录的用户
User user = (User) SecurityUtils.getSubject().getPrincipal();
// IP
HttpServletRequest req = (HttpServletRequest) request;
String ip = StringUtils.getIpAddr(req);
// 操作时间
Date date = new Date();
LoginLog log = new LoginLog();
log.setUserId(new Long(user.getId()));
log.setIp(ip);
log.setRecordTime(date);
if(loginLogService != null){
loginLogService.save(log);
}
issueSuccessRedirect(request, response);
return false;
}
注意如果我们自定义了表单拦截器,此时登录表单的路径一定要设置成authc,否则我们自定义的会失效,同是如果将登录的路径设置成authc,可能我们想通过直接访问登录页面会出现重定向过多问题,此时我们也可以在上述实现类中设置我们的登录路径,若不设置系统将自动定位到login页面处
@Override
public String getLoginUrl() {
return "/admin/login";
}
自定义退出登录
public class CustomLogoutFilter extends LogoutFilter {
@Override
protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
log.debug("logoutFilter");
Subject subject = getSubject(request, response);
if (isPostOnlyLogout()) {
if (!WebUtils.toHttp(request).getMethod().toUpperCase(Locale.ENGLISH).equals("POST")) {
return onLogoutRequestNotAPost(request, response);
}
}
String redirectUrl = getRedirectUrl(request, response, subject);
try {
PrincipalCollection principals = subject.getPrincipals();
RealmSecurityManager securityManager =
(RealmSecurityManager) SecurityUtils.getSecurityManager();
CustomRealm adminRealm = (CustomRealm) securityManager.getRealms().iterator().next();
adminRealm.clearCachedAuthorizationInfo(principals);
subject.logout();
} catch (SessionException ise) {
log.debug("Encountered session exception during logout. This can generally safely be ignored.", ise);
}
issueRedirect(request, response, redirectUrl);
return false;
}
}
3.springboot配置上述拦截器
package com.jidian.unicomad.web.conf;
import at.pollux.thymeleaf.shiro.dialect.ShiroDialect;
import com.jidian.unicomad.web.shiro.CustomFormAuthenticationFilter;
import com.jidian.unicomad.web.shiro.CustomRealm;
import com.jidian.unicomad.web.shiro.CustomLogoutFilter;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import javax.servlet.Filter;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.mgt.SessionsSecurityManager;
import org.apache.shiro.realm.Realm;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Slf4j
@Configuration
public class ShiroConfig {
/**
* 注入自定义的realm,告诉shiro如何获取用户信息来做登录或权限控制
*/
@Bean
public Realm realm() {
return new CustomRealm();
}
@Bean
public static DefaultAdvisorAutoProxyCreator getDefaultAdvisorAutoProxyCreator() {
DefaultAdvisorAutoProxyCreator creator = new DefaultAdvisorAutoProxyCreator();
/**
* setUsePrefix(false)用于解决一个奇怪的bug。在引入spring aop的情况下。
* 在@Controller注解的类的方法中加入@RequiresRole注解,会导致该方法无法映射请求,导致返回404。
* 加入这项配置能解决这个bug
*/
creator.setUsePrefix(true);
return creator;
}
///**
// * 这里统一做鉴权,即判断哪些请求路径需要用户登录,哪些请求路径不需要用户登录。
// * 这里只做鉴权,不做权限控制,因为权限用注解来做。
// * @return
// */
//@Bean
//public ShiroFilterChainDefinition shiroFilterChainDefinition() {
// DefaultShiroFilterChainDefinition chain = new DefaultShiroFilterChainDefinition();
// chain.addPathDefinition("/login", "anon");
// chain.addPathDefinition("/logout", "logout");
// chain.addPathDefinition("/**", "authc");
// return chain;
//}
@Bean(name = "shiroFilter")
public ShiroFilterFactoryBean shiroFilter(SessionsSecurityManager securityManager) {
log.debug("ShiroConfiguration.shirFilter()");
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
shiroFilterFactoryBean.setSecurityManager(securityManager);
Map<String, Filter> filters = new HashMap<>();
filters.put("logout", new CustomLogoutFilter());
filters.put("authc", this.CustomFormAuthenticationFilter());
shiroFilterFactoryBean.setFilters(filters);
Map<String, String> filterChainDefinitionMap = new LinkedHashMap<String, String>();
//<!-- 过滤链定义,从上向下顺序执行,一般将 /**放在最为下边 -->:这是一个坑呢,一不小心代码就不好使了;
//<!-- authc:所有url都必须认证通过才可以访问; anon:所有url都都可以匿名访问-->
filterChainDefinitionMap.put("/admin/login", "authc");
filterChainDefinitionMap.put("/admin/logout", "logout");
filterChainDefinitionMap.put("/admin/**", "authc");
shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
return shiroFilterFactoryBean;
}
@Bean(name = "shiroDialect")
public ShiroDialect shiroDialect(){
return new ShiroDialect();
}
@Bean
public Filter CustomFormAuthenticationFilter(){
return new CustomFormAuthenticationFilter();
}
}
这里注意了,在springboot下filter中不能直接直接注入service,因为 filter不能直接注入 spring容器里面的对象,我在这边的解决方法是在ShiroConfig中将应用到service的filter先实例化在引用,关键代码如下
filters.put("authc", this.CustomFormAuthenticationFilter());
@Bean
public Filter CustomFormAuthenticationFilter(){
return new CustomFormAuthenticationFilter();
}
最后要想集成成功还需配置两个文件application.yml和application-shiro.yml,其中前者关键代码如下:
mvc:
static-path-pattern: /**
throw-exception-if-no-handler-found: true
favicon:
enabled: false
profiles:
active: dev
include: shiro
后者关键代码如下:
shiro:
loginUrl: /admin/login # 用户未登录时跳转的请求路径
unauthorizedUrl: /admin/login # 用户没有访问权限时跳转的请求路径