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
8 changes: 7 additions & 1 deletion app/Access/Oidc/OidcService.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use BookStack\Facades\Theme;
use BookStack\Http\HttpRequestService;
use BookStack\Theming\ThemeEvents;
use BookStack\Uploads\UserAvatars;
use BookStack\Users\Models\User;
use Illuminate\Support\Facades\Cache;
use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider;
Expand All @@ -26,7 +27,8 @@ public function __construct(
protected RegistrationService $registrationService,
protected LoginService $loginService,
protected HttpRequestService $http,
protected GroupSyncService $groupService
protected GroupSyncService $groupService,
protected UserAvatars $userAvatars
) {
}

Expand Down Expand Up @@ -220,6 +222,10 @@ protected function processAccessTokenCallback(OidcAccessToken $accessToken, Oidc
throw new OidcException($exception->getMessage());
}

if ($this->config()['fetch_avatar'] && !$user->avatar()->exists() && $userDetails->picture) {
$this->userAvatars->assignToUserFromUrl($user, $userDetails->picture);
}

if ($this->shouldSyncGroups()) {
$detachExisting = $this->config()['remove_from_groups'];
$this->groupService->syncUserWithFoundGroups($user, $userDetails->groups ?? [], $detachExisting);
Expand Down
20 changes: 16 additions & 4 deletions app/Access/Oidc/OidcUserDetails.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ public function __construct(
public ?string $email = null,
public ?string $name = null,
public ?array $groups = null,
public ?string $picture = null,
) {
}

Expand Down Expand Up @@ -40,15 +41,16 @@ public function populate(
$this->email = $claims->getClaim('email') ?? $this->email;
$this->name = static::getUserDisplayName($displayNameClaims, $claims) ?? $this->name;
$this->groups = static::getUserGroups($groupsClaim, $claims) ?? $this->groups;
$this->picture = static::getPicture($claims) ?: $this->picture;
}

protected static function getUserDisplayName(string $displayNameClaims, ProvidesClaims $token): string
protected static function getUserDisplayName(string $displayNameClaims, ProvidesClaims $claims): string
{
$displayNameClaimParts = explode('|', $displayNameClaims);

$displayName = [];
foreach ($displayNameClaimParts as $claim) {
$component = $token->getClaim(trim($claim)) ?? '';
$component = $claims->getClaim(trim($claim)) ?? '';
if ($component !== '') {
$displayName[] = $component;
}
Expand All @@ -57,13 +59,13 @@ protected static function getUserDisplayName(string $displayNameClaims, Provides
return implode(' ', $displayName);
}

protected static function getUserGroups(string $groupsClaim, ProvidesClaims $token): ?array
protected static function getUserGroups(string $groupsClaim, ProvidesClaims $claims): ?array
{
if (empty($groupsClaim)) {
return null;
}

$groupsList = Arr::get($token->getAllClaims(), $groupsClaim);
$groupsList = Arr::get($claims->getAllClaims(), $groupsClaim);
if (!is_array($groupsList)) {
return null;
}
Expand All @@ -72,4 +74,14 @@ protected static function getUserGroups(string $groupsClaim, ProvidesClaims $tok
return is_string($val);
}));
}

protected static function getPicture(ProvidesClaims $claims): ?string
{
$picture = $claims->getClaim('picture');
if (is_string($picture) && str_starts_with($picture, 'http')) {
return $picture;
}

return null;
}
}
6 changes: 6 additions & 0 deletions app/Config/oidc.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@
// Multiple values can be provided comma seperated.
'additional_scopes' => env('OIDC_ADDITIONAL_SCOPES', null),

// Enable fetching of the user's avatar from the 'picture' claim on login.
// Will only be fetched if the user doesn't already have an avatar image assigned.
// This can be a security risk due to performing server-side fetching (with up to 3 redirects) of
// data from external URLs. Only enable if you trust the OIDC auth provider to provide safe URLs for user images.
'fetch_avatar' => env('OIDC_FETCH_AVATAR', false),

// Group sync options
// Enable syncing, upon login, of OIDC groups to BookStack roles
'user_to_groups' => env('OIDC_USER_TO_GROUPS', false),
Expand Down
44 changes: 42 additions & 2 deletions app/Uploads/UserAvatars.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use BookStack\Exceptions\HttpFetchException;
use BookStack\Http\HttpRequestService;
use BookStack\Users\Models\User;
use BookStack\Util\WebSafeMimeSniffer;
use Exception;
use GuzzleHttp\Psr7\Request;
use Illuminate\Support\Facades\Log;
Expand Down Expand Up @@ -53,6 +54,33 @@ public function assignToUserFromExistingData(User $user, string $imageData, stri
}
}

/**
* Assign a new avatar image to the given user by fetching from a remote URL.
*/
public function assignToUserFromUrl(User $user, string $avatarUrl): void
{
try {
$this->destroyAllForUser($user);
$imageData = $this->getAvatarImageData($avatarUrl);

$mime = (new WebSafeMimeSniffer())->sniff($imageData);
[$format, $type] = explode('/', $mime, 2);
if ($format !== 'image' || !ImageService::isExtensionSupported($type)) {
return;
}

$avatar = $this->createAvatarImageFromData($user, $imageData, $type);
$user->avatar()->associate($avatar);
$user->save();
} catch (Exception $e) {
Log::error('Failed to save user avatar image from URL', [
'exception' => $e->getMessage(),
'url' => $avatarUrl,
'user_id' => $user->id,
]);
}
}

/**
* Destroy all user avatars uploaded to the given user.
*/
Expand Down Expand Up @@ -105,15 +133,27 @@ protected function createAvatarImageFromData(User $user, string $imageData, stri
}

/**
* Gets an image from url and returns it as a string of image data.
* Get an image from a URL and return it as a string of image data.
*
* @throws HttpFetchException
*/
protected function getAvatarImageData(string $url): string
{
try {
$client = $this->http->buildClient(5);
$response = $client->sendRequest(new Request('GET', $url));
$responseCount = 0;

do {
$response = $client->sendRequest(new Request('GET', $url));
$responseCount++;
$isRedirect = ($response->getStatusCode() === 301 || $response->getStatusCode() === 302);
$url = $response->getHeader('Location')[0] ?? '';
} while ($responseCount < 3 && $isRedirect && is_string($url) && str_starts_with($url, 'http'));

if ($responseCount === 3) {
throw new HttpFetchException("Failed to fetch image, max redirect limit of 3 tries reached. Last fetched URL: {$url}");
}

if ($response->getStatusCode() !== 200) {
throw new HttpFetchException(trans('errors.cannot_get_image_from_url', ['url' => $url]));
}
Expand Down
1 change: 1 addition & 0 deletions app/Users/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
* @property string $system_name
* @property Collection $roles
* @property Collection $mfaValues
* @property ?Image $avatar
*/
class User extends Model implements AuthenticatableContract, CanResetPasswordContract, Loggable, Sluggable
{
Expand Down
101 changes: 101 additions & 0 deletions tests/Auth/OidcTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use BookStack\Activity\ActivityType;
use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents;
use BookStack\Uploads\UserAvatars;
use BookStack\Users\Models\Role;
use BookStack\Users\Models\User;
use GuzzleHttp\Psr7\Response;
Expand Down Expand Up @@ -41,6 +42,7 @@ protected function setUp(): void
'oidc.discover' => false,
'oidc.dump_user_details' => false,
'oidc.additional_scopes' => '',
'odic.fetch_avatar' => false,
'oidc.user_to_groups' => false,
'oidc.groups_claim' => 'group',
'oidc.remove_from_groups' => false,
Expand Down Expand Up @@ -457,6 +459,105 @@ public function test_auth_uses_mulitple_display_name_claims_if_configured()
]);
}

public function test_user_avatar_fetched_from_picture_on_first_login_if_enabled()
{
config()->set(['oidc.fetch_avatar' => true]);

$this->runLogin([
'email' => 'avatar@example.com',
'picture' => 'https://example.com/my-avatar.jpg',
], [
new Response(200, ['Content-Type' => 'image/jpeg'], $this->files->jpegImageData())
]);

$user = User::query()->where('email', '=', 'avatar@example.com')->first();
$this->assertNotNull($user);

$this->assertTrue($user->avatar()->exists());
}

public function test_user_avatar_fetched_for_existing_user_when_no_avatar_already_assigned()
{
config()->set(['oidc.fetch_avatar' => true]);
$editor = $this->users->editor();
$editor->external_auth_id = 'benny509';
$editor->save();

$this->assertFalse($editor->avatar()->exists());

$this->runLogin([
'picture' => 'https://example.com/my-avatar.jpg',
'sub' => 'benny509',
], [
new Response(200, ['Content-Type' => 'image/jpeg'], $this->files->jpegImageData())
]);

$editor->refresh();
$this->assertTrue($editor->avatar()->exists());
}

public function test_user_avatar_not_fetched_if_image_data_format_unknown()
{
config()->set(['oidc.fetch_avatar' => true]);

$this->runLogin([
'email' => 'avatar-format@example.com',
'picture' => 'https://example.com/my-avatar.jpg',
], [
new Response(200, ['Content-Type' => 'image/jpeg'], str_repeat('abc123', 5))
]);

$user = User::query()->where('email', '=', 'avatar-format@example.com')->first();
$this->assertNotNull($user);

$this->assertFalse($user->avatar()->exists());
}

public function test_user_avatar_not_fetched_when_avatar_already_assigned()
{
config()->set(['oidc.fetch_avatar' => true]);
$editor = $this->users->editor();
$editor->external_auth_id = 'benny509';
$editor->save();

$avatars = $this->app->make(UserAvatars::class);
$originalImageData = $this->files->pngImageData();
$avatars->assignToUserFromExistingData($editor, $originalImageData, 'png');

$this->runLogin([
'picture' => 'https://example.com/my-avatar.jpg',
'sub' => 'benny509',
], [
new Response(200, ['Content-Type' => 'image/jpeg'], $this->files->jpegImageData())
]);

$editor->refresh();
$newAvatarData = file_get_contents($this->files->relativeToFullPath($editor->avatar->path));
$this->assertEquals($originalImageData, $newAvatarData);
}

public function test_user_avatar_fetch_follows_up_to_three_redirects()
{
config()->set(['oidc.fetch_avatar' => true]);

$logger = $this->withTestLogger();

$this->runLogin([
'email' => 'avatar@example.com',
'picture' => 'https://example.com/my-avatar.jpg',
], [
new Response(302, ['Location' => 'https://example.com/a']),
new Response(302, ['Location' => 'https://example.com/b']),
new Response(302, ['Location' => 'https://example.com/c']),
new Response(302, ['Location' => 'https://example.com/d']),
]);

$user = User::query()->where('email', '=', 'avatar@example.com')->first();
$this->assertFalse($user->avatar()->exists());

$this->assertStringContainsString('"Failed to fetch image, max redirect limit of 3 tries reached. Last fetched URL: https://example.com/c"', $logger->getRecords()[0]->formatted);
}

public function test_login_group_sync()
{
config()->set([
Expand Down
8 changes: 8 additions & 0 deletions tests/Helpers/FileProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,14 @@ public function pngImageData(): string
return file_get_contents($this->testFilePath('test-image.png'));
}

/**
* Get raw data for a Jpeg image test file.
*/
public function jpegImageData(): string
{
return file_get_contents($this->testFilePath('test-image.jpg'));
}

/**
* Get the expected relative path for an uploaded image of the given type and filename.
*/
Expand Down
8 changes: 4 additions & 4 deletions tests/Permissions/EntityPermissionsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ public function test_book_update_restriction()

$this->get($bookUrl . '/edit')->assertRedirect('/');
$this->get('/')->assertSee('You do not have permission');
$this->get($bookPage->getUrl() . '/edit')->assertRedirect('/');
$this->get($bookPage->getUrl() . '/edit')->assertRedirect($bookPage->getUrl());
$this->get('/')->assertSee('You do not have permission');
$this->get($bookChapter->getUrl() . '/edit')->assertRedirect('/');
$this->get('/')->assertSee('You do not have permission');
Expand Down Expand Up @@ -282,7 +282,7 @@ public function test_chapter_update_restriction()

$this->get($chapterUrl . '/edit')->assertRedirect('/');
$this->get('/')->assertSee('You do not have permission');
$this->get($chapterPage->getUrl() . '/edit')->assertRedirect('/');
$this->get($chapterPage->getUrl() . '/edit')->assertRedirect($chapterPage->getUrl());
$this->get('/')->assertSee('You do not have permission');

$this->setRestrictionsForTestRoles($chapter, ['view', 'update']);
Expand Down Expand Up @@ -341,7 +341,7 @@ public function test_page_update_restriction()

$this->setRestrictionsForTestRoles($page, ['view', 'delete']);

$this->get($pageUrl . '/edit')->assertRedirect('/');
$this->get($pageUrl . '/edit')->assertRedirect($pageUrl);
$this->get('/')->assertSee('You do not have permission');

$this->setRestrictionsForTestRoles($page, ['view', 'update']);
Expand Down Expand Up @@ -565,7 +565,7 @@ public function test_book_update_restriction_override()

$this->get($bookUrl . '/edit')->assertRedirect('/');
$this->get('/')->assertSee('You do not have permission');
$this->get($bookPage->getUrl() . '/edit')->assertRedirect('/');
$this->get($bookPage->getUrl() . '/edit')->assertRedirect($bookPage->getUrl());
$this->get('/')->assertSee('You do not have permission');
$this->get($bookChapter->getUrl() . '/edit')->assertRedirect('/');
$this->get('/')->assertSee('You do not have permission');
Expand Down
Loading