diff --git a/app/Access/Oidc/OidcService.php b/app/Access/Oidc/OidcService.php index 7c1760649b5..660885e8b2a 100644 --- a/app/Access/Oidc/OidcService.php +++ b/app/Access/Oidc/OidcService.php @@ -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; @@ -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 ) { } @@ -227,6 +229,10 @@ protected function processAccessTokenCallback(OidcAccessToken $accessToken, Oidc $this->loginService->login($user, 'oidc'); + if ($this->config()['fetch_avatars'] && $userDetails->picture) { + $this->userAvatars->assignToUserFromUrl($user, $userDetails->picture, $accessToken->getToken()); + } + return $user; } diff --git a/app/Access/Oidc/OidcUserDetails.php b/app/Access/Oidc/OidcUserDetails.php index fae20de0b62..10595d1e0dd 100644 --- a/app/Access/Oidc/OidcUserDetails.php +++ b/app/Access/Oidc/OidcUserDetails.php @@ -11,6 +11,7 @@ public function __construct( public ?string $email = null, public ?string $name = null, public ?array $groups = null, + public ?string $picture = null, ) { } @@ -40,6 +41,7 @@ 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 = $claims->getClaim('picture') ?: $this->picture; } protected static function getUserDisplayName(string $displayNameClaims, ProvidesClaims $token): string diff --git a/app/Config/oidc.php b/app/Config/oidc.php index 8b5470931d0..62f19a119c0 100644 --- a/app/Config/oidc.php +++ b/app/Config/oidc.php @@ -54,4 +54,7 @@ 'groups_claim' => env('OIDC_GROUPS_CLAIM', 'groups'), // When syncing groups, remove any groups that no longer match. Otherwise, sync only adds new groups. 'remove_from_groups' => env('OIDC_REMOVE_FROM_GROUPS', false), + + // When enabled, BookStack will fetch the user’s avatar from the 'picture' claim (SSRF risk if URLs are untrusted). + 'fetch_avatars' => env('OIDC_FETCH_AVATARS', false), ]; diff --git a/app/Uploads/UserAvatars.php b/app/Uploads/UserAvatars.php index c623247352b..af91dfe70f1 100644 --- a/app/Uploads/UserAvatars.php +++ b/app/Uploads/UserAvatars.php @@ -53,6 +53,31 @@ 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, ?string $accessToken = null): void + { + // Quickly skip invalid or non-HTTP URLs + if (!$avatarUrl || !str_starts_with($avatarUrl, 'http')) { + return; + } + + try { + $this->destroyAllForUser($user); + $imageData = $this->getAvatarImageData($avatarUrl, $accessToken); + $avatar = $this->createAvatarImageFromData($user, $imageData, 'png'); + $user->avatar()->associate($avatar); + $user->save(); + } catch (Exception $e) { + Log::error('Failed to save user avatar image from URL', [ + 'exception' => $e, + 'url' => $avatarUrl, + 'user_id' => $user->id, + ]); + } + } + /** * Destroy all user avatars uploaded to the given user. */ @@ -105,15 +130,21 @@ protected function createAvatarImageFromData(User $user, string $imageData, stri } /** - * Gets an image from url and returns it as a string of image data. + * Gets an image from a URL (public or private) and returns it as a string of image data. * * @throws HttpFetchException */ - protected function getAvatarImageData(string $url): string + protected function getAvatarImageData(string $url, ?string $accessToken = null): string { try { + $headers = []; + if (!empty($accessToken)) { + $headers['Authorization'] = 'Bearer ' . $accessToken; + } + $client = $this->http->buildClient(5); - $response = $client->sendRequest(new Request('GET', $url)); + $response = $client->sendRequest(new Request('GET', $url, $headers)); + if ($response->getStatusCode() !== 200) { throw new HttpFetchException(trans('errors.cannot_get_image_from_url', ['url' => $url])); }