diff --git a/README.md b/README.md index fca42d4b..9ef2f089 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,16 @@ $app->add('CRUD')->setModel(new Client($app->db)); (If you do not have User model yet, you can extend or use \atk4\login\Model\User). -![Login](./docs/login-form.png) +The login form will look like follows at first: + +![Login](./docs/login-form1.png) + +In order to get started, add users by loading the User Admin page instead Auth() in the example above, see [User Admin](#User-Admin) +```php +$app->add(new \atk4\login\UserAdmin()) + ->setModel(new \atk4\login\Model\User($app->db)); +``` +That page of course should not be publicly accessible. ## Features @@ -138,7 +147,7 @@ You may also access user data like this: `$app->auth->model['name']`; Things to - Store last login / last access time in database - Move auth cache to MemCache -#### Profile Form +#### Preferences Form This form would allow user to change user data (including password) but only if user is authenticated. To implement profile form use: @@ -146,7 +155,7 @@ This form would allow user to change user data (including password) but only if $app->add('Form')->setModel($app->auth->user); ``` -Demos open profile form in a pop-up window, if you wish to do it, you can use this code: +Demos open preferences form in a pop-up window, if you wish to do it, you can use this code: ``` php $app->add(['Button', 'Profile', 'primary'])->on('click', $app->add('Modal')->set(function($p) { @@ -211,6 +220,45 @@ Migration relies on https://github.com/atk4/schema. When migration is executed it simply checks to make sure that table for 'user' exists and has all required fields. It will not delete or change existing fields or tables. +## LDAP authentication + +In order to authenticate against an LDAP directory, use AuthLDAP(): +```php + $app->add(new \atk4\login\AuthLDAP([ + 'ldapUrl' => 'ldap://ldap.acme.org/', + 'ldapBaseDn' => 'o=acme', + 'ldapObjFilter' => ['objectClass', 'Person'], + 'ldapFullNameAttr' => 'fullname', + 'ldapEmailAttr' => 'mail', + ]))->setModel(new \atk4\login\Model\UserLDAP($app->db)); +``` +Note that to be able to use this, your PHP installation must support LDAP and the server running ATK needs access to your directory server. + +Also please note that with this authentication method, it will not be possible for the user (or the admin) to change the password in ATK app. + +The following parameters exist: +* $ldapUrl: LDAP URL of the connection +* $ldapProxyUser: If you require a proxy user to look up the actual user, use this +* $ldapProxyPassword: If you use a proxy user (above), then you probably require a password, set it here +* $ldapBaseDn: Where to look for users in the directory tree +* $ldapObjFilter: Object Attributes to filter for +* $ldapCnAttr: Attribute name containing the usernames +* $ldapFullNameAttr: Attribute name containing the full name of the user +* $ldapEmailAttr: Attribute name containing the email address of the user +* $ldapAtkRoleAttr: Attribute name containing the ATK role id of the user, this is experimental +* $ldapUserDefaultRole: The default role to assign to an LDAP user never before seen by your app + +In order to find the appropriate configuration, ask your system administrator. + +As can be seen in the example from the introduction above, we still need persistent storage which is provided by the UserLDAP Model. Notably we need to locally record: +* username +* role_id + +To other user properties (full name, email address, ATK role id, ...) the following applies: +If attribute is provided by LDAP (Example: ```$ldapEmailAttr``` is set and LDAP exposes the attribute containing the email address), then the local DB is updated with that value. +If this is not the case, then the value may be locally set in [Preferences form](#preferences-form) available from the App menu (in Admin layout at least). +If local data has been set and directory is later modified to expose data and ```$ldap___Attr``` properties are enabled, then local data is overwritten with contents of the directory. + ## Roadmap Generally we wish to keep this add-on clean, but very extensible, with various tutorials on how to implement various scenarios (noted above under "Things to try"). diff --git a/docs/login-form1.png b/docs/login-form1.png new file mode 100644 index 00000000..53c2bcfa Binary files /dev/null and b/docs/login-form1.png differ diff --git a/src/Auth.php b/src/Auth.php index ce0125b6..8d0873e4 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -42,6 +42,13 @@ class Auth */ public $fieldLogin = 'email'; + /** + * Caption of the first field on the Login Form. + * + * @var string + */ + public $fieldLoginCaption = 'Email'; + /** * Password to be verified when authenticating. * @@ -216,10 +223,9 @@ public function check() $m->addItem(['Logout', 'icon'=>'sign out'], [$this->pageDashboard, 'logout'=>true]); } - // add preferences menu item + // add User Preferences view if ($this->hasPreferences && $this->app->stickyGet('preferences')) { - $this->app->add(['Header', 'User Preferences', 'subHeader'=>$this->user->getTitle(), 'icon'=>'user']); - $this->app->add('Form')->setModel($this->user); + $this->app->add('atk4\login\UserPreferences')->setModel($this->user); exit; } @@ -244,6 +250,7 @@ public function check() 'auth' => $this, 'linkSuccess' => [$this->pageDashboard], 'linkForgot' => false, + 'fieldLoginCaption' => $this->fieldLoginCaption, ]); $l->layout->template->set('title', 'Log-in Required'); diff --git a/src/AuthLDAP.php b/src/AuthLDAP.php new file mode 100644 index 00000000..de568733 --- /dev/null +++ b/src/AuthLDAP.php @@ -0,0 +1,219 @@ +user->loaded()) { + $this->user->getField($this->fieldLogin)->read_only = true; + if ($this->ldapFullNameAttr) { + $this->user->getField('name')->read_only = true; + } + if ($this->ldapEmailAttr) { + $this->user->getField('email')->read_only = true; + } + if ($this->ldapAtkRoleAttr) { + $this->user->getField('role_id')->read_only = true; + } + } + + parent::check(); + } + + /** + * Try to log in user using LDAP. + * This function completely replaces the one from the parent class. + * + * @param string $username + * @param string $password + * + * @throws Exception + * + * @return bool + */ + public function tryLogin($username, $password) + { + // It is imperative to check this because LDAP might successfully bind + // in "unauthenticated" mode when password is empty + if (empty($password)) { + return false; + } + + /* + * LDAP authentication happens in two steps: + * 1) From the provided username, try to retrieve the user DN. This can be done anonymously, or via a proxy user. + * 2) With the found DN try to bind using the password specified by the user. + */ + + $ldapConn = ldap_connect($this->ldapUrl); + if ($this->ldapProxyUser) { + ldap_bind($ldapConn, $this->ldapProxyUser, utf8_encode($this->ldapProxyPassword)); + } + $sr = ldap_search($ldapConn, + $this->ldapBaseDn, + sprintf('(&(%s=%s)(%s=%s))', $this->ldapCnAttr, $username, $this->ldapObjFilter[0], $this->ldapObjFilter[1]), + array("dn", $this->ldapFullNameAttr, $this->ldapEmailAttr, $this->ldapAtkRoleAttr)); + $info = ldap_get_entries($ldapConn, $sr); + if ($info['count']==1) { + if (ldap_bind($ldapConn, $info[0]['dn'], utf8_encode($password))) { + $user = clone $this->user; + $user->unload(); + $user->tryLoadBy($this->fieldLogin, $username); + if (!$user->loaded()) { + $user->insert([ + $this->fieldLogin => $username, + 'name' => isset($this->ldapFullNameAttr) + ? $info[0][$this->ldapFullNameAttr][0] ?? '' + : '', + 'email' => isset($this->ldapEmailAttr) + ? $info[0][$this->ldapEmailAttr][0] ?? '' + : '', + 'role_id'=> isset($this->ldapAtkRoleAttr) + ? $info[0][$this->ldapAtkRoleAttr][0] ?? $this->ldapUserDefaultRole + : $this->ldapUserDefaultRole, + ]); + $user->tryLoadBy($this->fieldLogin, $username); + } else { + if (isset($this->ldapFullNameAttr) && $info[0][$this->ldapFullNameAttr][0]) { + $user['name'] = $info[0][$this->ldapFullNameAttr][0]; + } + if (isset($this->ldapEmailAttr) && $info[0][$this->ldapEmailAttr][0]) { + $user['email'] = $info[0][$this->ldapEmailAttr][0]; + } + if (isset($this->ldapAtkRoleAttr) && $info[0][$this->ldapAtkRoleAttr][0]) { + $user['role'] = $info[0][$this->ldapAtkRoleAttr][0]; + } else { + $user['role'] = $this->ldapUserDefaultRole; + } + $user->save(); + } + $this->hook('loggedIn', [$user]); + $this->getSessionPersistence()->update($user, 1, $user->get()); + return true; + } + } + return false; + } +} diff --git a/src/Feature/SetupModel.php b/src/Feature/SetupModel.php index 31db4bb2..68262acd 100644 --- a/src/Feature/SetupModel.php +++ b/src/Feature/SetupModel.php @@ -10,6 +10,7 @@ use atk4\login\Model\AccessRule; use atk4\login\Model\Role; use atk4\login\Model\User; +use atk4\login\Model\UserLDAP; use atk4\login\FormField; @@ -73,7 +74,7 @@ public function setupUserModel() $this->getField('email')->required = true; $this->setUnique('email'); $this->getField('password')->ui['visible'] = false; - + // all AccessRules for all user roles // @TODO in future when there can be multiple, then merge them together $this->hasMany('AccessRules', [ @@ -85,7 +86,7 @@ function ($m) { ]); // add some validations - $this->addHook('beforeSave', function ($m){ + $this->onHook('beforeSave', function ($m){ // password should be set when trying to insert new record // but it can be empty if you update record (then it will not change password) if (!$m->loaded() && !$m->get('password')) { @@ -93,4 +94,27 @@ function ($m) { } }); } + + /** + * Setup User model LDAP. + */ + public function setupUserModelLDAP() + { + $this->getField('username')->required = true; + + // all AccessRules for all user roles + // @TODO in future when there can be multiple, then merge them together + $this->hasMany('AccessRules', [ + function ($m) { + return $m->ref('role_id')->ref('AccessRules'); + }, + 'our_field' => 'role_id', + 'their_field' => 'role_id', + ]); + + // add some validations + $this->onHook('beforeSave', function ($m){ + // nothing for now + }); + } } diff --git a/src/LoginForm.php b/src/LoginForm.php index 584cacea..20ac81af 100644 --- a/src/LoginForm.php +++ b/src/LoginForm.php @@ -19,8 +19,11 @@ class LoginForm extends \atk4\ui\Form /** @var false|string show cookie warning? */ public $cookieWarning = 'This website uses web cookie to remember you while you are logged in.'; + /** @var string The field caption of the main user identifier */ + public $fieldLoginCaption = 'Email'; + /** - * Intialization. + * Initialization. */ public function init() { @@ -32,7 +35,7 @@ public function init() $form->buttonSave->addClass('large fluid'); $form->buttonSave->iconRight = 'right arrow'; - $form->addField('email', null, ['required' => true]); + $form->addField('email', null, ['required' => true, 'caption' => $this->fieldLoginCaption]); $p = $form->addField('password', ['Password'], ['required' => true]); if ($this->linkForgot) { @@ -53,7 +56,7 @@ public function init() if ($this->auth->tryLogin($form->model['email'], $form->model['password'])) { return $this->app->jsRedirect($this->linkSuccess); } else { - return $form->error('password', 'Email or Password is incorrect'); + return $form->error('password', $this->fieldLoginCaption.' or Password is incorrect'); } }); } diff --git a/src/Model/UserLDAP.php b/src/Model/UserLDAP.php new file mode 100644 index 00000000..69317562 --- /dev/null +++ b/src/Model/UserLDAP.php @@ -0,0 +1,34 @@ +addField('username'); + $this->addField('name'); + $this->addField('email'); + + // currently user can have only one role. In future it should be n:n relation + $this->hasOne('role_id', [Role::class, 'our_field'=>'role_id', 'their_field'=>'id', 'caption'=>'Role'])->withTitle(); + + // traits + $this->setupUserModelLDAP(); + } +}