Laravel 实践 – Api Token认证

常见的Session认证中
1 客户端如果没有传递SessionID过来,那么就认为未认证
2 如果传递了SessionID过来,服务端根据SessionID定位Session,如果定位不到,那么就认为未认证
3 如果定位到了Session,但是Session中的认证标记不存在或无效,那么就认为未认证
4 Session中的认证标记存在并有效,认证通过

客户端没有传递SessionID过来,服务端一般会回写一个携带了SessionID的Cookie(意味着启动一个会话,也可能不回写,比如浏览静态资源),下次这个Cookie就会回传到过来,这样就能确定多个请求之间是同一个会话。然后再从Session中判断认证标志从而确定是否已认证。

如果客户端没有回传SessionID(通常是使用Cookie作为载体),一般服务端就会启动一个新会话,那么之前的会话就无法定位了,但是服务端会话数据还是可能存在。如果客户端回传了SessionID,服务端定位不到这个会话,说明已经长久不登录,服务端的会话已经被删除,这个时候也产生一个新的会话,另外就是如果定位到会话(未被删除),但是会话中没有认证标记,或有标记,但是时间已经超时,这时可以不产生新的会话(一般登录成功后会更换一个SessionID)。

这里面,我们是否可以把这个SessionID(不重复),直接对应一个用户,如果传递了SessionID,但是定位不到用户,认为未认证。如果定位到了用户,并且token未超时,认为认证通过。和Session方案相比,Session可以存储认证凭证,从而不用每次都查询。这里的简化流程,就是API Token认证。

在Laravel中,提供了TokenGuard,简单来说就是比对Token,具体操作流程:
1 在users表中添加api_token字段

$table->string('api_token', 60)->unique()->nullable();

2 在users表中寻找一个用户,把api_token字段填写一个任意token

3 在routes的api.php中定义一个闭包路由,应用auth:api中间件(auth表示需要认证,默认使用web对应的Guard,冒号后可以指定Guard)

Route::middleware('auth:api')->get('/user', function (Request $request) {
    return $request->user();
});

4 测试

curl -X http://demo.text/api/user \
-H "Accept: application/json" \
-d "token=xxxxxxxxxxxxxxxxxx"

注意:定义在api.php中的路由,访问时是不会产生会话的(确少StartSession中间件)。另外,Laravel的异常是根据请求是否是ajax或是否有Accept请求头并且包含json来渲染JSON返回的,否则就是渲染Html输出。但是API应该总是返回JSON,所以应该总是携带Accept: application/json请求头。不过每次都要这么写,多少有点麻烦,所以换一个姿势。API的URL都是以api开头的,所以可以修改$request的请求头,这个可以使用容器的rebinding来进行:

#文件:app/Providers/AppServiceProvider.php

    public function register()
    {
        $this->addAcceptableJsonType();
    }

    /**
     * Add "application/json" to the "Accept" header for the current request.
     */
    protected function addAcceptableJsonType()
    {
        $this->app->rebinding('request', function ($app, $request) {
            if ($request->is('api/*')) {
                $accept = $request->header('Accept');
                if (!str_contains($accept, ['/json', '+json'])) {
                    $accept = rtrim('application/json,' . $accept, ',');
                    $request->headers->set('Accept', $accept);
                    $request->server->set('HTTP_ACCEPT', $accept);
                    $_SERVER['HTTP_ACCEPT'] = $accept;
                }
            }
        });
    }

在用户登录后,让其在后台自己产生token,然后把token分发出去使用,这是一个很传统的做法。另外,token长期有效,会存在安全问题。可以改造一下,在登录成功后更换token,登出时擦除token。另外,还可以给token设置一个超时时间:

#文件:routes/api.php
// 登录(每次登录都更换token,登录相当于更换token,登录成功后返回用户实例,包含token)
// 请求体:email  password
Route::post('backend/login', 'API\Backend\Auth\LoginController@login');
// 注册(注册成功后返回用户实例,包含token)
// 请求体: name  email  password  password_confirmation
Route::post('backend/register', 'API\Backend\Auth\RegisterController@register');
// 重置密码 - 发送邮件
// 请求体: email
Route::post('backend/password/email', 'API\Backend\Auth\ForgotPasswordController@sendResetLinkEmail');
// 重置密码 - 输入新密码
// 请求体: token  email  password  password_confirmation
Route::post('backend/password/reset', 'API\Backend\Auth\ResetPasswordController@reset');

// Token保护的路由
// 添加请求头:Authorization  =>  "Bearer Token"
Route::group([
    'middleware' => ['auth:backend_api']
], function () {
    // 登出(撤销token),需要传递token
    Route::any('backend/logout', 'API\Backend\Auth\LoginController@logout');

    // 获取用户
    Route::get('backend/admin', function (Request $request) {
        return [
            'data' => $request->user()
        ];
    });
});

#文件 config/auth.php
#添加backend_api
<?php

return [

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

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

        'backend' => [
            'driver' => 'session',
            'provider' => 'admins',
        ],

        'api' => [
            'driver' => 'token',
            'provider' => 'users',
        ],

        'backend_api' => [
            'driver' => 'token',
            'provider' => 'admins',
        ],
    ],

    'providers' => [
        'users' => [
            'driver' => 'eloquent',
            'model' => App\Models\User::class,
        ],

        'admins' => [
            'driver' => 'eloquent',
            'model' => App\Models\Admin::class,
        ],
    ],

    'passwords' => [
        'users' => [
            'provider' => 'users',
            'table' => 'password_resets',
            'expire' => 60,
        ],
        'admins' => [
            'provider' => 'admins',
            'table' => 'admin_password_resets',
            'expire' => 60,
        ],
    ],

];

这里把用户存储到admins表,密码找回存储到admin_password_resets表。拷贝一套Auth文件,针对性覆盖某些方法:

#文件: app/Http/Controllers/API/Backend/Auth/LoginController.php
<?php

namespace App\Http\Controllers\API\Backend\Auth;

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

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()
    {
    }

    /**
     * 用户通过认证,返回JSON
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */
    protected function sendLoginResponse(Request $request)
    {
        //API调用,没有启动会话
        //$request->session()->regenerate();
        $this->clearLoginAttempts($request);
        $user = with($this->guard()->user())->makeToken();

        return response()->json([
            'data' => $user,
        ], 200);
    }

    /**
     * 取回需要验证的信息
     * 覆盖来自AuthenticatesUsers中的credentials方法
     *
     * @param  \Illuminate\Http\Request $request
     * @return array
     */
    public function logout(Request $request)
    {
        with($request->user())->freshToken();

        return response()->json(['data' => 'Token已经擦除'], 200);
    }

    protected function guard()
    {
        return Auth::guard('backend');
    }

    /**
     * 取回需要验证的信息
     * 覆盖来自AuthenticatesUsers中的credentials方法
     *
     * @param  \Illuminate\Http\Request $request
     * @return array
     */
    protected function credentials(Request $request)
    {
        return $request->only($this->username(), 'password') + ['is_active' => 1];
    }
}

#文件: app/Http/Controllers/API/Backend/Auth/RegisterController.php
<?php

namespace App\Http\Controllers\API\Backend\Auth;

use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
use Illuminate\Foundation\Auth\RegistersUsers;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use App\Models\Admin;

class RegisterController extends Controller
{
    /*
    |--------------------------------------------------------------------------
    | Register Controller
    |--------------------------------------------------------------------------
    |
    | This controller handles the registration of new users as well as their
    | validation and creation. By default this controller uses a trait to
    | provide this functionality without requiring any additional code.
    |
    */

    use RegistersUsers;

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

    /**
     * Create a new controller instance.
     *
     * @return void
     */
    public function __construct()
    {
    }

    /**
     * Get a validator for an incoming registration request.
     *
     * @param  array  $data
     * @return \Illuminate\Contracts\Validation\Validator
     */
    protected function validator(array $data)
    {
        return Validator::make($data, [
            'name' => 'required|string|max:255',
            'email' => 'required|string|email|max:255|unique:users',
            'password' => 'required|string|min:6|confirmed',
        ]);
    }

    /**
     * Create a new user instance after a valid registration.
     *
     * @param  array  $data
     * @return \App\Models\Admin
     */
    protected function create(array $data)
    {
        return Admin::create([
            'name' => $data['name'],
            'email' => $data['email'],
            'password' => bcrypt($data['password']),
        ]);
    }

    protected function guard()
    {
        return Auth::guard('backend');
    }

    /**
     * Handle a registration request for the application.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */
    public function register(Request $request)
    {
        $this->validator($request->all())->validate();

        event(new Registered($user = $this->create($request->all())));

        //不需要登录
        //$this->guard()->login($user);

        return response()->json([
            'data' => Admin::find($user->id)->makeToken(),
        ], 200);
    }
}

#文件: app/Http/Controllers/API/Backend/Auth/ForgotPasswordController.php
<?php

namespace App\Http\Controllers\API\Backend\Auth;

use Illuminate\Foundation\Auth\SendsPasswordResetEmails;
use Illuminate\Support\Facades\Password;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;

class ForgotPasswordController extends Controller
{
    /*
    |--------------------------------------------------------------------------
    | Password Reset Controller
    |--------------------------------------------------------------------------
    |
    | This controller is responsible for handling password reset emails and
    | includes a trait which assists in sending these notifications from
    | your application to your users. Feel free to explore this trait.
    |
    */

    use SendsPasswordResetEmails;

    /**
     * Create a new controller instance.
     *
     * @return void
     */
    public function __construct()
    {
    }

    /**
     * Get the response for a successful password reset link.
     *
     * @param  string  $response
     * @return \Illuminate\Http\Response
     */
    protected function sendResetLinkResponse($response)
    {
        return response()->json([
            'data' => trans($response),
        ], 200);
    }

    /**
     * Get the response for a failed password reset link.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  string  $response
     * @return \Illuminate\Http\Response
     */
    protected function sendResetLinkFailedResponse(Request $request, $response)
    {
        return response()->json([
            'message' => '发生错误',
            'errors' => ['email' => trans($response)],
        ], 500);
    }

    /**
     * 验证邮件地址
     *
     * @param  \Illuminate\Http\Request  $request
     * @return void
     */
    protected function validateEmail(Request $request)
    {
        $this->validate($request, ['email' => 'required|email']);
    }

    public function broker()
    {
        return Password::broker('admins');
    }
}


#文件: app/Http/Controllers/API/Backend/Auth/ResetPasswordController.php
<?php

namespace App\Http\Controllers\API\Backend\Auth;

use Illuminate\Support\Str;
use Illuminate\Foundation\Auth\ResetsPasswords;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Password;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Auth\Events\PasswordReset;
use App\Http\Controllers\Controller;

class ResetPasswordController extends Controller
{
    /*
    |--------------------------------------------------------------------------
    | Password Reset Controller
    |--------------------------------------------------------------------------
    |
    | This controller is responsible for handling password reset requests
    | and uses a simple trait to include this behavior. You're free to
    | explore this trait and override any methods you wish to tweak.
    |
    */

    use ResetsPasswords;

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

    /**
     * Create a new controller instance.
     *
     * @return void
     */
    public function __construct()
    {

    }

    /**
     * 如果账户已经禁用,不能再重置密码
     *
     * @param  \Illuminate\Http\Request  $request
     * @return array
     */
    protected function credentials(Request $request)
    {
        return $request->only(
            'email', 'password', 'password_confirmation', 'token'
        ) + ['is_active' => 1];
    }

    /**
     * 重置密码,重置后不需要重新登录
     *
     * @param  \Illuminate\Contracts\Auth\CanResetPassword  $user
     * @param  string  $password
     * @return void
     */
    protected function resetPassword($user, $password)
    {
        $user->password = Hash::make($password);

        $user->setRememberToken(Str::random(60));

        $user->save();

        event(new PasswordReset($user));

        //$this->guard()->login($user);
    }

    /**
     * Get the response for a successful password reset.
     *
     * @param  string  $response
     * @return \Illuminate\Http\Response
     */
    protected function sendResetResponse($response)
    {
        return response()->json([
            'data' => trans($response),
        ], 200);
    }

    /**
     * Get the response for a failed password reset.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  string  $response
     * @return \Illuminate\Http\Response
     */
    protected function sendResetFailedResponse(Request $request, $response)
    {
        return response()->json([
            'message' => '发生错误',
            'errors' => ['email' => trans($response)],
        ], 500);
    }

    public function broker()
    {
        return Password::broker('admins');
    }

    protected function guard()
    {
        return Auth::guard('backend');
    }
}

发生错误时,状态码返回500,成功操作时状态码返回200,验证不通过状态码返回422,登录错误超过阈值状态码返回423,没有权限状态码返回401。

另外,返回的格式:

// 状态码
[
    'message' => '', // 成功时可不返回
    'data' => [],    // 成功时返回的数据
    'errors' => []   // 不成功时,返回的错误详细
]