From 1897428d001cc6125532b792b2e57a7179139c14 Mon Sep 17 00:00:00 2001 From: Leon Hoppe Date: Sun, 24 Nov 2024 10:42:33 +0100 Subject: [PATCH 01/27] Reorganized project in solution folders --- HopFrame.sln | 17 +++++++++++++++-- {test => testing}/FrontendTest/.gitignore | 0 {test => testing}/FrontendTest/AdminContext.cs | 0 .../FrontendTest/Components/App.razor | 0 .../Components/Layout/MainLayout.razor | 0 .../Components/Layout/MainLayout.razor.css | 0 .../Components/Layout/NavMenu.razor | 0 .../Components/Layout/NavMenu.razor.css | 0 .../Components/Pages/Counter.razor | 0 .../FrontendTest/Components/Pages/Error.razor | 0 .../FrontendTest/Components/Pages/Home.razor | 0 .../Components/Pages/Weather.razor | 0 .../FrontendTest/Components/Routes.razor | 0 .../FrontendTest/Components/_Imports.razor | 0 .../FrontendTest/DatabaseContext.cs | 2 +- .../FrontendTest/FrontendTest.csproj | 0 .../FrontendTest/Models/Address.cs | 0 .../FrontendTest/Models/Employee.cs | 0 {test => testing}/FrontendTest/Program.cs | 0 .../Properties/launchSettings.json | 0 .../FrontendTest/Providers/AddressProvider.cs | 0 .../FrontendTest/Providers/EmployeeProvider.cs | 0 .../FrontendTest/appsettings.json | 0 {test => testing}/FrontendTest/wwwroot/app.css | 0 .../FrontendTest/wwwroot/favicon.png | Bin {test => testing}/RestApiTest/.gitignore | 0 .../RestApiTest/Controllers/TestController.cs | 0 .../RestApiTest/DatabaseContext.cs | 2 +- .../RestApiTest/Models/Address.cs | 0 .../RestApiTest/Models/Employee.cs | 0 {test => testing}/RestApiTest/Program.cs | 0 .../RestApiTest/Properties/launchSettings.json | 0 .../RestApiTest/RestApiTest.csproj | 0 {test => testing}/RestApiTest/appsettings.json | 0 34 files changed, 17 insertions(+), 4 deletions(-) rename {test => testing}/FrontendTest/.gitignore (100%) rename {test => testing}/FrontendTest/AdminContext.cs (100%) rename {test => testing}/FrontendTest/Components/App.razor (100%) rename {test => testing}/FrontendTest/Components/Layout/MainLayout.razor (100%) rename {test => testing}/FrontendTest/Components/Layout/MainLayout.razor.css (100%) rename {test => testing}/FrontendTest/Components/Layout/NavMenu.razor (100%) rename {test => testing}/FrontendTest/Components/Layout/NavMenu.razor.css (100%) rename {test => testing}/FrontendTest/Components/Pages/Counter.razor (100%) rename {test => testing}/FrontendTest/Components/Pages/Error.razor (100%) rename {test => testing}/FrontendTest/Components/Pages/Home.razor (100%) rename {test => testing}/FrontendTest/Components/Pages/Weather.razor (100%) rename {test => testing}/FrontendTest/Components/Routes.razor (100%) rename {test => testing}/FrontendTest/Components/_Imports.razor (100%) rename {test => testing}/FrontendTest/DatabaseContext.cs (88%) rename {test => testing}/FrontendTest/FrontendTest.csproj (100%) rename {test => testing}/FrontendTest/Models/Address.cs (100%) rename {test => testing}/FrontendTest/Models/Employee.cs (100%) rename {test => testing}/FrontendTest/Program.cs (100%) rename {test => testing}/FrontendTest/Properties/launchSettings.json (100%) rename {test => testing}/FrontendTest/Providers/AddressProvider.cs (100%) rename {test => testing}/FrontendTest/Providers/EmployeeProvider.cs (100%) rename {test => testing}/FrontendTest/appsettings.json (100%) rename {test => testing}/FrontendTest/wwwroot/app.css (100%) rename {test => testing}/FrontendTest/wwwroot/favicon.png (100%) rename {test => testing}/RestApiTest/.gitignore (100%) rename {test => testing}/RestApiTest/Controllers/TestController.cs (100%) rename {test => testing}/RestApiTest/DatabaseContext.cs (88%) rename {test => testing}/RestApiTest/Models/Address.cs (100%) rename {test => testing}/RestApiTest/Models/Employee.cs (100%) rename {test => testing}/RestApiTest/Program.cs (100%) rename {test => testing}/RestApiTest/Properties/launchSettings.json (100%) rename {test => testing}/RestApiTest/RestApiTest.csproj (100%) rename {test => testing}/RestApiTest/appsettings.json (100%) diff --git a/HopFrame.sln b/HopFrame.sln index 2e65007..269b7be 100644 --- a/HopFrame.sln +++ b/HopFrame.sln @@ -2,7 +2,7 @@ Microsoft Visual Studio Solution File, Format Version 12.00 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Database", "src\HopFrame.Database\HopFrame.Database.csproj", "{003120AE-F38B-4632-8497-BE4505189627}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RestApiTest", "test\RestApiTest\RestApiTest.csproj", "{921159CE-AF75-44C3-A3F9-6B9B1A4E85CF}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RestApiTest", "testing\RestApiTest\RestApiTest.csproj", "{921159CE-AF75-44C3-A3F9-6B9B1A4E85CF}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Security", "src\HopFrame.Security\HopFrame.Security.csproj", "{7F82E1C6-4A42-4337-9E03-2EE6429D004F}" EndProject @@ -10,10 +10,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Api", "src\HopFram EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Web", "src\HopFrame.Web\HopFrame.Web.csproj", "{3BE585BC-13A5-4BE4-A806-E9EC2D825956}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FrontendTest", "test\FrontendTest\FrontendTest.csproj", "{8F983A37-63CF-48D5-988D-58B78EF8AECD}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FrontendTest", "testing\FrontendTest\FrontendTest.csproj", "{8F983A37-63CF-48D5-988D-58B78EF8AECD}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Web.Admin", "src\HopFrame.Web.Admin\HopFrame.Web.Admin.csproj", "{02D9F10A-664A-4EF7-BF19-310C26FF4DEB}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Core", "Core", "{64EDCBED-A84F-4936-8697-78DC43CB2427}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Testing", "Testing", "{EEA20D27-D471-44AF-A287-C0E068D93182}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{1D98E5DE-CB8B-4C1C-A319-D49AC137441A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -50,5 +56,12 @@ Global {02D9F10A-664A-4EF7-BF19-310C26FF4DEB}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution + {1E821490-AEDC-4F55-B758-52F4FADAB53A} = {64EDCBED-A84F-4936-8697-78DC43CB2427} + {003120AE-F38B-4632-8497-BE4505189627} = {64EDCBED-A84F-4936-8697-78DC43CB2427} + {7F82E1C6-4A42-4337-9E03-2EE6429D004F} = {64EDCBED-A84F-4936-8697-78DC43CB2427} + {3BE585BC-13A5-4BE4-A806-E9EC2D825956} = {64EDCBED-A84F-4936-8697-78DC43CB2427} + {02D9F10A-664A-4EF7-BF19-310C26FF4DEB} = {64EDCBED-A84F-4936-8697-78DC43CB2427} + {8F983A37-63CF-48D5-988D-58B78EF8AECD} = {EEA20D27-D471-44AF-A287-C0E068D93182} + {921159CE-AF75-44C3-A3F9-6B9B1A4E85CF} = {EEA20D27-D471-44AF-A287-C0E068D93182} EndGlobalSection EndGlobal diff --git a/test/FrontendTest/.gitignore b/testing/FrontendTest/.gitignore similarity index 100% rename from test/FrontendTest/.gitignore rename to testing/FrontendTest/.gitignore diff --git a/test/FrontendTest/AdminContext.cs b/testing/FrontendTest/AdminContext.cs similarity index 100% rename from test/FrontendTest/AdminContext.cs rename to testing/FrontendTest/AdminContext.cs diff --git a/test/FrontendTest/Components/App.razor b/testing/FrontendTest/Components/App.razor similarity index 100% rename from test/FrontendTest/Components/App.razor rename to testing/FrontendTest/Components/App.razor diff --git a/test/FrontendTest/Components/Layout/MainLayout.razor b/testing/FrontendTest/Components/Layout/MainLayout.razor similarity index 100% rename from test/FrontendTest/Components/Layout/MainLayout.razor rename to testing/FrontendTest/Components/Layout/MainLayout.razor diff --git a/test/FrontendTest/Components/Layout/MainLayout.razor.css b/testing/FrontendTest/Components/Layout/MainLayout.razor.css similarity index 100% rename from test/FrontendTest/Components/Layout/MainLayout.razor.css rename to testing/FrontendTest/Components/Layout/MainLayout.razor.css diff --git a/test/FrontendTest/Components/Layout/NavMenu.razor b/testing/FrontendTest/Components/Layout/NavMenu.razor similarity index 100% rename from test/FrontendTest/Components/Layout/NavMenu.razor rename to testing/FrontendTest/Components/Layout/NavMenu.razor diff --git a/test/FrontendTest/Components/Layout/NavMenu.razor.css b/testing/FrontendTest/Components/Layout/NavMenu.razor.css similarity index 100% rename from test/FrontendTest/Components/Layout/NavMenu.razor.css rename to testing/FrontendTest/Components/Layout/NavMenu.razor.css diff --git a/test/FrontendTest/Components/Pages/Counter.razor b/testing/FrontendTest/Components/Pages/Counter.razor similarity index 100% rename from test/FrontendTest/Components/Pages/Counter.razor rename to testing/FrontendTest/Components/Pages/Counter.razor diff --git a/test/FrontendTest/Components/Pages/Error.razor b/testing/FrontendTest/Components/Pages/Error.razor similarity index 100% rename from test/FrontendTest/Components/Pages/Error.razor rename to testing/FrontendTest/Components/Pages/Error.razor diff --git a/test/FrontendTest/Components/Pages/Home.razor b/testing/FrontendTest/Components/Pages/Home.razor similarity index 100% rename from test/FrontendTest/Components/Pages/Home.razor rename to testing/FrontendTest/Components/Pages/Home.razor diff --git a/test/FrontendTest/Components/Pages/Weather.razor b/testing/FrontendTest/Components/Pages/Weather.razor similarity index 100% rename from test/FrontendTest/Components/Pages/Weather.razor rename to testing/FrontendTest/Components/Pages/Weather.razor diff --git a/test/FrontendTest/Components/Routes.razor b/testing/FrontendTest/Components/Routes.razor similarity index 100% rename from test/FrontendTest/Components/Routes.razor rename to testing/FrontendTest/Components/Routes.razor diff --git a/test/FrontendTest/Components/_Imports.razor b/testing/FrontendTest/Components/_Imports.razor similarity index 100% rename from test/FrontendTest/Components/_Imports.razor rename to testing/FrontendTest/Components/_Imports.razor diff --git a/test/FrontendTest/DatabaseContext.cs b/testing/FrontendTest/DatabaseContext.cs similarity index 88% rename from test/FrontendTest/DatabaseContext.cs rename to testing/FrontendTest/DatabaseContext.cs index 0dede8a..c1d3c7b 100644 --- a/test/FrontendTest/DatabaseContext.cs +++ b/testing/FrontendTest/DatabaseContext.cs @@ -11,7 +11,7 @@ public class DatabaseContext : HopDbContextBase { protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { base.OnConfiguring(optionsBuilder); - optionsBuilder.UseSqlite(@"Data Source=C:\Users\leon\Documents\Projekte\HopFrame\test\RestApiTest\bin\Debug\net8.0\test.db;Mode=ReadWrite;"); + optionsBuilder.UseSqlite(@"Data Source=C:\Users\leon\Documents\Projekte\HopFrame\testing\RestApiTest\bin\Debug\net8.0\test.db;Mode=ReadWrite;"); } protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/test/FrontendTest/FrontendTest.csproj b/testing/FrontendTest/FrontendTest.csproj similarity index 100% rename from test/FrontendTest/FrontendTest.csproj rename to testing/FrontendTest/FrontendTest.csproj diff --git a/test/FrontendTest/Models/Address.cs b/testing/FrontendTest/Models/Address.cs similarity index 100% rename from test/FrontendTest/Models/Address.cs rename to testing/FrontendTest/Models/Address.cs diff --git a/test/FrontendTest/Models/Employee.cs b/testing/FrontendTest/Models/Employee.cs similarity index 100% rename from test/FrontendTest/Models/Employee.cs rename to testing/FrontendTest/Models/Employee.cs diff --git a/test/FrontendTest/Program.cs b/testing/FrontendTest/Program.cs similarity index 100% rename from test/FrontendTest/Program.cs rename to testing/FrontendTest/Program.cs diff --git a/test/FrontendTest/Properties/launchSettings.json b/testing/FrontendTest/Properties/launchSettings.json similarity index 100% rename from test/FrontendTest/Properties/launchSettings.json rename to testing/FrontendTest/Properties/launchSettings.json diff --git a/test/FrontendTest/Providers/AddressProvider.cs b/testing/FrontendTest/Providers/AddressProvider.cs similarity index 100% rename from test/FrontendTest/Providers/AddressProvider.cs rename to testing/FrontendTest/Providers/AddressProvider.cs diff --git a/test/FrontendTest/Providers/EmployeeProvider.cs b/testing/FrontendTest/Providers/EmployeeProvider.cs similarity index 100% rename from test/FrontendTest/Providers/EmployeeProvider.cs rename to testing/FrontendTest/Providers/EmployeeProvider.cs diff --git a/test/FrontendTest/appsettings.json b/testing/FrontendTest/appsettings.json similarity index 100% rename from test/FrontendTest/appsettings.json rename to testing/FrontendTest/appsettings.json diff --git a/test/FrontendTest/wwwroot/app.css b/testing/FrontendTest/wwwroot/app.css similarity index 100% rename from test/FrontendTest/wwwroot/app.css rename to testing/FrontendTest/wwwroot/app.css diff --git a/test/FrontendTest/wwwroot/favicon.png b/testing/FrontendTest/wwwroot/favicon.png similarity index 100% rename from test/FrontendTest/wwwroot/favicon.png rename to testing/FrontendTest/wwwroot/favicon.png diff --git a/test/RestApiTest/.gitignore b/testing/RestApiTest/.gitignore similarity index 100% rename from test/RestApiTest/.gitignore rename to testing/RestApiTest/.gitignore diff --git a/test/RestApiTest/Controllers/TestController.cs b/testing/RestApiTest/Controllers/TestController.cs similarity index 100% rename from test/RestApiTest/Controllers/TestController.cs rename to testing/RestApiTest/Controllers/TestController.cs diff --git a/test/RestApiTest/DatabaseContext.cs b/testing/RestApiTest/DatabaseContext.cs similarity index 88% rename from test/RestApiTest/DatabaseContext.cs rename to testing/RestApiTest/DatabaseContext.cs index ef370c7..42ae5d1 100644 --- a/test/RestApiTest/DatabaseContext.cs +++ b/testing/RestApiTest/DatabaseContext.cs @@ -12,7 +12,7 @@ public class DatabaseContext : HopDbContextBase { protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { base.OnConfiguring(optionsBuilder); - optionsBuilder.UseSqlite(@"Data Source=C:\Users\leon\Documents\Projekte\HopFrame\test\RestApiTest\bin\Debug\net8.0\test.db;Mode=ReadWrite;"); + optionsBuilder.UseSqlite(@"Data Source=C:\Users\leon\Documents\Projekte\HopFrame\testing\RestApiTest\bin\Debug\net8.0\test.db;Mode=ReadWrite;"); } protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/test/RestApiTest/Models/Address.cs b/testing/RestApiTest/Models/Address.cs similarity index 100% rename from test/RestApiTest/Models/Address.cs rename to testing/RestApiTest/Models/Address.cs diff --git a/test/RestApiTest/Models/Employee.cs b/testing/RestApiTest/Models/Employee.cs similarity index 100% rename from test/RestApiTest/Models/Employee.cs rename to testing/RestApiTest/Models/Employee.cs diff --git a/test/RestApiTest/Program.cs b/testing/RestApiTest/Program.cs similarity index 100% rename from test/RestApiTest/Program.cs rename to testing/RestApiTest/Program.cs diff --git a/test/RestApiTest/Properties/launchSettings.json b/testing/RestApiTest/Properties/launchSettings.json similarity index 100% rename from test/RestApiTest/Properties/launchSettings.json rename to testing/RestApiTest/Properties/launchSettings.json diff --git a/test/RestApiTest/RestApiTest.csproj b/testing/RestApiTest/RestApiTest.csproj similarity index 100% rename from test/RestApiTest/RestApiTest.csproj rename to testing/RestApiTest/RestApiTest.csproj diff --git a/test/RestApiTest/appsettings.json b/testing/RestApiTest/appsettings.json similarity index 100% rename from test/RestApiTest/appsettings.json rename to testing/RestApiTest/appsettings.json From 85031de3c288cc0f072d6f2be441021bf1b60111 Mon Sep 17 00:00:00 2001 From: Leon Hoppe Date: Sun, 24 Nov 2024 12:40:51 +0100 Subject: [PATCH 02/27] Started creating tests for database module --- .../Data/DatabaseContext.cs | 11 ++ .../HopFrame.Database.Tests.csproj | 29 ++++ .../PermissionValidatorTests.cs | 57 +++++++ .../Repositories/GroupRepositoryTests.cs | 152 ++++++++++++++++++ .../Repositories/PermissionRepositoryTests.cs | 118 ++++++++++++++ .../Repositories/TokenRepositoryTests.cs | 89 ++++++++++ HopFrame.sln | 7 + HopFrame.sln.DotSettings.user | 19 ++- .../HopFrame.Database.csproj | 6 + 9 files changed, 487 insertions(+), 1 deletion(-) create mode 100644 HopFrame.Database.Tests/Data/DatabaseContext.cs create mode 100644 HopFrame.Database.Tests/HopFrame.Database.Tests.csproj create mode 100644 HopFrame.Database.Tests/PermissionValidatorTests.cs create mode 100644 HopFrame.Database.Tests/Repositories/GroupRepositoryTests.cs create mode 100644 HopFrame.Database.Tests/Repositories/PermissionRepositoryTests.cs create mode 100644 HopFrame.Database.Tests/Repositories/TokenRepositoryTests.cs diff --git a/HopFrame.Database.Tests/Data/DatabaseContext.cs b/HopFrame.Database.Tests/Data/DatabaseContext.cs new file mode 100644 index 0000000..7bc93de --- /dev/null +++ b/HopFrame.Database.Tests/Data/DatabaseContext.cs @@ -0,0 +1,11 @@ +using Microsoft.EntityFrameworkCore; + +namespace HopFrame.Database.Tests.Data; + +public class DatabaseContext : HopDbContextBase { + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { + base.OnConfiguring(optionsBuilder); + + optionsBuilder.UseInMemoryDatabase(Guid.NewGuid().ToString()); + } +} \ No newline at end of file diff --git a/HopFrame.Database.Tests/HopFrame.Database.Tests.csproj b/HopFrame.Database.Tests/HopFrame.Database.Tests.csproj new file mode 100644 index 0000000..5246200 --- /dev/null +++ b/HopFrame.Database.Tests/HopFrame.Database.Tests.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + diff --git a/HopFrame.Database.Tests/PermissionValidatorTests.cs b/HopFrame.Database.Tests/PermissionValidatorTests.cs new file mode 100644 index 0000000..9d858f8 --- /dev/null +++ b/HopFrame.Database.Tests/PermissionValidatorTests.cs @@ -0,0 +1,57 @@ +namespace HopFrame.Database.Tests; + +public class PermissionValidatorTests { + + [Fact] + public void IncludesPermission_Returns_True_For_ExactPermission() { + // Arrange + var permissions = new [] { "test.permission", "test.permission.exact" }; + var permission = "test.permission.exact"; + + // Act + var hasPermission = PermissionValidator.IncludesPermission(permission, permissions); + + // Assert + Assert.True(hasPermission); + } + + [Fact] + public void IncludesPermission_Returns_True_For_GroupPermission() { + // Arrange + var permissions = new [] { "test.permission.*", "test.permission.exact" }; + var permission = "test.permission.exact.other"; + + // Act + var hasPermission = PermissionValidator.IncludesPermission(permission, permissions); + + // Assert + Assert.True(hasPermission); + } + + [Fact] + public void IncludesPermission_Returns_True_For_StarPermission() { + // Arrange + var permissions = new [] { "test.permission", "test.permission.exact", "*" }; + var permission = "test.permission.exact.other"; + + // Act + var hasPermission = PermissionValidator.IncludesPermission(permission, permissions); + + // Assert + Assert.True(hasPermission); + } + + [Fact] + public void IncludesPermission_Returns_False() { + // Arrange + var permissions = new [] { "test.permission", "test.permission.exact" }; + var permission = "test.permission.exact.other"; + + // Act + var hasPermission = PermissionValidator.IncludesPermission(permission, permissions); + + // Assert + Assert.False(hasPermission); + } + +} \ No newline at end of file diff --git a/HopFrame.Database.Tests/Repositories/GroupRepositoryTests.cs b/HopFrame.Database.Tests/Repositories/GroupRepositoryTests.cs new file mode 100644 index 0000000..84270e0 --- /dev/null +++ b/HopFrame.Database.Tests/Repositories/GroupRepositoryTests.cs @@ -0,0 +1,152 @@ +using HopFrame.Database.Models; +using HopFrame.Database.Repositories; +using HopFrame.Database.Repositories.Implementation; +using HopFrame.Database.Tests.Data; + +namespace HopFrame.Database.Tests.Repositories; + +public class GroupRepositoryTests { + + private async Task<(DatabaseContext, IGroupRepository)> SetupEnvironment(int count = 5) { + var context = new DatabaseContext(); + var repo = new GroupRepository(context); + + for (int i = 0; i < count; i++) { + await context.Groups.AddAsync(new() { + Name = Guid.NewGuid().ToString() + }); + } + await context.SaveChangesAsync(); + + return (context, repo); + } + + [Fact] + public async Task GetPermissionGroups_Returns_AllPermissionGroups() { + // Arrange + var count = 5; + var (context, repo) = await SetupEnvironment(count); + + // Act + var groups = await repo.GetPermissionGroups(); + + // Assert + Assert.Equal(count, groups.Count); + } + + [Fact] + public async Task GetDefaultGroups_Returns_OnlyDefaultGroups() { + // Arrange + var (context, repo) = await SetupEnvironment(); + await context.Groups.AddAsync(new () { + Name = Guid.NewGuid().ToString(), + IsDefaultGroup = true + }); + await context.SaveChangesAsync(); + + // Act + var groups = await repo.GetDefaultGroups(); + + // Assert + Assert.Single(groups); + Assert.True(groups[0].IsDefaultGroup); + } + + [Fact] + public async Task GetUserGroups_Returns_OnlyUserGroups() { + // Arrange + var (context, repo) = await SetupEnvironment(); + await context.Groups.AddAsync(new () { + Name = "group.user_should_have_these", + }); + var user = new User { + Id = Guid.NewGuid(), + Email = "", + Username = "", + Password = "" + }; + user.Permissions = new() { + new() { + User = user, + PermissionName = "group.user_should_have_these" + } + }; + await context.Users.AddAsync(user); + await context.SaveChangesAsync(); + + // Act + var groups = await repo.GetUserGroups(user); + + // Assert + Assert.Single(groups); + Assert.Equal("group.user_should_have_these", groups[0].Name); + } + + [Fact] + public async Task GetPermissionGroup_Returns_OnlyOnePermissionGroup() { + // Arrange + var (context, repo) = await SetupEnvironment(); + await context.Groups.AddAsync(new () { + Name = "group.return" + }); + await context.SaveChangesAsync(); + + // Act + var group = await repo.GetPermissionGroup("group.return"); + + // Assert + Assert.NotNull(group); + Assert.Equal("group.return", group.Name); + } + + [Fact] + public async Task EditPermissionGroup_Should_EditOnePermissionGroup() { + // Arrange + var (context, repo) = await SetupEnvironment(); + var groupToEdit = new PermissionGroup { + Name = "group.edit" + }; + await context.Groups.AddAsync(groupToEdit); + await context.SaveChangesAsync(); + + // Act + groupToEdit.Description = "This description was edited"; + await repo.EditPermissionGroup(groupToEdit); + + // Assert + var group = context.Groups.SingleOrDefault(g => g.Name == "group.edit"); + Assert.NotNull(group); + Assert.Equal("This description was edited", group.Description); + } + + [Fact] + public async Task CreatePermissionGroup_Should_AddOnePermissionGroup() { + // Arrange + var (context, repo) = await SetupEnvironment(0); + var group = new PermissionGroup { + Name = "group", + Description = "Group to add" + }; + + // Act + var result = await repo.CreatePermissionGroup(group); + + // Assert + Assert.Equal(group, result); + Assert.Single(context.Groups); + } + + [Fact] + public async Task DeletePermissionGroup_Should_DeleteOnePermissionGroup() { + // Arrange + var count = 5; + var (context, repo) = await SetupEnvironment(count); + + // Act + await repo.DeletePermissionGroup(context.Groups.First()); + + // Assert + Assert.Equal(count - 1, context.Groups.Count()); + } + +} \ No newline at end of file diff --git a/HopFrame.Database.Tests/Repositories/PermissionRepositoryTests.cs b/HopFrame.Database.Tests/Repositories/PermissionRepositoryTests.cs new file mode 100644 index 0000000..52a9ef7 --- /dev/null +++ b/HopFrame.Database.Tests/Repositories/PermissionRepositoryTests.cs @@ -0,0 +1,118 @@ +using HopFrame.Database.Models; +using HopFrame.Database.Repositories; +using HopFrame.Database.Repositories.Implementation; +using HopFrame.Database.Tests.Data; +using Microsoft.EntityFrameworkCore; + +namespace HopFrame.Database.Tests.Repositories; + +public class PermissionRepositoryTests { + + private async Task<(DatabaseContext, IPermissionRepository)> SetupEnvironment(int count = 5) { + var context = new DatabaseContext(); + var repo = new PermissionRepository(context, new GroupRepository(context)); + + for (int i = 0; i < count; i++) { + await context.Permissions.AddAsync(new () { + PermissionName = Guid.NewGuid().ToString(), + User = CreateTestUser() + }); + } + await context.SaveChangesAsync(); + + return (context, repo); + } + + private User CreateTestUser() => new () { + Username = "", + Email = "", + Password = "" + }; + + [Fact] + public async Task HasPermission_Returns_True() { + // Arrange + var (context, repo) = await SetupEnvironment(); + var user = CreateTestUser(); + await context.Permissions.AddAsync(new() { + PermissionName = "*", + User = user + }); + await context.SaveChangesAsync(); + + // Act + var hasPermission = await repo.HasPermission(user, "*"); + + // Assert + Assert.True(hasPermission); + } + + [Fact] + public async Task AddPermission_Should_AddOnePermission() { + // Arrange + var (context, repo) = await SetupEnvironment(); + var user = CreateTestUser(); + + // Act + var result = await repo.AddPermission(user, "test.permission"); + + // Assert + Assert.NotNull(result); + Assert.Single(context.Permissions + .Include(p => p.User) + .Where(p => p.User.Id == user.Id && p.PermissionName == "test.permission")); + } + + [Fact] + public async Task RemovePermission_Should_RemoveOnePermission() { + // Arrange + var (context, repo) = await SetupEnvironment(); + var user = CreateTestUser(); + await context.Permissions.AddAsync(new() { + PermissionName = "test.permission", + User = user + }); + await context.SaveChangesAsync(); + + // Act + await repo.RemovePermission(user, "test.permission"); + + // Assert + Assert.Empty(context.Permissions + .Include(p => p.User) + .Where(p => p.User.Id == user.Id && p.PermissionName == "test.permission")); + } + + [Fact] + public async Task GetFullPermissions_Return_AllPermissions_Including_Inherited() { + // Arrange + var (context, repo) = await SetupEnvironment(); + var user = CreateTestUser(); + var group = new PermissionGroup { + Name = "group.admin" + }; + await context.Permissions.AddRangeAsync(new List { + new() { + PermissionName = "test.permission.inherited", + Group = group + }, + new() { + PermissionName = "test.permission", + User = user + }, + new() { + PermissionName = "group.admin", + User = user + } + }); + await context.SaveChangesAsync(); + + // Act + var perms = await repo.GetFullPermissions(user); + + // Assert + Assert.NotNull(perms); + Assert.Equal(3, perms.Count); + } + +} \ No newline at end of file diff --git a/HopFrame.Database.Tests/Repositories/TokenRepositoryTests.cs b/HopFrame.Database.Tests/Repositories/TokenRepositoryTests.cs new file mode 100644 index 0000000..97e382a --- /dev/null +++ b/HopFrame.Database.Tests/Repositories/TokenRepositoryTests.cs @@ -0,0 +1,89 @@ +using HopFrame.Database.Models; +using HopFrame.Database.Repositories; +using HopFrame.Database.Repositories.Implementation; +using HopFrame.Database.Tests.Data; +using Microsoft.EntityFrameworkCore; + +namespace HopFrame.Database.Tests.Repositories; + +public class TokenRepositoryTests { + + private async Task<(DatabaseContext, ITokenRepository)> SetupEnvironment(int count = 5) { + var context = new DatabaseContext(); + var repo = new TokenRepository(context); + + for (int i = 0; i < count; i++) { + await context.Tokens.AddAsync(new() { + Content = Guid.NewGuid(), + Owner = CreateTestUser(), + Type = Token.AccessTokenType + }); + } + await context.SaveChangesAsync(); + + return (context, repo); + } + + private User CreateTestUser() => new () { + Username = "", + Email = "", + Password = "" + }; + + [Fact] + public async Task GetToken_Return_CorrectToken() { + // Arrange + var (context, repo) = await SetupEnvironment(); + var token = context.Tokens.First(); + + // Act + var result = await repo.GetToken(token.Content.ToString()); + + // Assert + Assert.Equal(token, result); + } + + [Fact] + public async Task CreateToken_Should_CreateOneToken() { + // Arrange + var (context, repo) = await SetupEnvironment(0); + + // Act + var result = await repo.CreateToken(Token.AccessTokenType, CreateTestUser()); + + // Assert + Assert.NotEmpty(context.Tokens); + Assert.Equal(result, context.Tokens.First()); + } + + [Fact] + public async Task DeleteUserTokens_Should_DeleteOnlyUserTokens() { + // Arrange + var dummyCount = 5; + var (context, repo) = await SetupEnvironment(dummyCount); + var user = CreateTestUser(); + await context.Tokens.AddRangeAsync(new List { + new() { + Content = Guid.NewGuid(), + Owner = user, + Type = Token.AccessTokenType + }, + new() { + Content = Guid.NewGuid(), + Owner = user, + Type = Token.RefreshTokenType + } + }); + await context.SaveChangesAsync(); + + // Act + await repo.DeleteUserTokens(user); + + // Assert + Assert.Equal(dummyCount, context.Tokens.Count()); + Assert.Empty(context.Tokens + .Include(t => t.Owner) + .Where(t => t.Owner == user)); + } + +} \ No newline at end of file diff --git a/HopFrame.sln b/HopFrame.sln index 269b7be..9177f94 100644 --- a/HopFrame.sln +++ b/HopFrame.sln @@ -20,6 +20,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Testing", "Testing", "{EEA2 EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{1D98E5DE-CB8B-4C1C-A319-D49AC137441A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Database.Tests", "HopFrame.Database.Tests\HopFrame.Database.Tests.csproj", "{1CAAC943-B8FE-48DD-9712-92699647DE18}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -54,6 +56,10 @@ Global {02D9F10A-664A-4EF7-BF19-310C26FF4DEB}.Debug|Any CPU.Build.0 = Debug|Any CPU {02D9F10A-664A-4EF7-BF19-310C26FF4DEB}.Release|Any CPU.ActiveCfg = Release|Any CPU {02D9F10A-664A-4EF7-BF19-310C26FF4DEB}.Release|Any CPU.Build.0 = Release|Any CPU + {1CAAC943-B8FE-48DD-9712-92699647DE18}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1CAAC943-B8FE-48DD-9712-92699647DE18}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1CAAC943-B8FE-48DD-9712-92699647DE18}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1CAAC943-B8FE-48DD-9712-92699647DE18}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {1E821490-AEDC-4F55-B758-52F4FADAB53A} = {64EDCBED-A84F-4936-8697-78DC43CB2427} @@ -63,5 +69,6 @@ Global {02D9F10A-664A-4EF7-BF19-310C26FF4DEB} = {64EDCBED-A84F-4936-8697-78DC43CB2427} {8F983A37-63CF-48D5-988D-58B78EF8AECD} = {EEA20D27-D471-44AF-A287-C0E068D93182} {921159CE-AF75-44C3-A3F9-6B9B1A4E85CF} = {EEA20D27-D471-44AF-A287-C0E068D93182} + {1CAAC943-B8FE-48DD-9712-92699647DE18} = {1D98E5DE-CB8B-4C1C-A319-D49AC137441A} EndGlobalSection EndGlobal diff --git a/HopFrame.sln.DotSettings.user b/HopFrame.sln.DotSettings.user index a38eed3..1798b6e 100644 --- a/HopFrame.sln.DotSettings.user +++ b/HopFrame.sln.DotSettings.user @@ -5,4 +5,21 @@ <AssemblyExplorer> <Assembly Path="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\7.0.16\ref\net7.0\System.ComponentModel.Annotations.dll" /> <Assembly Path="C:\Users\Remote\.nuget\packages\blazorstrap\5.2.100.61524\lib\net7.0\BlazorStrap.dll" /> -</AssemblyExplorer> \ No newline at end of file +</AssemblyExplorer> + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/HopFrame.Database/HopFrame.Database.csproj b/src/HopFrame.Database/HopFrame.Database.csproj index de55cd5..621c7b9 100644 --- a/src/HopFrame.Database/HopFrame.Database.csproj +++ b/src/HopFrame.Database/HopFrame.Database.csproj @@ -22,4 +22,10 @@ + + + <_Parameter1>HopFrame.Database.Tests + + + From b7eca1937cb5209cc26db5fa81e87adeb57f5453 Mon Sep 17 00:00:00 2001 From: Leon Hoppe Date: Sun, 24 Nov 2024 12:43:41 +0100 Subject: [PATCH 03/27] Attempted to fix test workflow not being restored --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 6287fe9..d8ae244 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -21,7 +21,7 @@ build: test: stage: test script: - - dotnet test --no-restore --verbosity normal + - dotnet test --verbosity normal publish: stage: publish From da45a84f61451195c1a80ce9a84dc30cd191b6e5 Mon Sep 17 00:00:00 2001 From: Leon Hoppe Date: Sun, 24 Nov 2024 12:47:23 +0100 Subject: [PATCH 04/27] Moved test project to correct folder --- HopFrame.sln | 2 +- .../HopFrame.Database.Tests}/Data/DatabaseContext.cs | 0 .../HopFrame.Database.Tests}/HopFrame.Database.Tests.csproj | 1 + .../HopFrame.Database.Tests}/PermissionValidatorTests.cs | 0 .../Repositories/GroupRepositoryTests.cs | 0 .../Repositories/PermissionRepositoryTests.cs | 0 .../Repositories/TokenRepositoryTests.cs | 0 7 files changed, 2 insertions(+), 1 deletion(-) rename {HopFrame.Database.Tests => tests/HopFrame.Database.Tests}/Data/DatabaseContext.cs (100%) rename {HopFrame.Database.Tests => tests/HopFrame.Database.Tests}/HopFrame.Database.Tests.csproj (91%) rename {HopFrame.Database.Tests => tests/HopFrame.Database.Tests}/PermissionValidatorTests.cs (100%) rename {HopFrame.Database.Tests => tests/HopFrame.Database.Tests}/Repositories/GroupRepositoryTests.cs (100%) rename {HopFrame.Database.Tests => tests/HopFrame.Database.Tests}/Repositories/PermissionRepositoryTests.cs (100%) rename {HopFrame.Database.Tests => tests/HopFrame.Database.Tests}/Repositories/TokenRepositoryTests.cs (100%) diff --git a/HopFrame.sln b/HopFrame.sln index 9177f94..7a60cf4 100644 --- a/HopFrame.sln +++ b/HopFrame.sln @@ -20,7 +20,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Testing", "Testing", "{EEA2 EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{1D98E5DE-CB8B-4C1C-A319-D49AC137441A}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Database.Tests", "HopFrame.Database.Tests\HopFrame.Database.Tests.csproj", "{1CAAC943-B8FE-48DD-9712-92699647DE18}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Database.Tests", "tests\HopFrame.Database.Tests\HopFrame.Database.Tests.csproj", "{1CAAC943-B8FE-48DD-9712-92699647DE18}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/HopFrame.Database.Tests/Data/DatabaseContext.cs b/tests/HopFrame.Database.Tests/Data/DatabaseContext.cs similarity index 100% rename from HopFrame.Database.Tests/Data/DatabaseContext.cs rename to tests/HopFrame.Database.Tests/Data/DatabaseContext.cs diff --git a/HopFrame.Database.Tests/HopFrame.Database.Tests.csproj b/tests/HopFrame.Database.Tests/HopFrame.Database.Tests.csproj similarity index 91% rename from HopFrame.Database.Tests/HopFrame.Database.Tests.csproj rename to tests/HopFrame.Database.Tests/HopFrame.Database.Tests.csproj index 5246200..05a23e3 100644 --- a/HopFrame.Database.Tests/HopFrame.Database.Tests.csproj +++ b/tests/HopFrame.Database.Tests/HopFrame.Database.Tests.csproj @@ -23,6 +23,7 @@ + diff --git a/HopFrame.Database.Tests/PermissionValidatorTests.cs b/tests/HopFrame.Database.Tests/PermissionValidatorTests.cs similarity index 100% rename from HopFrame.Database.Tests/PermissionValidatorTests.cs rename to tests/HopFrame.Database.Tests/PermissionValidatorTests.cs diff --git a/HopFrame.Database.Tests/Repositories/GroupRepositoryTests.cs b/tests/HopFrame.Database.Tests/Repositories/GroupRepositoryTests.cs similarity index 100% rename from HopFrame.Database.Tests/Repositories/GroupRepositoryTests.cs rename to tests/HopFrame.Database.Tests/Repositories/GroupRepositoryTests.cs diff --git a/HopFrame.Database.Tests/Repositories/PermissionRepositoryTests.cs b/tests/HopFrame.Database.Tests/Repositories/PermissionRepositoryTests.cs similarity index 100% rename from HopFrame.Database.Tests/Repositories/PermissionRepositoryTests.cs rename to tests/HopFrame.Database.Tests/Repositories/PermissionRepositoryTests.cs diff --git a/HopFrame.Database.Tests/Repositories/TokenRepositoryTests.cs b/tests/HopFrame.Database.Tests/Repositories/TokenRepositoryTests.cs similarity index 100% rename from HopFrame.Database.Tests/Repositories/TokenRepositoryTests.cs rename to tests/HopFrame.Database.Tests/Repositories/TokenRepositoryTests.cs From fca6ef4fa6c96f3265c57a91d41ab73bf068ad50 Mon Sep 17 00:00:00 2001 From: Leon Hoppe Date: Sun, 24 Nov 2024 16:01:33 +0100 Subject: [PATCH 05/27] Implemented all tests for database module --- HopFrame.sln.DotSettings.user | 10 + .../HopFrame.Database.Tests.csproj | 1 - .../Repositories/UserRepositoryTests.cs | 184 ++++++++++++++++++ 3 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 tests/HopFrame.Database.Tests/Repositories/UserRepositoryTests.cs diff --git a/HopFrame.sln.DotSettings.user b/HopFrame.sln.DotSettings.user index 1798b6e..a0c3151 100644 --- a/HopFrame.sln.DotSettings.user +++ b/HopFrame.sln.DotSettings.user @@ -1,11 +1,21 @@  ForceIncluded + ForceIncluded ForceIncluded + ForceIncluded ForceIncluded <AssemblyExplorer> <Assembly Path="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\7.0.16\ref\net7.0\System.ComponentModel.Annotations.dll" /> <Assembly Path="C:\Users\Remote\.nuget\packages\blazorstrap\5.2.100.61524\lib\net7.0\BlazorStrap.dll" /> </AssemblyExplorer> + <SessionState ContinuousTestingMode="0" IsActive="True" Name="UserRepositoryTests" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <TestAncestor> + <TestId>xUnit::1CAAC943-B8FE-48DD-9712-92699647DE18::net8.0::HopFrame.Database.Tests.Repositories.UserRepositoryTests</TestId> + </TestAncestor> +</SessionState> + + + diff --git a/tests/HopFrame.Database.Tests/HopFrame.Database.Tests.csproj b/tests/HopFrame.Database.Tests/HopFrame.Database.Tests.csproj index 05a23e3..4435c88 100644 --- a/tests/HopFrame.Database.Tests/HopFrame.Database.Tests.csproj +++ b/tests/HopFrame.Database.Tests/HopFrame.Database.Tests.csproj @@ -24,7 +24,6 @@ - diff --git a/tests/HopFrame.Database.Tests/Repositories/UserRepositoryTests.cs b/tests/HopFrame.Database.Tests/Repositories/UserRepositoryTests.cs new file mode 100644 index 0000000..7d8362b --- /dev/null +++ b/tests/HopFrame.Database.Tests/Repositories/UserRepositoryTests.cs @@ -0,0 +1,184 @@ +using HopFrame.Database.Models; +using HopFrame.Database.Repositories; +using HopFrame.Database.Repositories.Implementation; +using HopFrame.Database.Tests.Data; + +namespace HopFrame.Database.Tests.Repositories; + +public class UserRepositoryTests { + + private async Task<(DatabaseContext, IUserRepository)> SetupEnvironment(int count = 5) { + var context = new DatabaseContext(); + var repo = new UserRepository(context, new GroupRepository(context)); + + for (int i = 0; i < count; i++) { + await context.Users.AddAsync(CreateTestUser()); + } + await context.SaveChangesAsync(); + + return (context, repo); + } + + private User CreateTestUser() => new () { + Username = "", + Email = "", + Password = "" + }; + + [Fact] + public async Task GetUsers_Returns_AllUsers() { + // Arrange + var count = 5; + var (context, repo) = await SetupEnvironment(count); + + // Act + var users = await repo.GetUsers(); + + // Assert + Assert.NotNull(users); + Assert.Equal(count, users.Count); + } + + [Fact] + public async Task GetUser_Returns_SingleUser() { + // Arrange + var (context, repo) = await SetupEnvironment(); + var guid = context.Users.First().Id; + + // Act + var user = await repo.GetUser(guid); + + // Assert + Assert.NotNull(user); + Assert.Equal(guid, user.Id); + } + + [Fact] + public async Task GetUserByMail_Returns_SingleUser() { + // Arrange + var (context, repo) = await SetupEnvironment(); + var user = CreateTestUser(); + user.Email = "test@example.com"; + await context.Users.AddAsync(user); + await context.SaveChangesAsync(); + + // Act + var result = await repo.GetUserByEmail("test@example.com"); + + // Assert + Assert.NotNull(result); + Assert.Equal(user, result); + } + + [Fact] + public async Task GetUserByUsername_Returns_SingleUser() { + // Arrange + var (context, repo) = await SetupEnvironment(); + var user = CreateTestUser(); + user.Username = "test.user"; + await context.Users.AddAsync(user); + await context.SaveChangesAsync(); + + // Act + var result = await repo.GetUserByUsername("test.user"); + + // Assert + Assert.NotNull(result); + Assert.Equal(user, result); + } + + [Fact] + public async Task AddUser_Returns_NewUser() { + // Arrange + var count = 5; + var (context, repo) = await SetupEnvironment(count); + + // Act + var user = await repo.AddUser(new User { + Username = "test.user", + Email = "test@example.com", + Password = "changeme" + }); + + // Assert + Assert.NotNull(user); + Assert.Equal(count + 1, context.Users.Count()); + } + + [Fact] + public async Task UpdateUser_Should_UpdateAUser() { + // Arrange + var (context, repo) = await SetupEnvironment(); + var user = context.Users.First(); + + // Act + user.Username = "test.user"; + await repo.UpdateUser(user); + + // Assert + var result = context.Users.SingleOrDefault(u => u.Username == "test.user"); + Assert.NotNull(result); + } + + [Fact] + public async Task DeleteUser_Should_DeleteSingleUser() { + // Arrange + var count = 5; + var (context, repo) = await SetupEnvironment(count); + var user = context.Users.First(); + + // Act + await repo.DeleteUser(user); + + // Assert + Assert.Equal(count - 1, context.Users.Count()); + Assert.DoesNotContain(user, context.Users); + } + + [Fact] + public async Task CheckUserPassword_Returns_True() { + // Arrange + var (context, repo) = await SetupEnvironment(0); + var user = CreateTestUser(); + user.Password = "changeme"; + user = await repo.AddUser(user); + + // Act + var result = await repo.CheckUserPassword(user, "changeme"); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task CheckUserPassword_Returns_False() { + // Arrange + var (context, repo) = await SetupEnvironment(0); + var user = CreateTestUser(); + user.Password = "changeme"; + user = await repo.AddUser(user); + + // Act + var result = await repo.CheckUserPassword(user, "dontchangeme"); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task ChangePassword_Should_ChangeUserPassword() { + // Arrange + var (context, repo) = await SetupEnvironment(0); + var user = CreateTestUser(); + user.Password = "changeme"; + user = await repo.AddUser(user); + + // Act + await repo.ChangePassword(user, "changedme"); + + // Assert + var result = await repo.CheckUserPassword(user, "changedme"); + Assert.True(result); + } + +} \ No newline at end of file From 14c82f4f0638a39b5b8a4f086dadd6df22ffbcce Mon Sep 17 00:00:00 2001 From: Leon Hoppe Date: Sun, 24 Nov 2024 17:18:35 +0100 Subject: [PATCH 06/27] Implemented security module tests --- HopFrame.sln | 7 + HopFrame.sln.DotSettings.user | 18 ++- .../Authentication/HopFrameAuthentication.cs | 1 - .../HopFrame.Database.Tests.csproj | 1 - .../AuthenticationTests.cs | 141 ++++++++++++++++++ .../AuthorizationTests.cs | 92 ++++++++++++ .../HopFrame.Security.Tests.csproj | 34 +++++ 7 files changed, 290 insertions(+), 4 deletions(-) create mode 100644 tests/HopFrame.Security.Tests/AuthenticationTests.cs create mode 100644 tests/HopFrame.Security.Tests/AuthorizationTests.cs create mode 100644 tests/HopFrame.Security.Tests/HopFrame.Security.Tests.csproj diff --git a/HopFrame.sln b/HopFrame.sln index 7a60cf4..94da369 100644 --- a/HopFrame.sln +++ b/HopFrame.sln @@ -22,6 +22,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{1D98E5DE EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Database.Tests", "tests\HopFrame.Database.Tests\HopFrame.Database.Tests.csproj", "{1CAAC943-B8FE-48DD-9712-92699647DE18}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Security.Tests", "tests\HopFrame.Security.Tests\HopFrame.Security.Tests.csproj", "{6747753A-6059-48F1-B779-D73765A373A6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -60,6 +62,10 @@ Global {1CAAC943-B8FE-48DD-9712-92699647DE18}.Debug|Any CPU.Build.0 = Debug|Any CPU {1CAAC943-B8FE-48DD-9712-92699647DE18}.Release|Any CPU.ActiveCfg = Release|Any CPU {1CAAC943-B8FE-48DD-9712-92699647DE18}.Release|Any CPU.Build.0 = Release|Any CPU + {6747753A-6059-48F1-B779-D73765A373A6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6747753A-6059-48F1-B779-D73765A373A6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6747753A-6059-48F1-B779-D73765A373A6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6747753A-6059-48F1-B779-D73765A373A6}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {1E821490-AEDC-4F55-B758-52F4FADAB53A} = {64EDCBED-A84F-4936-8697-78DC43CB2427} @@ -70,5 +76,6 @@ Global {8F983A37-63CF-48D5-988D-58B78EF8AECD} = {EEA20D27-D471-44AF-A287-C0E068D93182} {921159CE-AF75-44C3-A3F9-6B9B1A4E85CF} = {EEA20D27-D471-44AF-A287-C0E068D93182} {1CAAC943-B8FE-48DD-9712-92699647DE18} = {1D98E5DE-CB8B-4C1C-A319-D49AC137441A} + {6747753A-6059-48F1-B779-D73765A373A6} = {1D98E5DE-CB8B-4C1C-A319-D49AC137441A} EndGlobalSection EndGlobal diff --git a/HopFrame.sln.DotSettings.user b/HopFrame.sln.DotSettings.user index a0c3151..4f1a50c 100644 --- a/HopFrame.sln.DotSettings.user +++ b/HopFrame.sln.DotSettings.user @@ -1,4 +1,5 @@  + ForceIncluded ForceIncluded ForceIncluded ForceIncluded @@ -8,9 +9,16 @@ <Assembly Path="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\7.0.16\ref\net7.0\System.ComponentModel.Annotations.dll" /> <Assembly Path="C:\Users\Remote\.nuget\packages\blazorstrap\5.2.100.61524\lib\net7.0\BlazorStrap.dll" /> </AssemblyExplorer> - <SessionState ContinuousTestingMode="0" IsActive="True" Name="UserRepositoryTests" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + + + + + + + + <SessionState ContinuousTestingMode="0" Name="Authentication_With_NoToken_Should_Fail" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <TestAncestor> - <TestId>xUnit::1CAAC943-B8FE-48DD-9712-92699647DE18::net8.0::HopFrame.Database.Tests.Repositories.UserRepositoryTests</TestId> + <TestId>xUnit::6747753A-6059-48F1-B779-D73765A373A6::net8.0::HopFrame.Security.Tests.AuthenticationTests.Authentication_With_NoToken_Should_Fail</TestId> </TestAncestor> </SessionState> @@ -27,6 +35,12 @@ + + + + + + diff --git a/src/HopFrame.Security/Authentication/HopFrameAuthentication.cs b/src/HopFrame.Security/Authentication/HopFrameAuthentication.cs index 9c65b14..8709f91 100644 --- a/src/HopFrame.Security/Authentication/HopFrameAuthentication.cs +++ b/src/HopFrame.Security/Authentication/HopFrameAuthentication.cs @@ -17,7 +17,6 @@ public class HopFrameAuthentication( UrlEncoder encoder, ISystemClock clock, ITokenRepository tokens, - IUserRepository users, IPermissionRepository perms) : AuthenticationHandler(options, logger, encoder, clock) { diff --git a/tests/HopFrame.Database.Tests/HopFrame.Database.Tests.csproj b/tests/HopFrame.Database.Tests/HopFrame.Database.Tests.csproj index 4435c88..ce93d67 100644 --- a/tests/HopFrame.Database.Tests/HopFrame.Database.Tests.csproj +++ b/tests/HopFrame.Database.Tests/HopFrame.Database.Tests.csproj @@ -11,7 +11,6 @@ - diff --git a/tests/HopFrame.Security.Tests/AuthenticationTests.cs b/tests/HopFrame.Security.Tests/AuthenticationTests.cs new file mode 100644 index 0000000..2594cbb --- /dev/null +++ b/tests/HopFrame.Security.Tests/AuthenticationTests.cs @@ -0,0 +1,141 @@ +using System.Text.Encodings.Web; +using HopFrame.Database.Models; +using HopFrame.Database.Repositories; +using HopFrame.Security.Authentication; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; + +namespace HopFrame.Security.Tests; + +public class AuthenticationTests { + + private async Task SetupEnvironment(Token correctToken = null, string providedToken = null) { + var options = new Mock>(); + options + .Setup(x => x.Get(It.IsAny())) + .Returns(new AuthenticationSchemeOptions()); + + var logger = new Mock(); + logger + .Setup(x => x.CreateLogger(It.IsAny())) + .Returns(new Mock>().Object); + + var encoder = new Mock(); + var clock = new Mock(); + var tokens = new Mock(); + var perms = new Mock(); + + var provideCorrectToken = correctToken is null; + correctToken ??= new Token { + Content = Guid.NewGuid(), + CreatedAt = DateTime.Now, + Type = Token.AccessTokenType, + Owner = new User { + Id = Guid.NewGuid() + } + }; + + tokens + .Setup(x => x.GetToken(It.Is(t => t == correctToken.Content.ToString()))) + .ReturnsAsync(correctToken); + + perms + .Setup(x => x.GetFullPermissions(It.IsAny())) + .ReturnsAsync(new List()); + + var auth = new HopFrameAuthentication(options.Object, logger.Object, encoder.Object, clock.Object, tokens.Object, perms.Object); + var context = new DefaultHttpContext(); + if (provideCorrectToken) + context.HttpContext.Request.Headers.Append(HopFrameAuthentication.SchemeName, correctToken.Content.ToString()); + if (providedToken is not null) + context.HttpContext.Request.Headers.Append(HopFrameAuthentication.SchemeName, providedToken); + + await auth.InitializeAsync(new AuthenticationScheme(HopFrameAuthentication.SchemeName, null, typeof(HopFrameAuthentication)), context); + return auth; + } + + [Fact] + public async Task Authentication_Should_Succeed() { + // Arrange + var auth = await SetupEnvironment(); + + // Act + var result = await auth.AuthenticateAsync(); + + // Assert + Assert.True(result.Succeeded); + } + + [Fact] + public async Task Authentication_With_NoToken_Should_Fail() { + // Arrange + var auth = await SetupEnvironment(new Token()); + + // Act + var result = await auth.AuthenticateAsync(); + + // Assert + Assert.False(result.Succeeded); + Assert.NotNull(result.Failure); + Assert.Equal("No Access Token provided", result.Failure.Message); + } + + [Fact] + public async Task Authentication_With_InvalidToken_Should_Fail() { + // Arrange + var auth = await SetupEnvironment(null, Guid.NewGuid().ToString()); + + // Act + var result = await auth.AuthenticateAsync(); + + // Assert + Assert.False(result.Succeeded); + Assert.NotNull(result.Failure); + Assert.Equal("The provided Access Token does not exist", result.Failure.Message); + } + + [Fact] + public async Task Authentication_With_ExpiredToken_Should_Fail() { + // Arrange + var token = new Token { + Content = Guid.NewGuid(), + CreatedAt = DateTime.MinValue, + Type = Token.AccessTokenType, + Owner = new User() + }; + var auth = await SetupEnvironment(token, token.Content.ToString()); + + // Act + var result = await auth.AuthenticateAsync(); + + // Assert + Assert.False(result.Succeeded); + Assert.NotNull(result.Failure); + Assert.Equal("The provided Access Token is expired", result.Failure.Message); + } + + [Fact] + public async Task Authentication_With_UnownedToken_Should_Fail() { + // Arrange + var token = new Token { + Content = Guid.NewGuid(), + CreatedAt = DateTime.Now, + Type = Token.AccessTokenType, + Owner = null + }; + var auth = await SetupEnvironment(token, token.Content.ToString()); + + // Act + var result = await auth.AuthenticateAsync(); + + // Assert + Assert.False(result.Succeeded); + Assert.NotNull(result.Failure); + Assert.Equal("The provided Access Token does not match any user", result.Failure.Message); + } + + +} \ No newline at end of file diff --git a/tests/HopFrame.Security.Tests/AuthorizationTests.cs b/tests/HopFrame.Security.Tests/AuthorizationTests.cs new file mode 100644 index 0000000..730dfde --- /dev/null +++ b/tests/HopFrame.Security.Tests/AuthorizationTests.cs @@ -0,0 +1,92 @@ +using System.Security.Claims; +using HopFrame.Security.Authentication; +using HopFrame.Security.Authorization; +using HopFrame.Security.Claims; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Filters; +using Moq; + +namespace HopFrame.Security.Tests; + +public class AuthorizationTests { + + private (AuthorizedFilter, AuthorizationFilterContext) SetupEnvironment(string[] userPermissions, string[] requiredPermissions, bool accessTokenProvided = true) { + var filter = new AuthorizedFilter(requiredPermissions); + + var httpContext = new DefaultHttpContext(); + var actionContext = new ActionContext { HttpContext = httpContext, RouteData = new RouteData(), ActionDescriptor = new ActionDescriptor() }; + var context = new Mock(MockBehavior.Default, actionContext, new List()); + + context + .Setup(x => x.Filters) + .Returns(new List()); + + context.SetupProperty(c => c.Result); + + var claims = new List { + new(HopFrameClaimTypes.UserId, Guid.NewGuid().ToString()) + }; + if (accessTokenProvided) + claims.Add(new (HopFrameClaimTypes.AccessTokenId, Guid.NewGuid().ToString())); + claims.AddRange(userPermissions.Select(perm => new Claim(HopFrameClaimTypes.Permission, perm))); + + context.Object.HttpContext.User.AddIdentity(new ClaimsIdentity(claims, HopFrameAuthentication.SchemeName)); + + return (filter, context.Object); + } + + [Fact] + public void OnAuthorization_Should_Succeed() { + // Arrange + var (filter, context) = SetupEnvironment(["test.permission"], ["test.permission"]); + + // Act + filter.OnAuthorization(context); + + // Assert + Assert.Null(context.Result); + } + + [Fact] + public void OnAuthorization_With_NoToken_Should_Fail() { + // Arrange + var (filter, context) = SetupEnvironment([], [], false); + + // Act + filter.OnAuthorization(context); + + // Assert + Assert.NotNull(context.Result); + Assert.IsType(context.Result); + } + + [Fact] + public void OnAuthorization_With_NoPermissions_Should_Fail() { + // Arrange + var (filter, context) = SetupEnvironment([], ["test.permission"]); + + // Act + filter.OnAuthorization(context); + + // Assert + Assert.NotNull(context.Result); + Assert.IsType(context.Result); + } + + [Fact] + public void OnAuthorization_With_InsufficientPermissions_Should_Fail() { + // Arrange + var (filter, context) = SetupEnvironment(["permission.other"], ["test.permission"]); + + // Act + filter.OnAuthorization(context); + + // Assert + Assert.NotNull(context.Result); + Assert.IsType(context.Result); + } + +} \ No newline at end of file diff --git a/tests/HopFrame.Security.Tests/HopFrame.Security.Tests.csproj b/tests/HopFrame.Security.Tests/HopFrame.Security.Tests.csproj new file mode 100644 index 0000000..c1feef2 --- /dev/null +++ b/tests/HopFrame.Security.Tests/HopFrame.Security.Tests.csproj @@ -0,0 +1,34 @@ + + + + net8.0 + enable + disable + + false + true + + + + + + + + + + + + + + + + + ..\..\..\..\..\..\..\Program Files\dotnet\shared\Microsoft.AspNetCore.App\8.0.11\Microsoft.AspNetCore.Authentication.dll + + + + + + + + From a4d1d3227be2ceae7d48c37e91f55680582c85d8 Mon Sep 17 00:00:00 2001 From: Leon Hoppe Date: Mon, 9 Dec 2024 18:41:51 +0100 Subject: [PATCH 07/27] Created tests for HopFrame.Api --- HopFrame.sln | 7 + HopFrame.sln.DotSettings.user | 18 +- src/HopFrame.Api/HopFrame.Api.csproj | 6 + .../Logic/Implementation/AuthLogic.cs | 14 +- tests/HopFrame.Api.Tests/AuthLogicTests.cs | 403 ++++++++++++++++++ .../Extensions/HttpContextExtensions.cs | 29 ++ .../HopFrame.Api.Tests.csproj | 28 ++ 7 files changed, 493 insertions(+), 12 deletions(-) create mode 100644 tests/HopFrame.Api.Tests/AuthLogicTests.cs create mode 100644 tests/HopFrame.Api.Tests/Extensions/HttpContextExtensions.cs create mode 100644 tests/HopFrame.Api.Tests/HopFrame.Api.Tests.csproj diff --git a/HopFrame.sln b/HopFrame.sln index 94da369..b5779b1 100644 --- a/HopFrame.sln +++ b/HopFrame.sln @@ -24,6 +24,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Database.Tests", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Security.Tests", "tests\HopFrame.Security.Tests\HopFrame.Security.Tests.csproj", "{6747753A-6059-48F1-B779-D73765A373A6}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Api.Tests", "tests\HopFrame.Api.Tests\HopFrame.Api.Tests.csproj", "{25DE1510-47E5-46FF-89A4-B9F99542218E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -66,6 +68,10 @@ Global {6747753A-6059-48F1-B779-D73765A373A6}.Debug|Any CPU.Build.0 = Debug|Any CPU {6747753A-6059-48F1-B779-D73765A373A6}.Release|Any CPU.ActiveCfg = Release|Any CPU {6747753A-6059-48F1-B779-D73765A373A6}.Release|Any CPU.Build.0 = Release|Any CPU + {25DE1510-47E5-46FF-89A4-B9F99542218E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {25DE1510-47E5-46FF-89A4-B9F99542218E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {25DE1510-47E5-46FF-89A4-B9F99542218E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {25DE1510-47E5-46FF-89A4-B9F99542218E}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {1E821490-AEDC-4F55-B758-52F4FADAB53A} = {64EDCBED-A84F-4936-8697-78DC43CB2427} @@ -77,5 +83,6 @@ Global {921159CE-AF75-44C3-A3F9-6B9B1A4E85CF} = {EEA20D27-D471-44AF-A287-C0E068D93182} {1CAAC943-B8FE-48DD-9712-92699647DE18} = {1D98E5DE-CB8B-4C1C-A319-D49AC137441A} {6747753A-6059-48F1-B779-D73765A373A6} = {1D98E5DE-CB8B-4C1C-A319-D49AC137441A} + {25DE1510-47E5-46FF-89A4-B9F99542218E} = {1D98E5DE-CB8B-4C1C-A319-D49AC137441A} EndGlobalSection EndGlobal diff --git a/HopFrame.sln.DotSettings.user b/HopFrame.sln.DotSettings.user index 4f1a50c..ea6ada4 100644 --- a/HopFrame.sln.DotSettings.user +++ b/HopFrame.sln.DotSettings.user @@ -2,6 +2,7 @@ ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded @@ -16,11 +17,18 @@ - <SessionState ContinuousTestingMode="0" Name="Authentication_With_NoToken_Should_Fail" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <TestAncestor> - <TestId>xUnit::6747753A-6059-48F1-B779-D73765A373A6::net8.0::HopFrame.Security.Tests.AuthenticationTests.Authentication_With_NoToken_Should_Fail</TestId> - </TestAncestor> -</SessionState> + + + + + + + + + + + + diff --git a/src/HopFrame.Api/HopFrame.Api.csproj b/src/HopFrame.Api/HopFrame.Api.csproj index 744a466..c8b21c4 100644 --- a/src/HopFrame.Api/HopFrame.Api.csproj +++ b/src/HopFrame.Api/HopFrame.Api.csproj @@ -22,4 +22,10 @@ + + + <_Parameter1>HopFrame.Api.Tests + + + diff --git a/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs b/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs index e792add..c1f3c90 100644 --- a/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs +++ b/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs @@ -8,7 +8,7 @@ using Microsoft.AspNetCore.Http; namespace HopFrame.Api.Logic.Implementation; -public class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenContext tokenContext, IHttpContextAccessor accessor) : IAuthLogic { +internal class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenContext tokenContext, IHttpContextAccessor accessor) : IAuthLogic { public async Task>> Login(UserLogin login) { var user = await users.GetUserByEmail(login.Email); @@ -38,7 +38,7 @@ public class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenCon public async Task>> Register(UserRegister register) { if (register.Password.Length < 8) - return LogicResult>.Conflict("Password needs to be at least 8 characters long"); + return LogicResult>.BadRequest("Password needs to be at least 8 characters long"); var allUsers = await users.GetUsers(); if (allUsers.Any(user => user.Username == register.Username || user.Email == register.Email)) @@ -71,18 +71,18 @@ public class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenCon var refreshToken = accessor.HttpContext?.Request.Cookies[ITokenContext.RefreshTokenType]; if (string.IsNullOrEmpty(refreshToken)) - return LogicResult>.Conflict("Refresh token not provided"); + return LogicResult>.BadRequest("Refresh token not provided"); var token = await tokens.GetToken(refreshToken); - if (token.Type != Token.RefreshTokenType) - return LogicResult>.BadRequest("The provided token is not a refresh token"); - if (token is null) return LogicResult>.NotFound("Refresh token not valid"); + if (token.Type != Token.RefreshTokenType) + return LogicResult>.Conflict("The provided token is not a refresh token"); + if (token.CreatedAt + HopFrameAuthentication.RefreshTokenTime < DateTime.Now) - return LogicResult>.Conflict("Refresh token is expired"); + return LogicResult>.Forbidden("Refresh token is expired"); var accessToken = await tokens.CreateToken(Token.AccessTokenType, token.Owner); diff --git a/tests/HopFrame.Api.Tests/AuthLogicTests.cs b/tests/HopFrame.Api.Tests/AuthLogicTests.cs new file mode 100644 index 0000000..ae82b65 --- /dev/null +++ b/tests/HopFrame.Api.Tests/AuthLogicTests.cs @@ -0,0 +1,403 @@ +using System.Net; +using System.Security.Claims; +using HopFrame.Api.Logic; +using HopFrame.Api.Logic.Implementation; +using HopFrame.Api.Models; +using HopFrame.Api.Tests.Extensions; +using HopFrame.Database.Models; +using HopFrame.Database.Repositories; +using HopFrame.Security.Authentication; +using HopFrame.Security.Claims; +using HopFrame.Security.Models; +using Microsoft.AspNetCore.Http; +using Moq; + +namespace HopFrame.Api.Tests; + +public class AuthLogicTests { + + private readonly Guid _refreshToken = Guid.NewGuid(); + private readonly Guid _accessToken = Guid.NewGuid(); + + private (IAuthLogic, HttpContext) SetupEnvironment(bool passwordIsCorrect = true, Token providedRefreshToken = null, string providedTokenCookie = null, bool provideAccessToken = true) { + var accessor = new HttpContextAccessor { + HttpContext = new DefaultHttpContext() + }; + + if (providedTokenCookie != null) { + var cookies = new Mock(); + cookies + .SetupGet(c => c[ITokenContext.RefreshTokenType]) + .Returns(providedTokenCookie); + accessor.HttpContext.Request.Cookies = cookies.Object; + } + + if (provideAccessToken) { + var claims = new List { + new(HopFrameClaimTypes.AccessTokenId, _accessToken.ToString()) + }; + accessor.HttpContext.User.AddIdentity(new ClaimsIdentity(claims, HopFrameAuthentication.SchemeName)); + } + + var users = new Mock(); + users + .Setup(u => u.GetUserByEmail(It.Is(email => CreateDummyUser().Email == email))) + .ReturnsAsync(CreateDummyUser()); + users + .Setup(u => u.CheckUserPassword(It.Is(u => u.Email == CreateDummyUser().Email), It.IsAny())) + .ReturnsAsync(passwordIsCorrect); + users + .Setup(u => u.AddUser(It.IsAny())) + .ReturnsAsync(CreateDummyUser()); + users + .Setup(u => u.GetUsers()) + .ReturnsAsync(new List { CreateDummyUser() }); + + var tokens = new Mock(); + tokens + .Setup(t => t.CreateToken(It.Is(t => t == Token.RefreshTokenType), It.IsAny())) + .ReturnsAsync(new Token { + Content = _refreshToken, + Type = Token.RefreshTokenType + }); + tokens + .Setup(t => t.CreateToken(It.Is(t => t == Token.AccessTokenType), It.IsAny())) + .ReturnsAsync(new Token { + Content = _accessToken, + Type = Token.AccessTokenType + }); + tokens + .Setup(t => t.GetToken(It.Is(token => token == _refreshToken.ToString()))) + .ReturnsAsync(providedRefreshToken); + + var context = new Mock(); + context + .Setup(c => c.User) + .Returns(CreateDummyUser()); + + return (new AuthLogic(users.Object, tokens.Object, context.Object, accessor), accessor.HttpContext); + } + + private User CreateDummyUser() => new() { + Id = Guid.NewGuid(), + CreatedAt = DateTime.Now, + Email = "test@example.com", + Username = "ExampleUser", + Password = "1234567890" + }; + + [Fact] + public async Task Login_Should_Succeed() { + // Arrange + var (auth, context) = SetupEnvironment(); + var login = new UserLogin { + Email = CreateDummyUser().Email, + Password = CreateDummyUser().Password + }; + + // Act + var result = await auth.Login(login); + + // Assert + Assert.True(result.IsSuccessful); + Assert.Equal(_accessToken.ToString(), result.Data.Value); + Assert.Equal(_accessToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + Assert.Equal(_refreshToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType)); + } + + [Fact] + public async Task Login_With_WrongEmail_Should_Fail() { + // Arrange + var (auth, context) = SetupEnvironment(); + var login = new UserLogin { + Email = "wrong@example.com", + Password = CreateDummyUser().Password + }; + + // Act + var result = await auth.Login(login); + + // Assert + Assert.False(result.IsSuccessful); + Assert.Equal(HttpStatusCode.NotFound, result.State); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType)); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + } + + [Fact] + public async Task Login_With_WrongPassword_Should_Fail() { + // Arrange + var (auth, context) = SetupEnvironment(false); + var login = new UserLogin { + Email = CreateDummyUser().Email, + Password = CreateDummyUser().Password + }; + + // Act + var result = await auth.Login(login); + + // Assert + Assert.False(result.IsSuccessful); + Assert.Equal(HttpStatusCode.Forbidden, result.State); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType)); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + } + + [Fact] + public async Task Register_Should_Succeed() { + // Arrange + var (auth, context) = SetupEnvironment(); + var register = new UserRegister { + Email = "new@example.com", + Password = "1234567890", + Username = "NewUser" + }; + + // Act + var result = await auth.Register(register); + + // Assert + Assert.True(result.IsSuccessful); + Assert.Equal(_accessToken.ToString(), result.Data.Value); + Assert.Equal(_accessToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + Assert.Equal(_refreshToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType)); + } + + [Fact] + public async Task Register_With_ShortPassword_Should_Fail() { + // Arrange + var (auth, context) = SetupEnvironment(); + var register = new UserRegister { + Email = "new@example.com", + Password = "12345", + Username = "NewUser" + }; + + // Act + var result = await auth.Register(register); + + // Assert + Assert.False(result.IsSuccessful); + Assert.Equal(HttpStatusCode.BadRequest, result.State); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType)); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + } + + [Fact] + public async Task Register_With_ExistingUsername_Should_Fail() { + // Arrange + var (auth, context) = SetupEnvironment(); + var register = new UserRegister { + Email = "new@example.com", + Password = "1234567890", + Username = CreateDummyUser().Username + }; + + // Act + var result = await auth.Register(register); + + // Assert + Assert.False(result.IsSuccessful); + Assert.Equal(HttpStatusCode.Conflict, result.State); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType)); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + } + + [Fact] + public async Task Register_With_ExistingEmail_Should_Fail() { + // Arrange + var (auth, context) = SetupEnvironment(); + var register = new UserRegister { + Email = CreateDummyUser().Email, + Password = "1234567890", + Username = "NewUser" + }; + + // Act + var result = await auth.Register(register); + + // Assert + Assert.False(result.IsSuccessful); + Assert.Equal(HttpStatusCode.Conflict, result.State); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType)); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + } + + [Fact] + public async Task Authenticate_Should_Succeed() { + // Arrange + var token = new Token { + Type = Token.RefreshTokenType, + Content = _refreshToken, + CreatedAt = DateTime.Now, + Owner = CreateDummyUser() + }; + var (auth, context) = SetupEnvironment(true, token, token.Content.ToString()); + + // Act + var result = await auth.Authenticate(); + + // Assert + Assert.True(result.IsSuccessful); + Assert.Equal(_accessToken.ToString(), result.Data.Value); + Assert.Equal(_accessToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + } + + [Fact] + public async Task Authenticate_With_NoProvidedToken_Should_Fail() { + // Arrange + var (auth, context) = SetupEnvironment(); + + // Act + var result = await auth.Authenticate(); + + // Assert + Assert.False(result.IsSuccessful); + Assert.Equal(HttpStatusCode.BadRequest, result.State); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + } + + [Fact] + public async Task Authenticate_With_WrongToken_Should_Fail() { + // Arrange + var (auth, context) = SetupEnvironment(true, null, _refreshToken.ToString()); + + // Act + var result = await auth.Authenticate(); + + // Assert + Assert.False(result.IsSuccessful); + Assert.Equal(HttpStatusCode.NotFound, result.State); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + } + + [Fact] + public async Task Authenticate_With_WrongTokenType_Should_Fail() { + // Arrange + var token = new Token { + Type = Token.AccessTokenType, + Content = _refreshToken, + CreatedAt = DateTime.Now, + Owner = CreateDummyUser() + }; + var (auth, context) = SetupEnvironment(true, token, token.Content.ToString()); + + // Act + var result = await auth.Authenticate(); + + // Assert + Assert.False(result.IsSuccessful); + Assert.Equal(HttpStatusCode.Conflict, result.State); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + } + + [Fact] + public async Task Authenticate_With_ExpiredToken_Should_Fail() { + // Arrange + var token = new Token { + Type = Token.RefreshTokenType, + Content = _refreshToken, + CreatedAt = DateTime.MinValue, + Owner = CreateDummyUser() + }; + var (auth, context) = SetupEnvironment(true, token, token.Content.ToString()); + + // Act + var result = await auth.Authenticate(); + + // Assert + Assert.False(result.IsSuccessful); + Assert.Equal(HttpStatusCode.Forbidden, result.State); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + } + + [Fact] + public async Task Logout_Should_Succeed() { + // Arrange + var (auth, context) = SetupEnvironment(providedTokenCookie: _refreshToken.ToString()); + context.Response.Cookies.Append(ITokenContext.AccessTokenType, _accessToken.ToString()); + context.Response.Cookies.Append(ITokenContext.RefreshTokenType, _refreshToken.ToString()); + + // Act + var result = await auth.Logout(); + + // Assert + Assert.True(result.IsSuccessful); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType)); + } + + [Fact] + public async Task Logout_With_NoAccessToken_Should_Fail() { + // Arrange + var (auth, context) = SetupEnvironment(provideAccessToken: false); + context.Response.Cookies.Append(ITokenContext.AccessTokenType, _accessToken.ToString()); + context.Response.Cookies.Append(ITokenContext.RefreshTokenType, _refreshToken.ToString()); + + // Act + var result = await auth.Logout(); + + // Assert + Assert.False(result.IsSuccessful); + Assert.Equal(HttpStatusCode.Conflict, result.State); + Assert.Equal(_accessToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + Assert.Equal(_refreshToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType)); + } + + [Fact] + public async Task Logout_With_NoRefreshToken_Should_Fail() { + // Arrange + var (auth, context) = SetupEnvironment(); + context.Response.Cookies.Append(ITokenContext.AccessTokenType, _accessToken.ToString()); + context.Response.Cookies.Append(ITokenContext.RefreshTokenType, _refreshToken.ToString()); + + // Act + var result = await auth.Logout(); + + // Assert + Assert.False(result.IsSuccessful); + Assert.Equal(HttpStatusCode.Conflict, result.State); + Assert.Equal(_accessToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + Assert.Equal(_refreshToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType)); + } + + [Fact] + public async Task Delete_Should_Succeed() { + // Arrange + var (auth, context) = SetupEnvironment(); + context.Response.Cookies.Append(ITokenContext.AccessTokenType, _accessToken.ToString()); + context.Response.Cookies.Append(ITokenContext.RefreshTokenType, _refreshToken.ToString()); + var validation = new UserPasswordValidation { + Password = CreateDummyUser().Password + }; + + // Act + var result = await auth.Delete(validation); + + // Assert + Assert.True(result.IsSuccessful); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType)); + } + + [Fact] + public async Task Delete_With_WrongPassword_Should_Fail() { + // Arrange + var (auth, context) = SetupEnvironment(false); + context.Response.Cookies.Append(ITokenContext.AccessTokenType, _accessToken.ToString()); + context.Response.Cookies.Append(ITokenContext.RefreshTokenType, _refreshToken.ToString()); + var validation = new UserPasswordValidation { + Password = CreateDummyUser().Password + }; + + // Act + var result = await auth.Delete(validation); + + // Assert + Assert.False(result.IsSuccessful); + Assert.Equal(HttpStatusCode.Forbidden, result.State); + Assert.Equal(_accessToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + Assert.Equal(_refreshToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType)); + } + +} \ No newline at end of file diff --git a/tests/HopFrame.Api.Tests/Extensions/HttpContextExtensions.cs b/tests/HopFrame.Api.Tests/Extensions/HttpContextExtensions.cs new file mode 100644 index 0000000..4ccbb38 --- /dev/null +++ b/tests/HopFrame.Api.Tests/Extensions/HttpContextExtensions.cs @@ -0,0 +1,29 @@ +using System.Web; +using Microsoft.AspNetCore.Http; + +namespace HopFrame.Api.Tests.Extensions; + +internal static class HttpContextExtensions { + /// Extracts the partial cookie value from the header section. + /// + /// The key for identifying the cookie. + /// The value of the cookie. + public static string FindCookie(this IHeaderDictionary headers, string key) + { + string headerKey = $"{key}="; + var cookies = headers.Values + .SelectMany(h => h) + .Where(header => header.StartsWith(headerKey)) + .Select(header => header.Substring(headerKey.Length).Split(';').First()) + .ToArray(); + + //Note: cookie values in a header are encoded like a uri parameter value. + var value = cookies.LastOrDefault();//and the last set value, is the relevant one. + if (string.IsNullOrEmpty(value)) + return null; + + //That's why we should decode that last value, before we return it. + var decoded = HttpUtility.UrlDecode(value); + return decoded; + } +} \ No newline at end of file diff --git a/tests/HopFrame.Api.Tests/HopFrame.Api.Tests.csproj b/tests/HopFrame.Api.Tests/HopFrame.Api.Tests.csproj new file mode 100644 index 0000000..6c7f59f --- /dev/null +++ b/tests/HopFrame.Api.Tests/HopFrame.Api.Tests.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + enable + disable + + false + true + + + + + + + + + + + + + + + + + + + From 4d91ce181987796fd8a9baaab9b218cbec51e602 Mon Sep 17 00:00:00 2001 From: Leon Hoppe Date: Tue, 10 Dec 2024 16:30:46 +0100 Subject: [PATCH 08/27] Implemented HopFrame.Web tests --- HopFrame.sln | 7 + src/HopFrame.Web/HopFrame.Web.csproj | 6 + .../Services/Implementation/AuthService.cs | 13 +- .../HopFrame.Web.Tests/AuthMiddlewareTests.cs | 94 +++++ tests/HopFrame.Web.Tests/AuthServiceTests.cs | 334 ++++++++++++++++++ .../Extensions/HttpContextExtensions.cs | 29 ++ .../HopFrame.Web.Tests.csproj | 28 ++ 7 files changed, 503 insertions(+), 8 deletions(-) create mode 100644 tests/HopFrame.Web.Tests/AuthMiddlewareTests.cs create mode 100644 tests/HopFrame.Web.Tests/AuthServiceTests.cs create mode 100644 tests/HopFrame.Web.Tests/Extensions/HttpContextExtensions.cs create mode 100644 tests/HopFrame.Web.Tests/HopFrame.Web.Tests.csproj diff --git a/HopFrame.sln b/HopFrame.sln index b5779b1..195f022 100644 --- a/HopFrame.sln +++ b/HopFrame.sln @@ -26,6 +26,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Security.Tests", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Api.Tests", "tests\HopFrame.Api.Tests\HopFrame.Api.Tests.csproj", "{25DE1510-47E5-46FF-89A4-B9F99542218E}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Web.Tests", "tests\HopFrame.Web.Tests\HopFrame.Web.Tests.csproj", "{566C13B9-4ECA-48C4-8D02-FEB6CDF523E6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -72,6 +74,10 @@ Global {25DE1510-47E5-46FF-89A4-B9F99542218E}.Debug|Any CPU.Build.0 = Debug|Any CPU {25DE1510-47E5-46FF-89A4-B9F99542218E}.Release|Any CPU.ActiveCfg = Release|Any CPU {25DE1510-47E5-46FF-89A4-B9F99542218E}.Release|Any CPU.Build.0 = Release|Any CPU + {566C13B9-4ECA-48C4-8D02-FEB6CDF523E6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {566C13B9-4ECA-48C4-8D02-FEB6CDF523E6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {566C13B9-4ECA-48C4-8D02-FEB6CDF523E6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {566C13B9-4ECA-48C4-8D02-FEB6CDF523E6}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {1E821490-AEDC-4F55-B758-52F4FADAB53A} = {64EDCBED-A84F-4936-8697-78DC43CB2427} @@ -84,5 +90,6 @@ Global {1CAAC943-B8FE-48DD-9712-92699647DE18} = {1D98E5DE-CB8B-4C1C-A319-D49AC137441A} {6747753A-6059-48F1-B779-D73765A373A6} = {1D98E5DE-CB8B-4C1C-A319-D49AC137441A} {25DE1510-47E5-46FF-89A4-B9F99542218E} = {1D98E5DE-CB8B-4C1C-A319-D49AC137441A} + {566C13B9-4ECA-48C4-8D02-FEB6CDF523E6} = {1D98E5DE-CB8B-4C1C-A319-D49AC137441A} EndGlobalSection EndGlobal diff --git a/src/HopFrame.Web/HopFrame.Web.csproj b/src/HopFrame.Web/HopFrame.Web.csproj index a512b95..c368805 100644 --- a/src/HopFrame.Web/HopFrame.Web.csproj +++ b/src/HopFrame.Web/HopFrame.Web.csproj @@ -35,4 +35,10 @@ + + + <_Parameter1>HopFrame.Web.Tests + + + diff --git a/src/HopFrame.Web/Services/Implementation/AuthService.cs b/src/HopFrame.Web/Services/Implementation/AuthService.cs index 6a96a97..e5f1ec0 100644 --- a/src/HopFrame.Web/Services/Implementation/AuthService.cs +++ b/src/HopFrame.Web/Services/Implementation/AuthService.cs @@ -91,15 +91,12 @@ internal class AuthService( } public async Task IsLoggedIn() { - var accessToken = httpAccessor.HttpContext?.Request.Cookies[ITokenContext.AccessTokenType]; - if (string.IsNullOrEmpty(accessToken)) return false; - - var tokenEntry = await tokens.GetToken(accessToken); + var accessToken = context.AccessToken; - if (tokenEntry is null) return false; - if (tokenEntry.Type != Token.AccessTokenType) return false; - if (tokenEntry.CreatedAt + HopFrameAuthentication.AccessTokenTime < DateTime.Now) return false; - if (tokenEntry.Owner is null) return false; + if (accessToken is null) return false; + if (accessToken.Type != Token.AccessTokenType) return false; + if (accessToken.CreatedAt + HopFrameAuthentication.AccessTokenTime < DateTime.Now) return false; + if (accessToken.Owner is null) return false; return true; } diff --git a/tests/HopFrame.Web.Tests/AuthMiddlewareTests.cs b/tests/HopFrame.Web.Tests/AuthMiddlewareTests.cs new file mode 100644 index 0000000..70d1f8e --- /dev/null +++ b/tests/HopFrame.Web.Tests/AuthMiddlewareTests.cs @@ -0,0 +1,94 @@ +using System.Security.Claims; +using HopFrame.Database.Models; +using HopFrame.Database.Repositories; +using HopFrame.Security.Claims; +using HopFrame.Web.Services; +using Microsoft.AspNetCore.Http; +using Moq; + +namespace HopFrame.Web.Tests; + +public class AuthMiddlewareTests { + private readonly RequestDelegate _delegate = _ => Task.CompletedTask; + + public AuthMiddleware SetupEnvironment(bool isLoggedIn = true, Token newToken = null) { + var auth = new Mock(); + auth + .Setup(a => a.IsLoggedIn()) + .ReturnsAsync(isLoggedIn); + auth + .Setup(a => a.RefreshLogin()) + .ReturnsAsync(newToken); + + var perms = new Mock(); + perms + .Setup(p => p.GetFullPermissions(It.Is(u => newToken.Owner.Id == u.Id))) + .ReturnsAsync(CreateDummyUser().Permissions.Select(p => p.PermissionName).ToList); + + return new AuthMiddleware(auth.Object, perms.Object); + } + + private User CreateDummyUser() => new() { + Id = Guid.NewGuid(), + CreatedAt = DateTime.Now, + Email = "test@example.com", + Username = "ExampleUser", + Password = "1234567890", + Permissions = new List { + new () { + PermissionName = "test.permission" + } + } + }; + + [Fact] + public async Task InvokeAsync_With_ValidLogin_Should_Succeed() { + // Arrange + var auth = SetupEnvironment(); + var context = new DefaultHttpContext(); + + // Act + await auth.InvokeAsync(context, _delegate); + + // Assert + Assert.Null(context.User.FindFirst(HopFrameClaimTypes.UserId)); + Assert.Null(context.User.FindFirst(HopFrameClaimTypes.AccessTokenId)); + Assert.Null(context.User.FindFirst(HopFrameClaimTypes.Permission)); + } + + [Fact] + public async Task InvokeAsync_With_InvalidLoginValidToken_Should_Succeed() { + // Arrange + var token = new Token { + Content = Guid.NewGuid(), + CreatedAt = DateTime.Now, + Type = Token.AccessTokenType, + Owner = CreateDummyUser() + }; + var auth = SetupEnvironment(false, token); + var context = new DefaultHttpContext(); + + // Act + await auth.InvokeAsync(context, _delegate); + + // Assert + Assert.Equal(token.Owner.Id.ToString(), context.User.FindFirstValue(HopFrameClaimTypes.UserId)); + Assert.Equal(token.Content.ToString(), context.User.FindFirstValue(HopFrameClaimTypes.AccessTokenId)); + Assert.Equal(token.Owner.Permissions.First().PermissionName, context.User.FindFirstValue(HopFrameClaimTypes.Permission)); + } + + [Fact] + public async Task InvokeAsync_With_InvalidLoginInvalidToken_Should_Succeed() { + // Arrange + var auth = SetupEnvironment(false); + var context = new DefaultHttpContext(); + + // Act + await auth.InvokeAsync(context, _delegate); + + // Assert + Assert.Null(context.User.FindFirst(HopFrameClaimTypes.UserId)); + Assert.Null(context.User.FindFirst(HopFrameClaimTypes.AccessTokenId)); + Assert.Null(context.User.FindFirst(HopFrameClaimTypes.Permission)); + } +} \ No newline at end of file diff --git a/tests/HopFrame.Web.Tests/AuthServiceTests.cs b/tests/HopFrame.Web.Tests/AuthServiceTests.cs new file mode 100644 index 0000000..4374148 --- /dev/null +++ b/tests/HopFrame.Web.Tests/AuthServiceTests.cs @@ -0,0 +1,334 @@ +using HopFrame.Api.Tests.Extensions; +using HopFrame.Database.Models; +using HopFrame.Database.Repositories; +using HopFrame.Security.Claims; +using HopFrame.Security.Models; +using HopFrame.Web.Services; +using HopFrame.Web.Services.Implementation; +using Microsoft.AspNetCore.Http; +using Moq; + +namespace HopFrame.Web.Tests; + +public class AuthServiceTests { + private readonly Guid _refreshToken = Guid.NewGuid(); + private readonly Guid _accessToken = Guid.NewGuid(); + + private (IAuthService, HttpContext) SetupEnvironment(bool passwordIsCorrect = true, Token providedRefreshToken = null, string providedTokenCookie = null, Token providedAccessToken = null) { + var accessor = new HttpContextAccessor { + HttpContext = new DefaultHttpContext() + }; + + if (providedTokenCookie != null) { + var cookies = new Mock(); + cookies + .SetupGet(c => c[ITokenContext.RefreshTokenType]) + .Returns(providedTokenCookie); + accessor.HttpContext.Request.Cookies = cookies.Object; + } + + var users = new Mock(); + users + .Setup(u => u.GetUserByEmail(It.Is(email => CreateDummyUser().Email == email))) + .ReturnsAsync(CreateDummyUser()); + users + .Setup(u => u.CheckUserPassword(It.Is(u => u.Email == CreateDummyUser().Email), It.IsAny())) + .ReturnsAsync(passwordIsCorrect); + users + .Setup(u => u.AddUser(It.IsAny())) + .ReturnsAsync(CreateDummyUser()); + users + .Setup(u => u.GetUsers()) + .ReturnsAsync(new List { CreateDummyUser() }); + + var tokens = new Mock(); + tokens + .Setup(t => t.CreateToken(It.Is(t => t == Token.RefreshTokenType), It.IsAny())) + .ReturnsAsync(new Token { + Content = _refreshToken, + Type = Token.RefreshTokenType + }); + tokens + .Setup(t => t.CreateToken(It.Is(t => t == Token.AccessTokenType), It.IsAny())) + .ReturnsAsync(new Token { + Content = _accessToken, + Type = Token.AccessTokenType + }); + tokens + .Setup(t => t.GetToken(It.Is(token => token == _refreshToken.ToString()))) + .ReturnsAsync(providedRefreshToken); + + var context = new Mock(); + context + .Setup(c => c.User) + .Returns(CreateDummyUser()); + context + .Setup(c => c.AccessToken) + .Returns(providedAccessToken); + + return (new AuthService(users.Object, accessor, tokens.Object, context.Object), accessor.HttpContext); + } + + private User CreateDummyUser() => new() { + Id = Guid.NewGuid(), + CreatedAt = DateTime.Now, + Email = "test@example.com", + Username = "ExampleUser", + Password = "1234567890" + }; + + [Fact] + public async Task Register_Should_Succeed() { + // Arrange + var (service, context) = SetupEnvironment(); + var register = new UserRegister { + Email = CreateDummyUser().Email, + Username = CreateDummyUser().Username, + Password = CreateDummyUser().Password + }; + + // Act + await service.Register(register); + + // Assert + Assert.Equal(_accessToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + Assert.Equal(_refreshToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType)); + } + + [Fact] + public async Task Login_Should_Succeed() { + // Arrange + var (service, context) = SetupEnvironment(); + var login = new UserLogin { + Email = CreateDummyUser().Email, + Password = CreateDummyUser().Password + }; + + // Act + var result = await service.Login(login); + + // Assert + Assert.True(result); + Assert.Equal(_accessToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + Assert.Equal(_refreshToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType)); + } + + [Fact] + public async Task Login_With_WrongPassword_Should_Fail() { + // Arrange + var (service, context) = SetupEnvironment(false); + var login = new UserLogin { + Email = CreateDummyUser().Email, + Password = CreateDummyUser().Password + }; + + // Act + var result = await service.Login(login); + + // Assert + Assert.False(result); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType)); + } + + [Fact] + public async Task Login_With_WrongEmail_Should_Fail() { + // Arrange + var (service, context) = SetupEnvironment(); + var login = new UserLogin { + Email = "wrong@example.com", + Password = CreateDummyUser().Password + }; + + // Act + var result = await service.Login(login); + + // Assert + Assert.False(result); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType)); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + } + + [Fact] + public async Task Logout_Should_Succeed() { + // Arrange + var (service, context) = SetupEnvironment(providedTokenCookie: _refreshToken.ToString()); + context.Response.Cookies.Append(ITokenContext.AccessTokenType, _accessToken.ToString()); + context.Response.Cookies.Append(ITokenContext.RefreshTokenType, _refreshToken.ToString()); + + // Act + await service.Logout(); + + // Assert + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType)); + } + + [Fact] + public async Task RefreshLogin_Should_Succeed() { + // Arrange + var token = new Token { + Type = Token.RefreshTokenType, + Content = _refreshToken, + CreatedAt = DateTime.Now, + Owner = CreateDummyUser() + }; + var (service, context) = SetupEnvironment(true, token, token.Content.ToString()); + + // Act + var result = await service.RefreshLogin(); + + // Assert + Assert.NotNull(result); + Assert.Equal(_accessToken, result.Content); + Assert.Equal(_accessToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + } + + [Fact] + public async Task RefreshLogin_With_NoProvidedToken_Should_Fail() { + // Arrange + var (service, context) = SetupEnvironment(); + + // Act + var result = await service.RefreshLogin(); + + // Assert + Assert.Null(result); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + } + + [Fact] + public async Task RefreshLogin_With_WrongToken_Should_Fail() { + // Arrange + var (service, context) = SetupEnvironment(true, null, _refreshToken.ToString()); + + // Act + var result = await service.RefreshLogin(); + + // Assert + Assert.Null(result); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + } + + [Fact] + public async Task RefreshLogin_With_WrongTokenType_Should_Fail() { + // Arrange + var token = new Token { + Type = Token.AccessTokenType, + Content = _refreshToken, + CreatedAt = DateTime.Now, + Owner = CreateDummyUser() + }; + var (service, context) = SetupEnvironment(true, token, token.Content.ToString()); + + // Act + var result = await service.RefreshLogin(); + + // Assert + Assert.Null(result); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + } + + [Fact] + public async Task RefreshLogin_With_ExpiredToken_Should_Fail() { + // Arrange + var token = new Token { + Type = Token.RefreshTokenType, + Content = _refreshToken, + CreatedAt = DateTime.MinValue, + Owner = CreateDummyUser() + }; + var (service, context) = SetupEnvironment(true, token, token.Content.ToString()); + + // Act + var result = await service.RefreshLogin(); + + // Assert + Assert.Null(result); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + } + + [Fact] + public async Task IsLoggedIn_Should_Succeed() { + // Arrange + var token = new Token { + Type = Token.AccessTokenType, + Content = _accessToken, + CreatedAt = DateTime.Now, + Owner = CreateDummyUser() + }; + var (service, context) = SetupEnvironment(providedAccessToken: token); + + // Act + var result = await service.IsLoggedIn(); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task IsLoggedIn_With_NoProvidedToken_Should_Fail() { + // Arrange + var (service, context) = SetupEnvironment(); + + // Act + var result = await service.IsLoggedIn(); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task IsLoggedIn_With_WrongTokenType_Should_Fail() { + // Arrange + var token = new Token { + Type = Token.RefreshTokenType, + Content = _accessToken, + CreatedAt = DateTime.Now, + Owner = CreateDummyUser() + }; + var (service, context) = SetupEnvironment(providedAccessToken: token); + + // Act + var result = await service.IsLoggedIn(); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task IsLoggedIn_With_ExpiredToken_Should_Fail() { + // Arrange + var token = new Token { + Type = Token.AccessTokenType, + Content = _accessToken, + CreatedAt = DateTime.MinValue, + Owner = CreateDummyUser() + }; + var (service, context) = SetupEnvironment(providedAccessToken: token); + + // Act + var result = await service.IsLoggedIn(); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task IsLoggedIn_With_NoOwner_Should_Fail() { + // Arrange + var token = new Token { + Type = Token.AccessTokenType, + Content = _accessToken, + CreatedAt = DateTime.Now, + Owner = null + }; + var (service, context) = SetupEnvironment(providedAccessToken: token); + + // Act + var result = await service.IsLoggedIn(); + + // Assert + Assert.False(result); + } +} \ No newline at end of file diff --git a/tests/HopFrame.Web.Tests/Extensions/HttpContextExtensions.cs b/tests/HopFrame.Web.Tests/Extensions/HttpContextExtensions.cs new file mode 100644 index 0000000..4ccbb38 --- /dev/null +++ b/tests/HopFrame.Web.Tests/Extensions/HttpContextExtensions.cs @@ -0,0 +1,29 @@ +using System.Web; +using Microsoft.AspNetCore.Http; + +namespace HopFrame.Api.Tests.Extensions; + +internal static class HttpContextExtensions { + /// Extracts the partial cookie value from the header section. + /// + /// The key for identifying the cookie. + /// The value of the cookie. + public static string FindCookie(this IHeaderDictionary headers, string key) + { + string headerKey = $"{key}="; + var cookies = headers.Values + .SelectMany(h => h) + .Where(header => header.StartsWith(headerKey)) + .Select(header => header.Substring(headerKey.Length).Split(';').First()) + .ToArray(); + + //Note: cookie values in a header are encoded like a uri parameter value. + var value = cookies.LastOrDefault();//and the last set value, is the relevant one. + if (string.IsNullOrEmpty(value)) + return null; + + //That's why we should decode that last value, before we return it. + var decoded = HttpUtility.UrlDecode(value); + return decoded; + } +} \ No newline at end of file diff --git a/tests/HopFrame.Web.Tests/HopFrame.Web.Tests.csproj b/tests/HopFrame.Web.Tests/HopFrame.Web.Tests.csproj new file mode 100644 index 0000000..062ade0 --- /dev/null +++ b/tests/HopFrame.Web.Tests/HopFrame.Web.Tests.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + enable + disable + + false + true + + + + + + + + + + + + + + + + + + + From ee7bf1e204710021183590f26d744a0f482d8f1f Mon Sep 17 00:00:00 2001 From: Leon Hoppe Date: Tue, 10 Dec 2024 16:39:28 +0100 Subject: [PATCH 09/27] Renamed test projects --- HopFrame.sln | 8 ++++---- src/HopFrame.Api/HopFrame.Api.csproj | 2 +- src/HopFrame.Database/HopFrame.Database.csproj | 2 +- src/HopFrame.Web/HopFrame.Web.csproj | 2 +- .../AuthLogicTests.cs | 4 ++-- .../Extensions/HttpContextExtensions.cs | 2 +- .../HopFrame.Tests.Api.csproj} | 0 .../Data/DatabaseContext.cs | 3 ++- .../HopFrame.Tests.Database.csproj} | 0 .../PermissionValidatorTests.cs | 4 +++- .../Repositories/GroupRepositoryTests.cs | 4 ++-- .../Repositories/PermissionRepositoryTests.cs | 4 ++-- .../Repositories/TokenRepositoryTests.cs | 4 ++-- .../Repositories/UserRepositoryTests.cs | 4 ++-- .../AuthenticationTests.cs | 2 +- .../AuthorizationTests.cs | 4 ++-- .../HopFrame.Tests.Security.csproj} | 0 .../AuthMiddlewareTests.cs | 3 ++- .../AuthServiceTests.cs | 4 ++-- .../Extensions/HttpContextExtensions.cs | 2 +- .../HopFrame.Tests.Web.csproj} | 0 21 files changed, 31 insertions(+), 27 deletions(-) rename tests/{HopFrame.Api.Tests => HopFrame.Tests.Api}/AuthLogicTests.cs (99%) rename tests/{HopFrame.Api.Tests => HopFrame.Tests.Api}/Extensions/HttpContextExtensions.cs (96%) rename tests/{HopFrame.Api.Tests/HopFrame.Api.Tests.csproj => HopFrame.Tests.Api/HopFrame.Tests.Api.csproj} (100%) rename tests/{HopFrame.Database.Tests => HopFrame.Tests.Database}/Data/DatabaseContext.cs (81%) rename tests/{HopFrame.Database.Tests/HopFrame.Database.Tests.csproj => HopFrame.Tests.Database/HopFrame.Tests.Database.csproj} (100%) rename tests/{HopFrame.Database.Tests => HopFrame.Tests.Database}/PermissionValidatorTests.cs (96%) rename tests/{HopFrame.Database.Tests => HopFrame.Tests.Database}/Repositories/GroupRepositoryTests.cs (98%) rename tests/{HopFrame.Database.Tests => HopFrame.Tests.Database}/Repositories/PermissionRepositoryTests.cs (97%) rename tests/{HopFrame.Database.Tests => HopFrame.Tests.Database}/Repositories/TokenRepositoryTests.cs (96%) rename tests/{HopFrame.Database.Tests => HopFrame.Tests.Database}/Repositories/UserRepositoryTests.cs (98%) rename tests/{HopFrame.Security.Tests => HopFrame.Tests.Security}/AuthenticationTests.cs (99%) rename tests/{HopFrame.Security.Tests => HopFrame.Tests.Security}/AuthorizationTests.cs (98%) rename tests/{HopFrame.Security.Tests/HopFrame.Security.Tests.csproj => HopFrame.Tests.Security/HopFrame.Tests.Security.csproj} (100%) rename tests/{HopFrame.Web.Tests => HopFrame.Tests.Web}/AuthMiddlewareTests.cs (98%) rename tests/{HopFrame.Web.Tests => HopFrame.Tests.Web}/AuthServiceTests.cs (99%) rename tests/{HopFrame.Web.Tests => HopFrame.Tests.Web}/Extensions/HttpContextExtensions.cs (96%) rename tests/{HopFrame.Web.Tests/HopFrame.Web.Tests.csproj => HopFrame.Tests.Web/HopFrame.Tests.Web.csproj} (100%) diff --git a/HopFrame.sln b/HopFrame.sln index 195f022..95067c5 100644 --- a/HopFrame.sln +++ b/HopFrame.sln @@ -20,13 +20,13 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Testing", "Testing", "{EEA2 EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{1D98E5DE-CB8B-4C1C-A319-D49AC137441A}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Database.Tests", "tests\HopFrame.Database.Tests\HopFrame.Database.Tests.csproj", "{1CAAC943-B8FE-48DD-9712-92699647DE18}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Tests.Database", "tests\HopFrame.Tests.Database\HopFrame.Tests.Database.csproj", "{1CAAC943-B8FE-48DD-9712-92699647DE18}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Security.Tests", "tests\HopFrame.Security.Tests\HopFrame.Security.Tests.csproj", "{6747753A-6059-48F1-B779-D73765A373A6}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Tests.Security", "tests\HopFrame.Tests.Security\HopFrame.Tests.Security.csproj", "{6747753A-6059-48F1-B779-D73765A373A6}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Api.Tests", "tests\HopFrame.Api.Tests\HopFrame.Api.Tests.csproj", "{25DE1510-47E5-46FF-89A4-B9F99542218E}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Tests.Api", "tests\HopFrame.Tests.Api\HopFrame.Tests.Api.csproj", "{25DE1510-47E5-46FF-89A4-B9F99542218E}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Web.Tests", "tests\HopFrame.Web.Tests\HopFrame.Web.Tests.csproj", "{566C13B9-4ECA-48C4-8D02-FEB6CDF523E6}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Tests.Web", "tests\HopFrame.Tests.Web\HopFrame.Tests.Web.csproj", "{566C13B9-4ECA-48C4-8D02-FEB6CDF523E6}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/src/HopFrame.Api/HopFrame.Api.csproj b/src/HopFrame.Api/HopFrame.Api.csproj index c8b21c4..091e5a1 100644 --- a/src/HopFrame.Api/HopFrame.Api.csproj +++ b/src/HopFrame.Api/HopFrame.Api.csproj @@ -24,7 +24,7 @@ - <_Parameter1>HopFrame.Api.Tests + <_Parameter1>HopFrame.Tests.Api diff --git a/src/HopFrame.Database/HopFrame.Database.csproj b/src/HopFrame.Database/HopFrame.Database.csproj index 621c7b9..cd670af 100644 --- a/src/HopFrame.Database/HopFrame.Database.csproj +++ b/src/HopFrame.Database/HopFrame.Database.csproj @@ -24,7 +24,7 @@ - <_Parameter1>HopFrame.Database.Tests + <_Parameter1>HopFrame.Tests.Database diff --git a/src/HopFrame.Web/HopFrame.Web.csproj b/src/HopFrame.Web/HopFrame.Web.csproj index c368805..1eb0490 100644 --- a/src/HopFrame.Web/HopFrame.Web.csproj +++ b/src/HopFrame.Web/HopFrame.Web.csproj @@ -37,7 +37,7 @@ - <_Parameter1>HopFrame.Web.Tests + <_Parameter1>HopFrame.Tests.Web diff --git a/tests/HopFrame.Api.Tests/AuthLogicTests.cs b/tests/HopFrame.Tests.Api/AuthLogicTests.cs similarity index 99% rename from tests/HopFrame.Api.Tests/AuthLogicTests.cs rename to tests/HopFrame.Tests.Api/AuthLogicTests.cs index ae82b65..ca86b5b 100644 --- a/tests/HopFrame.Api.Tests/AuthLogicTests.cs +++ b/tests/HopFrame.Tests.Api/AuthLogicTests.cs @@ -3,7 +3,7 @@ using System.Security.Claims; using HopFrame.Api.Logic; using HopFrame.Api.Logic.Implementation; using HopFrame.Api.Models; -using HopFrame.Api.Tests.Extensions; +using HopFrame.Tests.Api.Extensions; using HopFrame.Database.Models; using HopFrame.Database.Repositories; using HopFrame.Security.Authentication; @@ -12,7 +12,7 @@ using HopFrame.Security.Models; using Microsoft.AspNetCore.Http; using Moq; -namespace HopFrame.Api.Tests; +namespace HopFrame.Tests.Api; public class AuthLogicTests { diff --git a/tests/HopFrame.Api.Tests/Extensions/HttpContextExtensions.cs b/tests/HopFrame.Tests.Api/Extensions/HttpContextExtensions.cs similarity index 96% rename from tests/HopFrame.Api.Tests/Extensions/HttpContextExtensions.cs rename to tests/HopFrame.Tests.Api/Extensions/HttpContextExtensions.cs index 4ccbb38..8f4de69 100644 --- a/tests/HopFrame.Api.Tests/Extensions/HttpContextExtensions.cs +++ b/tests/HopFrame.Tests.Api/Extensions/HttpContextExtensions.cs @@ -1,7 +1,7 @@ using System.Web; using Microsoft.AspNetCore.Http; -namespace HopFrame.Api.Tests.Extensions; +namespace HopFrame.Tests.Api.Extensions; internal static class HttpContextExtensions { /// Extracts the partial cookie value from the header section. diff --git a/tests/HopFrame.Api.Tests/HopFrame.Api.Tests.csproj b/tests/HopFrame.Tests.Api/HopFrame.Tests.Api.csproj similarity index 100% rename from tests/HopFrame.Api.Tests/HopFrame.Api.Tests.csproj rename to tests/HopFrame.Tests.Api/HopFrame.Tests.Api.csproj diff --git a/tests/HopFrame.Database.Tests/Data/DatabaseContext.cs b/tests/HopFrame.Tests.Database/Data/DatabaseContext.cs similarity index 81% rename from tests/HopFrame.Database.Tests/Data/DatabaseContext.cs rename to tests/HopFrame.Tests.Database/Data/DatabaseContext.cs index 7bc93de..271628f 100644 --- a/tests/HopFrame.Database.Tests/Data/DatabaseContext.cs +++ b/tests/HopFrame.Tests.Database/Data/DatabaseContext.cs @@ -1,6 +1,7 @@ +using HopFrame.Database; using Microsoft.EntityFrameworkCore; -namespace HopFrame.Database.Tests.Data; +namespace HopFrame.Tests.Database.Data; public class DatabaseContext : HopDbContextBase { protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { diff --git a/tests/HopFrame.Database.Tests/HopFrame.Database.Tests.csproj b/tests/HopFrame.Tests.Database/HopFrame.Tests.Database.csproj similarity index 100% rename from tests/HopFrame.Database.Tests/HopFrame.Database.Tests.csproj rename to tests/HopFrame.Tests.Database/HopFrame.Tests.Database.csproj diff --git a/tests/HopFrame.Database.Tests/PermissionValidatorTests.cs b/tests/HopFrame.Tests.Database/PermissionValidatorTests.cs similarity index 96% rename from tests/HopFrame.Database.Tests/PermissionValidatorTests.cs rename to tests/HopFrame.Tests.Database/PermissionValidatorTests.cs index 9d858f8..693f093 100644 --- a/tests/HopFrame.Database.Tests/PermissionValidatorTests.cs +++ b/tests/HopFrame.Tests.Database/PermissionValidatorTests.cs @@ -1,4 +1,6 @@ -namespace HopFrame.Database.Tests; +using HopFrame.Database; + +namespace HopFrame.Tests.Database; public class PermissionValidatorTests { diff --git a/tests/HopFrame.Database.Tests/Repositories/GroupRepositoryTests.cs b/tests/HopFrame.Tests.Database/Repositories/GroupRepositoryTests.cs similarity index 98% rename from tests/HopFrame.Database.Tests/Repositories/GroupRepositoryTests.cs rename to tests/HopFrame.Tests.Database/Repositories/GroupRepositoryTests.cs index 84270e0..e3fd4ff 100644 --- a/tests/HopFrame.Database.Tests/Repositories/GroupRepositoryTests.cs +++ b/tests/HopFrame.Tests.Database/Repositories/GroupRepositoryTests.cs @@ -1,9 +1,9 @@ using HopFrame.Database.Models; using HopFrame.Database.Repositories; using HopFrame.Database.Repositories.Implementation; -using HopFrame.Database.Tests.Data; +using HopFrame.Tests.Database.Data; -namespace HopFrame.Database.Tests.Repositories; +namespace HopFrame.Tests.Database.Repositories; public class GroupRepositoryTests { diff --git a/tests/HopFrame.Database.Tests/Repositories/PermissionRepositoryTests.cs b/tests/HopFrame.Tests.Database/Repositories/PermissionRepositoryTests.cs similarity index 97% rename from tests/HopFrame.Database.Tests/Repositories/PermissionRepositoryTests.cs rename to tests/HopFrame.Tests.Database/Repositories/PermissionRepositoryTests.cs index 52a9ef7..79fff4c 100644 --- a/tests/HopFrame.Database.Tests/Repositories/PermissionRepositoryTests.cs +++ b/tests/HopFrame.Tests.Database/Repositories/PermissionRepositoryTests.cs @@ -1,10 +1,10 @@ using HopFrame.Database.Models; using HopFrame.Database.Repositories; using HopFrame.Database.Repositories.Implementation; -using HopFrame.Database.Tests.Data; +using HopFrame.Tests.Database.Data; using Microsoft.EntityFrameworkCore; -namespace HopFrame.Database.Tests.Repositories; +namespace HopFrame.Tests.Database.Repositories; public class PermissionRepositoryTests { diff --git a/tests/HopFrame.Database.Tests/Repositories/TokenRepositoryTests.cs b/tests/HopFrame.Tests.Database/Repositories/TokenRepositoryTests.cs similarity index 96% rename from tests/HopFrame.Database.Tests/Repositories/TokenRepositoryTests.cs rename to tests/HopFrame.Tests.Database/Repositories/TokenRepositoryTests.cs index 97e382a..83dc770 100644 --- a/tests/HopFrame.Database.Tests/Repositories/TokenRepositoryTests.cs +++ b/tests/HopFrame.Tests.Database/Repositories/TokenRepositoryTests.cs @@ -1,10 +1,10 @@ using HopFrame.Database.Models; using HopFrame.Database.Repositories; using HopFrame.Database.Repositories.Implementation; -using HopFrame.Database.Tests.Data; +using HopFrame.Tests.Database.Data; using Microsoft.EntityFrameworkCore; -namespace HopFrame.Database.Tests.Repositories; +namespace HopFrame.Tests.Database.Repositories; public class TokenRepositoryTests { diff --git a/tests/HopFrame.Database.Tests/Repositories/UserRepositoryTests.cs b/tests/HopFrame.Tests.Database/Repositories/UserRepositoryTests.cs similarity index 98% rename from tests/HopFrame.Database.Tests/Repositories/UserRepositoryTests.cs rename to tests/HopFrame.Tests.Database/Repositories/UserRepositoryTests.cs index 7d8362b..5730064 100644 --- a/tests/HopFrame.Database.Tests/Repositories/UserRepositoryTests.cs +++ b/tests/HopFrame.Tests.Database/Repositories/UserRepositoryTests.cs @@ -1,9 +1,9 @@ using HopFrame.Database.Models; using HopFrame.Database.Repositories; using HopFrame.Database.Repositories.Implementation; -using HopFrame.Database.Tests.Data; +using HopFrame.Tests.Database.Data; -namespace HopFrame.Database.Tests.Repositories; +namespace HopFrame.Tests.Database.Repositories; public class UserRepositoryTests { diff --git a/tests/HopFrame.Security.Tests/AuthenticationTests.cs b/tests/HopFrame.Tests.Security/AuthenticationTests.cs similarity index 99% rename from tests/HopFrame.Security.Tests/AuthenticationTests.cs rename to tests/HopFrame.Tests.Security/AuthenticationTests.cs index 2594cbb..7c80e7d 100644 --- a/tests/HopFrame.Security.Tests/AuthenticationTests.cs +++ b/tests/HopFrame.Tests.Security/AuthenticationTests.cs @@ -8,7 +8,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Moq; -namespace HopFrame.Security.Tests; +namespace HopFrame.Tests.Security; public class AuthenticationTests { diff --git a/tests/HopFrame.Security.Tests/AuthorizationTests.cs b/tests/HopFrame.Tests.Security/AuthorizationTests.cs similarity index 98% rename from tests/HopFrame.Security.Tests/AuthorizationTests.cs rename to tests/HopFrame.Tests.Security/AuthorizationTests.cs index 730dfde..98032fb 100644 --- a/tests/HopFrame.Security.Tests/AuthorizationTests.cs +++ b/tests/HopFrame.Tests.Security/AuthorizationTests.cs @@ -2,14 +2,14 @@ using System.Security.Claims; using HopFrame.Security.Authentication; using HopFrame.Security.Authorization; using HopFrame.Security.Claims; -using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Routing; using Moq; -namespace HopFrame.Security.Tests; +namespace HopFrame.Tests.Security; public class AuthorizationTests { diff --git a/tests/HopFrame.Security.Tests/HopFrame.Security.Tests.csproj b/tests/HopFrame.Tests.Security/HopFrame.Tests.Security.csproj similarity index 100% rename from tests/HopFrame.Security.Tests/HopFrame.Security.Tests.csproj rename to tests/HopFrame.Tests.Security/HopFrame.Tests.Security.csproj diff --git a/tests/HopFrame.Web.Tests/AuthMiddlewareTests.cs b/tests/HopFrame.Tests.Web/AuthMiddlewareTests.cs similarity index 98% rename from tests/HopFrame.Web.Tests/AuthMiddlewareTests.cs rename to tests/HopFrame.Tests.Web/AuthMiddlewareTests.cs index 70d1f8e..d9e136f 100644 --- a/tests/HopFrame.Web.Tests/AuthMiddlewareTests.cs +++ b/tests/HopFrame.Tests.Web/AuthMiddlewareTests.cs @@ -2,11 +2,12 @@ using System.Security.Claims; using HopFrame.Database.Models; using HopFrame.Database.Repositories; using HopFrame.Security.Claims; +using HopFrame.Web; using HopFrame.Web.Services; using Microsoft.AspNetCore.Http; using Moq; -namespace HopFrame.Web.Tests; +namespace HopFrame.Tests.Web; public class AuthMiddlewareTests { private readonly RequestDelegate _delegate = _ => Task.CompletedTask; diff --git a/tests/HopFrame.Web.Tests/AuthServiceTests.cs b/tests/HopFrame.Tests.Web/AuthServiceTests.cs similarity index 99% rename from tests/HopFrame.Web.Tests/AuthServiceTests.cs rename to tests/HopFrame.Tests.Web/AuthServiceTests.cs index 4374148..a5df287 100644 --- a/tests/HopFrame.Web.Tests/AuthServiceTests.cs +++ b/tests/HopFrame.Tests.Web/AuthServiceTests.cs @@ -1,14 +1,14 @@ -using HopFrame.Api.Tests.Extensions; using HopFrame.Database.Models; using HopFrame.Database.Repositories; using HopFrame.Security.Claims; using HopFrame.Security.Models; +using HopFrame.Tests.Web.Extensions; using HopFrame.Web.Services; using HopFrame.Web.Services.Implementation; using Microsoft.AspNetCore.Http; using Moq; -namespace HopFrame.Web.Tests; +namespace HopFrame.Tests.Web; public class AuthServiceTests { private readonly Guid _refreshToken = Guid.NewGuid(); diff --git a/tests/HopFrame.Web.Tests/Extensions/HttpContextExtensions.cs b/tests/HopFrame.Tests.Web/Extensions/HttpContextExtensions.cs similarity index 96% rename from tests/HopFrame.Web.Tests/Extensions/HttpContextExtensions.cs rename to tests/HopFrame.Tests.Web/Extensions/HttpContextExtensions.cs index 4ccbb38..a26537a 100644 --- a/tests/HopFrame.Web.Tests/Extensions/HttpContextExtensions.cs +++ b/tests/HopFrame.Tests.Web/Extensions/HttpContextExtensions.cs @@ -1,7 +1,7 @@ using System.Web; using Microsoft.AspNetCore.Http; -namespace HopFrame.Api.Tests.Extensions; +namespace HopFrame.Tests.Web.Extensions; internal static class HttpContextExtensions { /// Extracts the partial cookie value from the header section. diff --git a/tests/HopFrame.Web.Tests/HopFrame.Web.Tests.csproj b/tests/HopFrame.Tests.Web/HopFrame.Tests.Web.csproj similarity index 100% rename from tests/HopFrame.Web.Tests/HopFrame.Web.Tests.csproj rename to tests/HopFrame.Tests.Web/HopFrame.Tests.Web.csproj From 5f746e0bc13166e9c3d24d739d82df4b7e6519f7 Mon Sep 17 00:00:00 2001 From: Leon Hoppe Date: Tue, 10 Dec 2024 16:55:36 +0100 Subject: [PATCH 10/27] Renamed testing projects --- HopFrame.sln | 4 ++-- .../.gitignore | 0 .../Controllers/TestController.cs | 4 ++-- .../DatabaseContext.cs | 6 +++--- .../HopFrame.Testing.Api.csproj} | 0 .../Models/Address.cs | 2 +- .../Models/Employee.cs | 2 +- .../Program.cs | 2 +- .../Properties/launchSettings.json | 0 .../appsettings.json | 0 .../.gitignore | 0 .../AdminContext.cs | 6 +++--- .../Components/App.razor | 2 +- .../Components/Layout/MainLayout.razor | 0 .../Components/Layout/MainLayout.razor.css | 0 .../Components/Layout/NavMenu.razor | 0 .../Components/Layout/NavMenu.razor.css | 0 .../Components/Pages/Counter.razor | 0 .../Components/Pages/Error.razor | 0 .../Components/Pages/Home.razor | 0 .../Components/Pages/Weather.razor | 0 .../Components/Routes.razor | 0 .../Components/_Imports.razor | 4 ++-- .../DatabaseContext.cs | 6 +++--- .../HopFrame.Testing.Web.csproj} | 0 .../Models/Address.cs | 2 +- .../Models/Employee.cs | 2 +- .../Program.cs | 4 ++-- .../Properties/launchSettings.json | 0 .../Providers/AddressProvider.cs | 4 ++-- .../Providers/EmployeeProvider.cs | 4 ++-- .../appsettings.json | 0 .../wwwroot/app.css | 0 .../wwwroot/favicon.png | Bin 34 files changed, 27 insertions(+), 27 deletions(-) rename testing/{FrontendTest => HopFrame.Testing.Api}/.gitignore (100%) rename testing/{RestApiTest => HopFrame.Testing.Api}/Controllers/TestController.cs (95%) rename testing/{RestApiTest => HopFrame.Testing.Api}/DatabaseContext.cs (80%) rename testing/{RestApiTest/RestApiTest.csproj => HopFrame.Testing.Api/HopFrame.Testing.Api.csproj} (100%) rename testing/{FrontendTest => HopFrame.Testing.Api}/Models/Address.cs (92%) rename testing/{RestApiTest => HopFrame.Testing.Api}/Models/Employee.cs (79%) rename testing/{RestApiTest => HopFrame.Testing.Api}/Program.cs (98%) rename testing/{RestApiTest => HopFrame.Testing.Api}/Properties/launchSettings.json (100%) rename testing/{FrontendTest => HopFrame.Testing.Api}/appsettings.json (100%) rename testing/{RestApiTest => HopFrame.Testing.Web}/.gitignore (100%) rename testing/{FrontendTest => HopFrame.Testing.Web}/AdminContext.cs (90%) rename testing/{FrontendTest => HopFrame.Testing.Web}/Components/App.razor (86%) rename testing/{FrontendTest => HopFrame.Testing.Web}/Components/Layout/MainLayout.razor (100%) rename testing/{FrontendTest => HopFrame.Testing.Web}/Components/Layout/MainLayout.razor.css (100%) rename testing/{FrontendTest => HopFrame.Testing.Web}/Components/Layout/NavMenu.razor (100%) rename testing/{FrontendTest => HopFrame.Testing.Web}/Components/Layout/NavMenu.razor.css (100%) rename testing/{FrontendTest => HopFrame.Testing.Web}/Components/Pages/Counter.razor (100%) rename testing/{FrontendTest => HopFrame.Testing.Web}/Components/Pages/Error.razor (100%) rename testing/{FrontendTest => HopFrame.Testing.Web}/Components/Pages/Home.razor (100%) rename testing/{FrontendTest => HopFrame.Testing.Web}/Components/Pages/Weather.razor (100%) rename testing/{FrontendTest => HopFrame.Testing.Web}/Components/Routes.razor (100%) rename testing/{FrontendTest => HopFrame.Testing.Web}/Components/_Imports.razor (83%) rename testing/{FrontendTest => HopFrame.Testing.Web}/DatabaseContext.cs (80%) rename testing/{FrontendTest/FrontendTest.csproj => HopFrame.Testing.Web/HopFrame.Testing.Web.csproj} (100%) rename testing/{RestApiTest => HopFrame.Testing.Web}/Models/Address.cs (92%) rename testing/{FrontendTest => HopFrame.Testing.Web}/Models/Employee.cs (79%) rename testing/{FrontendTest => HopFrame.Testing.Web}/Program.cs (93%) rename testing/{FrontendTest => HopFrame.Testing.Web}/Properties/launchSettings.json (100%) rename testing/{FrontendTest => HopFrame.Testing.Web}/Providers/AddressProvider.cs (91%) rename testing/{FrontendTest => HopFrame.Testing.Web}/Providers/EmployeeProvider.cs (91%) rename testing/{RestApiTest => HopFrame.Testing.Web}/appsettings.json (100%) rename testing/{FrontendTest => HopFrame.Testing.Web}/wwwroot/app.css (100%) rename testing/{FrontendTest => HopFrame.Testing.Web}/wwwroot/favicon.png (100%) diff --git a/HopFrame.sln b/HopFrame.sln index 95067c5..06d8283 100644 --- a/HopFrame.sln +++ b/HopFrame.sln @@ -2,7 +2,7 @@ Microsoft Visual Studio Solution File, Format Version 12.00 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Database", "src\HopFrame.Database\HopFrame.Database.csproj", "{003120AE-F38B-4632-8497-BE4505189627}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RestApiTest", "testing\RestApiTest\RestApiTest.csproj", "{921159CE-AF75-44C3-A3F9-6B9B1A4E85CF}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Testing.Api", "testing\HopFrame.Testing.Api\HopFrame.Testing.Api.csproj", "{921159CE-AF75-44C3-A3F9-6B9B1A4E85CF}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Security", "src\HopFrame.Security\HopFrame.Security.csproj", "{7F82E1C6-4A42-4337-9E03-2EE6429D004F}" EndProject @@ -10,7 +10,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Api", "src\HopFram EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Web", "src\HopFrame.Web\HopFrame.Web.csproj", "{3BE585BC-13A5-4BE4-A806-E9EC2D825956}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FrontendTest", "testing\FrontendTest\FrontendTest.csproj", "{8F983A37-63CF-48D5-988D-58B78EF8AECD}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Testing.Web", "testing\HopFrame.Testing.Web\HopFrame.Testing.Web.csproj", "{8F983A37-63CF-48D5-988D-58B78EF8AECD}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Web.Admin", "src\HopFrame.Web.Admin\HopFrame.Web.Admin.csproj", "{02D9F10A-664A-4EF7-BF19-310C26FF4DEB}" EndProject diff --git a/testing/FrontendTest/.gitignore b/testing/HopFrame.Testing.Api/.gitignore similarity index 100% rename from testing/FrontendTest/.gitignore rename to testing/HopFrame.Testing.Api/.gitignore diff --git a/testing/RestApiTest/Controllers/TestController.cs b/testing/HopFrame.Testing.Api/Controllers/TestController.cs similarity index 95% rename from testing/RestApiTest/Controllers/TestController.cs rename to testing/HopFrame.Testing.Api/Controllers/TestController.cs index 092784f..fb39666 100644 --- a/testing/RestApiTest/Controllers/TestController.cs +++ b/testing/HopFrame.Testing.Api/Controllers/TestController.cs @@ -2,11 +2,11 @@ using HopFrame.Api.Logic; using HopFrame.Database.Models; using HopFrame.Security.Authorization; using HopFrame.Security.Claims; +using HopFrame.Testing.Api.Models; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; -using RestApiTest.Models; -namespace RestApiTest.Controllers; +namespace HopFrame.Testing.Api.Controllers; [ApiController] [Route("test")] diff --git a/testing/RestApiTest/DatabaseContext.cs b/testing/HopFrame.Testing.Api/DatabaseContext.cs similarity index 80% rename from testing/RestApiTest/DatabaseContext.cs rename to testing/HopFrame.Testing.Api/DatabaseContext.cs index 42ae5d1..4c707f4 100644 --- a/testing/RestApiTest/DatabaseContext.cs +++ b/testing/HopFrame.Testing.Api/DatabaseContext.cs @@ -1,8 +1,8 @@ using HopFrame.Database; +using HopFrame.Testing.Api.Models; using Microsoft.EntityFrameworkCore; -using RestApiTest.Models; -namespace RestApiTest; +namespace HopFrame.Testing.Api; public class DatabaseContext : HopDbContextBase { @@ -12,7 +12,7 @@ public class DatabaseContext : HopDbContextBase { protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { base.OnConfiguring(optionsBuilder); - optionsBuilder.UseSqlite(@"Data Source=C:\Users\leon\Documents\Projekte\HopFrame\testing\RestApiTest\bin\Debug\net8.0\test.db;Mode=ReadWrite;"); + optionsBuilder.UseSqlite(@"Data Source=C:\Users\leon\Documents\Projekte\HopFrame\testing\HopFrame.Testing.Api\bin\Debug\net8.0\test.db;Mode=ReadWrite;"); } protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/testing/RestApiTest/RestApiTest.csproj b/testing/HopFrame.Testing.Api/HopFrame.Testing.Api.csproj similarity index 100% rename from testing/RestApiTest/RestApiTest.csproj rename to testing/HopFrame.Testing.Api/HopFrame.Testing.Api.csproj diff --git a/testing/FrontendTest/Models/Address.cs b/testing/HopFrame.Testing.Api/Models/Address.cs similarity index 92% rename from testing/FrontendTest/Models/Address.cs rename to testing/HopFrame.Testing.Api/Models/Address.cs index 386114d..5688ad1 100644 --- a/testing/FrontendTest/Models/Address.cs +++ b/testing/HopFrame.Testing.Api/Models/Address.cs @@ -2,7 +2,7 @@ using System.ComponentModel.DataAnnotations.Schema; using System.Text.Json.Serialization; using Microsoft.AspNetCore.Mvc.ModelBinding; -namespace RestApiTest.Models; +namespace HopFrame.Testing.Api.Models; public class Address { [ForeignKey("Employee")] diff --git a/testing/RestApiTest/Models/Employee.cs b/testing/HopFrame.Testing.Api/Models/Employee.cs similarity index 79% rename from testing/RestApiTest/Models/Employee.cs rename to testing/HopFrame.Testing.Api/Models/Employee.cs index 6f70edc..f7e3e27 100644 --- a/testing/RestApiTest/Models/Employee.cs +++ b/testing/HopFrame.Testing.Api/Models/Employee.cs @@ -1,4 +1,4 @@ -namespace RestApiTest.Models; +namespace HopFrame.Testing.Api.Models; public class Employee { public int EmployeeId { get; set; } diff --git a/testing/RestApiTest/Program.cs b/testing/HopFrame.Testing.Api/Program.cs similarity index 98% rename from testing/RestApiTest/Program.cs rename to testing/HopFrame.Testing.Api/Program.cs index bfcbc38..6651ecd 100644 --- a/testing/RestApiTest/Program.cs +++ b/testing/HopFrame.Testing.Api/Program.cs @@ -1,4 +1,4 @@ -using RestApiTest; +using HopFrame.Testing.Api; using HopFrame.Api.Extensions; using Microsoft.OpenApi.Models; diff --git a/testing/RestApiTest/Properties/launchSettings.json b/testing/HopFrame.Testing.Api/Properties/launchSettings.json similarity index 100% rename from testing/RestApiTest/Properties/launchSettings.json rename to testing/HopFrame.Testing.Api/Properties/launchSettings.json diff --git a/testing/FrontendTest/appsettings.json b/testing/HopFrame.Testing.Api/appsettings.json similarity index 100% rename from testing/FrontendTest/appsettings.json rename to testing/HopFrame.Testing.Api/appsettings.json diff --git a/testing/RestApiTest/.gitignore b/testing/HopFrame.Testing.Web/.gitignore similarity index 100% rename from testing/RestApiTest/.gitignore rename to testing/HopFrame.Testing.Web/.gitignore diff --git a/testing/FrontendTest/AdminContext.cs b/testing/HopFrame.Testing.Web/AdminContext.cs similarity index 90% rename from testing/FrontendTest/AdminContext.cs rename to testing/HopFrame.Testing.Web/AdminContext.cs index 2eaff8d..9d33628 100644 --- a/testing/FrontendTest/AdminContext.cs +++ b/testing/HopFrame.Testing.Web/AdminContext.cs @@ -1,10 +1,10 @@ -using FrontendTest.Providers; using HopFrame.Web.Admin; using HopFrame.Web.Admin.Generators; using HopFrame.Web.Admin.Models; -using RestApiTest.Models; +using HopFrame.Testing.Api.Models; +using HopFrame.Testing.Web.Providers; -namespace FrontendTest; +namespace HopFrame.Testing.Web; public class AdminContext : AdminPagesContext { diff --git a/testing/FrontendTest/Components/App.razor b/testing/HopFrame.Testing.Web/Components/App.razor similarity index 86% rename from testing/FrontendTest/Components/App.razor rename to testing/HopFrame.Testing.Web/Components/App.razor index 35c8065..b3faba0 100644 --- a/testing/FrontendTest/Components/App.razor +++ b/testing/HopFrame.Testing.Web/Components/App.razor @@ -7,7 +7,7 @@ - + diff --git a/testing/FrontendTest/Components/Layout/MainLayout.razor b/testing/HopFrame.Testing.Web/Components/Layout/MainLayout.razor similarity index 100% rename from testing/FrontendTest/Components/Layout/MainLayout.razor rename to testing/HopFrame.Testing.Web/Components/Layout/MainLayout.razor diff --git a/testing/FrontendTest/Components/Layout/MainLayout.razor.css b/testing/HopFrame.Testing.Web/Components/Layout/MainLayout.razor.css similarity index 100% rename from testing/FrontendTest/Components/Layout/MainLayout.razor.css rename to testing/HopFrame.Testing.Web/Components/Layout/MainLayout.razor.css diff --git a/testing/FrontendTest/Components/Layout/NavMenu.razor b/testing/HopFrame.Testing.Web/Components/Layout/NavMenu.razor similarity index 100% rename from testing/FrontendTest/Components/Layout/NavMenu.razor rename to testing/HopFrame.Testing.Web/Components/Layout/NavMenu.razor diff --git a/testing/FrontendTest/Components/Layout/NavMenu.razor.css b/testing/HopFrame.Testing.Web/Components/Layout/NavMenu.razor.css similarity index 100% rename from testing/FrontendTest/Components/Layout/NavMenu.razor.css rename to testing/HopFrame.Testing.Web/Components/Layout/NavMenu.razor.css diff --git a/testing/FrontendTest/Components/Pages/Counter.razor b/testing/HopFrame.Testing.Web/Components/Pages/Counter.razor similarity index 100% rename from testing/FrontendTest/Components/Pages/Counter.razor rename to testing/HopFrame.Testing.Web/Components/Pages/Counter.razor diff --git a/testing/FrontendTest/Components/Pages/Error.razor b/testing/HopFrame.Testing.Web/Components/Pages/Error.razor similarity index 100% rename from testing/FrontendTest/Components/Pages/Error.razor rename to testing/HopFrame.Testing.Web/Components/Pages/Error.razor diff --git a/testing/FrontendTest/Components/Pages/Home.razor b/testing/HopFrame.Testing.Web/Components/Pages/Home.razor similarity index 100% rename from testing/FrontendTest/Components/Pages/Home.razor rename to testing/HopFrame.Testing.Web/Components/Pages/Home.razor diff --git a/testing/FrontendTest/Components/Pages/Weather.razor b/testing/HopFrame.Testing.Web/Components/Pages/Weather.razor similarity index 100% rename from testing/FrontendTest/Components/Pages/Weather.razor rename to testing/HopFrame.Testing.Web/Components/Pages/Weather.razor diff --git a/testing/FrontendTest/Components/Routes.razor b/testing/HopFrame.Testing.Web/Components/Routes.razor similarity index 100% rename from testing/FrontendTest/Components/Routes.razor rename to testing/HopFrame.Testing.Web/Components/Routes.razor diff --git a/testing/FrontendTest/Components/_Imports.razor b/testing/HopFrame.Testing.Web/Components/_Imports.razor similarity index 83% rename from testing/FrontendTest/Components/_Imports.razor rename to testing/HopFrame.Testing.Web/Components/_Imports.razor index b17e0c0..f7abb33 100644 --- a/testing/FrontendTest/Components/_Imports.razor +++ b/testing/HopFrame.Testing.Web/Components/_Imports.razor @@ -6,5 +6,5 @@ @using static Microsoft.AspNetCore.Components.Web.RenderMode @using Microsoft.AspNetCore.Components.Web.Virtualization @using Microsoft.JSInterop -@using FrontendTest -@using FrontendTest.Components \ No newline at end of file +@using HopFrame.Testing.Web +@using HopFrame.Testing.Web.Components \ No newline at end of file diff --git a/testing/FrontendTest/DatabaseContext.cs b/testing/HopFrame.Testing.Web/DatabaseContext.cs similarity index 80% rename from testing/FrontendTest/DatabaseContext.cs rename to testing/HopFrame.Testing.Web/DatabaseContext.cs index c1d3c7b..bd12346 100644 --- a/testing/FrontendTest/DatabaseContext.cs +++ b/testing/HopFrame.Testing.Web/DatabaseContext.cs @@ -1,8 +1,8 @@ using HopFrame.Database; using Microsoft.EntityFrameworkCore; -using RestApiTest.Models; +using HopFrame.Testing.Api.Models; -namespace FrontendTest; +namespace HopFrame.Testing.Web; public class DatabaseContext : HopDbContextBase { public DbSet Employees { get; set; } @@ -11,7 +11,7 @@ public class DatabaseContext : HopDbContextBase { protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { base.OnConfiguring(optionsBuilder); - optionsBuilder.UseSqlite(@"Data Source=C:\Users\leon\Documents\Projekte\HopFrame\testing\RestApiTest\bin\Debug\net8.0\test.db;Mode=ReadWrite;"); + optionsBuilder.UseSqlite(@"Data Source=C:\Users\leon\Documents\Projekte\HopFrame\testing\HopFrame.Testing.Api\bin\Debug\net8.0\test.db;Mode=ReadWrite;"); } protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/testing/FrontendTest/FrontendTest.csproj b/testing/HopFrame.Testing.Web/HopFrame.Testing.Web.csproj similarity index 100% rename from testing/FrontendTest/FrontendTest.csproj rename to testing/HopFrame.Testing.Web/HopFrame.Testing.Web.csproj diff --git a/testing/RestApiTest/Models/Address.cs b/testing/HopFrame.Testing.Web/Models/Address.cs similarity index 92% rename from testing/RestApiTest/Models/Address.cs rename to testing/HopFrame.Testing.Web/Models/Address.cs index 386114d..5688ad1 100644 --- a/testing/RestApiTest/Models/Address.cs +++ b/testing/HopFrame.Testing.Web/Models/Address.cs @@ -2,7 +2,7 @@ using System.ComponentModel.DataAnnotations.Schema; using System.Text.Json.Serialization; using Microsoft.AspNetCore.Mvc.ModelBinding; -namespace RestApiTest.Models; +namespace HopFrame.Testing.Api.Models; public class Address { [ForeignKey("Employee")] diff --git a/testing/FrontendTest/Models/Employee.cs b/testing/HopFrame.Testing.Web/Models/Employee.cs similarity index 79% rename from testing/FrontendTest/Models/Employee.cs rename to testing/HopFrame.Testing.Web/Models/Employee.cs index 6f70edc..f7e3e27 100644 --- a/testing/FrontendTest/Models/Employee.cs +++ b/testing/HopFrame.Testing.Web/Models/Employee.cs @@ -1,4 +1,4 @@ -namespace RestApiTest.Models; +namespace HopFrame.Testing.Api.Models; public class Employee { public int EmployeeId { get; set; } diff --git a/testing/FrontendTest/Program.cs b/testing/HopFrame.Testing.Web/Program.cs similarity index 93% rename from testing/FrontendTest/Program.cs rename to testing/HopFrame.Testing.Web/Program.cs index 7547722..7957fff 100644 --- a/testing/FrontendTest/Program.cs +++ b/testing/HopFrame.Testing.Web/Program.cs @@ -1,5 +1,5 @@ -using FrontendTest; -using FrontendTest.Components; +using HopFrame.Testing.Web; +using HopFrame.Testing.Web.Components; using HopFrame.Web; using HopFrame.Web.Admin; diff --git a/testing/FrontendTest/Properties/launchSettings.json b/testing/HopFrame.Testing.Web/Properties/launchSettings.json similarity index 100% rename from testing/FrontendTest/Properties/launchSettings.json rename to testing/HopFrame.Testing.Web/Properties/launchSettings.json diff --git a/testing/FrontendTest/Providers/AddressProvider.cs b/testing/HopFrame.Testing.Web/Providers/AddressProvider.cs similarity index 91% rename from testing/FrontendTest/Providers/AddressProvider.cs rename to testing/HopFrame.Testing.Web/Providers/AddressProvider.cs index de5f13f..6ed1d6f 100644 --- a/testing/FrontendTest/Providers/AddressProvider.cs +++ b/testing/HopFrame.Testing.Web/Providers/AddressProvider.cs @@ -1,8 +1,8 @@ using HopFrame.Web.Admin; using Microsoft.EntityFrameworkCore; -using RestApiTest.Models; +using HopFrame.Testing.Api.Models; -namespace FrontendTest.Providers; +namespace HopFrame.Testing.Web.Providers; public class AddressProvider(DatabaseContext context) : ModelProvider
{ diff --git a/testing/FrontendTest/Providers/EmployeeProvider.cs b/testing/HopFrame.Testing.Web/Providers/EmployeeProvider.cs similarity index 91% rename from testing/FrontendTest/Providers/EmployeeProvider.cs rename to testing/HopFrame.Testing.Web/Providers/EmployeeProvider.cs index 89f7b84..9078eea 100644 --- a/testing/FrontendTest/Providers/EmployeeProvider.cs +++ b/testing/HopFrame.Testing.Web/Providers/EmployeeProvider.cs @@ -1,8 +1,8 @@ using HopFrame.Web.Admin; using Microsoft.EntityFrameworkCore; -using RestApiTest.Models; +using HopFrame.Testing.Api.Models; -namespace FrontendTest.Providers; +namespace HopFrame.Testing.Web.Providers; public class EmployeeProvider(DatabaseContext context) : ModelProvider { diff --git a/testing/RestApiTest/appsettings.json b/testing/HopFrame.Testing.Web/appsettings.json similarity index 100% rename from testing/RestApiTest/appsettings.json rename to testing/HopFrame.Testing.Web/appsettings.json diff --git a/testing/FrontendTest/wwwroot/app.css b/testing/HopFrame.Testing.Web/wwwroot/app.css similarity index 100% rename from testing/FrontendTest/wwwroot/app.css rename to testing/HopFrame.Testing.Web/wwwroot/app.css diff --git a/testing/FrontendTest/wwwroot/favicon.png b/testing/HopFrame.Testing.Web/wwwroot/favicon.png similarity index 100% rename from testing/FrontendTest/wwwroot/favicon.png rename to testing/HopFrame.Testing.Web/wwwroot/favicon.png From 7c835ea49b2d9532a22c5344406b8b623d1f8e07 Mon Sep 17 00:00:00 2001 From: Leon Hoppe Date: Wed, 11 Dec 2024 21:29:03 +0100 Subject: [PATCH 11/27] Started working on UnitTests for frontend --- HopFrame.sln.DotSettings.user | 17 +++ .../Pages/Administration/AdminLogin.razor | 2 +- .../HopFrame.Tests.Web.csproj | 1 + .../Pages/AdminLoginTests.cs | 123 ++++++++++++++++ .../Pages/AuthorizedViewTests.cs | 133 ++++++++++++++++++ 5 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 tests/HopFrame.Tests.Web/Pages/AdminLoginTests.cs create mode 100644 tests/HopFrame.Tests.Web/Pages/AuthorizedViewTests.cs diff --git a/HopFrame.sln.DotSettings.user b/HopFrame.sln.DotSettings.user index ea6ada4..1e30ef8 100644 --- a/HopFrame.sln.DotSettings.user +++ b/HopFrame.sln.DotSettings.user @@ -36,6 +36,23 @@ + + + + + + + + + + + + + + + + + diff --git a/src/HopFrame.Web/Pages/Administration/AdminLogin.razor b/src/HopFrame.Web/Pages/Administration/AdminLogin.razor index 1d9a61e..8e0f1e1 100644 --- a/src/HopFrame.Web/Pages/Administration/AdminLogin.razor +++ b/src/HopFrame.Web/Pages/Administration/AdminLogin.razor @@ -43,7 +43,7 @@ private UserLogin UserLogin { get; set; } [SupplyParameterFromQuery(Name = "redirect")] - private string RedirectAfter { get; set; } + public string RedirectAfter { get; set; } private const string DefaultRedirect = "/administration"; diff --git a/tests/HopFrame.Tests.Web/HopFrame.Tests.Web.csproj b/tests/HopFrame.Tests.Web/HopFrame.Tests.Web.csproj index 062ade0..b6e4aa4 100644 --- a/tests/HopFrame.Tests.Web/HopFrame.Tests.Web.csproj +++ b/tests/HopFrame.Tests.Web/HopFrame.Tests.Web.csproj @@ -10,6 +10,7 @@ + diff --git a/tests/HopFrame.Tests.Web/Pages/AdminLoginTests.cs b/tests/HopFrame.Tests.Web/Pages/AdminLoginTests.cs new file mode 100644 index 0000000..0e2909a --- /dev/null +++ b/tests/HopFrame.Tests.Web/Pages/AdminLoginTests.cs @@ -0,0 +1,123 @@ +using BlazorStrap; +using Bunit; +using Bunit.TestDoubles; +using CurrieTechnologies.Razor.SweetAlert2; +using HopFrame.Security.Models; +using HopFrame.Web.Pages.Administration; +using HopFrame.Web.Services; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace HopFrame.Tests.Web.Pages; + +public class AdminLoginTests : TestContext { + + private (IRenderedComponent, NavigationManager) SetupEnvironment(bool correctCredentials = true) { + var auth = new Mock(); + auth + .Setup(a => a.Login(It.IsAny())) + .ReturnsAsync(correctCredentials); + + Services.AddSweetAlert2(); + Services.AddBlazorStrap(); + Services.AddSingleton(auth.Object); + var navigator = Services.GetRequiredService(); + + var component = RenderComponent(); + return (component, navigator); + } + + [Fact] + public void Login_Has_RequiredFields() { + // Arrange + var (component, _) = SetupEnvironment(); + + // Act + var inputs = component.FindAll("input"); + var buttons = component.FindAll("button"); + var form = component.FindAll("form"); + + // Assert + Assert.Equal(2, inputs.Count); + Assert.Single(buttons); + Assert.Single(form); + Assert.Equal("submit", buttons[0].Attributes.GetNamedItem("type")?.Value); + + foreach (var input in inputs) { + var attribute = input.Attributes.GetNamedItem("required"); + Assert.NotNull(attribute); + Assert.NotEqual("false", attribute?.Value); + } + } + + [Fact] + public void Login_With_CorrectCredentials_Should_Redirect() { + // Arrange + var (component, nav) = SetupEnvironment(); + var email = component.Find("""input[type="email"]"""); + var password = component.Find("""input[type="password"]"""); + var submit = component.Find("button"); + + // Act + email.Change("test@example.com"); + password.Change("1234567890"); + submit.Click(); + + // Assert + Assert.EndsWith("/administration", nav.Uri); + } + + [Fact] + public void Login_With_CorrectCredentials_And_CustomRedirect_Should_Redirect() { + // Arrange + var (component, nav) = SetupEnvironment(); + var email = component.Find("""input[type="email"]"""); + var password = component.Find("""input[type="password"]"""); + var submit = component.Find("button"); + + component.Instance.RedirectAfter = "testRedirect"; + + // Act + email.Change("test@example.com"); + password.Change("1234567890"); + submit.Click(); + + // Assert + Assert.EndsWith("/administration/testRedirect", nav.Uri); + } + + [Fact] + public void Login_With_IncorrectCredentials_Should_Fail() { + // Arrange + var (component, nav) = SetupEnvironment(false); + var email = component.Find("""input[type="email"]"""); + var password = component.Find("""input[type="password"]"""); + var submit = component.Find("button"); + + // Act + email.Change("test@example.com"); + password.Change("1234567890"); + submit.Click(); + + // Assert + Assert.False(nav.Uri.EndsWith("/administration")); + } + + [Fact] + public void Login_With_IncorrectCredentials_DisplaysError() { + // Arrange + var (component, _) = SetupEnvironment(false); + var email = component.Find("""input[type="email"]"""); + var password = component.Find("""input[type="password"]"""); + var submit = component.Find("button"); + + // Act + email.Change("test@example.com"); + password.Change("1234567890"); + submit.Click(); + + // Assert + Assert.Contains("Email or password does not match any account!", component.Markup); + } +} \ No newline at end of file diff --git a/tests/HopFrame.Tests.Web/Pages/AuthorizedViewTests.cs b/tests/HopFrame.Tests.Web/Pages/AuthorizedViewTests.cs new file mode 100644 index 0000000..92b9611 --- /dev/null +++ b/tests/HopFrame.Tests.Web/Pages/AuthorizedViewTests.cs @@ -0,0 +1,133 @@ +using System.Security.Claims; +using Bunit; +using Bunit.TestDoubles; +using HopFrame.Security.Authentication; +using HopFrame.Security.Claims; +using HopFrame.Web.Components; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace HopFrame.Tests.Web.Pages; + +public class AuthorizedViewTests : TestContext { + private readonly string _testRedirect = "testRedirect"; + private readonly string _testPermission = "test.permission"; + private readonly string _innerHtml = "

Inner Render

"; + + public NavigationManager SetupEnvironment(bool authenticated = true, params string[] userPermissions) { + var auth = new Mock(); + auth + .Setup(a => a.IsAuthenticated) + .Returns(authenticated); + + var context = new DefaultHttpContext(); + var claims = userPermissions?.Select(perm => new Claim(HopFrameClaimTypes.Permission, perm)).ToList(); + context.User.AddIdentity(new ClaimsIdentity(claims, HopFrameAuthentication.SchemeName)); + var accessor = new Mock(); + accessor + .Setup(a => a.HttpContext) + .Returns(context); + + Services.AddSingleton(auth.Object); + Services.AddSingleton(accessor.Object); + return Services.GetRequiredService(); + } + + [Fact] + public void AuthorizedView_With_NoValidLogin_And_Redirection_Should_Redirect() { + // Arrange + var navigator = SetupEnvironment(false); + + // Act + RenderComponent(parameters => parameters + .Add(a => a.RedirectIfUnauthorized, _testRedirect)); + + // Assert + Assert.EndsWith(_testRedirect, navigator.Uri); + } + + [Fact] + public void AuthorizedView_With_NoPermissions_And_Redirection_Should_Redirect() { + // Arrange + var navigator = SetupEnvironment(); + + // Act + RenderComponent(parameters => parameters + .Add(a => a.RedirectIfUnauthorized, _testRedirect) + .Add(a => a.Permission, _testPermission)); + + // Assert + Assert.EndsWith(_testRedirect, navigator.Uri); + } + + [Fact] + public void AuthorizedView_With_FewPermissions_And_Redirection_Should_Redirect() { + // Arrange + var navigator = SetupEnvironment(true, "other.permission"); + + // Act + RenderComponent(parameters => parameters + .Add(a => a.RedirectIfUnauthorized, _testRedirect) + .Add(a => a.Permissions, [_testPermission, "other.permission"])); + + // Assert + Assert.EndsWith(_testRedirect, navigator.Uri); + } + + [Fact] + public void AuthorizedView_With_Permissions_And_Redirection_Should_NotRedirect() { + // Arrange + var navigator = SetupEnvironment(true, _testPermission); + + // Act + RenderComponent(parameters => parameters + .Add(a => a.RedirectIfUnauthorized, _testRedirect) + .Add(a => a.Permission, _testPermission)); + + // Assert + Assert.False(navigator.Uri.EndsWith(_testRedirect)); + } + + [Fact] + public void AuthorizedView_With_AllPermissions_And_Redirection_Should_NotRedirect() { + // Arrange + var navigator = SetupEnvironment(true, _testPermission, "other.permission"); + + // Act + RenderComponent(parameters => parameters + .Add(a => a.RedirectIfUnauthorized, _testRedirect) + .Add(a => a.Permissions, [_testPermission, "other.permission"])); + + // Assert + Assert.False(navigator.Uri.EndsWith(_testRedirect)); + } + + [Fact] + public void AuthorizedView_With_ChildComponent_And_ValidLogin_Should_DisplayChildren() { + // Arrange + SetupEnvironment(); + + // Act + var component = RenderComponent(parameters => parameters + .AddChildContent(_innerHtml)); + + // Assert + Assert.Contains(_innerHtml, component.Markup); + } + + [Fact] + public void AuthorizedView_With_ChildComponent_And_InvalidLogin_Should_NotDisplayChildren() { + // Arrange + SetupEnvironment(false); + + // Act + var component = RenderComponent(parameters => parameters + .AddChildContent(_innerHtml)); + + // Assert + Assert.DoesNotContain(_innerHtml, component.Markup); + } + +} \ No newline at end of file From c4ee8bb1e0f3173d234870b5c6c686f26a0a6341 Mon Sep 17 00:00:00 2001 From: Leon Hoppe Date: Sat, 21 Dec 2024 13:10:46 +0100 Subject: [PATCH 12/27] Fixed mistake corrected in v2.0.1 --- src/HopFrame.Web/Pages/Administration/AdminPageList.razor | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/HopFrame.Web/Pages/Administration/AdminPageList.razor b/src/HopFrame.Web/Pages/Administration/AdminPageList.razor index 163f66f..bf4c17d 100644 --- a/src/HopFrame.Web/Pages/Administration/AdminPageList.razor +++ b/src/HopFrame.Web/Pages/Administration/AdminPageList.razor @@ -33,7 +33,7 @@ - + Add Entry From 88c8fe612d69367bbdfe6d24f0e90826d5ab660d Mon Sep 17 00:00:00 2001 From: Leon Hoppe Date: Sat, 21 Dec 2024 14:04:49 +0100 Subject: [PATCH 13/27] Added configuration wrappers, authentication options and authentication documentation --- docs/authentication.md | 43 +++++++++++++++++++ .../Extensions/ServiceCollectionExtensions.cs | 11 +++-- .../Logic/Implementation/AuthLogic.cs | 15 ++++--- .../Authentication/HopFrameAuthentication.cs | 10 ++--- .../HopFrameAuthenticationExtensions.cs | 9 ++-- .../HopFrameAuthenticationOptions.cs | 20 +++++++++ .../Options/OptionsFromConfiguration.cs | 5 +++ .../OptionsFromConfigurationExtensions.cs | 19 ++++++++ .../ServiceCollectionExtensions.cs | 5 ++- .../Services/Implementation/AuthService.cs | 18 ++++---- testing/HopFrame.Testing.Api/Program.cs | 2 +- testing/HopFrame.Testing.Web/Program.cs | 2 +- tests/HopFrame.Tests.Api/AuthLogicTests.cs | 3 +- .../AuthenticationTests.cs | 2 +- tests/HopFrame.Tests.Web/AuthServiceTests.cs | 4 +- 15 files changed, 134 insertions(+), 34 deletions(-) create mode 100644 docs/authentication.md create mode 100644 src/HopFrame.Security/Authentication/HopFrameAuthenticationOptions.cs create mode 100644 src/HopFrame.Security/Options/OptionsFromConfiguration.cs create mode 100644 src/HopFrame.Security/Options/OptionsFromConfigurationExtensions.cs diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 0000000..e1a0182 --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,43 @@ +# HopFrame Authentication + +HopFrame uses a token system with a short term access token and a long term refresh token for authenticating users. +These tokens are usually provided to the endpoints of the API / Blazor Pages through Cookies: + +| Cookie key | Cookie value sample | Description | +|--------------------------------|----------------------------------------|-----------------------------| +| HopFrame.Security.RefreshToken | `42047983-914d-418b-841a-4382614231be` | The long term refresh token | +| HopFrame.Security.AccessToken | `d39c9432-0831-42df-8844-5e2b70f03eda` | The short term access token | + +The advantage of these cookies is that they are automatically set by the backend and delete themselves, when they are +no longer valid. + +The access token can also be delivered through a header called `HopFrame.Authentication` or `Token`. +It can also be delivered through a query parameter called `token`. This simplifies requests for images for example +because you can directly specify the url in the img tag in html. + +## Authentication configuration + +You can also configure the time span that the tokens are valid using the `appsettings.json` or environment variables +by configuring your configuration to load these. +>**Hint**: Configuring your application to use environment variables works by simply adding +> `builder.Configuration.AddEnvironmentVariables();` to your startup configuration before you add the +> custom configurations / HopFrame services. + +### Example + +You can specify `Seconds`, `Minutes`, `Hours` and `Days` for either of the two token types. +These get combined to a single time span. + +```json + "HopFrame": { + "Authentication": { + "AccessToken": { + "Minutes": 30 + }, + "RefreshToken": { + "Days": 10, + "Hours": 5 + } + } + } +``` diff --git a/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs b/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs index 618a437..51eacde 100644 --- a/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs +++ b/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs @@ -4,6 +4,7 @@ using HopFrame.Api.Logic.Implementation; using HopFrame.Database; using HopFrame.Security.Authentication; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -15,23 +16,25 @@ public static class ServiceCollectionExtensions { /// Adds all HopFrame endpoints and services to the application /// /// The service provider to add the services to + /// The configuration used to configure HopFrame authentication /// The data source for all HopFrame entities - public static void AddHopFrame(this IServiceCollection services) where TDbContext : HopDbContextBase { + public static void AddHopFrame(this IServiceCollection services, ConfigurationManager configuration) where TDbContext : HopDbContextBase { services.AddMvcCore().UseSpecificControllers(typeof(SecurityController)); - AddHopFrameNoEndpoints(services); + AddHopFrameNoEndpoints(services, configuration); } /// /// Adds all HopFrame services to the application /// /// The service provider to add the services to + /// The configuration used to configure HopFrame authentication /// The data source for all HopFrame entities - public static void AddHopFrameNoEndpoints(this IServiceCollection services) where TDbContext : HopDbContextBase { + public static void AddHopFrameNoEndpoints(this IServiceCollection services, ConfigurationManager configuration) where TDbContext : HopDbContextBase { services.AddHopFrameRepositories(); services.TryAddSingleton(); services.AddScoped(); - services.AddHopFrameAuthentication(); + services.AddHopFrameAuthentication(configuration); } } diff --git a/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs b/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs index c1f3c90..acf8fb7 100644 --- a/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs +++ b/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs @@ -5,10 +5,11 @@ using HopFrame.Security.Authentication; using HopFrame.Security.Claims; using HopFrame.Security.Models; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; namespace HopFrame.Api.Logic.Implementation; -internal class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenContext tokenContext, IHttpContextAccessor accessor) : IAuthLogic { +internal class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenContext tokenContext, IHttpContextAccessor accessor, IOptions options) : IAuthLogic { public async Task>> Login(UserLogin login) { var user = await users.GetUserByEmail(login.Email); @@ -23,12 +24,12 @@ internal class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenC var accessToken = await tokens.CreateToken(Token.AccessTokenType, user); accessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.Content.ToString(), new CookieOptions { - MaxAge = HopFrameAuthentication.RefreshTokenTime, + MaxAge = options.Value.RefreshTokenTime, HttpOnly = true, Secure = true }); accessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Content.ToString(), new CookieOptions { - MaxAge = HopFrameAuthentication.AccessTokenTime, + MaxAge = options.Value.AccessTokenTime, HttpOnly = true, Secure = true }); @@ -54,12 +55,12 @@ internal class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenC var accessToken = await tokens.CreateToken(Token.AccessTokenType, user); accessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.Content.ToString(), new CookieOptions { - MaxAge = HopFrameAuthentication.RefreshTokenTime, + MaxAge = options.Value.RefreshTokenTime, HttpOnly = true, Secure = true }); accessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Content.ToString(), new CookieOptions { - MaxAge = HopFrameAuthentication.AccessTokenTime, + MaxAge = options.Value.AccessTokenTime, HttpOnly = false, Secure = true }); @@ -81,13 +82,13 @@ internal class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenC if (token.Type != Token.RefreshTokenType) return LogicResult>.Conflict("The provided token is not a refresh token"); - if (token.CreatedAt + HopFrameAuthentication.RefreshTokenTime < DateTime.Now) + if (token.CreatedAt + options.Value.RefreshTokenTime < DateTime.Now) return LogicResult>.Forbidden("Refresh token is expired"); var accessToken = await tokens.CreateToken(Token.AccessTokenType, token.Owner); accessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Content.ToString(), new CookieOptions { - MaxAge = HopFrameAuthentication.AccessTokenTime, + MaxAge = options.Value.AccessTokenTime, HttpOnly = false, Secure = true }); diff --git a/src/HopFrame.Security/Authentication/HopFrameAuthentication.cs b/src/HopFrame.Security/Authentication/HopFrameAuthentication.cs index 8709f91..8b0a3b1 100644 --- a/src/HopFrame.Security/Authentication/HopFrameAuthentication.cs +++ b/src/HopFrame.Security/Authentication/HopFrameAuthentication.cs @@ -17,23 +17,23 @@ public class HopFrameAuthentication( UrlEncoder encoder, ISystemClock clock, ITokenRepository tokens, - IPermissionRepository perms) + IPermissionRepository perms, + IOptions tokenOptions) : AuthenticationHandler(options, logger, encoder, clock) { - public const string SchemeName = "HopCore.Authentication"; - public static readonly TimeSpan AccessTokenTime = new(0, 0, 5, 0); - public static readonly TimeSpan RefreshTokenTime = new(30, 0, 0, 0); + public const string SchemeName = "HopFrame.Authentication"; protected override async Task HandleAuthenticateAsync() { var accessToken = Request.Cookies[ITokenContext.AccessTokenType]; if (string.IsNullOrEmpty(accessToken)) accessToken = Request.Headers[SchemeName]; if (string.IsNullOrEmpty(accessToken)) accessToken = Request.Headers["Token"]; + if (string.IsNullOrEmpty(accessToken)) accessToken = Request.Query["token"]; if (string.IsNullOrEmpty(accessToken)) return AuthenticateResult.Fail("No Access Token provided"); var tokenEntry = await tokens.GetToken(accessToken); if (tokenEntry is null) return AuthenticateResult.Fail("The provided Access Token does not exist"); - if (tokenEntry.CreatedAt + AccessTokenTime < DateTime.Now) return AuthenticateResult.Fail("The provided Access Token is expired"); + if (tokenEntry.CreatedAt + tokenOptions.Value.AccessTokenTime < DateTime.Now) return AuthenticateResult.Fail("The provided Access Token is expired"); if (tokenEntry.Owner is null) return AuthenticateResult.Fail("The provided Access Token does not match any user"); diff --git a/src/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs b/src/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs index cf87810..d45b048 100644 --- a/src/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs +++ b/src/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs @@ -1,23 +1,26 @@ using HopFrame.Security.Claims; +using HopFrame.Security.Options; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; namespace HopFrame.Security.Authentication; public static class HopFrameAuthenticationExtensions { - /// /// Configures the WebApplication to use the authentication and authorization of the HopFrame API /// /// The service provider to add the services to - /// The database object that saves all entities that are important for the security api + /// The configuration used to configure HopFrame authentication /// - public static IServiceCollection AddHopFrameAuthentication(this IServiceCollection service) { + public static IServiceCollection AddHopFrameAuthentication(this IServiceCollection service, ConfigurationManager configuration) { service.TryAddSingleton(); service.AddScoped(); + service.AddOptionsFromConfiguration(configuration); + service.AddAuthentication(HopFrameAuthentication.SchemeName).AddScheme(HopFrameAuthentication.SchemeName, _ => {}); service.AddAuthorization(); diff --git a/src/HopFrame.Security/Authentication/HopFrameAuthenticationOptions.cs b/src/HopFrame.Security/Authentication/HopFrameAuthenticationOptions.cs new file mode 100644 index 0000000..b996d68 --- /dev/null +++ b/src/HopFrame.Security/Authentication/HopFrameAuthenticationOptions.cs @@ -0,0 +1,20 @@ +using HopFrame.Security.Options; + +namespace HopFrame.Security.Authentication; + +public class HopFrameAuthenticationOptions : OptionsFromConfiguration { + public override string Position { get; } = "HopFrame:Authentication"; + + public TimeSpan AccessTokenTime => AccessToken is null ? new(0, 0, 5, 0) : new(AccessToken.Days, AccessToken.Hours, AccessToken.Minutes, AccessToken.Seconds); + public TimeSpan RefreshTokenTime => RefreshToken is null ? new(30, 0, 0, 0) : new(RefreshToken.Days, RefreshToken.Hours, RefreshToken.Minutes, RefreshToken.Seconds); + + public TokenTime AccessToken { get; set; } + public TokenTime RefreshToken { get; set; } + + public class TokenTime { + public int Days { get; set; } + public int Hours { get; set; } + public int Minutes { get; set; } + public int Seconds { get; set; } + } +} \ No newline at end of file diff --git a/src/HopFrame.Security/Options/OptionsFromConfiguration.cs b/src/HopFrame.Security/Options/OptionsFromConfiguration.cs new file mode 100644 index 0000000..0f06fb8 --- /dev/null +++ b/src/HopFrame.Security/Options/OptionsFromConfiguration.cs @@ -0,0 +1,5 @@ +namespace HopFrame.Security.Options; + +public abstract class OptionsFromConfiguration { + public abstract string Position { get; } +} \ No newline at end of file diff --git a/src/HopFrame.Security/Options/OptionsFromConfigurationExtensions.cs b/src/HopFrame.Security/Options/OptionsFromConfigurationExtensions.cs new file mode 100644 index 0000000..f9b62c4 --- /dev/null +++ b/src/HopFrame.Security/Options/OptionsFromConfigurationExtensions.cs @@ -0,0 +1,19 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace HopFrame.Security.Options; + +public static class OptionsFromConfigurationExtensions { + public static void AddOptionsFromConfiguration(this IServiceCollection services, IConfiguration configuration) where T : OptionsFromConfiguration { + T optionsInstance = (T)Activator.CreateInstance(typeof(T)); + string position = optionsInstance?.Position; + if (position is null) { + throw new ArgumentException($"""Configuration "{typeof(T).Name}" has no position configured!"""); + } + + services.Configure((Action)(options => { + IConfigurationSection section = configuration.GetSection(position); + section.Bind(options); + })); + } +} \ No newline at end of file diff --git a/src/HopFrame.Web/ServiceCollectionExtensions.cs b/src/HopFrame.Web/ServiceCollectionExtensions.cs index 548e2e9..4b6232a 100644 --- a/src/HopFrame.Web/ServiceCollectionExtensions.cs +++ b/src/HopFrame.Web/ServiceCollectionExtensions.cs @@ -6,12 +6,13 @@ using HopFrame.Web.Admin; using HopFrame.Web.Services; using HopFrame.Web.Services.Implementation; using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; namespace HopFrame.Web; public static class ServiceCollectionExtensions { - public static IServiceCollection AddHopFrame(this IServiceCollection services) where TDbContext : HopDbContextBase { + public static IServiceCollection AddHopFrame(this IServiceCollection services, ConfigurationManager configuration) where TDbContext : HopDbContextBase { services.AddHttpClient(); services.AddHopFrameRepositories(); services.AddScoped(); @@ -22,7 +23,7 @@ public static class ServiceCollectionExtensions { services.AddSweetAlert2(); services.AddBlazorStrap(); - services.AddHopFrameAuthentication(); + services.AddHopFrameAuthentication(configuration); return services; } diff --git a/src/HopFrame.Web/Services/Implementation/AuthService.cs b/src/HopFrame.Web/Services/Implementation/AuthService.cs index e5f1ec0..6fca234 100644 --- a/src/HopFrame.Web/Services/Implementation/AuthService.cs +++ b/src/HopFrame.Web/Services/Implementation/AuthService.cs @@ -4,6 +4,7 @@ using HopFrame.Security.Authentication; using HopFrame.Security.Claims; using HopFrame.Security.Models; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; namespace HopFrame.Web.Services.Implementation; @@ -11,7 +12,8 @@ internal class AuthService( IUserRepository userService, IHttpContextAccessor httpAccessor, ITokenRepository tokens, - ITokenContext context) + ITokenContext context, + IOptions options) : IAuthService { public async Task Register(UserRegister register) { @@ -27,12 +29,12 @@ internal class AuthService( var accessToken = await tokens.CreateToken(Token.AccessTokenType, user); httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.Content.ToString(), new CookieOptions { - MaxAge = HopFrameAuthentication.RefreshTokenTime, + MaxAge = options.Value.RefreshTokenTime, HttpOnly = true, Secure = true }); httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Content.ToString(), new CookieOptions { - MaxAge = HopFrameAuthentication.AccessTokenTime, + MaxAge = options.Value.AccessTokenTime, HttpOnly = false, Secure = true }); @@ -48,12 +50,12 @@ internal class AuthService( var accessToken = await tokens.CreateToken(Token.AccessTokenType, user); httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.Content.ToString(), new CookieOptions { - MaxAge = HopFrameAuthentication.RefreshTokenTime, + MaxAge = options.Value.RefreshTokenTime, HttpOnly = true, Secure = true }); httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Content.ToString(), new CookieOptions { - MaxAge = HopFrameAuthentication.AccessTokenTime, + MaxAge = options.Value.AccessTokenTime, HttpOnly = false, Secure = true }); @@ -77,12 +79,12 @@ internal class AuthService( if (token is null || token.Type != Token.RefreshTokenType) return null; - if (token.CreatedAt + HopFrameAuthentication.RefreshTokenTime < DateTime.Now) return null; + if (token.CreatedAt + options.Value.RefreshTokenTime < DateTime.Now) return null; var accessToken = await tokens.CreateToken(Token.AccessTokenType, token.Owner); httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Content.ToString(), new CookieOptions { - MaxAge = HopFrameAuthentication.AccessTokenTime, + MaxAge = options.Value.AccessTokenTime, HttpOnly = false, Secure = true }); @@ -95,7 +97,7 @@ internal class AuthService( if (accessToken is null) return false; if (accessToken.Type != Token.AccessTokenType) return false; - if (accessToken.CreatedAt + HopFrameAuthentication.AccessTokenTime < DateTime.Now) return false; + if (accessToken.CreatedAt + options.Value.AccessTokenTime < DateTime.Now) return false; if (accessToken.Owner is null) return false; return true; diff --git a/testing/HopFrame.Testing.Api/Program.cs b/testing/HopFrame.Testing.Api/Program.cs index 6651ecd..b728eb3 100644 --- a/testing/HopFrame.Testing.Api/Program.cs +++ b/testing/HopFrame.Testing.Api/Program.cs @@ -7,7 +7,7 @@ var builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services.AddControllers(); -builder.Services.AddHopFrame(); +builder.Services.AddHopFrame(builder.Configuration); // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); diff --git a/testing/HopFrame.Testing.Web/Program.cs b/testing/HopFrame.Testing.Web/Program.cs index 7957fff..481e7fc 100644 --- a/testing/HopFrame.Testing.Web/Program.cs +++ b/testing/HopFrame.Testing.Web/Program.cs @@ -6,7 +6,7 @@ using HopFrame.Web.Admin; var builder = WebApplication.CreateBuilder(args); builder.Services.AddDbContext(); -builder.Services.AddHopFrame(); +builder.Services.AddHopFrame(builder.Configuration); builder.Services.AddAdminContext(); // Add services to the container. diff --git a/tests/HopFrame.Tests.Api/AuthLogicTests.cs b/tests/HopFrame.Tests.Api/AuthLogicTests.cs index ca86b5b..a5163d2 100644 --- a/tests/HopFrame.Tests.Api/AuthLogicTests.cs +++ b/tests/HopFrame.Tests.Api/AuthLogicTests.cs @@ -10,6 +10,7 @@ using HopFrame.Security.Authentication; using HopFrame.Security.Claims; using HopFrame.Security.Models; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; using Moq; namespace HopFrame.Tests.Api; @@ -75,7 +76,7 @@ public class AuthLogicTests { .Setup(c => c.User) .Returns(CreateDummyUser()); - return (new AuthLogic(users.Object, tokens.Object, context.Object, accessor), accessor.HttpContext); + return (new AuthLogic(users.Object, tokens.Object, context.Object, accessor, new OptionsWrapper(new HopFrameAuthenticationOptions())), accessor.HttpContext); } private User CreateDummyUser() => new() { diff --git a/tests/HopFrame.Tests.Security/AuthenticationTests.cs b/tests/HopFrame.Tests.Security/AuthenticationTests.cs index 7c80e7d..5cd6d44 100644 --- a/tests/HopFrame.Tests.Security/AuthenticationTests.cs +++ b/tests/HopFrame.Tests.Security/AuthenticationTests.cs @@ -46,7 +46,7 @@ public class AuthenticationTests { .Setup(x => x.GetFullPermissions(It.IsAny())) .ReturnsAsync(new List()); - var auth = new HopFrameAuthentication(options.Object, logger.Object, encoder.Object, clock.Object, tokens.Object, perms.Object); + var auth = new HopFrameAuthentication(options.Object, logger.Object, encoder.Object, clock.Object, tokens.Object, perms.Object, new OptionsWrapper(new HopFrameAuthenticationOptions())); var context = new DefaultHttpContext(); if (provideCorrectToken) context.HttpContext.Request.Headers.Append(HopFrameAuthentication.SchemeName, correctToken.Content.ToString()); diff --git a/tests/HopFrame.Tests.Web/AuthServiceTests.cs b/tests/HopFrame.Tests.Web/AuthServiceTests.cs index a5df287..d5c5ad7 100644 --- a/tests/HopFrame.Tests.Web/AuthServiceTests.cs +++ b/tests/HopFrame.Tests.Web/AuthServiceTests.cs @@ -1,11 +1,13 @@ using HopFrame.Database.Models; using HopFrame.Database.Repositories; +using HopFrame.Security.Authentication; using HopFrame.Security.Claims; using HopFrame.Security.Models; using HopFrame.Tests.Web.Extensions; using HopFrame.Web.Services; using HopFrame.Web.Services.Implementation; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; using Moq; namespace HopFrame.Tests.Web; @@ -66,7 +68,7 @@ public class AuthServiceTests { .Setup(c => c.AccessToken) .Returns(providedAccessToken); - return (new AuthService(users.Object, accessor, tokens.Object, context.Object), accessor.HttpContext); + return (new AuthService(users.Object, accessor, tokens.Object, context.Object, new OptionsWrapper(new HopFrameAuthenticationOptions())), accessor.HttpContext); } private User CreateDummyUser() => new() { From 422fd6c677ed1d89444271f487f4aef1c8522bb4 Mon Sep 17 00:00:00 2001 From: Leon Hoppe Date: Sat, 21 Dec 2024 14:08:47 +0100 Subject: [PATCH 14/27] Added authentication documentation to table of contents --- docs/readme.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/readme.md b/docs/readme.md index 289a64c..0fc3ff0 100644 --- a/docs/readme.md +++ b/docs/readme.md @@ -7,6 +7,7 @@ The HopFrame comes in two variations, you can eiter only use the backend with so - [Database](./database.md) - [Repositories](./repositories.md) - [Base Models](./models.md) +- [Authentication](./authentication.md) ## HopFrame Web API From 51c15eff4ce3187fee71cf027d45c1dab9a4abdb Mon Sep 17 00:00:00 2001 From: Leon Hoppe Date: Sat, 21 Dec 2024 14:12:54 +0100 Subject: [PATCH 15/27] Added environment variable example for authentication configuration --- docs/authentication.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/authentication.md b/docs/authentication.md index e1a0182..469ceee 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -28,6 +28,7 @@ by configuring your configuration to load these. You can specify `Seconds`, `Minutes`, `Hours` and `Days` for either of the two token types. These get combined to a single time span. +#### Configuration example ```json "HopFrame": { "Authentication": { @@ -41,3 +42,10 @@ These get combined to a single time span. } } ``` + +#### Environment variables example +```dotenv +HOPFRAME__AUTHENTICATION__ACCESSTOKEN__MINUTES=30 +HOPFRAME__AUTHENTICATION__REFRESHTOKEN__DAYS=10 +HOPFRAME__AUTHENTICATION__REFRESHTOKEN__HOURS=5 +``` From 92afc85dbaa72914fbbe1a3632bd5040c84b9049 Mon Sep 17 00:00:00 2001 From: Leon Hoppe Date: Sat, 21 Dec 2024 14:59:04 +0100 Subject: [PATCH 16/27] added permission configuration --- docs/permissions.md | 80 +++++++++++++++++++ docs/readme.md | 1 + src/HopFrame.Security/AdminPermissions.cs | 15 ---- .../HopFrameAuthenticationExtensions.cs | 2 + .../Authorization/AdminPermissionOptions.cs | 30 +++++++ .../Classes/AdminPermissionsAttribute.cs | 2 +- .../Generators/IAdminPageGenerator.cs | 2 +- .../Implementation/AdminPageGenerator.cs | 6 +- .../Models/AdminPagePermissions.cs | 2 +- src/HopFrame.Web/HopAdminContext.cs | 20 ++--- .../Pages/Administration/AdminDashboard.razor | 10 ++- .../Pages/Administration/AdminPageList.razor | 2 +- .../Administration/Layout/AdminMenu.razor | 2 +- 13 files changed, 138 insertions(+), 36 deletions(-) create mode 100644 docs/permissions.md delete mode 100644 src/HopFrame.Security/AdminPermissions.cs create mode 100644 src/HopFrame.Security/Authorization/AdminPermissionOptions.cs diff --git a/docs/permissions.md b/docs/permissions.md new file mode 100644 index 0000000..12a17cc --- /dev/null +++ b/docs/permissions.md @@ -0,0 +1,80 @@ +# HopFrame Permissions + +Permissions in the HopFrame are simple and effective to use. +As discussed in the [repositories](./repositories.md) documentation, you can manage user / group permissions +via the `IPermissionRepository` service. + +## How do permissions work in the HopFrame + +Permissions are defined using the . (dot) syntax. This enables you to nest permissions in namespaces. +You can also give a user or a group the permission to every permission in a namespace by using the * (star) syntax. + +| Permission | Example | Description | +|----------------------|-------------------------------|-------------------------------------------------------| +| `*` | `*` | all permissions | +| `[namespace].[name]` | `hopframe.admin.users.create` | single permission | +| `[namespace].*` | `hopframe.admin.*` | all permissions in that namespace (works recursively) | + +### Reserved namespaces + +| Namespace | Example | Description | +|-----------|---------------|------------------------------------------| +| `group` | `group.admin` | The user needs to be in a specific group | + +### Permission Groups + +You can manage them through the `IGroupRepository` as described in the [repositories](./repositories.md) documentation. +You add permissions just like you would to a user with the `IPermissionRepository`. +You can assign a user to a group by assigning the group permission to the user: +```csharp +permissionRepository.AddPermission(user, "group.admin"); +``` + +## Predefined Permissions + +| Permission | Description | +|--------------------------------|-------------------------------| +| `hopframe.admin` | Access to the admin dashboard | +| `hopframe.admin.users.read` | View all users | +| `hopframe.admin.users.update` | Edit a user | +| `hopframe.admin.users.delete` | Delete a user | +| `hopframe.admin.users.create` | Add a group | +| `hopframe.admin.groups.read` | View all groups | +| `hopframe.admin.groups.update` | Edit a group | +| `hopframe.admin.groups.delete` | Delete a group | +| `hopframe.admin.groups.create` | Add a group | + +### Configuring HopFrame permissions + +You can also configure the predefined permissions using the `appsettings.json` or environment variables +by configuring your configuration to load these. +>**Hint**: Configuring your application to use environment variables works by simply adding +> `builder.Configuration.AddEnvironmentVariables();` to your startup configuration before you add the +> custom configurations / HopFrame services. + +You can specify `Dashboard` for the dashboard permission and for `Users` and `Groups` you can specify +`Create`, `Read`, `Update` and `Delete` permissions. + +#### Configuration example +```json + "HopFrame": { + "Permissions": { + "Dashboard": "myapp.dashboard.view", + "Users": { + "Read": "myapp.read.users" + }, + "Groups": { + "Create": "myapp.create.groups", + "Update": "myapp.update.groups" + } + } + } +``` + +#### Environment variables example +```dotenv +HOPFRAME__PERMISSIONS__DASHBOARD="myapp.dashboard.view" +HOPFRAME__PERMISSIONS__USERS__READ="myapp.read.users" +HOPFRAME__PERMISSIONS__GROUPS__CREATE="myapp.create.groups" +HOPFRAME__PERMISSIONS__GROUPS__UPDATE="myapp.update.groups" +``` diff --git a/docs/readme.md b/docs/readme.md index 0fc3ff0..df7f363 100644 --- a/docs/readme.md +++ b/docs/readme.md @@ -8,6 +8,7 @@ The HopFrame comes in two variations, you can eiter only use the backend with so - [Repositories](./repositories.md) - [Base Models](./models.md) - [Authentication](./authentication.md) +- [Permissions](./permissions.md) ## HopFrame Web API diff --git a/src/HopFrame.Security/AdminPermissions.cs b/src/HopFrame.Security/AdminPermissions.cs deleted file mode 100644 index 7f45afc..0000000 --- a/src/HopFrame.Security/AdminPermissions.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace HopFrame.Security; - -public static class AdminPermissions { - public const string IsAdmin = "hopframe.admin"; - - public const string ViewUsers = "hopframe.admin.users.view"; - public const string EditUser = "hopframe.admin.users.edit"; - public const string DeleteUser = "hopframe.admin.users.delete"; - public const string AddUser = "hopframe.admin.users.add"; - - public const string ViewGroups = "hopframe.admin.groups.view"; - public const string EditGroup = "hopframe.admin.groups.edit"; - public const string DeleteGroup = "hopframe.admin.groups.delete"; - public const string AddGroup = "hopframe.admin.groups.add"; -} \ No newline at end of file diff --git a/src/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs b/src/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs index d45b048..e0b7d37 100644 --- a/src/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs +++ b/src/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs @@ -1,3 +1,4 @@ +using HopFrame.Security.Authorization; using HopFrame.Security.Claims; using HopFrame.Security.Options; using Microsoft.AspNetCore.Authentication; @@ -20,6 +21,7 @@ public static class HopFrameAuthenticationExtensions { service.AddScoped(); service.AddOptionsFromConfiguration(configuration); + service.AddOptionsFromConfiguration(configuration); service.AddAuthentication(HopFrameAuthentication.SchemeName).AddScheme(HopFrameAuthentication.SchemeName, _ => {}); service.AddAuthorization(); diff --git a/src/HopFrame.Security/Authorization/AdminPermissionOptions.cs b/src/HopFrame.Security/Authorization/AdminPermissionOptions.cs new file mode 100644 index 0000000..46fc7f0 --- /dev/null +++ b/src/HopFrame.Security/Authorization/AdminPermissionOptions.cs @@ -0,0 +1,30 @@ +using HopFrame.Security.Options; + +namespace HopFrame.Security.Authorization; + +public class AdminPermissionOptions : OptionsFromConfiguration { + public override string Position { get; } = "HopFrame:Permissions"; + + public string Dashboard { get; set; } = "hopframe.admin"; + + public CrudPermission Users { get; set; } = new() { + Read = "hopframe.admin.users.read", + Update = "hopframe.admin.users.update", + Delete = "hopframe.admin.users.delete", + Create = "hopframe.admin.users.create" + }; + + public CrudPermission Groups { get; set; } = new() { + Read = "hopframe.admin.groups.read", + Update = "hopframe.admin.groups.update", + Delete = "hopframe.admin.groups.delete", + Create = "hopframe.admin.groups.create" + }; + + public class CrudPermission { + public string Create { get; set; } + public string Read { get; set; } + public string Update { get; set; } + public string Delete { get; set; } + } +} \ No newline at end of file diff --git a/src/HopFrame.Web.Admin/Attributes/Classes/AdminPermissionsAttribute.cs b/src/HopFrame.Web.Admin/Attributes/Classes/AdminPermissionsAttribute.cs index 7d68eab..dc58ffb 100644 --- a/src/HopFrame.Web.Admin/Attributes/Classes/AdminPermissionsAttribute.cs +++ b/src/HopFrame.Web.Admin/Attributes/Classes/AdminPermissionsAttribute.cs @@ -8,6 +8,6 @@ public sealed class AdminPermissionsAttribute(string view = null, string create Create = create, Update = update, Delete = delete, - View = view + Read = view }; } diff --git a/src/HopFrame.Web.Admin/Generators/IAdminPageGenerator.cs b/src/HopFrame.Web.Admin/Generators/IAdminPageGenerator.cs index 65998bd..05ff528 100644 --- a/src/HopFrame.Web.Admin/Generators/IAdminPageGenerator.cs +++ b/src/HopFrame.Web.Admin/Generators/IAdminPageGenerator.cs @@ -24,7 +24,7 @@ public interface IAdminPageGenerator { /// /// the specified permission /// - IAdminPageGenerator ViewPermission(string permission); + IAdminPageGenerator ReadPermission(string permission); /// /// Sets the permission needed to create a new Entry diff --git a/src/HopFrame.Web.Admin/Generators/Implementation/AdminPageGenerator.cs b/src/HopFrame.Web.Admin/Generators/Implementation/AdminPageGenerator.cs index eb61f7d..3181c97 100644 --- a/src/HopFrame.Web.Admin/Generators/Implementation/AdminPageGenerator.cs +++ b/src/HopFrame.Web.Admin/Generators/Implementation/AdminPageGenerator.cs @@ -48,8 +48,8 @@ internal sealed class AdminPageGenerator : IAdminPageGenerator, return this; } - public IAdminPageGenerator ViewPermission(string permission) { - Page.Permissions.View = permission; + public IAdminPageGenerator ReadPermission(string permission) { + Page.Permissions.Read = permission; return this; } @@ -165,7 +165,7 @@ internal sealed class AdminPageGenerator : IAdminPageGenerator, var attribute = attributes.Single(a => a is AdminPermissionsAttribute) as AdminPermissionsAttribute; CreatePermission(attribute?.Permissions.Create); UpdatePermission(attribute?.Permissions.Update); - ViewPermission(attribute?.Permissions.View); + ReadPermission(attribute?.Permissions.Read); DeletePermission(attribute?.Permissions.Delete); } diff --git a/src/HopFrame.Web.Admin/Models/AdminPagePermissions.cs b/src/HopFrame.Web.Admin/Models/AdminPagePermissions.cs index e9629a6..0312aaa 100644 --- a/src/HopFrame.Web.Admin/Models/AdminPagePermissions.cs +++ b/src/HopFrame.Web.Admin/Models/AdminPagePermissions.cs @@ -1,7 +1,7 @@ namespace HopFrame.Web.Admin.Models; public sealed class AdminPagePermissions { - public string View { get; set; } + public string Read { get; set; } public string Create { get; set; } public string Update { get; set; } public string Delete { get; set; } diff --git a/src/HopFrame.Web/HopAdminContext.cs b/src/HopFrame.Web/HopAdminContext.cs index 0beffd2..198ce65 100644 --- a/src/HopFrame.Web/HopAdminContext.cs +++ b/src/HopFrame.Web/HopAdminContext.cs @@ -1,15 +1,17 @@ using System.Text.RegularExpressions; using HopFrame.Database.Models; using HopFrame.Security; +using HopFrame.Security.Authorization; using HopFrame.Web.Admin; using HopFrame.Web.Admin.Attributes; using HopFrame.Web.Admin.Generators; using HopFrame.Web.Admin.Models; using HopFrame.Web.Provider; +using Microsoft.Extensions.Options; namespace HopFrame.Web; -internal class HopAdminContext : AdminPagesContext { +internal class HopAdminContext(IOptions options) : AdminPagesContext { [AdminPageUrl("users")] public AdminPage Users { get; set; } @@ -21,10 +23,10 @@ internal class HopAdminContext : AdminPagesContext { generator.Page() .Description("On this page you can manage all user accounts.") .ConfigureProvider() - .ViewPermission(AdminPermissions.ViewUsers) - .CreatePermission(AdminPermissions.AddUser) - .UpdatePermission(AdminPermissions.EditUser) - .DeletePermission(AdminPermissions.DeleteUser); + .ReadPermission(options.Value.Users.Read) + .CreatePermission(options.Value.Users.Create) + .UpdatePermission(options.Value.Users.Update) + .DeletePermission(options.Value.Users.Delete); generator.Page().Property(u => u.Password) .DisplayInListing(false) @@ -64,10 +66,10 @@ internal class HopAdminContext : AdminPagesContext { generator.Page() .Description("On this page you can view, create, edit and delete permission groups.") .ConfigureProvider() - .ViewPermission(AdminPermissions.ViewGroups) - .CreatePermission(AdminPermissions.AddGroup) - .UpdatePermission(AdminPermissions.EditGroup) - .DeletePermission(AdminPermissions.DeleteGroup) + .ReadPermission(options.Value.Groups.Read) + .CreatePermission(options.Value.Groups.Create) + .UpdatePermission(options.Value.Groups.Update) + .DeletePermission(options.Value.Groups.Delete) .ListingProperty(g => g.Name); generator.Page().Property(g => g.Name) diff --git a/src/HopFrame.Web/Pages/Administration/AdminDashboard.razor b/src/HopFrame.Web/Pages/Administration/AdminDashboard.razor index 7ebb3cf..fe7afb1 100644 --- a/src/HopFrame.Web/Pages/Administration/AdminDashboard.razor +++ b/src/HopFrame.Web/Pages/Administration/AdminDashboard.razor @@ -5,25 +5,26 @@ @using BlazorStrap @using HopFrame.Web.Pages.Administration.Layout @using BlazorStrap.V5 -@using HopFrame.Security +@using HopFrame.Security.Authorization @using HopFrame.Web.Admin.Providers @using HopFrame.Web.Components @using Microsoft.AspNetCore.Components.Web +@using Microsoft.Extensions.Options @layout AdminLayout - + Admin Dashboard @foreach (var adminPage in Pages.LoadRegisteredAdminPages()) { - + @adminPage.Title - @adminPage.Permissions.View + @adminPage.Permissions.Read @adminPage.Description Open @@ -36,6 +37,7 @@ @inject NavigationManager Navigator @inject IAdminPagesProvider Pages +@inject IOptions Options @code { diff --git a/src/HopFrame.Web/Pages/Administration/AdminPageList.razor b/src/HopFrame.Web/Pages/Administration/AdminPageList.razor index bf4c17d..d796aeb 100644 --- a/src/HopFrame.Web/Pages/Administration/AdminPageList.razor +++ b/src/HopFrame.Web/Pages/Administration/AdminPageList.razor @@ -18,7 +18,7 @@ @using HopFrame.Web.Components @_pageData.Title - + diff --git a/src/HopFrame.Web/Pages/Administration/Layout/AdminMenu.razor b/src/HopFrame.Web/Pages/Administration/Layout/AdminMenu.razor index a47bafb..409b002 100644 --- a/src/HopFrame.Web/Pages/Administration/Layout/AdminMenu.razor +++ b/src/HopFrame.Web/Pages/Administration/Layout/AdminMenu.razor @@ -24,7 +24,7 @@ Dashboard @foreach (var adminPage in Pages.LoadRegisteredAdminPages()) { - + @adminPage.Title } From ba46147a74e7a7bbd12c0994cbea45f78a5a4b0a Mon Sep 17 00:00:00 2001 From: Leon Hoppe Date: Sat, 21 Dec 2024 16:09:55 +0100 Subject: [PATCH 17/27] Added API token functionality --- .idea/.idea.HopFrame/.idea/dataSources.xml | 2 +- HopFrame.sln.DotSettings.user | 1 + .../Logic/Implementation/AuthLogic.cs | 16 ++++++------ src/HopFrame.Database/HopDbContextBase.cs | 5 ++++ src/HopFrame.Database/Models/Permission.cs | 3 +++ src/HopFrame.Database/Models/Token.cs | 12 +++++++-- .../Repositories/ITokenRepository.cs | 1 + .../Implementation/PermissionRepository.cs | 19 ++++++++++++++ .../Implementation/TokenRepository.cs | 18 +++++++++++-- .../Authentication/HopFrameAuthentication.cs | 16 ++++++++++-- src/HopFrame.Web/AuthMiddleware.cs | 2 +- .../Services/Implementation/AuthService.cs | 10 +++---- tests/HopFrame.Tests.Api/AuthLogicTests.cs | 16 ++++++------ .../Repositories/TokenRepositoryTests.cs | 8 +++--- .../AuthenticationTests.cs | 14 +++++----- .../HopFrame.Tests.Web/AuthMiddlewareTests.cs | 4 +-- tests/HopFrame.Tests.Web/AuthServiceTests.cs | 26 +++++++++---------- 17 files changed, 118 insertions(+), 55 deletions(-) diff --git a/.idea/.idea.HopFrame/.idea/dataSources.xml b/.idea/.idea.HopFrame/.idea/dataSources.xml index ded00e9..3e820ee 100644 --- a/.idea/.idea.HopFrame/.idea/dataSources.xml +++ b/.idea/.idea.HopFrame/.idea/dataSources.xml @@ -5,7 +5,7 @@ sqlite.xerial true org.sqlite.JDBC - jdbc:sqlite:$PROJECT_DIR$/test/RestApiTest/bin/Debug/net8.0/test.db + jdbc:sqlite:C:\Users\leon\Documents\Projekte\HopFrame\testing\HopFrame.Testing.Api\bin\Debug\net8.0\test.db diff --git a/HopFrame.sln.DotSettings.user b/HopFrame.sln.DotSettings.user index 1e30ef8..86d9f80 100644 --- a/HopFrame.sln.DotSettings.user +++ b/HopFrame.sln.DotSettings.user @@ -69,6 +69,7 @@ + \ No newline at end of file diff --git a/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs b/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs index acf8fb7..61e9681 100644 --- a/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs +++ b/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs @@ -23,18 +23,18 @@ internal class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenC var refreshToken = await tokens.CreateToken(Token.RefreshTokenType, user); var accessToken = await tokens.CreateToken(Token.AccessTokenType, user); - accessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.Content.ToString(), new CookieOptions { + accessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.TokenId.ToString(), new CookieOptions { MaxAge = options.Value.RefreshTokenTime, HttpOnly = true, Secure = true }); - accessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Content.ToString(), new CookieOptions { + accessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.TokenId.ToString(), new CookieOptions { MaxAge = options.Value.AccessTokenTime, HttpOnly = true, Secure = true }); - return LogicResult>.Ok(accessToken.Content.ToString()); + return LogicResult>.Ok(accessToken.TokenId.ToString()); } public async Task>> Register(UserRegister register) { @@ -54,18 +54,18 @@ internal class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenC var refreshToken = await tokens.CreateToken(Token.RefreshTokenType, user); var accessToken = await tokens.CreateToken(Token.AccessTokenType, user); - accessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.Content.ToString(), new CookieOptions { + accessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.TokenId.ToString(), new CookieOptions { MaxAge = options.Value.RefreshTokenTime, HttpOnly = true, Secure = true }); - accessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Content.ToString(), new CookieOptions { + accessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.TokenId.ToString(), new CookieOptions { MaxAge = options.Value.AccessTokenTime, HttpOnly = false, Secure = true }); - return LogicResult>.Ok(accessToken.Content.ToString()); + return LogicResult>.Ok(accessToken.TokenId.ToString()); } public async Task>> Authenticate() { @@ -87,13 +87,13 @@ internal class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenC var accessToken = await tokens.CreateToken(Token.AccessTokenType, token.Owner); - accessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Content.ToString(), new CookieOptions { + accessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.TokenId.ToString(), new CookieOptions { MaxAge = options.Value.AccessTokenTime, HttpOnly = false, Secure = true }); - return LogicResult>.Ok(accessToken.Content.ToString()); + return LogicResult>.Ok(accessToken.TokenId.ToString()); } public async Task Logout() { diff --git a/src/HopFrame.Database/HopDbContextBase.cs b/src/HopFrame.Database/HopDbContextBase.cs index 21342ea..cd03860 100644 --- a/src/HopFrame.Database/HopDbContextBase.cs +++ b/src/HopFrame.Database/HopDbContextBase.cs @@ -30,5 +30,10 @@ public abstract class HopDbContextBase : DbContext { .HasMany(g => g.Permissions) .WithOne(p => p.Group) .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(t => t.Permissions) + .WithOne(t => t.Token) + .OnDelete(DeleteBehavior.Cascade); } } \ No newline at end of file diff --git a/src/HopFrame.Database/Models/Permission.cs b/src/HopFrame.Database/Models/Permission.cs index db111ba..658a90e 100644 --- a/src/HopFrame.Database/Models/Permission.cs +++ b/src/HopFrame.Database/Models/Permission.cs @@ -21,6 +21,9 @@ public class Permission { [ForeignKey("GroupName"), JsonIgnore] public virtual PermissionGroup Group { get; set; } + [ForeignKey("TokenId"), JsonIgnore] + public virtual Token Token { get; set; } + } public interface IPermissionOwner; diff --git a/src/HopFrame.Database/Models/Token.cs b/src/HopFrame.Database/Models/Token.cs index a42d367..b22bd21 100644 --- a/src/HopFrame.Database/Models/Token.cs +++ b/src/HopFrame.Database/Models/Token.cs @@ -4,24 +4,32 @@ using System.Text.Json.Serialization; namespace HopFrame.Database.Models; -public class Token { +public class Token : IPermissionOwner { public const int RefreshTokenType = 0; public const int AccessTokenType = 1; + public const int ApiTokenType = 2; /// /// Defines the Type of the stored Token /// 0: Refresh token /// 1: Access token + /// 2: Api token /// [Required, MinLength(1), MaxLength(1)] public int Type { get; set; } [Key, Required, MinLength(36), MaxLength(36)] - public Guid Content { get; set; } + public Guid TokenId { get; set; } + /// + /// Defines the creation date of the token + /// In case of an api token it defines the date it becomes invalid + /// [Required] public DateTime CreatedAt { get; set; } [ForeignKey("UserId"), JsonIgnore] public virtual User Owner { get; set; } + + public virtual List Permissions { get; set; } } \ No newline at end of file diff --git a/src/HopFrame.Database/Repositories/ITokenRepository.cs b/src/HopFrame.Database/Repositories/ITokenRepository.cs index bec3963..5f66769 100644 --- a/src/HopFrame.Database/Repositories/ITokenRepository.cs +++ b/src/HopFrame.Database/Repositories/ITokenRepository.cs @@ -6,4 +6,5 @@ public interface ITokenRepository { Task GetToken(string content); Task CreateToken(int type, User owner); Task DeleteUserTokens(User owner); + Task CreateApiToken(User owner, DateTime expirationDate); } \ No newline at end of file diff --git a/src/HopFrame.Database/Repositories/Implementation/PermissionRepository.cs b/src/HopFrame.Database/Repositories/Implementation/PermissionRepository.cs index 45bcfd8..f80b0b8 100644 --- a/src/HopFrame.Database/Repositories/Implementation/PermissionRepository.cs +++ b/src/HopFrame.Database/Repositories/Implementation/PermissionRepository.cs @@ -24,6 +24,10 @@ internal sealed class PermissionRepository(TDbContext context, IGrou entry.User = user; }else if (owner is PermissionGroup group) { entry.Group = group; + }else if (owner is Token token) { + if (token.Type != Token.ApiTokenType) + throw new ArgumentException("Only API tokens can have permissions!"); + entry.Token = token; } await context.Permissions.AddAsync(entry); @@ -48,6 +52,13 @@ internal sealed class PermissionRepository(TDbContext context, IGrou .Where(p =>p.Group.Name == group.Name) .Where(p => p.PermissionName == permission) .SingleOrDefaultAsync(); + }else if (owner is Token token) { + entry = await context.Permissions + .Include(p => p.Token) + .Where(p => p.Token != null) + .Where(p => p.Token.TokenId == token.TokenId) + .Where(p => p.PermissionName == permission) + .SingleOrDefaultAsync(); } if (entry is not null) { @@ -74,6 +85,14 @@ internal sealed class PermissionRepository(TDbContext context, IGrou .Where(p =>p.Group.Name == group.Name) .ToListAsync(); + permissions.AddRange(perms.Select(p => p.PermissionName)); + }else if (owner is Token token) { + var perms = await context.Permissions + .Include(p => p.Token) + .Where(p => p.Token != null) + .Where(p =>p.Token.TokenId == token.TokenId) + .ToListAsync(); + permissions.AddRange(perms.Select(p => p.PermissionName)); } diff --git a/src/HopFrame.Database/Repositories/Implementation/TokenRepository.cs b/src/HopFrame.Database/Repositories/Implementation/TokenRepository.cs index 70f727a..927d080 100644 --- a/src/HopFrame.Database/Repositories/Implementation/TokenRepository.cs +++ b/src/HopFrame.Database/Repositories/Implementation/TokenRepository.cs @@ -11,14 +11,14 @@ internal sealed class TokenRepository(TDbContext context) : ITokenRe return await context.Tokens .Include(t => t.Owner) - .Where(t => t.Content == guid) + .Where(t => t.TokenId == guid) .SingleOrDefaultAsync(); } public async Task CreateToken(int type, User owner) { var token = new Token { CreatedAt = DateTime.Now, - Content = Guid.NewGuid(), + TokenId = Guid.NewGuid(), Type = type, Owner = owner }; @@ -38,4 +38,18 @@ internal sealed class TokenRepository(TDbContext context) : ITokenRe context.Tokens.RemoveRange(tokens); await context.SaveChangesAsync(); } + + public async Task CreateApiToken(User owner, DateTime expirationDate) { + var token = new Token { + CreatedAt = expirationDate, + TokenId = Guid.NewGuid(), + Type = Token.ApiTokenType, + Owner = owner + }; + + await context.Tokens.AddAsync(token); + await context.SaveChangesAsync(); + + return token; + } } \ No newline at end of file diff --git a/src/HopFrame.Security/Authentication/HopFrameAuthentication.cs b/src/HopFrame.Security/Authentication/HopFrameAuthentication.cs index 8b0a3b1..9f9af47 100644 --- a/src/HopFrame.Security/Authentication/HopFrameAuthentication.cs +++ b/src/HopFrame.Security/Authentication/HopFrameAuthentication.cs @@ -1,5 +1,6 @@ using System.Security.Claims; using System.Text.Encodings.Web; +using HopFrame.Database.Models; using HopFrame.Database.Repositories; using HopFrame.Security.Claims; using Microsoft.AspNetCore.Authentication; @@ -33,7 +34,10 @@ public class HopFrameAuthentication( var tokenEntry = await tokens.GetToken(accessToken); if (tokenEntry is null) return AuthenticateResult.Fail("The provided Access Token does not exist"); - if (tokenEntry.CreatedAt + tokenOptions.Value.AccessTokenTime < DateTime.Now) return AuthenticateResult.Fail("The provided Access Token is expired"); + + if (tokenEntry.Type == Token.ApiTokenType) { + if (tokenEntry.CreatedAt < DateTime.Now) return AuthenticateResult.Fail("The provided API Token is expired"); + }else if (tokenEntry.CreatedAt + tokenOptions.Value.AccessTokenTime < DateTime.Now) return AuthenticateResult.Fail("The provided Access Token is expired"); if (tokenEntry.Owner is null) return AuthenticateResult.Fail("The provided Access Token does not match any user"); @@ -43,7 +47,15 @@ public class HopFrameAuthentication( new(HopFrameClaimTypes.UserId, tokenEntry.Owner.Id.ToString()) }; - var permissions = await perms.GetFullPermissions(tokenEntry.Owner); + IList permissions; + + if (tokenEntry.Type == Token.ApiTokenType) { + permissions = await perms.GetFullPermissions(tokenEntry); + } + else { + permissions = await perms.GetFullPermissions(tokenEntry.Owner); + } + claims.AddRange(permissions.Select(perm => new Claim(HopFrameClaimTypes.Permission, perm))); var principal = new ClaimsPrincipal(); diff --git a/src/HopFrame.Web/AuthMiddleware.cs b/src/HopFrame.Web/AuthMiddleware.cs index 33e2f52..ac5c954 100644 --- a/src/HopFrame.Web/AuthMiddleware.cs +++ b/src/HopFrame.Web/AuthMiddleware.cs @@ -22,7 +22,7 @@ public sealed class AuthMiddleware(IAuthService auth, IPermissionRepository perm } var claims = new List { - new(HopFrameClaimTypes.AccessTokenId, token.Content.ToString()), + new(HopFrameClaimTypes.AccessTokenId, token.TokenId.ToString()), new(HopFrameClaimTypes.UserId, token.Owner.Id.ToString()) }; diff --git a/src/HopFrame.Web/Services/Implementation/AuthService.cs b/src/HopFrame.Web/Services/Implementation/AuthService.cs index 6fca234..7bc38a4 100644 --- a/src/HopFrame.Web/Services/Implementation/AuthService.cs +++ b/src/HopFrame.Web/Services/Implementation/AuthService.cs @@ -28,12 +28,12 @@ internal class AuthService( var refreshToken = await tokens.CreateToken(Token.RefreshTokenType, user); var accessToken = await tokens.CreateToken(Token.AccessTokenType, user); - httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.Content.ToString(), new CookieOptions { + httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.TokenId.ToString(), new CookieOptions { MaxAge = options.Value.RefreshTokenTime, HttpOnly = true, Secure = true }); - httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Content.ToString(), new CookieOptions { + httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.TokenId.ToString(), new CookieOptions { MaxAge = options.Value.AccessTokenTime, HttpOnly = false, Secure = true @@ -49,12 +49,12 @@ internal class AuthService( var refreshToken = await tokens.CreateToken(Token.RefreshTokenType, user); var accessToken = await tokens.CreateToken(Token.AccessTokenType, user); - httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.Content.ToString(), new CookieOptions { + httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.TokenId.ToString(), new CookieOptions { MaxAge = options.Value.RefreshTokenTime, HttpOnly = true, Secure = true }); - httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Content.ToString(), new CookieOptions { + httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.TokenId.ToString(), new CookieOptions { MaxAge = options.Value.AccessTokenTime, HttpOnly = false, Secure = true @@ -83,7 +83,7 @@ internal class AuthService( var accessToken = await tokens.CreateToken(Token.AccessTokenType, token.Owner); - httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Content.ToString(), new CookieOptions { + httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.TokenId.ToString(), new CookieOptions { MaxAge = options.Value.AccessTokenTime, HttpOnly = false, Secure = true diff --git a/tests/HopFrame.Tests.Api/AuthLogicTests.cs b/tests/HopFrame.Tests.Api/AuthLogicTests.cs index a5163d2..39975f5 100644 --- a/tests/HopFrame.Tests.Api/AuthLogicTests.cs +++ b/tests/HopFrame.Tests.Api/AuthLogicTests.cs @@ -58,13 +58,13 @@ public class AuthLogicTests { tokens .Setup(t => t.CreateToken(It.Is(t => t == Token.RefreshTokenType), It.IsAny())) .ReturnsAsync(new Token { - Content = _refreshToken, + TokenId = _refreshToken, Type = Token.RefreshTokenType }); tokens .Setup(t => t.CreateToken(It.Is(t => t == Token.AccessTokenType), It.IsAny())) .ReturnsAsync(new Token { - Content = _accessToken, + TokenId = _accessToken, Type = Token.AccessTokenType }); tokens @@ -229,11 +229,11 @@ public class AuthLogicTests { // Arrange var token = new Token { Type = Token.RefreshTokenType, - Content = _refreshToken, + TokenId = _refreshToken, CreatedAt = DateTime.Now, Owner = CreateDummyUser() }; - var (auth, context) = SetupEnvironment(true, token, token.Content.ToString()); + var (auth, context) = SetupEnvironment(true, token, token.TokenId.ToString()); // Act var result = await auth.Authenticate(); @@ -277,11 +277,11 @@ public class AuthLogicTests { // Arrange var token = new Token { Type = Token.AccessTokenType, - Content = _refreshToken, + TokenId = _refreshToken, CreatedAt = DateTime.Now, Owner = CreateDummyUser() }; - var (auth, context) = SetupEnvironment(true, token, token.Content.ToString()); + var (auth, context) = SetupEnvironment(true, token, token.TokenId.ToString()); // Act var result = await auth.Authenticate(); @@ -297,11 +297,11 @@ public class AuthLogicTests { // Arrange var token = new Token { Type = Token.RefreshTokenType, - Content = _refreshToken, + TokenId = _refreshToken, CreatedAt = DateTime.MinValue, Owner = CreateDummyUser() }; - var (auth, context) = SetupEnvironment(true, token, token.Content.ToString()); + var (auth, context) = SetupEnvironment(true, token, token.TokenId.ToString()); // Act var result = await auth.Authenticate(); diff --git a/tests/HopFrame.Tests.Database/Repositories/TokenRepositoryTests.cs b/tests/HopFrame.Tests.Database/Repositories/TokenRepositoryTests.cs index 83dc770..d37fde2 100644 --- a/tests/HopFrame.Tests.Database/Repositories/TokenRepositoryTests.cs +++ b/tests/HopFrame.Tests.Database/Repositories/TokenRepositoryTests.cs @@ -14,7 +14,7 @@ public class TokenRepositoryTests { for (int i = 0; i < count; i++) { await context.Tokens.AddAsync(new() { - Content = Guid.NewGuid(), + TokenId = Guid.NewGuid(), Owner = CreateTestUser(), Type = Token.AccessTokenType }); @@ -37,7 +37,7 @@ public class TokenRepositoryTests { var token = context.Tokens.First(); // Act - var result = await repo.GetToken(token.Content.ToString()); + var result = await repo.GetToken(token.TokenId.ToString()); // Assert Assert.Equal(token, result); @@ -64,12 +64,12 @@ public class TokenRepositoryTests { var user = CreateTestUser(); await context.Tokens.AddRangeAsync(new List { new() { - Content = Guid.NewGuid(), + TokenId = Guid.NewGuid(), Owner = user, Type = Token.AccessTokenType }, new() { - Content = Guid.NewGuid(), + TokenId = Guid.NewGuid(), Owner = user, Type = Token.RefreshTokenType } diff --git a/tests/HopFrame.Tests.Security/AuthenticationTests.cs b/tests/HopFrame.Tests.Security/AuthenticationTests.cs index 5cd6d44..5a00df9 100644 --- a/tests/HopFrame.Tests.Security/AuthenticationTests.cs +++ b/tests/HopFrame.Tests.Security/AuthenticationTests.cs @@ -30,7 +30,7 @@ public class AuthenticationTests { var provideCorrectToken = correctToken is null; correctToken ??= new Token { - Content = Guid.NewGuid(), + TokenId = Guid.NewGuid(), CreatedAt = DateTime.Now, Type = Token.AccessTokenType, Owner = new User { @@ -39,7 +39,7 @@ public class AuthenticationTests { }; tokens - .Setup(x => x.GetToken(It.Is(t => t == correctToken.Content.ToString()))) + .Setup(x => x.GetToken(It.Is(t => t == correctToken.TokenId.ToString()))) .ReturnsAsync(correctToken); perms @@ -49,7 +49,7 @@ public class AuthenticationTests { var auth = new HopFrameAuthentication(options.Object, logger.Object, encoder.Object, clock.Object, tokens.Object, perms.Object, new OptionsWrapper(new HopFrameAuthenticationOptions())); var context = new DefaultHttpContext(); if (provideCorrectToken) - context.HttpContext.Request.Headers.Append(HopFrameAuthentication.SchemeName, correctToken.Content.ToString()); + context.HttpContext.Request.Headers.Append(HopFrameAuthentication.SchemeName, correctToken.TokenId.ToString()); if (providedToken is not null) context.HttpContext.Request.Headers.Append(HopFrameAuthentication.SchemeName, providedToken); @@ -101,12 +101,12 @@ public class AuthenticationTests { public async Task Authentication_With_ExpiredToken_Should_Fail() { // Arrange var token = new Token { - Content = Guid.NewGuid(), + TokenId = Guid.NewGuid(), CreatedAt = DateTime.MinValue, Type = Token.AccessTokenType, Owner = new User() }; - var auth = await SetupEnvironment(token, token.Content.ToString()); + var auth = await SetupEnvironment(token, token.TokenId.ToString()); // Act var result = await auth.AuthenticateAsync(); @@ -121,12 +121,12 @@ public class AuthenticationTests { public async Task Authentication_With_UnownedToken_Should_Fail() { // Arrange var token = new Token { - Content = Guid.NewGuid(), + TokenId = Guid.NewGuid(), CreatedAt = DateTime.Now, Type = Token.AccessTokenType, Owner = null }; - var auth = await SetupEnvironment(token, token.Content.ToString()); + var auth = await SetupEnvironment(token, token.TokenId.ToString()); // Act var result = await auth.AuthenticateAsync(); diff --git a/tests/HopFrame.Tests.Web/AuthMiddlewareTests.cs b/tests/HopFrame.Tests.Web/AuthMiddlewareTests.cs index d9e136f..bada100 100644 --- a/tests/HopFrame.Tests.Web/AuthMiddlewareTests.cs +++ b/tests/HopFrame.Tests.Web/AuthMiddlewareTests.cs @@ -61,7 +61,7 @@ public class AuthMiddlewareTests { public async Task InvokeAsync_With_InvalidLoginValidToken_Should_Succeed() { // Arrange var token = new Token { - Content = Guid.NewGuid(), + TokenId = Guid.NewGuid(), CreatedAt = DateTime.Now, Type = Token.AccessTokenType, Owner = CreateDummyUser() @@ -74,7 +74,7 @@ public class AuthMiddlewareTests { // Assert Assert.Equal(token.Owner.Id.ToString(), context.User.FindFirstValue(HopFrameClaimTypes.UserId)); - Assert.Equal(token.Content.ToString(), context.User.FindFirstValue(HopFrameClaimTypes.AccessTokenId)); + Assert.Equal(token.TokenId.ToString(), context.User.FindFirstValue(HopFrameClaimTypes.AccessTokenId)); Assert.Equal(token.Owner.Permissions.First().PermissionName, context.User.FindFirstValue(HopFrameClaimTypes.Permission)); } diff --git a/tests/HopFrame.Tests.Web/AuthServiceTests.cs b/tests/HopFrame.Tests.Web/AuthServiceTests.cs index d5c5ad7..306a94b 100644 --- a/tests/HopFrame.Tests.Web/AuthServiceTests.cs +++ b/tests/HopFrame.Tests.Web/AuthServiceTests.cs @@ -47,13 +47,13 @@ public class AuthServiceTests { tokens .Setup(t => t.CreateToken(It.Is(t => t == Token.RefreshTokenType), It.IsAny())) .ReturnsAsync(new Token { - Content = _refreshToken, + TokenId = _refreshToken, Type = Token.RefreshTokenType }); tokens .Setup(t => t.CreateToken(It.Is(t => t == Token.AccessTokenType), It.IsAny())) .ReturnsAsync(new Token { - Content = _accessToken, + TokenId = _accessToken, Type = Token.AccessTokenType }); tokens @@ -171,18 +171,18 @@ public class AuthServiceTests { // Arrange var token = new Token { Type = Token.RefreshTokenType, - Content = _refreshToken, + TokenId = _refreshToken, CreatedAt = DateTime.Now, Owner = CreateDummyUser() }; - var (service, context) = SetupEnvironment(true, token, token.Content.ToString()); + var (service, context) = SetupEnvironment(true, token, token.TokenId.ToString()); // Act var result = await service.RefreshLogin(); // Assert Assert.NotNull(result); - Assert.Equal(_accessToken, result.Content); + Assert.Equal(_accessToken, result.TokenId); Assert.Equal(_accessToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); } @@ -217,11 +217,11 @@ public class AuthServiceTests { // Arrange var token = new Token { Type = Token.AccessTokenType, - Content = _refreshToken, + TokenId = _refreshToken, CreatedAt = DateTime.Now, Owner = CreateDummyUser() }; - var (service, context) = SetupEnvironment(true, token, token.Content.ToString()); + var (service, context) = SetupEnvironment(true, token, token.TokenId.ToString()); // Act var result = await service.RefreshLogin(); @@ -236,11 +236,11 @@ public class AuthServiceTests { // Arrange var token = new Token { Type = Token.RefreshTokenType, - Content = _refreshToken, + TokenId = _refreshToken, CreatedAt = DateTime.MinValue, Owner = CreateDummyUser() }; - var (service, context) = SetupEnvironment(true, token, token.Content.ToString()); + var (service, context) = SetupEnvironment(true, token, token.TokenId.ToString()); // Act var result = await service.RefreshLogin(); @@ -255,7 +255,7 @@ public class AuthServiceTests { // Arrange var token = new Token { Type = Token.AccessTokenType, - Content = _accessToken, + TokenId = _accessToken, CreatedAt = DateTime.Now, Owner = CreateDummyUser() }; @@ -285,7 +285,7 @@ public class AuthServiceTests { // Arrange var token = new Token { Type = Token.RefreshTokenType, - Content = _accessToken, + TokenId = _accessToken, CreatedAt = DateTime.Now, Owner = CreateDummyUser() }; @@ -303,7 +303,7 @@ public class AuthServiceTests { // Arrange var token = new Token { Type = Token.AccessTokenType, - Content = _accessToken, + TokenId = _accessToken, CreatedAt = DateTime.MinValue, Owner = CreateDummyUser() }; @@ -321,7 +321,7 @@ public class AuthServiceTests { // Arrange var token = new Token { Type = Token.AccessTokenType, - Content = _accessToken, + TokenId = _accessToken, CreatedAt = DateTime.Now, Owner = null }; From 59c452ff73559cd40376fd2d9fa0887269eae0f7 Mon Sep 17 00:00:00 2001 From: Leon Hoppe Date: Sat, 21 Dec 2024 16:55:20 +0100 Subject: [PATCH 18/27] updated application to check for contextual permissions --- .idea/.idea.HopFrame/.idea/discord.xml | 14 ++++++++++++ HopFrame.sln.DotSettings.user | 1 + .../Repositories/ITokenRepository.cs | 1 + .../Implementation/PermissionRepository.cs | 8 +++++-- .../Implementation/TokenRepository.cs | 5 +++++ .../Authentication/HopFrameAuthentication.cs | 10 +-------- src/HopFrame.Security/Claims/ITokenContext.cs | 2 ++ .../Claims/TokenContextImplementor.cs | 4 +++- src/HopFrame.Web/AuthMiddleware.cs | 2 +- .../Administration/AdminPageModal.razor | 4 ++-- .../Pages/Administration/AdminPageList.razor | 4 ++-- .../Controllers/TestController.cs | 22 ++++++++++++++++--- testing/HopFrame.Testing.Api/Program.cs | 2 +- 13 files changed, 58 insertions(+), 21 deletions(-) create mode 100644 .idea/.idea.HopFrame/.idea/discord.xml diff --git a/.idea/.idea.HopFrame/.idea/discord.xml b/.idea/.idea.HopFrame/.idea/discord.xml new file mode 100644 index 0000000..912db82 --- /dev/null +++ b/.idea/.idea.HopFrame/.idea/discord.xml @@ -0,0 +1,14 @@ + + + + + \ No newline at end of file diff --git a/HopFrame.sln.DotSettings.user b/HopFrame.sln.DotSettings.user index 86d9f80..eab4e1d 100644 --- a/HopFrame.sln.DotSettings.user +++ b/HopFrame.sln.DotSettings.user @@ -70,6 +70,7 @@ + \ No newline at end of file diff --git a/src/HopFrame.Database/Repositories/ITokenRepository.cs b/src/HopFrame.Database/Repositories/ITokenRepository.cs index 5f66769..2c1192c 100644 --- a/src/HopFrame.Database/Repositories/ITokenRepository.cs +++ b/src/HopFrame.Database/Repositories/ITokenRepository.cs @@ -6,5 +6,6 @@ public interface ITokenRepository { Task GetToken(string content); Task CreateToken(int type, User owner); Task DeleteUserTokens(User owner); + Task DeleteToken(Token token); Task CreateApiToken(User owner, DateTime expirationDate); } \ No newline at end of file diff --git a/src/HopFrame.Database/Repositories/Implementation/PermissionRepository.cs b/src/HopFrame.Database/Repositories/Implementation/PermissionRepository.cs index f80b0b8..6d55bc0 100644 --- a/src/HopFrame.Database/Repositories/Implementation/PermissionRepository.cs +++ b/src/HopFrame.Database/Repositories/Implementation/PermissionRepository.cs @@ -69,6 +69,10 @@ internal sealed class PermissionRepository(TDbContext context, IGrou public async Task> GetFullPermissions(IPermissionOwner owner) { var permissions = new List(); + + if (owner is Token token && token.Type != Token.ApiTokenType) { + owner = token.Owner; + } if (owner is User user) { var perms = await context.Permissions @@ -86,11 +90,11 @@ internal sealed class PermissionRepository(TDbContext context, IGrou .ToListAsync(); permissions.AddRange(perms.Select(p => p.PermissionName)); - }else if (owner is Token token) { + }else if (owner is Token apiToken) { var perms = await context.Permissions .Include(p => p.Token) .Where(p => p.Token != null) - .Where(p =>p.Token.TokenId == token.TokenId) + .Where(p =>p.Token.TokenId == apiToken.TokenId) .ToListAsync(); permissions.AddRange(perms.Select(p => p.PermissionName)); diff --git a/src/HopFrame.Database/Repositories/Implementation/TokenRepository.cs b/src/HopFrame.Database/Repositories/Implementation/TokenRepository.cs index 927d080..b44dc43 100644 --- a/src/HopFrame.Database/Repositories/Implementation/TokenRepository.cs +++ b/src/HopFrame.Database/Repositories/Implementation/TokenRepository.cs @@ -39,6 +39,11 @@ internal sealed class TokenRepository(TDbContext context) : ITokenRe await context.SaveChangesAsync(); } + public async Task DeleteToken(Token token) { + context.Tokens.Remove(token); + await context.SaveChangesAsync(); + } + public async Task CreateApiToken(User owner, DateTime expirationDate) { var token = new Token { CreatedAt = expirationDate, diff --git a/src/HopFrame.Security/Authentication/HopFrameAuthentication.cs b/src/HopFrame.Security/Authentication/HopFrameAuthentication.cs index 9f9af47..88a95c1 100644 --- a/src/HopFrame.Security/Authentication/HopFrameAuthentication.cs +++ b/src/HopFrame.Security/Authentication/HopFrameAuthentication.cs @@ -47,15 +47,7 @@ public class HopFrameAuthentication( new(HopFrameClaimTypes.UserId, tokenEntry.Owner.Id.ToString()) }; - IList permissions; - - if (tokenEntry.Type == Token.ApiTokenType) { - permissions = await perms.GetFullPermissions(tokenEntry); - } - else { - permissions = await perms.GetFullPermissions(tokenEntry.Owner); - } - + var permissions = await perms.GetFullPermissions(tokenEntry); claims.AddRange(permissions.Select(perm => new Claim(HopFrameClaimTypes.Permission, perm))); var principal = new ClaimsPrincipal(); diff --git a/src/HopFrame.Security/Claims/ITokenContext.cs b/src/HopFrame.Security/Claims/ITokenContext.cs index 6b5a590..6b052bc 100644 --- a/src/HopFrame.Security/Claims/ITokenContext.cs +++ b/src/HopFrame.Security/Claims/ITokenContext.cs @@ -21,4 +21,6 @@ public interface ITokenContext { /// The access token the user provided /// Token AccessToken { get; } + + IList ContextualPermissions { get; } } \ No newline at end of file diff --git a/src/HopFrame.Security/Claims/TokenContextImplementor.cs b/src/HopFrame.Security/Claims/TokenContextImplementor.cs index dd50a08..47fce76 100644 --- a/src/HopFrame.Security/Claims/TokenContextImplementor.cs +++ b/src/HopFrame.Security/Claims/TokenContextImplementor.cs @@ -4,10 +4,12 @@ using Microsoft.AspNetCore.Http; namespace HopFrame.Security.Claims; -internal sealed class TokenContextImplementor(IHttpContextAccessor accessor, IUserRepository users, ITokenRepository tokens) : ITokenContext { +internal sealed class TokenContextImplementor(IHttpContextAccessor accessor, IUserRepository users, ITokenRepository tokens, IPermissionRepository permissions) : ITokenContext { public bool IsAuthenticated => !string.IsNullOrEmpty(accessor.HttpContext?.User.GetAccessTokenId()); public User User => users.GetUser(Guid.Parse(accessor.HttpContext?.User.GetUserId() ?? Guid.Empty.ToString())).GetAwaiter().GetResult(); public Token AccessToken => tokens.GetToken(accessor.HttpContext?.User.GetAccessTokenId()).GetAwaiter().GetResult(); + + public IList ContextualPermissions => permissions.GetFullPermissions(AccessToken).GetAwaiter().GetResult(); } \ No newline at end of file diff --git a/src/HopFrame.Web/AuthMiddleware.cs b/src/HopFrame.Web/AuthMiddleware.cs index ac5c954..b5fbc93 100644 --- a/src/HopFrame.Web/AuthMiddleware.cs +++ b/src/HopFrame.Web/AuthMiddleware.cs @@ -26,7 +26,7 @@ public sealed class AuthMiddleware(IAuthService auth, IPermissionRepository perm new(HopFrameClaimTypes.UserId, token.Owner.Id.ToString()) }; - var permissions = await perms.GetFullPermissions(token.Owner); + var permissions = await perms.GetFullPermissions(token); claims.AddRange(permissions.Select(perm => new Claim(HopFrameClaimTypes.Permission, perm))); context.User.AddIdentity(new ClaimsIdentity(claims, HopFrameAuthentication.SchemeName)); diff --git a/src/HopFrame.Web/Components/Administration/AdminPageModal.razor b/src/HopFrame.Web/Components/Administration/AdminPageModal.razor index 4876323..4e212b5 100644 --- a/src/HopFrame.Web/Components/Administration/AdminPageModal.razor +++ b/src/HopFrame.Web/Components/Administration/AdminPageModal.razor @@ -321,7 +321,7 @@ private async void Save() { if (_isEdit && _currentPage.Permissions.Update is not null) { - if (!await Permissions.HasPermission(Auth.User, _currentPage.Permissions.Update)) { + if (!await Permissions.HasPermission(Auth.AccessToken, _currentPage.Permissions.Update)) { await Alerts.FireAsync(new SweetAlertOptions { Title = "Unauthorized!", Text = "You don't have the required permissions to edit an entry!", @@ -330,7 +330,7 @@ return; } }else if (_currentPage.Permissions.Create is not null) { - if (!await Permissions.HasPermission(Auth.User, _currentPage.Permissions.Create)) { + if (!await Permissions.HasPermission(Auth.AccessToken, _currentPage.Permissions.Create)) { await Alerts.FireAsync(new SweetAlertOptions { Title = "Unauthorized!", Text = "You don't have the required permissions to add an entry!", diff --git a/src/HopFrame.Web/Pages/Administration/AdminPageList.razor b/src/HopFrame.Web/Pages/Administration/AdminPageList.razor index d796aeb..1086918 100644 --- a/src/HopFrame.Web/Pages/Administration/AdminPageList.razor +++ b/src/HopFrame.Web/Pages/Administration/AdminPageList.razor @@ -140,8 +140,8 @@ throw new ArgumentException($"AdminPage '{_pageData.Title}' does not specify a model repository!'"); _modelProvider = _pageData.LoadModelProvider(Provider); - _hasEditPermission = _pageData.Permissions.Update is null || await Permissions.HasPermission(Auth.User, _pageData.Permissions.Update); - _hasDeletePermission = _pageData.Permissions.Delete is null || await Permissions.HasPermission(Auth.User, _pageData.Permissions.Delete); + _hasEditPermission = _pageData.Permissions.Update is null || await Permissions.HasPermission(Auth.AccessToken, _pageData.Permissions.Update); + _hasDeletePermission = _pageData.Permissions.Delete is null || await Permissions.HasPermission(Auth.AccessToken, _pageData.Permissions.Delete); await Reload(); } diff --git a/testing/HopFrame.Testing.Api/Controllers/TestController.cs b/testing/HopFrame.Testing.Api/Controllers/TestController.cs index fb39666..d097592 100644 --- a/testing/HopFrame.Testing.Api/Controllers/TestController.cs +++ b/testing/HopFrame.Testing.Api/Controllers/TestController.cs @@ -1,5 +1,7 @@ using HopFrame.Api.Logic; +using HopFrame.Api.Models; using HopFrame.Database.Models; +using HopFrame.Database.Repositories; using HopFrame.Security.Authorization; using HopFrame.Security.Claims; using HopFrame.Testing.Api.Models; @@ -10,11 +12,11 @@ namespace HopFrame.Testing.Api.Controllers; [ApiController] [Route("test")] -public class TestController(ITokenContext userContext, DatabaseContext context) : ControllerBase { +public class TestController(ITokenContext userContext, DatabaseContext context, ITokenRepository tokens, IPermissionRepository permissions) : ControllerBase { [HttpGet("permissions"), Authorized] - public ActionResult> Permissions() { - return new ActionResult>(userContext.User.Permissions); + public ActionResult> Permissions() { + return new ActionResult>(userContext.ContextualPermissions); } [HttpGet("generate")] @@ -50,5 +52,19 @@ public class TestController(ITokenContext userContext, DatabaseContext context) public async Task>> GetAddresses() { return LogicResult>.Ok(await context.Addresses.Include(e => e.Employee).ToListAsync()); } + + [HttpGet("token"), Authorized] + public async Task>> GetApiToken() { + var token = await tokens.CreateApiToken(userContext.User, DateTime.MaxValue); + await permissions.AddPermission(token, "hopframe.admin"); + await permissions.AddPermission(token, "hopframe.admin.users.read"); + return LogicResult>.Ok(token.TokenId.ToString()); + } + + [HttpDelete("token/{tokenId}")] + public async Task DeleteToken(string tokenId) { + var token = await tokens.GetToken(tokenId); + await tokens.DeleteToken(token); + } } \ No newline at end of file diff --git a/testing/HopFrame.Testing.Api/Program.cs b/testing/HopFrame.Testing.Api/Program.cs index b728eb3..948be0d 100644 --- a/testing/HopFrame.Testing.Api/Program.cs +++ b/testing/HopFrame.Testing.Api/Program.cs @@ -18,7 +18,7 @@ builder.Services.AddSwaggerGen(c => { c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme { Description = @"JWT Authorization header using the Bearer scheme. \r\n\r\n Enter 'Bearer' [space] and then your token in the text input below.", - Name = "Authorization", + Name = "Token", In = ParameterLocation.Header, Type = SecuritySchemeType.ApiKey, Scheme = "Bearer" From e47d4917df393ce69519142d7241bc45de2426d8 Mon Sep 17 00:00:00 2001 From: Leon Hoppe Date: Sat, 21 Dec 2024 17:13:18 +0100 Subject: [PATCH 19/27] Added api key documentation + fixed tests --- HopFrame.sln.DotSettings.user | 2 ++ docs/authentication.md | 27 +++++++++++++++++-- docs/models.md | 4 ++- docs/repositories.md | 4 +++ .../AuthenticationTests.cs | 2 +- .../HopFrame.Tests.Web/AuthMiddlewareTests.cs | 2 +- 6 files changed, 36 insertions(+), 5 deletions(-) diff --git a/HopFrame.sln.DotSettings.user b/HopFrame.sln.DotSettings.user index eab4e1d..0d4f6c6 100644 --- a/HopFrame.sln.DotSettings.user +++ b/HopFrame.sln.DotSettings.user @@ -1,4 +1,5 @@  + ForceIncluded ForceIncluded ForceIncluded ForceIncluded @@ -71,6 +72,7 @@ + \ No newline at end of file diff --git a/docs/authentication.md b/docs/authentication.md index 469ceee..f79c51e 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -23,8 +23,6 @@ by configuring your configuration to load these. > `builder.Configuration.AddEnvironmentVariables();` to your startup configuration before you add the > custom configurations / HopFrame services. -### Example - You can specify `Seconds`, `Minutes`, `Hours` and `Days` for either of the two token types. These get combined to a single time span. @@ -49,3 +47,28 @@ HOPFRAME__AUTHENTICATION__ACCESSTOKEN__MINUTES=30 HOPFRAME__AUTHENTICATION__REFRESHTOKEN__DAYS=10 HOPFRAME__AUTHENTICATION__REFRESHTOKEN__HOURS=5 ``` + +## API tokens + +API tokens are useful to use in automation environments that need to access an endpoint or page of your application. +The HopFrame supports this natively and no further configuration is required in order to use them. + +### Create an api token + +You can create an api token via the `ITokenRepository`: +```csharp +tokens.CreateApiToken(user, DateTime.MaxValue); +``` + +This creates a new api token that is valid until the provided DateTime has passed. Note that in the database and the token +model the `CreatedAt` property represents the expiration date on an api token. For security reasons the api token by default +has no permissions. This allows you to create tokens that are just permitted to perform a single action. Note that a token +associated to a user can also have more permissions than the user itself so make sure to properly secure the creation process. + +### Add permissions to an api token + +You can add permissions to an api token like you would to a normal user or group: + +```csharp +permissions.AddPermission(apiToken, "token.permission"); +``` diff --git a/docs/models.md b/docs/models.md index 39ecc99..7f61e86 100644 --- a/docs/models.md +++ b/docs/models.md @@ -35,16 +35,18 @@ public class Permission { public DateTime GrantedAt { get; set; } public virtual User User { get; set; } public virtual PermissionGroup Group { get; set; } + public virtual Token Token { get; set; } } ``` ## Token ```csharp -public class Token { +public class Token : IPermissionOwner { public int Type { get; set; } public Guid Content { get; set; } public DateTime CreatedAt { get; set; } public virtual User Owner { get; set; } + public virtual List Permissions { get; set; } } ``` diff --git a/docs/repositories.md b/docs/repositories.md index 25cb4ac..f72d876 100644 --- a/docs/repositories.md +++ b/docs/repositories.md @@ -71,5 +71,9 @@ public interface ITokenRepository { Task CreateToken(int type, User owner); Task DeleteUserTokens(User owner); + + Task DeleteToken(Token token); + + Task CreateApiToken(User owner, DateTime expirationDate); } ``` diff --git a/tests/HopFrame.Tests.Security/AuthenticationTests.cs b/tests/HopFrame.Tests.Security/AuthenticationTests.cs index 5a00df9..17e3d1d 100644 --- a/tests/HopFrame.Tests.Security/AuthenticationTests.cs +++ b/tests/HopFrame.Tests.Security/AuthenticationTests.cs @@ -43,7 +43,7 @@ public class AuthenticationTests { .ReturnsAsync(correctToken); perms - .Setup(x => x.GetFullPermissions(It.IsAny())) + .Setup(x => x.GetFullPermissions(It.IsAny())) .ReturnsAsync(new List()); var auth = new HopFrameAuthentication(options.Object, logger.Object, encoder.Object, clock.Object, tokens.Object, perms.Object, new OptionsWrapper(new HopFrameAuthenticationOptions())); diff --git a/tests/HopFrame.Tests.Web/AuthMiddlewareTests.cs b/tests/HopFrame.Tests.Web/AuthMiddlewareTests.cs index bada100..685e588 100644 --- a/tests/HopFrame.Tests.Web/AuthMiddlewareTests.cs +++ b/tests/HopFrame.Tests.Web/AuthMiddlewareTests.cs @@ -23,7 +23,7 @@ public class AuthMiddlewareTests { var perms = new Mock(); perms - .Setup(p => p.GetFullPermissions(It.Is(u => newToken.Owner.Id == u.Id))) + .Setup(p => p.GetFullPermissions(It.Is(u => newToken.Owner.Id == u.Owner.Id))) .ReturnsAsync(CreateDummyUser().Permissions.Select(p => p.PermissionName).ToList); return new AuthMiddleware(auth.Object, perms.Object); From c6aca4baf6426b8620d5d76a7840ad4db67052c6 Mon Sep 17 00:00:00 2001 From: Leon Hoppe Date: Sat, 21 Dec 2024 17:35:11 +0100 Subject: [PATCH 20/27] secured api tokens against permission breaches --- docs/authentication.md | 4 ++-- src/HopFrame.Database/Repositories/ITokenRepository.cs | 2 +- .../Repositories/Implementation/PermissionRepository.cs | 6 ++++++ .../Repositories/Implementation/TokenRepository.cs | 7 ++++++- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/docs/authentication.md b/docs/authentication.md index f79c51e..c3489d0 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -62,8 +62,8 @@ tokens.CreateApiToken(user, DateTime.MaxValue); This creates a new api token that is valid until the provided DateTime has passed. Note that in the database and the token model the `CreatedAt` property represents the expiration date on an api token. For security reasons the api token by default -has no permissions. This allows you to create tokens that are just permitted to perform a single action. Note that a token -associated to a user can also have more permissions than the user itself so make sure to properly secure the creation process. +has no permissions. This allows you to create tokens that are just permitted to perform a single action. Note that an api token +can **never** have more permissions than the user associated with it. ### Add permissions to an api token diff --git a/src/HopFrame.Database/Repositories/ITokenRepository.cs b/src/HopFrame.Database/Repositories/ITokenRepository.cs index 2c1192c..9447994 100644 --- a/src/HopFrame.Database/Repositories/ITokenRepository.cs +++ b/src/HopFrame.Database/Repositories/ITokenRepository.cs @@ -5,7 +5,7 @@ namespace HopFrame.Database.Repositories; public interface ITokenRepository { Task GetToken(string content); Task CreateToken(int type, User owner); - Task DeleteUserTokens(User owner); + Task DeleteUserTokens(User owner, bool includeApiTokens = false); Task DeleteToken(Token token); Task CreateApiToken(User owner, DateTime expirationDate); } \ No newline at end of file diff --git a/src/HopFrame.Database/Repositories/Implementation/PermissionRepository.cs b/src/HopFrame.Database/Repositories/Implementation/PermissionRepository.cs index 6d55bc0..3156361 100644 --- a/src/HopFrame.Database/Repositories/Implementation/PermissionRepository.cs +++ b/src/HopFrame.Database/Repositories/Implementation/PermissionRepository.cs @@ -5,6 +5,10 @@ namespace HopFrame.Database.Repositories.Implementation; internal sealed class PermissionRepository(TDbContext context, IGroupRepository groupRepository) : IPermissionRepository where TDbContext : HopDbContextBase { public async Task HasPermission(IPermissionOwner owner, params string[] permissions) { + if (owner is Token { Type: Token.ApiTokenType } token) { + if (!await HasPermission(token.Owner, permissions)) return false; + } + var perms = (await GetFullPermissions(owner)).ToArray(); foreach (var permission in permissions) { @@ -27,6 +31,8 @@ internal sealed class PermissionRepository(TDbContext context, IGrou }else if (owner is Token token) { if (token.Type != Token.ApiTokenType) throw new ArgumentException("Only API tokens can have permissions!"); + if (!await HasPermission(token.Owner, permission)) + throw new ArgumentException("An api token cannot have more permissions than the owner has!"); entry.Token = token; } diff --git a/src/HopFrame.Database/Repositories/Implementation/TokenRepository.cs b/src/HopFrame.Database/Repositories/Implementation/TokenRepository.cs index b44dc43..29deaab 100644 --- a/src/HopFrame.Database/Repositories/Implementation/TokenRepository.cs +++ b/src/HopFrame.Database/Repositories/Implementation/TokenRepository.cs @@ -29,11 +29,16 @@ internal sealed class TokenRepository(TDbContext context) : ITokenRe return token; } - public async Task DeleteUserTokens(User owner) { + public async Task DeleteUserTokens(User owner, bool includeApiTokens = false) { var tokens = await context.Tokens .Include(t => t.Owner) .Where(t => t.Owner.Id == owner.Id) .ToListAsync(); + + if (!includeApiTokens) + tokens = tokens + .Where(t => t.Type != Token.ApiTokenType) + .ToList(); context.Tokens.RemoveRange(tokens); await context.SaveChangesAsync(); From ba7584c771604d3b5fbf77dee9f801921c031bae Mon Sep 17 00:00:00 2001 From: Leon Hoppe Date: Sat, 21 Dec 2024 22:35:04 +0100 Subject: [PATCH 21/27] Added OpenID authentication method --- HopFrame.sln.DotSettings.user | 1 + src/HopFrame.Database/Models/Token.cs | 1 + .../Implementation/TokenRepository.cs | 1 + .../Authentication/HopFrameAuthentication.cs | 53 +++++++++++++-- .../HopFrameAuthenticationExtensions.cs | 7 ++ .../Authentication/OpenID/IOpenIdAccessor.cs | 10 +++ .../OpenID/Implementation/OpenIdAccessor.cs | 64 +++++++++++++++++ .../OpenID/Models/OpenIdConfiguration.cs | 68 +++++++++++++++++++ .../OpenID/Models/OpenIdIntrospection.cs | 62 +++++++++++++++++ .../OpenID/Models/OpenIdToken.cs | 17 +++++ .../OpenID/Options/OpenIdOptions.cs | 15 ++++ src/HopFrame.Security/Claims/ITokenContext.cs | 2 - .../Claims/TokenContextImplementor.cs | 12 ++-- .../Controllers/AuthController.cs | 28 ++++++++ .../Controllers/TestController.cs | 4 +- testing/HopFrame.Testing.Api/Program.cs | 1 + .../AuthenticationTests.cs | 14 +++- 17 files changed, 345 insertions(+), 15 deletions(-) create mode 100644 src/HopFrame.Security/Authentication/OpenID/IOpenIdAccessor.cs create mode 100644 src/HopFrame.Security/Authentication/OpenID/Implementation/OpenIdAccessor.cs create mode 100644 src/HopFrame.Security/Authentication/OpenID/Models/OpenIdConfiguration.cs create mode 100644 src/HopFrame.Security/Authentication/OpenID/Models/OpenIdIntrospection.cs create mode 100644 src/HopFrame.Security/Authentication/OpenID/Models/OpenIdToken.cs create mode 100644 src/HopFrame.Security/Authentication/OpenID/Options/OpenIdOptions.cs create mode 100644 testing/HopFrame.Testing.Api/Controllers/AuthController.cs diff --git a/HopFrame.sln.DotSettings.user b/HopFrame.sln.DotSettings.user index 0d4f6c6..88abaf3 100644 --- a/HopFrame.sln.DotSettings.user +++ b/HopFrame.sln.DotSettings.user @@ -5,6 +5,7 @@ ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded <AssemblyExplorer> diff --git a/src/HopFrame.Database/Models/Token.cs b/src/HopFrame.Database/Models/Token.cs index b22bd21..f091123 100644 --- a/src/HopFrame.Database/Models/Token.cs +++ b/src/HopFrame.Database/Models/Token.cs @@ -8,6 +8,7 @@ public class Token : IPermissionOwner { public const int RefreshTokenType = 0; public const int AccessTokenType = 1; public const int ApiTokenType = 2; + public const int OpenIdTokenType = 3; /// /// Defines the Type of the stored Token diff --git a/src/HopFrame.Database/Repositories/Implementation/TokenRepository.cs b/src/HopFrame.Database/Repositories/Implementation/TokenRepository.cs index 29deaab..b02d2fb 100644 --- a/src/HopFrame.Database/Repositories/Implementation/TokenRepository.cs +++ b/src/HopFrame.Database/Repositories/Implementation/TokenRepository.cs @@ -1,5 +1,6 @@ using HopFrame.Database.Models; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; namespace HopFrame.Database.Repositories.Implementation; diff --git a/src/HopFrame.Security/Authentication/HopFrameAuthentication.cs b/src/HopFrame.Security/Authentication/HopFrameAuthentication.cs index 88a95c1..f0dff7e 100644 --- a/src/HopFrame.Security/Authentication/HopFrameAuthentication.cs +++ b/src/HopFrame.Security/Authentication/HopFrameAuthentication.cs @@ -2,6 +2,8 @@ using System.Security.Claims; using System.Text.Encodings.Web; using HopFrame.Database.Models; using HopFrame.Database.Repositories; +using HopFrame.Security.Authentication.OpenID; +using HopFrame.Security.Authentication.OpenID.Options; using HopFrame.Security.Claims; using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Logging; @@ -19,7 +21,10 @@ public class HopFrameAuthentication( ISystemClock clock, ITokenRepository tokens, IPermissionRepository perms, - IOptions tokenOptions) + IOptions tokenOptions, + IOptions openIdOptions, + IUserRepository users, + IOpenIdAccessor accessor) : AuthenticationHandler(options, logger, encoder, clock) { public const string SchemeName = "HopFrame.Authentication"; @@ -30,8 +35,39 @@ public class HopFrameAuthentication( if (string.IsNullOrEmpty(accessToken)) accessToken = Request.Headers["Token"]; if (string.IsNullOrEmpty(accessToken)) accessToken = Request.Query["token"]; if (string.IsNullOrEmpty(accessToken)) return AuthenticateResult.Fail("No Access Token provided"); - + var tokenEntry = await tokens.GetToken(accessToken); + + if (tokenEntry?.Type != Token.ApiTokenType && openIdOptions.Value.Enabled) { + var result = await accessor.InspectToken(accessToken); + + if (result is null || !result.Active) + return AuthenticateResult.Fail("Invalid OpenID Connect token"); + + var email = result.Email; + if (string.IsNullOrEmpty(email)) + return AuthenticateResult.Fail("OpenID user has no email associated to it"); + + var user = await users.GetUserByEmail(email); + if (user is null) { + if (!openIdOptions.Value.GenerateUsers) + return AuthenticateResult.Fail("OpenID user does not exist"); + + var username = result.PreferredUsername; + user = await users.AddUser(new User { + Email = email, + Username = username + }); + } + + var token = new Token { + Owner = user, + CreatedAt = DateTime.Now, + Type = Token.OpenIdTokenType + }; + var identity = await GenerateClaims(token); + return AuthenticateResult.Success(new AuthenticationTicket(identity, Scheme.Name)); + } if (tokenEntry is null) return AuthenticateResult.Fail("The provided Access Token does not exist"); @@ -42,17 +78,22 @@ public class HopFrameAuthentication( if (tokenEntry.Owner is null) return AuthenticateResult.Fail("The provided Access Token does not match any user"); + var principal = await GenerateClaims(tokenEntry); + return AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name)); + } + + private async Task GenerateClaims(Token token) { var claims = new List { - new(HopFrameClaimTypes.AccessTokenId, accessToken), - new(HopFrameClaimTypes.UserId, tokenEntry.Owner.Id.ToString()) + new(HopFrameClaimTypes.AccessTokenId, token.TokenId.ToString()), + new(HopFrameClaimTypes.UserId, token.Owner.Id.ToString()) }; - var permissions = await perms.GetFullPermissions(tokenEntry); + var permissions = await perms.GetFullPermissions(token); claims.AddRange(permissions.Select(perm => new Claim(HopFrameClaimTypes.Permission, perm))); var principal = new ClaimsPrincipal(); principal.AddIdentity(new ClaimsIdentity(claims, SchemeName)); - return AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name)); + return principal; } } \ No newline at end of file diff --git a/src/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs b/src/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs index e0b7d37..e4ccff7 100644 --- a/src/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs +++ b/src/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs @@ -1,3 +1,6 @@ +using HopFrame.Security.Authentication.OpenID; +using HopFrame.Security.Authentication.OpenID.Implementation; +using HopFrame.Security.Authentication.OpenID.Options; using HopFrame.Security.Authorization; using HopFrame.Security.Claims; using HopFrame.Security.Options; @@ -20,8 +23,12 @@ public static class HopFrameAuthenticationExtensions { service.TryAddSingleton(); service.AddScoped(); + service.AddHttpClient(); + service.AddScoped(); + service.AddOptionsFromConfiguration(configuration); service.AddOptionsFromConfiguration(configuration); + service.AddOptionsFromConfiguration(configuration); service.AddAuthentication(HopFrameAuthentication.SchemeName).AddScheme(HopFrameAuthentication.SchemeName, _ => {}); service.AddAuthorization(); diff --git a/src/HopFrame.Security/Authentication/OpenID/IOpenIdAccessor.cs b/src/HopFrame.Security/Authentication/OpenID/IOpenIdAccessor.cs new file mode 100644 index 0000000..31f0d67 --- /dev/null +++ b/src/HopFrame.Security/Authentication/OpenID/IOpenIdAccessor.cs @@ -0,0 +1,10 @@ +using HopFrame.Security.Authentication.OpenID.Models; + +namespace HopFrame.Security.Authentication.OpenID; + +public interface IOpenIdAccessor { + Task LoadConfiguration(); + Task RequestToken(string code); + Task ConstructAuthUri(string state = null); + Task InspectToken(string token); +} \ No newline at end of file diff --git a/src/HopFrame.Security/Authentication/OpenID/Implementation/OpenIdAccessor.cs b/src/HopFrame.Security/Authentication/OpenID/Implementation/OpenIdAccessor.cs new file mode 100644 index 0000000..8f70ab0 --- /dev/null +++ b/src/HopFrame.Security/Authentication/OpenID/Implementation/OpenIdAccessor.cs @@ -0,0 +1,64 @@ +using System.Text.Json; +using HopFrame.Security.Authentication.OpenID.Models; +using HopFrame.Security.Authentication.OpenID.Options; +using Microsoft.Extensions.Options; + +namespace HopFrame.Security.Authentication.OpenID.Implementation; + +internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions options) : IOpenIdAccessor { + public async Task LoadConfiguration() { + var client = clientFactory.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Get, Path.Combine(options.Value.Issuer, ".well-known/openid-configuration")); + var response = await client.SendAsync(request); + + if (!response.IsSuccessStatusCode) + return null; + + return await JsonSerializer.DeserializeAsync(await response.Content.ReadAsStreamAsync()); + } + + public async Task RequestToken(string code) { + var configuration = await LoadConfiguration(); + + var client = clientFactory.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Post, configuration.TokenEndpoint) { + Content = new FormUrlEncodedContent(new Dictionary { + { "grant_type", "authorization_code" }, + { "code", code }, + { "redirect_uri", options.Value.Callback }, + { "client_id", options.Value.ClientId }, + { "client_secret", options.Value.ClientSecret } + }) + }; + var response = await client.SendAsync(request); + + if (!response.IsSuccessStatusCode) + return null; + + return await JsonSerializer.DeserializeAsync(await response.Content.ReadAsStreamAsync()); + } + + public async Task ConstructAuthUri(string state = null) { + var configuration = await LoadConfiguration(); + return $"{configuration.AuthorizationEndpoint}?response_type=code&client_id={options.Value.ClientId}&redirect_uri={options.Value.Callback}&scope=openid%20profile%20email&state={state}"; + } + + public async Task InspectToken(string token) { + var configuration = await LoadConfiguration(); + + var client = clientFactory.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Post, configuration.IntrospectionEndpoint) { + Content = new FormUrlEncodedContent(new Dictionary { + { "token", token }, + { "client_id", options.Value.ClientId }, + { "client_secret", options.Value.ClientSecret } + }) + }; + var response = await client.SendAsync(request); + + if (!response.IsSuccessStatusCode) + return null; + + return await JsonSerializer.DeserializeAsync(await response.Content.ReadAsStreamAsync()); + } +} \ No newline at end of file diff --git a/src/HopFrame.Security/Authentication/OpenID/Models/OpenIdConfiguration.cs b/src/HopFrame.Security/Authentication/OpenID/Models/OpenIdConfiguration.cs new file mode 100644 index 0000000..60c1df2 --- /dev/null +++ b/src/HopFrame.Security/Authentication/OpenID/Models/OpenIdConfiguration.cs @@ -0,0 +1,68 @@ +using System.Text.Json.Serialization; + +namespace HopFrame.Security.Authentication.OpenID.Models; + +public sealed class OpenIdConfiguration { + [JsonPropertyName("issuer")] + public string Issuer { get; set; } + + [JsonPropertyName("authorization_endpoint")] + public string AuthorizationEndpoint { get; set; } + + [JsonPropertyName("token_endpoint")] + public string TokenEndpoint { get; set; } + + [JsonPropertyName("userinfo_endpoint")] + public string UserinfoEndpoint { get; set; } + + [JsonPropertyName("end_session_endpoint")] + public string EndSessionEndpoint { get; set; } + + [JsonPropertyName("introspection_endpoint")] + public string IntrospectionEndpoint { get; set; } + + [JsonPropertyName("revocation_endpoint")] + public string RevocationEndpoint { get; set; } + + [JsonPropertyName("device_authorization_endpoint")] + public string DeviceAuthorizationEndpoint { get; set; } + + [JsonPropertyName("response_types_supported")] + public List ResponseTypesSupported { get; set; } + + [JsonPropertyName("response_modes_supported")] + public List ResponseModesSupported { get; set; } + + [JsonPropertyName("jwks_uri")] + public string JwksUri { get; set; } + + [JsonPropertyName("grant_types_supported")] + public List GrantTypesSupported { get; set; } + + [JsonPropertyName("id_token_signing_alg_values_supported")] + public List IdTokenSigningAlgValuesSupported { get; set; } + + [JsonPropertyName("subject_types_supported")] + public List SubjectTypesSupported { get; set; } + + [JsonPropertyName("token_endpoint_auth_methods_supported")] + public List TokenEndpointAuthMethodsSupported { get; set; } + + [JsonPropertyName("acr_values_supported")] + public List AcrValuesSupported { get; set; } + + [JsonPropertyName("scopes_supported")] + public List ScopesSupported { get; set; } + + [JsonPropertyName("request_parameter_supported")] + public bool RequestParameterSupported { get; set; } + + [JsonPropertyName("claims_supported")] + public List ClaimsSupported { get; set; } + + [JsonPropertyName("claims_parameter_supported")] + public bool ClaimsParameterSupported { get; set; } + + [JsonPropertyName("code_challenge_methods_supported")] + public List CodeChallengeMethodsSupported { get; set; } +} diff --git a/src/HopFrame.Security/Authentication/OpenID/Models/OpenIdIntrospection.cs b/src/HopFrame.Security/Authentication/OpenID/Models/OpenIdIntrospection.cs new file mode 100644 index 0000000..a19b603 --- /dev/null +++ b/src/HopFrame.Security/Authentication/OpenID/Models/OpenIdIntrospection.cs @@ -0,0 +1,62 @@ +using System.Text.Json.Serialization; + +namespace HopFrame.Security.Authentication.OpenID.Models; + +public sealed class OpenIdIntrospection { + [JsonPropertyName("iss")] + public string Issuer { get; set; } + + [JsonPropertyName("sub")] + public string Subject { get; set; } + + [JsonPropertyName("aud")] + public string Audience { get; set; } + + [JsonPropertyName("exp")] + public long Expiration { get; set; } + + [JsonPropertyName("iat")] + public long IssuedAt { get; set; } + + [JsonPropertyName("auth_time")] + public long AuthTime { get; set; } + + [JsonPropertyName("acr")] + public string Acr { get; set; } + + [JsonPropertyName("amr")] + public List AuthenticationMethods { get; set; } + + [JsonPropertyName("sid")] + public string SessionId { get; set; } + + [JsonPropertyName("email")] + public string Email { get; set; } + + [JsonPropertyName("email_verified")] + public bool EmailVerified { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("given_name")] + public string GivenName { get; set; } + + [JsonPropertyName("preferred_username")] + public string PreferredUsername { get; set; } + + [JsonPropertyName("nickname")] + public string Nickname { get; set; } + + [JsonPropertyName("groups")] + public List Groups { get; set; } + + [JsonPropertyName("active")] + public bool Active { get; set; } + + [JsonPropertyName("scope")] + public string Scope { get; set; } + + [JsonPropertyName("client_id")] + public string ClientId { get; set; } +} \ No newline at end of file diff --git a/src/HopFrame.Security/Authentication/OpenID/Models/OpenIdToken.cs b/src/HopFrame.Security/Authentication/OpenID/Models/OpenIdToken.cs new file mode 100644 index 0000000..042183d --- /dev/null +++ b/src/HopFrame.Security/Authentication/OpenID/Models/OpenIdToken.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; + +namespace HopFrame.Security.Authentication.OpenID.Models; + +public sealed class OpenIdToken { + [JsonPropertyName("access_token")] + public string AccessToken { get; set; } + + [JsonPropertyName("token_type")] + public string TokenType { get; set; } + + [JsonPropertyName("expires_in")] + public int ExpiresIn { get; set; } + + [JsonPropertyName("id_token")] + public string IdToken { get; set; } +} \ No newline at end of file diff --git a/src/HopFrame.Security/Authentication/OpenID/Options/OpenIdOptions.cs b/src/HopFrame.Security/Authentication/OpenID/Options/OpenIdOptions.cs new file mode 100644 index 0000000..483f8ac --- /dev/null +++ b/src/HopFrame.Security/Authentication/OpenID/Options/OpenIdOptions.cs @@ -0,0 +1,15 @@ +using HopFrame.Security.Options; + +namespace HopFrame.Security.Authentication.OpenID.Options; + +public sealed class OpenIdOptions : OptionsFromConfiguration { + public override string Position { get; } = "HopFrame:Authentication:OpenID"; + + public bool Enabled { get; set; } = false; + public bool GenerateUsers { get; set; } = true; + + public string Issuer { get; set; } + public string ClientId { get; set; } + public string ClientSecret { get; set; } + public string Callback { get; set; } +} \ No newline at end of file diff --git a/src/HopFrame.Security/Claims/ITokenContext.cs b/src/HopFrame.Security/Claims/ITokenContext.cs index 6b052bc..6b5a590 100644 --- a/src/HopFrame.Security/Claims/ITokenContext.cs +++ b/src/HopFrame.Security/Claims/ITokenContext.cs @@ -21,6 +21,4 @@ public interface ITokenContext { /// The access token the user provided /// Token AccessToken { get; } - - IList ContextualPermissions { get; } } \ No newline at end of file diff --git a/src/HopFrame.Security/Claims/TokenContextImplementor.cs b/src/HopFrame.Security/Claims/TokenContextImplementor.cs index 47fce76..c464f23 100644 --- a/src/HopFrame.Security/Claims/TokenContextImplementor.cs +++ b/src/HopFrame.Security/Claims/TokenContextImplementor.cs @@ -1,15 +1,19 @@ using HopFrame.Database.Models; using HopFrame.Database.Repositories; +using HopFrame.Security.Authentication.OpenID.Options; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; namespace HopFrame.Security.Claims; -internal sealed class TokenContextImplementor(IHttpContextAccessor accessor, IUserRepository users, ITokenRepository tokens, IPermissionRepository permissions) : ITokenContext { +internal sealed class TokenContextImplementor(IHttpContextAccessor accessor, IUserRepository users, ITokenRepository tokens, IOptions options) : ITokenContext { public bool IsAuthenticated => !string.IsNullOrEmpty(accessor.HttpContext?.User.GetAccessTokenId()); public User User => users.GetUser(Guid.Parse(accessor.HttpContext?.User.GetUserId() ?? Guid.Empty.ToString())).GetAwaiter().GetResult(); - public Token AccessToken => tokens.GetToken(accessor.HttpContext?.User.GetAccessTokenId()).GetAwaiter().GetResult(); - - public IList ContextualPermissions => permissions.GetFullPermissions(AccessToken).GetAwaiter().GetResult(); + public Token AccessToken => options.Value.Enabled ? new Token { + Owner = User, + Type = Token.OpenIdTokenType, + CreatedAt = DateTime.Now + } : tokens.GetToken(accessor.HttpContext?.User.GetAccessTokenId()).GetAwaiter().GetResult(); } \ No newline at end of file diff --git a/testing/HopFrame.Testing.Api/Controllers/AuthController.cs b/testing/HopFrame.Testing.Api/Controllers/AuthController.cs new file mode 100644 index 0000000..c979c33 --- /dev/null +++ b/testing/HopFrame.Testing.Api/Controllers/AuthController.cs @@ -0,0 +1,28 @@ +using HopFrame.Security.Authentication.OpenID; +using Microsoft.AspNetCore.Mvc; +using HopFrame.Security.Authentication.OpenID.Models; + +namespace HopFrame.Testing.Api.Controllers; + +public class AuthController(IOpenIdAccessor accessor) : Controller { + + [HttpGet("auth/callback")] + public async Task> Callback([FromQuery] string code, [FromQuery] string state) { + if (string.IsNullOrEmpty(code)) { + return BadRequest("Authorization code is missing"); + } + + var token = await accessor.RequestToken(code); + return Ok(token.AccessToken); + } + + [HttpGet("auth")] + public async Task Authenticate() { + return Redirect(await accessor.ConstructAuthUri()); + } + + [HttpGet("check")] + public async Task> Check([FromQuery] string token) { + return Ok(await accessor.InspectToken(token)); + } +} \ No newline at end of file diff --git a/testing/HopFrame.Testing.Api/Controllers/TestController.cs b/testing/HopFrame.Testing.Api/Controllers/TestController.cs index d097592..aa773b8 100644 --- a/testing/HopFrame.Testing.Api/Controllers/TestController.cs +++ b/testing/HopFrame.Testing.Api/Controllers/TestController.cs @@ -15,8 +15,8 @@ namespace HopFrame.Testing.Api.Controllers; public class TestController(ITokenContext userContext, DatabaseContext context, ITokenRepository tokens, IPermissionRepository permissions) : ControllerBase { [HttpGet("permissions"), Authorized] - public ActionResult> Permissions() { - return new ActionResult>(userContext.ContextualPermissions); + public async Task>> Permissions() { + return new ActionResult>(await permissions.GetFullPermissions(userContext.AccessToken)); } [HttpGet("generate")] diff --git a/testing/HopFrame.Testing.Api/Program.cs b/testing/HopFrame.Testing.Api/Program.cs index 948be0d..45c9ef1 100644 --- a/testing/HopFrame.Testing.Api/Program.cs +++ b/testing/HopFrame.Testing.Api/Program.cs @@ -6,6 +6,7 @@ var builder = WebApplication.CreateBuilder(args); // Add services to the container. +builder.Services.AddHttpClient(); builder.Services.AddControllers(); builder.Services.AddHopFrame(builder.Configuration); // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle diff --git a/tests/HopFrame.Tests.Security/AuthenticationTests.cs b/tests/HopFrame.Tests.Security/AuthenticationTests.cs index 17e3d1d..3791f29 100644 --- a/tests/HopFrame.Tests.Security/AuthenticationTests.cs +++ b/tests/HopFrame.Tests.Security/AuthenticationTests.cs @@ -2,6 +2,8 @@ using System.Text.Encodings.Web; using HopFrame.Database.Models; using HopFrame.Database.Repositories; using HopFrame.Security.Authentication; +using HopFrame.Security.Authentication.OpenID; +using HopFrame.Security.Authentication.OpenID.Options; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; @@ -46,7 +48,17 @@ public class AuthenticationTests { .Setup(x => x.GetFullPermissions(It.IsAny())) .ReturnsAsync(new List()); - var auth = new HopFrameAuthentication(options.Object, logger.Object, encoder.Object, clock.Object, tokens.Object, perms.Object, new OptionsWrapper(new HopFrameAuthenticationOptions())); + var auth = new HopFrameAuthentication( + options.Object, + logger.Object, + encoder.Object, + clock.Object, + tokens.Object, + perms.Object, + new OptionsWrapper(new HopFrameAuthenticationOptions()), + new OptionsWrapper(new OpenIdOptions()), + new Mock().Object, + new Mock().Object); var context = new DefaultHttpContext(); if (provideCorrectToken) context.HttpContext.Request.Headers.Append(HopFrameAuthentication.SchemeName, correctToken.TokenId.ToString()); From 9b38a10797c88a884e5c911adfa0efa12dd173bb Mon Sep 17 00:00:00 2001 From: Leon Hoppe Date: Sun, 22 Dec 2024 10:55:24 +0100 Subject: [PATCH 22/27] Added all necessary api endpoints for OpenID --- HopFrame.sln.DotSettings.user | 1 + ...ecurityController.cs => AuthController.cs} | 4 +- .../Controller/HopFrameFeatureProvider.cs | 16 ++++ .../Controller/OpenIdController.cs | 79 +++++++++++++++++++ src/HopFrame.Api/Extensions/MvcExtensions.cs | 2 +- .../Extensions/ServiceCollectionExtensions.cs | 12 ++- .../Logic/Implementation/AuthLogic.cs | 4 +- .../HopFrameAuthenticationExtensions.cs | 1 + .../HopFrameAuthenticationOptions.cs | 6 +- .../Authentication/OpenID/IOpenIdAccessor.cs | 1 + .../OpenID/Implementation/OpenIdAccessor.cs | 73 +++++++++++++++-- .../OpenID/Models/OpenIdToken.cs | 3 + .../OpenID/Options/OpenIdOptions.cs | 38 +++++++++ .../Controllers/AuthController.cs | 4 +- .../Controllers/TestController.cs | 6 ++ 15 files changed, 233 insertions(+), 17 deletions(-) rename src/HopFrame.Api/Controller/{SecurityController.cs => AuthController.cs} (91%) create mode 100644 src/HopFrame.Api/Controller/HopFrameFeatureProvider.cs create mode 100644 src/HopFrame.Api/Controller/OpenIdController.cs diff --git a/HopFrame.sln.DotSettings.user b/HopFrame.sln.DotSettings.user index 88abaf3..c0c134b 100644 --- a/HopFrame.sln.DotSettings.user +++ b/HopFrame.sln.DotSettings.user @@ -74,6 +74,7 @@ + \ No newline at end of file diff --git a/src/HopFrame.Api/Controller/SecurityController.cs b/src/HopFrame.Api/Controller/AuthController.cs similarity index 91% rename from src/HopFrame.Api/Controller/SecurityController.cs rename to src/HopFrame.Api/Controller/AuthController.cs index d9c1128..4cf4430 100644 --- a/src/HopFrame.Api/Controller/SecurityController.cs +++ b/src/HopFrame.Api/Controller/AuthController.cs @@ -7,8 +7,8 @@ using Microsoft.AspNetCore.Mvc; namespace HopFrame.Api.Controller; [ApiController] -[Route("api/v1/authentication")] -public class SecurityController(IAuthLogic auth) : ControllerBase { +[Route("api/v1/auth")] +public class AuthController(IAuthLogic auth) : ControllerBase { [HttpPut("login")] public async Task>> Login([FromBody] UserLogin login) { diff --git a/src/HopFrame.Api/Controller/HopFrameFeatureProvider.cs b/src/HopFrame.Api/Controller/HopFrameFeatureProvider.cs new file mode 100644 index 0000000..c8f6cba --- /dev/null +++ b/src/HopFrame.Api/Controller/HopFrameFeatureProvider.cs @@ -0,0 +1,16 @@ +using System.Reflection; +using Microsoft.AspNetCore.Mvc.Controllers; + +namespace HopFrame.Api.Controller; + +public class HopFrameFeatureProvider(params Type[] controllerTypes) : ControllerFeatureProvider { + protected override bool IsController(TypeInfo typeInfo) { + if (typeInfo.Namespace != typeof(HopFrameFeatureProvider).Namespace) + return base.IsController(typeInfo); + + if (controllerTypes.All(c => c.Name != typeInfo.Name)) + return false; + + return base.IsController(typeInfo); + } +} \ No newline at end of file diff --git a/src/HopFrame.Api/Controller/OpenIdController.cs b/src/HopFrame.Api/Controller/OpenIdController.cs new file mode 100644 index 0000000..16f9d3a --- /dev/null +++ b/src/HopFrame.Api/Controller/OpenIdController.cs @@ -0,0 +1,79 @@ +using HopFrame.Api.Models; +using HopFrame.Security.Authentication.OpenID; +using HopFrame.Security.Authentication.OpenID.Options; +using HopFrame.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; + +namespace HopFrame.Api.Controller; + +[ApiController, Route("api/v1/openid")] +public class OpenIdController(IOpenIdAccessor accessor, IOptions options) : ControllerBase { + + [HttpGet("redirect")] + public async Task RedirectToProvider([FromQuery] string redirectAfter, [FromQuery] int performRedirect = 1) { + var uri = await accessor.ConstructAuthUri(redirectAfter); + + if (performRedirect == 1) { + return Redirect(uri); + } + + return Ok(new SingleValueResult(uri)); + } + + [HttpGet("callback")] + public async Task Callback([FromQuery] string code, [FromQuery] string state) { + if (string.IsNullOrEmpty(code)) { + return BadRequest("Authorization code is missing"); + } + + var token = await accessor.RequestToken(code); + + Response.Cookies.Append(ITokenContext.AccessTokenType, token.AccessToken, new CookieOptions { + MaxAge = TimeSpan.FromSeconds(token.ExpiresIn), + HttpOnly = false, + Secure = true + }); + Response.Cookies.Append(ITokenContext.RefreshTokenType, token.RefreshToken, new CookieOptions { + MaxAge = options.Value.RefreshToken.ConstructTimeSpan, + HttpOnly = false, + Secure = true + }); + + if (string.IsNullOrEmpty(state)) { + return Ok(new SingleValueResult(token.AccessToken)); + } + + return Redirect(state.Replace("{token}", token.AccessToken)); + } + + [HttpGet("refresh")] + public async Task Refresh() { + var refreshToken = Request.Cookies[ITokenContext.RefreshTokenType]; + + if (string.IsNullOrEmpty(refreshToken)) + return BadRequest("Refresh token not provided"); + + var token = await accessor.RefreshAccessToken(refreshToken); + + if (token is null) + return NotFound("Refresh token not valid"); + + Response.Cookies.Append(ITokenContext.AccessTokenType, token.AccessToken, new CookieOptions { + MaxAge = TimeSpan.FromSeconds(token.ExpiresIn), + HttpOnly = false, + Secure = true + }); + + return Ok(new SingleValueResult(token.AccessToken)); + } + + [HttpDelete("logout")] + public IActionResult Logout() { + Response.Cookies.Delete(ITokenContext.RefreshTokenType); + Response.Cookies.Delete(ITokenContext.AccessTokenType); + return Ok(); + } + +} \ No newline at end of file diff --git a/src/HopFrame.Api/Extensions/MvcExtensions.cs b/src/HopFrame.Api/Extensions/MvcExtensions.cs index d176de7..4329015 100644 --- a/src/HopFrame.Api/Extensions/MvcExtensions.cs +++ b/src/HopFrame.Api/Extensions/MvcExtensions.cs @@ -83,4 +83,4 @@ public static class MvcExtensions { return true; } } -} \ No newline at end of file +} diff --git a/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs b/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs index 51eacde..75a0b90 100644 --- a/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs +++ b/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs @@ -19,8 +19,13 @@ public static class ServiceCollectionExtensions { /// The configuration used to configure HopFrame authentication /// The data source for all HopFrame entities public static void AddHopFrame(this IServiceCollection services, ConfigurationManager configuration) where TDbContext : HopDbContextBase { - services.AddMvcCore().UseSpecificControllers(typeof(SecurityController)); + var controllers = new List { typeof(AuthController) }; + + if (configuration.GetValue("HopFrame:Authentication:OpenID:Enabled")) + controllers.Add(typeof(OpenIdController)); + AddHopFrameNoEndpoints(services, configuration); + services.AddMvcCore().UseSpecificControllers(controllers.ToArray()); } /// @@ -30,6 +35,11 @@ public static class ServiceCollectionExtensions { /// The configuration used to configure HopFrame authentication /// The data source for all HopFrame entities public static void AddHopFrameNoEndpoints(this IServiceCollection services, ConfigurationManager configuration) where TDbContext : HopDbContextBase { + services.AddMvcCore().ConfigureApplicationPartManager(manager => { + var endpoints = manager.ApplicationParts.SingleOrDefault(p => p.Name == typeof(ServiceCollectionExtensions).Namespace!.Replace(".Extensions", "")); + manager.ApplicationParts.Remove(endpoints); + }); + services.AddHopFrameRepositories(); services.TryAddSingleton(); services.AddScoped(); diff --git a/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs b/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs index 61e9681..25dfd76 100644 --- a/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs +++ b/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs @@ -101,9 +101,7 @@ internal class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenC var refreshToken = accessor.HttpContext?.Request.Cookies[ITokenContext.RefreshTokenType]; if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(refreshToken)) - return LogicResult.Conflict("access or refresh token not provided"); - - await tokens.DeleteUserTokens(tokenContext.User); + await tokens.DeleteUserTokens(tokenContext.User); accessor.HttpContext?.Response.Cookies.Delete(ITokenContext.RefreshTokenType); accessor.HttpContext?.Response.Cookies.Delete(ITokenContext.AccessTokenType); diff --git a/src/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs b/src/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs index e4ccff7..a6bf52c 100644 --- a/src/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs +++ b/src/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs @@ -24,6 +24,7 @@ public static class HopFrameAuthenticationExtensions { service.AddScoped(); service.AddHttpClient(); + service.AddMemoryCache(); service.AddScoped(); service.AddOptionsFromConfiguration(configuration); diff --git a/src/HopFrame.Security/Authentication/HopFrameAuthenticationOptions.cs b/src/HopFrame.Security/Authentication/HopFrameAuthenticationOptions.cs index b996d68..7cbd157 100644 --- a/src/HopFrame.Security/Authentication/HopFrameAuthenticationOptions.cs +++ b/src/HopFrame.Security/Authentication/HopFrameAuthenticationOptions.cs @@ -5,8 +5,8 @@ namespace HopFrame.Security.Authentication; public class HopFrameAuthenticationOptions : OptionsFromConfiguration { public override string Position { get; } = "HopFrame:Authentication"; - public TimeSpan AccessTokenTime => AccessToken is null ? new(0, 0, 5, 0) : new(AccessToken.Days, AccessToken.Hours, AccessToken.Minutes, AccessToken.Seconds); - public TimeSpan RefreshTokenTime => RefreshToken is null ? new(30, 0, 0, 0) : new(RefreshToken.Days, RefreshToken.Hours, RefreshToken.Minutes, RefreshToken.Seconds); + public TimeSpan AccessTokenTime => AccessToken is null ? new(0, 0, 5, 0) : AccessToken.ConstructTimeSpan; + public TimeSpan RefreshTokenTime => RefreshToken is null ? new(30, 0, 0, 0) : RefreshToken.ConstructTimeSpan; public TokenTime AccessToken { get; set; } public TokenTime RefreshToken { get; set; } @@ -16,5 +16,7 @@ public class HopFrameAuthenticationOptions : OptionsFromConfiguration { public int Hours { get; set; } public int Minutes { get; set; } public int Seconds { get; set; } + + public TimeSpan ConstructTimeSpan => new(Days, Hours, Minutes, Seconds); } } \ No newline at end of file diff --git a/src/HopFrame.Security/Authentication/OpenID/IOpenIdAccessor.cs b/src/HopFrame.Security/Authentication/OpenID/IOpenIdAccessor.cs index 31f0d67..df9f26f 100644 --- a/src/HopFrame.Security/Authentication/OpenID/IOpenIdAccessor.cs +++ b/src/HopFrame.Security/Authentication/OpenID/IOpenIdAccessor.cs @@ -7,4 +7,5 @@ public interface IOpenIdAccessor { Task RequestToken(string code); Task ConstructAuthUri(string state = null); Task InspectToken(string token); + Task RefreshAccessToken(string refreshToken); } \ No newline at end of file diff --git a/src/HopFrame.Security/Authentication/OpenID/Implementation/OpenIdAccessor.cs b/src/HopFrame.Security/Authentication/OpenID/Implementation/OpenIdAccessor.cs index 8f70ab0..5161b01 100644 --- a/src/HopFrame.Security/Authentication/OpenID/Implementation/OpenIdAccessor.cs +++ b/src/HopFrame.Security/Authentication/OpenID/Implementation/OpenIdAccessor.cs @@ -1,12 +1,24 @@ using System.Text.Json; using HopFrame.Security.Authentication.OpenID.Models; using HopFrame.Security.Authentication.OpenID.Options; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; namespace HopFrame.Security.Authentication.OpenID.Implementation; -internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions options) : IOpenIdAccessor { +internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions options, IHttpContextAccessor accessor, IMemoryCache cache) : IOpenIdAccessor { + private const string DefaultCallbackEndpoint = "api/v1/openid/callback"; + + private const string ConfigurationCacheKey = "HopFrame:OpenID:Configuration"; + private const string AuthCodeCacheKey = "HopFrame:OpenID:Code:"; + private const string TokenCacheKey = "HopFrame:OpenID:Token:"; + public async Task LoadConfiguration() { + if (options.Value.Cache.Enabled && options.Value.Cache.Configuration.Enabled && cache.TryGetValue(ConfigurationCacheKey, out object cachedConfiguration)) { + return cachedConfiguration as OpenIdConfiguration; + } + var client = clientFactory.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, Path.Combine(options.Value.Issuer, ".well-known/openid-configuration")); var response = await client.SendAsync(request); @@ -14,10 +26,22 @@ internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions(await response.Content.ReadAsStreamAsync()); + var config = await JsonSerializer.DeserializeAsync(await response.Content.ReadAsStreamAsync()); + + if (options.Value.Cache.Enabled && options.Value.Cache.Configuration.Enabled) + cache.Set(ConfigurationCacheKey, config, options.Value.Cache.Configuration.TTL.ConstructTimeSpan); + + return config; } public async Task RequestToken(string code) { + if (options.Value.Cache.Enabled && options.Value.Cache.Auth.Enabled && cache.TryGetValue(AuthCodeCacheKey + code, out object cachedToken)) { + return cachedToken as OpenIdToken; + } + + var protocol = accessor.HttpContext!.Request.IsHttps ? "https" : "http"; + var callback = options.Value.Callback ?? $"{protocol}://{accessor.HttpContext!.Request.Host.Value}/{DefaultCallbackEndpoint}"; + var configuration = await LoadConfiguration(); var client = clientFactory.CreateClient(); @@ -25,7 +49,7 @@ internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions { { "grant_type", "authorization_code" }, { "code", code }, - { "redirect_uri", options.Value.Callback }, + { "redirect_uri", callback }, { "client_id", options.Value.ClientId }, { "client_secret", options.Value.ClientSecret } }) @@ -35,15 +59,27 @@ internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions(await response.Content.ReadAsStreamAsync()); + var token = await JsonSerializer.DeserializeAsync(await response.Content.ReadAsStreamAsync()); + + if (options.Value.Cache.Enabled && options.Value.Cache.Auth.Enabled) + cache.Set(AuthCodeCacheKey + code, token, options.Value.Cache.Auth.TTL.ConstructTimeSpan); + + return token; } public async Task ConstructAuthUri(string state = null) { + var protocol = accessor.HttpContext!.Request.IsHttps ? "https" : "http"; + var callback = options.Value.Callback ?? $"{protocol}://{accessor.HttpContext!.Request.Host.Value}/{DefaultCallbackEndpoint}"; + var configuration = await LoadConfiguration(); - return $"{configuration.AuthorizationEndpoint}?response_type=code&client_id={options.Value.ClientId}&redirect_uri={options.Value.Callback}&scope=openid%20profile%20email&state={state}"; + return $"{configuration.AuthorizationEndpoint}?response_type=code&client_id={options.Value.ClientId}&redirect_uri={callback}&scope=openid%20profile%20email%20offline_access&state={state}"; } public async Task InspectToken(string token) { + if (options.Value.Cache.Enabled && options.Value.Cache.Inspection.Enabled && cache.TryGetValue(TokenCacheKey + token, out object cachedToken)) { + return cachedToken as OpenIdIntrospection; + } + var configuration = await LoadConfiguration(); var client = clientFactory.CreateClient(); @@ -59,6 +95,31 @@ internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions(await response.Content.ReadAsStreamAsync()); + var introspection = await JsonSerializer.DeserializeAsync(await response.Content.ReadAsStreamAsync()); + + if (options.Value.Cache.Enabled && options.Value.Cache.Inspection.Enabled) + cache.Set(TokenCacheKey + token, introspection, options.Value.Cache.Inspection.TTL.ConstructTimeSpan); + + return introspection; + } + + public async Task RefreshAccessToken(string refreshToken) { + var configuration = await LoadConfiguration(); + + var client = clientFactory.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Post, configuration.TokenEndpoint) { + Content = new FormUrlEncodedContent(new Dictionary { + { "grant_type", "refresh_token" }, + { "refresh_token", refreshToken }, + { "client_id", options.Value.ClientId }, + { "client_secret", options.Value.ClientSecret } + }) + }; + var response = await client.SendAsync(request); + + if (!response.IsSuccessStatusCode) + return null; + + return await JsonSerializer.DeserializeAsync(await response.Content.ReadAsStreamAsync()); } } \ No newline at end of file diff --git a/src/HopFrame.Security/Authentication/OpenID/Models/OpenIdToken.cs b/src/HopFrame.Security/Authentication/OpenID/Models/OpenIdToken.cs index 042183d..6303bda 100644 --- a/src/HopFrame.Security/Authentication/OpenID/Models/OpenIdToken.cs +++ b/src/HopFrame.Security/Authentication/OpenID/Models/OpenIdToken.cs @@ -5,6 +5,9 @@ namespace HopFrame.Security.Authentication.OpenID.Models; public sealed class OpenIdToken { [JsonPropertyName("access_token")] public string AccessToken { get; set; } + + [JsonPropertyName("refresh_token")] + public string RefreshToken { get; set; } [JsonPropertyName("token_type")] public string TokenType { get; set; } diff --git a/src/HopFrame.Security/Authentication/OpenID/Options/OpenIdOptions.cs b/src/HopFrame.Security/Authentication/OpenID/Options/OpenIdOptions.cs index 483f8ac..49a219c 100644 --- a/src/HopFrame.Security/Authentication/OpenID/Options/OpenIdOptions.cs +++ b/src/HopFrame.Security/Authentication/OpenID/Options/OpenIdOptions.cs @@ -12,4 +12,42 @@ public sealed class OpenIdOptions : OptionsFromConfiguration { public string ClientId { get; set; } public string ClientSecret { get; set; } public string Callback { get; set; } + + public HopFrameAuthenticationOptions.TokenTime RefreshToken { get; set; } = new() { + Days = 30 + }; + + public CachingOptions Cache { get; set; } = new() { + Enabled = true, + Configuration = new() { + Enabled = true, + TTL = new() { + Minutes = 10 + } + }, + Auth = new() { + Enabled = true, + TTL = new() { + Seconds = 30 + } + }, + Inspection = new() { + Enabled = true, + TTL = new() { + Minutes = 2 + } + } + }; + + public class CachingTypeOptions { + public bool Enabled { get; set; } + public HopFrameAuthenticationOptions.TokenTime TTL { get; set; } + } + + public class CachingOptions { + public bool Enabled { get; set; } + public CachingTypeOptions Configuration { get; set; } + public CachingTypeOptions Auth { get; set; } + public CachingTypeOptions Inspection { get; set; } + } } \ No newline at end of file diff --git a/testing/HopFrame.Testing.Api/Controllers/AuthController.cs b/testing/HopFrame.Testing.Api/Controllers/AuthController.cs index c979c33..083fd34 100644 --- a/testing/HopFrame.Testing.Api/Controllers/AuthController.cs +++ b/testing/HopFrame.Testing.Api/Controllers/AuthController.cs @@ -7,13 +7,13 @@ namespace HopFrame.Testing.Api.Controllers; public class AuthController(IOpenIdAccessor accessor) : Controller { [HttpGet("auth/callback")] - public async Task> Callback([FromQuery] string code, [FromQuery] string state) { + public async Task Callback([FromQuery] string code, [FromQuery] string state) { if (string.IsNullOrEmpty(code)) { return BadRequest("Authorization code is missing"); } var token = await accessor.RequestToken(code); - return Ok(token.AccessToken); + return Ok(token); } [HttpGet("auth")] diff --git a/testing/HopFrame.Testing.Api/Controllers/TestController.cs b/testing/HopFrame.Testing.Api/Controllers/TestController.cs index aa773b8..3a3affe 100644 --- a/testing/HopFrame.Testing.Api/Controllers/TestController.cs +++ b/testing/HopFrame.Testing.Api/Controllers/TestController.cs @@ -66,5 +66,11 @@ public class TestController(ITokenContext userContext, DatabaseContext context, var token = await tokens.GetToken(tokenId); await tokens.DeleteToken(token); } + + [HttpGet("url")] + public async Task>> GetUrl() { + var protocol = Request.IsHttps ? "https" : "http"; + return Ok($"{protocol}://{Request.Host.Value}/auth/callback"); + } } \ No newline at end of file From bee771a30ebc5bd167b1520d9ebb94656af1ee79 Mon Sep 17 00:00:00 2001 From: Leon Hoppe Date: Sun, 22 Dec 2024 14:28:49 +0100 Subject: [PATCH 23/27] finished OpenID integration --- HopFrame.sln.DotSettings.user | 4 ++ .../Controller/HopFrameFeatureProvider.cs | 16 ------ .../Controller/OpenIdController.cs | 9 +++- .../Extensions/ServiceCollectionExtensions.cs | 5 +- .../Logic/Implementation/AuthLogic.cs | 6 +++ .../Authentication/HopFrameAuthentication.cs | 11 ++-- .../HopFrameAuthenticationOptions.cs | 2 + .../Authentication/OpenID/IOpenIdAccessor.cs | 4 +- .../OpenID/Implementation/OpenIdAccessor.cs | 10 ++-- src/HopFrame.Web/AuthMiddleware.cs | 13 ++--- .../Services/Implementation/AuthService.cs | 52 ++++++++++++++++++- .../Controllers/AuthController.cs | 28 ---------- tests/HopFrame.Tests.Api/AuthLogicTests.cs | 18 +++---- tests/HopFrame.Tests.Web/AuthServiceTests.cs | 13 ++++- .../Pages/AuthorizedViewTests.cs | 1 + 15 files changed, 110 insertions(+), 82 deletions(-) delete mode 100644 src/HopFrame.Api/Controller/HopFrameFeatureProvider.cs delete mode 100644 testing/HopFrame.Testing.Api/Controllers/AuthController.cs diff --git a/HopFrame.sln.DotSettings.user b/HopFrame.sln.DotSettings.user index c0c134b..12afe51 100644 --- a/HopFrame.sln.DotSettings.user +++ b/HopFrame.sln.DotSettings.user @@ -1,4 +1,5 @@  + ForceIncluded ForceIncluded ForceIncluded ForceIncluded @@ -6,6 +7,7 @@ ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded <AssemblyExplorer> @@ -74,6 +76,8 @@ + + diff --git a/src/HopFrame.Api/Controller/HopFrameFeatureProvider.cs b/src/HopFrame.Api/Controller/HopFrameFeatureProvider.cs deleted file mode 100644 index c8f6cba..0000000 --- a/src/HopFrame.Api/Controller/HopFrameFeatureProvider.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Reflection; -using Microsoft.AspNetCore.Mvc.Controllers; - -namespace HopFrame.Api.Controller; - -public class HopFrameFeatureProvider(params Type[] controllerTypes) : ControllerFeatureProvider { - protected override bool IsController(TypeInfo typeInfo) { - if (typeInfo.Namespace != typeof(HopFrameFeatureProvider).Namespace) - return base.IsController(typeInfo); - - if (controllerTypes.All(c => c.Name != typeInfo.Name)) - return false; - - return base.IsController(typeInfo); - } -} \ No newline at end of file diff --git a/src/HopFrame.Api/Controller/OpenIdController.cs b/src/HopFrame.Api/Controller/OpenIdController.cs index 16f9d3a..50e8822 100644 --- a/src/HopFrame.Api/Controller/OpenIdController.cs +++ b/src/HopFrame.Api/Controller/OpenIdController.cs @@ -10,10 +10,11 @@ namespace HopFrame.Api.Controller; [ApiController, Route("api/v1/openid")] public class OpenIdController(IOpenIdAccessor accessor, IOptions options) : ControllerBase { + public const string DefaultCallback = "api/v1/openid/callback"; [HttpGet("redirect")] public async Task RedirectToProvider([FromQuery] string redirectAfter, [FromQuery] int performRedirect = 1) { - var uri = await accessor.ConstructAuthUri(redirectAfter); + var uri = await accessor.ConstructAuthUri(DefaultCallback, redirectAfter); if (performRedirect == 1) { return Redirect(uri); @@ -28,7 +29,11 @@ public class OpenIdController(IOpenIdAccessor accessor, IOptions return BadRequest("Authorization code is missing"); } - var token = await accessor.RequestToken(code); + var token = await accessor.RequestToken(code, DefaultCallback); + + if (token is null) { + return Forbid("Authorization code is not valid"); + } Response.Cookies.Append(ITokenContext.AccessTokenType, token.AccessToken, new CookieOptions { MaxAge = TimeSpan.FromSeconds(token.ExpiresIn), diff --git a/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs b/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs index 75a0b90..a596033 100644 --- a/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs +++ b/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs @@ -19,7 +19,10 @@ public static class ServiceCollectionExtensions { /// The configuration used to configure HopFrame authentication /// The data source for all HopFrame entities public static void AddHopFrame(this IServiceCollection services, ConfigurationManager configuration) where TDbContext : HopDbContextBase { - var controllers = new List { typeof(AuthController) }; + var controllers = new List(); + + if (configuration.GetValue("HopFrame:Authentication:DefaultAuthentication")) + controllers.Add(typeof(AuthController)); if (configuration.GetValue("HopFrame:Authentication:OpenID:Enabled")) controllers.Add(typeof(OpenIdController)); diff --git a/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs b/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs index 25dfd76..d0d188c 100644 --- a/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs +++ b/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs @@ -12,6 +12,8 @@ namespace HopFrame.Api.Logic.Implementation; internal class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenContext tokenContext, IHttpContextAccessor accessor, IOptions options) : IAuthLogic { public async Task>> Login(UserLogin login) { + if (!options.Value.DefaultAuthentication) return LogicResult>.BadRequest("HopFrame authentication scheme is disabled"); + var user = await users.GetUserByEmail(login.Email); if (user is null) @@ -38,6 +40,8 @@ internal class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenC } public async Task>> Register(UserRegister register) { + if (!options.Value.DefaultAuthentication) return LogicResult>.BadRequest("HopFrame authentication scheme is disabled"); + if (register.Password.Length < 8) return LogicResult>.BadRequest("Password needs to be at least 8 characters long"); @@ -69,6 +73,8 @@ internal class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenC } public async Task>> Authenticate() { + if (!options.Value.DefaultAuthentication) return LogicResult>.BadRequest("HopFrame authentication scheme is disabled"); + var refreshToken = accessor.HttpContext?.Request.Cookies[ITokenContext.RefreshTokenType]; if (string.IsNullOrEmpty(refreshToken)) diff --git a/src/HopFrame.Security/Authentication/HopFrameAuthentication.cs b/src/HopFrame.Security/Authentication/HopFrameAuthentication.cs index f0dff7e..8fb578f 100644 --- a/src/HopFrame.Security/Authentication/HopFrameAuthentication.cs +++ b/src/HopFrame.Security/Authentication/HopFrameAuthentication.cs @@ -38,7 +38,7 @@ public class HopFrameAuthentication( var tokenEntry = await tokens.GetToken(accessToken); - if (tokenEntry?.Type != Token.ApiTokenType && openIdOptions.Value.Enabled) { + if (tokenEntry?.Type != Token.ApiTokenType && openIdOptions.Value.Enabled && !Guid.TryParse(accessToken, out _)) { var result = await accessor.InspectToken(accessToken); if (result is null || !result.Active) @@ -65,10 +65,13 @@ public class HopFrameAuthentication( CreatedAt = DateTime.Now, Type = Token.OpenIdTokenType }; - var identity = await GenerateClaims(token); + var identity = await GenerateClaims(token, perms); return AuthenticateResult.Success(new AuthenticationTicket(identity, Scheme.Name)); } + if (!tokenOptions.Value.DefaultAuthentication) + return AuthenticateResult.Fail("HopFrame authentication scheme is disabled"); + if (tokenEntry is null) return AuthenticateResult.Fail("The provided Access Token does not exist"); if (tokenEntry.Type == Token.ApiTokenType) { @@ -78,11 +81,11 @@ public class HopFrameAuthentication( if (tokenEntry.Owner is null) return AuthenticateResult.Fail("The provided Access Token does not match any user"); - var principal = await GenerateClaims(tokenEntry); + var principal = await GenerateClaims(tokenEntry, perms); return AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name)); } - private async Task GenerateClaims(Token token) { + public static async Task GenerateClaims(Token token, IPermissionRepository perms) { var claims = new List { new(HopFrameClaimTypes.AccessTokenId, token.TokenId.ToString()), new(HopFrameClaimTypes.UserId, token.Owner.Id.ToString()) diff --git a/src/HopFrame.Security/Authentication/HopFrameAuthenticationOptions.cs b/src/HopFrame.Security/Authentication/HopFrameAuthenticationOptions.cs index 7cbd157..8a7285f 100644 --- a/src/HopFrame.Security/Authentication/HopFrameAuthenticationOptions.cs +++ b/src/HopFrame.Security/Authentication/HopFrameAuthenticationOptions.cs @@ -8,6 +8,8 @@ public class HopFrameAuthenticationOptions : OptionsFromConfiguration { public TimeSpan AccessTokenTime => AccessToken is null ? new(0, 0, 5, 0) : AccessToken.ConstructTimeSpan; public TimeSpan RefreshTokenTime => RefreshToken is null ? new(30, 0, 0, 0) : RefreshToken.ConstructTimeSpan; + public bool DefaultAuthentication { get; set; } = true; + public TokenTime AccessToken { get; set; } public TokenTime RefreshToken { get; set; } diff --git a/src/HopFrame.Security/Authentication/OpenID/IOpenIdAccessor.cs b/src/HopFrame.Security/Authentication/OpenID/IOpenIdAccessor.cs index df9f26f..09dc54c 100644 --- a/src/HopFrame.Security/Authentication/OpenID/IOpenIdAccessor.cs +++ b/src/HopFrame.Security/Authentication/OpenID/IOpenIdAccessor.cs @@ -4,8 +4,8 @@ namespace HopFrame.Security.Authentication.OpenID; public interface IOpenIdAccessor { Task LoadConfiguration(); - Task RequestToken(string code); - Task ConstructAuthUri(string state = null); + Task RequestToken(string code, string defaultCallback); + Task ConstructAuthUri(string defaultCallback, string state = null); Task InspectToken(string token); Task RefreshAccessToken(string refreshToken); } \ No newline at end of file diff --git a/src/HopFrame.Security/Authentication/OpenID/Implementation/OpenIdAccessor.cs b/src/HopFrame.Security/Authentication/OpenID/Implementation/OpenIdAccessor.cs index 5161b01..3dd1a82 100644 --- a/src/HopFrame.Security/Authentication/OpenID/Implementation/OpenIdAccessor.cs +++ b/src/HopFrame.Security/Authentication/OpenID/Implementation/OpenIdAccessor.cs @@ -8,8 +8,6 @@ using Microsoft.Extensions.Options; namespace HopFrame.Security.Authentication.OpenID.Implementation; internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions options, IHttpContextAccessor accessor, IMemoryCache cache) : IOpenIdAccessor { - private const string DefaultCallbackEndpoint = "api/v1/openid/callback"; - private const string ConfigurationCacheKey = "HopFrame:OpenID:Configuration"; private const string AuthCodeCacheKey = "HopFrame:OpenID:Code:"; private const string TokenCacheKey = "HopFrame:OpenID:Token:"; @@ -34,13 +32,13 @@ internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions RequestToken(string code) { + public async Task RequestToken(string code, string defaultCallback) { if (options.Value.Cache.Enabled && options.Value.Cache.Auth.Enabled && cache.TryGetValue(AuthCodeCacheKey + code, out object cachedToken)) { return cachedToken as OpenIdToken; } var protocol = accessor.HttpContext!.Request.IsHttps ? "https" : "http"; - var callback = options.Value.Callback ?? $"{protocol}://{accessor.HttpContext!.Request.Host.Value}/{DefaultCallbackEndpoint}"; + var callback = options.Value.Callback ?? $"{protocol}://{accessor.HttpContext!.Request.Host.Value}/{defaultCallback}"; var configuration = await LoadConfiguration(); @@ -67,9 +65,9 @@ internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions ConstructAuthUri(string state = null) { + public async Task ConstructAuthUri(string defaultCallback, string state = null) { var protocol = accessor.HttpContext!.Request.IsHttps ? "https" : "http"; - var callback = options.Value.Callback ?? $"{protocol}://{accessor.HttpContext!.Request.Host.Value}/{DefaultCallbackEndpoint}"; + var callback = options.Value.Callback ?? $"{protocol}://{accessor.HttpContext!.Request.Host.Value}/{defaultCallback}"; var configuration = await LoadConfiguration(); return $"{configuration.AuthorizationEndpoint}?response_type=code&client_id={options.Value.ClientId}&redirect_uri={callback}&scope=openid%20profile%20email%20offline_access&state={state}"; diff --git a/src/HopFrame.Web/AuthMiddleware.cs b/src/HopFrame.Web/AuthMiddleware.cs index b5fbc93..4a9b216 100644 --- a/src/HopFrame.Web/AuthMiddleware.cs +++ b/src/HopFrame.Web/AuthMiddleware.cs @@ -1,7 +1,6 @@ using System.Security.Claims; using HopFrame.Database.Repositories; using HopFrame.Security.Authentication; -using HopFrame.Security.Claims; using HopFrame.Web.Services; using Microsoft.AspNetCore.Http; @@ -20,16 +19,10 @@ public sealed class AuthMiddleware(IAuthService auth, IPermissionRepository perm next?.Invoke(context); return; } - - var claims = new List { - new(HopFrameClaimTypes.AccessTokenId, token.TokenId.ToString()), - new(HopFrameClaimTypes.UserId, token.Owner.Id.ToString()) - }; - var permissions = await perms.GetFullPermissions(token); - claims.AddRange(permissions.Select(perm => new Claim(HopFrameClaimTypes.Permission, perm))); - - context.User.AddIdentity(new ClaimsIdentity(claims, HopFrameAuthentication.SchemeName)); + var principal = await HopFrameAuthentication.GenerateClaims(token, perms); + if (principal?.Identity is ClaimsIdentity identity) + context.User.AddIdentity(identity); } await next?.Invoke(context); diff --git a/src/HopFrame.Web/Services/Implementation/AuthService.cs b/src/HopFrame.Web/Services/Implementation/AuthService.cs index 7bc38a4..5c95a8f 100644 --- a/src/HopFrame.Web/Services/Implementation/AuthService.cs +++ b/src/HopFrame.Web/Services/Implementation/AuthService.cs @@ -1,6 +1,8 @@ using HopFrame.Database.Models; using HopFrame.Database.Repositories; using HopFrame.Security.Authentication; +using HopFrame.Security.Authentication.OpenID; +using HopFrame.Security.Authentication.OpenID.Options; using HopFrame.Security.Claims; using HopFrame.Security.Models; using Microsoft.AspNetCore.Http; @@ -13,10 +15,15 @@ internal class AuthService( IHttpContextAccessor httpAccessor, ITokenRepository tokens, ITokenContext context, - IOptions options) + IOptions options, + IOptions openIdOptions, + IOpenIdAccessor accessor, + IUserRepository users) : IAuthService { public async Task Register(UserRegister register) { + if (!options.Value.DefaultAuthentication) return; + var user = await userService.AddUser(new User { Username = register.Username, Email = register.Email, @@ -41,6 +48,8 @@ internal class AuthService( } public async Task Login(UserLogin login) { + if (!options.Value.DefaultAuthentication) return false; + var user = await userService.GetUserByEmail(login.Email); if (user == null) return false; @@ -75,6 +84,45 @@ internal class AuthService( if (string.IsNullOrWhiteSpace(refreshToken)) return null; + if (openIdOptions.Value.Enabled && !Guid.TryParse(refreshToken, out _)) { + var openIdToken = await accessor.RefreshAccessToken(refreshToken); + + if (openIdToken is null) + return null; + + var inspection = await accessor.InspectToken(openIdToken.AccessToken); + + var email = inspection.Email; + if (string.IsNullOrEmpty(email)) + return null; + + var user = await users.GetUserByEmail(email); + if (user is null) { + if (!openIdOptions.Value.GenerateUsers) + return null; + + var username = inspection.PreferredUsername; + user = await users.AddUser(new User { + Email = email, + Username = username + }); + } + + httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, openIdToken.AccessToken, new CookieOptions { + MaxAge = TimeSpan.FromSeconds(openIdToken.ExpiresIn), + HttpOnly = false, + Secure = true + }); + return new() { + Owner = user, + CreatedAt = DateTime.Now, + Type = Token.OpenIdTokenType + }; + } + + if (!options.Value.DefaultAuthentication) + return null; + var token = await tokens.GetToken(refreshToken); if (token is null || token.Type != Token.RefreshTokenType) return null; @@ -96,7 +144,7 @@ internal class AuthService( var accessToken = context.AccessToken; if (accessToken is null) return false; - if (accessToken.Type != Token.AccessTokenType) return false; + if (accessToken.Type != Token.AccessTokenType && accessToken.Type != Token.OpenIdTokenType) return false; if (accessToken.CreatedAt + options.Value.AccessTokenTime < DateTime.Now) return false; if (accessToken.Owner is null) return false; diff --git a/testing/HopFrame.Testing.Api/Controllers/AuthController.cs b/testing/HopFrame.Testing.Api/Controllers/AuthController.cs deleted file mode 100644 index 083fd34..0000000 --- a/testing/HopFrame.Testing.Api/Controllers/AuthController.cs +++ /dev/null @@ -1,28 +0,0 @@ -using HopFrame.Security.Authentication.OpenID; -using Microsoft.AspNetCore.Mvc; -using HopFrame.Security.Authentication.OpenID.Models; - -namespace HopFrame.Testing.Api.Controllers; - -public class AuthController(IOpenIdAccessor accessor) : Controller { - - [HttpGet("auth/callback")] - public async Task Callback([FromQuery] string code, [FromQuery] string state) { - if (string.IsNullOrEmpty(code)) { - return BadRequest("Authorization code is missing"); - } - - var token = await accessor.RequestToken(code); - return Ok(token); - } - - [HttpGet("auth")] - public async Task Authenticate() { - return Redirect(await accessor.ConstructAuthUri()); - } - - [HttpGet("check")] - public async Task> Check([FromQuery] string token) { - return Ok(await accessor.InspectToken(token)); - } -} \ No newline at end of file diff --git a/tests/HopFrame.Tests.Api/AuthLogicTests.cs b/tests/HopFrame.Tests.Api/AuthLogicTests.cs index 39975f5..321b4a0 100644 --- a/tests/HopFrame.Tests.Api/AuthLogicTests.cs +++ b/tests/HopFrame.Tests.Api/AuthLogicTests.cs @@ -329,7 +329,7 @@ public class AuthLogicTests { } [Fact] - public async Task Logout_With_NoAccessToken_Should_Fail() { + public async Task Logout_With_NoAccessToken_Should_Succeed() { // Arrange var (auth, context) = SetupEnvironment(provideAccessToken: false); context.Response.Cookies.Append(ITokenContext.AccessTokenType, _accessToken.ToString()); @@ -339,14 +339,13 @@ public class AuthLogicTests { var result = await auth.Logout(); // Assert - Assert.False(result.IsSuccessful); - Assert.Equal(HttpStatusCode.Conflict, result.State); - Assert.Equal(_accessToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); - Assert.Equal(_refreshToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType)); + Assert.True(result.IsSuccessful); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType)); } [Fact] - public async Task Logout_With_NoRefreshToken_Should_Fail() { + public async Task Logout_With_NoRefreshToken_Should_Succeed() { // Arrange var (auth, context) = SetupEnvironment(); context.Response.Cookies.Append(ITokenContext.AccessTokenType, _accessToken.ToString()); @@ -356,10 +355,9 @@ public class AuthLogicTests { var result = await auth.Logout(); // Assert - Assert.False(result.IsSuccessful); - Assert.Equal(HttpStatusCode.Conflict, result.State); - Assert.Equal(_accessToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); - Assert.Equal(_refreshToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType)); + Assert.True(result.IsSuccessful); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType)); } [Fact] diff --git a/tests/HopFrame.Tests.Web/AuthServiceTests.cs b/tests/HopFrame.Tests.Web/AuthServiceTests.cs index 306a94b..56c0604 100644 --- a/tests/HopFrame.Tests.Web/AuthServiceTests.cs +++ b/tests/HopFrame.Tests.Web/AuthServiceTests.cs @@ -1,6 +1,8 @@ using HopFrame.Database.Models; using HopFrame.Database.Repositories; using HopFrame.Security.Authentication; +using HopFrame.Security.Authentication.OpenID; +using HopFrame.Security.Authentication.OpenID.Options; using HopFrame.Security.Claims; using HopFrame.Security.Models; using HopFrame.Tests.Web.Extensions; @@ -68,7 +70,16 @@ public class AuthServiceTests { .Setup(c => c.AccessToken) .Returns(providedAccessToken); - return (new AuthService(users.Object, accessor, tokens.Object, context.Object, new OptionsWrapper(new HopFrameAuthenticationOptions())), accessor.HttpContext); + return (new AuthService( + users.Object, + accessor, + tokens.Object, + context.Object, + new OptionsWrapper(new HopFrameAuthenticationOptions()), + new OptionsWrapper(new OpenIdOptions()), + new Mock().Object, + users.Object + ), accessor.HttpContext); } private User CreateDummyUser() => new() { diff --git a/tests/HopFrame.Tests.Web/Pages/AuthorizedViewTests.cs b/tests/HopFrame.Tests.Web/Pages/AuthorizedViewTests.cs index 92b9611..35355fd 100644 --- a/tests/HopFrame.Tests.Web/Pages/AuthorizedViewTests.cs +++ b/tests/HopFrame.Tests.Web/Pages/AuthorizedViewTests.cs @@ -2,6 +2,7 @@ using System.Security.Claims; using Bunit; using Bunit.TestDoubles; using HopFrame.Security.Authentication; +using HopFrame.Security.Authentication.OpenID; using HopFrame.Security.Claims; using HopFrame.Web.Components; using Microsoft.AspNetCore.Components; From ffae1be340b8d4fd6eb1bf0ab707d859ed2f7c21 Mon Sep 17 00:00:00 2001 From: Leon Hoppe Date: Sun, 22 Dec 2024 15:13:55 +0100 Subject: [PATCH 24/27] added proper documentation for openid integration --- README.md | 2 + docs/authentication.md | 4 +- docs/openid.md | 120 +++++++++++++++++++++++++++++++++++++++++ docs/readme.md | 1 + 4 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 docs/openid.md diff --git a/README.md b/README.md index 4d53003..b41c964 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ A simple backend management api for ASP.NET Core Web APIs - [x] User authentication - [x] Permission management - [x] Generated frontend administration boards +- [x] API token support +- [x] OpenID authentication integration # Usage There are two different versions of HopFrame, either the Web API version or the full Blazor web version. diff --git a/docs/authentication.md b/docs/authentication.md index c3489d0..a8e29a1 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -24,7 +24,9 @@ by configuring your configuration to load these. > custom configurations / HopFrame services. You can specify `Seconds`, `Minutes`, `Hours` and `Days` for either of the two token types. -These get combined to a single time span. +These get combined to a single time span. You can also completely disable the default authentication +by setting the `DefaultAuthentication` to `false`. Note that you will no longer be able to login in any +way unless you enabled the [OpenID](./openid.md) authentication. #### Configuration example ```json diff --git a/docs/openid.md b/docs/openid.md new file mode 100644 index 0000000..00a15f4 --- /dev/null +++ b/docs/openid.md @@ -0,0 +1,120 @@ +# OpenID Authentication +The HopFrame allows you to use an OpenID provider as your authentication provider for single sign on or better security +etc. To use it, you just simply need to configure it through the `appsettings.json` or environment variables. + +>**Note**: The Blazor module has not yet implemented endpoints for the login process, but the middleware is correctly +> configured and the `IOpenIdAccessor` service is also provided for you to easily implement the endpoints yourself. + +When you have enabled the integration, new endpoints will also be provided to perform the authentication. +simply use the swagger explorer to look up how the endpoints function. They're all under the subroute +`/api/v1/openid/`. + +## Configure the HopFrame to use OpenID authentication + +1. Create / Configure your OpenID provider: + + - Save the ClientID and Client Secret from the provider, because you need it later. + - The default redirect uri looks something like this: `https://example.com/api/v1/openid/callback`. + - **Replace** the origin with the FQDN of your service. + - In order for the HopFrame to automatically renew expired access tokens you need to enable the `offline_access` scope. + - The integration also works without doing that, but then you need to reauthenticate every time your access token expires. + +2. Configure the HopFrame integration: + + >**Hint**: All of these configuration options can also be defined as environment variables. Use '__' + > to separate the namespaces like so: `HOPFRAME__AUTHENTICATION__OPENID__ENABLED=true` + + - Add the following lines to your `appsettings.json`: + ```json + "HopFrame": { + "Authentication": { + "OpenID": { + "Enabled": true, + "Issuer": "your-issuer", + "ClientId": "your-client-id", + "ClientSecret": "your-client-secret" + } + } + } + ``` + + >**Hint**: If you are using Authentik, the issuer url looks something like this: `https://auth.example.com/application/o/application-name/`. + > Just replace the FQDN and application-name with your configured application. + + - **Optional**: You can also disable the default authentication via the config: + + ```json + "HopFrame": { + "Authentication": { + "DefaultAuthentication": false + } + } + ``` + + - **Optional**: By default, the HopFrame will cache the api responses to reduce api latency. This can also be configured in the config (the cache can also be completely disabled here): + + ```json + "HopFrame": { + "Authentication": { + "OpenID": { + "Cache": { + "Enabled": true, + "Configuration": { + "Hours": 5 + }, + "Auth": { + "Seconds": 90 + }, + "Inspection": { + "Minutes": 5 + } + } + } + } + } + ``` + + - **Optional**: You can also define your own callback endpoint like so (you also need to add / replace the endpoint in the provider settings): + + ```json + "HopFrame": { + "Authentication": { + "OpenID": { + "Callback": "https://example.com/auth/callback" + } + } + } + ``` + + - **Optional**: You can also prevent new users from being created by disabling it in the config: + + ```json + "HopFrame": { + "Authentication": { + "OpenID": { + "GenerateUsers": false + } + } + } + ``` + +## Use the abstraction to integrate OpenID yourself + +The HopFrame has a service, that simplifies the communication with the OpenID provider called `IOpenIdAccessor`. +You can inject it like every other service in your application. + +```csharp +public interface IOpenIdAccessor { + + Task LoadConfiguration(); + + Task RequestToken(string code, string defaultCallback); + + Task ConstructAuthUri(string defaultCallback, string state = null); + + Task InspectToken(string token); + + Task RefreshAccessToken(string refreshToken); + +} +``` diff --git a/docs/readme.md b/docs/readme.md index df7f363..99198a9 100644 --- a/docs/readme.md +++ b/docs/readme.md @@ -9,6 +9,7 @@ The HopFrame comes in two variations, you can eiter only use the backend with so - [Base Models](./models.md) - [Authentication](./authentication.md) - [Permissions](./permissions.md) +- [OpenID Integration](./openid.md) ## HopFrame Web API From ae7474510856ce2c5f86923bc397132d238cc909 Mon Sep 17 00:00:00 2001 From: Leon Hoppe Date: Sun, 22 Dec 2024 17:32:09 +0100 Subject: [PATCH 25/27] Added user management endpoints --- src/HopFrame.Api/Controller/UserController.cs | 83 ++++++++++++++ .../Extensions/ServiceCollectionExtensions.cs | 6 +- src/HopFrame.Api/Logic/ILogicResult.cs | 1 + src/HopFrame.Api/Logic/IUserLogic.cs | 16 +++ .../Logic/Implementation/AuthLogic.cs | 2 +- .../Logic/Implementation/UserLogic.cs | 105 ++++++++++++++++++ src/HopFrame.Api/Models/UserCreator.cs | 8 ++ src/HopFrame.Api/Models/UserPasswordChange.cs | 6 + src/HopFrame.Database/Models/User.cs | 4 +- 9 files changed, 226 insertions(+), 5 deletions(-) create mode 100644 src/HopFrame.Api/Controller/UserController.cs create mode 100644 src/HopFrame.Api/Logic/IUserLogic.cs create mode 100644 src/HopFrame.Api/Logic/Implementation/UserLogic.cs create mode 100644 src/HopFrame.Api/Models/UserCreator.cs create mode 100644 src/HopFrame.Api/Models/UserPasswordChange.cs diff --git a/src/HopFrame.Api/Controller/UserController.cs b/src/HopFrame.Api/Controller/UserController.cs new file mode 100644 index 0000000..6c0dd1b --- /dev/null +++ b/src/HopFrame.Api/Controller/UserController.cs @@ -0,0 +1,83 @@ +using HopFrame.Api.Logic; +using HopFrame.Api.Models; +using HopFrame.Database.Models; +using HopFrame.Database.Repositories; +using HopFrame.Security.Authorization; +using HopFrame.Security.Claims; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; + +namespace HopFrame.Api.Controller; + +[ApiController, Route("api/v1/users")] +public class UserController(IOptions permissions, IPermissionRepository perms, ITokenContext context, IUserLogic logic) : ControllerBase { + + private async Task AuthorizeRequest(string permission) { + return await perms.HasPermission(context.AccessToken, permission); + } + + [HttpGet, Authorized] + public async Task>> GetUsers() { + if (!await AuthorizeRequest(permissions.Value.Users.Read)) + return Unauthorized(); + + return await logic.GetUsers(); + } + + [HttpGet("{userId}"), Authorized] + public async Task> GetUser(string userId) { + if (!await AuthorizeRequest(permissions.Value.Users.Read)) + return Unauthorized(); + + return await logic.GetUser(userId); + } + + [HttpGet("username/{username}"), Authorized] + public async Task> GetUserByUsername(string username) { + if (!await AuthorizeRequest(permissions.Value.Users.Read)) + return Unauthorized(); + + return await logic.GetUserByUsername(username); + } + + [HttpGet("email/{email}"), Authorized] + public async Task> GetUserByEmail(string email) { + if (!await AuthorizeRequest(permissions.Value.Users.Read)) + return Unauthorized(); + + return await logic.GetUserByEmail(email); + } + + [HttpPost, Authorized] + public async Task> CreateUser([FromBody] UserCreator user) { + if (!await AuthorizeRequest(permissions.Value.Users.Create)) + return Unauthorized(); + + return await logic.CreateUser(user); + } + + [HttpPut("{userId}"), Authorized] + public async Task> UpdateUser(string userId, [FromBody] User user) { + if (!await AuthorizeRequest(permissions.Value.Users.Update)) + return Unauthorized(); + + return await logic.UpdateUser(userId, user); + } + + [HttpDelete("{userId}"), Authorized] + public async Task DeleteUser(string userId) { + if (!await AuthorizeRequest(permissions.Value.Users.Delete)) + return Unauthorized(); + + return await logic.DeleteUser(userId); + } + + [HttpPut("{userId}/password"), Authorized] + public async Task ChangePassword(string userId, [FromBody] UserPasswordChange passwordChange) { + if (context.User.Id.ToString() != userId && !await AuthorizeRequest(permissions.Value.Users.Update)) + return Unauthorized(); + + return await logic.UpdatePassword(userId, passwordChange.OldPassword, passwordChange.NewPassword); + } + +} \ No newline at end of file diff --git a/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs b/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs index a596033..936231f 100644 --- a/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs +++ b/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs @@ -19,9 +19,10 @@ public static class ServiceCollectionExtensions { /// The configuration used to configure HopFrame authentication /// The data source for all HopFrame entities public static void AddHopFrame(this IServiceCollection services, ConfigurationManager configuration) where TDbContext : HopDbContextBase { - var controllers = new List(); + var controllers = new List { typeof(UserController) }; - if (configuration.GetValue("HopFrame:Authentication:DefaultAuthentication")) + var defaultAuthenticationSection = configuration.GetSection("HopFrame:Authentication:DefaultAuthentication"); + if (!defaultAuthenticationSection.Exists() || configuration.GetValue("HopFrame:Authentication:DefaultAuthentication")) controllers.Add(typeof(AuthController)); if (configuration.GetValue("HopFrame:Authentication:OpenID:Enabled")) @@ -46,6 +47,7 @@ public static class ServiceCollectionExtensions { services.AddHopFrameRepositories(); services.TryAddSingleton(); services.AddScoped(); + services.AddScoped(); services.AddHopFrameAuthentication(configuration); } diff --git a/src/HopFrame.Api/Logic/ILogicResult.cs b/src/HopFrame.Api/Logic/ILogicResult.cs index 5efb2aa..c3ff17b 100644 --- a/src/HopFrame.Api/Logic/ILogicResult.cs +++ b/src/HopFrame.Api/Logic/ILogicResult.cs @@ -1,4 +1,5 @@ using System.Net; +using Microsoft.AspNetCore.Mvc; namespace HopFrame.Api.Logic; diff --git a/src/HopFrame.Api/Logic/IUserLogic.cs b/src/HopFrame.Api/Logic/IUserLogic.cs new file mode 100644 index 0000000..42f44e9 --- /dev/null +++ b/src/HopFrame.Api/Logic/IUserLogic.cs @@ -0,0 +1,16 @@ +using HopFrame.Api.Models; +using HopFrame.Database.Models; + +namespace HopFrame.Api.Logic; + +public interface IUserLogic { + Task>> GetUsers(); + Task> GetUser(string id); + Task> GetUserByUsername(string username); + Task> GetUserByEmail(string email); + + Task> CreateUser(UserCreator user); + Task> UpdateUser(string id, User user); + Task DeleteUser(string id); + Task UpdatePassword(string id, string oldPassword, string newPassword); +} \ No newline at end of file diff --git a/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs b/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs index d0d188c..444e084 100644 --- a/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs +++ b/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs @@ -9,7 +9,7 @@ using Microsoft.Extensions.Options; namespace HopFrame.Api.Logic.Implementation; -internal class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenContext tokenContext, IHttpContextAccessor accessor, IOptions options) : IAuthLogic { +internal sealed class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenContext tokenContext, IHttpContextAccessor accessor, IOptions options) : IAuthLogic { public async Task>> Login(UserLogin login) { if (!options.Value.DefaultAuthentication) return LogicResult>.BadRequest("HopFrame authentication scheme is disabled"); diff --git a/src/HopFrame.Api/Logic/Implementation/UserLogic.cs b/src/HopFrame.Api/Logic/Implementation/UserLogic.cs new file mode 100644 index 0000000..b9db19c --- /dev/null +++ b/src/HopFrame.Api/Logic/Implementation/UserLogic.cs @@ -0,0 +1,105 @@ +using HopFrame.Api.Models; +using HopFrame.Database.Models; +using HopFrame.Database.Repositories; +using HopFrame.Security.Claims; + +namespace HopFrame.Api.Logic.Implementation; + +internal sealed class UserLogic(IUserRepository users, ITokenContext context) : IUserLogic { + public async Task>> GetUsers() { + return LogicResult>.Ok(await users.GetUsers()); + } + + public async Task> GetUser(string id) { + if (!Guid.TryParse(id, out var userId)) + return LogicResult.BadRequest("Invalid user id"); + + var user = await users.GetUser(userId); + + if (user is null) + return LogicResult.NotFound("That user does not exist"); + + return LogicResult.Ok(user); + } + + public async Task> GetUserByUsername(string username) { + var user = await users.GetUserByUsername(username); + + if (user is null) + return LogicResult.NotFound("That user does not exist"); + + return LogicResult.Ok(user); + } + + public async Task> GetUserByEmail(string email) { + var user = await users.GetUserByEmail(email); + + if (user is null) + return LogicResult.NotFound("That user does not exist"); + + return LogicResult.Ok(user); + } + + public async Task> CreateUser(UserCreator user) { + var createdUser = new User { + Email = user.Email, + Username = user.Username, + Password = user.Password, + }; + createdUser.Permissions = user.Permissions?.Select(p => new Permission { + GrantedAt = DateTime.Now, + PermissionName = p, + User = createdUser + }).ToList(); + + var newUser = await users.AddUser(createdUser); + + if (newUser is null) + return LogicResult.Conflict("That user already exists"); + + return LogicResult.Ok(newUser); + } + + public async Task> UpdateUser(string id, User user) { + if (!Guid.TryParse(id, out var userId)) + return LogicResult.BadRequest("Invalid user id"); + + if (user.Id != userId) + return LogicResult.Conflict("Cannot edit user with different user id"); + + if (await users.GetUser(userId) is null) + return LogicResult.NotFound("That user does not exist"); + + await users.UpdateUser(user); + return LogicResult.Ok(user); + } + + public async Task DeleteUser(string id) { + if (!Guid.TryParse(id, out var userId)) + return LogicResult.BadRequest("Invalid user id"); + + var user = await users.GetUser(userId); + + if (user is null) + return LogicResult.NotFound("That user does not exist"); + + await users.DeleteUser(user); + return LogicResult.Ok(); + } + + public async Task UpdatePassword(string id, string oldPassword, string newPassword) { + if (!Guid.TryParse(id, out var userId)) + return LogicResult.BadRequest("Invalid user id"); + + var user = await users.GetUser(userId); + + if (user is null) + return LogicResult.NotFound("That user does not exist"); + + if (userId == context.User.Id && !await users.CheckUserPassword(user, oldPassword)) + return LogicResult.Conflict("Old password is not correct"); + + await users.ChangePassword(user, newPassword); + return LogicResult.Ok(); + } +} \ No newline at end of file diff --git a/src/HopFrame.Api/Models/UserCreator.cs b/src/HopFrame.Api/Models/UserCreator.cs new file mode 100644 index 0000000..9af93fb --- /dev/null +++ b/src/HopFrame.Api/Models/UserCreator.cs @@ -0,0 +1,8 @@ +namespace HopFrame.Api.Models; + +public class UserCreator { + public string Username { get; set; } + public string Email { get; set; } + public string Password { get; set; } + public virtual List Permissions { get; set; } +} \ No newline at end of file diff --git a/src/HopFrame.Api/Models/UserPasswordChange.cs b/src/HopFrame.Api/Models/UserPasswordChange.cs new file mode 100644 index 0000000..e6e183b --- /dev/null +++ b/src/HopFrame.Api/Models/UserPasswordChange.cs @@ -0,0 +1,6 @@ +namespace HopFrame.Api.Models; + +public class UserPasswordChange { + public string OldPassword { get; set; } + public string NewPassword { get; set; } +} \ No newline at end of file diff --git a/src/HopFrame.Database/Models/User.cs b/src/HopFrame.Database/Models/User.cs index feec39c..e540198 100644 --- a/src/HopFrame.Database/Models/User.cs +++ b/src/HopFrame.Database/Models/User.cs @@ -5,7 +5,7 @@ namespace HopFrame.Database.Models; public class User : IPermissionOwner { - [Key, Required, MinLength(36), MaxLength(36)] + [Key, Required] public Guid Id { get; init; } [Required, MaxLength(50)] @@ -14,7 +14,7 @@ public class User : IPermissionOwner { [Required, MaxLength(50), EmailAddress] public string Email { get; set; } - [Required, MinLength(8), MaxLength(255), JsonIgnore] + [MinLength(8), MaxLength(255), JsonIgnore] public string Password { get; set; } [Required] From 4aab0112248ad679fe6f486e4bb9e2b440802cb3 Mon Sep 17 00:00:00 2001 From: Leon Hoppe Date: Sun, 22 Dec 2024 18:08:05 +0100 Subject: [PATCH 26/27] Fixed Database update problem + added group management endpoints --- .../Controller/GroupController.cs | 74 +++++++++++++++++++ .../Extensions/ServiceCollectionExtensions.cs | 3 +- src/HopFrame.Api/Logic/IGroupLogic.cs | 14 ++++ .../Logic/Implementation/GroupLogic.cs | 66 +++++++++++++++++ .../Implementation/GroupRepository.cs | 31 ++++++-- .../Implementation/UserRepository.cs | 39 +++++++++- 6 files changed, 218 insertions(+), 9 deletions(-) create mode 100644 src/HopFrame.Api/Controller/GroupController.cs create mode 100644 src/HopFrame.Api/Logic/IGroupLogic.cs create mode 100644 src/HopFrame.Api/Logic/Implementation/GroupLogic.cs diff --git a/src/HopFrame.Api/Controller/GroupController.cs b/src/HopFrame.Api/Controller/GroupController.cs new file mode 100644 index 0000000..fdfbc07 --- /dev/null +++ b/src/HopFrame.Api/Controller/GroupController.cs @@ -0,0 +1,74 @@ +using HopFrame.Api.Logic; +using HopFrame.Database.Models; +using HopFrame.Database.Repositories; +using HopFrame.Security.Authorization; +using HopFrame.Security.Claims; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; + +namespace HopFrame.Api.Controller; + +[ApiController, Route("api/v1/groups")] +public class GroupController(IOptions permissions, IPermissionRepository perms, ITokenContext context, IGroupLogic groups) : ControllerBase { + + private async Task AuthorizeRequest(string permission) { + return await perms.HasPermission(context.AccessToken, permission); + } + + [HttpGet, Authorized] + public async Task>> GetGroups() { + if (!await AuthorizeRequest(permissions.Value.Groups.Read)) + return Unauthorized(); + + return await groups.GetGroups(); + } + + [HttpGet("default"), Authorized] + public async Task>> GetDefaultGroups() { + if (!await AuthorizeRequest(permissions.Value.Groups.Read)) + return Unauthorized(); + + return await groups.GetDefaultGroups(); + } + + [HttpGet("user/{userId}"), Authorized] + public async Task>> GetUserGroups(string userId) { + if (!await AuthorizeRequest(permissions.Value.Groups.Read)) + return Unauthorized(); + + return await groups.GetUserGroups(userId); + } + + [HttpGet("{name}"), Authorized] + public async Task> GetGroup(string name) { + if (!await AuthorizeRequest(permissions.Value.Groups.Read)) + return Unauthorized(); + + return await groups.GetGroup(name); + } + + [HttpPost, Authorized] + public async Task> CreateGroup([FromBody] PermissionGroup group) { + if (!await AuthorizeRequest(permissions.Value.Groups.Create)) + return Unauthorized(); + + return await groups.CreateGroup(group); + } + + [HttpPut, Authorized] + public async Task> UpdateGroup([FromBody] PermissionGroup group) { + if (!await AuthorizeRequest(permissions.Value.Groups.Update)) + return Unauthorized(); + + return await groups.UpdateGroup(group); + } + + [HttpDelete("{name}"), Authorized] + public async Task DeleteGroup(string name) { + if (!await AuthorizeRequest(permissions.Value.Groups.Delete)) + return Unauthorized(); + + return await groups.DeleteGroup(name); + } + +} \ No newline at end of file diff --git a/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs b/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs index 936231f..19436eb 100644 --- a/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs +++ b/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs @@ -19,7 +19,7 @@ public static class ServiceCollectionExtensions { /// The configuration used to configure HopFrame authentication /// The data source for all HopFrame entities public static void AddHopFrame(this IServiceCollection services, ConfigurationManager configuration) where TDbContext : HopDbContextBase { - var controllers = new List { typeof(UserController) }; + var controllers = new List { typeof(UserController), typeof(GroupController) }; var defaultAuthenticationSection = configuration.GetSection("HopFrame:Authentication:DefaultAuthentication"); if (!defaultAuthenticationSection.Exists() || configuration.GetValue("HopFrame:Authentication:DefaultAuthentication")) @@ -48,6 +48,7 @@ public static class ServiceCollectionExtensions { services.TryAddSingleton(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddHopFrameAuthentication(configuration); } diff --git a/src/HopFrame.Api/Logic/IGroupLogic.cs b/src/HopFrame.Api/Logic/IGroupLogic.cs new file mode 100644 index 0000000..48bdd45 --- /dev/null +++ b/src/HopFrame.Api/Logic/IGroupLogic.cs @@ -0,0 +1,14 @@ +using HopFrame.Database.Models; + +namespace HopFrame.Api.Logic; + +public interface IGroupLogic { + Task>> GetGroups(); + Task>> GetDefaultGroups(); + Task>> GetUserGroups(string userId); + Task> GetGroup(string name); + + Task> CreateGroup(PermissionGroup group); + Task> UpdateGroup(PermissionGroup group); + Task DeleteGroup(string name); +} \ No newline at end of file diff --git a/src/HopFrame.Api/Logic/Implementation/GroupLogic.cs b/src/HopFrame.Api/Logic/Implementation/GroupLogic.cs new file mode 100644 index 0000000..dd3e5b7 --- /dev/null +++ b/src/HopFrame.Api/Logic/Implementation/GroupLogic.cs @@ -0,0 +1,66 @@ +using HopFrame.Database.Models; +using HopFrame.Database.Repositories; + +namespace HopFrame.Api.Logic.Implementation; + +internal sealed class GroupLogic(IGroupRepository groups, IUserRepository users) : IGroupLogic { + public async Task>> GetGroups() { + return LogicResult>.Ok(await groups.GetPermissionGroups()); + } + + public async Task>> GetDefaultGroups() { + return LogicResult>.Ok(await groups.GetDefaultGroups()); + } + + public async Task>> GetUserGroups(string id) { + if (!Guid.TryParse(id, out var userId)) + return LogicResult>.BadRequest("Invalid user id"); + + var user = await users.GetUser(userId); + + if (user is null) + return LogicResult>.NotFound("That user does not exist"); + + return LogicResult>.Ok(await groups.GetUserGroups(user)); + } + + public async Task> GetGroup(string name) { + var group = await groups.GetPermissionGroup(name); + + if (group is null) + return LogicResult.NotFound("That group does not exist"); + + return LogicResult.Ok(group); + } + + public async Task> CreateGroup(PermissionGroup group) { + if (group is null) + return LogicResult.BadRequest("Provide a group"); + + if (!group.Name.StartsWith("group.")) + return LogicResult.BadRequest("Group names must start with 'group.'"); + + if (await groups.GetPermissionGroup(group.Name) != null) + return LogicResult.Conflict("That group already exists"); + + return LogicResult.Ok(await groups.CreatePermissionGroup(group)); + } + + public async Task> UpdateGroup(PermissionGroup group) { + if (await groups.GetPermissionGroup(group.Name) == null) + return LogicResult.NotFound("That user does not exist"); + + await groups.EditPermissionGroup(group); + return LogicResult.Ok(group); + } + + public async Task DeleteGroup(string name) { + var group = await groups.GetPermissionGroup(name); + + if (group is null) + return LogicResult.NotFound("That group does not exist"); + + await groups.DeletePermissionGroup(group); + return LogicResult.Ok(); + } +} \ No newline at end of file diff --git a/src/HopFrame.Database/Repositories/Implementation/GroupRepository.cs b/src/HopFrame.Database/Repositories/Implementation/GroupRepository.cs index 547e193..b190ce6 100644 --- a/src/HopFrame.Database/Repositories/Implementation/GroupRepository.cs +++ b/src/HopFrame.Database/Repositories/Implementation/GroupRepository.cs @@ -33,19 +33,38 @@ internal sealed class GroupRepository(TDbContext context) : IGroupRe } public async Task EditPermissionGroup(PermissionGroup group) { - var orig = await context.Groups.SingleOrDefaultAsync(g => g.Name == group.Name); - + var orig = await context.Groups + .Include(g => g.Permissions) // Include related entities + .SingleOrDefaultAsync(g => g.Name == group.Name); + if (orig is null) return; - var entity = context.Groups.Update(orig); + // Update the main entity's properties + orig.IsDefaultGroup = group.IsDefaultGroup; + orig.Description = group.Description; - entity.Entity.IsDefaultGroup = group.IsDefaultGroup; - entity.Entity.Description = group.Description; - entity.Entity.Permissions = group.Permissions; + // Update the permissions + foreach (var permission in group.Permissions) { + var existingPermission = orig.Permissions.FirstOrDefault(p => p.Id == permission.Id); + if (existingPermission != null) { + // Update existing permission + context.Entry(existingPermission).CurrentValues.SetValues(permission); + } else { + // Add new permission + orig.Permissions.Add(permission); + } + } + + // Remove deleted permissions + foreach (var permission in orig.Permissions.ToList().Where(permission => group.Permissions.All(p => p.Id != permission.Id))) { + orig.Permissions.Remove(permission); + context.Permissions.Remove(permission); // Ensure it gets removed from the database + } await context.SaveChangesAsync(); } + public async Task CreatePermissionGroup(PermissionGroup group) { group.CreatedAt = DateTime.Now; await context.Groups.AddAsync(group); diff --git a/src/HopFrame.Database/Repositories/Implementation/UserRepository.cs b/src/HopFrame.Database/Repositories/Implementation/UserRepository.cs index c642466..3e4e1b8 100644 --- a/src/HopFrame.Database/Repositories/Implementation/UserRepository.cs +++ b/src/HopFrame.Database/Repositories/Implementation/UserRepository.cs @@ -69,10 +69,45 @@ internal sealed class UserRepository(TDbContext context, IGroupRepos .SingleOrDefaultAsync(entry => entry.Id == user.Id); if (entry is null) return; + // Update the main entity's properties entry.Email = user.Email; entry.Username = user.Username; - entry.Permissions = user.Permissions; - entry.Tokens = user.Tokens; + + // Update Permissions + foreach (var permission in user.Permissions) { + var existingPermission = entry.Permissions.FirstOrDefault(p => p.Id == permission.Id); + if (existingPermission != null) { + // Update existing permission + context.Entry(existingPermission).CurrentValues.SetValues(permission); + } else { + // Add new permission + entry.Permissions.Add(permission); + } + } + + // Remove deleted permissions + foreach (var permission in entry.Permissions.ToList().Where(permission => user.Permissions.All(p => p.Id != permission.Id))) { + entry.Permissions.Remove(permission); + context.Permissions.Remove(permission); // Ensure it gets removed from the database + } + + // Update Tokens + foreach (var token in user.Tokens) { + var existingToken = entry.Tokens.FirstOrDefault(t => t.TokenId == token.TokenId); + if (existingToken != null) { + // Update existing token + context.Entry(existingToken).CurrentValues.SetValues(token); + } else { + // Add new token + entry.Tokens.Add(token); + } + } + + // Remove deleted tokens + foreach (var token in entry.Tokens.ToList().Where(token => user.Tokens.All(t => t.TokenId != token.TokenId))) { + entry.Tokens.Remove(token); + context.Tokens.Remove(token); // Ensure it gets removed from the database + } await context.SaveChangesAsync(); } From 1ede337565830b42cd206d70ee9aafdf3f01b9a7 Mon Sep 17 00:00:00 2001 From: Leon Hoppe Date: Sun, 22 Dec 2024 19:23:22 +0100 Subject: [PATCH 27/27] Updated ci pipeline --- .gitlab-ci.yml | 7 ++++--- HopFrame.sln.DotSettings.user | 1 + 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index d8ae244..47ff617 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -26,9 +26,10 @@ test: publish: stage: publish script: - - dotnet pack -c Release -o . + - export VERSION=$(echo $CI_COMMIT_TAG | sed 's/^v//') + - dotnet pack -c Release -o . /p:Version=$VERSION - for nupkg in *.nupkg; do dotnet nuget push $nupkg -k $NUGET_API_KEY -s https://api.nuget.org/v3/index.json; done only: - - main - variables: + - tags + variables: NUGET_API_KEY: $NUGET_API_KEY diff --git a/HopFrame.sln.DotSettings.user b/HopFrame.sln.DotSettings.user index 12afe51..ae702c9 100644 --- a/HopFrame.sln.DotSettings.user +++ b/HopFrame.sln.DotSettings.user @@ -79,6 +79,7 @@ + \ No newline at end of file