7_springboot_shiro_jwt_多端认证鉴权_自定义AuthenticationToken

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:

  1. 获取Subject对象:通过SecurityUtils.getSubject()方法获取当前执行代码的Subject对象。
  2. 认证:如果用户尚未认证(即未登录),可以使用Subject对象进行认证操作。通常是创建一个AuthenticationToken对象,封装用户提交的身份信息和凭证信息,然后调用Subject的login(AuthenticationToken token)方法进行认证。
  3. 授权:认证成功后,可以使用Subject对象来进行权限控制。通过调用Subject的hasRole(String role)isPermitted(String permission)等方法来检查当前用户是否具有某个角色或权限。
  4. 会话管理:Subject对象还可以用于管理用户的会话信息。可以通过Subject对象获取当前用户的会话,或者手动创建会话,设置会话属性等。
  5. 注销:用户操作完成后,可以使用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 进行了封装,分别提供了绑定和解绑 securityManagersubject 对象的方法。

因为底层使用的是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 是个接口。 通过前面的例子,我们知道认证的过程其实就是 :

  1. 收集用户提供的身份信息,叫做(org.apache.shiro.authc.AuthenticationToken 认证令牌接口),它包含了两部分信息:

    • Principal: 身份信息,Object类型,可以是任意对象。 它与Subject中的Principal概念是一致的,都表示身份,比如用户名
    • Credentials:凭证信息,Object类型,可以是任意对象,比如密码,数字证书 等

    默认使用的是org.apache.shiro.authc.UsernamePasswordToken 实现类

  2. subject 调用login方法进行认证,这个调用转交给 SecurityManager(它继承了Authenticator接口),

  3. 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就会被缓存起来了。

  4. Realm调用配置给它的匹配器 org.apache.shiro.authc.credential.CredentialsMatcher 将 AuthenticationToken和 AuthenticationInfo 进行对比,判定是否认证成功

  5. 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报文规范:

  1. 所有HTTP报文METHOD使用 POST

  2. 数据以JSON格式放入到 HTTP报文 BODY中。(文件传输除外)

  3. 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-KeyX-Access-AppId ,还有时间戳,签名等信息。所以我们要自定义AuthenticationToken,每个请求都需要进行认证。这个例子中就只做简单验证:X-Access-Key,X-Access-AppId 能和数据库中的信息对应上而且签名正确就认证成功。具体项目中根据安全级别可以自行设计更加复杂,安全性更高的认证算法。

身份信息保存在了数据库中,那么每次都要查询效率很低,所以需要引入缓存。

所以接下来需要做如下几件事情:

  1. 自定义AuthenticationToken , 直接实现org.apache.shiro.authc.AuthenticationToken 接口
  2. 自定义Filter,继承org.apache.shiro.web.filter.authc.AuthenticatingFilter在Filter中完成 AuthenticationToken 的创建,执行登录。因为只有执行登录,securityManager才会通过reaml来完成认证的动作
  3. 自定义Realm,继承org.apache.shiro.realm.AuthorizingRealm
  4. 自定义匹配器,继承 com.qinyeit.shirojwt.demos.shiro.matcher.CodecSupport
  5. 配置
    1. 配置filter
    2. 配置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 继承。 在这个类中主要完成两个任务:

  1. 创建自定义的ApiAuthenticationToken对象。即从请求报文中取出需要的数据。
  2. 执行登录,让框架进行认证

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中获取的认证信息是否匹配。

这里我们需要做两个方面的验证:

  1. 提交的AccessKey和 AppID是否在我们的系统中存在,如果不存在则不允许访问
  2. 验证请求参数的签名
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 分支上.

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/504402.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

数字化服务升级:数字乡村改善农民生活质量

随着信息技术的迅猛发展&#xff0c;数字化浪潮已经深入社会的各个角落&#xff0c;为人们的生活带来了翻天覆地的变化。在乡村地区&#xff0c;数字化服务的升级正在逐步改变农民的生活方式&#xff0c;提高他们的生活质量。本文将围绕数字化服务升级&#xff0c;探讨数字乡村…

微信小程序开发【从入门到精通】——页面导航

&#x1f468;‍&#x1f4bb;个人主页&#xff1a;开发者-曼亿点 &#x1f468;‍&#x1f4bb; hallo 欢迎 点赞&#x1f44d; 收藏⭐ 留言&#x1f4dd; 加关注✅! &#x1f468;‍&#x1f4bb; 本文由 曼亿点 原创 &#x1f468;‍&#x1f4bb; 收录于专栏&#xff1a…

C++教学——从入门到精通 5.单精度实数float

众所周知&#xff0c;三角形的面积公式是(底*高)/2 那就来做个三角形面积计算器吧 到吗如下 #include"bits/stdc.h" using namespace std; int main(){int a,b;cin>>a>>b;cout<<(a*b)/2; } 这不对呀&#xff0c;明明是7.5而他却是7&#xff0c;…

【面试经典150 | 动态规划】最小路径和

文章目录 写在前面Tag题目来源解题思路方法一&#xff1a;动态规划方法二&#xff1a;空间优化 写在最后 写在前面 本专栏专注于分析与讲解【面试经典150】算法&#xff0c;两到三天更新一篇文章&#xff0c;欢迎催更…… 专栏内容以分析题目为主&#xff0c;并附带一些对于本题…

DELL服务器使用iDRAC升级BIOS等固件版本

前言 正值DELL推出DELL R730XD服务器最新的BIOS固件&#xff08;2.19.0 2024/3/18&#xff09;之际&#xff0c;本人也有合适的时间将手头的服务器BIOS固件进行升级操作。 本文博将DELL R730xd 的iDRAC8版本为例&#xff0c;介绍整个升级过程。其他DELL类型的服务器操作类似&…

探究贪心算法:特点与实际应用

探究贪心算法&#xff1a;特点与实际应用 探究贪心算法&#xff1a;特点与实际应用&#x1f4dd; 摘要&#x1f680; 引言&#x1f4cb; 正文内容&#xff08;详细介绍&#xff09;&#x1f4cc; 小结&#x1f4ca; 表格总结&#x1f3af; 总结&#x1f52e; 未来展望&#x1f…

一篇搞定AVL树+旋转【附图详解旋转思想】

&#x1f389;个人名片&#xff1a; &#x1f43c;作者简介&#xff1a;一名乐于分享在学习道路上收获的大二在校生 &#x1f648;个人主页&#x1f389;&#xff1a;GOTXX &#x1f43c;个人WeChat&#xff1a;ILXOXVJE &#x1f43c;本文由GOTXX原创&#xff0c;首发CSDN&…

将jupyter notebook文件导出为pdf(简单有效)

1.打开jupyter notebook笔记&#xff1a; 2.点击file->print Preview 3.在新打开的页面右键打印 4.另存为PDF 5.保存即可 6.pdf效果 &#xff08;可能有少部分图片显示不了&#xff09; 网上也有其他方法&#xff0c;比如将其转换为.tex再转为PDF等&#xff0c;但个人觉…

STM32的芯片无法在线调试的情况分析

问题描述 本博客的目的在于帮助网友尽快地解决问题&#xff0c; 避免浪费时间&#xff0c; 查漏补缺。 在stm32的开发过程中&#xff0c;有时会遇到"STM No Target connected"的错误提示&#xff0c;这说明MDK开发环境无法与目标设备进行通信&#xff0c;导致无法烧…

Node.js网上购物商城-计算机毕业设计源码99525

摘 要 随着社会的发展&#xff0c;计算机的优势和普及使得网上购物商城的开发成为必需。网上购物商城主要是借助计算机&#xff0c;通过对首页、站点管理&#xff08;轮播图、公告栏&#xff09;用户管理&#xff08;管理员、注册用户&#xff09;内容管理&#xff08;商城资讯…

数据可视化-ECharts Html项目实战(8)

在之前的文章中&#xff0c;我们学习了如何设置散点图涟漪效果与仪表盘动态指针效果。想了解的朋友可以查看这篇文章。同时&#xff0c;希望我的文章能帮助到你&#xff0c;如果觉得我的文章写的不错&#xff0c;请留下你宝贵的点赞&#xff0c;谢谢 今天的文章&#xff0c;会…

python基础——异常捕获【try-except、else、finally】

&#x1f4dd;前言&#xff1a; 这篇文章主要介绍一下python基础中的异常处理&#xff1a; 1&#xff0c;异常 2&#xff0c;异常的捕获 3&#xff0c;finally语句 &#x1f3ac;个人简介&#xff1a;努力学习ing &#x1f4cb;个人专栏&#xff1a;C语言入门基础以及python入门…

C语言-写一个宏,可以将一个整数的二进制位的奇数位和偶数位交换。

0xaaaaaaaa...等是什么&#xff1f;-CSDN博客https://blog.csdn.net/Jason_from_China/article/details/137179252 #define _CRT_SECURE_NO_WARNINGS 1 #include<stdio.h> #define SWAP(num) (((num & 0xAAAAAAAA) >> 1) | ((num & 0x55555555) << …

FUSB302BMPX 可编程USB芯片控制器 接口集成电路 302B Type-C Control IC with PD

FUSB302BMPX是一种可编程的USB Type-C控制器&#xff0c;由安森美半导体公司生产。它支撑USB Type-C检测&#xff0c;包含衔接和方向&#xff0c;并集成了USB BMC功率输送协议的物理层&#xff0c;可完成高达100W的电源和角色交换。该控制器适用于希望完成DRP/SRC/SNK USB Type…

【C语言】宏定义

1. 预定义符号 C语言设置了一些预定符号&#xff0c;可以直接使用&#xff0c;预定义符号也是在预处理期间处理的。 __FILE__ //进⾏编译的源⽂件 __LINE__ //⽂件当前的⾏号 __DATE__ //⽂件被编译的⽇期 __TIME__ //⽂件被编译的时间 __STDC__ //如果编译器遵循ANSI C&…

Unix信号处理

信号的基本概念我已经在上一节中简单介绍了&#xff0c;大家可以去看我的上一篇博客&#xff1a; Unix中的进程和线程-2-CSDN博客 1.信号的产生 kill函数&#xff1a; #include <signal.h> #include <fcntl.h> #include<t_stdio.h> //自定义信号处理函数,n为…

JavaScript基础语法–变量

文章目录 认识JavaScript变量程序中变量的数据&#xff08;记录&#xff09;–变量变量的命名格式在Java script中变量定义包含两部分1. 变量声明&#xff08;高级JS引擎接下来定义一个变量&#xff09;2. 其他的写法 变量命名的规范&#xff08;遵守&#xff09;变量的练习a. …

【Docker】Windows中打包dockerfile镜像导入到Linux

【Docker】Windows中打包dockerfile镜像导入到Linux 大家好 我是寸铁&#x1f44a; 总结了一篇【Docker】Windows中打包dockerfile镜像导入到Linux✨ 喜欢的小伙伴可以点点关注 &#x1f49d; 前言 今天遇到一个新需求&#xff0c;如何将Windows中打包好的dockerfile镜像给迁移…

【Linux】进程程序替换 做一个简易的shell

提示&#xff1a;文章写完后&#xff0c;目录可以自动生成&#xff0c;如何生成可参考右边的帮助文档 目录 文章目录 前言 进程程序替换 替换原理 先看代码和现象 替换函数 第一个execl()&#xff1a; 第二个execv()&#xff1a; 第三个execvp()&#xff1a; 第四个execvpe()&a…

【MySQL】DQL-查询语句全解 [ 基础/条件/分组/排序/分页查询 ](附带代码演示&案例练习)

前言 大家好吖&#xff0c;欢迎来到 YY 滴MySQL系列 &#xff0c;热烈欢迎&#xff01; 本章主要内容面向接触过C Linux的老铁 主要内容含&#xff1a; 欢迎订阅 YY滴C专栏&#xff01;更多干货持续更新&#xff01;以下是传送门&#xff01; YY的《C》专栏YY的《C11》专栏YY的…