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_key
和 access_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