diff options
Diffstat (limited to 'main/app/sprinkles/account/src/Authorize')
4 files changed, 512 insertions, 0 deletions
diff --git a/main/app/sprinkles/account/src/Authorize/AccessConditionExpression.php b/main/app/sprinkles/account/src/Authorize/AccessConditionExpression.php new file mode 100755 index 0000000..dd5647e --- /dev/null +++ b/main/app/sprinkles/account/src/Authorize/AccessConditionExpression.php @@ -0,0 +1,139 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Account\Authorize; + +use Monolog\Logger; +use PhpParser\Lexer\Emulative as EmulativeLexer; +use PhpParser\Node; +use PhpParser\NodeTraverser; +use PhpParser\Parser as Parser; +use PhpParser\PrettyPrinter\Standard as StandardPrettyPrinter; +use PhpParser\Error as PhpParserException; +use Psr\Http\Message\ServerRequestInterface as Request; +use UserFrosting\Sprinkle\Account\Database\Models\User; + +/** + * AccessConditionExpression class + * + * This class models the evaluation of an authorization condition expression, as associated with permissions. + * A condition is built as a boolean expression composed of AccessCondition method calls. + * + * @author Alex Weissman (https://alexanderweissman.com) + */ +class AccessConditionExpression +{ + /** + * @var User A user object, which for convenience can be referenced as 'self' in access conditions. + */ + protected $user; + + /** + * @var ParserNodeFunctionEvaluator The node visitor, which evaluates access condition callbacks used in a permission condition. + */ + protected $nodeVisitor; + + /** + * @var \PhpParser\Parser The PhpParser object to use (initialized in the ctor) + */ + protected $parser; + + /** + * @var NodeTraverser The NodeTraverser object to use (initialized in the ctor) + */ + protected $traverser; + + /** + * @var StandardPrettyPrinter The PrettyPrinter object to use (initialized in the ctor) + */ + protected $prettyPrinter; + + /** + * @var Logger + */ + protected $logger; + + /** + * @var bool Set to true if you want debugging information printed to the auth log. + */ + protected $debug; + + /** + * Create a new AccessConditionExpression object. + * + * @param User $user A user object, which for convenience can be referenced as 'self' in access conditions. + * @param Logger $logger A Monolog logger, used to dump debugging info for authorization evaluations. + * @param bool $debug Set to true if you want debugging information printed to the auth log. + */ + public function __construct(ParserNodeFunctionEvaluator $nodeVisitor, User $user, Logger $logger, $debug = false) + { + $this->nodeVisitor = $nodeVisitor; + $this->user = $user; + $this->parser = new Parser(new EmulativeLexer); + $this->traverser = new NodeTraverser; + $this->traverser->addVisitor($nodeVisitor); + $this->prettyPrinter = new StandardPrettyPrinter; + $this->logger = $logger; + $this->debug = $debug; + } + + /** + * Evaluates a condition expression, based on the given parameters. + * + * The special parameter `self` is an array of the current user's data. + * This get included automatically, and so does not need to be passed in. + * @param string $condition a boolean expression composed of calls to AccessCondition functions. + * @param array[mixed] $params the parameters to be used when evaluating the expression. + * @return bool true if the condition is passed for the given parameters, otherwise returns false. + */ + public function evaluateCondition($condition, $params) + { + // Set the reserved `self` parameters. + // This replaces any values of `self` specified in the arguments, thus preventing them from being overridden in malicious user input. + // (For example, from an unfiltered request body). + $params['self'] = $this->user->export(); + + $this->nodeVisitor->setParams($params); + + $code = "<?php $condition;"; + + if ($this->debug) { + $this->logger->debug("Evaluating access condition '$condition' with parameters:", $params); + } + + // Traverse the parse tree, and execute any callbacks found using the supplied parameters. + // Replace the function node with the return value of the callback. + try { + // parse + $stmts = $this->parser->parse($code); + + // traverse + $stmts = $this->traverser->traverse($stmts); + + // Evaluate boolean statement. It is safe to use eval() here, because our expression has been reduced entirely to a boolean expression. + $expr = $this->prettyPrinter->prettyPrintExpr($stmts[0]); + $expr_eval = "return " . $expr . ";\n"; + $result = eval($expr_eval); + + if ($this->debug) { + $this->logger->debug("Expression '$expr' evaluates to " . ($result == true ? "true" : "false")); + } + + return $result; + } catch (PhpParserException $e) { + if ($this->debug) { + $this->logger->debug("Error parsing access condition '$condition':" . $e->getMessage()); + } + return false; // Access fails if the access condition can't be parsed. + } catch (AuthorizationException $e) { + if ($this->debug) { + $this->logger->debug("Error parsing access condition '$condition':" . $e->getMessage()); + } + return false; + } + } +} diff --git a/main/app/sprinkles/account/src/Authorize/AuthorizationException.php b/main/app/sprinkles/account/src/Authorize/AuthorizationException.php new file mode 100755 index 0000000..251b67f --- /dev/null +++ b/main/app/sprinkles/account/src/Authorize/AuthorizationException.php @@ -0,0 +1,23 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Account\Authorize; + +use UserFrosting\Support\Exception\ForbiddenException; + +/** + * AuthorizationException class + * + * Exception for AccessConditionExpression. + * + * @author Alex Weissman (https://alexanderweissman.com) + * @see http://www.userfrosting.com/components/#authorization + */ +class AuthorizationException extends ForbiddenException +{ + +} diff --git a/main/app/sprinkles/account/src/Authorize/AuthorizationManager.php b/main/app/sprinkles/account/src/Authorize/AuthorizationManager.php new file mode 100755 index 0000000..def152b --- /dev/null +++ b/main/app/sprinkles/account/src/Authorize/AuthorizationManager.php @@ -0,0 +1,157 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Account\Authorize; + +use Interop\Container\ContainerInterface; +use UserFrosting\Sprinkle\Account\Database\Models\User; + +/** + * AuthorizationManager class. + * + * Manages a collection of access condition callbacks, and uses them to perform access control checks on user objects. + * @author Alex Weissman (https://alexanderweissman.com) + */ +class AuthorizationManager +{ + /** + * @var ContainerInterface The global container object, which holds all your services. + */ + protected $ci; + + /** + * @var array[callable] An array of callbacks that accept some parameters and evaluate to true or false. + */ + protected $callbacks = []; + + /** + * Create a new AuthorizationManager object. + * + * @param ContainerInterface $ci The global container object, which holds all your services. + */ + public function __construct(ContainerInterface $ci, array $callbacks = []) + { + $this->ci = $ci; + $this->callbacks = $callbacks; + } + + /** + * Register an authorization callback, which can then be used in permission conditions. + * + * To add additional callbacks, simply extend the `authorizer` service in your Sprinkle's service provider. + * @param string $name + * @param callable $callback + */ + public function addCallback($name, $callback) + { + $this->callbacks[$name] = $callback; + return $this; + } + + /** + * Get all authorization callbacks. + * + * @return callable[] + */ + public function getCallbacks() + { + return $this->callbacks; + } + + /** + * Checks whether or not a user has access on a particular permission slug. + * + * Determine if this user has access to the given $slug under the given $params. + * + * @param UserFrosting\Sprinkle\Account\Database\Models\User $user + * @param string $slug The permission slug to check for access. + * @param array $params[optional] An array of field names => values, specifying any additional data to provide the authorization module + * when determining whether or not this user has access. + * @return boolean True if the user has access, false otherwise. + */ + public function checkAccess(User $user, $slug, array $params = []) + { + $debug = $this->ci->config['debug.auth']; + + if ($debug) { + $trace = array_slice(debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3), 1); + $this->ci->authLogger->debug("Authorization check requested at: ", $trace); + $this->ci->authLogger->debug("Checking authorization for user {$user->id} ('{$user->user_name}') on permission '$slug'..."); + } + + if ($this->ci->authenticator->guest()) { + if ($debug) { + $this->ci->authLogger->debug("User is not logged in. Access denied."); + } + return false; + } + + // The master (root) account has access to everything. + // Need to use loose comparison for now, because some DBs return `id` as a string. + + if ($user->id == $this->ci->config['reserved_user_ids.master']) { + if ($debug) { + $this->ci->authLogger->debug("User is the master (root) user. Access granted."); + } + return true; + } + + // Find all permissions that apply to this user (via roles), and check if any evaluate to true. + $permissions = $user->getCachedPermissions(); + + if (empty($permissions) || !isset($permissions[$slug])) { + if ($debug) { + $this->ci->authLogger->debug("No matching permissions found. Access denied."); + } + return false; + } + + $permissions = $permissions[$slug]; + + if ($debug) { + $this->ci->authLogger->debug("Found matching permissions: \n" . print_r($this->getPermissionsArrayDebugInfo($permissions), true)); + } + + $nodeVisitor = new ParserNodeFunctionEvaluator($this->callbacks, $this->ci->authLogger, $debug); + $ace = new AccessConditionExpression($nodeVisitor, $user, $this->ci->authLogger, $debug); + + foreach ($permissions as $permission) { + $pass = $ace->evaluateCondition($permission->conditions, $params); + if ($pass) { + if ($debug) { + $this->ci->authLogger->debug("User passed conditions '{$permission->conditions}' . Access granted."); + } + return true; + } + } + + if ($debug) { + $this->ci->authLogger->debug("User failed to pass any of the matched permissions. Access denied."); + } + + return false; + } + + /** + * Remove extraneous information from the permission to reduce verbosity. + * + * @param array + * @return array + */ + protected function getPermissionsArrayDebugInfo($permissions) + { + $permissionsInfo = []; + foreach ($permissions as $permission) { + $permissionData = array_only($permission->toArray(), ['id', 'slug', 'name', 'conditions', 'description']); + // Remove this until we can find an efficient way to only load these once during debugging + //$permissionData['roles_via'] = $permission->roles_via->pluck('id')->all(); + $permissionsInfo[] = $permissionData; + } + + return $permissionsInfo; + } +} diff --git a/main/app/sprinkles/account/src/Authorize/ParserNodeFunctionEvaluator.php b/main/app/sprinkles/account/src/Authorize/ParserNodeFunctionEvaluator.php new file mode 100755 index 0000000..e8e5cde --- /dev/null +++ b/main/app/sprinkles/account/src/Authorize/ParserNodeFunctionEvaluator.php @@ -0,0 +1,193 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Account\Authorize; + +use Monolog\Logger; +use PhpParser\Node; +use PhpParser\NodeVisitorAbstract; +use PhpParser\PrettyPrinter\Standard as StandardPrettyPrinter; + +/** + * ParserNodeFunctionEvaluator class + * + * This class parses access control condition expressions. + * + * @author Alex Weissman (https://alexanderweissman.com) + * @see http://www.userfrosting.com/components/#authorization + */ +class ParserNodeFunctionEvaluator extends NodeVisitorAbstract +{ + + /** + * @var array[callable] An array of callback functions to be used when evaluating a condition expression. + */ + protected $callbacks; + /** + * @var \PhpParser\PrettyPrinter\Standard The PrettyPrinter object to use (initialized in the ctor) + */ + protected $prettyPrinter; + /** + * @var array The parameters to be used when evaluating the methods in the condition expression, as an array. + */ + protected $params = []; + + /** + * @var Logger + */ + protected $logger; + + /** + * @var bool Set to true if you want debugging information printed to the auth log. + */ + protected $debug; + + /** + * Create a new ParserNodeFunctionEvaluator object. + * + * @param array $params The parameters to be used when evaluating the methods in the condition expression, as an array. + * @param Logger $logger A Monolog logger, used to dump debugging info for authorization evaluations. + * @param bool $debug Set to true if you want debugging information printed to the auth log. + */ + public function __construct($callbacks, $logger, $debug = false) + { + $this->callbacks = $callbacks; + $this->prettyPrinter = new StandardPrettyPrinter; + $this->logger = $logger; + $this->debug = $debug; + $this->params = []; + } + + public function leaveNode(Node $node) + { + // Look for function calls + if ($node instanceof \PhpParser\Node\Expr\FuncCall) { + $eval = new \PhpParser\Node\Scalar\LNumber; + + // Get the method name + $callbackName = $node->name->toString(); + // Get the method arguments + $argNodes = $node->args; + + $args = []; + $argsInfo = []; + foreach ($argNodes as $arg) { + $argString = $this->prettyPrinter->prettyPrintExpr($arg->value); + + // Debugger info + $currentArgInfo = [ + 'expression' => $argString + ]; + // Resolve parameter placeholders ('variable' names (either single-word or array-dot identifiers)) + if (($arg->value instanceof \PhpParser\Node\Expr\BinaryOp\Concat) || ($arg->value instanceof \PhpParser\Node\Expr\ConstFetch)) { + $value = $this->resolveParamPath($argString); + $currentArgInfo['type'] = "parameter"; + $currentArgInfo['resolved_value'] = $value; + // Resolve arrays + } elseif ($arg->value instanceof \PhpParser\Node\Expr\Array_) { + $value = $this->resolveArray($arg); + $currentArgInfo['type'] = "array"; + $currentArgInfo['resolved_value'] = print_r($value, true); + // Resolve strings + } elseif ($arg->value instanceof \PhpParser\Node\Scalar\String_) { + $value = $arg->value->value; + $currentArgInfo['type'] = "string"; + $currentArgInfo['resolved_value'] = $value; + // Resolve numbers + } elseif ($arg->value instanceof \PhpParser\Node\Scalar\DNumber) { + $value = $arg->value->value; + $currentArgInfo['type'] = "float"; + $currentArgInfo['resolved_value'] = $value; + } elseif ($arg->value instanceof \PhpParser\Node\Scalar\LNumber) { + $value = $arg->value->value; + $currentArgInfo['type'] = "integer"; + $currentArgInfo['resolved_value'] = $value; + // Anything else is simply interpreted as its literal string value + } else { + $value = $argString; + $currentArgInfo['type'] = "unknown"; + $currentArgInfo['resolved_value'] = $value; + } + + $args[] = $value; + $argsInfo[] = $currentArgInfo; + } + + if ($this->debug) { + if (count($args)) { + $this->logger->debug("Evaluating callback '$callbackName' on: ", $argsInfo); + } else { + $this->logger->debug("Evaluating callback '$callbackName'..."); + } + } + + // Call the specified access condition callback with the specified arguments. + if (isset($this->callbacks[$callbackName]) && is_callable($this->callbacks[$callbackName])) { + $result = call_user_func_array($this->callbacks[$callbackName], $args); + } else { + throw new AuthorizationException("Authorization failed: Access condition method '$callbackName' does not exist."); + } + + if ($this->debug) { + $this->logger->debug("Result: " . ($result ? "1" : "0")); + } + + return new \PhpParser\Node\Scalar\LNumber($result ? "1" : "0"); + } + } + + public function setParams($params) + { + $this->params = $params; + } + + /** + * Resolve an array expression in a condition expression into an actual array. + * + * @param string $arg the array, represented as a string. + * @return array[mixed] the array, as a plain ol' PHP array. + */ + private function resolveArray($arg) + { + $arr = []; + $items = (array) $arg->value->items; + foreach ($items as $item) { + if ($item->key) { + $arr[$item->key] = $item->value->value; + } else { + $arr[] = $item->value->value; + } + } + return $arr; + } + + /** + * Resolve a parameter path (e.g. "user.id", "post", etc) into its value. + * + * @param string $path the name of the parameter to resolve, based on the parameters set in this object. + * @throws Exception the path could not be resolved. Path is malformed or key does not exist. + * @return mixed the value of the specified parameter. + */ + private function resolveParamPath($path) + { + $pathTokens = explode(".", $path); + $value = $this->params; + foreach ($pathTokens as $token) { + $token = trim($token); + if (is_array($value) && isset($value[$token])) { + $value = $value[$token]; + continue; + } elseif (is_object($value) && isset($value->$token)) { + $value = $value->$token; + continue; + } else { + throw new AuthorizationException("Cannot resolve the path \"$path\". Error at token \"$token\"."); + } + } + return $value; + } +} |