diff --git a/db/Magic_Links.sql b/db/Magic_Links.sql new file mode 100644 index 00000000000..f84bde113b2 --- /dev/null +++ b/db/Magic_Links.sql @@ -0,0 +1,9 @@ +DROP TABLE IF EXISTS `Magic_Links`; + +CREATE TABLE `Magic_Links` ( + `Id` int NOT NULL AUTO_INCREMENT, + `UserId` varchar(99) COLLATE utf8mb4_general_ci NOT NULL, + `Token` varchar(99) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL, + `CreatedOn` TimeStamp NOT NULL default NOW(), + PRIMARY KEY(Id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; diff --git a/scripts/ZoneMinder/lib/ZoneMinder/ConfigData.pm.in b/scripts/ZoneMinder/lib/ZoneMinder/ConfigData.pm.in index a5ef08ee250..5cadfe78816 100644 --- a/scripts/ZoneMinder/lib/ZoneMinder/ConfigData.pm.in +++ b/scripts/ZoneMinder/lib/ZoneMinder/ConfigData.pm.in @@ -433,6 +433,19 @@ our @options = ( type => $types{boolean}, category => 'system', }, + { + name => 'ZM_AUTH_MAGIC', + default => 'yes', + description => 'Allow login by magic link', + help => q` + If enabled, allow password change by sending an email with a magic link to the users registered email address. + `, + requires => [ + { name=>'ZM_OPT_USE_AUTH', value=>'yes' } + ], + type => $types{boolean}, + category => 'system', + }, { name => 'ZM_JANUS_SECRET', default => '', diff --git a/web/CMakeLists.txt b/web/CMakeLists.txt index 9676f1cfa7c..292bfe090d6 100644 --- a/web/CMakeLists.txt +++ b/web/CMakeLists.txt @@ -6,7 +6,7 @@ add_subdirectory(api) configure_file(includes/config.php.in "${CMAKE_CURRENT_BINARY_DIR}/includes/config.php" @ONLY) # Install the web files -install(DIRECTORY vendor api ajax css fonts graphics includes js lang skins views DESTINATION "${ZM_WEBDIR}" PATTERN "*.in" EXCLUDE PATTERN "*Make*" EXCLUDE PATTERN "*cmake*" EXCLUDE) +install(DIRECTORY vendor api ajax css email_content fonts graphics includes js lang skins views DESTINATION "${ZM_WEBDIR}" PATTERN "*.in" EXCLUDE PATTERN "*Make*" EXCLUDE PATTERN "*cmake*" EXCLUDE) install(FILES index.php robots.txt DESTINATION "${ZM_WEBDIR}") install(FILES "${CMAKE_CURRENT_BINARY_DIR}/includes/config.php" DESTINATION "${ZM_WEBDIR}/includes") diff --git a/web/email_content/forgotten_password.php b/web/email_content/forgotten_password.php new file mode 100644 index 00000000000..7326ee42e3c --- /dev/null +++ b/web/email_content/forgotten_password.php @@ -0,0 +1,13 @@ +

+Hi Name() ?>, +

+

We have received a request to change your password.

+

Click here to change your password.

+

The link will take you to a secure webpage where you can change your password.

+

The above link is valid for 5 minutes only.

+

Thank you,

+

CloudMule

+ diff --git a/web/email_content/template.php b/web/email_content/template.php new file mode 100755 index 00000000000..100fb902a26 --- /dev/null +++ b/web/email_content/template.php @@ -0,0 +1,31 @@ + + + + + + <?php echo ZM_WEB_TITLE; ?> + + + + + + + + + + + +
+
+

+
+ + diff --git a/web/includes/MagicLink.php b/web/includes/MagicLink.php new file mode 100644 index 00000000000..5746ea4f063 --- /dev/null +++ b/web/includes/MagicLink.php @@ -0,0 +1,45 @@ + null, + 'UserId' => null, + 'Token' => null, + 'CreatedOn' => 'NOW()' + ); + + public static function find( $parameters = array(), $options = array() ) { + return ZM_Object::_find(get_class(), $parameters, $options); + } + + public static function find_one( $parameters = array(), $options = array() ) { + return ZM_Object::_find_one(get_class(), $parameters, $options); + } + + public function User() { + if ((!property_exists($this, 'User') or !$this->User) and $this->UserId) { + $this->User = User::find_one(['Id'=>$this->UserId]); + } + return $this->User; + } + + public function GenerateToken() { + $user = $this->User(); + if (!($user and $user->Username())) { + Error("Not logged in. Cannot generate magic link token"); + Error(print_r($user, true)); + return; + } + $this->Token = hash('sha256', ZM_AUTH_HASH_SECRET.$user->Username().time()); + return $this->Token; + } + + public function url() { + return ZM_URL.'/index.php?view=changepassword&user_id='.$this->User()->Id().'&magic='.$this->Token(); + } +} # end class MagicLink +?> diff --git a/web/includes/Object.php b/web/includes/Object.php index 31d79eeea47..9e98b638970 100644 --- a/web/includes/Object.php +++ b/web/includes/Object.php @@ -357,16 +357,16 @@ function($v) { ); } ); - $fields = array_keys($fields); - if ( $this->Id() ) { + $fields = array_keys($fields); + $sql = 'UPDATE `'.$table.'` SET '.implode(', ', array_map(function($field) {return '`'.$field.'`=?';}, $fields)).' WHERE Id=?'; $values = array_map(function($field){ return $this->{$field};}, $fields); $values[] = $this->{'Id'}; if (dbQuery($sql, $values)) return true; } else { unset($fields['Id']); - + $fields = array_keys($fields); $sql = 'INSERT INTO `'.$table. '` ('.implode(', ', array_map(function($field) {return '`'.$field.'`';}, $fields)). ') VALUES ('. diff --git a/web/includes/User.php b/web/includes/User.php index 79fd4c53762..351cacb910c 100644 --- a/web/includes/User.php +++ b/web/includes/User.php @@ -12,6 +12,9 @@ class User extends ZM_Object { protected $defaults = array( 'Id' => null, 'Username' => array('type'=>'text','filter_regexp'=>'/[^\w\.@ ]/', 'default'=>''), + 'Name' => '', + 'Email' => '', + 'Phone' => '', 'Password' => '', 'Language' => '', 'Enabled' => 1, @@ -42,6 +45,9 @@ public static function find_one( $parameters = array(), $options = array() ) { } public function Name( ) { + if (property_exists($this, 'Name') and !empty($this->Name)) { + return $this->Name; + } return $this->{'Username'}; } diff --git a/web/includes/actions/changepassword.php b/web/includes/actions/changepassword.php new file mode 100644 index 00000000000..3d3d7ec03c0 --- /dev/null +++ b/web/includes/actions/changepassword.php @@ -0,0 +1,66 @@ +'; + return; + } + + if (!(isset($_REQUEST['magic']) and $_REQUEST['magic'])) { + $error_message .= 'changepassword requires a magic link token.
'; + return; + } + if (empty($_REQUEST['user_id'])) { + $error_message .= 'You must specify a user.
'; + return; + } + $u = ZM\User::find_one(['Id'=>$_REQUEST['user_id']]); + if (!$u) { + $error_message .= 'User not found.
'; + return; + } + require_once('includes/MagicLink.php'); + $link = ZM\MagicLink::find_one(['UserId'=>$u->Id(), 'Token'=>$_REQUEST['magic']]); + if (!$link) { + $error_message .= 'Magic link invalid or expired.
'; + return; + } + $link->delete(); + userLogout(); # clear out user stored in session + $user = dbFetchOne('SELECT * FROM Users WHERE Id=?', NULL, [ $u->Id() ]); + # user is global, so this effectively logs us in, but since we don't update session or anything else, it won't last. +} + +if ('changepassword' == $action) { + if (empty($_REQUEST['password'])) { + $error_message .= 'Password cannot be empty.
'; + return; + } + $User = new ZM\User($user); + + $bcrypt_hash = password_hash($_REQUEST['password'], PASSWORD_BCRYPT); + if ($User->save(['Password'=>$bcrypt_hash]) and $User->Enabled()) { + saveUserToSession($user); + } +} # end if doing a login action +?> diff --git a/web/includes/actions/login.php b/web/includes/actions/login.php index d25d194378a..9529c0f335a 100644 --- a/web/includes/actions/login.php +++ b/web/includes/actions/login.php @@ -18,22 +18,88 @@ // Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. // +if ('login' == $action) { + if (isset($_REQUEST['username']) && ( ZM_AUTH_TYPE == 'remote' || isset($_REQUEST['password']))) { + // if true, a popup will display after login + // lets validate reCaptcha if it exists -if ( ('login' == $action) && isset($_REQUEST['username']) && ( ZM_AUTH_TYPE == 'remote' || isset($_REQUEST['password']) ) ) { + // if captcha existed, it was passed - // if true, a popup will display after login - // lets validate reCaptcha if it exists + zm_session_start(); + if (!isset($user) ) { + $_SESSION['loginFailed'] = true; + } else { + unset($_SESSION['loginFailed']); + $view = 'postlogin'; + } + unset($_SESSION['postLoginQuery']); + session_write_close(); + } else { - // if captcha existed, it was passed + } +} else if ('forgotpassword' == $action) { + global $error_mesage; + if ($user) { + $error_message .= 'You are already logged in. Not doing password recovery.
'; + return; + } + require_once('includes/MagicLink.php'); + if (empty($_REQUEST['username'])) { + $error_message .= 'You must specify a user by username or email address.
'; + return; + } + $u = ZM\User::find_one(['Username'=>$_REQUEST['username']]); + if (!$u) { + $u = ZM\User::find_one(['Email'=>$_REQUEST['username']]); + if (!$u) { + $error_message .= 'No user found for that username/email.
'; + return; + } + } + if (!$u->Email()) { + $error_message .= 'User does not have an email address assigned. We will not be able to send a magic link. Please have an admin reset your password.
'; + return; + } + userLogout(); # clear out user stored in session + global $user; + $user = dbFetchOne('SELECT * FROM Users WHERE Id=?', NULL, [ $u->Id() ]); + ZM\Debug("User". print_r($user, true)); - zm_session_start(); - if (!isset($user) ) { - $_SESSION['loginFailed'] = true; - } else { - unset($_SESSION['loginFailed']); - $view = 'postlogin'; + $link = new ZM\MagicLink(); + $link->UserId($u->Id()); + if (!$link->GenerateToken()) { + $error_message .= 'There was a system error generating the magic link. Please contact support.
'; + return; + } + if (!$link->save()) { + $error_message .= 'There was a system error generating the magic link. Please contact support.
'; + return; } - unset($_SESSION['postLoginQuery']); - session_write_close(); + $error_message .= 'Please check your email for a link to change your password.
'; + + $email_content = get_include_contents($_SERVER['DOCUMENT_ROOT'].'/email_content/forgotten_password.php'); + ZM\Debug("Email content $email_content"); + $email_content = get_include_contents($_SERVER['DOCUMENT_ROOT'].'/email_content/template.php'); + ZM\Debug("Email content $email_content"); + if (!$email_content) { + $email_content .= ' + + + Account Recovery Forgotten Password + + +

+Use the following link to login at '.ZM_URL.' +

+

+Click here to login and reset your password. + + +'; + } + # Send an email + $subject = 'Account Recovery Forgotten Password'; + + send_email($u->Email(), ZM_FROM_EMAIL, $subject, $email_content); } # end if doing a login action ?> diff --git a/web/includes/auth.php b/web/includes/auth.php index 0ccce2b95cc..62a8f8dac48 100644 --- a/web/includes/auth.php +++ b/web/includes/auth.php @@ -375,8 +375,56 @@ function get_auth_relay() { return ''; } // end function get_auth_relay +function saveUserToSession($user) { + if (ZM_AUTH_TYPE == 'builtin') { + $_SESSION['passwordHash'] = $user['Password']; + } + + $_SESSION['username'] = $user['Username']; + if (ZM_AUTH_RELAY == 'plain') { + // Need to save this in session, can't use the value in User because it is hashed + $_SESSION['password'] = $_REQUEST['password']; + } +} + +function authByMagic() { + global $user; + global $error_message; + # Must login by magic magic + if(empty($_REQUEST['magic'])) { + $error_message .= 'You must either be logged in, or authenticate by magic link to change your password.
'; + return; + } + + if (!(isset($_REQUEST['magic']) and $_REQUEST['magic'])) { + $error_message .= 'changepassword requires a magic link token.
'; + return; + } + if (empty($_REQUEST['user_id'])) { + $error_message .= 'You must specify a user.
'; + return; + } + $u = ZM\User::find_one(['Id'=>$_REQUEST['user_id']]); + if (!$u) { + $error_message .= 'User not found.
'; + return; + } + require_once('includes/MagicLink.php'); + $link = ZM\MagicLink::find_one(['UserId'=>$u->Id(), 'Token'=>$_REQUEST['magic']]); + if (!$link) { + $error_message .= 'Magic link invalid or expired.
'; + return; + } + $link->delete(); + userLogout(); # clear out user stored in session + $user = dbFetchOne('SELECT * FROM Users WHERE Id=?', NULL, [ $u->Id() ]); + # user is global, so this effectively logs us in, but since we don't update session or anything else, it won't last. +} + if (ZM_OPT_USE_AUTH) { - if (!empty($_REQUEST['token'])) { + if (!empty($_REQUEST['magic']) and (defined('ZM_AUTH_MAGIC') and ZM_AUTH_MAGIC)) { + authByMagic(); + } else if (!empty($_REQUEST['token'])) { // we only need to get the username here // don't know the token type. That will // be checked later @@ -448,15 +496,7 @@ function get_auth_relay() { migrateHash($username, $password); } - if (ZM_AUTH_TYPE == 'builtin') { - $_SESSION['passwordHash'] = $user['Password']; - } - - $_SESSION['username'] = $user['Username']; - if (ZM_AUTH_RELAY == 'plain') { - // Need to save this in session, can't use the value in User because it is hashed - $_SESSION['password'] = $_REQUEST['password']; - } + saveUserToSession($user); } else if ((ZM_AUTH_TYPE == 'remote') and !empty($_SERVER['REMOTE_USER'])) { if (ZM_CASE_INSENSITIVE_USERNAMES) { $sql = 'SELECT * FROM Users WHERE Enabled=1 AND LOWER(Username)=LOWER(?)'; diff --git a/web/includes/functions.php b/web/includes/functions.php index ee4f66f6ea7..ed8103f37da 100644 --- a/web/includes/functions.php +++ b/web/includes/functions.php @@ -2433,6 +2433,68 @@ function array_to_hash_by_key($key, $array) { } function check_datetime($x) { - return (date('Y-m-d H:i:s', strtotime($x)) == $x); + return (date('Y-m-d H:i:s', strtotime($x)) == $x); +} + +function get_include_contents($filename) { + if (is_file($filename)) { + ob_start(); + if (false == (include $filename)) + ZM\Error("Failed including $filename"); + return ob_get_clean(); + } + return false; +} + +/** + * Send email + * @param string|array $email + * @param object $from + * @param string $subject + * @param string $message + * @param string $headers optional + */ +function send_email($email, $from, $subject, $message, $headers = null) { + // Unique boundary + $boundary = md5( uniqid('', true)); + + // If no $headers sent + if (empty($headers)) { + // Add From: header + if (is_array($from)) { + $headers = 'From: ' . $from->name . ' <' . $from->email . ">\r\n"; + } else { + $headers = 'From: ' . $from."\r\n"; + } + + // Specify MIME version 1.0 + $headers .= "MIME-Version: 1.0\r\n"; + + // Tell e-mail client this e-mail contains alternate versions + $headers .= "Content-Type: multipart/alternative; boundary=\"$boundary\"\r\n\r\n"; + } + + // Plain text version of message + $body = "--$boundary\r\n" . + "Content-Type: text/plain; charset=UTF-8\r\n" . + "Content-Transfer-Encoding: base64\r\n\r\n"; + $body .= chunk_split( base64_encode( strip_tags($message) ) ); + + // HTML version of message + $body .= "--$boundary\r\n" . + "Content-Type: text/html; charset=UTF-8\r\n" . + "Content-Transfer-Encoding: base64\r\n\r\n"; + $body .= chunk_split( base64_encode( $message ) ); + + $body .= "--$boundary--"; + + // Send Email + if (is_array($email)) { + foreach ($email as $e) { + mail($e, $subject, $body, $headers); + } + } else { + mail($email, $subject, $body, $headers); + } } ?> diff --git a/web/index.php b/web/index.php index 014a721260f..76297c8f88f 100644 --- a/web/index.php +++ b/web/index.php @@ -157,6 +157,10 @@ $running = null; $action = null; $error_message = null; +if (isset($_SESSION['error_message'])) { + $error_message = $_SESSION['error_message']; + unset($_SESSION['error_message']); +} $redirect = null; $view = isset($_REQUEST['view']) ? detaintPath($_REQUEST['view']) : null; $user = null; @@ -196,7 +200,7 @@ check_timezone(); } -ZM\Debug("View: $view Request: $request Action: $action User: " . ( isset($user) ? $user['Username'] : 'none' )); +ZM\Debug("View: $view Request: $request Action: $action User: " . ( (isset($user) and $user) ? $user['Username'] : 'none' )); if ( ZM_ENABLE_CSRF_MAGIC && ( $action != 'login' ) && @@ -222,6 +226,7 @@ $view = 'none'; $redirect = ZM_BASE_URL.$_SERVER['PHP_SELF'].'?view=login'; zm_session_start(); + $_SESSION['error_message'] = $error_message; $_SESSION['postLoginQuery'] = $_SERVER['QUERY_STRING']; session_write_close(); } else if ( ZM_SHOW_PRIVACY && ($view != 'privacy') && ($view != 'options') && (!$request) && canEdit('System') ) { diff --git a/web/skins/classic/css/base/email.css b/web/skins/classic/css/base/email.css new file mode 100644 index 00000000000..a7f80c0b632 --- /dev/null +++ b/web/skins/classic/css/base/email.css @@ -0,0 +1,3 @@ +.UserName { + text-transform: capitalize; +} diff --git a/web/skins/classic/css/base/views/changepassword.css b/web/skins/classic/css/base/views/changepassword.css new file mode 100644 index 00000000000..cdbd504330e --- /dev/null +++ b/web/skins/classic/css/base/views/changepassword.css @@ -0,0 +1,48 @@ +body { + background-color: #f8f8f8; +} + +input[type="text"] { + margin-bottom: -1px; + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} + +input[type="password"] { + margin-bottom: 10px; + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +input[type="submit"] { + margin-top: 20px; +} + +form { + max-width: 450px; + margin: 15px auto; + border: 1px solid #e7e7e7; + background-color: #fff; + box-shadow: 0 0 6px 0 rgba(0,0,0,0.08); +} + +form .alarm { + font-size:180%; + padding:20px; + border-bottom: 1px solid #e7e7e7; +} + +#loginform { + padding: 40px 60px; +} + +.form-control { + height: 54px; +} + +h1 { + font-size: 150%; + margin-top: 0; + margin-bottom: 15px; +} + diff --git a/web/skins/classic/css/cloudmule/graphics/logo-white.png b/web/skins/classic/css/cloudmule/graphics/logo-white.png new file mode 100644 index 00000000000..21c20399b6a Binary files /dev/null and b/web/skins/classic/css/cloudmule/graphics/logo-white.png differ diff --git a/web/skins/classic/css/cloudmule/graphics/logo-wordmark.png b/web/skins/classic/css/cloudmule/graphics/logo-wordmark.png new file mode 100644 index 00000000000..53ef7e74993 Binary files /dev/null and b/web/skins/classic/css/cloudmule/graphics/logo-wordmark.png differ diff --git a/web/skins/classic/css/cloudmule/graphics/logo.png b/web/skins/classic/css/cloudmule/graphics/logo.png new file mode 100644 index 00000000000..8692c48ca47 Binary files /dev/null and b/web/skins/classic/css/cloudmule/graphics/logo.png differ diff --git a/web/skins/classic/js/skin.js b/web/skins/classic/js/skin.js index 8242a7ff89e..d4347e26693 100644 --- a/web/skins/classic/js/skin.js +++ b/web/skins/classic/js/skin.js @@ -257,7 +257,7 @@ function refreshParentWindow() { } } -if ( currentView != 'none' && currentView != 'login' ) { +if (currentView != 'none') { $j.ajaxSetup({timeout: AJAX_TIMEOUT}); //sets timeout for all getJSON. $j(document).ready(function() { diff --git a/web/skins/classic/views/_options_users.php b/web/skins/classic/views/_options_users.php index fd1291395cc..438bf475759 100644 --- a/web/skins/classic/views/_options_users.php +++ b/web/skins/classic/views/_options_users.php @@ -24,6 +24,8 @@ + + +

+
+UserId($user['Id']); + if (!$link->GenerateToken()) { + $error_message .= 'There was a system error generating the magic link. Please contact support.
'; + return; + } else if (!$link->save()) { + $error_message .= 'There was a system error generating the magic link. Please contact support.
'; + } else { + echo ''.PHP_EOL; + echo ''.PHP_EOL; + } +} + +if ($action == 'changepassword' and !$error_message) { +?> +

You have successfully updated your password. We will redirect in a moment.

+ +'.$error_message.'
'; + } +?> +
+

account_circle

+

+ Please enter a new password for your account +

+ + + + + + + +
+ + + + diff --git a/web/skins/classic/views/login.php b/web/skins/classic/views/login.php index 682b2321af1..5f386ba60c9 100644 --- a/web/skins/classic/views/login.php +++ b/web/skins/classic/views/login.php @@ -5,26 +5,29 @@
- - +'.$error_message.'
'; + } +?>
-

account_circle

- +
'; } ?> - + + + +
-User Authentication is not turned on. You cannot log in. - + User Authentication is not turned on. You cannot log in, we will try to redirect you. +