himanago

Azure・C#などのMS系技術やLINE関連技術など、好きな技術について書くブログ

Azure Blob Storage の SAS トークンを使って LINE Bot でセキュアに画像を送る

はじめに

Messaging API では、画像や動画の送信などの際、それらが置かれたパブリックな URL を指定することで実現します。

Azure を用いて LINE Bot を開発する場合は、画像等のリソースをパブリックアクセスレベルを「コンテナー」にした Blob Storage コンテナーに置くだけでパブリックな URL が得られて便利ですが、グローバルに公開してしまうことはセキュリティ上の懸念につながります。

そこで、少しでもセキュアに Blob Storage から Messaging API へのリソース連携を行うため、SAS トークンを使ったアクセス制限をかけてみます。

SAS トークンとは

SAS とは、Shared Access Signatures の略で、Azure Storage のリソースへのアクセス制限をかける機能です。

docs.microsoft.com

これを使うと、URL に付与して署名されているかどうかを検証できる「SAS トークン」が生成できます。SAS トークンつきの URL であれば、Blob コンテナーのパブリックアクセスレベルが匿名アクセスのできない「プライベート」になっていてもアクセスできるようになります。

では、LINE Bot のバックエンドで SAS トークンを生成することで、 Messaging API での画像送信を少しセキュアにしてみましょう。

実装してみる

バックエンドのひながた

Bot のバックエンドは Azure Functions で作り、言語は C# でいくことにします。Bot 用プロジェクトのひながたはこちらの記事参照。

LINE Bot を Azure Functions (C#) で作る際のオウム返しテンプレ - himanago

今回はシンプルに、何かしらメッセージを送ると Blob Storage に置いてある特定の画像を返信する、という Bot にします。

サービス SAS を使用する

SAS にはいくつか種類がありますが、今回使うのは「サービス SAS」です。

.NET でサービス SAS 使う方法が書いてあるドキュメントはこちら。

docs.microsoft.com

BLOB 用のサービス SAS を作成

上記のドキュメントの「Create a service SAS for a blob」の部分を参考に、実装をします。

「Azure.Storage.Blobs」パッケージ(v12)を追加し、以下のようにコードを追加します。

Configuration/LineBotSettings.cs

namespace AzureSecuredImageLineBotSample.Configurations
{
    public class LineBotSettings
    {
        public string ChannelSecret { get; set; }
        public string ChannelAccessToken { get; set; }
        public string StorageConnectionString { get; set; }
        public string BlobContainerName { get; set; }
        public string StorageAccountKey => StorageConnectionString.Split("AccountKey=")[1].Split(";")[0];
    }
}

設定は面倒なので同じファイルにまとめてしまいます…。

実際の設定値はポータルのアプリケーション設定から、名前を LineBotSettings:ChannelAccessToken などの形式にして設定しておきましょう(4 つ)。

Startup.cs

using Azure.Storage.Blobs;
using AzureSecuredImageLineBotSample.Configurations;
using LineDC.Messaging;
using LineDC.Messaging.Webhooks;
using Microsoft.Azure.Functions.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

[assembly: FunctionsStartup(typeof(AzureSecuredImageLineBotSample.Startup))]
namespace AzureSecuredImageLineBotSample
{
    public class Startup : FunctionsStartup
    {
        public override void Configure(IFunctionsHostBuilder builder)
        {
            var config = new ConfigurationBuilder()
                .AddJsonFile("local.settings.json", true)
                .AddEnvironmentVariables()
                .Build();

            var settings = config.GetSection(nameof(LineBotSettings)).Get<LineBotSettings>();

            // Blob Storage 用に追加
            var blobServiceClient = new BlobServiceClient(settings.StorageConnectionString);
            var blobContainerClient = blobServiceClient.GetBlobContainerClient(settings.BlobContainerName);
            builder.Services.AddSingleton(blobContainerClient);

            builder.Services
                .AddSingleton(settings)
                .AddSingleton<ILineMessagingClient>(_ => LineMessagingClient.Create(settings.ChannelAccessToken))
                .AddSingleton<IWebhookApplication, LineBotApp>();
        }
    }
}

LineBotApp.cs

using Azure.Storage;
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Specialized;
using Azure.Storage.Sas;
using AzureSecuredImageLineBotSample.Configurations;
using LineDC.Messaging;
using LineDC.Messaging.Messages;
using LineDC.Messaging.Webhooks;
using LineDC.Messaging.Webhooks.Events;
using Microsoft.Azure.WebJobs.Logging;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace AzureSecuredImageLineBotSample
{
    public class LineBotApp : WebhookApplication
    {
        private ILogger Logger { get; }
        private BlobContainerClient BlobContainerClient { get; }
        private LineBotSettings Settings { get; }

        public LineBotApp(ILineMessagingClient lineMessagingClient, LineBotSettings settings, ILoggerFactory loggerFactory,
            BlobContainerClient blobContainerClient)
            : base(lineMessagingClient, settings.ChannelSecret)
        {
            Logger = loggerFactory.CreateLogger(LogCategories.CreateFunctionUserCategory(nameof(WebhookEndpoint)));
            BlobContainerClient = blobContainerClient;
            Settings = settings;
        }

        protected override async Task OnMessageAsync(MessageEvent ev)
        {
            var key = new StorageSharedKeyCredential(BlobContainerClient.AccountName, Settings.StorageAccountKey);
            var originalContentUrl = GetBlobSasUri("original.png", key);
            var previewImageUrl = GetBlobSasUri("preview.png", key);

            Logger?.LogTrace($"OnMessageAsync => Type: {ev.Source.Type}, Id: {ev.Source.Id}");
            await Client.ReplyMessageAsync(ev.ReplyToken, new List<ISendMessage>
            {
                new ImageMessage(originalContentUrl, previewImageUrl)
            });
        }

        private string GetBlobSasUri(string blobName, StorageSharedKeyCredential key)
        {
            // Create a SAS token
            var sasBuilder = new BlobSasBuilder
            {
                BlobContainerName = BlobContainerClient.Name,
                BlobName = blobName,
                Resource = "b",
            };

            sasBuilder.StartsOn = DateTimeOffset.UtcNow.AddMinutes(-15);
            sasBuilder.ExpiresOn = DateTimeOffset.UtcNow.AddMinutes(10);
            sasBuilder.SetPermissions(BlobContainerSasPermissions.Read);

            // Use the key to get the SAS token.
            var sasToken = sasBuilder.ToSasQueryParameters(key).ToString();

            return $"{BlobContainerClient.GetBlockBlobClient(blobName).Uri}?{sasToken}";
        }
    }
}

SAS トークンを含む URL は GetBlobSasUri で作成します。

ここでは、SAS トークンは有効期限を 10 分としています(開始は時刻ずれを考慮して 15 分前を設定)。

ただし、LINE Bot の画像には、送ったメッセージをユーザーが見たときにはじめてリクエストが飛ぶので、あまり短すぎるとユーザーが見る前に期限が切れてしまいます。なので、かなり遠い未来の日付を指定してあげるとよいです。1

期限が長くてもリソースごと・メッセージ送信ごとに個別のトークンが生成されるので、正しいトークンを持たないユーザーはアクセスできませんし、URL を推測して他の Blob へアクセスすることもできなくなります。

まとめ

LINE Bot で画像や動画などを扱うことは多いですが、セキュアに運用するために SAS 機能は手軽に導入でき、便利です。

Blob Storage に Bot のリソースを置く場合、特にインターネットにさらしたくないファイルがそこにある場合には、導入していくべきだと思います。


  1. 残念ながら無期限にはできないようです。