背景
目前互联网应用系统大部分都做了前后端分离、后端也是各种微服务拆分,登录也基本都在使用单点登录系统,如果应用不多,业务并不是很复杂,就客户登录、运营登录这样的场景,使用单点登录系统的时间成本和维护成本就会比较高,这时就可以采用shiro来协助实现多个端的登录功能。 本文将介绍如何使用SpringBoot、Shiro和JWT在一个应用里实现客户端和运营端两套登录逻辑,以满足不同终端的登录需求,我们将使用Shiro进行认证和授权,同时使用JWT作为身份验证的令牌。
文章主要介绍思路以及提供demo例子核心代码做参考,主要集中在后端的逻辑实现,无前端页面。
需求
一个刚刚创业的小型公司,目前用户量不多,需要有应用系统去支持业务的发展,客户有客户端应用(web端、app端),运营人员有运营后台可以操作(主要是web端),要求就是快速实现两个端用户的登录功能。
思路
首先架构上可以做前后端分离,方便后续的扩展和维护,后端做分布式应用,可以采用springcloud框架实现。 由于本文主讲多端登录逻辑,暂只使用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, 2000, 2000, 5,
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()));
}
}
测试
-
登录前
-
调用客户端普通接口

-
调用运营端普通接口
-
登录
调用客户端登录接口,用户名密码错误调用客户端登录接口,用户名密码正确
调用运营端登录接口,用户名密码错误
调用运营端登录接口,用户名密码正确
-
登录后
客户端登录后,将返回的token放入头部,查询普通接口结果正常返回运营端登录后,将返回的token放入头部,查询普通接口结果正常返回
将运营端登录返回的token放入客户端普通方法请求头中或者将客户端登录返回的token放入运营端普通方法请求中,返回用户不存在
token格式不符个jwt格式token
至此,客户端和运营端两个端的登录和普通接口拦截都测试完了,可以看到双方登录相互不影响,而且token也不能共用,达到了一个系统支撑两套登录的目的。
总结
本文主要介绍了shiro支持多个realm实现多套验证的逻辑,而shiro还有非常多的功能,他是一个灵活的Java安全框架,可单独使用(不像spring security框架依赖spring),常用于身份验证、授权和会话管理、资源访问控制、记住我、单点登录等。 另外本文是一个demo示例,核心用来演示验证shiro支持多realm实现多个登录的逻辑, 涉及的流程图和代码验证逻辑都非常简单,如要用于生产环境需要完善业务逻辑。
原文始发于微信公众号(小新成长之路):使用SpringBoot+Shiro+JWT实现一个系统支撑两套登录流程
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/238378.html