diff --git a/src/BuildFlow.Host/BuildFlowHostModule.cs b/src/BuildFlow.Host/BuildFlowHostModule.cs index 91e08b8..9ff8ead 100644 --- a/src/BuildFlow.Host/BuildFlowHostModule.cs +++ b/src/BuildFlow.Host/BuildFlowHostModule.cs @@ -18,6 +18,7 @@ using IM.Domain.Common; using IM.Infrastructure; using Workflow.Domain.Common; using BuildFlow.Platform.Application; +using BuildFlow.Platform.Infrastructure.Configuration; using Scripting.Application; using Scripting.Infrastructure; using Monitoring.Application; @@ -122,6 +123,10 @@ public class BuildFlowHostModule : AbpModule // 头像 URL 解析:platform 模块查询用户信息时,把持久化的 objectKey 换算成预签名 URL services.AddScoped(); + // 首次部署管理员默认关闭,密码只能从外部配置源注入。 + services.Configure( + config.GetSection(BootstrapAdminOptions.SectionName)); + // JWT 认证 + CORS:使用共享内核扩展方法(消除重复的认证/跨域配置) services.AddSharedJwtAuthentication(config); services.AddSharedCors(); @@ -213,4 +218,4 @@ public class BuildFlowHostModule : AbpModule context.GetEndpointRouteBuilder().MapHub("/hubs/notifications"); context.GetEndpointRouteBuilder().MapHub("/hubs/chat").RequireAuthorization(); } -} \ No newline at end of file +} diff --git a/src/BuildFlow.Host/Configuration/DatabaseInitializer.cs b/src/BuildFlow.Host/Configuration/DatabaseInitializer.cs index d9828b4..8c70ec7 100644 --- a/src/BuildFlow.Host/Configuration/DatabaseInitializer.cs +++ b/src/BuildFlow.Host/Configuration/DatabaseInitializer.cs @@ -1,9 +1,11 @@ using BuildFlow.Infrastructure.Persistence; using BuildFlow.Platform.Infrastructure.Persistence; +using BuildFlow.Platform.Infrastructure.Configuration; using File.Infrastructure.Persistence; using IM.Infrastructure.Mongo; using IM.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Options; using MongoDB.Entities; using Monitoring.Infrastructure.Persistence; using Scripting.Infrastructure.Persistence; @@ -71,13 +73,29 @@ public static class DatabaseInitializer logger, "IM MongoDB 建索引"); - // 3. 种子数据:平台(权限/角色/用户/菜单)+ workflow(组件注册) + // 3. 平台种子和首次管理员初始化。管理员配置失败不阻断独立的 Workflow 种子。 await SafeRunAsync( async () => { var platformDb = sp.GetRequiredService(); await PlatformSeedData.SeedAsync(platformDb); + var bootstrapOptions = + sp.GetRequiredService>().Value; + var adminCreated = await BootstrapAdminSeeder.SeedAsync( + platformDb, + bootstrapOptions); + logger.LogInformation(adminCreated + ? "Bootstrap admin 已创建" + : "Bootstrap admin 已跳过"); + }, + logger, + "Platform Seed"); + + // 4. Workflow 组件注册与历史图数据回填。 + await SafeRunAsync( + async () => + { var wfDb = sp.GetRequiredService(); await WorkflowSeedData.SeedAsync(wfDb); @@ -87,7 +105,7 @@ public static class DatabaseInitializer logger.LogInformation("Workflow graph migration: {Defs} definitions, {Snaps} snapshots, {Insts} instances backfilled", defs, snaps, insts); }, logger, - "Seed"); + "Workflow Seed"); } /// diff --git a/src/BuildFlow.Host/Endpoints/Platform/Auth/RegisterRequestValidator.cs b/src/BuildFlow.Host/Endpoints/Platform/Auth/RegisterRequestValidator.cs index 124cfd2..463e224 100644 --- a/src/BuildFlow.Host/Endpoints/Platform/Auth/RegisterRequestValidator.cs +++ b/src/BuildFlow.Host/Endpoints/Platform/Auth/RegisterRequestValidator.cs @@ -1,4 +1,5 @@ using BuildFlow.Platform.Application.Auth.DTOs; +using BuildFlow.Platform.Domain.Services; namespace BuildFlow.Host.Endpoints.Platform.Auth; @@ -18,7 +19,10 @@ public class RegisterRequestValidator : Validator .EmailAddress().WithMessage("邮箱格式不正确"); RuleFor(x => x.Password) - .NotEmpty().WithMessage("密码不能为空") - .Length(6, 100).WithMessage("密码长度 6-100 个字符"); + .Custom((password, context) => + { + var error = PasswordPolicy.GetValidationError(password); + if (error is not null) context.AddFailure(error); + }); } } diff --git a/src/BuildFlow.Host/appsettings.Secrets.example.json b/src/BuildFlow.Host/appsettings.Secrets.example.json index 4261a08..fb1b2c5 100644 --- a/src/BuildFlow.Host/appsettings.Secrets.example.json +++ b/src/BuildFlow.Host/appsettings.Secrets.example.json @@ -4,6 +4,14 @@ "MongoDb": "mongodb://appuser:YOUR_MONGO_PASSWORD@YOUR_HOST:17017/im_system?authSource=app", "Redis": "YOUR_HOST:16379,password=YOUR_REDIS_PASSWORD,abortConnect=false" }, + "Platform": { + "BootstrapAdmin": { + "Enabled": false, + "Username": "admin", + "Email": "admin@your-company.example", + "Password": "YOUR_STRONG_BOOTSTRAP_PASSWORD" + } + }, "RabbitMq": { "Host": "YOUR_HOST", "Username": "rag", diff --git a/src/BuildFlow.Host/appsettings.json b/src/BuildFlow.Host/appsettings.json index b8cae72..d6de368 100644 --- a/src/BuildFlow.Host/appsettings.json +++ b/src/BuildFlow.Host/appsettings.json @@ -34,6 +34,14 @@ "Platform": "platform", "Monitoring": "monitoring" }, + "Platform": { + "BootstrapAdmin": { + "Enabled": false, + "Username": "admin", + "Email": "admin@rag.local", + "Password": "" + } + }, "RabbitMq": { "Host": "{INFRA_HOST}", "Port": 25672, diff --git a/src/modules/platform/Application/Auth/Validators/RegisterCommandValidator.cs b/src/modules/platform/Application/Auth/Validators/RegisterCommandValidator.cs index bc2a825..db10528 100644 --- a/src/modules/platform/Application/Auth/Validators/RegisterCommandValidator.cs +++ b/src/modules/platform/Application/Auth/Validators/RegisterCommandValidator.cs @@ -1,5 +1,6 @@ using FluentValidation; using BuildFlow.Platform.Application.Auth.Commands; +using BuildFlow.Platform.Domain.Services; namespace BuildFlow.Platform.Application.Auth.Validators; @@ -16,11 +17,11 @@ public class RegisterCommandValidator : AbstractValidator RuleFor(x => x.Email).NotEmpty().WithMessage("邮箱不能为空") .EmailAddress().WithMessage("邮箱格式不正确"); - RuleFor(x => x.Password).NotEmpty().WithMessage("密码不能为空") - .MinimumLength(8).WithMessage("密码至少 8 个字符") - .MaximumLength(100).WithMessage("密码最多 100 个字符") - .Matches("[A-Z]").WithMessage("密码必须包含至少一个大写字母") - .Matches("[a-z]").WithMessage("密码必须包含至少一个小写字母") - .Matches("[0-9]").WithMessage("密码必须包含至少一个数字"); + RuleFor(x => x.Password) + .Custom((password, context) => + { + var error = PasswordPolicy.GetValidationError(password); + if (error is not null) context.AddFailure(error); + }); } -} \ No newline at end of file +} diff --git a/src/modules/platform/Domain/Services/PasswordPolicy.cs b/src/modules/platform/Domain/Services/PasswordPolicy.cs new file mode 100644 index 0000000..66e5d33 --- /dev/null +++ b/src/modules/platform/Domain/Services/PasswordPolicy.cs @@ -0,0 +1,27 @@ +namespace BuildFlow.Platform.Domain.Services; + +/// +/// 平台账号统一密码强度规则,供注册和首次部署管理员初始化共同使用。 +/// +public static class PasswordPolicy +{ + /// 密码最小长度。 + public const int MinimumLength = 8; + + /// 密码最大长度。 + public const int MaximumLength = 100; + + /// + /// 返回首个密码强度错误;返回 表示密码符合要求。 + /// + public static string? GetValidationError(string? password) + { + if (string.IsNullOrEmpty(password)) return "密码不能为空"; + if (password.Length < MinimumLength) return "密码至少 8 个字符"; + if (password.Length > MaximumLength) return "密码最多 100 个字符"; + if (!password.Any(c => c is >= 'A' and <= 'Z')) return "密码必须包含至少一个大写字母"; + if (!password.Any(c => c is >= 'a' and <= 'z')) return "密码必须包含至少一个小写字母"; + if (!password.Any(c => c is >= '0' and <= '9')) return "密码必须包含至少一个数字"; + return null; + } +} diff --git a/src/modules/platform/Infrastructure/Configuration/BootstrapAdminOptions.cs b/src/modules/platform/Infrastructure/Configuration/BootstrapAdminOptions.cs new file mode 100644 index 0000000..e604a9b --- /dev/null +++ b/src/modules/platform/Infrastructure/Configuration/BootstrapAdminOptions.cs @@ -0,0 +1,22 @@ +namespace BuildFlow.Platform.Infrastructure.Configuration; + +/// +/// 首次部署管理员初始化配置。密码必须由环境变量、Secrets 或 Consul 注入。 +/// +public sealed class BootstrapAdminOptions +{ + /// 配置节名称。 + public const string SectionName = "Platform:BootstrapAdmin"; + + /// 是否启用首次部署管理员初始化。 + public bool Enabled { get; set; } + + /// 首次创建的管理员用户名。 + public string Username { get; set; } = "admin"; + + /// 首次创建的管理员邮箱。 + public string Email { get; set; } = "admin@rag.local"; + + /// 由外部安全配置源提供的管理员明文初始密码。 + public string Password { get; set; } = string.Empty; +} diff --git a/src/modules/platform/Infrastructure/Persistence/BootstrapAdminSeeder.cs b/src/modules/platform/Infrastructure/Persistence/BootstrapAdminSeeder.cs new file mode 100644 index 0000000..8f80be6 --- /dev/null +++ b/src/modules/platform/Infrastructure/Persistence/BootstrapAdminSeeder.cs @@ -0,0 +1,72 @@ +using BuildFlow.Platform.Domain.Entities.User; +using BuildFlow.Platform.Domain.Entities.UserRole; +using BuildFlow.Platform.Domain.Services; +using BuildFlow.Platform.Infrastructure.Configuration; +using Microsoft.EntityFrameworkCore; + +namespace BuildFlow.Platform.Infrastructure.Persistence; + +/// +/// 按部署配置幂等初始化最高权限管理员,已存在管理员时绝不修改凭据。 +/// +public static class BootstrapAdminSeeder +{ + /// 平台最高权限 Admin 角色的固定标识。 + public static readonly Guid AdminRoleId = + Guid.Parse("20000000-0000-0000-0000-000000000002"); + + /// + /// 执行管理员初始化;返回 true 表示本次创建,false 表示功能关闭或已有管理员。 + /// + public static async Task SeedAsync( + PlatformDbContext db, + BootstrapAdminOptions options, + CancellationToken ct = default) + { + if (!options.Enabled) return false; + + var existingAdminId = await db.UserRoles + .Where(x => x.RoleId == AdminRoleId) + .Select(x => x.UserId) + .FirstOrDefaultAsync(ct); + if (existingAdminId != Guid.Empty) return false; + + if (string.IsNullOrWhiteSpace(options.Username)) + throw new InvalidOperationException("BootstrapAdmin 用户名不能为空"); + if (string.IsNullOrWhiteSpace(options.Email)) + throw new InvalidOperationException("BootstrapAdmin 邮箱不能为空"); + + var passwordError = PasswordPolicy.GetValidationError(options.Password); + if (passwordError is not null) + throw new InvalidOperationException($"BootstrapAdmin 配置无效:{passwordError}"); + + if (!await db.Roles.AnyAsync(x => x.Id == AdminRoleId, ct)) + throw new InvalidOperationException("BootstrapAdmin 初始化失败:Admin 角色不存在"); + + var conflictingUser = await db.Users.FirstOrDefaultAsync( + x => x.Username == options.Username || x.Email == options.Email, + ct); + if (conflictingUser is not null) + { + throw new InvalidOperationException( + "BootstrapAdmin 用户名或邮箱已被非管理员账号占用"); + } + + var user = new UserEntity + { + Id = Guid.NewGuid(), + Username = options.Username, + Email = options.Email, + PasswordHash = BCrypt.Net.BCrypt.HashPassword(options.Password), + }; + await db.Users.AddAsync(user, ct); + await db.UserRoles.AddAsync(new UserRoleEntity + { + UserId = user.Id, + RoleId = AdminRoleId, + }, ct); + + await db.SaveChangesAsync(ct); + return true; + } +} diff --git a/src/modules/platform/Infrastructure/Persistence/SeedData.cs b/src/modules/platform/Infrastructure/Persistence/SeedData.cs index 7a097f8..c73739d 100644 --- a/src/modules/platform/Infrastructure/Persistence/SeedData.cs +++ b/src/modules/platform/Infrastructure/Persistence/SeedData.cs @@ -51,35 +51,6 @@ public static class SeedData // 方法已实现:先把 SuperAdmin 用户迁移到 Admin,再清理关联并软删除该角色。幂等。 await PurgeDeprecatedSuperAdminRoleAsync(db); - // 管理员用户 - var adminUserId = Guid.Parse("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); - var existingAdmin = await db.Users.FirstOrDefaultAsync(u => u.Id == adminUserId); - if (existingAdmin == null) - { - existingAdmin = await db.Users.FirstOrDefaultAsync(u => u.Username == "admin"); - if (existingAdmin == null) - { - existingAdmin = new UserEntity - { - Id = adminUserId, - Username = "admin", - Email = "admin@rag.local", - PasswordHash = BCrypt.Net.BCrypt.HashPassword("admin123") - }; - await db.Users.AddAsync(existingAdmin); - await db.SaveChangesAsync(); - } - } - - // 确保管理员有 Admin 角色(最高权限) - var adminRoleForUser = Guid.Parse("20000000-0000-0000-0000-000000000002"); - var hasRole = await db.UserRoles.AnyAsync(ur => ur.UserId == existingAdmin.Id && ur.RoleId == adminRoleForUser); - if (!hasRole) - { - await db.UserRoles.AddAsync(new UserRoleEntity { UserId = existingAdmin.Id, RoleId = adminRoleForUser }); - await db.SaveChangesAsync(); - } - // IM 演示用户:ID 与 im-system SeedData 保持一致,供通讯录和 Auth gRPC 查询使用 await SeedImDemoUsersAsync(db); } diff --git a/tests/BuildFlow.Platform.Tests/Auth/BootstrapAdminSeederTests.cs b/tests/BuildFlow.Platform.Tests/Auth/BootstrapAdminSeederTests.cs new file mode 100644 index 0000000..380d6f9 --- /dev/null +++ b/tests/BuildFlow.Platform.Tests/Auth/BootstrapAdminSeederTests.cs @@ -0,0 +1,104 @@ +using BuildFlow.Platform.Domain.Entities.Role; +using BuildFlow.Platform.Infrastructure.Configuration; +using BuildFlow.Platform.Infrastructure.Persistence; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace BuildFlow.Platform.Tests.Auth; + +/// +/// 首次部署管理员初始化安全边界测试。 +/// +public class BootstrapAdminSeederTests +{ + private static BootstrapAdminOptions ValidOptions(string password = "BootstrapAdmin1") => new() + { + Enabled = true, + Username = "bootstrap-admin", + Email = "bootstrap-admin@test.local", + Password = password, + }; + + private static async Task SeedAdminRoleAsync(PlatformDbContext db) + { + db.Roles.Add(PlatformTestDb.WithAudit(new RoleEntity + { + Id = BootstrapAdminSeeder.AdminRoleId, + Name = "Admin", + Description = "管理员", + })); + await db.SaveChangesAsync(); + } + + [Fact] + public async Task DisabledShouldNotCreateAUser() + { + await using var db = PlatformTestDb.CreateDbContext(); + + var created = await BootstrapAdminSeeder.SeedAsync( + db, + new BootstrapAdminOptions()); + + created.Should().BeFalse(); + db.Users.Should().BeEmpty(); + } + + [Fact] + public async Task EnabledWithoutPasswordShouldFailClearly() + { + await using var db = PlatformTestDb.CreateDbContext(); + var options = ValidOptions(""); + + var act = () => BootstrapAdminSeeder.SeedAsync(db, options); + + await act.Should().ThrowAsync() + .WithMessage("*密码不能为空*"); + } + + [Fact] + public async Task WeakPasswordShouldFailClearly() + { + await using var db = PlatformTestDb.CreateDbContext(); + + var act = () => BootstrapAdminSeeder.SeedAsync(db, ValidOptions("weakpass")); + + await act.Should().ThrowAsync() + .WithMessage("*大写字母*"); + } + + [Fact] + public async Task FirstRunShouldCreateAnAdminWithTheConfiguredPassword() + { + await using var db = PlatformTestDb.CreateDbContext(); + await SeedAdminRoleAsync(db); + var options = ValidOptions(); + + var created = await BootstrapAdminSeeder.SeedAsync(db, options); + + created.Should().BeTrue(); + var user = await db.Users.SingleAsync(); + user.Username.Should().Be(options.Username); + BCrypt.Net.BCrypt.Verify(options.Password, user.PasswordHash).Should().BeTrue(); + (await db.UserRoles.CountAsync(x => + x.UserId == user.Id && x.RoleId == BootstrapAdminSeeder.AdminRoleId)) + .Should().Be(1); + } + + [Fact] + public async Task RepeatedRunShouldNotResetTheExistingAdminPassword() + { + await using var db = PlatformTestDb.CreateDbContext(); + await SeedAdminRoleAsync(db); + await BootstrapAdminSeeder.SeedAsync(db, ValidOptions("BootstrapAdmin1")); + var originalHash = await db.Users.Select(x => x.PasswordHash).SingleAsync(); + + var created = await BootstrapAdminSeeder.SeedAsync( + db, + ValidOptions("DifferentAdmin2")); + + created.Should().BeFalse(); + (await db.Users.Select(x => x.PasswordHash).SingleAsync()).Should().Be(originalHash); + BCrypt.Net.BCrypt.Verify("BootstrapAdmin1", originalHash).Should().BeTrue(); + } +} diff --git a/tests/BuildFlow.Platform.Tests/Auth/PasswordPolicyTests.cs b/tests/BuildFlow.Platform.Tests/Auth/PasswordPolicyTests.cs new file mode 100644 index 0000000..32469f7 --- /dev/null +++ b/tests/BuildFlow.Platform.Tests/Auth/PasswordPolicyTests.cs @@ -0,0 +1,27 @@ +using BuildFlow.Platform.Domain.Services; +using Xunit; + +namespace BuildFlow.Platform.Tests.Auth; + +/// +/// 平台统一密码强度策略测试。 +/// +public class PasswordPolicyTests +{ + [Theory] + [InlineData("")] + [InlineData("short1A")] + [InlineData("alllowercase1")] + [InlineData("ALLUPPERCASE1")] + [InlineData("NoDigitsHere")] + public void InvalidPasswordsShouldReturnAnError(string password) + { + Assert.NotNull(PasswordPolicy.GetValidationError(password)); + } + + [Fact] + public void StrongPasswordShouldPass() + { + Assert.Null(PasswordPolicy.GetValidationError("BootstrapAdmin1")); + } +}