Page MenuHomePhorge

D1075.1774867940.diff
No OneTemporary

Authored By
Unknown
Size
81 KB
Referenced Files
None
Subscribers
None

D1075.1774867940.diff

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

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)

Event Timeline