vaguely

和歌山に戻りました。ふらふらと色々なものに手を出す毎日。

ASP.NET Core Identity でログイン・ログアウトしてみたい 1

はじめに

前回の通り、 ASP.NET Core でのログイン・ログアウト機能を試してみたいと思います。

ASP.NET Core では ASP.NET Core Identity なるものが用意されている、ということで、これを利用することにします。

とりあえずの目標は、 ASP.NET Core Identity + Entity Framework Core + PostgreSQL を組み合わせてログイン・ログアウト機能を作ってみる、ということにします。

実際にはパスワードの暗号化など不足する部分がでてくるかもですが、一旦は標準で提供されているレベルの機能を使う、くらいに抑えたいと思います。

ASP.NET Core Identity のプロジェクトを作ってみる

先ほどの Microsoft Docs によると、下記コマンドを実行するとログイン・ログアウト機能を持ったプロジェクトが生成できます。

dotnet new webapp --auth Individual -o AspNetCoreIdentifySample
  • 「AspNetCoreIdentifySample」はプロジェクト名です念のため。

生成されたプロジェクトを動かすと、ユーザー登録・ログイン・ログアウトを試すことができます。

コードがない

早速どんな処理が行われているのだろう、と思って生成されたコードを見てみると、ユーザー登録やログインに関連しそうなコードが見当たりません。

なん.........だと.........!?

関連しそうなのは下記ぐらいです( DB を除くと)。

Pages/Shared/_LoginPartial.cshtml

@using Microsoft.AspNetCore.Identity
@inject SignInManager< IdentityUser> SignInManager
@inject UserManager< IdentityUser> UserManager

< ul class="navbar-nav">
@if (SignInManager.IsSignedIn(User))
{
    < li class="nav-item">
        < a  class="nav-link text-dark" asp-area="Identity" asp-page="/Account/Manage/Index" title="Manage">Hello @User.Identity.Name!< /a>
    < /li>
    < li class="nav-item">
        < form class="form-inline" asp-area="Identity" asp-page="/Account/Logout" asp-route-returnUrl="@Url.Page("/", new { area = "" })" method="post" >
            < button  type="submit" class="nav-link btn btn-link text-dark">Logout< /button>
        < /form>
    < /li>
}
else
{
    < li class="nav-item">
        < a class="nav-link text-dark" asp-area="Identity" asp-page="/Account/Register">Register< /a>
    < /li>
    < li class="nav-item">
        < a class="nav-link text-dark" asp-area="Identity" asp-page="/Account/Login">Login< /a>
    < /li>
}
< /ul>

Startup.cs

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.UI;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using AspNetCoreIdentifySample.Data;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

namespace AspNetCoreIdentifySample
{
    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        // This method gets called by the runtime. Use this method to add services to the container.
        public void ConfigureServices(IServiceCollection services)
        {
            ~省略~

            services.AddDbContext< ApplicationDbContext>(options =>
                options.UseSqlite(
                    Configuration.GetConnectionString("DefaultConnection")));
            services.AddDefaultIdentity< IdentityUser>()
                .AddDefaultUI(UIFramework.Bootstrap4)
                .AddEntityFrameworkStores< ApplicationDbContext>();
            
            ~省略~
        }

        // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            ~省略~

            app.UseAuthentication();

            app.UseMvc();
        }
    }
}

_LoginPartial.cshtml はログインページなどへのリンクしか持っていないため、カギを握っているのは Startup での設定となります。

またログインページの UI は、 _Layout.cshtml を下記のような非常にシンプルなものにしても変わりませんでした。

Pages/Shared/_Layout.cshtml

< !DOCTYPE html>
< html>
    < head>
        < meta charset="utf-8" />
    < /head>
    < body>
        @RenderBody()
    < /body>
< /html>

f:id:mslGt:20190306003458p:plain

以上から、完全に別のところからログインページは来ていることがわかります。

Startup.cs の ConfigureServices の引数として渡されている IServiceCollection には、「 services.AddDefaultIdentity< IdentityUser>() 」 の他に 「 services.AddIdentity< T>() 」 なるメソッドも用意されています。

ログイン・ログアウトなどの設定をデフォルトから変更するには、どうやらこの辺りを設定してやれば良さそうです。

と、ドキュメントもロクに読まずに当たりをつけたところで、あれこれ試してみたいと思います。

シンプルなユーザー情報でログインしたい

テンプレートを利用して生成したユーザー情報は、 Data > Migrations のファイルなどを見るとわかりますが、かなり項目が多いです。

  • Id
  • UserName
  • NormalizedUserName
  • Email
  • NormalizedEmail
  • EmailConfirmed
  • PasswordHash
  • SecurityStamp
  • ConcurrencyStamp
  • PhoneNumber
  • PhoneNumberConfirmed
  • TwoFactorEnabled
  • LockoutEnd
  • LockoutEnabled
  • AccessFailedCount

今どきのセキュリティなどを考えれば妥当なのだろうとは思うのですが、とにかく多い。

今回は、必要に応じて項目を追加していくことにして、まず下記の項目だけを使ってログインできるようにしてみたいと思います。

  • Id
  • UserName
  • Password

また手抜きでいきたいので、パスワードもハッシュ化せずに DB に登録しちゃいます(本番環境では決してマネしないでねはーと)。

項目も少ないし、さらっと試してもう少し詳しく調べてみたい。

この時の僕はそんな風に軽く考えていたのです。。。

準備( DB )

とりあえず DB 側の準備です。

ユーザー情報を入れるテーブルだけを用意して、ユーザーを雑に追加します。

CREATE TABLE "User" (
    "UserId" Integer PRIMARY KEY,
    "Name" TEXT NOT NULL,
    "Password" TEXT NOT NULL
)
INSERT INTO "User" (
    "UserId",
    "Name",
    "Password"
)
VALUES(
    0,
    'Example1',
    'Test0123'
)

準備 ( ASO.NET Core )

こちらもシンプルに作りたいため、 Empty でプロジェクトを生成し、スキャフォールドもせずに作っていきます。

NuGet で Microsoft.EntityFrameworkCore と Npgsql.EntityFrameworkCore.PostgreSQL をインストールしておきます。

Startup.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using LoginLogoutSample.Models;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

namespace LoginLogoutSample
{
    public class Startup
    {
        public IConfiguration Configuration { get; }
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddDbContext< LoginLogoutSampleContext>(options =>
                options.UseNpgsql("Host=localhost;Database=LoginLogoutSample;Username=postgres;Password=XXXX"));

            services.AddDefaultIdentity< ApplicationUser>()
                .AddEntityFrameworkStores< LoginLogoutSampleContext>() 
                .AddDefaultTokenProviders();
            
            services.AddMvc();
        }
        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

            app.UseAuthentication();

            app.UseMvc();
        }
    }
}
  • DB 設定の他、先ほどのテンプレートを参考に、 AddDefaultIdentity や UseAuthentication を追加しています。

HomeController.cs

using System;
using System.Threading.Tasks;
using LoginLogoutSample.Models;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using SignInResult = Microsoft.AspNetCore.Identity.SignInResult;

namespace LoginLogoutSample.Controllers
{
    public class HomeController: Controller
    {
        private readonly SignInManager< ApplicationUser> _signInManager;

        public HomeController(SignInManager< ApplicationUser> signInManager)
        {
            _signInManager = signInManager;
        }

        [Route("")]
        [Route("/Home")]
        public async Task Index()
        {
            await Login();
            return "hello";
        }

        [Route("/Account/Login")]
        public async Task Login()
        {
            ApplicationUser debugUser = new ApplicationUser
            {
                UserName = "Example1",
                Password = "Test0123",
            };
            SignInResult result = await _signInManager.PasswordSignInAsync(
                debugUser.UserName, debugUser.Password,
                false, false);
            if (result.Succeeded)
            {
                Console.Write("Success!");
            }
            else
            {
                Console.WriteLine("Failed");
            }
        }
    }
}
  • ログインができるかを確認するため、固定でデータを作成し、ログインするようにしています。

ApplicationUser.cs

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.AspNetCore.Identity;

namespace LoginLogoutSample.Models
{
    [Table("User")]
    public class ApplicationUser: IdentityUser
    {
        [Key]
        [Column("UserId")]
        public int ApplicationUserId { get; set; }
        [Column("Name")]
        public override string UserName { get; set; }
        public string Password { get; set; }
    }
}

ユーザー情報を格納するクラスです。

ASP.NET Framework 時代は IUser インターフェースを継承して、独自のユーザーを作ることができていたようなのですが、 ASP.NET Core では IdentityUser のみに限定されているようです(調べた限りでは)。

LoginLogoutSampleContext.cs

using Microsoft.EntityFrameworkCore;

namespace LoginLogoutSample.Models
{
    public class LoginLogoutSampleContext: DbContext
    {
        public LoginLogoutSampleContext(DbContextOptions< LoginLogoutSampleContext> options)
            : base(options)
        {
            
        }
        public DbSet< ApplicationUser> Users { get; set; }
    }
}
  • Entity Framework Core を使って DB に接続するときと同じですね。

そしてエラー

これを実行すると、なんとエラーになります。

ええもう当然かのように。

理由は AccessFailedCount などのカラムがテーブルにないからです。

上で触れたように、 ASP.NET Core では ASP.NET Core Identity を使う場合、ユーザー情報は IdentityUser 、またはそれを継承したクラスで持つ必要があるようです。

なんでデフォルトをこんなモリモリにしたんや。。。

ということで、考えられる解決方法は 2 つあります。

  1. テーブルに残りの項目を追加する
  2. 不要な項目はテーブルにマッピングしないようにする

1.は使いもしないカラムを増やしまくるのがつらいため、 2.でいきたいところ。

Model クラスで、 DB にマッピングしないようにするには、 NotMapped を使います。

ApplicationUser.cs

using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.AspNetCore.Identity;

namespace LoginLogoutSample.Models
{
    [Table("User")]
    public class ApplicationUser: IdentityUser
    {
        [Key]
        [Column("UserId")]
        public int ApplicationUserId { get; set; }
        [Column("Name")]
        public override string UserName { get; set; }
        public string Password { get; set; }
        
        [NotMapped] public override string Id { get; set; }
        [NotMapped] public override string NormalizedUserName { get; set; }
        [NotMapped] public override string Email { get; set; }
        [NotMapped] public override string NormalizedEmail { get; set; }
        [NotMapped] public override bool EmailConfirmed { get; set; }
        [NotMapped] public override string PasswordHash { get; set; }
        [NotMapped] public override string SecurityStamp { get; set; }
        [NotMapped] public override string ConcurrencyStamp { get; set; }
        [NotMapped] public override string PhoneNumber { get; set; }
        [NotMapped] public override bool PhoneNumberConfirmed { get; set; }
        [NotMapped] public override bool TwoFactorEnabled { get; set; }
        [NotMapped] public override DateTimeOffset? LockoutEnd { get; set; }
        [NotMapped] public override bool LockoutEnabled { get; set; }
        [NotMapped] public override int AccessFailedCount { get; set; }
    }
}

うーむ。。。思うことがないではないのですが、とにかくエラーは出なくなりました。

ログインできない

確かにこれでエラーは出なくなったのですが、相変わらずログインには失敗します。

というのも、 SignInManager.PasswordSignInAsync で引数として渡したユーザー名、パスワードをそのまま使うのではなく、 NotMapped で無視している NormalizedUserName や PasswordHash を使って認証するためです。

ということで、この参照部分を変更しなければなりません。

結局どうしたかというと、下記などを参考に、 IUserPasswordStore を継承したクラスを用意し、認証時に呼ばれるよう設定しました。

Startup.cs

~省略~
public void ConfigureServices(IServiceCollection services)
{
    ~省略~

    services.AddIdentity< ApplicationUser, IdentityRole>()
        .AddUserStore< ApplicationUserStore>()
        .AddEntityFrameworkStores< LoginLogoutSampleContext>() 
        .AddDefaultTokenProviders();
    
    services.AddMvc();
}
~省略~
  • 今回 UserRole については特に何も変更していないため、デフォルトで用意されているクラスをそのまま使っています。

LoginLogoutSampleContext

using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;

namespace LoginLogoutSample.Models
{
    public class ApplicationUserStore: IUserPasswordStore< ApplicationUser>
    {
        private LoginLogoutSampleContext _context;
        
        public ApplicationUserStore(LoginLogoutSampleContext context)
        {
            _context = context;
        }
        public void Dispose() { /* とりあえず何もしない */            
        }

        public Task< string> GetUserIdAsync(ApplicationUser user, CancellationToken cancellationToken)
        {
            return Task.Run(() => user.ApplicationUserId.ToString(), cancellationToken);
        }

        public Task< string> GetUserNameAsync(ApplicationUser user, CancellationToken cancellationToken)
        {
            return Task.Run(() => user.UserName,cancellationToken);
        }

        public Task SetUserNameAsync(ApplicationUser user, string userName, CancellationToken cancellationToken)
        {
            throw new System.NotImplementedException();
        }

        public Task< string> GetNormalizedUserNameAsync(ApplicationUser user, CancellationToken cancellationToken)
        {
            return Task.Run(() => user.UserName.ToUpper(),cancellationToken);
        }

        public Task SetNormalizedUserNameAsync(ApplicationUser user, string normalizedName, CancellationToken cancellationToken)
        {
            throw new System.NotImplementedException();
        }
        public Task< IdentityResult> CreateAsync(ApplicationUser user, CancellationToken cancellationToken)
        {
            throw new System.NotImplementedException();
        }
        public Task< IdentityResult> UpdateAsync(ApplicationUser user, CancellationToken cancellationToken)
        {
            throw new System.NotImplementedException();
        }
        public Task< IdentityResult> DeleteAsync(ApplicationUser user, CancellationToken cancellationToken)
        {
            throw new System.NotImplementedException();
        }

        public Task< ApplicationUser> FindByIdAsync(string userId, CancellationToken cancellationToken)
        {
            return _context.Users.FirstOrDefaultAsync(u => u.ApplicationUserId.ToString() == userId, cancellationToken);
        }
        public Task< ApplicationUser> FindByNameAsync(string normalizedUserName, CancellationToken cancellationToken)
        {
            return _context.Users.FirstOrDefaultAsync(u => u.UserName.ToUpper() == normalizedUserName, cancellationToken);
        }

        public Task SetPasswordHashAsync(ApplicationUser user, string passwordHash, CancellationToken cancellationToken)
        {
            throw new System.NotImplementedException();
        }

        public Task< string> GetPasswordHashAsync(ApplicationUser user, CancellationToken cancellationToken)
        {
            return Task.Run(() => new PasswordHasher< ApplicationUser>().HashPassword(user, user.Password),
                cancellationToken);
        }
        public Task< bool> HasPasswordAsync(ApplicationUser user, CancellationToken cancellationToken)
        {
            return Task.Run(() => string.IsNullOrEmpty(user?.Password), cancellationToken);
        }
    }
}
  • 今回ユーザー情報の追加や編集を行っていないため、実装していないメソッドが結構あります(某勉強会参加の方に見られたら怒られそうです(;´Д`))。
  • IUserStore も継承が必要そうな気がしますが、 IUserPasswordStore が継承しているため不要だそうです。
  • NormalizedUserName は登録されたユーザー名を大文字に変換したものです。
  • GetPasswordHashAsync は、その名の通りハッシュ化されたパスワードを返す必要があり、そのまま返すとエラーになります。 PasswordHasher.HashPassword を使って変換したものを返すようにします。

不足メソッドの生成

話がそれますが、 ApplicationUserStore を実装する際一つ一つのメソッドを手動で追加していくのはなかなか大変ですね。

Rider だと、 Interface (IUserPasswordStore) を追加し、エラーが発生した状態で 右クリック > Generate > Missing members からひとまとめで追加することができます。

中身は全部 throw new System.NotImplementedException() のため、手動で実装が必要であるとはいえ便利ですね。

f:id:mslGt:20190306003913p:plain

おわりに

曲がりなりにもカスタムのユーザー情報を使ったログインもできるようになりました。

次回は IUserPasswordStore や SignInManager など今回登場したクラス・インターフェースを中心に、もう少し追いかけてみたいと思います。

参照