From b6f35d62edf72e28cf4f79f21b02b7cec42f3d5a Mon Sep 17 00:00:00 2001 From: miticcantonija Date: Mon, 2 Nov 2026 18:29:46 +0100 Subject: [PATCH 1/6] IDN-01 Domen i ugovori --- backend/Platform.Api/Platform.Api.csproj | 4 ++- backend/Platform.Api/Program.cs | 3 ++- .../IdentityServiceCollectionExtensions.cs | 27 +++++++++++++++++++ .../Contracts/IAccountRegistration.cs | 10 +++++++ .../Contracts/IIdentityContext.cs | 12 +++++++++ .../Domain/Enums/AccountStatus.cs | 8 ++++++ .../Domain/Enums/RegistrationOutcome.cs | 8 ++++++ .../Domain/Models/AppUser.cs | 10 +++++++ .../Domain/Models/RegisterAccountCommand.cs | 6 +++++ .../Domain/Models/RegisterAccountResult.cs | 18 +++++++++++++ .../Persistence/AccountDbContext.cs | 21 +++++++++++++++ .../Platform.Identity.csproj | 14 ++++++++++ 12 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 backend/Platform.Identity/Composition/IdentityServiceCollectionExtensions.cs create mode 100644 backend/Platform.Identity/Contracts/IAccountRegistration.cs create mode 100644 backend/Platform.Identity/Contracts/IIdentityContext.cs create mode 100644 backend/Platform.Identity/Domain/Enums/AccountStatus.cs create mode 100644 backend/Platform.Identity/Domain/Enums/RegistrationOutcome.cs create mode 100644 backend/Platform.Identity/Domain/Models/AppUser.cs create mode 100644 backend/Platform.Identity/Domain/Models/RegisterAccountCommand.cs create mode 100644 backend/Platform.Identity/Domain/Models/RegisterAccountResult.cs create mode 100644 backend/Platform.Identity/Infrastructure/Persistence/AccountDbContext.cs create mode 100644 backend/Platform.Identity/Platform.Identity.csproj diff --git a/backend/Platform.Api/Platform.Api.csproj b/backend/Platform.Api/Platform.Api.csproj index 0c96897..f985bf4 100644 --- a/backend/Platform.Api/Platform.Api.csproj +++ b/backend/Platform.Api/Platform.Api.csproj @@ -1,10 +1,12 @@ - + net10.0 enable enable + 3fd00482-a54c-426f-9d2a-0f2204817946 + diff --git a/backend/Platform.Api/Program.cs b/backend/Platform.Api/Program.cs index db0907c..6a5077f 100644 --- a/backend/Platform.Api/Program.cs +++ b/backend/Platform.Api/Program.cs @@ -2,10 +2,11 @@ using Platform.Access.Composition; using Platform.Access.Domain.Enums; using Platform.Access.Domain.Models; +using Platform.Identity.Composition; var builder = WebApplication.CreateBuilder(args); builder.Services.AddAccessControl(); - +builder.Services.AddIdentityModule(); var app = builder.Build(); // Starter endpoint only. Add domain APIs together with server-side authorization. diff --git a/backend/Platform.Identity/Composition/IdentityServiceCollectionExtensions.cs b/backend/Platform.Identity/Composition/IdentityServiceCollectionExtensions.cs new file mode 100644 index 0000000..5b0a4f9 --- /dev/null +++ b/backend/Platform.Identity/Composition/IdentityServiceCollectionExtensions.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Platform.Identity.Domain.Models; +using Platform.Identity.Infrastructure.Persistence; + +namespace Platform.Identity.Composition; + +public static class IdentityServiceCollectionExtensions +{ + public static IServiceCollection AddIdentityModule(this IServiceCollection services) + { + services.AddDbContext((sp, options) => + options.UseNpgsql( + sp.GetRequiredService().GetConnectionString("Identity"))); + + services.AddIdentityCore(options => + { + options.User.RequireUniqueEmail = true; + options.Password.RequiredLength = 12; + }) + .AddEntityFrameworkStores(); + + return services; + } +} \ No newline at end of file diff --git a/backend/Platform.Identity/Contracts/IAccountRegistration.cs b/backend/Platform.Identity/Contracts/IAccountRegistration.cs new file mode 100644 index 0000000..3a3f252 --- /dev/null +++ b/backend/Platform.Identity/Contracts/IAccountRegistration.cs @@ -0,0 +1,10 @@ +using Platform.Identity.Domain.Models; + +namespace Platform.Identity.Contracts; + +public interface IAccountRegistration +{ + ValueTask RegisterAsync( + RegisterAccountCommand command, + CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/backend/Platform.Identity/Contracts/IIdentityContext.cs b/backend/Platform.Identity/Contracts/IIdentityContext.cs new file mode 100644 index 0000000..6502354 --- /dev/null +++ b/backend/Platform.Identity/Contracts/IIdentityContext.cs @@ -0,0 +1,12 @@ +using Platform.Identity.Domain.Enums; + +namespace Platform.Identity.Contracts; + +public sealed record AccountSnapshot(Guid Id, AccountStatus Status); + +public interface IIdentityContext +{ + ValueTask FindByIdAsync( + Guid accountId, + CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/backend/Platform.Identity/Domain/Enums/AccountStatus.cs b/backend/Platform.Identity/Domain/Enums/AccountStatus.cs new file mode 100644 index 0000000..0c60662 --- /dev/null +++ b/backend/Platform.Identity/Domain/Enums/AccountStatus.cs @@ -0,0 +1,8 @@ +namespace Platform.Identity.Domain.Enums; + +public enum AccountStatus +{ + Active, + Blocked, + Archived +} \ No newline at end of file diff --git a/backend/Platform.Identity/Domain/Enums/RegistrationOutcome.cs b/backend/Platform.Identity/Domain/Enums/RegistrationOutcome.cs new file mode 100644 index 0000000..9b26134 --- /dev/null +++ b/backend/Platform.Identity/Domain/Enums/RegistrationOutcome.cs @@ -0,0 +1,8 @@ +namespace Platform.Identity.Domain.Enums; + +public enum RegistrationOutcome +{ + Created, + IdentifierTaken, + InvalidInput +} \ No newline at end of file diff --git a/backend/Platform.Identity/Domain/Models/AppUser.cs b/backend/Platform.Identity/Domain/Models/AppUser.cs new file mode 100644 index 0000000..5c09f74 --- /dev/null +++ b/backend/Platform.Identity/Domain/Models/AppUser.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Identity; +using Platform.Identity.Domain.Enums; + +namespace Platform.Identity.Domain.Models; + +public sealed class AppUser : IdentityUser +{ + public AccountStatus Status { get; set; } = AccountStatus.Active; + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; +} \ No newline at end of file diff --git a/backend/Platform.Identity/Domain/Models/RegisterAccountCommand.cs b/backend/Platform.Identity/Domain/Models/RegisterAccountCommand.cs new file mode 100644 index 0000000..fd12b27 --- /dev/null +++ b/backend/Platform.Identity/Domain/Models/RegisterAccountCommand.cs @@ -0,0 +1,6 @@ +namespace Platform.Identity.Domain.Models; + +public sealed record RegisterAccountCommand(string Email, string Password) +{ + public override string ToString() => "RegisterAccountCommand { *** }"; +} \ No newline at end of file diff --git a/backend/Platform.Identity/Domain/Models/RegisterAccountResult.cs b/backend/Platform.Identity/Domain/Models/RegisterAccountResult.cs new file mode 100644 index 0000000..aee0d0c --- /dev/null +++ b/backend/Platform.Identity/Domain/Models/RegisterAccountResult.cs @@ -0,0 +1,18 @@ +using Platform.Identity.Domain.Enums; + +namespace Platform.Identity.Domain.Models; + +public sealed record RegisterAccountResult( + RegistrationOutcome Outcome, + Guid? AccountId, + IReadOnlyList Errors) +{ + public static RegisterAccountResult Created(Guid id) => + new(RegistrationOutcome.Created, id, []); + + public static RegisterAccountResult Taken() => + new(RegistrationOutcome.IdentifierTaken, null, []); + + public static RegisterAccountResult Invalid(params string[] errors) => + new(RegistrationOutcome.InvalidInput, null, errors); +} \ No newline at end of file diff --git a/backend/Platform.Identity/Infrastructure/Persistence/AccountDbContext.cs b/backend/Platform.Identity/Infrastructure/Persistence/AccountDbContext.cs new file mode 100644 index 0000000..f301515 --- /dev/null +++ b/backend/Platform.Identity/Infrastructure/Persistence/AccountDbContext.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using Platform.Identity.Domain.Models; + +namespace Platform.Identity.Infrastructure.Persistence; + +public sealed class AccountDbContext(DbContextOptions options) + : IdentityUserContext(options) +{ + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + + builder.HasDefaultSchema("identity"); + + // IDN-01: jedinstven identifikator (email) na nivou baze, ne samo u kodu. + builder.Entity() + .HasIndex(u => u.NormalizedEmail) + .IsUnique(); + } +} \ No newline at end of file diff --git a/backend/Platform.Identity/Platform.Identity.csproj b/backend/Platform.Identity/Platform.Identity.csproj new file mode 100644 index 0000000..890cce9 --- /dev/null +++ b/backend/Platform.Identity/Platform.Identity.csproj @@ -0,0 +1,14 @@ + + + net10.0 + enable + enable + + + + + + + + + \ No newline at end of file From 1b3c1a8cf7e157e58e6b2ea6d8fb43050adeddf0 Mon Sep 17 00:00:00 2001 From: miticcantonija Date: Mon, 2 Nov 2026 19:23:12 +0100 Subject: [PATCH 2/6] PR71-2023: IDN-01 Baza i migracije --- .env.example | 3 - backend/Platform.Api/Platform.Api.csproj | 6 + .../IdentityServiceCollectionExtensions.cs | 3 +- ...20261102181215_InitialAccounts.Designer.cs | 194 ++++++++++++++++++ .../20261102181215_InitialAccounts.cs | 160 +++++++++++++++ .../AccountDbContextModelSnapshot.cs | 191 +++++++++++++++++ 6 files changed, 553 insertions(+), 4 deletions(-) delete mode 100644 .env.example create mode 100644 backend/Platform.Identity/Infrastructure/Persistence/Migrations/20261102181215_InitialAccounts.Designer.cs create mode 100644 backend/Platform.Identity/Infrastructure/Persistence/Migrations/20261102181215_InitialAccounts.cs create mode 100644 backend/Platform.Identity/Infrastructure/Persistence/Migrations/AccountDbContextModelSnapshot.cs diff --git a/.env.example b/.env.example deleted file mode 100644 index 4365875..0000000 --- a/.env.example +++ /dev/null @@ -1,3 +0,0 @@ -POSTGRES_USER=platform -POSTGRES_DB=platform -POSTGRES_PASSWORD=CHANGE_ME_FOR_LOCAL_DEVELOPMENT diff --git a/backend/Platform.Api/Platform.Api.csproj b/backend/Platform.Api/Platform.Api.csproj index f985bf4..3661911 100644 --- a/backend/Platform.Api/Platform.Api.csproj +++ b/backend/Platform.Api/Platform.Api.csproj @@ -9,4 +9,10 @@ + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + diff --git a/backend/Platform.Identity/Composition/IdentityServiceCollectionExtensions.cs b/backend/Platform.Identity/Composition/IdentityServiceCollectionExtensions.cs index 5b0a4f9..45dd7bc 100644 --- a/backend/Platform.Identity/Composition/IdentityServiceCollectionExtensions.cs +++ b/backend/Platform.Identity/Composition/IdentityServiceCollectionExtensions.cs @@ -13,7 +13,8 @@ public static IServiceCollection AddIdentityModule(this IServiceCollection servi { services.AddDbContext((sp, options) => options.UseNpgsql( - sp.GetRequiredService().GetConnectionString("Identity"))); + sp.GetRequiredService().GetConnectionString("Identity"), + npgsql => npgsql.MigrationsHistoryTable("__EFMigrationsHistory", "identity"))); services.AddIdentityCore(options => { diff --git a/backend/Platform.Identity/Infrastructure/Persistence/Migrations/20261102181215_InitialAccounts.Designer.cs b/backend/Platform.Identity/Infrastructure/Persistence/Migrations/20261102181215_InitialAccounts.Designer.cs new file mode 100644 index 0000000..5a3fc97 --- /dev/null +++ b/backend/Platform.Identity/Infrastructure/Persistence/Migrations/20261102181215_InitialAccounts.Designer.cs @@ -0,0 +1,194 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Platform.Identity.Infrastructure.Persistence; + +#nullable disable + +namespace Platform.Identity.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AccountDbContext))] + [Migration("20261102181215_InitialAccounts")] + partial class InitialAccounts + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("identity") + .HasAnnotation("ProductVersion", "10.0.14") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + 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") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", "identity"); + }); + + 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") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", "identity"); + }); + + modelBuilder.Entity("Platform.Identity.Domain.Models.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + 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("Status") + .HasColumnType("integer"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .IsUnique() + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Platform.Identity.Domain.Models.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Platform.Identity.Domain.Models.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Platform.Identity.Domain.Models.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Platform.Identity/Infrastructure/Persistence/Migrations/20261102181215_InitialAccounts.cs b/backend/Platform.Identity/Infrastructure/Persistence/Migrations/20261102181215_InitialAccounts.cs new file mode 100644 index 0000000..82d707c --- /dev/null +++ b/backend/Platform.Identity/Infrastructure/Persistence/Migrations/20261102181215_InitialAccounts.cs @@ -0,0 +1,160 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Platform.Identity.Infrastructure.Persistence.Migrations +{ + /// + public partial class InitialAccounts : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "identity"); + + migrationBuilder.CreateTable( + name: "AspNetUsers", + schema: "identity", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Status = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", 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: "AspNetUserClaims", + schema: "identity", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column(type: "uuid", 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, + principalSchema: "identity", + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserLogins", + schema: "identity", + 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: "uuid", 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, + principalSchema: "identity", + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserTokens", + schema: "identity", + columns: table => new + { + UserId = table.Column(type: "uuid", 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, + principalSchema: "identity", + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserClaims_UserId", + schema: "identity", + table: "AspNetUserClaims", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserLogins_UserId", + schema: "identity", + table: "AspNetUserLogins", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + schema: "identity", + table: "AspNetUsers", + column: "NormalizedEmail", + unique: true); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + schema: "identity", + table: "AspNetUsers", + column: "NormalizedUserName", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AspNetUserClaims", + schema: "identity"); + + migrationBuilder.DropTable( + name: "AspNetUserLogins", + schema: "identity"); + + migrationBuilder.DropTable( + name: "AspNetUserTokens", + schema: "identity"); + + migrationBuilder.DropTable( + name: "AspNetUsers", + schema: "identity"); + } + } +} diff --git a/backend/Platform.Identity/Infrastructure/Persistence/Migrations/AccountDbContextModelSnapshot.cs b/backend/Platform.Identity/Infrastructure/Persistence/Migrations/AccountDbContextModelSnapshot.cs new file mode 100644 index 0000000..50ac398 --- /dev/null +++ b/backend/Platform.Identity/Infrastructure/Persistence/Migrations/AccountDbContextModelSnapshot.cs @@ -0,0 +1,191 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Platform.Identity.Infrastructure.Persistence; + +#nullable disable + +namespace Platform.Identity.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AccountDbContext))] + partial class AccountDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("identity") + .HasAnnotation("ProductVersion", "10.0.14") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + 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") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", "identity"); + }); + + 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") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", "identity"); + }); + + modelBuilder.Entity("Platform.Identity.Domain.Models.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + 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("Status") + .HasColumnType("integer"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .IsUnique() + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Platform.Identity.Domain.Models.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Platform.Identity.Domain.Models.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Platform.Identity.Domain.Models.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} From 805be099148d8dfb6c46f86a678cd7ef170d568d Mon Sep 17 00:00:00 2001 From: milicadukic15 Date: Mon, 2 Nov 2026 18:32:30 +0000 Subject: [PATCH 3/6] IDN-01 Kreiranje naloga servis --- .../Application/AccountRegistrationService.cs | 79 +++++++++++++++++++ .../IdentityServiceCollectionExtensions.cs | 4 + 2 files changed, 83 insertions(+) create mode 100644 backend/Platform.Identity/Application/AccountRegistrationService.cs diff --git a/backend/Platform.Identity/Application/AccountRegistrationService.cs b/backend/Platform.Identity/Application/AccountRegistrationService.cs new file mode 100644 index 0000000..f8bb0c3 --- /dev/null +++ b/backend/Platform.Identity/Application/AccountRegistrationService.cs @@ -0,0 +1,79 @@ +using System.Net.Mail; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using Platform.Identity.Contracts; +using Platform.Identity.Domain.Models; + +namespace Platform.Identity.Application; + +public sealed class AccountRegistrationService(UserManager userManager) + : IAccountRegistration +{ + private const int MaxEmailLength = 254; + private const int MaxPasswordLength = 128; + + public async ValueTask RegisterAsync( + RegisterAccountCommand command, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(command); + cancellationToken.ThrowIfCancellationRequested(); + + var email = (command.Email ?? string.Empty).Trim().ToLowerInvariant(); + if (!IsValidEmail(email)) + { + return RegisterAccountResult.Invalid("Email adresa nije validna."); + } + + if (string.IsNullOrEmpty(command.Password)) + { + return RegisterAccountResult.Invalid("Lozinka je obavezna."); + } + + if (command.Password.Length > MaxPasswordLength) + { + return RegisterAccountResult.Invalid( + $"Lozinka može imati najviše {MaxPasswordLength} karaktera."); + } + + var user = new AppUser + { + Id = Guid.NewGuid(), + UserName = email, + Email = email + }; + + try + { + var result = await userManager.CreateAsync(user, command.Password); + + if (result.Succeeded) + { + return RegisterAccountResult.Created(user.Id); + } + + if (result.Errors.Any(e => + e.Code is nameof(IdentityErrorDescriber.DuplicateEmail) + or nameof(IdentityErrorDescriber.DuplicateUserName))) + { + return RegisterAccountResult.Taken(); + } + + return RegisterAccountResult.Invalid( + result.Errors.Select(e => e.Description).ToArray()); + } + catch (DbUpdateException ex) + when (ex.InnerException is PostgresException + { SqlState: PostgresErrorCodes.UniqueViolation }) + { + // Trka: dva istovremena zahteva za isti email, unique indeks u bazi je poslednja linija odbrane. + return RegisterAccountResult.Taken(); + } + } + + private static bool IsValidEmail(string email) => + email.Length is > 0 and <= MaxEmailLength + && MailAddress.TryCreate(email, out var parsed) + && parsed.Address == email; +} \ No newline at end of file diff --git a/backend/Platform.Identity/Composition/IdentityServiceCollectionExtensions.cs b/backend/Platform.Identity/Composition/IdentityServiceCollectionExtensions.cs index 45dd7bc..d0fc347 100644 --- a/backend/Platform.Identity/Composition/IdentityServiceCollectionExtensions.cs +++ b/backend/Platform.Identity/Composition/IdentityServiceCollectionExtensions.cs @@ -4,6 +4,8 @@ using Microsoft.Extensions.DependencyInjection; using Platform.Identity.Domain.Models; using Platform.Identity.Infrastructure.Persistence; +using Platform.Identity.Application; +using Platform.Identity.Contracts; namespace Platform.Identity.Composition; @@ -22,6 +24,8 @@ public static IServiceCollection AddIdentityModule(this IServiceCollection servi options.Password.RequiredLength = 12; }) .AddEntityFrameworkStores(); + + services.AddScoped(); return services; } From 98175748b5aa99c35a170f6333193775c7c60f4b Mon Sep 17 00:00:00 2001 From: andjelajavor Date: Mon, 2 Nov 2026 20:30:27 +0100 Subject: [PATCH 4/6] IDN-01-Testovi-kreiranja-naloga --- .../AccountRegistrationTests.cs | 184 ++++++++++++++++++ .../Platform.Identity.Tests.csproj | 21 ++ 2 files changed, 205 insertions(+) create mode 100644 backend/Platform.Identity.Tests/AccountRegistrationTests.cs create mode 100644 backend/Platform.Identity.Tests/Platform.Identity.Tests.csproj diff --git a/backend/Platform.Identity.Tests/AccountRegistrationTests.cs b/backend/Platform.Identity.Tests/AccountRegistrationTests.cs new file mode 100644 index 0000000..5ac08f8 --- /dev/null +++ b/backend/Platform.Identity.Tests/AccountRegistrationTests.cs @@ -0,0 +1,184 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Platform.Identity.Composition; +using Platform.Identity.Contracts; +using Platform.Identity.Domain.Enums; +using Platform.Identity.Domain.Models; +using Platform.Identity.Infrastructure.Persistence; +using Testcontainers.PostgreSql; + +namespace Platform.Identity.Tests; + +[TestFixture] +public sealed class AccountRegistrationTests +{ + private const string StrongPassword = "Str0ng-Password!2026"; + + private PostgreSqlContainer _db = null!; + private ServiceProvider _provider = null!; + + [OneTimeSetUp] + public async Task StartDatabase() + { + _db = new PostgreSqlBuilder("postgres:17-alpine").Build(); + await _db.StartAsync(); + + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:Identity"] = _db.GetConnectionString() + }) + .Build(); + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(config); + services.AddIdentityModule(); + _provider = services.BuildServiceProvider(); + + using var scope = _provider.CreateScope(); + await scope.ServiceProvider.GetRequiredService() + .Database.MigrateAsync(); + } + + [OneTimeTearDown] + public async Task StopDatabase() + { + await _provider.DisposeAsync(); + await _db.DisposeAsync(); + } + + [Test] + public async Task Valid_registration_creates_active_account_with_hashed_password() + { + var email = $"Mixed.{Guid.NewGuid():N}@Example.com"; + + var result = await RegisterAsync(email, StrongPassword); + + Assert.That(result.Outcome, Is.EqualTo(RegistrationOutcome.Created)); + Assert.That(result.AccountId, Is.Not.Null); + + var user = await FindByIdAsync(result.AccountId!.Value); + Assert.That(user.Email, Is.EqualTo(email.ToLowerInvariant())); + Assert.That(user.Status, Is.EqualTo(AccountStatus.Active)); + Assert.That(user.PasswordHash, Is.Not.Null.And.Not.EqualTo(StrongPassword)); + } + + [TestCase("")] + [TestCase(" ")] + [TestCase("not-an-email")] + [TestCase("a@")] + [TestCase("@example.com")] + [TestCase("two@@example.com")] + public async Task Invalid_email_is_rejected(string email) + { + var result = await RegisterAsync(email, StrongPassword); + + Assert.That(result.Outcome, Is.EqualTo(RegistrationOutcome.InvalidInput)); + Assert.That(result.AccountId, Is.Null); + } + + [TestCase("")] + [TestCase("short1!A")] + [TestCase("alllowercase-password-123")] + public async Task Weak_password_is_rejected_and_no_account_is_created(string password) + { + var email = NewEmail(); + + var result = await RegisterAsync(email, password); + + Assert.That(result.Outcome, Is.EqualTo(RegistrationOutcome.InvalidInput)); + Assert.That(await CountByEmailAsync(email), Is.EqualTo(0)); + } + + [Test] + public async Task Duplicate_email_is_rejected() + { + var email = NewEmail(); + + var first = await RegisterAsync(email, StrongPassword); + var second = await RegisterAsync(email, StrongPassword); + + Assert.That(first.Outcome, Is.EqualTo(RegistrationOutcome.Created)); + Assert.That(second.Outcome, Is.EqualTo(RegistrationOutcome.IdentifierTaken)); + Assert.That(await CountByEmailAsync(email), Is.EqualTo(1)); + } + + [Test] + public async Task Duplicate_email_differing_only_in_case_or_whitespace_is_rejected() + { + var email = NewEmail(); + + await RegisterAsync(email, StrongPassword); + var second = await RegisterAsync($" {email.ToUpperInvariant()} ", StrongPassword); + + Assert.That(second.Outcome, Is.EqualTo(RegistrationOutcome.IdentifierTaken)); + Assert.That(await CountByEmailAsync(email), Is.EqualTo(1)); + } + + [Test] + public async Task Identifier_of_archived_account_cannot_be_reused() + { + var email = NewEmail(); + var first = await RegisterAsync(email, StrongPassword); + + using (var scope = _provider.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var user = await db.Users.SingleAsync(u => u.Id == first.AccountId); + user.Status = AccountStatus.Archived; + await db.SaveChangesAsync(); + } + + var second = await RegisterAsync(email, StrongPassword); + + Assert.That(second.Outcome, Is.EqualTo(RegistrationOutcome.IdentifierTaken)); + Assert.That(await CountByEmailAsync(email), Is.EqualTo(1)); + } + + [Test] + public async Task Concurrent_registrations_of_same_email_create_exactly_one_account() + { + var email = NewEmail(); + + var results = await Task.WhenAll( + Enumerable.Range(0, 5).Select(_ => Task.Run(() => RegisterAsync(email, StrongPassword)))); + + Assert.That(results.Count(r => r.Outcome == RegistrationOutcome.Created), Is.EqualTo(1)); + Assert.That(results.Count(r => r.Outcome == RegistrationOutcome.IdentifierTaken), Is.EqualTo(4)); + Assert.That(await CountByEmailAsync(email), Is.EqualTo(1)); + } + + [Test] + public void Command_ToString_does_not_leak_password() + { + var command = new RegisterAccountCommand("someone@example.com", StrongPassword); + + Assert.That(command.ToString(), Does.Not.Contain(StrongPassword)); + } + + private static string NewEmail() => $"user-{Guid.NewGuid():N}@example.com"; + + private async Task RegisterAsync(string email, string password) + { + using var scope = _provider.CreateScope(); + var service = scope.ServiceProvider.GetRequiredService(); + return await service.RegisterAsync(new RegisterAccountCommand(email, password)); + } + + private async Task FindByIdAsync(Guid id) + { + using var scope = _provider.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return await db.Users.SingleAsync(u => u.Id == id); + } + + private async Task CountByEmailAsync(string email) + { + using var scope = _provider.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var normalized = email.Trim().ToUpperInvariant(); + return await db.Users.CountAsync(u => u.NormalizedEmail == normalized); + } +} \ No newline at end of file diff --git a/backend/Platform.Identity.Tests/Platform.Identity.Tests.csproj b/backend/Platform.Identity.Tests/Platform.Identity.Tests.csproj new file mode 100644 index 0000000..a4354a5 --- /dev/null +++ b/backend/Platform.Identity.Tests/Platform.Identity.Tests.csproj @@ -0,0 +1,21 @@ + + + net10.0 + enable + enable + false + true + + + + + + + + + + + + + + \ No newline at end of file From 945dc4d7b5ea5cb50fc0504c23f8e3927f94221d Mon Sep 17 00:00:00 2001 From: miticcantonija Date: Mon, 2 Nov 2026 20:46:05 +0100 Subject: [PATCH 5/6] IDN-01 Admin endpoint + access policy --- .../Endpoints/AccountEndpoints.cs | 77 +++++++++++++++++++ backend/Platform.Api/Program.cs | 15 +++- .../Security/DevelopmentSubjectResolver.cs | 13 ++++ .../Security/IRequestSubjectResolver.cs | 8 ++ .../UnauthenticatedSubjectResolver.cs | 8 ++ .../Application/CreateAccountPolicy.cs | 24 ++++++ .../Composition/IdentityAccessRegistration.cs | 22 ++++++ .../Platform.Identity.csproj | 3 + 8 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 backend/Platform.Api/Endpoints/AccountEndpoints.cs create mode 100644 backend/Platform.Api/Security/DevelopmentSubjectResolver.cs create mode 100644 backend/Platform.Api/Security/IRequestSubjectResolver.cs create mode 100644 backend/Platform.Api/Security/UnauthenticatedSubjectResolver.cs create mode 100644 backend/Platform.Identity/Application/CreateAccountPolicy.cs create mode 100644 backend/Platform.Identity/Composition/IdentityAccessRegistration.cs diff --git a/backend/Platform.Api/Endpoints/AccountEndpoints.cs b/backend/Platform.Api/Endpoints/AccountEndpoints.cs new file mode 100644 index 0000000..8ef6d19 --- /dev/null +++ b/backend/Platform.Api/Endpoints/AccountEndpoints.cs @@ -0,0 +1,77 @@ +using Platform.Access.Contracts; +using Platform.Access.Domain.Enums; +using Platform.Access.Domain.Models; +using Platform.Api.Security; +using Platform.Identity.Application; +using Platform.Identity.Contracts; +using Platform.Identity.Domain.Enums; +using Platform.Identity.Domain.Models; + +namespace Platform.Api.Endpoints; + +public sealed record CreateAccountRequest(string? Email, string? Password) +{ + public override string ToString() => "CreateAccountRequest { *** }"; +} + +public static class AccountEndpoints +{ + public static IEndpointRouteBuilder MapAccountEndpoints(this IEndpointRouteBuilder app) + { + app.MapPost("/api/admin/accounts", CreateAccountAsync); + return app; + } + + private static async Task CreateAccountAsync( + CreateAccountRequest? body, + HttpContext context, + IRequestSubjectResolver subjectResolver, + IAccessDecision accessDecision, + IAccountRegistration registration, + CancellationToken cancellationToken) + { + var correlationId = Guid.NewGuid().ToString("N"); + + var decision = await accessDecision.DecideAsync( + new AccessRequest( + subjectResolver.Resolve(context), + CreateAccountPolicy.ActionId, + null, + correlationId), + cancellationToken); + + if (decision.Outcome != AccessOutcome.Allow) + { + return decision.DenialReason == AccessDenialReason.Unauthenticated + ? Results.Json(new { error = "unauthenticated", correlationId }, + statusCode: StatusCodes.Status401Unauthorized) + : Results.Json(new { error = "forbidden", correlationId }, + statusCode: StatusCodes.Status403Forbidden); + } + + if (body is null) + { + return Results.BadRequest(new + { + error = "invalid_input", + errors = new[] { "Telo zahteva je obavezno." }, + correlationId + }); + } + + var result = await registration.RegisterAsync( + new RegisterAccountCommand(body.Email ?? string.Empty, body.Password ?? string.Empty), + cancellationToken); + + return result.Outcome switch + { + RegistrationOutcome.Created => Results.Created( + $"/api/admin/accounts/{result.AccountId}", + new { id = result.AccountId, correlationId }), + RegistrationOutcome.IdentifierTaken => Results.Conflict( + new { error = "identifier_taken", correlationId }), + _ => Results.BadRequest( + new { error = "invalid_input", errors = result.Errors, correlationId }) + }; + } +} \ No newline at end of file diff --git a/backend/Platform.Api/Program.cs b/backend/Platform.Api/Program.cs index 6a5077f..5050d52 100644 --- a/backend/Platform.Api/Program.cs +++ b/backend/Platform.Api/Program.cs @@ -3,12 +3,25 @@ using Platform.Access.Domain.Enums; using Platform.Access.Domain.Models; using Platform.Identity.Composition; +using Platform.Api.Endpoints; +using Platform.Api.Security; var builder = WebApplication.CreateBuilder(args); builder.Services.AddAccessControl(); builder.Services.AddIdentityModule(); -var app = builder.Build(); +if (builder.Environment.IsDevelopment()) +{ + builder.Services.AddSingleton(); +} +else +{ + builder.Services.AddSingleton(); +} + +var app = builder.Build(); +app.Services.RegisterIdentityAccess(); +app.MapAccountEndpoints(); // Starter endpoint only. Add domain APIs together with server-side authorization. app.MapGet("/api/health", () => Results.Ok(new { diff --git a/backend/Platform.Api/Security/DevelopmentSubjectResolver.cs b/backend/Platform.Api/Security/DevelopmentSubjectResolver.cs new file mode 100644 index 0000000..d2fae65 --- /dev/null +++ b/backend/Platform.Api/Security/DevelopmentSubjectResolver.cs @@ -0,0 +1,13 @@ +using Platform.Access.Domain.Enums; +using Platform.Access.Domain.Models; + +namespace Platform.Api.Security; + +public sealed class DevelopmentSubjectResolver(IConfiguration configuration) : IRequestSubjectResolver +{ + public SubjectRef? Resolve(HttpContext context) + { + var id = configuration["Identity:DevSubjectId"]; + return string.IsNullOrWhiteSpace(id) ? null : new SubjectRef(id, AccountState.Active); + } +} \ No newline at end of file diff --git a/backend/Platform.Api/Security/IRequestSubjectResolver.cs b/backend/Platform.Api/Security/IRequestSubjectResolver.cs new file mode 100644 index 0000000..2a68af9 --- /dev/null +++ b/backend/Platform.Api/Security/IRequestSubjectResolver.cs @@ -0,0 +1,8 @@ +using Platform.Access.Domain.Models; + +namespace Platform.Api.Security; + +public interface IRequestSubjectResolver +{ + SubjectRef? Resolve(HttpContext context); +} \ No newline at end of file diff --git a/backend/Platform.Api/Security/UnauthenticatedSubjectResolver.cs b/backend/Platform.Api/Security/UnauthenticatedSubjectResolver.cs new file mode 100644 index 0000000..250fa93 --- /dev/null +++ b/backend/Platform.Api/Security/UnauthenticatedSubjectResolver.cs @@ -0,0 +1,8 @@ +using Platform.Access.Domain.Models; + +namespace Platform.Api.Security; + +public sealed class UnauthenticatedSubjectResolver : IRequestSubjectResolver +{ + public SubjectRef? Resolve(HttpContext context) => null; +} \ No newline at end of file diff --git a/backend/Platform.Identity/Application/CreateAccountPolicy.cs b/backend/Platform.Identity/Application/CreateAccountPolicy.cs new file mode 100644 index 0000000..67ff7b9 --- /dev/null +++ b/backend/Platform.Identity/Application/CreateAccountPolicy.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.Configuration; +using Platform.Access.Contracts; +using Platform.Access.Domain.Models; + +namespace Platform.Identity.Application; + +public sealed class CreateAccountPolicy(IConfiguration configuration) : IAccessPolicy +{ + public const string ActionId = "identity.account.create"; + + public string Id => "identity.account.create.admin-only"; + public string Version => "v1"; + + public ValueTask EvaluateAsync( + AccessRequest request, + CancellationToken cancellationToken = default) + { + var admins = configuration.GetSection("Identity:AdminAccountIds").Get() ?? []; + var allowed = request.Subject is not null + && admins.Contains(request.Subject.Id, StringComparer.Ordinal); + + return ValueTask.FromResult(allowed); + } +} \ No newline at end of file diff --git a/backend/Platform.Identity/Composition/IdentityAccessRegistration.cs b/backend/Platform.Identity/Composition/IdentityAccessRegistration.cs new file mode 100644 index 0000000..615c0a3 --- /dev/null +++ b/backend/Platform.Identity/Composition/IdentityAccessRegistration.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Platform.Access.Infrastructure.Catalog; +using Platform.Access.Infrastructure.Policies; +using Platform.Identity.Application; + +namespace Platform.Identity.Composition; + +public static class IdentityAccessRegistration +{ + public static IServiceProvider RegisterIdentityAccess(this IServiceProvider services) + { + var configuration = services.GetRequiredService(); + + services.GetRequiredService() + .Register(CreateAccountPolicy.ActionId); + services.GetRequiredService() + .Register(CreateAccountPolicy.ActionId, new CreateAccountPolicy(configuration)); + + return services; + } +} \ No newline at end of file diff --git a/backend/Platform.Identity/Platform.Identity.csproj b/backend/Platform.Identity/Platform.Identity.csproj index 890cce9..615e5f8 100644 --- a/backend/Platform.Identity/Platform.Identity.csproj +++ b/backend/Platform.Identity/Platform.Identity.csproj @@ -11,4 +11,7 @@ + + + \ No newline at end of file From e9fb3eb0d3551f151c28115f90d8eb04050589b6 Mon Sep 17 00:00:00 2001 From: Jovana Date: Mon, 2 Nov 2026 21:07:54 +0100 Subject: [PATCH 6/6] IDN-01 forma i dokumentacija --- frontend/src/App.tsx | 2 + frontend/src/api/accounts.ts | 44 ++++++++ frontend/src/components/CreateAccountForm.tsx | 100 ++++++++++++++++++ frontend/src/style.css | 11 ++ 4 files changed, 157 insertions(+) create mode 100644 frontend/src/api/accounts.ts create mode 100644 frontend/src/components/CreateAccountForm.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 634821c..4d639f9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react' +import CreateAccountForm from './components/CreateAccountForm' export default function App() { const [health, setHealth] = useState('Checking API…') @@ -12,5 +13,6 @@ export default function App() {

Security Lab

An interactive laboratory platform for the Fundamentals of Information Security course. Develop features through the assigned project components.

Development status

{health}

KT1: initial implementation, component architecture, and data flow diagram.

+

Kreiranje naloga (administrator)

} diff --git a/frontend/src/api/accounts.ts b/frontend/src/api/accounts.ts new file mode 100644 index 0000000..c2ebda6 --- /dev/null +++ b/frontend/src/api/accounts.ts @@ -0,0 +1,44 @@ +export type CreateAccountOutcome = + | { kind: 'created'; id: string; correlationId?: string } + | { kind: 'taken'; correlationId?: string } + | { kind: 'invalid'; errors: string[]; correlationId?: string } + | { kind: 'unauthenticated'; correlationId?: string } + | { kind: 'forbidden'; correlationId?: string } + | { kind: 'error' } + +interface ApiBody { + id?: string + errors?: string[] + correlationId?: string +} + +export async function createAccount(email: string, password: string): Promise { + let res: Response + try { + res = await fetch('/api/admin/accounts', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }) + } catch { + return { kind: 'error' } + } + + const body = (await res.json().catch(() => null)) as ApiBody | null + const correlationId = body?.correlationId + + switch (res.status) { + case 201: + return body?.id ? { kind: 'created', id: body.id, correlationId } : { kind: 'error' } + case 400: + return { kind: 'invalid', errors: body?.errors ?? ['Zahtev nije validan.'], correlationId } + case 401: + return { kind: 'unauthenticated', correlationId } + case 403: + return { kind: 'forbidden', correlationId } + case 409: + return { kind: 'taken', correlationId } + default: + return { kind: 'error' } + } +} \ No newline at end of file diff --git a/frontend/src/components/CreateAccountForm.tsx b/frontend/src/components/CreateAccountForm.tsx new file mode 100644 index 0000000..f74c744 --- /dev/null +++ b/frontend/src/components/CreateAccountForm.tsx @@ -0,0 +1,100 @@ +import { useState } from 'react' +import type { FormEvent } from 'react' +import { createAccount } from '../api/accounts' +import type { CreateAccountOutcome } from '../api/accounts' + +const MIN_PASSWORD_LENGTH = 12 +const MAX_PASSWORD_LENGTH = 128 +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + +function messageFor(outcome: CreateAccountOutcome): { ok: boolean; lines: string[] } { + switch (outcome.kind) { + case 'created': + return { ok: true, lines: ['Nalog je uspešno kreiran.'] } + case 'taken': + return { ok: false, lines: ['Nalog sa ovom email adresom već postoji.'] } + case 'invalid': + return { ok: false, lines: outcome.errors } + case 'unauthenticated': + return { ok: false, lines: ['Niste prijavljeni.'] } + case 'forbidden': + return { ok: false, lines: ['Nemate dozvolu za kreiranje naloga.'] } + default: + return { ok: false, lines: ['Došlo je do greške. Pokušajte ponovo.'] } + } +} + +export default function CreateAccountForm() { + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [submitting, setSubmitting] = useState(false) + const [result, setResult] = useState<{ ok: boolean; lines: string[]; correlationId?: string } | null>(null) + + async function handleSubmit(event: FormEvent) { + event.preventDefault() + + // Samo korisnička pomoć: server je jedini koji stvarno validira. + const trimmed = email.trim() + const problems: string[] = [] + if (!EMAIL_PATTERN.test(trimmed)) problems.push('Unesite ispravnu email adresu.') + if (password.length < MIN_PASSWORD_LENGTH) { + problems.push(`Lozinka mora imati najmanje ${MIN_PASSWORD_LENGTH} karaktera.`) + } + if (problems.length > 0) { + setResult({ ok: false, lines: problems }) + return + } + + setSubmitting(true) + setResult(null) + const outcome = await createAccount(trimmed, password) + setPassword('') // lozinka se ne zadržava u stanju posle slanja + if (outcome.kind === 'created') setEmail('') + setResult({ + ...messageFor(outcome), + correlationId: 'correlationId' in outcome ? outcome.correlationId : undefined, + }) + setSubmitting(false) + } + + return ( +
+ + setEmail(e.target.value)} + required + /> + + + setPassword(e.target.value)} + aria-describedby="account-password-hint" + required + /> +

+ Najmanje {MIN_PASSWORD_LENGTH} karaktera, sa velikim i malim slovom, brojem i specijalnim znakom. +

+ + + + {result && ( +
+ {result.lines.map(line =>

{line}

)} + {result.correlationId &&

ID zahteva: {result.correlationId}

} +
+ )} +
+ ) +} \ No newline at end of file diff --git a/frontend/src/style.css b/frontend/src/style.css index 1f28b1f..8038629 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -5,3 +5,14 @@ main { max-width: 900px; margin: 8vh auto; padding: 0 24px; } h1 { font-size: clamp(2rem, 5vw, 3rem); line-height: 1.15; } .eyebrow { font-weight: 700; letter-spacing: .12em; color: #315d8a; } section { background: white; border: 1px solid #e1e6ed; border-radius: 12px; padding: 22px; margin-top: 32px; } +form { display: grid; gap: 8px; max-width: 420px; } +label { font-weight: 600; margin-top: 8px; } +input { padding: 10px 12px; border: 1px solid #c5cfdb; border-radius: 8px; font: inherit; } +input:focus-visible, button:focus-visible { outline: 2px solid #315d8a; outline-offset: 2px; } +button { margin-top: 12px; padding: 10px 14px; border: 0; border-radius: 8px; background: #315d8a; color: white; font: inherit; font-weight: 600; cursor: pointer; } +button:disabled { opacity: .6; cursor: not-allowed; } +.hint { margin: 0; font-size: .875rem; color: #5b6b7f; } +.result { margin-top: 12px; padding: 10px 12px; border-radius: 8px; } +.result p { margin: 0 0 4px; } +.result.ok { background: #e8f5ec; border: 1px solid #9ad0a8; } +.result.error { background: #fdecec; border: 1px solid #e3a3a3; }