diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index d6b63a88..2074eb4e 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -14,7 +14,7 @@ jobs: uses: actions/setup-java@v4 with: java-version: 17 - distribution: 'zulu' + distribution: "zulu" - uses: actions/checkout@v4 with: fetch-depth: 0 @@ -43,7 +43,7 @@ jobs: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} shell: powershell run: | - .\.sonar\scanner\dotnet-sonarscanner begin /k:"AnalogIO_analog-core" /o:"analogio" /d:sonar.login="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths="coverageunit.xml,coverageintegration.xml" /d:sonar.exclusions="CoffeeCard.Library/Migrations/*" + .\.sonar\scanner\dotnet-sonarscanner begin /k:"AnalogIO_analog-core" /o:"analogio" /d:sonar.login="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths="coverageunit.xml,coverageintegration.xml" dotnet tool install --global coverlet.console @@ -52,4 +52,5 @@ jobs: coverlet ./coffeecard/CoffeeCard.Tests.Unit/bin/Debug/net8.0/CoffeeCard.Tests.Unit.dll --target "dotnet" --targetargs "test --no-build coffeecard/CoffeeCard.Tests.Unit" -f=opencover -o="coverageunit.xml" coverlet ./coffeecard/CoffeeCard.Tests.Integration/bin/Debug/net8.0/CoffeeCard.Tests.Integration.dll --target "dotnet" --targetargs "test --no-build coffeecard/CoffeeCard.Tests.Integration" -f=opencover -o="coverageintegration.xml" - .\.sonar\scanner\dotnet-sonarscanner end /d:sonar.login="${{ secrets.SONAR_TOKEN }}" \ No newline at end of file + .\.sonar\scanner\dotnet-sonarscanner end /d:sonar.login="${{ secrets.SONAR_TOKEN }}" + diff --git a/.vscode/settings.json b/.vscode/settings.json index c90c169a..78c51da9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,5 +3,9 @@ "yaml.schemas": { "https://json.schemastore.org/github-workflow.json": "file:///c%3A/code/private/analogio/analog-core/.github/actions/deploy.yml", "https://json.schemastore.org/github-action.json": "file:///c%3A/code/private/analogio/analog-core/.github/actions/core-sonarcloud.yml" + }, + "sonarlint.connectedMode.project": { + "connectionId": "Analog", + "projectKey": "AnalogIO_analog-core" } } \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Common/Configuration/EnvironmentSettings.cs b/coffeecard/CoffeeCard.Common/Configuration/EnvironmentSettings.cs index 5741fdd2..dbb7115f 100644 --- a/coffeecard/CoffeeCard.Common/Configuration/EnvironmentSettings.cs +++ b/coffeecard/CoffeeCard.Common/Configuration/EnvironmentSettings.cs @@ -11,6 +11,8 @@ public class EnvironmentSettings : IValidatable [Required] public string DeploymentUrl { get; set; } + [Required] public string ShiftyUrl { get; set; } + public void Validate() { Validator.ValidateObject(this, new ValidationContext(this), true); diff --git a/coffeecard/CoffeeCard.Library/Migrations/20241022153731_AddTokenProperties.Designer.cs b/coffeecard/CoffeeCard.Library/Migrations/20241022153731_AddTokenProperties.Designer.cs new file mode 100644 index 00000000..e6e5f7e1 --- /dev/null +++ b/coffeecard/CoffeeCard.Library/Migrations/20241022153731_AddTokenProperties.Designer.cs @@ -0,0 +1,663 @@ +// +using System; +using CoffeeCard.Library.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CoffeeCard.Library.Migrations +{ + [DbContext(typeof(CoffeeCardContext))] + [Migration("20241022153731_AddTokenProperties")] + partial class AddTokenProperties + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("dbo") + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CoffeeCard.Models.Entities.LoginAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Time") + .HasColumnType("datetime2"); + + b.Property("User_Id") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("User_Id"); + + b.ToTable("LoginAttempts", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.MenuItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Active") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("MenuItems", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.MenuItemProduct", b => + { + b.Property("MenuItemId") + .HasColumnType("int"); + + b.Property("ProductId") + .HasColumnType("int"); + + b.HasKey("MenuItemId", "ProductId"); + + b.HasIndex("ProductId"); + + b.ToTable("MenuItemProducts", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.PosPurhase", b => + { + b.Property("PurchaseId") + .HasColumnType("int"); + + b.Property("BaristaInitials") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("PurchaseId"); + + b.ToTable("PosPurchases", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ExperienceWorth") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("NumberOfTickets") + .HasColumnType("int"); + + b.Property("Price") + .HasColumnType("int"); + + b.Property("Visible") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.ToTable("Products", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.ProductUserGroup", b => + { + b.Property("ProductId") + .HasColumnType("int"); + + b.Property("UserGroup") + .HasColumnType("int"); + + b.HasKey("ProductId", "UserGroup"); + + b.ToTable("ProductUserGroups", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Programme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FullName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ShortName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("SortPriority") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("Programmes", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Purchase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DateCreated") + .HasColumnType("datetime2"); + + b.Property("ExternalTransactionId") + .HasColumnType("nvarchar(450)"); + + b.Property("NumberOfTickets") + .HasColumnType("int"); + + b.Property("OrderId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Price") + .HasColumnType("int"); + + b.Property("ProductId") + .HasColumnType("int"); + + b.Property("ProductName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("PurchasedById") + .HasColumnType("int") + .HasColumnName("PurchasedBy_Id"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Type") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ExternalTransactionId"); + + b.HasIndex("OrderId") + .IsUnique(); + + b.HasIndex("ProductId"); + + b.HasIndex("PurchasedById"); + + b.ToTable("Purchases", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Statistic", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ExpiryDate") + .HasColumnType("datetime2"); + + b.Property("LastSwipe") + .HasColumnType("datetime2"); + + b.Property("Preset") + .HasColumnType("int"); + + b.Property("SwipeCount") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int") + .HasColumnName("User_Id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Preset", "ExpiryDate"); + + b.ToTable("Statistics", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Ticket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DateCreated") + .HasColumnType("datetime2"); + + b.Property("DateUsed") + .HasColumnType("datetime2"); + + b.Property("IsUsed") + .HasColumnType("bit"); + + b.Property("OwnerId") + .HasColumnType("int") + .HasColumnName("Owner_Id"); + + b.Property("ProductId") + .HasColumnType("int"); + + b.Property("PurchaseId") + .HasColumnType("int") + .HasColumnName("Purchase_Id"); + + b.Property("UsedOnMenuItemId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.HasIndex("PurchaseId"); + + b.HasIndex("UsedOnMenuItemId"); + + b.ToTable("Tickets", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Token", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Expires") + .HasColumnType("datetime2"); + + b.Property("Revoked") + .HasColumnType("bit"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Type") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int") + .HasColumnName("User_Id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Tokens", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DateCreated") + .HasColumnType("datetime2"); + + b.Property("DateUpdated") + .HasColumnType("datetime2"); + + b.Property("Email") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Experience") + .HasColumnType("int"); + + b.Property("IsVerified") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Password") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("PrivacyActivated") + .HasColumnType("bit"); + + b.Property("ProgrammeId") + .HasColumnType("int") + .HasColumnName("Programme_Id"); + + b.Property("Salt") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UserGroup") + .HasColumnType("int"); + + b.Property("UserState") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("Name"); + + b.HasIndex("ProgrammeId"); + + b.HasIndex("UserGroup"); + + b.ToTable("Users", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Voucher", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("DateCreated") + .HasColumnType("datetime2"); + + b.Property("DateUsed") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("ProductId") + .HasColumnType("int") + .HasColumnName("Product_Id"); + + b.Property("PurchaseId") + .HasColumnType("int"); + + b.Property("Requester") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int") + .HasColumnName("User_Id"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("ProductId"); + + b.HasIndex("PurchaseId"); + + b.HasIndex("UserId"); + + b.ToTable("Vouchers", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.WebhookConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("LastUpdated") + .HasColumnType("datetime2"); + + b.Property("SignatureKey") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Url") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("WebhookConfigurations", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.LoginAttempt", b => + { + b.HasOne("CoffeeCard.Models.Entities.User", "User") + .WithMany("LoginAttempts") + .HasForeignKey("User_Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.MenuItemProduct", b => + { + b.HasOne("CoffeeCard.Models.Entities.MenuItem", "MenuItem") + .WithMany("MenuItemProducts") + .HasForeignKey("MenuItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CoffeeCard.Models.Entities.Product", "Product") + .WithMany("MenuItemProducts") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MenuItem"); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.PosPurhase", b => + { + b.HasOne("CoffeeCard.Models.Entities.Purchase", "Purchase") + .WithMany() + .HasForeignKey("PurchaseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Purchase"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.ProductUserGroup", b => + { + b.HasOne("CoffeeCard.Models.Entities.Product", "Product") + .WithMany("ProductUserGroup") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Purchase", b => + { + b.HasOne("CoffeeCard.Models.Entities.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CoffeeCard.Models.Entities.User", "PurchasedBy") + .WithMany("Purchases") + .HasForeignKey("PurchasedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + + b.Navigation("PurchasedBy"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Statistic", b => + { + b.HasOne("CoffeeCard.Models.Entities.User", "User") + .WithMany("Statistics") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Ticket", b => + { + b.HasOne("CoffeeCard.Models.Entities.User", "Owner") + .WithMany("Tickets") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("CoffeeCard.Models.Entities.Purchase", "Purchase") + .WithMany("Tickets") + .HasForeignKey("PurchaseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CoffeeCard.Models.Entities.MenuItem", "UsedOnMenuItem") + .WithMany() + .HasForeignKey("UsedOnMenuItemId"); + + b.Navigation("Owner"); + + b.Navigation("Purchase"); + + b.Navigation("UsedOnMenuItem"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Token", b => + { + b.HasOne("CoffeeCard.Models.Entities.User", "User") + .WithMany("Tokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.NoAction); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.User", b => + { + b.HasOne("CoffeeCard.Models.Entities.Programme", "Programme") + .WithMany("Users") + .HasForeignKey("ProgrammeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Programme"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Voucher", b => + { + b.HasOne("CoffeeCard.Models.Entities.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CoffeeCard.Models.Entities.Purchase", "Purchase") + .WithMany() + .HasForeignKey("PurchaseId"); + + b.HasOne("CoffeeCard.Models.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Product"); + + b.Navigation("Purchase"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.MenuItem", b => + { + b.Navigation("MenuItemProducts"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Product", b => + { + b.Navigation("MenuItemProducts"); + + b.Navigation("ProductUserGroup"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Programme", b => + { + b.Navigation("Users"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Purchase", b => + { + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.User", b => + { + b.Navigation("LoginAttempts"); + + b.Navigation("Purchases"); + + b.Navigation("Statistics"); + + b.Navigation("Tickets"); + + b.Navigation("Tokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/coffeecard/CoffeeCard.Library/Migrations/20241022153731_AddTokenProperties.cs b/coffeecard/CoffeeCard.Library/Migrations/20241022153731_AddTokenProperties.cs new file mode 100644 index 00000000..86117337 --- /dev/null +++ b/coffeecard/CoffeeCard.Library/Migrations/20241022153731_AddTokenProperties.cs @@ -0,0 +1,58 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CoffeeCard.Library.Migrations +{ + /// + public partial class AddTokenProperties : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Expires", + schema: "dbo", + table: "Tokens", + type: "datetime2", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "Revoked", + schema: "dbo", + table: "Tokens", + type: "bit", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "Type", + schema: "dbo", + table: "Tokens", + type: "nvarchar(max)", + nullable: false, + defaultValue: ""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Expires", + schema: "dbo", + table: "Tokens"); + + migrationBuilder.DropColumn( + name: "Revoked", + schema: "dbo", + table: "Tokens"); + + migrationBuilder.DropColumn( + name: "Type", + schema: "dbo", + table: "Tokens"); + } + } +} diff --git a/coffeecard/CoffeeCard.Library/Migrations/20241126213412_AddTokenHashIndex.Designer.cs b/coffeecard/CoffeeCard.Library/Migrations/20241126213412_AddTokenHashIndex.Designer.cs new file mode 100644 index 00000000..9507f06f --- /dev/null +++ b/coffeecard/CoffeeCard.Library/Migrations/20241126213412_AddTokenHashIndex.Designer.cs @@ -0,0 +1,668 @@ +// +using System; +using CoffeeCard.Library.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CoffeeCard.Library.Migrations +{ + [DbContext(typeof(CoffeeCardContext))] + [Migration("20241126213412_AddTokenHashIndex")] + partial class AddTokenHashIndex + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("dbo") + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CoffeeCard.Models.Entities.LoginAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Time") + .HasColumnType("datetime2"); + + b.Property("User_Id") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("User_Id"); + + b.ToTable("LoginAttempts", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.MenuItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Active") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("MenuItems", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.MenuItemProduct", b => + { + b.Property("MenuItemId") + .HasColumnType("int"); + + b.Property("ProductId") + .HasColumnType("int"); + + b.HasKey("MenuItemId", "ProductId"); + + b.HasIndex("ProductId"); + + b.ToTable("MenuItemProducts", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.PosPurhase", b => + { + b.Property("PurchaseId") + .HasColumnType("int"); + + b.Property("BaristaInitials") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("PurchaseId"); + + b.ToTable("PosPurchases", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ExperienceWorth") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("NumberOfTickets") + .HasColumnType("int"); + + b.Property("Price") + .HasColumnType("int"); + + b.Property("Visible") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.ToTable("Products", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.ProductUserGroup", b => + { + b.Property("ProductId") + .HasColumnType("int"); + + b.Property("UserGroup") + .HasColumnType("int"); + + b.HasKey("ProductId", "UserGroup"); + + b.ToTable("ProductUserGroups", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Programme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FullName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ShortName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("SortPriority") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("Programmes", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Purchase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DateCreated") + .HasColumnType("datetime2"); + + b.Property("ExternalTransactionId") + .HasColumnType("nvarchar(450)"); + + b.Property("NumberOfTickets") + .HasColumnType("int"); + + b.Property("OrderId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Price") + .HasColumnType("int"); + + b.Property("ProductId") + .HasColumnType("int"); + + b.Property("ProductName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("PurchasedById") + .HasColumnType("int") + .HasColumnName("PurchasedBy_Id"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Type") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ExternalTransactionId"); + + b.HasIndex("OrderId") + .IsUnique(); + + b.HasIndex("ProductId"); + + b.HasIndex("PurchasedById"); + + b.ToTable("Purchases", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Statistic", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ExpiryDate") + .HasColumnType("datetime2"); + + b.Property("LastSwipe") + .HasColumnType("datetime2"); + + b.Property("Preset") + .HasColumnType("int"); + + b.Property("SwipeCount") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int") + .HasColumnName("User_Id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Preset", "ExpiryDate"); + + b.ToTable("Statistics", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Ticket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DateCreated") + .HasColumnType("datetime2"); + + b.Property("DateUsed") + .HasColumnType("datetime2"); + + b.Property("IsUsed") + .HasColumnType("bit"); + + b.Property("OwnerId") + .HasColumnType("int") + .HasColumnName("Owner_Id"); + + b.Property("ProductId") + .HasColumnType("int"); + + b.Property("PurchaseId") + .HasColumnType("int") + .HasColumnName("Purchase_Id"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UsedOnMenuItemId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.HasIndex("PurchaseId"); + + b.HasIndex("UsedOnMenuItemId"); + + b.ToTable("Tickets", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Token", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Expires") + .HasColumnType("datetime2"); + + b.Property("Revoked") + .HasColumnType("bit"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Type") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int") + .HasColumnName("User_Id"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash"); + + b.HasIndex("UserId"); + + b.ToTable("Tokens", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DateCreated") + .HasColumnType("datetime2"); + + b.Property("DateUpdated") + .HasColumnType("datetime2"); + + b.Property("Email") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Experience") + .HasColumnType("int"); + + b.Property("IsVerified") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Password") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("PrivacyActivated") + .HasColumnType("bit"); + + b.Property("ProgrammeId") + .HasColumnType("int") + .HasColumnName("Programme_Id"); + + b.Property("Salt") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UserGroup") + .HasColumnType("int"); + + b.Property("UserState") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("Name"); + + b.HasIndex("ProgrammeId"); + + b.HasIndex("UserGroup"); + + b.ToTable("Users", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Voucher", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("DateCreated") + .HasColumnType("datetime2"); + + b.Property("DateUsed") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("ProductId") + .HasColumnType("int") + .HasColumnName("Product_Id"); + + b.Property("PurchaseId") + .HasColumnType("int"); + + b.Property("Requester") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int") + .HasColumnName("User_Id"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("ProductId"); + + b.HasIndex("PurchaseId"); + + b.HasIndex("UserId"); + + b.ToTable("Vouchers", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.WebhookConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("LastUpdated") + .HasColumnType("datetime2"); + + b.Property("SignatureKey") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Url") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("WebhookConfigurations", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.LoginAttempt", b => + { + b.HasOne("CoffeeCard.Models.Entities.User", "User") + .WithMany("LoginAttempts") + .HasForeignKey("User_Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.MenuItemProduct", b => + { + b.HasOne("CoffeeCard.Models.Entities.MenuItem", "MenuItem") + .WithMany("MenuItemProducts") + .HasForeignKey("MenuItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CoffeeCard.Models.Entities.Product", "Product") + .WithMany("MenuItemProducts") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MenuItem"); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.PosPurhase", b => + { + b.HasOne("CoffeeCard.Models.Entities.Purchase", "Purchase") + .WithMany() + .HasForeignKey("PurchaseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Purchase"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.ProductUserGroup", b => + { + b.HasOne("CoffeeCard.Models.Entities.Product", "Product") + .WithMany("ProductUserGroup") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Purchase", b => + { + b.HasOne("CoffeeCard.Models.Entities.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CoffeeCard.Models.Entities.User", "PurchasedBy") + .WithMany("Purchases") + .HasForeignKey("PurchasedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + + b.Navigation("PurchasedBy"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Statistic", b => + { + b.HasOne("CoffeeCard.Models.Entities.User", "User") + .WithMany("Statistics") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Ticket", b => + { + b.HasOne("CoffeeCard.Models.Entities.User", "Owner") + .WithMany("Tickets") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("CoffeeCard.Models.Entities.Purchase", "Purchase") + .WithMany("Tickets") + .HasForeignKey("PurchaseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CoffeeCard.Models.Entities.MenuItem", "UsedOnMenuItem") + .WithMany() + .HasForeignKey("UsedOnMenuItemId"); + + b.Navigation("Owner"); + + b.Navigation("Purchase"); + + b.Navigation("UsedOnMenuItem"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Token", b => + { + b.HasOne("CoffeeCard.Models.Entities.User", "User") + .WithMany("Tokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.NoAction); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.User", b => + { + b.HasOne("CoffeeCard.Models.Entities.Programme", "Programme") + .WithMany("Users") + .HasForeignKey("ProgrammeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Programme"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Voucher", b => + { + b.HasOne("CoffeeCard.Models.Entities.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CoffeeCard.Models.Entities.Purchase", "Purchase") + .WithMany() + .HasForeignKey("PurchaseId"); + + b.HasOne("CoffeeCard.Models.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Product"); + + b.Navigation("Purchase"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.MenuItem", b => + { + b.Navigation("MenuItemProducts"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Product", b => + { + b.Navigation("MenuItemProducts"); + + b.Navigation("ProductUserGroup"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Programme", b => + { + b.Navigation("Users"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Purchase", b => + { + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.User", b => + { + b.Navigation("LoginAttempts"); + + b.Navigation("Purchases"); + + b.Navigation("Statistics"); + + b.Navigation("Tickets"); + + b.Navigation("Tokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/coffeecard/CoffeeCard.Library/Migrations/20241126213412_AddTokenHashIndex.cs b/coffeecard/CoffeeCard.Library/Migrations/20241126213412_AddTokenHashIndex.cs new file mode 100644 index 00000000..eced786b --- /dev/null +++ b/coffeecard/CoffeeCard.Library/Migrations/20241126213412_AddTokenHashIndex.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CoffeeCard.Library.Migrations +{ + /// + public partial class AddTokenHashIndex : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "TokenHash", + schema: "dbo", + table: "Tokens", + type: "nvarchar(450)", + nullable: false, + oldClrType: typeof(string), + oldType: "nvarchar(max)"); + + migrationBuilder.CreateIndex( + name: "IX_Tokens_TokenHash", + schema: "dbo", + table: "Tokens", + column: "TokenHash"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Tokens_TokenHash", + schema: "dbo", + table: "Tokens"); + + migrationBuilder.AlterColumn( + name: "TokenHash", + schema: "dbo", + table: "Tokens", + type: "nvarchar(max)", + nullable: false, + oldClrType: typeof(string), + oldType: "nvarchar(450)"); + } + } +} diff --git a/coffeecard/CoffeeCard.Library/Migrations/CoffeeCardContextModelSnapshot.cs b/coffeecard/CoffeeCard.Library/Migrations/CoffeeCardContextModelSnapshot.cs index e3e7e979..f343108d 100644 --- a/coffeecard/CoffeeCard.Library/Migrations/CoffeeCardContextModelSnapshot.cs +++ b/coffeecard/CoffeeCard.Library/Migrations/CoffeeCardContextModelSnapshot.cs @@ -309,7 +309,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + b.Property("Expires") + .HasColumnType("datetime2"); + + b.Property("Revoked") + .HasColumnType("bit"); + b.Property("TokenHash") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Type") .IsRequired() .HasColumnType("nvarchar(max)"); @@ -319,6 +329,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("TokenHash"); + b.HasIndex("UserId"); b.ToTable("Tokens", "dbo"); @@ -573,7 +585,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) { b.HasOne("CoffeeCard.Models.Entities.User", "User") .WithMany("Tokens") - .HasForeignKey("UserId"); + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.NoAction); b.Navigation("User"); }); diff --git a/coffeecard/CoffeeCard.Library/Persistence/CoffeecardContext.cs b/coffeecard/CoffeeCard.Library/Persistence/CoffeecardContext.cs index 1ee05f1e..b41c10a2 100644 --- a/coffeecard/CoffeeCard.Library/Persistence/CoffeecardContext.cs +++ b/coffeecard/CoffeeCard.Library/Persistence/CoffeecardContext.cs @@ -59,6 +59,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) // Use Enum to String for PurchaseTypes var purchaseTypeStringConverter = new EnumToStringConverter(); + var tokenTypeStringConverter = new EnumToStringConverter(); + modelBuilder.Entity() .Property(u => u.UserGroup) .HasConversion(userGroupIntConverter); @@ -86,6 +88,22 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .WithMany(u => u.Tickets) .HasForeignKey(t => t.OwnerId) .OnDelete(DeleteBehavior.NoAction); + + modelBuilder.Entity() + .Property(t => t.Type) + .HasConversion(tokenTypeStringConverter); + + modelBuilder.Entity() + .HasOne(t => t.User) + .WithMany(u => u.Tokens) + .HasForeignKey(t => t.UserId) + .OnDelete(DeleteBehavior.NoAction); + + modelBuilder.Entity() + .Property(t => t.Expires).IsRequired(); + + modelBuilder.Entity() + .Property(t => t.TokenHash).IsRequired(); } } } \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Library/Services/AccountService.cs b/coffeecard/CoffeeCard.Library/Services/AccountService.cs index 0053375a..b33b55c0 100644 --- a/coffeecard/CoffeeCard.Library/Services/AccountService.cs +++ b/coffeecard/CoffeeCard.Library/Services/AccountService.cs @@ -238,7 +238,7 @@ public async Task ForgotPasswordAsync(string email) new Claim(ClaimTypes.Role, "verification_token") }; var verificationToken = _tokenService.GenerateToken(claims); - user.Tokens.Add(new Token(verificationToken)); + user.Tokens.Add(new Token(verificationToken, TokenType.ResetPassword)); _context.SaveChanges(); await _emailService.SendVerificationEmailForLostPwAsync(user, verificationToken); } diff --git a/coffeecard/CoffeeCard.Library/Services/TokenService.cs b/coffeecard/CoffeeCard.Library/Services/TokenService.cs index 938b4e8f..c7e653d0 100644 --- a/coffeecard/CoffeeCard.Library/Services/TokenService.cs +++ b/coffeecard/CoffeeCard.Library/Services/TokenService.cs @@ -7,7 +7,9 @@ using System.Threading.Tasks; using CoffeeCard.Common.Configuration; using CoffeeCard.Common.Errors; +using CoffeeCard.Library.Persistence; using CoffeeCard.Library.Utils; +using CoffeeCard.Models.DataTransferObjects.CoffeeCard; using CoffeeCard.Models.Entities; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; @@ -40,7 +42,6 @@ public string GenerateToken(IEnumerable claims) DateTime.UtcNow.AddHours(24), new SigningCredentials(key, SecurityAlgorithms.HmacSha256) ); - return new JwtSecurityTokenHandler().WriteToken(jwt); //the method is called WriteToken but returns a string } @@ -101,7 +102,7 @@ public async Task ValidateTokenIsUnusedAsync(string tokenString) var user = await _claimsUtilities.ValidateAndReturnUserFromEmailClaimAsync(token.Claims); - if (user.Tokens.Contains(new Token(tokenString))) tokenIsUnused = true; // Tokens are removed from the user on account recovery + if (user.Tokens.Any((e) => e.TokenHash == tokenString)) tokenIsUnused = true; // Tokens are removed from the user on account recovery return ValidateToken(tokenString) && tokenIsUnused; } diff --git a/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs b/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs index a2ceb2fb..c31288bd 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs @@ -16,16 +16,28 @@ namespace CoffeeCard.Library.Services.v2 public class AccountService : IAccountService { private readonly CoffeeCardContext _context; - private readonly IEmailService _emailService; - private readonly IHashService _hashService; - private readonly ITokenService _tokenService; + private readonly CoffeeCard.Library.Services.IEmailService _emailService; + private readonly CoffeeCard.Library.Services.v2.IEmailService _emailServiceV2; + private readonly CoffeeCard.Library.Services.IHashService _hashService; + private readonly CoffeeCard.Library.Services.ITokenService _tokenService; + private readonly CoffeeCard.Library.Services.v2.ITokenService _tokenServiceV2; private readonly ILogger _logger; - public AccountService(CoffeeCardContext context, ITokenService tokenService, IEmailService emailService, IHashService hashService, ILogger logger) + public AccountService( + CoffeeCardContext context, + CoffeeCard.Library.Services.ITokenService tokenService, + CoffeeCard.Library.Services.IEmailService emailService, + CoffeeCard.Library.Services.v2.IEmailService emailServiceV2, + CoffeeCard.Library.Services.v2.ITokenService tokenServiceV2, + CoffeeCard.Library.Services.IHashService hashService, + ILogger logger + ) { _context = context; _tokenService = tokenService; _emailService = emailService; + _emailServiceV2 = emailServiceV2; + _tokenServiceV2 = tokenServiceV2; _hashService = hashService; _logger = logger; } @@ -38,7 +50,7 @@ public async Task RegisterAccountAsync(string name, string email, string p { _logger.LogInformation("Could not register user Name: {name}. Email:{email} already exists", name, email); throw new ApiException($"The email {email} is already being used by another user", - StatusCodes.Status409Conflict); + StatusCodes.Status409Conflict); } var salt = _hashService.GenerateSalt(); @@ -224,9 +236,9 @@ public async Task SearchUsers(String search, int pageNum, in else { query = _context.Users - .Where(u => EF.Functions.Like(u.Id.ToString(), $"%{search}%") || - EF.Functions.Like(u.Name, $"%{search}%") || - EF.Functions.Like(u.Email, $"%{search}%")); + .Where(u => EF.Functions.Like(u.Id.ToString(), $"%{search}%") || + EF.Functions.Like(u.Name, $"%{search}%") || + EF.Functions.Like(u.Email, $"%{search}%")); } var totalUsers = await query.CountAsync(); @@ -291,5 +303,44 @@ await _context.Users .ExecuteUpdateAsync(u => u.SetProperty(u => u.UserGroup, item.UserGroup)); } } + + public async Task SendMagicLinkEmail(string email, LoginType loginType) + { + var user = await _context.Users + .Where(u => u.Email == email) + .FirstOrDefaultAsync(); + if (user is null) + { + // Should not throw error to prevent showing a malicious user if an email is already registered + return; + } + var magicLinkTokenHash = await _tokenServiceV2.GenerateMagicLink(user); + await _emailServiceV2.SendMagicLink(user, magicLinkTokenHash, loginType); + } + + public async Task GenerateUserLoginFromToken(string token) + { + // Validate token in DB + var foundToken = await _tokenServiceV2.GetValidTokenByHashAsync(token); + + // Invalidate token in DB + foundToken.Revoked = true; + await _context.SaveChangesAsync(); + + // Generate refresh token + var refreshToken = await _tokenServiceV2.GenerateRefreshTokenAsync(foundToken.User); + + var claims = new[] + { + new Claim(ClaimTypes.Email, foundToken.User!.Email), + new Claim(ClaimTypes.Name, foundToken.User.Name), + new Claim("UserId", foundToken.User.Id.ToString()), + new Claim(ClaimTypes.Role, foundToken.User.UserGroup.ToString()), + }; + // Generate JWT token with user claims + var jwt = _tokenService.GenerateToken(claims); + + return new UserLoginResponse() { Jwt = jwt, RefreshToken = refreshToken }; + } } -} \ No newline at end of file +} diff --git a/coffeecard/CoffeeCard.Library/Services/v2/EmailService.cs b/coffeecard/CoffeeCard.Library/Services/v2/EmailService.cs new file mode 100644 index 00000000..224906cc --- /dev/null +++ b/coffeecard/CoffeeCard.Library/Services/v2/EmailService.cs @@ -0,0 +1,84 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using CoffeeCard.Common.Configuration; +using CoffeeCard.Models.Entities; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Logging; +using MimeKit; + +namespace CoffeeCard.Library.Services.v2 +{ + public class EmailService : IEmailService + { + private readonly IWebHostEnvironment _env; + private readonly EnvironmentSettings _environmentSettings; + private readonly IEmailSender _emailSender; + private readonly ILogger _logger; + + public EmailService(IEmailSender emailSender, EnvironmentSettings environmentSettings, + IWebHostEnvironment env, ILogger logger) + { + _emailSender = emailSender; + _environmentSettings = environmentSettings; + _env = env; + _logger = logger; + } + + public async Task SendMagicLink(User user, string magicLink, LoginType loginType) + { + _logger.LogInformation("Sending magic link email to {email} {userid}", user.Email, user.Id); + var message = new MimeMessage(); + var builder = RetrieveTemplate("email_magic_link_login.html"); + var baseUrl = loginType switch + { + LoginType.Shifty => _environmentSettings.ShiftyUrl, + LoginType.App => throw new NotImplementedException(), + _ => throw new NotImplementedException(), + }; + + var deeplink = loginType.GetDeepLink(baseUrl, magicLink); + + builder = BuildMagicLinkEmail(builder, user.Email, user.Name, deeplink); + + message.To.Add(new MailboxAddress(user.Name, user.Email)); + message.Subject = "Login to Analog"; + + message.Body = builder.ToMessageBody(); + + await _emailSender.SendEmailAsync(message); + } + + private static BodyBuilder BuildMagicLinkEmail(BodyBuilder builder, string email, string name, string deeplink) + { + builder.HtmlBody = builder.HtmlBody.Replace("{email}", email); + builder.HtmlBody = builder.HtmlBody.Replace("{name}", name); + builder.HtmlBody = builder.HtmlBody.Replace("{expires}", "30 minutes"); + builder.HtmlBody = builder.HtmlBody.Replace("{deeplink}", deeplink); + + return builder; + } + + private BodyBuilder RetrieveTemplate(string templateName) + { + var pathToTemplate = _env.WebRootPath + + Path.DirectorySeparatorChar + + "Templates" + + Path.DirectorySeparatorChar + + "EmailTemplate" + + Path.DirectorySeparatorChar + + "GeneratedEmails" + + Path.DirectorySeparatorChar + + templateName; + + var builder = new BodyBuilder(); + + using (var sourceReader = File.OpenText(pathToTemplate)) + { + builder.HtmlBody = sourceReader.ReadToEnd(); + } + + return builder; + } + } +} \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Library/Services/v2/IAccountService.cs b/coffeecard/CoffeeCard.Library/Services/v2/IAccountService.cs index cd5d90fc..f768de13 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/IAccountService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/IAccountService.cs @@ -77,5 +77,9 @@ public interface IAccountService /// Remove all existing priviliged user group assignments. Update users based on request contents /// Task UpdatePriviligedUserGroups(WebhookUpdateUserGroupRequest request); + + Task GenerateUserLoginFromToken(string token); + + Task SendMagicLinkEmail(string email, LoginType loginType); } } \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Library/Services/v2/IEmailService.cs b/coffeecard/CoffeeCard.Library/Services/v2/IEmailService.cs new file mode 100644 index 00000000..721eb903 --- /dev/null +++ b/coffeecard/CoffeeCard.Library/Services/v2/IEmailService.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; +using CoffeeCard.Models.Entities; + +namespace CoffeeCard.Library.Services.v2 +{ + public interface IEmailService + { + Task SendMagicLink(User user, string magicLink, LoginType loginType); + } +} \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Library/Services/v2/ITokenService.cs b/coffeecard/CoffeeCard.Library/Services/v2/ITokenService.cs new file mode 100644 index 00000000..db759d9a --- /dev/null +++ b/coffeecard/CoffeeCard.Library/Services/v2/ITokenService.cs @@ -0,0 +1,12 @@ +using System.Threading.Tasks; +using CoffeeCard.Models.Entities; + +namespace CoffeeCard.Library.Services.v2 +{ + public interface ITokenService + { + Task GenerateMagicLink(User user); + Task GenerateRefreshTokenAsync(User user); + Task GetValidTokenByHashAsync(string tokenString); + } +} \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Library/Services/v2/PurchaseService.cs b/coffeecard/CoffeeCard.Library/Services/v2/PurchaseService.cs index 96167dd3..940ae31f 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/PurchaseService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/PurchaseService.cs @@ -20,14 +20,14 @@ namespace CoffeeCard.Library.Services.v2 public sealed class PurchaseService : IPurchaseService { private readonly CoffeeCardContext _context; - private readonly IEmailService _emailService; + private readonly CoffeeCard.Library.Services.IEmailService _emailService; private readonly IMobilePayPaymentsService _mobilePayPaymentsService; private readonly ITicketService _ticketService; private readonly IProductService _productService; private readonly ILogger _logger; public PurchaseService(CoffeeCardContext context, IMobilePayPaymentsService mobilePayPaymentsService, - ITicketService ticketService, IEmailService emailService, IProductService productService, ILogger logger) + ITicketService ticketService, CoffeeCard.Library.Services.IEmailService emailService, IProductService productService, ILogger logger) { _context = context; _mobilePayPaymentsService = mobilePayPaymentsService; diff --git a/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs b/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs new file mode 100644 index 00000000..af1f8eb6 --- /dev/null +++ b/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs @@ -0,0 +1,66 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using CoffeeCard.Common.Errors; +using CoffeeCard.Library.Persistence; +using CoffeeCard.Models.Entities; +using Microsoft.EntityFrameworkCore; + +namespace CoffeeCard.Library.Services.v2; + +public class TokenService : ITokenService +{ + private readonly CoffeeCardContext _context; + private readonly IHashService _hashService; + + public TokenService(CoffeeCardContext context, IHashService hashService) + { + _context = context; + _hashService = hashService; + } + + public async Task GenerateMagicLink(User user) + { + var guid = Guid.NewGuid().ToString(); + var magicLinkToken = new Token(guid, TokenType.MagicLink); + + user.Tokens.Add(magicLinkToken); + await _context.SaveChangesAsync(); + return magicLinkToken.TokenHash; + } + + public async Task GenerateRefreshTokenAsync(User user) + { + var refreshToken = Guid.NewGuid().ToString(); + var hashedToken = _hashService.Hash(refreshToken); + _context.Tokens.Add(new Token(hashedToken, TokenType.Refresh) { UserId = user.Id }); + await _context.SaveChangesAsync(); + return refreshToken; + } + + public async Task GetValidTokenByHashAsync(string tokenString) + { + var tokenHash = _hashService.Hash(tokenString); + var foundToken = await _context.Tokens.Include(t => t.User).FirstOrDefaultAsync(t => t.TokenHash == tokenHash); + if (foundToken == null || foundToken.Revoked || foundToken.Expired()) + { + await InvalidateRefreshTokensForUser(foundToken?.User); + throw new ApiException("Invalid token", 401); + } + return foundToken; + } + + private async Task InvalidateRefreshTokensForUser(User user) + { + if (user is null) return; + + var tokens = _context.Tokens.Where(t => t.UserId == user.Id && t.Type == TokenType.Refresh); + + _context.Tokens.UpdateRange(tokens); + foreach (var token in tokens) + { + token.Revoked = true; + } + await _context.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/User/UserLoginRequest.cs b/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/User/UserLoginRequest.cs new file mode 100644 index 00000000..7fce8fec --- /dev/null +++ b/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/User/UserLoginRequest.cs @@ -0,0 +1,33 @@ +using System.ComponentModel.DataAnnotations; +using CoffeeCard.Models.Entities; + +namespace CoffeeCard.Models.DataTransferObjects.v2.User +{ + /// + /// User login request object + /// + /// + /// { + /// "email": "john@doe.com", + /// } + /// + public class UserLoginRequest + { + /// + /// Email of user + /// + /// Email + /// john@doe.com + [EmailAddress] + [Required] + public string Email { get; set; } = null!; + + /// + /// Defines which application should open on login + /// + /// LoginType + /// Shifty + [Required] + public LoginType LoginType { get; set; } + } +} \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/User/UserLoginResponse.cs b/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/User/UserLoginResponse.cs new file mode 100644 index 00000000..69b62192 --- /dev/null +++ b/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/User/UserLoginResponse.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; + +namespace CoffeeCard.Models.DataTransferObjects.v2.User +{ + /// + /// User login response object + /// + /// + /// { + /// "jwt": "[no example provided]", + /// "refreshToken": "[no example provided]" + /// } + /// + public class UserLoginResponse + { + /// + /// JSON Web Token with claims for the user logging in + /// + [Required] + public required string Jwt { get; set; } + + /// + /// Token used to obtain a new JWT token on expiration + /// + [Required] + public required string RefreshToken { get; set; } + } +} diff --git a/coffeecard/CoffeeCard.Models/Entities/LoginType.cs b/coffeecard/CoffeeCard.Models/Entities/LoginType.cs new file mode 100644 index 00000000..21591de4 --- /dev/null +++ b/coffeecard/CoffeeCard.Models/Entities/LoginType.cs @@ -0,0 +1,46 @@ +using CoffeeCard.Common.Errors; + +namespace CoffeeCard.Models.Entities +{ + /// + /// Enum for applications to log in to + /// + /// + /// Shifty + /// + public enum LoginType + { + /// + /// Log on Shifty website + /// + Shifty, + /// + /// Log on app + /// + App + } + + + /// + /// Extension methods for LoginType + /// + public static class LoginTypeExtensions + { + /// + /// Get the deep link for the correct application + /// + /// The application to log in to + /// The base URL for the application + /// The generated token associated with a user + /// string + /// Unable to resolve application to log in to + public static string GetDeepLink(this LoginType loginType, string baseUrl, string tokenHash) + { + return loginType switch + { + LoginType.Shifty => $"{baseUrl}auth?token={tokenHash}", + _ => throw new ApiException("Deep link for the given application has not been implemented"), + }; + } + } +} \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Models/Entities/Token.cs b/coffeecard/CoffeeCard.Models/Entities/Token.cs index 77d9cbf9..06134ec4 100644 --- a/coffeecard/CoffeeCard.Models/Entities/Token.cs +++ b/coffeecard/CoffeeCard.Models/Entities/Token.cs @@ -1,33 +1,79 @@ using System; +using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; namespace CoffeeCard.Models.Entities { - public class Token + /// + /// Shared Token class for different token types + /// + [Index(nameof(TokenHash))] + public class Token(string tokenHash, TokenType type) { - public Token(string tokenHash) - { - TokenHash = tokenHash; - } - + /// + /// The ID of the token + /// public int Id { get; set; } - public string TokenHash { get; set; } + /// + /// The randomly generated hash used to find the token + /// + public string TokenHash { get; set; } = tokenHash; + /// + /// The ID of the user that the token is associated with + /// [Column(name: "User_Id")] public int? UserId { get; set; } + /// + /// The user that the token is associated with + /// public User? User { get; set; } + /// + /// The type of token + /// + /// + /// RefreshToken + /// + public TokenType Type { get; set; } = type; + + /// + /// The date and time when the Token is no longer valid + /// + public DateTime Expires { get; set; } = type.getExpiresAt(); + + /// + /// Whether or not the token has been revoked + /// + public bool Revoked { get; set; } = false; + + /// + /// Determines if two tokens are equal + /// public override bool Equals(object? obj) { if (obj is Token newToken) return TokenHash.Equals(newToken.TokenHash); return false; } + /// + /// Gets the hash code of the token + /// public override int GetHashCode() { return HashCode.Combine(Id, TokenHash, User); } + + /// + /// Determines if the token has expired + /// + /// bool + public bool Expired() + { + return DateTime.UtcNow > Expires; + } } } \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Models/Entities/TokenType.cs b/coffeecard/CoffeeCard.Models/Entities/TokenType.cs new file mode 100644 index 00000000..5548a493 --- /dev/null +++ b/coffeecard/CoffeeCard.Models/Entities/TokenType.cs @@ -0,0 +1,51 @@ +using System; + +namespace CoffeeCard.Models.Entities +{ + /// + /// Enum for the different types of token + /// + public enum TokenType + { + /// + /// Token used to reset a user's password + /// + ResetPassword, + + /// + /// Token used to delete a user's account + /// + DeleteAccount, + + /// + /// Token used to log in to an application + /// + MagicLink, + + /// + /// Token used to refresh a JWT token + /// + Refresh + } + + /// + /// Extension methods for TokenType + /// + public static class TokenTypeExtensions + { + /// + /// Get the default date and time when a token expires + /// + public static DateTime getExpiresAt(this TokenType tokenType) + { + return tokenType switch + { + TokenType.ResetPassword => DateTime.UtcNow.AddDays(1), + TokenType.DeleteAccount => DateTime.UtcNow.AddDays(1), + TokenType.MagicLink => DateTime.UtcNow.AddMinutes(30), + TokenType.Refresh => DateTime.UtcNow.AddMonths(1), + _ => DateTime.UtcNow.AddDays(1) + }; + } + } +} \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Tests.Common/Builders/TokenBuilder.cs b/coffeecard/CoffeeCard.Tests.Common/Builders/TokenBuilder.cs index fb6d090b..0b510d86 100644 --- a/coffeecard/CoffeeCard.Tests.Common/Builders/TokenBuilder.cs +++ b/coffeecard/CoffeeCard.Tests.Common/Builders/TokenBuilder.cs @@ -7,8 +7,15 @@ public partial class TokenBuilder { public static TokenBuilder Simple() { - return new TokenBuilder() - .WithUser(UserBuilder.Simple().Build()); + var builder = new TokenBuilder(); + builder.Faker.CustomInstantiator(f => + new Token(f.Random.Guid().ToString(), TokenType.Refresh)) + .Ignore(t => t.TokenHash) + .Ignore(t => t.Type); + return builder + .WithExpires(DateTime.Now.AddDays(1)) + .WithRevoked(false) + .WithUser(UserBuilder.DefaultCustomer().Build()); } public static TokenBuilder Typical() diff --git a/coffeecard/CoffeeCard.Tests.Integration/CoffeeCard.Tests.Integration.csproj b/coffeecard/CoffeeCard.Tests.Integration/CoffeeCard.Tests.Integration.csproj index d4d7297c..fd58ab58 100644 --- a/coffeecard/CoffeeCard.Tests.Integration/CoffeeCard.Tests.Integration.csproj +++ b/coffeecard/CoffeeCard.Tests.Integration/CoffeeCard.Tests.Integration.csproj @@ -11,6 +11,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + @@ -43,12 +44,12 @@ - + CoffeeCardClient CoffeeCard.Tests.ApiClient.Generated /UseBaseUrl:false /OperationGenerationMode:SingleClientFromOperationId - + CoffeeCardClientV2 CoffeeCard.Tests.ApiClient.v2.Generated /AdditionalNamespaceUsages:CoffeeCard.Tests.ApiClient.Generated /UseBaseUrl:false /OperationGenerationMode:SingleClientFromOperationId diff --git a/coffeecard/CoffeeCard.Tests.Integration/Controllers/v2/LoginTest.cs b/coffeecard/CoffeeCard.Tests.Integration/Controllers/v2/LoginTest.cs new file mode 100644 index 00000000..5f233d54 --- /dev/null +++ b/coffeecard/CoffeeCard.Tests.Integration/Controllers/v2/LoginTest.cs @@ -0,0 +1,87 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Threading.Tasks; +using CoffeeCard.Library.Services; +using CoffeeCard.Models.Entities; +using CoffeeCard.Tests.ApiClient.v2.Generated; +using CoffeeCard.Tests.Common.Builders; +using CoffeeCard.Tests.Integration.WebApplication; +using CoffeeCard.WebApi; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.Extensions.DependencyInjection; +using MimeKit; +using Moq; +using Xunit; +using LoginType = CoffeeCard.Tests.ApiClient.v2.Generated.LoginType; + +namespace CoffeeCard.Tests.Integration.Controllers.v2.Account +{ + + public class LoginTest(CustomWebApplicationFactory factory) : BaseIntegrationTest(factory) + { + [Fact] + public async Task Unknown_user_login_doesnt_fail_but_no_token_is_created() + { + // It should not be possible to determine if account with email exists, thus never fail + var loginRequest = new UserLoginRequest + { + Email = "test@email.dk", + LoginType = LoginType.Shifty, + }; + + var exception = await Record.ExceptionAsync(async () => await CoffeeCardClientV2.Account_LoginAsync(loginRequest)); + Assert.Null(exception); + Assert.Empty(Context.Tokens); + } + + [Fact] + public async Task Known_user_login_saves_token_in_database_and_sends_one_mail() + { + Mock emailSenderMock = new Mock(); + ConfigureMockService(emailSenderMock.Object); + + var user = UserBuilder.DefaultCustomer().Build(); + + await Context.Users.AddAsync(user); + await Context.SaveChangesAsync(); + + var loginRequest = new UserLoginRequest + { + Email = user.Email, + LoginType = LoginType.Shifty, + }; + + await CoffeeCardClientV2.Account_LoginAsync(loginRequest); + + var token = await Context.Tokens.FirstOrDefaultAsync(); + + Assert.NotNull(token); + Assert.Equal(TokenType.MagicLink, token.Type); + Assert.Equal(user.Id, token.UserId); + + emailSenderMock.Verify(x => x.SendEmailAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Known_user_login_succeeds_returns_token() + { + var user = UserBuilder.DefaultCustomer().Build(); + var token = TokenBuilder.Simple().WithUser(user).WithType(TokenType.MagicLink).Build(); + var tokenString = token.TokenHash; + var tokenHash = Context.GetService().Hash(token.TokenHash); + token.TokenHash = tokenHash; // We need to hash the token before adding it to the database to ensure we don't leak credentials if database is breached + + await Context.Users.AddAsync(user); + await Context.Tokens.AddAsync(token); + await Context.SaveChangesAsync(); + + // We authenticate using the non-hashed token and let the backend hash the string for us + var response = await CoffeeCardClientV2.Account_AuthenticateAsync(tokenString); + + Assert.NotNull(response.Jwt); + Assert.NotNull(response.RefreshToken); + var tokenValidator = new JwtSecurityTokenHandler(); + Assert.True(tokenValidator.CanReadToken(response.Jwt)); + } + } +} \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Tests.Integration/WebApplication/BaseIntegrationTest.cs b/coffeecard/CoffeeCard.Tests.Integration/WebApplication/BaseIntegrationTest.cs index 3a774bed..a8c8cb4b 100644 --- a/coffeecard/CoffeeCard.Tests.Integration/WebApplication/BaseIntegrationTest.cs +++ b/coffeecard/CoffeeCard.Tests.Integration/WebApplication/BaseIntegrationTest.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IdentityModel.Tokens.Jwt; +using System.Linq; using System.Net.Http; using System.Security.Claims; using System.Text; @@ -12,6 +13,7 @@ using CoffeeCard.Tests.ApiClient.v2.Generated; using CoffeeCard.Tests.Common.Builders; using CoffeeCard.WebApi; +using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; using Microsoft.IdentityModel.Tokens; using Xunit; @@ -19,13 +21,13 @@ namespace CoffeeCard.Tests.Integration.WebApplication { [Collection("Integration tests, to be run sequentially")] - public abstract class BaseIntegrationTest : CustomWebApplicationFactory, IClassFixture> + public abstract class BaseIntegrationTest : IClassFixture>, IAsyncDisposable { private readonly CustomWebApplicationFactory _factory; private readonly IServiceScope _scope; - private readonly HttpClient _httpClient; - protected readonly CoffeeCardClient CoffeeCardClient; - protected readonly CoffeeCardClientV2 CoffeeCardClientV2; + private HttpClient _httpClient; + protected CoffeeCardClient CoffeeCardClient => new(_httpClient); + protected CoffeeCardClientV2 CoffeeCardClientV2 => new(_httpClient); protected readonly CoffeeCardContext Context; protected BaseIntegrationTest(CustomWebApplicationFactory factory) @@ -38,14 +40,12 @@ protected BaseIntegrationTest(CustomWebApplicationFactory factory) _scope = _factory.Services.CreateScope(); _httpClient = GetHttpClient(); - CoffeeCardClient = new CoffeeCardClient(_httpClient); - CoffeeCardClientV2 = new CoffeeCardClientV2(_httpClient); Context = GetCoffeeCardContext(); } private HttpClient GetHttpClient() { - var client = CreateClient(); + var client = _factory.CreateClient(); return client; } @@ -72,6 +72,18 @@ protected void SetDefaultAuthHeader(User user) _httpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("bearer", token); } + protected void ConfigureMockService(TService service) where TService : class + { + _httpClient = _factory.WithWebHostBuilder(services => + { + services.ConfigureTestServices(services => + { + services.Remove(services.SingleOrDefault(d => d.ServiceType == typeof(TService))); + services.AddSingleton(service); + }); + }).CreateClient(); + } + private string GenerateToken(IEnumerable claims) { var scopedServices = _scope.ServiceProvider; @@ -108,12 +120,11 @@ private CoffeeCardContext GetCoffeeCardContext() return context; } - public override ValueTask DisposeAsync() + public ValueTask DisposeAsync() { _scope.Dispose(); - _httpClient.Dispose(); GC.SuppressFinalize(this); - return base.DisposeAsync(); + return new ValueTask(Task.CompletedTask); } } } \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Tests.Unit/Services/AccountServiceTest.cs b/coffeecard/CoffeeCard.Tests.Unit/Services/AccountServiceTest.cs index 6371aaa1..cdb98915 100644 --- a/coffeecard/CoffeeCard.Tests.Unit/Services/AccountServiceTest.cs +++ b/coffeecard/CoffeeCard.Tests.Unit/Services/AccountServiceTest.cs @@ -64,7 +64,7 @@ public async Task RecoverUserGivenValidTokenReturnsTrue() tokenService.Setup(t => t.ValidateTokenIsUnusedAsync("valid")).ReturnsAsync(true); // Act - var token = new Token("valid"); + var token = new Token("valid", TokenType.ResetPassword); var userTokens = new List { token }; var programme = new Programme { FullName = "fullName", ShortName = "shortName" }; @@ -87,7 +87,7 @@ public async Task RecoverUserGivenValidTokenReturnsTrue() public async Task RecoverUserGivenValidTokenUpdatesPasswordAndResetsUsersTokens() { // Arrange - var token = new Token("valid"); + var token = new Token("valid", TokenType.ResetPassword); var userPass = "not set"; var user = UserBuilder.DefaultCustomer() diff --git a/coffeecard/CoffeeCard.Tests.Unit/Services/TokenServiceTest.cs b/coffeecard/CoffeeCard.Tests.Unit/Services/TokenServiceTest.cs index 167f2327..7f01f9c6 100644 --- a/coffeecard/CoffeeCard.Tests.Unit/Services/TokenServiceTest.cs +++ b/coffeecard/CoffeeCard.Tests.Unit/Services/TokenServiceTest.cs @@ -76,7 +76,7 @@ public async Task ValidateTokenGivenValidTokenReturnsTrue() var tokenService = new TokenService(_identity, claimsUtility, NullLogger.Instance); var token = tokenService.GenerateToken(claims); - var userTokens = new List { new Token(token) }; + var userTokens = new List { new Token(token, TokenType.MagicLink) }; var user = GenerateTestUser(tokens: userTokens); await context.AddAsync(user); await context.SaveChangesAsync(); @@ -152,7 +152,7 @@ public async Task ValidateTokenGivenWelformedExpiredTokenReturnsFalse() var token = new JwtSecurityTokenHandler().WriteToken(jwt); - var userTokens = new List { new Token(token) }; + var userTokens = new List { new Token(token, TokenType.MagicLink) }; var user = GenerateTestUser(tokens: userTokens); await context.AddAsync(user); await context.SaveChangesAsync(); diff --git a/coffeecard/CoffeeCard.Tests.Unit/Services/v2/AccountServiceTest.cs b/coffeecard/CoffeeCard.Tests.Unit/Services/v2/AccountServiceTest.cs index 50d0e2ba..8504bafd 100644 --- a/coffeecard/CoffeeCard.Tests.Unit/Services/v2/AccountServiceTest.cs +++ b/coffeecard/CoffeeCard.Tests.Unit/Services/v2/AccountServiceTest.cs @@ -1,19 +1,15 @@ using System; using System.Collections.Generic; -using System.IdentityModel.Tokens.Jwt; using System.Linq; using System.Security.Claims; -using System.Text; using System.Threading.Tasks; using CoffeeCard.Common.Configuration; using CoffeeCard.Common.Errors; using CoffeeCard.Library.Persistence; using CoffeeCard.Library.Services; -using CoffeeCard.Library.Services.v2; -using CoffeeCard.Library.Utils; -using CoffeeCard.Models.DataTransferObjects.v2.Programme; using CoffeeCard.Models.DataTransferObjects.v2.User; using CoffeeCard.Models.Entities; +using CoffeeCard.Tests.Common.Builders; using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -25,7 +21,7 @@ namespace CoffeeCard.Tests.Unit.Services.v2 { - public class AccountServiceTest + public class AccountServiceTest : BaseUnitTests { private CoffeeCardContext CreateTestCoffeeCardContextWithName(string name) { @@ -48,16 +44,23 @@ private CoffeeCardContext CreateTestCoffeeCardContextWithName(string name) public async Task GetAccountByClaimsReturnsUserClaimWithEmail() { // Arrange - var claims = new List() { new Claim(ClaimTypes.Email, "test@test.test") }; - var expected = new User() { Name = "User", Email = "test@test.test" }; + const string email = "test@test.test"; + var claims = new List() { new Claim(ClaimTypes.Email, email) }; + var expected = UserBuilder.DefaultCustomer().WithEmail(email).Build(); User result; using var context = CreateTestCoffeeCardContextWithName(nameof(GetAccountByClaimsReturnsUserClaimWithEmail)); context.Users.Add(expected); await context.SaveChangesAsync(); // Act - var accountService = new Library.Services.v2.AccountService(context, new Mock().Object, - new Mock().Object, new Mock().Object, NullLogger.Instance); + var accountService = new Library.Services.v2.AccountService( + context, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + NullLogger.Instance); result = await accountService.GetAccountByClaimsAsync(claims); // Assert @@ -70,14 +73,20 @@ public async Task GetAccountByClaimsReturnsUserClaimWithEmail() public async Task GetAccountByClaimsThrowsApiExceptionGivenInvalidClaim(IEnumerable claims) { // Arrange - var validUser = new User() { Name = "User", Email = "test@test.test" }; + var validUser = UserBuilder.DefaultCustomer().Build(); using var context = CreateTestCoffeeCardContextWithName(nameof(GetAccountByClaimsThrowsApiExceptionGivenInvalidClaim) + claims.ToString()); context.Users.Add(validUser); await context.SaveChangesAsync(); // Act - var accountService = new Library.Services.v2.AccountService(context, new Mock().Object, - new Mock().Object, new Mock().Object, NullLogger.Instance); + var accountService = new Library.Services.v2.AccountService( + context, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + NullLogger.Instance); // Assert await Assert.ThrowsAsync(async () => await accountService.GetAccountByClaimsAsync(claims)); @@ -91,27 +100,20 @@ public async Task GetAccountByClaimsThrowsApiExceptionGivenInvalidClaim(IEnumera public async Task RegisterAccountReturnsUserOnValidInput(String name, String email, string password, int programmeId) { // Arrange - var programme = new Programme() - { - Id = programmeId, - FullName = "test", - ShortName = "t", - Users = new List() - }; + var programme = ProgrammeBuilder.Simple().WithId(programmeId).Build(); var expectedPass = "HashedPassword"; - var expected = new User() - { - Name = name, - Email = email, - Password = expectedPass, - PrivacyActivated = false, - Programme = programme - }; + var expected = UserBuilder.DefaultCustomer() + .WithName(name) + .WithEmail(email) + .WithPassword(expectedPass) + .WithProgramme(programme) + .WithPrivacyActivated(false) + .Build(); User result; // Using same context across all valid users to test creation of multiple users using var context = CreateTestCoffeeCardContextWithName(nameof(RegisterAccountReturnsUserOnValidInput)); - var emailServiceMock = new Mock(); + var emailServiceMock = new Mock(); emailServiceMock.Setup(e => e.SendRegistrationVerificationEmailAsync(It.IsAny(), It.IsAny())).Returns(Task.CompletedTask); var emailService = emailServiceMock.Object; @@ -123,8 +125,15 @@ public async Task RegisterAccountReturnsUserOnValidInput(String name, String ema context.Programmes.Add(programme); await context.SaveChangesAsync(); // Act - var accountService = new Library.Services.v2.AccountService(context, new Mock().Object, - emailService, hashService, NullLogger.Instance); + var accountService = new Library.Services.v2.AccountService( + context, + new Mock().Object, + emailService, + new Mock().Object, + new Mock().Object, + hashService, + NullLogger.Instance); + result = await accountService.RegisterAccountAsync(name, email, password, programmeId); // Assert @@ -139,7 +148,7 @@ public async Task RegisterAccountReturnsUserOnValidInput(String name, String ema public async Task RegisterAccountThrowsApiExceptionWithStatus409OnExistingEmail() { // Arrange - var programme = new Programme() { Id = 1, FullName = "test", ShortName = "t", SortPriority = 1, Users = new List() }; + var programme = ProgrammeBuilder.Simple().Build(); var email = "test@test.dk"; using var context = CreateTestCoffeeCardContextWithName(nameof(RegisterAccountThrowsApiExceptionWithStatus409OnExistingEmail)); @@ -151,8 +160,15 @@ public async Task RegisterAccountThrowsApiExceptionWithStatus409OnExistingEmail( hashservice.Setup(h => h.Hash("pass")).Returns(""); // Act - var accountService = new Library.Services.v2.AccountService(context, new Mock().Object, - new Mock().Object, hashservice.Object, NullLogger.Instance); + var accountService = new Library.Services.v2.AccountService( + context, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + hashservice.Object, + NullLogger.Instance); + // Assert // Register the first user await accountService.RegisterAccountAsync("name", email, "pass", 1); @@ -168,8 +184,14 @@ public async Task RegisterAccountThrowsApiExceptionWithStatus400WhenGivenInvalid // Arrange using var context = CreateTestCoffeeCardContextWithName(nameof(RegisterAccountThrowsApiExceptionWithStatus400WhenGivenInvalidProgrammeId)); // Act - var accountService = new Library.Services.v2.AccountService(context, new Mock().Object, - new Mock().Object, new Mock().Object, NullLogger.Instance); + var accountService = new Library.Services.v2.AccountService( + context, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + NullLogger.Instance); // Assert var exception = await Assert.ThrowsAsync( @@ -181,25 +203,12 @@ public async Task RegisterAccountThrowsApiExceptionWithStatus400WhenGivenInvalid public async Task RegisterAccountSendsVerificationEmailOnlyValidInput() { // Arrange - var programme = new Programme() - { - Id = 1, - FullName = "test", - ShortName = "t", - Users = new List() - }; + var programme = ProgrammeBuilder.Simple().Build(); var expectedPass = "HashedPassword"; - var expected = new User() - { - Name = "name", - Email = "email", - Password = expectedPass, - PrivacyActivated = false, - Programme = programme - }; + var expected = UserBuilder.DefaultCustomer().WithPassword(expectedPass).WithProgramme(programme).Build(); using var context = CreateTestCoffeeCardContextWithName(nameof(RegisterAccountSendsVerificationEmailOnlyValidInput)); - var emailServiceMock = new Mock(); + var emailServiceMock = new Mock(); emailServiceMock.Setup(e => e.SendRegistrationVerificationEmailAsync(It.IsAny(), It.IsAny())).Returns(Task.CompletedTask); var emailService = emailServiceMock.Object; @@ -211,8 +220,15 @@ public async Task RegisterAccountSendsVerificationEmailOnlyValidInput() context.Programmes.Add(programme); await context.SaveChangesAsync(); // Act - var accountService = new Library.Services.v2.AccountService(context, new Mock().Object, - emailService, hashService, NullLogger.Instance); + var accountService = new Library.Services.v2.AccountService( + context, + new Mock().Object, + emailService, + new Mock().Object, + new Mock().Object, + hashService, + NullLogger.Instance); + await accountService.RegisterAccountAsync("name", "email", "password", 1); // Assert @@ -234,13 +250,7 @@ await Assert.ThrowsAsync( public async Task UpdateAccountUpdatesAllNonNullProperties(String name, String email, String? password, bool? privacyActivated, int? programmeId) { // Arrange - var programme = new Programme() - { - Id = 1, - FullName = "test", - ShortName = "t", - Users = new List() - }; + var programme = ProgrammeBuilder.Simple().Build(); var updateUserRequest = new UpdateUserRequest() { Name = name, @@ -249,22 +259,14 @@ public async Task UpdateAccountUpdatesAllNonNullProperties(String name, String e PrivacyActivated = privacyActivated, ProgrammeId = programmeId }; - var user = new User() - { - Name = "name", - Password = "pass", - PrivacyActivated = false, - Email = "test@test.test", - Programme = programme - }; - var expected = new User() - { - Name = name, - Email = email, - Password = password ?? user.Password, - PrivacyActivated = privacyActivated ?? user.PrivacyActivated, - Programme = programme ?? user.Programme - }; + var user = UserBuilder.DefaultCustomer().WithProgramme(programme).Build(); + var expected = UserBuilder.DefaultCustomer() + .WithName(name) + .WithEmail(email) + .WithPassword(password ?? user.Password) + .WithPrivacyActivated(privacyActivated ?? user.PrivacyActivated) + .WithProgramme(programme ?? user.Programme) + .Build(); using var context = CreateTestCoffeeCardContextWithName(nameof(UpdateAccountUpdatesAllNonNullProperties) + name); context.Users.Add(user); @@ -277,8 +279,15 @@ public async Task UpdateAccountUpdatesAllNonNullProperties(String name, String e var hashService = hashServiceMock.Object; // Act - var accountService = new Library.Services.v2.AccountService(context, new Mock().Object, - new Mock().Object, hashService, NullLogger.Instance); + var accountService = new Library.Services.v2.AccountService( + context, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + hashService, + NullLogger.Instance); + var result = await accountService.UpdateAccountAsync(user, updateUserRequest); // Assert @@ -293,13 +302,7 @@ public async Task UpdateAccountUpdatesAllNonNullProperties(String name, String e public async Task UpdateAccountThrowsApiExceptionOnInvalidProgrammeId() { // Arrange - var programme = new Programme() - { - Id = 1, - FullName = "test", - ShortName = "t", - Users = new List() - }; + var programme = ProgrammeBuilder.Simple().WithId(1).Build(); var updateUserRequest = new UpdateUserRequest() { Name = "name", @@ -308,14 +311,7 @@ public async Task UpdateAccountThrowsApiExceptionOnInvalidProgrammeId() PrivacyActivated = false, ProgrammeId = 2 // No prgramme with Id }; - var user = new User() - { - Name = "name", - Password = "pass", - PrivacyActivated = false, - Email = "test@test.test", - Programme = programme - }; + var user = UserBuilder.DefaultCustomer().WithProgramme(programme).Build(); using var context = CreateTestCoffeeCardContextWithName(nameof(UpdateAccountThrowsApiExceptionOnInvalidProgrammeId)); context.Users.Add(user); @@ -324,8 +320,14 @@ public async Task UpdateAccountThrowsApiExceptionOnInvalidProgrammeId() await context.SaveChangesAsync(); // Act - var accountService = new Library.Services.v2.AccountService(context, new Mock().Object, - new Mock().Object, new Mock().Object, NullLogger.Instance); + var accountService = new Library.Services.v2.AccountService( + context, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + NullLogger.Instance); // Assert var exception = await Assert.ThrowsAsync(async () => await accountService.UpdateAccountAsync(user, updateUserRequest)); @@ -336,25 +338,25 @@ public async Task UpdateAccountThrowsApiExceptionOnInvalidProgrammeId() public async Task RequestAnonymizationSendsEmail() { // Arrange - var user = new User() - { - Name = "name", - Password = "pass", - PrivacyActivated = false, - Email = "test@test.test", - }; + var user = UserBuilder.DefaultCustomer().Build(); using var context = CreateTestCoffeeCardContextWithName(nameof(RequestAnonymizationSendsEmail)); context.Users.Add(user); await context.SaveChangesAsync(); - var emailServiceMock = new Mock(); + var emailServiceMock = new Mock(); emailServiceMock.Setup(e => e.SendVerificationEmailForDeleteAccount(It.IsAny(), It.IsAny())).Returns(Task.CompletedTask); var emailService = emailServiceMock.Object; // Act - var accountService = new Library.Services.v2.AccountService(context, new Mock().Object, - emailService, new Mock().Object, NullLogger.Instance); + var accountService = new Library.Services.v2.AccountService( + context, + new Mock().Object, + emailService, + new Mock().Object, + new Mock().Object, + new Mock().Object, + NullLogger.Instance); await accountService.RequestAnonymizationAsync(user); // Assert @@ -366,37 +368,31 @@ public async Task AnonymizeAccountRemovesIdentifyableInformationFromUser() { // Arrange var userEmail = "test@test.test"; - var user = new User - { - Id = 1, - Name = "name", - Password = "pass", - Salt = "salt", - UserState = UserState.Active, - PrivacyActivated = false, - Email = userEmail, - }; - var expected = new User - { - Id = 1, - Name = "", - Password = "", - Salt = "", - UserState = UserState.Deleted, - PrivacyActivated = true, - Email = "", - }; + var user = UserBuilder.DefaultCustomer().WithEmail(userEmail).WithUserState(UserState.Active).Build(); + var expected = UserBuilder.DefaultCustomer() + .WithName("") + .WithPassword("") + .WithSalt("") + .WithEmail("") + .WithUserState(UserState.Deleted) + .Build(); await using var context = CreateTestCoffeeCardContextWithName(nameof(AnonymizeAccountRemovesIdentifyableInformationFromUser)); context.Users.Add(user); await context.SaveChangesAsync(); - var tokenServiceMock = new Mock(); + var tokenServiceMock = new Mock(); tokenServiceMock.Setup(e => e.ValidateVerificationTokenAndGetEmail("test")).Returns(userEmail); // Act - var accountService = new Library.Services.v2.AccountService(context, tokenServiceMock.Object, - new Mock().Object, new Mock().Object, NullLogger.Instance); + var accountService = new Library.Services.v2.AccountService( + context, + tokenServiceMock.Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + NullLogger.Instance); await accountService.AnonymizeAccountAsync("test"); var result = await context.Users.Where(u => u.Id == user.Id).FirstAsync(); @@ -416,25 +412,25 @@ public async Task ResendVerificationEmailWhenAccountIsNotVerified() { // Arrange const string userEmail = "test@test.test"; - var user = new User - { - Id = 1, - Name = "name", - Password = "pass", - Salt = "salt", - UserState = UserState.Active, - PrivacyActivated = false, - Email = userEmail, - IsVerified = false - }; + var user = UserBuilder.DefaultCustomer() + .WithEmail(userEmail) + .WithIsVerified(false) + .Build(); await using var context = CreateTestCoffeeCardContextWithName(nameof(ResendVerificationEmailWhenAccountIsNotVerified)); context.Users.Add(user); await context.SaveChangesAsync(); // Act - var emailService = new Mock(); - var accountService = new Library.Services.v2.AccountService(context, new Mock().Object, emailService.Object, new Mock().Object, NullLogger.Instance); + var emailService = new Mock(); + var accountService = new Library.Services.v2.AccountService( + context, + new Mock().Object, + emailService.Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + NullLogger.Instance); await accountService.ResendAccountVerificationEmail(new ResendAccountVerificationEmailRequest { @@ -450,24 +446,24 @@ public async Task ResendVerificationEmailThrowsConflictExceptionWhenAccountIsAlr { // Arrange const string userEmail = "test@test.test"; - var user = new User - { - Id = 1, - Name = "name", - Password = "pass", - Salt = "salt", - UserState = UserState.Active, - PrivacyActivated = false, - Email = userEmail, - IsVerified = true - }; + var user = UserBuilder.DefaultCustomer() + .WithEmail(userEmail) + .WithIsVerified(true) + .Build(); await using var context = CreateTestCoffeeCardContextWithName(nameof(ResendVerificationEmailThrowsConflictExceptionWhenAccountIsAlreadyVerified)); context.Users.Add(user); await context.SaveChangesAsync(); // Act - var accountService = new Library.Services.v2.AccountService(context, new Mock().Object, new Mock().Object, new Mock().Object, NullLogger.Instance); + var accountService = new Library.Services.v2.AccountService( + context, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + NullLogger.Instance); await Assert.ThrowsAsync(async () => await accountService.ResendAccountVerificationEmail(new ResendAccountVerificationEmailRequest { @@ -482,7 +478,14 @@ public async Task ResendVerificationEmailThrowsEntityNotFoundExceptionWhenEmailD await using var context = CreateTestCoffeeCardContextWithName(nameof(ResendVerificationEmailThrowsEntityNotFoundExceptionWhenEmailDoesnotExist)); // Act - var accountService = new Library.Services.v2.AccountService(context, new Mock().Object, new Mock().Object, new Mock().Object, NullLogger.Instance); + var accountService = new Library.Services.v2.AccountService( + context, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + NullLogger.Instance); await Assert.ThrowsAsync(async () => await accountService.ResendAccountVerificationEmail(new ResendAccountVerificationEmailRequest { @@ -490,6 +493,140 @@ await Assert.ThrowsAsync(async () => await accountServi })); } + [Fact(DisplayName = "SendMagicLink sends email when user is found")] + public async Task SendMagicLinkSendsEmailWhenUserIsFound() + { + // Arrange + const string userEmail = "john@cena.com"; + var user = UserBuilder.DefaultCustomer().WithEmail(userEmail).Build(); + + await using var context = CreateTestCoffeeCardContextWithName(nameof(SendMagicLinkSendsEmailWhenUserIsFound)); + + await context.Users.AddAsync(user); + await context.SaveChangesAsync(); + + var emailService = new Mock(); + var accountService = new Library.Services.v2.AccountService( + context, + new Mock().Object, + new Mock().Object, + emailService.Object, + new Mock().Object, + new Mock().Object, + NullLogger.Instance); + + // Act + await accountService.SendMagicLinkEmail(userEmail, LoginType.Shifty); + + // Assert + emailService.Verify(e => e.SendMagicLink(user, It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact(DisplayName = "SendMagicLink does not send mail when user is not found")] + public async Task SendMagicLinkDoesNotSendMailWhenUserIsNotFound() + { + // Arrange + await using var context = CreateTestCoffeeCardContextWithName(nameof(SendMagicLinkDoesNotSendMailWhenUserIsNotFound)); + + var emailService = new Mock(); + var accountService = new Library.Services.v2.AccountService( + context, + new Mock().Object, + new Mock().Object, + emailService.Object, + new Mock().Object, + new Mock().Object, + NullLogger.Instance); + + // Act + await accountService.SendMagicLinkEmail("nonexisting@email.com", LoginType.Shifty); + + // Assert + emailService.Verify(e => e.SendMagicLink(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact(DisplayName = "GenerateTokenPair revokes token on use")] + public async Task GenerateTokenPairRevokesTokenOnUse() + { + // Arrange + var user = UserBuilder.DefaultCustomer().Build(); + + const string tokenHash = "refreshToken"; + + var refreshToken = TokenBuilder.Simple() + .WithTokenHash(tokenHash) + .WithType(TokenType.Refresh) + .WithUser(user) + .Build(); + + await using var context = CreateTestCoffeeCardContextWithName(nameof(GenerateTokenPairRevokesTokenOnUse)); + await context.Users.AddAsync(user); + await context.Tokens.AddAsync(refreshToken); + await context.SaveChangesAsync(); + + var tokenService = new Mock(); + tokenService.Setup(t => t.GetValidTokenByHashAsync("refreshToken")).ReturnsAsync(refreshToken); + + var accountService = new Library.Services.v2.AccountService( + context, + new Mock().Object, + new Mock().Object, + new Mock().Object, + tokenService.Object, + new Mock().Object, + NullLogger.Instance); + + // Act + var tokenPair = await accountService.GenerateUserLoginFromToken(tokenHash); + + // Assert + Assert.True(refreshToken.Revoked); + } + + [Fact(DisplayName = "GenerateTokenPair returns token pair")] + public async Task GenerateTokenPairReturnsTokenPair() + { + // Arrange + var user = UserBuilder.DefaultCustomer().Build(); + + const string tokenHash = "refreshToken"; + + var refreshToken = TokenBuilder.Simple() + .WithTokenHash(tokenHash) + .WithType(TokenType.Refresh) + .WithUser(user) + .Build(); + + await using var context = CreateTestCoffeeCardContextWithName(nameof(GenerateTokenPairReturnsTokenPair)); + await context.Users.AddAsync(user); + await context.Tokens.AddAsync(refreshToken); + await context.SaveChangesAsync(); + + var tokenServicev2 = new Mock(); + tokenServicev2.Setup(t => t.GenerateRefreshTokenAsync(user)).ReturnsAsync("newToken"); + tokenServicev2.Setup(t => t.GetValidTokenByHashAsync(tokenHash)).ReturnsAsync(refreshToken); + + var tokenServicev1 = new Mock(); + tokenServicev1.Setup(t => t.GenerateToken(It.IsAny>())).Returns("jwtToken"); + + var accountService = new Library.Services.v2.AccountService( + context, + tokenServicev1.Object, + new Mock().Object, + new Mock().Object, + tokenServicev2.Object, + new Mock().Object, + NullLogger.Instance); + + // Act + var tokenPair = await accountService.GenerateUserLoginFromToken(tokenHash); + + // Assert + Assert.NotNull(tokenPair); + Assert.NotNull(tokenPair.RefreshToken); + Assert.NotNull(tokenPair.Jwt); + } + public static IEnumerable ClaimGenerator() { yield return new object[] { diff --git a/coffeecard/CoffeeCard.Tests.Unit/Services/v2/TokenServiceTests.cs b/coffeecard/CoffeeCard.Tests.Unit/Services/v2/TokenServiceTests.cs new file mode 100644 index 00000000..1219d30b --- /dev/null +++ b/coffeecard/CoffeeCard.Tests.Unit/Services/v2/TokenServiceTests.cs @@ -0,0 +1,168 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using CoffeeCard.Common.Configuration; +using CoffeeCard.Common.Errors; +using CoffeeCard.Library.Persistence; +using CoffeeCard.Library.Services; +using CoffeeCard.Models.Entities; +using CoffeeCard.Tests.Common.Builders; +using Microsoft.EntityFrameworkCore; +using Moq; +using Xunit; +using TokenService = CoffeeCard.Library.Services.v2.TokenService; + +namespace CoffeeCard.Tests.Unit.Services.v2 +{ + public class TokenServiceTests : BaseUnitTests + { + [Fact(DisplayName = "GenerateMagicLink returns a link with a valid token for user")] + public async Task GenerateMagicLink_ReturnsLinkWithValidTokenForUser() + { + // Arrange + var user = UserBuilder.DefaultCustomer().Build(); + + InitialContext.Users.Add(user); + await InitialContext.SaveChangesAsync(); + + var tokenService = new TokenService(AssertionContext, Mock.Of()); + + // Act + var result = await tokenService.GenerateMagicLink(user); + + // Assert + Assert.NotNull(result); + Assert.NotEmpty(result); + Assert.Contains(user.Tokens, t => t.TokenHash == result); + var token = user.Tokens.First(t => t.TokenHash == result); + Assert.Equal(TokenType.MagicLink, token.Type); + Assert.False(token.Revoked, "Token should not be revoked"); + } + + [Fact(DisplayName = "GenerateRefreshTokenAsync returns a valid token for user")] + public async Task GenerateRefreshTokenAsync_ReturnsValidTokenForUser() + { + // Arrange + var user = UserBuilder.DefaultCustomer().Build(); + + InitialContext.Users.Add(user); + await InitialContext.SaveChangesAsync(); + var hashService = new HashService(); + + var tokenService = new TokenService(AssertionContext, hashService); + + // Act + var result = await tokenService.GenerateRefreshTokenAsync(user); + + // Assert + Assert.NotNull(result); + Assert.NotEmpty(result); + // We cannot assert on tokenHash since it has been hashed for security reasons. Hashing is tested elsewhere. + var token = await AssertionContext.Tokens.FirstOrDefaultAsync(); + Assert.Equal(TokenType.Refresh, token.Type); + Assert.False(token.Revoked, "Token should not be revoked"); + } + + [Fact(DisplayName = "GetValidTokenByHashAsync throws exception if token does not exist")] + public async Task GetValidTokenByHashAsync_ThrowsExceptionIfTokenDoesNotExist() + { + // Arrange + var tokenService = new TokenService(AssertionContext, Mock.Of()); + + // Act & Assert + await Assert.ThrowsAsync(() => tokenService.GetValidTokenByHashAsync("token")); + } + + [Fact(DisplayName = "GetValidTokenByHashAsync throws exception if token is revoked")] + public async Task GetValidTokenByHashAsync_ThrowsExceptionIfTokenIsRevoked() + { + // Arrange + var token = TokenBuilder.Simple().Build(); + + InitialContext.Tokens.Add(token); + + await InitialContext.SaveChangesAsync(); + + var tokenService = new TokenService(AssertionContext, Mock.Of()); + + // Act & Assert + await Assert.ThrowsAsync(() => tokenService.GetValidTokenByHashAsync("token")); + } + + [Fact(DisplayName = "GetValidTokenByHashAsync throws exception if token has expired")] + public async Task GetValidTokenByHashAsync_ThrowsExceptionIfTokenHasExpired() + { + // Arrange + var token = TokenBuilder.Simple().WithExpires(DateTime.Now.AddDays(-1)).Build(); + InitialContext.Tokens.Add(token); + + await InitialContext.SaveChangesAsync(); + + var tokenService = new TokenService(AssertionContext, Mock.Of()); + + // Act & Assert + await Assert.ThrowsAsync(() => tokenService.GetValidTokenByHashAsync("token")); + } + + [Fact(DisplayName = "GetValidTokenByHashAsync returns token by valid hash")] + public async Task GetValidTokenByHashAsync_ReturnsTokenByValidHash() + { + // Arrange + var token = TokenBuilder.Simple().Build(); + InitialContext.Tokens.Add(token); + + await InitialContext.SaveChangesAsync(); + + var hashService = new Mock(); + hashService.Setup(h => h.Hash(It.IsAny())).Returns(token.TokenHash); + + var tokenService = new TokenService(AssertionContext, hashService.Object); + + // Act & Assert + var result = await tokenService.GetValidTokenByHashAsync(token.TokenHash); + + // Assert + Assert.NotNull(result); + Assert.Equal(token, result); + Assert.False(result.Revoked); + Assert.False(result.Expired()); + } + + [Fact(DisplayName = "GetValidTokenByHashAsync invalidates users refresh tokens if token is invalid")] + public async Task GetValidTokenByHashAsync_InvalidatesUsersRefreshTokensIfTokenIsInvalid() + { + // Arrange + var user = UserBuilder.DefaultCustomer().Build(); + InitialContext.Users.Add(user); + + var token = TokenBuilder.Simple().WithUser(user).WithRevoked(true).WithType(TokenType.Refresh).Build(); + var refreshToken = TokenBuilder.Simple().WithUser(user).WithType(TokenType.Refresh).Build(); + + Token[] otherTokens = + { + new ("magicLink", TokenType.MagicLink) {User = user}, + new ("reset", TokenType.ResetPassword) {User = user}, + }; + + InitialContext.Tokens.AddRange(token, refreshToken); + InitialContext.Tokens.AddRange(otherTokens); + + await InitialContext.SaveChangesAsync(); + + var hashService = new Mock(); + hashService.Setup(h => h.Hash(It.IsAny())).Returns(token.TokenHash); + + var tokenService = new TokenService(AssertionContext, hashService.Object); + + // Act & Assert + await Assert.ThrowsAsync(() => tokenService.GetValidTokenByHashAsync(token.TokenHash)); + + // Assert + Assert.True((await AssertionContext.Tokens.FirstOrDefaultAsync(t => t.Id == refreshToken.Id)).Revoked); + foreach (var otherToken in otherTokens) + { + Assert.False((await AssertionContext.Tokens.FirstOrDefaultAsync(t => t.Id == otherToken.Id)).Revoked); + } + } + } +} diff --git a/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs b/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs index 351639bf..a5a62d7f 100644 --- a/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs +++ b/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs @@ -14,6 +14,8 @@ using CoffeeCard.Models.Entities; using CoffeeCard.WebApi.Helpers; using System.ComponentModel.DataAnnotations; +using CoffeeCard.Models.DataTransferObjects.User; +using Microsoft.AspNetCore.Identity.Data; namespace CoffeeCard.WebApi.Controllers.v2 { @@ -33,7 +35,8 @@ public class AccountController : ControllerBase /// /// Initializes a new instance of the class. /// - public AccountController(IAccountService accountService, ClaimsUtilities claimsUtilities, + public AccountController(IAccountService accountService, + ClaimsUtilities claimsUtilities, ILeaderboardService leaderboardService) { _accountService = accountService; @@ -155,7 +158,8 @@ public async Task> EmailExists([FromBody] Emai [ProducesResponseType(typeof(ApiError), StatusCodes.Status401Unauthorized)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] [Route("{id:int}/user-group")] - public async Task UpdateAccountUserGroup([FromRoute] int id, [FromBody] UpdateUserGroupRequest updateUserGroupRequest) + public async Task UpdateAccountUserGroup([FromRoute] int id, + [FromBody] UpdateUserGroupRequest updateUserGroupRequest) { await _accountService.UpdateUserGroup(updateUserGroupRequest.UserGroup, id); @@ -220,9 +224,47 @@ private async Task UserWithRanking(User user) [ProducesResponseType(typeof(ApiError), StatusCodes.Status401Unauthorized)] [ProducesResponseType(typeof(UserSearchResponse), StatusCodes.Status200OK)] [Route("search")] - public async Task> SearchUsers([FromQuery][Range(0, int.MaxValue)] int pageNum, [FromQuery] string filter = "", [FromQuery][Range(1, 100)] int pageLength = 30) + public async Task> SearchUsers( + [FromQuery][Range(0, int.MaxValue)] int pageNum, [FromQuery] string filter = "", + [FromQuery][Range(1, 100)] int pageLength = 30) { return Ok(await _accountService.SearchUsers(filter, pageNum, pageLength)); } + + /// + /// Sends a magic link to the user's email to login + /// + /// User's email + /// + [HttpPost] + [AllowAnonymous] + [Route("login")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesDefaultResponseType] + public async Task Login([FromBody] UserLoginRequest request) + { + await _accountService.SendMagicLinkEmail(request.Email, request.LoginType); + return new NoContentResult(); + } + + /// + /// Authenticates the user with the token hash from a magic link + /// + /// The token hash from the magic link + /// A JSON Web Token used to authenticate for other endpoints and a refresh token to re-authenticate without a new magic link + [HttpPost] + [AllowAnonymous] + [Route("auth")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(MessageResponseDto), StatusCodes.Status404NotFound)] + [ProducesDefaultResponseType] + public async Task> Authenticate(string tokenHash) + { + if (tokenHash is null) + return NotFound(new MessageResponseDto { Message = "Token required for app authentication." }); + + var token = await _accountService.GenerateUserLoginFromToken(tokenHash); + return Ok(token); + } } -} \ No newline at end of file +} diff --git a/coffeecard/CoffeeCard.WebApi/Startup.cs b/coffeecard/CoffeeCard.WebApi/Startup.cs index d2fd0489..93eee9e2 100644 --- a/coffeecard/CoffeeCard.WebApi/Startup.cs +++ b/coffeecard/CoffeeCard.WebApi/Startup.cs @@ -77,7 +77,8 @@ public void ConfigureServices(IServiceCollection services) services.AddSingleton(_environment); services.AddSingleton(); services.AddScoped(); - services.AddTransient(); + services.AddTransient(); + services.AddScoped(); services.AddSingleton(); services.AddScoped(); services.AddScoped(); @@ -92,7 +93,8 @@ public void ConfigureServices(IServiceCollection services) { services.AddTransient(); } - services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/coffeecard/CoffeeCard.WebApi/appsettings.json b/coffeecard/CoffeeCard.WebApi/appsettings.json index a377b255..03c6f3a7 100644 --- a/coffeecard/CoffeeCard.WebApi/appsettings.json +++ b/coffeecard/CoffeeCard.WebApi/appsettings.json @@ -6,7 +6,8 @@ "EnvironmentSettings": { "EnvironmentType": "LocalDevelopment", "MinAppVersion": "2.0.0", - "DeploymentUrl": "https://localhost:8080/" + "DeploymentUrl": "https://localhost:8081/", + "ShiftyUrl": "https://localhost:8001/" }, "DatabaseSettings": { "ConnectionString": "Server=localhost;Initial Catalog=master;User=sa;Password=Your_password123;TrustServerCertificate=True;", @@ -86,4 +87,4 @@ "FeatureManagement": { "MobilePayManageWebhookRegistration": false } -} \ No newline at end of file +} diff --git a/coffeecard/CoffeeCard.WebApi/wwwroot/Templates/EmailTemplate/GeneratedEmails/email_magic_link_login.html b/coffeecard/CoffeeCard.WebApi/wwwroot/Templates/EmailTemplate/GeneratedEmails/email_magic_link_login.html new file mode 100644 index 00000000..3d419bbc --- /dev/null +++ b/coffeecard/CoffeeCard.WebApi/wwwroot/Templates/EmailTemplate/GeneratedEmails/email_magic_link_login.html @@ -0,0 +1,27 @@ +
Analog logo
Hello {name},
Use the following link to login to the app. This email expires in {expires}.
If you did not try to login you can safely disregard this message.
Login
Cafe Analog
Rued Langgaards Vej 7, 2300 Copenhagen S / CVR: 34657343
\ No newline at end of file diff --git a/coffeecard/CoffeeCard.WebApi/wwwroot/Templates/EmailTemplate/SourceEmails/email_magic_link_login.mjml b/coffeecard/CoffeeCard.WebApi/wwwroot/Templates/EmailTemplate/SourceEmails/email_magic_link_login.mjml new file mode 100644 index 00000000..446426d4 --- /dev/null +++ b/coffeecard/CoffeeCard.WebApi/wwwroot/Templates/EmailTemplate/SourceEmails/email_magic_link_login.mjml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + Hello {name}, + + + Use the following link to login to the app. This email expires in {expires}. + + + + If you did not try to login you can safely disregard this message. + + + + Login + + + + + + + + + + + + diff --git a/infrastructure/bicepconfig.json b/infrastructure/bicepconfig.json index 8b4eae54..796f8349 100644 --- a/infrastructure/bicepconfig.json +++ b/infrastructure/bicepconfig.json @@ -40,4 +40,4 @@ } } } -} \ No newline at end of file +} diff --git a/infrastructure/dev.settings.json b/infrastructure/dev.settings.json index 230341b1..75191726 100644 --- a/infrastructure/dev.settings.json +++ b/infrastructure/dev.settings.json @@ -21,6 +21,10 @@ "name": "EnvironmentSettings__DeploymentUrl", "value": "https://core.dev.analogio.dk/" }, + { + "name": "EnvironmentSettings__ShiftyUrl", + "value": "https://shifty.dev.analogio.dk/" + }, { "name": "MailgunSettings__Domain", "value": "mg.cafeanalog.dk" @@ -89,4 +93,5 @@ } ] } -} \ No newline at end of file +} + diff --git a/infrastructure/prd.settings.json b/infrastructure/prd.settings.json index 385f3a0c..27cb71d0 100644 --- a/infrastructure/prd.settings.json +++ b/infrastructure/prd.settings.json @@ -21,6 +21,10 @@ "name": "EnvironmentSettings__DeploymentUrl", "value": "https://core.prd.analogio.dk/" }, + { + "name": "EnvironmentSettings__ShiftyUrl", + "value": "https://shifty.prd.analogio.dk/" + }, { "name": "MailgunSettings__Domain", "value": "mg.cafeanalog.dk"