AWS ALB 認證與授權整合

AWS ALB Authentication and Authorization Integration

前言

在現代雲端架構中,應用程式的認證與授權是不可或缺的安全機制。AWS Application Load Balancer (ALB) 提供了內建的認證功能,可以在流量到達後端應用程式之前就完成使用者身份驗證,大幅簡化了開發團隊的工作負擔。

本文將詳細介紹如何利用 AWS ALB 的認證功能,整合 Amazon Cognito 或第三方 OIDC Provider,建立安全且可擴展的認證機制。


1. ALB 內建認證功能概述

什麼是 ALB Authentication?

AWS ALB 自 2018 年起支援內建認證功能,允許您在負載平衡器層級執行使用者認證,而不需要修改後端應用程式的程式碼。這項功能支援:

  • Amazon Cognito User Pools:AWS 原生的使用者管理服務
  • 任何符合 OIDC 標準的 Identity Provider (IdP):如 Okta、Auth0、Google、Azure AD 等

運作原理

1
2
使用者 → ALB → 認證檢查 → [未認證] → 重導向至 IdP
                         → [已認證] → 轉發至後端 (附帶 JWT Token)

當使用者嘗試存取受保護的資源時,ALB 會執行以下流程:

  1. 檢查請求中是否包含有效的認證 Session Cookie
  2. 若無有效 Session,將使用者重導向至 IdP 登入頁面
  3. 使用者完成登入後,IdP 回傳 Authorization Code
  4. ALB 使用 Authorization Code 交換 Access Token 和 ID Token
  5. ALB 驗證 Token 並建立 Session
  6. 將請求轉發至後端,並在 HTTP Header 中加入使用者資訊

優勢

優勢說明
簡化開發無需在應用程式中實作認證邏輯
集中管理在負載平衡器層級統一處理認證
安全性提升未認證流量不會到達後端
支援多種 IdP可整合 Cognito 或任何 OIDC Provider

2. Amazon Cognito 整合設定

建立 Cognito User Pool

首先,我們需要建立一個 Cognito User Pool 來管理使用者。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# 使用 AWS CLI 建立 User Pool
aws cognito-idp create-user-pool \
    --pool-name my-app-user-pool \
    --auto-verified-attributes email \
    --username-attributes email \
    --policies '{
        "PasswordPolicy": {
            "MinimumLength": 12,
            "RequireUppercase": true,
            "RequireLowercase": true,
            "RequireNumbers": true,
            "RequireSymbols": true
        }
    }' \
    --schema '[
        {
            "Name": "email",
            "Required": true,
            "Mutable": true
        }
    ]'

建立 App Client

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# 取得 User Pool ID(假設為 us-east-1_xxxxxxxxx)
USER_POOL_ID="us-east-1_xxxxxxxxx"

# 建立 App Client
aws cognito-idp create-user-pool-client \
    --user-pool-id $USER_POOL_ID \
    --client-name my-app-client \
    --generate-secret \
    --supported-identity-providers COGNITO \
    --callback-urls '["https://your-domain.com/oauth2/idpresponse"]' \
    --logout-urls '["https://your-domain.com/logout"]' \
    --allowed-o-auth-flows code \
    --allowed-o-auth-scopes openid email profile \
    --allowed-o-auth-flows-user-pool-client

設定 Cognito Domain

1
2
3
4
# 設定 Cognito 託管的網域
aws cognito-idp create-user-pool-domain \
    --domain my-app-auth \
    --user-pool-id $USER_POOL_ID

在 ALB 設定 Cognito 認證

透過 AWS Console 或 CLI 設定 Listener Rule:

 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
# 取得必要資訊
ALB_ARN="arn:aws:elasticloadbalancing:region:account-id:loadbalancer/app/my-alb/xxxxxxxxxx"
TARGET_GROUP_ARN="arn:aws:elasticloadbalancing:region:account-id:targetgroup/my-tg/xxxxxxxxxx"

# 建立帶有 Cognito 認證的 Listener Rule
aws elbv2 create-rule \
    --listener-arn $LISTENER_ARN \
    --priority 10 \
    --conditions '[{
        "Field": "path-pattern",
        "Values": ["/protected/*"]
    }]' \
    --actions '[
        {
            "Type": "authenticate-cognito",
            "Order": 1,
            "AuthenticateCognitoConfig": {
                "UserPoolArn": "arn:aws:cognito-idp:region:account-id:userpool/us-east-1_xxxxxxxxx",
                "UserPoolClientId": "xxxxxxxxxxxxxxxxxxxxxxxxxx",
                "UserPoolDomain": "my-app-auth",
                "SessionCookieName": "AWSELBAuthSessionCookie",
                "Scope": "openid email profile",
                "SessionTimeout": 604800,
                "OnUnauthenticatedRequest": "authenticate"
            }
        },
        {
            "Type": "forward",
            "Order": 2,
            "TargetGroupArn": "'$TARGET_GROUP_ARN'"
        }
    ]'

3. 第三方 OIDC Provider 整合

支援的 OIDC Provider

ALB 可以整合任何符合 OIDC 標準的 Identity Provider,常見的包括:

  • Okta
  • Auth0
  • Google Workspace
  • Azure Active Directory
  • Keycloak
  • PingIdentity

以 Okta 為例的整合設定

在 Okta 建立應用程式

  1. 登入 Okta Admin Console
  2. 前往 Applications → Create App Integration
  3. 選擇 OIDC - OpenID Connect 和 Web Application
  4. 設定以下資訊:
    • Sign-in redirect URIs: https://your-alb-domain.com/oauth2/idpresponse
    • Sign-out redirect URIs: https://your-alb-domain.com
  5. 記錄 Client ID 和 Client Secret

取得 OIDC Endpoints

1
2
# Okta 的 OIDC Discovery Endpoint
curl https://your-okta-domain.okta.com/.well-known/openid-configuration

主要需要的 Endpoints:

  • Authorization Endpoint: https://your-okta-domain.okta.com/oauth2/v1/authorize
  • Token Endpoint: https://your-okta-domain.okta.com/oauth2/v1/token
  • User Info Endpoint: https://your-okta-domain.okta.com/oauth2/v1/userinfo
  • Issuer: https://your-okta-domain.okta.com

在 ALB 設定 OIDC 認證

 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
aws elbv2 create-rule \
    --listener-arn $LISTENER_ARN \
    --priority 20 \
    --conditions '[{
        "Field": "path-pattern",
        "Values": ["/api/*"]
    }]' \
    --actions '[
        {
            "Type": "authenticate-oidc",
            "Order": 1,
            "AuthenticateOidcConfig": {
                "Issuer": "https://your-okta-domain.okta.com",
                "AuthorizationEndpoint": "https://your-okta-domain.okta.com/oauth2/v1/authorize",
                "TokenEndpoint": "https://your-okta-domain.okta.com/oauth2/v1/token",
                "UserInfoEndpoint": "https://your-okta-domain.okta.com/oauth2/v1/userinfo",
                "ClientId": "your-client-id",
                "ClientSecret": "your-client-secret",
                "SessionCookieName": "AWSELBAuthSessionCookie",
                "Scope": "openid email profile",
                "SessionTimeout": 3600,
                "OnUnauthenticatedRequest": "authenticate"
            }
        },
        {
            "Type": "forward",
            "Order": 2,
            "TargetGroupArn": "'$TARGET_GROUP_ARN'"
        }
    ]'

以 Azure AD 為例

1
2
3
4
5
# Azure AD OIDC 設定
# Issuer: https://login.microsoftonline.com/{tenant-id}/v2.0
# Authorization Endpoint: https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/authorize
# Token Endpoint: https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/token
# User Info Endpoint: https://graph.microsoft.com/oidc/userinfo

4. JWT Token 驗證與傳遞

ALB 傳遞的 HTTP Headers

當認證成功後,ALB 會在轉發給後端的請求中加入以下 Headers:

Header說明
x-amzn-oidc-accesstokenAccess Token(Base64 編碼)
x-amzn-oidc-identity使用者識別(通常是 sub claim)
x-amzn-oidc-dataJWT 格式的使用者資訊(已簽名)

驗證 x-amzn-oidc-data Token

x-amzn-oidc-data 是一個由 ALB 簽署的 JWT Token,後端應用程式應該驗證其簽名以確保資料的完整性。

Python 範例

 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
import jwt
import requests
import base64
from cryptography.hazmat.primitives import serialization

def get_alb_public_key(region, key_id):
    """從 AWS 取得 ALB 的公鑰"""
    url = f"https://public-keys.auth.elb.{region}.amazonaws.com/{key_id}"
    response = requests.get(url)
    return response.text

def verify_alb_token(token, region):
    """驗證 ALB 簽發的 JWT Token"""
    # 解析 JWT Header 取得 Key ID
    header = jwt.get_unverified_header(token)
    key_id = header['kid']

    # 取得公鑰
    public_key_pem = get_alb_public_key(region, key_id)

    # 驗證並解碼 Token
    try:
        payload = jwt.decode(
            token,
            public_key_pem,
            algorithms=['ES256'],
            options={
                'verify_exp': True,
                'verify_iss': False  # ALB Token 沒有標準 issuer
            }
        )
        return payload
    except jwt.ExpiredSignatureError:
        raise Exception("Token has expired")
    except jwt.InvalidTokenError as e:
        raise Exception(f"Invalid token: {e}")

# 使用範例
from flask import Flask, request

app = Flask(__name__)

@app.route('/api/user')
def get_user():
    oidc_data = request.headers.get('x-amzn-oidc-data')
    if oidc_data:
        user_info = verify_alb_token(oidc_data, 'us-east-1')
        return {
            'email': user_info.get('email'),
            'sub': user_info.get('sub'),
            'name': user_info.get('name')
        }
    return {'error': 'No authentication data'}, 401

Node.js 範例

 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
const jwt = require('jsonwebtoken');
const axios = require('axios');

async function getALBPublicKey(region, keyId) {
    const url = `https://public-keys.auth.elb.${region}.amazonaws.com/${keyId}`;
    const response = await axios.get(url);
    return response.data;
}

async function verifyALBToken(token, region) {
    // 解析 JWT Header 取得 Key ID
    const decoded = jwt.decode(token, { complete: true });
    const keyId = decoded.header.kid;

    // 取得公鑰
    const publicKey = await getALBPublicKey(region, keyId);

    // 驗證並解碼 Token
    return new Promise((resolve, reject) => {
        jwt.verify(token, publicKey, { algorithms: ['ES256'] }, (err, payload) => {
            if (err) {
                reject(err);
            } else {
                resolve(payload);
            }
        });
    });
}

// Express.js 中介軟體範例
async function authMiddleware(req, res, next) {
    const oidcData = req.headers['x-amzn-oidc-data'];

    if (!oidcData) {
        return res.status(401).json({ error: 'No authentication data' });
    }

    try {
        req.user = await verifyALBToken(oidcData, 'us-east-1');
        next();
    } catch (error) {
        res.status(401).json({ error: 'Invalid token' });
    }
}

module.exports = { authMiddleware };

Go 範例

 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
package auth

import (
    "crypto/ecdsa"
    "encoding/pem"
    "fmt"
    "io/ioutil"
    "net/http"

    "github.com/golang-jwt/jwt/v5"
)

func GetALBPublicKey(region, keyID string) (*ecdsa.PublicKey, error) {
    url := fmt.Sprintf("https://public-keys.auth.elb.%s.amazonaws.com/%s", region, keyID)

    resp, err := http.Get(url)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    keyPEM, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        return nil, err
    }

    block, _ := pem.Decode(keyPEM)
    if block == nil {
        return nil, fmt.Errorf("failed to parse PEM block")
    }

    return jwt.ParseECPublicKeyFromPEM(keyPEM)
}

func VerifyALBToken(tokenString, region string) (jwt.MapClaims, error) {
    // 解析未驗證的 Token 以取得 Key ID
    token, _, err := new(jwt.Parser).ParseUnverified(tokenString, jwt.MapClaims{})
    if err != nil {
        return nil, err
    }

    keyID := token.Header["kid"].(string)

    // 取得公鑰
    publicKey, err := GetALBPublicKey(region, keyID)
    if err != nil {
        return nil, err
    }

    // 驗證 Token
    token, err = jwt.Parse(tokenString, func(t *jwt.Token) (interface{}, error) {
        if _, ok := t.Method.(*jwt.SigningMethodECDSA); !ok {
            return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
        }
        return publicKey, nil
    })

    if err != nil {
        return nil, err
    }

    if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
        return claims, nil
    }

    return nil, fmt.Errorf("invalid token")
}

5. Listener Rules 設定

認證行為選項

ALB 提供三種未認證請求的處理行為:

選項說明適用場景
authenticate重導向至 IdP 進行認證需要強制登入的頁面
allow允許請求通過(不認證)公開 API 或資源
deny回傳 401 UnauthorizedAPI 端點(不需重導向)

混合式認證設定

在實際應用中,您可能需要同時支援已認證和未認證的請求:

 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
# 規則 1:公開路徑(不需認證)
aws elbv2 create-rule \
    --listener-arn $LISTENER_ARN \
    --priority 5 \
    --conditions '[{
        "Field": "path-pattern",
        "Values": ["/public/*", "/health", "/api/v1/status"]
    }]' \
    --actions '[{
        "Type": "forward",
        "TargetGroupArn": "'$TARGET_GROUP_ARN'"
    }]'

# 規則 2:Web 頁面(需認證,重導向登入)
aws elbv2 create-rule \
    --listener-arn $LISTENER_ARN \
    --priority 10 \
    --conditions '[{
        "Field": "path-pattern",
        "Values": ["/app/*", "/dashboard/*"]
    }]' \
    --actions '[
        {
            "Type": "authenticate-cognito",
            "Order": 1,
            "AuthenticateCognitoConfig": {
                "UserPoolArn": "'$USER_POOL_ARN'",
                "UserPoolClientId": "'$CLIENT_ID'",
                "UserPoolDomain": "'$COGNITO_DOMAIN'",
                "OnUnauthenticatedRequest": "authenticate"
            }
        },
        {
            "Type": "forward",
            "Order": 2,
            "TargetGroupArn": "'$TARGET_GROUP_ARN'"
        }
    ]'

# 規則 3:API 端點(需認證,回傳 401)
aws elbv2 create-rule \
    --listener-arn $LISTENER_ARN \
    --priority 15 \
    --conditions '[{
        "Field": "path-pattern",
        "Values": ["/api/v1/protected/*"]
    }]' \
    --actions '[
        {
            "Type": "authenticate-cognito",
            "Order": 1,
            "AuthenticateCognitoConfig": {
                "UserPoolArn": "'$USER_POOL_ARN'",
                "UserPoolClientId": "'$CLIENT_ID'",
                "UserPoolDomain": "'$COGNITO_DOMAIN'",
                "OnUnauthenticatedRequest": "deny"
            }
        },
        {
            "Type": "forward",
            "Order": 2,
            "TargetGroupArn": "'$TARGET_GROUP_ARN'"
        }
    ]'

基於 Header 的條件式認證

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# 對於帶有特定 Header 的請求跳過認證(例如:內部服務呼叫)
aws elbv2 create-rule \
    --listener-arn $LISTENER_ARN \
    --priority 1 \
    --conditions '[
        {
            "Field": "path-pattern",
            "Values": ["/api/*"]
        },
        {
            "Field": "http-header",
            "HttpHeaderConfig": {
                "HttpHeaderName": "X-Internal-Request",
                "Values": ["true"]
            }
        }
    ]' \
    --actions '[{
        "Type": "forward",
        "TargetGroupArn": "'$INTERNAL_TARGET_GROUP_ARN'"
    }]'

6. Terraform 配置範例

完整的 Terraform 配置

  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
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
# variables.tf
variable "environment" {
  description = "Environment name"
  type        = string
  default     = "production"
}

variable "domain_name" {
  description = "Domain name for the application"
  type        = string
}

variable "vpc_id" {
  description = "VPC ID"
  type        = string
}

variable "public_subnet_ids" {
  description = "List of public subnet IDs"
  type        = list(string)
}

variable "certificate_arn" {
  description = "ACM Certificate ARN"
  type        = string
}

# cognito.tf
resource "aws_cognito_user_pool" "main" {
  name = "${var.environment}-user-pool"

  # 密碼策略
  password_policy {
    minimum_length                   = 12
    require_lowercase                = true
    require_numbers                  = true
    require_symbols                  = true
    require_uppercase                = true
    temporary_password_validity_days = 7
  }

  # MFA 設定
  mfa_configuration = "OPTIONAL"

  software_token_mfa_configuration {
    enabled = true
  }

  # 使用者屬性
  schema {
    name                     = "email"
    attribute_data_type      = "String"
    mutable                  = true
    required                 = true
    developer_only_attribute = false

    string_attribute_constraints {
      min_length = 5
      max_length = 256
    }
  }

  # 自動驗證設定
  auto_verified_attributes = ["email"]
  username_attributes      = ["email"]

  # 帳號復原設定
  account_recovery_setting {
    recovery_mechanism {
      name     = "verified_email"
      priority = 1
    }
  }

  # Email 設定
  email_configuration {
    email_sending_account = "COGNITO_DEFAULT"
  }

  tags = {
    Environment = var.environment
  }
}

resource "aws_cognito_user_pool_domain" "main" {
  domain       = "${var.environment}-auth-domain"
  user_pool_id = aws_cognito_user_pool.main.id
}

resource "aws_cognito_user_pool_client" "alb_client" {
  name         = "${var.environment}-alb-client"
  user_pool_id = aws_cognito_user_pool.main.id

  generate_secret = true

  # OAuth 設定
  allowed_oauth_flows                  = ["code"]
  allowed_oauth_flows_user_pool_client = true
  allowed_oauth_scopes                 = ["openid", "email", "profile"]
  supported_identity_providers         = ["COGNITO"]

  # Callback URLs
  callback_urls = [
    "https://${var.domain_name}/oauth2/idpresponse"
  ]

  logout_urls = [
    "https://${var.domain_name}/logout"
  ]

  # Token 有效期設定
  access_token_validity  = 1    # 小時
  id_token_validity      = 1    # 小時
  refresh_token_validity = 30   # 天

  token_validity_units {
    access_token  = "hours"
    id_token      = "hours"
    refresh_token = "days"
  }

  # 防止 Token 撤銷
  enable_token_revocation = true

  # 防止使用者存在錯誤
  prevent_user_existence_errors = "ENABLED"
}

# alb.tf
resource "aws_lb" "main" {
  name               = "${var.environment}-alb"
  internal           = false
  load_balancer_type = "application"
  security_groups    = [aws_security_group.alb.id]
  subnets            = var.public_subnet_ids

  enable_deletion_protection = true
  enable_http2               = true

  access_logs {
    bucket  = aws_s3_bucket.alb_logs.id
    prefix  = "alb-logs"
    enabled = true
  }

  tags = {
    Environment = var.environment
  }
}

resource "aws_security_group" "alb" {
  name        = "${var.environment}-alb-sg"
  description = "Security group for ALB"
  vpc_id      = var.vpc_id

  ingress {
    description = "HTTPS from anywhere"
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    description = "HTTP from anywhere (redirect to HTTPS)"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "${var.environment}-alb-sg"
  }
}

resource "aws_lb_target_group" "app" {
  name        = "${var.environment}-app-tg"
  port        = 8080
  protocol    = "HTTP"
  vpc_id      = var.vpc_id
  target_type = "ip"

  health_check {
    enabled             = true
    healthy_threshold   = 2
    interval            = 30
    matcher             = "200"
    path                = "/health"
    port                = "traffic-port"
    protocol            = "HTTP"
    timeout             = 5
    unhealthy_threshold = 3
  }

  tags = {
    Environment = var.environment
  }
}

# HTTPS Listener
resource "aws_lb_listener" "https" {
  load_balancer_arn = aws_lb.main.arn
  port              = "443"
  protocol          = "HTTPS"
  ssl_policy        = "ELBSecurityPolicy-TLS13-1-2-2021-06"
  certificate_arn   = var.certificate_arn

  default_action {
    type = "fixed-response"

    fixed_response {
      content_type = "text/plain"
      message_body = "Not Found"
      status_code  = "404"
    }
  }
}

# HTTP to HTTPS Redirect
resource "aws_lb_listener" "http_redirect" {
  load_balancer_arn = aws_lb.main.arn
  port              = "80"
  protocol          = "HTTP"

  default_action {
    type = "redirect"

    redirect {
      port        = "443"
      protocol    = "HTTPS"
      status_code = "HTTP_301"
    }
  }
}

# listener_rules.tf

# 公開路徑規則
resource "aws_lb_listener_rule" "public" {
  listener_arn = aws_lb_listener.https.arn
  priority     = 10

  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.app.arn
  }

  condition {
    path_pattern {
      values = ["/public/*", "/health", "/api/v1/status"]
    }
  }
}

# 需要認證的 Web 頁面規則
resource "aws_lb_listener_rule" "authenticated_web" {
  listener_arn = aws_lb_listener.https.arn
  priority     = 20

  action {
    type = "authenticate-cognito"

    authenticate_cognito {
      user_pool_arn       = aws_cognito_user_pool.main.arn
      user_pool_client_id = aws_cognito_user_pool_client.alb_client.id
      user_pool_domain    = aws_cognito_user_pool_domain.main.domain

      session_cookie_name = "AWSELBAuthSessionCookie"
      session_timeout     = 604800  # 7 天
      scope               = "openid email profile"

      on_unauthenticated_request = "authenticate"
    }
  }

  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.app.arn
  }

  condition {
    path_pattern {
      values = ["/app/*", "/dashboard/*", "/settings/*"]
    }
  }
}

# 需要認證的 API 規則(回傳 401)
resource "aws_lb_listener_rule" "authenticated_api" {
  listener_arn = aws_lb_listener.https.arn
  priority     = 30

  action {
    type = "authenticate-cognito"

    authenticate_cognito {
      user_pool_arn       = aws_cognito_user_pool.main.arn
      user_pool_client_id = aws_cognito_user_pool_client.alb_client.id
      user_pool_domain    = aws_cognito_user_pool_domain.main.domain

      session_cookie_name = "AWSELBAuthSessionCookie"
      session_timeout     = 3600  # 1 小時
      scope               = "openid email profile"

      on_unauthenticated_request = "deny"
    }
  }

  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.app.arn
  }

  condition {
    path_pattern {
      values = ["/api/v1/*"]
    }
  }
}

# outputs.tf
output "alb_dns_name" {
  description = "ALB DNS name"
  value       = aws_lb.main.dns_name
}

output "cognito_user_pool_id" {
  description = "Cognito User Pool ID"
  value       = aws_cognito_user_pool.main.id
}

output "cognito_app_client_id" {
  description = "Cognito App Client ID"
  value       = aws_cognito_user_pool_client.alb_client.id
}

output "cognito_domain" {
  description = "Cognito Domain"
  value       = "https://${aws_cognito_user_pool_domain.main.domain}.auth.${data.aws_region.current.name}.amazoncognito.com"
}

data "aws_region" "current" {}

OIDC Provider 的 Terraform 配置

 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
# 使用 OIDC Provider(如 Okta)的認證規則
resource "aws_lb_listener_rule" "oidc_authenticated" {
  listener_arn = aws_lb_listener.https.arn
  priority     = 25

  action {
    type = "authenticate-oidc"

    authenticate_oidc {
      authorization_endpoint = "https://your-okta-domain.okta.com/oauth2/v1/authorize"
      client_id              = var.okta_client_id
      client_secret          = var.okta_client_secret
      issuer                 = "https://your-okta-domain.okta.com"
      token_endpoint         = "https://your-okta-domain.okta.com/oauth2/v1/token"
      user_info_endpoint     = "https://your-okta-domain.okta.com/oauth2/v1/userinfo"

      session_cookie_name = "AWSELBAuthSessionCookie"
      session_timeout     = 3600
      scope               = "openid email profile"

      on_unauthenticated_request = "authenticate"
    }
  }

  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.app.arn
  }

  condition {
    path_pattern {
      values = ["/oidc/*"]
    }
  }
}

# 使用 AWS Secrets Manager 儲存 OIDC 憑證
resource "aws_secretsmanager_secret" "oidc_credentials" {
  name = "${var.environment}/oidc/credentials"

  tags = {
    Environment = var.environment
  }
}

resource "aws_secretsmanager_secret_version" "oidc_credentials" {
  secret_id = aws_secretsmanager_secret.oidc_credentials.id
  secret_string = jsonencode({
    client_id     = var.okta_client_id
    client_secret = var.okta_client_secret
  })
}

7. 安全性最佳實務

1. 使用 HTTPS

務必使用 HTTPS,避免認證 Token 在傳輸過程中被攔截。

1
2
3
4
5
6
# 強制使用 TLS 1.2 或更高版本
resource "aws_lb_listener" "https" {
  # ...
  ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
  # ...
}

ALB 的認證 Cookie 預設使用 SecureHttpOnly 屬性,但您應該確保:

  • 使用足夠隨機的 Session Cookie 名稱
  • 設定適當的 Session Timeout
1
2
# 設定較短的 Session Timeout(例如:1 小時)
"SessionTimeout": 3600

3. 限制 Callback URL

在 Cognito 或 OIDC Provider 中,僅允許您控制的 Callback URL:

1
2
3
4
5
6
7
8
resource "aws_cognito_user_pool_client" "alb_client" {
  # ...
  callback_urls = [
    "https://app.example.com/oauth2/idpresponse"
    # 不要使用萬用字元如 https://*.example.com
  ]
  # ...
}

4. 驗證 JWT Token

永遠在後端驗證 x-amzn-oidc-data Token 的簽名

1
2
3
4
5
6
7
8
# 不要直接信任 Header 內容
# 錯誤做法
user_email = request.headers.get('x-amzn-oidc-identity')  # ❌ 不安全

# 正確做法
oidc_data = request.headers.get('x-amzn-oidc-data')
verified_payload = verify_alb_token(oidc_data, region)  # ✅ 驗證後使用
user_email = verified_payload.get('email')

5. 實施最小權限原則

為 ALB 和 Cognito 設定最小必要的 IAM 權限:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# ALB 的 IAM Policy(如需存取 Secrets Manager)
resource "aws_iam_role_policy" "alb_secrets_access" {
  name = "alb-secrets-access"
  role = aws_iam_role.alb_role.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "secretsmanager:GetSecretValue"
        ]
        Resource = [
          aws_secretsmanager_secret.oidc_credentials.arn
        ]
      }
    ]
  })
}

6. 啟用 Access Logs

記錄所有 ALB 流量以供審計:

1
2
3
4
5
6
7
8
9
resource "aws_lb" "main" {
  # ...
  access_logs {
    bucket  = aws_s3_bucket.alb_logs.id
    prefix  = "alb-logs"
    enabled = true
  }
  # ...
}

7. 設定 WAF 規則

使用 AWS WAF 保護 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
65
66
resource "aws_wafv2_web_acl_association" "alb" {
  resource_arn = aws_lb.main.arn
  web_acl_arn  = aws_wafv2_web_acl.main.arn
}

resource "aws_wafv2_web_acl" "main" {
  name        = "${var.environment}-waf"
  description = "WAF for ALB"
  scope       = "REGIONAL"

  default_action {
    allow {}
  }

  # 防止 SQL Injection
  rule {
    name     = "AWSManagedRulesSQLiRuleSet"
    priority = 1

    override_action {
      none {}
    }

    statement {
      managed_rule_group_statement {
        name        = "AWSManagedRulesSQLiRuleSet"
        vendor_name = "AWS"
      }
    }

    visibility_config {
      cloudwatch_metrics_enabled = true
      metric_name                = "SQLiRuleSet"
      sampled_requests_enabled   = true
    }
  }

  # Rate Limiting
  rule {
    name     = "RateLimitRule"
    priority = 2

    action {
      block {}
    }

    statement {
      rate_based_statement {
        limit              = 2000
        aggregate_key_type = "IP"
      }
    }

    visibility_config {
      cloudwatch_metrics_enabled = true
      metric_name                = "RateLimitRule"
      sampled_requests_enabled   = true
    }
  }

  visibility_config {
    cloudwatch_metrics_enabled = true
    metric_name                = "WAFWebACL"
    sampled_requests_enabled   = true
  }
}

8. 定期輪換 Client Secret

1
2
3
4
5
6
7
8
# 建立新的 Client Secret
aws cognito-idp update-user-pool-client \
    --user-pool-id $USER_POOL_ID \
    --client-id $CLIENT_ID \
    --generate-secret

# 更新 ALB Listener Rule 使用新的 Secret
# (需要更新 Terraform 或 CLI 設定)

8. 故障排除指南

常見錯誤與解決方案

1. 認證重導向迴圈

症狀:使用者持續被重導向至 IdP,無法完成登入。

可能原因與解決方案

1
2
3
4
5
6
7
# 檢查 Callback URL 是否正確
# Cognito App Client 的 Callback URL 必須包含:
# https://your-alb-domain.com/oauth2/idpresponse

# 確認 ALB 的 DNS 名稱與 Callback URL 相符
aws elbv2 describe-load-balancers --names my-alb \
    --query 'LoadBalancers[0].DNSName'

2. 401 Unauthorized 錯誤

症狀:API 請求收到 401 錯誤。

檢查項目

1
2
3
4
5
6
7
8
# 1. 確認 Session Cookie 是否存在
curl -v https://your-domain.com/api/v1/resource

# 2. 檢查 Cookie 過期時間
# SessionTimeout 設定是否合理

# 3. 確認 OIDC Scope 是否正確
# 必須包含 "openid"

3. Token 驗證失敗

症狀:後端無法驗證 x-amzn-oidc-data Token。

 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
# 除錯腳本
import jwt
import base64
import json

def debug_alb_token(token):
    # 解碼 JWT(不驗證)
    parts = token.split('.')

    # Header
    header = json.loads(base64.urlsafe_b64decode(parts[0] + '=='))
    print(f"Header: {json.dumps(header, indent=2)}")

    # Payload
    payload = json.loads(base64.urlsafe_b64decode(parts[1] + '=='))
    print(f"Payload: {json.dumps(payload, indent=2)}")

    # 檢查 Key ID
    print(f"Key ID: {header.get('kid')}")

    # 檢查過期時間
    import datetime
    exp = payload.get('exp')
    if exp:
        exp_time = datetime.datetime.fromtimestamp(exp)
        print(f"Expires: {exp_time}")
        print(f"Expired: {datetime.datetime.now() > exp_time}")

# 使用
debug_alb_token(request.headers.get('x-amzn-oidc-data'))

4. CORS 問題

症狀:前端 JavaScript 無法存取認證後的 API。

解決方案:在後端設定適當的 CORS Headers:

1
2
3
4
5
6
7
# Flask 範例
from flask_cors import CORS

app = Flask(__name__)
CORS(app, supports_credentials=True, origins=[
    "https://your-frontend-domain.com"
])

5. IdP 連線問題

症狀:ALB 無法與 OIDC Provider 通訊。

1
2
3
4
5
6
# 檢查 ALB 的 Security Group 是否允許 outbound 443
aws ec2 describe-security-groups --group-ids $ALB_SG_ID \
    --query 'SecurityGroups[0].IpPermissionsEgress'

# 確認 VPC 有 NAT Gateway 或 Internet Gateway
# ALB 需要能夠存取 OIDC Provider 的端點

CloudWatch Logs 除錯

啟用 ALB Access Logs 來追蹤認證流程:

1
2
3
4
5
6
7
8
# 分析認證相關的請求
aws s3 cp s3://your-log-bucket/alb-logs/ ./logs --recursive

# 搜尋認證錯誤
zcat logs/*.gz | grep -E "(401|302)" | head -20

# 檢查認證重導向
zcat logs/*.gz | grep "oauth2/idpresponse"

使用 AWS CLI 檢查設定

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# 列出所有 Listener Rules
aws elbv2 describe-rules \
    --listener-arn $LISTENER_ARN \
    --query 'Rules[*].{Priority:Priority,Conditions:Conditions,Actions:Actions}' \
    --output table

# 檢查 Cognito User Pool 設定
aws cognito-idp describe-user-pool \
    --user-pool-id $USER_POOL_ID

# 檢查 App Client 設定
aws cognito-idp describe-user-pool-client \
    --user-pool-id $USER_POOL_ID \
    --client-id $CLIENT_ID

測試認證流程

1
2
3
4
5
6
7
8
9
# 使用 curl 測試認證流程
# 1. 未認證請求(應回傳 302 重導向)
curl -v -c cookies.txt https://your-domain.com/protected/resource

# 2. 手動完成認證後,使用 Cookie 再次請求
curl -v -b cookies.txt https://your-domain.com/protected/resource

# 3. 檢查 Headers
curl -v -b cookies.txt https://your-domain.com/api/debug-headers

總結

AWS ALB 的內建認證功能提供了一個強大且靈活的方式來保護您的應用程式。透過整合 Amazon Cognito 或第三方 OIDC Provider,您可以:

  1. 簡化開發:將認證邏輯從應用程式中抽離
  2. 提升安全性:在網路邊緣攔截未認證流量
  3. 支援多種認證方式:輕鬆整合企業 SSO 解決方案
  4. 靈活配置:根據路徑和條件設定不同的認證策略

在實作時,請務必遵循安全性最佳實務,包括使用 HTTPS、驗證 JWT Token、限制 Callback URL、以及定期審計存取日誌。

如有任何問題,可以參考 AWS 官方文件 或在 AWS 論壇尋求協助。


參考資源

comments powered by Disqus
Built with Hugo
Theme Stack designed by Jimmy