diff --git a/docker-compose.yml b/docker-compose.yml index 50628a37..79426848 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -68,6 +68,7 @@ services: environment: PORT: ${WEB_PORT:-3000} SEED_DEMO: ${SEED_DEMO:-true} + DEMO_ADMIN_USERNAME: ${DEMO_ADMIN_USERNAME:-} Jwt__Key: ${JWT_KEY} Jwt__Issuer: ${JWT_ISSUER} Jwt__Audience: ${JWT_AUDIENCE} diff --git a/web.Tests/FunctionTests/Authorization/DemoAuthHandler.Tests.cs b/web.Tests/FunctionTests/Authorization/DemoAuthHandler.Tests.cs new file mode 100644 index 00000000..82c4138d --- /dev/null +++ b/web.Tests/FunctionTests/Authorization/DemoAuthHandler.Tests.cs @@ -0,0 +1,45 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using System.Linq; +using System.Threading.Tasks; +using Atlas_Web.Authentication; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; + +namespace web.Tests.FunctionTests.Authorization; + +public class DemoAuthHandlerTests +{ + [Fact] + public async Task AuthenticateAsync_UsesConfiguredDemoAdminUsername() + { + var options = new DemoSchemeOptions { Username = "local-admin" }; + var optionsMonitor = new Mock>(); + optionsMonitor.Setup(x => x.CurrentValue).Returns(options); + optionsMonitor.Setup(x => x.Get(It.IsAny())).Returns(options); + + var handler = new DemoAuthHandler( + optionsMonitor.Object, + NullLoggerFactory.Instance, + UrlEncoder.Default, + new SystemClock() + ); + + await handler.InitializeAsync( + new AuthenticationScheme("Demo", "Demo", typeof(DemoAuthHandler)), + new DefaultHttpContext() + ); + + var result = await handler.AuthenticateAsync(); + + Assert.True(result.Succeeded); + Assert.Equal( + "local-admin", + result.Principal?.Claims.Single(c => c.Type == ClaimTypes.Name).Value + ); + } +} diff --git a/web.Tests/FunctionTests/Controllers/AuthApiController.Tests.cs b/web.Tests/FunctionTests/Controllers/AuthApiController.Tests.cs new file mode 100644 index 00000000..757b2cfc --- /dev/null +++ b/web.Tests/FunctionTests/Controllers/AuthApiController.Tests.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; +using System.IdentityModel.Tokens.Jwt; +using System.Linq; +using System.Threading.Tasks; +using Atlas_Web.Controllers.Api; +using Atlas_Web.Models; +using Atlas_Web.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.IdentityModel.Tokens; +using Xunit; + +namespace web.Tests.FunctionTests.Controllers; + +public class AuthApiControllerTests +{ + [Fact] + public async Task Login_UsesConfiguredDemoAdminUsername_WhenDemoModeIsEnabled() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: "auth-api-demo-admin") + .Options; + + await using var context = new Atlas_WebContext(options); + context.Users.Add( + new User + { + UserId = 99, + Username = "local-admin", + FullnameCalc = "Local Admin", + } + ); + await context.SaveChangesAsync(); + + var config = new ConfigurationBuilder() + .AddInMemoryCollection( + new Dictionary + { + ["Demo"] = "True", + ["DEMO_ADMIN_USERNAME"] = "local-admin", + ["Cors:AllowedOrigins:0"] = "http://localhost:3000", + ["Auth:DefaultCallbackPath"] = "/auth/callback", + ["Jwt:Issuer"] = "atlas-test-issuer", + ["Jwt:Audience"] = "atlas-test-audience", + } + ) + .Build(); + + var signingKey = new SymmetricSecurityKey( + System.Text.Encoding.UTF8.GetBytes( + "test-jwt-secret-key-for-function-tests-32-chars-minimum" + ) + ); + var jwt = new JwtTokenService(signingKey, "atlas-test-issuer", "atlas-test-audience"); + var controller = new AuthApiController(jwt, context, config); + + var result = await controller.Login("http://localhost:3000/auth/callback"); + + var redirect = Assert.IsType(result); + var target = new System.Uri(redirect.Url!, System.UriKind.Absolute); + var token = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(target.Query)["token"] + .Single(); + var jwtToken = new JwtSecurityTokenHandler().ReadJwtToken(token); + + Assert.Equal( + "local-admin", + jwtToken.Claims.Single(c => c.Type == System.Security.Claims.ClaimTypes.Name).Value + ); + Assert.Equal("99", jwtToken.Claims.Single(c => c.Type == "UserId").Value); + } +} diff --git a/web/Authorization/DemoAuthHandler.cs b/web/Authorization/DemoAuthHandler.cs index 7d5d36ee..fc2d41dd 100644 --- a/web/Authorization/DemoAuthHandler.cs +++ b/web/Authorization/DemoAuthHandler.cs @@ -6,7 +6,10 @@ namespace Atlas_Web.Authentication { #pragma warning disable S2094 - public class DemoSchemeOptions : AuthenticationSchemeOptions { } + public class DemoSchemeOptions : AuthenticationSchemeOptions + { + public string Username { get; set; } = "Default"; + } public class DemoAuthHandler : AuthenticationHandler { @@ -20,14 +23,17 @@ ISystemClock clock protected override Task HandleAuthenticateAsync() { + var username = string.IsNullOrWhiteSpace(Options.Username) + ? "Default" + : Options.Username.Trim(); var claims = new[] { - new Claim(ClaimTypes.Name, "Default"), + new Claim(ClaimTypes.Name, username), new Claim(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString()), }; - var identity = new ClaimsIdentity(claims, "Default"); + var identity = new ClaimsIdentity(claims, "Demo"); var principal = new ClaimsPrincipal(identity); - var ticket = new AuthenticationTicket(principal, "Default"); + var ticket = new AuthenticationTicket(principal, "Demo"); var result = AuthenticateResult.Success(ticket); diff --git a/web/Controllers/Api/AuthApiController.cs b/web/Controllers/Api/AuthApiController.cs index 00f79112..e1f34c7a 100644 --- a/web/Controllers/Api/AuthApiController.cs +++ b/web/Controllers/Api/AuthApiController.cs @@ -32,7 +32,6 @@ public AuthApiController(JwtTokenService jwt, Atlas_WebContext context, IConfigu public async Task Login([FromQuery] string? returnUrl = null) #pragma warning restore CS8632 { - var user = await _context.Users.FirstOrDefaultAsync(x => x.Username == "Default"); var safeReturnUrlResult = GetSafeRedirectUrl(returnUrl); if (safeReturnUrlResult is BadRequestObjectResult) { @@ -43,9 +42,17 @@ public async Task Login([FromQuery] string? returnUrl = null) if (_config["Demo"] == "True") { + var demoUsername = _config["DEMO_ADMIN_USERNAME"]; + var selectedDemoUsername = string.IsNullOrWhiteSpace(demoUsername) + ? "Default" + : demoUsername.Trim(); + var user = await _context.Users.FirstOrDefaultAsync( + x => x.Username == selectedDemoUsername + ); + if (user == null) { - return NotFound("Demo user not found."); + return NotFound($"Demo user '{selectedDemoUsername}' not found."); } var demoToken = _jwt.IssueToken( diff --git a/web/Program.cs b/web/Program.cs index 4f4d0349..87e84099 100644 --- a/web/Program.cs +++ b/web/Program.cs @@ -355,7 +355,7 @@ var signingCertificate in entityDescriptor.IdPSsoDescriptor.SigningCertificates app.Use( async (context, next) => { - context.Response.Headers.Add("Content-Security-Policy", "frame-ancestors 'self' *;"); + context.Response.Headers["Content-Security-Policy"] = "frame-ancestors 'self';"; await next(); } ); diff --git a/web/ProgramConfiguration.cs b/web/ProgramConfiguration.cs index d4581042..a441a08e 100644 --- a/web/ProgramConfiguration.cs +++ b/web/ProgramConfiguration.cs @@ -89,7 +89,10 @@ private static void ConfigureDemoAuthentication( #pragma warning disable S1116 builder .Services.AddAuthentication(options => options.DefaultScheme = "Demo") - .AddScheme("Demo", options => { }) + .AddScheme("Demo", options => + { + options.Username = builder.Configuration["DEMO_ADMIN_USERNAME"] ?? "Default"; + }) .AddJwtBearer("Bearer", options => { options.TokenValidationParameters = new TokenValidationParameters