From ea11e6969bec2ae3fa4df6048b4c491adbbc28e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Kub=C3=AD=C4=8Dek?= Date: Fri, 1 May 2026 18:07:27 +0200 Subject: [PATCH] Changed Auth to use Identity, with easy to set passsword requirenments via appsettings.json --- .../Admin/Controllers/UsersController.cs | 29 +- .../Areas/Admin/Views/Users/Details.cshtml | 19 +- .../Areas/Admin/Views/Users/Index.cshtml | 21 +- .../Areas/Admin/Views/_ViewImports.cshtml | 1 - .../TemplateWeb/Controllers/AuthController.cs | 240 +++++++++------ Template/TemplateWeb/Data/AppDbContext.cs | 9 +- Template/TemplateWeb/Data/DataSeeder.cs | 34 ++- Template/TemplateWeb/Entities/UserEntity.cs | 7 + Template/TemplateWeb/Entities/UserModel.cs | 28 -- .../TemplateWeb/Interfaces/IAuthService.cs | 15 - .../20260418160408_InitMigrations.Designer.cs | 58 ---- .../20260418160408_InitMigrations.cs | 38 --- ...0260501160159_SwitchToIdentity.Designer.cs | 277 ++++++++++++++++++ .../20260501160159_SwitchToIdentity.cs | 223 ++++++++++++++ .../Migrations/AppDbContextModelSnapshot.cs | 245 +++++++++++++++- .../Models/UserWithRolesViewModel.cs | 9 + Template/TemplateWeb/Program.cs | 53 ++-- Template/TemplateWeb/Services/AuthService.cs | 183 ------------ Template/TemplateWeb/TemplateWeb.csproj | 2 +- .../ViewModels/ProfileViewModel.cs | 5 +- .../TemplateWeb/Views/Auth/Profile.cshtml | 6 + Template/TemplateWeb/appsettings.json | 10 + Template/WebTests/AuthServiceTests.cs | 104 +++---- Template/WebTests/WebTests.csproj | 2 + rename-template.sh | 50 ++++ 25 files changed, 1107 insertions(+), 561 deletions(-) create mode 100644 Template/TemplateWeb/Entities/UserEntity.cs delete mode 100644 Template/TemplateWeb/Entities/UserModel.cs delete mode 100644 Template/TemplateWeb/Interfaces/IAuthService.cs delete mode 100644 Template/TemplateWeb/Migrations/20260418160408_InitMigrations.Designer.cs delete mode 100644 Template/TemplateWeb/Migrations/20260418160408_InitMigrations.cs create mode 100644 Template/TemplateWeb/Migrations/20260501160159_SwitchToIdentity.Designer.cs create mode 100644 Template/TemplateWeb/Migrations/20260501160159_SwitchToIdentity.cs create mode 100644 Template/TemplateWeb/Models/UserWithRolesViewModel.cs delete mode 100644 Template/TemplateWeb/Services/AuthService.cs create mode 100644 rename-template.sh diff --git a/Template/TemplateWeb/Areas/Admin/Controllers/UsersController.cs b/Template/TemplateWeb/Areas/Admin/Controllers/UsersController.cs index ca2cb32..64999c4 100644 --- a/Template/TemplateWeb/Areas/Admin/Controllers/UsersController.cs +++ b/Template/TemplateWeb/Areas/Admin/Controllers/UsersController.cs @@ -1,28 +1,37 @@ +using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using TemplateWeb.Data; +using TemplateWeb.Entities; +using TemplateWeb.Models; namespace TemplateWeb.Areas.Admin.Controllers; public class UsersController : AdminBaseController { - private readonly AppDbContext _context; + private readonly UserManager _userManager; - public UsersController(AppDbContext context) + public UsersController(UserManager userManager) { - _context = context; + _userManager = userManager; } public async Task Index() { - var users = await _context.Users.ToListAsync(); - return View(users); + var users = _userManager.Users.ToList(); + var viewModels = new List(); + foreach (var user in users) + { + var roles = await _userManager.GetRolesAsync(user); + viewModels.Add(new UserWithRolesViewModel { UserEntity = user, Roles = roles }); + } + return View(viewModels); } - public async Task Details(int id) + public async Task Details(string id) { - var user = await _context.Users.FirstOrDefaultAsync(u => u.Id == id); + var user = await _userManager.FindByIdAsync(id); if (user == null) return NotFound(); - return View(user); + + var roles = await _userManager.GetRolesAsync(user); + return View(new UserWithRolesViewModel { UserEntity = user, Roles = roles }); } } diff --git a/Template/TemplateWeb/Areas/Admin/Views/Users/Details.cshtml b/Template/TemplateWeb/Areas/Admin/Views/Users/Details.cshtml index 7d36a3f..f246174 100644 --- a/Template/TemplateWeb/Areas/Admin/Views/Users/Details.cshtml +++ b/Template/TemplateWeb/Areas/Admin/Views/Users/Details.cshtml @@ -1,4 +1,4 @@ -@model UserModel +@model UserWithRolesViewModel @{ ViewData["Title"] = "User Details"; } @@ -9,22 +9,25 @@
-

User Information: @Model.UserName

+

User Information: @Model.UserEntity.UserName

-
ID
-
@Model.Id
+
ID
+
@Model.UserEntity.Id
Username
-
@Model.UserName
+
@Model.UserEntity.UserName
Email
-
@Model.Email
+
@Model.UserEntity.Email
-
Role
+
Roles
- @Model.Role + @foreach (var role in Model.Roles) + { + @role + }
diff --git a/Template/TemplateWeb/Areas/Admin/Views/Users/Index.cshtml b/Template/TemplateWeb/Areas/Admin/Views/Users/Index.cshtml index eb8ee81..50544af 100644 --- a/Template/TemplateWeb/Areas/Admin/Views/Users/Index.cshtml +++ b/Template/TemplateWeb/Areas/Admin/Views/Users/Index.cshtml @@ -1,4 +1,4 @@ -@model IEnumerable +@model IEnumerable @{ ViewData["Title"] = "User Management"; } @@ -13,20 +13,25 @@ ID Username Email - Role + Roles Actions - @foreach (var user in Model) + @foreach (var vm in Model) { - @user.Id - @user.UserName - @user.Email - @user.Role + @vm.UserEntity.Id + @vm.UserEntity.UserName + @vm.UserEntity.Email - Details + @foreach (var role in vm.Roles) + { + @role + } + + + Details } diff --git a/Template/TemplateWeb/Areas/Admin/Views/_ViewImports.cshtml b/Template/TemplateWeb/Areas/Admin/Views/_ViewImports.cshtml index e3a22d5..1d6fbe7 100644 --- a/Template/TemplateWeb/Areas/Admin/Views/_ViewImports.cshtml +++ b/Template/TemplateWeb/Areas/Admin/Views/_ViewImports.cshtml @@ -1,4 +1,3 @@ @using TemplateWeb @using TemplateWeb.Models -@using TemplateWeb.Models.DbModels @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/Template/TemplateWeb/Controllers/AuthController.cs b/Template/TemplateWeb/Controllers/AuthController.cs index 8b10f8b..692a0f3 100644 --- a/Template/TemplateWeb/Controllers/AuthController.cs +++ b/Template/TemplateWeb/Controllers/AuthController.cs @@ -1,6 +1,9 @@ +using System.Text; +using System.Text.Json; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; -using TemplateWeb.Interfaces; +using TemplateWeb.Entities; using TemplateWeb.Models; namespace TemplateWeb.Controllers; @@ -8,144 +11,189 @@ namespace TemplateWeb.Controllers; public class AuthController : Controller { private readonly ILogger _logger; - private readonly IAuthService _authService; - - public AuthController(ILogger logger, IAuthService authService) + private readonly UserManager _userManager; + private readonly SignInManager _signInManager; + + public AuthController( + ILogger logger, + UserManager userManager, + SignInManager signInManager) { _logger = logger; - _authService = authService; + _userManager = userManager; + _signInManager = signInManager; } - + #region HttpGet + [HttpGet] [AllowAnonymous] public IActionResult Login(string? returnUrl) { if (User.Identity?.IsAuthenticated == true) - { return RedirectToAction("Index", "Home"); - } + return View(new AuthLoginViewModel { ReturnUrl = returnUrl }); } - + [HttpGet] [AllowAnonymous] public IActionResult Register(string? returnUrl) { if (User.Identity?.IsAuthenticated == true) - { return RedirectToAction("Index", "Home"); - } + return View(new AuthRegisterViewModel { ReturnUrl = returnUrl }); } [HttpGet] - public IActionResult AccessDenied() - { - return View(); - } + public IActionResult AccessDenied() => View(); [HttpGet] [Authorize] public async Task Profile() { - var userResult = await _authService.FindUserByEmailOrUsernameAsync(User.Identity?.Name ?? ""); - if (!userResult.Success || userResult.Data == null) return RedirectToAction("Login"); + var user = await _userManager.GetUserAsync(User); + if (user == null) return RedirectToAction("Login"); - var model = new ProfileViewModel + return View(new ProfileViewModel { - UserName = userResult.Data.UserName, - Email = userResult.Data.Email - }; - return View(model); + UserName = user.UserName!, + Email = user.Email!, + }); } + #endregion - - #region httpPost - [HttpPost] [Authorize] [ValidateAntiForgeryToken] - public async Task Profile(ProfileViewModel model) + + #region HttpPost + + [HttpPost] + [AllowAnonymous] + [ValidateAntiForgeryToken] + public async Task Login(AuthLoginViewModel model) { if (!ModelState.IsValid) return View(model); - var userIdClaim = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); - if (userIdClaim == null || !int.TryParse(userIdClaim.Value, out int userId)) return RedirectToAction("Login"); + // Support login by email or username + var user = model.EmailOrUsername.Contains('@') + ? await _userManager.FindByEmailAsync(model.EmailOrUsername) + : await _userManager.FindByNameAsync(model.EmailOrUsername); - var result = await _authService.UpdateProfileAsync(userId, model); - if (!result.Success) - { - ModelState.AddModelError(string.Empty, result.Message); - return View(model); - } - - TempData["SuccessMessage"] = "Profile updated successfully. Please log in again if you changed your username."; - return RedirectToAction("Profile"); - } - - [HttpPost] [AllowAnonymous] [ValidateAntiForgeryToken] - public async Task Login(AuthLoginViewModel model) - { - if (!ModelState.IsValid) - { - return View(model); - } - - var userResult = await _authService.FindUserByEmailOrUsernameAsync(model.EmailOrUsername); - if (!userResult.Success || userResult.Data == null) - { - ModelState.AddModelError(string.Empty, userResult.Message); - return View(model); - } - - var user = userResult.Data; - if (!BCrypt.Net.BCrypt.Verify(model.Password, user.Password)) + if (user == null) { ModelState.AddModelError(string.Empty, "Invalid credentials."); return View(model); } - - var authResult = await _authService.AuthenticateAsync(user, model.RememberMe); - if (!authResult.Success) + + var result = await _signInManager.PasswordSignInAsync( + user, model.Password, model.RememberMe, lockoutOnFailure: false); + + if (!result.Succeeded) { - ModelState.AddModelError(string.Empty, authResult.Message); + ModelState.AddModelError(string.Empty, "Invalid credentials."); return View(model); } if (!string.IsNullOrEmpty(model.ReturnUrl) && Url.IsLocalUrl(model.ReturnUrl)) - { return Redirect(model.ReturnUrl); - } - + return RedirectToAction("Index", "Home"); } - [HttpPost] [AllowAnonymous] [ValidateAntiForgeryToken] + [HttpPost] + [AllowAnonymous] + [ValidateAntiForgeryToken] public async Task Register(AuthRegisterViewModel model) { - if (!ModelState.IsValid) + if (!ModelState.IsValid) return View(model); + + var user = new UserEntity { - return View(model); - } - - var result = await _authService.RegisterAsync(model); - if (!result.Success) + UserName = model.UserName.Trim(), + Email = model.Email.Trim().ToLower(), + }; + + var result = await _userManager.CreateAsync(user, model.Password); + if (!result.Succeeded) { - ModelState.AddModelError(string.Empty, result.Message); + foreach (var error in result.Errors) + ModelState.AddModelError(string.Empty, error.Description); return View(model); } + await _userManager.AddToRoleAsync(user, "User"); + _logger.LogInformation("User {UserName} registered.", user.UserName); + if (!string.IsNullOrEmpty(model.ReturnUrl) && Url.IsLocalUrl(model.ReturnUrl)) - { return Redirect(model.ReturnUrl); - } - + return RedirectToAction("Login"); } - + + [HttpPost] + [Authorize] + [ValidateAntiForgeryToken] + public async Task Profile(ProfileViewModel model) + { + if (!ModelState.IsValid) return View(model); + + var user = await _userManager.GetUserAsync(User); + if (user == null) return RedirectToAction("Login"); + + // Update username + if (user.UserName != model.UserName.Trim()) + { + var setName = await _userManager.SetUserNameAsync(user, model.UserName.Trim()); + if (!setName.Succeeded) + { + foreach (var e in setName.Errors) + ModelState.AddModelError(string.Empty, e.Description); + return View(model); + } + } + + // Update email + if (user.Email != model.Email.Trim().ToLower()) + { + var setEmail = await _userManager.SetEmailAsync(user, model.Email.Trim().ToLower()); + if (!setEmail.Succeeded) + { + foreach (var e in setEmail.Errors) + ModelState.AddModelError(string.Empty, e.Description); + return View(model); + } + } + + // Change password (requires current password) + if (!string.IsNullOrWhiteSpace(model.NewPassword)) + { + if (string.IsNullOrWhiteSpace(model.CurrentPassword)) + { + ModelState.AddModelError(nameof(model.CurrentPassword), "Current password is required to set a new password."); + return View(model); + } + + var changePassword = await _userManager.ChangePasswordAsync(user, model.CurrentPassword, model.NewPassword); + if (!changePassword.Succeeded) + { + foreach (var e in changePassword.Errors) + ModelState.AddModelError(string.Empty, e.Description); + return View(model); + } + } + + // Refresh cookie so the user stays logged in after security-stamp changes + await _signInManager.RefreshSignInAsync(user); + + TempData["SuccessMessage"] = "Profile updated successfully."; + return RedirectToAction("Profile"); + } + [HttpPost] [ValidateAntiForgeryToken] public async Task Logout() { - await _authService.LogoutAsync(); + await _signInManager.SignOutAsync(); return RedirectToAction("Index", "Home"); } @@ -154,19 +202,12 @@ public class AuthController : Controller [ValidateAntiForgeryToken] public async Task DeleteAccount() { - var userIdClaim = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); - if (userIdClaim == null) return RedirectToAction("Login"); + var user = await _userManager.GetUserAsync(User); + if (user == null) return RedirectToAction("Login"); - if (!int.TryParse(userIdClaim.Value, out var userId)) - { - return RedirectToAction("Index", "Home"); - } - - var userResult = await _authService.FindUserByEmailOrUsernameAsync(User.Identity?.Name ?? ""); - if (userResult is not { Success: true, Data: not null }) return RedirectToAction("Register"); - - await _authService.DeleteUserAsync(userResult.Data); - await _authService.LogoutAsync(); + await _signInManager.SignOutAsync(); + await _userManager.DeleteAsync(user); + _logger.LogInformation("User {UserId} deleted their account.", user.Id); return RedirectToAction("Register"); } @@ -175,16 +216,25 @@ public class AuthController : Controller [Authorize] public async Task DownloadData() { - var userIdClaim = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); - if (userIdClaim == null || !int.TryParse(userIdClaim.Value, out int userId)) return RedirectToAction("Login"); + var user = await _userManager.GetUserAsync(User); + if (user == null) return RedirectToAction("Login"); - var result = await _authService.GetUserDataAsync(userId); - if (!result.Success || result.Data == null) return BadRequest(result.Message); + var roles = await _userManager.GetRolesAsync(user); - var json = System.Text.Json.JsonSerializer.Serialize(result.Data, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); - var bytes = System.Text.Encoding.UTF8.GetBytes(json); + var data = new + { + user.Id, + user.UserName, + user.Email, + Roles = roles, + ExportDate = DateTime.UtcNow, + }; - return File(bytes, "application/json", $"user_data_{User.Identity?.Name}.json"); + var json = JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true }); + var bytes = Encoding.UTF8.GetBytes(json); + + return File(bytes, "application/json", $"user_data_{user.UserName}.json"); } + #endregion } diff --git a/Template/TemplateWeb/Data/AppDbContext.cs b/Template/TemplateWeb/Data/AppDbContext.cs index ecb2ac3..a3160b3 100644 --- a/Template/TemplateWeb/Data/AppDbContext.cs +++ b/Template/TemplateWeb/Data/AppDbContext.cs @@ -1,14 +1,13 @@ -using Microsoft.EntityFrameworkCore; -using TemplateWeb.Models.DbModels; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using TemplateWeb.Entities; namespace TemplateWeb.Data; -public class AppDbContext : DbContext +public class AppDbContext : IdentityDbContext { public AppDbContext(DbContextOptions options) : base(options) { } - - public DbSet Users { get; set; } } diff --git a/Template/TemplateWeb/Data/DataSeeder.cs b/Template/TemplateWeb/Data/DataSeeder.cs index 3d830cb..7a47dd0 100644 --- a/Template/TemplateWeb/Data/DataSeeder.cs +++ b/Template/TemplateWeb/Data/DataSeeder.cs @@ -1,27 +1,33 @@ -using Microsoft.EntityFrameworkCore; -using TemplateWeb.Models.DbModels; +using Microsoft.AspNetCore.Identity; +using TemplateWeb.Entities; namespace TemplateWeb.Data; public static class DataSeeder { - public static async Task SeedAdminUser(AppDbContext context) + public static async Task SeedAsync(UserManager userManager, RoleManager roleManager) { - // Only run if no users exist - if (await context.Users.AnyAsync()) + // Ensure roles exist + foreach (var role in new[] { "Admin", "User" }) { - return; + if (!await roleManager.RoleExistsAsync(role)) + await roleManager.CreateAsync(new IdentityRole(role)); } - var admin = new UserModel + // Seed initial admin user if none exists + if (!userManager.Users.Any()) { - UserName = "admin", - Email = "admin@template.com", - Password = BCrypt.Net.BCrypt.HashPassword("Admin123!"), // You should change this on first login - Role = UserRole.Admin - }; + var admin = new UserEntity + { + UserName = "admin", + Email = "admin@template.com", + EmailConfirmed = true, + }; - await context.Users.AddAsync(admin); - await context.SaveChangesAsync(); + // You should change this password on first login + var result = await userManager.CreateAsync(admin, "Admin123!"); + if (result.Succeeded) + await userManager.AddToRoleAsync(admin, "Admin"); + } } } diff --git a/Template/TemplateWeb/Entities/UserEntity.cs b/Template/TemplateWeb/Entities/UserEntity.cs new file mode 100644 index 0000000..415b68a --- /dev/null +++ b/Template/TemplateWeb/Entities/UserEntity.cs @@ -0,0 +1,7 @@ +using Microsoft.AspNetCore.Identity; + +namespace TemplateWeb.Entities; + +public class UserEntity : IdentityUser +{ +} diff --git a/Template/TemplateWeb/Entities/UserModel.cs b/Template/TemplateWeb/Entities/UserModel.cs deleted file mode 100644 index b1c8ea2..0000000 --- a/Template/TemplateWeb/Entities/UserModel.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace TemplateWeb.Models.DbModels; - -public class UserModel -{ - [Key] - public int Id { get; set; } - [Required] - [MaxLength(50)] - [MinLength(3)] - public string UserName { get; set; } - [Required] - [DataType(DataType.EmailAddress)] - public string Email { get; set; } - [Required] - [MinLength(5)] - [DataType(DataType.Password)] - public string Password { get; set; } - - public UserRole Role { get; set; } = UserRole.User; -} - -public enum UserRole -{ - User, - Admin -} \ No newline at end of file diff --git a/Template/TemplateWeb/Interfaces/IAuthService.cs b/Template/TemplateWeb/Interfaces/IAuthService.cs deleted file mode 100644 index ac33be9..0000000 --- a/Template/TemplateWeb/Interfaces/IAuthService.cs +++ /dev/null @@ -1,15 +0,0 @@ -using TemplateWeb.Models; -using TemplateWeb.Models.DbModels; - -namespace TemplateWeb.Interfaces; - -public interface IAuthService -{ - Task> FindUserByEmailOrUsernameAsync(string emailOrUsername); - Task AuthenticateAsync(UserModel user, bool rememberMe); - Task> RegisterAsync(AuthRegisterViewModel model); - Task LogoutAsync(); - Task DeleteUserAsync(UserModel user); - Task UpdateProfileAsync(int userId, ProfileViewModel model); - Task> GetUserDataAsync(int userId); -} diff --git a/Template/TemplateWeb/Migrations/20260418160408_InitMigrations.Designer.cs b/Template/TemplateWeb/Migrations/20260418160408_InitMigrations.Designer.cs deleted file mode 100644 index 455e8b3..0000000 --- a/Template/TemplateWeb/Migrations/20260418160408_InitMigrations.Designer.cs +++ /dev/null @@ -1,58 +0,0 @@ -// -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using TemplateWeb.Data; - -#nullable disable - -namespace TemplateWeb.Migrations -{ - [DbContext(typeof(AppDbContext))] - [Migration("20260418160408_InitMigrations")] - partial class InitMigrations - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.0-preview.1.25081.1") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("TemplateWeb.Models.DbModels.UserModel", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("Email") - .IsRequired() - .HasColumnType("text"); - - b.Property("Password") - .IsRequired() - .HasColumnType("text"); - - b.Property("Role") - .HasColumnType("integer"); - - b.Property("UserName") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.HasKey("Id"); - - b.ToTable("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Template/TemplateWeb/Migrations/20260418160408_InitMigrations.cs b/Template/TemplateWeb/Migrations/20260418160408_InitMigrations.cs deleted file mode 100644 index 83ea632..0000000 --- a/Template/TemplateWeb/Migrations/20260418160408_InitMigrations.cs +++ /dev/null @@ -1,38 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace TemplateWeb.Migrations -{ - /// - public partial class InitMigrations : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "Users", - columns: table => new - { - Id = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - UserName = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), - Email = table.Column(type: "text", nullable: false), - Password = table.Column(type: "text", nullable: false), - Role = table.Column(type: "integer", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Users", x => x.Id); - }); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "Users"); - } - } -} diff --git a/Template/TemplateWeb/Migrations/20260501160159_SwitchToIdentity.Designer.cs b/Template/TemplateWeb/Migrations/20260501160159_SwitchToIdentity.Designer.cs new file mode 100644 index 0000000..848312d --- /dev/null +++ b/Template/TemplateWeb/Migrations/20260501160159_SwitchToIdentity.Designer.cs @@ -0,0 +1,277 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TemplateWeb.Data; + +#nullable disable + +namespace TemplateWeb.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260501160159_SwitchToIdentity")] + partial class SwitchToIdentity + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("TemplateWeb.Entities.UserEntity", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("TemplateWeb.Entities.UserEntity", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("TemplateWeb.Entities.UserEntity", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TemplateWeb.Entities.UserEntity", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("TemplateWeb.Entities.UserEntity", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Template/TemplateWeb/Migrations/20260501160159_SwitchToIdentity.cs b/Template/TemplateWeb/Migrations/20260501160159_SwitchToIdentity.cs new file mode 100644 index 0000000..a9ce1e3 --- /dev/null +++ b/Template/TemplateWeb/Migrations/20260501160159_SwitchToIdentity.cs @@ -0,0 +1,223 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace TemplateWeb.Migrations +{ + /// + public partial class SwitchToIdentity : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AspNetRoles", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + ConcurrencyStamp = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetUsers", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + UserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + Email = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedEmail = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + EmailConfirmed = table.Column(type: "boolean", nullable: false), + PasswordHash = table.Column(type: "text", nullable: true), + SecurityStamp = table.Column(type: "text", nullable: true), + ConcurrencyStamp = table.Column(type: "text", nullable: true), + PhoneNumber = table.Column(type: "text", nullable: true), + PhoneNumberConfirmed = table.Column(type: "boolean", nullable: false), + TwoFactorEnabled = table.Column(type: "boolean", nullable: false), + LockoutEnd = table.Column(type: "timestamp with time zone", nullable: true), + LockoutEnabled = table.Column(type: "boolean", nullable: false), + AccessFailedCount = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUsers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetRoleClaims", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + RoleId = table.Column(type: "text", nullable: false), + ClaimType = table.Column(type: "text", nullable: true), + ClaimValue = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetRoleClaims_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserClaims", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column(type: "text", nullable: false), + ClaimType = table.Column(type: "text", nullable: true), + ClaimValue = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetUserClaims_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserLogins", + columns: table => new + { + LoginProvider = table.Column(type: "text", nullable: false), + ProviderKey = table.Column(type: "text", nullable: false), + ProviderDisplayName = table.Column(type: "text", nullable: true), + UserId = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey }); + table.ForeignKey( + name: "FK_AspNetUserLogins_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserRoles", + columns: table => new + { + UserId = table.Column(type: "text", nullable: false), + RoleId = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId }); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserTokens", + columns: table => new + { + UserId = table.Column(type: "text", nullable: false), + LoginProvider = table.Column(type: "text", nullable: false), + Name = table.Column(type: "text", nullable: false), + Value = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); + table.ForeignKey( + name: "FK_AspNetUserTokens_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AspNetRoleClaims_RoleId", + table: "AspNetRoleClaims", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + table: "AspNetRoles", + column: "NormalizedName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserClaims_UserId", + table: "AspNetUserClaims", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserLogins_UserId", + table: "AspNetUserLogins", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserRoles_RoleId", + table: "AspNetUserRoles", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + table: "AspNetUsers", + column: "NormalizedEmail"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + table: "AspNetUsers", + column: "NormalizedUserName", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AspNetRoleClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserLogins"); + + migrationBuilder.DropTable( + name: "AspNetUserRoles"); + + migrationBuilder.DropTable( + name: "AspNetUserTokens"); + + migrationBuilder.DropTable( + name: "AspNetRoles"); + + migrationBuilder.DropTable( + name: "AspNetUsers"); + } + } +} diff --git a/Template/TemplateWeb/Migrations/AppDbContextModelSnapshot.cs b/Template/TemplateWeb/Migrations/AppDbContextModelSnapshot.cs index 5a4f6df..05b0b72 100644 --- a/Template/TemplateWeb/Migrations/AppDbContextModelSnapshot.cs +++ b/Template/TemplateWeb/Migrations/AppDbContextModelSnapshot.cs @@ -1,4 +1,5 @@ // +using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; @@ -16,12 +17,38 @@ namespace TemplateWeb.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "10.0.0-preview.1.25081.1") + .HasAnnotation("ProductVersion", "10.0.6") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("TemplateWeb.Models.DbModels.UserModel", b => + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -29,25 +56,217 @@ namespace TemplateWeb.Migrations NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - b.Property("Email") - .IsRequired() + b.Property("ClaimType") .HasColumnType("text"); - b.Property("Password") - .IsRequired() + b.Property("ClaimValue") .HasColumnType("text"); - b.Property("Role") - .HasColumnType("integer"); - - b.Property("UserName") + b.Property("RoleId") .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); + .HasColumnType("text"); b.HasKey("Id"); - b.ToTable("Users"); + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("TemplateWeb.Entities.UserEntity", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("TemplateWeb.Entities.UserEntity", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("TemplateWeb.Entities.UserEntity", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TemplateWeb.Entities.UserEntity", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("TemplateWeb.Entities.UserEntity", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); }); #pragma warning restore 612, 618 } diff --git a/Template/TemplateWeb/Models/UserWithRolesViewModel.cs b/Template/TemplateWeb/Models/UserWithRolesViewModel.cs new file mode 100644 index 0000000..d4d50d6 --- /dev/null +++ b/Template/TemplateWeb/Models/UserWithRolesViewModel.cs @@ -0,0 +1,9 @@ +using TemplateWeb.Entities; + +namespace TemplateWeb.Models; + +public class UserWithRolesViewModel +{ + public UserEntity UserEntity { get; set; } = null!; + public IList Roles { get; set; } = []; +} diff --git a/Template/TemplateWeb/Program.cs b/Template/TemplateWeb/Program.cs index d0608d0..d9f3a9d 100644 --- a/Template/TemplateWeb/Program.cs +++ b/Template/TemplateWeb/Program.cs @@ -1,9 +1,8 @@ using dotenv.net; -using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using TemplateWeb.Data; -using TemplateWeb.Interfaces; -using TemplateWeb.Services; +using TemplateWeb.Entities; namespace TemplateWeb; @@ -15,29 +14,38 @@ public class Program var builder = WebApplication.CreateBuilder(args); // gets the connection string from configuration - var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") + var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found."); - + builder.Services.AddDbContext(options => options.UseNpgsql(connectionString)); builder.Services.AddControllersWithViews(); - builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) - .AddCookie(options => + // Configure Identity — password requirements are read from appsettings.json "Identity:Password" + var pwSection = builder.Configuration.GetSection("Identity:Password"); + builder.Services.AddIdentity(options => { - options.LoginPath = "/Auth/Login"; - options.AccessDeniedPath = "/Auth/AccessDenied"; - options.ExpireTimeSpan = TimeSpan.FromHours(2); - }); - builder.Services.AddAuthorization(); - - builder.Services.AddScoped(); - builder.Services.AddHttpContextAccessor(); - + options.Password.RequiredLength = pwSection.GetValue("RequiredLength", 6); + options.Password.RequireDigit = pwSection.GetValue("RequireDigit", true); + options.Password.RequireLowercase = pwSection.GetValue("RequireLowercase", true); + options.Password.RequireUppercase = pwSection.GetValue("RequireUppercase", true); + options.Password.RequireNonAlphanumeric = pwSection.GetValue("RequireNonAlphanumeric", false); + options.Password.RequiredUniqueChars = pwSection.GetValue("RequiredUniqueChars", 1); + }) + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + + builder.Services.ConfigureApplicationCookie(options => + { + options.LoginPath = "/Auth/Login"; + options.AccessDeniedPath = "/Auth/AccessDenied"; + options.ExpireTimeSpan = TimeSpan.FromHours(2); + }); + var app = builder.Build(); - // Apply migrations on startup + // Apply migrations and seed on startup using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); @@ -45,9 +53,10 @@ public class Program { db.Database.Migrate(); } - - // Seed the initial admin user - DataSeeder.SeedAdminUser(db).GetAwaiter().GetResult(); + + var userManager = scope.ServiceProvider.GetRequiredService>(); + var roleManager = scope.ServiceProvider.GetRequiredService>(); + DataSeeder.SeedAsync(userManager, roleManager).GetAwaiter().GetResult(); } // Configure the HTTP request pipeline. @@ -65,7 +74,7 @@ public class Program app.MapControllerRoute( name: "areas", pattern: "{area:exists}/{controller=Home}/{action=Index}/{id?}"); - + app.MapControllerRoute( name: "default", pattern: "{controller=Home}/{action=Index}/{id?}") @@ -73,4 +82,4 @@ public class Program app.Run(); } -} \ No newline at end of file +} diff --git a/Template/TemplateWeb/Services/AuthService.cs b/Template/TemplateWeb/Services/AuthService.cs deleted file mode 100644 index c627956..0000000 --- a/Template/TemplateWeb/Services/AuthService.cs +++ /dev/null @@ -1,183 +0,0 @@ -using System.Security.Claims; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.Cookies; -using Microsoft.EntityFrameworkCore; -using TemplateWeb.Data; -using TemplateWeb.Interfaces; -using TemplateWeb.Models; -using TemplateWeb.Models.DbModels; - -namespace TemplateWeb.Services; - -public class AuthService : IAuthService -{ - private readonly AppDbContext _context; - private readonly ILogger _logger; - private readonly IHttpContextAccessor _httpContextAccessor; - - public AuthService( - AppDbContext context, ILogger logger, IHttpContextAccessor httpContextAccessor) - { - _context = context; - _logger = logger; - _httpContextAccessor = httpContextAccessor; - } - - public async Task> FindUserByEmailOrUsernameAsync(string emailOrUsername) - { - if (string.IsNullOrWhiteSpace(emailOrUsername)) - return ServiceResult.Failure("Email or username is required."); - - var trimmed = emailOrUsername.Trim().ToLower(); - - var user = await _context.Users.SingleOrDefaultAsync(u => - string.Equals(u.Email, trimmed) || - string.Equals(u.UserName, trimmed)); - - return user == null ? ServiceResult.Failure("User not found.") : ServiceResult.Ok(user); - } - - public async Task AuthenticateAsync(UserModel user, bool rememberMe) - { - var claims = new List - { - new(ClaimTypes.NameIdentifier, user.Id.ToString()), - new(ClaimTypes.Name, user.UserName), - new(ClaimTypes.Email, user.Email), - new(ClaimTypes.Role, user.Role.ToString()), - }; - - var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); - var principal = new ClaimsPrincipal(identity); - - var httpContext = _httpContextAccessor.HttpContext; - if (httpContext == null) - { - _logger.LogError("HttpContext is null during authentication."); - return ServiceResult.Failure("An internal error occurred."); - } - - await httpContext.SignInAsync( - CookieAuthenticationDefaults.AuthenticationScheme, - principal, - new AuthenticationProperties - { - IsPersistent = rememberMe, - ExpiresUtc = rememberMe - ? DateTimeOffset.UtcNow.AddDays(30) - : DateTimeOffset.UtcNow.AddHours(8) - }); - - return ServiceResult.Ok(); - } - - public async Task> RegisterAsync(AuthRegisterViewModel viewModel) - { - var userNameTrimmed = viewModel.UserName.Trim(); - var emailTrimmed = viewModel.Email.Trim().ToLower(); - - var existingUser = await _context.Users.AnyAsync(u => - string.Equals(u.UserName, userNameTrimmed) || - string.Equals(u.Email, emailTrimmed)); - - if (existingUser) - { - return ServiceResult.Failure("Username or Email already exists."); - } - - var user = new UserModel - { - UserName = userNameTrimmed, - Email = emailTrimmed, - Password = BCrypt.Net.BCrypt.HashPassword(viewModel.Password), - Role = UserRole.User - }; - - try - { - await _context.Users.AddAsync(user); - await _context.SaveChangesAsync(); - _logger.LogInformation("User {UserName} registered.", user.UserName); - return ServiceResult.Ok(user); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error registering user {UserName}", user.UserName); - return ServiceResult.Failure("An error occurred while saving the user."); - } - } - - public async Task LogoutAsync() - { - var httpContext = _httpContextAccessor.HttpContext; - if (httpContext != null) - { - await httpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); - _logger.LogInformation("User logged out."); - } - } - - public async Task DeleteUserAsync(UserModel user) - { - try - { - _context.Users.Remove(user); - await _context.SaveChangesAsync(); - _logger.LogInformation("User {UserId} deleted.", user.Id); - return ServiceResult.Ok(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting user {UserId}", user.Id); - return ServiceResult.Failure("An error occurred while deleting the user."); - } - } - - public async Task UpdateProfileAsync(int userId, ProfileViewModel model) - { - var user = await _context.Users.FindAsync(userId); - if (user == null) return ServiceResult.Failure("User not found."); - - var isTaken = await _context.Users.AnyAsync(u => u.Id != userId && - (u.UserName.ToLower() == model.UserName.Trim().ToLower() || - u.Email.ToLower() == model.Email.Trim().ToLower())); - - if (isTaken) return ServiceResult.Failure("Username or Email already exists."); - - user.UserName = model.UserName.Trim(); - user.Email = model.Email.Trim().ToLower(); - - if (!string.IsNullOrWhiteSpace(model.NewPassword)) - { - user.Password = BCrypt.Net.BCrypt.HashPassword(model.NewPassword); - } - - try - { - await _context.SaveChangesAsync(); - return ServiceResult.Ok(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error updating profile for user {UserId}", userId); - return ServiceResult.Failure("An error occurred while saving changes."); - } - } - - public async Task> GetUserDataAsync(int userId) - { - var user = await _context.Users.FindAsync(userId); - if (user == null) return ServiceResult.Failure("User not found."); - - var data = new - { - user.Id, - user.UserName, - user.Email, - Role = user.Role.ToString(), - ExportDate = DateTime.UtcNow - }; - - return ServiceResult.Ok(data); - } -} diff --git a/Template/TemplateWeb/TemplateWeb.csproj b/Template/TemplateWeb/TemplateWeb.csproj index eb1d955..30e6630 100644 --- a/Template/TemplateWeb/TemplateWeb.csproj +++ b/Template/TemplateWeb/TemplateWeb.csproj @@ -8,8 +8,8 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Template/TemplateWeb/ViewModels/ProfileViewModel.cs b/Template/TemplateWeb/ViewModels/ProfileViewModel.cs index 65c8d2a..8057fb6 100644 --- a/Template/TemplateWeb/ViewModels/ProfileViewModel.cs +++ b/Template/TemplateWeb/ViewModels/ProfileViewModel.cs @@ -14,9 +14,12 @@ public class ProfileViewModel [Display(Name = "Email")] public string Email { get; set; } = string.Empty; + [DataType(DataType.Password)] + [Display(Name = "Current Password")] + public string? CurrentPassword { get; set; } + [DataType(DataType.Password)] [Display(Name = "New Password (leave blank to keep current)")] - [MinLength(5)] public string? NewPassword { get; set; } [DataType(DataType.Password)] diff --git a/Template/TemplateWeb/Views/Auth/Profile.cshtml b/Template/TemplateWeb/Views/Auth/Profile.cshtml index f331fdb..d8ac084 100644 --- a/Template/TemplateWeb/Views/Auth/Profile.cshtml +++ b/Template/TemplateWeb/Views/Auth/Profile.cshtml @@ -32,6 +32,12 @@

Change Password (optional)

+
+ + + +
+
diff --git a/Template/TemplateWeb/appsettings.json b/Template/TemplateWeb/appsettings.json index ea28350..75c84b0 100644 --- a/Template/TemplateWeb/appsettings.json +++ b/Template/TemplateWeb/appsettings.json @@ -8,5 +8,15 @@ "AllowedHosts": "*", "ConnectionStrings": { "DefaultConnection": "Host=localhost;Database=templatedb;Username=user;Password=password" + }, + "Identity": { + "Password": { + "RequiredLength": 6, + "RequireDigit": true, + "RequireLowercase": true, + "RequireUppercase": true, + "RequireNonAlphanumeric": false, + "RequiredUniqueChars": 1 + } } } diff --git a/Template/WebTests/AuthServiceTests.cs b/Template/WebTests/AuthServiceTests.cs index c3dc01b..9f4f02f 100644 --- a/Template/WebTests/AuthServiceTests.cs +++ b/Template/WebTests/AuthServiceTests.cs @@ -1,115 +1,97 @@ -using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Moq; +using Microsoft.Extensions.DependencyInjection; using TemplateWeb.Data; -using TemplateWeb.Models; -using TemplateWeb.Models.DbModels; -using TemplateWeb.Services; +using TemplateWeb.Entities; namespace WebTests; [TestClass] public class AuthServiceTests { - private AppDbContext _context = null!; - private Mock> _loggerMock = null!; - private Mock _httpContextAccessorMock = null!; - private AuthService _authService = null!; + private ServiceProvider _serviceProvider = null!; + private UserManager _userManager = null!; [TestInitialize] public void Initialize() { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) - .Options; - _context = new AppDbContext(options); - _loggerMock = new Mock>(); - _httpContextAccessorMock = new Mock(); - _authService = new AuthService(_context, _loggerMock.Object, _httpContextAccessorMock.Object); + var services = new ServiceCollection(); + + services.AddLogging(); + + services.AddDbContext(options => + options.UseInMemoryDatabase(Guid.NewGuid().ToString())); + + services.AddIdentity() + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + + _serviceProvider = services.BuildServiceProvider(); + _userManager = _serviceProvider.GetRequiredService>(); } [TestCleanup] public void Cleanup() { - _context.Database.EnsureDeleted(); - _context.Dispose(); + _serviceProvider.Dispose(); } [TestMethod] - public async Task RegisterAsync_ShouldReturnSuccess_WhenUserIsNew() + public async Task CreateAsync_ShouldReturnSuccess_WhenUserIsNew() { // Arrange - var model = new AuthRegisterViewModel - { - UserName = "testuser", - Email = "test@example.com", - Password = "password123", - ConfirmPassword = "password123" - }; + var user = new UserEntity { UserName = "testuser", Email = "test@example.com" }; // Act - var result = await _authService.RegisterAsync(model); + var result = await _userManager.CreateAsync(user, "Password123!"); // Assert - Assert.IsTrue(result.Success); - Assert.IsNotNull(result.Data); - Assert.AreEqual("testuser", result.Data.UserName); - Assert.AreEqual("test@example.com", result.Data.Email); - - var userInDb = await _context.Users.FirstOrDefaultAsync(u => u.UserName == "testuser"); + Assert.IsTrue(result.Succeeded); + + var userInDb = await _userManager.FindByNameAsync("testuser"); Assert.IsNotNull(userInDb); + Assert.AreEqual("test@example.com", userInDb.Email); } [TestMethod] - public async Task RegisterAsync_ShouldReturnFailure_WhenUserAlreadyExists() + public async Task CreateAsync_ShouldReturnFailure_WhenUsernameAlreadyExists() { // Arrange - var existingUser = new UserModel { UserName = "existing", Email = "existing@example.com", Password = "hashedpassword" }; - _context.Users.Add(existingUser); - await _context.SaveChangesAsync(); + var existing = new UserEntity { UserName = "existing", Email = "existing@example.com" }; + await _userManager.CreateAsync(existing, "Password123!"); - var model = new AuthRegisterViewModel - { - UserName = "existing", - Email = "new@example.com", - Password = "password123", - ConfirmPassword = "password123" - }; + var duplicate = new UserEntity { UserName = "existing", Email = "other@example.com" }; // Act - var result = await _authService.RegisterAsync(model); + var result = await _userManager.CreateAsync(duplicate, "Password123!"); // Assert - Assert.IsFalse(result.Success); - Assert.AreEqual("Username or Email already exists.", result.Message); + Assert.IsFalse(result.Succeeded); + Assert.IsTrue(result.Errors.Any(e => e.Code == "DuplicateUserName")); } [TestMethod] - public async Task FindUserByEmailOrUsernameAsync_ShouldReturnUser_WhenUsernameMatches() + public async Task FindByNameAsync_ShouldReturnUser_CaseInsensitive() { // Arrange - var user = new UserModel { UserName = "testuser", Email = "test@example.com", Password = "password" }; - _context.Users.Add(user); - await _context.SaveChangesAsync(); + var user = new UserEntity { UserName = "testuser", Email = "test@example.com" }; + await _userManager.CreateAsync(user, "Password123!"); - // Act - var result = await _authService.FindUserByEmailOrUsernameAsync("TESTUSER"); + // Act — Identity normalizes usernames, so lookup is always case-insensitive + var found = await _userManager.FindByNameAsync("TESTUSER"); // Assert - Assert.IsTrue(result.Success); - Assert.IsNotNull(result.Data); - Assert.AreEqual("testuser", result.Data.UserName); + Assert.IsNotNull(found); + Assert.AreEqual("testuser", found.UserName); } [TestMethod] - public async Task FindUserByEmailOrUsernameAsync_ShouldReturnFailure_WhenUserNotFound() + public async Task FindByNameAsync_ShouldReturnNull_WhenUserNotFound() { // Act - var result = await _authService.FindUserByEmailOrUsernameAsync("nonexistent"); + var result = await _userManager.FindByNameAsync("nonexistent"); // Assert - Assert.IsFalse(result.Success); - Assert.AreEqual("User not found.", result.Message); + Assert.IsNull(result); } } diff --git a/Template/WebTests/WebTests.csproj b/Template/WebTests/WebTests.csproj index b684186..121f38e 100644 --- a/Template/WebTests/WebTests.csproj +++ b/Template/WebTests/WebTests.csproj @@ -9,7 +9,9 @@ + + diff --git a/rename-template.sh b/rename-template.sh new file mode 100644 index 0000000..4434888 --- /dev/null +++ b/rename-template.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "Usage: $0 " + exit 1 +fi + +NEW_NAME="$1" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$SCRIPT_DIR/Template" + +if [[ ! -d "$ROOT" ]]; then + echo "Error: Template directory not found at $ROOT" + exit 1 +fi + +echo "Renaming 'Template' -> '$NEW_NAME' in: $ROOT" + +# Rename files first (deepest first to avoid path issues) +while IFS= read -r -d '' path; do + dir="$(dirname "$path")" + base="$(basename "$path")" + new_base="${base//Template/$NEW_NAME}" + if [[ "$base" != "$new_base" ]]; then + echo " FILE: $base -> $new_base" + mv "$path" "$dir/$new_base" + fi +done < <(find "$ROOT" \ + -not -path "*/bin/*" \ + -not -path "*/obj/*" \ + -not -path "*/.idea/*" \ + -type f -name "*Template*" -print0 | sort -rz) + +# Rename directories (deepest first) +while IFS= read -r -d '' path; do + parent="$(dirname "$path")" + base="$(basename "$path")" + new_base="${base//Template/$NEW_NAME}" + if [[ "$base" != "$new_base" ]]; then + echo " DIR: $base -> $new_base" + mv "$path" "$parent/$new_base" + fi +done < <(find "$ROOT" \ + -not -path "*/bin/*" \ + -not -path "*/obj/*" \ + -not -path "*/.idea/*" \ + -type d -name "*Template*" -print0 | sort -rz) + +echo "Done."