ASP.NET Identityを利用してTwitter認証を実装する最低限のコード

ASP.NETを学習し始めたので、
とりあえず小さなWebアプリを作ってみようと思いました。

最初にログイン機能を作らないと何もできないので、
手軽にTwitter認証ができるということなので、
ASP.NET Identityを利用して作ることにしました。

ググった方法でやってみると、確かに簡単なのですが、
Visual Studioが自動で書いてくれた大量のコードに数行追加するだけだから簡単、
という話なのでちょっと。

下手すると作る予定のWebアプリより多機能そうなユーザー管理機能をデフォルトで用意されてしまうと、
なんというか邪魔です。

なので、Twitter認証を実現する最低限の実装を調べてみました。
多少調査に手間取ったので、結果を書いておきます。

深い理解しているわけでもないのに認証関連で最小限のコードを目指したので、
セキュリティ的に重要なコードが抜けている可能性があります。
気付いた方がいたら指摘ください。

使ったのは
Visual Studio Community 2015 
ASP.NET MVCのバージョンは5.2.3でした。

NuGetでMicrosoft.AspNet.Identity.Owinを入れておく必要があるようです。

ASP.NETのプロジェクトを、EnptyにMVCのチェックをつけて作成します。

Twitter Developpersへのアプリの登録の説明はあちこちにあるので省略。
Websiteは開発用に
http://127.0.0.1:60003/
とかで。
60003の部分はポート番号なので、プロジェクトのプロパティのWebタグのプロジェクトのURLにあるものに合わせます。

以下コードの説明。
名前空間などは省略してあります。

``` /App_Start/RouteConfig.cs
public class RouteConfig
{
    public static void RegisterRoutes(RouteCollection routes)
    {
        routes.IgnoreRoute("{resource}.axd/{*pathInfo}");
        routes.MapRoute(
            name: "Default",
            url: "{controller}/{action}/{id}",
            defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }
        );
    }
}
```
この部分はデフォルトで用意されたままです。



``` /Global.asax
public class Global : HttpApplication
{
    void Application_Start(object sender, EventArgs e)
    {
        AreaRegistration.RegisterAllAreas();
        RouteConfig.RegisterRoutes(RouteTable.Routes);            
    }
}
```
これも用意されたまま使っています。



``` /Models/User.cs
    public class User : IUser<string>
    {
        public string Id => UserName;

        public string UserName { get; set; }
    }
```
アプリ内でユーザーを表すクラスです。
あまり利用しないので最低限の機能だけつけました。



``` /Models/UserStore.cs
public class UserStore : IUserStore<User>
{
    static List<User> cache = new List<User>();

    public void Dispose()
    {
    }

    public Task<User> FindByNameAsync(string userName)
    {
        return Task.FromResult(
            (from user in cache
                where user.UserName == userName
                select user)
            .SingleOrDefault());
    }

    public Task CreateAsync(User user)
    {
        if (!cache.Any(already => already.Id == user.Id))
        {
            cache.Add(user);
        }
        return Task.CompletedTask;
    }

    public Task DeleteAsync(User user) { throw new NotImplementedException(); }

    public Task<User> FindByIdAsync(string userId) { throw new NotImplementedException(); }

    public Task UpdateAsync(User user) { throw new NotImplementedException(); }
}
```
アプリ内でユーザーを管理するクラス。
これもできるだけ最低限の機能で済ませました。
ユーザーはstatic変数に突っ込んであるだけなので、アプリ再起動したら消えますが、
ユーザー情報をほとんど利用しないならこれでなんとかなります。
普通はDBに保存とかしたほうが良いでしょう。

Task.FromResultとかTask.CompletedTaskはあまり見かけない機能かもしれませんが、
これは単にasyncメソッドのWarning対策なので認証とは関係ありません。




``` /Startup.cs
[assembly: OwinStartup(typeof(Startup))]
public class Startup
{
    public void Configuration(IAppBuilder app)
    {
        app.CreatePerOwinContext<UserManager<User>>(() => new UserManager<User>(new UserStore()));
        app.UseCookieAuthentication(new CookieAuthenticationOptions
        {
            AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie,
            LoginPath = new PathString("/Home/Index"),
            Provider = new CookieAuthenticationProvider
            {
                OnValidateIdentity = SecurityStampValidator.OnValidateIdentity<UserManager<User>, User>(
                    TimeSpan.FromMinutes(30),
                    async (manager, user) => await manager.CreateIdentityAsync(user, DefaultAuthenticationTypes.ApplicationCookie))
            }
        });
        app.UseExternalSignInCookie(DefaultAuthenticationTypes.ExternalCookie);

        app.UseTwitterAuthentication(
            new TwitterAuthenticationOptions
            {
                ConsumerKey = "TwitterからもらったConsumer Key (API Key)をここに書き込んでください。",
                ConsumerSecret = "TwitterからもらったConsumer Secret (API Secret)をここに書き込んでください。",
                BackchannelCertificateValidator =
                new CertificateSubjectKeyIdentifierValidator(
                    new[]
                    {
                        "A5EF0B11CEC04103A34A659048B21CE0572D7D47", // VeriSign Class 3 Secure Server CA - G2
                        "0D445C165344C1827E1D20AB25F40163D8BE79A5", // VeriSign Class 3 Secure Server CA - G3
                        "7FD365A7C2DDECBBF03009F34339FA02AF333133", // VeriSign Class 3 Public Primary Certification Authority - G5
                        "39A55D933676616E73A761DFA16A7E59CDE66FAD", // Symantec Class 3 Secure Server CA - G4
                        "4eb6d578499b1ccf5f581ead56be3d9b6744a5e5", // VeriSign Class 3 Primary CA - G5
                        "5168FF90AF0207753CCCD9656462A212B859723B", // DigiCert SHA2 High Assurance Server C‎A 
                        "B13EC36903F8BF4701D498261A0802EF63642BC3" // DigiCert High Assurance EV Root CA
                    })
            });
    }
}
```
ConsumerKey とConsumerSecret はTwitterからもらったものを記入してください。

BackchannelCertificateValidator に設定してある英数字の羅列は、認証方法の設定を登録しているようです。
普通はなくても動くようなのですが、必要とされる条件がわかりませんでした。
localhostになっているのが怪しいかもしれません。



``` /Controllers/AccountController.cs
[Authorize]
public class AccountController : Controller
{
    public ActionResult LogOff()
    {
        HttpContext.GetOwinContext().Authentication.SignOut(DefaultAuthenticationTypes.ApplicationCookie);
        return RedirectToAction("Index", "Home");
    }
        
    [AllowAnonymous]
    public ActionResult ExternalLogin()
    {
        return new ChallengeResult("Twitter", Url.Action("ExternalLoginCallback", "Account"));
    }

    [AllowAnonymous]
    public async Task<ActionResult> ExternalLoginCallback()
    {
        var loginInfo = await HttpContext.GetOwinContext().Authentication.GetExternalLoginInfoAsync();
            
        if (loginInfo.ExternalIdentity.IsAuthenticated)
        {
            var manager = HttpContext.GetOwinContext().GetUserManager<UserManager<User>>();
            var user = new User { UserName = loginInfo.DefaultUserName};
            await manager.CreateAsync(user);
            var identity = manager.CreateIdentity(user, DefaultAuthenticationTypes.ApplicationCookie);
            HttpContext.GetOwinContext().Authentication.SignIn(identity);
        }
        return RedirectToAction("Index", "Home");
    }

    internal class ChallengeResult : HttpUnauthorizedResult
    {

        public ChallengeResult(string provider, string redirectUri)
        {
            LoginProvider = provider;
            RedirectUri = redirectUri;
        }

        public string LoginProvider { get; set; }
        public string RedirectUri { get; set; }

        public override void ExecuteResult(ControllerContext context)
        {
            context.HttpContext.GetOwinContext().Authentication.Challenge(
                new AuthenticationProperties { RedirectUri = RedirectUri },
                LoginProvider);
        }
    }
}
```
認証用のコントローラーです。



認証用のコードはここまでで、
あとはこれを利用する最小限のMVCとなります。



``` /Models/Message
public class Message
{
    public string Value { get; set; }
}
```
モデル。最小限の機能です。



``` /Controllers/HomeController.cs
public class HomeController : Controller
{

    public ActionResult Index()
    {
        var model = new Message();
        if (Request.IsAuthenticated)
        {
            model.Value = $"ようこそ{User.Identity.Name}さん。";
        }
        else
        {
            model.Value = "ログインしてください。";
        }
        return View(model);
    }
}
```
コントローラー。
適当にメッセージを設定します。
ここでログインユーザーの名前は利用できます。


``` /Views/Home/Index.cshtml
@model Web.Models.Message
@{
    ViewBag.Title = "Index";
}

@Model.Value
```
ビュー。上記のHomeControllerで設定したメッセージを表示します。
@modelにあるのはモデルの名前空間込みのクラス名です。



``` /Views/Shared/_Layout.cshtml
@using Microsoft.Owin.Security
@using System.Web
@using Microsoft.AspNet.Identity
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>@ViewBag.Title - </title>
    <link href="~/Content/Site.css" rel="stylesheet" type="text/css" />
    <link href="~/Content/bootstrap.min.css" rel="stylesheet" type="text/css" />
    <script src="~/Scripts/modernizr-2.6.2.js"></script>
</head>
<body>
    <div class="navbar navbar-inverse navbar-fixed-top">
        <div class="container">
            <div class="navbar-header">
                <button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
                    <span class="icon-bar"></span>
                    <span class="icon-bar"></span>
                    <span class="icon-bar"></span>
                </button>
                @Html.ActionLink("ホーム", "Index", "Home", new { area = "" }, new { @class = "navbar-brand" })

            </div>
            <div class="nav navbar-nav navbar-right">
                @if (Request.IsAuthenticated)
                {

                    <a href=@Url.Action("LogOff", "Account") class="btn btn-link">
                        <img src="~/Images/TwitterLogo.png" />
                        ログアウト
                    </a>
                }
                else
                {
                    <a href=@Url.Action("ExternalLogin", "Account") class="btn btn-link">
                        <img src="~/Images/TwitterLogo.png" />
                        ログイン
                    </a>
                }
            </div>
            <div class="navbar-collapse collapse">
                <ul class="nav navbar-nav"></ul>
            </div>
        </div>
    </div>

    <div class="container body-content">
        @RenderBody()
        <hr />
        <footer>
            <p>&copy; @DateTime.Now.Year - Marimo</p>
        </footer>
    </div>

    <script src="~/Scripts/jquery-1.10.2.min.js"></script>
    <script src="~/Scripts/bootstrap.min.js"></script>
</body>
</html>
```
ここだけは最低限ではないです。
最低限にすると見た目がたいへんしょぼくなるので。

ログインとログアウトのリンクはここに書いてあります。
真ん中あたりですね。
ログイン済みかどうかで切り替えています。




ほかいろいろ、MVCで必要なファイルはありますが、
Visual Studioが用意したままで、かつたぶん認証と関係ないので省略します。