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