Skip to content

Commit

Permalink
Implement OAuth and SAML2 support (#13764)
Browse files Browse the repository at this point in the history
* Implement OAuth and SAML2 support via Socialite

* Add socialite docs

* fixes

* Additional information added

* wip

* 22.3.0 targeted version

* Allow mysql auth as long as there is a password saved

Co-authored-by: laf <gh+n@laf.io>
Co-authored-by: Tony Murray <murraytony@gmail.com>
  • Loading branch information
3 people committed Feb 20, 2022
1 parent 2e5b343 commit 09929bd
Show file tree
Hide file tree
Showing 42 changed files with 1,105 additions and 31 deletions.
4 changes: 2 additions & 2 deletions LibreNMS/Authentication/MysqlAuthorizer.php
Expand Up @@ -18,7 +18,7 @@ public function authenticate($credentials)
$username = $credentials['username'] ?? null;
$password = $credentials['password'] ?? null;

$user_data = User::thisAuth()->firstWhere(['username' => $username]);
$user_data = User::whereNotNull('password')->firstWhere(['username' => $username]);
$hash = $user_data->password;
$enabled = $user_data->enabled;

Expand Down Expand Up @@ -76,7 +76,7 @@ public function addUser($username, $password, $level = 0, $email = '', $realname
$user_id = $new_user->user_id;

// set auth_id
$new_user->auth_id = $this->getUserid($username);
$new_user->auth_id = (string) $this->getUserid($username);
$new_user->save();

if ($user_id) {
Expand Down
12 changes: 12 additions & 0 deletions LibreNMS/Util/DynamicConfigItem.php
Expand Up @@ -83,6 +83,18 @@ public function checkValue($value)
return filter_var($value, FILTER_VALIDATE_EMAIL);
} elseif ($this->type == 'array') {
return is_array($value); // this should probably have more complex validation via validator rules
} elseif ($this->type == 'array-sub-keyed') {
if (! is_array($value)) {
return false;
}

foreach ($value as $v) {
if (! is_array($v)) {
return false;
}
}

return true;
} elseif ($this->type == 'color') {
return (bool) preg_match('/^#?[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/', $value);
} elseif (in_array($this->type, ['text', 'password'])) {
Expand Down
2 changes: 1 addition & 1 deletion app/Console/Commands/AddUserCommand.php
Expand Up @@ -97,7 +97,7 @@ public function handle()
$user->setPassword($password);
$user->save();

$user->auth_id = LegacyAuth::get()->getUserid($user->username) ?: $user->user_id;
$user->auth_id = (string) LegacyAuth::get()->getUserid($user->username) ?: $user->user_id;
$user->save();

$this->info(__('commands.user:add.success', ['username' => $user->username]));
Expand Down
14 changes: 11 additions & 3 deletions app/Http/Controllers/Auth/LoginController.php
Expand Up @@ -41,13 +41,21 @@ public function __construct()
$this->middleware('guest')->except('logout');
}

public function username()
public function username(): string
{
return 'username';
}

public function showLoginForm()
/**
* @return \Illuminate\View\View|\Illuminate\Http\RedirectResponse|\Symfony\Component\HttpFoundation\Response
*/
public function showLoginForm(Request $request)
{
// Check if we want to redirect users to the socialite provider directly
if (! $request->has('redirect') && Config::get('auth.socialite.redirect') && array_key_first(Config::get('auth.socialite.configs', []))) {
return (new SocialiteController)->redirect($request, array_key_first(Config::get('auth.socialite.configs', [])));
}

if (Config::get('public_status')) {
$devices = Device::isActive()->with('location')->get();

Expand All @@ -57,7 +65,7 @@ public function showLoginForm()
return view('auth.login');
}

protected function loggedOut(Request $request)
protected function loggedOut(Request $request): \Illuminate\Http\RedirectResponse
{
return redirect(Config::get('auth_logout_handler', $this->redirectTo));
}
Expand Down
223 changes: 223 additions & 0 deletions app/Http/Controllers/Auth/SocialiteController.php
@@ -0,0 +1,223 @@
<?php
/**
* SocialiateController.php
*
* -Description-
*
* 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 <https://www.gnu.org/licenses/>.
*
* @link https://www.librenms.org
*/

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\Models\User;
use Config;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Event;
use Laravel\Socialite\Contracts\User as SocialiteUser;
use Laravel\Socialite\Facades\Socialite;
use LibreNMS\Config as LibreNMSConfig;
use LibreNMS\Exceptions\AuthenticationException;
use Log;

class SocialiteController extends Controller
{
/** @var SocialiteUser */
private $socialite_user;

public function __construct()
{
$this->injectConfig();
}

public static function registerEventListeners(): void
{
foreach (LibreNMSConfig::get('auth.socialite.configs', []) as $provider => $config) {
// Treat not set as "disabled"
if (! isset($config['listener'])) {
continue;
}
$listener = $config['listener'];

if (class_exists($listener)) {
Event::listen(\SocialiteProviders\Manager\SocialiteWasCalled::class, "$listener@handle");
} else {
Log::error("Wrong value for auth.socialite.configs.$provider.listener set, class: '$listener' does not exist!");
}
}
}

/**
* @return RedirectResponse|\Symfony\Component\HttpFoundation\Response
*/
public function redirect(Request $request, string $provider)
{
// Re-store target url since it will be forgotten after the redirect
$request->session()->put('url.intended', redirect()->intended()->getTargetUrl());

return Socialite::driver($provider)->redirect();
}

public function callback(Request $request, string $provider): RedirectResponse
{
$this->socialite_user = Socialite::driver($provider)->user();

// If we already have a valid session, user is trying to pair their account
if (Auth::user()) {
return $this->pairUser($provider);
}

$this->register($provider);

return $this->login($provider);
}

/**
* Metadata endpoint used in SAML
*/
public function metadata(Request $request, string $provider): \Illuminate\Http\Response
{
$socialite = Socialite::driver($provider);

if (method_exists($socialite, 'getServiceProviderMetadata')) {
return $socialite->getServiceProviderMetadata();
}

return abort(404);
}

private function login(string $provider): RedirectResponse
{
$user = User::where('auth_type', "socialite_$provider")
->where('auth_id', $this->socialite_user->getId())
->first();

try {
if (! $user) {
throw new AuthenticationException();
}

Auth::login($user);

return redirect()->intended();
} catch (AuthenticationException $e) {
flash()->addError($e->getMessage());
}

return redirect()->route('login');
}

private function register(string $provider): void
{
if (! LibreNMSConfig::get('auth.socialite.register', false)) {
return;
}

$user = User::firstOrNew([
'auth_type' => "socialite_$provider",
'auth_id' => $this->socialite_user->getId(),
]);

if ($user->user_id) {
return;
}

$user->username = $this->buildUsername();
$user->email = $this->socialite_user->getEmail();
$user->realname = $this->buildRealName();

$user->save();
}

private function pairUser(string $provider): RedirectResponse
{
$user = Auth::user();
$user->auth_type = "socialite_$provider";
$user->auth_id = $this->socialite_user->getId();

$user->save();

return redirect()->route('preferences.index');
}

private function buildUsername(): string
{
return $this->socialite_user->getNickname()
?: $this->socialite_user->getEmail()
?: $this->buildRealName();
}

private function buildRealName(): string
{
$name = '';

// These methods only exist for a few providers
if (method_exists($this->socialite_user, 'getFirstName')) {
$name = $this->socialite_user->getFirstName();
}

if (method_exists($this->socialite_user, 'getLastName')) {
$name = trim($name . ' ' . $this->socialite_user->getLastName());
}

if (empty($name)) {
$name = $this->socialite_user->getName();
}

return ! empty($name) ? $name : '';
}

/**
* Take the config from Librenms Config, and insert it into Laravel Config
*/
private function injectConfig(): void
{
foreach (LibreNMSConfig::get('auth.socialite.configs', []) as $provider => $config) {
Config::set("services.$provider", $config);

// Inject redirect URL automatically if not set
if (! Config::has("services.$provider.redirect")) {
Config::set("services.$provider.redirect",
route('socialite.callback', [$provider])
);
}

// Inject SAML redirect url automatically
$this->injectSAML2Config($provider);
}
}

private function injectSAML2Config(string $provider): void
{
if ($provider !== 'saml2') {
return;
}

if (! Config::has("services.$provider.sp_acs")) {
Config::set("services.$provider.sp_acs", route('socialite.callback', [$provider]));
}

if (! Config::has("services.$provider.client_id")) {
Config::set("services.$provider.client_id", '');
}

if (! Config::has("services.$provider.client_secret")) {
Config::set("services.$provider.client_secret", '');
}
}
}
2 changes: 1 addition & 1 deletion app/Http/Controllers/UserController.php
Expand Up @@ -98,7 +98,7 @@ public function store(StoreUserRequest $request, FlasherInterface $flasher)
$user = User::create($user);

$user->setPassword($request->new_password);
$user->auth_id = LegacyAuth::get()->getUserid($user->username) ?: $user->user_id;
$user->auth_id = (string) LegacyAuth::get()->getUserid($user->username) ?: $user->user_id;
$this->updateDashboard($user, $request->get('dashboard'));

if ($user->save()) {
Expand Down
2 changes: 1 addition & 1 deletion app/Http/Middleware/VerifyCsrfToken.php
Expand Up @@ -12,6 +12,6 @@ class VerifyCsrfToken extends Middleware
* @var array
*/
protected $except = [
// '*', // FIXME: CSRF completely disabled!
'/auth/*/callback',
];
}
19 changes: 19 additions & 0 deletions app/Providers/AppServiceProvider.php
Expand Up @@ -53,6 +53,7 @@ public function boot(MeasurementManager $measure)

$this->app->booted('\LibreNMS\DB\Eloquent::initLegacyListeners');
$this->app->booted('\LibreNMS\Config::load');
$this->app->booted('\App\Http\Controllers\Auth\SocialiteController::registerEventListeners');

$this->bootCustomBladeDirectives();
$this->bootCustomValidators();
Expand Down Expand Up @@ -174,5 +175,23 @@ private function bootCustomValidators()

return $validator->passes();
}, trans('validation.exists'));

Validator::extend('url_or_xml', function ($attribute, $value): bool {
if (! is_string($value)) {
return false;
}

if (filter_var($value, FILTER_VALIDATE_URL) !== false) {
return true;
}

libxml_use_internal_errors(true);
$xml = simplexml_load_string($value);
if ($xml !== false) {
return true;
}

return false;
});
}
}
2 changes: 1 addition & 1 deletion app/Providers/LegacyUserProvider.php
Expand Up @@ -205,7 +205,7 @@ public function retrieveByCredentials(array $credentials)
/** @var User $user */
$user->fill($new_user); // fill all attributes
$user->auth_type = $type; // doing this here in case it was null (legacy)
$user->auth_id = $auth_id;
$user->auth_id = (string) $auth_id;
$user->save();

return $user;
Expand Down
1 change: 1 addition & 0 deletions composer.json
Expand Up @@ -49,6 +49,7 @@
"php-flasher/flasher-laravel": "^0.9",
"phpmailer/phpmailer": "~6.0",
"predis/predis": "^1.1",
"socialiteproviders/manager": "^4.1",
"symfony/yaml": "^4.0",
"tecnickcom/tcpdf": "^6.4",
"tightenco/ziggy": "^0.9"
Expand Down

0 comments on commit 09929bd

Please sign in to comment.