diff --git a/src/Hua.Todo.Application/CloudSync/CloudSyncEndpointExtensions.cs b/src/Hua.Todo.Application/CloudSync/CloudSyncEndpointExtensions.cs index 5328964..c6ddff4 100644 --- a/src/Hua.Todo.Application/CloudSync/CloudSyncEndpointExtensions.cs +++ b/src/Hua.Todo.Application/CloudSync/CloudSyncEndpointExtensions.cs @@ -22,6 +22,7 @@ public static class CloudSyncEndpointExtensions auth.MapPost("/bootstrap", BootstrapAdminAsync).AllowAnonymous(); auth.MapPost("/login", LoginAsync).AllowAnonymous(); auth.MapPost("/step-up", StepUpAsync).RequireAuthorization(); + auth.MapPost("/change-password", ChangePasswordAsync).RequireAuthorization(); var tasks = app.MapGroup("/tasks").WithTags("CloudSync - Tasks"); tasks.MapGet("/", GetTasksAsync).RequireAuthorization("tasks:read"); @@ -58,19 +59,19 @@ public static class CloudSyncEndpointExtensions HttpContext httpContext, 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 ok = await authService.BootstrapAdminAsync(request.UserName, request.Password, ip, ua, cancellationToken); - if (!ok) + var result = await authService.BootstrapAdminAsync(request.UserName, request.Password, ip, ua, cancellationToken); + if (result == null) { return CloudApiErrors.Forbidden("Bootstrap is not allowed (already initialized or invalid input)."); } - return Results.Ok(); + return Results.Json(result); } private static async Task LoginAsync( @@ -121,6 +122,38 @@ public static class CloudSyncEndpointExtensions return Results.Json(new StepUpResponse { StepUpExpiresAtUtc = expiresAt.Value }); } + private static async Task 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 GetTasksAsync( CloudTaskSyncService taskService, HttpContext httpContext, diff --git a/src/Hua.Todo.Application/CloudSync/Models/AuthDtos.cs b/src/Hua.Todo.Application/CloudSync/Models/AuthDtos.cs index 182be80..a0e178d 100644 --- a/src/Hua.Todo.Application/CloudSync/Models/AuthDtos.cs +++ b/src/Hua.Todo.Application/CloudSync/Models/AuthDtos.cs @@ -45,6 +45,11 @@ public class LoginResponse /// 用户权限列表。 /// public List Permissions { get; set; } = new(); + + /// + /// 是否必须修改密码。 + /// + public bool MustChangePassword { get; set; } } /// @@ -58,9 +63,41 @@ public class BootstrapAdminRequest public string UserName { get; set; } = string.Empty; /// - /// 密码。 + /// 密码(可选,若不提供则自动生成随机密码)。 /// - public string Password { get; set; } = string.Empty; + public string? Password { get; set; } +} + +/// +/// 初始化管理员账号响应。 +/// +public class BootstrapAdminResponse +{ + /// + /// 用户名。 + /// + public string UserName { get; set; } = string.Empty; + + /// + /// 生成的临时密码(仅在首次创建时返回)。 + /// + public string GeneratedPassword { get; set; } = string.Empty; +} + +/// +/// 修改密码请求。 +/// +public class ChangePasswordRequest +{ + /// + /// 当前密码。 + /// + public string CurrentPassword { get; set; } = string.Empty; + + /// + /// 新密码。 + /// + public string NewPassword { get; set; } = string.Empty; } /// diff --git a/src/Hua.Todo.Application/CloudSync/Services/CloudAuthService.cs b/src/Hua.Todo.Application/CloudSync/Services/CloudAuthService.cs index ddb0d49..b02c60d 100644 --- a/src/Hua.Todo.Application/CloudSync/Services/CloudAuthService.cs +++ b/src/Hua.Todo.Application/CloudSync/Services/CloudAuthService.cs @@ -15,6 +15,7 @@ public class CloudAuthService private readonly TodoDbContext _dbContext; private readonly IPasswordHasher _passwordHasher; private readonly IRolePermissionMapper _rolePermissionMapper; + private static readonly Random _random = new(); /// /// 创建 。 @@ -32,6 +33,34 @@ public class CloudAuthService _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( string eventType, string description, @@ -62,17 +91,17 @@ public class CloudAuthService /// 初始化系统管理员账号(仅在系统尚无云用户时可用)。 /// /// 用户名。 - /// 密码。 + /// 密码(可选,若不提供则自动生成)。 /// 客户端 IP。 /// User-Agent。 /// 取消令牌。 - /// 是否初始化成功。 - public async Task BootstrapAdminAsync(string userName, string password, string? clientIp, string? userAgent, CancellationToken cancellationToken) + /// 初始化结果,包含生成的密码(若自动生成)。 + public async Task BootstrapAdminAsync(string userName, string? password, string? clientIp, string? userAgent, CancellationToken cancellationToken) { 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 @@ -82,7 +111,7 @@ public class CloudAuthService if (hasAnyCloudUser) { await LogAsync("BootstrapFailed", "System already initialized.", userName: normalizedUserName, isSuccess: false, clientIp: clientIp, userAgent: userAgent); - return false; + return null; } var exists = await _dbContext.Users @@ -91,9 +120,10 @@ public class CloudAuthService if (exists) { - return false; + return null; } + var generatedPassword = password ?? GenerateRandomPassword(); var now = DateTime.UtcNow; var user = new UserEntity { @@ -101,17 +131,22 @@ public class CloudAuthService UserName = normalizedUserName, Role = "admin", 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.SecurityPolicies.Add(new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = user.Id, AllowPersist = true }); await _dbContext.SaveChangesAsync(cancellationToken); await LogAsync("BootstrapSuccess", "System administrator created.", user.Id, user.UserName, clientIp: clientIp, userAgent: userAgent); - return true; + return new BootstrapAdminResponse + { + UserName = normalizedUserName, + GeneratedPassword = generatedPassword + }; } /// @@ -176,7 +211,8 @@ public class CloudAuthService ExpiresAtUtc = expiresAt, UserId = user.Id, 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; } + + /// + /// 修改用户密码。 + /// + /// 会话 ID。 + /// 当前密码。 + /// 新密码。 + /// 客户端 IP。 + /// User-Agent。 + /// 取消令牌。 + /// 是否修改成功。 + public async Task 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; + } } diff --git a/src/Hua.Todo.Application/Hua.Todo.Application.csproj b/src/Hua.Todo.Application/Hua.Todo.Application.csproj index 7d78d5a..d64e18b 100644 --- a/src/Hua.Todo.Application/Hua.Todo.Application.csproj +++ b/src/Hua.Todo.Application/Hua.Todo.Application.csproj @@ -21,6 +21,10 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + diff --git a/src/Hua.Todo.Application/Migrations/20260424164713_AddPasswordSaltToUsers.Designer.cs b/src/Hua.Todo.Application/Migrations/20260424164713_AddPasswordSaltToUsers.Designer.cs new file mode 100644 index 0000000..a825b6a --- /dev/null +++ b/src/Hua.Todo.Application/Migrations/20260424164713_AddPasswordSaltToUsers.Designer.cs @@ -0,0 +1,273 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClientIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsSuccess") + .HasColumnType("INTEGER"); + + b.Property("TimestampUtc") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AllowPersist") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AllowSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IsTrustedDeviceOnly") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("SecondFactorExpiryMinutes") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(30); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValueSql("datetime('now')"); + + b.Property("IsCompleted") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("ParentTaskId") + .HasColumnType("INTEGER"); + + b.Property("Priority") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValueSql("datetime('now')"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PasswordSalt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ExpiresAtUtc") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StepUpExpiresAtUtc") + .HasColumnType("TEXT"); + + b.Property("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 + } + } +} diff --git a/src/Hua.Todo.Application/Migrations/20260424164713_AddPasswordSaltToUsers.cs b/src/Hua.Todo.Application/Migrations/20260424164713_AddPasswordSaltToUsers.cs new file mode 100644 index 0000000..be004a5 --- /dev/null +++ b/src/Hua.Todo.Application/Migrations/20260424164713_AddPasswordSaltToUsers.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Hua.Todo.Application.Migrations +{ + /// + public partial class AddPasswordSaltToUsers : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PasswordSalt", + table: "Users", + type: "TEXT", + nullable: false, + defaultValue: ""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "PasswordSalt", + table: "Users"); + } + } +} diff --git a/src/Hua.Todo.Application/Migrations/20260510171230_AddMustChangePasswordToUsers.Designer.cs b/src/Hua.Todo.Application/Migrations/20260510171230_AddMustChangePasswordToUsers.Designer.cs new file mode 100644 index 0000000..7c70e20 --- /dev/null +++ b/src/Hua.Todo.Application/Migrations/20260510171230_AddMustChangePasswordToUsers.Designer.cs @@ -0,0 +1,276 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClientIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsSuccess") + .HasColumnType("INTEGER"); + + b.Property("TimestampUtc") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AllowPersist") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AllowSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IsTrustedDeviceOnly") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("SecondFactorExpiryMinutes") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(30); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValueSql("datetime('now')"); + + b.Property("IsCompleted") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("ParentTaskId") + .HasColumnType("INTEGER"); + + b.Property("Priority") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValueSql("datetime('now')"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("MustChangePassword") + .HasColumnType("INTEGER"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PasswordSalt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ExpiresAtUtc") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StepUpExpiresAtUtc") + .HasColumnType("TEXT"); + + b.Property("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 + } + } +} diff --git a/src/Hua.Todo.Application/Migrations/20260510171230_AddMustChangePasswordToUsers.cs b/src/Hua.Todo.Application/Migrations/20260510171230_AddMustChangePasswordToUsers.cs new file mode 100644 index 0000000..6415488 --- /dev/null +++ b/src/Hua.Todo.Application/Migrations/20260510171230_AddMustChangePasswordToUsers.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Hua.Todo.Application.Migrations +{ + /// + public partial class AddMustChangePasswordToUsers : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "MustChangePassword", + table: "Users", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "MustChangePassword", + table: "Users"); + } + } +} diff --git a/src/Hua.Todo.Application/Migrations/TodoDbContextModelSnapshot.cs b/src/Hua.Todo.Application/Migrations/TodoDbContextModelSnapshot.cs index 9d3ec9c..3e9f3f0 100644 --- a/src/Hua.Todo.Application/Migrations/TodoDbContextModelSnapshot.cs +++ b/src/Hua.Todo.Application/Migrations/TodoDbContextModelSnapshot.cs @@ -159,10 +159,17 @@ namespace Hua.Todo.Application.Migrations b.Property("CreatedAtUtc") .HasColumnType("TEXT"); + b.Property("MustChangePassword") + .HasColumnType("INTEGER"); + b.Property("PasswordHash") .IsRequired() .HasColumnType("TEXT"); + b.Property("PasswordSalt") + .IsRequired() + .HasColumnType("TEXT"); + b.Property("Role") .IsRequired() .HasMaxLength(32) diff --git a/src/Hua.Todo.Core/Entities/UserEntity.cs b/src/Hua.Todo.Core/Entities/UserEntity.cs index 841e456..21ec770 100644 --- a/src/Hua.Todo.Core/Entities/UserEntity.cs +++ b/src/Hua.Todo.Core/Entities/UserEntity.cs @@ -20,6 +20,11 @@ public class UserEntity /// public string PasswordHash { get; set; } = string.Empty; + /// + /// 密码盐值(用于增强安全性,防止撞库)。 + /// + public string PasswordSalt { get; set; } = string.Empty; + /// /// 用户角色(用于 RBAC 权限映射)。 /// @@ -39,5 +44,10 @@ public class UserEntity /// 用户任务集合。 /// public List Tasks { get; set; } = new(); + + /// + /// 是否必须修改密码(首次登录或管理员重置后为 true)。 + /// + public bool MustChangePassword { get; set; } = false; } diff --git a/src/Hua.Todo.Host/Program.cs b/src/Hua.Todo.Host/Program.cs index a5305a5..6967fef 100644 --- a/src/Hua.Todo.Host/Program.cs +++ b/src/Hua.Todo.Host/Program.cs @@ -24,7 +24,8 @@ builder.Services.AddCors(options => { policy.AllowAnyOrigin() .AllowAnyMethod() - .AllowAnyHeader(); + .AllowAnyHeader() + .SetPreflightMaxAge(TimeSpan.FromMinutes(30)); }); }); @@ -37,16 +38,54 @@ using (var scope = app.Services.CreateScope()) var dbContext = services.GetRequiredService(); dbContext.Database.Migrate(); - // Seed default admin from configuration var config = services.GetRequiredService(); + var authService = services.GetRequiredService(); + var adminConfig = config.GetSection("DefaultAdmin"); var adminUserName = adminConfig["UserName"]; var adminPassword = adminConfig["Password"]; - if (!string.IsNullOrWhiteSpace(adminUserName) && !string.IsNullOrWhiteSpace(adminPassword)) + if (!string.IsNullOrWhiteSpace(adminUserName)) { - var authService = services.GetRequiredService(); - await authService.BootstrapAdminAsync(adminUserName, adminPassword, "system", "seeding", CancellationToken.None); + var result = 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.UseCors("AllowAll"); app.UseHttpsRedirection(); app.UseStaticFiles(); -app.UseCors("AllowAll"); app.UseAuthentication(); app.UseAuthorization(); diff --git a/src/Hua.Todo.Host/appsettings.json b/src/Hua.Todo.Host/appsettings.json index 26b0679..a00d33f 100644 --- a/src/Hua.Todo.Host/appsettings.json +++ b/src/Hua.Todo.Host/appsettings.json @@ -8,6 +8,6 @@ "AllowedHosts": "*", "DefaultAdmin": { "UserName": "admin", - "Password": "ChangeMe@123" + "Password": "123456" } }