diff --git a/CHANGELOG.md b/CHANGELOG.md index db5875a..81a4d60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,19 @@ # Craft Basic Authentication Changelog +## 1.1.0 - 2021-06-02 + +### Added +- Add an allowlist of IP addresses or subnets (CIDR). + +### Fixed +- Fixed an error when storing an empty credentials table. + ## 1.0.1 - 2020-02-04 + ### Fixed - A newly entered password was not hashed in some cases. ## 1.0.0 - 2020-02-02 + ### Added - Initial release diff --git a/README.md b/README.md index d653efe..c248d21 100644 --- a/README.md +++ b/README.md @@ -25,11 +25,13 @@ I you are using Apache to host your CraftCMS, you have to tell Apache to pass th CGIPassAuth on ``` -## Credentials +## Settings On the settings page in the control panel you can add credentials to use for authentication. -![Screenshot](resources/credentials.png) +![Screenshot](resources/settings.png) + +You can add a list of IP addresses and subnets (v4 and v6) that have access without any credentials. Use the [CIDR notation](https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing#CIDR_notation) for subnets. These settings supports the project config if enabled. @@ -91,13 +93,21 @@ Only activates the BasicAuth if a certain condition is met. {% basicauth require valid if craft.app.request.isMobileBrowser() %} ``` -### Globals +## Globals -This plugin provides als two global variables with the credentials of the user: +This plugin provides two global variables with the credentials of the user: ```twig

Hello {{ basicAuthUsername }}!

Your password is: {{ basicAuthPassword }}

``` +## Why should I use this plugin and not the webserver module? + +May you ask yourself why you should use this plugin and not the Basic Authentication provided by the webserver? Here are some aspects: + +1. You have full control of the Basic Authentication without your DevOps friends. +2. You can add the `{% basicauth %}` wherever you need it: In your central layout for all pages or only in one special template with some fancy conditions. +3. You can use conditions from Craft. For example: `{% basicauth require valid if not currentUser %}` + With ❤ by [codemonauts](https://codemonauts.com) diff --git a/composer.json b/composer.json index 92f1ba1..c3f35ee 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "codemonauts/craft-basicauth", "description": "Craft CMS plugin for HTTP Basic Authentication within templates.", - "version": "1.0.1", + "version": "1.1.0", "type": "craft-plugin", "keywords": [ "craft", @@ -23,6 +23,7 @@ }, "require": { "craftcms/cms": "^3.0.0", + "symfony/http-foundation": "^5.3.1", "ext-json": "*" }, "autoload": { diff --git a/resources/basicauth.png b/resources/basicauth.png index 4744421..6173ca6 100644 Binary files a/resources/basicauth.png and b/resources/basicauth.png differ diff --git a/resources/credentials.png b/resources/credentials.png deleted file mode 100644 index a696494..0000000 Binary files a/resources/credentials.png and /dev/null differ diff --git a/resources/settings.png b/resources/settings.png new file mode 100644 index 0000000..c0c64b2 Binary files /dev/null and b/resources/settings.png differ diff --git a/src/BasicAuth.php b/src/BasicAuth.php index 7d8332c..0bd7484 100644 --- a/src/BasicAuth.php +++ b/src/BasicAuth.php @@ -40,31 +40,33 @@ public function init() Event::on(Plugin::class, Plugin::EVENT_BEFORE_SAVE_SETTINGS, function(ModelEvent $event) { $settings = $this->getSettings(); - // Set new entered passwords - foreach ($settings->newPasswords as $key => $newPassword) { - if (trim($newPassword) != '') { - $settings->credentials[$key][1] = Craft::$app->security->hashPassword($newPassword); - } - } + if (is_array($settings->credentials) && !empty($settings->credentials)) { - // Set passwords for new rows - foreach ($settings->credentials as $key => $cred) { - if (preg_match('/^\$2.\$/i', $cred[1]) !== 1) { - $settings->credentials[$key][1] = Craft::$app->security->hashPassword($cred[1]); + // Set new entered passwords + foreach ($settings->newPasswords as $key => $newPassword) { + if (trim($newPassword) != '') { + $settings->credentials[$key][1] = Craft::$app->security->hashPassword($newPassword); + } } - } + // Set passwords for new rows + foreach ($settings->credentials as $key => $cred) { + if (preg_match('/^\$2.\$/i', $cred[1]) !== 1) { + $settings->credentials[$key][1] = Craft::$app->security->hashPassword($cred[1]); + } + } - $settings->newPasswords = []; + $settings->newPasswords = []; - $this->getSettings()->setAttributes($settings->toArray()); + $this->getSettings()->setAttributes($settings->toArray()); - // Check values - if ($this->getSettings()->credentials) { - foreach ($this->getSettings()->credentials as $cred) { - if ($cred[0] == '' || $cred[1] == '') { - $event->isValid = false; - return; + // Check values + if ($this->getSettings()->credentials) { + foreach ($this->getSettings()->credentials as $cred) { + if ($cred[0] == '' || $cred[1] == '') { + $event->isValid = false; + return; + } } } } @@ -112,7 +114,7 @@ protected function settingsHtml() return Craft::$app->getView()->renderTemplate('basicauth/settings', [ 'settings' => $this->getSettings(), 'creds' => $creds, - 'cols' => [ + 'credentialsCols' => [ [ 'heading' => 'Username*', 'type' => 'singleline', @@ -128,6 +130,13 @@ protected function settingsHtml() 'type' => 'singleline', ] ], + 'allowlistCols' => [ + [ + 'heading' => 'IP address or subnet*', + 'info' => 'IPv4 or IPv6 address or subnet in CIDR notation', + 'type' => 'singleline', + ], + ], ] ); } diff --git a/src/models/Settings.php b/src/models/Settings.php index 2293ed1..54ffc6a 100644 --- a/src/models/Settings.php +++ b/src/models/Settings.php @@ -3,6 +3,7 @@ namespace codemonauts\basicauth\models; use craft\base\Model; +use yii\validators\IpValidator; class Settings extends Model { @@ -11,6 +12,11 @@ class Settings extends Model */ public $credentials = []; + /** + * @var array The allowlist of IP addresses or ranges that overwrites credentials. + */ + public $allowlist = []; + /** * @var array The new passwords set by user */ @@ -23,6 +29,23 @@ public function rules() { return [ [['credentials', 'newPasswords'], 'safe'], + ['allowlist', 'validateIps'] + ]; } + + public function validateIps($attribute) + { + $values = $this->$attribute; + + if (is_array($values)) { + $ipValidator = new IpValidator(['subnet' => null]); + foreach ($values as $ip) { + $error = []; + if (!$ipValidator->validate($ip[0], $error)) { + $this->addError($attribute, $ip[0] . ': ' . $error); + } + } + } + } } diff --git a/src/services/AuthService.php b/src/services/AuthService.php index 02d3932..6a72f10 100644 --- a/src/services/AuthService.php +++ b/src/services/AuthService.php @@ -6,6 +6,7 @@ use Craft; use craft\base\Component; use craft\helpers\StringHelper; +use Symfony\Component\HttpFoundation\IpUtils; class AuthService extends Component { @@ -28,6 +29,16 @@ public function check($type, $entity = null, $siteHandle = null, $env = null, $r if ($matchedSite && $matchedEnv) { + // Check if IP address matches allowlist + $allowlist = BasicAuth::getInstance()->getSettings()->allowlist; + if (!empty($allowlist)) { + $allowlist = array_merge([], ...$allowlist); + $ip = Craft::$app->request->getRemoteIP(); + if (IpUtils::checkIp($ip, $allowlist)) { + return; + } + } + $isAuthenticated = false; $hasCredentials = (isset($_SERVER['PHP_AUTH_USER']) && isset($_SERVER['PHP_AUTH_PW'])); diff --git a/src/templates/settings.twig b/src/templates/settings.twig index 1f3932a..cf48546 100644 --- a/src/templates/settings.twig +++ b/src/templates/settings.twig @@ -5,6 +5,18 @@ label: "Credentials", id: 'credentials', name: 'credentials', - cols: cols, + cols: credentialsCols, rows: creds }) }} + +{{ forms.editableTableField({ + first: true, + label: "IP Allowlist", + instructions: 'IP addresses and subnets that have access without any credentials.', + id: 'allowlist', + name: 'allowlist', + cols: allowlistCols, + rows: settings.allowlist, + errors: settings.getErrors('allowlist'), + tip: "Your current IP accessing the CP is: " ~ craft.app.request.getRemoteIp() +}) }}