LaravelでAD認証を導入する

#環境

PHP            : 7.3
Laravel        : 5.8.2(5.5~対応)

#はじめに

##注意
「adldap2-laravel」を利用してAuthの認証方法をActive Directory認証に変更する手順です。
Docker環境での手順となる為、後述のLDAP有効化手順部分については環境ごとに相違があるので必要であれば別途調べてください。

##本記事の実装内容

###ADサーバからの取得項目
- samaccountname
    - usernameとしてUsersテーブルに格納
- mail
    - emailとしてUsersテーブルに格納
- cn
    - nameとしてUsersテーブルに格納

###認証に使う項目
- samaccountname
- パスワード

###備考
samaccountnameには「ユーザ名@ドメイン」という値が入っている。
ログイン時に@以降まで入力するのはめんどくさいので
接尾辞を自動付与するように設定し、ユーザ名のみでログインできるようにしている。

#下準備（LDAPの有効化とadldap2-laravelインストール）
##LDAPの有効化
まず必要なパッケージをインストールします。

```console:Console
apt-get install -y ldap-utils libldap2-dev
```

##phpのldapエクステンションの有効化（dockerコンテナでのやり方）

```console:Console
docker-php-ext-configure ldap --with-libdir=lib/x86_64-linux-gnu/
```

```console:Console
docker-php-ext-install ldap
```

##有効化結果確認
下記コマンドに「ldap」が追加されているか確認します。

```console:Console
php -m
```

「php -m」コマンドにldapが存在しない場合、「/usr/local/php/conf.d/」に「docker-php-ext-ldap.ini」ファイルを作成します。（dockerコンテナでのやり方）
iniファイルの配置場所は環境によって違うかと思いますので、ご自身の環境に合わせて手順を実施してください。

```console:docker-php-ext-ldap.ini
extension=ldap.so
```



#実作業

新規のプロジェクトを作成して手順を示します。もちろん既存のプロジェクトに改修をかけても大丈夫です。

##新規プロジェクト作成
新規のLaravelプロジェクトを作成します。

```console:Console
composer create-project laravel/laravel ldap-test
```

##adldap2-laravelのインストール
プロジェクトのディレクトリ内に移動し、composerコマンドでadldap2-laravelをインストールします。

```console:Console
cd ldap-test
composer require adldap2/adldap2-laravel
```

##エイリアスの登録

```php:app.php
vi config/app.php

//～略～

'aliases' => [
    // already existing façade aliases

    'Adldap' => Adldap\Laravel\Facades\Adldap::class,  //←エイリアスの配列にこの記述を追加
],

//～略～
```

##サービスプロバイダの発行

```console:Console
php artisan vendor:publish --provider="Adldap\Laravel\AdldapServiceProvider"
php artisan vendor:publish --provider="Adldap\Laravel\AdldapAuthServiceProvider"
```

##認証ドライバを変更
authに使う認証ドライバを、デフォルト(eloquent)からldapへ変更します。

```php:auth.php
vi config/auth.php

//～略～

'providers' => [
    'users' => [
        'driver' => 'ldap', //←ここを'eloquent'から'ldap'へ変更
        'model'  => App\User::class,
    ],
],

//～略～
```

##.envファイルの編集
.envファイルにadldap2-laravel用の定数を追記します。
※"LDAP_USER_ATTRIBUTE=samaccountname"は、認証に使うActive Directoryの項目を指定しています。環境や要件に応じて適宣変更してください。

```console:.env
vi .env

LDAP_HOSTS=example.co.jp
LDAP_BASE_DN=dc=example,dc=co,dc=jp
LDAP_USER_ATTRIBUTE=samaccountname
LDAP_USER_FORMAT=samaccountname=%s,dc=example,dc=co,dc=jp
LDAP_CONNECTION=default
LDAP_ACCOUNT_SUFFIX=@example.co.jp

～略～
```

##マイグレーションファイルの編集

プロジェクト作成時にデフォルトで生成されるauth用のuserモデルに対するマイグレーションファイルをAD認証用のカラム構成に変更します。
カラム名は任意なので、ADから取得したい項目や、プロジェクトの仕様に合わせて適宜変更してください。
※既存のプロジェクトを利用し、既にuserモデルが存在する場合はそれを適宜変更してください。

```php:2014_10_12_000000_create_users_table.php
vi database/migrations/2014_10_12_000000_create_users_table.php

<?php

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

class CreateUsersTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('users', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->string('name');
            $table->string('username'); //←追加
            $table->string('email')->unique();
            //$table->timestamp('email_verified_at')->nullable();←メール認証は不要なので削除します。
            $table->string('password'); //←ここには空文字が登録されます。不要な気もするが参考資料は削除してないので真似します・・・。
            $table->rememberToken();//←passwordと同じく参考資料の真似。
            $table->timestamps();
        });
    }

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

##パスワードリセット用マイグレーションファイルの削除
パスワードリセット機能は今回使用しないので、そのマイグレーションファイルを削除します。

```console:Console
rm -f database/migrations/2014_10_12_100000_create_password_resets_table.php
```

##マイグレーション実行
テーブルを生成します。

```console:Console
php artisan migrate
```

##auth認証を生成
プロジェクトにauthを生成します。

```console:Console
php artisan make:auth
```

##LDAPの設定変更
2種類のconfigファイルをいじくります。基本的には「.env」で定義しているので、変更箇所は少ないです。
※長くなるので、コメント部分は省略します。

```php:ldap.php
vi config/ldap.php

<?php

return [
    'logging' => env('LDAP_LOGGING', false),
    'connections' => [
        'default' => [
            'auto_connect' => env('LDAP_AUTO_CONNECT', true),
            'connection' => Adldap\Connections\Ldap::class,
            'settings' => [
                'schema' => Adldap\Schemas\ActiveDirectory::class,
                'account_prefix' => env('LDAP_ACCOUNT_PREFIX', ''),
                'account_suffix' => env('LDAP_ACCOUNT_SUFFIX', ''),
                'hosts' => explode(' ', env('LDAP_HOSTS', 'example.co.jp')),//←変更
                'port' => env('LDAP_PORT', 389),
                'timeout' => env('LDAP_TIMEOUT', 5),
                'base_dn' => env('LDAP_BASE_DN', 'dc=example,dc=co,dc=jp'),//←変更
                'username' => env('LDAP_USERNAME'),
                'password' => env('LDAP_PASSWORD'),
                'follow_referrals' => false,
                'use_ssl' => env('LDAP_USE_SSL', false),
                'use_tls' => env('LDAP_USE_TLS', false),
            ],
        ],
    ],
];
```

```php:ldap_auth.php
vi config/ldap_auth.php

<?php

return [
    'connection' => env('LDAP_CONNECTION', 'default'),
    'provider' => Adldap\Laravel\Auth\DatabaseUserProvider::class,
    'model' => App\User::class,
    'rules' => [
        Adldap\Laravel\Validation\Rules\DenyTrashed::class,
    ],
    'scopes' => [
    ],
    'identifiers' => [
        'ldap' => [
			'locate_users_by' => env('LDAP_USER_ATTRIBUTE','samaccountname'),//←変更
			'bind_users_by' => env('LDAP_USER_ATTRIBUTE','samaccountname'),//←変更
        ],
        'database' => [
            'guid_column' => 'email',//←変更
            'username_column' => 'username',
        ],
        'windows' => [
            'locate_users_by' => 'samaccountname',
            'server_key' => 'AUTH_USER',
        ],
    ],
    'passwords' => [
        'sync' => env('LDAP_PASSWORD_SYNC', false),
        'column' => 'password',
    ],
    'login_fallback' => env('LDAP_LOGIN_FALLBACK', false),
    'sync_attributes' => [
        'email' => 'mail',//←変更
        'username' => env('LDAP_USER_ATTRIBUTE'),//←変更
        'name' => 'cn',
    ],
    'logging' => [
        'enabled' => env('LDAP_LOGGING', true),
        'events' => [
            \Adldap\Laravel\Events\Importing::class => \Adldap\Laravel\Listeners\LogImport::class,
            \Adldap\Laravel\Events\Synchronized::class => \Adldap\Laravel\Listeners\LogSynchronized::class,
            \Adldap\Laravel\Events\Synchronizing::class => \Adldap\Laravel\Listeners\LogSynchronizing::class,
            \Adldap\Laravel\Events\Authenticated::class => \Adldap\Laravel\Listeners\LogAuthenticated::class,
            \Adldap\Laravel\Events\Authenticating::class => \Adldap\Laravel\Listeners\LogAuthentication::class,
            \Adldap\Laravel\Events\AuthenticationFailed::class => \Adldap\Laravel\Listeners\LogAuthenticationFailure::class,
            \Adldap\Laravel\Events\AuthenticationRejected::class => \Adldap\Laravel\Listeners\LogAuthenticationRejection::class,
            \Adldap\Laravel\Events\AuthenticationSuccessful::class => \Adldap\Laravel\Listeners\LogAuthenticationSuccess::class,
            \Adldap\Laravel\Events\DiscoveredWithCredentials::class => \Adldap\Laravel\Listeners\LogDiscovery::class,
            \Adldap\Laravel\Events\AuthenticatedWithWindows::class => \Adldap\Laravel\Listeners\LogWindowsAuth::class,
            \Adldap\Laravel\Events\AuthenticatedModelTrashed::class => \Adldap\Laravel\Listeners\LogTrashedModel::class,
        ],
    ],
];
```

##Loginコントローラの変更

コントローラを丸々下記に書き換えます。

```php:LoginController.php
vi app/Http/Controllers/Auth/LoginController

<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Adldap\Laravel\Facades\Adldap;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

class LoginController extends Controller
{
    /*
    |--------------------------------------------------------------------------
    | Login Controller
    |--------------------------------------------------------------------------
    |
    | This controller handles authenticating users for the application and
    | redirecting them to your home screen. The controller uses a trait
    | to conveniently provide its functionality to your applications.
    |
    */

    use AuthenticatesUsers;

    /**
     * Where to redirect users after login.
     *
     * @var string
     */
    protected $redirectTo = '/';

    /**
     * Create a new controller instance.
     *
     * @return void
     */
    public function __construct()
    {
        $this->middleware('guest')->except('logout');
    }
    public function username()
    {
	return 'username';
    }
    
    
    protected function validateLogin(Request $request)
    {
        $this->validate($request, [
            $this->username() => 'required|string',
            'password' => 'required|string',
        ]);
    }

    protected function attemptLogin(Request $request)
    {
        $credentials = $request->only($this->username(), 'password');
        $username = $credentials[$this->username()];
        $password = $credentials['password'];

        $user_format = env('LDAP_USER_FORMAT', 'cn=%s,'.env('LDAP_BASE_DN', ''));
        $userdn = sprintf($user_format, $username);
        // you might need this, as reported in
        // [#14](https://github.com/jotaelesalinas/laravel-simple-ldap-auth/issues/14):
        //  Adldap::auth()->bind($userdn, $password);
      


	if(Adldap::auth()->attempt($username,$password,$bindAsUser = true)){
        //if(Adldap::auth()->attempt($userdn, $password, $bindAsUser = true)) {
            // the user exists in the LDAP server, with the provided password

            $user = \App\User::where($this->username(), $username)->first();
            if (!$user) {
                // the user doesn't exist in the local database, so we have to create one
                    
                $user = new \App\User();
                $user->username = $username;
                $user->password = '';

                // you can skip this if there are no extra attributes to read from the LDAP server
                // or you can move it below this if(!$user) block if you want to keep the user always
                // in sync with the LDAP server 
                $sync_attrs = $this->retrieveSyncAttributes($username . env('LDAP_ACCOUNT_SUFFIX'));
         
                foreach ($sync_attrs as $field => $value) {
                    $user->$field = $value !== null ? $value : '';
                }
            }
            

            // by logging the user we create the session, so there is no need to login again (in the configured time).
            // pass false as second parameter if you want to force the session to expire when the user closes the browser.
            // have a look at the section 'session lifetime' in `config/session.php` for more options.
            $this->guard()->login($user, true);
            return true;
        }
 
        // the user doesn't exist in the LDAP server or the password is wrong
        // log error
        return false;
    }

    protected function retrieveSyncAttributes($username)
    {
        $ldapuser = Adldap::search()->findBy('userprincipalname',$username);
        if ( !$ldapuser ) {
            // log error
            return false;
        }
        // if you want to see the list of available attributes in your specific LDAP server:
        // var_dump($ldapuser->attributes); exit;

        // needed if any attribute is not directly accessible via a method call.
        // attributes in \Adldap\Models\User are protected, so we will need
        // to retrieve them using reflection.
        $ldapuser_attrs = null;

        $attrs = [];

        foreach (config('ldap_auth.sync_attributes') as $local_attr => $ldap_attr) {
            if ( $local_attr == 'username' ) {
                continue;
            }

            $method = 'get' . $ldap_attr;
            if (method_exists($ldapuser, $method)) {
                $attrs[$local_attr] = $ldapuser->$method();
                continue;
            }

            if ($ldapuser_attrs === null) {
                $ldapuser_attrs = self::accessProtected($ldapuser, 'attributes');
            }

            if (!isset($ldapuser_attrs[$ldap_attr])) {
                // an exception could be thrown
                $attrs[$local_attr] = null;
                continue;
            }

            if (!is_array($ldapuser_attrs[$ldap_attr])) {
                $attrs[$local_attr] = $ldapuser_attrs[$ldap_attr];
            }

            if (count($ldapuser_attrs[$ldap_attr]) == 0) {
                // an exception could be thrown
                $attrs[$local_attr] = null;
                continue;
            }

            // now it returns the first item, but it could return
            // a comma-separated string or any other thing that suits you better
            $attrs[$local_attr] = $ldapuser_attrs[$ldap_attr][0];
            //$attrs[$local_attr] = implode(',', $ldapuser_attrs[$ldap_attr]);
        }

        return $attrs;
    }

    protected static function accessProtected ($obj, $prop)
    {
        $reflection = new \ReflectionClass($obj);
        $property = $reflection->getProperty($prop);
        $property->setAccessible(true);
        return $property->getValue($obj);
    }

}
```

##ルーティングの設定

ログイン機能周りのルーティングについて、「Auth:routes();」を設定していると
本来不要なパスワードリセット等もルーティング設定されてしまうため、変更します。

```php:web.php
vi routes/web.php

<?php

//Auth::routes();←これを無効にする

Route::get('login', 'Auth\LoginController@showLoginForm')->name('login');//←追加
Route::post('login', 'Auth\LoginController@login');//←追加
Route::post('logout', 'Auth\LoginController@logout')->name('logout');//←追加
```

##ログイン画面のviewファイルを変更

下記の部分がemailで認証するような表記になっているので、それをusernameで認証するよう変更します。
また、「パスワードを忘れた場合」のリンクは不要なので削除します。

```php:login.blade.php
vi resources/views/auth/login.blade.php

<div class="form-group row">
<label for="username" class="col-md-4 col-form-label text-md-right">User Name</label>

<div class="col-md-6">
    <input id="username" type="username" class="form-control{{ $errors->has('username') ? ' is-invalid' : '' }}"                 
        name="username" value="{{ old('username') }}" required autofocus>
        @if ($errors->has('username'))
            <span class="invalid-feedback" role="alert">
                <strong>{{ $errors->first('username') }}</strong>
            </span>
        @endif
    </div>
</div>

//-----------------この部分は削除-----------------

<a class="btn btn-link" href="{{ route('password.request') }}">
    {{ __('Forgot Your Password?') }}
</a>

//-----------------ここまで-----------------
```

##不要ファイルの削除

Auth関連で、不要になったファイルを削除します。

```console:Console

rm -f app/Http/Controllers/Auth/ForgotPasswordController.php
rm -f app/Http/Controllers/Auth/RegisterController.php
rm -f app/Http/Controllers/Auth/ResetPasswordController.php
rm -f app/Http/Controllers/Auth/VerificationController.php
rm -f resources/views/auth/register.blade.php
rm -f resources/views/auth/verify.blade.php
rm -fr resources/views/auth/passwords
```

##完成！試してみる
下記コマンドを実行し、「 http://localhost:8000 」にアクセスし、ADに登録されているアカウントでログインしてください。


```console:Console
php artisan serve --host 0.0.0.0
```

##参考資料
https://github.com/jotaelesalinas/laravel-simple-ldap-auth