『星野、目をつぶって。(12)』を読んだ

星野は素顔を晒したせいで今までのグループには入れなくなったけど、小早川と共にジャージ女として活動していて築いた新しい繋がりがあることに気付かされたところはクルものがあった。

新しい繋がりがあるからそれで万事OKとはもちろんいかないわけで、 小早川は星野が元のグループに戻れるように文字通り身体を張る。 本人はカッコ悪いと思うんだろうが、 ブン投げられていても不思議とカッコいい。自分から行動を起こせるようになったからなんだろうな。

余談だけど、加納、 チョコレート作りの腕前もパティシエ級てどんだけ高スペックなのよ。 勉強料理運動なんでもできてマジ完璧超人。 松方と和解した今となっては、欠点といったら男の趣味くらいか。 小早川と星野は復縁という感じではないし、加納にワンチャンあったりしないもんかね。

このマンガはとうとう次巻で完結。 ラブコメを長く引っ張ってもグダるだけなので、13巻くらいで終わるのはちょうどいいかもしれない。

笑うかど

薬院にある『笑うかど』に晩ご飯を食べに行ってきた。ゴールデンウィークに行ったときは運悪く店休日だったので、今回はリベンジ。開いててよかった。

ボリュームありそうなメニューがたくさんあって目移りしそうだった。 その中でも一際目を引いたのが人気メニューの『オムカツそばめし』。こいつに決めた。

ソースでしっかりと味付けされたそばめしに、ふわとろの卵のまろやかさが合わさって、 ちょうどいい塩梅の味に仕上がっていた。 おむそばに卵は間違いないね。 上のトンカツもアツアツかつサクサク。 予想以上のボリュームでお腹はもうパンパンになった。

職場か自宅の近くにこんな定食屋があればなぁ。 薬院は良い店が多くて、住んでいる人が羨ましい。

笑うかど

笑うかど

『からかい上手の(元)高木さん(3)』を読んだ

やはり血は争えないのか、娘のちーもからかいに目覚めたようだ。 娘にからかいまくると宣言されたときの、西片くんの表情といったらもう。 ウケる。

でも、ちーはどちらかというと父親似なので、(元)高木さんに勝負を挑んで返り討ちにあったり、からかわれたりする。

(元)高木さんと娘の 2 人にからかわれるのは、さすがに西片くんが可哀想なので、(元)高木さん vs. 西片くんとちーぐらいがちょうどいい。まぁ、2 人がかりでも勝てそうにないけど。

望遠のマーチ

iTunes Store で配信されたので購入。 この曲は妖怪ウォッチワールドのタイアップらしい。軽快なテンポ。のびやかなサビ。 スマホを握って外に繰り出したくなる曲だった。

近年のバンプの曲はタイアップが多い。 露出が増えるのは個人的に喜ばしいことに思う。 アルバムが待ち遠しい。

望遠のマーチ

望遠のマーチ

JwtBearer Authentication と ASP.NET Core Identity の 2 要素認証を無理やり組み合わせてみた

以前、JwtBearer Authentication の裏で ASP.NET Core Identity を使うことには成功した。

tnakamura.hatenablog.com

ASP.NET Core Identity は 2 要素認証をサポートしているので、 JwtBearer Authentication を使う場合でもなんとか組み合わせることができないものか、 いろいろ試行錯誤してみた。

結論としては、少々無理やりではあるけど、なんとか使うことができた。 以下、サンプルコード。

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.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
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.Linq;
using System.Security.Claims;
using System.Text;
using System.Text.Encodings.Web;
using System.Threading;
using System.Threading.Tasks;

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

        public static IWebHost BuildWebHost(string[] args) =>
            WebHost.CreateDefaultBuilder(args)
                .UseStartup<Startup>()
                .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, ApplicationRole>()
                .AddDefaultTokenProviders();
            services.AddTransient<IUserStore<ApplicationUser>, ApplicationUserStore>();
            services.AddTransient<IRoleStore<ApplicationRole>, ApplicationRoleStore>();

            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"])),
                };
            })
            // 2 要素認証専用のスキームを登録
            .AddJwtBearer("Bearer.TwoFactor", options =>
            {
                options.SaveToken = true;
                // 2 要素認証用のトークンは 2 要素認証関連の API だけを呼び出せるように、
                // JWT のシグネチャを変えておく。
                options.TokenValidationParameters = new TokenValidationParameters
                {
                    ValidIssuer = Configuration["JwtIssuer"],
                    ValidAudience = Configuration["JwtIssuer"],
                    IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Configuration["JwtTwoFactorKey"])),
                };
            });

            services.AddMvc();
        }

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

            app.UseAuthentication();

            app.UseMvc();

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

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

        public DbSet<ApplicationUser> Users => Set<ApplicationUser>();
    }

    public class ApplicationUser
    {
        [Key]
        public string Id { get; set; } = Guid.NewGuid().ToString();

        [Required]
        public string UserName { get; set; }

        [Required]
        public string PasswordHash { get; set; }

        public bool IsTwoFactorEnabled { get; set; } = false;

        public string AuthenticatorKey { get; set; }

        public string RecoveryCode { get; set; }
    }

    public class ApplicationRole
    {
        public string Id { get; set; } = Guid.NewGuid().ToString();

        public string RoleName { get; set; }
    }

    public class ApplicationUserStore : IUserStore<ApplicationUser>,
                                        IUserPasswordStore<ApplicationUser>,
                                        IUserTwoFactorStore<ApplicationUser>,
                                        IUserAuthenticatorKeyStore<ApplicationUser>,
                                        IUserTwoFactorRecoveryCodeStore<ApplicationUser>,
                                        IUserEmailStore<ApplicationUser>,
                                        IUserPhoneNumberStore<ApplicationUser>
    {
        readonly ApplicationDbContext context;

        public ApplicationUserStore(ApplicationDbContext context)
        {
            this.context = context;
        }

        #region IDisposable

        public void Dispose()
        {
        }

        #endregion

        #region IUserStore<ApplicationUser>

        public async Task<IdentityResult> CreateAsync(ApplicationUser user, CancellationToken cancellationToken)
        {
            context.Users.Add(user);
            await context.SaveChangesAsync(cancellationToken);
            return IdentityResult.Success;
        }

        public async Task<IdentityResult> DeleteAsync(ApplicationUser user, CancellationToken cancellationToken)
        {
            context.Users.Remove(user);
            await context.SaveChangesAsync(cancellationToken);
            return IdentityResult.Success;
        }

        public async Task<ApplicationUser> FindByIdAsync(string userId, CancellationToken cancellationToken)
        {
            return await context.Users
                .FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
        }

        public async Task<ApplicationUser> FindByNameAsync(string normalizedUserName, CancellationToken cancellationToken)
        {
            return await context.Users
                .FirstOrDefaultAsync(u => u.UserName == normalizedUserName, cancellationToken);
        }

        public Task<string> GetNormalizedUserNameAsync(ApplicationUser user, CancellationToken cancellationToken)
        {
            return GetUserNameAsync(user, cancellationToken);
        }

        public Task<string> GetUserIdAsync(ApplicationUser user, CancellationToken cancellationToken)
        {
            return Task.FromResult(user.Id);
        }

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

        public Task SetNormalizedUserNameAsync(ApplicationUser user, string normalizedName, CancellationToken cancellationToken)
        {
            return SetUserNameAsync(user, normalizedName, cancellationToken);
        }

        public Task SetUserNameAsync(ApplicationUser user, string userName, CancellationToken cancellationToken)
        {
            user.UserName = userName;
            return Task.CompletedTask;
        }

        public async Task<IdentityResult> UpdateAsync(ApplicationUser user, CancellationToken cancellationToken)
        {
            context.Users.Update(user);
            await context.SaveChangesAsync(cancellationToken);
            return IdentityResult.Success;
        }

        #endregion

        #region IUserPasswordStore<ApplicationUser>

        public Task<string> GetPasswordHashAsync(ApplicationUser user, CancellationToken cancellationToken)
        {
            return Task.FromResult(user.PasswordHash);
        }

        public Task<bool> HasPasswordAsync(ApplicationUser user, CancellationToken cancellationToken)
        {
            return Task.FromResult(user.PasswordHash != null);
        }

        public Task SetPasswordHashAsync(ApplicationUser user, string passwordHash, CancellationToken cancellationToken)
        {
            user.PasswordHash = passwordHash;
            return Task.CompletedTask;
        }

        #endregion

        #region IUserTwoFactorStore<ApplicationUser>

        public Task<bool> GetTwoFactorEnabledAsync(ApplicationUser user, CancellationToken cancellationToken)
        {
            return Task.FromResult(user.IsTwoFactorEnabled);
        }

        public Task SetTwoFactorEnabledAsync(ApplicationUser user, bool enabled, CancellationToken cancellationToken)
        {
            user.IsTwoFactorEnabled = enabled;
            return Task.CompletedTask;
        }

        #endregion

        #region IUserAuthenticatorKeyStore<ApplicationUser>

        public Task SetAuthenticatorKeyAsync(ApplicationUser user, string key, CancellationToken cancellationToken)
        {
            user.AuthenticatorKey = key;
            return Task.CompletedTask;
        }

        public Task<string> GetAuthenticatorKeyAsync(ApplicationUser user, CancellationToken cancellationToken)
        {
            return Task.FromResult(user.AuthenticatorKey);
        }

        #endregion

        #region IUserTwoFactorRecoveryCodeStore<ApplicationUser>

        public Task<int> CountCodesAsync(ApplicationUser user, CancellationToken cancellationToken)
        {
            var count = user.RecoveryCode?.Split(";").Length ?? 0;
            return Task.FromResult(count);
        }

        public async Task<bool> RedeemCodeAsync(ApplicationUser user, string code, CancellationToken cancellationToken)
        {
            var codes = user.RecoveryCode.Split(";");
            if (codes.Contains(code))
            {
                var updatedCodes = codes.Where(c => c != code).ToList();
                await ReplaceCodesAsync(user, updatedCodes, cancellationToken);
                return true;
            }
            else
            {
                return false;
            }
        }

        public Task ReplaceCodesAsync(ApplicationUser user, IEnumerable<string> recoveryCodes, CancellationToken cancellationToken)
        {
            user.RecoveryCode = string.Join(";", recoveryCodes);
            return Task.CompletedTask;
        }

        #endregion

        #region IUserEmailStore<ApplicationUser>

        public Task<ApplicationUser> FindByEmailAsync(string normalizedEmail, CancellationToken cancellationToken)
        {
            return FindByNameAsync(normalizedEmail, cancellationToken);
        }

        public Task<string> GetEmailAsync(ApplicationUser user, CancellationToken cancellationToken)
        {
            return Task.FromResult(user.UserName);
        }

        public Task<bool> GetEmailConfirmedAsync(ApplicationUser user, CancellationToken cancellationToken)
        {
            return Task.FromResult(true);
        }

        public Task<string> GetNormalizedEmailAsync(ApplicationUser user, CancellationToken cancellationToken)
        {
            return GetEmailAsync(user, cancellationToken);
        }

        public Task SetEmailAsync(ApplicationUser user, string email, CancellationToken cancellationToken)
        {
            user.UserName = email;
            return Task.CompletedTask;
        }

        public Task SetEmailConfirmedAsync(ApplicationUser user, bool confirmed, CancellationToken cancellationToken)
        {
            return Task.CompletedTask;
        }

        public Task SetNormalizedEmailAsync(ApplicationUser user, string normalizedEmail, CancellationToken cancellationToken)
        {
            return SetEmailAsync(user, normalizedEmail, cancellationToken);
        }

        #endregion

        #region IUserPhoneNumberStore<ApplicationUser>

        public Task<string> GetPhoneNumberAsync(ApplicationUser user, CancellationToken cancellationToken)
        {
            return Task.FromResult(default(string));
        }

        public Task<bool> GetPhoneNumberConfirmedAsync(ApplicationUser user, CancellationToken cancellationToken)
        {
            return Task.FromResult(false);
        }

        public Task SetPhoneNumberAsync(ApplicationUser user, string phoneNumber, CancellationToken cancellationToken)
        {
            return Task.CompletedTask;
        }

        public Task SetPhoneNumberConfirmedAsync(ApplicationUser user, bool confirmed, CancellationToken cancellationToken)
        {
            return Task.CompletedTask;
        }

        #endregion
    }

    public class ApplicationRoleStore : IRoleStore<ApplicationRole>
    {
        public Task<IdentityResult> CreateAsync(ApplicationRole role, CancellationToken cancellationToken) =>
            throw new NotImplementedException();

        public Task<IdentityResult> DeleteAsync(ApplicationRole role, CancellationToken cancellationToken) =>
            throw new NotImplementedException();

        public void Dispose() { }

        public Task<ApplicationRole> FindByIdAsync(string roleId, CancellationToken cancellationToken) =>
            throw new NotImplementedException();

        public Task<ApplicationRole> FindByNameAsync(string normalizedRoleName, CancellationToken cancellationToken) =>
            throw new NotImplementedException();

        public Task<string> GetNormalizedRoleNameAsync(ApplicationRole role, CancellationToken cancellationToken) =>
            throw new NotImplementedException();

        public Task<IdentityResult> UpdateAsync(ApplicationRole role, CancellationToken cancellationToken) =>
            throw new NotImplementedException();

        public Task<string> GetRoleIdAsync(ApplicationRole role, CancellationToken cancellationToken) =>
            Task.FromResult(role.Id);

        public Task<string> GetRoleNameAsync(ApplicationRole role, CancellationToken cancellationToken) =>
            Task.FromResult(role.RoleName);

        public Task SetNormalizedRoleNameAsync(ApplicationRole role, string normalizedName, CancellationToken cancellationToken) =>
            SetRoleNameAsync(role, normalizedName, cancellationToken);

        public Task SetRoleNameAsync(ApplicationRole role, string roleName, CancellationToken cancellationToken)
        {
            role.RoleName = roleName;
            return Task.CompletedTask;
        }
    }

    [Route("[controller]/[action]")]
    public class UserController : Controller
    {
        const string AuthenticatorUriFormat = "otpauth://totp/{0}:{1}?secret={2}&issuer={0}&digits=6";
        readonly UserManager<ApplicationUser> userManager;
        readonly SignInManager<ApplicationUser> signInManager;
        readonly IConfiguration configuration;
        readonly UrlEncoder urlEncoder;

        public UserController(
            SignInManager<ApplicationUser> signInManager,
            UserManager<ApplicationUser> userManager,
            IConfiguration configuration,
            UrlEncoder urlEncoder)
        {
            this.signInManager = signInManager;
            this.userManager = userManager;
            this.configuration = configuration;
            this.urlEncoder = urlEncoder;
        }

        [HttpPost]
        public async Task<IActionResult> Register([FromBody] RegisterViewModel model)
        {
            if (ModelState.IsValid)
            {
                var user = new ApplicationUser()
                {
                    UserName = model.UserName,
                };
                var result = await userManager.CreateAsync(user, model.Password);
                if (result.Succeeded)
                {
                    await signInManager.SignInAsync(user, false);
                    return Ok();
                }
            }
            return BadRequest(ModelState);
        }

        [HttpPost]
        public async Task<IActionResult> Login([FromBody]LoginViewModel model)
        {
            if (ModelState.IsValid)
            {
                var result = await signInManager.PasswordSignInAsync(model.UserName, model.Password, true, false);
                if (result.Succeeded)
                {
                    var user = await userManager.FindByNameAsync(model.UserName);

                    // 2 要素認証が OFF のときは通常の JWT を返す
                    var token = await GenerateJwtTokenAsync(user);
                    return Ok(token);
                }
                else if (result.RequiresTwoFactor)
                {
                    var user = await userManager.FindByNameAsync(model.UserName);

                    // 2 要素認証が ON のときは 2 要素認証関連 API だけ利用できる JWT を返す
                    var token = await GenerateJwtTokenAsync(user, true);
                    return Ok(token);
                }
            }
            return BadRequest(ModelState);
        }

        // 2 要素認証関連 API は認証スキームを別にしておく
        [HttpPost]
        [Authorize(AuthenticationSchemes = "Bearer.TwoFactor")]
        public async Task<IActionResult> LoginWith2fa([FromBody]LoginWith2faViewModel model)
        {
            if (ModelState.IsValid)
            {
                var user = await userManager.GetUserAsync(User);
                var code = model.Code.Replace(" ", "").Replace("-", "");
                var result = await userManager.VerifyTwoFactorTokenAsync(user, TokenOptions.DefaultAuthenticatorProvider, code);
                if (result)
                {
                    var token = await GenerateJwtTokenAsync(user);
                    return Ok(token);
                }
            }
            return BadRequest(ModelState);
        }

        // 2 要素認証関連 API は認証スキームを別にしておく
        [HttpPost]
        [Authorize(AuthenticationSchemes = "Bearer.TwoFactor")]
        public async Task<IActionResult> LoginWithRecoveryCode([FromBody]LoginWithRecoveryCodeViewModel model)
        {
            if (ModelState.IsValid)
            {
                var user = await userManager.GetUserAsync(User);
                var code = model.RecoveryCode.Replace(" ", "");
                var result = await userManager.RedeemTwoFactorRecoveryCodeAsync(user, code);
                if (result.Succeeded)
                {
                    var token = await GenerateJwtTokenAsync(user);
                    return Ok(token);
                }
            }
            return BadRequest(ModelState);
        }

        async Task<TokenViewModel> GenerateJwtTokenAsync(ApplicationUser user, bool requiresTwoFactor = false)
        {
            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()));

            SymmetricSecurityKey key;
            if (requiresTwoFactor)
            {
                // 2 要素認証関連 API のみ利用できる JWT を作成
                claims.Add(new Claim(ClaimTypes.AuthenticationMethod, IdentityConstants.TwoFactorUserIdScheme));
                key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["JwtTwoFactorKey"]));
            }
            else
            {
                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,
                RequiresTwoFactor = requiresTwoFactor,
            };
        }

        [HttpGet]
        [Authorize]
        public async Task<IActionResult> Current()
        {
            var user = await userManager.GetUserAsync(User);
            return Ok(user);
        }

        [HttpGet]
        [Authorize]
        public async Task<IActionResult> EnableAuthenticator()
        {
            var user = await userManager.GetUserAsync(User);
            if (user == null)
            {
                throw new ApplicationException($"Unable to load user with ID '{userManager.GetUserId(User)}'.");
            }

            var model = new EnableAuthenticatorViewModel();
            await LoadSharedKeyAndQrCodeUriAsync(user, model);

            return Ok(model);
        }

        [HttpPost]
        [Authorize]
        public async Task<IActionResult> EnableAuthenticator([FromBody] EnableAuthenticatorViewModel model)
        {
            var user = await userManager.GetUserAsync(User);
            if (user == null)
            {
                throw new ApplicationException(
                    $"Unable to load user with ID '{userManager.GetUserId(User)}'.");
            }

            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }

            // 半角スペースとハイフンを除去
            var verificationCode = model.Code
                .Replace(" ", string.Empty)
                .Replace("-", string.Empty);

            var is2faTokenValid = await userManager.VerifyTwoFactorTokenAsync(
                user, userManager.Options.Tokens.AuthenticatorTokenProvider, verificationCode);

            if (!is2faTokenValid)
            {
                ModelState.AddModelError("Code", "Verification code is invalid.");
                return BadRequest(ModelState);
            }

            await userManager.SetTwoFactorEnabledAsync(user, true);

            var recoveryCodes = await userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10);
            var viewModel = new ShowRecoveryCodesViewModel
            {
                RecoveryCodes = recoveryCodes.ToArray()
            };
            return Ok(viewModel);
        }

        [HttpPost]
        [Authorize]
        public async Task<IActionResult> Disable2fa()
        {
            var user = await userManager.GetUserAsync(User);
            if (user == null)
            {
                throw new ApplicationException(
                    $"Unable to load user with ID '{userManager.GetUserId(User)}'.");
            }

            var disable2faResult = await userManager.SetTwoFactorEnabledAsync(user, false);
            if (!disable2faResult.Succeeded)
            {
                throw new ApplicationException($"Unexpected error occured disabling 2FA for user with ID '{user.Id}'.");
            }

            return Ok();
        }

        [HttpPost]
        [Authorize]
        public async Task<IActionResult> ResetAuthenticator()
        {
            var user = await userManager.GetUserAsync(User);
            if (user == null)
            {
                throw new ApplicationException(
                    $"Unable to load user with ID '{userManager.GetUserId(User)}'.");
            }
            await userManager.SetTwoFactorEnabledAsync(user, false);
            await userManager.ResetAuthenticatorKeyAsync(user);
            return Ok();
        }


        #region Helpers

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

        private string FormatKey(string unformattedKey)
        {
            var result = new StringBuilder();
            int currentPosition = 0;
            while (currentPosition + 4 < unformattedKey.Length)
            {
                result.Append(unformattedKey.Substring(currentPosition, 4)).Append(" ");
                currentPosition += 4;
            }
            if (currentPosition < unformattedKey.Length)
            {
                result.Append(unformattedKey.Substring(currentPosition));
            }

            return result.ToString().ToLowerInvariant();
        }

        private string GenerateQrCodeUri(string email, string unformattedKey)
        {
            return string.Format(
                AuthenticatorUriFormat,
                urlEncoder.Encode("JwtTwoFactAuthSample"),
                urlEncoder.Encode(email),
                unformattedKey);
        }

        private async Task LoadSharedKeyAndQrCodeUriAsync(ApplicationUser user, EnableAuthenticatorViewModel model)
        {
            var unformattedKey = await userManager.GetAuthenticatorKeyAsync(user);
            if (string.IsNullOrEmpty(unformattedKey))
            {
                await userManager.ResetAuthenticatorKeyAsync(user);
                unformattedKey = await userManager.GetAuthenticatorKeyAsync(user);
            }

            model.SharedKey = FormatKey(unformattedKey);
            model.AuthenticatorUri = GenerateQrCodeUri(user.UserName, unformattedKey);
        }

        #endregion
    }

    // ユーザー登録用
    public class RegisterViewModel
    {
        [Required]
        public string UserName { get; set; }

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

    // ログイン用
    public class LoginViewModel
    {
        [Required]
        public string UserName { get; set; }

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

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

        [BindNever]
        public string SharedKey { get; set; }

        [BindNever]
        public string AuthenticatorUri { get; set; }
    }

    // 2 要素認証の認証コードのバインド用
    public class LoginWith2faViewModel
    {
        [Required]
        public string Code { get; set; }
    }

    public class LoginWithRecoveryCodeViewModel
    {
        [Required]
        public string RecoveryCode { get; set; }
    }

    public class ShowRecoveryCodesViewModel
    {
        public string[] RecoveryCodes { get; set; }
    }

    // JWT をレスポンスで返すとき用
    public class TokenViewModel
    {
        public string Token { get; set; }

        public bool RequiresTwoFactor { get; set; }
    }
}

Visual StudioASP.NET Core Identity を使う Web アプリケーションプロジェクトを作成すると、2 要素認証を行うコードまで作成される。ただし作成されたコードは Cookie を使うこと前提で、JwtBearer Authentication と組み合わせる際にそのままでは使えなかった。

そのため、内部で呼び出されているメソッドをASP.NET Core Identity のソースコードで調べ、そのメソッドを直接呼び出すことで無理やり実現している。呼び出しているのはどれも public なメソッドだし、内部 API というようなものでも無さそうなので、今のところ問題は起きていない。あまりオススメのやり方では無いのは重々承知。

『からかい上手の高木さん(9)』を読んだ

最近はほとんど、西片くんが高木さんに勝負を挑み、返り討ちに合うという展開。 色々策を講じるものの、結局は高木さんに上を行かれてしまうわけで。 もはや様式美だな。

それはそうと西片くん、何としても高木さんに勝ちたいのか、 いよいよ手段を選ばなくなってきた。

姑息。あまりにも姑息。 それで勝ったところで虚しくならないんだろうか。 まぁ結局勝てないので余計な心配でしかないんだけど。

高木さんの方も、既成事実を作りつつ、 西片くんの外堀を順調に埋めていて、 攻略は完了一歩手前という感じ。

あと、気になっていたサブキャラの北条さんに恋の予感。 男の方は浜口だっけ。 帰る方向が同じということなので、幼馴染とかだったりするのかも。

北条さんはまんざらでもなさそうな表情だったので、高木さんと西片くんを差し置いて、 作中2組目のカップル誕生なるか。

北条さんのスピンオフが出たらいいな。

『ぼくたちは勉強ができない(7)』を読んだ

夏休みが終わってあしゅみー先輩の出番が減るかと思いきや、そんなことは全くなく7巻でも健在。それにしても、夏休み&予備校も終わったというのに、成幸はまだハイステージに通ってるのか。

ここのところ、誰か1人がメインではなく、3人娘全員が同じくらい登場するような回が増えてきた印象。そういった回は結局、最後にあしゅみー先輩や真冬先生が持っていってしまうんだけど。

その真冬先生、プライベートではポンコツを晒しているが仕事では完璧。…といつから錯覚していた?話の都合上仕方ないとはいえ、成幸無しでよく今まで社会人やってこれたもんだ。

7巻ではうるかがかなり積極的に攻めていた。成幸に手料理を振る舞ったのは彼女くらいだし、他に好きな人がいると勘違いされながらも着実に進んでいる印象。ただ、2人を応援していた方が先にくっつくとは予想してなかったな。

文乃は3人娘の中において最初の出遅れを一気に挽回して、その勢いに陰りは見えない。女心の師匠と姉弟ごっこを経て、なんだかんだで成幸と文乃は良いコンビになってきた。父親回も残っているし、メインヒロイン扱いっぽくなってきた気がする。

理珠は再度イメチェンを図ったりと作者の苦労が滲み出てる気がする。メイン回少ないし。ポテンシャルは高いだけに勿体ない。やはり、自分の気持ちに気付いてからが勝負だろう。そうなると、理珠も重要エピソードを残してることになるな。

ブコメだから最終的にはヒロインの誰かと結ばれるんだろうけど、その相手がまったく予想できない。みんな可能性ありそう。途中からヒロインに加わったあしゅみー先輩や真冬先生はさすがに難しいとは思うが、先生推しの自分としては前例にない事をやって欲しいとも思ってる。