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 });
}
}