I'm developing a React/CakePHP app with a dockerized dev environment. For authentication, I'm using an OpenID Connect provider to establish the user identity, which I then encapsulate in a JWT as suggested in this article. Using the CakePHP/Authentication plugin, I redirect requests from 'https://mydomain.local/' to 'https://mydomain.local/login', which handles the OIDC logic. Once authenticated, the user is again redirected back to the site root, now with the JWT in two cookies.
My problem is that the request hangs on that final redirect. If I disable the redirect and manually navigate back to root after the cookies are set, the request works fine and my app sees the authenticated user correctly via the JWT.
For my dev environment, I'm using a Caddy container as a proxy to terminate https and a php-apache container to host the app itself. Neither server's logs show the final request occuring.
Here are the relevant portions of my code:
docker_compose.yml:
services:
caddy:
image: "abiosoft/caddy:latest"
volumes:
- ./caddy/certs:/root/certs
- ./caddy/Caddyfile:/etc/Caddyfile
- ./caddy/logs:/var/log
ports:
- "443:2015"
depends_on:
- web
web:
build:
context: .
links:
- db
volumes:
- "./src:/var/www/html/src:rw"
db:
image: mysql:latest
caddy/Caddyfile:
mydomain.local {
log /var/log/access.log
# Mkcert - https://github.com/FiloSottile/mkcert
tls /root/certs/mydomain.local.pem /root/certs/mydomain.local-key.pem
proxy / http://web:80 {
transparent
}
}
src/Application.php:
public function middleware($middlewareQueue)
{
$middlewareQueue
->add(new ErrorHandlerMiddleware(null, Configure::read('Error')))
->add(new AssetMiddleware([
'cacheTime' => Configure::read('Asset.cacheTime')
]))
->add(new RoutingMiddleware($this))
->prepend(new JwtMiddleware())
->add(new AuthenticationMiddleware($this));
return $middlewareQueue;
}
public function getAuthenticationService(ServerRequestInterface $request, ResponseInterface $response)
{
$service = new AuthenticationService([
'unauthenticatedRedirect' => Router::url(['controller' => 'Main', 'action' => 'login']),
'queryParam' => 'redirect',
]);
$service->loadIdentifier('Authentication.JwtSubject', [
'tokenField' => 'id',
'dataField' => 'sub',
'resolver' => 'Authentication.Orm',
]);
$service->loadAuthenticator('Authentication.Jwt', [
'header' => 'Authorization',
'queryParam' => 'token',
'tokenPrefix' => 'Bearer',
'algorithms' => ['HS256'],
'returnPayload' => 'false',
'secretKey' => Security::getSalt(),
]);
return $service;
}
src/Middleware/JwtMiddleware.php:
use Lcobucci\JWT\Parser;
use Lcobucci\JWT\ValidationData;
class JwtMiddleware
{
public function __invoke(RequestInterface $request, ResponseInterface $response, $next)
{
$jwt[0] = $request->getCookie('sa');
$jwt[1] = $request->getCookie('sb');
if (!empty($jwt[0]) && !empty($jwt[1])) {
$data = new ValidationData();
$data->setIssuer('mydomain');
$data->setAudience('mydomain.local');
$data->setId('mydomain');
$jwt = implode('.', $jwt);
$token = (new Parser())->parse((string) $jwt);
if ($token->validate($data)) {
$request = $request->withAddedHeader('Authorization', 'Bearer ' . $jwt);
$response = $response->withCookie((new Cookie('sa'))
->withValue($token->getPayload())
->withExpiry(new \DateTime('+30 minutes'))
->withPath('/')
->withHttpOnly(false)
);
}
}
return $next($request, $response);
}
}
src/Controller/MainController.php:
use Jumbojett\OpenIDConnectClient;
use Jumbojett\OpenIDConnectClientException;
use Lcobucci\JWT\Builder;
use Lcobucci\JWT\Signer\Hmac\Sha256;
use Lcobucci\JWT\Signer\Key;
/**
* Main Controller
*
* #property UsersTable $Users
*/
class MainController extends AppController
{
public function beforeFilter(Event $event)
{
$this->Authentication->allowUnauthenticated(['login']);
return parent::beforeFilter($event);
}
/**
* Index method
*
* #return Response|null
*/
public function index()
{
$filePath = WWW_ROOT . '/static.html';
$file = new File($filePath);
$index = $file->read();
$file->close();
return $this->response->withStringBody($index);
}
/**
* Login method
*
* #return Response|null
* #throws OpenIDConnectClientException
*/
public function login()
{
$oidc = new OpenIDConnectClient(
env('OIDC_URL'),
env('OIDC_CLIENT'),
env('OIDC_SECRET')
);
$oidc->addScope('openid');
$oidc->addScope('profile');
$oidc->authenticate();
$this->loadModel('Users');
$user = $this->Users->find()
->where(['auth_id' => $oidc->requestUserInfo('sub')])
->firstOrFail();
$signer = new Sha256();
$time = time();
$token = (new Builder())
->issuedBy('mydomain')
->permittedFor('mydomain.local')
->identifiedBy('mydomain')
->issuedAt($time)
->expiresAt($time + 3600)
->withClaim('sub', $user->id)
->getToken($signer, new Key(Security::getSalt()));
$signature = explode('.', $token->__toString())[2];
$sa = (new Cookie('sa'))
->withValue($token->getPayload())
->withExpiry(new \DateTime('+30 minutes'))
->withPath('/')
->withHttpOnly(false);
$sb = (new Cookie('sb'))
->withValue($signature)
->withPath('/')
->withHttpOnly(true);
$this->response = $this->response
->withCookieCollection(new CookieCollection([$sa, $sb]));
/**** HANG OCCURS ON THIS LINE ****/
return $this->redirect($this->Authentication->getLoginRedirect());
}
}
Any advice/suggestions greatly appreciated!!!
The issue was that the redirect was insecure because the app server was running HTTP (with SSL terminated at the proxy). Changing the last line of login() in MainController.php to
return $this->redirect(Router::url('/', true)); // generate full URL
and setting fullBaseUrl in config/app.php to 'https://mydomain.local' fixed the issue.
Related
I'm currently working on a Laravel 8 project, I have two projects:
A Laravel 8 project used as an API, it exposes some endpoints
A Laravel Lumen 8 project which runs on it's own domain.
Both have Cors enabled, and both run on the same domain, I'm having issues with Guzzle in my Lumen project connecting to an endpoint that exists on my Laravel API, here's the scenario and request flow:
A request comes in to: /hub/microservice/fudge-api-reports on the Laravel api, this is it's controller method:
/**
* Route the microservice
*
* #param \Illuminate\Http\Request $request
* #return \Illuminate\Http\Response
*/
public function microservice(Request $request, $service)
{
$validator = Validator::make($request->all(), [
'endpoint' => 'required|string',
'method' => 'required|string|in:post,get',
'data' => 'nullable|array'
]);
if ($validator->fails()) {
return response()->json([
'message' => "One or more fields has been missed or is invalid.",
'errors' => $validator->messages()
], 400);
}
$microservice = Microservice::where('microservice', $service)->first();
if (!$microservice) {
return response()->json([
'message' => "The microservice you're trying to access is invalid or doesn't exist"
], 404);
}
// auth
$auth = 'Bearer ' . $microservice->hub_access_token;
// the url
$url = $microservice->hub_domain . $request->input('endpoint');
// define how to communicate with the microservice
if ($request->input('method') == 'post') {
if ($microservice->hub_access_token) {
$response = Http::timeout(60)->withHeaders([
'Authorization' => $auth
])->post($url, $request->input('data'));
} else {
$response = Http::timeout(60)->post($url, $request->input('data'));
}
} else {
if ($microservice->hub_access_token) {
$response = Http::timeout(60)->withHeaders([
'Authorization' => $auth
])->get($url);
} else {
$response = Http::timeout(60)->get($url);
}
}
// the response from the microservice
return response()->json($response->json(), $response->status());
}
The request then (based on the endpoint and method) goes to the microservice which runs on it's own domain, mine is: http://localhost:8001/, my URL would be a GET request, and would have an access token, so it makes it into the else statement above and into the first if, e.g: http://localhost:8001/api/reports?report=MyReport and has a token as the header.
The request comes into the fudge-api-reports Laravel Lumen project, and goes to a controller method, but first passes through my BeforeMiddleware where it performs a "log in" request back to the Laravel API to authenticate and check the abilities, this part of the middleware is:
<?php
namespace App\Http\Middleware;
use Closure;
use GuzzleHttp\Client;
class BeforeMiddleware
{
/**
* Request attributes
*
*/
public $attributes;
/**
* Get API url
*/
protected function getApiUrl()
{
return rtrim(config('fudge.fudge_api_domain'), '/');
}
/**
* Handle an incoming request.
*
* #param \Illuminate\Http\Request $request
* #param \Closure $next
* #return mixed
*/
public function handle($request, Closure $next, $ability)
{
$api = $this->getApiUrl();
$token = $request->input('token');
if (!$token && $request->header('Authorization')) {
$token = explode(' ', $request->header('Authorization'))[1];
}
// TODO: this part appears to ALWAYS time out, despite
// http://localhost:8000/api/hub/login working just fine via Postman
$client = new Client([
'base_uri' => $api,
'timeout' => 5
]);
$res = $client->request('POST', '/api/hub/login', [
'token' => $token,
'ability' => "reports:$ability"
]);
// the response
$res = $res->json();
$hasAbility = isset($res['has_ability']) && !empty($res['has_ability']) ? $res['has_ability'] : false;
// not authorised
if (!$hasAbility) {
return response()->json([
'message' => "You aren't authorised"
], 200);
}
// add the hub's user to the request
$request->attributes->add(['has_ability' => $hasAbility]);
// Post-Middleware Action
return $next($request);
}
}
If the Hub log in is successful, then a has_ability is returned with the value of true back to the the middleware, which then goes through the controller method and finally returns the response back to the initial request of: /hub/microservice/fudge-api-reports
My issue
in my BeforeMiddleware my Guzzle POST request always fails, and never returns a response, it always seems to time out accessing my /api/hub/login endpoint that exists in my Laravel project.
It works perfectly fine through Postman, and Cors is enabled, why would this always timeout in the context of the Middleware, what am I missing?
I try to use setcookie in Laravel listeners with queue, setcookie return true but the problem is in chrome (Or Firefox) DevTools> Application> Cookies cookies are not set and not working.
My Class:
class FlarumEventSubscriber implements ShouldQueue
{
public function onUserLogin($event)
{
$user = $event->user;
$response = $this->authenticate($user->mobile, $user->password);
$token = $response['token'] ?: '';
setcookie('flarum_session', $token, time() + 99999999 , '/', 'localhost'); // ======> The problem
}
public function subscribe($events)
{
$events->listen(
'Illuminate\Auth\Events\Login',
'App\Listeners\FlarumEventSubscriber#onUserLogin'
);
}
private function authenticate($id, $password)
{
$endpoint = '/api/token';
$method = 'POST';
$data = [
'identification' => $id,
'password' => $password,
'lifetime' => 99999999
];
return $this->sendRequest($endpoint, $method, $data);
}
}
EventServiceProvider :
class EventServiceProvider extends ServiceProvider
{
/**flarum subscriber */
protected $subscribe = [
'App\Listeners\FlarumEventSubscriber',
];
}
In FlarumEventSubscriber class, I send a request after the user login to Laravel, and after that, some information needs to be stored in the cookie.
My efforts:
Use Cookie::queue() instead of setcookie() => Not working
Test on other domains => Not working
Info:
PHP v7.4.26
Laravel 8.0
Wampserver v3.2.6 64bit
Here is how I handle the login in my Laravel app:
public function authenticate(Request $request)
{
$validator = Validator::make($request->all(), [
'email' => 'required|email',
'password' => 'required',
]);
if ($validator->passes()) {
$credentials = array(
'email' => $request->email,
'password' => hash('sha512', $request->password)
);
if (User::where($credentials)->exists()) {
$user = User::where($credentials)->first();
Auth::login($user, isset($request->remember));
return redirect()->back()->withInput($request);
} else {
return redirect()->route('login');
}
} else {
return redirect()->route('login', ['error' => $validator->errors()->first()]);
}
}
And here is how I initiate the Authorize method of Laravel Passport:
public function auth(Request $request)
{
$request->session()->put('state', $state = Str::random(40));
$query = http_build_query([
'client_id' => '3',
'redirect_uri' => 'http://127.0.0.1:8000/authorize/response',
'response_type' => 'code',
'scope' => '',
'state' => $state
]);
return redirect('http://127.0.0.1:9000/oauth/authorize?'.$query);
}
So basically when I run auth function and I am not logged in when I nabvigate to http://127.0.0.1:9000/oauth/authorize.... url I get redirected to the Login route of my app. That's fine I need to login to authorize the permission. So far so good.
The problem is that after I login successfully I don't get redirected back to http://127.0.0.1:9000/oauth/authorize... URL. Why?
How can I fix that so that after I login I get directly redirected back to the Authorization prompt?
I also had the same issue when I was authorizing clients with PKCE. I am not sure if it's the most elegant way to achieve that but it works anyways.
In order to solve this issue I created a middleware named KeepClientParameters and stored client request in session.
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class KeepClientParameters
{
/**
* Handle an incoming request.
*
* #param \Illuminate\Http\Request $request
* #param \Closure(\Illuminate\Http\Request): (\Illuminate\Http\Response|\Illuminate\Http\RedirectResponse) $next
* #return \Illuminate\Http\Response|\Illuminate\Http\RedirectResponse
*/
public function handle(Request $request, Closure $next)
{
if ('code' === $request->get('response_type', false)) {
$allParams = $request->all();
$request->session()->put('client', $allParams);
}
return $next($request);
}
}
And set middleware priority in App/Http/Kernel.php, below StartSession::class and above AuthenticatesRequests::class because you should run the middleware before authentication requests, otherwise you will not be able to store your client request in session.
/**
* The priority-sorted list of middleware.
*
* Forces non-global middleware to always be in the given order.
*
* #var string[]
*/
protected $middlewarePriority = [
\Illuminate\Session\Middleware\StartSession::class,
\App\Http\Middleware\KeepClientParameters::class,
// ...
\Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests::class,
];
After successful Authentication, you can redirect to Authorization page and pull session data in authorization page.
$client = $request->session()->pull('client', []);
return redirect(
route('passport.authorizations.authorize', $client)
);
Hi I'm trying to set JWT token after the login test is complete in a global var and then extend all tests off of that class, now I'm not sure if I am even doing it right so any help is appreciated or documentation I could look up
<?php
use Illuminate\Support\Facades\Config;
class AuthenticationTest extends TestCase {
/**
* #var string
*/
public $token = '';
/**
* Register test user
*
* #return void
*/
public function testRegister()
{
$this->json('post', '/auth/register', [
'email' => Config::get('tests.email'),
'password' => Config::get('tests.password'),
'companyName' => Config::get('tests.companyName'),
'firstname' => Config::get('tests.firstname'),
'lastname' => Config::get('tests.lastname')
]);
$this->assertEquals(200, $this->response->status());
$this->seeJsonEquals([
'message' => "Registration successful, please confirm email to login",
'status' => true,
]);
}
/**
* Login test user
*
* #return void
*/
public function testLogin()
{
$email = Config::get('tests.email');
$password = Config::get('tests.password');
$encryptedToken = base64_encode($email . ":" . $password);
$this->json('post', 'auth/login', ['token' => $encryptedToken]);
$this->assertEquals(200, $this->response->status());
$this->seeJsonStructure([
'token',
'refresh',
'status'
]);
$content = json_decode($this->response->getContent());
$this->assertObjectHasAttribute('token', $content);
$this->token = $content->token;
$this->token;
}
}
?>
The login test works as it should but the token isn't being set for other tests that extend off of this test case ad use $this->token to send the JWT token
PHPUnit creates a new instance of AuthenticationTest for every test method to run each test in isolation.
For tests that need an authenticated user, you have to obtain the token somehow. You could make an api request like you did in testLogin. A bit faster and less brittle way would be to create the token programatically using the same service as in your production code.
To reduce boilerplate, create a test case for tests with an authenticated and create the token in the setup method. Tests that extend from this class have the token property. Alternatively, you could also make this functionality a trait.
EDIT:
Here's a simple code example.
abstract class AuthenticatedUserTestCase extends TestCase
{
protected function token(): string
{
$email = Config::get('tests.email');
$password = Config::get('tests.password');
$encryptedToken = base64_encode($email . ':' . $password);
$response = $this->json('post', 'auth/login', ['token' => $encryptedToken]);
$content = json_decode($response->getContent());
if (!isset($content->token)) {
throw new RuntimeException('Token missing in response');
}
return $content->token;
}
}
final class UserProfileTest extends AuthenticatedUserTestCase
{
public function test_that_user_can_access_their_profile_page()
{
$response = $this->json('get', '/user/profile', [], ['token' => $this->token()]);
// ... assert stuff
}
}
I want to test my basic auth protected pages. The test for unauthorization works fine. But I struggle on the authorized login, as I do not know how to set the headers on in the test.
I could not find a hint, how to set headers on $this->call(). The only information I could find was:
$this->call($method, $uri, $parameters, $cookies, $files, $server, $content);
and there are the headers missing.
How do I easily test basic auth on laravel. Concrete: How do I set the basic auth header for the test request?
What I currently have:
class ExampleTest extends TestCase {
public function test401UnauthorizedOnMe() {
$response = $this->call('GET', '/api/me');
$this->assertResponseStatus( 401);
}
public function testCorrectLoginOnMe() {
// http://shortrecipes.blogspot.de/2009/12/testing-basic-http-authentication-using.html
//send header with correct user and password i.e.
////YWRtaW46YWRtaW4xMg== is equal to base64_encode( "admin:admin12")
$this->request->setHeader( 'Authorization','Basic YWRtaW46YWRtaW4xMg==');
$response = $this->call('GET', '/api/me');
$this->assertResponseStatus(200);
}
}
I tried $this->$request->setHeader(); but with this I only get an error:
1) ExampleTest::testCorrectLoginOnMe
ErrorException: Undefined property: ExampleTest::$request
Found the solution with HTTP authentication with PHP. This can be used in the $server parameter of $this->call().
Here's my working function:
public function testCorrectLoginOnMe() {
// call( $method, $uri, $parameters = [], $cookies = [], $files = [], $server = [], $content = null)
$this->call('GET', '/api/me', [], [], [], ['PHP_AUTH_USER' => 'admin', 'PHP_AUTH_PW' => 'admin12']);
$this->assertResponseStatus( 200 );
}
Basic Auth is usually achieved with a header key of 'Authorization'. For convenience, I have the following method in my base TestCase class:
protected function withBasicAuth(User $user, $password = 'password'): self
{
return $this->withHeaders([
'Authorization' => 'Basic '. base64_encode("{$user->email}:{$password}")
]);
}
Then in any of my test cases I can run a HTTP test with a user authenticated over basic auth like so:
$user = User::factory()->create();
$this->withBasicAuth($user)
->get('/');
->assertStatus(Response::HTTP_OK);
Note: the default password for a user created from the factory is 'password'.
$encoded_details = base64_encode('admin:admin12');
$headers = [
'HTTP_Authorization' => 'Basic '. $encoded_details
];
$response = $this->withHeaders($headers)->json('GET', '/api/me');
Just another solution which worked for me
protected function basicAuthenticate($detailsEncoded, $user, $password): self
{
$_SERVER['HTTP_AUTHORIZATION'] = $detailsEncoded;
$_SERVER['PHP_AUTH_USER'] = $user;
$_SERVER['PHP_AUTH_PW'] = $password;
return $this;
}
$response = $this->basicAuthenticate($detailsEncoded, $user, $password)->get($url);
$response->assertStatus(200);