Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Dynamic analysis for Auth calls #326

Merged
merged 21 commits into from
Mar 4, 2023
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
ab5a7d4
Order phpcs exclude-patterns
alies-dev Feb 6, 2023
8ffae5a
Add Admin model and auth config (for testing)
alies-dev Feb 6, 2023
4ce623b
Remove stale code from Acceptance Module
alies-dev Feb 6, 2023
a6dfba7
Add a return type handler for Guard implementation calls
alies-dev Feb 6, 2023
928a0cc
Add a return type handler for Auth Facade calls
alies-dev Feb 6, 2023
e7c3677
Add a return type handler for Request::user() calls
alies-dev Feb 6, 2023
46d314b
Fix PHP coding style issues 🪄
actions-user Feb 6, 2023
d7f61e1
Merge branch 'master' into auth-handler
alies-dev Feb 6, 2023
3f63f50
Merge branch 'master' into auth-handler
alies-dev Feb 6, 2023
2e5d695
Merge branch 'master' into auth-handler
alies-dev Feb 6, 2023
0f301bd
Merge branch 'master' into auth-handler
alies-dev Feb 6, 2023
4cda6d4
Merge branch 'master' into auth-handler
alies-dev Feb 14, 2023
1dbd3f9
Merge branch 'master' into auth-handler
alies-dev Mar 2, 2023
57ccc8e
Cleanup
alies-dev Mar 2, 2023
6e2b5f0
Merge branch 'master' into auth-handler
alies-dev Mar 2, 2023
e8c0241
Remove outdated MixedPropertyFetch
alies-dev Mar 2, 2023
45e51f2
Merge remote-tracking branch 'upstream/master' into auth-handler
alies-dev Mar 3, 2023
5506cd1
Support `database` guard driver (that returns \Illuminate\Auth\Generi…
alies-dev Mar 4, 2023
27d005a
Reorhanize code, fully support `database` user provider driver
alies-dev Mar 4, 2023
f99fa72
Ignore tests-app dir
alies-dev Mar 4, 2023
b1b1253
Add allowMissingFiles (tests-app may not exist)
alies-dev Mar 4, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion phpcs.xml
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,11 @@
<file>src/</file>
<file>tests</file>
<exclude-pattern>cache/</exclude-pattern>
<exclude-pattern>tests/Application/</exclude-pattern>
<exclude-pattern>tests/_run/</exclude-pattern>
<exclude-pattern>tests/Acceptance/_output</exclude-pattern>
<exclude-pattern>tests/Acceptance/_run</exclude-pattern>
<exclude-pattern>tests/Acceptance/_support</exclude-pattern>
<exclude-pattern>tests/Acceptance/Support/</exclude-pattern><!-- This code mostly copy-paste from https://github.com/psalm/codeception-psalm-module and thus may have different coding-style rules -->
<exclude-pattern>tests/Application/</exclude-pattern>
<exclude-pattern>tests/Unit/Handlers/Eloquent/Schema/migrations</exclude-pattern>
</ruleset>
47 changes: 47 additions & 0 deletions src/Handlers/Auth/AuthConfigHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

namespace Psalm\LaravelPlugin\Handlers\Auth;

use Psalm\LaravelPlugin\Providers\ConfigRepositoryProvider;

use function is_string;

final class AuthConfigHelper
{
/**
* @psalm-suppress MoreSpecificReturnType
* @return class-string<\Illuminate\Contracts\Auth\Authenticatable&\Illuminate\Database\Eloquent\Model>|null
*/
public static function getAuthModel(string $guard): ?string
{
$config = ConfigRepositoryProvider::get();

if ($guard === 'default') {
/** @psalm-suppress MixedAssignment */
$guard = $config->get('auth.defaults.guard');
if (! is_string($guard)) {
return null;
}
}

$provider = $config->get("auth.guards.$guard.provider");
if (! is_string($provider) || $provider === '') {
return null;
}

if ($config->get("auth.providers.$provider.driver") !== 'eloquent') {
return null;
}

/** @psalm-suppress MixedAssignment */
$model_fqcn = $config->get("auth.providers.$provider.model");
if (is_string($model_fqcn)) {
/** @psalm-suppress LessSpecificReturnStatement */
return $model_fqcn;
}

return null;
}
}
75 changes: 75 additions & 0 deletions src/Handlers/Auth/AuthHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php

declare(strict_types=1);

namespace Psalm\LaravelPlugin\Handlers\Auth;

use Psalm\Plugin\EventHandler\Event\MethodReturnTypeProviderEvent;
use Psalm\Plugin\EventHandler\MethodReturnTypeProviderInterface;
use Psalm\Type;

use function in_array;

/**
* Handles cases (methods that return Authenticatable instance) [when called, "default" guard is used]:
* @see \Illuminate\Support\Facades\Auth::user() returns Authenticatable|null
* @see \Illuminate\Support\Facades\Auth::loginUsingId() returns Authenticatable|false
* @see \Illuminate\Support\Facades\Auth::onceUsingId() returns Authenticatable|false
* @see \Illuminate\Support\Facades\Auth::logoutOtherDevices() returns Authenticatable|null
* @see \Illuminate\Support\Facades\Auth::getLastAttempted() returns Authenticatable
* @see \Illuminate\Support\Facades\Auth::getUser() returns Authenticatable|null
* @see \Illuminate\Support\Facades\Auth::authenticate() returns Authenticatable
*
* There are also Methods that return Guard instance (handed in {@see \Psalm\LaravelPlugin\Handlers\Auth\GuardHandler}):
* @see \Illuminate\Support\Facades\Auth::createSessionDriver()
* @see \Illuminate\Support\Facades\Auth::createTokenDriver()
* @see \Illuminate\Support\Facades\Auth::setRememberDuration()
* @see \Illuminate\Support\Facades\Auth::setRequest()
* @see \Illuminate\Support\Facades\Auth::forgetUser()
*/
final class AuthHandler implements MethodReturnTypeProviderInterface
{
/** @inheritDoc */
public static function getClassLikeNames(): array
{
return [\Illuminate\Support\Facades\Auth::class];
}

/** @inheritDoc */
public static function getMethodReturnType(MethodReturnTypeProviderEvent $event): ?Type\Union
{
$method_name_lowercase = $event->getMethodNameLowercase();

if (
! in_array($method_name_lowercase, [
'user',
'loginusingid',
'onceusingid',
'logoutotherdevices',
'getlastattempted',
'getuser',
'authenticate',
], true)
) {
return null;
}

$user_model_type = GuardHandler::getReturnTypeForGuard('default');
if (! $user_model_type instanceof Type\Atomic\TNamedObject) {
return null;
}

return match ($method_name_lowercase) {
'user', 'logoutotherdevices', 'getuser' => new Type\Union([
$user_model_type,
new Type\Atomic\TNull(),
]),
'loginusingid', 'onceusingid' => new Type\Union([
$user_model_type,
new Type\Atomic\TFalse(),
]),
'getlastattempted', 'authenticate' => new Type\Union([$user_model_type]),
default => null,
};
}
}
114 changes: 114 additions & 0 deletions src/Handlers/Auth/GuardHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
<?php

declare(strict_types=1);

namespace Psalm\LaravelPlugin\Handlers\Auth;

use PhpParser\Node\Arg;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Identifier;
use PhpParser\Node\Scalar\String_;
use Psalm\Plugin\EventHandler\Event\MethodReturnTypeProviderEvent;
use Psalm\Plugin\EventHandler\MethodReturnTypeProviderInterface;
use Psalm\Type;

use function is_string;
use function in_array;

/**
* Handles cases:
* @see \Illuminate\Contracts\Auth\Guard::user() returns Authenticatable|null
* @see \Illuminate\Contracts\Auth\StatefulGuard::loginUsingId returns Authenticatable|false
* @see \Illuminate\Contracts\Auth\StatefulGuard::onceUsingId returns Authenticatable|false
*/
final class GuardHandler implements MethodReturnTypeProviderInterface
{
/** @inheritDoc */
public static function getClassLikeNames(): array
{
return [\Illuminate\Contracts\Auth\Guard::class];
}

/** @inheritDoc */
public static function getMethodReturnType(MethodReturnTypeProviderEvent $event): ?Type\Union
{
$method_name_lowercase = $event->getMethodNameLowercase();

if (! in_array($method_name_lowercase, ['user', 'loginusingid', 'onceusingid'], true)) {
return null;
}

$statement = $event->getStmt();
if (! $statement instanceof MethodCall) {
return null;
}

$guard_name = self::findGuardNameInCallChain($statement);
if (! is_string($guard_name)) {
return null;
}

$user_model_type = self::getReturnTypeForGuard($guard_name);
if (! $user_model_type instanceof Type\Atomic\TNamedObject) {
return null;
}

return match ($method_name_lowercase) {
'user' => new Type\Union([
$user_model_type,
new Type\Atomic\TNull(),
]),
'loginusingid', 'onceusingid' => new Type\Union([
$user_model_type,
new Type\Atomic\TFalse(),
]),
default => null,
};
}

/**
* @psalm-param lowercase-string $method_name_lowercase
*/
public static function getReturnTypeForGuard(string $guard): ?Type\Atomic\TNamedObject
{
$user_model_fqcn = AuthConfigHelper::getAuthModel($guard);
if (!is_string($user_model_fqcn)) {
return null;
}

return new Type\Atomic\TNamedObject($user_model_fqcn);
}

private static function findGuardNameInCallChain(MethodCall $methodCall): ?string
{
$guard_method_call = null;

$previous_call = $methodCall->var;
while ($previous_call instanceof MethodCall) {
if (($previous_call->name instanceof Identifier) && $previous_call->name->name === 'guard') {
$guard_method_call = $previous_call;
continue;
}

$previous_call = $previous_call->var;
}
unset($previous_call);

if (! $guard_method_call instanceof MethodCall) {
return null;
}

$guard_method_call_args = $guard_method_call->args;
if ($guard_method_call_args === []) {
return 'default';
}

$first_guard_method_arg = $guard_method_call_args[0];
if ($first_guard_method_arg instanceof Arg && $first_guard_method_arg->value instanceof String_) {
return $first_guard_method_arg->value->value;
}

// @todo how about null as arg or empty args?
return null;
}
}
68 changes: 68 additions & 0 deletions src/Handlers/Auth/RequestHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php

declare(strict_types=1);

namespace Psalm\LaravelPlugin\Handlers\Auth;

use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Expr\StaticCall;
use PhpParser\Node\Scalar\String_;
use Psalm\Plugin\EventHandler\Event\MethodReturnTypeProviderEvent;
use Psalm\Plugin\EventHandler\MethodReturnTypeProviderInterface;
use Psalm\Type;

use function is_string;

/**
* Handles cases:
* @see \Illuminate\Http\Request::user()
* @see \Illuminate\Http\Request::user('guard-name')
*/
final class RequestHandler implements MethodReturnTypeProviderInterface
{
/** @inheritDoc */
public static function getClassLikeNames(): array
{
return [\Illuminate\Http\Request::class];
}

/** @inheritDoc */
public static function getMethodReturnType(MethodReturnTypeProviderEvent $event): ?Type\Union
{
if ($event->getMethodNameLowercase() !== 'user') {
return null;
}

$guard_name = self::getGuardName($event->getStmt());
if (! is_string($guard_name)) {
return null;
}

$user_model_type = GuardHandler::getReturnTypeForGuard($guard_name);
if (! $user_model_type instanceof Type\Atomic\TNamedObject) {
return null;
}

return new Type\Union([
$user_model_type,
new Type\Atomic\TNull(),
]);
}

private static function getGuardName(MethodCall|StaticCall $stmt): ?string
{
$call_args = $stmt->getArgs();
if ($call_args === []) {
return 'default';
}

$first_arg_type = $call_args[0]->value;

if ($first_arg_type instanceof String_) {
return $first_arg_type->value;
}

// @todo support const, probably vars that contains string, probably enum value
return null;
}
}
10 changes: 10 additions & 0 deletions src/Plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
use Illuminate\Foundation\Application;
use Psalm\LaravelPlugin\Handlers\Application\ContainerHandler;
use Psalm\LaravelPlugin\Handlers\Application\OffsetHandler;
use Psalm\LaravelPlugin\Handlers\Auth\AuthHandler;
use Psalm\LaravelPlugin\Handlers\Auth\GuardHandler;
use Psalm\LaravelPlugin\Handlers\Auth\RequestHandler;
use Psalm\LaravelPlugin\Handlers\Eloquent\ModelMethodHandler;
use Psalm\LaravelPlugin\Handlers\Eloquent\ModelPropertyAccessorHandler;
use Psalm\LaravelPlugin\Handlers\Eloquent\ModelRelationshipPropertyHandler;
Expand Down Expand Up @@ -90,6 +93,13 @@ private function registerHandlers(RegistrationInterface $registration): void
require_once 'Handlers/Application/OffsetHandler.php';
$registration->registerHooksFromClass(OffsetHandler::class);

require_once 'Handlers/Auth/AuthHandler.php';
$registration->registerHooksFromClass(AuthHandler::class);
require_once 'Handlers/Auth/GuardHandler.php';
$registration->registerHooksFromClass(GuardHandler::class);
require_once 'Handlers/Auth/RequestHandler.php';
$registration->registerHooksFromClass(RequestHandler::class);

require_once 'Handlers/Eloquent/ModelRelationshipPropertyHandler.php';
$registration->registerHooksFromClass(ModelRelationshipPropertyHandler::class);
require_once 'Handlers/Eloquent/ModelPropertyAccessorHandler.php';
Expand Down
17 changes: 17 additions & 0 deletions src/Providers/ConfigRepositoryProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Psalm\LaravelPlugin\Providers;

use Illuminate\Contracts\Config\Repository;

final class ConfigRepositoryProvider
{
/** @psalm-suppress MixedInferredReturnType */
public static function get(): Repository
{
/** @psalm-suppress MixedReturnStatement */
return ApplicationProvider::getApp()->get(Repository::class);
}
}
6 changes: 2 additions & 4 deletions tests/Acceptance/Support/Module.php
Original file line number Diff line number Diff line change
Expand Up @@ -135,12 +135,10 @@ public function _before(TestInterface $test): void
*/
public function runPsalmOn(string $filename, array $options = []): void
{
$suppressProgress = $this->packageSatisfiesVersionConstraint('vimeo/psalm', '>=3.4.0');

$options = array_map('escapeshellarg', $options);
$cmd = $this->getPsalmPath()
. ' --output-format=json '
. ($suppressProgress ? ' --no-progress ' : ' ')
. ' --no-progress'
. join(' ', $options) . ' '
. ($filename ? escapeshellarg($filename) : '')
. ' 2>&1';
Expand All @@ -156,7 +154,7 @@ public function runPsalmOn(string $filename, array $options = []): void
}

/**
* @param string[] $options
* @param list<string> $options
*/
public function runPsalmIn(string $dir, array $options = []): void
{
Expand Down
9 changes: 9 additions & 0 deletions tests/Application/app/Models/Admin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php declare(strict_types=1);

namespace App\Models;

use Illuminate\Foundation\Auth\User as Authenticatable;

class Admin extends Authenticatable
{
}
Loading