[总结]微服务认证鉴权与API权限控制
从单体应用架构到分布式应用架构再到微服务架构,应用的安全访问在不断的经受考验。为了适应架构的变化、需求的变化,身份认证与鉴权方案也在不断的变革。面对数十个甚至上百个微服务之间的调用,如何保证高效安全的身份认证?面对外部的服务访问,该如何提供细粒度的鉴权方案?
1. 1. 背景
最近在做权限相关服务的开发,在系统微服务化后,原有的单体应用是基于session的安全权限方式,不能满足现有的微服务架构的认证与鉴权需求。微服务架构下,一个应用会被拆分成若干个微应用,每个微应用都需要对访问进行鉴权,每个微应用都需要明确当前访问用户以及其权限。尤其当访问来源不只是浏览器,还包括其他服务的调用时,单体应用架构下的鉴权方式就不是特别合适了。在微服务架构下,要考虑外部应用接入的场景、用户–服务的鉴权、服务–服务的鉴权等多种鉴权场景。
比如用户A访问User Service,A如果未登录,则首先需要登录,请求获取授权token。获取token之后,A将携带着token去请求访问某个文件,这样就需要对A的身份进行校验,并且A可以访问该文件。
为了适应架构的变化、需求的变化,auth权限模块被单独出来作为一个基础的微服务系统,为其他业务service提供服务。
2. 2. 系统架构的变更
随着微服务架构的兴起,传统的单体应用场景下的身份认证和鉴权面临的挑战越来越大。单体应用体系下,应用是一个整体,一般针对所有的请求都会进行权限校验。请求一般会通过一个权限的拦截器进行权限的校验,在登录时将用户信息缓存到 session 中,后续访问则从缓存中获取用户信息。
而微服务架构下,一个应用会被拆分成若干个微应用,每个微应用都需要对访问进行鉴权,每个微应用都需要明确当前访问用户以及其权限。尤其当访问来源不只是浏览器,还包括其他服务的调用时,单体应用架构下的鉴权方式就不是特别合适了。在为服务架构下,要考虑外部应用接入的场景、用户 – 服务的鉴权、服务 – 服务的鉴权等多种鉴权场景。
David Borsos 在伦敦的微服务大会上提出了四种方案:
1. 单点登录(SSO)
这种方案意味着每个面向用户的服务都必须与认证服务交互,这会产生大量非常琐碎的网络流量和重复的工作,当动辄数十个微应用时,这种方案的弊端会更加明显。
2. 分布式 Session 方案
分布式会话方案原理主要是将关于用户认证的信息存储在共享存储中,且通常由用户会话作为 key 来实现的简单分布式哈希映射。当用户访问微服务时,用户数据可以从共享存储中获取。在某些场景下,这种方案很不错,用户登录状态是不透明的。同时也是一个高可用且可扩展的解决方案。这种方案的缺点在于共享存储需要一定保护机制,因此需要通过安全链接来访问,这时解决方案的实现就通常具有相当高的复杂性了。
3. 客户端 Token 方案
令牌在客户端生成,由身份验证服务进行签名,并且必须包含足够的信息,以便可以在所有微服务中建立用户身份。令牌会附加到每个请求上,为微服务提供用户身份验证,这种解决方案的安全性相对较好,但身份验证注销是一个大问题,缓解这种情况的方法可以使用短期令牌和频繁检查认证服务等。对于客户端令牌的编码方案,Borsos 更喜欢使用 JSON Web Tokens(JWT),它足够简单且库支持程度也比较好。
4. 客户端 Token 与 API 网关结合
这个方案意味着所有请求都通过网关,从而有效地隐藏了微服务。 在请求时,网关将原始用户令牌转换为内部会话 ID 令牌。在这种情况下,注销就不是问题,因为网关可以在注销时撤销用户的令牌。
3. 3. 技术方案
这主要包括两方面需求:其一是认证与鉴权,对于请求的用户身份的授权以及合法性鉴权;其二是API级别的操作权限控制,这个在第一点之后,当鉴定完用户身份合法之后,对于该用户的某个具体请求是否具有该操作执行权限进行校验。
3-1. 3.1 认证与鉴权
3-1-1. 3.1.1 分布式Session
方案
分布式会话方案原理主要是将关于用户认证的信息存储在共享存储中,且通常由用户会话作为 key 来实现的简单分布式哈希映射。当用户访问微服务时,用户数据可以从共享存储中获取。在某些场景下,这种方案很不错,用户登录状态是不透明的。同时也是一个高可用且可扩展的解决方案。这种方案的缺点在于共享存储需要一定保护机制,因此需要通过安全链接来访问,这时解决方案的实现就通常具有相当高的复杂性了。
分布式架构下,可以通过 Session 复制或者 Session 粘制的方案来解决。
Session 复制依赖于应用服务器,需要应用服务器有 Session 复制能力,不过现在大部分应用服务器如 Tomcat、JBoss、WebSphere 等都已经提供了这个能力。
除此之外,Session 复制的一大缺陷在于当节点数比较多时,大量的 Session 数据复制会占用较多网络资源。Session 粘滞是通过负载均衡器,将统一用户的请求都分发到固定的服务器节点上,这样就保证了对某一用户而言,Session 数据始终是正确的。不过这种方案依赖于负载均衡器,并且只能满足水平扩展的集群场景,无法满足应用分割后的分布式场景。
在微服务架构下,每个微服务拆分的粒度会很细,并且不只有用户和微服务打交道,更多还有微服务间的调用。这个时候上述两个方案都无法满足,就要求必须要将 Session 从应用服务器中剥离出来,存放在外部进行集中管理。可以是数据库,也可以是分布式缓存,如 Memchached、Redis 等。这正是 David Borsos 建议的第二种方案,分布式 Session 方案。
3-1-2. 3.1.2 基于Token
方案
随着 Restful API、微服务的兴起,基于Token
的认证现在已经越来越普遍。Token和Session ID 不同,并非只是一个 key。Token 一般会包含用户的相关信息,通过验证 Token 就可以完成身份校验。用户输入登录信息,发送到身份认证服务进行认证。AuthorizationServer验证登录信息是否正确,返回用户基础信息、权限范围、有效时间等信息,客户端存储接口。用户将 Token 放在 HTTP 请求头中,发起相关 API 调用。被调用的微服务,验证Token
。ResourceServer返回相关资源和数据。
基于 Token 认证的一个典型流程如下:
- 用户输入登录信息(或者调用 Token 接口,传入用户信息),发送到身份认证服务进行认证(身份认证服务可以和服务端在一起,也可以分离,看微服务拆分情况了)。
- 身份验证服务验证登录信息是否正确,返回接口(一般接口中会包含用户基础信息、权限范围、有效时间等信息),客户端存储接口,可以存储在 Session 或者数据库中。
- 用户将 Token 放在 HTTP 请求头中,发起相关 API 调用。
- 被调用的微服务,验证 Token 权限。
- 服务端返回相关资源和数据。
OAuth2 Token
认证的好处如下:
- 服务端无状态:Token 机制在服务端不需要存储 session 信息,因为 Token 自身包含了所有用户的相关信息。
- 性能较好,因为在验证 Token 时不用再去访问数据库或者远程服务进行权限校验,自然可以提升不少性能。
- 现在很多应用都是同时面向移动端和web端,
OAuth2 Token
机制可以支持移动设备。 - 最后一点,也是挺重要的,OAuth2与Spring Security结合使用,Spring Security OAuth2的文档写得较为详细。
oauth2根据使用场景不同,分成了4种模式:
- 授权码模式(authorization code)
- 简化模式(implicit)
- 密码模式(resource owner password credentials)
- 客户端模式(client credentials)
3-1-3. 角色
- Resource Owner:资源所有者,即用户
- User Agent:用户代理
- Authorization Server:即提供第三方登录服务的服务器
- Client:第三方应用,我们的应用就是一个client
3-1-4. 3.1.2.1 授权码类型(authorization code)
通过重定向的方式让资源所有者直接与授权服务器进行交互来进行授权,避免了资源所有者信息泄漏给客户端,是功能最完整、流程最严密的授权类型,但是需要客户端必须能与资源所有者的代理(通常是Web浏览器)进行交互,和可从授权服务器中接受请求(重定向给予授权码),授权流程如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
+----------+ | Resource | | Owner | | | +----------+ ^ | (B) +----|-----+ Client Identifier +---------------+ | -+----(A)-- & Redirection URI ---->| | | User- | | Authorization | | Agent -+----(B)-- User authenticates --->| Server | | | | | | -+----(C)-- Authorization Code ---<| | +-|----|---+ +---------------+ | | ^ v (A) (C) | | | | | | ^ v | | +---------+ | | | |>---(D)-- Authorization Code ---------' | | Client | & Redirection URI | | | | | |<---(E)----- Access Token -------------------' +---------+ (w/ Optional Refresh Token) |
- 客户端引导资源所有者的用户代理到授权服务器的endpoint,一般通过重定向的方式。客户端提交的信息应包含客户端标识(client identifier)、请求范围(requested scope)、本地状态(local state)和用于返回授权码的重定向地址(redirection URI)
- 授权服务器认证资源所有者(通过用户代理),并确认资源所有者允许还是拒绝客户端的访问请求
- 如果资源所有者授予客户端访问权限,授权服务器通过重定向用户代理的方式回调客户端提供的重定向地址,并在重定向地址中添加授权码和客户端先前提供的任何本地状态
- 客户端携带上一步获得的授权码向授权服务器请求访问令牌。在这一步中授权码和客户端都要被授权服务器进行认证。客户端需要提交用于获取授权码的重定向地址
- 授权服务器对客户端进行身份验证,和认证授权码,确保接收到的重定向地址与第三步中用于的获取授权码的重定向地址相匹配。如果有效,返回访问令牌,可能会有刷新令牌(Refresh Token)
3-1-4-1. 实例
1.授权请求 比如你想用登录gitlab,想利用三方授权登录,比如谷歌账号,当你点击谷歌图标的时候,会首先发起一个授权请求。 会带上以下参数,向授权服务器发送授权请求:
1 2 3 4 5 |
response_type:表示授权类型,必选项 client_id:表示客户端的ID,必选项,一般是在授权服务器上申请应用的时候,颁发的 redirect_uri:重定向的 uri scope:表示申请的权限范围 state:表示客户端的当前状态,可以是任意值,认证服务器会原封不动的返回这个值 |
下面是一个例子:
1 2 3 |
GET /authorize?response_type=code&client_id=s6BhdRkqt3&state=xyz &redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb HTTP/1.1 Host: server.example.com |
授权服务器验证该请求,确保所有的参数提交且有效,授权服务器会引导用户进入授权页面
2.当用户点击确定授权时,授权服务器会返回授权码(code)和状态参数(state),返回请求到相应的回调地址(redirect_uri)。至此,用户的主动行为已经结束。 3.第三方应用,也就是我们的客户端,在拿到授权码之后会直接向授权服务器请求访问令牌,参数如下:
1 2 3 4 5 |
grant_type:授权模式,必选项,值 "authorization_code" code:从授权服务器收到的授权码 redirect_uri:回调地址 client_id:标识应用ID client_secret:授权服务器颁发的密钥 |
授权服务器验证通过后会返回如下参数:
1 2 3 4 5 6 7 8 9 10 11 |
HTTP/1.1 200 OK Content-Type: application/json;charset=UTF-8 Cache-Control: no-store Pragma: no-cache { "access_token":"2YotnFZFEjr1zCsicMWpAA", "token_type":"example", "expires_in":3600, "refresh_token":"tGzv3JOkF0XG5Qx2TlKWIA", "example_parameter":"example_value" } |
至此整个授权码流程结束。
3-1-4-2. 需要注意的几个问题
- 重定向 redirect_uri uri 整个授权过程都是一样的
- 授权过程只是授权: 比如,你想用 “支付宝” 授权登录 “优酷” ,整个授权的过程只是支付宝验证用户授权,返回一个 openId 和 支付宝允许给优酷的一些用户信息,优酷拿到 openId可以直接上传服务端注册,至此整个授权流程结束。后面优酷相关联的资源获取和授权没有任何关联了。优酷会对这个 openId和账号做唯一映射,这样下次还用支付宝授权登录,优酷的服务端不会在创建新的账号给该用户,即不需要用户密码的一键登录。
- 为什么整个授权流程不直接返回 access_token 而是要经过中间步骤先要获取授权码 code ,在根据授权码再次获取 access_token ? 最直接的答案是安全,code有效期比较短,而且一个code只能换取一次access_token即失效。获取code的请求是用户主动授权之后获取的从这里截止,用户的主动行为结束。 获取参数不包含 appsecret,请求回来的code回立马再次发起请求,这次请求会带上appsecret,授权服务器验证并返回 access_token
3-1-5. 3.1.2.2 隐式授权类型(implicit)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
+----------+ | Resource | | Owner | | | +----------+ ^ | (B) +----|-----+ Client Identifier +---------------+ | -+----(A)-- & Redirection URI --->| | | User- | | Authorization | | Agent -|----(B)-- User authenticates -->| Server | | | | | | |<---(C)--- Redirection URI ----<| | | | with Access Token +---------------+ | | in Fragment | | +---------------+ | |----(D)--- Redirection URI ---->| Web-Hosted | | | without Fragment | Client | | | | Resource | | (F) |<---(E)------- Script ---------<| | | | +---------------+ +-|--------+ | | (A) (G) Access Token | | ^ v +---------+ | | | Client | | | +---------+ |
隐式授权类型(Implicit Grant Type)不通过第三方应用程序的服务器,直接在浏览器中向认证服务器申请令牌,跳过了”授权码”这个步骤,因此得名。所有步骤在浏览器中完成,令牌对访问者是可见的,且客户端不需要认证。流程如下:
- 客户端将用户导向认证服务器。
- 用户决定是否给于客户端授权。
- 假设用户给予授权,认证服务器将用户导向客户端指定的”重定向 URI”,并在 URI 的 Hash 部分包含了访问令牌。
- 浏览器向资源服务器发出请求,其中不包括上一步收到的 Hash 值。
- 资源服务器返回一个网页,其中包含的代码可以获取 Hash 值中的令牌。
- 浏览器执行上一步获得的脚本,提取出令牌。
- 浏览器将令牌发给客户端。
3-1-5-1. 实例
和授权码请求一样,首先你会发起一个授权请求,验证参数
1.授权请求:
1 2 3 4 5 |
response_type:表示授权类型,此处固定值为 token client_id:客户端标识 redirect_uri:重定向 uri scope:访问的权限范围 state:表示客户端的当前状态,可以是任意值,认证服务器会原封不动的返回 |
客户端发起 HTTP 请求,如下:
1 2 |
GET /authorize?response_type=token&client_id=s6BhdRkqt3&state=xyz&redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb HTTP/1.1 Host: server.example.com |
2.授权服务器会验证该请求,确保所有的参数已提交且有效。授权服务器验证请求参数中的uri和客户端提交的重定向的uri保持一致。 如果,该请求是有效的,授权服务器验证对资源所有者进行身份验证并展示授权页面给用户。 用户同意授权请求时,授权服务器回返回如下参数:
1 2 3 4 5 |
access_token:授权服务器颁发的访问令牌 token_type:令牌类型 expires_in:过期时间 scope:表示授权权限范围 state:表示客户端的当前状态,可以是任意值,认证服务器会原封不动的返回这个 |
这些参数,拼接在重定向 uri 地址的后面
3.第三方应用向资源服务器发起请求,不包含上一步获取的access_token 资源服务器返回一个网页(通常是带有嵌入式脚本的 HTML 文档),其中的脚本可以提取出令牌,浏览器把access_token发送给客户端 至此整个简单授权模式结束。
隐式授权和授权码授权的区别就是,是直接获取access_token,不需要经过code步骤。但是获取的access_token需要资源服务器的脚本从第三方代理(既浏览器)中提取出来)发送给客户端。
3-1-6. 3.1.2.3 密码模式(resource owner password credentials)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
+----------+ | Resource | | Owner | | | +----------+ v | Resource Owner (A) Password Credentials | v +---------+ +---------------+ | |>--(B)---- Resource Owner ------->| | | | Password Credentials | Authorization | | Client | | Server | | |<--(C)---- Access Token ---------<| | | | (w/ Optional Refresh Token) | | +---------+ +---------------+ |
密码模式中,用户向客户端提供自己的用户名和密码。客户端使用这些信息,向”服务商提供商”索要授权。在这种模式中,用户必须把自己的密码给客户端,但是客户端不得储存密码。这通常用在用户对客户端高度信任的情况下,比如客户端是操作系统的一部分,或者由一个著名公司出品。而认证服务器只有在其他授权模式无法执行的情况下,才能考虑使用这种模式。流程如下:
- 资源所有者像客户端提供他的用户名和密码
- 当发起请求客户端向授权服务器进行身份认证
- 授权服务器对客户端进行身份验证,验证资源所有者的凭证,如果有效则颁发访问令牌。
3-1-6-1. 实例
访问令牌请求参数:
1 2 3 4 |
grant_type:值必须为 "password" username:资源所有者用户名 password:资源所有者密码 scope:客户端授权请求范围 |
例如:
1 2 3 4 5 |
POST /token HTTP/1.1 Host: server.example.com Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW Content-Type: application/x-www-form-urlencoded grant_type=password&username=johndoe&password=A3ddj3w |
授权服务器验证授权请求通过,参数如下:
1 2 3 4 5 6 7 8 9 10 11 |
HTTP/1.1 200 OK Content-Type: application/json;charset=UTF-8 Cache-Control: no-store Pragma: no-cache { "access_token":"2YotnFZFEjr1zCsicMWpAA", "token_type":"example", "expires_in":3600, "refresh_token":"tGzv3JOkF0XG5Qx2TlKWIA", "example_parameter":"example_value" } |
3-1-7. 3.1.2.4 客户端模式(Client Credentials)
1 2 3 4 5 6 7 |
+---------+ +---------------+ | | | | | |>--(A)- Client Authentication --->| Authorization | | Client | | Server | | |<--(B)---- Access Token ---------<| | | | | | +---------+ +---------------+ |
客户端模式(Client Credentials Grant)指客户端以自己的名义,而不是以用户的名义,向”服务提供商”进行认证。严格地说,客户端模式并不属于 OAuth 框架所要解决的问题。
在这种模式中,用户直接向客户端注册,客户端以自己的名义要求”服务提供商”提供服务,其实不存在授权问题。流程如下:
- 客户端向认证服务器进行身份认证,并要求一个访问令牌。
- 认证服务器确认无误后,向客户端提供访问令牌。
3-1-7-1. 实例
访问令牌请求:
1 2 |
grant_type:表示授权类型,值必须为 "client_credentials" scope:客户端的授权请求反问 |
如下,请求:
1 2 3 4 5 |
POST /token HTTP/1.1 Host: server.example.com Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW Content-Type: application/x-www-form-urlencoded grant_type=client_credentials |
授权服务器验证该请求,如果请求有效则返回,令牌:
1 2 3 4 5 6 7 8 9 |
HTTP/1.1 200 OK Content-Type: application/json;charset=UTF-8 Cache-Control: no-store Pragma: no-cache { "access_token":"2YotnFZFEjr1zCsicMWpAA", "token_type":"example", "expires_in":3600, "example_parameter":"example_value" } |
3-1-8. 刷新访问令牌
如果,授权服务器颁发了刷新 refresh_token,可以在客户端发起刷新 access_token,一般 refresh_token有效期是大于access_token有效期 请求参数如下:
1 2 3 |
grant_type:授权类型,此处必须为 "refresh_token" refresh_token:颁发给客户端的刷新令牌 scope:表示可访问的权限范围 |
如下 HTTP 请求
1 2 3 4 5 |
POST /token HTTP/1.1 Host: server.example.com Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW Content-Type: application/x-www-form-urlencoded grant_type=refresh_token&refresh_token=tGzv3JOkF0XG5Qx2TlKWIA |
实际开发中,该模式还是需要Resource来获取userinfo或tokeninfo。
3-2.
3-3. 3.2 操作权限控制
Shiro
Shiro是一个强大而灵活的开源安全框架,能够非常清晰的处理认证、授权、管理会话以及密码加密。Shiro很容易入手,上手快控制粒度可糙可细。自由度高,Shiro既能配合Spring使用也可以单独使用。Spring Security
Spring社区生态很强大。除了不能脱离Spring,Spring Security具有Shiro所有的功能。而且Spring Security对Oauth、OpenID也有支持,Shiro则需要自己手动实现。Spring Security的权限细粒度更高。但是Spring Security太过复杂
4. 4. 总结
正如 David Borsos 所建议的一种方案,在微服务架构下,我们更倾向于将 Oauth 和 JWT 结合使用,Oauth 一般用于第三方接入的场景,管理对外的权限,所以比较适合和 API 网关结合,针对于外部的访问进行鉴权(当然,底层 Token 标准采用 JWT 也是可以的)。
JWT 更加轻巧,在微服务之间进行访问鉴权已然足够,并且可以避免在流转过程中和身份认证服务打交道。当然,从能力实现角度来说,类似于分布式 Session 在很多场景下也是完全能满足需求,具体怎么去选择鉴权方案,还是要结合实际的需求来。
[Reference]
认证鉴权与API权限控制在微服务架构中的设计与实现:授权码模式
OAuth 2.0 与 OpenID Connect 协议的完整指南