Files
dolibarr/htdocs/core/lib/openid_connect.lib.php
Sacha Pignot 4810534025 NEW: Add OIDC user auto-creation and fix login bugs (#37314)
* NEW #25922 Add OIDC user auto-creation and fix login bugs

NEW #25922 Add OIDC user auto-creation and fix login bugs

* Fix indentation in OIDC auto-create user admin settings block

* Move require_once statements to file level in openid_connect.lib.php

* Cast login_claim to string before user creation

---------

Co-authored-by: Laurent Destailleur <eldy@destailleur.fr>
2026-02-28 15:37:48 +01:00

205 lines
7.3 KiB
PHP

<?php
/* Copyright (C) 2017 Open-DSI <support@open-dsi.fr>
* Copyright (C) 2024 MDW <mdeweerd@users.noreply.github.com>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
/**
* \file htdocs/core/lib/openid_connect.lib.php
* \ingroup openid_connect
* \brief Library of functions for OpenID Connect authentication
*/
require_once DOL_DOCUMENT_ROOT.'/user/class/user.class.php';
require_once DOL_DOCUMENT_ROOT.'/core/lib/security2.lib.php';
/**
* Generate a self-verifiable state token for the OIDC authorization request.
*
* Uses HMAC with the instance unique ID as secret so the state can be verified
* without depending on the original session. This is necessary because browsers
* with SameSite=Lax cookies do not send the session cookie on cross-site redirects
* from the OIDC provider, causing a new session to be created on the callback.
*
* @return string State token (format: nonce.signature)
*/
function openid_connect_get_state()
{
global $dolibarr_main_instance_unique_id;
$nonce = bin2hex(random_bytes(16));
$signature = hash_hmac('sha256', $nonce, $dolibarr_main_instance_unique_id);
return $nonce.'.'.$signature;
}
/**
* Verify an OIDC state token.
*
* Checks that the state was generated by this Dolibarr instance by recomputing
* the HMAC signature from the nonce and comparing it to the provided signature.
*
* @param string $state The state token to verify (format: nonce.signature)
* @return bool True if valid, false otherwise
*/
function openid_connect_verify_state($state)
{
global $dolibarr_main_instance_unique_id;
if (empty($state)) {
return false;
}
$parts = explode('.', $state, 2);
if (count($parts) !== 2) {
return false;
}
$nonce = $parts[0];
$signature = $parts[1];
$expected = hash_hmac('sha256', $nonce, $dolibarr_main_instance_unique_id);
return hash_equals($expected, $signature);
}
/**
* Return the OIDC callback redirect URL
*
* @return string Redirect URL
*/
function openid_connect_get_redirect_url()
{
return DOL_MAIN_URL_ROOT . '/core/modules/openid_connect/callback.php';
}
/**
* Return the OIDC authorization URL
*
* @return string Authorization URL
*/
function openid_connect_get_url()
{
// Note: For the scope, we must use rawurlencode instead of urlencode.
$url = getDolGlobalString('MAIN_AUTHENTICATION_OIDC_AUTHORIZE_URL').'?client_id='.urlencode(getDolGlobalString('MAIN_AUTHENTICATION_OIDC_CLIENT_ID')).'&redirect_uri='.urlencode(openid_connect_get_redirect_url()).'&scope='.rawurlencode(getDolGlobalString('MAIN_AUTHENTICATION_OIDC_SCOPES')).'&response_type=code&state='.urlencode(openid_connect_get_state());
return $url;
}
/**
* Create a Dolibarr user from OIDC userinfo claims.
*
* The login is sanitized to remove characters not allowed by Dolibarr (e.g. @ from emails).
* If the OIDC userinfo contains a preferred_username claim without bad characters, it is used instead.
*
* @param DoliDB $db Database handler
* @param stdClass $userinfo Decoded OIDC userinfo JSON (claims from json_decode)
* @param string $login Login value extracted from the configured claim
* @param int $entity Entity (multicompany) ID
* @return string|int Sanitized login string on success, negative int on failure
* @see check_user_password_openid_connect()
*/
function openid_connect_create_user($db, $userinfo, $login, $entity)
{
// Read claim mapping configuration
$claim_email = getDolGlobalString('MAIN_AUTHENTICATION_OIDC_CLAIM_EMAIL', 'email');
$claim_firstname = getDolGlobalString('MAIN_AUTHENTICATION_OIDC_CLAIM_FIRSTNAME', 'given_name');
$claim_lastname = getDolGlobalString('MAIN_AUTHENTICATION_OIDC_CLAIM_LASTNAME', 'family_name');
// Sanitize login: Dolibarr rejects certain characters (default: ,@<>"')
$badChars = getDolGlobalString('MAIN_LOGIN_BADCHARUNAUTHORIZED', ',@<>"\'');
$sanitized_login = $login;
if (preg_match('/['.preg_quote($badChars, '/').']/', $login)) {
// First try preferred_username from OIDC (common standard claim)
if (property_exists($userinfo, 'preferred_username') && !empty($userinfo->preferred_username)) {
$preferred = $userinfo->preferred_username;
if (!preg_match('/['.preg_quote($badChars, '/').']/', $preferred)) {
$sanitized_login = $preferred;
}
}
// If still invalid (no preferred_username or it also has bad chars), extract local part of email
if (preg_match('/['.preg_quote($badChars, '/').']/', $sanitized_login) && strpos($sanitized_login, '@') !== false) {
$sanitized_login = substr($sanitized_login, 0, strpos($sanitized_login, '@'));
}
// Final fallback: replace any remaining bad characters
$sanitized_login = preg_replace('/['.preg_quote($badChars, '/').']/', '.', $sanitized_login);
}
$newuser = new User($db);
$newuser->login = $sanitized_login;
$newuser->entity = $entity;
$newuser->statut = 1; // Active
$newuser->status = 1; // Active (alias)
if (property_exists($userinfo, $claim_email)) {
$newuser->email = $userinfo->$claim_email;
}
if (property_exists($userinfo, $claim_firstname)) {
$newuser->firstname = $userinfo->$claim_firstname;
}
if (property_exists($userinfo, $claim_lastname)) {
$newuser->lastname = $userinfo->$claim_lastname;
}
// If no lastname from claims, use login as fallback (lastname is required)
if (empty($newuser->lastname)) {
$newuser->lastname = $login;
}
// Set a random password (user authenticates via OIDC, not locally)
$newuser->pass = getRandomPassword(true);
// Find the admin user to set as creator (configurable, default: user ID 1)
$creator_id = getDolGlobalInt('MAIN_AUTHENTICATION_OIDC_DEFAULT_CREATOR', 1);
if ($creator_id <= 0) {
$creator_id = 1;
}
$adminuser = new User($db);
$result_fetch = $adminuser->fetch($creator_id);
if ($result_fetch <= 0 || empty($adminuser->admin) || $adminuser->statut != 1) {
dol_syslog("openid_connect_create_user::Error: configured creator user ID=".$creator_id." is not a valid active admin", LOG_ERR);
return -2;
}
$db->begin();
$result_create = $newuser->create($adminuser);
if ($result_create < 0) {
$db->rollback();
dol_syslog("openid_connect_create_user::Error creating user: ".$newuser->error, LOG_ERR);
return $result_create;
}
// Add to default group if configured
$default_group = getDolGlobalInt('MAIN_AUTHENTICATION_OIDC_DEFAULT_GROUP');
if ($default_group > 0) {
$res_group = $newuser->SetInGroup($default_group, $entity);
if ($res_group < 0) {
dol_syslog("openid_connect_create_user::Warning: Error adding user to group ".$default_group.": ".$newuser->error, LOG_WARNING);
// Don't fail user creation if group assignment fails
}
}
$db->commit();
dol_syslog("openid_connect_create_user::User created id=".$result_create." login=".$sanitized_login);
return $sanitized_login;
}