diff --git a/HopFrame.sln.DotSettings.user b/HopFrame.sln.DotSettings.user index 88abaf3..c0c134b 100644 --- a/HopFrame.sln.DotSettings.user +++ b/HopFrame.sln.DotSettings.user @@ -74,6 +74,7 @@ + \ No newline at end of file diff --git a/src/HopFrame.Api/Controller/SecurityController.cs b/src/HopFrame.Api/Controller/AuthController.cs similarity index 91% rename from src/HopFrame.Api/Controller/SecurityController.cs rename to src/HopFrame.Api/Controller/AuthController.cs index d9c1128..4cf4430 100644 --- a/src/HopFrame.Api/Controller/SecurityController.cs +++ b/src/HopFrame.Api/Controller/AuthController.cs @@ -7,8 +7,8 @@ using Microsoft.AspNetCore.Mvc; namespace HopFrame.Api.Controller; [ApiController] -[Route("api/v1/authentication")] -public class SecurityController(IAuthLogic auth) : ControllerBase { +[Route("api/v1/auth")] +public class AuthController(IAuthLogic auth) : ControllerBase { [HttpPut("login")] public async Task>> Login([FromBody] UserLogin login) { diff --git a/src/HopFrame.Api/Controller/HopFrameFeatureProvider.cs b/src/HopFrame.Api/Controller/HopFrameFeatureProvider.cs new file mode 100644 index 0000000..c8f6cba --- /dev/null +++ b/src/HopFrame.Api/Controller/HopFrameFeatureProvider.cs @@ -0,0 +1,16 @@ +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); + } +} \ No newline at end of file diff --git a/src/HopFrame.Api/Controller/OpenIdController.cs b/src/HopFrame.Api/Controller/OpenIdController.cs new file mode 100644 index 0000000..16f9d3a --- /dev/null +++ b/src/HopFrame.Api/Controller/OpenIdController.cs @@ -0,0 +1,79 @@ +using HopFrame.Api.Models; +using HopFrame.Security.Authentication.OpenID; +using HopFrame.Security.Authentication.OpenID.Options; +using HopFrame.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; + +namespace HopFrame.Api.Controller; + +[ApiController, Route("api/v1/openid")] +public class OpenIdController(IOpenIdAccessor accessor, IOptions options) : ControllerBase { + + [HttpGet("redirect")] + public async Task RedirectToProvider([FromQuery] string redirectAfter, [FromQuery] int performRedirect = 1) { + var uri = await accessor.ConstructAuthUri(redirectAfter); + + if (performRedirect == 1) { + return Redirect(uri); + } + + return Ok(new SingleValueResult(uri)); + } + + [HttpGet("callback")] + public async Task Callback([FromQuery] string code, [FromQuery] string state) { + if (string.IsNullOrEmpty(code)) { + return BadRequest("Authorization code is missing"); + } + + var token = await accessor.RequestToken(code); + + Response.Cookies.Append(ITokenContext.AccessTokenType, token.AccessToken, new CookieOptions { + MaxAge = TimeSpan.FromSeconds(token.ExpiresIn), + HttpOnly = false, + Secure = true + }); + Response.Cookies.Append(ITokenContext.RefreshTokenType, token.RefreshToken, new CookieOptions { + MaxAge = options.Value.RefreshToken.ConstructTimeSpan, + HttpOnly = false, + Secure = true + }); + + if (string.IsNullOrEmpty(state)) { + return Ok(new SingleValueResult(token.AccessToken)); + } + + return Redirect(state.Replace("{token}", token.AccessToken)); + } + + [HttpGet("refresh")] + public async Task Refresh() { + var refreshToken = Request.Cookies[ITokenContext.RefreshTokenType]; + + if (string.IsNullOrEmpty(refreshToken)) + return BadRequest("Refresh token not provided"); + + var token = await accessor.RefreshAccessToken(refreshToken); + + if (token is null) + return NotFound("Refresh token not valid"); + + Response.Cookies.Append(ITokenContext.AccessTokenType, token.AccessToken, new CookieOptions { + MaxAge = TimeSpan.FromSeconds(token.ExpiresIn), + HttpOnly = false, + Secure = true + }); + + return Ok(new SingleValueResult(token.AccessToken)); + } + + [HttpDelete("logout")] + public IActionResult Logout() { + Response.Cookies.Delete(ITokenContext.RefreshTokenType); + Response.Cookies.Delete(ITokenContext.AccessTokenType); + return Ok(); + } + +} \ No newline at end of file diff --git a/src/HopFrame.Api/Extensions/MvcExtensions.cs b/src/HopFrame.Api/Extensions/MvcExtensions.cs index d176de7..4329015 100644 --- a/src/HopFrame.Api/Extensions/MvcExtensions.cs +++ b/src/HopFrame.Api/Extensions/MvcExtensions.cs @@ -83,4 +83,4 @@ public static class MvcExtensions { return true; } } -} \ No newline at end of file +} diff --git a/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs b/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs index 51eacde..75a0b90 100644 --- a/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs +++ b/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs @@ -19,8 +19,13 @@ public static class ServiceCollectionExtensions { /// The configuration used to configure HopFrame authentication /// The data source for all HopFrame entities public static void AddHopFrame(this IServiceCollection services, ConfigurationManager configuration) where TDbContext : HopDbContextBase { - services.AddMvcCore().UseSpecificControllers(typeof(SecurityController)); + var controllers = new List { typeof(AuthController) }; + + if (configuration.GetValue("HopFrame:Authentication:OpenID:Enabled")) + controllers.Add(typeof(OpenIdController)); + AddHopFrameNoEndpoints(services, configuration); + services.AddMvcCore().UseSpecificControllers(controllers.ToArray()); } /// @@ -30,6 +35,11 @@ public static class ServiceCollectionExtensions { /// The configuration used to configure HopFrame authentication /// The data source for all HopFrame entities public static void AddHopFrameNoEndpoints(this IServiceCollection services, ConfigurationManager configuration) where TDbContext : HopDbContextBase { + services.AddMvcCore().ConfigureApplicationPartManager(manager => { + var endpoints = manager.ApplicationParts.SingleOrDefault(p => p.Name == typeof(ServiceCollectionExtensions).Namespace!.Replace(".Extensions", "")); + manager.ApplicationParts.Remove(endpoints); + }); + services.AddHopFrameRepositories(); services.TryAddSingleton(); services.AddScoped(); diff --git a/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs b/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs index 61e9681..25dfd76 100644 --- a/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs +++ b/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs @@ -101,9 +101,7 @@ internal class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenC var refreshToken = accessor.HttpContext?.Request.Cookies[ITokenContext.RefreshTokenType]; if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(refreshToken)) - return LogicResult.Conflict("access or refresh token not provided"); - - await tokens.DeleteUserTokens(tokenContext.User); + await tokens.DeleteUserTokens(tokenContext.User); accessor.HttpContext?.Response.Cookies.Delete(ITokenContext.RefreshTokenType); accessor.HttpContext?.Response.Cookies.Delete(ITokenContext.AccessTokenType); diff --git a/src/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs b/src/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs index e4ccff7..a6bf52c 100644 --- a/src/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs +++ b/src/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs @@ -24,6 +24,7 @@ public static class HopFrameAuthenticationExtensions { service.AddScoped(); service.AddHttpClient(); + service.AddMemoryCache(); service.AddScoped(); service.AddOptionsFromConfiguration(configuration); diff --git a/src/HopFrame.Security/Authentication/HopFrameAuthenticationOptions.cs b/src/HopFrame.Security/Authentication/HopFrameAuthenticationOptions.cs index b996d68..7cbd157 100644 --- a/src/HopFrame.Security/Authentication/HopFrameAuthenticationOptions.cs +++ b/src/HopFrame.Security/Authentication/HopFrameAuthenticationOptions.cs @@ -5,8 +5,8 @@ namespace HopFrame.Security.Authentication; public class HopFrameAuthenticationOptions : OptionsFromConfiguration { public override string Position { get; } = "HopFrame:Authentication"; - public TimeSpan AccessTokenTime => AccessToken is null ? new(0, 0, 5, 0) : new(AccessToken.Days, AccessToken.Hours, AccessToken.Minutes, AccessToken.Seconds); - public TimeSpan RefreshTokenTime => RefreshToken is null ? new(30, 0, 0, 0) : new(RefreshToken.Days, RefreshToken.Hours, RefreshToken.Minutes, RefreshToken.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) : RefreshToken.ConstructTimeSpan; public TokenTime AccessToken { get; set; } public TokenTime RefreshToken { get; set; } @@ -16,5 +16,7 @@ public class HopFrameAuthenticationOptions : OptionsFromConfiguration { public int Hours { get; set; } public int Minutes { get; set; } public int Seconds { get; set; } + + public TimeSpan ConstructTimeSpan => new(Days, Hours, Minutes, Seconds); } } \ No newline at end of file diff --git a/src/HopFrame.Security/Authentication/OpenID/IOpenIdAccessor.cs b/src/HopFrame.Security/Authentication/OpenID/IOpenIdAccessor.cs index 31f0d67..df9f26f 100644 --- a/src/HopFrame.Security/Authentication/OpenID/IOpenIdAccessor.cs +++ b/src/HopFrame.Security/Authentication/OpenID/IOpenIdAccessor.cs @@ -7,4 +7,5 @@ public interface IOpenIdAccessor { Task RequestToken(string code); Task ConstructAuthUri(string state = null); Task InspectToken(string token); + Task RefreshAccessToken(string refreshToken); } \ No newline at end of file diff --git a/src/HopFrame.Security/Authentication/OpenID/Implementation/OpenIdAccessor.cs b/src/HopFrame.Security/Authentication/OpenID/Implementation/OpenIdAccessor.cs index 8f70ab0..5161b01 100644 --- a/src/HopFrame.Security/Authentication/OpenID/Implementation/OpenIdAccessor.cs +++ b/src/HopFrame.Security/Authentication/OpenID/Implementation/OpenIdAccessor.cs @@ -1,12 +1,24 @@ using System.Text.Json; using HopFrame.Security.Authentication.OpenID.Models; using HopFrame.Security.Authentication.OpenID.Options; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; namespace HopFrame.Security.Authentication.OpenID.Implementation; -internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions options) : IOpenIdAccessor { +internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions options, IHttpContextAccessor accessor, IMemoryCache cache) : IOpenIdAccessor { + private const string DefaultCallbackEndpoint = "api/v1/openid/callback"; + + private const string ConfigurationCacheKey = "HopFrame:OpenID:Configuration"; + private const string AuthCodeCacheKey = "HopFrame:OpenID:Code:"; + private const string TokenCacheKey = "HopFrame:OpenID:Token:"; + public async Task LoadConfiguration() { + if (options.Value.Cache.Enabled && options.Value.Cache.Configuration.Enabled && cache.TryGetValue(ConfigurationCacheKey, out object cachedConfiguration)) { + return cachedConfiguration as OpenIdConfiguration; + } + var client = clientFactory.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, Path.Combine(options.Value.Issuer, ".well-known/openid-configuration")); var response = await client.SendAsync(request); @@ -14,10 +26,22 @@ internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions(await response.Content.ReadAsStreamAsync()); + var config = await JsonSerializer.DeserializeAsync(await response.Content.ReadAsStreamAsync()); + + if (options.Value.Cache.Enabled && options.Value.Cache.Configuration.Enabled) + cache.Set(ConfigurationCacheKey, config, options.Value.Cache.Configuration.TTL.ConstructTimeSpan); + + return config; } public async Task RequestToken(string code) { + 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}/{DefaultCallbackEndpoint}"; + var configuration = await LoadConfiguration(); var client = clientFactory.CreateClient(); @@ -25,7 +49,7 @@ internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions { { "grant_type", "authorization_code" }, { "code", code }, - { "redirect_uri", options.Value.Callback }, + { "redirect_uri", callback }, { "client_id", options.Value.ClientId }, { "client_secret", options.Value.ClientSecret } }) @@ -35,15 +59,27 @@ internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions(await response.Content.ReadAsStreamAsync()); + var token = await JsonSerializer.DeserializeAsync(await response.Content.ReadAsStreamAsync()); + + if (options.Value.Cache.Enabled && options.Value.Cache.Auth.Enabled) + cache.Set(AuthCodeCacheKey + code, token, options.Value.Cache.Auth.TTL.ConstructTimeSpan); + + return token; } public async Task ConstructAuthUri(string state = null) { + var protocol = accessor.HttpContext!.Request.IsHttps ? "https" : "http"; + var callback = options.Value.Callback ?? $"{protocol}://{accessor.HttpContext!.Request.Host.Value}/{DefaultCallbackEndpoint}"; + var configuration = await LoadConfiguration(); - return $"{configuration.AuthorizationEndpoint}?response_type=code&client_id={options.Value.ClientId}&redirect_uri={options.Value.Callback}&scope=openid%20profile%20email&state={state}"; + return $"{configuration.AuthorizationEndpoint}?response_type=code&client_id={options.Value.ClientId}&redirect_uri={callback}&scope=openid%20profile%20email%20offline_access&state={state}"; } public async Task InspectToken(string token) { + if (options.Value.Cache.Enabled && options.Value.Cache.Inspection.Enabled && cache.TryGetValue(TokenCacheKey + token, out object cachedToken)) { + return cachedToken as OpenIdIntrospection; + } + var configuration = await LoadConfiguration(); var client = clientFactory.CreateClient(); @@ -59,6 +95,31 @@ internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions(await response.Content.ReadAsStreamAsync()); + var introspection = await JsonSerializer.DeserializeAsync(await response.Content.ReadAsStreamAsync()); + + if (options.Value.Cache.Enabled && options.Value.Cache.Inspection.Enabled) + cache.Set(TokenCacheKey + token, introspection, options.Value.Cache.Inspection.TTL.ConstructTimeSpan); + + return introspection; + } + + public async Task RefreshAccessToken(string refreshToken) { + var configuration = await LoadConfiguration(); + + var client = clientFactory.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Post, configuration.TokenEndpoint) { + Content = new FormUrlEncodedContent(new Dictionary { + { "grant_type", "refresh_token" }, + { "refresh_token", refreshToken }, + { "client_id", options.Value.ClientId }, + { "client_secret", options.Value.ClientSecret } + }) + }; + var response = await client.SendAsync(request); + + if (!response.IsSuccessStatusCode) + return null; + + return await JsonSerializer.DeserializeAsync(await response.Content.ReadAsStreamAsync()); } } \ No newline at end of file diff --git a/src/HopFrame.Security/Authentication/OpenID/Models/OpenIdToken.cs b/src/HopFrame.Security/Authentication/OpenID/Models/OpenIdToken.cs index 042183d..6303bda 100644 --- a/src/HopFrame.Security/Authentication/OpenID/Models/OpenIdToken.cs +++ b/src/HopFrame.Security/Authentication/OpenID/Models/OpenIdToken.cs @@ -5,6 +5,9 @@ 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; } diff --git a/src/HopFrame.Security/Authentication/OpenID/Options/OpenIdOptions.cs b/src/HopFrame.Security/Authentication/OpenID/Options/OpenIdOptions.cs index 483f8ac..49a219c 100644 --- a/src/HopFrame.Security/Authentication/OpenID/Options/OpenIdOptions.cs +++ b/src/HopFrame.Security/Authentication/OpenID/Options/OpenIdOptions.cs @@ -12,4 +12,42 @@ public sealed class OpenIdOptions : OptionsFromConfiguration { public string ClientId { get; set; } public string ClientSecret { get; set; } public string Callback { get; set; } + + public HopFrameAuthenticationOptions.TokenTime RefreshToken { get; set; } = new() { + Days = 30 + }; + + public CachingOptions Cache { get; set; } = new() { + Enabled = true, + Configuration = new() { + Enabled = true, + TTL = new() { + Minutes = 10 + } + }, + Auth = new() { + Enabled = true, + TTL = new() { + Seconds = 30 + } + }, + Inspection = new() { + Enabled = true, + TTL = new() { + Minutes = 2 + } + } + }; + + public class CachingTypeOptions { + public bool Enabled { get; set; } + public HopFrameAuthenticationOptions.TokenTime TTL { get; set; } + } + + public class CachingOptions { + public bool Enabled { get; set; } + public CachingTypeOptions Configuration { get; set; } + public CachingTypeOptions Auth { get; set; } + public CachingTypeOptions Inspection { get; set; } + } } \ No newline at end of file diff --git a/testing/HopFrame.Testing.Api/Controllers/AuthController.cs b/testing/HopFrame.Testing.Api/Controllers/AuthController.cs index c979c33..083fd34 100644 --- a/testing/HopFrame.Testing.Api/Controllers/AuthController.cs +++ b/testing/HopFrame.Testing.Api/Controllers/AuthController.cs @@ -7,13 +7,13 @@ namespace HopFrame.Testing.Api.Controllers; public class AuthController(IOpenIdAccessor accessor) : Controller { [HttpGet("auth/callback")] - public async Task> Callback([FromQuery] string code, [FromQuery] string state) { + public async Task Callback([FromQuery] string code, [FromQuery] string state) { if (string.IsNullOrEmpty(code)) { return BadRequest("Authorization code is missing"); } var token = await accessor.RequestToken(code); - return Ok(token.AccessToken); + return Ok(token); } [HttpGet("auth")] diff --git a/testing/HopFrame.Testing.Api/Controllers/TestController.cs b/testing/HopFrame.Testing.Api/Controllers/TestController.cs index aa773b8..3a3affe 100644 --- a/testing/HopFrame.Testing.Api/Controllers/TestController.cs +++ b/testing/HopFrame.Testing.Api/Controllers/TestController.cs @@ -66,5 +66,11 @@ public class TestController(ITokenContext userContext, DatabaseContext context, var token = await tokens.GetToken(tokenId); await tokens.DeleteToken(token); } + + [HttpGet("url")] + public async Task>> GetUrl() { + var protocol = Request.IsHttps ? "https" : "http"; + return Ok($"{protocol}://{Request.Host.Value}/auth/callback"); + } } \ No newline at end of file