mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-08-01 14:11:47 +00:00
Use bundle entities
This commit is contained in:
parent
c2948f7d84
commit
01a747da1d
30 changed files with 665 additions and 1954 deletions
|
|
@ -1,12 +1,13 @@
|
||||||
## OAuth2 authorization server used for API/MCP app auto-provisioning (see docs/api/authentication.md).
|
## OAuth2 authorization server used for API/MCP app auto-provisioning (see docs/api/authentication.md).
|
||||||
#
|
#
|
||||||
# The bundle's own persistence layer (its Client/AccessToken/RefreshToken/AuthCode Doctrine entities) is
|
# Uses the bundle's own Doctrine persistence (its Client/AccessToken/RefreshToken/AuthorizationCode
|
||||||
# NOT used - "persistence: in_memory" here just satisfies the bundle's config schema (it registers a set
|
# entities, tables prefixed "oauth2_") rather than custom entities/repositories, so upstream bundle
|
||||||
# of trivial in-memory managers that some of the bundle's console commands/services still need to exist,
|
# upgrades keep this working without us having to touch repository code every time (as happened going
|
||||||
# even though we never use them). The actual League\OAuth2\Server\Repositories\* interfaces are overridden
|
# from league/oauth2-server 8.5 to 9.2). OAuth-issued tokens are therefore a distinct credential type
|
||||||
# in config/services.yaml to point at our own Doctrine-backed repositories (App\Security\OAuth\Repository),
|
# from App\Entity\UserSystem\ApiToken (Personal Access Tokens) - see App\Security\OAuth\OAuthBearerAuthenticator
|
||||||
# which make an OAuth2 access token just be a regular App\Entity\UserSystem\ApiToken row - see
|
# for how incoming requests are routed to the right validator, and role_prefix below for how OAuth scopes
|
||||||
# App\Security\OAuth\Repository\AccessTokenRepository for why.
|
# end up granting the exact same ROLE_API_* roles the existing permission system (config/permissions.yaml)
|
||||||
|
# already understands.
|
||||||
league_oauth2_server:
|
league_oauth2_server:
|
||||||
authorization_server:
|
authorization_server:
|
||||||
private_key: '%kernel.project_dir%/var/oauth2/private.key'
|
private_key: '%kernel.project_dir%/var/oauth2/private.key'
|
||||||
|
|
@ -26,18 +27,24 @@ league_oauth2_server:
|
||||||
enable_refresh_token_grant: true
|
enable_refresh_token_grant: true
|
||||||
require_code_challenge_for_public_clients: true
|
require_code_challenge_for_public_clients: true
|
||||||
revoke_refresh_tokens: true
|
revoke_refresh_tokens: true
|
||||||
response_type_class: App\Security\OAuth\OpaqueBearerTokenResponse
|
# No response_type_class override: access tokens are real JWTs (the bundle's default). Clients
|
||||||
|
# only ever treat access_token as an opaque string per the OAuth2 spec, so this is not a
|
||||||
|
# compatibility concern - and it means the RSA keypair below is actually used, unlike the earlier
|
||||||
|
# custom-repository design.
|
||||||
resource_server:
|
resource_server:
|
||||||
# Unused: we protect /api and /mcp with our own App\Security\ApiTokenAuthenticator, not the
|
|
||||||
# bundle's resource-server/security integration. Still required by the bundle's config schema.
|
|
||||||
public_key: '%kernel.project_dir%/var/oauth2/public.key'
|
public_key: '%kernel.project_dir%/var/oauth2/public.key'
|
||||||
scopes:
|
scopes:
|
||||||
# Scopes are just the existing App\Entity\UserSystem\ApiTokenLevel enum - see App\Security\OAuth\OAuthScope.
|
# Scopes map 1:1 onto the existing App\Entity\UserSystem\ApiTokenLevel enum names. Combined with
|
||||||
|
# role_prefix below, an OAuth token scoped "edit" ends up with role ROLE_API_EDIT - the exact same
|
||||||
|
# role App\Entity\UserSystem\ApiTokenLevel::EDIT grants a Personal Access Token, so
|
||||||
|
# config/permissions.yaml's apiTokenRole mappings apply unchanged to both credential types (see
|
||||||
|
# App\Services\UserSystem\VoterHelper for the permission-ceiling check both types go through).
|
||||||
available: ['read_only', 'edit', 'admin', 'full']
|
available: ['read_only', 'edit', 'admin', 'full']
|
||||||
default: ['read_only']
|
default: ['read_only']
|
||||||
persistence:
|
persistence:
|
||||||
in_memory: ~
|
doctrine:
|
||||||
|
table_prefix: 'oauth2_'
|
||||||
client:
|
client:
|
||||||
# Irrelevant to us (we bypass the bundle's own client model entirely - see the top comment),
|
|
||||||
# set to false just to silence the bundle's deprecation notice for the true default.
|
|
||||||
allow_plaintext_secrets: false
|
allow_plaintext_secrets: false
|
||||||
|
# Roles end up as ROLE_API_<SCOPE>, e.g. ROLE_API_READ_ONLY - see the "scopes" comment above.
|
||||||
|
role_prefix: 'ROLE_API_'
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ security:
|
||||||
|
|
||||||
custom_authenticators:
|
custom_authenticators:
|
||||||
- App\Security\ApiTokenAuthenticator
|
- App\Security\ApiTokenAuthenticator
|
||||||
|
- App\Security\OAuth\OAuthBearerAuthenticator
|
||||||
|
|
||||||
two_factor:
|
two_factor:
|
||||||
auth_form_path: 2fa_login
|
auth_form_path: 2fa_login
|
||||||
|
|
|
||||||
|
|
@ -140,22 +140,6 @@ services:
|
||||||
$update_group_on_login: '%env(bool:SAML_UPDATE_GROUP_ON_LOGIN)%'
|
$update_group_on_login: '%env(bool:SAML_UPDATE_GROUP_ON_LOGIN)%'
|
||||||
|
|
||||||
|
|
||||||
####################################################################################################################
|
|
||||||
# OAuth2 authorization server (auto-provisioning of API/MCP tokens, see docs/api/authentication.md)
|
|
||||||
#
|
|
||||||
# league/oauth2-server-bundle provides the /authorize and /token routes/controllers and builds the
|
|
||||||
# League\OAuth2\Server\AuthorizationServer service from config/packages/league_oauth2_server.yaml, but
|
|
||||||
# its own persistence layer is not used (see that file's top comment) - these overrides point the
|
|
||||||
# core league/oauth2-server repository interfaces at our own Doctrine-backed implementations instead
|
|
||||||
# of the bundle's, so an OAuth2 access token stays a plain App\Entity\UserSystem\ApiToken row.
|
|
||||||
####################################################################################################################
|
|
||||||
|
|
||||||
League\OAuth2\Server\Repositories\ClientRepositoryInterface: '@App\Security\OAuth\Repository\ClientRepository'
|
|
||||||
League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface: '@App\Security\OAuth\Repository\AccessTokenRepository'
|
|
||||||
League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface: '@App\Security\OAuth\Repository\RefreshTokenRepository'
|
|
||||||
League\OAuth2\Server\Repositories\AuthCodeRepositoryInterface: '@App\Security\OAuth\Repository\AuthCodeRepository'
|
|
||||||
League\OAuth2\Server\Repositories\ScopeRepositoryInterface: '@App\Security\OAuth\Repository\ScopeRepository'
|
|
||||||
|
|
||||||
security.access_token_extractor.header.token:
|
security.access_token_extractor.header.token:
|
||||||
class: Symfony\Component\Security\Http\AccessToken\HeaderAccessTokenExtractor
|
class: Symfony\Component\Security\Http\AccessToken\HeaderAccessTokenExtractor
|
||||||
arguments:
|
arguments:
|
||||||
|
|
|
||||||
|
|
@ -1,279 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace DoctrineMigrations;
|
|
||||||
|
|
||||||
use App\Migration\AbstractMultiPlatformMigration;
|
|
||||||
use Doctrine\DBAL\Schema\Schema;
|
|
||||||
use Doctrine\Migrations\AbstractMigration;
|
|
||||||
|
|
||||||
final class Version20260726170000 extends AbstractMultiPlatformMigration
|
|
||||||
{
|
|
||||||
public function getDescription(): string
|
|
||||||
{
|
|
||||||
return 'Add OAuth2 authorization server tables (oauth_clients, oauth_auth_codes, oauth_refresh_tokens) and link api_tokens to the OAuth client that issued them';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function mySQLUp(Schema $schema): void
|
|
||||||
{
|
|
||||||
$this->addSql(<<<'SQL'
|
|
||||||
CREATE TABLE oauth_clients (
|
|
||||||
id INT AUTO_INCREMENT NOT NULL,
|
|
||||||
identifier VARCHAR(255) NOT NULL,
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
redirect_uris JSON NOT NULL,
|
|
||||||
registration_access_token VARCHAR(255) NOT NULL,
|
|
||||||
dynamically_registered TINYINT(1) NOT NULL,
|
|
||||||
last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
|
||||||
datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
|
||||||
UNIQUE INDEX UNIQ_13CE8101772E836A (identifier),
|
|
||||||
UNIQUE INDEX UNIQ_13CE8101105C05AC (registration_access_token),
|
|
||||||
PRIMARY KEY (id)
|
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`
|
|
||||||
SQL);
|
|
||||||
$this->addSql(<<<'SQL'
|
|
||||||
CREATE TABLE oauth_auth_codes (
|
|
||||||
id INT AUTO_INCREMENT NOT NULL,
|
|
||||||
identifier VARCHAR(255) NOT NULL,
|
|
||||||
scope_identifiers JSON NOT NULL,
|
|
||||||
expiry_date_time DATETIME NOT NULL,
|
|
||||||
redirect_uri VARCHAR(255) DEFAULT NULL,
|
|
||||||
revoked TINYINT(1) NOT NULL,
|
|
||||||
client_id INT NOT NULL,
|
|
||||||
user_id INT NOT NULL,
|
|
||||||
UNIQUE INDEX UNIQ_BB493F83772E836A (identifier),
|
|
||||||
INDEX IDX_BB493F8319EB6921 (client_id),
|
|
||||||
INDEX IDX_BB493F83A76ED395 (user_id),
|
|
||||||
PRIMARY KEY (id)
|
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`
|
|
||||||
SQL);
|
|
||||||
$this->addSql(<<<'SQL'
|
|
||||||
CREATE TABLE oauth_refresh_tokens (
|
|
||||||
id INT AUTO_INCREMENT NOT NULL,
|
|
||||||
identifier VARCHAR(255) NOT NULL,
|
|
||||||
expiry_date_time DATETIME NOT NULL,
|
|
||||||
revoked TINYINT(1) NOT NULL,
|
|
||||||
family_id VARCHAR(255) NOT NULL,
|
|
||||||
api_token_id INT NOT NULL,
|
|
||||||
client_id INT NOT NULL,
|
|
||||||
UNIQUE INDEX UNIQ_5AB687772E836A (identifier),
|
|
||||||
INDEX IDX_5AB68792E52D36 (api_token_id),
|
|
||||||
INDEX IDX_5AB68719EB6921 (client_id),
|
|
||||||
PRIMARY KEY (id)
|
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`
|
|
||||||
SQL);
|
|
||||||
$this->addSql('ALTER TABLE oauth_auth_codes ADD CONSTRAINT FK_BB493F8319EB6921 FOREIGN KEY (client_id) REFERENCES oauth_clients (id) ON DELETE CASCADE');
|
|
||||||
$this->addSql('ALTER TABLE oauth_auth_codes ADD CONSTRAINT FK_BB493F83A76ED395 FOREIGN KEY (user_id) REFERENCES `users` (id) ON DELETE CASCADE');
|
|
||||||
$this->addSql('ALTER TABLE oauth_refresh_tokens ADD CONSTRAINT FK_5AB68792E52D36 FOREIGN KEY (api_token_id) REFERENCES api_tokens (id) ON DELETE CASCADE');
|
|
||||||
$this->addSql('ALTER TABLE oauth_refresh_tokens ADD CONSTRAINT FK_5AB68719EB6921 FOREIGN KEY (client_id) REFERENCES oauth_clients (id) ON DELETE CASCADE');
|
|
||||||
$this->addSql('ALTER TABLE api_tokens ADD oauth_client_id INT DEFAULT NULL');
|
|
||||||
$this->addSql('ALTER TABLE api_tokens ADD CONSTRAINT FK_2CAD560EDCA49ED FOREIGN KEY (oauth_client_id) REFERENCES oauth_clients (id) ON DELETE CASCADE');
|
|
||||||
$this->addSql('CREATE INDEX IDX_2CAD560EDCA49ED ON api_tokens (oauth_client_id)');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function mySQLDown(Schema $schema): void
|
|
||||||
{
|
|
||||||
$this->addSql('ALTER TABLE oauth_auth_codes DROP FOREIGN KEY FK_BB493F8319EB6921');
|
|
||||||
$this->addSql('ALTER TABLE oauth_auth_codes DROP FOREIGN KEY FK_BB493F83A76ED395');
|
|
||||||
$this->addSql('ALTER TABLE oauth_refresh_tokens DROP FOREIGN KEY FK_5AB68792E52D36');
|
|
||||||
$this->addSql('ALTER TABLE oauth_refresh_tokens DROP FOREIGN KEY FK_5AB68719EB6921');
|
|
||||||
$this->addSql('ALTER TABLE api_tokens DROP FOREIGN KEY FK_2CAD560EDCA49ED');
|
|
||||||
$this->addSql('DROP INDEX IDX_2CAD560EDCA49ED ON api_tokens');
|
|
||||||
$this->addSql('ALTER TABLE api_tokens DROP oauth_client_id');
|
|
||||||
$this->addSql('DROP TABLE oauth_auth_codes');
|
|
||||||
$this->addSql('DROP TABLE oauth_refresh_tokens');
|
|
||||||
$this->addSql('DROP TABLE oauth_clients');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function sqLiteUp(Schema $schema): void
|
|
||||||
{
|
|
||||||
$this->addSql(<<<'SQL'
|
|
||||||
CREATE TABLE oauth_clients (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
identifier VARCHAR(255) NOT NULL,
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
redirect_uris CLOB NOT NULL,
|
|
||||||
registration_access_token VARCHAR(255) NOT NULL,
|
|
||||||
dynamically_registered BOOLEAN NOT NULL,
|
|
||||||
last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
|
||||||
datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
|
|
||||||
)
|
|
||||||
SQL);
|
|
||||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_13CE8101772E836A ON oauth_clients (identifier)');
|
|
||||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_13CE8101105C05AC ON oauth_clients (registration_access_token)');
|
|
||||||
$this->addSql(<<<'SQL'
|
|
||||||
CREATE TABLE oauth_auth_codes (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
identifier VARCHAR(255) NOT NULL,
|
|
||||||
scope_identifiers CLOB NOT NULL,
|
|
||||||
expiry_date_time DATETIME NOT NULL,
|
|
||||||
redirect_uri VARCHAR(255) DEFAULT NULL,
|
|
||||||
revoked BOOLEAN NOT NULL,
|
|
||||||
client_id INTEGER NOT NULL,
|
|
||||||
user_id INTEGER NOT NULL,
|
|
||||||
CONSTRAINT FK_BB493F8319EB6921 FOREIGN KEY (client_id) REFERENCES oauth_clients (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE,
|
|
||||||
CONSTRAINT FK_BB493F83A76ED395 FOREIGN KEY (user_id) REFERENCES "users" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE
|
|
||||||
)
|
|
||||||
SQL);
|
|
||||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_BB493F83772E836A ON oauth_auth_codes (identifier)');
|
|
||||||
$this->addSql('CREATE INDEX IDX_BB493F8319EB6921 ON oauth_auth_codes (client_id)');
|
|
||||||
$this->addSql('CREATE INDEX IDX_BB493F83A76ED395 ON oauth_auth_codes (user_id)');
|
|
||||||
$this->addSql(<<<'SQL'
|
|
||||||
CREATE TABLE oauth_refresh_tokens (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
identifier VARCHAR(255) NOT NULL,
|
|
||||||
expiry_date_time DATETIME NOT NULL,
|
|
||||||
revoked BOOLEAN NOT NULL,
|
|
||||||
family_id VARCHAR(255) NOT NULL,
|
|
||||||
api_token_id INTEGER NOT NULL,
|
|
||||||
client_id INTEGER NOT NULL,
|
|
||||||
CONSTRAINT FK_5AB68792E52D36 FOREIGN KEY (api_token_id) REFERENCES api_tokens (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE,
|
|
||||||
CONSTRAINT FK_5AB68719EB6921 FOREIGN KEY (client_id) REFERENCES oauth_clients (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE
|
|
||||||
)
|
|
||||||
SQL);
|
|
||||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_5AB687772E836A ON oauth_refresh_tokens (identifier)');
|
|
||||||
$this->addSql('CREATE INDEX IDX_5AB68792E52D36 ON oauth_refresh_tokens (api_token_id)');
|
|
||||||
$this->addSql('CREATE INDEX IDX_5AB68719EB6921 ON oauth_refresh_tokens (client_id)');
|
|
||||||
|
|
||||||
//SQLite can't ALTER TABLE ADD COLUMN with a foreign key constraint, so the table is rebuilt
|
|
||||||
$this->addSql(<<<'SQL'
|
|
||||||
CREATE TEMPORARY TABLE __temp__api_tokens AS
|
|
||||||
SELECT id, user_id, name, valid_until, token, level, last_time_used, last_modified, datetime_added
|
|
||||||
FROM api_tokens
|
|
||||||
SQL);
|
|
||||||
$this->addSql('DROP TABLE api_tokens');
|
|
||||||
$this->addSql(<<<'SQL'
|
|
||||||
CREATE TABLE api_tokens (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
user_id INTEGER DEFAULT NULL,
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
valid_until DATETIME DEFAULT NULL,
|
|
||||||
token VARCHAR(68) NOT NULL,
|
|
||||||
level SMALLINT NOT NULL,
|
|
||||||
last_time_used DATETIME DEFAULT NULL,
|
|
||||||
last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
|
||||||
datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
|
||||||
oauth_client_id INTEGER DEFAULT NULL,
|
|
||||||
CONSTRAINT FK_2CAD560EA76ED395 FOREIGN KEY (user_id) REFERENCES users (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE,
|
|
||||||
CONSTRAINT FK_2CAD560EDCA49ED FOREIGN KEY (oauth_client_id) REFERENCES oauth_clients (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE
|
|
||||||
)
|
|
||||||
SQL);
|
|
||||||
$this->addSql(<<<'SQL'
|
|
||||||
INSERT INTO api_tokens (id, user_id, name, valid_until, token, level, last_time_used, last_modified, datetime_added)
|
|
||||||
SELECT id, user_id, name, valid_until, token, level, last_time_used, last_modified, datetime_added
|
|
||||||
FROM __temp__api_tokens
|
|
||||||
SQL);
|
|
||||||
$this->addSql('DROP TABLE __temp__api_tokens');
|
|
||||||
$this->addSql('CREATE INDEX IDX_2CAD560EA76ED395 ON api_tokens (user_id)');
|
|
||||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_2CAD560E5F37A13B ON api_tokens (token)');
|
|
||||||
$this->addSql('CREATE INDEX IDX_2CAD560EDCA49ED ON api_tokens (oauth_client_id)');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function sqLiteDown(Schema $schema): void
|
|
||||||
{
|
|
||||||
$this->addSql('DROP TABLE oauth_auth_codes');
|
|
||||||
$this->addSql('DROP TABLE oauth_refresh_tokens');
|
|
||||||
$this->addSql('DROP TABLE oauth_clients');
|
|
||||||
|
|
||||||
$this->addSql(<<<'SQL'
|
|
||||||
CREATE TEMPORARY TABLE __temp__api_tokens AS
|
|
||||||
SELECT id, name, valid_until, token, level, last_time_used, last_modified, datetime_added, user_id
|
|
||||||
FROM api_tokens
|
|
||||||
SQL);
|
|
||||||
$this->addSql('DROP TABLE api_tokens');
|
|
||||||
$this->addSql(<<<'SQL'
|
|
||||||
CREATE TABLE api_tokens (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
valid_until DATETIME DEFAULT NULL,
|
|
||||||
token VARCHAR(68) NOT NULL,
|
|
||||||
level SMALLINT NOT NULL,
|
|
||||||
last_time_used DATETIME DEFAULT NULL,
|
|
||||||
last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
|
||||||
datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
|
||||||
user_id INTEGER DEFAULT NULL,
|
|
||||||
CONSTRAINT FK_2CAD560EA76ED395 FOREIGN KEY (user_id) REFERENCES "users" (id) NOT DEFERRABLE INITIALLY IMMEDIATE
|
|
||||||
)
|
|
||||||
SQL);
|
|
||||||
$this->addSql(<<<'SQL'
|
|
||||||
INSERT INTO api_tokens (id, name, valid_until, token, level, last_time_used, last_modified, datetime_added, user_id)
|
|
||||||
SELECT id, name, valid_until, token, level, last_time_used, last_modified, datetime_added, user_id
|
|
||||||
FROM __temp__api_tokens
|
|
||||||
SQL);
|
|
||||||
$this->addSql('DROP TABLE __temp__api_tokens');
|
|
||||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_2CAD560E5F37A13B ON api_tokens (token)');
|
|
||||||
$this->addSql('CREATE INDEX IDX_2CAD560EA76ED395 ON api_tokens (user_id)');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function postgreSQLUp(Schema $schema): void
|
|
||||||
{
|
|
||||||
$this->addSql(<<<'SQL'
|
|
||||||
CREATE TABLE oauth_clients (
|
|
||||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
|
||||||
identifier VARCHAR(255) NOT NULL,
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
redirect_uris JSON NOT NULL,
|
|
||||||
registration_access_token VARCHAR(255) NOT NULL,
|
|
||||||
dynamically_registered BOOLEAN NOT NULL,
|
|
||||||
last_modified TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
|
||||||
datetime_added TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
|
||||||
PRIMARY KEY (id)
|
|
||||||
)
|
|
||||||
SQL);
|
|
||||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_13CE8101772E836A ON oauth_clients (identifier)');
|
|
||||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_13CE8101105C05AC ON oauth_clients (registration_access_token)');
|
|
||||||
$this->addSql(<<<'SQL'
|
|
||||||
CREATE TABLE oauth_auth_codes (
|
|
||||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
|
||||||
identifier VARCHAR(255) NOT NULL,
|
|
||||||
scope_identifiers JSON NOT NULL,
|
|
||||||
expiry_date_time TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
|
||||||
redirect_uri VARCHAR(255) DEFAULT NULL,
|
|
||||||
revoked BOOLEAN NOT NULL,
|
|
||||||
client_id INT NOT NULL,
|
|
||||||
user_id INT NOT NULL,
|
|
||||||
PRIMARY KEY (id)
|
|
||||||
)
|
|
||||||
SQL);
|
|
||||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_BB493F83772E836A ON oauth_auth_codes (identifier)');
|
|
||||||
$this->addSql('CREATE INDEX IDX_BB493F8319EB6921 ON oauth_auth_codes (client_id)');
|
|
||||||
$this->addSql('CREATE INDEX IDX_BB493F83A76ED395 ON oauth_auth_codes (user_id)');
|
|
||||||
$this->addSql(<<<'SQL'
|
|
||||||
CREATE TABLE oauth_refresh_tokens (
|
|
||||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
|
||||||
identifier VARCHAR(255) NOT NULL,
|
|
||||||
expiry_date_time TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
|
||||||
revoked BOOLEAN NOT NULL,
|
|
||||||
family_id VARCHAR(255) NOT NULL,
|
|
||||||
api_token_id INT NOT NULL,
|
|
||||||
client_id INT NOT NULL,
|
|
||||||
PRIMARY KEY (id)
|
|
||||||
)
|
|
||||||
SQL);
|
|
||||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_5AB687772E836A ON oauth_refresh_tokens (identifier)');
|
|
||||||
$this->addSql('CREATE INDEX IDX_5AB68792E52D36 ON oauth_refresh_tokens (api_token_id)');
|
|
||||||
$this->addSql('CREATE INDEX IDX_5AB68719EB6921 ON oauth_refresh_tokens (client_id)');
|
|
||||||
$this->addSql('ALTER TABLE oauth_auth_codes ADD CONSTRAINT FK_BB493F8319EB6921 FOREIGN KEY (client_id) REFERENCES oauth_clients (id) ON DELETE CASCADE NOT DEFERRABLE');
|
|
||||||
$this->addSql('ALTER TABLE oauth_auth_codes ADD CONSTRAINT FK_BB493F83A76ED395 FOREIGN KEY (user_id) REFERENCES "users" (id) ON DELETE CASCADE NOT DEFERRABLE');
|
|
||||||
$this->addSql('ALTER TABLE oauth_refresh_tokens ADD CONSTRAINT FK_5AB68792E52D36 FOREIGN KEY (api_token_id) REFERENCES api_tokens (id) ON DELETE CASCADE NOT DEFERRABLE');
|
|
||||||
$this->addSql('ALTER TABLE oauth_refresh_tokens ADD CONSTRAINT FK_5AB68719EB6921 FOREIGN KEY (client_id) REFERENCES oauth_clients (id) ON DELETE CASCADE NOT DEFERRABLE');
|
|
||||||
$this->addSql('ALTER TABLE api_tokens ADD oauth_client_id INT DEFAULT NULL');
|
|
||||||
$this->addSql('ALTER TABLE api_tokens ADD CONSTRAINT FK_2CAD560EDCA49ED FOREIGN KEY (oauth_client_id) REFERENCES oauth_clients (id) ON DELETE CASCADE NOT DEFERRABLE');
|
|
||||||
$this->addSql('CREATE INDEX IDX_2CAD560EDCA49ED ON api_tokens (oauth_client_id)');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function postgreSQLDown(Schema $schema): void
|
|
||||||
{
|
|
||||||
$this->addSql('ALTER TABLE oauth_auth_codes DROP CONSTRAINT FK_BB493F8319EB6921');
|
|
||||||
$this->addSql('ALTER TABLE oauth_auth_codes DROP CONSTRAINT FK_BB493F83A76ED395');
|
|
||||||
$this->addSql('ALTER TABLE oauth_refresh_tokens DROP CONSTRAINT FK_5AB68792E52D36');
|
|
||||||
$this->addSql('ALTER TABLE oauth_refresh_tokens DROP CONSTRAINT FK_5AB68719EB6921');
|
|
||||||
$this->addSql('ALTER TABLE api_tokens DROP CONSTRAINT FK_2CAD560EDCA49ED');
|
|
||||||
$this->addSql('DROP INDEX IDX_2CAD560EDCA49ED');
|
|
||||||
$this->addSql('ALTER TABLE api_tokens DROP oauth_client_id');
|
|
||||||
$this->addSql('DROP TABLE oauth_auth_codes');
|
|
||||||
$this->addSql('DROP TABLE oauth_refresh_tokens');
|
|
||||||
$this->addSql('DROP TABLE oauth_clients');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
209
migrations/Version20260726180454.php
Normal file
209
migrations/Version20260726180454.php
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use App\Migration\AbstractMultiPlatformMigration;
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260726180454 extends AbstractMultiPlatformMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add league/oauth2-server-bundle\'s own Doctrine tables (oauth2_client, oauth2_access_token, oauth2_refresh_token, oauth2_authorization_code) for OAuth2 authorization server support (API/MCP app auto-provisioning)';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mySQLUp(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE oauth2_client (
|
||||||
|
name VARCHAR(128) NOT NULL,
|
||||||
|
secret VARCHAR(128) DEFAULT NULL,
|
||||||
|
redirect_uris TEXT DEFAULT NULL,
|
||||||
|
grants TEXT DEFAULT NULL,
|
||||||
|
scopes TEXT DEFAULT NULL,
|
||||||
|
active TINYINT NOT NULL,
|
||||||
|
allow_plain_text_pkce TINYINT DEFAULT 0 NOT NULL,
|
||||||
|
identifier VARCHAR(32) NOT NULL,
|
||||||
|
PRIMARY KEY (identifier)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`
|
||||||
|
SQL);
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE oauth2_access_token (
|
||||||
|
identifier CHAR(80) NOT NULL,
|
||||||
|
expiry DATETIME NOT NULL,
|
||||||
|
user_identifier VARCHAR(128) DEFAULT NULL,
|
||||||
|
scopes TEXT DEFAULT NULL,
|
||||||
|
revoked TINYINT NOT NULL,
|
||||||
|
client VARCHAR(32) NOT NULL,
|
||||||
|
INDEX IDX_454D9673C7440455 (client),
|
||||||
|
PRIMARY KEY (identifier)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`
|
||||||
|
SQL);
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE oauth2_authorization_code (
|
||||||
|
identifier CHAR(80) NOT NULL,
|
||||||
|
expiry DATETIME NOT NULL,
|
||||||
|
user_identifier VARCHAR(128) DEFAULT NULL,
|
||||||
|
scopes TEXT DEFAULT NULL,
|
||||||
|
revoked TINYINT NOT NULL,
|
||||||
|
client VARCHAR(32) NOT NULL,
|
||||||
|
INDEX IDX_509FEF5FC7440455 (client),
|
||||||
|
PRIMARY KEY (identifier)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`
|
||||||
|
SQL);
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE oauth2_refresh_token (
|
||||||
|
identifier CHAR(80) NOT NULL,
|
||||||
|
expiry DATETIME NOT NULL,
|
||||||
|
revoked TINYINT NOT NULL,
|
||||||
|
access_token CHAR(80) DEFAULT NULL,
|
||||||
|
INDEX IDX_4DD90732B6A2DD68 (access_token),
|
||||||
|
PRIMARY KEY (identifier)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`
|
||||||
|
SQL);
|
||||||
|
$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');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mySQLDown(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_refresh_token');
|
||||||
|
$this->addSql('DROP TABLE oauth2_client');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sqLiteUp(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE oauth2_client (
|
||||||
|
name VARCHAR(128) NOT NULL,
|
||||||
|
secret VARCHAR(128) DEFAULT NULL,
|
||||||
|
redirect_uris CLOB DEFAULT NULL,
|
||||||
|
grants CLOB DEFAULT NULL,
|
||||||
|
scopes CLOB DEFAULT NULL,
|
||||||
|
active BOOLEAN NOT NULL,
|
||||||
|
allow_plain_text_pkce BOOLEAN DEFAULT 0 NOT NULL,
|
||||||
|
identifier VARCHAR(32) NOT NULL,
|
||||||
|
PRIMARY KEY (identifier)
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE oauth2_access_token (
|
||||||
|
identifier CHAR(80) NOT NULL,
|
||||||
|
expiry DATETIME NOT NULL,
|
||||||
|
user_identifier VARCHAR(128) DEFAULT NULL,
|
||||||
|
scopes CLOB DEFAULT NULL,
|
||||||
|
revoked BOOLEAN NOT NULL,
|
||||||
|
client VARCHAR(32) NOT NULL,
|
||||||
|
PRIMARY KEY (identifier),
|
||||||
|
CONSTRAINT FK_454D9673C7440455 FOREIGN KEY (client) REFERENCES oauth2_client (identifier) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
$this->addSql('CREATE INDEX IDX_454D9673C7440455 ON oauth2_access_token (client)');
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE oauth2_authorization_code (
|
||||||
|
identifier CHAR(80) NOT NULL,
|
||||||
|
expiry DATETIME NOT NULL,
|
||||||
|
user_identifier VARCHAR(128) DEFAULT NULL,
|
||||||
|
scopes CLOB DEFAULT NULL,
|
||||||
|
revoked BOOLEAN NOT NULL,
|
||||||
|
client VARCHAR(32) NOT NULL,
|
||||||
|
PRIMARY KEY (identifier),
|
||||||
|
CONSTRAINT FK_509FEF5FC7440455 FOREIGN KEY (client) REFERENCES oauth2_client (identifier) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
$this->addSql('CREATE INDEX IDX_509FEF5FC7440455 ON oauth2_authorization_code (client)');
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE oauth2_refresh_token (
|
||||||
|
identifier CHAR(80) NOT NULL,
|
||||||
|
expiry DATETIME NOT NULL,
|
||||||
|
revoked BOOLEAN NOT NULL,
|
||||||
|
access_token CHAR(80) DEFAULT NULL,
|
||||||
|
PRIMARY KEY (identifier),
|
||||||
|
CONSTRAINT FK_4DD90732B6A2DD68 FOREIGN KEY (access_token) REFERENCES oauth2_access_token (identifier) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
$this->addSql('CREATE INDEX IDX_4DD90732B6A2DD68 ON oauth2_refresh_token (access_token)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sqLiteDown(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('DROP TABLE oauth2_access_token');
|
||||||
|
$this->addSql('DROP TABLE oauth2_authorization_code');
|
||||||
|
$this->addSql('DROP TABLE oauth2_refresh_token');
|
||||||
|
$this->addSql('DROP TABLE oauth2_client');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function postgreSQLUp(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE oauth2_client (
|
||||||
|
name VARCHAR(128) NOT NULL,
|
||||||
|
secret VARCHAR(128) DEFAULT NULL,
|
||||||
|
redirect_uris TEXT DEFAULT NULL,
|
||||||
|
grants TEXT DEFAULT NULL,
|
||||||
|
scopes TEXT DEFAULT NULL,
|
||||||
|
active BOOLEAN NOT NULL,
|
||||||
|
allow_plain_text_pkce BOOLEAN DEFAULT false NOT NULL,
|
||||||
|
identifier VARCHAR(32) NOT NULL,
|
||||||
|
PRIMARY KEY (identifier)
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE oauth2_access_token (
|
||||||
|
identifier CHAR(80) NOT NULL,
|
||||||
|
expiry TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||||
|
user_identifier VARCHAR(128) DEFAULT NULL,
|
||||||
|
scopes TEXT DEFAULT NULL,
|
||||||
|
revoked BOOLEAN NOT NULL,
|
||||||
|
client VARCHAR(32) NOT NULL,
|
||||||
|
PRIMARY KEY (identifier)
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
$this->addSql('CREATE INDEX IDX_454D9673C7440455 ON oauth2_access_token (client)');
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE oauth2_authorization_code (
|
||||||
|
identifier CHAR(80) NOT NULL,
|
||||||
|
expiry TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||||
|
user_identifier VARCHAR(128) DEFAULT NULL,
|
||||||
|
scopes TEXT DEFAULT NULL,
|
||||||
|
revoked BOOLEAN NOT NULL,
|
||||||
|
client VARCHAR(32) NOT NULL,
|
||||||
|
PRIMARY KEY (identifier)
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
$this->addSql('CREATE INDEX IDX_509FEF5FC7440455 ON oauth2_authorization_code (client)');
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE oauth2_refresh_token (
|
||||||
|
identifier CHAR(80) NOT NULL,
|
||||||
|
expiry TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||||
|
revoked BOOLEAN NOT NULL,
|
||||||
|
access_token CHAR(80) DEFAULT NULL,
|
||||||
|
PRIMARY KEY (identifier)
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
$this->addSql('CREATE INDEX IDX_4DD90732B6A2DD68 ON oauth2_refresh_token (access_token)');
|
||||||
|
$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');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function postgreSQLDown(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE oauth2_access_token DROP CONSTRAINT FK_454D9673C7440455');
|
||||||
|
$this->addSql('ALTER TABLE oauth2_authorization_code DROP CONSTRAINT FK_509FEF5FC7440455');
|
||||||
|
$this->addSql('ALTER TABLE oauth2_refresh_token DROP CONSTRAINT FK_4DD90732B6A2DD68');
|
||||||
|
$this->addSql('DROP TABLE oauth2_access_token');
|
||||||
|
$this->addSql('DROP TABLE oauth2_authorization_code');
|
||||||
|
$this->addSql('DROP TABLE oauth2_refresh_token');
|
||||||
|
$this->addSql('DROP TABLE oauth2_client');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -34,10 +34,10 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates the RSA keypair required by league/oauth2-server-bundle's AuthorizationServer (for API/MCP
|
* Generates the RSA keypair required by league/oauth2-server-bundle's AuthorizationServer (for API/MCP
|
||||||
* app auto-provisioning, see config/packages/league_oauth2_server.yaml). This keypair is only used by
|
* app auto-provisioning, see config/packages/league_oauth2_server.yaml). Unlike an inert placeholder,
|
||||||
* the underlying library's (unused, in our setup) JWT-signing code path - see
|
* this keypair actually signs the JWT access tokens handed to OAuth clients - keep the private key
|
||||||
* App\Security\OAuth\OpaqueBearerTokenResponse - so its content is not otherwise security-critical, but
|
* secret and do not regenerate it while tokens signed with the old one are still outstanding (they
|
||||||
* the library still requires a syntactically valid keypair to exist at the configured paths.
|
* would fail validation against the new public key).
|
||||||
*/
|
*/
|
||||||
#[AsCommand('partdb:oauth:generate-keys', 'Generates the RSA keypair used by the OAuth2 authorization server (for API/MCP app auto-provisioning)')]
|
#[AsCommand('partdb:oauth:generate-keys', 'Generates the RSA keypair used by the OAuth2 authorization server (for API/MCP app auto-provisioning)')]
|
||||||
class OAuthGenerateKeysCommand extends Command
|
class OAuthGenerateKeysCommand extends Command
|
||||||
|
|
@ -61,7 +61,7 @@ class OAuthGenerateKeysCommand extends Command
|
||||||
$io = new SymfonyStyle($input, $output);
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
|
||||||
if (!$input->getOption('force') && (is_file($this->privateKeyPath) || is_file($this->publicKeyPath))) {
|
if (!$input->getOption('force') && (is_file($this->privateKeyPath) || is_file($this->publicKeyPath))) {
|
||||||
$io->error('A keypair already exists. Use --force to overwrite it (this invalidates nothing, since the keypair is not used to sign anything a client relies on - see OpaqueBearerTokenResponse).');
|
$io->error('A keypair already exists. Use --force to overwrite it - note this invalidates every outstanding OAuth2 access token, since they are JWTs signed with the old private key.');
|
||||||
|
|
||||||
return Command::FAILURE;
|
return Command::FAILURE;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@ use ApiPlatform\OpenApi\Model\Operation;
|
||||||
use ApiPlatform\Serializer\Filter\PropertyFilter;
|
use ApiPlatform\Serializer\Filter\PropertyFilter;
|
||||||
use App\Entity\Base\TimestampTrait;
|
use App\Entity\Base\TimestampTrait;
|
||||||
use App\Entity\Contracts\TimeStampableInterface;
|
use App\Entity\Contracts\TimeStampableInterface;
|
||||||
use App\Entity\UserSystem\OAuth\OAuthClient;
|
|
||||||
use App\Repository\UserSystem\ApiTokenRepository;
|
use App\Repository\UserSystem\ApiTokenRepository;
|
||||||
use App\State\CurrentApiTokenProvider;
|
use App\State\CurrentApiTokenProvider;
|
||||||
use App\Validator\Constraints\Year2038BugWorkaround;
|
use App\Validator\Constraints\Year2038BugWorkaround;
|
||||||
|
|
@ -92,15 +91,6 @@ class ApiToken implements TimeStampableInterface
|
||||||
#[Groups('token:read')]
|
#[Groups('token:read')]
|
||||||
private ?\DateTimeImmutable $last_time_used = null;
|
private ?\DateTimeImmutable $last_time_used = null;
|
||||||
|
|
||||||
/**
|
|
||||||
* The OAuth2 client this token was issued to, if it originates from the OAuth authorization code
|
|
||||||
* flow (see src/Security/OAuth) rather than being manually created by the user.
|
|
||||||
*/
|
|
||||||
#[ORM\ManyToOne(targetEntity: OAuthClient::class)]
|
|
||||||
#[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')]
|
|
||||||
#[Groups('token:read')]
|
|
||||||
private ?OAuthClient $oauthClient = null;
|
|
||||||
|
|
||||||
public function __construct(ApiTokenType $tokenType = ApiTokenType::PERSONAL_ACCESS_TOKEN)
|
public function __construct(ApiTokenType $tokenType = ApiTokenType::PERSONAL_ACCESS_TOKEN)
|
||||||
{
|
{
|
||||||
// Generate a rondom token on creation. The tokenType is 3 characters long (plus underscore), so the token is 68 characters long.
|
// Generate a rondom token on creation. The tokenType is 3 characters long (plus underscore), so the token is 68 characters long.
|
||||||
|
|
@ -205,15 +195,4 @@ class ApiToken implements TimeStampableInterface
|
||||||
return substr($this->token, -4);
|
return substr($this->token, -4);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getOauthClient(): ?OAuthClient
|
|
||||||
{
|
|
||||||
return $this->oauthClient;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setOauthClient(?OAuthClient $oauthClient): self
|
|
||||||
{
|
|
||||||
$this->oauthClient = $oauthClient;
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -31,13 +31,6 @@ enum ApiTokenType: string
|
||||||
{
|
{
|
||||||
case PERSONAL_ACCESS_TOKEN = 'tcp';
|
case PERSONAL_ACCESS_TOKEN = 'tcp';
|
||||||
|
|
||||||
/**
|
|
||||||
* A token issued through the OAuth2 authorization code flow (see src/Security/OAuth), rather than
|
|
||||||
* created manually by a user. Functionally just an ApiToken like any other; the prefix only exists
|
|
||||||
* so the UI can tell them apart.
|
|
||||||
*/
|
|
||||||
case OAUTH_ACCESS_TOKEN = 'oat';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the prefix of the token including the underscore
|
* Get the prefix of the token including the underscore
|
||||||
* @return string
|
* @return string
|
||||||
|
|
@ -60,4 +53,20 @@ enum ApiTokenType: string
|
||||||
}
|
}
|
||||||
return self::from($parts[0]);
|
return self::from($parts[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the given string looks like one of our own tokens (i.e. has a recognized prefix),
|
||||||
|
* without throwing. Used by App\Security\ApiTokenAuthenticator and
|
||||||
|
* App\Security\OAuth\OAuthBearerAuthenticator to decide, up front, which of the two authenticators
|
||||||
|
* should even attempt to handle a given bearer credential.
|
||||||
|
*/
|
||||||
|
public static function isRecognizedToken(string $token): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
self::getTypeFromToken($token);
|
||||||
|
return true;
|
||||||
|
} catch (\InvalidArgumentException|\ValueError) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,141 +0,0 @@
|
||||||
<?php
|
|
||||||
/*
|
|
||||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
|
||||||
*
|
|
||||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Entity\UserSystem\OAuth;
|
|
||||||
|
|
||||||
use App\Entity\UserSystem\User;
|
|
||||||
use App\Repository\UserSystem\OAuth\OAuthAuthCodeRepository;
|
|
||||||
use Doctrine\DBAL\Types\Types;
|
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A revocation/single-use record for a short-lived OAuth2 authorization code (RFC 6749 section 4.1).
|
|
||||||
*
|
|
||||||
* league/oauth2-server hands the client an encrypted, self-contained code and never asks for this
|
|
||||||
* data again - it only calls App\Security\OAuth\Repository\AuthCodeRepository::isAuthCodeRevoked()/
|
|
||||||
* revokeAuthCode() by identifier. So unlike OAuthClient, this entity does not need to implement
|
|
||||||
* AuthCodeEntityInterface; App\Security\OAuth\Entity\TransientAuthCode fills that role during issuance.
|
|
||||||
* The client/user/scopes/redirect_uri columns exist purely for admin auditing.
|
|
||||||
*/
|
|
||||||
#[ORM\Entity(repositoryClass: OAuthAuthCodeRepository::class)]
|
|
||||||
#[ORM\Table(name: 'oauth_auth_codes')]
|
|
||||||
class OAuthAuthCode
|
|
||||||
{
|
|
||||||
#[ORM\Id]
|
|
||||||
#[ORM\Column(type: Types::INTEGER)]
|
|
||||||
#[ORM\GeneratedValue]
|
|
||||||
private int $id;
|
|
||||||
|
|
||||||
#[ORM\Column(type: Types::STRING, unique: true)]
|
|
||||||
private string $identifier;
|
|
||||||
|
|
||||||
#[ORM\ManyToOne(targetEntity: OAuthClient::class)]
|
|
||||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
|
||||||
private OAuthClient $client;
|
|
||||||
|
|
||||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
|
||||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
|
||||||
private User $user;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var string[]
|
|
||||||
*/
|
|
||||||
#[ORM\Column(type: Types::JSON)]
|
|
||||||
private array $scopeIdentifiers = [];
|
|
||||||
|
|
||||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
|
|
||||||
private \DateTimeImmutable $expiryDateTime;
|
|
||||||
|
|
||||||
#[ORM\Column(type: Types::STRING, nullable: true)]
|
|
||||||
private ?string $redirectUri = null;
|
|
||||||
|
|
||||||
#[ORM\Column(type: Types::BOOLEAN)]
|
|
||||||
private bool $revoked = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param string[] $scopeIdentifiers
|
|
||||||
*/
|
|
||||||
public function __construct(
|
|
||||||
string $identifier,
|
|
||||||
OAuthClient $client,
|
|
||||||
User $user,
|
|
||||||
array $scopeIdentifiers,
|
|
||||||
\DateTimeImmutable $expiryDateTime,
|
|
||||||
?string $redirectUri,
|
|
||||||
) {
|
|
||||||
$this->identifier = $identifier;
|
|
||||||
$this->client = $client;
|
|
||||||
$this->user = $user;
|
|
||||||
$this->scopeIdentifiers = $scopeIdentifiers;
|
|
||||||
$this->expiryDateTime = $expiryDateTime;
|
|
||||||
$this->redirectUri = $redirectUri;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getId(): int
|
|
||||||
{
|
|
||||||
return $this->id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getIdentifier(): string
|
|
||||||
{
|
|
||||||
return $this->identifier;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getClient(): OAuthClient
|
|
||||||
{
|
|
||||||
return $this->client;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getUser(): User
|
|
||||||
{
|
|
||||||
return $this->user;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return string[]
|
|
||||||
*/
|
|
||||||
public function getScopeIdentifiers(): array
|
|
||||||
{
|
|
||||||
return $this->scopeIdentifiers;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getExpiryDateTime(): \DateTimeImmutable
|
|
||||||
{
|
|
||||||
return $this->expiryDateTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getRedirectUri(): ?string
|
|
||||||
{
|
|
||||||
return $this->redirectUri;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isRevoked(): bool
|
|
||||||
{
|
|
||||||
return $this->revoked;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setRevoked(bool $revoked): self
|
|
||||||
{
|
|
||||||
$this->revoked = $revoked;
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,161 +0,0 @@
|
||||||
<?php
|
|
||||||
/*
|
|
||||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
|
||||||
*
|
|
||||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Entity\UserSystem\OAuth;
|
|
||||||
|
|
||||||
use App\Entity\Base\TimestampTrait;
|
|
||||||
use App\Entity\Contracts\TimeStampableInterface;
|
|
||||||
use App\Repository\UserSystem\OAuth\OAuthClientRepository;
|
|
||||||
use Doctrine\DBAL\Types\Types;
|
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
|
||||||
use League\OAuth2\Server\Entities\ClientEntityInterface;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An OAuth2 client application that has been registered against this Part-DB instance, either via
|
|
||||||
* Dynamic Client Registration (RFC 7591) or (in the future) manually by an admin.
|
|
||||||
*
|
|
||||||
* Only public clients (Authorization Code + PKCE, no client secret) are supported.
|
|
||||||
*
|
|
||||||
* Note: this class deliberately does NOT use league/oauth2-server's ClientTrait/EntityTrait. Those
|
|
||||||
* traits declare their properties (e.g. $identifier, $name) without a type, and Doctrine attribute
|
|
||||||
* mapping requires typed properties; PHP considers a typed redeclaration of an untyped trait property
|
|
||||||
* "incompatible" (fatal error), so the interface is implemented manually instead.
|
|
||||||
*/
|
|
||||||
#[ORM\Entity(repositoryClass: OAuthClientRepository::class)]
|
|
||||||
#[ORM\Table(name: 'oauth_clients')]
|
|
||||||
#[ORM\HasLifecycleCallbacks]
|
|
||||||
class OAuthClient implements ClientEntityInterface, TimeStampableInterface
|
|
||||||
{
|
|
||||||
use TimestampTrait;
|
|
||||||
|
|
||||||
#[ORM\Id]
|
|
||||||
#[ORM\Column(type: Types::INTEGER)]
|
|
||||||
#[ORM\GeneratedValue]
|
|
||||||
protected int $id;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The public client_id handed out to the client. This is what league/oauth2-server calls the
|
|
||||||
* "identifier", it is NOT the same as the Doctrine primary key.
|
|
||||||
*/
|
|
||||||
#[ORM\Column(type: Types::STRING, unique: true)]
|
|
||||||
private string $identifier;
|
|
||||||
|
|
||||||
#[ORM\Column(type: Types::STRING)]
|
|
||||||
private string $name;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var string[]
|
|
||||||
*/
|
|
||||||
#[ORM\Column(type: Types::JSON)]
|
|
||||||
private array $redirectUris = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The opaque bearer token used to authenticate against the RFC 7591 client-configuration endpoint
|
|
||||||
* (GET/PUT/DELETE /oauth/register/{client_id}). Not related to the OAuth access tokens issued to users.
|
|
||||||
*/
|
|
||||||
#[ORM\Column(type: Types::STRING, unique: true)]
|
|
||||||
private string $registrationAccessToken;
|
|
||||||
|
|
||||||
#[ORM\Column(type: Types::BOOLEAN)]
|
|
||||||
private bool $dynamicallyRegistered = true;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param string[] $redirectUris
|
|
||||||
*/
|
|
||||||
public function __construct(string $clientId, string $name, array $redirectUris, string $registrationAccessToken)
|
|
||||||
{
|
|
||||||
$this->identifier = $clientId;
|
|
||||||
$this->name = $name;
|
|
||||||
$this->redirectUris = $redirectUris;
|
|
||||||
$this->registrationAccessToken = $registrationAccessToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getId(): int
|
|
||||||
{
|
|
||||||
return $this->id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getIdentifier(): string
|
|
||||||
{
|
|
||||||
return $this->identifier;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getName(): string
|
|
||||||
{
|
|
||||||
return $this->name;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setName(string $name): self
|
|
||||||
{
|
|
||||||
$this->name = $name;
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return string[]
|
|
||||||
*/
|
|
||||||
public function getRedirectUri(): array
|
|
||||||
{
|
|
||||||
return $this->redirectUris;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return string[]
|
|
||||||
*/
|
|
||||||
public function getRedirectUris(): array
|
|
||||||
{
|
|
||||||
return $this->redirectUris;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param string[] $redirectUris
|
|
||||||
*/
|
|
||||||
public function setRedirectUris(array $redirectUris): self
|
|
||||||
{
|
|
||||||
$this->redirectUris = $redirectUris;
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Only public clients (Authorization Code + PKCE, no client secret) are supported.
|
|
||||||
*/
|
|
||||||
public function isConfidential(): bool
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getRegistrationAccessToken(): string
|
|
||||||
{
|
|
||||||
return $this->registrationAccessToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isDynamicallyRegistered(): bool
|
|
||||||
{
|
|
||||||
return $this->dynamicallyRegistered;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setDynamicallyRegistered(bool $dynamicallyRegistered): self
|
|
||||||
{
|
|
||||||
$this->dynamicallyRegistered = $dynamicallyRegistered;
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,132 +0,0 @@
|
||||||
<?php
|
|
||||||
/*
|
|
||||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
|
||||||
*
|
|
||||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Entity\UserSystem\OAuth;
|
|
||||||
|
|
||||||
use App\Entity\UserSystem\ApiToken;
|
|
||||||
use App\Repository\UserSystem\OAuth\OAuthRefreshTokenRepository;
|
|
||||||
use Doctrine\DBAL\Types\Types;
|
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A revocation record for a refresh token issued alongside an OAuth2 access token (which is itself
|
|
||||||
* just an ApiToken, see AccessTokenRepository). Refresh tokens are rotated on every use; reuse of an
|
|
||||||
* already-revoked refresh token revokes the whole token family (see
|
|
||||||
* App\Security\OAuth\Repository\RefreshTokenRepository::isRefreshTokenRevoked()).
|
|
||||||
*
|
|
||||||
* league/oauth2-server hands the client an encrypted, self-contained refresh token and never asks for
|
|
||||||
* this data again - it only calls RefreshTokenRepository::isRefreshTokenRevoked()/revokeRefreshToken()
|
|
||||||
* by identifier. So unlike OAuthClient, this entity does not need to implement
|
|
||||||
* RefreshTokenEntityInterface; App\Security\OAuth\Entity\TransientRefreshToken fills that role during
|
|
||||||
* issuance.
|
|
||||||
*/
|
|
||||||
#[ORM\Entity(repositoryClass: OAuthRefreshTokenRepository::class)]
|
|
||||||
#[ORM\Table(name: 'oauth_refresh_tokens')]
|
|
||||||
class OAuthRefreshToken
|
|
||||||
{
|
|
||||||
#[ORM\Id]
|
|
||||||
#[ORM\Column(type: Types::INTEGER)]
|
|
||||||
#[ORM\GeneratedValue]
|
|
||||||
private int $id;
|
|
||||||
|
|
||||||
#[ORM\Column(type: Types::STRING, unique: true)]
|
|
||||||
private string $identifier;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The ApiToken that this refresh token can be exchanged for a new access token for. Cascading the
|
|
||||||
* delete means revoking/deleting the ApiToken (e.g. via the "revoke" button in user settings)
|
|
||||||
* automatically invalidates the refresh token too.
|
|
||||||
*/
|
|
||||||
#[ORM\ManyToOne(targetEntity: ApiToken::class)]
|
|
||||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
|
||||||
private ApiToken $apiToken;
|
|
||||||
|
|
||||||
#[ORM\ManyToOne(targetEntity: OAuthClient::class)]
|
|
||||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
|
||||||
private OAuthClient $client;
|
|
||||||
|
|
||||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
|
|
||||||
private \DateTimeImmutable $expiryDateTime;
|
|
||||||
|
|
||||||
#[ORM\Column(type: Types::BOOLEAN)]
|
|
||||||
private bool $revoked = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared by every refresh token in the same rotation chain (i.e. copied forward on each rotation,
|
|
||||||
* starting from the one issued at the original authorization). Lets
|
|
||||||
* App\Security\OAuth\Repository\RefreshTokenRepository::isRefreshTokenRevoked() revoke the whole
|
|
||||||
* chain's *currently active* token when an old, already-rotated-out refresh token is replayed -
|
|
||||||
* not just re-delete the (already dead) token from when this one was issued.
|
|
||||||
*/
|
|
||||||
#[ORM\Column(type: Types::STRING)]
|
|
||||||
private string $familyId;
|
|
||||||
|
|
||||||
public function __construct(string $identifier, ApiToken $apiToken, OAuthClient $client, \DateTimeImmutable $expiryDateTime, string $familyId)
|
|
||||||
{
|
|
||||||
$this->identifier = $identifier;
|
|
||||||
$this->apiToken = $apiToken;
|
|
||||||
$this->client = $client;
|
|
||||||
$this->expiryDateTime = $expiryDateTime;
|
|
||||||
$this->familyId = $familyId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getId(): int
|
|
||||||
{
|
|
||||||
return $this->id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getIdentifier(): string
|
|
||||||
{
|
|
||||||
return $this->identifier;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getApiToken(): ApiToken
|
|
||||||
{
|
|
||||||
return $this->apiToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getClient(): OAuthClient
|
|
||||||
{
|
|
||||||
return $this->client;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getExpiryDateTime(): \DateTimeImmutable
|
|
||||||
{
|
|
||||||
return $this->expiryDateTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getFamilyId(): string
|
|
||||||
{
|
|
||||||
return $this->familyId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isRevoked(): bool
|
|
||||||
{
|
|
||||||
return $this->revoked;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setRevoked(bool $revoked): self
|
|
||||||
{
|
|
||||||
$this->revoked = $revoked;
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
<?php
|
|
||||||
/*
|
|
||||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
|
||||||
*
|
|
||||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Repository\UserSystem\OAuth;
|
|
||||||
|
|
||||||
use Doctrine\ORM\EntityRepository;
|
|
||||||
|
|
||||||
class OAuthAuthCodeRepository extends EntityRepository
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
<?php
|
|
||||||
/*
|
|
||||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
|
||||||
*
|
|
||||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Repository\UserSystem\OAuth;
|
|
||||||
|
|
||||||
use Doctrine\ORM\EntityRepository;
|
|
||||||
|
|
||||||
class OAuthClientRepository extends EntityRepository
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
<?php
|
|
||||||
/*
|
|
||||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
|
||||||
*
|
|
||||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Repository\UserSystem\OAuth;
|
|
||||||
|
|
||||||
use Doctrine\ORM\EntityRepository;
|
|
||||||
|
|
||||||
class OAuthRefreshTokenRepository extends EntityRepository
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
@ -24,6 +24,7 @@ declare(strict_types=1);
|
||||||
namespace App\Security;
|
namespace App\Security;
|
||||||
|
|
||||||
use App\Entity\UserSystem\ApiToken;
|
use App\Entity\UserSystem\ApiToken;
|
||||||
|
use App\Entity\UserSystem\ApiTokenType;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
|
@ -88,7 +89,15 @@ class ApiTokenAuthenticator implements AuthenticatorInterface
|
||||||
|
|
||||||
public function supports(Request $request): ?bool
|
public function supports(Request $request): ?bool
|
||||||
{
|
{
|
||||||
return null === $this->accessTokenExtractor->extractAccessToken($request) ? false : null;
|
$accessToken = $this->accessTokenExtractor->extractAccessToken($request);
|
||||||
|
if ($accessToken === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
//Only claim tokens that look like our own (tcp_... Personal Access Tokens); anything else
|
||||||
|
//(e.g. an OAuth2-issued JWT) is left for App\Security\OAuth\OAuthBearerAuthenticator, which is
|
||||||
|
//mutually exclusive with this authenticator for exactly this reason - see that class.
|
||||||
|
return ApiTokenType::isRecognizedToken($accessToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function authenticate(Request $request): Passport
|
public function authenticate(Request $request): Passport
|
||||||
|
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
<?php
|
|
||||||
/*
|
|
||||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
|
||||||
*
|
|
||||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Security\OAuth\Entity;
|
|
||||||
|
|
||||||
use App\Entity\UserSystem\User;
|
|
||||||
use League\OAuth2\Server\Entities\UserEntityInterface;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adapts App\Entity\UserSystem\User (whose Symfony UserInterface identifier is its username) to
|
|
||||||
* league/oauth2-server's UserEntityInterface, which identifies a user by an opaque scalar - we use the
|
|
||||||
* numeric database id, since that's what App\Security\OAuth\Repository\AccessTokenRepository and
|
|
||||||
* AuthCodeRepository resolve back into a User via EntityManager::find().
|
|
||||||
*/
|
|
||||||
class OAuthUserEntity implements UserEntityInterface
|
|
||||||
{
|
|
||||||
public function __construct(private readonly int $id)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function fromUser(User $user): self
|
|
||||||
{
|
|
||||||
return new self($user->getID());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getIdentifier(): string
|
|
||||||
{
|
|
||||||
return (string) $this->id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
<?php
|
|
||||||
/*
|
|
||||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
|
||||||
*
|
|
||||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Security\OAuth\Entity;
|
|
||||||
|
|
||||||
use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
|
|
||||||
use League\OAuth2\Server\Entities\Traits\AccessTokenTrait;
|
|
||||||
use League\OAuth2\Server\Entities\Traits\EntityTrait;
|
|
||||||
use League\OAuth2\Server\Entities\Traits\TokenEntityTrait;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A purely in-memory AccessTokenEntityInterface used only while league/oauth2-server is issuing a
|
|
||||||
* token. It is never persisted itself: our AccessTokenRepository::persistNewAccessToken() creates a
|
|
||||||
* real App\Entity\UserSystem\ApiToken from it and overwrites its identifier with the ApiToken's own
|
|
||||||
* "oat_..." token string, so the value returned to the client and the value stored in the api_tokens
|
|
||||||
* table are identical - see AccessTokenRepository for why (keeps ApiTokenAuthenticator as the single
|
|
||||||
* validation path for both PATs and OAuth-issued tokens).
|
|
||||||
*/
|
|
||||||
class TransientAccessToken implements AccessTokenEntityInterface
|
|
||||||
{
|
|
||||||
use AccessTokenTrait;
|
|
||||||
use EntityTrait;
|
|
||||||
use TokenEntityTrait;
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
<?php
|
|
||||||
/*
|
|
||||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
|
||||||
*
|
|
||||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Security\OAuth\Entity;
|
|
||||||
|
|
||||||
use League\OAuth2\Server\Entities\AuthCodeEntityInterface;
|
|
||||||
use League\OAuth2\Server\Entities\Traits\AuthCodeTrait;
|
|
||||||
use League\OAuth2\Server\Entities\Traits\EntityTrait;
|
|
||||||
use League\OAuth2\Server\Entities\Traits\TokenEntityTrait;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A purely in-memory AuthCodeEntityInterface used only while league/oauth2-server is issuing an
|
|
||||||
* authorization code. The code_challenge/method and all other request data end up embedded directly
|
|
||||||
* in the encrypted code handed to the client (league/oauth2-server never asks the repository for them
|
|
||||||
* again); our AuthCodeRepository only needs to persist a small revocation record
|
|
||||||
* (App\Entity\UserSystem\OAuth\OAuthAuthCode) keyed by identifier, for single-use enforcement.
|
|
||||||
*/
|
|
||||||
class TransientAuthCode implements AuthCodeEntityInterface
|
|
||||||
{
|
|
||||||
use AuthCodeTrait;
|
|
||||||
use EntityTrait;
|
|
||||||
use TokenEntityTrait;
|
|
||||||
}
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
<?php
|
|
||||||
/*
|
|
||||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
|
||||||
*
|
|
||||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Security\OAuth\Entity;
|
|
||||||
|
|
||||||
use League\OAuth2\Server\Entities\RefreshTokenEntityInterface;
|
|
||||||
use League\OAuth2\Server\Entities\Traits\EntityTrait;
|
|
||||||
use League\OAuth2\Server\Entities\Traits\RefreshTokenTrait;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A purely in-memory RefreshTokenEntityInterface used only while league/oauth2-server is issuing a
|
|
||||||
* refresh token. league/oauth2-server itself never stores the refresh token's content server-side -
|
|
||||||
* it hands the client an encrypted, self-contained payload and decrypts/validates it directly on the
|
|
||||||
* next request. Our RefreshTokenRepository only needs to persist a small revocation record
|
|
||||||
* (App\Entity\UserSystem\OAuth\OAuthRefreshToken) keyed by identifier; see that repository.
|
|
||||||
*/
|
|
||||||
class TransientRefreshToken implements RefreshTokenEntityInterface
|
|
||||||
{
|
|
||||||
use EntityTrait;
|
|
||||||
use RefreshTokenTrait;
|
|
||||||
}
|
|
||||||
158
src/Security/OAuth/OAuthBearerAuthenticator.php
Normal file
158
src/Security/OAuth/OAuthBearerAuthenticator.php
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Security\OAuth;
|
||||||
|
|
||||||
|
use App\Entity\UserSystem\ApiTokenType;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Security\Authentication\Token\OAuth2Token;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Security\Exception\OAuth2AuthenticationException;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Security\Exception\OAuth2AuthenticationFailedException;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Security\Passport\Badge\ScopeBadge;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Security\User\ClientCredentialsUser;
|
||||||
|
use League\OAuth2\Server\Exception\OAuthServerException;
|
||||||
|
use League\OAuth2\Server\ResourceServer;
|
||||||
|
use Symfony\Bridge\PsrHttpMessage\HttpMessageFactoryInterface;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
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\Core\User\UserInterface;
|
||||||
|
use Symfony\Component\Security\Core\User\UserProviderInterface;
|
||||||
|
use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface;
|
||||||
|
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||||
|
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
|
||||||
|
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
|
||||||
|
use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates OAuth2-issued Bearer tokens (auto-provisioned API/MCP app credentials, see
|
||||||
|
* config/packages/league_oauth2_server.yaml) against the bundle's own ResourceServer/token storage.
|
||||||
|
*
|
||||||
|
* This is functionally almost identical to the bundle's own
|
||||||
|
* League\Bundle\OAuth2ServerBundle\Security\Authenticator\OAuth2Authenticator - reimplemented here
|
||||||
|
* (rather than reused) for one reason: supports() must be mutually exclusive with
|
||||||
|
* App\Security\ApiTokenAuthenticator. Symfony's AuthenticatorManager runs *every* authenticator whose
|
||||||
|
* supports() matches a request, not just the first one that succeeds - so if both authenticators
|
||||||
|
* claimed every "Authorization: Bearer ..." request, whichever one runs second would immediately
|
||||||
|
* re-validate (and fail on) a token meant for the other, overwriting an already-successful
|
||||||
|
* authentication with a 401. Restricting each authenticator to the token shapes it actually owns
|
||||||
|
* (ApiTokenAuthenticator: our own "tcp_..." Personal Access Tokens; this class: everything else, i.e.
|
||||||
|
* OAuth2-issued JWTs) avoids that collision entirely. See App\Entity\UserSystem\ApiTokenType::isRecognizedToken().
|
||||||
|
*/
|
||||||
|
class OAuthBearerAuthenticator implements AuthenticatorInterface, AuthenticationEntryPointInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Must match config/packages/league_oauth2_server.yaml's authorization_server.role_prefix.
|
||||||
|
*/
|
||||||
|
private const ROLE_PREFIX = 'ROLE_API_';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
#[Autowire(service: 'league.oauth2_server.factory.psr_http')]
|
||||||
|
private readonly HttpMessageFactoryInterface $httpMessageFactory,
|
||||||
|
private readonly ResourceServer $resourceServer,
|
||||||
|
#[Autowire(service: 'security.user.provider.concrete.app_user_provider')]
|
||||||
|
private readonly UserProviderInterface $userProvider,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function supports(Request $request): bool
|
||||||
|
{
|
||||||
|
$header = $request->headers->get('Authorization', '');
|
||||||
|
if (!str_starts_with((string) $header, 'Bearer ')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !ApiTokenType::isRecognizedToken(substr($header, 7));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function start(Request $request, ?AuthenticationException $authException = null): Response
|
||||||
|
{
|
||||||
|
return new Response($authException?->getMessage() ?? 'Authentication required', Response::HTTP_UNAUTHORIZED, ['WWW-Authenticate' => 'Bearer']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function authenticate(Request $request): Passport
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$psr7Request = $this->resourceServer->validateAuthenticatedRequest($this->httpMessageFactory->createRequest($request));
|
||||||
|
} catch (OAuthServerException $e) {
|
||||||
|
throw OAuth2AuthenticationFailedException::create('The resource server rejected the request.', $e);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var string $userIdentifier */
|
||||||
|
$userIdentifier = $psr7Request->getAttribute('oauth_user_id', '');
|
||||||
|
|
||||||
|
/** @var string $accessTokenId */
|
||||||
|
$accessTokenId = $psr7Request->getAttribute('oauth_access_token_id');
|
||||||
|
|
||||||
|
/** @var list<string> $scopes */
|
||||||
|
$scopes = $psr7Request->getAttribute('oauth_scopes', []);
|
||||||
|
|
||||||
|
/** @var non-empty-string $oauthClientId */
|
||||||
|
$oauthClientId = $psr7Request->getAttribute('oauth_client_id', '');
|
||||||
|
|
||||||
|
$userLoader = function (string $userIdentifier) use ($oauthClientId): UserInterface {
|
||||||
|
if ($oauthClientId === $userIdentifier) {
|
||||||
|
return new ClientCredentialsUser($oauthClientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->userProvider->loadUserByIdentifier($userIdentifier);
|
||||||
|
};
|
||||||
|
|
||||||
|
$passport = new SelfValidatingPassport(new UserBadge($userIdentifier, $userLoader), [
|
||||||
|
new ScopeBadge($scopes),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$passport->setAttribute('accessTokenId', $accessTokenId);
|
||||||
|
$passport->setAttribute('oauthClientId', $oauthClientId);
|
||||||
|
|
||||||
|
return $passport;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createToken(Passport $passport, string $firewallName): TokenInterface
|
||||||
|
{
|
||||||
|
/** @var string $accessTokenId */
|
||||||
|
$accessTokenId = $passport->getAttribute('accessTokenId');
|
||||||
|
|
||||||
|
/** @var ScopeBadge $scopeBadge */
|
||||||
|
$scopeBadge = $passport->getBadge(ScopeBadge::class);
|
||||||
|
|
||||||
|
/** @var string $oauthClientId */
|
||||||
|
$oauthClientId = $passport->getAttribute('oauthClientId', '');
|
||||||
|
|
||||||
|
return new OAuth2Token($passport->getUser(), $accessTokenId, $oauthClientId, $scopeBadge->getScopes(), self::ROLE_PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response
|
||||||
|
{
|
||||||
|
if ($exception instanceof OAuth2AuthenticationException) {
|
||||||
|
return new Response($exception->getMessage(), $exception->getStatusCode(), $exception->getHeaders());
|
||||||
|
}
|
||||||
|
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
<?php
|
|
||||||
/*
|
|
||||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
|
||||||
*
|
|
||||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Security\OAuth;
|
|
||||||
|
|
||||||
use App\Entity\UserSystem\ApiTokenLevel;
|
|
||||||
use League\OAuth2\Server\Entities\ScopeEntityInterface;
|
|
||||||
use League\OAuth2\Server\Entities\Traits\EntityTrait;
|
|
||||||
use League\OAuth2\Server\Entities\Traits\ScopeTrait;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An OAuth2 scope. There is no separate scope taxonomy: scopes are just the existing ApiTokenLevel
|
|
||||||
* enum values (read_only/edit/admin/full), so an OAuth-granted token gets exactly the same Symfony
|
|
||||||
* roles as an equivalent manually-created Personal Access Token. See OAuthScopeRepository.
|
|
||||||
*/
|
|
||||||
class OAuthScope implements ScopeEntityInterface
|
|
||||||
{
|
|
||||||
use EntityTrait;
|
|
||||||
use ScopeTrait;
|
|
||||||
|
|
||||||
public function __construct(private readonly ApiTokenLevel $level)
|
|
||||||
{
|
|
||||||
$this->identifier = self::scopeIdentifierFor($level);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getLevel(): ApiTokenLevel
|
|
||||||
{
|
|
||||||
return $this->level;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function scopeIdentifierFor(ApiTokenLevel $level): string
|
|
||||||
{
|
|
||||||
return strtolower($level->name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
<?php
|
|
||||||
/*
|
|
||||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
|
||||||
*
|
|
||||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Security\OAuth;
|
|
||||||
|
|
||||||
use League\OAuth2\Server\ResponseTypes\BearerTokenResponse;
|
|
||||||
use LogicException;
|
|
||||||
use Psr\Http\Message\ResponseInterface;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* league/oauth2-server's default BearerTokenResponse returns a signed JWT as the "access_token" value
|
|
||||||
* (via AccessTokenEntityInterface::__toString()/convertToJWT()). We don't want that: our access tokens
|
|
||||||
* ARE App\Entity\UserSystem\ApiToken rows (see App\Security\OAuth\Repository\AccessTokenRepository),
|
|
||||||
* validated by the existing App\Security\ApiTokenAuthenticator via a plain DB lookup, so the string
|
|
||||||
* handed to the client must be exactly ApiToken::getToken() rather than a JWT wrapping it.
|
|
||||||
*
|
|
||||||
* This is otherwise an exact copy of BearerTokenResponse::generateHttpResponse(), the only change
|
|
||||||
* being `$this->accessToken->getIdentifier()` instead of `(string) $this->accessToken`. Overriding this
|
|
||||||
* method (rather than just the identifier) is necessary because the base class doesn't expose a smaller
|
|
||||||
* extension point for it - see the "extra params" hook, which is for adding *additional* fields, not
|
|
||||||
* for changing how access_token itself is rendered.
|
|
||||||
*/
|
|
||||||
class OpaqueBearerTokenResponse extends BearerTokenResponse
|
|
||||||
{
|
|
||||||
public function generateHttpResponse(ResponseInterface $response): ResponseInterface
|
|
||||||
{
|
|
||||||
$expireDateTime = $this->accessToken->getExpiryDateTime()->getTimestamp();
|
|
||||||
|
|
||||||
$responseParams = [
|
|
||||||
'token_type' => 'Bearer',
|
|
||||||
'expires_in' => $expireDateTime - time(),
|
|
||||||
'access_token' => $this->accessToken->getIdentifier(),
|
|
||||||
];
|
|
||||||
|
|
||||||
//$refreshToken is a typed, non-nullable property with no default - isset() (rather than an
|
|
||||||
//instanceof/truthiness check, which would throw on an uninitialized typed property) is the
|
|
||||||
//correct way to test whether setRefreshToken() was ever called for this response.
|
|
||||||
if (isset($this->refreshToken)) {
|
|
||||||
$refreshTokenPayload = json_encode([
|
|
||||||
'client_id' => $this->accessToken->getClient()->getIdentifier(),
|
|
||||||
'refresh_token_id' => $this->refreshToken->getIdentifier(),
|
|
||||||
'access_token_id' => $this->accessToken->getIdentifier(),
|
|
||||||
'scopes' => $this->accessToken->getScopes(),
|
|
||||||
'user_id' => $this->accessToken->getUserIdentifier(),
|
|
||||||
'expire_time' => $this->refreshToken->getExpiryDateTime()->getTimestamp(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($refreshTokenPayload === false) {
|
|
||||||
throw new LogicException('Error encountered JSON encoding the refresh token payload');
|
|
||||||
}
|
|
||||||
|
|
||||||
$responseParams['refresh_token'] = $this->encrypt($refreshTokenPayload);
|
|
||||||
}
|
|
||||||
|
|
||||||
$responseParams = json_encode(array_merge($this->getExtraParams($this->accessToken), $responseParams));
|
|
||||||
|
|
||||||
if ($responseParams === false) {
|
|
||||||
throw new LogicException('Error encountered JSON encoding response parameters');
|
|
||||||
}
|
|
||||||
|
|
||||||
$response = $response
|
|
||||||
->withStatus(200)
|
|
||||||
->withHeader('pragma', 'no-cache')
|
|
||||||
->withHeader('cache-control', 'no-store')
|
|
||||||
->withHeader('content-type', 'application/json; charset=UTF-8');
|
|
||||||
|
|
||||||
$response->getBody()->write($responseParams);
|
|
||||||
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
<?php
|
|
||||||
/*
|
|
||||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
|
||||||
*
|
|
||||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Security\OAuth\Repository;
|
|
||||||
|
|
||||||
use App\Entity\UserSystem\ApiToken;
|
|
||||||
use App\Entity\UserSystem\ApiTokenLevel;
|
|
||||||
use App\Entity\UserSystem\ApiTokenType;
|
|
||||||
use App\Entity\UserSystem\OAuth\OAuthClient;
|
|
||||||
use App\Entity\UserSystem\User;
|
|
||||||
use App\Security\OAuth\Entity\TransientAccessToken;
|
|
||||||
use App\Security\OAuth\OAuthScope;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
|
|
||||||
use League\OAuth2\Server\Entities\ClientEntityInterface;
|
|
||||||
use League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Makes an OAuth2 access token just be a regular App\Entity\UserSystem\ApiToken (with a new
|
|
||||||
* ApiTokenType::OAUTH_ACCESS_TOKEN prefix), so App\Security\ApiTokenAuthenticator validates it with no
|
|
||||||
* changes at all - no parallel JWT-verification path, no second revocation mechanism. See
|
|
||||||
* TransientAccessToken for why the AccessTokenEntityInterface object itself is never persisted.
|
|
||||||
*/
|
|
||||||
class AccessTokenRepository implements AccessTokenRepositoryInterface
|
|
||||||
{
|
|
||||||
public function __construct(private readonly EntityManagerInterface $entityManager)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getNewToken(ClientEntityInterface $clientEntity, array $scopes, ?string $userIdentifier = null): AccessTokenEntityInterface
|
|
||||||
{
|
|
||||||
$accessToken = new TransientAccessToken();
|
|
||||||
$accessToken->setClient($clientEntity);
|
|
||||||
foreach ($scopes as $scope) {
|
|
||||||
$accessToken->addScope($scope);
|
|
||||||
}
|
|
||||||
if ($userIdentifier !== null) {
|
|
||||||
$accessToken->setUserIdentifier($userIdentifier);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $accessToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function persistNewAccessToken(AccessTokenEntityInterface $accessTokenEntity): void
|
|
||||||
{
|
|
||||||
$client = $accessTokenEntity->getClient();
|
|
||||||
if (!$client instanceof OAuthClient) {
|
|
||||||
throw new \LogicException('Expected an OAuthClient instance.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = $this->entityManager->find(User::class, $accessTokenEntity->getUserIdentifier());
|
|
||||||
if (!$user instanceof User) {
|
|
||||||
throw new \LogicException('Could not resolve the user the access token was issued to.');
|
|
||||||
}
|
|
||||||
|
|
||||||
//ScopeRepository::finalizeScopes() always collapses the requested scopes down to a single
|
|
||||||
//ApiTokenLevel, so there is exactly one scope here.
|
|
||||||
$level = ApiTokenLevel::READ_ONLY;
|
|
||||||
foreach ($accessTokenEntity->getScopes() as $scope) {
|
|
||||||
if ($scope instanceof OAuthScope) {
|
|
||||||
$level = $scope->getLevel();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$apiToken = new ApiToken(ApiTokenType::OAUTH_ACCESS_TOKEN);
|
|
||||||
$apiToken->setUser($user);
|
|
||||||
$apiToken->setLevel($level);
|
|
||||||
$apiToken->setName(sprintf('OAuth: %s', $client->getName()));
|
|
||||||
$apiToken->setValidUntil($accessTokenEntity->getExpiryDateTime());
|
|
||||||
$apiToken->setOauthClient($client);
|
|
||||||
|
|
||||||
$this->entityManager->persist($apiToken);
|
|
||||||
$this->entityManager->flush();
|
|
||||||
|
|
||||||
//Overwrite league's randomly generated identifier with our own ApiToken's token string, so the
|
|
||||||
//bearer token string returned to the client and api_tokens.token are the same value.
|
|
||||||
$accessTokenEntity->setIdentifier($apiToken->getToken());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function revokeAccessToken(string $tokenId): void
|
|
||||||
{
|
|
||||||
//A bulk DQL delete instead of load+remove()+flush(): during refresh token rotation,
|
|
||||||
//RefreshTokenGrant::respondToAccessTokenRequest() revokes this access token and then, in the
|
|
||||||
//same request, touches the OLD OAuthRefreshToken row that still references it - which is the
|
|
||||||
//very same managed object sitting in the identity map since issuance. Loading (and therefore
|
|
||||||
//re-managing) this ApiToken via the ORM here makes Doctrine try to validate that association on
|
|
||||||
//the next flush() and fail ("a new entity was found through the relationship..."). A bulk delete
|
|
||||||
//never touches the UnitOfWork's per-entity changeset tracking, so it can't trigger that.
|
|
||||||
$this->entityManager->createQueryBuilder()
|
|
||||||
->delete(ApiToken::class, 't')
|
|
||||||
->where('t.token = :token')
|
|
||||||
->setParameter('token', $tokenId)
|
|
||||||
->getQuery()
|
|
||||||
->execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isAccessTokenRevoked(string $tokenId): bool
|
|
||||||
{
|
|
||||||
$apiToken = $this->findByToken($tokenId);
|
|
||||||
|
|
||||||
return !$apiToken instanceof ApiToken || !$apiToken->isValid();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function findByToken(string $token): ?ApiToken
|
|
||||||
{
|
|
||||||
return $this->entityManager->getRepository(ApiToken::class)->findOneBy(['token' => $token]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
<?php
|
|
||||||
/*
|
|
||||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
|
||||||
*
|
|
||||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Security\OAuth\Repository;
|
|
||||||
|
|
||||||
use App\Entity\UserSystem\OAuth\OAuthAuthCode;
|
|
||||||
use App\Entity\UserSystem\OAuth\OAuthClient;
|
|
||||||
use App\Entity\UserSystem\User;
|
|
||||||
use App\Security\OAuth\Entity\TransientAuthCode;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
use League\OAuth2\Server\Entities\AuthCodeEntityInterface;
|
|
||||||
use League\OAuth2\Server\Repositories\AuthCodeRepositoryInterface;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* See TransientAuthCode and OAuthAuthCode for why this only ever persists a small revocation record,
|
|
||||||
* never the code's actual content.
|
|
||||||
*/
|
|
||||||
class AuthCodeRepository implements AuthCodeRepositoryInterface
|
|
||||||
{
|
|
||||||
public function __construct(private readonly EntityManagerInterface $entityManager)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getNewAuthCode(): AuthCodeEntityInterface
|
|
||||||
{
|
|
||||||
return new TransientAuthCode();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function persistNewAuthCode(AuthCodeEntityInterface $authCodeEntity): void
|
|
||||||
{
|
|
||||||
$client = $authCodeEntity->getClient();
|
|
||||||
if (!$client instanceof OAuthClient) {
|
|
||||||
throw new \LogicException('Expected an OAuthClient instance.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = $this->entityManager->find(User::class, $authCodeEntity->getUserIdentifier());
|
|
||||||
if (!$user instanceof User) {
|
|
||||||
throw new \LogicException('Could not resolve the user the authorization code was issued to.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$scopeIdentifiers = array_map(
|
|
||||||
static fn ($scope) => $scope->getIdentifier(),
|
|
||||||
$authCodeEntity->getScopes()
|
|
||||||
);
|
|
||||||
|
|
||||||
$authCode = new OAuthAuthCode(
|
|
||||||
$authCodeEntity->getIdentifier(),
|
|
||||||
$client,
|
|
||||||
$user,
|
|
||||||
$scopeIdentifiers,
|
|
||||||
$authCodeEntity->getExpiryDateTime(),
|
|
||||||
$authCodeEntity->getRedirectUri(),
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->entityManager->persist($authCode);
|
|
||||||
$this->entityManager->flush();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function revokeAuthCode(string $codeId): void
|
|
||||||
{
|
|
||||||
$authCode = $this->findByIdentifier($codeId);
|
|
||||||
if ($authCode instanceof OAuthAuthCode) {
|
|
||||||
$authCode->setRevoked(true);
|
|
||||||
$this->entityManager->flush();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isAuthCodeRevoked(string $codeId): bool
|
|
||||||
{
|
|
||||||
$authCode = $this->findByIdentifier($codeId);
|
|
||||||
|
|
||||||
//A code we have no record of can't be validated, so treat it as revoked/invalid.
|
|
||||||
return !$authCode instanceof OAuthAuthCode || $authCode->isRevoked();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function findByIdentifier(string $codeId): ?OAuthAuthCode
|
|
||||||
{
|
|
||||||
return $this->entityManager->getRepository(OAuthAuthCode::class)
|
|
||||||
->findOneBy(['identifier' => $codeId]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
<?php
|
|
||||||
/*
|
|
||||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
|
||||||
*
|
|
||||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Security\OAuth\Repository;
|
|
||||||
|
|
||||||
use App\Entity\UserSystem\OAuth\OAuthClient;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
use League\OAuth2\Server\Entities\ClientEntityInterface;
|
|
||||||
use League\OAuth2\Server\Repositories\ClientRepositoryInterface;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Only public clients (Authorization Code + PKCE, no client secret) are supported, so validateClient()
|
|
||||||
* never checks a secret - it just checks the client is registered.
|
|
||||||
*/
|
|
||||||
class ClientRepository implements ClientRepositoryInterface
|
|
||||||
{
|
|
||||||
public function __construct(private readonly EntityManagerInterface $entityManager)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getClientEntity(string $clientIdentifier): ?ClientEntityInterface
|
|
||||||
{
|
|
||||||
return $this->entityManager->getRepository(OAuthClient::class)
|
|
||||||
->findOneBy(['identifier' => $clientIdentifier]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function validateClient(string $clientIdentifier, ?string $clientSecret, ?string $grantType): bool
|
|
||||||
{
|
|
||||||
return $this->getClientEntity($clientIdentifier) instanceof OAuthClient;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,172 +0,0 @@
|
||||||
<?php
|
|
||||||
/*
|
|
||||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
|
||||||
*
|
|
||||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Security\OAuth\Repository;
|
|
||||||
|
|
||||||
use App\Entity\UserSystem\ApiToken;
|
|
||||||
use App\Entity\UserSystem\OAuth\OAuthClient;
|
|
||||||
use App\Entity\UserSystem\OAuth\OAuthRefreshToken;
|
|
||||||
use App\Security\OAuth\Entity\TransientRefreshToken;
|
|
||||||
use Doctrine\ORM\AbstractQuery;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
use League\OAuth2\Server\Entities\RefreshTokenEntityInterface;
|
|
||||||
use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh tokens are rotated on every use. Reuse of an already-revoked (i.e. already rotated-out)
|
|
||||||
* refresh token is a signal of token theft, so isRefreshTokenRevoked() responds by revoking the whole
|
|
||||||
* token family - deleting the linked ApiToken, not just this refresh token - per OAuth 2.1 guidance.
|
|
||||||
*
|
|
||||||
* revokeRefreshToken()/isRefreshTokenRevoked()'s family-kill path deliberately use bulk DQL
|
|
||||||
* updates/deletes instead of loading entities through the ORM: during rotation,
|
|
||||||
* League\OAuth2\Server\Grant\RefreshTokenGrant::respondToAccessTokenRequest() revokes the old access
|
|
||||||
* token and old refresh token in the same request/EntityManager that originally persisted them, so the
|
|
||||||
* OLD OAuthRefreshToken is still the exact same managed object sitting in the identity map from
|
|
||||||
* issuance. Loading and mutating it via the ORM after AccessTokenRepository::revokeAccessToken() has
|
|
||||||
* already deleted its linked ApiToken makes Doctrine try to validate that (now-stale) "apiToken"
|
|
||||||
* association on the next flush() and fail ("a new entity was found through the relationship...").
|
|
||||||
* Bulk DQL bypasses the UnitOfWork's per-entity changeset/association checks entirely.
|
|
||||||
*/
|
|
||||||
class RefreshTokenRepository implements RefreshTokenRepositoryInterface
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* The family id of the refresh token most recently revoked (via revokeRefreshToken()) in this
|
|
||||||
* request, so the token about to be persisted (via persistNewRefreshToken(), which
|
|
||||||
* league/oauth2-server always calls immediately afterward during rotation - see RefreshTokenGrant::
|
|
||||||
* respondToAccessTokenRequest()) can carry the same family id forward instead of starting a new one.
|
|
||||||
*/
|
|
||||||
private ?string $pendingFamilyId = null;
|
|
||||||
|
|
||||||
public function __construct(private readonly EntityManagerInterface $entityManager)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getNewRefreshToken(): ?RefreshTokenEntityInterface
|
|
||||||
{
|
|
||||||
return new TransientRefreshToken();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function persistNewRefreshToken(RefreshTokenEntityInterface $refreshTokenEntity): void
|
|
||||||
{
|
|
||||||
$accessToken = $refreshTokenEntity->getAccessToken();
|
|
||||||
|
|
||||||
$apiToken = $this->entityManager->getRepository(ApiToken::class)
|
|
||||||
->findOneBy(['token' => $accessToken->getIdentifier()]);
|
|
||||||
if (!$apiToken instanceof ApiToken) {
|
|
||||||
throw new \LogicException('Could not resolve the ApiToken this refresh token belongs to.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$client = $accessToken->getClient();
|
|
||||||
if (!$client instanceof OAuthClient) {
|
|
||||||
throw new \LogicException('Expected an OAuthClient instance.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$familyId = $this->pendingFamilyId ?? bin2hex(random_bytes(16));
|
|
||||||
$this->pendingFamilyId = null;
|
|
||||||
|
|
||||||
$refreshToken = new OAuthRefreshToken(
|
|
||||||
$refreshTokenEntity->getIdentifier(),
|
|
||||||
$apiToken,
|
|
||||||
$client,
|
|
||||||
$refreshTokenEntity->getExpiryDateTime(),
|
|
||||||
$familyId,
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->entityManager->persist($refreshToken);
|
|
||||||
$this->entityManager->flush();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function revokeRefreshToken(string $tokenId): void
|
|
||||||
{
|
|
||||||
$familyId = $this->entityManager->createQueryBuilder()
|
|
||||||
->select('r.familyId')
|
|
||||||
->from(OAuthRefreshToken::class, 'r')
|
|
||||||
->where('r.identifier = :identifier')
|
|
||||||
->setParameter('identifier', $tokenId)
|
|
||||||
->getQuery()
|
|
||||||
->getOneOrNullResult(AbstractQuery::HYDRATE_SINGLE_SCALAR);
|
|
||||||
|
|
||||||
if ($familyId === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->pendingFamilyId = $familyId;
|
|
||||||
|
|
||||||
$this->entityManager->createQueryBuilder()
|
|
||||||
->update(OAuthRefreshToken::class, 'r')
|
|
||||||
->set('r.revoked', ':revoked')
|
|
||||||
->where('r.identifier = :identifier')
|
|
||||||
->setParameter('revoked', true)
|
|
||||||
->setParameter('identifier', $tokenId)
|
|
||||||
->getQuery()
|
|
||||||
->execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isRefreshTokenRevoked(string $tokenId): bool
|
|
||||||
{
|
|
||||||
$row = $this->entityManager->createQueryBuilder()
|
|
||||||
->select('r.revoked', 'r.familyId')
|
|
||||||
->from(OAuthRefreshToken::class, 'r')
|
|
||||||
->where('r.identifier = :identifier')
|
|
||||||
->setParameter('identifier', $tokenId)
|
|
||||||
->getQuery()
|
|
||||||
->getOneOrNullResult(AbstractQuery::HYDRATE_ARRAY);
|
|
||||||
|
|
||||||
if ($row === null) {
|
|
||||||
//No record at all - can't validate, treat as revoked/invalid.
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($row['revoked']) {
|
|
||||||
//Reuse of an already-rotated-out refresh token: kill the whole family - every refresh token
|
|
||||||
//(and thus ApiToken) descended from the same original grant, not just this one, since the
|
|
||||||
//currently active token in the chain is what actually needs to stop working.
|
|
||||||
$apiTokenIds = $this->entityManager->createQueryBuilder()
|
|
||||||
->select('IDENTITY(r.apiToken) AS apiTokenId')
|
|
||||||
->from(OAuthRefreshToken::class, 'r')
|
|
||||||
->where('r.familyId = :familyId')
|
|
||||||
->setParameter('familyId', $row['familyId'])
|
|
||||||
->getQuery()
|
|
||||||
->getSingleColumnResult();
|
|
||||||
|
|
||||||
$this->entityManager->createQueryBuilder()
|
|
||||||
->delete(OAuthRefreshToken::class, 'r')
|
|
||||||
->where('r.familyId = :familyId')
|
|
||||||
->setParameter('familyId', $row['familyId'])
|
|
||||||
->getQuery()
|
|
||||||
->execute();
|
|
||||||
|
|
||||||
if ($apiTokenIds !== []) {
|
|
||||||
$this->entityManager->createQueryBuilder()
|
|
||||||
->delete(ApiToken::class, 't')
|
|
||||||
->where('t.id IN (:ids)')
|
|
||||||
->setParameter('ids', $apiTokenIds)
|
|
||||||
->getQuery()
|
|
||||||
->execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
<?php
|
|
||||||
/*
|
|
||||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
|
||||||
*
|
|
||||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Security\OAuth\Repository;
|
|
||||||
|
|
||||||
use App\Entity\UserSystem\ApiTokenLevel;
|
|
||||||
use App\Security\OAuth\OAuthScope;
|
|
||||||
use League\OAuth2\Server\Entities\ClientEntityInterface;
|
|
||||||
use League\OAuth2\Server\Entities\ScopeEntityInterface;
|
|
||||||
use League\OAuth2\Server\Repositories\ScopeRepositoryInterface;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scopes are just the existing ApiTokenLevel enum (read_only/edit/admin/full) - see OAuthScope. There
|
|
||||||
* is no fine-grained per-endpoint scoping; an OAuth-granted token gets exactly the same Symfony roles
|
|
||||||
* as an equivalent manually-created Personal Access Token of that level.
|
|
||||||
*/
|
|
||||||
class ScopeRepository implements ScopeRepositoryInterface
|
|
||||||
{
|
|
||||||
public function getScopeEntityByIdentifier(string $identifier): ?ScopeEntityInterface
|
|
||||||
{
|
|
||||||
$level = $this->levelFromIdentifier($identifier);
|
|
||||||
|
|
||||||
return $level !== null ? new OAuthScope($level) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param ScopeEntityInterface[] $scopes
|
|
||||||
* @return ScopeEntityInterface[]
|
|
||||||
*/
|
|
||||||
public function finalizeScopes(array $scopes, string $grantType, ClientEntityInterface $clientEntity, ?string $userIdentifier = null, ?string $authCodeId = null): array
|
|
||||||
{
|
|
||||||
//ApiTokenLevel is cumulative (a higher level already includes everything a lower one grants),
|
|
||||||
//so a token only ever has a single level. If several scopes were requested, keep only the
|
|
||||||
//highest one; if none were requested/valid, default to the least-privileged level.
|
|
||||||
$highest = ApiTokenLevel::READ_ONLY;
|
|
||||||
foreach ($scopes as $scope) {
|
|
||||||
if ($scope instanceof OAuthScope && $scope->getLevel()->value > $highest->value) {
|
|
||||||
$highest = $scope->getLevel();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [new OAuthScope($highest)];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function levelFromIdentifier(string $identifier): ?ApiTokenLevel
|
|
||||||
{
|
|
||||||
foreach (ApiTokenLevel::cases() as $level) {
|
|
||||||
if (OAuthScope::scopeIdentifierFor($level) === $identifier) {
|
|
||||||
return $level;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -27,6 +27,7 @@ use App\Entity\UserSystem\User;
|
||||||
use App\Repository\UserRepository;
|
use App\Repository\UserRepository;
|
||||||
use App\Security\ApiTokenAuthenticatedToken;
|
use App\Security\ApiTokenAuthenticatedToken;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Security\Authentication\Token\OAuth2Token;
|
||||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||||
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
||||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||||
|
|
@ -88,8 +89,13 @@ final class VoterHelper
|
||||||
$user = $this->resolveUser($token);
|
$user = $this->resolveUser($token);
|
||||||
}
|
}
|
||||||
|
|
||||||
//If the token is a APITokenAuthenticated
|
//If the token is a APITokenAuthenticated token (Personal Access Token) or an OAuth2Token (an
|
||||||
if ($token instanceof ApiTokenAuthenticatedToken) {
|
//OAuth2-issued token, see App\Security\OAuth\OAuthBearerAuthenticator), the token can only ever
|
||||||
|
//grant a *subset* of what the user could already do - apply the same API-level ceiling check to
|
||||||
|
//both, keyed off the token's own role list (ROLE_API_* either way - see
|
||||||
|
//App\Entity\UserSystem\ApiTokenLevel::getAdditionalRoles() and
|
||||||
|
//config/packages/league_oauth2_server.yaml's role_prefix/scopes).
|
||||||
|
if ($token instanceof ApiTokenAuthenticatedToken || $token instanceof OAuth2Token) {
|
||||||
//Use the special API token checker
|
//Use the special API token checker
|
||||||
return $this->permissionManager->inheritWithAPILevel($user, $token->getRoleNames(), $permission, $operation);
|
return $this->permissionManager->inheritWithAPILevel($user, $token->getRoleNames(), $permission, $operation);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,246 +0,0 @@
|
||||||
<?php
|
|
||||||
/*
|
|
||||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
|
||||||
*
|
|
||||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Tests\Security\OAuth;
|
|
||||||
|
|
||||||
use App\Entity\UserSystem\ApiToken;
|
|
||||||
use App\Entity\UserSystem\ApiTokenLevel;
|
|
||||||
use App\Entity\UserSystem\ApiTokenType;
|
|
||||||
use App\Entity\UserSystem\OAuth\OAuthClient;
|
|
||||||
use App\Entity\UserSystem\User;
|
|
||||||
use App\Security\OAuth\Entity\OAuthUserEntity;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
use League\OAuth2\Server\AuthorizationServer;
|
|
||||||
use League\OAuth2\Server\Exception\OAuthServerException;
|
|
||||||
use Nyholm\Psr7\Factory\Psr17Factory;
|
|
||||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drives League\OAuth2\Server\AuthorizationServer directly (no HTTP layer yet - that's Phase 2/3 of the
|
|
||||||
* OAuth auto-provisioning work) to verify the foundations laid in Phase 1: our Doctrine-backed
|
|
||||||
* repositories, the opaque (non-JWT) access token response, and - most importantly - that the token
|
|
||||||
* handed back to the client round-trips as a real App\Entity\UserSystem\ApiToken row, findable exactly
|
|
||||||
* the way App\Security\ApiTokenAuthenticator finds any other token.
|
|
||||||
*/
|
|
||||||
final class AuthorizationServerTest extends KernelTestCase
|
|
||||||
{
|
|
||||||
private AuthorizationServer $authServer;
|
|
||||||
private EntityManagerInterface $entityManager;
|
|
||||||
private Psr17Factory $psr17Factory;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
|
||||||
{
|
|
||||||
self::bootKernel();
|
|
||||||
$this->authServer = self::getContainer()->get(AuthorizationServer::class);
|
|
||||||
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
|
||||||
$this->psr17Factory = new Psr17Factory();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function createTestClient(string $redirectUri): OAuthClient
|
|
||||||
{
|
|
||||||
$client = new OAuthClient(
|
|
||||||
'test-client-'.bin2hex(random_bytes(8)),
|
|
||||||
'Test Client',
|
|
||||||
[$redirectUri],
|
|
||||||
bin2hex(random_bytes(16)),
|
|
||||||
);
|
|
||||||
$this->entityManager->persist($client);
|
|
||||||
$this->entityManager->flush();
|
|
||||||
|
|
||||||
return $client;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getAdminUser(): User
|
|
||||||
{
|
|
||||||
$user = $this->entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']);
|
|
||||||
self::assertInstanceOf(User::class, $user);
|
|
||||||
|
|
||||||
return $user;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{0: string, 1: string} [code_verifier, code_challenge]
|
|
||||||
*/
|
|
||||||
private function generatePkcePair(): array
|
|
||||||
{
|
|
||||||
$verifier = rtrim(strtr(base64_encode(random_bytes(40)), '+/', '-_'), '=');
|
|
||||||
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
|
|
||||||
|
|
||||||
return [$verifier, $challenge];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Runs the full authorize -> approve -> token exchange flow and returns the decoded token response.
|
|
||||||
*
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function runAuthorizationCodeFlow(OAuthClient $client, User $user, string $redirectUri, string $scope): array
|
|
||||||
{
|
|
||||||
[$verifier, $challenge] = $this->generatePkcePair();
|
|
||||||
|
|
||||||
$authorizeRequest = $this->psr17Factory->createServerRequest('GET', 'https://part-db.test/oauth/authorize?'.http_build_query([
|
|
||||||
'response_type' => 'code',
|
|
||||||
'client_id' => $client->getIdentifier(),
|
|
||||||
'redirect_uri' => $redirectUri,
|
|
||||||
'code_challenge' => $challenge,
|
|
||||||
'code_challenge_method' => 'S256',
|
|
||||||
'scope' => $scope,
|
|
||||||
]));
|
|
||||||
|
|
||||||
$authRequest = $this->authServer->validateAuthorizationRequest($authorizeRequest);
|
|
||||||
$authRequest->setUser(OAuthUserEntity::fromUser($user));
|
|
||||||
$authRequest->setAuthorizationApproved(true);
|
|
||||||
|
|
||||||
$redirectResponse = $this->authServer->completeAuthorizationRequest($authRequest, $this->psr17Factory->createResponse());
|
|
||||||
$location = $redirectResponse->getHeaderLine('Location');
|
|
||||||
parse_str((string) parse_url($location, PHP_URL_QUERY), $query);
|
|
||||||
self::assertArrayHasKey('code', $query, 'Expected an authorization code in the redirect: '.$location);
|
|
||||||
|
|
||||||
$tokenRequest = $this->psr17Factory->createServerRequest('POST', 'https://part-db.test/oauth/token')
|
|
||||||
->withParsedBody([
|
|
||||||
'grant_type' => 'authorization_code',
|
|
||||||
'client_id' => $client->getIdentifier(),
|
|
||||||
'redirect_uri' => $redirectUri,
|
|
||||||
'code' => $query['code'],
|
|
||||||
'code_verifier' => $verifier,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$tokenResponse = $this->authServer->respondToAccessTokenRequest($tokenRequest, $this->psr17Factory->createResponse());
|
|
||||||
$body = (string) $tokenResponse->getBody();
|
|
||||||
|
|
||||||
return json_decode($body, true, flags: JSON_THROW_ON_ERROR);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testAuthorizationCodeFlowIssuesRealApiToken(): void
|
|
||||||
{
|
|
||||||
$redirectUri = 'https://client.example.invalid/callback';
|
|
||||||
$client = $this->createTestClient($redirectUri);
|
|
||||||
$user = $this->getAdminUser();
|
|
||||||
|
|
||||||
$data = $this->runAuthorizationCodeFlow($client, $user, $redirectUri, 'edit');
|
|
||||||
|
|
||||||
self::assertSame('Bearer', $data['token_type']);
|
|
||||||
self::assertArrayHasKey('access_token', $data);
|
|
||||||
self::assertArrayHasKey('refresh_token', $data);
|
|
||||||
//The access token must NOT look like a JWT (no dots) - see OpaqueBearerTokenResponse.
|
|
||||||
self::assertStringStartsWith(ApiTokenType::OAUTH_ACCESS_TOKEN->getTokenPrefix(), $data['access_token']);
|
|
||||||
self::assertStringNotContainsString('.', $data['access_token']);
|
|
||||||
|
|
||||||
$this->entityManager->clear();
|
|
||||||
$apiToken = $this->entityManager->getRepository(ApiToken::class)->findOneBy(['token' => $data['access_token']]);
|
|
||||||
|
|
||||||
self::assertInstanceOf(ApiToken::class, $apiToken);
|
|
||||||
self::assertTrue($apiToken->isValid());
|
|
||||||
self::assertSame(ApiTokenType::OAUTH_ACCESS_TOKEN, $apiToken->getTokenType());
|
|
||||||
self::assertSame(ApiTokenLevel::EDIT, $apiToken->getLevel());
|
|
||||||
self::assertSame($user->getID(), $apiToken->getUser()?->getID());
|
|
||||||
self::assertSame($client->getId(), $apiToken->getOauthClient()?->getId());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testInvalidPkceVerifierIsRejected(): void
|
|
||||||
{
|
|
||||||
$redirectUri = 'https://client.example.invalid/callback';
|
|
||||||
$client = $this->createTestClient($redirectUri);
|
|
||||||
$user = $this->getAdminUser();
|
|
||||||
|
|
||||||
[, $challenge] = $this->generatePkcePair();
|
|
||||||
|
|
||||||
$authorizeRequest = $this->psr17Factory->createServerRequest('GET', 'https://part-db.test/oauth/authorize?'.http_build_query([
|
|
||||||
'response_type' => 'code',
|
|
||||||
'client_id' => $client->getIdentifier(),
|
|
||||||
'redirect_uri' => $redirectUri,
|
|
||||||
'code_challenge' => $challenge,
|
|
||||||
'code_challenge_method' => 'S256',
|
|
||||||
]));
|
|
||||||
|
|
||||||
$authRequest = $this->authServer->validateAuthorizationRequest($authorizeRequest);
|
|
||||||
$authRequest->setUser(OAuthUserEntity::fromUser($user));
|
|
||||||
$authRequest->setAuthorizationApproved(true);
|
|
||||||
|
|
||||||
$redirectResponse = $this->authServer->completeAuthorizationRequest($authRequest, $this->psr17Factory->createResponse());
|
|
||||||
parse_str((string) parse_url($redirectResponse->getHeaderLine('Location'), PHP_URL_QUERY), $query);
|
|
||||||
|
|
||||||
$tokenRequest = $this->psr17Factory->createServerRequest('POST', 'https://part-db.test/oauth/token')
|
|
||||||
->withParsedBody([
|
|
||||||
'grant_type' => 'authorization_code',
|
|
||||||
'client_id' => $client->getIdentifier(),
|
|
||||||
'redirect_uri' => $redirectUri,
|
|
||||||
'code' => $query['code'],
|
|
||||||
//Wrong verifier - does not match the code_challenge used above.
|
|
||||||
'code_verifier' => str_repeat('a', 43),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->expectException(OAuthServerException::class);
|
|
||||||
$this->authServer->respondToAccessTokenRequest($tokenRequest, $this->psr17Factory->createResponse());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testRefreshTokenRotationIssuesNewApiTokenAndRevokesOld(): void
|
|
||||||
{
|
|
||||||
$redirectUri = 'https://client.example.invalid/callback';
|
|
||||||
$client = $this->createTestClient($redirectUri);
|
|
||||||
$user = $this->getAdminUser();
|
|
||||||
|
|
||||||
$first = $this->runAuthorizationCodeFlow($client, $user, $redirectUri, 'read_only');
|
|
||||||
$originalAccessToken = $first['access_token'];
|
|
||||||
|
|
||||||
$refreshRequest = $this->psr17Factory->createServerRequest('POST', 'https://part-db.test/oauth/token')
|
|
||||||
->withParsedBody([
|
|
||||||
'grant_type' => 'refresh_token',
|
|
||||||
'client_id' => $client->getIdentifier(),
|
|
||||||
'refresh_token' => $first['refresh_token'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$refreshResponse = $this->authServer->respondToAccessTokenRequest($refreshRequest, $this->psr17Factory->createResponse());
|
|
||||||
$second = json_decode((string) $refreshResponse->getBody(), true, flags: JSON_THROW_ON_ERROR);
|
|
||||||
|
|
||||||
self::assertNotSame($originalAccessToken, $second['access_token']);
|
|
||||||
|
|
||||||
$this->entityManager->clear();
|
|
||||||
|
|
||||||
//The old access token must have been revoked (deleted) as part of rotation.
|
|
||||||
$oldToken = $this->entityManager->getRepository(ApiToken::class)->findOneBy(['token' => $originalAccessToken]);
|
|
||||||
self::assertNull($oldToken, 'The original access token should have been revoked on refresh.');
|
|
||||||
|
|
||||||
$newToken = $this->entityManager->getRepository(ApiToken::class)->findOneBy(['token' => $second['access_token']]);
|
|
||||||
self::assertInstanceOf(ApiToken::class, $newToken);
|
|
||||||
self::assertSame(ApiTokenLevel::READ_ONLY, $newToken->getLevel());
|
|
||||||
|
|
||||||
//Reusing the now-rotated-out refresh token must fail and revoke the whole family.
|
|
||||||
$reuseRequest = $this->psr17Factory->createServerRequest('POST', 'https://part-db.test/oauth/token')
|
|
||||||
->withParsedBody([
|
|
||||||
'grant_type' => 'refresh_token',
|
|
||||||
'client_id' => $client->getIdentifier(),
|
|
||||||
'refresh_token' => $first['refresh_token'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
$this->authServer->respondToAccessTokenRequest($reuseRequest, $this->psr17Factory->createResponse());
|
|
||||||
self::fail('Expected reuse of a rotated-out refresh token to be rejected.');
|
|
||||||
} catch (OAuthServerException) {
|
|
||||||
//Expected.
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->entityManager->clear();
|
|
||||||
$newTokenAfterReuse = $this->entityManager->getRepository(ApiToken::class)->findOneBy(['token' => $second['access_token']]);
|
|
||||||
self::assertNull($newTokenAfterReuse, 'Reuse of a revoked refresh token should revoke the whole token family.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
237
tests/Security/OAuth/OAuthBearerAuthenticationTest.php
Normal file
237
tests/Security/OAuth/OAuthBearerAuthenticationTest.php
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Security\OAuth;
|
||||||
|
|
||||||
|
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
|
||||||
|
use ApiPlatform\Symfony\Bundle\Test\Client;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Entity\User as LeagueUser;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Manager\ClientManagerInterface;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Model\Client as OAuthClientModel;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\ValueObject\Grant;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\ValueObject\RedirectUri;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\ValueObject\Scope;
|
||||||
|
use League\OAuth2\Server\AuthorizationServer;
|
||||||
|
use League\OAuth2\Server\Exception\OAuthServerException;
|
||||||
|
use Nyholm\Psr7\Factory\Psr17Factory;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the OAuth2 authorization code + PKCE flow end to end (against league/oauth2-server-bundle's
|
||||||
|
* own Doctrine persistence - see config/packages/league_oauth2_server.yaml), then makes *real* HTTP
|
||||||
|
* requests bearing the issued token against protected API routes. This specifically exercises the two
|
||||||
|
* pieces of custom code this design needs on top of the bundle: App\Security\OAuth\OAuthBearerAuthenticator
|
||||||
|
* (routing OAuth2 bearer tokens to the bundle's ResourceServer without colliding with
|
||||||
|
* App\Security\ApiTokenAuthenticator) and the App\Services\UserSystem\VoterHelper fix that makes an
|
||||||
|
* OAuth2-issued token's scope act as a permission ceiling, exactly like a Personal Access Token's level.
|
||||||
|
*
|
||||||
|
* All container/service access happens through the single kernel that static::createClient() boots for
|
||||||
|
* the test (via $client->getContainer()) - mixing that with a separately-booted kernel leads to
|
||||||
|
* inconsistent state (e.g. entities persisted through one EntityManager not visible to the other).
|
||||||
|
*/
|
||||||
|
final class OAuthBearerAuthenticationTest extends ApiTestCase
|
||||||
|
{
|
||||||
|
private function createTestClient(Client $client, string $redirectUri, array $scopes): OAuthClientModel
|
||||||
|
{
|
||||||
|
$oauthClient = new OAuthClientModel('Test Client', 'test-client-'.bin2hex(random_bytes(8)), null);
|
||||||
|
$oauthClient->setRedirectUris(new RedirectUri($redirectUri));
|
||||||
|
$oauthClient->setGrants(new Grant('authorization_code'), new Grant('refresh_token'));
|
||||||
|
$oauthClient->setScopes(...array_map(static fn (string $s) => new Scope($s), $scopes));
|
||||||
|
$oauthClient->setActive(true);
|
||||||
|
|
||||||
|
$client->getContainer()->get(ClientManagerInterface::class)->save($oauthClient);
|
||||||
|
|
||||||
|
return $oauthClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: string, 1: string} [code_verifier, code_challenge]
|
||||||
|
*/
|
||||||
|
private function generatePkcePair(): array
|
||||||
|
{
|
||||||
|
$verifier = rtrim(strtr(base64_encode(random_bytes(40)), '+/', '-_'), '=');
|
||||||
|
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
|
||||||
|
|
||||||
|
return [$verifier, $challenge];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives AuthorizationServer directly (bypassing the not-yet-built consent-screen UI, which will
|
||||||
|
* sit in front of the bundle's own /authorize route) to get a real access/refresh token pair for
|
||||||
|
* the given scopes, exactly as if a user had approved that grant.
|
||||||
|
*
|
||||||
|
* @param string[] $scopes
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function issueTokenForScopes(Client $client, OAuthClientModel $oauthClient, string $redirectUri, array $scopes): array
|
||||||
|
{
|
||||||
|
$authServer = $client->getContainer()->get(AuthorizationServer::class);
|
||||||
|
$psr17 = new Psr17Factory();
|
||||||
|
|
||||||
|
[$verifier, $challenge] = $this->generatePkcePair();
|
||||||
|
|
||||||
|
$authorizeRequest = $psr17->createServerRequest('GET', 'https://part-db.test/authorize?'.http_build_query([
|
||||||
|
'response_type' => 'code',
|
||||||
|
'client_id' => $oauthClient->getIdentifier(),
|
||||||
|
'redirect_uri' => $redirectUri,
|
||||||
|
'code_challenge' => $challenge,
|
||||||
|
'code_challenge_method' => 'S256',
|
||||||
|
'scope' => implode(' ', $scopes),
|
||||||
|
]));
|
||||||
|
|
||||||
|
$authRequest = $authServer->validateAuthorizationRequest($authorizeRequest);
|
||||||
|
$leagueUser = new LeagueUser();
|
||||||
|
$leagueUser->setIdentifier('admin');
|
||||||
|
$authRequest->setUser($leagueUser);
|
||||||
|
$authRequest->setAuthorizationApproved(true);
|
||||||
|
|
||||||
|
$redirectResponse = $authServer->completeAuthorizationRequest($authRequest, $psr17->createResponse());
|
||||||
|
parse_str((string) parse_url($redirectResponse->getHeaderLine('Location'), PHP_URL_QUERY), $query);
|
||||||
|
self::assertArrayHasKey('code', $query, 'Expected an authorization code in the redirect.');
|
||||||
|
|
||||||
|
$tokenRequest = $psr17->createServerRequest('POST', 'https://part-db.test/token')
|
||||||
|
->withParsedBody([
|
||||||
|
'grant_type' => 'authorization_code',
|
||||||
|
'client_id' => $oauthClient->getIdentifier(),
|
||||||
|
'redirect_uri' => $redirectUri,
|
||||||
|
'code' => $query['code'],
|
||||||
|
'code_verifier' => $verifier,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tokenResponse = $authServer->respondToAccessTokenRequest($tokenRequest, $psr17->createResponse());
|
||||||
|
|
||||||
|
return json_decode((string) $tokenResponse->getBody(), true, flags: JSON_THROW_ON_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function bearer(string $accessToken): array
|
||||||
|
{
|
||||||
|
return ['headers' => ['authorization' => 'Bearer '.$accessToken]];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReadOnlyScopedTokenCanReadButNotEdit(): void
|
||||||
|
{
|
||||||
|
$httpClient = static::createClient();
|
||||||
|
$redirectUri = 'https://client.example.invalid/callback';
|
||||||
|
$oauthClient = $this->createTestClient($httpClient, $redirectUri, ['read_only']);
|
||||||
|
$data = $this->issueTokenForScopes($httpClient, $oauthClient, $redirectUri, ['read_only']);
|
||||||
|
|
||||||
|
self::assertArrayHasKey('access_token', $data);
|
||||||
|
//A real JWT, not one of our own "tcp_..." Personal Access Tokens.
|
||||||
|
self::assertStringContainsString('.', $data['access_token']);
|
||||||
|
|
||||||
|
$httpClient->request('GET', '/api/parts', $this->bearer($data['access_token']));
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
$httpClient->request('POST', '/api/footprints', array_merge(['json' => ['name' => 'oauth read_only test']], $this->bearer($data['access_token'])));
|
||||||
|
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFullScopedTokenCanEdit(): void
|
||||||
|
{
|
||||||
|
$httpClient = static::createClient();
|
||||||
|
$redirectUri = 'https://client.example.invalid/callback';
|
||||||
|
//A real "full"-level PAT gets the cumulative ROLE_API_READ_ONLY/EDIT/ADMIN/FULL roles (see
|
||||||
|
//App\Entity\UserSystem\ApiTokenLevel::getAdditionalRoles()); OAuth2 scopes are not cumulative,
|
||||||
|
//so the client/token must request every level it needs explicitly.
|
||||||
|
$oauthClient = $this->createTestClient($httpClient, $redirectUri, ['read_only', 'edit', 'admin', 'full']);
|
||||||
|
$data = $this->issueTokenForScopes($httpClient, $oauthClient, $redirectUri, ['read_only', 'edit', 'admin', 'full']);
|
||||||
|
|
||||||
|
$httpClient->request('GET', '/api/parts', $this->bearer($data['access_token']));
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
$httpClient->request('POST', '/api/footprints', array_merge(['json' => ['name' => 'oauth full test']], $this->bearer($data['access_token'])));
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInvalidPkceVerifierIsRejected(): void
|
||||||
|
{
|
||||||
|
$httpClient = static::createClient();
|
||||||
|
$redirectUri = 'https://client.example.invalid/callback';
|
||||||
|
$oauthClient = $this->createTestClient($httpClient, $redirectUri, ['read_only']);
|
||||||
|
|
||||||
|
$authServer = $httpClient->getContainer()->get(AuthorizationServer::class);
|
||||||
|
$psr17 = new Psr17Factory();
|
||||||
|
|
||||||
|
[, $challenge] = $this->generatePkcePair();
|
||||||
|
|
||||||
|
$authorizeRequest = $psr17->createServerRequest('GET', 'https://part-db.test/authorize?'.http_build_query([
|
||||||
|
'response_type' => 'code',
|
||||||
|
'client_id' => $oauthClient->getIdentifier(),
|
||||||
|
'redirect_uri' => $redirectUri,
|
||||||
|
'code_challenge' => $challenge,
|
||||||
|
'code_challenge_method' => 'S256',
|
||||||
|
'scope' => 'read_only',
|
||||||
|
]));
|
||||||
|
|
||||||
|
$authRequest = $authServer->validateAuthorizationRequest($authorizeRequest);
|
||||||
|
$leagueUser = new LeagueUser();
|
||||||
|
$leagueUser->setIdentifier('admin');
|
||||||
|
$authRequest->setUser($leagueUser);
|
||||||
|
$authRequest->setAuthorizationApproved(true);
|
||||||
|
|
||||||
|
$redirectResponse = $authServer->completeAuthorizationRequest($authRequest, $psr17->createResponse());
|
||||||
|
parse_str((string) parse_url($redirectResponse->getHeaderLine('Location'), PHP_URL_QUERY), $query);
|
||||||
|
|
||||||
|
$tokenRequest = $psr17->createServerRequest('POST', 'https://part-db.test/token')
|
||||||
|
->withParsedBody([
|
||||||
|
'grant_type' => 'authorization_code',
|
||||||
|
'client_id' => $oauthClient->getIdentifier(),
|
||||||
|
'redirect_uri' => $redirectUri,
|
||||||
|
'code' => $query['code'],
|
||||||
|
//Wrong verifier - does not match the code_challenge used above.
|
||||||
|
'code_verifier' => str_repeat('a', 43),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->expectException(OAuthServerException::class);
|
||||||
|
$authServer->respondToAccessTokenRequest($tokenRequest, $psr17->createResponse());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRefreshTokenRotationIssuesNewAccessTokenAndRevokesOld(): void
|
||||||
|
{
|
||||||
|
$httpClient = static::createClient();
|
||||||
|
$redirectUri = 'https://client.example.invalid/callback';
|
||||||
|
$oauthClient = $this->createTestClient($httpClient, $redirectUri, ['read_only']);
|
||||||
|
$first = $this->issueTokenForScopes($httpClient, $oauthClient, $redirectUri, ['read_only']);
|
||||||
|
|
||||||
|
$authServer = $httpClient->getContainer()->get(AuthorizationServer::class);
|
||||||
|
$psr17 = new Psr17Factory();
|
||||||
|
|
||||||
|
$refreshRequest = $psr17->createServerRequest('POST', 'https://part-db.test/token')
|
||||||
|
->withParsedBody([
|
||||||
|
'grant_type' => 'refresh_token',
|
||||||
|
'client_id' => $oauthClient->getIdentifier(),
|
||||||
|
'refresh_token' => $first['refresh_token'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$refreshResponse = $authServer->respondToAccessTokenRequest($refreshRequest, $psr17->createResponse());
|
||||||
|
$second = json_decode((string) $refreshResponse->getBody(), true, flags: JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
self::assertNotSame($first['access_token'], $second['access_token']);
|
||||||
|
|
||||||
|
//The old access token must now be rejected by the resource server (revoked on rotation).
|
||||||
|
$httpClient->request('GET', '/api/parts', $this->bearer($first['access_token']));
|
||||||
|
self::assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED);
|
||||||
|
|
||||||
|
$httpClient->request('GET', '/api/parts', $this->bearer($second['access_token']));
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue