Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117631254
D1075.1774867940.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
81 KB
Referenced Files
None
Subscribers
None
D1075.1774867940.diff
View Options
diff --git a/bin/phpstan b/bin/phpstan
--- a/bin/phpstan
+++ b/bin/phpstan
@@ -4,7 +4,7 @@
pushd ${cwd}/../src/
-php -dmemory_limit=256M \
+php -dmemory_limit=320M \
vendor/bin/phpstan \
analyse
diff --git a/src/.env.example b/src/.env.example
--- a/src/.env.example
+++ b/src/.env.example
@@ -23,6 +23,11 @@
SESSION_DRIVER=file
SESSION_LIFETIME=120
+2FA_DSN=mysql://roundcube:Welcome2KolabSystems@127.0.0.1/roundcube
+2FA_TOTP_DIGITS=6
+2FA_TOTP_INTERVAL=30
+2FA_TOTP_DIGEST=sha1
+
IMAP_URI=ssl://127.0.0.1:993
IMAP_ADMIN_LOGIN=cyrus-admin
IMAP_ADMIN_PASSWORD=Welcome2KolabSystems
diff --git a/src/app/Auth/SecondFactor.php b/src/app/Auth/SecondFactor.php
new file mode 100644
--- /dev/null
+++ b/src/app/Auth/SecondFactor.php
@@ -0,0 +1,335 @@
+<?php
+
+namespace App\Auth;
+
+use App\Sku;
+use App\User;
+use Illuminate\Support\Facades\Auth;
+use Illuminate\Support\Facades\DB;
+use Kolab2FA\Storage\Base;
+
+/**
+ * A class to maintain 2-factor authentication
+ */
+class SecondFactor extends Base
+{
+ protected $user;
+ protected $cache = [];
+ protected $config = [
+ 'keymap' => [],
+ ];
+
+
+ /**
+ * Class constructor
+ *
+ * @param \App\User $user User object
+ */
+ public function __construct($user)
+ {
+ $this->user = $user;
+
+ parent::__construct();
+ }
+
+ /**
+ * Validate 2-factor authentication code
+ *
+ * @param \Illuminate\Http\Request $request The API request.
+ *
+ * @return \Illuminate\Http\JsonResponse|null
+ */
+ public function requestHandler($request)
+ {
+ // get list of configured authentication factors
+ $factors = $this->factors();
+
+ // do nothing if no factors configured
+ if (empty($factors)) {
+ return null;
+ }
+
+ if (empty($request->secondfactor) || !is_string($request->secondfactor)) {
+ $errors = ['secondfactor' => \trans('validation.2fareq')];
+ return response()->json(['status' => 'error', 'errors' => $errors], 422);
+ }
+
+ // try to verify each configured factor
+ foreach ($factors as $factor) {
+ // verify the submitted code
+ // if (strpos($factor, 'dummy:') === 0 && (\app('env') != 'production') {
+ // if ($request->secondfactor === 'dummy') {
+ // return null;
+ // }
+ // } else
+ if ($this->verify($factor, $request->secondfactor)) {
+ return null;
+ }
+ }
+
+ $errors = ['secondfactor' => \trans('validation.2fainvalid')];
+ return response()->json(['status' => 'error', 'errors' => $errors], 422);
+ }
+
+ /**
+ * Remove all configured 2FA methods for the current user
+ *
+ * @return bool True on success, False otherwise
+ */
+ public function removeFactors(): bool
+ {
+ $this->cache = [];
+
+ $prefs = [];
+ $prefs[$this->key2property('blob')] = null;
+ $prefs[$this->key2property('factors')] = null;
+
+ return $this->savePrefs($prefs);
+ }
+
+ /**
+ * Returns a list of 2nd factor methods configured for the user
+ */
+ public function factors(): array
+ {
+ // First check if the user has the 2FA SKU
+ $sku_2fa = Sku::where('title', '2fa')->first();
+
+ if ($sku_2fa) {
+ $has_2fa = $this->user->entitlements()->where('sku_id', $sku_2fa->id)->first();
+
+ if ($has_2fa) {
+ $factors = (array) $this->enumerate();
+ $factors = array_unique($factors);
+
+ return $factors;
+ }
+ }
+
+ return [];
+ }
+
+ /**
+ * Helper method to verify the given method/code tuple
+ *
+ * @param string $factor Factor identifier (<method>:<id>)
+ * @param string $code Authentication code
+ *
+ * @return bool True on successful validation
+ */
+ protected function verify($factor, $code): bool
+ {
+ if ($driver = $this->getDriver($factor)) {
+ return $driver->verify($code, time());
+ }
+
+ return false;
+ }
+
+ /**
+ * Load driver class for the given authentication factor
+ *
+ * @param string $factor Factor identifier (<method>:<id>)
+ *
+ * @return \Kolab2FA\Driver\Base
+ */
+ protected function getDriver(string $factor)
+ {
+ list($method) = explode(':', $factor, 2);
+
+ $config = \config('2fa.' . $method, []);
+
+ $driver = \Kolab2FA\Driver\Base::factory($factor, $config);
+
+ // configure driver
+ $driver->storage = $this;
+ $driver->username = $this->user->email;
+
+ return $driver;
+ }
+
+ /**
+ * Helper for seeding a Roundcube account with 2FA setup
+ * for testing.
+ *
+ * @param string $email Email address
+ */
+ public static function seed(string $email): void
+ {
+ $config = [
+ 'kolab_2fa_blob' => [
+ 'totp:8132a46b1f741f88de25f47e' => [
+ 'label' => 'Mobile app (TOTP)',
+ 'created' => 1584573552,
+ 'secret' => 'UAF477LDHZNWVLNA',
+ 'active' => true,
+ ],
+ // 'dummy:dummy' => [
+ // 'active' => true,
+ // ],
+ ],
+ 'kolab_2fa_factors' => [
+ 'totp:8132a46b1f741f88de25f47e',
+ // 'dummy:dummy',
+ ]
+ ];
+
+ self::dbh()->table('users')->updateOrInsert(
+ ['username' => $email, 'mail_host' => '127.0.0.1'],
+ ['preferences' => serialize($config)]
+ );
+ }
+
+ /**
+ * Helper for generating current TOTP code for a test user
+ *
+ * @param string $email Email address
+ *
+ * @return string Generated code
+ */
+ public static function code(string $email): string
+ {
+ $sf = new self(User::where('email', $email)->first());
+ $driver = $sf->getDriver('totp:8132a46b1f741f88de25f47e');
+
+ return (string) $driver->get_code();
+ }
+
+
+ //******************************************************
+ // Methods required by Kolab2FA Storage Base
+ //******************************************************
+
+ /**
+ * Initialize the storage driver with the given config options
+ */
+ public function init(array $config)
+ {
+ $this->config = array_merge($this->config, $config);
+ }
+
+ /**
+ * List methods activated for this user
+ */
+ public function enumerate()
+ {
+ if ($factors = $this->getFactors()) {
+ return array_keys(array_filter($factors, function ($prop) {
+ return !empty($prop['active']);
+ }));
+ }
+
+ return [];
+ }
+
+ /**
+ * Read data for the given key
+ */
+ public function read($key)
+ {
+ \Log::debug(__METHOD__ . ' ' . $key);
+
+ if (!isset($this->cache[$key])) {
+ $factors = $this->getFactors();
+ $this->cache[$key] = $factors[$key];
+ }
+
+ return $this->cache[$key];
+ }
+
+ /**
+ * Save data for the given key
+ */
+ public function write($key, $value)
+ {
+ \Log::debug(__METHOD__ . ' ' . @json_encode($value));
+
+ // TODO: Not implemented
+ return false;
+ }
+
+ /**
+ * Remove the data stored for the given key
+ */
+ public function remove($key)
+ {
+ return $this->write($key, null);
+ }
+
+ /**
+ *
+ */
+ protected function getFactors(): array
+ {
+ $prefs = $this->getPrefs();
+ $key = $this->key2property('blob');
+
+ return isset($prefs[$key]) ? (array) $prefs[$key] : [];
+ }
+
+ /**
+ *
+ */
+ protected function key2property($key)
+ {
+ // map key to configured property name
+ if (is_array($this->config['keymap']) && isset($this->config['keymap'][$key])) {
+ return $this->config['keymap'][$key];
+ }
+
+ // default
+ return 'kolab_2fa_' . $key;
+ }
+
+ /**
+ * Gets user preferences from Roundcube users table
+ */
+ protected function getPrefs()
+ {
+ $user = $this->dbh()->table('users')
+ ->select('preferences')
+ ->where('username', strtolower($this->user->email))
+ ->first();
+
+ return $user ? (array) unserialize($user->preferences) : null;
+ }
+
+ /**
+ * Saves user preferences in Roundcube users table.
+ * This will merge into old preferences
+ */
+ protected function savePrefs($prefs)
+ {
+ $old_prefs = $this->getPrefs();
+
+ if (!is_array($old_prefs)) {
+ return false;
+ }
+
+ $prefs = array_merge($old_prefs, $prefs);
+
+ $this->dbh()->table('users')
+ ->where('username', strtolower($this->user->email))
+ ->update(['preferences' => serialize($prefs)]);
+
+ return true;
+ }
+
+ /**
+ * Init connection to the Roundcube database
+ */
+ public static function dbh()
+ {
+ $dsn = \config('2fa.dsn');
+
+ if (empty($dsn)) {
+ \Log::warning("2-FACTOR database not configured");
+
+ return DB::connection(\config('database.default'));
+ }
+
+ \Config::set('database.connections.2fa', ['url' => $dsn]);
+
+ return DB::connection('2fa');
+ }
+}
diff --git a/src/app/Http/Controllers/API/UsersController.php b/src/app/Http/Controllers/API/UsersController.php
--- a/src/app/Http/Controllers/API/UsersController.php
+++ b/src/app/Http/Controllers/API/UsersController.php
@@ -131,6 +131,12 @@
$credentials = $request->only('email', 'password');
if ($token = $this->guard()->attempt($credentials)) {
+ $sf = new \App\Auth\SecondFactor($this->guard()->user());
+
+ if ($response = $sf->requestHandler($request)) {
+ return $response;
+ }
+
return $this->respondWithToken($token);
}
diff --git a/src/app/Observers/EntitlementObserver.php b/src/app/Observers/EntitlementObserver.php
--- a/src/app/Observers/EntitlementObserver.php
+++ b/src/app/Observers/EntitlementObserver.php
@@ -53,4 +53,21 @@
return false;
}
}
+
+ /**
+ * Handle the entitlement "deleted" event.
+ *
+ * @param \App\Entitlement $entitlement The entitlement.
+ *
+ * @return void
+ */
+ public function deleted(Entitlement $entitlement)
+ {
+ // Remove all configured 2FA methods from Roundcube database
+ if ($entitlement->sku->title == '2fa') {
+ // FIXME: Should that be an async job?
+ $sf = new \App\Auth\SecondFactor($entitlement->entitleable);
+ $sf->removeFactors();
+ }
+ }
}
diff --git a/src/composer.json b/src/composer.json
--- a/src/composer.json
+++ b/src/composer.json
@@ -26,6 +26,7 @@
"morrislaptop/laravel-queue-clear": "^1.2",
"silviolleite/laravelpwa": "^1.0",
"spatie/laravel-translatable": "^4.2",
+ "spomky-labs/otphp": "~4.0.0",
"swooletw/laravel-swoole": "^2.6",
"torann/currency": "^1.0",
"torann/geoip": "^1.0",
diff --git a/src/config/2fa.php b/src/config/2fa.php
new file mode 100644
--- /dev/null
+++ b/src/config/2fa.php
@@ -0,0 +1,14 @@
+<?php
+
+return [
+
+ 'totp' => [
+ 'digits' => (int) env('2FA_TOTP_DIGITS', 6),
+ 'interval' => (int) env('2FA_TOTP_INTERVAL', 30),
+ 'digest' => env('2FA_TOTP_DIGEST', 'sha1'),
+ 'issuer' => env('APP_NAME', 'Laravel'),
+ ],
+
+ 'dsn' => env('2FA_DSN'),
+
+];
diff --git a/src/config/database.php b/src/config/database.php
--- a/src/config/database.php
+++ b/src/config/database.php
@@ -90,7 +90,6 @@
'prefix' => '',
'prefix_indexes' => true,
],
-
],
/*
diff --git a/src/database/seeds/UserSeeder.php b/src/database/seeds/UserSeeder.php
--- a/src/database/seeds/UserSeeder.php
+++ b/src/database/seeds/UserSeeder.php
@@ -1,5 +1,6 @@
<?php
+use App\Auth\SecondFactor;
use App\Domain;
use App\Entitlement;
use App\User;
@@ -111,6 +112,11 @@
// Ned is a controller on Jack's wallet
$john->wallets()->first()->addController($ned);
+ // Ned is also our 2FA test user
+ $sku2fa = Sku::firstOrCreate(['title' => '2fa']);
+ $ned->assignSku($sku2fa);
+ SecondFactor::seed('ned@kolab.org');
+
factory(User::class, 10)->create();
}
}
diff --git a/src/include/Kolab2FA/Driver/Base.php b/src/include/Kolab2FA/Driver/Base.php
new file mode 100644
--- /dev/null
+++ b/src/include/Kolab2FA/Driver/Base.php
@@ -0,0 +1,354 @@
+<?php
+
+/**
+ * Kolab 2-Factor-Authentication Driver base class
+ *
+ * @author Thomas Bruederli <bruederli@kolabsys.com>
+ *
+ * Copyright (C) 2015, Kolab Systems AG <contact@kolabsys.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+namespace Kolab2FA\Driver;
+
+abstract class Base
+{
+ public $method;
+ public $id;
+ public $storage;
+ public $username;
+
+ protected $config = array();
+ protected $props = array();
+ protected $user_props = array();
+ protected $pending_changes = false;
+ protected $temporary = false;
+ protected $allowed_props = array('username');
+
+ public $user_settings = array(
+ 'active' => array(
+ 'type' => 'boolean',
+ 'editable' => false,
+ 'hidden' => false,
+ 'default' => false,
+ ),
+ 'label' => array(
+ 'type' => 'text',
+ 'editable' => true,
+ 'label' => 'label',
+ 'generator' => 'default_label',
+ ),
+ 'created' => array(
+ 'type' => 'datetime',
+ 'editable' => false,
+ 'hidden' => false,
+ 'label' => 'created',
+ 'generator' => 'time',
+ ),
+ );
+
+ /**
+ * Static factory method
+ */
+ public static function factory($id, $config)
+ {
+ list($method) = explode(':', $id);
+
+ $classmap = array(
+ 'totp' => '\\Kolab2FA\\Driver\\TOTP',
+ 'hotp' => '\\Kolab2FA\\Driver\\HOTP',
+ 'yubikey' => '\\Kolab2FA\\Driver\\Yubikey',
+ );
+
+ $cls = $classmap[strtolower($method)];
+ if ($cls && class_exists($cls)) {
+ return new $cls($config, $id);
+ }
+
+ throw new Exception("Unknown 2FA driver '$method'");
+ }
+
+ /**
+ * Default constructor
+ */
+ public function __construct($config = null, $id = null)
+ {
+ $this->init($config);
+
+ if (!empty($id) && $id != $this->method) {
+ $this->id = $id;
+ }
+ else { // generate random ID
+ $this->id = $this->method . ':' . bin2hex(openssl_random_pseudo_bytes(12));
+ $this->temporary = true;
+ }
+ }
+
+ /**
+ * Initialize the driver with the given config options
+ */
+ public function init($config)
+ {
+ if (is_array($config)) {
+ $this->config = array_merge($this->config, $config);
+ }
+
+ if (!empty($config['storage'])) {
+ $this->storage = \Kolab2FA\Storage\Base::factory($config['storage'], $config['storage_config']);
+ }
+ }
+
+ /**
+ * Verify the submitted authentication code
+ *
+ * @param string $code The 2nd authentication factor to verify
+ * @param int $timestamp Timestamp of authentication process (window start)
+ * @return boolean True if valid, false otherwise
+ */
+ abstract function verify($code, $timestamp = null);
+
+ /**
+ * Getter for user-visible properties
+ */
+ public function props($force = false)
+ {
+ $data = array();
+
+ foreach ($this->user_settings as $key => $p) {
+ if (!empty($p['private'])) {
+ continue;
+ }
+
+ $data[$key] = array(
+ 'type' => $p['type'],
+ 'editable' => $p['editable'],
+ 'hidden' => $p['hidden'],
+ 'label' => $p['label'],
+ 'value' => $this->get($key, $force),
+ );
+
+ // format value into text
+ switch ($p['type']) {
+ case 'boolean':
+ $data[$key]['value'] = (bool)$data[$key]['value'];
+ $data[$key]['text'] = $data[$key]['value'] ? 'yes' : 'no';
+ break;
+
+ case 'datetime':
+ if (is_numeric($data[$key]['value'])) {
+ $data[$key]['text'] = date('c', $data[$key]['value']);
+ break;
+ }
+
+ default:
+ $data[$key]['text'] = $data[$key]['value'];
+ }
+ }
+
+ return $data;
+ }
+
+ /**
+ * Implement this method if the driver can be prpvisioned via QR code
+ */
+ /* abstract function get_provisioning_uri(); */
+
+ /**
+ * Generate a random secret string
+ */
+ public function generate_secret($length = 16)
+ {
+ // Base32 characters
+ $chars = array(
+ 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', // 7
+ 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', // 15
+ 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', // 23
+ 'Y', 'Z', '2', '3', '4', '5', '6', '7', // 31
+ );
+
+ $secret = '';
+ for ($i = 0; $i < $length; $i++) {
+ $secret .= $chars[array_rand($chars)];
+ }
+ return $secret;
+ }
+
+ /**
+ * Generate the default label based on the method
+ */
+ public function default_label()
+ {
+ if (class_exists('\\rcmail', false)) {
+ return \rcmail::get_instance()->gettext($this->method, 'kolab_2fa');
+ }
+
+ return strtoupper($this->method);
+ }
+
+ /**
+ * Get current code (for testing)
+ */
+ public function get_code()
+ {
+ // to be overriden by a driver
+ return '';
+ }
+
+ /**
+ * Getter for read-only access to driver properties
+ */
+ public function get($key, $force = false)
+ {
+ // this is a per-user property: get from persistent storage
+ if (isset($this->user_settings[$key])) {
+ $value = $this->get_user_prop($key);
+
+ // generate property value
+ if (!isset($value) && $force && $this->user_settings[$key]['generator']) {
+ $func = $this->user_settings[$key]['generator'];
+ if (is_string($func) && !is_callable($func)) {
+ $func = array($this, $func);
+ }
+ if (is_callable($func)) {
+ $value = call_user_func($func);
+ }
+ if (isset($value)) {
+ $this->set_user_prop($key, $value);
+ }
+ }
+ }
+ else {
+ $value = $this->props[$key];
+ }
+
+ return $value;
+ }
+
+ /**
+ * Setter for restricted access to driver properties
+ */
+ public function set($key, $value, $persistent = true)
+ {
+ // store as per-user property
+ if (isset($this->user_settings[$key])) {
+ if ($persistent) {
+ return $this->set_user_prop($key, $value);
+ }
+ $this->user_props[$key] = $value;
+ }
+
+ $setter = 'set_' . $key;
+ if (method_exists($this, $setter)) {
+ call_user_func(array($this, $setter), $value);
+ }
+ else if (in_array($key, $this->allowed_props)) {
+ $this->props[$key] = $value;
+ }
+
+ return true;
+ }
+
+ /**
+ * Commit changes to storage
+ */
+ public function commit()
+ {
+ if (!empty($this->user_props) && $this->storage && $this->pending_changes) {
+ if ($this->storage->write($this->id, $this->user_props)) {
+ $this->pending_changes = false;
+ $this->temporary = false;
+ }
+ }
+
+ return !$this->pending_changes;
+ }
+
+ /**
+ * Dedicated setter for the username property
+ */
+ public function set_username($username)
+ {
+ $this->props['username'] = $username;
+
+ if ($this->storage) {
+ $this->storage->set_username($username);
+ }
+
+ return true;
+ }
+
+ /**
+ * Clear data stored for this driver
+ */
+ public function clear()
+ {
+ if ($this->storage) {
+ return $this->storage->remove($this->id);
+ }
+
+ return false;
+ }
+
+ /**
+ * Getter for per-user properties for this method
+ */
+ protected function get_user_prop($key)
+ {
+ if (!isset($this->user_props[$key]) && $this->storage && !$this->pending_changes && !$this->temporary) {
+ $this->user_props = (array)$this->storage->read($this->id);
+ }
+
+ return $this->user_props[$key];
+ }
+
+ /**
+ * Setter for per-user properties for this method
+ */
+ protected function set_user_prop($key, $value)
+ {
+ $this->pending_changes |= ($this->user_props[$key] !== $value);
+ $this->user_props[$key] = $value;
+ return true;
+ }
+
+ /**
+ * Magic getter for read-only access to driver properties
+ */
+ public function __get($key)
+ {
+ // this is a per-user property: get from persistent storage
+ if (isset($this->user_settings[$key])) {
+ return $this->get_user_prop($key);
+ }
+
+ return $this->props[$key];
+ }
+
+ /**
+ * Magic setter for restricted access to driver properties
+ */
+ public function __set($key, $value)
+ {
+ $this->set($key, $value, false);
+ }
+
+ /**
+ * Magic check if driver property is defined
+ */
+ public function __isset($key)
+ {
+ return isset($this->props[$key]);
+ }
+}
diff --git a/src/include/Kolab2FA/Driver/Exception.php b/src/include/Kolab2FA/Driver/Exception.php
new file mode 100644
--- /dev/null
+++ b/src/include/Kolab2FA/Driver/Exception.php
@@ -0,0 +1,8 @@
+<?php
+
+namespace Kolab2FA\Driver;
+
+class Exception extends \Exception
+{
+
+}
diff --git a/src/include/Kolab2FA/Driver/HOTP.php b/src/include/Kolab2FA/Driver/HOTP.php
new file mode 100644
--- /dev/null
+++ b/src/include/Kolab2FA/Driver/HOTP.php
@@ -0,0 +1,128 @@
+<?php
+
+/**
+ * Kolab 2-Factor-Authentication HOTP driver implementation
+ *
+ * @author Thomas Bruederli <bruederli@kolabsys.com>
+ *
+ * Copyright (C) 2015, Kolab Systems AG <contact@kolabsys.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+namespace Kolab2FA\Driver;
+
+class HOTP extends Base
+{
+ public $method = 'hotp';
+
+ protected $config = array(
+ 'digits' => 6,
+ 'window' => 4,
+ 'digest' => 'sha1',
+ );
+
+ protected $backend;
+
+ /**
+ *
+ */
+ public function init($config)
+ {
+ parent::init($config);
+
+ $this->user_settings += array(
+ 'secret' => array(
+ 'type' => 'text',
+ 'private' => true,
+ 'label' => 'secret',
+ 'generator' => 'generate_secret',
+ ),
+ 'counter' => array(
+ 'type' => 'integer',
+ 'editable' => false,
+ 'hidden' => true,
+ 'generator' => 'random_counter',
+ ),
+ );
+
+ // copy config options
+ $this->backend = new \Kolab2FA\OTP\HOTP();
+ $this->backend
+ ->setDigits($this->config['digits'])
+ ->setDigest($this->config['digest'])
+ ->setIssuer($this->config['issuer'])
+ ->setIssuerIncludedAsParameter(true);
+ }
+
+ /**
+ *
+ */
+ public function verify($code, $timestamp = null)
+ {
+ // get my secret from the user storage
+ $secret = $this->get('secret');
+ $counter = $this->get('counter');
+
+ if (!strlen($secret)) {
+ // LOG: "no secret set for user $this->username"
+ // rcube::console("VERIFY HOTP: no secret set for user $this->username");
+ return false;
+ }
+
+ try {
+ $this->backend->setLabel($this->username)->setSecret($secret)->setCounter(intval($this->get('counter')));
+ $pass = $this->backend->verify($code, $counter, $this->config['window']);
+
+ // store incremented counter value
+ $this->set('counter', $this->backend->getCounter());
+ $this->commit();
+ }
+ catch (\Exception $e) {
+ // LOG: exception
+ // rcube::console("VERIFY HOTP: $this->id, " . strval($e));
+ $pass = false;
+ }
+
+ // rcube::console('VERIFY HOTP', $this->username, $secret, $counter, $code, $pass);
+ return $pass;
+ }
+
+ /**
+ *
+ */
+ public function get_provisioning_uri()
+ {
+ if (!$this->secret) {
+ // generate new secret and store it
+ $this->set('secret', $this->get('secret', true));
+ $this->set('counter', $this->get('counter', true));
+ $this->set('created', $this->get('created', true));
+ $this->commit();
+ }
+
+ // TODO: deny call if already active?
+
+ $this->backend->setLabel($this->username)->setSecret($this->secret)->setCounter(intval($this->get('counter')));
+ return $this->backend->getProvisioningUri();
+ }
+
+ /**
+ * Generate a random counter value
+ */
+ public function random_counter()
+ {
+ return mt_rand(1, 999);
+ }
+}
diff --git a/src/include/Kolab2FA/Driver/TOTP.php b/src/include/Kolab2FA/Driver/TOTP.php
new file mode 100644
--- /dev/null
+++ b/src/include/Kolab2FA/Driver/TOTP.php
@@ -0,0 +1,137 @@
+<?php
+
+/**
+ * Kolab 2-Factor-Authentication TOTP driver implementation
+ *
+ * @author Thomas Bruederli <bruederli@kolabsys.com>
+ *
+ * Copyright (C) 2015, Kolab Systems AG <contact@kolabsys.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+namespace Kolab2FA\Driver;
+
+class TOTP extends Base
+{
+ public $method = 'totp';
+
+ protected $config = array(
+ 'digits' => 6,
+ 'interval' => 30,
+ 'digest' => 'sha1',
+ );
+
+ protected $backend;
+
+ /**
+ *
+ */
+ public function init($config)
+ {
+ parent::init($config);
+
+ $this->user_settings += array(
+ 'secret' => array(
+ 'type' => 'text',
+ 'private' => true,
+ 'label' => 'secret',
+ 'generator' => 'generate_secret',
+ ),
+ );
+
+ // copy config options
+ $this->backend = new \Kolab2FA\OTP\TOTP();
+ $this->backend
+ ->setDigits($this->config['digits'])
+ ->setInterval($this->config['interval'])
+ ->setDigest($this->config['digest'])
+ ->setIssuer($this->config['issuer'])
+ ->setIssuerIncludedAsParameter(true);
+ }
+
+ /**
+ *
+ */
+ public function verify($code, $timestamp = null)
+ {
+ // get my secret from the user storage
+ $secret = $this->get('secret');
+
+ if (!strlen($secret)) {
+ // LOG: "no secret set for user $this->username"
+ // rcube::console("VERIFY TOTP: no secret set for user $this->username");
+ return false;
+ }
+
+ $this->backend->setLabel($this->username)->setSecret($secret);
+
+ // PHP gets a string, but we're comparing integers.
+ $code = (int)$code;
+//$code = (string) $code;
+ // Pass a window to indicate the maximum timeslip between client (mobile
+ // device) and server.
+ $pass = $this->backend->verify($code, $timestamp, 150);
+
+ // try all codes from $timestamp till now
+ if (!$pass && $timestamp) {
+ $now = time();
+ while (!$pass && $timestamp < $now) {
+ $pass = $code === $this->backend->at($timestamp);
+ $timestamp += $this->config['interval'];
+ }
+ }
+
+ // rcube::console('VERIFY TOTP', $this->username, $secret, $code, $timestamp, $pass);
+ return $pass;
+ }
+
+ /**
+ * Get current code (for testing)
+ */
+ public function get_code()
+ {
+ // get my secret from the user storage
+ $secret = $this->get('secret');
+
+ if (!strlen($secret)) {
+ return;
+ }
+
+ $this->backend->setLabel($this->username)->setSecret($secret);
+
+ return $this->backend->at(time());
+ }
+
+ /**
+ *
+ */
+ public function get_provisioning_uri()
+ {
+ // rcube::console('PROV', $this->secret);
+ if (!$this->secret) {
+ // generate new secret and store it
+ $this->set('secret', $this->get('secret', true));
+ $this->set('created', $this->get('created', true));
+ // rcube::console('PROV2', $this->secret);
+ $this->commit();
+ }
+
+ // TODO: deny call if already active?
+
+ $this->backend->setLabel($this->username)->setSecret($this->secret);
+ return $this->backend->getProvisioningUri();
+ }
+
+}
diff --git a/src/include/Kolab2FA/Driver/Yubikey.php b/src/include/Kolab2FA/Driver/Yubikey.php
new file mode 100644
--- /dev/null
+++ b/src/include/Kolab2FA/Driver/Yubikey.php
@@ -0,0 +1,126 @@
+<?php
+
+/**
+ * Kolab 2-Factor-Authentication Yubikey driver implementation
+ *
+ * @author Thomas Bruederli <bruederli@kolabsys.com>
+ *
+ * Copyright (C) 2015, Kolab Systems AG <contact@kolabsys.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+namespace Kolab2FA\Driver;
+
+class Yubikey extends Base
+{
+ public $method = 'yubikey';
+
+ protected $backend;
+
+ /**
+ *
+ */
+ public function init($config)
+ {
+ parent::init($config);
+
+ $this->user_settings += array(
+ 'yubikeyid' => array(
+ 'type' => 'text',
+ 'editable' => true,
+ 'label' => 'secret',
+ ),
+ );
+
+ // initialize validator
+ $this->backend = new \Yubikey\Validate($this->config['apikey'], $this->config['clientid']);
+
+ // set configured validation hosts
+ if (!empty($this->config['hosts'])) {
+ $this->backend->setHosts((array)$this->config['hosts']);
+ }
+
+ if (isset($this->config['use_https'])) {
+ $this->backend->setUseSecure((bool)$this->config['use_https']);
+ }
+ }
+
+ /**
+ *
+ */
+ public function verify($code, $timestamp = null)
+ {
+ // get my secret from the user storage
+ $keyid = $this->get('yubikeyid');
+ $pass = false;
+
+ if (!strlen($keyid)) {
+ // LOG: "no key registered for user $this->username"
+ return false;
+ }
+
+ // check key prefix with associated Yubikey ID
+ if (strpos($code, $keyid) === 0) {
+ try {
+ $response = $this->backend->check($code);
+ $pass = $response->success() === true;
+ }
+ catch (\Exception $e) {
+ // TODO: log exception
+ }
+ }
+
+ // rcube::console('VERIFY Yubikey', $this->username, $keyid, $code, $pass);
+ return $pass;
+ }
+
+ /**
+ * @override
+ */
+ public function set($key, $value)
+ {
+ if ($key == 'yubikeyid' && strlen($value) > 12) {
+ // verify the submitted code
+ try {
+ $response = $this->backend->check($value);
+ if ($response->success() !== true) {
+ // TODO: report error
+ return false;
+ }
+ }
+ catch (\Exception $e) {
+ return false;
+ }
+
+ // truncate the submitted yubikey code to 12 characters
+ $value = substr($value, 0, 12);
+ }
+
+ return parent::set($key, $value);
+ }
+
+ /**
+ * @override
+ */
+ protected function set_user_prop($key, $value)
+ {
+ // set created timestamp
+ if ($key !== 'created' && !isset($this->created)) {
+ parent::set_user_prop('created', $this->get('created', true));
+ }
+
+ return parent::set_user_prop($key, $value);
+ }
+}
diff --git a/src/include/Kolab2FA/Log/Logger.php b/src/include/Kolab2FA/Log/Logger.php
new file mode 100644
--- /dev/null
+++ b/src/include/Kolab2FA/Log/Logger.php
@@ -0,0 +1,42 @@
+<?php
+
+/**
+ * Logging interface for the Kolab 2-Factor-Authentication components
+ *
+ * @author Thomas Bruederli <bruederli@kolabsys.com>
+ *
+ * Copyright (C) 2015, Kolab Systems AG <contact@kolabsys.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+namespace Kolab2FA\Log;
+
+interface Logger
+{
+ /**
+ * Setter for the log name
+ */
+ public function set_name($name);
+
+ /**
+ * Setter for the minimum log level
+ */
+ public function set_level($level);
+
+ /**
+ * Do log the given message at the given level
+ */
+ public function log($level, $message);
+}
diff --git a/src/include/Kolab2FA/Log/RcubeLogger.php b/src/include/Kolab2FA/Log/RcubeLogger.php
new file mode 100644
--- /dev/null
+++ b/src/include/Kolab2FA/Log/RcubeLogger.php
@@ -0,0 +1,80 @@
+<?php
+
+/**
+ * Kolab 2-Factor-Authentication Logging class to log messages
+ * through the Roundcube logging facilities.
+ *
+ * @author Thomas Bruederli <bruederli@kolabsys.com>
+ *
+ * Copyright (C) 2015, Kolab Systems AG <contact@kolabsys.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+namespace Kolab2FA\Log;
+
+use \rcube;
+
+class RcubeLogger implements Logger
+{
+ protected $name = null;
+ protected $level = LOG_DEBUG;
+
+ public function __construct($name = null)
+ {
+ if ($name !== null) {
+ $this->set_name($name);
+ }
+ }
+
+ public function set_name($name)
+ {
+ $this->name = $name;
+ }
+
+ public function set_level($name)
+ {
+ $this->level = $level;
+ }
+
+ public function log($level, $message)
+ {
+ if (!is_string($message)) {
+ $message = var_export($message, true);
+ }
+
+ switch ($level) {
+ case LOG_DEBUG:
+ case LOG_INFO:
+ case LOG_NOTICE:
+ if ($level >= $this->level) {
+ rcube::write_log($this->name ?: 'console', $message);
+ }
+ break;
+
+ case LOG_EMERGE:
+ case LOG_ALERT:
+ case LOG_CRIT:
+ case LOG_ERR:
+ case LOG_WARNING:
+ rcube::raise_error(array(
+ 'code' => 600,
+ 'type' => 'php',
+ 'message' => $message,
+ ), true, false);
+ break;
+ }
+ }
+}
+
diff --git a/src/include/Kolab2FA/Log/Syslog.php b/src/include/Kolab2FA/Log/Syslog.php
new file mode 100644
--- /dev/null
+++ b/src/include/Kolab2FA/Log/Syslog.php
@@ -0,0 +1,54 @@
+<?php
+
+/**
+ * Kolab 2-Factor-Authentication Logging class to log messages
+ * through the Roundcube logging facilities.
+ *
+ * @author Thomas Bruederli <bruederli@kolabsys.com>
+ *
+ * Copyright (C) 2015, Kolab Systems AG <contact@kolabsys.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+namespace Kolab2FA\Log;
+
+use \rcube;
+
+class Syslog implements Logger
+{
+ protected $name = 'Kolab2FA';
+ protected $level = LOG_INFO;
+
+ public function set_name($name)
+ {
+ $this->name = $name;
+ }
+
+ public function set_level($name)
+ {
+ $this->level = $level;
+ }
+
+ public function log($level, $message)
+ {
+ if ($level >= $this->level) {
+ if (!is_string($message)) {
+ $message = var_export($message, true);
+ }
+
+ syslog($level, '[' . $this->name . '] ' . $message);
+ }
+ }
+}
diff --git a/src/include/Kolab2FA/OTP/HOTP.php b/src/include/Kolab2FA/OTP/HOTP.php
new file mode 100644
--- /dev/null
+++ b/src/include/Kolab2FA/OTP/HOTP.php
@@ -0,0 +1,58 @@
+<?php
+
+/**
+ * Kolab HOTP implementation based on Spomky-Labs/otphp
+ *
+ * This basically follows the exmaple implementation from
+ * https://github.com/Spomky-Labs/otphp/tree/master/examples
+ *
+ * @author Thomas Bruederli <bruederli@kolabsys.com>
+ *
+ * Copyright (C) 2015, Kolab Systems AG <contact@kolabsys.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+
+namespace Kolab2FA\OTP;
+
+use OTPHP\HOTP as Base;
+
+class HOTP extends Base
+{
+ use OTP;
+ protected $counter = 0;
+
+ public function setCounter($counter)
+ {
+ if (!is_integer($counter) || $counter < 0) {
+ throw new \Exception('Counter must be at least 0.');
+ }
+ $this->counter = $counter;
+
+ return $this;
+ }
+
+ public function getCounter()
+ {
+ return $this->counter;
+ }
+
+ public function updateCounter($counter)
+ {
+ $this->counter = $counter;
+
+ return $this;
+ }
+}
diff --git a/src/include/Kolab2FA/OTP/OTP.php b/src/include/Kolab2FA/OTP/OTP.php
new file mode 100644
--- /dev/null
+++ b/src/include/Kolab2FA/OTP/OTP.php
@@ -0,0 +1,133 @@
+<?php
+
+/**
+ * Kolab OTP trait based on Spomky-Labs/otphp
+ *
+ * This basically follows the exmaple implementation from
+ * https://github.com/Spomky-Labs/otphp/tree/master/examples
+ *
+ * @author Thomas Bruederli <bruederli@kolabsys.com>
+ *
+ * Copyright (C) 2015, Kolab Systems AG <contact@kolabsys.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+namespace Kolab2FA\OTP;
+
+trait OTP
+{
+ protected $secret = null;
+ protected $issuer = null;
+ protected $issuer_included_as_parameter = false;
+ protected $label = null;
+ protected $digest = 'sha1';
+ protected $digits = 6;
+
+ public function setSecret($secret)
+ {
+ $this->secret = $secret;
+
+ return $this;
+ }
+
+ public function getSecret()
+ {
+ return $this->secret;
+ }
+
+ public function setLabel($label)
+ {
+ if ($this->hasSemicolon($label)) {
+ throw new \Exception('Label must not contain a semi-colon.');
+ }
+ $this->label = $label;
+
+ return $this;
+ }
+
+ public function getLabel()
+ {
+ return $this->label;
+ }
+
+ public function setIssuer($issuer)
+ {
+ if ($this->hasSemicolon($issuer)) {
+ throw new \Exception('Issuer must not contain a semi-colon.');
+ }
+ $this->issuer = $issuer;
+
+ return $this;
+ }
+
+ public function getIssuer()
+ {
+ return $this->issuer;
+ }
+
+ public function isIssuerIncludedAsParameter()
+ {
+ return $this->issuer_included_as_parameter;
+ }
+
+ public function setIssuerIncludedAsParameter($issuer_included_as_parameter)
+ {
+ $this->issuer_included_as_parameter = $issuer_included_as_parameter;
+
+ return $this;
+ }
+
+ public function setDigits($digits)
+ {
+ if (!is_numeric($digits) || $digits < 1) {
+ throw new \Exception('Digits must be at least 1.');
+ }
+ $this->digits = $digits;
+
+ return $this;
+ }
+
+ public function getDigits()
+ {
+ return $this->digits;
+ }
+
+ public function setDigest($digest)
+ {
+ if (!in_array($digest, array('md5', 'sha1', 'sha256', 'sha512'))) {
+ throw new \Exception("'$digest' digest is not supported.");
+ }
+ $this->digest = $digest;
+
+ return $this;
+ }
+
+ public function getDigest()
+ {
+ return $this->digest;
+ }
+
+ private function hasSemicolon($value)
+ {
+ $semicolons = array(':', '%3A', '%3a');
+ foreach ($semicolons as $semicolon) {
+ if (false !== strpos($value, $semicolon)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/src/include/Kolab2FA/OTP/TOTP.php b/src/include/Kolab2FA/OTP/TOTP.php
new file mode 100644
--- /dev/null
+++ b/src/include/Kolab2FA/OTP/TOTP.php
@@ -0,0 +1,50 @@
+<?php
+
+/**
+ * Kolab TOTP implementation based on Spomky-Labs/otphp
+ *
+ * This basically follows the exmaple implementation from
+ * https://github.com/Spomky-Labs/otphp/tree/master/examples
+ *
+ * @author Thomas Bruederli <bruederli@kolabsys.com>
+ *
+ * Copyright (C) 2015, Kolab Systems AG <contact@kolabsys.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+namespace Kolab2FA\OTP;
+
+use OTPHP\TOTP as Base;
+
+class TOTP extends Base
+{
+ use OTP;
+ protected $interval = 30;
+
+ public function setInterval($interval)
+ {
+ if (!is_integer($interval) || $interval < 1) {
+ throw new \Exception('Interval must be at least 1.');
+ }
+ $this->interval = $interval;
+
+ return $this;
+ }
+
+ public function getInterval()
+ {
+ return $this->interval;
+ }
+}
diff --git a/src/include/Kolab2FA/Storage/Base.php b/src/include/Kolab2FA/Storage/Base.php
new file mode 100644
--- /dev/null
+++ b/src/include/Kolab2FA/Storage/Base.php
@@ -0,0 +1,127 @@
+<?php
+
+/**
+ * Abstract storage backend class for the Kolab 2-Factor-Authentication plugin
+ *
+ * @author Thomas Bruederli <bruederli@kolabsys.com>
+ *
+ * Copyright (C) 2015, Kolab Systems AG <contact@kolabsys.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+namespace Kolab2FA\Storage;
+
+use \Kolab2FA\Log;
+
+
+abstract class Base
+{
+ public $username = null;
+ protected $config = array();
+ protected $logger;
+
+ /**
+ *
+ */
+ public static function factory($backend, $config)
+ {
+ $classmap = array(
+ 'ldap' => '\\Kolab2FA\\Storage\\LDAP',
+ 'roundcube' => '\\Kolab2FA\\Storage\\RcubeUser',
+ 'rcubeuser' => '\\Kolab2FA\\Storage\\RcubeUser',
+ );
+
+ $cls = $classmap[strtolower($backend)];
+ if ($cls && class_exists($cls)) {
+ return new $cls($config);
+ }
+
+ throw new Exception("Unknown storage backend '$backend'");
+ }
+
+ /**
+ * Default constructor
+ */
+ public function __construct($config = null)
+ {
+ if (is_array($config)) {
+ $this->init($config);
+ }
+ }
+
+ /**
+ * Initialize the driver with the given config options
+ */
+ public function init(array $config)
+ {
+ $this->config = array_merge($this->config, $config);
+
+ // use syslog logger by default
+ $this->set_logger(new Log\Syslog());
+ }
+
+ /**
+ *
+ */
+ public function set_logger(Log\Logger $logger)
+ {
+ $this->logger = $logger;
+
+ if (!empty($this->config['debug'])) {
+ $this->logger->set_level(LOG_DEBUG);
+ }
+ else if (isset($this->config['loglevel'])) {
+ $this->logger->set_level($this->config['loglevel']);
+ }
+ }
+
+ /**
+ * Set username to store data for
+ */
+ public function set_username($username)
+ {
+ $this->username = $username;
+ }
+
+ /**
+ * Send messager to the logging system
+ */
+ protected function log($level, $message)
+ {
+ if ($this->logger) {
+ $this->logger->log($level, $message);
+ }
+ }
+
+ /**
+ * List keys holding settings for 2-factor-authentication
+ */
+ abstract public function enumerate();
+
+ /**
+ * Read data for the given key
+ */
+ abstract public function read($key);
+
+ /**
+ * Save data for the given key
+ */
+ abstract public function write($key, $value);
+
+ /**
+ * Remove the data stored for the given key
+ */
+ abstract public function remove($key);
+}
diff --git a/src/include/Kolab2FA/Storage/Exception.php b/src/include/Kolab2FA/Storage/Exception.php
new file mode 100644
--- /dev/null
+++ b/src/include/Kolab2FA/Storage/Exception.php
@@ -0,0 +1,8 @@
+<?php
+
+namespace Kolab2FA\Storage;
+
+class Exception extends \Exception
+{
+
+}
diff --git a/src/include/Kolab2FA/Storage/LDAP.php b/src/include/Kolab2FA/Storage/LDAP.php
new file mode 100644
--- /dev/null
+++ b/src/include/Kolab2FA/Storage/LDAP.php
@@ -0,0 +1,350 @@
+<?php
+
+/**
+ * Storage backend to store 2-Factor-Authentication settings in LDAP
+ *
+ * @author Thomas Bruederli <bruederli@kolabsys.com>
+ *
+ * Copyright (C) 2015, Kolab Systems AG <contact@kolabsys.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+namespace Kolab2FA\Storage;
+
+use \Net_LDAP3;
+use \Kolab2FA\Log\Logger;
+
+class LDAP extends Base
+{
+ public $userdn;
+ public $ready;
+
+ private $cache = array();
+ private $ldapcache = array();
+ private $conn;
+ private $error;
+
+ public function init(array $config)
+ {
+ parent::init($config);
+
+ $this->conn = new Net_LDAP3($config);
+ $this->conn->config_set('log_hook', array($this, 'log'));
+
+ $this->conn->connect();
+
+ $bind_pass = $this->config['bind_pass'];
+ $bind_user = $this->config['bind_user'];
+ $bind_dn = $this->config['bind_dn'];
+
+ $this->ready = $this->conn->bind($bind_dn, $bind_pass);
+
+ if (!$this->ready) {
+ throw new Exception("LDAP storage not ready: " . $this->error);
+ }
+ }
+
+ /**
+ * List/set methods activated for this user
+ */
+ public function enumerate($active = true)
+ {
+ $filter = $this->parse_vars($this->config['filter'], '*');
+ $base_dn = $this->parse_vars($this->config['base_dn'], '*');
+ $scope = $this->config['scope'] ?: 'sub';
+ $ids = array();
+
+ if ($this->ready && ($result = $this->conn->search($base_dn, $filter, $scope, array($this->config['fieldmap']['id'], $this->config['fieldmap']['active'])))) {
+ foreach ($result as $dn => $entry) {
+ $rec = $this->field_mapping($dn, Net_LDAP3::normalize_entry($entry, true));
+ if (!empty($rec['id']) && ($active === null || $active == $rec['active'])) {
+ $ids[] = $rec['id'];
+ }
+ }
+ }
+
+ // TODO: cache this in memory
+
+ return $ids;
+ }
+
+ /**
+ * Read data for the given key
+ */
+ public function read($key)
+ {
+ if (!isset($this->cache[$key])) {
+ $this->cache[$key] = $this->get_ldap_record($this->username, $key);
+ }
+
+ return $this->cache[$key];
+ }
+
+ /**
+ * Save data for the given key
+ */
+ public function write($key, $value)
+ {
+ $success = false;
+ $ldap_attrs = array();
+
+ if (is_array($value)) {
+ // add some default values
+ $value += (array)$this->config['defaults'] + array('active' => false, 'username' => $this->username, 'userdn' => $this->userdn);
+
+ foreach ($value as $k => $val) {
+ if ($attr = $this->config['fieldmap'][$k]) {
+ $ldap_attrs[$attr] = $this->value_mapping($k, $val, false);
+ }
+ }
+ }
+ else {
+ // invalid data structure
+ return false;
+ }
+
+ // update existing record
+ if ($rec = $this->get_ldap_record($this->username, $key)) {
+ $old_attrs = $rec['_raw'];
+ $new_attrs = array_merge($old_attrs, $ldap_attrs);
+
+ $result = $this->conn->modify_entry($rec['_dn'], $old_attrs, $new_attrs);
+ $success = !empty($result);
+ }
+ // insert new record
+ else if ($this->ready) {
+ $entry_dn = $this->get_entry_dn($this->username, $key);
+
+ // add object class attribute
+ $me = $this;
+ $ldap_attrs['objectclass'] = array_map(function($cls) use ($me, $key) {
+ return $me->parse_vars($cls, $key);
+ }, (array)$this->config['objectclass']);
+
+ $success = $this->conn->add_entry($entry_dn, $ldap_attrs);
+ }
+
+ if ($success) {
+ $this->cache[$key] = $value;
+ $this->ldapcache = array();
+
+ // cleanup: remove disabled/inactive/temporary entries
+ if ($value['active']) {
+ foreach ($this->enumerate(false) as $id) {
+ if ($id != $key) {
+ $this->remove($id);
+ }
+ }
+
+ // set user roles according to active factors
+ $this->set_user_roles();
+ }
+ }
+
+ return $success;
+ }
+
+ /**
+ * Remove the data stored for the given key
+ */
+ public function remove($key)
+ {
+ if ($this->ready) {
+ $entry_dn = $this->get_entry_dn($this->username, $key);
+ $success = $this->conn->delete_entry($entry_dn);
+
+ // set user roles according to active factors
+ if ($success) {
+ $this->set_user_roles();
+ }
+
+ return $success;
+ }
+
+ return false;
+ }
+
+ /**
+ * Set username to store data for
+ */
+ public function set_username($username)
+ {
+ parent::set_username($username);
+
+ // reset cached values
+ $this->cache = array();
+ $this->ldapcache = array();
+ }
+
+ /**
+ *
+ */
+ protected function set_user_roles()
+ {
+ if (!$this->ready || !$this->userdn || empty($this->config['user_roles'])) {
+ return false;
+ }
+
+ $auth_roles = array();
+ foreach ($this->enumerate(true) as $id) {
+ foreach ($this->config['user_roles'] as $prefix => $role) {
+ if (strpos($id, $prefix) === 0) {
+ $auth_roles[] = $role;
+ }
+ }
+ }
+
+ $role_attr = $this->config['fieldmap']['roles'] ?: 'nsroledn';
+ if ($user_attrs = $this->conn->get_entry($this->userdn, array($role_attr))) {
+ $internals = array_values($this->config['user_roles']);
+ $new_attrs = $old_attrs = Net_LDAP3::normalize_entry($user_attrs);
+ $new_attrs[$role_attr] = array_merge(
+ array_unique($auth_roles),
+ array_filter((array)$old_attrs[$role_attr], function($f) use ($internals) { return !in_array($f, $internals); })
+ );
+
+ $result = $this->conn->modify_entry($this->userdn, $old_attrs, $new_attrs);
+ return !empty($result);
+ }
+
+ return false;
+ }
+
+ /**
+ * Fetches user data from LDAP addressbook
+ */
+ protected function get_ldap_record($user, $key)
+ {
+ $entry_dn = $this->get_entry_dn($user, $key);
+
+ if (!isset($this->ldapcache[$entry_dn])) {
+ $this->ldapcache[$entry_dn] = array();
+
+ if ($this->ready && ($entry = $this->conn->get_entry($entry_dn, array_values($this->config['fieldmap'])))) {
+ $this->ldapcache[$entry_dn] = $this->field_mapping($entry_dn, Net_LDAP3::normalize_entry($entry, true));
+ }
+ }
+
+ return $this->ldapcache[$entry_dn];
+ }
+
+ /**
+ * Compose a full DN for the given record identifier
+ */
+ protected function get_entry_dn($user, $key)
+ {
+ $base_dn = $this->parse_vars($this->config['base_dn'], $key);
+ return sprintf('%s=%s,%s', $this->config['rdn'], Net_LDAP3::quote_string($key, true), $base_dn);
+ }
+
+ /**
+ * Maps LDAP attributes to defined fields
+ */
+ protected function field_mapping($dn, $entry)
+ {
+ $entry['_dn'] = $dn;
+ $entry['_raw'] = $entry;
+
+ // fields mapping
+ foreach ($this->config['fieldmap'] as $field => $attr) {
+ $attr_lc = strtolower($attr);
+ if (isset($entry[$attr_lc])) {
+ $entry[$field] = $this->value_mapping($field, $entry[$attr_lc], true);
+ }
+ else if (isset($entry[$attr])) {
+ $entry[$field] = $this->value_mapping($field, $entry[$attr], true);
+ }
+ }
+
+ return $entry;
+ }
+
+ /**
+ *
+ */
+ protected function value_mapping($attr, $value, $reverse = false)
+ {
+ if ($map = $this->config['valuemap'][$attr]) {
+ if ($reverse) {
+ $map = array_flip($map);
+ }
+
+ if (is_array($value)) {
+ $value = array_filter(array_map(function($val) use ($map) {
+ return $map[$val];
+ }, $value));
+ }
+ else {
+ $value = $map[$value];
+ }
+ }
+
+ // convert (date) type
+ switch ($this->config['attrtypes'][$attr]) {
+ case 'datetime':
+ $ts = is_numeric($value) ? $value : strtotime($value);
+ if ($ts) {
+ $value = gmdate($reverse ? 'U' : 'YmdHi\Z', $ts);
+ }
+ break;
+
+ case 'integer':
+ $value = intval($value);
+ break;
+ }
+
+ return $value;
+ }
+
+ /**
+ * Prepares filter query for LDAP search
+ */
+ protected function parse_vars($str, $key)
+ {
+ $user = $this->username;
+
+ if (strpos($user, '@') > 0) {
+ list($u, $d) = explode('@', $user);
+ }
+ else if ($this->userdn) {
+ $u = $this->userdn;
+ $d = trim(str_replace(',dc=', '.', substr($u, strpos($u, ',dc='))), '.');
+ }
+
+ if ($this->userdn) {
+ $user = $this->userdn;
+ }
+
+ // build hierarchal domain string
+ $dc = $this->conn->domain_root_dn($d);
+
+ $class = $this->config['classmap'] ? $this->config['classmap']['*'] : '*';
+
+ // map key to objectclass
+ if (is_array($this->config['classmap'])) {
+ foreach ($this->config['classmap'] as $k => $c) {
+ if (strpos($key, $k) === 0) {
+ $class = $c;
+ break;
+ }
+ }
+ }
+
+ $replaces = array('%dc' => $dc, '%d' => $d, '%fu' => $user, '%u' => $u, '%c' => $class);
+
+ return strtr($str, $replaces);
+ }
+
+}
diff --git a/src/include/Kolab2FA/Storage/RcubeUser.php b/src/include/Kolab2FA/Storage/RcubeUser.php
new file mode 100644
--- /dev/null
+++ b/src/include/Kolab2FA/Storage/RcubeUser.php
@@ -0,0 +1,195 @@
+<?php
+
+/**
+ * Storage backend to use the Roundcube user prefs to store 2-Factor-Authentication settings
+ *
+ * @author Thomas Bruederli <bruederli@kolabsys.com>
+ *
+ * Copyright (C) 2015, Kolab Systems AG <contact@kolabsys.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+namespace Kolab2FA\Storage;
+
+use \rcmail;
+use \rcube_user;
+
+class RcubeUser extends Base
+{
+ // sefault config
+ protected $config = array(
+ 'keymap' => array(),
+ );
+
+ private $cache = array();
+ private $user;
+
+ public function init(array $config)
+ {
+ parent::init($config);
+
+ $rcmail = rcmail::get_instance();
+ $this->config['hostname'] = $rcmail->user->ID ? $rcmail->user->data['mail_host'] : $_SESSION['hostname'];
+ }
+
+ /**
+ * List/set methods activated for this user
+ */
+ public function enumerate()
+ {
+ if ($factors = $this->get_factors()) {
+ return array_keys(array_filter($factors, function($prop) {
+ return !empty($prop['active']);
+ }));
+ }
+
+ return array();
+ }
+
+ /**
+ * Read data for the given key
+ */
+ public function read($key)
+ {
+ if (!isset($this->cache[$key])) {
+ $factors = $this->get_factors();
+ $this->log(LOG_DEBUG, 'RcubeUser::read() ' . $key);
+ $this->cache[$key] = $factors[$key];
+ }
+
+ return $this->cache[$key];
+ }
+
+ /**
+ * Save data for the given key
+ */
+ public function write($key, $value)
+ {
+ $this->log(LOG_DEBUG, 'RcubeUser::write() ' . @json_encode($value));
+
+ if ($user = $this->get_user($this->username)) {
+ $this->cache[$key] = $value;
+
+ $factors = $this->get_factors();
+ $factors[$key] = $value;
+
+ $pkey = $this->key2property('blob');
+ $save_data = array($pkey => $factors);
+ $update_index = false;
+
+ // remove entry
+ if ($value === null) {
+ unset($factors[$key]);
+ $update_index = true;
+ }
+ // remove non-active entries
+ else if (!empty($value['active'])) {
+ $factors = array_filter($factors, function($prop) {
+ return !empty($prop['active']);
+ });
+ $update_index = true;
+ }
+
+ // update the index of active factors
+ if ($update_index) {
+ $save_data[$this->key2property('factors')] = array_keys(
+ array_filter($factors, function($prop) {
+ return !empty($prop['active']);
+ })
+ );
+ }
+
+ $success = $user->save_prefs($save_data, true);
+
+ if (!$success) {
+ $this->log(LOG_WARNING, sprintf('Failed to save prefs for user %s', $this->username));
+ }
+
+ return $success;
+ }
+
+ return false;
+ }
+
+ /**
+ * Remove the data stored for the given key
+ */
+ public function remove($key)
+ {
+ return $this->write($key, null);
+ }
+
+ /**
+ * Set username to store data for
+ */
+ public function set_username($username)
+ {
+ parent::set_username($username);
+
+ // reset cached values
+ $this->cache = array();
+ $this->user = null;
+ }
+
+ /**
+ * Helper method to get a rcube_user instance for storing prefs
+ */
+ private function get_user($username)
+ {
+ // use global instance if we have a valid Roundcube session
+ $rcmail = rcmail::get_instance();
+ if ($rcmail->user->ID && $rcmail->user->get_username() == $username) {
+ return $rcmail->user;
+ }
+
+ if (!$this->user) {
+ $this->user = rcube_user::query($username, $this->config['hostname']);
+ }
+
+ if (!$this->user) {
+ $this->log(LOG_WARNING, sprintf('No user record found for %s @ %s', $username, $this->config['hostname']));
+ }
+
+ return $this->user;
+ }
+
+ /**
+ *
+ */
+ private function get_factors()
+ {
+ if ($user = $this->get_user($this->username)) {
+ $prefs = $user->get_prefs();
+ return (array)$prefs[$this->key2property('blob')];
+ }
+
+ return null;
+ }
+
+ /**
+ *
+ */
+ private function key2property($key)
+ {
+ // map key to configured property name
+ if (is_array($this->config['keymap']) && isset($this->config['keymap'][$key])) {
+ return $this->config['keymap'][$key];
+ }
+
+ // default
+ return 'kolab_2fa_' . $key;
+ }
+
+}
diff --git a/src/resources/js/app.js b/src/resources/js/app.js
--- a/src/resources/js/app.js
+++ b/src/resources/js/app.js
@@ -56,9 +56,10 @@
return response
},
error => {
- var error_msg
+ let error_msg
+ let status = error.response ? error.response.status : 200
- if (error.response && error.response.status == 422) {
+ if (error.response && status == 422) {
error_msg = "Form validation error"
$.each(error.response.data.errors || {}, (idx, msg) => {
diff --git a/src/resources/js/fontawesome.js b/src/resources/js/fontawesome.js
--- a/src/resources/js/fontawesome.js
+++ b/src/resources/js/fontawesome.js
@@ -11,6 +11,9 @@
faCheck,
faGlobe,
faInfoCircle,
+ faLock,
+ faKey,
+ faSignInAlt,
faSyncAlt,
faTrashAlt,
faUser,
@@ -22,10 +25,13 @@
// Register only these icons we need
library.add(
faCheckSquare,
- faSquare,
faCheck,
faGlobe,
faInfoCircle,
+ faLock,
+ faKey,
+ faSignInAlt,
+ faSquare,
faSyncAlt,
faTrashAlt,
faUser,
diff --git a/src/resources/lang/en/auth.php b/src/resources/lang/en/auth.php
--- a/src/resources/lang/en/auth.php
+++ b/src/resources/lang/en/auth.php
@@ -16,4 +16,5 @@
'failed' => 'Invalid username or password.',
'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',
'logoutsuccess' => 'Successfully logged out.',
+
];
diff --git a/src/resources/lang/en/validation.php b/src/resources/lang/en/validation.php
--- a/src/resources/lang/en/validation.php
+++ b/src/resources/lang/en/validation.php
@@ -117,6 +117,9 @@
'url' => 'The :attribute format is invalid.',
'uuid' => 'The :attribute must be a valid UUID.',
+
+ '2fareq' => 'Second factor code is required.',
+ '2fainvalid' => 'Second factor code is invalid.',
'emailinvalid' => 'The specified email address is invalid.',
'domaininvalid' => 'The specified domain is invalid.',
'logininvalid' => 'The specified login is invalid.',
diff --git a/src/resources/vue/Login.vue b/src/resources/vue/Login.vue
--- a/src/resources/vue/Login.vue
+++ b/src/resources/vue/Login.vue
@@ -1,36 +1,73 @@
<template>
- <div class="text-center form-wrapper">
- <form class="form-signin" @submit.prevent="submitLogin">
- <h1 class="h3 mb-3 font-weight-normal">Please sign in</h1>
-
- <label for="inputEmail" class="sr-only">Email address</label>
- <input type="email" id="inputEmail" class="form-control" placeholder="Email address" required autofocus v-model="email">
-
- <label for="inputPassword" class="sr-only">Password</label>
- <input type="password" id="inputPassword" class="form-control" placeholder="Password" required v-model="password">
-
- <button class="btn btn-lg btn-primary btn-block" type="submit">Sign in</button>
-
- <br><br><router-link :to="{ name: 'password-reset' }">Forgot password?</router-link>
- </form>
+ <div class="container d-flex flex-column align-items-center">
+ <div class="card col-sm-8 col-lg-6">
+ <div class="card-body">
+ <h1 class="card-title text-center mb-3">Please sign in</h1>
+ <div class="card-text">
+ <form class="form-signin" @submit.prevent="submitLogin">
+ <div class="form-group">
+ <label for="inputEmail" class="sr-only">Email address</label>
+ <div class="input-group">
+ <span class="input-group-prepend">
+ <span class="input-group-text"><svg-icon icon="user"></svg-icon></span>
+ </span>
+ <input type="email" id="inputEmail" class="form-control" placeholder="Email address" required autofocus v-model="email">
+ </div>
+ </div>
+ <div class="form-group">
+ <label for="inputPassword" class="sr-only">Password</label>
+ <div class="input-group">
+ <span class="input-group-prepend">
+ <span class="input-group-text"><svg-icon icon="lock"></svg-icon></span>
+ </span>
+ <input type="password" id="inputPassword" class="form-control" placeholder="Password" required v-model="password">
+ </div>
+ </div>
+ <div class="form-group pt-3">
+ <label for="secondfactor" class="sr-only">2FA</label>
+ <div class="input-group">
+ <span class="input-group-prepend">
+ <span class="input-group-text"><svg-icon icon="key"></svg-icon></span>
+ </span>
+ <input type="text" id="secondfactor" class="form-control rounded-right" placeholder="Second factor code" v-model="secondFactor">
+ </div>
+ <small class="form-text text-muted">Second factor code is optional for users with no 2-Factor Authentication setup.</small>
+ </div>
+ <div class="text-center">
+ <button class="btn btn-primary" type="submit">
+ <svg-icon icon="sign-in-alt"></svg-icon> Sign in
+ </button>
+ </div>
+ </form>
+ </div>
+ </div>
+ </div>
+ <div class="mt-1">
+ <router-link :to="{ name: 'password-reset' }">Forgot password?</router-link>
+ </div>
</div>
</template>
+
<script>
export default {
data() {
return {
email: '',
password: '',
+ secondFactor: '',
loginError: false
}
},
methods: {
submitLogin() {
this.loginError = false
+ this.$root.clearFormValidation($('form.form-signin'))
+
axios.post('/api/auth/login', {
email: this.email,
- password: this.password
+ password: this.password,
+ secondfactor: this.secondFactor
}).then(response => {
// login user and redirect to dashboard
this.$root.loginUser(response.data.access_token)
@@ -41,45 +78,3 @@
}
}
</script>
-
-<style scoped>
- .form-wrapper {
- position: absolute;
- top: 0;
- width: 100%;
- height: 100%;
- display: flex;
- align-items: center;
- }
-
- .form-signin {
- width: 100%;
- max-width: 330px;
- padding: 15px;
- margin: 0 auto;
- }
-
- .form-signin .form-control {
- position: relative;
- box-sizing: border-box;
- height: auto;
- padding: 10px;
- font-size: 16px;
- }
-
- .form-signin .form-control:focus {
- z-index: 2;
- }
-
- .form-signin input[type="email"] {
- margin-bottom: -1px;
- border-bottom-right-radius: 0;
- border-bottom-left-radius: 0;
- }
-
- .form-signin input[type="password"] {
- margin-bottom: 10px;
- border-top-left-radius: 0;
- border-top-right-radius: 0;
- }
-</style>
diff --git a/src/tests/Browser.php b/src/tests/Browser.php
--- a/src/tests/Browser.php
+++ b/src/tests/Browser.php
@@ -5,6 +5,7 @@
use Facebook\WebDriver\WebDriverKeys;
use PHPUnit\Framework\Assert;
use Tests\Browser\Components\Error;
+use Tests\Browser\Components\Toast;
/**
* Laravel Dusk Browser extensions
@@ -45,6 +46,20 @@
}
/**
+ * Assert Toast element content (and close it)
+ */
+ public function assertToast($type, $title, $message)
+ {
+ return $this->withinBody(function ($browser) use ($type, $title, $message) {
+ $browser->with(new Toast($type), function (Browser $browser) use ($title, $message) {
+ $browser->assertToastTitle($title)
+ ->assertToastMessage($message)
+ ->closeToast();
+ });
+ });
+ }
+
+ /**
* Assert specified error page is displayed.
*/
public function assertErrorPage(int $error_code)
diff --git a/src/tests/Browser/LogonTest.php b/src/tests/Browser/LogonTest.php
--- a/src/tests/Browser/LogonTest.php
+++ b/src/tests/Browser/LogonTest.php
@@ -156,4 +156,47 @@
});
});
}
+
+ /**
+ * Test 2-Factor Authentication
+ *
+ * @depends testLogoutByURL
+ */
+ public function test2FA(): void
+ {
+ $this->browse(function (Browser $browser) {
+ // Test missing 2fa code
+ $browser->on(new Home())
+ ->type('@email-input', 'ned@kolab.org')
+ ->type('@password-input', 'simple123')
+ ->press('form button')
+ ->waitFor('@second-factor-input.is-invalid + .invalid-feedback')
+ ->assertSeeIn(
+ '@second-factor-input.is-invalid + .invalid-feedback',
+ 'Second factor code is required.'
+ )
+ ->assertFocused('@second-factor-input')
+ ->assertToast(Toast::TYPE_ERROR, 'Error', 'Form validation error');
+
+ // Test invalid code
+ $browser->type('@second-factor-input', '123456')
+ ->press('form button')
+ ->waitUntilMissing('@second-factor-input.is-invalid')
+ ->waitFor('@second-factor-input.is-invalid + .invalid-feedback')
+ ->assertSeeIn(
+ '@second-factor-input.is-invalid + .invalid-feedback',
+ 'Second factor code is invalid.'
+ )
+ ->assertFocused('@second-factor-input')
+ ->assertToast(Toast::TYPE_ERROR, 'Error', 'Form validation error');
+
+ $code = \App\Auth\SecondFactor::code('ned@kolab.org');
+
+ // Test valid (TOTP) code
+ $browser->type('@second-factor-input', $code)
+ ->press('form button')
+ ->waitUntilMissing('@second-factor-input.is-invalid')
+ ->waitForLocation('/dashboard')->on(new Dashboard());
+ });
+ }
}
diff --git a/src/tests/Browser/Pages/Home.php b/src/tests/Browser/Pages/Home.php
--- a/src/tests/Browser/Pages/Home.php
+++ b/src/tests/Browser/Pages/Home.php
@@ -38,6 +38,9 @@
{
return [
'@app' => '#app',
+ '@email-input' => '#inputEmail',
+ '@password-input' => '#inputPassword',
+ '@second-factor-input' => '#secondfactor',
];
}
@@ -53,10 +56,15 @@
*/
public function submitLogon($browser, $username, $password, $wait_for_dashboard = false)
{
- $browser
- ->type('#inputEmail', $username)
- ->type('#inputPassword', $password)
- ->press('form button');
+ $browser->type('@email-input', $username)
+ ->type('@password-input', $password);
+
+ if ($username == 'ned@kolab.org') {
+ $code = \App\Auth\SecondFactor::code('ned@kolab.org');
+ $browser->type('@second-factor-input', $code);
+ }
+
+ $browser->press('form button');
if ($wait_for_dashboard) {
$browser->waitForLocation('/dashboard');
diff --git a/src/tests/Feature/Auth/SecondFactorTest.php b/src/tests/Feature/Auth/SecondFactorTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Auth/SecondFactorTest.php
@@ -0,0 +1,63 @@
+<?php
+
+namespace Tests\Feature\Auth;
+
+use App\Auth\SecondFactor;
+use App\Entitlement;
+use App\Sku;
+use App\User;
+use Tests\TestCase;
+
+class SecondFactorTest extends TestCase
+{
+
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestUser('entitlement-test@kolabnow.com');
+ }
+
+ public function tearDown(): void
+ {
+ $this->deleteTestUser('entitlement-test@kolabnow.com');
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test that 2FA config is removed from Roundcube database
+ * on entitlement delete
+ */
+ public function testEntitlementDelete(): void
+ {
+ // Create the user, and assign 2FA to him, and add Roundcube setup
+ $sku_2fa = Sku::where('title', '2fa')->first();
+ $user = $this->getTestUser('entitlement-test@kolabnow.com');
+ $user->assignSku($sku_2fa);
+ SecondFactor::seed('entitlement-test@kolabnow.com');
+
+ $entitlement = Entitlement::where('sku_id', $sku_2fa->id)
+ ->where('entitleable_id', $user->id)
+ ->first();
+
+ $this->assertTrue(!empty($entitlement));
+
+ $sf = new SecondFactor($user);
+ $factors = $sf->factors();
+
+ $this->assertCount(2, $factors);
+ $this->assertSame('totp:8132a46b1f741f88de25f47e', $factors[0]);
+ $this->assertSame('dummy:dummy', $factors[1]);
+
+ // Delete the entitlement, expect all configured 2FA methods in Roundcube removed
+ $entitlement->delete();
+
+ $this->assertTrue($entitlement->trashed());
+
+ $sf = new SecondFactor($user);
+ $factors = $sf->factors();
+
+ $this->assertCount(0, $factors);
+ }
+}
diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php
--- a/src/tests/Feature/Controller/UsersTest.php
+++ b/src/tests/Feature/Controller/UsersTest.php
@@ -255,6 +255,8 @@
$this->assertEquals(\config('jwt.ttl') * 60, $json['expires_in']);
$this->assertEquals('bearer', $json['token_type']);
+ // TODO: We have browser tests for 2FA but we should probably also test it here
+
return $json['access_token'];
}
@@ -448,11 +450,13 @@
$storage_sku = Sku::where('title', 'storage')->first();
$groupware_sku = Sku::where('title', 'groupware')->first();
$mailbox_sku = Sku::where('title', 'mailbox')->first();
+ $secondfactor_sku = Sku::where('title', '2fa')->first();
- $this->assertCount(3, $json['skus']);
+ $this->assertCount(4, $json['skus']);
$this->assertSame(2, $json['skus'][$storage_sku->id]['count']);
$this->assertSame(1, $json['skus'][$groupware_sku->id]['count']);
$this->assertSame(1, $json['skus'][$mailbox_sku->id]['count']);
+ $this->assertSame(1, $json['skus'][$secondfactor_sku->id]['count']);
}
/**
diff --git a/src/tests/Feature/EntitlementTest.php b/src/tests/Feature/EntitlementTest.php
--- a/src/tests/Feature/EntitlementTest.php
+++ b/src/tests/Feature/EntitlementTest.php
@@ -2,6 +2,7 @@
namespace Tests\Feature;
+use App\Auth\SecondFactor;
use App\Domain;
use App\Entitlement;
use App\Package;
diff --git a/src/tests/data/2fa-code.png b/src/tests/data/2fa-code.png
new file mode 100644
index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000
GIT binary patch
literal 0
Hc$@<O00001
literal 0
Hc$@<O00001
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Mon, Mar 30, 10:52 AM (1 w, 3 d ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18805047
Default Alt Text
D1075.1774867940.diff (81 KB)
Attached To
Mode
D1075: 2FA - initial, non-working code
Attached
Detach File
Event Timeline