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>

View File

@@ -1,20 +1,17 @@
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 class OpenIdController(IOpenIdAccessor accessor) : 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);
var uri = await accessor.ConstructAuthUri(redirectAfter);
if (performRedirect == 1) {
return Redirect(uri);
@@ -29,22 +26,13 @@ public class OpenIdController(IOpenIdAccessor accessor, IOptions<OpenIdOptions>
return BadRequest("Authorization code is missing");
}
var token = await accessor.RequestToken(code, DefaultCallback);
var token = await accessor.RequestToken(code);
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
});
accessor.SetAuthenticationCookies(token);
if (string.IsNullOrEmpty(state)) {
return Ok(new SingleValueResult<string>(token.AccessToken));
@@ -65,19 +53,14 @@ public class OpenIdController(IOpenIdAccessor accessor, IOptions<OpenIdOptions>
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
});
accessor.SetAuthenticationCookies(token);
return Ok(new SingleValueResult<string>(token.AccessToken));
}
[HttpDelete("logout")]
public IActionResult Logout() {
Response.Cookies.Delete(ITokenContext.RefreshTokenType);
Response.Cookies.Delete(ITokenContext.AccessTokenType);
accessor.Logout();
return Ok();
}

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
using System.Text.Json;
using HopFrame.Security.Authentication.OpenID.Models;
using HopFrame.Security.Authentication.OpenID.Options;
using HopFrame.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
@@ -18,7 +19,7 @@ internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions<OpenIdO
}
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);
if (!response.IsSuccessStatusCode)
@@ -32,13 +33,13 @@ internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions<OpenIdO
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)) {
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 callback = options.Value.Callback ?? Path.Combine($"{protocol}://{accessor.HttpContext!.Request.Host.Value}", IOpenIdAccessor.DefaultCallback).Replace("\\", "/");
var configuration = await LoadConfiguration();
@@ -65,9 +66,9 @@ internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions<OpenIdO
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 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();
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());
}
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() {
Enabled = true,
TTL = new() {
Minutes = 10
Hours = 24
}
},
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.Web.Admin.Providers
@using HopFrame.Web.Components
@using HopFrame.Web.Models
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.Extensions.Options
@layout AdminLayout
<AuthorizedView Permission="@Options.Value.Dashboard" RedirectIfUnauthorized="/administration/login" />
<AuthorizedView Permission="@Options.Value.Dashboard" RedirectIfUnauthorized="@ConstructRedirectUri()" />
<PageTitle>Admin Dashboard</PageTitle>
@@ -38,11 +39,16 @@
@inject NavigationManager Navigator
@inject IAdminPagesProvider Pages
@inject IOptions<AdminPermissionOptions> Options
@inject HopFrameWebModuleConfig Config
@code {
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;
}
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.Web.Admin
@using HopFrame.Web.Components
@using HopFrame.Web.Models
<PageTitle>@_pageData.Title</PageTitle>
<AuthorizedView Permission="@_pageData.Permissions.Read" RedirectIfUnauthorized="@GenerateRedirectString()" />
@@ -107,6 +108,7 @@
@inject IPermissionRepository Permissions
@inject SweetAlertService Alerts
@inject NavigationManager Navigator
@inject HopFrameWebModuleConfig Config
@code {
[Parameter]
@@ -251,6 +253,6 @@
}
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.Security.Authentication;
using HopFrame.Web.Admin;
using HopFrame.Web.Models;
using HopFrame.Web.Services;
using HopFrame.Web.Services.Implementation;
using Microsoft.AspNetCore.Builder;
@@ -12,12 +13,13 @@ using Microsoft.Extensions.DependencyInjection;
namespace HopFrame.Web;
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.AddHopFrameRepositories<TDbContext>();
services.AddScoped<IAuthService, AuthService>();
services.AddTransient<AuthMiddleware>();
services.AddAdminContext<HopAdminContext>();
services.AddSingleton(config ?? new HopFrameWebModuleConfig());
// Component library's
services.AddSweetAlert2();

View File

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

View File

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

View File

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