JwtBearer Authentication の裏で ASP.NET Core Identity を使う

認証に ASP.NET Core Identity を使うけど、アプリ向けに JwtBearer の認証もサポートしたい。JwtBearer でトークンを発行するときは、ユーザーを認証するコードを書くことになるわけなので、そこで Identity の SignInManager や UserManager を使えばいいかもしれない。早速試してみた。

using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using System.Threading.Tasks;

namespace IdentityJwtSample
{
    public class Program
    {
        public static void Main(string[] args)
        {
            BuildWebHost(args).Run();
        }

        public static IWebHost BuildWebHost(string[] args)
        {
            // 構成にはメモリ内プロバイダーを使う
            var dict = new Dictionary<string, string>
            {
                ["ConnectionStrings:DefaultConnection"] = "Server=(localdb)\\mssqllocaldb;Database=aspnet-IdentityJwtSample-550CDEDB-AB81-4711-8A92-0988E2505FD1;Trusted_Connection=True;MultipleActiveResultSets=true",
                ["JwtKey"] = "9AC93E2B-8C4F-46C3-B9E5-F7E7CB37A73A",
                ["JwtIssuer"] = "http://yourdomain.com",
            };
            var builder = new ConfigurationBuilder();
            builder.AddInMemoryCollection(dict);

            return WebHost.CreateDefaultBuilder(args)
                .UseStartup<Startup>()
                .UseConfiguration(builder.Build())
                .Build();
        }
    }

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

        public IConfiguration Configuration { get; }

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddDbContext<ApplicationDbContext>(options =>
                options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));

            services.AddIdentity<ApplicationUser, IdentityRole>()
                .AddEntityFrameworkStores<ApplicationDbContext>()
                .AddDefaultTokenProviders();

            services.AddAuthentication(options =>
            {
                // JWT Bearer をデフォルトにする
                options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
                options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
                options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
            }).AddJwtBearer(options =>
            {
                options.SaveToken = true;
                options.TokenValidationParameters = new TokenValidationParameters
                {
                    ValidIssuer = Configuration["JwtIssuer"],
                    ValidAudience = Configuration["JwtIssuer"],
                    IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Configuration["JwtKey"])),
                };
            });

            services.AddMvc();
        }

        public void Configure(IApplicationBuilder app, IHostingEnvironment env, ApplicationDbContext dbContext)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

            app.UseAuthentication();

            app.UseMvc();

            // データベースが無ければ作る
            dbContext.Database.EnsureCreated();
        }
    }

    public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
    {
        public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
            : base(options)
        {
        }

        protected override void OnModelCreating(ModelBuilder builder)
        {
            base.OnModelCreating(builder);
        }
    }

    // アプリケーション用ユーザー
    public class ApplicationUser : IdentityUser
    {
    }

    // ログインに使うパラメーターを格納
    public class LoginViewModel
    {
        [Required]
        [EmailAddress]
        public string Email { get; set; }

        [Required]
        [DataType(DataType.Password)]
        public string Password { get; set; }
    }

    // ユーザー登録に使うパラメーターを格納
    public class RegisterViewModel
    {
        [Required]
        [EmailAddress]
        [Display(Name = "Email")]
        public string Email { get; set; }

        [Required]
        [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
        [DataType(DataType.Password)]
        [Display(Name = "Password")]
        public string Password { get; set; }

        [DataType(DataType.Password)]
        [Display(Name = "Confirm password")]
        [Compare("Password", ErrorMessage = "The password and confirmation password do not match.")]
        public string ConfirmPassword { get; set; }
    }

    // 発行したアクセストークンを格納
    public class TokenViewModel
    {
        public string Token { get; set; }
    }

    // API で取得できるユーザー情報を格納
    public class UserViewModel
    {
        public string Id { get; set; }

        public string UserName { get; set; }

        public string Email { get; set; }
    }

    [Route("api/user")]
    public class AccountController : Controller
    {
        private readonly UserManager<ApplicationUser> _userManager;
        private readonly SignInManager<ApplicationUser> _signInManager;
        private readonly IConfiguration _configuration;

        public AccountController(
            UserManager<ApplicationUser> userManager,
            SignInManager<ApplicationUser> signInManager,
            IConfiguration configuration)
        {
            _userManager = userManager;
            _signInManager = signInManager;
            _configuration = configuration;
        }

        // ユーザーを新規登録してアクセストークンを取得
        [HttpPost("register")]
        public async Task<IActionResult> Register([FromBody]RegisterViewModel model)
        {
            if (ModelState.IsValid)
            {
                var user = new ApplicationUser()
                {
                    UserName = model.Email,
                    Email = model.Email
                };
                var result = await _userManager.CreateAsync(user, model.Password);
                if (result.Succeeded)
                {
                    await _signInManager.SignInAsync(user, isPersistent: false);

                    var token = await GenerateJwtTokenAsync(user);
                    return Ok(token);
                }
                AddErrors(result);
            }

            return BadRequest(ModelState);
        }

        // メールアドレスとパスワードでログインしてアクセストークンを取得
        [HttpPost("login")]
        public async Task<IActionResult> Login([FromBody]LoginViewModel model)
        {
            if (ModelState.IsValid)
            {
                var result = await _signInManager.PasswordSignInAsync(model.Email, model.Password, false, lockoutOnFailure: false);
                if (result.Succeeded)
                {
                    var user = await _userManager.FindByEmailAsync(model.Email);
                    var token = await GenerateJwtTokenAsync(user);
                    return Ok(token);
                }
            }

            return BadRequest(ModelState);
        }

        // アクセストークンを生成
        async Task<TokenViewModel> GenerateJwtTokenAsync(ApplicationUser user)
        {
            var principal = await _signInManager.CreateUserPrincipalAsync(user);

            var claims = new List<Claim>(principal.Claims);
            claims.Add(new Claim(JwtRegisteredClaimNames.Sub, user.UserName));
            claims.Add(new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()));

            var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["JwtKey"]));
            var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
            var expires = DateTime.Now.AddDays(7);

            var token = new JwtSecurityToken(
                _configuration["JwtIssuer"],
                _configuration["JwtIssuer"],
                claims,
                expires: expires,
                signingCredentials: credentials
            );

            var jwt = new JwtSecurityTokenHandler().WriteToken(token);
            return new TokenViewModel()
            {
                Token = jwt
            };
        }

        // ログインユーザーの情報を取得
        [HttpGet("current")]
        [Authorize]
        public async Task<IActionResult> Current()
        {
            var user = await _userManager.GetUserAsync(User);
            var viewModel = new UserViewModel()
            {
                Id = user.Id,
                UserName = user.UserName,
                Email = user.Email,
            };
            return Ok(viewModel);
        }

        private void AddErrors(IdentityResult result)
        {
            foreach (var error in result.Errors)
            {
                ModelState.AddModelError(string.Empty, error.Description);
            }
        }
    }
}

curl で動作確認してみる。 まずはユーザー登録。

f:id:griefworker:20180604133613p:plain

ユーザーの登録に成功し、アクセストークンが返ってきた。

次はログイン。

f:id:griefworker:20180604133632p:plain

ログインに成功すればアクセストークンが返ってくる。

そのアクセストークンを使って、認証必須の API を呼び出せるかテスト。

f:id:griefworker:20180604133655p:plain

ログインユーザーの情報が返ってきた。テスト成功。

『アオアシ(13)』を読んだ

プレミアリーグ柏大商業戦で葦人がベンチ入りできたと思ったら、アクシデントによりまさかの途中出場とは。 まだプレミアリーグのレベルにないので、相手から集中的に狙われてしまうわけだが。

このまま無惨に散るのかと思いきや、 ユースに入ったばかりの頃と比べて成長していたので、同じ轍は踏まなかった。 実力不足を認め、できないならどうするか、考えることをやめない葦人の姿勢には学びがある。

あと、敵対していた阿久津と協力して(?)守備するところは見どころ。 監督の手前仕方なくって感じで、 認められたわけではないけど。 阿久津の指示の意図を理解して動いたところと、最後のカウンターにはシビレた。

福岡アンパンマンこどもミュージアムinモール

f:id:griefworker:20180601214052p:plain

子供の誕生日に有休とって『福岡アンパンマンこどもミュージアムinモール』に行ってきた。平日だからか、予想よりもかなり人が少なくて、ステージはすべて最前列で見ることができた。凄く混雑した写真を見ていてビビってたけど、あれは休日だからだろうな。

子供はステージの他には砂場がたいそう気に入ったみたいで、滞在時間の大半を砂場で費やした。手にほとんど粒がつかないほどサラサラで、夢中になるのも分かる。大人でも少し童心に返ってしまった。 ただ、砂場とステージ以外はそれほど遊べるものがなくて、他のミュージアム&モールも同じくらいの内容なんだろうか。期待値が高すぎたのかもしれない。

www.fukuoka-anpanman.jp

タカサキハンバーグ

飯倉から七隈方面に向かう福大通沿いに、気になる店構えのハンバーグ屋があった。名前は『タカサキハンバーグ』。 食べログや Retty によると、かなりの人気店らしい。行列覚悟で行ってみたら案の定、一時間待ちだった。

席に案内されたころには 14 時を過ぎていた。もう空腹の絶頂。今ならハンバーグ 2 個ぐらい余裕で食べれそうだったので、挑戦してみた。

サラダはドレッシングが美味。評判が良いのか、ドレッシングの販売もしているようだった。

スープと味噌汁のどちらか選べたので、味噌汁を選択。ご飯は1回だけおかわりできる。

そしてお待ちかねのハンバーグ。 1個が結構大きい。運ばれてきたときは、2個完食できるか不安になった。 箸を入れると閉じ込められていた肉汁が溢れ出てくる。ふっくらとしていてジューシーだった。 ソースはジンジャーソース。 オニオンとはまた違った辛味と爽やかな後味は新鮮で、お店イチオシなのも納得。

空腹 MAX だったのもあって、無事に完食できた。ご飯のおかわりまでしてしまった。一時間待った甲斐はあったと思う。自分の中のハンバーグランキングトップ5には入るな。

タンメン笑盛

アサデス。の朝メシ前の昼メシ情報で紹介されていた『博多辛味噌タンメン 笑盛』がずっと気になっていたので、昼休みに行ってみた。わらもり、ではなく、えもー。エモい。

辛味噌タンメンといえば、東京の蒙古タンメン中本が思い浮かぶ。笑盛は中本とは関係ないらしいので、中本インスパイア系とでも言うべきか。辛いのは好きだけど、強いわけではないので、『笑盛タンメン(780円)』を注文した。

中本を食べたことがあるのでつい比べてしまうが、中本の野菜はクタクタなのに対し、笑盛は食感がしっかり残ってる。野菜は食感ある方が好み。麻婆餡を溶かしながら麺をすすっていくと、辛さと旨味がじわっと広がってきて、中盤以降は額に汗がにじんだ。 中本は東京に行かないと食べられないので、辛いタンメンを食べたい欲求を満たしたい場合には、足を運ぶとよさそう。

タンメン笑盛 天神南店

食べログ タンメン笑盛 天神南店

めんちゃんこ亭

福岡で麺といえばラーメンとうどんだけど、『めんちゃんこ』なるものがあることをグルメ本で知った。鍋のシメに麺を入れることは定番だが、それを最初から味わってしまおうという代物。

休日で時間があったので、これは本店に行くしかない。藤崎駅出てすぐにある『めんちゃんこ亭 本店』に行ってきた。

注文したのは定番の『めんちゃんこ』。 ベースはちゃんこなので、野菜もしっかりとれて滋養ありそう。 麺はちゃんぽん並みの太麺で、 なるほど、たしかに鍋のシメを食べてる気分だ。 白くて丸い物体は最初卵かと思ったら餅だった。 麺と餅の組み合わせは初めてかも。 1杯でかなりのボリュームがある。

今回はうっかりスープを飲み干してしまった。 雑炊にしても良さそう。 そうなるとシメのさらにシメってことになるな。 背徳感ある。

関連ランキング:ラーメン | 藤崎駅室見駅

PostgreSQL で character(n) 型の列を主キーにしていたら Entity Framework Core で酷い目にあった

PostgreSQL で Entity Framework Core(Npgsql.EntityFrameworkCore.PostgreSQL) を使っていて、 テーブルの主キーの列に character(n) 型を使っていたら、 エンティティの更新に失敗して嵌った。

再現サンプルは下記の通り。

using Microsoft.EntityFrameworkCore;
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Threading.Tasks;

namespace NpgsqlEFSample
{
    [Table("books")]
    public class Book
    {
        [Key]
        [Required]
        [Column("code", TypeName = "character(20)")]
        public string Code { get; set; }

        [Column("title")]
        public string Title { get; set; }
    }

    public class ApplicationDbContext : DbContext
    {
        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) =>
            optionsBuilder.UseNpgsql("Host=localhost;Username=postgres;Password=p@ssword;Database=sample");

        public DbSet<Book> Books => Set<Book>();
    }

    class Program
    {
        static void Main(string[] args)
        {
            try
            {
                MainAsync(args).GetAwaiter().GetResult();
            }
            catch (Exception ex)
            {
                Console.WriteLine(ex.Message);
            }

            Console.WriteLine("Press Enter Key.");
            Console.ReadLine();
        }

        static async Task MainAsync(string[] args)
        {
            using (var context = new ApplicationDbContext())
            {
                // データベースを作りなおす
                await context.Database.EnsureDeletedAsync();
                await context.Database.EnsureCreatedAsync();

                context.Books.Add(new Book()
                {
                    Code = "12345",
                    Title = "Foo",
                });
                await context.SaveChangesAsync();
            }

            using (var context = new ApplicationDbContext())
            {
                var book = await context.Books
                    .FirstOrDefaultAsync(b => b.Code == "12345");
                book.Title = "Bar";
                await context.SaveChangesAsync();
            }

            using (var context = new ApplicationDbContext())
            {
                var books = await context.Books.ToListAsync();
                foreach (var book in books)
                {
                    Console.WriteLine($"{book.Code}:{book.Title}");
                }
            }
        }
    }
}

このコードを実行すると、次のような例外が発生してしまう。

f:id:griefworker:20180522162315p:plain

原因

context.Books.Add(new Book()
{
    Code = "12345",
    Title = "Foo",
});
await context.SaveChangesAsync();

を実行すると、Code プロパティにマップしている code 列は character(20) 型なので、 テーブルには 12345 (後ろに空白が 15 個ある) が保存される。

var book = await context.Books
    .FirstOrDefaultAsync(b => b.Code == "12345");

で取得した bookCode プロパティには、後ろに空白が 15 個ある 12345 が格納されている。

book.Title = "Bar";
await context.SaveChangesAsync();

を実行すると、Entity Framework Core は主キーである code 列の値が 12345 (後ろに空白が 15 個ある) の行を更新しようとするが、後ろに空白を含んでいるとなぜか条件に引っかからず、更新できない。 そして 1 行更新しようとしたけど更新できなかったから例外発生。

試しに

var book = await context.Books
    .FirstOrDefaultAsync(b => b.Code == "12345               "); // 12345 の後ろに空白が 15 個ある

で取得しようとするとヒットせず null が返ってくる。

ちなみに、Npgsql.EntityFrameworkCore.PostgreSQL ではなく、Npgsql を直接使った場合も、 後ろに空白を含む文字列で検索してもヒットしなかった。

対策1 varchar に変更する

character ではなく varchar を使うのが手っ取り早いし、 根本的な解決になる。

using Microsoft.EntityFrameworkCore;
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Threading.Tasks;

namespace NpgsqlEFSample
{
    [Table("books")]
    public class Book
    {
        [Key]
        [Required]
        [Column("code", TypeName = "varchar(20)")]  // varchar を使えば後ろに空白は入らない
        public string Code { get; set; }

        [Column("title")]
        public string Title { get; set; }
    }

    public class ApplicationDbContext : DbContext
    {
        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) =>
            optionsBuilder.UseNpgsql("Host=localhost;Username=postgres;Password=p@ssword;Database=sample");

        public DbSet<Book> Books => Set<Book>();
    }

    class Program
    {
        static void Main(string[] args)
        {
            try
            {
                MainAsync(args).GetAwaiter().GetResult();
            }
            catch (Exception ex)
            {
                Console.WriteLine(ex.Message);
            }

            Console.WriteLine("Press Enter Key.");
            Console.ReadLine();
        }

        static async Task MainAsync(string[] args)
        {
            using (var context = new ApplicationDbContext())
            {
                // データベースを作りなおす
                await context.Database.EnsureDeletedAsync();
                await context.Database.EnsureCreatedAsync();

                context.Books.Add(new Book()
                {
                    Code = "12345",
                    Title = "Foo",
                });
                await context.SaveChangesAsync();
            }

            using (var context = new ApplicationDbContext())
            {
                var book = await context.Books
                    .FirstOrDefaultAsync(b => b.Code == "12345");
                book.Title = "Bar";
                await context.SaveChangesAsync();
            }

            using (var context = new ApplicationDbContext())
            {
                var books = await context.Books.ToListAsync();
                foreach (var book in books)
                {
                    Console.WriteLine($"{book.Code}:{book.Title}");
                }
            }
        }
    }
}

実行すると、ちゃんと更新できる。

f:id:griefworker:20180522162324p:plain

「どうしても character でないといけない」という場合は使えないけど、 新規に作成するデータベースでそんなことあるかなぁ。

対策2 空白を除去してから保存する

スマートなやり方ではないけど、SaveChangesAsync で保存する前に、 キーのプロパティに格納されている文字列から空白を除去してやれば、 無事保存される。

using Microsoft.EntityFrameworkCore;
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Threading.Tasks;

namespace NpgsqlEFSample
{
    [Table("books")]
    public class Book
    {
        [Key]
        [Required]
        [Column("code", TypeName = "character(20)")]
        public string Code { get; set; }

        [Column("title")]
        public string Title { get; set; }
    }

    public class ApplicationDbContext : DbContext
    {
        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) =>
            optionsBuilder.UseNpgsql("Host=localhost;Username=postgres;Password=p@ssword;Database=sample");

        public DbSet<Book> Books => Set<Book>();
    }

    class Program
    {
        static void Main(string[] args)
        {
            try
            {
                MainAsync(args).GetAwaiter().GetResult();
            }
            catch (Exception ex)
            {
                Console.WriteLine(ex.Message);
            }

            Console.WriteLine("Press Enter Key.");
            Console.ReadLine();
        }

        static async Task MainAsync(string[] args)
        {
            using (var context = new ApplicationDbContext())
            {
                // データベースを作りなおす
                await context.Database.EnsureDeletedAsync();
                await context.Database.EnsureCreatedAsync();

                context.Books.Add(new Book()
                {
                    Code = "12345",
                    Title = "Foo",
                });
                await context.SaveChangesAsync();
            }

            using (var context = new ApplicationDbContext())
            {
                // 変更を追跡しない
                var book = await context.Books
                    .AsNoTracking()
                    .FirstOrDefaultAsync(b => b.Code == "12345");

                // 変更を追跡していないので、キーのプロパティを編集できる
                book.Code = book.Code.TrimEnd();
                book.Title = "Bar";
                context.Books.Update(book);
                await context.SaveChangesAsync();
            }

            using (var context = new ApplicationDbContext())
            {
                var books = await context.Books.ToListAsync();
                foreach (var book in books)
                {
                    Console.WriteLine($"{book.Code}:{book.Title}");
                }
            }
        }
    }
}

実行するとちゃんと更新に成功する。

f:id:griefworker:20180522162333p:plain