登录这事之于一个需要识别用户身份的产品,就仿佛cs101之于computer science。感谢各种语言里各种优秀的登录模块(比如nodejs的passport),绝大多数产品,把它们拿来配置一下,闭着眼睛,花点功夫,就完成了一个从用户注册到登录一条龙的服务。很好很强大,不需要较真,也没人较真。
可登录还真是一件即便你半天就搞定还是需要好好较一下真的问题。本文回归本源,谈谈登录中那些极其重要又被人忽视的思想。
首先需要回答的一个问题是:要求用户登录的目的何在?
这个问题的答案是不言而喻的。服务器上的资源并非人人可以访问和操作,我们需要识别用户身份,从而了解他可以访问哪些资源,完成哪些操作。这里面隐含着几个重要的概念:
- 资源(resource)
- 操作(operation)
- 身份,或者角色(role)
先看「资源」。如果你设计一个聊天系统,那么,为聊天而建的群组(channel),在群组中大家畅所欲言发表的信息(message)就是资源的概念。这个很好理解。
「操作」是附着在「资源」上的用户行为。在某个资源上,最基本的操作是:读(read),写(write),执行(execute)。那么,读/写/执行究竟怎么理解呢?聊天系统列出(list)当前所有可见的群组,或者显示(show)某个群组下的某条聊天记录,这便是读操作;某个用户创建(create)一个群组,修改(update)群组信息,发表(create)聊天记录,撤销(delete)一条聊天记录,这些都是写操作的范畴。至于在聊天记录里面全文搜索(search),存档(archive)旧的聊天记录,可以被视作执行。
操作 | 示例 |
---|---|
读 | 列出所有群组/显示某条聊天记录,或者说 list/show |
写 | 创建群组/修改群组信息/发表聊天记录/撤销聊天记录,或者说 create/update/delete |
执行 | 全文检索/存档,或者说 search/archive |
读/写/执行是最基本的操作,而list/show/create/update/delete/search/archive是具体的操作。
「角色」是一个用户属性,定义用户对资源的访问权限。上述的聊天系统可能的角色有:所有用户(all users),匿名用户(anonymous users),已登录用户(authenticated users),群主(更广义一些说,resource owners)以及管理员(administrators)。这五个角色是一个系统最基本的角色,在此基础上可以衍生出来一些特定的角色,比如群成员。
对于一个「角色」来说,其访问权限可以通过访问列表(ACL,access list)来定义。一般而言:
- 所有用户不能进行任何操作
- 匿名用户可以进行读操作
- 已登录用户可以进行创建资源(特定的写操作)
- 资源拥有者可以对自己创建的资源进行任何写操作(修改/删除)
- 管理员可以对任何资源进行写操作
web应用的访问列表的功能可以类比网络中的防火墙的功能:
对于我们举的聊天系统的例子,具体的访问列表可能是这个样子:
- 所有用户不能进行任何操作
- 匿名用户只能执行登录/注册操作
- 已登录用户可以创建群组(写)
- 已登录用户可以读取群组列表(读)
- 已登录用户可以加入群组(执行)
- 群成员可以发信息(写)
- 群成员可以删除自己最后发出的信息(写)
- 群主可以修改群组信息(写)
- 群主可以批准加入请求(执行)
- 群主可以把不良分子驱逐出群(执行)
- …(管理员就不列了)
把这些访问列表以yaml的形式定义,大概是这个样子:
当系统里每个角色都有了定义清晰的访问列表后,一个用户的登录行为实际上就是动态迁移角色的行为。比如说,登录前小明的角色是 [所有用户, 匿名用户],登陆后他的角色转化为 [所有用户, 已登录用户],当他创建群组A后,并进入群组A后,他的角色转化为 [所有用户, 已登录用户, A群成员, A群群主],当他加入群组B,开始聊天时,他的角色又转化为 [所有用户, 已登录用户, B群成员]。无论小明访问系统的哪个部分,我们都能找到他对应的角色,进而算出他拥有的权限的集合(所有角色的访问列表的并集)。
有同学可能会认为「所有用户」这个角色,以及「所有用户不能进行任何操作」这个访问列表有些多余,其实,这正是系统设计严密性的一种体现。就如一个防火墙,其默认的策略是「从任意源到任何目的地的网络数据都丢弃」,或者一段switch case,最后总需要有一个default是同一个道理。一个用户在极端的情况下可能没有附加任何角色,或者请求的操作并未找到对应的访问列表,那么能唯一匹配的访问列表就是「所有用户不能进行任何操作」(all, *, *, DENY),所以不允许他做任何事情,在逻辑上是严密的。
定义好了资源,对资源允许的操作,用户可以附加的角色,以及角色拥有的访问列表这些最基本的内容之后,整个用户权限系统就清晰多了。你再也不必用散落在各处的代码苦心孤诣地从上下文里扒拉出来这个用户究竟允不允许做当前的操作,而是通过在请求的入口处设立一道闸门(middleware),挡掉不合法的请求,只允许合法的请求通过这道闸门,闸门的设计很简单:
代码语言:javascript复制guard(resource, operation, role_list)
其中,resource和operation必然在请求中包含,比如一个http请求:https://api.chat.xyz/channels/wtf/actions/send/, wtf(组名)就是resource,send就是operation。而role_list可以在user session里找到。
这大大简化了权限处理,而guard本身,实际上就是一个acl lookup engine。你可以找现有的解决方案,也可以把所有定义好的访问列表塞到一个hash table里,放在redis里进行快速查询,当然,如果你会一门趁手的函数式编程语言,比如elixir,可以直接做pattern matching:
代码语言:javascript复制def do_guard("channel", "$owner", "$execute") do
"ALLOW"
end
...
def do_guard(_, _, "$all") do
"DENY"
end
对于那些允许管理员在后台修改访问列表的系统,我们还可以使用使用elixir的macro功能,在每次后台修改完成后,触发重新生成acl lookup engine,并利用erlang VM的特性,hot code reload到系统中。