feat:首次启动创建默认账户

This commit is contained in:
ShaoHua
2026-05-11 01:48:17 +08:00
parent 3dbd97103c
commit c54f2e2ecc
12 changed files with 850 additions and 25 deletions
@@ -22,6 +22,7 @@ public static class CloudSyncEndpointExtensions
auth.MapPost("/bootstrap", BootstrapAdminAsync).AllowAnonymous(); auth.MapPost("/bootstrap", BootstrapAdminAsync).AllowAnonymous();
auth.MapPost("/login", LoginAsync).AllowAnonymous(); auth.MapPost("/login", LoginAsync).AllowAnonymous();
auth.MapPost("/step-up", StepUpAsync).RequireAuthorization(); auth.MapPost("/step-up", StepUpAsync).RequireAuthorization();
auth.MapPost("/change-password", ChangePasswordAsync).RequireAuthorization();
var tasks = app.MapGroup("/tasks").WithTags("CloudSync - Tasks"); var tasks = app.MapGroup("/tasks").WithTags("CloudSync - Tasks");
tasks.MapGet("/", GetTasksAsync).RequireAuthorization("tasks:read"); tasks.MapGet("/", GetTasksAsync).RequireAuthorization("tasks:read");
@@ -58,19 +59,19 @@ public static class CloudSyncEndpointExtensions
HttpContext httpContext, HttpContext httpContext,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (request == null || string.IsNullOrWhiteSpace(request.UserName) || string.IsNullOrWhiteSpace(request.Password)) if (request == null || string.IsNullOrWhiteSpace(request.UserName))
{ {
return CloudApiErrors.BadRequest("UserName and Password are required."); return CloudApiErrors.BadRequest("UserName is required.");
} }
var (ip, ua) = GetClientInfo(httpContext); var (ip, ua) = GetClientInfo(httpContext);
var ok = await authService.BootstrapAdminAsync(request.UserName, request.Password, ip, ua, cancellationToken); var result = await authService.BootstrapAdminAsync(request.UserName, request.Password, ip, ua, cancellationToken);
if (!ok) if (result == null)
{ {
return CloudApiErrors.Forbidden("Bootstrap is not allowed (already initialized or invalid input)."); return CloudApiErrors.Forbidden("Bootstrap is not allowed (already initialized or invalid input).");
} }
return Results.Ok(); return Results.Json(result);
} }
private static async Task<IResult> LoginAsync( private static async Task<IResult> LoginAsync(
@@ -121,6 +122,38 @@ public static class CloudSyncEndpointExtensions
return Results.Json(new StepUpResponse { StepUpExpiresAtUtc = expiresAt.Value }); return Results.Json(new StepUpResponse { StepUpExpiresAtUtc = expiresAt.Value });
} }
private static async Task<IResult> ChangePasswordAsync(
ChangePasswordRequest request,
CloudAuthService authService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var sessionId = httpContext.User.GetSessionId();
if (sessionId == null)
{
return CloudApiErrors.Unauthorized();
}
if (request == null || string.IsNullOrWhiteSpace(request.CurrentPassword) || string.IsNullOrWhiteSpace(request.NewPassword))
{
return CloudApiErrors.BadRequest("CurrentPassword and NewPassword are required.");
}
if (request.NewPassword.Length < 8)
{
return CloudApiErrors.BadRequest("NewPassword must be at least 8 characters.");
}
var (ip, ua) = GetClientInfo(httpContext);
var ok = await authService.ChangePasswordAsync(sessionId.Value, request.CurrentPassword, request.NewPassword, ip, ua, cancellationToken);
if (!ok)
{
return CloudApiErrors.BadRequest("Invalid current password or session expired.");
}
return Results.Ok();
}
private static async Task<IResult> GetTasksAsync( private static async Task<IResult> GetTasksAsync(
CloudTaskSyncService taskService, CloudTaskSyncService taskService,
HttpContext httpContext, HttpContext httpContext,
@@ -45,6 +45,11 @@ public class LoginResponse
/// 用户权限列表。 /// 用户权限列表。
/// </summary> /// </summary>
public List<string> Permissions { get; set; } = new(); public List<string> Permissions { get; set; } = new();
/// <summary>
/// 是否必须修改密码。
/// </summary>
public bool MustChangePassword { get; set; }
} }
/// <summary> /// <summary>
@@ -58,9 +63,41 @@ public class BootstrapAdminRequest
public string UserName { get; set; } = string.Empty; public string UserName { get; set; } = string.Empty;
/// <summary> /// <summary>
/// 密码。 /// 密码(可选,若不提供则自动生成随机密码)
/// </summary> /// </summary>
public string Password { get; set; } = string.Empty; public string? Password { get; set; }
}
/// <summary>
/// 初始化管理员账号响应。
/// </summary>
public class BootstrapAdminResponse
{
/// <summary>
/// 用户名。
/// </summary>
public string UserName { get; set; } = string.Empty;
/// <summary>
/// 生成的临时密码(仅在首次创建时返回)。
/// </summary>
public string GeneratedPassword { get; set; } = string.Empty;
}
/// <summary>
/// 修改密码请求。
/// </summary>
public class ChangePasswordRequest
{
/// <summary>
/// 当前密码。
/// </summary>
public string CurrentPassword { get; set; } = string.Empty;
/// <summary>
/// 新密码。
/// </summary>
public string NewPassword { get; set; } = string.Empty;
} }
/// <summary> /// <summary>
@@ -15,6 +15,7 @@ public class CloudAuthService
private readonly TodoDbContext _dbContext; private readonly TodoDbContext _dbContext;
private readonly IPasswordHasher<UserEntity> _passwordHasher; private readonly IPasswordHasher<UserEntity> _passwordHasher;
private readonly IRolePermissionMapper _rolePermissionMapper; private readonly IRolePermissionMapper _rolePermissionMapper;
private static readonly Random _random = new();
/// <summary> /// <summary>
/// 创建 <see cref="CloudAuthService"/>。 /// 创建 <see cref="CloudAuthService"/>。
@@ -32,6 +33,34 @@ public class CloudAuthService
_rolePermissionMapper = rolePermissionMapper; _rolePermissionMapper = rolePermissionMapper;
} }
private static string GenerateRandomPassword(int length = 16)
{
const string uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const string lowercase = "abcdefghijklmnopqrstuvwxyz";
const string digits = "0123456789";
const string special = "!@#$%^&*";
var allChars = uppercase + lowercase + digits + special;
var password = new char[length];
password[0] = uppercase[_random.Next(uppercase.Length)];
password[1] = lowercase[_random.Next(lowercase.Length)];
password[2] = digits[_random.Next(digits.Length)];
password[3] = special[_random.Next(special.Length)];
for (int i = 4; i < length; i++)
{
password[i] = allChars[_random.Next(allChars.Length)];
}
for (int i = password.Length - 1; i > 0; i--)
{
int j = _random.Next(i + 1);
(password[i], password[j]) = (password[j], password[i]);
}
return new string(password);
}
private async Task LogAsync( private async Task LogAsync(
string eventType, string eventType,
string description, string description,
@@ -62,17 +91,17 @@ public class CloudAuthService
/// 初始化系统管理员账号(仅在系统尚无云用户时可用)。 /// 初始化系统管理员账号(仅在系统尚无云用户时可用)。
/// </summary> /// </summary>
/// <param name="userName">用户名。</param> /// <param name="userName">用户名。</param>
/// <param name="password">密码。</param> /// <param name="password">密码(可选,若不提供则自动生成)。</param>
/// <param name="clientIp">客户端 IP。</param> /// <param name="clientIp">客户端 IP。</param>
/// <param name="userAgent">User-Agent。</param> /// <param name="userAgent">User-Agent。</param>
/// <param name="cancellationToken">取消令牌。</param> /// <param name="cancellationToken">取消令牌。</param>
/// <returns>是否初始化成功。</returns> /// <returns>初始化结果,包含生成的密码(若自动生成)。</returns>
public async Task<bool> BootstrapAdminAsync(string userName, string password, string? clientIp, string? userAgent, CancellationToken cancellationToken) public async Task<BootstrapAdminResponse?> BootstrapAdminAsync(string userName, string? password, string? clientIp, string? userAgent, CancellationToken cancellationToken)
{ {
var normalizedUserName = (userName ?? string.Empty).Trim(); var normalizedUserName = (userName ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalizedUserName) || string.IsNullOrWhiteSpace(password)) if (string.IsNullOrWhiteSpace(normalizedUserName))
{ {
return false; return null;
} }
var hasAnyCloudUser = await _dbContext.Users var hasAnyCloudUser = await _dbContext.Users
@@ -82,7 +111,7 @@ public class CloudAuthService
if (hasAnyCloudUser) if (hasAnyCloudUser)
{ {
await LogAsync("BootstrapFailed", "System already initialized.", userName: normalizedUserName, isSuccess: false, clientIp: clientIp, userAgent: userAgent); await LogAsync("BootstrapFailed", "System already initialized.", userName: normalizedUserName, isSuccess: false, clientIp: clientIp, userAgent: userAgent);
return false; return null;
} }
var exists = await _dbContext.Users var exists = await _dbContext.Users
@@ -91,9 +120,10 @@ public class CloudAuthService
if (exists) if (exists)
{ {
return false; return null;
} }
var generatedPassword = password ?? GenerateRandomPassword();
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var user = new UserEntity var user = new UserEntity
{ {
@@ -101,17 +131,22 @@ public class CloudAuthService
UserName = normalizedUserName, UserName = normalizedUserName,
Role = "admin", Role = "admin",
CreatedAtUtc = now, CreatedAtUtc = now,
UpdatedAtUtc = now UpdatedAtUtc = now,
MustChangePassword = true
}; };
user.PasswordHash = _passwordHasher.HashPassword(user, password); user.PasswordHash = _passwordHasher.HashPassword(user, generatedPassword);
_dbContext.Users.Add(user); _dbContext.Users.Add(user);
_dbContext.SecurityPolicies.Add(new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = user.Id, AllowPersist = true }); _dbContext.SecurityPolicies.Add(new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = user.Id, AllowPersist = true });
await _dbContext.SaveChangesAsync(cancellationToken); await _dbContext.SaveChangesAsync(cancellationToken);
await LogAsync("BootstrapSuccess", "System administrator created.", user.Id, user.UserName, clientIp: clientIp, userAgent: userAgent); await LogAsync("BootstrapSuccess", "System administrator created.", user.Id, user.UserName, clientIp: clientIp, userAgent: userAgent);
return true; return new BootstrapAdminResponse
{
UserName = normalizedUserName,
GeneratedPassword = generatedPassword
};
} }
/// <summary> /// <summary>
@@ -176,7 +211,8 @@ public class CloudAuthService
ExpiresAtUtc = expiresAt, ExpiresAtUtc = expiresAt,
UserId = user.Id, UserId = user.Id,
Role = user.Role, Role = user.Role,
Permissions = _rolePermissionMapper.GetPermissions(user.Role).ToList() Permissions = _rolePermissionMapper.GetPermissions(user.Role).ToList(),
MustChangePassword = user.MustChangePassword
}; };
} }
@@ -229,5 +265,57 @@ public class CloudAuthService
return stepUpExpiresAt; return stepUpExpiresAt;
} }
/// <summary>
/// 修改用户密码。
/// </summary>
/// <param name="sessionId">会话 ID。</param>
/// <param name="currentPassword">当前密码。</param>
/// <param name="newPassword">新密码。</param>
/// <param name="clientIp">客户端 IP。</param>
/// <param name="userAgent">User-Agent。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>是否修改成功。</returns>
public async Task<bool> ChangePasswordAsync(Guid sessionId, string currentPassword, string newPassword, string? clientIp, string? userAgent, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(currentPassword) || string.IsNullOrWhiteSpace(newPassword))
{
return false;
}
if (newPassword.Length < 8)
{
return false;
}
var now = DateTime.UtcNow;
var session = await _dbContext.UserSessions.FirstOrDefaultAsync(s => s.Id == sessionId, cancellationToken);
if (session == null || session.ExpiresAtUtc <= now)
{
return false;
}
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Id == session.UserId, cancellationToken);
if (user == null)
{
return false;
}
var verify = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, currentPassword);
if (verify == PasswordVerificationResult.Failed)
{
await LogAsync("ChangePasswordFailed", "Invalid current password.", user.Id, user.UserName, isSuccess: false, clientIp: clientIp, userAgent: userAgent);
return false;
}
user.PasswordHash = _passwordHasher.HashPassword(user, newPassword);
user.MustChangePassword = false;
user.UpdatedAtUtc = DateTime.UtcNow;
await _dbContext.SaveChangesAsync(cancellationToken);
await LogAsync("ChangePasswordSuccess", "Password changed successfully.", user.Id, user.UserName, clientIp: clientIp, userAgent: userAgent);
return true;
}
} }
@@ -21,6 +21,10 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.5" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.5" />
</ItemGroup> </ItemGroup>
@@ -0,0 +1,273 @@
// <auto-generated />
using System;
using Hua.Todo.Application.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Hua.Todo.Application.Migrations
{
[DbContext(typeof(TodoDbContext))]
[Migration("20260424164713_AddPasswordSaltToUsers")]
partial class AddPasswordSaltToUsers
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
modelBuilder.Entity("Hua.Todo.Core.Entities.AuditLogEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("ClientIp")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<bool>("IsSuccess")
.HasColumnType("INTEGER");
b.Property<string>("TimestampUtc")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("UserAgent")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<Guid?>("UserId")
.HasColumnType("TEXT");
b.Property<string>("UserName")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TimestampUtc");
b.HasIndex("UserId");
b.ToTable("AuditLogs", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("AllowPersist")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<bool>("AllowSync")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<bool>("IsTrustedDeviceOnly")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<int>("SecondFactorExpiryMinutes")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30);
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId")
.IsUnique();
b.ToTable("SecurityPolicies", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CreatedAt")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
b.Property<bool>("IsCompleted")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<int?>("ParentTaskId")
.HasColumnType("INTEGER");
b.Property<int>("Priority")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1);
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("UpdatedAt")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
b.Property<Guid>("UserId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000001"));
b.HasKey("Id");
b.HasIndex("ParentTaskId");
b.HasIndex("UserId");
b.ToTable("Tasks", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PasswordSalt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("UserName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserName")
.IsUnique();
b.ToTable("Users", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("CreatedAtUtc")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ExpiresAtUtc")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("StepUpExpiresAtUtc")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UserSessions", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b =>
{
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
{
b.HasOne("Hua.Todo.Core.Entities.TaskEntity", "ParentTask")
.WithMany("SubTasks")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
.WithMany("Tasks")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ParentTask");
b.Navigation("User");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b =>
{
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
{
b.Navigation("SubTasks");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b =>
{
b.Navigation("Tasks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hua.Todo.Application.Migrations
{
/// <inheritdoc />
public partial class AddPasswordSaltToUsers : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "PasswordSalt",
table: "Users",
type: "TEXT",
nullable: false,
defaultValue: "");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PasswordSalt",
table: "Users");
}
}
}
@@ -0,0 +1,276 @@
// <auto-generated />
using System;
using Hua.Todo.Application.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Hua.Todo.Application.Migrations
{
[DbContext(typeof(TodoDbContext))]
[Migration("20260510171230_AddMustChangePasswordToUsers")]
partial class AddMustChangePasswordToUsers
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
modelBuilder.Entity("Hua.Todo.Core.Entities.AuditLogEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("ClientIp")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<bool>("IsSuccess")
.HasColumnType("INTEGER");
b.Property<string>("TimestampUtc")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("UserAgent")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<Guid?>("UserId")
.HasColumnType("TEXT");
b.Property<string>("UserName")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TimestampUtc");
b.HasIndex("UserId");
b.ToTable("AuditLogs", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("AllowPersist")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<bool>("AllowSync")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<bool>("IsTrustedDeviceOnly")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<int>("SecondFactorExpiryMinutes")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30);
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId")
.IsUnique();
b.ToTable("SecurityPolicies", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CreatedAt")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
b.Property<bool>("IsCompleted")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<int?>("ParentTaskId")
.HasColumnType("INTEGER");
b.Property<int>("Priority")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1);
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("UpdatedAt")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
b.Property<Guid>("UserId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000001"));
b.HasKey("Id");
b.HasIndex("ParentTaskId");
b.HasIndex("UserId");
b.ToTable("Tasks", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<bool>("MustChangePassword")
.HasColumnType("INTEGER");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PasswordSalt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("UserName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserName")
.IsUnique();
b.ToTable("Users", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("CreatedAtUtc")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ExpiresAtUtc")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("StepUpExpiresAtUtc")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UserSessions", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b =>
{
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
{
b.HasOne("Hua.Todo.Core.Entities.TaskEntity", "ParentTask")
.WithMany("SubTasks")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
.WithMany("Tasks")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ParentTask");
b.Navigation("User");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b =>
{
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
{
b.Navigation("SubTasks");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b =>
{
b.Navigation("Tasks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hua.Todo.Application.Migrations
{
/// <inheritdoc />
public partial class AddMustChangePasswordToUsers : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "MustChangePassword",
table: "Users",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "MustChangePassword",
table: "Users");
}
}
}
@@ -159,10 +159,17 @@ namespace Hua.Todo.Application.Migrations
b.Property<DateTime>("CreatedAtUtc") b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<bool>("MustChangePassword")
.HasColumnType("INTEGER");
b.Property<string>("PasswordHash") b.Property<string>("PasswordHash")
.IsRequired() .IsRequired()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("PasswordSalt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Role") b.Property<string>("Role")
.IsRequired() .IsRequired()
.HasMaxLength(32) .HasMaxLength(32)
+10
View File
@@ -20,6 +20,11 @@ public class UserEntity
/// </summary> /// </summary>
public string PasswordHash { get; set; } = string.Empty; public string PasswordHash { get; set; } = string.Empty;
/// <summary>
/// 密码盐值(用于增强安全性,防止撞库)。
/// </summary>
public string PasswordSalt { get; set; } = string.Empty;
/// <summary> /// <summary>
/// 用户角色(用于 RBAC 权限映射)。 /// 用户角色(用于 RBAC 权限映射)。
/// </summary> /// </summary>
@@ -39,5 +44,10 @@ public class UserEntity
/// 用户任务集合。 /// 用户任务集合。
/// </summary> /// </summary>
public List<TaskEntity> Tasks { get; set; } = new(); public List<TaskEntity> Tasks { get; set; } = new();
/// <summary>
/// 是否必须修改密码(首次登录或管理员重置后为 true)。
/// </summary>
public bool MustChangePassword { get; set; } = false;
} }
+45 -6
View File
@@ -24,7 +24,8 @@ builder.Services.AddCors(options =>
{ {
policy.AllowAnyOrigin() policy.AllowAnyOrigin()
.AllowAnyMethod() .AllowAnyMethod()
.AllowAnyHeader(); .AllowAnyHeader()
.SetPreflightMaxAge(TimeSpan.FromMinutes(30));
}); });
}); });
@@ -37,16 +38,54 @@ using (var scope = app.Services.CreateScope())
var dbContext = services.GetRequiredService<Hua.Todo.Application.Data.TodoDbContext>(); var dbContext = services.GetRequiredService<Hua.Todo.Application.Data.TodoDbContext>();
dbContext.Database.Migrate(); dbContext.Database.Migrate();
// Seed default admin from configuration
var config = services.GetRequiredService<IConfiguration>(); var config = services.GetRequiredService<IConfiguration>();
var authService = services.GetRequiredService<CloudAuthService>();
var adminConfig = config.GetSection("DefaultAdmin"); var adminConfig = config.GetSection("DefaultAdmin");
var adminUserName = adminConfig["UserName"]; var adminUserName = adminConfig["UserName"];
var adminPassword = adminConfig["Password"]; var adminPassword = adminConfig["Password"];
if (!string.IsNullOrWhiteSpace(adminUserName) && !string.IsNullOrWhiteSpace(adminPassword)) if (!string.IsNullOrWhiteSpace(adminUserName))
{ {
var authService = services.GetRequiredService<CloudAuthService>(); var result = await authService.BootstrapAdminAsync(adminUserName, adminPassword, "system", "seeding", CancellationToken.None);
await authService.BootstrapAdminAsync(adminUserName, adminPassword, "system", "seeding", CancellationToken.None); if (result != null)
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("========================================");
Console.WriteLine("首次启动:已创建默认管理员账号");
Console.WriteLine($"用户名: {result.UserName}");
if (!string.IsNullOrEmpty(adminPassword))
{
Console.WriteLine($"密码: {adminPassword}(已在配置中设置)");
}
else
{
Console.WriteLine($"临时密码: {result.GeneratedPassword}");
Console.WriteLine("请立即登录并修改密码!");
}
Console.WriteLine("========================================");
Console.ResetColor();
}
}
else
{
var hasAnyCloudUser = await dbContext.Users.AsNoTracking().AnyAsync(u => u.Role != "local");
if (!hasAnyCloudUser)
{
var defaultUserName = "admin";
var result = await authService.BootstrapAdminAsync(defaultUserName, null, "system", "auto-seeding", CancellationToken.None);
if (result != null)
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("========================================");
Console.WriteLine("首次启动:已自动创建管理员账号");
Console.WriteLine($"用户名: {result.UserName}");
Console.WriteLine($"临时密码: {result.GeneratedPassword}");
Console.WriteLine("请立即登录并修改密码!");
Console.WriteLine("========================================");
Console.ResetColor();
}
}
} }
} }
@@ -56,9 +95,9 @@ if (app.Environment.IsDevelopment())
app.UseSwaggerUI(); app.UseSwaggerUI();
} }
app.UseCors("AllowAll");
app.UseHttpsRedirection(); app.UseHttpsRedirection();
app.UseStaticFiles(); app.UseStaticFiles();
app.UseCors("AllowAll");
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
+1 -1
View File
@@ -8,6 +8,6 @@
"AllowedHosts": "*", "AllowedHosts": "*",
"DefaultAdmin": { "DefaultAdmin": {
"UserName": "admin", "UserName": "admin",
"Password": "ChangeMe@123" "Password": "123456"
} }
} }