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;
protected RegistrationService $registrationService,
protected LoginService $loginService,
protected HttpRequestService $http,
- protected GroupSyncService $groupService
+ protected GroupSyncService $groupService,
+ protected UserAvatars $userAvatars
) {
}
$this->loginService->login($user, 'oidc');
+ if ($this->config()['fetch_avatars'] && $userDetails->picture) {
+ $this->userAvatars->assignToUserFromUrl($user, $userDetails->picture, $accessToken->getToken());
+ }
+
return $user;
}
public ?string $email = null,
public ?string $name = null,
public ?array $groups = null,
+ public ?string $picture = null,
) {
}
$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
'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),
];
}
}
+ /**
+ * 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.
*/
}
/**
- * 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]));
}