diff --git a/HopFrame.sln.DotSettings.user b/HopFrame.sln.DotSettings.user index c0c134b..12afe51 100644 --- a/HopFrame.sln.DotSettings.user +++ b/HopFrame.sln.DotSettings.user @@ -1,4 +1,5 @@  + ForceIncluded ForceIncluded ForceIncluded ForceIncluded @@ -6,6 +7,7 @@ ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded <AssemblyExplorer> @@ -74,6 +76,8 @@ + + diff --git a/src/HopFrame.Api/Controller/HopFrameFeatureProvider.cs b/src/HopFrame.Api/Controller/HopFrameFeatureProvider.cs deleted file mode 100644 index c8f6cba..0000000 --- a/src/HopFrame.Api/Controller/HopFrameFeatureProvider.cs +++ /dev/null @@ -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); - } -} \ No newline at end of file diff --git a/src/HopFrame.Api/Controller/OpenIdController.cs b/src/HopFrame.Api/Controller/OpenIdController.cs index 16f9d3a..50e8822 100644 --- a/src/HopFrame.Api/Controller/OpenIdController.cs +++ b/src/HopFrame.Api/Controller/OpenIdController.cs @@ -10,10 +10,11 @@ namespace HopFrame.Api.Controller; [ApiController, Route("api/v1/openid")] public class OpenIdController(IOpenIdAccessor accessor, IOptions options) : ControllerBase { + public const string DefaultCallback = "api/v1/openid/callback"; [HttpGet("redirect")] public async Task RedirectToProvider([FromQuery] string redirectAfter, [FromQuery] int performRedirect = 1) { - var uri = await accessor.ConstructAuthUri(redirectAfter); + var uri = await accessor.ConstructAuthUri(DefaultCallback, redirectAfter); if (performRedirect == 1) { return Redirect(uri); @@ -28,7 +29,11 @@ public class OpenIdController(IOpenIdAccessor accessor, IOptions 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 { MaxAge = TimeSpan.FromSeconds(token.ExpiresIn), diff --git a/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs b/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs index 75a0b90..a596033 100644 --- a/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs +++ b/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs @@ -19,7 +19,10 @@ 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 { - var controllers = new List { typeof(AuthController) }; + var controllers = new List(); + + if (configuration.GetValue("HopFrame:Authentication:DefaultAuthentication")) + controllers.Add(typeof(AuthController)); if (configuration.GetValue("HopFrame:Authentication:OpenID:Enabled")) controllers.Add(typeof(OpenIdController)); diff --git a/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs b/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs index 25dfd76..d0d188c 100644 --- a/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs +++ b/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs @@ -12,6 +12,8 @@ namespace HopFrame.Api.Logic.Implementation; internal class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenContext tokenContext, IHttpContextAccessor accessor, IOptions options) : IAuthLogic { public async Task>> Login(UserLogin login) { + if (!options.Value.DefaultAuthentication) return LogicResult>.BadRequest("HopFrame authentication scheme is disabled"); + var user = await users.GetUserByEmail(login.Email); if (user is null) @@ -38,6 +40,8 @@ internal class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenC } public async Task>> Register(UserRegister register) { + if (!options.Value.DefaultAuthentication) return LogicResult>.BadRequest("HopFrame authentication scheme is disabled"); + if (register.Password.Length < 8) return LogicResult>.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>> Authenticate() { + if (!options.Value.DefaultAuthentication) return LogicResult>.BadRequest("HopFrame authentication scheme is disabled"); + var refreshToken = accessor.HttpContext?.Request.Cookies[ITokenContext.RefreshTokenType]; if (string.IsNullOrEmpty(refreshToken)) diff --git a/src/HopFrame.Security/Authentication/HopFrameAuthentication.cs b/src/HopFrame.Security/Authentication/HopFrameAuthentication.cs index f0dff7e..8fb578f 100644 --- a/src/HopFrame.Security/Authentication/HopFrameAuthentication.cs +++ b/src/HopFrame.Security/Authentication/HopFrameAuthentication.cs @@ -38,7 +38,7 @@ public class HopFrameAuthentication( 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); if (result is null || !result.Active) @@ -65,10 +65,13 @@ public class HopFrameAuthentication( CreatedAt = DateTime.Now, Type = Token.OpenIdTokenType }; - var identity = await GenerateClaims(token); + 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.Type == Token.ApiTokenType) { @@ -78,11 +81,11 @@ public class HopFrameAuthentication( if (tokenEntry.Owner is null) 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)); } - private async Task GenerateClaims(Token token) { + public static async Task GenerateClaims(Token token, IPermissionRepository perms) { var claims = new List { new(HopFrameClaimTypes.AccessTokenId, token.TokenId.ToString()), new(HopFrameClaimTypes.UserId, token.Owner.Id.ToString()) diff --git a/src/HopFrame.Security/Authentication/HopFrameAuthenticationOptions.cs b/src/HopFrame.Security/Authentication/HopFrameAuthenticationOptions.cs index 7cbd157..8a7285f 100644 --- a/src/HopFrame.Security/Authentication/HopFrameAuthenticationOptions.cs +++ b/src/HopFrame.Security/Authentication/HopFrameAuthenticationOptions.cs @@ -8,6 +8,8 @@ public class HopFrameAuthenticationOptions : OptionsFromConfiguration { 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 bool DefaultAuthentication { get; set; } = true; + public TokenTime AccessToken { get; set; } public TokenTime RefreshToken { get; set; } diff --git a/src/HopFrame.Security/Authentication/OpenID/IOpenIdAccessor.cs b/src/HopFrame.Security/Authentication/OpenID/IOpenIdAccessor.cs index df9f26f..09dc54c 100644 --- a/src/HopFrame.Security/Authentication/OpenID/IOpenIdAccessor.cs +++ b/src/HopFrame.Security/Authentication/OpenID/IOpenIdAccessor.cs @@ -4,8 +4,8 @@ namespace HopFrame.Security.Authentication.OpenID; public interface IOpenIdAccessor { Task LoadConfiguration(); - Task RequestToken(string code); - Task ConstructAuthUri(string state = null); + Task RequestToken(string code, string defaultCallback); + Task ConstructAuthUri(string defaultCallback, 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 5161b01..3dd1a82 100644 --- a/src/HopFrame.Security/Authentication/OpenID/Implementation/OpenIdAccessor.cs +++ b/src/HopFrame.Security/Authentication/OpenID/Implementation/OpenIdAccessor.cs @@ -8,8 +8,6 @@ using Microsoft.Extensions.Options; namespace HopFrame.Security.Authentication.OpenID.Implementation; 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:"; @@ -34,13 +32,13 @@ internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions RequestToken(string code) { + public async Task 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}/{DefaultCallbackEndpoint}"; + var callback = options.Value.Callback ?? $"{protocol}://{accessor.HttpContext!.Request.Host.Value}/{defaultCallback}"; var configuration = await LoadConfiguration(); @@ -67,9 +65,9 @@ internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions ConstructAuthUri(string state = null) { + public async Task 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}/{DefaultCallbackEndpoint}"; + 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}"; diff --git a/src/HopFrame.Web/AuthMiddleware.cs b/src/HopFrame.Web/AuthMiddleware.cs index b5fbc93..4a9b216 100644 --- a/src/HopFrame.Web/AuthMiddleware.cs +++ b/src/HopFrame.Web/AuthMiddleware.cs @@ -1,7 +1,6 @@ using System.Security.Claims; using HopFrame.Database.Repositories; using HopFrame.Security.Authentication; -using HopFrame.Security.Claims; using HopFrame.Web.Services; using Microsoft.AspNetCore.Http; @@ -20,16 +19,10 @@ public sealed class AuthMiddleware(IAuthService auth, IPermissionRepository perm next?.Invoke(context); return; } - - var claims = new List { - new(HopFrameClaimTypes.AccessTokenId, token.TokenId.ToString()), - new(HopFrameClaimTypes.UserId, token.Owner.Id.ToString()) - }; - var permissions = await perms.GetFullPermissions(token); - claims.AddRange(permissions.Select(perm => new Claim(HopFrameClaimTypes.Permission, perm))); - - context.User.AddIdentity(new ClaimsIdentity(claims, HopFrameAuthentication.SchemeName)); + var principal = await HopFrameAuthentication.GenerateClaims(token, perms); + if (principal?.Identity is ClaimsIdentity identity) + context.User.AddIdentity(identity); } await next?.Invoke(context); diff --git a/src/HopFrame.Web/Services/Implementation/AuthService.cs b/src/HopFrame.Web/Services/Implementation/AuthService.cs index 7bc38a4..5c95a8f 100644 --- a/src/HopFrame.Web/Services/Implementation/AuthService.cs +++ b/src/HopFrame.Web/Services/Implementation/AuthService.cs @@ -1,6 +1,8 @@ using HopFrame.Database.Models; using HopFrame.Database.Repositories; using HopFrame.Security.Authentication; +using HopFrame.Security.Authentication.OpenID; +using HopFrame.Security.Authentication.OpenID.Options; using HopFrame.Security.Claims; using HopFrame.Security.Models; using Microsoft.AspNetCore.Http; @@ -13,10 +15,15 @@ internal class AuthService( IHttpContextAccessor httpAccessor, ITokenRepository tokens, ITokenContext context, - IOptions options) + IOptions options, + IOptions openIdOptions, + IOpenIdAccessor accessor, + IUserRepository users) : IAuthService { public async Task Register(UserRegister register) { + if (!options.Value.DefaultAuthentication) return; + var user = await userService.AddUser(new User { Username = register.Username, Email = register.Email, @@ -41,6 +48,8 @@ internal class AuthService( } public async Task Login(UserLogin login) { + if (!options.Value.DefaultAuthentication) return false; + var user = await userService.GetUserByEmail(login.Email); if (user == null) return false; @@ -75,6 +84,45 @@ internal class AuthService( 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); if (token is null || token.Type != Token.RefreshTokenType) return null; @@ -96,7 +144,7 @@ internal class AuthService( var accessToken = context.AccessToken; 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.Owner is null) return false; diff --git a/testing/HopFrame.Testing.Api/Controllers/AuthController.cs b/testing/HopFrame.Testing.Api/Controllers/AuthController.cs deleted file mode 100644 index 083fd34..0000000 --- a/testing/HopFrame.Testing.Api/Controllers/AuthController.cs +++ /dev/null @@ -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 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 Authenticate() { - return Redirect(await accessor.ConstructAuthUri()); - } - - [HttpGet("check")] - public async Task> Check([FromQuery] string token) { - return Ok(await accessor.InspectToken(token)); - } -} \ No newline at end of file diff --git a/tests/HopFrame.Tests.Api/AuthLogicTests.cs b/tests/HopFrame.Tests.Api/AuthLogicTests.cs index 39975f5..321b4a0 100644 --- a/tests/HopFrame.Tests.Api/AuthLogicTests.cs +++ b/tests/HopFrame.Tests.Api/AuthLogicTests.cs @@ -329,7 +329,7 @@ public class AuthLogicTests { } [Fact] - public async Task Logout_With_NoAccessToken_Should_Fail() { + public async Task Logout_With_NoAccessToken_Should_Succeed() { // Arrange var (auth, context) = SetupEnvironment(provideAccessToken: false); context.Response.Cookies.Append(ITokenContext.AccessTokenType, _accessToken.ToString()); @@ -339,14 +339,13 @@ public class AuthLogicTests { var result = await auth.Logout(); // Assert - Assert.False(result.IsSuccessful); - Assert.Equal(HttpStatusCode.Conflict, result.State); - Assert.Equal(_accessToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); - Assert.Equal(_refreshToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType)); + Assert.True(result.IsSuccessful); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType)); } [Fact] - public async Task Logout_With_NoRefreshToken_Should_Fail() { + public async Task Logout_With_NoRefreshToken_Should_Succeed() { // Arrange var (auth, context) = SetupEnvironment(); context.Response.Cookies.Append(ITokenContext.AccessTokenType, _accessToken.ToString()); @@ -356,10 +355,9 @@ public class AuthLogicTests { var result = await auth.Logout(); // Assert - Assert.False(result.IsSuccessful); - Assert.Equal(HttpStatusCode.Conflict, result.State); - Assert.Equal(_accessToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); - Assert.Equal(_refreshToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType)); + Assert.True(result.IsSuccessful); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.AccessTokenType)); + Assert.Null(context.Response.Headers.FindCookie(ITokenContext.RefreshTokenType)); } [Fact] diff --git a/tests/HopFrame.Tests.Web/AuthServiceTests.cs b/tests/HopFrame.Tests.Web/AuthServiceTests.cs index 306a94b..56c0604 100644 --- a/tests/HopFrame.Tests.Web/AuthServiceTests.cs +++ b/tests/HopFrame.Tests.Web/AuthServiceTests.cs @@ -1,6 +1,8 @@ using HopFrame.Database.Models; using HopFrame.Database.Repositories; using HopFrame.Security.Authentication; +using HopFrame.Security.Authentication.OpenID; +using HopFrame.Security.Authentication.OpenID.Options; using HopFrame.Security.Claims; using HopFrame.Security.Models; using HopFrame.Tests.Web.Extensions; @@ -68,7 +70,16 @@ public class AuthServiceTests { .Setup(c => c.AccessToken) .Returns(providedAccessToken); - return (new AuthService(users.Object, accessor, tokens.Object, context.Object, new OptionsWrapper(new HopFrameAuthenticationOptions())), accessor.HttpContext); + return (new AuthService( + users.Object, + accessor, + tokens.Object, + context.Object, + new OptionsWrapper(new HopFrameAuthenticationOptions()), + new OptionsWrapper(new OpenIdOptions()), + new Mock().Object, + users.Object + ), accessor.HttpContext); } private User CreateDummyUser() => new() { diff --git a/tests/HopFrame.Tests.Web/Pages/AuthorizedViewTests.cs b/tests/HopFrame.Tests.Web/Pages/AuthorizedViewTests.cs index 92b9611..35355fd 100644 --- a/tests/HopFrame.Tests.Web/Pages/AuthorizedViewTests.cs +++ b/tests/HopFrame.Tests.Web/Pages/AuthorizedViewTests.cs @@ -2,6 +2,7 @@ using System.Security.Claims; using Bunit; using Bunit.TestDoubles; using HopFrame.Security.Authentication; +using HopFrame.Security.Authentication.OpenID; using HopFrame.Security.Claims; using HopFrame.Web.Components; using Microsoft.AspNetCore.Components;