1. Shiro简介
Shiro 是 Java 的一个安全框架,它相对比较简单。主要特性:
- Authentication(认证):用户身份识别,通常被称为用户“登录”,即 “你是谁”
- Authorization(授权):访问控制。比如某个用户是否具有某个操作的使用权限。 即“你可以做什么“
- Session Management(会话管理):特定于用户的会话管理,甚至在非web 应用中
- Cryptography(加密):在对数据源使用加密算法加密的同时,保证易于使用
还有其他的功能来支持和加强这些不同应用环境下安全领域的关注点
- Web支持: Shiro的Web支持API有助于保护Web应用程序
- 缓存: 缓存是Apache Shiro API中的第一级,以确保安全操作保持快速和高效。
- 并发性: Apache Shiro支持具有并发功能的多线程应用程序。
- 测试: 存在测试支持,可帮助您编写单元测试和集成测试,并确保代码按预期得到保障。
- 运行方式: 允许用户承担另一个用户的身份(如果允许)的功能
- 记住我: 记住用户在会话中的身份。
注意: Shiro不会去维护用户、维护权限,这些需要我们自己去设计/提供,然后通过相应的接口注入给Shiro
2. 核心概念
Shiro 架构包含三个主要的理念:Subject,SecurityManager和 Realm
- Subject: 当前用户
- SecurityManager: 管理所有Subject,SecurityManager 是 Shiro 架构的核心,
- Realms: 用于进行权限信息的验证,我们自己实现。Realm 本质上是一个特定的安全 DAO
我们需要实现Realms的Authentication 和 Authorization。其中 Authentication 是用来验证用户身份,Authorization 是授权访问控制,用于对用户进行的操作授权,证明该用户是否允许进行当前操作,如访问某个链接,某个资源文件等。
3. 从 shiro-spring-boot-starter开始
Shiro 官方提供了 Spring Boot Starter. 目前最新版本是 2.0.0 . 下面使用了官方的例子,并对其进行改造,主要通过这个例子来理解Shiro的内部运行原理。
官方使用了 spring-boot-starter-thymeleaf
进行了服务端页面渲染,我的例子为了方便起见,将会全部返回JSON
格式的数据,不会返回页面。
3.1 新建SpringBoot项目
这里使用的SpringBoot 版本为 2.7.6
, JDK使用 JDK17 , shiro 以及 shiro-spring-boot-starter 版本均为 2.0.0 . 下面只给出重点代码,完整代码可以访问 github 获取完整项目.
引入 fastjson2和commons-lang3 是为了方便使用工具类
pom.xml
...
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring-boot-web-starter</artifactId>
<!-- 2.0.0-->
<version>${shiro-version}</version>
</dependency>
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.47</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.14.0</version>
</dependency>
...
3.2 Shiro-Java Configuration
package com.qinyeit.shirojwt.demos.configuration;
@Configuration
public class ShiroConfiguration {
@Bean
public Realm realm() {
TextConfigurationRealm realm = new TextConfigurationRealm();
// 定义了admin 和 user 两个角色, admin 用户可以读写, user 用户只能读
realm.setRoleDefinitions("""
admin=read,write
user=read
""");
// 定义了两个用户 joe.coder 和 jill.coder, joe.coder 用户可以读写, jill.coder 用户可以读
realm.setUserDefinitions("""
joe.coder=123,user
jill.coder=456,admin
""");
// 开启了缓存,其实默认已经开启
realm.setCachingEnabled(true);
return realm;
}
@Bean
public ShiroFilterChainDefinition shiroFilterChainDefinition() {
DefaultShiroFilterChainDefinition chainDefinition = new DefaultShiroFilterChainDefinition();
// 让Shiro框架拦截所有的请求
chainDefinition.addPathDefinition("/**", "authc");
return chainDefinition;
}
}
配置中只配置了Realm和什么请求会被Shiro拦截器拦截。 这里使用了 shiro框架提供的 TextConfigurationRealm , 并定义了用户的定义和用户角色的定义。实际项目中这些数据都是存放到DB中的,此时就需要我们自定义 Realm。Shiro拦截器拦截了所有的请求,实际项目中可以根据需要对不需要认证的请求进行开放比如:
// 所有以/test开头的请求Shiro都不会处理
chainDefinition.addPathDefinition("/test/**", "anon");
3.3 Controller
- HomeController.java
package com.qinyeit.shirojwt.demos.controller;
@RestController
@Slf4j
public class HomeController {
@GetMapping("/")
public Map<String, String> home() {
// 现在将 subject理解成当前用户
Subject subject = SecurityUtils.getSubject();
// 用户凭证,简单理解成用户名
PrincipalCollection principalCollection = subject.getPrincipals();
String name = principalCollection.getPrimaryPrincipal().toString();
// 当前用户登录成功后,它的session中都存放了哪些key
String sessionKeys = subject.getSession().getAttributeKeys().toString();
// 返回结果
return Map.of("name", name, "sessionKeys", sessionKeys);
}
}
当访问 “http://127.0.0.1:8080” 的时候会执行home()
方法,如果用户没有通过认证会被shiro拦截
- AuthenticateController.java
其中login() 方法在请求通过了Shiro 的 authc 过滤器,并且成功登录后,会调用它,在这个方法中获取了当前登录用户主体(Subject),并向客户端返回凭证。
package com.qinyeit.shirojwt.demos.controller;
@RestController
@Slf4j
public class AuthenticateController {
@PostMapping("/login")
public Map<String, String> login() {
Subject subject = SecurityUtils.getSubject();
// 主体的标识,可以有多个,但是需要具备唯一性。比如:用户名,手机号,邮箱等。
PrincipalCollection principalCollection = subject.getPrincipals();
Map<String, String> map = new HashMap<>();
log.info("是否认证:{},当前登录用户主体信息:{}", subject.isAuthenticated(), principalCollection.getPrimaryPrincipal());
map.put("name", principalCollection.getPrimaryPrincipal().toString());
map.put("message", "登录成功");
return map;
}
@PostMapping("/logout")
public Map<String, String> logout() {
Subject subject = SecurityUtils.getSubject();
// 主体的标识,可以有多个,但是需要具备唯一性。比如:用户名,手机号,邮箱等。
PrincipalCollection principalCollection = subject.getPrincipals();
String name = principalCollection.getPrimaryPrincipal().toString();
// 退出登录
subject.logout();
Map<String, String> resultMap = new HashMap<>();
resultMap.put("name", name);
resultMap.put("message", "退出登录成功");
return resultMap;
}
}
因为Shiro拦截了所有的请求,所以当发起"http://127.0.0.1:8080/logini" 请求,并携带用户名,密码 POST 提交后, Shiro首先会到 Realm中获取用户真实信息,然后调用匹配器 将提交的信息与真实信息进行匹配,即验证登录逻辑,在上面的配置中,这个匹配器 已经内置了
3.4 application.properties
# 告诉Shiro框架,哪个是登录的地址
shiro.loginUrl = /login
4. 开始测试
这里使用ApiFox 这个工具软件进行测试。首先进行正常的登录, 访问home,然后退出。
4.1 正常登录
响应:
{
"name": "joe.coder",
"message": "登录成功"
}
4.2 登录后访问home
响应:
{
"sessionKeys": "[org.apache.shiro.subject.support.DefaultSubjectContext_AUTHENTICATED_SESSION_KEY, org.apache.shiro.web.session.HttpServletSession.HOST_SESSION_KEY, org.apache.shiro.subject.support.DefaultSubjectContext_PRINCIPALS_SESSION_KEY]",
"name": "joe.coder"
}
4.3 登录后退出
响应:
{
"name": "joe.coder",
"message": "退出登录成功"
}
4.4 错误密码登录
将密码修改为 1234 这个错误密码后进行登录,但是收到了这个响应:
意思是说发生了发生了重复的重定向。不光是错误登录请求会出现这个错误,所有的请求都会出现这个错误。
为什么会发生这个错误?
5. 错误分析
先看这段配置:
ShiroConfiguration.java
...
@Bean
public ShiroFilterChainDefinition shiroFilterChainDefinition() {
DefaultShiroFilterChainDefinition chainDefinition = new DefaultShiroFilterChainDefinition();
chainDefinition.addPathDefinition("/**", "authc");
return chainDefinition;
}
...
所有的请求都会被Shiro 的 authc 拦截器拦截。Shiro框架定义了一些默认的拦截器,在 org.apache.shiro.web.filter.mgt.DefaultFilter 中,它是一个枚举:
public enum DefaultFilter {
anon(AnonymousFilter.class),
authc(FormAuthenticationFilter.class),
authcBasic(BasicHttpAuthenticationFilter.class),
authcBearer(BearerHttpAuthenticationFilter.class),
ip(IpFilter.class),
logout(LogoutFilter.class),
noSessionCreation(NoSessionCreationFilter.class),
perms(PermissionsAuthorizationFilter.class),
port(PortFilter.class),
rest(HttpMethodPermissionFilter.class),
roles(RolesAuthorizationFilter.class),
ssl(SslFilter.class),
user(UserFilter.class),
...
}
可以看到authc 对应的类是org.apache.shiro.web.filter.authc.FormAuthenticationFilter
public class FormAuthenticationFilter extends AuthenticatingFilter {
...
// 请求参数的名字在没有配置的情况下,是 username
public static final String DEFAULT_USERNAME_PARAM = "username";
// 密码请求参数名
public static final String DEFAULT_PASSWORD_PARAM = "password";
// 实例化的时候,设置了默认的登录URL,DEFAULT_LOGIN_URL是个常量,值为 /login.jsp
public FormAuthenticationFilter() {
setLoginUrl(DEFAULT_LOGIN_URL);
}
// 设置登录RUL
@Override
public void setLoginUrl(String loginUrl) {...}
// 设置用户名参数名称
public void setUsernameParam(String usernameParam) {...}
// 框架判断当前请求禁止访问后,将会调用这个方法
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
// 判断当前请求是否是登录请求
if (isLoginRequest(request, response)) {
if (isLoginSubmission(request, response)) {
...
// 执行登录
return executeLogin(request, response);
} else {
...
}
} else {
// 重定向到登录页面
saveRequestAndRedirectToLogin(request, response);
return false;
}
}
...
// 登录成功后调用
protected boolean onLoginSuccess(AuthenticationToken token, Subject subject,
ServletRequest request, ServletResponse response) throws Exception {
// 重定向到登录成功的URL, 追踪源码发现默认的成功页面URL 为 "/"
issueSuccessRedirect(request, response);
//we handled the success redirect directly, prevent the chain from continuing:
return false;
}
// 登录失败后调用
protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e,
ServletRequest request, ServletResponse response) {
...
// 框架执行登录验证的时候会抛出异常,然后将异常信息保存到了request scope中
setFailureAttribute(request, e);
//login failed, let request continue back to the login page:
return true;
}
...
}
-
第3-5行可以看到在我们没有配置的情况下,登录的请求参数就是
username
和password
-
第 8 行 对应的是
application.properties
中配置的shiro.loginUrl=/login
-
第 23 行,执行登录,实际上执行的是
subject.login(token)
方法,框架内部就会从 定义的realm
中获取身份信息,然后调用匹配器进行匹配,如果没有匹配上就会抛出异常信息,接下来调用onLoginFailure()
方法,这个方法将异常信息保存到了request范围中,返回了true, 这样请求就会进入到要访问的Controller中。如果登录成功,则调用了
onLoginSuccess()
这个方法,而这个方法将请求重定向到了/
这个URL上了。 -
第 30 行: 在访问被禁止后,会重定向到
/login
这个URL上, 而重新向/login
发起请求后,又被禁止,重复重定向到/login
, 这样就造成了上面那个错误的产生。
6. 自定义FormAuthenticationFilter
弄清了为什么出错,问题就好解决了。因为现在前后端分离后,大多数情况下,后端都向前端返回JSON,而不应该进行重定向。
所以我们需要自己定义一个Filter,去继承FormAuthenticationFilter
,然后重写它的一些方法。最后再将这个Filter注册到Shiro框架中,覆盖 “authc” 这个过滤器
6.1 自定义Filter
AuthenticationFilter.java
package com.qinyeit.shirojwt.demos.shiro.filter;
...
@Slf4j
public class AuthenticationFilter extends org.apache.shiro.web.filter.authc.FormAuthenticationFilter {
private void responseJsonResult(Map<String, ?> result, ServletResponse response) {
if (response instanceof HttpServletResponse res) {
res.setContentType("application/json;charset=UTF-8");
res.setStatus(200);
res.setCharacterEncoding("UTF-8");
try {
// 输出JSON 数据
res.getWriter().write(JSON.toJSONString(result));
res.getWriter().flush();
res.getWriter().close();
} catch (Exception e) {
log.error(e.getMessage(), e);
}
}
}
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
if (isLoginRequest(request, response)) {
if (isLoginSubmission(request, response)) {
return executeLogin(request, response);
} else {
return true;
}
} else {
Map<String, ?> result = Map.of("code", 401, "msg", "未登录或登录已过期");
responseJsonResult(result, response);
//saveRequestAndRedirectToLogin(request, response);
return false;
}
}
protected boolean onLoginSuccess(AuthenticationToken token, Subject subject,
ServletRequest request, ServletResponse response) throws Exception {
// issueSuccessRedirect(request, response);
// 登录成功直接放行,让请求到达Controller
return true;
}
}
6.2 注册Filter
这个配置的方法用到了 ShiroFilterFactoryBean
因为项目中使用的是shiro-spring-boot-web-starter
它的自动配置中已经配置了 ShiroFilterFactoryBean
, 所以可以直接在这个方法中“注入” 进来
内置的Filter都存放在 ShiroFilterFactoryBean 中,所以只需要拿到ShiroFilterFactoryBean 中的filter Map集合,然后将新的filter put进去就完成了 Filter的替换。
ShiroConfiguration.java
@Configuration
public class ShiroConfiguration {
...
@Bean
public FilterRegistrationBean<AuthenticationFilter> customShiroFilterRegistration(ShiroFilterFactoryBean shiroFilterFactoryBean) {
FilterRegistrationBean<AuthenticationFilter> registration = new FilterRegistrationBean<>();
registration.setFilter(new AuthenticationFilter());
AuthenticationFilter authcFilter = new AuthenticationFilter();
// 设置登录请求的URL, application.properties 中的 loginUrl 配置项就可以去掉了
authcFilter.setLoginUrl("/login");
// 可以设置过滤器名称、顺序等属性
//使用这个名称,覆盖掉内置的authc过滤器
registration.setName("authc");
// 设置过滤器执行顺序
registration.setOrder(Integer.MAX_VALUE - 1);
// 覆盖掉源有的 authc 过滤器
shiroFilterFactoryBean.getFilters().put("authc", authcFilter);
return registration;
}
}
6.3 不登录直接访问Home
现在再来测试一下,不登录的情况下访问Home ,此时就不会再重定向了
返回:
{
"msg": "未登录或登录已过期",
"code": 401
}
7. 登录错误捕获
在 org.apache.shiro.web.filter.authc.FormAuthenticationFilter
中,定义的 onLoginFailure方法:
protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e,
ServletRequest request, ServletResponse response) {
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("Authentication exception", e);
}
setFailureAttribute(request, e);
//login failed, let request continue back to the login page:
return true;
}
我们自定义的Filter中,并没有重写这个方法,可以看到这个方法将异常信息放到了request范围中后返回的是true,这样在登录失败的情况下就会进入到Controller中,所以要捕获异常信息,可以在Controller中进行。
在Controller中只需要将Request范围中放入的异常类的名字获取到,就可以知道登录究竟出了什么样的错误。
AuthenticateController.java
package com.qinyeit.shirojwt.demos.controller;
import com.qinyeit.shirojwt.demos.shiro.filter.AuthenticationFilter;
...
@RestController
@Slf4j
public class AuthenticateController {
@PostMapping("/login")
public Map<String, String> login(HttpServletRequest req) {
Subject subject = SecurityUtils.getSubject();
Map<String, String> map = new HashMap<>();
if (subject.isAuthenticated()) {
// 主体的标识,可以有多个,但是需要具备唯一性。比如:用户名,手机号,邮箱等。
PrincipalCollection principalCollection = subject.getPrincipals();
log.info("是否认证:{},当前登录用户主体信息:{}", subject.isAuthenticated(), principalCollection.getPrimaryPrincipal());
map.put("name", principalCollection.getPrimaryPrincipal().toString());
map.put("message", "登录成功");
} else {
String exceptionClassName = (String) req.getAttribute(AuthenticationFilter.DEFAULT_ERROR_KEY_ATTRIBUTE_NAME);
log.error("signinError:{}", exceptionClassName);
String error = null;
if (UnknownAccountException.class.getName().equals(exceptionClassName)) {
error = "用户名/密码错误";
} else if (IncorrectCredentialsException.class.getName().equals(exceptionClassName)) {
error = "用户名/密码错误";
} else if (ExcessiveAttemptsException.class.getName().equals(exceptionClassName)) {
error = "登录次数过多";
} else if (exceptionClassName != null) {
error = "其他错误:" + exceptionClassName;
}
map.put("message", error);
}
return map;
}
...
}
8. 小结
通过这个例子,我们大概知道了Shiro的整个认证流程。
- 请求被我们指定的 authc 拦截器拦截,这个拦截器是默认的
FormAuthenticationFilter
,它在禁止访问的时候,进行了重定向,所以我们对它进行了改写,直接向客户端响应数据,不再进行重定向。 - 如果提交的是登录请求,则会调用
subject.login(token)
进行登录,登录的时候从 配置的realm
中获取用户的凭证,然后再用匹配器对提交的数据和从realm中获取的凭证进行匹配,如果匹配成功则登录成功。如果抛出异常则登录失败,将异常类信息保存到Request范围中,进入Controller,这样Controller中就可以捕获错误信息,然后响应给客户端。 - 自定义的过滤器要进行注册。因为要替换掉默认的 authc 过滤器,所以在注册过滤器的时候注入了
ShiroFilterFactoryBean
, 它负责管理所有的过滤器,在Springboot 的自动配置中,它已经被创建好了放入到Spring Bean 容器中了。
代码仓库 https://github.com/kaiwill/shiro-jwt , 本节代码在 1_springboot_shiro_jwt_多端认证鉴权_Shiro入门 分支上.
本章节对FormAuthenticationFilter
进行了定制改造,下一章节将会对TextConfigurationRealm
和匹配器进行定制,替换掉 TextConfigurationRealm , TextConfigurationRealm 使用了默认的凭证匹配器,下一章节也会对它进行改造。