使用SpringBoot+Shiro+JWT实现一个系统支撑两套登录流程

背景

目前互联网应用系统大部分都做了前后端分离、后端也是各种微服务拆分,登录也基本都在使用单点登录系统,如果应用不多,业务并不是很复杂,就客户登录、运营登录这样的场景,使用单点登录系统的时间成本和维护成本就会比较高,这时就可以采用shiro来协助实现多个端的登录功能。 本文将介绍如何使用SpringBoot、Shiro和JWT在一个应用里实现客户端和运营端两套登录逻辑,以满足不同终端的登录需求,我们将使用Shiro进行认证和授权,同时使用JWT作为身份验证的令牌。

文章主要介绍思路以及提供demo例子核心代码做参考,主要集中在后端的逻辑实现,无前端页面。

需求

一个刚刚创业的小型公司,目前用户量不多,需要有应用系统去支持业务的发展,客户有客户端应用(web端、app端),运营人员有运营后台可以操作(主要是web端),要求就是快速实现两个端用户的登录功能。

思路

首先架构上可以做前后端分离,方便后续的扩展和维护,后端做分布式应用,可以采用springcloud框架实现。 由于本文主讲多端登录逻辑,暂只使用springboot做一个单体微服务应用,登录可采用shiro框架借助jwt来完完成身份认证。 虽然是两套登录,逻辑不同,但整体流程时一样的。

  • 登录流程:

登录流程不进行任何拦截,按请求地址走各自登录验证流程

主要是演示登录的正向流程,具体验证细节实际应用中会比这更复杂。

使用SpringBoot+Shiro+JWT实现一个系统支撑两套登录流程

  • 身份认证流程:

根据请求地址的不同,走不同的拦截器,客户和运营认证流程相同,逻辑不同。

主要是演示身份认证的正向流程,具体验证细节实际应用中会比这更复杂。

使用SpringBoot+Shiro+JWT实现一个系统支撑两套登录流程

实现

添加依赖

主要是springboot的版本,shiro、jwt、redis的依赖。 pom文件如下:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">

  <modelVersion>4.0.0</modelVersion>
  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.7.10</version>
    <relativePath/> <!-- lookup parent from repository -->
  </parent>
  <groupId>com.star95</groupId>
  <artifactId>shirologin</artifactId>
  <version>0.0.1-SNAPSHOT</version>
  <name>shirologin</name>
  <description>shirologin</description>
  <properties>
    <java.version>1.8</java.version>
  </properties>
  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter</artifactId>
    </dependency>

    <!-- mvc -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- lombok -->
    <dependency>
      <groupId>org.projectlombok</groupId>
      <artifactId>lombok</artifactId>
    </dependency>

    <!-- redis -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>

    <!--JWT-->
    <dependency>
      <groupId>com.auth0</groupId>
      <artifactId>java-jwt</artifactId>
      <version>3.11.0</version>
    </dependency>

    <!--shiro-->
    <dependency>
      <groupId>org.apache.shiro</groupId>
      <artifactId>shiro-spring-boot-starter</artifactId>
      <version>1.10.0</version>
    </dependency>
    <!-- shiro-redis -->
    <dependency>
      <groupId>org.crazycake</groupId>
      <artifactId>shiro-redis</artifactId>
      <version>3.2.3</version>
      <exclusions>
        <exclusion>
          <groupId>org.apache.shiro</groupId>
          <artifactId>shiro-core</artifactId>
        </exclusion>
      </exclusions>
    </dependency>

    <!-- knife4j -->
    <dependency>
      <groupId>com.github.xiaoymin</groupId>
      <artifactId>knife4j-spring-boot-starter</artifactId>
      <version>3.0.3</version>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
      </plugin>
    </plugins>
  </build>

</project>

application.yml

jwt token使用redis存储,所以这里需要redis配置。

server:
  port: 8080

spring:
  redis:
    database: 0
    host: 127.0.0.1
    port: 6379
    password: ''
  mvc:
    static-path-pattern: /**
    #Spring Boot 2.6+后映射匹配的默认策略已从AntPathMatcher更改为PathPatternParser,需要手动指定为ant-path-matcher
    pathmatch:
      matching-strategy: ant_path_matcher

shiro配置

shiro的主要配置,需要拦截的和不需要拦截的地址都在这里配置,同时配置两个自定义的客户和运营拦截器,分别拦截对应的url。

package com.star95.shirologin.config.shiro;

import com.star95.shirologin.config.shiro.filter.CustomerJwtFilter;
import com.star95.shirologin.config.shiro.filter.OperatorJwtFilter;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.pool2.impl.GenericObjectPoolConfig;
import org.apache.shiro.mgt.DefaultSessionStorageEvaluator;
import org.apache.shiro.mgt.DefaultSubjectDAO;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.spring.LifecycleBeanPostProcessor;
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.crazycake.shiro.IRedisManager;
import org.crazycake.shiro.RedisCacheManager;
import org.crazycake.shiro.RedisClusterManager;
import org.crazycake.shiro.RedisManager;
import org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.util.StringUtils;
import redis.clients.jedis.HostAndPort;
import redis.clients.jedis.JedisCluster;

import javax.annotation.Resource;
import javax.servlet.Filter;
import java.util.*;

/**
 * shiro 配置类
 */

@Slf4j
@Configuration
public class ShiroConfig {

    @Resource
    private LettuceConnectionFactory lettuceConnectionFactory;

    @Bean("shiroFilterFactoryBean")
    public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) {
        CustomShiroFilterFactoryBean shiroFilterFactoryBean = new CustomShiroFilterFactoryBean();
        shiroFilterFactoryBean.setSecurityManager(securityManager);
        // 拦截器
        Map<String, String> filterChainDefinitionMap = new LinkedHashMap<String, String>();

        // 配置不会被拦截的链接 顺序判断
        filterChainDefinitionMap.put("/customer/login""anon"); // 客户端登录接口排除
        filterChainDefinitionMap.put("/operator/login""anon"); //运营端登录接口排除

        // 静态文件不需要拦截
        filterChainDefinitionMap.put("/""anon");
        filterChainDefinitionMap.put("/doc.html""anon");
        filterChainDefinitionMap.put("/**/*.js""anon");
        filterChainDefinitionMap.put("/**/*.css""anon");
        filterChainDefinitionMap.put("/**/*.html""anon");
        filterChainDefinitionMap.put("/**/*.svg""anon");
        filterChainDefinitionMap.put("/**/*.pdf""anon");
        filterChainDefinitionMap.put("/**/*.jpg""anon");
        filterChainDefinitionMap.put("/**/*.png""anon");
        filterChainDefinitionMap.put("/**/*.gif""anon");
        filterChainDefinitionMap.put("/**/*.ico""anon");
        filterChainDefinitionMap.put("/**/*.ttf""anon");
        filterChainDefinitionMap.put("/**/*.woff""anon");
        filterChainDefinitionMap.put("/**/*.woff2""anon");

        filterChainDefinitionMap.put("/druid/**""anon");
        filterChainDefinitionMap.put("/swagger-ui.html""anon");
        filterChainDefinitionMap.put("/swagger**/**""anon");
        filterChainDefinitionMap.put("/webjars/**""anon");
        filterChainDefinitionMap.put("/v2/**""anon");

        // 自定义拦截器
        Map<String, Filter> filterMap = new HashMap<String, Filter>(1);
        filterMap.put("customer"new CustomerJwtFilter());
        filterMap.put("operator"new OperatorJwtFilter());
        shiroFilterFactoryBean.setFilters(filterMap);
        filterChainDefinitionMap.put("/operator/**""operator");
        filterChainDefinitionMap.put("/customer/**""customer");
        // 其他路径统一用customer的过滤器
        filterChainDefinitionMap.put("/**""customer");

        // 未授权界面返回JSON
        shiroFilterFactoryBean.setUnauthorizedUrl("/sys/common/403");
        shiroFilterFactoryBean.setLoginUrl("/sys/common/403");
        shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
        return shiroFilterFactoryBean;
    }

        @Bean("securityManager")
        public DefaultWebSecurityManager securityManager(CustomerShiroRealm customerShiroRealm, OperatorShiroRealm operatorShiroRealm) {
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
        // 重点,添加多个自定义的realms
        securityManager.setRealms(Arrays.asList(customerShiroRealm, operatorShiroRealm));
        DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
        DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
        defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
        subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
        securityManager.setSubjectDAO(subjectDAO);
        // 使用redis存储
        securityManager.setCacheManager(redisCacheManager());
        return securityManager;
    }

        /**
        * 下面的代码是添加注解支持
        *
        * @return
        */

        @Bean
        @DependsOn("lifecycleBeanPostProcessor")
        public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
        DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
        defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);
        defaultAdvisorAutoProxyCreator.setUsePrefix(true);
        defaultAdvisorAutoProxyCreator.setAdvisorBeanNamePrefix("_no_advisor");
        return defaultAdvisorAutoProxyCreator;
    }

        @Bean
        public static LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
        return new LifecycleBeanPostProcessor();
    }

        @Bean
        public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(DefaultWebSecurityManager securityManager) {
        AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
        advisor.setSecurityManager(securityManager);
        return advisor;
    }

        /**
        * cacheManager 缓存 redis实现
        * 使用的是shiro-redis开源插件
        *
        * @return
        */

        public RedisCacheManager redisCacheManager() {
        RedisCacheManager redisCacheManager = new RedisCacheManager();
        redisCacheManager.setRedisManager(redisManager());
        //唯一标识,使用主键
        redisCacheManager.setPrincipalIdFieldName("id");
        redisCacheManager.setExpire(200000);
        return redisCacheManager;
    }

        /**
        * 配置shiro redisManager
        * 使用的是shiro-redis开源插件
        *
        * @return
        */

        @Bean
        public IRedisManager redisManager() {
        IRedisManager manager;
        if (lettuceConnectionFactory.getClusterConfiguration() == null || lettuceConnectionFactory.getClusterConfiguration().getClusterNodes().isEmpty()) {
        RedisManager redisManager = new RedisManager();
        redisManager.setHost(lettuceConnectionFactory.getHostName() + ":" + lettuceConnectionFactory.getPort());
        redisManager.setDatabase(lettuceConnectionFactory.getDatabase());
        redisManager.setTimeout(0);
        if (!StringUtils.isEmpty(lettuceConnectionFactory.getPassword())) {
        redisManager.setPassword(lettuceConnectionFactory.getPassword());
    }
        manager = redisManager;
    } else {
        // redis集群支持,优先使用集群配置
        RedisClusterManager redisManager = new RedisClusterManager();
        Set<HostAndPort> portSet = new HashSet<>();
        lettuceConnectionFactory.getClusterConfiguration().getClusterNodes().forEach(node -> portSet.add(new HostAndPort(node.getHost(), node.getPort())));
        if (!StringUtils.isEmpty(lettuceConnectionFactory.getPassword())) {
        JedisCluster jedisCluster = new JedisCluster(portSet, 200020005,
        lettuceConnectionFactory.getPassword(), new GenericObjectPoolConfig());
        redisManager.setPassword(lettuceConnectionFactory.getPassword());
        redisManager.setJedisCluster(jedisCluster);
    } else {
        JedisCluster jedisCluster = new JedisCluster(portSet);
        redisManager.setJedisCluster(jedisCluster);
    }
        manager = redisManager;
    }
        return manager;
    }
    }

这个类中,创建安全管理器是关键,这里会把自定义的两个realm设置进去,后续的filter把token提交时就会找到realm验证。

@Bean("securityManager")
        public DefaultWebSecurityManager securityManager(CustomerShiroRealm customerShiroRealm, OperatorShiroRealm operatorShiroRealm) {
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
        // 重点,添加多个自定义的realms
        securityManager.setRealms(Arrays.asList(customerShiroRealm, operatorShiroRealm));
        DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
        DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
        defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
        subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
        securityManager.setSubjectDAO(subjectDAO);
        // 使用redis存储
        securityManager.setCacheManager(redisCacheManager());
        return securityManager;
    }

filter配置

客户端filter逻辑

package com.star95.shirologin.config.shiro.filter;

import com.star95.shirologin.common.constants.CommonConstants;
import com.star95.shirologin.common.util.JwtUtil;
import com.star95.shirologin.config.shiro.CustomerJwtToken;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.web.filter.authc.BasicHttpAuthenticationFilter;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.RequestMethod;

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

/**
 * 客户鉴权过滤器
 */

@Slf4j
public class CustomerJwtFilter extends BasicHttpAuthenticationFilter {

    public CustomerJwtFilter() {
    }

    /**
     * 执行登录认证
     *
     * @param request
     * @param response
     * @param mappedValue
     * @return
     */

    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
        try {
            executeLogin(request, response);
            return true;
        } catch (Exception e) {
            JwtUtil.responseError(response, 401, CommonConstants.TOKEN_IS_INVALID_MSG);
            return false;
        }
    }

    /**
     *
     */

    @Override
    protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        String token = httpServletRequest.getHeader(CommonConstants.X_ACCESS_TOKEN);
        if (StringUtils.isEmpty(token)) {
            token = httpServletRequest.getParameter("token");
        }
        CustomerJwtToken jwtToken = new CustomerJwtToken(token);
        // 提交给realm进行登录,如果错误他会抛出异常并被捕获
        getSubject(request, response).login(jwtToken);
        // 如果没有抛出异常则代表登入成功,返回true
        return true;
    }

    /**
     * 对跨域提供支持
     */

    @Override
    protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        HttpServletResponse httpServletResponse = (HttpServletResponse) response;
        httpServletResponse.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, httpServletRequest.getHeader(HttpHeaders.ORIGIN));
        // 允许客户端请求方法
        httpServletResponse.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, "GET,POST,OPTIONS,PUT,DELETE");
        // 允许客户端提交的Header
        String requestHeaders = httpServletRequest.getHeader(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS);
        if (StringUtils.isNotEmpty(requestHeaders)) {
            httpServletResponse.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, requestHeaders);
        }
        // 允许客户端携带凭证信息(是否允许发送Cookie)
        httpServletResponse.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
        // 跨域时会首先发送一个option请求,这里我们给option请求直接返回正常状态
        if (RequestMethod.OPTIONS.name().equalsIgnoreCase(httpServletRequest.getMethod())) {
            httpServletResponse.setStatus(HttpStatus.OK.value());
            return false;
        }
        return super.preHandle(request, response);
    }
}

运营端filter逻辑

package com.star95.shirologin.config.shiro.filter;

import com.star95.shirologin.common.constants.CommonConstants;
import com.star95.shirologin.common.util.JwtUtil;
import com.star95.shirologin.config.shiro.OperatorJwtToken;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.web.filter.authc.BasicHttpAuthenticationFilter;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.RequestMethod;

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

/**
 * 运营人员鉴权过滤器
 */

@Slf4j
public class OperatorJwtFilter extends BasicHttpAuthenticationFilter {
    public OperatorJwtFilter() {
    }

    /**
     * 执行登录认证
     *
     * @param request
     * @param response
     * @param mappedValue
     * @return
     */

    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
        try {
            executeLogin(request, response);
            return true;
        } catch (Exception e) {
            JwtUtil.responseError(response, 401, CommonConstants.TOKEN_IS_INVALID_MSG);
            return false;
        }
    }

    /**
     *
     */

    @Override
    protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        String token = httpServletRequest.getHeader(CommonConstants.X_ACCESS_TOKEN);
        if (StringUtils.isEmpty(token)) {
            token = httpServletRequest.getParameter("token");
        }
        OperatorJwtToken operatorJwtToken = new OperatorJwtToken(token);
        // 提交给realm进行登录,如果错误他会抛出异常并被捕获
        getSubject(request, response).login(operatorJwtToken);
        // 如果没有抛出异常则代表登入成功,返回true
        return true;
    }

    /**
     * 对跨域提供支持
     */

    @Override
    protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        HttpServletResponse httpServletResponse = (HttpServletResponse) response;
        httpServletResponse.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, httpServletRequest.getHeader(HttpHeaders.ORIGIN));
        // 允许客户端请求方法
        httpServletResponse.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, "GET,POST,OPTIONS,PUT,DELETE");
        // 允许客户端提交的Header
        String requestHeaders = httpServletRequest.getHeader(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS);
        if (StringUtils.isNotEmpty(requestHeaders)) {
            httpServletResponse.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, requestHeaders);
        }
        // 允许客户端携带凭证信息(是否允许发送Cookie)
        httpServletResponse.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
        // 跨域时会首先发送一个option请求,这里我们给option请求直接返回正常状态
        if (RequestMethod.OPTIONS.name().equalsIgnoreCase(httpServletRequest.getMethod())) {
            httpServletResponse.setStatus(HttpStatus.OK.value());
            return false;
        }
        return super.preHandle(request, response);
    }
}

getSubject(request, response).login(operatorJwtToken);这行代码就是把token提交到realm验证。 客户端和运营端分别使用自己的Token对象,方便realm用来判断是否需要验证。

package com.star95.shirologin.config.shiro;

import org.apache.shiro.authc.AuthenticationToken;

public class CustomerJwtToken implements AuthenticationToken {

    private static final long serialVersionUID = 1L;
    private String token;

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

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

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

package com.star95.shirologin.config.shiro;

import org.apache.shiro.authc.AuthenticationToken;

public class OperatorJwtToken implements AuthenticationToken {

    private static final long serialVersionUID = 1L;
    private String token;

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

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

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

realm配置

package com.star95.shirologin.config.shiro;

import com.star95.shirologin.common.constants.CommonConstants;
import com.star95.shirologin.common.util.JwtUtil;
import com.star95.shirologin.common.util.RedisUtil;
import com.star95.shirologin.common.util.SpringContextUtils;
import com.star95.shirologin.common.util.UserUtil;
import com.star95.shirologin.dto.CustomerUserDto;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.SimpleAuthenticationInfo;
import org.apache.shiro.realm.AuthenticatingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;

/**
 * 客户登录鉴权
 */

@Component
@Slf4j
public class CustomerShiroRealm extends AuthenticatingRealm {

    @Resource
    private RedisUtil redisUtil;

    @Override
    public boolean supports(AuthenticationToken token) {
        return token instanceof CustomerJwtToken;
    }

    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken auth) throws AuthenticationException {
        String token = (String) auth.getCredentials();
        if (token == null) {
            log.error("token无效");
            throw new AuthenticationException("token为空!");
        }
        // 校验token有效性
        try {
            CustomerUserDto customerUser = this.checkUserTokenIsEffect(token);
            return new SimpleAuthenticationInfo(customerUser, token, getName());
        } catch (AuthenticationException e) {
            JwtUtil.responseError(SpringContextUtils.getHttpServletResponse(), 401, e.getMessage());
            e.printStackTrace();
            return null;
        }

    }

    /**
     * 校验token的有效性
     *
     * @param token
     */

    public CustomerUserDto checkUserTokenIsEffect(String token) throws AuthenticationException {
        // 解密获得username,用于和数据库进行对比
        String username = JwtUtil.getUsername(token);
        if (username == null) {
            throw new AuthenticationException("token非法无效!");
        }
        CustomerUserDto customerUser = UserUtil.customerUserDtoMap.get(username);
        if (customerUser == null) {
            throw new AuthenticationException("用户不存在!");
        }
        // 校验token是否超时失效 & 或者账号密码是否错误
        if (!jwtTokenRefresh(token, username, customerUser.getPassword())) {
            throw new AuthenticationException("token已失效");
        }
        return customerUser;
    }

    /**
     * JWTToken刷新生命周期 (实现: 用户在线操作不掉线功能)
     * 1、登录成功后将用户的JWT生成的Token作为k、v存储到cache缓存里面(这时候k、v值一样),缓存有效期设置为Jwt有效时间的2倍
     * 2、当该用户再次请求时,通过JWTFilter层层校验之后会进入到doGetAuthenticationInfo进行身份验证
     * 3、当该用户这次请求jwt生成的token值已经超时,但该token对应cache中的k还是存在,则表示该用户一直在操作只是JWT的token失效了,程序会给token对应的k映射的v值重新生成JWTToken并覆盖v值,该缓存生命周期重新计算
     * 4、当该用户这次请求jwt在生成的token值已经超时,并在cache中不存在对应的k,则表示该用户账户空闲超时,返回用户信息已失效,请重新登录。
     * 注意: 前端请求Header中设置Authorization保持不变,校验有效性以缓存中的token为准。
     * 用户过期时间 = Jwt有效时间 * 2。
     *
     * @param userName
     * @param passWord
     * @return
     */

    public boolean jwtTokenRefresh(String token, String userName, String passWord) {
        String cacheToken = String.valueOf(redisUtil.get(CommonConstants.PREFIX_USER_TOKEN + token));
        if (StringUtils.isNotEmpty(cacheToken)) {
            // 校验token有效性
            if (!JwtUtil.verify(cacheToken, userName, passWord)) {
                String newAuthorization = JwtUtil.sign(userName, passWord);
                // 设置超时时间
                redisUtil.set(CommonConstants.PREFIX_USER_TOKEN + token, newAuthorization, TimeUnit.SECONDS, JwtUtil.EXPIRE_TIME * 2);
            }
            return true;
        }
        //redis中不存在此TOEKN,说明token非法返回false
        return false;
    }

    /**
     * 清除当前用户的权限认证缓存
     *
     * @param principals 权限信息
     */

    @Override
    public void clearCache(PrincipalCollection principals) {
        super.clearCache(principals);
    }

}
package com.star95.shirologin.config.shiro;

import com.star95.shirologin.common.constants.CommonConstants;
import com.star95.shirologin.common.util.JwtUtil;
import com.star95.shirologin.common.util.RedisUtil;
import com.star95.shirologin.common.util.SpringContextUtils;
import com.star95.shirologin.common.util.UserUtil;
import com.star95.shirologin.dto.OperatorUserDto;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.SimpleAuthenticationInfo;
import org.apache.shiro.realm.AuthenticatingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;

/**
 * 运营人员登录鉴权
 */

@Component
@Slf4j
public class OperatorShiroRealm extends AuthenticatingRealm {

    @Resource
    private RedisUtil redisUtil;

    @Override
    public boolean supports(AuthenticationToken token) {
        return token instanceof OperatorJwtToken;
    }

    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken auth) throws AuthenticationException {
        String token = (String) auth.getCredentials();
        if (token == null) {
            log.error("token无效");
            throw new AuthenticationException("token为空!");
        }
        // 校验token有效性
        try {
            OperatorUserDto operatorUser = this.checkUserTokenIsEffect(token);
            return new SimpleAuthenticationInfo(operatorUser, token, getName());
        } catch (AuthenticationException e) {
            JwtUtil.responseError(SpringContextUtils.getHttpServletResponse(), 401, e.getMessage());
            e.printStackTrace();
            return null;
        }

    }

    /**
     * 校验token的有效性
     *
     * @param token
     */

    public OperatorUserDto checkUserTokenIsEffect(String token) throws AuthenticationException {
        // 解密获得username,用于和数据库进行对比
        String username = JwtUtil.getUsername(token);
        if (username == null) {
            throw new AuthenticationException("token非法无效!");
        }
        OperatorUserDto operatorUser = UserUtil.operatorUserDtoMap.get(username);
        if (operatorUser == null) {
            throw new AuthenticationException("用户不存在!");
        }
        // 校验token是否超时失效 & 或者账号密码是否错误
        if (!jwtTokenRefresh(token, username, operatorUser.getPassword())) {
            throw new AuthenticationException("token已失效");
        }
        return operatorUser;
    }

    /**
     * JWTToken刷新生命周期 (实现: 用户在线操作不掉线功能)
     * 1、登录成功后将用户的JWT生成的Token作为k、v存储到cache缓存里面(这时候k、v值一样),缓存有效期设置为Jwt有效时间的2倍
     * 2、当该用户再次请求时,通过JWTFilter层层校验之后会进入到doGetAuthenticationInfo进行身份验证
     * 3、当该用户这次请求jwt生成的token值已经超时,但该token对应cache中的k还是存在,则表示该用户一直在操作只是JWT的token失效了,程序会给token对应的k映射的v值重新生成JWTToken并覆盖v值,该缓存生命周期重新计算
     * 4、当该用户这次请求jwt在生成的token值已经超时,并在cache中不存在对应的k,则表示该用户账户空闲超时,返回用户信息已失效,请重新登录。
     * 注意: 前端请求Header中设置Authorization保持不变,校验有效性以缓存中的token为准。
     * 用户过期时间 = Jwt有效时间 * 2。
     *
     * @param userName
     * @param passWord
     * @return
     */

    public boolean jwtTokenRefresh(String token, String userName, String passWord) {
        String cacheToken = String.valueOf(redisUtil.get(CommonConstants.PREFIX_USER_TOKEN + token));
        if (StringUtils.isNotEmpty(cacheToken)) {
            // 校验token有效性
            if (!JwtUtil.verify(cacheToken, userName, passWord)) {
                String newAuthorization = JwtUtil.sign(userName, passWord);
                // 设置超时时间
                redisUtil.set(CommonConstants.PREFIX_USER_TOKEN + token, newAuthorization, TimeUnit.SECONDS, JwtUtil.EXPIRE_TIME * 2);
            }
            return true;
        }
        //redis中不存在此TOEKN,说明token非法返回false
        return false;
    }

    /**
     * 清除当前用户的权限认证缓存
     *
     * @param principals 权限信息
     */

    @Override
    public void clearCache(PrincipalCollection principals) {
        super.clearCache(principals);
    }

}

在filter中会调用getSubject(request, response).login(operatorJwtToken);提交给realm进行登录验证。 realm里supports方法会根据token对象类型判断是否需要自己验证。是需要自己验证时会调用doGetAuthenticationInfo方法

public boolean supports(AuthenticationToken token) {
    return token instanceof OperatorJwtToken;
}

工具类

JwtUtil主要用来生成token,验证token。

package com.star95.shirologin.common.util;

import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTDecodeException;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.star95.shirologin.dto.Result;
import com.star95.shirologin.exception.ShiroLoginException;
import org.springframework.util.StringUtils;

import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.OutputStream;
import java.util.Date;

/**
 * JWT工具类
 */

public class JwtUtil {

    /**
     * Token有效期为24小时(Token在reids中缓存时间乘以2)
     */

    public static final long EXPIRE_TIME = 12 * 60 * 60 * 1000;

    /**
     * @param response
     * @param code
     * @param errorMsg
     */

    public static void responseError(ServletResponse response, Integer code, String errorMsg) {
        HttpServletResponse httpServletResponse = (HttpServletResponse) response;
        // issues/I4YH95浏览器显示乱码问题
        httpServletResponse.setHeader("Content-type""text/html;charset=UTF-8");
        Result jsonResult = new Result(code, errorMsg);
        jsonResult.setSuccess(false);
        OutputStream os = null;
        try {
            os = httpServletResponse.getOutputStream();
            httpServletResponse.setCharacterEncoding("UTF-8");
            httpServletResponse.setStatus(code);
            os.write(new ObjectMapper().writeValueAsString(jsonResult).getBytes("UTF-8"));
            os.flush();
            os.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    /**
     * 校验token是否正确
     *
     * @param token  密钥
     * @param secret 用户的密码
     * @return 是否正确
     */

    public static boolean verify(String token, String username, String secret) {
        try {
            // 根据密码生成JWT效验器
            Algorithm algorithm = Algorithm.HMAC256(secret);
            JWTVerifier verifier = JWT.require(algorithm).withClaim("username", username).build();
            // 效验TOKEN
            DecodedJWT jwt = verifier.verify(token);
            return true;
        } catch (Exception exception) {
            return false;
        }
    }

    /**
     * 获得token中的信息无需secret解密也能获得
     *
     * @return token中包含的用户名
     */

    public static String getUsername(String token) {
        try {
            DecodedJWT jwt = JWT.decode(token);
            return jwt.getClaim("username").asString();
        } catch (JWTDecodeException e) {
            return null;
        }
    }

    /**
     * 生成签名,5min后过期
     *
     * @param username 用户名
     * @param secret   用户的密码
     * @return 加密的token
     */

    public static String sign(String username, String secret) {
        Date date = new Date(System.currentTimeMillis() + EXPIRE_TIME);
        Algorithm algorithm = Algorithm.HMAC256(secret);
        // 附带username信息
        return JWT.create().withClaim("username", username).withExpiresAt(date).sign(algorithm);

    }

    /**
     * 根据request中的token获取用户账号
     *
     * @param request
     * @return
     * @throws ShiroLoginException
     */

    public static String getUserNameByToken(HttpServletRequest request) throws ShiroLoginException {
        String accessToken = request.getHeader("X-Access-Token");
        String username = getUsername(accessToken);
        if (StringUtils.isEmpty(username)) {
            throw new ShiroLoginException("未获取到用户");
        }
        return username;
    }
}

RedisUtil主要用来存放和获取token。

package com.star95.shirologin.common.util;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import java.util.concurrent.TimeUnit;

@Component
public class RedisUtil {
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    public RedisUtil() {
    }


    public void set(String key, Object value, TimeUnit unit, Long ttl) {
        this.redisTemplate.opsForValue().set(key, value, ttl, unit);
    }

    public Object get(String key) {
        return key == null ? null : this.redisTemplate.opsForValue().get(key);
    }
}

UserUtil用来模拟客户端和运营端的账号,采用map存储,真实的应从数据库获取。

package com.star95.shirologin.common.util;

import com.star95.shirologin.dto.CustomerUserDto;
import com.star95.shirologin.dto.OperatorUserDto;

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

public class UserUtil {
    /**
     * 客户信息map
     */

    public static Map<String, CustomerUserDto> customerUserDtoMap = new HashMap<>();

    /**
     * 运营人员信息
     */

    public static Map<String, OperatorUserDto> operatorUserDtoMap = new HashMap<>();


    static {
        customerUserDtoMap.put("zhangsan"new CustomerUserDto("c1""zhangsan""张三""123456"));
        customerUserDtoMap.put("lisi"new CustomerUserDto("c1""lisi""李四""654321"));

        operatorUserDtoMap.put("admin1"new OperatorUserDto("o1""admin1""管理员1""123456"));
        operatorUserDtoMap.put("admin2"new OperatorUserDto("o1""admin2""管理员2""654321"));
    }
}

常量类

package com.star95.shirologin.common.constants;

public class CommonConstants {
    public static String X_ACCESS_TOKEN = "X-Access-Token";

    /**
     * 登录用户Token令牌缓存KEY前缀
     */

    public static String PREFIX_USER_TOKEN = "prefix_user_token:";

    public static String TOKEN_IS_INVALID_MSG = "token无效,请重新登录!";
}

controller

建一个客户端的web接口类,提供登录接口和普通的查询用户余额接口。 登录接口主要生成和保存token信息和用户信息一起返给前端。

package com.star95.shirologin.controller;

import com.star95.shirologin.common.constants.CommonConstants;
import com.star95.shirologin.common.util.JwtUtil;
import com.star95.shirologin.common.util.RedisUtil;
import com.star95.shirologin.common.util.UserUtil;
import com.star95.shirologin.dto.CustomerUserDto;
import com.star95.shirologin.dto.Result;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Objects;
import java.util.concurrent.TimeUnit;

/**
 * 客户端接口
 */

@Slf4j
@RestController
@RequestMapping("/customer")
@Api(tags = "客户端接口")
public class CustomerController {
    @Autowired
    private RedisUtil redisUtil;

    @ApiOperation("登录接口")
    @PostMapping("/login")
    public Result<CustomerUserDto> login(@Validated @RequestBody CustomerUserDto customerUserDto) {
        log.info("客户端登录请求参数,customerUserDto:{}", customerUserDto);
        //1. 校验用户是否有效
        CustomerUserDto customerUserDto1 = UserUtil.customerUserDtoMap.get(customerUserDto.getCustomerAccount());
        if (Objects.isNull(customerUserDto1)) {
            log.info("用户登录失败,用户不存在!");
            return Result.error("用户不存在");
        }
        //2. 校验用户名或密码是否正确
        if (!customerUserDto1.getPassword().equals(customerUserDto.getPassword())) {
            log.info("密码不正确,customerAccount:{}", customerUserDto1.getCustomerAccount());
            return Result.error("用户名或密码错误");
        }
        //用户登录信息
        log.info("用户账号: {},登录成功!", customerUserDto1.getCustomerAccount());
        String username = customerUserDto1.getCustomerAccount();
        String syspassword = customerUserDto1.getPassword();
        //1.生成token
        String token = JwtUtil.sign(username, syspassword);
        // 设置token缓存有效时间
        redisUtil.set(CommonConstants.PREFIX_USER_TOKEN + token, token, TimeUnit.SECONDS, JwtUtil.EXPIRE_TIME * 2);
        customerUserDto1.setToken(token);
        return Result.ok(customerUserDto1);
    }

    @ApiOperation("查询用户余额接口")
    @PostMapping("/balance")
    public Result<Double> customerList() {
        log.info("客户端请求查询用户余额接口");
        return Result.ok(9999.99d);
    }
}

建一个运营端的web接口类,提供登录接口和普通的查询用户列表接口。

package com.star95.shirologin.controller;

import com.star95.shirologin.common.constants.CommonConstants;
import com.star95.shirologin.common.util.JwtUtil;
import com.star95.shirologin.common.util.RedisUtil;
import com.star95.shirologin.common.util.UserUtil;
import com.star95.shirologin.dto.CustomerUserDto;
import com.star95.shirologin.dto.OperatorUserDto;
import com.star95.shirologin.dto.Result;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.TimeUnit;

/**
 * 运营端接口
 */

@Slf4j
@RestController
@RequestMapping("/operator")
@Api(tags = "运营端接口")
public class OperatorController {

    @Autowired
    private RedisUtil redisUtil;

    @ApiOperation("登录接口")
    @PostMapping("/login")
    public Result<OperatorUserDto> login(@Validated @RequestBody OperatorUserDto operatorUserDto) {
        log.info("运营人员登录请求参数,operatorUserDto:{}", operatorUserDto);
        //1. 校验用户是否有效
        OperatorUserDto operatorUserDto1 = UserUtil.operatorUserDtoMap.get(operatorUserDto.getOperatorAccount());
        if (Objects.isNull(operatorUserDto1)) {
            log.info("用户登录失败,用户不存在!");
            return Result.error("用户不存在");
        }
        //2. 校验用户名或密码是否正确
        if (!operatorUserDto1.getPassword().equals(operatorUserDto.getPassword())) {
            log.info("密码不正确,operatorAccount:{}", operatorUserDto1.getOperatorAccount());
            return Result.error("用户名或密码错误");
        }
        //用户登录信息
        log.info("用户账号: {},登录成功!", operatorUserDto1.getOperatorAccount());
        String username = operatorUserDto1.getOperatorAccount();
        String syspassword = operatorUserDto1.getPassword();
        //1.生成token
        String token = JwtUtil.sign(username, syspassword);
        // 设置token缓存有效时间
        redisUtil.set(CommonConstants.PREFIX_USER_TOKEN + token, token, TimeUnit.SECONDS, JwtUtil.EXPIRE_TIME * 2);
        operatorUserDto1.setToken(token);
        return Result.ok(operatorUserDto1);
    }

    @ApiOperation("查询用户列表接口")
    @PostMapping("/customers")
    public Result<List<CustomerUserDto>> customerList() {
        log.info("请求运营端查询用户列表接口");
        return Result.ok(new ArrayList<>(UserUtil.customerUserDtoMap.values()));
    }
}

测试

  1. 登录前
  • 调用客户端普通接口
使用SpringBoot+Shiro+JWT实现一个系统支撑两套登录流程

  • 调用运营端普通接口

使用SpringBoot+Shiro+JWT实现一个系统支撑两套登录流程未登录的情况下调用普通接口,都会被拦截

  1. 登录

调用客户端登录接口,用户名密码错误使用SpringBoot+Shiro+JWT实现一个系统支撑两套登录流程调用客户端登录接口,用户名密码正确使用SpringBoot+Shiro+JWT实现一个系统支撑两套登录流程调用运营端登录接口,用户名密码错误使用SpringBoot+Shiro+JWT实现一个系统支撑两套登录流程调用运营端登录接口,用户名密码正确使用SpringBoot+Shiro+JWT实现一个系统支撑两套登录流程

  1. 登录后

客户端登录后,将返回的token放入头部,查询普通接口结果正常返回使用SpringBoot+Shiro+JWT实现一个系统支撑两套登录流程运营端登录后,将返回的token放入头部,查询普通接口结果正常返回使用SpringBoot+Shiro+JWT实现一个系统支撑两套登录流程将运营端登录返回的token放入客户端普通方法请求头中或者将客户端登录返回的token放入运营端普通方法请求中,返回用户不存在使用SpringBoot+Shiro+JWT实现一个系统支撑两套登录流程使用SpringBoot+Shiro+JWT实现一个系统支撑两套登录流程token格式不符个jwt格式token使用SpringBoot+Shiro+JWT实现一个系统支撑两套登录流程至此,客户端和运营端两个端的登录和普通接口拦截都测试完了,可以看到双方登录相互不影响,而且token也不能共用,达到了一个系统支撑两套登录的目的。

总结

本文主要介绍了shiro支持多个realm实现多套验证的逻辑,而shiro还有非常多的功能,他是一个灵活的Java安全框架,可单独使用(不像spring security框架依赖spring),常用于身份验证、授权和会话管理、资源访问控制、记住我、单点登录等。 另外本文是一个demo示例,核心用来演示验证shiro支持多realm实现多个登录的逻辑, 涉及的流程图和代码验证逻辑都非常简单,如要用于生产环境需要完善业务逻辑。


原文始发于微信公众号(小新成长之路):使用SpringBoot+Shiro+JWT实现一个系统支撑两套登录流程

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/238378.html

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!