DirectoryTree / LdapRecord-Laravel

Multi-domain LDAP Authentication & Management for Laravel.
https://ldaprecord.com/docs/laravel/v3
MIT License
483 stars 51 forks source link

Laravel Blade @auth not working. #608

Closed lela2011 closed 7 months ago

lela2011 commented 8 months ago

Environment:

Dear @stevebauman,

First of all thank you for this great library. After struggling a bit and some wrong information on my side I was able to authenticate a user against the university's OpenLDAP-Server. This is my code:

ldap.php:

'default' => env('LDAP_CONNECTION', 'default'),

    'connections' => [

        'default' => [
            'hosts' => [env('LDAP_HOST', 'XXX')],
            'username' => env('LDAP_USERNAME', 'XXX'),
            'password' => env('LDAP_PASSWORD', 'XXX'),
            'port' => env('LDAP_PORT', 636),
            'base_dn' => env('LDAP_BASE_DN', 'XXX'),
            'timeout' => env('LDAP_TIMEOUT', 5),
            'use_ssl' => env('LDAP_SSL', true),
            'use_tls' => env('LDAP_TLS', false),
            'use_sasl' => env('LDAP_SASL', false),
            'sasl_options' => [
                // 'mech' => 'GSSAPI',
            ],
        ],

    ],

    'logging' => [
        'enabled' => env('LDAP_LOGGING', true),
        'channel' => env('LOG_CHANNEL', 'stack'),
        'level' => env('LOG_LEVEL', 'debug'),
    ],

    'cache' => [
        'enabled' => env('LDAP_CACHE', false),
        'driver' => env('CACHE_DRIVER', 'file'),
    ],

auth.php:

'defaults' => [
        'guard' => 'web',
        'passwords' => 'users',
    ],

    'guards' => [
        'web' => [
            'driver' => 'session',
            'provider' => 'users',
        ],
    ],

    'providers' => [
        'users' => [
            'driver' => 'ldap',
            'model' => App\Ldap\User::class,
            'rules' => [
                App\Rules\OnlyXXX::class,
            ],
            'scopes' => [],
            'database' => [
                'model' => App\Models\User::class,
                'sync_passwords' => false,
                'sync_attributes' => [
                    'uid' => 'uid',
                    'first_name' => 'givenName',
                    'last_name' => 'sn',
                ],
            ],
        ],
    ],

    'passwords' => [
        'users' => [
            'provider' => 'users',
            'table' => 'password_reset_tokens',
            'expire' => 60,
            'throttle' => 60,
        ],
    ],

    'password_timeout' => 10800,

Overridden Group.php for Rules:

<?php

namespace App\Ldap;

use LdapRecord\Models\OpenLDAP\Group as OpenLDAPGroup;

class Group extends OpenLDAPGroup
{
    public static array $objectClasses = ['groupOfNames'];
}

Overridden LDAPRecord User.php:

<?php

namespace App\Ldap;

use LdapRecord\Models\OpenLDAP\User as OpenLDAPUser;
//use LdapRecord\Models\OpenLDAP\Group;
use LdapRecord\Models\Relations\HasMany;

class User extends OpenLDAPUser
{
    protected string $guidKey = 'uid';
    public function groups(): HasMany
    {
        return $this->hasMany(Group::class, 'member');
    }
}

DB-User-Model:

<?php

namespace App\Models;

use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use LdapRecord\Laravel\Auth\AuthenticatesWithLdap;
use LdapRecord\Laravel\Auth\LdapAuthenticatable;

class User extends Authenticatable implements LdapAuthenticatable
{
    use Notifiable, AuthenticatesWithLdap;

    protected $fillable = [
        'name',
        'email',
        'password',
    ];

    protected $hidden = [
        'password',
        'remember_token',
    ];

    protected $casts = [
        'email_verified_at' => 'datetime',
        'password' => 'hashed',
    ];
}

UserController:

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use LdapRecord\Laravel\Auth\ListensForLdapBindFailure;

class UserController extends Controller
{

    use ListensForLdapBindFailure;

    public function authenticate(Request $request)
    {
        $credentials = $request->validate([
            'uid' => 'required',
            'password' => 'required'
        ]);

        if(Auth::attempt($credentials)) {
            $request->session()->regenerate();
            Log::info("Logged in!");
            return redirect('/');
        }

        return back()->withErrors(['uid' => 'Invalid Credentials'])->onlyInput('shortname');
    }
}

Users-Table-Migration:

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('users', function (Blueprint $table) {
            $table->string('uid')->unique();
            $table->string('first_name');
            $table->string('last_name');
            $table->string('xxx')->nullable();
            $table->string('xxx')->nullable();
            $table->string('xxx')->nullable();
            $table->longText('xxx')->nullable();
            $table->string('password');
            $table->rememberToken();
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('users');
    }
};

I had to override the User and Group of the library cause else the LDAP-Filter didn't work. In my home route I use

@auth
//welcome screen
@else
//login screen
@endauth

For some reason I always see the login screen as if the application is not aware of the fact that the user is signed in. Can you help me figure this out? I don't have any other settings changed in the app. Or not that I would have done it consciously.

stevebauman commented 8 months ago

Hi @lela2011! Thanks so much for the sponsorship ❤️

I can definitely help you get to the bottom of this 👍

  1. Can you confirm that you can connect successfully to your LDAP server by running the php artisan ldap:test command?

  2. Can you confirm after you login with an LDAP user that a session is properly generated with your configured session.driver (config/session.php file)? This is set to file by default, so check in the storage/framework/sessions folder after logging in successfully to see if a new file is created. If a new file is created, open it, and make sure you see your users uid inside.

  3. Can you confirm if you see any error log entries in your storage/logs folder after authenticating?

lela2011 commented 8 months ago

Dear @stevebauman

  1. I checked $ php artisan ldap:test and it says that it successfully connected.
  2. I checked the config.php and it is 100% default. I didn't setup anything in there. At least it's the first time I'm opening this file. The session-file itself also doesn't contain the uid.
  3. The log returns: User [xxx] has successfully passed LDAP authentication. and User [xxx] has successfully authenticated.

I suppose I have to setup session.php somehow?

stevebauman commented 8 months ago

Thanks for checking all of that @lela2011!

Can you try removing $request->session()->regenerate() inside of your controller and try authenticating again to see if it's possibly a session regeneration issue?

lela2011 commented 8 months ago

@stevebauman That unfortunately didn't work. The session-file still doesn't have the uid in it and the @auth functionality is not working.

lela2011 commented 8 months ago

@stevebauman These are my routes:

<?php

use App\Http\Controllers\HomeController;
use App\Http\Controllers\UserController;
use Illuminate\Support\Facades\Route;

Route::get('/', [HomeController::class, 'home']);

Route::get('/test', [HomeController::class, 'test']);

Route::post('/authenticate', [UserController::class, 'authenticate']);

This is my session.php file

<?php

use Illuminate\Support\Str;

return [

    'driver' => env('SESSION_DRIVER', 'file'),

    'lifetime' => env('SESSION_LIFETIME', 120),

    'expire_on_close' => false,

    'encrypt' => false,

    'files' => storage_path('framework/sessions'),

    'connection' => env('SESSION_CONNECTION'),

    'table' => 'sessions',

    'store' => env('SESSION_STORE'),

    'lottery' => [2, 100],

    'cookie' => env(
        'SESSION_COOKIE',
        Str::slug(env('APP_NAME', 'laravel'), '_').'_session'
    ),

    'path' => '/',

    'domain' => env('SESSION_DOMAIN'),

    'secure' => env('SESSION_SECURE_COOKIE'),

    'http_only' => true,

    'same_site' => 'lax',

];

I saw somewhere that I would have to set middleware but I haven't done that in the test project what uses DB-Authentication.

stevebauman commented 8 months ago

Does the LDAP user you login with have their database record synchronized upon login? And is the uid column filled with their uid?

stevebauman commented 8 months ago

I saw somewhere that I would have to set middleware but I haven't done that in the test project what uses DB-Authentication.

You only need the auth middleware if you want to protect a route from guest users 👍

Have you modified your RouteServiceProvider by chance and removed the web middleware?

lela2011 commented 8 months ago

@stevebauman Could you maybe give me a few more details what I would have to do to check that? I am syncing uid, first name and surname. At least that's how I read my auth.php file.

stevebauman commented 8 months ago

Yea for sure -- just place this in your routes/web.php file at the top, refresh your application, and see if any dumped user exists with the uid you signed in with:

dd(\App\Models\User::all());
lela2011 commented 8 months ago

@stevebauman

Have you modified your RouteServiceProvider by chance and removed the web middleware?

I checked. That one is still the same as the default laravel config

lela2011 commented 8 months ago

@stevebauman

Yea for sure -- just place this in your routes/web.php file at the top, refresh your application, and see if any dumped user exists with the uid you signed in with:

This is what it says. And if I check the database ther also is a user in there with the uid

Illuminate\Database\Eloquent\Collection {#1079
  #items: array:1 [
    0 => App\Models\User {#1035
      #connection: "mysql"
      #table: "users"
      #primaryKey: "id"
      #keyType: "int"
      +incrementing: true
      #with: []
      #withCount: []
      +preventsLazyLoading: false
      #perPage: 15
      +exists: true
      +wasRecentlyCreated: false
      #escapeWhenCastingToString: false
      #attributes: array:13 [
        "uid" => "xxx"
        "first_name" => "xxx"
        "last_name" => "xxx"
        "xxx" => null
        "xxx" => null
        "xxx" => null
        "xxx" => null
        "password" => "xxx"
        "remember_token" => null
        "created_at" => "2023-11-15 16:04:57"
        "updated_at" => "2023-11-15 16:04:57"
        "guid" => "xxx"
        "domain" => "default"
      ]
      #original: array:13 [
        "uid" => "xxx"
        "first_name" => "xxx"
        "last_name" => "xxx"
        "xxx" => null
        "xxx" => null
        "xxx" => null
        "xxx"=> null
        "password" => "xxx"
        "remember_token" => null
        "created_at" => "2023-11-15 16:04:57"
        "updated_at" => "2023-11-15 16:04:57"
        "guid" => "xxx"
        "domain" => "default"
      ]
      #changes: []
      #casts: array:2 [
        "email_verified_at" => "datetime"
        "password" => "hashed"
      ]
      #classCastCache: []
      #attributeCastCache: []
      #dateFormat: null
      #appends: []
      #dispatchesEvents: []
      #observables: []
      #guarded: array:1 [
        0 => "*"
      ]
      #rememberTokenName: "remember_token"
    }
  ]
  #escapeWhenCastingToString: false
}
lela2011 commented 8 months ago

@stevebauman I figured out that the authentication is lost once a redirect happens. As long as I don't direct away from my authenticate route there isn't a problem.

lela2011 commented 8 months ago

@stevebauman After further debugging I found 2 more things.

  1. dd(Auth::user()); returns the user if it's called before the redirect
  2. dd(Auth::id()); returns null if it's called before the redirect

Might that be the issue?

stevebauman commented 8 months ago

Yea that's definitely an issue @lela2011, can you try adding this method to your extended app/Ldap/User.php model and then try logging in?

public function getAuthIdentifier(): ?string
{
    return $this->getFirstAttribute($this->guidKey);
}
lela2011 commented 7 months ago

@stevebauman this solved the issue. Additionally I also had to make sure that protected $primaryKey = "uid" and public $incrementing = false; was set.

I have one more question though: The user table will be accessed by another php script to pull certain fields. I have some predefined data from a csv that I would like to add to the users table before the user is signed in. If I prepopulate the table with for example uid, first_name, last_name and then try to sign in I get this error: SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry 'xxx' for key 'PRIMARY' Is there any way to avoid that error?

stevebauman commented 7 months ago

That's great to hear @lela2011! I'll create a bug fix for the getAuthIdentifier() method.

In regards to your question, that error means a user already exists in the database with the given uid. You'll have to use the firstOrCreate method to ensure you don't attempt creating a new database entry for a user that already exists with that uid:

use App\Models\User;

// The first array will be merged with
// the second if the user doesn't exist.
$user = User::firstOrCreate(['uid' => $uid], [
    'first_name' => $firstName,
    'last_name' => $lastName,
]);
lela2011 commented 7 months ago

@stevebauman where would I have to put that code? In the AuthController?

stevebauman commented 7 months ago

Oh sorry I misread your previous comment. If you have existing users in your database that you would like to synchronize with, use the sync_existing option in your auth.php configuration:

https://ldaprecord.com/docs/laravel/v3/auth/database/configuration/#sync-existing-records

// config/auth.php

return [
    // ...

    'providers' => [
        'users' => [
            'driver' => 'ldap',
            'model' => App\Ldap\User::class,
            'rules' => [
                App\Rules\OnlyXXX::class,
            ],
            'scopes' => [],
            'database' => [
                'model' => App\Models\User::class,
                'sync_passwords' => false,
                'sync_attributes' => [
                    'uid' => 'uid',
                    'first_name' => 'givenName',
                    'last_name' => 'sn',
                ],
                'sync_existing' => ['uid' => 'uid'], // <-- Added here.
            ],
        ],
    ],
],
lela2011 commented 7 months ago

@stevebauman Alright. That makes sense. I forgot about that option. I opted for a second 'user-profile' table with a 1:1 relation but I might rollback and implement this. I have one last question though. How would you create something like an admin user? The user should also login with LDAP but then be able to edit everyone's data and not just their own. Preferably they should should also be able to set additional administrators.

stevebauman commented 7 months ago

Sounds good!

In regards to your question, you can use an attribute handler to set a flag on your User model, or create an event listener to assign a role to the user after its synchronized via the php artisan ldap:import command (the way you implement a role/permission system is up to you of course):

https://ldaprecord.com/docs/laravel/v3/auth/configuration/#attribute-handlers

Using an Attribute Handler:

namespace App\Ldap;

use App\Ldap\User as LdapUser;
use App\Ldap\Group as LdapGroup;
use App\Models\User as DatabaseUser;

class AttributeHandler
{
    public function handle(LdapUser $ldap, DatabaseUser $database)
    {
        $database->is_admin = $ldap->isDescendantOf('ou=Administrators,dc=local,dc=com');
    }
}

Using an Event Listener on Import Saved event:

use App\Models\Role;
use LdapRecord\Models\OpenLDAP\Group;
use LdapRecord\Laravel\Events\Import\Saved;

class SyncRole
{
    public function handle(Saved $event)
    {
        $administrators = Group::findOrFail(
            'cn=Administrators,dc=local,dc=com',
        );

        if ($event->object->groups()->exists($administrators)) {
            $event->eloquent->roles()->syncWithoutDetaching(
                Role::firstWhere('administrators')
            );   
        }
    }
}

Closing this out now as your original issue has been resolved! 🙏