使用AWS应用负载均衡器和Azure AD OIDC简化单点登录
AWS应用负载均衡器支持一个我认为被低估的功能:在第七层通过OIDC认证请求。这使得开发者可以将几乎所有认证逻辑排除在应用层代码之外。一个理想的用例是仅限内部访问且需要认证但几乎不需要RBAC授权的Web应用。
根据AWS官方博客文章,该功能的工作原理如下:
ALB认证通过在监听器规则中定义认证动作来实现。ALB的认证动作会检查传入请求是否存在会话cookie,并验证其有效性。如果会话cookie已设置且有效,则ALB会将请求路由到目标组,并设置X-AMZN-OIDC-*头部。
ALB认证支持Cognito和通用OIDC身份提供商。本文将重点介绍与Azure AD OIDC的集成,使用Terraform和Serverless框架进行管理。
Azure AD – 企业应用配置
要与Azure AD建立OIDC集成,首先需要配置一个企业应用。微软已有通过Azure门户UI手动配置的教程,因此我将重点介绍使用Azure AD Terraform提供商的部署方法。这种方法的好处是能够使用生成的Terraform输出自动为ALB认证配置(用于Serverless)提供输入。
以下是Azure应用的配置代码:
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
|
data "azuread_application_published_app_ids" "well_known" {}
resource "azuread_service_principal" "msgraph" {
application_id = data.azuread_application_published_app_ids.well_known.result.MicrosoftGraph
use_existing = true
}
resource "azuread_application" "app" {
display_name = local.app_title
group_membership_claims = ["SecurityGroup"]
sign_in_audience = "AzureADMyOrg"
web {
homepage_url = local.base_url
redirect_uris = [local.reply_url]
implicit_grant {
access_token_issuance_enabled = false
id_token_issuance_enabled = false
}
}
required_resource_access {
resource_app_id = azuread_service_principal.msgraph.application_id
resource_access {
id = azuread_service_principal.msgraph.oauth2_permission_scope_ids["openid"]
type = "Scope"
}
}
}
resource "azuread_service_principal" "service_principal" {
application_id = azuread_application.app.application_id
tags = ["WindowsAzureActiveDirectoryIntegratedApp"]
app_role_assignment_required = true
}
resource "azuread_application_password" "app_password" {
application_object_id = azuread_application.app.id
display_name = local.app_title
}
resource "azuread_service_principal_delegated_permission_grant" "delegated_grant" {
service_principal_object_id = azuread_service_principal.service_principal.object_id
resource_service_principal_object_id = azuread_service_principal.msgraph.object_id
claim_values = ["openid"]
}
|
上述Terraform代码中有两点需要注意:一是设置了app_role_assignment_required,这要求将特定用户分配到Azure中的应用才能成功进行SSO;二是azuread_service_principal_delegated_permission_grant资源,这将代表企业授予openid权限的管理员同意。
回到Terraform,我们将使用SSM将生成的app配置参数(ALB监听器所需)暴露给AWS:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
resource "aws_ssm_parameter" "tenant_id" {
name = "/web-app/AZURE_TENANT_ID"
type = "String"
value = data.azuread_client_config.current.tenant_id
}
resource "aws_ssm_parameter" "client_id" {
name = "/web-app/AZURE_CLIENT_ID"
type = "String"
value = azuread_application.app.application_id
}
resource "aws_ssm_parameter" "client_secret" {
name = "/web-app/AZURE_CLIENT_SECRET"
type = "SecureString"
value = azuread_application_password.app_password.value
}
|
Serverless – ALB监听器
创建Azure企业应用后,我们可以继续配置ALB本身。首先定义这些资源:
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
|
Resources:
############
# ALB Resources
############
ALBSecurityGroupEgress:
Type: AWS::EC2::SecurityGroupEgress
Properties:
CidrIp: '0.0.0.0/0' # OIDC需要外部出口
GroupId: !GetAtt ALBSecurityGroup.GroupId
IpProtocol: tcp
FromPort: 443
ToPort: 443
ALBSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: ${self:service}-${self:provider.stage}-alb-sg
VpcId: ${self:custom.alb.vpc}
ALBSecurityGroupIngress:
Type: AWS::EC2::SecurityGroupIngress
Properties:
GroupId: !GetAtt ALBSecurityGroup.GroupId
IpProtocol: tcp
FromPort: ${self:custom.alb.port}
ToPort: ${self:custom.alb.port}
CidrIp: ${self:custom.alb.ingress}
LambdaFunctionPermission:
Type: AWS::Lambda::Permission
Properties:
Action: lambda:InvokeFunction
FunctionName: !GetAtt ApiLambdaFunction.Arn # 从函数动态获取
Principal: elasticloadbalancing.amazonaws.com
ALBElasticLoadBalancer:
Type: AWS::ElasticLoadBalancingV2::LoadBalancer
Properties:
Subnets: ${self:custom.alb.subnets}
Scheme: ${self:custom.alb.scheme}
SecurityGroups:
- !GetAtt ALBSecurityGroup.GroupId
DependsOn:
- ALBSecurityGroup
ALBDNSRecord:
Type: AWS::Route53::RecordSet
Properties:
HostedZoneId: ${self:custom.alb.hostedZoneId}
Name: ${self:custom.alb.dnsName}.
AliasTarget:
DNSName: !GetAtt ALBElasticLoadBalancer.DNSName
HostedZoneId: !GetAtt ALBElasticLoadBalancer.CanonicalHostedZoneID
Type: A
LoadBalancerListener:
Type: AWS::ElasticLoadBalancingV2::Listener
Properties:
Certificates:
- CertificateArn: ${self:custom.alb.certArn}
DefaultActions:
- Type: fixed-response
FixedResponseConfig:
ContentType: "text/plain"
MessageBody: ""
StatusCode: "404"
LoadBalancerArn:
Ref: ALBElasticLoadBalancer
Port: ${self:custom.alb.port}
Protocol: ${self:custom.alb.protocol}
|
注意ALBSecurityGroupEgress资源,表明ALB需要外部出口(即使是仅内部ALB),以便与Azure ODIC API交互。
接下来,我们将在Serverless中配置ALB监听器规则,从ALB授权器配置开始:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
custom:
oidc:
tenantId: '${ssm:/web-app/AZURE_TENANT_ID}'
clientId: '${ssm:/web-app/AZURE_CLIENT_ID}'
clientSecret: '${ssm:/web-app/AZURE_CLIENT_SECRET}'
provider:
name: aws
runtime: python3.9
timeout: 60
alb:
authorizers:
azureAdAuth:
type: oidc
authorizationEndpoint: https://login.microsoftonline.com/${self:custom.oidc.tenantId}/oauth2/v2.0/authorize
tokenEndpoint: https://login.microsoftonline.com/${self:custom.oidc.tenantId}/oauth2/v2.0/token
userInfoEndpoint: https://graph.microsoft.com/oidc/userinfo
onUnauthenticatedRequest: authenticate
clientId: ${self:custom.oidc.clientId}
clientSecret: ${self:custom.oidc.clientSecret}
|
注意对custom.oidc属性的引用,这些属性从SSM动态获取,并由上述Terraform提供。接下来,将授权器连接到Lambda函数上的ALB事件:
1
2
3
4
5
6
7
8
9
10
11
|
functions:
api:
handler: webapp.alb_handler
events:
- alb:
listenerArn: !Ref LoadBalancerListener
priority: 1
multiValueHeaders: true
authorizer: azureAdAuth
conditions:
path: "*"
|
部署后,您将在ALB的规则UI中看到类似的内容。
K8s ALB注解
您还可以使用Kubernetes中的ingress注解实现类似配置:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
kind: Ingress
apiVersion: extensions/v1beta1
metadata:
name: ***REMOVED***
namespace: ***REMOVED***
annotations:
alb.ingress.kubernetes.io/auth-idp-oidc: >-
alb.ingress.kubernetes.io/auth-on-unauthenticated-request: authenticate
alb.ingress.kubernetes.io/auth-scope: openid
alb.ingress.kubernetes.io-auth-session-cookie: AWSELBAuthSessionCookie
alb.ingress.kubernetes.io-auth-session-timeout: '604800'
alb.ingress.kubernetes.io-auth-type: oidc
|
OIDC头部
认证请求后,ALB在将请求转发到目标之前会添加包含用户声明的额外头部。链接文档中有解码x-amzn-oidc-data头部的示例 – 我稍作调整以缓存公钥、添加日志记录并验证颁发者:
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
|
AWS_REGION = os.getenv("AWS_REGION", "us-east-1")
PUB_KEY = None
def decode_jwt(encoded_jwt):
log = logger.new(name="DecodeJwt")
global PUB_KEY
if not PUB_KEY:
log.debug("Initializing ALB public key")
# 步骤1:从JWT头部获取密钥ID(kid字段)
jwt_headers = encoded_jwt.split(".")[0]
decoded_jwt_headers = base64.b64decode(jwt_headers)
decoded_jwt_headers = decoded_jwt_headers.decode("utf-8")
decoded_json = json.loads(decoded_jwt_headers)
kid = decoded_json["kid"]
# 步骤2:从区域端点获取公钥
url = f"https://public-keys.auth.elb.{AWS_REGION}.amazonaws.com/{kid}"
req = requests.get(url)
PUB_KEY = req.text
else:
log.debug("Using cached public key")
# 步骤3:获取有效负载
log.debug("Decoding JWT...")
log.debug("Decoded JWT", payload=payload)
return payload
|
以下是来自Azure AD的解码声明有效负载示例:
1
2
3
4
5
6
7
8
9
10
|
{
"sub": "***REMOVED***",
"name": "Randy Westergren",
"family_name": "Westergren",
"given_name": "Randolph",
"picture": "https://graph.microsoft.com/v1.0/me/photo/$value",
"email": "randy.westergren@***REMOVED***.com",
"exp": 1665076372,
"iss": "https://login.microsoftonline.com/***REMOVED***/v2.0"
}
|
您可以将此用于从记录用户请求到扩展应用以添加授权规则的各种用途。
登录
现在当您访问受保护的路径时,首先会被重定向到Azure AD登录页面。成功登录后,后续请求将设置AWSELBAuthSessionCookie cookie。