diff --git a/.env.dist b/.env.dist
index 6064e45..62619d6 100644
--- a/.env.dist
+++ b/.env.dist
@@ -1,11 +1,11 @@
APP_BUILDER_API=\Aphiria\Framework\Api\SynchronousApiApplicationBuilder
APP_BUILDER_CONSOLE=\Aphiria\Framework\Console\ConsoleApplicationBuilder
+APP_COOKIE_DOMAIN=
+APP_COOKIE_SECURE=0
APP_ENV=development
APP_URL=http://localhost:8080
-DB_DRIVER=postgres
DB_HOST=localhost
-DB_USER=myuser
-DB_PASSWORD=mypassword
-DB_NAME=public
-DB_PORT=5432
+DB_PATH=/database/database.sqlite
LOG_LEVEL=debug
+USER_DEFAULT_EMAIL=admin@example.com
+USER_DEFAULT_PASSWORD=abc123
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a030801..b5b055d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,6 +1,8 @@
name: ci
on:
push:
+ branches:
+ - '*.x'
pull_request:
jobs:
ci:
@@ -19,7 +21,7 @@ jobs:
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
- extensions: curl, dom, libxml, mbstring, pcntl, xdebug, zip
+ extensions: curl, dom, libxml, mbstring, pcntl, sqlite3, xdebug, zip
tools: composer:v2
coverage: xdebug
- name: Install Dependencies
@@ -29,4 +31,10 @@ jobs:
- name: Run Linter
run: composer phpcs-test
- name: Run Psalm Static Analysis
- run: composer psalm
+ run: composer psalm -- --shepherd
+ - name: Upload Coverage Results To Coveralls
+ env:
+ COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ composer global require php-coveralls/php-coveralls
+ php-coveralls --coverage_clover=./.coverage/clover.xml --json_path=./coveralls-upload.json -v
diff --git a/.gitignore b/.gitignore
index b3c8e0b..94ec7c1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,6 +6,7 @@
/.phpunit.result.cache
/composer.lock
/composer.phar
+/database/database.sqlite
/nbproject/
/phpunit.phar
/vendor/
diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php
index b73a19f..1b2885c 100644
--- a/.php-cs-fixer.dist.php
+++ b/.php-cs-fixer.dist.php
@@ -58,7 +58,8 @@
'method_protected',
'method_private_static',
'method_private'
- ]
+ ],
+ 'sort_algorithm' => 'alpha'
],
'ordered_imports' => true,
'return_type_declaration' => ['space_before' => 'none'],
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7f3f3ba..2943e4c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,10 @@
## [v1.0.0-alpha5](https://github.com/aphiria/app/compare/v1.0.0-alpha4...v1.0.0-alpha5) (?)
+### Added
+
+- Added auth and user scaffolding, backed by a SQLite database ([#34](https://github.com/aphiria/app/pull/34))
+
### Changed
- Updated PHPUnit and Psalm ([#33](https://github.com/aphiria/app/pull/33))
diff --git a/README.md b/README.md
index 138698a..571f4c3 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,14 @@
-
+
+
-> **Note:** This library is not stable yet.
+> **Note:** This framework is not stable yet.
This application is a useful starting point for projects that use the Aphiria framework. Check out this repository, and get started building your own REST API.
@@ -29,11 +30,7 @@ php aphiria app:serve
## Demo
-This app comes with an extremely simple demo that can store and retrieve users from local file storage. It should not be used in production - it is simply a demo of some Aphiria features. The demo routes can be found as PHP attributes in [_src/Demo/Api/Controllers/UserController.php_](src/Demo/Api/Controllers/UserController.php).
-
-### Removing Demo Code
-
-To remove the built-in demo code, simply delete the [_src/Demo_](src/Demo) and [_tests/Integration/Demo_](tests/Integration/Demo) directories, and remove the `DemoModule` from [_src/GlobalModule.php_](src/GlobalModule.php).
+This app comes with a simple demo that can store, retrieve, and authenticate users from a local SQLite database.
## Learn More
diff --git a/composer.json b/composer.json
index 1a8597f..57d6a93 100644
--- a/composer.json
+++ b/composer.json
@@ -27,6 +27,8 @@
"require": {
"aphiria/aphiria": "1.x-dev",
"ext-mbstring": "*",
+ "ext-pdo": "*",
+ "ext-sqlite3": "*",
"php": ">=8.2",
"symfony/dotenv": "^6.1"
},
@@ -43,7 +45,9 @@
"php -r \"file_exists('.env') || copy('.env.dist', '.env');\""
],
"post-create-project-cmd": [
- "php -r \"echo 'Important: make ' . __DIR__ . DIRECTORY_SEPARATOR . 'tmp writable' . PHP_EOL;\""
+ "php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
+ "php -r \"echo 'Important: make ' . __DIR__ . DIRECTORY_SEPARATOR . 'tmp writable' . PHP_EOL;\"",
+ "php aphiria user:generate-default-credentials"
],
"post-install-cmd": [
"php -r \"shell_exec((file_exists(getcwd() . '/composer.phar') ? PHP_BINARY . ' composer.phar' : 'composer') . ' dump-autoload -o');\"",
diff --git a/database/.gitkeep b/database/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index 4e650c3..f5905d9 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -20,5 +20,6 @@
+
diff --git a/psalm.xml.dist b/psalm.xml.dist
index 953e004..e7251a6 100644
--- a/psalm.xml.dist
+++ b/psalm.xml.dist
@@ -21,6 +21,11 @@
+
+
+
+
+
diff --git a/src/Auth/Api/Controllers/AuthController.php b/src/Auth/Api/Controllers/AuthController.php
new file mode 100644
index 0000000..0c61f1b
--- /dev/null
+++ b/src/Auth/Api/Controllers/AuthController.php
@@ -0,0 +1,63 @@
+authenticator->logIn($this->getUser(), $this->request, $response, 'cookie');
+
+ return $response;
+ }
+
+ /**
+ * Logs out the user
+ *
+ * @return IResponse The logout response
+ * @throws AuthenticationSchemeNotFoundException|UnsupportedAuthenticationHandlerException Thrown if there was an issue with authentication
+ */
+ #[Post('/logout')]
+ public function logOut(): IResponse
+ {
+ $response = new Response();
+ /** @psalm-suppress PossiblyNullArgument The request will be set */
+ $this->authenticator->logOut($this->request, $response, 'cookie');
+
+ return $response;
+ }
+}
diff --git a/src/Auth/AuthModule.php b/src/Auth/AuthModule.php
new file mode 100644
index 0000000..374164f
--- /dev/null
+++ b/src/Auth/AuthModule.php
@@ -0,0 +1,86 @@
+withBinders($appBuilder, new AuthServiceBinder())
+ ->withDatabaseSeeders($appBuilder, SqlTokenSeeder::class)
+ // Add our default authentication scheme
+ ->withAuthenticationScheme(
+ $appBuilder,
+ new AuthenticationScheme(
+ 'cookie',
+ CookieAuthenticationHandler::class,
+ new CookieAuthenticationOptions(
+ cookieName: 'authToken',
+ cookieMaxAge: 3600,
+ cookiePath: '/',
+ cookieDomain: (string)\getenv('APP_COOKIE_DOMAIN'),
+ cookieIsSecure: (bool)\getenv('APP_COOKIE_SECURE'),
+ cookieIsHttpOnly: true,
+ cookieSameSite: SameSiteMode::Strict,
+ loginPagePath: '/login',
+ forbiddenPagePath: '/access-denied',
+ claimsIssuer: (string)\getenv('APP_COOKIE_DOMAIN')
+ )
+ ),
+ true
+ )
+ ->withAuthenticationScheme(
+ $appBuilder,
+ new AuthenticationScheme(
+ 'basic',
+ BasicAuthenticationHandler::class,
+ new BasicAuthenticationOptions((string)\getenv('APP_URL'))
+ )
+ )
+ ->withAuthorizationRequirementHandler(
+ $appBuilder,
+ AuthorizedUserDeleterRequirement::class,
+ new AuthorizedUserDeleterRequirementHandler()
+ )
+ ->withAuthorizationRequirementHandler(
+ $appBuilder,
+ RolesRequirement::class,
+ new RolesRequirementHandler()
+ )
+ ->withAuthorizationPolicy(
+ $appBuilder,
+ new AuthorizationPolicy(
+ 'authorized-user-deleter',
+ new AuthorizedUserDeleterRequirement('admin')
+ )
+ )
+ ->withProblemDetails(
+ $appBuilder,
+ InvalidCredentialsException::class,
+ status: HttpStatusCode::BadRequest
+ );
+ }
+}
diff --git a/src/Auth/AuthorizedUserDeleterRequirement.php b/src/Auth/AuthorizedUserDeleterRequirement.php
new file mode 100644
index 0000000..49045ba
--- /dev/null
+++ b/src/Auth/AuthorizedUserDeleterRequirement.php
@@ -0,0 +1,26 @@
+ The list of roles authorized to delete other users' accounts */
+ public array $authorizedRoles;
+
+ /**
+ * @param list|string $authorizedRoles The role or list of roles that are authorized to delete other users' accounts
+ */
+ public function __construct(array|string $authorizedRoles)
+ {
+ if (!\is_array($authorizedRoles)) {
+ $authorizedRoles = [$authorizedRoles];
+ }
+
+ $this->authorizedRoles = $authorizedRoles;
+ }
+}
diff --git a/src/Auth/AuthorizedUserDeleterRequirementHandler.php b/src/Auth/AuthorizedUserDeleterRequirementHandler.php
new file mode 100644
index 0000000..a2b219a
--- /dev/null
+++ b/src/Auth/AuthorizedUserDeleterRequirementHandler.php
@@ -0,0 +1,54 @@
+
+ */
+final class AuthorizedUserDeleterRequirementHandler implements IAuthorizationRequirementHandler
+{
+ /**
+ * @inheritdoc
+ */
+ public function handle(IPrincipal $user, object $requirement, AuthorizationContext $authorizationContext): void
+ {
+ if (!$requirement instanceof AuthorizedUserDeleterRequirement) {
+ throw new InvalidArgumentException('Requirement must be of type ' . AuthorizedUserDeleterRequirement::class);
+ }
+
+ $userToDelete = $authorizationContext->resource;
+
+ if (!$userToDelete instanceof User) {
+ throw new InvalidArgumentException('Resource must be of type ' . User::class);
+ }
+
+ if ($userToDelete->id === (int)$user->getPrimaryIdentity()?->getNameIdentifier()) {
+ // The user being deleted is the current user
+ $authorizationContext->requirementPassed($requirement);
+
+ return;
+ }
+
+ foreach ($requirement->authorizedRoles as $authorizedRole) {
+ if ($user->hasClaim(ClaimType::Role, $authorizedRole)) {
+ // The user is authorized to delete the user's account
+ $authorizationContext->requirementPassed($requirement);
+
+ return;
+ }
+ }
+
+ $authorizationContext->fail();
+ }
+}
diff --git a/src/Auth/BasicAuthenticationHandler.php b/src/Auth/BasicAuthenticationHandler.php
new file mode 100644
index 0000000..aae4931
--- /dev/null
+++ b/src/Auth/BasicAuthenticationHandler.php
@@ -0,0 +1,48 @@
+users->getUserByEmailAndPassword($username, $password)) === null) {
+ return AuthenticationResult::fail('Invalid credentials', $scheme->name);
+ }
+
+ return AuthenticationResult::pass(
+ (new PrincipalBuilder($scheme->options->claimsIssuer ?? $scheme->name))->withNameIdentifier($user->id)
+ ->withEmail($user->email)
+ ->withRoles($user->roles)
+ ->withAuthenticationSchemeName($scheme->name)
+ ->build(),
+ $scheme->name
+ );
+ }
+}
diff --git a/src/Auth/Binders/AuthServiceBinder.php b/src/Auth/Binders/AuthServiceBinder.php
new file mode 100644
index 0000000..aed8014
--- /dev/null
+++ b/src/Auth/Binders/AuthServiceBinder.php
@@ -0,0 +1,25 @@
+bindClass(ITokenService::class, SqlTokenService::class);
+ }
+}
diff --git a/src/Auth/CookieAuthenticationHandler.php b/src/Auth/CookieAuthenticationHandler.php
new file mode 100644
index 0000000..e00b692
--- /dev/null
+++ b/src/Auth/CookieAuthenticationHandler.php
@@ -0,0 +1,116 @@
+ $scheme The scheme
+ * @throws InvalidCredentialsException Thrown if there was an error decoding the cookie value
+ */
+ public function logOut(IRequest $request, IResponse $response, AuthenticationScheme $scheme): void
+ {
+ $cookieValue = $this->getCookieValueFromRequest($request, $scheme);
+
+ if ($cookieValue !== null) {
+ try {
+ /** @var array{userId: int, token: string} $decodedCookieValue */
+ $decodedCookieValue = \json_decode(\base64_decode($cookieValue), true, 512, JSON_THROW_ON_ERROR);
+ } catch (JsonException $ex) {
+ throw new InvalidCredentialsException('Cookie contained invalid JSON', 0, $ex);
+ }
+
+ $userId = isset($decodedCookieValue['userId']) ? (int)$decodedCookieValue['userId'] : null;
+ $token = $decodedCookieValue['token'] ?? null;
+
+ if ($userId !== null && $token !== null) {
+ $this->tokens->expireToken($userId, $token);
+ }
+ }
+
+ parent::logOut($request, $response, $scheme);
+ }
+
+ /**
+ * @inheritdoc
+ * @param AuthenticationScheme $scheme The scheme
+ * @throws UserNotFoundException Thrown if no user was found with the retrieved user ID
+ */
+ protected function createAuthenticationResultFromCookie(string $cookieValue, IRequest $request, AuthenticationScheme $scheme): AuthenticationResult
+ {
+ try {
+ /** @var array{userId: int, token: string} $decodedCookieValue */
+ $decodedCookieValue = \json_decode(\base64_decode($cookieValue), true, 512, JSON_THROW_ON_ERROR);
+ } catch (JsonException) {
+ return AuthenticationResult::fail('Token format is invalid', $scheme->name);
+ }
+
+ if (!isset($decodedCookieValue['userId'], $decodedCookieValue['token'])) {
+ return AuthenticationResult::fail('Token format is invalid', $scheme->name);
+ }
+
+ $userId = (int)$decodedCookieValue['userId'];
+ $token = (string)$decodedCookieValue['token'];
+
+ if (!$this->tokens->validateToken($userId, $token)) {
+ return AuthenticationResult::fail('Invalid token', $scheme->name);
+ }
+
+ $user = $this->users->getUserById($userId);
+
+ return AuthenticationResult::pass(
+ (new PrincipalBuilder($scheme->options->claimsIssuer ?? $scheme->name))->withNameIdentifier($user->id)
+ ->withEmail($user->email)
+ ->withRoles($user->roles)
+ ->withAuthenticationSchemeName($scheme->name)
+ ->build(),
+ $scheme->name
+ );
+ }
+
+ /**
+ * @inheritdoc
+ * @param AuthenticationScheme $scheme The scheme
+ * @throws JsonException Thrown if there was an error encoding the JSON (very unlikely)
+ */
+ protected function createCookieValueForUser(IPrincipal $user, AuthenticationScheme $scheme): string|int|float
+ {
+ $cookieTtlSeconds = $scheme->options->cookieMaxAge ?? self::DEFAULT_COOKIE_TTL_SECONDS;
+ $userId = (int)$user->getPrimaryIdentity()?->getNameIdentifier();
+ $token = $this->tokens->createToken($userId, $cookieTtlSeconds);
+
+ return \base64_encode(\json_encode(['userId' => $userId, 'token' => $token], JSON_THROW_ON_ERROR));
+ }
+}
diff --git a/src/Auth/ITokenService.php b/src/Auth/ITokenService.php
new file mode 100644
index 0000000..f93f578
--- /dev/null
+++ b/src/Auth/ITokenService.php
@@ -0,0 +1,37 @@
+pdo->exec(
+ <<pdo->exec(
+ <<pdo->exec(
+ <<pdo->prepare(
+ <<execute([
+ 'userId' => $userId,
+ 'hashedToken' => self::hashToken($token),
+ 'expiration' => \time() + $ttlSeconds
+ ]);
+
+ return $token;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function expireToken(int $userId, string $token): void
+ {
+ $statement = $this->pdo->prepare(
+ <<execute([
+ 'expiration' => 0,
+ 'userId' => $userId,
+ 'hashToken' => self::hashToken($token)
+ ]);
+ }
+
+ /**
+ * @inheridoc
+ */
+ public function validateToken(int $userId, string $token): bool
+ {
+ $statement = $this->pdo->prepare(
+ << :time
+SQL
+ );
+ $statement->execute([
+ 'userId' => $userId,
+ 'hashedToken' => self::hashToken($token),
+ 'time' => \time()
+ ]);
+
+ return \count($statement->fetchAll()) === 1;
+ }
+
+ /**
+ * Hashes a token for storage
+ *
+ * @param string $token The token to hash
+ * @return string The hashed token
+ */
+ private static function hashToken(string $token): string
+ {
+ return \hash('sha256', $token);
+ }
+}
diff --git a/src/Database/Binders/DatabaseBinder.php b/src/Database/Binders/DatabaseBinder.php
new file mode 100644
index 0000000..dcf68a6
--- /dev/null
+++ b/src/Database/Binders/DatabaseBinder.php
@@ -0,0 +1,29 @@
+bindFactory(
+ PDO::class,
+ fn () => new PDO($dsn),
+ true
+ );
+ }
+}
diff --git a/src/Database/Components/DatabaseComponents.php b/src/Database/Components/DatabaseComponents.php
new file mode 100644
index 0000000..1d5257d
--- /dev/null
+++ b/src/Database/Components/DatabaseComponents.php
@@ -0,0 +1,36 @@
+|list> $classNames The name or names of database seeder classes to add
+ * @return static For chaining
+ * @throws RuntimeException Thrown if the global container instance was not set
+ */
+ protected function withDatabaseSeeders(IApplicationBuilder $appBuilder, string|array $classNames): static
+ {
+ if (!$appBuilder->hasComponent(DatabaseSeederComponent::class)) {
+ $appBuilder->withComponent(new DatabaseSeederComponent(Container::$globalInstance ?? throw new RuntimeException('Global instance of container not set')));
+ }
+
+ $appBuilder->getComponent(DatabaseSeederComponent::class)
+ ->withDatabaseSeeders($classNames);
+
+ return $this;
+ }
+}
diff --git a/src/Database/Components/DatabaseSeederComponent.php b/src/Database/Components/DatabaseSeederComponent.php
new file mode 100644
index 0000000..cc825a3
--- /dev/null
+++ b/src/Database/Components/DatabaseSeederComponent.php
@@ -0,0 +1,54 @@
+> The list of registered database seeders */
+ private array $databaseSeeders = [];
+
+ /**
+ * @param IContainer $container The DI container
+ */
+ public function __construct(private readonly IContainer $container)
+ {
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function build(): void
+ {
+ $globalDatabaseSeeder = new GlobalDatabaseSeeder(
+ \array_map(
+ fn (string $classString) => $this->container->resolve($classString),
+ $this->databaseSeeders
+ )
+ );
+ $this->container->bindInstance(GlobalDatabaseSeeder::class, $globalDatabaseSeeder);
+ }
+
+ /**
+ * Adds a database seeder or seeders to the application
+ *
+ * @param class-string|list> $classNames The name or names of database seeders to register
+ * @return static For chaining
+ */
+ public function withDatabaseSeeders(array|string $classNames): static
+ {
+ $classNames = \is_string($classNames) ? [$classNames] : $classNames;
+ $this->databaseSeeders = [...$this->databaseSeeders, ...$classNames];
+
+ return $this;
+ }
+}
diff --git a/src/Database/Console/DatabaseSeederCommandHandler.php b/src/Database/Console/DatabaseSeederCommandHandler.php
new file mode 100644
index 0000000..834e954
--- /dev/null
+++ b/src/Database/Console/DatabaseSeederCommandHandler.php
@@ -0,0 +1,35 @@
+writeln('Seeding database...');
+ $this->globalDatabaseSeeder->seed();
+ $output->writeln('Database seeded');
+ }
+}
diff --git a/src/Database/DatabaseModule.php b/src/Database/DatabaseModule.php
new file mode 100644
index 0000000..f595493
--- /dev/null
+++ b/src/Database/DatabaseModule.php
@@ -0,0 +1,23 @@
+withBinders($appBuilder, new DatabaseBinder());
+ }
+}
diff --git a/src/Database/GlobalDatabaseSeeder.php b/src/Database/GlobalDatabaseSeeder.php
new file mode 100644
index 0000000..159c6d2
--- /dev/null
+++ b/src/Database/GlobalDatabaseSeeder.php
@@ -0,0 +1,28 @@
+ $databaseSeeders The list of database seeders
+ */
+ public function __construct(private readonly array $databaseSeeders)
+ {
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function seed(): void
+ {
+ foreach ($this->databaseSeeders as $databaseSeeder) {
+ $databaseSeeder->seed();
+ }
+ }
+}
diff --git a/src/Database/IDatabaseSeeder.php b/src/Database/IDatabaseSeeder.php
new file mode 100644
index 0000000..93e8815
--- /dev/null
+++ b/src/Database/IDatabaseSeeder.php
@@ -0,0 +1,20 @@
+ The list of created users
- * @throws HttpException Thrown if the request body could not be read
- */
- #[Post('many')]
- public function createManyUsers(): array
- {
- // Demonstrate how to read the body as an array of models
- /** @var list $users */
- $users = $this->readRequestBodyAs(User::class . '[]');
-
- return $this->userService->createManyUsers($users);
- }
-
- /**
- * Creates a single user
- *
- * @param User $user The user to create
- * @return User The created user
- */
- #[Post('')]
- public function createUser(User $user): User
- {
- // Demonstrate how to use content negotiation on request and response bodies
- return $this->userService->createUser($user);
- }
-
- /**
- * Deletes all the users
- */
- #[Delete('')]
- public function deleteAllUsers(): void
- {
- $this->userService->deleteAllUsers();
- }
-
- /**
- * Gets all users
- *
- * @return IResponse The response containing all users
- * @throws HttpException Thrown if there was an error creating the response
- */
- #[Get(''), Authenticate('dummy')]
- public function getAllUsers(): IResponse
- {
- // Demonstrate how to use controller helper methods to create a response
- return $this->ok($this->userService->getAllUsers());
- }
-
- /**
- * Gets a random user
- *
- * @return IResponse The response containing the user
- * @throws HttpException Thrown if there was an error creating the response
- */
- #[Get('random')]
- public function getRandomUser(): IResponse
- {
- $user = $this->userService->getRandomUser();
-
- if ($user === null) {
- return $this->notFound();
- }
-
- // Demonstrate how to manually create a response
- $headers = new Headers();
- $headers->add('Content-Type', 'application/json');
- $body = new StringBody('{"id":' . $user->id . ',"email":"' . $user->email . '"}');
-
- return new Response(200, $headers, $body);
- }
-
- /**
- * Gets a user with the input ID
- *
- * @param int $id The ID of the user to get
- * @return User The user with the input ID
- * @throws UserNotFoundException Thrown if there was no user with the input ID
- */
- #[Get(':id(int)')]
- public function getUserById(int $id): User
- {
- // Demonstrate how to use route variables and response body negotiation
- return $this->userService->getUserById($id);
- }
-}
diff --git a/src/Demo/Auth/DummyAuthenticationHandler.php b/src/Demo/Auth/DummyAuthenticationHandler.php
deleted file mode 100644
index fdadb67..0000000
--- a/src/Demo/Auth/DummyAuthenticationHandler.php
+++ /dev/null
@@ -1,88 +0,0 @@
-
- */
-final class DummyAuthenticationHandler implements IAuthenticationSchemeHandler
-{
- /**
- * @param IUserService $users The user service to retrieve users from
- * @param RequestParser $requestParser The request parser to use
- */
- public function __construct(
- private readonly IUserService $users,
- private readonly RequestParser $requestParser = new RequestParser()
- ) {
- }
-
- /**
- * @inheritdoc
- * @throws RuntimeException Thrown if this was used in production
- */
- public function authenticate(IRequest $request, AuthenticationScheme $scheme): AuthenticationResult
- {
- if (\getenv('APP_ENV') === 'production') {
- throw new RuntimeException('Do not use this handler in production - it is for demo purposes only');
- }
-
- // Note: This is just a really silly demo - do not use this in production
- $queryString = $this->requestParser->parseQueryString($request);
-
- if (!$queryString->containsKey('letMeIn') || !$queryString->containsKey('userId')) {
- return AuthenticationResult::fail('Could not authenticate user', $scheme->name);
- }
-
- try {
- $currUser = $this->users->getUserById((int)$queryString->get('userId'));
- } catch (UserNotFoundException) {
- return AuthenticationResult::fail('No user with this ID found', $scheme->name);
- }
-
- $claimsIssuer = $scheme->options->claimsIssuer ?? $scheme->name;
- /** @var list> $claims */
- $claims = [
- new Claim(ClaimType::NameIdentifier, $currUser->id, $claimsIssuer),
- new Claim(ClaimType::Email, $currUser->email, $claimsIssuer)
- ];
-
- return AuthenticationResult::pass(new User(new Identity($claims)), $scheme->name);
- }
-
- /**
- * @inheritdoc
- */
- public function challenge(IRequest $request, IResponse $response, AuthenticationScheme $scheme): void
- {
- $response->setStatusCode(HttpStatusCode::Unauthorized);
- }
-
- /**
- * @inheritdoc
- */
- public function forbid(IRequest $request, IResponse $response, AuthenticationScheme $scheme): void
- {
- $response->setStatusCode(HttpStatusCode::Forbidden);
- }
-}
diff --git a/src/Demo/Binders/UserServiceBinder.php b/src/Demo/Binders/UserServiceBinder.php
deleted file mode 100644
index ce99795..0000000
--- a/src/Demo/Binders/UserServiceBinder.php
+++ /dev/null
@@ -1,26 +0,0 @@
-bindInstance(IUserService::class, $userService);
- }
-}
diff --git a/src/Demo/Console/Commands/UserCountCommandHandler.php b/src/Demo/Console/Commands/UserCountCommandHandler.php
deleted file mode 100644
index 2fe121e..0000000
--- a/src/Demo/Console/Commands/UserCountCommandHandler.php
+++ /dev/null
@@ -1,33 +0,0 @@
-writeln("Number of users: {$this->userService->getNumUsers()}");
- }
-}
diff --git a/src/Demo/FileUserService.php b/src/Demo/FileUserService.php
deleted file mode 100644
index cca7942..0000000
--- a/src/Demo/FileUserService.php
+++ /dev/null
@@ -1,123 +0,0 @@
-createUser($user);
- }
-
- return $createdUsers;
- }
-
- /**
- * @inheritdoc
- */
- public function createUser(User $user): User
- {
- $users = $this->readUsersFromFile();
- $users[] = $user;
- $encodedUsers = [];
-
- foreach ($users as $decodedUser) {
- $encodedUsers[] = ['id' => $decodedUser->id, 'email' => $decodedUser->email];
- }
-
- \file_put_contents($this->filePath, \json_encode($encodedUsers));
-
- return $user;
- }
-
- public function deleteAllUsers(): void
- {
- @\unlink($this->filePath);
- }
-
- /**
- * @inheritdoc
- */
- public function getAllUsers(): array
- {
- return $this->readUsersFromFile();
- }
-
- public function getNumUsers(): int
- {
- return \count($this->getAllUsers());
- }
-
- /**
- * @inheritdoc
- */
- public function getRandomUser(): ?User
- {
- $users = $this->getAllUsers();
-
- if (\count($users) === 0) {
- return null;
- }
-
- return $users[\random_int(0, \count($users) - 1)];
- }
-
- /**
- * @inheritdoc
- */
- public function getUserById(int $id): User
- {
- foreach ($this->getAllUsers() as $user) {
- if ($user->id === $id) {
- return $user;
- }
- }
-
- throw new UserNotFoundException("No user with ID $id found");
- }
-
- /**
- * Reads the users from the local file
- *
- * @return list The list of users
- */
- private function readUsersFromFile(): array
- {
- if (!\file_exists($this->filePath)) {
- return [];
- }
-
- /** @var array|false $encodedUsers */
- $encodedUsers = \json_decode(\file_get_contents($this->filePath), true);
-
- if (!\is_array($encodedUsers)) {
- return [];
- }
-
- $decodedUsers = [];
-
- foreach ($encodedUsers as $encodedUser) {
- $decodedUsers[] = new User($encodedUser['id'], $encodedUser['email']);
- }
-
- return $decodedUsers;
- }
-}
diff --git a/src/Demo/IUserService.php b/src/Demo/IUserService.php
deleted file mode 100644
index 36b38e5..0000000
--- a/src/Demo/IUserService.php
+++ /dev/null
@@ -1,62 +0,0 @@
- $users The users to create
- * @return list The users that were created
- */
- public function createManyUsers(array $users): array;
-
- /**
- * Creates a user
- *
- * @param User $user The user to create
- * @return User The created user
- */
- public function createUser(User $user): User;
-
- /**
- * Deletes all users
- */
- public function deleteAllUsers(): void;
-
- /**
- * Gets all the users
- *
- * @return list All the users
- */
- public function getAllUsers(): array;
-
- /**
- * Gets the number of users
- *
- * @return int The number of users
- */
- public function getNumUsers(): int;
-
- /**
- * Gets a random user
- *
- * @return User|null The random user, or null if there are no users
- */
- public function getRandomUser(): ?User;
-
- /**
- * Gets a user by ID
- *
- * @param int $id The ID to search for
- * @return User The user with the corresponding ID
- * @throws UserNotFoundException Thrown if no user with that ID was found
- */
- public function getUserById(int $id): User;
-}
diff --git a/src/Demo/User.php b/src/Demo/User.php
deleted file mode 100644
index 01c0ad2..0000000
--- a/src/Demo/User.php
+++ /dev/null
@@ -1,21 +0,0 @@
-response->getStatusCode()->value >= 500 ? LogLevel::ERROR : LogLevel::DEBUG;
})
->withModules($appBuilder, [
- new DemoModule()
+ new DatabaseModule(),
+ new UserModule(),
+ new AuthModule()
]);
}
@@ -117,10 +121,6 @@ private function getBinderDispatcher(): IBinderDispatcher
$cache = new FileBinderMetadataCollectionCache($cachePath);
$this->container->bindInstance(IBinderMetadataCollectionCache::class, $cache);
- if (\getenv('APP_ENV') === 'production') {
- return new LazyBinderDispatcher($cache);
- }
-
- return new LazyBinderDispatcher();
+ return new LazyBinderDispatcher(\getenv('APP_ENV') === 'production' ? $cache : null);
}
}
diff --git a/src/Users/Api/Controllers/UserController.php b/src/Users/Api/Controllers/UserController.php
new file mode 100644
index 0000000..402a3d3
--- /dev/null
+++ b/src/Users/Api/Controllers/UserController.php
@@ -0,0 +1,107 @@
+users->createUser($user);
+ }
+
+ /**
+ * Deletes a user
+ *
+ * @param int $id The ID of the user to delete
+ * @return IResponse The response
+ * @throws HttpException Thrown if the content could not be negotiated
+ * @throws PolicyNotFoundException|RequirementHandlerNotFoundException Thrown if there was an error authorizing this request
+ */
+ #[Delete('/:id'), Authenticate()]
+ public function deleteUser(int $id): IResponse
+ {
+ try {
+ $userToDelete = $this->users->getUserById($id);
+ } catch (UserNotFoundException $ex) {
+ return $this->notFound();
+ }
+
+ /** @psalm-suppress PossiblyNullArgument The user will be set */
+ if (!$this->authority->authorize($this->getUser(), 'authorized-user-deleter', $userToDelete)->passed) {
+ return $this->forbidden();
+ }
+
+ $this->users->deleteUser($id);
+
+ return $this->noContent();
+ }
+
+ /**
+ * Gets a page of users
+ *
+ * @param int $pageNumber The page number to retrieve
+ * @param int $pageSize The page size
+ * @return IResponse The response containing all users
+ * @throws HttpException Thrown if there was an error creating the response
+ * @throws InvalidPageException Thrown if the pagination parameters were invalid
+ */
+ #[Get(''), AuthorizeRoles('admin')]
+ public function getPagedUsers(int $pageNumber = 1, int $pageSize = 100): IResponse
+ {
+ return $this->ok($this->users->getPagedUsers($pageNumber, $pageSize));
+ }
+
+ /**
+ * Gets a user with the input ID
+ *
+ * @param int $id The ID of the user to get
+ * @return User The user with the input ID
+ * @throws UserNotFoundException Thrown if there was no user with the input ID
+ */
+ #[Get(':id'), Authenticate()]
+ public function getUserById(int $id): User
+ {
+ return $this->users->getUserById($id);
+ }
+}
diff --git a/src/Users/Binders/UserServiceBinder.php b/src/Users/Binders/UserServiceBinder.php
new file mode 100644
index 0000000..a58c07e
--- /dev/null
+++ b/src/Users/Binders/UserServiceBinder.php
@@ -0,0 +1,35 @@
+bindClass([IUserService::class, SqlUserService::class], SqlUserService::class, resolveAsSingleton: true);
+ $container->bindInstance(
+ SqlUserSeeder::class,
+ new SqlUserSeeder(
+ $container->resolve(SqlUserService::class),
+ $container->resolve(PDO::class),
+ (string)\getenv('USER_DEFAULT_EMAIL'),
+ (string)\getenv('USER_DEFAULT_PASSWORD')
+ )
+ );
+ }
+}
diff --git a/src/Users/Console/DefaultCredentialGeneratorCommandHandler.php b/src/Users/Console/DefaultCredentialGeneratorCommandHandler.php
new file mode 100644
index 0000000..7aacf08
--- /dev/null
+++ b/src/Users/Console/DefaultCredentialGeneratorCommandHandler.php
@@ -0,0 +1,66 @@
+ask(new Confirmation('Would you like to update the default user credentials in your .env file? [Y/N] '), $output)) {
+ $output->writeln('For security, remember to update these credentials before deploying to production');
+
+ return;
+ }
+
+ $output->writeln('Generating default user credentials...');
+ $defaultUserEmail = '';
+
+ while (\strlen(\trim($defaultUserEmail)) === 0) {
+ $output->write('Enter the email address of the default admin user: ');
+ $defaultUserEmail = $output->readLine();
+ }
+
+ $defaultUserPassword = \bin2hex(\random_bytes(self::DEFAULT_USER_PASSWORD_LENGTH));
+ $dotEnvFilePath = __DIR__ . '/../../../../.env';
+
+ if (!\file_exists($dotEnvFilePath)) {
+ throw new RuntimeException("No .env file found at $dotEnvFilePath");
+ }
+
+ $dotEnvContents = \file_get_contents($dotEnvFilePath);
+ $dotEnvContents = \preg_replace('/^USER_DEFAULT_EMAIL=.*$/m', "USER_DEFAULT_EMAIL=$defaultUserEmail", $dotEnvContents);
+ $dotEnvContents = \preg_replace('/^USER_DEFAULT_PASSWORD=.*$/m', "USER_DEFAULT_PASSWORD=$defaultUserPassword", $dotEnvContents);
+ \file_put_contents($dotEnvFilePath, $dotEnvContents);
+
+ $output->writeln('.env file updated');
+
+ if (\array_key_exists('show-password', $input->options)) {
+ $output->writeln("Password: $defaultUserPassword");
+ }
+ }
+}
diff --git a/src/Users/IUserService.php b/src/Users/IUserService.php
new file mode 100644
index 0000000..3b6407e
--- /dev/null
+++ b/src/Users/IUserService.php
@@ -0,0 +1,63 @@
+ The page of users
+ * @throws InvalidPageException Thrown if the page number or size was invalid
+ */
+ public function getPagedUsers(int $pageNumber = 1, int $pageSize = 100): array;
+
+ /**
+ * Gets a user with the input email address
+ *
+ * @param string $email The email address to look up
+ * @return User|null The user if one was found, otherwise null
+ */
+ public function getUserByEmail(string $email): ?User;
+
+ /**
+ * Gets a user with the input credentials
+ *
+ * @param string $email The email address to look up
+ * @param string $password The user password
+ * @return User|null The user if one was found, otherwise null
+ */
+ public function getUserByEmailAndPassword(string $email, string $password): ?User;
+
+ /**
+ * Gets a user by ID
+ *
+ * @param int $id The ID to search for
+ * @return User The user with the corresponding ID
+ * @throws UserNotFoundException Thrown if no user with that ID was found
+ */
+ public function getUserById(int $id): User;
+}
diff --git a/src/Users/InvalidPageException.php b/src/Users/InvalidPageException.php
new file mode 100644
index 0000000..af61b7f
--- /dev/null
+++ b/src/Users/InvalidPageException.php
@@ -0,0 +1,14 @@
+ $roles The user's roles
+ */
+ public function __construct(
+ #[Email] public string $email,
+ public string $password,
+ public array $roles = []
+ ) {
+ }
+}
diff --git a/src/Users/SqlUserSeeder.php b/src/Users/SqlUserSeeder.php
new file mode 100644
index 0000000..4b5432e
--- /dev/null
+++ b/src/Users/SqlUserSeeder.php
@@ -0,0 +1,77 @@
+createTables();
+
+ // Only create the user if they do not exist already
+ if ($this->users->getUserByEmail($this->defaultUserEmail) === null) {
+ $this->users->createUser(
+ new NewUser($this->defaultUserEmail, $this->defaultUserPassword, ['admin']),
+ true
+ );
+ }
+ }
+
+ /**
+ * Creates the database tables needed to store user data
+ *
+ * @throws PDOException Thrown if there was an error creating the tables
+ */
+ private function createTables(): void
+ {
+ // Create the user table and index
+ $this->pdo->exec(
+ <<pdo->exec(
+ <<pdo->exec(
+ <<pdo->exec(
+ <<email, $newUser->password);
+ }
+
+ $normalizedEmail = self::normalizeEmail($newUser->email);
+
+ $this->pdo->beginTransaction();
+ // Insert the user
+ $createUserStatement = $this->pdo->prepare(
+ <<execute([
+ 'email' => $normalizedEmail,
+ 'hashedPassword' => self::hashPassword($newUser->password)
+ ]);
+ $createdUser = new User(
+ (int)$this->pdo->lastInsertId(),
+ $normalizedEmail,
+ $newUser->roles
+ );
+
+ // Insert the roles
+ foreach ($newUser->roles as $role) {
+ $createRoleStatement = $this->pdo->prepare(
+ <<execute([
+ 'userId' => $createdUser->id,
+ 'role' => $role
+ ]);
+ }
+
+ $this->pdo->commit();
+
+ return $createdUser;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function deleteUser(int $id): void
+ {
+ $this->pdo->beginTransaction();
+
+ // Delete the user roles
+ $deleteRolesStatement = $this->pdo->prepare(
+ <<execute(['userId' => $id]);
+
+ // Delete the user
+ $deleteUserStatement = $this->pdo->prepare(
+ <<execute(['userId' => $id]);
+
+ $this->pdo->commit();
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getPagedUsers(int $pageNumber = 1, int $pageSize = 100): array
+ {
+ if ($pageNumber < 0) {
+ throw new InvalidPageException('Page number must begin at 0');
+ }
+
+ if ($pageSize < 1) {
+ throw new InvalidPageException('Page size must be greater than 0');
+ }
+
+ if ($pageSize > self::MAX_PAGE_SIZE) {
+ throw new InvalidPageException('Page size cannot exceed ' . self::MAX_PAGE_SIZE);
+ }
+
+ $statement = $this->pdo->prepare(
+ <<execute(['start' => ($pageNumber - 1) * $pageSize, 'limit' => $pageSize]);
+ $users = [];
+
+ /** @var array{id: int, email: string, roles: string} $row */
+ foreach ($statement->fetchAll(PDO::FETCH_ASSOC) as $row) {
+ $users[] = self::createUserFromRow($row);
+ }
+
+ return $users;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getUserByEmail(string $email): ?User
+ {
+ $rows = $this->queryUserByEmail($email);
+
+ if (\count($rows) !== 1) {
+ return null;
+ }
+
+ return self::createUserFromRow($rows[0]);
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getUserByEmailAndPassword(string $email, string $password): ?User
+ {
+ $rows = $this->queryUserByEmail($email);
+
+ if (\count($rows) !== 1 || !\password_verify($password, $rows[0]['hashed_password'])) {
+ return null;
+ }
+
+ return self::createUserFromRow($rows[0]);
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getUserById(int $id): User
+ {
+ $statement = $this->pdo->prepare(
+ <<execute(['id' => $id]);
+ $row = $statement->fetch(PDO::FETCH_ASSOC);
+
+ if (empty($row)) {
+ throw new UserNotFoundException("No user found with ID $id");
+ }
+
+ /** @var array{id: int, email: string, roles: string} $row */
+ return self::createUserFromRow($row);
+ }
+
+ /**
+ * Creates a user from an entity
+ *
+ * @param array{id: int, email: string, roles: ?string, hashed_password?: string} $userRow The row of user data to create the user from
+ * @return User The created user
+ */
+ private static function createUserFromRow(array $userRow): User
+ {
+ if (isset($userRow['roles'])) {
+ $roles = \explode(',', $userRow['roles'] ?? '');
+ } else {
+ $roles = [];
+ }
+
+ return new User(
+ (int)$userRow['id'],
+ (string)$userRow['email'],
+ $roles
+ );
+ }
+
+ /**
+ * Hashes a password for storage
+ *
+ * @param string $password The password to hash
+ * @return string The hashed password
+ */
+ private static function hashPassword(string $password): string
+ {
+ return \password_hash($password, PASSWORD_ARGON2ID);
+ }
+
+ /**
+ * Normalizes the email address so that casing and string padding do not affect lookups
+ *
+ * @param string $email The email address to normalize
+ * @return string The normalized email address
+ */
+ private static function normalizeEmail(string $email): string
+ {
+ return \strtolower(\trim($email));
+ }
+
+ /**
+ * Queries for a user by email address
+ *
+ * @param string $email The email address to query for
+ * @return list The row of data for the user
+ */
+ private function queryUserByEmail(string $email): array
+ {
+ $statement = $this->pdo->prepare(
+ <<execute(['email' => self::normalizeEmail($email)]);
+ /** @var list $rows */
+ $rows = $statement->fetchAll(PDO::FETCH_ASSOC);
+
+ return $rows;
+ }
+}
diff --git a/src/Users/User.php b/src/Users/User.php
new file mode 100644
index 0000000..5143486
--- /dev/null
+++ b/src/Users/User.php
@@ -0,0 +1,25 @@
+ $roles The user's roles
+ */
+ public function __construct(
+ public int $id,
+ #[Email] public string $email,
+ public array $roles
+ ) {
+ }
+}
diff --git a/src/Demo/DemoModule.php b/src/Users/UserModule.php
similarity index 66%
rename from src/Demo/DemoModule.php
rename to src/Users/UserModule.php
index 88f35bf..6481eed 100644
--- a/src/Demo/DemoModule.php
+++ b/src/Users/UserModule.php
@@ -2,36 +2,39 @@
declare(strict_types=1);
-namespace App\Demo;
+namespace App\Users;
use Aphiria\Application\IApplicationBuilder;
-use Aphiria\Authentication\AuthenticationScheme;
use Aphiria\Framework\Application\AphiriaModule;
use Aphiria\Net\Http\HttpStatusCode;
-use App\Demo\Auth\DummyAuthenticationHandler;
-use App\Demo\Binders\UserServiceBinder;
+use App\Database\Components\DatabaseComponents;
+use App\Users\Binders\UserServiceBinder;
use Psr\Log\LogLevel;
/**
- * Defines the demo module
+ * Defines the user
*/
-final class DemoModule extends AphiriaModule
+final class UserModule extends AphiriaModule
{
+ use DatabaseComponents;
+
/**
* @inheritdoc
*/
public function configure(IApplicationBuilder $appBuilder): void
{
$this->withBinders($appBuilder, new UserServiceBinder())
- ->withAuthenticationScheme(
- $appBuilder,
- new AuthenticationScheme('dummy', DummyAuthenticationHandler::class)
- )
+ ->withDatabaseSeeders($appBuilder, SqlUserSeeder::class)
->withProblemDetails(
$appBuilder,
UserNotFoundException::class,
status: HttpStatusCode::NotFound
)
+ ->withProblemDetails(
+ $appBuilder,
+ InvalidPageException::class,
+ status: HttpStatusCode::BadRequest
+ )
->withLogLevelFactory(
$appBuilder,
UserNotFoundException::class,
diff --git a/src/Demo/UserNotFoundException.php b/src/Users/UserNotFoundException.php
similarity index 90%
rename from src/Demo/UserNotFoundException.php
rename to src/Users/UserNotFoundException.php
index 2b3b2aa..974516d 100644
--- a/src/Demo/UserNotFoundException.php
+++ b/src/Users/UserNotFoundException.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace App\Demo;
+namespace App\Users;
use Exception;
diff --git a/tests/Integration/Auth/AuthTest.php b/tests/Integration/Auth/AuthTest.php
new file mode 100644
index 0000000..8e6fe80
--- /dev/null
+++ b/tests/Integration/Auth/AuthTest.php
@@ -0,0 +1,60 @@
+seedDatabase();
+ }
+
+ public function testLoggingInAndOutUnsetsTokenCookie(): void
+ {
+ $user = $this->createUser(password: 'foo');
+ $loginResponse = $this->post(
+ '/auth/login',
+ ['Authorization' => 'Basic ' . \base64_encode("$user->email:foo")]
+ );
+ $this->assertHasCookie($loginResponse, 'authToken');
+ $logoutResponse = $this->post(
+ '/auth/logout',
+ ['Cookie' => (string)$this->responseParser->parseCookies($loginResponse)->get('authToken')->value]
+ );
+ $this->assertStatusCodeEquals(HttpStatusCode::Ok, $logoutResponse);
+ $this->assertCookieIsUnset($logoutResponse, 'authToken');
+ }
+
+ public function testLoggingInWithInvalidCredentialsReturnsUnauthorizedResponse(): void
+ {
+ $user = $this->createUser(password: 'foo');
+ $response = $this->post('/auth/login', ['Authorization' => 'Basic ' . \base64_encode("$user->email:bar")]);
+ $this->assertStatusCodeEquals(HttpStatusCode::Unauthorized, $response);
+ }
+
+ public function testLoggingInWithValidCredentialsSetsTokenCookie(): void
+ {
+ $user = $this->createUser(password: 'foo');
+ $response = $this->post('/auth/login', ['Authorization' => 'Basic ' . \base64_encode("$user->email:foo")]);
+ $this->assertStatusCodeEquals(HttpStatusCode::Ok, $response);
+ $this->assertHasCookie($response, 'authToken');
+ }
+
+ public function testLoggingOutWithoutTokenCookieSetStillUnsetsTokenCookie(): void
+ {
+ $response = $this->post('/auth/logout');
+ $this->assertStatusCodeEquals(HttpStatusCode::Ok, $response);
+ $this->assertCookieIsUnset($response, 'authToken');
+ }
+}
diff --git a/tests/Integration/CreatesUser.php b/tests/Integration/CreatesUser.php
new file mode 100644
index 0000000..3ea8a66
--- /dev/null
+++ b/tests/Integration/CreatesUser.php
@@ -0,0 +1,36 @@
+readResponseBodyAs(User::class, $this->post('/users', body: $newUser));
+
+ return $createdUser;
+ }
+
+}
diff --git a/tests/Integration/Demo/UserTest.php b/tests/Integration/Demo/UserTest.php
deleted file mode 100644
index 8fc64cc..0000000
--- a/tests/Integration/Demo/UserTest.php
+++ /dev/null
@@ -1,40 +0,0 @@
-delete('/demo/users');
- }
-
- public function testGettingAllUsers(): void
- {
- // Seed some users
- $this->post('/demo/users', [], new User(123, 'foo@bar.com'));
- $this->post('/demo/users', [], new User(456, 'baz@qux.com'));
- // Get the users
- $response = $this->get('/demo/users?letMeIn=1&userId=123');
- $this->assertStatusCodeEquals(200, $response);
- $this->assertParsedBodyEquals(
- [new User(123, 'foo@bar.com'), new User(456, 'baz@qux.com')],
- $response
- );
- }
-
- public function testGettingInvalidUserReturns404(): void
- {
- $response = $this->get('/demo/users/-1');
- $this->assertStatusCodeEquals(HttpStatusCode::NotFound, $response);
- }
-}
diff --git a/tests/Integration/SeedsDatabase.php b/tests/Integration/SeedsDatabase.php
new file mode 100644
index 0000000..9973582
--- /dev/null
+++ b/tests/Integration/SeedsDatabase.php
@@ -0,0 +1,31 @@
+resolve(GlobalDatabaseSeeder::class)->seed();
+ }
+}
diff --git a/tests/Integration/Users/UserTest.php b/tests/Integration/Users/UserTest.php
new file mode 100644
index 0000000..316a044
--- /dev/null
+++ b/tests/Integration/Users/UserTest.php
@@ -0,0 +1,132 @@
+seedDatabase();
+ }
+
+ /**
+ * Provides invalid page size and number parameters
+ *
+ * @return list The invalid page size and numbers
+ */
+ public static function provideInvalidPageSizes(): array
+ {
+ return [
+ [0, 1],
+ [101, 1],
+ [1, -1]
+ ];
+ }
+
+ public function testCreatingUsersMakesThemRetrievableAsAdminUser(): void
+ {
+ $createdUser = $this->createUser();
+ $adminUser = (new PrincipalBuilder('example.com'))->withRoles('admin')
+ ->build();
+ $response = $this->actingAs($adminUser, fn () => $this->get("/users/$createdUser->id"));
+ $this->assertStatusCodeEquals(HttpStatusCode::Ok, $response);
+ $this->assertParsedBodyEquals($createdUser, $response);
+ }
+
+ public function testDeletingAnotherUserAsAdminReturns204(): void
+ {
+ $createdUserId = $this->createUser()->id;
+ $adminUser = (new PrincipalBuilder('example.com'))->withRoles('admin')
+ ->build();
+ $response = $this->actingAs($adminUser, fn () => $this->delete("/users/$createdUserId"));
+ $this->assertStatusCodeEquals(HttpStatusCode::NoContent, $response);
+ }
+
+ public function testDeletingAnotherUserAsNonAdminReturns403(): void
+ {
+ $createdUserId = $this->createUser()->id;
+ $nonAdminUser = (new PrincipalBuilder('example.com'))->withNameIdentifier(1)
+ ->build();
+ $response = $this->actingAs($nonAdminUser, fn () => $this->delete("/users/$createdUserId"));
+ $this->assertStatusCodeEquals(HttpStatusCode::Forbidden, $response);
+ }
+
+ public function testDeletingNonExistentUserReturns404(): void
+ {
+ $adminUser = (new PrincipalBuilder('example.com'))->withRoles('admin')
+ ->build();
+ $response = $this->actingAs($adminUser, fn () => $this->get('/users/0'));
+ $this->assertStatusCodeEquals(HttpStatusCode::NotFound, $response);
+ }
+
+ public function testDeletingYourOwnUserReturns204(): void
+ {
+ $createdUserId = $this->createUser()->id;
+ $createdUser = (new PrincipalBuilder('example.com'))->withNameIdentifier($createdUserId)
+ ->build();
+ $response = $this->actingAs($createdUser, fn () => $this->delete("/users/$createdUserId"));
+ $this->assertStatusCodeEquals(HttpStatusCode::NoContent, $response);
+ }
+
+ public function testGettingInvalidUserReturns404(): void
+ {
+ $user = new Principal(new Identity());
+ $response = $this->actingAs($user, fn () => $this->get('/users/0'));
+ $this->assertStatusCodeEquals(HttpStatusCode::NotFound, $response);
+ }
+
+ public function testGettingPagedUsersRedirectsToForbiddenPageForNonAdmins(): void
+ {
+ $nonAdminUser = new Principal(new Identity());
+ $response = $this->actingAs($nonAdminUser, fn () => $this->get('/users'));
+ $this->assertStatusCodeEquals(HttpStatusCode::Found, $response);
+ $this->assertHeaderEquals('/access-denied', $response, 'Location');
+ }
+
+ public function testGettingPagedUsersReturnsSuccessfullyForAdmins(): void
+ {
+ $this->createUser();
+ $adminUser = (new PrincipalBuilder('example.com'))->withRoles('admin')
+ ->build();
+ $response = $this->actingAs($adminUser, fn () => $this->get('/users'));
+ $this->assertStatusCodeEquals(HttpStatusCode::Ok, $response);
+ // Integration tests may have created many users, so just check that the endpoint returns a non-empty list
+ $this->assertParsedBodyPassesCallback(
+ $response,
+ User::class . '[]',
+ fn (array $users): bool => \count($users) > 0
+ );
+ }
+
+ /**
+ * @param int $pageSize The page size to test
+ * @param int $pageNumber The page number to test
+ */
+ #[DataProvider('provideInvalidPageSizes')]
+ public function testGettingPagedUsersWithInvalidPageSizesReturnsBadRequests(int $pageSize, int $pageNumber): void
+ {
+ $adminUser = (new PrincipalBuilder('example.com'))->withRoles('admin')
+ ->build();
+ $response = $this->actingAs(
+ $adminUser,
+ fn () => $this->get("/users?pageSize=$pageSize&pageNumber=$pageNumber")
+ );
+ $this->assertStatusCodeEquals(HttpStatusCode::BadRequest, $response);
+ }
+}