Spring Boot整合分布式Shiro
1. shiro基础知识
Shiro是Apache下的一个开源项目,我们称之为Apache Shiro。它是一个很易用与Java项目的的安全框架,提供了认证、授权、加密、会话管理,与spring Security 一样都是做一个权限的安全框架,但是与Spring Security 相比,在于 Shiro 使用了比较简单易懂易于使用的授权方式。shiro属于轻量级框架,相对于security简单的多,也没有security那么复杂。
![](https://img.laitimes.com/img/9ZDMuAjOiMmIsIjOiQnIsIiclRnblN2XjlGcjAzNfRHLGZkRGZkRfJ3bs92YsYTMfVmepNHLzUEVPVTS65EeNpHW4Z0MMBjVtJWd0ckW65UbM5WOHJWa5kHT20ESjBjUIF2X0hXZ0xCMx81dvRWYoNHLrdEZwZ1Rh5WNXp1bwNjW1ZUba9VZwlHdssmch1mclRXY39CXldWYtlWPzNXZj9mcw1ycz9WL49zZuBnL2AzNxADN1ADM2IDMxAjMwIzLc52YucWbp5GZzNmLn9Gbi1yZtl2Lc9CX6MHc0RHaiojIsJye.png)
Subject:主体,代表了当前 “用户”,这个用户不一定是一个具体的人,与当前应用交互的任何东西都是 Subject,如网络爬虫,机器人等;即一个抽象概念;所有 Subject 都绑定到 SecurityManager,与 Subject 的所有交互都会委托给 SecurityManager;可以把 Subject 认为是一个门面;SecurityManager 才是实际的执行者;
SecurityManager:安全管理器;即所有与安全有关的操作都会与 SecurityManager 交互;且它管理着所有 Subject;可以看出它是 Shiro 的核心,它负责与后边介绍的其他组件进行交互,如果学习过 SpringMVC,你可以把它看成 DispatcherServlet 前端控制器;
Realm:域,Shiro 从从 Realm 获取安全数据(如用户、角色、权限),就是说 SecurityManager 要验证用户身份,那么它需要从 Realm 获取相应的用户进行比较以确定用户身份是否合法;也需要从 Realm 得到用户相应的角色 / 权限进行验证用户是否能进行操作;可以把 Realm 看成 DataSource,即安全数据源。
2.自定义Reaml获取安全数据
自定义SabRealm继承AuthorizingRealm类,并且重写了认证doGetAuthenticationInfo方法和授权doGetAuthorizationInfo方法。特别注意return new SimpleAuthenticationInfo(user, user.getPassword(), ByteSource.Util.bytes(user.getSalt()), getName());这里的user必须实现Serializer接口,并且这个user对象必须是DTO,不要用PO,否则会出现Session问题。
public class SabRealm extends AuthorizingRealm {
@Autowired
private SabUserService sabUserService;
@Autowired
private ShiroUtils shiroUtils;
/**
* 授权
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) throws AuthorizationException {
SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
//查询用户的角色权限
authorizationInfo.setStringPermissions(shiroUtils.getCurrentUserPermissions());
return authorizationInfo;
}
/**
* 认证
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException{
//获取用户的账号
String loginAccount = (String)authenticationToken.getPrincipal();
SabUserDto user = sabUserService.findUserByLoginAccount(loginAccount);
if(user == null) {
return null;
} else {
return new SimpleAuthenticationInfo(user, user.getPassword(), ByteSource.Util.bytes(user.getSalt()), getName());
}
}
}
3. 自定义SessionManager
在传统的项目中,shiro默认从cookie读取sessionId来维持会话,在分布式环境中,每个JVM会维持自己的session,无法识别其他JVM的session。因此,在分布式环境中,选择将sessionId作为key把session集中保存在redis,JVM通过从请求头中读取登录时返回的sessionId,再根据这个sessionId查询redis读取session,以此实现session共享。因此自定义类SabSessionManager继承DefaultWebSessionManager,并需要重写shiro获取sessionId的方法,该方法位于shiro的session管理器中,只需要自定义session管理器并重写该方法,同时在pom引入的shiro-redis开源工具包,该工具包实现了将session信息保存在redis的方法(shiro默认是保存在内存)。这里返回给浏览器的sessionId的属性是Authorization,登陆以后每次请求只需要如下图所示带上该请求头即可:
<!--pom.xml文件-->
<dependency>
<groupId>org.crazycake</groupId>
<artifactId>shiro-redis</artifactId>
<version>3.2.3</version>
</dependency>
public class SabSessionManager extends DefaultWebSessionManager {
private static final String AUTHORIZATION = "Authorization";
private static final String REFERENCE_SESSION_ID_SOURCE = "Stateless request";
@Override
protected Serializable getSessionId(ServletRequest request, ServletResponse response) {
String id = WebUtils.toHttp(request).getHeader(AUTHORIZATION);
if(!StringUtils.isBlank(id)) {
//如果请求头有“Authorization”参数,则其值为sessionId
//设置请求的属性,并返回该sessionId
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE, REFERENCE_SESSION_ID_SOURCE);
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID, id);
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_IS_VALID, Boolean.TRUE);
return id;
} else {
//如果没有指定参数,则使用默认方法获取sessionId
return super.getSessionId(request, response);
}
}
}
4. 配置shiroConfig
注意这里的RedisManager的host的格式是host+":"+port,例如127.0.0.1:6379,这里的散列算法采用md5,散列2次。
@Configuration
public class ShiroConfig {
private static final int HASH_ITERATIONS = 2;
private static final String HASH_ALGORITHMNAME = "md5";
private static final int SESSION_EXPIRETIME_SECOND = 60 * 60 * 24;
@Value("${redis.host}")
private String host;
@Value("${redis.port}")
private Integer port;
/**
* 配置过滤器
*/
@Bean
public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) throws Exception {
ShiroFilterFactoryBean shiroFilterFactory = new ShiroFilterFactoryBean();
//过滤链接Map
//利用LinkedHashMap顺序访问的特点,authc应该anon在之后,如果是匿名可访问则不再去认证授权
//anon:可以匿名访问
//authc:需要认证授权才能访问
Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
//登陆/注册页面可以匿名访问
filterChainDefinitionMap.put("/sab/user/login", "anon");
filterChainDefinitionMap.put("/sab/user/register", "anon");
//其他所有页面必须认证授权访问
filterChainDefinitionMap.put("/**", "authc");
//退出
filterChainDefinitionMap.put("/sab/user/logout", "logout");
//设置过滤连接map
shiroFilterFactory.setFilterChainDefinitionMap(filterChainDefinitionMap);
//设置安全管理器
shiroFilterFactory.setSecurityManager(securityManager);
//前后端分离项目不通过下面设置跳转,否则会重定向页面
//设置没有登录时访问的页面
//shiroFilterFactory.setLoginUrl("/sab/user/redirect/login");
//设置无权限跳转的页面
//shiroFilterFactory.setUnauthorizedUrl("/sab/user/redirect/unauthorized");
//前后端分离项目设置Filter防止错误的认证信息导致的重定向
LinkedHashMap<String, Filter> filterMap = new LinkedHashMap<>();
filterMap.put(DefaultFilter.authc.toString(), new SabAuthenAndAuthorFilter());
shiroFilterFactory.setFilters(filterMap);
return shiroFilterFactory;
}
/**
* 凭证匹配器
*/
@Bean
public HashedCredentialsMatcher hashedCredentialsMatcher() throws Exception {
HashedCredentialsMatcher matcher = new HashedCredentialsMatcher();
//散列算法,这里使用md5
matcher.setHashAlgorithmName(HASH_ALGORITHMNAME);
//散列次数,这里散列2次
matcher.setHashIterations(HASH_ITERATIONS);
return matcher;
}
/**
* 将自定义Realm设置为Bean,使用自定义凭证匹配值
*/
@Bean
public SabRealm sabRealm() throws Exception{
SabRealm sabRealm = new SabRealm();
sabRealm.setCredentialsMatcher(hashedCredentialsMatcher());
return sabRealm;
}
/**
* 配置shiro redis管理器,自动使用shiro-redis开源插件
*/
@Bean
public RedisManager redisManager() throws Exception {
RedisManager redisManager = new RedisManager();
redisManager.setHost(this.host+":"+this.port);
return redisManager;
}
/**
* sessionId生成器
*/
@Bean
public JavaUuidSessionIdGenerator sessionIdGenerator() throws Exception {
return new JavaUuidSessionIdGenerator();
}
/**
* 配置redis session DAO,为其配置redisManager和session生成器以及过期时间
*/
@Bean
public RedisSessionDAO redisSessionDAO() throws Exception {
RedisSessionDAO redisSessionDAO = new RedisSessionDAO();
redisSessionDAO.setRedisManager(redisManager());
redisSessionDAO.setSessionIdGenerator(sessionIdGenerator());
//session在redis中的保存时间,单位:秒
redisSessionDAO.setExpire(SESSION_EXPIRETIME_SECOND);
return redisSessionDAO;
}
/**
* 配置session管理器,使用redis session DAO
*/
@Bean
public SessionManager sessionManager() throws Exception {
SabSessionManager sessionManager = new SabSessionManager();
sessionManager.setSessionDAO(redisSessionDAO());
//全局session超时时间,单位:毫秒
sessionManager.setGlobalSessionTimeout(SESSION_EXPIRETIME_SECOND * 1000L);
//这个配置很重要,防止登录返回相同sessionId
sessionManager.setSessionIdCookieEnabled(false);
return sessionManager;
}
/**
* 配置redis缓存管理器
*/
@Bean
public RedisCacheManager redisCacheManager() throws Exception {
RedisCacheManager redisCacheManager = new RedisCacheManager();
redisCacheManager.setRedisManager(redisManager());
//设置缓存类对象的主键字段
redisCacheManager.setPrincipalIdFieldName("id");
//设置访问缓存时间,单位:秒
redisCacheManager.setExpire(SESSION_EXPIRETIME_SECOND);
return redisCacheManager;
}
/**
* 配置安全管理器
*/
@Bean
public SecurityManager securityManager() throws Exception {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
//使用自定义Realm
securityManager.setRealm(sabRealm());
//使用自定义session管理器
securityManager.setSessionManager(sessionManager());
//使用自定义缓存管理器
securityManager.setCacheManager(redisCacheManager());
return securityManager;
}
/**
* 开启shiro的注解
*/
@Bean
public DefaultAdvisorAutoProxyCreator advisorAutoProxyCreator() throws Exception {
DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
defaultAdvisorAutoProxyCreator.setProxyTargetClass(Boolean.TRUE);
return defaultAdvisorAutoProxyCreator;
}
/**
* 开启shiro的注解
*/
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor() throws Exception {
AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
authorizationAttributeSourceAdvisor.setSecurityManager(securityManager());
return authorizationAttributeSourceAdvisor;
}
}
5. 新建用户时的密码加密
加密过程必须和解密过程匹配
//加密工具类
public class GeneratedValueUtils {
public static String uuid() {
return UUID.randomUUID().toString().replace("-", "");
}
public static String salt(Object pwd, String salt) {
return new SimpleHash("MD5", pwd, salt, 2).toString();
}
}
//密码加密过程,略去user的其他属性配置
String salt = GeneratedValueUtils.uuid();
String pwd = "qq123456";
String encodedPwd = GeneratedValueUtils.salt(pwd, salt);
user.setSalt(salt);
user.setPassword(encodedPwd);
6. 用户登录/登出业务方法
@Autowired
private ShiroUtils shiroUtils;
public UserResponse login(LoginRequest request) throws BaseException {
//获取用户实例
Subject currentUser = SecurityUtils.getSubject();
//将用户名和密码封装到UserNamePasswordToken
UsernamePasswordToken token = new UsernamePasswordToken(request.getAccount(), request.getPassword());
token.setRememberMe(false);
UserResponse response = new UserResponse();
try {
//调用shiro的login方法,里面会调用SabRealm的方法进行认证
currentUser.login(token);
} catch (UnknownAccountException e) {
throw new BaseException(ExceptionCode.LOGIN_USER_NOT_FOUNT);
} catch (IncorrectCredentialsException e) {
throw new BaseException(ExceptionCode.LOGIN_USER_PASSWORD_WRONG);
} catch (AuthenticationException e) {
throw new BaseException(ExceptionCode.LOGIN_USER_AUTHENTICATION);
} catch (Exception e) {
throw new BaseException(ExceptionCode.LOGIN_FAILED);
}
//设置权限缓存
shiroUtils.setRedisCache();
SabUserDto user = shiroUtils.getCurrentUser();
String sessionId = shiroUtils.getCurrentSessionId();
//设置返回对象的属性
BeanUtils.copyProperties(user, response);
response.setSessionId(sessionId);
return response;
}
public void logout() throws BaseException {
//TODO 日志
shiroUtils.logout();
}
7. 统一异常拦截
BaseException和SQLException是我的系统的自定义异常,UnauthenticatedException和UnauthorizedException是shiro的未认证和未授权异常,BaseController是我的自定义controller,包含了封装返回结果的方法buildResponse。
@ControllerAdvice
public class GlobalExceptionHandler extends BaseController {
@ResponseStatus(HttpStatus.FORBIDDEN)
@ResponseBody
@ExceptionHandler(value = UnauthenticatedException.class)
public JSONObject handleUnAuthorizedException(UnauthenticatedException e) {
return buildResponse(ExceptionCode.UNAUTHENTICATED, null);
}
@ResponseStatus(HttpStatus.FORBIDDEN)
@ResponseBody
@ExceptionHandler(value = UnauthorizedException.class)
public JSONObject handleUnAuthorizedException(UnauthorizedException e) {
return buildResponse(ExceptionCode.UNAHTUORIZED, null);
}
@ResponseStatus(HttpStatus.EXPECTATION_FAILED)
@ResponseBody
@ExceptionHandler(value = BaseException.class)
public JSONObject handleBaseException(BaseException e) {
return buildResponse(e.getCode(), e.getMsg(), null);
}
@ResponseStatus(HttpStatus.EXPECTATION_FAILED)
@ResponseBody
@ExceptionHandler(value = SQLException.class)
public JSONObject handleSQLException(SQLException e) {
return buildResponse(e.getCode(), e.getMsg(), null);
}
@ResponseStatus(HttpStatus.EXPECTATION_FAILED)
@ResponseBody
@ExceptionHandler(value = Exception.class)
public JSONObject handleException(Exception e) {
return buildResponse(ExceptionCode.FAIL, null);
}
}
8.更新角色或者权限时,删除用户的缓存,让用户重新登录
注意,spring boot 2.2.x以后的版本调用redisSessionDAO.getActiveSessions()出现java.lang.NoSuchMethodError: redis.clients.jedis.ScanResult.getStringCursor()的错误。
原因是spring boot 2.2.x以后的版本使用的jedis和shiro-redis使用的jedis版本有冲突/BUG,
我暂时将spring boot的版本降到2.1.16,可以避免这个问题。可能更换较低版本的jedis也可以解决问题。
或者引用大神的包,解决问题,本文于此附上链接
解决edisSessionDAO.getActiveSessions()出现java.lang.NoSuchMethodError: redis.clients.jedis.ScanResult.getStringCursor()
@Component
public class ShiroUtils {
private final Integer EXPIRE_TIME_HOURS = 24 ;
@Autowired
private RedisSessionDAO redisSessionDAO;
@Autowired
private RedissonClient redissonClient;
public SabUserDto getCurrentUser() throws BaseException {
Subject currentUser = SecurityUtils.getSubject();
return (SabUserDto) currentUser.getPrincipals().getPrimaryPrincipal();
}
public Set<String> getCurrentUserPermissions() throws BaseException {
SabUserDto currentUser = getCurrentUser();
RMapCache<String, Set<String>> userPermissionsMap = redissonClient.getMapCache(IConstant.USER_PERMISSIONS);
return userPermissionsMap.get(currentUser.getId());
}
public void logout() throws BaseException {
Subject currentUser = SecurityUtils.getSubject();
//登出
currentUser.logout();
}
/**
* 用户权限更新后,清除缓存中的用户权限信息
* @param uid
* @throws BaseException
*/
public void clearUserCache(String uid) throws BaseException {
Collection<Session> sessions = redisSessionDAO.getActiveSessions();
for(Session session : sessions) {
Object attribute = session.getAttribute(DefaultSubjectContext.PRINCIPALS_SESSION_KEY);
if(attribute != null) {
SimplePrincipalCollection attrCollection = (SimplePrincipalCollection) attribute;
SabUserDto sabUserDto = (SabUserDto) attrCollection.getPrimaryPrincipal();
if(sabUserDto != null) {
if(uid.equals(sabUserDto.getId())) {
//删除session缓存
redisSessionDAO.delete(session);
//清除对象缓存
DefaultWebSecurityManager securityManager = (DefaultWebSecurityManager) SecurityUtils.getSecurityManager();
Authenticator authc = securityManager.getAuthenticator();
((LogoutAware)authc).onLogout(attrCollection);
//清除权限缓存
RMapCache<String, Set<String>> userPermissionsMap = redissonClient.getMapCache(IConstant.USER_PERMISSIONS);
userPermissionsMap.removeAsync(sabUserDto.getId());
}
}
}
}
}
public String getCurrentSessionId() throws BaseException {
Session session = SecurityUtils.getSubject().getSession();
return session.getId().toString();
}
public void setRedisCache() throws BaseException {
SabUserDto user = this.getCurrentUser();
//将用户的权限存进redis
RMapCache<String, Set<String>> userPermissionsMap = redissonClient.getMapCache(IConstant.USER_PERMISSIONS);
Set<String> permissionSet = new HashSet<>();
List<SabRoleDto> roles = user.getRoles();
if(!CollectionUtils.isEmpty(roles)) {
for(SabRoleDto role : roles) {
List<SabAuthorizationDto> authorizations = role.getAuthorizations();
if(!CollectionUtils.isEmpty(authorizations)) {
for(SabAuthorizationDto authorization : authorizations) {
permissionSet.add(authorization.getName()+":"+authorization.getId());
}
}
}
}
userPermissionsMap.put(user.getId(), permissionSet, EXPIRE_TIME_HOURS, TimeUnit.HOURS);
}
}
9.测试权限拦截
这个 user_management:2是自定义Realm中authorizationInfo.addStringPermission(authorization.getName()+":"+authorization.getId());这里加进去的值,如果有,则认为有该权限,无则认为没有改权限。还有的注解可以根据角色来判断是否有权限进入方法,这里就不写了。
@RequiresPermissions("user_management:2")
@PostMapping("")
public JSONObject createUser(@RequestBody CreateOrUpdateUserRequest request) throws BaseException {
return buildResponse(sabUserComponent.createUser(request));
}
10. pom补充
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>1.5.3</version>
</dependency>
11. 用户Controller补充
@RestController
@RequestMapping("/sab/user")
public class SabUserController extends BaseController {
@Autowired
private SabUserComponent sabUserComponent;
@RequiresPermissions("user_management:2")
@PostMapping("")
public JSONObject createUser(@RequestBody CreateOrUpdateUserRequest request) throws BaseException {
return buildResponse(sabUserComponent.createUser(request));
}
@RequiresPermissions("user_management:2")
@PutMapping("")
public JSONObject updateUser(@RequestBody CreateOrUpdateUserRequest request) throws BaseException {
sabUserComponent.updateUser(request);
return buildResponse();
}
@DeleteMapping("")
public JSONObject deleteUser(@RequestParam String id) throws BaseException {
sabUserComponent.deleteUser(id);
return buildResponse();
}
@RequiresPermissions("user_management:2")
@GetMapping("")
public JSONObject getUser(@RequestParam String id) throws BaseException {
return buildResponse(sabUserComponent.getUser(id));
}
@RequiresPermissions("user_management:2123")
@GetMapping("/list")
public JSONObject getUserList(@RequestParam(required = false) Boolean gender,
@RequestParam(required = false) String keyword) throws BaseException {
QueryUserRequest userRequest = new QueryUserRequest();
userRequest.setGender(gender);
userRequest.setKeyword(keyword);
return buildResponse(sabUserComponent.getUserList(userRequest));
}
@PostMapping("/login")
public JSONObject login(@RequestBody LoginRequest request) throws BaseException {
return buildResponse(sabUserComponent.login(request));
}
@PostMapping("/logout")
public JSONObject logout() throws BaseException {
sabUserComponent.logout();
return buildResponse();
}
@GetMapping("/current")
public JSONObject getCurrentUserInfo() throws BaseException {
return buildResponse(sabUserComponent.getCurrentUserInfo());
}
}
12.防止认证失败发生重定向的Filter类补充
/**
* authc 认证授权过滤器
*/
public class SabAuthenAndAuthorFilter extends UserFilter {
/**
* 当不带认证消息,shiro内部可以抛出UNAUTHENTICATED异常
* 当带错误的认证信息,需要这里加上拒绝访问的返回消息替代父类重定向的操作
* @param request
* @param response
* @return
* @throws Exception
*/
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
HttpServletResponse resp = (HttpServletResponse) response;
resp.setContentType("application/json; charset=utf-8");//以ajax形式返回
PrintWriter out = resp.getWriter();
Map<String, Object> responseMap = new HashMap<>();
responseMap.put("code", ExceptionCode.UNAUTHENTICATED.getCode());
responseMap.put("msg", ExceptionCode.UNAUTHENTICATED.getMsg());
responseMap.put("data", null);
responseMap.put("time", System.currentTimeMillis());
out.write(JSONObject.toJSONString(responseMap)); // 返回自己的json
out.flush();
out.close();
return false;
}
}