diff --git a/app/Enums/HttpBasicAuthHashAlgorithm.php b/app/Enums/HttpBasicAuthHashAlgorithm.php new file mode 100644 index 0000000000..c6340c0a1f --- /dev/null +++ b/app/Enums/HttpBasicAuthHashAlgorithm.php @@ -0,0 +1,9 @@ + ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], + 'http_basic_auth_hash_algorithm' => ['type' => 'string', 'enum' => ['bcrypt', 'argon2id'], 'default' => 'bcrypt', 'description' => 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'], + 'http_basic_auth_bcrypt_cost' => ['type' => 'integer', 'minimum' => 4, 'maximum' => 14, 'default' => 10, 'description' => 'Bcrypt cost for the HTTP Basic Authentication password.'], + 'http_basic_auth_argon2id_memory_cost' => ['type' => 'integer', 'minimum' => 8192, 'maximum' => 262144, 'default' => 65536, 'description' => 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'], + 'http_basic_auth_argon2id_time_cost' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 12, 'default' => 4, 'description' => 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], @@ -620,6 +625,10 @@ class ApplicationsController extends Controller 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], + 'http_basic_auth_hash_algorithm' => ['type' => 'string', 'enum' => ['bcrypt', 'argon2id'], 'default' => 'bcrypt', 'description' => 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'], + 'http_basic_auth_bcrypt_cost' => ['type' => 'integer', 'minimum' => 4, 'maximum' => 14, 'default' => 10, 'description' => 'Bcrypt cost for the HTTP Basic Authentication password.'], + 'http_basic_auth_argon2id_memory_cost' => ['type' => 'integer', 'minimum' => 8192, 'maximum' => 262144, 'default' => 65536, 'description' => 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'], + 'http_basic_auth_argon2id_time_cost' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 12, 'default' => 4, 'description' => 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], @@ -815,6 +824,10 @@ class ApplicationsController extends Controller 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], + 'http_basic_auth_hash_algorithm' => ['type' => 'string', 'enum' => ['bcrypt', 'argon2id'], 'default' => 'bcrypt', 'description' => 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'], + 'http_basic_auth_bcrypt_cost' => ['type' => 'integer', 'minimum' => 4, 'maximum' => 14, 'default' => 10, 'description' => 'Bcrypt cost for the HTTP Basic Authentication password.'], + 'http_basic_auth_argon2id_memory_cost' => ['type' => 'integer', 'minimum' => 8192, 'maximum' => 262144, 'default' => 65536, 'description' => 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'], + 'http_basic_auth_argon2id_time_cost' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 12, 'default' => 4, 'description' => 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], @@ -981,6 +994,10 @@ class ApplicationsController extends Controller 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], + 'http_basic_auth_hash_algorithm' => ['type' => 'string', 'enum' => ['bcrypt', 'argon2id'], 'default' => 'bcrypt', 'description' => 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'], + 'http_basic_auth_bcrypt_cost' => ['type' => 'integer', 'minimum' => 4, 'maximum' => 14, 'default' => 10, 'description' => 'Bcrypt cost for the HTTP Basic Authentication password.'], + 'http_basic_auth_argon2id_memory_cost' => ['type' => 'integer', 'minimum' => 8192, 'maximum' => 262144, 'default' => 65536, 'description' => 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'], + 'http_basic_auth_argon2id_time_cost' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 12, 'default' => 4, 'description' => 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], @@ -1143,6 +1160,10 @@ class ApplicationsController extends Controller 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], + 'http_basic_auth_hash_algorithm' => ['type' => 'string', 'enum' => ['bcrypt', 'argon2id'], 'default' => 'bcrypt', 'description' => 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'], + 'http_basic_auth_bcrypt_cost' => ['type' => 'integer', 'minimum' => 4, 'maximum' => 14, 'default' => 10, 'description' => 'Bcrypt cost for the HTTP Basic Authentication password.'], + 'http_basic_auth_argon2id_memory_cost' => ['type' => 'integer', 'minimum' => 8192, 'maximum' => 262144, 'default' => 65536, 'description' => 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'], + 'http_basic_auth_argon2id_time_cost' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 12, 'default' => 4, 'description' => 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], @@ -1228,7 +1249,7 @@ class ApplicationsController extends Controller if ($return instanceof JsonResponse) { return $return; } - $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'noindex_domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'use_build_secrets', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'tags', 'is_preserve_repository_enabled', 'preview_url_template', 'max_restart_count', ...self::APPLICATION_SETTING_FIELDS]; + $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'noindex_domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'use_build_secrets', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'http_basic_auth_hash_algorithm', 'http_basic_auth_bcrypt_cost', 'http_basic_auth_argon2id_memory_cost', 'http_basic_auth_argon2id_time_cost', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'tags', 'is_preserve_repository_enabled', 'preview_url_template', 'max_restart_count', ...self::APPLICATION_SETTING_FIELDS]; $validator = customApiValidator($request->all(), [ 'name' => 'string|max:255', @@ -1240,7 +1261,9 @@ class ApplicationsController extends Controller 'destination_uuid' => 'string', 'is_http_basic_auth_enabled' => 'boolean', 'http_basic_auth_username' => 'string|nullable', - 'http_basic_auth_password' => 'string|nullable', + // bcrypt ignores everything after 72 bytes. Labels for Traefik always use bcrypt and are also generated on + // Caddy servers when "Labels for all supported proxies" is selected, so that the proxy can be switched easily. + 'http_basic_auth_password' => 'string|nullable|max:72', 'autogenerate_domain' => 'boolean', 'tags' => 'array|nullable', 'tags.*' => 'string|min:2', @@ -3106,6 +3129,10 @@ class ApplicationsController extends Controller 'watch_paths' => ['type' => 'string', 'description' => 'The watch paths.'], 'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'], 'use_build_secrets' => ['type' => 'boolean', 'description' => 'Use Docker Build Secrets for build-time environment variables.'], + 'http_basic_auth_hash_algorithm' => ['type' => 'string', 'enum' => ['bcrypt', 'argon2id'], 'description' => 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'], + 'http_basic_auth_bcrypt_cost' => ['type' => 'integer', 'minimum' => 4, 'maximum' => 14, 'description' => 'Bcrypt cost for the HTTP Basic Authentication password.'], + 'http_basic_auth_argon2id_memory_cost' => ['type' => 'integer', 'minimum' => 8192, 'maximum' => 262144, 'description' => 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'], + 'http_basic_auth_argon2id_time_cost' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 12, 'description' => 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'], 'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'], 'is_git_lfs_enabled' => ['type' => 'boolean', 'description' => 'Enable Git LFS.'], 'is_git_shallow_clone_enabled' => ['type' => 'boolean', 'description' => 'Use a shallow Git clone.'], @@ -3226,7 +3253,7 @@ class ApplicationsController extends Controller $dnsCleanup = app(ManagedDnsRecordCleanup::class); $previousDnsHostnames = $dnsCleanup->hostnamesOf($application); $server = $application->destination->server; - $allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'noindex_domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'use_build_secrets', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled', 'preview_url_template', 'max_restart_count', ...self::APPLICATION_SETTING_FIELDS]; + $allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'noindex_domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'use_build_secrets', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'http_basic_auth_hash_algorithm', 'http_basic_auth_bcrypt_cost', 'http_basic_auth_argon2id_memory_cost', 'http_basic_auth_argon2id_time_cost', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled', 'preview_url_template', 'max_restart_count', ...self::APPLICATION_SETTING_FIELDS]; $validationRules = [ 'name' => 'string|max:255', @@ -3241,7 +3268,9 @@ class ApplicationsController extends Controller 'is_http_basic_auth_enabled' => 'boolean|nullable', 'is_preview_deployments_enabled' => 'boolean|nullable', 'http_basic_auth_username' => 'string', - 'http_basic_auth_password' => 'string', + // bcrypt ignores everything after 72 bytes. Labels for Traefik always use bcrypt and are also generated on + // Caddy servers when "Labels for all supported proxies" is selected, so that the proxy can be switched easily. + 'http_basic_auth_password' => 'string|max:72', 'include_source_commit_in_build' => 'boolean', 'ports_exposes' => 'nullable|string|regex:/^(\d+)(,\d+)*$/', ]; @@ -3322,7 +3351,8 @@ class ApplicationsController extends Controller $requestHasHttpBasicAuth = $request->has('is_http_basic_auth_enabled') || $request->has('http_basic_auth_username') - || $request->has('http_basic_auth_password'); + || $request->has('http_basic_auth_password') + || $request->hasAny(['http_basic_auth_hash_algorithm', 'http_basic_auth_bcrypt_cost', 'http_basic_auth_argon2id_memory_cost', 'http_basic_auth_argon2id_time_cost']); if ($request->has('is_http_basic_auth_enabled') && $request->is_http_basic_auth_enabled === true) { if (blank($application->http_basic_auth_username) || blank($application->http_basic_auth_password)) { @@ -4867,6 +4897,15 @@ class ApplicationsController extends Controller { $teamId = getTeamIdFromToken(); + if ($request->input('http_basic_auth_hash_algorithm') === HttpBasicAuthHashAlgorithm::ARGON2ID->value && ! $server->caddySupportsArgon2idBasicAuth()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + 'http_basic_auth_hash_algorithm' => 'Argon2id needs a server that runs the Caddy proxy, version 2.11 or newer.', + ], + ], 422); + } + // Validate ports_mappings if ($request->has('ports_mappings')) { $ports = []; diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index 5d272b36fa..0e13b97501 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -2,6 +2,7 @@ namespace App\Livewire\Project\Application; +use App\Enums\HttpBasicAuthHashAlgorithm; use App\Enums\StaticImageTypes; use App\Jobs\ApplicationDeploymentJob; use App\Livewire\Project\Service\Storage; @@ -118,6 +119,14 @@ class General extends Component public ?string $httpBasicAuthPassword = null; + public string $httpBasicAuthHashAlgorithm = HttpBasicAuthHashAlgorithm::BCRYPT->value; + + public int $httpBasicAuthBcryptCost = 10; + + public int $httpBasicAuthArgon2idMemoryCost = 65536; + + public int $httpBasicAuthArgon2idTimeCost = 4; + public ?string $watchPaths = null; public string $redirect; @@ -193,7 +202,13 @@ class General extends Component 'isPreserveRepositoryEnabled' => 'boolean|required', 'isHttpBasicAuthEnabled' => 'boolean|required', 'httpBasicAuthUsername' => 'string|nullable', - 'httpBasicAuthPassword' => 'string|nullable', + // bcrypt ignores everything after 72 bytes. Labels for Traefik always use bcrypt and are also generated on + // Caddy servers when "Labels for all supported proxies" is selected, so that the proxy can be switched easily. + 'httpBasicAuthPassword' => 'string|nullable|max:72', + 'httpBasicAuthHashAlgorithm' => ['required', Rule::enum(HttpBasicAuthHashAlgorithm::class)], + 'httpBasicAuthBcryptCost' => 'required|integer|min:4|max:14', + 'httpBasicAuthArgon2idMemoryCost' => 'required|integer|min:8192|max:262144', + 'httpBasicAuthArgon2idTimeCost' => 'required|integer|min:1|max:12', 'watchPaths' => 'nullable', 'redirect' => 'string|required', ]; @@ -367,6 +382,9 @@ class General extends Component if ($toModel) { $this->validate(); $this->validateChangedDockerComposeRaw(); + if ($this->httpBasicAuthHashAlgorithm === HttpBasicAuthHashAlgorithm::ARGON2ID->value && ! $this->application->destination->server->caddySupportsArgon2idBasicAuth()) { + throw new Exception('Argon2id needs a server that runs the Caddy proxy, version 2.11 or newer.'); + } // Application properties $this->application->name = $this->name; @@ -407,6 +425,10 @@ class General extends Component $this->application->is_http_basic_auth_enabled = $this->isHttpBasicAuthEnabled; $this->application->http_basic_auth_username = $this->httpBasicAuthUsername; $this->application->http_basic_auth_password = $this->httpBasicAuthPassword; + $this->application->http_basic_auth_hash_algorithm = $this->httpBasicAuthHashAlgorithm; + $this->application->http_basic_auth_bcrypt_cost = $this->httpBasicAuthBcryptCost; + $this->application->http_basic_auth_argon2id_memory_cost = $this->httpBasicAuthArgon2idMemoryCost; + $this->application->http_basic_auth_argon2id_time_cost = $this->httpBasicAuthArgon2idTimeCost; $this->application->watch_paths = $this->watchPaths; $this->application->redirect = $this->redirect; @@ -460,6 +482,10 @@ class General extends Component $this->httpBasicAuthPassword = auth()->user()->can('update', $this->application) ? $this->application->http_basic_auth_password : null; + $this->httpBasicAuthHashAlgorithm = ($this->application->usesArgon2idBasicAuth() ? HttpBasicAuthHashAlgorithm::ARGON2ID : HttpBasicAuthHashAlgorithm::BCRYPT)->value; + $this->httpBasicAuthBcryptCost = $this->application->http_basic_auth_bcrypt_cost; + $this->httpBasicAuthArgon2idMemoryCost = $this->application->http_basic_auth_argon2id_memory_cost; + $this->httpBasicAuthArgon2idTimeCost = $this->application->http_basic_auth_argon2id_time_cost; $this->watchPaths = $this->application->watch_paths; $this->redirect = $this->application->redirect; @@ -497,7 +523,7 @@ class General extends Component if ($oldIsSpa !== $this->isSpa) { $this->generateNginxConfiguration($this->isSpa ? 'spa' : 'static'); } - if ($oldIsHttpBasicAuthEnabled !== $this->isHttpBasicAuthEnabled) { + if ($oldIsHttpBasicAuthEnabled !== $this->isHttpBasicAuthEnabled || $this->application->isDirty(['http_basic_auth_hash_algorithm', 'http_basic_auth_bcrypt_cost', 'http_basic_auth_argon2id_memory_cost', 'http_basic_auth_argon2id_time_cost'])) { $this->application->save(); } diff --git a/app/Models/Application.php b/app/Models/Application.php index 277b7ec246..1c3c416bb1 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Enums\ApplicationDeploymentStatus; use App\Enums\BuildPackTypes; +use App\Enums\HttpBasicAuthHashAlgorithm; use App\Enums\ProxyTypes; use App\Exceptions\DeploymentException; use App\Services\ConfigurationGenerator; @@ -125,6 +126,10 @@ use Symfony\Component\Yaml\Yaml; 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], + 'http_basic_auth_hash_algorithm' => ['type' => 'string', 'enum' => ['bcrypt', 'argon2id'], 'description' => 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'], + 'http_basic_auth_bcrypt_cost' => ['type' => 'integer', 'minimum' => 4, 'maximum' => 14, 'description' => 'Bcrypt cost for the HTTP Basic Authentication password.'], + 'http_basic_auth_argon2id_memory_cost' => ['type' => 'integer', 'minimum' => 8192, 'maximum' => 262144, 'description' => 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'], + 'http_basic_auth_argon2id_time_cost' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 12, 'description' => 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'], new OA\Property(property: 'settings', ref: '#/components/schemas/ApplicationSetting'), ] )] @@ -220,6 +225,10 @@ class Application extends BaseModel 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', + 'http_basic_auth_hash_algorithm', + 'http_basic_auth_bcrypt_cost', + 'http_basic_auth_argon2id_memory_cost', + 'http_basic_auth_argon2id_time_cost', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled', @@ -268,6 +277,7 @@ class Application extends BaseModel { return [ 'http_basic_auth_password' => 'encrypted', + 'http_basic_auth_hash_algorithm' => HttpBasicAuthHashAlgorithm::class, 'manual_webhook_secret_github' => 'encrypted', 'manual_webhook_secret_gitlab' => 'encrypted', 'manual_webhook_secret_bitbucket' => 'encrypted', @@ -657,6 +667,12 @@ class Application extends BaseModel return $this->source instanceof GithubApp; } + public function usesArgon2idBasicAuth(): bool + { + return $this->http_basic_auth_hash_algorithm === HttpBasicAuthHashAlgorithm::ARGON2ID + && $this->destination->server->caddySupportsArgon2idBasicAuth(); + } + public function isForceHttpsEnabled() { return data_get($this, 'settings.is_force_https_enabled', false); diff --git a/app/Models/Server.php b/app/Models/Server.php index 88f3ecc15a..2fc5a99e3e 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -1244,6 +1244,12 @@ $siteAddress { return $this->caddyRunsCurrentVersion(); } + public function caddySupportsArgon2idBasicAuth(): bool + { + return $this->caddyRunsCurrentVersion() + && self::caddyDockerProxyImageVersion($this->configuredCaddyProxyImage()) >= [2, 11]; + } + public function isServerApiEnabled(): bool { return $this->settings->is_sentinel_enabled; diff --git a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php index b974e03db3..6a38840c5f 100644 --- a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php +++ b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php @@ -208,6 +208,10 @@ class ApplicationConfigurationSnapshot $this->item('is_http_basic_auth_enabled', 'HTTP basic auth', $this->application->is_http_basic_auth_enabled, 'redeploy'), $this->item('http_basic_auth_username', 'HTTP basic auth username', $this->application->http_basic_auth_username, 'redeploy'), $this->item('http_basic_auth_password', 'HTTP basic auth password', $this->application->http_basic_auth_password, 'redeploy', sensitive: true), + $this->item('http_basic_auth_hash_algorithm', 'HTTP basic auth hash algorithm', $this->application->http_basic_auth_hash_algorithm?->value, 'redeploy'), + $this->item('http_basic_auth_bcrypt_cost', 'HTTP basic auth bcrypt cost factor', $this->application->http_basic_auth_bcrypt_cost, 'redeploy'), + $this->item('http_basic_auth_argon2id_memory_cost', 'HTTP basic auth Argon2id memory (KiB)', $this->application->http_basic_auth_argon2id_memory_cost, 'redeploy'), + $this->item('http_basic_auth_argon2id_time_cost', 'HTTP basic auth Argon2id iterations', $this->application->http_basic_auth_argon2id_time_cost, 'redeploy'), ]; } diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index ee89f52c6f..46501331e9 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -2,6 +2,7 @@ use App\Actions\Shared\MigrateResourceToDestination; use App\Enums\BuildPackTypes; +use App\Enums\HttpBasicAuthHashAlgorithm; use App\Enums\RedirectTypes; use App\Enums\StaticImageTypes; use App\Models\Application; @@ -276,6 +277,10 @@ function sharedDataApplications(?Application $application = null): array 'docker_compose_custom_build_command' => ValidationPatterns::shellSafeCommandRules(), 'is_container_label_escape_enabled' => 'boolean', 'is_preserve_repository_enabled' => 'boolean', + 'http_basic_auth_hash_algorithm' => Rule::enum(HttpBasicAuthHashAlgorithm::class), + 'http_basic_auth_bcrypt_cost' => 'integer|min:4|max:14', + 'http_basic_auth_argon2id_memory_cost' => 'integer|min:8192|max:262144', + 'http_basic_auth_argon2id_time_cost' => 'integer|min:1|max:12', ]; } diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index b44714a8b0..464bad84cc 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -766,7 +766,7 @@ function isNoindexDomain(string $domain, ?Collection $noindex_domains): bool ->contains(ValidationPatterns::normalizeApplicationDomainUrl($domain)); } -function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, ?string $image = null, string $redirect_direction = 'both', ?string $predefinedPort = null, bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $is_traffic_analytics_enabled = false, array $domainPortOverrides = [], bool $supports_log_append = false, bool $supports_basic_auth_directive = false) +function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, ?string $image = null, string $redirect_direction = 'both', ?string $predefinedPort = null, bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $is_traffic_analytics_enabled = false, array $domainPortOverrides = [], bool $supports_log_append = false, bool $supports_basic_auth_directive = false, ?int $http_basic_auth_bcrypt_cost = null, array $http_basic_auth_argon2id_options = []) { $labels = collect([]); if ($serviceLabels) { @@ -777,7 +777,9 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, $is_http_basic_auth_enabled = $is_http_basic_auth_enabled && $http_basic_auth_username !== null && $http_basic_auth_password !== null; if ($is_http_basic_auth_enabled) { - $hashedPassword = password_hash($http_basic_auth_password, PASSWORD_BCRYPT, ['cost' => 10]); + $hashedPassword = $http_basic_auth_argon2id_options + ? password_hash($http_basic_auth_password, PASSWORD_ARGON2ID, $http_basic_auth_argon2id_options) + : password_hash($http_basic_auth_password, PASSWORD_BCRYPT, ['cost' => $http_basic_auth_bcrypt_cost ?? 10]); } $trafficAppKey = caddyTrafficAppKey($uuid, $service_name); @@ -834,6 +836,9 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, if ($is_http_basic_auth_enabled) { // Caddy 2.8 renamed basicauth to basic_auth; see Server::caddySupportsBasicAuthDirective(). $basicAuthDirective = $supports_basic_auth_directive ? 'basic_auth' : 'basicauth'; + if ($http_basic_auth_argon2id_options) { + $labels->push("caddy_{$loop}.{$basicAuthDirective}=argon2id"); + } $labels->push("caddy_{$loop}.{$basicAuthDirective}.{$http_basic_auth_username}=\"{$hashedPassword}\""); } if ($is_traffic_analytics_enabled) { @@ -907,7 +912,7 @@ function dockerComposeServicePorts(?string $compose, ?string $serviceName): arra ->unique()->values()->all(); } -function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, bool $generate_unique_uuid = false, ?string $image = null, string $redirect_direction = 'both', bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $escape_redirect_replacement_for_compose = true, array $domainPortOverrides = []) +function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, bool $generate_unique_uuid = false, ?string $image = null, string $redirect_direction = 'both', bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $escape_redirect_replacement_for_compose = true, array $domainPortOverrides = [], ?int $http_basic_auth_bcrypt_cost = null) { $labels = collect([]); $labels->push('traefik.enable=true'); @@ -919,7 +924,7 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_ $is_http_basic_auth_enabled = $is_http_basic_auth_enabled && $http_basic_auth_username !== null && $http_basic_auth_password !== null; $http_basic_auth_label = "http-basic-auth-{$uuid}"; if ($is_http_basic_auth_enabled) { - $hashedPassword = password_hash($http_basic_auth_password, PASSWORD_BCRYPT, ['cost' => 10]); + $hashedPassword = password_hash($http_basic_auth_password, PASSWORD_BCRYPT, ['cost' => $http_basic_auth_bcrypt_cost ?? 10]); } if ($is_http_basic_auth_enabled) { @@ -1186,6 +1191,10 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview $appUuid = $appUuid.'-pr-'.$pull_request_id; } $labels = collect([]); + $httpBasicAuthArgon2idOptions = $application->usesArgon2idBasicAuth() ? [ + 'memory_cost' => $application->http_basic_auth_argon2id_memory_cost, + 'time_cost' => $application->http_basic_auth_argon2id_time_cost, + ] : []; if ($pull_request_id === 0) { if ($application->fqdn) { $domains = str(data_get($application, 'fqdn'))->explode(','); @@ -1205,6 +1214,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled, http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, + http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost, noindex_domains: $noindexDomains, domainPortOverrides: $application->domain_port_overrides ?? [], )); @@ -1222,6 +1232,8 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled, http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, + http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost, + http_basic_auth_argon2id_options: $httpBasicAuthArgon2idOptions, noindex_domains: $noindexDomains, is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(), supports_log_append: $application->destination->server->caddySupportsLogAppend(), @@ -1242,6 +1254,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled, http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, + http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost, noindex_domains: $noindexDomains, escape_redirect_replacement_for_compose: false, domainPortOverrides: $application->domain_port_overrides ?? [], @@ -1258,6 +1271,8 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled, http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, + http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost, + http_basic_auth_argon2id_options: $httpBasicAuthArgon2idOptions, noindex_domains: $noindexDomains, is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(), supports_log_append: $application->destination->server->caddySupportsLogAppend(), @@ -1288,6 +1303,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled, http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, + http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost, noindex_domains: $noindexDomains, escape_redirect_replacement_for_compose: false, domainPortOverrides: $preview->domain_port_overrides ?? [], @@ -1305,6 +1321,8 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled, http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, + http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost, + http_basic_auth_argon2id_options: $httpBasicAuthArgon2idOptions, noindex_domains: $noindexDomains, is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(), supports_log_append: $application->destination->server->caddySupportsLogAppend(), @@ -1324,6 +1342,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled, http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, + http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost, noindex_domains: $noindexDomains, escape_redirect_replacement_for_compose: false, domainPortOverrides: $preview->domain_port_overrides ?? [], @@ -1339,6 +1358,8 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled, http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, + http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost, + http_basic_auth_argon2id_options: $httpBasicAuthArgon2idOptions, noindex_domains: $noindexDomains, is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(), supports_log_append: $application->destination->server->caddySupportsLogAppend(), diff --git a/database/factories/ApplicationFactory.php b/database/factories/ApplicationFactory.php index 188d32954b..ecbae8862f 100644 --- a/database/factories/ApplicationFactory.php +++ b/database/factories/ApplicationFactory.php @@ -21,6 +21,9 @@ class ApplicationFactory extends Factory 'ports_exposes' => '3000', 'environment_id' => 1, 'destination_id' => 1, + 'http_basic_auth_bcrypt_cost' => 10, + 'http_basic_auth_argon2id_memory_cost' => 65536, + 'http_basic_auth_argon2id_time_cost' => 4, ]; } } diff --git a/database/migrations/2026_10_02_170101_add_http_basic_auth_hash_options_to_applications_table.php b/database/migrations/2026_10_02_170101_add_http_basic_auth_hash_options_to_applications_table.php new file mode 100644 index 0000000000..17212a63a6 --- /dev/null +++ b/database/migrations/2026_10_02_170101_add_http_basic_auth_hash_options_to_applications_table.php @@ -0,0 +1,30 @@ +string('http_basic_auth_hash_algorithm')->default('bcrypt'); + $table->unsignedTinyInteger('http_basic_auth_bcrypt_cost')->default(10); + $table->unsignedInteger('http_basic_auth_argon2id_memory_cost')->default(65536); + $table->unsignedTinyInteger('http_basic_auth_argon2id_time_cost')->default(4); + }); + } + + public function down(): void + { + Schema::table('applications', function (Blueprint $table) { + $table->dropColumn([ + 'http_basic_auth_hash_algorithm', + 'http_basic_auth_bcrypt_cost', + 'http_basic_auth_argon2id_memory_cost', + 'http_basic_auth_argon2id_time_cost', + ]); + }); + } +}; diff --git a/openapi.json b/openapi.json index 313fefa63a..8c4eb62e7f 100644 --- a/openapi.json +++ b/openapi.json @@ -979,6 +979,36 @@ "nullable": true, "description": "Password for HTTP Basic Authentication" }, + "http_basic_auth_hash_algorithm": { + "type": "string", + "enum": [ + "bcrypt", + "argon2id" + ], + "default": "bcrypt", + "description": "Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer." + }, + "http_basic_auth_bcrypt_cost": { + "type": "integer", + "minimum": 4, + "maximum": 14, + "default": 10, + "description": "Bcrypt cost for the HTTP Basic Authentication password." + }, + "http_basic_auth_argon2id_memory_cost": { + "type": "integer", + "minimum": 8192, + "maximum": 262144, + "default": 65536, + "description": "Argon2id memory cost in KiB for the HTTP Basic Authentication password." + }, + "http_basic_auth_argon2id_time_cost": { + "type": "integer", + "minimum": 1, + "maximum": 12, + "default": 4, + "description": "Argon2id time cost (iterations) for the HTTP Basic Authentication password." + }, "connect_to_docker_network": { "type": "boolean", "description": "The flag to connect the service to the predefined Docker network." @@ -1570,6 +1600,36 @@ "nullable": true, "description": "Password for HTTP Basic Authentication" }, + "http_basic_auth_hash_algorithm": { + "type": "string", + "enum": [ + "bcrypt", + "argon2id" + ], + "default": "bcrypt", + "description": "Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer." + }, + "http_basic_auth_bcrypt_cost": { + "type": "integer", + "minimum": 4, + "maximum": 14, + "default": 10, + "description": "Bcrypt cost for the HTTP Basic Authentication password." + }, + "http_basic_auth_argon2id_memory_cost": { + "type": "integer", + "minimum": 8192, + "maximum": 262144, + "default": 65536, + "description": "Argon2id memory cost in KiB for the HTTP Basic Authentication password." + }, + "http_basic_auth_argon2id_time_cost": { + "type": "integer", + "minimum": 1, + "maximum": 12, + "default": 4, + "description": "Argon2id time cost (iterations) for the HTTP Basic Authentication password." + }, "connect_to_docker_network": { "type": "boolean", "description": "The flag to connect the service to the predefined Docker network." @@ -2161,6 +2221,36 @@ "nullable": true, "description": "Password for HTTP Basic Authentication" }, + "http_basic_auth_hash_algorithm": { + "type": "string", + "enum": [ + "bcrypt", + "argon2id" + ], + "default": "bcrypt", + "description": "Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer." + }, + "http_basic_auth_bcrypt_cost": { + "type": "integer", + "minimum": 4, + "maximum": 14, + "default": 10, + "description": "Bcrypt cost for the HTTP Basic Authentication password." + }, + "http_basic_auth_argon2id_memory_cost": { + "type": "integer", + "minimum": 8192, + "maximum": 262144, + "default": 65536, + "description": "Argon2id memory cost in KiB for the HTTP Basic Authentication password." + }, + "http_basic_auth_argon2id_time_cost": { + "type": "integer", + "minimum": 1, + "maximum": 12, + "default": 4, + "description": "Argon2id time cost (iterations) for the HTTP Basic Authentication password." + }, "connect_to_docker_network": { "type": "boolean", "description": "The flag to connect the service to the predefined Docker network." @@ -2647,6 +2737,36 @@ "nullable": true, "description": "Password for HTTP Basic Authentication" }, + "http_basic_auth_hash_algorithm": { + "type": "string", + "enum": [ + "bcrypt", + "argon2id" + ], + "default": "bcrypt", + "description": "Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer." + }, + "http_basic_auth_bcrypt_cost": { + "type": "integer", + "minimum": 4, + "maximum": 14, + "default": 10, + "description": "Bcrypt cost for the HTTP Basic Authentication password." + }, + "http_basic_auth_argon2id_memory_cost": { + "type": "integer", + "minimum": 8192, + "maximum": 262144, + "default": 65536, + "description": "Argon2id memory cost in KiB for the HTTP Basic Authentication password." + }, + "http_basic_auth_argon2id_time_cost": { + "type": "integer", + "minimum": 1, + "maximum": 12, + "default": 4, + "description": "Argon2id time cost (iterations) for the HTTP Basic Authentication password." + }, "connect_to_docker_network": { "type": "boolean", "description": "The flag to connect the service to the predefined Docker network." @@ -3113,6 +3233,36 @@ "nullable": true, "description": "Password for HTTP Basic Authentication" }, + "http_basic_auth_hash_algorithm": { + "type": "string", + "enum": [ + "bcrypt", + "argon2id" + ], + "default": "bcrypt", + "description": "Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer." + }, + "http_basic_auth_bcrypt_cost": { + "type": "integer", + "minimum": 4, + "maximum": 14, + "default": 10, + "description": "Bcrypt cost for the HTTP Basic Authentication password." + }, + "http_basic_auth_argon2id_memory_cost": { + "type": "integer", + "minimum": 8192, + "maximum": 262144, + "default": 65536, + "description": "Argon2id memory cost in KiB for the HTTP Basic Authentication password." + }, + "http_basic_auth_argon2id_time_cost": { + "type": "integer", + "minimum": 1, + "maximum": 12, + "default": 4, + "description": "Argon2id time cost (iterations) for the HTTP Basic Authentication password." + }, "connect_to_docker_network": { "type": "boolean", "description": "The flag to connect the service to the predefined Docker network." @@ -3702,6 +3852,32 @@ "type": "boolean", "description": "Use Docker Build Secrets for build-time environment variables." }, + "http_basic_auth_hash_algorithm": { + "type": "string", + "enum": [ + "bcrypt", + "argon2id" + ], + "description": "Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer." + }, + "http_basic_auth_bcrypt_cost": { + "type": "integer", + "minimum": 4, + "maximum": 14, + "description": "Bcrypt cost for the HTTP Basic Authentication password." + }, + "http_basic_auth_argon2id_memory_cost": { + "type": "integer", + "minimum": 8192, + "maximum": 262144, + "description": "Argon2id memory cost in KiB for the HTTP Basic Authentication password." + }, + "http_basic_auth_argon2id_time_cost": { + "type": "integer", + "minimum": 1, + "maximum": 12, + "description": "Argon2id time cost (iterations) for the HTTP Basic Authentication password." + }, "is_git_submodules_enabled": { "type": "boolean", "description": "Clone Git submodules." @@ -23504,6 +23680,32 @@ "nullable": true, "description": "Password for HTTP Basic Authentication" }, + "http_basic_auth_hash_algorithm": { + "type": "string", + "enum": [ + "bcrypt", + "argon2id" + ], + "description": "Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer." + }, + "http_basic_auth_bcrypt_cost": { + "type": "integer", + "minimum": 4, + "maximum": 14, + "description": "Bcrypt cost for the HTTP Basic Authentication password." + }, + "http_basic_auth_argon2id_memory_cost": { + "type": "integer", + "minimum": 8192, + "maximum": 262144, + "description": "Argon2id memory cost in KiB for the HTTP Basic Authentication password." + }, + "http_basic_auth_argon2id_time_cost": { + "type": "integer", + "minimum": 1, + "maximum": 12, + "description": "Argon2id time cost (iterations) for the HTTP Basic Authentication password." + }, "settings": { "$ref": "#\/components\/schemas\/ApplicationSetting" } diff --git a/openapi.yaml b/openapi.yaml index dc2b3aebf9..cfc334c844 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -645,6 +645,29 @@ paths: type: string nullable: true description: 'Password for HTTP Basic Authentication' + http_basic_auth_hash_algorithm: + type: string + enum: [bcrypt, argon2id] + default: bcrypt + description: 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.' + http_basic_auth_bcrypt_cost: + type: integer + minimum: 4 + maximum: 14 + default: 10 + description: 'Bcrypt cost for the HTTP Basic Authentication password.' + http_basic_auth_argon2id_memory_cost: + type: integer + minimum: 8192 + maximum: 262144 + default: 65536 + description: 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.' + http_basic_auth_argon2id_time_cost: + type: integer + minimum: 1 + maximum: 12 + default: 4 + description: 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.' connect_to_docker_network: type: boolean description: 'The flag to connect the service to the predefined Docker network.' @@ -1031,6 +1054,29 @@ paths: type: string nullable: true description: 'Password for HTTP Basic Authentication' + http_basic_auth_hash_algorithm: + type: string + enum: [bcrypt, argon2id] + default: bcrypt + description: 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.' + http_basic_auth_bcrypt_cost: + type: integer + minimum: 4 + maximum: 14 + default: 10 + description: 'Bcrypt cost for the HTTP Basic Authentication password.' + http_basic_auth_argon2id_memory_cost: + type: integer + minimum: 8192 + maximum: 262144 + default: 65536 + description: 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.' + http_basic_auth_argon2id_time_cost: + type: integer + minimum: 1 + maximum: 12 + default: 4 + description: 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.' connect_to_docker_network: type: boolean description: 'The flag to connect the service to the predefined Docker network.' @@ -1417,6 +1463,29 @@ paths: type: string nullable: true description: 'Password for HTTP Basic Authentication' + http_basic_auth_hash_algorithm: + type: string + enum: [bcrypt, argon2id] + default: bcrypt + description: 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.' + http_basic_auth_bcrypt_cost: + type: integer + minimum: 4 + maximum: 14 + default: 10 + description: 'Bcrypt cost for the HTTP Basic Authentication password.' + http_basic_auth_argon2id_memory_cost: + type: integer + minimum: 8192 + maximum: 262144 + default: 65536 + description: 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.' + http_basic_auth_argon2id_time_cost: + type: integer + minimum: 1 + maximum: 12 + default: 4 + description: 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.' connect_to_docker_network: type: boolean description: 'The flag to connect the service to the predefined Docker network.' @@ -1744,6 +1813,29 @@ paths: type: string nullable: true description: 'Password for HTTP Basic Authentication' + http_basic_auth_hash_algorithm: + type: string + enum: [bcrypt, argon2id] + default: bcrypt + description: 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.' + http_basic_auth_bcrypt_cost: + type: integer + minimum: 4 + maximum: 14 + default: 10 + description: 'Bcrypt cost for the HTTP Basic Authentication password.' + http_basic_auth_argon2id_memory_cost: + type: integer + minimum: 8192 + maximum: 262144 + default: 65536 + description: 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.' + http_basic_auth_argon2id_time_cost: + type: integer + minimum: 1 + maximum: 12 + default: 4 + description: 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.' connect_to_docker_network: type: boolean description: 'The flag to connect the service to the predefined Docker network.' @@ -2057,6 +2149,29 @@ paths: type: string nullable: true description: 'Password for HTTP Basic Authentication' + http_basic_auth_hash_algorithm: + type: string + enum: [bcrypt, argon2id] + default: bcrypt + description: 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.' + http_basic_auth_bcrypt_cost: + type: integer + minimum: 4 + maximum: 14 + default: 10 + description: 'Bcrypt cost for the HTTP Basic Authentication password.' + http_basic_auth_argon2id_memory_cost: + type: integer + minimum: 8192 + maximum: 262144 + default: 65536 + description: 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.' + http_basic_auth_argon2id_time_cost: + type: integer + minimum: 1 + maximum: 12 + default: 4 + description: 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.' connect_to_docker_network: type: boolean description: 'The flag to connect the service to the predefined Docker network.' @@ -2429,6 +2544,25 @@ paths: use_build_secrets: type: boolean description: 'Use Docker Build Secrets for build-time environment variables.' + http_basic_auth_hash_algorithm: + type: string + enum: [bcrypt, argon2id] + description: 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.' + http_basic_auth_bcrypt_cost: + type: integer + minimum: 4 + maximum: 14 + description: 'Bcrypt cost for the HTTP Basic Authentication password.' + http_basic_auth_argon2id_memory_cost: + type: integer + minimum: 8192 + maximum: 262144 + description: 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.' + http_basic_auth_argon2id_time_cost: + type: integer + minimum: 1 + maximum: 12 + description: 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.' is_git_submodules_enabled: type: boolean description: 'Clone Git submodules.' @@ -15009,6 +15143,27 @@ components: type: string nullable: true description: 'Password for HTTP Basic Authentication' + http_basic_auth_hash_algorithm: + type: string + enum: + - bcrypt + - argon2id + description: 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.' + http_basic_auth_bcrypt_cost: + type: integer + minimum: 4 + maximum: 14 + description: 'Bcrypt cost for the HTTP Basic Authentication password.' + http_basic_auth_argon2id_memory_cost: + type: integer + minimum: 8192 + maximum: 262144 + description: 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.' + http_basic_auth_argon2id_time_cost: + type: integer + minimum: 1 + maximum: 12 + description: 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.' settings: $ref: '#/components/schemas/ApplicationSetting' type: object diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index 1df2bedfb2..45e03854a3 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -1,3 +1,4 @@ +@php use App\Enums\HttpBasicAuthHashAlgorithm; use App\Enums\ProxyTypes; @endphp
+ @php + $server = $application->destination->server; + $supportsArgon2id = $server->caddySupportsArgon2idBasicAuth(); + $usesArgon2id = $httpBasicAuthHashAlgorithm === HttpBasicAuthHashAlgorithm::ARGON2ID->value; + @endphp +
+

Password hashing

+
+ + @if ($usesArgon2id) +
+ +
+
+ +
+ @else +
+ +
+ @endif +
+
@endif @endif diff --git a/tests/Feature/Api/ApplicationSettingsApiTest.php b/tests/Feature/Api/ApplicationSettingsApiTest.php index 8074be8bbe..018c662a4b 100644 --- a/tests/Feature/Api/ApplicationSettingsApiTest.php +++ b/tests/Feature/Api/ApplicationSettingsApiTest.php @@ -267,6 +267,35 @@ test('http basic auth updates preserve user-managed labels', function () { expect(base64_decode($this->application->fresh()->custom_labels))->toBe('sentinel-label=true'); }); +test('http basic auth hash settings are saved and applied to managed labels', function () { + $this->application->update([ + 'fqdn' => 'https://app.example.com', + 'is_http_basic_auth_enabled' => true, + 'http_basic_auth_username' => 'api-user', + 'http_basic_auth_password' => 'api-password', + ]); + + $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", ['http_basic_auth_bcrypt_cost' => 4]) + ->assertOk(); + + expect($this->application->fresh()->parseContainerLabels())->toContain('.basicauth.users=api-user:$2y$04$'); +}); + +test('rejects http basic auth settings that are not supported', function (string $field, mixed $value) { + $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [$field => $value]) + ->assertUnprocessable() + ->assertJsonValidationErrors($field); +})->with([ + 'Argon2id on a proxy without it' => ['http_basic_auth_hash_algorithm', 'argon2id'], + 'unknown algorithm' => ['http_basic_auth_hash_algorithm', 'md5'], + 'bcrypt cost above 14' => ['http_basic_auth_bcrypt_cost', 15], + 'Argon2id memory above 256 MiB' => ['http_basic_auth_argon2id_memory_cost', 262145], + 'Argon2id iterations above 12' => ['http_basic_auth_argon2id_time_cost', 13], + 'password longer than 72 characters' => ['http_basic_auth_password', str_repeat('a', 73)], +]); + test('rejects invalid boolean application settings', function () { $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken)) ->patchJson("/api/v1/applications/{$this->application->uuid}", [ diff --git a/tests/Feature/ApplicationHttpBasicAuthUiTest.php b/tests/Feature/ApplicationHttpBasicAuthUiTest.php index 78d42bd481..666cddec44 100644 --- a/tests/Feature/ApplicationHttpBasicAuthUiTest.php +++ b/tests/Feature/ApplicationHttpBasicAuthUiTest.php @@ -1,5 +1,6 @@ team = Team::factory()->create(); $this->user = User::factory()->create(); $this->team->members()->attach($this->user->id, ['role' => 'owner']); @@ -47,9 +49,12 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== ?? StandaloneDocker::factory()->create(['server_id' => $this->server->id, 'network' => 'coolify-test']); }); +afterEach(fn () => Server::flushIdentityMap()); + function createApplicationForHttpBasicAuth(array $overrides = []): Application { return Application::factory()->create(array_merge([ + 'name' => 'Basic Auth App', 'environment_id' => test()->environment->id, 'destination_id' => test()->destination->id, 'destination_type' => StandaloneDocker::class, @@ -112,3 +117,68 @@ test('disabling http basic auth is saved instantly', function () { expect((bool) $application->is_http_basic_auth_enabled)->toBeFalse(); }); + +function useCaddyProxyForHttpBasicAuth(string $image): void +{ + test()->server->forceFill(['proxy' => [ + 'type' => ProxyTypes::CADDY->value, + 'status' => 'running', + 'last_saved_settings' => 'applied', + 'last_applied_settings' => 'applied', + 'last_saved_proxy_configuration' => "services:\n caddy:\n image: '{$image}'\n", + ]])->save(); +} + +test('changing the bcrypt cost factor is saved instantly and applied to the proxy labels', function () { + $application = createApplicationForHttpBasicAuth(['fqdn' => 'https://app.example.com']); + + Livewire::test(General::class, ['application' => $application]) + ->set('httpBasicAuthBcryptCost', 4) + ->call('instantSave') + ->assertDispatched('success'); + + $application->refresh(); + + expect($application->http_basic_auth_bcrypt_cost)->toBe(4) + ->and(base64_decode($application->custom_labels))->toContain('.basicauth.users=admin:$2y$04$'); +}); + +test('selecting Argon2id is saved instantly and applied to the Caddy labels', function () { + useCaddyProxyForHttpBasicAuth('lucaslorentz/caddy-docker-proxy:2.13-alpine'); + $application = createApplicationForHttpBasicAuth(['fqdn' => 'https://app.example.com']); + + Livewire::test(General::class, ['application' => $application]) + ->set('httpBasicAuthHashAlgorithm', 'argon2id') + ->set('httpBasicAuthArgon2idMemoryCost', 8192) + ->call('instantSave') + ->assertDispatched('success') + ->assertSee('Iterations') + ->assertDontSee('Cost factor'); + + expect(base64_decode($application->refresh()->custom_labels)) + ->toContain('caddy_0.basic_auth=argon2id', 'caddy_0.basic_auth.admin="$argon2id$v=19$m=8192,t=4,p=1$'); +}); + +test('http basic auth form explains why Argon2id cannot be selected', function (?string $image, string $reason) { + if ($image !== null) { + useCaddyProxyForHttpBasicAuth($image); + } + + Livewire::test(General::class, ['application' => createApplicationForHttpBasicAuth()]) + ->assertSee($reason); +})->with([ + 'Traefik' => [null, 'Only supported by the Caddy proxy'], + 'Caddy 2.10' => ['lucaslorentz/caddy-docker-proxy:2.10-alpine', 'Needs Caddy 2.11 or newer on this server'], +]); + +test('an unsupported http basic auth setting is rejected', function (string $property, mixed $value) { + Livewire::test(General::class, ['application' => createApplicationForHttpBasicAuth()]) + ->set($property, $value) + ->call('instantSave') + ->assertDispatched('error') + ->assertNotDispatched('success'); +})->with([ + 'Argon2id on a proxy without it' => ['httpBasicAuthHashAlgorithm', 'argon2id'], + 'bcrypt cost above 14' => ['httpBasicAuthBcryptCost', 15], + 'password longer than 72 characters' => ['httpBasicAuthPassword', str_repeat('a', 73)], +]); diff --git a/tests/Feature/Proxy/CaddyBasicAuthLabelsTest.php b/tests/Feature/Proxy/CaddyBasicAuthLabelsTest.php index 359abef073..4f3aa36510 100644 --- a/tests/Feature/Proxy/CaddyBasicAuthLabelsTest.php +++ b/tests/Feature/Proxy/CaddyBasicAuthLabelsTest.php @@ -106,3 +106,55 @@ it('emits the matching basic auth directive in application labels', function (st 'caddy-docker-proxy 2.13, preview, all proxies' => ['lucaslorentz/caddy-docker-proxy:2.13-alpine', false, true, 'basic_auth'], 'caddy-docker-proxy 2.13, preview, exact labels' => ['lucaslorentz/caddy-docker-proxy:2.13-alpine', true, true, 'basic_auth'], ]); + +it('hashes the password in application labels with the selected settings', function (array $proxy, bool $exactLabels, bool $preview, array $hashSettings, array $expectedLabels) { + $team = Team::factory()->create(); + $environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $team->id])->id]); + $server = Server::factory()->create(['team_id' => $team->id, 'proxy' => $proxy]); + $server->settings->update(['generate_exact_labels' => $exactLabels]); + $destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail(); + $application = Application::factory()->createOne([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'fqdn' => 'https://example.com', + 'is_http_basic_auth_enabled' => true, + 'http_basic_auth_username' => 'admin', + 'http_basic_auth_password' => 'secret', + ...$hashSettings, + ]); + Server::flushIdentityMap(); + + $applicationPreview = $preview + ? (new ApplicationPreview)->forceFill(['pull_request_id' => 7, 'fqdn' => 'https://pr-7.example.com']) + : null; + + $labels = collect(generateLabelsApplication($application->fresh(), $applicationPreview))->implode("\n"); + + expect($labels)->toContain(...$expectedLabels); +})->with(function () { + $caddy = basicAuthCaddyProxy('lucaslorentz/caddy-docker-proxy:2.13-alpine'); + $traefik = basicAuthCaddyProxy('traefik:v3.7', ['type' => ProxyTypes::TRAEFIK->value]); + $bcrypt = ['http_basic_auth_bcrypt_cost' => 4]; + $argon2id = ['http_basic_auth_hash_algorithm' => 'argon2id', 'http_basic_auth_argon2id_memory_cost' => 8192, 'http_basic_auth_argon2id_time_cost' => 2]; + $caddyBcrypt = 'caddy_0.basic_auth.admin="$2y$04$'; + $traefikBcrypt = '.basicauth.users=admin:$2y$04$'; + + return [ + 'bcrypt, all proxies' => [$caddy, false, false, $bcrypt, [$caddyBcrypt, $traefikBcrypt]], + 'bcrypt, all proxies, preview' => [$caddy, false, true, $bcrypt, [$caddyBcrypt, $traefikBcrypt]], + 'bcrypt, exact Caddy labels' => [$caddy, true, false, $bcrypt, [$caddyBcrypt]], + 'bcrypt, exact Caddy labels, preview' => [$caddy, true, true, $bcrypt, [$caddyBcrypt]], + 'bcrypt, exact Traefik labels' => [$traefik, true, false, $bcrypt, [$traefikBcrypt]], + 'bcrypt, exact Traefik labels, preview' => [$traefik, true, true, $bcrypt, [$traefikBcrypt]], + 'Argon2id for Caddy, bcrypt for Traefik' => [$caddy, false, false, $argon2id, [ + 'caddy_0.basic_auth=argon2id', + 'caddy_0.basic_auth.admin="$argon2id$v=19$m=8192,t=2,p=1$', + '.basicauth.users=admin:$2y$10$', + ]], + 'bcrypt when Caddy is too old for the stored Argon2id' => [basicAuthCaddyProxy('lucaslorentz/caddy-docker-proxy:2.10-alpine'), false, false, $argon2id, [ + 'caddy_0.basic_auth.admin="$2y$10$', + '.basicauth.users=admin:$2y$10$', + ]], + ]; +}); diff --git a/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php index 1e2e0d796c..f4b4586832 100644 --- a/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php +++ b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php @@ -148,6 +148,22 @@ it('detects deployment hook changes as redeploy-only changes', function (string 'post-deployment container' => ['post_deployment_command_container', 'Post-deployment command container', 'worker'], ]); +it('detects HTTP basic auth hash setting changes as redeploy-only changes', function (string $field, string $label, string|int $newValue) { + $application = snapshotTestApplication(); + markSnapshotTestApplicationDeployed($application); + + $application->update([$field => $newValue]); + $diff = $application->refresh()->pendingDeploymentConfigurationDiff(); + + expect($diff->requiresBuild())->toBeFalse() + ->and(collect($diff->changes())->pluck('label'))->toContain($label); +})->with([ + 'hash algorithm' => ['http_basic_auth_hash_algorithm', 'HTTP basic auth hash algorithm', 'argon2id'], + 'bcrypt cost factor' => ['http_basic_auth_bcrypt_cost', 'HTTP basic auth bcrypt cost factor', 4], + 'Argon2id memory' => ['http_basic_auth_argon2id_memory_cost', 'HTTP basic auth Argon2id memory (KiB)', 8192], + 'Argon2id iterations' => ['http_basic_auth_argon2id_time_cost', 'HTTP basic auth Argon2id iterations', 2], +]); + it('detects source integration changes as build changes', function (string $field, string $label, mixed $newValue) { $application = snapshotTestApplication([ 'source_id' => 1, diff --git a/tests/v4/Browser/ApplicationHttpBasicAuthHashingTest.php b/tests/v4/Browser/ApplicationHttpBasicAuthHashingTest.php new file mode 100644 index 0000000000..148900b144 --- /dev/null +++ b/tests/v4/Browser/ApplicationHttpBasicAuthHashingTest.php @@ -0,0 +1,45 @@ + Server::flushIdentityMap()); + +afterEach(fn () => Server::flushIdentityMap()); + +it('replaces the cost factor with memory and iterations when Argon2id is selected', function () { + $stack = seedBrowserResourceStack(); + $stack['server']->forceFill(['proxy' => [ + 'type' => ProxyTypes::CADDY->value, + 'status' => 'running', + 'last_saved_settings' => 'applied', + 'last_applied_settings' => 'applied', + 'last_saved_proxy_configuration' => "services:\n caddy:\n image: 'lucaslorentz/caddy-docker-proxy:2.13-alpine'\n", + ]])->save(); + $application = createBrowserApplication($stack, [ + 'name' => 'Basic Auth App', + 'fqdn' => 'https://basic-auth.example.com', + 'is_http_basic_auth_enabled' => true, + 'http_basic_auth_username' => 'admin', + 'http_basic_auth_password' => 'secret', + ]); + loginAndSkipBoarding(); + + $page = visit(applicationConfigurationUrl($stack['project'], $stack['environment'], $application)); + $page->assertSee('Cost factor') + ->script("document.querySelector('#security-section').scrollIntoView()"); + + selectListboxOption($page, 'httpBasicAuthHashAlgorithm', 'Argon2id Memory-hard, more resistant to cracking'); + + $page->assertSeeIn('#httpBasicAuthArgon2idMemoryCost-trigger', '64 MiB') + ->assertSeeIn('#httpBasicAuthArgon2idTimeCost-trigger', '4') + ->assertDontSee('Cost factor') + ->assertNoJavaScriptErrors() + ->screenshot(filename: 'application-basic-auth-argon2id'); + + expect($application->fresh()->http_basic_auth_hash_algorithm)->toBe(HttpBasicAuthHashAlgorithm::ARGON2ID); +});