定义
我正在参加「掘金·启航计划」
Apcache shiro 是一个功能强大的 Java 安全权限框架,可以完成:认证、授权、加密、会话管理、与 Web 集成、缓存等。
好处
- 容易上手:Shiro 的设计目标是简单易用,同时提供灵活性和可扩展性,因此上手难度较小。
- 可以集成到多种框架中:Shiro 可以集成到多种框架中,包括 Spring、Spring MVC、Struts2 等。
- 支持多种身份认证方式:Shiro 支持多种身份认证方式,包括基于表单的认证、基于 HTTP 的认证、基于LDAP 的认证等。
- 支持多种授权方式:Shiro 支持多种授权方式,包括基于角色的访问控制、基于权限的访问控制、基于资源的访问控制等。
- 提供会话管理功能:Shiro 提供会话管理功能,可以对会话进行跟踪和管理,同时支持集群环境下的会话管理。
主要功能
- 认证登录
- 授权、权限验证
- 加密
- 其他:并发、测试、缓存、” 记住我 ” 、会话管理等
Shiro 架构
(1)Subject:任何可以与应用交互的“用户”;
(2)SecurityManager :相当于 SpringMVC 中的 DispatcherServlet;是 Shiro 的心脏; 所有具体的交互都通过 SecurityManager
进行控制;它管理着所有 Subject
、且负责进 行认证、授权、会话及缓存的管理。
(3)Authenticator:负责 Subject 认证,是一个扩展点,可以自定义实现;可以使用认证策略(Authentication Strategy),即什么情况下算用户认证通过了;
(4)Authorizer:授权器、即访问控制器,用来决定主体是否有权限进行相应的操作;即控制着用户能访问应用中的哪些功能;
(5)Realm:可以有 1 个或多个 Realm,可以认为是安全实体数据源,即用于获取安全实体的;可以是 JDBC 实现,也可以是内存实现等等;由用户提供;所以一般在应用中都需要实现自己的 Realm;
(6)SessionManager:管理 Session 生命周期的组件;而 Shiro 并不仅仅可以用在 Web 环境,也可以用在如普通的 JavaSE 环境
(7)CacheManager:缓存控制器,来管理如用户、角色、权限等的缓存的;因为这些数据基本上很少改变,放到缓存中后可以提高访问的性能
(8)Cryptography:密码模块,Shiro 提高了一些常见的加密组件用于如密码加密/解密。
核心组件
- SecurityManager:SecurityManager 是 Shiro 的核心组件,负责管理所有的 Subject、Realm 和其他组件之间的交互。
- Subject:Subject 代表当前用户,包含了身份信息和授权信息。
- Realm:Realm 是 Shiro 用来获取安全实体(如用户、角色、权限)的组件,可以从数据库、LDAP、文件等不同的数据源中获取安全实体。
- AuthenticationToken:AuthenticationToken 是用来封装用户提交的身份信息的,如用户名和密码。
- AuthorizationInfo:AuthorizationInfo 是用来封装用户的授权信息的,如用户所拥有的角色和权限等。
- SessionManager:SessionManager 是用来管理会话的组件,可以对会话进行跟踪和管理,同时支持集群环境下的会话管理。
功能介绍
引入依赖
创建 maven 工程,并引入依赖
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-core</artifactId>
<version>1.9.0</version>
</dependency>
认证登录
一般要提供唯一的表示信息来表明登陆者的身份,如 email、用户名/密码来证明等
在 shiro 中,用户需要提供 principals
(身份)和 credential
(证明)给 shrio,从而验证用户身份
principals
:身份,即用户的唯一标识,如用户 ID,用户名等;credential
:证明/凭证,即只有主体知道的安全值,如密码 / 数字证书等;- 最常见的 pricipal 和 credential 就是用户名和密码
登录流程:
- 客户端提供 pricipal 和 credential,即用户名 / 密码;
- 调用 subject.login() 进行登录;
- 若登录失败则产生 AuthenticationException 异常;
// 获取 SecurityManager
IniSecurityManagerFactory factory = new IniSecurityManagerFactory("classpath:shiro.ini");
SecurityManager securityManager = factory.getInstance();
// 获取 subject 对象
SecurityUtils.setSecurityManager(securityManager);
Subject subject = SecurityUtils.getSubject();
// 验证 token
UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken("zhangsan", "123456");
// 完成登录
try {
subject.login(usernamePasswordToken);
} catch (AuthenticationException e) {
e.printStackTrace();
}
角色授权
授权,也叫访问控制,即在应用中控制谁访问哪些资源(CURD),需要了解主体(Subject)、资源(Resource)、权限(Permission)、角色(Role) 4 个概念
主体(Subject) :访问应用的用户;
资源(Resource) :在应用中用户可以访问的 URL比如访问 JSP 页面、查看/编辑数据、访问某个业务方法、打印文本等等都是资源。用户只要授权后才能访问;
权 限(Permission) :安全策略中的原子授权单位,通过权限我们可以表示在应用中用户有没有操作某个资源的权力。即权限表示在应用中用户能不能访问某个资源,如:访问用户列表页面查看/新增/修改/删除用户数据(即很多时候都是 CRUD 式权限控制)等。
角色(Role) :即权限的集合,某一个角色拥有一组权限,把该角色赋给某个 Subject,即该 Subject 拥有这一组权限;
授权过程:
- 得到用户的 Subject
- 调用 Subject 中的
isPermitted
方法(是否还有相应的权限) - 调用 Subject 中的
hasRole
方法(是否是相应的角色)
@Test
public void test() {
// 获取 SecurityManager
IniSecurityManagerFactory factory = new IniSecurityManagerFactory("classpath:shiro.ini");
SecurityManager securityManager = factory.getInstance();
// 获取 subject 对象
SecurityUtils.setSecurityManager(securityManager);
Subject subject = SecurityUtils.getSubject();
// 验证 token
UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken("zhangsan", "123456");
// 完成登录
try {
subject.login(usernamePasswordToken);
boolean role1 = subject.hasRole("role1");
System.out.println("用户含有 role1 角色:" + role1);
boolean permitted = subject.isPermitted("user:insert");
System.out.println("用户含有插入权限:" + permitted);
} catch (AuthenticationException e) {
e.printStackTrace();
}
}
加密
可以使用 Md5Hash 类来对字符串进行加密,其构造函数 Md5Hash(Object source, Object salt, int hashIterations)
source
:加密的主体;
salt
:盐值
hashIterations
:重复加密的次数
@Test
public void test() {
String password = "sa";
Md5Hash md5Hash = new Md5Hash(password);
System.out.println("加密后的值为:" + md5Hash.toHex());
Md5Hash md5Hash1 = new Md5Hash(password, "salt");
System.out.println("加密后的值为:" + md5Hash1.toHex());
Md5Hash md5Hash2 = new Md5Hash(password, "salt", 3);
System.out.println("加密后的值为:" + md5Hash2.toHex());
}
SpringBoot 整合 Shiro
整合 Shiro 依赖
使用 mybatis-plus 作为 orm 框架,同时整合 shiro 的 starter
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.1</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>8.0.31</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring-boot-web-starter</artifactId>
<version>1.9.0</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
实现登录认证
- 自定义 realm 类,向底层的验证逻辑提供正确的数据
@Component
public class LoginRealm extends AuthorizingRealm {
@Resource
private MallUserMapper mallUserMapper;
/**
* 权限控制
* @param principalCollection
* @return
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
return null;
}
/**
* 登录控制
* @param authenticationToken
* @return
* @throws AuthenticationException
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
// 用户唯一凭证
String principal = authenticationToken.getPrincipal().toString();
String password = mallUserMapper.getPassword(principal);
if (StrUtil.isNotEmpty(password)) {
// 自定义信息验证类,具体验证过程由 shiro 底层实现,我们只需要提供正确的数据即可
AuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo(
authenticationToken.getPrincipal(),
password,
authenticationToken.getPrincipal().toString()
);
return authenticationInfo;
}
return null;
}
}
AuthorizingRealm
中有一个supports
方法,用来判断传入的authenticationToken
的类型,通过这个方法可以让某一个realm
处理指定的authenticationToken
,若没有重写该方法,默认处理的是UsernamePasswordToken
及其子类
- 编写 ShiroConfig 向 DefaultWebSecurityManager 注入自定义的 realm
@Configuration
public class ShiroConfig {
@Resource
private LoginRealm loginRealm;
@Bean
public DefaultWebSecurityManager defaultWebSecurityManager() {
DefaultWebSecurityManager defaultWebSecurityManager = new DefaultWebSecurityManager();
defaultWebSecurityManager.setRealm(loginRealm);
return defaultWebSecurityManager;
}
@Bean
public DefaultShiroFilterChainDefinition shiroFilterChainDefinition() {
DefaultShiroFilterChainDefinition defaultShiroFilterChainDefinition = new DefaultShiroFilterChainDefinition();
// anon 不需要鉴权,可以公共访问的资源
defaultShiroFilterChainDefinition.addPathDefinition("/user/login", "anon");
// 需要登录鉴权的路径
defaultShiroFilterChainDefinition.addPathDefinition("/user/**", "authc");
return defaultShiroFilterChainDefinition;
}
}
- 在登录接口通过 DefaultWebSecurityManager 获取 Subject 调用 login 方法
@PostMapping("/login")
public BaseResponse<UserCommonResp> userLogin(@RequestBody UserLoginReq userLoginReq, HttpServletRequest request) {
String userPassword = userLoginReq.getUserPassword();
String userAccount = userLoginReq.getUserAccount();
Subject subject = SecurityUtils.getSubject();
AuthenticationToken authenticationToken = new UsernamePasswordToken(userAccount, userPassword);
try {
// 成功登录
subject.login(authenticationToken);
logger.info("UserController|userLogin|用户成功登录|userAccount: {}", userAccount);
} catch (AuthenticationException authenticationException) {
// 登录失败
logger.info("UserController|userLogin|用户登录失败|userAccount: {} , reason: {}", userAccount, authenticationException.getMessage());
return ResultsUtils.fail(Code.UserCode.LOGIN_FAIL.getCode(), authenticationException.getMessage());
}
UserCommonResp userCommonResp = mallUserService.userLogin(userAccount, userPassword, request);
return ResultsUtils.success(Code.UserCode.LOGIN_SUCCESS.getCode(), Code.UserCode.LOGIN_SUCCESS.getMessage(), userCommonResp);
}
加密处理
- 创建 HashedCredentialsMatcher ,指定算法名称和 HashIterations(加密次数)
- 将 HashedCredentialsMatcher 注入到 realm 中
@Bean
public DefaultWebSecurityManager defaultWebSecurityManager() {
DefaultWebSecurityManager defaultWebSecurityManager = new DefaultWebSecurityManager();
// 密码使用 MD5 来校验
HashedCredentialsMatcher hashedCredentialsMatcher = new HashedCredentialsMatcher();
hashedCredentialsMatcher.setHashAlgorithmName(Code.ShiroCode.MD5_ALGORITHM);
hashedCredentialsMatcher.setHashIterations(Code.ShiroCode.HASH_ITERATIONS);
loginRealm.setCredentialsMatcher(hashedCredentialsMatcher);
defaultWebSecurityManager.setRealm(loginRealm);
return defaultWebSecurityManager;
}
授权处理
开启 Shiro 注解功能
// 开启对 shiro 注解的支持
@Bean
public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);
return defaultAdvisorAutoProxyCreator;
}
在登录的时候为 当前的 Subject 授权
@Component
public class LoginRealm extends AuthorizingRealm {
@Resource
private UserMapper userMapper;
/**
* 鉴权
* @param principalCollection
* @return
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
String principal = principalCollection.getPrimaryPrincipal().toString();
User user = userMapper.selectUserByAccount(principal);
// 设置角色权限
SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo ();
simpleAuthorizationInfo.addRole(user.getUserRole());
return simpleAuthorizationInfo;
}
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
// 用户唯一凭证
String principal = authenticationToken.getPrincipal().toString();
String password = userMapper.getPasswordByAccount(principal);
if (StrUtil.isNotEmpty(password)) {
// 自定义信息验证类,具体验证过程由 shiro 底层实现,我们只需要提供正确的数据即可
AuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo(
authenticationToken.getPrincipal(),
password,
ByteSource.Util.bytes(Code.ShiroCode.SALT), // 添加盐值
authenticationToken.getPrincipal().toString()
);
return authenticationInfo;
}
return null;
}
}
接着在某个 Controller
上添加上@RequiresRoles("admin")
注解即可
@PostMapping("/user/outLogin")
@RequiresRoles("admin")
@ResponseBody
public BaseResponse<UserOutLoginResp> userOutLogin(@RequestBody UserOutLoginReq userOutLoginReq) {
log.info("UserController|userRegister|用户开始注销登录, id: {}", userOutLoginReq.getId());
BaseResponse<UserOutLoginResp> userOutLoginRespBaseResponse = userService.userOutLogin(userOutLoginReq);
log.info("UserController|userRegister|用户注销登录, id: {}", userOutLoginReq.getId());
return userOutLoginRespBaseResponse;
}
Remember Me
Shiro 提供了记住我(RememberMe)的功能,比如访问一些网站时,关闭了浏览器,下次再打开时还是能记住你是谁, 下次访问时无需再登录即可访问。
基本流程
- 首先在登录页面选中
RememberMe
然后登录成功;如果是浏览器登录,一般会把RememberMe
的 Cookie 写到客户端并保存下来; - 关闭浏览器再重新打开,会发现浏览器还是记住你的;
- 访问一般的网页服务器端,仍然知道你是谁,且能正常访问;
- 但是,如果我们访问电商平台时,如果要查看我的订单或进行支付时,此时还是需要再进行身份认证的,以确保当前用户还是你。
过滤器链
Shiro 的过滤器链是一系列过滤器的集合,用于对请求进行过滤和拦截,并根据配置的规则进行认证和授权处理。过滤器链决定了不同 URL 路径或请求的处理方式,可以根据需求配置不同的过滤器和认证/授权规则。
在 Shrio 中,每一个 URL 可以对应一个过滤器,请求的URL会根据配置的模式匹配到相应的过滤器,然后执行相应的逻辑。
实现 AccessControlFilter
接口自定义过滤器
isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue)
: 这个方法用于判断当前请求是否允许访问。
onAccessDenied(ServletRequest request, ServletResponse response)
: 这个方法在访问被拒绝时被调用。
getSubject(ServletRequest request, ServletResponse response)
: 这个方法用于获取当前的 Subject 对象
getPathWithinApplication(ServletRequest request)
: 这个方法用于获取请求的相对路径。子类可以根据自己的需要来处理路径信息。
执行顺序
- 当请求到达被
AccessControlFilter
过滤器拦截时,首先会执行isAccessAllowed
方法来判断当前请求是否允许访问。 - 如果
isAccessAllowed
方法返回true
,表示当前请求被允许访问,那么接下来的过滤器链将继续执行,并最终到达目标资源。 - 如果
isAccessAllowed
方法返回false
,表示当前请求被拒绝访问,那么接下来会调用onAccessDenied
方法来处理访问被拒绝的情况。 - 在
onAccessDenied
方法中,可以实现自定义的处理逻辑,例如跳转到登录页面、返回错误信息等。处理完后,请求的处理流程结束,不会继续执行过滤器链。
总结起来,isAccessAllowed
方法用于判断当前请求是否允许访问,如果返回 true,则继续执行过滤器链;如果返回 false
,则调用 onAccessDenied
方法进行访问被拒绝的处理。这两个方法的执行顺序是先执行 isAccessAllowed
,再根据返回结果决定是否执行 onAccessDenied
以下是Shiro中常用的过滤器:
anon
(AnonymousFilter):匿名过滤器,用于表示该URL可以匿名访问,不需要进行认证和授权。authc
(FormAuthenticationFilter):身份认证过滤器,用于表示该URL需要进行身份认证,用户必须登录才能访问。logout
(LogoutFilter):登出过滤器,用于处理用户登出操作。roles
(RolesAuthorizationFilter):角色授权过滤器,用于判断用户是否具有指定 角色。perms
(PermissionsAuthorizationFilter):权限授权过滤器,用于判断用户是否具有指定权限。
除了上述常用的过滤器,还可以根据具体需求自定义过滤器。通过配置过滤器链,可以按照优先级和匹配规则依次执行过滤器的逻辑,从而实现对请求的认证和授权控制。
在Shiro的配置中,通过shiroFilterFactoryBean
方法返回的ShiroFilterFactoryBean
对象来配置过滤器链。可以使用setFilterChainDefinitionMap
方法设置URL
模式与过滤器的映射关系,定义每个URL路径应该使用哪个过滤器进行处理。
Apache Shiro 中,你可以继承或使用以下一些预定义的拦截器来实现不同的安全控制逻辑:
AccessControlFilter
:用于基于权限进行访问控制的拦截器。
AnonymousFilter
:用于匿名访问的拦截器,允许未经认证的用户访问受限资源。
AuthenticatingFilter
:用于认证的拦截器,负责用户的身份认证。
AuthorizationFilter
:用于授权的拦截器,验证用户是否具有执行特定操作的权限。
FormAuthenticationFilter
:用于表单认证的拦截器,支持用户通过表单输入用户名和密码进行身份认证。
LogoutFilter
:用于注销的拦截器,处理用户的注销请求。
RolesAuthorizationFilter
:用于基于角色进行授权的拦截器,验证用户是否拥有指定角色。
UserFilter
:用于用户认证和授权的拦截器,支持基于用户名和密码的认证和基于用户的角色授权。
除了以上的拦截器,你还可以根据需要自定义拦截器,实现Filter接口并提供相应的逻辑。
你可以通过配置Shiro的过滤器链(filterChainDefinitionMap)来将这些拦截器与具体的URL路径进行关联,实现不同路径的访问控制和安全处理。
JWT
通过使用 JSON 对象作为安全令牌,是信息可以被验证和信任
JWT 分为三部分:头部、载荷、签名
头部:包含令牌的类型(JWT)、签名算法
载荷:签发时间(iat)、JWT的唯一标识(jti)、签发人(iss)、过期时间(exp)、claims 是指还想要在jwt中存储的一些非隐私信息
签名:用于验证令牌的真实性和完整性。签名是通过将头部和载荷与秘密密钥进行加密生成的,只有持有密钥的一方才能验证签名。
引入依赖
<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<!-- https://mvnrepository.com/artifact/com.auth0/java-jwt -->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>4.4.0</version>
</dependency>
构造器方法
public JwtUtils(String encodedSecretKey, SignatureAlgorithm signatureAlgorithm) {
this.base64EncodedSecretKey = Base64.encodeBase64String(encodedSecretKey.getBytes());
this.signatureAlgorithm = signatureAlgorithm;
}
- 对传入的字符串进行一次
encode
操作
生成 token 的方法
/**
* 获取 JWT
*
* @param issUser 用户
* @param ttlTime 过期时间
* @param claims 其他信息
* @return 结果
*/
public String encode(String issUser, long ttlTime, Map<String, Object> claims) {
if (claims == null) {
claims = new HashMap<>();
}
long now = System.currentTimeMillis();
JwtBuilder jwtBuilder = Jwts.builder()
// 设置其他附带的信息
.setClaims(claims)
// 设置签发的时间
.setIssuedAt(new Date(now))
// 设置签发的用户
.setIssuer(issUser)
// 设置唯一标识
.setId(UUID.randomUUID().toString())
// 设置算法签名信息
.signWith(signatureAlgorithm, base64EncodedSecretKey);
// 设置过期时间
if (ttlTime > 0) {
long exp = ttlTime + now;
jwtBuilder.setExpiration(new Date(exp));
}
return jwtBuilder.compact();
}
- 设置一系列的基本信息:负载信息(可以没有)、签发时间、签发用户、唯一标识、算法签名信息、过期时间等;
- 这里有一个小坑:
signWith(signatureAlgorithm, base64EncodedSecretKey)
底层会对base64EncodedSecretKey
进行一次decode
操作,在验证的时候要自行对base64EncodedSecretKey
进行一次decode
;
decode 获取负载信息
/**
* 解析 jwt 获取载荷信息
*
* @param jwt 字符串
* @return 载荷信息
*/
public Claims decode(String jwt) {
return Jwts.parser()
.setSigningKey(base64EncodedSecretKey)
.parseClaimsJws(jwt)
.getBody();
}
- 通过
pareser
方法对 jwt 进行解析,获取claim
信息; setSigningKey
也会对base64EncodedSecretKey
进行一次decode
操作
verify 验证字符串
/**
* 验证 jwt
*
* @param jwt 字符串
* @return 结果
*/
public Boolean verify(String jwt) {
Algorithm algorithm = null;
switch (signatureAlgorithm) {
case HS256:
algorithm = Algorithm.HMAC256(Base64.decodeBase64(base64EncodedSecretKey));
break;
default:
throw new RuntimeException("not support other algorithm");
}
JWTVerifier jwtVerifier = JWT.require(algorithm).build();
jwtVerifier.verify(jwt);
// 校验不通过会抛出异常
// 判断合法的标准:1. 头部和荷载部分没有篡改过。2. 没有过期
return true;
}
- 在使用
Algorithm
获取算法时,先要对base64EncodedSecretKey
进行一次decode
操作; - 然后调用
jwtVerifier.verify(jwt)
验证token
; - 如果成功则直返回
true
,否则会抛出异常