1. 目标
本小节会先对Shiro的核心流程进行一次回顾,并进行梳理。然后会介绍如果应用是以API接口的方式提供给它方进行调用,那么在这种情况下如何使用Shiro框架来完成接口调用的认证和授权。
2. 核心架构
引用官方的架构图:
2.1 Subject(主体)
org.apache.shiro.subject.Subject
接口,翻译为主体,主体代表当前与软件系统交互的用户、程序或任何其他实体。Subject可以是实际用户(例如登录的用户),也可以是程序(例如后台任务或定时任务)。Shiro将Subject视为与安全相关操作的主要入口点,它封装了与安全相关的操作和状态。
与 Subject 相关的概念:
- Principal(身份): Principal代表了Subject的身份信息,通常是唯一标识Subject的信息,比如用户名、用户ID等。Principal通常用于认证过程中,用来标识Subject的身份。在Shiro中,Principal可以是任何对象(Object类型),但通常是字符串或者其他可以唯一标识Subject身份的对象。
- PrincipalCollection(身份集合): PrincipalCollection是一个集合,用于保存Subject的所有身份信息。在某些情况下,Subject可能具有多个身份信息,例如同时具有用户名、用户ID等多个身份。PrincipalCollection用于保存这些身份信息,并提供了一些便捷的方法来访问和操作这些身份信息。在Shiro中,Subject可以具有一个或多个Principal,它们都被保存在PrincipalCollection中。
在应用开发中,一般我们这样使用Subject:
- 获取Subject对象:通过
SecurityUtils.getSubject()
方法获取当前执行代码的Subject对象。 - 认证:如果用户尚未认证(即未登录),可以使用Subject对象进行认证操作。通常是创建一个AuthenticationToken对象,封装用户提交的身份信息和凭证信息,然后调用Subject的
login(AuthenticationToken token)
方法进行认证。 - 授权:认证成功后,可以使用Subject对象来进行权限控制。通过调用Subject的
hasRole(String role)
、isPermitted(String permission)
等方法来检查当前用户是否具有某个角色或权限。 - 会话管理:Subject对象还可以用于管理用户的会话信息。可以通过Subject对象获取当前用户的会话,或者手动创建会话,设置会话属性等。
- 注销:用户操作完成后,可以使用Subject对象进行注销操作,清除用户的认证状态和会话信息。
通过前面章节的分析,Web环境下,请求会先经过SpringShiroFilter过滤器,在过滤器的执行链中,创建Subject对象交给了securityManager来创建,而真正到底层的时候,SubJect 对象最终是由 org.apache.shiro.web.mgt.DefaultWebSubjectFactory
这个工厂来创建的。 在过滤器中调用SecurityManager来创建Subject实例对象之前会创建一个SubjectContext。
Subject 上下文:它的作用是为Subject的创建提供了一个统一的上下文环境,可以在其中设置和获取Subject的相关配置信息,还可以用于传递Subject的上下文信息,例如认证状态、会话状态等。它其实就是一个
java.util.Map
, 这个Map中存放了以下的key:
- SECURITY_MANAGER (securityManager对象)
- SESSION_ID (sessionId)
- SUBJECT(subject)
- PRINCIPALS 身份信息
- SESSION 会话
- AUTHENTICATED 是否认证
- AUTHENTICATION_INFO (reaml 中的 AuthenticationInfo,即认证信息)
- AUTHENTICATION_TOKEN (提交的认证token信息)
这个对象刚被创建出来的时候,里面的数据是空的。但是随着调用链的深入,这些信息将会被逐步填充进去
在应用中我们一般用SecurityUtils.getSubject()
方法来获取当前的subject对象。我们发现它是一个静态方法,而且不管在什么时候调用,得到的都是同一个subject对象。
前面分析过,过滤器中得到subject
对象之后,subject将会被绑定到当前线程上。实际就是使用了 ThreadLocal
的子类 java.lang.InheritableThreadLocal
(它绑定了一个Map结构,map中有两个key,一个是securityManager的key,一个是subject的key) 。Shiro框架用 ThreadContext
这个类对 ThreadLocal
进行了封装,分别提供了绑定和解绑 securityManager
和subject
对象的方法。
因为底层使用的是
java.lang.InheritableThreadLocal
所以在主线程以及这个主线程创建的子线程中获取到的Subject信息是一致的
在Web应用中,每个HTTP请求都会对应一个Subject对象,而DefaultWebSubjectFactory
负责在每个请求到达时创建对应的Subject对象。
2.2 SecurityManager(安全管理器)
定义:
public interface SecurityManager extends Authenticator, Authorizer, SessionManager{
...
}
从定义可以看出,SecurityManager虽然叫做 安全管理器,它从Authenticator, Authorizer, SessionManager 几个接口继承而来,也就是说它具备认证和鉴权还有会话管理器的功能。默认情况下使用的实现类是:org.apache.shiro.web.mgt.DefaultWebSecurityManager
安全管理器是Shiro框架的核心组件,负责管理所有的Subject对象,并协调它们之间的安全操作。SecurityManager是一个入口点,提供了对Shiro的所有功能的访问,并负责执行安全策略、协调身份验证和授权、管理会话等操作。
也就是说subject中的一些方法调用,都将全部委托给 SecurityManager对象来完成,它是真正"协调干活" 的人
下面是SecurityManager 三个重要的"能力": Authenticator(认证), Authorizer(鉴权/授权), SessionManager(会话管理)
2.3 Authentication(认证)
org.apache.shiro.authc.Authenticator
是个接口。 通过前面的例子,我们知道认证的过程其实就是 :
-
收集用户提供的身份信息,叫做(
org.apache.shiro.authc.AuthenticationToken
认证令牌接口),它包含了两部分信息:- Principal: 身份信息,Object类型,可以是任意对象。 它与Subject中的Principal概念是一致的,都表示身份,比如用户名
- Credentials:凭证信息,Object类型,可以是任意对象,比如密码,数字证书 等
默认使用的是
org.apache.shiro.authc.UsernamePasswordToken
实现类 -
subject 调用login方法进行认证,这个调用转交给 SecurityManager(它继承了Authenticator接口),
-
SecurityManager 调用对应的Realm, 获取认证信息(
org.apache.shiro.authc.AuthenticationInfo
), 它包含了两部分信息:- PrincipalCollection 身份信息集合。 注意与 AuthenticationToken中的区别, AuthenticationInfo中是合法的,可以有多个,而 AuthenticationToken中是提交的身份,未认证的身份信息,只是一个
- Credentials: 凭证信息。 注意与 AuthenticationToken中的区别, AuthenticationInfo中是合法凭证,如密码。 而 而 AuthenticationToken中是提交的凭证,未认过的。
默认使用的是
org.apache.shiro.authc.SimpleAuthenticationInfo
实现类。如果为SecurityManager配置了缓存管理器,SecurityManager会将这个缓存管理器应用到每个Reaml上, Reaml 获取的AuthenticationInfo就会被缓存起来了。
-
Realm调用配置给它的匹配器
org.apache.shiro.authc.credential.CredentialsMatcher
将 AuthenticationToken和 AuthenticationInfo 进行对比,判定是否认证成功 -
SecurityManager 调用SessionManager创建Session,并调用sessionDAO 保存session
2.4 Authorization(授权)
org.apache.shiro.authz.Authorizer
是个接口, SecurityManager(它继承了Authorizer接口)。 前面例子中,在Controller方法上使用了 @RequiresRoles("admin")
, @RequiresPermissions("employee:read")
等,此时就会执行授权流程,或者直接调用subject.checkPermission
,subject.isPermitted
方法就会进去授权流程。SecurityManager 同样会调用realm 来获取 org.apache.shiro.authz.AuthorizationInfo
, 其中包含了权限与角色信息。常用的实现类是org.apache.shiro.authz.SimpleAuthorizationInfo
同样,如果配置了缓存管理器,AuthorizationInfo将会被缓存起来。
2.5 Realm(域)
org.apache.shiro.realm.Realm
是个接口,一般应用都会自定义Realm,都会继承org.apache.shiro.realm.AuthorizingRealm
即可, Realm从数据源(如数据库)中获取用户身份(Principal)和权限信息,并根据这些信息进行认证和授权操作。在认证过程中,Realm根据传入的Principal(通常是用户名)从数据源中获取对应的密码和其他身份信息,然后与传入的凭证进行比较以验证身份的真实性。在授权过程中,Realm根据Principal获取对应的权限信息,并判断Subject是否具有某项操作的权限。
前面我们自己定义了SystemAccountRealm
用Map模拟了用户身份信息,角色,权限信息。自定义了一个匹配器 Sha256HashCredentialsMatcher
对密码加salt后进行了两次 hash计算,再与AuthenticationInfo 中的凭证进行比较。
2.6 SessionManager
org.apache.shiro.session.mgt.SessionManager
是个接口,SecurityManager继承了这个接口,用来管理session。前面我们定义了自己的SessionManager AccessTokenWebSessionManager
实现了在禁用Cookie的情况下,从请求头中获取SessionID来保持会话。
2.7 SessionDAO
org.apache.shiro.session.mgt.eis.SessionDAO
主要用来实现Session的增,删,改。前面我们实现了 ShiroRedisSessionDAO
用来把session保存到Redis中
2.8 CacheManager
org.apache.shiro.cache.CacheManager
是个接口,前面我们自己实现了ShiroRedisCacheManager
,用来将 AuthenticationInfo
,和AuthorizationInfo
缓存到Redis中。当然值实现 CacheManager 是不行的,还写了一个 ShiroRedisCache
实现了 org.apache.shiro.cache.Cache
接口。
也可以为SessionManager设置缓存管理器,用来缓存活跃session数据
3. 对API接口访问的认证
如果现在我们的应用需要开放API接口供它方进行调用,一般我们会为它方应用分配一个以下几个参数:
-
access_key
身份标识符 -
secret_key
秘钥,一般用来对API请求进行签名,防止请求数据被劫持,篡改后重放。 -
app_id
应用ID。如果它方有多种不同的应用要接入,可以使用这个参数来标识不同的应用场景。这个参数不是必须的,可以根据实际情况来决定是否需要分配这个参数。
3.1 它方接入规范
它方拿到分配的参数后,我们需要制定接入规范,这里做一些简单的HTTP报文规范:
-
所有HTTP报文METHOD使用 POST
-
数据以JSON格式放入到 HTTP报文 BODY中。(文件传输除外)
-
HTTP报文请求头加入 :
-
X-Access-Key
分配的身份标识 -
X-Access-Timestamp
请求发起的时间戳(Unix timestamp)毫秒单位 -
X-Access-Sign
请求数据签名签名算法:SignContent =JSON字符串(UTF-8编码)自然排序+时间戳 , Sign=SHA256(SignContent,
secret_key
) -
X-Access-AppId
应用程序ID
-
3.2 服务端
它方按照上面的规范组织好报文,然后发送给服务端。服务端利用Shiro框架来进行认证和验证签名。
此时客户端提交的报文首先经过我们自己定义的Filter。前面代码也自定义了一个Filter,因为是使用用户名,密码的认证方式,所以它从org.apache.shiro.web.filter.authc.FormAuthenticationFilter
继承,使用的是 UsernamePasswordToken
,这个Token是框架自带的。
现在的情况发生了变化,提交的不再是用户名密码,而是分配的X-Access-Key
和 X-Access-AppId
,还有时间戳,签名等信息。所以我们要自定义AuthenticationToken
,每个请求都需要进行认证。这个例子中就只做简单验证:X-Access-Key,X-Access-AppId 能和数据库中的信息对应上而且签名正确就认证成功。具体项目中根据安全级别可以自行设计更加复杂,安全性更高的认证算法。
身份信息保存在了数据库中,那么每次都要查询效率很低,所以需要引入缓存。
所以接下来需要做如下几件事情:
- 自定义
AuthenticationToken
, 直接实现org.apache.shiro.authc.AuthenticationToken
接口 - 自定义Filter,继承
org.apache.shiro.web.filter.authc.AuthenticatingFilter
在Filter中完成AuthenticationToken
的创建,执行登录。因为只有执行登录,securityManager才会通过reaml来完成认证的动作 - 自定义Realm,继承
org.apache.shiro.realm.AuthorizingRealm
- 自定义匹配器,继承
com.qinyeit.shirojwt.demos.shiro.matcher.CodecSupport
- 配置
- 配置filter
- 配置realm
4. 自定义AuthenticationToken
这里直接实现AuthenticationToken 接口
package com.qinyeit.shirojwt.demos.shiro.token;
import lombok.Data;
import org.apache.shiro.authc.AuthenticationToken;
@Data
public class ApiAuthenticationToken implements AuthenticationToken {
private String accessKey; // 身份标识
private String accessTimestamp;// 访问时间戳
private String accessSign;// 参数签名
private String accessAppId; // 客户端应用ID
private String requestBody; //请求报文Body,JSON格式
public ApiAuthenticationToken(String accessKey, String accessTimestamp,
String accessSign, String accessAppId, String requestBody) {
this.accessKey = accessKey;
this.accessTimestamp = accessTimestamp;
this.accessSign = accessSign;
this.accessAppId = accessAppId;
this.requestBody = requestBody;
}
// 身份信息
@Override
public Object getPrincipal() {
return getAccessKey(); // 返回身份标识
}
// 凭证
@Override
public Object getCredentials() {
return accessSign; // 返回参数签名
}
}
5. 自定义Filter
这个Filter 直接从 AuthenticatingFilter
继承。 在这个类中主要完成两个任务:
- 创建自定义的ApiAuthenticationToken对象。即从请求报文中取出需要的数据。
- 执行登录,让框架进行认证
5.1 构建ApiAuthenticationToken对象
我们首先需要从请求头上取出:
X-Access-Key
分配的身份标识X-Access-Timestamp
请求发起的时间戳(Unix timestamp)毫秒单位X-Access-Sign
请求数据签名X-Access-AppId
应用程序ID- HTTP Body 内容
这里有一个问题: 取出body需要通过request中的Stream来读取其内容,一旦stream被读取之后,它是无法重置的,这样这个reqeust对象到达Spring Web框架的时候,Spring Controller 就无法获取请求的内容了。所以这里我们需要一个HttpServletRequestWrapper
类对reqeust对象进行包装,使得后续spring Controller中还可以继续获取内容。
Spring提供了org.springframework.web.util.ContentCachingRequestWrapper
,它从javax.servlet.http.HttpServletRequestWrapper
继承,从原始InputStream 流中读取内容,并包装到内部的ContentCachingInputStream
中使得后续可以继续获取请求体的内容。
查看源码后发现,它对于底层的 ServletInputSream并没有很好的封装,我们现在需要的是在Filter中读取Request Body中的内容。但是实验发现读取不到。所以干脆就仿照 ContentCachingRequestWrapper
自己封装一个,名字还是叫做 ContentCachingRequestWrapper
,其思路就是在ContentCachingRequestWrapper 的构造方法中,立即读取ServletInputStream中的内容缓存起来,即将原始流中的内容拷贝到 ·ByteArrayOutputStream· 中。后面需要读取数据的时候,将缓存中所有的字节读取出来再次包装成 ServletInputSream,这样就可以重复读取数据了。
public class ContentCachingRequestWrapper extends HttpServletRequestWrapper {
private static final Logger LOGGER = LoggerFactory.getLogger(ContentCachingRequestWrapper.class);
private final ByteArrayOutputStream cachedContent;
private Map<String, String[]> cachedForm;
@Nullable
private ServletInputStream inputStream;
public ContentCachingRequestWrapper(HttpServletRequest request) {
super(request);
this.cachedContent = new ByteArrayOutputStream();
this.cachedForm = new HashMap<>();
cacheData();
}
@Override
public ServletInputStream getInputStream() throws IOException {
this.inputStream = new ContentCachingInputStream(cachedContent.toByteArray());
return this.inputStream;
}
@Override
public String getCharacterEncoding() {
String enc = super.getCharacterEncoding();
return (enc != null ? enc : WebUtils.DEFAULT_CHARACTER_ENCODING);
}
@Override
public BufferedReader getReader() throws IOException {
return new BufferedReader(new InputStreamReader(getInputStream(), getCharacterEncoding()));
}
@Override
public String getParameter(String name) {
String value = null;
if (isFormPost()) {
String[] values = cachedForm.get(name);
if (null != values && values.length > 0) {
value = values[0];
}
}
if (StringUtils.isEmpty(value)) {
value = super.getParameter(name);
}
return value;
}
@Override
public Map<String, String[]> getParameterMap() {
if (isFormPost() && !CollectionUtils.sizeIsEmpty(cachedForm)) {
return cachedForm;
}
return super.getParameterMap();
}
@Override
public Enumeration<String> getParameterNames() {
if (isFormPost() && !CollectionUtils.sizeIsEmpty(cachedForm)) {
return Collections.enumeration(cachedForm.keySet());
}
return super.getParameterNames();
}
@Override
public String[] getParameterValues(String name) {
if (isFormPost() && !CollectionUtils.sizeIsEmpty(cachedForm)) {
return cachedForm.get(name);
}
return super.getParameterValues(name);
}
private void cacheData() {
try {
if (isFormPost()) {
this.cachedForm = super.getParameterMap();
} else {
ServletInputStream inputStream = super.getInputStream();
StreamUtils.copy(inputStream, this.cachedContent);
}
} catch (IOException e) {
LOGGER.warn("[RepeatReadHttpRequest:cacheData], error: {}", e.getMessage());
}
}
private boolean isFormPost() {
String contentType = getContentType();
return (contentType != null &&
(contentType.contains(MediaType.APPLICATION_FORM_URLENCODED_VALUE) ||
contentType.contains(MediaType.MULTIPART_FORM_DATA_VALUE)) &&
HttpMethod.POST.matches(getMethod()));
}
private class ContentCachingInputStream extends ServletInputStream {
private final ByteArrayInputStream inputStream;
public ContentCachingInputStream(byte[] bytes) {
this.inputStream = new ByteArrayInputStream(bytes);
}
@Override
public int read() throws IOException {
return this.inputStream.read();
}
@Override
public int readLine(byte[] b, int off, int len) throws IOException {
return this.inputStream.read(b, off, len);
}
@Override
public boolean isFinished() {
return this.inputStream.available() == 0;
}
@Override
public boolean isReady() {
return true;
}
@Override
public void setReadListener(ReadListener listener) {
}
}
}
接着在Filter中进行包装:
@Slf4j
public class ApiAuthenticationFilter extends AuthenticatingFilter {
private boolean isNeedWrapper(ServletRequest request) {
// 因为请求先通过了 ShiroFilter,已经被包装成了ShiroHttpServletRequest
// 如果没有包装成 ShiroHttpServletRequest,说明不是Shiro环境,就没有必要包装
if (!(request instanceof ShiroHttpServletRequest)) {
return false;
}
HttpServletRequest req = WebUtils.toHttp(request);
String requestMethod = req.getMethod().toUpperCase();
//只针对 json数据提交,并且是POST提交或者是PUT提交
if (request.getContentType() != null
&& request.getContentType().contains("application/json")
&& ("POST".equals(requestMethod) || "PUT".equals(requestMethod))) {
return true;
}
return false;
}
// 包装 request对象,使得请求到达SpringWeb框架后可以重复读取请求体内容
@Override
public void doFilterInternal(ServletRequest request, ServletResponse response, FilterChain chain)
throws ServletException, IOException {
if (isNeedWrapper(request)) { // 满足条件才进行包装,否则不包装
super.doFilterInternal(new ContentCachingRequestWrapper(WebUtils.toHttp(request)), response, chain);
} else {
super.doFilterInternal(request, response, chain);
}
}
/**
* 从请求中获取认证Token信息
*
* @param request
* @param response
* @return
* @throws Exception
*/
@Override
protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) throws Exception {
HttpServletRequest req = WebUtils.toHttp(request);
String accessKey = req.getHeader("X-Access-Key");
String accessTimestamp = req.getHeader("X-Access-Timestamp");
String accessSign = req.getHeader("X-Access-Sign");
String accessAppId = req.getHeader("X-Access-AppId");
ContentCachingRequestWrapper cachedRequestWrapper = (ContentCachingRequestWrapper) request;
String requestBody = new String(cachedRequestWrapper.getContentAsByteArray(),
cachedRequestWrapper.getCharacterEncoding());
return new ApiAuthenticationToken(accessKey, accessTimestamp, accessSign, accessAppId, requestBody);
}
}
5.2 登录认证
在现在的场景下,每个API的每次调用都需要进行认证,不需要进行会话保持,每次请求过来都是未认证的,所以一定会调用onAccessDenied
,所以只需要在这个方法中做登录认证操作即可
@Slf4j
public class ApiAuthenticationFilter extends AuthenticatingFilter {
...
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
// 父类中的executeLogin 方法会调用 onLoginSuccess或者 onLoginFailure,所以要重写这两个方法
return super.executeLogin(request, response);
}
// 认证成功直接放行
@Override
protected boolean onLoginSuccess(AuthenticationToken token, Subject subject, ServletRequest request, ServletResponse response) throws Exception {
return true;
}
// 认证失败响应消息
@Override
protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response) {
Map<String, ?> result = Map.of("code", 401, "msg", "未授权,请联系我们");
responseJsonResult(result, response);
return false;
}
// 向调用方发送JSON数据
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);
}
}
}
}
6. 自定义Realm
自定义的Realm直接继承AuthorizingRealm
,声明它支持的Token类型是ApiAuthenticationToken
。
6.1 准备一些静态数据
@Data
@ToString
@Builder
public class ApiAccount implements Serializable {
private String appId;
private String accessKey;
private String secretKey;
}
6.2 准备一个匹配器
匹配器是用来对比数据的,即对比提交的 AauthenticationToken 中的内容和 从Realm中获取的认证信息是否匹配。
这里我们需要做两个方面的验证:
- 提交的AccessKey和 AppID是否在我们的系统中存在,如果不存在则不允许访问
- 验证请求参数的签名
public class ApiAuthenticationCredentialsMatcher extends CodecSupport implements CredentialsMatcher {
@Override
public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) {
// 取出真实身份信息
Object primaryPrincipal = info.getPrincipals().getPrimaryPrincipal();
// 取出 token 中的身份信息
ApiAuthenticationToken apiAuthenticationToken = (ApiAuthenticationToken) token;
// 如果身份信息是 SystemAccount 对象
// 此时要注意,Realm 中要将 ApiAccount 对象放入到 AuthenticationInfo 中
if (primaryPrincipal instanceof ApiAccount account) {
String accessKey = account.getAccessKey();
// 秘钥
String secretKey = account.getSecretKey();
String appId = account.getAppId();
//简单验证账号信息,这里可以根据需要增加验证复杂性
if (accessKey.equals(apiAuthenticationToken.getAccessKey()) &&
appId.equals(apiAuthenticationToken.getAccessAppId())) {
// 验证签名
return verifySign(secretKey, apiAuthenticationToken);
}
}
return false;
}
// 验证签名 从realm中取出 secreKey 秘钥进行签名,然后与提交的签名进行对比
private boolean verifySign(String secretKey, ApiAuthenticationToken apiAuthenticationToken) {
// 提交的签名串
String signInToken = apiAuthenticationToken.getAccessSign();
if (StringUtils.isBlank(signInToken)) {
return false;
}
log.info("body:{}", apiAuthenticationToken.getRequestBody());
// SignContent =JSON字符串(UTF-8编码)字典排序+时间戳 , Sign=SHA256(SignContent,`secret_key` )
// 字符串字典顺序排序
char[] jsonChars = apiAuthenticationToken.getRequestBody().toCharArray();
log.info("jsonChars:{}", jsonChars);
Arrays.sort(jsonChars);
log.info("jsonChars:{}", jsonChars);
String signContent = new String(jsonChars) + apiAuthenticationToken.getAccessTimestamp();
// 签名
String sign = new Sha256Hash(signContent, secretKey).toHex();
log.info("signContent:{}", signContent);
log.info("signInToken:{}, timestamp:{}", signInToken, apiAuthenticationToken.getAccessTimestamp());
log.info("sign:{}", sign);
// 比较两个签名
return signInToken.equals(sign);
}
}
6.3 定义Realm
@Slf4j
public class ApiAuthenticationRealm extends AuthorizingRealm {
private Map<String, ApiAccount> apiAccountMap = new HashedMap();
// 模拟数据库
public ApiAuthenticationRealm() {
// 指定密码匹配器
super(new ApiAuthenticationCredentialsMatcher());
// key是 accessToken
apiAccountMap.put("db0f017ac3cacb", ApiAccount.builder()
.accessKey("db0f017ac3cacb")
.secretKey("cbce2d1aad0867f8317e7ebeb3427999")
.appId("123456")
.build());
apiAccountMap.put("f0ac034fad089", ApiAccount.builder()
.accessKey("f0ac034fad089")
.secretKey("cbce2d1aad0867f8317e7ebeb3427888")
.appId("456789")
.build());
}
// 声明它只支持 ApiAuthenticationToken
@Override
public boolean supports(AuthenticationToken token) {
return token != null && ApiAuthenticationToken.class.isAssignableFrom(token.getClass());
}
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
// 如果需要某些api需要授权才能访问,这里可以返回授权信息
return null;
}
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
// 1.从传过来的认证Token信息中,实际类型是ApiAuthenticationToken
// ApiAuthenticationToken 重写了 getPrincipal() 返回的就是 accessKey
String accessKey = token.getPrincipal().toString();
log.info("Realm accessKey:{}", accessKey);
// 2.通过用户名到数据库中获取整个用户对象
ApiAccount apiAccount = apiAccountMap.get(accessKey);
if (apiAccount == null) {
throw new UnknownAccountException();
}
// 3. 创建认证信息,即用户正确的用户名和密码。
// 四个参数:
// 第一个参数为主体,第二个参数为凭证,第三个参数为Realm的名称
// 因为上面将凭证信息和主体身份信息都保存在 apiAccount,所以这里直接将 apiAccount 对象作为主体信息即可
// 第二个参数表示凭证,匹配器中会从 SystemAccount中获取盐值,密码登凭证信息,所以这里直接传null。
// 第三个参数,表示盐值,这里使用了自定义的SaltSimpleByteSource,之所以在这里new了一个自定义的SaltSimpleByteSource,
// 是因为开启redis缓存的情况下,序列化会报错
// 第四个参数表示 Realm的名称
// 这里将 apiAccount 整个对象放进去,其它传空,匹配器中能获取到apiAccount 就可以进行对比认证了
SimpleAuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo(
apiAccount,
null,
null,
getName()
);
return authenticationInfo;
}
}
7. 配置
因为api调用场景下,都是是无状态该的。所以基本上不会的session进行跟踪。所以无需再配置 sessionManager和 SessionDAO
@Configuration
@Slf4j
public class ShiroConfiguration {
@Bean
public Realm realm() {
ApiAuthenticationRealm realm = new ApiAuthenticationRealm();
// 开启全局缓存
realm.setCachingEnabled(true);
// 打开认证缓存
realm.setAuthenticationCachingEnabled(true);
// 认证缓存的名字,不设置也可以,默认由
realm.setAuthenticationCacheName("shiro:authentication:cache");
return realm;
}
@Bean
public CacheManager cacheManager(RedisTemplate redisTemplate) {
RedisSerializer<String> stringSerializer = RedisSerializer.string();
// 设置key的序列化器
redisTemplate.setKeySerializer(stringSerializer);
// 设置 Hash 结构中 key 的序列化器
redisTemplate.setHashKeySerializer(stringSerializer);
return new ShiroRedisCacheManager(redisTemplate);
}
/**
* 重要配置
* ShiroFilter 的 FactoryBean
*
* @param securityManager
* @return
*/
@Bean
protected ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) {
ShiroFilterFactoryBean filterFactoryBean = new ShiroFilterFactoryBean();
filterFactoryBean.setSecurityManager(securityManager);
filterFactoryBean.setFilterChainDefinitionMap(shiroFilterChainDefinition().getFilterChainMap());
filterFactoryBean.setFilters(getCustomerShiroFilter());
return filterFactoryBean;
}
/**
* URL配置
*
* @return
*/
private ShiroFilterChainDefinition shiroFilterChainDefinition() {
DefaultShiroFilterChainDefinition chainDefinition = new DefaultShiroFilterChainDefinition();
chainDefinition.addPathDefinition("/**", "authc");
return chainDefinition;
}
/**
* 自定义拦截器
*
* @return
*/
private Map<String, Filter> getCustomerShiroFilter() {
ApiAuthenticationFilter authcFilter = new ApiAuthenticationFilter();
Map<String, Filter> filters = new HashMap<>();
filters.put("authc", authcFilter);
return filters;
}
8. 准备Controller接收数据
@RestController
@Slf4j
@RequestMapping("/api/employees")
public class EmployeeApiController {
@PostMapping
public void create(@RequestBody Employee employee) {
log.info("创建员工: {}", employee);
}
}
9. 写一个用例计算签名
这里我们使用固定提交的数据,然后计算出签名
@Slf4j
public class ApiSignTest {
@Test
public void getSign() {
// 请求地址 /api/employees
// 请求参数
Employee employee = new Employee();
employee.setName("张三");
employee.setGender("男");
String jsonBody = JSON.toJSONString(employee);
// 请求时间戳
Long timestamp = System.currentTimeMillis();
// 签名秘钥
String secretKey = "cbce2d1aad0867f8317e7ebeb3427999";
char[] jsonChars = jsonBody.toCharArray();
Arrays.sort(jsonChars);
String signContent = new String(jsonChars) + timestamp;
// 签名
String sign = new Sha256Hash(signContent, secretKey).toHex();
log.info("请求地址:{}", "/api/employees");
log.info("X-Access-Key:{}", "db0f017ac3cacb");
log.info("X-Access-Timestamp:{}", timestamp);
log.info("X-Access-Sign:{}", sign);
log.info("X-Access-AppId:{}", "123456");
log.info("Request Body:{}", jsonBody);
}
}
输出:
请求地址:/api/employees
X-Access-Key:db0f017ac3cacb
X-Access-Timestamp:1711866992050
X-Access-Sign:987b71f4961d78b95acaa019a70ac0a6439a6a566d9bb800fa0078feba8d7864
X-Access-AppId:123456
Request Body:{"gender":"男","name":"张三"}
10. 发送报文
POST /api/employees HTTP/1.1
Host: 127.0.0.1:8080
X-Access-Key: db0f017ac3cacb
X-Access-Timestamp: 1711866992050
X-Access-Sign: 987b71f4961d78b95acaa019a70ac0a6439a6a566d9bb800fa0078feba8d7864
X-Access-AppId: 123456
Content-Type: application/json
Content-Length: 43
{"name": "张三","gender": "男"}
如果access-key , appid没有对应,或者签名不正确则会返回:
{
"code": 401,
"msg": "未授权,请联系我们"
}
代码仓库 https://github.com/kaiwill/shiro-jwt , 本节代码在 7_springboot_shiro_jwt_多端认证鉴权_自定义AuthenticationToken 分支上.