当前位置:   article > 正文

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

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{
    ...
}
  • 1
  • 2
  • 3

从定义可以看出,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; // 返回参数签名
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33

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) {

        }
    }

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81
  • 82
  • 83
  • 84
  • 85
  • 86
  • 87
  • 88
  • 89
  • 90
  • 91
  • 92
  • 93
  • 94
  • 95
  • 96
  • 97
  • 98
  • 99
  • 100
  • 101
  • 102
  • 103
  • 104
  • 105
  • 106
  • 107
  • 108
  • 109
  • 110
  • 111
  • 112
  • 113
  • 114
  • 115
  • 116
  • 117
  • 118
  • 119
  • 120
  • 121
  • 122
  • 123
  • 124
  • 125
  • 126
  • 127
  • 128
  • 129
  • 130
  • 131
  • 132
  • 133

接着在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);
    }
    
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52

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);
            }
        }
    } 
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39

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;
}

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

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);
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50

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;
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65

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;
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64

8. 准备Controller接收数据

@RestController
@Slf4j
@RequestMapping("/api/employees")
public class EmployeeApiController {
    @PostMapping
    public void create(@RequestBody Employee employee) {
        log.info("创建员工: {}", employee);
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

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);
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29

输出:

请求地址:/api/employees
X-Access-Key:db0f017ac3cacb
X-Access-Timestamp:1711866992050
X-Access-Sign:987b71f4961d78b95acaa019a70ac0a6439a6a566d9bb800fa0078feba8d7864
 X-Access-AppId:123456
 Request Body:{"gender":"男","name":"张三"}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

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": "男"}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

如果access-key , appid没有对应,或者签名不正确则会返回:

{
    "code": 401,
    "msg": "未授权,请联系我们"
}
  • 1
  • 2
  • 3
  • 4

代码仓库 https://github.com/kaiwill/shiro-jwt , 本节代码在 7_springboot_shiro_jwt_多端认证鉴权_自定义AuthenticationToken 分支上.

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/不正经/article/detail/356978
推荐阅读
相关标签
  

闽ICP备14008679号