[安全]使用 Keycloak 替代 AWS Cognito UserPool 方案

[安全]使用 Keycloak 替代 AWS Cognito UserPool 方案

背景

AWS Cognito User Pool是AWS做为身份池的托管服务,在许多AWS Solutions或是用户创建的WEB/移动端应用都被广泛的使用。通过该服务可以快速建立一个身份池并与AWS的其他服务集成,并用来做身份认证和授权。一个常用的场景是API Gateway可以集成Cognito User Pool进行用户身份认证与授权,方便对API进行保护。另一个常用的场景是结合Cognito Identity Pool来在Web或移动端安全地获取AWS临时密钥,进而访问其他AWS服务。

解决方案概览

2014 年,AWS IAM 使用 OpenID Connect (OIDC) 增加了对联合身份的支持。此功能允许您通过支持的身份提供商对 AWS API 调用进行身份验证,并获得有效的 OIDC Web 令牌 (JWT)。您可以将此令牌传递到 AWS STS AssumeRoleWithWebIdentity API 操作并接收 IAM 临时角色凭证。您可以使用这些凭证与任意 AWS 服务交互,例如 Amazon S3 和 DynamoDB。

Keycloak是一个开放源代码身份和访问管理解决方案。 它可以使用内置的数据库,也可以使用传统的关系型数据库例如 MySQL来存储用户登录信息。 它可以帮助在用户在应用程序之上构建安全层,并且可以同 AWS 的 Cognito Identiy Pool 相集成。

本解决方案基于 Keycloak 的容器镜像文件,结合 AWS 的 ECS,Fargate 及 RDS for MySQL 等服务,提供一个高可用的架构的自动化部署方式。

功能

此解决方案提供了在AWS 云上构建高可用架构的Keycloak集群。Keycloak 是一款开箱即用的开源身份及访问控制软件,提供了单点登录(SSO)功能,支持OpenID Connect、OAuth 2.0、SAML 2.0标准协议。Keycloak 提供可自定义的用户界面,用于登录,注册,管理和帐户管理。此外,用户可以将Keycloak 与Amazon Cognito或与其他现有的 LDAP 和 Microsoft Active Directory服务器进行集成。用户还可以将身份验证委派给第三方身份提供商。

挑战

  • 目前Cognito User Pool服务在国内区域暂时未上线,这对将国外的AWS Solutions或用户应用迁移至国内区域部署造成困难。需要找到找到一个基于 OpenID Connect 协议的开源软件来替代Cognito User Pool的认证服务功能。
  • 另外虽然AWS 的合作伙伴也有类似的解决方案,例如 authing.cn 的方案,但使用合作伙伴的 Saas 服务时用户数据是存储在第三方。不管是从合规的角度或是对于安全性有特殊要求的用户,还是希望类似用户名及密码数据存储在自己的 AWS 账户内
  • 用户希望能找到一个方案以支持OpenID Connect或SAML 2.0等开放协议标准,并且需要能灵活地结合 AWS 的 cognito identity pool 进行联合的身份验证。

期望收益

  • 补充 AWS China Cognito user pool 服务的没有上线所带来功能缺失。
  • 用户如果不想使用第三方合作伙伴的服务而想使用开源的软件来拥有用户认证的功能,并希望能灵活地结合 AWS 的 cognito identity pool 进行联合的身份验证。

架构决定

为什么选择 Keycloak

备选 OpenID 开源软件

OpenID开源备选软件列表可参考 https://openid.net/developers/certified/

KeyCloak 所能提供的功能:

  • 支持浏览器应用程序的单点登录和单点退出。
  • OpenID Connect支持。
  • OAuth 2.0支持。
  • SAML支持。
  • 身份代理-通过外部OpenID Connect或SAML身份提供程序进行身份验证。
  • 社交登录-启用通过Google,GitHub,Facebook,Twitter和其他社交网络的登录。
  • 用户联合-同步LDAP和Active Directory服务器中的用户。
  • Kerberos Bridge-自动验证登录到Kerberos服务器的用户。
  • 管理控制台,用于集中管理用户,角色,角色映射,客户端和配置。
  • 帐户管理控制台,允许用户集中管理其帐户。
  • 主题支持-自定义所有面向用户的页面以与您的应用程序和品牌集成。
  • 双因素身份验证-通过Google Authenticator或FreeOTP支持TOTP / HOTP。
  • 登录流程-可选的用户自我注册,恢复密码,验证电子邮件,要求密码更新等。
  • 会话管理-管理员和用户本身可以查看和管理用户会话。
  • Token mapper-将用户属性,角色等映射到令牌和语句的方式。
  • CORS支持-客户端适配器具有对CORS的内置支持。
  • 服务提供商接口(SPI)-用于自定义服务器各个方面的许多SPI。身份验证流,用户联合提供程序,协议映射器等等。
  • 适用于JavaScript应用程序,WildFly,JBoss EAP,Fuse,Tomcat,Jetty,Spring等的客户端适配器。
  • 支持任何具有OpenID Connect依赖方库或SAML 2.0服务提供商库的平台/语言。

KeyCloak 优点包括:

  • Keycloak是针对Web应用程序和RESTful Web服务的单一登录解决方案。 Keycloak的目标是简化安全性,以便应用程序开发人员可以轻松保护已部署在组织中的应用程序和服务。
  • 开箱即用地提供了开发人员通常必须为自己编写的安全功能,可以轻松地针对组织的个性化需求进行定制。
  • Keycloak提供可自定义的用户界面,用于登录,注册,管理和帐户管理。
  • 您还可以将Keycloak用作集成平台,以将其挂接到现有的LDAP和Active Directory服务器中。 您还可以将身份验证委派给第三方身份提供商,例如Facebook和Google+。
  • Keycloak使用OpenID Connect或SAML 2.0等开放协议标准来保护您的应用程序。

License 类型

KeyCloak使用 Apache License 2.0 参考: https://github.com/keycloak/keycloak/blob/master/LICENSE.txt

GitHub Star

截止到 2020-05-09, KeyCloak 在 Github 上的 Star 为 6.1K

方便同 AWS 现有服务集成

应用程序用户可以通过用户池直接登录,也可以通过第三方身份提供商 (IdP) 联合。用户池管理从通过 Facebook、Google、Amazon 和 Apple 进行的社交登录返回的以及从 OpenID Connect (OIDC) 和 SAML IdP 返回的令牌的处理开销。 利用内置托管 Web UI,Amazon Cognito 将为来自所有身份提供商的经过身份验证的用户提供令牌处理和管理,让后端系统能够基于一组用户池令牌实现标准化。

综合以上因素,决定采用 Keycloak。

系统架构图

组件

ECS ECR ACM IAM Route53

KeyCloak 介绍

Keycloak是针对Web应用程序和RESTful Web服务的单一登录解决方案。 Keycloak的目标是简化安全性,以便应用程序开发人员可以轻松保护已部署在组织中的应用程序和服务。 开箱即用地提供了开发人员通常必须为自己编写的安全功能,可以轻松地针对组织的个性化需求进行定制。 Keycloak提供可自定义的用户界面,用于登录,注册,管理和帐户管理。 您还可以将Keycloak用作集成平台,以将其挂接到现有的LDAP和Active Directory服务器中。 您还可以将身份验证委派给第三方身份提供商,例如Facebook和Google+。

Keycloak使用OpenID Connect或SAML 2.0等开放协议标准来保护您的应用程序。 浏览器应用程序将用户的浏览器从应用程序重定向到Keycloak身份验证服务器,并在其中输入其凭据。 这很重要,因为用户与应用程序完全隔离,并且应用程序永远看不到用户的凭据。 相反,为应用程序提供了经过密码签名的身份令牌或断言。 这些令牌可以具有身份信息,例如用户名,地址,电子邮件和其他配置文件数据。 他们还可以保存权限数据,以便应用程序可以做出授权决策。 这些令牌还可以用于对基于REST的服务进行安全调用。

Keycloak是为现代应用和服务提供了开源IAM(Identity and Access Management)解决方案,其基于标准协议,并提供对OpenID Connect,OAuth 2.0和SAML的支持。

Single-Sign On 登录功能

通过Keycloak处理用户认证,意味着你的应用不需要处理登录界面,认证用户,存储用户信息。一旦登录Keycloak, 用户不需要再次登录Keycloak管理下的其它应用。实现一次登录,多处登录不同应用,一处登出,所有应用登出。

提供 Identity Broker and Social Login

Keycloak通过配置,可实现对不同身份认证服务的集成,通过这些身份认证服务登录应用。

提供联合认证

在企业系统中有使用LDAP/AD管理用户,同样 Keycloak 提供了对LDAP/AD的集成方案,可以方便的同步用户。

Key Cloak 概念

  1. Realm:Releam保护和管理一组用户,应用程序和已注册的身份验证客户端的安全元数据。
  2. Client: Client 是可以请求领域内用户认证的实体。
  3. Role:Role标识用户的类型或类别。 Keycloak通常为特定角色而不是单个用户分配访问权限和权限,以实现细粒度的访问控制。

Keycloak 角色类型

  1. Realm Level Role:位于所有客户端共享的全局命名空间中。
  2. Client Level Role:基本上具有专用于客户端的名称空间。
  3. Composite Level Role:是具有一个或多个其他角色的角色。

架构决策

keycloak on AWS solution 使用keycloak docker容器,在AWS上以ECS Fargate的方式进行部署,

为什么使用fargate来部署keycloak

Keycloak作为基础的身份认证服务,势必要部署多个实例组成集群以保证高可用性, 而容器技术可以在failover后实现秒级启动,借助firecracker技术,fargate中的keycloak 容器启动时间可以缩短至毫秒级别。借助Fargate服务,您的用户部署此方案时不必管理 Amazon EC2 实例的服务器或集群,无需预置和管理服务器,可避免扩展、修补、保护和管理服务器的运营开销,这样就无需再选择服务器类型、确定扩展集群的时间和优化集群打包 ,Fargate 可确保运行您容器的基础设施始终通过所需的补丁保持最新状态。Fargate 也可以缩放计算资源,以密切符合计算资源的需求,无需超额预置并为额外的服务器付费。

为什么使用autoscaling

在Fargate中,会同时启动两个以keycloak docker image为镜像的task。当两个Fargate task同时启动时,keycloak docker image会首先进行初始化过程。 在初始化的过程中会对相连的数据库进行初始化操作,其中包括创建相应的表等操作,如果两个task同时启动则会陷入相互竞争的局面,导致两个task的初始化过程纷纷报错而失败,所以需要有一个单独的初始化过程,待初始化过程结束再进行生产环境高可用部署。 所以我们选择使用autoscling group 中启动ec2来执行单独的初始化工作,待初始化工作完成后可以将autoscling group内的机器数量改为0,即可不再占用任何计算资源,顺利过渡到生产环境的高可用task部署阶段。

使用场景

当您的用户使用 OIDC IdP 登录您的应用程序时,具体的步骤如下:

  1. 您的用户将登录Keycloak内置登录页面,并获得通过 OIDC IdP 登录的选项。
  2. 您的用户将重定向到 OIDC IdP 的 authorization 终端节点。
  3. 在您的用户经过身份验证后,OIDC IdP 将使用授权代码重定向至 Keycloak。
  4. Keycloak 将与 OIDC IdP 交换此授权代码以获得访问令牌。
  5. Keycloak在您的用户池中创建或更新用户账户。
  6. Keycloak 颁发应用程序持有者令牌,可能包括身份、访问和刷新令牌。

OIDC 是基于 OAuth 2.0 的身份层,它指定 IdP 向 OIDC 客户端应用程序 (信赖方) 颁发的 JSON 格式的 (JWT) 身份令牌。有关将 Amazon Cognito 添加为 OIDC 信赖方的信息,请参阅您的 OIDC IdP 的文档。 当用户进行身份验证后,用户池将返回 ID Token,Access Token和 Refesh Token。ID Token是用于身份管理的标准 OIDC 令牌,而Access Token是标准 OAuth 2.0 令牌。

OAuth2.0 授权模式

  • 授权码模式(authorization-code)
  • 隐藏式(implicit)
  • 密码式(password)
  • 客户端凭证(clientcredentials)

不管哪一种授权方式,第三方应用申请令牌之前,都必须先到OpenID备案,说明自己的身份,然后会拿到两个身份识别码:客户端 ID(client ID)和客户端密钥(client secret)为了防止令牌被滥用,没有备案过的第三方应用,是不会拿到令牌的。 本指南以授权码模式进行示例。这种方式是最常用的流程,安全性也最高,它适用于那些有后端的 Web 应用。 根据 ID Token 得到 AWS Credential

第一步:授权码

第二步:跳转

第三步:请求令牌

第四步:返回令牌

至此,应用同 Keycloak 的集成已经完成。但有时我们需要 Keycloak 同 cognito identity pool, STS 结合。希望已经通过 Keycloak 认证的用户能都得到访问密钥(访问密钥 ID - AccesskeyID 及 秘密访问密钥 SecretKey),详情见 https://docs.aws.amazon.com/zh_cn/general/latest/gr/aws-sec-cred-types.html#access-keys-and-secret-access-keys 通过访问密钥,就可以访问相关的 AWS服务。 关于如何得到访问密钥,请见第五六步。

第五步:得到 IdentityID

第六步:得到 accesskeyID/secretKey

KeyCloak 安装(基于 EC2 Ubuntu)废弃

说明:使用这种方式安装,其实是使用 Keycloak 内置的 小型数据库。在生成环境中,还是建议使用 RDS 数据库。

 1~~# Connect to the EC2 instance
 2ssh -i "keypair.pem" ubuntu@ec2-xx-xxx-xxx-xxx.region-x.compute.amazonaws.com
 3# Install Java
 4sudo apt update
 5sudo apt install default-jre
 6java -version
 7# Download and extract Keycloak
 8# https://www.keycloak.org/downloads.html
 9wget https://downloads.jboss.org/keycloak/10.0.1/keycloak-10.0.1.zip
10tar -zxvf keycloak-10.0.1.tar.gz
11cd keycloak-10.0.1/
12# Create the admin user
13./bin/add-user-keycloak.sh -r master -u <username> -p <password>
14# Find the Private IP of your instance in the EC2 Console, then
15# start the Keycloak server
16./bin/standalone.sh -b XXX.XX.XX.XXX (私网 IP~~~~)~~

KeyCloak PKCE (Proof Key for Code Exchange)

从KeyCloak 7.0开始支持 PKCE 的功能。PKCE将授权流程中使用的静态秘钥替换为临时的一次性挑战密码,使其可以在非安全的公共端中使用。 详情请参考: https://www.janua.fr/pkce-support-with-keycloak-7-0/

如何在 KeyCloak 开启 PKCE

如何使用 PKCE?

 1resp = requests.post(
 2    url=openid_provider + "/protocol/openid-connect/token",
 3    data={
 4        "client_secret":client_secret,
 5        "grant_type": "authorization_code",
 6        "client_id": client_id,
 7        "redirect_uri": redirect_uri,
 8        "code": auth_code,
 9        "code_verifier": code_verifier,
10    },
11    allow_redirects=False
12)
13result = resp.json()
14*# print(json.dumps(result,indent=2))*
15access_token=result['access_token']
16print ("Access Token: %s\n" %access_token)
17jwt=jwt_payload_decode(result['access_token'])
18print("JWT Token Decode:")
19print(json.dumps(jwt,indent=2))
20id_token=result['id_token']
21print("\nID Token: %s\n" %id_token)

Keycloak 同 Nginx 集成

https://kuaibao.qq.com/s/20190908AZOW2I00?refer=spider

Keycloak 同 K8S 集成

https://developer.ibm.com/zh/articles/cl-lo-openid-connect-kubernetes-authentication2/

使用代码测试 keycloak 并获得 accesskeyid 和 secretkey

您可以利用下面的python脚本测试上述此解决方案的功能。运行此脚本,需要您的环境中预装python。

脚本参数

您需要将下列参数的具体值填入Python代码中。

 1
 2openid_provider = "https://keycloak.ch.test.com/auth/realms/iot" #需要替换为在 keycloak中创建的 realm 名称
 3client_id = "iotclient" #需要替换为在 keycloak realm 中创建的client 名称
 4username = "test1" #需要替换为在 keycloak realm 中创建的测试用户
 5password = "test1" #需要替换为在 keycloak realm 中创建的测试用户密码
 6redirect_uri = "http://127.0.0.1/home/login" #需要替换为在 keycloak realm 中创建的client 中 Valid Redirect URI
 7client_secret="c2669e72-d858-472c-8bda-xxxxxf" #获得 Keycloak realm 中 Client 的 Secret
 8identity_pool_id="cn-north-1:xxxxx-8c7e-0d81205xx" #获得 AWS Cognito Identity Pool Id
 9default_region_name="cn-north-1" 
10iam_openid_provider_name="keycloak.ch.test.com/auth/realms/iot" #需要替换为在 keycloak中创建的 realm 名称,注意该字符串不包含 'https://' 字符

Python 代码

  1# coding=utf-8
  2import boto3
  3import base64
  4import hashlib
  5import html
  6import json
  7import os
  8import re
  9import urllib.parse
 10import requests
 11
 12def _b64_decode(data):
 13    data += '=' * (4 - len(data) % 4)
 14    return base64.b64decode(data).decode('utf-8')
 15def jwt_payload_decode(jwt):
 16    _, payload, _ = jwt.split('.')
 17    return json.loads(_b64_decode(payload))
 18    
 19openid_provider = "https://keycloak.ch.test.com/auth/realms/iot" #需要替换为在 keycloak中创建的 releam 名称
 20client_id = "iotclient" #需要替换为在 keycloak releam 中创建的client 名称
 21username = "test1" #需要替换为在 keycloak releam 中创建的测试用户
 22password = "test1" #需要替换为在 keycloak releam 中创建的测试用户密码
 23redirect_uri = "http://127.0.0.1/home/login" #需要替换为在 keycloak releam 中创建的client 中 Valid Redirect URI
 24client_secret="c2669e72-d858-472c-8bda-f9xxxxxx" #获得 Keycloak Releam 中 Client 的 Secret
 25identity_pool_id="cn-north-1:xxxxx-8c7e-0d81xxxxxa" #获得 AWS Cognito identity pool id
 26default_region_name="cn-north-1" 
 27iam_openid_provider_name="keycloak.ch.test.com/auth/realms/iot" #需要替换为在 keycloak中创建的 releam 名称,注意该字符串不包含 'https://' 字符
 28
 29#Why use PKCE in keycloak
 30'''
 31From: 
 32PKCE support with Keycloak 7.0: Keycloak 7.0 has been released on Aug 25th 2019 with PKCE support. This represents a major breakthrough for all mobile apps to increase security and to mitigate malicious attacks
 33Public client security vulnerability
 34OAuth 2.0 [RFC6749] public clients are susceptible to the authorization code interception attack.
 35In this attack, the attacker intercepts the authorization code returned from the authorization endpoint within a communication path not protected by Transport Layer Security (TLS), such as interapplication communication within the client’s operating system.
 36Once the attacker has gained access to the authorization code, it can use it to obtain the access token.
 37'''
 38#generate code_verifier
 39code_verifier = base64.urlsafe_b64encode(os.urandom(40)).decode('utf-8')
 40code_verifier = re.sub('[^a-zA-Z0-9]+', '', code_verifier)
 41print("==================")
 42print("code_verifier: %s" %code_verifier)
 43#generate code_challenge
 44code_challenge = hashlib.sha256(code_verifier.encode('utf-8')).digest()
 45code_challenge = base64.urlsafe_b64encode(code_challenge).decode('utf-8')
 46code_challenge = code_challenge.replace('=', '')
 47print("==================")
 48print("code_challenge: %s\n" %code_challenge)
 49state = "fooobarbaz"
 50resp = requests.get(
 51    url=openid_provider + "/protocol/openid-connect/auth",
 52    params={
 53        "response_type": "code",
 54        "client_id": client_id,
 55        "scope": "openid",
 56        "redirect_uri": redirect_uri,
 57        "state": state,
 58        "code_challenge": code_challenge,
 59        "code_challenge_method": "S256",
 60    },
 61    allow_redirects=False
 62)
 63
 64
 65cookie = resp.headers['Set-Cookie']
 66cookie = '; '.join(c.split(';')[0] for c in cookie.split(', '))
 67print("==================")
 68print ("cookie: %s \n"%cookie)
 69page = resp.text
 70form_action = html.unescape(re.search('<form\s+.*?\s+action="(.*?)"', page, re.DOTALL).group(1))
 71print("<form action> : %s\n"%form_action)
 72
 73resp = requests.post(
 74    url=form_action, 
 75    data={
 76        "username": username,
 77        "password": password,
 78    }, 
 79    headers={"Cookie": cookie},
 80    allow_redirects=False
 81)
 82
 83redirect = resp.headers['Location']
 84print("==================")
 85print("redirect url : %s\n"%redirect)
 86query = urllib.parse.urlparse(redirect).query
 87print("==================")
 88print("redirect_query : %s\n"%query)
 89redirect_params = urllib.parse.parse_qs(query)
 90print("==================")
 91print("redirect_params : %s\n"%redirect_params)
 92auth_code = redirect_params['code'][0]
 93print("==================")
 94print('auth-code: {0}\n'.format(auth_code))
 95resp = requests.post(
 96    url=openid_provider + "/protocol/openid-connect/token",
 97    data={
 98        "client_secret":client_secret,
 99        "grant_type": "authorization_code",
100        "client_id": client_id,
101        "redirect_uri": redirect_uri,
102        "code": auth_code,
103        "code_verifier": code_verifier,
104    },
105    allow_redirects=False
106)
107result = resp.json()
108print("==================")
109print(json.dumps(result,indent=2))
110access_token=result['access_token']
111print("==================")
112print ("Access Token: %s\n" %access_token)
113jwt=jwt_payload_decode(result['access_token'])
114print("==================")
115print("JWT Token Decode:")
116print(json.dumps(jwt,indent=2))
117id_token=result['id_token']
118print("==================")
119print("\nID Token: %s\n" %id_token)
120
121
122cognito_idp_client = boto3.client('cognito-identity',region_name=default_region_name)
123# response = cognito_idp_client.describe_identity_pool(
124#     IdentityPoolId=identity_pool_id
125# )
126# print(json.dumps(response,indent=2))
127#这里非常的关键,iam_openid_provider_name 应该是 IAM 中身份提供商中的提供商的名字
128#受众应该是 keycloak releam中的 client name
129custom_logins={iam_openid_provider_name : id_token}
130
131response = cognito_idp_client.get_id(IdentityPoolId=identity_pool_id, Logins=custom_logins)
132identity_id = response['IdentityId']
133print("==================")
134print ("\nIdentity ID: %s" %identity_id)
135
136resp = cognito_idp_client.get_credentials_for_identity(IdentityId=identity_id,Logins=custom_logins)
137secretKey = resp['Credentials']['SecretKey']
138accessKey = resp['Credentials']['AccessKeyId']
139sessionToken = resp['Credentials']['SessionToken']
140expiration = resp['Credentials']['Expiration']
141print("==================")
142print ("\nSecret Key: %s"%(secretKey))
143print("==================")
144print ("\nAccess Key: %s"%(accessKey))
145print("==================")
146print ("\nSession Token: %s"%(sessionToken))
147print("==================")
148print ("\nExpiration: %s"%(expiration))
149print("==================")

输出