diff --git a/web/backend/API/API.Core/API.Core.csproj b/web/backend/API/API.Core/API.Core.csproj index f53cbdcd..953b4f3a 100644 --- a/web/backend/API/API.Core/API.Core.csproj +++ b/web/backend/API/API.Core/API.Core.csproj @@ -18,6 +18,7 @@ + diff --git a/web/backend/API/API.Core/Authentication/JwtAuthenticationHandler.cs b/web/backend/API/API.Core/Authentication/JwtAuthenticationHandler.cs index e0de10d6..35915fe1 100644 --- a/web/backend/API/API.Core/Authentication/JwtAuthenticationHandler.cs +++ b/web/backend/API/API.Core/Authentication/JwtAuthenticationHandler.cs @@ -1,5 +1,6 @@ using System.Security.Claims; using System.Text.Encodings.Web; +using Infrastructure.Configuration; using Infrastructure.Jwt; using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Options; @@ -32,9 +33,9 @@ public class JwtAuthenticationHandler( protected override async Task HandleAuthenticateAsync() { // Use the same access-token secret source as TokenService to avoid mismatched validation. - string? secret = configuration["ACCESS_TOKEN_SECRET"]; + string? secret = configuration[ConfigurationKeys.AccessTokenSecret]; if (string.IsNullOrWhiteSpace(secret)) - secret = configuration["Jwt:SecretKey"]; + secret = configuration[ConfigurationKeys.JwtSecretKeyFallback]; if (string.IsNullOrWhiteSpace(secret)) return AuthenticateResult.Fail("JWT secret is not configured"); diff --git a/web/backend/API/API.Specs/API.Specs.csproj b/web/backend/API/API.Specs/API.Specs.csproj index 3dffa56b..2cd5bccc 100644 --- a/web/backend/API/API.Specs/API.Specs.csproj +++ b/web/backend/API/API.Specs/API.Specs.csproj @@ -38,6 +38,7 @@ + diff --git a/web/backend/API/API.Specs/Steps/AuthSteps.cs b/web/backend/API/API.Specs/Steps/AuthSteps.cs index a49f1308..9b1499ec 100644 --- a/web/backend/API/API.Specs/Steps/AuthSteps.cs +++ b/web/backend/API/API.Specs/Steps/AuthSteps.cs @@ -1,6 +1,7 @@ using System.Text; using System.Text.Json; using FluentAssertions; +using Infrastructure.Configuration; using Infrastructure.Jwt; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -430,7 +431,7 @@ public class AuthSteps(ScenarioContext scenario) ? user : throw new InvalidOperationException("registered username not found in scenario"); - string secret = GetRequiredConfigValue("ACCESS_TOKEN_SECRET"); + string secret = GetRequiredConfigValue(ConfigurationKeys.AccessTokenSecret); scenario["accessToken"] = GenerateJwtToken( userId, username, @@ -449,7 +450,7 @@ public class AuthSteps(ScenarioContext scenario) ? user : throw new InvalidOperationException("registered username not found in scenario"); - string secret = GetRequiredConfigValue("CONFIRMATION_TOKEN_SECRET"); + string secret = GetRequiredConfigValue(ConfigurationKeys.ConfirmationTokenSecret); scenario["confirmationToken"] = GenerateJwtToken( userId, username, @@ -468,7 +469,7 @@ public class AuthSteps(ScenarioContext scenario) ? user : throw new InvalidOperationException("registered username not found in scenario"); - string secret = GetRequiredConfigValue("CONFIRMATION_TOKEN_SECRET"); + string secret = GetRequiredConfigValue(ConfigurationKeys.ConfirmationTokenSecret); scenario["confirmationToken"] = GenerateJwtToken( userId, username, diff --git a/web/backend/Database/Database.Connection/ConfigurationHelpers.cs b/web/backend/Database/Database.Connection/ConfigurationHelpers.cs deleted file mode 100644 index 73340cf0..00000000 --- a/web/backend/Database/Database.Connection/ConfigurationHelpers.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Microsoft.Extensions.Configuration; - -namespace Database.Connection; - -public static class ConfigurationHelpers -{ - public static string GetKeyOrThrow(IConfiguration configuration, string key) => - configuration[key] - ?? throw new InvalidOperationException($"The {key} environment variable is not set."); -} diff --git a/web/backend/Database/Database.Connection/Database.Connection.csproj b/web/backend/Database/Database.Connection/Database.Connection.csproj index 0cebb12a..ef78601b 100644 --- a/web/backend/Database/Database.Connection/Database.Connection.csproj +++ b/web/backend/Database/Database.Connection/Database.Connection.csproj @@ -11,11 +11,14 @@ + + + diff --git a/web/backend/Database/Database.Connection/DefaultSqlConnectionFactory.cs b/web/backend/Database/Database.Connection/DefaultSqlConnectionFactory.cs index c2b3996b..97e7f588 100644 --- a/web/backend/Database/Database.Connection/DefaultSqlConnectionFactory.cs +++ b/web/backend/Database/Database.Connection/DefaultSqlConnectionFactory.cs @@ -1,4 +1,4 @@ -using System.Data.Common; +using Infrastructure.Configuration; using Microsoft.Data.SqlClient; using Microsoft.Extensions.Configuration; @@ -19,7 +19,7 @@ public class DefaultSqlConnectionFactory(IConfiguration configuration) : ISqlCon { get { - string? fullConnectionString = configuration["DB_CONNECTION_STRING"]; + string? fullConnectionString = configuration[ConfigurationKeys.DbConnectionString]; if (!string.IsNullOrEmpty(fullConnectionString)) return fullConnectionString; diff --git a/web/backend/Database/Database.Connection/SqlConnectionStringHelper.cs b/web/backend/Database/Database.Connection/SqlConnectionStringHelper.cs index 97e1fba3..3c88788d 100644 --- a/web/backend/Database/Database.Connection/SqlConnectionStringHelper.cs +++ b/web/backend/Database/Database.Connection/SqlConnectionStringHelper.cs @@ -1,3 +1,4 @@ +using Infrastructure.Configuration; using Microsoft.Data.SqlClient; using Microsoft.Extensions.Configuration; @@ -14,9 +15,15 @@ public static class SqlConnectionStringHelper ) => new() { - DataSource = ConfigurationHelpers.GetKeyOrThrow(configuration, "DB_SERVER"), - UserID = ConfigurationHelpers.GetKeyOrThrow(configuration, "DB_USER"), - Password = ConfigurationHelpers.GetKeyOrThrow(configuration, "DB_PASSWORD"), + DataSource = ConfigurationHelpers.GetKeyOrThrow( + configuration, + ConfigurationKeys.DbServer + ), + UserID = ConfigurationHelpers.GetKeyOrThrow(configuration, ConfigurationKeys.DbUser), + Password = ConfigurationHelpers.GetKeyOrThrow( + configuration, + ConfigurationKeys.DbPassword + ), TrustServerCertificate = ResolveTrustServerCertificate(configuration), Encrypt = true, }; @@ -38,7 +45,10 @@ public static class SqlConnectionStringHelper try { SqlConnectionStringBuilder builder = MakeConfiguredConnectionBuilder(configuration); - builder.InitialCatalog = ConfigurationHelpers.GetKeyOrThrow(configuration, "DB_NAME"); + builder.InitialCatalog = ConfigurationHelpers.GetKeyOrThrow( + configuration, + ConfigurationKeys.DbName + ); connectionString = builder.ConnectionString; return true; } @@ -95,7 +105,7 @@ public static class SqlConnectionStringHelper private static bool ResolveTrustServerCertificate(IConfiguration configuration) { return !bool.TryParse( - configuration["DB_TRUST_SERVER_CERTIFICATE"], + configuration[ConfigurationKeys.DbTrustServerCertificate], out bool trustServerCertificate ) || trustServerCertificate; } diff --git a/web/backend/Database/Database.Migrations/Database.Migrations.csproj b/web/backend/Database/Database.Migrations/Database.Migrations.csproj index 42038c47..1e26fcd7 100644 --- a/web/backend/Database/Database.Migrations/Database.Migrations.csproj +++ b/web/backend/Database/Database.Migrations/Database.Migrations.csproj @@ -19,6 +19,7 @@ + diff --git a/web/backend/Database/Database.Migrations/Program.cs b/web/backend/Database/Database.Migrations/Program.cs index 7ad60ecb..cbded8f0 100644 --- a/web/backend/Database/Database.Migrations/Program.cs +++ b/web/backend/Database/Database.Migrations/Program.cs @@ -1,4 +1,5 @@ using Database.Connection; +using Infrastructure.Configuration; using Microsoft.Data.SqlClient; using Microsoft.Extensions.Configuration; @@ -21,7 +22,7 @@ internal static class Program .Build(); bool clearDatabase = string.Equals( - configuration["CLEAR_DATABASE"], + configuration[ConfigurationKeys.ClearDatabase], "true", StringComparison.OrdinalIgnoreCase ); diff --git a/web/backend/Features/Features.Auth.Tests/Features.Auth.Tests.csproj b/web/backend/Features/Features.Auth.Tests/Features.Auth.Tests.csproj index 7c743e53..aa3c8bff 100644 --- a/web/backend/Features/Features.Auth.Tests/Features.Auth.Tests.csproj +++ b/web/backend/Features/Features.Auth.Tests/Features.Auth.Tests.csproj @@ -21,5 +21,6 @@ + diff --git a/web/backend/Features/Features.Auth.Tests/Services/TokenServiceRefreshTests.cs b/web/backend/Features/Features.Auth.Tests/Services/TokenServiceRefreshTests.cs index b78bd5ae..2c8c3f0b 100644 --- a/web/backend/Features/Features.Auth.Tests/Services/TokenServiceRefreshTests.cs +++ b/web/backend/Features/Features.Auth.Tests/Services/TokenServiceRefreshTests.cs @@ -5,6 +5,7 @@ using Features.Auth.Identity; using Features.Auth.Services; using Features.Auth.Tests.TestSupport; using FluentAssertions; +using Infrastructure.Configuration; using Infrastructure.Jwt; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Configuration; @@ -27,9 +28,11 @@ public class TokenServiceRefreshTests .AddInMemoryCollection( new Dictionary { - ["ACCESS_TOKEN_SECRET"] = "test-access-secret-that-is-very-long-1234567890", - ["REFRESH_TOKEN_SECRET"] = "test-refresh-secret-that-is-very-long-1234567890", - ["CONFIRMATION_TOKEN_SECRET"] = + [ConfigurationKeys.AccessTokenSecret] = + "test-access-secret-that-is-very-long-1234567890", + [ConfigurationKeys.RefreshTokenSecret] = + "test-refresh-secret-that-is-very-long-1234567890", + [ConfigurationKeys.ConfirmationTokenSecret] = "test-confirmation-secret-that-is-very-long-1234567890", } ) diff --git a/web/backend/Features/Features.Auth.Tests/Services/TokenServiceValidationTests.cs b/web/backend/Features/Features.Auth.Tests/Services/TokenServiceValidationTests.cs index 0c06683b..df73095c 100644 --- a/web/backend/Features/Features.Auth.Tests/Services/TokenServiceValidationTests.cs +++ b/web/backend/Features/Features.Auth.Tests/Services/TokenServiceValidationTests.cs @@ -4,6 +4,7 @@ using Domain.Exceptions; using Features.Auth.Services; using Features.Auth.Tests.TestSupport; using FluentAssertions; +using Infrastructure.Configuration; using Infrastructure.Jwt; using Microsoft.Extensions.Configuration; using Moq; @@ -23,9 +24,11 @@ public class TokenServiceValidationTests .AddInMemoryCollection( new Dictionary { - ["ACCESS_TOKEN_SECRET"] = "test-access-secret-that-is-very-long-1234567890", - ["REFRESH_TOKEN_SECRET"] = "test-refresh-secret-that-is-very-long-1234567890", - ["CONFIRMATION_TOKEN_SECRET"] = + [ConfigurationKeys.AccessTokenSecret] = + "test-access-secret-that-is-very-long-1234567890", + [ConfigurationKeys.RefreshTokenSecret] = + "test-refresh-secret-that-is-very-long-1234567890", + [ConfigurationKeys.ConfirmationTokenSecret] = "test-confirmation-secret-that-is-very-long-1234567890", } ) diff --git a/web/backend/Features/Features.Auth/Features.Auth.csproj b/web/backend/Features/Features.Auth/Features.Auth.csproj index 1ea6af4c..53379c51 100644 --- a/web/backend/Features/Features.Auth/Features.Auth.csproj +++ b/web/backend/Features/Features.Auth/Features.Auth.csproj @@ -14,6 +14,7 @@ + diff --git a/web/backend/Features/Features.Auth/Services/TokenService.cs b/web/backend/Features/Features.Auth/Services/TokenService.cs index 00f3e4ce..d9ebe6d8 100644 --- a/web/backend/Features/Features.Auth/Services/TokenService.cs +++ b/web/backend/Features/Features.Auth/Services/TokenService.cs @@ -2,6 +2,7 @@ using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using Domain.Exceptions; using Features.Auth.Identity; +using Infrastructure.Configuration; using Infrastructure.Jwt; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Configuration; @@ -33,23 +34,20 @@ public class TokenService : ITokenService _tokenInfrastructure = tokenInfrastructure; _userManager = userManager; - _accessTokenSecret = - configuration["ACCESS_TOKEN_SECRET"] - ?? throw new InvalidOperationException( - "ACCESS_TOKEN_SECRET environment variable is not set" - ); + _accessTokenSecret = ConfigurationHelpers.GetKeyOrThrow( + configuration, + ConfigurationKeys.AccessTokenSecret + ); - _refreshTokenSecret = - configuration["REFRESH_TOKEN_SECRET"] - ?? throw new InvalidOperationException( - "REFRESH_TOKEN_SECRET environment variable is not set" - ); + _refreshTokenSecret = ConfigurationHelpers.GetKeyOrThrow( + configuration, + ConfigurationKeys.RefreshTokenSecret + ); - _confirmationTokenSecret = - configuration["CONFIRMATION_TOKEN_SECRET"] - ?? throw new InvalidOperationException( - "CONFIRMATION_TOKEN_SECRET environment variable is not set" - ); + _confirmationTokenSecret = ConfigurationHelpers.GetKeyOrThrow( + configuration, + ConfigurationKeys.ConfirmationTokenSecret + ); } /// diff --git a/web/backend/Features/Features.Emails/Features.Emails.csproj b/web/backend/Features/Features.Emails/Features.Emails.csproj index 9271696c..713ce39d 100644 --- a/web/backend/Features/Features.Emails/Features.Emails.csproj +++ b/web/backend/Features/Features.Emails/Features.Emails.csproj @@ -14,6 +14,7 @@ + diff --git a/web/backend/Features/Features.Emails/Services/EmailDispatcher.cs b/web/backend/Features/Features.Emails/Services/EmailDispatcher.cs index 32395220..2fb89f19 100644 --- a/web/backend/Features/Features.Emails/Services/EmailDispatcher.cs +++ b/web/backend/Features/Features.Emails/Services/EmailDispatcher.cs @@ -1,3 +1,4 @@ +using Infrastructure.Configuration; using Infrastructure.Email; using Infrastructure.Email.Templates.Rendering; using Microsoft.Extensions.Configuration; @@ -21,8 +22,7 @@ public class EmailDispatcher( /// Thrown when the WEBSITE_BASE_URL configuration value is not set. /// private string WebsiteBaseUrl => - configuration["WEBSITE_BASE_URL"] - ?? throw new InvalidOperationException("WEBSITE_BASE_URL environment variable is not set"); + ConfigurationHelpers.GetKeyOrThrow(configuration, ConfigurationKeys.WebsiteBaseUrl); /// public async Task SendRegistrationEmailAsync( diff --git a/web/backend/Infrastructure/Infrastructure.Configuration/ConfigurationHelpers.cs b/web/backend/Infrastructure/Infrastructure.Configuration/ConfigurationHelpers.cs new file mode 100644 index 00000000..4238565f --- /dev/null +++ b/web/backend/Infrastructure/Infrastructure.Configuration/ConfigurationHelpers.cs @@ -0,0 +1,32 @@ +using Microsoft.Extensions.Configuration; + +namespace Infrastructure.Configuration; + +public static class ConfigurationHelpers +{ + public static string GetKeyOrThrow(IConfiguration configuration, string key) => + configuration[key] + ?? throw new InvalidOperationException($"The {key} environment variable is not set."); + + public static bool GetBooleanKeyOrThrow(IConfiguration configuration, string key) + { + string value = GetKeyOrThrow(configuration, key); + + return bool.TryParse(value, out bool result) + ? result + : throw new InvalidOperationException( + $"The {key} environment variable is set to '{value}', which is not a valid boolean." + ); + } + + public static int GetIntKeyOrThrow(IConfiguration configuration, string key) + { + string value = GetKeyOrThrow(configuration, key); + + return int.TryParse(value, out int result) + ? result + : throw new InvalidOperationException( + $"The {key} environment variable is set to '{value}', which is not a valid integer." + ); + } +} diff --git a/web/backend/Infrastructure/Infrastructure.Configuration/ConfigurationKeys.cs b/web/backend/Infrastructure/Infrastructure.Configuration/ConfigurationKeys.cs new file mode 100644 index 00000000..0238734e --- /dev/null +++ b/web/backend/Infrastructure/Infrastructure.Configuration/ConfigurationKeys.cs @@ -0,0 +1,33 @@ +namespace Infrastructure.Configuration; + +public static class ConfigurationKeys +{ + public const string AccessTokenSecret = "ACCESS_TOKEN_SECRET"; + public const string RefreshTokenSecret = "REFRESH_TOKEN_SECRET"; + public const string ConfirmationTokenSecret = "CONFIRMATION_TOKEN_SECRET"; + + /// + /// Fallback configuration section path for the access-token signing key, checked when + /// is not set. + /// + public const string JwtSecretKeyFallback = "Jwt:SecretKey"; + + public const string WebsiteBaseUrl = "WEBSITE_BASE_URL"; + + public const string SmtpHost = "SMTP_HOST"; + public const string SmtpPort = "SMTP_PORT"; + public const string SmtpUsername = "SMTP_USERNAME"; + public const string SmtpPassword = "SMTP_PASSWORD"; + public const string SmtpUseSsl = "SMTP_USE_SSL"; + public const string SmtpFromEmail = "SMTP_FROM_EMAIL"; + public const string SmtpFromName = "SMTP_FROM_NAME"; + + public const string DbServer = "DB_SERVER"; + public const string DbUser = "DB_USER"; + public const string DbPassword = "DB_PASSWORD"; + public const string DbName = "DB_NAME"; + public const string DbConnectionString = "DB_CONNECTION_STRING"; + public const string DbTrustServerCertificate = "DB_TRUST_SERVER_CERTIFICATE"; + + public const string ClearDatabase = "CLEAR_DATABASE"; +} diff --git a/web/backend/Infrastructure/Infrastructure.Configuration/Infrastructure.Configuration.csproj b/web/backend/Infrastructure/Infrastructure.Configuration/Infrastructure.Configuration.csproj new file mode 100644 index 00000000..a4fbf1bb --- /dev/null +++ b/web/backend/Infrastructure/Infrastructure.Configuration/Infrastructure.Configuration.csproj @@ -0,0 +1,15 @@ + + + net10.0 + enable + enable + Infrastructure.Configuration + + + + + + diff --git a/web/backend/Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj b/web/backend/Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj index 24c165b5..7869192a 100644 --- a/web/backend/Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj +++ b/web/backend/Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj @@ -13,4 +13,8 @@ Version="10.0.1" /> + + + + diff --git a/web/backend/Infrastructure/Infrastructure.Email/SmtpEmailProvider.cs b/web/backend/Infrastructure/Infrastructure.Email/SmtpEmailProvider.cs index 724eb2d4..2c97a641 100644 --- a/web/backend/Infrastructure/Infrastructure.Email/SmtpEmailProvider.cs +++ b/web/backend/Infrastructure/Infrastructure.Email/SmtpEmailProvider.cs @@ -1,4 +1,5 @@ -using MailKit.Net.Smtp; +using Infrastructure.Configuration; +using MailKit.Net.Smtp; using MailKit.Security; using Microsoft.Extensions.Configuration; using MimeKit; @@ -25,31 +26,27 @@ public class SmtpEmailProvider : IEmailProvider /// SMTP_FROM_EMAIL, SMTP_FROM_NAME) from . /// /// - /// Thrown when SMTP_HOST or SMTP_FROM_EMAIL is not set, or when SMTP_PORT is not a valid integer. + /// Thrown when SMTP_HOST, SMTP_PORT, or SMTP_FROM_EMAIL is not set, + /// or when SMTP_PORT is not a valid integer. /// public SmtpEmailProvider(IConfiguration configuration) { - _host = - configuration["SMTP_HOST"] - ?? throw new InvalidOperationException("SMTP_HOST environment variable is not set"); + _host = ConfigurationHelpers.GetKeyOrThrow(configuration, ConfigurationKeys.SmtpHost); - string portString = configuration["SMTP_PORT"] ?? "587"; - if (!int.TryParse(portString, out _port)) - throw new InvalidOperationException($"SMTP_PORT '{portString}' is not a valid integer"); + _port = ConfigurationHelpers.GetIntKeyOrThrow(configuration, ConfigurationKeys.SmtpPort); - _username = configuration["SMTP_USERNAME"]; - _password = configuration["SMTP_PASSWORD"]; + _username = configuration[ConfigurationKeys.SmtpUsername]; + _password = configuration[ConfigurationKeys.SmtpPassword]; - string useSslString = configuration["SMTP_USE_SSL"] ?? "true"; + string useSslString = configuration[ConfigurationKeys.SmtpUseSsl] ?? "true"; _useSsl = bool.Parse(useSslString); - _fromEmail = - configuration["SMTP_FROM_EMAIL"] - ?? throw new InvalidOperationException( - "SMTP_FROM_EMAIL environment variable is not set" - ); + _fromEmail = ConfigurationHelpers.GetKeyOrThrow( + configuration, + ConfigurationKeys.SmtpFromEmail + ); - _fromName = configuration["SMTP_FROM_NAME"] ?? "The Biergarten"; + _fromName = configuration[ConfigurationKeys.SmtpFromName] ?? "The Biergarten"; } ///