かずきのBlog@hatena

すきな言語は C# + XAML の組み合わせ。Azure Functions も好き。最近は Go 言語勉強中。日本マイクロソフトで働いていますが、ここに書いていることは個人的なメモなので会社の公式見解ではありません。

Bot Builder SDK v4 で認証をつけてみよう

単純なものなら動くようになってきたので、個人的に興味のあった認証部分をやってみようと思う。

前回はこちら。

blog.okazuki.jp

ドキュメントはこちら。

docs.microsoft.com

Azure AD にアプリを追加

v1 と v2 があるけど v2 の機能が必要ない限りは v1 でと言われてるので v1 でいきましょう。 Azure ポータルの Azure AD の画面に行きます。 そしてアプリの登録から新しいアプリを作成します。

適当に名前を入れてアプリケーションの種類を Web アプリ/API にしてサインオン URL を https://token.botframework.com/.auth/web/redirect にしましょう。

アプリケーション ID を控えておきます。キーで新しいパスワードを作成します。これも控えておきます。必要なアクセス許可を追加します。 今回はログインしたあとに Microsoft Graph API を使ってユーザー情報をひっぱってこようかなと思ってるので Microsoft Graph の Sign in and read user profile を選択します。

次に Azure ポータルで移動して Bot Channels Registration を作ります。

メッセージングエンドポイントは空っぽのままで、他の項目は適当にそのまま OK です。

作成した Bot Channels Registration の設定でOAuth 接続設定で設定の追加をします。Name は適当でサービスプロバイダーを Azure Active Directory にして Client id にアプリケーション ID を、Client secret にアプリで生成したパスワードを、Grant Type に authorization_code に、Login URL に https://login.microsoftonline.com、Tenant ID には、特定テナントからのログインのみにしたかったら特定のテナント(例:microsoft.com とか xxx.onmicrosoft.com とか)で、どこのテナントでも OK の場合は common を入力。今回は common にしました。Resource URL は、Graph API に繋ぎたいので https://graph.microsoft.com/ です。

保存しましょう。追加された OAuth 接続設定を選択すると接続のテストが出来ます。ログイン出来ることを確認しておきます。

ボットのアプリ ID とパスワード(Azure で登録したアプリではなく Bot Channels Registration に紐づいてるほう)を BotConfiguration.bot の appId と appPassword に設定します。

あとは、OAuthPrompt というダイアログを使えばログインが出来ます。素敵!

こんな感じです。

using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Schema;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

namespace LoginBotLab
{
    public class Bot : IBot
    {
        private static string OAuthConnectionSettingName { get; } = "bot";
        private static string LoginPromptName { get; } = "LoginPrompt";
        private BotStatusAccessors BotStatusAccessors { get; }
        private DialogSet Dialogs { get; }
        public Bot(BotStatusAccessors botStatusAccessors)
        {
            BotStatusAccessors = botStatusAccessors;
            Dialogs = new DialogSet(BotStatusAccessors.DialogState);

            Dialogs.Add(new OAuthPrompt(LoginPromptName,
                new OAuthPromptSettings
                {
                    ConnectionName = OAuthConnectionSettingName,
                    Text = "サインインをしてください",
                    Title = "サインイン",
                    Timeout = 1000 * 60 * 5,
                }));
            Dialogs.Add(new TextPrompt("output"));
            Dialogs.Add(new WaterfallDialog("login", new WaterfallStep[]
            {
                PromptStepAsync,
                LoginStepAsync,
            }));
        }

        public async Task OnTurnAsync(ITurnContext turnContext, CancellationToken cancellationToken = default(CancellationToken))
        {
            if (turnContext == null)
            {
                throw new ArgumentNullException(nameof(turnContext));
            }

            var dc = await Dialogs.CreateContextAsync(turnContext, cancellationToken);
            if (turnContext.Activity.Type == ActivityTypes.Message)
            {
                var r = await dc.ContinueDialogAsync(cancellationToken);
                if (r.Status == DialogTurnStatus.Empty)
                {
                    await dc.BeginDialogAsync("login", cancellationToken: cancellationToken);
                }
            }

            await BotStatusAccessors.SaveChangesAsync(turnContext);
        }

        private async Task<DialogTurnResult> PromptStepAsync(WaterfallStepContext step, CancellationToken cancellationToken)
        {
            return await step.BeginDialogAsync(LoginPromptName, cancellationToken: cancellationToken);
        }

        private async Task<DialogTurnResult> LoginStepAsync(WaterfallStepContext step, CancellationToken cancellationToken)
        {
            var tokenResponse = (TokenResponse)step.Result;
            if (tokenResponse == null)
            {
                await step.Context.SendActivityAsync("ログインに失敗しました。", cancellationToken: cancellationToken);
                return Dialog.EndOfTurn;
            }

            await step.Context.SendActivityAsync($"Finished: {tokenResponse.Token}", cancellationToken: cancellationToken);
            return Dialog.EndOfTurn;
        }
    }
}

中で使ってる BotStatusAccessors は以下のようなクラスです。単純に ConversationState に DialogState を持たせてるだけです。

using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.Dialogs;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace LoginBotLab
{
    public class BotStatusAccessors
    {
        private ConversationState ConversationState { get; }
        private UserState UserState { get; }

        public IStatePropertyAccessor<DialogState> DialogState { get; }
        public BotStatusAccessors(ConversationState conversationState, UserState userState)
        {
            ConversationState = conversationState ?? throw new ArgumentNullException(nameof(conversationState));
            UserState = userState ?? throw new ArgumentNullException(nameof(userState));

            DialogState = ConversationState.CreateProperty<DialogState>($"{nameof(BotStatusAccessors)}.{nameof(DialogState)}");
        }

        public Task SaveConversationStateChangesAsync(ITurnContext turnContext) => ConversationState.SaveChangesAsync(turnContext);
        public Task SaveUserStateChangesAsync(ITurnContext turnContext) => UserState.SaveChangesAsync(turnContext);
        public async Task SaveChangesAsync(ITurnContext turnContext)
        {
            await SaveConversationStateChangesAsync(turnContext);
            await SaveUserStateChangesAsync(turnContext);
        }
    }
}

これでエミュレーターとかで話しかけるとサインインボタンが出てきてサインインするとアクセストークンが表示されます。実際には、このアクセストークンを使って各種 API 呼ぶ感じですね。