Apache Shiro 统一化实现多端登录(PC端移动端)
Apache Shiro 是一个强大且易用的Java安全框架,提供了身份验证、授权、密码学和会话管理等功能。它被广泛用于保护各种类型的应用程序,包括Web应用、桌面应用、RESTful服务、移动端应用和大型企业级应用。
需求背景
在当今数字化浪潮的推动下,我们共同见证了互联网从萌芽到繁荣的辉煌历程,随后移动互联网的异军突起,更是将信息的触角延伸至社会的每一个角落。这一系列的变革,不仅重塑了人们的生活方式,也极大地丰富了我们的数字生活体验。如今,用户接入数字世界的终端类型呈现出前所未有的多样化态势,从传统的Web端,到便捷的APP端,再到轻量级的小程序端,每一种终端都承载着不同的使用场景与用户需求,共同编织起一张错综复杂的数字网络。
面对如此多元且复杂的终端环境,登录与注销作为用户进入和退出系统的出入口,看似简单,实则不然。从单一终端的视角审视,登录与注销功能或许显得简单直接,无非是输入凭证、验证身份、完成会话的建立或终止。然而,当我们转换视角,从多端协同的宏观层面去考量时,就会发现这一功能的实现远非想象中那般轻松。
根据以往经验,面对此问题,我们可能是这样做的:
- Web端:开发一套接口,单独维护;
- 移动端:开发一套接口,单独维护;
当前方案功能实现无碍,但后续维护成本高昂。需求变更时需同步维护多套关联接口并开展回归测试,既耗时又易因版本差异引发非预期故障,影响系统稳定性。
需求分析
我们再来分析一下,多端会话ID在交互方式上呈现的差异化特征:
- Web端:在常规应用场景中,会话 ID 一般会被存储在 Cookie 里,随后借助 Cookie 机制来实现交互操作。
- 移动端:在与服务端进行交互时,采用 RESTful 接口的方式,而会话 ID 一般会被放置在请求的 Header 中,以此来实现会话的标识与传递。
若期望通过一套代码来达成多端需求的统一实现,其核心思路在于将各类相关特征进行有机整合,并依据请求类型的差异,动态地启用相应的处理机制。这一理念在逻辑上清晰明了,然而令人遗憾的是,Apache Shiro 框架原生并不支持这种实现方式。
我曾查遍 Apache Shiro 官网文档,竟找不出任何关于支持移动端的任何描述。但方法总比困难多,通过阅读源码,逐步Debug,梳理出一条清晰的脉络,可以实现上述的思路。这就是开源的好处呀!
不得不说,Apache Shrio 的设计哲学真的太棒了,原理易懂,模块划分合理,它以简洁高效之姿,展现出独特的魅力与实用价值。
搞清楚这一点,你便会恍然大悟:Apache Shiro 凭借其强大的功能特性,可应用于各种需要认证与鉴权的场景,无论是传统的 Web 端,还是当下流行的移动端,亦或是便捷实用的小程序端,皆在其适用范畴之内。
建议:大家有时间,真的可以把Shiro源码跟着Debug阅读一下,必将大有脾益。那时,你不仅会愈发倾心于Apache Shiro的精巧设计,更能参透其蕴含的安全哲学。彼时,或许便能体会我此刻按捺不住的分享热忱。
相信此刻的你,已经迫不及待啦!我们一起揭开这神秘的面纱吧!从实战角度,一步步达成目标。
知晓了原理,代码实现很简单。
实战环境
- Spring Boot 3
- JDK 17
- Redis
关于 Spring Boot 3 如何集成 Apache Shiro 可以参考这篇文章,本次实战,以此为基础。
SpringBoot3 集成 Shiro
https://blog.csdn.net/li277967151/article/details/140927139
实战
注:此次实战,仅展示核心Code。
完整代码,大家可以访问这个开源项目,直接Running,更有Feel
TyFast: 基于SpringBoot+Shiro搭建的快速开发平台
https://gitee.com/tommycloud/TyFast
1、Yaml配置
#Shiro配置
shiro:
loginUrl: /login
successUrl: /index
unauthorizedUrl: /error/401.html
logoutUrl: /logout
userNativeSessionManager: true #false:表示基于Servlet容器 实现Session(即HttpSession)
sessionManager:
cookie:
name: tysid
path: /
2、SpringBoot 自动装配类
package com.ty.web.spring.config;
import at.pollux.thymeleaf.shiro.dialect.ShiroDialect;
import com.ty.web.shiro.AuthenticationFilter;
import com.ty.web.shiro.AuthorizationFilter;
import com.ty.web.shiro.CookieLogoutFilter;
import com.ty.web.shiro.DistributedSessionDao;
import com.ty.web.shiro.TyWebSessionManager;
import com.ty.web.shiro.realm.NormalRealm;
import com.ty.web.shiro.realm.WithoutPasswordRealm;
import com.ty.web.spring.config.properties.ShiroProperties;
import jakarta.servlet.DispatcherType;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.config.Ini;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.realm.Realm;
import org.apache.shiro.session.mgt.SessionManager;
import org.apache.shiro.session.mgt.eis.SessionDAO;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.spring.web.config.AbstractShiroWebConfiguration;
import org.apache.shiro.spring.web.config.DefaultShiroFilterChainDefinition;
import org.apache.shiro.util.CollectionUtils;
import org.apache.shiro.web.config.IniFilterChainResolverFactory;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.apache.shiro.web.servlet.AbstractShiroFilter;
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.Map;
import static com.ty.cm.constant.ShiroConstant.SESSION_TIMEOUT;
import static org.apache.shiro.spring.config.web.autoconfigure.ShiroWebFilterConfiguration.FILTER_NAME;
import static org.apache.shiro.spring.config.web.autoconfigure.ShiroWebFilterConfiguration.REGISTRATION_BEAN_NAME;
/**
* Shiro配置
*
* @Author Tommy
* @Date 2022/1/27
*/
@Configuration
@EnableConfigurationProperties(ShiroProperties.class)
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
@ConditionalOnProperty(name = "shiro.web.enabled", matchIfMissing = true)
@Slf4j
public class ShiroConfig extends AbstractShiroWebConfiguration {
@Value("#{ @environment['shiro.sessionManager.cookie.name'] ?: 'x-auth-token'}")
private String sessionIdHeader;
/**
* Shiro 常规认证Realm
*/
@Bean
public Realm authenticationRealm() {
return new NormalRealm();
}
/**
* Shiro 免密认证Realm
*/
@Bean
public Realm withoutPasswordRealm() {
return new WithoutPasswordRealm();
}
/**
* 分布式Session Dao
*/
@Bean
public SessionDAO sessionDAO() {
return new DistributedSessionDao();
}
/**
* Shiro Session Manager
*/
@Bean
public SessionManager sessionManager() {
return super.sessionManager();
}
/**
* Shiro 核心过滤器
*/
@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager, ShiroProperties shiroProperties) {
final ShiroFilterFactoryBean filterFactoryBean = new ShiroFilterFactoryBean();
filterFactoryBean.setSecurityManager(securityManager); // Shiro的核心安全接口,这个属性是必须的
// 各URL参数
filterFactoryBean.setLoginUrl(shiroProperties.getLoginUrl()); // 登录URL,非必须的属性
filterFactoryBean.setSuccessUrl(shiroProperties.getSuccessUrl()); // 登录成功后要跳转的URL
filterFactoryBean.setUnauthorizedUrl(shiroProperties.getUnauthorizedUrl()); // 访问未经授权的资源时,转到的URL
// 替换Shiro默认的Filter(ShiroFilter 集成了过滤器filterchain 模式,所以Shiro内部Filter不要通过SpringBoot实例化,否则就会成为全局Filter,拦截异常)
filterFactoryBean.getFilters().put("authc", authenticationFilter());
filterFactoryBean.getFilters().put("perms", authorizationFilter());
filterFactoryBean.getFilters().put("logout", cookieLogoutFilter());
// 设置鉴权规则
filterFactoryBean.setFilterChainDefinitionMap(shiroFilterChainDefinition(shiroProperties));
// 设置Session有效期(只有自己实现Session DAO时,才需要设置此项)
// 基于Servlet容器的 Shiro Session,有效期同 HttpSession
DefaultWebSecurityManager webSecurityManager = (DefaultWebSecurityManager) securityManager;
((DefaultWebSessionManager) webSecurityManager.getSessionManager()).setGlobalSessionTimeout(SESSION_TIMEOUT * 1000); // 单位:毫秒
// 设置Shiro工具类,便于获取相关对象
SecurityUtils.setSecurityManager(securityManager);
log.info("Apache Shiro :: 初始化完成!");
return filterFactoryBean;
}
/**
* 手动配置 Shiro 核心过滤器 (建议手动配置,否则可能因SpringBoot问题,无法初始化)
*/
@Bean(name = REGISTRATION_BEAN_NAME)
public FilterRegistrationBean<AbstractShiroFilter> filterShiroFilterRegistrationBean(ShiroFilterFactoryBean shiroFilterFactoryBean) throws Exception {
FilterRegistrationBean<AbstractShiroFilter> filterRegistrationBean = new FilterRegistrationBean<>();
filterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.FORWARD, DispatcherType.INCLUDE, DispatcherType.ERROR);
filterRegistrationBean.setFilter((AbstractShiroFilter)shiroFilterFactoryBean.getObject());
filterRegistrationBean.setName(FILTER_NAME);
filterRegistrationBean.setOrder(1);
return filterRegistrationBean;
}
/**
* Shiro连接约束配置,即过滤链的定义
* <b>
* <p> anon: 匿名访问</p>
* <p> authc:认证访问</p>
* <p> perms:授权访问</p>
* <p> logout:注销访问</p>
* </b>
*/
private Map<String, String> shiroFilterChainDefinition(ShiroProperties shiroProperties) {
final DefaultShiroFilterChainDefinition chainDefinition = new DefaultShiroFilterChainDefinition();
if (StringUtils.isNotBlank(shiroProperties.getLoginUrl())) { // 登录
chainDefinition.addPathDefinition(shiroProperties.getLoginUrl(), "authc");
}
if (StringUtils.isNotBlank(shiroProperties.getLogoutUrl())) { // 注销
chainDefinition.addPathDefinition(shiroProperties.getLogoutUrl(), "logout");
}
// 设置无需鉴权的URL
shiroProperties.getIgnoreUrls().stream().filter(url -> StringUtils.isNotBlank(url)).forEach(url -> chainDefinition.addPathDefinition(url, "anon"));
// 读取鉴权配置信息
if (StringUtils.isNotBlank(shiroProperties.getRules())) {
final Ini ini = new Ini();
ini.load(shiroProperties.getRules());
Ini.Section section = ini.getSection(IniFilterChainResolverFactory.URLS);
if (CollectionUtils.isEmpty(section)) {
section = ini.getSection(Ini.DEFAULT_SECTION_NAME);
}
chainDefinition.addPathDefinitions(section);
}
log.info("Shiro::鉴权规则初始化完毕::DefaultShiroFilterChainDefinition! --> " + chainDefinition.getFilterChainMap());
return chainDefinition.getFilterChainMap();
}
/**
* 替换Shiro默认的Filter实现:认证过滤器
*/
@Bean
public AuthenticationFilter authenticationFilter() {
AuthenticationFilter authcFilter = new AuthenticationFilter();
authcFilter.setUsernameParam("loginName");
return authcFilter;
}
/**
* 替换Shiro默认的Filter实现:鉴权过滤器
*/
private AuthorizationFilter authorizationFilter() {
return new AuthorizationFilter();
}
/**
* 替换Shiro默认的Filter实现:Logout过滤器
*/
private CookieLogoutFilter cookieLogoutFilter() {
return new CookieLogoutFilter();
}
/**
* 替换Shiro默认的 Native Session Manager
*/
@Override
protected SessionManager nativeSessionManager() {
TyWebSessionManager webSessionManager = new TyWebSessionManager();
webSessionManager.setSessionIdCookieEnabled(this.sessionIdCookieEnabled);
webSessionManager.setSessionIdUrlRewritingEnabled(this.sessionIdUrlRewritingEnabled);
webSessionManager.setSessionIdHeader(this.sessionIdHeader);
webSessionManager.setSessionIdCookie(this.sessionCookieTemplate());
webSessionManager.setSessionFactory(this.sessionFactory());
webSessionManager.setSessionDAO(this.sessionDAO());
webSessionManager.setDeleteInvalidSessions(this.sessionManagerDeleteInvalidSessions);
return webSessionManager;
}
/**
* Thymeleaf 与 Shiro 整合
*/
@Bean
public ShiroDialect shiroDialect() {
return new ShiroDialect();
}
}
关于实现此需求,这个装配类的核心代码就2点
- 此类需继承:AbstractShiroWebConfiguration
- 替换Shiro默认的 Native Session Manager
/**
* 替换Shiro默认的 Native Session Manager
*/
@Override
protected SessionManager nativeSessionManager() {
TyWebSessionManager webSessionManager = new TyWebSessionManager();
webSessionManager.setSessionIdCookieEnabled(this.sessionIdCookieEnabled);
webSessionManager.setSessionIdUrlRewritingEnabled(this.sessionIdUrlRewritingEnabled);
webSessionManager.setSessionIdHeader(this.sessionIdHeader);
webSessionManager.setSessionIdCookie(this.sessionCookieTemplate());
webSessionManager.setSessionFactory(this.sessionFactory());
webSessionManager.setSessionDAO(this.sessionDAO());
webSessionManager.setDeleteInvalidSessions(this.sessionManagerDeleteInvalidSessions);
return webSessionManager;
}
注:为什么这两个是关键点,说来话长,这里不做阐述,若你Debug一下源码,自然分晓。
3、实现自己的认证接口Filter
package com.ty.web.shiro;
import com.ty.api.log.service.LoginAuditLogService;
import com.ty.api.model.log.LoginAuditLog;
import com.ty.api.model.system.SysUser;
import com.ty.api.system.service.SysUserService;
import com.ty.cm.model.AjaxResult;
import com.ty.cm.utils.URLUtils;
import com.ty.web.push.TPush;
import com.ty.web.spring.SpringContextHolder;
import com.ty.web.utils.WebIpUtil;
import com.ty.web.utils.WebUtil;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.web.filter.authc.FormAuthenticationFilter;
import org.apache.shiro.web.util.WebUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import java.io.IOException;
import java.util.Date;
import static com.ty.cm.constant.Numbers.ONE;
import static com.ty.cm.constant.ShiroConstant.DEFAULT_CAPTCHA_PARAM;
/**
* Shiro认证服务
*
* @Author Tommy
* @Date 2022/1/27
*/
@Slf4j
public class AuthenticationFilter extends FormAuthenticationFilter {
/** 账户业务接口 **/
@Autowired
@Lazy
private SysUserService sysUserService;
/** 登录日志接口 **/
@Autowired
@Lazy
private LoginAuditLogService loginAuditLogService;
/** TPush消息推送 **/
@Autowired
@Lazy
private TPush tpush;
/** "验证码"参数名称 */
private String captchaParam = DEFAULT_CAPTCHA_PARAM;
/**
* 创建令牌
*/
@Override
protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) {
String username = getUsername(request);
String password = getPassword(request);
boolean rememberMe = isRememberMe(request);
String host = getHost(request);
String captcha = getCaptcha(request);
return new com.ty.web.shiro.AuthenticationToken(username, password, rememberMe, host, captcha);
}
/**
* 未经认证时访问系统在此拦截
*/
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
final boolean isLoginRequest = super.isLoginRequest(request, response);
if (!isLoginRequest && WebUtil.isAjax()) { // 登录URL不能拦截
WebUtil.sendError(WebUtils.toHttp(response), HttpServletResponse.SC_UNAUTHORIZED);
return false;
}
return super.onAccessDenied(request, response);
}
/**
* 登录失败的回调函数
*/
@Override
protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException ex, ServletRequest request, ServletResponse response) {
final boolean isAjax = WebUtil.isAjax();
/*
* 转换标准异常为自定义异常(因框架架构设计问题,只在多Realm情况下,才需要此操作)
*/
if (token instanceof com.ty.web.shiro.AuthenticationToken) {
com.ty.web.shiro.AuthenticationToken authenticationToken = (com.ty.web.shiro.AuthenticationToken) token;
ex = null != authenticationToken.getAex()? authenticationToken.getAex() : ex;
}
try {
WebUtil.writeJSON(WebUtils.toHttp(response), AjaxResult.warn(SpringContextHolder.getMessage(ex.getMessage())));
} catch (IOException ioe) {
log.error(ioe.getMessage(), ioe);
} finally {
log.warn("登录校验失败::" + (isAjax? "异步":"同步") + "::" + ex.getMessage());
}
return !isAjax;
}
/**
* 登录成功的回调函数
*/
@Override
protected boolean onLoginSuccess(AuthenticationToken token, Subject subject, ServletRequest request, ServletResponse response) throws Exception {
final boolean isAjax = WebUtil.isAjax();
// 进入系统前的业务处理
this.postHandle(subject, isAjax, request, response);
// 输出成功信息
try {
WebUtil.writeJSON(WebUtils.toHttp(response), AjaxResult.success(subject.getSession().getId()));
} catch (IOException ioe) {
log.error(ioe.getMessage(), ioe);
}
return !isAjax? super.onLoginSuccess(token, subject, request, response) : !isAjax;
}
/**
* 获取验证码
*
* @param request
* @return 验证码
*/
protected String getCaptcha(ServletRequest request) {
return WebUtils.getCleanParam(request, captchaParam);
}
/**
* 认证后,进入系统前的处理
*
* @param subject
* @param isAjax
* @throws Exception
*/
public void postHandle(Subject subject, boolean isAjax, ServletRequest request, ServletResponse response) throws Exception {
String loginIp = WebIpUtil.getClientIP();
String domain = URLUtils.getPrimaryDomain(WebUtil.getDomain(), true);
final SysUser account = (SysUser) subject.getPrincipal();
log.info(account.getLoginName() + " 登录成功::" + (isAjax? "异步":"同步") + " :: From " + loginIp);
// 此处可写业务代码
// 如:获取员工信息等,可在账户表中,添加辅助字段,用于存储业务数据
// ......
// 更新用户的登录信息(IP & 登录时间)
SysUser sysUser = new SysUser();
sysUser.setUserId(account.getUserId());
sysUser.setLoginTime(new Date());
sysUser.setLoginIp(loginIp);
sysUserService.update(sysUser);
// 记录登录日志
loginAuditLogService.save(new LoginAuditLog(account.getLoginName(), loginIp, WebUtil.getUserAgent(), ONE));
// 实现登录互踢
boolean result = sysUserService.kickOut(account, subject.getSession().getId().toString());
if (result) { // 将下线消息通知到同账户的其它客户端
tpush.kickOut(account.getLoginName());
}
}
}
关于实现此需求,核心代码如下:
/**
* 登录成功的回调函数
*/
@Override
protected boolean onLoginSuccess(AuthenticationToken token, Subject subject, ServletRequest request, ServletResponse response) throws Exception {
final boolean isAjax = WebUtil.isAjax();
// 进入系统前的业务处理
this.postHandle(subject, isAjax, request, response);
// 输出成功信息
try {
WebUtil.writeJSON(WebUtils.toHttp(response), AjaxResult.success(subject.getSession().getId()));
} catch (IOException ioe) {
log.error(ioe.getMessage(), ioe);
}
return !isAjax? super.onLoginSuccess(token, subject, request, response) : !isAjax;
}
此段代码,当移动端以异步请求登录成功后,服务端会将Session ID返回。而Web端登录成功后,走Shiro原生逻辑。
4、实现自己的Logout Filter
package com.ty.web.shiro;
import com.ty.cm.model.AjaxResult;
import com.ty.cm.utils.URLUtils;
import com.ty.web.utils.WebUtil;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.shiro.web.filter.authc.LogoutFilter;
import org.apache.shiro.web.util.WebUtils;
/**
* 基于Cookie机制的注销登录服务
*
* @Author Tommy
* @Date 2022/1/27
*/
public class CookieLogoutFilter extends LogoutFilter {
/**
* 注销登录业务逻辑处理
*/
@Override
protected boolean preHandle(ServletRequest request, ServletResponse response)
throws Exception {
getSubject(request, response).logout(); // Shiro内部实现
// Cookie 登出处理
String domain = URLUtils.getPrimaryDomain(WebUtil.getDomain(), true);
WebUtil.removeAllCookie((HttpServletRequest) request, (HttpServletResponse) response, domain);
// 登出后的前端交互
if (WebUtil.isAjax()) {
WebUtil.writeJSON(WebUtils.toHttp(response), AjaxResult.success());
} else {
issueRedirect(request, response, getRedirectUrl());
}
return false;
}
}
5、【核心】实现自己的Web Session Manager
package com.ty.web.shiro;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import lombok.Data;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
import java.io.Serializable;
/**
* 增强 Web Session Manager,支持从Header中获取Session ID
*
* @Author Tommy
* @Date 2025/3/11
*/
@Data
public class TyWebSessionManager extends DefaultWebSessionManager {
private String sessionIdHeader;
@Override
protected Serializable getSessionId(ServletRequest request, ServletResponse response) {
Serializable id = super.getSessionId(request, response);
// 若 Shiro 原生获取不到SessionID,则从Header中尝试获取
HttpServletRequest httpRequest = (HttpServletRequest) request;
String sessionId = httpRequest.getHeader(sessionIdHeader);
if (StringUtils.isNotBlank(sessionId)) {
id = sessionId;
}
return id;
}
}
若Debug源码后,你会发现,这段代码实现,其实是对原生Shiro的补充,以支持移动端场景。
经过上述的5个步骤,我们的代码工作就完成了,是不是很简单呢。若你想知晓,为什么是这么写,那就只能Debug源码喽!因为这个事情,真的不太好通过Blog文字的方式,讲清楚呢!
测试
1、移动端
- 登录接口测试
- 调用数据接口测试
- 注销接口测试
2、Web端
- 登录接口测试
- 注销接口测试
结论
通过上述测试可知,我们通过统一的接口,完美同时支持Web端与移动端,成功达成了一套代码适配多平台的高效解决方案。
此刻,你是否如拨云见日般,心中豁然开朗?是否恍然发觉,这看似棘手的难题,实则并不繁杂。只要思路如灵动的丝线般清晰穿梭,Coding便会如行云流水般简单自然。
至此分享结束!
Enjoy It!