Nancyでのトークン認証について(WebAPI(認証有)、トークン生成アプリが別の場合)

前回の続きです。

WebAPIの認証機能の実装について

API利用する開発者ごとに固有のトークンを生成、これで認証を行う」ようなことを実現するための調査を行いました。

WebAPIは Nancy の使用して実装することを前提で進めます。

Nancyでは認証の種類として、ステートレス認証*1 ・ フォーム認証 ・ トークン認証 が標準で用意されています。

このうち、名前もズバリなトークン認証 を使うことにします。しかし、

トークン認証ドキュメントページ中のコードは、トークン発行ページ(DeveloperRegistSiteに相当)、トークン認証ページ(WebAPIに相当)が同じWEBアプリ内に存在することが前提となっているため、

個今回実現したいそれぞれが別のWEBアプリとして構成されている場合に対応できませんでした。

別WEBアプリ間でのトークン認証に対応させる

デフォルトでは、暗号化キー保存ををローカルファイルに行います。 (FileSystemTokenKeyStoreクラス を使用)

このため、DeveloperRegistSiteとWebAPIがそれぞれ別々に暗号化キーを持つことになります。

対処方法としては、案1.共通のキー保存ファイルを使うように設定する。 案2.保存場所をローカルファイル以外の共通参照できるものに変更する。 が考えられます。

今回は案2で対応することにしました。KeyStoreクラスは以下のように実装しました。(DBアクセスはDapperを使用)

--テーブル定義

CREATE TABLE [dbo].[token_key_store](
    [ticks] [bigint] NOT NULL,
    [ekey] [binary](64) NOT NULL,
 CONSTRAINT [PK_token_key_store] PRIMARY KEY CLUSTERED 
(
    [ticks] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
) ON [PRIMARY]
//DBに対して値を読み書きするKeyStoreクラス

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Data;
using System.Data.SqlClient;
using Nancy.Authentication.Token.Storage;
using Dapper;

namespace Tokenken2Common
{
    public class DBTokenKeyStore : ITokenKeyStore
    {
        private IDbConnection con;

        public DBTokenKeyStore(string connectionString)
        {
            this.con = new System.Data.SqlClient.SqlConnection(connectionString);
        }

        /// <summary>
        /// パージ
        /// </summary>
        public void Purge()
        {
            con.Open();
            IDbTransaction tran = con.BeginTransaction();
            try
            {
                con.Execute("delete from token_key_store", null, tran);
                tran.Commit();
            }
            catch
            {
                tran.Rollback();
                throw;
            }
            finally
            {
                con.Close();
            }
        }

        /// <summary>
        /// 取り出し
        /// </summary>
        /// <returns></returns>
        public IDictionary<DateTime, byte[]> Retrieve()
        {
            con.Open();
            try
            {
                return con
                    .Query("select ticks, ekey from token_key_store order by ticks")
                    .Select<dynamic, Tuple<long, byte[]>>((src) =>
                    {
                        return new Tuple<long, byte[]>(
                            Convert.ToInt64(src.ticks),
                            (byte[])src.ekey);
                    })
                    .Aggregate(new Dictionary<DateTime, byte[]>(), (memo, item) =>
                    {
                        memo[DateTime.FromBinary(item.Item1)] = item.Item2;
                        return memo;
                    });
            }
            finally
            {
                con.Close();
            }
        }

        /// <summary>
        /// 格納
        /// </summary>
        /// <param name="keys"></param>
        public void Store(IDictionary<DateTime, byte[]> keys)
        {
            con.Open();
            IDbTransaction tran = con.BeginTransaction();
            try
            {
                con.Execute("insert into token_key_store(ticks, ekey)values(@TICKS, @EKEY)", keys.Select((i) => new { TICKS = i.Key.Ticks, EKEY = i.Value }), tran);
                tran.Commit();

                //foreach (var k in keys)
                //{
                //    if (1 <= con.Execute("select count(*) from token_key_store where ticks = @TICKS", new { TICKS = k.Key.Ticks }, tran))
                //        continue;
                //    con.Execute("insert into token_key_store(ticks, ekey)values(@TICKS, @EKEY)", new { TICKS = k.Key.Ticks, EKEY = k.Value }, tran);
                //}

                //tran.Commit();
            }
            catch
            {
                tran.Rollback();
                throw;
            }
            finally
            {
                con.Close();
            }
        }
    }
}

次に、このKeyStoreクラスをDIコンテナに登録する必要があります。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Configuration;
using Nancy;
using Nancy.Authentication.Forms;
using Nancy.Authentication.Token;
using Tokenken2Common;

namespace Tokenken2
{
    public class MyBootstrapper : DefaultNancyBootstrapper
    {
        protected override void ApplicationStartup(Nancy.TinyIoc.TinyIoCContainer container, Nancy.Bootstrapper.IPipelines pipelines)
        {
            base.ApplicationStartup(container, pipelines);
            container.Register<ITokenizer>(new Tokenizer((config) =>
            {
                config.WithKeyCache(new DBTokenKeyStore(ConfigurationManager.AppSettings["tokenDbConnection"]));
            }));
        }
    }
}

これらを実装することで、暗号化キー情報がDBに保存されるようになり、別Webアプリからトークン認証が可能になるはずです!

と思っていたのですが、いざ実験していると認証に失敗します。。

調べてみたところ、デフォルトでは認証トークン生成時リクエストのUserAgentとトークン認証時リクエストのUserAgentが一致しないと認証エラーになるようです。

(認証トークンにUserAgentを含めておき、認証処理時に取り出して認証時リクエストのと一致するか確認する。)

今回想定しているのは、手順1.開発者サイトにブラウザでログインしてトークン生成する。 手順2.WebAppのサーバーサイド処理でWebAPIにリクエストして情報取得する。 の2ステップです。

この場合、確実に各手順でのUserAgentは異なります。手順1でのUserAgentをWebAppでも使用するスマートな方法があればいいのでしょうが、そんなものは思いつきませんでした。困りました。。

調べたところ、トークンに含める情報の設定は、DBTokenKeyStoreクラスの登録と同様にコンストラクタで設定できるようです。

これで、UserAgentは認証チェック対象外となり、別Webアプリからトークン認証が可能になります。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Configuration;
using Nancy;
using Nancy.Authentication.Forms;
using Nancy.Authentication.Token;
using Tokenken2Common;

namespace Tokenken2
{
    public class MyBootstrapper : DefaultNancyBootstrapper
    {
        protected override void ApplicationStartup(Nancy.TinyIoc.TinyIoCContainer container, Nancy.Bootstrapper.IPipelines pipelines)
        {
            base.ApplicationStartup(container, pipelines);
            container.Register<ITokenizer>(new Tokenizer((config) =>
            {
                //このメソッドでトークンに追加する要素情報を指定する。デフォルトでUserAgentが設定されているので、それをクリアするようにしてやればOK
                config.AdditionalItems();

                config.WithKeyCache(new DBTokenKeyStore(ConfigurationManager.AppSettings["tokenDbConnection"]));
            }));
        }
    }
}

(2014/06/22追記) トークンの有効期限を延ばす

認証トークンはデフォルト1日、ハッシュ値作成の暗号化キーはデフォルト7日で期限切れとなります。 期限切れになると認証が通らなくなり、期限が切れる度に新しいトークンを発行する必要があります。

認証トークンについて

下記のような文字の羅列です。 dXNlcg0KEHo2MzUzODk2NTg3MDIyWzI1zzA=:pvd44i7dqJrFNpgzo1g/Li5EXt7ZJSpsm/NZ0pjhcMM=

構成としては、:で区切った2要素から構成されています。 先頭要素は、<ユーザー名>,<ユーザークレーム>,<現在日時(UTC時間)>を改行文字でJoinしたものをUtf8のバイト列(A)に変換し、それのBase64変換した文字列で、 末尾要素は、Aのバイト列をHMACSHA256関数で求めたハッシュ値です。

認証処理中での期限切れチェック

認証処理している現在日時と、トークン中の現在日時(UTC時間)の差がトークンの有効期限を超えている場合、認証失敗となります。 私の場合は、ここで引っかかりエラーとなりました。。

対応策

トークンの有効期間を大きくします。TimeSpan(DateTime.MaxValue)とできれば安心でしょうか。 しかし、暗号化キーの有効期限以上に設定することができないので、トークン有効期限<暗号化キー有効期限となるような値をセットすることになります。 指定する際は、暗号化キーの有効期限から設定する必要があるので注意してください。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Configuration;
using Nancy;
using Nancy.Authentication.Forms;
using Nancy.Authentication.Token;
using Tokenken2Common;

namespace Tokenken2
{
    public class MyBootstrapper : DefaultNancyBootstrapper
    {
        protected override void ApplicationStartup(Nancy.TinyIoc.TinyIoCContainer container, Nancy.Bootstrapper.IPipelines pipelines)
        {
            base.ApplicationStartup(container, pipelines);
            container.Register<ITokenizer>(new Tokenizer((config) =>
            {
                config.AdditionalItems();
                //暗号化キーの有効期限
                config.KeyExpiration(() => new TimeSpan(DateTime.MaxValue.Ticks));
                //認証トークン有効期限
                config.TokenExpiration(() => new TimeSpan(DateTime.MaxValue.AddDays(-1).Ticks));
                //暗号化キー保存処理実行クラスの設定
                config.WithKeyCache(new DBTokenKeyStore(ConfigurationManager.AppSettings["tokenDbConnection"]));
            }));
        }
    }
}

疑問点

  • なぜトークン生成でのデフォルト構成要素にUserAgentが含まれているのか? 今回のような想定の場合は確実に認証エラーとなるのに含めている理由が不明。→ トークン生成/認証が同一WebAppのパターンしか想定していないため?

  • セキュリティー的この実装ってどうなの? 今回のは、イントラネット内に構築して、外からの/外へのアクセスは無い環境での案件対応なので、深くはあまり考えていないが、どうなんでしょうか?

*1:詳しく見てないのでどういうものなのかはよくわかってません..