├── .github ├── FUNDING.yml └── workflows │ └── ci_build.yml ├── LICENSE ├── README.md ├── composer.json ├── config ├── force-https-module.local.php.dist ├── mezzio-force-https-module.local.php.dist └── module.config.php ├── phpstan.neon └── src ├── HttpsTrait.php ├── Listener ├── ForceHttps.php └── ForceHttpsFactory.php ├── Middleware ├── ForceHttps.php └── ForceHttpsFactory.php └── Module.php /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: samsonasik 2 | -------------------------------------------------------------------------------- /.github/workflows/ci_build.yml: -------------------------------------------------------------------------------- 1 | name: "ci build" 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - "master" 8 | 9 | jobs: 10 | build: 11 | name: PHP ${{ matrix.php-versions }} 12 | runs-on: ubuntu-latest 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | php-versions: ['8.2', '8.3', '8.4'] 17 | steps: 18 | - name: Setup PHP Action 19 | uses: shivammathur/setup-php@v2 20 | with: 21 | extensions: intl 22 | php-version: "${{ matrix.php-versions }}" 23 | coverage: xdebug 24 | - name: Checkout 25 | uses: actions/checkout@v2 26 | - run: "composer validate" 27 | - name: "Install dependencies" 28 | run: "composer install --prefer-dist" 29 | - name: "CS Check" 30 | run: "composer cs-check" 31 | - name: "Code analyze" 32 | run: | 33 | bin/phpstan analyse src/ --level=max -c phpstan.neon 34 | bin/rector process --dry-run 35 | - name: "Run test suite" 36 | run: "mkdir -p build/logs && bin/kahlan --coverage=4 --reporter=verbose --clover=build/logs/clover.xml" 37 | - name: Upload coverage to Codecov 38 | if: github.event.pull_request.head.repo.full_name == 'samsonasik/ForceHttpsModule' 39 | uses: codecov/codecov-action@v1 40 | with: 41 | token: ${{ secrets.CODECOV_TOKEN }} 42 | file: ./build/logs/clover.xml 43 | flags: tests 44 | name: codecov-umbrella 45 | yml: ./codecov.yml 46 | fail_ci_if_error: true 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Abdul Malik Ikhsan 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ForceHttpsModule 2 | ================ 3 | 4 | [![Latest Version](https://img.shields.io/github/release/samsonasik/ForceHttpsModule.svg?style=flat-square)](https://github.com/samsonasik/ForceHttpsModule/releases) 5 | ![ci build](https://github.com/samsonasik/ForceHttpsModule/workflows/ci%20build/badge.svg) 6 | [![Code Coverage](https://codecov.io/gh/samsonasik/ForceHttpsModule/branch/master/graph/badge.svg)](https://codecov.io/gh/samsonasik/ForceHttpsModule) 7 | [![PHPStan](https://img.shields.io/badge/PHPStan-enabled-brightgreen.svg?style=flat)](https://github.com/phpstan/phpstan) 8 | [![Downloads](https://poser.pugx.org/samsonasik/force-https-module/downloads)](https://packagist.org/packages/samsonasik/force-https-module) 9 | 10 | Introduction 11 | ------------ 12 | 13 | ForceHttpsModule is a configurable module for force https in your [Laminas Mvc](https://docs.laminas.dev/tutorials/) and [Mezzio](https://docs.mezzio.dev/mezzio/) Application. 14 | 15 | > This is README for version ^5.0 which only support Laminas Mvc version 3 and Mezzio version 3 with php ^8.2. 16 | 17 | > For ^4.1.x, you can read at [version 4.1.x readme](https://github.com/samsonasik/ForceHttpsModule/blob/4.1.x/README.md) which only support Laminas Mvc version 3 and Mezzio version 3 with php ^7.4|~8.0 18 | 19 | > For ~4.0.0, you can read at [version 4.0.x readme](https://github.com/samsonasik/ForceHttpsModule/blob/4.0.x/README.md) which only support Laminas Mvc version 3 and Mezzio version 3 with php ^7.3|~8.0 20 | 21 | > For version ^3.0, you can read at [version 3 readme](https://github.com/samsonasik/ForceHttpsModule/tree/3.x.x) which only support Laminas Mvc version 3 and Mezzio version 3 with php ^7.1. 22 | 23 | > For version ^2.0, you can read at [version 2 readme](https://github.com/samsonasik/ForceHttpsModule/tree/2.x.x) which only support ZF3 and ZF Expressive version 3 with php ^7.1. 24 | 25 | > For version 1, you can read at [version 1 readme](https://github.com/samsonasik/ForceHttpsModule/tree/1.x.x) which still support ZF2 and ZF Expressive version 1 and 2 with php ^5.6|^7.0 support. 26 | 27 | Features 28 | -------- 29 | 30 | - [x] Enable/disable force https. 31 | - [x] Force Https to All routes. 32 | - [x] Force Https to All routes except exclusion list. 33 | - [x] Force Https to specific routes only. 34 | - [x] Keep headers, request method, and request body. 35 | - [x] Enable/disable HTTP Strict Transport Security Header and set its value. 36 | - [x] Allow add `www.` prefix during redirection from http or already https. 37 | - [x] Allow remove `www.` prefix during redirection from http or already https. 38 | - [x] Force Https for 404 pages 39 | 40 | Installation 41 | ------------ 42 | 43 | **1. Require this module uses [composer](https://getcomposer.org/).** 44 | 45 | ```sh 46 | composer require samsonasik/force-https-module 47 | ``` 48 | 49 | **2. Copy config** 50 | 51 | ***a. For [Laminas Mvc](https://docs.laminas.dev/tutorials/) application, copy `force-https-module.local.php.dist` config to your local's autoload and configure it*** 52 | 53 | | source | destination | 54 | |------------------------------------------------------------------------------|---------------------------------------------| 55 | | vendor/samsonasik/force-https-module/config/force-https-module.local.php.dist | config/autoload/force-https-module.local.php | 56 | 57 | Or run copy command: 58 | 59 | ```sh 60 | cp vendor/samsonasik/force-https-module/config/force-https-module.local.php.dist config/autoload/force-https-module.local.php 61 | ``` 62 | 63 | ***b. For [Mezzio](https://docs.mezzio.dev/mezzio/) application, copy `mezzio-force-https-module.local.php.dist` config to your local's autoload and configure it*** 64 | 65 | | source | destination | 66 | |------------------------------------------------------------------------------|---------------------------------------------| 67 | | vendor/samsonasik/force-https-module/config/mezzio-force-https-module.local.php.dist | config/autoload/mezzio-force-https-module.local.php | 68 | 69 | Or run copy command: 70 | 71 | ```sh 72 | cp vendor/samsonasik/force-https-module/config/mezzio-force-https-module.local.php.dist config/autoload/mezzio-force-https-module.local.php 73 | ``` 74 | 75 | When done, you can modify your local config: 76 | 77 | ```php 78 | [ 82 | 'enable' => true, 83 | 'force_all_routes' => true, 84 | 'force_specific_routes' => [ 85 | // only works if previous's config 'force_all_routes' => false 86 | 'checkout', 87 | 'payment' 88 | ], 89 | 'exclude_specific_routes' => [ 90 | // a lists of specific routes to not be https 91 | // only works if previous config 'force_all_routes' => true 92 | 'non-https-route', 93 | ], 94 | // set HTTP Strict Transport Security Header 95 | 'strict_transport_security' => [ 96 | // set to false to disable it 97 | 'enable' => true, 98 | 'value' => 'max-age=31536000', 99 | ], 100 | // set to true to add "www." prefix during redirection from http or already https 101 | 'add_www_prefix' => false, 102 | // remove existing "www." prefix during redirection from http or already https 103 | // only works if previous's config 'add_www_prefix' => false 104 | 'remove_www_prefix' => false, 105 | // Force Https for 404 pages 106 | 'allow_404' => true, 107 | ], 108 | // ... 109 | ]; 110 | ``` 111 | 112 | **3. Lastly, enable it** 113 | 114 | ***a. For Laminas Mvc application*** 115 | 116 | ```php 117 | // config/modules.config.php or config/application.config.php 118 | return [ 119 | 'Application' 120 | 'ForceHttpsModule', // register here 121 | ], 122 | ``` 123 | 124 | ***b. For Mezzio application*** 125 | 126 | For [mezzio-skeleton](https://github.com/mezzio/mezzio-skeleton) ^3.0, you need to open `config/pipeline.php` and add: 127 | 128 | ```php 129 | $app->pipe(ForceHttpsModule\Middleware\ForceHttps::class); 130 | ``` 131 | 132 | at the very first pipeline records. 133 | 134 | Contributing 135 | ------------ 136 | Contributions are very welcome. Please read [CONTRIBUTING.md](https://github.com/samsonasik/ForceHttpsModule/blob/master/CONTRIBUTING.md) 137 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "samsonasik/force-https-module", 3 | "type": "library", 4 | "description": "Force Https Module for Laminas Mvc and Mezzio application", 5 | "keywords": [ 6 | "laminas3", 7 | "expressive", 8 | "middleware", 9 | "force", 10 | "https", 11 | "http", 12 | "psr7", 13 | "psr11", 14 | "psr-15" 15 | ], 16 | "homepage": "https://github.com/samsonasik/ForceHttpsModule", 17 | "license": "MIT", 18 | "authors": [ 19 | { 20 | "name": "Abdul Malik Ikhsan", 21 | "email": "samsonasik@gmail.com", 22 | "homepage": "http://samsonasik.wordpress.com", 23 | "role": "Developer" 24 | } 25 | ], 26 | "require": { 27 | "php": "^8.2", 28 | "webmozart/assert": "^1.11" 29 | }, 30 | "conflict": { 31 | "mezzio/mezzio": "<3.0", 32 | "laminas/laminas-mvc": "<3.0" 33 | }, 34 | "require-dev": { 35 | "kahlan/kahlan": "^6.0", 36 | "laminas/laminas-coding-standard": "^2.5", 37 | "laminas/laminas-mvc": "^3.8", 38 | "mezzio/mezzio": "^3.20.1", 39 | "php-coveralls/php-coveralls": "^2.7", 40 | "phpstan/phpstan": "^2.0.4", 41 | "phpstan/phpstan-webmozart-assert": "^2.0", 42 | "rector/rector": "dev-main" 43 | }, 44 | "config": { 45 | "bin-dir": "bin", 46 | "sort-packages": true, 47 | "allow-plugins": { 48 | "dealerdirect/phpcodesniffer-composer-installer": true 49 | } 50 | }, 51 | "extra": { 52 | "laminas": { 53 | "module": "ForceHttpsModule" 54 | } 55 | }, 56 | "autoload": { 57 | "psr-4": { 58 | "ForceHttpsModule\\": "src/" 59 | } 60 | }, 61 | "autoload-dev": { 62 | "psr-4": { 63 | "ForceHttpsModule\\Spec\\": "spec/" 64 | } 65 | }, 66 | "scripts": { 67 | "cs-check": "phpcs", 68 | "cs-fix": "phpcbf" 69 | }, 70 | "minimum-stability": "dev", 71 | "prefer-stable": true 72 | } 73 | -------------------------------------------------------------------------------- /config/force-https-module.local.php.dist: -------------------------------------------------------------------------------- 1 | [ 5 | 'enable' => true, 6 | 'force_all_routes' => true, 7 | 'force_specific_routes' => [ 8 | // a lists of specific routes to be https 9 | // only works if previous config 'force_all_routes' => false 10 | ], 11 | 'exclude_specific_routes' => [ 12 | // a lists of specific routes to not be https 13 | // only works if previous config 'force_all_routes' => true 14 | ], 15 | // set HTTP Strict Transport Security Header 16 | 'strict_transport_security' => [ 17 | 'enable' => true, // set to false to disable it 18 | 'value' => 'max-age=31536000', 19 | ], 20 | 'add_www_prefix' => false, 21 | 'remove_www_prefix' => false, 22 | 'allow_404' => true, 23 | ], 24 | ]; 25 | -------------------------------------------------------------------------------- /config/mezzio-force-https-module.local.php.dist: -------------------------------------------------------------------------------- 1 | [ 7 | 'enable' => true, 8 | 'force_all_routes' => true, 9 | 'force_specific_routes' => [ 10 | // a lists of specific routes to be https 11 | // only works if previous config 'force_all_routes' => false 12 | ], 13 | 'exclude_specific_routes' => [ 14 | // a lists of specific routes to not be https 15 | // only works if previous config 'force_all_routes' => true 16 | ], 17 | // set HTTP Strict Transport Security Header 18 | 'strict_transport_security' => [ 19 | 'enable' => true, // set to false to disable it 20 | 'value' => 'max-age=31536000', 21 | ], 22 | 'add_www_prefix' => false, 23 | 'remove_www_prefix' => false, 24 | 'allow_404' => true, 25 | ], 26 | 27 | 'dependencies' => [ 28 | 'factories' => [ 29 | Middleware\ForceHttps::class => Middleware\ForceHttpsFactory::class, 30 | ], 31 | ], 32 | 33 | 'middleware_pipeline' => [ 34 | 'always' => [ 35 | 'middleware' => [ 36 | Middleware\ForceHttps::class 37 | ], 38 | 'priority' => PHP_INT_MAX, 39 | ], 40 | ], 41 | 42 | ]; 43 | -------------------------------------------------------------------------------- /config/module.config.php: -------------------------------------------------------------------------------- 1 | [ 9 | 'factories' => [ 10 | ForceHttps::class => ForceHttpsFactory::class, 11 | ], 12 | ], 13 | 'listeners' => [ 14 | ForceHttps::class, 15 | ], 16 | ]; 17 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - vendor/phpstan/phpstan-webmozart-assert/extension.neon 3 | - phpstan-baseline.neon 4 | -------------------------------------------------------------------------------- /src/HttpsTrait.php: -------------------------------------------------------------------------------- 1 | isFailure())) { 31 | return (bool) ($this->config['allow_404'] ?? false); 32 | } 33 | 34 | $matchedRouteName = $match->getMatchedRouteName(); 35 | 36 | if ($this->config['force_all_routes']) { 37 | return ! (! empty($this->config['exclude_specific_routes']) 38 | && in_array($matchedRouteName, $this->config['exclude_specific_routes'])); 39 | } 40 | 41 | return in_array($matchedRouteName, $this->config['force_specific_routes']); 42 | } 43 | 44 | /** 45 | * Check if Setup Strict-Transport-Security need to be skipped. 46 | */ 47 | private function isSkippedHttpStrictTransportSecurity( 48 | string $uriScheme, 49 | RouteMatch|RouteResult|null $match = null 50 | ): bool { 51 | return ! $this->isSchemeHttps($uriScheme) || 52 | ! $this->isGoingToBeForcedToHttps($match) || 53 | ! isset( 54 | $this->config['strict_transport_security']['enable'], 55 | $this->config['strict_transport_security']['value'] 56 | ); 57 | } 58 | 59 | /** 60 | * Add www. prefix when use add_www_prefix = true 61 | */ 62 | private function withWwwPrefixWhenRequired(string $httpsRequestUri): string 63 | { 64 | $this->needsWwwPrefix = (bool) ($this->config['add_www_prefix'] ?? false); 65 | $this->alreadyHasWwwPrefix = strpos($httpsRequestUri, 'www.', 8) === 8; 66 | 67 | if (! $this->needsWwwPrefix || $this->alreadyHasWwwPrefix) { 68 | return $httpsRequestUri; 69 | } 70 | 71 | return substr_replace($httpsRequestUri, 'www.', 8, 0); 72 | } 73 | 74 | /** 75 | * Remove www. prefix when use remove_www_prefix = true 76 | * It only works if previous's config 'add_www_prefix' => false 77 | */ 78 | private function withoutWwwPrefixWhenNotRequired(string $httpsRequestUri): string 79 | { 80 | if ($this->needsWwwPrefix) { 81 | return $httpsRequestUri; 82 | } 83 | 84 | $removeWwwPrefix = $this->config['remove_www_prefix'] ?? false; 85 | if (! $removeWwwPrefix || ! $this->alreadyHasWwwPrefix) { 86 | return $httpsRequestUri; 87 | } 88 | 89 | return substr_replace($httpsRequestUri, '', 8, 4); 90 | } 91 | 92 | /** 93 | * Get Final Request Uri with configured with or without www prefix 94 | */ 95 | private function getFinalhttpsRequestUri(string $httpsRequestUri): string 96 | { 97 | $httpsRequestUri = $this->withWwwPrefixWhenRequired($httpsRequestUri); 98 | 99 | return $this->withoutWwwPrefixWhenNotRequired($httpsRequestUri); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/Listener/ForceHttps.php: -------------------------------------------------------------------------------- 1 | isInConsole() || ! $this->config['enable']) { 37 | return; 38 | } 39 | 40 | $this->listeners[] = $eventManager->attach(MvcEvent::EVENT_ROUTE, [$this, 'forceHttpsScheme']); 41 | $this->listeners[] = $eventManager->attach(MvcEvent::EVENT_DISPATCH_ERROR, [$this, 'forceHttpsScheme'], 1000); 42 | } 43 | 44 | /** 45 | * Check if currently running in console 46 | */ 47 | private function isInConsole(): bool 48 | { 49 | return PHP_SAPI === 'cli' || defined('STDIN'); 50 | } 51 | 52 | /** 53 | * Force Https Scheme handle. 54 | */ 55 | public function forceHttpsScheme(MvcEvent $mvcEvent): void 56 | { 57 | /** @var Request $request */ 58 | $request = $mvcEvent->getRequest(); 59 | /** @var Response $response */ 60 | $response = $mvcEvent->getResponse(); 61 | 62 | $http = $request->getUri(); 63 | /** @var string $uriScheme*/ 64 | $uriScheme = $http->getScheme(); 65 | 66 | /** @var RouteMatch|null $routeMatch */ 67 | $routeMatch = $mvcEvent->getRouteMatch(); 68 | $response = $this->setHttpStrictTransportSecurity($uriScheme, $response, $routeMatch); 69 | if (! $this->isGoingToBeForcedToHttps($routeMatch)) { 70 | return; 71 | } 72 | 73 | if ($this->isSchemeHttps($uriScheme)) { 74 | $uriString = $http->toString(); 75 | $httpsRequestUri = $this->getFinalhttpsRequestUri($uriString); 76 | 77 | if ($uriString === $httpsRequestUri) { 78 | return; 79 | } 80 | } 81 | 82 | $httpsRequestUri ??= $this->getFinalhttpsRequestUri((string) $http->setScheme('https')); 83 | 84 | // 308 keeps headers, request method, and request body 85 | $response->setStatusCode(308); 86 | $response->getHeaders() 87 | ->addHeaderLine('Location', $httpsRequestUri); 88 | $response->send(); 89 | 90 | exit(0); 91 | } 92 | 93 | private function setHttpStrictTransportSecurity( 94 | string $uriScheme, 95 | Response $response, 96 | ?RouteMatch $routeMatch 97 | ): Response { 98 | if ($this->isSkippedHttpStrictTransportSecurity($uriScheme, $routeMatch)) { 99 | return $response; 100 | } 101 | 102 | if ($this->config['strict_transport_security']['enable'] === true) { 103 | $response->getHeaders() 104 | ->addHeaderLine(sprintf( 105 | 'Strict-Transport-Security: %s', 106 | $this->config['strict_transport_security']['value'] 107 | )); 108 | return $response; 109 | } 110 | 111 | // set max-age = 0 to strictly expire it, 112 | $response->getHeaders() 113 | ->addHeaderLine('Strict-Transport-Security: max-age=0'); 114 | return $response; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/Listener/ForceHttpsFactory.php: -------------------------------------------------------------------------------- 1 | get('config'); 14 | $forceHttpsConfig = (array) ($config['force-https-module'] ?? ['enable' => false]); 15 | 16 | return new ForceHttps($forceHttpsConfig); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Middleware/ForceHttps.php: -------------------------------------------------------------------------------- 1 | isSkippedHttpStrictTransportSecurity($uriScheme, $routeResult)) { 32 | return $response; 33 | } 34 | 35 | if ($this->config['strict_transport_security']['enable'] === true) { 36 | return $response->withHeader( 37 | 'Strict-Transport-Security', 38 | $this->config['strict_transport_security']['value'] 39 | ); 40 | } 41 | 42 | return $response->withHeader('Strict-Transport-Security', 'max-age=0'); 43 | } 44 | 45 | public function process( 46 | ServerRequestInterface $serverRequest, 47 | RequestHandlerInterface $requestHandler 48 | ): ResponseInterface { 49 | $response = $requestHandler->handle($serverRequest); 50 | if (! $this->config['enable']) { 51 | return $response; 52 | } 53 | 54 | $match = $this->router->match($serverRequest); 55 | 56 | $uri = $serverRequest->getUri(); 57 | $uriScheme = $uri->getScheme(); 58 | 59 | $response = $this->setHttpStrictTransportSecurity($uriScheme, $response, $match); 60 | if (! $this->isGoingToBeForcedToHttps($match)) { 61 | return $response; 62 | } 63 | 64 | if ($this->isSchemeHttps($uriScheme)) { 65 | $uriString = $uri->__toString(); 66 | $httpsRequestUri = $this->getFinalhttpsRequestUri($uriString); 67 | 68 | if ($uriString === $httpsRequestUri) { 69 | return $response; 70 | } 71 | } 72 | 73 | $httpsRequestUri ??= $this->getFinalhttpsRequestUri((string) $uri->withScheme('https')); 74 | 75 | // 308 keeps headers, request method, and request body 76 | $response = $response->withStatus(308); 77 | return $response->withHeader('Location', $httpsRequestUri); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Middleware/ForceHttpsFactory.php: -------------------------------------------------------------------------------- 1 | get('config'); 15 | /** @var RouterInterface $router */ 16 | $router = $container->get(RouterInterface::class); 17 | $forceHttpsConfig = (array) ($config['force-https-module'] ?? ['enable' => false]); 18 | 19 | return new ForceHttps($forceHttpsConfig, $router); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Module.php: -------------------------------------------------------------------------------- 1 |