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 @@ + + + + + +
+ ![]() |
+
+ + + |
+
+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 @@
+ Please enter a new password for your account +
+ + + + + + + +