设计模式简记-面向对象实战二运用面向对象分析设计方法开发接口鉴权功能

2.10运用面向对象分析设计方法开发接口鉴权功能

2.10.1需求分析

  • 需求提出:

    “为了保证接口调用的安全性,我们希望设计实现一个接口调用鉴权功能,只有经过认证之后的系统才能调用我们的接口,没有认证过的系统调用我们的接口会被拒绝。我希望由你来负责这个任务的开发,争取尽快上线。”

  • 第一轮基础分析:通过用户名加密码来做认证

    给每个允许访问我们服务的调用方,派发一个应用名(或者叫应用 ID、AppID)和一个对应的密码(或者叫秘钥)。调用方每次进行接口请求的时候,都携带自己的 AppID 和密码。微服务在接收到接口调用请求之后,会解析出 AppID 和密码,跟存储在微服务端的 AppID 和密码进行比对。如果一致,说明认证成功,则允许接口调用请求;否则,就拒绝接口调用请求。

  • 第二轮分析优化:明文传输密码,是不安全的。借助加密算法(比如 SHA),对密码进行加密之后,再传递到微服务端验证,也是不安全的,因为加密之后的密码及 AppID,照样可以被未认证系统(或者说黑客)截获,未认证系统可以携带这个加密之后的密码以及对应的 AppID,伪装成已认证系统来访问我们的接口。这就是典型的“重放攻击”

    token验证:调用方将请求接口的 URL 跟 AppID、密码拼接在一起,然后进行加密,生成一个 token。将appid和token一起传给服务端,服务端根据appid从数据库中取出密码,用相同的方法加密生成token跟客户端传入的token做对比验证,一致通过,不一致拒绝。

    graph TB subgraph Client端 A(1.生成token:SHA--http//ip:port/user?id=123&appid=abc&pwd=pwd123) B(2.生成新url:http//ip:port/user?id=123&appid=abc&token=xxx) A --> B end B --访问Server端--> C subgraph Server端 C(3.解析出URL\AppID\token) D(4.从数据库中取出AppID对应的密码) E(5.生成server端token_s) F1(6.允许访问) F2(6.拒绝访问) C --> D D --> E E --token等于token_s--> F1 E --token不等于token_s--> F2 end
  • 第三轮分析优化:

    每个 URL 拼接上 AppID、密码生成的 token 都是固定的,这样的设计仍然存在重放攻击的风险

    可以进一步优化 token 生成算法,引入一个随机变量时间戳,让每次接口请求生成的 token 都不一样。

    并可根据时间戳验证token是否过期

    新的流程:

    graph TB subgraph Client端 A(1.生成token:SHA--http//ip:port/user?id=123&appid=abc&pwd=pwd123&ts=1561523224) B(2.生成新url:http//ip:port/user?id=123&appid=abc&token=xxx&ts=1561523224) A --> B end B --访问Server端--> C subgraph Server端 C(3.解析出URL\AppID\token\ts) C1(4.根据ts验证token是否失效,失效则拒绝,否则执行5) D(5.从数据库中取出AppID对应的密码) E(6.生成server端token_s) F1(7.允许访问) F2(8.拒绝访问) C --> C1 C1 --> D D --> E E --token等于token_s--> F1 E --token不等于token_s--> F2 end
  • 第四轮分析优化:

    在微服务端存储每个授权调用方的 AppID 和密码如何存储,最容易想到是储存到数据库

    但开发像鉴权这样的非业务功能,最好不要与具体的第三方系统有过度的耦合

    针对 AppID 和密码的存储,最好能灵活地支持各种不同的存储方式,比如 ZooKeeper、本地配置文件、自研配置中心、MySQL、Redis 等。不一定针对每种存储方式都去做代码实现,但起码要留有扩展点,保证系统有足够的灵活性和扩展性,能够在我们切换存储方式的时候,尽可能地减少代码的改动。

  • 最终确定需求

    • 调用方进行接口请求的时候,将 URL、AppID、密码、时间戳拼接在一起,通过加密算法生成 token,并且将 token、AppID、时间戳拼接在 URL 中,一并发送到微服务端。
    • 微服务端在接收到调用方的接口请求之后,从请求中拆解出 token、AppID、时间戳。
    • 微服务端首先检查传递过来的时间戳跟当前时间,是否在 token 失效时间窗口内。如果已经超过失效时间,那就算接口调用鉴权失败,拒绝接口调用请求。
    • 如果 token 验证没有过期失效,微服务端再从自己的存储中,取出 AppID 对应的密码,通过同样的 token 生成算法,生成另外一个 token,与调用方传递过来的 token 进行匹配;如果一致,则鉴权成功,允许接口调用,否则就拒绝接口调用。
  • 针对框架、类库、组件等非业务系统的开发,其中一个比较大的难点就是,需求一般都比较抽象、模糊,需要你自己去挖掘,做合理取舍、权衡、假设,把抽象的问题具象化,最终产生清晰的、可落地的需求定义。需求分析的过程实际上是一个不断迭代优化的过程。不要试图一下就能给出一个完美的解决方案,而是先给出一个粗糙的、基础的方案,有一个迭代的基础,然后再慢慢优化,这样一个思考过程能让我们摆脱无从下手的窘境。

  • 进一步优化(小白):

    1.要求客户端生成一个唯一的请求id,如以uuid方式
    2.客户端在以sha等加密哈希方式生成token时,也将请求id加入其中
    3.客户端也要将请求id作为参数传递到服务端,如果是rest api就是也要将请求id拼接到url参数中
    4.服务端检查服务端的缓存中(可以是redis)是否有客户端传递的请求id,如果有,则判定为重放攻击,拒绝请求。如果没有,则将请求id放到缓存中同时设置在token失效的时间窗内缓存的请求id自动失效(如redis key的TTL)
    这个实现思路是: 在时间窗内的重放攻击,以服务端在时间缓存了在时间窗内的所有请求id的形式来防护,而在时间窗外的重放攻击就是老师的方案中检查客户端传过来的时间(时间戳)和服务端当前时间(时间戳)相减的绝对值不能超过时间窗的长度来实现。另外,时间戳、请求id等都hash在了token
    中,所有客户端是无法篡改的。
    这个实现思路的缺点是: 改实现方案要求客户端的时间和服务端的时间之间的差距不能超过时间窗,如果时间窗设置为1分钟这种比较小的,则要求客户端时间和服务端时间不能超过1分钟,这个有点苛刻,比如客户端如app所在的手机的时间不准确了,但就差1分钟,将无法访问接口。如果时间窗设置过长,如30分钟,则要求服务端缓存中缓存最近30分钟的请求id,如果接口的访问并发挺大的话,缓存占用空间也将很大,需要评估。

2.10.2设计与开发

2.10.2.1如何进行面向对象设计?
  • 划分职责进而识别出有哪些类;
  • 定义类及其属性和方法;
  • 定义类与类之间的交互关系;
  • 将类组装起来并提供执行入口。
2.10.2.2划分职责进而识别出有哪些类
  • 逐句阅读上面的需求描述,拆解成小的功能点,一条一条罗列下来

    1. 把 URL、AppID、密码、时间戳拼接为一个字符串;
    2. 对字符串通过加密算法加密生成 token;
    3. 将 token、AppID、时间戳拼接到 URL 中,形成新的 URL;
    4. 解析 URL,得到 token、AppID、时间戳等信息;
    5. 从存储中取出 AppID 和对应的密码;
    6. 根据时间戳判断 token 是否过期失效;验证两个 token 是否匹配;
    7. 验证两个 token 是否匹配 ;
  • 分析:

    • 1,2,6,7跟token有关,负责 token 的生成、验证;
    • 3、4 都是在处理 URL,负责 URL 的拼接、解析;
    • 5 是操作 AppID 和密码,负责从存储中读取 AppID 和密码。
  • 所以,粗略地得到三个核心的类:AuthToken、Url、CredentialStorage。

    • AuthToken 负责实现 1、2、6、7 这四个操作;
    • Url 负责 3、4 两个操作;
    • CredentialStorage 负责 5 这个操作。
  • 面对更加大型、更加复杂的需求开发,涉及的功能点可能会很多,最后会得到一个长长的功能列表,就会有点凌乱、没有规律。针对这种复杂的需求开发首先要做的是进行模块划分,将需求先简单划分成几个小的、独立的功能模块,然后再在模块内部,应用刚刚讲的方法,进行面向对象设计。而模块的划分和识别,跟类的划分和识别,是类似的套路

2.10.2.2定义类及其属性和方法
  • AuthToken 类相关的功能点有四个:

    • 把 URL、AppID、密码、时间戳拼接为一个字符串;

    • 对字符串通过加密算法加密生成 token;

    • 根据时间戳判断 token 是否过期失效;

    • 验证两个 token 是否匹配。

      AuthToken类

      属性

      private static final long DEFAULT_EXPIRED_TIME_INTERVAL = 1 * 60 * 1000
      private String token;
      private long createTime;
      private long expiredTimeInterval = DEFAULT_EXPIRED_TIME_INTERVAL;
      

      构造方法

      public AuthToken(String token, long createTime);
      public AuthToken(String token, long createTime, long expiredTimeInterval);
      

      方法

      public static AuthToken create(String baseUrl, long createTime, Map<String,String> params);
      public String getToken();
      public boolean isExpired();
      public boolean match(AuthToken authToken);
      

    几个细节

    • 第一个细节:并不是所有出现的名词都被定义为类的属性,比如 URL、AppID、密码、时间戳这几个名词,我们把它作为了方法的参数,从业务模型上来说,不应该属于这个类的属性和方法,不应该被放到这个类里。比如 URL、AppID 这些信息,从业务模型上来说,不应该属于 AuthToken,所以我们不应该放到这个类中。。
    • 第二个细节:我们还需要挖掘一些没有出现在功能点描述中属性,比如 createTime,expireTimeInterval,它们用在 isExpired() 函数中,用来判定 token 是否过期。
    • 第三个细节:我们还给 AuthToken 类添加了一个功能点描述中没有提到的方法 getToken()。
    • 在设计类具有哪些属性和方法的时候,不能单纯地依赖当下的需求,还要分析这个类从业务模型上来讲,理应具有哪些属性和方法。这样可以一方面保证类定义的完整性,另一方面不仅为当下的需求还为未来的需求做些准备。
  • Url 类相关的功能点有两个:

    • 将 token、AppID、时间戳拼接到 URL 中,形成新的 URL;
    • 解析 URL,得到 token、AppID、时间戳等信息。
    • 为了让这个类更加通用,命名更加贴切,把它命名为 ApiRequest

    ApiRequest类

    属性

    private String baseUrl;
    private String token;
    private String appId;
    private long timestamp;
    

    构造方法

    public ApiRequest(String baseUrl, String token, String appId, long timestamp);
    

    方法

    public static ApiRequest createFromFullUrl( String url);
    public String getBaseUrl();
    public String getToken();
    public String getAppId();
    public long getTimestamp();
    
  • CredentialStorage 类相关的功能点有一个:

    • 从存储中取出 AppID 和对应的密码。

    为了做到抽象封装具体的存储方式,将 CredentialStorage 设计成接口,基于接口而非具体的实现编程。

    CredentialStorage

    接口方法

    String getPasswordByAppId(String appId);
    
2.10.2.3定义类与类之间的交互关系

类与类之间都哪些交互关系呢?UML 统一建模语言中定义了六种类之间的关系。

它们分别是:泛化、实现、关联、聚合、组合、依赖

  • 泛化(Generalization)可以简单理解为继承关系。具体到 Java 代码就是下面这样:
public class A { ... }
public class B extends A { ... }
  • 实现(Realization)一般是指接口和实现类之间的关系。具体到 Java 代码就是下面这样:

    public interface A {...}
    public class B implements A { ... }
    
  • 聚合(Aggregation)是一种包含关系,A 类对象包含 B 类对象,B 类对象的生命周期可以不依赖 A 类对象的生命周期,也就是说可以单独销毁 A 类对象而不影响 B 对象,比如课程与学生之间的关系。具体到 Java 代码就是下面这样:

    public class A {
      private B b;
      public A() {
        this.b = new B();
      }
    }
    
  • 组合(Composition)也是一种包含关系。A 类对象包含 B 类对象,B 类对象的生命周期跟依赖 A 类对象的生命周期,B 类对象不可单独存在,比如鸟与翅膀之间的关系。具体到 Java 代码就是下面这样:

    public class A {
      private B b;
      public A() {
        this.b = new B();
      }
    }
    
  • 关联(Association)是一种非常弱的关系,包含聚合、组合两种关系。具体到代码层面,如果 B 类对象是 A 类的成员变量,那 B 类和 A 类就是关联关系。具体到 Java 代码就是下面这样:

    public class A {
      private B b;
      public A(B b) {
        this.b = b;
      }
    }
    或者
    public class A {
      private B b;
      public A() {
        this.b = new B();
      }
    }
    
  • 依赖(Dependency)是一种比关联关系更加弱的关系,包含关联关系。不管是 B 类对象是 A 类对象的成员变量,还是 A 类的方法使用 B 类对象作为参数或者返回值、局部变量,只要 B 类对象和 A 类对象有任何使用关系,都称它们有依赖关系。具体到 Java 代码就是下面这样:

    public class A {
      private B b;
      public A(B b) {
        this.b = b;
      }
    }
    或者
    public class A {
      private B b;
      public A() {
        this.b = new B();
      }
    }
    或者
    public class A {
      public void func(B b) { ... }
    }
    
  • 目前只用到了实现关系,也即 CredentialStorage 和 MysqlCredentialStorage 之间的关系。

2.10.2.4将类组装起来并提供执行入口
  • 接口鉴权并不是一个独立运行的系统,而是一个集成在系统上运行的组件,所以,封装所有的实现细节,设计一个最顶层的 ApiAuthenticator 接口类,暴露一组给外部调用者使用的 API 接口,作为触发执行鉴权逻辑的入口。具体的类的设计如下所示

    ApiAuthenticator接口

    接口函数

    void auth(String url);
    void auth(ApiRequest apiRequest);
    

    ​ ⬇️实现

    DefaultApiAuthenticatorImpl类

    属性

    private CredentialStorage credentialStorage;
    

    构造方法

    private DefaultApiAuthenticator();
    pvivate DefaultApiAuthenticator(CredentialStorage credentialStorage);
    

    方法

    void auth(String url);
    void auth(ApiRequest apiRequest);
    

2.10.3如何进行面向对象编程

  • 这里我只给出比较复杂的 ApiAuthenticator 的实现。对于 AuthToken、ApiRequest、CredentialStorage 这三个类,在这里我就不给出具体的代码实现了。

    public interface ApiAuthenticator {
      void auth(String url);
      void auth(ApiRequest apiRequest);
    }
    
    public class DefaultApiAuthenticatorImpl implements ApiAuthenticator {
      private CredentialStorage credentialStorage;
      
      public DefaultApiAuthenticator() {
        this.credentialStorage = new MysqlCredentialStorage();
      }
      
      public DefaultApiAuthenticator(CredentialStorage credentialStorage) {
        this.credentialStorage = credentialStorage;
      }
    
      @Override
      public void auth(String url) {
        ApiRequest apiRequest = ApiRequest.buildFromUrl(url);
        auth(apiRequest);
      }
    
      @Override
      public void auth(ApiRequest apiRequest) {
        String appId = apiRequest.getAppId();
        String token = apiRequest.getToken();
        long timestamp = apiRequest.getTimestamp();
        String originalUrl = apiRequest.getOriginalUrl();
    
        AuthToken clientAuthToken = new AuthToken(token, timestamp);
        if (clientAuthToken.isExpired()) {
          throw new RuntimeException("Token is expired.");
        }
    
        String password = credentialStorage.getPasswordByAppId(appId);
        AuthToken serverAuthToken = AuthToken.generate(originalUrl, appId, password, timestamp);
        if (!serverAuthToken.match(clientAuthToken)) {
          throw new RuntimeException("Token verfication failed.");
        }
      }
    }
    

2.10.4辩证思考与灵活应用

  • 在之前的讲解中,面向对象分析、设计、实现,每个环节的界限划分都比较清楚。而且,设计和实现基本上是按照功能点的描述,逐句照着翻译过来的。这样做的好处是先做什么、后做什么,非常清晰、明确,有章可循,即便是没有太多设计经验的初级工程师,都可以按部就班地参照着这个流程来做分析、设计和实现

  • 不过,即便绘制出完美的类图、UML 图,也不可能把每个细节、交互都想得很清楚。在落实到代码的时候,还是要反复迭代、重构、打破重写。

posted @ 2020-02-21 11:30  杨海星  阅读(284)  评论(0编辑  收藏  举报