diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 6287fe9..47ff617 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -21,14 +21,15 @@ build:
test:
stage: test
script:
- - dotnet test --no-restore --verbosity normal
+ - dotnet test --verbosity normal
publish:
stage: publish
script:
- - dotnet pack -c Release -o .
+ - export VERSION=$(echo $CI_COMMIT_TAG | sed 's/^v//')
+ - dotnet pack -c Release -o . /p:Version=$VERSION
- for nupkg in *.nupkg; do dotnet nuget push $nupkg -k $NUGET_API_KEY -s https://api.nuget.org/v3/index.json; done
only:
- - main
- variables:
+ - tags
+ variables:
NUGET_API_KEY: $NUGET_API_KEY
diff --git a/.idea/.idea.HopFrame/.idea/dataSources.xml b/.idea/.idea.HopFrame/.idea/dataSources.xml
index ded00e9..3e820ee 100644
--- a/.idea/.idea.HopFrame/.idea/dataSources.xml
+++ b/.idea/.idea.HopFrame/.idea/dataSources.xml
@@ -5,7 +5,7 @@
sqlite.xerial
true
org.sqlite.JDBC
- jdbc:sqlite:$PROJECT_DIR$/test/RestApiTest/bin/Debug/net8.0/test.db
+ jdbc:sqlite:C:\Users\leon\Documents\Projekte\HopFrame\testing\HopFrame.Testing.Api\bin\Debug\net8.0\test.db
diff --git a/.idea/.idea.HopFrame/.idea/discord.xml b/.idea/.idea.HopFrame/.idea/discord.xml
new file mode 100644
index 0000000..912db82
--- /dev/null
+++ b/.idea/.idea.HopFrame/.idea/discord.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/HopFrame.sln b/HopFrame.sln
index 2e65007..06d8283 100644
--- a/HopFrame.sln
+++ b/HopFrame.sln
@@ -2,7 +2,7 @@
Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Database", "src\HopFrame.Database\HopFrame.Database.csproj", "{003120AE-F38B-4632-8497-BE4505189627}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RestApiTest", "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
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Security", "src\HopFrame.Security\HopFrame.Security.csproj", "{7F82E1C6-4A42-4337-9E03-2EE6429D004F}"
EndProject
@@ -10,10 +10,24 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Api", "src\HopFram
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Web", "src\HopFrame.Web\HopFrame.Web.csproj", "{3BE585BC-13A5-4BE4-A806-E9EC2D825956}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FrontendTest", "test\FrontendTest\FrontendTest.csproj", "{8F983A37-63CF-48D5-988D-58B78EF8AECD}"
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "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
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -48,7 +62,34 @@ Global
{02D9F10A-664A-4EF7-BF19-310C26FF4DEB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{02D9F10A-664A-4EF7-BF19-310C26FF4DEB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{02D9F10A-664A-4EF7-BF19-310C26FF4DEB}.Release|Any CPU.Build.0 = Release|Any CPU
+ {1CAAC943-B8FE-48DD-9712-92699647DE18}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {1CAAC943-B8FE-48DD-9712-92699647DE18}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {1CAAC943-B8FE-48DD-9712-92699647DE18}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {1CAAC943-B8FE-48DD-9712-92699647DE18}.Release|Any CPU.Build.0 = Release|Any CPU
+ {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
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
EndGlobal
diff --git a/HopFrame.sln.DotSettings.user b/HopFrame.sln.DotSettings.user
index a38eed3..ae702c9 100644
--- a/HopFrame.sln.DotSettings.user
+++ b/HopFrame.sln.DotSettings.user
@@ -1,8 +1,85 @@
+ ForceIncluded
+ ForceIncluded
+ ForceIncluded
ForceIncluded
+ ForceIncluded
+ ForceIncluded
ForceIncluded
+ ForceIncluded
+ ForceIncluded
+ ForceIncluded
ForceIncluded
<AssemblyExplorer>
<Assembly Path="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\7.0.16\ref\net7.0\System.ComponentModel.Annotations.dll" />
<Assembly Path="C:\Users\Remote\.nuget\packages\blazorstrap\5.2.100.61524\lib\net7.0\BlazorStrap.dll" />
-</AssemblyExplorer>
\ No newline at end of file
+</AssemblyExplorer>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/README.md b/README.md
index 4d53003..b41c964 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,8 @@ A simple backend management api for ASP.NET Core Web APIs
- [x] User authentication
- [x] Permission management
- [x] Generated frontend administration boards
+- [x] API token support
+- [x] OpenID authentication integration
# Usage
There are two different versions of HopFrame, either the Web API version or the full Blazor web version.
diff --git a/docs/authentication.md b/docs/authentication.md
new file mode 100644
index 0000000..a8e29a1
--- /dev/null
+++ b/docs/authentication.md
@@ -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");
+```
diff --git a/docs/models.md b/docs/models.md
index 39ecc99..7f61e86 100644
--- a/docs/models.md
+++ b/docs/models.md
@@ -35,16 +35,18 @@ public class Permission {
public DateTime GrantedAt { get; set; }
public virtual User User { get; set; }
public virtual PermissionGroup Group { get; set; }
+ public virtual Token Token { get; set; }
}
```
## Token
```csharp
-public class Token {
+public class Token : IPermissionOwner {
public int Type { get; set; }
public Guid Content { get; set; }
public DateTime CreatedAt { get; set; }
public virtual User Owner { get; set; }
+ public virtual List Permissions { get; set; }
}
```
diff --git a/docs/openid.md b/docs/openid.md
new file mode 100644
index 0000000..00a15f4
--- /dev/null
+++ b/docs/openid.md
@@ -0,0 +1,120 @@
+# OpenID Authentication
+The HopFrame allows you to use an OpenID provider as your authentication provider for single sign on or better security
+etc. To use it, you just simply need to configure it through the `appsettings.json` or environment variables.
+
+>**Note**: The Blazor module has not yet implemented endpoints for the login process, but the middleware is correctly
+> configured and the `IOpenIdAccessor` service is also provided for you to easily implement the endpoints yourself.
+
+When you have enabled the integration, new endpoints will also be provided to perform the authentication.
+simply use the swagger explorer to look up how the endpoints function. They're all under the subroute
+`/api/v1/openid/`.
+
+## Configure the HopFrame to use OpenID authentication
+
+1. Create / Configure your OpenID provider:
+
+ - Save the ClientID and Client Secret from the provider, because you need it later.
+ - The default redirect uri looks something like this: `https://example.com/api/v1/openid/callback`.
+ - **Replace** the origin with the FQDN of your service.
+ - In order for the HopFrame to automatically renew expired access tokens you need to enable the `offline_access` scope.
+ - The integration also works without doing that, but then you need to reauthenticate every time your access token expires.
+
+2. Configure the HopFrame integration:
+
+ >**Hint**: All of these configuration options can also be defined as environment variables. Use '__'
+ > to separate the namespaces like so: `HOPFRAME__AUTHENTICATION__OPENID__ENABLED=true`
+
+ - Add the following lines to your `appsettings.json`:
+ ```json
+ "HopFrame": {
+ "Authentication": {
+ "OpenID": {
+ "Enabled": true,
+ "Issuer": "your-issuer",
+ "ClientId": "your-client-id",
+ "ClientSecret": "your-client-secret"
+ }
+ }
+ }
+ ```
+
+ >**Hint**: If you are using Authentik, the issuer url looks something like this: `https://auth.example.com/application/o/application-name/`.
+ > Just replace the FQDN and application-name with your configured application.
+
+ - **Optional**: You can also disable the default authentication via the config:
+
+ ```json
+ "HopFrame": {
+ "Authentication": {
+ "DefaultAuthentication": false
+ }
+ }
+ ```
+
+ - **Optional**: By default, the HopFrame will cache the api responses to reduce api latency. This can also be configured in the config (the cache can also be completely disabled here):
+
+ ```json
+ "HopFrame": {
+ "Authentication": {
+ "OpenID": {
+ "Cache": {
+ "Enabled": true,
+ "Configuration": {
+ "Hours": 5
+ },
+ "Auth": {
+ "Seconds": 90
+ },
+ "Inspection": {
+ "Minutes": 5
+ }
+ }
+ }
+ }
+ }
+ ```
+
+ - **Optional**: You can also define your own callback endpoint like so (you also need to add / replace the endpoint in the provider settings):
+
+ ```json
+ "HopFrame": {
+ "Authentication": {
+ "OpenID": {
+ "Callback": "https://example.com/auth/callback"
+ }
+ }
+ }
+ ```
+
+ - **Optional**: You can also prevent new users from being created by disabling it in the config:
+
+ ```json
+ "HopFrame": {
+ "Authentication": {
+ "OpenID": {
+ "GenerateUsers": false
+ }
+ }
+ }
+ ```
+
+## Use the abstraction to integrate OpenID yourself
+
+The HopFrame has a service, that simplifies the communication with the OpenID provider called `IOpenIdAccessor`.
+You can inject it like every other service in your application.
+
+```csharp
+public interface IOpenIdAccessor {
+
+ Task LoadConfiguration();
+
+ Task RequestToken(string code, string defaultCallback);
+
+ Task ConstructAuthUri(string defaultCallback, string state = null);
+
+ Task InspectToken(string token);
+
+ Task RefreshAccessToken(string refreshToken);
+
+}
+```
diff --git a/docs/permissions.md b/docs/permissions.md
new file mode 100644
index 0000000..12a17cc
--- /dev/null
+++ b/docs/permissions.md
@@ -0,0 +1,80 @@
+# HopFrame Permissions
+
+Permissions in the HopFrame are simple and effective to use.
+As discussed in the [repositories](./repositories.md) documentation, you can manage user / group permissions
+via the `IPermissionRepository` service.
+
+## How do permissions work in the HopFrame
+
+Permissions are defined using the . (dot) syntax. This enables you to nest permissions in namespaces.
+You can also give a user or a group the permission to every permission in a namespace by using the * (star) syntax.
+
+| Permission | Example | Description |
+|----------------------|-------------------------------|-------------------------------------------------------|
+| `*` | `*` | all permissions |
+| `[namespace].[name]` | `hopframe.admin.users.create` | single permission |
+| `[namespace].*` | `hopframe.admin.*` | all permissions in that namespace (works recursively) |
+
+### Reserved namespaces
+
+| Namespace | Example | Description |
+|-----------|---------------|------------------------------------------|
+| `group` | `group.admin` | The user needs to be in a specific group |
+
+### Permission Groups
+
+You can manage them through the `IGroupRepository` as described in the [repositories](./repositories.md) documentation.
+You add permissions just like you would to a user with the `IPermissionRepository`.
+You can assign a user to a group by assigning the group permission to the user:
+```csharp
+permissionRepository.AddPermission(user, "group.admin");
+```
+
+## Predefined Permissions
+
+| Permission | Description |
+|--------------------------------|-------------------------------|
+| `hopframe.admin` | Access to the admin dashboard |
+| `hopframe.admin.users.read` | View all users |
+| `hopframe.admin.users.update` | Edit a user |
+| `hopframe.admin.users.delete` | Delete a user |
+| `hopframe.admin.users.create` | Add a group |
+| `hopframe.admin.groups.read` | View all groups |
+| `hopframe.admin.groups.update` | Edit a group |
+| `hopframe.admin.groups.delete` | Delete a group |
+| `hopframe.admin.groups.create` | Add a group |
+
+### Configuring HopFrame permissions
+
+You can also configure the predefined permissions using the `appsettings.json` or environment variables
+by configuring your configuration to load these.
+>**Hint**: Configuring your application to use environment variables works by simply adding
+> `builder.Configuration.AddEnvironmentVariables();` to your startup configuration before you add the
+> custom configurations / HopFrame services.
+
+You can specify `Dashboard` for the dashboard permission and for `Users` and `Groups` you can specify
+`Create`, `Read`, `Update` and `Delete` permissions.
+
+#### Configuration example
+```json
+ "HopFrame": {
+ "Permissions": {
+ "Dashboard": "myapp.dashboard.view",
+ "Users": {
+ "Read": "myapp.read.users"
+ },
+ "Groups": {
+ "Create": "myapp.create.groups",
+ "Update": "myapp.update.groups"
+ }
+ }
+ }
+```
+
+#### Environment variables example
+```dotenv
+HOPFRAME__PERMISSIONS__DASHBOARD="myapp.dashboard.view"
+HOPFRAME__PERMISSIONS__USERS__READ="myapp.read.users"
+HOPFRAME__PERMISSIONS__GROUPS__CREATE="myapp.create.groups"
+HOPFRAME__PERMISSIONS__GROUPS__UPDATE="myapp.update.groups"
+```
diff --git a/docs/readme.md b/docs/readme.md
index 289a64c..99198a9 100644
--- a/docs/readme.md
+++ b/docs/readme.md
@@ -7,6 +7,9 @@ The HopFrame comes in two variations, you can eiter only use the backend with so
- [Database](./database.md)
- [Repositories](./repositories.md)
- [Base Models](./models.md)
+- [Authentication](./authentication.md)
+- [Permissions](./permissions.md)
+- [OpenID Integration](./openid.md)
## HopFrame Web API
diff --git a/docs/repositories.md b/docs/repositories.md
index 25cb4ac..f72d876 100644
--- a/docs/repositories.md
+++ b/docs/repositories.md
@@ -71,5 +71,9 @@ public interface ITokenRepository {
Task CreateToken(int type, User owner);
Task DeleteUserTokens(User owner);
+
+ Task DeleteToken(Token token);
+
+ Task CreateApiToken(User owner, DateTime expirationDate);
}
```
diff --git a/src/HopFrame.Api/Controller/SecurityController.cs b/src/HopFrame.Api/Controller/AuthController.cs
similarity index 91%
rename from src/HopFrame.Api/Controller/SecurityController.cs
rename to src/HopFrame.Api/Controller/AuthController.cs
index d9c1128..4cf4430 100644
--- a/src/HopFrame.Api/Controller/SecurityController.cs
+++ b/src/HopFrame.Api/Controller/AuthController.cs
@@ -7,8 +7,8 @@ using Microsoft.AspNetCore.Mvc;
namespace HopFrame.Api.Controller;
[ApiController]
-[Route("api/v1/authentication")]
-public class SecurityController(IAuthLogic auth) : ControllerBase {
+[Route("api/v1/auth")]
+public class AuthController(IAuthLogic auth) : ControllerBase {
[HttpPut("login")]
public async Task>> Login([FromBody] UserLogin login) {
diff --git a/src/HopFrame.Api/Controller/GroupController.cs b/src/HopFrame.Api/Controller/GroupController.cs
new file mode 100644
index 0000000..fdfbc07
--- /dev/null
+++ b/src/HopFrame.Api/Controller/GroupController.cs
@@ -0,0 +1,74 @@
+using HopFrame.Api.Logic;
+using HopFrame.Database.Models;
+using HopFrame.Database.Repositories;
+using HopFrame.Security.Authorization;
+using HopFrame.Security.Claims;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Options;
+
+namespace HopFrame.Api.Controller;
+
+[ApiController, Route("api/v1/groups")]
+public class GroupController(IOptions permissions, IPermissionRepository perms, ITokenContext context, IGroupLogic groups) : ControllerBase {
+
+ private async Task AuthorizeRequest(string permission) {
+ return await perms.HasPermission(context.AccessToken, permission);
+ }
+
+ [HttpGet, Authorized]
+ public async Task>> GetGroups() {
+ if (!await AuthorizeRequest(permissions.Value.Groups.Read))
+ return Unauthorized();
+
+ return await groups.GetGroups();
+ }
+
+ [HttpGet("default"), Authorized]
+ public async Task>> GetDefaultGroups() {
+ if (!await AuthorizeRequest(permissions.Value.Groups.Read))
+ return Unauthorized();
+
+ return await groups.GetDefaultGroups();
+ }
+
+ [HttpGet("user/{userId}"), Authorized]
+ public async Task>> GetUserGroups(string userId) {
+ if (!await AuthorizeRequest(permissions.Value.Groups.Read))
+ return Unauthorized();
+
+ return await groups.GetUserGroups(userId);
+ }
+
+ [HttpGet("{name}"), Authorized]
+ public async Task> GetGroup(string name) {
+ if (!await AuthorizeRequest(permissions.Value.Groups.Read))
+ return Unauthorized();
+
+ return await groups.GetGroup(name);
+ }
+
+ [HttpPost, Authorized]
+ public async Task> CreateGroup([FromBody] PermissionGroup group) {
+ if (!await AuthorizeRequest(permissions.Value.Groups.Create))
+ return Unauthorized();
+
+ return await groups.CreateGroup(group);
+ }
+
+ [HttpPut, Authorized]
+ public async Task> UpdateGroup([FromBody] PermissionGroup group) {
+ if (!await AuthorizeRequest(permissions.Value.Groups.Update))
+ return Unauthorized();
+
+ return await groups.UpdateGroup(group);
+ }
+
+ [HttpDelete("{name}"), Authorized]
+ public async Task DeleteGroup(string name) {
+ if (!await AuthorizeRequest(permissions.Value.Groups.Delete))
+ return Unauthorized();
+
+ return await groups.DeleteGroup(name);
+ }
+
+}
\ No newline at end of file
diff --git a/src/HopFrame.Api/Controller/OpenIdController.cs b/src/HopFrame.Api/Controller/OpenIdController.cs
new file mode 100644
index 0000000..50e8822
--- /dev/null
+++ b/src/HopFrame.Api/Controller/OpenIdController.cs
@@ -0,0 +1,84 @@
+using HopFrame.Api.Models;
+using HopFrame.Security.Authentication.OpenID;
+using HopFrame.Security.Authentication.OpenID.Options;
+using HopFrame.Security.Claims;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Options;
+
+namespace HopFrame.Api.Controller;
+
+[ApiController, Route("api/v1/openid")]
+public class OpenIdController(IOpenIdAccessor accessor, IOptions options) : ControllerBase {
+ public const string DefaultCallback = "api/v1/openid/callback";
+
+ [HttpGet("redirect")]
+ public async Task RedirectToProvider([FromQuery] string redirectAfter, [FromQuery] int performRedirect = 1) {
+ var uri = await accessor.ConstructAuthUri(DefaultCallback, redirectAfter);
+
+ if (performRedirect == 1) {
+ return Redirect(uri);
+ }
+
+ return Ok(new SingleValueResult(uri));
+ }
+
+ [HttpGet("callback")]
+ public async Task Callback([FromQuery] string code, [FromQuery] string state) {
+ if (string.IsNullOrEmpty(code)) {
+ return BadRequest("Authorization code is missing");
+ }
+
+ var token = await accessor.RequestToken(code, DefaultCallback);
+
+ if (token is null) {
+ return Forbid("Authorization code is not valid");
+ }
+
+ Response.Cookies.Append(ITokenContext.AccessTokenType, token.AccessToken, new CookieOptions {
+ MaxAge = TimeSpan.FromSeconds(token.ExpiresIn),
+ HttpOnly = false,
+ Secure = true
+ });
+ Response.Cookies.Append(ITokenContext.RefreshTokenType, token.RefreshToken, new CookieOptions {
+ MaxAge = options.Value.RefreshToken.ConstructTimeSpan,
+ HttpOnly = false,
+ Secure = true
+ });
+
+ if (string.IsNullOrEmpty(state)) {
+ return Ok(new SingleValueResult(token.AccessToken));
+ }
+
+ return Redirect(state.Replace("{token}", token.AccessToken));
+ }
+
+ [HttpGet("refresh")]
+ public async Task Refresh() {
+ var refreshToken = Request.Cookies[ITokenContext.RefreshTokenType];
+
+ if (string.IsNullOrEmpty(refreshToken))
+ return BadRequest("Refresh token not provided");
+
+ var token = await accessor.RefreshAccessToken(refreshToken);
+
+ if (token is null)
+ return NotFound("Refresh token not valid");
+
+ Response.Cookies.Append(ITokenContext.AccessTokenType, token.AccessToken, new CookieOptions {
+ MaxAge = TimeSpan.FromSeconds(token.ExpiresIn),
+ HttpOnly = false,
+ Secure = true
+ });
+
+ return Ok(new SingleValueResult(token.AccessToken));
+ }
+
+ [HttpDelete("logout")]
+ public IActionResult Logout() {
+ Response.Cookies.Delete(ITokenContext.RefreshTokenType);
+ Response.Cookies.Delete(ITokenContext.AccessTokenType);
+ return Ok();
+ }
+
+}
\ No newline at end of file
diff --git a/src/HopFrame.Api/Controller/UserController.cs b/src/HopFrame.Api/Controller/UserController.cs
new file mode 100644
index 0000000..6c0dd1b
--- /dev/null
+++ b/src/HopFrame.Api/Controller/UserController.cs
@@ -0,0 +1,83 @@
+using HopFrame.Api.Logic;
+using HopFrame.Api.Models;
+using HopFrame.Database.Models;
+using HopFrame.Database.Repositories;
+using HopFrame.Security.Authorization;
+using HopFrame.Security.Claims;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Options;
+
+namespace HopFrame.Api.Controller;
+
+[ApiController, Route("api/v1/users")]
+public class UserController(IOptions permissions, IPermissionRepository perms, ITokenContext context, IUserLogic logic) : ControllerBase {
+
+ private async Task AuthorizeRequest(string permission) {
+ return await perms.HasPermission(context.AccessToken, permission);
+ }
+
+ [HttpGet, Authorized]
+ public async Task>> GetUsers() {
+ if (!await AuthorizeRequest(permissions.Value.Users.Read))
+ return Unauthorized();
+
+ return await logic.GetUsers();
+ }
+
+ [HttpGet("{userId}"), Authorized]
+ public async Task> GetUser(string userId) {
+ if (!await AuthorizeRequest(permissions.Value.Users.Read))
+ return Unauthorized();
+
+ return await logic.GetUser(userId);
+ }
+
+ [HttpGet("username/{username}"), Authorized]
+ public async Task> GetUserByUsername(string username) {
+ if (!await AuthorizeRequest(permissions.Value.Users.Read))
+ return Unauthorized();
+
+ return await logic.GetUserByUsername(username);
+ }
+
+ [HttpGet("email/{email}"), Authorized]
+ public async Task> GetUserByEmail(string email) {
+ if (!await AuthorizeRequest(permissions.Value.Users.Read))
+ return Unauthorized();
+
+ return await logic.GetUserByEmail(email);
+ }
+
+ [HttpPost, Authorized]
+ public async Task> CreateUser([FromBody] UserCreator user) {
+ if (!await AuthorizeRequest(permissions.Value.Users.Create))
+ return Unauthorized();
+
+ return await logic.CreateUser(user);
+ }
+
+ [HttpPut("{userId}"), Authorized]
+ public async Task> UpdateUser(string userId, [FromBody] User user) {
+ if (!await AuthorizeRequest(permissions.Value.Users.Update))
+ return Unauthorized();
+
+ return await logic.UpdateUser(userId, user);
+ }
+
+ [HttpDelete("{userId}"), Authorized]
+ public async Task DeleteUser(string userId) {
+ if (!await AuthorizeRequest(permissions.Value.Users.Delete))
+ return Unauthorized();
+
+ return await logic.DeleteUser(userId);
+ }
+
+ [HttpPut("{userId}/password"), Authorized]
+ public async Task ChangePassword(string userId, [FromBody] UserPasswordChange passwordChange) {
+ if (context.User.Id.ToString() != userId && !await AuthorizeRequest(permissions.Value.Users.Update))
+ return Unauthorized();
+
+ return await logic.UpdatePassword(userId, passwordChange.OldPassword, passwordChange.NewPassword);
+ }
+
+}
\ No newline at end of file
diff --git a/src/HopFrame.Api/Extensions/MvcExtensions.cs b/src/HopFrame.Api/Extensions/MvcExtensions.cs
index d176de7..4329015 100644
--- a/src/HopFrame.Api/Extensions/MvcExtensions.cs
+++ b/src/HopFrame.Api/Extensions/MvcExtensions.cs
@@ -83,4 +83,4 @@ public static class MvcExtensions {
return true;
}
}
-}
\ No newline at end of file
+}
diff --git a/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs b/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs
index 618a437..19436eb 100644
--- a/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs
+++ b/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs
@@ -4,6 +4,7 @@ using HopFrame.Api.Logic.Implementation;
using HopFrame.Database;
using HopFrame.Security.Authentication;
using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
@@ -15,23 +16,41 @@ public static class ServiceCollectionExtensions {
/// Adds all HopFrame endpoints and services to the application
///
/// The service provider to add the services to
+ /// The configuration used to configure HopFrame authentication
/// The data source for all HopFrame entities
- public static void AddHopFrame(this IServiceCollection services) where TDbContext : HopDbContextBase {
- services.AddMvcCore().UseSpecificControllers(typeof(SecurityController));
- AddHopFrameNoEndpoints(services);
+ public static void AddHopFrame(this IServiceCollection services, ConfigurationManager configuration) where TDbContext : HopDbContextBase {
+ var controllers = new List { typeof(UserController), typeof(GroupController) };
+
+ var defaultAuthenticationSection = configuration.GetSection("HopFrame:Authentication:DefaultAuthentication");
+ if (!defaultAuthenticationSection.Exists() || configuration.GetValue("HopFrame:Authentication:DefaultAuthentication"))
+ controllers.Add(typeof(AuthController));
+
+ if (configuration.GetValue("HopFrame:Authentication:OpenID:Enabled"))
+ controllers.Add(typeof(OpenIdController));
+
+ AddHopFrameNoEndpoints(services, configuration);
+ services.AddMvcCore().UseSpecificControllers(controllers.ToArray());
}
///
/// Adds all HopFrame services to the application
///
/// The service provider to add the services to
+ /// The configuration used to configure HopFrame authentication
/// The data source for all HopFrame entities
- public static void AddHopFrameNoEndpoints(this IServiceCollection services) where TDbContext : HopDbContextBase {
+ public static void AddHopFrameNoEndpoints(this IServiceCollection services, ConfigurationManager configuration) where TDbContext : HopDbContextBase {
+ services.AddMvcCore().ConfigureApplicationPartManager(manager => {
+ var endpoints = manager.ApplicationParts.SingleOrDefault(p => p.Name == typeof(ServiceCollectionExtensions).Namespace!.Replace(".Extensions", ""));
+ manager.ApplicationParts.Remove(endpoints);
+ });
+
services.AddHopFrameRepositories();
services.TryAddSingleton();
services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
- services.AddHopFrameAuthentication();
+ services.AddHopFrameAuthentication(configuration);
}
}
diff --git a/src/HopFrame.Api/HopFrame.Api.csproj b/src/HopFrame.Api/HopFrame.Api.csproj
index 744a466..091e5a1 100644
--- a/src/HopFrame.Api/HopFrame.Api.csproj
+++ b/src/HopFrame.Api/HopFrame.Api.csproj
@@ -22,4 +22,10 @@
+
+
+ <_Parameter1>HopFrame.Tests.Api
+
+
+
diff --git a/src/HopFrame.Api/Logic/IGroupLogic.cs b/src/HopFrame.Api/Logic/IGroupLogic.cs
new file mode 100644
index 0000000..48bdd45
--- /dev/null
+++ b/src/HopFrame.Api/Logic/IGroupLogic.cs
@@ -0,0 +1,14 @@
+using HopFrame.Database.Models;
+
+namespace HopFrame.Api.Logic;
+
+public interface IGroupLogic {
+ Task>> GetGroups();
+ Task>> GetDefaultGroups();
+ Task>> GetUserGroups(string userId);
+ Task> GetGroup(string name);
+
+ Task> CreateGroup(PermissionGroup group);
+ Task> UpdateGroup(PermissionGroup group);
+ Task DeleteGroup(string name);
+}
\ No newline at end of file
diff --git a/src/HopFrame.Api/Logic/ILogicResult.cs b/src/HopFrame.Api/Logic/ILogicResult.cs
index 5efb2aa..c3ff17b 100644
--- a/src/HopFrame.Api/Logic/ILogicResult.cs
+++ b/src/HopFrame.Api/Logic/ILogicResult.cs
@@ -1,4 +1,5 @@
using System.Net;
+using Microsoft.AspNetCore.Mvc;
namespace HopFrame.Api.Logic;
diff --git a/src/HopFrame.Api/Logic/IUserLogic.cs b/src/HopFrame.Api/Logic/IUserLogic.cs
new file mode 100644
index 0000000..42f44e9
--- /dev/null
+++ b/src/HopFrame.Api/Logic/IUserLogic.cs
@@ -0,0 +1,16 @@
+using HopFrame.Api.Models;
+using HopFrame.Database.Models;
+
+namespace HopFrame.Api.Logic;
+
+public interface IUserLogic {
+ Task>> GetUsers();
+ Task> GetUser(string id);
+ Task> GetUserByUsername(string username);
+ Task> GetUserByEmail(string email);
+
+ Task> CreateUser(UserCreator user);
+ Task> UpdateUser(string id, User user);
+ Task DeleteUser(string id);
+ Task UpdatePassword(string id, string oldPassword, string newPassword);
+}
\ No newline at end of file
diff --git a/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs b/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs
index e792add..444e084 100644
--- a/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs
+++ b/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs
@@ -5,12 +5,15 @@ using HopFrame.Security.Authentication;
using HopFrame.Security.Claims;
using HopFrame.Security.Models;
using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Options;
namespace HopFrame.Api.Logic.Implementation;
-public class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenContext tokenContext, IHttpContextAccessor accessor) : IAuthLogic {
+internal sealed class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenContext tokenContext, IHttpContextAccessor accessor, IOptions options) : IAuthLogic {
public async Task>> Login(UserLogin login) {
+ if (!options.Value.DefaultAuthentication) return LogicResult>.BadRequest("HopFrame authentication scheme is disabled");
+
var user = await users.GetUserByEmail(login.Email);
if (user is null)
@@ -22,23 +25,25 @@ public class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenCon
var refreshToken = await tokens.CreateToken(Token.RefreshTokenType, user);
var accessToken = await tokens.CreateToken(Token.AccessTokenType, user);
- accessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.Content.ToString(), new CookieOptions {
- MaxAge = HopFrameAuthentication.RefreshTokenTime,
+ accessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.TokenId.ToString(), new CookieOptions {
+ MaxAge = options.Value.RefreshTokenTime,
HttpOnly = true,
Secure = true
});
- accessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Content.ToString(), new CookieOptions {
- MaxAge = HopFrameAuthentication.AccessTokenTime,
+ accessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.TokenId.ToString(), new CookieOptions {
+ MaxAge = options.Value.AccessTokenTime,
HttpOnly = true,
Secure = true
});
- return LogicResult>.Ok(accessToken.Content.ToString());
+ return LogicResult>.Ok(accessToken.TokenId.ToString());
}
public async Task>> Register(UserRegister register) {
+ if (!options.Value.DefaultAuthentication) return LogicResult>.BadRequest("HopFrame authentication scheme is disabled");
+
if (register.Password.Length < 8)
- return LogicResult>.Conflict("Password needs to be at least 8 characters long");
+ return LogicResult>.BadRequest("Password needs to be at least 8 characters long");
var allUsers = await users.GetUsers();
if (allUsers.Any(user => user.Username == register.Username || user.Email == register.Email))
@@ -53,46 +58,48 @@ public class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenCon
var refreshToken = await tokens.CreateToken(Token.RefreshTokenType, user);
var accessToken = await tokens.CreateToken(Token.AccessTokenType, user);
- accessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.Content.ToString(), new CookieOptions {
- MaxAge = HopFrameAuthentication.RefreshTokenTime,
+ accessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.TokenId.ToString(), new CookieOptions {
+ MaxAge = options.Value.RefreshTokenTime,
HttpOnly = true,
Secure = true
});
- accessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Content.ToString(), new CookieOptions {
- MaxAge = HopFrameAuthentication.AccessTokenTime,
+ accessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.TokenId.ToString(), new CookieOptions {
+ MaxAge = options.Value.AccessTokenTime,
HttpOnly = false,
Secure = true
});
- return LogicResult>.Ok(accessToken.Content.ToString());
+ return LogicResult>.Ok(accessToken.TokenId.ToString());
}
public async Task>> Authenticate() {
+ if (!options.Value.DefaultAuthentication) return LogicResult>.BadRequest("HopFrame authentication scheme is disabled");
+
var refreshToken = accessor.HttpContext?.Request.Cookies[ITokenContext.RefreshTokenType];
if (string.IsNullOrEmpty(refreshToken))
- return LogicResult>.Conflict("Refresh token not provided");
+ return LogicResult>.BadRequest("Refresh token not provided");
var token = await tokens.GetToken(refreshToken);
- if (token.Type != Token.RefreshTokenType)
- return LogicResult>.BadRequest("The provided token is not a refresh token");
-
if (token is null)
return LogicResult>.NotFound("Refresh token not valid");
- if (token.CreatedAt + HopFrameAuthentication.RefreshTokenTime < DateTime.Now)
- return LogicResult>.Conflict("Refresh token is expired");
+ if (token.Type != Token.RefreshTokenType)
+ return LogicResult>.Conflict("The provided token is not a refresh token");
+
+ if (token.CreatedAt + options.Value.RefreshTokenTime < DateTime.Now)
+ return LogicResult>.Forbidden("Refresh token is expired");
var accessToken = await tokens.CreateToken(Token.AccessTokenType, token.Owner);
- accessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Content.ToString(), new CookieOptions {
- MaxAge = HopFrameAuthentication.AccessTokenTime,
+ accessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.TokenId.ToString(), new CookieOptions {
+ MaxAge = options.Value.AccessTokenTime,
HttpOnly = false,
Secure = true
});
- return LogicResult>.Ok(accessToken.Content.ToString());
+ return LogicResult>.Ok(accessToken.TokenId.ToString());
}
public async Task Logout() {
@@ -100,9 +107,7 @@ public class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenCon
var refreshToken = accessor.HttpContext?.Request.Cookies[ITokenContext.RefreshTokenType];
if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(refreshToken))
- return LogicResult.Conflict("access or refresh token not provided");
-
- await tokens.DeleteUserTokens(tokenContext.User);
+ await tokens.DeleteUserTokens(tokenContext.User);
accessor.HttpContext?.Response.Cookies.Delete(ITokenContext.RefreshTokenType);
accessor.HttpContext?.Response.Cookies.Delete(ITokenContext.AccessTokenType);
diff --git a/src/HopFrame.Api/Logic/Implementation/GroupLogic.cs b/src/HopFrame.Api/Logic/Implementation/GroupLogic.cs
new file mode 100644
index 0000000..dd3e5b7
--- /dev/null
+++ b/src/HopFrame.Api/Logic/Implementation/GroupLogic.cs
@@ -0,0 +1,66 @@
+using HopFrame.Database.Models;
+using HopFrame.Database.Repositories;
+
+namespace HopFrame.Api.Logic.Implementation;
+
+internal sealed class GroupLogic(IGroupRepository groups, IUserRepository users) : IGroupLogic {
+ public async Task>> GetGroups() {
+ return LogicResult>.Ok(await groups.GetPermissionGroups());
+ }
+
+ public async Task>> GetDefaultGroups() {
+ return LogicResult>.Ok(await groups.GetDefaultGroups());
+ }
+
+ public async Task>> GetUserGroups(string id) {
+ if (!Guid.TryParse(id, out var userId))
+ return LogicResult>.BadRequest("Invalid user id");
+
+ var user = await users.GetUser(userId);
+
+ if (user is null)
+ return LogicResult>.NotFound("That user does not exist");
+
+ return LogicResult>.Ok(await groups.GetUserGroups(user));
+ }
+
+ public async Task> GetGroup(string name) {
+ var group = await groups.GetPermissionGroup(name);
+
+ if (group is null)
+ return LogicResult.NotFound("That group does not exist");
+
+ return LogicResult.Ok(group);
+ }
+
+ public async Task> CreateGroup(PermissionGroup group) {
+ if (group is null)
+ return LogicResult.BadRequest("Provide a group");
+
+ if (!group.Name.StartsWith("group."))
+ return LogicResult.BadRequest("Group names must start with 'group.'");
+
+ if (await groups.GetPermissionGroup(group.Name) != null)
+ return LogicResult.Conflict("That group already exists");
+
+ return LogicResult.Ok(await groups.CreatePermissionGroup(group));
+ }
+
+ public async Task> UpdateGroup(PermissionGroup group) {
+ if (await groups.GetPermissionGroup(group.Name) == null)
+ return LogicResult.NotFound("That user does not exist");
+
+ await groups.EditPermissionGroup(group);
+ return LogicResult.Ok(group);
+ }
+
+ public async Task DeleteGroup(string name) {
+ var group = await groups.GetPermissionGroup(name);
+
+ if (group is null)
+ return LogicResult.NotFound("That group does not exist");
+
+ await groups.DeletePermissionGroup(group);
+ return LogicResult.Ok();
+ }
+}
\ No newline at end of file
diff --git a/src/HopFrame.Api/Logic/Implementation/UserLogic.cs b/src/HopFrame.Api/Logic/Implementation/UserLogic.cs
new file mode 100644
index 0000000..b9db19c
--- /dev/null
+++ b/src/HopFrame.Api/Logic/Implementation/UserLogic.cs
@@ -0,0 +1,105 @@
+using HopFrame.Api.Models;
+using HopFrame.Database.Models;
+using HopFrame.Database.Repositories;
+using HopFrame.Security.Claims;
+
+namespace HopFrame.Api.Logic.Implementation;
+
+internal sealed class UserLogic(IUserRepository users, ITokenContext context) : IUserLogic {
+ public async Task>> GetUsers() {
+ return LogicResult>.Ok(await users.GetUsers());
+ }
+
+ public async Task> GetUser(string id) {
+ if (!Guid.TryParse(id, out var userId))
+ return LogicResult.BadRequest("Invalid user id");
+
+ var user = await users.GetUser(userId);
+
+ if (user is null)
+ return LogicResult.NotFound("That user does not exist");
+
+ return LogicResult.Ok(user);
+ }
+
+ public async Task> GetUserByUsername(string username) {
+ var user = await users.GetUserByUsername(username);
+
+ if (user is null)
+ return LogicResult.NotFound("That user does not exist");
+
+ return LogicResult.Ok(user);
+ }
+
+ public async Task> GetUserByEmail(string email) {
+ var user = await users.GetUserByEmail(email);
+
+ if (user is null)
+ return LogicResult.NotFound("That user does not exist");
+
+ return LogicResult.Ok(user);
+ }
+
+ public async Task> CreateUser(UserCreator user) {
+ var createdUser = new User {
+ Email = user.Email,
+ Username = user.Username,
+ Password = user.Password,
+ };
+ createdUser.Permissions = user.Permissions?.Select(p => new Permission {
+ GrantedAt = DateTime.Now,
+ PermissionName = p,
+ User = createdUser
+ }).ToList();
+
+ var newUser = await users.AddUser(createdUser);
+
+ if (newUser is null)
+ return LogicResult.Conflict("That user already exists");
+
+ return LogicResult.Ok(newUser);
+ }
+
+ public async Task> UpdateUser(string id, User user) {
+ if (!Guid.TryParse(id, out var userId))
+ return LogicResult.BadRequest("Invalid user id");
+
+ if (user.Id != userId)
+ return LogicResult.Conflict("Cannot edit user with different user id");
+
+ if (await users.GetUser(userId) is null)
+ return LogicResult.NotFound("That user does not exist");
+
+ await users.UpdateUser(user);
+ return LogicResult.Ok(user);
+ }
+
+ public async Task DeleteUser(string id) {
+ if (!Guid.TryParse(id, out var userId))
+ return LogicResult.BadRequest("Invalid user id");
+
+ var user = await users.GetUser(userId);
+
+ if (user is null)
+ return LogicResult.NotFound("That user does not exist");
+
+ await users.DeleteUser(user);
+ return LogicResult.Ok();
+ }
+
+ public async Task UpdatePassword(string id, string oldPassword, string newPassword) {
+ if (!Guid.TryParse(id, out var userId))
+ return LogicResult.BadRequest("Invalid user id");
+
+ var user = await users.GetUser(userId);
+
+ if (user is null)
+ return LogicResult.NotFound("That user does not exist");
+
+ if (userId == context.User.Id && !await users.CheckUserPassword(user, oldPassword))
+ return LogicResult.Conflict("Old password is not correct");
+
+ await users.ChangePassword(user, newPassword);
+ return LogicResult.Ok();
+ }
+}
\ No newline at end of file
diff --git a/src/HopFrame.Api/Models/UserCreator.cs b/src/HopFrame.Api/Models/UserCreator.cs
new file mode 100644
index 0000000..9af93fb
--- /dev/null
+++ b/src/HopFrame.Api/Models/UserCreator.cs
@@ -0,0 +1,8 @@
+namespace HopFrame.Api.Models;
+
+public class UserCreator {
+ public string Username { get; set; }
+ public string Email { get; set; }
+ public string Password { get; set; }
+ public virtual List Permissions { get; set; }
+}
\ No newline at end of file
diff --git a/src/HopFrame.Api/Models/UserPasswordChange.cs b/src/HopFrame.Api/Models/UserPasswordChange.cs
new file mode 100644
index 0000000..e6e183b
--- /dev/null
+++ b/src/HopFrame.Api/Models/UserPasswordChange.cs
@@ -0,0 +1,6 @@
+namespace HopFrame.Api.Models;
+
+public class UserPasswordChange {
+ public string OldPassword { get; set; }
+ public string NewPassword { get; set; }
+}
\ No newline at end of file
diff --git a/src/HopFrame.Database/HopDbContextBase.cs b/src/HopFrame.Database/HopDbContextBase.cs
index 21342ea..cd03860 100644
--- a/src/HopFrame.Database/HopDbContextBase.cs
+++ b/src/HopFrame.Database/HopDbContextBase.cs
@@ -30,5 +30,10 @@ public abstract class HopDbContextBase : DbContext {
.HasMany(g => g.Permissions)
.WithOne(p => p.Group)
.OnDelete(DeleteBehavior.Cascade);
+
+ modelBuilder.Entity()
+ .HasMany(t => t.Permissions)
+ .WithOne(t => t.Token)
+ .OnDelete(DeleteBehavior.Cascade);
}
}
\ No newline at end of file
diff --git a/src/HopFrame.Database/HopFrame.Database.csproj b/src/HopFrame.Database/HopFrame.Database.csproj
index de55cd5..cd670af 100644
--- a/src/HopFrame.Database/HopFrame.Database.csproj
+++ b/src/HopFrame.Database/HopFrame.Database.csproj
@@ -22,4 +22,10 @@
+
+
+ <_Parameter1>HopFrame.Tests.Database
+
+
+
diff --git a/src/HopFrame.Database/Models/Permission.cs b/src/HopFrame.Database/Models/Permission.cs
index db111ba..658a90e 100644
--- a/src/HopFrame.Database/Models/Permission.cs
+++ b/src/HopFrame.Database/Models/Permission.cs
@@ -21,6 +21,9 @@ public class Permission {
[ForeignKey("GroupName"), JsonIgnore]
public virtual PermissionGroup Group { get; set; }
+ [ForeignKey("TokenId"), JsonIgnore]
+ public virtual Token Token { get; set; }
+
}
public interface IPermissionOwner;
diff --git a/src/HopFrame.Database/Models/Token.cs b/src/HopFrame.Database/Models/Token.cs
index a42d367..f091123 100644
--- a/src/HopFrame.Database/Models/Token.cs
+++ b/src/HopFrame.Database/Models/Token.cs
@@ -4,24 +4,33 @@ using System.Text.Json.Serialization;
namespace HopFrame.Database.Models;
-public class Token {
+public class Token : IPermissionOwner {
public const int RefreshTokenType = 0;
public const int AccessTokenType = 1;
+ public const int ApiTokenType = 2;
+ public const int OpenIdTokenType = 3;
///
/// Defines the Type of the stored Token
/// 0: Refresh token
/// 1: Access token
+ /// 2: Api token
///
[Required, MinLength(1), MaxLength(1)]
public int Type { get; set; }
[Key, Required, MinLength(36), MaxLength(36)]
- public Guid Content { get; set; }
+ public Guid TokenId { get; set; }
+ ///
+ /// Defines the creation date of the token
+ /// In case of an api token it defines the date it becomes invalid
+ ///
[Required]
public DateTime CreatedAt { get; set; }
[ForeignKey("UserId"), JsonIgnore]
public virtual User Owner { get; set; }
+
+ public virtual List Permissions { get; set; }
}
\ No newline at end of file
diff --git a/src/HopFrame.Database/Models/User.cs b/src/HopFrame.Database/Models/User.cs
index feec39c..e540198 100644
--- a/src/HopFrame.Database/Models/User.cs
+++ b/src/HopFrame.Database/Models/User.cs
@@ -5,7 +5,7 @@ namespace HopFrame.Database.Models;
public class User : IPermissionOwner {
- [Key, Required, MinLength(36), MaxLength(36)]
+ [Key, Required]
public Guid Id { get; init; }
[Required, MaxLength(50)]
@@ -14,7 +14,7 @@ public class User : IPermissionOwner {
[Required, MaxLength(50), EmailAddress]
public string Email { get; set; }
- [Required, MinLength(8), MaxLength(255), JsonIgnore]
+ [MinLength(8), MaxLength(255), JsonIgnore]
public string Password { get; set; }
[Required]
diff --git a/src/HopFrame.Database/Repositories/ITokenRepository.cs b/src/HopFrame.Database/Repositories/ITokenRepository.cs
index bec3963..9447994 100644
--- a/src/HopFrame.Database/Repositories/ITokenRepository.cs
+++ b/src/HopFrame.Database/Repositories/ITokenRepository.cs
@@ -5,5 +5,7 @@ namespace HopFrame.Database.Repositories;
public interface ITokenRepository {
Task GetToken(string content);
Task CreateToken(int type, User owner);
- Task DeleteUserTokens(User owner);
+ Task DeleteUserTokens(User owner, bool includeApiTokens = false);
+ Task DeleteToken(Token token);
+ Task CreateApiToken(User owner, DateTime expirationDate);
}
\ No newline at end of file
diff --git a/src/HopFrame.Database/Repositories/Implementation/GroupRepository.cs b/src/HopFrame.Database/Repositories/Implementation/GroupRepository.cs
index 547e193..b190ce6 100644
--- a/src/HopFrame.Database/Repositories/Implementation/GroupRepository.cs
+++ b/src/HopFrame.Database/Repositories/Implementation/GroupRepository.cs
@@ -33,19 +33,38 @@ internal sealed class GroupRepository(TDbContext context) : IGroupRe
}
public async Task EditPermissionGroup(PermissionGroup group) {
- var orig = await context.Groups.SingleOrDefaultAsync(g => g.Name == group.Name);
-
+ var orig = await context.Groups
+ .Include(g => g.Permissions) // Include related entities
+ .SingleOrDefaultAsync(g => g.Name == group.Name);
+
if (orig is null) return;
- var entity = context.Groups.Update(orig);
+ // Update the main entity's properties
+ orig.IsDefaultGroup = group.IsDefaultGroup;
+ orig.Description = group.Description;
- entity.Entity.IsDefaultGroup = group.IsDefaultGroup;
- entity.Entity.Description = group.Description;
- entity.Entity.Permissions = group.Permissions;
+ // Update the permissions
+ foreach (var permission in group.Permissions) {
+ var existingPermission = orig.Permissions.FirstOrDefault(p => p.Id == permission.Id);
+ if (existingPermission != null) {
+ // Update existing permission
+ context.Entry(existingPermission).CurrentValues.SetValues(permission);
+ } else {
+ // Add new permission
+ orig.Permissions.Add(permission);
+ }
+ }
+
+ // Remove deleted permissions
+ foreach (var permission in orig.Permissions.ToList().Where(permission => group.Permissions.All(p => p.Id != permission.Id))) {
+ orig.Permissions.Remove(permission);
+ context.Permissions.Remove(permission); // Ensure it gets removed from the database
+ }
await context.SaveChangesAsync();
}
+
public async Task CreatePermissionGroup(PermissionGroup group) {
group.CreatedAt = DateTime.Now;
await context.Groups.AddAsync(group);
diff --git a/src/HopFrame.Database/Repositories/Implementation/PermissionRepository.cs b/src/HopFrame.Database/Repositories/Implementation/PermissionRepository.cs
index 45bcfd8..3156361 100644
--- a/src/HopFrame.Database/Repositories/Implementation/PermissionRepository.cs
+++ b/src/HopFrame.Database/Repositories/Implementation/PermissionRepository.cs
@@ -5,6 +5,10 @@ namespace HopFrame.Database.Repositories.Implementation;
internal sealed class PermissionRepository(TDbContext context, IGroupRepository groupRepository) : IPermissionRepository where TDbContext : HopDbContextBase {
public async Task HasPermission(IPermissionOwner owner, params string[] permissions) {
+ if (owner is Token { Type: Token.ApiTokenType } token) {
+ if (!await HasPermission(token.Owner, permissions)) return false;
+ }
+
var perms = (await GetFullPermissions(owner)).ToArray();
foreach (var permission in permissions) {
@@ -24,6 +28,12 @@ internal sealed class PermissionRepository(TDbContext context, IGrou
entry.User = user;
}else if (owner is PermissionGroup group) {
entry.Group = group;
+ }else if (owner is Token token) {
+ if (token.Type != Token.ApiTokenType)
+ throw new ArgumentException("Only API tokens can have permissions!");
+ 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);
@@ -48,6 +58,13 @@ internal sealed class PermissionRepository(TDbContext context, IGrou
.Where(p =>p.Group.Name == group.Name)
.Where(p => p.PermissionName == permission)
.SingleOrDefaultAsync();
+ }else if (owner is Token token) {
+ entry = await context.Permissions
+ .Include(p => p.Token)
+ .Where(p => p.Token != null)
+ .Where(p => p.Token.TokenId == token.TokenId)
+ .Where(p => p.PermissionName == permission)
+ .SingleOrDefaultAsync();
}
if (entry is not null) {
@@ -58,6 +75,10 @@ internal sealed class PermissionRepository(TDbContext context, IGrou
public async Task> GetFullPermissions(IPermissionOwner owner) {
var permissions = new List();
+
+ if (owner is Token token && token.Type != Token.ApiTokenType) {
+ owner = token.Owner;
+ }
if (owner is User user) {
var perms = await context.Permissions
@@ -74,6 +95,14 @@ internal sealed class PermissionRepository(TDbContext context, IGrou
.Where(p =>p.Group.Name == group.Name)
.ToListAsync();
+ permissions.AddRange(perms.Select(p => p.PermissionName));
+ }else if (owner is Token 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));
}
diff --git a/src/HopFrame.Database/Repositories/Implementation/TokenRepository.cs b/src/HopFrame.Database/Repositories/Implementation/TokenRepository.cs
index 70f727a..b02d2fb 100644
--- a/src/HopFrame.Database/Repositories/Implementation/TokenRepository.cs
+++ b/src/HopFrame.Database/Repositories/Implementation/TokenRepository.cs
@@ -1,5 +1,6 @@
using HopFrame.Database.Models;
using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Options;
namespace HopFrame.Database.Repositories.Implementation;
@@ -11,14 +12,14 @@ internal sealed class TokenRepository(TDbContext context) : ITokenRe
return await context.Tokens
.Include(t => t.Owner)
- .Where(t => t.Content == guid)
+ .Where(t => t.TokenId == guid)
.SingleOrDefaultAsync();
}
public async Task CreateToken(int type, User owner) {
var token = new Token {
CreatedAt = DateTime.Now,
- Content = Guid.NewGuid(),
+ TokenId = Guid.NewGuid(),
Type = type,
Owner = owner
};
@@ -29,13 +30,37 @@ internal sealed class TokenRepository(TDbContext context) : ITokenRe
return token;
}
- public async Task DeleteUserTokens(User owner) {
+ public async Task DeleteUserTokens(User owner, bool includeApiTokens = false) {
var tokens = await context.Tokens
.Include(t => t.Owner)
.Where(t => t.Owner.Id == owner.Id)
.ToListAsync();
+
+ if (!includeApiTokens)
+ tokens = tokens
+ .Where(t => t.Type != Token.ApiTokenType)
+ .ToList();
context.Tokens.RemoveRange(tokens);
await context.SaveChangesAsync();
}
+
+ public async Task DeleteToken(Token token) {
+ context.Tokens.Remove(token);
+ await context.SaveChangesAsync();
+ }
+
+ public async Task CreateApiToken(User owner, DateTime expirationDate) {
+ var token = new Token {
+ CreatedAt = expirationDate,
+ TokenId = Guid.NewGuid(),
+ Type = Token.ApiTokenType,
+ Owner = owner
+ };
+
+ await context.Tokens.AddAsync(token);
+ await context.SaveChangesAsync();
+
+ return token;
+ }
}
\ No newline at end of file
diff --git a/src/HopFrame.Database/Repositories/Implementation/UserRepository.cs b/src/HopFrame.Database/Repositories/Implementation/UserRepository.cs
index c642466..3e4e1b8 100644
--- a/src/HopFrame.Database/Repositories/Implementation/UserRepository.cs
+++ b/src/HopFrame.Database/Repositories/Implementation/UserRepository.cs
@@ -69,10 +69,45 @@ internal sealed class UserRepository(TDbContext context, IGroupRepos
.SingleOrDefaultAsync(entry => entry.Id == user.Id);
if (entry is null) return;
+ // Update the main entity's properties
entry.Email = user.Email;
entry.Username = user.Username;
- entry.Permissions = user.Permissions;
- entry.Tokens = user.Tokens;
+
+ // Update Permissions
+ foreach (var permission in user.Permissions) {
+ var existingPermission = entry.Permissions.FirstOrDefault(p => p.Id == permission.Id);
+ if (existingPermission != null) {
+ // Update existing permission
+ context.Entry(existingPermission).CurrentValues.SetValues(permission);
+ } else {
+ // Add new permission
+ entry.Permissions.Add(permission);
+ }
+ }
+
+ // Remove deleted permissions
+ foreach (var permission in entry.Permissions.ToList().Where(permission => user.Permissions.All(p => p.Id != permission.Id))) {
+ entry.Permissions.Remove(permission);
+ context.Permissions.Remove(permission); // Ensure it gets removed from the database
+ }
+
+ // Update Tokens
+ foreach (var token in user.Tokens) {
+ var existingToken = entry.Tokens.FirstOrDefault(t => t.TokenId == token.TokenId);
+ if (existingToken != null) {
+ // Update existing token
+ context.Entry(existingToken).CurrentValues.SetValues(token);
+ } else {
+ // Add new token
+ entry.Tokens.Add(token);
+ }
+ }
+
+ // Remove deleted tokens
+ foreach (var token in entry.Tokens.ToList().Where(token => user.Tokens.All(t => t.TokenId != token.TokenId))) {
+ entry.Tokens.Remove(token);
+ context.Tokens.Remove(token); // Ensure it gets removed from the database
+ }
await context.SaveChangesAsync();
}
diff --git a/src/HopFrame.Security/AdminPermissions.cs b/src/HopFrame.Security/AdminPermissions.cs
deleted file mode 100644
index 7f45afc..0000000
--- a/src/HopFrame.Security/AdminPermissions.cs
+++ /dev/null
@@ -1,15 +0,0 @@
-namespace HopFrame.Security;
-
-public static class AdminPermissions {
- public const string IsAdmin = "hopframe.admin";
-
- public const string ViewUsers = "hopframe.admin.users.view";
- public const string EditUser = "hopframe.admin.users.edit";
- public const string DeleteUser = "hopframe.admin.users.delete";
- public const string AddUser = "hopframe.admin.users.add";
-
- public const string ViewGroups = "hopframe.admin.groups.view";
- public const string EditGroup = "hopframe.admin.groups.edit";
- public const string DeleteGroup = "hopframe.admin.groups.delete";
- public const string AddGroup = "hopframe.admin.groups.add";
-}
\ No newline at end of file
diff --git a/src/HopFrame.Security/Authentication/HopFrameAuthentication.cs b/src/HopFrame.Security/Authentication/HopFrameAuthentication.cs
index 9c65b14..8fb578f 100644
--- a/src/HopFrame.Security/Authentication/HopFrameAuthentication.cs
+++ b/src/HopFrame.Security/Authentication/HopFrameAuthentication.cs
@@ -1,6 +1,9 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
+using HopFrame.Database.Models;
using HopFrame.Database.Repositories;
+using HopFrame.Security.Authentication.OpenID;
+using HopFrame.Security.Authentication.OpenID.Options;
using HopFrame.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging;
@@ -17,39 +20,83 @@ public class HopFrameAuthentication(
UrlEncoder encoder,
ISystemClock clock,
ITokenRepository tokens,
+ IPermissionRepository perms,
+ IOptions tokenOptions,
+ IOptions openIdOptions,
IUserRepository users,
- IPermissionRepository perms)
+ IOpenIdAccessor accessor)
: AuthenticationHandler(options, logger, encoder, clock) {
- public const string SchemeName = "HopCore.Authentication";
- public static readonly TimeSpan AccessTokenTime = new(0, 0, 5, 0);
- public static readonly TimeSpan RefreshTokenTime = new(30, 0, 0, 0);
+ public const string SchemeName = "HopFrame.Authentication";
protected override async Task HandleAuthenticateAsync() {
var accessToken = Request.Cookies[ITokenContext.AccessTokenType];
if (string.IsNullOrEmpty(accessToken)) accessToken = Request.Headers[SchemeName];
if (string.IsNullOrEmpty(accessToken)) accessToken = Request.Headers["Token"];
+ if (string.IsNullOrEmpty(accessToken)) accessToken = Request.Query["token"];
if (string.IsNullOrEmpty(accessToken)) return AuthenticateResult.Fail("No Access Token provided");
-
+
var tokenEntry = await tokens.GetToken(accessToken);
+
+ if (tokenEntry?.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.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 (tokenEntry.Owner is null)
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 GenerateClaims(Token token, IPermissionRepository perms) {
var claims = new List {
- new(HopFrameClaimTypes.AccessTokenId, accessToken),
- new(HopFrameClaimTypes.UserId, tokenEntry.Owner.Id.ToString())
+ new(HopFrameClaimTypes.AccessTokenId, token.TokenId.ToString()),
+ new(HopFrameClaimTypes.UserId, token.Owner.Id.ToString())
};
- var permissions = await perms.GetFullPermissions(tokenEntry.Owner);
+ var permissions = await perms.GetFullPermissions(token);
claims.AddRange(permissions.Select(perm => new Claim(HopFrameClaimTypes.Permission, perm)));
var principal = new ClaimsPrincipal();
principal.AddIdentity(new ClaimsIdentity(claims, SchemeName));
- return AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name));
+ return principal;
}
}
\ No newline at end of file
diff --git a/src/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs b/src/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs
index cf87810..a6bf52c 100644
--- a/src/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs
+++ b/src/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs
@@ -1,23 +1,36 @@
+using HopFrame.Security.Authentication.OpenID;
+using HopFrame.Security.Authentication.OpenID.Implementation;
+using HopFrame.Security.Authentication.OpenID.Options;
+using HopFrame.Security.Authorization;
using HopFrame.Security.Claims;
+using HopFrame.Security.Options;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace HopFrame.Security.Authentication;
public static class HopFrameAuthenticationExtensions {
-
///
/// Configures the WebApplication to use the authentication and authorization of the HopFrame API
///
/// The service provider to add the services to
- /// The database object that saves all entities that are important for the security api
+ /// The configuration used to configure HopFrame authentication
///
- public static IServiceCollection AddHopFrameAuthentication(this IServiceCollection service) {
+ public static IServiceCollection AddHopFrameAuthentication(this IServiceCollection service, ConfigurationManager configuration) {
service.TryAddSingleton();
service.AddScoped();
+ service.AddHttpClient();
+ service.AddMemoryCache();
+ service.AddScoped();
+
+ service.AddOptionsFromConfiguration(configuration);
+ service.AddOptionsFromConfiguration(configuration);
+ service.AddOptionsFromConfiguration(configuration);
+
service.AddAuthentication(HopFrameAuthentication.SchemeName).AddScheme(HopFrameAuthentication.SchemeName, _ => {});
service.AddAuthorization();
diff --git a/src/HopFrame.Security/Authentication/HopFrameAuthenticationOptions.cs b/src/HopFrame.Security/Authentication/HopFrameAuthenticationOptions.cs
new file mode 100644
index 0000000..8a7285f
--- /dev/null
+++ b/src/HopFrame.Security/Authentication/HopFrameAuthenticationOptions.cs
@@ -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);
+ }
+}
\ No newline at end of file
diff --git a/src/HopFrame.Security/Authentication/OpenID/IOpenIdAccessor.cs b/src/HopFrame.Security/Authentication/OpenID/IOpenIdAccessor.cs
new file mode 100644
index 0000000..09dc54c
--- /dev/null
+++ b/src/HopFrame.Security/Authentication/OpenID/IOpenIdAccessor.cs
@@ -0,0 +1,11 @@
+using HopFrame.Security.Authentication.OpenID.Models;
+
+namespace HopFrame.Security.Authentication.OpenID;
+
+public interface IOpenIdAccessor {
+ Task LoadConfiguration();
+ Task RequestToken(string code, string defaultCallback);
+ Task ConstructAuthUri(string defaultCallback, string state = null);
+ Task InspectToken(string token);
+ Task RefreshAccessToken(string refreshToken);
+}
\ No newline at end of file
diff --git a/src/HopFrame.Security/Authentication/OpenID/Implementation/OpenIdAccessor.cs b/src/HopFrame.Security/Authentication/OpenID/Implementation/OpenIdAccessor.cs
new file mode 100644
index 0000000..3dd1a82
--- /dev/null
+++ b/src/HopFrame.Security/Authentication/OpenID/Implementation/OpenIdAccessor.cs
@@ -0,0 +1,123 @@
+using System.Text.Json;
+using HopFrame.Security.Authentication.OpenID.Models;
+using HopFrame.Security.Authentication.OpenID.Options;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Options;
+
+namespace HopFrame.Security.Authentication.OpenID.Implementation;
+
+internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions options, 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 LoadConfiguration() {
+ if (options.Value.Cache.Enabled && options.Value.Cache.Configuration.Enabled && cache.TryGetValue(ConfigurationCacheKey, out object cachedConfiguration)) {
+ return cachedConfiguration as OpenIdConfiguration;
+ }
+
+ var client = clientFactory.CreateClient();
+ var request = new HttpRequestMessage(HttpMethod.Get, Path.Combine(options.Value.Issuer, ".well-known/openid-configuration"));
+ var response = await client.SendAsync(request);
+
+ if (!response.IsSuccessStatusCode)
+ return null;
+
+ var config = await JsonSerializer.DeserializeAsync(await response.Content.ReadAsStreamAsync());
+
+ if (options.Value.Cache.Enabled && options.Value.Cache.Configuration.Enabled)
+ cache.Set(ConfigurationCacheKey, config, options.Value.Cache.Configuration.TTL.ConstructTimeSpan);
+
+ return config;
+ }
+
+ public async Task RequestToken(string code, string defaultCallback) {
+ if (options.Value.Cache.Enabled && options.Value.Cache.Auth.Enabled && cache.TryGetValue(AuthCodeCacheKey + code, out object cachedToken)) {
+ return cachedToken as OpenIdToken;
+ }
+
+ var protocol = accessor.HttpContext!.Request.IsHttps ? "https" : "http";
+ var callback = options.Value.Callback ?? $"{protocol}://{accessor.HttpContext!.Request.Host.Value}/{defaultCallback}";
+
+ var configuration = await LoadConfiguration();
+
+ var client = clientFactory.CreateClient();
+ var request = new HttpRequestMessage(HttpMethod.Post, configuration.TokenEndpoint) {
+ Content = new FormUrlEncodedContent(new Dictionary {
+ { "grant_type", "authorization_code" },
+ { "code", code },
+ { "redirect_uri", 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(await response.Content.ReadAsStreamAsync());
+
+ if (options.Value.Cache.Enabled && options.Value.Cache.Auth.Enabled)
+ cache.Set(AuthCodeCacheKey + code, token, options.Value.Cache.Auth.TTL.ConstructTimeSpan);
+
+ return token;
+ }
+
+ public async Task ConstructAuthUri(string defaultCallback, string state = null) {
+ var protocol = accessor.HttpContext!.Request.IsHttps ? "https" : "http";
+ var callback = options.Value.Callback ?? $"{protocol}://{accessor.HttpContext!.Request.Host.Value}/{defaultCallback}";
+
+ var configuration = await LoadConfiguration();
+ return $"{configuration.AuthorizationEndpoint}?response_type=code&client_id={options.Value.ClientId}&redirect_uri={callback}&scope=openid%20profile%20email%20offline_access&state={state}";
+ }
+
+ public async Task InspectToken(string token) {
+ if (options.Value.Cache.Enabled && options.Value.Cache.Inspection.Enabled && cache.TryGetValue(TokenCacheKey + token, out object cachedToken)) {
+ return cachedToken as OpenIdIntrospection;
+ }
+
+ var configuration = await LoadConfiguration();
+
+ var client = clientFactory.CreateClient();
+ var request = new HttpRequestMessage(HttpMethod.Post, configuration.IntrospectionEndpoint) {
+ Content = new FormUrlEncodedContent(new Dictionary {
+ { "token", token },
+ { "client_id", options.Value.ClientId },
+ { "client_secret", options.Value.ClientSecret }
+ })
+ };
+ var response = await client.SendAsync(request);
+
+ if (!response.IsSuccessStatusCode)
+ return null;
+
+ var introspection = await JsonSerializer.DeserializeAsync(await response.Content.ReadAsStreamAsync());
+
+ if (options.Value.Cache.Enabled && options.Value.Cache.Inspection.Enabled)
+ cache.Set(TokenCacheKey + token, introspection, options.Value.Cache.Inspection.TTL.ConstructTimeSpan);
+
+ return introspection;
+ }
+
+ public async Task RefreshAccessToken(string refreshToken) {
+ var configuration = await LoadConfiguration();
+
+ var client = clientFactory.CreateClient();
+ var request = new HttpRequestMessage(HttpMethod.Post, configuration.TokenEndpoint) {
+ Content = new FormUrlEncodedContent(new Dictionary {
+ { "grant_type", "refresh_token" },
+ { "refresh_token", refreshToken },
+ { "client_id", options.Value.ClientId },
+ { "client_secret", options.Value.ClientSecret }
+ })
+ };
+ var response = await client.SendAsync(request);
+
+ if (!response.IsSuccessStatusCode)
+ return null;
+
+ return await JsonSerializer.DeserializeAsync(await response.Content.ReadAsStreamAsync());
+ }
+}
\ No newline at end of file
diff --git a/src/HopFrame.Security/Authentication/OpenID/Models/OpenIdConfiguration.cs b/src/HopFrame.Security/Authentication/OpenID/Models/OpenIdConfiguration.cs
new file mode 100644
index 0000000..60c1df2
--- /dev/null
+++ b/src/HopFrame.Security/Authentication/OpenID/Models/OpenIdConfiguration.cs
@@ -0,0 +1,68 @@
+using System.Text.Json.Serialization;
+
+namespace HopFrame.Security.Authentication.OpenID.Models;
+
+public sealed class OpenIdConfiguration {
+ [JsonPropertyName("issuer")]
+ public string Issuer { get; set; }
+
+ [JsonPropertyName("authorization_endpoint")]
+ public string AuthorizationEndpoint { get; set; }
+
+ [JsonPropertyName("token_endpoint")]
+ public string TokenEndpoint { get; set; }
+
+ [JsonPropertyName("userinfo_endpoint")]
+ public string UserinfoEndpoint { get; set; }
+
+ [JsonPropertyName("end_session_endpoint")]
+ public string EndSessionEndpoint { get; set; }
+
+ [JsonPropertyName("introspection_endpoint")]
+ public string IntrospectionEndpoint { get; set; }
+
+ [JsonPropertyName("revocation_endpoint")]
+ public string RevocationEndpoint { get; set; }
+
+ [JsonPropertyName("device_authorization_endpoint")]
+ public string DeviceAuthorizationEndpoint { get; set; }
+
+ [JsonPropertyName("response_types_supported")]
+ public List ResponseTypesSupported { get; set; }
+
+ [JsonPropertyName("response_modes_supported")]
+ public List ResponseModesSupported { get; set; }
+
+ [JsonPropertyName("jwks_uri")]
+ public string JwksUri { get; set; }
+
+ [JsonPropertyName("grant_types_supported")]
+ public List GrantTypesSupported { get; set; }
+
+ [JsonPropertyName("id_token_signing_alg_values_supported")]
+ public List IdTokenSigningAlgValuesSupported { get; set; }
+
+ [JsonPropertyName("subject_types_supported")]
+ public List SubjectTypesSupported { get; set; }
+
+ [JsonPropertyName("token_endpoint_auth_methods_supported")]
+ public List TokenEndpointAuthMethodsSupported { get; set; }
+
+ [JsonPropertyName("acr_values_supported")]
+ public List AcrValuesSupported { get; set; }
+
+ [JsonPropertyName("scopes_supported")]
+ public List ScopesSupported { get; set; }
+
+ [JsonPropertyName("request_parameter_supported")]
+ public bool RequestParameterSupported { get; set; }
+
+ [JsonPropertyName("claims_supported")]
+ public List ClaimsSupported { get; set; }
+
+ [JsonPropertyName("claims_parameter_supported")]
+ public bool ClaimsParameterSupported { get; set; }
+
+ [JsonPropertyName("code_challenge_methods_supported")]
+ public List CodeChallengeMethodsSupported { get; set; }
+}
diff --git a/src/HopFrame.Security/Authentication/OpenID/Models/OpenIdIntrospection.cs b/src/HopFrame.Security/Authentication/OpenID/Models/OpenIdIntrospection.cs
new file mode 100644
index 0000000..a19b603
--- /dev/null
+++ b/src/HopFrame.Security/Authentication/OpenID/Models/OpenIdIntrospection.cs
@@ -0,0 +1,62 @@
+using System.Text.Json.Serialization;
+
+namespace HopFrame.Security.Authentication.OpenID.Models;
+
+public sealed class OpenIdIntrospection {
+ [JsonPropertyName("iss")]
+ public string Issuer { get; set; }
+
+ [JsonPropertyName("sub")]
+ public string Subject { get; set; }
+
+ [JsonPropertyName("aud")]
+ public string Audience { get; set; }
+
+ [JsonPropertyName("exp")]
+ public long Expiration { get; set; }
+
+ [JsonPropertyName("iat")]
+ public long IssuedAt { get; set; }
+
+ [JsonPropertyName("auth_time")]
+ public long AuthTime { get; set; }
+
+ [JsonPropertyName("acr")]
+ public string Acr { get; set; }
+
+ [JsonPropertyName("amr")]
+ public List AuthenticationMethods { get; set; }
+
+ [JsonPropertyName("sid")]
+ public string SessionId { get; set; }
+
+ [JsonPropertyName("email")]
+ public string Email { get; set; }
+
+ [JsonPropertyName("email_verified")]
+ public bool EmailVerified { get; set; }
+
+ [JsonPropertyName("name")]
+ public string Name { get; set; }
+
+ [JsonPropertyName("given_name")]
+ public string GivenName { get; set; }
+
+ [JsonPropertyName("preferred_username")]
+ public string PreferredUsername { get; set; }
+
+ [JsonPropertyName("nickname")]
+ public string Nickname { get; set; }
+
+ [JsonPropertyName("groups")]
+ public List Groups { get; set; }
+
+ [JsonPropertyName("active")]
+ public bool Active { get; set; }
+
+ [JsonPropertyName("scope")]
+ public string Scope { get; set; }
+
+ [JsonPropertyName("client_id")]
+ public string ClientId { get; set; }
+}
\ No newline at end of file
diff --git a/src/HopFrame.Security/Authentication/OpenID/Models/OpenIdToken.cs b/src/HopFrame.Security/Authentication/OpenID/Models/OpenIdToken.cs
new file mode 100644
index 0000000..6303bda
--- /dev/null
+++ b/src/HopFrame.Security/Authentication/OpenID/Models/OpenIdToken.cs
@@ -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; }
+}
\ No newline at end of file
diff --git a/src/HopFrame.Security/Authentication/OpenID/Options/OpenIdOptions.cs b/src/HopFrame.Security/Authentication/OpenID/Options/OpenIdOptions.cs
new file mode 100644
index 0000000..49a219c
--- /dev/null
+++ b/src/HopFrame.Security/Authentication/OpenID/Options/OpenIdOptions.cs
@@ -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() {
+ Minutes = 10
+ }
+ },
+ Auth = new() {
+ Enabled = true,
+ TTL = new() {
+ Seconds = 30
+ }
+ },
+ Inspection = new() {
+ Enabled = true,
+ TTL = new() {
+ Minutes = 2
+ }
+ }
+ };
+
+ public class CachingTypeOptions {
+ public bool Enabled { get; set; }
+ public HopFrameAuthenticationOptions.TokenTime TTL { get; set; }
+ }
+
+ public class CachingOptions {
+ public bool Enabled { get; set; }
+ public CachingTypeOptions Configuration { get; set; }
+ public CachingTypeOptions Auth { get; set; }
+ public CachingTypeOptions Inspection { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/src/HopFrame.Security/Authorization/AdminPermissionOptions.cs b/src/HopFrame.Security/Authorization/AdminPermissionOptions.cs
new file mode 100644
index 0000000..46fc7f0
--- /dev/null
+++ b/src/HopFrame.Security/Authorization/AdminPermissionOptions.cs
@@ -0,0 +1,30 @@
+using HopFrame.Security.Options;
+
+namespace HopFrame.Security.Authorization;
+
+public class AdminPermissionOptions : OptionsFromConfiguration {
+ public override string Position { get; } = "HopFrame:Permissions";
+
+ public string Dashboard { get; set; } = "hopframe.admin";
+
+ public CrudPermission Users { get; set; } = new() {
+ Read = "hopframe.admin.users.read",
+ Update = "hopframe.admin.users.update",
+ Delete = "hopframe.admin.users.delete",
+ Create = "hopframe.admin.users.create"
+ };
+
+ public CrudPermission Groups { get; set; } = new() {
+ Read = "hopframe.admin.groups.read",
+ Update = "hopframe.admin.groups.update",
+ Delete = "hopframe.admin.groups.delete",
+ Create = "hopframe.admin.groups.create"
+ };
+
+ public class CrudPermission {
+ public string Create { get; set; }
+ public string Read { get; set; }
+ public string Update { get; set; }
+ public string Delete { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/src/HopFrame.Security/Claims/TokenContextImplementor.cs b/src/HopFrame.Security/Claims/TokenContextImplementor.cs
index dd50a08..c464f23 100644
--- a/src/HopFrame.Security/Claims/TokenContextImplementor.cs
+++ b/src/HopFrame.Security/Claims/TokenContextImplementor.cs
@@ -1,13 +1,19 @@
using HopFrame.Database.Models;
using HopFrame.Database.Repositories;
+using HopFrame.Security.Authentication.OpenID.Options;
using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Options;
namespace HopFrame.Security.Claims;
-internal sealed class TokenContextImplementor(IHttpContextAccessor accessor, IUserRepository users, ITokenRepository tokens) : ITokenContext {
+internal sealed class TokenContextImplementor(IHttpContextAccessor accessor, IUserRepository users, ITokenRepository tokens, IOptions options) : ITokenContext {
public bool IsAuthenticated => !string.IsNullOrEmpty(accessor.HttpContext?.User.GetAccessTokenId());
public User User => users.GetUser(Guid.Parse(accessor.HttpContext?.User.GetUserId() ?? Guid.Empty.ToString())).GetAwaiter().GetResult();
- public Token AccessToken => tokens.GetToken(accessor.HttpContext?.User.GetAccessTokenId()).GetAwaiter().GetResult();
+ public Token AccessToken => options.Value.Enabled ? new Token {
+ Owner = User,
+ Type = Token.OpenIdTokenType,
+ CreatedAt = DateTime.Now
+ } : tokens.GetToken(accessor.HttpContext?.User.GetAccessTokenId()).GetAwaiter().GetResult();
}
\ No newline at end of file
diff --git a/src/HopFrame.Security/Options/OptionsFromConfiguration.cs b/src/HopFrame.Security/Options/OptionsFromConfiguration.cs
new file mode 100644
index 0000000..0f06fb8
--- /dev/null
+++ b/src/HopFrame.Security/Options/OptionsFromConfiguration.cs
@@ -0,0 +1,5 @@
+namespace HopFrame.Security.Options;
+
+public abstract class OptionsFromConfiguration {
+ public abstract string Position { get; }
+}
\ No newline at end of file
diff --git a/src/HopFrame.Security/Options/OptionsFromConfigurationExtensions.cs b/src/HopFrame.Security/Options/OptionsFromConfigurationExtensions.cs
new file mode 100644
index 0000000..f9b62c4
--- /dev/null
+++ b/src/HopFrame.Security/Options/OptionsFromConfigurationExtensions.cs
@@ -0,0 +1,19 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace HopFrame.Security.Options;
+
+public static class OptionsFromConfigurationExtensions {
+ public static void AddOptionsFromConfiguration(this IServiceCollection services, IConfiguration configuration) where T : OptionsFromConfiguration {
+ T optionsInstance = (T)Activator.CreateInstance(typeof(T));
+ string position = optionsInstance?.Position;
+ if (position is null) {
+ throw new ArgumentException($"""Configuration "{typeof(T).Name}" has no position configured!""");
+ }
+
+ services.Configure((Action)(options => {
+ IConfigurationSection section = configuration.GetSection(position);
+ section.Bind(options);
+ }));
+ }
+}
\ No newline at end of file
diff --git a/src/HopFrame.Web.Admin/Attributes/Classes/AdminPermissionsAttribute.cs b/src/HopFrame.Web.Admin/Attributes/Classes/AdminPermissionsAttribute.cs
index 7d68eab..dc58ffb 100644
--- a/src/HopFrame.Web.Admin/Attributes/Classes/AdminPermissionsAttribute.cs
+++ b/src/HopFrame.Web.Admin/Attributes/Classes/AdminPermissionsAttribute.cs
@@ -8,6 +8,6 @@ public sealed class AdminPermissionsAttribute(string view = null, string create
Create = create,
Update = update,
Delete = delete,
- View = view
+ Read = view
};
}
diff --git a/src/HopFrame.Web.Admin/Generators/IAdminPageGenerator.cs b/src/HopFrame.Web.Admin/Generators/IAdminPageGenerator.cs
index 65998bd..05ff528 100644
--- a/src/HopFrame.Web.Admin/Generators/IAdminPageGenerator.cs
+++ b/src/HopFrame.Web.Admin/Generators/IAdminPageGenerator.cs
@@ -24,7 +24,7 @@ public interface IAdminPageGenerator {
///
/// the specified permission
///
- IAdminPageGenerator ViewPermission(string permission);
+ IAdminPageGenerator ReadPermission(string permission);
///
/// Sets the permission needed to create a new Entry
diff --git a/src/HopFrame.Web.Admin/Generators/Implementation/AdminPageGenerator.cs b/src/HopFrame.Web.Admin/Generators/Implementation/AdminPageGenerator.cs
index eb61f7d..3181c97 100644
--- a/src/HopFrame.Web.Admin/Generators/Implementation/AdminPageGenerator.cs
+++ b/src/HopFrame.Web.Admin/Generators/Implementation/AdminPageGenerator.cs
@@ -48,8 +48,8 @@ internal sealed class AdminPageGenerator : IAdminPageGenerator,
return this;
}
- public IAdminPageGenerator ViewPermission(string permission) {
- Page.Permissions.View = permission;
+ public IAdminPageGenerator ReadPermission(string permission) {
+ Page.Permissions.Read = permission;
return this;
}
@@ -165,7 +165,7 @@ internal sealed class AdminPageGenerator : IAdminPageGenerator,
var attribute = attributes.Single(a => a is AdminPermissionsAttribute) as AdminPermissionsAttribute;
CreatePermission(attribute?.Permissions.Create);
UpdatePermission(attribute?.Permissions.Update);
- ViewPermission(attribute?.Permissions.View);
+ ReadPermission(attribute?.Permissions.Read);
DeletePermission(attribute?.Permissions.Delete);
}
diff --git a/src/HopFrame.Web.Admin/Models/AdminPagePermissions.cs b/src/HopFrame.Web.Admin/Models/AdminPagePermissions.cs
index e9629a6..0312aaa 100644
--- a/src/HopFrame.Web.Admin/Models/AdminPagePermissions.cs
+++ b/src/HopFrame.Web.Admin/Models/AdminPagePermissions.cs
@@ -1,7 +1,7 @@
namespace HopFrame.Web.Admin.Models;
public sealed class AdminPagePermissions {
- public string View { get; set; }
+ public string Read { get; set; }
public string Create { get; set; }
public string Update { get; set; }
public string Delete { get; set; }
diff --git a/src/HopFrame.Web/AuthMiddleware.cs b/src/HopFrame.Web/AuthMiddleware.cs
index 33e2f52..4a9b216 100644
--- a/src/HopFrame.Web/AuthMiddleware.cs
+++ b/src/HopFrame.Web/AuthMiddleware.cs
@@ -1,7 +1,6 @@
using System.Security.Claims;
using HopFrame.Database.Repositories;
using HopFrame.Security.Authentication;
-using HopFrame.Security.Claims;
using HopFrame.Web.Services;
using Microsoft.AspNetCore.Http;
@@ -20,16 +19,10 @@ public sealed class AuthMiddleware(IAuthService auth, IPermissionRepository perm
next?.Invoke(context);
return;
}
-
- var claims = new List {
- new(HopFrameClaimTypes.AccessTokenId, token.Content.ToString()),
- new(HopFrameClaimTypes.UserId, token.Owner.Id.ToString())
- };
- var permissions = await perms.GetFullPermissions(token.Owner);
- claims.AddRange(permissions.Select(perm => new Claim(HopFrameClaimTypes.Permission, perm)));
-
- context.User.AddIdentity(new ClaimsIdentity(claims, HopFrameAuthentication.SchemeName));
+ var principal = await HopFrameAuthentication.GenerateClaims(token, perms);
+ if (principal?.Identity is ClaimsIdentity identity)
+ context.User.AddIdentity(identity);
}
await next?.Invoke(context);
diff --git a/src/HopFrame.Web/Components/Administration/AdminPageModal.razor b/src/HopFrame.Web/Components/Administration/AdminPageModal.razor
index 4876323..4e212b5 100644
--- a/src/HopFrame.Web/Components/Administration/AdminPageModal.razor
+++ b/src/HopFrame.Web/Components/Administration/AdminPageModal.razor
@@ -321,7 +321,7 @@
private async void Save() {
if (_isEdit && _currentPage.Permissions.Update is not null) {
- if (!await Permissions.HasPermission(Auth.User, _currentPage.Permissions.Update)) {
+ if (!await Permissions.HasPermission(Auth.AccessToken, _currentPage.Permissions.Update)) {
await Alerts.FireAsync(new SweetAlertOptions {
Title = "Unauthorized!",
Text = "You don't have the required permissions to edit an entry!",
@@ -330,7 +330,7 @@
return;
}
}else if (_currentPage.Permissions.Create is not null) {
- if (!await Permissions.HasPermission(Auth.User, _currentPage.Permissions.Create)) {
+ if (!await Permissions.HasPermission(Auth.AccessToken, _currentPage.Permissions.Create)) {
await Alerts.FireAsync(new SweetAlertOptions {
Title = "Unauthorized!",
Text = "You don't have the required permissions to add an entry!",
diff --git a/src/HopFrame.Web/HopAdminContext.cs b/src/HopFrame.Web/HopAdminContext.cs
index 0beffd2..198ce65 100644
--- a/src/HopFrame.Web/HopAdminContext.cs
+++ b/src/HopFrame.Web/HopAdminContext.cs
@@ -1,15 +1,17 @@
using System.Text.RegularExpressions;
using HopFrame.Database.Models;
using HopFrame.Security;
+using HopFrame.Security.Authorization;
using HopFrame.Web.Admin;
using HopFrame.Web.Admin.Attributes;
using HopFrame.Web.Admin.Generators;
using HopFrame.Web.Admin.Models;
using HopFrame.Web.Provider;
+using Microsoft.Extensions.Options;
namespace HopFrame.Web;
-internal class HopAdminContext : AdminPagesContext {
+internal class HopAdminContext(IOptions options) : AdminPagesContext {
[AdminPageUrl("users")]
public AdminPage Users { get; set; }
@@ -21,10 +23,10 @@ internal class HopAdminContext : AdminPagesContext {
generator.Page()
.Description("On this page you can manage all user accounts.")
.ConfigureProvider()
- .ViewPermission(AdminPermissions.ViewUsers)
- .CreatePermission(AdminPermissions.AddUser)
- .UpdatePermission(AdminPermissions.EditUser)
- .DeletePermission(AdminPermissions.DeleteUser);
+ .ReadPermission(options.Value.Users.Read)
+ .CreatePermission(options.Value.Users.Create)
+ .UpdatePermission(options.Value.Users.Update)
+ .DeletePermission(options.Value.Users.Delete);
generator.Page().Property(u => u.Password)
.DisplayInListing(false)
@@ -64,10 +66,10 @@ internal class HopAdminContext : AdminPagesContext {
generator.Page()
.Description("On this page you can view, create, edit and delete permission groups.")
.ConfigureProvider()
- .ViewPermission(AdminPermissions.ViewGroups)
- .CreatePermission(AdminPermissions.AddGroup)
- .UpdatePermission(AdminPermissions.EditGroup)
- .DeletePermission(AdminPermissions.DeleteGroup)
+ .ReadPermission(options.Value.Groups.Read)
+ .CreatePermission(options.Value.Groups.Create)
+ .UpdatePermission(options.Value.Groups.Update)
+ .DeletePermission(options.Value.Groups.Delete)
.ListingProperty(g => g.Name);
generator.Page().Property(g => g.Name)
diff --git a/src/HopFrame.Web/HopFrame.Web.csproj b/src/HopFrame.Web/HopFrame.Web.csproj
index a512b95..1eb0490 100644
--- a/src/HopFrame.Web/HopFrame.Web.csproj
+++ b/src/HopFrame.Web/HopFrame.Web.csproj
@@ -35,4 +35,10 @@
+
+
+ <_Parameter1>HopFrame.Tests.Web
+
+
+
diff --git a/src/HopFrame.Web/Pages/Administration/AdminDashboard.razor b/src/HopFrame.Web/Pages/Administration/AdminDashboard.razor
index 7ebb3cf..fe7afb1 100644
--- a/src/HopFrame.Web/Pages/Administration/AdminDashboard.razor
+++ b/src/HopFrame.Web/Pages/Administration/AdminDashboard.razor
@@ -5,25 +5,26 @@
@using BlazorStrap
@using HopFrame.Web.Pages.Administration.Layout
@using BlazorStrap.V5
-@using HopFrame.Security
+@using HopFrame.Security.Authorization
@using HopFrame.Web.Admin.Providers
@using HopFrame.Web.Components
@using Microsoft.AspNetCore.Components.Web
+@using Microsoft.Extensions.Options
@layout AdminLayout
-
+
Admin Dashboard
@foreach (var adminPage in Pages.LoadRegisteredAdminPages()) {
-
+
@adminPage.Title
- @adminPage.Permissions.View
+ @adminPage.Permissions.Read
@adminPage.Description
Open
@@ -36,6 +37,7 @@
@inject NavigationManager Navigator
@inject IAdminPagesProvider Pages
+@inject IOptions Options
@code {
diff --git a/src/HopFrame.Web/Pages/Administration/AdminLogin.razor b/src/HopFrame.Web/Pages/Administration/AdminLogin.razor
index 1d9a61e..8e0f1e1 100644
--- a/src/HopFrame.Web/Pages/Administration/AdminLogin.razor
+++ b/src/HopFrame.Web/Pages/Administration/AdminLogin.razor
@@ -43,7 +43,7 @@
private UserLogin UserLogin { get; set; }
[SupplyParameterFromQuery(Name = "redirect")]
- private string RedirectAfter { get; set; }
+ public string RedirectAfter { get; set; }
private const string DefaultRedirect = "/administration";
diff --git a/src/HopFrame.Web/Pages/Administration/AdminPageList.razor b/src/HopFrame.Web/Pages/Administration/AdminPageList.razor
index 163f66f..1086918 100644
--- a/src/HopFrame.Web/Pages/Administration/AdminPageList.razor
+++ b/src/HopFrame.Web/Pages/Administration/AdminPageList.razor
@@ -18,7 +18,7 @@
@using HopFrame.Web.Components
@_pageData.Title
-
+
@@ -33,7 +33,7 @@
-
+
Add Entry
@@ -140,8 +140,8 @@
throw new ArgumentException($"AdminPage '{_pageData.Title}' does not specify a model repository!'");
_modelProvider = _pageData.LoadModelProvider(Provider);
- _hasEditPermission = _pageData.Permissions.Update is null || await Permissions.HasPermission(Auth.User, _pageData.Permissions.Update);
- _hasDeletePermission = _pageData.Permissions.Delete is null || await Permissions.HasPermission(Auth.User, _pageData.Permissions.Delete);
+ _hasEditPermission = _pageData.Permissions.Update is null || await Permissions.HasPermission(Auth.AccessToken, _pageData.Permissions.Update);
+ _hasDeletePermission = _pageData.Permissions.Delete is null || await Permissions.HasPermission(Auth.AccessToken, _pageData.Permissions.Delete);
await Reload();
}
diff --git a/src/HopFrame.Web/Pages/Administration/Layout/AdminMenu.razor b/src/HopFrame.Web/Pages/Administration/Layout/AdminMenu.razor
index a47bafb..409b002 100644
--- a/src/HopFrame.Web/Pages/Administration/Layout/AdminMenu.razor
+++ b/src/HopFrame.Web/Pages/Administration/Layout/AdminMenu.razor
@@ -24,7 +24,7 @@
Dashboard
@foreach (var adminPage in Pages.LoadRegisteredAdminPages()) {
-
+
@adminPage.Title
}
diff --git a/src/HopFrame.Web/ServiceCollectionExtensions.cs b/src/HopFrame.Web/ServiceCollectionExtensions.cs
index 548e2e9..4b6232a 100644
--- a/src/HopFrame.Web/ServiceCollectionExtensions.cs
+++ b/src/HopFrame.Web/ServiceCollectionExtensions.cs
@@ -6,12 +6,13 @@ using HopFrame.Web.Admin;
using HopFrame.Web.Services;
using HopFrame.Web.Services.Implementation;
using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace HopFrame.Web;
public static class ServiceCollectionExtensions {
- public static IServiceCollection AddHopFrame(this IServiceCollection services) where TDbContext : HopDbContextBase {
+ public static IServiceCollection AddHopFrame(this IServiceCollection services, ConfigurationManager configuration) where TDbContext : HopDbContextBase {
services.AddHttpClient();
services.AddHopFrameRepositories();
services.AddScoped();
@@ -22,7 +23,7 @@ public static class ServiceCollectionExtensions {
services.AddSweetAlert2();
services.AddBlazorStrap();
- services.AddHopFrameAuthentication();
+ services.AddHopFrameAuthentication(configuration);
return services;
}
diff --git a/src/HopFrame.Web/Services/Implementation/AuthService.cs b/src/HopFrame.Web/Services/Implementation/AuthService.cs
index 6a96a97..5c95a8f 100644
--- a/src/HopFrame.Web/Services/Implementation/AuthService.cs
+++ b/src/HopFrame.Web/Services/Implementation/AuthService.cs
@@ -1,9 +1,12 @@
using HopFrame.Database.Models;
using HopFrame.Database.Repositories;
using HopFrame.Security.Authentication;
+using HopFrame.Security.Authentication.OpenID;
+using HopFrame.Security.Authentication.OpenID.Options;
using HopFrame.Security.Claims;
using HopFrame.Security.Models;
using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Options;
namespace HopFrame.Web.Services.Implementation;
@@ -11,10 +14,16 @@ internal class AuthService(
IUserRepository userService,
IHttpContextAccessor httpAccessor,
ITokenRepository tokens,
- ITokenContext context)
+ ITokenContext context,
+ IOptions options,
+ IOptions openIdOptions,
+ IOpenIdAccessor accessor,
+ IUserRepository users)
: IAuthService {
public async Task Register(UserRegister register) {
+ if (!options.Value.DefaultAuthentication) return;
+
var user = await userService.AddUser(new User {
Username = register.Username,
Email = register.Email,
@@ -26,19 +35,21 @@ internal class AuthService(
var refreshToken = await tokens.CreateToken(Token.RefreshTokenType, user);
var accessToken = await tokens.CreateToken(Token.AccessTokenType, user);
- httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.Content.ToString(), new CookieOptions {
- MaxAge = HopFrameAuthentication.RefreshTokenTime,
+ httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.TokenId.ToString(), new CookieOptions {
+ MaxAge = options.Value.RefreshTokenTime,
HttpOnly = true,
Secure = true
});
- httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Content.ToString(), new CookieOptions {
- MaxAge = HopFrameAuthentication.AccessTokenTime,
+ httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.TokenId.ToString(), new CookieOptions {
+ MaxAge = options.Value.AccessTokenTime,
HttpOnly = false,
Secure = true
});
}
public async Task Login(UserLogin login) {
+ if (!options.Value.DefaultAuthentication) return false;
+
var user = await userService.GetUserByEmail(login.Email);
if (user == null) return false;
@@ -47,13 +58,13 @@ internal class AuthService(
var refreshToken = await tokens.CreateToken(Token.RefreshTokenType, user);
var accessToken = await tokens.CreateToken(Token.AccessTokenType, user);
- httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.Content.ToString(), new CookieOptions {
- MaxAge = HopFrameAuthentication.RefreshTokenTime,
+ httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.TokenId.ToString(), new CookieOptions {
+ MaxAge = options.Value.RefreshTokenTime,
HttpOnly = true,
Secure = true
});
- httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Content.ToString(), new CookieOptions {
- MaxAge = HopFrameAuthentication.AccessTokenTime,
+ httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.TokenId.ToString(), new CookieOptions {
+ MaxAge = options.Value.AccessTokenTime,
HttpOnly = false,
Secure = true
});
@@ -73,16 +84,55 @@ internal class AuthService(
if (string.IsNullOrWhiteSpace(refreshToken)) return null;
+ if (openIdOptions.Value.Enabled && !Guid.TryParse(refreshToken, out _)) {
+ var openIdToken = await accessor.RefreshAccessToken(refreshToken);
+
+ if (openIdToken is null)
+ return null;
+
+ var inspection = await accessor.InspectToken(openIdToken.AccessToken);
+
+ var email = inspection.Email;
+ if (string.IsNullOrEmpty(email))
+ return null;
+
+ var user = await users.GetUserByEmail(email);
+ if (user is null) {
+ if (!openIdOptions.Value.GenerateUsers)
+ return null;
+
+ var username = inspection.PreferredUsername;
+ user = await users.AddUser(new User {
+ Email = email,
+ Username = username
+ });
+ }
+
+ httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, openIdToken.AccessToken, new CookieOptions {
+ MaxAge = TimeSpan.FromSeconds(openIdToken.ExpiresIn),
+ HttpOnly = false,
+ Secure = true
+ });
+ return new() {
+ Owner = user,
+ CreatedAt = DateTime.Now,
+ Type = Token.OpenIdTokenType
+ };
+ }
+
+ if (!options.Value.DefaultAuthentication)
+ return null;
+
var token = await tokens.GetToken(refreshToken);
if (token is null || token.Type != Token.RefreshTokenType) return null;
- if (token.CreatedAt + HopFrameAuthentication.RefreshTokenTime < DateTime.Now) return null;
+ if (token.CreatedAt + options.Value.RefreshTokenTime < DateTime.Now) return null;
var accessToken = await tokens.CreateToken(Token.AccessTokenType, token.Owner);
- httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Content.ToString(), new CookieOptions {
- MaxAge = HopFrameAuthentication.AccessTokenTime,
+ httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.TokenId.ToString(), new CookieOptions {
+ MaxAge = options.Value.AccessTokenTime,
HttpOnly = false,
Secure = true
});
@@ -91,15 +141,12 @@ internal class AuthService(
}
public async Task IsLoggedIn() {
- var accessToken = httpAccessor.HttpContext?.Request.Cookies[ITokenContext.AccessTokenType];
- if (string.IsNullOrEmpty(accessToken)) return false;
-
- var tokenEntry = await tokens.GetToken(accessToken);
+ var accessToken = context.AccessToken;
- if (tokenEntry is null) return false;
- if (tokenEntry.Type != Token.AccessTokenType) return false;
- if (tokenEntry.CreatedAt + HopFrameAuthentication.AccessTokenTime < DateTime.Now) return false;
- if (tokenEntry.Owner is null) return false;
+ if (accessToken is null) return false;
+ if (accessToken.Type != Token.AccessTokenType && accessToken.Type != Token.OpenIdTokenType) return false;
+ if (accessToken.CreatedAt + options.Value.AccessTokenTime < DateTime.Now) return false;
+ if (accessToken.Owner is null) return false;
return true;
}
diff --git a/test/FrontendTest/.gitignore b/testing/HopFrame.Testing.Api/.gitignore
similarity index 100%
rename from test/FrontendTest/.gitignore
rename to testing/HopFrame.Testing.Api/.gitignore
diff --git a/test/RestApiTest/Controllers/TestController.cs b/testing/HopFrame.Testing.Api/Controllers/TestController.cs
similarity index 52%
rename from test/RestApiTest/Controllers/TestController.cs
rename to testing/HopFrame.Testing.Api/Controllers/TestController.cs
index 092784f..3a3affe 100644
--- a/test/RestApiTest/Controllers/TestController.cs
+++ b/testing/HopFrame.Testing.Api/Controllers/TestController.cs
@@ -1,20 +1,22 @@
using HopFrame.Api.Logic;
+using HopFrame.Api.Models;
using HopFrame.Database.Models;
+using HopFrame.Database.Repositories;
using HopFrame.Security.Authorization;
using HopFrame.Security.Claims;
+using HopFrame.Testing.Api.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
-using RestApiTest.Models;
-namespace RestApiTest.Controllers;
+namespace HopFrame.Testing.Api.Controllers;
[ApiController]
[Route("test")]
-public class TestController(ITokenContext userContext, DatabaseContext context) : ControllerBase {
+public class TestController(ITokenContext userContext, DatabaseContext context, ITokenRepository tokens, IPermissionRepository permissions) : ControllerBase {
[HttpGet("permissions"), Authorized]
- public ActionResult> Permissions() {
- return new ActionResult>(userContext.User.Permissions);
+ public async Task>> Permissions() {
+ return new ActionResult>(await permissions.GetFullPermissions(userContext.AccessToken));
}
[HttpGet("generate")]
@@ -50,5 +52,25 @@ public class TestController(ITokenContext userContext, DatabaseContext context)
public async Task>> GetAddresses() {
return LogicResult>.Ok(await context.Addresses.Include(e => e.Employee).ToListAsync());
}
+
+ [HttpGet("token"), Authorized]
+ public async Task>> GetApiToken() {
+ var token = await tokens.CreateApiToken(userContext.User, DateTime.MaxValue);
+ await permissions.AddPermission(token, "hopframe.admin");
+ await permissions.AddPermission(token, "hopframe.admin.users.read");
+ return LogicResult>.Ok(token.TokenId.ToString());
+ }
+
+ [HttpDelete("token/{tokenId}")]
+ public async Task DeleteToken(string tokenId) {
+ var token = await tokens.GetToken(tokenId);
+ await tokens.DeleteToken(token);
+ }
+
+ [HttpGet("url")]
+ public async Task>> GetUrl() {
+ var protocol = Request.IsHttps ? "https" : "http";
+ return Ok($"{protocol}://{Request.Host.Value}/auth/callback");
+ }
}
\ No newline at end of file
diff --git a/test/RestApiTest/DatabaseContext.cs b/testing/HopFrame.Testing.Api/DatabaseContext.cs
similarity index 80%
rename from test/RestApiTest/DatabaseContext.cs
rename to testing/HopFrame.Testing.Api/DatabaseContext.cs
index ef370c7..4c707f4 100644
--- a/test/RestApiTest/DatabaseContext.cs
+++ b/testing/HopFrame.Testing.Api/DatabaseContext.cs
@@ -1,8 +1,8 @@
using HopFrame.Database;
+using HopFrame.Testing.Api.Models;
using Microsoft.EntityFrameworkCore;
-using RestApiTest.Models;
-namespace RestApiTest;
+namespace HopFrame.Testing.Api;
public class DatabaseContext : HopDbContextBase {
@@ -12,7 +12,7 @@ public class DatabaseContext : HopDbContextBase {
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) {
base.OnConfiguring(optionsBuilder);
- optionsBuilder.UseSqlite(@"Data Source=C:\Users\leon\Documents\Projekte\HopFrame\test\RestApiTest\bin\Debug\net8.0\test.db;Mode=ReadWrite;");
+ optionsBuilder.UseSqlite(@"Data Source=C:\Users\leon\Documents\Projekte\HopFrame\testing\HopFrame.Testing.Api\bin\Debug\net8.0\test.db;Mode=ReadWrite;");
}
protected override void OnModelCreating(ModelBuilder modelBuilder) {
diff --git a/test/RestApiTest/RestApiTest.csproj b/testing/HopFrame.Testing.Api/HopFrame.Testing.Api.csproj
similarity index 100%
rename from test/RestApiTest/RestApiTest.csproj
rename to testing/HopFrame.Testing.Api/HopFrame.Testing.Api.csproj
diff --git a/test/RestApiTest/Models/Address.cs b/testing/HopFrame.Testing.Api/Models/Address.cs
similarity index 92%
rename from test/RestApiTest/Models/Address.cs
rename to testing/HopFrame.Testing.Api/Models/Address.cs
index 386114d..5688ad1 100644
--- a/test/RestApiTest/Models/Address.cs
+++ b/testing/HopFrame.Testing.Api/Models/Address.cs
@@ -2,7 +2,7 @@ using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Mvc.ModelBinding;
-namespace RestApiTest.Models;
+namespace HopFrame.Testing.Api.Models;
public class Address {
[ForeignKey("Employee")]
diff --git a/test/FrontendTest/Models/Employee.cs b/testing/HopFrame.Testing.Api/Models/Employee.cs
similarity index 79%
rename from test/FrontendTest/Models/Employee.cs
rename to testing/HopFrame.Testing.Api/Models/Employee.cs
index 6f70edc..f7e3e27 100644
--- a/test/FrontendTest/Models/Employee.cs
+++ b/testing/HopFrame.Testing.Api/Models/Employee.cs
@@ -1,4 +1,4 @@
-namespace RestApiTest.Models;
+namespace HopFrame.Testing.Api.Models;
public class Employee {
public int EmployeeId { get; set; }
diff --git a/test/RestApiTest/Program.cs b/testing/HopFrame.Testing.Api/Program.cs
similarity index 90%
rename from test/RestApiTest/Program.cs
rename to testing/HopFrame.Testing.Api/Program.cs
index bfcbc38..45c9ef1 100644
--- a/test/RestApiTest/Program.cs
+++ b/testing/HopFrame.Testing.Api/Program.cs
@@ -1,4 +1,4 @@
-using RestApiTest;
+using HopFrame.Testing.Api;
using HopFrame.Api.Extensions;
using Microsoft.OpenApi.Models;
@@ -6,8 +6,9 @@ var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
+builder.Services.AddHttpClient();
builder.Services.AddControllers();
-builder.Services.AddHopFrame();
+builder.Services.AddHopFrame(builder.Configuration);
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
@@ -18,7 +19,7 @@ builder.Services.AddSwaggerGen(c => {
c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme {
Description = @"JWT Authorization header using the Bearer scheme. \r\n\r\n
Enter 'Bearer' [space] and then your token in the text input below.",
- Name = "Authorization",
+ Name = "Token",
In = ParameterLocation.Header,
Type = SecuritySchemeType.ApiKey,
Scheme = "Bearer"
diff --git a/test/RestApiTest/Properties/launchSettings.json b/testing/HopFrame.Testing.Api/Properties/launchSettings.json
similarity index 100%
rename from test/RestApiTest/Properties/launchSettings.json
rename to testing/HopFrame.Testing.Api/Properties/launchSettings.json
diff --git a/test/FrontendTest/appsettings.json b/testing/HopFrame.Testing.Api/appsettings.json
similarity index 100%
rename from test/FrontendTest/appsettings.json
rename to testing/HopFrame.Testing.Api/appsettings.json
diff --git a/test/RestApiTest/.gitignore b/testing/HopFrame.Testing.Web/.gitignore
similarity index 100%
rename from test/RestApiTest/.gitignore
rename to testing/HopFrame.Testing.Web/.gitignore
diff --git a/test/FrontendTest/AdminContext.cs b/testing/HopFrame.Testing.Web/AdminContext.cs
similarity index 90%
rename from test/FrontendTest/AdminContext.cs
rename to testing/HopFrame.Testing.Web/AdminContext.cs
index 2eaff8d..9d33628 100644
--- a/test/FrontendTest/AdminContext.cs
+++ b/testing/HopFrame.Testing.Web/AdminContext.cs
@@ -1,10 +1,10 @@
-using FrontendTest.Providers;
using HopFrame.Web.Admin;
using HopFrame.Web.Admin.Generators;
using HopFrame.Web.Admin.Models;
-using RestApiTest.Models;
+using HopFrame.Testing.Api.Models;
+using HopFrame.Testing.Web.Providers;
-namespace FrontendTest;
+namespace HopFrame.Testing.Web;
public class AdminContext : AdminPagesContext {
diff --git a/test/FrontendTest/Components/App.razor b/testing/HopFrame.Testing.Web/Components/App.razor
similarity index 86%
rename from test/FrontendTest/Components/App.razor
rename to testing/HopFrame.Testing.Web/Components/App.razor
index 35c8065..b3faba0 100644
--- a/test/FrontendTest/Components/App.razor
+++ b/testing/HopFrame.Testing.Web/Components/App.razor
@@ -7,7 +7,7 @@
-
+
diff --git a/test/FrontendTest/Components/Layout/MainLayout.razor b/testing/HopFrame.Testing.Web/Components/Layout/MainLayout.razor
similarity index 100%
rename from test/FrontendTest/Components/Layout/MainLayout.razor
rename to testing/HopFrame.Testing.Web/Components/Layout/MainLayout.razor
diff --git a/test/FrontendTest/Components/Layout/MainLayout.razor.css b/testing/HopFrame.Testing.Web/Components/Layout/MainLayout.razor.css
similarity index 100%
rename from test/FrontendTest/Components/Layout/MainLayout.razor.css
rename to testing/HopFrame.Testing.Web/Components/Layout/MainLayout.razor.css
diff --git a/test/FrontendTest/Components/Layout/NavMenu.razor b/testing/HopFrame.Testing.Web/Components/Layout/NavMenu.razor
similarity index 100%
rename from test/FrontendTest/Components/Layout/NavMenu.razor
rename to testing/HopFrame.Testing.Web/Components/Layout/NavMenu.razor
diff --git a/test/FrontendTest/Components/Layout/NavMenu.razor.css b/testing/HopFrame.Testing.Web/Components/Layout/NavMenu.razor.css
similarity index 100%
rename from test/FrontendTest/Components/Layout/NavMenu.razor.css
rename to testing/HopFrame.Testing.Web/Components/Layout/NavMenu.razor.css
diff --git a/test/FrontendTest/Components/Pages/Counter.razor b/testing/HopFrame.Testing.Web/Components/Pages/Counter.razor
similarity index 100%
rename from test/FrontendTest/Components/Pages/Counter.razor
rename to testing/HopFrame.Testing.Web/Components/Pages/Counter.razor
diff --git a/test/FrontendTest/Components/Pages/Error.razor b/testing/HopFrame.Testing.Web/Components/Pages/Error.razor
similarity index 100%
rename from test/FrontendTest/Components/Pages/Error.razor
rename to testing/HopFrame.Testing.Web/Components/Pages/Error.razor
diff --git a/test/FrontendTest/Components/Pages/Home.razor b/testing/HopFrame.Testing.Web/Components/Pages/Home.razor
similarity index 100%
rename from test/FrontendTest/Components/Pages/Home.razor
rename to testing/HopFrame.Testing.Web/Components/Pages/Home.razor
diff --git a/test/FrontendTest/Components/Pages/Weather.razor b/testing/HopFrame.Testing.Web/Components/Pages/Weather.razor
similarity index 100%
rename from test/FrontendTest/Components/Pages/Weather.razor
rename to testing/HopFrame.Testing.Web/Components/Pages/Weather.razor
diff --git a/test/FrontendTest/Components/Routes.razor b/testing/HopFrame.Testing.Web/Components/Routes.razor
similarity index 100%
rename from test/FrontendTest/Components/Routes.razor
rename to testing/HopFrame.Testing.Web/Components/Routes.razor
diff --git a/test/FrontendTest/Components/_Imports.razor b/testing/HopFrame.Testing.Web/Components/_Imports.razor
similarity index 83%
rename from test/FrontendTest/Components/_Imports.razor
rename to testing/HopFrame.Testing.Web/Components/_Imports.razor
index b17e0c0..f7abb33 100644
--- a/test/FrontendTest/Components/_Imports.razor
+++ b/testing/HopFrame.Testing.Web/Components/_Imports.razor
@@ -6,5 +6,5 @@
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
-@using FrontendTest
-@using FrontendTest.Components
\ No newline at end of file
+@using HopFrame.Testing.Web
+@using HopFrame.Testing.Web.Components
\ No newline at end of file
diff --git a/test/FrontendTest/DatabaseContext.cs b/testing/HopFrame.Testing.Web/DatabaseContext.cs
similarity index 80%
rename from test/FrontendTest/DatabaseContext.cs
rename to testing/HopFrame.Testing.Web/DatabaseContext.cs
index 0dede8a..bd12346 100644
--- a/test/FrontendTest/DatabaseContext.cs
+++ b/testing/HopFrame.Testing.Web/DatabaseContext.cs
@@ -1,8 +1,8 @@
using HopFrame.Database;
using Microsoft.EntityFrameworkCore;
-using RestApiTest.Models;
+using HopFrame.Testing.Api.Models;
-namespace FrontendTest;
+namespace HopFrame.Testing.Web;
public class DatabaseContext : HopDbContextBase {
public DbSet Employees { get; set; }
@@ -11,7 +11,7 @@ public class DatabaseContext : HopDbContextBase {
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) {
base.OnConfiguring(optionsBuilder);
- optionsBuilder.UseSqlite(@"Data Source=C:\Users\leon\Documents\Projekte\HopFrame\test\RestApiTest\bin\Debug\net8.0\test.db;Mode=ReadWrite;");
+ optionsBuilder.UseSqlite(@"Data Source=C:\Users\leon\Documents\Projekte\HopFrame\testing\HopFrame.Testing.Api\bin\Debug\net8.0\test.db;Mode=ReadWrite;");
}
protected override void OnModelCreating(ModelBuilder modelBuilder) {
diff --git a/test/FrontendTest/FrontendTest.csproj b/testing/HopFrame.Testing.Web/HopFrame.Testing.Web.csproj
similarity index 100%
rename from test/FrontendTest/FrontendTest.csproj
rename to testing/HopFrame.Testing.Web/HopFrame.Testing.Web.csproj
diff --git a/test/FrontendTest/Models/Address.cs b/testing/HopFrame.Testing.Web/Models/Address.cs
similarity index 92%
rename from test/FrontendTest/Models/Address.cs
rename to testing/HopFrame.Testing.Web/Models/Address.cs
index 386114d..5688ad1 100644
--- a/test/FrontendTest/Models/Address.cs
+++ b/testing/HopFrame.Testing.Web/Models/Address.cs
@@ -2,7 +2,7 @@ using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Mvc.ModelBinding;
-namespace RestApiTest.Models;
+namespace HopFrame.Testing.Api.Models;
public class Address {
[ForeignKey("Employee")]
diff --git a/test/RestApiTest/Models/Employee.cs b/testing/HopFrame.Testing.Web/Models/Employee.cs
similarity index 79%
rename from test/RestApiTest/Models/Employee.cs
rename to testing/HopFrame.Testing.Web/Models/Employee.cs
index 6f70edc..f7e3e27 100644
--- a/test/RestApiTest/Models/Employee.cs
+++ b/testing/HopFrame.Testing.Web/Models/Employee.cs
@@ -1,4 +1,4 @@
-namespace RestApiTest.Models;
+namespace HopFrame.Testing.Api.Models;
public class Employee {
public int EmployeeId { get; set; }
diff --git a/test/FrontendTest/Program.cs b/testing/HopFrame.Testing.Web/Program.cs
similarity index 86%
rename from test/FrontendTest/Program.cs
rename to testing/HopFrame.Testing.Web/Program.cs
index 7547722..481e7fc 100644
--- a/test/FrontendTest/Program.cs
+++ b/testing/HopFrame.Testing.Web/Program.cs
@@ -1,12 +1,12 @@
-using FrontendTest;
-using FrontendTest.Components;
+using HopFrame.Testing.Web;
+using HopFrame.Testing.Web.Components;
using HopFrame.Web;
using HopFrame.Web.Admin;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext();
-builder.Services.AddHopFrame();
+builder.Services.AddHopFrame(builder.Configuration);
builder.Services.AddAdminContext();
// Add services to the container.
diff --git a/test/FrontendTest/Properties/launchSettings.json b/testing/HopFrame.Testing.Web/Properties/launchSettings.json
similarity index 100%
rename from test/FrontendTest/Properties/launchSettings.json
rename to testing/HopFrame.Testing.Web/Properties/launchSettings.json
diff --git a/test/FrontendTest/Providers/AddressProvider.cs b/testing/HopFrame.Testing.Web/Providers/AddressProvider.cs
similarity index 91%
rename from test/FrontendTest/Providers/AddressProvider.cs
rename to testing/HopFrame.Testing.Web/Providers/AddressProvider.cs
index de5f13f..6ed1d6f 100644
--- a/test/FrontendTest/Providers/AddressProvider.cs
+++ b/testing/HopFrame.Testing.Web/Providers/AddressProvider.cs
@@ -1,8 +1,8 @@
using HopFrame.Web.Admin;
using Microsoft.EntityFrameworkCore;
-using RestApiTest.Models;
+using HopFrame.Testing.Api.Models;
-namespace FrontendTest.Providers;
+namespace HopFrame.Testing.Web.Providers;
public class AddressProvider(DatabaseContext context) : ModelProvider {
diff --git a/test/FrontendTest/Providers/EmployeeProvider.cs b/testing/HopFrame.Testing.Web/Providers/EmployeeProvider.cs
similarity index 91%
rename from test/FrontendTest/Providers/EmployeeProvider.cs
rename to testing/HopFrame.Testing.Web/Providers/EmployeeProvider.cs
index 89f7b84..9078eea 100644
--- a/test/FrontendTest/Providers/EmployeeProvider.cs
+++ b/testing/HopFrame.Testing.Web/Providers/EmployeeProvider.cs
@@ -1,8 +1,8 @@
using HopFrame.Web.Admin;
using Microsoft.EntityFrameworkCore;
-using RestApiTest.Models;
+using HopFrame.Testing.Api.Models;
-namespace FrontendTest.Providers;
+namespace HopFrame.Testing.Web.Providers;
public class EmployeeProvider(DatabaseContext context) : ModelProvider {
diff --git a/test/RestApiTest/appsettings.json b/testing/HopFrame.Testing.Web/appsettings.json
similarity index 100%
rename from test/RestApiTest/appsettings.json
rename to testing/HopFrame.Testing.Web/appsettings.json
diff --git a/test/FrontendTest/wwwroot/app.css b/testing/HopFrame.Testing.Web/wwwroot/app.css
similarity index 100%
rename from test/FrontendTest/wwwroot/app.css
rename to testing/HopFrame.Testing.Web/wwwroot/app.css
diff --git a/test/FrontendTest/wwwroot/favicon.png b/testing/HopFrame.Testing.Web/wwwroot/favicon.png
similarity index 100%
rename from test/FrontendTest/wwwroot/favicon.png
rename to testing/HopFrame.Testing.Web/wwwroot/favicon.png
diff --git a/tests/HopFrame.Tests.Api/AuthLogicTests.cs b/tests/HopFrame.Tests.Api/AuthLogicTests.cs
new file mode 100644
index 0000000..321b4a0
--- /dev/null
+++ b/tests/HopFrame.Tests.Api/AuthLogicTests.cs
@@ -0,0 +1,402 @@
+using System.Net;
+using System.Security.Claims;
+using HopFrame.Api.Logic;
+using HopFrame.Api.Logic.Implementation;
+using HopFrame.Api.Models;
+using HopFrame.Tests.Api.Extensions;
+using HopFrame.Database.Models;
+using HopFrame.Database.Repositories;
+using HopFrame.Security.Authentication;
+using HopFrame.Security.Claims;
+using HopFrame.Security.Models;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Options;
+using Moq;
+
+namespace HopFrame.Tests.Api;
+
+public class AuthLogicTests {
+
+ private readonly Guid _refreshToken = Guid.NewGuid();
+ private readonly Guid _accessToken = Guid.NewGuid();
+
+ private (IAuthLogic, HttpContext) SetupEnvironment(bool passwordIsCorrect = true, Token providedRefreshToken = null, string providedTokenCookie = null, bool provideAccessToken = true) {
+ var accessor = new HttpContextAccessor {
+ HttpContext = new DefaultHttpContext()
+ };
+
+ if (providedTokenCookie != null) {
+ var cookies = new Mock();
+ cookies
+ .SetupGet(c => c[ITokenContext.RefreshTokenType])
+ .Returns(providedTokenCookie);
+ accessor.HttpContext.Request.Cookies = cookies.Object;
+ }
+
+ if (provideAccessToken) {
+ var claims = new List {
+ new(HopFrameClaimTypes.AccessTokenId, _accessToken.ToString())
+ };
+ accessor.HttpContext.User.AddIdentity(new ClaimsIdentity(claims, HopFrameAuthentication.SchemeName));
+ }
+
+ var users = new Mock();
+ users
+ .Setup(u => u.GetUserByEmail(It.Is(email => CreateDummyUser().Email == email)))
+ .ReturnsAsync(CreateDummyUser());
+ users
+ .Setup(u => u.CheckUserPassword(It.Is