Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add magic logins #3669

Open
wants to merge 33 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
d2bafe8
Add some logic to send magic link
Mar 1, 2023
11cfe82
Add Forgot password button
Mar 1, 2023
f1d1cdb
Add MagicLink class
Mar 1, 2023
b312c5a
Add MagicLink table
Mar 1, 2023
044214a
Merge branch 'master' into add_magic_Logins
Mar 1, 2023
2596bbd
Fixup table def. Add primary key, autoincremement and defaults
Mar 2, 2023
581781f
Add email_content
Mar 2, 2023
705bb40
Add User() fill in GenerateToken and url()
Mar 2, 2023
15eca9c
Fix inserting including Id
Mar 2, 2023
860ea5b
Add Name,Email,Phone defaults and if user has a name assigned, use it…
Mar 2, 2023
33c220a
Use magic instead of token as that conflict with jwt. user user_id in…
Mar 2, 2023
4d4b787
do error messages if action=login but required fields not there. Uf d…
Mar 2, 2023
b8d272b
Add saveUserToSession and authByMagic
Mar 2, 2023
8793fc2
Add get_include_contents and send_email functions
Mar 2, 2023
c8e6832
Handle user being false instead of null. Don't redirect to login if …
Mar 2, 2023
da6f1f5
Still do all the header code when view=login i case we are viewing th…
Mar 2, 2023
2b383c8
add canEdit to _options_users
Mar 2, 2023
c30696d
Spacing
Mar 2, 2023
6ae46a2
include options_users instead of options_api
Mar 2, 2023
ef51214
add changepassword, email content and email css
Mar 2, 2023
0693f61
Add config setting for allowing login by Magic link
Mar 2, 2023
fa60381
Do auth by Magic link if feature is turned on
Mar 2, 2023
49b37e9
since magic auth is done in auth.php now, must be logged in to see ch…
Mar 2, 2023
6e5314b
Only show forgot password button if auth magic is turned on
Mar 2, 2023
ccdd6d3
Don't do login in view, since we can't redirect
Mar 2, 2023
45922a3
Carry over error_messages in session
Mar 2, 2023
26ea691
Handle empty Name
Mar 2, 2023
7c6be83
include skin/css based email.css
Mar 2, 2023
6312404
Add a success message
Mar 2, 2023
88dd5d2
Add css styles for changepassword
Mar 2, 2023
706a0dc
Use sha256 instead of md5
Mar 3, 2023
fe1245a
bcrypt hash the password when changing password
Mar 3, 2023
c92dea9
Handle ZM_AUTH_MAGIC not being defined
Mar 3, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions db/Magic_Links.sql
Original file line number Diff line number Diff line change
@@ -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;
13 changes: 13 additions & 0 deletions scripts/ZoneMinder/lib/ZoneMinder/ConfigData.pm.in
Original file line number Diff line number Diff line change
Expand Up @@ -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 => '',
Expand Down
2 changes: 1 addition & 1 deletion web/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
13 changes: 13 additions & 0 deletions web/email_content/forgotten_password.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<p>
Hi <span class="UserName"><?php
global $user;
global $link;
echo (new ZM\User($user))->Name() ?>,
</span></p>
<p>We have received a request to change your password.</p>
<p><a href="<?php echo $link->url() ?>">Click here to change your password.</a></p>
<p>The link will take you to a secure webpage where you can change your password.</p>
<p>The above link is valid for 5 minutes only.</p>
<p>Thank you,</p>
<p>CloudMule</p>

31 changes: 31 additions & 0 deletions web/email_content/template.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php
global $skin;
global $css;
global $email_content;
?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<title><?php echo ZM_WEB_TITLE; ?></title>
<style type="text/css">
<?php echo get_include_contents($_SERVER['DOCUMENT_ROOT'].'/skins/'.$skin.'/css/'.$css.'/email.css')?>
</style>
<base href="<?php echo ZM_URL; ?>"/>
</head>
<body>
<table width="98%" border="0">
<tr>
<td height="72" id="banner"><a href="<?php echo ZM_URL ?>" target="_blank">
<img src="skins/<?php echo $skin ?>/css/<?php echo $css ?>/graphics/email_header.png" height="40" border="0" alt="<?php echo ( ZM_WEB_TITLE ) ?>"/></a></td>
</tr>
<tr>
<td align="left" valign="top" style="padding: 5px;">
<br/>
<?php
echo $email_content; ?> <br/> <br/>
</td>
</tr>
</table>
</body>
</html>
45 changes: 45 additions & 0 deletions web/includes/MagicLink.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php
namespace ZM;
require_once('Object.php');

class MagicLink extends ZM_Object {
protected static $table = 'Magic_Links';

protected $defaults = array(
'Id' => 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&amp;user_id='.$this->User()->Id().'&amp;magic='.$this->Token();
}
} # end class MagicLink
?>
6 changes: 3 additions & 3 deletions web/includes/Object.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 ('.
Expand Down
6 changes: 6 additions & 0 deletions web/includes/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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'};
}

Expand Down
66 changes: 66 additions & 0 deletions web/includes/actions/changepassword.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php
//
// ZoneMinder web action file
// Copyright (C) 2023 ZoneMinder LLC
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// 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 General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
//

global $error_mesage;
if (!$user) {
# 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.<br/>';
return;
}

if (!(isset($_REQUEST['magic']) and $_REQUEST['magic'])) {
$error_message .= 'changepassword requires a magic link token.<br/>';
return;
}
if (empty($_REQUEST['user_id'])) {
$error_message .= 'You must specify a user.<br/>';
return;
}
$u = ZM\User::find_one(['Id'=>$_REQUEST['user_id']]);
if (!$u) {
$error_message .= 'User not found.<br/>';
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.<br/>';
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.<br/>';
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
?>
90 changes: 78 additions & 12 deletions web/includes/actions/login.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.<br/>';
return;
}
require_once('includes/MagicLink.php');
if (empty($_REQUEST['username'])) {
$error_message .= 'You must specify a user by username or email address.<br/>';
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.<br/>';
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.<br/>';
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.<br/>';
return;
}
if (!$link->save()) {
$error_message .= 'There was a system error generating the magic link. Please contact support.<br/>';
return;
}
unset($_SESSION['postLoginQuery']);
session_write_close();
$error_message .= 'Please check your email for a link to change your password.<br/>';

$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 .= '
<html>
<head>
<title>Account Recovery Forgotten Password</title>
</head>
<body>
<p>
Use the following link to login at '.ZM_URL.'
</p>
<p>
<a href="'.$link->url().'">Click here to login and reset your password.<a/>
</body>
</html>
';
}
# Send an email
$subject = 'Account Recovery Forgotten Password';

send_email($u->Email(), ZM_FROM_EMAIL, $subject, $email_content);
} # end if doing a login action
?>
Loading