Skip to content

Commit

Permalink
Merge pull request #211 from c-jar/openid-connect
Browse files Browse the repository at this point in the history
Feat: OpenID Connect
  • Loading branch information
dbarzin authored Nov 2, 2024
2 parents 6fec6eb + 5b07c06 commit ad0e85a
Show file tree
Hide file tree
Showing 11 changed files with 840 additions and 40 deletions.
28 changes: 28 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,31 @@ MAIL_PASSWORD=
# MAIL_DKIM_PRIVATE = '/path/to/private/key';
# MAIL_DKIM_SELECTOR = 'default'; # Match your DKIM DNS selector
# MAIL_DKIM_PASSPHRASE = ''; # Only if your key has a passphrase

# List of socialite providers separated by a space. Possible value : keycloak, oidc
SOCIALITE_PROVIDERS=""

KEYCLAOK_DISPLAY_NAME="Keycloak"
KEYCLOAK_ALLOW_CREATE_USER=false
KEYCLOAK_ALLOW_UPDATE_USER=false
KEYCLOAK_DEFAULT_ROLE="auditee"
KEYCLOAK_ROLE_CLAIM="resource_access.deming.roles.0"
KEYCLOAK_ADDITIONAL_SCOPES="roles"

KEYCLOAK_CLIENT_ID=deming
KEYCLOAK_CLIENT_SECRET=secret
KEYCLOAK_REDIRECT_URI=${APP_URL}auth/callback/keycloak
KEYCLOAK_BASE_URL=https://keycloak.local
KEYCLOAK_REALM=main

OIDC_DISPLAY_NAME="Generic OIDC"
OIDC_ALLOW_CREATE_USER=false
OIDC_ALLOW_UPDATE_USER=false
OIDC_DEFAULT_ROLE="auditee"
OIDC_ROLE_CLAIM=""
OIDC_ADDITIONAL_SCOPES="deming_role"

OIDC_CLIENT_ID=deming
OIDC_CLIENT_SECRET=deming
OIDC_BASE_URL=http://auth.lan
OIDC_REDIRECT_URI=${APP_URL}auth/callback/oidc
251 changes: 251 additions & 0 deletions app/Http/Controllers/SocialiteController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
<?php

namespace App\Http\Controllers;

use Illuminate\Database\QueryException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Laravel\Socialite\Facades\Socialite;
use Log;
use Laravel\Socialite\Two\User as SocialiteUser;
use App\Http\Controllers\Controller;
use App\Models\User;

/**
* Socialite Controller for OpenID Connect Autentication
*/
class SocialiteController extends Controller
{

public const ROLES_MAP = [
//'admin' => '1',
'user' => '2',
'auditee' => '5',
'auditor' => '3',
//'api' => '4',
];

public const LOCALES = ['en', 'fr'];

public function __construct()
{
$this->middleware('auth:api', ['except' => ['redirect', 'callback']]);
}

/**
* Redirect action use to redirect user to OIDC provider.
*/
public function redirect(string $provider)
{
$providers = config('services.socialite_controller.providers', []);

if (in_array($provider, $providers)) {
Log::debug("Redirect with '$provider' provider");
$config_name = 'services.socialite_controller.'.$provider;
$additional_scopes = config($config_name.'.additional_scopes');
return Socialite::with($provider)->scopes($additional_scopes)->redirect();
}

Log::warning("Redirect: Provider '$provider' not found.");
abort(404);
}

/**
* Callback action use when OIDC provider redirect user to app.
*/
public function callback(Request $request, string $provider)
{
$providers = config('services.socialite_controller.providers', []);

if (! in_array($provider, $providers)) {
Log::warning("Callback: Provider '$provider' not found.");
abort(404);
}

Log::debug("Callback provider : '$provider'");

// Get additionnal config for current provider
$config_name = 'services.socialite_controller.'.$provider;
$allow_create_user = false;
$allow_update_user = false;
if(config($config_name)){
$allow_create_user = config($config_name.'.allow_create_user', $allow_create_user);
$allow_update_user = config($config_name.'.allow_update_user', $allow_update_user);
}
Log::debug('CONFIG: allow_create_user='.($allow_create_user ? 'true' : 'false'));
Log::debug('CONFIG: allow_update_user='.($allow_update_user ? 'true' : 'false'));
if($allow_create_user || $allow_update_user){
$role_claim = config($config_name.'.role_claim', '');
Log::debug('CONFIG: role_claim='.$role_claim);
$default_role = config($config_name.'.default_role', '');
Log::debug('CONFIG: default_role='.$default_role);
}

try {
$socialite_user = Socialite::with($provider)->user();
$user = null;

// Search user by email
if($socialite_user->email){
$user = User::query()->whereEmail($socialite_user->email)->first();
} else {
Log::warning("User has no attribute email");
}

// If not exist and allow to create user then create it
if (!$user && $allow_create_user) {
$user = $this->create_user($socialite_user, $provider, $role_claim, $default_role);
}

// If no user redirect to login with error message
if (!$user) {
Log::warning("User [$socialite_user->id, $socialite_user->email] not found in deming database");
return redirect('login')->withErrors(['socialite' => trans('cruds.login.error.user_not_exist') ]);
}

if($allow_update_user){
$this->update_user($user, $socialite_user, $provider, $role_claim, $default_role);
}

Log::info("User '$user->login' login with $provider provider");

Auth::guard('web')->login($user);

return redirect('/');
} catch (Exception $exception) {
return redirect('login');
}
}

/**
* Create user with claims provided.
*/
protected function create_user(SocialiteUser $socialite_user, string $provider, string $role_claim, string $default_role)
{
$user = new User();

$user->login = $this->get_user_login($socialite_user);
$user->name = $socialite_user->name;
$user->email = $socialite_user->email;
$user->title = "User provide by $provider";
$user->role = $this->get_user_role($socialite_user, $role_claim, $default_role);
$user->language = $this->get_user_langage($socialite_user);

// TODO allow null password
$user->password = bin2hex(random_bytes(32));

Log::info("Create new user '$user->login' with role '$user->role' from $provider provider");
try {
$user->save();
} catch(QueryException $exception){
Log::debug($exception->getMessage());
Log::error("Unable to create user");
return null;
}

return $user;
}

/**
* Update user with claims providid.
*/
protected function update_user(User $user, SocialiteUser $socialite_user, string $provider, string $role_claim, string $default_role)
{
$updated = false;

$login = $this->get_user_login($socialite_user);
if ($login !== $user->login) {
Log::debug("Login changed $user->login => $login");
$user->login = $login;
$updated = true;
}

if ($socialite_user->name !== $user->name) {
Log::debug("Name changed $user->name => $socialite_user->name");
$user->name = $socialite_user->name;
$updated = true;
}

$role = $this->get_user_role($socialite_user, $role_claim, $default_role);
if($role != $user->role){
Log::debug("Role changed $user->role => $role");
$user->role = $role;
$updated = true;
}

$language = $this->get_user_langage($socialite_user);
if ($language !== $user->language) {
Log::debug("Lauguage change $user->language => $language");
$user->language = $language;
$updated = true;
}

if ($updated) {
Log::info("Update user '$user->login' with role '$user->role' from $provider provider");
$user->save();
}
return $user;
}

/**
* Return user's login.
*/
private function get_user_login(SocialiteUser $socialite_user)
{
// set login with preferred_username, otherwise use id
if($socialite_user->offsetExists('preferred_username')){
return $socialite_user->offsetGet("preferred_username");
}
return $socialite_user->id;
}

/**
* Return user's role.
* If no role provided, use $default_role value.
* If $default_role is null and no role provided, null return.
*/
private function get_user_role(SocialiteUser $socialite_user, string $role_claim, string $default_role)
{
$role_name = "";
if(!empty($role_claim)){
$role_name = $this->get_claim_value($socialite_user, $role_claim);
Log::debug("Provided claim '$role_claim'='$role_name'");
}
if(!array_key_exists($role_name, self::ROLES_MAP)){
if(!empty($default_role)){
$role_name = $default_role;
} else {
Log::error("No default role set! A valid role must be provided. role='$role_name'");
return null;
}
}
return self::ROLES_MAP[$role_name];
}

/**
* Return user's language.
* Use locale claim to dertermine user's language.
*/
private function get_user_langage(SocialiteUser $socialite_user)
{
if ($socialite_user->offsetExists('locale')){
$locale = explode('-', $socialite_user->offsetGet('locale'))[0];
if (in_array($locale, self::LOCALES)) return $locale;
}
return self::LOCALES[0];
}

private function get_claim_value(SocialiteUser $user, string $claim){
$value = null;
foreach(explode('.', $claim) as $offset) {
if(! $value){
if (! $user->offsetExists($offset)) return null;
$value = $user->offsetGet($offset);
continue;
}
if (! array_key_exists($offset, $value)) return null;
$value = $value[$offset];
}
return $value;
}
}
26 changes: 26 additions & 0 deletions app/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use DB;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider;
Expand Down Expand Up @@ -40,5 +41,30 @@ public function boot()
);
});
}

if (in_array('keycloak', Config::get('services.socialite_controller.providers'))){
Event::listen(function (\SocialiteProviders\Manager\SocialiteWasCalled $event) {
$event->extendSocialite('keycloak', \SocialiteProviders\Keycloak\Provider::class);
});
}

if (in_array('oidc', Config::get('services.socialite_controller.providers'))){
$this->bootOIDCSocialite();
}
}

/**
* Register Generic OpenID Connect Provider.
*/
private function bootOIDCSocialite()
{
$socialite = $this->app->make('Laravel\Socialite\Contracts\Factory');
$socialite->extend(
'oidc',
function ($app) use ($socialite) {
$config = $app['config']['services.oidc'];
return $socialite->buildProvider(\App\Providers\Socialite\GenericSocialiteProvider::class, $config);
}
);
}
}
Loading

0 comments on commit ad0e85a

Please sign in to comment.