パスワードレスの cognito を運用する意味とは

# パスワードは忘れるためにあるのかしら

## 背景

最近新しいパソコンを11年ぶりに購入しました。ひとまず古いパソコンからデータを移行中。MAC なので手裏剣が動かない（それはさておき。
新しい端末を操作するとブラウザに保存されているパスワードを期待する自分がよく分かります。ブラウザでパスワードを引継ぎしない禁欲生活の自分を少し泳がしてみたところ
１. パスワードを忘れる
２. ログインできない
３. パスワードのリマインダを使って再発行する
４. ブラウザに保存する（駄目
なるほど。欲に溺れそうな予感。

アカウントにemailが紐づいていれば、リマインダが使えるから何とかなるのですね。

ていうか。それならパスワードいらなくない？

## Cognitoの認証チャレンジを利用する

Cognito にログインIDとパスワードを保存して運用しているシステム。そこそこあるかと思います。
やっぱり運用期間が長くて利用者が多くなればなるほど、パスワードを忘れる問合せしてくるお客さまって沢山いますよね。
お客さまのパスワードを確認することなんて最近できない仕組みが多いから基本再発行。

もちろん再発行するときにはメールアドレスやSMSとかで本人確認してからですよね。
みんなのCognitoにも同等の機能が標準装備されています。

ただ私が担当するシステムは本人確認をネットワークを介さず、目視と長年の勘のみで判断する高度セキュリティにより保護されています。
もしパスワードを忘れたとしても携帯持ち込み禁止なのでメール受信とかSMS配信とかできません。

ではどうやってその場でパスワードを再発行すれば。
もちろんご自宅に帰宅してもらってからパスワード再発行するなんて罰ゲームはなし。交通費も無駄ですし。

本題；そこは黙ってパスワードを利用せずにログイン、笑

## 悪者アプリケーションクライアント

Cognitoのアプリケーションクライアントを追加しましょう。


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3136487/770245f8-7205-4347-b41d-05da5a94cec4.png)

最近Cognitoの管理画面、またデザインが変わっているみたいです。

パスワードなしの画面なんて作りたくないので
Machine to Machineアプリケーションを選択。

ちなみにシングルページアプリケーションを選択した場合にのみ
クライアントシークレットなしのクライアントを作成可能です。

今回は少しでもセキュアなクライアントシークレットありで（どの口がw

ただ標準作成では駄目です。何もできない石の狸になります。
デフォルトではカスタム認証できないので設定変更。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3136487/702df81a-70d5-4549-a66c-18f92d6ed56a.png)

認証フローを少し変更；
・Lambdaトリガーからのカスタム認証フローを使用（ALLOW_CUSTOM_AUTH）を有効に。
・既存認証済セッションからユーザトークンを取得（ALLOW_REFRESH_TOKEN_AUTH）を無効に。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3136487/75f66f80-d43a-44d5-9707-be8fde389f8c.png)
いい感じ。

今回パスワードなしでログインするなんて悪行をするので
Cognito認証チャレンジを利用する必要があります。

Cognitoの管理画面にある「認証」から「拡張機能」を選択。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3136487/8425c0b9-9e04-475b-a971-4ec613132c35.png)

追加するボタン押す。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3136487/6904093a-8b1c-4b99-bcc7-eb9e4ff766b5.png)

カスタム認証で「認証チャレンジを定義」。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3136487/6aad6eb2-8ee0-4118-b2f1-0c19aa4bcf95.png)

追加するLambda関数はこんな感じ

<details><summary>devilman script</summary>

```python:devilman.py
import json
import logging

# Initialize logger
logger = logging.getLogger()
logger.setLevel(logging.INFO)

def lambda_handler(event, context):
    """AWS Lambda handler for defining authentication challenges."""
    logger.debug("DefineAuthChallenge event: %s", json.dumps(event))

    # -------------------------------------------------------------------------
    # Check for Devilman negotiation request
    # -------------------------------------------------------------------------
    if is_negotiation_request(event):
        logger.info("Negotiation request detected from Devilman.")
        # TODO: Handle negotiation logic here

    # -------------------------------------------------------------------------
    # Perform main devilman logic validation
    # -------------------------------------------------------------------------
    is_valid = evaluate_devil_logic(event)
    if not is_valid:
        # Unstable energy in horn ornament prevents beam emission
        raise ValueError(
            "Error: Devil Arrow launch aborted—unstable energy in horn ornament prevents beam emission."
        )

    # -------------------------------------------------------------------------
    # Authentication successful: issue tokens and set custom challenge
    # -------------------------------------------------------------------------
    event['response']['challengeName'] = 'CUSTOM_CHALLENGE'
    event['response']['issueTokens'] = True
    event['response']['failAuthentication'] = False

    return event

def is_negotiation_request(event):
    """Determine if the incoming request is a negotiation request from Devilman."""
    # Example criterion: a specific flag in the event payload
    return True
#    return event.get('devilmanNegotiation', False)

def evaluate_devil_logic(event):
    """Evaluate business rules to validate the authentication flow."""
    # Example rule: session must be a non-empty list
    # Additional rules can be added here
    return True
```
</details> 

必要に応じてデビル判定を追記してください。

## HMAC-SHA256の計算

クライアントシークレットを有効にしたアプリケーションクライアントを利用する場合、秘密鍵としてクライアントシークレットを利用した文字列の計算が必要です。

利用する値はCognitoに登録されているログインID（USERNAME）とクライアントIDです。
公式に記載の以下のスクリプトが参考になります。

<details><summary>devilman hash script</summary>

```python:devilman_hash.py
import sys
import hmac
import hashlib
import base64

def print_usage_and_exit():
    """Emit Devilman's decree of proper incantation and exit."""
    usage = (
        "Devilman Usage: Empowered by demonic forces, you must invoke me as follows:\n"
        "    python script.py <username> <app_client_id> <secret_key>\n"
        "Provide all three relics to forge the Secret Hash or face the abyss!"
    )
    print(usage)
    sys.exit(1)

# Ensure mortal inputs are complete or unleash wrath
if len(sys.argv) < 4:
    # Devilman warns of insufficient parameters
    print_usage_and_exit()

# Devilman Invocation: Unleash the secret hash ritual
# Gather mortal inputs: username, app client ID, and demonic key
username, app_client_id, key = sys.argv[1:4]

# Forge the message and key into byte-forged weapons
# Message charged with user and client sigil
message = (username + app_client_id).encode('utf-8')
# Key encoded in dark runes
key_bytes = key.encode('utf-8')

# Channel infernal power: compute HMAC-SHA256 digest
# This is the core of the Devil Arrow of authentication
digest = hmac.new(key_bytes, message, digestmod=hashlib.sha256).digest()

# Transmute the digest into Base64—an incantation for transport
secret_hash = base64.b64encode(digest).decode()

# Announce the conjured secret hash
print(f"Secret Hash for user '{username}': {secret_hash}")

```
</details>

以下のようにターミナルから実行すると変換できます。
```
$ python devilman_hash.py \
  taro.yamada \ ← これはCognitoのログインID
  1rflbgxxxxxxxxxxxxxxxxxxxx \ ← アプリケーションクライアントのクライアントID
  1dg0vg7qxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ← クライアントシークレット
Secret Hash for user 'taro.yamada': hNSr5Vxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx=
```

こんな感じです。

さあ。準備は完了しました。実行するチャンスだ。

## 正常系動作確認

アプリケーションクライアントで ALLOW_USER_PASSWORD_AUTH を有効にしてから確認。

・クライアントシークレットの判定
CLIだと認証パラメタにSECRET_HASHが必要です。指定しないとエラーになるのでOK。

```python
$ aws cognito-idp initiate-auth \
--auth-flow USER_PASSWORD_AUTH \
--auth-parameters \
USERNAME=taro.yamada,PASSWORD=password \
--client-id 1rflbgxxxxxxxxxxxxxxxxxxxx \
--region ap-northeast-1

An error occurred (NotAuthorizedException) when calling the InitiateAuth operation: Client 1rflbgxxxxxxxxxxxxxxxxxxxx is configured with secret but SECRET_HASH was not received
```

・パスワード間違い
クライアントシークレットを追加してからパスワードを間違えてみました。
ちゃんとパスワード間違いとなるのでOK
```python
$ aws cognito-idp initiate-auth \
--auth-flow USER_PASSWORD_AUTH \
--auth-parameters \
USERNAME=taro.yamada,PASSWORD=password,SECRET_HASH=hNSr5Vxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx= \
--client-id 1rflbgxxxxxxxxxxxxxxxxxxxx \
--region ap-northeast-1

An error occurred (NotAuthorizedException) when calling the InitiateAuth operation: Incorrect username or password.
```

・正しいログインIDとパスワードを指定
ちゃんとアクセストークンが返却されました。
```python
$ aws cognito-idp initiate-auth \
--auth-flow USER_PASSWORD_AUTH \
--auth-parameters \
USERNAME=taro.yamada,PASSWORD=password1,SECRET_HASH=hNSr5Vxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx= \
--client-id 1rflbgxxxxxxxxxxxxxxxxxxxx \
--region ap-northeast-1

{
    "ChallengeParameters": {
        "USERNAME": "taro.yamada"
    },
    "AuthenticationResult": {
        "AccessToken": "eyJraWQiOiI1VHNOUERXMUZ0a2R0S3RNRXlPd0lYQzR3azA1ellPSFVWaXJ3UFpDZWcwPSIsImFsZyI6IlJTMjU2In0 省略・・>",
        "ExpiresIn": 3600,
        "TokenType": "Bearer",
        "RefreshToken": "eyJjdHkiOiJKV1QiLCJlbmMiOiJBMjU2R0NNIiwiYWxnIjoiUlNBLU9BRVAifQ.cOl6a17w1cYiAW5N69jCpbTfeG- 省略・・>",
        "IdToken": "eyJraWQiOiJhYjdFTUNGS3ZydEdoOFA1KzNid3pPQnE3VkNjVVZHekpyV3l1OXpWUWVzPSIsImFsZyI6IlJTMjU2In0.eyJ 省略・・>"
    }
}
```

アクセストークンでログインした情報を取得することもできます。
ユーザ属性がデビル。年齢はデビルマンの掲載日開始です。
```python
$ aws cognito-idp get-user --region ap-northeast-1 --access-token "eyJraWQiOiI1VHNOUERXMUZ0a2R0S3RNRXlPd0lYQzR3azA1ellPSFVWaXJ3UFpDZWcwPSIsImFsZyI6IlJTMjU2In0 省略・・>"
{
    "Username": "taro.yamada",
    "UserAttributes": [
        {
            "Name": "custom:userType",
            "Value": "devil"
        },
        {
            "Name": "sub",
            "Value": "a88e68c2-afb3-4935-9947-xxxxxxxxxxxx"
        },
        {
            "Name": "custom:age",
            "Value": "1972"
        },
        {
            "Name": "email_verified",
            "Value": "true"
        },
        {
            "Name": "custom:author",
            "Value": "Go Nagai"
        }
    ]
}
```

## カスタム認証チャレンジの確認

認証チャレンジを利用するので認証フローは CUSTOM_AUTH を指定します。

・クライアントシークレットの判定
CLIだと認証パラメタにSECRET_HASHが必要です。指定しないとエラーになるのでOK。

```python
$ aws cognito-idp initiate-auth \
--auth-flow CUSTOM_AUTH \
--auth-parameters \
USERNAME=taro.yamada,PASSWORD=password \
--client-id 1rflbgxxxxxxxxxxxxxxxxxxxx \
--region ap-northeast-1

An error occurred (NotAuthorizedException) when calling the InitiateAuth operation: Client 1rflbgxxxxxxxxxxxxxxxxxxxx is configured with secret but SECRET_HASH was not received
```

・パスワード間違い
クライアントシークレットを追加してからパスワードを間違えてみました。
むむむ。アクセストークンが返却されてきたぞ。
```python
$ aws cognito-idp initiate-auth \
--auth-flow CUSTOM_AUTH \
--auth-parameters \
USERNAME=taro.yamada,PASSWORD=password,SECRET_HASH=hNSr5Vxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx= \
--client-id 1rflbgxxxxxxxxxxxxxxxxxxxx \
--region ap-northeast-1

{
    "ChallengeParameters": {
        "USERNAME": "taro.yamada"
    },
    "AuthenticationResult": {
        "AccessToken": "eyJraWQiOiI1VHNOUERXMUZ0a2R0S3RNRXlPd0lYQzR3azA1ellPSFVWaXJ3UFpDZWcwPSIsImFsZyI6IlJTMjU2In0 省略・・>",
        "ExpiresIn": 3600,
        "TokenType": "Bearer",
        "RefreshToken": "eyJjdHkiOiJKV1QiLCJlbmMiOiJBMjU2R0NNIiwiYWxnIjoiUlNBLU9BRVAifQ.cOl6a17w1cYiAW5N69jCpbTfeG- 省略・・>",
        "IdToken": "eyJraWQiOiJhYjdFTUNGS3ZydEdoOFA1KzNid3pPQnE3VkNjVVZHekpyV3l1OXpWUWVzPSIsImFsZyI6IlJTMjU2In0.eyJ 省略・・>"
    }
}
```

・パスワードなし
無論、パスワードなしでもアクセストークンが返却されます。
デビルマンは目視なのでパスワードなんて無視してますから、笑
```python
$ aws cognito-idp initiate-auth \
--auth-flow CUSTOM_AUTH \
--auth-parameters \
USERNAME=taro.yamada,SECRET_HASH=hNSr5Vxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx= \
--client-id 1rflbgxxxxxxxxxxxxxxxxxxxx \
--region ap-northeast-1

{
    "ChallengeParameters": {
        "USERNAME": "taro.yamada"
    },
    "AuthenticationResult": {
        "AccessToken": "eyJraWQiOiI1VHNOUERXMUZ0a2R0S3RNRXlPd0lYQzR3azA1ellPSFVWaXJ3UFpDZWcwPSIsImFsZyI6IlJTMjU2In0 省略・・>",
        "ExpiresIn": 3600,
        "TokenType": "Bearer",
        "RefreshToken": "eyJjdHkiOiJKV1QiLCJlbmMiOiJBMjU2R0NNIiwiYWxnIjoiUlNBLU9BRVAifQ.cOl6a17w1cYiAW5N69jCpbTfeG- 省略・・>",
        "IdToken": "eyJraWQiOiJhYjdFTUNGS3ZydEdoOFA1KzNid3pPQnE3VkNjVVZHekpyV3l1OXpWUWVzPSIsImFsZyI6IlJTMjU2In0.eyJ 省略・・>"
    }
}
```

アクセストークンでログインした情報を取得することもできます。

```python
$ aws cognito-idp get-user --region ap-northeast-1 --access-token "eyJraWQiOiI1VHNOUERXMUZ0a2R0S3RNRXlPd0lYQzR3azA1ellPSFVWaXJ3UFpDZWcwPSIsImFsZyI6IlJTMjU2In0 省略・・>"
{
    "Username": "taro.yamada",
    "UserAttributes": [
        {
            "Name": "custom:userType",
            "Value": "devil"
        },
        {
            "Name": "sub",
            "Value": "a88e68c2-afb3-4935-9947-xxxxxxxxxxxx"
        },
        {
            "Name": "custom:age",
            "Value": "1972"
        },
        {
            "Name": "email_verified",
            "Value": "true"
        },
        {
            "Name": "custom:author",
            "Value": "Go Nagai"
        }
    ]
}
```

・存在しないアカウントで判定

```python
$ aws cognito-idp initiate-auth \
--auth-flow CUSTOM_AUTH \
--auth-parameters \
USERNAME=devilman,SECRET_HASH=RBskwMIxxxxxxxxxxxxxxxxxxxxxxxxxxxx= \
--client-id 1rflbgxxxxxxxxxxxxxxxxxxxx \
--region ap-northeast-1

An error occurred (UserNotFoundException) when calling the InitiateAuth operation: User does not exist.
```

ユーザが存在するかどうかを返却するのはセキュリティ弱虫システムになるため
Lambda関数の設定を見直してください

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3136487/b4577331-3ae7-447f-8a8d-21b2a78432fd.png)

このチェックをいれるだけでユーザ存在エラーなんて返却しないようになります。

```python
$ aws cognito-idp initiate-auth \
--auth-flow CUSTOM_AUTH \
--auth-parameters \
USERNAME=devilman,SECRET_HASH=RBskwMIxxxxxxxxxxxxxxxxxxxxxxxxxxxx= \
--client-id 1rflbgxxxxxxxxxxxxxxxxxxxx \
--region ap-northeast-1

An error occurred (NotAuthorizedException) when calling the InitiateAuth operation: Incorrect username or password.
```

## まとめ

認証チャレンジの定義を１個追加するだけで普通にパスワードなんて使わない処理になります。
これだと「Cognitoを利用する意味なんてない」とか言わないこと。

https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-define-auth-challenge.html

Define auth challenge のみ設定すれば動いてしまうものの。
ちゃんと開発する場合は公式ガイドに記載のある通り開発担当者の負担が高まる実装になります。
パスワードを利用したくないなら、Cognitoなんて使うのやめて（駄目

:::note info
These three Lambda functions chain together to present an authentication mechanism that is completely within your control and of your own design. Because custom authentication requires application logic in your client and in the Lambda functions, you can't process custom authentication within managed login. This authentication system requires additional developer effort. Your application must perform the authentication flow with the user pools API and handle the resulting challenge with a custom-built login interface that renders the question at the center of the custom authentication challenge.
:::

パスワードレスでも、やる気レスにはならないように。
いつも心に太陽を♪