Laravel+Passport+Vue实现Oauth2登录认证

前情提要: 这里主要详诉一些细节和理论和部分代码,这里不讲

Oauth2 是什么

阮一峰: OAuth 2.0 的四种方式

这里简单描述一下,Oauth主要是4方式

  • 授权码(authorization code)方式,指的是第三方应用先申请一个授权码,然后再用该码获取令牌。
  • 隐藏式: 有些 Web 应用是纯前端应用,没有后端, 允许直接向前端颁发令牌。这种方式没有授权码这个中间步骤,所以称为(授权码)"隐藏式"(implicit)。
  • 密码式: 如果你高度信任某个应用,RFC 6749 也允许用户把用户名和密码,直接告诉该应用。该应用就使用你的密码,申请令牌,这种方式称为"密码式"(password)。
  • 凭证式: 最后一种方式是凭证式(client credentials),适用于没有前端的命令行应用,即在命令行下请求令牌。

以上4中方式中,除了授权码模式都非常简单,也就是平常的登录 然后 发放 token,然后前端设置token 请求鉴权即可,这里不说这个,自行实践及理解(我认为和jwt的操作没什么区别)

所以主要描述授权码模式

授权码模式

使用场景

在平常的登录中,使用最多的第一为密码登录,第二的也就是授权码模式这种鉴权方式了

参考: 微信第三方应用使用微信授权登录包括QQ授权微博登录 等等

明细

他么都有非常明显的特点, 例如微信公众号: 唤起微信用户登录

流程: 公众号登录 -> 微信请求授权 -> 确认 -> 返回原平台 -> 登录成功

图片

公众号作为一个应用程序,

  • 1、获取用户信息的时候,发现用户未登录,然后重定向授权中心(微信),
  • 2、微信监测用户登录态,登录态正常(一般来说在公众号中打开都是登录的),用户确认,
  • 3、确认后微信根据开发者配置的重定向地址,携带code 返回公众号。
  • 4、公众号监测到用户已经授权成功,后端将获取到的code,向微信发起请求 asses_token
  • 5、微信确认code可用,返回用户包含的权限(scope)和 token 已经 刷新 token
  • 6、公众号确认用户信息可用,将token存储起来,并且返回数据给客户端
  • 7、接下来的每次请求,客户端都将携带token向公众号后端发起请求,公众号后端会进行判断token是否过期,过期则重复如上步骤

可以参考阮一峰对于授权码模式的描述

在Laravel 中使用 Passport

推荐官方文档

https://laravel.com/docs/8.x/passport

安装

命令

// 1、请安装对应Laravel 版本的passport
// 2、可以忽略版本 composer require laravel/passport --ignore-platform-reqs -vvv
composer require laravel/passport -vvv

// artisan 运行
// 数据表创建
php artisan migrate

// 秘钥创建
php artisan passport:install

// 创建一个客户端
php artisan passport:client

// 创建完成后,可以从 database.oauth_clients 表中看到
// 注意一下, 一般来说你刚创建的client_id 是 3
//  Personal Access Client : 个人访问客户端模式
//  Password Grant Client : 密码访问模式

模型

// 这里我们同时配置了 jwt,因为我们用的是前后端分离,没有采用 基本的web鉴权登录

namespace App\Models\Module;

use Illuminate\Notifications\Notifiable;
use Laravel\Passport\HasApiTokens;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Tymon\JWTAuth\Contracts\JWTSubject;

class ModuleUsers extends Authenticatable implements JWTSubject
{
    protected $table = 't_module_user';
    use HasApiTokens, Notifiable;


    public function getJWTIdentifier()
    {
        return $this->getKey();
    }

    public function getJWTCustomClaims()
    {
        return [];
    }
}

设置一下自定义的Client模型

主要是因为,我想免授权,就是不需要确认授权直接跳转走
namespace App\Models\Passport;

use Laravel\Passport\Client as BaseClient;

class Client extends BaseClient
{
    /**
    * Determine if the client should skip the authorization prompt.
    *
    * @return bool
    */
    public function skipsAuthorization()
    {
        return true;
    }
}

-----

// app/Providers/AuthServiceProvider.php

class AuthServiceProvider extends ServiceProvider {
    ...
    
    public function boot()
    {
        $this->registerPolicies();

        // 覆盖原来的 model
        Passport::useClientModel(Client::class);
    }
}

配置Guard

这里我们不使用默认的 guard : guard 值得是维持登录态的东西

客户端我们采用 client guard
授权中心我们采用 oauth guard

oauth 授权中心我们采用jwt认证方式, 默认的话使用的 laravel 自带的登录

// config/auth.php
'guards' => [ 
    ...
    'client' => [
        'driver' => 'passport',
        'provider' => 'moduleUsers',
        'hash' => false,
    ],

    'oauth' => [
        'driver' => 'jwt',
        'provider' => 'moduleUsers',
        'hash' => false,
    ],
]


'providers' => [
    ...
     'moduleUsers' => [
         'driver' => 'eloquent',
         'model' => App\Models\Module\ModuleUsers::class,
     ],
]

干掉原本的中间件

首先我们要确认,哪些路由需要权限认证的哪些不需要的

// 命令
php artisan route:ist

// 结果
// 我们找到核心的几个路由
方法  | 路由            |  中间件别名
POST | oauth/authorize |  web,auth
GET  | oauth/authorize |  web,auth
POST | oauth/token     |  throttle

如上所示,我们需要使用自定义中间件 接管 web 和 auth 路由的鉴权

否则的话基本会弹出 Login 路由不存在

app/Http/Kernel.php
protected $middlewareGroups = [
    'web' => [
        ...
        // 注释如下路由
        // \Illuminate\Session\Middleware\AuthenticateSession::class,
        ..
    ]
]

...

protected $routeMiddleware = [
    ...
    // 将原来的路由修改为如下路由
    'auth' => \App\Http\Middleware\OauthApiTokenMiddleware::class,

    // client 为新增
    'client' => ClientApiTokenMiddleware::class,
]

接下来我们处理一下,auth 中间件,这个中间件的作用主要是 授权中心的认证,也就是如上中说的 微信的作用

// \App\Http\Middleware\OauthApiTokenMiddleware
namespace App\Http\Middleware;

use App\Services\HttpResponse;
use Closure;
use Cyd622\LaravelApi\Response\ApiResponse;
use Tymon\JWTAuth\Http\Middleware\BaseMiddleware;

class OauthApiTokenMiddleware extends BaseMiddleware
{
    use ApiResponse;
    /**
    * Handle an incoming request.
    *
    * @param  \Illuminate\Http\Request  $request
    * @param  \Closure  $next
    * @return mixed
    */
    public function handle($request, Closure $next)
    {
        if (strtoupper($request->method()) === "options") {
            HttpResponse::to();
        }

        $user = null;

        // 获取cookie,将cookie放置到header 供jwt进行验证
        if($token = $request->get('token')) {
            $user = auth(env('MODULE_LOGIN_GUARD'))->setToken($token)->user();

            // 设置解析器,否则passport获取不到module User 用户
            $request->setUserResolver(function ($guard = null) {
                return auth(env('MODULE_LOGIN_GUARD'))->user();
            });
        }

        if($user) {
            return $next($request);
        }

        HttpResponse::toHttp(401, ['redirect' => \env('MODULE_LOGIN')], '请登录', 200);
    }
}

如上看到,我们会监测其是否拥有token,有的话则接管 $request 请求中的user,并设置 $request->user() 为我们对应guard的user

为什么要 setUserResolver ,因为 Passport 默认使用的 是 Auth:user() 的方式来获取user,这样会导致他拿到的是 guardauth 的用户,所以重置解析器

HttpResponse 直接抛出响应,参考最底部方法详情

现在我们已经完成了授权中心的认证了,我们来编写一下login的代码

namespace App\Http\Controllers\Module;

use App\Http\Controllers\Controller;
use App\Http\Requests\Module\LoginRequest;
use App\Models\Module\ModuleUsers;
use App\Traits\AuthTrait;
use Cyd622\LaravelApi\Auth\LoginActionTrait;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;

class AuthController extends Controller
{
    use LoginActionTrait, AuthTrait;
    protected $guard = '';

    public function __construct()
    {
        $this->guard = "oauth";
    }

    public function login(LoginRequest $request)
    {
        $credentials = request(['account', 'password', 'app_id']);

        if (!$token = $this->attempt($credentials)) {
            return $this->error('账号或者密码错误', 200, 401);
        }

        return $this->respondWithToken($token);
    }

    public function logout()
    {
        auth($this->guard)->logout();
        return $this->success('退出登陆成功');
    }

    public function refresh()
    {
        return $this->respondWithToken(auth($this->guard)->refresh());
    }

    protected function respondWithToken($token)
    {
        return $this->success([
            'access_token' => $token,
            'token_type' => 'bearer',
            'expires_in' => 60*60*60,
            'redirect' => $this->createRedirectUri()
        ]);
    }

    /**
    * 创建授权uri
    */
    protected function createRedirectUri()
    {
        $query = [
            'client_id' => request()->get('client_id'),
            'redirect_uri' => request()->get('redirect'),
            'response_type' => 'code',
            'scope' => '',
        ];

        return route('passport.authorizations.authorize', $query);
    }

    public function attempt($credentials)
    {
        list($username, $password, $app_id) = array_values($credentials);
        $account = 'username';
        if(filter_var($username, FILTER_VALIDATE_EMAIL)) {
            $account = 'email';
        }

        $user = ModuleUsers::query()->where($account, $username)->where(compact('app_id'))->first();
        if (Arr::get($user, 'password') == $password) {
            // 使guard 登录成功
            $token = auth($this->guard)->login($user);
            return $token;
        }
        return false;
    }
}

如上所示, 主要看 login 方法和 attempt 方法,登录成功后,则返回数据,告知前端要进行重定向地址

回到前端

1、创建登录界面,并设置登录
2、创建请求拦截器

这里的登录界面是授权中心的登录界面,所以我们要知道登录的客户端是谁,所以我们携带 client_id 和 redirect_uri 来到登录界面

// login.vue
mounted() {
    const { redirect_uri, client_id } = this.$route.query;
    if (!redirect_uri) {
        redirect_uri = '/document';
    }

    if (!client_id) {
        client_id = 3;
    }

    this.client_id = client_id
    this.redirect_uri = redirect_uri;
}


// login submit 

await login(Object.assign(this.form, {client_id: this.client_id, redirect_uri: this.redirect_uri}))
            .then(({ data }) => {
                const token = data.access_token;
                this.$store.dispatch('login', token);
                this.loading = false;

                this.$notify({
                    title: '提示',
                    message: '登录成功',
                    type: 'success'
                });

                //  登录成功后,重定向到授权客户端权限的地方,这里由于我们
                // 在 client Model 中 app/Models/Passport/Client.php
                // 设置了 skipsAuthorization 方法
                // 所以它会直接重定向到,数据库中配置的地址,并且会带上code
                setTimeout(() => {
                    window.location.href = data.redirect + "&token=" + token
                }, 2000)
            })
            .catch(() => {
                this.loading = false;
            });

前端客户端

async mounted() {
    // 设置当前 client id
    await this.setClient();

    // 检查是否包含code
    // code 存在则表明现在登录阶段
    await this.hasCode();
}

    /**
     * 设置当前的客户端
     */
    setClient() {
        this.$store.dispatch('client_id', this.client_id);
    },  

    /**
     * 监测到包含code的时候操作
     */
    async hasCode() {
        const { code } = this.$route.query;
        if (!code) {
            return;
        }

        let token = '';
        // getToken 对应的
        await getToken({ code: code, client_id: this.client_id })
            .then(({ data }) => {
                token = data.access_token;
                if (!token) {
                    return false;
                }
            })
            .catch(e => {
                console.log(e);
            });

        await this.$store.dispatch('login', token);

        window.location.href = 'document';
    },  


















HttpResponse 方法

仅供参考
<?php
/**
* User: surestdeng
* Date: 2020/5/11
* Time: 15:39:28
*/

namespace App\Services;

use App\Exceptions\DivHttpResponseException as HttpResponseException;
use Illuminate\Http\JsonResponse;

/**
* 抛出一个响应
* User: surestdeng
* Date: 2020/5/11
* Time: 3:45 下午
*/
class HttpResponse
{
    /**
    * 抛出一个响应
    * User: surest
    * Date: 2020/5/11
    */
    public static function to(int $code = 200, array $data = [], string $message = 'success')
    {
        $response = new JsonResponse(compact('code', 'data', 'message'));
        throw new HttpResponseException($response);
    }

    /**
    * 抛出一个特定httpcode响应
    * User: surest
    * Date: 2020/5/11
    */
    public static function toHttp(int $code = 200, array $data = [], string $message = 'success', int $httpCode = 200)
    {
        $response = new JsonResponse(compact('code', 'data', 'message'), $httpCode);
        throw new HttpResponseException($response);
    }
}

原文: https://surest.cn/archives/165/


邓锋
273 声望7 粉丝

想清楚做什么,想清楚如何去做,想清楚如何做的更好