Skip to content

Commit d149b80

Browse files
authored
Merge pull request #5626 from BookStackApp/rubentalstra-development
Review of #5429, OIDC avatar fetching
2 parents 454b152 + eb47e11 commit d149b80

File tree

10 files changed

+195
-19
lines changed

10 files changed

+195
-19
lines changed

app/Access/Oidc/OidcService.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use BookStack\Facades\Theme;
1212
use BookStack\Http\HttpRequestService;
1313
use BookStack\Theming\ThemeEvents;
14+
use BookStack\Uploads\UserAvatars;
1415
use BookStack\Users\Models\User;
1516
use Illuminate\Support\Facades\Cache;
1617
use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider;
@@ -26,7 +27,8 @@ public function __construct(
2627
protected RegistrationService $registrationService,
2728
protected LoginService $loginService,
2829
protected HttpRequestService $http,
29-
protected GroupSyncService $groupService
30+
protected GroupSyncService $groupService,
31+
protected UserAvatars $userAvatars
3032
) {
3133
}
3234

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

225+
if ($this->config()['fetch_avatar'] && !$user->avatar()->exists() && $userDetails->picture) {
226+
$this->userAvatars->assignToUserFromUrl($user, $userDetails->picture);
227+
}
228+
223229
if ($this->shouldSyncGroups()) {
224230
$detachExisting = $this->config()['remove_from_groups'];
225231
$this->groupService->syncUserWithFoundGroups($user, $userDetails->groups ?? [], $detachExisting);

app/Access/Oidc/OidcUserDetails.php

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ public function __construct(
1111
public ?string $email = null,
1212
public ?string $name = null,
1313
public ?array $groups = null,
14+
public ?string $picture = null,
1415
) {
1516
}
1617

@@ -40,15 +41,16 @@ public function populate(
4041
$this->email = $claims->getClaim('email') ?? $this->email;
4142
$this->name = static::getUserDisplayName($displayNameClaims, $claims) ?? $this->name;
4243
$this->groups = static::getUserGroups($groupsClaim, $claims) ?? $this->groups;
44+
$this->picture = static::getPicture($claims) ?: $this->picture;
4345
}
4446

45-
protected static function getUserDisplayName(string $displayNameClaims, ProvidesClaims $token): string
47+
protected static function getUserDisplayName(string $displayNameClaims, ProvidesClaims $claims): string
4648
{
4749
$displayNameClaimParts = explode('|', $displayNameClaims);
4850

4951
$displayName = [];
5052
foreach ($displayNameClaimParts as $claim) {
51-
$component = $token->getClaim(trim($claim)) ?? '';
53+
$component = $claims->getClaim(trim($claim)) ?? '';
5254
if ($component !== '') {
5355
$displayName[] = $component;
5456
}
@@ -57,13 +59,13 @@ protected static function getUserDisplayName(string $displayNameClaims, Provides
5759
return implode(' ', $displayName);
5860
}
5961

60-
protected static function getUserGroups(string $groupsClaim, ProvidesClaims $token): ?array
62+
protected static function getUserGroups(string $groupsClaim, ProvidesClaims $claims): ?array
6163
{
6264
if (empty($groupsClaim)) {
6365
return null;
6466
}
6567

66-
$groupsList = Arr::get($token->getAllClaims(), $groupsClaim);
68+
$groupsList = Arr::get($claims->getAllClaims(), $groupsClaim);
6769
if (!is_array($groupsList)) {
6870
return null;
6971
}
@@ -72,4 +74,14 @@ protected static function getUserGroups(string $groupsClaim, ProvidesClaims $tok
7274
return is_string($val);
7375
}));
7476
}
77+
78+
protected static function getPicture(ProvidesClaims $claims): ?string
79+
{
80+
$picture = $claims->getClaim('picture');
81+
if (is_string($picture) && str_starts_with($picture, 'http')) {
82+
return $picture;
83+
}
84+
85+
return null;
86+
}
7587
}

app/Config/oidc.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@
4747
// Multiple values can be provided comma seperated.
4848
'additional_scopes' => env('OIDC_ADDITIONAL_SCOPES', null),
4949

50+
// Enable fetching of the user's avatar from the 'picture' claim on login.
51+
// Will only be fetched if the user doesn't already have an avatar image assigned.
52+
// This can be a security risk due to performing server-side fetching (with up to 3 redirects) of
53+
// data from external URLs. Only enable if you trust the OIDC auth provider to provide safe URLs for user images.
54+
'fetch_avatar' => env('OIDC_FETCH_AVATAR', false),
55+
5056
// Group sync options
5157
// Enable syncing, upon login, of OIDC groups to BookStack roles
5258
'user_to_groups' => env('OIDC_USER_TO_GROUPS', false),

app/Uploads/UserAvatars.php

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use BookStack\Exceptions\HttpFetchException;
66
use BookStack\Http\HttpRequestService;
77
use BookStack\Users\Models\User;
8+
use BookStack\Util\WebSafeMimeSniffer;
89
use Exception;
910
use GuzzleHttp\Psr7\Request;
1011
use Illuminate\Support\Facades\Log;
@@ -53,6 +54,33 @@ public function assignToUserFromExistingData(User $user, string $imageData, stri
5354
}
5455
}
5556

57+
/**
58+
* Assign a new avatar image to the given user by fetching from a remote URL.
59+
*/
60+
public function assignToUserFromUrl(User $user, string $avatarUrl): void
61+
{
62+
try {
63+
$this->destroyAllForUser($user);
64+
$imageData = $this->getAvatarImageData($avatarUrl);
65+
66+
$mime = (new WebSafeMimeSniffer())->sniff($imageData);
67+
[$format, $type] = explode('/', $mime, 2);
68+
if ($format !== 'image' || !ImageService::isExtensionSupported($type)) {
69+
return;
70+
}
71+
72+
$avatar = $this->createAvatarImageFromData($user, $imageData, $type);
73+
$user->avatar()->associate($avatar);
74+
$user->save();
75+
} catch (Exception $e) {
76+
Log::error('Failed to save user avatar image from URL', [
77+
'exception' => $e->getMessage(),
78+
'url' => $avatarUrl,
79+
'user_id' => $user->id,
80+
]);
81+
}
82+
}
83+
5684
/**
5785
* Destroy all user avatars uploaded to the given user.
5886
*/
@@ -105,15 +133,27 @@ protected function createAvatarImageFromData(User $user, string $imageData, stri
105133
}
106134

107135
/**
108-
* Gets an image from url and returns it as a string of image data.
136+
* Get an image from a URL and return it as a string of image data.
109137
*
110138
* @throws HttpFetchException
111139
*/
112140
protected function getAvatarImageData(string $url): string
113141
{
114142
try {
115143
$client = $this->http->buildClient(5);
116-
$response = $client->sendRequest(new Request('GET', $url));
144+
$responseCount = 0;
145+
146+
do {
147+
$response = $client->sendRequest(new Request('GET', $url));
148+
$responseCount++;
149+
$isRedirect = ($response->getStatusCode() === 301 || $response->getStatusCode() === 302);
150+
$url = $response->getHeader('Location')[0] ?? '';
151+
} while ($responseCount < 3 && $isRedirect && is_string($url) && str_starts_with($url, 'http'));
152+
153+
if ($responseCount === 3) {
154+
throw new HttpFetchException("Failed to fetch image, max redirect limit of 3 tries reached. Last fetched URL: {$url}");
155+
}
156+
117157
if ($response->getStatusCode() !== 200) {
118158
throw new HttpFetchException(trans('errors.cannot_get_image_from_url', ['url' => $url]));
119159
}

app/Users/Models/User.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
* @property string $system_name
4646
* @property Collection $roles
4747
* @property Collection $mfaValues
48+
* @property ?Image $avatar
4849
*/
4950
class User extends Model implements AuthenticatableContract, CanResetPasswordContract, Loggable, Sluggable
5051
{

tests/Auth/OidcTest.php

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use BookStack\Activity\ActivityType;
66
use BookStack\Facades\Theme;
77
use BookStack\Theming\ThemeEvents;
8+
use BookStack\Uploads\UserAvatars;
89
use BookStack\Users\Models\Role;
910
use BookStack\Users\Models\User;
1011
use GuzzleHttp\Psr7\Response;
@@ -41,6 +42,7 @@ protected function setUp(): void
4142
'oidc.discover' => false,
4243
'oidc.dump_user_details' => false,
4344
'oidc.additional_scopes' => '',
45+
'odic.fetch_avatar' => false,
4446
'oidc.user_to_groups' => false,
4547
'oidc.groups_claim' => 'group',
4648
'oidc.remove_from_groups' => false,
@@ -457,6 +459,105 @@ public function test_auth_uses_mulitple_display_name_claims_if_configured()
457459
]);
458460
}
459461

462+
public function test_user_avatar_fetched_from_picture_on_first_login_if_enabled()
463+
{
464+
config()->set(['oidc.fetch_avatar' => true]);
465+
466+
$this->runLogin([
467+
'email' => 'avatar@example.com',
468+
'picture' => 'https://example.com/my-avatar.jpg',
469+
], [
470+
new Response(200, ['Content-Type' => 'image/jpeg'], $this->files->jpegImageData())
471+
]);
472+
473+
$user = User::query()->where('email', '=', 'avatar@example.com')->first();
474+
$this->assertNotNull($user);
475+
476+
$this->assertTrue($user->avatar()->exists());
477+
}
478+
479+
public function test_user_avatar_fetched_for_existing_user_when_no_avatar_already_assigned()
480+
{
481+
config()->set(['oidc.fetch_avatar' => true]);
482+
$editor = $this->users->editor();
483+
$editor->external_auth_id = 'benny509';
484+
$editor->save();
485+
486+
$this->assertFalse($editor->avatar()->exists());
487+
488+
$this->runLogin([
489+
'picture' => 'https://example.com/my-avatar.jpg',
490+
'sub' => 'benny509',
491+
], [
492+
new Response(200, ['Content-Type' => 'image/jpeg'], $this->files->jpegImageData())
493+
]);
494+
495+
$editor->refresh();
496+
$this->assertTrue($editor->avatar()->exists());
497+
}
498+
499+
public function test_user_avatar_not_fetched_if_image_data_format_unknown()
500+
{
501+
config()->set(['oidc.fetch_avatar' => true]);
502+
503+
$this->runLogin([
504+
'email' => 'avatar-format@example.com',
505+
'picture' => 'https://example.com/my-avatar.jpg',
506+
], [
507+
new Response(200, ['Content-Type' => 'image/jpeg'], str_repeat('abc123', 5))
508+
]);
509+
510+
$user = User::query()->where('email', '=', 'avatar-format@example.com')->first();
511+
$this->assertNotNull($user);
512+
513+
$this->assertFalse($user->avatar()->exists());
514+
}
515+
516+
public function test_user_avatar_not_fetched_when_avatar_already_assigned()
517+
{
518+
config()->set(['oidc.fetch_avatar' => true]);
519+
$editor = $this->users->editor();
520+
$editor->external_auth_id = 'benny509';
521+
$editor->save();
522+
523+
$avatars = $this->app->make(UserAvatars::class);
524+
$originalImageData = $this->files->pngImageData();
525+
$avatars->assignToUserFromExistingData($editor, $originalImageData, 'png');
526+
527+
$this->runLogin([
528+
'picture' => 'https://example.com/my-avatar.jpg',
529+
'sub' => 'benny509',
530+
], [
531+
new Response(200, ['Content-Type' => 'image/jpeg'], $this->files->jpegImageData())
532+
]);
533+
534+
$editor->refresh();
535+
$newAvatarData = file_get_contents($this->files->relativeToFullPath($editor->avatar->path));
536+
$this->assertEquals($originalImageData, $newAvatarData);
537+
}
538+
539+
public function test_user_avatar_fetch_follows_up_to_three_redirects()
540+
{
541+
config()->set(['oidc.fetch_avatar' => true]);
542+
543+
$logger = $this->withTestLogger();
544+
545+
$this->runLogin([
546+
'email' => 'avatar@example.com',
547+
'picture' => 'https://example.com/my-avatar.jpg',
548+
], [
549+
new Response(302, ['Location' => 'https://example.com/a']),
550+
new Response(302, ['Location' => 'https://example.com/b']),
551+
new Response(302, ['Location' => 'https://example.com/c']),
552+
new Response(302, ['Location' => 'https://example.com/d']),
553+
]);
554+
555+
$user = User::query()->where('email', '=', 'avatar@example.com')->first();
556+
$this->assertFalse($user->avatar()->exists());
557+
558+
$this->assertStringContainsString('"Failed to fetch image, max redirect limit of 3 tries reached. Last fetched URL: https://example.com/c"', $logger->getRecords()[0]->formatted);
559+
}
560+
460561
public function test_login_group_sync()
461562
{
462563
config()->set([

tests/Helpers/FileProvider.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,14 @@ public function pngImageData(): string
6060
return file_get_contents($this->testFilePath('test-image.png'));
6161
}
6262

63+
/**
64+
* Get raw data for a Jpeg image test file.
65+
*/
66+
public function jpegImageData(): string
67+
{
68+
return file_get_contents($this->testFilePath('test-image.jpg'));
69+
}
70+
6371
/**
6472
* Get the expected relative path for an uploaded image of the given type and filename.
6573
*/

tests/Permissions/EntityPermissionsTest.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ public function test_book_update_restriction()
184184

185185
$this->get($bookUrl . '/edit')->assertRedirect('/');
186186
$this->get('/')->assertSee('You do not have permission');
187-
$this->get($bookPage->getUrl() . '/edit')->assertRedirect('/');
187+
$this->get($bookPage->getUrl() . '/edit')->assertRedirect($bookPage->getUrl());
188188
$this->get('/')->assertSee('You do not have permission');
189189
$this->get($bookChapter->getUrl() . '/edit')->assertRedirect('/');
190190
$this->get('/')->assertSee('You do not have permission');
@@ -282,7 +282,7 @@ public function test_chapter_update_restriction()
282282

283283
$this->get($chapterUrl . '/edit')->assertRedirect('/');
284284
$this->get('/')->assertSee('You do not have permission');
285-
$this->get($chapterPage->getUrl() . '/edit')->assertRedirect('/');
285+
$this->get($chapterPage->getUrl() . '/edit')->assertRedirect($chapterPage->getUrl());
286286
$this->get('/')->assertSee('You do not have permission');
287287

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

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

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

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

566566
$this->get($bookUrl . '/edit')->assertRedirect('/');
567567
$this->get('/')->assertSee('You do not have permission');
568-
$this->get($bookPage->getUrl() . '/edit')->assertRedirect('/');
568+
$this->get($bookPage->getUrl() . '/edit')->assertRedirect($bookPage->getUrl());
569569
$this->get('/')->assertSee('You do not have permission');
570570
$this->get($bookChapter->getUrl() . '/edit')->assertRedirect('/');
571571
$this->get('/')->assertSee('You do not have permission');

0 commit comments

Comments
 (0)