この記事はASP.NET Advent Calendar 2014の22日目となる記事です。
酔った勢いで登録ボタン押したのが始まりです。フフフ。
Qiita、アドベントカレンダー、GitHub、C#、ASP.NET、インターネット
全てが初挑戦なので、いろいろ抜けていたらごめんなさい!
ASP.NET MVC 5を使って、認証を必要とするSPAなアプリを開発し
WebAPIをアプリケーション内のViewなどによって提供しているクライアントのためだけに作る場合の話です。
##前置き
WebAPIをViewのために作るので、RestfullなWebAPIを作るというよりは、
MVCと同じように扱えるほうが都合がよいでしょう。
そこで、SessionStateオブジェクトを扱いつつログイン状態も同期させることで不要な考慮を省いてしまおうと考えます。
アプリケーションの一部としてWebAPIを作るのであれば、やはりMVCと同じように扱いたいと。
じゃあ、「MVCでそのままやってしまえばいいじゃないのか!?」ともなります。
ですが、WebAPIはMVCよりも軽量で高速に動作させることを前提としているようですので、そこに最小限で手を加えて使ってみるのはどうでしょう?という結論の元に進めます。
##目的
というわけで、WebAPIは以下の要件を満たしていきたいと思います。
1.Form認証を利用する
2.認証を前提にするため、キャッシュ、保存を無効化する
3.部分的には匿名アクセスを許容させることも考慮したい。
4.SessionStateを利用する
5.全部に属性設定するのはちょっと面倒なので、局所的なものだけ個別設定したい
認証はお手軽さのためForm認証を使います。必要に応じてMemberShipProviderを実装することで様々な状況にも対応できるかと思います。ここではそこについて触れません。
##<1.~Form認証~>
まずは、認証部分を作ります。
ここでは、ログインのページを作ることを必要としますが
WebAPIをメインにしているので、MVCコントローラは限りなくシンプルに…。
Form認証のMVCコントローラは以下3つの操作を持たせます。
#####1-1.View表示
#####1-2.ログイン1
#####1-3.ログアウト
// GET: Account
[AllowAnonymous]
public ActionResult Index()
{
return View();
}
// GET: /Account/Login
[AllowAnonymous]
public ActionResult Login(LoginViewModel model)
{
if (!this.ModelState.IsValid)
{
return View("Index", model);
}
// フォーム認証のチケット発行
FormsAuthentication.SetAuthCookie(model.UserName, false);
// SessionStateに情報を格納
this.Session["セッションその1キー"] = "セッションその1値";
this.Session["セッションその2キー"] = "セッションその2値";
return this.Redirect("~/");
}
// GET: /Account/Logout
public ActionResult Logout()
{
// フォーム認証のチケット削除
FormsAuthentication.SignOut();
return this.Redirect("~/");
}
後に利用するため、SessionStateにも情報を格納します
ユーザー検証してません。このまま使うと大変なことになります
Web.configも設定しておきます
<configuration>
<system.web>
<authentication mode="Forms">
<forms loginUrl="~/Account">
</forms>
</authentication>
</system.web>
</configuration>
##<2.~キャッシュ無効化属性~>
WebAPIのために、「ActionFilterAttribute」を利用して
独自の属性クラスを作ります。
動きを見れたほうがいいので、Traceも出しておきます。
ついでに承認のほうも気になるので、Traceを出します。
※ASP.NET MVC5の時点では、MVCコントローラとWebAPIは別ものとなっていることに注意。2
no-cache用属性
/// <summary>
/// no-cacheヘッダーを付与
/// </summary>
public class NoCacheAttribute : ActionFilterAttribute
{
/// <summary>
/// アクションメソッドの実行後処理
/// no-cacheヘッダーを付与
/// </summary>
/// <param name="actionExecutedContext"></param>
public override void OnActionExecuted(HttpActionExecutedContext actionExecutedContext)
{
System.Diagnostics.Trace.WriteLine("NoCacheAttribute.OnActionExecuted");
base.OnActionExecuted(actionExecutedContext);
// キャッシュコントロールを必要に応じて設定
if (actionExecutedContext.Response.Headers.CacheControl == null)
actionExecutedContext.Response.Headers.CacheControl = new CacheControlHeaderValue();
// no-cacheヘッダーを付与
actionExecutedContext.Response.Headers.CacheControl.NoCache = true;
}
}
no-store用属性
/// <summary>
/// no-storeヘッダーを付与
/// </summary>
public class NoStoreAttribute : ActionFilterAttribute
{
/// <summary>
/// アクションメソッドの実行後処理
/// no-storeヘッダーを付与
/// </summary>
/// <param name="actionExecutedContext"></param>
public override void OnActionExecuted(HttpActionExecutedContext actionExecutedContext)
{
System.Diagnostics.Trace.WriteLine("NoStoreAttribute.OnActionExecuted");
base.OnActionExecuted(actionExecutedContext);
// キャッシュコントロールを必要に応じて設定
if (actionExecutedContext.Response.Headers.CacheControl == null)
actionExecutedContext.Response.Headers.CacheControl = new CacheControlHeaderValue();
// no-storeヘッダーを付与
actionExecutedContext.Response.Headers.CacheControl.NoStore = true;
}
}
Trace出力してくれる承認フィルター
/// <summary>
/// 拡張された承認フィルター
/// </summary>
public class AuthorizeExtendAttribute : AuthorizeAttribute
{
public override void OnAuthorization(System.Web.Http.Controllers.HttpActionContext actionContext)
{
System.Diagnostics.Trace.WriteLine("OnAuthorization");
base.OnAuthorization(actionContext);
}
public override System.Threading.Tasks.Task OnAuthorizationAsync(System.Web.Http.Controllers.HttpActionContext actionContext, System.Threading.CancellationToken cancellationToken)
{
System.Diagnostics.Trace.WriteLine("OnAuthorizationAsync");
return base.OnAuthorizationAsync(actionContext, cancellationToken);
}
protected override bool IsAuthorized(System.Web.Http.Controllers.HttpActionContext actionContext)
{
System.Diagnostics.Trace.WriteLine("IsAuthorized");
return base.IsAuthorized(actionContext);
}
protected override void HandleUnauthorizedRequest(System.Web.Http.Controllers.HttpActionContext actionContext)
{
System.Diagnostics.Trace.WriteLine("HandleUnauthorizedRequest");
base.HandleUnauthorizedRequest(actionContext);
}
}
使う側
[AuthorizeExtend]
[NoCache]
[NoStore]
public class AuthorizeApiController : ApiController
{
// GET: api/AuthorizeApi
public IEnumerable<string> Get()
{
return new string[] { "value1", "value2" };
}
// GET: api/AuthorizeApi/5
public string Get(int id)
{
return "value";
}
}
使ってみたらこんな感じ
###ログイン前
OnAuthorizationAsync
OnAuthorization
IsAuthorized
HandleUnauthorizedRequest
###ログイン後
OnAuthorizationAsync
OnAuthorization
IsAuthorized
NoCacheAttribute.OnActionExecuted
NoStoreAttribute.OnActionExecuted
ヘッダーにno-store、no-cacheが付与されていることを確認できます。
また、ログイン前では、no-store、no-cacheまでいっていないことがわかります。
##<3.~匿名アクセス~>
今度は、匿名アクセスを許容させてみます。
クラスレベルでは認証必須としながら、メソッドレベルで匿名可能とする例。
[AuthorizeExtend]
[NoCache]
[NoStore]
public class AnonymousApiController : ApiController
{
// GET: api/AnonymousApi
[AllowAnonymous]
public IEnumerable<string> Get()
{
return new string[] { "value1", "value2" };
}
// GET: api/AnonymousApi/5
[AllowAnonymous]
public string Get(int id)
{
return "value";
}
}
###ログイン前
OnAuthorizationAsync
OnAuthorization
NoStoreAttribute.OnActionExecuted
NoCacheAttribute.OnActionExecuted
ログインをしていなくても、利用できてます。
また、no-store、no-cacheもきいてます。
##<4.~SessionStateを利用する~>
次は、SessionStateを利用して、ユーザー固有な情報を保持させ
WebAPIもそれに合わせて結果を変えるようにしてみます。
WebAPIは既定でSessionStateを無効化されているので、強制的に有効にします。
HttpContext.Current.SetSessionStateBehavior(SessionStateBehavior.Required);
これを普通に、ActionFilterAttributeで実装したとしても、例外が発生します。
'HttpContext.SetSessionStateBehavior' を呼び出すことができるのは、'HttpApplication.AcquireRequestState' イベントが発生する前だけです。
なので、手っ取り早い方法として、Global.asax.csに入れてみます。
ついでに、AcquireRequestStateもTraceしておきます。
public class Global : System.Web.HttpApplication
{
protected void Application_Start(object sender, EventArgs e)
{
System.Diagnostics.Trace.WriteLine("Application_Start");
AreaRegistration.RegisterAllAreas();
GlobalConfiguration.Configure(WebApiConfig.Register);
FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
RouteConfig.RegisterRoutes(RouteTable.Routes);
}
protected void Application_AuthenticateRequest(object sender, EventArgs e)
{
System.Diagnostics.Trace.WriteLine("Application_AuthenticateRequest");
HttpContext.Current.SetSessionStateBehavior(SessionStateBehavior.Required);
}
protected void Application_AcquireRequestState(object sender, EventArgs e)
{
System.Diagnostics.Trace.WriteLine("Application_AcquireRequestState");
}
}
ApiController
[AuthorizeExtend]
[NoCache]
[NoStore]
public class SessionStateApiController : ApiController
{
// GET: api/SessionStateApi
public IEnumerable<string> Get()
{
IHttpSessionState session = SessionStateUtility.GetHttpSessionStateFromContext(HttpContext.Current);
foreach (string key in session.Keys)
{
yield return key + ":" + session[key].ToString();
}
}
// GET: api/SessionStateApi/5
public string Get(string id)
{
IHttpSessionState session = SessionStateUtility.GetHttpSessionStateFromContext(HttpContext.Current);
return session[id].ToString();
}
}
###ログイン後
Application_AuthenticateRequest
Application_AcquireRequestState
OnAuthorizationAsync
OnAuthorization
IsAuthorized
NoCacheAttribute.OnActionExecuted
NoStoreAttribute.OnActionExecuted
無事に、AccountControllerのログインで設定したSessionStateの内容を
取得できることを確認できました。
##<5.~アプリケーション全体に設定するもの、そうでないもの~>
今回のWebAPIは、アプリケーション内で利用するためのものであるため
・SessionStateは常に利用する。
・認証されないアクセスは許容しない
・キャッシュは認めない、保存させない
ということで、最初から今までの属性を付与してしまいます。
アプリケーション開始時のWebApiConfigに追加
public static class WebApiConfig
{
public static void Register(HttpConfiguration config)
{
// Web API routes
config.MapHttpAttributeRoutes();
config.Routes.MapHttpRoute(
name: "DefaultApi",
routeTemplate: "api/{controller}/{id}",
defaults: new { id = RouteParameter.Optional }
);
// フィルター属性追加
config.Filters.Add(new AuthorizeExtendAttribute);
config.Filters.Add(new NoCacheAttribute);
config.Filters.Add(new NoStoreAttribute);
}
}
特に何も設定しないApiControllerを用意します。
public class SimpleApiController : ApiController
{
// GET: api/SimpleApi
public IEnumerable<string> Get()
{
return new string[] { "value1", "value2" };
}
// GET: api/SimpleApi/5
public string Get(int id)
{
return "value";
}
}
###ログイン前
Application_AuthenticateRequest
Application_AcquireRequestState
OnAuthorizationAsync
OnAuthorization
IsAuthorized
###ログイン後
Application_AuthenticateRequest
Application_AcquireRequestState
OnAuthorizationAsync
OnAuthorization
IsAuthorized
NoStoreAttribute.OnActionExecuted
NoCacheAttribute.OnActionExecuted
ApiControllerには何も属性を設定していませんが
ちゃんと動いています。
##最後に
いかがなもんでしょうか?
このようにしてWebAPIをアプリケーション専用のものとしてしまうと
より効率的に作れるのではないか?と考えた次第でした。
SessionStateは全て有効となるようにしてますが、
要求のURLによって可否を変えることもできるような情報もありました。
こことか、ここが参考になりそうです。
本題とは関係ないところでTraceを出してきましたが、これを見ることで少し動きが見えてくるような気がしたので省かないようにしました。
ソースは、Githubにのせておきます。
Github
https://github.com/darkcrash/WebApiAttributes
明日はkazuhisam3さんです。