Merge pull request #190 from goteo/feat/oauth
feat: OAuth2 server integration
This commit is contained in:
@@ -61,3 +61,10 @@ CURRENCY_DEFAULT="EUR"
|
||||
# postgresql+advisory://db_user:db_password@localhost/db_name
|
||||
LOCK_DSN=flock
|
||||
###< symfony/lock ###
|
||||
|
||||
###> league/oauth2-server-bundle ###
|
||||
OAUTH_PRIVATE_KEY=%kernel.project_dir%/config/jwt/private.pem
|
||||
OAUTH_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
|
||||
OAUTH_PASSPHRASE=df7ec1f514c44825926e3dabaf34e3f5
|
||||
OAUTH_ENCRYPTION_KEY=325b6ce98c009bb83e885657ec08d142
|
||||
###< league/oauth2-server-bundle ###
|
||||
|
||||
+11
-1
@@ -32,4 +32,14 @@
|
||||
###< doctrineencryptbundle/doctrine-encrypt-bundle
|
||||
|
||||
.php-version
|
||||
*.sql
|
||||
*.sql
|
||||
###> league/oauth2-server-bundle ###
|
||||
/config/jwt/*.pem
|
||||
###< league/oauth2-server-bundle ###
|
||||
|
||||
###> symfony/webpack-encore-bundle ###
|
||||
/node_modules/
|
||||
/public/build/
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
###< symfony/webpack-encore-bundle ###
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import './bootstrap.js';
|
||||
/*
|
||||
* Welcome to your app's main JavaScript file!
|
||||
*
|
||||
* We recommend including the built version of this JavaScript file
|
||||
* (and its CSS file) in your base layout (base.html.twig).
|
||||
*/
|
||||
|
||||
// any CSS you import will output into a single css file (app.css in this case)
|
||||
import './styles/app.css';
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
import { startStimulusApp } from '@symfony/stimulus-bridge';
|
||||
|
||||
// Registers Stimulus controllers from controllers.json and in the controllers/ directory
|
||||
export const app = startStimulusApp(require.context(
|
||||
'@symfony/stimulus-bridge/lazy-controller-loader!./controllers',
|
||||
true,
|
||||
/\.[jt]sx?$/
|
||||
));
|
||||
// register any custom, 3rd party controllers here
|
||||
// app.register('some_controller_name', SomeImportedController);
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"controllers": [],
|
||||
"entrypoints": []
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Controller } from '@hotwired/stimulus';
|
||||
|
||||
/*
|
||||
* This is an example Stimulus controller!
|
||||
*
|
||||
* Any element with a data-controller="hello" attribute will cause
|
||||
* this controller to be executed. The name "hello" comes from the filename:
|
||||
* hello_controller.js -> "hello"
|
||||
*
|
||||
* Delete this file or adapt it for your use!
|
||||
*/
|
||||
export default class extends Controller {
|
||||
connect() {
|
||||
this.element.textContent = 'Hello Stimulus! Edit me in assets/controllers/hello_controller.js';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
@import "tailwindcss";
|
||||
@import "@fontsource/karla";
|
||||
|
||||
@source not "../../public";
|
||||
|
||||
@theme {
|
||||
/* Light Theme (default) */
|
||||
--color-primary: #59e9d3;
|
||||
--color-primary-hover: #4dd0b8;
|
||||
--color-secondary: #462949;
|
||||
--color-tertiary: #e94668;
|
||||
--color-content: #575757;
|
||||
--color-body: #1e293b;
|
||||
|
||||
/* Extended color tokens for components */
|
||||
--color-white: #fbfbfb;
|
||||
--color-black: #3d3d3d;
|
||||
--color-grey: #f3f3ef;
|
||||
--color-gray-light: #f3f3f3;
|
||||
/* Light gray for borders */
|
||||
--color-light-accent: #e6d9f3;
|
||||
/* Light purple accent for buttons */
|
||||
--color-soft-purple: #faf9ff;
|
||||
--color-light-pink: #ff56d5;
|
||||
/* Platform project update cards color */
|
||||
--color-neutral-light: #e6e6e6;
|
||||
/* Light neutral border */
|
||||
--color-divider: #f1efff;
|
||||
/* Budget bar divider color */
|
||||
|
||||
--color-variant1: #e6e5f7;
|
||||
--color-variant2: #99ffcc;
|
||||
--color-variant3: #72d8ff;
|
||||
--color-variant4: #ffff99;
|
||||
|
||||
--color-semantic-error: #e9b1bd;
|
||||
--color-semantic-success: #b1e9e1;
|
||||
--color-semantic-notification: #c2eeff;
|
||||
--color-semantic-warning: #ffffc2;
|
||||
|
||||
--font-body: "Karla", sans-serif;
|
||||
--body-regular: 1rem;
|
||||
--body-small: 0.875rem;
|
||||
}
|
||||
+5
-2
@@ -20,9 +20,9 @@
|
||||
"embed/embed": "^4.4",
|
||||
"gedmo/doctrine-extensions": "3.20",
|
||||
"goteo/benzina": "^0.5",
|
||||
"jolicode/automapper": "^9.2",
|
||||
"jolicode/automapper": "9.2.1",
|
||||
"league/oauth2-server-bundle": "^1.1",
|
||||
"nelmio/cors-bundle": "^2.3",
|
||||
"nyholm/psr7": "*",
|
||||
"php-http/discovery": "^1.20",
|
||||
"phpdocumentor/reflection-docblock": "^5.3",
|
||||
"phpstan/phpdoc-parser": "^1.24",
|
||||
@@ -48,8 +48,11 @@
|
||||
"symfony/runtime": "6.4.*",
|
||||
"symfony/security-bundle": "6.4.*",
|
||||
"symfony/serializer": "6.4.*",
|
||||
"symfony/stimulus-bundle": "^2.32",
|
||||
"symfony/translation": "6.4.*",
|
||||
"symfony/twig-bundle": "6.4.*",
|
||||
"symfony/validator": "6.4.*",
|
||||
"symfony/webpack-encore-bundle": "^2.4",
|
||||
"symfony/yaml": "6.4.*",
|
||||
"zenstruck/foundry": "^2.4"
|
||||
},
|
||||
|
||||
Generated
+1612
-449
File diff suppressed because it is too large
Load Diff
@@ -15,4 +15,7 @@ return [
|
||||
AutoMapper\Symfony\Bundle\AutoMapperBundle::class => ['all' => true],
|
||||
Goteo\Benzina\BenzinaBundle::class => ['all' => true],
|
||||
Zenstruck\Foundry\ZenstruckFoundryBundle::class => ['dev' => true, 'test' => true],
|
||||
League\Bundle\OAuth2ServerBundle\LeagueOAuth2ServerBundle::class => ['all' => true],
|
||||
Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true],
|
||||
Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true],
|
||||
];
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
league_oauth2_server:
|
||||
authorization_server:
|
||||
private_key: '%env(resolve:OAUTH_PRIVATE_KEY)%'
|
||||
private_key_passphrase: '%env(resolve:OAUTH_PASSPHRASE)%'
|
||||
encryption_key: '%env(resolve:OAUTH_ENCRYPTION_KEY)%'
|
||||
resource_server:
|
||||
public_key: '%env(resolve:OAUTH_PUBLIC_KEY)%'
|
||||
scopes:
|
||||
available: ['email']
|
||||
default: ['email']
|
||||
persistence:
|
||||
doctrine: null
|
||||
|
||||
when@test:
|
||||
league_oauth2_server:
|
||||
persistence:
|
||||
in_memory: null
|
||||
@@ -0,0 +1,11 @@
|
||||
services:
|
||||
# Register nyholm/psr7 services for autowiring with PSR-17 (HTTP factories)
|
||||
Psr\Http\Message\RequestFactoryInterface: '@nyholm.psr7.psr17_factory'
|
||||
Psr\Http\Message\ResponseFactoryInterface: '@nyholm.psr7.psr17_factory'
|
||||
Psr\Http\Message\ServerRequestFactoryInterface: '@nyholm.psr7.psr17_factory'
|
||||
Psr\Http\Message\StreamFactoryInterface: '@nyholm.psr7.psr17_factory'
|
||||
Psr\Http\Message\UploadedFileFactoryInterface: '@nyholm.psr7.psr17_factory'
|
||||
Psr\Http\Message\UriFactoryInterface: '@nyholm.psr7.psr17_factory'
|
||||
|
||||
nyholm.psr7.psr17_factory:
|
||||
class: Nyholm\Psr7\Factory\Psr17Factory
|
||||
@@ -4,7 +4,7 @@ security:
|
||||
legacy:
|
||||
id: 'App\Security\Hasher\LegacyPasswordHasher'
|
||||
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
|
||||
algorithm: 'auto'
|
||||
algorithm: "auto"
|
||||
migrate_from:
|
||||
- legacy
|
||||
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
|
||||
@@ -18,15 +18,23 @@ security:
|
||||
dev:
|
||||
pattern: ^/(_(profiler|wdt)|css|images|js)/
|
||||
security: false
|
||||
main:
|
||||
lazy: true
|
||||
api:
|
||||
pattern: ^/v4
|
||||
security: true
|
||||
stateless: true
|
||||
provider: app_user_provider
|
||||
|
||||
access_token:
|
||||
token_handler: App\Security\AccessTokenHandler
|
||||
custom_authenticators:
|
||||
- App\Security\CookieAuthenticator
|
||||
oauth2: true
|
||||
main:
|
||||
form_login:
|
||||
login_path: app_login
|
||||
check_path: app_login
|
||||
enable_csrf: true
|
||||
remember_me:
|
||||
secret: '%kernel.secret%'
|
||||
lifetime: 604800
|
||||
logout:
|
||||
path: app_logout
|
||||
# where to redirect after logout
|
||||
# target: app_any_route
|
||||
|
||||
# activate different ways to authenticate
|
||||
# https://symfony.com/doc/current/security.html#the-firewall
|
||||
@@ -37,11 +45,18 @@ security:
|
||||
# Easy way to control access for large sections of your site
|
||||
# Note: Only the *first* access control that matches will be used
|
||||
access_control:
|
||||
- { path: ^/oauth/authorize, roles: IS_AUTHENTICATED_REMEMBERED }
|
||||
- { path: ^/security/consent, roles: IS_AUTHENTICATED_REMEMBERED }
|
||||
# - { path: ^/admin, roles: ROLE_ADMIN }
|
||||
# - { path: ^/profile, roles: ROLE_USER }
|
||||
|
||||
when@test:
|
||||
security:
|
||||
firewalls:
|
||||
api:
|
||||
oauth2: false
|
||||
custom_authenticators:
|
||||
- App\Security\TestingAuthenticator
|
||||
password_hashers:
|
||||
# By default, password hashers are resource intensive and take time. This is
|
||||
# important to generate secure password hashes. In tests however, secure hashes
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
framework:
|
||||
default_locale: en
|
||||
translator:
|
||||
default_path: '%kernel.project_dir%/translations'
|
||||
providers:
|
||||
@@ -0,0 +1,45 @@
|
||||
webpack_encore:
|
||||
# The path where Encore is building the assets - i.e. Encore.setOutputPath()
|
||||
output_path: '%kernel.project_dir%/public/build'
|
||||
# If multiple builds are defined (as shown below), you can disable the default build:
|
||||
# output_path: false
|
||||
|
||||
# Set attributes that will be rendered on all script and link tags
|
||||
script_attributes:
|
||||
defer: true
|
||||
# Uncomment (also under link_attributes) if using Turbo Drive
|
||||
# https://turbo.hotwired.dev/handbook/drive#reloading-when-assets-change
|
||||
# 'data-turbo-track': reload
|
||||
# link_attributes:
|
||||
# Uncomment if using Turbo Drive
|
||||
# 'data-turbo-track': reload
|
||||
|
||||
# If using Encore.enableIntegrityHashes() and need the crossorigin attribute (default: false, or use 'anonymous' or 'use-credentials')
|
||||
# crossorigin: 'anonymous'
|
||||
|
||||
# Preload all rendered script and link tags automatically via the HTTP/2 Link header
|
||||
# preload: true
|
||||
|
||||
# Throw an exception if the entrypoints.json file is missing or an entry is missing from the data
|
||||
# strict_mode: false
|
||||
|
||||
# If you have multiple builds:
|
||||
# builds:
|
||||
# frontend: '%kernel.project_dir%/public/frontend/build'
|
||||
|
||||
# pass the build name as the 3rd argument to the Twig functions
|
||||
# {{ encore_entry_script_tags('entry1', null, 'frontend') }}
|
||||
|
||||
framework:
|
||||
assets:
|
||||
json_manifest_path: '%kernel.project_dir%/public/build/manifest.json'
|
||||
|
||||
#when@prod:
|
||||
# webpack_encore:
|
||||
# # Cache the entrypoints.json (rebuild Symfony's cache when entrypoints.json changes)
|
||||
# # Available in version 1.2
|
||||
# cache: true
|
||||
|
||||
#when@test:
|
||||
# webpack_encore:
|
||||
# strict_mode: false
|
||||
@@ -0,0 +1,4 @@
|
||||
league_oauth2_server:
|
||||
resource: '@LeagueOAuth2ServerBundle/config/routes.php'
|
||||
type: php
|
||||
prefix: /oauth
|
||||
@@ -75,9 +75,9 @@ services:
|
||||
arguments:
|
||||
- !tagged 'app.matchfunding.rule'
|
||||
|
||||
App\Service\Auth\AuthService:
|
||||
App\Security\TestingAuthenticator:
|
||||
arguments:
|
||||
- '%env(APP_SECRET)%'
|
||||
- '%env(APP_ENV)%'
|
||||
|
||||
App\Service\LocalizationService:
|
||||
arguments:
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260209144151 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'OAuth2 integration via thephpleague/oauth2-server-bundle';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE oauth2_access_token (identifier CHAR(80) NOT NULL, client VARCHAR(32) NOT NULL, expiry DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', user_identifier VARCHAR(128) DEFAULT NULL, scopes TEXT DEFAULT NULL COMMENT \'(DC2Type:oauth2_scope)\', revoked TINYINT(1) NOT NULL, INDEX IDX_454D9673C7440455 (client), PRIMARY KEY(identifier)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
|
||||
$this->addSql('CREATE TABLE oauth2_authorization_code (identifier CHAR(80) NOT NULL, client VARCHAR(32) NOT NULL, expiry DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', user_identifier VARCHAR(128) DEFAULT NULL, scopes TEXT DEFAULT NULL COMMENT \'(DC2Type:oauth2_scope)\', revoked TINYINT(1) NOT NULL, INDEX IDX_509FEF5FC7440455 (client), PRIMARY KEY(identifier)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
|
||||
$this->addSql('CREATE TABLE oauth2_client (identifier VARCHAR(32) NOT NULL, name VARCHAR(128) NOT NULL, secret VARCHAR(128) DEFAULT NULL, redirect_uris TEXT DEFAULT NULL COMMENT \'(DC2Type:oauth2_redirect_uri)\', grants TEXT DEFAULT NULL COMMENT \'(DC2Type:oauth2_grant)\', scopes TEXT DEFAULT NULL COMMENT \'(DC2Type:oauth2_scope)\', active TINYINT(1) NOT NULL, allow_plain_text_pkce TINYINT(1) DEFAULT 0 NOT NULL, PRIMARY KEY(identifier)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
|
||||
$this->addSql('CREATE TABLE oauth2_refresh_token (identifier CHAR(80) NOT NULL, access_token CHAR(80) DEFAULT NULL, expiry DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', revoked TINYINT(1) NOT NULL, INDEX IDX_4DD90732B6A2DD68 (access_token), PRIMARY KEY(identifier)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
|
||||
$this->addSql('ALTER TABLE oauth2_access_token ADD CONSTRAINT FK_454D9673C7440455 FOREIGN KEY (client) REFERENCES oauth2_client (identifier) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE oauth2_authorization_code ADD CONSTRAINT FK_509FEF5FC7440455 FOREIGN KEY (client) REFERENCES oauth2_client (identifier) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE oauth2_refresh_token ADD CONSTRAINT FK_4DD90732B6A2DD68 FOREIGN KEY (access_token) REFERENCES oauth2_access_token (identifier) ON DELETE SET NULL');
|
||||
$this->addSql('ALTER TABLE project CHANGE video_src video_src LONGTEXT DEFAULT NULL, CHANGE video_thumbnail video_thumbnail LONGTEXT DEFAULT NULL, CHANGE video_cover video_cover LONGTEXT DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE oauth2_access_token DROP FOREIGN KEY FK_454D9673C7440455');
|
||||
$this->addSql('ALTER TABLE oauth2_authorization_code DROP FOREIGN KEY FK_509FEF5FC7440455');
|
||||
$this->addSql('ALTER TABLE oauth2_refresh_token DROP FOREIGN KEY FK_4DD90732B6A2DD68');
|
||||
$this->addSql('DROP TABLE oauth2_access_token');
|
||||
$this->addSql('DROP TABLE oauth2_authorization_code');
|
||||
$this->addSql('DROP TABLE oauth2_client');
|
||||
$this->addSql('DROP TABLE oauth2_refresh_token');
|
||||
$this->addSql('ALTER TABLE project CHANGE video_src video_src TEXT DEFAULT NULL, CHANGE video_cover video_cover TEXT DEFAULT NULL, CHANGE video_thumbnail video_thumbnail TEXT DEFAULT NULL');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260224155112 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Removed UserToken entity';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE user_token DROP FOREIGN KEY FK_BDF55A637E3C61F9');
|
||||
$this->addSql('DROP TABLE user_token');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE user_token (id INT AUTO_INCREMENT NOT NULL, owner_id INT NOT NULL, token VARCHAR(68) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`, date_created DATETIME NOT NULL, INDEX IDX_BDF55A637E3C61F9 (owner_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB COMMENT = \'\' ');
|
||||
$this->addSql('ALTER TABLE user_token ADD CONSTRAINT FK_BDF55A637E3C61F9 FOREIGN KEY (owner_id) REFERENCES user (id)');
|
||||
}
|
||||
}
|
||||
Generated
+6382
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.17.0",
|
||||
"@babel/preset-env": "^7.16.0",
|
||||
"@hotwired/stimulus": "^3.0.0",
|
||||
"@symfony/stimulus-bridge": "^3.2.0 || ^4.0.0",
|
||||
"@symfony/webpack-encore": "^5.1.0",
|
||||
"core-js": "^3.38.0",
|
||||
"regenerator-runtime": "^0.13.9",
|
||||
"webpack": "^5.74.0",
|
||||
"webpack-cli": "^5.1.0"
|
||||
},
|
||||
"license": "UNLICENSED",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev-server": "encore dev-server",
|
||||
"dev": "encore dev",
|
||||
"watch": "encore dev --watch",
|
||||
"build": "encore production --progress"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/karla": "^5.2.8",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"postcss": "^8.5.8",
|
||||
"postcss-loader": "^8.2.1",
|
||||
"tailwindcss": "^4.2.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
@@ -38,7 +38,6 @@ class ChargeApiResource
|
||||
/**
|
||||
* The Checkout to which this Charge item belongs to.
|
||||
*/
|
||||
#[API\ApiProperty(writable: false, security: 'is_granted("IS_AUTHENTICATED_FULLY")')]
|
||||
#[API\ApiFilter(GatewayFilter::class, properties: ['checkout.gateway'])]
|
||||
#[API\ApiFilter(Filter\SearchFilter::class, properties: ['checkout.trackings.value'])]
|
||||
public CheckoutApiResource $checkout;
|
||||
@@ -70,6 +69,7 @@ class ChargeApiResource
|
||||
* The Accounting receiving the money after a successful payment.
|
||||
*/
|
||||
#[Assert\NotBlank()]
|
||||
#[API\ApiProperty(security: 'is_granted("ACCOUNTING_VIEW", object.target)')]
|
||||
#[API\ApiFilter(Filter\SearchFilter::class, strategy: 'exact')]
|
||||
public AccountingApiResource $target;
|
||||
|
||||
|
||||
@@ -27,9 +27,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
provider: ApiResourceStateProvider::class,
|
||||
processor: CheckoutStateProcessor::class,
|
||||
)]
|
||||
#[API\GetCollection(
|
||||
security: 'is_granted("IS_AUTHENTICATED_FULLY")'
|
||||
)]
|
||||
#[API\GetCollection()]
|
||||
#[API\Post()]
|
||||
#[API\Get()]
|
||||
#[API\Patch(
|
||||
@@ -52,6 +50,7 @@ class CheckoutApiResource
|
||||
/**
|
||||
* The Accounting paying for the charges.
|
||||
*/
|
||||
#[API\ApiProperty(security: 'is_granted("ACCOUNTING_VIEW", object.origin)')]
|
||||
#[Assert\NotBlank()]
|
||||
public AccountingApiResource $origin;
|
||||
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\ApiResource\User;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\State\Options;
|
||||
use ApiPlatform\Metadata as API;
|
||||
use App\Dto\UserTokenLoginDto;
|
||||
use App\Entity\User\UserToken;
|
||||
use App\State\ApiResourceStateProcessor;
|
||||
use App\State\ApiResourceStateProvider;
|
||||
use App\State\User\UserTokenLoginProcessor;
|
||||
|
||||
/**
|
||||
* UserTokens authenticate requests on behalf of the User who owns them.\
|
||||
* \
|
||||
* When a UserToken is created v4 generates a SHA-256 hash that is unique for each UserToken.
|
||||
* The value of a UserToken comes preceded by a 4-digit-length prefix based on the type of token it is.
|
||||
*/
|
||||
#[API\ApiResource(
|
||||
shortName: 'UserToken',
|
||||
stateOptions: new Options(entityClass: UserToken::class),
|
||||
provider: ApiResourceStateProvider::class,
|
||||
processor: ApiResourceStateProcessor::class,
|
||||
)]
|
||||
#[API\Post(input: UserTokenLoginDto::class, processor: UserTokenLoginProcessor::class)]
|
||||
#[API\Get(security: 'is_granted("USER_OWNED", object)')]
|
||||
#[API\Delete(security: 'is_granted("USER_OWNED", object)')]
|
||||
class UserTokenApiResource
|
||||
{
|
||||
#[API\ApiProperty(writable: false, identifier: true)]
|
||||
public int $id;
|
||||
|
||||
/**
|
||||
* The User token itself.
|
||||
*/
|
||||
#[API\ApiProperty(writable: false)]
|
||||
public string $token;
|
||||
|
||||
/**
|
||||
* The User who this token grants access as.
|
||||
*/
|
||||
#[API\ApiProperty(writable: false)]
|
||||
public UserApiResource $owner;
|
||||
}
|
||||
@@ -11,25 +11,25 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
/**
|
||||
* The `GatewaysController` exposes the handler routes for responding to network requests sent by external gateways.
|
||||
*/
|
||||
#[Route('/v4/controllers')]
|
||||
#[Route('/gateways')]
|
||||
class GatewaysController extends AbstractController
|
||||
{
|
||||
public const REDIRECT = 'gateway_controller.redirect';
|
||||
public const WEBHOOKS = 'gateway_controller.webhooks';
|
||||
public const REDIRECT = 'gateways_controller.redirect';
|
||||
public const WEBHOOKS = 'gateways_controller.webhooks';
|
||||
|
||||
public function __construct(
|
||||
private GatewayLocator $gatewayLocator,
|
||||
) {}
|
||||
|
||||
#[Route('/gateway_redirects', name: self::REDIRECT)]
|
||||
public function handleRedirect(Request $request): Response
|
||||
#[Route('/redirect', name: self::REDIRECT)]
|
||||
public function handleRedirect(Request $request, string $gateway): Response
|
||||
{
|
||||
$gateway = $this->gatewayLocator->get($request->query->get('gateway'));
|
||||
$gateway = $this->gatewayLocator->get($gateway);
|
||||
|
||||
return $gateway->handleRedirect($request);
|
||||
}
|
||||
|
||||
#[Route('/gateway_webhooks/{gateway}', name: self::WEBHOOKS)]
|
||||
#[Route('/webhook/{gateway}', name: self::WEBHOOKS)]
|
||||
public function handleWebhook(Request $request, string $gateway): Response
|
||||
{
|
||||
$handler = $this->gatewayLocator->get($gateway);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\EventListener\LeaugeOAuth2AuthorizationListener;
|
||||
use League\Bundle\OAuth2ServerBundle\Manager\ClientManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
|
||||
|
||||
#[Route('/security')]
|
||||
class SecurityController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private ClientManagerInterface $clientManager,
|
||||
) {}
|
||||
|
||||
#[Route(path: '/login', name: 'app_login')]
|
||||
public function login(AuthenticationUtils $authenticationUtils): Response
|
||||
{
|
||||
// get the login error if there is one
|
||||
$error = $authenticationUtils->getLastAuthenticationError();
|
||||
|
||||
// last username entered by the user
|
||||
$lastUsername = $authenticationUtils->getLastUsername();
|
||||
|
||||
return $this->render('security/login.html.twig', [
|
||||
'last_username' => $lastUsername,
|
||||
'error' => $error,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route(path: '/logout', name: 'app_logout')]
|
||||
public function logout(): void
|
||||
{
|
||||
throw new \LogicException('This method can be blank - it will be intercepted by the logout key on your firewall.');
|
||||
}
|
||||
|
||||
#[Route(path: '/consent', name: 'app_consent')]
|
||||
public function consent(Request $request): Response
|
||||
{
|
||||
if ($request->getMethod() === Request::METHOD_POST) {
|
||||
$request->getSession()->set(
|
||||
LeaugeOAuth2AuthorizationListener::AUTHORIZATION_RESULT,
|
||||
$request->get('_consent') ?? false
|
||||
);
|
||||
|
||||
return $this->redirectToRoute('oauth2_authorize', $request->query->all());
|
||||
}
|
||||
|
||||
$client = $this->clientManager->find($request->query->get('client_id'));
|
||||
|
||||
return $this->render('security/consent.html.twig', [
|
||||
'client_app' => $client->getName(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Doctrine;
|
||||
|
||||
use App\Entity\Gateway\Checkout;
|
||||
use App\Security\Trait\UserTrait;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
final class GatewayCheckoutExtension extends AbstractQueryResourceExtensionInterface
|
||||
{
|
||||
use UserTrait;
|
||||
|
||||
protected function supports(string $resourceClass): bool
|
||||
{
|
||||
return $resourceClass === Checkout::class;
|
||||
}
|
||||
|
||||
protected function applyFilters(QueryBuilder $queryBuilder, string $rootAlias): void
|
||||
{
|
||||
$user = $this->getAuthenticatedUser();
|
||||
|
||||
if ($this->isAdmin($user)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$queryBuilder
|
||||
->leftJoin("$rootAlias.origin", 'co')
|
||||
->leftJoin('co.user', 'u')
|
||||
->andWhere('u.id = :userId')
|
||||
->setParameter('userId', $user->getId());
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Dto;
|
||||
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* UserTokens are created via a login flow where v4:
|
||||
* 1. Receives the User credentials
|
||||
* 2. Validates them
|
||||
* 3. Generates the UserToken for the authenticated User
|
||||
*/
|
||||
final class UserTokenLoginDto
|
||||
{
|
||||
/**
|
||||
* The identifier (email, handle) of the User to be authenticated.
|
||||
*/
|
||||
#[Assert\NotBlank()]
|
||||
public readonly string $identifier;
|
||||
|
||||
/**
|
||||
* The password of the User to be authenticated.
|
||||
*/
|
||||
#[Assert\NotBlank()]
|
||||
public readonly string $password;
|
||||
|
||||
public function __construct(string $identifier, string $password)
|
||||
{
|
||||
$this->identifier = $identifier;
|
||||
$this->password = $password;
|
||||
}
|
||||
}
|
||||
@@ -72,12 +72,6 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, Account
|
||||
#[ORM\OneToMany(mappedBy: 'owner', targetEntity: Project::class, cascade: ['persist'])]
|
||||
private Collection $projects;
|
||||
|
||||
/**
|
||||
* The UserTokens owned by this User. Owner only property.
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'owner', targetEntity: UserToken::class, orphanRemoval: true)]
|
||||
private Collection $tokens;
|
||||
|
||||
#[ORM\OneToOne(mappedBy: 'user', cascade: ['persist', 'remove'])]
|
||||
private ?Person $person = null;
|
||||
|
||||
@@ -128,7 +122,6 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, Account
|
||||
$this->accounting = Accounting::of($this);
|
||||
|
||||
$this->projects = new ArrayCollection();
|
||||
$this->tokens = new ArrayCollection();
|
||||
$this->person = Person::for($this);
|
||||
|
||||
$this->emailConfirmed = false;
|
||||
@@ -236,36 +229,6 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, Account
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, UserToken>
|
||||
*/
|
||||
public function getTokens(): Collection
|
||||
{
|
||||
return $this->tokens;
|
||||
}
|
||||
|
||||
public function addToken(UserToken $token): static
|
||||
{
|
||||
if (!$this->tokens->contains($token)) {
|
||||
$this->tokens->add($token);
|
||||
$token->setOwner($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeToken(UserToken $token): static
|
||||
{
|
||||
if ($this->tokens->removeElement($token)) {
|
||||
// set the owning side to null (unless already changed)
|
||||
if ($token->getOwner() === $this) {
|
||||
$token->setOwner(null);
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPerson(): ?Person
|
||||
{
|
||||
return $this->person;
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity\User;
|
||||
|
||||
use App\Entity\DateCreatedTrait;
|
||||
use App\Entity\UserOwnedInterface;
|
||||
use App\Entity\UserOwnedTrait;
|
||||
use App\Mapping\Provider\EntityMapProvider;
|
||||
use App\Repository\User\UserTokenRepository;
|
||||
use AutoMapper\Attribute\MapProvider;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* UserTokens authenticate requests on behalf of the User who owns them.\
|
||||
* \
|
||||
* When a UserToken is created v4 generates a SHA-256 hash that is unique for that Token and the User it represents.
|
||||
* The value of a token comes preceded by a 4-digit-length prefix based on the type of token it is.\
|
||||
* \
|
||||
* `oat_` means the token was created via an OAuth flow.\
|
||||
* `pat_` means the token was created via a login flow.
|
||||
*/
|
||||
#[MapProvider(EntityMapProvider::class)]
|
||||
#[ORM\Entity(repositoryClass: UserTokenRepository::class)]
|
||||
class UserToken implements UserOwnedInterface
|
||||
{
|
||||
use DateCreatedTrait;
|
||||
use UserOwnedTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
/**
|
||||
* The value to be used in the Authorization header.
|
||||
*/
|
||||
#[ORM\Column(length: 68)]
|
||||
private ?string $token = null;
|
||||
|
||||
/**
|
||||
* The User on behalf of which this UserToken authenticates.
|
||||
*/
|
||||
#[ORM\ManyToOne(inversedBy: 'tokens')]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
private ?User $owner = null;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getToken(): ?string
|
||||
{
|
||||
return $this->token;
|
||||
}
|
||||
|
||||
public function setToken(string $token): static
|
||||
{
|
||||
$this->token = $token;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventListener;
|
||||
|
||||
use League\Bundle\OAuth2ServerBundle\Event\AuthorizationRequestResolveEvent;
|
||||
use League\Bundle\OAuth2ServerBundle\OAuth2Events;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
final class LeaugeOAuth2AuthorizationListener
|
||||
{
|
||||
public const string AUTHORIZATION_RESULT = 'oauth2.authorization_result';
|
||||
|
||||
public function __construct(
|
||||
private RequestStack $requestStack,
|
||||
private UrlGeneratorInterface $urlGenerator,
|
||||
) {}
|
||||
|
||||
#[AsEventListener(event: OAuth2Events::AUTHORIZATION_REQUEST_RESOLVE)]
|
||||
public function onAuthorizationRequestResolve(AuthorizationRequestResolveEvent $event): void
|
||||
{
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
|
||||
if ($request->getSession()->has(self::AUTHORIZATION_RESULT)) {
|
||||
$event->resolveAuthorization($request->getSession()->get(self::AUTHORIZATION_RESULT));
|
||||
$request->getSession()->remove(self::AUTHORIZATION_RESULT);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$event->setResponse(new RedirectResponse(
|
||||
$this->urlGenerator->generate('app_consent', $request->query->all())
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use ApiPlatform\Symfony\EventListener\EventPriorities;
|
||||
use App\ApiResource\User\UserTokenApiResource;
|
||||
use App\Service\Auth\AuthService;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Event\ResponseEvent;
|
||||
use Symfony\Component\HttpKernel\Event\ViewEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
|
||||
final class AuthorizationTokenSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(
|
||||
private AuthService $authService,
|
||||
) {}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
KernelEvents::VIEW => ['onView', EventPriorities::POST_WRITE],
|
||||
KernelEvents::RESPONSE => ['onResponse', EventPriorities::POST_RESPOND],
|
||||
];
|
||||
}
|
||||
|
||||
public function onView(ViewEvent $event): void
|
||||
{
|
||||
$resourceClass = $event->getRequest()->attributes->get('_api_resource_class');
|
||||
|
||||
if ($resourceClass !== UserTokenApiResource::class) {
|
||||
return;
|
||||
}
|
||||
|
||||
$event->getRequest()->attributes->set('authTokenMethod', $event->getRequest()->getMethod());
|
||||
|
||||
if ($event->getRequest()->getMethod() === Request::METHOD_POST) {
|
||||
$token = $event->getControllerResult();
|
||||
|
||||
$event->getRequest()->attributes->set('authTokenValue', $token->token);
|
||||
}
|
||||
}
|
||||
|
||||
public function onResponse(ResponseEvent $event): void
|
||||
{
|
||||
$token = $event->getRequest()->attributes->get('authTokenValue');
|
||||
$method = $event->getRequest()->attributes->get('authTokenMethod');
|
||||
|
||||
if (!$method) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch ($method) {
|
||||
case Request::METHOD_DELETE:
|
||||
$event->getResponse()->headers->clearCookie(AuthService::AUTH_COOKIE_NAME);
|
||||
break;
|
||||
case Request::METHOD_POST:
|
||||
$event->getResponse()->headers->setCookie($this->authService->generateCookie($token));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,4 +64,13 @@ final class UserFactory extends PersistentProxyObjectFactory
|
||||
$defaults = $optimized ? self::defaultsOptimized() : self::defaultsFull();
|
||||
self::createMany($count, array_merge($defaults, $overrides));
|
||||
}
|
||||
|
||||
public function test(): self
|
||||
{
|
||||
return $this->with([
|
||||
'handle' => 'test_user',
|
||||
'email' => 'testuser@example.com',
|
||||
'password' => 'T3stUs3rP455w0rd',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,25 +7,20 @@ v4 exposes a REST API with predictable and resource-oriented URLs, accepts reque
|
||||
This API is documented in the OpenApi initiative [Specification v3.1.0](https://spec.openapis.org/oas/v3.1.0), which allows v4 to be easily understood by API development suites, documentation generators and SDK generators for the convenience of developers and API users. Most of the spec is generated automatically as changes are introduced, but some additional content such as this introduction and other sections might not be updated as regularly.
|
||||
|
||||
<span class="hl-yellow">
|
||||
This API is still in early development and is not set to have backward compatibility with the Goteo API v1 (http://api.goteo.org/v1/). Major changes are to be expected.
|
||||
This API is still in early development and is not set to have backward compatibility with the Goteo API v1 (https://api.goteo.org/v1/). Major changes are to be expected.
|
||||
</span>
|
||||
|
||||
# Authentication
|
||||
|
||||
The v4 API uses Access Tokens to authenticate requests. To use a Token, include it in the `Authorization` header using the [Bearer](https://swagger.io/docs/specification/authentication/bearer-authentication/) strategy.
|
||||
The v4 API uses [OAuth 2.0](https://oauth.net/2/) to authenticate requests from consumer applications.
|
||||
|
||||
```sh
|
||||
curl -X 'GET' \
|
||||
'https://api.goteo.org/v4/projects?page=1' \
|
||||
-H 'accept: application/json' \
|
||||
-H 'Authorization: Bearer <THE-TOKEN-HERE>'
|
||||
```
|
||||
Auth clients are issued on a case-by-case basis. You may reach out to us in order to obtain client credentials to initiate auth flows.
|
||||
|
||||
UserTokens exist under an User's scope, when you obtain a token this will only grant you as much permissions as the User under which it was created.
|
||||
The following paths are available for authentication:
|
||||
- **`POST /oauth/token`** to obtain Access Tokens
|
||||
- **`GET /oauth/authorize`** to initiate *Authorization Code Grant* flows
|
||||
|
||||
To obtain a UserToken you must send a POST request to [/v4/user_tokens](/v4/user_tokens) with the User login credentials (identifier and password) in the payload. If the credentials are correct a UserToken will be created, the `token` property value of which you must include in future requests.
|
||||
|
||||
Users can delete UserTokens owned by them at any moment, revoking your application's access to the v4 API on their behalf. When a UserToken fails to authenticate a request you will receive a 401 Unauthorized response from the API, at which point your application must look to get a new UserToken from the User.
|
||||
Please notice these paths do not have the `/v4` path prefix.
|
||||
|
||||
# Localization
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ trait RedocExtensionsTrait
|
||||
'name' => 'Users',
|
||||
'tags' => [
|
||||
'User',
|
||||
'UserToken',
|
||||
'Person',
|
||||
'Organization',
|
||||
],
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repository\User;
|
||||
|
||||
use App\Entity\User\UserToken;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<UserToken>
|
||||
*
|
||||
* @method UserToken|null find($id, $lockMode = null, $lockVersion = null)
|
||||
* @method UserToken|null findOneBy(array $criteria, array $orderBy = null)
|
||||
* @method UserToken[] findAll()
|
||||
* @method UserToken[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
|
||||
*/
|
||||
class UserTokenRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, UserToken::class);
|
||||
}
|
||||
|
||||
// /**
|
||||
// * @return UserToken[] Returns an array of UserToken objects
|
||||
// */
|
||||
// public function findByExampleField($value): array
|
||||
// {
|
||||
// return $this->createQueryBuilder('a')
|
||||
// ->andWhere('a.exampleField = :val')
|
||||
// ->setParameter('val', $value)
|
||||
// ->orderBy('a.id', 'ASC')
|
||||
// ->setMaxResults(10)
|
||||
// ->getQuery()
|
||||
// ->getResult()
|
||||
// ;
|
||||
// }
|
||||
|
||||
// public function findOneBySomeField($value): ?UserToken
|
||||
// {
|
||||
// return $this->createQueryBuilder('a')
|
||||
// ->andWhere('a.exampleField = :val')
|
||||
// ->setParameter('val', $value)
|
||||
// ->getQuery()
|
||||
// ->getOneOrNullResult()
|
||||
// ;
|
||||
// }
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Security;
|
||||
|
||||
use App\Repository\User\UserTokenRepository;
|
||||
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
|
||||
use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||
|
||||
class AccessTokenHandler implements AccessTokenHandlerInterface
|
||||
{
|
||||
public function __construct(private UserTokenRepository $userTokenRepository) {}
|
||||
|
||||
public function getUserBadgeFrom(#[\SensitiveParameter] string $accessToken): UserBadge
|
||||
{
|
||||
$token = $this->userTokenRepository->findOneBy(['token' => $accessToken]);
|
||||
|
||||
if (!$token) {
|
||||
throw new BadCredentialsException();
|
||||
}
|
||||
|
||||
return new UserBadge($token->getOwner()->getUserIdentifier());
|
||||
}
|
||||
}
|
||||
@@ -2,44 +2,43 @@
|
||||
|
||||
namespace App\Security;
|
||||
|
||||
use App\Service\Auth\AuthService;
|
||||
use App\Repository\User\UserRepository;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
||||
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
|
||||
|
||||
/**
|
||||
* @see https://symfony.com/doc/current/security/custom_authenticator.html
|
||||
*/
|
||||
class CookieAuthenticator extends AbstractAuthenticator
|
||||
class TestingAuthenticator extends AbstractAuthenticator
|
||||
{
|
||||
public const string AUTH_HEADER = 'X-Test-Auth';
|
||||
|
||||
public function __construct(
|
||||
private AccessTokenHandler $accessTokenHandler,
|
||||
private string $appEnv,
|
||||
private UserRepository $userRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Called on every request to decide if this authenticator should be
|
||||
* used for the request. Returning `false` will cause this authenticator
|
||||
* to be skipped.
|
||||
*/
|
||||
public function supports(Request $request): ?bool
|
||||
{
|
||||
return $request->cookies->has(AuthService::AUTH_COOKIE_NAME);
|
||||
}
|
||||
|
||||
public function authenticate(Request $request): Passport
|
||||
{
|
||||
$token = $request->cookies->get(AuthService::AUTH_COOKIE_NAME);
|
||||
|
||||
if (!$token) {
|
||||
throw new AuthenticationException();
|
||||
if (!\in_array($this->appEnv, ['test'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return new SelfValidatingPassport($this->accessTokenHandler->getUserBadgeFrom($token));
|
||||
return $request->headers->has(self::AUTH_HEADER);
|
||||
}
|
||||
|
||||
public function authenticate(Request $request): SelfValidatingPassport
|
||||
{
|
||||
$userId = $request->headers->get(self::AUTH_HEADER);
|
||||
|
||||
$passport = new SelfValidatingPassport(new UserBadge($userId, function ($id) {
|
||||
return $this->userRepository->find($id);
|
||||
}), []);
|
||||
|
||||
return $passport;
|
||||
}
|
||||
|
||||
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
|
||||
@@ -1,49 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Security\Trait;
|
||||
|
||||
use App\Entity\User\User;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
||||
use Symfony\Contracts\Service\Attribute\Required;
|
||||
|
||||
trait UserTrait
|
||||
{
|
||||
private Security $security;
|
||||
|
||||
#[Required]
|
||||
public function setSecurity(Security $security): void
|
||||
{
|
||||
$this->security = $security;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify and get the authenticated user.
|
||||
*
|
||||
* @return User the authenticated user
|
||||
*
|
||||
* @throws AuthenticationException if there is no authenticated user
|
||||
*/
|
||||
protected function getAuthenticatedUser(): User
|
||||
{
|
||||
$user = $this->security->getUser();
|
||||
|
||||
if (!$user instanceof User || $user == null) {
|
||||
throw new AuthenticationException();
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify if the user has the administrator role.
|
||||
*
|
||||
* @param User $user the authenticated user
|
||||
*
|
||||
* @return bool true if the user is administrator, false otherwise
|
||||
*/
|
||||
protected function isAdmin(User $user): bool
|
||||
{
|
||||
return $user->hasRoles(['ROLE_ADMIN']);
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,10 @@ use ApiPlatform\Metadata\IriConverterInterface;
|
||||
use App\ApiResource\Accounting\AccountingApiResource;
|
||||
use App\ApiResource\User\UserApiResource;
|
||||
use App\Entity\User\User;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
class AccountingVoter extends Voter
|
||||
{
|
||||
@@ -17,6 +19,7 @@ class AccountingVoter extends Voter
|
||||
public const VIEW = 'ACCOUNTING_VIEW';
|
||||
|
||||
public function __construct(
|
||||
private Security $security,
|
||||
private IriConverterInterface $iriConverter,
|
||||
) {}
|
||||
|
||||
@@ -44,11 +47,11 @@ class AccountingVoter extends Voter
|
||||
return false;
|
||||
}
|
||||
|
||||
private function voteOn(string $attribute, mixed $subject, ?User $user): bool
|
||||
private function voteOn(string $attribute, mixed $subject, ?UserInterface $user): bool
|
||||
{
|
||||
switch ($attribute) {
|
||||
case self::EDIT:
|
||||
return $user->hasRoles(['ROLE_ADMIN'])
|
||||
return $this->security->isGranted('ROLE_ADMIN', $user)
|
||||
|| $this->isOwnerOf($subject, $user);
|
||||
case self::VIEW:
|
||||
return true;
|
||||
@@ -57,7 +60,7 @@ class AccountingVoter extends Voter
|
||||
return false;
|
||||
}
|
||||
|
||||
private function voteOnUser(string $attribute, UserApiResource $owner, ?User $user): bool
|
||||
private function voteOnUser(string $attribute, UserApiResource $owner, ?UserInterface $user): bool
|
||||
{
|
||||
if (!$user instanceof User) {
|
||||
return false;
|
||||
@@ -66,7 +69,7 @@ class AccountingVoter extends Voter
|
||||
switch ($attribute) {
|
||||
case self::EDIT:
|
||||
case self::VIEW:
|
||||
return $user->hasRoles(['ROLE_ADMIN'])
|
||||
return $this->security->isGranted('ROLE_ADMIN', $user)
|
||||
|| $this->isOwnerOf($owner, $user);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,11 +11,12 @@ final class CheckoutVoter extends Voter
|
||||
{
|
||||
use UserOwnedVoterTrait;
|
||||
|
||||
public const VIEW = 'CHECKOUT_VIEW';
|
||||
public const EDIT = 'CHECKOUT_EDIT';
|
||||
|
||||
protected function supports(string $attribute, mixed $subject): bool
|
||||
{
|
||||
return in_array($attribute, [self::EDIT])
|
||||
return in_array($attribute, [self::VIEW, self::EDIT])
|
||||
&& $subject instanceof CheckoutApiResource;
|
||||
}
|
||||
|
||||
@@ -31,6 +32,7 @@ final class CheckoutVoter extends Voter
|
||||
}
|
||||
|
||||
switch ($attribute) {
|
||||
case self::VIEW:
|
||||
case self::EDIT:
|
||||
return $user->hasRoles(['ROLE_ADMIN'])
|
||||
|| $this->isOwnerOf($subject->origin, $user);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Security\Voter;
|
||||
|
||||
use App\Entity\Interface\UserOwnedInterface;
|
||||
use App\Entity\UserOwnedInterface;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ namespace App\Security\Voter;
|
||||
|
||||
use App\ApiResource\Accounting\AccountingApiResource;
|
||||
use App\ApiResource\User\UserApiResource;
|
||||
use App\Entity\Interface\UserOwnedInterface;
|
||||
use App\Entity\User\User;
|
||||
use App\Entity\UserOwnedInterface;
|
||||
|
||||
trait UserOwnedVoterTrait
|
||||
{
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\Auth;
|
||||
|
||||
use App\Entity\User\User;
|
||||
use App\Entity\User\UserToken;
|
||||
use App\Repository\User\UserRepository;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\Cookie;
|
||||
|
||||
class AuthService
|
||||
{
|
||||
public const AUTH_COOKIE_NAME = 'authToken';
|
||||
public const AUTH_COOKIE_TTL = 86400;
|
||||
|
||||
private const TOKEN_HASH_ALGO = 'sha256';
|
||||
|
||||
private array $config;
|
||||
|
||||
public function __construct(
|
||||
private string $appSecret,
|
||||
private Security $security,
|
||||
private UserRepository $userRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{CORS_ALLOW_ORIGIN: string, SESSION_LIFETIME: int}
|
||||
*/
|
||||
public function getConfig(): array
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
private function getCookieTtl(): \DateTimeInterface
|
||||
{
|
||||
return (new \DateTime())->add(new \DateInterval(\sprintf('PT%dS', AuthService::AUTH_COOKIE_TTL)));
|
||||
}
|
||||
|
||||
public function generateCookie(string $token): Cookie
|
||||
{
|
||||
return new Cookie(
|
||||
name: AuthService::AUTH_COOKIE_NAME,
|
||||
value: $token,
|
||||
expire: $this->getCookieTtl(),
|
||||
path: '/',
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
sameSite: 'Strict'
|
||||
);
|
||||
}
|
||||
|
||||
public function generateUserToken(User $user, AuthTokenType $type): UserToken
|
||||
{
|
||||
$token = new UserToken();
|
||||
|
||||
$token->setOwner($user);
|
||||
$token->setToken(sprintf('%s%s', $type->value, hash(
|
||||
self::TOKEN_HASH_ALGO,
|
||||
join('', [
|
||||
microtime(true),
|
||||
$this->appSecret,
|
||||
random_bytes(32),
|
||||
$user->getUserIdentifier(),
|
||||
])
|
||||
)));
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
public function getUser(): ?User
|
||||
{
|
||||
$loggedInUser = $this->security->getUser();
|
||||
|
||||
if (!$loggedInUser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->userRepository->findOneBy(
|
||||
['handle' => $loggedInUser->getUserIdentifier()]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use App\Controller\GatewaysController;
|
||||
use App\Entity\Gateway\Charge;
|
||||
use App\Entity\Gateway\Checkout;
|
||||
use App\Gateway\CheckoutStatus;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Component\Routing\RouterInterface;
|
||||
|
||||
class CheckoutService
|
||||
@@ -14,7 +15,7 @@ class CheckoutService
|
||||
public const RESPONSE_TYPE_FAILURE = 'failure';
|
||||
|
||||
public function __construct(
|
||||
private RouterInterface $router,
|
||||
private UrlGeneratorInterface $urlGenerator,
|
||||
private ChargeService $transactionService,
|
||||
) {}
|
||||
|
||||
@@ -29,7 +30,7 @@ class CheckoutService
|
||||
string $type = self::RESPONSE_TYPE_SUCCESS,
|
||||
array $parameters = [],
|
||||
): string {
|
||||
return $this->router->generate(
|
||||
return $this->urlGenerator->generate(
|
||||
GatewaysController::REDIRECT,
|
||||
[
|
||||
'type' => $type,
|
||||
|
||||
@@ -10,8 +10,8 @@ use App\Dto\ProjectUpdationDto;
|
||||
use App\Entity\Project\Project;
|
||||
use App\Entity\Project\ProjectCalendar;
|
||||
use App\Mapping\AutoMapper;
|
||||
use App\Service\Auth\AuthService;
|
||||
use App\State\EntityStateProcessor;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
||||
|
||||
class ProjectStateProcessor implements ProcessorInterface
|
||||
@@ -19,7 +19,7 @@ class ProjectStateProcessor implements ProcessorInterface
|
||||
public function __construct(
|
||||
private EntityStateProcessor $entityStateProcessor,
|
||||
private AutoMapper $autoMapper,
|
||||
private AuthService $authService,
|
||||
private Security $security,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -52,7 +52,7 @@ class ProjectStateProcessor implements ProcessorInterface
|
||||
/** @var Project */
|
||||
$project = $this->autoMapper->map($data, Project::class);
|
||||
|
||||
$owner = $this->authService->getUser();
|
||||
$owner = $this->security->getUser();
|
||||
|
||||
if (!$owner) {
|
||||
throw new AuthenticationException();
|
||||
|
||||
@@ -8,8 +8,8 @@ use App\ApiResource\Project\RewardClaimApiResource;
|
||||
use App\Dto\RewardClaimCreationDto;
|
||||
use App\Entity\Project\RewardClaim;
|
||||
use App\Mapping\AutoMapper;
|
||||
use App\Service\Auth\AuthService;
|
||||
use App\State\EntityStateProcessor;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
||||
|
||||
class RewardClaimStateProcessor implements ProcessorInterface
|
||||
@@ -17,7 +17,7 @@ class RewardClaimStateProcessor implements ProcessorInterface
|
||||
public function __construct(
|
||||
private EntityStateProcessor $entityStateProcessor,
|
||||
private AutoMapper $autoMapper,
|
||||
private AuthService $authService,
|
||||
private Security $security,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -31,7 +31,7 @@ class RewardClaimStateProcessor implements ProcessorInterface
|
||||
$claim = $this->autoMapper->map($data, RewardClaim::class);
|
||||
|
||||
if ($data instanceof RewardClaimCreationDto) {
|
||||
$owner = $this->authService->getUser();
|
||||
$owner = $this->security->getUser();
|
||||
|
||||
if (!$owner) {
|
||||
throw new AuthenticationException();
|
||||
|
||||
@@ -7,16 +7,16 @@ use ApiPlatform\State\ProcessorInterface;
|
||||
use App\ApiResource\Project\SupportApiResource;
|
||||
use App\Entity\Project\Support;
|
||||
use App\Mapping\AutoMapper;
|
||||
use App\Service\Auth\AuthService;
|
||||
use App\State\EntityStateProcessor;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
||||
|
||||
class SupportStateProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private EntityStateProcessor $entityStateProcessor,
|
||||
private AuthService $authService,
|
||||
private Security $security,
|
||||
private AutoMapper $autoMapper,
|
||||
private EntityManagerInterface $entityManager,
|
||||
) {}
|
||||
@@ -28,7 +28,8 @@ class SupportStateProcessor implements ProcessorInterface
|
||||
*/
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
|
||||
{
|
||||
$user = $this->authService->getUser();
|
||||
$user = $this->security->getUser();
|
||||
|
||||
if (!$user) {
|
||||
throw new AuthenticationException();
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ use ApiPlatform\State\ProcessorInterface;
|
||||
use App\ApiResource\Project\UpdateApiResource;
|
||||
use App\Entity\Project\Update;
|
||||
use App\Mapping\AutoMapper;
|
||||
use App\Service\Auth\AuthService;
|
||||
use App\State\EntityStateProcessor;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
||||
|
||||
class UpdateStateProcessor implements ProcessorInterface
|
||||
@@ -16,7 +16,7 @@ class UpdateStateProcessor implements ProcessorInterface
|
||||
public function __construct(
|
||||
private EntityStateProcessor $entityStateProcessor,
|
||||
private AutoMapper $autoMapper,
|
||||
private AuthService $authService,
|
||||
private Security $security,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -29,7 +29,8 @@ class UpdateStateProcessor implements ProcessorInterface
|
||||
/** @var Update */
|
||||
$update = $this->autoMapper->map($data, Update::class);
|
||||
|
||||
$user = $this->authService->getUser();
|
||||
$user = $this->security->getUser();
|
||||
|
||||
if (!$user) {
|
||||
throw new AuthenticationException();
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\State\User;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\ApiResource\User\UserTokenApiResource;
|
||||
use App\Dto\UserTokenLoginDto;
|
||||
use App\Mapping\AutoMapper;
|
||||
use App\Repository\User\UserRepository;
|
||||
use App\Service\Auth\AuthService;
|
||||
use App\Service\Auth\AuthTokenType;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
class UserTokenLoginProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private AuthService $authService,
|
||||
private UserRepository $userRepository,
|
||||
private EntityManagerInterface $entityManager,
|
||||
private UserPasswordHasherInterface $userPasswordHasher,
|
||||
private AutoMapper $autoMapper,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param UserTokenLoginDto $data
|
||||
*
|
||||
* @return UserTokenApiResource
|
||||
*/
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
|
||||
{
|
||||
$user = $this->userRepository->findOneByIdentifier($data->identifier);
|
||||
|
||||
if (!$user) {
|
||||
throw new HttpException(Response::HTTP_NOT_FOUND, sprintf("The user '%s' does not exist.", $data->identifier));
|
||||
}
|
||||
|
||||
if (!$this->userPasswordHasher->isPasswordValid($user, $data->password)) {
|
||||
throw new HttpException(Response::HTTP_UNAUTHORIZED, sprintf('The password could not be validated'));
|
||||
}
|
||||
|
||||
$token = $this->authService->generateUserToken($user, AuthTokenType::Personal);
|
||||
|
||||
$this->entityManager->persist($token);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->autoMapper->map($token, UserTokenApiResource::class);
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,19 @@
|
||||
"goteo/benzina": {
|
||||
"version": "0.1"
|
||||
},
|
||||
"league/oauth2-server-bundle": {
|
||||
"version": "1.1",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "0.11",
|
||||
"ref": "b40f5dd1cb1761570881520b9d3d7edae9c220cc"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/league_oauth2_server.yaml",
|
||||
"config/routes/league_oauth2_server.yaml"
|
||||
]
|
||||
},
|
||||
"nelmio/cors-bundle": {
|
||||
"version": "2.3",
|
||||
"recipe": {
|
||||
@@ -256,6 +269,33 @@
|
||||
"config/packages/security.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/stimulus-bundle": {
|
||||
"version": "2.32",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "2.13",
|
||||
"ref": "6acd9ff4f7fd5626d2962109bd4ebab351d43c43"
|
||||
},
|
||||
"files": [
|
||||
"assets/bootstrap.js",
|
||||
"assets/controllers.json",
|
||||
"assets/controllers/hello_controller.js"
|
||||
]
|
||||
},
|
||||
"symfony/translation": {
|
||||
"version": "6.4",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "6.3",
|
||||
"ref": "620a1b84865ceb2ba304c8f8bf2a185fbf32a843"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/translation.yaml",
|
||||
"translations/.gitignore"
|
||||
]
|
||||
},
|
||||
"symfony/twig-bundle": {
|
||||
"version": "6.3",
|
||||
"recipe": {
|
||||
@@ -293,6 +333,22 @@
|
||||
"config/packages/validator.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/webpack-encore-bundle": {
|
||||
"version": "2.4",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "2.0",
|
||||
"ref": "719f6110345acb6495e496601fc1b4977d7102b3"
|
||||
},
|
||||
"files": [
|
||||
"assets/app.js",
|
||||
"assets/styles/app.css",
|
||||
"config/packages/webpack_encore.yaml",
|
||||
"package.json",
|
||||
"webpack.config.js"
|
||||
]
|
||||
},
|
||||
"zenstruck/foundry": {
|
||||
"version": "2.4",
|
||||
"recipe": {
|
||||
|
||||
+22
-11
@@ -1,16 +1,27 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{% block title %}Welcome!{% endblock %}</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text></svg>">
|
||||
{% block stylesheets %}
|
||||
{% endblock %}
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>
|
||||
{% block title %}Welcome!
|
||||
{% endblock %}
|
||||
</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text></svg>">
|
||||
{% block stylesheets %}
|
||||
{{ encore_entry_link_tags('app') }}
|
||||
{% endblock %}
|
||||
|
||||
{% block javascripts %}
|
||||
{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
{% block body %}{% endblock %}
|
||||
{% block javascripts %}
|
||||
{{ encore_entry_script_tags('app') }}
|
||||
{% endblock %}
|
||||
</head>
|
||||
<body class="bg-white font-body flex flex-col min-h-dvh">
|
||||
{{ include('header.html.twig') }}
|
||||
<main class="grow">
|
||||
{% block body %}
|
||||
{% endblock %}
|
||||
</main>
|
||||
{{ include('footer.html.twig') }}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,84 @@
|
||||
<header class="sticky top-0 z-100 w-full px-2 py-3 md:px-6 md:py-8">
|
||||
<div class="mx-auto max-w-360 px-2">
|
||||
<div class="border-gray-light flex flex-col rounded-xl border bg-white/50 backdrop-blur-xl transition-all duration-300"
|
||||
id="header-container">
|
||||
<div class="flex items-center justify-between px-2 py-3 md:px-4 md:py-6" id="header-main">
|
||||
<div class="flex items-center gap-2 md:gap-4">
|
||||
<a href="/" class="shrink-0">
|
||||
<svg width="97" height="24" viewbox="0 0 97 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M19.8701 7.67198C18.4595 7.81705 16.9816 7.8935 15.5572 8.02467C15.2749 7.55819 14.9676 7.16902 14.7414 6.85108C14.2951 6.22563 13.4008 5.40125 12.5271 5.092C8.78915 3.77074 4.94931 6.30468 4.48573 10.4865C4.06359 14.2835 5.60971 18.2143 8.79778 19.0474C12.5617 20.0325 15.1817 17.6193 15.8015 14.7239C14.3961 14.7239 12.4926 14.7821 10.8955 14.7066C10.8955 13.4948 10.8955 12.2847 10.8955 11.0703C14.0422 11.0703 17.1897 11.0703 20.3354 11.0703C20.3225 15.7464 19.3927 18.5922 17.1888 20.6874C13.4327 24.2595 6.56622 23.8659 3.08636 20.452C0.864293 18.2716 -0.271774 14.8291 0.0554068 10.4813C0.435247 5.45164 3.72777 1.36449 9.37963 0.864131C15.2154 0.351608 18.6486 3.23302 19.8701 7.67198Z"
|
||||
fill="#3D3D3D"></path>
|
||||
<path
|
||||
d="M31.7556 0.987442C38.1507 0.661686 42.7028 5.38297 42.7131 11.8946C42.7269 18.9031 37.7795 23.1527 32.1061 23.1527C26.4801 23.1536 22.484 19.096 21.9661 13.771C21.2591 6.45492 25.2232 1.31841 31.7556 0.987442ZM26.8626 15.4119C27.6542 17.4933 29.6052 19.122 32.1069 19.1655C40.3823 19.3123 40.2744 3.84888 31.5242 4.97469C27.4608 5.49677 25.0134 10.5577 26.8626 15.4119Z"
|
||||
fill="#3D3D3D"></path>
|
||||
<path
|
||||
d="M85.4884 0.98751C91.4234 0.661755 95.8977 4.71502 96.4433 10.3701C97.1287 17.4464 92.7735 23.1528 85.9554 23.1528C79.6388 23.1537 75.7127 18.3099 75.6988 12.2456C75.685 6.0137 79.3047 1.32456 85.4884 0.98751ZM83.0418 5.67839C78.7376 8.41995 79.0224 19.2107 86.0719 19.1655C91.3413 19.1308 93.7637 11.9068 90.9676 7.55474C89.9161 5.92075 87.8537 4.59341 85.2561 4.97476C84.0847 5.14589 83.6686 5.27706 83.0418 5.67839Z"
|
||||
fill="#3D3D3D"></path>
|
||||
<path
|
||||
d="M58.2159 1.22114C58.2159 2.55109 58.2159 3.88018 58.2159 5.20926C56.39 5.20926 54.5642 5.20926 52.7375 5.20926C52.7375 11.0346 52.7375 16.8591 52.7375 22.6845C51.2985 22.6845 49.8628 22.6845 48.4246 22.6845C48.4246 16.8974 48.4246 11.0216 48.4246 5.23619C46.6799 5.23619 44.8576 5.20926 43.0637 5.20926C43.0637 3.77072 43.0378 2.58932 43.0378 1.22114C48.0482 1.22114 53.2028 1.22114 58.2159 1.22114Z"
|
||||
fill="#3D3D3D"></path>
|
||||
<path
|
||||
d="M74.0658 1.22117C74.0658 2.51204 74.1193 3.9054 74.1193 5.19539C71.0944 5.19539 68.2094 5.20929 65.0921 5.20929C65.0921 6.73383 65.0921 8.39475 65.0921 9.91755C67.7648 9.91755 70.5152 9.90104 73.484 9.90104C73.484 11.1893 73.484 12.4802 73.484 13.7701C70.6878 13.7701 67.8908 13.7701 65.0921 13.7701C65.0921 15.5787 65.0532 17.3891 65.0532 18.8372C67.9633 18.8372 70.314 18.8128 73.9493 18.8128C73.9493 20.1028 73.9493 21.3937 73.9493 22.6837C69.5215 22.6837 65.0921 22.6837 60.6644 22.6837C60.7403 15.5692 60.5072 8.14109 60.7792 1.22031C65.2078 1.22117 69.6355 1.22117 74.0658 1.22117Z"
|
||||
fill="#3D3D3D"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
<nav>
|
||||
<ul class="flex items-center gap-1 md:gap-4">
|
||||
{% if app.user %}
|
||||
<div class="relative inline-block w-full">
|
||||
<button id="user-user-dropdown-btn" class="flex w-full cursor-pointer items-center gap-1">
|
||||
<svg width="32" height="32" viewbox="0 0 32 32" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M16 31C24.2843 31 31 24.2843 31 16C31 7.71573 24.2843 1 16 1C7.71573 1 1 7.71573 1 16C1 24.2843 7.71573 31 16 31ZM16 31C11.499 31.0002 7.5 28.8 5.09535 26.3C5.09535 23.8 9.75 19.75 16 19.75C22.25 19.75 26.4096 24.3 26.4096 26.8C24 29.3 20.501 30.9998 16 31ZM21 12.25C21 15.0114 18.7614 17.25 16 17.25C13.2386 17.25 11 15.0114 11 12.25C11 9.48858 13.2386 7.25 16 7.25C18.7614 7.25 21 9.48858 21 12.25Z"
|
||||
stroke="#462949" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">
|
||||
{{ 'greeting' | trans({ '%name%': app.user.person.firstName }) }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div id="user-dropdown" style="right: 0;"
|
||||
class="absolute top-full mt-2 min-w-32 max-w-[calc(100vw-1rem)] flex-col rounded-lg bg-white p-2 shadow-lg hidden">
|
||||
<a href="{{ logout_path() }}" class="block px-4 py-2 hover:bg-gray-100">
|
||||
{{ 'login.logout' | trans }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
function initHeader() {
|
||||
const root = document.querySelector("header");
|
||||
if (!root) return;
|
||||
|
||||
const btn = root.querySelector("#user-user-dropdown-btn");
|
||||
const dropdown = root.querySelector("#user-dropdown");
|
||||
|
||||
if (!btn || !dropdown) return;
|
||||
|
||||
if (btn.dataset.bound) return;
|
||||
btn.dataset.bound = "true";
|
||||
|
||||
btn.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
dropdown.classList.toggle("hidden");
|
||||
});
|
||||
|
||||
document.addEventListener("click", function (e) {
|
||||
if (!root.contains(e.target)) {
|
||||
dropdown.classList.add("hidden");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
initHeader();
|
||||
</script>
|
||||
</header>
|
||||
@@ -0,0 +1,29 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
|
||||
{% block title %}
|
||||
{{ 'consent.title' | trans }}
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="mx-auto max-w-360 px-10 mb-20">
|
||||
<form method="post" class="mx-auto max-w-122 flex flex-col gap-6">
|
||||
<h2 class="text-lg font-bold text-black mb-4 md:text-xl lg:text-2xl">
|
||||
{{ 'consent.description' | trans({ '%clientapp%': '<span class="text-secondary">' ~ client_app ~ '</span>' }) | raw }}
|
||||
</h2>
|
||||
<p>
|
||||
{{ 'consent.tip' | trans }}
|
||||
</p>
|
||||
|
||||
<div class="checkbox mb-3 flex flex-row gap-2">
|
||||
<input type="checkbox" name="_consent" id="_consent" class="w-6 h-6 border-secondary rounded-sm accent-primary text-secondary focus:ring-secondary border" checked>
|
||||
<label for="_consent">{{ 'consent.consent' | trans }}</label>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-end">
|
||||
<button class="btn btn-lg btn-primary text-secondary disabled:bg-grey flex items-center justify-center gap-2 font-bold transition hover:cursor-pointer px-8 py-4 rounded-3xl bg-primary w-fit" type="submit">
|
||||
{{ 'consent.submit' | trans }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,43 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
|
||||
{% block title %}
|
||||
{{ 'login.title' | trans }}
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="mx-auto max-w-360 px-10 mb-20">
|
||||
<form method="post" class="mx-auto max-w-122 flex flex-col gap-6">
|
||||
{% if error %}
|
||||
<div class="alert alert-danger">{{ error.messageKey|trans(error.messageData, 'security') }}</div>
|
||||
{% endif %}
|
||||
|
||||
<h1 class="text-2xl font-bold tracking-[0%] text-black mb-8 md:text-3xl md:leading-tight lg:text-[40px] lg:leading-12">{{ 'login.title' | trans }}</h1>
|
||||
|
||||
<div class="relative">
|
||||
<label for="username" class="absolute -top-2 left-4 -translate-y-1/2 transform text-sm font-medium text-gray-500 transition-all">
|
||||
{{ 'login.username.label' | trans }}
|
||||
</label>
|
||||
<input type="text" value="{{ last_username }}" placeholder="{{ 'login.username.placeholder' | trans }}" name="_username" id="username" class="form-control peer border-secondary focus:ring-tertiary w-full rounded-md border bg-white p-4 text-base text-gray-700 placeholder-gray-400 focus:ring-1 focus:outline-none" autocomplete="username" required autofocus>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<label for="password" class="absolute -top-2 left-4 -translate-y-1/2 transform text-sm font-medium text-gray-500 transition-all">
|
||||
{{ 'login.password.label' | trans }}
|
||||
</label>
|
||||
<input type="password" placeholder="{{ 'login.password.placeholder' | trans }}" name="_password" id="password" class="form-control peer border-secondary focus:ring-tertiary w-full rounded-md border bg-white p-4 text-base text-gray-700 placeholder-gray-400 focus:ring-1 focus:outline-none" autocomplete="current-password" required>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="_csrf_token" data-controller="csrf-protection" value="{{ csrf_token('authenticate') }}">
|
||||
|
||||
<div class="checkbox mb-3 flex flex-row gap-2">
|
||||
<input type="checkbox" name="_remember_me" id="_remember_me" class="w-6 h-6 border-secondary rounded-sm accent-primary text-secondary focus:ring-secondary border">
|
||||
<label for="_remember_me">{{ 'login.rememberme' | trans }}</label>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-end">
|
||||
<button class="btn btn-lg btn-primary text-secondary disabled:bg-grey flex items-center justify-center gap-2 font-bold transition hover:cursor-pointer px-8 py-4 rounded-3xl bg-primary w-fit" type="submit">
|
||||
{{ 'login.submit' | trans }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -2,94 +2,47 @@
|
||||
|
||||
namespace App\Tests\Entity\ProjectApi;
|
||||
|
||||
use App\Tests\Fixtures\TestUser;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class CreateTest extends ProjectTestCase
|
||||
{
|
||||
// Auxiliary functions
|
||||
|
||||
protected function getMethod(): string
|
||||
{
|
||||
return 'POST';
|
||||
}
|
||||
|
||||
private function assertProjectData(array $expectedData, array $responseData): void
|
||||
{
|
||||
$this->assertArrayHasKey('id', $responseData);
|
||||
|
||||
$expectedSubset = $expectedData;
|
||||
unset($expectedSubset['video']);
|
||||
|
||||
$this->assertArraySubset($expectedSubset, $responseData);
|
||||
|
||||
$this->assertEquals(
|
||||
$expectedData['territory']['country'],
|
||||
$responseData['territory']['country']
|
||||
);
|
||||
|
||||
$this->assertMatchesRegularExpression('/^https?:\/\//', $responseData['video']['src']);
|
||||
}
|
||||
|
||||
// TESTS
|
||||
|
||||
// Auxiliary Tests
|
||||
|
||||
private function testPostSetBase(
|
||||
array $setData,
|
||||
int $expectedCode = Response::HTTP_CREATED,
|
||||
): void {
|
||||
$requestData = [
|
||||
'title' => 'New Education Project',
|
||||
'subtitle' => 'Education for the Future',
|
||||
'territory' => ['country' => 'ES'],
|
||||
'description' => 'Detailed project description',
|
||||
'deadline' => 'minimum',
|
||||
'video' => 'https://www.youtube.com/watch?v=bnrVQHEXmOk',
|
||||
'categories' => ['/v4/categories/test'],
|
||||
];
|
||||
|
||||
$requestData = array_merge($requestData, $setData);
|
||||
|
||||
$this->createTestUser();
|
||||
$this->testRequestHelper($requestData, self::BASE_URI, $expectedCode);
|
||||
}
|
||||
|
||||
private function testPostWithInvalidInput(array $invalidData): void
|
||||
{
|
||||
$expectedCode = Response::HTTP_BAD_REQUEST;
|
||||
$this->testPostSetBase($invalidData, $expectedCode);
|
||||
}
|
||||
|
||||
private function testPostWithUnprocessableEntity(array $invalidData): void
|
||||
{
|
||||
$expectedCode = Response::HTTP_UNPROCESSABLE_ENTITY;
|
||||
$this->testPostSetBase($invalidData, $expectedCode);
|
||||
}
|
||||
|
||||
// Runable Tests
|
||||
|
||||
public function testPostWithoutMandatoryField(): void
|
||||
{
|
||||
$this->createTestUser();
|
||||
|
||||
// Expected data without the 'title' field
|
||||
$requestData = [
|
||||
'subtitle' => 'Education for the Future',
|
||||
'territory' => ['country' => 'ES'],
|
||||
'description' => 'Detailed project description',
|
||||
'deadline' => 'minimum',
|
||||
'video' => 'https://www.youtube.com/watch?v=bnrVQHEXmOk',
|
||||
];
|
||||
|
||||
$client = static::createClient();
|
||||
$client->request('POST', self::BASE_URI, $this->getRequestOptions($client, $requestData));
|
||||
$this->request($this->getMethod(), self::BASE_URI, [
|
||||
'headers' => $this->withAuthHeader(TestUser::get()),
|
||||
'json' => [
|
||||
'subtitle' => 'Education for the Future',
|
||||
'territory' => ['country' => 'ES'],
|
||||
'description' => 'Detailed project description',
|
||||
'deadline' => 'minimum',
|
||||
'video' => 'https://www.youtube.com/watch?v=bnrVQHEXmOk',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
public function testPostWithInvalidCategories(): void
|
||||
{
|
||||
$this->testPostWithInvalidInput(['categories' => 'nonexistent-categories']);
|
||||
$this->request($this->getMethod(), self::BASE_URI, [
|
||||
'headers' => $this->withAuthHeader(TestUser::get()),
|
||||
'json' => [
|
||||
'subtitle' => 'Education for the Future',
|
||||
'territory' => ['country' => 'ES'],
|
||||
'description' => 'Detailed project description',
|
||||
'deadline' => 'minimum',
|
||||
'video' => 'https://www.youtube.com/watch?v=bnrVQHEXmOk',
|
||||
'categories' => 'nonexistent-categories',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
public function testPostUnauthorized()
|
||||
@@ -108,9 +61,4 @@ class CreateTest extends ProjectTestCase
|
||||
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
public function testPostWithInvalidToken()
|
||||
{
|
||||
$this->testInvalidToken($this->getUri());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
|
||||
namespace App\Tests\Entity\ProjectApi;
|
||||
|
||||
use App\Factory\User\UserFactory;
|
||||
use App\Tests\Fixtures\TestUser;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class DeleteTest extends ProjectTestCase
|
||||
{
|
||||
protected function getMethod(): string
|
||||
{
|
||||
return 'DELETE';
|
||||
return Request::METHOD_DELETE;
|
||||
}
|
||||
|
||||
// Runable Tests
|
||||
@@ -17,8 +20,7 @@ class DeleteTest extends ProjectTestCase
|
||||
{
|
||||
$this->createTestProjectOptimized();
|
||||
|
||||
$client = static::createClient();
|
||||
$client->request('DELETE', $this->getUri(1), $this->getRequestOptions($client));
|
||||
$this->request(Request::METHOD_DELETE, $this->getUri(1), ['headers' => $this->withAuthHeader(TestUser::get())]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_NO_CONTENT);
|
||||
}
|
||||
@@ -27,16 +29,11 @@ class DeleteTest extends ProjectTestCase
|
||||
{
|
||||
$this->createTestProjectOptimized();
|
||||
|
||||
static::createClient()->request('DELETE', $this->getUri(1));
|
||||
static::createClient()->request(Request::METHOD_DELETE, $this->getUri(1));
|
||||
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
public function testDeleteWithInvalidToken(): void
|
||||
{
|
||||
$this->testInvalidToken($this->getUri(1));
|
||||
}
|
||||
|
||||
public function testDeleteNotFound(): void
|
||||
{
|
||||
$this->testOneNotFound();
|
||||
@@ -44,6 +41,13 @@ class DeleteTest extends ProjectTestCase
|
||||
|
||||
public function testDeleteForbidden(): void
|
||||
{
|
||||
$this->testForbidden();
|
||||
$this->createTestProjectOptimized();
|
||||
|
||||
$otherUser = UserFactory::new(['handle' => 'other_user', 'email' => 'otheruser@example.com'])->create();
|
||||
$this->request($this->getMethod(), $this->getUri(1), [
|
||||
'headers' => $this->withAuthHeader($otherUser),
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ namespace App\Tests\Entity\ProjectApi;
|
||||
use App\Entity\Project\ProjectStatus;
|
||||
use App\Entity\Territory;
|
||||
use App\Factory\Project\ProjectFactory;
|
||||
use App\Security\TestingAuthenticator;
|
||||
use App\Tests\Fixtures\TestUser;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class GetAllTest extends ProjectTestCase
|
||||
@@ -50,7 +52,7 @@ class GetAllTest extends ProjectTestCase
|
||||
$searchCount = 2,
|
||||
int $responseCode = Response::HTTP_OK,
|
||||
) {
|
||||
$owner = $this->createTestUser();
|
||||
$owner = TestUser::get();
|
||||
$territory = new Territory('ES');
|
||||
$baseAttributes = [
|
||||
'owner' => $owner,
|
||||
@@ -65,12 +67,11 @@ class GetAllTest extends ProjectTestCase
|
||||
$valueName = $this->getString($searchValue);
|
||||
|
||||
$uri = self::BASE_URI."?$param=$valueName";
|
||||
$client = static::createClient();
|
||||
$client->request($this->getMethod(), $uri, $this->getRequestOptions($client));
|
||||
$response = $this->request($this->getMethod(), $uri);
|
||||
|
||||
$this->assertResponseStatusCodeSame($responseCode);
|
||||
|
||||
$responseData = json_decode($client->getResponse()->getContent(), true);
|
||||
$responseData = json_decode($response->getContent(), true);
|
||||
$this->assertCount($searchCount, $responseData['member']);
|
||||
}
|
||||
|
||||
@@ -79,7 +80,7 @@ class GetAllTest extends ProjectTestCase
|
||||
array $searchValues,
|
||||
string|ProjectStatus $otherValue,
|
||||
) {
|
||||
$owner = $this->createTestUser();
|
||||
$owner = TestUser::get();
|
||||
$territory = new Territory('ES');
|
||||
$baseAttributes = [
|
||||
'owner' => $owner,
|
||||
@@ -94,14 +95,15 @@ class GetAllTest extends ProjectTestCase
|
||||
$valueNames[] = $this->getString($searchValue);
|
||||
}
|
||||
|
||||
$client = static::createClient();
|
||||
$queryParams = $this->buildQueryParams($param, $valueNames);
|
||||
$options = array_merge($this->getRequestOptions($client), $queryParams);
|
||||
$client->request($this->getMethod(), $this->getUri(), $options);
|
||||
$response = $this->request(
|
||||
$this->getMethod(),
|
||||
$this->getUri(),
|
||||
$this->buildQueryParams($param, $valueNames)
|
||||
);
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
|
||||
$responseData = json_decode($client->getResponse()->getContent(), true);
|
||||
$responseData = json_decode($response->getContent(), true);
|
||||
$this->assertCount(count($searchValues), $responseData['member']);
|
||||
}
|
||||
|
||||
@@ -139,20 +141,15 @@ class GetAllTest extends ProjectTestCase
|
||||
|
||||
public function testGetAllSuccessful(): void
|
||||
{
|
||||
$owner = $this->createTestUser();
|
||||
$owner = TestUser::get();
|
||||
$numberOfProjects = 2;
|
||||
ProjectFactory::createMany($numberOfProjects, ['owner' => $owner]);
|
||||
|
||||
$client = static::createClient();
|
||||
$client->request(
|
||||
$this->getMethod(),
|
||||
self::BASE_URI,
|
||||
$this->getRequestOptions($client)
|
||||
);
|
||||
$response = $this->request($this->getMethod(), self::BASE_URI);
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
|
||||
$responseData = json_decode($client->getResponse()->getContent(), true);
|
||||
$responseData = json_decode($response->getContent(), true);
|
||||
$this->assertCount($numberOfProjects, $responseData['member']);
|
||||
}
|
||||
|
||||
@@ -166,16 +163,11 @@ class GetAllTest extends ProjectTestCase
|
||||
|
||||
$this->createTestProjectOptimized($totalNumberOfProjects);
|
||||
|
||||
$client = static::createClient();
|
||||
$client->request(
|
||||
$this->getMethod(),
|
||||
self::BASE_URI."?page=$page&itemsPerPage=$itemsPerPage",
|
||||
$this->getRequestOptions($client)
|
||||
);
|
||||
$response = $this->request($this->getMethod(), self::BASE_URI."?page=$page&itemsPerPage=$itemsPerPage");
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
|
||||
$responseData = json_decode($client->getResponse()->getContent(), true);
|
||||
$responseData = json_decode($response->getContent(), true);
|
||||
$this->assertSame($totalNumberOfProjects, $responseData['totalItems']);
|
||||
$this->assertCount(max($numberOfProjectsInPage, 0), $responseData['member']);
|
||||
}
|
||||
@@ -187,16 +179,11 @@ class GetAllTest extends ProjectTestCase
|
||||
|
||||
$this->createTestProjectOptimized($totalNumberOfProjects);
|
||||
|
||||
$client = static::createClient();
|
||||
$client->request(
|
||||
$this->getMethod(),
|
||||
self::BASE_URI."?itemsPerPage=$itemsPerPage",
|
||||
$this->getRequestOptions($client)
|
||||
);
|
||||
$response = $this->request($this->getMethod(), self::BASE_URI."?itemsPerPage=$itemsPerPage");
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
|
||||
$responseData = json_decode($client->getResponse()->getContent(), true);
|
||||
$responseData = json_decode($response->getContent(), true);
|
||||
$this->assertSame($totalNumberOfProjects, $responseData['totalItems']);
|
||||
$this->assertCount($itemsPerPage, $responseData['member']);
|
||||
}
|
||||
@@ -208,35 +195,31 @@ class GetAllTest extends ProjectTestCase
|
||||
|
||||
public function testGetAllByCategoryWithInvalidCategory()
|
||||
{
|
||||
$this->createTestUser();
|
||||
|
||||
$category = 'invalid_category';
|
||||
$uri = self::BASE_URI."?category=$category";
|
||||
$client = static::createClient();
|
||||
$client->request($this->getMethod(), $uri, $this->getRequestOptions($client));
|
||||
$response = $this->request($this->getMethod(), $uri);
|
||||
|
||||
$responseCode = Response::HTTP_OK;
|
||||
$this->assertResponseStatusCodeSame($responseCode);
|
||||
|
||||
$responseData = json_decode($client->getResponse()->getContent(), true);
|
||||
$responseData = json_decode($response->getContent(), true);
|
||||
$this->assertCount(0, $responseData['member']);
|
||||
}
|
||||
|
||||
public function testGetAllByTitleNotFound()
|
||||
{
|
||||
ProjectFactory::createOne([
|
||||
'owner' => $this->createTestUser(),
|
||||
'owner' => TestUser::get(),
|
||||
'territory' => new Territory('ES'),
|
||||
'title' => 'Lorem ipsum title',
|
||||
]);
|
||||
|
||||
$uri = self::BASE_URI.'?title=NotFound';
|
||||
$client = static::createClient();
|
||||
$client->request($this->getMethod(), $uri, $this->getRequestOptions($client));
|
||||
$response = $this->request($this->getMethod(), $uri);
|
||||
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
|
||||
|
||||
$responseData = json_decode($client->getResponse()->getContent(), true);
|
||||
$responseData = json_decode($response->getContent(), true);
|
||||
$this->assertCount(0, $responseData['member']);
|
||||
}
|
||||
|
||||
@@ -276,12 +259,11 @@ class GetAllTest extends ProjectTestCase
|
||||
{
|
||||
$this->createTestProjectOptimized(1);
|
||||
|
||||
$client = static::createClient();
|
||||
$client->request('GET', self::BASE_URI, $this->getRequestOptions($client));
|
||||
$response = $this->request('GET', self::BASE_URI);
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
|
||||
$responseData = json_decode($client->getResponse()->getContent(), true);
|
||||
$responseData = json_decode($response->getContent(), true);
|
||||
$project = $responseData['member'][0];
|
||||
|
||||
$localesKey = 'locales';
|
||||
@@ -306,7 +288,7 @@ class GetAllTest extends ProjectTestCase
|
||||
static::createClient()->request(
|
||||
$this->getMethod(),
|
||||
self::BASE_URI,
|
||||
['headers' => ['Authorization' => 'Bearer 123']]
|
||||
['headers' => [TestingAuthenticator::AUTH_HEADER => 'none']]
|
||||
);
|
||||
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED);
|
||||
|
||||
@@ -41,11 +41,10 @@ class GetOneTest extends ProjectTestCase
|
||||
|
||||
private function testSuccessfulGetOneBase(Project $project): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->request('GET', $this->getUri(1), $this->getRequestOptions($client));
|
||||
$response = $this->request('GET', $this->getUri(1));
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
$responseData = json_decode($client->getResponse()->getContent(), true);
|
||||
$responseData = json_decode($response->getContent(), true);
|
||||
$this->assertProjectData($responseData, $project);
|
||||
}
|
||||
|
||||
@@ -79,16 +78,11 @@ class GetOneTest extends ProjectTestCase
|
||||
{
|
||||
$this->createTestProjectOptimized(1);
|
||||
|
||||
static::createClient()->request('GET', $this->getUri(1));
|
||||
$this->request('GET', $this->getUri(1));
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
public function testGetOneWithInvalidToken(): void
|
||||
{
|
||||
$this->testInvalidToken($this->getUri(1));
|
||||
}
|
||||
|
||||
public function testGetOneNotFound(): void
|
||||
{
|
||||
$this->testOneNotFound();
|
||||
|
||||
@@ -3,12 +3,11 @@
|
||||
namespace App\Tests\Entity\ProjectApi;
|
||||
|
||||
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
|
||||
use ApiPlatform\Symfony\Bundle\Test\Client;
|
||||
use App\Entity\Territory;
|
||||
use App\Factory\CategoryFactory;
|
||||
use App\Factory\Project\ProjectFactory;
|
||||
use App\Factory\User\UserFactory;
|
||||
use App\Tests\Traits\TestHelperTrait;
|
||||
use App\Tests\Fixtures\TestUser;
|
||||
use App\Tests\Traits\RequestingTestTrait;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Zenstruck\Foundry\Test\Factories;
|
||||
use Zenstruck\Foundry\Test\ResetDatabase;
|
||||
@@ -19,10 +18,8 @@ abstract class ProjectTestCase extends ApiTestCase
|
||||
{
|
||||
use ResetDatabase;
|
||||
use Factories;
|
||||
use TestHelperTrait;
|
||||
use RequestingTestTrait;
|
||||
|
||||
private const USER_EMAIL = 'testuser@example.com';
|
||||
private const USER_PASSWORD = 'projectapitestuserpassword';
|
||||
protected const BASE_URI = '/v4/projects';
|
||||
|
||||
public function setUp(): void
|
||||
@@ -49,41 +46,9 @@ abstract class ProjectTestCase extends ApiTestCase
|
||||
return self::BASE_URI.$param;
|
||||
}
|
||||
|
||||
protected function getHeaders(Client $client): array
|
||||
{
|
||||
return $this->getAuthHeaders(
|
||||
$client,
|
||||
self::USER_EMAIL,
|
||||
self::USER_PASSWORD,
|
||||
$this->getMethod()
|
||||
);
|
||||
}
|
||||
|
||||
protected function getRequestOptions(Client $client, array $data = []): array
|
||||
{
|
||||
$options = ['headers' => $this->getHeaders($client)];
|
||||
|
||||
if (!empty($data)) {
|
||||
$options['json'] = $data;
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
protected function createTestUser(
|
||||
string $handle = 'test_user',
|
||||
string $email = self::USER_EMAIL,
|
||||
) {
|
||||
return UserFactory::createOne([
|
||||
'handle' => $handle,
|
||||
'email' => $email,
|
||||
'password' => self::USER_PASSWORD,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function createTestProjectOptimized(int $count = 1, array $attributes = []): array
|
||||
{
|
||||
$owner = $this->createTestUser();
|
||||
$owner = TestUser::get();
|
||||
$territory = new Territory('ES');
|
||||
|
||||
$mergedAttributes = array_merge([
|
||||
@@ -96,57 +61,10 @@ abstract class ProjectTestCase extends ApiTestCase
|
||||
|
||||
// Auxiliary Tests
|
||||
|
||||
protected function testRequestHelper(
|
||||
array $data = [],
|
||||
string $uri = self::BASE_URI,
|
||||
int $expectedCode = Response::HTTP_OK,
|
||||
): void {
|
||||
$client = static::createClient();
|
||||
$client->request(
|
||||
$this->getMethod(),
|
||||
$uri,
|
||||
$this->getRequestOptions($client, $data)
|
||||
);
|
||||
|
||||
$this->assertResponseStatusCodeSame($expectedCode);
|
||||
}
|
||||
|
||||
protected function testOneNotFound(): void
|
||||
{
|
||||
$this->createTestProjectOptimized(1);
|
||||
|
||||
$this->testRequestHelper([], $this->getUri(999), Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
protected function testInvalidToken(
|
||||
string $uri,
|
||||
string $contentType = 'application/json',
|
||||
int $expectedToken = Response::HTTP_UNAUTHORIZED,
|
||||
): void {
|
||||
static::createClient()->request(
|
||||
$this->getMethod(),
|
||||
$uri,
|
||||
['headers' => [
|
||||
'Authorization' => 'Bearer invalid_token',
|
||||
'Content-Type' => $contentType,
|
||||
]]
|
||||
);
|
||||
|
||||
$this->assertResponseStatusCodeSame($expectedToken);
|
||||
}
|
||||
|
||||
protected function testForbidden(): void
|
||||
{
|
||||
$otherUser = $this->createTestUser('other_user', 'otheruser@example.com');
|
||||
$this->createTestProjectOptimized(1, ['owner' => $otherUser]);
|
||||
|
||||
$client = static::createClient();
|
||||
$client->request(
|
||||
$this->getMethod(),
|
||||
$this->getUri(1),
|
||||
$this->getRequestOptions($client)
|
||||
);
|
||||
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
|
||||
$this->request($this->getMethod(), $this->getUri(999), [], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Tests\Entity\ProjectApi;
|
||||
|
||||
use App\Factory\User\UserFactory;
|
||||
use App\Tests\Fixtures\TestUser;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class UpdateTest extends ProjectTestCase
|
||||
@@ -19,12 +21,15 @@ class UpdateTest extends ProjectTestCase
|
||||
{
|
||||
$this->createTestProjectOptimized();
|
||||
|
||||
$dataToModify = [
|
||||
'title' => 'New project title',
|
||||
'description' => 'Updated project description',
|
||||
];
|
||||
$this->request($this->getMethod(), $this->getUri(1), [
|
||||
'headers' => $this->withAuthHeader(TestUser::get()),
|
||||
'json' => [
|
||||
'title' => 'New project title',
|
||||
'description' => 'Updated project description',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->testRequestHelper($dataToModify, $this->getUri(1));
|
||||
$this->assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
public function testUpdateUnauthorized(): void
|
||||
@@ -36,11 +41,6 @@ class UpdateTest extends ProjectTestCase
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
public function testUpdateWithInvalidToken(): void
|
||||
{
|
||||
$this->testInvalidToken($this->getUri(1), 'application/merge-patch+json');
|
||||
}
|
||||
|
||||
public function testUpdateNotFound(): void
|
||||
{
|
||||
$this->testOneNotFound();
|
||||
@@ -49,23 +49,27 @@ class UpdateTest extends ProjectTestCase
|
||||
public function testUpdateInvalidInput(): void
|
||||
{
|
||||
$this->createTestProjectOptimized();
|
||||
$invalidInput = [
|
||||
'title' => 'New project title',
|
||||
'categories' => ['invalid-category'],
|
||||
];
|
||||
|
||||
$client = static::createClient();
|
||||
$client->request(
|
||||
$this->getMethod(),
|
||||
$this->getUri(1),
|
||||
$this->getRequestOptions($client, $invalidInput)
|
||||
);
|
||||
$this->request($this->getMethod(), $this->getUri(1), [
|
||||
'headers' => $this->withAuthHeader(TestUser::get()),
|
||||
'json' => [
|
||||
'title' => 'New project title',
|
||||
'categories' => ['invalid-category'],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
public function testUpdateForbidden(): void
|
||||
{
|
||||
$this->testForbidden();
|
||||
$this->createTestProjectOptimized();
|
||||
|
||||
$otherUser = UserFactory::new(['handle' => 'other_user', 'email' => 'otheruser@example.com'])->create();
|
||||
$this->request($this->getMethod(), $this->getUri(1), [
|
||||
'headers' => $this->withAuthHeader($otherUser),
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Fixtures;
|
||||
|
||||
use App\Entity\User\User;
|
||||
use App\Factory\User\UserFactory;
|
||||
use Zenstruck\Foundry\Test\Factories;
|
||||
|
||||
final class TestUser
|
||||
{
|
||||
use Factories;
|
||||
|
||||
private const EMAIL = 'test@example.com';
|
||||
|
||||
public static function get(): User
|
||||
{
|
||||
$existing = UserFactory::repository()->findOneBy([
|
||||
'email' => self::EMAIL,
|
||||
]);
|
||||
|
||||
if ($existing) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
return UserFactory::new()
|
||||
->create([
|
||||
'email' => self::EMAIL,
|
||||
'handle' => 'test_user',
|
||||
'password' => 'T35tU53rP455w0rd',
|
||||
'roles' => ['ROLE_USER'],
|
||||
])
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Traits;
|
||||
|
||||
use ApiPlatform\Symfony\Bundle\Test\Client;
|
||||
use App\Entity\User\User;
|
||||
use App\Security\TestingAuthenticator;
|
||||
use Symfony\Contracts\HttpClient\ResponseInterface;
|
||||
|
||||
trait RequestingTestTrait
|
||||
{
|
||||
protected function request(
|
||||
string $method,
|
||||
string $uri,
|
||||
array $options = [],
|
||||
?int $expectedCode = null,
|
||||
): ResponseInterface {
|
||||
/** @var Client */
|
||||
$client = static::createClient();
|
||||
$response = $client->request($method, $uri, \array_merge_recursive(
|
||||
[
|
||||
'headers' => [
|
||||
'Content-Type' => match ($method) {
|
||||
'PATCH' => 'application/merge-patch+json',
|
||||
default => 'application/json',
|
||||
},
|
||||
],
|
||||
],
|
||||
$options,
|
||||
));
|
||||
|
||||
if ($expectedCode !== null) {
|
||||
$this->assertResponseStatusCodeSame($expectedCode);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
protected function withAuthHeader(User $user): array
|
||||
{
|
||||
return [
|
||||
TestingAuthenticator::AUTH_HEADER => $user->getId(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Traits;
|
||||
|
||||
use ApiPlatform\Symfony\Bundle\Test\Client;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
trait TestHelperTrait
|
||||
{
|
||||
protected function getValidToken(Client $client, string $email, string $password): string
|
||||
{
|
||||
$client->request(
|
||||
'POST',
|
||||
'/v4/user_tokens',
|
||||
[
|
||||
'json' => [
|
||||
'identifier' => $email,
|
||||
'password' => $password,
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
return json_decode($client->getResponse()->getContent(), true)['token'];
|
||||
}
|
||||
|
||||
protected function getAuthHeaders(
|
||||
Client $client,
|
||||
string $email,
|
||||
string $password,
|
||||
string $method = 'GET',
|
||||
): array {
|
||||
$contentType = $method == 'PATCH' ? 'application/merge-patch+json' : 'application/json';
|
||||
|
||||
return [
|
||||
'Authorization' => 'Bearer '.$this->getValidToken($client, $email, $password),
|
||||
'Content-Type' => $contentType,
|
||||
];
|
||||
}
|
||||
|
||||
protected function testInvalidToken(
|
||||
string $uri,
|
||||
string $contentType = 'application/json',
|
||||
int $expectedToken = Response::HTTP_UNAUTHORIZED,
|
||||
): void {
|
||||
static::createClient()->request(
|
||||
$this->getMethod(),
|
||||
$uri,
|
||||
['headers' => [
|
||||
'Authorization' => 'Bearer invalid_token',
|
||||
'Content-Type' => $contentType,
|
||||
]]
|
||||
);
|
||||
|
||||
$this->assertResponseStatusCodeSame($expectedToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
greeting: Hello, %name%.
|
||||
|
||||
login:
|
||||
title: Login
|
||||
loggedinas: You are logged in as %name%
|
||||
logout: Logout
|
||||
username:
|
||||
label: Email or username
|
||||
placeholder: johndoe@example.com
|
||||
password:
|
||||
label: Password
|
||||
placeholder: TrustNo1
|
||||
rememberme: Remember me
|
||||
submit: Sign in
|
||||
|
||||
consent:
|
||||
title: Authorize
|
||||
description: The application %clientapp% would like permission to access your Goteo account.
|
||||
tip: You should only give access to your Goteo account to third parties you trust.
|
||||
consent: Yes, allow access
|
||||
submit: Continue
|
||||
|
||||
footer:
|
||||
legal:
|
||||
termsOfUse: Terms of use
|
||||
licenses: Licencias
|
||||
platoniq:
|
||||
foundation: Foundation
|
||||
@@ -0,0 +1,81 @@
|
||||
const Encore = require('@symfony/webpack-encore');
|
||||
|
||||
// Manually configure the runtime environment if not already configured yet by the "encore" command.
|
||||
// It's useful when you use tools that rely on webpack.config.js file.
|
||||
if (!Encore.isRuntimeEnvironmentConfigured()) {
|
||||
Encore.configureRuntimeEnvironment(process.env.NODE_ENV || 'dev');
|
||||
}
|
||||
|
||||
Encore
|
||||
// directory where compiled assets will be stored
|
||||
.setOutputPath('public/build/')
|
||||
// public path used by the web server to access the output path
|
||||
.setPublicPath('/build')
|
||||
// only needed for CDN's or subdirectory deploy
|
||||
//.setManifestKeyPrefix('build/')
|
||||
|
||||
/*
|
||||
* ENTRY CONFIG
|
||||
*
|
||||
* Each entry will result in one JavaScript file (e.g. app.js)
|
||||
* and one CSS file (e.g. app.css) if your JavaScript imports CSS.
|
||||
*/
|
||||
.addEntry('app', './assets/app.js')
|
||||
|
||||
// When enabled, Webpack "splits" your files into smaller pieces for greater optimization.
|
||||
.splitEntryChunks()
|
||||
|
||||
// enables the Symfony UX Stimulus bridge (used in assets/bootstrap.js)
|
||||
.enableStimulusBridge('./assets/controllers.json')
|
||||
|
||||
// will require an extra script tag for runtime.js
|
||||
// but, you probably want this, unless you're building a single-page app
|
||||
.enableSingleRuntimeChunk()
|
||||
|
||||
/*
|
||||
* FEATURE CONFIG
|
||||
*
|
||||
* Enable & configure other features below. For a full
|
||||
* list of features, see:
|
||||
* https://symfony.com/doc/current/frontend.html#adding-more-features
|
||||
*/
|
||||
.cleanupOutputBeforeBuild()
|
||||
|
||||
// Displays build status system notifications to the user
|
||||
// .enableBuildNotifications()
|
||||
|
||||
.enableSourceMaps(!Encore.isProduction())
|
||||
// enables hashed filenames (e.g. app.abc123.css)
|
||||
.enableVersioning(Encore.isProduction())
|
||||
|
||||
// configure Babel
|
||||
// .configureBabel((config) => {
|
||||
// config.plugins.push('@babel/a-babel-plugin');
|
||||
// })
|
||||
|
||||
// enables and configure @babel/preset-env polyfills
|
||||
.configureBabelPresetEnv((config) => {
|
||||
config.useBuiltIns = 'usage';
|
||||
config.corejs = '3.38';
|
||||
})
|
||||
|
||||
.enablePostCssLoader()
|
||||
|
||||
// enables Sass/SCSS support
|
||||
//.enableSassLoader()
|
||||
|
||||
// uncomment if you use TypeScript
|
||||
//.enableTypeScriptLoader()
|
||||
|
||||
// uncomment if you use React
|
||||
//.enableReactPreset()
|
||||
|
||||
// uncomment to get integrity="..." attributes on your script & link tags
|
||||
// requires WebpackEncoreBundle 1.4 or higher
|
||||
//.enableIntegrityHashes(Encore.isProduction())
|
||||
|
||||
// uncomment if you're having problems with a jQuery plugin
|
||||
//.autoProvidejQuery()
|
||||
;
|
||||
|
||||
module.exports = Encore.getWebpackConfig();
|
||||
Reference in New Issue
Block a user