fix: secure bootstrap administrator creation

This commit is contained in:
xiangning 2026-07-23 17:55:24 +08:00
parent 09ced1cd32
commit 3f6082a9b1
12 changed files with 308 additions and 41 deletions

View File

@ -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<BuildFlow.Platform.Domain.Services.IAvatarUrlResolver, InProcessAvatarUrlResolver>();
// 首次部署管理员默认关闭,密码只能从外部配置源注入。
services.Configure<BootstrapAdminOptions>(
config.GetSection(BootstrapAdminOptions.SectionName));
// JWT 认证 + CORS:使用共享内核扩展方法(消除重复的认证/跨域配置)
services.AddSharedJwtAuthentication(config);
services.AddSharedCors();
@ -213,4 +218,4 @@ public class BuildFlowHostModule : AbpModule
context.GetEndpointRouteBuilder().MapHub<NotificationHub>("/hubs/notifications");
context.GetEndpointRouteBuilder().MapHub<ChatHub>("/hubs/chat").RequireAuthorization();
}
}
}

View File

@ -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<PlatformDbContext>();
await PlatformSeedData.SeedAsync(platformDb);
var bootstrapOptions =
sp.GetRequiredService<IOptions<BootstrapAdminOptions>>().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<WorkflowDbContext>();
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");
}
/// <summary>

View File

@ -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<RegisterRequest>
.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);
});
}
}

View File

@ -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",

View File

@ -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,

View File

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

View File

@ -0,0 +1,27 @@
namespace BuildFlow.Platform.Domain.Services;
/// <summary>
/// 平台账号统一密码强度规则,供注册和首次部署管理员初始化共同使用。
/// </summary>
public static class PasswordPolicy
{
/// <summary>密码最小长度。</summary>
public const int MinimumLength = 8;
/// <summary>密码最大长度。</summary>
public const int MaximumLength = 100;
/// <summary>
/// 返回首个密码强度错误;返回 <see langword="null"/> 表示密码符合要求。
/// </summary>
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;
}
}

View File

@ -0,0 +1,22 @@
namespace BuildFlow.Platform.Infrastructure.Configuration;
/// <summary>
/// 首次部署管理员初始化配置。密码必须由环境变量、Secrets 或 Consul 注入。
/// </summary>
public sealed class BootstrapAdminOptions
{
/// <summary>配置节名称。</summary>
public const string SectionName = "Platform:BootstrapAdmin";
/// <summary>是否启用首次部署管理员初始化。</summary>
public bool Enabled { get; set; }
/// <summary>首次创建的管理员用户名。</summary>
public string Username { get; set; } = "admin";
/// <summary>首次创建的管理员邮箱。</summary>
public string Email { get; set; } = "admin@rag.local";
/// <summary>由外部安全配置源提供的管理员明文初始密码。</summary>
public string Password { get; set; } = string.Empty;
}

View File

@ -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;
/// <summary>
/// 按部署配置幂等初始化最高权限管理员,已存在管理员时绝不修改凭据。
/// </summary>
public static class BootstrapAdminSeeder
{
/// <summary>平台最高权限 Admin 角色的固定标识。</summary>
public static readonly Guid AdminRoleId =
Guid.Parse("20000000-0000-0000-0000-000000000002");
/// <summary>
/// 执行管理员初始化;返回 true 表示本次创建,false 表示功能关闭或已有管理员。
/// </summary>
public static async Task<bool> 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;
}
}

View File

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

View File

@ -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;
/// <summary>
/// 首次部署管理员初始化安全边界测试。
/// </summary>
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<InvalidOperationException>()
.WithMessage("*密码不能为空*");
}
[Fact]
public async Task WeakPasswordShouldFailClearly()
{
await using var db = PlatformTestDb.CreateDbContext();
var act = () => BootstrapAdminSeeder.SeedAsync(db, ValidOptions("weakpass"));
await act.Should().ThrowAsync<InvalidOperationException>()
.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();
}
}

View File

@ -0,0 +1,27 @@
using BuildFlow.Platform.Domain.Services;
using Xunit;
namespace BuildFlow.Platform.Tests.Auth;
/// <summary>
/// 平台统一密码强度策略测试。
/// </summary>
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"));
}
}