Caffeine Inject

研究のこととかUnityのこととか書く

UnityからAWS Cognito Identity Providerで認証機能を実装する(User_Auth_Flow)

前置き:AWSSDK for Unityでは現状,公式にCognito Identity Providerの機能をサポートしていません.紹介した方法は自己責任で参考にしてください.

UnityからCognito User Poolを使って認証する

この記事ではAWSのCognito User Poolを使用してログイン機能をUnityのアプリケーションに実装し,AWSのリソースにアクセスするためのトークンを取得する方法を紹介します.

Cognito User Poolとは

https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/cognito-user-identity-pools.html

自分でサーバー側の実装を書くことなくユーザー認証機能を実装できます.

手順

環境

AWSの設定

User Poolの作成と設定

UserPool.png

Cognitoからユーザーを管理するためのUser Poolを作成し,以下のように設定します.

  • プール名
    • わかりやすい任意の名前を設定します.
  • 属性
    • 標準属性のチェックを全て外し,ユーザー名とパスワードだけでログインできるように設定します.
  • アプリクライアントの追加
    • "アプリクライアントの追加"をクリックします.
    • アプリクライアント名を設定します.
    • "クライアントシークレットを生成する"をオフにします.

設定し終わったら"確認タブ"からプール作成を終了し,

  • プールIDとプール名 (リージョン名と固有なプール名の組み合わせ)
    • 例) ap-northeast-1_Hoge0Hoge を確認します.

さらにアプリの統合>アプリクライアントの設定タブから,

  • "有効なIDプロバイダ"のチェック欄に表示されている"Cognito User Pool"にチェックを入れます.
  • アプリクライアントID (25桁のAlphanumericな値)を確認します.

Identity Poolの作成と設定

IdentityPool.png

Cognitoから"フェデレーテッドアイデンティティ"のページに移動し,"新しいIDプールの作成"をクリックするとIDプール作成ウィザードが開始します.

  • IDプール名を入力します.
  • "認証されていないIDに対してアクセスを有効にする"をオンにするとゲストユーザーとしてアプリを使用することが可能です.ここでは必要ないのでオフのままで大丈夫です.
  • 認証プロバイダーのセクションで"Cognito"タブをクリックし,先ほど確認したユーザープールのIDとアプリクライアントIDを入力します.

Createpolicy.png

"プールの作成"をクリックすると,"AWSの諸々にアクセスするための権限の設定をする必要がある"ということで,ゲストユーザーと認証ユーザーの二つのユーザーについてIAMロールを設定するように言われます.

ここではどちらも"新しいIAMロールの作成"を選択し,そのまま右下の許可を押します.ここで作成した二つのIAMロール(Cognito_*Hoge*Auth_RoleCognito_*Hoge*Unauth_Role)はあとで編集するので名前を覚えておきます.

IAMロールの編集

先ほど作成したロールの権限を編集し,Cognito User Poolに対して認証リクエストを送る権限を付与します.

CogitoIdentityProvider.dll for Unityのビルド

AWS Mobile SDK for UnityではCognito User Poolがサポートされておらず,自分でCognitoIdentityProvider.dllをビルドする必要があります.dotNet版のコードジェネレーターの設定をいじることでUnityに対応したdllを得ることができます.

GitHubのissueに具体的な方法が書かれており,それを翻訳すると以下のようになります.

  1. AWS SDK dot netのリポジトリをクローンします.

  2. aws-sdk-net\generator\ServiceModels\cognito-idp\metadata.jsonを編集し,"platforms": ["Unity"],jsonのrootに追加します.

  3. aws-sdk-net\generator\ServiceClientGeneratorLib/Generators/ProjectFiles/UnityProjectFile.csの265行目でUnityEngine.dllのパスを修正します. UnityEngine.dllの場所はMacOSWindowsで異なるので注意.ここではWindowsでビルドします.修正後のパスはC:/Program Files/Unity/Editor/Data/Managed/UnityEngine.dllとなります.

  4. aws-sdk-net\generator\AWSSDKGenerator.slnVisual Studioで開きます.実行ボタン(上の緑の三角ボタン)を押すとビルドされたコード生成プログラムが実行され,上手くいけばaws-sdk-net\sdk\src\Services\CognitoIdentityProvider内部にAWSSDK.CognitoIdentityProvider.Unity.csprojというファイルが生成されます.

  5. 次にaws-sdk-net\sdk\AWSSDK.Unity.slnを開き,Build TypeをReleaseに設定してからビルド(ctrl + shift + B)します.すると新しくaws-sdk-net\sdk\src\Services\CognitoIdentityProvider\bin\Release\unityフォルダが作られ,中にCognitoIdentityProvider.dllが生成されます.

  6. UnityプロジェクトのAssets/下任意の場所にdllを置きます.

{
  "platforms": ["Unity"],
  "active": true, 
  "synopsis": "You can create a user pool in Amazon Cognito Identity to manage directories and users. You can authenticate a user to obtain tokens related to user identity and access policies. This API reference provides information about user pools in Amazon Cognito Identity, which is a new capability that is available as a beta."
}
...
 this.Write(this.ToStringHelper.ToStringWithCulture(Path.Combine((string)this.Session["UnityPath"], "Editor", "Data", "Managed", "UnityEngine.dll")));
...

Unityで認証フローを実装する

AWS Mobile SDK for Unityを公式サイトからダウンロードし,AWSSDK.IdentityManagement.unitypackageをプロジェクトにインポートします.

以下,ブログエントリーを参考にして認証フローと暗号化処理を実装します.

AdminInitiateAuth関数を使った方法が紹介されることが多いですが,パスワードが平文で送信されるのでモバイルアプリで用いるにはセキュリティ的に非常に危険です.今回はSecure Remote Password (SRP)プロトコルをつかった認証フローであるUSER_SRP_AUTHに従います.

暗号化の実装はブログエントリーが詳しく,また依存するHkdfクラスの実装もGistに公開されているものが完動するのでコピペします.Bouncy Castle C#のライブラリが必要なのでBouncy Castle C#のGitHubリポジトリから最新リリースをダウンロードし,.net2.0版のdllをプロジェクトにコピーします.

注意すべき点として, 作成したばかりのユーザーのステータスはFORCE_CHANGE_PASSWORDと設定されており,User Poolからのレスポンス(チャレンジ)として新しいパスワードを要求してきます.下のプログラムではまだ実装しておらず,パスワード変更済みのユーザーに対してログイン処理をかけています.

認証フローが成功すると * IdToken * AccessToken * RefreshToken の三つが手に入ります.

得られたIdトークンを使ってCredential.AddLogins(IdentityProviderName , IdToken)することで,GetCredentialForIdentityAsync関数を実行した時にAWSAPIを叩く上で必要なトークンを得ることができます.このトークンを使って認可されるアクション,アクセスできるリソースはIAMで設定したCognito_HogeAuth_Roleに従います.

リフレッシュトークンはPlayerPrefなどに保存し,アプリを起動した際にIdトークンを更新することで自動ログイン処理を行います.

namespace CognitoLogInSample
{
    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    using UnityEngine.UI;
    using System;
    using System.Globalization;
    using Amazon;
    using Amazon.Runtime;
    using Amazon.CognitoIdentity;
    using Amazon.CognitoIdentity.Model;
    using Amazon.CognitoIdentityProvider;
    using Amazon.CognitoIdentityProvider.Model;

    public class CognitoUserPoolClient : MonoBehaviour
    {
        #region CognitoCredentials
        public string IdentityPoolId;            // IDプールのID
        public string CognitoIdentityRegion;     // リージョン名 例)ap-northeast-1

        private RegionEndpoint _CognitoIdentityRegion
        {
            get { return RegionEndpoint.GetBySystemName(CognitoIdentityRegion); }
        }
        private CognitoAWSCredentials _credentials;

        private CognitoAWSCredentials Credentials
        {
            get
            {
                if (_credentials == null)
                {
                    _credentials = new CognitoAWSCredentials(IdentityPoolId, _CognitoIdentityRegion);
                    _credentials.IdentityChangedEvent += Credentials_IdentityChangedEvent;
                }
                return _credentials;
            }
        }
        #endregion

        #region CognitoIdP
        public string CognitoIdPRegion;          // User Poolのリージョン 例)ap-northeast-1

        private RegionEndpoint _CognitoIdPRegion
        {
            get { return RegionEndpoint.GetBySystemName(CognitoIdPRegion); }
        }

        [SerializeField]
        string userPoolName;                     // User Poolの固有名 アンダーバーで区切ったうちの後半
        [SerializeField]
        string clientId;                         // User PoolのクライアントID

        string UserPoolId
        {
            get
            {
                return string.Format("{0}_{1}", _CognitoIdPRegion, userPoolName);
            }
        }

        public string CognitoIdentityProviderName
        {
            get
            {
                return string.Format("cognito-idp.{0}.amazonaws.com/{1}", _CognitoIdentityRegion.SystemName, UserPoolId);
            }
        }
        #endregion

        AmazonCognitoIdentityProviderClient idpClient;
        AmazonCognitoIdentityClient cognitoIdentityClient;

        public InputField IdInputField;
        public InputField passwordInputField;
        public Toggle clearCredentialToggle;

        TokenCacheManager tokenCacheManager;
        public bool cleanPlayerPrefSetting;

        string currentSession;
        string currentUserName;

        private void Start()
        {
            tokenCacheManager = new TokenCacheManager();
            if (cleanPlayerPrefSetting)
            {
                tokenCacheManager.DeleteCachedToken();
            }
            //AdminAuthenticateWithRefreshToken();
            SignInWithRefreshToken();
        }

        public void OnButtonClick()
        {
            if (clearCredentialToggle.isOn)
            {
                Credentials.Clear();
            }
            SignIn(IdInputField.text, passwordInputField.text);
        }

        #region InitiateAuthFlow

        /// <summary>
        /// Signs In with refresh token (USER_AUTH_FLOW).
        /// </summary>
        void SignInWithRefreshToken()
        {
            tokenCacheManager.GetCachedTokens((getCachedTokensResult) =>
            {
                if (getCachedTokensResult.IsCacheAvailable)
                {
                    Debug.Log(getCachedTokensResult.Token.ToString());
                    // RefreshToken
                    idpClient = new AmazonCognitoIdentityProviderClient(Credentials, _CognitoIdentityRegion);
                    InitiateAuthRequest initiateAuthRequest = new InitiateAuthRequest()
                    {
                        ClientId = clientId,
                        AuthFlow = AuthFlowType.REFRESH_TOKEN_AUTH,
                    };
                    initiateAuthRequest.AuthParameters.Add("REFRESH_TOKEN", getCachedTokensResult.Token.refreshToken);

                    idpClient.InitiateAuthAsync(initiateAuthRequest, (initiateAuthResponse) =>
                    {
                        if (initiateAuthResponse.Exception != null) return;
                        CognitoIdentityProviderToken cognitoIdentityProviderToken = new CognitoIdentityProviderToken
                        {
                            accessToken = initiateAuthResponse.Response.AuthenticationResult.AccessToken,
                            idToken = initiateAuthResponse.Response.AuthenticationResult.IdToken ?? getCachedTokensResult.Token.idToken,
                            refreshToken = initiateAuthResponse.Response.AuthenticationResult.RefreshToken ?? getCachedTokensResult.Token.refreshToken,
                            expireTime = initiateAuthResponse.Response.AuthenticationResult.ExpiresIn
                        };
                        tokenCacheManager.CacheTokens(cognitoIdentityProviderToken);

                        Credentials.AddLogin(CognitoIdentityProviderName, initiateAuthResponse.Response.AuthenticationResult.IdToken);
                        Credentials.GetIdentityIdAsync(responce =>
                        {
                            Debug.Log("Logged In with refreshed IdToken : " + responce.Response);
                        });
                    });
                    idpClient.Dispose();
                }
                else
                {
                    Credentials.Clear();
                    // Redirect to LogIn Dialog
                    Debug.Log("RefreshToken is not available");
                }
            });
        }


        /// <summary>
        /// Sign In.
        /// </summary>
        /// <param name="userName">User name.</param>
        /// <param name="password">Password.</param>
        void SignIn(string userName, string password)
        {
            Debug.Log("Initiate Authentication Flow");

            var cred = new AnonymousAWSCredentials();

            idpClient = new AmazonCognitoIdentityProviderClient(cred, _CognitoIdentityRegion);
            var TupleAa = AuthenticationHelper.CreateAaTuple();

            var initiateAuthRequest = new InitiateAuthRequest
            {
                AuthFlow = AuthFlowType.USER_SRP_AUTH,
                ClientId = clientId,
                AuthParameters = new Dictionary<string, string>(){
                            {"USERNAME", userName},
                            {"SRP_A", TupleAa.Item1.ToString(16)},
                        }
            };

            Debug.Log(initiateAuthRequest.AuthParameters["SRP_A"]);

            idpClient.InitiateAuthAsync(initiateAuthRequest, (initiateAuthResponse) =>
            {
                var challengeName = initiateAuthResponse.Response.ChallengeName;
                if (challengeName == ChallengeNameType.NEW_PASSWORD_REQUIRED)
                {
                    // newPasswordRequired
                    idpClient.Dispose();
                }
                else if (challengeName == ChallengeNameType.PASSWORD_VERIFIER)
                {
                    DateTime timestamp = TimeZoneInfo.ConvertTimeToUtc(DateTime.Now);
                    var usCulture = new CultureInfo("en-US");
                    string timeStr = timestamp.ToString("ddd MMM d HH:mm:ss \"UTC\" yyyy", usCulture);

                    byte[] claim = AuthenticationHelper.authenticateUser(initiateAuthResponse.Response.ChallengeParameters["USERNAME"],
                                                                         password,
                                                                         userPoolName,
                                                                         TupleAa,
                                                                         initiateAuthResponse.Response.ChallengeParameters["SALT"],
                                                                         initiateAuthResponse.Response.ChallengeParameters["SRP_B"],
                                                                         initiateAuthResponse.Response.ChallengeParameters["SECRET_BLOCK"],
                                                                         timeStr
                                                                        );
                    string claimBase64 = Convert.ToBase64String(claim);

                    var respondToAuthChallengeRequest = new RespondToAuthChallengeRequest()
                    {
                        ChallengeName = initiateAuthResponse.Response.ChallengeName,
                        ClientId = clientId,
                        ChallengeResponses = new Dictionary<string, string>(){
                                    { "PASSWORD_CLAIM_SECRET_BLOCK", initiateAuthResponse.Response.ChallengeParameters["SECRET_BLOCK"]},
                                    { "PASSWORD_CLAIM_SIGNATURE", claimBase64 },
                                    { "USERNAME", userName },
                                    { "TIMESTAMP", timeStr }
                        }
                    };

                    Debug.Log(timeStr);

                    idpClient.RespondToAuthChallengeAsync(respondToAuthChallengeRequest, respondToAuthChallengeResponse =>
                   {
                       try
                       {
                           Debug.LogFormat("User was verified in SRP Auth Flow : {0}", respondToAuthChallengeResponse.Response.AuthenticationResult.IdToken);
                           Credentials.AddLogin(CognitoIdentityProviderName, respondToAuthChallengeResponse.Response.AuthenticationResult.IdToken);

                           tokenCacheManager.CacheTokens(new CognitoIdentityProviderToken()
                           {
                               accessToken = respondToAuthChallengeResponse.Response.AuthenticationResult.AccessToken,
                               idToken = respondToAuthChallengeResponse.Response.AuthenticationResult.IdToken,
                               refreshToken = respondToAuthChallengeResponse.Response.AuthenticationResult.RefreshToken,
                               expireTime = respondToAuthChallengeResponse.Response.AuthenticationResult.ExpiresIn,
                           });
                       }
                       catch (Exception e)
                       {
                           Debug.LogErrorFormat("Encountered exception: {0}", e);
                       }
                       finally
                       {
                           idpClient.Dispose();
                       }
                   });
                }
            });

            currentUserName = userName;
        }
        #endregion

        private void Credentials_IdentityChangedEvent(object sender, CognitoAWSCredentials.IdentityChangedArgs e)
        {
            Debug.Log(string.Format("Identity has changed from {0} to {1}", e.OldIdentityId, e.NewIdentityId));
        }
    }
}

参考資料

MSYS2でgit-lfsを使う環境を構築するまで

MSYS2のUnix環境は強力ですがGit LFSのコマンドをたたこうとすると色々やることがあるのでメモする。

環境

  • Windows 10 Home Edition
  • MSYS2 mintty 2.8.3
  • Git LFS v2.3.4

MSYS2のインストール

MSYS2を導入するとWindows上でUnixっぽいコマンドライン環境が構築でき、Unix上で動くソフトウェアが使えるようになる。 パッケージマネージャーとしてpacmanが入っておりパッケージのインストールやMSYS2の更新がワンコマンドで行える。

MSYS2 Installer

以下の記事が詳しい qiita.com

MSYS2に必要なパッケージをインストールする

verifiedby.me gitやOpenSSHといった必要なものが全部入りで入っているのでtool-chainをインストールすると良いと思う。

git-lfsをインストールする

Git LFSWindowsにインストールするときは普通にインストーラを使う。

git-lfs.github.com

実行ファイルは/c/Program Files/Git LFS/下にインストールされる。

git-lfsの実行ファイルにパスを通す

MSYS2を立ち上げて~/.bashrcを編集し、さっきインストールしたGit LFSの実行ファイルにパスを通す。

$ echo 'export PATH="{PATH}:/c/Program Files/Git LFS"' >> .bashrc

SSHの認証鍵を生成してGitHubに設定する

Git LFSではSSH接続が必須なのでSSH鍵を生成する。Windowsのアプリが使うSSH鍵(/c/Users/(ユーザー名)/.ssh下に入っている)とは別にMSYS2のホームディレクトリ下に鍵を作って格納する。

GitHubの公式ヘルプが詳しい。 Generating a new SSH key and adding it to the ssh-agent - User Documentation

MSYS2で

$ cd
$ ssh-keygen -t rsa -b 4096 -C "your_email@example.com"
$ eval `ssh-agent` 
$ ssh-add ~/.ssh/id_rsa

と一連のコマンドを打つ。

作られたファイル~/.ssh/id_rsa~/.ssh/id_rsa.pubのうち後者の中身をのぞくと

ssh-rsa AAAAB.....k732v takumi10194617@gmail.com

みたいな感じの文字列が入っているはずなのでこれをコピーする。

GitHubのSettingページ>Personal Setting>SSH adn GPG Keys>New SSH Keyと進んで表示される大枠に文字列をペーストすると公開鍵が登録される。

Unityのエディタ上で作ったスクリプトに自動でnamespaceをつける

https://gyazo.com/9798ec353c0a7bcb933edb7755f2b055

自分の作るスクリプトには自動で独自に設定した名前空間を設定したい. ストアを見た感じ手軽な拡張が見当たらなかったのでEditor拡張の練習がてら作ってみた.

具体的にはUnityでアセットを新しく生成した時に呼ばれるコールバックがあるのでそこでスクリプトのパスをもらい,C#スクリプトテンプレートに文字列操作で先頭に"namespace (自分の名前空間) {"を,末尾に"}"を追加する.

パッケージを作ったのでよければどうぞ

ScriptNamespace.unitypackage - Google ドライブ

Unity文字操作の処理

Assets/Create下にエディタ拡張を追加する

設定用のScriptableObject

OffMeshLink/NavMeshLinkでJumpさせる

github.com

https://i.gyazo.com/d6c5fc44ffb64b1f322b2ac202c2ce8f.gif

NavMeshComponentsを使う

OffMeshLinkは明示的にジャンプ地点を指定することが難しいと感じます。その点NavMeshLinkは始点と終点をPosition指定で編集できるので便利です。 https://gyazo.com/b3cbee1fbe33f6b10891b4a471671f46 NavMeshComponentsを使うにはUnity-Technologiesの公式リポジトリから引っ張ってきてプロジェクトにコピーしましょう。

github.com

諸々のセットアップ

ブログエントリーを参考に,アニメーションと連動したNavMeshの設定をします。

tsubakit1.hateblo.jp

tsubakit1.hateblo.jp

https://gyazo.com/ac53c079dfa42302a9f80217c73b91d0 https://gyazo.com/4378a9a5eb0a3b0e7e91be8833a12d13

ジャンプの挙動を実装する

NavMeshLinkに乗った時にNavMeshを停止させ,DoTweenの機能を使ってNavMeshLinkの終点までtransform.DoMoveします。 ジャンプのアニメーションに合わせてWaitForSecondsを挟み,動きが自然になるよう調節します。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
using DG.Tweening;

public class NavMeshJump : MonoBehaviour {

    NavMeshAgent agent;
    Animator anim;
    public Transform destination;

    // Use this for initialization
    void Start () {
        agent = GetComponent<NavMeshAgent>();
        anim = GetComponent<Animator>();
        agent.SetDestination(destination.position);
        StartCoroutine(NavMeshRoutine());
    }

    void Update()
    {
        anim.SetFloat("Speed", Vector3.Magnitude(agent.velocity));
    }

    IEnumerator NavMeshRoutine(){
        agent.autoTraverseOffMeshLink = false;

        while (Application.isPlaying){
            yield return new WaitUntil(() => agent.isOnOffMeshLink);

            // OffMeshLink/NavMeshLinkについたらJumpする
            agent.isStopped = true;
            anim.SetTrigger("Jump");
            yield return new WaitForSeconds(0.3f);
            yield return transform.DOMove(agent.currentOffMeshLinkData.endPos, 1.0f, false).WaitForCompletion();
            yield return new WaitForSeconds(0.8f);
            agent.CompleteOffMeshLink();
            agent.isStopped = false;
        }
    }
}

f:id:takumi10194617:20171207165421j:plain

Captionパッケージによる注釈幅の制御

注釈の幅を狭めてメリハリのある文章にする

Captionが目一杯広がるとダサい

例えばこんなLaTeXファイルがあるとして、

ビルドした結果は次のようになります.

f:id:takumi10194617:20170114015407p:plain

注釈は時として図の説明のために長くなりますが、特に指定をしないと本文と同じ幅、つまり\textwidthまで横に広がります.メリハリがなくて少し読みづらいです.

Captionの幅を小さくして読みやすくする

図の幅は\includegraphicsのオプションで指定できますが、注釈の幅はcaptionパッケージを用いて変更できます.具体的にはcaptionパッケージをインポートして、注釈のつく\figureブロックの中で\captionsetup{width=(指定幅)}とします.例えば\textwidthの0.8倍を指定した時は以下のようになります.

f:id:takumi10194617:20170114015414p:plain 本文との区別が明確になり読みやすくなったと思います.

subfigと組み合わせる

subfigパッケージで各画像に注釈をつけるときは\subfloatのオプションとして注釈の内容を記しますが、この注釈もまたcaptionパッケージによる設定の影響を受けます

f:id:takumi10194617:20170114024355p:plain

まとめ

\textwidthに対する割合による指定をするだけでもかなり柔軟に使えます.うまく上書きしてあげることでsubfig環境でも使えるので試してみてはいかがでしょうか.

あとセイレンは今季の覇権アニメなので見ましょう.