ALBのTargetとしてLambdaが選択できるようになり、 若干の時間課金が発生する代わりに柔軟にルーティングできるAPI Gatewayのように使えるようになった。 ActionとしてCognito認証を入れて認証に失敗したらログイン画面を表示させる。
API GatewayでCognitoの認証をかけて必要ならログイン画面に飛ばす処理をGoで書く - sambaiz-net
ACMで証明書を発行する
HTTPSでListenするため証明書が必要。 AWS Certificate Manager (ACM)でAWSで使える証明書を無料で発行でき参照できる。 外部で取ったドメインでもよい。 検証方法はDNSとメールとで選ぶことができて、DNSで行う場合Route53ならワンクリックで検証用のCNAMEレコードを作成できる。 検証までやや時間がかかるのでちゃんと通ってるかnslookupで確認しといた方がよい。
Application Load Balancer (ALB)
次の要素から構成されるL7のロードバランサー。
- Listener: 指定したプロトコルとポートでリクエストを受ける。
- ListenerRule: パスやHeaderなどの値を条件にどのTargetGroupにルーティングするかのルール。
- TargetGroup: ルーティングする1つ以上のTarget。Instance, IP, Lambdaが選べる。
Ruleの作成
Serverless FrameworkではALBのenentを付けるだけでLambdaに向くRuleが作成されるが、そこにはCognitoを追加できなさそうなので使っていない。OnUnauthenticatedRequestで認証失敗時の挙動を選択できる。UserPoolとSecretありのClientはあらかじめ作っておく。コールバックURLにはhttps://<domain>/oauth2/idpresponse
を追加する。
API Gatewayだとタイムアウトの上限が30秒なのに対してALBはLambdaの上限まで待てる。
$ cat serverless.yml
service: alb-cognito-auth-example
frameworkVersion: ">=1.28.0 <2.0.0"
provider:
name: aws
runtime: go1.x
region: ap-northeast-1
package:
exclude:
- ./**
include:
- ./bin/**
functions:
privateapi:
handler: bin/privateapi
timeout: 30
resources:
Resources:
ALBTargetGroup:
DependsOn: InvokeLambdaPermissionForALB
Type: AWS::ElasticLoadBalancingV2::TargetGroup
Properties:
Name: "private-lambda-target-group"
TargetType: "lambda"
Targets:
- Id: !GetAtt PrivateapiLambdaFunction.Arn # Reference functions.privateapi
InvokeLambdaPermissionForALB:
Type: AWS::Lambda::Permission
Properties:
Action: "lambda:InvokeFunction"
FunctionName: !Ref PrivateapiLambdaFunction
Principal: "elasticloadbalancing.amazonaws.com"
ALB:
Type: "AWS::ElasticLoadBalancingV2::LoadBalancer"
Properties:
Name: "serverless-auth-example-alb"
Scheme: "internet-facing"
LoadBalancerAttributes:
- Key: "idle_timeout.timeout_seconds"
Value: 30
SecurityGroups:
- "sg-*****"
Subnets:
- "subnet-*****"
- "subnet-*****"
ALBListener:
Type: "AWS::ElasticLoadBalancingV2::Listener"
Properties:
DefaultActions:
- Type: authenticate-cognito
Order: 1
AuthenticateCognitoConfig:
OnUnauthenticatedRequest: authenticate
UserPoolArn: "arn:aws:cognito-idp:ap-northeast-1:*****"
UserPoolClientId: "*****"
UserPoolDomain: "*****.auth.ap-northeast-1.amazoncognito.com"
- Type: forward
Order: 2
TargetGroupArn: !Ref ALBTargetGroup
LoadBalancerArn: !Ref ALB
Port: 443
Protocol: HTTPS
Certificates:
- CertificateArn: "arn:aws:acm:ap-northeast-1:*****:certificate/*****"
RecordSet:
Type: AWS::Route53::RecordSet
Properties:
Type: "A" # or AAAA (IPv6)
HostedZoneName: "foo.example.com."
Name: "auth-example.foo.example.com."
AliasTarget:
HostedZoneId: !GetAtt ALB.CanonicalHostedZoneID
DNSName: !GetAtt ALB.DNSName
Comment: "Added by servereless-alb-cognito-auth-example"
x-amzn-oidc-data HeaderにALBが署名したJWTトークンが入っているので、 それがALBから来ているものかを検証しclaimを参照できる。リクエストはAPI Gatewayと同じような形式。
func ParseToken(tokenString string) (jwt.MapClaims, error) {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// ES256
if _, ok := token.Method.(*jwt.SigningMethodECDSA); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
url := fmt.Sprintf("https://public-keys.auth.elb.ap-northeast-1.amazonaws.com/%s", token.Header["kid"])
resp, err := http.Get(url)
if err != nil {
return nil, err
}
body, err := ioutil.ReadAll(resp.Body)
return jwt.ParseECPublicKeyFromPEM(body)
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
return claims, nil
} else {
return nil, errors.New("token is invalid")
}
}
type Response events.APIGatewayProxyResponse
func Handler(request events.APIGatewayProxyRequest) (Response, error) {
var buf bytes.Buffer
_, err := ParseToken(request.Headers["x-amzn-oidc-data"])
if err != nil {
return Response{StatusCode: http.StatusUnauthorizeds}, err
}
resp := Response{
StatusCode: http.StatusOK,
Body: "ok",
}
return resp, nil
}