-
-
Save burnz/6f5fd6b095421653b1cbba51406a98be to your computer and use it in GitHub Desktop.
Laravel Passport: SPA Frontend Authentication
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# Added to the bottom of my file | |
PROXY_OAUTH_CLIENT_ID=2 | |
PROXY_OAUTH_CLIENT_SECRET=SECRET-GENERATED-KEY-HERE | |
PROXY_OAUTH_GRANT_TYPE=password |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace App\Http\Controllers\Api\Auth; | |
use App\Proxy\HttpKernelProxy; | |
use Illuminate\Http\Request; | |
use Illuminate\Http\Response; | |
use App\Http\Controllers\Api\Controller; | |
use Illuminate\Validation\ValidationException; | |
use Illuminate\Foundation\Auth\ThrottlesLogins; | |
class AccessTokensController extends Controller | |
{ | |
use ThrottlesLogins; | |
/** | |
* A tool for proxying requests to the existing application. | |
* | |
* @var HttpKernelProxy | |
*/ | |
protected $proxy; | |
/** | |
* Create a new controller instance. | |
* | |
* @return void | |
*/ | |
public function __construct(HttpKernelProxy $proxy) | |
{ | |
$this->middleware('auth:api')->except(['store', 'update']); | |
$this->proxy = $proxy; | |
} | |
/** | |
* Get the login username to be used by the controller. | |
* | |
* @return string | |
*/ | |
public function username() | |
{ | |
return 'email'; | |
} | |
/** | |
* Generate a new access token. | |
* | |
* @param \Illuminate\Http\Request $request | |
* @return \Illuminate\Http\Response | |
*/ | |
public function store(Request $request) | |
{ | |
$request->validate([ | |
'username' => 'required|string', | |
'password' => 'required|string', | |
]); | |
if ($this->hasTooManyLoginAttempts($request)) { | |
$this->fireLockoutEvent($request); | |
return $this->sendLockoutResponse($request); | |
} | |
return $this->requestPasswordGrant($request); | |
} | |
/** | |
* Refresh an access token. | |
* | |
* @param \Illuminate\Http\Request $request | |
* @return \Illuminate\Http\Response | |
*/ | |
public function update(Request $request) | |
{ | |
$token = $request->cookie('refresh_token'); | |
if (!$token) { | |
throw ValidationException::withMessages([ | |
'refresh_token' => trans('oauth.missing_refresh_token') | |
]); | |
} | |
$response = $this->proxy->postJson('oauth/token', [ | |
'client_id' => config('auth.proxy.client_id'), | |
'client_secret' => config('auth.proxy.client_secret'), | |
'grant_type' => 'refresh_token', | |
'refresh_token' => $token, | |
'scopes' => '[*]', | |
]); | |
if ($response->isSuccessful()) { | |
return $this->sendSuccessResponse($response); | |
} | |
return response($response->getContent(), $response->getStatusCode()); | |
} | |
/** | |
* Remove the specified resource from storage. | |
* | |
* @param int $id | |
* @return \Illuminate\Http\Response | |
*/ | |
public function destroy() | |
{ | |
$this->guard()->user()->token()->revoke(); | |
return response(null, 204); | |
} | |
/** | |
* Create a new access token from a password grant client. | |
* | |
* @param \Illuminate\Http\Request $request | |
* @return \Illuminate\Http\Response | |
*/ | |
public function requestPasswordGrant(Request $request) | |
{ | |
$response = $this->proxy->postJson('oauth/token', [ | |
'client_id' => config('auth.proxy.client_id'), | |
'client_secret' => config('auth.proxy.client_secret'), | |
'grant_type' => config('auth.proxy.grant_type'), | |
'username' => $request->username, | |
'password' => $request->password, | |
'scopes' => '[*]' | |
]); | |
if ($response->isSuccessful()) { | |
$this->clearLoginAttempts($request); | |
return $this->sendSuccessResponse($response); | |
} | |
$this->incrementLoginAttempts($request); | |
return response($response->getContent(), $response->getStatusCode()); | |
} | |
/** | |
* Return a successful response for requesting an api token. | |
* | |
* @param \Illuminate\Http\Response $response | |
* @return \Illuminate\Http\Response | |
*/ | |
public function sendSuccessResponse(Response $response) | |
{ | |
$data = json_decode($response->getContent()); | |
$content = [ | |
'access_token' => $data->access_token, | |
'expires_in' => $data->expires_in, | |
]; | |
return response($content, $response->getStatusCode())->cookie( | |
'refresh_token', | |
$data->refresh_token, | |
10 * 24 * 60, | |
"", | |
"", | |
true, | |
true | |
); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace Tests\Api\Auth; | |
use App\User; | |
use Tests\TestCase; | |
use Illuminate\Foundation\Testing\RefreshDatabase; | |
class AccessTokenTest extends TestCase | |
{ | |
use RefreshDatabase; | |
/** | |
* A basic test example. | |
* | |
* @return void | |
*/ | |
public function testAccessTokenCanBeGranted() | |
{ | |
// My application seeds a dummy admin user that I test against | |
$response = $this->post('/api/auth/access-tokens', [ | |
'username' => env('SEED_EMAIL'), | |
'password' => env('SEED_PASSWORD'), | |
]); | |
$response->assertStatus(200); | |
$response->assertJsonStructure([ | |
'access_token', | |
'expires_in', | |
]); | |
$response->assertDontSee('refresh_token'); | |
$response->assertCookie('refresh_token'); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
/* Added to my config/auth.php file */ | |
return [ | |
/* | |
|-------------------------------------------------------------------------- | |
| OAuth Proxy Authentication | |
|-------------------------------------------------------------------------- | |
| | |
| If you are planning to use your application to self-authenticate as a | |
| proxy, you can define the client and grant type to use here. This is | |
| sometimes the case when a trusted Single Page Application doesn't | |
| use a backend to send the authentication request, but instead | |
| relies on the API to handle proxying the request to itself. | |
| | |
*/ | |
'proxy' => [ | |
'client_id' => env('PROXY_OAUTH_CLIENT_ID'), | |
'client_secret' => env('PROXY_OAUTH_CLIENT_SECRET'), | |
'grant_type' => env('PROXY_OAUTH_GRANT_TYPE'), | |
], | |
]; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace App\Proxy; | |
use Illuminate\Support\Str; | |
use Illuminate\Http\Request; | |
use Illuminate\Contracts\Container\Container; | |
use Illuminate\Contracts\Http\Kernel as HttpKernel; | |
use Symfony\Component\HttpFoundation\Request as SymfonyRequest; | |
use Symfony\Component\HttpFoundation\File\UploadedFile as SymfonyUploadedFile; | |
class HttpKernelProxy | |
{ | |
/** | |
* Additional headers for the request. | |
* | |
* @var array | |
*/ | |
protected $defaultHeaders = []; | |
/** | |
* Additional server variables for the request. | |
* | |
* @var array | |
*/ | |
protected $serverVariables = []; | |
/** | |
* Indicates whether redirects should be followed. | |
* | |
* @var bool | |
*/ | |
protected $followRedirects = false; | |
/** | |
* The constructor for the proxy. | |
* | |
* @param \Illuminate\Contracts\Container\Container $app | |
*/ | |
public function __construct(Container $app) { | |
$this->app = $app; | |
} | |
/** | |
* Define additional headers to be sent with the request. | |
* | |
* @param array $headers | |
* @return $this | |
*/ | |
public function withHeaders(array $headers) | |
{ | |
$this->defaultHeaders = array_merge($this->defaultHeaders, $headers); | |
return $this; | |
} | |
/** | |
* Add a header to be sent with the request. | |
* | |
* @param string $name | |
* @param string $value | |
* @return $this | |
*/ | |
public function withHeader(string $name, string $value) | |
{ | |
$this->defaultHeaders[$name] = $value; | |
return $this; | |
} | |
/** | |
* Flush all the configured headers. | |
* | |
* @return $this | |
*/ | |
public function flushHeaders() | |
{ | |
$this->defaultHeaders = []; | |
return $this; | |
} | |
/** | |
* Define a set of server variables to be sent with the requests. | |
* | |
* @param array $server | |
* @return $this | |
*/ | |
public function withServerVariables(array $server) | |
{ | |
$this->serverVariables = $server; | |
return $this; | |
} | |
/** | |
* Automatically follow any redirects returned from the response. | |
* | |
* @return $this | |
*/ | |
public function followingRedirects() | |
{ | |
$this->followRedirects = true; | |
return $this; | |
} | |
/** | |
* Set the referer header to simulate a previous request. | |
* | |
* @param string $url | |
* @return $this | |
*/ | |
public function from(string $url) | |
{ | |
return $this->withHeader('referer', $url); | |
} | |
/** | |
* Visit the given URI with a GET request. | |
* | |
* @param string $uri | |
* @param array $headers | |
* @return \Illuminate\Http\Response | |
*/ | |
public function get($uri, array $headers = []) | |
{ | |
$server = $this->transformHeadersToServerVars($headers); | |
return $this->call('GET', $uri, [], [], [], $server); | |
} | |
/** | |
* Visit the given URI with a GET request, expecting a JSON response. | |
* | |
* @param string $uri | |
* @param array $headers | |
* @return \Illuminate\Http\Response | |
*/ | |
public function getJson($uri, array $headers = []) | |
{ | |
return $this->json('GET', $uri, [], $headers); | |
} | |
/** | |
* Visit the given URI with a POST request. | |
* | |
* @param string $uri | |
* @param array $data | |
* @param array $headers | |
* @return \Illuminate\Http\Response | |
*/ | |
public function post($uri, array $data = [], array $headers = []) | |
{ | |
$server = $this->transformHeadersToServerVars($headers); | |
return $this->call('POST', $uri, $data, [], [], $server); | |
} | |
/** | |
* Visit the given URI with a POST request, expecting a JSON response. | |
* | |
* @param string $uri | |
* @param array $data | |
* @param array $headers | |
* @return \Illuminate\Http\Response | |
*/ | |
public function postJson($uri, array $data = [], array $headers = []) | |
{ | |
return $this->json('POST', $uri, $data, $headers); | |
} | |
/** | |
* Visit the given URI with a PUT request. | |
* | |
* @param string $uri | |
* @param array $data | |
* @param array $headers | |
* @return \Illuminate\Http\Response | |
*/ | |
public function put($uri, array $data = [], array $headers = []) | |
{ | |
$server = $this->transformHeadersToServerVars($headers); | |
return $this->call('PUT', $uri, $data, [], [], $server); | |
} | |
/** | |
* Visit the given URI with a PUT request, expecting a JSON response. | |
* | |
* @param string $uri | |
* @param array $data | |
* @param array $headers | |
* @return \Illuminate\Http\Response | |
*/ | |
public function putJson($uri, array $data = [], array $headers = []) | |
{ | |
return $this->json('PUT', $uri, $data, $headers); | |
} | |
/** | |
* Visit the given URI with a PATCH request. | |
* | |
* @param string $uri | |
* @param array $data | |
* @param array $headers | |
* @return \Illuminate\Http\Response | |
*/ | |
public function patch($uri, array $data = [], array $headers = []) | |
{ | |
$server = $this->transformHeadersToServerVars($headers); | |
return $this->call('PATCH', $uri, $data, [], [], $server); | |
} | |
/** | |
* Visit the given URI with a PATCH request, expecting a JSON response. | |
* | |
* @param string $uri | |
* @param array $data | |
* @param array $headers | |
* @return \Illuminate\Http\Response | |
*/ | |
public function patchJson($uri, array $data = [], array $headers = []) | |
{ | |
return $this->json('PATCH', $uri, $data, $headers); | |
} | |
/** | |
* Visit the given URI with a DELETE request. | |
* | |
* @param string $uri | |
* @param array $data | |
* @param array $headers | |
* @return \Illuminate\Http\Response | |
*/ | |
public function delete($uri, array $data = [], array $headers = []) | |
{ | |
$server = $this->transformHeadersToServerVars($headers); | |
return $this->call('DELETE', $uri, $data, [], [], $server); | |
} | |
/** | |
* Visit the given URI with a DELETE request, expecting a JSON response. | |
* | |
* @param string $uri | |
* @param array $data | |
* @param array $headers | |
* @return \Illuminate\Http\Response | |
*/ | |
public function deleteJson($uri, array $data = [], array $headers = []) | |
{ | |
return $this->json('DELETE', $uri, $data, $headers); | |
} | |
/** | |
* Call the given URI with a JSON request. | |
* | |
* @param string $method | |
* @param string $uri | |
* @param array $data | |
* @param array $headers | |
* @return \Illuminate\Http\Response | |
*/ | |
public function json($method, $uri, array $data = [], array $headers = []) | |
{ | |
$files = $this->extractFilesFromDataArray($data); | |
$content = json_encode($data); | |
$headers = array_merge([ | |
'CONTENT_LENGTH' => mb_strlen($content, '8bit'), | |
'CONTENT_TYPE' => 'application/json', | |
'Accept' => 'application/json', | |
], $headers); | |
return $this->call( | |
$method, $uri, [], [], $files, $this->transformHeadersToServerVars($headers), $content | |
); | |
} | |
/** | |
* Call the given URI and return the Response. | |
* | |
* @param string $method | |
* @param string $uri | |
* @param array $parameters | |
* @param array $cookies | |
* @param array $files | |
* @param array $server | |
* @param string $content | |
* @return \Illuminate\Http\Response | |
*/ | |
public function call($method, $uri, $parameters = [], $cookies = [], $files = [], $server = [], $content = null) | |
{ | |
$kernel = $this->app->make(HttpKernel::class); | |
$files = array_merge($files, $this->extractFilesFromDataArray($parameters)); | |
$symfonyRequest = SymfonyRequest::create( | |
$this->prepareUrlForRequest($uri), $method, $parameters, | |
$cookies, $files, array_replace($this->serverVariables, $server), $content | |
); | |
$response = $kernel->handle( | |
$request = Request::createFromBase($symfonyRequest) | |
); | |
if ($this->followRedirects) { | |
$response = $this->followRedirects($response); | |
} | |
$kernel->terminate($request, $response); | |
return $response; | |
} | |
/** | |
* Turn the given URI into a fully qualified URL. | |
* | |
* @param string $uri | |
* @return string | |
*/ | |
protected function prepareUrlForRequest($uri) | |
{ | |
if (Str::startsWith($uri, '/')) { | |
$uri = substr($uri, 1); | |
} | |
if (! Str::startsWith($uri, 'http')) { | |
$uri = config('app.url').'/'.$uri; | |
} | |
return trim($uri, '/'); | |
} | |
/** | |
* Transform headers array to array of $_SERVER vars with HTTP_* format. | |
* | |
* @param array $headers | |
* @return array | |
*/ | |
protected function transformHeadersToServerVars(array $headers) | |
{ | |
return collect(array_merge($this->defaultHeaders, $headers))->mapWithKeys(function ($value, $name) { | |
$name = strtr(strtoupper($name), '-', '_'); | |
return [$this->formatServerHeaderKey($name) => $value]; | |
})->all(); | |
} | |
/** | |
* Format the header name for the server array. | |
* | |
* @param string $name | |
* @return string | |
*/ | |
protected function formatServerHeaderKey($name) | |
{ | |
if (! Str::startsWith($name, 'HTTP_') && $name != 'CONTENT_TYPE' && $name != 'REMOTE_ADDR') { | |
return 'HTTP_'.$name; | |
} | |
return $name; | |
} | |
/** | |
* Extract the file uploads from the given data array. | |
* | |
* @param array $data | |
* @return array | |
*/ | |
protected function extractFilesFromDataArray(&$data) | |
{ | |
$files = []; | |
foreach ($data as $key => $value) { | |
if ($value instanceof SymfonyUploadedFile) { | |
$files[$key] = $value; | |
unset($data[$key]); | |
} | |
if (is_array($value)) { | |
$files[$key] = $this->extractFilesFromDataArray($value); | |
$data[$key] = $value; | |
} | |
} | |
return $files; | |
} | |
/** | |
* Follow a redirect chain until a non-redirect is received. | |
* | |
* @param \Illuminate\Http\Response $response | |
* @return \Illuminate\Http\Response | |
*/ | |
protected function followRedirects($response) | |
{ | |
while ($response->isRedirect()) { | |
$response = $this->get($response->headers->get('Location')); | |
} | |
$this->followRedirects = false; | |
return $response; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// My bad solution on the frontend SPA | |
const loginPayload = { | |
username: '[email protected]', | |
password: 'secret', | |
client_id: 1, | |
client_secret: 'the-client-secret-here', | |
grant_type: 'password', | |
scope: '*', | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment