diff --git a/migrations/Version20230815212203.php b/migrations/Version20230815212203.php new file mode 100644 index 00000000..1a6c372e --- /dev/null +++ b/migrations/Version20230815212203.php @@ -0,0 +1,33 @@ +addSql('CREATE TABLE api_tokens (id INT AUTO_INCREMENT NOT NULL, user_id INT DEFAULT NULL, name VARCHAR(255) NOT NULL, valid_until DATETIME DEFAULT NULL, token VARCHAR(68) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, UNIQUE INDEX UNIQ_2CAD560E5F37A13B (token), INDEX IDX_2CAD560EA76ED395 (user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE api_tokens ADD CONSTRAINT FK_2CAD560EA76ED395 FOREIGN KEY (user_id) REFERENCES `users` (id)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE api_tokens DROP FOREIGN KEY FK_2CAD560EA76ED395'); + $this->addSql('DROP TABLE api_tokens'); + } +} diff --git a/src/Controller/UserSettingsController.php b/src/Controller/UserSettingsController.php index 704bacb7..1bc9318e 100644 --- a/src/Controller/UserSettingsController.php +++ b/src/Controller/UserSettingsController.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace App\Controller; use App\Entity\Attachments\Attachment; +use App\Entity\UserSystem\ApiToken; use App\Entity\UserSystem\U2FKey; use App\Entity\UserSystem\User; use App\Entity\UserSystem\WebauthnKey; @@ -39,6 +40,7 @@ use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Google\GoogleAuthenticator use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\Form\Extension\Core\Type\DateTimeType; use Symfony\Component\Form\Extension\Core\Type\PasswordType; use Symfony\Component\Form\Extension\Core\Type\RepeatedType; use Symfony\Component\Form\Extension\Core\Type\SubmitType; @@ -395,4 +397,46 @@ class UserSettingsController extends AbstractController ], ]); } + + /** + * @return Response + */ + #[Route('/api_token/create', name: 'user_api_token_create')] + public function addApiToken(Request $request, EntityManagerInterface $entityManager): Response + { + $token = new ApiToken(); + + $secret = null; + + $form = $this->createFormBuilder($token) + ->add('name', TextType::class, [ + 'label' => 'user.api_token.name', + ]) + ->add('valid_until', DateTimeType::class, [ + 'label' => 'user.api_token.valid_until', + 'widget' => 'single_text', + 'required' => false, + 'html5' => true + ]) + ->add('submit', SubmitType::class, [ + 'label' => 'save', + ]) + ->getForm(); + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $token->setUser($this->getUser()); + $entityManager->persist($token); + $entityManager->flush(); + + $secret = $token->getToken(); + } + + return $this->render('users/api_token_create.html.twig', [ + 'token' => $token, + 'form' => $form, + 'secret' => $secret, + ]); + } } diff --git a/src/Entity/UserSystem/ApiToken.php b/src/Entity/UserSystem/ApiToken.php new file mode 100644 index 00000000..f895febf --- /dev/null +++ b/src/Entity/UserSystem/ApiToken.php @@ -0,0 +1,125 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Entity\UserSystem; + +use App\Entity\Base\AbstractNamedDBElement; +use App\Entity\Base\TimestampTrait; +use App\Repository\UserSystem\ApiTokenRepository; +use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; +use Symfony\Component\Validator\Constraints\NotBlank; + +#[ORM\Entity(repositoryClass: ApiTokenRepository::class)] +#[ORM\Table(name: 'api_tokens')] +#[ORM\HasLifecycleCallbacks] +#[UniqueEntity(fields: ['name', 'user'])] +class ApiToken +{ + + use TimestampTrait; + + #[ORM\Id] + #[ORM\Column(type: Types::INTEGER)] + #[ORM\GeneratedValue] + protected int $id; + + #[ORM\Column(type: Types::STRING)] + #[NotBlank] + protected string $name = ''; + + #[ORM\ManyToOne(inversedBy: 'api_tokens')] + private ?User $user = null; + + #[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)] + private ?\DateTimeInterface $valid_until = null; + + #[ORM\Column(length: 68, unique: true)] + private string $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. + $this->token = $tokenType->getTokenPrefix() . bin2hex(random_bytes(32)); + } + + public function getTokenType(): ApiTokenType + { + return ApiTokenType::getTypeFromToken($this->token); + } + + public function getUser(): ?User + { + return $this->user; + } + + public function setUser(?User $user): ApiToken + { + $this->user = $user; + return $this; + } + + public function getValidUntil(): ?\DateTimeInterface + { + return $this->valid_until; + } + + /** + * Checks if the token is still valid. + * @return bool + */ + public function isValid(): bool + { + return $this->valid_until === null || $this->valid_until > new \DateTime(); + } + + public function setValidUntil(?\DateTimeInterface $valid_until): ApiToken + { + $this->valid_until = $valid_until; + return $this; + } + + public function getToken(): string + { + return $this->token; + } + + public function getId(): int + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): ApiToken + { + $this->name = $name; + return $this; + } + + +} \ No newline at end of file diff --git a/src/Entity/UserSystem/ApiTokenType.php b/src/Entity/UserSystem/ApiTokenType.php new file mode 100644 index 00000000..f8beb378 --- /dev/null +++ b/src/Entity/UserSystem/ApiTokenType.php @@ -0,0 +1,56 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Entity\UserSystem; + +/** + * The type of ApiToken. + * The enum value is the prefix of the token. It must be 3 characters long. + */ +enum ApiTokenType: string +{ + case PERSONAL_ACCESS_TOKEN = 'tcp'; + + /** + * Get the prefix of the token including the underscore + * @return string + */ + public function getTokenPrefix(): string + { + return $this->value . '_'; + } + + /** + * Get the type from the token prefix + * @param string $api_token + * @return ApiTokenType + */ + public static function getTypeFromToken(string $api_token): ApiTokenType + { + $parts = explode('_', $api_token); + if (count($parts) !== 2) { + throw new \InvalidArgumentException('Invalid token format'); + } + return self::from($parts[0]); + } +} diff --git a/src/Entity/UserSystem/User.php b/src/Entity/UserSystem/User.php index 7bd22924..cfc144d8 100644 --- a/src/Entity/UserSystem/User.php +++ b/src/Entity/UserSystem/User.php @@ -279,6 +279,12 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe #[ORM\OneToMany(mappedBy: 'user', targetEntity: WebauthnKey::class, cascade: ['REMOVE'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] protected Collection $webauthn_keys; + /** + * @var Collection + */ + #[ORM\OneToMany(mappedBy: 'user', targetEntity: ApiToken::class, cascade: ['REMOVE'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] + private Collection $api_tokens; + /** * @var Currency|null The currency the user wants to see prices in. * Dont use fetch=EAGER here, this will cause problems with setting the currency setting. @@ -316,6 +322,7 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe $this->permissions = new PermissionData(); $this->u2fKeys = new ArrayCollection(); $this->webauthn_keys = new ArrayCollection(); + $this->api_tokens = new ArrayCollection(); } /** @@ -969,8 +976,6 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe return $this; } - - public function setSamlAttributes(array $attributes): void { //When mail attribute exists, set it @@ -1000,4 +1005,36 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe $this->setEmail($attributes['urn:oid:1.2.840.113549.1.9.1'][0]); } } + + /** + * Return all API tokens of the user. + * @return Collection + */ + public function getApiTokens(): Collection + { + return $this->api_tokens; + } + + /** + * Add an API token to the user. + * @param ApiToken $apiToken + * @return void + */ + public function addApiToken(ApiToken $apiToken): void + { + $this->api_tokens->add($apiToken); + } + + /** + * Remove an API token from the user. + * @param ApiToken $apiToken + * @return void + */ + public function removeApiToken(ApiToken $apiToken): void + { + $this->api_tokens->removeElement($apiToken); + } + + + } diff --git a/src/Repository/UserSystem/ApiTokenRepository.php b/src/Repository/UserSystem/ApiTokenRepository.php new file mode 100644 index 00000000..7e11ee53 --- /dev/null +++ b/src/Repository/UserSystem/ApiTokenRepository.php @@ -0,0 +1,32 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Repository\UserSystem; + +use App\Repository\NamedDBElementRepository; +use Doctrine\ORM\EntityRepository; + +class ApiTokenRepository extends EntityRepository +{ + +} \ No newline at end of file diff --git a/templates/users/api_token_create.html.twig b/templates/users/api_token_create.html.twig new file mode 100644 index 00000000..57b86708 --- /dev/null +++ b/templates/users/api_token_create.html.twig @@ -0,0 +1,16 @@ +{% extends "main_card.html.twig" %} + +{% block card_title %}Add API token{% endblock %} + +{% block card_content %} + {# Show API secret after submit #} + + {% if secret is not null %} +
+ Your API token is: {{ secret }}
+ Please save it. You wont be able to see it again! +
+ {% endif %} + + {{ form(form) }} +{% endblock %} \ No newline at end of file diff --git a/tests/Entity/UserSystem/ApiTokenTypeTest.php b/tests/Entity/UserSystem/ApiTokenTypeTest.php new file mode 100644 index 00000000..d504f39d --- /dev/null +++ b/tests/Entity/UserSystem/ApiTokenTypeTest.php @@ -0,0 +1,50 @@ +. + */ + +namespace App\Tests\Entity\UserSystem; + +use App\Entity\UserSystem\ApiTokenType; +use PHPUnit\Framework\TestCase; + +class ApiTokenTypeTest extends TestCase +{ + + public function testGetTokenPrefix(): void + { + $this->assertEquals('tcp_', ApiTokenType::PERSONAL_ACCESS_TOKEN->getTokenPrefix()); + } + + public function testGetTypeFromToken(): void + { + $this->assertEquals(ApiTokenType::PERSONAL_ACCESS_TOKEN, ApiTokenType::getTypeFromToken('tcp_123')); + } + + public function testGetTypeFromTokenInvalid(): void + { + $this->expectException(\InvalidArgumentException::class); + ApiTokenType::getTypeFromToken('tcp123'); + } + + public function testGetTypeFromTokenNonExisting(): void + { + $this->expectException(\ValueError::class); + ApiTokenType::getTypeFromToken('abc_123'); + } +}