Skip to content

Commit 554131d

Browse files
committed
Added API key on users
1 parent 28a6d27 commit 554131d

9 files changed

Lines changed: 172 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
- [#88](https://github.com/itk-dev/devops_itksites/pull/88)
11+
- Let users use the API
1012
- [#80](https://github.com/itk-dev/devops_itksites/pull/80) 5566: Service agreements
1113
- Add security contract entity with crud controller
1214
- Add Abstract full crud controller and extend on it in some cases

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,24 @@ The system is build so that all analyzed data can be truncated safely and rebuil
4545
by "replaying" the DetectionResults. This means that care must be taken when
4646
manually maintained data and auto updated data must have cross references.
4747

48+
## API
49+
50+
Authenticated users can access a simple read-only API – see the API documentation on `/api/docs` for details.
51+
52+
### API keys
53+
54+
Run the `app:user:set-api-key` console command to set the API for a user:
55+
56+
``` shell
57+
docker compose exec phpfpm php bin/console app:user:set-api-key <user-id>
58+
```
59+
60+
Use the API key to make an authenticated request, e.g.
61+
62+
``` shell
63+
curl --header 'accept: application/json' --header 'authorization: Apikey <the API key>' https://itksites.local.itkdev.dk/api/sites
64+
```
65+
4866
## Development
4967

5068
```sh

config/packages/security.yaml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ security:
1212
entity:
1313
class: App\Entity\User
1414
property: email
15+
16+
app_api_users:
17+
chain:
18+
providers: [app_server_provider, app_user_provider]
19+
1520
firewalls:
1621
dev:
1722
pattern: ^/(_(profiler|wdt)|css|images|js)/
@@ -20,7 +25,7 @@ security:
2025
pattern: ^/api
2126
custom_authenticators:
2227
- App\Security\ApiKeyAuthenticator
23-
provider: app_server_provider
28+
provider: app_api_users
2429

2530
main:
2631
custom_authenticators:
@@ -41,7 +46,7 @@ security:
4146
# Note: Only the *first* access control that matches will be used
4247
access_control:
4348
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
44-
- { path: ^/api, roles: ROLE_SERVER }
49+
- { path: ^/api, roles: [ROLE_SERVER, ROLE_USER] }
4550
- { path: ^/admin, roles: ROLE_ADMIN }
4651
# - { path: ^/profile, roles: ROLE_USER }
4752

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace DoctrineMigrations;
6+
7+
use Doctrine\DBAL\Schema\Schema;
8+
use Doctrine\Migrations\AbstractMigration;
9+
10+
/**
11+
* Auto-generated Migration: Please modify to your needs!
12+
*/
13+
final class Version20260702123347 extends AbstractMigration
14+
{
15+
public function getDescription(): string
16+
{
17+
return '';
18+
}
19+
20+
public function up(Schema $schema): void
21+
{
22+
// this up() migration is auto-generated, please modify it to your needs
23+
$this->addSql('ALTER TABLE user ADD api_key VARCHAR(255) NOT NULL');
24+
$this->addSql('CREATE UNIQUE INDEX UNIQ_8D93D649C912ED9D ON user (api_key)');
25+
}
26+
27+
public function down(Schema $schema): void
28+
{
29+
// this down() migration is auto-generated, please modify it to your needs
30+
$this->addSql('DROP INDEX UNIQ_8D93D649C912ED9D ON user');
31+
$this->addSql('ALTER TABLE user DROP api_key');
32+
}
33+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
3+
namespace App\Command;
4+
5+
use App\Repository\UserRepository;
6+
use Doctrine\ORM\EntityManagerInterface;
7+
use Symfony\Component\Console\Attribute\Argument;
8+
use Symfony\Component\Console\Attribute\AsCommand;
9+
use Symfony\Component\Console\Command\Command;
10+
use Symfony\Component\Console\Exception\InvalidArgumentException;
11+
use Symfony\Component\Console\Style\SymfonyStyle;
12+
13+
#[AsCommand(
14+
name: 'app:user:set-api-key',
15+
description: 'Set API key for a user',
16+
)]
17+
readonly class UserSetApiKeyCommand
18+
{
19+
public function __construct(
20+
private UserRepository $userRepository,
21+
private EntityManagerInterface $entityManager,
22+
) {
23+
}
24+
25+
public function __invoke(
26+
SymfonyStyle $io,
27+
#[Argument]
28+
string $userId,
29+
): int {
30+
$user = $this->userRepository->findOneBy(['email' => $userId])
31+
?? $this->userRepository->findOneBy(['name' => $userId]);
32+
33+
if (null === $user) {
34+
throw new InvalidArgumentException(sprintf('Cannot load user with id %s', $userId));
35+
}
36+
37+
$question = sprintf('Really set API key on user %s', $user->getUserIdentifier());
38+
if (!$io->confirm($question)) {
39+
return Command::SUCCESS;
40+
}
41+
42+
$user->setApiKey($user->generateApiKey());
43+
$this->entityManager->flush();
44+
45+
$io->success([
46+
sprintf('API key for user %s set to', $user->getUserIdentifier()),
47+
$user->getApiKey(),
48+
]);
49+
50+
return Command::SUCCESS;
51+
}
52+
}

src/Entity/Server.php

Lines changed: 4 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace App\Entity;
66

77
use App\Repository\ServerRepository;
8+
use App\Trait\ApiKeyEntityTrait;
89
use Doctrine\Common\Collections\ArrayCollection;
910
use Doctrine\Common\Collections\Collection;
1011
use Doctrine\ORM\Mapping as ORM;
@@ -16,16 +17,9 @@
1617
#[ORM\Entity(repositoryClass: ServerRepository::class)]
1718
class Server extends AbstractBaseEntity implements UserInterface, \Stringable
1819
{
19-
private const array ROLES = ['ROLE_USER', 'ROLE_SERVER'];
20+
use ApiKeyEntityTrait;
2021

21-
#[ORM\Column(type: 'string', length: 255, unique: true)]
22-
#[Assert\Length(
23-
min: 40,
24-
max: 255,
25-
minMessage: 'Api key must be at least {{ limit }} characters long',
26-
maxMessage: 'Api key cannot be longer than {{ limit }} characters',
27-
)]
28-
private string $apiKey;
22+
private const array ROLES = ['ROLE_USER', 'ROLE_SERVER'];
2923

3024
#[ORM\Column(type: 'string', length: 255, unique: true)]
3125
#[Groups(['export'])]
@@ -93,8 +87,8 @@ class Server extends AbstractBaseEntity implements UserInterface, \Stringable
9387
*/
9488
public function __construct()
9589
{
90+
$this->setApiKey($this->generateApiKey());
9691
$this->detectionResults = new ArrayCollection();
97-
$this->apiKey = sha1(\random_bytes(40));
9892
$this->installations = new ArrayCollection();
9993
}
10094

@@ -258,18 +252,6 @@ public function setUsedFor(?string $usedFor): self
258252
return $this;
259253
}
260254

261-
public function getApiKey(): string
262-
{
263-
return $this->apiKey;
264-
}
265-
266-
public function setApiKey(string $apiKey): self
267-
{
268-
$this->apiKey = $apiKey;
269-
270-
return $this;
271-
}
272-
273255
/**
274256
* @return Collection<int, DetectionResult>
275257
*/

src/Entity/User.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,15 @@
55
namespace App\Entity;
66

77
use App\Repository\UserRepository;
8+
use App\Trait\ApiKeyEntityTrait;
89
use Doctrine\ORM\Mapping as ORM;
910
use Symfony\Component\Security\Core\User\UserInterface;
1011

1112
#[ORM\Entity(repositoryClass: UserRepository::class)]
1213
class User extends AbstractBaseEntity implements UserInterface
1314
{
15+
use ApiKeyEntityTrait;
16+
1417
public function __construct(
1518
#[ORM\Column(length: 255)]
1619
private string $name,
@@ -19,6 +22,7 @@ public function __construct(
1922
#[ORM\Column(type: 'json')]
2023
private array $roles = [],
2124
) {
25+
$this->setApiKey($this->generateApiKey());
2226
}
2327

2428
#[\Override]

src/Security/ApiKeyAuthenticator.php

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@
44

55
namespace App\Security;
66

7+
use App\Repository\ServerRepository;
8+
use App\Repository\UserRepository;
79
use Symfony\Component\HttpFoundation\JsonResponse;
810
use Symfony\Component\HttpFoundation\Request;
911
use Symfony\Component\HttpFoundation\Response;
1012
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
1113
use Symfony\Component\Security\Core\Exception\AuthenticationException;
14+
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
1215
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
1316
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
1417
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
@@ -20,6 +23,12 @@ class ApiKeyAuthenticator extends AbstractAuthenticator
2023
public const string AUTH_HEADER = 'Authorization';
2124
public const string AUTH_HEADER_PREFIX = 'Apikey ';
2225

26+
public function __construct(
27+
private readonly ServerRepository $serverRepository,
28+
private readonly UserRepository $userRepository,
29+
) {
30+
}
31+
2332
/**
2433
* Called on every request to decide if this authenticator should be used for the request.
2534
*
@@ -40,7 +49,14 @@ public function authenticate(Request $request): Passport
4049
throw new CustomUserMessageAuthenticationException('No API token provided');
4150
}
4251

43-
return new SelfValidatingPassport(new UserBadge($apiKey));
52+
// Users and servers can authenticate to use the API.
53+
$apiUser = $this->serverRepository->findOneBy(['apiKey' => $apiKey])
54+
?? $this->userRepository->findOneBy(['apiKey' => $apiKey]);
55+
if (null !== $apiUser) {
56+
return new SelfValidatingPassport(new UserBadge($apiUser->getUserIdentifier()));
57+
}
58+
59+
throw new BadCredentialsException('Invalid credentials.');
4460
}
4561

4662
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response

src/Trait/ApiKeyEntityTrait.php

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
namespace App\Trait;
4+
5+
use Doctrine\ORM\Mapping as ORM;
6+
use Symfony\Component\Validator\Constraints as Assert;
7+
8+
trait ApiKeyEntityTrait
9+
{
10+
#[ORM\Column(type: 'string', length: 255, unique: true)]
11+
#[Assert\Length(
12+
min: 40,
13+
max: 255,
14+
minMessage: 'Api key must be at least {{ limit }} characters long',
15+
maxMessage: 'Api key cannot be longer than {{ limit }} characters',
16+
)]
17+
private string $apiKey;
18+
19+
public function getApiKey(): string
20+
{
21+
return $this->apiKey;
22+
}
23+
24+
public function setApiKey(string $apiKey): static
25+
{
26+
$this->apiKey = $apiKey;
27+
28+
return $this;
29+
}
30+
31+
public function generateApiKey(): string
32+
{
33+
return sha1(\random_bytes(40));
34+
}
35+
}

0 commit comments

Comments
 (0)