]> BookStack Code Mirror - bookstack/commitdiff
Add optional OIDC avatar fetching from the “picture” claim 5429/head
authorTalstra Ruben SRSNL <redacted>
Mon, 20 Jan 2025 16:21:46 +0000 (17:21 +0100)
committerTalstra Ruben SRSNL <redacted>
Mon, 20 Jan 2025 16:21:46 +0000 (17:21 +0100)
app/Access/Oidc/OidcService.php
app/Access/Oidc/OidcUserDetails.php
app/Config/oidc.php
app/Uploads/UserAvatars.php

index 7c1760649b5bb5bfc8600cb38fe56fe98282df18..660885e8b2a4f30b0c2db8401efbd8a8655d6624 100644 (file)
@@ -11,6 +11,7 @@ use BookStack\Exceptions\UserRegistrationException;
 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 @@ class OidcService
         protected RegistrationService $registrationService,
         protected LoginService $loginService,
         protected HttpRequestService $http,
-        protected GroupSyncService $groupService
+        protected GroupSyncService $groupService,
+        protected UserAvatars $userAvatars
     ) {
     }
 
@@ -227,6 +229,10 @@ class OidcService
 
         $this->loginService->login($user, 'oidc');
 
+        if ($this->config()['fetch_avatars'] && $userDetails->picture) {
+            $this->userAvatars->assignToUserFromUrl($user, $userDetails->picture, $accessToken->getToken());
+        }
+
         return $user;
     }
 
index fae20de0b6224cb011807017aa6442c56179e4e8..10595d1e0ddef9e1a2a7bd17b719810b74001a50 100644 (file)
@@ -11,6 +11,7 @@ class OidcUserDetails
         public ?string $email = null,
         public ?string $name = null,
         public ?array $groups = null,
+        public ?string $picture = null,
     ) {
     }
 
@@ -40,6 +41,7 @@ class OidcUserDetails
         $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
index 8b5470931d08379431c8f3b420f02aa7356c87b1..62f19a119c04bc222d30052a242e5adb2fbcc180 100644 (file)
@@ -54,4 +54,7 @@ return [
     '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),
 ];
index c623247352b17234cd60168fb7686f19375478bc..af91dfe70f146236912948887e9dfff598a79053 100644 (file)
@@ -53,6 +53,31 @@ class UserAvatars
         }
     }
 
+    /**
+     * 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 @@ class UserAvatars
     }
 
     /**
-     * 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]));
             }