Files
vtuber-awards/Backend/Data/TeamAccountBootstrapper.cs
T
2026-06-26 18:09:29 +02:00

190 lines
6.6 KiB
C#

using Backend.Domain;
using Backend.Security;
using Microsoft.EntityFrameworkCore;
namespace Backend.Data;
public static class TeamAccountBootstrapper
{
private const string SeedActor = "system:team-seed";
public static async Task EnsureAsync(AwardsDbContext db, IConfiguration configuration)
{
var ownerSeed = BuildSeed(
configuration,
sectionName: "Owner",
environmentPrefix: "VTSA_TEAM_OWNER",
defaultLogin: "jayuhime",
defaultDisplayName: "Jayuhime",
role: AdminRoles.Owner,
fallbackPassword: null);
var creatorSeed = BuildSeed(
configuration,
sectionName: "Creator",
environmentPrefix: "VTSA_TEAM_CREATOR",
defaultLogin: "sleepy_bao",
defaultDisplayName: "sleepy_bao",
role: AdminRoles.Creator,
fallbackPassword: null);
foreach (var seed in new[] { ownerSeed, creatorSeed })
{
await EnsureMemberAsync(db, seed);
}
await DeactivateDemoBackedSeedAccountAsync(
db,
ownerSeed,
configuration["VTSA_DEMO_ADMIN_PASSWORD"] ?? configuration["DemoAdmin:Password"]);
await db.SaveChangesAsync();
}
private static async Task EnsureMemberAsync(AwardsDbContext db, TeamAccountSeed seed)
{
if (seed.Credentials is null)
{
return;
}
var member = await db.TeamMembers.FirstOrDefaultAsync(item => item.Login == seed.Login);
if (member is null)
{
db.TeamMembers.Add(new TeamMember
{
Login = seed.Login,
DisplayName = seed.DisplayName,
Role = seed.Role,
PasswordHash = seed.Credentials.Value.Hash,
PasswordSalt = seed.Credentials.Value.Salt,
MustChangePassword = false,
IsActive = true,
CreatedByTwitchId = SeedActor,
CreatedAt = DateTimeOffset.UtcNow,
});
return;
}
var changed = false;
if (!string.Equals(member.DisplayName, seed.DisplayName, StringComparison.Ordinal))
{
member.DisplayName = seed.DisplayName;
changed = true;
}
if (!string.Equals(member.Role, seed.Role, StringComparison.Ordinal))
{
member.Role = seed.Role;
changed = true;
}
if (!member.IsActive)
{
member.IsActive = true;
changed = true;
}
if (member.CreatedByTwitchId == SeedActor
&& (string.IsNullOrWhiteSpace(member.UpdatedByTwitchId)
|| string.Equals(member.UpdatedByTwitchId, SeedActor, StringComparison.Ordinal))
&& (!string.Equals(member.PasswordHash, seed.Credentials.Value.Hash, StringComparison.Ordinal)
|| !string.Equals(member.PasswordSalt, seed.Credentials.Value.Salt, StringComparison.Ordinal)))
{
member.PasswordHash = seed.Credentials.Value.Hash;
member.PasswordSalt = seed.Credentials.Value.Salt;
member.MustChangePassword = false;
changed = true;
}
if (changed)
{
member.UpdatedAt = DateTimeOffset.UtcNow;
member.UpdatedByTwitchId = SeedActor;
}
}
private static async Task DeactivateDemoBackedSeedAccountAsync(AwardsDbContext db, TeamAccountSeed seed, string? demoPassword)
{
if (seed.Credentials is not null || string.IsNullOrWhiteSpace(demoPassword))
{
return;
}
var member = await db.TeamMembers.FirstOrDefaultAsync(item => item.Login == seed.Login);
if (member is null
|| member.CreatedByTwitchId != SeedActor
|| !DemoCredentialHasher.VerifyPassword(demoPassword, member.PasswordHash, member.PasswordSalt))
{
return;
}
member.IsActive = false;
member.UpdatedAt = DateTimeOffset.UtcNow;
member.UpdatedByTwitchId = SeedActor;
}
private static TeamAccountSeed BuildSeed(
IConfiguration configuration,
string sectionName,
string environmentPrefix,
string defaultLogin,
string defaultDisplayName,
string role,
string? fallbackPassword)
{
var login = ReadSetting(configuration, sectionName, environmentPrefix, "Login") ?? defaultLogin;
var displayName = ReadSetting(configuration, sectionName, environmentPrefix, "DisplayName") ?? defaultDisplayName;
return new TeamAccountSeed(
NormalizeLogin(login),
displayName.Trim(),
role,
ResolveCredentials(configuration, sectionName, environmentPrefix, fallbackPassword));
}
private static TeamSeedCredentials? ResolveCredentials(
IConfiguration configuration,
string sectionName,
string environmentPrefix,
string? fallbackPassword)
{
var configuredHash = ReadSetting(configuration, sectionName, environmentPrefix, "PasswordHash");
var configuredSalt = ReadSetting(configuration, sectionName, environmentPrefix, "PasswordSalt");
if (!string.IsNullOrWhiteSpace(configuredHash) && !string.IsNullOrWhiteSpace(configuredSalt))
{
return new TeamSeedCredentials(configuredHash.Trim(), configuredSalt.Trim());
}
var password = ReadSetting(configuration, sectionName, environmentPrefix, "Password") ?? fallbackPassword;
if (string.IsNullOrWhiteSpace(password))
{
return null;
}
var credentials = DemoCredentialHasher.HashPassword(password);
return new TeamSeedCredentials(credentials.Hash, credentials.Salt);
}
private static string? ReadSetting(IConfiguration configuration, string sectionName, string environmentPrefix, string key)
{
var raw = configuration[$"{environmentPrefix}_{ToEnvironmentKey(key)}"]
?? configuration[$"TeamSeed:{sectionName}:{key}"];
return string.IsNullOrWhiteSpace(raw) ? null : raw;
}
private static string ToEnvironmentKey(string key)
{
return string.Concat(key.Select((character, index) =>
index > 0 && char.IsUpper(character) ? $"_{character}" : character.ToString())).ToUpperInvariant();
}
private static string NormalizeLogin(string value) =>
value.Trim().TrimStart('@').ToLowerInvariant();
private sealed record TeamAccountSeed(string Login, string DisplayName, string Role, TeamSeedCredentials? Credentials);
private readonly record struct TeamSeedCredentials(string Hash, string Salt);
}