diff --git a/EmailManager.sln b/EmailManager.sln index e1ce735..fe7bdde 100644 --- a/EmailManager.sln +++ b/EmailManager.sln @@ -16,6 +16,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EmailManager.Shared", "src\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EmailManager.BusinessLayer", "src\EmailManager.BusinessLayer\EmailManager.BusinessLayer.csproj", "{D7DF698B-95A0-4515-A88F-65DA674E97F3}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EmailManager.DataAccessLayer", "src\EmailManager.DataAccessLayer\EmailManager.DataAccessLayer.csproj", "{EEA8A36B-9EA6-42B5-A371-0E406A48E3FE}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -34,6 +36,10 @@ Global {D7DF698B-95A0-4515-A88F-65DA674E97F3}.Debug|Any CPU.Build.0 = Debug|Any CPU {D7DF698B-95A0-4515-A88F-65DA674E97F3}.Release|Any CPU.ActiveCfg = Release|Any CPU {D7DF698B-95A0-4515-A88F-65DA674E97F3}.Release|Any CPU.Build.0 = Release|Any CPU + {EEA8A36B-9EA6-42B5-A371-0E406A48E3FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EEA8A36B-9EA6-42B5-A371-0E406A48E3FE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EEA8A36B-9EA6-42B5-A371-0E406A48E3FE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EEA8A36B-9EA6-42B5-A371-0E406A48E3FE}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/EmailManager.BusinessLayer/Authentication/SubscriptionValidator.cs b/src/EmailManager.BusinessLayer/Authentication/SubscriptionValidator.cs new file mode 100644 index 0000000..30c1629 --- /dev/null +++ b/src/EmailManager.BusinessLayer/Authentication/SubscriptionValidator.cs @@ -0,0 +1,32 @@ +using System.Security.Claims; +using EmailManager.DataAccessLayer; +using Microsoft.EntityFrameworkCore; +using SimpleAuthentication.ApiKey; + +namespace EmailManager.BusinessLayer.Authentication; + +public class SubscriptionValidator(ApplicationDbContext dbContext, TimeProvider timeProvider) : IApiKeyValidator +{ + public async Task ValidateAsync(string apiKey) + { + var subscription = await dbContext.Subscriptions.FirstOrDefaultAsync(s => s.ApiKey == apiKey); + if (subscription is null) + { + return ApiKeyValidationResult.Fail("API key is invalid"); + } + + var now = timeProvider.GetUtcNow(); + if (subscription.ValidFrom > now || subscription.ValidTo < now) + { + return ApiKeyValidationResult.Fail("API key is expired"); + } + + var claims = new List + { + new("requests_per_window", subscription.RequestsPerWindow.ToString()), + new("window_minutes", subscription.WindowMinutes.ToString()) + }; + + return ApiKeyValidationResult.Success(subscription.UserName, claims); + } +} \ No newline at end of file diff --git a/src/EmailManager.BusinessLayer/EmailManager.BusinessLayer.csproj b/src/EmailManager.BusinessLayer/EmailManager.BusinessLayer.csproj index 2208062..d98a0b3 100644 --- a/src/EmailManager.BusinessLayer/EmailManager.BusinessLayer.csproj +++ b/src/EmailManager.BusinessLayer/EmailManager.BusinessLayer.csproj @@ -13,9 +13,11 @@ + + diff --git a/src/EmailManager.DataAccessLayer/ApplicationDbContext.cs b/src/EmailManager.DataAccessLayer/ApplicationDbContext.cs new file mode 100644 index 0000000..b004f8c --- /dev/null +++ b/src/EmailManager.DataAccessLayer/ApplicationDbContext.cs @@ -0,0 +1,57 @@ +using System.Reflection; +using EmailManager.DataAccessLayer.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; + +namespace EmailManager.DataAccessLayer; + +public class ApplicationDbContext(DbContextOptions options, IConfiguration configuration) : DbContext(options) +{ + public DbSet Subscriptions { get; set; } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + base.OnConfiguring(optionsBuilder); + + var administratorUserName = configuration.GetValue("AppSettings:AdministratorUserName")!; + var administratorApiKey = configuration.GetValue("AppSettings:AdministratorApiKey")!; + + optionsBuilder.UseSeeding((context, _) => + { + var subscription = context.Set().FirstOrDefault(s => s.UserName == administratorUserName); + CheckSubscription(context, subscription, administratorUserName, administratorApiKey); + + context.SaveChanges(); + }); + + optionsBuilder.UseAsyncSeeding(async (context, _, cancellationToken) => + { + var subscription = await context.Set().FirstOrDefaultAsync(s => s.UserName == administratorUserName, cancellationToken: cancellationToken); + CheckSubscription(context, subscription, administratorUserName, administratorApiKey); + + await context.SaveChangesAsync(cancellationToken); + }); + + static void CheckSubscription(DbContext context, Subscription? subscription, string administratorUserName, string administratorApiKey) + { + if (subscription is null) + { + context.Add(new Subscription + { + UserName = administratorUserName, + ApiKey = administratorApiKey, + RequestsPerWindow = 10, + WindowMinutes = 1, + ValidFrom = DateTimeOffset.MinValue, + ValidTo = DateTimeOffset.MaxValue + }); + } + } + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly()); + base.OnModelCreating(modelBuilder); + } +} \ No newline at end of file diff --git a/src/EmailManager.DataAccessLayer/Configurations/SubscriptionConfiguration.cs b/src/EmailManager.DataAccessLayer/Configurations/SubscriptionConfiguration.cs new file mode 100644 index 0000000..edbd0a0 --- /dev/null +++ b/src/EmailManager.DataAccessLayer/Configurations/SubscriptionConfiguration.cs @@ -0,0 +1,21 @@ +using EmailManager.DataAccessLayer.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace EmailManager.DataAccessLayer.Configurations; + +internal class SubscriptionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Subscriptions"); + builder.HasKey(e => e.Id); + builder.Property(e => e.Id).HasDefaultValueSql("(newsequentialid())"); + + builder.HasIndex(e => e.ApiKey, "IX_Subscriptions_ApiKey").IsUnique(); + builder.HasIndex(e => e.UserName, "IX_Subscriptions_UserName").IsUnique(); + + builder.Property(e => e.ApiKey).HasMaxLength(512).IsUnicode(false); + builder.Property(e => e.UserName).HasMaxLength(255); + } +} \ No newline at end of file diff --git a/src/EmailManager.DataAccessLayer/EmailManager.DataAccessLayer.csproj b/src/EmailManager.DataAccessLayer/EmailManager.DataAccessLayer.csproj new file mode 100644 index 0000000..d81b3cf --- /dev/null +++ b/src/EmailManager.DataAccessLayer/EmailManager.DataAccessLayer.csproj @@ -0,0 +1,19 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + + diff --git a/src/EmailManager.DataAccessLayer/Entities/Subscription.cs b/src/EmailManager.DataAccessLayer/Entities/Subscription.cs new file mode 100644 index 0000000..d36b7a7 --- /dev/null +++ b/src/EmailManager.DataAccessLayer/Entities/Subscription.cs @@ -0,0 +1,18 @@ +namespace EmailManager.DataAccessLayer.Entities; + +public class Subscription +{ + public Guid Id { get; set; } + + public string UserName { get; set; } = null!; + + public string ApiKey { get; set; } = null!; + + public DateTimeOffset ValidFrom { get; set; } + + public DateTimeOffset ValidTo { get; set; } + + public int RequestsPerWindow { get; set; } + + public int WindowMinutes { get; set; } +} \ No newline at end of file diff --git a/src/EmailManager.DataAccessLayer/Migrations/20250905161309_Initial.Designer.cs b/src/EmailManager.DataAccessLayer/Migrations/20250905161309_Initial.Designer.cs new file mode 100644 index 0000000..8830290 --- /dev/null +++ b/src/EmailManager.DataAccessLayer/Migrations/20250905161309_Initial.Designer.cs @@ -0,0 +1,71 @@ +// +using System; +using EmailManager.DataAccessLayer; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EmailManager.DataAccessLayer.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20250905161309_Initial")] + partial class Initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("EmailManager.DataAccessLayer.Entities.Subscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("(newsequentialid())"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(512) + .IsUnicode(false) + .HasColumnType("varchar(512)"); + + b.Property("RequestsPerWindow") + .HasColumnType("int"); + + b.Property("UserName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("ValidFrom") + .HasColumnType("datetimeoffset"); + + b.Property("ValidTo") + .HasColumnType("datetimeoffset"); + + b.Property("WindowMinutes") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "ApiKey" }, "IX_Subscriptions_ApiKey") + .IsUnique(); + + b.HasIndex(new[] { "UserName" }, "IX_Subscriptions_UserName") + .IsUnique(); + + b.ToTable("Subscriptions", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EmailManager.DataAccessLayer/Migrations/20250905161309_Initial.cs b/src/EmailManager.DataAccessLayer/Migrations/20250905161309_Initial.cs new file mode 100644 index 0000000..e8ef50d --- /dev/null +++ b/src/EmailManager.DataAccessLayer/Migrations/20250905161309_Initial.cs @@ -0,0 +1,51 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EmailManager.DataAccessLayer.Migrations +{ + /// + public partial class Initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Subscriptions", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false, defaultValueSql: "(newsequentialid())"), + UserName = table.Column(type: "nvarchar(255)", maxLength: 255, nullable: false), + ApiKey = table.Column(type: "varchar(512)", unicode: false, maxLength: 512, nullable: false), + ValidFrom = table.Column(type: "datetimeoffset", nullable: false), + ValidTo = table.Column(type: "datetimeoffset", nullable: false), + RequestsPerWindow = table.Column(type: "int", nullable: false), + WindowMinutes = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Subscriptions", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_Subscriptions_ApiKey", + table: "Subscriptions", + column: "ApiKey", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Subscriptions_UserName", + table: "Subscriptions", + column: "UserName", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Subscriptions"); + } + } +} diff --git a/src/EmailManager.DataAccessLayer/Migrations/ApplicationDbContextModelSnapshot.cs b/src/EmailManager.DataAccessLayer/Migrations/ApplicationDbContextModelSnapshot.cs new file mode 100644 index 0000000..1e8a1ec --- /dev/null +++ b/src/EmailManager.DataAccessLayer/Migrations/ApplicationDbContextModelSnapshot.cs @@ -0,0 +1,68 @@ +// +using System; +using EmailManager.DataAccessLayer; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EmailManager.DataAccessLayer.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + partial class ApplicationDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("EmailManager.DataAccessLayer.Entities.Subscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("(newsequentialid())"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(512) + .IsUnicode(false) + .HasColumnType("varchar(512)"); + + b.Property("RequestsPerWindow") + .HasColumnType("int"); + + b.Property("UserName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("ValidFrom") + .HasColumnType("datetimeoffset"); + + b.Property("ValidTo") + .HasColumnType("datetimeoffset"); + + b.Property("WindowMinutes") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "ApiKey" }, "IX_Subscriptions_ApiKey") + .IsUnique(); + + b.HasIndex(new[] { "UserName" }, "IX_Subscriptions_UserName") + .IsUnique(); + + b.ToTable("Subscriptions", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EmailManager/EmailManager.csproj b/src/EmailManager/EmailManager.csproj index 2a07782..2f798e2 100644 --- a/src/EmailManager/EmailManager.csproj +++ b/src/EmailManager/EmailManager.csproj @@ -8,10 +8,15 @@ - - - - + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + diff --git a/src/EmailManager/Program.cs b/src/EmailManager/Program.cs index 401ba7d..69ce8d0 100644 --- a/src/EmailManager/Program.cs +++ b/src/EmailManager/Program.cs @@ -1,12 +1,17 @@ using System.Globalization; using System.Threading.RateLimiting; +using EmailManager.BusinessLayer.Authentication; using EmailManager.BusinessLayer.Services; using EmailManager.BusinessLayer.Services.Interfaces; using EmailManager.BusinessLayer.Validations; +using EmailManager.DataAccessLayer; using EmailManager.Shared.Models; using FluentValidation; using Microsoft.AspNetCore.Localization; +using Microsoft.EntityFrameworkCore; using MinimalHelpers.FluentValidation; +using SimpleAuthentication; +using SimpleAuthentication.ApiKey; using TinyHelpers.AspNetCore.Extensions; using TinyHelpers.AspNetCore.OpenApi; @@ -57,18 +62,27 @@ ValidatorOptions.Global.LanguageManager.Enabled = false; builder.Services.AddValidatorsFromAssemblyContaining(); +builder.Services.AddSingleton(TimeProvider.System); + builder.Services.AddOpenApi(options => { options.RemoveServerList(); + options.AddSimpleAuthentication(builder.Configuration); options.AddDefaultProblemDetailsResponse(); options.AddAcceptLanguageHeader(); }); +builder.Services.AddSimpleAuthentication(builder.Configuration); +builder.Services.AddTransient(); + +builder.Services.AddAzureSql(builder.Configuration.GetConnectionString("SqlConnection")); + builder.Services.AddDefaultProblemDetails(); builder.Services.AddDefaultExceptionHandler(); var app = builder.Build(); +await ConfigureDatabaseAsync(app.Services); // Configure the HTTP request pipeline. app.UseHttpsRedirection(); @@ -87,8 +101,8 @@ app.UseRequestLocalization(); -//app.UseAuthentication(); -//app.UseAuthorization(); +app.UseAuthentication(); +app.UseAuthorization(); app.UseRateLimiter(); @@ -100,7 +114,16 @@ return TypedResults.Ok(response); }) .Produces() +.RequireAuthorization() .WithValidation() .RequireRateLimiting("SendEmail"); app.Run(); + +static async Task ConfigureDatabaseAsync(IServiceProvider serviceProvider) +{ + await using var scope = serviceProvider.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + await dbContext.Database.MigrateAsync(); +} \ No newline at end of file diff --git a/src/EmailManager/appsettings.Development.json b/src/EmailManager/appsettings.Development.json index bfa1112..85799fe 100644 --- a/src/EmailManager/appsettings.Development.json +++ b/src/EmailManager/appsettings.Development.json @@ -1,4 +1,11 @@ { + "ConnectionStrings": { + "SqlConnection": "" + }, + "AppSettings": { + "AdministratorUserName": "", + "AdministratorApiKey": "" + }, "EmailSettings": { "SmtpSettings": { "Host": "localhost", @@ -12,4 +19,4 @@ "Microsoft.AspNetCore": "Warning" } } -} +} \ No newline at end of file diff --git a/src/EmailManager/appsettings.json b/src/EmailManager/appsettings.json index 0088a88..f4d993c 100644 --- a/src/EmailManager/appsettings.json +++ b/src/EmailManager/appsettings.json @@ -1,4 +1,12 @@ { + "ConnectionStrings": { + "SqlConnection": "" + }, + "Authentication": { + "ApiKey": { + "HeaderName": "x-api-key" + } + }, "EmailSettings": { "SmtpSettings": { "Host": "",