diff --git a/api/Migrations/20260321203739_UseIdentityPlatform.Designer.cs b/api/Migrations/20260321203739_UseIdentityPlatform.Designer.cs new file mode 100644 index 0000000..7c70f29 --- /dev/null +++ b/api/Migrations/20260321203739_UseIdentityPlatform.Designer.cs @@ -0,0 +1,84 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace agologum_api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260321203739_UseIdentityPlatform")] + partial class UseIdentityPlatform + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("agologumApi.Models.User", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasColumnType("text"); + + b.Property("NormalizedUserName") + .HasColumnType("text"); + + 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") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/api/Migrations/20260321203739_UseIdentityPlatform.cs b/api/Migrations/20260321203739_UseIdentityPlatform.cs new file mode 100644 index 0000000..6f022b9 --- /dev/null +++ b/api/Migrations/20260321203739_UseIdentityPlatform.cs @@ -0,0 +1,221 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace agologum_api.Migrations +{ + /// + public partial class UseIdentityPlatform : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Name", + table: "Users"); + + migrationBuilder.DropColumn( + name: "Role", + table: "Users"); + + migrationBuilder.AlterColumn( + name: "PasswordHash", + table: "Users", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "text"); + + migrationBuilder.AlterColumn( + name: "Email", + table: "Users", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "text"); + + migrationBuilder.AlterColumn( + name: "Id", + table: "Users", + type: "text", + nullable: false, + oldClrType: typeof(int), + oldType: "integer") + .OldAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + migrationBuilder.AddColumn( + name: "AccessFailedCount", + table: "Users", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "ConcurrencyStamp", + table: "Users", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "EmailConfirmed", + table: "Users", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "LockoutEnabled", + table: "Users", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "LockoutEnd", + table: "Users", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "NormalizedEmail", + table: "Users", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "NormalizedUserName", + table: "Users", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "PhoneNumber", + table: "Users", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "PhoneNumberConfirmed", + table: "Users", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "SecurityStamp", + table: "Users", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "TwoFactorEnabled", + table: "Users", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "UserName", + table: "Users", + type: "text", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AccessFailedCount", + table: "Users"); + + migrationBuilder.DropColumn( + name: "ConcurrencyStamp", + table: "Users"); + + migrationBuilder.DropColumn( + name: "EmailConfirmed", + table: "Users"); + + migrationBuilder.DropColumn( + name: "LockoutEnabled", + table: "Users"); + + migrationBuilder.DropColumn( + name: "LockoutEnd", + table: "Users"); + + migrationBuilder.DropColumn( + name: "NormalizedEmail", + table: "Users"); + + migrationBuilder.DropColumn( + name: "NormalizedUserName", + table: "Users"); + + migrationBuilder.DropColumn( + name: "PhoneNumber", + table: "Users"); + + migrationBuilder.DropColumn( + name: "PhoneNumberConfirmed", + table: "Users"); + + migrationBuilder.DropColumn( + name: "SecurityStamp", + table: "Users"); + + migrationBuilder.DropColumn( + name: "TwoFactorEnabled", + table: "Users"); + + migrationBuilder.DropColumn( + name: "UserName", + table: "Users"); + + migrationBuilder.AlterColumn( + name: "PasswordHash", + table: "Users", + type: "text", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Email", + table: "Users", + type: "text", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Id", + table: "Users", + type: "integer", + nullable: false, + oldClrType: typeof(string), + oldType: "text") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + migrationBuilder.AddColumn( + name: "Name", + table: "Users", + type: "text", + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "Role", + table: "Users", + type: "text", + nullable: false, + defaultValue: ""); + } + } +} diff --git a/api/Migrations/AppDbContextModelSnapshot.cs b/api/Migrations/AppDbContextModelSnapshot.cs index 472f8c1..b84960f 100644 --- a/api/Migrations/AppDbContextModelSnapshot.cs +++ b/api/Migrations/AppDbContextModelSnapshot.cs @@ -23,29 +23,52 @@ namespace agologum_api.Migrations modelBuilder.Entity("agologumApi.Models.User", b => { - b.Property("Id") - .ValueGeneratedOnAdd() + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") .HasColumnType("integer"); - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + b.Property("ConcurrencyStamp") + .HasColumnType("text"); b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); b.Property("Email") - .IsRequired() .HasColumnType("text"); - b.Property("Name") - .IsRequired() + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasColumnType("text"); + + b.Property("NormalizedUserName") .HasColumnType("text"); b.Property("PasswordHash") - .IsRequired() .HasColumnType("text"); - b.Property("Role") - .IsRequired() + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") .HasColumnType("text"); b.HasKey("Id"); diff --git a/api/Program.cs b/api/Program.cs index a05ead5..fbf5bc5 100644 --- a/api/Program.cs +++ b/api/Program.cs @@ -3,8 +3,11 @@ using Microsoft.AspNetCore.HttpOverrides; using Microsoft.EntityFrameworkCore; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using System.Text; +using agologumApi.Models; using agologumApi.Services; var builder = WebApplication.CreateBuilder(args); @@ -22,15 +25,20 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); // configuration for jwt authentication +builder.Services.AddIdentity() + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); builder.Services.AddAuthentication(options => { options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }).AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters { - ValidateIssuer = false, - ValidateAudience = false, + ValidateIssuer = true, + ValidateAudience = true, ValidateLifetime = true, ValidateIssuerSigningKey = true, + ValidIssuer = "agologum", + ValidAudience = "agologum", IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)) }; }); diff --git a/api/agologum-api.csproj b/api/agologum-api.csproj index 82bd216..cfe18b4 100644 --- a/api/agologum-api.csproj +++ b/api/agologum-api.csproj @@ -10,6 +10,7 @@ + diff --git a/api/src/Controllers/AuthController.cs b/api/src/Controllers/AuthController.cs index 6cb3db8..ba2ae21 100644 --- a/api/src/Controllers/AuthController.cs +++ b/api/src/Controllers/AuthController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; using agologumApi.Models; using agologumApi.Services; @@ -9,11 +10,17 @@ using agologumApi.Services; [Route("api/[controller]")] public class AuthController : ControllerBase { + // identity things + private readonly UserManager userManager_; + private readonly SignInManager signInManager_; + private readonly UserService users_; private readonly JwtService jwt_; - public AuthController(UserService users, JwtService jwt) - { + public AuthController(UserManager userManager, SignInManager signInManager, UserService users, JwtService jwt) { + + userManager_ = userManager; + signInManager_ = signInManager; users_ = users; jwt_ = jwt; } @@ -21,10 +28,9 @@ public class AuthController : ControllerBase { [HttpPost("register")] public async Task Register(RegisterDto dto) { var user = new User { - Name = dto.Username, + UserName = dto.UserName, Email = dto.Email, PasswordHash = BCrypt.Net.BCrypt.HashPassword(dto.Password), // TODO: secondary hashing stage in client - Role = "user", CreatedAt = DateTime.UtcNow // yeah why not utc }; @@ -39,11 +45,13 @@ public class AuthController : ControllerBase { [HttpPost("login")] public async Task Login(LoginDto dto) { - var user = await users_.Get(dto.Username); + var user = await users_.Get(dto.UserName); - if (user == null || !BCrypt.Net.BCrypt.Verify(dto.Password, user.PasswordHash)) { - return Unauthorized(); - } + if (user == null) return Unauthorized(); + + var result = await signInManager_.CheckPasswordSignInAsync(user, dto.Password, false); + + if(!result.Succeeded) return Unauthorized(); var token = jwt_.GenerateJwt(user); diff --git a/api/src/Models/Dto.cs b/api/src/Models/Dto.cs index 7403c6f..a740896 100644 --- a/api/src/Models/Dto.cs +++ b/api/src/Models/Dto.cs @@ -1,7 +1,7 @@ public class RegisterDto { - public string Name { get; set; } = ""; + public string UserName { get; set; } = ""; public string Email { get; set; } = ""; public string Password { get; set; } = ""; @@ -9,7 +9,7 @@ public class RegisterDto { public class LoginDto { - public string Name { get; set; } = ""; + public string UserName { get; set; } = ""; public string Password { get; set; } = ""; } diff --git a/api/src/Models/User.cs b/api/src/Models/User.cs index ab3618a..2488222 100644 --- a/api/src/Models/User.cs +++ b/api/src/Models/User.cs @@ -1,13 +1,32 @@ +using Microsoft.AspNetCore.Identity; + namespace agologumApi.Models; -public class User { +public class User : IdentityUser { - public int Id { get; set; } - public string Name { get; set; } = ""; - public string Email { get; set; } = ""; - public string PasswordHash { get; set; } = ""; - public string Role { get; set; } = ""; public DateTime CreatedAt { get; set; } + // properties inherited from IdentityUser: + /* + AccessFailedCount: Gets or sets the number of failed login attempts for the current user. + Claims: Navigation property for the claims this user possesses. + ConcurrencyStamp: A random value that must change whenever a user is persisted to the store + Email: Gets or sets the email address for this user. + EmailConfirmed: Gets or sets a flag indicating if a user has confirmed their email address. + Id: Gets or sets the primary key for this user. + LockoutEnabled: Gets or sets a flag indicating if the user could be locked out. + LockoutEnd: Gets or sets the date and time, in UTC, when any user lockout ends. + Logins: Navigation property for this users login accounts. + NormalizedEmail: Gets or sets the normalized email address for this user. + NormalizedUserName: Gets or sets the normalized user name for this user. + PasswordHash: Gets or sets a salted and hashed representation of the password for this user. + PhoneNumber: Gets or sets a telephone number for the user. + PhoneNumberConfirmed: Gets or sets a flag indicating if a user has confirmed their telephone address. + Roles: Navigation property for the roles this user belongs to. + SecurityStamp: A random value that must change whenever a users credentials change (password changed, login removed) + TwoFactorEnabled: Gets or sets a flag indicating if two factor authentication is enabled for this user. + UserName: Gets or sets the user name for this user. + https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.identity.entityframeworkcore.identityuser?view=aspnetcore-1.1 + */ }; diff --git a/api/src/Services/JwtService.cs b/api/src/Services/JwtService.cs index 14e0b69..a79188e 100644 --- a/api/src/Services/JwtService.cs +++ b/api/src/Services/JwtService.cs @@ -22,13 +22,17 @@ public class JwtService { var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + if(user.UserName == null) return null; + // not too sure var claims = new[] { - new Claim(ClaimTypes.Name, user.Name), + new Claim(ClaimTypes.Name, user.UserName), new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()) }; var token = new JwtSecurityToken( + issuer: "agologum", + audience: "agologum", claims: claims, expires: DateTime.UtcNow.AddHours(2), // will add a refresher later signingCredentials: creds diff --git a/api/src/Services/UserService.cs b/api/src/Services/UserService.cs index 1f9f247..1e6c104 100644 --- a/api/src/Services/UserService.cs +++ b/api/src/Services/UserService.cs @@ -22,7 +22,7 @@ public class UserService { } public async Task Get(string username) { - return await db_.Users.FirstOrDefaultAsync(u => u.Name == username); + return await db_.Users.FirstOrDefaultAsync(u => u.UserName == username); } public async Task Create(User user) {