天天看点

Spring Boot整合分布式Shiro

Spring Boot整合分布式Shiro

1. shiro基础知识

Shiro是Apache下的一个开源项目,我们称之为Apache Shiro。它是一个很易用与Java项目的的安全框架,提供了认证、授权、加密、会话管理,与spring Security 一样都是做一个权限的安全框架,但是与Spring Security 相比,在于 Shiro 使用了比较简单易懂易于使用的授权方式。shiro属于轻量级框架,相对于security简单的多,也没有security那么复杂。

Spring Boot整合分布式Shiro

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;
    }
}