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 というようなものでも無さそうなので、今のところ問題は起きていない。あまりオススメのやり方では無いのは重々承知。