Skip to content
Merged
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
76 changes: 76 additions & 0 deletions app/Auth/Access/ExternalAuthService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php namespace BookStack\Auth\Access;

use BookStack\Auth\Role;
use BookStack\Auth\User;
use Illuminate\Database\Eloquent\Builder;

class ExternalAuthService
{
/**
* Check a role against an array of group names to see if it matches.
* Checked against role 'external_auth_id' if set otherwise the name of the role.
* @param \BookStack\Auth\Role $role
* @param array $groupNames
* @return bool
*/
protected function roleMatchesGroupNames(Role $role, array $groupNames)
{
if ($role->external_auth_id) {
$externalAuthIds = explode(',', strtolower($role->external_auth_id));
foreach ($externalAuthIds as $externalAuthId) {
if (in_array(trim($externalAuthId), $groupNames)) {
return true;
}
}
return false;
}

$roleName = str_replace(' ', '-', trim(strtolower($role->display_name)));
return in_array($roleName, $groupNames);
}

/**
* Match an array of group names to BookStack system roles.
* Formats group names to be lower-case and hyphenated.
* @param array $groupNames
* @return \Illuminate\Support\Collection
*/
protected function matchGroupsToSystemsRoles(array $groupNames)
{
foreach ($groupNames as $i => $groupName) {
$groupNames[$i] = str_replace(' ', '-', trim(strtolower($groupName)));
}

$roles = Role::query()->where(function (Builder $query) use ($groupNames) {
$query->whereIn('name', $groupNames);
foreach ($groupNames as $groupName) {
$query->orWhere('external_auth_id', 'LIKE', '%' . $groupName . '%');
}
})->get();

$matchedRoles = $roles->filter(function (Role $role) use ($groupNames) {
return $this->roleMatchesGroupNames($role, $groupNames);
});

return $matchedRoles->pluck('id');
}

/**
* Sync the groups to the user roles for the current user
* @param \BookStack\Auth\User $user
* @param array $userGroups
*/
public function syncWithGroups(User $user, array $userGroups)
{
// Get the ids for the roles from the names
$groupsAsRoles = $this->matchGroupsToSystemsRoles($userGroups);

// Sync groups
if ($this->config['remove_from_groups']) {
$user->roles()->sync($groupsAsRoles);
$this->userRepo->attachDefaultRole($user);
} else {
$user->roles()->syncWithoutDetaching($groupsAsRoles);
}
}
}
65 changes: 2 additions & 63 deletions app/Auth/Access/LdapService.php
Original file line number Diff line number Diff line change
@@ -1,19 +1,17 @@
<?php namespace BookStack\Auth\Access;

use BookStack\Auth\Access;
use BookStack\Auth\Role;
use BookStack\Auth\User;
use BookStack\Auth\UserRepo;
use BookStack\Exceptions\LdapException;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Builder;

/**
* Class LdapService
* Handles any app-specific LDAP tasks.
* @package BookStack\Services
*/
class LdapService
class LdapService extends Access\ExternalAuthService
{

protected $ldap;
Expand Down Expand Up @@ -351,65 +349,6 @@ protected function groupFilter(array $userGroupSearchResponse)
public function syncGroups(User $user, string $username)
{
$userLdapGroups = $this->getUserGroups($username);

// Get the ids for the roles from the names
$ldapGroupsAsRoles = $this->matchLdapGroupsToSystemsRoles($userLdapGroups);

// Sync groups
if ($this->config['remove_from_groups']) {
$user->roles()->sync($ldapGroupsAsRoles);
$this->userRepo->attachDefaultRole($user);
} else {
$user->roles()->syncWithoutDetaching($ldapGroupsAsRoles);
}
}

/**
* Match an array of group names from LDAP to BookStack system roles.
* Formats LDAP group names to be lower-case and hyphenated.
* @param array $groupNames
* @return \Illuminate\Support\Collection
*/
protected function matchLdapGroupsToSystemsRoles(array $groupNames)
{
foreach ($groupNames as $i => $groupName) {
$groupNames[$i] = str_replace(' ', '-', trim(strtolower($groupName)));
}

$roles = Role::query()->where(function (Builder $query) use ($groupNames) {
$query->whereIn('name', $groupNames);
foreach ($groupNames as $groupName) {
$query->orWhere('external_auth_id', 'LIKE', '%' . $groupName . '%');
}
})->get();

$matchedRoles = $roles->filter(function (Role $role) use ($groupNames) {
return $this->roleMatchesGroupNames($role, $groupNames);
});

return $matchedRoles->pluck('id');
}

/**
* Check a role against an array of group names to see if it matches.
* Checked against role 'external_auth_id' if set otherwise the name of the role.
* @param \BookStack\Auth\Role $role
* @param array $groupNames
* @return bool
*/
protected function roleMatchesGroupNames(Role $role, array $groupNames)
{
if ($role->external_auth_id) {
$externalAuthIds = explode(',', strtolower($role->external_auth_id));
foreach ($externalAuthIds as $externalAuthId) {
if (in_array(trim($externalAuthId), $groupNames)) {
return true;
}
}
return false;
}

$roleName = str_replace(' ', '-', trim(strtolower($role->display_name)));
return in_array($roleName, $groupNames);
$this->syncWithGroups($user, $userLdapGroups);
}
}
227 changes: 227 additions & 0 deletions app/Auth/Access/Saml2Service.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
<?php namespace BookStack\Auth\Access;

use BookStack\Auth\Access;
use BookStack\Auth\User;
use BookStack\Auth\UserRepo;
use BookStack\Exceptions\SamlException;
use Illuminate\Contracts\Auth\Authenticatable;


/**
* Class Saml2Service
* Handles any app-specific SAML tasks.
* @package BookStack\Services
*/
class Saml2Service extends Access\ExternalAuthService
{
protected $config;
protected $userRepo;
protected $user;
protected $enabled;

/**
* Saml2Service constructor.
* @param \BookStack\Auth\UserRepo $userRepo
*/
public function __construct(UserRepo $userRepo, User $user)
{
$this->config = config('services.saml');
$this->userRepo = $userRepo;
$this->user = $user;
$this->enabled = config('saml2_settings.enabled') === true;
}

/**
* Check if groups should be synced.
* @return bool
*/
public function shouldSyncGroups()
{
return $this->enabled && $this->config['user_to_groups'] !== false;
}

/** Calculate the display name
* @param array $samlAttributes
* @param string $defaultValue
* @return string
*/
protected function getUserDisplayName(array $samlAttributes, string $defaultValue)
{
$displayNameAttr = $this->config['display_name_attribute'];

$displayName = [];
foreach ($displayNameAttr as $dnAttr) {
$dnComponent = $this->getSamlResponseAttribute($samlAttributes, $dnAttr, null);
if ($dnComponent !== null) {
$displayName[] = $dnComponent;
}
}

if (count($displayName) == 0) {
$displayName = $defaultValue;
} else {
$displayName = implode(' ', $displayName);
}

return $displayName;
}

protected function getUserName(array $samlAttributes, string $defaultValue)
{
$userNameAttr = $this->config['user_name_attribute'];

if ($userNameAttr === null) {
$userName = $defaultValue;
} else {
$userName = $this->getSamlResponseAttribute($samlAttributes, $userNameAttr, $defaultValue);
}

return $userName;
}

/**
* Extract the details of a user from a SAML response.
* @param $samlID
* @param $samlAttributes
* @return array
*/
public function getUserDetails($samlID, $samlAttributes)
{
$emailAttr = $this->config['email_attribute'];
$userName = $this->getUserName($samlAttributes, $samlID);

return [
'uid' => $userName,
'name' => $this->getUserDisplayName($samlAttributes, $userName),
'dn' => $samlID,
'email' => $this->getSamlResponseAttribute($samlAttributes, $emailAttr, null),
];
}

/**
* Get the groups a user is a part of from the SAML response.
* @param array $samlAttributes
* @return array
*/
public function getUserGroups($samlAttributes)
{
$groupsAttr = $this->config['group_attribute'];
$userGroups = $samlAttributes[$groupsAttr];

if (!is_array($userGroups)) {
$userGroups = [];
}

return $userGroups;
}

/**
* For an array of strings, return a default for an empty array,
* a string for an array with one element and the full array for
* more than one element.
*
* @param array $data
* @param $defaultValue
* @return string
*/
protected function simplifyValue(array $data, $defaultValue) {
switch (count($data)) {
case 0:
$data = $defaultValue;
break;
case 1:
$data = $data[0];
break;
}
return $data;
}

/**
* Get a property from an SAML response.
* Handles properties potentially being an array.
* @param array $userDetails
* @param string $propertyKey
* @param $defaultValue
* @return mixed
*/
protected function getSamlResponseAttribute(array $samlAttributes, string $propertyKey, $defaultValue)
{
if (isset($samlAttributes[$propertyKey])) {
$data = $this->simplifyValue($samlAttributes[$propertyKey], $defaultValue);
} else {
$data = $defaultValue;
}

return $data;
}

/**
* Register a user that is authenticated but not
* already registered.
* @param array $userDetails
* @return User
*/
protected function registerUser($userDetails)
{
// Create an array of the user data to create a new user instance
$userData = [
'name' => $userDetails['name'],
'email' => $userDetails['email'],
'password' => str_random(30),
'external_auth_id' => $userDetails['uid'],
'email_confirmed' => true,
];

$user = $this->user->forceCreate($userData);
$this->userRepo->attachDefaultRole($user);
$this->userRepo->downloadAndAssignUserAvatar($user);
return $user;
}

/**
* Get the user from the database for the specified details.
* @param array $userDetails
* @return User|null
*/
protected function getOrRegisterUser($userDetails)
{
$isRegisterEnabled = config('services.saml.auto_register') === true;
$user = $this->user
->where('external_auth_id', $userDetails['uid'])
->first();

if ($user === null && $isRegisterEnabled) {
$user = $this->registerUser($userDetails);
}

return $user;
}

/**
* Process the SAML response for a user. Login the user when
* they exist, optionally registering them automatically.
* @param string $samlID
* @param array $samlAttributes
* @throws SamlException
*/
public function processLoginCallback($samlID, $samlAttributes)
{
$userDetails = $this->getUserDetails($samlID, $samlAttributes);
$isLoggedIn = auth()->check();

if ($isLoggedIn) {
throw new SamlException(trans('errors.saml_already_logged_in'), '/login');
} else {
$user = $this->getOrRegisterUser($userDetails);
if ($user === null) {
throw new SamlException(trans('errors.saml_user_not_registered', ['name' => $userDetails['uid']]), '/login');
} else {
$groups = $this->getUserGroups($samlAttributes);
$this->syncWithGroups($user, $groups);
auth()->login($user);
}
}

return $user;
}
}
Loading