Passport的初步研究,并使用Passport扩展Laravel-admin的登录方式

使用laravel/passport实现Oauth2登录,并结合到Laravel-admin

环境介绍

  • 开发工具: phpstorm
  • 运行环境:
    • Homestead 9.0(Ubuntu18.04)
    • php7.2
    • mysql 5.7
    • larave 5.5.45
    • passport 4.0.3
    • laravel-admin 1.7.2

业务场景

实际业务中需要多个后台,后台是基于Laravel-admin构建的,主后台中存放这业务数据,其余的分后台需要从主后台获取管理员数据.

具体流程

  1. 首选主后台管理员登录
  2. 管理员点击进入分后台时系统生成授权码
  3. 管理员携带授权码登录到分后台
  4. 分后台根据授权码获取该用户的 access_tokenrefresh_token
  5. 获取成功以后进行其他相关操作

开发流程

安装Passport , Laravel-admin

composer require laravel/passport
composer require encore/laravel-admin
......

注意 Laravel 5.5 需要使用 laravel/passport 4.0

分析Passport中的实现逻辑,在 主系统(QQ) 中实现OAuthServer

首先我们查看一下路由

 oauth/authorize                         | \Laravel\Passport\Http\Controllers\AuthorizationController@authorize
 oauth/authorize                         | \Laravel\Passport\Http\Controllers\DenyAuthorizationController@deny
 oauth/authorize                         | \Laravel\Passport\Http\Controllers\ApproveAuthorizationController@approve
 oauth/clients                           | \Laravel\Passport\Http\Controllers\ClientController@forUser
 oauth/clients                           | \Laravel\Passport\Http\Controllers\ClientController@store
 oauth/clients/{client_id}               | \Laravel\Passport\Http\Controllers\ClientController@update
 oauth/clients/{client_id}               | \Laravel\Passport\Http\Controllers\ClientController@destroy
 oauth/personal-access-tokens            | \Laravel\Passport\Http\Controllers\PersonalAccessTokenController@forUser
 oauth/personal-access-tokens            | \Laravel\Passport\Http\Controllers\PersonalAccessTokenController@store
 oauth/personal-access-tokens/{token_id} | \Laravel\Passport\Http\Controllers\PersonalAccessTokenController@destroy
 oauth/scopes                            | \Laravel\Passport\Http\Controllers\ScopeController@all
 oauth/token                             | \Laravel\Passport\Http\Controllers\AccessTokenController@issueToken
 oauth/token/refresh                     | \Laravel\Passport\Http\Controllers\TransientTokenController@refresh
 oauth/tokens                            | \Laravel\Passport\Http\Controllers\AuthorizedAccessTokenController@forUser
 oauth/tokens/{token_id}                 | \Laravel\Passport\Http\Controllers\AuthorizedAccessTokenController@destroy

分析前三个路由 oauth/authorize,分别对应的是显示授权页面,拒绝授权,接受授权

嵌入接收授权页面到 Laravel-admin

使用命令发布passport的视图文件

php artisan vendor:publish --tag=passport-views

授权页面在 resources/views/vendor/passport/authorize.blade.php ,这个页面可以自己设计,也可以直接参考 Passport 自带的授权页面,但是注意其中的表单提交路径!

增加一个直接进入授权页面的控制器

为了方便理解,我这边将我们的 主后台 称为 QQ ,分后台 称为 百度

一般的授权流程是由用户在第三方系统发起的,比如用户在百度上选择QQ登录,然后跳转到QQ进行授权,而我们的系统需要直接向用户展示QQ登录页面,然后直接重定向到百度.

  • 正常流程 百度->选择QQ登录->QQ->用户授权->302到百度->百度根据code获取用户信息
  • 我们需要的流程 QQ->用户授权->302到百度->百度根据code获取用户信息

控制器实际上就是一个发起跳转,来代替用户在百度上选择QQ登录这一操作.注意此处 authorize.get 是我自定义的路由别名,请根据实际情况修改代码.

$url=route('authorize.get',[
    'client_id' => $this->mode->client_id,
    'redirect_uri' => $this->mode->admin_url,
    'response_type' => 'code',
    'scope' => '',
]);
redirect($url);

自定义授权路由

通过观察源码发现系统路由存在一个坑点,Admin后台的用户登录部分判断使用的都是 Admin::guard() 方式去获取 guard,而Passport是使用 $request->user() 方式获取的,这样获取的就是默认的guard,默认的guard在config/auth.php中可以配置.既然访问的路径需要修改,直接自己创建路由操作起来比较方便.

admin中间件对应的代码位于vendor/encore/laravel-admin/src/Middleware/Authenticate.php

......
/**
 * Handle an incoming request.
 *
 * @param \Illuminate\Http\Request $request
 * @param \Closure                 $next
 *
 * @return mixed
 */
public function handle($request, Closure $next)
{
    $redirectTo = admin_base_path(config('admin.auth.redirect_to', 'auth/login'));
    if (Admin::guard()->guest() && !$this->shouldPassThrough($request)) {
        return redirect()->guest($redirectTo);
    }
    return $next($request);
}
......

此处AdminFacade,找到真正的实现位于 vendor/encore/laravel-admin/src/Admin.php 其中获取 guard 方法如下:

/**
 * Attempt to get the guard from the local cache.
 *
 * @return \Illuminate\Contracts\Auth\Guard|\Illuminate\Contracts\Auth\StatefulGuard
 */
public function guard()
{
    $guard = config('admin.auth.guard') ?: 'admin';
    return Auth::guard($guard);
}

所以我定义了两个路由用户显示授权页面以及处理授权请求.

$router->group(['prefix'=>'oauth','middleware'=>'auth:'.(config('admin.auth.guard') ?: 'admin')],function (Router $router){
    $router->get('authorize','\Laravel\Passport\Http\Controllers\AuthorizationController@authorize')->name('authorize.get');
    $router->post('authorize','\Laravel\Passport\Http\Controllers\ApproveAuthorizationController@approve');
});

自定义Token的获取路由

Route::post('/admin/api/oauth/token','\Laravel\Passport\Http\Controllers\AccessTokenController@issueToken')->middleware('throttle');

在分后台访问上面的路由就可以获取到用户的 access_tokenrefresh_token ,这里通过curl来模拟

curl -X POST \
  http://homestead.test/admin/api/oauth/token \
  -F grant_type=authorization_code \
  -F client_id=1 \
  -F client_secret=Kkwcww*****Qxy0uJIp4D \
  -F redirect_uri=http://*****.test/admin \
  -F code=def502009c********2cf590257f7ff704d7c5

响应如下

{
    "token_type": "Bearer",
    "expires_in": 31622400,
    "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI**************Bw6n6bZX6qC0pgqzLpXV5yRC2B5R0",
    "refresh_token": "def502009b328a313c3c414c6eca26**********f855c7da9625f9"
}

以上完成了显示授权页面,生成授权 code ,获取 access_tokenrefresh_token.还需要提供一个获取用户信息的接口


使用 access_token 获取用户基本信息

这个过程相对会比较复杂一点,我分X步来完成.

  1. 新增 Guard 用于 api认证

    Guard译为看守,相当于说是用来控制用户登录的,一个guards数组里面都会有两个元素 driverprovider , provider 一般是提供认证方法的, driver 就是这个认证流程的驱动,通过session,token,passport等不同的驱动来维护登录状态.所以我们需要定义一个新的Guard用来判断分系统的API通讯认证,修改laravel-admin的配置文件 config/admin.php ,新增 admin_api Guard,驱动选择 passport ,这里的 admin_api 就是我新加的.

    ......
    'auth' => [
        ......
        'guards' => [
            'admin' => [
                'driver'   => 'session',
                'provider' => 'admin',
            ],
            'admin_api' => [ //新增的Guard
                'driver' => 'passport',
                'provider' => 'admin'
            ]
        ],
        .......
    ],
    ......
    
  2. 修改模型

    需要在模型中添加 use HasApiTokens;,因为passport需要一些关联关系来获取用户认证时需要的信息.这边需要继承原有的模型,增加use.

    ......
    use \Encore\Admin\Auth\Database\Administrator;
    use Laravel\Passport\HasApiTokens;
    
    class Admin extends Administrator
    {
        use HasApiTokens;
        ......
    }
    

    修改laravel-admin的配置信息,启用自定义的用户模型.

    ......
    'providers' => [
        'admin' => [
            'driver' => 'admin',
            'model' => Admin::class, //必改
        ],
    ],
    ......
    'users_model' => Admin::class, //可以选择不改
    ......
    
  3. 新增一个控制器用来获取用户信息

    这边要注意一下路由定义的位置,因为laravel-admin的路由组是带admin的中间件的,这个中间件是通过 admin Guard来判断用户是否登录的,如果定义在一起会被拦截下来.(如果需要继续使用admin的权限管理需要自己引入对应中间件,会涉及一些其他问题,比如默认的Guard需要调整,我这边就不引入了)

    Route::group([
        'prefix' => config('admin.route.prefix').'/api',
        'middleware' => 'auth:admin_api',
        'namespace' => config('admin.route.namespace'),
    ], function (Router $router) {
        $router->get('admin_user_info','ApiController@adminUserInfo');
    });
    

    laravel-admin中注册的路由: (文件位于 vendor/encore/laravel-admin/src/AdminServiceProvider.php )

    ......
    protected $routeMiddleware = [
        'admin.auth'       => Middleware\Authenticate::class,
        'admin.pjax'       => Middleware\Pjax::class,
        'admin.log'        => Middleware\LogOperation::class,
        'admin.permission' => Middleware\Permission::class,
        'admin.bootstrap'  => Middleware\Bootstrap::class,
        'admin.session'    => Middleware\Session::class,
    ];
    ......
    

修改 分系统(百度) 的登录逻辑

由于系统自带的登录逻辑是通过用户名+密码的形式,而我们这边采用的只有一个code字段,了解Laravel登录逻辑的就会发现登录分为 retrieveByCredentialsvalidateCredentials 这两步,至于其他的 retrieveByTokenupdateRememberToken 在我们这种情景下是不适用的.所以直接覆盖掉原有的函数即可.接下来的重点是 retrieveByCredentialsvalidateCredentials 的实现了.

定义新的 Provider 并注册

创建 AdminAuthServiceProvider 然后在 app/Providers/AuthServiceProvider.php 中注册别名为 admin 的provider

namespace App\Providers;

use GuzzleHttp\Client;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Auth\EloquentUserProvider;

class AdminAuthServiceProvider extends EloquentUserProvider
{
    public function retrieveByToken($identifier, $token)
    {

    }

    public function updateRememberToken(Authenticatable $user, $token)
    {

    }

    public function retrieveByCredentials(array $credentials)
    {
        $http = new Client();

        $response = $http->post(env('OAUTH_SERVER') . '/admin/api/oauth/token', [
            'form_params' => [
                'grant_type' => 'authorization_code',
                'client_id' => env('CLIENT_ID'),
                'client_secret' => env('CLIENT_SECRET'),
                'redirect_uri' => env('APP_URL') . '/admin/auth/login',
                'code' => $credentials['code'],
            ],
        ]);

        $oauthToekn = json_decode((string)$response->getBody(), true);

        $response = $http->get(env('OAUTH_SERVER') . '/admin/api/admin_user_info', [
            'headers' => ['Authorization' => 'Bearer ' . $oauthToekn['access_token']],
        ]);

        $user = json_decode((string)$response->getBody(), true)['data'];
        $user = Admin::firstOrCreate($user);
        $user->access_token = $oauthToekn['access_token'];
        $user->refresh_token = $oauthToekn['refresh_token'];
        $user->token_type = $oauthToekn['token_type'];
        $user->save();
        return $user;
    }

    public function validateCredentials(Authenticatable $user, array $credentials)
    {
        return !empty($user->access_token) && !empty($user->refresh_token);
    }
}
public function boot()
{
    ......
    Auth::provider('admin', function ($app, array $config) {
        // Return an instance of Illuminate\Contracts\Auth\UserProvider...
        return new AdminAuthServiceProvider($app['hash'], $config['model']);
    });
    ......
}

修改 config/admin.php 中的 auth.providers.admin.driver ,改为admin

......
'providers' => [
    'admin' => [
        'driver' => 'admin',//修改此处为上面定义的admin
        'model'  => Encore\Admin\Auth\Database\Administrator::class,
    ],
],
......

覆盖 AuthController 中的 getLoginpostLogin

class AuthController extends BaseAuthController
{
    public function getLogin()
    {
        //验证是否登录
        if ($this->guard()->check()) {
            return redirect($this->redirectPath());
        }

        $credentials = request()->only(['code']);
        if ($this->guard()->attempt($credentials)) {
            return $this->sendLoginResponse(request());
        }

        abort(405);//登录失败抛出异常,根据具体情况修改
    }

    public function postLogin(Request $request)
    {
        abort(405);//屏蔽默认的postLogin方法,可以随便抛出一个异常,个人认为这样做比较方便也没有什么隐患
    }
}
点赞