ASP.NET Core 認証・リアルタイム通信実践ガイド

Identity によるユーザー管理

ASP.NET Core Identity は、ユーザー・パスワード・ロール・クレーム・トークンなどを一元的に管理するためのフレームワークである。Entity Framework Core を経由して任意のリレーショナルデータベースに保存できる。

モデル定義

public class AppUser : IdentityUser<long> { }

public class AppRole : IdentityRole<long> { }

public class SecurityDb : IdentityDbContext<AppUser, AppRole, long>
{
    public SecurityDb(DbContextOptions<SecurityDb> opts) : base(opts) { }
}

サービス登録

builder.Services
    .AddIdentityCore<AppUser>(o =>
    {
        o.Password.RequiredLength = 6;
        o.Password.RequireNonAlphanumeric = false;
        o.Password.RequireDigit = false;
        o.Password.RequireLowercase = false;
        o.Password.RequireUppercase = false;
        o.Lockout.MaxFailedAccessAttempts = 3;
        o.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5);
    })
    .AddRoles<AppRole>()
    .AddEntityFrameworkStores<SecurityDb>()
    .AddDefaultTokenProviders();

ログイン実装

[ApiController, Route("api/[controller]")]
public class AuthController : ControllerBase
{
    private readonly UserManager<AppUser> _userMgr;
    public AuthController(UserManager<AppUser> um) => _userMgr = um;

    [HttpPost("login")]
    public async Task<IActionResult> Login(LoginDto dto)
    {
        var user = await _userMgr.FindByNameAsync(dto.UserName);
        if (user == null) return BadRequest("ユーザー名またはパスワードが違います");

        if (await _userMgr.IsLockedOutAsync(user))
        {
            var until = await _userMgr.GetLockoutEndDateAsync(user);
            return BadRequest($"アカウントは {(until - DateTimeOffset.UtcNow)?.Seconds} 秒間ロックされています");
        }

        if (!await _userMgr.CheckPasswordAsync(user, dto.Password))
        {
            await _userMgr.AccessFailedAsync(user);
            return BadRequest("ユーザー名またはパスワードが違います");
        }

        await _userMgr.ResetAccessFailedCountAsync(user);
        return Ok(await _userMgr.GetRolesAsync(user));
    }
}

JWT 発行・検証

サーバーの状態を保持しない REST API では、JWT を用いた Bearer 認証が一般的である。

設定クラス

public class JwtSettings
{
    public string Secret { get; init; } = string.Empty;
    public int AccessLifetimeSec { get; init; } = 900;   // 15 分
    public int RefreshLifetimeSec { get; init; } = 604800; // 7 日
}

トークン発行ヘルパー

public static class TokenIssuer
{
    public static string CreateAccessToken(IEnumerable<Claim> claims, JwtSettings cfg)
    {
        var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(cfg.Secret));
        var cred = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
        var jwt = new JwtSecurityToken(
            claims: claims,
            expires: DateTime.UtcNow.AddSeconds(cfg.AccessLifetimeSec),
            signingCredentials: cred);
        return new JwtSecurityTokenHandler().WriteToken(jwt);
    }

    public static string CreateRefreshToken()
    {
        var buffer = RandomNumberGenerator.GetBytes(32);
        return Convert.ToBase64String(buffer);
    }
}

DI 登録

var jwtSec = builder.Configuration.GetSection("JwtSettings");
builder.Services.Configure<JwtSettings>(jwtSec);
var jwt = jwtSec.Get<JwtSettings>();

builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(o =>
    {
        o.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuerSigningKey = true,
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt.Secret)),
            ValidateIssuer = false,
            ValidateAudience = false,
            ValidateLifetime = true,
            ClockSkew = TimeSpan.Zero
        };
    });

Redis を活用した Refresh Token 戦略

アクセストークンの有効期間を短くし、Refresh Token を Redis に保存することで、ステートレスながらも安全にトークンを更新できる。

public static class TokenStore
{
    public static async Task<(string access, string refresh)> GenerateAsync(
        AppUser user, IDistributedCache cache, JwtSettings cfg)
    {
        var claims = new[] {
            new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
            new Claim(ClaimTypes.Name, user.UserName!)
        };
        var refresh = TokenIssuer.CreateRefreshToken();
        var access = TokenIssuer.CreateAccessToken(claims, cfg);

        await cache.SetStringAsync(
            $"rt:{user.UserName}",
            refresh,
            new DistributedCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(cfg.RefreshLifetimeSec)
            });

        return (access, refresh);
    }

    public static async Task<string?> RefreshAsync(
        string username, string incomingRefresh, IDistributedCache cache, JwtSettings cfg)
    {
        var stored = await cache.GetStringAsync($"rt:{username}");
        if (stored == null || stored != incomingRefresh) return null;

        var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(cfg.Secret));
        var handler = new JwtSecurityTokenHandler();
        var principal = handler.ValidateToken(
            incomingRefresh,
            new TokenValidationParameters
            {
                ValidateIssuerSigningKey = true,
                IssuerSigningKey = key,
                ValidateLifetime = false,
                ValidateIssuer = false,
                ValidateAudience = false
            },
            out _);

        var newAccess = TokenIssuer.CreateAccessToken(principal.Claims, cfg);
        return newAccess;
    }
}

バックグラウンドサービス

BackgroundService を継承することで、定期的な処理を簡潔に実装できる。

public sealed class AuditLogService : BackgroundService
{
    private readonly IServiceProvider _provider;
    private readonly ILogger<AuditLogService> _log;

    public AuditLogService(IServiceProvider p, ILogger<AuditLogService> l)
    {
        _provider = p;
        _log = l;
    }

    protected override async Task ExecuteAsync(CancellationToken stop)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromMinutes(30));
        while (await timer.WaitForNextTickAsync(stop))
        {
            using var scope = _provider.CreateScope();
            var db = scope.ServiceProvider.GetRequiredService<SecurityDb>();
            var locked = await db.Users
                                 .Where(u => u.LockoutEnd > DateTimeOffset.UtcNow)
                                 .ToListAsync(stop);
            _log.LogInformation("{Count} 件のアカウントがロックされています", locked.Count);
        }
    }
}

FluentValidation による入力検証

属性ベースよりも表現力が高く、テスト可能な検証ルールを記述できる。

public class RegisterDtoValidator : AbstractValidator<RegisterDto>
{
    public RegisterDtoValidator()
    {
        RuleFor(x => x.UserName)
            .NotEmpty().WithMessage("必須")
            .Length(3, 20).WithMessage("3〜20 文字");

        RuleFor(x => x.Password)
            .MinimumLength(6).WithMessage("6 文字以上");

        RuleFor(x => x.ConfirmPassword)
            .Equal(x => x.Password).WithMessage("パスワードが一致しません");
    }
}

builder.Services
    .AddFluentValidationAutoValidation()
    .AddValidatorsFromAssemblyContaining<RegisterDtoValidator>();

SignalR リアルタイム通信

WebSocket を抽象化した SignalR を使うと、双方向通信を容易に実装できる。

Hub 実装

public class NotificationHub : Hub
{
    public async Task JoinGroup(string group)
    {
        await Groups.AddToGroupAsync(Context.ConnectionId, group);
    }

    public async Task SendToGroup(string group, string message)
    {
        await Clients.Group(group).SendAsync("Receive", message);
    }
}

サーバー側登録

builder.Services.AddSignalR();
builder.Services.AddCors(o => o.AddDefaultPolicy(p => p
    .WithOrigins("http://localhost:5173")
    .AllowAnyHeader()
    .AllowAnyMethod()
    .AllowCredentials()));

app.MapHub<NotificationHub>("/hub/notification");

Vue クライアント例

import * as signalR from '@microsoft/signalr'

const conn = new signalR.HubConnectionBuilder()
    .withUrl('https://localhost:7035/hub/notification', {
        skipNegotiation: true,
        transport: signalR.HttpTransportType.WebSockets,
        accessTokenFactory: () => localStorage.getItem('token') ?? ''
    })
    .build()

conn.on('Receive', msg => console.log(msg))
await conn.start()
await conn.invoke('JoinGroup', 'sales')
await conn.invoke('SendToGroup', 'sales', 'Hello!')

ファイルアップロード進捗通知

SignalR を使って CSV インポートの進捗をリアルタイムでクライアントへ配信する例。

public class CsvImporter
{
    private readonly IHubContext<NotificationHub> _hub;
    public CsvImporter(IHubContext<NotificationHub> hub) => _hub = hub;

    public async Task ImportAsync(string fileName, string connId)
    {
        using var reader = new StreamReader($"Uploads/{fileName}");
        using var csv = new CsvReader(reader, CultureInfo.InvariantCulture);
        var records = csv.GetRecords<Product>().ToList();
        int total = records.Count, done = 0;

        foreach (var batch in records.Chunk(100))
        {
            // DB bulk insert
            done += batch.Length;
            await _hub.Clients.Client(connId)
                      .SendAsync("Progress", done, total);
        }
    }
}

[Authorize]
public class ProductController : ControllerBase
{
    [HttpPost("upload")]
    public async Task<IActionResult> Upload(IFormFile file)
    {
        var path = Path.Combine("Uploads", file.FileName);
        await using var stream = System.IO.File.Create(path);
        await file.CopyToAsync(stream);

        return Ok(new { file.FileName });
    }
}

タグ: ASP.NET Core Identity JWT redis SignalR FluentValidation

5月15日 00:48 投稿