Spring Boot + Spring Cloud 实现权限管理系统 后端篇(十一):集成 Shiro 框架

Apache Shiro

优势特点

它是一个功能强大、灵活的,优秀开源的安全框架。

它可以处理身份验证、授权、企业会话管理和加密。

它易于使用和理解,相比Spring Security入门门槛低。

主要功能

  • 验证用户身份
  • 用户访问权限控制
  • 支持单点登录(SSO)功能
  • 可以响应认证、访问控制,或Session事件
  • 支持提供“Remember Me”服务
  • 。。。

框架体系

Shiro 的整体框架如下图所示:

Authentication(认证), Authorization(授权), Session Management(会话管理), Cryptography(加密)被 Shiro 框架的开发团队称之为应用安全的四大基石。

它们分别是:

  • Authentication(认证):用户身份识别,通常被称为用户“登录”。
  • Authorization(授权):访问控制。比如某个用户是否具有某个操作的使用权限。
  • Session Management(会话管理):特定于用户的会话管理,甚至在非web 应用程序。
  • Cryptography(加密):在对数据源使用加密算法加密的同时,保证易于使用。

除此之外,还有其他的功能来支持和加强这些不同应用环境下安全领域的关注点。特别是对以下的功能支持:

  • Web支持:Shiro 提供的 web 支持 api ,可以很轻松的保护 web 应用程序的安全。
  • 缓存:缓存是 Apache Shiro 保证安全操作快速、高效的重要手段。
  • 并发:Apache Shiro 支持多线程应用程序的并发特性。
  • 测试:支持单元测试和集成测试,确保代码和预想的一样安全。
  • “Run As”:这个功能允许用户假设另一个用户的身份(在许可的前提下)。
  • “Remember Me”:跨 session 记录用户的身份,只有在强制需要时才需要登录。

主要流程

在概念层,Shiro 架构包含三个主要的理念:Subject,SecurityManager 和 Realm。下面的图展示了这些组件如何相互作用,我们将在下面依次对其进行描述。

  • Subject:当前用户,Subject 可以是一个人,但也可以是第三方服务、守护进程帐户、时钟守护任务或者其它–当前和软件交互的任何事件。
  • SecurityManager:管理所有Subject,SecurityManager 是 Shiro 架构的核心,配合内部安全组件共同组成安全伞。
  • Realms:用于进行权限信息的验证,我们自己实现。Realm 本质上是一个特定的安全 DAO:它封装与数据源连接的细节,得到Shiro 所需的相关的数据。在配置 Shiro 的时候,你必须指定至少一个Realm 来实现认证(authentication)和/或授权(authorization)。

我们需要实现Realms的Authentication 和 Authorization。其中 Authentication 是用来验证用户身份,Authorization 是授权访问控制,用于对用户进行的操作授权,证明该用户是否允许进行当前操作,如访问某个链接,某个资源文件等。

以上描述摘抄自纯洁的微笑博客文章,更多详情可以参考:

Shiro 官网:http://shiro.apache.org/

纯洁的微笑:http://www.ityouknow.com/springboot/2017/06/26/springboot-shiro.html

Shiro 集成

下面就来讲解如何在我们的项目里集成 Shiro 框架。

引入依赖

首先上 maven 仓库查找,当前最新的版本是 1.4.0,我们就用这个版本。

kitty-pom/pom.xml 父POM中添加属性和 dependencyManagement 依赖

<shiro.version>1.4.0</shiro.version>
<!-- shiro -->
<dependency>
    <groupId>org.apache.shiro</groupId>
     <artifactId>shiro-spring</artifactId>
    <version>${shiro.version}</version>
</dependency>

kitty-admin/pom.xml 添加 dependencies 依赖

<!-- shiro -->
<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-spring</artifactId>
</dependency>

同理,把后续要用到的几个工具包也导入进来。

<!-- fastjson -->
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>${fastjson.version}</version>
</dependency>
<!-- commons -->
<dependency> <groupId>commons-lang</groupId> <artifactId>commons-lang</artifactId> <version>${commons.lang.version}</version> </dependency> <dependency> <groupId>commons-fileupload</groupId> <artifactId>commons-fileupload</artifactId> <version>${commons.fileupload.version}</version> </dependency> <dependency> <groupId>commons-io</groupId> <artifactId>commons-io</artifactId> <version>${commons.io.version}</version> </dependency> <dependency> <groupId>commons-codec</groupId> <artifactId>commons-codec</artifactId> <version>${commons.codec.version}</version> </dependency>

添加配置

 1. 添加配置类

添加配置类,注入自定义的认证过滤器(OAuth2Filter)和认证器(OAuth2Realm),并添加请求路径拦截配置。

ShiroConfig.java

package com.louis.kitty.boot.config;

import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;

import javax.servlet.Filter;

import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.realm.Realm;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import com.louis.kitty.admin.oauth2.OAuth2Filter;
import com.louis.kitty.admin.oauth2.OAuth2Realm;

/**
 * Shiro 配置
 * @author Louis
 * @date Sep 1, 2018
 */
@Configuration
public class ShiroConfig {
    
    @Bean
    public ShiroFilterFactoryBean shirFilter(SecurityManager securityManager) {
        ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean();
        shiroFilter.setSecurityManager(securityManager);
        // 自定义 OAuth2Filter 过滤器,替代默认的过滤器
        Map<String, Filter> filters = new HashMap<>();
        filters.put("oauth2", new OAuth2Filter());
        shiroFilter.setFilters(filters);
        // 访问路径拦截配置,"anon"表示无需验证,未登录也可访问
        Map<String, String> filterMap = new LinkedHashMap<>();
        filterMap.put("/webjars/**", "anon");
        // 查看SQL监控(druid)
        filterMap.put("/druid/**", "anon");
        // 首页和登录页面
        filterMap.put("/", "anon");
        filterMap.put("/sys/login", "anon"); 
        // swagger
        filterMap.put("/swagger-ui.html", "anon");
        filterMap.put("/swagger-resources", "anon");
        filterMap.put("/v2/api-docs", "anon");
        filterMap.put("/webjars/springfox-swagger-ui/**", "anon");
        // 其他所有路径交给OAuth2Filter处理
        filterMap.put("/**", "oauth2");
        shiroFilter.setFilterChainDefinitionMap(filterMap);
        return shiroFilter;
    }

    @Bean
    public Realm getShiroRealm(){
        OAuth2Realm myShiroRealm = new OAuth2Realm();
        return myShiroRealm;
    }

    @Bean
    public SecurityManager securityManager(){
        DefaultWebSecurityManager securityManager =  new DefaultWebSecurityManager();
        // 注入 Realm 实现类,实现自己的登录逻辑
        securityManager.setRealm(getShiroRealm());
        return securityManager;
    }
}

2. 认证过滤器

拦截除配置成不需认证的请求路径外的请求,都交由这个过滤器处理,负责接收前台带过来的token并封装成对象,如果请求没有携带token,则提示错误。

OAuth2Filter.java

package com.louis.kitty.admin.oauth2;

import java.io.IOException;

import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.web.filter.authc.AuthenticatingFilter;

import com.alibaba.fastjson.JSONObject;
import com.louis.kitty.common.utils.StringUtils;
import com.louis.kitty.core.http.HttpResult;
import com.louis.kitty.core.http.HttpStatus;


/**
 * Oauth2过滤器
 * @author Louis
 * @date Sep 1, 2018
 */
public class OAuth2Filter extends AuthenticatingFilter {

    @Override
    protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) throws Exception {
        // 获取请求token
        String token = getRequestToken((HttpServletRequest) request);
        if(StringUtils.isBlank(token)){
            return null;
        }
        return new OAuth2Token(token);
    }

    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
        return false;
    }

    @Override
    protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
        // 获取请求token,如果token不存在,直接返回401
        String token = getRequestToken((HttpServletRequest) request);
        if(StringUtils.isBlank(token)){
            HttpServletResponse httpResponse = (HttpServletResponse) response;
            HttpResult result = HttpResult.error(HttpStatus.SC_UNAUTHORIZED, "invalid token");
            String json = JSONObject.toJSONString(result);
            httpResponse.getWriter().print(json);
            return false;
        }
        return executeLogin(request, response);
    }

    @Override
    protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response) {
        HttpServletResponse httpResponse = (HttpServletResponse) response;
        httpResponse.setContentType("application/json; charset=utf-8");
        try {
            // 处理登录失败的异常
            Throwable throwable = e.getCause() == null ? e : e.getCause();
            HttpResult result = HttpResult.error(HttpStatus.SC_UNAUTHORIZED, throwable.getMessage());
            String json = JSONObject.toJSONString(result);
            httpResponse.getWriter().print(json);
        } catch (IOException e1) {
        }
        return false;
    }

    /**
     * 获取请求的token
     */
    private String getRequestToken(HttpServletRequest httpRequest){
        // 从header中获取token
        String token = httpRequest.getHeader("token");
        // 如果header中不存在token,则从参数中获取token
        if(StringUtils.isBlank(token)){
            token = httpRequest.getParameter("token");
        }
        return token;
    }

}

OAuth2Token.java

package com.louis.kitty.admin.oauth2;


import org.apache.shiro.authc.AuthenticationToken;

/**
 * 自定义 token 对象
 * @author Louis
 * @date Sep 1, 2018
 */
public class OAuth2Token implements AuthenticationToken {
    private static final long serialVersionUID = 1L;
    
    private String token;

    public OAuth2Token(String token){
        this.token = token;
    }

    @Override
    public String getPrincipal() {
        return token;
    }

    @Override
    public Object getCredentials() {
        return token;
    }
}

3. 逻辑认证器

逻辑认证器是认证和授权的主体逻辑,主要包含两部分。

doGetAuthenticationInfo:实现自己的登录验证逻辑,这里主要是认证 token。

doGetAuthorizationInfo:实现接口授权逻辑,收集权限标识或角色,用来判定接口是否可以访问 

 OAuth2Realm.java

package com.louis.kitty.admin.oauth2;

import java.util.Set;

import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.IncorrectCredentialsException;
import org.apache.shiro.authc.LockedAccountException;
import org.apache.shiro.authc.SimpleAuthenticationInfo;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import com.louis.kitty.admin.model.SysUser;
import com.louis.kitty.admin.model.SysUserToken;
import com.louis.kitty.admin.sevice.SysUserService;
import com.louis.kitty.admin.sevice.SysUserTokenService;

/**
 * 认证Realm实现
 * @author Louis
 * @date Sep 1, 2018
 */
@Component
public class OAuth2Realm extends AuthorizingRealm {

    @Autowired
    SysUserService sysUserService;
    @Autowired
    SysUserTokenService sysUserTokenService;
    
    @Override
    public boolean supports(AuthenticationToken token) {
        return token instanceof OAuth2Token;
    }

    /**
     * 授权(接口保护,验证接口调用权限时调用)
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        SysUser user = (SysUser)principals.getPrimaryPrincipal();
        // 用户权限列表,根据用户拥有的权限标识与如 @permission标注的接口对比,决定是否可以调用接口
        Set<String> permsSet = sysUserService.findPermissions(user.getUsername());
        SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
        info.setStringPermissions(permsSet);
        return info;
    }

    /**
     * 认证(登录时调用)
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
        String token = (String) authenticationToken.getPrincipal();
        // 根据accessToken,查询用户token信息
        SysUserToken sysUserToken = sysUserTokenService.findByToken(token);
        if(sysUserToken == null || sysUserToken.getExpireTime().getTime() < System.currentTimeMillis()){
            // token已经失效
            throw new IncorrectCredentialsException("token失效,请重新登录");
        }
        // 查询用户信息
        SysUser user = sysUserService.findById(sysUserToken.getUserId());
        // 账号被锁定
        if(user.getStatus() == 0){
            throw new LockedAccountException("账号已被锁定,请联系管理员");
        }
        SimpleAuthenticationInfo info = new SimpleAuthenticationInfo(user, token, getName());
        return info;
    }
}

4. 完善登录接口

 完善登录逻辑,在用户密码匹配成功之后,创建并保存token,最后将token返回给前台,以后请求带上token。

SysLoginController.java

    /**
     * 登录接口
     */
    @PostMapping(value = "/sys/login")
    public HttpResult login(@RequestBody LoginBean loginBean) throws IOException {
        String username = loginBean.getUsername();
        String password = loginBean.getPassword();

        // 用户信息
        SysUser user = sysUserService.findByUserName(username);

        // 账号不存在、密码错误
        if (user == null) {
            return HttpResult.error("账号不存在");
        }
        
        if (!match(user, password)) {
            return HttpResult.error("密码不正确");
        }

        // 账号锁定
        if (user.getStatus() == 0) {
            return HttpResult.error("账号已被锁定,请联系管理员");
        }

        // 生成token,并保存到数据库
        SysUserToken data = sysUserTokenService.createToken(user.getUserId());
        return HttpResult.ok(data);
    }

    /**
     * 验证用户密码
     * @param user
     * @param password
     * @return
     */
    public boolean match(SysUser user, String password) {
        return user.getPassword().equals(PasswordUtils.encrypte(password, user.getSalt()));
    }

SysUserTokenServiceImpl.java,生成并保存token,这里把token保存在数据库,也可以选择保存在redis或session。

   @Override
    public SysUserToken createToken(long userId) {
        // 生成一个token
        String token = TokenGenerator.generateToken();
        // 当前时间
        Date now = new Date();
        // 过期时间
        Date expireTime = new Date(now.getTime() + EXPIRE * 1000);
        // 判断是否生成过token
        SysUserToken sysUserToken = findByUserId(userId);
        if(sysUserToken == null){
            sysUserToken = new SysUserToken();
            sysUserToken.setUserId(userId);
            sysUserToken.setToken(token);
            sysUserToken.setLastUpdateTime(now);
            sysUserToken.setExpireTime(expireTime);
            // 保存token,这里选择保存到数据库,也可以放到Redis或Session之类可存储的地方
            save(sysUserToken);
        } else{
            sysUserToken.setToken(token);
            sysUserToken.setLastUpdateTime(now);
            sysUserToken.setExpireTime(expireTime);
            // 如果token已经生成,则更新token的过期时间
            update(sysUserToken);
        }
        return sysUserToken;
    }

登录测试

登录 Swagger: localhost:8088/swagger-ui.html

用户名:admin 密码: admin

登录成功之后,会返回token,如下图所示。

登录成功之后,一般的逻辑是调到主页,这里我们可以继续访问一个接口当作登录成功之后的跳转(如 /dept/findTree,不用传参方便)。

然后我们就会发现调用失败,甚至打断点到目标接口代码,连接口代码都没有进来,根本没有调用到findTree接口。

这是必然的,因为引入乐Shiro之后便有了权限认证,如果访问请求没有携带token是不能通过验证的,具体解决方案参加下面的登录流程。

登录流程

为了帮助大家理解 shiro 的工作流程,这里对使用了 shiro 以后,我们项目的登录流程做一下简单的说明。

我们开启Debug模式,给登录接口及过滤器和认证器都打上断点,调用登录接口,跟着代码移动的脚步来了解整个登录的流程。

首先代码来到了我们调用的接口: login

成功验证用户密码,即将生成和保存token

根据条件生成或更新token,成功后登录接口会将token返回给前台,前台会带上token进入登录验证

登录接口返回之后就已经登录成功了,按照一般逻辑,这时就会跳转到主页了,我们这边没有页面,就通过访问接口来模拟吧。

我们访问Swagger里 dept/findTree 接口,获取机构数据,这个接口不用传参,比较方便。

结果发现访问没有访问正常结果,甚至debug发现连对应的后台接口代码都没有进去。那是因为加了shiro以后,访问除配置放过外的接口都是需要验证的。

我们直接在浏览器访问:http://localhost:8088/dept/findTree,发现代码来到了我们在过滤器设置的断点里边。

因为我们访问接口的时候,没有把刚才登录成功之后返回的token信息携带过来,所以在过滤器里验证token失败,返回"invalid token" 提示

果然,在代码执行完毕之后,页面得到 “invalid token” 的提示,那我们要继续访问还得带上token才行。

 

那怎样才能让 swagger 发送请求的时候把 token 也带过去呢,我们这样处理。

修改 Swagger 配置,添加请求头参数,用来传递 token。

SwaggerConfig.java

package com.louis.kitty.boot.config;
import java.util.ArrayList;
import java.util.List;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.ParameterBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.schema.ModelRef;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.service.Parameter;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;

@Configuration
@EnableSwagger2
public class SwaggerConfig {

    @Bean
    public Docket createRestApi(){
        // 添加请求参数,我们这里把token作为请求头部参数传入后端
        ParameterBuilder parameterBuilder = new ParameterBuilder();  
        List<Parameter> parameters = new ArrayList<Parameter>();  
        parameterBuilder.name("token").description("令牌")
            .modelRef(new ModelRef("string")).parameterType("header").required(false).build();  
        parameters.add(parameterBuilder.build());  
        return new Docket(DocumentationType.SWAGGER_2).apiInfo(apiInfo()).select()
                .apis(RequestHandlerSelectors.any()).paths(PathSelectors.any())
                .build().globalOperationParameters(parameters);
//        return new Docket(DocumentationType.SWAGGER_2).apiInfo(apiInfo())
//                .select()
//                .apis(RequestHandlerSelectors.any())
//                .paths(PathSelectors.any()).build();
    }

    private ApiInfo apiInfo(){
        return new ApiInfoBuilder()
                .title("Kitty API Doc")
                .description("This is a restful api document of Kitty.")
                .version("1.0")
                .build();
    }

}

 重启代码,发现接口页面已经多了token请求参数了。

我们先调用登录接口,拿到返回的token之后,把token复制过来一起发送过去。

继续用 amdin 用户登录,获得返回 token

携带 token 再次访问 findTree 接口。

 代码进入过滤器,发现 token 已经成功传过来了,往下执行 executeLogin 继续登录流程。

上面方法调用下面的接口,尝试从请求头或请求参数中获取token。

 

父类的 executeLogin 方法调用 createToken 创建 token,然后使用 Subject 进行登录。

过滤器的 createToken 方法返回我们自定义的 token 对象。

Subject 调用 SecurityManager 继续进行登录流程。

看下面的调用栈截图,经过系列操作之后,终于来到了我们的 OAuth2Realm,这里有我们的登录和授权逻辑。

 来到 OAuth2Realm 的 doGetAuthenticationInfo 方法,将前台传递的token跟后台存储的做比对,比对成功继续往下走。

 验证成功之后,代码终于来到了我们的目标接口,成功的完成了调用。

 继续往前,放行代码,代码执行完毕,调用界面成功的返回了结果。

 我们不传 token 或者传一个不存在的 token 试试。

 发现代码在过滤器验证的时候没有通过,返回 “Token 失效” 提示。

 接口响应结果,提示 “token失效,请重新登录”。

 

最后注意:加了Shiro之后每次调试接口都需要传递token,对我们开发来说也是麻烦,如有需要可以通过以下方法取消验证。

在 ShiroConfig 配置类中,把接口路径映射到 anon 过滤器,调试时就不需要 token 验证了。

源码下载

后端:https://gitee.com/liuge1988/kitty

前端:https://gitee.com/liuge1988/kitty-ui.git


作者:朝雨忆轻尘
出处:https://www.cnblogs.com/xifengxiaoma/ 
版权所有,欢迎转载,转载请注明原文作者及出处。

posted on 2018-09-01 15:15  朝雨忆轻尘  阅读(24488)  评论(1编辑  收藏  举报