From 541eb1cc7970f58387fd66544938631a1a59a5e9 Mon Sep 17 00:00:00 2001 From: Tino Hager Date: Tue, 2 Jan 2024 14:06:26 +0100 Subject: [PATCH] Optimize bruteforce protection, add .net 8 support. cleanup --- .github/workflows/dotnet.yml | 4 +- .../Entities/UserEntity.cs | 7 +- .../Models/AuthenticationInfo.cs | 4 +- .../Models/UserInfo.cs | 8 +- .../Nager.Authentication.Abstraction.csproj | 2 +- .../Repositories/IUserRepository.cs | 8 ++ .../Controllers/UserManagementController.cs | 8 +- .../Dtos/UserInfoDto.cs | 8 +- .../Nager.Authentication.AspNet.csproj | 18 ++-- .../InMemoryUserRepository.cs | 30 +++++++ .../DatabaseContext.cs | 23 ----- .../DatabaseContextFactory.cs | 20 ----- .../20230621115350_Initial.Designer.cs | 68 --------------- .../Migrations/20230621115350_Initial.cs | 43 --------- .../DatabaseContextModelSnapshot.cs | 65 -------------- .../MssqlUserRepository.cs | 87 ------------------- ...ager.Authentication.MssqlRepository.csproj | 26 ------ .../readme.md | 23 ----- .../start development mssql server.cmd | 4 - .../MigrationHelper.cs | 49 ----------- ....Authentication.TestProject.WebApp6.csproj | 1 - .../Program.cs | 23 ----- .../Helpers/LoggerHelper.cs | 37 ++++++++ .../Nager.Authentication.UnitTest.csproj | 3 +- .../UserServiceTest.cs | 15 +++- src/Nager.Authentication.sln | 16 +--- .../Nager.Authentication.csproj | 14 ++- .../Services/UserAuthenticationService.cs | 68 ++++++++++----- .../Services/UserManagementService.cs | 44 ++++++---- 29 files changed, 214 insertions(+), 512 deletions(-) delete mode 100644 src/Nager.Authentication.MssqlRepository/DatabaseContext.cs delete mode 100644 src/Nager.Authentication.MssqlRepository/DatabaseContextFactory.cs delete mode 100644 src/Nager.Authentication.MssqlRepository/Migrations/20230621115350_Initial.Designer.cs delete mode 100644 src/Nager.Authentication.MssqlRepository/Migrations/20230621115350_Initial.cs delete mode 100644 src/Nager.Authentication.MssqlRepository/Migrations/DatabaseContextModelSnapshot.cs delete mode 100644 src/Nager.Authentication.MssqlRepository/MssqlUserRepository.cs delete mode 100644 src/Nager.Authentication.MssqlRepository/Nager.Authentication.MssqlRepository.csproj delete mode 100644 src/Nager.Authentication.MssqlRepository/readme.md delete mode 100644 src/Nager.Authentication.MssqlRepository/start development mssql server.cmd delete mode 100644 src/Nager.Authentication.TestProject.WebApp6/MigrationHelper.cs create mode 100644 src/Nager.Authentication.UnitTest/Helpers/LoggerHelper.cs diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index b886884..df2c359 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -18,10 +18,10 @@ jobs: steps: - uses: actions/checkout@v3 - - name: Setup .NET 6.0 + - name: Setup .NET 8.0 uses: actions/setup-dotnet@v3 with: - dotnet-version: 6.0.x + dotnet-version: 8.0.x - name: Restore dependencies working-directory: ./src run: dotnet restore diff --git a/src/Nager.Authentication.Abstraction/Entities/UserEntity.cs b/src/Nager.Authentication.Abstraction/Entities/UserEntity.cs index b3292d4..12cd10c 100644 --- a/src/Nager.Authentication.Abstraction/Entities/UserEntity.cs +++ b/src/Nager.Authentication.Abstraction/Entities/UserEntity.cs @@ -1,4 +1,5 @@ -using System.ComponentModel.DataAnnotations; +using System; +using System.ComponentModel.DataAnnotations; namespace Nager.Authentication.Abstraction.Entities { @@ -24,5 +25,9 @@ public class UserEntity [MaxLength(32)] public byte[]? PasswordHash { get; set; } + + public DateTime LastValidationTimestamp { get; set; } + + public DateTime LastSuccessfulValidationTimestamp { get; set; } } } diff --git a/src/Nager.Authentication.Abstraction/Models/AuthenticationInfo.cs b/src/Nager.Authentication.Abstraction/Models/AuthenticationInfo.cs index f7844cb..fa44e7e 100644 --- a/src/Nager.Authentication.Abstraction/Models/AuthenticationInfo.cs +++ b/src/Nager.Authentication.Abstraction/Models/AuthenticationInfo.cs @@ -5,7 +5,9 @@ namespace Nager.Authentication.Abstraction.Models public class AuthenticationInfo { public DateTime LastValid { get; set; } - public int InvalidCount { get; set; } + public DateTime LastInvalid { get; set; } + + public int InvalidCount { get; set; } } } diff --git a/src/Nager.Authentication.Abstraction/Models/UserInfo.cs b/src/Nager.Authentication.Abstraction/Models/UserInfo.cs index 9237cf3..f658e78 100644 --- a/src/Nager.Authentication.Abstraction/Models/UserInfo.cs +++ b/src/Nager.Authentication.Abstraction/Models/UserInfo.cs @@ -1,4 +1,6 @@ -namespace Nager.Authentication.Abstraction.Models +using System; + +namespace Nager.Authentication.Abstraction.Models { public class UserInfo { @@ -11,5 +13,9 @@ public class UserInfo public string? Firstname { get; set; } public string? Lastname { get; set; } + + public DateTime LastValidationTimestamp { get; set; } + + public DateTime LastSuccessfulValidationTimestamp { get; set; } } } diff --git a/src/Nager.Authentication.Abstraction/Nager.Authentication.Abstraction.csproj b/src/Nager.Authentication.Abstraction/Nager.Authentication.Abstraction.csproj index 2fc0025..6229389 100644 --- a/src/Nager.Authentication.Abstraction/Nager.Authentication.Abstraction.csproj +++ b/src/Nager.Authentication.Abstraction/Nager.Authentication.Abstraction.csproj @@ -20,7 +20,7 @@ enable netstandard2.1 - 1.0.1 + 1.1.0 diff --git a/src/Nager.Authentication.Abstraction/Repositories/IUserRepository.cs b/src/Nager.Authentication.Abstraction/Repositories/IUserRepository.cs index 4fc95b6..6f38ce1 100644 --- a/src/Nager.Authentication.Abstraction/Repositories/IUserRepository.cs +++ b/src/Nager.Authentication.Abstraction/Repositories/IUserRepository.cs @@ -29,5 +29,13 @@ Task UpdateAsync( Task DeleteAsync( Expression> predicate, CancellationToken cancellationToken = default); + + Task SetLastValidationTimestampAsync( + Expression> predicate, + CancellationToken cancellationToken = default); + + Task SetLastSuccessfulValidationTimestampAsync( + Expression> predicate, + CancellationToken cancellationToken = default); } } diff --git a/src/Nager.Authentication.AspNet/Controllers/UserManagementController.cs b/src/Nager.Authentication.AspNet/Controllers/UserManagementController.cs index e1ada1f..19c1318 100644 --- a/src/Nager.Authentication.AspNet/Controllers/UserManagementController.cs +++ b/src/Nager.Authentication.AspNet/Controllers/UserManagementController.cs @@ -63,7 +63,9 @@ public async Task> GetUserAsync( EmailAddress = userInfo.EmailAddress, Firstname = userInfo.Firstname, Lastname = userInfo.Lastname, - Roles = userInfo.Roles + Roles = userInfo.Roles, + LastValidationTimestamp = userInfo.LastValidationTimestamp, + LastSuccessfulValidationTimestamp = userInfo.LastSuccessfulValidationTimestamp }; return StatusCode(StatusCodes.Status200OK, item); @@ -90,7 +92,9 @@ public async Task> QueryUsersAsync( EmailAddress = userInfo.EmailAddress, Firstname = userInfo.Firstname, Lastname = userInfo.Lastname, - Roles = userInfo.Roles + Roles = userInfo.Roles, + LastValidationTimestamp = userInfo.LastValidationTimestamp, + LastSuccessfulValidationTimestamp = userInfo.LastSuccessfulValidationTimestamp }); return StatusCode(StatusCodes.Status200OK, items); diff --git a/src/Nager.Authentication.AspNet/Dtos/UserInfoDto.cs b/src/Nager.Authentication.AspNet/Dtos/UserInfoDto.cs index b315818..8a13167 100644 --- a/src/Nager.Authentication.AspNet/Dtos/UserInfoDto.cs +++ b/src/Nager.Authentication.AspNet/Dtos/UserInfoDto.cs @@ -1,4 +1,6 @@ -namespace Nager.Authentication.AspNet.Dtos +using System; + +namespace Nager.Authentication.AspNet.Dtos { public class UserInfoDto { @@ -11,5 +13,9 @@ public class UserInfoDto public string Firstname { get; set; } public string Lastname { get; set; } + + public DateTime LastValidationTimestamp { get; set; } + + public DateTime LastSuccessfulValidationTimestamp { get; set; } } } diff --git a/src/Nager.Authentication.AspNet/Nager.Authentication.AspNet.csproj b/src/Nager.Authentication.AspNet/Nager.Authentication.AspNet.csproj index 98997e4..0ae5459 100644 --- a/src/Nager.Authentication.AspNet/Nager.Authentication.AspNet.csproj +++ b/src/Nager.Authentication.AspNet/Nager.Authentication.AspNet.csproj @@ -15,10 +15,10 @@ https://github.com/nager/Nager.Authentication Authentication - net7.0;net6.0 + net8.0;net7.0;net6.0 enable - 1.0.9 + 1.1.0 @@ -26,15 +26,21 @@ - + - + - + - + + + + + + + diff --git a/src/Nager.Authentication.InMemoryRepository/InMemoryUserRepository.cs b/src/Nager.Authentication.InMemoryRepository/InMemoryUserRepository.cs index 89bae21..dd0726b 100644 --- a/src/Nager.Authentication.InMemoryRepository/InMemoryUserRepository.cs +++ b/src/Nager.Authentication.InMemoryRepository/InMemoryUserRepository.cs @@ -85,5 +85,35 @@ public Task DeleteAsync( var isSuccessful = this._userInfos.TryRemove(item.Id, out _); return Task.FromResult(isSuccessful); } + + public Task SetLastValidationTimestampAsync( + Expression> predicate, + CancellationToken cancellationToken = default) + { + var item = this._userInfos.Values.AsQueryable().Where(predicate).FirstOrDefault(); + if (item == null) + { + return Task.FromResult(false); + } + + item.LastValidationTimestamp = DateTime.UtcNow; + + return Task.FromResult(true); + } + + public Task SetLastSuccessfulValidationTimestampAsync( + Expression> predicate, + CancellationToken cancellationToken = default) + { + var item = this._userInfos.Values.AsQueryable().Where(predicate).FirstOrDefault(); + if (item == null) + { + return Task.FromResult(false); + } + + item.LastSuccessfulValidationTimestamp = DateTime.UtcNow; + + return Task.FromResult(true); + } } } diff --git a/src/Nager.Authentication.MssqlRepository/DatabaseContext.cs b/src/Nager.Authentication.MssqlRepository/DatabaseContext.cs deleted file mode 100644 index 1e11d0a..0000000 --- a/src/Nager.Authentication.MssqlRepository/DatabaseContext.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Nager.Authentication.Abstraction.Entities; - -namespace Nager.Authentication.MssqlRepository -{ - public class DatabaseContext : DbContext - { - private readonly DbContextOptions _options; - - public DbSet Users { get; set; } - - public DatabaseContext(DbContextOptions options) - : base(options) - { - this._options = options; - } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.Entity().HasIndex(entity => new { entity.EmailAddress }); - } - } -} diff --git a/src/Nager.Authentication.MssqlRepository/DatabaseContextFactory.cs b/src/Nager.Authentication.MssqlRepository/DatabaseContextFactory.cs deleted file mode 100644 index b6535ac..0000000 --- a/src/Nager.Authentication.MssqlRepository/DatabaseContextFactory.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Design; - -namespace Nager.Authentication.MssqlRepository -{ - /// - /// Database Context Factory - /// - /// Only required for database migration - public class DatabaseContextFactory : IDesignTimeDbContextFactory - { - public DatabaseContext CreateDbContext(string[] args) - { - var optionsBuilder = new DbContextOptionsBuilder(); - optionsBuilder.UseSqlServer("Server=localhost;Database=CourseManagement;User Id=sa;Password=Secure-Password.1234;"); - - return new DatabaseContext(optionsBuilder.Options); - } - } -} \ No newline at end of file diff --git a/src/Nager.Authentication.MssqlRepository/Migrations/20230621115350_Initial.Designer.cs b/src/Nager.Authentication.MssqlRepository/Migrations/20230621115350_Initial.Designer.cs deleted file mode 100644 index 3ba614d..0000000 --- a/src/Nager.Authentication.MssqlRepository/Migrations/20230621115350_Initial.Designer.cs +++ /dev/null @@ -1,68 +0,0 @@ -// -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Nager.Authentication.MssqlRepository; - -#nullable disable - -namespace Nager.Authentication.MssqlRepository.Migrations -{ - [DbContext(typeof(DatabaseContext))] - [Migration("20230621115350_Initial")] - partial class Initial - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "7.0.7") - .HasAnnotation("Relational:MaxIdentifierLength", 128); - - SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - - modelBuilder.Entity("Nager.Authentication.Abstraction.Entities.UserEntity", b => - { - b.Property("Id") - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("EmailAddress") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("Firstname") - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("Lastname") - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("PasswordHash") - .HasMaxLength(32) - .HasColumnType("varbinary(32)"); - - b.Property("PasswordSalt") - .IsRequired() - .HasMaxLength(16) - .HasColumnType("varbinary(16)"); - - b.Property("RolesData") - .HasMaxLength(1000) - .HasColumnType("nvarchar(1000)"); - - b.HasKey("Id"); - - b.HasIndex("EmailAddress"); - - b.ToTable("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/Nager.Authentication.MssqlRepository/Migrations/20230621115350_Initial.cs b/src/Nager.Authentication.MssqlRepository/Migrations/20230621115350_Initial.cs deleted file mode 100644 index 9d7a0c2..0000000 --- a/src/Nager.Authentication.MssqlRepository/Migrations/20230621115350_Initial.cs +++ /dev/null @@ -1,43 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Nager.Authentication.MssqlRepository.Migrations -{ - /// - public partial class Initial : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "Users", - columns: table => new - { - Id = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), - EmailAddress = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), - Firstname = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), - Lastname = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), - RolesData = table.Column(type: "nvarchar(1000)", maxLength: 1000, nullable: true), - PasswordSalt = table.Column(type: "varbinary(16)", maxLength: 16, nullable: false), - PasswordHash = table.Column(type: "varbinary(32)", maxLength: 32, nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Users", x => x.Id); - }); - - migrationBuilder.CreateIndex( - name: "IX_Users_EmailAddress", - table: "Users", - column: "EmailAddress"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "Users"); - } - } -} diff --git a/src/Nager.Authentication.MssqlRepository/Migrations/DatabaseContextModelSnapshot.cs b/src/Nager.Authentication.MssqlRepository/Migrations/DatabaseContextModelSnapshot.cs deleted file mode 100644 index 9fe22ef..0000000 --- a/src/Nager.Authentication.MssqlRepository/Migrations/DatabaseContextModelSnapshot.cs +++ /dev/null @@ -1,65 +0,0 @@ -// -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Nager.Authentication.MssqlRepository; - -#nullable disable - -namespace Nager.Authentication.MssqlRepository.Migrations -{ - [DbContext(typeof(DatabaseContext))] - partial class DatabaseContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "7.0.7") - .HasAnnotation("Relational:MaxIdentifierLength", 128); - - SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - - modelBuilder.Entity("Nager.Authentication.Abstraction.Entities.UserEntity", b => - { - b.Property("Id") - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("EmailAddress") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("Firstname") - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("Lastname") - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("PasswordHash") - .HasMaxLength(32) - .HasColumnType("varbinary(32)"); - - b.Property("PasswordSalt") - .IsRequired() - .HasMaxLength(16) - .HasColumnType("varbinary(16)"); - - b.Property("RolesData") - .HasMaxLength(1000) - .HasColumnType("nvarchar(1000)"); - - b.HasKey("Id"); - - b.HasIndex("EmailAddress"); - - b.ToTable("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/Nager.Authentication.MssqlRepository/MssqlUserRepository.cs b/src/Nager.Authentication.MssqlRepository/MssqlUserRepository.cs deleted file mode 100644 index 49fafcc..0000000 --- a/src/Nager.Authentication.MssqlRepository/MssqlUserRepository.cs +++ /dev/null @@ -1,87 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Nager.Authentication.Abstraction.Entities; -using Nager.Authentication.Abstraction.Validators; -using System.Linq.Expressions; - -namespace Nager.Authentication.MssqlRepository -{ - public class MssqlUserRepository : IUserRepository - { - private readonly DatabaseContext _databaseContext; - - public MssqlUserRepository(DatabaseContext databaseContext) - { - this._databaseContext = databaseContext; - } - - public async Task QueryAsync( - int take, - int skip, - Expression>? predicate = default, - CancellationToken cancellationToken = default) - { - var query = this._databaseContext.Users.AsQueryable(); - if (predicate != null ) - { - query = query.Where(predicate); - } - - return await query.Skip(skip).Take(take).ToArrayAsync(cancellationToken); - } - - public async Task GetAsync( - Expression> predicate, - CancellationToken cancellationToken = default) - { - var query = this._databaseContext.Users.AsQueryable(); - if (predicate != null) - { - query = query.Where(predicate); - } - - return await query.FirstOrDefaultAsync(cancellationToken); - } - - public async Task AddAsync( - UserEntity entity, - CancellationToken cancellationToken = default) - { - this._databaseContext.Users.Add(entity); - await this._databaseContext.SaveChangesAsync(cancellationToken); - - return true; - } - - public async Task UpdateAsync( - UserEntity entity, - CancellationToken cancellationToken = default) - { - var existingItem = await this._databaseContext.Users.SingleOrDefaultAsync(o => o.Id == entity.Id, cancellationToken); - if (existingItem == null) - { - return false; - } - - existingItem.Firstname = entity.Firstname; - existingItem.Lastname = entity.Lastname; - existingItem.EmailAddress = entity.EmailAddress; - existingItem.RolesData = entity.RolesData; - existingItem.PasswordHash = entity.PasswordHash; - - await this._databaseContext.SaveChangesAsync(cancellationToken); - - return true; - } - - public async Task DeleteAsync( - Expression> predicate, - CancellationToken cancellationToken = default) - { - var items = await this._databaseContext.Users.Where(predicate).ToArrayAsync(cancellationToken); - this._databaseContext.Users.RemoveRange(items); - await this._databaseContext.SaveChangesAsync(cancellationToken); - - return true; - } - } -} diff --git a/src/Nager.Authentication.MssqlRepository/Nager.Authentication.MssqlRepository.csproj b/src/Nager.Authentication.MssqlRepository/Nager.Authentication.MssqlRepository.csproj deleted file mode 100644 index 5d5fe60..0000000 --- a/src/Nager.Authentication.MssqlRepository/Nager.Authentication.MssqlRepository.csproj +++ /dev/null @@ -1,26 +0,0 @@ - - - - net6.0 - enable - enable - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - diff --git a/src/Nager.Authentication.MssqlRepository/readme.md b/src/Nager.Authentication.MssqlRepository/readme.md deleted file mode 100644 index 11e2170..0000000 --- a/src/Nager.Authentication.MssqlRepository/readme.md +++ /dev/null @@ -1,23 +0,0 @@ -# Database - -## Start docker mssql -`start development mssql server.cmd` - - -# Entity Framework - Prepare Migration - -## Prepare EF Tools Visual Studio -dotnet tool install --global dotnet-ef --ignore-failed-sources - -## Update EF Tools Visual Studio -dotnet tool update --global dotnet-ef --ignore-failed-sources - - -# Entity Framework - Add/Remove Migration - -## After change the Database model or Initial -dotnet ef migrations add --project Nager.Authentication.MssqlRepository DESCRIPTION-TEXT-CHANGE-ME - -## Revert last commit -dotnet ef migrations remove --project Nager.Authentication.MssqlRepository - diff --git a/src/Nager.Authentication.MssqlRepository/start development mssql server.cmd b/src/Nager.Authentication.MssqlRepository/start development mssql server.cmd deleted file mode 100644 index d74a4ad..0000000 --- a/src/Nager.Authentication.MssqlRepository/start development mssql server.cmd +++ /dev/null @@ -1,4 +0,0 @@ -docker run --name Nager.Authentication -e "ACCEPT_EULA=Y" -e "SA_PASSWORD=yourStrong(!)Password" -p 1433:1433 -d mcr.microsoft.com/mssql/server:2022-latest -docker start Nager.Authentication - -timeout /T 3 \ No newline at end of file diff --git a/src/Nager.Authentication.TestProject.WebApp6/MigrationHelper.cs b/src/Nager.Authentication.TestProject.WebApp6/MigrationHelper.cs deleted file mode 100644 index 90f8390..0000000 --- a/src/Nager.Authentication.TestProject.WebApp6/MigrationHelper.cs +++ /dev/null @@ -1,49 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Nager.Authentication.MssqlRepository; - -namespace Nager.Authentication.TestProject.WebApp6 -{ - public class MigrationHelper - { - private readonly ILogger _logger; - private readonly IServiceScopeFactory _serviceScopeFactory; - - public MigrationHelper( - ILogger logger, - IServiceScopeFactory serviceScopeFactory) - { - this._logger = logger; - this._serviceScopeFactory = serviceScopeFactory; - } - - public async Task UpdateDatabaseAsync() - { - var retryCount = 60; - - for (var i = 0; i < retryCount; i++) - { - using var serviceScope = this._serviceScopeFactory.CreateScope(); - using var context = serviceScope.ServiceProvider.GetService(); - - if (context == null) - { - this._logger.LogError($"{nameof(UpdateDatabaseAsync)} - Context is not available"); - return false; - } - - try - { - await context.Database.MigrateAsync(); - return true; - } - catch (Exception exception) - { - this._logger.LogError(exception, $"{nameof(UpdateDatabaseAsync)} - Cannot execute database migrate, retry: {i}"); - await Task.Delay(1000); - } - } - - return false; - } - } -} diff --git a/src/Nager.Authentication.TestProject.WebApp6/Nager.Authentication.TestProject.WebApp6.csproj b/src/Nager.Authentication.TestProject.WebApp6/Nager.Authentication.TestProject.WebApp6.csproj index ad49d43..eefb864 100644 --- a/src/Nager.Authentication.TestProject.WebApp6/Nager.Authentication.TestProject.WebApp6.csproj +++ b/src/Nager.Authentication.TestProject.WebApp6/Nager.Authentication.TestProject.WebApp6.csproj @@ -14,7 +14,6 @@ - diff --git a/src/Nager.Authentication.TestProject.WebApp6/Program.cs b/src/Nager.Authentication.TestProject.WebApp6/Program.cs index 68dd43c..3a931ea 100644 --- a/src/Nager.Authentication.TestProject.WebApp6/Program.cs +++ b/src/Nager.Authentication.TestProject.WebApp6/Program.cs @@ -1,5 +1,4 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi.Models; using Nager.Authentication.Abstraction.Models; @@ -7,7 +6,6 @@ using Nager.Authentication.Abstraction.Validators; using Nager.Authentication.Helpers; using Nager.Authentication.InMemoryRepository; -using Nager.Authentication.MssqlRepository; using Nager.Authentication.Services; using Nager.Authentication.TestProject.WebApp6; using Nager.Authentication.TestProject.WebApp6.Dtos; @@ -42,14 +40,6 @@ builder.Services.AddSingleton(); -//builder.Services.AddDbContextPool(options => -//{ -// var connectionString = builder.Configuration.GetConnectionString("Default"); -// options.UseSqlServer(connectionString); -//}); -//builder.Services.AddScoped(); -//builder.Services.AddSingleton(); - builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -85,10 +75,6 @@ { #region Provide the extended endpoint description from the xml comments - //var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"; - //var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile); - //configuration.IncludeXmlComments(xmlPath); - foreach (var filePath in Directory.GetFiles(AppContext.BaseDirectory, "*.xml", SearchOption.TopDirectoryOnly)) { configuration.IncludeXmlComments(filePath); @@ -131,15 +117,6 @@ var app = builder.Build(); -var migrationHelper = app.Services.GetService(); -if (migrationHelper != null) -{ - if (!await migrationHelper.UpdateDatabaseAsync()) - { - return; - } -} - using (var serviceScope = app.Services.CreateScope()) { var services = serviceScope.ServiceProvider; diff --git a/src/Nager.Authentication.UnitTest/Helpers/LoggerHelper.cs b/src/Nager.Authentication.UnitTest/Helpers/LoggerHelper.cs new file mode 100644 index 0000000..4969786 --- /dev/null +++ b/src/Nager.Authentication.UnitTest/Helpers/LoggerHelper.cs @@ -0,0 +1,37 @@ +using Microsoft.Extensions.Logging; +using Moq; +using System; +using System.Diagnostics; + +namespace Nager.Authentication.UnitTest.Helpers +{ + public static class LoggerHelper + { + public static Mock> GetLogger() + { + var logger = new Mock>(); + + logger.Setup(x => x.Log( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + (Func)It.IsAny())) + .Callback(new InvocationAction(invocation => + { + var logLevel = (LogLevel)invocation.Arguments[0]; // The first two will always be whatever is specified in the setup above + var eventId = (EventId)invocation.Arguments[1]; // so I'm not sure you would ever want to actually use them + var state = invocation.Arguments[2]; + var exception = (Exception)invocation.Arguments[3]; + var formatter = invocation.Arguments[4]; + + var invokeMethod = formatter.GetType().GetMethod("Invoke"); + var logMessage = (string)invokeMethod?.Invoke(formatter, new[] { state, exception }); + + Trace.WriteLine($"{logLevel} - {logMessage}"); + })); + + return logger; + } + } +} diff --git a/src/Nager.Authentication.UnitTest/Nager.Authentication.UnitTest.csproj b/src/Nager.Authentication.UnitTest/Nager.Authentication.UnitTest.csproj index 867f5a3..a1a6d43 100644 --- a/src/Nager.Authentication.UnitTest/Nager.Authentication.UnitTest.csproj +++ b/src/Nager.Authentication.UnitTest/Nager.Authentication.UnitTest.csproj @@ -1,7 +1,7 @@ - net7.0 + net8.0 false @@ -10,6 +10,7 @@ + diff --git a/src/Nager.Authentication.UnitTest/UserServiceTest.cs b/src/Nager.Authentication.UnitTest/UserServiceTest.cs index 837d77f..e64b3e9 100644 --- a/src/Nager.Authentication.UnitTest/UserServiceTest.cs +++ b/src/Nager.Authentication.UnitTest/UserServiceTest.cs @@ -6,6 +6,7 @@ using Nager.Authentication.Helpers; using Nager.Authentication.InMemoryRepository; using Nager.Authentication.Services; +using Nager.Authentication.UnitTest.Helpers; using System.Threading.Tasks; namespace Nager.Authentication.UnitTest @@ -16,6 +17,9 @@ public class UserServiceTest [TestMethod] public async Task ValidateCredentialsAsync_10Retrys_Block() { + var userManagementLoggerMock = LoggerHelper.GetLogger(); + var userAuthenticationLoggerMock = LoggerHelper.GetLogger(); + var memoryCache = new MemoryCache(new MemoryCacheOptions()); var userInfos = new UserInfoWithPassword[] { @@ -29,8 +33,8 @@ public async Task ValidateCredentialsAsync_10Retrys_Block() IUserRepository userRepository = new InMemoryUserRepository(); - IUserManagementService userManagementService = new UserManagementService(userRepository); - IUserAuthenticationService userService = new UserAuthenticationService(userRepository, memoryCache); + IUserManagementService userManagementService = new UserManagementService(userManagementLoggerMock.Object, userRepository); + IUserAuthenticationService userService = new UserAuthenticationService(userAuthenticationLoggerMock.Object, userRepository, memoryCache); await InitialUserHelper.CreateUsersAsync(userInfos, userManagementService); @@ -54,6 +58,9 @@ public async Task ValidateCredentialsAsync_10Retrys_Block() [TestMethod] public async Task ValidateCredentialsAsync_UpperCaseUsername_Allow() { + var userManagementLoggerMock = LoggerHelper.GetLogger(); + var userAuthenticationLoggerMock = LoggerHelper.GetLogger(); + var memoryCache = new MemoryCache(new MemoryCacheOptions()); var userInfos = new UserInfoWithPassword[] { @@ -68,8 +75,8 @@ public async Task ValidateCredentialsAsync_UpperCaseUsername_Allow() IUserRepository userRepository = new InMemoryUserRepository(); - IUserManagementService userManagementService = new UserManagementService(userRepository); - IUserAuthenticationService userService = new UserAuthenticationService(userRepository, memoryCache); + IUserManagementService userManagementService = new UserManagementService(userManagementLoggerMock.Object, userRepository); + IUserAuthenticationService userService = new UserAuthenticationService(userAuthenticationLoggerMock.Object, userRepository, memoryCache); await InitialUserHelper.CreateUsersAsync(userInfos, userManagementService); diff --git a/src/Nager.Authentication.sln b/src/Nager.Authentication.sln index 2050c79..d6f6c16 100644 --- a/src/Nager.Authentication.sln +++ b/src/Nager.Authentication.sln @@ -15,9 +15,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "TestProjects", "TestProject EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Nager.Authentication.TestProject.WebApp6", "Nager.Authentication.TestProject.WebApp6\Nager.Authentication.TestProject.WebApp6.csproj", "{2361CF04-D289-4264-B7A4-ED7B7F003D73}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Nager.Authentication.MssqlRepository", "Nager.Authentication.MssqlRepository\Nager.Authentication.MssqlRepository.csproj", "{119AF312-B9EF-40F9-A9B0-E55A3F588037}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nager.Authentication", "Nager.Authentication\Nager.Authentication.csproj", "{BDEF5CA1-309A-4718-B325-AFE92192ED4B}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Nager.Authentication", "Nager.Authentication\Nager.Authentication.csproj", "{BDEF5CA1-309A-4718-B325-AFE92192ED4B}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -89,18 +87,6 @@ Global {2361CF04-D289-4264-B7A4-ED7B7F003D73}.Release|x64.Build.0 = Release|Any CPU {2361CF04-D289-4264-B7A4-ED7B7F003D73}.Release|x86.ActiveCfg = Release|Any CPU {2361CF04-D289-4264-B7A4-ED7B7F003D73}.Release|x86.Build.0 = Release|Any CPU - {119AF312-B9EF-40F9-A9B0-E55A3F588037}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {119AF312-B9EF-40F9-A9B0-E55A3F588037}.Debug|Any CPU.Build.0 = Debug|Any CPU - {119AF312-B9EF-40F9-A9B0-E55A3F588037}.Debug|x64.ActiveCfg = Debug|Any CPU - {119AF312-B9EF-40F9-A9B0-E55A3F588037}.Debug|x64.Build.0 = Debug|Any CPU - {119AF312-B9EF-40F9-A9B0-E55A3F588037}.Debug|x86.ActiveCfg = Debug|Any CPU - {119AF312-B9EF-40F9-A9B0-E55A3F588037}.Debug|x86.Build.0 = Debug|Any CPU - {119AF312-B9EF-40F9-A9B0-E55A3F588037}.Release|Any CPU.ActiveCfg = Release|Any CPU - {119AF312-B9EF-40F9-A9B0-E55A3F588037}.Release|Any CPU.Build.0 = Release|Any CPU - {119AF312-B9EF-40F9-A9B0-E55A3F588037}.Release|x64.ActiveCfg = Release|Any CPU - {119AF312-B9EF-40F9-A9B0-E55A3F588037}.Release|x64.Build.0 = Release|Any CPU - {119AF312-B9EF-40F9-A9B0-E55A3F588037}.Release|x86.ActiveCfg = Release|Any CPU - {119AF312-B9EF-40F9-A9B0-E55A3F588037}.Release|x86.Build.0 = Release|Any CPU {BDEF5CA1-309A-4718-B325-AFE92192ED4B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {BDEF5CA1-309A-4718-B325-AFE92192ED4B}.Debug|Any CPU.Build.0 = Debug|Any CPU {BDEF5CA1-309A-4718-B325-AFE92192ED4B}.Debug|x64.ActiveCfg = Debug|Any CPU diff --git a/src/Nager.Authentication/Nager.Authentication.csproj b/src/Nager.Authentication/Nager.Authentication.csproj index 541e682..514883e 100644 --- a/src/Nager.Authentication/Nager.Authentication.csproj +++ b/src/Nager.Authentication/Nager.Authentication.csproj @@ -18,21 +18,27 @@ https://github.com/nager/Nager.Authentication enable - net7.0;net6.0 + net8.0;net7.0;net6.0 - 1.0.9 + 1.1.0 - + - + + + + + + + diff --git a/src/Nager.Authentication/Services/UserAuthenticationService.cs b/src/Nager.Authentication/Services/UserAuthenticationService.cs index 351dea3..c188bcb 100644 --- a/src/Nager.Authentication/Services/UserAuthenticationService.cs +++ b/src/Nager.Authentication/Services/UserAuthenticationService.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; using Nager.Authentication.Abstraction.Models; using Nager.Authentication.Abstraction.Services; using Nager.Authentication.Abstraction.Validators; @@ -16,30 +17,39 @@ namespace Nager.Authentication.Services /// With Brute-Force Protection public class UserAuthenticationService : IUserAuthenticationService { + private readonly ILogger _logger; private readonly IUserRepository _userRepository; private readonly IMemoryCache _memoryCache; private readonly string _cacheKeyPrefix = "AuthenticationInfo"; private readonly TimeSpan _cacheLiveTime = TimeSpan.FromMinutes(10); - private readonly int _delayTimeMultiplier = 100; + private readonly int _delayTimeMultiplier = 400; //ms private readonly int _maxInvalidLogins = 10; private readonly int _maxInvalidLoginsBeforeDelay = 3; + /// + /// User Authentication Service + /// + /// + /// + /// public UserAuthenticationService( + ILogger logger, IUserRepository userRepository, IMemoryCache memoryCache) { + this._logger = logger; this._userRepository = userRepository; this._memoryCache = memoryCache; } - private string GetCacheKey(string ipAddress) + private string GetCacheKey(string identifier) { - return $"{this._cacheKeyPrefix}.{ipAddress}"; + return $"{this._cacheKeyPrefix}.{identifier.Trim()}"; } - private void SetInvalidLogin(string ipAddress) + private void SetInvalidLogin(string identifier) { - var cacheKey = this.GetCacheKey(ipAddress); + var cacheKey = this.GetCacheKey(identifier); if (!this._memoryCache.TryGetValue(cacheKey, out var authenticationInfo)) { authenticationInfo = new AuthenticationInfo(); @@ -47,18 +57,18 @@ private void SetInvalidLogin(string ipAddress) if (authenticationInfo == null) { - throw new ArgumentNullException(nameof(authenticationInfo)); + throw new NullReferenceException(nameof(authenticationInfo)); } authenticationInfo.InvalidCount++; - authenticationInfo.LastInvalid = DateTime.Now; + authenticationInfo.LastInvalid = DateTime.UtcNow; - this._memoryCache.Set(cacheKey, authenticationInfo, _cacheLiveTime); + this._memoryCache.Set(cacheKey, authenticationInfo, this._cacheLiveTime); } - private void SetValidLogin(string ipAddress) + private void SetValidLogin(string identifier) { - var cacheKey = this.GetCacheKey(ipAddress); + var cacheKey = this.GetCacheKey(identifier); if (!this._memoryCache.TryGetValue(cacheKey, out var authenticationInfo)) { authenticationInfo = new AuthenticationInfo(); @@ -66,18 +76,18 @@ private void SetValidLogin(string ipAddress) if (authenticationInfo == null) { - throw new ArgumentNullException(nameof(authenticationInfo)); + throw new NullReferenceException(nameof(authenticationInfo)); } - authenticationInfo.LastValid = DateTime.Now; + authenticationInfo.LastValid = DateTime.UtcNow; authenticationInfo.InvalidCount = 0; this._memoryCache.Set(cacheKey, authenticationInfo, this._cacheLiveTime); } - private async Task IsIpAddressBlockedAsync(string ipAddress) + private async Task IsIdentifierBlockedAsync(string identifier) { - var cacheKey = this.GetCacheKey(ipAddress); + var cacheKey = this.GetCacheKey(identifier); if (!this._memoryCache.TryGetValue(cacheKey, out var authenticationInfo)) { @@ -96,7 +106,7 @@ private async Task IsIpAddressBlockedAsync(string ipAddress) await Task.Delay(authenticationInfo.InvalidCount * this._delayTimeMultiplier); - if (authenticationInfo.InvalidCount > this._maxInvalidLogins && DateTime.Now < authenticationInfo.LastInvalid.AddMinutes(2)) + if (authenticationInfo.InvalidCount > this._maxInvalidLogins && DateTime.UtcNow < authenticationInfo.LastInvalid.AddMinutes(2)) { return true; } @@ -115,22 +125,27 @@ public async Task ValidateCredentialsAsync( if (string.IsNullOrEmpty(authenticationRequest.IpAddress)) { - throw new NullReferenceException(nameof(authenticationRequest.IpAddress)); + throw new NullReferenceException($"Missing {nameof(authenticationRequest.IpAddress)}"); } - if (await this.IsIpAddressBlockedAsync(authenticationRequest.IpAddress)) + if (await this.IsIdentifierBlockedAsync(authenticationRequest.IpAddress)) { + this._logger.LogWarning($"{nameof(ValidateCredentialsAsync)} - Block {authenticationRequest.IpAddress}"); return AuthenticationStatus.TemporaryBlocked; } - //TODO: Protect users when trying to flood the same user - // with requests from different IP addresses in a short period of time - // add cache item with username + if (await this.IsIdentifierBlockedAsync(authenticationRequest.EmailAddress)) + { + this._logger.LogWarning($"{nameof(ValidateCredentialsAsync)} - Block {authenticationRequest.EmailAddress}"); + return AuthenticationStatus.TemporaryBlocked; + } var userEntity = await this._userRepository.GetAsync(o => o.EmailAddress == authenticationRequest.EmailAddress, cancellationToken); if (userEntity == null) { this.SetInvalidLogin(authenticationRequest.IpAddress); + this.SetInvalidLogin(authenticationRequest.EmailAddress); + return AuthenticationStatus.Invalid; } @@ -139,16 +154,24 @@ public async Task ValidateCredentialsAsync( throw new NullReferenceException(nameof(userEntity.PasswordHash)); } + using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var passwordHash = PasswordHelper.HashPasword(authenticationRequest.Password, userEntity.PasswordSalt); if (userEntity.PasswordHash.SequenceEqual(passwordHash)) { - //Set Last Login Time - this.SetValidLogin(authenticationRequest.IpAddress); + this.SetValidLogin(authenticationRequest.EmailAddress); + + await this._userRepository.SetLastSuccessfulValidationTimestampAsync(o => o.Id == userEntity.Id, cancellationTokenSource.Token); + return AuthenticationStatus.Valid; } this.SetInvalidLogin(authenticationRequest.IpAddress); + this.SetInvalidLogin(authenticationRequest.EmailAddress); + + await this._userRepository.SetLastValidationTimestampAsync(o => o.Id != userEntity.Id, cancellationTokenSource.Token); + return AuthenticationStatus.Invalid; } @@ -157,7 +180,6 @@ public async Task ValidateCredentialsAsync( CancellationToken cancellationToken = default) { var userEntity = await this._userRepository.GetAsync(o => o.EmailAddress == emailAddress); - if (userEntity == null) { return null; diff --git a/src/Nager.Authentication/Services/UserManagementService.cs b/src/Nager.Authentication/Services/UserManagementService.cs index 1561f84..30f2557 100644 --- a/src/Nager.Authentication/Services/UserManagementService.cs +++ b/src/Nager.Authentication/Services/UserManagementService.cs @@ -1,4 +1,5 @@ -using Nager.Authentication.Abstraction.Entities; +using Microsoft.Extensions.Logging; +using Nager.Authentication.Abstraction.Entities; using Nager.Authentication.Abstraction.Models; using Nager.Authentication.Abstraction.Services; using Nager.Authentication.Abstraction.Validators; @@ -12,10 +13,14 @@ namespace Nager.Authentication.Services { public class UserManagementService : IUserManagementService { + private readonly ILogger _logger; private readonly IUserRepository _userRepository; - public UserManagementService(IUserRepository userRepository) + public UserManagementService( + ILogger logger, + IUserRepository userRepository) { + this._logger = logger; this._userRepository = userRepository; } @@ -35,6 +40,20 @@ public async Task QueryAsync( }).ToArray(); } + private UserInfo MapUserInfo(UserEntity userEntity) + { + return new UserInfo + { + Id = userEntity.Id, + EmailAddress = userEntity.EmailAddress, + Firstname = userEntity.Firstname, + Lastname = userEntity.Lastname, + Roles = RoleHelper.GetRoles(userEntity.RolesData), + LastValidationTimestamp = userEntity.LastValidationTimestamp, + LastSuccessfulValidationTimestamp = userEntity.LastSuccessfulValidationTimestamp + }; + } + public async Task GetByIdAsync( string id, CancellationToken cancellationToken = default) @@ -42,17 +61,11 @@ public async Task QueryAsync( var userEntity = await this._userRepository.GetAsync(o => o.Id == id, cancellationToken); if (userEntity == null) { + this._logger.LogError($"{nameof(GetByIdAsync)} - Cannot found id:{id}"); return null; } - return new UserInfo - { - Id = userEntity.Id, - EmailAddress = userEntity.EmailAddress, - Firstname = userEntity.Firstname, - Lastname = userEntity.Lastname, - Roles = RoleHelper.GetRoles(userEntity.RolesData) - }; + return this.MapUserInfo(userEntity); } public async Task GetByEmailAddressAsync( @@ -65,14 +78,7 @@ public async Task QueryAsync( return null; } - return new UserInfo - { - Id = userEntity.Id, - EmailAddress = userEntity.EmailAddress, - Firstname = userEntity.Firstname, - Lastname = userEntity.Lastname, - Roles = RoleHelper.GetRoles(userEntity.RolesData) - }; + return this.MapUserInfo(userEntity); } public async Task ResetPasswordAsync( @@ -98,6 +104,7 @@ public async Task ResetPasswordAsync( return randomPassword; } + this._logger.LogError($"{nameof(ResetPasswordAsync)} - Cannot reset password for id:{id}"); return null; } @@ -155,6 +162,7 @@ public async Task AddRoleAsync( var userEntity = await this._userRepository.GetAsync(o => o.Id == id, cancellationToken); if (userEntity == null) { + this._logger.LogError($"{nameof(UpdateAsync)} - Cannot found id:{id}"); return false; }