OpenID Connect
OAuth是关于授权(给予第三方应用访问你存放在某处的资料,例如让Gmem访问你的Github头像)的开放标准,目前版本是2.0。
只有得到用户的授权,Github才会允许Gmem访问你的头像。那么Github怎么样才能确认Gmem获得授权了呢?最简单的方法是把你的Github密码告诉Gmem,这显然是不安全的:
- 你必须修改密码,才能取消授权
- 如果Gmem泄漏了密码,你的Github账户将发生危险
OAuth协议就是为了解决这个安全问题而生。
术语 | 说明 |
Third-party App | 第三方应用程序,OAuth的客户端,例如上面例子中的Gmem |
HTTP service | 服务提供商,例如上面例子中的Github |
Resource Owner | 资源所有者,用户,例如上面例子中的Github用户 |
User Agent | 用户代理,通常是浏览器 |
Authorization server | 专门处理授权请求的服务 |
Resource server | 专门维护资源的服务,可以和Authorization server是同一服务,也可以是不同服务 |
OAuth提供一种机制,让Third-party App安全的获得User Agent的授权,然后和HTTP service交互。
OAuth在第三方应用和服务提供商之间,加入一个授权层(Authorization layer),第三方应用不直接登陆到服务提供商,而是登陆授权层。授权层给予第三方APP以令牌,令牌包含权限范围、有效期等关键信息。
第三方应用携带令牌,访问服务提供商,后者校验令牌并确定它能否访问目标资源。
OAuth协议的抽象工作流图:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
+--------+ +---------------+ | |--(A)- Authorization Request ->| Resource | | | | Owner | | |<-(B)-- Authorization Grant ---| | | | +---------------+ | | | | +---------------+ | |--(C)-- Authorization Grant -->| Authorization | | Client | | Server | | |<-(D)----- Access Token -------| | | | +---------------+ | | | | +---------------+ | |--(E)----- Access Token ------>| Resource | | | | Server | | |<-(F)--- Protected Resource ---| | +--------+ +---------------+ |
其中A、B表示第三方APP获得资源所有者的授权的过程,有多种变体,对应了OAuth的授权模式。
客户端必须得到用户的授权(Authorization Grant),才能获得令牌(Access Token)。OAuth 2.0定义了四种授权方式:
- 授权码模式(authorization code)
- 简化模式(implicit)
- 密码模式(resource owner password credentials)
- 客户端模式(client credentials)
该模式功能最完整、流程最严密,它让第三方应用的后台,与授权服务进行交互:
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 |
+----------+ | 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) Note: The lines illustrating steps (A), (B), and (C) are broken into two parts as they pass through the user-agent. Figure 3: Authorization Code Flow |
步骤如下:
- 用户访问第三方应用,后者将前者导向授权服务器,附带一个重定向URL(A)。导向授权服务器时,附带以下URL参数:
- response_type:表示授权类型,必须,此处的值固定为"code"
- client_id:表示客户端的ID,必须
- redirect_uri:表示重定向URI,可选
- scope:表示申请的权限范围,可选
- state:表示客户端的当前状态,可以指定任意值,授权服务器会原样返回
- 认证服务器会询问资源所有者(通常就是上条的用户,坐在浏览器面前),是否授予权限(B)
- 如果资源所有者点击允许,则认证服务器执行浏览器重定向,回到步骤A指定的URL,并且附带授权码(C)。授权服务器进行重定向时,在URL后附加参数:
- code:表示授权码,必须。授权码的有效期应该很短,例如10分钟,第三方应用只能使用该码一次,否则会被授权服务器拒绝。授权码和client_id + redirect_url的组合是一一对应的
- state:如果客户端的请求中包含这个参数,授权服务器原样返回
- 第三方应用的后台,使用授权码,向授权服务器申请访问令牌(D)。授权服务器的应答包含以下参数:
1234567{"access_token":"2YotnFZFEjr1zCsicMWpAA", // 令牌"token_type":"bearer", // 令牌类型,例如bearer、mac"expires_in":3600, // 令牌有效期,单位秒"refresh_token":"tGzv3JOkF0XG5Qx2TlKWIA", // 用于刷新令牌"scope":"..." // 授权范围,如果和申请时一致,可省略} - 授权服务器向第三方应用的后端发送访问令牌(E),此令牌第三方后端自己保存,不发给前端
该模式下,不需要经过第三方应用的后端,直接在浏览器中申请令牌,跳过授权码这个步骤。令牌对用户可见。
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 33 34 35 36 37 |
+----------+ | 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 | | | +---------+ Note: The lines illustrating steps (A) and (B) are broken into two parts as they pass through the user-agent. Figure 4: Implicit Grant Flow |
步骤如下:
- 第三方应用将用户导向授权服务器
- 用户决定是否给于第三方应用授权
- 如果用户给予授权,授权服务器将用户导向第三方应用指定的重定向URL,并在URL的Hash部分包含了访问令牌
- 浏览器向资源服务器发出请求,其中不包括上一步收到的Hash值
- 资源服务器返回一个脚本,其中包含的代码可以获取Hash值中的令牌
- 浏览器执行上一步获得的脚本,提取出令牌
- 浏览器将令牌传递给第三方应用客户的
在此模式下,用户向第三方应用提供自己的用户名和密码。第三方应用使用这些信息,向服务商提供商索要授权。
第三方应用以自己的身份,通过授权服务器,向服务提供商索取授权。
OpenID Connect目前已经成为单点登陆(single sign-on)、身份提供(identity provision)的领先标准。它的特点是:
- 简单的基于JSON的身份令牌(identity tokens) —— JWT,JWT简单而具有可移植性(自我验证),支持大量签名算法、加密算法
- 衍生自OAuth 2.0的工作流,对Web应用、基于Web的/原生的移动应用友好
OpenID Connect是2014年发布的标准,它不是IdP的第一个标准,但是却是最简单、好用的。
应用程序通常都需要识别(identify)其用户,简单的方式是,使用本地数据库,存储用户账户、凭证信息。这种方式的缺点是:
- 注册、登陆很繁琐,导致用户反感
- 具有很多应用的企业,维护多套用户信息很痛苦
解决方案就是引入独立的认证服务器,将身份验证(authentication)、用户创建(provisioning)的任务交给它统一处理。此认证服务器叫做Identity Provider(IdP)。
Github、Google、Wechat等互联网服务,都扮演IdP的身份。你的应用可以直接与之集成,有它们提供登陆功能。
除了用于单点登陆之外,ID Token还可以用于:
- 无状态会话:将ID Token放入Cookie,可以实现简单的轻量级stateless session,而不需要服务器端记录任何信息。服务器通过检查ID Token校验会话有效性
- 将身份信息传递给第三方应用
- Token exchange:在那些需要出示你的身份,以获得访问的场景。例如在酒店Check in的时候获取房间钥匙
代表一个身份,标准的JWT格式,被OpenID Provider(OP)签名。
为了获得ID Token,第三方应用必须将终端用户导向(OP)提供的界面,进行身份验证操作,类似OAuth那样。
ID Token中包含以下字段:
- 对用户身份的断言,用户身份在OpenID中叫做subject,也叫sub
- 颁发此Token的权威机构(就是OP),issuing authority,也叫iss
- 是否此Token仅限于特定应用(客户端)使用,audience,也叫aud
- 可以包含一个nonce
- 颁发时间iat、过期时间(exp)
- 可以包含请求的关于subject的额外信息,例如名字、电子邮件地址
- 签名信息
- 可以加密
这些字段,也叫statements / claims,打包在JSON中:
1 2 3 4 5 6 7 8 9 10 |
{ "sub" : "alice", "iss" : "https://openid.c2id.com", "aud" : "client-12345", "nonce" : "n-0S6_WzA2Mj", "auth_time" : 1311280969, "acr" : "c2id.loa.hisec", "iat" : 1311280970, "exp" : 1311281970 } |
ID Token头部 + claims JSON + 签名,被一起Base64编码,便于基于HTTP进行传递。
第三方应用(客户端,Relying Party,RP)要获取ID Token,必须通过IdP,在IdP那里,终端用户的会话/凭证被检查。为了将终端用户引导到IdP那里,需要一个可靠的User Agent,这个角色通常由浏览器扮演:
- 对于Web应用,可能是弹出对话框,在此对话框中重定向到IdP
- 对于Native应用,客户端可能需要启动系统浏览器,因为内嵌的Web view不被信任(防止Native应用嗅探密码)
OIDC标准没有指定用户身份以何种方式确认,这完全由IdP自由决定,最简单的方式是基于口令。
请求ID Token的流程,遵循OAuth 2.0协议。可以使用:
- 授权码模式:最常使用,兼容传统Web应用、原生应用、移动应用。最安全,因为浏览器/第三方客户端不会看到ID Token。流程概要:
- 浏览器到OP、从OP重定向回来,实现身份校验,得到授权码
- 第三方应用的后台,使用授权码再次请求OP,获得ID token
- 简单模式:用于纯粹的JS应用,没有后端服务的那种。ID Token直接从OP的重定向中返回给客户端
- 混合模式:很少使用,1+2组合
本节的后续内容,以授权码模式为例,阐述获取ID Token的完整流程。
本步骤的目的:
- 确认最终用户的身份
实现方式:前端请求,浏览器重定向,请求Authorisation Endpoint。获得授权码
首先,RP触发浏览器重定向,重定向到OP的Authorisation Endpoint:
1 2 3 4 5 6 7 8 9 10 11 12 |
HTTP/1.1 302 Found Location: https://openid.c2id.com/login? # code表示使用授权码模式 response_type=code # OAuth授权范围openid,表示请求进行OpenID身份验证,获取ID Token &scope=openid # RP在OP那里的身份标识 &client_id=s6BhdRkqt3 # 对于OP没有明确意义的(Qpaque)的字符串,用于维持请求/回调之间的关联性 &state=af0ifjsldkj # RP的回调地质 &redirect_uri=https%3A%2F%2Fclient.example.org%2Fcb |
在OP的页面上,通常会:
- 检查用户是否具有合法的session
- 如果没有,则进入登陆页面,提示用户登陆
- 登陆成功后,OP通常会询问用户,是否同意登陆到RP
随后,OP会调用/login时指定的redirect_uri,在URL中提供授权码。如果失败(登陆失败、不同意登陆到RP、回调地址不正确等)则附带一个错误码:
1 2 3 4 |
HTTP/1.1 302 Found Location: https://client.example.org/cb? code=SplxlOBeZQQYbYS6WxSbIA &state=af0ifjsldkj |
RP必须校验state,确保和之前的/login请求指定的一致。
本步骤的目的:
- 确认第三方应用(客户端)的身份,可选
- 获取ID Token
实现方式:后端请求,第三方应用的后端服务器直接访问OP。获得ID Token(+ OAuth 2.0访问令牌)。
上一步骤中,OP将授权码发给RP,发送的方式是浏览器重定向。这时候,RP前端会将授权码传给后端,由后端请求RP获得ID Token。这种做法的意义是:
- 获取ID Token之前,OP有机会校验RP的身份
- 后端获得ID Token后不会返回给前端(不可靠),不会造成敏感信息泄漏
RP后端将发起下面的请求:
1 2 3 4 5 6 7 8 9 10 11 12 |
POST /token HTTP/1.1 Host: openid.c2id.com Content-Type: application/x-www-form-urlencoded # RP的Client ID和Secret编码在此。注意OIDC也支持基于JWT校验RP身份 Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW # 授权模式 grant_type=authorization_code # 授权码 &code=SplxlOBeZQQYbYS6WxSbIA # 重定向地址,必须和第一步中的一致 &redirect_uri=https%3A%2F%2Fclient.example.org%2Fcb |
如果成功,OP将会返回一个JSON对象:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
HTTP/1.1 200 OK Content-Type: application/json Cache-Control: no-store Pragma: no-cache { # ID Token,它是Base64编码的JWT,包含claims、签名等信息 "id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjFlOWdkazcifQ.ewogImlzc yI6ICJodHRwOi8vc2VydmVyLmV4YW1wbGUuY29tIiwKICJzdWIiOiAiMjQ4Mjg5 NzYxMDAxIiwKICJhdWQiOiAiczZCaGRSa3F0MyIsCiAibm9uY2UiOiAibi0wUzZ fV3pBMk1qIiwKICJleHAiOiAxMzExMjgxOTcwLAogImlhdCI6IDEzMTEyODA5Nz AKfQ.ggW8hZ1EuVLuxNuuIJKX_V8a_OMXzR0EHR9R6jgdqrOOF4daGU96Sr_P6q Jp6IcmD3HP99Obi1PRs-cwh3LO-p146waJ8IhehcwL7F09JdijmBqkvPeB2T9CJ NqeGpe-gccMg4vfKjkM8FcGvnzZUN4_KSP0aAp1tOJ1zZwgjxqGByKHiOtX7Tpd QyHE5lcMiKPXfEIQILVq0pc_E2DzL7emopWoaoZTF_m0_N0YzFC6g6EJbOEoRoS K5hoDalrcvRYLSrQAZZKflyuVCyixEoV9GfNQC3_osjzw2PAithfubEEBLuVVk4 XUVrWOLrLl0nx7RkKU8NXNHq-rvKMzqg" # 不记名令牌,为了兼容OAuth 2.0协议。请求user profile data时可以用到 "access_token": "SlAV32hkKG", "token_type": "Bearer", "expires_in": 3600, } |
其中id_token字段是Base64编码的JWT,包含claims、签名等信息。RP必须进行必要的校验。
OIDC规范提供了一系列标准的Claims(也就是用户属性)。 Claims提供用户的各种详细信息,例如电子邮件、头像。客户端要获取Claims,有两种方式。
第一种方式是,在/login请求的scope中,根据Claims所属的scope来获取。例如:
1 2 3 4 5 6 7 8 |
# 设置scope为openid email,获取ID Token + 电子邮件 HTTP/1.1 302 Found Location: https://openid.c2id.com/login? response_type=code &scope=openid%20email &client_id=s6BhdRkqt3 &state=af0ifjsldkj &redirect_uri=https%3A%2F%2Fclient.example.org%2Fcb |
所有Scope和Claims对应关系如下表:
Scope | 关联的Claims |
email, email_verified | |
phone | phone_number, phone_number_verified |
profile | name, family_name, given_name, middle_name, nickname, preferred_username, profile, picture, website, gender, birthdate, zoneinfo, locale, updated_at |
address | address |
第二种方式是,单独使用claims参数。
dexidp/dex是一个开源项目,它是OIDC Provider、OAuth 2.0 Provider。支持可拔插的Connector。
作为身份验证服务,dex实现了框架部分,并且可以通过Connector适配到其它identity providers,这样,用户密码等信息就可以由LDAP服务器、SAML provider、AD之类的服务提供。
在前文中,我们了解到ID Tokens是OIDC对OAuth 2的扩展。ID Tokens是OAuth 2响应的一部分。它的格式是JWT,dex负责签名JWT,ID Tokens包含了标准的claims。
包括Kubernetes在内的第三方系统,都可以使用dex产生的ID Tokens。
dex在生产环境中的一个应用是,作为CoreOS的K8S解决方案,Tectonic的Authn加载项。
当用户通过dex进行登陆时,用户的身份信息通常存放在第三方用户管理系统中,例如LDAP。Dex作为客户端和第三方用户管理系统之间的垫片,其价值是,客户端仅仅需要理解OIDC,后端用户管理系统可以随时切换。
Connector是Dex联系第三方系统进行用户身份验证的策略接口,取决于这些第三方系统的特性,OIDC的某些功能可能无法支持。
1 2 3 |
go get github.com/dexidp/dex cd $GOPATH/src/github.com/dexidp/dex make |
Dex从一个配置文件中提取所有配置信息,阳历配置如下:
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 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 |
# Dex的base url,也是OIDC服务的外部名称,所有客户端必须基于此URL访问Dex # 如果提供了path部分,则dex的HTTP服务在non-root URL上监听 issuer: http://127.0.0.1:5556/dex # Dex如何存储它的状态 storage: type: sqlite3 config: file: examples/dex.db # 基于MySQL的例子 # type: mysql # config: # host: localhost # port: 3306 # database: dex # user: mysql # password: mysql # ssl: # mode: "false" # 基于PostgreSQL的例子 # type: postgres # config: # host: localhost # port: 5432 # database: dex # user: postgres # password: postgres # ssl: # mode: disable # 基于Etcd的例子 # config: # endpoints: # - http://localhost:2379 # namespace: dex/ # 基于K8S的例子 # config: # kubeConfigFile: $HOME/.kube/config # HTTP端点配置 web: http: 0.0.0.0:5556 # 可选的HTTPS配置 # https: 127.0.0.1:5554 # tlsCert: /etc/dex/tls.crt # tlsKey: /etc/dex/tls.key # 遥测配置 telemetry: http: 0.0.0.0:5558 # 启用gRPC API,地址必须和HTTP不同 # grpc: # addr: 127.0.0.1:5557 # tlsCert: examples/grpc-client/server.crt # tlsKey: examples/grpc-client/server.key # tlsClientCA: /etc/dex/client.crt # 密码、Token超时配置 # expiry: # signingKeys: "6h" # idTokens: "24h" # 日志配置 # logger: # level: "debug" # format: "text" # can also be "json" # oauth2: # 时用 ["code", "token", "id_token"] 可以为web-only客户端启用简化模式 # responseTypes: [ "code" ] # also allowed are "token" and "id_token" # 默认情况下,Dex会询问用户,是否允许和应用程序共享数据 # skipApprovalScreen: false # 如果仅启用一种身份验证方式,默认行为是直接进入 # 对于连接的IdP来说,浏览器会直接从应用程序条砖到上游IdP页面 # alwaysShowLoginScreen: false # Uncommend the passwordConnector to use a specific connector for password grants # passwordConnector: local # 静态客户端,不需要从外部存储读取 staticClients: - id: example-app redirectURIs: - 'http://127.0.0.1:5555/callback' name: 'Example App' secret: ZXhhbXBsZS1hcHAtc2VjcmV0 # 上游IdP配置 connectors: - type: mockCallback id: mock name: Example # Google配置 # - type: google # id: google # name: Google # config: # issuer: https://accounts.google.com # # Connector config values starting with a "$" will read from the environment. # clientID: $GOOGLE_CLIENT_ID # clientSecret: $GOOGLE_CLIENT_SECRET # redirectURI: http://127.0.0.1:5556/dex/callback # hostedDomains: # - $GOOGLE_HOSTED_DOMAIN # Let dex keep a list of passwords which can be used to login to dex. enablePasswordDB: true # 用于登陆的静态最终用户信息 # A static list of passwords to login the end user. By identifying here, dex # won't look in its underlying storage for passwords. # # If this option isn't chosen users may be added through the gRPC API. staticPasswords: - email: "admin@example.com" # bcrypt hash of the string "password" hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W" username: "admin" userID: "08a8684b-db88-4b73-90a9-3cd1661f5466" |
执行下面的命令启动Dex服务:
1 |
./bin/dex serve examples/config-dev.yaml |
Dex的工作方式类似于其他OAuth 2 Provider,用户被从客户端应用重定向到Dex,进行登陆。
Dex提供了一个示例客户端: ./bin/example-app ,这是一个Web应用,你可以打开浏览器:
- 打开http://localhost:5555/,进入此Web应用
- 点击登陆按钮,重定向到Dex
- 选择Login with Email,输入admin@example.com" 和 "password"进行登陆
- 允许Web应用的登陆请求
- 你可以在Web应用页面看到Token
应用程序和Dex的交互,可以分为两类:
- 请求ODIC的ID Tokens,来认证最终用户的身份,必须基于Web进行
- 从其它应用消费ID Token,需要验证客户端是代表用户进行操作
客户端应该在初始化阶段,创建provider等对象:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
// 携带了Http客户端的Context // 这是一个struct{}实例 // context.WithValue(ctx, oauth2.HTTPClient, client) ctx := oidc.ClientContext(context.Background(), a.client) // 使用Context中携带的客户端请求dex // ctx.Value(oauth2.HTTPClient).(*http.Client) provider, err := oidc.NewProvider(ctx, issuerURL) // 查询dex支持的Scope(Claim逻辑组) var s struct { ScopesSupported []string `json:"scopes_supported"` } if err := provider.Claims(&s); err != nil { return fmt.Errorf("failed to parse provider scopes_supported: %v", err) } // 创建ID Token验证器 a.verifier = provider.Verifier(&oidc.Config{ClientID: a.clientID}) |
这类用法中,应用是标准的OAuth2客户端。用户打开应用页面,应用期望知道用户的身份,做法就是导向Dex进行身份校验,然后检查得到的ID Token中的claims。
第一步,你需要将应用注册到Dex,最简单的方法是使用staticClients配置:
1 2 3 4 5 6 |
staticClients: - id: example-app secret: example-app-secret name: 'Example App' redirectURIs: - 'http://127.0.0.1:5555/callback' |
在登陆时,应用需要拼接合理的URL,重定向到Dex:
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 33 34 35 36 37 |
func (a *app) handleLogin(w http.ResponseWriter, r *http.Request) { var scopes []string if extraScopes := r.FormValue("extra_scopes"); extraScopes != "" { scopes = strings.Split(extraScopes, " ") } var clients []string if crossClients := r.FormValue("cross_client"); crossClients != "" { clients = strings.Split(crossClients, " ") } for _, client := range clients { scopes = append(scopes, "audience:server:client_id:"+client) } connectorID := "" if id := r.FormValue("connector_id"); id != "" { connectorID = id } authCodeURL := "" scopes = append(scopes, "openid", "profile", "email") if r.FormValue("offline_access") != "yes" { authCodeURL = a.oauth2Config(scopes).AuthCodeURL(exampleAppState) } else if a.offlineAsScope { scopes = append(scopes, "offline_access") authCodeURL = a.oauth2Config(scopes).AuthCodeURL(exampleAppState) } else { authCodeURL = a.oauth2Config(scopes).AuthCodeURL(exampleAppState, oauth2.AccessTypeOffline) } if connectorID != "" { authCodeURL = authCodeURL + "&connector_id=" + connectorID } // 身份校验端点 // http://127.0.0.1:5556/dex/auth?client_id=example-app&redirect_uri= // http%3A%2F%2F127.0.0.1%3A5555%2Fcallback&response_type=code& // scope=audience%3Aserver%3Aclient_id%3Aexample-app+openid+profile+email+ // offline_access&state=I+wish+to+wash+my+irish+wristwatch http.Redirect(w, r, authCodeURL, http.StatusSeeOther) } |
用户在Dex页面执行操作后, 应用需要处理Dex发回的回调:
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 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 |
func (a *app) oauth2Config(scopes []string) *oauth2.Config { return &oauth2.Config{ ClientID: a.clientID, ClientSecret: a.clientSecret, Endpoint: a.provider.Endpoint(), Scopes: scopes, RedirectURL: a.redirectURI, } } func (a *app) handleCallback(w http.ResponseWriter, r *http.Request) { var ( err error token *oauth2.Token ) ctx := oidc.ClientContext(r.Context(), a.client) oauth2Config := a.oauth2Config(nil) switch r.Method { case http.MethodGet: // 这个分支意味着OAuth2授权工作流的回调 if errMsg := r.FormValue("error"); errMsg != "" { http.Error(w, errMsg+": "+r.FormValue("error_description"), http.StatusBadRequest) return } // 授权码在回调的URL参数中传递 code := r.FormValue("code") if code == "" { http.Error(w, fmt.Sprintf("no code in request: %q", r.Form), http.StatusBadRequest) return } // 验证state和当初请求登陆时是否一样 if state := r.FormValue("state"); state != exampleAppState { http.Error(w, fmt.Sprintf("expected state %q got %q", exampleAppState, state), http.StatusBadRequest) return } // 刷新Token token, err = oauth2Config.Exchange(ctx, code) case http.MethodPost: // 这个分支意味着用户请求了刷新Token操作 refresh := r.FormValue("refresh_token") if refresh == "" { http.Error(w, fmt.Sprintf("no refresh_token in request: %q", r.Form), http.StatusBadRequest) return } t := &oauth2.Token{ RefreshToken: refresh, Expiry: time.Now().Add(-time.Hour), } // 使用授权码,再次请求Dex,得到oauth2.Token token, err = oauth2Config.TokenSource(ctx, t).Token() default: http.Error(w, fmt.Sprintf("method not implemented: %s", r.Method), http.StatusBadRequest) return } if err != nil { http.Error(w, fmt.Sprintf("failed to get token: %v", err), http.StatusInternalServerError) return } // ID Token在OAuth2响应的额外字段中 rawIDToken, ok := token.Extra("id_token").(string) if !ok { http.Error(w, "no id_token in token response", http.StatusInternalServerError) return } // 对ID Token进行签名校验 idToken, err := a.verifier.Verify(r.Context(), rawIDToken) if err != nil { http.Error(w, fmt.Sprintf("failed to verify ID token: %v", err), http.StatusInternalServerError) return } accessToken, ok := token.Extra("access_token").(string) if !ok { http.Error(w, "no access_token in token response", http.StatusInternalServerError) return } // 获取JWT中的claims信息 var claims json.RawMessage if err := idToken.Claims(&claims); err != nil { http.Error(w, fmt.Sprintf("error decoding ID token claims: %v", err), http.StatusInternalServerError) return } buff := new(bytes.Buffer) if err := json.Indent(buff, []byte(claims), "", " "); err != nil { http.Error(w, fmt.Sprintf("error indenting ID token claims: %v", err), http.StatusInternalServerError) return } renderToken(w, a.redirectURI, rawIDToken, accessToken, token.RefreshToken, buff.String()) } |
这类用法中,应用将ID Token作为凭证使用。一个第三方的服务负责处理OAuth流程, 典型的例子是K8S的OpenID Connect Token支持。你可以将ID Token作为K8S API请求的bearer token使用,K8S负责通过数字证书校验此Token的合法性。
消费ID Token,就是去校验它的有效性。你需要创建一个校验器:
1 2 3 4 5 6 |
provider, err := oidc.NewProvider(ctx, "https://dex-issuer-url.com") if err != nil { // handle error } // 构建ID Token校验器,仅仅信任颁发给example-app这个客户端的Token idTokenVerifier := provider.Verifier(&oidc.Config{ClientID: "example-app"}) |
然后,校验ID Token,并从中读取可信任的claims:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
type user struct { email string groups []string } func authorize(ctx context.Context, bearerToken string) (*user, error) { idToken, err := idTokenVerifier.Verify(ctx, bearerToken) if err != nil { return nil, fmt.Errorf("could not verify bearer token: %v", err) } var claims struct { Email string `json:"email"` Verified bool `json:"email_verified"` Groups []string `json:"groups"` } // 校验通过,读取claims if err := idToken.Claims(&claims); err != nil { return nil, fmt.Errorf("failed to parse claims: %v", err) } if !claims.Verified { return nil, fmt.Errorf("email (%q) in returned claims was not verified", claims.Email) } return &user{claims.Email, claims.Groups}, nil } |
Scope | 说明 |
openid | 对于所有登陆请求必须 |
email email_verified |
Dex扩展,非标准 ID Token claims应当包含终端用户的电子邮件信息、电子邮件是否被上游Provider验证过 |
profile | ID Token claims应当包含用户名 |
groups |
Dex扩展,非标准 ID Token claims应当包含用户所属的组 |
federated:id |
Dex扩展,非标准 ID token claims应当包含来自真实IdP的信息,包括connector ID、在IdP中用户的ID |
name |
Dex扩展,非标准 用户的显示名称 |
offline_access | Token响应中,应该包含一个刷新Token。对于某些Connector是不支持的,例如SAML,会忽略此scope |
audience:server:client_id:( client-id ) | 动态的Scope,提示Dex,当前验证请求,是代表其它客户端(不是当前客户端)来获取ID Token |
Dex支持向这样的客户端颁发ID Token:该客户是代表另外一个客户端来请求Token的。
在OIDC的术语中,这种情况意味着ID Token的aud(audience) claim,不是发起请求的那个客户端的client ID。
利用这个特性,一个Web应用可以代表一个命令行工具,来申请ID Token:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
staticClients: # Web应用 - id: web-app redirectURIs: - 'https://web-app.example.com/callback' name: 'Web app' secret: web-app-secret # 命令行应用 - id: cli-app redirectURIs: - 'https://cli-app.example.com/callback' name: 'Command line tool' secret: cli-app-secret # 表示命令行应用信任Web应用,允许后者代表自己去申请ID Token trustedPeers: - web-app |
Web应用发起/auth请求时,可以设置下面的Scope,表示自己在代表命令行应用: audience:server:client_id:cli-app
Dex返回的ID Token中,会包含如下字段:
1 2 3 4 5 6 7 |
{ // 此Token为哪个客户端(应用)颁发 "aud": "cli-app", // 请求此Token的是谁 "azp": "web-app", "email": "foo@bar.com" } |
可以将客户端标记为公共的:
1 2 3 4 5 |
staticClients: - id: cli-app public: true name: 'CLI app' secret: cli-app-secret |
则给应用强加了一个限制:不要尝试将Client Secret作为私有的。
对于公共客户端,它发起请求时指定的回调URL,必须以http://localhost开头,或者指定为一个特殊的out-of-browser地址 urn:ietf:wg:oauth:2.0:oob 。
out-of-browser会导致dex在浏览器中显示OAuth2 code,提示终端用户手工拷贝给其它APP。
Dex需要存储一些状态信息,以支持:跟踪刷新Token、防止replay、进行key轮换。
1 2 3 4 5 6 7 |
storage: type: etcd config: endpoints: - http://localhost:2379 # 前缀 namespace: my-etcd-namespace/ |
如果在K8S中运行Dex,考虑这种方式。Dex会创建若干类型的CRD,并在CR中存储状态。配置:
1 2 3 4 |
storage: type: kubernetes config: inCluster: true |
Leave a Reply