Laravel Vuejs 实战:开发知乎 (21)前后端分离 API token 认证
解决上一节当中如果api路由改成:
1 Route::middleware('auth:api')->post('/questions/follow', 'QuestionController@followThroughApi');
之后 axios ajax post请求报 401 unauthorized 异常的问题。
原理:
教程:
Building SPAs with Laravel 5 and Vue.js 2 - Finishing Up
修改指导:简单来说,auth要求的token我们没有提供:
通过api访问走的是token认证,这里没有提供token所以就认证失败返回401了
这个tokendriver对应的其实就是:
这个TokenGuard.php文件,这里面需要一个api_token。需要在request里提供api_token参数,为了区别是哪个用户,需要在user表添加api_token字段。认证过程调用的是TokenGuard.php中的getTokenForRequest方法:
这个bearerToken实际找header中是否存在Authorization
我们需要来提供这个token:
原理参考:
BearerToken:
本质上给用户表添加api_token,后台根据这个字段判断是否是有效的用户,无效返回401,有效返回查询结果。
优点是容易理解,缺点太简单,安全也不够。
为了安全,可以实现下面的功能:每次登录成功后刷新api_token为新值
其实 Laravel 官方提供了一个 Laravel Passport 的包。Laravel Passport is an OAuth2 server and API authentication package 。搞一搞laravel里api路由的 auth:api 和 api_token
JWT(Json Web Token):
【但是并不建议真实生产环境下用下面的方法,建议用官方的passport或者jwt】
步骤:
执行命令:
1 php artisan make:migration add_api_token_to_users_table --table=users
编辑****_add_api_token_to_users_table.php文件:
_add_api_token_to_users_table.php1 <?php 2 3 use Illuminate\Database\Migrations\Migration; 4 use Illuminate\Database\Schema\Blueprint; 5 use Illuminate\Support\Facades\Schema; 6 7 class AddApiTokenToUsersTable extends Migration 8 { 9 /** 10 * Run the migrations. 11 * 12 * @return void 13 */ 14 public function up() 15 { 16 Schema::table('users', function (Blueprint $table) { 17 // 18 $table->string('api_token', 64)->unique()->comment("api验证token"); 19 }); 20 } 21 22 /** 23 * Reverse the migrations. 24 * 25 * @return void 26 */ 27 public function down() 28 { 29 Schema::table('users', function (Blueprint $table) { 30 // 31 $table->dropColumn('api_token'); 32 }); 33 } 34 } 35 36执行命令:
1 php artisan migrate
其余的参考Laravel Vue 前后端分离 使用token认证 按照此链接教程,完成后打开网页刷新,再点击关注按钮,axios提交的时候不会报401异常了,记得修改 api.php中:
1 Route::middleware('auth:api')->post('/questions/follow', 'QuestionController@followThroughApi');
修改一下之前的两个错误:
1.因为之前采用的是
一个路由 处理刷新取关注状态和按下按钮关注的逻辑,这样一刷新页面就又相当于执行了一次关注/取关操作,必须分割开
1 //加载页面时取关注状态 2 Route::middleware('auth:api')->post('/questions/follow/stats', 'QuestionController@getFollowStats'); 3 //执行关注/取关操作 4 Route::middleware('auth:api')->post('/questions/follow', 'QuestionController@followThroughApi'); 5然后,对应需要在QuestionController添加一个方法取关注状态,
2.之前判断用户可否关注的逻辑错误
1 public function getFollowStats(Request $request) 2 { 3 $user = auth()->guard('api')->user(); 4 $question = Question::find($request->get('question')); 5 6 $followable = $user->can('follow', $question); 7 8 return response()->json([ 9 'followable' => $followable, 10 ]); 11 } 12 13 public function followThroughApi(Request $request) 14 { 15 $user = auth()->guard('api')->user(); 16 $question = Question::find($request->get('question')); 17 18 //同步记录 19 $user->followQuestions()->toggle($question->id); 20 $question->followers_count = $question->followUsers()->count(); 21 $question->update(); 22 //判断用户关注状态 23 $followable = $user->can('follow', $question); 24 25 return response()->json([ 26 'followable' => $followable, 27 ]); 28 } 29
再然后,优化一下,用户的数据不由props传递,也不由axios提交,因为有bearer token了。
接着QuestionController中followThroughApi和getFollowStats方法内,
$user获取: 直接通过 auth()->user() 或 auth()->guard('api')->user(); 获取即可。
QuestionController.php
1 <?php 2 3 namespace App\Http\Controllers; 4 5 use App\Http\Requests\QuestionStoreRequest; 6 use App\Models\Question; 7 use App\Repositories\QuestionRepository; 8 use App\User; 9 use Illuminate\Http\Request; 10 11 class QuestionController extends Controller 12 { 13 14 /** 15 * @var QuestionRepository 16 */ 17 private $questionRepository; 18 19 public function __construct(QuestionRepository $questionRepository) 20 { 21 $this->middleware( 22 'auth', 23 [ 24 'except' => 25 [ 26 'index', 27 'show', 28 'followThroughApi' 29 ]//非注册用户只能查看不能编辑添加更改删除 30 ] 31 ); 32 33 $this->questionRepository = $questionRepository; 34 } 35 36 37 /** Display a listing of the resource. 38 * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View 39 */ 40 public function index() 41 { 42 // 43 $questions = $this->questionRepository->getQuestionPublished(); 44 return view('questions.index', compact('questions')); 45 } 46 47 48 /** 49 * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View 50 */ 51 public function create() 52 { 53 // 54 return view('questions.create'); 55 } 56 57 58 /** 59 * @param QuestionStoreRequest $request 60 * @return \Illuminate\Http\RedirectResponse 61 */ 62 public function store(QuestionStoreRequest $request)//依赖注入QuestionStoreRequest实例 63 { 64 // 65 // $data = $request->validate([ 66 // 'title' => 'required|min:8', 67 // 'content' => 'required|min:28', 68 // ]); 69 //存储topics 70 $topics = $this->questionRepository->normalizeTopics($request->get('topics')); 71 //初始化question要用到的数据 72 $data = $request->all(); 73 $data['user_id'] = auth()->user()->id; 74 75 // $question=Question::create($data); 被下方代码取代 76 $question = $this->questionRepository->create($data); 77 78 //使用我们再question model里面添加的topics方法获得 topics关联,再使用attach方法 79 $question->topics()->attach($topics); 80 81 return redirect()->route('questions.show', $question); 82 } 83 84 85 /** 86 * @param Question $question 87 * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View 88 */ 89 public function show(Question $question) 90 { 91 //使用关系关联加载,with方法会将分类之下的主题一起查询出来,而且不会出现N+1影响性能的问题 92 $question->with('topics')->get(); 93 //使用关系关联加载,with方法会将分类之下的回答一起查询出来,而且不会出现N+1影响性能的问题 94 $question->with('answers')->get(); 95 96 return view('questions.show', compact('question')); 97 } 98 99 100 /**判断权限 返回视图 101 * @param Question $question 102 * @return \Illuminate\Contracts\View\Factory|\Illuminate\Http\RedirectResponse|\Illuminate\View\View 103 */ 104 public function edit(Question $question) 105 { 106 if (auth()->user()->can('update', $question)) //判断当前用户是否有权编辑更新该question实例 107 { 108 //返回编辑视图 109 return view('questions.edit', compact('question')); 110 } else { 111 //返回警告 没有权限 112 return redirect()->back()->with('warning', '你不能编辑不属于你的问题!'); 113 } 114 } 115 116 117 /** Update the specified resource in storage. 118 * @param QuestionStoreRequest $questionStoreRequest 119 * @param Question $question 120 * @return \Illuminate\Http\RedirectResponse 121 */ 122 public function update(QuestionStoreRequest $questionStoreRequest, Question $question) 123 { 124 //更新前 判断下权限 125 if (!(auth()->user()->can('update', $question))) { 126 //返回警告 没有权限 127 return redirect()->back()->with('warning', '你不能编辑不属于你的问题!'); 128 } 129 //取得更新的字段 使用Eloquent提供的update方法执行问题更新 130 $question->update([ 131 'title' => $questionStoreRequest->get('title'), 132 'content' => $questionStoreRequest->get('content'), 133 ]); 134 135 136 //topics的操作这时候看起来有点臃肿 可以使用TopicController来管理,暂时省略 137 //存储topics 138 $topics = $this->questionRepository->normalizeTopics($questionStoreRequest->get('topics')); 139 //使用我们再question model里面添加的topics方法获得 topics关联, 140 //再使用sync方法同步tag 【删除的会被删除掉,没删除的就保留,新的就增加】 141 $question->topics()->sync($topics); 142 143 //更新完成,跳转回去 144 return redirect()->back(); 145 } 146 147 148 /**Remove the specified resource from storage. 149 * @param Question $question 150 * @return \Illuminate\Http\RedirectResponse 151 * @throws \Exception 152 */ 153 public function destroy(Question $question) 154 { 155 // 156 if (auth()->user()->can('destroy', $question)) { 157 $question->delete(); 158 return redirect()->route('questions.index')->with('success', "删除成功!"); 159 } 160 return redirect()->back()->with('danger', "你不能删除不属于你的问题!"); 161 } 162 163 164 public function follow(Question $question) 165 { 166 if (auth()->user()->can('follow', $question)) //通过QuestionPolicy的follow方法判断用户是否可以关注问题 167 { 168 $message = "关注"; 169 } else { 170 $message = "取关"; 171 } 172 //同步记录 173 auth()->user()->followQuestions()->toggle($question); 174 $question->followers_count = $question->followUsers()->count(); 175 $question->save(); 176 return redirect()->back()->with('success', $message . '成功!'); 177 } 178 179 public function getFollowStats(Request $request) 180 { 181 $user = auth()->guard('api')->user(); 182 $question = Question::find($request->get('question')); 183 184 $followable = $user->can('follow', $question); 185 186 return response()->json([ 187 'followable' => $followable, 188 ]); 189 } 190 191 public function followThroughApi(Request $request) 192 { 193 $user = auth()->guard('api')->user(); 194 $question = Question::find($request->get('question')); 195 196 //同步记录 197 $user->followQuestions()->toggle($question->id); 198 $question->followers_count = $question->followUsers()->count(); 199 $question->update(); 200 //判断用户关注状态 201 $followable = $user->can('follow', $question); 202 203 return response()->json([ 204 'followable' => $followable, 205 ]); 206 } 207 } 208 209
QuestionPolicy.php
1 <?php 2 3 namespace App\Policies; 4 5 use App\Models\Question; 6 use App\User; 7 use Illuminate\Auth\Access\HandlesAuthorization; 8 9 class QuestionPolicy 10 { 11 use HandlesAuthorization; 12 13 /** 14 * Create a new policy instance. 15 * 16 * @return void 17 */ 18 public function __construct() 19 { 20 // 21 22 } 23 24 25 /** 26 * 判断用户是否有权编辑更新问题 27 * @param User $user 28 * @param Question $question 29 * @return bool 30 */ 31 public function update(User $user, Question $question) 32 { 33 return $user->id === $question->user_id; 34 } 35 36 37 /** 38 * 判断用户是否有权删除问题 39 * @param User $user 40 * @param Question $question 41 * @return bool 42 */ 43 public function destroy(User $user, Question $question) 44 { 45 return $user->id === $question->user_id; 46 } 47 48 49 /** 用户是否可以关注问题,未登录不行,关注了不行 50 * @param User $user 51 * @param Question $question 52 * @return bool 53 */ 54 public function follow(User $user, Question $question) 55 { 56 //axiox api 需要auth:api 先不实现,注释掉 57 if (auth()->check()) { 58 return !($user->followQuestions->contains('id', $question->id)); 59 } else { 60 61 } 62 } 63 } 64 65
app.blade.php
1 <!doctype html> 2 <html lang="{{ str_replace('_', '-', app()->getLocale()) }}"> 3 <head> 4 <meta charset="utf-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1"> 6 7 {{-- CSRF Token--}} 8 <meta name="csrf-token" content="{{ csrf_token() }}"> 9 {{-- api bearer Token--}} 10 <meta name="api-token" content="{{ Auth::check() ? 'Bearer '.auth()->user()->api_token : 'Bearer ' }}"> 11 12 <title>{{ config('app.name', 'Laravel') }}</title> 13 14 15 {{-- Fonts--}} 16 <link rel="dns-prefetch" href="//fonts.gstatic.com"> 17 <link href="https://fonts.googleapis.com/css?family=Nunito" rel="stylesheet"> 18 19 {{-- Styles--}} 20 <link href="{{ mix('css/app.css') }}" rel="stylesheet"> 21 22 </head> 23 <body> 24 <div id="app"> 25 <nav class="navbar navbar-expand-md navbar-light bg-white shadow-sm"> 26 <div class="container"> 27 <a class="navbar-brand" href="{{ url('/') }}"> 28 {{ config('app.name', 'Laravel') }} 29 </a> 30 <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" 31 aria-controls="navbarSupportedContent" aria-expanded="false" 32 aria-label="{{ __('Toggle navigation') }}"> 33 <span class="navbar-toggler-icon"></span> 34 </button> 35 36 <div class="collapse navbar-collapse" id="navbarSupportedContent"> 37 {{-- Left Side Of Navbar--}} 38 <ul class="navbar-nav mr-auto"> 39 40 </ul> 41 42 {{-- Right Side Of Navbar--}} 43 <ul class="navbar-nav ml-auto"> 44 {{-- Authentication Links--}} 45 @guest 46 <li class="nav-item"> 47 <a class="nav-link" href="{{ route('login') }}">{{ __('Login') }}</a> 48 </li> 49 @if (Route::has('register')) 50 <li class="nav-item"> 51 <a class="nav-link" href="{{ route('register') }}">{{ __('Register') }}</a> 52 </li> 53 @endif 54 @else 55 <li class="nav-item dropdown"> 56 <a id="navbarDropdown" class="nav-link dropdown-toggle" href="#" role="button" 57 data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" v-pre> 58 {{ Auth::user()->name }} <span class="caret"></span> 59 </a> 60 61 <div class="dropdown-menu dropdown-menu-right" aria-labelledby="navbarDropdown"> 62 <a class="dropdown-item" href="{{ route('logout') }}" 63 onclick="event.preventDefault(); 64 document.getElementById('logout-form').submit();"> 65 {{ __('Logout') }} 66 </a> 67 68 <form id="logout-form" action="{{ route('logout') }}" method="POST" 69 style="display: none;"> 70 @csrf 71 </form> 72 </div> 73 </li> 74 @endguest 75 </ul> 76 </div> 77 </div> 78 </nav> 79 80 <main class="py-4"> 81 @include('flash::message') 82 @yield('content') 83 </main> 84 </div> 85 {{--Scripts--}} 86 <script src="{{ mix('js/app.js') }}"></script> 87 88 <script> 89 $('#flash-overlay-modal').modal(); 90 window.UEDITOR_CONFIG.serverUrl = "{{ config('ueditor.route.name') }}"; 91 </script> 92 @yield('footer-js') 93 </body> 94 </html> 95 96
api.php
1 <?php 2 3 use Illuminate\Http\Request; 4 5 /* 6 |-------------------------------------------------------------------------- 7 | API Routes 8 |-------------------------------------------------------------------------- 9 | 10 | Here is where you can register API routes for your application. These 11 | routes are loaded by the RouteServiceProvider within a group which 12 | is assigned the "api" middleware group. Enjoy building your API! 13 | 14 */ 15 16 Route::middleware('auth:api')->get('/user', function (Request $request) { 17 return $request->user(); 18 }); 19 20 Route::middleware('api')->get('/topics', function (Request $request) { 21 $query = $request->query('q'); 22 return \App\Topic::query()->where('name', 'like', '%' . $query . '%')->get(); 23 }); 24 //加载页面时取关注状态 25 Route::middleware('auth:api')->post('/questions/follow/stats', 'QuestionController@getFollowStats'); 26 //执行关注/取关操作 27 Route::middleware('auth:api')->post('/questions/follow', 'QuestionController@followThroughApi'); 28 29 30 31
bootstrap.js
1 window._ = require('lodash'); 2 3 /** 4 * We'll load jQuery and the Bootstrap jQuery plugin which provides support 5 * for JavaScript based Bootstrap features such as modals and tabs. This 6 * code may be modified to fit the specific needs of your application. 7 */ 8 9 try { 10 window.Popper = require('popper.js').default; 11 window.$ = window.jQuery = require('jquery'); 12 13 require('bootstrap'); 14 } catch (e) { 15 } 16 17 /** 18 * We'll load the axios HTTP library which allows us to easily issue requests 19 * to our Laravel back-end. This library automatically handles sending the 20 * CSRF token as a header based on the value of the "XSRF" token cookie. 21 */ 22 23 window.axios = require('axios'); 24 25 window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; 26 27 let api_token = document.head.querySelector('meta[name="api-token"]'); 28 29 if (api_token) { 30 window.axios.defaults.headers.common['Authorization'] = api_token.content; 31 } else { 32 console.error('Authorization token not found: https://laravel.com/docs/csrf#csrf-x-csrf-token'); 33 } 34 35 /** 36 * Echo exposes an expressive API for subscribing to channels and listening 37 * for events that are broadcast by Laravel. Echo and event broadcasting 38 * allows your team to easily build robust real-time web applications. 39 */ 40 41 // import Echo from 'laravel-echo'; 42 43 // window.Pusher = require('pusher-js'); 44 45 // window.Echo = new Echo({ 46 // broadcaster: 'pusher', 47 // key: process.env.MIX_PUSHER_APP_KEY, 48 // cluster: process.env.MIX_PUSHER_APP_CLUSTER, 49 // encrypted: true 50 // }); 51 52
QuestionFollowButton.vue
1 <template> 2 <button :class="classObject" 3 @click="follow" 4 v-text="text"> 5 </button> 6 </template> 7 8 <script> 9 export default { 10 props: ['question'], 11 name: "QuestionFollowButton", 12 data() { 13 return { 14 followable: true, 15 } 16 }, 17 computed: { 18 text() { 19 return this.followable ? "关注用户" : "取消关注"; 20 }, 21 classObject() { 22 return this.followable ? "btn btn-block btn-primary" : "btn btn-block btn-danger"; 23 }, 24 }, 25 mounted: function () { 26 let currentObj = this; 27 axios.post('/api/questions/follow/stats', {'question': this.question}) 28 .then(function (response) { 29 currentObj.followable = response.data.followable; 30 }) 31 .catch(function (e) { 32 console.log(e); 33 }); 34 }, 35 methods: { 36 follow() { 37 let currentObj = this; 38 axios.post('/api/questions/follow', {'question': this.question}) 39 .then(function (response) { 40 currentObj.followable = response.data.followable; 41 } 42 ) 43 .catch(function (e) { 44 console.log(e); 45 }); 46 }, 47 } 48 } 49 </script> 50 51 <style scoped> 52 53 </style> 54 55
show.blade.php
1 @extends('layouts.app') 2 @section('content') 3 <div class="container"> 4 <div class="row"> 5 <div class="col-md-8 col-md offset-1"> 6 {{--问题--}} 7 <div class="card"> 8 <div class="card-header"> 9 {{ $question->title }} 10 11 @foreach(['success','warning','danger'] as $info) 12 @if(session()->has($info)) 13 <div class="alert alert-{{$info}}">{{ session()->get($info) }}</div> 14 @endif 15 @endforeach 16 17 @can('update',$question) 18 <a href="{{ route('questions.edit',$question) }}" class="btn btn-warning">编辑</a> 19 @endcan 20 21 @can('destroy',$question) 22 <form action="{{ route('questions.destroy',$question) }}" method="post"> 23 @csrf 24 @method('DELETE') 25 <button type="submit" class="btn btn-danger">删除</button> 26 </form> 27 @endcan 28 29 @forelse($question->topics as $topic) 30 <button class="btn btn-secondary float-md-right m-1">{{ $topic->name }}</button> 31 @empty 32 <p class="text text-warning float-md-right"> "No Topics"</p> 33 @endforelse 34 35 <p class="text text-info float-md-right"> 已有{{ count($question->answers) }}个回答</p> 36 37 </div> 38 <div class="card-body"> 39 {!! $question->content !!} 40 </div> 41 </div> 42 43 44 {{--回答提交form--}} 45 {{--只有登录用户可以提交回答--}} 46 @if(auth()->check()) 47 <div class="card mt-2"> 48 <div class="card-header"> 49 提交回答 50 </div> 51 <div class="card-body"> 52 <form action="{{ route('answers.store',$question) }}" method="post"> 53 @csrf 54 <!-- 回答编辑器容器 --> 55 <script id="container" name="content" type="text/plain" 56 style="width: 100%;height: 200px">{!! old('content') !!}</script> 57 <p class="text text-danger"> @error('content') {{ $message }} @enderror </p> 58 <!--提交按钮--> 59 <button type="submit" class="btn btn-primary float-md-right mt-2">提交回答</button> 60 </form> 61 </div> 62 </div> 63 @else 64 {{--显示请登录--}} 65 <a href="{{ route('login') }}" class="btn btn-success btn-block mt-4">登录提交答案</a> 66 @endif 67 {{--展示答案--}} 68 @forelse($question->answers as $answer) 69 <div class="card mt-4"> 70 <div class="card-header"> 71 <div class="float-left"> 72 <img src="{{ $answer->user->avatar }}" class="img-thumbnail imgWrap" 73 style="height: 50px" alt="{{ $answer->user->name }}"> 74 <span class="text text-info">{{ $answer->user->name }}</span> 75 </div> 76 <span class="float-right text text-info m-auto">{{ $answer->updated_at }}</span> 77 </div> 78 79 <div class="card-body"> 80 {!! $answer->content !!} 81 </div> 82 </div> 83 84 @empty 85 86 @endforelse 87 </div> 88 89 <div class="col-md-3"> 90 <div class="card"> 91 <div class="card-header"> 92 <h2> {{ $question->followers_count }}</h2> 93 <span>关注者</span> 94 </div> 95 96 <div class="card-body"> 97 <div id="app"> 98 <question-follow-button question="{{$question->id}}"> 99 </question-follow-button> 100 </div> 101 </div> 102 </div> 103 </div> 104 </div> 105 </div> 106 @endsection 107 @section('footer-js') 108 @include('questions._footer_js') 109 @endsection 110 111
补充:
截取
API requests with axios always unauthorized with Laravel API
I'm not using Passort or any library like that since it's an internal API serving only VueJS to obtain stuff from the database.
If the API is not stateless, meaning that the user is known to be logged in with a standard session cookie, then you can just use the default 'web' middleware for the API routes.
In the default RouteServiceProvider, change the mapApiRoutes function to use the web middleware instead:
protected function mapApiRoutes()
{
Route::prefix('api')
// ->middleware('api')
->middleware('web')
->namespace($this->namespace)
->group(base_path('routes/api.php'));
}
That being said, you should really put the API routes behind the default 'auth' middleware since they're not throttled by default.
In the routes/api.php file:
Route::group(['middleware' => 'auth'], function() { Route::get('/latest', 'InternalApiController@latest'); });
And if you want to ensure it's an AJAX request, you can create a simple middleware that checks that the request has the X-Requested-With header set to XMLHttpRequest.
class RequestIsAjax { /** * Handle an incoming request. * * @param \Illuminate\Http\Request $request * @param \Closure $next * @return mixed */ public function handle($request, Closure $next) { if (!$request->ajax()) { return redirect()->route('login.index'); } return $next($request); } }
And register it within the $routeMiddleware array inside the \App\Http\Kernel class.
protected $routeMiddleware = [ 'ajax' => \App\Http\Middleware\RequestIsAjax::class,