93 Commits

Author SHA1 Message Date
5c7e38aa40 Merge branch 'dev' into 'main'
Release v2.1.3

See merge request leon.hoppe/hopframe!11
2024-12-23 11:43:32 +00:00
11126e8080 Merge branch 'feature/moduleConfig' into 'dev'
Resolve "Module configuration"

See merge request leon.hoppe/hopframe!10
2024-12-23 11:40:41 +00:00
0b9766f7db Added logout function + increased default openid config caching time 2024-12-23 12:38:30 +01:00
849ad649a8 Fixed path combining issues + added cookie helper function 2024-12-23 12:32:12 +01:00
3031dda710 Removed implicit callback definition 2024-12-23 12:17:54 +01:00
73d89a241f Fixed pipeline 2024-12-23 12:03:45 +01:00
df68b6dbf8 properly combined OpenId callback uri 2024-12-23 11:55:56 +01:00
20684ca40a added admin login url customization 2024-12-23 11:33:16 +01:00
a57127af0e Merge branch 'dev' into 'main'
hotfix/pipeline

See merge request leon.hoppe/hopframe!9
2024-12-22 19:31:35 +00:00
20b82245d0 reworked ci pipeline 2024-12-22 20:30:40 +01:00
8db38183c2 Merge branch 'main' into 'dev'
Merge hotfix

See merge request leon.hoppe/hopframe!8
2024-12-22 19:04:58 +00:00
5898ea8188 Merge branch 'hotfix/ci' into 'main'
Removed unnecessary versions + fixed ci artifacts

See merge request leon.hoppe/hopframe!7
2024-12-22 18:39:13 +00:00
1bc48b0ba2 Removed unnecessary versions + fixed ci artifacts 2024-12-22 19:38:16 +01:00
2308e1520d Merge branch 'release/v2.1.0' into 'main'
Release/v2.1.0

See merge request leon.hoppe/hopframe!6
2024-12-22 18:24:17 +00:00
1ede337565 Updated ci pipeline 2024-12-22 19:23:22 +01:00
a7d2f8031e Merge branch 'feature/crud' into 'dev'
Resolve "Crud API endpoints"

See merge request leon.hoppe/hopframe!5
2024-12-22 18:13:36 +00:00
4aab011224 Fixed Database update problem + added group management endpoints 2024-12-22 18:08:05 +01:00
ae74745108 Added user management endpoints 2024-12-22 17:32:09 +01:00
401dfc9909 Merge branch 'feature/openid' into 'dev'
Resolve "OAuth"

See merge request leon.hoppe/hopframe!4
2024-12-22 14:17:05 +00:00
ffae1be340 added proper documentation for openid integration 2024-12-22 15:13:55 +01:00
bee771a30e finished OpenID integration 2024-12-22 14:28:49 +01:00
9b38a10797 Added all necessary api endpoints for OpenID 2024-12-22 10:55:24 +01:00
ba7584c771 Added OpenID authentication method 2024-12-21 22:35:04 +01:00
df89450745 Merge branch 'feature/tokens' into 'dev'
Resolve "API tokens"

See merge request leon.hoppe/hopframe!3
2024-12-21 16:36:12 +00:00
c6aca4baf6 secured api tokens against permission breaches 2024-12-21 17:35:11 +01:00
e47d4917df Added api key documentation + fixed tests 2024-12-21 17:13:18 +01:00
59c452ff73 updated application to check for contextual permissions 2024-12-21 16:55:20 +01:00
ba46147a74 Added API token functionality 2024-12-21 16:09:55 +01:00
c087dbdf2b Merge branch 'feature/config' into 'dev'
Resolve "Configuratable token times"

See merge request leon.hoppe/hopframe!2
2024-12-21 14:03:48 +00:00
92afc85dba added permission configuration 2024-12-21 14:59:04 +01:00
51c15eff4c Added environment variable example for authentication configuration 2024-12-21 14:12:54 +01:00
422fd6c677 Added authentication documentation to table of contents 2024-12-21 14:08:47 +01:00
88c8fe612d Added configuration wrappers, authentication options and authentication documentation 2024-12-21 14:04:49 +01:00
dce0471105 Merge branch 'feature/testing' into 'dev'
Feature/testing

See merge request leon.hoppe/hopframe!1
2024-12-21 12:12:17 +00:00
c4ee8bb1e0 Fixed mistake corrected in v2.0.1 2024-12-21 13:10:46 +01:00
7c835ea49b Started working on UnitTests for frontend 2024-12-11 21:29:03 +01:00
5f746e0bc1 Renamed testing projects 2024-12-10 16:55:36 +01:00
ee7bf1e204 Renamed test projects 2024-12-10 16:39:28 +01:00
4d91ce1819 Implemented HopFrame.Web tests 2024-12-10 16:30:46 +01:00
a4d1d3227b Created tests for HopFrame.Api 2024-12-09 18:41:51 +01:00
14c82f4f06 Implemented security module tests 2024-11-24 17:18:35 +01:00
fca6ef4fa6 Implemented all tests for database module 2024-11-24 16:01:33 +01:00
da45a84f61 Moved test project to correct folder 2024-11-24 12:47:23 +01:00
b7eca1937c Attempted to fix test workflow not being restored 2024-11-24 12:43:41 +01:00
85031de3c2 Started creating tests for database module 2024-11-24 12:40:51 +01:00
1897428d00 Reorganized project in solution folders 2024-11-24 10:42:33 +01:00
4a5855250c Merge branch 'release/v2.0.0' into 'dev'
Resolve "Prepare release v2.0.0"

Closes #23

See merge request leon.hoppe/HopFrame!12
2024-11-23 15:16:16 +00:00
b206ab65ce Changed dotnet sdk version 2024-11-23 16:12:15 +01:00
92a329ef41 Accidentally deleted ci file 2024-11-23 16:10:27 +01:00
92b7e9a8ae Renamed ci file 2024-11-23 16:07:22 +01:00
776b055cfb Added readme to HopFrame.Web.Admin.csproj + added ci scripts 2024-11-23 16:05:57 +01:00
bd7751b44b Updated versions + edited project readme's 2024-11-23 15:40:58 +01:00
f8995ca990 Renamed model repo to provider + provider registration check 2024-11-23 15:29:46 +01:00
beac2aa20c Merge branch 'feature/admincontext-dependencyinjection' into 'dev'
Resolve "Proper Admin Context dependency injection"

Closes #21

See merge request leon.hoppe/HopFrame!11
2024-11-22 17:57:39 +00:00
c00c30ea3f Made admin pages dependency injectable 2024-11-22 18:58:39 +01:00
e257e36b66 Merge branch 'feature/documentation' into 'dev'
Resolve "Documentation"

Closes #18

See merge request leon.hoppe/HopFrame!10
2024-11-22 13:17:35 +00:00
fee99c60b6 Finished documentation 2024-11-22 14:18:40 +01:00
0c2c02136d added docs for admin pages and blazor pages + fixed typos 2024-11-22 14:04:15 +01:00
Leon Hoppe
16ef41800d added endpoint documentation 2024-11-22 12:20:33 +01:00
Leon Hoppe
2bc8a5d70b working on various components of the documentation 2024-11-22 11:42:21 +01:00
Leon Hoppe
a531cd7a47 Reorganized files 2024-11-22 10:49:48 +01:00
e8c61dbc7f Fixed visual separation issues 2024-11-21 17:02:15 +01:00
01cd0e1590 Renamed Authentication to Authorization 2024-11-21 16:56:45 +01:00
eef03c152d finished documentation for installation, database usage and authentication 2024-11-21 16:55:56 +01:00
986c5cebde Added inline documentation + fixed small bugs 2024-11-21 16:15:18 +01:00
6d2f7051ee attempt to fix diagram display issue 2024-11-20 20:24:33 +01:00
6c5c5c9e9d Integrated models directly into docs 2024-11-20 20:20:48 +01:00
leonhoppe
53d214ed8b Merge pull request #9 from leonhoppe/feature/adminLogin
Feature/admin login
2024-11-19 18:19:34 +01:00
4801e790c0 finished admin login 2024-11-19 18:20:27 +01:00
61323f089d Redesigned admin login page 2024-11-14 16:20:09 +01:00
leonhoppe
94e7a41e59 Merge pull request #8 from leonhoppe/feature/generatedAdminPages
Feature/generated admin pages
2024-11-09 11:19:02 +01:00
89a3185c8b cleanup 2024-11-09 11:18:28 +01:00
bc7dfa8e6a Removed unnecessary files + added proper documentation to admin page generators 2024-11-09 11:17:50 +01:00
601b502c8c Improved developer experience for AdminContext's 2024-11-09 10:39:17 +01:00
0cc4eb44da Implemented selector properties for admin pages 2024-11-05 18:45:34 +01:00
d38cce6dc2 Added validation to admin pages 2024-10-27 15:26:25 +01:00
85a45ece55 finished list input + added proper prefix rendering 2024-10-27 11:33:50 +01:00
ce15717c7d Made AdminPageProperty generic + added dynamic display property selecting 2024-10-26 11:54:02 +02:00
599ce2bf43 Working on Admin page modal 2024-10-13 17:42:40 +02:00
d2729870e3 Started working on admin page modal 2024-10-08 21:34:00 +02:00
bc0651cb75 Added order, search and delete functionality to admin page 2024-10-08 19:21:46 +02:00
075ca2286f Worked on admin page listing 2024-10-07 17:39:05 +02:00
6a781990e4 Started creating generated admin page 2024-10-06 12:48:05 +02:00
dd67bba07d Moved populator methods to corresponding classes 2024-10-06 11:26:54 +02:00
6a110d5b8b Added AdminPages to admin dashboard and navigation + created 2.0 todo list 2024-10-06 11:09:00 +02:00
9cf818c55d Created static object provider + added some properties 2024-10-05 12:18:32 +02:00
66ddc22012 Created AdminContext handling 2024-09-30 19:01:39 +02:00
672f0fd2c3 Updated launchSettings.json of test projects 2024-09-28 21:23:42 +02:00
leonhoppe
8d280422cf Merge pull request #6 from leonhoppe/feature/relations
Feature/relations
2024-09-28 12:18:58 +02:00
cfda1bd053 Took Database layout changes into account in frontend pages 2024-09-28 12:17:07 +02:00
f71587d72e Rebuild data storage system so that database dependencies get taken into account 2024-09-26 21:06:48 +02:00
leonhoppe
1b3ffc82ff Merge pull request #5 from leonhoppe/release/v1.1.0
Release/v1.1.0
2024-09-26 12:36:52 +02:00
c3f8615eba Reverted to breaking changes 2024-09-26 12:35:53 +02:00
209 changed files with 8187 additions and 2716 deletions

39
.gitlab-ci.yml Normal file
View File

@@ -0,0 +1,39 @@
image: mcr.microsoft.com/dotnet/sdk:8.0
stages:
- build
- test
- publish
before_script:
- echo "Setting up environment"
- 'dotnet --version'
build:
stage: build
script:
- dotnet restore
- dotnet build --configuration Release --no-restore
artifacts:
paths:
- "**/bin/Release"
expire_in: 10 minutes
test:
stage: test
script:
- dotnet test --verbosity normal
dependencies:
- build
publish:
stage: publish
script:
- 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:
- tags
dependencies:
- build
- test

View File

@@ -5,7 +5,7 @@
<driver-ref>sqlite.xerial</driver-ref> <driver-ref>sqlite.xerial</driver-ref>
<synchronize>true</synchronize> <synchronize>true</synchronize>
<jdbc-driver>org.sqlite.JDBC</jdbc-driver> <jdbc-driver>org.sqlite.JDBC</jdbc-driver>
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/RestApiTest/bin/Debug/net8.0/test.db</jdbc-url> <jdbc-url>jdbc:sqlite:C:\Users\leon\Documents\Projekte\HopFrame\testing\HopFrame.Testing.Api\bin\Debug\net8.0\test.db</jdbc-url>
<jdbc-additional-properties> <jdbc-additional-properties>
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" /> <property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
</jdbc-additional-properties> </jdbc-additional-properties>

14
.idea/.idea.HopFrame/.idea/discord.xml generated Normal file
View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DiscordProjectSettings">
<option name="show" value="PROJECT_FILES" />
<option name="description" value="" />
<option name="applicationTheme" value="default" />
<option name="iconsTheme" value="default" />
<option name="button1Title" value="" />
<option name="button1Url" value="" />
<option name="button2Title" value="" />
<option name="button2Url" value="" />
<option name="customApplicationId" value="" />
</component>
</project>

1025
.idea/config/applicationhost.config generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
Microsoft Visual Studio Solution File, Format Version 12.00 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}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Database", "src\HopFrame.Database\HopFrame.Database.csproj", "{003120AE-F38B-4632-8497-BE4505189627}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RestApiTest", "test\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 EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Security", "src\HopFrame.Security\HopFrame.Security.csproj", "{7F82E1C6-4A42-4337-9E03-2EE6429D004F}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Security", "src\HopFrame.Security\HopFrame.Security.csproj", "{7F82E1C6-4A42-4337-9E03-2EE6429D004F}"
EndProject EndProject
@@ -10,7 +10,23 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Api", "src\HopFram
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Web", "src\HopFrame.Web\HopFrame.Web.csproj", "{3BE585BC-13A5-4BE4-A806-E9EC2D825956}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Web", "src\HopFrame.Web\HopFrame.Web.csproj", "{3BE585BC-13A5-4BE4-A806-E9EC2D825956}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FrontendTest", "test\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
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
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.Tests.Security", "tests\HopFrame.Tests.Security\HopFrame.Tests.Security.csproj", "{6747753A-6059-48F1-B779-D73765A373A6}"
EndProject
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.Tests.Web", "tests\HopFrame.Tests.Web\HopFrame.Tests.Web.csproj", "{566C13B9-4ECA-48C4-8D02-FEB6CDF523E6}"
EndProject EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -42,7 +58,38 @@ Global
{8F983A37-63CF-48D5-988D-58B78EF8AECD}.Debug|Any CPU.Build.0 = Debug|Any CPU {8F983A37-63CF-48D5-988D-58B78EF8AECD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8F983A37-63CF-48D5-988D-58B78EF8AECD}.Release|Any CPU.ActiveCfg = Release|Any CPU {8F983A37-63CF-48D5-988D-58B78EF8AECD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8F983A37-63CF-48D5-988D-58B78EF8AECD}.Release|Any CPU.Build.0 = Release|Any CPU {8F983A37-63CF-48D5-988D-58B78EF8AECD}.Release|Any CPU.Build.0 = Release|Any CPU
{02D9F10A-664A-4EF7-BF19-310C26FF4DEB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{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
{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
{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
{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 EndGlobalSection
GlobalSection(NestedProjects) = preSolution 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}
{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 EndGlobalSection
EndGlobal EndGlobal

View File

@@ -1,5 +1,86 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation"> <wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAsyncValueTaskMethodBuilderT_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003F1e8feaadf5c3fa14d36ea2a638c432a2e1a47b7837d8b83d88303c5d9c15cf_003FAsyncValueTaskMethodBuilderT_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAuthenticationHandler_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fca451c12d69fe026a0e7e9b1a0ddbf4cf6f6b8316cb2aec7984a7241813f648_003FAuthenticationHandler_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAuthenticationSchemeOptions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003F8525b7a9e58c77f532f1a88d4f2897e3c2baf316b9eb2c391b242a3885fcce6_003FAuthenticationSchemeOptions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AEditContextDataAnnotationsExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fbc307cd57fb42fc4c7fb9795381958122734d3750f41b6c1735c7d132ecda70_003FEditContextDataAnnotationsExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AExceptionDispatchInfo_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fbd1d5c50194fea68ff3559c160230b0ab50f5acf4ce3061bffd6d62958e2182_003FExceptionDispatchInfo_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIHttpContextAccessor_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Ffe1b239a13ce466b829e79538c654ff54e938_003Fa5_003F4093f165_003FIHttpContextAccessor_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AList_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fb7208b3f72528d22781d25fde9a55271bdf2b5aade4f03b1324579a25493cd8_003FList_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AOpenIdConnectConfiguration_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F2820ca73717f4b1ab2b3b74fd61961bd1d5b0_003F7e_003F8be6b109_003FOpenIdConnectConfiguration_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARedirectResult_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003F2261f0cc7e883c3c89b8e6c7ea509ac7c52d0629e68690ed216e1cc0c8ac_003FRedirectResult_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AThrowHelper_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003F2c8e7ca976f350cba9836d5565dac56b11e0b56656fa786460eb1395857a6fa_003FThrowHelper_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AValidationMessageStore_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003Ffc81648e473bb3cc818f71427c286ecddc3604d2f4c69c565205bb89e8b4ef4_003FValidationMessageStore_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/Environment/AssemblyExplorer/XmlDocument/@EntryValue">&lt;AssemblyExplorer&gt;&#xD; <s:String x:Key="/Default/Environment/AssemblyExplorer/XmlDocument/@EntryValue">&lt;AssemblyExplorer&gt;&#xD;
&lt;Assembly Path="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\7.0.16\ref\net7.0\System.ComponentModel.Annotations.dll" /&gt;&#xD; &lt;Assembly Path="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\7.0.16\ref\net7.0\System.ComponentModel.Annotations.dll" /&gt;&#xD;
&lt;Assembly Path="C:\Users\Remote\.nuget\packages\blazorstrap\5.2.100.61524\lib\net7.0\BlazorStrap.dll" /&gt;&#xD; &lt;Assembly Path="C:\Users\Remote\.nuget\packages\blazorstrap\5.2.100.61524\lib\net7.0\BlazorStrap.dll" /&gt;&#xD;
&lt;/AssemblyExplorer&gt;</s:String></wpf:ResourceDictionary> &lt;/AssemblyExplorer&gt;</s:String>
</wpf:ResourceDictionary>

View File

@@ -5,13 +5,17 @@ A simple backend management api for ASP.NET Core Web APIs
- [x] Database management - [x] Database management
- [x] User authentication - [x] User authentication
- [x] Permission management - [x] Permission management
- [x] Frontend dashboards - [x] Generated frontend administration boards
- [x] API token support
- [x] OpenID authentication integration
# Usage # Usage
There are two different versions of HopFrame, either the Web API version or the full Blazor web version. There are two different versions of HopFrame, either the Web API version or the full Blazor web version.
## Ho to use the Web API version ## Ho to use the Web API version
> **Hint:** For more information about the HopFrame installation and usage go to the [docs](./docs).
1. Add the HopFrame.Api library to your project: 1. Add the HopFrame.Api library to your project:
``` ```

View File

@@ -1,26 +0,0 @@
@startuml ApiModels
namespace HopFrame.Security {
class UserLogin {
+Email: string
+Password: string
}
class UserRegister {
+Username: string
+Email: string
+Password: string
}
}
namespace HopFrame.Api {
class SingleValueResult<TValue> {
+Value: TValue
}
class UserPasswordValidation {
+Password: string
}
}
@enduml

View File

@@ -1,37 +0,0 @@
@startuml BaseModels
set namespaceSeparator none
namespace HopFrame.Database {
class User {
+Id: Guid
+Username: string
+Email: string
+CreatedAt: DateTime
+Permissions: IList<Permission>
}
class Permission {
+Id: long
+PermissionName: string
+Owner: Guid
+GrantedAt: DateTime
}
class PermissionGroup {
+Name: string
+IsDefaultGroup: bool
+Description: string
+CreatedAt: DateTime
+Permissions: IList<Permission>
}
interface IPermissionOwner {}
}
IPermissionOwner <|-- User
IPermissionOwner <|-- PermissionGroup
User .. Permission
PermissionGroup .. Permission
@enduml

View File

@@ -1,38 +0,0 @@
@startuml DatabaseModels
set namespaceSeparator none
namespace HopFrame.Database {
class UserEntry {
+Id: string
+Username: string
+Email: string
+Password: string
+CreatedAt: DateTime
}
class TokenEntry {
+Type: int
+Token: string
+UserId: string
+CreatedAt: DateTime
}
class PermissionEntry {
+RecordId: long
+PermissionText: string
+UserId: string
+GrantedAt: DateTime
}
class GroupEntry {
+Name: string
+Default: bool
+Description: string
+CreatedAt: DateTime
}
}
UserEntry *-- TokenEntry
UserEntry *-- PermissionEntry
@enduml

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -1,13 +0,0 @@
# HopFrame documentation
These sides contain all documentation available for the HopFrame modules
## Content
| Topic | Description | Document |
|----------|------------------------------------------------|-----------------------|
| Models | All models used by the HopFrame | [link](./models.md) |
| Services | All services provided by the HopFrame | [link](./services.md) |
| Usage | How to properly implement the HopFrame modules | [link](./usage.md) |
## Dependencies
Both the HopFrame.Api and HopFrame.Web modules are dependent on the HopFrame.Database and HopFrame.Security modules.
So all models and services provided by these modules are available in the other modules as well.

30
docs/api/authorization.md Normal file
View File

@@ -0,0 +1,30 @@
# HopFrame Authentication
With the provided HopFrame services, you can secure your endpoints so that only logged-in users or users with the right permissions can access the endpoint.
## Usage
You can secure your endpoints by adding the `Authorized` attribute.
```csharp
// Everyone can access this endpoint
[HttpGet("hello")]
public ActionResult<string> HelloWorld() {
return "Hello, World!";
}
```
```csharp
// Only logged-in users can access this endpoint
[HttpGet("hello"), Authorized]
public ActionResult<string> HelloWorld() {
return "Hello, World!";
}
```
```csharp
// Only logged-in users with the specified permissions can access this endpoint
[HttpGet("hello"), Authorized("test.permission", "test.permission.another")]
public ActionResult<string> HelloWorld() {
return "Hello, World!";
}
```

21
docs/api/endpoints.md Normal file
View File

@@ -0,0 +1,21 @@
# HopFrame Endpoints
HopFrame currently only supports endpoints for authentication out of the box.
> **Hint:** with the help of the [repositories](../repositories.md) you can very easily create missing endpoints for HopFrame components yourself.
## All currently supported endpoints
> **Hint:** you can use the build-in [swagger](https://swagger.io/) ui to explore and test all endpoints of your application __including__ HopFrame endpoints.
### SecurityController
Base endpoint: `/api/v1/authentication`\
**Important:** All primitive data types (including `string`) are return as a [`SingleValueResult`](./models.md#SingleValueResult)
| Method | Endpoint | Payload | Returns |
|--------|---------------|--------------------------------------------------------------|-----------------------|
| PUT | /login | [UserLogin](../models.md#UserLogin) | access token (string) |
| POST | /register | [UserRegister](../models.md#UserRegister) | access token (string) |
| GET | /authenticate | | access token (string) |
| DELETE | /logout | | |
| DELETE | /delete | [UserPasswordValidation](./models.md#UserPasswordValidation) | |

16
docs/api/installation.md Normal file
View File

@@ -0,0 +1,16 @@
# Ho to use the Web API version
This Installation adds all HopFrame [endpoints](./endpoints.md) and [repositories](../repositories.md) to the application.
1. Add the HopFrame.Api library to your project:
```
dotnet add package HopFrame.Api
```
2. Create a [DbContext](../database.md) that inherits the ``HopDbContext`` and add a data source
3. Add the HopFrame services to your application, provide the previously created `DatabaseContext` that inherits from `HopDbContextBase`
```csharp
builder.Services.AddHopFrame<DatabaseContext>();
```

33
docs/api/logicresults.md Normal file
View File

@@ -0,0 +1,33 @@
# LogicResults
LogicResults provide another layer of abstraction above the ActionResults.
They help you sending the right `HttpStatusCode` with the right data.
## Usage
1. Create an endpoint that returns an `ActionResult`:
```csharp
[HttpGet("hello")]
public ActionResult<string> Hello() {
return new ActionResult<string>("Hello, World!");
}
```
2. Now instead of directly returning the `ActionResult`, return a `LogicResult`
```csharp
[HttpGet("hello")]
public ActionResult<string> Hello() {
return LogicResult<string>.Ok("Hello, World!");
}
```
3. This allows you to very easily change the return type by simply calling the right function
```csharp
[HttpGet("hello")]
public ActionResult<string> Hello() {
if (!Auth.IsLoggedIn)
return LogicResult<string>.Forbidden();
return LogicResult<string>.Ok("Hello, World!");
}
```
> **Hint:** You can also provide an error message for status codes that are not in the 200 range.

16
docs/api/models.md Normal file
View File

@@ -0,0 +1,16 @@
# HopFrame Models
All models used by the RestAPI are listed below
## SingleValueResult
```csharp
public struct SingleValueResult<TValue>(TValue value) {
public TValue Value { get; set; } = value;
}
```
## UserPasswordValidation
```csharp
public sealed class UserPasswordValidation {
public string Password { get; set; }
}
```

76
docs/authentication.md Normal file
View File

@@ -0,0 +1,76 @@
# 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.
You can specify `Seconds`, `Minutes`, `Hours` and `Days` for either of the two token types.
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
"HopFrame": {
"Authentication": {
"AccessToken": {
"Minutes": 30
},
"RefreshToken": {
"Days": 10,
"Hours": 5
}
}
}
```
#### Environment variables example
```dotenv
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 an api token
can **never** have more permissions than the user associated with it.
### 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");
```

144
docs/blazor/admin.md Normal file
View File

@@ -0,0 +1,144 @@
# HopFrame Admin Pages
Admin pages can be defined through a `AdminContext` similar to how a `DbContext` is defined. They generate administration pages like [`/administration/users`](./pages.md)
simply by reading the structure of the provided model and optionally some additional configuration.
> **Fun fact:** The already existing pages `/administration/users` and `/administration/groups` are also generated using an internal `AdminContext`.
## Usage
1. Create a class that inherits the `AdminPagesContext` base class
```csharp
public class AdminContext : AdminPagesContext {
}
```
2. Add your admin pages as properties to the class
```csharp
public class AdminContext : AdminPagesContext {
public AdminPage<Address> Addresses { get; set; }
public AdminPage<Employee> Employees { get; set; }
}
```
> **Hint:** you can specify the url of the admin page by adding the `AdminPageUrl` Attribute
3. **Optionally** you can further configure your pages in the `OnModelCreating` method
```csharp
public class AdminContext : AdminPagesContext {
public AdminPage<Address> Addresses { get; set; }
public AdminPage<Employee> Employees { get; set; }
public override void OnModelCreating(IAdminContextGenerator generator) {
base.OnModelCreating(generator);
generator.Page<Employee>()
.Property(e => e.Address)
.IsSelector();
generator.Page<Address>()
.Property(a => a.Employee)
.Ignore();
generator.Page<Address>()
.Property(a => a.AddressId)
.IsSelector<Employee>()
.Parser<Employee>((model, e) => model.AddressId = e.EmployeeId);
generator.Page<Employee>()
.ConfigureRepository<EmployeeProvider>()
.ListingProperty(e => e.Name);
generator.Page<Address>()
.ConfigureRepository<AddressProvider>()
.ListingProperty(a => a.City);
}
}
```
4. **Optionally** you can also add some of the following attributes to your classes / properties to further configure the admin pages:\
\
Attributes for classes and properties:
```csharp
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Property)]
public sealed class AdminNameAttribute(string name) : Attribute {
public string Name { get; set; } = name;
}
```
Attributes for classes:
```csharp
[AttributeUsage(AttributeTargets.Class)]
public sealed class AdminButtonConfigAttribute(bool showCreateButton = true, bool showDeleteButton = true, bool showUpdateButton = true) : Attribute {
public bool ShowCreateButton { get; set; } = showCreateButton;
public bool ShowDeleteButton { get; set; } = showDeleteButton;
public bool ShowUpdateButton { get; set; } = showUpdateButton;
}
```
```csharp
[AttributeUsage(AttributeTargets.Class)]
public sealed class AdminPermissionsAttribute(string view = null, string create = null, string update = null, string delete = null) : Attribute {
public AdminPagePermissions Permissions { get; set; } = new() {
Create = create,
Update = update,
Delete = delete,
View = view
};
}
```
```csharp
[AttributeUsage(AttributeTargets.Class)]
public sealed class AdminDescriptionAttribute(string description) : Attribute {
public string Description { get; set; } = description;
}
```
Attributes for properties:
```csharp
[AttributeUsage(AttributeTargets.Property)]
public sealed class AdminHideValueAttribute : Attribute;
```
```csharp
[AttributeUsage(AttributeTargets.Property)]
public sealed class AdminIgnoreAttribute(bool onlyForListing = false) : Attribute {
public bool OnlyForListing { get; set; } = onlyForListing;
}
```
```csharp
[AttributeUsage(AttributeTargets.Property)]
public sealed class AdminPrefixAttribute(string prefix) : Attribute {
public string Prefix { get; set; } = prefix;
}
```
```csharp
[AttributeUsage(AttributeTargets.Property)]
public sealed class AdminUneditableAttribute : Attribute;
```
```csharp
[AttributeUsage(AttributeTargets.Property)]
public class AdminUniqueAttribute : Attribute;
```
```csharp
[AttributeUsage(AttributeTargets.Property)]
public sealed class AdminUnsortableAttribute : Attribute;
```
```csharp
[AttributeUsage(AttributeTargets.Property)]
public sealed class ListingPropertyAttribute : Attribute;
```

20
docs/blazor/auth.md Normal file
View File

@@ -0,0 +1,20 @@
# Auth Service
The `IAuthService` provides some useful methods to handle user authentication (login/register).
## Usage
Simply define the `IAuthService` as a dependency
```csharp
public interface IAuthService {
Task Register(UserRegister register);
Task<bool> Login(UserLogin login);
Task Logout();
Task<Token> RefreshLogin();
Task<bool> IsLoggedIn();
}
```
## Automatically refresh user sessions
1. Make sure you have implemented the `AuthMiddleware` how it's described in step 5 of the [installation](./installation.md).
2. After that, the access token of the user gets automatically refreshed as long as the refresh token is valid.

View File

@@ -0,0 +1,20 @@
# HopFrame Authentication
With the provided HopFrame services, you can secure your blazor pages so that only logged-in users or users with the right permissions can access the page.
## Usage
You can secure your Blazor pages by using the `AuthorizedView` component.
Everything placed inside this component will only be displayed if the authorization was successful.
You can also redirect the user if the authorization fails by specifying a `RedirectIfUnauthorized` url.
```html
<!-- You can either specify one 'Permission', multiple 'Permissions' or none if the user only needs to be logged-in -->
<AuthorizedView Permission="test.permission">
<p>This paragraph is only visible if the user is logged-in and has the required permission</p>
</AuthorizedView>
```
```html
<!-- This component will redirect the user to the login page if the user is unauthorized -->
<AuthorizedView RedirectIfUnauthorized="/login" />
```

View File

@@ -0,0 +1,36 @@
## How to use the Blazor API
This Installation adds all HopFrame [pages](./pages.md) and [repositories](../repositories.md) to the application.
1. Add the HopFrame.Web library to your project
```
dotnet add package HopFrame.Web
```
2. Create a [DbContext](../database.md) that inherits the ``HopDbContext`` and add a data source
3. Add the HopFrame services to your application, provide the previously created `DatabaseContext` that inherits from `HopDbContextBase`
```csharp
builder.Services.AddHopFrame<DatabaseContext>();
```
4. **Optional:** You can also add your [AdminContext](./admin.md)
```csharp
builder.Services.AddAdminContext<AdminContext>();
```
5. Add the authentication middleware to your app
```csharp
app.UseMiddleware<AuthMiddleware>();
```
6. Add the HopFrame pages to your Razor components
```csharp
app.MapRazorComponents<App>()
.AddHopFrameAdminPages()
.AddInteractiveServerRenderMode();
```

14
docs/blazor/pages.md Normal file
View File

@@ -0,0 +1,14 @@
# HopFrame Pages
By default, the HopFrame provides some blazor pages for managing user accounts and permissions
## All currently supported blazor pages
| Page | Endpoint | Permission | Usage |
|-----------------|------------------------|----------------------------|--------------------------------------------------------------------------------------------------------|
| Admin Dashboard | /administration | hopframe.admin | This page provides an overview to all admin pages built-in and created by [AdminContexts](./admin.md). |
| Admin Login | /administration/login | | This page is a simple login screen so no login screen needs to be created to access the admin pages. |
| User Dashboard | /administration/users | hopframe.admin.users.view | This page serves as a management site for all users and their permissions. |
| Group Dashboard | /administration/groups | hopframe.admin.groups.view | This page serves as a management site for all groups and their permissions. |
> **Hint:** All pages created by [AdminContexts](./admin.md) are also under the `/administration/` location. This can unfortunately __not__ be changed at the moment.

35
docs/database.md Normal file
View File

@@ -0,0 +1,35 @@
# Database initialization
You also need to initialize the data source with the tables from HopFrame.
## Create a DbContext
1. Create a c# class that inherits from the `HopDbContextBase` and add a data source (In the example Sqlite is used)\
**IMPORTANT:** You need to leave the `base.OnConfiguring(optionsBuilder)` in place so the HopFrame model relations are set correctly.
```csharp
public class DatabaseContext : HopDbContextBase {
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) {
base.OnConfiguring(optionsBuilder);
optionsBuilder.UseSqlite("...");
}
}
```
2. Register the `DatabaseContext` as a service
```csharp
builder.Services.AddDbContext<DatabaseContext>();
```
3. Create a database migration
```bash
dotnet ef migrations add Initial
```
4. Apply the migration to the data source
```bash
dotnet ef database update
```

View File

@@ -1,21 +1,73 @@
# Models for HopFrame # HopFrame base models
All models listed below are part of the core HopFrame components and accessible in all installation variations
This page shows all models that HopFrame uses. > **Note:** All properties of the models that are `virtual` are relational properties and don't directly correspond to columns in the database.
## User
```csharp
public class User : IPermissionOwner {
public Guid Id { get; init; }
public string Username { get; set; }
public string Email { get; set; }
public string Password { get; set; }
public DateTime CreatedAt { get; set; }
public virtual List<Permission> Permissions { get; set; }
public virtual List<Token> Tokens { get; set; }
}
```
## Base Models ## PermissionGroup
These are the models used by the various database services. ```csharp
public class PermissionGroup : IPermissionOwner {
public string Name { get; init; }
public bool IsDefaultGroup { get; set; }
public string Description { get; set; }
public DateTime CreatedAt { get; set; }
public virtual List<Permission> Permissions { get; set; }
}
```
![](./Diagrams/Models/img/BaseModels.svg) ## Permission
```csharp
public class Permission {
public long Id { get; init; }
public string PermissionName { get; set; }
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 : 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<Permission> Permissions { get; set; }
}
```
## API Models ## UserLogin
These are the models used by the REST API and the Blazor API. ```csharp
public class UserLogin {
public string Email { get; set; }
public string Password { get; set; }
}
```
![](./Diagrams/Models/img/ApiModels.svg) ## UserRegister
```csharp
public class UserRegister {
public string Username { get; set; }
public string Email { get; set; }
public string Password { get; set; }
}
```
## IPermissionOwner
## Database Models ```csharp
These are the models that correspond to the scheme in the Database public interface IPermissionOwner;
```
![](./Diagrams/Models/img/DatabaseModels.svg)

120
docs/openid.md Normal file
View File

@@ -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<OpenIdConfiguration> LoadConfiguration();
Task<OpenIdToken> RequestToken(string code, string defaultCallback);
Task<string> ConstructAuthUri(string defaultCallback, string state = null);
Task<OpenIdIntrospection> InspectToken(string token);
Task<OpenIdToken> RefreshAccessToken(string refreshToken);
}
```

80
docs/permissions.md Normal file
View File

@@ -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"
```

28
docs/readme.md Normal file
View File

@@ -0,0 +1,28 @@
# HopFrame Documentation
The HopFrame comes in two variations, you can eiter only use the backend with some basic endpoints or with fully fledged blazor pages for managing HopFrame components.
## Shared HopFrame Modules
- [Database](./database.md)
- [Repositories](./repositories.md)
- [Base Models](./models.md)
- [Authentication](./authentication.md)
- [Permissions](./permissions.md)
- [OpenID Integration](./openid.md)
## HopFrame Web API
- [Installation](./api/installation.md)
- [Endpoints](./api/endpoints.md)
- [Authorization](./api/authorization.md)
- [Models](./api/models.md)
- [LogicResults](./api/logicresults.md)
## HopFrame Blazor library
- [Installation](./blazor/installation.md)
- [Pages](./blazor/pages.md)
- [Authorization](./blazor/authorization.md)
- [Auth Service](./blazor/auth.md)
- [Admin Context](./blazor/admin.md)

79
docs/repositories.md Normal file
View File

@@ -0,0 +1,79 @@
# HopFrame Repositories
The HopFrame provies repositories for the various build in database models as an abstraction around the `HopDbContext` to ensure, that the data is proccessed and saved correctly.
## Overview
The repositories can also be used by simply defining them as a dependency in your service / controller.
### User Repository
```csharp
public interface IUserRepository {
Task<IList<User>> GetUsers();
Task<User> GetUser(Guid userId);
Task<User> GetUserByEmail(string email);
Task<User> GetUserByUsername(string username);
Task<User> AddUser(User user);
Task UpdateUser(User user);
Task DeleteUser(User user);
Task<bool> CheckUserPassword(User user, string password);
Task ChangePassword(User user, string password);
}
```
### Group Repository
```csharp
public interface IGroupRepository {
Task<IList<PermissionGroup>> GetPermissionGroups();
Task<IList<PermissionGroup>> GetDefaultGroups();
Task<IList<PermissionGroup>> GetUserGroups(User user);
Task<PermissionGroup> GetPermissionGroup(string name);
Task EditPermissionGroup(PermissionGroup group);
Task<PermissionGroup> CreatePermissionGroup(PermissionGroup group);
Task DeletePermissionGroup(PermissionGroup group);
}
```
### Permission Repository
```csharp
public interface IPermissionRepository {
Task<bool> HasPermission(IPermissionOwner owner, params string[] permissions);
Task<Permission> AddPermission(IPermissionOwner owner, string permission);
Task RemovePermission(IPermissionOwner owner, string permission);
Task<IList<string>> GetFullPermissions(IPermissionOwner owner);
}
```
### Token Repository
```csharp
public interface ITokenRepository {
Task<Token> GetToken(string content);
Task<Token> CreateToken(int type, User owner);
Task DeleteUserTokens(User owner);
Task DeleteToken(Token token);
Task<Token> CreateApiToken(User owner, DateTime expirationDate);
}
```

View File

@@ -1,145 +0,0 @@
# HopFrame Services
This page describes all services provided by the HopFrame.
You can use these services by specifying them as a dependency. All of them are scoped dependencies.
## HopFrame.Security
### ITokenContext
This service provides the information given by the current request
```csharp
public interface ITokenContext {
bool IsAuthenticated { get; }
User User { get; }
Guid AccessToken { get; }
}
```
### IUserService
This service simplifies the data access of the user table in the database.
```csharp
public interface IUserService {
Task<IList<User>> GetUsers();
Task<User> GetUser(Guid userId);
Task<User> GetUserByEmail(string email);
Task<User> GetUserByUsername(string username);
Task<User> AddUser(UserRegister user);
Task UpdateUser(User user);
Task DeleteUser(User user);
Task<bool> CheckUserPassword(User user, string password);
Task ChangePassword(User user, string password);
}
```
### IPermissionService
This service handles all permission and group interactions with the data source.
```csharp
public interface IPermissionService {
Task<bool> HasPermission(string permission, Guid user);
Task<IList<PermissionGroup>> GetPermissionGroups();
Task<PermissionGroup> GetPermissionGroup(string name);
Task EditPermissionGroup(PermissionGroup group);
Task<IList<PermissionGroup>> GetUserPermissionGroups(User user);
Task RemoveGroupFromUser(User user, PermissionGroup group);
Task<PermissionGroup> CreatePermissionGroup(string name, bool isDefault = false, string description = null);
Task DeletePermissionGroup(PermissionGroup group);
Task<Permission> GetPermission(string name, IPermissionOwner owner);
Task AddPermission(IPermissionOwner owner, string permission);
Task RemovePermission(Permission permission);
Task<string[]> GetFullPermissions(string user);
}
```
## HopFrame.Api
### LogicResult
Logic result is an extension of the ActionResult for an ApiController. It provides simple Http status results with either a message or data by specifying the generic type.
```csharp
public class LogicResult : ILogicResult {
public static LogicResult Ok();
public static LogicResult BadRequest();
public static LogicResult BadRequest(string message);
public static LogicResult Forbidden();
public static LogicResult Forbidden(string message);
public static LogicResult NotFound();
public static LogicResult NotFound(string message);
public static LogicResult Conflict();
public static LogicResult Conflict(string message);
public static LogicResult Forward(LogicResult result);
public static LogicResult Forward<T>(ILogicResult<T> result);
public static implicit operator ActionResult(LogicResult v);
}
public class LogicResult<T> : ILogicResult<T> {
public static LogicResult<T> Ok();
public static LogicResult<T> Ok(T result);
...
}
```
### IAuthLogic
This service handles all logic needed to provide the authentication endpoints by using the LogicResults.
```csharp
public interface IAuthLogic {
Task<LogicResult<SingleValueResult<string>>> Login(UserLogin login);
Task<LogicResult<SingleValueResult<string>>> Register(UserRegister register);
Task<LogicResult<SingleValueResult<string>>> Authenticate();
Task<LogicResult> Logout();
Task<LogicResult> Delete(UserPasswordValidation validation);
}
```
## HopFrame.Web
### IAuthService
This service handles all the authentication like login or register. It properly creates all tokens so the user can be identified
```csharp
public interface IAuthService {
Task Register(UserRegister register);
Task<bool> Login(UserLogin login);
Task Logout();
Task<TokenEntry> RefreshLogin();
Task<bool> IsLoggedIn();
}
```

View File

@@ -1,70 +0,0 @@
# HopFrame Usage
There are two different versions of HopFrame, either the Web API version or the full Blazor web version.
## Ho to use the Web API version
1. Add the HopFrame.Api library to your project:
```
dotnet add package HopFrame.Api
```
2. Create a DbContext that inherits the ``HopDbContext`` and add a data source
```csharp
public class DatabaseContext : HopDbContextBase {
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) {
base.OnConfiguring(optionsBuilder);
optionsBuilder.UseSqlite("...");
}
}
```
3. Add the DbContext and HopFrame to your services
```csharp
builder.Services.AddDbContext<DatabaseContext>();
builder.Services.AddHopFrame<DatabaseContext>();
```
## How to use the Blazor API
1. Add the HopFrame.Web library to your project
```
dotnet add package HopFrame.Web
```
2. Create a DbContext that inherits the ``HopDbContext`` and add a data source
```csharp
public class DatabaseContext : HopDbContextBase {
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) {
base.OnConfiguring(optionsBuilder);
optionsBuilder.UseSqlite("...");
}
}
```
3. Add the DbContext and HopFrame to your services
```csharp
builder.Services.AddDbContext<DatabaseContext>();
builder.Services.AddHopFrame<DatabaseContext>();
```
4. Add the authentication middleware to your app
```csharp
app.UseMiddleware<AuthMiddleware>();
```
5. Add the HopFrame pages to your Razor components
```csharp
app.MapRazorComponents<App>()
.AddHopFrameAdminPages()
.AddInteractiveServerRenderMode();
```

View File

@@ -7,8 +7,8 @@ using Microsoft.AspNetCore.Mvc;
namespace HopFrame.Api.Controller; namespace HopFrame.Api.Controller;
[ApiController] [ApiController]
[Route("authentication")] [Route("api/v1/auth")]
public class SecurityController(IAuthLogic auth) : ControllerBase { public class AuthController(IAuthLogic auth) : ControllerBase {
[HttpPut("login")] [HttpPut("login")]
public async Task<ActionResult<SingleValueResult<string>>> Login([FromBody] UserLogin login) { public async Task<ActionResult<SingleValueResult<string>>> Login([FromBody] UserLogin login) {

View File

@@ -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<AdminPermissionOptions> permissions, IPermissionRepository perms, ITokenContext context, IGroupLogic groups) : ControllerBase {
private async Task<bool> AuthorizeRequest(string permission) {
return await perms.HasPermission(context.AccessToken, permission);
}
[HttpGet, Authorized]
public async Task<ActionResult<IList<PermissionGroup>>> GetGroups() {
if (!await AuthorizeRequest(permissions.Value.Groups.Read))
return Unauthorized();
return await groups.GetGroups();
}
[HttpGet("default"), Authorized]
public async Task<ActionResult<IList<PermissionGroup>>> GetDefaultGroups() {
if (!await AuthorizeRequest(permissions.Value.Groups.Read))
return Unauthorized();
return await groups.GetDefaultGroups();
}
[HttpGet("user/{userId}"), Authorized]
public async Task<ActionResult<IList<PermissionGroup>>> GetUserGroups(string userId) {
if (!await AuthorizeRequest(permissions.Value.Groups.Read))
return Unauthorized();
return await groups.GetUserGroups(userId);
}
[HttpGet("{name}"), Authorized]
public async Task<ActionResult<PermissionGroup>> GetGroup(string name) {
if (!await AuthorizeRequest(permissions.Value.Groups.Read))
return Unauthorized();
return await groups.GetGroup(name);
}
[HttpPost, Authorized]
public async Task<ActionResult<PermissionGroup>> CreateGroup([FromBody] PermissionGroup group) {
if (!await AuthorizeRequest(permissions.Value.Groups.Create))
return Unauthorized();
return await groups.CreateGroup(group);
}
[HttpPut, Authorized]
public async Task<ActionResult<PermissionGroup>> UpdateGroup([FromBody] PermissionGroup group) {
if (!await AuthorizeRequest(permissions.Value.Groups.Update))
return Unauthorized();
return await groups.UpdateGroup(group);
}
[HttpDelete("{name}"), Authorized]
public async Task<ActionResult> DeleteGroup(string name) {
if (!await AuthorizeRequest(permissions.Value.Groups.Delete))
return Unauthorized();
return await groups.DeleteGroup(name);
}
}

View File

@@ -0,0 +1,67 @@
using HopFrame.Api.Models;
using HopFrame.Security.Authentication.OpenID;
using HopFrame.Security.Claims;
using Microsoft.AspNetCore.Mvc;
namespace HopFrame.Api.Controller;
[ApiController, Route("api/v1/openid")]
public class OpenIdController(IOpenIdAccessor accessor) : ControllerBase {
public const string DefaultCallback = "api/v1/openid/callback";
[HttpGet("redirect")]
public async Task<IActionResult> RedirectToProvider([FromQuery] string redirectAfter, [FromQuery] int performRedirect = 1) {
var uri = await accessor.ConstructAuthUri(redirectAfter);
if (performRedirect == 1) {
return Redirect(uri);
}
return Ok(new SingleValueResult<string>(uri));
}
[HttpGet("callback")]
public async Task<IActionResult> Callback([FromQuery] string code, [FromQuery] string state) {
if (string.IsNullOrEmpty(code)) {
return BadRequest("Authorization code is missing");
}
var token = await accessor.RequestToken(code);
if (token is null) {
return Forbid("Authorization code is not valid");
}
accessor.SetAuthenticationCookies(token);
if (string.IsNullOrEmpty(state)) {
return Ok(new SingleValueResult<string>(token.AccessToken));
}
return Redirect(state.Replace("{token}", token.AccessToken));
}
[HttpGet("refresh")]
public async Task<IActionResult> 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");
accessor.SetAuthenticationCookies(token);
return Ok(new SingleValueResult<string>(token.AccessToken));
}
[HttpDelete("logout")]
public IActionResult Logout() {
accessor.Logout();
return Ok();
}
}

View File

@@ -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<AdminPermissionOptions> permissions, IPermissionRepository perms, ITokenContext context, IUserLogic logic) : ControllerBase {
private async Task<bool> AuthorizeRequest(string permission) {
return await perms.HasPermission(context.AccessToken, permission);
}
[HttpGet, Authorized]
public async Task<ActionResult<IList<User>>> GetUsers() {
if (!await AuthorizeRequest(permissions.Value.Users.Read))
return Unauthorized();
return await logic.GetUsers();
}
[HttpGet("{userId}"), Authorized]
public async Task<ActionResult<User>> GetUser(string userId) {
if (!await AuthorizeRequest(permissions.Value.Users.Read))
return Unauthorized();
return await logic.GetUser(userId);
}
[HttpGet("username/{username}"), Authorized]
public async Task<ActionResult<User>> GetUserByUsername(string username) {
if (!await AuthorizeRequest(permissions.Value.Users.Read))
return Unauthorized();
return await logic.GetUserByUsername(username);
}
[HttpGet("email/{email}"), Authorized]
public async Task<ActionResult<User>> GetUserByEmail(string email) {
if (!await AuthorizeRequest(permissions.Value.Users.Read))
return Unauthorized();
return await logic.GetUserByEmail(email);
}
[HttpPost, Authorized]
public async Task<ActionResult<User>> CreateUser([FromBody] UserCreator user) {
if (!await AuthorizeRequest(permissions.Value.Users.Create))
return Unauthorized();
return await logic.CreateUser(user);
}
[HttpPut("{userId}"), Authorized]
public async Task<ActionResult<User>> 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<ActionResult> DeleteUser(string userId) {
if (!await AuthorizeRequest(permissions.Value.Users.Delete))
return Unauthorized();
return await logic.DeleteUser(userId);
}
[HttpPut("{userId}/password"), Authorized]
public async Task<ActionResult> 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);
}
}

View File

@@ -83,4 +83,4 @@ public static class MvcExtensions {
return true; return true;
} }
} }
} }

View File

@@ -3,7 +3,9 @@ using HopFrame.Api.Logic;
using HopFrame.Api.Logic.Implementation; using HopFrame.Api.Logic.Implementation;
using HopFrame.Database; using HopFrame.Database;
using HopFrame.Security.Authentication; using HopFrame.Security.Authentication;
using HopFrame.Security.Authentication.OpenID;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.DependencyInjection.Extensions;
@@ -15,22 +17,43 @@ public static class ServiceCollectionExtensions {
/// Adds all HopFrame endpoints and services to the application /// Adds all HopFrame endpoints and services to the application
/// </summary> /// </summary>
/// <param name="services">The service provider to add the services to</param> /// <param name="services">The service provider to add the services to</param>
/// <param name="configuration">The configuration used to configure HopFrame authentication</param>
/// <typeparam name="TDbContext">The data source for all HopFrame entities</typeparam> /// <typeparam name="TDbContext">The data source for all HopFrame entities</typeparam>
public static void AddHopFrame<TDbContext>(this IServiceCollection services) where TDbContext : HopDbContextBase { public static void AddHopFrame<TDbContext>(this IServiceCollection services, ConfigurationManager configuration) where TDbContext : HopDbContextBase {
services.AddMvcCore().UseSpecificControllers(typeof(SecurityController)); var controllers = new List<Type> { typeof(UserController), typeof(GroupController) };
AddHopFrameNoEndpoints<TDbContext>(services);
var defaultAuthenticationSection = configuration.GetSection("HopFrame:Authentication:DefaultAuthentication");
if (!defaultAuthenticationSection.Exists() || configuration.GetValue<bool>("HopFrame:Authentication:DefaultAuthentication"))
controllers.Add(typeof(AuthController));
if (configuration.GetValue<bool>("HopFrame:Authentication:OpenID:Enabled")) {
IOpenIdAccessor.DefaultCallback = OpenIdController.DefaultCallback;
controllers.Add(typeof(OpenIdController));
}
AddHopFrameNoEndpoints<TDbContext>(services, configuration);
services.AddMvcCore().UseSpecificControllers(controllers.ToArray());
} }
/// <summary> /// <summary>
/// Adds all HopFrame services to the application /// Adds all HopFrame services to the application
/// </summary> /// </summary>
/// <param name="services">The service provider to add the services to</param> /// <param name="services">The service provider to add the services to</param>
/// <param name="configuration">The configuration used to configure HopFrame authentication</param>
/// <typeparam name="TDbContext">The data source for all HopFrame entities</typeparam> /// <typeparam name="TDbContext">The data source for all HopFrame entities</typeparam>
public static void AddHopFrameNoEndpoints<TDbContext>(this IServiceCollection services) where TDbContext : HopDbContextBase { public static void AddHopFrameNoEndpoints<TDbContext>(this IServiceCollection services, ConfigurationManager configuration) where TDbContext : HopDbContextBase {
services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>(); services.AddMvcCore().ConfigureApplicationPartManager(manager => {
services.AddScoped<IAuthLogic, AuthLogic<TDbContext>>(); var endpoints = manager.ApplicationParts.SingleOrDefault(p => p.Name == typeof(ServiceCollectionExtensions).Namespace!.Replace(".Extensions", ""));
manager.ApplicationParts.Remove(endpoints);
});
services.AddHopFrameAuthentication<TDbContext>(); services.AddHopFrameRepositories<TDbContext>();
services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();
services.AddScoped<IAuthLogic, AuthLogic>();
services.AddScoped<IUserLogic, UserLogic>();
services.AddScoped<IGroupLogic, GroupLogic>();
services.AddHopFrameAuthentication(configuration);
} }
} }

View File

@@ -7,7 +7,6 @@
<Nullable>disable</Nullable> <Nullable>disable</Nullable>
<PackageId>HopFrame.Api</PackageId> <PackageId>HopFrame.Api</PackageId>
<Version>1.1.0</Version>
<PackageReadmeFile>README.md</PackageReadmeFile> <PackageReadmeFile>README.md</PackageReadmeFile>
<PackageLicenseExpression>MIT</PackageLicenseExpression> <PackageLicenseExpression>MIT</PackageLicenseExpression>
<IsPackable>true</IsPackable> <IsPackable>true</IsPackable>
@@ -22,4 +21,10 @@
<None Include="README.md" Pack="true" PackagePath="\"/> <None Include="README.md" Pack="true" PackagePath="\"/>
</ItemGroup> </ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>HopFrame.Tests.Api</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Project> </Project>

View File

@@ -8,9 +8,18 @@ public interface IAuthLogic {
Task<LogicResult<SingleValueResult<string>>> Register(UserRegister register); Task<LogicResult<SingleValueResult<string>>> Register(UserRegister register);
/// <summary>
/// Reassures that the user has a valid refresh token and generates a new access token
/// </summary>
/// <returns>The newly generated access token</returns>
Task<LogicResult<SingleValueResult<string>>> Authenticate(); Task<LogicResult<SingleValueResult<string>>> Authenticate();
Task<LogicResult> Logout(); Task<LogicResult> Logout();
/// <summary>
/// Deletes the user account that called the endpoint if the provided password is correct
/// </summary>
/// <param name="validation">The password od the user</param>
/// <returns></returns>
Task<LogicResult> Delete(UserPasswordValidation validation); Task<LogicResult> Delete(UserPasswordValidation validation);
} }

View File

@@ -0,0 +1,14 @@
using HopFrame.Database.Models;
namespace HopFrame.Api.Logic;
public interface IGroupLogic {
Task<LogicResult<IList<PermissionGroup>>> GetGroups();
Task<LogicResult<IList<PermissionGroup>>> GetDefaultGroups();
Task<LogicResult<IList<PermissionGroup>>> GetUserGroups(string userId);
Task<LogicResult<PermissionGroup>> GetGroup(string name);
Task<LogicResult<PermissionGroup>> CreateGroup(PermissionGroup group);
Task<LogicResult<PermissionGroup>> UpdateGroup(PermissionGroup group);
Task<LogicResult> DeleteGroup(string name);
}

View File

@@ -1,4 +1,5 @@
using System.Net; using System.Net;
using Microsoft.AspNetCore.Mvc;
namespace HopFrame.Api.Logic; namespace HopFrame.Api.Logic;

View File

@@ -0,0 +1,16 @@
using HopFrame.Api.Models;
using HopFrame.Database.Models;
namespace HopFrame.Api.Logic;
public interface IUserLogic {
Task<LogicResult<IList<User>>> GetUsers();
Task<LogicResult<User>> GetUser(string id);
Task<LogicResult<User>> GetUserByUsername(string username);
Task<LogicResult<User>> GetUserByEmail(string email);
Task<LogicResult<User>> CreateUser(UserCreator user);
Task<LogicResult<User>> UpdateUser(string id, User user);
Task<LogicResult> DeleteUser(string id);
Task<LogicResult> UpdatePassword(string id, string oldPassword, string newPassword);
}

View File

@@ -1,18 +1,19 @@
using HopFrame.Api.Models; using HopFrame.Api.Models;
using HopFrame.Database; using HopFrame.Database.Models;
using HopFrame.Database.Models.Entries; using HopFrame.Database.Repositories;
using HopFrame.Security.Authentication; using HopFrame.Security.Authentication;
using HopFrame.Security.Claims; using HopFrame.Security.Claims;
using HopFrame.Security.Models; using HopFrame.Security.Models;
using HopFrame.Security.Services;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options;
namespace HopFrame.Api.Logic.Implementation; namespace HopFrame.Api.Logic.Implementation;
public class AuthLogic<TDbContext>(TDbContext context, IUserService users, ITokenContext tokenContext, IHttpContextAccessor accessor) : IAuthLogic where TDbContext : HopDbContextBase { internal sealed class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenContext tokenContext, IHttpContextAccessor accessor, IOptions<HopFrameAuthenticationOptions> options) : IAuthLogic {
public async Task<LogicResult<SingleValueResult<string>>> Login(UserLogin login) { public async Task<LogicResult<SingleValueResult<string>>> Login(UserLogin login) {
if (!options.Value.DefaultAuthentication) return LogicResult<SingleValueResult<string>>.BadRequest("HopFrame authentication scheme is disabled");
var user = await users.GetUserByEmail(login.Email); var user = await users.GetUserByEmail(login.Email);
if (user is null) if (user is null)
@@ -21,107 +22,84 @@ public class AuthLogic<TDbContext>(TDbContext context, IUserService users, IToke
if (!await users.CheckUserPassword(user, login.Password)) if (!await users.CheckUserPassword(user, login.Password))
return LogicResult<SingleValueResult<string>>.Forbidden("The provided password is not correct"); return LogicResult<SingleValueResult<string>>.Forbidden("The provided password is not correct");
var refreshToken = new TokenEntry { var refreshToken = await tokens.CreateToken(Token.RefreshTokenType, user);
CreatedAt = DateTime.Now, var accessToken = await tokens.CreateToken(Token.AccessTokenType, user);
Token = Guid.NewGuid().ToString(),
Type = TokenEntry.RefreshTokenType,
UserId = user.Id.ToString()
};
var accessToken = new TokenEntry {
CreatedAt = DateTime.Now,
Token = Guid.NewGuid().ToString(),
Type = TokenEntry.AccessTokenType,
UserId = user.Id.ToString()
};
accessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.Token, new CookieOptions { accessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.TokenId.ToString(), new CookieOptions {
MaxAge = HopFrameAuthentication<TDbContext>.RefreshTokenTime, MaxAge = options.Value.RefreshTokenTime,
HttpOnly = true, HttpOnly = true,
Secure = true Secure = true
}); });
accessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Token, new CookieOptions { accessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.TokenId.ToString(), new CookieOptions {
MaxAge = HopFrameAuthentication<TDbContext>.AccessTokenTime, MaxAge = options.Value.AccessTokenTime,
HttpOnly = true, HttpOnly = true,
Secure = true Secure = true
}); });
await context.Tokens.AddRangeAsync(refreshToken, accessToken); return LogicResult<SingleValueResult<string>>.Ok(accessToken.TokenId.ToString());
await context.SaveChangesAsync();
return LogicResult<SingleValueResult<string>>.Ok(accessToken.Token);
} }
public async Task<LogicResult<SingleValueResult<string>>> Register(UserRegister register) { public async Task<LogicResult<SingleValueResult<string>>> Register(UserRegister register) {
if (!options.Value.DefaultAuthentication) return LogicResult<SingleValueResult<string>>.BadRequest("HopFrame authentication scheme is disabled");
if (register.Password.Length < 8) if (register.Password.Length < 8)
return LogicResult<SingleValueResult<string>>.Conflict("Password needs to be at least 8 characters long"); return LogicResult<SingleValueResult<string>>.BadRequest("Password needs to be at least 8 characters long");
var allUsers = await users.GetUsers(); var allUsers = await users.GetUsers();
if (allUsers.Any(user => user.Username == register.Username || user.Email == register.Email)) if (allUsers.Any(user => user.Username == register.Username || user.Email == register.Email))
return LogicResult<SingleValueResult<string>>.Conflict("Username or Email is already registered"); return LogicResult<SingleValueResult<string>>.Conflict("Username or Email is already registered");
var user = await users.AddUser(register); var user = await users.AddUser(new User {
Username = register.Username,
Email = register.Email,
Password = register.Password
});
var refreshToken = new TokenEntry { var refreshToken = await tokens.CreateToken(Token.RefreshTokenType, user);
CreatedAt = DateTime.Now, var accessToken = await tokens.CreateToken(Token.AccessTokenType, user);
Token = Guid.NewGuid().ToString(),
Type = TokenEntry.RefreshTokenType,
UserId = user.Id.ToString()
};
var accessToken = new TokenEntry {
CreatedAt = DateTime.Now,
Token = Guid.NewGuid().ToString(),
Type = TokenEntry.AccessTokenType,
UserId = user.Id.ToString()
};
await context.Tokens.AddRangeAsync(refreshToken, accessToken); accessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.TokenId.ToString(), new CookieOptions {
await context.SaveChangesAsync(); MaxAge = options.Value.RefreshTokenTime,
accessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.Token, new CookieOptions {
MaxAge = HopFrameAuthentication<TDbContext>.RefreshTokenTime,
HttpOnly = true, HttpOnly = true,
Secure = true Secure = true
}); });
accessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Token, new CookieOptions { accessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.TokenId.ToString(), new CookieOptions {
MaxAge = HopFrameAuthentication<TDbContext>.AccessTokenTime, MaxAge = options.Value.AccessTokenTime,
HttpOnly = false, HttpOnly = false,
Secure = true Secure = true
}); });
return LogicResult<SingleValueResult<string>>.Ok(accessToken.Token); return LogicResult<SingleValueResult<string>>.Ok(accessToken.TokenId.ToString());
} }
public async Task<LogicResult<SingleValueResult<string>>> Authenticate() { public async Task<LogicResult<SingleValueResult<string>>> Authenticate() {
if (!options.Value.DefaultAuthentication) return LogicResult<SingleValueResult<string>>.BadRequest("HopFrame authentication scheme is disabled");
var refreshToken = accessor.HttpContext?.Request.Cookies[ITokenContext.RefreshTokenType]; var refreshToken = accessor.HttpContext?.Request.Cookies[ITokenContext.RefreshTokenType];
if (string.IsNullOrEmpty(refreshToken)) if (string.IsNullOrEmpty(refreshToken))
return LogicResult<SingleValueResult<string>>.Conflict("Refresh token not provided"); return LogicResult<SingleValueResult<string>>.BadRequest("Refresh token not provided");
var token = await tokens.GetToken(refreshToken);
var token = await context.Tokens.SingleOrDefaultAsync(token => token.Token == refreshToken && token.Type == TokenEntry.RefreshTokenType);
if (token is null) if (token is null)
return LogicResult<SingleValueResult<string>>.NotFound("Refresh token not valid"); return LogicResult<SingleValueResult<string>>.NotFound("Refresh token not valid");
if (token.CreatedAt + HopFrameAuthentication<TDbContext>.RefreshTokenTime < DateTime.Now) if (token.Type != Token.RefreshTokenType)
return LogicResult<SingleValueResult<string>>.Conflict("Refresh token is expired"); return LogicResult<SingleValueResult<string>>.Conflict("The provided token is not a refresh token");
var accessToken = new TokenEntry { if (token.CreatedAt + options.Value.RefreshTokenTime < DateTime.Now)
CreatedAt = DateTime.Now, return LogicResult<SingleValueResult<string>>.Forbidden("Refresh token is expired");
Token = Guid.NewGuid().ToString(),
Type = TokenEntry.AccessTokenType,
UserId = token.UserId
};
await context.Tokens.AddAsync(accessToken); var accessToken = await tokens.CreateToken(Token.AccessTokenType, token.Owner);
await context.SaveChangesAsync();
accessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Token, new CookieOptions { accessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.TokenId.ToString(), new CookieOptions {
MaxAge = HopFrameAuthentication<TDbContext>.AccessTokenTime, MaxAge = options.Value.AccessTokenTime,
HttpOnly = false, HttpOnly = false,
Secure = true Secure = true
}); });
return LogicResult<SingleValueResult<string>>.Ok(accessToken.Token); return LogicResult<SingleValueResult<string>>.Ok(accessToken.TokenId.ToString());
} }
public async Task<LogicResult> Logout() { public async Task<LogicResult> Logout() {
@@ -129,19 +107,7 @@ public class AuthLogic<TDbContext>(TDbContext context, IUserService users, IToke
var refreshToken = accessor.HttpContext?.Request.Cookies[ITokenContext.RefreshTokenType]; var refreshToken = accessor.HttpContext?.Request.Cookies[ITokenContext.RefreshTokenType];
if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(refreshToken)) if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(refreshToken))
return LogicResult.Conflict("access or refresh token not provided"); await tokens.DeleteUserTokens(tokenContext.User);
var tokenEntries = await context.Tokens.Where(token =>
(token.Token == accessToken && token.Type == TokenEntry.AccessTokenType) ||
(token.Token == refreshToken && token.Type == TokenEntry.RefreshTokenType))
.ToArrayAsync();
if (tokenEntries.Length != 2)
return LogicResult.NotFound("One or more of the provided tokens was not found");
context.Tokens.Remove(tokenEntries[0]);
context.Tokens.Remove(tokenEntries[1]);
await context.SaveChangesAsync();
accessor.HttpContext?.Response.Cookies.Delete(ITokenContext.RefreshTokenType); accessor.HttpContext?.Response.Cookies.Delete(ITokenContext.RefreshTokenType);
accessor.HttpContext?.Response.Cookies.Delete(ITokenContext.AccessTokenType); accessor.HttpContext?.Response.Cookies.Delete(ITokenContext.AccessTokenType);

View File

@@ -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<LogicResult<IList<PermissionGroup>>> GetGroups() {
return LogicResult<IList<PermissionGroup>>.Ok(await groups.GetPermissionGroups());
}
public async Task<LogicResult<IList<PermissionGroup>>> GetDefaultGroups() {
return LogicResult<IList<PermissionGroup>>.Ok(await groups.GetDefaultGroups());
}
public async Task<LogicResult<IList<PermissionGroup>>> GetUserGroups(string id) {
if (!Guid.TryParse(id, out var userId))
return LogicResult<IList<PermissionGroup>>.BadRequest("Invalid user id");
var user = await users.GetUser(userId);
if (user is null)
return LogicResult<IList<PermissionGroup>>.NotFound("That user does not exist");
return LogicResult<IList<PermissionGroup>>.Ok(await groups.GetUserGroups(user));
}
public async Task<LogicResult<PermissionGroup>> GetGroup(string name) {
var group = await groups.GetPermissionGroup(name);
if (group is null)
return LogicResult<PermissionGroup>.NotFound("That group does not exist");
return LogicResult<PermissionGroup>.Ok(group);
}
public async Task<LogicResult<PermissionGroup>> CreateGroup(PermissionGroup group) {
if (group is null)
return LogicResult<PermissionGroup>.BadRequest("Provide a group");
if (!group.Name.StartsWith("group."))
return LogicResult<PermissionGroup>.BadRequest("Group names must start with 'group.'");
if (await groups.GetPermissionGroup(group.Name) != null)
return LogicResult<PermissionGroup>.Conflict("That group already exists");
return LogicResult<PermissionGroup>.Ok(await groups.CreatePermissionGroup(group));
}
public async Task<LogicResult<PermissionGroup>> UpdateGroup(PermissionGroup group) {
if (await groups.GetPermissionGroup(group.Name) == null)
return LogicResult<PermissionGroup>.NotFound("That user does not exist");
await groups.EditPermissionGroup(group);
return LogicResult<PermissionGroup>.Ok(group);
}
public async Task<LogicResult> 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();
}
}

View File

@@ -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<LogicResult<IList<User>>> GetUsers() {
return LogicResult<IList<User>>.Ok(await users.GetUsers());
}
public async Task<LogicResult<User>> GetUser(string id) {
if (!Guid.TryParse(id, out var userId))
return LogicResult<User>.BadRequest("Invalid user id");
var user = await users.GetUser(userId);
if (user is null)
return LogicResult<User>.NotFound("That user does not exist");
return LogicResult<User>.Ok(user);
}
public async Task<LogicResult<User>> GetUserByUsername(string username) {
var user = await users.GetUserByUsername(username);
if (user is null)
return LogicResult<User>.NotFound("That user does not exist");
return LogicResult<User>.Ok(user);
}
public async Task<LogicResult<User>> GetUserByEmail(string email) {
var user = await users.GetUserByEmail(email);
if (user is null)
return LogicResult<User>.NotFound("That user does not exist");
return LogicResult<User>.Ok(user);
}
public async Task<LogicResult<User>> 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<User>.Conflict("That user already exists");
return LogicResult<User>.Ok(newUser);
}
public async Task<LogicResult<User>> UpdateUser(string id, User user) {
if (!Guid.TryParse(id, out var userId))
return LogicResult<User>.BadRequest("Invalid user id");
if (user.Id != userId)
return LogicResult<User>.Conflict("Cannot edit user with different user id");
if (await users.GetUser(userId) is null)
return LogicResult<User>.NotFound("That user does not exist");
await users.UpdateUser(user);
return LogicResult<User>.Ok(user);
}
public async Task<LogicResult> 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<LogicResult> 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();
}
}

View File

@@ -1,5 +1,10 @@
namespace HopFrame.Api.Models; namespace HopFrame.Api.Models;
/// <summary>
/// Useful for endpoints that only return a single int or string
/// </summary>
/// <param name="value">The value of the result</param>
/// <typeparam name="TValue">The type of the result</typeparam>
public struct SingleValueResult<TValue>(TValue value) { public struct SingleValueResult<TValue>(TValue value) {
public TValue Value { get; set; } = value; public TValue Value { get; set; } = value;

View File

@@ -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<string> Permissions { get; set; }
}

View File

@@ -0,0 +1,6 @@
namespace HopFrame.Api.Models;
public class UserPasswordChange {
public string OldPassword { get; set; }
public string NewPassword { get; set; }
}

View File

@@ -1,100 +1,4 @@
# HopFrame API module # HopFrame API module
This module contains some useful endpoints for user login / register management. This module contains some useful endpoints for user login / register management.
## Ho to use the Web API version For more information about the HopFrame visit the [docs](https://git.leon-hoppe.de/leon.hoppe/HopFrame).
1. Add the HopFrame.Api library to your project:
```
dotnet add package HopFrame.Api
```
2. Create a DbContext that inherits the ``HopDbContext`` and add a data source
```csharp
public class DatabaseContext : HopDbContextBase {
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) {
base.OnConfiguring(optionsBuilder);
optionsBuilder.UseSqlite("...");
}
}
```
3. Add the DbContext and HopFrame to your services
```csharp
builder.Services.AddDbContext<DatabaseContext>();
builder.Services.AddHopFrame<DatabaseContext>();
```
# Endpoints
By default, the module provides a controller for handling authentication based requests by the user.
You can explore the contoller by the build in swagger site from ASP .NET.
## Disable the Endpoints
```csharp
builder.Services.AddDbContext<DatabaseContext>();
//builder.Services.AddHopFrame<DatabaseContext>();
services.AddHopFrameNoEndpoints<TDbContext>();
```
# Services added in this module
You can use these services by specifying them as a dependency. All of them are scoped dependencies.
## LogicResult
Logic result is an extension of the ActionResult for an ApiController. It provides simple Http status results with either a message or data by specifying the generic type.
```csharp
public class LogicResult : ILogicResult {
public static LogicResult Ok();
public static LogicResult BadRequest();
public static LogicResult BadRequest(string message);
public static LogicResult Forbidden();
public static LogicResult Forbidden(string message);
public static LogicResult NotFound();
public static LogicResult NotFound(string message);
public static LogicResult Conflict();
public static LogicResult Conflict(string message);
public static LogicResult Forward(LogicResult result);
public static LogicResult Forward<T>(ILogicResult<T> result);
public static implicit operator ActionResult(LogicResult v);
}
public class LogicResult<T> : ILogicResult<T> {
public static LogicResult<T> Ok();
public static LogicResult<T> Ok(T result);
...
}
```
## IAuthLogic
This service handles all logic needed to provide the authentication endpoints by using the LogicResults.
```csharp
public interface IAuthLogic {
Task<LogicResult<SingleValueResult<string>>> Login(UserLogin login);
Task<LogicResult<SingleValueResult<string>>> Register(UserRegister register);
Task<LogicResult<SingleValueResult<string>>> Authenticate();
Task<LogicResult> Logout();
Task<LogicResult> Delete(UserPasswordValidation validation);
}
```

View File

@@ -1,6 +1,6 @@
using Microsoft.AspNetCore.Cryptography.KeyDerivation; using Microsoft.AspNetCore.Cryptography.KeyDerivation;
namespace HopFrame.Security; namespace HopFrame.Database;
public static class EncryptionManager { public static class EncryptionManager {

View File

@@ -1,4 +1,4 @@
using HopFrame.Database.Models.Entries; using HopFrame.Database.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace HopFrame.Database; namespace HopFrame.Database;
@@ -8,25 +8,32 @@ namespace HopFrame.Database;
/// </summary> /// </summary>
public abstract class HopDbContextBase : DbContext { public abstract class HopDbContextBase : DbContext {
public virtual DbSet<UserEntry> Users { get; set; } public virtual DbSet<User> Users { get; set; }
public virtual DbSet<PermissionEntry> Permissions { get; set; } public virtual DbSet<Permission> Permissions { get; set; }
public virtual DbSet<TokenEntry> Tokens { get; set; } public virtual DbSet<Token> Tokens { get; set; }
public virtual DbSet<GroupEntry> Groups { get; set; } public virtual DbSet<PermissionGroup> Groups { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder) { protected override void OnModelCreating(ModelBuilder modelBuilder) {
base.OnModelCreating(modelBuilder); base.OnModelCreating(modelBuilder);
modelBuilder.Entity<UserEntry>(); modelBuilder.Entity<User>()
modelBuilder.Entity<PermissionEntry>(); .HasMany(u => u.Tokens)
modelBuilder.Entity<TokenEntry>(); .WithOne(t => t.Owner)
modelBuilder.Entity<GroupEntry>(); .OnDelete(DeleteBehavior.Cascade);
}
/// <summary> modelBuilder.Entity<User>()
/// Gets executed when a user is deleted through the IUserService from the .HasMany(u => u.Permissions)
/// HopFrame.Security package. You can override this method to also delete .WithOne(p => p.User)
/// related user specific entries in the database .OnDelete(DeleteBehavior.Cascade);
/// </summary>
/// <param name="user"></param> modelBuilder.Entity<PermissionGroup>()
public virtual void OnUserDelete(UserEntry user) {} .HasMany(g => g.Permissions)
.WithOne(p => p.Group)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Token>()
.HasMany(t => t.Permissions)
.WithOne(t => t.Token)
.OnDelete(DeleteBehavior.Cascade);
}
} }

View File

@@ -7,13 +7,13 @@
<Nullable>disable</Nullable> <Nullable>disable</Nullable>
<PackageId>HopFrame.Database</PackageId> <PackageId>HopFrame.Database</PackageId>
<Version>1.1.0</Version>
<PackageReadmeFile>README.md</PackageReadmeFile> <PackageReadmeFile>README.md</PackageReadmeFile>
<PackageLicenseExpression>MIT</PackageLicenseExpression> <PackageLicenseExpression>MIT</PackageLicenseExpression>
<IsPackable>true</IsPackable> <IsPackable>true</IsPackable>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Cryptography.KeyDerivation" Version="8.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.7" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.7" />
</ItemGroup> </ItemGroup>
@@ -21,4 +21,10 @@
<None Include="README.md" Pack="true" PackagePath="\"/> <None Include="README.md" Pack="true" PackagePath="\"/>
</ItemGroup> </ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>HopFrame.Tests.Database</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Project> </Project>

View File

@@ -1,18 +0,0 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
namespace HopFrame.Database.Models.Entries;
public class GroupEntry {
[Key, Required, MaxLength(50)]
public string Name { get; set; }
[Required, DefaultValue(false)]
public bool Default { get; set; }
[MaxLength(500)]
public string Description { get; set; }
[Required]
public DateTime CreatedAt { get; set; }
}

View File

@@ -1,18 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace HopFrame.Database.Models.Entries;
public sealed class PermissionEntry {
[Key, Required, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public long RecordId { get; set; }
[Required, MaxLength(255)]
public string PermissionText { get; set; }
[Required, MinLength(36), MaxLength(36)]
public string UserId { get; set; }
[Required]
public DateTime GrantedAt { get; set; }
}

View File

@@ -1,25 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace HopFrame.Database.Models.Entries;
public class TokenEntry {
public const int RefreshTokenType = 0;
public const int AccessTokenType = 1;
/// <summary>
/// Defines the Type of the stored Token
/// 0: Refresh token
/// 1: Access token
/// </summary>
[Required, MinLength(1), MaxLength(1)]
public int Type { get; set; }
[Key, Required, MinLength(36), MaxLength(36)]
public string Token { get; set; }
[Required, MinLength(36), MaxLength(36)]
public string UserId { get; set; }
[Required]
public DateTime CreatedAt { get; set; }
}

View File

@@ -1,20 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace HopFrame.Database.Models.Entries;
public class UserEntry {
[Key, Required, MinLength(36), MaxLength(36)]
public string Id { get; set; }
[MaxLength(50)]
public string Username { get; set; }
[Required, MaxLength(50), EmailAddress]
public string Email { get; set; }
[Required, MinLength(8), MaxLength(255)]
public string Password { get; set; }
[Required]
public DateTime CreatedAt { get; set; }
}

View File

@@ -1,56 +0,0 @@
using HopFrame.Database.Models.Entries;
namespace HopFrame.Database.Models;
public static class ModelExtensions {
/// <summary>
/// Converts the database model to a friendly user model
/// </summary>
/// <param name="entry">the database model</param>
/// <param name="contextBase">the data source for the permissions and users</param>
/// <returns></returns>
public static User ToUserModel(this UserEntry entry, HopDbContextBase contextBase) {
var user = new User {
Id = Guid.Parse(entry.Id),
Username = entry.Username,
Email = entry.Email,
CreatedAt = entry.CreatedAt
};
user.Permissions = contextBase.Permissions
.Where(perm => perm.UserId == entry.Id)
.Select(perm => perm.ToPermissionModel())
.ToList();
return user;
}
public static Permission ToPermissionModel(this PermissionEntry entry) {
Guid.TryParse(entry.UserId, out var userId);
return new Permission {
Owner = userId,
PermissionName = entry.PermissionText,
GrantedAt = entry.GrantedAt,
Id = entry.RecordId
};
}
public static PermissionGroup ToPermissionGroup(this GroupEntry entry, HopDbContextBase contextBase) {
var group = new PermissionGroup {
Name = entry.Name,
IsDefaultGroup = entry.Default,
Description = entry.Description,
CreatedAt = entry.CreatedAt
};
group.Permissions = contextBase.Permissions
.Where(perm => perm.UserId == group.Name)
.Select(perm => perm.ToPermissionModel())
.ToList();
return group;
}
}

View File

@@ -1,10 +1,29 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
namespace HopFrame.Database.Models; namespace HopFrame.Database.Models;
public sealed class Permission { public class Permission {
[Key, Required, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public long Id { get; init; } public long Id { get; init; }
[Required, MaxLength(255)]
public string PermissionName { get; set; } public string PermissionName { get; set; }
public Guid Owner { get; set; }
[Required]
public DateTime GrantedAt { get; set; } public DateTime GrantedAt { get; set; }
[ForeignKey("UserId"), JsonIgnore]
public virtual User User { get; set; }
[ForeignKey("GroupName"), JsonIgnore]
public virtual PermissionGroup Group { get; set; }
[ForeignKey("TokenId"), JsonIgnore]
public virtual Token Token { get; set; }
} }
public interface IPermissionOwner {} public interface IPermissionOwner;

View File

@@ -1,9 +1,22 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
namespace HopFrame.Database.Models; namespace HopFrame.Database.Models;
public class PermissionGroup : IPermissionOwner { public class PermissionGroup : IPermissionOwner {
[Key, Required, MaxLength(50)]
public string Name { get; init; } public string Name { get; init; }
[Required, DefaultValue(false)]
public bool IsDefaultGroup { get; set; } public bool IsDefaultGroup { get; set; }
[MaxLength(500)]
public string Description { get; set; } public string Description { get; set; }
[Required]
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
public IList<Permission> Permissions { get; set; }
public virtual List<Permission> Permissions { get; set; }
} }

View File

@@ -0,0 +1,36 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
namespace HopFrame.Database.Models;
public class Token : IPermissionOwner {
public const int RefreshTokenType = 0;
public const int AccessTokenType = 1;
public const int ApiTokenType = 2;
public const int OpenIdTokenType = 3;
/// <summary>
/// Defines the Type of the stored Token
/// 0: Refresh token
/// 1: Access token
/// 2: Api token
/// </summary>
[Required, MinLength(1), MaxLength(1)]
public int Type { get; set; }
[Key, Required, MinLength(36), MaxLength(36)]
public Guid TokenId { get; set; }
/// <summary>
/// Defines the creation date of the token
/// In case of an api token it defines the date it becomes invalid
/// </summary>
[Required]
public DateTime CreatedAt { get; set; }
[ForeignKey("UserId"), JsonIgnore]
public virtual User Owner { get; set; }
public virtual List<Permission> Permissions { get; set; }
}

View File

@@ -1,9 +1,28 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace HopFrame.Database.Models; namespace HopFrame.Database.Models;
public sealed class User : IPermissionOwner { public class User : IPermissionOwner {
[Key, Required]
public Guid Id { get; init; } public Guid Id { get; init; }
[Required, MaxLength(50)]
public string Username { get; set; } public string Username { get; set; }
[Required, MaxLength(50), EmailAddress]
public string Email { get; set; } public string Email { get; set; }
[MinLength(8), MaxLength(255), JsonIgnore]
public string Password { get; set; }
[Required]
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
public IList<Permission> Permissions { get; set; }
public virtual List<Permission> Permissions { get; set; }
[JsonIgnore]
public virtual List<Token> Tokens { get; set; }
} }

View File

@@ -1,4 +1,4 @@
namespace HopFrame.Security.Authorization; namespace HopFrame.Database;
public static class PermissionValidator { public static class PermissionValidator {

View File

@@ -1,2 +1,4 @@
# HopFrame Database module # HopFrame Database module
This module contains all the logic for the database communication This module contains all the logic for the database communication.
For more information about the HopFrame visit the [docs](https://git.leon-hoppe.de/leon.hoppe/HopFrame).

View File

@@ -0,0 +1,21 @@
using HopFrame.Database.Models;
namespace HopFrame.Database.Repositories;
public interface IGroupRepository {
Task<IList<PermissionGroup>> GetPermissionGroups();
Task<IList<PermissionGroup>> GetDefaultGroups();
Task<IList<PermissionGroup>> GetUserGroups(User user);
Task<PermissionGroup> GetPermissionGroup(string name);
Task EditPermissionGroup(PermissionGroup group);
Task<PermissionGroup> CreatePermissionGroup(PermissionGroup group);
Task DeletePermissionGroup(PermissionGroup group);
internal Task<IList<string>> GetFullGroupPermissions(string group);
}

View File

@@ -0,0 +1,23 @@
using HopFrame.Database.Models;
namespace HopFrame.Database.Repositories;
public interface IPermissionRepository {
Task<bool> HasPermission(IPermissionOwner owner, params string[] permissions);
/// <summary>
/// permission system:<br/>
/// - "*" -> all rights<br/>
/// - "group.[name]" -> group member<br/>
/// - "[namespace].[name]" -> single permission<br/>
/// - "[namespace].*" -> all permissions in the namespace
/// </summary>
/// <param name="owner"></param>
/// <param name="permission"></param>
/// <returns></returns>
Task<Permission> AddPermission(IPermissionOwner owner, string permission);
Task RemovePermission(IPermissionOwner owner, string permission);
Task<IList<string>> GetFullPermissions(IPermissionOwner owner);
}

View File

@@ -0,0 +1,11 @@
using HopFrame.Database.Models;
namespace HopFrame.Database.Repositories;
public interface ITokenRepository {
Task<Token> GetToken(string content);
Task<Token> CreateToken(int type, User owner);
Task DeleteUserTokens(User owner, bool includeApiTokens = false);
Task DeleteToken(Token token);
Task<Token> CreateApiToken(User owner, DateTime expirationDate);
}

View File

@@ -1,9 +1,8 @@
using HopFrame.Database.Models; using HopFrame.Database.Models;
using HopFrame.Security.Models;
namespace HopFrame.Security.Services; namespace HopFrame.Database.Repositories;
public interface IUserService { public interface IUserRepository {
Task<IList<User>> GetUsers(); Task<IList<User>> GetUsers();
Task<User> GetUser(Guid userId); Task<User> GetUser(Guid userId);
@@ -12,13 +11,8 @@ public interface IUserService {
Task<User> GetUserByUsername(string username); Task<User> GetUserByUsername(string username);
Task<User> AddUser(UserRegister user); Task<User> AddUser(User user);
/// <summary>
/// IMPORTANT:<br/>
/// This function does not add or remove any permissions to the user.
/// For that please use <see cref="IPermissionService"/>
/// </summary>
Task UpdateUser(User user); Task UpdateUser(User user);
Task DeleteUser(User user); Task DeleteUser(User user);

View File

@@ -0,0 +1,98 @@
using HopFrame.Database.Models;
using Microsoft.EntityFrameworkCore;
namespace HopFrame.Database.Repositories.Implementation;
internal sealed class GroupRepository<TDbContext>(TDbContext context) : IGroupRepository where TDbContext : HopDbContextBase {
public async Task<IList<PermissionGroup>> GetPermissionGroups() {
return await context.Groups
.Include(g => g.Permissions)
.ToListAsync();
}
public async Task<IList<PermissionGroup>> GetDefaultGroups() {
return await context.Groups
.Include(g => g.Permissions)
.Where(g => g.IsDefaultGroup)
.ToListAsync();
}
public Task<IList<PermissionGroup>> GetUserGroups(User user) {
return Task.FromResult((IList<PermissionGroup>) context.Groups
.Include(g => g.Permissions)
.AsEnumerable()
.Where(g => user.Permissions.Any(p => p.PermissionName == g.Name))
.ToList());
}
public async Task<PermissionGroup> GetPermissionGroup(string name) {
return await context.Groups
.Include(g => g.Permissions)
.Where(g => g.Name == name)
.SingleOrDefaultAsync();
}
public async Task EditPermissionGroup(PermissionGroup group) {
var orig = await context.Groups
.Include(g => g.Permissions) // Include related entities
.SingleOrDefaultAsync(g => g.Name == group.Name);
if (orig is null) return;
// Update the main entity's properties
orig.IsDefaultGroup = group.IsDefaultGroup;
orig.Description = group.Description;
// 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<PermissionGroup> CreatePermissionGroup(PermissionGroup group) {
group.CreatedAt = DateTime.Now;
await context.Groups.AddAsync(group);
await context.SaveChangesAsync();
return group;
}
public async Task DeletePermissionGroup(PermissionGroup group) {
context.Groups.Remove(group);
await context.SaveChangesAsync();
}
public async Task<IList<string>> GetFullGroupPermissions(string group) {
var permissions = await context.Permissions
.Include(p => p.Group)
.Where(p => p.Group != null)
.Where(p => p.Group.Name == group)
.Select(p => p.PermissionName)
.ToListAsync();
var groups = permissions
.Where(p => p.StartsWith("group."))
.ToList();
foreach (var subgroup in groups) {
permissions.AddRange(await GetFullGroupPermissions(subgroup));
}
return permissions;
}
}

View File

@@ -0,0 +1,118 @@
using HopFrame.Database.Models;
using Microsoft.EntityFrameworkCore;
namespace HopFrame.Database.Repositories.Implementation;
internal sealed class PermissionRepository<TDbContext>(TDbContext context, IGroupRepository groupRepository) : IPermissionRepository where TDbContext : HopDbContextBase {
public async Task<bool> 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) {
if (!PermissionValidator.IncludesPermission(permission, perms)) return false;
}
return true;
}
public async Task<Permission> AddPermission(IPermissionOwner owner, string permission) {
var entry = new Permission {
GrantedAt = DateTime.Now,
PermissionName = permission
};
if (owner is User user) {
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!");
if (!await HasPermission(token.Owner, permission))
throw new ArgumentException("An api token cannot have more permissions than the owner has!");
entry.Token = token;
}
await context.Permissions.AddAsync(entry);
await context.SaveChangesAsync();
return entry;
}
public async Task RemovePermission(IPermissionOwner owner, string permission) {
Permission entry = null;
if (owner is User user) {
entry = await context.Permissions
.Include(p => p.User)
.Where(p => p.User != null)
.Where(p => p.User.Id == user.Id)
.Where(p => p.PermissionName == permission)
.SingleOrDefaultAsync();
}else if (owner is PermissionGroup group) {
entry = await context.Permissions
.Include(p => p.Group)
.Where(p => p.Group != null)
.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) {
context.Permissions.Remove(entry);
await context.SaveChangesAsync();
}
}
public async Task<IList<string>> GetFullPermissions(IPermissionOwner owner) {
var permissions = new List<string>();
if (owner is Token token && token.Type != Token.ApiTokenType) {
owner = token.Owner;
}
if (owner is User user) {
var perms = await context.Permissions
.Include(p => p.User)
.Where(p => p.User != null)
.Where(p => p.User.Id == user.Id)
.ToListAsync();
permissions.AddRange(perms.Select(p => p.PermissionName));
}else if (owner is PermissionGroup group) {
var perms = await context.Permissions
.Include(p => p.Group)
.Where(p => p.Group != null)
.Where(p =>p.Group.Name == group.Name)
.ToListAsync();
permissions.AddRange(perms.Select(p => p.PermissionName));
}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 == apiToken.TokenId)
.ToListAsync();
permissions.AddRange(perms.Select(p => p.PermissionName));
}
var groups = permissions
.Where(p => p.StartsWith("group."))
.ToList();
foreach (var group in groups) {
permissions.AddRange(await groupRepository.GetFullGroupPermissions(group));
}
return permissions;
}
}

View File

@@ -0,0 +1,66 @@
using HopFrame.Database.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace HopFrame.Database.Repositories.Implementation;
internal sealed class TokenRepository<TDbContext>(TDbContext context) : ITokenRepository where TDbContext : HopDbContextBase {
public async Task<Token> GetToken(string content) {
var success = Guid.TryParse(content, out Guid guid);
if (!success) return null;
return await context.Tokens
.Include(t => t.Owner)
.Where(t => t.TokenId == guid)
.SingleOrDefaultAsync();
}
public async Task<Token> CreateToken(int type, User owner) {
var token = new Token {
CreatedAt = DateTime.Now,
TokenId = Guid.NewGuid(),
Type = type,
Owner = owner
};
await context.Tokens.AddAsync(token);
await context.SaveChangesAsync();
return token;
}
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();
}
public async Task DeleteToken(Token token) {
context.Tokens.Remove(token);
await context.SaveChangesAsync();
}
public async Task<Token> 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;
}
}

View File

@@ -0,0 +1,148 @@
using System.Globalization;
using System.Text;
using HopFrame.Database.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Query;
namespace HopFrame.Database.Repositories.Implementation;
internal sealed class UserRepository<TDbContext>(TDbContext context, IGroupRepository groupRepository) : IUserRepository where TDbContext : HopDbContextBase {
private IIncludableQueryable<User, IList<Token>> IncludeReferences() {
return context.Users
.Include(u => u.Permissions)
.Include(u => u.Tokens);
}
public async Task<IList<User>> GetUsers() {
return await IncludeReferences()
.ToListAsync();
}
public async Task<User> GetUser(Guid userId) {
return await IncludeReferences()
.Where(u => u.Id == userId)
.SingleOrDefaultAsync();
}
public async Task<User> GetUserByEmail(string email) {
return await IncludeReferences()
.Where(u => u.Email == email)
.SingleOrDefaultAsync();
}
public async Task<User> GetUserByUsername(string username) {
return await IncludeReferences()
.Where(u => u.Username == username)
.SingleOrDefaultAsync();
}
public async Task<User> AddUser(User user) {
if (await GetUserByEmail(user.Email) is not null) return null;
if (await GetUserByUsername(user.Username) is not null) return null;
var entry = new User {
Id = Guid.NewGuid(),
Email = user.Email,
Username = user.Username,
CreatedAt = DateTime.Now,
Permissions = user.Permissions ?? new List<Permission>(),
Tokens = user.Tokens
};
entry.Password = EncryptionManager.Hash(user.Password, Encoding.Default.GetBytes(entry.CreatedAt.ToString(CultureInfo.InvariantCulture)));
var defaultGroups = await groupRepository.GetDefaultGroups();
foreach (var group in defaultGroups) {
entry.Permissions.Add(new Permission {
PermissionName = group.Name,
GrantedAt = DateTime.Now
});
}
await context.Users.AddAsync(entry);
await context.SaveChangesAsync();
return entry;
}
public async Task UpdateUser(User user) {
var entry = await IncludeReferences()
.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;
// 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();
}
public async Task DeleteUser(User user) {
var entry = await context.Users
.SingleOrDefaultAsync(entry => entry.Id == user.Id);
if (entry is null) return;
context.Users.Remove(entry);
await context.SaveChangesAsync();
}
public async Task<bool> CheckUserPassword(User user, string password) {
var hash = EncryptionManager.Hash(password, Encoding.Default.GetBytes(user.CreatedAt.ToString(CultureInfo.InvariantCulture)));
var entry = await context.Users
.Where(entry => entry.Id == user.Id)
.SingleOrDefaultAsync();
return entry.Password == hash;
}
public async Task ChangePassword(User user, string password) {
var entry = await context.Users
.Where(entry => entry.Id == user.Id)
.SingleOrDefaultAsync();
if (entry is null) return;
var hash = EncryptionManager.Hash(password, Encoding.Default.GetBytes(user.CreatedAt.ToString(CultureInfo.InvariantCulture)));
entry.Password = hash;
await context.SaveChangesAsync();
}
}

View File

@@ -0,0 +1,18 @@
using HopFrame.Database.Repositories;
using HopFrame.Database.Repositories.Implementation;
using Microsoft.Extensions.DependencyInjection;
namespace HopFrame.Database;
public static class ServiceCollectionExtensions {
public static IServiceCollection AddHopFrameRepositories<TDbContext>(this IServiceCollection services) where TDbContext : HopDbContextBase {
services.AddScoped<IGroupRepository, GroupRepository<TDbContext>>();
services.AddScoped<IPermissionRepository, PermissionRepository<TDbContext>>();
services.AddScoped<IUserRepository, UserRepository<TDbContext>>();
services.AddScoped<ITokenRepository, TokenRepository<TDbContext>>();
return services;
}
}

View File

@@ -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";
}

View File

@@ -1,10 +1,11 @@
using System.Security.Claims; using System.Security.Claims;
using System.Text.Encodings.Web; using System.Text.Encodings.Web;
using HopFrame.Database; using HopFrame.Database.Models;
using HopFrame.Database.Repositories;
using HopFrame.Security.Authentication.OpenID;
using HopFrame.Security.Authentication.OpenID.Options;
using HopFrame.Security.Claims; using HopFrame.Security.Claims;
using HopFrame.Security.Services;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@@ -13,45 +14,89 @@ using Microsoft.Extensions.Options;
namespace HopFrame.Security.Authentication; namespace HopFrame.Security.Authentication;
public class HopFrameAuthentication<TDbContext>( public class HopFrameAuthentication(
IOptionsMonitor<AuthenticationSchemeOptions> options, IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger, ILoggerFactory logger,
UrlEncoder encoder, UrlEncoder encoder,
ISystemClock clock, ISystemClock clock,
TDbContext context, ITokenRepository tokens,
IPermissionService perms) IPermissionRepository perms,
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder, clock) IOptions<HopFrameAuthenticationOptions> tokenOptions,
where TDbContext : HopDbContextBase { IOptions<OpenIdOptions> openIdOptions,
IUserRepository users,
IOpenIdAccessor accessor)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder, clock) {
public const string SchemeName = "HopCore.Authentication"; public const string SchemeName = "HopFrame.Authentication";
public static readonly TimeSpan AccessTokenTime = new(0, 0, 5, 0);
public static readonly TimeSpan RefreshTokenTime = new(30, 0, 0, 0);
protected override async Task<AuthenticateResult> HandleAuthenticateAsync() { protected override async Task<AuthenticateResult> HandleAuthenticateAsync() {
var accessToken = Request.Cookies[ITokenContext.AccessTokenType]; var accessToken = Request.Cookies[ITokenContext.AccessTokenType];
if (string.IsNullOrEmpty(accessToken)) accessToken = Request.Headers[SchemeName]; if (string.IsNullOrEmpty(accessToken)) accessToken = Request.Headers[SchemeName];
if (string.IsNullOrEmpty(accessToken)) accessToken = Request.Headers["Token"]; 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"); if (string.IsNullOrEmpty(accessToken)) return AuthenticateResult.Fail("No Access Token provided");
var tokenEntry = await context.Tokens.SingleOrDefaultAsync(token => token.Token == accessToken); var tokenEntry = await tokens.GetToken(accessToken);
if (tokenEntry?.Type != Token.ApiTokenType && openIdOptions.Value.Enabled && !Guid.TryParse(accessToken, out _)) {
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, 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 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.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 (!await context.Users.AnyAsync(user => user.Id == tokenEntry.UserId)) if (tokenEntry.Owner is null)
return AuthenticateResult.Fail("The provided Access Token does not match any user"); return AuthenticateResult.Fail("The provided Access Token does not match any user");
var principal = await GenerateClaims(tokenEntry, perms);
return AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name));
}
public static async Task<ClaimsPrincipal> GenerateClaims(Token token, IPermissionRepository perms) {
var claims = new List<Claim> { var claims = new List<Claim> {
new(HopFrameClaimTypes.AccessTokenId, accessToken), new(HopFrameClaimTypes.AccessTokenId, token.TokenId.ToString()),
new(HopFrameClaimTypes.UserId, tokenEntry.UserId) new(HopFrameClaimTypes.UserId, token.Owner.Id.ToString())
}; };
var permissions = await perms.GetFullPermissions(tokenEntry.UserId); var permissions = await perms.GetFullPermissions(token);
claims.AddRange(permissions.Select(perm => new Claim(HopFrameClaimTypes.Permission, perm))); claims.AddRange(permissions.Select(perm => new Claim(HopFrameClaimTypes.Permission, perm)));
var principal = new ClaimsPrincipal(); var principal = new ClaimsPrincipal();
principal.AddIdentity(new ClaimsIdentity(claims, SchemeName)); principal.AddIdentity(new ClaimsIdentity(claims, SchemeName));
return AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name)); return principal;
} }
} }

View File

@@ -1,29 +1,37 @@
using HopFrame.Database; 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.Claims;
using HopFrame.Security.Services; using HopFrame.Security.Options;
using HopFrame.Security.Services.Implementation;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.DependencyInjection.Extensions;
namespace HopFrame.Security.Authentication; namespace HopFrame.Security.Authentication;
public static class HopFrameAuthenticationExtensions { public static class HopFrameAuthenticationExtensions {
/// <summary> /// <summary>
/// Configures the WebApplication to use the authentication and authorization of the HopFrame API /// Configures the WebApplication to use the authentication and authorization of the HopFrame API
/// </summary> /// </summary>
/// <param name="service">The service provider to add the services to</param> /// <param name="service">The service provider to add the services to</param>
/// <typeparam name="TDbContext">The database object that saves all entities that are important for the security api</typeparam> /// <param name="configuration">The configuration used to configure HopFrame authentication</param>
/// <returns></returns> /// <returns></returns>
public static IServiceCollection AddHopFrameAuthentication<TDbContext>(this IServiceCollection service) where TDbContext : HopDbContextBase { public static IServiceCollection AddHopFrameAuthentication(this IServiceCollection service, ConfigurationManager configuration) {
service.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>(); service.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();
service.AddScoped<ITokenContext, TokenContextImplementor<TDbContext>>(); service.AddScoped<ITokenContext, TokenContextImplementor>();
service.AddScoped<IPermissionService, PermissionService<TDbContext>>();
service.AddScoped<IUserService, UserService<TDbContext>>();
service.AddAuthentication(HopFrameAuthentication<TDbContext>.SchemeName).AddScheme<AuthenticationSchemeOptions, HopFrameAuthentication<TDbContext>>(HopFrameAuthentication<TDbContext>.SchemeName, _ => {}); service.AddHttpClient();
service.AddMemoryCache();
service.AddScoped<IOpenIdAccessor, OpenIdAccessor>();
service.AddOptionsFromConfiguration<HopFrameAuthenticationOptions>(configuration);
service.AddOptionsFromConfiguration<AdminPermissionOptions>(configuration);
service.AddOptionsFromConfiguration<OpenIdOptions>(configuration);
service.AddAuthentication(HopFrameAuthentication.SchemeName).AddScheme<AuthenticationSchemeOptions, HopFrameAuthentication>(HopFrameAuthentication.SchemeName, _ => {});
service.AddAuthorization(); service.AddAuthorization();
return service; return service;

View File

@@ -0,0 +1,24 @@
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) : 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; }
public class TokenTime {
public int Days { get; set; }
public int Hours { get; set; }
public int Minutes { get; set; }
public int Seconds { get; set; }
public TimeSpan ConstructTimeSpan => new(Days, Hours, Minutes, Seconds);
}
}

View File

@@ -0,0 +1,15 @@
using HopFrame.Security.Authentication.OpenID.Models;
namespace HopFrame.Security.Authentication.OpenID;
public interface IOpenIdAccessor {
public static string DefaultCallback;
Task<OpenIdConfiguration> LoadConfiguration();
Task<OpenIdToken> RequestToken(string code);
Task<string> ConstructAuthUri(string state = null);
Task<OpenIdIntrospection> InspectToken(string token);
Task<OpenIdToken> RefreshAccessToken(string refreshToken);
void SetAuthenticationCookies(OpenIdToken token);
void Logout();
}

View File

@@ -0,0 +1,145 @@
using System.Text.Json;
using HopFrame.Security.Authentication.OpenID.Models;
using HopFrame.Security.Authentication.OpenID.Options;
using HopFrame.Security.Claims;
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<OpenIdOptions> options, IHttpContextAccessor accessor, IMemoryCache cache) : IOpenIdAccessor {
private const string ConfigurationCacheKey = "HopFrame:OpenID:Configuration";
private const string AuthCodeCacheKey = "HopFrame:OpenID:Code:";
private const string TokenCacheKey = "HopFrame:OpenID:Token:";
public async Task<OpenIdConfiguration> 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").Replace("\\", "/"));
var response = await client.SendAsync(request);
if (!response.IsSuccessStatusCode)
return null;
var config = await JsonSerializer.DeserializeAsync<OpenIdConfiguration>(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<OpenIdToken> 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 ?? Path.Combine($"{protocol}://{accessor.HttpContext!.Request.Host.Value}", IOpenIdAccessor.DefaultCallback).Replace("\\", "/");
var configuration = await LoadConfiguration();
var client = clientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, configuration.TokenEndpoint) {
Content = new FormUrlEncodedContent(new Dictionary<string, string> {
{ "grant_type", "authorization_code" },
{ "code", code },
{ "redirect_uri", callback },
{ "client_id", options.Value.ClientId },
{ "client_secret", options.Value.ClientSecret }
})
};
var response = await client.SendAsync(request);
if (!response.IsSuccessStatusCode)
return null;
var token = await JsonSerializer.DeserializeAsync<OpenIdToken>(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<string> ConstructAuthUri(string state = null) {
var protocol = accessor.HttpContext!.Request.IsHttps ? "https" : "http";
var callback = options.Value.Callback ?? Path.Combine($"{protocol}://{accessor.HttpContext!.Request.Host.Value}", IOpenIdAccessor.DefaultCallback).Replace("\\", "/");
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}";
}
public async Task<OpenIdIntrospection> 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();
var request = new HttpRequestMessage(HttpMethod.Post, configuration.IntrospectionEndpoint) {
Content = new FormUrlEncodedContent(new Dictionary<string, string> {
{ "token", token },
{ "client_id", options.Value.ClientId },
{ "client_secret", options.Value.ClientSecret }
})
};
var response = await client.SendAsync(request);
if (!response.IsSuccessStatusCode)
return null;
var introspection = await JsonSerializer.DeserializeAsync<OpenIdIntrospection>(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<OpenIdToken> RefreshAccessToken(string refreshToken) {
var configuration = await LoadConfiguration();
var client = clientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, configuration.TokenEndpoint) {
Content = new FormUrlEncodedContent(new Dictionary<string, string> {
{ "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<OpenIdToken>(await response.Content.ReadAsStreamAsync());
}
public void SetAuthenticationCookies(OpenIdToken token) {
if (token.AccessToken is not null)
accessor.HttpContext!.Response.Cookies.Append(ITokenContext.AccessTokenType, token.AccessToken, new CookieOptions {
MaxAge = TimeSpan.FromSeconds(token.ExpiresIn),
HttpOnly = false,
Secure = true
});
if (token.RefreshToken is not null)
accessor.HttpContext!.Response.Cookies.Append(ITokenContext.RefreshTokenType, token.RefreshToken, new CookieOptions {
MaxAge = options.Value.RefreshToken.ConstructTimeSpan,
HttpOnly = false,
Secure = true
});
}
public void Logout() {
accessor.HttpContext!.Response.Cookies.Delete(ITokenContext.RefreshTokenType);
accessor.HttpContext!.Response.Cookies.Delete(ITokenContext.AccessTokenType);
}
}

View File

@@ -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<string> ResponseTypesSupported { get; set; }
[JsonPropertyName("response_modes_supported")]
public List<string> ResponseModesSupported { get; set; }
[JsonPropertyName("jwks_uri")]
public string JwksUri { get; set; }
[JsonPropertyName("grant_types_supported")]
public List<string> GrantTypesSupported { get; set; }
[JsonPropertyName("id_token_signing_alg_values_supported")]
public List<string> IdTokenSigningAlgValuesSupported { get; set; }
[JsonPropertyName("subject_types_supported")]
public List<string> SubjectTypesSupported { get; set; }
[JsonPropertyName("token_endpoint_auth_methods_supported")]
public List<string> TokenEndpointAuthMethodsSupported { get; set; }
[JsonPropertyName("acr_values_supported")]
public List<string> AcrValuesSupported { get; set; }
[JsonPropertyName("scopes_supported")]
public List<string> ScopesSupported { get; set; }
[JsonPropertyName("request_parameter_supported")]
public bool RequestParameterSupported { get; set; }
[JsonPropertyName("claims_supported")]
public List<string> ClaimsSupported { get; set; }
[JsonPropertyName("claims_parameter_supported")]
public bool ClaimsParameterSupported { get; set; }
[JsonPropertyName("code_challenge_methods_supported")]
public List<string> CodeChallengeMethodsSupported { get; set; }
}

View File

@@ -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<string> 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<string> 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; }
}

View File

@@ -0,0 +1,20 @@
using System.Text.Json.Serialization;
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; }
[JsonPropertyName("expires_in")]
public int ExpiresIn { get; set; }
[JsonPropertyName("id_token")]
public string IdToken { get; set; }
}

View File

@@ -0,0 +1,53 @@
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; }
public HopFrameAuthenticationOptions.TokenTime RefreshToken { get; set; } = new() {
Days = 30
};
public CachingOptions Cache { get; set; } = new() {
Enabled = true,
Configuration = new() {
Enabled = true,
TTL = new() {
Hours = 24
}
},
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; }
}
}

View File

@@ -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; }
}
}

View File

@@ -1,3 +1,4 @@
using HopFrame.Database;
using HopFrame.Security.Claims; using HopFrame.Security.Claims;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Authorization; using Microsoft.AspNetCore.Mvc.Authorization;

View File

@@ -20,5 +20,5 @@ public interface ITokenContext {
/// <summary> /// <summary>
/// The access token the user provided /// The access token the user provided
/// </summary> /// </summary>
Guid AccessToken { get; } Token AccessToken { get; }
} }

View File

@@ -1,15 +1,19 @@
using HopFrame.Database;
using HopFrame.Database.Models; using HopFrame.Database.Models;
using HopFrame.Database.Repositories;
using HopFrame.Security.Authentication.OpenID.Options;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
namespace HopFrame.Security.Claims; namespace HopFrame.Security.Claims;
internal sealed class TokenContextImplementor<TDbContext>(IHttpContextAccessor accessor, TDbContext context) : ITokenContext where TDbContext : HopDbContextBase { internal sealed class TokenContextImplementor(IHttpContextAccessor accessor, IUserRepository users, ITokenRepository tokens, IOptions<OpenIdOptions> options) : ITokenContext {
public bool IsAuthenticated => !string.IsNullOrEmpty(accessor.HttpContext?.User.GetAccessTokenId()); public bool IsAuthenticated => !string.IsNullOrEmpty(accessor.HttpContext?.User.GetAccessTokenId());
public User User => context.Users public User User => users.GetUser(Guid.Parse(accessor.HttpContext?.User.GetUserId() ?? Guid.Empty.ToString())).GetAwaiter().GetResult();
.SingleOrDefault(user => user.Id == accessor.HttpContext.User.GetUserId())?
.ToUserModel(context); public Token AccessToken => options.Value.Enabled ? new Token {
Owner = User,
public Guid AccessToken => Guid.Parse(accessor.HttpContext?.User.GetAccessTokenId() ?? Guid.Empty.ToString()); Type = Token.OpenIdTokenType,
CreatedAt = DateTime.Now
} : tokens.GetToken(accessor.HttpContext?.User.GetAccessTokenId()).GetAwaiter().GetResult();
} }

View File

@@ -8,7 +8,6 @@
<RootNamespace>HopFrame.Security</RootNamespace> <RootNamespace>HopFrame.Security</RootNamespace>
<PackageId>HopFrame.Security</PackageId> <PackageId>HopFrame.Security</PackageId>
<Version>1.1.0</Version>
<PackageReadmeFile>README.md</PackageReadmeFile> <PackageReadmeFile>README.md</PackageReadmeFile>
<PackageLicenseExpression>MIT</PackageLicenseExpression> <PackageLicenseExpression>MIT</PackageLicenseExpression>
<IsPackable>true</IsPackable> <IsPackable>true</IsPackable>

View File

@@ -0,0 +1,5 @@
namespace HopFrame.Security.Options;
public abstract class OptionsFromConfiguration {
public abstract string Position { get; }
}

View File

@@ -0,0 +1,19 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace HopFrame.Security.Options;
public static class OptionsFromConfigurationExtensions {
public static void AddOptionsFromConfiguration<T>(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<T>)(options => {
IConfigurationSection section = configuration.GetSection(position);
section.Bind(options);
}));
}
}

View File

@@ -1,74 +1,4 @@
# HopFrame Security module # HopFrame Security module
this module contains all handlers for the login and register validation. It also checks the user permissions. this module contains all handlers for the login and register validation. It also checks the user permissions.
# Services added in this module For more information about the HopFrame visit the [docs](https://git.leon-hoppe.de/leon.hoppe/HopFrame).
You can use these services by specifying them as a dependency. All of them are scoped dependencies.
## ITokenContext
This service provides the information given by the current request
```csharp
public interface ITokenContext {
bool IsAuthenticated { get; }
User User { get; }
Guid AccessToken { get; }
}
```
## IUserService
This service simplifies the data access of the user table in the database.
```csharp
public interface IUserService {
Task<IList<User>> GetUsers();
Task<User> GetUser(Guid userId);
Task<User> GetUserByEmail(string email);
Task<User> GetUserByUsername(string username);
Task<User> AddUser(UserRegister user);
Task UpdateUser(User user);
Task DeleteUser(User user);
Task<bool> CheckUserPassword(User user, string password);
Task ChangePassword(User user, string password);
}
```
## IPermissionService
This service handles all permission and group interactions with the data source.
```csharp
public interface IPermissionService {
Task<bool> HasPermission(string permission, Guid user);
Task<IList<PermissionGroup>> GetPermissionGroups();
Task<PermissionGroup> GetPermissionGroup(string name);
Task EditPermissionGroup(PermissionGroup group);
Task<IList<PermissionGroup>> GetUserPermissionGroups(User user);
Task RemoveGroupFromUser(User user, PermissionGroup group);
Task<PermissionGroup> CreatePermissionGroup(string name, bool isDefault = false, string description = null);
Task DeletePermissionGroup(PermissionGroup group);
Task<Permission> GetPermission(string name, IPermissionOwner owner);
Task AddPermission(IPermissionOwner owner, string permission);
Task RemovePermission(Permission permission);
Task<string[]> GetFullPermissions(string user);
}
```

View File

@@ -1,48 +0,0 @@
using HopFrame.Database.Models;
namespace HopFrame.Security.Services;
/// <summary>
/// permission system:<br/>
/// - "*" -> all rights<br/>
/// - "group.[name]" -> group member<br/>
/// - "[namespace].[name]" -> single permission<br/>
/// - "[namespace].*" -> all permissions in the namespace
/// </summary>
public interface IPermissionService {
Task<bool> HasPermission(string permission, Guid user);
Task<IList<PermissionGroup>> GetPermissionGroups();
Task<PermissionGroup> GetPermissionGroup(string name);
Task EditPermissionGroup(PermissionGroup group);
Task<IList<PermissionGroup>> GetUserPermissionGroups(User user);
Task RemoveGroupFromUser(User user, PermissionGroup group);
Task<PermissionGroup> CreatePermissionGroup(string name, bool isDefault = false, string description = null);
Task DeletePermissionGroup(PermissionGroup group);
Task<Permission> GetPermission(string name, IPermissionOwner owner);
/// <summary>
/// permission system:<br/>
/// - "*" -> all rights<br/>
/// - "group.[name]" -> group member<br/>
/// - "[namespace].[name]" -> single permission<br/>
/// - "[namespace].*" -> all permissions in the namespace
/// </summary>
/// <param name="owner"></param>
/// <param name="permission"></param>
/// <returns></returns>
Task AddPermission(IPermissionOwner owner, string permission);
Task RemovePermission(Permission permission);
Task<string[]> GetFullPermissions(string user);
}

View File

@@ -1,178 +0,0 @@
using HopFrame.Database;
using HopFrame.Database.Models;
using HopFrame.Database.Models.Entries;
using HopFrame.Security.Authorization;
using HopFrame.Security.Claims;
using Microsoft.EntityFrameworkCore;
namespace HopFrame.Security.Services.Implementation;
internal sealed class PermissionService<TDbContext>(TDbContext context, ITokenContext current) : IPermissionService where TDbContext : HopDbContextBase {
public async Task<bool> HasPermission(string permission) {
return await HasPermission(permission, current.User.Id);
}
public async Task<bool> HasPermissions(params string[] permissions) {
var user = current.User.Id.ToString();
var perms = await GetFullPermissions(user);
foreach (var permission in permissions) {
if (!PermissionValidator.IncludesPermission(permission, perms)) return false;
}
return true;
}
public async Task<bool> HasAnyPermission(params string[] permissions) {
var user = current.User.Id.ToString();
var perms = await GetFullPermissions(user);
foreach (var permission in permissions) {
if (PermissionValidator.IncludesPermission(permission, perms)) return true;
}
return false;
}
public async Task<bool> HasPermission(string permission, Guid user) {
var permissions = await GetFullPermissions(user.ToString());
return PermissionValidator.IncludesPermission(permission, permissions);
}
public async Task<IList<PermissionGroup>> GetPermissionGroups() {
return await context.Groups
.Select(group => group.ToPermissionGroup(context))
.ToListAsync();
}
public Task<PermissionGroup> GetPermissionGroup(string name) {
return context.Groups
.Where(group => group.Name == name)
.Select(group => group.ToPermissionGroup(context))
.SingleOrDefaultAsync();
}
public async Task EditPermissionGroup(PermissionGroup group) {
var orig = await context.Groups.SingleOrDefaultAsync(g => g.Name == group.Name);
if (orig is null) return;
var entity = context.Groups.Update(orig);
entity.Entity.Default = group.IsDefaultGroup;
entity.Entity.Description = group.Description;
await context.SaveChangesAsync();
}
public async Task<IList<PermissionGroup>> GetUserPermissionGroups(User user) {
var groups = await context.Groups.ToListAsync();
var perms = await GetFullPermissions(user.Id.ToString());
return groups
.Where(group => perms.Contains(group.Name))
.Select(group => group.ToPermissionGroup(context))
.ToList();
}
public async Task RemoveGroupFromUser(User user, PermissionGroup group) {
var entry = await context.Permissions
.Where(perm => perm.PermissionText == group.Name && perm.UserId == user.Id.ToString())
.SingleOrDefaultAsync();
if (entry is null) return;
context.Permissions.Remove(entry);
await context.SaveChangesAsync();
}
public async Task<PermissionGroup> CreatePermissionGroup(string name, bool isDefault = false, string description = null) {
var group = new GroupEntry {
Name = name,
Description = description,
Default = isDefault,
CreatedAt = DateTime.Now
};
await context.Groups.AddAsync(group);
if (isDefault) {
var users = await context.Users.ToListAsync();
foreach (var user in users) {
await context.Permissions.AddAsync(new PermissionEntry {
GrantedAt = DateTime.Now,
PermissionText = group.Name,
UserId = user.Id
});
}
}
await context.SaveChangesAsync();
return group.ToPermissionGroup(context);
}
public async Task DeletePermissionGroup(PermissionGroup group) {
var entry = await context.Groups.SingleOrDefaultAsync(entry => entry.Name == group.Name);
context.Groups.Remove(entry);
var permissions = await context.Permissions
.Where(perm => perm.UserId == group.Name || perm.PermissionText == group.Name)
.ToListAsync();
if (permissions.Count > 0) {
context.Permissions.RemoveRange(permissions);
}
await context.SaveChangesAsync();
}
public async Task<Permission> GetPermission(string name, IPermissionOwner owner) {
var ownerId = (owner is User user) ? user.Id.ToString() : ((PermissionGroup)owner).Name;
return await context.Permissions
.Where(perm => perm.PermissionText == name && perm.UserId == ownerId)
.Select(perm => perm.ToPermissionModel())
.SingleOrDefaultAsync();
}
public async Task AddPermission(IPermissionOwner owner, string permission) {
var userId = owner is User user ? user.Id.ToString() : (owner as PermissionGroup)?.Name;
await context.Permissions.AddAsync(new PermissionEntry {
UserId = userId,
PermissionText = permission,
GrantedAt = DateTime.Now
});
await context.SaveChangesAsync();
}
public async Task RemovePermission(Permission permission) {
var entry = await context.Permissions.SingleOrDefaultAsync(entry => entry.RecordId == permission.Id);
context.Permissions.Remove(entry);
await context.SaveChangesAsync();
}
public async Task<string[]> GetFullPermissions(string user) {
var permissions = await context.Permissions
.Where(perm => perm.UserId == user)
.Select(perm => perm.PermissionText)
.ToListAsync();
var groups = permissions
.Where(perm => perm.StartsWith("group."))
.ToList();
var groupPerms = new List<string>();
foreach (var group in groups) {
var perms = await GetFullPermissions(group);
groupPerms.AddRange(perms);
}
permissions.AddRange(groupPerms);
return permissions.ToArray();
}
}

View File

@@ -1,128 +0,0 @@
using System.Globalization;
using System.Text;
using HopFrame.Database;
using HopFrame.Database.Models;
using HopFrame.Database.Models.Entries;
using HopFrame.Security.Models;
using Microsoft.EntityFrameworkCore;
namespace HopFrame.Security.Services.Implementation;
internal sealed class UserService<TDbContext>(TDbContext context) : IUserService where TDbContext : HopDbContextBase {
public async Task<IList<User>> GetUsers() {
return await context.Users
.Select(user => user.ToUserModel(context))
.ToListAsync();
}
public Task<User> GetUser(Guid userId) {
var id = userId.ToString();
return context.Users
.Where(user => user.Id == id)
.Select(user => user.ToUserModel(context))
.SingleOrDefaultAsync();
}
public Task<User> GetUserByEmail(string email) {
return context.Users
.Where(user => user.Email == email)
.Select(user => user.ToUserModel(context))
.SingleOrDefaultAsync();
}
public Task<User> GetUserByUsername(string username) {
return context.Users
.Where(user => user.Username == username)
.Select(user => user.ToUserModel(context))
.SingleOrDefaultAsync();
}
public async Task<User> AddUser(UserRegister user) {
if (await GetUserByEmail(user.Email) is not null) return null;
if (await GetUserByUsername(user.Username) is not null) return null;
var entry = new UserEntry {
Id = Guid.NewGuid().ToString(),
Email = user.Email,
Username = user.Username,
CreatedAt = DateTime.Now
};
entry.Password = EncryptionManager.Hash(user.Password, Encoding.Default.GetBytes(entry.CreatedAt.ToString(CultureInfo.InvariantCulture)));
await context.Users.AddAsync(entry);
var defaultGroups = await context.Groups
.Where(group => group.Default)
.Select(group => "group." + group.Name)
.ToListAsync();
await context.Permissions.AddRangeAsync(defaultGroups.Select(group => new PermissionEntry {
GrantedAt = DateTime.Now,
PermissionText = group,
UserId = entry.Id
}));
await context.SaveChangesAsync();
return entry.ToUserModel(context);
}
public async Task UpdateUser(User user) {
var id = user.Id.ToString();
var entry = await context.Users
.SingleOrDefaultAsync(entry => entry.Id == id);
if (entry is null) return;
entry.Email = user.Email;
entry.Username = user.Username;
await context.SaveChangesAsync();
}
public async Task DeleteUser(User user) {
var id = user.Id.ToString();
var entry = await context.Users
.SingleOrDefaultAsync(entry => entry.Id == id);
if (entry is null) return;
context.Users.Remove(entry);
var userTokens = await context.Tokens
.Where(token => token.UserId == id)
.ToArrayAsync();
context.Tokens.RemoveRange(userTokens);
var userPermissions = await context.Permissions
.Where(perm => perm.UserId == id)
.ToArrayAsync();
context.Permissions.RemoveRange(userPermissions);
context.OnUserDelete(entry);
await context.SaveChangesAsync();
}
public async Task<bool> CheckUserPassword(User user, string password) {
var id = user.Id.ToString();
var hash = EncryptionManager.Hash(password, Encoding.Default.GetBytes(user.CreatedAt.ToString(CultureInfo.InvariantCulture)));
var entry = await context.Users
.Where(entry => entry.Id == id)
.SingleOrDefaultAsync();
return entry.Password == hash;
}
public async Task ChangePassword(User user, string password) {
var entry = await context.Users
.Where(entry => entry.Id == user.Id.ToString())
.SingleOrDefaultAsync();
if (entry is null) return;
var hash = EncryptionManager.Hash(password, Encoding.Default.GetBytes(user.CreatedAt.ToString(CultureInfo.InvariantCulture)));
entry.Password = hash;
await context.SaveChangesAsync();
}
}

View File

@@ -0,0 +1,9 @@
using HopFrame.Web.Admin.Generators;
namespace HopFrame.Web.Admin;
public abstract class AdminPagesContext {
public virtual void OnModelCreating(IAdminContextGenerator generator) {}
}

View File

@@ -0,0 +1,6 @@
namespace HopFrame.Web.Admin.Attributes;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Property)]
public sealed class AdminNameAttribute(string name) : Attribute {
public string Name { get; set; } = name;
}

View File

@@ -0,0 +1,10 @@
namespace HopFrame.Web.Admin.Attributes;
/// <summary>
/// This attribute specifies the url of the admin page and needs to be applied on the AdminPage property in the AdminContext directly
/// </summary>
/// <param name="url">The page url: '/administration/{url}'</param>
[AttributeUsage(AttributeTargets.Property)]
public sealed class AdminPageUrlAttribute(string url) : Attribute {
public string Url { get; set; } = url;
}

View File

@@ -0,0 +1,8 @@
namespace HopFrame.Web.Admin.Attributes.Classes;
[AttributeUsage(AttributeTargets.Class)]
public sealed class AdminButtonConfigAttribute(bool showCreateButton = true, bool showDeleteButton = true, bool showUpdateButton = true) : Attribute {
public bool ShowCreateButton { get; set; } = showCreateButton;
public bool ShowDeleteButton { get; set; } = showDeleteButton;
public bool ShowUpdateButton { get; set; } = showUpdateButton;
}

View File

@@ -0,0 +1,6 @@
namespace HopFrame.Web.Admin.Attributes;
[AttributeUsage(AttributeTargets.Class)]
public sealed class AdminDescriptionAttribute(string description) : Attribute {
public string Description { get; set; } = description;
}

Some files were not shown because too many files have changed in this diff Show More