shiro是一个功能强大,简单的安全框架。对传统的单机系统支持较好,但与微服务整合后比较麻烦,网上资料比较散乱。本文主要介绍我做这一块儿的方法以及遇到的一些坑。
微服务架构下的权限认证方案最简单的是分布式session,前端去登录认证模块请求登录,登录成功后shiro会生成session并将sessionId返回前端,session中包含用户基本信息及权限信息。shiro会将session放入redis中供其他服务查看。
基本思路有了,接下来是实现步骤,
首先引入shiro相关依赖
org.apache.shiro
shiro-spring
1.3.2
shiro-quartz
org.apache.shiro
org.crazycake
shiro-redis
2.4.2.1-RELEASE
shiro的核心部分,包含认证和授权逻辑,此realm放在公共模块,便于其他模块授权。
/**
* 公共授权realm域
*/
public class RealmCommon extends AuthorizingRealm {
@Override
public void setName(String name) {
super.setName("RealmCommon");
}
/**
* 只重写授权方法
* @param principalCollection 身份信息集合
* @return 授权信息
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
//1.获取认证的用户数据 | devtools冲突导致无法强转,需更改类加载器:resources/META-INF/spring-devtools.properties
UserEntity user = (UserEntity)principalCollection.getPrimaryPrincipal();
//2.构造认证数据
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
Set roles = user.getRoleList();
if (CollectionUtils.isEmpty(roles)) {
//用户没有角色
throw new AuthorizationException();
}
for (RoleEntity role:roles){
//添加角色信息
info.addRole(role.getRoleName());
//角色权限
Set permissions = role.getPermissions();
for (PermissionEntity permissionEntity : permissions) {
info.addStringPermission(permissionEntity.getPermissionname());
}
}
return info;
}
/**
* 认证方法在登录模块中补全
* @param authenticationToken
* @return
* @throws AuthenticationException
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
return null;
}
}
这里有个坑,如果项目中引入了spring-boot-devtools会发生报错
java.lang.ClassCastException: com.common.pojo.UserEntity cannot be cast to com.common.pojo.UserEntity
同类型无法强转。原因是shiro-redis使用的类加载器与其他类的类加载器不同,要解决这个问题有两种办法。
1).直接移除devtools依赖
2).让所有类的类加载器为同一个:在common下创建 resources/META-INF/spring-devtools.properties
,修改热部署配置。
restart.include.shiro-redis=/shiro-[\w-\.]+jar
自定义session管理器,指定sessionid生成方式
/**
* 自定义sessionManager
*/
public class CommonWebSessionManager extends DefaultWebSessionManager {
private static final String AUTHORIZATION = "Authorization";
public CommonWebSessionManager(){
super();
}
@Override
protected Serializable getSessionId(ServletRequest request, ServletResponse response){
String id = WebUtils.toHttp(request).getHeader(AUTHORIZATION);
if (StringUtils.isEmpty(id)){
//如果没有携带id参数则按照父类的方式在cookie进行获取
return super.getSessionId(request,response);
}else {
//如果请求头中有 authToken 则其值为sessionId
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE,"header");
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID,id);
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_IS_VALID,Boolean.TRUE);
return id;
}
}
}
自定义认证过滤器,由于shiro本来是支持传统系统的,若未登录则会默认跳到内置的login.jsp,现在项目大多采用前后端分离模式,因此需要重写过滤器,返回未登录信息给前端,由前端实现跳转。即使后端指定到前端的登录页面,也会产生许多坑。
/**
* 自定义过滤器,处理shiro重定向问题
* @author sunqiyan
*/
public class CustomAuthenticationFilter extends FormAuthenticationFilter {
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
return super.isAccessAllowed(request, response, mappedValue);
}
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
Subject subject = SecurityUtils.getSubject();
Object principal = subject.getPrincipal();
if (ObjectUtils.isEmpty(principal)) {
Map map = ResultUtil.genResult(ResultUtil.Status.NOT_LOGIN, "未登录");
httpServletResponse.setCharacterEncoding("UTF-8");
httpServletResponse.setContentType("application/json");
httpServletResponse.getWriter().write(JSONObject.toJSONString(map, SerializerFeature.WriteMapNullValue));
}
return false;
}
}
接下来是公共的shiro配置类
/**
* shiro配置类
*/
public class ShiroConfig {
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port}")
private int port;
@Value("${spring.redis.password}")
private String password;
/**
* 自定义realm
* @return
*/
@Bean
public RealmCommon getRealm() {
return new RealmCommon();
}
/**
* 安全管理器
* @param realm realm域
* @return SecurityManager
*/
@Bean
public SecurityManager securityManager(RealmCommon realm) {
//默认安全管理器
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(realm);
//将自定义的realm交给安全管理器管理
securityManager.setRealm(realm);
//自定义session管理器
securityManager.setSessionManager(sessionManager());
//自定义缓存实现
securityManager.setCacheManager(cacheManager());
return securityManager;
}
/**
* shiro过滤器工厂
* @param securityManager
* @return
*/
@Bean
public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) {
//shiro过滤器工厂
ShiroFilterFactoryBean filterFactory = new ShiroFilterFactoryBean();
//设置安全管理器
filterFactory.setSecurityManager(securityManager);
LinkedHashMap filterMap = new LinkedHashMap<>();
//自定义认证过滤器
filterMap.put("auth",new CustomAuthenticationFilter());
filterFactory.setFilters(filterMap);
//设置过滤链
Map filterChainMap = new LinkedHashMap<>();
//anon 游客即可访问
filterChainMap.put("/css/**","anon");
filterChainMap.put("/js/**","anon");
filterChainMap.put("/image/**","anon");
filterChainMap.put("favicon.ico","anon");
//authc 需经过验证才能访问 auth自定义的过滤策略
filterChainMap.put("/**","auth");
filterFactory.setFilterChainDefinitionMap(filterChainMap);
return filterFactory;
}
/**
* 开启shiro aop注解支持
* @param securityManager
* @return
*/
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
advisor.setSecurityManager(securityManager);
return advisor;
}
@Bean
public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator(){
DefaultAdvisorAutoProxyCreator advisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
advisorAutoProxyCreator.setProxyTargetClass(true);
return advisorAutoProxyCreator;
}
/**
* redis管理器
* @return
*/
public RedisManager redisManager(){
RedisManager redisManager = new RedisManager();
//设置redis ip 端口 密码
redisManager.setHost(host);
redisManager.setPort(port);
redisManager.setPassword(password);
return redisManager;
}
/**
* 配置redis缓存管理器,用户、角色、权限实体类需序列化
* @return
*/
public RedisCacheManager cacheManager() {
RedisCacheManager redisCacheManager = new RedisCacheManager();
//设置redis管理器
redisCacheManager.setRedisManager(redisManager());
return redisCacheManager;
}
/**
* redisSessiondao,实现redis的增删改查,交给shiro管理,shiro使用的是jedis
* 也可自定义
* @return
*/
public RedisSessionDAO redisSessionDAO() {
RedisSessionDAO redisSessionDAO = new RedisSessionDAO();
redisSessionDAO.setRedisManager(redisManager());
return redisSessionDAO;
}
/**
* session管理器
* @return
*/
public DefaultWebSessionManager sessionManager(){
CommonWebSessionManager sessionManager = new CommonWebSessionManager();
sessionManager.setSessionDAO(redisSessionDAO());
//设置session超时时间(单位毫秒),设置为-1000L永不过期
sessionManager.setGlobalSessionTimeout(1000*60*30);
//删除过期的session
sessionManager.setDeleteInvalidSessions(true);
//定时检查session
sessionManager.setSessionValidationSchedulerEnabled(true);
//可自定义sessionId
//sessionManager.setSessionIdCookie(new SimpleCookie("fs_session"));
return sessionManager;
}
}
登录模块添加realm实现认证
public class CustomRealm extends CommonRealm {
@Autowired
private UserService userService;
@Override
public void setName(String name) {
super.setName("customRealm");
}
/**
* 认证匹配用户是否存在
* @param authenticationToken shiro subject的认证信息
* @return 认证成功
* @throws AuthenticationException 认证失败
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
//1.获取登录的token
UsernamePasswordToken token = (UsernamePasswordToken) authenticationToken;
//2.获取用户名
String username = token.getUsername();
if (StringUtils.isBlank(username)) {
//账户异常
throw new AccountException("用户名不能为空");
}
//3.数据库查询用户
UserEntity userEntity = this.userService.queryUserByName(username);
if (userEntity == null) {
throw new UnknownAccountException();
}
if (userEntity.getStatus()!=1) {
//用户锁定
throw new LockedAccountException();
}
return new SimpleAuthenticationInfo(userEntity,userEntity.getPassword(),this.getName());
}
}
由于项目中需要实现异地登录顶出功能,因此需要自定义匹配器实现认证逻辑。gai
/**
* 自定义验证器
*/
@Component
public class CustomCredentialsMatcher extends SimpleCredentialsMatcher {
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 最大重试次数
*/
@Value("#{'${cus.matcher.maxRetryNum:5}'}")
private int maxRetryNum;
/**
*超时时间
*/
@Value("#{'${cus.matcher.timeOutNum:20}'}")
private int timeOutNum;
/**
* redis键
*/
private static final String PREFIX = "LOGIN_ERROR:";
@Override
public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) {
//获取token中的用户名密码
UsernamePasswordToken token1 = (UsernamePasswordToken) token;
String username = token1.getUsername();
String password = new String(token1.getPassword());
//获取凭证中的信息
UserEntity user = (UserEntity)info.getPrincipals().getPrimaryPrincipal();
String infoPassword = getCredentials(info).toString();
//失败次数初始化
AtomicInteger errorNum = new AtomicInteger(0);
String o = redisTemplate.opsForValue().get(PREFIX + username);
if (StringUtils.isNotBlank(o)){
errorNum = new AtomicInteger(Integer.parseInt(o));
}
//失败次数超标
if (errorNum.get() >=maxRetryNum) {
throw new ExcessiveAttemptsException();
}
//密码校验
boolean match = infoPassword.equals(password);
if (match) {
//登录成功,删除缓存
redisTemplate.delete(PREFIX+username);
DefaultWebSecurityManager securityManager = (DefaultWebSecurityManager) SecurityUtils.getSecurityManager();
DefaultWebSessionManager sessionManager = (DefaultWebSessionManager) securityManager.getSessionManager();
//异地登录顶出
//获取在线的session,判断登录用户是否已存在 | shiro分布式session弊端,影响性能
Collection sessions = sessionManager.getSessionDAO().getActiveSessions();
for (Session session:sessions) {
//强转为SimplePrincipalCollection
SimplePrincipalCollection attribute = (SimplePrincipalCollection)session.getAttribute(DefaultSubjectContext.PRINCIPALS_SESSION_KEY);
if (ObjectUtils.isEmpty(attribute)) {
continue;
}
UserEntity userEntity = (UserEntity) attribute.getPrimaryPrincipal();
if (user.getUserId()==userEntity.getUserId()){
//session中存在用户则删除
sessionManager.getSessionDAO().delete(session);
}
}
}else {
//设置超时时间,到时自动解锁
redisTemplate.opsForValue().set(PREFIX+username,errorNum.incrementAndGet()+"",timeOutNum, TimeUnit.MINUTES);
throw new IncorrectCredentialsException();
}
return match;
}
}
此处实现挤出功能的方法是遍历session,用户少的情况下还行,用户多的话会影响性能,暂时没有想到解决办法。
匹配器完成后需要在登录模块的shiroConfig中设置:
/**
* 自定义匹配器
*/
@Bean(name = "credentialsMatcher")
public CredentialsMatcher customCredentialsMatcher(){
return new CustomCredentialsMatcher();
}
/**
* 自定义realm
* @return
*/
@Bean
public CommonRealm getRealm() {
CommonRealm customRealm = new CustomRealm();
customRealm.setCredentialsMatcher(customCredentialsMatcher());
return customRealm;
}
其他模块只需要授权功能,每个模块继承公共模块的shiroConfig即可。
shiro与springcloud整合还有个坑,就是使用feign远程调用时,feign默认会过滤到cookie,导致远程调用失败。失败返回值还不为空,而是正常的对象,对象里的属性都为空,直接跳过了判空操作,这就很麻烦。因此,需要自定义个拦截器,在远程调用时将cookie设置进请求里
/**
* 公共拦截器,处理feign远程调用过滤cookie问题
*/
public class FeignCookieInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate requestTemplate) {
if (null == getHttpServletRequest()){
return;
}
requestTemplate.header("Cookie",getHttpServletRequest().getHeader("Cookie"));
}
private HttpServletRequest getHttpServletRequest(){
try{
return ((ServletRequestAttributes)RequestContextHolder.getRequestAttributes()).getRequest();
}catch (Exception e){
return null;
}
}
}
此拦截器也可放入公共模块中,在其他模块使用是注入即可。
因为做登录功能时要求把登录失败的原因记录到日志中,因此需要捕获各种异常,需要捕获的异常有以下几类
ExcessiveAttemptsException 操作频繁异常
LockedAccountException 账户锁定异常
IncorrectCredentialsException 密码错误异常
UnknownAccountException 未知账户异常
UnknownSessionException
未知session异常,该异常本来是判断session是否存在,
由于在做异地登录功能时直接把session删除了,因此账户被顶出会抛出该异常。
也可以不把session删除,设置session立马过期,但是我没看到效果,只好暴力删除了。
以上就是shiro+springcloud的整合过程,感觉shiro对微服务的支持不是太好。
shiro可以使用注解控制权限,但是注解的value不支持动态获取,后期万一该角色或权限会比较麻烦,暂时没找到解决办法。
shiro的注解也不支持加在类上,这也是比较坑的点。
总的来说,shiro用起来还是比较简单的,不过个人认为分布式系统还是用其他方案好些,当然大佬也可以尝试修改源码o( ̄︶ ̄)o.