使用AWS应用负载均衡器和Azure AD OIDC简化单点登录

本文详细介绍了如何利用AWS应用负载均衡器的OIDC认证功能与Azure AD集成,实现无需应用层代码的单点登录方案,包含Terraform配置和Serverless框架部署指南。

使用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。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计