OIDC: Support picture claim for use as user avatar #3824

Closed
opened 2026-02-05 07:35:09 +03:00 by OVERLORD · 9 comments
Owner

Originally created by @Ghost-chu on GitHub (May 26, 2023).

Describe the Bug

Although the OIDC response contains the picture field, the Bookstack still use default user avatar.

{
  "sub": "<censored>",
  "iss": "<censored>",
  "aud": "7acd8e81792f80dc48e9",
  "preferred_username": "<censored>",
  "name": "<censored>",
  "email": "<censored>",
  "picture": "https://cdn.<censored>/casdoor/avatar/<censored>/Ghost_chu.png?t=1685018195637388715"
}

Steps to Reproduce

  1. Setup the OIDC for Bookstack
  2. Create a new user and upload a avatar from your OIDC provider management
  3. Login to Bookstack
  4. Bookstack use default blue avatar as new user default avatar

Expected Behaviour

Bookstack should use the avatar from OIDC response instead the default avatar

Screenshots or Additional Context

No response

Browser Details

Brave 1.51.118 Chromium: 113.0.5672.126(Release) (64 bit)

Exact BookStack Version

v23.05.2

PHP Version

No response

Hosting Environment

debian-11.7 - Bookstack Docker Image by LinuxServer

      - AUTH_METHOD=oidc
      - AUTH_AUTO_INITIATE=true
      - OIDC_NAME=<censored>
      - OIDC_DISPLAY_NAME_CLAIMS=name
      - OIDC_CLIENT_ID=<censored>
      - OIDC_CLIENT_SECRET=<censored>
      - OIDC_ISSUER=<censored>
      - OIDC_ISSUER_DISCOVER=true
Originally created by @Ghost-chu on GitHub (May 26, 2023). ### Describe the Bug Although the OIDC response contains the `picture` field, the Bookstack still use default user avatar. ```json { "sub": "<censored>", "iss": "<censored>", "aud": "7acd8e81792f80dc48e9", "preferred_username": "<censored>", "name": "<censored>", "email": "<censored>", "picture": "https://cdn.<censored>/casdoor/avatar/<censored>/Ghost_chu.png?t=1685018195637388715" } ``` ### Steps to Reproduce 1. Setup the OIDC for Bookstack 2. Create a new user and upload a avatar from your OIDC provider management 3. Login to Bookstack 4. Bookstack use default blue avatar as new user default avatar ### Expected Behaviour Bookstack should use the avatar from OIDC response instead the default avatar ### Screenshots or Additional Context _No response_ ### Browser Details Brave 1.51.118 Chromium: 113.0.5672.126(Release) (64 bit) ### Exact BookStack Version v23.05.2 ### PHP Version _No response_ ### Hosting Environment debian-11.7 - Bookstack Docker Image by LinuxServer ``` - AUTH_METHOD=oidc - AUTH_AUTO_INITIATE=true - OIDC_NAME=<censored> - OIDC_DISPLAY_NAME_CLAIMS=name - OIDC_CLIENT_ID=<censored> - OIDC_CLIENT_SECRET=<censored> - OIDC_ISSUER=<censored> - OIDC_ISSUER_DISCOVER=true ```
OVERLORD added the 🔨 Feature Request🚪 Authentication labels 2026-02-05 07:35:09 +03:00
Author
Owner

@ssddanbrown commented on GitHub (May 26, 2023):

Thanks for raising, but I have recategorised this as a feature request, and updated the title to suit, since this is not a break in existing logic. We've just never specifically supported user avatars via the picture claim.

@ssddanbrown commented on GitHub (May 26, 2023): Thanks for raising, but I have recategorised this as a feature request, and updated the title to suit, since this is not a break in existing logic. We've just never specifically supported user avatars via the picture claim.
Author
Owner

@cal940 commented on GitHub (Feb 3, 2024):

hello, is there any new progress on this issue?

would be nice to see this feature in the following releases.

@cal940 commented on GitHub (Feb 3, 2024): hello, is there any new progress on this issue? would be nice to see this feature in the following releases.
Author
Owner

@jasonpincin commented on GitHub (Aug 24, 2024):

Plus one on this one, fwiw.

@jasonpincin commented on GitHub (Aug 24, 2024): Plus one on this one, fwiw.
Author
Owner

@rubentalstra commented on GitHub (Jan 20, 2025):

I’ve opened a merge request (#5429) adding optional support for fetching user avatars from the OIDC picture claim. It reuses our existing avatar logic so there’s no major code duplication. A new oidc.fetch_avatars config option must be enabled for this feature to take effect. Feedback and testing are welcome!

@rubentalstra commented on GitHub (Jan 20, 2025): I’ve opened a merge request (#5429) adding optional support for fetching user avatars from the OIDC ```picture``` claim. It reuses our existing avatar logic so there’s no major code duplication. A new ```oidc.fetch_avatars``` config option must be enabled for this feature to take effect. Feedback and testing are welcome!
Author
Owner

@ssddanbrown commented on GitHub (May 25, 2025):

This has now been added via #5429 and #5626.
Thanks to @rubentalstra for providing an implementation for this.
Thanks @Ghost-chu for the original request.
This will be part of the next feature release.

It will be disabled by default, but enabled with a .env option.
Just to confirm though, this does not assure it will support all auth providers. The exact details of using the picture claim are not too detailed in the spec, and it looks like auth providers like to do awkward things.
BookStack will fetch an image at the picture claim, following up to 3 GET redirects, and the image provided will need to be one of BookStack's accepted image formats (png, webp, avif, gif, bmp).
Any platforms acting outside of that (For example Azure which seems to need credentials) will be outside the scope of what we support, at least in this revision, although it may be possible to use the logical theme system to implement custom workarounds.
Also, this will fetch and assign the avatar image at every login, where the user does not have an avatar already assigned (either manually configured in platform or via a prior fetch). This aligns with our logic for LDAP.

@ssddanbrown commented on GitHub (May 25, 2025): This has now been added via #5429 and #5626. Thanks to @rubentalstra for providing an implementation for this. Thanks @Ghost-chu for the original request. This will be part of the next feature release. It will be disabled by default, but enabled with a `.env` option. Just to confirm though, this does not assure it will support all auth providers. The exact details of using the `picture` claim are not too detailed in the spec, and it looks like auth providers like to do awkward things. BookStack will fetch an image at the `picture` claim, following up to 3 GET redirects, and the image provided will need to be one of BookStack's accepted image formats (png, webp, avif, gif, bmp). Any platforms acting outside of that (For example Azure which seems to need credentials) will be outside the scope of what we support, at least in this revision, although it may be possible to use the [logical theme system](https://github.com/BookStackApp/BookStack/blob/development/dev/docs/logical-theme-system.md) to implement custom workarounds. Also, this will fetch and assign the avatar image at every login, where the user does not have an avatar already assigned (either manually configured in platform or via a prior fetch). This aligns with our logic for LDAP.
Author
Owner

@Tomblarom commented on GitHub (Jul 31, 2025):

@ssddanbrown is there a workaround for Azure? We are using OIDC, which works perfectly to automatically log in, fetch the username/email and assign the matching roles based on their groups. But avatar/picture are not being fetched.. Is it possibe to pull it from LDAP instead? Authentification with LDAP works and fetches the picture, but lacks the auto-login-feature, which is why we switched to OIDC..

@Tomblarom commented on GitHub (Jul 31, 2025): @ssddanbrown is there a workaround for Azure? We are using OIDC, which works perfectly to automatically log in, fetch the username/email and assign the matching roles based on their groups. But avatar/picture are not being fetched.. Is it possibe to pull it from LDAP instead? Authentification with LDAP works and fetches the picture, but lacks the auto-login-feature, which is why we switched to OIDC..
Author
Owner

@ssddanbrown commented on GitHub (Jul 31, 2025):

@Tomblarom No workaround as of yet, although it should technically be possible to create one with the logical theme system, although the process might be a bit akward. We provide a OIDC specific event you can hook into, which provides the auth details during the OIDC process. You could use that to get the image, put it into accessible web space, then set an avatar URL in the OIDC data for the image to point to that image in accessible web space. Probably many other way to go about it, but using the provided OIDC logical theme event is key to having the correct auth.

LDAP systems are quite different, and I'm not sure about shared auth between OIDC/LDAP for Azure. Would probably be more faff to attempt I reckon.

@ssddanbrown commented on GitHub (Jul 31, 2025): @Tomblarom No workaround as of yet, although it should technically be possible to create one with the logical theme system, although the process might be a bit akward. We provide a OIDC specific event you can hook into, which provides the auth details during the OIDC process. You could use that to get the image, put it into accessible web space, then set an avatar URL in the OIDC data for the image to point to that image in accessible web space. Probably many other way to go about it, but using the provided OIDC logical theme event is key to having the correct auth. LDAP systems are quite different, and I'm not sure about shared auth between OIDC/LDAP for Azure. Would probably be more faff to attempt I reckon.
Author
Owner

@Tomblarom commented on GitHub (Oct 16, 2025):

@ssddanbrown so I hacked together my own solution and it seems to do exactly what I want. :) Since I used ChatGPT I would highly recommend to review and refactor the code, before potentially publishing as an unofficial hack. 😉


Workaround for retrieving picture when using OIDC

Caution

For each new user, a separate LDAP-connection is established! If too many users register at once, this might lead to latency or even downtime of your LDAP-servers.

As primary authentication provider, I used AUTH_METHOD=oidc (setup: video, docs). Additionally I repurposed the variables from the LDAP-authentication: LDAP_SERVER, LDAP_BASE_DN, LDAP_DN, LDAP_PASS, LDAP_THUMBNAIL_ATTRIBUTE, LDAP_USER_FILTER (optional) and LDAP_START_TLS (optional).

The script utilizes the suggested logical theme system and hooks into AUTH_LOGIN / AUTH_REGISTER. Upon each login/registration it checks, if the picture for that user already exists. If not, it established a LDAP-connection, pulls the picture, saves it to bookstack/app/www/uploads/images/user/YYYY-MM/ and links it to the present user.

In order to retrieve and update the pictures of existing users, there is an artisan command update-avatars. This command has not been tested by me, but you definitely get the idea.

The script itself lives in bookstack/app/www/themes/oidc-avatar-from-ldap/functions.php and needs to be registered by adding APP_THEME=oidc-avatar-from-ldap.

functions.php
<?php
use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents;
use BookStack\Users\Models\User;
use BookStack\Uploads\Image;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;

/**
 * BookStack Theme: OIDC Avatar from LDAP
 * 
 * Fetches profile pictures from LDAP after OIDC login,
 * since OIDC from Azure AD does not deliver avatars.
 */

// After successful OIDC login
Theme::listen(ThemeEvents::AUTH_LOGIN, function($method, $user) {
    if ($method !== 'oidc') return;
    Log::info("OIDC login detected for user: {$user->email}");
    // Only update avatar if not already set
    if (empty($user->image_id)) {
        fetchAvatarFromLDAP($user);
    } else {
        Log::info("User {$user->email} already has a profile image set. Skipping avatar update.");
    }
});

// After OIDC registration
Theme::listen(ThemeEvents::AUTH_REGISTER, function($method, $user) {
    if ($method !== 'oidc') return;
    Log::info("OIDC registration detected for user: {$user->email}");
    // Only update avatar if not already set
    if (empty($user->image_id)) {
        fetchAvatarFromLDAP($user);
    } else {
        Log::info("User {$user->email} already has a profile image set. Skipping avatar update.");
    }
});

/**
 * Fetches a user's profile picture from LDAP.
 */
function fetchAvatarFromLDAP($user) {
    $ldapServer = env('LDAP_SERVER', 'domain.ch:389');
    $ldapBaseDn = env('LDAP_BASE_DN', 'DC=domain,DC=ch');
    $ldapBindUser = env('LDAP_DN', 'ldap_user');
    $ldapBindPassword = env('LDAP_PASS', 'ldap_pass');
    $ldapThumbnailAttribute = env('LDAP_THUMBNAIL_ATTRIBUTE', 'thumbnailPhoto');
    $ldapUserFilter = env('LDAP_USER_FILTER', '(&(mail={user}))');
    $ldapStartTls = env('LDAP_START_TLS', false);

    Log::info("LDAP config - Server: {$ldapServer}, BaseDN: {$ldapBaseDn}, BindUser: {$ldapBindUser}");

    try {
        $ldapConnection = ldap_connect("ldap://{$ldapServer}");
        if (!$ldapConnection) {
            Log::error("LDAP connection failed for server: {$ldapServer}");
            return false;
        }

        ldap_set_option($ldapConnection, LDAP_OPT_PROTOCOL_VERSION, 3);
        ldap_set_option($ldapConnection, LDAP_OPT_REFERRALS, 0);
        ldap_set_option($ldapConnection, LDAP_OPT_NETWORK_TIMEOUT, 5);

        if ($ldapStartTls) {
            if (!ldap_start_tls($ldapConnection)) {
                Log::error("LDAP StartTLS failed");
                ldap_close($ldapConnection);
                return false;
            }
        }

        // Multiple bind strategies, as used by BookStack
        $bindAttempts = [];
        $bindAttempts[] = $ldapBindUser;
        if (!str_contains($ldapBindUser, '=')) {
            $bindAttempts[] = "CN={$ldapBindUser},{$ldapBaseDn}";
        }
        if (!str_contains($ldapBindUser, '@') && !str_contains($ldapBindUser, '=')) {
            $domain = str_replace(['DC=', ','], ['', '.'], $ldapBaseDn);
            $bindAttempts[] = "{$ldapBindUser}@{$domain}";
        }
        if (!str_contains($ldapBindUser, '@') && str_contains($ldapBaseDn, 'DC=')) {
            preg_match_all('/DC=([^,]+)/', $ldapBaseDn, $matches);
            if (isset($matches[1])) {
                $domain = implode('.', $matches[1]);
                $bindAttempts[] = "{$ldapBindUser}@{$domain}";
            }
        }

        $bindResult = false;
        foreach ($bindAttempts as $bindDn) {
            Log::info("Attempting LDAP bind with: {$bindDn}");
            $bindResult = @ldap_bind($ldapConnection, $bindDn, $ldapBindPassword);
            if ($bindResult) {
                Log::info("LDAP bind successful with: {$bindDn}");
                break;
            } else {
                Log::warning("LDAP bind failed with {$bindDn}: " . ldap_error($ldapConnection));
            }
        }
        if (!$bindResult) {
            Log::error("All LDAP bind attempts failed for user: {$ldapBindUser}");
            ldap_close($ldapConnection);
            return false;
        }

        Log::info("LDAP authenticated successfully, searching for user: {$user->email}");
        $searchFilter = str_replace('{user}', $user->email, $ldapUserFilter);
        Log::info("LDAP search filter: {$searchFilter}");

        $searchResult = ldap_search($ldapConnection, $ldapBaseDn, $searchFilter, [$ldapThumbnailAttribute, 'mail', 'cn', 'displayName']);
        if (!$searchResult) {
            $ldapError = ldap_error($ldapConnection);
            Log::warning("LDAP search failed: {$ldapError}");
            ldap_close($ldapConnection);
            return false;
        }
        $entries = ldap_get_entries($ldapConnection, $searchResult);
        Log::info("LDAP search returned {$entries['count']} entries");
        if ($entries['count'] === 0) {
            Log::warning("No LDAP entry found for user: {$user->email}");
            ldap_close($ldapConnection);
            return false;
        }

        // Debug: show found attributes
        $foundAttributes = array_keys($entries[0]);
        $filteredAttributes = array_filter($foundAttributes, function($key) { 
            return !is_numeric($key) && $key !== 'count'; 
        });
        Log::info("Available attributes: " . implode(', ', $filteredAttributes));

        // Check for thumbnail
        if (!isset($entries[0][$ldapThumbnailAttribute][0])) {
            Log::info("No {$ldapThumbnailAttribute} found for user: {$user->email}");
            ldap_close($ldapConnection);
            return false;
        }

        $imageData = $entries[0][$ldapThumbnailAttribute][0];
        $imageSize = strlen($imageData);
        Log::info("Found {$ldapThumbnailAttribute} ({$imageSize} bytes) for user: {$user->email}");

        // Detect image format
        $imageInfo = getimagesizefromstring($imageData);
        if (!$imageInfo) {
            Log::error("Invalid image data from LDAP for user: {$user->email}");
            ldap_close($ldapConnection);
            return false;
        }
        $mimeType = $imageInfo['mime'];
        $extension = '';
        switch ($mimeType) {
            case 'image/jpeg': $extension = 'jpg'; break;
            case 'image/png':  $extension = 'png'; break;
            case 'image/gif':  $extension = 'gif'; break;
            default:
                Log::error("Unsupported image type: {$mimeType} for user: {$user->email}");
                ldap_close($ldapConnection);
                return false;
        }

        // Save avatar
        $avatarPath = saveUserAvatar($user, $imageData, $extension);
        if ($avatarPath) {
            Log::info("Avatar successfully updated for user: {$user->email}");
        }
        ldap_close($ldapConnection);
        return $avatarPath;

    } catch (Exception $e) {
        Log::error("Exception in fetchAvatarFromLDAP: " . $e->getMessage());
        if (isset($ldapConnection)) {
            ldap_close($ldapConnection);
        }
        return false;
    }
}

/**
 * Saves the avatar image for a user.
 */
function saveUserAvatar($user, $imageData, $extension) {
    try {
        $yearMonth = date('Y-m');
        $avatarDir = "/config/www/uploads/images/user/{$yearMonth}";

        if (!file_exists($avatarDir)) {
            mkdir($avatarDir, 0755, true);
        }

        $randomString = generateBookStackRandomString(12);
        $filename = "{$randomString}-avatar.{$extension}";
        $relativePath = "uploads/images/user/{$yearMonth}/{$filename}";
        $fullPath = "{$avatarDir}/{$filename}";

        if (file_put_contents($fullPath, $imageData) === false) {
            Log::error("Failed to save avatar file: {$fullPath}");
            return false;
        }

        // Create BookStack Image object
        $image = new Image();
        $image->name = $filename;
        $image->type = 'user';
        $image->path = $relativePath;
        $baseUrl = rtrim(env('APP_URL'), '/');
        $image->url = $baseUrl . '/' . $relativePath;
        $image->uploaded_to = $user->id;
        $image->created_by = $user->id;
        $image->updated_by = $user->id;
        $image->save();

        // Remove old profile image
        if (isset($user->image_id) && $user->image_id) {
            $oldImage = Image::find($user->image_id);
            if ($oldImage && $oldImage->path !== $relativePath) {
                $oldPath = "/config/www/" . $oldImage->path;
                if (file_exists($oldPath)) {
                    unlink($oldPath);
                    Log::info("Deleted old profile image: " . $oldImage->path);
                }
                $oldImage->delete();
            }
        }

        // Set new profile image
        $user->image_id = $image->id;
        $user->save();

        Log::info("Avatar saved successfully: {$relativePath}");
        return $relativePath;

    } catch (Exception $e) {
        Log::error("Exception in saveUserAvatar: " . $e->getMessage());
        return false;
    }
}

/**
 * Generates a random string in BookStack style (lowercase letters and digits).
 */
function generateBookStackRandomString($length = 12) {
    $characters = 'abcdefghijklmnopqrstuvwxyz0123456789';
    $randomString = '';
    for ($i = 0; $i < $length; $i++) {
        $randomString .= $characters[random_int(0, strlen($characters) - 1)];
    }
    return $randomString;
}

/**
 * Debug: List all LDAP attributes for a user.
 * Can be called via /admin/debug-ldap-user (admins only).
 */
function debugLDAPUser($email) {
    $ldapServer = env('LDAP_SERVER', 'domain.ch:389');
    $ldapBaseDn = env('LDAP_BASE_DN', 'DC=domain,DC=ch');
    $ldapBindUser = env('LDAP_DN', 'ldap_user');
    $ldapBindPassword = env('LDAP_PASS', '');

    try {
        $ldapConnection = ldap_connect("ldap://{$ldapServer}");
        ldap_set_option($ldapConnection, LDAP_OPT_PROTOCOL_VERSION, 3);
        ldap_set_option($ldapConnection, LDAP_OPT_REFERRALS, 0);

        $bindResult = ldap_bind($ldapConnection, $ldapBindUser, $ldapBindPassword);
        if (!$bindResult) {
            return "LDAP bind failed";
        }

        $searchFilter = "(&(mail={$email}))";
        $searchResult = ldap_search($ldapConnection, $ldapBaseDn, $searchFilter);
        $entries = ldap_get_entries($ldapConnection, $searchResult);

        if ($entries['count'] === 0) {
            return "User not found: {$email}";
        }

        $attributes = [];
        foreach ($entries[0] as $key => $value) {
            if (!is_numeric($key) && $key !== 'count') {
                $hasPhoto = in_array($key, ['thumbnailPhoto', 'jpegphoto', 'photo', 'userphoto', 'picture']);
                $attributes[] = $key . ($hasPhoto && isset($value[0]) ? ' (HAS PHOTO DATA)' : '');
            }
        }
        ldap_close($ldapConnection);
        return "Attributes for {$email}: " . implode(', ', $attributes);

    } catch (Exception $e) {
        return "Error: " . $e->getMessage();
    }
}

// Debug route for admins only
Theme::listen(ThemeEvents::WEB_MIDDLEWARE_BEFORE, function() {
    if (request()->path() === 'admin/debug-ldap-user' && auth()->user() && auth()->user()->hasSystemRole('admin')) {
        $email = request()->get('email', 'user@domain.ch');
        echo "<pre>" . debugLDAPUser($email) . "</pre>";
        exit;
    }
});

function updateAllAvatarsFromLDAP() {
    $users = User::where('auth_id', '!=', '')->get(); // Only SSO users
    foreach ($users as $user) {
        Log::info("Updating avatar for user: {$user->email}");
        fetchAvatarFromLDAP($user);
        usleep(500000); // 0.5 second pause to not overload LDAP
    }
}

// Optional: Register artisan command for manual avatar update
if (app()->runningInConsole()) {
    Theme::listen(ThemeEvents::APP_BOOT, function() {
        \Illuminate\Support\Facades\Artisan::command('theme:update-avatars', function() {
            $this->info('Updating avatars from LDAP...');
            updateAllAvatarsFromLDAP();
            $this->info('Avatar update completed!');
        })->describe('Update all user avatars from LDAP');
    });
}
@Tomblarom commented on GitHub (Oct 16, 2025): @ssddanbrown so I hacked together my own solution and it seems to do exactly what I want. :) Since I used ChatGPT I would highly recommend to review and refactor the code, before potentially publishing as an [unofficial hack](https://www.bookstackapp.com/hacks). 😉 --- ### Workaround for retrieving `picture` when using OIDC > [!CAUTION] > For each new user, a separate LDAP-connection is established! If too many users register at once, this might lead to latency or even downtime of your LDAP-servers. As primary authentication provider, I used `AUTH_METHOD=oidc` (setup: [video](https://foss.video/w/a744K8GxFF1LqBFSadAsuV), [docs](https://www.bookstackapp.com/docs/admin/oidc-auth/)). Additionally I repurposed the variables from the LDAP-authentication: `LDAP_SERVER`, `LDAP_BASE_DN`, `LDAP_DN`, `LDAP_PASS`, `LDAP_THUMBNAIL_ATTRIBUTE`, `LDAP_USER_FILTER` (optional) and `LDAP_START_TLS` (optional). The script utilizes the suggested [logical theme system](https://github.com/BookStackApp/BookStack/blob/development/dev/docs/logical-theme-system.md) and hooks into `AUTH_LOGIN` / `AUTH_REGISTER`. Upon each login/registration it checks, if the picture for that user already exists. If not, it established a LDAP-connection, pulls the picture, saves it to `bookstack/app/www/uploads/images/user/YYYY-MM/` and links it to the present user. In order to retrieve and update the pictures of existing users, there is an artisan command `update-avatars`. This command has not been tested by me, but you definitely get the idea. The script itself lives in `bookstack/app/www/themes/oidc-avatar-from-ldap/functions.php` and needs to be registered by adding `APP_THEME=oidc-avatar-from-ldap`. <details><summary><b>functions.php</b></summary> ```php <?php use BookStack\Facades\Theme; use BookStack\Theming\ThemeEvents; use BookStack\Users\Models\User; use BookStack\Uploads\Image; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; /** * BookStack Theme: OIDC Avatar from LDAP * * Fetches profile pictures from LDAP after OIDC login, * since OIDC from Azure AD does not deliver avatars. */ // After successful OIDC login Theme::listen(ThemeEvents::AUTH_LOGIN, function($method, $user) { if ($method !== 'oidc') return; Log::info("OIDC login detected for user: {$user->email}"); // Only update avatar if not already set if (empty($user->image_id)) { fetchAvatarFromLDAP($user); } else { Log::info("User {$user->email} already has a profile image set. Skipping avatar update."); } }); // After OIDC registration Theme::listen(ThemeEvents::AUTH_REGISTER, function($method, $user) { if ($method !== 'oidc') return; Log::info("OIDC registration detected for user: {$user->email}"); // Only update avatar if not already set if (empty($user->image_id)) { fetchAvatarFromLDAP($user); } else { Log::info("User {$user->email} already has a profile image set. Skipping avatar update."); } }); /** * Fetches a user's profile picture from LDAP. */ function fetchAvatarFromLDAP($user) { $ldapServer = env('LDAP_SERVER', 'domain.ch:389'); $ldapBaseDn = env('LDAP_BASE_DN', 'DC=domain,DC=ch'); $ldapBindUser = env('LDAP_DN', 'ldap_user'); $ldapBindPassword = env('LDAP_PASS', 'ldap_pass'); $ldapThumbnailAttribute = env('LDAP_THUMBNAIL_ATTRIBUTE', 'thumbnailPhoto'); $ldapUserFilter = env('LDAP_USER_FILTER', '(&(mail={user}))'); $ldapStartTls = env('LDAP_START_TLS', false); Log::info("LDAP config - Server: {$ldapServer}, BaseDN: {$ldapBaseDn}, BindUser: {$ldapBindUser}"); try { $ldapConnection = ldap_connect("ldap://{$ldapServer}"); if (!$ldapConnection) { Log::error("LDAP connection failed for server: {$ldapServer}"); return false; } ldap_set_option($ldapConnection, LDAP_OPT_PROTOCOL_VERSION, 3); ldap_set_option($ldapConnection, LDAP_OPT_REFERRALS, 0); ldap_set_option($ldapConnection, LDAP_OPT_NETWORK_TIMEOUT, 5); if ($ldapStartTls) { if (!ldap_start_tls($ldapConnection)) { Log::error("LDAP StartTLS failed"); ldap_close($ldapConnection); return false; } } // Multiple bind strategies, as used by BookStack $bindAttempts = []; $bindAttempts[] = $ldapBindUser; if (!str_contains($ldapBindUser, '=')) { $bindAttempts[] = "CN={$ldapBindUser},{$ldapBaseDn}"; } if (!str_contains($ldapBindUser, '@') && !str_contains($ldapBindUser, '=')) { $domain = str_replace(['DC=', ','], ['', '.'], $ldapBaseDn); $bindAttempts[] = "{$ldapBindUser}@{$domain}"; } if (!str_contains($ldapBindUser, '@') && str_contains($ldapBaseDn, 'DC=')) { preg_match_all('/DC=([^,]+)/', $ldapBaseDn, $matches); if (isset($matches[1])) { $domain = implode('.', $matches[1]); $bindAttempts[] = "{$ldapBindUser}@{$domain}"; } } $bindResult = false; foreach ($bindAttempts as $bindDn) { Log::info("Attempting LDAP bind with: {$bindDn}"); $bindResult = @ldap_bind($ldapConnection, $bindDn, $ldapBindPassword); if ($bindResult) { Log::info("LDAP bind successful with: {$bindDn}"); break; } else { Log::warning("LDAP bind failed with {$bindDn}: " . ldap_error($ldapConnection)); } } if (!$bindResult) { Log::error("All LDAP bind attempts failed for user: {$ldapBindUser}"); ldap_close($ldapConnection); return false; } Log::info("LDAP authenticated successfully, searching for user: {$user->email}"); $searchFilter = str_replace('{user}', $user->email, $ldapUserFilter); Log::info("LDAP search filter: {$searchFilter}"); $searchResult = ldap_search($ldapConnection, $ldapBaseDn, $searchFilter, [$ldapThumbnailAttribute, 'mail', 'cn', 'displayName']); if (!$searchResult) { $ldapError = ldap_error($ldapConnection); Log::warning("LDAP search failed: {$ldapError}"); ldap_close($ldapConnection); return false; } $entries = ldap_get_entries($ldapConnection, $searchResult); Log::info("LDAP search returned {$entries['count']} entries"); if ($entries['count'] === 0) { Log::warning("No LDAP entry found for user: {$user->email}"); ldap_close($ldapConnection); return false; } // Debug: show found attributes $foundAttributes = array_keys($entries[0]); $filteredAttributes = array_filter($foundAttributes, function($key) { return !is_numeric($key) && $key !== 'count'; }); Log::info("Available attributes: " . implode(', ', $filteredAttributes)); // Check for thumbnail if (!isset($entries[0][$ldapThumbnailAttribute][0])) { Log::info("No {$ldapThumbnailAttribute} found for user: {$user->email}"); ldap_close($ldapConnection); return false; } $imageData = $entries[0][$ldapThumbnailAttribute][0]; $imageSize = strlen($imageData); Log::info("Found {$ldapThumbnailAttribute} ({$imageSize} bytes) for user: {$user->email}"); // Detect image format $imageInfo = getimagesizefromstring($imageData); if (!$imageInfo) { Log::error("Invalid image data from LDAP for user: {$user->email}"); ldap_close($ldapConnection); return false; } $mimeType = $imageInfo['mime']; $extension = ''; switch ($mimeType) { case 'image/jpeg': $extension = 'jpg'; break; case 'image/png': $extension = 'png'; break; case 'image/gif': $extension = 'gif'; break; default: Log::error("Unsupported image type: {$mimeType} for user: {$user->email}"); ldap_close($ldapConnection); return false; } // Save avatar $avatarPath = saveUserAvatar($user, $imageData, $extension); if ($avatarPath) { Log::info("Avatar successfully updated for user: {$user->email}"); } ldap_close($ldapConnection); return $avatarPath; } catch (Exception $e) { Log::error("Exception in fetchAvatarFromLDAP: " . $e->getMessage()); if (isset($ldapConnection)) { ldap_close($ldapConnection); } return false; } } /** * Saves the avatar image for a user. */ function saveUserAvatar($user, $imageData, $extension) { try { $yearMonth = date('Y-m'); $avatarDir = "/config/www/uploads/images/user/{$yearMonth}"; if (!file_exists($avatarDir)) { mkdir($avatarDir, 0755, true); } $randomString = generateBookStackRandomString(12); $filename = "{$randomString}-avatar.{$extension}"; $relativePath = "uploads/images/user/{$yearMonth}/{$filename}"; $fullPath = "{$avatarDir}/{$filename}"; if (file_put_contents($fullPath, $imageData) === false) { Log::error("Failed to save avatar file: {$fullPath}"); return false; } // Create BookStack Image object $image = new Image(); $image->name = $filename; $image->type = 'user'; $image->path = $relativePath; $baseUrl = rtrim(env('APP_URL'), '/'); $image->url = $baseUrl . '/' . $relativePath; $image->uploaded_to = $user->id; $image->created_by = $user->id; $image->updated_by = $user->id; $image->save(); // Remove old profile image if (isset($user->image_id) && $user->image_id) { $oldImage = Image::find($user->image_id); if ($oldImage && $oldImage->path !== $relativePath) { $oldPath = "/config/www/" . $oldImage->path; if (file_exists($oldPath)) { unlink($oldPath); Log::info("Deleted old profile image: " . $oldImage->path); } $oldImage->delete(); } } // Set new profile image $user->image_id = $image->id; $user->save(); Log::info("Avatar saved successfully: {$relativePath}"); return $relativePath; } catch (Exception $e) { Log::error("Exception in saveUserAvatar: " . $e->getMessage()); return false; } } /** * Generates a random string in BookStack style (lowercase letters and digits). */ function generateBookStackRandomString($length = 12) { $characters = 'abcdefghijklmnopqrstuvwxyz0123456789'; $randomString = ''; for ($i = 0; $i < $length; $i++) { $randomString .= $characters[random_int(0, strlen($characters) - 1)]; } return $randomString; } /** * Debug: List all LDAP attributes for a user. * Can be called via /admin/debug-ldap-user (admins only). */ function debugLDAPUser($email) { $ldapServer = env('LDAP_SERVER', 'domain.ch:389'); $ldapBaseDn = env('LDAP_BASE_DN', 'DC=domain,DC=ch'); $ldapBindUser = env('LDAP_DN', 'ldap_user'); $ldapBindPassword = env('LDAP_PASS', ''); try { $ldapConnection = ldap_connect("ldap://{$ldapServer}"); ldap_set_option($ldapConnection, LDAP_OPT_PROTOCOL_VERSION, 3); ldap_set_option($ldapConnection, LDAP_OPT_REFERRALS, 0); $bindResult = ldap_bind($ldapConnection, $ldapBindUser, $ldapBindPassword); if (!$bindResult) { return "LDAP bind failed"; } $searchFilter = "(&(mail={$email}))"; $searchResult = ldap_search($ldapConnection, $ldapBaseDn, $searchFilter); $entries = ldap_get_entries($ldapConnection, $searchResult); if ($entries['count'] === 0) { return "User not found: {$email}"; } $attributes = []; foreach ($entries[0] as $key => $value) { if (!is_numeric($key) && $key !== 'count') { $hasPhoto = in_array($key, ['thumbnailPhoto', 'jpegphoto', 'photo', 'userphoto', 'picture']); $attributes[] = $key . ($hasPhoto && isset($value[0]) ? ' (HAS PHOTO DATA)' : ''); } } ldap_close($ldapConnection); return "Attributes for {$email}: " . implode(', ', $attributes); } catch (Exception $e) { return "Error: " . $e->getMessage(); } } // Debug route for admins only Theme::listen(ThemeEvents::WEB_MIDDLEWARE_BEFORE, function() { if (request()->path() === 'admin/debug-ldap-user' && auth()->user() && auth()->user()->hasSystemRole('admin')) { $email = request()->get('email', 'user@domain.ch'); echo "<pre>" . debugLDAPUser($email) . "</pre>"; exit; } }); function updateAllAvatarsFromLDAP() { $users = User::where('auth_id', '!=', '')->get(); // Only SSO users foreach ($users as $user) { Log::info("Updating avatar for user: {$user->email}"); fetchAvatarFromLDAP($user); usleep(500000); // 0.5 second pause to not overload LDAP } } // Optional: Register artisan command for manual avatar update if (app()->runningInConsole()) { Theme::listen(ThemeEvents::APP_BOOT, function() { \Illuminate\Support\Facades\Artisan::command('theme:update-avatars', function() { $this->info('Updating avatars from LDAP...'); updateAllAvatarsFromLDAP(); $this->info('Avatar update completed!'); })->describe('Update all user avatars from LDAP'); }); } ``` </details>
Author
Owner

@ssddanbrown commented on GitHub (Nov 1, 2025):

A BookStack support customer also had the need for images with Azure/Entra, so I've published a simple example logical theme solution on our hack site: https://www.bookstackapp.com/hacks/oidc-azure-avatar-images/


Thanks @Tomblarom for offering that! but I decided to go a simpler route rather than getting LDAP involved in this.

@ssddanbrown commented on GitHub (Nov 1, 2025): A BookStack support customer also had the need for images with Azure/Entra, so I've published a simple example logical theme solution on our hack site: https://www.bookstackapp.com/hacks/oidc-azure-avatar-images/ --- Thanks @Tomblarom for offering that! but I decided to go a simpler route rather than getting LDAP involved in this.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: starred/BookStack#3824