BlockAuth 基本设计以及在实现中的一些思考

BlockAuth 基本设计以及在实现中的一些思考

作者: taotao(后端程序员)

BlockAuth 模块几乎是所有系统中必不可少的一个环节,承载用户注册、登录、授权等各种常规操作,为整个系统中的其他逻辑提供支持。在当前阶段,BlockAuth 模块主要为 OCAP service 提供支持,通过对用户区分不同的 role,来分配不同的 service quota,以此提升 OCAP service 使用体验。现阶段而言,BlockAuth 模块采用相对于分布式 ID 方案(DID)更加中心化的实现,但在 DID 技术成熟后可以平滑过渡到去中心的实现。

本文试图从 BlockAuth 模块常见的组件入手,阐述 BlockAuth 的基本设计以及在实现过程中遇到的一些有趣的想法和思考。其中,常见组件主要会介绍:

  • User Register
  • JWT (Json Web Token)
  • MFA
  • HMAC
  • User Role

而一些有趣的想法和思考,主要会包括:

  • 使用 absinthe middleware 抽象组件逻辑
  • 使用 pipeline 来封装多重逻辑
  • 代码层面的读写分离
  • 并发控制以及异常控制

User Register

对于用户注册而言,在 BlockAuth 模块中,会将 email 作为一个非常重要的参数,为了防止 email 被占用带给后续注册的各种麻烦,会将验证 email 作为前置的操作。换言之,只有在用户的 email 被确认之后,才能进行后续的各种操作。这样的话,可以将 email 被占用的风险降低到最小。

BlockAuth 模块,采用的是常见 register email 流程:

  • 用户填写邮箱
  • server 端发送验证链接到填写的邮箱
  • 用户点击验证链接完成邮箱验证

在这个过程中,email 被占用是最常见的一种异常情形。

假如用户 A 使用email_b 来注册,但用户 A 并不拥有该邮箱,正常而言,用户 A 也就无法完整验证,当用户 B 也使用 email_b 来注册时,server 端同样会发送验证链接到 email_b,之后用户 B 可以继续完成验证流程。

如果用户 B 在使用email_b 注册时,非常不幸的发现:

  • 邮箱已被验证

此时用户 B 可以继续完成后续的操作

  • 邮箱已被注册用户

用户 B 可以通过重置密码,拿回邮箱的使用权

通过前置邮箱验证的方式,极大程度的可以保证邮箱不会恶意占用。(感兴趣的读者可以尝试在 Github 体验邮箱被恶意占用的困扰)

JWT

简言之,JWT 是一种基于 Json 的服务器认证方案,不同于 session 存储方式:

  • JWT 是在用户登录之后,server 端通过签名的方式生成 JWT 数据(简称为 JWT Token)并返回给 client 端
  • client 端需要保存 JWT,并随每次请求,将 JWT 置于 http request headers 中发送给 server 端
  • server 端接收到 JWT 后,解析并验证是否被篡改

基于此,server 端将不再需要保存 session 数据,对于 client 的请求验证将变为 stateless 的方式,进而可以提高 server 端的扩展性。

但是在 JWT 的使用中,同样存在着一定风险,假如 JWT Token 被非法截获,JWT Token 就会被冒用,导致不可完全预料的隐患。因此,JWT Token 在生成之初,就包括了过期时间(expiration)以及刷新 Token (refresh token)。

基本的流程:

  • 过期后,access token 将无法使用
  • client 可以使用 refresh token 获取新的 access token
  • 如果 refresh token 同样过期,用户重新登录

也就是说,如果过期时间过大,可能会导致安全性降低,过期时间多小,用户就需要频繁的重新登录。在 BlockAuth 模块中,会针对不同的应用,设置不同的过期时间,兼顾安全性以及用户体验。

MFA

在之前的分享中,小山同学已经详细介绍过 MFA 的原理以及应用,在此就不再赘述。

HMAC

HMAC 是一种消息校验方式,在面向 developer 的场景是发挥作用,应用于服务端验证 client 端的请求是否合法而未被伪造。常见的使用方式:

  • client 端发起 request
  • client 端计算 HMAC signature
  • client 端将 request 以及 HMAC signature 发送给 server 端

server 端接收到 client 端的请求以及 HMAC signature 之后,会采用与 client 端相同的方式产生 HMAC signature,如果与 client 端发送的相同,则证明 client 端发送的请求是合法而未被伪造的。

为了计算 HMAC signature,就需要构造进行签名计算的字符串,以及进行签名的密钥。

在 BlockAuth 模块中,基于使用 GraphQL 的前提,进行签名的字符串是由 GraphQL 的 query 请求构成:

{"query":"{\n\trichestAccounts {\n    data {\n      address\n    }\n  }\n}\n","variables":null}

为了计算 signature 就需要签名的密钥,在 BlockAuth 模块中,使用了access_keyaccess_secret 的方式来管理密钥。

client 端可以通过 BlockAuth 模块 create access_key access_secret 对,在计算 HMAC signature 时:

  • client 端使用access_secret 作为密钥
  • 并将与之相对的access_key 发送给 server 端
  • server 端根据 client 发送的access_key 获得对应的access_secret
  • 计算 HMAC signature 签名

此外,为了尽可能防止合法签名的请求被冒用,client 端构造签名以及发送请求时,时间戳都是其中重要的一部分,server 端会校验时间戳,如果时间戳超过一定范围,该请求会被认为已经失效。

User Role

对于用户权限管理,BlockAuth 采用了 role-based access control (RBAC)的方式;对于用户操作而言,采用的是策略控制访问,对于不同的 Resource,可以定义 allow 的 action 列表,如:

[
  {
    "arn": "ocap",
    "action": ["read"],
    "resource": ["btc", "eth"],
    "quota": {
      "qps": "10/1",
      "cursor_limit": 100
    }
  },
  {
    "arn": "BlockAuth",
    "action": [
      "get_user_by_id",
      "get_user_by_email",
      "mutation_register_cellphone",
      "mutation_unregister_cellphone"
    ],
    "resource": "*",
    "quota": {
      "query_qps": "10/1",
      "mutation_qps": "1/1"
    }
  }
]

而控制策略的基本原则是,只有显式 allow 的 action 才能允许被执行。

结合 RBAC,为不同的 user 分配不同的 role,不同的 role 设置不同的访问控制策略,就能到达到管理用户权限的效果。

在 BlockAuth 实际实现中,根据用户检查用户当前操作是否被 allow 的基本流程:

  • get user privilege based on user role
  • check the action if allowed
  • check action if exceed the quota limit

对于 action 判断是否 allowed 的伪代码大致为:

    case Map.get(specific_privilege, "action") do
      "*" ->
        {:continue, ...}

      action_list when is_list(action_list) ->
        if Enum.member?(action_list, action) do
          {:continue, ...}
        else
          @forbidden
        end

      _ ->
        @forbidden
    end

而对于 action 是否超出 quota limit,在 BlockAuth 模块中,采用的是Token Bucket 算法。

在实现中的有趣的想法和思考

在整个 BlockAuth 模块的实现过程中,经过各种设计、权衡、编码、代码测试,再三反复,着实遇到一些有缺的想法和思考。至于一些基本的就不再赘述,例如单元测试的重要性,代码结构的设计。接下来,会从一些落脚点出发,抛砖引玉的讨论几个有趣的想法和思考。

使用 absinthe middleware 抽象组件逻辑

熟悉 ArcBlock 的同学,应该对 ArcBlock 采用的技术有一些基本的了解,在构建 service 时,主要采用了 GraphQL 协议以及 Elixir 编程语言,而 Absinthe 是使用 Elixir 实现的 GraphQL 框架。

基于此大背景,在逻辑实现时,针对不同的 action 逻辑,需要前置 Authenticate 操作,大致的伪代码:

def mutation_create(parent, args, info) do
  info
  |> get_jwt_token()
  |> BlockAuthenticate_action("mutation_create")
  |> case do
    {:ok, BlockAuthenticate} -> continue_logic()
    {:error, _} = error -> error
  end
end

换言之,对于所有的接口逻辑,都需要前置这样的 BlockAuthenticate 操作,就可能带来一些问题:

  • 代码冗余 [显而易见]
  • 单元测试冗余 [需要为每个接口的 error case 覆盖]
  • 难以维护

所幸,GraphQL 协议中,提供了 middleware,可以前置或者后置一些操作。在 Absinthe 的实现中,定义 middleware 可采用如下方式:

    @desc "Create one user access key"
    field(:create_user_access_key, :user_access_key) do
      middleware(ArcBlockAuthService.GQL.BlockAuth.Middleware.BlockAuthenticateAction,
        action: :mutation_create_user_access_key,
        action_type: :mutation
      )

      resolve(fn parent, args, resolution ->
        Logger.metadata(mutation: :mutation_create_user_access_key)
        apply(Resolver, :mutation_create_user_access_key, [parent, args, resolution])
      end)
    end

也就是,对于实际的接口而言,在执行 resolve 函数之前,先进行 BlockAuthenticate 操作。对于感兴趣的读者朋友,继续深入了解,可参见:

使用 pipeline 来封装多重逻辑

在逻辑实现中,经常存在这样的场景:一个复杂的逻辑操作,会由多重逻辑组成,前一重逻辑的输出是后一重逻辑的输入,如果某一重逻辑失败,中断后续的逻辑。

最容易想到的方式,大概是:

def logic() do
  case fn_1() do
    {:ok, _} ->
      case fn_2() do
        {:ok, _} ->
          case fn_3() do
            {:ok, _} ->
              :ok

            {:error_} ->
              :error
          end

        _ ->
          :error
      end

    _ ->
      :error
  end
end

但是这种方式的问题也显而易见,随着多重逻辑的增加,最先爆炸的是代码的缩进,可维护性也大大降级。为了解决这类问题,在编码时尝试了三种方案:

1,使用 try catch 捕获 throw

def logic() do
  res_1 =
    case fn_1() do
      {:ok, _} = return -> return
      {:error, _} = error -> throw(error)
    end

  res_2 =
    case fn_2(res_1) do
      {:ok, _} = return -> return
      {:error, _} = error -> throw(error)
    end

  case fn_3(res_2) do
    {:ok, _} = return -> return
    {:error, _} = error -> throw(error)
  end
catch
  error ->
    error
end

这种方式,能够极大的避免代码缩进的问题,相比较最初的方式,是提升了代码的维护性,但是从流线型的角度出发,还是不够流畅。

2,使用|> 串联多重逻辑

def logic() do
  fn_1()
  |> fn_2()
  |> fn_3()
end

defp fn_1(), do: {:ok, nil}

defp fn_2({:error, _} = error), do: error
defp fn_2({:ok, res_1}), do: {:ok, handle_res_1(res_1)}

defp fn_3({:error, _} = error), do: error
defp fn_3({:ok, res_2}), do: {:ok, handle_res_2(res_2)}

通过这种流线型 pipeline 式的方式,可以很轻松方便的串联多重逻辑。

3,使用 with

使用 |> 串联的方式仍旧有个问题,每一重逻辑函数,都需要处理 error 的 case,如果使用 with

def logic do
  with {:ok, res_1} <- fn_1(),
       {:ok, res_2} <- fn_2(res_1),
       {:ok, res_3} <- fn_3(res_2) do
    res_3
  else
    err -> err
  end
end

defp fn_1(), do: {:ok, "ok"}
defp fn_2(res_1), do: {:err, res_1}
defp fn_3(res_2), do: {:ok, res_2}

相比较第二种方式,使用 with 的好处就是不需要在每一重逻辑函数内考虑非正常的 case。

代码层面的读写分离

从接口角度分类,可以将接口大致分为读操作和写操作。常见的写操作:

  • 创建用户
  • 修改用户角色

而常见的读操作:

  • 查询用户信息
  • 获取用户权限

对于读操作和写操作来说,对数据一致性和接口性能的要求,都存在一定的差异。对于写操作,数据一致性要求就会高一些,对于接口性能的容忍度就大一些,可以接受略微的响应延迟。然而,对于读操作,对接口性能的要求就会比较高,但是对数据一致性的要求就略微降低。

而缓存是一种提升接口性能的常规手段,对于读操作而言,可以在 database 前增加一层缓存用来加速读操作。所以在代码结构组织上,BlockAuth 就尽可能将读写操作分开,便于各自 model 层外做适当的缓存用以提升接口性能:

~~~~> $>> tree
.
├── mutations
│   ├── model
│   │   ├── cellphone_state.ex
│   │   ├── email_state.ex
│   │   ├── role.ex
│   └── resolver
│       ├── cellphone.ex
│       ├── email.ex
│       ├── login.ex
│       ├── role.ex
└── queries
    ├── cache
    │   └── user.ex
    ├── model
    │   ├── email.ex
    │   ├── roles.ex
    └── resolver
        ├── email.ex
        ├── roles.ex

而为了后续的扩展性以及给架构演进留有余地,读写操作的代码尽可能相互不交叉,读部分的逻辑只依赖读部分的 model,写部分亦然。

总结

关于 BlockAuth 模块的方方面面边边角角比较多,本文选出若干部分以及几个有趣的点拿出来和大家分享。一是对内的总结,二也是希望能和大家共同交流进步。ArcBlock 是一家快速成长的公司,招小伙伴的进程一直没有 crash,欢迎简历。OPEN POSITIONS

Sign Up For Our Newsletter