8 Commits

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

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

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

View File

@@ -80,6 +80,7 @@
</wpf:ResourceDictionary> </wpf:ResourceDictionary>

View File

@@ -1,20 +1,17 @@
using HopFrame.Api.Models; using HopFrame.Api.Models;
using HopFrame.Security.Authentication.OpenID; using HopFrame.Security.Authentication.OpenID;
using HopFrame.Security.Authentication.OpenID.Options;
using HopFrame.Security.Claims; using HopFrame.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
namespace HopFrame.Api.Controller; 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) : ControllerBase {
public const string DefaultCallback = "api/v1/openid/callback"; 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(DefaultCallback, redirectAfter); var uri = await accessor.ConstructAuthUri(redirectAfter);
if (performRedirect == 1) { if (performRedirect == 1) {
return Redirect(uri); return Redirect(uri);
@@ -29,22 +26,13 @@ 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, DefaultCallback); var token = await accessor.RequestToken(code);
if (token is null) { if (token is null) {
return Forbid("Authorization code is not valid"); return Forbid("Authorization code is not valid");
} }
Response.Cookies.Append(ITokenContext.AccessTokenType, token.AccessToken, new CookieOptions { accessor.SetAuthenticationCookies(token);
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)) { if (string.IsNullOrEmpty(state)) {
return Ok(new SingleValueResult<string>(token.AccessToken)); return Ok(new SingleValueResult<string>(token.AccessToken));
@@ -65,19 +53,14 @@ public class OpenIdController(IOpenIdAccessor accessor, IOptions<OpenIdOptions>
if (token is null) if (token is null)
return NotFound("Refresh token not valid"); return NotFound("Refresh token not valid");
Response.Cookies.Append(ITokenContext.AccessTokenType, token.AccessToken, new CookieOptions { accessor.SetAuthenticationCookies(token);
MaxAge = TimeSpan.FromSeconds(token.ExpiresIn),
HttpOnly = false,
Secure = true
});
return Ok(new SingleValueResult<string>(token.AccessToken)); return Ok(new SingleValueResult<string>(token.AccessToken));
} }
[HttpDelete("logout")] [HttpDelete("logout")]
public IActionResult Logout() { public IActionResult Logout() {
Response.Cookies.Delete(ITokenContext.RefreshTokenType); accessor.Logout();
Response.Cookies.Delete(ITokenContext.AccessTokenType);
return Ok(); return Ok();
} }

View File

@@ -3,6 +3,7 @@ using HopFrame.Api.Logic;
using HopFrame.Api.Logic.Implementation; using HopFrame.Api.Logic.Implementation;
using HopFrame.Database; using HopFrame.Database;
using HopFrame.Security.Authentication; using HopFrame.Security.Authentication;
using HopFrame.Security.Authentication.OpenID;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -25,8 +26,10 @@ public static class ServiceCollectionExtensions {
if (!defaultAuthenticationSection.Exists() || configuration.GetValue<bool>("HopFrame:Authentication:DefaultAuthentication")) if (!defaultAuthenticationSection.Exists() || configuration.GetValue<bool>("HopFrame:Authentication:DefaultAuthentication"))
controllers.Add(typeof(AuthController)); controllers.Add(typeof(AuthController));
if (configuration.GetValue<bool>("HopFrame:Authentication:OpenID:Enabled")) if (configuration.GetValue<bool>("HopFrame:Authentication:OpenID:Enabled")) {
IOpenIdAccessor.DefaultCallback = OpenIdController.DefaultCallback;
controllers.Add(typeof(OpenIdController)); controllers.Add(typeof(OpenIdController));
}
AddHopFrameNoEndpoints<TDbContext>(services, configuration); AddHopFrameNoEndpoints<TDbContext>(services, configuration);
services.AddMvcCore().UseSpecificControllers(controllers.ToArray()); services.AddMvcCore().UseSpecificControllers(controllers.ToArray());

View File

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

View File

@@ -1,6 +1,7 @@
using System.Text.Json; using System.Text.Json;
using HopFrame.Security.Authentication.OpenID.Models; using HopFrame.Security.Authentication.OpenID.Models;
using HopFrame.Security.Authentication.OpenID.Options; using HopFrame.Security.Authentication.OpenID.Options;
using HopFrame.Security.Claims;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@@ -18,7 +19,7 @@ internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions<OpenIdO
} }
var client = clientFactory.CreateClient(); var client = clientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Get, Path.Combine(options.Value.Issuer, ".well-known/openid-configuration")); var request = new HttpRequestMessage(HttpMethod.Get, Path.Combine(options.Value.Issuer, ".well-known/openid-configuration").Replace("\\", "/"));
var response = await client.SendAsync(request); var response = await client.SendAsync(request);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
@@ -32,13 +33,13 @@ internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions<OpenIdO
return config; return config;
} }
public async Task<OpenIdToken> RequestToken(string code, string defaultCallback) { public async Task<OpenIdToken> RequestToken(string code) {
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}/{defaultCallback}"; var callback = options.Value.Callback ?? Path.Combine($"{protocol}://{accessor.HttpContext!.Request.Host.Value}", IOpenIdAccessor.DefaultCallback).Replace("\\", "/");
var configuration = await LoadConfiguration(); var configuration = await LoadConfiguration();
@@ -65,9 +66,9 @@ internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions<OpenIdO
return token; return token;
} }
public async Task<string> ConstructAuthUri(string defaultCallback, string state = null) { public async Task<string> ConstructAuthUri(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}/{defaultCallback}"; var callback = options.Value.Callback ?? Path.Combine($"{protocol}://{accessor.HttpContext!.Request.Host.Value}", IOpenIdAccessor.DefaultCallback).Replace("\\", "/");
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}";
@@ -120,4 +121,25 @@ internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions<OpenIdO
return await JsonSerializer.DeserializeAsync<OpenIdToken>(await response.Content.ReadAsStreamAsync()); return await JsonSerializer.DeserializeAsync<OpenIdToken>(await response.Content.ReadAsStreamAsync());
} }
public void SetAuthenticationCookies(OpenIdToken token) {
if (token.AccessToken is not null)
accessor.HttpContext!.Response.Cookies.Append(ITokenContext.AccessTokenType, token.AccessToken, new CookieOptions {
MaxAge = TimeSpan.FromSeconds(token.ExpiresIn),
HttpOnly = false,
Secure = true
});
if (token.RefreshToken is not null)
accessor.HttpContext!.Response.Cookies.Append(ITokenContext.RefreshTokenType, token.RefreshToken, new CookieOptions {
MaxAge = options.Value.RefreshToken.ConstructTimeSpan,
HttpOnly = false,
Secure = true
});
}
public void Logout() {
accessor.HttpContext!.Response.Cookies.Delete(ITokenContext.RefreshTokenType);
accessor.HttpContext!.Response.Cookies.Delete(ITokenContext.AccessTokenType);
}
} }

View File

@@ -22,7 +22,7 @@ public sealed class OpenIdOptions : OptionsFromConfiguration {
Configuration = new() { Configuration = new() {
Enabled = true, Enabled = true,
TTL = new() { TTL = new() {
Minutes = 10 Hours = 24
} }
}, },
Auth = new() { Auth = new() {

View File

@@ -0,0 +1,5 @@
namespace HopFrame.Web.Models;
public class HopFrameWebModuleConfig {
public string AdminLoginPageUri { get; set; } = "/administration/login";
}

View File

@@ -8,11 +8,12 @@
@using HopFrame.Security.Authorization @using HopFrame.Security.Authorization
@using HopFrame.Web.Admin.Providers @using HopFrame.Web.Admin.Providers
@using HopFrame.Web.Components @using HopFrame.Web.Components
@using HopFrame.Web.Models
@using Microsoft.AspNetCore.Components.Web @using Microsoft.AspNetCore.Components.Web
@using Microsoft.Extensions.Options @using Microsoft.Extensions.Options
@layout AdminLayout @layout AdminLayout
<AuthorizedView Permission="@Options.Value.Dashboard" RedirectIfUnauthorized="/administration/login" /> <AuthorizedView Permission="@Options.Value.Dashboard" RedirectIfUnauthorized="@ConstructRedirectUri()" />
<PageTitle>Admin Dashboard</PageTitle> <PageTitle>Admin Dashboard</PageTitle>
@@ -38,11 +39,16 @@
@inject NavigationManager Navigator @inject NavigationManager Navigator
@inject IAdminPagesProvider Pages @inject IAdminPagesProvider Pages
@inject IOptions<AdminPermissionOptions> Options @inject IOptions<AdminPermissionOptions> Options
@inject HopFrameWebModuleConfig Config
@code { @code {
public void NavigateTo(string url) { public void NavigateTo(string url) {
Navigator.NavigateTo("administration/" + url, true); Navigator.NavigateTo("/administration/" + url, true);
}
public string ConstructRedirectUri() {
return Config.AdminLoginPageUri + "?redirect=/administration";
} }
} }

View File

@@ -65,6 +65,6 @@
return; return;
} }
Navigator.NavigateTo(string.IsNullOrEmpty(RedirectAfter) ? DefaultRedirect : "/administration/" + RedirectAfter, true); Navigator.NavigateTo(string.IsNullOrEmpty(RedirectAfter) ? DefaultRedirect : RedirectAfter, true);
} }
} }

View File

@@ -16,6 +16,7 @@
@using HopFrame.Security.Claims @using HopFrame.Security.Claims
@using HopFrame.Web.Admin @using HopFrame.Web.Admin
@using HopFrame.Web.Components @using HopFrame.Web.Components
@using HopFrame.Web.Models
<PageTitle>@_pageData.Title</PageTitle> <PageTitle>@_pageData.Title</PageTitle>
<AuthorizedView Permission="@_pageData.Permissions.Read" RedirectIfUnauthorized="@GenerateRedirectString()" /> <AuthorizedView Permission="@_pageData.Permissions.Read" RedirectIfUnauthorized="@GenerateRedirectString()" />
@@ -107,6 +108,7 @@
@inject IPermissionRepository Permissions @inject IPermissionRepository Permissions
@inject SweetAlertService Alerts @inject SweetAlertService Alerts
@inject NavigationManager Navigator @inject NavigationManager Navigator
@inject HopFrameWebModuleConfig Config
@code { @code {
[Parameter] [Parameter]
@@ -251,6 +253,6 @@
} }
private string GenerateRedirectString() { private string GenerateRedirectString() {
return "/administration/login?redirect=" + _pageData?.Url; return Config.AdminLoginPageUri + "?redirect=/administration/" + _pageData?.Url;
} }
} }

View File

@@ -3,6 +3,7 @@ using CurrieTechnologies.Razor.SweetAlert2;
using HopFrame.Database; using HopFrame.Database;
using HopFrame.Security.Authentication; using HopFrame.Security.Authentication;
using HopFrame.Web.Admin; using HopFrame.Web.Admin;
using HopFrame.Web.Models;
using HopFrame.Web.Services; using HopFrame.Web.Services;
using HopFrame.Web.Services.Implementation; using HopFrame.Web.Services.Implementation;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
@@ -12,12 +13,13 @@ using Microsoft.Extensions.DependencyInjection;
namespace HopFrame.Web; namespace HopFrame.Web;
public static class ServiceCollectionExtensions { public static class ServiceCollectionExtensions {
public static IServiceCollection AddHopFrame<TDbContext>(this IServiceCollection services, ConfigurationManager configuration) where TDbContext : HopDbContextBase { public static IServiceCollection AddHopFrame<TDbContext>(this IServiceCollection services, ConfigurationManager configuration, HopFrameWebModuleConfig config = null) where TDbContext : HopDbContextBase {
services.AddHttpClient(); services.AddHttpClient();
services.AddHopFrameRepositories<TDbContext>(); services.AddHopFrameRepositories<TDbContext>();
services.AddScoped<IAuthService, AuthService>(); services.AddScoped<IAuthService, AuthService>();
services.AddTransient<AuthMiddleware>(); services.AddTransient<AuthMiddleware>();
services.AddAdminContext<HopAdminContext>(); services.AddAdminContext<HopAdminContext>();
services.AddSingleton(config ?? new HopFrameWebModuleConfig());
// Component library's // Component library's
services.AddSweetAlert2(); services.AddSweetAlert2();

View File

@@ -108,11 +108,7 @@ internal class AuthService(
}); });
} }
httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, openIdToken.AccessToken, new CookieOptions { accessor.SetAuthenticationCookies(openIdToken);
MaxAge = TimeSpan.FromSeconds(openIdToken.ExpiresIn),
HttpOnly = false,
Secure = true
});
return new() { return new() {
Owner = user, Owner = user,
CreatedAt = DateTime.Now, CreatedAt = DateTime.Now,

View File

@@ -2,6 +2,7 @@ using HopFrame.Api.Logic;
using HopFrame.Api.Models; using HopFrame.Api.Models;
using HopFrame.Database.Models; using HopFrame.Database.Models;
using HopFrame.Database.Repositories; using HopFrame.Database.Repositories;
using HopFrame.Security.Authentication.OpenID;
using HopFrame.Security.Authorization; using HopFrame.Security.Authorization;
using HopFrame.Security.Claims; using HopFrame.Security.Claims;
using HopFrame.Testing.Api.Models; using HopFrame.Testing.Api.Models;
@@ -68,9 +69,8 @@ public class TestController(ITokenContext userContext, DatabaseContext context,
} }
[HttpGet("url")] [HttpGet("url")]
public async Task<ActionResult<SingleValueResult<string>>> GetUrl() { public ActionResult<string> GetUrl() {
var protocol = Request.IsHttps ? "https" : "http"; return Ok(IOpenIdAccessor.DefaultCallback ?? "Not set");
return Ok($"{protocol}://{Request.Host.Value}/auth/callback");
} }
} }

View File

@@ -76,7 +76,7 @@ public class AdminLoginTests : TestContext {
var password = component.Find("""input[type="password"]"""); var password = component.Find("""input[type="password"]""");
var submit = component.Find("button"); var submit = component.Find("button");
component.Instance.RedirectAfter = "testRedirect"; component.Instance.RedirectAfter = "/administration/testRedirect";
// Act // Act
email.Change("test@example.com"); email.Change("test@example.com");