fix: secure bootstrap administrator creation
This commit is contained in:
parent
09ced1cd32
commit
3f6082a9b1
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
27
src/modules/platform/Domain/Services/PasswordPolicy.cs
Normal file
27
src/modules/platform/Domain/Services/PasswordPolicy.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
104
tests/BuildFlow.Platform.Tests/Auth/BootstrapAdminSeederTests.cs
Normal file
104
tests/BuildFlow.Platform.Tests/Auth/BootstrapAdminSeederTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
27
tests/BuildFlow.Platform.Tests/Auth/PasswordPolicyTests.cs
Normal file
27
tests/BuildFlow.Platform.Tests/Auth/PasswordPolicyTests.cs
Normal 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"));
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user