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 all 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
3 changes: 1 addition & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,9 @@
}
},
"scripts": {
"analyze": "psalm --find-dead-code --find-unused-psalm-suppress --long-progress",
"lint": "phpcs --report-full --report-summary --colors -n -s",
"lint-fix": "phpcbf -n",
"psalm": "@analyze",
"psalm": "psalm --find-dead-code --find-unused-psalm-suppress --long-progress --output-format=phpstorm",
"psalm-set-baseline": "@php ./psalm --set-baseline=psalm-baseline.xml",
"test": [
"@lint",
Expand Down
1 change: 1 addition & 0 deletions phpcs.xml
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,5 @@
<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>
<exclude-pattern>tests-app/</exclude-pattern>
</ruleset>
29 changes: 21 additions & 8 deletions psalm-baseline.xml
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<files psalm-version="5.6.0@e784128902dfe01d489c4123d69918a9f3c1eac5">
<file src="src/Handlers/Helpers/PathHandler.php">
<InvalidArgument>
<code>[$argument]</code>
</InvalidArgument>
<MixedAssignment>
<code>$argument</code>
</MixedAssignment>
<files psalm-version="5.7.7@e028ba46ba0d7f9a78bc3201c251e137383e145f">
<file src="src/Handlers/Auth/AuthConfigAnalyzer.php">
<LessSpecificReturnStatement>
<code><![CDATA['\Illuminate\Auth\GenericUser']]></code>
</LessSpecificReturnStatement>
<MixedArgument>
<code><![CDATA[$this->config->get('auth.guards')]]></code>
</MixedArgument>
<MixedInferredReturnType>
<code>?string</code>
<code><![CDATA[class-string<\Illuminate\Contracts\Auth\Authenticatable>|null]]></code>
</MixedInferredReturnType>
<MixedReturnStatement>
<code><![CDATA[$this->config->get("auth.providers.$provider.model", null)]]></code>
<code><![CDATA[$this->config->get('auth.defaults.guard')]]></code>
</MixedReturnStatement>
</file>
<file src="src/Handlers/Auth/AuthHandler.php">
<TypeDoesNotContainType>
<code>null</code>
</TypeDoesNotContainType>
</file>
</files>
3 changes: 2 additions & 1 deletion psalm.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@
>
<projectFiles>
<directory name="src" />
<ignoreFiles>
<ignoreFiles allowMissingFiles="true">
<directory name="vendor"/>
<directory name="tests-app"/>
<directory name="tests/Unit/Handlers/Eloquent/Schema/migrations"/>
</ignoreFiles>
</projectFiles>
Expand Down
81 changes: 81 additions & 0 deletions src/Handlers/Auth/AuthConfigAnalyzer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php

declare(strict_types=1);

namespace Psalm\LaravelPlugin\Handlers\Auth;

use Illuminate\Contracts\Config\Repository as ConfigRepository;
use Psalm\LaravelPlugin\Providers\ConfigRepositoryProvider;

use function is_string;
use function array_keys;

final class AuthConfigAnalyzer
{
private static ?AuthConfigAnalyzer $instance = null;

private ConfigRepository $config;

private function __construct(ConfigRepository $config)
{
$this->config = $config;
}

public static function instance(): self
{
if (self::$instance === null) {
self::$instance = new AuthConfigAnalyzer(ConfigRepositoryProvider::get());
}

return self::$instance;
}

/**
* @return class-string<\Illuminate\Contracts\Auth\Authenticatable>|null
*/
public function getAuthenticatableFQCN(?string $guard = null): ?string
{
if ($guard === null) {
$guard = $this->getDefaultGuard();

if (! is_string($guard)) {
return null;
}
}

$provider = $this->config->get("auth.guards.$guard.provider");

if (! is_string($provider)) {
return null;
}

if ($this->config->get("auth.providers.$provider.driver") === 'database') {
return '\Illuminate\Auth\GenericUser';
}

return $this->config->get("auth.providers.$provider.model", null);
}

public function getDefaultGuard(): ?string
{
return $this->config->get('auth.defaults.guard');
}

/** @return list<class-string<\Illuminate\Contracts\Auth\Authenticatable>> */
public function getAllAuthenticatables(): array
{
$all_authenticatables = [];

/** @var list<string> $guards */
$guards = array_keys($this->config->get('auth.guards'));

foreach ($guards as $guard) {
$authenticatable_fqcn = $this->getAuthenticatableFQCN($guard);
if (is_string($authenticatable_fqcn)) {
$all_authenticatables[] = $authenticatable_fqcn;
}
}

return $all_authenticatables;
}
}
84 changes: 84 additions & 0 deletions src/Handlers/Auth/AuthHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?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;
use function is_string;

/**
* 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;
}

$default_guard = AuthConfigAnalyzer::instance()->getDefaultGuard();
if (! is_string($default_guard)) {
return null; // normally should not happen (e.g. empty or invalid auth.php)
}

$authenticatable_fqcn = AuthConfigAnalyzer::instance()->getAuthenticatableFQCN($default_guard);

if (! is_string($authenticatable_fqcn)) {
return null; // normally should not happen (e.g. empty or invalid auth.php)
}

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

declare(strict_types=1);

namespace Psalm\LaravelPlugin\Handlers\Auth\Concerns;

use PhpParser\Node\Expr\CallLike;
use PhpParser\Node\Scalar\String_;

trait ExtractsGuardNameFromCallLike
{
public static function getGuardNameFromFirstArgument(CallLike $stmt, string $default_guard): ?string
{
$call_args = $stmt->getArgs();
if ($call_args === []) {
return $default_guard;
}

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

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

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

declare(strict_types=1);

namespace Psalm\LaravelPlugin\Handlers\Auth;

use PhpParser\Node\Expr\CallLike;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Expr\StaticCall;
use PhpParser\Node\Identifier;
use PhpParser\Node\Name;
use Psalm\LaravelPlugin\Handlers\Auth\Concerns\ExtractsGuardNameFromCallLike;
use Psalm\Plugin\EventHandler\Event\MethodReturnTypeProviderEvent;
use Psalm\Plugin\EventHandler\MethodReturnTypeProviderInterface;
use Psalm\Type;

use function in_array;
use function is_string;

/**
* Handles cases (only non-static method calls):
* @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
{
use ExtractsGuardNameFromCallLike;

/** @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;
}

$authenticatables = AuthConfigAnalyzer::instance()->getAllAuthenticatables();
if ($authenticatables === []) {
return null; // normally should not happen (e.g. empty or invalid auth.php)
}

$app_possible_authenticatable_types = [];

foreach ($authenticatables as $authenticatable_fqcn) {
$app_possible_authenticatable_types[] = new Type\Atomic\TNamedObject($authenticatable_fqcn);
}

$empty_return_type = $method_name_lowercase === 'user' ? new Type\Atomic\TNull() : new Type\Atomic\TFalse();
$default_return_type = new Type\Union([...$app_possible_authenticatable_types, $empty_return_type]);

$statement = $event->getStmt();
if (! $statement instanceof MethodCall) { // in theory, it can also be a StaticCall
return $default_return_type;
}

$guard = self::findGuardNameInCallChain($statement);

$is_guard_known = is_string($guard);
if (! $is_guard_known) {
return $default_return_type;
}

$authenticatable_fqcn = AuthConfigAnalyzer::instance()->getAuthenticatableFQCN($guard);
if (! is_string($authenticatable_fqcn)) {
return $default_return_type;
}

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

/**
* Go backward in callstack in order to find
* ->guard($guard) method call or auth($guard) helper call.
* Return null when such method nof found (so we don't know which guard used).
*/
private static function findGuardNameInCallChain(MethodCall $methodCall): ?string
{
$call_contains_guard_name = null;

$previous_call = $methodCall->var;
while ($call_contains_guard_name === null && $previous_call instanceof CallLike) {
if ($previous_call instanceof MethodCall || $previous_call instanceof StaticCall) {
if (($previous_call->name instanceof Identifier) && $previous_call->name->name === 'guard') {
$call_contains_guard_name = $previous_call; // exit from while loop
continue;
}

if ($previous_call instanceof MethodCall) {
$previous_call = $previous_call->var;
continue;
}
}

// auth() or auth('guard') call
if ($previous_call instanceof FuncCall) {
if ($previous_call->name instanceof Name && $previous_call->name->parts[0] === 'auth') {
$call_contains_guard_name = $previous_call; // exit from while loop
}
}

$previous_call = null; // exit from while loop
}
unset($previous_call);

if (! $call_contains_guard_name instanceof CallLike) {
return null;
}

$default_guard = AuthConfigAnalyzer::instance()->getDefaultGuard();
if (! is_string($default_guard)) {
return null; // normally should not happen (e.g. empty or invalid auth.php)
}

return self::getGuardNameFromFirstArgument(
$call_contains_guard_name,
$default_guard
);
}
}
Loading