├── public ├── favicon.ico ├── assets │ └── .gitkeep ├── img │ ├── .gitkeep │ └── limoncello.png ├── robots.txt ├── index.php ├── .htaccess └── web.config ├── server ├── storage │ ├── local.sqlite │ ├── logs │ │ └── .gitignore │ └── cache │ │ ├── settings │ │ └── .gitignore │ │ └── templates │ │ └── .gitignore ├── app │ ├── Json │ │ ├── Middleware │ │ │ └── .gitkeep │ │ ├── Controllers │ │ │ ├── BaseController.php │ │ │ ├── UsersController.php │ │ │ └── RolesController.php │ │ ├── Schemas │ │ │ ├── RoleSchema.php │ │ │ ├── BaseSchema.php │ │ │ └── UserSchema.php │ │ ├── readme.md │ │ └── Exceptions │ │ │ └── ThrowableConverter.php │ ├── Validation │ │ ├── readme.md │ │ ├── ErrorCodes.php │ │ ├── L10n │ │ │ └── Messages.php │ │ ├── Role │ │ │ ├── RoleUpdateForm.php │ │ │ ├── RoleCreateForm.php │ │ │ ├── RoleRules.php │ │ │ ├── RoleUpdateJson.php │ │ │ ├── RoleCreateJson.php │ │ │ └── RolesReadQuery.php │ │ ├── Auth │ │ │ └── SignIn.php │ │ ├── User │ │ │ ├── UserUpdateForm.php │ │ │ ├── UserCreateForm.php │ │ │ ├── UserUpdateJson.php │ │ │ ├── UserCreateJson.php │ │ │ ├── UserRules.php │ │ │ └── UsersReadQuery.php │ │ └── BaseRules.php │ ├── Data │ │ ├── readme.md │ │ ├── seeds.php │ │ ├── migrations.php │ │ ├── Models │ │ │ ├── CommonFields.php │ │ │ ├── RoleScope.php │ │ │ ├── Role.php │ │ │ └── User.php │ │ ├── Migrations │ │ │ ├── RolesMigration.php │ │ │ ├── UsersMigration.php │ │ │ └── RolesScopesMigration.php │ │ └── Seeds │ │ │ ├── RolesSeed.php │ │ │ ├── UsersSeed.php │ │ │ └── PassportSeed.php │ ├── Commands │ │ ├── readme.md │ │ └── Middleware │ │ │ └── CliAuthenticationMiddleware.php │ ├── Application.php │ ├── Container │ │ ├── CliCommandsConfigurator.php │ │ ├── RequestStorageConfigurator.php │ │ ├── readme.md │ │ └── TwigConfigurator.php │ ├── Routes │ │ ├── readme.md │ │ ├── CliRoutes.php │ │ ├── ApiRoutes.php │ │ └── WebRoutes.php │ ├── Web │ │ ├── Controllers │ │ │ └── HomeController.php │ │ ├── readme.md │ │ ├── Views.php │ │ └── Middleware │ │ │ ├── RememberRequestMiddleware.php │ │ │ ├── CustomErrorResponsesMiddleware.php │ │ │ └── CookieAuth.php │ ├── readme.md │ ├── Authorization │ │ ├── UserRules.php │ │ ├── readme.md │ │ ├── RulesTrait.php │ │ └── RoleRules.php │ ├── Api │ │ ├── RolesApi.php │ │ └── UsersApi.php │ └── Authentication │ │ └── OAuth.php ├── resources │ ├── views │ │ ├── emails │ │ │ └── .gitkeep │ │ └── pages │ │ │ └── en │ │ │ ├── 403.html.twig │ │ │ ├── 404.html.twig │ │ │ ├── 401.html.twig │ │ │ ├── sections │ │ │ └── pagination.html.twig │ │ │ ├── base │ │ │ ├── master.html.twig │ │ │ └── with-header-and-footer.master.html.twig │ │ │ ├── roles.html.twig │ │ │ ├── role-modify.html.twig │ │ │ ├── sign-in.html.twig │ │ │ ├── users.html.twig │ │ │ ├── user-modify.html.twig │ │ │ └── home.html.twig │ ├── img │ │ └── screen-shot.png │ └── messages │ │ └── en │ │ └── App.Views.Pages.php ├── readme.md ├── settings │ ├── L10n.php │ ├── Monolog.php │ ├── Commands.php │ ├── Hasher.php │ ├── Session.php │ ├── PdoDatabase.php │ ├── Templates.php │ ├── readme.md │ ├── Authorization.php │ ├── Doctrine.php │ ├── Data.php │ ├── Passport.php │ ├── ApplicationApi.php │ ├── Cors.php │ └── Application.php └── tests │ ├── Api │ ├── RoleApiTest.php │ └── UserApiTest.php │ ├── Cli │ └── CliiTest.php │ ├── Json │ └── RoleApiTest.php │ └── Web │ ├── HomeWebTest.php │ └── AuthWebTest.php ├── client ├── scss │ ├── index.scss │ └── pages │ │ └── _sign-in.scss ├── src │ ├── declaration.d.ts │ ├── tsconfig.json │ ├── index.ts │ └── Application │ │ └── AuthToken.ts ├── readme.md └── webpack │ ├── production.config.js │ ├── development.config.js │ ├── FsWatchPlugin.js │ └── base.config.js ├── .gitignore ├── .editorconfig ├── phpunit.xml ├── LICENSE.md ├── .env.sample ├── package.json ├── docker-compose.yml ├── composer.json └── README.md /public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/img/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/storage/local.sqlite: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/app/Json/Middleware/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/resources/views/emails/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /server/storage/logs/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /server/storage/cache/settings/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /server/storage/cache/templates/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /client/scss/index.scss: -------------------------------------------------------------------------------- 1 | @import "~bootstrap/scss/bootstrap"; 2 | @import "pages/sign-in"; 3 | -------------------------------------------------------------------------------- /public/img/limoncello.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/limoncello-php/app/HEAD/public/img/limoncello.png -------------------------------------------------------------------------------- /client/src/declaration.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.scss' { 2 | const content: any; 3 | export default content; 4 | } 5 | -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | run(); 6 | -------------------------------------------------------------------------------- /server/resources/img/screen-shot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/limoncello-php/app/HEAD/server/resources/img/screen-shot.png -------------------------------------------------------------------------------- /server/app/Validation/readme.md: -------------------------------------------------------------------------------- 1 | This folder contains validation logic for HTTP Query parameters, JSON API inputs and Web form inputs. 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | server/storage/local.sqlite 2 | .idea/ 3 | node_modules/ 4 | vendor/ 5 | .env 6 | composer.lock 7 | package-lock.json 8 | yarn.lock 9 | yarn-error.log 10 | -------------------------------------------------------------------------------- /client/readme.md: -------------------------------------------------------------------------------- 1 | - Folder `src` contains application source code (JavaScript/TypeScript). 2 | - Folder `webpack` contains [webpack](https://webpack.js.org/) settings for the application. 3 | -------------------------------------------------------------------------------- /client/webpack/production.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | let baseConfig = require('./base.config.js'); 4 | 5 | baseConfig.mode = 'production'; 6 | baseConfig.devtool = 'source-map'; 7 | 8 | module.exports = baseConfig; 9 | -------------------------------------------------------------------------------- /server/app/Data/readme.md: -------------------------------------------------------------------------------- 1 | When a new migration is created add it to `migrations.php` in an order you need it to be executed. 2 | 3 | When a new data seed is created add it to `seeds.php` in an order you need it to be executed. 4 | -------------------------------------------------------------------------------- /client/webpack/development.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | let baseConfig = require('./base.config.js'); 4 | 5 | baseConfig.mode = 'development'; 6 | baseConfig.devtool = "inline-source-map"; 7 | 8 | module.exports = baseConfig; 9 | -------------------------------------------------------------------------------- /server/app/Data/seeds.php: -------------------------------------------------------------------------------- 1 | `. Create one and have a look at its code. It is commented very well. 2 | 3 | When a new command is developed make it available in composer by running `composer l:commands connect` command. 4 | -------------------------------------------------------------------------------- /server/readme.md: -------------------------------------------------------------------------------- 1 | `app` folder contains application source code. 2 | 3 | `resources` folder contains resources such as messages, HTML templates, email templates and etc. 4 | 5 | `setting` folder contains configuration files for application and its components such as authorization, logging, database, and etc. 6 | 7 | `storage` folder contains cache, logs, database files, and etc. 8 | 9 | `tests` folder contains application tests. 10 | -------------------------------------------------------------------------------- /server/app/Validation/ErrorCodes.php: -------------------------------------------------------------------------------- 1 | 7 |

{{ title | default("403") }}

8 |

{{ message | default("Forbidden") }}

9 |

{{ details | default("The request is forbidden for the current user.") }}

10 | 11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /server/resources/views/pages/en/404.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'pages/en/base/with-header-and-footer.master.html.twig' %} 2 | 3 | {% block title %}Not Found{% endblock %} 4 | 5 | {% block content %} 6 |
7 |

{{ title | default("404") }}

8 |

{{ message | default("Not found") }}

9 |

{{ details | default("The server cannot find the requested resource.") }}

10 |
11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /server/resources/views/pages/en/401.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'pages/en/base/with-header-and-footer.master.html.twig' %} 2 | 3 | {% block title %}Unauthorized{% endblock %} 4 | 5 | {% block content %} 6 |
7 |

{{ title | default("401") }}

8 |

{{ message | default("Unauthorized") }}

9 |

{{ details | default("The request lacks valid authentication credentials for the target resource(s).") }}

10 |
11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /server/settings/L10n.php: -------------------------------------------------------------------------------- 1 | implode(DIRECTORY_SEPARATOR, [__DIR__, '..', 'resources', 'messages']), 18 | 19 | ] + parent::getSettings(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /server/settings/Monolog.php: -------------------------------------------------------------------------------- 1 | implode(DIRECTORY_SEPARATOR, [__DIR__, '..', 'storage', 'logs']), 18 | 19 | ] + parent::getSettings(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /server/resources/views/pages/en/sections/pagination.html.twig: -------------------------------------------------------------------------------- 1 | {% if prevLink or nextLink %} 2 | 12 | {% endif %} 13 | -------------------------------------------------------------------------------- /server/settings/Commands.php: -------------------------------------------------------------------------------- 1 | $userId, 20 | ] + parent::getSettings(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /server/settings/Hasher.php: -------------------------------------------------------------------------------- 1 | load(); 17 | 18 | return [ 19 | 20 | /** @see http://php.net/manual/en/function.password-hash.php */ 21 | static::KEY_COST => 10, 22 | 23 | ] + parent::getSettings(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /client/src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": true, 3 | "compilerOptions": { 4 | "moduleResolution": "node", 5 | "lib": [ 6 | "dom", 7 | "es2015", 8 | "es2016", 9 | "es2017" 10 | ], 11 | "alwaysStrict": true, 12 | "noImplicitAny": true, 13 | "noUnusedLocals": true, 14 | "noImplicitReturns": true, 15 | "removeComments": false, 16 | "inlineSourceMap": true, 17 | "inlineSources": true, 18 | 19 | "target": "es2017", 20 | "outDir": "./out" 21 | }, 22 | "include": [ 23 | "./**/*.*" 24 | ], 25 | "exclude": [ 26 | "./index.ts", 27 | "./**/*.spec.ts" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /public/.htaccess: -------------------------------------------------------------------------------- 1 | 2 | 3 | Options -MultiViews -Indexes 4 | 5 | 6 | RewriteEngine On 7 | 8 | # Handle authorization header. 9 | RewriteCond %{HTTP:Authorization} . 10 | RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] 11 | 12 | # Redirect trailing slashes if not a folder. 13 | RewriteCond %{REQUEST_FILENAME} !-d 14 | RewriteCond %{REQUEST_URI} (.+)/$ 15 | RewriteRule ^ %1 [L,R=301] 16 | 17 | # Handle front controller. 18 | RewriteCond %{REQUEST_FILENAME} !-d 19 | RewriteCond %{REQUEST_FILENAME} !-f 20 | RewriteRule ^ index.php [L] 21 | 22 | 23 | -------------------------------------------------------------------------------- /server/app/Application.php: -------------------------------------------------------------------------------- 1 | '', 22 | static::KEY_COOKIE_LIFETIME => '0', 23 | 24 | ] + parent::getSettings(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /client/scss/pages/_sign-in.scss: -------------------------------------------------------------------------------- 1 | .form-sign-in { 2 | width: 100%; 3 | max-width: 330px; 4 | padding: 15px; 5 | margin: auto; 6 | } 7 | .form-sign-in .checkbox { 8 | font-weight: 400; 9 | } 10 | .form-sign-in .form-control { 11 | position: relative; 12 | box-sizing: border-box; 13 | height: auto; 14 | padding: 10px; 15 | font-size: 16px; 16 | } 17 | .form-sign-in .form-control:focus { 18 | z-index: 2; 19 | } 20 | .form-sign-in input[type="email"] { 21 | margin-bottom: -1px; 22 | border-bottom-right-radius: 0; 23 | border-bottom-left-radius: 0; 24 | } 25 | .form-sign-in input[type="password"] { 26 | margin-bottom: 10px; 27 | border-top-left-radius: 0; 28 | border-top-right-radius: 0; 29 | } 30 | -------------------------------------------------------------------------------- /server/app/Validation/Role/RoleUpdateForm.php: -------------------------------------------------------------------------------- 1 | r::required(r::description()), 22 | 23 | CsrfSettings::DEFAULT_HTTP_REQUEST_CSRF_TOKEN_KEY => r::required(r::isString()), 24 | ]; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /server/settings/PdoDatabase.php: -------------------------------------------------------------------------------- 1 | load(); 17 | // 18 | // return [ 19 | // 20 | // static::KEY_USER_NAME => getenv('PDO_USER_NAME'), 21 | // static::KEY_PASSWORD => getenv('PDO_USER_PASSWORD'), 22 | // static::KEY_CONNECTION_STRING => getenv('PDO_CONNECTION_STRING'), 23 | // 24 | // ] + parent::getSettings(); 25 | // } 26 | //} 27 | -------------------------------------------------------------------------------- /server/resources/views/pages/en/base/master.html.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | {% block title %}{% endblock %} 9 | {% if master_canonical_sub_url %}{% endif %} 10 | 11 | {% block head_bottom %}{% endblock %} 12 | 13 | 14 | 15 | 16 |
17 | {% block header %}{% endblock %} 18 | {% block content %}{% endblock %} 19 | {% block footer %}{% endblock %} 20 |
21 | 22 | {% block body_bottom %}{% endblock %} 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /server/app/Container/CliCommandsConfigurator.php: -------------------------------------------------------------------------------- 1 | r::required(r::isUniqueRoleId()), 22 | Schema::ATTR_DESCRIPTION => r::required(r::description()), 23 | 24 | CsrfSettings::DEFAULT_HTTP_REQUEST_CSRF_TOKEN_KEY => r::required(r::isString()), 25 | ]; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /server/app/Container/RequestStorageConfigurator.php: -------------------------------------------------------------------------------- 1 | $appRootFolder, 22 | static::KEY_TEMPLATES_FOLDER => $templatesFolder, 23 | static::KEY_CACHE_FOLDER => $cacheFolder, 24 | 25 | ] + parent::getSettings(); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /server/app/Routes/readme.md: -------------------------------------------------------------------------------- 1 | `Routes` maps HTTP requests (e.g. `GET /index`) with Controller methods where responses for those requests are created. 2 | 3 | Controller methods should signature 4 | ```php 5 | class AppController 6 | { 7 | public static function methodName( 8 | array $routeParams, 9 | PsrContainerInterface $container, 10 | ServerRequestInterface $request 11 | ): ResponseInterface { 12 | return ...; 13 | } 14 | } 15 | ``` 16 | 17 | where `$routeParams` would contain route parameter values if parameters were used. For example, for request `GET /users/123` if route `/users/{idx}` was it would be 18 | ```php 19 | [ 20 | 'idx' => '123', 21 | ] 22 | ``` 23 | 24 | `$container` is the application container which could be used for getting database connections, APIs, logging and etc. 25 | 26 | `$request` actual [HTTP Request (PSR 7)](http://www.php-fig.org/psr/psr-7/). 27 | -------------------------------------------------------------------------------- /server/app/Routes/CliRoutes.php: -------------------------------------------------------------------------------- 1 | addGlobalContainerConfigurators([ 23 | CliCommandsConfigurator::CONFIGURATOR, 24 | ]) 25 | ->addCommandMiddleware(DataCommand::NAME, [CliAuthenticationMiddleware::CALLABLE_HANDLER]); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /server/app/Data/Migrations/RolesMigration.php: -------------------------------------------------------------------------------- 1 | createTable(Model::class, [ 23 | $this->primaryString(Model::FIELD_ID), 24 | $this->string(Model::FIELD_DESCRIPTION), 25 | $this->timestamps(), 26 | ]); 27 | } 28 | 29 | /** 30 | * @inheritdoc 31 | */ 32 | public function rollback(): void 33 | { 34 | $this->dropTableIfExists(Model::class); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /server/app/Validation/Auth/SignIn.php: -------------------------------------------------------------------------------- 1 | r::required(r::email()), 21 | AuthController::FORM_PASSWORD => r::required(r::password()), 22 | AuthController::FORM_REMEMBER => r::stringToBool(), 23 | 24 | CsrfSettings::DEFAULT_HTTP_REQUEST_CSRF_TOKEN_KEY => r::required(r::isString()), 25 | ]; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /server/settings/readme.md: -------------------------------------------------------------------------------- 1 | `Settings` is the main way to configure the application. 2 | 3 | In order to create custom settings a class implementing `SettingsInterface` should be created 4 | 5 | ```php 6 | 'some value', 16 | ]; 17 | } 18 | } 19 | ``` 20 | 21 | You can get it everywhere in the application from `Container` 22 | 23 | ```php 24 | $settings = $container->get(SettingsProviderInterface::class)->get(CustomSettings::class); 25 | $value = $settings['SOME_KEY']; 26 | ``` 27 | 28 | You can also override settings for built-in or 3rd party components by inheriting their settings class and overriding values from `get` method. The application is smart enough to understand it should use the modified settings. 29 | -------------------------------------------------------------------------------- /public/web.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | ./server/tests/ 15 | 16 | 17 | 18 | 19 | server/app/ 20 | 21 | 22 | 29 | 30 | -------------------------------------------------------------------------------- /server/resources/messages/en/App.Views.Pages.php: -------------------------------------------------------------------------------- 1 | implode(DIRECTORY_SEPARATOR, ['pages', 'en', '401.html.twig']), 7 | Views::NOT_FORBIDDEN_PAGE => implode(DIRECTORY_SEPARATOR, ['pages', 'en', '403.html.twig']), 8 | Views::NOT_FOUND_PAGE => implode(DIRECTORY_SEPARATOR, ['pages', 'en', '404.html.twig']), 9 | 10 | Views::HOME_PAGE => implode(DIRECTORY_SEPARATOR, ['pages', 'en', 'home.html.twig']), 11 | Views::SIGN_IN_PAGE => implode(DIRECTORY_SEPARATOR, ['pages', 'en', 'sign-in.html.twig']), 12 | 13 | Views::USERS_INDEX_PAGE => implode(DIRECTORY_SEPARATOR, ['pages', 'en', 'users.html.twig']), 14 | Views::USER_MODIFY_PAGE => implode(DIRECTORY_SEPARATOR, ['pages', 'en', 'user-modify.html.twig']), 15 | 16 | Views::ROLES_INDEX_PAGE => implode(DIRECTORY_SEPARATOR, ['pages', 'en', 'roles.html.twig']), 17 | Views::ROLE_MODIFY_PAGE => implode(DIRECTORY_SEPARATOR, ['pages', 'en', 'role-modify.html.twig']), 18 | ]; 19 | -------------------------------------------------------------------------------- /server/app/Json/Controllers/UsersController.php: -------------------------------------------------------------------------------- 1 | load(); 20 | 21 | return [ 22 | 23 | static::KEY_LOG_IS_ENABLED => filter_var(getenv('APP_ENABLE_LOGS'), FILTER_VALIDATE_BOOLEAN), 24 | static::KEY_POLICIES_FOLDER => implode(DIRECTORY_SEPARATOR, [__DIR__, '..', 'app', 'Authorization']), 25 | 26 | static::KEY_AUTH_COOKIE_ONLY_OVER_HTTPS => filter_var(getenv('APP_AUTH_COOKIE_ONLY_OVER_HTTPS'), FILTER_VALIDATE_BOOLEAN), 27 | 28 | ] + parent::getSettings(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /server/app/Validation/User/UserUpdateForm.php: -------------------------------------------------------------------------------- 1 | r::firstName(), 22 | Schema::ATTR_LAST_NAME => r::lastName(), 23 | Schema::REL_ROLE => r::roleId(), 24 | 25 | 26 | Schema::CAPTURE_NAME_PASSWORD => r::orX(r::equals(''), r::password()), 27 | Schema::CAPTURE_NAME_PASSWORD_CONFIRMATION => r::orX(r::equals(''), r::password()), 28 | 29 | CsrfSettings::DEFAULT_HTTP_REQUEST_CSRF_TOKEN_KEY => r::required(r::isString()), 30 | ]; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /server/app/Web/Controllers/HomeController.php: -------------------------------------------------------------------------------- 1 | 'Limoncello', 31 | ]); 32 | 33 | return new HtmlResponse($body); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /server/app/Json/Schemas/RoleSchema.php: -------------------------------------------------------------------------------- 1 | [ 29 | self::RESOURCE_ID => Model::FIELD_ID, 30 | self::ATTR_DESCRIPTION => Model::FIELD_DESCRIPTION, 31 | self::ATTR_CREATED_AT => Model::FIELD_CREATED_AT, 32 | self::ATTR_UPDATED_AT => Model::FIELD_UPDATED_AT, 33 | ], 34 | self::SCHEMA_RELATIONSHIPS => [ 35 | self::REL_USERS => Model::REL_USERS, 36 | ], 37 | ]; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /server/app/Web/readme.md: -------------------------------------------------------------------------------- 1 | The overall process is 2 | 3 | - HTTP request containing form data is parsed and validated. 4 | - The validated data are sent to API. 5 | 6 | `Controllers` folder contains web HTTP controllers. 7 | `Middleware` folder contains middleware used with web HTTP controllers. 8 | 9 | Controller methods should have signature 10 | 11 | ```php 12 | class AppController 13 | { 14 | public static function methodName( 15 | array $routeParams, 16 | ContainerInterface $container, 17 | ServerRequestInterface $request 18 | ): ResponseInterface { 19 | return ...; 20 | } 21 | } 22 | ``` 23 | 24 | where `$routeParams` would contain route parameter values if parameters were used. For example, for request `GET /users/123` if route `/users/{idx}` was it would be 25 | ```php 26 | [ 27 | 'idx' => '123', 28 | ] 29 | ``` 30 | 31 | `$container` is the application [container (PSR 11)](http://www.php-fig.org/psr/psr-11/) which could be used for getting database connections, APIs, logging and etc. 32 | 33 | `$request` actual [HTTP Request (PSR 7)](http://www.php-fig.org/psr/psr-7/). 34 | -------------------------------------------------------------------------------- /server/app/Json/Schemas/BaseSchema.php: -------------------------------------------------------------------------------- 1 | classImplements(static::MODEL, ModelInterface::class) === true); 32 | 33 | /** @var ModelInterface $modelClass */ 34 | $modelClass = static::MODEL; 35 | 36 | $pkName = $modelClass::getPrimaryKeyName(); 37 | 38 | return $resource->{$pkName}; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2017 info@neomerx.com 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /server/app/Web/Views.php: -------------------------------------------------------------------------------- 1 | setPreventCommits(); 18 | 19 | $this->setModerator(); 20 | $api = $this->createApi(RolesApi::class); 21 | 22 | $roleId = RolesSeed::ROLE_USER; 23 | $this->assertNotNull($api->read($roleId)); 24 | } 25 | 26 | /** 27 | * Same test but with auth by a OAuth token. 28 | */ 29 | public function testLowLevelApiReadWithAuthByToken(): void 30 | { 31 | $this->setPreventCommits(); 32 | 33 | $oauthToken = $this->getModeratorOAuthToken(); 34 | $accessToken = $oauthToken->access_token; 35 | $this->setUserByToken($accessToken); 36 | 37 | $api = $this->createApi(RolesApi::class); 38 | 39 | $roleId = RolesSeed::ROLE_USER; 40 | $this->assertNotNull($api->read($roleId)); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /server/app/Validation/User/UserCreateForm.php: -------------------------------------------------------------------------------- 1 | r::required(r::firstName()), 22 | Schema::ATTR_LAST_NAME => r::required(r::lastName()), 23 | Schema::ATTR_EMAIL => r::required(r::uniqueEmail()), 24 | Schema::REL_ROLE => r::required(r::roleId()), 25 | 26 | Schema::CAPTURE_NAME_PASSWORD => r::required(r::password()), 27 | Schema::CAPTURE_NAME_PASSWORD_CONFIRMATION => r::required(r::password()), 28 | 29 | CsrfSettings::DEFAULT_HTTP_REQUEST_CSRF_TOKEN_KEY => r::required(r::isString()), 30 | ]; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /server/app/Commands/Middleware/CliAuthenticationMiddleware.php: -------------------------------------------------------------------------------- 1 | get(FactoryInterface::class); 26 | /** @var UsersApi $userApi */ 27 | $userApi = $factory->createApi(UsersApi::class); 28 | 29 | $scopes = $userApi->noAuthReadScopes($userId); 30 | } else { 31 | $scopes = []; 32 | } 33 | 34 | return $scopes; 35 | }; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | APP_NAME = 'Limoncello' 2 | APP_ENABLE_LOGS = 1 3 | APP_IS_DEBUG = 1 4 | 5 | APP_ORIGIN_SCHEME = 'http' 6 | APP_ORIGIN_HOST = 'localhost' 7 | APP_ORIGIN_PORT = 8080 8 | 9 | # It recommended to turn on this option and develop with HTTPS enabled, 10 | # however you might need set up HTTPS first. 11 | # 12 | # A good read on how to set up HTTPS in local development environment 13 | # https://deliciousbrains.com/ssl-certificate-authority-for-local-https-development/ 14 | APP_AUTH_COOKIE_ONLY_OVER_HTTPS = 0 15 | 16 | # Sample configuration for Doctrine (SQLite) 17 | DB_DATABASE_NAME = '' 18 | DB_USER_NAME = 'root' 19 | DB_USER_PASSWORD = '' 20 | DB_HOST = '' 21 | DB_PORT = '' 22 | DB_CHARSET = 'UTF8' 23 | DB_DRIVER = 'pdo_sqlite' 24 | DB_FILE = 'local.sqlite' 25 | 26 | # Sample configuration for Doctrine (MySQL) 27 | #DB_DATABASE_NAME = 'limoncello_app' 28 | #DB_USER_NAME = 'root' 29 | #DB_USER_PASSWORD = 'root' 30 | #DB_HOST = '127.0.0.1' 31 | #DB_PORT = 3306 32 | #DB_CHARSET = 'UTF8' 33 | #DB_DRIVER = 'pdo_mysql' 34 | 35 | # Sample configuration for PDO (MySQL) 36 | #PDO_USER_NAME = 'root' 37 | #PDO_USER_PASSWORD = 'root' 38 | #PDO_CONNECTION_STRING = 'mysql:host=127.0.0.1;dbname=limoncello_app' 39 | -------------------------------------------------------------------------------- /server/app/Validation/Role/RoleRules.php: -------------------------------------------------------------------------------- 1 | has(RequestStorageInterface::class) === true) { 29 | /** @var RequestStorageInterface $requestStorage */ 30 | $requestStorage = $container->get(RequestStorageInterface::class); 31 | $requestStorage->set($request); 32 | } 33 | 34 | /** @var ResponseInterface $response */ 35 | $response = $next($request); 36 | 37 | return $response; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /server/app/Data/Migrations/UsersMigration.php: -------------------------------------------------------------------------------- 1 | createTable(Model::class, [ 24 | $this->primaryInt(Model::FIELD_ID), 25 | $this->relationship(Model::REL_ROLE, RelationshipRestrictions::CASCADE), 26 | $this->string(Model::FIELD_FIRST_NAME), 27 | $this->string(Model::FIELD_LAST_NAME), 28 | $this->string(Model::FIELD_EMAIL), 29 | $this->string(Model::FIELD_PASSWORD_HASH), 30 | $this->timestamps(), 31 | 32 | $this->unique([Model::FIELD_EMAIL]), 33 | ]); 34 | } 35 | 36 | /** 37 | * @inheritdoc 38 | */ 39 | public function rollback(): void 40 | { 41 | $this->dropTableIfExists(Model::class); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /server/settings/Doctrine.php: -------------------------------------------------------------------------------- 1 | load(); 17 | 18 | $dbFile = getenv('DB_FILE'); 19 | $dbPath = empty($dbFile) === true ? null : implode(DIRECTORY_SEPARATOR, [__DIR__, '..', 'storage', $dbFile]); 20 | 21 | return [ 22 | 23 | static::KEY_DATABASE_NAME => getenv('DB_DATABASE_NAME'), 24 | static::KEY_USER_NAME => getenv('DB_USER_NAME'), 25 | static::KEY_PASSWORD => getenv('DB_USER_PASSWORD'), 26 | static::KEY_HOST => getenv('DB_HOST'), 27 | static::KEY_PORT => getenv('DB_PORT'), 28 | static::KEY_CHARSET => getenv('DB_CHARSET'), 29 | static::KEY_DRIVER => getenv('DB_DRIVER'), 30 | static::KEY_PATH => $dbPath, 31 | static::KEY_EXEC => [ 32 | 'PRAGMA foreign_keys = ON;' 33 | ], 34 | 35 | ] + parent::getSettings(); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /server/app/Validation/Role/RoleUpdateJson.php: -------------------------------------------------------------------------------- 1 | r::required(r::description()), 38 | ]; 39 | } 40 | 41 | /** 42 | * @inheritdoc 43 | */ 44 | public static function getToOneRelationshipRules(): array 45 | { 46 | return []; 47 | } 48 | 49 | /** 50 | * @inheritdoc 51 | */ 52 | public static function getToManyRelationshipRules(): array 53 | { 54 | return []; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "limoncello-client-demo", 3 | "version": "0.10.0", 4 | "description": "Limoncello Client Demo Application", 5 | "main": "./public/assets/index.js", 6 | "scripts": { 7 | "dev-serve": "webpack-dev-server --config ./client/webpack/development.config.js --open", 8 | "php-serve": "php -d zend.assertions=1 -d assert.exception=1 -S 0.0.0.0:8090 -t public", 9 | "serve": "npm run php-serve & npm run dev-serve", 10 | "watch": "webpack --config ./client/webpack/development.config.js --watch", 11 | "build": "webpack --config ./client/webpack/production.config.js" 12 | }, 13 | "author": "info@neomerx.com", 14 | "license": "MIT", 15 | "devDependencies": { 16 | "@limoncello-framework/json-api-client": "^0.1.4", 17 | "@limoncello-framework/oauth-client": "^0.1.5", 18 | "autoprefixer": "^9.1.3", 19 | "bootstrap": "^4.1.0", 20 | "clean-webpack-plugin": "^0.1.19", 21 | "css-loader": "^1.0.0", 22 | "file-loader": "^2.0.0", 23 | "jquery": "^3.3.1", 24 | "node-sass": "^4.8.3", 25 | "popper.js": "^1.14.3", 26 | "postcss-loader": "^3.0.0", 27 | "precss": "^3.1.2", 28 | "sass-loader": "^7.0.1", 29 | "style-loader": "^0.23.0", 30 | "ts-loader": "^4.2.0", 31 | "typescript": "^3.0.0", 32 | "webpack": "^4.17.0", 33 | "webpack-cli": "^3.1.0", 34 | "webpack-dev-server": "^3.1.3" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /server/app/Container/readme.md: -------------------------------------------------------------------------------- 1 | When the application starts it creates [Container (PSR 11)](http://www.php-fig.org/psr/) and configures it before it is accessible from middlewares and HTTP Controller. 2 | 3 | Just create a configurator class as shown below and place it to this folder. 4 | 5 | ```php 6 | get(SettingsProviderInterface::class)->get(SomeSettings::class); 20 | $data = $settings[SomeSettings::KEY_SOME_VALUE]; 21 | 22 | // use settings $data to create and configure a resource 23 | $resource = ...; 24 | 25 | return $resource; 26 | }; 27 | 28 | // you can add to $container as many items as you need 29 | } 30 | } 31 | ``` 32 | -------------------------------------------------------------------------------- /server/app/Validation/Role/RoleCreateJson.php: -------------------------------------------------------------------------------- 1 | r::required(r::description()), 38 | ]; 39 | } 40 | 41 | /** 42 | * @inheritdoc 43 | */ 44 | public static function getToOneRelationshipRules(): array 45 | { 46 | return []; 47 | } 48 | 49 | /** 50 | * @inheritdoc 51 | */ 52 | public static function getToManyRelationshipRules(): array 53 | { 54 | return []; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /server/tests/Cli/CliiTest.php: -------------------------------------------------------------------------------- 1 | setPreventCommits(); 27 | 28 | $arguments = [DataCommand::ARG_ACTION => DataCommand::ACTION_SEED]; 29 | $options = []; 30 | $ioMock = new CommandsDebugIo($arguments, $options); 31 | 32 | $container = $this->createApplication()->createContainer(); 33 | 34 | $this->executeCommand( 35 | DataCommand::NAME, 36 | [DataCommand::class, DataCommand::COMMAND_METHOD_NAME], 37 | $ioMock, 38 | $container 39 | ); 40 | 41 | $this->assertEmpty($ioMock->getErrorRecords()); 42 | $this->assertEmpty($ioMock->getWarningRecords()); 43 | $this->assertNotEmpty($ioMock->getInfoRecords()); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /server/app/readme.md: -------------------------------------------------------------------------------- 1 | `Api` folder contains native PHP implementation for application API such as CRUD (Create, Read, Update and Delete) operations. 2 | 3 | `Authentication` folders contains application authentication implementation. 4 | 5 | `Authorization` folder contains rules for authorizing authenticated user for making API calls such as view, create, update, delete resources and etc. 6 | 7 | `Commands` folder contains your application console commands which could be executed from [composer](https://getcomposer.org/). A well commented command boilerplate could be generated with 8 | ```bash 9 | composer l:commands create 10 | ``` 11 | 12 | `Container` folder contains configurators for application [Container (PSR 11)](http://www.php-fig.org/psr/). If you need external libraries accessible from the application container (e.g. mailer, payments, etc) you can add it here. 13 | 14 | `Data` folder contains application database models, database migrations and seedings. 15 | 16 | `Json` folder contains your [JSON API](http://jsonapi.org/) implementation code such as controllers, mapping between JSON data and Models with `Schemas`, middleware, and etc. 17 | 18 | `Routes` folder contains routing for web and API. 19 | 20 | `Validation` folder contains validation rules and validators for HTTP forms, HTTP query parameters and JSON API inputs. 21 | 22 | `Web` folder contains Web implementation code such as controllers, middleware, and etc. 23 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # Usefull links 2 | # ============= 3 | # https://hub.docker.com/_/php/ 4 | # https://docs.docker.com/compose/overview/ 5 | # https://docs.docker.com/compose/compose-file/ 6 | # 7 | # Usefull commands 8 | # ================ 9 | # 10 | # Start containers 11 | # $ docker-compose up -d 12 | # 13 | # View running containers 14 | # $ docker-compose ps 15 | # 16 | # Connect into command line of running container 17 | # $ docker-compose run bash 18 | # where could be db_limoncello_app, etc. 19 | # Tip: `Ctrl+p` + `Ctrl+q` + `Enter` (to exit container bash) 20 | # 21 | # Stop containers 22 | # $ docker-compose down 23 | # 24 | # Remove images 25 | # $ docker rmi db_limoncello_app 26 | # 27 | # Run non-default docker-compose file 28 | # $ docker-compose -f up -d 29 | # $ docker-compose -f down 30 | # $ docker-compose -f stop 31 | 32 | version: '3' 33 | services: 34 | db: 35 | image: percona 36 | container_name: db_limoncello_app 37 | ports: 38 | - "3306:3306" 39 | environment: 40 | MYSQL_ROOT_PASSWORD: root 41 | MYSQL_USER: passport 42 | MYSQL_PASSWORD: secret 43 | MYSQL_DATABASE: limoncello_app 44 | 45 | # db: 46 | # image: postgres 47 | # container_name: db_limoncello_app 48 | # ports: 49 | # - "5432:5432" 50 | # environment: 51 | # POSTGRES_USER: root 52 | # POSTGRES_PASSWORD: secret 53 | # POSTGRES_DB: limoncello_app 54 | -------------------------------------------------------------------------------- /server/app/Json/readme.md: -------------------------------------------------------------------------------- 1 | The overall process is 2 | 3 | - HTTP request containing JSON API data is parsed and validated. 4 | - JSON API data are mapped to application models with `Schemas`. 5 | - The converted input data are sent to API. 6 | 7 | `Controllers` folder contains JSON API HTTP controllers. 8 | 9 | `Exceptions` folder contains a code that transforms various exceptions in API (application specific, authorization, 3rd party, etc) to JSON API errors. 10 | 11 | `Middleware` folder contains middleware in with JSON API HTTP controllers. 12 | 13 | `Schemas` folder contains descriptions for mapping between JSON API fields and application Model ones. 14 | 15 | Controller methods should signature 16 | 17 | ```php 18 | class AppController 19 | { 20 | public static function methodName( 21 | array $routeParams, 22 | ContainerInterface $container, 23 | ServerRequestInterface $request 24 | ): ResponseInterface { 25 | return ...; 26 | } 27 | } 28 | ``` 29 | 30 | where `$routeParams` would contain route parameter values if parameters were used. For example, for request `GET /users/123` if route `/users/{idx}` was it would be 31 | ```php 32 | [ 33 | 'idx' => '123', 34 | ] 35 | ``` 36 | 37 | `$container` is the application [container (PSR 11)](http://www.php-fig.org/psr/psr-11/) which could be used for getting database connections, APIs, logging and etc. 38 | 39 | `$request` actual [HTTP Request (PSR 7)](http://www.php-fig.org/psr/psr-7/). 40 | -------------------------------------------------------------------------------- /server/app/Data/Migrations/RolesScopesMigration.php: -------------------------------------------------------------------------------- 1 | createTable(Model::class, [ 28 | $this->primaryInt(Model::FIELD_ID), 29 | $this->foreignRelationship(Model::FIELD_ID_ROLE, Role::class, RelationshipRestrictions::CASCADE), 30 | $this->foreignColumn( 31 | Model::FIELD_ID_SCOPE, 32 | DatabaseSchema::TABLE_SCOPES, 33 | Scope::FIELD_ID, 34 | Type::STRING, 35 | true 36 | ), 37 | $this->timestamps(), 38 | ]); 39 | } 40 | 41 | /** 42 | * @inheritdoc 43 | */ 44 | public function rollback(): void 45 | { 46 | $this->dropTableIfExists(Model::class); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /server/app/Data/Seeds/RolesSeed.php: -------------------------------------------------------------------------------- 1 | now(); 34 | $this->seedRowData(Model::TABLE_NAME, [ 35 | Model::FIELD_ID => self::ROLE_ADMIN, 36 | Model::FIELD_DESCRIPTION => 'Administrator', 37 | Model::FIELD_CREATED_AT => $now, 38 | ]); 39 | $this->seedRowData(Model::TABLE_NAME, [ 40 | Model::FIELD_ID => self::ROLE_MODERATOR, 41 | Model::FIELD_DESCRIPTION => 'Moderator', 42 | Model::FIELD_CREATED_AT => $now, 43 | ]); 44 | $this->seedRowData(Model::TABLE_NAME, [ 45 | Model::FIELD_ID => self::ROLE_USER, 46 | Model::FIELD_DESCRIPTION => 'User', 47 | Model::FIELD_CREATED_AT => $now, 48 | ]); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /server/resources/views/pages/en/roles.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'pages/en/base/with-header-and-footer.master.html.twig' %} 2 | 3 | {% block title %}Limoncello Roles{% endblock %} 4 | 5 | {% block content %} 6 |
7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | {% for role in models %} 18 | 19 | 20 | 21 | 22 | 26 | 27 | {% endfor %} 28 | 29 |
NameDescriptionCreated at
{{ role.id_role }}{{ role.description }}{{ role.created_at | date("Y-m-d") }} 23 | {% if can_admin_roles %}Modify{% endif %} 24 | {% if can_view_users and can_view_roles %}View Users{% endif %} 25 |
30 | {% if can_admin_roles %} 31 | Add New 32 | {% endif %} 33 | {{ include('pages/en/sections/pagination.html.twig') }} 34 |
35 | {% endblock %} 36 | -------------------------------------------------------------------------------- /server/app/Validation/User/UserUpdateJson.php: -------------------------------------------------------------------------------- 1 | r::firstName(), 38 | Schema::ATTR_LAST_NAME => r::lastName(), 39 | Schema::ATTR_EMAIL => r::uniqueEmail(), 40 | Schema::V_ATTR_PASSWORD => r::password(), 41 | ]; 42 | } 43 | 44 | /** 45 | * @inheritdoc 46 | */ 47 | public static function getToOneRelationshipRules(): array 48 | { 49 | return [ 50 | Schema::REL_ROLE => r::roleRelationship(), 51 | ]; 52 | } 53 | 54 | /** 55 | * @inheritdoc 56 | */ 57 | public static function getToManyRelationshipRules(): array 58 | { 59 | return []; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /server/app/Validation/User/UserCreateJson.php: -------------------------------------------------------------------------------- 1 | r::required(r::firstName()), 38 | Schema::ATTR_LAST_NAME => r::required(r::lastName()), 39 | Schema::ATTR_EMAIL => r::required(r::uniqueEmail()), 40 | Schema::V_ATTR_PASSWORD => r::required(r::password()), 41 | ]; 42 | } 43 | 44 | /** 45 | * @inheritdoc 46 | */ 47 | public static function getToOneRelationshipRules(): array 48 | { 49 | return [ 50 | Schema::REL_ROLE => r::required(r::roleRelationship()), 51 | ]; 52 | } 53 | 54 | /** 55 | * @inheritdoc 56 | */ 57 | public static function getToManyRelationshipRules(): array 58 | { 59 | return []; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /server/app/Authorization/UserRules.php: -------------------------------------------------------------------------------- 1 | seedModelsData(self::NUMBER_OF_RECORDS, Model::class, function (ContainerInterface $container) { 30 | /** @var Generator $faker */ 31 | $faker = $container->get(Generator::class); 32 | /** @var HasherInterface $hasher */ 33 | $hasher = $container->get(HasherInterface::class); 34 | 35 | $role = $faker->randomElement([ 36 | RolesSeed::ROLE_ADMIN, 37 | RolesSeed::ROLE_MODERATOR, 38 | RolesSeed::ROLE_USER, 39 | ]); 40 | 41 | return [ 42 | Model::FIELD_FIRST_NAME => $faker->firstName, 43 | Model::FIELD_LAST_NAME => $faker->lastName, 44 | Model::FIELD_EMAIL => $faker->email, 45 | Model::FIELD_ID_ROLE => $role, 46 | Model::FIELD_PASSWORD_HASH => $hasher->hash(self::DEFAULT_PASSWORD), 47 | Model::FIELD_CREATED_AT => $this->now(), 48 | ]; 49 | }); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /server/app/Authorization/readme.md: -------------------------------------------------------------------------------- 1 | Authorization rules are described with classes which should implement interface `AuthorizationRulesInterface`. This interface is just a 'marker' so no specific methods are required. 2 | 3 | In order to add an `authorization action` to your application you will add a method with the name of the action as shown below. 4 | 5 | ```php 6 | hasScope($scope); 33 | } 34 | 35 | return $result; 36 | } 37 | 38 | /** 39 | * @param ContextInterface $context 40 | * 41 | * @return int|string|null 42 | * 43 | * @throws ContainerExceptionInterface 44 | * @throws NotFoundExceptionInterface 45 | */ 46 | protected static function getCurrentUserIdentity(ContextInterface $context) 47 | { 48 | $userId = null; 49 | 50 | /** @var PassportAccountInterface $account */ 51 | if (self::ctxHasCurrentAccount($context) === true && 52 | ($account = self::ctxGetCurrentAccount($context)) !== null && 53 | $account->hasUserIdentity() === true 54 | ) { 55 | $userId = $account->getUserIdentity(); 56 | } 57 | 58 | return $userId; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /server/tests/Api/UserApiTest.php: -------------------------------------------------------------------------------- 1 | setPreventCommits(); 21 | 22 | // create API 23 | 24 | /** @var UsersApi $api */ 25 | $api = $this->createUsersApi(); 26 | 27 | // Call and check any method from low level API. 28 | 29 | /** Default seed data. Manually checked. */ 30 | $this->assertEquals(5, $api->noAuthReadUserIdByEmail('denesik.stewart@gmail.com')); 31 | } 32 | 33 | /** 34 | * Test for password reset. 35 | * 36 | * @throws DBALException 37 | * @throws AuthorizationExceptionInterface 38 | */ 39 | public function testResetPassword() 40 | { 41 | $this->setPreventCommits(); 42 | 43 | // create APIs 44 | 45 | $noAuthApi = $this->createUsersApi(); 46 | 47 | $this->setAdmin(); 48 | $api = $this->createUsersApi(); 49 | 50 | // Call reset method. 51 | $userId = 1; 52 | $before = $api->read($userId); 53 | $this->assertTrue($noAuthApi->noAuthResetPassword($userId, 'new password')); 54 | $after = $api->read($userId); 55 | $this->assertNotEquals($before->{User::FIELD_PASSWORD_HASH}, $after->{User::FIELD_PASSWORD_HASH}); 56 | } 57 | 58 | /** 59 | * @return UsersApi 60 | */ 61 | private function createUsersApi(): UsersApi 62 | { 63 | $api = $this->createApi(UsersApi::class); 64 | assert($api instanceof UsersApi); 65 | 66 | return $api; 67 | } 68 | 69 | } 70 | -------------------------------------------------------------------------------- /client/webpack/FsWatchPlugin.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | module.exports = class { 5 | constructor(settings = {extraRootFolders: [], extraFolders: [], extraFiles: []}) { 6 | this._extraFiles = settings.extraFiles; 7 | this._extraFolders = settings.extraFolders; 8 | this._extraRootFolders = settings.extraRootFolders; 9 | } 10 | 11 | // noinspection JSUnusedGlobalSymbols 12 | apply(compiler) { 13 | compiler.hooks.afterCompile.tap('LimoncelloFsWatch', (compilation) => { 14 | const webpackFiles = Array.isArray(compilation.fileDependencies) ? compilation.fileDependencies : []; 15 | const webpackFolders = Array.isArray(compilation.contextDependencies) ? compilation.contextDependencies : []; 16 | 17 | let extraFolders = []; 18 | this._extraRootFolders.forEach(extraRootFolder => { 19 | extraFolders = extraFolders.concat(this.addAllNestedFolders(extraRootFolder)); 20 | }); 21 | 22 | compilation.fileDependencies = this.constructor.makeUniqueAndNonNull( 23 | webpackFiles.concat(this._extraFiles) 24 | ); 25 | compilation.contextDependencies = this.constructor.makeUniqueAndNonNull( 26 | webpackFolders.concat(extraFolders).concat(this._extraRootFolders).concat(this._extraFolders) 27 | ); 28 | }); 29 | } 30 | 31 | addAllNestedFolders(root, folders = []) { 32 | fs.readdirSync(root).forEach(fileOrFolder => { 33 | const fullPath = path.resolve(root, fileOrFolder); 34 | if (fs.lstatSync(fullPath).isDirectory() === true) { 35 | folders.push(fullPath); 36 | this.addAllNestedFolders(fullPath, folders); 37 | } 38 | }); 39 | 40 | return folders; 41 | } 42 | 43 | static makeUniqueAndNonNull(array) { 44 | return array.filter((value, index, self) => value != null && self.indexOf(value) === index); 45 | } 46 | }; 47 | -------------------------------------------------------------------------------- /server/settings/Data.php: -------------------------------------------------------------------------------- 1 | $modelsFolder, 27 | static::KEY_MIGRATIONS_FOLDER => $migrationsFolder, 28 | static::KEY_MIGRATIONS_LIST_FILE => $migrationsList, 29 | static::KEY_SEEDS_FOLDER => $seedsFolder, 30 | static::KEY_SEEDS_LIST_FILE => $seedsList, 31 | static::KEY_SEED_INIT => [static::class, 'resetFaker'], 32 | ] + parent::getSettings(); 33 | } 34 | 35 | /** 36 | * @param ContainerInterface $container 37 | * @param string $seedClass 38 | * 39 | * @return void 40 | * 41 | * @throws ContainerExceptionInterface 42 | * @throws NotFoundExceptionInterface 43 | */ 44 | public static function resetFaker(ContainerInterface $container, string $seedClass) 45 | { 46 | /** @var Generator $faker */ 47 | $faker = $container->get(Generator::class); 48 | $faker->seed(crc32($seedClass)); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /server/app/Json/Exceptions/ThrowableConverter.php: -------------------------------------------------------------------------------- 1 | getAction(); 28 | $errors = static::createErrorWith( 29 | 'Unauthorized', 30 | "You are not unauthorized for action `$action`.", 31 | $httpCode 32 | ); 33 | $converted = new JsonApiException($errors, $httpCode, $throwable); 34 | } elseif ($throwable instanceof AuthenticationException) { 35 | $httpCode = 401; 36 | $errors = static::createErrorWith('Authentication failed', 'Authentication failed', $httpCode); 37 | $converted = new JsonApiException($errors, $httpCode, $throwable); 38 | } 39 | 40 | return $converted; 41 | } 42 | 43 | /** 44 | * @param string $title 45 | * @param string $detail 46 | * @param int $httpCode 47 | * 48 | * @return ErrorCollection 49 | */ 50 | private static function createErrorWith(string $title, string $detail, int $httpCode): ErrorCollection 51 | { 52 | return (new ErrorCollection())->addDataError($title, $detail, null, null, null, null, $httpCode); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /server/app/Json/Schemas/UserSchema.php: -------------------------------------------------------------------------------- 1 | [ 53 | self::RESOURCE_ID => Model::FIELD_ID, 54 | self::ATTR_FIRST_NAME => Model::FIELD_FIRST_NAME, 55 | self::ATTR_LAST_NAME => Model::FIELD_LAST_NAME, 56 | self::ATTR_EMAIL => Model::FIELD_EMAIL, 57 | self::ATTR_CREATED_AT => Model::FIELD_CREATED_AT, 58 | self::ATTR_UPDATED_AT => Model::FIELD_UPDATED_AT, 59 | 60 | self::V_ATTR_PASSWORD => self::CAPTURE_NAME_PASSWORD, 61 | self::V_ATTR_PASSWORD_CONFIRMATION => self::CAPTURE_NAME_PASSWORD_CONFIRMATION, 62 | ], 63 | self::SCHEMA_RELATIONSHIPS => [ 64 | self::REL_ROLE => Model::REL_ROLE, 65 | ], 66 | ]; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /server/app/Data/Models/RoleScope.php: -------------------------------------------------------------------------------- 1 | Type::INTEGER, 48 | self::FIELD_ID_ROLE => Role::getAttributeTypes()[Role::FIELD_ID], 49 | self::FIELD_ID_SCOPE => Type::STRING, 50 | self::FIELD_CREATED_AT => DateTimeType::NAME, 51 | self::FIELD_UPDATED_AT => DateTimeType::NAME, 52 | self::FIELD_DELETED_AT => DateTimeType::NAME, 53 | ]; 54 | } 55 | 56 | /** 57 | * @inheritdoc 58 | */ 59 | public static function getAttributeLengths(): array 60 | { 61 | return [ 62 | self::FIELD_ID_ROLE => Role::getAttributeLengths()[Role::FIELD_ID], 63 | self::FIELD_ID_SCOPE => 255, 64 | ]; 65 | } 66 | 67 | /** 68 | * @inheritdoc 69 | */ 70 | public static function getRawAttributes(): array 71 | { 72 | return []; 73 | } 74 | 75 | /** 76 | * @inheritdoc 77 | */ 78 | public static function getRelationships(): array 79 | { 80 | return []; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /server/app/Json/Controllers/RolesController.php: -------------------------------------------------------------------------------- 1 | Type::STRING, 48 | self::FIELD_DESCRIPTION => Type::STRING, 49 | self::FIELD_CREATED_AT => DateTimeType::NAME, 50 | self::FIELD_UPDATED_AT => DateTimeType::NAME, 51 | self::FIELD_DELETED_AT => DateTimeType::NAME, 52 | ]; 53 | } 54 | 55 | /** 56 | * @inheritdoc 57 | */ 58 | public static function getAttributeLengths(): array 59 | { 60 | return [ 61 | self::FIELD_ID => 255, 62 | self::FIELD_DESCRIPTION => 255, 63 | ]; 64 | } 65 | 66 | /** 67 | * @inheritdoc 68 | */ 69 | public static function getRawAttributes(): array 70 | { 71 | return []; 72 | } 73 | 74 | /** 75 | * @inheritdoc 76 | */ 77 | public static function getRelationships(): array 78 | { 79 | return [ 80 | RelationshipTypes::HAS_MANY => [ 81 | self::REL_USERS => [User::class, User::FIELD_ID_ROLE, User::REL_ROLE], 82 | ], 83 | ]; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /server/settings/Passport.php: -------------------------------------------------------------------------------- 1 | load(); 34 | 35 | $isLogEnabled = filter_var(getenv('APP_ENABLE_LOGS'), FILTER_VALIDATE_BOOLEAN); 36 | 37 | return [ 38 | 39 | static::KEY_IS_LOG_ENABLED => $isLogEnabled, 40 | static::KEY_DEFAULT_CLIENT_NAME => getenv('APP_NAME'), 41 | static::KEY_DEFAULT_CLIENT_ID => static::DEFAULT_CLIENT_ID, 42 | static::KEY_TOKEN_CUSTOM_PROPERTIES_PROVIDER => OAuth::TOKEN_CUSTOM_PROPERTIES_PROVIDER, 43 | static::KEY_APPROVAL_URI_STRING => static::APPROVAL_URI, 44 | static::KEY_ERROR_URI_STRING => static::ERROR_URI, 45 | static::KEY_DEFAULT_CLIENT_REDIRECT_URIS => [], 46 | static::KEY_USER_TABLE_NAME => User::TABLE_NAME, 47 | static::KEY_USER_PRIMARY_KEY_NAME => User::FIELD_ID, 48 | static::KEY_USER_CREDENTIALS_VALIDATOR => OAuth::USER_VALIDATOR, 49 | static::KEY_USER_SCOPE_VALIDATOR => OAuth::SCOPE_VALIDATOR, 50 | 51 | ] + parent::getSettings(); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /server/app/Authorization/RoleRules.php: -------------------------------------------------------------------------------- 1 | 14 | 15 | {{ csrf() }} 16 | 17 | {% if post_create_url %} 18 |
19 | 20 | 21 |
22 | {% if (errors['id'] ?? false) %} 23 | 24 | {% endif %} 25 | {% endif %} 26 |
27 | 28 | 29 |
30 | {% if (errors['description'] ?? false) %} 31 | 32 | {% endif %} 33 | 34 | {% if error_message %} 35 | 36 | {% endif %} 37 | 38 | 39 | {% if can_admin_roles %} 40 | {% if post_create_url %} 41 | 42 | {% endif %} 43 | {% if post_update_url %} 44 | 45 | {% endif %} 46 | {% if post_delete_url %} 47 |
48 | {{ csrf() }} 49 |
50 | 51 | {% endif %} 52 | {% endif %} 53 | {% endblock %} 54 | -------------------------------------------------------------------------------- /server/app/Validation/Role/RolesReadQuery.php: -------------------------------------------------------------------------------- 1 | static::getIdentityRule(), 30 | Schema::ATTR_DESCRIPTION => r::asSanitizedString(), 31 | Schema::ATTR_CREATED_AT => r::asJsonApiDateTime(), 32 | ]; 33 | } 34 | 35 | /** 36 | * @inheritdoc 37 | */ 38 | public static function getFieldSetRules(): ?array 39 | { 40 | // no field sets are allowed 41 | return []; 42 | } 43 | 44 | /** 45 | * @inheritdoc 46 | */ 47 | public static function getSortsRule(): ?RuleInterface 48 | { 49 | return r::isString(r::inValues([ 50 | Schema::RESOURCE_ID, 51 | Schema::ATTR_DESCRIPTION, 52 | ])); 53 | } 54 | 55 | /** 56 | * @inheritdoc 57 | */ 58 | public static function getIncludesRule(): ?RuleInterface 59 | { 60 | // no includes are allowed 61 | return r::fail(); 62 | } 63 | 64 | /** 65 | * @inheritdoc 66 | */ 67 | public static function getPageOffsetRule(): ?RuleInterface 68 | { 69 | // defaults are fine 70 | return DefaultQueryValidationRules::getPageOffsetRule(); 71 | } 72 | 73 | /** 74 | * @inheritdoc 75 | */ 76 | public static function getPageLimitRule(): ?RuleInterface 77 | { 78 | // defaults are fine 79 | return DefaultQueryValidationRules::getPageLimitRuleForDefaultAndMaxSizes( 80 | ApplicationApi::DEFAULT_PAGE_SIZE, 81 | ApplicationApi::DEFAULT_MAX_PAGE_SIZE 82 | ); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /server/resources/views/pages/en/sign-in.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'pages/en/base/master.html.twig' %} 2 | 3 | {% block title %}Sign in{% endblock %} 4 | 5 | {% block content %} 6 |
7 | 41 | 49 |
50 | {% endblock %} 51 | -------------------------------------------------------------------------------- /server/resources/views/pages/en/users.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'pages/en/base/with-header-and-footer.master.html.twig' %} 2 | 3 | {% block title %}Limoncello Users{% endblock %} 4 | 5 | {% block content %} 6 |
7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | {% for user in models %} 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | {% endfor %} 31 | 32 |
#FirstLastEmailRoleCreated at
{{ user.id_user }}{{ user.first_name }}{{ user.last_name }}{{ user.email }}{{ user.id_role }}{{ user.created_at | date("Y-m-d") }}{% if can_admin_users %}Modify{% endif %}
33 | {% if can_admin_users %} 34 | Add New 35 | {% endif %} 36 | {{ include('pages/en/sections/pagination.html.twig') }} 37 |
38 | 46 | {% endblock %} 47 | -------------------------------------------------------------------------------- /server/app/Routes/ApiRoutes.php: -------------------------------------------------------------------------------- 1 | group(self::API_URI_PREFIX, function (GroupInterface $routes): void { 41 | 42 | $routes->addContainerConfigurators([ 43 | FluteContainerConfigurator::CONFIGURE_EXCEPTION_HANDLER, 44 | ]); 45 | 46 | self::apiController($routes, UserSchema::TYPE, UsersController::class); 47 | 48 | self::apiController($routes, RoleSchema::TYPE, RolesController::class); 49 | self::relationship($routes, RoleSchema::TYPE, RoleSchema::REL_USERS, RolesController::class, 'readUsers'); 50 | }); 51 | } 52 | 53 | /** 54 | * This middleware will be executed on every request even when no matching route is found. 55 | * 56 | * @return string[] 57 | */ 58 | public static function getMiddleware(): array 59 | { 60 | return [ 61 | //ClassName::class, 62 | ]; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /server/resources/views/pages/en/base/with-header-and-footer.master.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'pages/en/base/master.html.twig' %} 2 | 3 | {% block header %} 4 | {{ parent() }} 5 | 27 | {% endblock %} 28 | 29 | {% block footer %} 30 | {{ parent() }} 31 |
32 |
33 |
34 | 35 | 2016-2018 36 | 37 | 38 | 39 | 40 |
41 |
42 |
43 | {% endblock %} 44 | -------------------------------------------------------------------------------- /server/app/Web/Middleware/CustomErrorResponsesMiddleware.php: -------------------------------------------------------------------------------- 1 | getThrowable() instanceof AuthorizationExceptionInterface) { 42 | return static::createResponseFromTemplate($container, Views::NOT_FORBIDDEN_PAGE, 403); 43 | } 44 | } 45 | 46 | // error responses might have just HTTP 4xx code as well 47 | switch ($response->getStatusCode()) { 48 | case 404: 49 | return static::createResponseFromTemplate($container, Views::NOT_FOUND_PAGE, 404); 50 | default: 51 | return $response; 52 | } 53 | } 54 | 55 | /** 56 | * @param ContainerInterface $container 57 | * @param int $templateId 58 | * @param int $httpCode 59 | * 60 | * @return ResponseInterface 61 | * 62 | * @throws ContainerExceptionInterface 63 | * @throws NotFoundExceptionInterface 64 | */ 65 | private static function createResponseFromTemplate( 66 | ContainerInterface $container, 67 | int $templateId, 68 | int $httpCode 69 | ): ResponseInterface { 70 | $body = static::view($container, $templateId); 71 | 72 | return new HtmlResponse($body, $httpCode); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /server/settings/ApplicationApi.php: -------------------------------------------------------------------------------- 1 | ApiRoutes::API_URI_PREFIX, 37 | static::KEY_THROWABLE_TO_JSON_API_EXCEPTION_CONVERTER => ThrowableConverter::class, 38 | static::KEY_API_FOLDER => $apiFolder, 39 | static::KEY_JSON_CONTROLLERS_FOLDER => $jsonCtrlFolder, 40 | static::KEY_SCHEMAS_FOLDER => $schemasFolder, 41 | static::KEY_JSON_VALIDATION_RULES_FOLDER => $valRulesFolder, 42 | static::KEY_JSON_VALIDATORS_FOLDER => $jsonValFolder, 43 | static::KEY_FORM_VALIDATORS_FOLDER => $formValFolder, 44 | static::KEY_QUERY_VALIDATORS_FOLDER => $queryValFolder, 45 | static::KEY_JSON_ENCODE_OPTIONS => $defaults[static::KEY_JSON_ENCODE_OPTIONS] | JSON_PRETTY_PRINT, 46 | static::KEY_DO_NOT_LOG_EXCEPTIONS_LIST => [ 47 | 48 | AuthorizationException::class, 49 | 50 | ] + $defaults[static::KEY_DO_NOT_LOG_EXCEPTIONS_LIST], 51 | 52 | ] + $defaults; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /client/webpack/base.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const CleanWebpackPlugin = require('clean-webpack-plugin'); 3 | const FsWatchPlugin = require('./FsWatchPlugin'); 4 | 5 | const assetsSubFolder = 'assets'; 6 | const publicDir = path.resolve(__dirname, '..', '..', 'public'); 7 | const outputDir = path.resolve(publicDir, assetsSubFolder); 8 | const srcRootFolder = path.resolve(__dirname, '..', 'src'); 9 | 10 | module.exports = { 11 | entry: { 12 | index: path.resolve(srcRootFolder, 'index.ts') 13 | }, 14 | module: { 15 | rules: [ 16 | { 17 | test: /\.tsx?$/, 18 | exclude: /node_modules/, 19 | use: [ 20 | { 21 | loader: 'ts-loader', 22 | options: { 23 | configFile: path.resolve(srcRootFolder, 'tsconfig.json') 24 | } 25 | } 26 | ], 27 | }, { 28 | test: /\.(scss)$/, 29 | use: [{ 30 | loader: 'style-loader', // inject CSS to page 31 | }, { 32 | loader: 'css-loader', // translates CSS into CommonJS modules 33 | }, { 34 | loader: 'postcss-loader', // Run post css actions 35 | options: 'precss,autoprefixer' 36 | }, { 37 | loader: 'sass-loader' // compiles Sass to CSS 38 | }] 39 | }, { 40 | test: /\.(jpe|jpg|woff|woff2|eot|ttf|svg)/, 41 | loader: 'file-loader' 42 | } 43 | ] 44 | }, 45 | plugins: [ 46 | // hack with `root` dir. Otherwise the files are not removed because they are 'outside of the project root'. 47 | new CleanWebpackPlugin('*.*', {root: outputDir}), 48 | 49 | new FsWatchPlugin({ 50 | extraRootFolders: [ 51 | path.resolve(__dirname, '..', 'src'), 52 | path.resolve(__dirname, '..', '..', 'server', 'resources', 'views'), 53 | ] 54 | }), 55 | ], 56 | resolve: { 57 | extensions: ['.tsx', '.ts', '.js'] 58 | }, 59 | output: { 60 | // https://webpack.js.org/configuration/output/#output-librarytarget 61 | libraryTarget: 'var', 62 | publicPath: `/${assetsSubFolder}/`, 63 | path: outputDir, 64 | filename: '[name].js', 65 | }, 66 | devServer: { 67 | contentBase: publicDir, 68 | compress: true, 69 | port: 8080, 70 | stats: 'minimal', 71 | proxy: [{ 72 | context: ['**', '!/assets/**', '!/img/**'], 73 | target: 'http://localhost:8090', 74 | }], 75 | }, 76 | }; 77 | -------------------------------------------------------------------------------- /server/app/Validation/User/UserRules.php: -------------------------------------------------------------------------------- 1 | getCookieParams(); 35 | if (array_key_exists(static::COOKIE_NAME, $cookies) === true && 36 | is_string($tokenValue = $cookies[static::COOKIE_NAME]) === true && 37 | empty($tokenValue) === false 38 | ) { 39 | // ... and user hasn't been authenticated before ... 40 | /** @var PassportAccountManagerInterface $accountManager */ 41 | $accountManager = $container->get(PassportAccountManagerInterface::class); 42 | if ($accountManager->getAccount() === null) { 43 | // ... then auth with the cookie 44 | try { 45 | $accountManager->setAccountWithTokenValue($tokenValue); 46 | } catch (AuthenticationException $exception) { 47 | // ignore if auth with the token fails or add the accident to log (could be taken from container) 48 | /** @var LoggerInterface $logger */ 49 | $logger = $container->get(LoggerInterface::class); 50 | $logger->warning( 51 | 'Auth cookie received with request however authentication failed due to its invalid value.', 52 | ['exception' => $exception] 53 | ); 54 | } catch (RepositoryException $exception) { 55 | // ignore if auth with the token fails or add the accident to log (could be taken from container) 56 | /** @var LoggerInterface $logger */ 57 | $logger = $container->get(LoggerInterface::class); 58 | $logger->warning( 59 | 'Auth cookie received with request however authentication failed due to database issue(s).', 60 | ['exception' => $exception] 61 | ); 62 | } 63 | } 64 | } 65 | 66 | return $next($request); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /client/src/index.ts: -------------------------------------------------------------------------------- 1 | import 'bootstrap'; 2 | import './../scss/index.scss'; 3 | 4 | // import {AuthToken} from './Application/AuthToken'; 5 | // import {QueryBuilder} from '@limoncello-framework/json-api-client'; 6 | // import {TokenInterface} from "@limoncello-framework/oauth-client"; 7 | // 8 | // (async () => { 9 | // 10 | // const serverUrl = 'http://localhost:8080'; 11 | // const userName = 'denesik.stewart@gmail.com'; 12 | // const password = 'secret'; 13 | // 14 | // try { 15 | // // Get OAuth token (for details see https://tools.ietf.org/html/rfc6749#section-5.1) 16 | // const token: TokenInterface = await (new AuthToken(serverUrl + '/token')).password(userName, password); 17 | // console.log('OAuth token: ' + JSON.stringify(token, null, ' ')); 18 | // const authHeader = {Authorization: 'Bearer ' + token.access_token}; 19 | // 20 | // // CREATE Post sample 21 | // let response = await fetch(serverUrl + '/api/v1/posts', { 22 | // method: 'POST', headers: authHeader, body: JSON.stringify({ 23 | // data: { 24 | // type: 'posts', 25 | // attributes: { 26 | // title: 'Some title', 27 | // text: 'Some text 12345', 28 | // } 29 | // } 30 | // }) 31 | // }); 32 | // console.log('New post created at: ' + JSON.stringify(response.headers.get('Location'))); 33 | // 34 | // // UPDATE Post sample 35 | // await fetch(serverUrl + '/api/v1/posts/101', { 36 | // method: 'PATCH', headers: authHeader, body: JSON.stringify({ 37 | // data: { 38 | // id: '101', 39 | // type: 'posts', 40 | // attributes: { 41 | // title: 'Updated title', 42 | // } 43 | // } 44 | // }) 45 | // }); 46 | // 47 | // // SEARCH Posts sample 48 | // const apiUrl: string = serverUrl + '/api/v1' + (new QueryBuilder('posts')) 49 | // .withFilters({ 50 | // field: 'text', 51 | // operation: 'like', 52 | // parameters: '%12345%' 53 | // }) 54 | // .withSorts({ 55 | // field: 'text', 56 | // isAscending: false 57 | // }) 58 | // .withPagination(0, 10) 59 | // .index(); 60 | // response = await fetch(apiUrl, {method: 'GET', headers: authHeader}); 61 | // console.log(await response.json()); 62 | // 63 | // // DELETE Post sample 64 | // await fetch(serverUrl + '/api/v1/posts/101', {method: 'DELETE', headers: authHeader}); 65 | // 66 | // } catch (error) { 67 | // if (error.reason !== undefined) { 68 | // // see https://tools.ietf.org/html/rfc6749#section-5.2 69 | // console.error('Authentication failed. Reason: ' + error.reason.error); 70 | // } else { 71 | // // invalid token URL, network error, invalid response format or server-side error 72 | // console.error('Error occurred: ' + error.message); 73 | // } 74 | // } 75 | // })(); 76 | -------------------------------------------------------------------------------- /server/app/Data/Models/User.php: -------------------------------------------------------------------------------- 1 | Type::INTEGER, 66 | self::FIELD_ID_ROLE => Role::getAttributeTypes()[Role::FIELD_ID], 67 | self::FIELD_FIRST_NAME => Type::STRING, 68 | self::FIELD_LAST_NAME => Type::STRING, 69 | self::FIELD_EMAIL => Type::STRING, 70 | self::FIELD_PASSWORD_HASH => Type::STRING, 71 | self::FIELD_CREATED_AT => DateTimeType::NAME, 72 | self::FIELD_UPDATED_AT => DateTimeType::NAME, 73 | self::FIELD_DELETED_AT => DateTimeType::NAME, 74 | ]; 75 | } 76 | 77 | /** 78 | * @inheritdoc 79 | */ 80 | public static function getAttributeLengths(): array 81 | { 82 | return [ 83 | self::FIELD_ID_ROLE => Role::getAttributeLengths()[Role::FIELD_ID], 84 | self::FIELD_FIRST_NAME => 100, 85 | self::FIELD_LAST_NAME => 100, 86 | self::FIELD_EMAIL => 255, 87 | self::FIELD_PASSWORD_HASH => 100, 88 | ]; 89 | } 90 | 91 | /** 92 | * @inheritdoc 93 | */ 94 | public static function getRawAttributes(): array 95 | { 96 | return []; 97 | } 98 | 99 | /** 100 | * @inheritdoc 101 | */ 102 | public static function getRelationships(): array 103 | { 104 | return [ 105 | RelationshipTypes::BELONGS_TO => [ 106 | self::REL_ROLE => [Role::class, self::FIELD_ID_ROLE, Role::REL_USERS], 107 | ], 108 | // RelationshipTypes::HAS_MANY => [ 109 | // self::REL_POSTS => [Post::class, Post::FIELD_ID_USER, Post::REL_USER], 110 | // ], 111 | ]; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /server/app/Api/RolesApi.php: -------------------------------------------------------------------------------- 1 | authorize(RoleRules::ACTION_ADMIN_ROLES, Schema::TYPE, $index); 36 | 37 | return parent::create($index, $attributes, $toMany); 38 | } 39 | 40 | /** 41 | * @inheritdoc 42 | * 43 | * @throws AuthorizationExceptionInterface 44 | */ 45 | public function update(string $index, iterable $attributes, iterable $toMany): int 46 | { 47 | $this->authorize(RoleRules::ACTION_ADMIN_ROLES, Schema::TYPE, $index); 48 | 49 | return parent::update($index, $attributes, $toMany); 50 | } 51 | 52 | /** 53 | * @inheritdoc 54 | * 55 | * @throws AuthorizationExceptionInterface 56 | */ 57 | public function remove(string $index): bool 58 | { 59 | $this->authorize(RoleRules::ACTION_ADMIN_ROLES, Schema::TYPE, $index); 60 | 61 | return parent::remove($index); 62 | } 63 | 64 | /** 65 | * @inheritdoc 66 | * 67 | * @throws AuthorizationExceptionInterface 68 | */ 69 | public function index(): PaginatedDataInterface 70 | { 71 | $this->authorize(RoleRules::ACTION_VIEW_ROLES, Schema::TYPE); 72 | 73 | return parent::index(); 74 | } 75 | 76 | /** 77 | * @inheritdoc 78 | * 79 | * @throws AuthorizationExceptionInterface 80 | */ 81 | public function read(string $index) 82 | { 83 | $this->authorize(RoleRules::ACTION_VIEW_ROLES, Schema::TYPE, $index); 84 | 85 | return parent::read($index); 86 | } 87 | 88 | /** 89 | * @param string|int $index 90 | * @param iterable|null $relationshipFilters 91 | * @param iterable|null $relationshipSorts 92 | * 93 | * @return PaginatedDataInterface 94 | * 95 | * @throws ContainerExceptionInterface 96 | * @throws NotFoundExceptionInterface 97 | * @throws AuthorizationExceptionInterface 98 | */ 99 | public function readUsers( 100 | $index, 101 | iterable $relationshipFilters = null, 102 | iterable $relationshipSorts = null 103 | ): PaginatedDataInterface { 104 | $this->authorize(RoleRules::ACTION_VIEW_ROLE_USERS, Schema::TYPE, $index); 105 | 106 | return $this->readRelationshipInt($index, Model::REL_USERS, $relationshipFilters, $relationshipSorts); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /server/app/Validation/User/UsersReadQuery.php: -------------------------------------------------------------------------------- 1 | static::getIdentityRule(), 31 | Schema::ATTR_FIRST_NAME => r::asSanitizedString(), 32 | Schema::ATTR_LAST_NAME => r::asSanitizedString(), 33 | Schema::ATTR_CREATED_AT => r::asJsonApiDateTime(), 34 | Schema::REL_ROLE => r::asSanitizedString(), 35 | Schema::REL_ROLE . '.' . RoleSchema::ATTR_DESCRIPTION => r::asSanitizedString(), 36 | ]; 37 | } 38 | 39 | /** 40 | * @return RuleInterface[]|null 41 | */ 42 | public static function getFieldSetRules(): ?array 43 | { 44 | return [ 45 | // if fields sets are given only the following fields are OK 46 | Schema::TYPE => r::inValues([ 47 | Schema::RESOURCE_ID, 48 | Schema::ATTR_FIRST_NAME, 49 | Schema::ATTR_LAST_NAME, 50 | Schema::REL_ROLE, 51 | ]), 52 | // roles field sets could be any 53 | RoleSchema::TYPE => r::success(), 54 | ]; 55 | } 56 | 57 | /** 58 | * @return RuleInterface|null 59 | */ 60 | public static function getSortsRule(): ?RuleInterface 61 | { 62 | return r::isString(r::inValues([ 63 | Schema::RESOURCE_ID, 64 | Schema::ATTR_FIRST_NAME, 65 | Schema::ATTR_LAST_NAME, 66 | Schema::REL_ROLE, 67 | ])); 68 | } 69 | 70 | /** 71 | * @return RuleInterface|null 72 | */ 73 | public static function getIncludesRule(): ?RuleInterface 74 | { 75 | return r::isString(r::inValues([ 76 | Schema::REL_ROLE, 77 | ])); 78 | } 79 | 80 | /** 81 | * @return RuleInterface|null 82 | */ 83 | public static function getPageOffsetRule(): ?RuleInterface 84 | { 85 | // defaults are fine 86 | return DefaultQueryValidationRules::getPageOffsetRule(); 87 | } 88 | 89 | /** 90 | * @return RuleInterface|null 91 | */ 92 | public static function getPageLimitRule(): ?RuleInterface 93 | { 94 | // defaults are fine 95 | return DefaultQueryValidationRules::getPageLimitRuleForDefaultAndMaxSizes( 96 | ApplicationApi::DEFAULT_PAGE_SIZE, 97 | ApplicationApi::DEFAULT_MAX_PAGE_SIZE 98 | ); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /server/app/Container/TwigConfigurator.php: -------------------------------------------------------------------------------- 1 | get(SettingsProviderInterface::class); 31 | 32 | $settings = $provider->get(C::class); 33 | $templates = new TwigTemplates( 34 | $settings[C::KEY_APP_ROOT_FOLDER], 35 | $settings[C::KEY_TEMPLATES_FOLDER], 36 | $settings[C::KEY_CACHE_FOLDER] ?? null, 37 | $settings[C::KEY_IS_DEBUG] ?? false, 38 | $settings[C::KEY_IS_AUTO_RELOAD] ?? false 39 | ); 40 | 41 | $templates->getTwig()->addExtension(new Twig_Extensions_Extension_Text()); 42 | 43 | $templates->getTwig()->addFunction(new Twig_Function( 44 | 'csrf', 45 | function () use ($container, $provider): string { 46 | [CsrfSettings::HTTP_REQUEST_CSRF_TOKEN_KEY => $key] = $provider->get(CsrfSettings::class); 47 | 48 | /** @var CsrfTokenGeneratorInterface $generator */ 49 | $generator = $container->get(CsrfTokenGeneratorInterface::class); 50 | $token = $generator->create(); 51 | 52 | $result = ''; 53 | 54 | return $result; 55 | }, 56 | ['is_safe' => ['html']] 57 | )); 58 | 59 | $templates->getTwig()->addFunction(new Twig_Function( 60 | 'csrf_name', 61 | function () use ($container, $provider): string { 62 | [CsrfSettings::HTTP_REQUEST_CSRF_TOKEN_KEY => $key] = $provider->get(CsrfSettings::class); 63 | 64 | return $key; 65 | }, 66 | ['is_safe' => ['html']] 67 | )); 68 | 69 | $templates->getTwig()->addFunction(new Twig_Function( 70 | 'csrf_value', 71 | function () use ($container, $provider): string { 72 | /** @var CsrfTokenGeneratorInterface $generator */ 73 | $generator = $container->get(CsrfTokenGeneratorInterface::class); 74 | $token = $generator->create(); 75 | 76 | return $token; 77 | }, 78 | ['is_safe' => ['html']] 79 | )); 80 | 81 | return $templates; 82 | }; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /server/settings/Cors.php: -------------------------------------------------------------------------------- 1 | load(); 17 | 18 | return [ 19 | static::KEY_LOG_IS_ENABLED => filter_var(getenv('APP_ENABLE_LOGS'), FILTER_VALIDATE_BOOLEAN), 20 | 21 | /** 22 | * A list of allowed request origins (no trail slashes, no default ports). 23 | * For example, [ 24 | * 'http://example.com:123', 25 | * ]; 26 | */ 27 | static::KEY_ALLOWED_ORIGINS => [ 28 | 'http://localhost:8080', 29 | ], 30 | 31 | /** 32 | * A list of allowed request methods. 33 | * 34 | * Security Note: you have to remember CORS is not access control system and you should not expect all 35 | * cross-origin requests will have pre-flights. For so-called 'simple' methods with so-called 'simple' 36 | * headers request will be made without pre-flight. Thus you can not restrict such requests with CORS 37 | * and should use other means. 38 | * For example method 'GET' without any headers or with only 'simple' headers will not have pre-flight 39 | * request so disabling it will not restrict access to resource(s). 40 | * 41 | * You can read more on 'simple' methods at http://www.w3.org/TR/cors/#simple-method 42 | */ 43 | static::KEY_ALLOWED_METHODS => [ 44 | 'GET', 45 | 'POST', 46 | 'PATCH', 47 | 'PUT', 48 | 'DELETE', 49 | ], 50 | 51 | /** 52 | * A list of allowed request headers. 53 | * 54 | * Security Note: you have to remember CORS is not access control system and you should not expect all 55 | * cross-origin requests will have pre-flights. For so-called 'simple' methods with so-called 'simple' 56 | * headers request will be made without pre-flight. Thus you can not restrict such requests with CORS 57 | * and should use other means. 58 | * For example method 'GET' without any headers or with only 'simple' headers will not have pre-flight 59 | * request so disabling it will not restrict access to resource(s). 60 | * 61 | * You can read more on 'simple' headers at http://www.w3.org/TR/cors/#simple-header 62 | */ 63 | static::KEY_ALLOWED_HEADERS => [ 64 | 'Accept', 65 | 'Content-Type', 66 | 'Authorization', 67 | 'Origin', 68 | ], 69 | 70 | /** 71 | * A list of headers (case insensitive) which will be made accessible to 72 | * user agent (browser) in response. 73 | */ 74 | static::KEY_EXPOSED_HEADERS => [ 75 | 'Content-Type', 76 | ], 77 | 78 | static::KEY_IS_CHECK_HOST => !filter_var(getenv('APP_IS_DEBUG'), FILTER_VALIDATE_BOOLEAN), 79 | 80 | ] + parent::getSettings(); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /server/app/Validation/BaseRules.php: -------------------------------------------------------------------------------- 1 | =7.1.0", 16 | "vlucas/phpdotenv": "^2.3", 17 | "limoncello-php/framework": "^0.10.0", 18 | "neomerx/json-api": "^3.0.0", 19 | "twig/extensions": "^1.5" 20 | }, 21 | "require-dev": { 22 | "limoncello-php/testing": "^0.10.0", 23 | "filp/whoops": "^2.1", 24 | "squizlabs/php_codesniffer": "^2.9", 25 | "phpmd/phpmd": "^2.6", 26 | "phpunit/phpunit": "^7.0", 27 | "mockery/mockery": "^1.0", 28 | "doctrine/dbal": "^2.5.0", 29 | "fzaninotto/faker": "^1.7" 30 | }, 31 | "autoload": { 32 | "psr-4": { 33 | "App\\": "server/app/", 34 | "Settings\\": "server/settings/", 35 | "Cached\\": "server/storage/cache/settings/" 36 | } 37 | }, 38 | "autoload-dev": { 39 | "psr-4": { 40 | "Tests\\": "server/tests/" 41 | } 42 | }, 43 | "extra": { 44 | "application": { 45 | "commands_cache": "server/storage/cache/settings/commands_cache.php" 46 | } 47 | }, 48 | "config": { 49 | "optimize-autoloader": true 50 | }, 51 | "scripts": { 52 | "post-root-package-install": [ 53 | "php -r \"file_exists('.env') || copy('.env.sample', '.env');\"" 54 | ], 55 | "post-create-project-cmd": [ 56 | "@composer l:commands connect", 57 | "@composer db" 58 | ], 59 | "post-update-cmd": ["@composer l:commands connect"], 60 | 61 | "serve": "php -S 0.0.0.0:8080 -t public", 62 | 63 | "develop": ["@clear-app-cache", "@composer update --optimize-autoloader --quiet"], 64 | "build": ["@refresh-app-cache", "@composer update --no-dev --optimize-autoloader --quiet"], 65 | "settings-cache": [ 66 | "@composer dump-autoload --optimize --quiet", 67 | "@composer l:app cache", 68 | "@composer dump-autoload --optimize --quiet" 69 | ], 70 | "clear-settings-cache": [ 71 | "@composer dump-autoload --optimize --quiet", 72 | "@composer l:app clear-cache", 73 | "@composer dump-autoload --optimize --quiet" 74 | ], 75 | "refresh-settings-cache": ["@clear-settings-cache", "@settings-cache"], 76 | "app-cache": [ 77 | "@refresh-settings-cache", "@composer l:templates cache", 78 | "@composer dump-autoload --optimize --quiet" 79 | ], 80 | "clear-app-cache": [ 81 | "@refresh-settings-cache", 82 | "@composer l:templates clear-cache", "@clear-settings-cache", 83 | "@composer dump-autoload --optimize --quiet" 84 | ], 85 | "refresh-app-cache": ["@clear-app-cache", "@app-cache"], 86 | 87 | "db": ["@composer l:db rollback", "@composer l:db migrate", "@composer l:db seed"], 88 | 89 | "test": ["@test-unit"], 90 | "test-all": ["@test-coverage", "@test-cs", "@test-md"], 91 | "test-unit": "./vendor/bin/phpunit", 92 | "test-coverage": "./vendor/bin/phpunit --coverage-text", 93 | "test-cs": "./vendor/bin/phpcs -p -s --standard=PSR2 ./server/app ./server/tests", 94 | "test-md": "./vendor/bin/phpmd ./server/app text codesize,controversial,cleancode,design,unusedcode,naming", 95 | "stress": "wrk -t10 -d5s -c400 http://127.0.0.1:8080/" 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /client/src/Application/AuthToken.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Authorizer, 3 | AuthorizerInterface, 4 | ClientRequestsInterface, 5 | TokenInterface 6 | } from '@limoncello-framework/oauth-client' 7 | 8 | /** 9 | * A wrapper for sending HTML forms to OAuth server. 10 | */ 11 | class ClientRequests implements ClientRequestsInterface { 12 | /** 13 | * Authorization server endpoint URL. 14 | */ 15 | private readonly tokenUrl: string; 16 | 17 | /** 18 | * Request parameters. 19 | */ 20 | private readonly requestInit: RequestInit; 21 | 22 | /** 23 | * @param {string} tokenUrl Authorization server endpoint URL. 24 | * @param {RequestInit} requestInit Request parameters. 25 | */ 26 | constructor(tokenUrl: string, requestInit: RequestInit) { 27 | this.tokenUrl = tokenUrl; 28 | this.requestInit = requestInit; 29 | } 30 | 31 | /** 32 | * Send HTML form data. 33 | * 34 | * @param data Data to send. 35 | * @param {boolean} addAuth If OAuth client authentication should be added to the request. 36 | * 37 | * @returns {Promise} 38 | */ 39 | sendForm(data: any, addAuth: boolean): Promise { 40 | // We use only password authentication and OAuth client authentication is not used in password authentication. 41 | // So it is a safety measure. 42 | if (addAuth === true) { 43 | throw new Error('OAuth client authentication is not supported.'); 44 | } 45 | 46 | let init: RequestInit = Object.assign({}, this.requestInit); 47 | 48 | let form = new FormData(); 49 | Object.getOwnPropertyNames(data).forEach((name) => form.append(name, data[name])); 50 | init.body = form; 51 | 52 | // Fetch API has pretty good support in modern browsers. 53 | // If you need to support older ones feel free to use other means to send forms. 54 | // 55 | // https://caniuse.com/#search=fetch 56 | return fetch(this.tokenUrl, init); 57 | } 58 | } 59 | 60 | /** 61 | * Authorizes by user credentials. 62 | */ 63 | export class AuthToken { 64 | /** 65 | * The actual authorizer that does OAuth heavy lifting. 66 | */ 67 | private readonly authorizer: AuthorizerInterface; 68 | 69 | /** 70 | * @param {string} tokenUrl Authorization server endpoint URL. 71 | * @param {RequestInit} requestInit Optional request parameters. 72 | */ 73 | constructor( 74 | tokenUrl: string, 75 | requestInit: RequestInit = { 76 | method: "post", 77 | mode: "cors", 78 | credentials: "omit", 79 | cache: "no-cache", 80 | } 81 | ) { 82 | this.authorizer = new Authorizer(new ClientRequests(tokenUrl, requestInit)) 83 | } 84 | 85 | /** 86 | * 87 | * @param {string} userName Required user email. 88 | * @param {string} password Required user password. 89 | * @param {string} scope Optional OAuth scopes to assign to OAuth token. 90 | * If not given all users scopes will be assigned. 91 | * 92 | * @returns {Promise} 93 | */ 94 | password(userName: string, password: string, scope?: string): Promise { 95 | return this.authorizer.password(userName, password, scope); 96 | } 97 | 98 | /** 99 | * Refresh OAuth token with a new one. 100 | * 101 | * @param {string} refreshToken Required refresh token value. 102 | * @param {string} scope Optional OAuth scopes to assign to OAuth token. 103 | * If not given all scopes assigned to the previous token will be included. 104 | * 105 | * @returns {Promise} 106 | */ 107 | refresh(refreshToken: string, scope?: string): Promise { 108 | return this.authorizer.refresh(refreshToken, scope); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /server/app/Routes/WebRoutes.php: -------------------------------------------------------------------------------- 1 | group(self::TOP_GROUP_PREFIX, function (GroupInterface $routes): void { 50 | 51 | $routes->addContainerConfigurators([ 52 | WhoopsContainerConfigurator::CONFIGURE_EXCEPTION_HANDLER, 53 | CsrfContainerConfigurator::CONFIGURATOR, 54 | SessionContainerConfigurator::CONFIGURATOR, 55 | RequestStorageConfigurator::CONFIGURATOR, 56 | ])->addMiddleware([ 57 | CustomErrorResponsesMiddleware::CALLABLE_HANDLER, 58 | SessionMiddleware::CALLABLE_HANDLER, 59 | CsrfMiddleware::CALLABLE_HANDLER, 60 | RememberRequestMiddleware::CALLABLE_HANDLER, 61 | ]); 62 | 63 | $routes 64 | ->get('/', [HomeController::class, 'index'], [RouteInterface::PARAM_NAME => HomeController::ROUTE_NAME_HOME]) 65 | ->get('/sign-in', AuthController::CALLABLE_SHOW_SIGN_IN, [RouteInterface::PARAM_NAME => AuthController::ROUTE_NAME_SIGN_IN]) 66 | ->post('/sign-in', AuthController::CALLABLE_AUTHENTICATE) 67 | ->get('/sign-out', AuthController::CALLABLE_LOGOUT, [RouteInterface::PARAM_NAME => AuthController::ROUTE_NAME_LOGOUT]); 68 | 69 | 70 | $idx = '{' . WebControllerInterface::ROUTE_KEY_INDEX . '}'; 71 | 72 | self::webController($routes, 'users', UsersController::class); 73 | self::webController($routes, 'roles', RolesController::class); 74 | $routes->get("roles/$idx/users", RolesController::CALLABLE_READ_USERS, [RouteInterface::PARAM_NAME => RolesController::ROUTE_NAME_READ_USERS]); 75 | 76 | }); 77 | } 78 | 79 | /** 80 | * This middleware will be executed on every request even when no matching route is found. 81 | * 82 | * @return string[] 83 | */ 84 | public static function getMiddleware(): array 85 | { 86 | return [ 87 | CookieAuth::class, 88 | ]; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /server/settings/Application.php: -------------------------------------------------------------------------------- 1 | load(); 20 | 21 | $routesMask = '*Routes.php'; 22 | $routesFolder = implode(DIRECTORY_SEPARATOR, [__DIR__, '..', 'app', 'Routes']); 23 | $webCtrlFolder = implode(DIRECTORY_SEPARATOR, [__DIR__, '..', 'app', 'Web', 'Controllers']); 24 | $routesPath = implode(DIRECTORY_SEPARATOR, [$routesFolder, $routesMask]); 25 | $confPath = implode(DIRECTORY_SEPARATOR, [__DIR__, '..', 'app', 'Container', '*.php']); 26 | $commandsFolder = implode(DIRECTORY_SEPARATOR, [__DIR__, '..', 'app', 'Commands']); 27 | $cacheFolder = implode(DIRECTORY_SEPARATOR, [__DIR__, '..', 'storage', 'cache', 'settings']); 28 | 29 | $originScheme = getenv('APP_ORIGIN_SCHEME'); 30 | $originHost = getenv('APP_ORIGIN_HOST'); 31 | $originPort = getenv('APP_ORIGIN_PORT'); 32 | $originUri = filter_var("$originScheme://$originHost:$originPort", FILTER_VALIDATE_URL); 33 | assert(is_string($originUri) === true); 34 | 35 | return [ 36 | static::KEY_APP_NAME => getenv('APP_NAME'), 37 | static::KEY_IS_LOG_ENABLED => filter_var(getenv('APP_ENABLE_LOGS'), FILTER_VALIDATE_BOOLEAN), 38 | static::KEY_IS_DEBUG => filter_var(getenv('APP_IS_DEBUG'), FILTER_VALIDATE_BOOLEAN), 39 | static::KEY_ROUTES_FILE_MASK => $routesMask, 40 | static::KEY_ROUTES_FOLDER => $routesFolder, 41 | static::KEY_WEB_CONTROLLERS_FOLDER => $webCtrlFolder, 42 | static::KEY_ROUTES_PATH => $routesPath, 43 | static::KEY_CONTAINER_CONFIGURATORS_PATH => $confPath, 44 | static::KEY_CACHE_FOLDER => $cacheFolder, 45 | static::KEY_CACHE_CALLABLE => static::CACHE_CALLABLE, 46 | static::KEY_COMMANDS_FOLDER => $commandsFolder, 47 | static::KEY_APP_ORIGIN_SCHEMA => $originScheme, 48 | static::KEY_APP_ORIGIN_HOST => $originHost, 49 | static::KEY_APP_ORIGIN_PORT => $originPort, 50 | static::KEY_APP_ORIGIN_URI => $originUri, 51 | static::KEY_PROVIDER_CLASSES => [ 52 | \Limoncello\Application\Packages\Application\ApplicationProvider::class, 53 | \Limoncello\Application\Packages\Authorization\AuthorizationProvider::class, 54 | //\Limoncello\Application\Packages\PDO\PdoProvider::class, 55 | \Limoncello\Application\Packages\Cookies\CookieProvider::class, 56 | \Limoncello\Application\Packages\Cors\CorsProvider::class, 57 | \Limoncello\Application\Packages\Csrf\CsrfMinimalProvider::class, 58 | \Limoncello\Application\Packages\Data\DataProvider::class, 59 | \Limoncello\Application\Packages\L10n\L10nProvider::class, 60 | \Limoncello\Application\Packages\Monolog\MonologFileProvider::class, 61 | \Limoncello\Application\Packages\FileSystem\FileSystemProvider::class, 62 | //\Limoncello\Application\Packages\Session\SessionProvider::class, 63 | \Limoncello\Crypt\Package\HasherProvider::class, 64 | //\Limoncello\Crypt\Package\SymmetricCryptProvider::class, 65 | //\Limoncello\Crypt\Package\AsymmetricPublicEncryptPrivateDecryptProvider::class, 66 | //\Limoncello\Crypt\Package\AsymmetricPrivateEncryptPublicDecryptProvider::class, 67 | //\Limoncello\Events\Package\EventProvider::class, 68 | \Limoncello\Flute\Package\FluteProvider::class, 69 | \Limoncello\Passport\Package\PassportProvider::class, 70 | \Limoncello\Templates\Package\TwigTemplatesProvider::class, 71 | ], 72 | ]; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![License](https://img.shields.io/packagist/l/limoncello-php/app.svg)](https://packagist.org/packages/limoncello-php/app) 2 | 3 | ### Summary 4 | 5 | [![How to create JSON API application](http://img.youtube.com/vi/UsIW5jC7rSc/0.jpg)](http://www.youtube.com/watch?v=UsIW5jC7rSc) 6 | 7 | Limoncello App is a fully featured OAuth 2.0 [JSON API](http://jsonapi.org/) quick start application. 8 | 9 | Out-of-the-box it has 10 | 11 | - [JSON API](http://jsonapi.org/) CRUD operations (create, read, update and delete) for a few sample resources with `to-one`, `to-many` and `many-to-many` relationship types. 12 | - Support for such JSON API [features](http://jsonapi.org/format/#fetching) as resource inclusion, sparse field sets, sorting, filtering and pagination. 13 | - Database migrations and seedings. 14 | - OAuth 2.0 server authentication and role authorization. 15 | - Admin panel for managing users and roles. 16 | - Cross-Origin Resource Sharing (CORS). 17 | - JSON API errors. 18 | - API tests. 19 | - Web tests. 20 | 21 | Supported features 22 | - Multiple nested paths resource inclusion (e.g. `posts,posts.user,posts.comments.user`). 23 | - Filtering and sorting by multiple attributes in resources and its relationships. 24 | - Supported operators `=`, `eq`, `equals`, `!=`, `neq`, `not-equals`, `<`, `lt`, `less-than`, `<=`, `lte`, `less-or-equals`, `>`, `gt`, `greater-than`, `>=`, `gte`, `greater-or-equals`, `like`, `not-like`, `in`, `not-in`, `is-null`, `not-null`. 25 | - Pagination works for main resources and resources in relationships. Limits for maximum number of resources are configurable. 26 | 27 | Based on 28 | - [Zend Diactoros](https://github.com/zendframework/zend-diactoros) 29 | - [Doctrine](http://www.doctrine-project.org/) 30 | - [Pimple](http://pimple.sensiolabs.org/) 31 | - [Monolog](https://github.com/Seldaek/monolog) 32 | - [FastRoute](https://github.com/nikic/FastRoute) 33 | - [Twig](https://twig.sensiolabs.org/) 34 | - [JSON API implementation](https://github.com/neomerx/json-api) 35 | - [Cross-Origin Resource Sharing](https://github.com/neomerx/cors-psr7) 36 | - Built with :heart: [Limoncello](https://github.com/limoncello-php/framework) 37 | 38 | It could be a great start if you are planning to develop JSON API. 39 | 40 | Feel free to ask questions and thank you for supporting the project with :star:. 41 | 42 | ### Installation 43 | 44 | #### 1 Create project 45 | 46 | ```bash 47 | $ composer create-project --prefer-dist limoncello-php/app app_name 48 | $ cd app_name 49 | ``` 50 | 51 | Recommended additional step 52 | ```bash 53 | $ npm install 54 | ``` 55 | or 56 | ```bash 57 | $ yarn install 58 | ``` 59 | 60 | #### 2 Run server 61 | 62 | Application runs PHP built-in server on port 8080 63 | 64 | ```bash 65 | $ composer serve 66 | ``` 67 | 68 | Recommended 69 | ```bash 70 | $ npm serve 71 | ``` 72 | or 73 | ```bash 74 | $ yarn serve 75 | ``` 76 | 77 | > Port could be configured in `composer.json` or `client/webpack/base.config.js` 78 | 79 | If you use `Postman` here is a collection of API requests that would work with a local server (reading users, filter users, create users, and etc). 80 | 81 | [![Run in Postman](https://run.pstmn.io/button.svg)](https://app.getpostman.com/run-collection/a911c4ba41085dea3816) 82 | 83 | Here is a `Postman` screen-shot with the collection opened 84 | 85 | ![Requests in Postman](server/resources/img/screen-shot.png) 86 | 87 | #### 3 Turn on production mode (optional) 88 | 89 | **By default** the application is installed in **development mode** (less performance, tests and development libraries are available). Application could be switched into **production mode** (higher performance, no tests, no development libraries) with command 90 | 91 | ```bash 92 | $ composer build 93 | ``` 94 | 95 | Performance comparision with other frameworks could be found [here](https://github.com/limoncello-php/framework/tree/master/docs/bench/minimalistic) and [here](https://github.com/limoncello-php/framework/tree/master/docs/bench/realistic). 96 | 97 | ### Testing 98 | 99 | ```bash 100 | $ composer test 101 | ``` 102 | 103 | ### How-to add Google Auth to the Application 104 | 105 | You can find detailed instructions [here](https://github.com/limoncello-php/framework/blob/develop/docs/101.How_to_add_Google_auth.md). 106 | 107 | ### License 108 | 109 | [MIT license](http://opensource.org/licenses/MIT) 110 | -------------------------------------------------------------------------------- /server/resources/views/pages/en/user-modify.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'pages/en/base/with-header-and-footer.master.html.twig' %} 2 | 3 | {% block title %} 4 | {% if post_create_url %} 5 | New User 6 | {% endif %} 7 | {% if post_update_url %} 8 | {{ model['first-name'] ~ ' ' ~ model['last-name'] ~ ' (' ~ model['role'] ~ ')' }} 9 | {% endif %} 10 | {% endblock %} 11 | 12 | {% block content %} 13 |
14 | 15 | {{ csrf() }} 16 | 17 |
18 | 19 | 20 |
21 | {% if (errors['first-name'] ?? false) %} 22 | 23 | {% endif %} 24 | 25 |
26 | 27 | 28 |
29 | {% if (errors['last-name'] ?? false) %} 30 | 31 | {% endif %} 32 | 33 | {# Email will be editable only on create #} 34 | {% if post_create_url %} 35 |
36 | 37 | 38 |
39 | {% if (errors['email'] ?? false) %} 40 | 41 | {% endif %} 42 | {% else %} 43 |
44 | 45 | 46 |
47 | {% endif %} 48 | 49 |
50 | 51 | 56 |
57 | 58 |
59 | 60 | 61 |
62 | {% if (errors['password'] ?? false) %} 63 | 64 | {% endif %} 65 |
66 | 67 | 68 |
69 | {% if (errors['password-confirmation'] ?? false) %} 70 | 71 | {% endif %} 72 | 73 | {% if error_message %} 74 | 75 | {% endif %} 76 | 77 |
78 | {% if can_admin_users %} 79 | {% if post_create_url %} 80 | 81 | {% endif %} 82 | {% if post_update_url %} 83 | 84 | {% endif %} 85 | {% if post_delete_url %} 86 |
87 | {{ csrf() }} 88 |
89 | 90 | {% endif %} 91 | {% endif %} 92 | {% endblock %} 93 | -------------------------------------------------------------------------------- /server/resources/views/pages/en/home.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'pages/en/base/with-header-and-footer.master.html.twig' %} 2 | 3 | {% block title %}{{ title }}{% endblock %} 4 | 5 | {% block content %} 6 |
7 |

Limoncello

8 | 9 |

Limoncello App is a fully featured OAuth 2.0 JSON API quick start application which supports

10 |
    11 |
  • JSON API CRUD operations (create, read, update and delete) for a few sample resources with to-one, to-many and many-to-many relationship types.
  • 12 |
  • Support for such JSON API features as resource inclusion, sparse field sets, sorting, filtering and pagination.
  • 13 |
  • Database migrations and seedings.
  • 14 |
  • OAuth 2.0 server authentication and role authorization.
  • 15 |
  • Cross-Origin Resource Sharing (CORS).
  • 16 |
  • JSON API errors.
  • 17 |
  • API tests.
  • 18 |
19 |

Supported features

20 |
    21 |
  • Multiple nested paths resource inclusion (e.g. posts,posts.user,posts.comments.user).
  • 22 |
  • Filtering and sorting by multiple attributes in resources and its relationships.
  • 23 |
  • Supported operators =, eq, equals, !=, neq, not-equals, <, lt, less-than, <=, lte, less-or-equals, >, gt, greater-than, >=, gte, greater-or-equals, like, not-like, in, not-in, is-null, not-null.
  • 24 |
  • Pagination works for main resources and resources in relationships. Limits for maximum number of resources are configurable.
  • 25 |
26 |

Server API documentation is here.

27 |

Thank you for supporting the project with star.

28 |

This project is open-source. Would you like to make the app nicer or improve the framework? Feel free to contact about the app here and the framework here.

29 |

Performance comparision with other frameworks could be found here and here.

30 |

Based on

31 | 42 |
43 | {% endblock %} 44 | -------------------------------------------------------------------------------- /server/app/Data/Seeds/PassportSeed.php: -------------------------------------------------------------------------------- 1 | getContainer(); 46 | $settings = $container->get(SettingsProviderInterface::class)->get(S::class); 47 | $integration = $container->get(PassportServerIntegrationInterface::class); 48 | 49 | // create OAuth scopes 50 | 51 | // scope ID => description (don't hesitate to add required for your application) 52 | $scopes = [ 53 | static::SCOPE_ADMIN_OAUTH => 'Can create, update and delete OAuth clients, redirect URIs and scopes.', 54 | static::SCOPE_ADMIN_USERS => 'Can create, update and delete users.', 55 | static::SCOPE_ADMIN_ROLES => 'Can create, update and delete roles.', 56 | static::SCOPE_VIEW_USERS => 'Can view users.', 57 | static::SCOPE_VIEW_ROLES => 'Can view roles.', 58 | ]; 59 | $scopeRepo = $integration->getScopeRepository(); 60 | foreach ($scopes as $scopeId => $scopeDescription) { 61 | $scopeRepo->create( 62 | (new Scope()) 63 | ->setIdentifier($scopeId) 64 | ->setDescription($scopeDescription) 65 | ); 66 | } 67 | 68 | // create OAuth clients 69 | 70 | $client = (new Client()) 71 | ->setIdentifier($settings[S::KEY_DEFAULT_CLIENT_ID]) 72 | ->setName($settings[S::KEY_DEFAULT_CLIENT_NAME]) 73 | ->setPublic() 74 | ->useDefaultScopesOnEmptyRequest() 75 | ->disableScopeExcess() 76 | ->enablePasswordGrant() 77 | ->disableCodeGrant() 78 | ->disableImplicitGrant() 79 | ->disableClientGrant() 80 | ->enableRefreshGrant() 81 | ->setScopeIdentifiers(array_keys($scopes)); 82 | 83 | $this->seedClient($integration, $client, [], $settings[S::KEY_DEFAULT_CLIENT_REDIRECT_URIS] ?? []); 84 | 85 | // assign scopes to roles 86 | 87 | $this->assignScopes(RolesSeed::ROLE_ADMIN, [ 88 | static::SCOPE_ADMIN_OAUTH, 89 | static::SCOPE_ADMIN_USERS, 90 | static::SCOPE_ADMIN_ROLES, 91 | static::SCOPE_VIEW_USERS, 92 | static::SCOPE_VIEW_ROLES, 93 | ]); 94 | 95 | $this->assignScopes(RolesSeed::ROLE_MODERATOR, [ 96 | static::SCOPE_ADMIN_USERS, 97 | static::SCOPE_VIEW_USERS, 98 | static::SCOPE_VIEW_ROLES, 99 | ]); 100 | 101 | $this->assignScopes(RolesSeed::ROLE_USER, [ 102 | static::SCOPE_VIEW_USERS, 103 | ]); 104 | } 105 | 106 | /** 107 | * @param string $roleId 108 | * @param string[] $scopeIds 109 | * 110 | * @return void 111 | * 112 | * @throws DBALException 113 | * @throws Exception 114 | */ 115 | private function assignScopes(string $roleId, array $scopeIds) 116 | { 117 | $now = $this->now(); 118 | foreach ($scopeIds as $scopeId) { 119 | $this->seedRowData(RoleScope::TABLE_NAME, [ 120 | RoleScope::FIELD_ID_ROLE => $roleId, 121 | RoleScope::FIELD_ID_SCOPE => $scopeId, 122 | RoleScope::FIELD_CREATED_AT => $now, 123 | ]); 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /server/tests/Json/RoleApiTest.php: -------------------------------------------------------------------------------- 1 | setPreventCommits(); 24 | 25 | $response = $this->get(self::API_URI, [], $this->getModeratorOAuthHeader()); 26 | $this->assertEquals(200, $response->getStatusCode()); 27 | 28 | $json = json_decode((string)$response->getBody()); 29 | $this->assertObjectHasAttribute('data', $json); 30 | $this->assertCount(3, $json->data); 31 | } 32 | 33 | /** 34 | * Test Role's API. 35 | */ 36 | public function testRead() 37 | { 38 | $this->setPreventCommits(); 39 | 40 | $roleId = RolesSeed::ROLE_USER; 41 | $response = $this->get(self::API_URI . "/$roleId", [], $this->getModeratorOAuthHeader()); 42 | $this->assertEquals(200, $response->getStatusCode()); 43 | 44 | $json = json_decode((string)$response->getBody()); 45 | $this->assertObjectHasAttribute('data', $json); 46 | $this->assertEquals($roleId, $json->data->id); 47 | $this->assertEquals(RoleSchema::TYPE, $json->data->type); 48 | } 49 | 50 | /** 51 | * Test Role's API. 52 | */ 53 | public function testReadRelationships() 54 | { 55 | $this->setPreventCommits(); 56 | 57 | $roleId = RolesSeed::ROLE_USER; 58 | $response = $this->get(self::API_URI . "/$roleId/users", [], $this->getModeratorOAuthHeader()); 59 | $this->assertEquals(200, $response->getStatusCode()); 60 | 61 | $json = json_decode((string)$response->getBody()); 62 | $this->assertObjectHasAttribute('data', $json); 63 | $this->assertCount(4, $json->data); 64 | } 65 | 66 | /** 67 | * Test Role's API. 68 | */ 69 | public function testCreate() 70 | { 71 | $this->setPreventCommits(); 72 | 73 | $description = "New role"; 74 | $jsonInput = <<getAdminOAuthHeader(); 86 | 87 | $response = $this->postJsonApi(self::API_URI, $jsonInput, $headers); 88 | $this->assertEquals(201, $response->getStatusCode()); 89 | 90 | $json = json_decode((string)$response->getBody()); 91 | $this->assertObjectHasAttribute('data', $json); 92 | $roleId = $json->data->id; 93 | 94 | // check role exists 95 | $this->assertEquals(200, $this->get(self::API_URI . "/$roleId", [], $headers)->getStatusCode()); 96 | 97 | // ... or make same check in the database 98 | $query = $this->getCapturedConnection()->createQueryBuilder(); 99 | $statement = $query 100 | ->select('*') 101 | ->from(Role::TABLE_NAME) 102 | ->where(Role::FIELD_ID . '=' . $query->createPositionalParameter($roleId)) 103 | ->execute(); 104 | $this->assertNotEmpty($statement->fetch()); 105 | } 106 | 107 | /** 108 | * Test Role's API. 109 | */ 110 | public function testUpdate() 111 | { 112 | $this->setPreventCommits(); 113 | 114 | $index = RolesSeed::ROLE_USER; 115 | $description = "New description"; 116 | $jsonInput = <<getAdminOAuthHeader(); 128 | 129 | $response = $this->patchJsonApi(self::API_URI . "/$index", $jsonInput, $headers); 130 | $this->assertEquals(200, $response->getStatusCode()); 131 | 132 | $json = json_decode((string)$response->getBody()); 133 | $this->assertObjectHasAttribute('data', $json); 134 | $this->assertEquals($index, $json->data->id); 135 | 136 | // check role exists 137 | $this->assertEquals(200, $this->get(self::API_URI . "/$index", [], $headers)->getStatusCode()); 138 | 139 | // ... or make same check in the database 140 | $query = $this->getCapturedConnection()->createQueryBuilder(); 141 | $statement = $query 142 | ->select('*') 143 | ->from(Role::TABLE_NAME) 144 | ->where(Role::FIELD_ID . '=' . $query->createPositionalParameter($index)) 145 | ->execute(); 146 | $this->assertNotEmpty($values = $statement->fetch()); 147 | $this->assertEquals($description, $values[Role::FIELD_DESCRIPTION]); 148 | $this->assertNotEmpty($values[Role::FIELD_UPDATED_AT]); 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /server/app/Authentication/OAuth.php: -------------------------------------------------------------------------------- 1 | get(Connection::class); 43 | 44 | $query = $connection->createQueryBuilder(); 45 | $query 46 | ->select([User::FIELD_ID, User::FIELD_EMAIL, User::FIELD_PASSWORD_HASH]) 47 | ->from(User::TABLE_NAME) 48 | ->where(User::FIELD_EMAIL . '=' . $query->createPositionalParameter($userName)) 49 | ->setMaxResults(1); 50 | $user = $query->execute()->fetch(PDO::FETCH_ASSOC); 51 | if ($user === false) { 52 | return null; 53 | } 54 | 55 | /** @var HasherInterface $hasher */ 56 | $hasher = $container->get(HasherInterface::class); 57 | if ($hasher->verify($password, $user[User::FIELD_PASSWORD_HASH]) === false) { 58 | return null; 59 | } 60 | 61 | return (int)$user[User::FIELD_ID]; 62 | } 63 | 64 | /** 65 | * @param ContainerInterface $container 66 | * @param int $userId 67 | * @param array|null $scope 68 | * 69 | * @return null|array 70 | * 71 | * @throws ContainerExceptionInterface 72 | * @throws NotFoundExceptionInterface 73 | */ 74 | public static function validateScope(ContainerInterface $container, int $userId, array $scope = null): ?array 75 | { 76 | // Here is the place you can implement your scope limitation for users. Such as 77 | // limiting scopes that could be assigned for the user token. 78 | // It could be role based system or any other system that suits your application. 79 | // 80 | // Possible return values: 81 | // - `null` means no scope changes for the user. 82 | // - `array` with scope identities. Token issued for the user will be limited to this scope. 83 | // - authorization exception if you want to stop token issuing process and notify the user 84 | // do not have enough rights to issue requested scopes. 85 | 86 | $result = null; 87 | if ($scope !== null) { 88 | /** @var UsersApi $usersApi */ 89 | /** @var FactoryInterface $factory */ 90 | $factory = $container->get(FactoryInterface::class); 91 | $usersApi = $factory->createApi(UsersApi::class); 92 | 93 | $userScopes = $usersApi->noAuthReadScopes($userId); 94 | $adjustedScope = array_intersect($userScopes, $scope); 95 | if (count($adjustedScope) !== count($scope)) { 96 | $result = $adjustedScope; 97 | } 98 | } 99 | 100 | return $result; 101 | } 102 | 103 | /** 104 | * @param ContainerInterface $container 105 | * @param TokenInterface $token 106 | * 107 | * @return array 108 | * 109 | * @throws ContainerExceptionInterface 110 | * @throws NotFoundExceptionInterface 111 | * @throws DBALException 112 | */ 113 | public static function getTokenCustomProperties(ContainerInterface $container, TokenInterface $token): array 114 | { 115 | $userId = $token->getUserIdentifier(); 116 | 117 | /** @var FactoryInterface $factory */ 118 | $factory = $container->get(FactoryInterface::class); 119 | /** @var UsersApi $usersApi */ 120 | $usersApi = $factory->createApi(UsersApi::class); 121 | 122 | $builder = $usersApi->shouldBeUntyped()->withIndexFilter($userId)->createIndexBuilder([ 123 | User::FIELD_EMAIL, 124 | User::FIELD_FIRST_NAME, 125 | User::FIELD_LAST_NAME, 126 | ]); 127 | 128 | $user = $usersApi->fetchRow($builder, User::class); 129 | 130 | return [ 131 | User::FIELD_ID => $userId, 132 | User::FIELD_EMAIL => $user[User::FIELD_EMAIL], 133 | User::FIELD_FIRST_NAME => $user[User::FIELD_FIRST_NAME], 134 | User::FIELD_LAST_NAME => $user[User::FIELD_LAST_NAME], 135 | ]; 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /server/tests/Web/HomeWebTest.php: -------------------------------------------------------------------------------- 1 | measureTime(function (): ResponseInterface { 34 | return $this->get(self::PAGE_URL); 35 | }, $time); 36 | 37 | $this->assertEquals(200, $response->getStatusCode()); 38 | $this->assertLessThan(1.0, $time, 'Our home page has become sloppy.'); 39 | } 40 | 41 | /** 42 | * Test valid sign-in. 43 | * 44 | * @return void 45 | * 46 | * @throws ContainerExceptionInterface 47 | * @throws NotFoundExceptionInterface 48 | */ 49 | public function testSignInWithValidCredentials(): void 50 | { 51 | $this->setPreventCommits(); 52 | 53 | // make CSRF protection check successful 54 | $this->passThroughCsrfOnNextAppCall(); 55 | 56 | $form = [ 57 | AuthController::FORM_EMAIL => $this->getUserEmail(), 58 | AuthController::FORM_PASSWORD => $this->getUserPassword(), 59 | AuthController::FORM_REMEMBER => 'on', 60 | 61 | CsrfSettings::DEFAULT_HTTP_REQUEST_CSRF_TOKEN_KEY => 'anything', 62 | ]; 63 | 64 | $this->captureFromNextAppCall(CookieJarInterface::class); 65 | 66 | $response = $this->post(self::SIGN_IN_URL, $form); 67 | 68 | /** @var CookieJarInterface $cookies */ 69 | $cookies = $this->getCapturedFromPreviousAppCall(CookieJarInterface::class); 70 | 71 | $this->assertEquals(302, $response->getStatusCode()); 72 | 73 | // check auth cookie was set 74 | $nowAsTimestamp = (new DateTime())->getTimestamp(); 75 | 76 | $this->assertTrue($cookies->has(CookieAuth::COOKIE_NAME)); 77 | $this->assertTrue($nowAsTimestamp < $cookies->get(CookieAuth::COOKIE_NAME)->getExpiresAtUnixTime()); 78 | } 79 | 80 | /** 81 | * Test invalid sign-in. 82 | * 83 | * @return void 84 | * 85 | * @throws ContainerExceptionInterface 86 | * @throws NotFoundExceptionInterface 87 | */ 88 | public function testSignInWithInvalidCredentials(): void 89 | { 90 | $this->setPreventCommits(); 91 | 92 | // make CSRF protection check successful 93 | $this->passThroughCsrfOnNextAppCall(); 94 | 95 | $form = [ 96 | AuthController::FORM_EMAIL => $this->getUserEmail(), 97 | AuthController::FORM_PASSWORD => $this->getUserPassword() . '-=#', // <- invalid password 98 | 99 | CsrfSettings::DEFAULT_HTTP_REQUEST_CSRF_TOKEN_KEY => 'anything', 100 | ]; 101 | 102 | $this->captureFromNextAppCall(CookieJarInterface::class); 103 | 104 | $response = $this->post(self::SIGN_IN_URL, $form); 105 | 106 | /** @var CookieJarInterface $cookies */ 107 | $cookies = $this->getCapturedFromPreviousAppCall(CookieJarInterface::class); 108 | 109 | $this->assertEquals(401, $response->getStatusCode()); 110 | 111 | // check auth cookie was not set 112 | 113 | $this->assertFalse($cookies->has(CookieAuth::COOKIE_NAME)); 114 | } 115 | 116 | /** 117 | * Test invalid sign-in. 118 | * 119 | * @return void 120 | * 121 | * @throws ContainerExceptionInterface 122 | * @throws NotFoundExceptionInterface 123 | */ 124 | public function testSignInWithInvalidInputs(): void 125 | { 126 | $this->setPreventCommits(); 127 | 128 | // make CSRF protection check successful 129 | $this->passThroughCsrfOnNextAppCall(); 130 | 131 | $form = [ 132 | AuthController::FORM_EMAIL => 'it-does-not-look-like-email', 133 | AuthController::FORM_PASSWORD => '123', // <- too short for a password 134 | 135 | CsrfSettings::DEFAULT_HTTP_REQUEST_CSRF_TOKEN_KEY => 'anything', 136 | ]; 137 | 138 | $this->captureFromNextAppCall(CookieJarInterface::class); 139 | 140 | $response = $this->post(self::SIGN_IN_URL, $form); 141 | 142 | /** @var CookieJarInterface $cookies */ 143 | $cookies = $this->getCapturedFromPreviousAppCall(CookieJarInterface::class); 144 | 145 | $this->assertEquals(422, $response->getStatusCode()); 146 | 147 | // check auth cookie was not set 148 | $this->assertFalse($cookies->has(CookieAuth::COOKIE_NAME)); 149 | } 150 | 151 | /** 152 | * Test logout page. 153 | * 154 | * @return void 155 | * 156 | * @throws ContainerExceptionInterface 157 | * @throws NotFoundExceptionInterface 158 | */ 159 | public function testLogout(): void 160 | { 161 | $this->captureFromNextAppCall(CookieJarInterface::class); 162 | 163 | $response = $this->get(self::SIGN_OUT_URL); 164 | 165 | /** @var CookieJarInterface $cookies */ 166 | $cookies = $this->getCapturedFromPreviousAppCall(CookieJarInterface::class); 167 | 168 | $this->assertEquals(302, $response->getStatusCode()); 169 | 170 | // check auth cookie was set 171 | $nowAsTimestamp = (new DateTime())->getTimestamp(); 172 | 173 | $this->assertTrue($cookies->has(CookieAuth::COOKIE_NAME)); 174 | $this->assertTrue($cookies->get(CookieAuth::COOKIE_NAME)->getExpiresAtUnixTime() < $nowAsTimestamp); 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /server/tests/Web/AuthWebTest.php: -------------------------------------------------------------------------------- 1 | captureFromNextAppCall(SessionInterface::class); 31 | 32 | $response = $this->get(self::SIGN_IN_URL); 33 | 34 | /** @var SessionInterface $session */ 35 | $session = $this->getCapturedFromPreviousAppCall(SessionInterface::class); 36 | 37 | $this->assertEquals(200, $response->getStatusCode()); 38 | 39 | // check CSRF token were added on page view. 40 | $csrfTokens = $session['csrf_tokens']; 41 | $this->assertNotEmpty($csrfTokens); 42 | } 43 | 44 | /** 45 | * Test valid sign-in. 46 | * 47 | * @return void 48 | * 49 | * @throws ContainerExceptionInterface 50 | * @throws NotFoundExceptionInterface 51 | */ 52 | public function testSignInWithValidCredentials(): void 53 | { 54 | $this->setPreventCommits(); 55 | 56 | // make CSRF protection check successful 57 | $this->passThroughCsrfOnNextAppCall(); 58 | 59 | $form = [ 60 | AuthController::FORM_EMAIL => $this->getUserEmail(), 61 | AuthController::FORM_PASSWORD => $this->getUserPassword(), 62 | AuthController::FORM_REMEMBER => 'on', 63 | 64 | CsrfSettings::DEFAULT_HTTP_REQUEST_CSRF_TOKEN_KEY => 'anything', 65 | ]; 66 | 67 | $this->captureFromNextAppCall(CookieJarInterface::class); 68 | 69 | $response = $this->post(self::SIGN_IN_URL, $form); 70 | $this->assertEquals(302, $response->getStatusCode()); 71 | 72 | /** @var CookieJarInterface $cookies */ 73 | $cookies =$this->getCapturedFromPreviousAppCall(CookieJarInterface::class); 74 | 75 | // check auth cookie was set 76 | $nowAsTimestamp = (new DateTime())->getTimestamp(); 77 | 78 | $this->assertTrue($cookies->has(CookieAuth::COOKIE_NAME)); 79 | $this->assertTrue($nowAsTimestamp < $cookies->get(CookieAuth::COOKIE_NAME)->getExpiresAtUnixTime()); 80 | } 81 | 82 | /** 83 | * Test invalid sign-in. 84 | * 85 | * @return void 86 | * 87 | * @throws ContainerExceptionInterface 88 | * @throws NotFoundExceptionInterface 89 | */ 90 | public function testSignInWithInvalidCredentials(): void 91 | { 92 | $this->setPreventCommits(); 93 | 94 | // make CSRF protection check successful 95 | $this->passThroughCsrfOnNextAppCall(); 96 | 97 | $form = [ 98 | AuthController::FORM_EMAIL => $this->getUserEmail(), 99 | AuthController::FORM_PASSWORD => $this->getUserPassword() . '-=#', // <- invalid password 100 | 101 | CsrfSettings::DEFAULT_HTTP_REQUEST_CSRF_TOKEN_KEY => 'anything', 102 | ]; 103 | 104 | $this->captureFromNextAppCall(CookieJarInterface::class); 105 | 106 | $response = $this->post(self::SIGN_IN_URL, $form); 107 | 108 | /** @var CookieJarInterface $cookies */ 109 | $cookies = $this->getCapturedFromPreviousAppCall(CookieJarInterface::class); 110 | 111 | $this->assertEquals(401, $response->getStatusCode()); 112 | $this->assertContains('Invalid email or password', (string)$response->getBody()); 113 | 114 | // check auth cookie was not set 115 | $this->assertFalse($cookies->has(CookieAuth::COOKIE_NAME)); 116 | } 117 | 118 | /** 119 | * Test invalid sign-in. 120 | * 121 | * @return void 122 | * 123 | * @throws ContainerExceptionInterface 124 | * @throws NotFoundExceptionInterface 125 | */ 126 | public function testSignInWithInvalidInputs(): void 127 | { 128 | $this->setPreventCommits(); 129 | 130 | // make CSRF protection check successful 131 | $this->passThroughCsrfOnNextAppCall(); 132 | 133 | $form = [ 134 | AuthController::FORM_EMAIL => 'it-does-not-look-like-email', 135 | AuthController::FORM_PASSWORD => '123', // <- too short for a password 136 | 137 | CsrfSettings::DEFAULT_HTTP_REQUEST_CSRF_TOKEN_KEY => 'anything', 138 | ]; 139 | 140 | $this->captureFromNextAppCall(CookieJarInterface::class); 141 | 142 | $response = $this->post(self::SIGN_IN_URL, $form); 143 | 144 | /** @var CookieJarInterface $cookies */ 145 | $cookies = $this->getCapturedFromPreviousAppCall(CookieJarInterface::class); 146 | 147 | $this->assertEquals(422, $response->getStatusCode()); 148 | 149 | // check auth cookie was not set 150 | $this->assertFalse($cookies->has(CookieAuth::COOKIE_NAME)); 151 | } 152 | 153 | /** 154 | * Test logout page. 155 | * 156 | * @return void 157 | * 158 | * @throws ContainerExceptionInterface 159 | * @throws NotFoundExceptionInterface 160 | */ 161 | public function testLogout(): void 162 | { 163 | $this->captureFromNextAppCall(CookieJarInterface::class); 164 | 165 | $response = $this->get(self::SIGN_OUT_URL); 166 | 167 | /** @var CookieJarInterface $cookies */ 168 | $cookies = $this->getCapturedFromPreviousAppCall(CookieJarInterface::class); 169 | 170 | $this->assertEquals(302, $response->getStatusCode()); 171 | 172 | // check auth cookie was set 173 | $nowAsTimestamp = (new DateTime())->getTimestamp(); 174 | $this->assertTrue($cookies->has(CookieAuth::COOKIE_NAME)); 175 | $this->assertTrue($cookies->get(CookieAuth::COOKIE_NAME)->getExpiresAtUnixTime() < $nowAsTimestamp); 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /server/app/Api/UsersApi.php: -------------------------------------------------------------------------------- 1 | authorize(UserRules::ACTION_MANAGE_USERS, Schema::TYPE); 41 | 42 | return parent::create($index, $this->getReplacePasswordWithHash($attributes), $toMany); 43 | } 44 | 45 | /** 46 | * @inheritdoc 47 | * 48 | * @throws AuthorizationExceptionInterface 49 | */ 50 | public function update(string $index, array $attributes, array $toMany): int 51 | { 52 | $this->authorize(UserRules::ACTION_MANAGE_USERS, Schema::TYPE, $index); 53 | 54 | return parent::update($index, $this->getReplacePasswordWithHash($attributes), $toMany); 55 | } 56 | 57 | /** 58 | * @inheritdoc 59 | * 60 | * @throws AuthorizationExceptionInterface 61 | */ 62 | public function remove(string $index): bool 63 | { 64 | $this->authorize(UserRules::ACTION_MANAGE_USERS, Schema::TYPE, $index); 65 | 66 | return parent::remove($index); 67 | } 68 | 69 | /** 70 | * @inheritdoc 71 | * 72 | * @throws AuthorizationExceptionInterface 73 | */ 74 | public function index(): PaginatedDataInterface 75 | { 76 | $this->authorize(UserRules::ACTION_VIEW_USERS, Schema::TYPE); 77 | 78 | return parent::index(); 79 | } 80 | 81 | /** 82 | * @inheritdoc 83 | * 84 | * @throws AuthorizationExceptionInterface 85 | */ 86 | public function read(string $index) 87 | { 88 | $this->authorize(UserRules::ACTION_VIEW_USERS, Schema::TYPE, $index); 89 | 90 | return parent::read($index); 91 | } 92 | 93 | /** 94 | * @param int $userId 95 | * 96 | * @return array 97 | * 98 | * @throws ContainerExceptionInterface 99 | * @throws NotFoundExceptionInterface 100 | */ 101 | public function noAuthReadScopes(int $userId): array 102 | { 103 | /** @var Connection $connection */ 104 | $connection = $this->getContainer()->get(Connection::class); 105 | $query = $connection->createQueryBuilder(); 106 | $users = 'u'; 107 | $uRoleId = Model::FIELD_ID_ROLE; 108 | $rolesScopes = 'rs'; 109 | $rsRoleId = RoleScope::FIELD_ID_ROLE; 110 | $query 111 | ->select(RoleScope::FIELD_ID_SCOPE) 112 | ->from(Model::TABLE_NAME, $users) 113 | ->leftJoin($users, RoleScope::TABLE_NAME, $rolesScopes, "$users.$uRoleId = $rolesScopes.$rsRoleId") 114 | ->where(Model::FIELD_ID . '=' . $query->createPositionalParameter($userId, PDO::PARAM_INT)); 115 | 116 | $scopes = array_column($query->execute()->fetchAll(), RoleScope::FIELD_ID_SCOPE); 117 | 118 | return $scopes; 119 | } 120 | 121 | /** 122 | * @param string $email 123 | * 124 | * @return int|null 125 | */ 126 | public function noAuthReadUserIdByEmail(string $email): ?int 127 | { 128 | /** @var Connection $connection */ 129 | $connection = $this->getContainer()->get(Connection::class); 130 | $query = $connection->createQueryBuilder(); 131 | $query 132 | ->select(Model::FIELD_ID) 133 | ->from(Model::TABLE_NAME) 134 | ->where(Model::FIELD_EMAIL . '=' . $query->createPositionalParameter($email)) 135 | ->setMaxResults(1); 136 | $statement = $query->execute(); 137 | $idOrFalse = $statement->fetchColumn(); 138 | $userId = $idOrFalse === false ? null : (int)$idOrFalse; 139 | 140 | return $userId; 141 | } 142 | 143 | /** 144 | * @param int $userId 145 | * @param string $newPassword 146 | * 147 | * @return bool 148 | */ 149 | public function noAuthResetPassword(int $userId, string $newPassword): bool 150 | { 151 | $hash = $this->createHasher()->hash($newPassword); 152 | 153 | try { 154 | $changed = parent::update($userId, [Model::FIELD_PASSWORD_HASH => $hash], []); 155 | 156 | return $changed > 0; 157 | } catch (DBALException $exception) { 158 | return false; 159 | } 160 | } 161 | 162 | /** 163 | * @param array $attributes 164 | * 165 | * @return array 166 | * 167 | * @throws ContainerExceptionInterface 168 | * @throws NotFoundExceptionInterface 169 | */ 170 | private function getReplacePasswordWithHash(array $attributes): array 171 | { 172 | // in attributes were captured validated input password we need to convert it into password hash 173 | if (array_key_exists(Schema::CAPTURE_NAME_PASSWORD, $attributes) === true) { 174 | $attributes[Model::FIELD_PASSWORD_HASH] = 175 | $this->createHasher()->hash($attributes[Schema::CAPTURE_NAME_PASSWORD]); 176 | } 177 | 178 | return $attributes; 179 | } 180 | 181 | /** 182 | * @return HasherInterface 183 | */ 184 | private function createHasher(): HasherInterface 185 | { 186 | return $this->getContainer()->get(HasherInterface::class); 187 | } 188 | } 189 | --------------------------------------------------------------------------------