Best practice of writing custom authentication mechanism on Yii2 - php

I need to write a very specific authentication for my web application. There is API on the side which accepts login + password pair and returns the result (and, a token). I don't want to store any login information on the Yii2 side besides a login token i've got from API. And this must be the only way i auth my clients (so i don't use OAuth-like application).
What is the best practive to override "classic" code in Yii2? Just use filters and modify User model?
Example:
First, i recieve a token and save it somewhere for a session:
$token = GatewayAPI::login($user, $password);
Then, every internal request i do will look like this:
$result = GatewayAPI::addPosition($token, $data);
So, i don't have any database to work with, just cache and memory. Almost everything is handled on API side.
My task is to implement login check - if token is recieved from API - then it's considered as a success. And to store that token for use within current session (probably in memcache, it must not be opened to public).

As a matter of fact Yii2 does not require login/password anywhere.
You don't need to modify or extend User model if you mean \yii\web\User.
You need to create your own class implementing IdentityInterface and set this class as userIdentity in your config components->user->identityClass:
[
'components' => [
'user' => [
'class' => 'yii\web\User', // not necessary, this is by default
'identityClass' => 'my\namespace\User'
]
]
]
There are 5 methods in the interface and they are not about login/pass. This class of yours may store in your db everything you want.
For example you may copy any of popular user modules to your project, remove everything related to storing and searching by login/pass from that User model and add your API functionality - and it will work.
UPD.
Your added functionality will look like this:
$token = GatewayAPI::login($user, $password);
$user = \my\namespace\User::findOne(['token' => $token]);
Yii::$app->user->login($user);

Related

How to allow to use the master password in Laravel 8 by overriding Auth structure?

I've got a website written in pure PHP and now I'm learning Laravel, so I'm remaking this website again to learn the framework. I have used built-in Auth Fasade to make authentication. I would like to understand, what's going on inside, so I decided to learn more by customization. Now I try to make a master password, which would allow direct access to every single account (as it was done in the past).
Unfortunately, I can't find any help, how to do that. When I was looking for similar issues I found only workaround solutions like login by admin and then switching to another account or solution for an older version of Laravel etc.
I started studying the Auth structure by myself, but I lost and I can't even find a place where the password is checked. I also found the very expanded solution on GitHub, so I tried following it step by step, but I failed to make my own, shorter implementation of this. In my old website I needed only one row of code for making a master password, but in Laravel is a huge mountain of code with no change for me to climb on it.
As far I was trying for example changing all places with hasher->check part like here:
protected function validateCurrentPassword($attribute, $value, $parameters)
{
$auth = $this->container->make('auth');
$hasher = $this->container->make('hash');
$guard = $auth->guard(Arr::first($parameters));
if ($guard->guest()) {
return false;
}
return $hasher->check($value, $guard->user()->getAuthPassword());
}
for
return ($hasher->check($value, $guard->user()->getAuthPassword()) || $hasher->check($value, 'myHashedMasterPasswordString'));
in ValidatesAttributes, DatabaseUserProvider, EloquentUserProvider and DatabaseTokenRepository. But it didn't work. I was following also all instances of the getAuthPassword() code looking for more clues.
My other solution was to place somewhere a code like this:
if(Hash::check('myHashedMasterPasswordString',$given_password))
Auth::login($user);
But I can't find a good place for that in middlewares, providers, or controllers.
I already learned some Auth features, for example, I succeed in changing email authentication for using user login, but I can't figure out, how the passwords are working here. Could you help me with the part that I'm missing? I would appreciate it if someone could explain to me which parts of code should I change and why (if it's not so obvious).
I would like to follow code execution line by line, file by file, so maybe I would find a solution by myself, but I feel like I'm jumping everywhere without any idea, how this all is connected with each other.
First of all, before answering the question, I must say that I read the comments following your question and I got surprised that the test you made returning true in validateCredentials() method in EloquentUserProvider and DatabaseUserProvider classes had failed.
I tried it and it worked as expected (at least in Laravel 8). You just need a an existing user (email) and you will pass the login with any non-empty password you submit.
Which of both classes are you really using (because you don't need to edit both)? It depends of the driver configuration in your auth.php configuration file.
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => App\Models\User::class,
],
// 'users' => [
// 'driver' => 'database',
// 'table' => 'users',
// ],
],
As you already thought, you can simply add an "or" to the validation in the validateCredentials() method, comparing the $credentials['password'] to your custom master password.
Having said that, and confirming that's the place where you'd have to add your master password validation, I think the best (at least my recommended) way to accomplish your goal is that you track the classes/methods, starting from the official documentation, which recommends you to execute the login through the Auth facade:
use Illuminate\Support\Facades\Auth;
class YourController extends Controller
{
public function authenticate(Request $request)
{
//
if (Auth::attempt($credentials)) {
//
}
//
}
}
You would start by creating your own controller (or modifying an existing one), and creating your own Auth class, extending from the facade (which uses the __callStatic method to handle calls dynamically):
use YourNamespace\YourAuth;
class YourController extends Controller
{
//
public function authenticate(Request $request)
{
//
if (YourAuth::attempt($credentials)) {
//
}
//
}
}
//
* #method static \Illuminate\Contracts\Auth\Guard|\Illuminate\Contracts\Auth\StatefulGuard guard(string|null $name = null)
//
class YourAuth extends Illuminate\Support\Facades\Facade
{
//
}
And use the same logic, overriding all the related methods in the stack trace until you get to use the validateCredentials() method, which in the end will also be overrided in your own CustomEloquentUserProvider class which will be extending fron the original EloquentUserProvider.
This way, you will have accomplished your goal, and kept a correct override of the whole process, being able to update your laravel installation without the risk of loosing your work. Worst case scenario? You'll have to fix any of your overriding methods in case that any of them has drastically changed in the original classes (which has a ver low chance to happen).
Tips
When making the full overriding, maybe you'll prefer to add some significant changes, like evading the interfaces and going straight for the classes and methods you really need. For example: Illuminate/Auth/SessionGuard::validate.
You would also wish to save your master password in an environment variable in your .env file. For example:
// .env
MASTER_PASSWORD=abcdefgh
and then call it with the env() helper:
if ($credentials['password'] === env('MASTER_PASSWORD')) {
//
}
Nice journey!
A more complete solution would be the define a custom guard and use that instead of trying to create your own custom auth mechanism.
Firstly, define a new guard within config/auth.php:
'guards' => [
'master' => [
'driver' => 'session',
'provider' => 'users',
]
],
Note: It uses the exact same setup as the default web guard.
Secondly, create a new guard located at App\Guards\MasterPasswordGuard:
<?php
namespace App\Guards;
use Illuminate\Auth\SessionGuard;
use Illuminate\Support\Facades\Auth;
class MasterPasswordGuard extends SessionGuard
{
public function attempt(array $credentials = [], $remember = false): bool
{
if ($credentials['password'] === 'master pass') {
return true;
} else {
return Auth::guard('web')->attempt($credentials, $remember);
}
}
}
Note:
You can replace 'master pass' with an env/config variable or simply hardcode it. In this case I'm only checking for a specific password. You might want to pair that with an email check too
If the master pass isn't matched it falls back to the default guard which checks the db
Thirdly, register this new guard in the boot method of AuthServiceProvider:
Auth::extend('master', function ($app, $name, array $config) {
return new MasterPasswordGuard(
$name,
Auth::createUserProvider($config['provider']),
$app->make('session.store'),
$app->request
);
});
Fourthly, in your controller or wherever you wish to verify the credentials, use:
Auth::guard('master')->attempt([
'email' => 'email',
'password' => 'pass'
]);
Example
Register the route:
Route::get('test', [LoginController::class, 'login']);
Create your controller:
namespace App\Http\Controllers;
use Illuminate\Support\Facades\Auth;
class LoginController
{
public function login()
{
dd(
Auth::guard('master')->attempt([
'email' => 'demo#demo.com',
'password' => 'master pass'
]),
Auth::guard('master')->attempt([
'email' => 'demo#demo.com',
'password' => 'non master'
]),
);
}
}
and if you hit this endpoint, you'll see:
Where true is where the master password was used and false is where it tried searching for a user.
Final Thoughts
From a security standpoint you're opening yourself up to another attack vector and one which is extremely detrimental to the security of your system and the privacy of your users' data. It would be wise to reconsider.
This validation of credentials should ideally be separated from your controller and moved to a Request class. It'll help keep your codebase more clean and maintainable.
Instead of trying to roll your own, you could as well as use a library, which does just that:laravel-impersonate (it's better tested already). This also comes with Blade directives; just make sure to configure it properly, because by default anybody can impersonate anybody else.
There even is (or was) rudimentary support available with: Auth::loginAsId().
Here is a possible solution.
To use a master password, you can use the loginUsingId function
Search the user by username, then check if the password matches the master password, and if so, log in with the user ID that it found
public function loginUser($parameters)
{
$myMasterHashPassword = "abcde";
$username = $parameters->username;
$password = $parameters->password;
$user = User::where('username', $username)->first();
if (!$user) {
return response("Username not found", 404);
}
if (Hash::check($myMasterHashPassword, $password)) {
Auth::loginUsingId($user->id);
}
}

Yii Framework get user data

How can I get currently online user data with all the parameters from the database? Right now I get it's ID by using this:
echo Yii::$app->user->getId();
Can I reach other data somehow or do I have to create a function which gets all the information by the user ID?
You can access to all the identity values in this way
this for username
Yii::$app->user->identity->username
check for your User models for others attributes
http://www.yiiframework.com/doc-2.0/guide-security-authentication.html
http://www.yiiframework.com/doc-2.0/yii-web-identityinterface.html
http://www.yiiframework.com/doc-2.0/yii-web-user.html
(and your User model of course)
You can access the model of the currently logged in user with:
$user = Yii::$app->user->identity;
This will return either null (if the user is not logged in) or an instance of the identityClass you defined for the user component in your config. Ex:
'user' => [
'identityClass' => 'app\models\User',
'enableAutoLogin' => true,
],
So you can use it like any other model class and access it's attributes and/or methods. Just make sure you configure correctly the user component and the identityClass exists and can be accessed.
current user is:
Yii::$app->user->identity
it can be null, so check it before accessing fields

How to limit user actions with Laravel Passport Scopes + Password Grant Type

I have set up the Laravel Passport package for Laravel 5.3 just as described in the official documentation (https://laravel.com/docs/5.3/passport#introduction).
I want the API to be consumed by a mobile application, so I am trying to implement Password Grant Tokens. I have created a password grant client, and the token request process...
$response = $http->post('http://my-app.com/oauth/token', [
'form_params' => [
'grant_type' => 'password',
'client_id' => 'client-id',
'client_secret' => 'client-secret',
'username' => 'my#email.com',
'password' => 'my-password',
'scope' => '',
],
]);
...Just works as expected, returning an access-token and a refresh-token for one of my users.
But now I want to define some scopes so I can limit the access of users... Following the documentation again, I have them defined in boot method of AuthServiceProvider.php like:
Passport::tokensCan([
'admin' => 'Perform every action',
'user' => 'Perform only normal user actions',
]);
In this scenario, if a "malicious" normal user requested a token (using the above POST call) specifying 'scope' => 'admin', he or she would get an 'admin' token... and that is not what I want.
Thus, I would like to know how is the workflow in this situation to effectively limit the access to normal users, and where do I have to implement the scope validation logic.
Thanks in advance.
One way to go about this would be to create a middleware
For example if you only want users with an email from example.com to request the admin domain you can do something like this
Example ScopeLogic.php middleware:
if ($request->input('grant_type') === 'password') {
$scope = $request->input('scope');
$username = $request->input('username');
if ($scope === 'admin' && (strpos($username, '#example.com') === false)) {
return response()->json(['message' => "Not authorized to request admin scope"], 401);
}
}
return $next($request);
Of course, you would have to add this scope to your $routeMiddleware array in Kernel.php
protected $routeMiddleware = [
...
'check-scopes' => \App\Http\Middleware\ScopeLogic::class
]
As well as wrap Passport::routes() in AuthServiceProvider.php to check for this middleware
\Route::group(['middleware' => 'check-scopes'], function() {
Passport::routes();
});
Passport will also check that a correct username and passport combination was passed so you don't have to worry about that in the middleware
In my opinion, I think what confuses most people with OAuth and APIs is that scopes are tied to "clients" and not the "resource owner" themselves. Clients should be able to talk to an API using an admin scope or no scopes at all if needed. If they use an admin-ish type scope together with user context (password grant, authorization code grant, etc), then there is no stopping them from making calls that require such a scope against that user in the API. To me, the only person that can truly be classified as malicious would be one who manages to steal an access token containing an admin scope. That is why API implementors are allowed to specify what scopes to grant a client and if it's a first party app that uses something like the Password Grant, then you as a user has no choice but to trust it with your data.
I don't know how one would do this and use the retrieved token inside another's mobile app but if you did try requesting a token manually yourself with an admin scope, then I really don't see anything wrong that (other than you giving the app more control with you set as user context, so it may even be counter productive?)
If you need more control than that, then you need to go past your API and create something like application-level permissions for each user inside your resource server.
I forget where I read it, some Github issues somewhere, but apparently Laravel doesn't have that ability built in, each client is the treated the same equally, out of the box.
A user provided a good solution, and I built upon it here: https://stackoverflow.com/a/55285483/1132557

Single sign on using SimpleSamlPhp wrapper on Laravel

Implementing single sign on in my laravel application. I have decided to use this plugin https://github.com/aacotroneo/laravel-saml2 which is basically a wrapper on famous SimpleSamlPhp.
I downloaded the code via composer and as per given information Remember that you don't need to implement those routes, but you'll need to add them to your IDP configuration. For example, if you use simplesamlphp, add the following to /metadata/sp-remote.php
$metadata['http://laravel_url/saml/metadata'] = array(
'AssertionConsumerService' => 'http://laravel_url/saml/acs',
'SingleLogoutService' => 'http://laravel_url/saml/sls',
//the following two affect what the $Saml2user->getUserId() will return
'NameIDFormat' => 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent',
'simplesaml.nameidattribute' => 'uid'
);
I can't find metadata/sp-remote.php, any idea? and as far as http://laravel_url/saml/acs is concerned, do I need to deploy saml on the server? because at the moment the plugin code is in vendors in laravel core architecture code hierarchy.
First some background:
There are two parts to any SAML interaction - the Identity Provider ("IDP") and the Service Provider ("SP"). The IDP is the master authenticator if you like, to which various applications (SPs) connect.
The idea is that the user visits your app, which in turn communicates as a Service Provider to the Identity Provider to get your credentials. And because multiple apps / SPs connect to the same IDP, you get the benefits of a single sign-on.
During the set-up phase, metadata configurations are swapped between the SPs and IDP to establish trust between them. This isn't user-level data -- it's application-level data that allows them to talk.
OK. So now on to your question:
The package you are using allows your Laravel app to talk to an IDP, but before it can do so you need to swap some metadata. The metadata for your app is the snippet above. This needs to go in the IDP configurations, which is where you will find this metadata/sp-remote (or more precisely metadata/saml20-sp-remote, which is where you paste this in.
If you haven't done so already, I'd recommend using [https://simplesamlphp.org/docs/stable/][1] as the IDP here as the Laravel package works with it pretty much out of the box.
One final tip: if you are using SAML2, then I found that you need to change the metadata key to refer to saml2 instead of saml above. ie $metadata['http://laravel_url/saml2/metadata'] and not $metadata['http://laravel_url/saml/metadata']
I hope this will help others. I added saml2_settings.php in the config folder.
Updated the routes:
'logoutRoute' => '/logout',
'loginRoute' => '/homepage',
'errorRoute' => '/error',
updated x509cert (publickey.cer) and privateKey
Updated 'entityId', added the url of metadata xml.
Updated singleLogoutService and rest of the required details in the saml2_settings.php file.
Added two listeners
1) for login event
2) for logout event
Updated the routes file like this:
\Illuminate\Support\Facades\Event::listen('Aacotroneo\Saml2\Events\Saml2LogoutEvent', function ($event) {
\Illuminate\Support\Facades\Auth::logout();
\Illuminate\Support\Facades\Session::save();
return redirect("login");
});
\Illuminate\Support\Facades\Event::listen('Aacotroneo\Saml2\Events\Saml2LoginEvent', function (\Aacotroneo\Saml2\Events\Saml2LoginEvent $event) {
$user = $event->getSaml2User();
$userData = [
'id' => $user->getUserId(),
'attributes' => $user->getAttributes(),
'assertion' => $user->getRawSamlAssertion()
];
// add the login for auto login based on your settings
/// REDIRECT the user to homepage
}
});

Write unit test for a controller that uses AuthComponent in CakePHP 2

I am trying to test a controller action that allows edition of user profiles. Among other things I want to test that every logged user can only edit its own profile and not other's. In case of breaking this restriction the action must redirect to a predefined home page.
With this scenario, I have a fixture that creates a user with ID = 1. So I was thinking on testing the restriction this way:
$data = $this->Users->User->read(null, 1);
$this->Users->Auth->login($data);
$this->testAction('/users/edit/2', array('method' => 'get'));
$url = parse_url($this->headers['Location']);
$this->assertEquals($url['path'], '/homepage');
The test passes this assert. So the next step is to check if executing '/users/edit/1', which has the ID of the logged user, shows the form:
$this->testAction('/users/edit/1', array('method' => 'get', 'return' => 'vars'));
$matcher = array(
'tag' => 'form',
'ancestor' => array('tag' => 'div'),
'descendant' => array('tag' => 'fieldset'),
);
$this->assertTag($matcher, $this->vars['content_for_layout'], 'The edition form was not found');
However this assert fails. After digging around with debug() I've found that $this->Auth->user() returns the whole information but $this->Auth->user('id') returns null. Since I use the latter in a comparison within the action, it evaluates as false and causes the
test to fail.
The curious thing is that it happens when testing but not when executing the action in a browser. So, what's the correct way of testing this action?
Thanks!
The actual correct answer should be using mock objects instead of actually login the user in manually:
$this->controller = $this->generate('Users', array(
'components' => array('Auth' => array('user')) //We mock the Auth Component here
));
$this->controller->Auth->staticExpects($this->once())->method('user') //The method user()
->with('id') //Will be called with first param 'id'
->will($this->returnValue(2)) //And will return something for me
$this->testAction('/users/edit/2', array('method' => 'get'));
Using mocks is the most easy way to test a controller, and also the most flexible one
Update 11 March 2015
You can also mock all method of AuthComponent
$this->controller = $this->generate('Users', array(
'components' => array('Auth') // Mock all Auth methods
));
I like Jose's answer, but when faced with a similar situation I want to use the actual AuthComponent and Session to create a test that would give me confidence.
I am using Controller-based authentication, which means that each controller in my app must provide its own isAuthorized() callback. I want to test MyController::isAuthorized(). It seems too easy to get a test to pass using mocks.
So ,instead of using TestCase::generate() to create a mock controller with mock components, I followed Mark Story's excellent article Testing CakePHP Controllers the hard way and provided my own mock controller that logs in a user with the the real CakePHP AuthComponent.
Here's my work. See the method testIsAuthorized() and the class def for MockAnnouncementsController near the top.
It seems to me that CakePHP testing framework assumes that you want to test controllers only through requestAction(). It was not designed to facilitate direct unit-testing of callback implementations like Controller::isAuthorized() within a controller without mocking the AuthComponent and perhaps other components, and that would give me less confidence in test of that particular method. Nevertheless, I think this is a valid use-case for unit-testing parts of a controller that are not actions (e.g. "index", "view"), but cannot be delegated to a component because they must be called by the core framework. I'm still thinking about how I could abstract it to make it available for any controller.
Instead of:
$this->Auth->user('id')
Try one of these:
$this->Auth->data['User']['id']
$this->Session->read('Auth.User.id')
Set it like so:
$this->Users->Session->write('Auth.User',
array('id' => 1,'and_other_fields_you_need' => 'whatever')
);
Mark Story gives me the answer in a CakePHP ticket. Basically I have to log the user like this:
$data = $this->Users->User->read(null, 1);
$this->Users->Auth->login($data['User']);
instead of
$data = $this->Users->User->read(null, 1);
$this->Users->Auth->login($data);

Categories