几乎所有的系统里面都需要用户的概念。那么随之而来的就是一些各个系统都需要但是有非常类似的功能,比如登录、注册、账号密码重置、邮箱确认、修改基本信息...每个系统都要做一遍是不是很麻烦?有没有解决方案减少这些重复劳动?这部分功能我们可以成为账户系统。
另一方面,用户可以是内部用户或者是管理员,也可以是一般的服务使用者,每个用户由于其角色以及其自己创建的资源的私密性,系统需要提供相应的机制管理用户的访问权限,然而目前的微服务架构中多个系统之间如何传递这种用户的信息并执行相应的权限管理呢?处理这部分的系统我们可以成为认证系统。
可以看到,账号系统和认证系统是相互依赖的,用户系统和认证系统基本是要一起做的。(有些诡异的开源产品仅仅做了认证的工作虽然那部分工作做的很出色,但是依然是不完整的。)这里讨论一下有关这两个主题的一些内容,以及目前一些可以使用的解决方案。
微服务环境下的权限认证一直是我很头痛的一个问题:首先,账户系统理应是一个独立的服务,但是由于每个微服务都有用户权限检查的需求,那么每次对任意一个微服务的请求就有会包含一个额外的去账户系统的请求,这样的账户系统压力该多大...然后,微服务涉及到服务之间的权限认证问题,即 A 服务有访问 B 服务的需求,但是 A 服务是不需要有神马真正的“人”去使用的,服务自身是一个特殊的用户,我们需要处理这种特殊的情况。
调研了一番,发现其实目前已经有一个很好的认证标准了,它就是 OpenId connect。它建立在 oauth 2.0 之上,首先它提供了一个除了 oauth2.0 的 accesst_token
外的另一个 id_token
,它是 JWT 格式的,是我们减少大量鉴定用户请求的前提;并且其所遵循的 oauth2.0 的不同的认证流程也满足了我们对人和服务认证的需求;最后,其也为其授权的标准 url
制定了标准,使得各个实现了 openid connect 标准的任意系统都可以和其 client 轻松的集成。
OpenID connect 的认证流程和 oauth2.0 是一致的,但是又比其多了 discovery 的部分。这里首先明确几个重要的接口:
/.well-known/openid-configuration
这是一个入口,指明了几个重要的链接。
{
"issuer": "http://localhost:3000",
"authorization_endpoint": "http://localhost:3000/oauth/authorize",
"token_endpoint": "http://localhost:3000/oauth/token",
"userinfo_endpoint": "http://localhost:3000/oauth/userinfo",
"jwks_uri": "http://localhost:3000/oauth/discovery/keys",
...
}
authorization_endpoint
是登录界面,提供给人使用的
token_endpoint
用于获取 access_token
以及 id_token
userinfo_endpoint
用于获取用户的信息
jwks_uri
提供了 id_token
签名的公钥信息(一定会使用非对称式的加密,否则密钥就暴露了)
然后,在实现流程之前需要创建一个 client:auth2.0 流程要求每个向认证系统请求用户信息的应用都必须是注册了的,注册时需要提供基本的名称,要求获取用户的信息范围以及合法的跳转链接(用户信息范围和跳转链接的使用在下文会提及),之后 client 会获取一个 client_id
和 client_secret
在后面的流程中会使用到这些内容。
authorization_endpoint
的页面,并附带了一些认证流程需要的信息,比如scope
表明 client 需要获取的用户信息的范围,其中 openid 是必须的,然后还可以有额外的信息,比如 email 比如 profile,这些 scope 必须是在创建 client 时提供的redirect_uri
在认证系统认证成功后要求认证系统跳转的 uri,这个 uri 必须是在创建 client 时提供的response_type
要求认证系统返回的信息,在 authorization code 流程中就是 code
在 authorization_endpoint
用户可以创建新的账号或者直接登录已有的账号,这些动作都是一个用户系统所应当提供的内容,在完成登录之后,认证系统会随着 redirect_uri
跳转会 client 并在 query
提供一个 code
的参数
client 在从认证系统获得了 code
参数后,连同 client_id
client_secret
grant_type
一起 POST 给上文提到的 token_endpoint
,其中 grant_type
= authorization_code
,表明其所使用的认证流程。然后认证系统返回合法的 accesss_token
和 id_token
:
{
"access_token": "askdfjasdf",
"id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ..."
}
在获取 id_token
和 access_token
之后,一方面可以通过 access_token
获取 userinfo
,另一方面可以将 id_token
在各个系统之间传递。
前面提的是一种后端渲染的认证流程,在用户被重定向到认证系统并返回来时面对的是一个 server。而在前后端分离的情况下只有一点点的变化:
code
并调用一个后端接口在后端请求 token_endpoint
获取 id_token
以及 access_token
然后后端可以将 id_token
返回给前端,作为前端获取后端信息的认证。这样的好处就是 client_secret 始终不会因为暴露在前端而被他人获取前面讲了人的认证流程,下面是一个机器的认证流程了。其中最大的区别在于没有人工输入密码的过程。oauth 2.0 提供了两个可以这么做的认证流程。一个是直接密码登录,一个是连账号和密码都不需要,直接通过 client_id
client_secret
实现登录。
对于 password flow 我们是为 service 创建一个特殊的用户角色,而 client credentials 则更进一步:client_id 和 client_secret 本身就表明它是一个特殊的账号了。可见 client credentials 是更适合这种机器之间的通讯的。之所以还要强调 password flow 是因为并不是所有的 openid connect 体系都支持 client credentials 的,因为你可以认为它将 client 看做一个特别的 resource owner 和一般的用户认证体系有点格格不入。
+----------+
| Resource |
| Owner |
| |
+----------+
v
| Resource Owner
(A) Password Credentials
|
v
+---------+ +---------------+
| |>--(B)---- Resource Owner ------->| |
| | Password Credentials | Authorization |
| Client | | Server |
| |<--(C)---- Access Token ---------<| |
| | (w/ Optional Refresh Token) | |
+---------+ +---------------+
password flow
+---------+ +---------------+
| | | |
| |>--(A)- Client Authentication --->| Authorization |
| Client | | Server |
| |<--(B)---- Access Token ---------<| |
| | | |
+---------+ +---------------+
client credentials flow
前面提到微服务体系会导致用户每次访问都需要到认证系统认证权限,但是如果采用了 id_token 这样的 jwt 格式的令牌就可以避免这个问题了。
首先,认证系统的 jwks_uri
会将创建 id_token
签名的公钥暴露出来。那么,其他的微服务只需要将其保存在自己的服务中并在获得 id_token
时利用这个公钥检查其签名是否合法就能判断其是否来自自己的认证系统了。
并且,jwt 自己会涵盖一些基本的用户信息(当然,我们自己也可以控制里面承载的内容),这样每次想要获取基本的用户信息的时候直接从 jwt 中获取即可。
最后,jwt 自带 exp
的字段表明其失效的时间,每次请求微服务我们可以通过检测签名 + 失效时间判断是有效性。
前面提到,用户系统和认证系统对于很多系统来说都是重复性的功能。我们系统找到一个解决方案可以避免这种重复工作。目前来看,市面上有三种可选择的方案。
auth0
okta
都是比较著名的认证和账号管理解决方案(auth0 的那个宣传视频很清楚的解释了重复性劳动的问题,推荐看看),服务稳定,功能优良。当然也价格昂贵...而且有 GFW 的存在导致任何国际访问流量都慢了一个档次...首先 Okta 本来就是我司使用的方案,其速度之慢我感受颇深,然后 auth0 我也在自己的方案中亲测过,各种 timeout。所以并不推荐在国内使用。
keycloak
以及 cloudfoundry uaa
等是做为其自身 PaaS 产品的账号解决方案,结果生产环境的考验,系统稳定,功能健全。是我们可以考虑的方案。尤其是 keycloak 可以说是功能非常完备,openid connect 所有的认证流程(包括 client credentials)都支持。目前来看是一个可行的方案,我们自己的系统也有采用它的。
它唯一的问题就是有点历史包袱:SAML有的时候它就有了,很多采用的技术有点古老,自己定制化修改要费一些功夫。
在 rails 社区有成熟的用户系统的类库 devise
,有成熟的 oauth2.0 的类库 doorkeeper
, 还有 openid-connect 的半成品类库 doorkeeper-openid_connect
。如果把他们很好的组合起来应该是既灵活又功能完备的体系。但是...并没有被很好的集成在一起。
目前我自己正在做一个集成的 uaa 系统。
最近攻克了一个之前部署 single-page-app 的一个痛点:支持在运行时环境变量。这里讲述一下问题以及目前的解决方案。
目前我的绝大部分的项目都是一个前后端分离的方式开发的。其中前端基本都是用 create-react-app
创建出来的标准的 react 的 spa 应用。这种 spa 在部署是将所有的 js 和 css 打包成一个或多个文件然后用 serve
或者其他类似的 http server 以静态文件的形式对外提供服务,但是这种前端静态文件话的应用没有 nodejs 的支持,没办法使用 process.env
这样的运行时注入环境变量的功能。
目前 create-react-app
提供了一个编译运行时环境变量的方案,因为在 build
的时候是有 nodejs
支持的,通过 REACT_APP_API_URL=http://xxx.com yarn run build
的方式在编译 spa 的时候注入环境变量。那么编译时的环境变量能不能解决问题呢?看情况了...可以做一个简单的对比。
要知道我们通常要把什么样子的环境变量注入到 spa 中。额,我这里的需求很有限,为了让前后端一起运作,我所需要的环境变量就是后端 API 的入口。对于部署流程简单到之后生产环境且生产环境固定(尤其是后端生产环境 IP、域名固定)的情况,直接在编译时将后端的入口写死注入就行了。但如果有多个环境(staging)的需求就不适用了,假如没有运行时环境变量的支持为不同的环境提供不同的入口只能重新编译应用并注入不同的变量。
有没有需求在应用运行时修改我们的环境变量。很明显运行时的环境变量支持通过重启就能修改环境变量的功能,如果有这种灵活修改环境变量的情况,编译时环境变量很明显也不能满足。
在编译时对代码选择和裁剪。很明显,这个是最应该使用编译时环境变量的地方了。
说白了,其实不同时期的环境变量的作用是不一样的。两者不可能做到相互替代,在 [1]
[2]
两个场景都是使用运行时环境变量比较舒服的地方,采用编译时的环境变量实在是不太方便。下面就介绍一下目前让 spa 应用支持运行时环境变量的方法,这里还是以 create-react-app
的模板为示例。
前端没有 process.env
这样的东西,我们只能用 javascript 的全局变量模拟。在将这个打包好的 spa 运行起来的时候,我们需要利用 shell 脚本生成这个 config.js 文件,让它把必要的环境变量翻译成全局变量。然后让默认的入口 html 文件引入这个全局变量文件。
首先,我们需要一段 shell 脚本,把环境变量翻译成 config.js
文件:
#!/bin/bash
if [[ $CONFIG_VARS ]]; then
SPLIT=$(echo $CONFIG_VARS | tr "," "\n")
ARGS=
for VAR in ${SPLIT}; do
ARGS="${ARGS} -v ${VAR} "
done
JSON=`json_env --json $ARGS`
echo " ==> Writing ${CONFIG_FILE_PATH}/config.js with ${JSON}"
echo "window.__env = ${JSON}" > ${CONFIG_FILE_PATH}/config.js
fi
exec "$@"
如果我们提供这样的环境变量
export REACT_APP_API_PREFIX=http://petstore-backend.example.com
export CONFIG_VARS=REACT_APP_API_PREFIX
那么所生成的 config.js
文件是这个样子的:
window.__env = {
'REACT_APP_API_PREFIX': 'http://petstore-backend.example.com'
}
然后,我们需要在 原来的 index.html
模板文件中引入这个我们生成的 config.js
文件:
<!doctype html>
<html lang="en">
<head>
...
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
<script type="text/javascript" src="config.js"></script>
</body>
</html>
这样,我们就拥有了一个 window.__env
的全局对象,它包含了所有的运行时环境变量。我们可以以如下的方式使用它:
axios.defaults.adapter = httpAdapter;
let baseUrl;
let env = window.__env || {}; // 1
if (process.env.NODE_ENV === 'test') {
baseUrl = 'http://example.com';
} else if (process.env.NODE_ENV === 'development') {
baseUrl = env.REACT_APP_API_PREFIX || 'http://localhost:8080'; // 2
} else {
baseUrl = env.REACT_APP_API_PREFIX;
}
const fetcher = axios.create({
baseURL: baseUrl,
headers: {
'Content-Type': 'application/json'
}
});
window.__env
全局变量当然,这种依赖 shell 生成 config.js
的方案只有我们将 spa 打包好的之后才会使用,为了更好的使用这个 shell 我们可以采用 docker 化的方式把其启动流程以 entrypoint 的方式固化在应用的启动流程中。SocialEngine/docker-nginx-spa 就实现了这个方案,是一个很好的用 base image。如果我们需要创建一个支持运行时环境变量的 create-react-app spa 的时候,首先按照上面的步骤修改 public/index.html
并且用 window.__env
作为环境变量使用。然后提供一个继承自 SocialEngine/docker-nginx-spa
的 Dockerfile
即可。
FROM socialengine/nginx-spa
COPY build/ /app
其中 build/
是 create-react-app
编译生成静态文件的默认目录。然后打包运行这个应用的方式如下:
$ yarn run build
$ docker build -t spa-app .
$ docker run -e CONFIG_VARS=REACT_APP_API_PREFIX -e REACT_APP_API_PREFIX=http://petstore-backend.example.com -p 3000:80 spa-app
当然,我们本地开发环境不用这么麻烦。只需要在 public/
目录下自己创建一个 config.js
然后把开发需要的环境变量塞进去就可以了。在 docker 化后,entrypoint 触发的命令会自动覆盖这个 config.js 文件。
这里 是一个样例项目。
这么久以来,各种各样的框架试图让 web 组件化。到目前为止,react 基本做到了这一点:用一个自定义标签的方式组织 html 在一起。
<Wrapper>
<Header />
<ProductList />
<Footer />
</Wrapper>
上面的这种写法在传统的 web 开发中真是不敢想象,然而在 react 中的确实现了。如果你使用 create-react-app
这样的脚手架工具,你可以快速的搭建起来这样的体系。然而,即便是这样子,web 组件化依然有一个点没有解决:如何将样式和组件绑定在一起。当然,试图解决这个问题的工具有很多,也有很多人不认为这是一个问题。我在这里试图解释一些观点,并阐述为什么我觉得用 styled-components
可以在一定程度上解决一系列问题。
目前,react 阵营对写 css 这个问题有两个阵营。一个阵营表示 css 应当和 js 写在一起,而另一个阵营则认为 css 原本是可以和 js 分离的。我们在这里做一个简单的例子。
首先是 css in js 的例子:
const style = {
margin: "1em 2em",
color: "gray",
background-color: "white"
};
const StyledDiv = (props) => {
return <div style={style}>A test</div>
};
而 css 和 js 分离就很简单了:
.styled-div {
margin: 1em 2em;
color: gray;
background-color: white;
}
import "./StyledDiv.css";
const StyledDiv = (props) => {
return <div className="styled-div">A test</div>
};
当然,这里展示的 css in js 只是一种非常原始的方式:用 object 直接将 style 注入到组件中。这样做的好处有两个:
可以看到,这里基本上就是以解决 css 的全局性为出发点的。
而 css 和 js 的分离当然也有其天然的优势:
对我来说,用 object 的方式去写 css 体验实在是太差了。而且作为 css
的 cascade
,如果不能用多级的选择器去定位 css 而是在一层层的 html 元素中添加样式简直就是噩梦。我不觉得这样的可维护水平比全局 css 要高...所以我觉得如果能把两者的优势结合在一起,就应该是一个可以被更多人介绍的方式:
那么在这里就不得不提另外一个有意思的东西:css-modules。它的主要思想是通过为 css 生成随机的类名称的方式来建立一种局部类命名的方式。
styled-components
基本上集成了这个工作,并在此基础上基本实现了以上的三点要求。
const Summary = styled.div`
margin-top: 2em;
text-align: right;
.price {
color: #ff0036;
font-size: 1.2em;
}
&> * {
display: inline-block;
margin-left: 1em;
}
`;
Summary
的 css 是以 css 的方式编写的,支持多层次的定义styled-components
会把上面定义的 css 以一个特别的 className 的方式注入到元素上,实现了局部类定义styled-components
支持了基本的类似于 scss 的嵌套语法(还支持 extend 语法,这里并没有展示),并且内嵌了 autoprefix
的模块我最近开始在一个项目上使用它,整体来说还是感觉不错。
styled-components
采用的 css-module 的模式有另外一个好处就是可以很好的与其他的主题库进行兼容。因为大部分的 css 框架或者 css 主题都是以 className 的方式进行样式处理的,额外的 className 和主题的 className 并不会有太大的冲突。你可以认为这是一个应当使用全局 css 的地方(所以我并不赞成用 styled-components 里面的 theming 接口去做这件事)。相对于以 object 的方式写 style 的 material-ui 真是好太多了,看看 material-ui 讲述如何进行样式自定义就知道这并不是一个很成熟的想:
styled-components
的语法同样支持对一个 React 组件进行扩展:
const StyledDiv = styled(Row)`
position: relative;
height: 100%;
.image img {
width: 100%;
}
.content {
min-height: 30em;
overflow: auto;
}
.content h2 {
font-size: 1.8em;
color: black;
margin-bottom: 1em;
}
`;
这里我把 ant design 做为我默认的样式库,在其基础上我对其一些元素做了增强。两者可以很好的在一起使用。
这里符一个 github 项目 里面包含了很多使用 styled-components
的例子。