Resolve "OAuth" #42

Merged
leon.hoppe merged 4 commits from feature/openid into dev 2024-12-22 15:17:06 +01:00
31 changed files with 747 additions and 48 deletions

View File

@@ -1,10 +1,13 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation"> <wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAsyncValueTaskMethodBuilderT_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003F1e8feaadf5c3fa14d36ea2a638c432a2e1a47b7837d8b83d88303c5d9c15cf_003FAsyncValueTaskMethodBuilderT_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAuthenticationHandler_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fca451c12d69fe026a0e7e9b1a0ddbf4cf6f6b8316cb2aec7984a7241813f648_003FAuthenticationHandler_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAuthenticationHandler_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fca451c12d69fe026a0e7e9b1a0ddbf4cf6f6b8316cb2aec7984a7241813f648_003FAuthenticationHandler_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAuthenticationSchemeOptions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003F8525b7a9e58c77f532f1a88d4f2897e3c2baf316b9eb2c391b242a3885fcce6_003FAuthenticationSchemeOptions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAuthenticationSchemeOptions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003F8525b7a9e58c77f532f1a88d4f2897e3c2baf316b9eb2c391b242a3885fcce6_003FAuthenticationSchemeOptions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AEditContextDataAnnotationsExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fbc307cd57fb42fc4c7fb9795381958122734d3750f41b6c1735c7d132ecda70_003FEditContextDataAnnotationsExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AEditContextDataAnnotationsExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fbc307cd57fb42fc4c7fb9795381958122734d3750f41b6c1735c7d132ecda70_003FEditContextDataAnnotationsExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AExceptionDispatchInfo_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fbd1d5c50194fea68ff3559c160230b0ab50f5acf4ce3061bffd6d62958e2182_003FExceptionDispatchInfo_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AExceptionDispatchInfo_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fbd1d5c50194fea68ff3559c160230b0ab50f5acf4ce3061bffd6d62958e2182_003FExceptionDispatchInfo_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIHttpContextAccessor_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Ffe1b239a13ce466b829e79538c654ff54e938_003Fa5_003F4093f165_003FIHttpContextAccessor_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIHttpContextAccessor_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Ffe1b239a13ce466b829e79538c654ff54e938_003Fa5_003F4093f165_003FIHttpContextAccessor_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AList_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fb7208b3f72528d22781d25fde9a55271bdf2b5aade4f03b1324579a25493cd8_003FList_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AList_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fb7208b3f72528d22781d25fde9a55271bdf2b5aade4f03b1324579a25493cd8_003FList_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AOpenIdConnectConfiguration_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F2820ca73717f4b1ab2b3b74fd61961bd1d5b0_003F7e_003F8be6b109_003FOpenIdConnectConfiguration_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARedirectResult_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003F2261f0cc7e883c3c89b8e6c7ea509ac7c52d0629e68690ed216e1cc0c8ac_003FRedirectResult_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AThrowHelper_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003F2c8e7ca976f350cba9836d5565dac56b11e0b56656fa786460eb1395857a6fa_003FThrowHelper_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AThrowHelper_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003F2c8e7ca976f350cba9836d5565dac56b11e0b56656fa786460eb1395857a6fa_003FThrowHelper_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AValidationMessageStore_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003Ffc81648e473bb3cc818f71427c286ecddc3604d2f4c69c565205bb89e8b4ef4_003FValidationMessageStore_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AValidationMessageStore_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003Ffc81648e473bb3cc818f71427c286ecddc3604d2f4c69c565205bb89e8b4ef4_003FValidationMessageStore_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/Environment/AssemblyExplorer/XmlDocument/@EntryValue">&lt;AssemblyExplorer&gt;&#xD; <s:String x:Key="/Default/Environment/AssemblyExplorer/XmlDocument/@EntryValue">&lt;AssemblyExplorer&gt;&#xD;
@@ -71,6 +74,9 @@

View File

@@ -6,6 +6,8 @@ A simple backend management api for ASP.NET Core Web APIs
- [x] User authentication - [x] User authentication
- [x] Permission management - [x] Permission management
- [x] Generated frontend administration boards - [x] Generated frontend administration boards
- [x] API token support
- [x] OpenID authentication integration
# Usage # Usage
There are two different versions of HopFrame, either the Web API version or the full Blazor web version. There are two different versions of HopFrame, either the Web API version or the full Blazor web version.

View File

@@ -24,7 +24,9 @@ by configuring your configuration to load these.
> custom configurations / HopFrame services. > custom configurations / HopFrame services.
You can specify `Seconds`, `Minutes`, `Hours` and `Days` for either of the two token types. You can specify `Seconds`, `Minutes`, `Hours` and `Days` for either of the two token types.
These get combined to a single time span. These get combined to a single time span. You can also completely disable the default authentication
by setting the `DefaultAuthentication` to `false`. Note that you will no longer be able to login in any
way unless you enabled the [OpenID](./openid.md) authentication.
#### Configuration example #### Configuration example
```json ```json

120
docs/openid.md Normal file
View File

@@ -0,0 +1,120 @@
# OpenID Authentication
The HopFrame allows you to use an OpenID provider as your authentication provider for single sign on or better security
etc. To use it, you just simply need to configure it through the `appsettings.json` or environment variables.
>**Note**: The Blazor module has not yet implemented endpoints for the login process, but the middleware is correctly
> configured and the `IOpenIdAccessor` service is also provided for you to easily implement the endpoints yourself.
When you have enabled the integration, new endpoints will also be provided to perform the authentication.
simply use the swagger explorer to look up how the endpoints function. They're all under the subroute
`/api/v1/openid/`.
## Configure the HopFrame to use OpenID authentication
1. Create / Configure your OpenID provider:
- Save the ClientID and Client Secret from the provider, because you need it later.
- The default redirect uri looks something like this: `https://example.com/api/v1/openid/callback`.
- **Replace** the origin with the FQDN of your service.
- In order for the HopFrame to automatically renew expired access tokens you need to enable the `offline_access` scope.
- The integration also works without doing that, but then you need to reauthenticate every time your access token expires.
2. Configure the HopFrame integration:
>**Hint**: All of these configuration options can also be defined as environment variables. Use '__'
> to separate the namespaces like so: `HOPFRAME__AUTHENTICATION__OPENID__ENABLED=true`
- Add the following lines to your `appsettings.json`:
```json
"HopFrame": {
"Authentication": {
"OpenID": {
"Enabled": true,
"Issuer": "your-issuer",
"ClientId": "your-client-id",
"ClientSecret": "your-client-secret"
}
}
}
```
>**Hint**: If you are using Authentik, the issuer url looks something like this: `https://auth.example.com/application/o/application-name/`.
> Just replace the FQDN and application-name with your configured application.
- **Optional**: You can also disable the default authentication via the config:
```json
"HopFrame": {
"Authentication": {
"DefaultAuthentication": false
}
}
```
- **Optional**: By default, the HopFrame will cache the api responses to reduce api latency. This can also be configured in the config (the cache can also be completely disabled here):
```json
"HopFrame": {
"Authentication": {
"OpenID": {
"Cache": {
"Enabled": true,
"Configuration": {
"Hours": 5
},
"Auth": {
"Seconds": 90
},
"Inspection": {
"Minutes": 5
}
}
}
}
}
```
- **Optional**: You can also define your own callback endpoint like so (you also need to add / replace the endpoint in the provider settings):
```json
"HopFrame": {
"Authentication": {
"OpenID": {
"Callback": "https://example.com/auth/callback"
}
}
}
```
- **Optional**: You can also prevent new users from being created by disabling it in the config:
```json
"HopFrame": {
"Authentication": {
"OpenID": {
"GenerateUsers": false
}
}
}
```
## Use the abstraction to integrate OpenID yourself
The HopFrame has a service, that simplifies the communication with the OpenID provider called `IOpenIdAccessor`.
You can inject it like every other service in your application.
```csharp
public interface IOpenIdAccessor {
Task<OpenIdConfiguration> LoadConfiguration();
Task<OpenIdToken> RequestToken(string code, string defaultCallback);
Task<string> ConstructAuthUri(string defaultCallback, string state = null);
Task<OpenIdIntrospection> InspectToken(string token);
Task<OpenIdToken> RefreshAccessToken(string refreshToken);
}
```

View File

@@ -9,6 +9,7 @@ The HopFrame comes in two variations, you can eiter only use the backend with so
- [Base Models](./models.md) - [Base Models](./models.md)
- [Authentication](./authentication.md) - [Authentication](./authentication.md)
- [Permissions](./permissions.md) - [Permissions](./permissions.md)
- [OpenID Integration](./openid.md)
## HopFrame Web API ## HopFrame Web API

View File

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

View File

@@ -0,0 +1,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<OpenIdOptions> options) : ControllerBase {
public const string DefaultCallback = "api/v1/openid/callback";
[HttpGet("redirect")]
public async Task<IActionResult> RedirectToProvider([FromQuery] string redirectAfter, [FromQuery] int performRedirect = 1) {
var uri = await accessor.ConstructAuthUri(DefaultCallback, redirectAfter);
if (performRedirect == 1) {
return Redirect(uri);
}
return Ok(new SingleValueResult<string>(uri));
}
[HttpGet("callback")]
public async Task<IActionResult> Callback([FromQuery] string code, [FromQuery] string state) {
if (string.IsNullOrEmpty(code)) {
return BadRequest("Authorization code is missing");
}
var token = await accessor.RequestToken(code, 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<string>(token.AccessToken));
}
return Redirect(state.Replace("{token}", token.AccessToken));
}
[HttpGet("refresh")]
public async Task<IActionResult> Refresh() {
var refreshToken = Request.Cookies[ITokenContext.RefreshTokenType];
if (string.IsNullOrEmpty(refreshToken))
return BadRequest("Refresh token not provided");
var token = await accessor.RefreshAccessToken(refreshToken);
if (token is null)
return NotFound("Refresh token not valid");
Response.Cookies.Append(ITokenContext.AccessTokenType, token.AccessToken, new CookieOptions {
MaxAge = TimeSpan.FromSeconds(token.ExpiresIn),
HttpOnly = false,
Secure = true
});
return Ok(new SingleValueResult<string>(token.AccessToken));
}
[HttpDelete("logout")]
public IActionResult Logout() {
Response.Cookies.Delete(ITokenContext.RefreshTokenType);
Response.Cookies.Delete(ITokenContext.AccessTokenType);
return Ok();
}
}

View File

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

View File

@@ -19,8 +19,16 @@ public static class ServiceCollectionExtensions {
/// <param name="configuration">The configuration used to configure HopFrame authentication</param> /// <param name="configuration">The configuration used to configure HopFrame authentication</param>
/// <typeparam name="TDbContext">The data source for all HopFrame entities</typeparam> /// <typeparam name="TDbContext">The data source for all HopFrame entities</typeparam>
public static void AddHopFrame<TDbContext>(this IServiceCollection services, ConfigurationManager configuration) where TDbContext : HopDbContextBase { public static void AddHopFrame<TDbContext>(this IServiceCollection services, ConfigurationManager configuration) where TDbContext : HopDbContextBase {
services.AddMvcCore().UseSpecificControllers(typeof(SecurityController)); var controllers = new List<Type>();
if (configuration.GetValue<bool>("HopFrame:Authentication:DefaultAuthentication"))
controllers.Add(typeof(AuthController));
if (configuration.GetValue<bool>("HopFrame:Authentication:OpenID:Enabled"))
controllers.Add(typeof(OpenIdController));
AddHopFrameNoEndpoints<TDbContext>(services, configuration); AddHopFrameNoEndpoints<TDbContext>(services, configuration);
services.AddMvcCore().UseSpecificControllers(controllers.ToArray());
} }
/// <summary> /// <summary>
@@ -30,6 +38,11 @@ public static class ServiceCollectionExtensions {
/// <param name="configuration">The configuration used to configure HopFrame authentication</param> /// <param name="configuration">The configuration used to configure HopFrame authentication</param>
/// <typeparam name="TDbContext">The data source for all HopFrame entities</typeparam> /// <typeparam name="TDbContext">The data source for all HopFrame entities</typeparam>
public static void AddHopFrameNoEndpoints<TDbContext>(this IServiceCollection services, ConfigurationManager configuration) where TDbContext : HopDbContextBase { public static void AddHopFrameNoEndpoints<TDbContext>(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<TDbContext>(); services.AddHopFrameRepositories<TDbContext>();
services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>(); services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();
services.AddScoped<IAuthLogic, AuthLogic>(); services.AddScoped<IAuthLogic, AuthLogic>();

View File

@@ -12,6 +12,8 @@ namespace HopFrame.Api.Logic.Implementation;
internal class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenContext tokenContext, IHttpContextAccessor accessor, IOptions<HopFrameAuthenticationOptions> options) : IAuthLogic { internal class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenContext tokenContext, IHttpContextAccessor accessor, IOptions<HopFrameAuthenticationOptions> options) : IAuthLogic {
public async Task<LogicResult<SingleValueResult<string>>> Login(UserLogin login) { public async Task<LogicResult<SingleValueResult<string>>> Login(UserLogin login) {
if (!options.Value.DefaultAuthentication) return LogicResult<SingleValueResult<string>>.BadRequest("HopFrame authentication scheme is disabled");
var user = await users.GetUserByEmail(login.Email); var user = await users.GetUserByEmail(login.Email);
if (user is null) if (user is null)
@@ -38,6 +40,8 @@ internal class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenC
} }
public async Task<LogicResult<SingleValueResult<string>>> Register(UserRegister register) { public async Task<LogicResult<SingleValueResult<string>>> Register(UserRegister register) {
if (!options.Value.DefaultAuthentication) return LogicResult<SingleValueResult<string>>.BadRequest("HopFrame authentication scheme is disabled");
if (register.Password.Length < 8) if (register.Password.Length < 8)
return LogicResult<SingleValueResult<string>>.BadRequest("Password needs to be at least 8 characters long"); return LogicResult<SingleValueResult<string>>.BadRequest("Password needs to be at least 8 characters long");
@@ -69,6 +73,8 @@ internal class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenC
} }
public async Task<LogicResult<SingleValueResult<string>>> Authenticate() { public async Task<LogicResult<SingleValueResult<string>>> Authenticate() {
if (!options.Value.DefaultAuthentication) return LogicResult<SingleValueResult<string>>.BadRequest("HopFrame authentication scheme is disabled");
var refreshToken = accessor.HttpContext?.Request.Cookies[ITokenContext.RefreshTokenType]; var refreshToken = accessor.HttpContext?.Request.Cookies[ITokenContext.RefreshTokenType];
if (string.IsNullOrEmpty(refreshToken)) if (string.IsNullOrEmpty(refreshToken))
@@ -101,9 +107,7 @@ internal class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenC
var refreshToken = accessor.HttpContext?.Request.Cookies[ITokenContext.RefreshTokenType]; var refreshToken = accessor.HttpContext?.Request.Cookies[ITokenContext.RefreshTokenType];
if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(refreshToken)) if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(refreshToken))
return LogicResult.Conflict("access or refresh token not provided"); await tokens.DeleteUserTokens(tokenContext.User);
await tokens.DeleteUserTokens(tokenContext.User);
accessor.HttpContext?.Response.Cookies.Delete(ITokenContext.RefreshTokenType); accessor.HttpContext?.Response.Cookies.Delete(ITokenContext.RefreshTokenType);
accessor.HttpContext?.Response.Cookies.Delete(ITokenContext.AccessTokenType); accessor.HttpContext?.Response.Cookies.Delete(ITokenContext.AccessTokenType);

View File

@@ -8,6 +8,7 @@ public class Token : IPermissionOwner {
public const int RefreshTokenType = 0; public const int RefreshTokenType = 0;
public const int AccessTokenType = 1; public const int AccessTokenType = 1;
public const int ApiTokenType = 2; public const int ApiTokenType = 2;
public const int OpenIdTokenType = 3;
/// <summary> /// <summary>
/// Defines the Type of the stored Token /// Defines the Type of the stored Token

View File

@@ -1,5 +1,6 @@
using HopFrame.Database.Models; using HopFrame.Database.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace HopFrame.Database.Repositories.Implementation; namespace HopFrame.Database.Repositories.Implementation;

View File

@@ -2,6 +2,8 @@ using System.Security.Claims;
using System.Text.Encodings.Web; using System.Text.Encodings.Web;
using HopFrame.Database.Models; using HopFrame.Database.Models;
using HopFrame.Database.Repositories; using HopFrame.Database.Repositories;
using HopFrame.Security.Authentication.OpenID;
using HopFrame.Security.Authentication.OpenID.Options;
using HopFrame.Security.Claims; using HopFrame.Security.Claims;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -19,7 +21,10 @@ public class HopFrameAuthentication(
ISystemClock clock, ISystemClock clock,
ITokenRepository tokens, ITokenRepository tokens,
IPermissionRepository perms, IPermissionRepository perms,
IOptions<HopFrameAuthenticationOptions> tokenOptions) IOptions<HopFrameAuthenticationOptions> tokenOptions,
IOptions<OpenIdOptions> openIdOptions,
IUserRepository users,
IOpenIdAccessor accessor)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder, clock) { : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder, clock) {
public const string SchemeName = "HopFrame.Authentication"; public const string SchemeName = "HopFrame.Authentication";
@@ -30,8 +35,42 @@ public class HopFrameAuthentication(
if (string.IsNullOrEmpty(accessToken)) accessToken = Request.Headers["Token"]; if (string.IsNullOrEmpty(accessToken)) accessToken = Request.Headers["Token"];
if (string.IsNullOrEmpty(accessToken)) accessToken = Request.Query["token"]; if (string.IsNullOrEmpty(accessToken)) accessToken = Request.Query["token"];
if (string.IsNullOrEmpty(accessToken)) return AuthenticateResult.Fail("No Access Token provided"); if (string.IsNullOrEmpty(accessToken)) return AuthenticateResult.Fail("No Access Token provided");
var tokenEntry = await tokens.GetToken(accessToken); var tokenEntry = await tokens.GetToken(accessToken);
if (tokenEntry?.Type != Token.ApiTokenType && openIdOptions.Value.Enabled && !Guid.TryParse(accessToken, out _)) {
var result = await accessor.InspectToken(accessToken);
if (result is null || !result.Active)
return AuthenticateResult.Fail("Invalid OpenID Connect token");
var email = result.Email;
if (string.IsNullOrEmpty(email))
return AuthenticateResult.Fail("OpenID user has no email associated to it");
var user = await users.GetUserByEmail(email);
if (user is null) {
if (!openIdOptions.Value.GenerateUsers)
return AuthenticateResult.Fail("OpenID user does not exist");
var username = result.PreferredUsername;
user = await users.AddUser(new User {
Email = email,
Username = username
});
}
var token = new Token {
Owner = user,
CreatedAt = DateTime.Now,
Type = Token.OpenIdTokenType
};
var identity = await GenerateClaims(token, perms);
return AuthenticateResult.Success(new AuthenticationTicket(identity, Scheme.Name));
}
if (!tokenOptions.Value.DefaultAuthentication)
return AuthenticateResult.Fail("HopFrame authentication scheme is disabled");
if (tokenEntry is null) return AuthenticateResult.Fail("The provided Access Token does not exist"); if (tokenEntry is null) return AuthenticateResult.Fail("The provided Access Token does not exist");
@@ -42,17 +81,22 @@ public class HopFrameAuthentication(
if (tokenEntry.Owner is null) if (tokenEntry.Owner is null)
return AuthenticateResult.Fail("The provided Access Token does not match any user"); return AuthenticateResult.Fail("The provided Access Token does not match any user");
var principal = await GenerateClaims(tokenEntry, perms);
return AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name));
}
public static async Task<ClaimsPrincipal> GenerateClaims(Token token, IPermissionRepository perms) {
var claims = new List<Claim> { var claims = new List<Claim> {
new(HopFrameClaimTypes.AccessTokenId, accessToken), new(HopFrameClaimTypes.AccessTokenId, token.TokenId.ToString()),
new(HopFrameClaimTypes.UserId, tokenEntry.Owner.Id.ToString()) new(HopFrameClaimTypes.UserId, token.Owner.Id.ToString())
}; };
var permissions = await perms.GetFullPermissions(tokenEntry); var permissions = await perms.GetFullPermissions(token);
claims.AddRange(permissions.Select(perm => new Claim(HopFrameClaimTypes.Permission, perm))); claims.AddRange(permissions.Select(perm => new Claim(HopFrameClaimTypes.Permission, perm)));
var principal = new ClaimsPrincipal(); var principal = new ClaimsPrincipal();
principal.AddIdentity(new ClaimsIdentity(claims, SchemeName)); principal.AddIdentity(new ClaimsIdentity(claims, SchemeName));
return AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name)); return principal;
} }
} }

View File

@@ -1,3 +1,6 @@
using HopFrame.Security.Authentication.OpenID;
using HopFrame.Security.Authentication.OpenID.Implementation;
using HopFrame.Security.Authentication.OpenID.Options;
using HopFrame.Security.Authorization; using HopFrame.Security.Authorization;
using HopFrame.Security.Claims; using HopFrame.Security.Claims;
using HopFrame.Security.Options; using HopFrame.Security.Options;
@@ -20,8 +23,13 @@ public static class HopFrameAuthenticationExtensions {
service.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>(); service.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();
service.AddScoped<ITokenContext, TokenContextImplementor>(); service.AddScoped<ITokenContext, TokenContextImplementor>();
service.AddHttpClient();
service.AddMemoryCache();
service.AddScoped<IOpenIdAccessor, OpenIdAccessor>();
service.AddOptionsFromConfiguration<HopFrameAuthenticationOptions>(configuration); service.AddOptionsFromConfiguration<HopFrameAuthenticationOptions>(configuration);
service.AddOptionsFromConfiguration<AdminPermissionOptions>(configuration); service.AddOptionsFromConfiguration<AdminPermissionOptions>(configuration);
service.AddOptionsFromConfiguration<OpenIdOptions>(configuration);
service.AddAuthentication(HopFrameAuthentication.SchemeName).AddScheme<AuthenticationSchemeOptions, HopFrameAuthentication>(HopFrameAuthentication.SchemeName, _ => {}); service.AddAuthentication(HopFrameAuthentication.SchemeName).AddScheme<AuthenticationSchemeOptions, HopFrameAuthentication>(HopFrameAuthentication.SchemeName, _ => {});
service.AddAuthorization(); service.AddAuthorization();

View File

@@ -5,8 +5,10 @@ namespace HopFrame.Security.Authentication;
public class HopFrameAuthenticationOptions : OptionsFromConfiguration { public class HopFrameAuthenticationOptions : OptionsFromConfiguration {
public override string Position { get; } = "HopFrame:Authentication"; public override string Position { get; } = "HopFrame:Authentication";
public TimeSpan AccessTokenTime => AccessToken is null ? new(0, 0, 5, 0) : new(AccessToken.Days, AccessToken.Hours, AccessToken.Minutes, AccessToken.Seconds); public TimeSpan AccessTokenTime => AccessToken is null ? new(0, 0, 5, 0) : AccessToken.ConstructTimeSpan;
public TimeSpan RefreshTokenTime => RefreshToken is null ? new(30, 0, 0, 0) : new(RefreshToken.Days, RefreshToken.Hours, RefreshToken.Minutes, RefreshToken.Seconds); 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 AccessToken { get; set; }
public TokenTime RefreshToken { get; set; } public TokenTime RefreshToken { get; set; }
@@ -16,5 +18,7 @@ public class HopFrameAuthenticationOptions : OptionsFromConfiguration {
public int Hours { get; set; } public int Hours { get; set; }
public int Minutes { get; set; } public int Minutes { get; set; }
public int Seconds { get; set; } public int Seconds { get; set; }
public TimeSpan ConstructTimeSpan => new(Days, Hours, Minutes, Seconds);
} }
} }

View File

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

View File

@@ -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<OpenIdOptions> options, IHttpContextAccessor accessor, IMemoryCache cache) : IOpenIdAccessor {
private const string ConfigurationCacheKey = "HopFrame:OpenID:Configuration";
private const string AuthCodeCacheKey = "HopFrame:OpenID:Code:";
private const string TokenCacheKey = "HopFrame:OpenID:Token:";
public async Task<OpenIdConfiguration> LoadConfiguration() {
if (options.Value.Cache.Enabled && options.Value.Cache.Configuration.Enabled && cache.TryGetValue(ConfigurationCacheKey, out object cachedConfiguration)) {
return cachedConfiguration as OpenIdConfiguration;
}
var client = clientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Get, Path.Combine(options.Value.Issuer, ".well-known/openid-configuration"));
var response = await client.SendAsync(request);
if (!response.IsSuccessStatusCode)
return null;
var config = await JsonSerializer.DeserializeAsync<OpenIdConfiguration>(await response.Content.ReadAsStreamAsync());
if (options.Value.Cache.Enabled && options.Value.Cache.Configuration.Enabled)
cache.Set(ConfigurationCacheKey, config, options.Value.Cache.Configuration.TTL.ConstructTimeSpan);
return config;
}
public async Task<OpenIdToken> RequestToken(string code, 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<string, string> {
{ "grant_type", "authorization_code" },
{ "code", code },
{ "redirect_uri", callback },
{ "client_id", options.Value.ClientId },
{ "client_secret", options.Value.ClientSecret }
})
};
var response = await client.SendAsync(request);
if (!response.IsSuccessStatusCode)
return null;
var token = await JsonSerializer.DeserializeAsync<OpenIdToken>(await response.Content.ReadAsStreamAsync());
if (options.Value.Cache.Enabled && options.Value.Cache.Auth.Enabled)
cache.Set(AuthCodeCacheKey + code, token, options.Value.Cache.Auth.TTL.ConstructTimeSpan);
return token;
}
public async Task<string> ConstructAuthUri(string 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<OpenIdIntrospection> InspectToken(string token) {
if (options.Value.Cache.Enabled && options.Value.Cache.Inspection.Enabled && cache.TryGetValue(TokenCacheKey + token, out object cachedToken)) {
return cachedToken as OpenIdIntrospection;
}
var configuration = await LoadConfiguration();
var client = clientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, configuration.IntrospectionEndpoint) {
Content = new FormUrlEncodedContent(new Dictionary<string, string> {
{ "token", token },
{ "client_id", options.Value.ClientId },
{ "client_secret", options.Value.ClientSecret }
})
};
var response = await client.SendAsync(request);
if (!response.IsSuccessStatusCode)
return null;
var introspection = await JsonSerializer.DeserializeAsync<OpenIdIntrospection>(await response.Content.ReadAsStreamAsync());
if (options.Value.Cache.Enabled && options.Value.Cache.Inspection.Enabled)
cache.Set(TokenCacheKey + token, introspection, options.Value.Cache.Inspection.TTL.ConstructTimeSpan);
return introspection;
}
public async Task<OpenIdToken> RefreshAccessToken(string refreshToken) {
var configuration = await LoadConfiguration();
var client = clientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, configuration.TokenEndpoint) {
Content = new FormUrlEncodedContent(new Dictionary<string, string> {
{ "grant_type", "refresh_token" },
{ "refresh_token", refreshToken },
{ "client_id", options.Value.ClientId },
{ "client_secret", options.Value.ClientSecret }
})
};
var response = await client.SendAsync(request);
if (!response.IsSuccessStatusCode)
return null;
return await JsonSerializer.DeserializeAsync<OpenIdToken>(await response.Content.ReadAsStreamAsync());
}
}

View File

@@ -0,0 +1,68 @@
using System.Text.Json.Serialization;
namespace HopFrame.Security.Authentication.OpenID.Models;
public sealed class OpenIdConfiguration {
[JsonPropertyName("issuer")]
public string Issuer { get; set; }
[JsonPropertyName("authorization_endpoint")]
public string AuthorizationEndpoint { get; set; }
[JsonPropertyName("token_endpoint")]
public string TokenEndpoint { get; set; }
[JsonPropertyName("userinfo_endpoint")]
public string UserinfoEndpoint { get; set; }
[JsonPropertyName("end_session_endpoint")]
public string EndSessionEndpoint { get; set; }
[JsonPropertyName("introspection_endpoint")]
public string IntrospectionEndpoint { get; set; }
[JsonPropertyName("revocation_endpoint")]
public string RevocationEndpoint { get; set; }
[JsonPropertyName("device_authorization_endpoint")]
public string DeviceAuthorizationEndpoint { get; set; }
[JsonPropertyName("response_types_supported")]
public List<string> ResponseTypesSupported { get; set; }
[JsonPropertyName("response_modes_supported")]
public List<string> ResponseModesSupported { get; set; }
[JsonPropertyName("jwks_uri")]
public string JwksUri { get; set; }
[JsonPropertyName("grant_types_supported")]
public List<string> GrantTypesSupported { get; set; }
[JsonPropertyName("id_token_signing_alg_values_supported")]
public List<string> IdTokenSigningAlgValuesSupported { get; set; }
[JsonPropertyName("subject_types_supported")]
public List<string> SubjectTypesSupported { get; set; }
[JsonPropertyName("token_endpoint_auth_methods_supported")]
public List<string> TokenEndpointAuthMethodsSupported { get; set; }
[JsonPropertyName("acr_values_supported")]
public List<string> AcrValuesSupported { get; set; }
[JsonPropertyName("scopes_supported")]
public List<string> ScopesSupported { get; set; }
[JsonPropertyName("request_parameter_supported")]
public bool RequestParameterSupported { get; set; }
[JsonPropertyName("claims_supported")]
public List<string> ClaimsSupported { get; set; }
[JsonPropertyName("claims_parameter_supported")]
public bool ClaimsParameterSupported { get; set; }
[JsonPropertyName("code_challenge_methods_supported")]
public List<string> CodeChallengeMethodsSupported { get; set; }
}

View File

@@ -0,0 +1,62 @@
using System.Text.Json.Serialization;
namespace HopFrame.Security.Authentication.OpenID.Models;
public sealed class OpenIdIntrospection {
[JsonPropertyName("iss")]
public string Issuer { get; set; }
[JsonPropertyName("sub")]
public string Subject { get; set; }
[JsonPropertyName("aud")]
public string Audience { get; set; }
[JsonPropertyName("exp")]
public long Expiration { get; set; }
[JsonPropertyName("iat")]
public long IssuedAt { get; set; }
[JsonPropertyName("auth_time")]
public long AuthTime { get; set; }
[JsonPropertyName("acr")]
public string Acr { get; set; }
[JsonPropertyName("amr")]
public List<string> AuthenticationMethods { get; set; }
[JsonPropertyName("sid")]
public string SessionId { get; set; }
[JsonPropertyName("email")]
public string Email { get; set; }
[JsonPropertyName("email_verified")]
public bool EmailVerified { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
[JsonPropertyName("given_name")]
public string GivenName { get; set; }
[JsonPropertyName("preferred_username")]
public string PreferredUsername { get; set; }
[JsonPropertyName("nickname")]
public string Nickname { get; set; }
[JsonPropertyName("groups")]
public List<string> Groups { get; set; }
[JsonPropertyName("active")]
public bool Active { get; set; }
[JsonPropertyName("scope")]
public string Scope { get; set; }
[JsonPropertyName("client_id")]
public string ClientId { get; set; }
}

View File

@@ -0,0 +1,20 @@
using System.Text.Json.Serialization;
namespace HopFrame.Security.Authentication.OpenID.Models;
public sealed class OpenIdToken {
[JsonPropertyName("access_token")]
public string AccessToken { get; set; }
[JsonPropertyName("refresh_token")]
public string RefreshToken { get; set; }
[JsonPropertyName("token_type")]
public string TokenType { get; set; }
[JsonPropertyName("expires_in")]
public int ExpiresIn { get; set; }
[JsonPropertyName("id_token")]
public string IdToken { get; set; }
}

View File

@@ -0,0 +1,53 @@
using HopFrame.Security.Options;
namespace HopFrame.Security.Authentication.OpenID.Options;
public sealed class OpenIdOptions : OptionsFromConfiguration {
public override string Position { get; } = "HopFrame:Authentication:OpenID";
public bool Enabled { get; set; } = false;
public bool GenerateUsers { get; set; } = true;
public string Issuer { get; set; }
public string ClientId { get; set; }
public string ClientSecret { get; set; }
public string Callback { get; set; }
public HopFrameAuthenticationOptions.TokenTime RefreshToken { get; set; } = new() {
Days = 30
};
public CachingOptions Cache { get; set; } = new() {
Enabled = true,
Configuration = new() {
Enabled = true,
TTL = new() {
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; }
}
}

View File

@@ -21,6 +21,4 @@ public interface ITokenContext {
/// The access token the user provided /// The access token the user provided
/// </summary> /// </summary>
Token AccessToken { get; } Token AccessToken { get; }
IList<string> ContextualPermissions { get; }
} }

View File

@@ -1,15 +1,19 @@
using HopFrame.Database.Models; using HopFrame.Database.Models;
using HopFrame.Database.Repositories; using HopFrame.Database.Repositories;
using HopFrame.Security.Authentication.OpenID.Options;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
namespace HopFrame.Security.Claims; namespace HopFrame.Security.Claims;
internal sealed class TokenContextImplementor(IHttpContextAccessor accessor, IUserRepository users, ITokenRepository tokens, IPermissionRepository permissions) : ITokenContext { internal sealed class TokenContextImplementor(IHttpContextAccessor accessor, IUserRepository users, ITokenRepository tokens, IOptions<OpenIdOptions> options) : ITokenContext {
public bool IsAuthenticated => !string.IsNullOrEmpty(accessor.HttpContext?.User.GetAccessTokenId()); public bool IsAuthenticated => !string.IsNullOrEmpty(accessor.HttpContext?.User.GetAccessTokenId());
public User User => users.GetUser(Guid.Parse(accessor.HttpContext?.User.GetUserId() ?? Guid.Empty.ToString())).GetAwaiter().GetResult(); 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,
public IList<string> ContextualPermissions => permissions.GetFullPermissions(AccessToken).GetAwaiter().GetResult(); Type = Token.OpenIdTokenType,
CreatedAt = DateTime.Now
} : tokens.GetToken(accessor.HttpContext?.User.GetAccessTokenId()).GetAwaiter().GetResult();
} }

View File

@@ -1,7 +1,6 @@
using System.Security.Claims; using System.Security.Claims;
using HopFrame.Database.Repositories; using HopFrame.Database.Repositories;
using HopFrame.Security.Authentication; using HopFrame.Security.Authentication;
using HopFrame.Security.Claims;
using HopFrame.Web.Services; using HopFrame.Web.Services;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
@@ -20,16 +19,10 @@ public sealed class AuthMiddleware(IAuthService auth, IPermissionRepository perm
next?.Invoke(context); next?.Invoke(context);
return; return;
} }
var claims = new List<Claim> {
new(HopFrameClaimTypes.AccessTokenId, token.TokenId.ToString()),
new(HopFrameClaimTypes.UserId, token.Owner.Id.ToString())
};
var permissions = await perms.GetFullPermissions(token); var principal = await HopFrameAuthentication.GenerateClaims(token, perms);
claims.AddRange(permissions.Select(perm => new Claim(HopFrameClaimTypes.Permission, perm))); if (principal?.Identity is ClaimsIdentity identity)
context.User.AddIdentity(identity);
context.User.AddIdentity(new ClaimsIdentity(claims, HopFrameAuthentication.SchemeName));
} }
await next?.Invoke(context); await next?.Invoke(context);

View File

@@ -1,6 +1,8 @@
using HopFrame.Database.Models; using HopFrame.Database.Models;
using HopFrame.Database.Repositories; using HopFrame.Database.Repositories;
using HopFrame.Security.Authentication; using HopFrame.Security.Authentication;
using HopFrame.Security.Authentication.OpenID;
using HopFrame.Security.Authentication.OpenID.Options;
using HopFrame.Security.Claims; using HopFrame.Security.Claims;
using HopFrame.Security.Models; using HopFrame.Security.Models;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
@@ -13,10 +15,15 @@ internal class AuthService(
IHttpContextAccessor httpAccessor, IHttpContextAccessor httpAccessor,
ITokenRepository tokens, ITokenRepository tokens,
ITokenContext context, ITokenContext context,
IOptions<HopFrameAuthenticationOptions> options) IOptions<HopFrameAuthenticationOptions> options,
IOptions<OpenIdOptions> openIdOptions,
IOpenIdAccessor accessor,
IUserRepository users)
: IAuthService { : IAuthService {
public async Task Register(UserRegister register) { public async Task Register(UserRegister register) {
if (!options.Value.DefaultAuthentication) return;
var user = await userService.AddUser(new User { var user = await userService.AddUser(new User {
Username = register.Username, Username = register.Username,
Email = register.Email, Email = register.Email,
@@ -41,6 +48,8 @@ internal class AuthService(
} }
public async Task<bool> Login(UserLogin login) { public async Task<bool> Login(UserLogin login) {
if (!options.Value.DefaultAuthentication) return false;
var user = await userService.GetUserByEmail(login.Email); var user = await userService.GetUserByEmail(login.Email);
if (user == null) return false; if (user == null) return false;
@@ -75,6 +84,45 @@ internal class AuthService(
if (string.IsNullOrWhiteSpace(refreshToken)) return null; 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); var token = await tokens.GetToken(refreshToken);
if (token is null || token.Type != Token.RefreshTokenType) return null; if (token is null || token.Type != Token.RefreshTokenType) return null;
@@ -96,7 +144,7 @@ internal class AuthService(
var accessToken = context.AccessToken; var accessToken = context.AccessToken;
if (accessToken is null) return false; if (accessToken is null) return false;
if (accessToken.Type != Token.AccessTokenType) return false; if (accessToken.Type != Token.AccessTokenType && accessToken.Type != Token.OpenIdTokenType) return false;
if (accessToken.CreatedAt + options.Value.AccessTokenTime < DateTime.Now) return false; if (accessToken.CreatedAt + options.Value.AccessTokenTime < DateTime.Now) return false;
if (accessToken.Owner is null) return false; if (accessToken.Owner is null) return false;

View File

@@ -15,8 +15,8 @@ namespace HopFrame.Testing.Api.Controllers;
public class TestController(ITokenContext userContext, DatabaseContext context, ITokenRepository tokens, IPermissionRepository permissions) : ControllerBase { public class TestController(ITokenContext userContext, DatabaseContext context, ITokenRepository tokens, IPermissionRepository permissions) : ControllerBase {
[HttpGet("permissions"), Authorized] [HttpGet("permissions"), Authorized]
public ActionResult<IList<string>> Permissions() { public async Task<ActionResult<IList<string>>> Permissions() {
return new ActionResult<IList<string>>(userContext.ContextualPermissions); return new ActionResult<IList<string>>(await permissions.GetFullPermissions(userContext.AccessToken));
} }
[HttpGet("generate")] [HttpGet("generate")]
@@ -66,5 +66,11 @@ public class TestController(ITokenContext userContext, DatabaseContext context,
var token = await tokens.GetToken(tokenId); var token = await tokens.GetToken(tokenId);
await tokens.DeleteToken(token); await tokens.DeleteToken(token);
} }
[HttpGet("url")]
public async Task<ActionResult<SingleValueResult<string>>> GetUrl() {
var protocol = Request.IsHttps ? "https" : "http";
return Ok($"{protocol}://{Request.Host.Value}/auth/callback");
}
} }

View File

@@ -6,6 +6,7 @@ var builder = WebApplication.CreateBuilder(args);
// Add services to the container. // Add services to the container.
builder.Services.AddHttpClient();
builder.Services.AddControllers(); builder.Services.AddControllers();
builder.Services.AddHopFrame<DatabaseContext>(builder.Configuration); builder.Services.AddHopFrame<DatabaseContext>(builder.Configuration);
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle

View File

@@ -329,7 +329,7 @@ public class AuthLogicTests {
} }
[Fact] [Fact]
public async Task Logout_With_NoAccessToken_Should_Fail() { public async Task Logout_With_NoAccessToken_Should_Succeed() {
// Arrange // Arrange
var (auth, context) = SetupEnvironment(provideAccessToken: false); var (auth, context) = SetupEnvironment(provideAccessToken: false);
context.Response.Cookies.Append(ITokenContext.AccessTokenType, _accessToken.ToString()); context.Response.Cookies.Append(ITokenContext.AccessTokenType, _accessToken.ToString());
@@ -339,14 +339,13 @@ public class AuthLogicTests {
var result = await auth.Logout(); var result = await auth.Logout();
// Assert // Assert
Assert.False(result.IsSuccessful); Assert.True(result.IsSuccessful);
Assert.Equal(HttpStatusCode.Conflict, result.State); Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType));
Assert.Equal(_accessToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); Assert.Null(context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType));
Assert.Equal(_refreshToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType));
} }
[Fact] [Fact]
public async Task Logout_With_NoRefreshToken_Should_Fail() { public async Task Logout_With_NoRefreshToken_Should_Succeed() {
// Arrange // Arrange
var (auth, context) = SetupEnvironment(); var (auth, context) = SetupEnvironment();
context.Response.Cookies.Append(ITokenContext.AccessTokenType, _accessToken.ToString()); context.Response.Cookies.Append(ITokenContext.AccessTokenType, _accessToken.ToString());
@@ -356,10 +355,9 @@ public class AuthLogicTests {
var result = await auth.Logout(); var result = await auth.Logout();
// Assert // Assert
Assert.False(result.IsSuccessful); Assert.True(result.IsSuccessful);
Assert.Equal(HttpStatusCode.Conflict, result.State); Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType));
Assert.Equal(_accessToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); Assert.Null(context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType));
Assert.Equal(_refreshToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType));
} }
[Fact] [Fact]

View File

@@ -2,6 +2,8 @@ using System.Text.Encodings.Web;
using HopFrame.Database.Models; using HopFrame.Database.Models;
using HopFrame.Database.Repositories; using HopFrame.Database.Repositories;
using HopFrame.Security.Authentication; using HopFrame.Security.Authentication;
using HopFrame.Security.Authentication.OpenID;
using HopFrame.Security.Authentication.OpenID.Options;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -46,7 +48,17 @@ public class AuthenticationTests {
.Setup(x => x.GetFullPermissions(It.IsAny<Token>())) .Setup(x => x.GetFullPermissions(It.IsAny<Token>()))
.ReturnsAsync(new List<string>()); .ReturnsAsync(new List<string>());
var auth = new HopFrameAuthentication(options.Object, logger.Object, encoder.Object, clock.Object, tokens.Object, perms.Object, new OptionsWrapper<HopFrameAuthenticationOptions>(new HopFrameAuthenticationOptions())); var auth = new HopFrameAuthentication(
options.Object,
logger.Object,
encoder.Object,
clock.Object,
tokens.Object,
perms.Object,
new OptionsWrapper<HopFrameAuthenticationOptions>(new HopFrameAuthenticationOptions()),
new OptionsWrapper<OpenIdOptions>(new OpenIdOptions()),
new Mock<IUserRepository>().Object,
new Mock<IOpenIdAccessor>().Object);
var context = new DefaultHttpContext(); var context = new DefaultHttpContext();
if (provideCorrectToken) if (provideCorrectToken)
context.HttpContext.Request.Headers.Append(HopFrameAuthentication.SchemeName, correctToken.TokenId.ToString()); context.HttpContext.Request.Headers.Append(HopFrameAuthentication.SchemeName, correctToken.TokenId.ToString());

View File

@@ -1,6 +1,8 @@
using HopFrame.Database.Models; using HopFrame.Database.Models;
using HopFrame.Database.Repositories; using HopFrame.Database.Repositories;
using HopFrame.Security.Authentication; using HopFrame.Security.Authentication;
using HopFrame.Security.Authentication.OpenID;
using HopFrame.Security.Authentication.OpenID.Options;
using HopFrame.Security.Claims; using HopFrame.Security.Claims;
using HopFrame.Security.Models; using HopFrame.Security.Models;
using HopFrame.Tests.Web.Extensions; using HopFrame.Tests.Web.Extensions;
@@ -68,7 +70,16 @@ public class AuthServiceTests {
.Setup(c => c.AccessToken) .Setup(c => c.AccessToken)
.Returns(providedAccessToken); .Returns(providedAccessToken);
return (new AuthService(users.Object, accessor, tokens.Object, context.Object, new OptionsWrapper<HopFrameAuthenticationOptions>(new HopFrameAuthenticationOptions())), accessor.HttpContext); return (new AuthService(
users.Object,
accessor,
tokens.Object,
context.Object,
new OptionsWrapper<HopFrameAuthenticationOptions>(new HopFrameAuthenticationOptions()),
new OptionsWrapper<OpenIdOptions>(new OpenIdOptions()),
new Mock<IOpenIdAccessor>().Object,
users.Object
), accessor.HttpContext);
} }
private User CreateDummyUser() => new() { private User CreateDummyUser() => new() {

View File

@@ -2,6 +2,7 @@ using System.Security.Claims;
using Bunit; using Bunit;
using Bunit.TestDoubles; using Bunit.TestDoubles;
using HopFrame.Security.Authentication; using HopFrame.Security.Authentication;
using HopFrame.Security.Authentication.OpenID;
using HopFrame.Security.Claims; using HopFrame.Security.Claims;
using HopFrame.Web.Components; using HopFrame.Web.Components;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;