From 746ba96a1f83dd7ef9642959e74b024b7b27c3ce Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Wed, 20 May 2026 20:58:03 +0200 Subject: [PATCH] [codex] Remove production-usable default admin credentials (#7500) * Remove default admin credentials * Fix shell admin API key configuration --- ...r-bootstrap-for-initial-identity-access.md | 3 +- doc/wiki/build-run-operate.md | 4 +- .../appsettings.Development.json | 16 ++++ .../Elsa.ModularServer.Web/appsettings.json | 8 -- src/apps/Elsa.Server.Web/README.md | 7 +- .../appsettings.Development.json | 55 +++++++++++ src/apps/Elsa.Server.Web/appsettings.json | 50 +--------- .../Features/DefaultAuthenticationFeature.cs | 33 ++++++- .../Elsa.Identity/Features/IdentityFeature.cs | 21 ++++- .../Options/AdminApiKeyOptions.cs | 22 +++++ .../Options/AdminUserProviderOptions.cs | 27 ++++++ .../Providers/AdminApiKeyProvider.cs | 33 +++++-- .../Providers/AdminUserProvider.cs | 41 +++++++-- src/modules/Elsa.Identity/README.md | 9 +- .../DefaultAuthenticationFeature.cs | 33 +++++++ .../ShellFeatures/IdentityFeature.cs | 1 + .../Helpers/Fixtures/WorkflowServer.cs | 6 +- .../Providers/AdminCredentialProviderTests.cs | 91 +++++++++++++++++++ .../DefaultAuthenticationFeatureTests.cs | 57 ++++++++++++ 19 files changed, 424 insertions(+), 93 deletions(-) create mode 100644 src/apps/Elsa.Server.Web/appsettings.Development.json create mode 100644 src/modules/Elsa.Identity/Options/AdminApiKeyOptions.cs create mode 100644 src/modules/Elsa.Identity/Options/AdminUserProviderOptions.cs create mode 100644 test/unit/Elsa.Identity.UnitTests/Providers/AdminCredentialProviderTests.cs create mode 100644 test/unit/Elsa.Identity.UnitTests/ShellFeatures/DefaultAuthenticationFeatureTests.cs diff --git a/doc/adr/0010-default-admin-user-bootstrap-for-initial-identity-access.md b/doc/adr/0010-default-admin-user-bootstrap-for-initial-identity-access.md index 9675f8069..8e0f917ae 100644 --- a/doc/adr/0010-default-admin-user-bootstrap-for-initial-identity-access.md +++ b/doc/adr/0010-default-admin-user-bootstrap-for-initial-identity-access.md @@ -73,7 +73,7 @@ This narrows the purpose of `SecurityRoot` and keeps it from being the default a - **More responsibility for integrators**: Deployments must intentionally configure `DefaultAdminUser` or provide another trusted bootstrap path if no administrator exists yet. - **Migration awareness**: Existing documentation and operational guidance that referenced `SecurityRoot` for user bootstrap must be updated. -- **Potential misconfiguration risk**: A weak or default admin password remains a deployment concern and must be handled carefully by integrators. +- **Potential misconfiguration risk**: A weak admin password remains a deployment concern and must be handled carefully by integrators. ### Neutral @@ -87,4 +87,3 @@ This narrows the purpose of `SecurityRoot` and keeps it from being the default a - User-management endpoints should document only their permission requirements, not `SecurityRoot`. - Authentication configuration may still use `SecurityRoot` for operations that intentionally remain root-level. - Integrators should prefer environment-specific configuration for default admin credentials and rotate them according to their security practices. - diff --git a/doc/wiki/build-run-operate.md b/doc/wiki/build-run-operate.md index 079ac17e7..01f80b723 100644 --- a/doc/wiki/build-run-operate.md +++ b/doc/wiki/build-run-operate.md @@ -85,14 +85,14 @@ docker pull elsaworkflows/elsa-server-and-studio-v3:latest docker run -t -i -e ASPNETCORE_ENVIRONMENT='Development' -e HTTP_PORTS=8080 -e HTTP__BASEURL=http://localhost:13000 -p 13000:8080 elsaworkflows/elsa-server-and-studio-v3:latest ``` -Default development login: +Default development login is available only when a development configuration explicitly provisions it: ```text Username: admin Password: password ``` -Do not use default credentials in production. +Do not use development credentials in production. ## ASP.NET Middleware Order diff --git a/src/apps/Elsa.ModularServer.Web/appsettings.Development.json b/src/apps/Elsa.ModularServer.Web/appsettings.Development.json index 960be5067..df9fb2694 100644 --- a/src/apps/Elsa.ModularServer.Web/appsettings.Development.json +++ b/src/apps/Elsa.ModularServer.Web/appsettings.Development.json @@ -3,5 +3,21 @@ "LogLevel": { "Default": "Information" } + }, + "CShells": { + "Shells": { + "Default": { + "Features": { + "DefaultAdminUser": { + "AdminUserName": "admin", + "AdminPassword": "password", + "AdminRoleName": "admin", + "AdminRolePermissions": [ + "*" + ] + } + } + } + } } } diff --git a/src/apps/Elsa.ModularServer.Web/appsettings.json b/src/apps/Elsa.ModularServer.Web/appsettings.json index ac49e62c9..c48ddff4b 100644 --- a/src/apps/Elsa.ModularServer.Web/appsettings.json +++ b/src/apps/Elsa.ModularServer.Web/appsettings.json @@ -23,14 +23,6 @@ "Identity": { "SigningKey": "CHANGE_ME_TO_A_SECURE_RANDOM_KEY" }, - "DefaultAdminUser": { - "AdminUserName": "admin", - "AdminPassword": "password", - "AdminRoleName": "admin", - "AdminRolePermissions": [ - "*" - ] - }, "FastEndpoints": { "GlobalRoutePrefix": "elsa/api" }, diff --git a/src/apps/Elsa.Server.Web/README.md b/src/apps/Elsa.Server.Web/README.md index 89efb3af2..bdbd855f0 100644 --- a/src/apps/Elsa.Server.Web/README.md +++ b/src/apps/Elsa.Server.Web/README.md @@ -3,11 +3,8 @@ This project represents an Elsa application that hosts workflows and exposes API endpoints to manage & execute workflows. ## Secrets -The following are the secrets stored in hashed form in appsettings.json: -**API key**: `48587230567A646D394B435A6277734A-4802fa49-e91e-45e8-b00f-b5492377e20b` -**Admin user**: `admin` -**Admin password**: `password` +`appsettings.json` does not include production-usable default admin credentials or API keys. Configure initial users and applications through environment-specific configuration or a secret manager. ## OpenTelemetry (MacOS) @@ -25,4 +22,4 @@ OTEL_DOTNET_AUTO_TRACES_ADDITIONAL_SOURCES=Proto.Actor,Elsa.Workflows OTEL_DOTNET_AUTO_TRACES_CONSOLE_EXPORTER_ENABLED=true OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 OTEL_EXPORTER_OTLP_PROTOCOL=grpc -OTEL_RESOURCE_ATTRIBUTES=service.name=Elsa Server,service.version=3.3.0,service.instance.id=instance-123,deployment.environment=development \ No newline at end of file +OTEL_RESOURCE_ATTRIBUTES=service.name=Elsa Server,service.version=3.3.0,service.instance.id=instance-123,deployment.environment=development diff --git a/src/apps/Elsa.Server.Web/appsettings.Development.json b/src/apps/Elsa.Server.Web/appsettings.Development.json new file mode 100644 index 000000000..23d236224 --- /dev/null +++ b/src/apps/Elsa.Server.Web/appsettings.Development.json @@ -0,0 +1,55 @@ +{ + "Identity": { + "Tokens": { + "SigningKey": "development-only-secret-signing-key-change-before-production" + }, + "Users": [ + { + "Id": "a2323f46-42db-4e15-af8b-94238717d817", + "Name": "admin", + "HashedPassword": "TfKzh9RLix6FPcCNeHLkGrysFu3bYxqzGqduNdi8v1U=", + "HashedPasswordSalt": "JEy9kBlhHCNsencitRHlGxmErmSgY+FVyMJulCH27Ds=", + "Roles": [ + "admin" + ], + "TenantId": "" + }, + { + "Id": "b0cd0e506e713a9d", + "Name": "alice", + "Roles": [ + "admin" + ], + "HashedPassword": "8B0fFK/f/kk9GkVtzXfRJ2Y6cNyYVvLTfKouWcAcuPg=", + "HashedPasswordSalt": "xlNWvEng8fRvo0McyJopbRJ2MJ9NIYV/4IY5dOZeiiw=", + "TenantId": "tenant-2" + }, + { + "Id": "789d5056cb3bb8e9", + "Name": "bob", + "Roles": [ + "admin" + ], + "HashedPassword": "GMHHvUw7RfIIBq+GAWZ/AlHR50TbgONYfGQ92Xge0EI=", + "HashedPasswordSalt": "V4uHKg1PFBc1bsngCQoptibOu14faj9obSn5B23h1wE=", + "TenantId": "tenant-1" + } + ], + "Applications": [ + { + "Id": "d57030226341448daff5a2935aba2d3f", + "Name": "Postman", + "Roles": [ + "admin" + ], + "ClientId": "HXr0Vzdm9KCZbwsJ", + "ClientSecret": "a<~QGGHTEA%u4;CU&'Wga5ED:_&Gd1C)", + "HashedApiKey": "Z5ClHs3mbzx8Pnw3+PxbMq8A/Y+VKMCCDTGYtax8JFM=", + "HashedApiKeySalt": "kBisa1X8FwBfN2zmyGMFRgIVVBleghhQAJ4WGyTkaD0=", + "HashedClientSecret": "jEv58d0SVbGQ3nBZM0lkzHghG4Y+lMKW80wipz+9vHk=", + "HashedClientSecretSalt": "xRKy14Ok1/tU3kLf/8V1fcbLIegy9vcM90Peu2tzohU=", + "TenantId": "default" + } + ] + } +} diff --git a/src/apps/Elsa.Server.Web/appsettings.json b/src/apps/Elsa.Server.Web/appsettings.json index 0010606f2..d997223f0 100644 --- a/src/apps/Elsa.Server.Web/appsettings.json +++ b/src/apps/Elsa.Server.Web/appsettings.json @@ -68,7 +68,7 @@ }, "Identity": { "Tokens": { - "SigningKey": "sufficiently-large-secret-signing-key", + "SigningKey": "CHANGE_ME_TO_A_SECURE_RANDOM_KEY", "AccessTokenLifetime": "1:00:00:00", "RefreshTokenLifetime": "7:00:00:00" }, @@ -81,54 +81,6 @@ ], "TenantId": "default" } - ], - "Users": [ - { - "Id": "a2323f46-42db-4e15-af8b-94238717d817", - "Name": "admin", - "HashedPassword": "TfKzh9RLix6FPcCNeHLkGrysFu3bYxqzGqduNdi8v1U=", - "HashedPasswordSalt": "JEy9kBlhHCNsencitRHlGxmErmSgY+FVyMJulCH27Ds=", - "Roles": [ - "admin" - ], - "TenantId": "" - }, - { - "Id": "b0cd0e506e713a9d", - "Name": "alice", - "Roles": [ - "admin" - ], - "HashedPassword": "8B0fFK/f/kk9GkVtzXfRJ2Y6cNyYVvLTfKouWcAcuPg=", - "HashedPasswordSalt": "xlNWvEng8fRvo0McyJopbRJ2MJ9NIYV/4IY5dOZeiiw=", - "TenantId": "tenant-2" - }, - { - "Id": "789d5056cb3bb8e9", - "Name": "bob", - "Roles": [ - "admin" - ], - "HashedPassword": "GMHHvUw7RfIIBq+GAWZ/AlHR50TbgONYfGQ92Xge0EI=", - "HashedPasswordSalt": "V4uHKg1PFBc1bsngCQoptibOu14faj9obSn5B23h1wE=", - "TenantId": "tenant-1" - } - ], - "Applications": [ - { - "Id": "d57030226341448daff5a2935aba2d3f", - "Name": "Postman", - "Roles": [ - "admin" - ], - "ClientId": "HXr0Vzdm9KCZbwsJ", - "ClientSecret": "a<~QGGHTEA%u4;CU&'Wga5ED:_&Gd1C)", - "HashedApiKey": "Z5ClHs3mbzx8Pnw3+PxbMq8A/Y+VKMCCDTGYtax8JFM=", - "HashedApiKeySalt": "kBisa1X8FwBfN2zmyGMFRgIVVBleghhQAJ4WGyTkaD0=", - "HashedClientSecret": "jEv58d0SVbGQ3nBZM0lkzHghG4Y+lMKW80wipz+9vHk=", - "HashedClientSecretSalt": "xRKy14Ok1/tU3kLf/8V1fcbLIegy9vcM90Peu2tzohU=", - "TenantId": "default" - } ] }, "Scripting": { diff --git a/src/modules/Elsa.Identity/Features/DefaultAuthenticationFeature.cs b/src/modules/Elsa.Identity/Features/DefaultAuthenticationFeature.cs index f967ecaaf..6aadbafd8 100644 --- a/src/modules/Elsa.Identity/Features/DefaultAuthenticationFeature.cs +++ b/src/modules/Elsa.Identity/Features/DefaultAuthenticationFeature.cs @@ -4,6 +4,7 @@ using Elsa.Features.Abstractions; using Elsa.Features.Attributes; using Elsa.Features.Services; using Elsa.Identity.Constants; +using Elsa.Identity.Options; using Elsa.Identity.Providers; using Elsa.Requirements; using Microsoft.AspNetCore.Authentication; @@ -40,15 +41,44 @@ public class DefaultAuthenticationFeature : FeatureBase /// The current . public DefaultAuthenticationFeature UseApiKeyAuthorization() where T : class, IApiKeyProvider { + ApiKeyProviderType = typeof(T); _configureApiKeyAuthorization = builder => builder.AddApiKeyInAuthorizationHeader(); return this; } /// - /// Configures the API key provider type to . + /// Configures the API key provider type to . The provider denies all keys unless configured. /// /// The current . public DefaultAuthenticationFeature UseAdminApiKey() => UseApiKeyAuthorization(); + + /// + /// Configures the admin API key provider with an explicit API key. + /// + /// The API key to accept. + /// The current . + public DefaultAuthenticationFeature UseAdminApiKey(string apiKey) + { + Services.Configure(options => options.ApiKey = apiKey); + return UseAdminApiKey(); + } + + /// + /// Configures the admin API key provider with an explicit API key. + /// + /// The admin API key options to configure. + /// The current . + public DefaultAuthenticationFeature UseAdminApiKey(Action configure) + { + Services.Configure(configure); + return UseAdminApiKey(); + } + + /// + /// Enables the all-zero development admin API key. Do not use in production. + /// + /// The current . + public DefaultAuthenticationFeature UseDevelopmentAdminApiKey() => UseAdminApiKey(AdminApiKeyProvider.DevelopmentApiKey); /// /// Disables the local host requirement for the security root policy. @@ -64,6 +94,7 @@ public class DefaultAuthenticationFeature : FeatureBase public override void Apply() { Services.ConfigureOptions(); + Services.Configure(_ => { }); Services.AddIdentityTokenOptionsValidation(); var authBuilder = Services diff --git a/src/modules/Elsa.Identity/Features/IdentityFeature.cs b/src/modules/Elsa.Identity/Features/IdentityFeature.cs index 66b8bd735..15a22f69d 100644 --- a/src/modules/Elsa.Identity/Features/IdentityFeature.cs +++ b/src/modules/Elsa.Identity/Features/IdentityFeature.cs @@ -102,7 +102,7 @@ public class IdentityFeature : FeatureBase } /// - /// Configures the feature to use . + /// Configures the feature to use . The provider denies all users unless configured. /// public void UseAdminUserProvider() { @@ -110,6 +110,24 @@ public class IdentityFeature : FeatureBase RoleProvider = sp => sp.GetRequiredService(); } + /// + /// Configures the feature to use with an explicit admin user. + /// + public void UseAdminUserProvider(Action configure) + { + UseAdminUserProvider(); + Services.Configure(configure); + } + + /// + /// Configures the feature to use the development admin user. Do not use in production. + /// + public void UseDevelopmentAdminUserProvider() => UseAdminUserProvider(options => + { + options.UserName = "admin"; + options.Password = "password"; + }); + /// /// Configures the feature to use . /// @@ -149,6 +167,7 @@ public class IdentityFeature : FeatureBase { Services.Configure(TokenOptions); Services.Configure(ApiKeyDefaults.AuthenticationScheme, ApiKeyOptions); + Services.Configure(_ => { }); Services.Configure(UsersOptions); Services.Configure(ApplicationsOptions); Services.Configure(RolesOptions); diff --git a/src/modules/Elsa.Identity/Options/AdminApiKeyOptions.cs b/src/modules/Elsa.Identity/Options/AdminApiKeyOptions.cs new file mode 100644 index 000000000..21d8b53da --- /dev/null +++ b/src/modules/Elsa.Identity/Options/AdminApiKeyOptions.cs @@ -0,0 +1,22 @@ +namespace Elsa.Identity.Options; + +/// +/// Options for the built-in admin API key provider. +/// +public class AdminApiKeyOptions +{ + /// + /// Gets or sets the API key to accept. Leave empty to disable the provider. + /// + public string ApiKey { get; set; } = ""; + + /// + /// Gets or sets the owner name assigned to the API key identity. + /// + public string OwnerName { get; set; } = "admin"; + + /// + /// Gets or sets the permissions assigned to the API key identity. + /// + public ICollection Permissions { get; set; } = ["*"]; +} diff --git a/src/modules/Elsa.Identity/Options/AdminUserProviderOptions.cs b/src/modules/Elsa.Identity/Options/AdminUserProviderOptions.cs new file mode 100644 index 000000000..a0bdb47a7 --- /dev/null +++ b/src/modules/Elsa.Identity/Options/AdminUserProviderOptions.cs @@ -0,0 +1,27 @@ +namespace Elsa.Identity.Options; + +/// +/// Options for the built-in admin user provider. +/// +public class AdminUserProviderOptions +{ + /// + /// Gets or sets the user ID assigned to the configured admin user. + /// + public string UserId { get; set; } = "admin"; + + /// + /// Gets or sets the admin user name to accept. Leave empty to disable the provider. + /// + public string UserName { get; set; } = ""; + + /// + /// Gets or sets the admin password to accept. Leave empty to disable the provider. + /// + public string Password { get; set; } = ""; + + /// + /// Gets or sets the roles assigned to the configured admin user. + /// + public ICollection Roles { get; set; } = ["admin"]; +} diff --git a/src/modules/Elsa.Identity/Providers/AdminApiKeyProvider.cs b/src/modules/Elsa.Identity/Providers/AdminApiKeyProvider.cs index 0fb8a189b..9a4069f4b 100644 --- a/src/modules/Elsa.Identity/Providers/AdminApiKeyProvider.cs +++ b/src/modules/Elsa.Identity/Providers/AdminApiKeyProvider.cs @@ -1,27 +1,44 @@ using System.Security.Claims; using AspNetCore.Authentication.ApiKey; using Elsa.Identity.Models; +using Elsa.Identity.Options; +using Microsoft.Extensions.Options; namespace Elsa.Identity.Providers; /// -/// Provides an with admin privileges for the default admin API key. +/// Provides an with admin privileges for an explicitly configured admin API key. /// -public class AdminApiKeyProvider : IApiKeyProvider +public class AdminApiKeyProvider(IOptions options) : IApiKeyProvider { /// - /// The default admin API key. + /// Initializes a new instance of the class with no accepted API key. /// - public static readonly string DefaultApiKey = Guid.Empty.ToString(); + [Obsolete("Use the options-based constructor. The built-in admin API key is disabled unless explicitly configured.")] + public AdminApiKeyProvider() : this(Microsoft.Extensions.Options.Options.Create(new AdminApiKeyOptions())) + { + } + + /// + /// The all-zero development admin API key. Do not enable in production. + /// + public static readonly string DevelopmentApiKey = Guid.Empty.ToString(); + + /// + /// The legacy development admin API key. + /// + [Obsolete("Use DevelopmentApiKey. The built-in admin API key is disabled unless explicitly configured.")] + public static readonly string DefaultApiKey = DevelopmentApiKey; /// public Task ProvideAsync(string key) { - if(key != DefaultApiKey) + var apiKeyOptions = options.Value; + if (string.IsNullOrWhiteSpace(apiKeyOptions.ApiKey) || key != apiKeyOptions.ApiKey) return Task.FromResult(null); - var claims = new List { new("permissions", "*") }; - var apiKey = new ApiKey(key, "admin", claims); + var claims = apiKeyOptions.Permissions.Select(permission => new Claim("permissions", permission)).ToList(); + var apiKey = new ApiKey(key, apiKeyOptions.OwnerName, claims); return Task.FromResult(apiKey)!; } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Identity/Providers/AdminUserProvider.cs b/src/modules/Elsa.Identity/Providers/AdminUserProvider.cs index a1721c945..46d21432a 100644 --- a/src/modules/Elsa.Identity/Providers/AdminUserProvider.cs +++ b/src/modules/Elsa.Identity/Providers/AdminUserProvider.cs @@ -1,36 +1,59 @@ using Elsa.Identity.Contracts; using Elsa.Identity.Entities; using Elsa.Identity.Models; +using Elsa.Identity.Options; +using Microsoft.Extensions.Options; namespace Elsa.Identity.Providers; /// -/// Represents a user provider that always returns a single admin user. This is useful for development purposes. +/// Represents a user provider that returns a single explicitly configured admin user. This is useful for development purposes. /// public class AdminUserProvider : IUserProvider { - private readonly User _adminUser; + private readonly User? _adminUser; /// /// Initializes a new instance of the class. /// - public AdminUserProvider(ISecretHasher secretHasher) + [Obsolete("Use the options-based constructor. The built-in admin user is disabled unless explicitly configured.")] + public AdminUserProvider(ISecretHasher secretHasher) : this(secretHasher, Microsoft.Extensions.Options.Options.Create(new AdminUserProviderOptions())) { - var hashedSecret = secretHasher.HashSecret("password"); + } + + /// + /// Initializes a new instance of the class. + /// + public AdminUserProvider(ISecretHasher secretHasher, IOptions options) + { + var providerOptions = options.Value; + if (string.IsNullOrWhiteSpace(providerOptions.UserName) || string.IsNullOrWhiteSpace(providerOptions.Password)) + return; + + var hashedSecret = secretHasher.HashSecret(providerOptions.Password); _adminUser = new User { - Id = "admin", - Name = "admin", + Id = providerOptions.UserId, + Name = providerOptions.UserName, HashedPassword = hashedSecret.EncodeSecret(), HashedPasswordSalt = hashedSecret.EncodeSalt(), - Roles = { "admin" } + Roles = providerOptions.Roles.ToList() }; } /// public Task FindAsync(UserFilter filter, CancellationToken cancellationToken = default) { - return Task.FromResult(_adminUser)!; + if (_adminUser == null) + return Task.FromResult(null); + + if (filter.Id != null && filter.Id != _adminUser.Id) + return Task.FromResult(null); + + if (filter.Name != null && filter.Name != _adminUser.Name) + return Task.FromResult(null); + + return Task.FromResult(_adminUser); } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Identity/README.md b/src/modules/Elsa.Identity/README.md index 5fec28bb4..0f3a3acb9 100644 --- a/src/modules/Elsa.Identity/README.md +++ b/src/modules/Elsa.Identity/README.md @@ -27,7 +27,7 @@ Example (`appsettings.json`): "DefaultAuthentication": {}, "DefaultAdminUser": { "AdminUserName": "admin", - "AdminPassword": "password", + "AdminPassword": "REPLACE_WITH_SECURE_BOOTSTRAP_PASSWORD", "AdminRoleName": "admin", "AdminRolePermissions": ["*"] } @@ -57,7 +57,7 @@ services.AddElsa(elsa => identity.UseDefaultAdmin(admin => admin .WithAdminUserName("admin") - .WithAdminPassword("password") + .WithAdminPassword("REPLACE_WITH_SECURE_BOOTSTRAP_PASSWORD") .WithAdminRoleName("admin") .WithAdminRolePermissions(new List { "*" })); }) @@ -68,13 +68,12 @@ services.AddElsa(elsa => You can also use the shorthand overload: ```csharp -identity.UseDefaultAdmin("admin", "password", "admin", new List { "*" }); +identity.UseDefaultAdmin("admin", "REPLACE_WITH_SECURE_BOOTSTRAP_PASSWORD", "admin", new List { "*" }); ``` ### Operational notes - The initializer is idempotent: existing admin role/user are not recreated. -- Do not keep development defaults (`admin` / `password`) in production. +- Do not keep development defaults in production. - Prefer environment variables or a secret manager for admin credentials. - After first bootstrap, rotate credentials according to your security policy. - diff --git a/src/modules/Elsa.Identity/ShellFeatures/DefaultAuthenticationFeature.cs b/src/modules/Elsa.Identity/ShellFeatures/DefaultAuthenticationFeature.cs index f7f198c10..51f892a61 100644 --- a/src/modules/Elsa.Identity/ShellFeatures/DefaultAuthenticationFeature.cs +++ b/src/modules/Elsa.Identity/ShellFeatures/DefaultAuthenticationFeature.cs @@ -2,7 +2,9 @@ using AspNetCore.Authentication.ApiKey; using CShells.Features; using Elsa.Extensions; using Elsa.Identity.Constants; +using Elsa.Identity.Options; using Elsa.Identity.Providers; +using Elsa.PackageManifest.Generator.Hints; using Elsa.Requirements; using JetBrains.Annotations; using Microsoft.AspNetCore.Authentication.JwtBearer; @@ -28,10 +30,41 @@ public class DefaultAuthenticationFeature : IShellFeature /// public Type ApiKeyProviderType { get; set; } = typeof(DefaultApiKeyProvider); + /// + /// Gets or sets an explicit API key for . Leave empty to disable the provider. + /// + [ManifestSetting( + DisplayName = "Admin API Key", + Description = "Explicit API key for the admin API key provider. Leave empty to disable built-in admin API key authentication.", + Category = "Security", + Secret = true, + Sensitive = true, + RestartRequired = true)] + public string AdminApiKey { get; set; } = ""; + + /// + /// Gets or sets whether the all-zero development admin API key should be enabled. Do not enable in production. + /// + [ManifestSetting( + DisplayName = "Use Development Admin API Key", + Description = "Enables the all-zero development admin API key. Do not enable in production.", + Category = "Security", + DefaultValue = "false", + RestartRequired = true)] + public bool UseDevelopmentAdminApiKey { get; set; } + public void ConfigureServices(IServiceCollection services) { + var resolvedAdminApiKey = UseDevelopmentAdminApiKey ? AdminApiKeyProvider.DevelopmentApiKey : AdminApiKey; + if (!string.IsNullOrWhiteSpace(resolvedAdminApiKey)) + ApiKeyProviderType = typeof(AdminApiKeyProvider); + services.ConfigureOptions(); services.AddIdentityTokenOptionsValidation(); + services.Configure(options => + { + options.ApiKey = resolvedAdminApiKey; + }); var authBuilder = services .AddAuthentication(MultiScheme) diff --git a/src/modules/Elsa.Identity/ShellFeatures/IdentityFeature.cs b/src/modules/Elsa.Identity/ShellFeatures/IdentityFeature.cs index 3aff621c5..d1e8bb354 100644 --- a/src/modules/Elsa.Identity/ShellFeatures/IdentityFeature.cs +++ b/src/modules/Elsa.Identity/ShellFeatures/IdentityFeature.cs @@ -34,6 +34,7 @@ public class IdentityFeature : IFastEndpointsShellFeature options.Realm = "Elsa Workflows"; options.KeyName = "ApiKey"; }); + services.Configure(_ => { }); services.Configure(_ => { }); services.Configure(_ => { }); services.Configure(_ => { }); diff --git a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs index 04ae1027f..05b9ee254 100644 --- a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs +++ b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs @@ -74,7 +74,7 @@ public class WorkflowServer(Infrastructure infrastructure, string url) : WebAppl elsa.AddWorkflowsFrom(); elsa.AddActivitiesFrom(); elsa.AddActivityHost(); - elsa.UseDefaultAuthentication(defaultAuthentication => defaultAuthentication.UseAdminApiKey()); + elsa.UseDefaultAuthentication(defaultAuthentication => defaultAuthentication.UseDevelopmentAdminApiKey()); elsa.UseFluentStorageProvider(sp => { var assemblyLocation = Assembly.GetExecutingAssembly().Location; @@ -177,6 +177,6 @@ public class WorkflowServer(Infrastructure infrastructure, string url) : WebAppl protected override void ConfigureClient(HttpClient client) { - client.DefaultRequestHeaders.Authorization = new("ApiKey", AdminApiKeyProvider.DefaultApiKey); + client.DefaultRequestHeaders.Authorization = new("ApiKey", AdminApiKeyProvider.DevelopmentApiKey); } -} \ No newline at end of file +} diff --git a/test/unit/Elsa.Identity.UnitTests/Providers/AdminCredentialProviderTests.cs b/test/unit/Elsa.Identity.UnitTests/Providers/AdminCredentialProviderTests.cs new file mode 100644 index 000000000..e966b1f04 --- /dev/null +++ b/test/unit/Elsa.Identity.UnitTests/Providers/AdminCredentialProviderTests.cs @@ -0,0 +1,91 @@ +using Elsa.Identity.Options; +using Elsa.Identity.Providers; +using Elsa.Identity.Services; +using OptionsFactory = Microsoft.Extensions.Options.Options; + +namespace Elsa.Identity.UnitTests.Providers; + +public class AdminCredentialProviderTests +{ + private readonly DefaultSecretHasher _secretHasher = new(); + + [Fact] + public async Task AdminApiKeyProviderDeniesDevelopmentApiKeyByDefault() + { + var provider = CreateAdminApiKeyProvider(); + + var apiKey = await provider.ProvideAsync(AdminApiKeyProvider.DevelopmentApiKey); + + Assert.Null(apiKey); + } + + [Fact] + public async Task AdminApiKeyProviderAcceptsDevelopmentApiKeyWhenExplicitlyConfigured() + { + var provider = CreateAdminApiKeyProvider(options => options.ApiKey = AdminApiKeyProvider.DevelopmentApiKey); + + var apiKey = await provider.ProvideAsync(AdminApiKeyProvider.DevelopmentApiKey); + + Assert.NotNull(apiKey); + Assert.Equal("admin", apiKey.OwnerName); + Assert.Contains(apiKey.Claims, claim => claim.Type == "permissions" && claim.Value == "*"); + } + + [Theory] + [InlineData("admin")] + [InlineData("anyone")] + public async Task AdminUserProviderDeniesStaticPasswordByDefault(string userName) + { + var validator = CreateCredentialsValidator(); + + var user = await validator.ValidateAsync(userName, "password"); + + Assert.Null(user); + } + + [Fact] + public async Task AdminUserProviderAcceptsDevelopmentCredentialsWhenExplicitlyConfigured() + { + var validator = CreateCredentialsValidator(options => + { + options.UserName = "admin"; + options.Password = "password"; + }); + + var user = await validator.ValidateAsync("admin", "password"); + + Assert.NotNull(user); + Assert.Equal("admin", user.Name); + } + + [Fact] + public async Task AdminUserProviderDeniesArbitraryUsernameWhenDevelopmentCredentialsAreConfigured() + { + var validator = CreateCredentialsValidator(options => + { + options.UserName = "admin"; + options.Password = "password"; + }); + + var user = await validator.ValidateAsync("anyone", "password"); + + Assert.Null(user); + } + + private static AdminApiKeyProvider CreateAdminApiKeyProvider(Action? configure = null) + { + var options = new AdminApiKeyOptions(); + configure?.Invoke(options); + + return new(OptionsFactory.Create(options)); + } + + private DefaultUserCredentialsValidator CreateCredentialsValidator(Action? configure = null) + { + var options = new AdminUserProviderOptions(); + configure?.Invoke(options); + + var userProvider = new AdminUserProvider(_secretHasher, OptionsFactory.Create(options)); + return new(userProvider, _secretHasher); + } +} diff --git a/test/unit/Elsa.Identity.UnitTests/ShellFeatures/DefaultAuthenticationFeatureTests.cs b/test/unit/Elsa.Identity.UnitTests/ShellFeatures/DefaultAuthenticationFeatureTests.cs new file mode 100644 index 000000000..bc2c17a01 --- /dev/null +++ b/test/unit/Elsa.Identity.UnitTests/ShellFeatures/DefaultAuthenticationFeatureTests.cs @@ -0,0 +1,57 @@ +using AspNetCore.Authentication.ApiKey; +using Elsa.Identity.Options; +using Elsa.Identity.Providers; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using ShellDefaultAuthenticationFeature = Elsa.Identity.ShellFeatures.DefaultAuthenticationFeature; + +namespace Elsa.Identity.UnitTests.ShellFeatures; + +public class DefaultAuthenticationFeatureTests +{ + private readonly ShellDefaultAuthenticationFeature _feature = new(); + private readonly ServiceCollection _services = new(); + + [Fact] + public void UsesDefaultApiKeyProviderWhenAdminApiKeyIsNotConfigured() + { + using var serviceProvider = Activate(); + + Assert.Equal(typeof(DefaultApiKeyProvider), _feature.ApiKeyProviderType); + Assert.Equal(string.Empty, serviceProvider.GetRequiredService>().Value.ApiKey); + } + + [Fact] + public async Task UsesAdminApiKeyProviderWhenAdminApiKeyIsConfigured() + { + _feature.AdminApiKey = "configured-admin-api-key"; + + using var serviceProvider = Activate(); + + var provider = Assert.IsType(serviceProvider.GetRequiredService()); + var apiKey = await provider.ProvideAsync(_feature.AdminApiKey); + + Assert.Equal(typeof(AdminApiKeyProvider), _feature.ApiKeyProviderType); + Assert.NotNull(apiKey); + } + + [Fact] + public async Task UsesAdminApiKeyProviderWhenDevelopmentAdminApiKeyIsEnabled() + { + _feature.UseDevelopmentAdminApiKey = true; + + using var serviceProvider = Activate(); + + var provider = Assert.IsType(serviceProvider.GetRequiredService()); + var apiKey = await provider.ProvideAsync(AdminApiKeyProvider.DevelopmentApiKey); + + Assert.Equal(typeof(AdminApiKeyProvider), _feature.ApiKeyProviderType); + Assert.NotNull(apiKey); + } + + private ServiceProvider Activate() + { + _feature.ConfigureServices(_services); + return _services.BuildServiceProvider(); + } +}