├── CHANGELOG.md ├── LICENSE ├── README.md ├── composer.json ├── docs ├── assets │ ├── favicon.ico │ ├── flame.svg │ ├── github-dark-dimmed.css │ └── hljs.js ├── commands.md ├── configuration.md ├── filters.md ├── helpers.md ├── index.md ├── installation.md └── methods.md ├── infection.json.dist ├── mkdocs.yml ├── psalm.xml ├── psalm_autoload.php ├── src ├── Commands │ ├── SignedUrlAlgorithms.php │ └── SignedUrlPublish.php ├── Common.php ├── Config │ ├── Registrar.php │ ├── Services.php │ └── SignedUrl.php ├── Exceptions │ └── SignedUrlException.php ├── Filters │ └── SignedUrl.php ├── Language │ └── en │ │ └── SignedUrl.php └── SignedUrl.php └── tests ├── CommonTest.php └── SignedUrlTest.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | ## [Unreleased] 5 | 6 | ## [2.1.0](https://github.com/michalsn/codeigniter-signed-url/compare/v1.0.0...v1.1.0) - 2023-09-08 7 | 8 | ### Enhancements 9 | - Added `token` option to set a randomly generated token of the specified length by @michalsn in #45 10 | - Added `redirectTo` option setting to set a URI path for redirection when filter URL validation fails by @michalsn in #46 11 | 12 | ## [2.0.0](https://github.com/michalsn/codeigniter-signed-url/compare/v1.1.1...v2.0.0) - 2023-08-26 13 | 14 | ### Fixes 15 | - Compatibility with CodeIgniter 4.4 by @michalsn in #38 16 | 17 | ### Enhancements 18 | - Default hashing algorithm has been changed from `sha1` to `sha256` by @michalsn in #38 19 | 20 | ## [1.1.1](https://github.com/michalsn/codeigniter-signed-url/compare/v1.1.0...v1.1.1) - 2023-04-05 21 | 22 | ### Bugs 23 | - Take `App::$indexPage` and `App::$baseURL` into consideration during URL verification by @michalsn in #22 24 | 25 | ## [1.1.0](https://github.com/michalsn/codeigniter-signed-url/compare/v1.0.0...v1.1.0) - 2022-12-31 26 | 27 | ### Enhancements 28 | - Autoload `signedurl` filter by @datamweb in #2 29 | 30 | ## [1.0.0] - 2022-12-28 31 | Initial release 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Michal Sniatala 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CodeIgniter Signed URL 2 | 3 | Prevent manual URL manipulation and auto expiry URLs. 4 | 5 | [![PHPUnit](https://github.com/michalsn/codeigniter-signed-url/actions/workflows/phpunit.yml/badge.svg)](https://github.com/michalsn/codeigniter-signed-url/actions/workflows/phpunit.yml) 6 | [![PHPStan](https://github.com/michalsn/codeigniter-signed-url/actions/workflows/phpstan.yml/badge.svg)](https://github.com/michalsn/codeigniter-signed-url/actions/workflows/phpstan.yml) 7 | [![Deptrac](https://github.com/michalsn/codeigniter-signed-url/actions/workflows/deptrac.yml/badge.svg)](https://github.com/michalsn/codeigniter-signed-url/actions/workflows/deptrac.yml) 8 | [![Coverage Status](https://coveralls.io/repos/github/michalsn/codeigniter-signed-url/badge.svg?branch=develop)](https://coveralls.io/github/michalsn/codeigniter-signed-url?branch=develop) 9 | 10 | 11 | ## Installation 12 | 13 | composer require michalsn/codeigniter-signed-url 14 | 15 | ## Overview 16 | 17 | We can sign URLs very easy with two main methods that act similar to the helper functions known from CodeIgniter's URL helper. 18 | 19 | ```php 20 | echo signedurl()->siteUrl('controller/method?query=string'); 21 | // https://example.com/controller/method?query=string&signature=signature-goes-here 22 | ``` 23 | 24 | ```php 25 | echo signedurl()->setExpiration(DAY * 2)->urlTo('namedRoute', 12); 26 | // https://example.com/route/name/12?expiration=1671980371&signature=signature-goes-here 27 | ``` 28 | 29 | ## Versions 30 | 31 | Versions are not compatible - URLs generated in one version of Signed URL will not work with another version. 32 | 33 | | CodeIgniter version | Signed URL version | 34 | |---------------------|--------------------| 35 | | `>= 4.4` | `2.*` | 36 | | `< 4.4` | `1.*` | 37 | 38 | ## Docs 39 | 40 | https://michalsn.github.io/codeigniter-signed-url 41 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "michalsn/codeigniter-signed-url", 3 | "description": "Signing URL functionality for CodeIgniter 4 framework", 4 | "license": "MIT", 5 | "type": "library", 6 | "keywords": ["codeigniter", "codeigniter4", "signed-url"], 7 | "authors": [ 8 | { 9 | "name": "michalsn", 10 | "homepage": "https://github.com/michalsn", 11 | "role": "Developer" 12 | } 13 | ], 14 | "homepage": "https://github.com/michalsn/codeigniter-signed-url", 15 | "require": { 16 | "php": "^8.0" 17 | }, 18 | "require-dev": { 19 | "codeigniter4/devkit": "^1.0", 20 | "codeigniter4/framework": "^4.4" 21 | }, 22 | "minimum-stability": "dev", 23 | "prefer-stable": true, 24 | "autoload": { 25 | "psr-4": { 26 | "Michalsn\\CodeIgniterSignedUrl\\": "src" 27 | }, 28 | "files": ["src/Common.php"] 29 | }, 30 | "autoload-dev": { 31 | "psr-4": { 32 | "Tests\\": "tests" 33 | } 34 | }, 35 | "config": { 36 | "allow-plugins": { 37 | "phpstan/extension-installer": true 38 | } 39 | }, 40 | "scripts": { 41 | "analyze": [ 42 | "phpstan analyze", 43 | "psalm", 44 | "rector process --dry-run" 45 | ], 46 | "sa": "@analyze", 47 | "ci": [ 48 | "Composer\\Config::disableProcessTimeout", 49 | "@cs", 50 | "@deduplicate", 51 | "@inspect", 52 | "@analyze", 53 | "@test" 54 | ], 55 | "cs": "php-cs-fixer fix --ansi --verbose --dry-run --diff", 56 | "cs-fix": "php-cs-fixer fix --ansi --verbose --diff", 57 | "style": "@cs-fix", 58 | "deduplicate": "phpcpd app/ src/", 59 | "inspect": "deptrac analyze --cache-file=build/deptrac.cache", 60 | "mutate": "infection --threads=2 --skip-initial-tests --coverage=build/phpunit", 61 | "test": "phpunit" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /docs/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michalsn/codeigniter-signed-url/d858fba8d69baf03fd1f0ac9b544b478df1cf19e/docs/assets/favicon.ico -------------------------------------------------------------------------------- /docs/assets/flame.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /docs/assets/github-dark-dimmed.css: -------------------------------------------------------------------------------- 1 | pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*! 2 | Theme: GitHub Dark Dimmed 3 | Description: Dark dimmed theme as seen on github.com 4 | Author: github.com 5 | Maintainer: @Hirse 6 | Updated: 2021-05-15 7 | Modified: 2022:12:27 by @michalsn 8 | 9 | Colors taken from GitHub's CSS 10 | */.hljs{color:#adbac7 !important;background-color:#22272e !important}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#f47067}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#dcbdfb}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#6cb6ff}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#96d0ff}.hljs-built_in,.hljs-symbol{color:#f69d50}.hljs-code,.hljs-comment,.hljs-formula{color:#768390}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#8ddb8c}.hljs-subst{color:#adbac7}.hljs-section{color:#316dca;font-weight:700}.hljs-bullet{color:#eac55f}.hljs-emphasis{color:#adbac7;font-style:italic}.hljs-strong{color:#adbac7;font-weight:700}.hljs-addition{color:#b4f1b4;background-color:#1b4721}.hljs-deletion{color:#ffd8d3;background-color:#78191b} 11 | 12 | [data-md-color-scheme="default"] { 13 | --md-default-fg-color--lightest: #575757; 14 | --md-default-fg-color--light: #959595; 15 | } 16 | -------------------------------------------------------------------------------- /docs/assets/hljs.js: -------------------------------------------------------------------------------- 1 | window.document$.subscribe(() => { 2 | hljs.highlightAll(); 3 | }); 4 | -------------------------------------------------------------------------------- /docs/commands.md: -------------------------------------------------------------------------------- 1 | # Commands 2 | 3 | Available options: 4 | 5 | - [publish](#publish) 6 | - [algorithms](#algorithms) 7 | 8 | ### publish 9 | 10 | This command will publish configuration file to the APP namespace. 11 | 12 | ```console 13 | php spark signedurl:publish 14 | ``` 15 | 16 | ### algorithms 17 | 18 | This command will list all avaliable algorithms options to use with `$algorithm` config variable. 19 | 20 | ```console 21 | php spark signedurl:algorithms 22 | ``` 23 | 24 | !!! warning 25 | 26 | If you're not sure what you're doing please stay with the default option. 27 | -------------------------------------------------------------------------------- /docs/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | To make changes to the config file, we have to have our copy in the `app/Config/SignedUrl.php`. Luckily, this package comes with handy command that will make this easy. 4 | 5 | When we run: 6 | 7 | php spark signedurl:publish 8 | 9 | We will get our copy ready for modifications. 10 | 11 | --- 12 | 13 | Available options: 14 | 15 | - [$expiration](#expiration) 16 | - [$token](#token) 17 | - [$algorithm](#algorithm) 18 | - [$expirationKey](#expirationKey) 19 | - [$tokenKey](#tokenKey) 20 | - [$signatureKey](#signatureKey) 21 | - [$algorithmKey](#algorithmKey) 22 | - [$includeAlgorithmKey](#includeAlgorithmKey) 23 | - [$redirect](#redirect) 24 | - [$show404](#show404) 25 | 26 | ### $expiration 27 | 28 | This setting allows us to set a fixed time after which the signed URL will expire. 29 | It's number of seconds in unix timestamp that will be added to the current date. 30 | 31 | By default, this is set to `null`. 32 | 33 | ### $token 34 | 35 | This setting allows us to set a randomly generated token with given length. 36 | It is useful when you have very few changing parameters in the URL. 37 | 38 | By default, this is set to `null`. 39 | 40 | ### $algorithm 41 | 42 | This setting allows us to set algorithm that will be used during signing the URLs. 43 | 44 | By default, this is set to `sha256`. 45 | 46 | !!! note 47 | 48 | If you're not sure what you're doing please stay with the default option. 49 | 50 | You can see the list of all available options when running command: 51 | 52 | php spark signedurl:algorithms 53 | 54 | !!! warning 55 | 56 | When you don't include used algorithm to the query string (default), then changing algorithm will result with invalidating all the generated URLs. 57 | 58 | ### $expirationKey 59 | 60 | This is the name of the query string key, which will be responsible for storing the time after which the URL will expire. 61 | 62 | By default, this is set to `expires`. 63 | 64 | !!! note 65 | 66 | Whatever name you will choose, treat it as a restricted name and don't use it as a part of the query string in your code. 67 | 68 | ### $tokenKey 69 | 70 | This is the name of the query string key, which will be responsible for storing the token string. 71 | 72 | By default, this is set to `token`. 73 | 74 | !!! note 75 | 76 | Whatever name you will choose, treat it as a restricted name and don't use it as a part of the query string in your code. 77 | 78 | ### $signatureKey 79 | 80 | This is the name of the query string key, which will be responsible for storing the signature by which the validity of the entire URL will be checked. 81 | 82 | By default, this is set to `signature`. 83 | 84 | !!! note 85 | 86 | Whatever name you will choose, treat it as a restricted name and don't use it as a part of the query string in your code. 87 | 88 | ### $algorithmKey 89 | 90 | This is the name of the query string key, which will be responsible for storing the algorithm by which the validity of the entire URL will be checked. 91 | 92 | By default, this is set to `algorithm`. 93 | 94 | !!! note 95 | 96 | Whatever name you will choose, treat it as a restricted name and don't use it as a part of the query string in your code. 97 | 98 | ### $includeAlgorithmKey 99 | 100 | This setting determines if the algorithm will be included to the query string of the generated URL. 101 | 102 | By default, this is set to `false`. 103 | 104 | ### $redirectTo 105 | 106 | This setting is used in the Filter to determine whether we will redirect user to the given URI path with the `error`, when URL will not be valid or expired. 107 | 108 | By default, this is set to `null`. 109 | 110 | ### $redirect 111 | 112 | This setting is used in the Filter to determine whether we will redirect user to the previous page with the `error`, when URL will not be valid or expired. 113 | 114 | By default, this is set to `false`. 115 | 116 | ### $show404 117 | 118 | This setting is used in the Filter to determine whether we will show a 404 page, when URL will not be valid or expired. 119 | 120 | By default, this is set to `false`. 121 | -------------------------------------------------------------------------------- /docs/filters.md: -------------------------------------------------------------------------------- 1 | # Filters 2 | 3 | ## Overview 4 | 5 | To validate signed URLs we can use build in filter. We can enable it in one simple step. 6 | 7 | Define when filter should be fired up. In the example below we will assume it will be used when the first segment of the url will contain `signed-urls` string. 8 | 9 | ```php 10 | // app/Config/Filters.php 11 | ['before' => ['signed-urls/*']], 22 | ]; 23 | } 24 | ``` 25 | 26 | ## Options 27 | 28 | By default, this filter will throw `SignedUrlException` when the URL won't be signed or will be expired. But there are other options, and we can enable them by editing the config file: 29 | 30 | * We can redirect to the previous page 31 | * Or show 404 page 32 | 33 | More info you can find in the [Configuration](configuration.md) page. 34 | 35 | !!! note 36 | 37 | Remember, that if the filter implementation doesn't suit you, you can always [create your own](https://codeigniter.com/user_guide/incoming/filters.html?highlight=filter#creating-a-filter), which will behave differently upon an error. You can also not use the filter at all and make the check in the controller. 38 | -------------------------------------------------------------------------------- /docs/helpers.md: -------------------------------------------------------------------------------- 1 | # Helpers 2 | 3 | Available options: 4 | 5 | - [signedurl()](#signedurl) 6 | 7 | #### signedurl() 8 | 9 | This function returns the `SignedUrl` class instance. 10 | 11 | ```php 12 | signedurl()->setExpiration(DAY)->siteUrl('controller/method'); 13 | ``` 14 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # CodeIgniter Signed URL Documentation 2 | 3 | This library makes it easy to sign URLs in CodeIgniter 4 framework. It can be used to **prevent manual URL manipulation** or to **auto expiry links** that have been given to the end user. 4 | 5 | ## Overview 6 | 7 | We can sign URLs very easy with two main methods that act similar to the helper functions known from CodeIgniter's URL helper. 8 | 9 | ```php 10 | echo signedurl()->siteUrl('controller/method?query=string'); 11 | // https://example.com/controller/method?query=string&signature=signature-goes-here 12 | ``` 13 | 14 | ```php 15 | echo signedurl()->setExpiration(DAY * 2)->urlTo('namedRoute', 12); 16 | // https://example.com/route/name/12?expiration=1671980371&signature=signature-goes-here 17 | ``` 18 | 19 | ## Versions 20 | 21 | Versions are not compatible - URLs generated in one version of Signed URL will not work with another version. 22 | 23 | | CodeIgniter version | Signed URL version | 24 | |---------------------|--------------------| 25 | | `>= 4.4` | `2.*` | 26 | | `< 4.4` | `1.*` | 27 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | - [Composer Installation](#composer-installation) 4 | - [Manual Installation](#manual-installation) 5 | - [Generate encryption key](#generate-encryption-key) 6 | 7 | ## Composer Installation 8 | 9 | The only thing you have to do is to run this command, and you're ready to go. 10 | 11 | ```console 12 | composer require michalsn/codeigniter-signed-url 13 | ``` 14 | 15 | ## Manual Installation 16 | 17 | In the example below we will assume, that files from this project will be located in `app/ThirdParty/signed-url` directory. 18 | 19 | Download this project and then enable it by editing the `app/Config/Autoload.php` file and adding the `Michalsn\CodeIgniterSignedUrl` namespace to the `$psr4` array. You also have to add `Common.php` to the `$files` array, like in the below example: 20 | 21 | ```php 22 | APPPATH, // For custom app namespace 28 | 'Config' => APPPATH . 'Config', 29 | 'Michalsn\CodeIgniterSignedUrl' => APPPATH . 'ThirdParty/signed-url/src', 30 | ]; 31 | 32 | ... 33 | 34 | public $files = [ 35 | APPPATH . 'ThirdParty/signed-url/src/Common.php', 36 | ]; 37 | ``` 38 | 39 | ## Generate encryption key 40 | 41 | Make sure that you have generated the encryption key. If not, please run command: 42 | 43 | ```console 44 | php spark key:generate 45 | ``` 46 | 47 | !!! warning 48 | 49 | Please remember that any change made to the `encryption key` after generating signed URLs will auto expire them. 50 | 51 | 52 | -------------------------------------------------------------------------------- /docs/methods.md: -------------------------------------------------------------------------------- 1 | # Methods 2 | 3 | Available options: 4 | 5 | - [setExpiration()](#setExpiration) 6 | - [siteUrl()](#siteUrl) 7 | - [urlTo()](#urlTo) 8 | - [sign()](#sign) 9 | - [verify()](#verify) 10 | 11 | ### setExpiration() 12 | 13 | With this method we can set temporary value for expiration. The value set here will be reset when the: `siteUrl()`, `urlTo()` or `sign()` methods are called. 14 | 15 | This is number of seconds in unix timestamp that will be added to the current date. 16 | 17 | ```php 18 | service('signedurl')->setExpiration(DAY)->siteUrl('url'); 19 | ``` 20 | 21 | !!! note 22 | 23 | If you want the URLs to always be valid for a certain period of time, you can set time in the `$expiration` variable in the configuration file. 24 | 25 | ### siteUrl() 26 | 27 | This method is similar to the standard `site_url`, but it produces signed URL. 28 | 29 | ```php 30 | service('signedurl')->siteUrl('controller/method'); 31 | ``` 32 | 33 | ### urlTo() 34 | 35 | This method is similar to the standard `url_to`, but it produces signed URL. 36 | 37 | ```php 38 | service('signedurl')->urlTo('namedRoute', 'param'); 39 | ``` 40 | 41 | ### sign() 42 | 43 | With this method we can sign URI. Usually you won't be using this method directly, since it is used by other methods. 44 | 45 | ```php 46 | service('signedurl')->sign($uri); 47 | ``` 48 | 49 | ### verify() 50 | 51 | With this method we can verify if given URL is properly signed and not expired if expiration timestamp was set during URL creation. 52 | 53 | ```php 54 | service('signedurl')->verify($request); 55 | ``` 56 | 57 | The URL verification may take place automatically via Filter class, but you can also make it happen in your Controller instead. The choice is up to you. 58 | -------------------------------------------------------------------------------- /infection.json.dist: -------------------------------------------------------------------------------- 1 | { 2 | "source": { 3 | "directories": [ 4 | "src/" 5 | ], 6 | "excludes": [ 7 | "Config", 8 | "Database/Migrations", 9 | "Views" 10 | ] 11 | }, 12 | "logs": { 13 | "text": "build/infection.log" 14 | }, 15 | "mutators": { 16 | "@default": true 17 | }, 18 | "bootstrap": "vendor/codeigniter4/framework/system/Test/bootstrap.php" 19 | } 20 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: CodeIgniter Signed URL 2 | site_description: Documentation for Signed URL library for CodeIgniter 4 framework 3 | 4 | theme: 5 | name: material 6 | logo: assets/flame.svg 7 | favicon: assets/favicon.ico 8 | icon: 9 | repo: fontawesome/brands/github 10 | palette: 11 | - media: "(prefers-color-scheme: light)" 12 | scheme: default 13 | primary: indigo 14 | accent: indigo 15 | toggle: 16 | icon: material/brightness-7 17 | name: Switch to dark mode 18 | - media: "(prefers-color-scheme: dark)" 19 | scheme: slate 20 | primary: indigo 21 | accent: indigo 22 | toggle: 23 | icon: material/brightness-4 24 | name: Switch to light mode 25 | features: 26 | - navigation.instant 27 | 28 | extra: 29 | homepage: https://michalsn.github.io/codeigniter-signed-url 30 | 31 | social: 32 | - icon: fontawesome/brands/github 33 | link: https://github.com/michalsn/codeigniter-signed-url 34 | name: GitHub 35 | 36 | site_url: https://michalsn.github.io/codeigniter-signed-url/ 37 | repo_url: https://github.com/michalsn/codeigniter-signed-url 38 | edit_uri: edit/develop/docs/ 39 | 40 | markdown_extensions: 41 | - admonition 42 | - pymdownx.superfences 43 | - pymdownx.highlight: 44 | use_pygments: false 45 | 46 | extra_css: 47 | - assets/github-dark-dimmed.css 48 | 49 | extra_javascript: 50 | - https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.6.0/build/highlight.min.js 51 | - assets/hljs.js 52 | 53 | nav: 54 | - Home: index.md 55 | - Installation: installation.md 56 | - Configuration: configuration.md 57 | - Methods: methods.md 58 | - Helpers: helpers.md 59 | - Filters: filters.md 60 | - Commands: commands.md 61 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /psalm_autoload.php: -------------------------------------------------------------------------------- 1 | $item) { 25 | $tbody[] = [++$key, $item]; 26 | } 27 | 28 | CLI::table($tbody, $thead); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Commands/SignedUrlPublish.php: -------------------------------------------------------------------------------- 1 | getNamespace('Michalsn\\CodeIgniterSignedUrl')[0]; 22 | 23 | $publisher = new Publisher($source, APPPATH); 24 | 25 | try { 26 | $publisher->addPaths([ 27 | 'Config/SignedUrl.php', 28 | ])->merge(false); 29 | } catch (Throwable $e) { 30 | $this->showError($e); 31 | 32 | return; 33 | } 34 | 35 | foreach ($publisher->getPublished() as $file) { 36 | $contents = file_get_contents($file); 37 | $contents = str_replace('namespace Michalsn\\CodeIgniterSignedUrl\\Config', 'namespace Config', $contents); 38 | $contents = str_replace('use CodeIgniter\\Config\\BaseConfig', 'use Michalsn\\CodeIgniterSignedUrl\\Config\\SignedUrl as BaseSignedUrl', $contents); 39 | $contents = str_replace('class SignedUrl extends BaseConfig', 'class SignedUrl extends BaseSignedUrl', $contents); 40 | file_put_contents($file, $contents); 41 | } 42 | 43 | CLI::write(CLI::color(' Published! ', 'green') . 'You can customize the configuration by editing the "app/Config/SignedUrl.php" file.'); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Common.php: -------------------------------------------------------------------------------- 1 | [ 16 | 'signedurl' => SignedUrl::class, 17 | ], 18 | ]; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Config/Services.php: -------------------------------------------------------------------------------- 1 | verify($request); 46 | } catch (SignedUrlException $e) { 47 | if ($path = $signedUrl->shouldRedirectTo()) { 48 | return redirect()->to($path)->with('error', $e->getMessage()); 49 | } 50 | 51 | if ($signedUrl->shouldRedirect()) { 52 | return redirect()->back()->with('error', $e->getMessage()); 53 | } 54 | 55 | if ($signedUrl->shouldShow404()) { 56 | throw PageNotFoundException::forPageNotFound($e->getMessage()); 57 | } 58 | 59 | throw $e; 60 | } 61 | } 62 | 63 | /** 64 | * We don't have anything to do here. 65 | * 66 | * @param array|null $arguments 67 | * 68 | * @return void 69 | */ 70 | public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) 71 | { 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Language/en/SignedUrl.php: -------------------------------------------------------------------------------- 1 | 'Algorithm is incorrect, please run command: "php spark signedurl:algorithms" to see available options.', 5 | 'invalidAlgorithm' => 'Algorithm is invalid or not supported.', 6 | 'emptyExpirationKey' => 'Expiration key cannot be empty.', 7 | 'emptyTokenKey' => 'Token key cannot be empty.', 8 | 'emptySignatureKey' => 'Signature key cannot be empty.', 9 | 'emptyAlgorithmKey' => 'Algorithm key cannot be empty.', 10 | 'sameKeyNames' => 'Expiration, Token, Signature or Algorithm keys cannot share the same name.', 11 | 'emptyEncryptionKey' => 'Encryption key is missing, please run command: "php spark key:generate"', 12 | 'missingSignature' => 'This URL have to be signed.', 13 | 'urlNotValid' => 'This URL is not valid.', 14 | 'urlExpired' => 'This URL has expired.', 15 | ]; 16 | -------------------------------------------------------------------------------- /src/SignedUrl.php: -------------------------------------------------------------------------------- 1 | key = config('Encryption')->key; 22 | 23 | if ($this->config->algorithm === '' || $this->config->algorithm === '0' || ! in_array($this->config->algorithm, hash_hmac_algos(), true)) { 24 | throw SignedUrlException::forIncorrectAlgorithm(); 25 | } 26 | 27 | if ($this->config->expirationKey === '' || $this->config->expirationKey === '0') { 28 | throw SignedUrlException::forEmptyExpirationKey(); 29 | } 30 | 31 | if ($this->config->tokenKey === '' || $this->config->tokenKey === '0') { 32 | throw SignedUrlException::forEmptyTokenKey(); 33 | } 34 | 35 | if ($this->config->signatureKey === '' || $this->config->signatureKey === '0') { 36 | throw SignedUrlException::forEmptySignatureKey(); 37 | } 38 | 39 | if ($this->config->algorithmKey === '' || $this->config->algorithmKey === '0') { 40 | throw SignedUrlException::forEmptyAlgorithmKey(); 41 | } 42 | 43 | if (count(array_unique([$this->config->expirationKey, $this->config->tokenKey, $this->config->signatureKey, $this->config->algorithmKey])) !== 4) { 44 | throw SignedUrlException::forSameKeyNames(); 45 | } 46 | 47 | if (empty($this->key)) { 48 | throw SignedUrlException::forEmptyEncryptionKey(); 49 | } 50 | 51 | $this->resetSettings(); 52 | } 53 | 54 | /** 55 | * Reset settings between calls. 56 | */ 57 | protected function resetSettings(): void 58 | { 59 | $this->tempExpiration = $this->config->expiration; 60 | } 61 | 62 | /** 63 | * Set the URL expiration time. 64 | */ 65 | public function setExpiration(?int $sec): static 66 | { 67 | $this->tempExpiration = $sec; 68 | 69 | return $this; 70 | } 71 | 72 | /** 73 | * Similar to site_url() helper function but with ability of sign the URL. 74 | */ 75 | public function siteUrl(array|string $relativePath): string 76 | { 77 | if (is_array($relativePath)) { 78 | $relativePath = implode('/', $relativePath); 79 | } 80 | 81 | $host = service('request')->getUri()->getHost(); 82 | 83 | $config = config(App::class); 84 | 85 | $uri = new SiteURI($config, $relativePath, $host); 86 | 87 | return $this->sign($uri); 88 | } 89 | 90 | /** 91 | * Similar to url_to() helper function but with ability of sign the URL. 92 | * 93 | * @throws RouterException 94 | */ 95 | public function urlTo(string $controller, int|string ...$args): string 96 | { 97 | if (! $route = route_to($controller, ...$args)) { 98 | $explode = explode('::', $controller); 99 | 100 | if (isset($explode[1])) { 101 | throw RouterException::forControllerNotFound($explode[0], $explode[1]); 102 | } 103 | 104 | throw RouterException::forInvalidRoute($controller); 105 | } 106 | 107 | return $this->siteUrl($route); 108 | } 109 | 110 | /** 111 | * Transform the URI to signed URL. 112 | */ 113 | public function sign(URI $uri): string 114 | { 115 | if ($this->tempExpiration !== null) { 116 | $uri->addQuery($this->config->expirationKey, Time::now()->addSeconds($this->tempExpiration)->getTimestamp()); 117 | } 118 | 119 | if ($this->config->token !== null) { 120 | helper('text'); 121 | $uri->addQuery($this->config->tokenKey, random_string('alnum', $this->config->token)); 122 | } 123 | 124 | if ($this->config->includeAlgorithmKey) { 125 | $uri->addQuery($this->config->algorithmKey, $this->config->algorithm); 126 | } 127 | 128 | $url = URI::createURIString($uri->getScheme(), $uri->getAuthority(), $uri->getPath(), $uri->getQuery(), $uri->getFragment()); 129 | $signature = base64url_encode(hash_hmac($this->config->algorithm, $url, $this->key, true)); 130 | 131 | $uri->addQuery($this->config->signatureKey, $signature); 132 | 133 | $url = URI::createURIString($uri->getScheme(), $uri->getAuthority(), $uri->getPath(), $uri->getQuery(), $uri->getFragment()); 134 | 135 | $this->resetSettings(); 136 | 137 | return $url; 138 | } 139 | 140 | /** 141 | * Verify if URL is properly signed 142 | * 143 | * @throws SignedUrlException 144 | */ 145 | public function verify(IncomingRequest $request): bool 146 | { 147 | $querySignature = $request->getGet($this->config->signatureKey); 148 | $queryExpiration = (int) $request->getGet($this->config->expirationKey); 149 | $queryAlgorithm = $request->getGet($this->config->algorithmKey) ?? $this->config->algorithm; 150 | 151 | if (empty($querySignature)) { 152 | throw SignedUrlException::forMissingSignature(); 153 | } 154 | 155 | if (empty($queryAlgorithm) || ! in_array($queryAlgorithm, hash_hmac_algos(), true)) { 156 | throw SignedUrlException::forInvalidAlgorithm(); 157 | } 158 | 159 | $querySignature = base64url_decode($querySignature); 160 | 161 | $uri = $request->getUri(); 162 | $uri->stripQuery($this->config->signatureKey); 163 | 164 | $url = URI::createURIString('', base_url(), $uri->getPath(), $uri->getQuery(), $uri->getFragment()); 165 | $signature = hash_hmac($queryAlgorithm, $url, $this->key, true); 166 | 167 | if (! hash_equals($querySignature, $signature)) { 168 | throw SignedUrlException::forUrlNotValid(); 169 | } 170 | 171 | if (! empty($queryExpiration) && Time::now()->getTimestamp() > $queryExpiration) { 172 | throw SignedUrlException::forUrlExpired(); 173 | } 174 | 175 | return true; 176 | } 177 | 178 | /** 179 | * Return redirectTo config option. 180 | */ 181 | public function shouldRedirectTo(): ?string 182 | { 183 | return $this->config->redirectTo; 184 | } 185 | 186 | /** 187 | * Check if redirect option is enabled. 188 | */ 189 | public function shouldRedirect(): bool 190 | { 191 | return $this->config->redirect; 192 | } 193 | 194 | /** 195 | * Check if show 404 option is enabled. 196 | */ 197 | public function shouldShow404(): bool 198 | { 199 | return $this->config->show404; 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /tests/CommonTest.php: -------------------------------------------------------------------------------- 1 | key = hex2bin('6ece79d55cd04503600bd97520a0138a067690112fbfb44c704b0c626a7c62a2'); 23 | } 24 | 25 | public function testSignedurl(): void 26 | { 27 | $this->assertInstanceOf(SignedUrl::class, signedurl()); 28 | } 29 | 30 | public function testSignedUrlSiteUrl(): void 31 | { 32 | $this->assertSame( 33 | 'https://example.com/index.php/controller/method?signature=I0a1XPGLCTlRQo5c5f3LCz9R-tKP244-6pKCRV54AEk', 34 | signedurl()->siteUrl(['controller', 'method']) 35 | ); 36 | } 37 | 38 | public function testSignedUrlSiteUrlWithExpirationTime(): void 39 | { 40 | Time::setTestNow('2022-12-25 14:59:11', 'UTC'); 41 | 42 | $this->assertSame( 43 | 'https://example.com/index.php/controller/method?expires=1671980361&signature=9ZKau6qjzGOPY6unRPozK7dtZB1k_5hHQ9j3pwaQmzU', 44 | signedurl()->setExpiration(SECOND * 10)->siteUrl('controller/method') 45 | ); 46 | } 47 | 48 | public function testSignedUrlTo(): void 49 | { 50 | $routes = service('routes'); 51 | $routes->add('path/(:num)', 'myController::goto/$1', ['as' => 'gotoPage']); 52 | 53 | $this->assertSame( 54 | 'https://example.com/index.php/path/13?signature=niwm-RgYXkGSKzuEH1semjC6TU5T8WrHs7FvEEyD8uQ', 55 | signedurl()->urlTo('gotoPage', 13) 56 | ); 57 | } 58 | 59 | public function testSignedUrlToWithExpirationTime(): void 60 | { 61 | $routes = service('routes'); 62 | $routes->add('path/(:num)', 'myController::goto/$1', ['as' => 'gotoPage']); 63 | 64 | Time::setTestNow('2022-12-25 14:59:11', 'UTC'); 65 | 66 | $this->assertSame( 67 | 'https://example.com/index.php/path/13?expires=1671980361&signature=pHMHFrXI74G5JuQc1mUUETznuUNnpHkwhAOsjazxlUw', 68 | signedurl()->setExpiration(SECOND * 10)->urlTo('gotoPage', 13) 69 | ); 70 | } 71 | 72 | public function testSignedUrlToThrowControllerNotFound(): void 73 | { 74 | $this->expectException(RouterException::class); 75 | $this->expectExceptionMessage('Controller or its method is not found: Controller::method'); 76 | signedurl()->urlTo('Controller::method', 13); 77 | } 78 | 79 | public function testSignedUrlToThrowInvalidRoute(): void 80 | { 81 | $this->expectException(RouterException::class); 82 | $this->expectExceptionMessage('The route for "gotoPage" cannot be found.'); 83 | 84 | signedurl()->urlTo('gotoPage', 13); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /tests/SignedUrlTest.php: -------------------------------------------------------------------------------- 1 | config = new App(); 33 | $this->config->baseURL = 'http://example.com/'; 34 | $this->config->indexPage = ''; 35 | 36 | $_SERVER['HTTP_HOST'] = 'example.com'; 37 | $_SERVER['REQUEST_URI'] = '/'; 38 | $_SERVER['SCRIPT_NAME'] = ''; 39 | 40 | config('Encryption')->key = hex2bin('6ece79d55cd04503600bd97520a0138a067690112fbfb44c704b0c626a7c62a2'); 41 | } 42 | 43 | private function createRequest(?App $config = null, $body = null, ?string $path = null) 44 | { 45 | $config ??= new App(); 46 | 47 | $factory = new SiteURIFactory($config, new Superglobals()); 48 | $uri = $factory->createFromGlobals(); 49 | 50 | if ($path !== null) { 51 | $uri->setPath($path); 52 | } 53 | 54 | $request = new IncomingRequest($config, $uri, $body, new UserAgent()); 55 | 56 | Factories::injectMock('config', 'App', $config); 57 | 58 | return $request; 59 | } 60 | 61 | public function testIncorrectAlgorithm(): void 62 | { 63 | $this->expectException(SignedUrlException::class); 64 | $this->expectExceptionMessage('Algorithm is incorrect, please run command: "php spark signedurl:algorithms" to see available options.'); 65 | 66 | $config = new SignedUrlConfig(); 67 | $config->algorithm = ''; 68 | new SignedUrl($config); 69 | } 70 | 71 | public function testMissingExpirationKey(): void 72 | { 73 | $this->expectException(SignedUrlException::class); 74 | $this->expectExceptionMessage('Expiration key cannot be empty.'); 75 | 76 | $config = new SignedUrlConfig(); 77 | $config->expirationKey = ''; 78 | new SignedUrl($config); 79 | } 80 | 81 | public function testMissingTokenKey(): void 82 | { 83 | $this->expectException(SignedUrlException::class); 84 | $this->expectExceptionMessage('Token key cannot be empty.'); 85 | 86 | $config = new SignedUrlConfig(); 87 | $config->tokenKey = ''; 88 | new SignedUrl($config); 89 | } 90 | 91 | public function testMissingSignatureKey(): void 92 | { 93 | $this->expectException(SignedUrlException::class); 94 | $this->expectExceptionMessage('Signature key cannot be empty.'); 95 | 96 | $config = new SignedUrlConfig(); 97 | $config->signatureKey = ''; 98 | new SignedUrl($config); 99 | } 100 | 101 | public function testMissingAlgorithmKey(): void 102 | { 103 | $this->expectException(SignedUrlException::class); 104 | $this->expectExceptionMessage('Algorithm key cannot be empty.'); 105 | 106 | $config = new SignedUrlConfig(); 107 | $config->algorithmKey = ''; 108 | new SignedUrl($config); 109 | } 110 | 111 | public function testSameKeyNames(): void 112 | { 113 | $this->expectException(SignedUrlException::class); 114 | $this->expectExceptionMessage('Expiration, Token, Signature or Algorithm keys cannot share the same name.'); 115 | 116 | $config = new SignedUrlConfig(); 117 | $config->expirationKey = 'same'; 118 | $config->signatureKey = 'same'; 119 | new SignedUrl($config); 120 | } 121 | 122 | public function testMissingEncryptionKey(): void 123 | { 124 | $this->expectException(SignedUrlException::class); 125 | $this->expectExceptionMessage('Encryption key is missing, please run command: "php spark key:generate"'); 126 | 127 | config('Encryption')->key = ''; 128 | 129 | $config = new SignedUrlConfig(); 130 | new SignedUrl($config); 131 | } 132 | 133 | public function testSignWithNoExpirationInConfig(): void 134 | { 135 | $expectedUrl = 'https://example.com/path?query=string'; 136 | $uri = new URI($expectedUrl); 137 | 138 | $config = new SignedUrlConfig(); 139 | $signedUrl = new SignedUrl($config); 140 | $url = $signedUrl->sign($uri); 141 | 142 | $expectedUrl .= '&signature=ongZW4ttfJMqN757mwNXp5kx_3snwQhaDyI6JiV-5FM'; 143 | 144 | $this->assertSame($expectedUrl, $url); 145 | } 146 | 147 | public function testSignWithIncludedAlgorithm(): void 148 | { 149 | $expectedUrl = 'https://example.com/path?query=string'; 150 | $uri = new URI($expectedUrl); 151 | 152 | $config = new SignedUrlConfig(); 153 | $config->includeAlgorithmKey = true; 154 | 155 | $signedUrl = new SignedUrl($config); 156 | $url = $signedUrl->sign($uri); 157 | 158 | $expectedUrl .= '&algorithm=sha256&signature=IldvSUQVJqTc8Gq47i0pEvuUYNjK_oRX1PAw-ZaXyM4'; 159 | 160 | $this->assertSame($expectedUrl, $url); 161 | } 162 | 163 | public function testSignWithExpirationFromConfig(): void 164 | { 165 | $expectedUrl = 'https://example.com/path?query=string'; 166 | $uri = new URI($expectedUrl); 167 | 168 | Time::setTestNow('2022-12-25 14:59:11', 'UTC'); 169 | 170 | $config = new SignedUrlConfig(); 171 | $config->expiration = SECOND * 10; 172 | $signedUrl = new SignedUrl($config); 173 | $url = $signedUrl->sign($uri); 174 | 175 | $expectedUrl .= '&expires=1671980361&signature=qohLh7fvypmDF9vktdJ6DBXH6fiKyBezNQblosN2sbA'; 176 | 177 | $this->assertSame($expectedUrl, $url); 178 | } 179 | 180 | public function testSignWithOverwritenExpirationFromConfig(): void 181 | { 182 | $expectedUrl = 'https://example.com/path?query=string'; 183 | $uri = new URI($expectedUrl); 184 | 185 | Time::setTestNow('2022-12-25 14:59:11', 'UTC'); 186 | 187 | $config = new SignedUrlConfig(); 188 | $config->expiration = SECOND * 10; 189 | $signedUrl = new SignedUrl($config); 190 | $url = $signedUrl->setExpiration(SECOND * 20)->sign($uri); 191 | 192 | $expectedUrl .= '&expires=1671980371&signature=IzHjHhkTOOBPTayZnk8f_ut0H4-3q0YrDb11slKPWWE'; 193 | 194 | $this->assertSame($expectedUrl, $url); 195 | } 196 | 197 | public function testVerifyWithIndexPage(): void 198 | { 199 | $this->config->indexPage = 'index.php'; 200 | $_SERVER['SCRIPT_NAME'] = '/index.php'; 201 | 202 | $_SERVER['REQUEST_URI'] = '/path?query=string&signature=joVnKjlHYIeuLtyUW5SnQ-US2FPkWkykZnSmf2D_RZY'; 203 | 204 | $request = $this->createRequest($this->config); 205 | 206 | $config = new SignedUrlConfig(); 207 | $signedUrl = new SignedUrl($config); 208 | 209 | Time::setTestNow('2022-12-25 14:59:11', 'UTC'); 210 | 211 | $this->assertTrue($signedUrl->verify($request)); 212 | } 213 | 214 | public function testVerifyWithoutExpiration(): void 215 | { 216 | $_SERVER['REQUEST_URI'] = '/path?query=string&signature=iBEmAoQ9cPafZ3N05b9jEMj906Nd5nmSsJV7rKzFZSY'; 217 | 218 | $request = $this->createRequest($this->config); 219 | 220 | $config = new SignedUrlConfig(); 221 | $signedUrl = new SignedUrl($config); 222 | 223 | Time::setTestNow('2022-12-25 14:59:11', 'UTC'); 224 | 225 | $this->assertTrue($signedUrl->verify($request)); 226 | } 227 | 228 | public function testVerifyWithExpiration(): void 229 | { 230 | $_SERVER['REQUEST_URI'] = '/path?query=string&expires=1671980371&signature=9GNwvgcsK7jJUPpXe3MK5xFbE0rb5ZBHIjKc1qqWSgU'; 231 | 232 | $request = $this->createRequest($this->config); 233 | 234 | $config = new SignedUrlConfig(); 235 | $signedUrl = new SignedUrl($config); 236 | 237 | Time::setTestNow('2022-12-25 14:59:11', 'UTC'); 238 | 239 | $this->assertTrue($signedUrl->verify($request)); 240 | } 241 | 242 | public function testVerifyThrowExceptionForMissingSignature(): void 243 | { 244 | $this->expectException(SignedUrlException::class); 245 | $this->expectExceptionMessage('This URL have to be signed.'); 246 | 247 | $_SERVER['REQUEST_URI'] = '/path?query=string'; 248 | 249 | $request = $this->createRequest($this->config); 250 | 251 | $config = new SignedUrlConfig(); 252 | $signedUrl = new SignedUrl($config); 253 | $signedUrl->verify($request); 254 | } 255 | 256 | public function testVerifyThrowExceptionForInvalidAlgorithm(): void 257 | { 258 | $this->expectException(SignedUrlException::class); 259 | $this->expectExceptionMessage('Algorithm is invalid or not supported.'); 260 | 261 | $_SERVER['REQUEST_URI'] = '/path?query=string&algorithm=fake&signature=fake'; 262 | 263 | $request = $this->createRequest($this->config); 264 | 265 | $config = new SignedUrlConfig(); 266 | $signedUrl = new SignedUrl($config); 267 | $signedUrl->verify($request); 268 | } 269 | 270 | public function testVerifyThrowExceptionForUrlNotValid(): void 271 | { 272 | $this->expectException(SignedUrlException::class); 273 | $this->expectExceptionMessage('URL is not valid.'); 274 | 275 | $_SERVER['REQUEST_URI'] = '/path?query=string123&expires=1671980371&signature=GSU95yKkJm3DqU5t3ZyYxUpgmBI'; 276 | 277 | $request = $this->createRequest($this->config); 278 | 279 | $config = new SignedUrlConfig(); 280 | $signedUrl = new SignedUrl($config); 281 | $signedUrl->verify($request); 282 | } 283 | 284 | public function testVerifyThrowExceptionForExpiredUrl(): void 285 | { 286 | $this->expectException(SignedUrlException::class); 287 | $this->expectExceptionMessage('This URL has expired.'); 288 | 289 | $_SERVER['REQUEST_URI'] = '/path?query=string&expires=1671980371&signature=9GNwvgcsK7jJUPpXe3MK5xFbE0rb5ZBHIjKc1qqWSgU'; 290 | 291 | $request = $this->createRequest($this->config); 292 | 293 | Time::setTestNow('2022-12-25 15:59:11', 'UTC'); 294 | 295 | $config = new SignedUrlConfig(); 296 | $signedUrl = new SignedUrl($config); 297 | $signedUrl->verify($request); 298 | } 299 | 300 | public function testShouldRedirectTo(): void 301 | { 302 | $config = new SignedUrlConfig(); 303 | $signedUrl = new SignedUrl($config); 304 | $this->assertNull($signedUrl->shouldRedirectTo()); 305 | } 306 | 307 | public function testShouldRedirect(): void 308 | { 309 | $config = new SignedUrlConfig(); 310 | $signedUrl = new SignedUrl($config); 311 | $this->assertFalse($signedUrl->shouldRedirect()); 312 | } 313 | 314 | public function testShouldShow404(): void 315 | { 316 | $config = new SignedUrlConfig(); 317 | $signedUrl = new SignedUrl($config); 318 | $this->assertFalse($signedUrl->shouldShow404()); 319 | } 320 | } 321 | --------------------------------------------------------------------------------