2026-07-07 13:27:49 +09:00

591 lines
21 KiB
C#

using MemberCenter.Application.Abstractions;
using MemberCenter.Domain.Entities;
using MemberCenter.Infrastructure.Configuration;
using MemberCenter.Infrastructure.Identity;
using MemberCenter.Infrastructure.Persistence;
using MemberCenter.Infrastructure.Services;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Npgsql;
using OpenIddict.Abstractions;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.CommandLine;
EnvLoader.LoadDotEnvIfDevelopment();
var root = new RootCommand("Member Center installer");
var connectionStringOption = new Option<string>(
name: "--connection-string",
description: "Database connection string");
connectionStringOption.AddAlias("-c");
var appsettingsOption = new Option<string?>(
name: "--appsettings",
description: "Path to appsettings.json");
var noPromptOption = new Option<bool>(
name: "--no-prompt",
description: "Disable interactive prompts");
var verboseOption = new Option<bool>(
name: "--verbose",
description: "Enable verbose output");
var forceOption = new Option<bool>(
name: "--force",
description: "Force execution even if already installed");
var adminEmailOption = new Option<string?>(
name: "--admin-email",
description: "Admin email");
var adminPasswordOption = new Option<string?>(
name: "--admin-password",
description: "Admin password");
var adminDisplayNameOption = new Option<string?>(
name: "--admin-display-name",
description: "Admin display name (optional)");
var targetMigrationOption = new Option<string?>(
name: "--target",
description: "Target migration");
var initCommand = new Command("init", "Initialize database (migrate + seed + superuser)");
initCommand.AddOption(connectionStringOption);
initCommand.AddOption(appsettingsOption);
initCommand.AddOption(noPromptOption);
initCommand.AddOption(verboseOption);
initCommand.AddOption(forceOption);
initCommand.AddOption(adminEmailOption);
initCommand.AddOption(adminPasswordOption);
initCommand.AddOption(adminDisplayNameOption);
initCommand.SetHandler(async (string? connectionString, string? appsettings, bool noPrompt, bool verbose, bool force, string? adminEmail, string? adminPassword) =>
{
var resolvedConnection = ResolveConnectionString(connectionString, appsettings, noPrompt);
if (string.IsNullOrWhiteSpace(resolvedConnection))
{
Console.Error.WriteLine("Connection string is required.");
return;
}
var services = BuildServices(resolvedConnection);
await using var scope = services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<MemberCenterDbContext>();
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<ApplicationRole>>();
if (!force && await IsInstalledAsync(db))
{
Console.Error.WriteLine("System already installed. Use --force to override.");
return;
}
if (!noPrompt)
{
adminEmail ??= Prompt("Superuser email", "admin@example.com");
adminPassword ??= PromptSecret("Superuser password");
}
adminEmail ??= "admin@example.com";
if (string.IsNullOrWhiteSpace(adminPassword))
{
Console.Error.WriteLine("Superuser password is required.");
return;
}
await db.Database.MigrateAsync();
var registry = scope.ServiceProvider.GetRequiredService<IAuthResourceRegistryService>();
await registry.EnsureDefaultsAsync();
var adminPermissionSeeder = scope.ServiceProvider.GetRequiredService<IAdminPermissionSeeder>();
await adminPermissionSeeder.EnsureDefaultsAsync();
await EnsureRoleAsync(roleManager, "superuser");
await EnsureRoleAsync(roleManager, "admin");
await EnsureRoleAsync(roleManager, "support");
var admin = await userManager.FindByEmailAsync(adminEmail);
if (admin is null)
{
admin = new ApplicationUser
{
Id = Guid.NewGuid(),
UserName = adminEmail,
Email = adminEmail,
EmailConfirmed = true
};
var createResult = await userManager.CreateAsync(admin, adminPassword);
if (!createResult.Succeeded)
{
Console.Error.WriteLine(string.Join(Environment.NewLine, createResult.Errors.Select(e => e.Description)));
return;
}
}
if (!await userManager.IsInRoleAsync(admin, "superuser"))
{
await userManager.AddToRoleAsync(admin, "superuser");
}
await SetInstalledFlagAsync(db);
if (verbose)
{
Console.WriteLine("Init completed.");
}
}, connectionStringOption, appsettingsOption, noPromptOption, verboseOption, forceOption, adminEmailOption, adminPasswordOption);
var addAdminCommand = new Command("add-superuser", "Add or elevate superuser");
addAdminCommand.AddAlias("add-admin");
addAdminCommand.AddOption(connectionStringOption);
addAdminCommand.AddOption(appsettingsOption);
addAdminCommand.AddOption(noPromptOption);
addAdminCommand.AddOption(adminEmailOption);
addAdminCommand.AddOption(adminPasswordOption);
addAdminCommand.SetHandler(async (string? connectionString, string? appsettings, bool noPrompt, string? adminEmail, string? adminPassword) =>
{
var resolvedConnection = ResolveConnectionString(connectionString, appsettings, noPrompt);
if (string.IsNullOrWhiteSpace(resolvedConnection))
{
Console.Error.WriteLine("Connection string is required.");
return;
}
if (!noPrompt)
{
adminEmail ??= Prompt("Superuser email", "admin@example.com");
adminPassword ??= PromptSecret("Superuser password");
}
adminEmail ??= "admin@example.com";
if (string.IsNullOrWhiteSpace(adminPassword))
{
Console.Error.WriteLine("Superuser password is required.");
return;
}
var services = BuildServices(resolvedConnection);
await using var scope = services.CreateAsyncScope();
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<ApplicationRole>>();
await EnsureRoleAsync(roleManager, "superuser");
var admin = await userManager.FindByEmailAsync(adminEmail);
if (admin is null)
{
admin = new ApplicationUser
{
Id = Guid.NewGuid(),
UserName = adminEmail,
Email = adminEmail,
EmailConfirmed = true
};
var createResult = await userManager.CreateAsync(admin, adminPassword);
if (!createResult.Succeeded)
{
Console.Error.WriteLine(string.Join(Environment.NewLine, createResult.Errors.Select(e => e.Description)));
return;
}
}
if (!await userManager.IsInRoleAsync(admin, "superuser"))
{
await userManager.AddToRoleAsync(admin, "superuser");
}
Console.WriteLine("Superuser ready.");
}, connectionStringOption, appsettingsOption, noPromptOption, adminEmailOption, adminPasswordOption);
var resetCommand = new Command("reset-superuser-password", "Reset superuser password");
resetCommand.AddAlias("reset-admin-password");
resetCommand.AddOption(connectionStringOption);
resetCommand.AddOption(appsettingsOption);
resetCommand.AddOption(noPromptOption);
resetCommand.AddOption(adminEmailOption);
resetCommand.AddOption(adminPasswordOption);
resetCommand.SetHandler(async (string? connectionString, string? appsettings, bool noPrompt, string? adminEmail, string? adminPassword) =>
{
var resolvedConnection = ResolveConnectionString(connectionString, appsettings, noPrompt);
if (string.IsNullOrWhiteSpace(resolvedConnection))
{
Console.Error.WriteLine("Connection string is required.");
return;
}
if (!noPrompt)
{
adminEmail ??= Prompt("Superuser email", "admin@example.com");
adminPassword ??= PromptSecret("New password");
}
adminEmail ??= "admin@example.com";
if (string.IsNullOrWhiteSpace(adminPassword))
{
Console.Error.WriteLine("Superuser password is required.");
return;
}
var services = BuildServices(resolvedConnection);
await using var scope = services.CreateAsyncScope();
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
var admin = await userManager.FindByEmailAsync(adminEmail);
if (admin is null)
{
Console.Error.WriteLine("Superuser not found.");
return;
}
var resetToken = await userManager.GeneratePasswordResetTokenAsync(admin);
var result = await userManager.ResetPasswordAsync(admin, resetToken, adminPassword);
if (!result.Succeeded)
{
Console.Error.WriteLine(string.Join(Environment.NewLine, result.Errors.Select(e => e.Description)));
return;
}
Console.WriteLine("Password reset completed.");
}, connectionStringOption, appsettingsOption, noPromptOption, adminEmailOption, adminPasswordOption);
var migrateCommand = new Command("migrate", "Run migrations only");
migrateCommand.AddOption(connectionStringOption);
migrateCommand.AddOption(appsettingsOption);
migrateCommand.AddOption(targetMigrationOption);
migrateCommand.SetHandler(async (string? connectionString, string? appsettings, string? target) =>
{
var resolvedConnection = ResolveConnectionString(connectionString, appsettings, noPrompt: true);
if (string.IsNullOrWhiteSpace(resolvedConnection))
{
Console.Error.WriteLine("Connection string is required.");
return;
}
var services = BuildServices(resolvedConnection);
await using var scope = services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<MemberCenterDbContext>();
if (string.IsNullOrWhiteSpace(target))
{
await db.Database.MigrateAsync();
var registry = scope.ServiceProvider.GetRequiredService<IAuthResourceRegistryService>();
await registry.EnsureDefaultsAsync();
var adminPermissionSeeder = scope.ServiceProvider.GetRequiredService<IAdminPermissionSeeder>();
await adminPermissionSeeder.EnsureDefaultsAsync();
}
else
{
var migrator = scope.ServiceProvider.GetRequiredService<IMigrator>();
await migrator.MigrateAsync(target);
}
Console.WriteLine("Migrations completed.");
}, connectionStringOption, appsettingsOption, targetMigrationOption);
var syncOAuthClientsCommand = new Command("sync-oauth-clients", "Refresh OAuth client permissions from usage defaults");
syncOAuthClientsCommand.AddOption(connectionStringOption);
syncOAuthClientsCommand.AddOption(appsettingsOption);
syncOAuthClientsCommand.SetHandler(async (string? connectionString, string? appsettings) =>
{
var resolvedConnection = ResolveConnectionString(connectionString, appsettings, noPrompt: true);
if (string.IsNullOrWhiteSpace(resolvedConnection))
{
Console.Error.WriteLine("Connection string is required.");
return;
}
var services = BuildServices(resolvedConnection);
await using var scope = services.CreateAsyncScope();
var registry = scope.ServiceProvider.GetRequiredService<IAuthResourceRegistryService>();
await registry.EnsureDefaultsAsync();
var adminPermissionSeeder = scope.ServiceProvider.GetRequiredService<IAdminPermissionSeeder>();
await adminPermissionSeeder.EnsureDefaultsAsync();
var applicationManager = scope.ServiceProvider.GetRequiredService<IOpenIddictApplicationManager>();
var applications = new List<object>();
await foreach (var application in applicationManager.ListAsync())
{
applications.Add(application);
}
var updated = 0;
foreach (var application in applications)
{
var properties = await applicationManager.GetPropertiesAsync(application);
if (!properties.TryGetValue("usage", out var usageElement))
{
continue;
}
var usage = usageElement.GetString();
if (string.IsNullOrWhiteSpace(usage))
{
continue;
}
var descriptor = new OpenIddictApplicationDescriptor();
await applicationManager.PopulateAsync(descriptor, application);
await ApplyOAuthClientPermissionsAsync(descriptor, registry, usage);
await applicationManager.UpdateAsync(application, descriptor);
updated++;
}
Console.WriteLine($"OAuth clients synchronized: {updated}.");
}, connectionStringOption, appsettingsOption);
root.AddCommand(initCommand);
root.AddCommand(addAdminCommand);
root.AddCommand(resetCommand);
root.AddCommand(migrateCommand);
root.AddCommand(syncOAuthClientsCommand);
return await root.InvokeAsync(args);
static IServiceProvider BuildServices(string connectionString)
{
var services = new ServiceCollection();
var configuration = new ConfigurationBuilder()
.AddEnvironmentVariables()
.Build();
var identitySecurity = configuration.GetSection("IdentitySecurity").Get<IdentitySecurityOptions>() ?? new();
SecurityRuntimeOptionsValidator.Validate(identitySecurity);
services.AddLogging(builder => builder.AddConsole());
services.AddSingleton<IConfiguration>(configuration);
services.AddDbContext<MemberCenterDbContext>(options =>
{
options.UseNpgsql(connectionString);
options.UseOpenIddict();
});
services.AddOpenIddict()
.AddCore(options =>
{
options.UseEntityFrameworkCore()
.UseDbContext<MemberCenterDbContext>();
});
services.AddIdentity<ApplicationUser, ApplicationRole>(options =>
{
options.User.RequireUniqueEmail = true;
options.Password.RequireDigit = identitySecurity.Password.RequireDigit;
options.Password.RequireLowercase = identitySecurity.Password.RequireLowercase;
options.Password.RequireUppercase = identitySecurity.Password.RequireUppercase;
options.Password.RequireNonAlphanumeric = identitySecurity.Password.RequireNonAlphanumeric;
options.Password.RequiredLength = identitySecurity.Password.RequiredLength;
options.Lockout.AllowedForNewUsers = identitySecurity.Lockout.AllowedForNewUsers;
options.Lockout.MaxFailedAccessAttempts = identitySecurity.Lockout.MaxFailedAccessAttempts;
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(identitySecurity.Lockout.DefaultLockoutMinutes);
})
.AddEntityFrameworkStores<MemberCenterDbContext>()
.AddDefaultTokenProviders();
services.AddScoped<IAuthResourceRegistryService, AuthResourceRegistryService>();
services.AddScoped<AdminPermissionService>();
services.AddScoped<IAdminPermissionChecker>(provider => provider.GetRequiredService<AdminPermissionService>());
services.AddScoped<IAdminPermissionSeeder>(provider => provider.GetRequiredService<AdminPermissionService>());
return services.BuildServiceProvider();
}
static async Task ApplyOAuthClientPermissionsAsync(
OpenIddictApplicationDescriptor descriptor,
IAuthResourceRegistryService registry,
string usage)
{
descriptor.Permissions.Clear();
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Token);
if (UsesAuthorizationCodeFlow(usage))
{
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Authorization);
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode);
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.RefreshToken);
descriptor.Permissions.Add(OpenIddictConstants.Permissions.ResponseTypes.Code);
}
else
{
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.ClientCredentials);
}
var scopes = await registry.GetAllowedScopesForUsageAsync(usage);
foreach (var scope in scopes)
{
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Prefixes.Scope + scope);
}
}
static bool UsesAuthorizationCodeFlow(string usage) =>
string.Equals(usage, "web_login", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "webhook_outbound", StringComparison.OrdinalIgnoreCase);
static string? ResolveConnectionString(string? connectionString, string? appsettingsPath, bool noPrompt)
{
var targetPath = ResolveAppsettingsPath(appsettingsPath);
var envConnection = Environment.GetEnvironmentVariable("ConnectionStrings__Default")
?? Environment.GetEnvironmentVariable("MEMBERCENTER_CONNECTION");
if (!string.IsNullOrWhiteSpace(envConnection))
{
return envConnection;
}
if (!string.IsNullOrWhiteSpace(connectionString))
{
WriteConnectionString(targetPath, connectionString);
return connectionString;
}
if (File.Exists(targetPath))
{
var config = new ConfigurationBuilder()
.AddJsonFile(targetPath, optional: true)
.Build();
var existing = config.GetConnectionString("Default");
if (!string.IsNullOrWhiteSpace(existing))
{
return existing;
}
}
if (noPrompt)
{
return null;
}
var host = Prompt("DB host", "localhost");
var database = Prompt("DB name", "member_center");
var username = Prompt("DB user", "postgres");
var password = PromptSecret("DB password");
var generated = $"Host={host};Database={database};Username={username};Password={password}";
WriteConnectionString(targetPath, generated);
return generated;
}
static string ResolveAppsettingsPath(string? appsettingsPath)
{
if (!string.IsNullOrWhiteSpace(appsettingsPath))
{
return appsettingsPath;
}
var apiDefault = Path.Combine("src", "MemberCenter.Api", "appsettings.json");
if (File.Exists(apiDefault))
{
return apiDefault;
}
return "appsettings.json";
}
static void WriteConnectionString(string path, string connectionString)
{
JsonNode root;
if (File.Exists(path))
{
var json = File.ReadAllText(path);
root = JsonNode.Parse(json) ?? new JsonObject();
}
else
{
root = new JsonObject();
}
var obj = root as JsonObject ?? new JsonObject();
var connectionStrings = obj["ConnectionStrings"] as JsonObject ?? new JsonObject();
connectionStrings["Default"] = connectionString;
obj["ConnectionStrings"] = connectionStrings;
var options = new JsonSerializerOptions { WriteIndented = true };
File.WriteAllText(path, obj.ToJsonString(options));
}
static async Task<bool> IsInstalledAsync(MemberCenterDbContext db)
{
try
{
var flag = await db.SystemFlags.FirstOrDefaultAsync(f => f.Key == "installed");
return flag is not null && string.Equals(flag.Value, "true", StringComparison.OrdinalIgnoreCase);
}
catch (PostgresException ex) when (ex.SqlState == "42P01")
{
return false;
}
}
static async Task SetInstalledFlagAsync(MemberCenterDbContext db)
{
var flag = await db.SystemFlags.FirstOrDefaultAsync(f => f.Key == "installed");
if (flag is null)
{
db.SystemFlags.Add(new SystemFlag
{
Id = Guid.NewGuid(),
Key = "installed",
Value = "true",
UpdatedAt = DateTimeOffset.UtcNow
});
}
else
{
flag.Value = "true";
flag.UpdatedAt = DateTimeOffset.UtcNow;
}
await db.SaveChangesAsync();
}
static async Task EnsureRoleAsync(RoleManager<ApplicationRole> roleManager, string roleName)
{
if (!await roleManager.RoleExistsAsync(roleName))
{
var result = await roleManager.CreateAsync(new ApplicationRole
{
Id = Guid.NewGuid(),
Name = roleName,
NormalizedName = roleName.ToUpperInvariant()
});
if (!result.Succeeded)
{
throw new InvalidOperationException(string.Join(Environment.NewLine, result.Errors.Select(e => e.Description)));
}
}
}
static string Prompt(string label, string defaultValue)
{
Console.Write($"{label} [{defaultValue}]: ");
var input = Console.ReadLine();
return string.IsNullOrWhiteSpace(input) ? defaultValue : input.Trim();
}
static string PromptSecret(string label)
{
Console.Write($"{label}: ");
var password = string.Empty;
ConsoleKeyInfo key;
while ((key = Console.ReadKey(true)).Key != ConsoleKey.Enter)
{
if (key.Key == ConsoleKey.Backspace && password.Length > 0)
{
password = password[..^1];
Console.Write("\b \b");
continue;
}
if (!char.IsControl(key.KeyChar))
{
password += key.KeyChar;
Console.Write("*");
}
}
Console.WriteLine();
return password;
}