WallStudio

技術ブログや創作ブログに届かない雑記です

ASP.NET Core MVC via nginxでTwitterアプリを作るまでの地雷原

プログラムからの投稿です.

WEB開発は今までフレームワークを用いないPHPやクライアントサイドで完結する(JavaScript)タイプのものしか作ってこなかったのですが,ASP.NET CoreがC#で書ける上にプラットフォーム非依存なのでええ感じやんってことで手を出してみました.

.NETはC#とかF#とかC++/CLI なんかを動かすためのMS製のミドルウェアです.長らくプロプライエタリでしたが,最近はMSも気が変わったらしく一部OSS化して.NET Coreとなり,そのWEBフレームワークがASP.NET Coreということらしいです.WEBエンジニアと話しててASP.NETの話を聞いたことが無いのですがあまり使われていないのでしょうか?OSS化するまではコストが嵩む(=お偉いさん的には安心できそう?)イメージで保守的な案件でしか使われてこなかったからなんでしょうか.今となってはフリーで使えますし,UnityやXamarinの人達が「ちょっとバックエンドにサーバ欲しいな」と思ったときに慣れ親しんだ言語・環境で開発できていいと思うんですけどね.

前置きはこれぐらいに,順を追って書いていきます.

何をするか

開発環境

  • Windows 10 pro x64
  • VisualStudio 2017 Com
  • フロントはVS組み込みのIIS Express

本番環境

  • CentOS7
  • nginx(リバースプロキシ)
  • Let's Encrypt
  • SQLite
  • dotnet 2.0

Twitter OAuth1.0でユーザー管理し任意の処理をしつつTwitterAPIでつぶやいたりする.(真の目的は同人誌読み上げアプリKamishibaのバックエンドサーバーですがあくまでTwitterアプリとして必要最小限の一般的な部分だけ書いていきます)

プロジェクトの作成

C#の何がいいかって,VisualStudioが使えるところですよね!CLIでもできるのですが,楽をするためにVS使っていきます.VS2017はモジュール式なのでInstallerからASP.NETコンポーネントを入れます.

f:id:yukawallstudio:20180628052157p:plain

f:id:yukawallstudio:20180628052734p:plain

f:id:yukawallstudio:20180628052738p:plain

認証を個人にすると独自のログインもできるようになりますが,今回は必ずTwitter経由で利用してほしいので認証無しをベースにしていきます.

f:id:yukawallstudio:20180628053117p:plain

プロジェクトの中身はこんな感じです.

  • Controllers/

    XXXController.Index()というメソッドの返り値が http://example.com/XXX/Index のHTMLになります.

  • Models/

    User.csやProduct.csといったデータベースに放り込む系のデータのクラス

  • Views/

    XXXController.Index()の返り値をViewにしておくと,XXX/Index.cshtmlがHTMLとしてクライアントに送信されます.cshtmlはPHPのようにHTMLとC#をごちゃまぜに記述できます.( @if(isHoge) huge.Piyo(); みたいに)Views/Shared/は他のcshtmlからIncludeする系.特に_Layout.cshtmlがデフォルトでIncludeされるみたいです.

  • wwwroot/

    普通のHTMLやJS,画像等.

  • Program.cs

    起動時に一番に実行されるのがProgram.Main(string[] args).これは普通の.NETアプリと変わりません.ASP.NETでは基本的に触りませんが起動時に全く関係ない何かをしたいのであれば.

  • Start.cs

    ユーザー認証や,ヘッダの設定等サーバー全体の設定をするところ.Twitter認証をActiveにするにはこを主にいじる.

開発環境(IIS Express)SSL

f:id:yukawallstudio:20180628061130p:plain

SSL化にチェックを入れて,Host名をlocalhostから172.0.0.1に変更します.これはTwitter(OAuth)の仕様でSSL出ないと受け入れてくれないからです.まぁ鍵を渡すわけですから暗号化は当然ですよね.後TwitterのApplicationManegimaentはlocalhostURIとして認識してくれないのでIPで起動するようにしています.(地雷その1)

実行すると証明書エラーが出ますが開発環境なので無視してデフォルトページを拝みます.

Twitter側の登録

apps.twitter.com

から登録します.CallbackURLsに https://127.0.0.1:44375/signin-twitter と入れて後は適当に入力します.

認証機能を有効化

syncer.jp

本来はこんな感じでTwitterのアクセストークン&シークレットを入手し,それをID&Passとして利用するのですが,ASP.NETにはそれらを全自動でやってくれる機能があります.(ASP.NET Identity)

Startup.cs

public class Startup {
    ...
    public void ConfigureServices(IServiceCollection services) {
        ...
        // 規模的にSQLiteで十分なので,管理が楽なSQLiteに変更しておく(デフォはMS SQL Server)
        services.AddDbContext<ApplicationDbContext>(options =>
            options.UseSqlite("Data Source=a.sqlite"));
        ...
        services.AddAuthentication(options => {
            options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
            options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
            options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        }).AddTwitter(options => {
            options.ConsumerKey = "xxxxxxxxxxxxxxxxxx";
            options.ConsumerSecret = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX";
        })
        .AddCookie();

        services.AddMvc();
    }

    public void Configure(IApplicationBuilder app, IHostingEnvironment env) {
        ...
        app.UseAuthentication();
        ...
    }
}

ログイン用UIを作る

Views/Shared/_Layout.cshtml

<div class="navbar-collapse collapse">
    <ul class="nav navbar-nav">
        <li><a asp-area="" asp-controller="Home" asp-action="Index">Home</a></li>
        <li><a asp-area="" asp-controller="Home" asp-action="About">About</a></li>
        <li><a asp-area="" asp-controller="Home" asp-action="Contact">Contact</a></li>
        <!-- ログインのステータスを表示する -->
        @if (User.Identity.IsAuthenticated){
            <li><a asp-area="" asp-controller="Authentication" asp-action="SignOut">Sign out</a></li>
        } else {
                        <li><a asp-area="" asp-controller="Authentication" asp-action="Login">Login</a></li>
        }
    </ul>
</div>

Views/Authentication/Login.cshtml

@{
    ViewData["Title"] = "Login";
}

<h2>Login</h2>
<!-- 本プロジェクトではTwitterだけなのでforeach回す必要は本当はない -->
@foreach (var item in Model) {
    <a class="btn btn-primary login-button" asp-action="SignIn" asp-route-provider="@item">@item</a>
}

Controllers/Authentication.cs

public class AuthenticationController : Controller {

    private readonly IAuthenticationSchemeProvider authenticationSchemeProvider;

    public AuthenticationController(IAuthenticationSchemeProvider authenticationSchemeProvider) {
        this.authenticationSchemeProvider = authenticationSchemeProvider;
    }

    public async Task<IActionResult> Login() {
        var allSchemeProvider = (await authenticationSchemeProvider.GetAllSchemesAsync())
            .Select(n => n.DisplayName).Where(n => !String.IsNullOrEmpty(n));

        return View(allSchemeProvider);
    }

    // 「Twitter」ボタンを押すとこれが実行される
    public IActionResult SignIn(String provider) {
        return Challenge(new AuthenticationProperties { RedirectUri = "/" }, provider);
    }

    public async Task<IActionResult> SignOut() {
        await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
        return RedirectToAction("Index", "Home");
    }
}

Twitter OAuthログインの確認

PackagesManagerでDBを初期化し,Update-Database VSのデバッグの実行で起動したブラウザで https://127.0.0.1:44375/Authentication/Login を開き,Twitterボタンを押すと…

f:id:yukawallstudio:20180628070153p:plain

Twitterに飛び…

f:id:yukawallstudio:20180628070206p:plain

OK!

参考:

github.com

本番サーバーの準備

ASP.NET Coreは一応KestrelというWEBホスティング機能(http/httpsリクエストを捌く)を内包しています.なのでこれをこのまま:443に設定して公開することもできますが,そうするとOSに対し1プロセスしか:443を使えなくなってしまう上に機能も最低限なのでnginxでhttpsリクエストを全て受け取って,www.wallstudio.workにマッチするものを:5000にhttpリクエストとして転送します.そして:5000でKestrelが待ち構えておけばOKです.

…が,httpsを受け取ってhttpに投げ直す時にヘッダーが全部死にます.どういうことかというと,nginxがhttpsで受けた際には,"プロトコル=https;クライアント=133.5.6.44;リモートホスト:www.wallstudio.work;" みたいになっているのですが,Kestrelが受け取るのは"プロトコル=http;クライアント=127.0.0.1;リモートホスト:localhost;" みたいなことになってしまいます.性的なHTMLを返すだけのような場合には大して問題になりませんが,ユーザー認証を使っていて特にTwitterはCallbackURLが違うと403エラーで弾かれてしまいます.プログラムで正しいCallbackURLを生成できるように,ヘッダーを正しく転送してあげる必要があるのです.(地雷その2)

/etc/nginx/nginx.conf

...
server {
    listen 443;
    # 環境に合わせて
    server_name www.wallstudio.work;

    # Let's Encrypt
    ssl_certificate      /etc/letsencrypt/live/wallstudio.work/fullchain.pem;
    ssl_certificate_key  /etc/letsencrypt/live/wallstudio.work/privkey.pem;

    location / {
        proxy_pass http://localhost:5000;
        proxy_http_version 1.1;
        proxy_set_header Connection keep-alive;
        proxy_set_header Upgrade $http_upgrade;
        # クライアントIP転送
        proxy_set_header X-Real-IP $remote_addr; 
        # プロトコル設定転送
        proxy_set_header X-Forwarded-Proto https;
        # サーバー側のURI転送
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        # サーバーのホスト名転送
        proxy_set_header Host $http_host;
        proxy_cache_bypass $http_upgrade;
        # Basic認証(Basicは平文でパスワード送るので,目隠し程度←)
        auth_basic "Makimaki";
        auth_basic_user_file /etc/nginx/.htpasswd;
    }
}

深堀はしませんが,SSLの設定もついでに(nginx,certbotyumで入ります)

証明書取得

certbot certonly --standalone \
    -d wallstudio.work \
    -d www.wallstudio.work \
    -m yukawallstudio@gmail.com \
    --agree-tos -n

更新用crontab

00 16 * * 2 root /usr/bin/certbot-auto renew --pre-hook "systemctl stop nginx" --post-hook "systemctl start nginx"

更新のたびにサーバーを一回殺しています.本当は1秒でもサーバーが死んでいるというのは良くないです.--standalone はnginxやApacheが立っている(:80が占有されている)と使えませんが,nginxやApache経由で認証する--webrootという方式も実は存在します.ですが,リバースプロキシを使っているとWebサーバーのルートが居なくなってしまう訳です.ASP.NETではwwwrootがルートになるのでできるといえばできますが,この実行ファイルの居場所が変われば変わってしまう危さがあります.本プロジェクトではまぁ数秒落ちても許容Lvなので,一旦nginxを殺す方を選びました.また,DNSサーバーのTEXTレコードで認証する方法もありDNSを自動で書き換えられる環境ならばこっちを使うのが最善だと思います.(ワイルドカード証明書も使える)私は書き換えられる環境ですが,面倒くさくなってやめました.(地雷その3)

www.microsoft.com

dotnetはMSのリポジトリを登録すればyumで入ります.SDKの方も入れておきます.

転送されたヘッダーを受け入れ

Startup.cs

public class Startup{
    public void Configure(IApplicationBuilder app, IHostingEnvironment env){
        ...
        app.UseForwardedHeaders(new ForwardedHeadersOptions{
                ForwardedHeaders
                    = ForwardedHeaders.XForwardedFor 
                    | ForwardedHeaders.XForwardedProto 
                    | ForwardedHeaders.XForwardedHost});
        ...
    }
}

これでサーバーにデプロイの準備ができたのでVSでビルド>発行で吐き出されたファイル群をSCPか何かでサーバーに転送します.nginxとKastrelはTCPで通信するので場所はどこでもいいです.でも間違ってもnginxのデフォルトルートにぶち込んではいけません.(リソースが公開されます)デバッグフラグとして環境変数を設定します.export ASPNETCORE_ENVIRONMENT=Development お待ちかね,dotnet projectname.dll で起動しブラウザからアクセスします.Twitterログインを試みるとこうなるのでApplyでDBを初期化します.更新してもう一度やってみるとログインが成功します.

f:id:yukawallstudio:20180630034442p:plain

ログインはこれで完璧ですね後はTwitterの情報が抜ければ完成です.

アクセストークンの取得

ユーザーがTwitterの許可ボタンを押すとサーバーにTwitterからAccessTokenとAccessSecretが送信されます.しかし,この認証機能(ASP.NET Identity)では折角とってきたこれらを保存しません.ASP.NET君はTwitter等のソーシャルサービスのAPIを叩くことは想定していないのでしょう.セキュリティ的に使わないなら保存すべきではないです.なので自分で保存してあげます.(地雷その4)

AccessToken があればTwitter APIを叩いてユーザーのアイコンやニックネーム,タイムライン,ツイートを読み書きすることができます.HTTPリクエストを直書きするのは面倒くさいのでCoreTweetというラッパーライブラリを利用します.パッケージマネージャから Install-Package CoreTweet でプロジェクトに追加できます.もちろんNugetのGUIの方で検索してインスコしても等価です.

Startup.cs

public class Startup {
    ...
    public void ConfigureServices(IServiceCollection services) {
        ...
            options.ConsumerSecret = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX";
        })
        .AddCookie();

        services.AddMvc();
    }

    public void Configure(IApplicationBuilder app, IHostingEnvironment env) {
        ...
        app.UseAuthentication();
        // Identity(ユーザーの認証情報を持ってるオブジェクト)にTwitter情報もつっこむ
        options.Events.OnCreatingTicket = async context => {
            var identity = (ClaimsIdentity)context.Principal.Identity;
            identity.AddClaim(new Claim(nameof(context.AccessToken), context.AccessToken));
            identity.AddClaim(new Claim(nameof(context.AccessTokenSecret), context.AccessTokenSecret));
            // ついでにユーザーID(一般ユーザーからは見えないID)とスクリーンネーム(@の後のやつ)も
            identity.AddClaim(new Claim(nameof(context.UserId), context.UserId));
            identity.AddClaim(new Claim(nameof(context.ScreenName), context.ScreenName));
        };
        ...
    }
}

Controllers/Authentication.cs

public class AuthenticationController : Controller {

    ...

    // 「Twitter」ボタンを押すとこれが実行される
    public IActionResult SignIn(String provider) {
        return Challenge(new AuthenticationProperties { RedirectUri = "/Authentication/ExternalLoginCallback" }, provider);
        //return Challenge(new AuthenticationProperties { RedirectUri = "/" }, provider);
    }

    public async Task<IActionResult> ExternalLoginCallback(string returnUrl = null, string remoteError = null)
        {
            var accessToken = User.Claims.FirstOrDefault(x => x.Type == "AccessToken")?.Value;
            var accessSecret = User.Claims.FirstOrDefault(x => x.Type == "AccessTokenSecret")?.Value;
            var accessUserId = User.Claims.FirstOrDefault(x => x.Type == "UserId")?.Value;
            var accessScreen = User.Claims.FirstOrDefault(x => x.Type == "ScreenName")?.Value;

            // CoreTweet
            var tokens = Tokens.Create(
                "xxxxxxxxxxxxxxxxx",
                "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
                accessToken, accessSecret);
            var r = await tokens.Statuses.UpdateAsync("マキマキカワイイヤッター!!");
            var s = await tokens.Account.UpdateProfileAsync();

            var isAuthenticated = User.Identity.IsAuthenticated;
            if (remoteError != null) 
                return RedirectToAction(nameof(Login));
            if (!isAuthenticated)
                return RedirectToAction(nameof(Login));

            ViewData.Add("nicname", s.Name);
            return View();
        }
}

ExternalLoginCallback.cshtml

@{
    ViewData["Title"] = "ExternalLoginCallback";
}
<h2>ExternalLoginCallback</h2>
<p>@ViewData["nicname"]</p>

やったぜ☆

f:id:yukawallstudio:20180630041155p:plain

ASP.NET OAuth Twitter Access Token について調べると,プロジェクトテンプレートで個人認証ONで作成した場合の情報はあって,その場合 User.Claims のところが (awite SigninManager.GetExternalLoginInfoAsync).Principal.Claims で取得しています.(地雷その5)

あと,今回はこの先は書きませんが,ここで取得したAccessTokenは次の要求では亡くなってしまうのでDBにユーザーIDと絡めて永続化しておく必要があります.(地雷その6)

いや~なかなかですね…「ASP.NET MVCTwitterアプリ作ってみたよ」的なズバリな記事がその辺に転がってるだろうと思っていたのですが,目算が甘かったですね.お〇箱とかTg〇terとか何で作ってるんでしょうね.まぁC#で書けるてクロスプラットフォームってだけで個人的に幸せみがあるんですけど.まぁでもこれでカードはそろったって感じですね.後はしこしこUIやらDBのインターフェイスを実装していくのみです.順調順調…

って,もう7月じゃあないですかー (;゙^'ω^')どうすんのこれ間に合うの?