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

Implement LDAP auth #37

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
54 changes: 51 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -138,15 +147,15 @@ 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:

``` php
$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) {
Expand Down Expand Up @@ -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").
Expand Down
Binary file added docs/login-form1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 10 additions & 3 deletions src/Auth.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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;
}

Expand All @@ -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');
Expand Down
219 changes: 219 additions & 0 deletions src/AuthLDAP.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
<?php

namespace atk4\login;

use atk4\core\Exception;

/**
* Authentication controller for LDAP authentication.
*/
class AuthLDAP extends Auth
{
/**
* Which field to look up user by.
*
* @var string
*/
public $fieldLogin = 'username';

/**
* Caption of the first field on the Login Form.
*
* @var string
*/
public $fieldLoginCaption = 'Username';

/**
* LDAP server URL
* Example: 'ldap://ldap.acme.com/'
*
* @var string
*/
public $ldapUrl = null;

/**
* LDAP server proxy user to use in order to connect to directory and look for DN of username required to bind
* Example: 'cn=admin,ou=it,o=acme'
*
* @var string
*/
public $ldapProxyUser = null;

/**
* LDAP server proxy user password
*
* @var string
*/
public $ldapProxyPassword = null;

/**
* LDAP Base DistinguishedName: where to start the search for the provided username
* Example: 'o=acme'
*
* @var string
*/
public $ldapBaseDn = null;

/**
* LDAP Object filter: Return only users whose attributes match this filter
* Example: ['objectClass', 'Person'];
*
* @var array
*/
public $ldapObjFilter = null;

/**
* LDAP CommonName attribute name: the name of the attribute in which the username exists
*
* @var string
*/
public $ldapCnAttr = 'cn';

/**
* LDAP attribute storing the full name of the user
* Example: 'fullname'
*
* @var string
*/
public $ldapFullNameAttr = null;

/**
* LDAP attribute storing the email address of the user
* Example: 'mail'
*
* @var string
*/
public $ldapEmailAttr = null;

/**
* LDAP attribute storing the ATK role id of the user
*
* @var string
*/
public $ldapAtkRoleAttr = null;

/**
* Default role assigned to the user upon first login into ATK
*
* @var string
*/
public $ldapUserDefaultRole = null;

/**
* Constructor.
*
* @param array $options
*
* @throws Exception
*/
public function __construct($options = [])
{
parent::__construct($options);
if (!extension_loaded('ldap')) {
throw new Exception(['Maybe you should enable PHP LDAP extension before using LDAP authentication']);
}
}

/**
* Call this method to verify credentials.
*
* In this child class of User, we first set a few values and then let the parent class do its job.
*
* @throws Exception
*/
public function check()
{
// The following fields are loaded from LDAP and not editable.
// Setting them read-only nicely removes them from the Preferences screen.
// Note: We do show the email address in parentheses in the subheader if it was retrieved from LDAP.
if ($this->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;
}
}
28 changes: 26 additions & 2 deletions src/Feature/SetupModel.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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', [
Expand All @@ -85,12 +86,35 @@ 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')) {
throw new ValidationException(['password' => 'Password is required'], $this);
}
});
}

/**
* 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
});
}
}
Loading