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

news2024/11/27 7:35:02

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 分支上.

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

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

蓝桥杯第十五届抱佛脚(八)并查集

蓝桥杯第十五届抱佛脚&#xff08;八&#xff09;并查集 基本概念 并查集是一种数据结构&#xff0c;用于管理一系列不交集的元素集合&#xff0c;并支持两种操作&#xff1a; 查找&#xff08;Find&#xff09;&#xff1a; 查找操作用于确定某个元素属于哪个集合&#xf…

Topaz Photo AI for Mac v2.4.2 智能AI降噪软件

Topaz Photo AI是一款适用于Mac的图像处理软件&#xff0c;使用人工智能技术对照片进行编辑和优化。该软件提供了多种强大的功能&#xff0c;包括降噪、锐化、消除噪点、提高分辨率等&#xff0c;可以帮助用户改善图像质量&#xff0c;并实现自定义的效果。 软件下载&#xff1…

前端-html-02

1.列表 标签名功能和语义属性单标签还是双标签ul无序列表包裹元素双标签 ol 有序列表包裹元素双标签li列表项双标签dl定义列表包裹元素双标签dt定义列表项标题双标签dd定义列表项描述双标签 li必须由Ul或者ol包裹 <!DOCTYPE html> <html><head><…

Web APIs知识点讲解(阶段七)

正则表达式 1.能够利用正则表达式校验输入信息的合法性2. 具备利用正则表达式验证小兔鲜注册页面表单的能力 一.正则表达式 1.正则表达式 正则表达式&#xff08;Regular Expression&#xff09;是用于匹配字符串中字符组合的模式。在 JavaScript中&#xff0c;正则表达式也…

我们正在被 DDoS 攻击,但是我们啥也不干,随便攻击...

最近&#xff0c;一场激烈的攻防大战在网络世界悄然上演。 主角不是什么国家安全局或者黑客组织&#xff0c;而是一家名不见经传的创业公司——TablePlus。 DDoS 攻击者们摩拳擦掌&#xff0c;跃跃欲试。他们从四面八方蜂拥而至&#xff0c;誓要用数亿次请求把 TablePlus 的服…

Redis 常见数据结构及命令

目录 一.Redis常见的数据结构 二.Redis数据结构对应的命令 1.String类型 2.Hash类型 3.List类型 4.Set类型 5.Sorted Set类型 一.Redis常见的数据结构 Redis支持多种数据结构&#xff0c;包括字符串&#xff08;string&#xff09;、哈希&#xff08;hash&#xff09;、…

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

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

【JavaSE】类和对象详解(上)

欢迎关注个人主页&#xff1a;逸狼 创造不易&#xff0c;可以点点赞吗~ 如有错误&#xff0c;欢迎指出~ 目录 类和对象 类的组成 对类的理解 成员变量的访问和类方法的调用 this 抛出一个问题 this的作用 初始化成员变量 未初始化的成员变量 代码举例 就地初始化 构…

Autodesk Maya 2025 mac玛雅三维动画特效软件

Autodesk Maya 2025 for Mac是一款功能强大、操作简便的三维动画软件&#xff0c;适用于电影、电视、游戏、建筑、工业设计、虚拟现实和动画等领域。无论是专业设计师还是初学者&#xff0c;都可以通过Maya 2025实现自己的创意和想法&#xff0c;创作出高质量的三维作品。 软件…

浅谈Spring体系的理解

浅谈Spring知识体系 Spring Framework架构图Spring家族技术生态全景图XMind汇总 本文不涉及细节&#xff0c;主要回答两个问题&#xff1a; Spring家族技术生态全景图有哪些Spring Framework架构下每个模块有哪些东西&#xff0c;以及部分模块之间的关联关系 Spring Framework架…

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

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

【Redis教程0x08】详解Redis过期删除策略内存淘汰策略

引言 Redis的过期删除策略和内存淘汰策略是经常被问道的问题&#xff0c;这两个机制都是做删除操作&#xff0c;但是触发的条件和使用的策略是不同的。今天就来深入理解一下这两个策略。 过期删除策略 Redis 是可以对 key 设置过期时间的&#xff0c;因此需要有相应的机制将…

智能文档合规检测系统:在央企国企招标采购领域的应用

一、背景介绍 在央企国企采购过程中&#xff0c;合规性是一个不可忽视的重要方面。采购方需要确保供应商的资质、业绩、规模等条件符合采购要求&#xff0c;同时避免设置不合理的条件限制或排斥潜在供应商。为了提高采购效率和确保合规性&#xff0c;智能文档合规检测系统应运…

40.网络游戏逆向分析与漏洞攻防-角色管理功能通信分析-角色删除功能的数据包失败的分析

免责声明&#xff1a;内容仅供学习参考&#xff0c;请合法利用知识&#xff0c;禁止进行违法犯罪活动&#xff01; 如果看不懂、不知道现在做的什么&#xff0c;那就跟着做完看效果 内容参考于&#xff1a; 易道云信息技术研究院VIP课 上一个内容&#xff1a;39.角色数据的维…

Linux|centos7-postgresql数据库|yum安装数据库和配置repmgr高可用集群以及repmgr的日常管理工作

一、 前言 postgresql 的yum部署其实还是有点东西的&#xff0c;本文就做一个小小的记录&#xff0c;高可用方面repmgr插件还是非常不错的&#xff0c;但如何部署以及部署后如何使用也是一个难点&#xff0c;因此&#xff0c;也在本文里做一个记录 环境介绍&#xff1a; 第…

TonyBai go语言第一课 学习笔记

文章目录 大纲前置篇显式 大纲 前置篇 显式 package main import "fmt" func main() { var a int16 5 var b int 8 var c int64 c a b fmt.Printf("%d\n", c) }如果我们编译这段程序&#xff0c;将得到类似这样的编译器错误&#xff1a;“invalid op…

jmeter性能压测的标准和实战中会遇到的问题

1.性能标准建议 CPU 使用率&#xff1a;不超过 70% 内存使用率&#xff1a;不超过 70% 磁盘&#xff1a;%util到达80%严重繁忙 &#xff08;os.disIO.filesystem.writeKbPS 每秒写入的千字节&#xff09; 响应时间&#xff1a;95%的响应时间不超过8000ms 事务成功率&#xff1a…

计算线上点坐标

综上代码 void point_on_line(float* res, float* p, float* q, float lambda) {float tmp 1 - lambda;res[0] tmp* p[0] lambda * q[0];res[1] tmp* p[1] lambda * q[1];res[2] tmp* p[2] lambda * q[2]; }

基于SpringBoot华强北二手手机商城系统

点赞收藏关注 → 私信领取本源代码、数据库 一、项目概述 项目名称&#xff1a;基于SpringBoot华强北二手手机商城 项目架构&#xff1a;B/S架构 开发语言&#xff1a;Java语言 主要技术&#xff1a;SpringBootMybatisMySQL 运行环境&#xff1a;Windows7以上、JDK1.8、M…

【微服务】Nacos(配置中心)

文章目录 1.AP和CP1.基本介绍2.说明 2.Nacos配置中心实例1.架构图2.在Nacos Server加入配置1.配置列表&#xff0c;加号2.加入配置3.点击发布&#xff0c;然后返回4.还可以编辑 3. 创建 Nacos 配置客户端模块获取配置中心信息1.创建子模块 e-commerce-nacos-config-client50002…