会话管理
Shiro提供了完整的企业级会话管理功能,不依赖于底层容器(如Tomcat),不管是J2SE还是J2EE环境都可以使用,提供了会话管理,会话事件监听,会话存储/持久化,容器无关的集群,失效/过期支持,对Web的透明支持,SSO单点登录的支持等特性。
会话相关API
Subject.getSession():获取会话,等价于Subject.getSession(true),即如果当前没有创建session对象会创建一个;Subject.getSession(false),如果当前没有创建session对象则返回null。
session.setAttribute():设置会话属性
session.getAttribute():获取会话属性
session.removeAttribute(key):删除会话属性
SessionDAO
Shiro提供SessionDAO用于会话持久化,提供CRUD操作。
AbstractSessionDAO:提供了SessionDAO的基础实现,如生成会话ID等。
CachingSessioDAO:提供了对开发者透明的会话缓存的功能,需要设置相应的CacheManager。
MemorySessionDAO:直接在内存中进行会话维护。
EnterpriseCacheSessionDAO:提供了缓存功能的会话维护,默认情况下使用MapCache实现,内部使用ConcurrentHashMap保存缓存的会话。
在实际开发中,如果要用到SessionDAO组件,可以自定义类实现自EnterpriseCacheSessionDAO类,为其注入sessionIdGenerator属性,如果用到缓存的话还可以注入一个缓存的实现,然后将这个SessionDAO组件注入给SessionManager(会话管理器),最后将SessionManager配置给SecurityManager。
会话使用
建议在开发中,Controller层使用原生的HttpSession对象,在Service层中使用Shiro提供的Session对象。如果在Service层中使用HttpSession对象,那么属于侵入式,并不建议这么做。
shiro提供的Session能够很好的解决这个问题。
在Controller中,通过request.getSession()获取会话session,该session到底来源于ServletRequest还是由Shiro管理并创建会话,主要由安全管理器SecurityManage和SessionManager会话管理器决定。
在使用默认SessionManager会话管理器的情况下,不管是通过request.getSession()或者subject.getSession()获取到session,操作session,两者都是等价的,请大家放心使用!
缓存
问题分析
每次在访问设置了权限的页面时,都会去执行doGetAuthorizationInfo()方法来获取权限信息判断当前用户是否具备访问权限。由于在实际情况中,权限是不会经常改变的,能否不用每次都去执行doGetAuthorizationInfo()方法获取权限呢?
解决方法
解决方法就是对权限授权数据进行缓存处理,我们会使用第三方的shiro-redis集成redis实现缓存。
具体实现
1.添加依赖
<dependency>
<groupId>org.crazycake</groupId>
<artifactId>shiro-redis</artifactId>
<version>3.1.0</version>
</dependency>
2.application.properties配置文件中添加Redis配置
#Redis服务器地址
spring.redis.host=localhost
#Redis服务器连接端口
spring.redis.port=6379
#Redis服务器连接密码(默认为空)
#spring.redis.password=foobared
#连接池最大连接数(使用负值表示没有权限)默认为8
spring.redis.lettuce.pool.max-active=8
#连接池最大阻塞等待时间(使用负值表示没有限制)默认为-1
spring.redis.lettuce.pool.max-wait=-1
#连接池中的最大空闲连接 默认8
spring.redis.lettuce.pool.max-idle=8
#连接池只能的最小空闲连接 默认0
spring.redis.lettuce.pool.min-idle=0
#Redis服务器超时时间
spring.redis.timeout=5000
3.改造ShiroConfig
ShiroConfig改造的步骤如下:
- 注入Rrdis参数:@Value注解从application.properties配置文件中获取
- 添加redisManager():创建RedisManager
- 添加cacheManager():创建RedisCacheManager,注入RedisManager
- 添加redisSessionDao():创建RedisSessionDAO,注入RedusSessionDAO
- 修改myShiroRealm():创建MyShiroRealm,启用缓存
- 修改securityManager():创建SecurityManager,注入MyShiroRealm、RedisCacheManager、SessionManager
import org.apache.shiro.authc.credential.HashedCredentialsMatcher;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
import org.crazycake.shiro.RedisCacheManager;
import org.crazycake.shiro.RedisManager;
import org.crazycake.shiro.RedisSessionDAO;
import org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.LinkedCaseInsensitiveMap;
import javax.annotation.Resource;
import java.util.List;
import java.util.Map;
@Configuration
public class ShiroConfig {
//注入Redis参数,从application.properties获得
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port}")
private int port;
/* @Value("${spring.redis.password}")
private String password;*/
@Value("${spring.redis.timeout}")
private int timeout;
@Resource
private RoleService roleService;
/* @Bean(name="shiroDialect")
public ShiroDialect shiroDialect(){
return new ShiroDialect();
}*/
/**
* 开启Shiro注解(如@RequiresRoles,@RequiresPermissions),
* 需借助SpringAOP扫描使用Shiro注解的类,并在必要时进行安全逻辑验证
* 配置以下两个bean(DefaultAdvisorAutoProxyCreator和AuthorizationAttributeSourceAdvisor)
* @return
*/
@Bean
public DefaultAdvisorAutoProxyCreator advisorAutoProxyCreator(){
DefaultAdvisorAutoProxyCreator advisorAutoProxyCreator=new DefaultAdvisorAutoProxyCreator();
advisorAutoProxyCreator.setProxyTargetClass(true);
return advisorAutoProxyCreator;
}
/**
* 开启aop注释支持
*/
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager){
AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor=new AuthorizationAttributeSourceAdvisor();
authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
return authorizationAttributeSourceAdvisor;
}
public RedisManager redisManager(){
RedisManager redisManager=new RedisManager();
redisManager.setHost(host);
redisManager.setPort(port);
/* redisManager.setPassword(password);*/
redisManager.setTimeout(timeout);
return redisManager;
}
public RedisCacheManager cacheManager(){
RedisCacheManager cacheManager=new RedisCacheManager();
cacheManager.setRedisManager(redisManager());
//缓存名称
cacheManager.setPrincipalIdFieldName("usrName");
//缓存有效时间
cacheManager.setExpire(1800);
return cacheManager;
}
//会话操作
public RedisSessionDAO redisSessionDAO(){
RedisSessionDAO sessionDAO=new RedisSessionDAO();
sessionDAO.setRedisManager(redisManager());
return sessionDAO;
}
//会话管理
public DefaultWebSessionManager sessionManager(){
DefaultWebSessionManager sessionManager=new DefaultWebSessionManager();
sessionManager.setSessionDAO(redisSessionDAO());
return sessionManager;
}
@Bean
public MyShiroRealm myShiroRealm(){//自定义Realm
MyShiroRealm shiroRealm=new MyShiroRealm();
//设置启用缓存,并设置缓存名称
shiroRealm.setCachingEnabled(true);
shiroRealm.setAuthorizationCachingEnabled(true);
shiroRealm.setAuthenticationCacheName("authorizationCache");
//设置凭证(密码)匹配器
shiroRealm.setCredentialsMatcher(hashedCredentialsMatcher());
return shiroRealm;
}
@Bean
public SecurityManager securityManager(){//安全管理器SecurityManager
DefaultWebSecurityManager securityManager=new DefaultWebSecurityManager();
//注入Realm
securityManager.setRealm(myShiroRealm());
//注入缓存管理器
securityManager.setCacheManager(cacheManager());
//注入会话管理器
securityManager.setSessionManager(sessionManager());
return securityManager;
}
@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager){//Shiro过滤器:权限认证
ShiroFilterFactoryBean shiroFilterFactory=new ShiroFilterFactoryBean();
//注入SecurityManager
shiroFilterFactory.setSecurityManager(securityManager);
//权限验证:使用Filter控制资源(URL)的访问
shiroFilterFactory.setLoginUrl("/dologin");
shiroFilterFactory.setSuccessUrl("/main");
shiroFilterFactory.setUnauthorizedUrl("/403");//没有权限跳转403页面
Map<String,String> filterChianDefinitionMap=new LinkedCaseInsensitiveMap<String>();//必须使用LinkedHashMap(有序集合)
//配置可以匿名访问的资源(URL):静态资源
filterChianDefinitionMap.put("/css/**","anon");
filterChianDefinitionMap.put("/fonts/**","anon");
filterChianDefinitionMap.put("/images/**","anon");
filterChianDefinitionMap.put("/js/**","anon");
filterChianDefinitionMap.put("/localcss/**","anon");
filterChianDefinitionMap.put("/localjs/**","anon");
filterChianDefinitionMap.put("/login","anon");
filterChianDefinitionMap.put("/logout","logout");//注销过滤器,自动注销
//配置需要特定权限才能访问的资源(URL)
//静态权限:包括全部需要特定权限才能访问的资源(URL)
/* filterChianDefinitionMap.put("/user/list","perms[用户列表]");
filterChianDefinitionMap.put("/user/add","perms[用户添加]");
filterChianDefinitionMap.put("/user/edit","perms[用户修改]");
filterChianDefinitionMap.put("/user/del","perms[用户删除]");*/
//动态授权
List<Right> rights=roleService.findAllRights();
for(Right right:rights){
if(right.getRightUrl()!=null&&!right.getRightUrl().trim().equals("")){
filterChianDefinitionMap.put(right.getRightUrl(),"perms["+right.getRightCode()+"]");
}
}
//配置认证访问,其他资源(URL)必须认证通过才能访问
filterChianDefinitionMap.put("/**","authc");//必须放在过滤器链最后面
shiroFilterFactory.setFilterChainDefinitionMap(filterChianDefinitionMap);
return shiroFilterFactory;
}
}
4.演示测试
登录成功后,访问“用户管理”功能,程序可能会报错,查看后台,错误如下:
提示User不能转换为User,可是两个对象应该是相同的啊,继续查看错误出现位置:
发现是从Shiro中获取主身份凭证转换为User时报错,以前这里都是没有问题的,现在使用缓存后出错了,但是Shiro中获取到的主身份凭证的确是User对象。
经过仔细排查,发现错误的原因是我们的项目使用了热部署,造成类加载器不一致:项目启动时候加载项目当中的类所使用到的加载器是org.springframework boot.detls.restar.lassloader.RestartClassLoader,这是因为之前在项启动时候加载项目当中的类所使用到的加载器
是org.springframework.boot.devtools.restart.classloader.RestartClassoader, 这是因为之前在项目当中引入了spring boot devtools这个热部署包来提高效率。而从Shiro session取对象时所用到的类加载器并不是这个,而是sun.misc.Launcher.AppClassLoader,从而导致类型的转换异常。
问题找到了,解决办法有两个: 1、不使用热部署: 2、保证对引入的jar包使用相同的类加载器。此处我们肯定选择方案2,具体步骤如下:
1、在resources目录下新建META-INF目录
2、META-INF 目录下新建spring-devtools.properties文件
3、spring-devtools.properties 文件中添加以下两条配置信息,表示shiro-redis 和thymeleaf extras-shiro包下的类也使用RestartClassLoader类加载器:
restart.include.shiro-redis=/shiro-[\\w-\\.]+jar
restart.include.thymeleaf-extras-shiro=/thymeleaf-extras-[\\w-\\.]+jar
加密
哈希与盐
如果你需要保存密码( 比如网站用户的密码),你要考虑如何保护这些密码数据,像那样直接将密码写入数据库中是极不安全的,因为任何可以打开数据库的人,都将可以直接看到这些密码,比如之前的600w 优快云账号泄露对用户可能造成很大损失。
解决的办法是将密码加密后再存储进数据库,比较常用的加密方法是使用哈希函数(散列算法),常见的散列算法如MD5 、SHA 等。哈希函数的具体定义,大家可以在网上或者相关书籍中查阅到,简单地说,它的特性如下:
1.原始密码经哈希 函计算后得到-一个哈希值
2.改变原始密码,哈希函数计算出的哈希值也会相应改变
3. 同样的密码,哈希值也是相同的
哈希函数是单向、不可逆的。也就是说从哈希值,你无法推算出原始的密码是多少。
一般进行散列时最好提供一个salt(盐), 加密领域的盐salt,不是炒菜的调料,而是为了提高加密的安全性。不管是对称加密,还是非对称加密,在用户信息和算法可能被泄漏的情况下,都存在密码被反推算出来的可能。在加密环节如果加盐,等于多了一重安全因素。一般来说,盐就是一个不被外界知道的随机字符串,把用户的明文密码加上盐,再进行加密得到密文(密码的加密后的形式)。
比如加密密码"admin",产生的散列值是"21232f297a57a5a743894a0e4a801fc3”,可以到一
些MD5解密网站很容易的通过散列值得到密码"admin",即如果直接对密码进行散列相对来
说破解更容易,此时我们可以加一些只有系统知道的干扰数据,如用户名和ID(即盐);这样散
列的对象是“密码+用户名+ID", 这样生成的散列值相对来说更难破解。
@Test
public void testMd5Hash(){
String password="123456";
String salt="lili";
Md5Hash md5Hash=new Md5Hash(password,salt);
System.out.println(md5Hash.toString());
//输出599b89b44301289555134992cacef601
}
加密与验证
Shiro提供了PasswordService及CredentialsMatcher用于提供加密密码及验证密码服务
public interface PasswordService {
//输入明文密码得到密文密码
String encryptPassword(Object plaintextPassword) throws IllegalArgumentException;
}
public interface CredentialsMatcher {
//匹配用户输入的token的凭证(未加密)与系统提供的凭证(已加密)
boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info);
}
具体实现
1.SimpleCredentialsMatcher
Shiro的所有开箱即用的Realm实现默认使用简单凭证匹配器SimpleCredentialsMatcher,这
意味着默认情况下密码的匹配只是简单的字符串比较。例如Realm的认证方法收到一个
UsernamePasswordToken,简单凭证匹配器会直接用Token中用户提交的密码和数据库的密码进行比较。当然,该匹配器除了支持String类型的比较,还支持char[]、 byte[]、 File 和InputStream等其它类型。
既然简单凭证匹配器SimpleCredentialsMatcher 只是简单的字符串比较,而数据库的密码应
该是经过加密之后的密码,那么UsernamePasswordToken中用户提交的密码也应该是经过加密之后的密码,但是用户在客户端是不可能提交经过加密的密码的,只会是提交明文密码(原始密码),所以我们需要在Controller中将客户端接收到的密码经过加密之后再封装到
UsernamePasswordToken中,然后再提交给SimpleCredentialsMatcher 进行匹配。
1、在UserService中添加加密方法:
@Override
public String encryptPassword(Object plaintextPassword) throws IllegalArgumentException {
String salt="lili";
Md5Hash md5Hash=new Md5Hash(plaintextPassword,salt,2);
return md5Hash.toString();
}
此处使用Md5Hash进行加密,且固定使用”czkt”作为salt,加密2次。本来salt最好是用户
名+ ID作为salt,或是用户名+随机数作为salt,但这样就需要修改数据库,在sys_ _user 表中增加salt字段保存salt值,本次课就不那么麻烦了。另外,在整个系统中,User的添加和修改密码都需要使用userService.encryptPassword()方法对客户端接收的明文密码进行加密之后再存储到数据库中。
2、修改IndexController中的login方法,在封装UsernamePasswordToken时对密码进行加
密:
usrPassword=userService.encryptPassword(usrPassword);
登录次数限制
前面我们已经实现了密码加密和验证,大大提高了系统的安全性。在系统中,还可以对登
录失败次数进行限制。如在1个小时内密码最多重试5次,如果尝试次数超过5次就锁定1
小时,1小时后可再次重试,防止密码被暴力破解。
因为需要将锁定时间控制在一- 个规定时间内,此处可结合Redis 缓存实现,具体实现步骤
如下:
1、添加spring boot-starter data-redis依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
2、修改MyShiroRealm,注入StringRedisTemplate,在doGetAuthenticationInfo()方法中控制
登录次数,超过5次则锁定用户登录:
public class MyShiroRealm extends AuthorizingRealm {
@Resource
private UserService userService;
@Resource
private StringRedisTemplate stringRedisTemplate;
//redis中的数据的key的前缀
private String SHIRO_LOGIN_COUNT="shiro_login_count_";//登录计数
private String SHIRO_IS_LOCK="shiro_is_lock_";//锁定用户登录
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
System.out.println("调用MyShiroRealm.doGetAuthenticationInfo获取身份信息");
//获得身份信息
UsernamePasswordToken token=(UsernamePasswordToken)authenticationToken;
String usrName=token.getUsername();
//每次访问,登录次数+1
ValueOperations<String,String> opsForValue=stringRedisTemplate.opsForValue();
opsForValue.increment(SHIRO_LOGIN_COUNT+usrName,1);
//计数大于5时,设置用户被锁定1分钟,清空登录技术
if(Integer.parseInt(opsForValue.get(SHIRO_LOGIN_COUNT+usrName))>5){
opsForValue.set(SHIRO_LOGIN_COUNT+usrName,"LOCK");
stringRedisTemplate.expire(SHIRO_IS_LOCK+usrName,1, TimeUnit.MINUTES);
stringRedisTemplate.delete(SHIRO_LOGIN_COUNT+usrName);//清空登录计数
}
if("LOCK".equals(opsForValue.get(SHIRO_IS_LOCK+usrName))){
throw new DisabledAccountException();
}
User user=userService.getUserByUsrName(usrName);
if(user==null){
throw new UnknownAccountException();//账号错误
}
if(user.getUsrFlag()==null||user.getUsrFlag().intValue()==0){
throw new LockedAccountException();//账号锁定
}
SimpleAuthenticationInfo info=new SimpleAuthenticationInfo(
user,//身份(根据用户查询数据库获得用户)
user.getUsrPassword(),//凭证(查询数据库获得的密码)
ByteSource.Util.bytes("lili"),//加密使用的salt
getName()//Realm对象的名称
);
//返回身份信息
return info;
}
}
3、修改IndexController,注入StringRedisTemplate,在login()登录方法中当登录成功时清空:
@Controller
public class LoginController {
@Resource
private UserService userService;
@Resource
private StringRedisTemplate stringRedisTemplate;
//redis中数据的key的前缀
private String SHIRO_LOGIN_COUNT="shiro_login_count";
@RequestMapping(value = "/dologin")
public String login(String usrName, String usrPassword, Model model, HttpSession session){
try{
/* usrPassword=userService.encryptPassword(usrPassword);
System.out.println("加密密码为:"+usrPassword);*/
UsernamePasswordToken token=new UsernamePasswordToken(usrName,usrPassword);
Subject subject= SecurityUtils.getSubject();
subject.login(token);//认证、登录
//认证(登录)成功,清空登录计数
stringRedisTemplate.delete(SHIRO_LOGIN_COUNT+usrName);
//认证(登录)成功
User user=(User)subject.getPrincipal();
//获取权限
Role role=user.getRole();
List<Right> rights=roleService.findRightsByRole(role);
role.getRights().addAll(rights);
session.setAttribute("loginUser",user);
return "redirect:/main";
}catch (UnknownAccountException| IncorrectCredentialsException e){
model.addAttribute("error","用户名或密码错误,登录失败!");
return "login";
}catch (LockedAccountException e){
model.addAttribute("error","用户被禁用,登陆失败!");
return "login";
}catch (AuthenticationException e){
model.addAttribute("mag","认证异常,登录失败!");
return "login";
}
}