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(u => u.Email == CreateDummyUser().Email), It.IsAny())) + .ReturnsAsync(passwordIsCorrect); + users + .Setup(u => u.AddUser(It.IsAny())) + .ReturnsAsync(CreateDummyUser()); + users + .Setup(u => u.GetUsers()) + .ReturnsAsync(new List { CreateDummyUser() }); + + var tokens = new Mock(); + tokens + .Setup(t => t.CreateToken(It.Is(t => t == Token.RefreshTokenType), It.IsAny())) + .ReturnsAsync(new Token { + TokenId = _refreshToken, + Type = Token.RefreshTokenType + }); + tokens + .Setup(t => t.CreateToken(It.Is(t => t == Token.AccessTokenType), It.IsAny())) + .ReturnsAsync(new Token { + TokenId = _accessToken, + Type = Token.AccessTokenType + }); + tokens + .Setup(t => t.GetToken(It.Is(token => token == _refreshToken.ToString()))) + .ReturnsAsync(providedRefreshToken); + + var context = new Mock(); + context + .Setup(c => c.User) + .Returns(CreateDummyUser()); + + return (new AuthLogic(users.Object, tokens.Object, context.Object, accessor, new OptionsWrapper(new HopFrameAuthenticationOptions())), accessor.HttpContext); + } + + private User CreateDummyUser() => new() { + Id = Guid.NewGuid(), + CreatedAt = DateTime.Now, + Email = "test@example.com", + Username = "ExampleUser", + Password = "1234567890" + }; + + [Fact] + public async Task Login_Should_Succeed() { + // Arrange + var (auth, context) = SetupEnvironment(); + var login = new UserLogin { + Email = CreateDummyUser().Email, + Password = CreateDummyUser().Password + }; + + // Act + var result = await auth.Login(login); + + // Assert + Assert.True(result.IsSuccessful); + Assert.Equal(_accessToken.ToString(), result.Data.Value); + Assert.Equal(_accessToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + Assert.Equal(_refreshToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType)); + } + + [Fact] + public async Task Login_With_WrongEmail_Should_Fail() { + // Arrange + var (auth, context) = SetupEnvironment(); + var login = new UserLogin { + Email = "wrong@example.com", + Password = CreateDummyUser().Password + }; + + // Act + var result = await auth.Login(login); + + // Assert + Assert.False(result.IsSuccessful); + Assert.Equal(HttpStatusCode.NotFound, result.State); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType)); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + } + + [Fact] + public async Task Login_With_WrongPassword_Should_Fail() { + // Arrange + var (auth, context) = SetupEnvironment(false); + var login = new UserLogin { + Email = CreateDummyUser().Email, + Password = CreateDummyUser().Password + }; + + // Act + var result = await auth.Login(login); + + // Assert + Assert.False(result.IsSuccessful); + Assert.Equal(HttpStatusCode.Forbidden, result.State); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType)); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + } + + [Fact] + public async Task Register_Should_Succeed() { + // Arrange + var (auth, context) = SetupEnvironment(); + var register = new UserRegister { + Email = "new@example.com", + Password = "1234567890", + Username = "NewUser" + }; + + // Act + var result = await auth.Register(register); + + // Assert + Assert.True(result.IsSuccessful); + Assert.Equal(_accessToken.ToString(), result.Data.Value); + Assert.Equal(_accessToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + Assert.Equal(_refreshToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType)); + } + + [Fact] + public async Task Register_With_ShortPassword_Should_Fail() { + // Arrange + var (auth, context) = SetupEnvironment(); + var register = new UserRegister { + Email = "new@example.com", + Password = "12345", + Username = "NewUser" + }; + + // Act + var result = await auth.Register(register); + + // Assert + Assert.False(result.IsSuccessful); + Assert.Equal(HttpStatusCode.BadRequest, result.State); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType)); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + } + + [Fact] + public async Task Register_With_ExistingUsername_Should_Fail() { + // Arrange + var (auth, context) = SetupEnvironment(); + var register = new UserRegister { + Email = "new@example.com", + Password = "1234567890", + Username = CreateDummyUser().Username + }; + + // Act + var result = await auth.Register(register); + + // Assert + Assert.False(result.IsSuccessful); + Assert.Equal(HttpStatusCode.Conflict, result.State); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType)); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + } + + [Fact] + public async Task Register_With_ExistingEmail_Should_Fail() { + // Arrange + var (auth, context) = SetupEnvironment(); + var register = new UserRegister { + Email = CreateDummyUser().Email, + Password = "1234567890", + Username = "NewUser" + }; + + // Act + var result = await auth.Register(register); + + // Assert + Assert.False(result.IsSuccessful); + Assert.Equal(HttpStatusCode.Conflict, result.State); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType)); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + } + + [Fact] + public async Task Authenticate_Should_Succeed() { + // Arrange + var token = new Token { + Type = Token.RefreshTokenType, + TokenId = _refreshToken, + CreatedAt = DateTime.Now, + Owner = CreateDummyUser() + }; + var (auth, context) = SetupEnvironment(true, token, token.TokenId.ToString()); + + // Act + var result = await auth.Authenticate(); + + // Assert + Assert.True(result.IsSuccessful); + Assert.Equal(_accessToken.ToString(), result.Data.Value); + Assert.Equal(_accessToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + } + + [Fact] + public async Task Authenticate_With_NoProvidedToken_Should_Fail() { + // Arrange + var (auth, context) = SetupEnvironment(); + + // Act + var result = await auth.Authenticate(); + + // Assert + Assert.False(result.IsSuccessful); + Assert.Equal(HttpStatusCode.BadRequest, result.State); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + } + + [Fact] + public async Task Authenticate_With_WrongToken_Should_Fail() { + // Arrange + var (auth, context) = SetupEnvironment(true, null, _refreshToken.ToString()); + + // Act + var result = await auth.Authenticate(); + + // Assert + Assert.False(result.IsSuccessful); + Assert.Equal(HttpStatusCode.NotFound, result.State); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + } + + [Fact] + public async Task Authenticate_With_WrongTokenType_Should_Fail() { + // Arrange + var token = new Token { + Type = Token.AccessTokenType, + TokenId = _refreshToken, + CreatedAt = DateTime.Now, + Owner = CreateDummyUser() + }; + var (auth, context) = SetupEnvironment(true, token, token.TokenId.ToString()); + + // Act + var result = await auth.Authenticate(); + + // Assert + Assert.False(result.IsSuccessful); + Assert.Equal(HttpStatusCode.Conflict, result.State); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + } + + [Fact] + public async Task Authenticate_With_ExpiredToken_Should_Fail() { + // Arrange + var token = new Token { + Type = Token.RefreshTokenType, + TokenId = _refreshToken, + CreatedAt = DateTime.MinValue, + Owner = CreateDummyUser() + }; + var (auth, context) = SetupEnvironment(true, token, token.TokenId.ToString()); + + // Act + var result = await auth.Authenticate(); + + // Assert + Assert.False(result.IsSuccessful); + Assert.Equal(HttpStatusCode.Forbidden, result.State); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + } + + [Fact] + public async Task Logout_Should_Succeed() { + // Arrange + var (auth, context) = SetupEnvironment(providedTokenCookie: _refreshToken.ToString()); + context.Response.Cookies.Append(ITokenContext.AccessTokenType, _accessToken.ToString()); + context.Response.Cookies.Append(ITokenContext.RefreshTokenType, _refreshToken.ToString()); + + // Act + var result = await auth.Logout(); + + // Assert + Assert.True(result.IsSuccessful); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType)); + } + + [Fact] + public async Task Logout_With_NoAccessToken_Should_Succeed() { + // Arrange + var (auth, context) = SetupEnvironment(provideAccessToken: false); + context.Response.Cookies.Append(ITokenContext.AccessTokenType, _accessToken.ToString()); + context.Response.Cookies.Append(ITokenContext.RefreshTokenType, _refreshToken.ToString()); + + // Act + var result = await auth.Logout(); + + // Assert + Assert.True(result.IsSuccessful); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType)); + } + + [Fact] + public async Task Logout_With_NoRefreshToken_Should_Succeed() { + // Arrange + var (auth, context) = SetupEnvironment(); + context.Response.Cookies.Append(ITokenContext.AccessTokenType, _accessToken.ToString()); + context.Response.Cookies.Append(ITokenContext.RefreshTokenType, _refreshToken.ToString()); + + // Act + var result = await auth.Logout(); + + // Assert + Assert.True(result.IsSuccessful); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType)); + } + + [Fact] + public async Task Delete_Should_Succeed() { + // Arrange + var (auth, context) = SetupEnvironment(); + context.Response.Cookies.Append(ITokenContext.AccessTokenType, _accessToken.ToString()); + context.Response.Cookies.Append(ITokenContext.RefreshTokenType, _refreshToken.ToString()); + var validation = new UserPasswordValidation { + Password = CreateDummyUser().Password + }; + + // Act + var result = await auth.Delete(validation); + + // Assert + Assert.True(result.IsSuccessful); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType)); + } + + [Fact] + public async Task Delete_With_WrongPassword_Should_Fail() { + // Arrange + var (auth, context) = SetupEnvironment(false); + context.Response.Cookies.Append(ITokenContext.AccessTokenType, _accessToken.ToString()); + context.Response.Cookies.Append(ITokenContext.RefreshTokenType, _refreshToken.ToString()); + var validation = new UserPasswordValidation { + Password = CreateDummyUser().Password + }; + + // Act + var result = await auth.Delete(validation); + + // Assert + Assert.False(result.IsSuccessful); + Assert.Equal(HttpStatusCode.Forbidden, result.State); + Assert.Equal(_accessToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + Assert.Equal(_refreshToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType)); + } + +} \ No newline at end of file diff --git a/tests/HopFrame.Tests.Api/Extensions/HttpContextExtensions.cs b/tests/HopFrame.Tests.Api/Extensions/HttpContextExtensions.cs new file mode 100644 index 0000000..8f4de69 --- /dev/null +++ b/tests/HopFrame.Tests.Api/Extensions/HttpContextExtensions.cs @@ -0,0 +1,29 @@ +using System.Web; +using Microsoft.AspNetCore.Http; + +namespace HopFrame.Tests.Api.Extensions; + +internal static class HttpContextExtensions { + /// Extracts the partial cookie value from the header section. + /// + /// The key for identifying the cookie. + /// The value of the cookie. + public static string FindCookie(this IHeaderDictionary headers, string key) + { + string headerKey = $"{key}="; + var cookies = headers.Values + .SelectMany(h => h) + .Where(header => header.StartsWith(headerKey)) + .Select(header => header.Substring(headerKey.Length).Split(';').First()) + .ToArray(); + + //Note: cookie values in a header are encoded like a uri parameter value. + var value = cookies.LastOrDefault();//and the last set value, is the relevant one. + if (string.IsNullOrEmpty(value)) + return null; + + //That's why we should decode that last value, before we return it. + var decoded = HttpUtility.UrlDecode(value); + return decoded; + } +} \ No newline at end of file diff --git a/tests/HopFrame.Tests.Api/HopFrame.Tests.Api.csproj b/tests/HopFrame.Tests.Api/HopFrame.Tests.Api.csproj new file mode 100644 index 0000000..6c7f59f --- /dev/null +++ b/tests/HopFrame.Tests.Api/HopFrame.Tests.Api.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + enable + disable + + false + true + + + + + + + + + + + + + + + + + + + diff --git a/tests/HopFrame.Tests.Database/Data/DatabaseContext.cs b/tests/HopFrame.Tests.Database/Data/DatabaseContext.cs new file mode 100644 index 0000000..271628f --- /dev/null +++ b/tests/HopFrame.Tests.Database/Data/DatabaseContext.cs @@ -0,0 +1,12 @@ +using HopFrame.Database; +using Microsoft.EntityFrameworkCore; + +namespace HopFrame.Tests.Database.Data; + +public class DatabaseContext : HopDbContextBase { + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { + base.OnConfiguring(optionsBuilder); + + optionsBuilder.UseInMemoryDatabase(Guid.NewGuid().ToString()); + } +} \ No newline at end of file diff --git a/tests/HopFrame.Tests.Database/HopFrame.Tests.Database.csproj b/tests/HopFrame.Tests.Database/HopFrame.Tests.Database.csproj new file mode 100644 index 0000000..ce93d67 --- /dev/null +++ b/tests/HopFrame.Tests.Database/HopFrame.Tests.Database.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + diff --git a/tests/HopFrame.Tests.Database/PermissionValidatorTests.cs b/tests/HopFrame.Tests.Database/PermissionValidatorTests.cs new file mode 100644 index 0000000..693f093 --- /dev/null +++ b/tests/HopFrame.Tests.Database/PermissionValidatorTests.cs @@ -0,0 +1,59 @@ +using HopFrame.Database; + +namespace HopFrame.Tests.Database; + +public class PermissionValidatorTests { + + [Fact] + public void IncludesPermission_Returns_True_For_ExactPermission() { + // Arrange + var permissions = new [] { "test.permission", "test.permission.exact" }; + var permission = "test.permission.exact"; + + // Act + var hasPermission = PermissionValidator.IncludesPermission(permission, permissions); + + // Assert + Assert.True(hasPermission); + } + + [Fact] + public void IncludesPermission_Returns_True_For_GroupPermission() { + // Arrange + var permissions = new [] { "test.permission.*", "test.permission.exact" }; + var permission = "test.permission.exact.other"; + + // Act + var hasPermission = PermissionValidator.IncludesPermission(permission, permissions); + + // Assert + Assert.True(hasPermission); + } + + [Fact] + public void IncludesPermission_Returns_True_For_StarPermission() { + // Arrange + var permissions = new [] { "test.permission", "test.permission.exact", "*" }; + var permission = "test.permission.exact.other"; + + // Act + var hasPermission = PermissionValidator.IncludesPermission(permission, permissions); + + // Assert + Assert.True(hasPermission); + } + + [Fact] + public void IncludesPermission_Returns_False() { + // Arrange + var permissions = new [] { "test.permission", "test.permission.exact" }; + var permission = "test.permission.exact.other"; + + // Act + var hasPermission = PermissionValidator.IncludesPermission(permission, permissions); + + // Assert + Assert.False(hasPermission); + } + +} \ No newline at end of file diff --git a/tests/HopFrame.Tests.Database/Repositories/GroupRepositoryTests.cs b/tests/HopFrame.Tests.Database/Repositories/GroupRepositoryTests.cs new file mode 100644 index 0000000..e3fd4ff --- /dev/null +++ b/tests/HopFrame.Tests.Database/Repositories/GroupRepositoryTests.cs @@ -0,0 +1,152 @@ +using HopFrame.Database.Models; +using HopFrame.Database.Repositories; +using HopFrame.Database.Repositories.Implementation; +using HopFrame.Tests.Database.Data; + +namespace HopFrame.Tests.Database.Repositories; + +public class GroupRepositoryTests { + + private async Task<(DatabaseContext, IGroupRepository)> SetupEnvironment(int count = 5) { + var context = new DatabaseContext(); + var repo = new GroupRepository(context); + + for (int i = 0; i < count; i++) { + await context.Groups.AddAsync(new() { + Name = Guid.NewGuid().ToString() + }); + } + await context.SaveChangesAsync(); + + return (context, repo); + } + + [Fact] + public async Task GetPermissionGroups_Returns_AllPermissionGroups() { + // Arrange + var count = 5; + var (context, repo) = await SetupEnvironment(count); + + // Act + var groups = await repo.GetPermissionGroups(); + + // Assert + Assert.Equal(count, groups.Count); + } + + [Fact] + public async Task GetDefaultGroups_Returns_OnlyDefaultGroups() { + // Arrange + var (context, repo) = await SetupEnvironment(); + await context.Groups.AddAsync(new () { + Name = Guid.NewGuid().ToString(), + IsDefaultGroup = true + }); + await context.SaveChangesAsync(); + + // Act + var groups = await repo.GetDefaultGroups(); + + // Assert + Assert.Single(groups); + Assert.True(groups[0].IsDefaultGroup); + } + + [Fact] + public async Task GetUserGroups_Returns_OnlyUserGroups() { + // Arrange + var (context, repo) = await SetupEnvironment(); + await context.Groups.AddAsync(new () { + Name = "group.user_should_have_these", + }); + var user = new User { + Id = Guid.NewGuid(), + Email = "", + Username = "", + Password = "" + }; + user.Permissions = new() { + new() { + User = user, + PermissionName = "group.user_should_have_these" + } + }; + await context.Users.AddAsync(user); + await context.SaveChangesAsync(); + + // Act + var groups = await repo.GetUserGroups(user); + + // Assert + Assert.Single(groups); + Assert.Equal("group.user_should_have_these", groups[0].Name); + } + + [Fact] + public async Task GetPermissionGroup_Returns_OnlyOnePermissionGroup() { + // Arrange + var (context, repo) = await SetupEnvironment(); + await context.Groups.AddAsync(new () { + Name = "group.return" + }); + await context.SaveChangesAsync(); + + // Act + var group = await repo.GetPermissionGroup("group.return"); + + // Assert + Assert.NotNull(group); + Assert.Equal("group.return", group.Name); + } + + [Fact] + public async Task EditPermissionGroup_Should_EditOnePermissionGroup() { + // Arrange + var (context, repo) = await SetupEnvironment(); + var groupToEdit = new PermissionGroup { + Name = "group.edit" + }; + await context.Groups.AddAsync(groupToEdit); + await context.SaveChangesAsync(); + + // Act + groupToEdit.Description = "This description was edited"; + await repo.EditPermissionGroup(groupToEdit); + + // Assert + var group = context.Groups.SingleOrDefault(g => g.Name == "group.edit"); + Assert.NotNull(group); + Assert.Equal("This description was edited", group.Description); + } + + [Fact] + public async Task CreatePermissionGroup_Should_AddOnePermissionGroup() { + // Arrange + var (context, repo) = await SetupEnvironment(0); + var group = new PermissionGroup { + Name = "group", + Description = "Group to add" + }; + + // Act + var result = await repo.CreatePermissionGroup(group); + + // Assert + Assert.Equal(group, result); + Assert.Single(context.Groups); + } + + [Fact] + public async Task DeletePermissionGroup_Should_DeleteOnePermissionGroup() { + // Arrange + var count = 5; + var (context, repo) = await SetupEnvironment(count); + + // Act + await repo.DeletePermissionGroup(context.Groups.First()); + + // Assert + Assert.Equal(count - 1, context.Groups.Count()); + } + +} \ No newline at end of file diff --git a/tests/HopFrame.Tests.Database/Repositories/PermissionRepositoryTests.cs b/tests/HopFrame.Tests.Database/Repositories/PermissionRepositoryTests.cs new file mode 100644 index 0000000..79fff4c --- /dev/null +++ b/tests/HopFrame.Tests.Database/Repositories/PermissionRepositoryTests.cs @@ -0,0 +1,118 @@ +using HopFrame.Database.Models; +using HopFrame.Database.Repositories; +using HopFrame.Database.Repositories.Implementation; +using HopFrame.Tests.Database.Data; +using Microsoft.EntityFrameworkCore; + +namespace HopFrame.Tests.Database.Repositories; + +public class PermissionRepositoryTests { + + private async Task<(DatabaseContext, IPermissionRepository)> SetupEnvironment(int count = 5) { + var context = new DatabaseContext(); + var repo = new PermissionRepository(context, new GroupRepository(context)); + + for (int i = 0; i < count; i++) { + await context.Permissions.AddAsync(new () { + PermissionName = Guid.NewGuid().ToString(), + User = CreateTestUser() + }); + } + await context.SaveChangesAsync(); + + return (context, repo); + } + + private User CreateTestUser() => new () { + Username = "", + Email = "", + Password = "" + }; + + [Fact] + public async Task HasPermission_Returns_True() { + // Arrange + var (context, repo) = await SetupEnvironment(); + var user = CreateTestUser(); + await context.Permissions.AddAsync(new() { + PermissionName = "*", + User = user + }); + await context.SaveChangesAsync(); + + // Act + var hasPermission = await repo.HasPermission(user, "*"); + + // Assert + Assert.True(hasPermission); + } + + [Fact] + public async Task AddPermission_Should_AddOnePermission() { + // Arrange + var (context, repo) = await SetupEnvironment(); + var user = CreateTestUser(); + + // Act + var result = await repo.AddPermission(user, "test.permission"); + + // Assert + Assert.NotNull(result); + Assert.Single(context.Permissions + .Include(p => p.User) + .Where(p => p.User.Id == user.Id && p.PermissionName == "test.permission")); + } + + [Fact] + public async Task RemovePermission_Should_RemoveOnePermission() { + // Arrange + var (context, repo) = await SetupEnvironment(); + var user = CreateTestUser(); + await context.Permissions.AddAsync(new() { + PermissionName = "test.permission", + User = user + }); + await context.SaveChangesAsync(); + + // Act + await repo.RemovePermission(user, "test.permission"); + + // Assert + Assert.Empty(context.Permissions + .Include(p => p.User) + .Where(p => p.User.Id == user.Id && p.PermissionName == "test.permission")); + } + + [Fact] + public async Task GetFullPermissions_Return_AllPermissions_Including_Inherited() { + // Arrange + var (context, repo) = await SetupEnvironment(); + var user = CreateTestUser(); + var group = new PermissionGroup { + Name = "group.admin" + }; + await context.Permissions.AddRangeAsync(new List { + new() { + PermissionName = "test.permission.inherited", + Group = group + }, + new() { + PermissionName = "test.permission", + User = user + }, + new() { + PermissionName = "group.admin", + User = user + } + }); + await context.SaveChangesAsync(); + + // Act + var perms = await repo.GetFullPermissions(user); + + // Assert + Assert.NotNull(perms); + Assert.Equal(3, perms.Count); + } + +} \ No newline at end of file diff --git a/tests/HopFrame.Tests.Database/Repositories/TokenRepositoryTests.cs b/tests/HopFrame.Tests.Database/Repositories/TokenRepositoryTests.cs new file mode 100644 index 0000000..d37fde2 --- /dev/null +++ b/tests/HopFrame.Tests.Database/Repositories/TokenRepositoryTests.cs @@ -0,0 +1,89 @@ +using HopFrame.Database.Models; +using HopFrame.Database.Repositories; +using HopFrame.Database.Repositories.Implementation; +using HopFrame.Tests.Database.Data; +using Microsoft.EntityFrameworkCore; + +namespace HopFrame.Tests.Database.Repositories; + +public class TokenRepositoryTests { + + private async Task<(DatabaseContext, ITokenRepository)> SetupEnvironment(int count = 5) { + var context = new DatabaseContext(); + var repo = new TokenRepository(context); + + for (int i = 0; i < count; i++) { + await context.Tokens.AddAsync(new() { + TokenId = Guid.NewGuid(), + Owner = CreateTestUser(), + Type = Token.AccessTokenType + }); + } + await context.SaveChangesAsync(); + + return (context, repo); + } + + private User CreateTestUser() => new () { + Username = "", + Email = "", + Password = "" + }; + + [Fact] + public async Task GetToken_Return_CorrectToken() { + // Arrange + var (context, repo) = await SetupEnvironment(); + var token = context.Tokens.First(); + + // Act + var result = await repo.GetToken(token.TokenId.ToString()); + + // Assert + Assert.Equal(token, result); + } + + [Fact] + public async Task CreateToken_Should_CreateOneToken() { + // Arrange + var (context, repo) = await SetupEnvironment(0); + + // Act + var result = await repo.CreateToken(Token.AccessTokenType, CreateTestUser()); + + // Assert + Assert.NotEmpty(context.Tokens); + Assert.Equal(result, context.Tokens.First()); + } + + [Fact] + public async Task DeleteUserTokens_Should_DeleteOnlyUserTokens() { + // Arrange + var dummyCount = 5; + var (context, repo) = await SetupEnvironment(dummyCount); + var user = CreateTestUser(); + await context.Tokens.AddRangeAsync(new List { + new() { + TokenId = Guid.NewGuid(), + Owner = user, + Type = Token.AccessTokenType + }, + new() { + TokenId = Guid.NewGuid(), + Owner = user, + Type = Token.RefreshTokenType + } + }); + await context.SaveChangesAsync(); + + // Act + await repo.DeleteUserTokens(user); + + // Assert + Assert.Equal(dummyCount, context.Tokens.Count()); + Assert.Empty(context.Tokens + .Include(t => t.Owner) + .Where(t => t.Owner == user)); + } + +} \ No newline at end of file diff --git a/tests/HopFrame.Tests.Database/Repositories/UserRepositoryTests.cs b/tests/HopFrame.Tests.Database/Repositories/UserRepositoryTests.cs new file mode 100644 index 0000000..5730064 --- /dev/null +++ b/tests/HopFrame.Tests.Database/Repositories/UserRepositoryTests.cs @@ -0,0 +1,184 @@ +using HopFrame.Database.Models; +using HopFrame.Database.Repositories; +using HopFrame.Database.Repositories.Implementation; +using HopFrame.Tests.Database.Data; + +namespace HopFrame.Tests.Database.Repositories; + +public class UserRepositoryTests { + + private async Task<(DatabaseContext, IUserRepository)> SetupEnvironment(int count = 5) { + var context = new DatabaseContext(); + var repo = new UserRepository(context, new GroupRepository(context)); + + for (int i = 0; i < count; i++) { + await context.Users.AddAsync(CreateTestUser()); + } + await context.SaveChangesAsync(); + + return (context, repo); + } + + private User CreateTestUser() => new () { + Username = "", + Email = "", + Password = "" + }; + + [Fact] + public async Task GetUsers_Returns_AllUsers() { + // Arrange + var count = 5; + var (context, repo) = await SetupEnvironment(count); + + // Act + var users = await repo.GetUsers(); + + // Assert + Assert.NotNull(users); + Assert.Equal(count, users.Count); + } + + [Fact] + public async Task GetUser_Returns_SingleUser() { + // Arrange + var (context, repo) = await SetupEnvironment(); + var guid = context.Users.First().Id; + + // Act + var user = await repo.GetUser(guid); + + // Assert + Assert.NotNull(user); + Assert.Equal(guid, user.Id); + } + + [Fact] + public async Task GetUserByMail_Returns_SingleUser() { + // Arrange + var (context, repo) = await SetupEnvironment(); + var user = CreateTestUser(); + user.Email = "test@example.com"; + await context.Users.AddAsync(user); + await context.SaveChangesAsync(); + + // Act + var result = await repo.GetUserByEmail("test@example.com"); + + // Assert + Assert.NotNull(result); + Assert.Equal(user, result); + } + + [Fact] + public async Task GetUserByUsername_Returns_SingleUser() { + // Arrange + var (context, repo) = await SetupEnvironment(); + var user = CreateTestUser(); + user.Username = "test.user"; + await context.Users.AddAsync(user); + await context.SaveChangesAsync(); + + // Act + var result = await repo.GetUserByUsername("test.user"); + + // Assert + Assert.NotNull(result); + Assert.Equal(user, result); + } + + [Fact] + public async Task AddUser_Returns_NewUser() { + // Arrange + var count = 5; + var (context, repo) = await SetupEnvironment(count); + + // Act + var user = await repo.AddUser(new User { + Username = "test.user", + Email = "test@example.com", + Password = "changeme" + }); + + // Assert + Assert.NotNull(user); + Assert.Equal(count + 1, context.Users.Count()); + } + + [Fact] + public async Task UpdateUser_Should_UpdateAUser() { + // Arrange + var (context, repo) = await SetupEnvironment(); + var user = context.Users.First(); + + // Act + user.Username = "test.user"; + await repo.UpdateUser(user); + + // Assert + var result = context.Users.SingleOrDefault(u => u.Username == "test.user"); + Assert.NotNull(result); + } + + [Fact] + public async Task DeleteUser_Should_DeleteSingleUser() { + // Arrange + var count = 5; + var (context, repo) = await SetupEnvironment(count); + var user = context.Users.First(); + + // Act + await repo.DeleteUser(user); + + // Assert + Assert.Equal(count - 1, context.Users.Count()); + Assert.DoesNotContain(user, context.Users); + } + + [Fact] + public async Task CheckUserPassword_Returns_True() { + // Arrange + var (context, repo) = await SetupEnvironment(0); + var user = CreateTestUser(); + user.Password = "changeme"; + user = await repo.AddUser(user); + + // Act + var result = await repo.CheckUserPassword(user, "changeme"); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task CheckUserPassword_Returns_False() { + // Arrange + var (context, repo) = await SetupEnvironment(0); + var user = CreateTestUser(); + user.Password = "changeme"; + user = await repo.AddUser(user); + + // Act + var result = await repo.CheckUserPassword(user, "dontchangeme"); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task ChangePassword_Should_ChangeUserPassword() { + // Arrange + var (context, repo) = await SetupEnvironment(0); + var user = CreateTestUser(); + user.Password = "changeme"; + user = await repo.AddUser(user); + + // Act + await repo.ChangePassword(user, "changedme"); + + // Assert + var result = await repo.CheckUserPassword(user, "changedme"); + Assert.True(result); + } + +} \ No newline at end of file diff --git a/tests/HopFrame.Tests.Security/AuthenticationTests.cs b/tests/HopFrame.Tests.Security/AuthenticationTests.cs new file mode 100644 index 0000000..3791f29 --- /dev/null +++ b/tests/HopFrame.Tests.Security/AuthenticationTests.cs @@ -0,0 +1,153 @@ +using System.Text.Encodings.Web; +using HopFrame.Database.Models; +using HopFrame.Database.Repositories; +using HopFrame.Security.Authentication; +using HopFrame.Security.Authentication.OpenID; +using HopFrame.Security.Authentication.OpenID.Options; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; + +namespace HopFrame.Tests.Security; + +public class AuthenticationTests { + + private async Task SetupEnvironment(Token correctToken = null, string providedToken = null) { + var options = new Mock>(); + options + .Setup(x => x.Get(It.IsAny())) + .Returns(new AuthenticationSchemeOptions()); + + var logger = new Mock(); + logger + .Setup(x => x.CreateLogger(It.IsAny())) + .Returns(new Mock>().Object); + + var encoder = new Mock(); + var clock = new Mock(); + var tokens = new Mock(); + var perms = new Mock(); + + var provideCorrectToken = correctToken is null; + correctToken ??= new Token { + TokenId = Guid.NewGuid(), + CreatedAt = DateTime.Now, + Type = Token.AccessTokenType, + Owner = new User { + Id = Guid.NewGuid() + } + }; + + tokens + .Setup(x => x.GetToken(It.Is(t => t == correctToken.TokenId.ToString()))) + .ReturnsAsync(correctToken); + + perms + .Setup(x => x.GetFullPermissions(It.IsAny())) + .ReturnsAsync(new List()); + + var auth = new HopFrameAuthentication( + options.Object, + logger.Object, + encoder.Object, + clock.Object, + tokens.Object, + perms.Object, + new OptionsWrapper(new HopFrameAuthenticationOptions()), + new OptionsWrapper(new OpenIdOptions()), + new Mock().Object, + new Mock().Object); + var context = new DefaultHttpContext(); + if (provideCorrectToken) + context.HttpContext.Request.Headers.Append(HopFrameAuthentication.SchemeName, correctToken.TokenId.ToString()); + if (providedToken is not null) + context.HttpContext.Request.Headers.Append(HopFrameAuthentication.SchemeName, providedToken); + + await auth.InitializeAsync(new AuthenticationScheme(HopFrameAuthentication.SchemeName, null, typeof(HopFrameAuthentication)), context); + return auth; + } + + [Fact] + public async Task Authentication_Should_Succeed() { + // Arrange + var auth = await SetupEnvironment(); + + // Act + var result = await auth.AuthenticateAsync(); + + // Assert + Assert.True(result.Succeeded); + } + + [Fact] + public async Task Authentication_With_NoToken_Should_Fail() { + // Arrange + var auth = await SetupEnvironment(new Token()); + + // Act + var result = await auth.AuthenticateAsync(); + + // Assert + Assert.False(result.Succeeded); + Assert.NotNull(result.Failure); + Assert.Equal("No Access Token provided", result.Failure.Message); + } + + [Fact] + public async Task Authentication_With_InvalidToken_Should_Fail() { + // Arrange + var auth = await SetupEnvironment(null, Guid.NewGuid().ToString()); + + // Act + var result = await auth.AuthenticateAsync(); + + // Assert + Assert.False(result.Succeeded); + Assert.NotNull(result.Failure); + Assert.Equal("The provided Access Token does not exist", result.Failure.Message); + } + + [Fact] + public async Task Authentication_With_ExpiredToken_Should_Fail() { + // Arrange + var token = new Token { + TokenId = Guid.NewGuid(), + CreatedAt = DateTime.MinValue, + Type = Token.AccessTokenType, + Owner = new User() + }; + var auth = await SetupEnvironment(token, token.TokenId.ToString()); + + // Act + var result = await auth.AuthenticateAsync(); + + // Assert + Assert.False(result.Succeeded); + Assert.NotNull(result.Failure); + Assert.Equal("The provided Access Token is expired", result.Failure.Message); + } + + [Fact] + public async Task Authentication_With_UnownedToken_Should_Fail() { + // Arrange + var token = new Token { + TokenId = Guid.NewGuid(), + CreatedAt = DateTime.Now, + Type = Token.AccessTokenType, + Owner = null + }; + var auth = await SetupEnvironment(token, token.TokenId.ToString()); + + // Act + var result = await auth.AuthenticateAsync(); + + // Assert + Assert.False(result.Succeeded); + Assert.NotNull(result.Failure); + Assert.Equal("The provided Access Token does not match any user", result.Failure.Message); + } + + +} \ No newline at end of file diff --git a/tests/HopFrame.Tests.Security/AuthorizationTests.cs b/tests/HopFrame.Tests.Security/AuthorizationTests.cs new file mode 100644 index 0000000..98032fb --- /dev/null +++ b/tests/HopFrame.Tests.Security/AuthorizationTests.cs @@ -0,0 +1,92 @@ +using System.Security.Claims; +using HopFrame.Security.Authentication; +using HopFrame.Security.Authorization; +using HopFrame.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Routing; +using Moq; + +namespace HopFrame.Tests.Security; + +public class AuthorizationTests { + + private (AuthorizedFilter, AuthorizationFilterContext) SetupEnvironment(string[] userPermissions, string[] requiredPermissions, bool accessTokenProvided = true) { + var filter = new AuthorizedFilter(requiredPermissions); + + var httpContext = new DefaultHttpContext(); + var actionContext = new ActionContext { HttpContext = httpContext, RouteData = new RouteData(), ActionDescriptor = new ActionDescriptor() }; + var context = new Mock(MockBehavior.Default, actionContext, new List()); + + context + .Setup(x => x.Filters) + .Returns(new List()); + + context.SetupProperty(c => c.Result); + + var claims = new List { + new(HopFrameClaimTypes.UserId, Guid.NewGuid().ToString()) + }; + if (accessTokenProvided) + claims.Add(new (HopFrameClaimTypes.AccessTokenId, Guid.NewGuid().ToString())); + claims.AddRange(userPermissions.Select(perm => new Claim(HopFrameClaimTypes.Permission, perm))); + + context.Object.HttpContext.User.AddIdentity(new ClaimsIdentity(claims, HopFrameAuthentication.SchemeName)); + + return (filter, context.Object); + } + + [Fact] + public void OnAuthorization_Should_Succeed() { + // Arrange + var (filter, context) = SetupEnvironment(["test.permission"], ["test.permission"]); + + // Act + filter.OnAuthorization(context); + + // Assert + Assert.Null(context.Result); + } + + [Fact] + public void OnAuthorization_With_NoToken_Should_Fail() { + // Arrange + var (filter, context) = SetupEnvironment([], [], false); + + // Act + filter.OnAuthorization(context); + + // Assert + Assert.NotNull(context.Result); + Assert.IsType(context.Result); + } + + [Fact] + public void OnAuthorization_With_NoPermissions_Should_Fail() { + // Arrange + var (filter, context) = SetupEnvironment([], ["test.permission"]); + + // Act + filter.OnAuthorization(context); + + // Assert + Assert.NotNull(context.Result); + Assert.IsType(context.Result); + } + + [Fact] + public void OnAuthorization_With_InsufficientPermissions_Should_Fail() { + // Arrange + var (filter, context) = SetupEnvironment(["permission.other"], ["test.permission"]); + + // Act + filter.OnAuthorization(context); + + // Assert + Assert.NotNull(context.Result); + Assert.IsType(context.Result); + } + +} \ No newline at end of file diff --git a/tests/HopFrame.Tests.Security/HopFrame.Tests.Security.csproj b/tests/HopFrame.Tests.Security/HopFrame.Tests.Security.csproj new file mode 100644 index 0000000..c1feef2 --- /dev/null +++ b/tests/HopFrame.Tests.Security/HopFrame.Tests.Security.csproj @@ -0,0 +1,34 @@ + + + + net8.0 + enable + disable + + false + true + + + + + + + + + + + + + + + + + ..\..\..\..\..\..\..\Program Files\dotnet\shared\Microsoft.AspNetCore.App\8.0.11\Microsoft.AspNetCore.Authentication.dll + + + + + + + + diff --git a/tests/HopFrame.Tests.Web/AuthMiddlewareTests.cs b/tests/HopFrame.Tests.Web/AuthMiddlewareTests.cs new file mode 100644 index 0000000..685e588 --- /dev/null +++ b/tests/HopFrame.Tests.Web/AuthMiddlewareTests.cs @@ -0,0 +1,95 @@ +using System.Security.Claims; +using HopFrame.Database.Models; +using HopFrame.Database.Repositories; +using HopFrame.Security.Claims; +using HopFrame.Web; +using HopFrame.Web.Services; +using Microsoft.AspNetCore.Http; +using Moq; + +namespace HopFrame.Tests.Web; + +public class AuthMiddlewareTests { + private readonly RequestDelegate _delegate = _ => Task.CompletedTask; + + public AuthMiddleware SetupEnvironment(bool isLoggedIn = true, Token newToken = null) { + var auth = new Mock(); + auth + .Setup(a => a.IsLoggedIn()) + .ReturnsAsync(isLoggedIn); + auth + .Setup(a => a.RefreshLogin()) + .ReturnsAsync(newToken); + + var perms = new Mock(); + perms + .Setup(p => p.GetFullPermissions(It.Is(u => newToken.Owner.Id == u.Owner.Id))) + .ReturnsAsync(CreateDummyUser().Permissions.Select(p => p.PermissionName).ToList); + + return new AuthMiddleware(auth.Object, perms.Object); + } + + private User CreateDummyUser() => new() { + Id = Guid.NewGuid(), + CreatedAt = DateTime.Now, + Email = "test@example.com", + Username = "ExampleUser", + Password = "1234567890", + Permissions = new List { + new () { + PermissionName = "test.permission" + } + } + }; + + [Fact] + public async Task InvokeAsync_With_ValidLogin_Should_Succeed() { + // Arrange + var auth = SetupEnvironment(); + var context = new DefaultHttpContext(); + + // Act + await auth.InvokeAsync(context, _delegate); + + // Assert + Assert.Null(context.User.FindFirst(HopFrameClaimTypes.UserId)); + Assert.Null(context.User.FindFirst(HopFrameClaimTypes.AccessTokenId)); + Assert.Null(context.User.FindFirst(HopFrameClaimTypes.Permission)); + } + + [Fact] + public async Task InvokeAsync_With_InvalidLoginValidToken_Should_Succeed() { + // Arrange + var token = new Token { + TokenId = Guid.NewGuid(), + CreatedAt = DateTime.Now, + Type = Token.AccessTokenType, + Owner = CreateDummyUser() + }; + var auth = SetupEnvironment(false, token); + var context = new DefaultHttpContext(); + + // Act + await auth.InvokeAsync(context, _delegate); + + // Assert + Assert.Equal(token.Owner.Id.ToString(), context.User.FindFirstValue(HopFrameClaimTypes.UserId)); + Assert.Equal(token.TokenId.ToString(), context.User.FindFirstValue(HopFrameClaimTypes.AccessTokenId)); + Assert.Equal(token.Owner.Permissions.First().PermissionName, context.User.FindFirstValue(HopFrameClaimTypes.Permission)); + } + + [Fact] + public async Task InvokeAsync_With_InvalidLoginInvalidToken_Should_Succeed() { + // Arrange + var auth = SetupEnvironment(false); + var context = new DefaultHttpContext(); + + // Act + await auth.InvokeAsync(context, _delegate); + + // Assert + Assert.Null(context.User.FindFirst(HopFrameClaimTypes.UserId)); + Assert.Null(context.User.FindFirst(HopFrameClaimTypes.AccessTokenId)); + Assert.Null(context.User.FindFirst(HopFrameClaimTypes.Permission)); + } +} \ No newline at end of file diff --git a/tests/HopFrame.Tests.Web/AuthServiceTests.cs b/tests/HopFrame.Tests.Web/AuthServiceTests.cs new file mode 100644 index 0000000..56c0604 --- /dev/null +++ b/tests/HopFrame.Tests.Web/AuthServiceTests.cs @@ -0,0 +1,347 @@ +using HopFrame.Database.Models; +using HopFrame.Database.Repositories; +using HopFrame.Security.Authentication; +using HopFrame.Security.Authentication.OpenID; +using HopFrame.Security.Authentication.OpenID.Options; +using HopFrame.Security.Claims; +using HopFrame.Security.Models; +using HopFrame.Tests.Web.Extensions; +using HopFrame.Web.Services; +using HopFrame.Web.Services.Implementation; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using Moq; + +namespace HopFrame.Tests.Web; + +public class AuthServiceTests { + private readonly Guid _refreshToken = Guid.NewGuid(); + private readonly Guid _accessToken = Guid.NewGuid(); + + private (IAuthService, HttpContext) SetupEnvironment(bool passwordIsCorrect = true, Token providedRefreshToken = null, string providedTokenCookie = null, Token providedAccessToken = null) { + var accessor = new HttpContextAccessor { + HttpContext = new DefaultHttpContext() + }; + + if (providedTokenCookie != null) { + var cookies = new Mock(); + cookies + .SetupGet(c => c[ITokenContext.RefreshTokenType]) + .Returns(providedTokenCookie); + accessor.HttpContext.Request.Cookies = cookies.Object; + } + + var users = new Mock(); + users + .Setup(u => u.GetUserByEmail(It.Is(email => CreateDummyUser().Email == email))) + .ReturnsAsync(CreateDummyUser()); + users + .Setup(u => u.CheckUserPassword(It.Is(u => u.Email == CreateDummyUser().Email), It.IsAny())) + .ReturnsAsync(passwordIsCorrect); + users + .Setup(u => u.AddUser(It.IsAny())) + .ReturnsAsync(CreateDummyUser()); + users + .Setup(u => u.GetUsers()) + .ReturnsAsync(new List { CreateDummyUser() }); + + var tokens = new Mock(); + tokens + .Setup(t => t.CreateToken(It.Is(t => t == Token.RefreshTokenType), It.IsAny())) + .ReturnsAsync(new Token { + TokenId = _refreshToken, + Type = Token.RefreshTokenType + }); + tokens + .Setup(t => t.CreateToken(It.Is(t => t == Token.AccessTokenType), It.IsAny())) + .ReturnsAsync(new Token { + TokenId = _accessToken, + Type = Token.AccessTokenType + }); + tokens + .Setup(t => t.GetToken(It.Is(token => token == _refreshToken.ToString()))) + .ReturnsAsync(providedRefreshToken); + + var context = new Mock(); + context + .Setup(c => c.User) + .Returns(CreateDummyUser()); + context + .Setup(c => c.AccessToken) + .Returns(providedAccessToken); + + return (new AuthService( + users.Object, + accessor, + tokens.Object, + context.Object, + new OptionsWrapper(new HopFrameAuthenticationOptions()), + new OptionsWrapper(new OpenIdOptions()), + new Mock().Object, + users.Object + ), accessor.HttpContext); + } + + private User CreateDummyUser() => new() { + Id = Guid.NewGuid(), + CreatedAt = DateTime.Now, + Email = "test@example.com", + Username = "ExampleUser", + Password = "1234567890" + }; + + [Fact] + public async Task Register_Should_Succeed() { + // Arrange + var (service, context) = SetupEnvironment(); + var register = new UserRegister { + Email = CreateDummyUser().Email, + Username = CreateDummyUser().Username, + Password = CreateDummyUser().Password + }; + + // Act + await service.Register(register); + + // Assert + Assert.Equal(_accessToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + Assert.Equal(_refreshToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType)); + } + + [Fact] + public async Task Login_Should_Succeed() { + // Arrange + var (service, context) = SetupEnvironment(); + var login = new UserLogin { + Email = CreateDummyUser().Email, + Password = CreateDummyUser().Password + }; + + // Act + var result = await service.Login(login); + + // Assert + Assert.True(result); + Assert.Equal(_accessToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + Assert.Equal(_refreshToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType)); + } + + [Fact] + public async Task Login_With_WrongPassword_Should_Fail() { + // Arrange + var (service, context) = SetupEnvironment(false); + var login = new UserLogin { + Email = CreateDummyUser().Email, + Password = CreateDummyUser().Password + }; + + // Act + var result = await service.Login(login); + + // Assert + Assert.False(result); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType)); + } + + [Fact] + public async Task Login_With_WrongEmail_Should_Fail() { + // Arrange + var (service, context) = SetupEnvironment(); + var login = new UserLogin { + Email = "wrong@example.com", + Password = CreateDummyUser().Password + }; + + // Act + var result = await service.Login(login); + + // Assert + Assert.False(result); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType)); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + } + + [Fact] + public async Task Logout_Should_Succeed() { + // Arrange + var (service, context) = SetupEnvironment(providedTokenCookie: _refreshToken.ToString()); + context.Response.Cookies.Append(ITokenContext.AccessTokenType, _accessToken.ToString()); + context.Response.Cookies.Append(ITokenContext.RefreshTokenType, _refreshToken.ToString()); + + // Act + await service.Logout(); + + // Assert + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType)); + } + + [Fact] + public async Task RefreshLogin_Should_Succeed() { + // Arrange + var token = new Token { + Type = Token.RefreshTokenType, + TokenId = _refreshToken, + CreatedAt = DateTime.Now, + Owner = CreateDummyUser() + }; + var (service, context) = SetupEnvironment(true, token, token.TokenId.ToString()); + + // Act + var result = await service.RefreshLogin(); + + // Assert + Assert.NotNull(result); + Assert.Equal(_accessToken, result.TokenId); + Assert.Equal(_accessToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + } + + [Fact] + public async Task RefreshLogin_With_NoProvidedToken_Should_Fail() { + // Arrange + var (service, context) = SetupEnvironment(); + + // Act + var result = await service.RefreshLogin(); + + // Assert + Assert.Null(result); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + } + + [Fact] + public async Task RefreshLogin_With_WrongToken_Should_Fail() { + // Arrange + var (service, context) = SetupEnvironment(true, null, _refreshToken.ToString()); + + // Act + var result = await service.RefreshLogin(); + + // Assert + Assert.Null(result); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + } + + [Fact] + public async Task RefreshLogin_With_WrongTokenType_Should_Fail() { + // Arrange + var token = new Token { + Type = Token.AccessTokenType, + TokenId = _refreshToken, + CreatedAt = DateTime.Now, + Owner = CreateDummyUser() + }; + var (service, context) = SetupEnvironment(true, token, token.TokenId.ToString()); + + // Act + var result = await service.RefreshLogin(); + + // Assert + Assert.Null(result); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + } + + [Fact] + public async Task RefreshLogin_With_ExpiredToken_Should_Fail() { + // Arrange + var token = new Token { + Type = Token.RefreshTokenType, + TokenId = _refreshToken, + CreatedAt = DateTime.MinValue, + Owner = CreateDummyUser() + }; + var (service, context) = SetupEnvironment(true, token, token.TokenId.ToString()); + + // Act + var result = await service.RefreshLogin(); + + // Assert + Assert.Null(result); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + } + + [Fact] + public async Task IsLoggedIn_Should_Succeed() { + // Arrange + var token = new Token { + Type = Token.AccessTokenType, + TokenId = _accessToken, + CreatedAt = DateTime.Now, + Owner = CreateDummyUser() + }; + var (service, context) = SetupEnvironment(providedAccessToken: token); + + // Act + var result = await service.IsLoggedIn(); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task IsLoggedIn_With_NoProvidedToken_Should_Fail() { + // Arrange + var (service, context) = SetupEnvironment(); + + // Act + var result = await service.IsLoggedIn(); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task IsLoggedIn_With_WrongTokenType_Should_Fail() { + // Arrange + var token = new Token { + Type = Token.RefreshTokenType, + TokenId = _accessToken, + CreatedAt = DateTime.Now, + Owner = CreateDummyUser() + }; + var (service, context) = SetupEnvironment(providedAccessToken: token); + + // Act + var result = await service.IsLoggedIn(); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task IsLoggedIn_With_ExpiredToken_Should_Fail() { + // Arrange + var token = new Token { + Type = Token.AccessTokenType, + TokenId = _accessToken, + CreatedAt = DateTime.MinValue, + Owner = CreateDummyUser() + }; + var (service, context) = SetupEnvironment(providedAccessToken: token); + + // Act + var result = await service.IsLoggedIn(); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task IsLoggedIn_With_NoOwner_Should_Fail() { + // Arrange + var token = new Token { + Type = Token.AccessTokenType, + TokenId = _accessToken, + CreatedAt = DateTime.Now, + Owner = null + }; + var (service, context) = SetupEnvironment(providedAccessToken: token); + + // Act + var result = await service.IsLoggedIn(); + + // Assert + Assert.False(result); + } +} \ No newline at end of file diff --git a/tests/HopFrame.Tests.Web/Extensions/HttpContextExtensions.cs b/tests/HopFrame.Tests.Web/Extensions/HttpContextExtensions.cs new file mode 100644 index 0000000..a26537a --- /dev/null +++ b/tests/HopFrame.Tests.Web/Extensions/HttpContextExtensions.cs @@ -0,0 +1,29 @@ +using System.Web; +using Microsoft.AspNetCore.Http; + +namespace HopFrame.Tests.Web.Extensions; + +internal static class HttpContextExtensions { + /// Extracts the partial cookie value from the header section. + /// + /// The key for identifying the cookie. + /// The value of the cookie. + public static string FindCookie(this IHeaderDictionary headers, string key) + { + string headerKey = $"{key}="; + var cookies = headers.Values + .SelectMany(h => h) + .Where(header => header.StartsWith(headerKey)) + .Select(header => header.Substring(headerKey.Length).Split(';').First()) + .ToArray(); + + //Note: cookie values in a header are encoded like a uri parameter value. + var value = cookies.LastOrDefault();//and the last set value, is the relevant one. + if (string.IsNullOrEmpty(value)) + return null; + + //That's why we should decode that last value, before we return it. + var decoded = HttpUtility.UrlDecode(value); + return decoded; + } +} \ No newline at end of file diff --git a/tests/HopFrame.Tests.Web/HopFrame.Tests.Web.csproj b/tests/HopFrame.Tests.Web/HopFrame.Tests.Web.csproj new file mode 100644 index 0000000..b6e4aa4 --- /dev/null +++ b/tests/HopFrame.Tests.Web/HopFrame.Tests.Web.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + disable + + false + true + + + + + + + + + + + + + + + + + + + + diff --git a/tests/HopFrame.Tests.Web/Pages/AdminLoginTests.cs b/tests/HopFrame.Tests.Web/Pages/AdminLoginTests.cs new file mode 100644 index 0000000..0e2909a --- /dev/null +++ b/tests/HopFrame.Tests.Web/Pages/AdminLoginTests.cs @@ -0,0 +1,123 @@ +using BlazorStrap; +using Bunit; +using Bunit.TestDoubles; +using CurrieTechnologies.Razor.SweetAlert2; +using HopFrame.Security.Models; +using HopFrame.Web.Pages.Administration; +using HopFrame.Web.Services; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace HopFrame.Tests.Web.Pages; + +public class AdminLoginTests : TestContext { + + private (IRenderedComponent, NavigationManager) SetupEnvironment(bool correctCredentials = true) { + var auth = new Mock(); + auth + .Setup(a => a.Login(It.IsAny())) + .ReturnsAsync(correctCredentials); + + Services.AddSweetAlert2(); + Services.AddBlazorStrap(); + Services.AddSingleton(auth.Object); + var navigator = Services.GetRequiredService(); + + var component = RenderComponent(); + return (component, navigator); + } + + [Fact] + public void Login_Has_RequiredFields() { + // Arrange + var (component, _) = SetupEnvironment(); + + // Act + var inputs = component.FindAll("input"); + var buttons = component.FindAll("button"); + var form = component.FindAll("form"); + + // Assert + Assert.Equal(2, inputs.Count); + Assert.Single(buttons); + Assert.Single(form); + Assert.Equal("submit", buttons[0].Attributes.GetNamedItem("type")?.Value); + + foreach (var input in inputs) { + var attribute = input.Attributes.GetNamedItem("required"); + Assert.NotNull(attribute); + Assert.NotEqual("false", attribute?.Value); + } + } + + [Fact] + public void Login_With_CorrectCredentials_Should_Redirect() { + // Arrange + var (component, nav) = SetupEnvironment(); + var email = component.Find("""input[type="email"]"""); + var password = component.Find("""input[type="password"]"""); + var submit = component.Find("button"); + + // Act + email.Change("test@example.com"); + password.Change("1234567890"); + submit.Click(); + + // Assert + Assert.EndsWith("/administration", nav.Uri); + } + + [Fact] + public void Login_With_CorrectCredentials_And_CustomRedirect_Should_Redirect() { + // Arrange + var (component, nav) = SetupEnvironment(); + var email = component.Find("""input[type="email"]"""); + var password = component.Find("""input[type="password"]"""); + var submit = component.Find("button"); + + component.Instance.RedirectAfter = "testRedirect"; + + // Act + email.Change("test@example.com"); + password.Change("1234567890"); + submit.Click(); + + // Assert + Assert.EndsWith("/administration/testRedirect", nav.Uri); + } + + [Fact] + public void Login_With_IncorrectCredentials_Should_Fail() { + // Arrange + var (component, nav) = SetupEnvironment(false); + var email = component.Find("""input[type="email"]"""); + var password = component.Find("""input[type="password"]"""); + var submit = component.Find("button"); + + // Act + email.Change("test@example.com"); + password.Change("1234567890"); + submit.Click(); + + // Assert + Assert.False(nav.Uri.EndsWith("/administration")); + } + + [Fact] + public void Login_With_IncorrectCredentials_DisplaysError() { + // Arrange + var (component, _) = SetupEnvironment(false); + var email = component.Find("""input[type="email"]"""); + var password = component.Find("""input[type="password"]"""); + var submit = component.Find("button"); + + // Act + email.Change("test@example.com"); + password.Change("1234567890"); + submit.Click(); + + // Assert + Assert.Contains("Email or password does not match any account!", component.Markup); + } +} \ No newline at end of file diff --git a/tests/HopFrame.Tests.Web/Pages/AuthorizedViewTests.cs b/tests/HopFrame.Tests.Web/Pages/AuthorizedViewTests.cs new file mode 100644 index 0000000..35355fd --- /dev/null +++ b/tests/HopFrame.Tests.Web/Pages/AuthorizedViewTests.cs @@ -0,0 +1,134 @@ +using System.Security.Claims; +using Bunit; +using Bunit.TestDoubles; +using HopFrame.Security.Authentication; +using HopFrame.Security.Authentication.OpenID; +using HopFrame.Security.Claims; +using HopFrame.Web.Components; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace HopFrame.Tests.Web.Pages; + +public class AuthorizedViewTests : TestContext { + private readonly string _testRedirect = "testRedirect"; + private readonly string _testPermission = "test.permission"; + private readonly string _innerHtml = "

Inner Render

"; + + public NavigationManager SetupEnvironment(bool authenticated = true, params string[] userPermissions) { + var auth = new Mock(); + auth + .Setup(a => a.IsAuthenticated) + .Returns(authenticated); + + var context = new DefaultHttpContext(); + var claims = userPermissions?.Select(perm => new Claim(HopFrameClaimTypes.Permission, perm)).ToList(); + context.User.AddIdentity(new ClaimsIdentity(claims, HopFrameAuthentication.SchemeName)); + var accessor = new Mock(); + accessor + .Setup(a => a.HttpContext) + .Returns(context); + + Services.AddSingleton(auth.Object); + Services.AddSingleton(accessor.Object); + return Services.GetRequiredService(); + } + + [Fact] + public void AuthorizedView_With_NoValidLogin_And_Redirection_Should_Redirect() { + // Arrange + var navigator = SetupEnvironment(false); + + // Act + RenderComponent(parameters => parameters + .Add(a => a.RedirectIfUnauthorized, _testRedirect)); + + // Assert + Assert.EndsWith(_testRedirect, navigator.Uri); + } + + [Fact] + public void AuthorizedView_With_NoPermissions_And_Redirection_Should_Redirect() { + // Arrange + var navigator = SetupEnvironment(); + + // Act + RenderComponent(parameters => parameters + .Add(a => a.RedirectIfUnauthorized, _testRedirect) + .Add(a => a.Permission, _testPermission)); + + // Assert + Assert.EndsWith(_testRedirect, navigator.Uri); + } + + [Fact] + public void AuthorizedView_With_FewPermissions_And_Redirection_Should_Redirect() { + // Arrange + var navigator = SetupEnvironment(true, "other.permission"); + + // Act + RenderComponent(parameters => parameters + .Add(a => a.RedirectIfUnauthorized, _testRedirect) + .Add(a => a.Permissions, [_testPermission, "other.permission"])); + + // Assert + Assert.EndsWith(_testRedirect, navigator.Uri); + } + + [Fact] + public void AuthorizedView_With_Permissions_And_Redirection_Should_NotRedirect() { + // Arrange + var navigator = SetupEnvironment(true, _testPermission); + + // Act + RenderComponent(parameters => parameters + .Add(a => a.RedirectIfUnauthorized, _testRedirect) + .Add(a => a.Permission, _testPermission)); + + // Assert + Assert.False(navigator.Uri.EndsWith(_testRedirect)); + } + + [Fact] + public void AuthorizedView_With_AllPermissions_And_Redirection_Should_NotRedirect() { + // Arrange + var navigator = SetupEnvironment(true, _testPermission, "other.permission"); + + // Act + RenderComponent(parameters => parameters + .Add(a => a.RedirectIfUnauthorized, _testRedirect) + .Add(a => a.Permissions, [_testPermission, "other.permission"])); + + // Assert + Assert.False(navigator.Uri.EndsWith(_testRedirect)); + } + + [Fact] + public void AuthorizedView_With_ChildComponent_And_ValidLogin_Should_DisplayChildren() { + // Arrange + SetupEnvironment(); + + // Act + var component = RenderComponent(parameters => parameters + .AddChildContent(_innerHtml)); + + // Assert + Assert.Contains(_innerHtml, component.Markup); + } + + [Fact] + public void AuthorizedView_With_ChildComponent_And_InvalidLogin_Should_NotDisplayChildren() { + // Arrange + SetupEnvironment(false); + + // Act + var component = RenderComponent(parameters => parameters + .AddChildContent(_innerHtml)); + + // Assert + Assert.DoesNotContain(_innerHtml, component.Markup); + } + +} \ No newline at end of file