开放平台设计方案与实践

老周聊架构

共 13585字,需浏览 28分钟

 ·

2021-05-19 09:12

点击上方老周聊架构关注我


一、背景

随着业务的发展,越来越多的系统需要数据往来。那对外提供的接口也越来越多,而且各个接口散落在不同的项目中被调用,多了的话排查问题困难且混乱。基于这个痛点,我们有必要打造一套开放平台来管理各个 api 的调用情况。

二、开放平台设计

我们先从整体的功能需求来分析,主要有以下几点:

  • 开发者身份注册与数据权限范围授权

  • 开发者获取相关资料(接口文档、使用说明、对接人联系方式等)

  • 平台方接入管理,申请审核流程、服务配置、服务管理、参数配置等

  • 平台方运营管理,业务交易管理及统计报表分析

  • 安全层面需求,加密、应用秘钥、应用接口权限控制、访问黑白名单、字段脱敏还原等

  • 性能方面要求,客户端缓存、服务端缓存、缓存等


这里老周给出自己的一个架构,大家可以参考下:

上面的设计方案更多的是针对比较大型的公司,想要把整个开平的能力建设完善。但市场上更多的是中小型公司,它们没有太多的人力去开发与建设这么全面的开放平台。

那如果是中小型公司,那它们的开放平台如何不费很大精力去实现呢?不管中小型还是大型公司的开放平台,上面说的那个图中其它部分可以省略,但安全机制是必需的,也就是架构图中的统一鉴权。试想一下,作为提供给第三方调用接口的开放平台,如果安全机制不能保障,那外部谁都可以来调用你们公司的内部资源,危害可想而知。

老周下面就来针对不同的业务场景来给出相应的开放平台安全机制的保障,也就是根据不同类型的网站给出相对应的开放平台设计方案。

三、小型网站

3.1 基于 session 的登录认证

在传统的用户登录认证中,因为 http 是无状态的,所以都是采用 session 方式。用户登录成功,服务端会保存一个 session,当然会给客户端一个 sessionId,客户端会把 sessionId 保存在 cookie 中,每次请求都会携带这个 sessionId。服务器收到 sessionId,找到前期保存的数据,由此得知用户的身份。


对于小型网站,特别是单机系统,基于 session 的登录认证方案已经够用了,而且简单高效。

四、中型网站

随着用户量的增多,上面基于 cookie + session 的这种模式缺点就显现出来了,这种模式通常是保存在内存中,而且服务从单服务到多服务会面临 session 共享问题,开销也随即越来越大。

那中型网站的安全认证机制是啥呢?接下来 JWT(JSON Web Token) 即将登场,关于 JWT 的概念与原理,老周这里觉得还是有必要说一下。

4.1 JWT 的概念

4.1.1 什么是 JWT?

JWT 是一个开放的行业标准(RFC 7519),它定义了一种简洁的、自包含的协议格式,用于在通信双方传递 json 对象,传递的信息经过数字签名可以被验证和信任。JWT 可以使用 HMAC 算法或使用 RSA的公钥/私钥对来签名,防止被篡改。

说白了 JWT 就是一套基于 token 的身份认证的方案,可以保证安全传输的前提下传送一些基本的信息,以减轻对外部存储的依赖,减少了分布式组件的依赖,减少了硬件的资源。

可实现无状态、分布式的 Web 应用授权,JWT 的安全特性保证了 token 的不可伪造和不可篡改。

本质上是一个独立的身份验证令牌,可以包含用户标识、用户角色和权限等信息,以及您可以存储任何其他信息(自包含)。任何人都可以轻松读取和解析,并使用密钥来验证真实性。

4.1.2 JWT 令牌结构

JWT 令牌由三部分组成,每部分中间使用点(.)分隔,比如:xxxxx.yyyyy.zzzzz

  • Header
    头部包括令牌的类型(即JWT)及使用的哈希算法(如HMAC SHA256或RSA),例如:

    {
        "alg""HS256"
        "typ""JWT"
    }

    将上边的内容使用 Base64Url 编码,得到一个字符串就是 JWT 令牌的第一部分。

  • Payload
    第二部分是负载,内容也是一个 json 对象,它是存放有效信息的地方,它可以存放 jwt 提供的现成字段,比如:iss(签发者),exp(过期时间戳),sub(面向的用户)等,也可自定义字段。此部分不建议存放敏感信息,因为此部分可以解码还原原始内容。最后将第二部分负载使用 Base64Url 编码,得到一个字符串就是 JWT 令牌的第二部分。一个例子:

    {
        "sub""1234567890"
        "name""微信公众号【老周料架构】"
        "iat"1516239022
    }
  • Signature
    第三部分是签名,此部分用于防止 jwt 内容被篡改。这个部分使用 base64url 将前两部分进行编码,编码后使用点(.)连接组成字符串,最后使用 header 中声明签名算法进行签名。
    secret:签名所使用的密钥。

    HMACSHA256 ( 
        base64UrlEncode(header) + "." + base64UrlEncode(payload), secret
    )

    验签过程描述:获取 token 值,读取 Header 部分并 Base64 解码,得到签名算法。根据以上方法算出签名,如果签名信息不一致,说明是非法的。

4.2 JWT 的流程



4.3 JWT 代码案例

如果你们公司有第三方应用接入的开放平台,那可以在里面走相应的接入流程得到 appId 和 appSecret。如果没有的话,那可以简单点与第三方约定相应的 appId 和 appSecret。老周这里假设你们已经约定好了,我这里直接放在请求头里来获取 token,还有其它的方式,比如放在请求参数或者 cookie 里。

4.3.1 maven 依赖

<dependency>
    <groupId>com.auth0</groupId>
    <artifactId>java-jwt</artifactId>
    <version>3.4.1</version>
</dependency>

4.3.2 JWTUtil 工具类

public class JWTUtil {
    private static String SECRETE = "default_secrete";
    private static String APP_ID = "zhifubao";
    private static String APP_SECRETE = "123abc";

    /**
     * 传入 appId、appSecret 进行验证
     * @param appId 应用id
     * @param appSecret 应用密钥
     * @return 返回一个加密 JWT token
     */

    public static String getToken(String appId, String appSecret) {
        String token = JWT.create()
                // 存放 payload 数据
                .withClaim("appId", appId)
                .withClaim("appSecret", appSecret)
                // 使用 SECRETE 对称加密生成 signature
                .sign(Algorithm.HMAC256(SECRETE));
        return token;
    }

    /**
     * 验证 token
     * @param token
     * @return
     */

    public static boolean verifyToken(String token) {
        HashMap<String, String> map = new HashMap<>();
        // 通过 SECRETE 和相同的对称加密算法反加密
        DecodedJWT jwt = JWT.require(Algorithm.HMAC256(SECRETE))
                .build().verify(token);
        // 获得你储存的 payload 信息
        String appId = jwt.getClaim("appId").asString();
        String appSecret = jwt.getClaim("appSecret").asString();
        if (APP_ID.equals(appId) && APP_SECRETE.equals(appSecret)) {
            return true;
        }
        return false;
    }
}

4.3.3 JWTController 类

@RestController
public class JWTController {
    @RequestMapping("/getToken")
    public String getToken(@RequestHeader("appId") String appId, @RequestHeader("appSecret") String appSecret) {
        return JWTUtil.getToken(appId, appSecret);
    }
}

4.3.4 测试


拓展:这个私钥 secrete 是固定的,为了加强安全,你甚至可以使用动态的 secrete 私钥,
例如:动态私钥 = 静态私钥 + 用户的 ip,这样即使别人得到了用户的 token,也会因为 ip 不一致而访问失败。

拿到了应用资源服务器的 token 令牌了,那我们拿这个令牌去访问相应的资源看看。

@RequestMapping("/getResource")
public String getResource(String resourceId) {
    return resourceId + " 资源获取成功";
}

简单模拟一个请求,直接返回该资源获取成功。我们接下来就用 postman 工具来模拟一下这个资源服务器的这个接口请求。



认证失败了,这是因为我们没有在请求头里填刚刚获取的 token。我们把通过调用 getToken 接口获取的 token 值放在在请求头,然后认证通过,获取到了资源服务器的资源。



4.3.5 继续追问

这里你有可能问了,老周,这里咋就带上 token 在请求头就可以获取到了资源服务器的资源啊。

我把代码贴出来,你一看就知道了。

这里写了一个 token 的拦截器,对请求头的 token 进行验签,通过才放行。

@Component
public class TokenInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String token = request.getHeader("token");
        if (token != null) {
            boolean result = JWTUtil.verifyToken(token);
            if (result) {
                System.out.println("通过拦截器");
                return true;
            }
        }

        response.setCharacterEncoding("UTF-8");
        response.setContentType("application/json; charset=utf-8");
        try{
            response.getWriter().append("认证失败,无效的token令牌!");
            System.out.println("认证失败,无效的token令牌!");
        } catch (Exception e) {
            e.printStackTrace();
            response.sendError(500);
            return false;
        }
        return false;
    }
}

这里有个拦截器配置类,把需要拦截的 api 路径放进来,然后会对某个 api 进行细粒度的管控。

@Configuration
public class IntercepterConfig implements WebMvcConfigurer {
    private TokenInterceptor tokenInterceptor;

    public IntercepterConfig(TokenInterceptor tokenInterceptor){
        this.tokenInterceptor = tokenInterceptor;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        List<String> excludePath = new ArrayList<>();
        excludePath.add("/getResource/");
        excludePath.add("/static/**");  //静态资源
        registry.addInterceptor(tokenInterceptor)
                .addPathPatterns("/**")
                .excludePathPatterns(excludePath);
        WebMvcConfigurer.super.addInterceptors(registry);
    }
}

这就实现中型网站安全认证机制了,细心的读者可能会发现,这个 token 是固定的,会存在一些不安全。是的,我上面也说了,可以用动态的 secrete 私钥或者 token 过期机制来继续保证更高的安全性。

五、大型网站

大型网站的话,针对中型网站的方案就不太可行了,为什么呢?由于大型网站的请求流量很大,而 token 由于自包含信息,因此一般数据量较大,而且每次请求都需要传递,因此比较占带宽。另外,token 的签名验签操作也会给 cpu 带来额外的处理负担。可以采用微服务统一认证方案 Spring Cloud OAuth2,那什么情况下需要使用 OAuth2?

  • 第三方授权登录的场景:比如,我们经常登录一些网站或者应用的时候,可以选择使用第三方授权登录的方式,比如:微信授权登录、QQ授权登录、微博授权登录等,这是典型的 OAuth2 使用场景。

  • 单点登录的场景:如果项目中有很多微服务或者公司内部有很多服务,可以专⻔做一个认证中心(充当认证平台⻆色),所有的服务都要到这个认证中心做认证,只做一次登录,就可以在多个授权范围内的服务中自由串行。

5.1 OAuth2 构建微服务统一认证服务思路


注意:在我们统一认证的场景中,Resource Server 其实就是我们的各种受保护的微服务,微服务中的 各种 API 访问接口就是资源,发起 http 请求的浏览器就是 Client 客户端(对应为第三方应用)。

5.1.1 搭建认证服务器(Authorization Server)

5.1.1.1 maven 依赖文件


5.1.1.2 application.yml 文件



5.1.1.3 OauthServerApplication9999 启动类


5.1.1.4 认证服务器配置类


5.1.1.5 认证服务器安全配置类



5.1.1.6 测试

5.1.1.6.1 获取 token

http://localhost:9999/oauth/token?client_secret=abcxyz&grant_type=password&username=admin&password=123456&client_id=client_riemann

endpoint:/oauth/token

获取token携带的参数
client_id:客户端id
client_secret:客户单密码
grant_type:指定使用哪种颁发类型,password
username:用户名
password:密码



5.1.1.6.2 校验 token

http://localhost:9999/oauth/check_token?token=28317df7-4036-4bbb-8bb3-12f71fa07802


如果出现以上页面,表明 token 过期了,设置的是 20s。所以要在 20s 以内校验才会生效。

下面才是 token 校验成功的效果:


5.1.1.6.3 刷新 token

http://localhost:9999/oauth/token?grant_type=refresh_token&client_id=client_riemann&client_secret=abcxyz&refresh_token=68582d02-3a1d-4c31-ae22-ac7e84824d0d



5.1.2 搭建资源服务器(希望访问被认证的微服务)

5.1.2.1 资源服务 Resource Server 配置类


5.1.2.2 测试





此测试结果也印证了代码的效果



我们加上带上token测下看看:


5.2 OAuth2 统一认证服务思考

  • 当我们第一次登陆之后,认证服务器颁发 token 并将其存储在认证服务器中,后期我们 访问资源服务器时会携带 token,资源服务器会请求认证服务器验证 token 有效性,如果资源服务器有很多,那么认证服务器压力会很大。

  • 另外,资源服务器向认证服务器 check_token,获取的也是用户信息 UserInfo,能否把用户信息存储到令牌中,让客户端一直持有这个令牌,令牌的验证也在资源服务器进行,这样避免和认证服务器频繁的交互。

  • 我们可以考虑使用 JWT 进行改造,使用 JWT 机制之后资源服务器不需要访问认证服务器。

5.3 JWT 改造统一认证授权中心的令牌存储机制

JWT 在上面中型网站那一节说过了,这里就不重复说了,老周直接上代码了。

5.3.1 认证服务器端 JWT 改造(改造主配置类)


5.3.2 修改 JWT 令牌服务方法



5.3.3 认证服务器端测试


可以看出,使用 jwt 令牌生成的 access_token 和上一篇的不一样。

我们用这个网站:https://jwt.io/#encoded-jwt 把该 access_token 进行解码,解码如下:



其他两个验证 token、刷新 token 跟上一篇类似。

5.3.4 资源服务器校验 JWT 令牌

不需要和远程认证服务器交互,添加本地 tokenStore。



5.3.5 源服务器端测试



这样就完成了资源服务根据事先约定的算法自行完成令牌校验,无需每次都请求认证服务完成授权。

六、总结

老周首先从开放平台的整体功能设计来分析了有如下几个要点:开发者认证、开放平台内部管理系统、安全机制以及性能。

但考虑很多公司它们没有太多的人力去开发与建设这么全面的开放平台,故抓住其中的最核心的一点,那就是安全机制。

针对于安全机制来说,不同类型的网站有不同的安全机制保障。

  • 小型网站:基于 session 的登录认证,在小型网站特别是单机系统,这种方案够用了,而且简单高效;

  • 中型网站:到了中型网站,服务肯定是分布式部署的,这个时候小型网站中基于 session 的登录认证方案的缺点就暴露出来了。每个应用服务都需要在 session 中存储用户身份信息,通过负载均衡将本地的请求分配到另一个应用服务需要将 session 信息带过去,否则会重新认证。我们还要通过 session 共享、session 黏贴等方案来解决。从而引入了第三方分布式组件,比如 redis,增加了系统的复杂性。并且 session 方案还有另一个缺点,比如基于 cookie,移动端不能有效使用等。所以中型网站的话基于 JWT 的 token 认证机制,服务端不用存储认证数据,易维护扩展性强,客户端可以把 token 存在任意地方,并且可以实现 web 和 app 统一认证机制。

  • 大型网站:到了大型网站,请求量也随之暴涨,中型网站的 token 认证机制的缺点也逐步暴露出来了,token 由于自包含信息,因此 一般数据量较大,而且每次请求都需要传递,因此比较占带宽。另外,token 的签名验签操作也会给 cpu 带来额外的处理负担。这个时候得采用微服务统一认证方案 Spring Cloud OAuth2,后面我们又对 OAuth2 进行了一些优化,因为大型网站的开平请求流量会很大,资源服务器会请求认证服务器验证 token 有效性,那么认证服务器压力会很大。另外,资源服务器向认证服务器 check_token,获取的也是用户信息 UserInfo,能否把用户信息存储到令牌中,让客户端一直持有这个令牌,令牌的验证也在资源服务器进行,这样避免和认证服务器频繁的交互。所以我们后续使用 JWT 进行改造,使用 JWT 机制之后资源服务器不需要访问认证服务器。性能以及安全机制都得到了有力保障。


欢迎大家关注我的公众号【老周聊架构】,Java后端主流技术栈的原理、源码分析、架构以及各种互联网高并发、高性能、高可用的解决方案。

喜欢的话,点赞、再看、分享三连。

点个在看你最好看



浏览 118
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报