-
-
Save thebrubaker/563d2638d1122eaca1baaa442a2cd103 to your computer and use it in GitHub Desktop.
# 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 |
<?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 | |
); | |
} | |
} |
<?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'); | |
} | |
} |
<?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'), | |
], | |
]; |
<?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; | |
} | |
} |
// My bad solution on the frontend SPA | |
const loginPayload = { | |
username: 'admin@application.com', | |
password: 'secret', | |
client_id: 1, | |
client_secret: 'the-client-secret-here', | |
grant_type: 'password', | |
scope: '*', | |
} |
@luceos Wow yeah that's super simple, and now I'm wondering why I have been looking this up for so long. Thanks
@mrb2590 Are you storying the token in local storage or something when using it later?
Laravel seems like it doesn't need that when using password grant. But then if you open another tab or refresh the tab suddently your logged out again?
If you are storying it how are you modifying axios to include it rather than requesting a new one on each load?
Great article on medium and thanks for the gist, it would be interesting to see your frontend implentation of this as well :)
@luceos I thought the same, but quickly realized you can't test against that implementation with Guzzle (though I'm sure you could mock the request in your tests) - with the proxy it will properly resolve to the correct endpoint for you. Otherwise when testing, the request is sent to your local dev environment, resulting in tests always failing.
Instead of creating a class that's injected, I moved the proxy class above to a MakesHttpRequests
trait to clean things up.
Does anyone know when we are supposed to call AccessTokensController@update
to refresh the token? I don't see anywhere where this should be called in the code. Any idea?
cc: @thebrubaker @luceos @mrb2590 @robjbrain @martinherweg @kaidesu
My question is, how do you protect that route from other people to consume it?
My question is, how do you protect that route from other people to consume it?
Maybe by disabling CORS on that route if the Vue app hosted within Laravel or, if the Vue app is stored somewhere else, by creating a custom middleware that would check if the request is made by that server/referer.
You don't even need Guzzle for that. Laravel applications can proxy their own requests.
$proxyRequest = Request::create('/oauth/token', 'POST', [
'grant_type' => 'password',
'username' => $request->email,
'password' => $request->password,
'client_id' => $request->client_id,
'client_secret' => $request->client_secret,
]);
$response = app()->handle($proxyRequest);
Laravel Passport Supports now Authorization Code Grant with PKCE which is the preferred way to go: https://laravel.com/docs/6.x/passport#code-grant-pkce
Although I totally stole your concept, I'm not sure implementing the Proxy Client makes any sense. You could have easily instantiated Guzzle to do all this:
public function login(Request $request, Client $client) { $this->validateLogin($request); if ($this->hasTooManyLoginAttempts($request)) { $this->fireLockoutEvent($request); return $this->sendLockoutResponse($request); } /** @var \Laravel\Passport\Client $proxy */ $proxy = app('proxy.grant.client'); $response = $client->post(config('app.url') . '/oauth/token', [ 'form_params' => [ 'client_id' => $proxy->id, 'client_secret' => $proxy->secret, 'grant_type' => 'password', 'username' => $request->email, 'password' => $request->password, 'scopes' => '[*]' ] ]); if ($response->getStatusCode() === 200) { $this->clearLoginAttempts($request); return response($response->getBody()->getContents(), $response->getStatusCode()); } $this->incrementLoginAttempts($request); return response($response->getBody()->getContents(), $response->getStatusCode()); }
hi, am new to this, dont quite understand how this code snippets work
should I replace app('proxy.grant.client');
with app('\Laravel\Passport\Client');
?
which class should I import for Client $client
? Thanks
Although I totally stole your concept, I'm not sure implementing the Proxy Client makes any sense. You could have easily instantiated Guzzle to do all this: