2008年3月21日金曜日

ASP.NET MVCでAjaxな認証

ASP.NET Ajaxとの相性が良くないよね、という思いからprototype.jsへ返り咲き。 jQueryにはすごいのがあったしね。あ、でもそこまで(Controllerを派生させてExecuteとRenderViewをオーバーライドして、Site.Masterか Ajax.Masterか切り替え。さらにクライアントサイドのjQueryで対象エレメント部だけを抽出しなおして描画に反映。すごくて感動した)のはちょっと面倒なので、軽くね。

  1. まず、普通にASP.NET MVC(Preview2)でプロジェクト作成。
  2. そしたらControllers/Models/Views/Contentフォルダができるので、このContentにprototype.jsを入れる。
  3. Controllersに名前を"SecureController"を作成(なんでもいいんだけど)。
  4. Controllersに名前を"ApiController"を作成(なんでもいいんだけど)。
  5. Views/Homeフォルダに"Mvc View Content Page"をテンプレートに"Login.aspx"を作成。
  6. 同じくViews/Apiフォルダ(作る)に"Mvc View Page"(ここ大事!)をテンプレートに"Login.aspx"を作成。
  7. Views/Secureフォルダ(作る)に"Mvc View Content Page"として"Index.aspx"を作成。

ここまでで準備完了。イメージ的には/Secure/Indexはログインしてないとアクセスできないよ、って言う感じです。 ルートのweb.configでForm認証使うことと、セキュアなパスを指定。

        <authentication mode="Forms">
           <forms loginUrl="/Home/Login"></forms>
       </authentication>

↑これと↓これ。

<location path="Secure"> <system.web> <authorization> <deny users="?"/> </authorization> </system.web> </location>

Views/Shared/Site.Masterのhead要素内にprototype.jsへのscriptタグを作成。

    <script src="/Content/prototype.js" type="text/javascript"></script> 

※常にルートを指すようにしてるけど、相対で参照するようにしたい場合は、ヘルパーを作りましょう。標準で用意してくれることを希望しつつ。 Views/Home/Indexの中身は単純にSecure/Indexへのリンクだけ。これをActionLink<T>で作成(なんかこれがお勧めなリンクの作り方っぽい)。

<%@ Page Language="C#" MasterPageFile="~/Views/Shared/Site.Master" AutoEventWireup="true" CodeBehind="Index.aspx.cs" Inherits="MvcApplication1.Views.Home.Index" %>
<%@import Namespace="MvcApplication1.Controllers" %>
<asp:Content ID="indexContent" ContentPlaceHolderID="MainContentPlaceHolder" runat="server">
   <h2>Ajaxな認証機能</h2>
   <p>
   <%= Html.ActionLink<SecureController>(c=>c.Index(),"Secure Page") %>
   </p>
</asp:Content> 

※@importでコントローラのnamespaceを指定しておく。

img.aspx

"Secure Page"のリンク先は"/Secure/Index"(Indexは省略されてるけど)。 中身は単純に↓こんな感じにしときました。

<h2>要認証!!</h2>
ログインしてないとみれないよ。<br />
Welcome <b><%=User.Identity.Name %></b>!!

この段階でリンクをクリックすると認証してないので、単純に”Home/Login”にリダイレクトされます。なので、今度はそっちを用意。

<%@ Page Title="" Language="C#" MasterPageFile="~/Views/Shared/Site.Master" AutoEventWireup="true" CodeBehind="Login.aspx.cs" Inherits="MvcApplication1.Views.Home.Login" %>
<asp:Content ID="Content1" ContentPlaceHolderID="MainContentPlaceHolder" runat="server">
<form>
 <table>
 <tr>
   <th>Longin ID</th>
   <td><%=Html.TextBox("id") %></td>
 </tr>
 <tr>
   <th>Password</th>
   <td><%=Html.Password("pass") %></td>
 </tr>
 <tr>
   <td colspan="2"><input type="button" value="Login" onclick="login()" /></td>
 </tr>
 </table>
</form>
<script type="text/javascript">
function login() {
 var params = $H({"id":$F("id"), "pass":$F("pass")}).toQueryString();

 new Ajax.Request("/Api/Login",{
   method:"post",
   postBody:params,
   onComplete:function(req){
     var status = req.status;
   
     //req.responseText
     if (status==200)
       alert("Success!! " + req.responseText);
     else
       alert("Fail... " + req.responseText);
   }
 });
}
</script>
</asp:Content> 

認証するためのリクエストパスが"Api/Login"になってるのと、認証結果はRESTfulっぽくステータスコードで判定。今回は成功すれば200(OK)、失敗すれば406(Not Acceptable)を利用(使い方があってるのか自信ないけど...)。 HTTPステータスコード – Wikipedia

続いて、Api/Loginの中身。今回は単純なIDとPasswordを利用してみます。実際にはMembershipを使うと思うけど。

    public void Login()
   {
     string id = this.ReadFromRequest("id");
     string pass = this.ReadFromRequest("pass");

     ViewData["login"] = false;
     if (id == "yama" && pass == "kawa")
     {
       ViewData["login"] = true;
       FormsAuthentication.SetAuthCookie(id, false);
     }

     RenderView("Login");
   } 

続いて、Views/Api/Login。 ここは戻り値を返すだけの機能にしたいし、レスポンス内容をJsonにしてみるために、Masterを参照しないページ(Mvc View Page)にしました。

<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="Login.aspx.cs" Inherits="MvcApplication1.Views.Api.Login" %>
(<%= ResponseJson() %>) 

ページの中身は↑これだけ。

public void Page_Load() { Response.ContentType = "application/json"; if ((bool)ViewData["login"] == true) Response.StatusCode = 200; else Response.StatusCode = 406; } public string ResponseJson() { return "{\"result\":" + (ViewData["login"]+"").ToLower() + "}"; }

↑コードもこれだけ。Jsonをベタ書きしてるけど、実際には上手いことシリアライズを使うでしょうね。 で、動かす! img.aspx2

あえて、違うID/Passwordを入れてみる。

img.aspx3

正しく入れる。

img.aspx4

この成功か失敗の判定はレスポンス内容のJsonをevalして判定せず、XHRのstatsプロパティが200かどうかで判定。RESTfulっぽい~。 画像だと分かりにくいけど、画面のリフレッシュは発生してないからね! 最後に、Home/Indexに戻って"Secure Page"のリンクをクリックすると~。

img.aspx5

イエ~イ。 実際にはControllerでSetAuthCookieやるより、Viewでやるの?ViewでJsonとか返したかったらControllerFactory使うの?えらい人教えて! これやってて思ったけど、承認はアクション毎にかけるのがMVCっぽいんだね(今回は違うけど)。リソースに対するアクションに権限があるかどうかの方が、コントローラに権限があるかどうかよりもRESTfulだし。フォルダに制限をかけるっていう発想じゃなくて、誰がどのリソースにどんな権限があるかってことだもんね。 結局ActionFilterAttributeで制限かけるのが王道になるのかな~。

2008年3月20日木曜日

ASP.NET MVCでアクセス制限をかける

Webアプリケーションには必ず必要だよね、認証(Authentication)と承認(Authorization)。 はてさてMVC形式でフォルダが割り振られ、物理フォルダの階層とルーティング機能でアクセスするURLのマッピングが一致してないASP.NET MVCではどのようにアクセス制限をかけましょうか。悩ましいですたい。

とりあえずスコットさんとこで確認したところMembershipやらRoleやらは今まで通り使えるってことなので、その辺aspnet_regsqlコマンドとか使って事前に準備を済ませ、ユーザー登録部分も普通にCreateUserWizardコントロールで作成、ログインはLoginコントロールを使って作成。 ただし、この場合runat="server"属性のformが必要になるので、美しさは少し欠ける。綺麗にするならHtml.Formヘルパー使って form要素を作成し、Viewもちゃんと自分でHTML書いてやるのがいいですよね。でも、その方法だと試しに作るには面倒すぎるので、とりあえずでよければ既存コントロールを使って自身にPostbackした後、Response.RedirectでContoroller/Action形式なりの URLに飛ばすことで認証はOKでしょう。

で、今度は承認。 まずはオーソドックスにViewsフォルダの中のController別フォルダにweb.configを入れてみた。 が、しかし上手くいかず。Authorizeするタイミングがこのタイミングだと遅いんだろうか。 ※本家Scottさんとこのコメント欄でそれらしいこと書いてた(ルーティングはOnPostMapRequestRequestHandler eventの後ですよって)。 で、ってことで、ルートのWeb.Configにlocation入れてみた。そしたら、これはすんなりできた。ロールに合わせてコントローラを分けるように作ればフォルダ単位で制御できるから今までとおなじように扱える。ただし、pathにアクションまで含めても上手くいかなかった。そんな使い方するのかどうか微妙だけど、気になるから調べてみたら、2つの方法で解決できる模様(実質1つか)。 まずは標準の方法でPrincipalPermission属性をアクション(コントローラのメソッド)に指定する方法。 ASP.NET MVC framework - Security もうひとつがSystem.Web.Mvc.ActionFilterAttributeを派生させてカスタム属性を作ってそこで制御する方法(要するにどっちも属性なんだけど)。 Rob Conery » ASP.NET MVC: Securing Your Controller Actions どっちも例外発生するからApplication_Errorで例外をハンドリング。 アクション毎に制限をかけたいっていう需要がどの程度これから出てくるか(個人的にそういう設計にするかどうか)分からないけど、ソースをいじるより web.configいじったほうが気分が楽だから、コントローラ単位の承認(Authorize)がいいんじゃなかろうかと思う次第でやんす。 全然話はかわるんですが↓これがちょっとすごい。 Chris van de Steeg’s blog - What’s practical is logical ASP.NET MVC+jQuery。テンプレートまで用意してる。ViewでJSON返すよ~とか、Ajaxで簡単取得(Htmlヘルパーも拡張してる)とか。

2008年3月13日木曜日

ASP.NET MVCとASP.NET AJAX

ポストバック手法とMVC手法は相互に乗り入れるということを前提に作るものじゃないってことなんだろうか。 ASP.NET MVCでのサンプルを作ってみて、コントローラ/アクションからビューへの展開が今までのポストバック手法(WebForms)に比べて新鮮で面白い。クリーンなHTML(ViewStateとかその他のスクリプト)を出力するし。 型指定のViewDataじゃないと無駄にコードが長くなる(Viewの中で)ので、基本的にはきちんとViewData用のクラスを用意して利用(ViewPage<ViewDataの型>)するべし。

    <tr>
     <th>カテゴリ</th>
     <td><%=Html.Select("CategoryID", ViewData["Categories"] as List<Category>, (ViewData["Product"] as Product).CategoryID)%></td>
   </tr>
   <tr>
     <th>業者</th>
     <td><%=Html.Select("SupplierID", ViewData["Suppliers"] as List<Supplier>, (ViewData["Product"] as Product).SupplierID)%></td>
   </tr>
   <tr>
     <th>単価</th>
     <td><%=Html.TextBox("UnitPrice", (ViewData["Product"] as Product).UnitPrice)%></td>
   </tr>

↑キャスト長すぎ...。

  public class ProductEditViewData
 {
   public Product Product { get; set; }
   public List<Supplier> Suppliers { get; set; }
   public List<Category> Categories { get; set; }
 }

↑これがあれば↓これで済む。

    <tr>
     <th>カテゴリ</th>
     <td><%=Html.Select("CategoryID", ViewData.Categories, ViewData.Product.CategoryID)%></td>
   </tr>
   <tr>
     <th>業者</th>
     <td><%=Html.Select("SupplierID", ViewData.Suppliers, ViewData.Product.SupplierID)%></td>
   </tr>
   <tr>
     <th>単価</th>
     <td><%=Html.TextBox("UnitPrice", ViewData.Product.UnitPrice)%></td>
   </tr> 

※ベタだけどNorthwind使ってスコットさんと同じように作ってみました。 ちなみにこれらのFormの値をコントローラで取得する場合は、UpdateFormを使う模様。

Product product = new Product(); BindingHelperExtensions.UpdateFrom(product, Request.Form, new[] { "ProductName", "CategoryID", "SupplierID", "UnitPrice" });

※ヘルパーとか12月のやつからちょこちょこ変わってる。 Formの値を個別に取得する場合は、コントローラの拡張メソッドになってるReadFromRequestを使う。 Html の要素作成にヘルパー使うなら気にしないけど、手書き(input/radio/textarea/select/hidden)のときはnameに指定したものじゃないとサーバーで値取れません(単なるRequest.Form/QueryStringだから当たり前なんだけど)。 runat="server"じゃないし。 で、もちろんViewStateなんてないのでイロイロこまごましたのは自分でちゃんと実装する必要があり。Pageのインスタンスをサーバーサイドで再構築出来ないんだから。 ここで気になるのがASP.NET AJAXとの絡み。試してみたけどやっぱりすんなりできるものじゃないっす。ASP.NET AJAXのうまみは基本的に全く利用できない感じです。UpdatePanelとかに必要なデータを自分で文字列生成して返すとか考えられない。自身にポストバックする従来の方法なら、いくらでもPage再構築出来るかもしれないけど、コントローラに"post"するんだからサーバーサイドでPageの再構築とか厳しい。 そうなると、普通に自分でAjaxを実装(もちろんライブラリを使うけど)していくことになって、あっちをとればこっちが立たずみたいな。機能的に完全に分離してもよくて、運用サイドが使うような機能なら従来の手法(楽ちんに作れる)、ユーザーサイドが使うならMVC(綺麗に作れる)とかなのかな~?

そもそもASP.NET AJAXを使ってないから、あんまり気にしないんだけど、実際市場ではどういう使い分けをするのかが気になるデス。 そうそう、ASP.NET MVCのパフォーマンスってどうよ?みたいなことも、もちろん気になるわけで。

従来のフローをはしょって書くと↓。 System.Web.HttpRuntime → System.Web.HttpApplication → Pageのインスタンス

MVCだと↓。

System.Web.HttpRuntime → System.Web.HttpApplication → System.Web.Mvc.MvcHandler(ルーティング) → System.Web.Mvc.Controller(ターゲットアクション探す:リフレクション) → System.Web.Mvc.ActionFilterExecutor(アクション起動) → 自分のコントローラ → System.Web.Mvc.WebFormViewEngine(ビューへの橋渡し) → System.Web.Mvc.ViewPage.RenderView(ビュー準備) → Pageのインスタンス → 自分のView

たぶんこうなんじゃないかと...。 なんとなくそうなのかなっていう程度です。 途中"自分のコントローラ"の直前に[ネイティブからマネージの移行][マネージからネイティブへの移行]っていうのが入ってて、これが何してんのかよくわかなかった。 ※ちなみに、IISじゃなくてVS2008から起動するWebServer上で、デバッグ実行時の呼び出し履歴を見ただけなので、かなり推測でしゃべってます。 少なくとも、Page(View)に到達するまでに結構いろいろやってる。でもポストバック元のPageをオンメモリに再構築するっていう手間がないから、結局どっちがいいのか判断難しいところです(CPUリソースかメモリリソースかっていう問題なのかな)。 あとね~、LINQ to SQL。DataContextは結局コントローラのプロパティにして使いまわすのでいいんじゃないかな。いろいろなところで、 HttpContext.Current.Itemsに突っ込んで同一コンテキスト内ではシングルトンな使い方がいいみたいに書いてたけど。

public class DContext { public static T Get() where T : System.Data.Linq.DataContext, new() { // HttpContextが不明なら常にnewして返す if (HttpContext.Current == null) { System.Diagnostics.Debug.WriteLine("no context!"); return new T(); } T context; // ItemのKeyを生成(同一コンテキスト内なら同じキーができるはず) string key = "__WRSCDC_" + HttpContext.Current.GetHashCode().ToString("x") + Thread.CurrentContext.ContextID.ToString(); context = HttpContext.Current.Items[key] as T; if (context == null) { context = new T(); HttpContext.Current.Items[key] = context; System.Diagnostics.Debug.WriteLine("new context!! key:" + key); } return context; } }

※試しに作ったクラス。

パフォーマンスをテストしてみたけど、普通にコントローラでDataContext使うのが早かった。 もちろんコード自体は分離していかないと、ごっちゃごちゃになっちゃうけど、DataContextそのものをPartialで書き足して行かないで、別途機能ドメイン毎にクラスを作って、それぞれの中で個別にDataContextのインスタンスを作るくらいでいいんじゃないかと。 複数のDataContextが1度のリクエストで発生するのが無駄遣いなら、コントローラで作ったDataContextのインスタンスを渡すとか。

なんだかんだ大量のデータ更新を一度にやりたい時とかはストアド使ってやればいいし。ホントはバッチ更新とかできるのかなとも思ったけどそれは無理みたい。

Public Sector Developer Weblog : I was wrong :(

トランザクションが良くわかんなかったけど、分散でまたがないし、複数DataContextを使わないならTransactionScopeいらないっぽい。SubmitChangesが上手いことやってくれるっぽい。

方法 : データ送信をトランザクションで囲む (LINQ to SQL)

ちなみに↑これはts.comlete()を実行してないから動かないと思うんだけど、どうなんでしょう(試した限りではコミットしてくれなかった)? なんだかんだと、まとまりのない文章になっちゃった。 誰かえらい人イロイロ教えて。

2008年3月12日水曜日

Normalization

ASP.NET MVC p2をいじってみてるんだけど、クラスのリファレンスが見当たらないのは気のせいなのかな。RouteValueDictionaryって?IDictionary<string,object>のコンストラクタの使い方が分からなくて足止め。 ASP.NET MVC Normalization - ASP.NET Forums

new Dictionary<string,object> { {"class", "user-login" } } 
↓instead of  
new { @class = "user-login" }  

@classってなんだ!?Verbatim?ダブルクォーテーションなくてもいいのか。結局はリテラルと同じ動きになるね(匿名クラスでclassって名前のメンバを使うからあえて@つけてるのかな~)。 匿名クラスはそのままConsole.WriteLineで書きだすと、inspect的な出力になるのに、class定義して同じようにしてもクラス名しか出力しない。c#でinspectってないのかな。

class cdct { public string key1 { get; set; } public string key2 { get; set; } public string key3 { get; set; } } var dct1 = new { key1 = "value1", key2 = "value2", key3 = "value3" }; var dct2 = new cdct { key1 = "value1", key2 = "value2", key3 = "value3" }; Console.WriteLine(dct1); // => { key1 = value1, key2 = value2, key3 = value3 } Console.WriteLine(dct2); // => ConsoleApplication.cdct

簡単な拡張メソッドでそれっぽくしてみた。

static class ObjectExt { public static string Inspect(this object obj) { return Inspect(obj, false); } public static string Inspect(this object obj, bool withType) { bool first = true; string inspect = ""; Type t = obj.GetType(); foreach (MemberInfo mi in t.GetMembers()) { if (mi.MemberType == MemberTypes.Property) { inspect += (first ? "" : ",") + mi.Name + "=" + t.GetProperty(mi.Name).GetValue(obj, null); first = false; } } return (withType ? t.ToString() : "") + "{" + inspect + "}"; } }

へぼいから取扱注意。

Console.WriteLine(dct1.Inspect()); // => {key1=value1,key2=value2,key3=value3} Console.WriteLine(dct2.Inspect()); // => {key1=value1,key2=value2,key3=value3}