diff options
-rw-r--r-- | ChangeLog | 1 | ||||
-rw-r--r-- | app/Auth/LdapAuth.php | 2 | ||||
-rw-r--r-- | app/Controller/AvatarFile.php | 2 | ||||
-rw-r--r-- | app/Core/Ldap/User.php | 15 | ||||
-rw-r--r-- | app/Core/User/UserProfile.php | 4 | ||||
-rw-r--r-- | app/Core/User/UserSync.php | 6 | ||||
-rw-r--r-- | app/Event/UserProfileSyncEvent.php | 64 | ||||
-rw-r--r-- | app/Model/AvatarFile.php | 32 | ||||
-rw-r--r-- | app/ServiceProvider/EventDispatcherProvider.php | 5 | ||||
-rw-r--r-- | app/Subscriber/BaseSubscriber.php | 1 | ||||
-rw-r--r-- | app/Subscriber/LdapUserPhotoSubscriber.php | 49 | ||||
-rw-r--r-- | app/User/LdapUserProvider.php | 23 | ||||
-rw-r--r-- | app/constants.php | 1 | ||||
-rw-r--r-- | config.default.php | 3 | ||||
-rw-r--r-- | doc/index.markdown | 1 | ||||
-rw-r--r-- | doc/ldap-parameters.markdown | 1 | ||||
-rw-r--r-- | doc/ldap-profile-picture.markdown | 27 | ||||
-rw-r--r-- | tests/units/Core/Ldap/LdapUserTest.php | 70 | ||||
-rw-r--r-- | tests/units/Subscriber/LdapUserPhotoSubscriberTest.php | 83 |
19 files changed, 377 insertions, 13 deletions
@@ -5,6 +5,7 @@ New features: * Added automated action to change task color based on the priority * Added support for LDAP Posix Groups (OpenLDAP with memberUid) +* Added support for LDAP user photo attribute (Avatar image) * Search in activity stream * Search in comments * Search by task creator diff --git a/app/Auth/LdapAuth.php b/app/Auth/LdapAuth.php index c9423580..a8dcfcb6 100644 --- a/app/Auth/LdapAuth.php +++ b/app/Auth/LdapAuth.php @@ -76,7 +76,7 @@ class LdapAuth extends Base implements PasswordAuthenticationProviderInterface throw new LogicException('Username not found in LDAP profile, check the parameter LDAP_USER_ATTRIBUTE_USERNAME'); } - $this->logger->info('Authenticate user: '.$user->getDn()); + $this->logger->info('Authenticate this user: '.$user->getDn()); if ($client->authenticate($user->getDn(), $this->password)) { $this->userInfo = $user; diff --git a/app/Controller/AvatarFile.php b/app/Controller/AvatarFile.php index 5974cde7..45cb1615 100644 --- a/app/Controller/AvatarFile.php +++ b/app/Controller/AvatarFile.php @@ -32,7 +32,7 @@ class AvatarFile extends Base { $user = $this->getUser(); - if (! $this->avatarFile->uploadFile($user['id'], $this->request->getFileInfo('avatar'))) { + if (! $this->avatarFile->uploadImageFile($user['id'], $this->request->getFileInfo('avatar'))) { $this->flash->failure(t('Unable to upload the file.')); } diff --git a/app/Core/Ldap/User.php b/app/Core/Ldap/User.php index d5e1eb8a..d0036ea7 100644 --- a/app/Core/Ldap/User.php +++ b/app/Core/Ldap/User.php @@ -145,7 +145,8 @@ class User $entry->getFirstValue($this->getAttributeName()), $entry->getFirstValue($this->getAttributeEmail()), $this->getRole($groupIds), - $groupIds + $groupIds, + $entry->getFirstValue($this->getAttributePhoto()) ); } @@ -164,6 +165,7 @@ class User $this->getAttributeName(), $this->getAttributeEmail(), $this->getAttributeGroup(), + $this->getAttributePhoto(), ))); } @@ -224,6 +226,17 @@ class User } /** + * Get LDAP profile photo attribute + * + * @access public + * @return string + */ + public function getAttributePhoto() + { + return strtolower(LDAP_USER_ATTRIBUTE_PHOTO); + } + + /** * Get LDAP Group User filter * * @access public diff --git a/app/Core/User/UserProfile.php b/app/Core/User/UserProfile.php index ef325801..4f873390 100644 --- a/app/Core/User/UserProfile.php +++ b/app/Core/User/UserProfile.php @@ -3,6 +3,7 @@ namespace Kanboard\Core\User; use Kanboard\Core\Base; +use Kanboard\Event\UserProfileSyncEvent; /** * User Profile @@ -12,6 +13,8 @@ use Kanboard\Core\Base; */ class UserProfile extends Base { + const EVENT_USER_PROFILE_AFTER_SYNC = 'user_profile.after.sync'; + /** * Assign provider data to the local user * @@ -54,6 +57,7 @@ class UserProfile extends Base if (! empty($profile) && $profile['is_active'] == 1) { $this->userSession->initialize($profile); + $this->dispatcher->dispatch(self::EVENT_USER_PROFILE_AFTER_SYNC, new UserProfileSyncEvent($profile, $user)); return true; } diff --git a/app/Core/User/UserSync.php b/app/Core/User/UserSync.php index d450a0bd..055c7106 100644 --- a/app/Core/User/UserSync.php +++ b/app/Core/User/UserSync.php @@ -64,13 +64,13 @@ class UserSync extends Base */ private function createUser(UserProviderInterface $user, array $properties) { - $id = $this->user->create($properties); + $userId = $this->user->create($properties); - if ($id === false) { + if ($userId === false) { $this->logger->error('Unable to create user profile: '.$user->getExternalId()); return array(); } - return $this->user->getById($id); + return $this->user->getById($userId); } } diff --git a/app/Event/UserProfileSyncEvent.php b/app/Event/UserProfileSyncEvent.php new file mode 100644 index 00000000..c02e1d89 --- /dev/null +++ b/app/Event/UserProfileSyncEvent.php @@ -0,0 +1,64 @@ +<?php + +namespace Kanboard\Event; + +use Kanboard\Core\User\UserProviderInterface; +use Kanboard\User\LdapUserProvider; +use Symfony\Component\EventDispatcher\Event; + +/** + * Class UserProfileSyncEvent + * + * @package Kanboard\Event + * @author Fredic Guillot + */ +class UserProfileSyncEvent extends Event +{ + /** + * User profile + * + * @var array + */ + private $profile; + + /** + * User provider + * + * @var UserProviderInterface + */ + private $user; + + /** + * UserProfileSyncEvent constructor. + * + * @param array $profile + * @param UserProviderInterface $user + */ + public function __construct(array $profile, UserProviderInterface $user) + { + $this->profile = $profile; + $this->user = $user; + } + + /** + * Get user profile + * + * @access public + * @return array + */ + public function getProfile() + { + return $this->profile; + } + + /** + * Get user provider object + * + * @access public + * @return UserProviderInterface|LdapUserProvider + */ + public function getUser() + { + return $this->user; + } +} diff --git a/app/Model/AvatarFile.php b/app/Model/AvatarFile.php index 36784e60..9f47ccc7 100644 --- a/app/Model/AvatarFile.php +++ b/app/Model/AvatarFile.php @@ -75,20 +75,20 @@ class AvatarFile extends Base } /** - * Upload avatar image + * Upload avatar image file * * @access public * @param integer $user_id * @param array $file * @return boolean */ - public function uploadFile($user_id, array $file) + public function uploadImageFile($user_id, array $file) { try { if ($file['error'] == UPLOAD_ERR_OK && $file['size'] > 0) { - $destination_filename = $this->generatePath($user_id, $file['name']); - $this->objectStorage->moveUploadedFile($file['tmp_name'], $destination_filename); - $this->create($user_id, $destination_filename); + $destinationFilename = $this->generatePath($user_id, $file['name']); + $this->objectStorage->moveUploadedFile($file['tmp_name'], $destinationFilename); + $this->create($user_id, $destinationFilename); } else { throw new Exception('File not uploaded: '.var_export($file['error'], true)); } @@ -102,6 +102,28 @@ class AvatarFile extends Base } /** + * Upload avatar image content + * + * @access public + * @param integer $user_id + * @param string $blob + * @return boolean + */ + public function uploadImageContent($user_id, &$blob) + { + try { + $destinationFilename = $this->generatePath($user_id, 'imageContent'); + $this->objectStorage->put($destinationFilename, $blob); + $this->create($user_id, $destinationFilename); + } catch (Exception $e) { + $this->logger->error($e->getMessage()); + return false; + } + + return true; + } + + /** * Generate the path for a new filename * * @access public diff --git a/app/ServiceProvider/EventDispatcherProvider.php b/app/ServiceProvider/EventDispatcherProvider.php index 880caa41..6b3dc098 100644 --- a/app/ServiceProvider/EventDispatcherProvider.php +++ b/app/ServiceProvider/EventDispatcherProvider.php @@ -2,6 +2,7 @@ namespace Kanboard\ServiceProvider; +use Kanboard\Subscriber\LdapUserPhotoSubscriber; use Pimple\Container; use Pimple\ServiceProviderInterface; use Symfony\Component\EventDispatcher\EventDispatcher; @@ -28,6 +29,10 @@ class EventDispatcherProvider implements ServiceProviderInterface $container['dispatcher']->addSubscriber(new TransitionSubscriber($container)); $container['dispatcher']->addSubscriber(new RecurringTaskSubscriber($container)); + if (LDAP_AUTH && LDAP_USER_ATTRIBUTE_PHOTO !== '') { + $container['dispatcher']->addSubscriber(new LdapUserPhotoSubscriber($container)); + } + return $container; } } diff --git a/app/Subscriber/BaseSubscriber.php b/app/Subscriber/BaseSubscriber.php index 2e41da76..fdea29f6 100644 --- a/app/Subscriber/BaseSubscriber.php +++ b/app/Subscriber/BaseSubscriber.php @@ -34,7 +34,6 @@ class BaseSubscriber extends Base } $this->called[$key] = true; - return false; } } diff --git a/app/Subscriber/LdapUserPhotoSubscriber.php b/app/Subscriber/LdapUserPhotoSubscriber.php new file mode 100644 index 00000000..3cf46077 --- /dev/null +++ b/app/Subscriber/LdapUserPhotoSubscriber.php @@ -0,0 +1,49 @@ +<?php + +namespace Kanboard\Subscriber; + +use Kanboard\Core\User\UserProfile; +use Kanboard\Event\UserProfileSyncEvent; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Class LdapUserPhotoSubscriber + * + * @package Kanboard\Subscriber + * @author Frederic Guillot + */ +class LdapUserPhotoSubscriber extends BaseSubscriber implements EventSubscriberInterface +{ + /** + * Get event listeners + * + * @static + * @access public + * @return array + */ + public static function getSubscribedEvents() + { + return array( + UserProfile::EVENT_USER_PROFILE_AFTER_SYNC => 'syncUserPhoto', + ); + } + + /** + * Save the user profile photo from LDAP to the object storage + * + * @access public + * @param UserProfileSyncEvent $event + */ + public function syncUserPhoto(UserProfileSyncEvent $event) + { + if (is_a($event->getUser(), 'Kanboard\User\LdapUserProvider')) { + $profile = $event->getProfile(); + $photo = $event->getUser()->getPhoto(); + + if (empty($profile['avatar_path']) && ! empty($photo)) { + $this->logger->info('Saving user photo from LDAP profile'); + $this->avatarFile->uploadImageContent($profile['id'], $photo); + } + } + } +} diff --git a/app/User/LdapUserProvider.php b/app/User/LdapUserProvider.php index 8d5d4b1f..3e2dcd2b 100644 --- a/app/User/LdapUserProvider.php +++ b/app/User/LdapUserProvider.php @@ -61,6 +61,14 @@ class LdapUserProvider implements UserProviderInterface protected $groupIds; /** + * User photo + * + * @access protected + * @var string + */ + protected $photo = ''; + + /** * Constructor * * @access public @@ -70,8 +78,9 @@ class LdapUserProvider implements UserProviderInterface * @param string $email * @param string $role * @param string[] $groupIds + * @param string $photo */ - public function __construct($dn, $username, $name, $email, $role, array $groupIds) + public function __construct($dn, $username, $name, $email, $role, array $groupIds, $photo = '') { $this->dn = $dn; $this->username = $username; @@ -79,6 +88,7 @@ class LdapUserProvider implements UserProviderInterface $this->email = $email; $this->role = $role; $this->groupIds = $groupIds; + $this->photo = $photo; } /** @@ -203,4 +213,15 @@ class LdapUserProvider implements UserProviderInterface { return $this->dn; } + + /** + * Get user photo + * + * @access public + * @return string + */ + public function getPhoto() + { + return $this->photo; + } } diff --git a/app/constants.php b/app/constants.php index 94086fa9..b5b01960 100644 --- a/app/constants.php +++ b/app/constants.php @@ -59,6 +59,7 @@ defined('LDAP_USER_ATTRIBUTE_USERNAME') or define('LDAP_USER_ATTRIBUTE_USERNAME' defined('LDAP_USER_ATTRIBUTE_FULLNAME') or define('LDAP_USER_ATTRIBUTE_FULLNAME', 'cn'); defined('LDAP_USER_ATTRIBUTE_EMAIL') or define('LDAP_USER_ATTRIBUTE_EMAIL', 'mail'); defined('LDAP_USER_ATTRIBUTE_GROUPS') or define('LDAP_USER_ATTRIBUTE_GROUPS', 'memberof'); +defined('LDAP_USER_ATTRIBUTE_PHOTO') or define('LDAP_USER_ATTRIBUTE_PHOTO', ''); defined('LDAP_USER_CREATION') or define('LDAP_USER_CREATION', true); defined('LDAP_GROUP_ADMIN_DN') or define('LDAP_GROUP_ADMIN_DN', ''); diff --git a/config.default.php b/config.default.php index 49988a3b..8e5c6e49 100644 --- a/config.default.php +++ b/config.default.php @@ -114,6 +114,9 @@ define('LDAP_USER_ATTRIBUTE_EMAIL', 'mail'); // LDAP attribute to find groups in user profile define('LDAP_USER_ATTRIBUTE_GROUPS', 'memberof'); +// LDAP attribute for user avatar image: thumbnailPhoto or jpegPhoto +define('LDAP_USER_ATTRIBUTE_PHOTO', ''); + // Allow automatic LDAP user creation define('LDAP_USER_CREATION', true); diff --git a/doc/index.markdown b/doc/index.markdown index 30083fa9..1294de78 100644 --- a/doc/index.markdown +++ b/doc/index.markdown @@ -121,6 +121,7 @@ Technical details - [LDAP authentication](ldap-authentication.markdown) - [LDAP group synchronization](ldap-group-sync.markdown) +- [LDAP profile picture](ldap-profile-picture.markdown) - [LDAP parameters](ldap-parameters.markdown) - [Reverse proxy authentication](reverse-proxy-authentication.markdown) diff --git a/doc/ldap-parameters.markdown b/doc/ldap-parameters.markdown index c7202641..c18c2b06 100644 --- a/doc/ldap-parameters.markdown +++ b/doc/ldap-parameters.markdown @@ -20,6 +20,7 @@ Here is the list of available LDAP parameters: | `LDAP_USER_ATTRIBUTE_FULLNAME` | cn | LDAP attribute for user full name (Example: "displayname") | | `LDAP_USER_ATTRIBUTE_EMAIL` | mail | LDAP attribute for user email | | `LDAP_USER_ATTRIBUTE_GROUPS` | memberof | LDAP attribute to find groups in user profile | +| `LDAP_USER_ATTRIBUTE_PHOTO` | Empty | LDAP attribute to find user photo (jpegPhoto or thumbnailPhoto | | `LDAP_USER_CREATION` | true | Enable automatic LDAP user creation | | `LDAP_GROUP_ADMIN_DN` | Empty | LDAP DN for administrators (Example: "CN=Kanboard-Admins,CN=Users,DC=kanboard,DC=local") | | `LDAP_GROUP_MANAGER_DN` | Empty | LDAP DN for managers (Example: "CN=Kanboard Managers,CN=Users,DC=kanboard,DC=local") | diff --git a/doc/ldap-profile-picture.markdown b/doc/ldap-profile-picture.markdown new file mode 100644 index 00000000..4798f645 --- /dev/null +++ b/doc/ldap-profile-picture.markdown @@ -0,0 +1,27 @@ +LDAP User Profile Photo +======================= + +Kanboard can download automatically user pictures from the LDAP server. + +This feature is enabled only if LDAP authentication is activated and the parameter `LDAP_USER_ATTRIBUTE_PHOTO` is defined. + +Configuration +------------- + +In your `config.php`, you have to set the LDAP attribute used to store the image. + +```php +define('LDAP_USER_ATTRIBUTE_PHOTO', 'jpegPhoto'); +``` + +Usually, the attributes `jpegPhoto` or `thumbnailPhoto` are used. +The image can be stored in JPEG or PNG format. + +To upload the image in the user profile, Active Directory administrators may use software like [AD Photo Edit](http://www.cjwdev.co.uk/Software/ADPhotoEdit/Info.html). + +Notes +----- + +The profile image is **downloaded at login time only if the user do not already have uploaded an image previously**. + +To change the user photo, the previous one have to be removed manually in the user profile. diff --git a/tests/units/Core/Ldap/LdapUserTest.php b/tests/units/Core/Ldap/LdapUserTest.php index d861871e..02b9331e 100644 --- a/tests/units/Core/Ldap/LdapUserTest.php +++ b/tests/units/Core/Ldap/LdapUserTest.php @@ -52,6 +52,7 @@ class LdapUserTest extends Base 'getAttributeEmail', 'getAttributeName', 'getAttributeGroup', + 'getAttributePhoto', 'getGroupUserFilter', 'getGroupAdminDn', 'getGroupManagerDn', @@ -136,10 +137,79 @@ class LdapUserTest extends Base $this->assertEquals('My LDAP user', $user->getName()); $this->assertEquals('user1@localhost', $user->getEmail()); $this->assertEquals(Role::APP_USER, $user->getRole()); + $this->assertSame('', $user->getPhoto()); $this->assertEquals(array(), $user->getExternalGroupIds()); $this->assertEquals(array('is_ldap_user' => 1), $user->getExtraAttributes()); } + public function testGetUserWithPhoto() + { + $entries = new Entries(array( + 'count' => 1, + 0 => array( + 'count' => 2, + 'dn' => 'uid=my_ldap_user,ou=People,dc=kanboard,dc=local', + 'displayname' => array( + 'count' => 1, + 0 => 'My LDAP user', + ), + 'mail' => array( + 'count' => 2, + 0 => 'user1@localhost', + 1 => 'user2@localhost', + ), + 'samaccountname' => array( + 'count' => 1, + 0 => 'my_ldap_user', + ), + 'jpegPhoto' => array( + 'count' => 1, + 0 => 'my photo', + ), + 0 => 'displayname', + 1 => 'mail', + 2 => 'samaccountname', + ) + )); + + $this->client + ->expects($this->any()) + ->method('getConnection') + ->will($this->returnValue('my_ldap_resource')); + + $this->query + ->expects($this->once()) + ->method('execute') + ->with( + $this->equalTo('ou=People,dc=kanboard,dc=local'), + $this->equalTo('(uid=my_ldap_user)') + ); + + $this->query + ->expects($this->once()) + ->method('hasResult') + ->will($this->returnValue(true)); + + $this->query + ->expects($this->once()) + ->method('getEntries') + ->will($this->returnValue($entries)); + + $this->user + ->expects($this->any()) + ->method('getAttributePhoto') + ->will($this->returnValue('jpegPhoto')); + + $this->user + ->expects($this->any()) + ->method('getBasDn') + ->will($this->returnValue('ou=People,dc=kanboard,dc=local')); + + $user = $this->user->find('(uid=my_ldap_user)'); + $this->assertInstanceOf('Kanboard\User\LdapUserProvider', $user); + $this->assertEquals('my photo', $user->getPhoto()); + } + public function testGetUserWithAdminRole() { $entries = new Entries(array( diff --git a/tests/units/Subscriber/LdapUserPhotoSubscriberTest.php b/tests/units/Subscriber/LdapUserPhotoSubscriberTest.php new file mode 100644 index 00000000..df44d232 --- /dev/null +++ b/tests/units/Subscriber/LdapUserPhotoSubscriberTest.php @@ -0,0 +1,83 @@ +<?php + +use Kanboard\Core\Security\Role; +use Kanboard\Event\UserProfileSyncEvent; +use Kanboard\Model\User; +use Kanboard\Subscriber\LdapUserPhotoSubscriber; +use Kanboard\User\DatabaseUserProvider; +use Kanboard\User\LdapUserProvider; + +require_once __DIR__.'/../Base.php'; + +class LdapUserPhotoSubscriberTest extends Base +{ + public function testWhenTheProviderIsNotLdap() + { + $userProvider = new DatabaseUserProvider(array()); + $subscriber = new LdapUserPhotoSubscriber($this->container); + $userModel = new User($this->container); + + $userModel->update(array('id' => 1, 'avatar_path' => 'my avatar')); + $user = $userModel->getById(1); + + $subscriber->syncUserPhoto(new UserProfileSyncEvent($user, $userProvider)); + + $user = $userModel->getById(1); + $this->assertEquals('my avatar', $user['avatar_path']); + } + + public function testWhenTheUserHaveLdapPhoto() + { + $userProvider = new LdapUserProvider('dn', 'admin', 'Admin', 'admin@localhost', Role::APP_ADMIN, array(), 'my photo'); + $subscriber = new LdapUserPhotoSubscriber($this->container); + $userModel = new User($this->container); + $user = $userModel->getById(1); + + $this->container['objectStorage'] + ->expects($this->once()) + ->method('put') + ->with($this->anything(), 'my photo'); + + + $subscriber->syncUserPhoto(new UserProfileSyncEvent($user, $userProvider)); + + $user = $userModel->getById(1); + $this->assertStringStartsWith('avatars', $user['avatar_path']); + } + + public function testWhenTheUserDoNotHaveLdapPhoto() + { + $userProvider = new LdapUserProvider('dn', 'admin', 'Admin', 'admin@localhost', Role::APP_ADMIN, array()); + $subscriber = new LdapUserPhotoSubscriber($this->container); + $userModel = new User($this->container); + $user = $userModel->getById(1); + + $this->container['objectStorage'] + ->expects($this->never()) + ->method('put'); + + $subscriber->syncUserPhoto(new UserProfileSyncEvent($user, $userProvider)); + + $user = $userModel->getById(1); + $this->assertEmpty($user['avatar_path']); + } + + public function testWhenTheUserAlreadyHaveAvatar() + { + $userProvider = new LdapUserProvider('dn', 'admin', 'Admin', 'admin@localhost', Role::APP_ADMIN, array(), 'my photo'); + $subscriber = new LdapUserPhotoSubscriber($this->container); + $userModel = new User($this->container); + + $userModel->update(array('id' => 1, 'avatar_path' => 'my avatar')); + $user = $userModel->getById(1); + + $this->container['objectStorage'] + ->expects($this->never()) + ->method('put'); + + $subscriber->syncUserPhoto(new UserProfileSyncEvent($user, $userProvider)); + + $user = $userModel->getById(1); + $this->assertEquals('my avatar', $user['avatar_path']); + } +} |