2013年10月22日火曜日

[WAAD]OAuth で WebAPI を保護する ①

Windows Azure Active Directory(WAAD)が OAuth2.0 / OpenID Connect に対応しつつある、という話は以前紹介しましたが、具体的にどうやって使うのか?についてはあまり解説記事もないので、今回簡単に紹介していこうと思います。
(一部、Preview の機能を使うので今後手順などが変更になる可能性があります)

参考)[WAAD] OAuth2.0 への対応状況まとめ&ちょこっと OpenID Connect も
    http://idmlab.eidentity.jp/2013/07/waad-oauth20-openid-connect.html

◆やりたいこと
WebAPI へのアクセスを WAAD の OAuth2.0 の機能を使って保護する
⇒つまり、WAAD が発行したアクセストークンを使って WebAPI の実行の認可をしてみようと思います。

簡単に絵にすると以下のようになります。


ポイントは、ユーザが直接 WebAPI を実行するのではなく、OAuth クライアントが実行する、かつその際に OAuth クライアントに ID/PWD を設定しておかなくてもユーザの代理として機能する、というところです。


◆作業の流れ
作業の流れは以下の通りです。
  1. WebAPI の作成:今回は ASP.NET MVC4 の WebAPI を使います
  2. WebAPI の保護:WAAD を使って認可する様に設定を行います
  3. WebAPI を WAAD に登録:WAAD の保護対象リソースとして WAAD へ登録します
  4. OAuth クライアントの作成:本来は真面目にクライアントも作るのですが、今回は生の動きを見るために Chrome Extension の Advanced REST Client とダミー URL を使います
  5. OAuth クライアントを WAAD に登録:WAAD を使うアプリケーションとして OAuth クライアントを登録します
  6. WebAPI へのアクセス許可設定:OAuth クライアントが WebAPI へアクセスできるように設定します
  7. 動作確認:実際に OAuth2.0 のフローに従ってアクセスできるかどうかテストします。(今回は認可コードフローを試してみます)
ちょっと長めなので、2,3回に分割して紹介していきます。


◆実際の作業
では、始めます。

1.WebAPI の作成

Visual Studio 2012 で以下の通り WebAPI を作成します。ここで作成した WebAPI へのアクセスを Windows Azure Active Directory(WAAD)の OAuth を使って保護します。

まずは、ASP.NET MVC4 Web アプリケーションを作成します。



プロジェクトテンプレートでは WebAPI を選択します。



作成が終わったら F5 を押してデバッグモードで起動します。
ブラウザで http://localhost:[アサインされたポート番号]/Api/Values へアクセスすると Visual Studio のテンプレートに登録されている値が表示されます。


2.WebAPI の保護

作成した WebAPI を WAAD で保護するための設定を行います。具体的には Authorization ヘッダに設定されてくるアクセストークンの Validation をするために、global.asax にValidationHandler を作成、登録します。これでトークンの Validation に失敗すると HTTP 401 Unauthorized が返るようになります。

まず必要なライブラリへの参照を設定します。必要なのは、以下の3つです。
  • System.IdentityModel
  • JSON Web Token Handler for the Microsoft .NET Framework(NuGet パッケージ)
  • Microsoft Token Validation Extension for Microsoft .NET Framework 4.5(NuGet パッケージ)
System.IdentityModel

JSON Web Token Handler for the Microsoft .NET Framework

Microsoft Token Validation Extension for Microsoft .NET Framework 4.5



参照設定が終わったら、global.asax へ TokenValidationHandler を追加します。
この部分は MSDN の以下のサイトに紹介されているので、global.asax のソースコードはそのまま使います。
 Securing a Windows Store Application and REST Web Service Using Windows Azure AD (Preview)

環境によって変えるのは、以下の2点だけです。
 const string domainName = “xxx.onmicrosoft.com";
  ※契約した WAAD のテナントドメイン名
 const string audience = “http://localhost:[ポート番号]";
  ※作成した WebAPI の URI


一応ソースを張り付けておきます。

using System;
using System.Collections.Generic;
using System.IdentityModel.Metadata;
using System.IdentityModel.Selectors;
using System.IdentityModel.Tokens;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Security.Claims;
using System.Security.Cryptography.X509Certificates;
using System.ServiceModel.Security;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Web;
using System.Web.Http;
using System.Web.Mvc;
using System.Web.Optimization;
using System.Web.Routing;
using System.Xml;
using System.Xml.Linq;

namespace ProtectedAPI
{
    // メモ: IIS6 または IIS7 のクラシック モードの詳細については、
    // http://go.microsoft.com/?LinkId=9394801 を参照してください

    public class WebApiApplication : System.Web.HttpApplication
    {
        protected void Application_Start()
        {
            AreaRegistration.RegisterAllAreas();

            WebApiConfig.Register(GlobalConfiguration.Configuration);
            FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
            RouteConfig.RegisterRoutes(RouteTable.Routes);
            BundleConfig.RegisterBundles(BundleTable.Bundles);

            // Add Token Validation Handler -- 20131020
            GlobalConfiguration.Configuration.MessageHandlers.Add(new TokenValidationHandler());
        }
    }

    // Token Validation Handler Class -- 20131020
    internal class TokenValidationHandler : DelegatingHandler
    {
        // Domain name or Tenant name
        const string domainName = "xxxx.onmicrosoft.com";
        const string audience = "http://localhost:52941";

        static DateTime _stsMetadataRetrievalTime = DateTime.MinValue;
        static List<X509SecurityToken> _signingTokens = null;
        static string _issuer = string.Empty;


        // SendAsync is used to validate incoming requests contain a valid access token, and sets the current user identity 
        protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
        {
            string jwtToken;
            string issuer;
            List<X509SecurityToken> signingTokens;

            if (!TryRetrieveToken(request, out jwtToken))
            {
                return Task.FromResult<HttpResponseMessage>(new HttpResponseMessage(HttpStatusCode.Unauthorized));
            }

            try
            {
                // Get tenant information that's used to validate incoming jwt tokens
                GetTenantInformation(string.Format("https://login.windows.net/{0}/federationmetadata/2007-06/federationmetadata.xml", domainName), out issuer, out signingTokens);
            }
            catch (Exception)
            {
                return Task.FromResult<HttpResponseMessage>(new HttpResponseMessage(HttpStatusCode.InternalServerError));
            }

            JwtSecurityTokenHandler tokenHandler = new JwtSecurityTokenHandler()
            {
                CertificateValidator = X509CertificateValidator.None
            };

            TokenValidationParameters validationParameters = new TokenValidationParameters
            {
                AllowedAudience = audience,
                ValidIssuer = issuer,
                SigningTokens = signingTokens
            };

            try
            {

                // Validate token
                ClaimsPrincipal claimsPrincipal = tokenHandler.ValidateToken(jwtToken,
validationParameters);

                //set the ClaimsPrincipal on the current thread.
                Thread.CurrentPrincipal = claimsPrincipal;

                // set the ClaimsPrincipal on HttpContext.Current if the app is running in web hosted environment.
                if (HttpContext.Current != null)
                {
                    HttpContext.Current.User = claimsPrincipal;
                }

                // Verify that required permission is set in the scope claim
                if (ClaimsPrincipal.Current.FindFirst("http://schemas.microsoft.com/identity/claims/scope").Value != "user_impersonation")
                {
                    return Task.FromResult<HttpResponseMessage>(new HttpResponseMessage(HttpStatusCode.Unauthorized));
                }

                return base.SendAsync(request, cancellationToken);
            }
            catch (SecurityTokenValidationException)
            {
                return Task.FromResult<HttpResponseMessage>(new HttpResponseMessage(HttpStatusCode.Unauthorized));
            }
            catch (Exception)
            {
                return Task.FromResult<HttpResponseMessage>(new HttpResponseMessage(HttpStatusCode.InternalServerError));
            }
        }

        // Reads the token from the authorization header on the incoming request
        private static bool TryRetrieveToken(HttpRequestMessage request, out string token)
        {
            token = null;
            string authzHeader;

            if (!request.Headers.Contains("Authorization"))
            {
                return false;
            }

            authzHeader = request.Headers.GetValues("Authorization").First<string>();

            // Verify Authorization header contains 'Bearer' scheme
            token = authzHeader.StartsWith("Bearer ") ? authzHeader.Split(' ')[1] : null;

            if (null == token)
            {
                return false;
            }

            return true;
        }

        /// <summary>
        /// Parses the federation metadata document and gets issuer Name and Signing Certificates
        /// </summary>
        /// <param name="metadataAddress">URL of the Federation Metadata document</param>
        /// <param name="issuer">Issuer Name</param>
        /// <param name="signingTokens">Signing Certificates in the form of X509SecurityToken</param>
        static void GetTenantInformation(string metadataAddress, out string issuer, out List<X509SecurityToken> signingTokens)
        {
            signingTokens = new List<X509SecurityToken>();

            // The issuer and signingTokens are cached for 24 hours. They are updated if any of the conditions in the if condition is true.            
            if (DateTime.UtcNow.Subtract(_stsMetadataRetrievalTime).TotalHours > 24
                || string.IsNullOrEmpty(_issuer)
                || _signingTokens == null)
            {
                MetadataSerializer serializer = new MetadataSerializer()
                {
                    CertificateValidationMode = X509CertificateValidationMode.None
                };
                MetadataBase metadata = serializer.ReadMetadata(XmlReader.Create(metadataAddress));

                EntityDescriptor entityDescriptor = (EntityDescriptor)metadata;

                // get the issuer name
                if (!string.IsNullOrWhiteSpace(entityDescriptor.EntityId.Id))
                {
                    _issuer = entityDescriptor.EntityId.Id;
                }

                // get the signing certs
                _signingTokens = ReadSigningCertsFromMetadata(entityDescriptor);

                _stsMetadataRetrievalTime = DateTime.UtcNow;
            }

            issuer = _issuer;
            signingTokens = _signingTokens;
        }

        static List<X509SecurityToken> ReadSigningCertsFromMetadata(EntityDescriptor entityDescriptor)
        {
            List<X509SecurityToken> stsSigningTokens = new List<X509SecurityToken>();

            SecurityTokenServiceDescriptor stsd = entityDescriptor.RoleDescriptors.OfType<SecurityTokenServiceDescriptor>().First();

            if (stsd != null && stsd.Keys != null)
            {
                IEnumerable<X509RawDataKeyIdentifierClause> x509DataClauses = stsd.Keys.Where(key => key.KeyInfo != null && (key.Use == KeyType.Signing || key.Use == KeyType.Unspecified)).
Select(key => key.KeyInfo.OfType<X509RawDataKeyIdentifierClause>().First());

                stsSigningTokens.AddRange(x509DataClauses.Select(clause => new X509SecurityToken(new X509Certificate2(clause.GetX509RawData()))));
            }
            else
            {
                throw new InvalidOperationException("There is no RoleDescriptor of type SecurityTokenServiceType in the metadata");
            }

            return stsSigningTokens;
        }
    }
}



この状態で再度デバッグ実行し、ブラウザからアクセスするとAuthorizationヘッダがないため、401 Unauthorizedが返ってきます。



とりあえず、今回は WebAPI を作るところまでを紹介しましたので、次回は WAAD で実際に保護するための設定を入れていきます。

0 件のコメント: