[小ネタ] curlで突破するALBユーザー認証(独自IdP)

概要

ALBのユーザー認証はOIDC準拠のIdProviderを設定でき、Authorization Code Grantをサポートしています。しかしAuthorization Codeでの認証の場合、Login Formを介してユーザーがログインを行う必要があります。今回はCLIのコマンドのみでなんとかできないかを模索します。基本的にブラウザでできることならばできないことはない(はず

Authorization Code Grantのおさらい

Developers.IOを読んでる方ならば理解していると思うので、特に不要かと思いますが、自分は物覚えがあまりよろしくないため、ALB+OIDCの認証処理フローシーケンスを図解しておきます。

認証情報はどうやって持ってるか

ALBとIdP間は、AuthCodeで引き換えたIdTokenを利用しているようです。ではALBに接続するクライアントは何をもって認証済みとしているのでしょうか。

ALBは接続するクライアントとの認証済みかどうかはCookieを利用して判断しています。CookieをRequestに載せれば認証済みとしてIdPのログイン画面は表示しません。

クリアすべき課題を理解する

CLIのコマンドで認証を突破するには以下が必要です。

  1. Redirectを理解し、Locationで指定されたURLへ遷移してくれる
  2. Cookieが保持できる

どちらもcurlのコマンドオプションを使えば可能そうです。

前提

  • コンテンツサーバーをバックエンドにもつALBのドメイン: sampledomain.net
  • IdProviderのログインサーバー: logindomain.net

ログインフォームまで遷移する

まずはALBで認証が必要なコンテンツのURLを指定しGETする。

$ curl -c <export_cookie_path> -v -L -X GET 'https://sampledomain.net/contents/index.html'
  • -c 指定したパスへCookie情報を書き出す
  • -v verboseオプションなのでDEBUG時に指定しているだけ。不要なら外してOK
  • -L ステータスコード3xxが返却された際にLocationで指定されたURLへ自動的に遷移する。これがないと302のレスポンスを受け取って止まってしまう。
$ curl -c <export_cookie_path> -v -L -X GET 'https://sampledomain.net/contents/index.html'
....
> GET /contents/index.html HTTP/1.1
> Host: sampledomain.net
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 302 Moved Temporarily
< Server: awselb/2.0
< Date: Sat, 01 Sep 2018 16:08:12 GMT
< Content-Type: text/html
< Content-Length: 126
< Connection: keep-alive
< Location: https://logindomein.net/oauth/authorize?client_id=yyyyyyyy&redirect_uri=https%3A%2F%2Fsampledomain.net%2Foauth2%2Fidpresponse&response_type=code&scope=openid&state=ydCHcdIQMsdPagksxSLph8rQyzzy2I0OL1YRVFjWMQJlK8tkCu6mOrxCg2t%2Br9jULiX3mX8Z6gAy1tclVBWi4M8TTHIosg4gdSYP21YPXGX03GkIyYlgMfOhy8ePKu8bwn%2BeQJ6M2ZN5M7IdfNbGqhBf4HogUXfM4pFo07H3%2BuMnP4dd%2FVAUPSVb6t6WmtLGkdqDR8TmmCqdNYsmQ92ugispZGlSlnU5wiP16YmxHRQ%3D
<
* Ignoring the response-body
* Connection #0 to host sampledomain.net left intact
* Issue another request to this URL: 'https://logindomain.net/oauth/authorize?client_id=yyyyyyyy&redirect_uri=https%3A%2F%2Fsampledomain.net%2Foauth2%2Fidpresponse&response_type=code&scope=openid&state=ydCHcdIQMsdPagksxSLph8rQyzzy2I0OL1YRVFjWMQJlK8tkCu6mOrxCg2t%2Br9jULiX3mX8Z6gAy1tclVBWi4M8TTHIosg4gdSYP21YPXGX03GkIyYlgMfOhy8ePKu8bwn%2BeQJ6M2ZN5M7IdfNbGqhBf4HogUXfM4pFo07H3%2BuMnP4dd%2FVAUPSVb6t6WmtLGkdqDR8TmmCqdNYsmQ92ugispZGlSlnU5wiP16YmxHRQ%3D'
.....
> GET /oauth/authorize?client_id=yyyyyyyy&redirect_uri=https%3A%2F%2Fsampledomain.net%2Foauth2%2Fidpresponse&response_type=code&scope=openid&state=ydCHcdIQMsdPagksxSLph8rQyzzy2I0OL1YRVFjWMQJlK8tkCu6mOrxCg2t%2Br9jULiX3mX8Z6gAy1tclVBWi4M8TTHIosg4gdSYP21YPXGX03GkIyYlgMfOhy8ePKu8bwn%2BeQJ6M2ZN5M7IdfNbGqhBf4HogUXfM4pFo07H3%2BuMnP4dd%2FVAUPSVb6t6WmtLGkdqDR8TmmCqdNYsmQ92ugispZGlSlnU5wiP16YmxHRQ%3D HTTP/1.1
> Host: logindomain.net
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 302
< Date: Sat, 01 Sep 2018 16:08:13 GMT
< Content-Length: 0
< Connection: keep-alive
< Request-Id: e6878b08-e32c-4b8c-b412-2e8bbb174a1c
< X-Content-Type-Options: nosniff
< X-XSS-Protection: 1; mode=block
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< Strict-Transport-Security: max-age=31536000 ; includeSubDomains
* Added cookie SAMPLE_SESSIONID="7191f020-25ec-4060-869a-4a7c43c6b867" for domain logindomain.net, path /, expire 0
< Set-Cookie: SAMPLE_SESSIONID=7191f020-25ec-4060-869a-4a7c43c6b867; Path=/; Secure; HttpOnly
< Location: https://logindomain.net/login
<
* Connection #1 to host logindomain.net left intact
* Issue another request to this URL: 'https://logindomain.net/login'
* Found bundle for host logindomain.net: xxxxxxxxxx [can pipeline]
* Re-using existing connection! (#1) with host logindomain.net
* Connected to logindomain.net (??.??.???.??) port 443 (#1)
> GET /login HTTP/1.1
> Host: logindomain.net
> User-Agent: curl/7.54.0
> Accept: */*
> Cookie: SAMPLE_SESSIONID=7191f020-25ec-4060-869a-4a7c43c6b867
>
< HTTP/1.1 200
< Date: Sat, 01 Sep 2018 16:08:13 GMT
< Content-Type: text/html;charset=UTF-8
< Transfer-Encoding: chunked
< Connection: keep-alive
< Request-Id: 75ac612c-d8f8-4992-a2ef-e6bf670ae1a2
< X-Content-Type-Options: nosniff
< X-XSS-Protection: 1; mode=block
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< Strict-Transport-Security: max-age=31536000 ; includeSubDomains
< Content-Language: en
<
<!DOCTYPE html>
<!--

    Copyright 2015-2016 Classmethod, Inc.
    All Rights Reserved.
...
-->
<html xmlns="http://www.w3.org/1999/xhtml" lang="ja">
....
<div class="container">
    <section class="login-widget">
        <header class="text-center">
            <h1>Login <small>with LoginIdP</small></h1>
            <p>Note: This page's design is configurable.</p>
        </header>
        <div class="body">
            ....
                <div class="form-group form-actions">
                    <button id="login" class="btn btn-primary btn-block btn-lg" type="submit">
                        <i class="fa fa-chevron-circle-right"></i>
                        <span>Login</span>
                    </button>
                    <input type="hidden" id="csrf_token" name="_csrf" value="xxxxxxxx-vvvv-eeee-ffff-aaaaaaaaaaaa" />
                    <input type="hidden" name="authenticity_token" />
                </div>
            </form>

        </div>
    </section>
</div>
....
</body>
</html>

ログインフォームまで取得できました。ログインセッションを確立します。

ログイン情報をPOSTする

続いてログインフォームにはIdPに登録してあるユーザー名とパスワードを入力します、

$ curl -b <cookie_file_path> -c <export_cookie_path> -v -L -F "username=<login_id>" -F "password=<hogehoge>" -F "_csrf=xxxxxxxx-vvvv-eeee-ffff-aaaaaaaaaaaa" "https://logindomain.net/login"
  • -b 先程作成したCookieファイルのパスを指定する。これによりCookieをRequestに載せることができる
  • -F POSTする際の入力パラメータを指定する。特にURLエンコード不要。

ログインID、パスワードの他にCSRFトークンを送付します。

CSRFパラメータに関してはログインフォームの設定次第と言えるので必須ではないかもしれません。注意すべきはパラメータIDではなくパラメータ名を指定することです。 csrf_token をパラメータに指定してもInvalid Requestとして処理されてしまいます。ここだけは何らかの形でHTMLをパースして値を取得する必要があります。

> POST /login HTTP/1.1
> Host: logindomain.net
> User-Agent: curl/7.54.0
> Accept: */*
> Cookie: SAMPLE_SESSIONID=7191f020-25ec-4060-869a-4a7c43c6b867
> Content-Length: 416
> Expect: 100-continue
> Content-Type: multipart/form-data; boundary=------------------------1751f050fb4e86a1
>
< HTTP/1.1 100 Continue
< HTTP/1.1 302
< Date: Sat, 01 Sep 2018 16:26:39 GMT
< Content-Length: 0
< Connection: keep-alive
< Request-Id: 71a2745b-bd7f-40ab-b4c4-1a000d2aa34a
< X-Content-Type-Options: nosniff
< X-XSS-Protection: 1; mode=block
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< Strict-Transport-Security: max-age=31536000 ; includeSubDomains
* Replaced cookie SAMPLE_SESSIONID="7191f020-25ec-4060-869a-4a7c43c6b867" for domain logindomain.net, path /, expire 0
< Set-Cookie: SAMPLE_SESSIONID=7191f020-25ec-4060-869a-4a7c43c6b867; Path=/; Secure; HttpOnly
< Location: https://logindomain.net/oauth/authorize?client_id=yyyyyyyyyy&redirect_uri=https%3A%2F%2Fsampledomain.net%2Foauth2%2Fidpresponse&response_type=code&scope=openid&state=9JOBhOFg73lg9HpQbcYFHM1BSewdp1AVoHXj%2BeYEaVZeOZi2ij%2B%2F26KORyejTjz4cPp0QtINWzmyfDoDjty1yjaXpmg4YKfYP87XDT88cPH%2FyQA82RnuRFcZPAPDILh0Otw97l3%2BxY0I5kTte6kfaFvwFYOpfT%2Fyb4Isg%2BlBcAj9U%2BAZbVRvs7QbP1xyM0pmEg%2F8gZHJSwBwH6ZE3jDXU6a3hm7YD%2Fyr2SHz8ph%2FO5s%3D
* HTTP error before end of send, stop sending
<
* Closing connection 0

長いので分割します。まずはじめにAuthorizeエンドポイントへのRedirectがレスポンスとして返ってきます。

* Issue another request to this URL: 'https://logindomain.net/oauth/authorize?client_id=yyyyyyyyyy&redirect_uri=https%3A%2F%2Fsampledomain.net%2Foauth2%2Fidpresponse&response_type=code&scope=openid&state=9JOBhOFg73lg9HpQbcYFHM1BSewdp1AVoHXj%2BeYEaVZeOZi2ij%2B%2F26KORyejTjz4cPp0QtINWzmyfDoDjty1yjaXpmg4YKfYP87XDT88cPH%2FyQA82RnuRFcZPAPDILh0Otw97l3%2BxY0I5kTte6kfaFvwFYOpfT%2Fyb4Isg%2BlBcAj9U%2BAZbVRvs7QbP1xyM0pmEg%2F8gZHJSwBwH6ZE3jDXU6a3hm7YD%2Fyr2SHz8ph%2FO5s%3D'
* Switch from POST to GET
* Hostname logindomain.net was found in DNS cache
.....
> GET /oauth/authorize?client_id=yyyyyyyyyy&redirect_uri=https%3A%2F%2Fsampledomain.net%2Foauth2%2Fidpresponse&response_type=code&scope=openid&state=9JOBhOFg73lg9HpQbcYFHM1BSewdp1AVoHXj%2BeYEaVZeOZi2ij%2B%2F26KORyejTjz4cPp0QtINWzmyfDoDjty1yjaXpmg4YKfYP87XDT88cPH%2FyQA82RnuRFcZPAPDILh0Otw97l3%2BxY0I5kTte6kfaFvwFYOpfT%2Fyb4Isg%2BlBcAj9U%2BAZbVRvs7QbP1xyM0pmEg%2F8gZHJSwBwH6ZE3jDXU6a3hm7YD%2Fyr2SHz8ph%2FO5s%3D HTTP/1.1
> Host: logindomain.net
> User-Agent: curl/7.54.0
> Accept: */*
> Cookie: SAMPLE_SESSIONID=7191f020-25ec-4060-869a-4a7c43c6b867
>
< HTTP/1.1 302
< Date: Sat, 01 Sep 2018 16:26:39 GMT
< Content-Length: 0
< Connection: keep-alive
< Request-Id: 73dedf6a-a207-456d-b842-a8ca281326f8
< Cache-Control: no-store
< X-Content-Type-Options: nosniff
< X-XSS-Protection: 1; mode=block
< Strict-Transport-Security: max-age=31536000 ; includeSubDomains
< Location: https://sampledomain.net/oauth2/idpresponse?code=KvE5SN&state=9JOBhOFg73lg9HpQbcYFHM1BSewdp1AVoHXj%2BeYEaVZeOZi2ij%2B/26KORyejTjz4cPp0QtINWzmyfDoDjty1yjaXpmg4YKfYP87XDT88cPH/yQA82RnuRFcZPAPDILh0Otw97l3%2BxY0I5kTte6kfaFvwFYOpfT/yb4Isg%2BlBcAj9U%2BAZbVRvs7QbP1xyM0pmEg/8gZHJSwBwH6ZE3jDXU6a3hm7YD/yr2SHz8ph/O5s%3D
< Content-Language: en
<

AuthorizeエンドポイントへはGETになるため、POSTからGETへの変更が記録されています。ALBの認証で保護されているドメインの /oauth2/idpresponse へのRedirectレスポンスが返却されます。さらに移動。

* Connection #1 to host logindomain.net left intact
* Issue another request to this URL: 'https://sampledomain.net/oauth2/idpresponse?code=xxxxxx&state=9JOBhOFg73lg9HpQbcYFHM1BSewdp1AVoHXj%2BeYEaVZeOZi2ij%2B/26KORyejTjz4cPp0QtINWzmyfDoDjty1yjaXpmg4YKfYP87XDT88cPH/yQA82RnuRFcZPAPDILh0Otw97l3%2BxY0I5kTte6kfaFvwFYOpfT/yb4Isg%2BlBcAj9U%2BAZbVRvs7QbP1xyM0pmEg/8gZHJSwBwH6ZE3jDXU6a3hm7YD/yr2SHz8ph/O5s%3D'
....
> GET /oauth2/idpresponse?code=xxxxxx&state=9JOBhOFg73lg9HpQbcYFHM1BSewdp1AVoHXj%2BeYEaVZeOZi2ij%2B/26KORyejTjz4cPp0QtINWzmyfDoDjty1yjaXpmg4YKfYP87XDT88cPH/yQA82RnuRFcZPAPDILh0Otw97l3%2BxY0I5kTte6kfaFvwFYOpfT/yb4Isg%2BlBcAj9U%2BAZbVRvs7QbP1xyM0pmEg/8gZHJSwBwH6ZE3jDXU6a3hm7YD/yr2SHz8ph/O5s%3D HTTP/1.1
> Host: sampledomain.net
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 302 Moved Temporarily
< Server: awselb/2.0
< Date: Sat, 01 Sep 2018 16:26:40 GMT
< Content-Type: text/html
< Content-Length: 126
< Connection: keep-alive
* Added cookie AWSELBAuthSessionCookie-0="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" for domain sampledomain.net, path /, expire 3072243200
< Set-Cookie: AWSELBAuthSessionCookie-0=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; Expires=Tue, 10 May 2067 08:53:20 GMT; Path=/; Secure; HttpOnly
< Location: https://sampledomain.net/contents/index.html
<

/oauth2/idpresponsesampledomain.net のパスの一部ですがサーバー側が実装をする必要はなくこれらはALBの中で勝手に処理されます。Authorization CodeとIdTokenの引き換えを裏でやっているようです。レスポンスを確認すると AWSELBAuthSessionCookie-0 というKeyでCookieの値が追加されているのがわかります。実際のIdTokenは見えていませんが、これがALBの認証情報になります。

最後に目的のContentsのURLへのRedirectレスポンスが返却されます。これで認証処理をパスすることができました。

認証後について

認証後については、ALBが発行したCookie情報をリクエストに付与することで認証済みとして、ログインフォームへのRedirectは行われず目的のコンテンツへのアクセスが可能になります。

$ curl -v -L -c login-cookie.txt -X GET 'https://sampledomain.net/contents/index.html'

何回かRedirectが返却されますが、ログインフォームによって途切れることなく目的のコンテンツを取得できます。

トラブルシューティング

Clientのscopeにopenidが指定されていない

ALBのユーザー認証はOpenIDを利用します。そのため、ALBに設定したClientのscopeに openid が指定されていなければinvalid_scopeとしてALBからエラーが返却されます。この場合は、ALBの裏のコンテンツサーバーには通信がいきません。

> GET /oauth2/idpresponse?error=invalid_scope&error_description=Invalid%20scope:%20openid&state=IZH5tTP2I/VNTooazmVx2JfqtnUXuL5LmBh0basx4POiqP0d4oL61M0S3dtRzQypx29ouT6V%2BmCmRXmBEgLlJ0mQx4d/NfIoYILgDgG%2BtMANr0cePdAUK1on1Ws4Cd0Uaa5TTC28tNTrIvmInWnH/IJEirF0vb7VyINY1OJxqQESXn8P%2BPe2LRieQTBgCzso72WwFxG2WHg33zyA9pBgZNc0eMlMo%2BOAqvveRv%2BNnmWWqs6lmxKgJpy0&scope=root HTTP/1.1
> Host: sampledomain.net
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 561
< Server: awselb/2.0
< Date: Sun, 02 Sep 2018 15:57:28 GMT
< Content-Type: text/html
< Content-Length: 156
< Connection: keep-alive
<
<html>
<head><title>561 Authentication Error</title></head>
<body bgcolor="white">
<center><h1>561 Authentication Error</h1></center>
</body>
</html>

Redirect先のURLの中にQueryとしてエラーメッセージが出力されています。

error=invalid_scope&error_description=Invalid%20scope:%20openid

Clientのscopesが正しいかの確認します。

RequestにCSRFを指定してない

RequestにCSRFトークンを指定しない場合、403 Forbiddenのレスポンスコードが返却されます。

< HTTP/1.1 403
< Date: Sun, 02 Sep 2018 15:46:14 GMT
< Content-Type: application/json;charset=UTF-8
< Transfer-Encoding: chunked
< Connection: keep-alive
< Request-Id: 1868a63b-7c02-42f7-9739-5601f6065b02
< X-Content-Type-Options: nosniff
< X-XSS-Protection: 1; mode=block
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< Strict-Transport-Security: max-age=31536000 ; includeSubDomains
* HTTP error before end of send, stop sending
<
* Closing connection 0
{"timestamp":1535903174903,"path":"/login","status":403,"error":"access_denied","error_description":"No message available"}

IdProviderのログを確認してみると、CSRFトークンの検証に失敗しているのがわかります。

{
    "timestamp": "2018-09-03T00:46:14.894000+0900",
    "level": "DEBUG",
    "thread": "http-nio-8080-exec-2",
    "mdc": {
        "req.requestURI": "/login",
        "req.xForwardedFor": null,
        "req.queryString": null,
        "requestId": "1868a63b-7c02-42f7-9739-5601f6065b02",
        "req.method": "POST",
        "req.requestURL": "https://logindomain.net/login",
        "req.userAgent": "curl/7.54.0"
    },
    "logger": "org.springframework.security.web.csrf.CsrfFilter",
    "message": "Invalid CSRF token found for https://logindomain.net/login",
    "context": "default",
    "service": "IdProvider",
    "log_type": "app"
}

まとめ

元々ブラウザで操作することを想定していたのですが、諸事情からCLIのコマンドのみでなんとかならないかという以来を受けたため調査してみました。curlを使うことで改めてAuthorization Code Grant Flowを確認することができ、IdToken発行までのシーケンスについての理解が深まったかと思います。

ということでALB+ユーザー認証は簡単なコマンドのみでなんとかなる、という結論でした。

それでは皆様ごきげんよう。

参照