Amazon Echo(Alexa) にログイン機能を付ける - Implicit Grant編

スマホとPCでデータやファイルを共有する場合、スマホアプリやWEBアプリのログイン機能を使ってユーザーを特定しクラウド上でデータを共有するのが一般的です。

 

クラウド上でデータを共有

 

Amazon Echo も同様で、ログイン機能を実装するとスマホやPCとデータを共有できるようになります。
Amazon Echo のログイン機能のことを"アカウントリンク"と呼びます。

この記事では分かりやすく噛み砕いて説明していきますが、より専門的に知りたい方は Amazon Alexa のブログを参照ください。

"Alexaユーザーとシステムユーザーを関連付ける"
developer.amazon.com

"Alexaスキル開発トレーニングシリーズ 第5回 アカウントリンクとホームカード機能" https://developer.amazon.com/ja/blogs/alexa/post/6df1f491-30f5-4451-b0da-bcd8f0a06a5c/chapter5-jp

アカウントリンクには2通りの方法があり、どちらもログイン自体はスマホアプリの"Amazon Alexa"から行います。

"Authorization Code Grant"・・・2段階認証で、カスタムスキルとホームスキルで使用できる。
"Implicit Grant"・・・1段階認証で、カスタムスキルで使用できる。

1段階認証である"Implicit Grant"の方が実装の手間が少なく、よくあるログイン機能に近いのでカスタムスキルでは主にこちらを使うことが多いと思います。

今回は"Implicit Grant"での実装例を載せていきます。

データフローは次のようになります。

自分で用意する物は次の2つです。
・Alexaスキル
Echoから呼出すカスタムスキル

・ログイン画面
ユーザーID、パスワード等を入力し認証するWEBアプリケーションで、 JAVAASP.NET(VB.NETC#)、PHP などで作ることが多いです。
トークンとはユーザーIDみたいなもので、ユーザーを特定できるキーであれば どんな書式や内容でも構いません。

いっそユーザーIDをトークンにしてしまいたいところですが、 ユーザーIDをAlexaに覚えさせ、覚えたユーザーIDでユーザーを特定しスキル実行するとなれば 別のユーザーIDを覚えさせるようにログイン画面を改変するだけでハッキングできてしまいます。
なのでユーザーIDとは別の複雑な文字列をトークンに設定することが望ましいです。

スキルの処理をAWS Lambda、認証用の画面をAWS S3に作った場合のスキル設定画面は次のようになります。

 

AWS S3に作った場合のスキル設定画面

 

"プライバシーポリシーURL"はひとまず何でもいいので入力しておけば良いです。

"Amazon Alexa"アプリで実際に動かすと次のようになります

 

 

"認証URL"のサイトがURLパラメータ付きで開きます

 

Amazon Alexa

 

URLパラメータに"redirect_url"があるので、認証が終わったらそこへ遷移します。
URLは次の書式になっています。
URLパラメータのredirect_url + "#state=" + URLパラメータのstate + "&access_token=" + トークン + "&token_type=Bearer"

 

Amazon Alexa

 

Amazon Echo からスキルを呼び出してみてください。

node.jsの場合、event変数に、
"alexa-skills-kit-sdk-for-nodejs"を使用している場合、"this.event.session.user.accessToken"にトークンが入っているのが確認できます。

注意:Amazon Echoの実機でないとトークンは入ってきません。

エミュレータとかでは検証できないので気をつけてください。

 

参考のためS3に置いたログイン画面のソースを載せておきます。
URLパラメータの作り方なんかの参考にしてもらえればいいと思います。
注意:トークンの作成をjavascriptで行なっていますが、きちんとするなら トークン発行関数をLambdaで作ってやり、javascriptから呼んでやるのが簡単でいいと思います。

ImplicitGrant.html

<!doctype html>
<html>
<head>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <title>Alexa Browser</title>
    <script>
        window.onload = function() {
            var urlParams = getUrlParams()
 
            document.getElementsByTagName('form')[0].onsubmit = function() {
            var userId = document.getElementById('userId').value;
            var password = document.getElementById('password').value;
 
            docCookies.setItem('userId', userId);
            docCookies.setItem('password', password);
 
            window.location.href = decodeURIComponent(urlParams.redirect_uri) +
                '#state=' + urlParams.state +
                '&access_token=' + userId + '_' + password
                '&token_type=Bearer';
                return false;
            };
 
            document.getElementById('userId').value = docCookies.getItem('userId');
            document.getElementById('password').value = docCookies.getItem('password');
        }
 
        function getUrlParams() {
            var arg = new Object;
            var pair=location.search.substring(1).split('&');
            for(var i=0;pair[i];i++) {
                var kv = pair[i].split('=');
                arg[kv[0]]=kv[1];
            }
            return arg;
        }
 
        var docCookies = {
            getItem: function (sKey) {
                if (!sKey || !this.hasItem(sKey)) { return null; }
                return unescape(document.cookie.replace(new RegExp("(?:^|.*;\\s*)" + escape(sKey).replace(/[\-\.\+\*]/g, "\\$&") + "\\s*\\=\\s*((?:[^;](?!;))*[^;]?).*"), "$1"));
            },
            setItem: function (sKey, sValue, vEnd, sPath, sDomain, bSecure) {
                if (!sKey || /^(?:expires|max\-age|path|domain|secure)$/i.test(sKey)) { return; }
                var sExpires = "";
                if (vEnd) {
                    switch (vEnd.constructor) {
                        case Number:
                            sExpires = vEnd === Infinity ? "; expires=Tue, 19 Jan 2038 03:14:07 GMT" : "; max-age=" + vEnd;
                            break;
                        case String:
                            sExpires = "; expires=" + vEnd;
                            break;
                        case Date:
                            sExpires = "; expires=" + vEnd.toGMTString();
                            break;
                    }
                }
                document.cookie = escape(sKey) + "=" + escape(sValue) + sExpires + (sDomain ? "; domain=" + sDomain : "") + (sPath ? "; path=" + sPath : "") + (bSecure ? "; secure" : "");
            },
            removeItem: function (sKey, sPath) {
                if (!sKey || !this.hasItem(sKey)) { return; }
                document.cookie = escape(sKey) + "=; expires=Thu, 01 Jan 1970 00:00:00 GMT" + (sPath ? "; path=" + sPath : "");
            },
            hasItem: function (sKey) {
                return (new RegExp("(?:^|;\\s*)" + escape(sKey).replace(/[\-\.\+\*]/g, "\\$&") + "\\s*\\=")).test(document.cookie);
            },
            keys: /* optional method: you can safely remove it! */ function () {
                var aKeys = document.cookie.replace(/((?:^|\s*;)[^\=]+)(?=;|$)|^\s*|\s*(?:\=[^;]*)?(?:\1|$)/g, "").split(/\s*(?:\=[^;]*)?;\s*/);
                for (var nIdx = 0; nIdx < aKeys.length; nIdx++) { aKeys[nIdx] = unescape(aKeys[nIdx]); }
                return aKeys;
            }
        };
</script>
</head>
<body style="background-color:#ccc">
    <div style="width:300px">
    <form>
        <p>userId</p>
        <input type="text" id="userId" style="width:100%"/>
        <p>password</p>
        <input type="password" id="password" style="width:100%"/>
        <button>OK</button>
    </form>
    </div>
</body>
</html>

 

 

 

AWS相談会