1. 引言
在 Java 生态中,Apache Shiro 是一款轻量级的安全框架,广泛应用于身份认证、授权、会话管理和加密等场景。很多开发者会好奇:Shiro 是如何确认一个用户身份的?本文从常见架构视角,梳理 Shiro 的认证确认流程与核心机制。
2. Shiro 认证确认的核心流程
Shiro 的认证确认本质上是一个「收集凭证、校验身份、建立会话」的过程,核心步骤如下:
- 收集身份与凭证 :用户提交用户名和密码,Shiro 将其封装为
UsernamePasswordToken。 - 提交认证请求 :调用
Subject.login(token),将令牌交给 SecurityManager。 - 委托给 Authenticator :SecurityManager 将认证任务委托给内部的
Authenticator。 - 调用 Realm 校验 :Authenticator 根据配置选择一个或多个
Realm,调用其getAuthenticationInfo方法。 - 返回认证结果 :Realm 从数据源(数据库、LDAP 等)查询用户信息,比对凭证后返回
AuthenticationInfo;校验失败则抛出对应异常。 - 建立会话 :认证成功后,Shiro 将身份信息写入 Subject 的 Session,后续请求即可通过
Subject快速确认登录状态。
下图以时序图形式展示 Subject、SecurityManager、Authenticator、Realm 与 CredentialsMatcher 之间的认证交互顺序:
图中关键步骤说明:用户先通过 Subject.login(token) 提交凭证,SecurityManager 将任务委托给 Authenticator;Authenticator 调用 Realm 的 getAuthenticationInfo 查询用户数据,随后由 CredentialsMatcher 完成密码比对;校验通过后,认证结果沿调用链逐层返回,最终由 Subject 固化登录状态,完成整个认证确认闭环。
3. Realm:认证确认的关键落点
Realm 是 Shiro 连接应用与数据源的桥梁,也是「确认身份」真正发生的地方。常见架构中,开发者通常自定义一个 Realm,继承 AuthorizingRealm 并重写两个方法:
java
public class CustomRealm extends AuthorizingRealm {
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) {
String username = (String) token.getPrincipal();
// 从数据库查询用户
User user = userMapper.findByUsername(username);
if (user == null) {
return null; // 用户名不存在
}
// 返回密码与盐值,Shiro 会与 token 中的密码比对
return new SimpleAuthenticationInfo(user, user.getPassword(), ByteSource.Util.bytes(user.getSalt()), getName());
}
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
// 授权逻辑,返回角色与权限
}
}
这里的关键在于:Realm 只负责「取出正确的用户凭证」,真正的密码比对由 Shiro 的 CredentialsMatcher 完成,默认使用 SimpleCredentialsMatcher 做明文比对,生产环境通常换成 HashedCredentialsMatcher 支持 MD5、SHA-256 加盐校验。
在 Spring Boot 环境中,可以通过一个 @Configuration 类将自定义的 CustomRealm 注册到 SecurityManager,并配置 HashedCredentialsMatcher 使用 MD5 加盐校验密码。完整实现如下:
java
import org.apache.shiro.authc.credential.HashedCredentialsMatcher;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ShiroConfig {
// 1. 配置 HashedCredentialsMatcher,使用 MD5 加盐校验
@Bean
public HashedCredentialsMatcher hashedCredentialsMatcher() {
HashedCredentialsMatcher matcher = new HashedCredentialsMatcher();
matcher.setHashAlgorithmName("MD5");
matcher.setHashIterations(1); // 加密迭代次数,与注册时保持一致
return matcher;
}
// 2. 将自定义 Realm 注册为 Bean,并绑定凭证匹配器
@Bean
public CustomRealm customRealm() {
CustomRealm realm = new CustomRealm();
realm.setCredentialsMatcher(hashedCredentialsMatcher());
return realm;
}
// 3. 将 Realm 注册到 SecurityManager
@Bean
public SecurityManager securityManager() {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setRealm(customRealm());
return securityManager;
}
// 4. 配置 Shiro 过滤器,拦截所有请求
@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) {
ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();
factoryBean.setSecurityManager(securityManager);
factoryBean.setLoginUrl("/login");
factoryBean.setUnauthorizedUrl("/403");
return factoryBean;
}
// 5. 开启 Shiro 注解支持(如 @RequiresPermissions)
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
advisor.setSecurityManager(securityManager);
return advisor;
}
}
这段配置的核心在于:HashedCredentialsMatcher 指定了 MD5 算法与迭代次数,CustomRealm 通过 setCredentialsMatcher 绑定该匹配器,最终由 DefaultWebSecurityManager 统一接管认证与授权流程。这样,Realm 中返回的密码与盐值就会按照 MD5 加盐规则与用户提交的凭证进行比对。
4. 常见架构中的确认方式对比
| 架构场景 | Shiro 确认方式 | 说明 |
|---|---|---|
| 单体应用 | Session 内确认 | 登录成功后身份存入 Session,每次请求通过 Subject 判断是否已认证。 |
| 前后端分离 | Token 确认 | 登录后签发自定义 Token,前端每次请求携带,Shiro 通过过滤器解析并确认身份。 |
| 分布式 / 微服务 | Redis 共享 Session 或 JWT | Shiro 的 SessionDAO 可对接 Redis,实现多节点共享登录态;也可结合 JWT 做无状态认证。 |
| 第三方登录(OAuth2) | 自定义 Realm 适配 | 在 Realm 中调用第三方接口换取用户信息,再封装为 AuthenticationInfo 完成确认。 |
上面的表格比较抽象,下面用几个生活化的例子,帮大家把每种「确认方式」理解得更透彻。
4.0.1 单体应用:Session 内确认
打个比方:就像去一家老式澡堂,进门时前台给你一把带号码的储物柜钥匙。你每次去拿东西,只要出示这把钥匙,前台就知道你是哪个柜子的主人,不用再重新登记。
对应到 Shiro:用户登录成功后,Shiro 把身份信息写进 Session(相当于那把钥匙)。之后每次请求,Shiro 只要看到 Session 里已经有登录标记,就认定「这个人已经认证过了」,直接放行,不用再输一遍密码。
适用场景:只有一个应用服务,所有请求都打到同一台机器上,Session 存在本机内存里就够了,简单直接。
4.0.2 前后端分离:Token 确认
打个比方:就像去游乐场买了一张手环门票,戴在手上。每个项目入口的检票员不查你的身份证,只看你手上的手环是否有效,有效就放你进去。
对应到 Shiro:登录成功后,服务端签发一个 Token(相当于手环)给前端。前端每次请求都把这个 Token 带上,Shiro 的过滤器解析 Token、验证签名,确认有效就认定身份,不再依赖服务端保存的 Session。
适用场景:前端是独立的网页或 App,和后端通过接口通信,后端不关心「上一次请求是谁」,只看这次请求带的 Token 是否可信。
4.0.3 分布式 / 微服务:Redis 共享 Session 或 JWT
打个比方(Redis 共享 Session):就像连锁酒店集团,你在北京店办了会员卡,入住信息统一存在集团总部的系统里。你到上海店入住时,前台一查总部系统,就知道你是会员,不用重新办卡。
对应到 Shiro:多个应用节点把 Session 统一存到 Redis 里。用户在 A 节点登录后,请求被转发到 B 节点,B 节点去 Redis 一查,发现 Session 还在,就认定已登录,登录态不会因为换了节点而丢失。
打个比方(JWT):就像一张盖了公章、防伪的纸质通行证。通行证本身印着你的身份信息,任何检查点只要验证公章(签名)是真的,就认这张证,不需要打电话回总部核实。
对应到 Shiro:服务端不保存会话,登录后签发一个 JWT 给客户端。客户端每次请求带上 JWT,任何节点只要用密钥验证签名通过,就认定身份,无需访问共享存储。
适用场景:系统拆成多个服务、部署在多台机器上。如果业务需要服务端能随时踢人下线,选 Redis 共享 Session;如果追求高并发、无状态、易扩展,选 JWT。
4.0.4 第三方登录(OAuth2):自定义 Realm 适配
打个比方:就像你用微信扫码登录某个网站。网站自己不保存你的账号密码,而是让微信确认「这个人是谁」,微信确认后告诉网站「这是张三」,网站就放你进来。
对应到 Shiro :在自定义 Realm 里调用第三方接口(如微信、GitHub),换取用户信息,再封装成 Shiro 能识别的 AuthenticationInfo。Shiro 不关心用户密码是什么,只关心第三方确认的结果。
适用场景:应用本身不维护账号体系,直接信任微信、GitHub 等第三方平台的登录结果。
4.1 分布式场景下的 Shiro 认证实践
在分布式 / 微服务架构中,Shiro 的 Session 默认存储在应用内存中,多节点部署时会出现登录态不一致的问题。解决思路之一是将 Session 持久化到 Redis,通过自定义 SessionDAO 实现多节点共享登录态。下面给出一个基于 Spring Boot 的配置示例:
java
import org.apache.shiro.session.mgt.eis.EnterpriseCacheSessionDAO;
import org.apache.shiro.session.mgt.eis.SessionDAO;
import org.apache.shiro.web.servlet.SimpleCookie;
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class ShiroRedisSessionConfig {
// 1. 配置 RedisTemplate,指定序列化方式
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
// Key 使用 String 序列化,Value 使用 JSON 序列化
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
template.afterPropertiesSet();
return template;
}
// 2. 自定义 SessionDAO,将 Session 写入 Redis
@Bean
public SessionDAO sessionDAO(RedisTemplate<String, Object> redisTemplate) {
return new RedisSessionDAO(redisTemplate);
}
// 3. 配置 SessionManager,绑定 SessionDAO 与 Cookie
@Bean
public DefaultWebSessionManager sessionManager(SessionDAO sessionDAO) {
DefaultWebSessionManager sessionManager = new DefaultWebSessionManager();
sessionManager.setSessionDAO(sessionDAO);
// 设置 Session 全局过期时间(毫秒),默认 30 分钟
sessionManager.setGlobalSessionTimeout(1800000);
// 配置 Cookie 名称与路径
SimpleCookie cookie = new SimpleCookie("SHIRO_SESSION_ID");
cookie.setHttpOnly(true);
cookie.setPath("/");
sessionManager.setSessionIdCookie(cookie);
return sessionManager;
}
}
上述配置中,RedisSessionDAO 需要自行实现,核心逻辑是重写 EnterpriseCacheSessionDAO 的 doCreate、doRead、doUpdate、doDelete 四个方法,将 Session 对象序列化后写入 Redis。序列化方式建议使用 JSON(如 GenericJackson2JsonRedisSerializer),便于跨语言、跨节点反序列化;如果对性能要求更高,也可以使用 JDK 原生序列化,但可读性和兼容性较差。
与 Redis 共享 Session 相比,JWT 方案属于无状态认证:服务端不保存会话,登录后签发 Token,客户端每次请求携带,服务端通过签名校验身份。两者的取舍如下:
- Redis 共享 Session:适合需要服务端主动控制会话生命周期(如强制下线、踢人)的场景,实现直观、与单体 Session 语义一致,但需要维护 Redis 的可用性与序列化兼容性。
- JWT 无状态认证:适合高并发、水平扩展友好的场景,服务端无需存储会话,天然支持跨域与多端;但 Token 一旦签发难以主动失效,需要配合黑名单或短过期时间处理登出与权限变更。
实际选型时,如果业务强依赖「服务端可随时吊销会话」,优先选择 Redis 共享 Session;如果追求无状态、易扩展且能接受 Token 过期策略的复杂度,则 JWT 更合适。两者也可以结合:用 JWT 做接口鉴权,用 Redis 维护黑名单或刷新令牌,兼顾无状态与可控性。
4.1.2 JWT 与 Redis 共享 Session 的选型对比
在分布式场景下,JWT 无状态认证与 Redis 共享 Session 是两种主流的登录态方案,二者在会话控制、性能、安全性和适用场景上各有侧重。下面从四个维度进行对比:
| 对比维度 | JWT 无状态认证 | Redis 共享 Session |
|---|---|---|
| 会话控制 | 服务端不保存会话,Token 签发后难以主动失效,强制下线、踢人等操作需要借助黑名单或短过期时间实现。 | 会话集中存储在 Redis,服务端可随时删除或修改 Session,天然支持强制下线、踢人、会话续期等主动控制。 |
| 性能 | 请求无需访问共享存储,服务端仅做签名校验,水平扩展友好,高并发下性能更优。 | 每次请求需从 Redis 读取 Session,存在一次网络往返,高并发下对 Redis 的读写压力较大。 |
| 安全性 | 依赖签名算法与密钥管理,Token 一旦泄露在有效期内可被冒用,需配合 HTTPS、短过期与刷新机制降低风险。 | 会话数据集中在服务端,客户端仅持有 Session ID,安全性更可控,但需防范 Session 固定攻击并做好 Redis 访问控制。 |
| 适用场景 | 适合高并发、水平扩展、跨域多端、前后端分离的微服务架构,以及第三方开放平台等无状态接口场景。 | 适合强依赖服务端会话控制、需要实时吊销登录态的业务,如后台管理系统、金融交易、需要审计追踪的场景。 |
选型建议:如果业务强依赖「服务端可随时吊销会话」,例如需要强制下线、踢人或严格的权限变更即时生效,优先选择 Redis 共享 Session;如果追求无状态、易扩展、高并发友好,且能接受 Token 过期策略与刷新机制的复杂度,则 JWT 更合适。实际项目中两者也可以结合:用 JWT 做接口鉴权,用 Redis 维护黑名单或刷新令牌,兼顾无状态与可控性。
5. 认证确认的异常与边界
Shiro 通过异常类型区分确认失败的原因,常见的有:
UnknownAccountException:用户名不存在。IncorrectCredentialsException:密码错误。LockedAccountException:账号被锁定。ExcessiveAttemptsException:尝试次数过多。
在架构设计上,建议在 Realm 中统一捕获查询异常并转换为 Shiro 标准异常,避免把数据库细节暴露给上层。
下面给出一个 Java 示例,演示如何捕获 Shiro 的常见认证异常并转换为友好的提示信息:
java
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.ExcessiveAttemptsException;
import org.apache.shiro.authc.IncorrectCredentialsException;
import org.apache.shiro.authc.LockedAccountException;
import org.apache.shiro.authc.UnknownAccountException;
public class LoginService {
public String login(String username, String password) {
try {
// 封装令牌并提交认证
UsernamePasswordToken token = new UsernamePasswordToken(username, password);
SecurityUtils.getSubject().login(token);
return "登录成功";
} catch (UnknownAccountException e) {
return "用户名不存在";
} catch (IncorrectCredentialsException e) {
return "密码错误";
} catch (LockedAccountException e) {
return "账号已被锁定";
} catch (ExcessiveAttemptsException e) {
return "尝试次数过多,请稍后再试";
} catch (AuthenticationException e) {
return "认证失败:" + e.getMessage();
}
}
}
在 Realm 中,建议统一捕获底层查询异常并转换为 Shiro 标准异常,避免把数据库细节暴露给上层。示例如下:
java
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) {
String username = (String) token.getPrincipal();
try {
User user = userMapper.findByUsername(username);
if (user == null) {
throw new UnknownAccountException("用户名不存在");
}
if (user.getStatus() == 0) {
throw new LockedAccountException("账号已被锁定");
}
return new SimpleAuthenticationInfo(user, user.getPassword(),
ByteSource.Util.bytes(user.getSalt()), getName());
} catch (DataAccessException e) {
// 将数据库访问异常统一转换为 Shiro 标准异常
throw new AuthenticationException("用户数据查询失败", e);
}
}
通过这种分层处理,上层业务只需捕获 AuthenticationException 及其子类即可完成友好的错误提示,同时避免把 SQL 异常等底层细节直接暴露给调用方。
6. 小结
Shiro 的认证确认可以概括为:Token 收集凭证、Authenticator 调度、Realm 查询数据、CredentialsMatcher 比对密码、Session 固化结果。理解这条链路,就能在常见架构中灵活配置 Shiro,无论是单体 Session 还是分布式 Token 方案,都能找到对应的确认落点。