Windows Azure Active Directory(WAAD)が OAuth2.0 / OpenID Connect に対応しつつある、という話は以前紹介しましたが、具体的にどうやって使うのか?についてはあまり解説記事もないので、今回簡単に紹介していこうと思います。
参考)[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 を設定しておかなくてもユーザの代理として機能する、というところです。
(一部、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 を設定しておかなくてもユーザの代理として機能する、というところです。
◆作業の流れ
作業の流れは以下の通りです。
- WebAPI の作成:今回は ASP.NET MVC4 の WebAPI を使います
- WebAPI の保護:WAAD を使って認可する様に設定を行います
- WebAPI を WAAD に登録:WAAD の保護対象リソースとして WAAD へ登録します
- OAuth クライアントの作成:本来は真面目にクライアントも作るのですが、今回は生の動きを見るために Chrome Extension の Advanced REST Client とダミー URL を使います
- OAuth クライアントを WAAD に登録:WAAD を使うアプリケーションとして OAuth クライアントを登録します
- WebAPI へのアクセス許可設定:OAuth クライアントが WebAPI へアクセスできるように設定します
- 動作確認:実際に 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 で実際に保護するための設定を入れていきます。