Release/v2.1.0 #44

Merged
leon.hoppe merged 32 commits from release/v2.1.0 into main 2024-12-22 19:24:17 +01:00
15 changed files with 110 additions and 82 deletions
Showing only changes of commit bee771a30e - Show all commits

View File

@@ -1,4 +1,5 @@
<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>
@@ -6,6 +7,7 @@
<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_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;
@@ -74,6 +76,8 @@

View File

@@ -1,16 +0,0 @@
using System.Reflection;
using Microsoft.AspNetCore.Mvc.Controllers;
namespace HopFrame.Api.Controller;
public class HopFrameFeatureProvider(params Type[] controllerTypes) : ControllerFeatureProvider {
protected override bool IsController(TypeInfo typeInfo) {
if (typeInfo.Namespace != typeof(HopFrameFeatureProvider).Namespace)
return base.IsController(typeInfo);
if (controllerTypes.All(c => c.Name != typeInfo.Name))
return false;
return base.IsController(typeInfo);
}
}

View File

@@ -10,10 +10,11 @@ namespace HopFrame.Api.Controller;
[ApiController, Route("api/v1/openid")] [ApiController, Route("api/v1/openid")]
public class OpenIdController(IOpenIdAccessor accessor, IOptions<OpenIdOptions> options) : ControllerBase { public class OpenIdController(IOpenIdAccessor accessor, IOptions<OpenIdOptions> options) : ControllerBase {
public const string DefaultCallback = "api/v1/openid/callback";
[HttpGet("redirect")] [HttpGet("redirect")]
public async Task<IActionResult> RedirectToProvider([FromQuery] string redirectAfter, [FromQuery] int performRedirect = 1) { public async Task<IActionResult> RedirectToProvider([FromQuery] string redirectAfter, [FromQuery] int performRedirect = 1) {
var uri = await accessor.ConstructAuthUri(redirectAfter); var uri = await accessor.ConstructAuthUri(DefaultCallback, redirectAfter);
if (performRedirect == 1) { if (performRedirect == 1) {
return Redirect(uri); return Redirect(uri);
@@ -28,7 +29,11 @@ public class OpenIdController(IOpenIdAccessor accessor, IOptions<OpenIdOptions>
return BadRequest("Authorization code is missing"); return BadRequest("Authorization code is missing");
} }
var token = await accessor.RequestToken(code); var token = await accessor.RequestToken(code, DefaultCallback);
if (token is null) {
return Forbid("Authorization code is not valid");
}
Response.Cookies.Append(ITokenContext.AccessTokenType, token.AccessToken, new CookieOptions { Response.Cookies.Append(ITokenContext.AccessTokenType, token.AccessToken, new CookieOptions {
MaxAge = TimeSpan.FromSeconds(token.ExpiresIn), MaxAge = TimeSpan.FromSeconds(token.ExpiresIn),

View File

@@ -19,7 +19,10 @@ 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 {
var controllers = new List<Type> { typeof(AuthController) }; var controllers = new List<Type>();
if (configuration.GetValue<bool>("HopFrame:Authentication:DefaultAuthentication"))
controllers.Add(typeof(AuthController));
if (configuration.GetValue<bool>("HopFrame:Authentication:OpenID:Enabled")) if (configuration.GetValue<bool>("HopFrame:Authentication:OpenID:Enabled"))
controllers.Add(typeof(OpenIdController)); controllers.Add(typeof(OpenIdController));

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))

View File

@@ -38,7 +38,7 @@ public class HopFrameAuthentication(
var tokenEntry = await tokens.GetToken(accessToken); var tokenEntry = await tokens.GetToken(accessToken);
if (tokenEntry?.Type != Token.ApiTokenType && openIdOptions.Value.Enabled) { if (tokenEntry?.Type != Token.ApiTokenType && openIdOptions.Value.Enabled && !Guid.TryParse(accessToken, out _)) {
var result = await accessor.InspectToken(accessToken); var result = await accessor.InspectToken(accessToken);
if (result is null || !result.Active) if (result is null || !result.Active)
@@ -65,10 +65,13 @@ public class HopFrameAuthentication(
CreatedAt = DateTime.Now, CreatedAt = DateTime.Now,
Type = Token.OpenIdTokenType Type = Token.OpenIdTokenType
}; };
var identity = await GenerateClaims(token); var identity = await GenerateClaims(token, perms);
return AuthenticateResult.Success(new AuthenticationTicket(identity, Scheme.Name)); return AuthenticateResult.Success(new AuthenticationTicket(identity, Scheme.Name));
} }
if (!tokenOptions.Value.DefaultAuthentication)
return AuthenticateResult.Fail("HopFrame authentication scheme is disabled");
if (tokenEntry is null) return AuthenticateResult.Fail("The provided Access Token does not exist"); if (tokenEntry is null) return AuthenticateResult.Fail("The provided Access Token does not exist");
if (tokenEntry.Type == Token.ApiTokenType) { if (tokenEntry.Type == Token.ApiTokenType) {
@@ -78,11 +81,11 @@ 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); var principal = await GenerateClaims(tokenEntry, perms);
return AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name)); return AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name));
} }
private async Task<ClaimsPrincipal> GenerateClaims(Token token) { public static async Task<ClaimsPrincipal> GenerateClaims(Token token, IPermissionRepository perms) {
var claims = new List<Claim> { var claims = new List<Claim> {
new(HopFrameClaimTypes.AccessTokenId, token.TokenId.ToString()), new(HopFrameClaimTypes.AccessTokenId, token.TokenId.ToString()),
new(HopFrameClaimTypes.UserId, token.Owner.Id.ToString()) new(HopFrameClaimTypes.UserId, token.Owner.Id.ToString())

View File

@@ -8,6 +8,8 @@ public class HopFrameAuthenticationOptions : OptionsFromConfiguration {
public TimeSpan AccessTokenTime => AccessToken is null ? new(0, 0, 5, 0) : AccessToken.ConstructTimeSpan; 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 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; }

View File

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

View File

@@ -8,8 +8,6 @@ using Microsoft.Extensions.Options;
namespace HopFrame.Security.Authentication.OpenID.Implementation; namespace HopFrame.Security.Authentication.OpenID.Implementation;
internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions<OpenIdOptions> options, IHttpContextAccessor accessor, IMemoryCache cache) : IOpenIdAccessor { internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions<OpenIdOptions> options, IHttpContextAccessor accessor, IMemoryCache cache) : IOpenIdAccessor {
private const string DefaultCallbackEndpoint = "api/v1/openid/callback";
private const string ConfigurationCacheKey = "HopFrame:OpenID:Configuration"; private const string ConfigurationCacheKey = "HopFrame:OpenID:Configuration";
private const string AuthCodeCacheKey = "HopFrame:OpenID:Code:"; private const string AuthCodeCacheKey = "HopFrame:OpenID:Code:";
private const string TokenCacheKey = "HopFrame:OpenID:Token:"; private const string TokenCacheKey = "HopFrame:OpenID:Token:";
@@ -34,13 +32,13 @@ internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions<OpenIdO
return config; return config;
} }
public async Task<OpenIdToken> RequestToken(string code) { 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)) { if (options.Value.Cache.Enabled && options.Value.Cache.Auth.Enabled && cache.TryGetValue(AuthCodeCacheKey + code, out object cachedToken)) {
return cachedToken as OpenIdToken; return cachedToken as OpenIdToken;
} }
var protocol = accessor.HttpContext!.Request.IsHttps ? "https" : "http"; var protocol = accessor.HttpContext!.Request.IsHttps ? "https" : "http";
var callback = options.Value.Callback ?? $"{protocol}://{accessor.HttpContext!.Request.Host.Value}/{DefaultCallbackEndpoint}"; var callback = options.Value.Callback ?? $"{protocol}://{accessor.HttpContext!.Request.Host.Value}/{defaultCallback}";
var configuration = await LoadConfiguration(); var configuration = await LoadConfiguration();
@@ -67,9 +65,9 @@ internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions<OpenIdO
return token; return token;
} }
public async Task<string> ConstructAuthUri(string state = null) { public async Task<string> ConstructAuthUri(string defaultCallback, string state = null) {
var protocol = accessor.HttpContext!.Request.IsHttps ? "https" : "http"; var protocol = accessor.HttpContext!.Request.IsHttps ? "https" : "http";
var callback = options.Value.Callback ?? $"{protocol}://{accessor.HttpContext!.Request.Host.Value}/{DefaultCallbackEndpoint}"; var callback = options.Value.Callback ?? $"{protocol}://{accessor.HttpContext!.Request.Host.Value}/{defaultCallback}";
var configuration = await LoadConfiguration(); 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}"; return $"{configuration.AuthorizationEndpoint}?response_type=code&client_id={options.Value.ClientId}&redirect_uri={callback}&scope=openid%20profile%20email%20offline_access&state={state}";

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

@@ -1,28 +0,0 @@
using HopFrame.Security.Authentication.OpenID;
using Microsoft.AspNetCore.Mvc;
using HopFrame.Security.Authentication.OpenID.Models;
namespace HopFrame.Testing.Api.Controllers;
public class AuthController(IOpenIdAccessor accessor) : Controller {
[HttpGet("auth/callback")]
public async Task<ActionResult> Callback([FromQuery] string code, [FromQuery] string state) {
if (string.IsNullOrEmpty(code)) {
return BadRequest("Authorization code is missing");
}
var token = await accessor.RequestToken(code);
return Ok(token);
}
[HttpGet("auth")]
public async Task<ActionResult> Authenticate() {
return Redirect(await accessor.ConstructAuthUri());
}
[HttpGet("check")]
public async Task<ActionResult<OpenIdIntrospection>> Check([FromQuery] string token) {
return Ok(await accessor.InspectToken(token));
}
}

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

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