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

Apache Shiro

优点特色

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

它能够处理身份验证、受权、企业会话管理和加密。前端

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

主要功能

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

框架体系

Shiro 的总体框架以下图所示:git

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

它们分别是:redis

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

除此以外,还有其余的功能来支持和增强这些不一样应用环境下安全领域的关注点。特别是对如下的功能支持:算法

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

主要流程

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

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

咱们须要实现Realms的Authentication 和 Authorization。其中 Authentication 是用来验证用户身份,Authorization 是受权访问控制,用于对用户进行的操做受权,证实该用户是否容许进行当前操做,如访问某个连接,某个资源文件等。数据库

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

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/ 版权全部,欢迎转载,转载请注明原文做者及出处。

相关文章
相关标签/搜索