add: webseite mit jwt
This commit is contained in:
10
webseite-react-php-jwt/slim-php/.env.example
Normal file
10
webseite-react-php-jwt/slim-php/.env.example
Normal file
@@ -0,0 +1,10 @@
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=8889
|
||||
DB_NAME=nerdshop
|
||||
DB_USER=root
|
||||
DB_PASS=root
|
||||
CORS_ORIGIN=http://localhost:5173
|
||||
# Auf Netcup z. B. /api/public — leer lassen für automatische Erkennung
|
||||
SLIM_BASE_PATH=
|
||||
JWT_SECRET=change-me-to-a-long-random-secret
|
||||
JWT_TTL=86400
|
||||
2
webseite-react-php-jwt/slim-php/.gitignore
vendored
Normal file
2
webseite-react-php-jwt/slim-php/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/vendor/
|
||||
.env
|
||||
13
webseite-react-php-jwt/slim-php/.htaccess
Normal file
13
webseite-react-php-jwt/slim-php/.htaccess
Normal file
@@ -0,0 +1,13 @@
|
||||
# Projekt-Root: Anfragen nach public/ durchreichen (auch in Unterverzeichnissen wie /api).
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
DirectoryIndex index.php
|
||||
|
||||
RewriteRule ^(\.env|composer\.(json|lock)|src/|vendor/|sql/) - [F,L,NC]
|
||||
|
||||
RewriteRule ^public(?:/|$) - [L]
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^(.*)$ public/$1 [L]
|
||||
</IfModule>
|
||||
8
webseite-react-php-jwt/slim-php/.vscode/settings.json
vendored
Normal file
8
webseite-react-php-jwt/slim-php/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"workbench.colorCustomizations": {
|
||||
"titleBar.activeForeground": "#333",
|
||||
"titleBar.activeBackground": "#cdc586",
|
||||
"titleBar.inactiveForeground": "#ddd",
|
||||
"titleBar.inactiveBackground": "#b5b08c"
|
||||
}
|
||||
}
|
||||
47
webseite-react-php-jwt/slim-php/README.md
Normal file
47
webseite-react-php-jwt/slim-php/README.md
Normal file
@@ -0,0 +1,47 @@
|
||||
Die MySQL-Datenbank und die Slim-REST-API sind eingerichtet und getestet.
|
||||
|
||||
**Datenbank `nerdshop`** (MAMP, Port 8889) enthält die Tabelle `products` mit den 13 Einträgen aus der JSON-Datei. Die Mongo-IDs bleiben als `id` erhalten; die API liefert sie als `_id`, damit die React-App kompatibel bleibt.
|
||||
|
||||
**API läuft auf** [http://localhost:8080](http://localhost:8080)
|
||||
|
||||
Authentifizierung erfolgt per **JWT** (`firebase/php-jwt`, HS256). Login liefert ein Bearer-Token; geschützte Routen erwarten `Authorization: Bearer <token>`.
|
||||
|
||||
| Methode | URL | Auth | Status |
|
||||
| -------- | ---------------- | ---- | ------------- |
|
||||
| `POST` | `/login` | nein | JWT + User |
|
||||
| `POST` | `/logout` | ja | Session Ende |
|
||||
| `GET` | `/me` | ja | aktueller User|
|
||||
| `GET` | `/users` | ja | alle User |
|
||||
| `GET` | `/users/{id}` | ja | ein User |
|
||||
| `GET` | `/products` | nein | alle Produkte |
|
||||
| `GET` | `/products/{id}` | nein | ein Produkt |
|
||||
| `POST` | `/products` | ja | anlegen |
|
||||
| `PUT` | `/products/{id}` | ja | aktualisieren |
|
||||
| `PATCH` | `/products/{id}` | ja | teilweise |
|
||||
| `DELETE` | `/products/{id}` | ja | löschen |
|
||||
|
||||
Login-Beispiel:
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost:8080/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"username":"megaman","password":"<passwort>"}'
|
||||
```
|
||||
|
||||
Antwort: `{ "user": { ... }, "token": "<jwt>" }`. Anschließend:
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8080/users \
|
||||
-H "Authorization: Bearer <jwt>"
|
||||
```
|
||||
|
||||
`JWT_SECRET` und `JWT_TTL` (Sekunden, Standard 86400) stehen in `.env`. CORS ist für `http://localhost:5173` (Vite) gesetzt, inklusive Header `Authorization`.
|
||||
|
||||
Server neu starten:
|
||||
|
||||
```bash
|
||||
cd slim-php-framework
|
||||
php -S localhost:8080 -t public public/router.php
|
||||
```
|
||||
|
||||
Falls MAMP andere Zugangsdaten nutzt, stehen sie in `slim-php-framework/.env`.
|
||||
23
webseite-react-php-jwt/slim-php/composer.json
Normal file
23
webseite-react-php-jwt/slim-php/composer.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "nerdshop/slim-api",
|
||||
"version": "1.0.0",
|
||||
"description": "Nerdshop REST API",
|
||||
"require": {
|
||||
"php": "^8.1",
|
||||
"ext-pdo": "*",
|
||||
"ext-pdo_mysql": "*",
|
||||
"firebase/php-jwt": "^7.1",
|
||||
"php-di/php-di": "^7.0",
|
||||
"slim/psr7": "^1.7",
|
||||
"slim/slim": "^4.14",
|
||||
"vlucas/phpdotenv": "^5.6"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "src/"
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"sort-packages": true
|
||||
}
|
||||
}
|
||||
1420
webseite-react-php-jwt/slim-php/composer.lock
generated
Normal file
1420
webseite-react-php-jwt/slim-php/composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
5
webseite-react-php-jwt/slim-php/index.php
Normal file
5
webseite-react-php-jwt/slim-php/index.php
Normal file
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/public/index.php';
|
||||
12
webseite-react-php-jwt/slim-php/public/.htaccess
Normal file
12
webseite-react-php-jwt/slim-php/public/.htaccess
Normal file
@@ -0,0 +1,12 @@
|
||||
# MAMP/Apache FastCGI verwirft Authorization sonst, bevor PHP es sieht.
|
||||
CGIPassAuth On
|
||||
SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1
|
||||
|
||||
DirectoryIndex index.php
|
||||
RewriteEngine On
|
||||
RewriteCond %{HTTP:Authorization} .
|
||||
RewriteRule ^ - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^ index.php [QSA,L]
|
||||
75
webseite-react-php-jwt/slim-php/public/index.php
Normal file
75
webseite-react-php-jwt/slim-php/public/index.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Auth\JwtService;
|
||||
use App\Config;
|
||||
use App\Middleware\AuthMiddleware;
|
||||
use App\Middleware\CorsMiddleware;
|
||||
use App\Repository\ProductRepository;
|
||||
use App\Repository\UserRepository;
|
||||
use DI\Container;
|
||||
use Slim\Factory\AppFactory;
|
||||
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
$dotenv = Dotenv\Dotenv::createImmutable(dirname(__DIR__));
|
||||
$dotenv->safeLoad();
|
||||
|
||||
$container = new Container();
|
||||
$container->set(PDO::class, static fn () => Config::pdo());
|
||||
$container->set(
|
||||
ProductRepository::class,
|
||||
static fn ($c) => new ProductRepository($c->get(PDO::class))
|
||||
);
|
||||
$container->set(
|
||||
UserRepository::class,
|
||||
static fn ($c) => new UserRepository($c->get(PDO::class))
|
||||
);
|
||||
$container->set(
|
||||
JwtService::class,
|
||||
static fn () => new JwtService(
|
||||
$_ENV['JWT_SECRET'] ?? '',
|
||||
(int) ($_ENV['JWT_TTL'] ?? 86400)
|
||||
)
|
||||
);
|
||||
$container->set(
|
||||
AuthMiddleware::class,
|
||||
static fn ($c) => new AuthMiddleware(
|
||||
$c->get(JwtService::class),
|
||||
$c->get(UserRepository::class)
|
||||
)
|
||||
);
|
||||
|
||||
AppFactory::setContainer($container);
|
||||
$app = AppFactory::create();
|
||||
|
||||
$basePath = Config::slimBasePath();
|
||||
if ($basePath !== '') {
|
||||
$app->setBasePath($basePath);
|
||||
}
|
||||
|
||||
$app->addBodyParsingMiddleware();
|
||||
$app->addRoutingMiddleware();
|
||||
$app->addErrorMiddleware(true, true, true);
|
||||
$app->add(new CorsMiddleware());
|
||||
|
||||
(require __DIR__ . '/../src/Routes/productRoutes.php')($app);
|
||||
(require __DIR__ . '/../src/Routes/userRoutes.php')($app);
|
||||
|
||||
if (empty($_SERVER['HTTP_AUTHORIZATION'])) {
|
||||
$redirected = $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? '';
|
||||
|
||||
if ($redirected !== '') {
|
||||
$_SERVER['HTTP_AUTHORIZATION'] = $redirected;
|
||||
} elseif (function_exists('getallheaders')) {
|
||||
foreach (getallheaders() as $name => $value) {
|
||||
if (strcasecmp((string) $name, 'Authorization') === 0 && is_string($value) && $value !== '') {
|
||||
$_SERVER['HTTP_AUTHORIZATION'] = $value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$app->run();
|
||||
11
webseite-react-php-jwt/slim-php/public/router.php
Normal file
11
webseite-react-php-jwt/slim-php/public/router.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$uri = urldecode(parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/');
|
||||
|
||||
if ($uri !== '/' && file_exists(__DIR__ . $uri)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
require __DIR__ . '/index.php';
|
||||
57
webseite-react-php-jwt/slim-php/sql/nerdshop.sql
Normal file
57
webseite-react-php-jwt/slim-php/sql/nerdshop.sql
Normal file
@@ -0,0 +1,57 @@
|
||||
CREATE DATABASE IF NOT EXISTS nerdshop
|
||||
CHARACTER SET utf8mb4
|
||||
COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE nerdshop;
|
||||
|
||||
DROP TABLE IF EXISTS products;
|
||||
|
||||
CREATE TABLE products (
|
||||
id CHAR(24) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
sku VARCHAR(32) NOT NULL,
|
||||
stock INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
price DECIMAL(10,2) NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
tagline VARCHAR(255) NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_products_sku (sku)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
INSERT INTO products (id, title, sku, stock, price, description, tagline) VALUES
|
||||
('675bee96bb39023b77e6cd92', 'Smart Coffee Mug with LCD Level Indicator', 'MUG0007', 25, 49.99, 'Know exactly how much coffee you have left with this LCD-equipped mug.', 'Never face an empty mug again.'),
|
||||
('675bee96bb39023b77e6cd93', 'Bluetooth Coffee Mug with Fortune Telling Scanner', 'MUG0013', 15, 99.99, 'Get your daily fortune with every sip using this Bluetooth-connected mug.', 'Coffee reading, made smart.'),
|
||||
('675bee96bb39023b77e6cd94', 'South Pole Tested Pre-Warmed Ink Pen', 'OFF3145', 34, 29.95, 'Write smoothly even in the coldest conditions with this pre-warmed ink pen.', 'The pen that conquers the cold.'),
|
||||
('675bee96bb39023b77e6cd95', 'Ambidextrous Computer Mouse', 'COM1001', 50, 19.90, 'Finally, a mouse designed for both left and right-handed users.', 'End the left-right struggle.'),
|
||||
('675bee96bb39023b77e6cd96', 'Easter Egg Themed Webcam', 'COM0404', 8, 14.99, 'Add some festive flair to your video calls with this Easter egg webcam.', 'Happy Easter, every day.'),
|
||||
('675bee96bb39023b77e6cd97', 'Vulcan Language vi Cheatsheet', 'COM0001', 6, 9.90, 'Master the Vulcan language and vi editor simultaneously with this handy cheatsheet.', 'Learn vi and Vulcan, live long and prosper.'),
|
||||
('675bee96bb39023b77e6cd98', 'Klingon Language emacs Cheatsheet', 'COM1536', 33, 9.90, 'Conquer the Klingon language and emacs with this comprehensive cheatsheet.', 'Learn emacs and Klingon, qapla''!'),
|
||||
('675bee96bb39023b77e6cda0', 'Self-Folding Laundry Basket', 'HME0023', 15, 79.99, 'Say goodbye to laundry clutter with this self-folding basket.', 'Laundry day just got easier.'),
|
||||
('675bee96bb39023b77e6cda1', 'Noise-Cancelling Headphones for Cats', 'PET0112', 39, 39.95, 'Give your cat the gift of silence with these noise-canceling headphones.', 'Purrfect tranquility for your feline friend.'),
|
||||
('675bee96bb39023b77e6cda2', 'Glow-in-the-Dark Toilet Paper', 'HME0221', 65, 12.50, 'Navigate your midnight bathroom trips with ease using this glow-in-the-dark toilet paper.', 'A guiding light in the darkness.'),
|
||||
('675bee96bb39023b77e6cda3', 'Automatic Plant Waterer with Compliment Dispenser', 'GRD0334', 17, 69.99, 'Keep your plants happy and hydrated with automatic watering and daily compliments.', 'Nurture your plants, boost their self-esteem.'),
|
||||
('675bee96bb39023b77e6cda4', 'Self-Stirring Cereal Bowl', 'KIT0445', 55, 24.95, 'Enjoy perfectly crunchy cereal every time with this self-stirring bowl.', 'No more soggy surprises.'),
|
||||
('675bee96bb39023b77e6cda5', 'Polygon Planet Lamp', 'LMP0556', 4, 39.90, 'Transform your space with the enchanting glow of a planetarium-inspired lamp.', 'Bring the cosmos into your home.');
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
username VARCHAR(64) NOT NULL,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
token VARCHAR(64) NULL DEFAULT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_users_username (username),
|
||||
UNIQUE KEY uq_users_email (email),
|
||||
KEY idx_users_token (token)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
INSERT INTO users (username, email, password_hash)
|
||||
VALUES (
|
||||
'megaman',
|
||||
'order@gkontra.de',
|
||||
'$2y$12$CdOg.e08jP968kQoA298Ne92r9pyEYBfSya4gFWQCh.X8gJhmDaVW'
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
email = VALUES(email),
|
||||
password_hash = VALUES(password_hash);
|
||||
24
webseite-react-php-jwt/slim-php/sql/users.sql
Normal file
24
webseite-react-php-jwt/slim-php/sql/users.sql
Normal file
@@ -0,0 +1,24 @@
|
||||
USE nerdshop;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
username VARCHAR(64) NOT NULL,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
token VARCHAR(64) NULL DEFAULT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_users_username (username),
|
||||
UNIQUE KEY uq_users_email (email),
|
||||
KEY idx_users_token (token)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
INSERT INTO users (username, email, password_hash)
|
||||
VALUES (
|
||||
'megaman',
|
||||
'order@gkontra.de',
|
||||
'$2y$12$CdOg.e08jP968kQoA298Ne92r9pyEYBfSya4gFWQCh.X8gJhmDaVW'
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
email = VALUES(email),
|
||||
password_hash = VALUES(password_hash);
|
||||
49
webseite-react-php-jwt/slim-php/src/Auth/JwtService.php
Normal file
49
webseite-react-php-jwt/slim-php/src/Auth/JwtService.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Auth;
|
||||
|
||||
use App\Domain\User;
|
||||
use Firebase\JWT\JWT;
|
||||
use Firebase\JWT\Key;
|
||||
use UnexpectedValueException;
|
||||
|
||||
final class JwtService
|
||||
{
|
||||
public function __construct(
|
||||
private string $secret,
|
||||
private int $ttlSeconds = 86400,
|
||||
) {
|
||||
if ($this->secret === '') {
|
||||
throw new UnexpectedValueException('JWT_SECRET is not configured');
|
||||
}
|
||||
|
||||
JWT::$leeway = 60;
|
||||
}
|
||||
|
||||
public function encode(User $user): string
|
||||
{
|
||||
$now = time();
|
||||
|
||||
return JWT::encode(
|
||||
[
|
||||
'iss' => 'nerdshop-api',
|
||||
'aud' => 'nerdshop-app',
|
||||
'iat' => $now,
|
||||
'nbf' => $now,
|
||||
'exp' => $now + $this->ttlSeconds,
|
||||
'sub' => (string) $user->id,
|
||||
'username' => $user->username,
|
||||
'email' => $user->email,
|
||||
],
|
||||
$this->secret,
|
||||
'HS256'
|
||||
);
|
||||
}
|
||||
|
||||
public function decode(string $token): object
|
||||
{
|
||||
return JWT::decode($token, new Key($this->secret, 'HS256'));
|
||||
}
|
||||
}
|
||||
67
webseite-react-php-jwt/slim-php/src/Config.php
Normal file
67
webseite-react-php-jwt/slim-php/src/Config.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
use PDO;
|
||||
|
||||
final class Config
|
||||
{
|
||||
/**
|
||||
* Slim-Pfadpräfix für Installationen in einem Unterverzeichnis
|
||||
* (z. B. https://js-lernen.de/api/public/products).
|
||||
*/
|
||||
public static function slimBasePath(): string
|
||||
{
|
||||
$configured = trim((string) ($_ENV['SLIM_BASE_PATH'] ?? getenv('SLIM_BASE_PATH') ?: ''), '/');
|
||||
if ($configured !== '') {
|
||||
return '/' . $configured;
|
||||
}
|
||||
|
||||
$scriptName = str_replace('\\', '/', (string) ($_SERVER['SCRIPT_NAME'] ?? ''));
|
||||
$requestUri = parse_url((string) ($_SERVER['REQUEST_URI'] ?? '/'), PHP_URL_PATH) ?: '/';
|
||||
$requestUri = '/' . trim($requestUri, '/');
|
||||
$scriptDir = rtrim(str_replace('\\', '/', dirname($scriptName)), '/');
|
||||
|
||||
if ($scriptDir === '' || $scriptDir === '.' || $scriptDir === '/') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ($requestUri === $scriptDir || str_starts_with($requestUri . '/', $scriptDir . '/')) {
|
||||
return $scriptDir;
|
||||
}
|
||||
|
||||
if (str_ends_with($scriptDir, '/public')) {
|
||||
$withoutPublic = substr($scriptDir, 0, -strlen('/public'));
|
||||
if (
|
||||
$withoutPublic !== ''
|
||||
&& ($requestUri === $withoutPublic || str_starts_with($requestUri . '/', $withoutPublic . '/'))
|
||||
) {
|
||||
return $withoutPublic;
|
||||
}
|
||||
}
|
||||
|
||||
return $scriptDir;
|
||||
}
|
||||
|
||||
public static function pdo(): PDO
|
||||
{
|
||||
$dsn = sprintf(
|
||||
'mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4',
|
||||
$_ENV['DB_HOST'] ?? '127.0.0.1',
|
||||
$_ENV['DB_PORT'] ?? '3306',
|
||||
$_ENV['DB_NAME'] ?? 'nerdshop'
|
||||
);
|
||||
|
||||
return new PDO(
|
||||
$dsn,
|
||||
$_ENV['DB_USER'] ?? 'root',
|
||||
$_ENV['DB_PASS'] ?? '',
|
||||
[
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
46
webseite-react-php-jwt/slim-php/src/Domain/Product.php
Normal file
46
webseite-react-php-jwt/slim-php/src/Domain/Product.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain;
|
||||
|
||||
final class Product
|
||||
{
|
||||
public function __construct(
|
||||
public string $id,
|
||||
public string $title,
|
||||
public string $sku,
|
||||
public int $stock,
|
||||
public float $price,
|
||||
public string $description,
|
||||
public string $tagline,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function fromRow(array $row): self
|
||||
{
|
||||
return new self(
|
||||
id: $row['id'],
|
||||
title: $row['title'],
|
||||
sku: $row['sku'],
|
||||
stock: (int) $row['stock'],
|
||||
price: (float) $row['price'],
|
||||
description: $row['description'],
|
||||
tagline: $row['tagline'],
|
||||
);
|
||||
}
|
||||
|
||||
/** Kompatibel mit der React-App (_id statt id). */
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'_id' => $this->id,
|
||||
'title' => $this->title,
|
||||
'sku' => $this->sku,
|
||||
'stock' => $this->stock,
|
||||
'price' => $this->price,
|
||||
'description' => $this->description,
|
||||
'tagline' => $this->tagline,
|
||||
];
|
||||
}
|
||||
}
|
||||
40
webseite-react-php-jwt/slim-php/src/Domain/User.php
Normal file
40
webseite-react-php-jwt/slim-php/src/Domain/User.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain;
|
||||
|
||||
final class User
|
||||
{
|
||||
public function __construct(
|
||||
public int $id,
|
||||
public string $username,
|
||||
public string $email,
|
||||
public string $passwordHash,
|
||||
public ?string $token,
|
||||
public string $createdAt,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function fromRow(array $row): self
|
||||
{
|
||||
return new self(
|
||||
id: (int) $row['id'],
|
||||
username: $row['username'],
|
||||
email: $row['email'],
|
||||
passwordHash: $row['password_hash'],
|
||||
token: $row['token'] ?: null,
|
||||
createdAt: $row['created_at'],
|
||||
);
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'username' => $this->username,
|
||||
'email' => $this->email,
|
||||
'created_at' => $this->createdAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Middleware;
|
||||
|
||||
use App\Auth\JwtService;
|
||||
use App\Repository\UserRepository;
|
||||
use DomainException;
|
||||
use Firebase\JWT\BeforeValidException;
|
||||
use Firebase\JWT\ExpiredException;
|
||||
use Firebase\JWT\SignatureInvalidException;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface as Handler;
|
||||
use Slim\Psr7\Response as SlimResponse;
|
||||
use UnexpectedValueException;
|
||||
|
||||
final class AuthMiddleware implements MiddlewareInterface
|
||||
{
|
||||
public function __construct(
|
||||
private JwtService $jwt,
|
||||
private UserRepository $users,
|
||||
) {
|
||||
}
|
||||
|
||||
public function process(Request $request, Handler $handler): Response
|
||||
{
|
||||
if ($request->getMethod() === 'OPTIONS') {
|
||||
return $handler->handle($request);
|
||||
}
|
||||
|
||||
$header = $this->authorizationHeader($request);
|
||||
|
||||
if (!preg_match('/^Bearer\s+(\S+)$/i', $header, $matches)) {
|
||||
return $this->unauthorized('Missing bearer token');
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = $this->jwt->decode($matches[1]);
|
||||
} catch (ExpiredException) {
|
||||
return $this->unauthorized('Token expired');
|
||||
} catch (SignatureInvalidException | BeforeValidException | UnexpectedValueException | DomainException) {
|
||||
return $this->unauthorized('Invalid or expired token');
|
||||
}
|
||||
|
||||
$userId = (int) ($payload->sub ?? 0);
|
||||
$user = $userId > 0 ? $this->users->findById($userId) : null;
|
||||
|
||||
if ($user === null) {
|
||||
return $this->unauthorized('Invalid or expired token');
|
||||
}
|
||||
|
||||
return $handler->handle($request->withAttribute('user', $user));
|
||||
}
|
||||
|
||||
private function authorizationHeader(Request $request): string
|
||||
{
|
||||
$header = $request->getHeaderLine('Authorization');
|
||||
if ($header !== '') {
|
||||
return $header;
|
||||
}
|
||||
|
||||
$params = $request->getServerParams();
|
||||
|
||||
foreach (['HTTP_AUTHORIZATION', 'REDIRECT_HTTP_AUTHORIZATION'] as $key) {
|
||||
$value = $params[$key] ?? $_SERVER[$key] ?? '';
|
||||
if (is_string($value) && $value !== '') {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
if (function_exists('getallheaders')) {
|
||||
foreach (getallheaders() as $name => $value) {
|
||||
if (strcasecmp((string) $name, 'Authorization') === 0 && is_string($value) && $value !== '') {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function unauthorized(string $message): Response
|
||||
{
|
||||
$response = new SlimResponse(401);
|
||||
$response->getBody()->write(json_encode(['error' => $message], JSON_UNESCAPED_UNICODE));
|
||||
|
||||
return $response
|
||||
->withHeader('Content-Type', 'application/json')
|
||||
->withHeader('WWW-Authenticate', 'Bearer realm="nerdshop-api"');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Middleware;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface as Handler;
|
||||
use Slim\Psr7\Response as SlimResponse;
|
||||
|
||||
final class CorsMiddleware implements MiddlewareInterface
|
||||
{
|
||||
public function process(Request $request, Handler $handler): Response
|
||||
{
|
||||
$origin = $this->allowedOrigin($request);
|
||||
|
||||
if ($request->getMethod() === 'OPTIONS') {
|
||||
$response = new SlimResponse(204);
|
||||
} else {
|
||||
$response = $handler->handle($request);
|
||||
}
|
||||
|
||||
return $response
|
||||
->withHeader('Access-Control-Allow-Origin', $origin)
|
||||
->withHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
|
||||
->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
||||
}
|
||||
|
||||
private function allowedOrigin(Request $request): string
|
||||
{
|
||||
$configured = $_ENV['CORS_ORIGIN'] ?? 'http://localhost:5173';
|
||||
$allowed = array_values(array_filter(array_map('trim', explode(',', $configured))));
|
||||
$requestOrigin = $request->getHeaderLine('Origin');
|
||||
|
||||
if ($requestOrigin !== '' && in_array($requestOrigin, $allowed, true)) {
|
||||
return $requestOrigin;
|
||||
}
|
||||
|
||||
return $allowed[0] ?? 'http://localhost:5173';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Domain\Product;
|
||||
use PDO;
|
||||
|
||||
final class ProductRepository
|
||||
{
|
||||
public function __construct(private PDO $pdo)
|
||||
{
|
||||
}
|
||||
|
||||
/** @return Product[] */
|
||||
public function findAll(): array
|
||||
{
|
||||
$stmt = $this->pdo->query('SELECT * FROM products ORDER BY sku');
|
||||
|
||||
return array_map(Product::fromRow(...), $stmt->fetchAll());
|
||||
}
|
||||
|
||||
public function findById(string $id): ?Product
|
||||
{
|
||||
$stmt = $this->pdo->prepare('SELECT * FROM products WHERE id = :id');
|
||||
$stmt->execute(['id' => $id]);
|
||||
$row = $stmt->fetch();
|
||||
|
||||
return $row ? Product::fromRow($row) : null;
|
||||
}
|
||||
|
||||
public function create(array $data): Product
|
||||
{
|
||||
$id = $data['_id'] ?? $this->generateId();
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'INSERT INTO products (id, title, sku, stock, price, description, tagline)
|
||||
VALUES (:id, :title, :sku, :stock, :price, :description, :tagline)'
|
||||
);
|
||||
$stmt->execute([
|
||||
'id' => $id,
|
||||
'title' => $data['title'],
|
||||
'sku' => $data['sku'],
|
||||
'stock' => (int) $data['stock'],
|
||||
'price' => (float) $data['price'],
|
||||
'description' => $data['description'],
|
||||
'tagline' => $data['tagline'],
|
||||
]);
|
||||
|
||||
$product = $this->findById($id);
|
||||
|
||||
if ($product === null) {
|
||||
throw new \RuntimeException('Product could not be created');
|
||||
}
|
||||
|
||||
return $product;
|
||||
}
|
||||
|
||||
public function update(string $id, array $data): ?Product
|
||||
{
|
||||
if ($this->findById($id) === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'UPDATE products
|
||||
SET title = :title, sku = :sku, stock = :stock,
|
||||
price = :price, description = :description, tagline = :tagline
|
||||
WHERE id = :id'
|
||||
);
|
||||
$stmt->execute([
|
||||
'id' => $id,
|
||||
'title' => $data['title'],
|
||||
'sku' => $data['sku'],
|
||||
'stock' => (int) $data['stock'],
|
||||
'price' => (float) $data['price'],
|
||||
'description' => $data['description'],
|
||||
'tagline' => $data['tagline'],
|
||||
]);
|
||||
|
||||
return $this->findById($id);
|
||||
}
|
||||
|
||||
public function patch(string $id, array $data): ?Product
|
||||
{
|
||||
$current = $this->findById($id);
|
||||
|
||||
if ($current === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->update($id, [
|
||||
'title' => $data['title'] ?? $current->title,
|
||||
'sku' => $data['sku'] ?? $current->sku,
|
||||
'stock' => array_key_exists('stock', $data) ? (int) $data['stock'] : $current->stock,
|
||||
'price' => array_key_exists('price', $data) ? (float) $data['price'] : $current->price,
|
||||
'description' => $data['description'] ?? $current->description,
|
||||
'tagline' => $data['tagline'] ?? $current->tagline,
|
||||
]);
|
||||
}
|
||||
|
||||
public function delete(string $id): bool
|
||||
{
|
||||
$stmt = $this->pdo->prepare('DELETE FROM products WHERE id = :id');
|
||||
$stmt->execute(['id' => $id]);
|
||||
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
private function generateId(): string
|
||||
{
|
||||
return bin2hex(random_bytes(12));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Domain\User;
|
||||
use PDO;
|
||||
|
||||
final class UserRepository
|
||||
{
|
||||
public function __construct(private PDO $pdo)
|
||||
{
|
||||
}
|
||||
|
||||
/** @return User[] */
|
||||
public function findAll(): array
|
||||
{
|
||||
$stmt = $this->pdo->query('SELECT * FROM users ORDER BY username');
|
||||
|
||||
return array_map(User::fromRow(...), $stmt->fetchAll());
|
||||
}
|
||||
|
||||
public function findById(int $id): ?User
|
||||
{
|
||||
$stmt = $this->pdo->prepare('SELECT * FROM users WHERE id = :id');
|
||||
$stmt->execute(['id' => $id]);
|
||||
$row = $stmt->fetch();
|
||||
|
||||
return $row ? User::fromRow($row) : null;
|
||||
}
|
||||
|
||||
public function findByUsernameOrEmail(string $login): ?User
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT * FROM users WHERE username = :login OR email = :login LIMIT 1'
|
||||
);
|
||||
$stmt->execute(['login' => $login]);
|
||||
$row = $stmt->fetch();
|
||||
|
||||
return $row ? User::fromRow($row) : null;
|
||||
}
|
||||
}
|
||||
158
webseite-react-php-jwt/slim-php/src/Routes/productRoutes.php
Normal file
158
webseite-react-php-jwt/slim-php/src/Routes/productRoutes.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Middleware\AuthMiddleware;
|
||||
use App\Repository\ProductRepository;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Slim\App;
|
||||
use Slim\Routing\RouteCollectorProxy;
|
||||
|
||||
return function (App $app): void {
|
||||
$container = $app->getContainer();
|
||||
|
||||
if ($container === null) {
|
||||
throw new RuntimeException('DI container is not configured');
|
||||
}
|
||||
|
||||
$json = static function (Response $response, mixed $data, int $status = 200): Response {
|
||||
$response->getBody()->write(json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
return $response
|
||||
->withHeader('Content-Type', 'application/json')
|
||||
->withStatus($status);
|
||||
};
|
||||
|
||||
$requiredFields = ['title', 'sku', 'stock', 'price', 'description', 'tagline'];
|
||||
|
||||
$validate = static function (array $data) use ($requiredFields): ?string {
|
||||
foreach ($requiredFields as $field) {
|
||||
if (!isset($data[$field]) || $data[$field] === '') {
|
||||
return "Missing field: {$field}";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
$app->get('/', function (Request $request, Response $response) use ($json): Response {
|
||||
return $json($response, [
|
||||
'name' => 'Nerdshop API',
|
||||
'endpoints' => [
|
||||
'POST /login',
|
||||
'POST /logout',
|
||||
'GET /me',
|
||||
'GET /users',
|
||||
'GET /users/{id}',
|
||||
'GET /products',
|
||||
'GET /products/{id}',
|
||||
'POST /products',
|
||||
'PUT /products/{id}',
|
||||
'PATCH /products/{id}',
|
||||
'DELETE /products/{id}',
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
$app->get('/products', function (Request $request, Response $response) use ($container, $json): Response {
|
||||
$repo = $container->get(ProductRepository::class);
|
||||
$items = array_map(static fn ($product) => $product->toArray(), $repo->findAll());
|
||||
|
||||
return $json($response, $items);
|
||||
});
|
||||
|
||||
$app->get('/products/{id}', function (Request $request, Response $response, array $args) use ($container, $json): Response {
|
||||
$product = $container->get(ProductRepository::class)->findById($args['id']);
|
||||
|
||||
if ($product === null) {
|
||||
return $json($response, ['error' => 'Product not found'], 404);
|
||||
}
|
||||
|
||||
return $json($response, $product->toArray());
|
||||
});
|
||||
|
||||
$app->group('', function (RouteCollectorProxy $group) use ($container, $json, $validate): void {
|
||||
$group->post('/products', function (Request $request, Response $response) use ($container, $json, $validate): Response {
|
||||
$data = (array) $request->getParsedBody();
|
||||
$error = $validate($data);
|
||||
|
||||
if ($error !== null) {
|
||||
return $json($response, ['error' => $error], 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$product = $container->get(ProductRepository::class)->create($data);
|
||||
} catch (\PDOException $exception) {
|
||||
if ((int) $exception->getCode() === 23000) {
|
||||
return $json($response, ['error' => 'SKU already exists'], 409);
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
return $json($response, $product->toArray(), 201);
|
||||
});
|
||||
|
||||
$group->put('/products/{id}', function (Request $request, Response $response, array $args) use ($container, $json, $validate): Response {
|
||||
$data = (array) $request->getParsedBody();
|
||||
$error = $validate($data);
|
||||
|
||||
if ($error !== null) {
|
||||
return $json($response, ['error' => $error], 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$product = $container->get(ProductRepository::class)->update($args['id'], $data);
|
||||
} catch (\PDOException $exception) {
|
||||
if ((int) $exception->getCode() === 23000) {
|
||||
return $json($response, ['error' => 'SKU already exists'], 409);
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
if ($product === null) {
|
||||
return $json($response, ['error' => 'Product not found'], 404);
|
||||
}
|
||||
|
||||
return $json($response, $product->toArray());
|
||||
});
|
||||
|
||||
$group->patch('/products/{id}', function (Request $request, Response $response, array $args) use ($container, $json): Response {
|
||||
$data = (array) $request->getParsedBody();
|
||||
$allowed = ['title', 'sku', 'stock', 'price', 'description', 'tagline'];
|
||||
$patch = array_intersect_key($data, array_flip($allowed));
|
||||
|
||||
if ($patch === []) {
|
||||
return $json($response, ['error' => 'No fields to update'], 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$product = $container->get(ProductRepository::class)->patch($args['id'], $patch);
|
||||
} catch (\PDOException $exception) {
|
||||
if ((int) $exception->getCode() === 23000) {
|
||||
return $json($response, ['error' => 'SKU already exists'], 409);
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
if ($product === null) {
|
||||
return $json($response, ['error' => 'Product not found'], 404);
|
||||
}
|
||||
|
||||
return $json($response, $product->toArray());
|
||||
});
|
||||
|
||||
$group->delete('/products/{id}', function (Request $request, Response $response, array $args) use ($container, $json): Response {
|
||||
$deleted = $container->get(ProductRepository::class)->delete($args['id']);
|
||||
|
||||
if (!$deleted) {
|
||||
return $json($response, ['error' => 'Product not found'], 404);
|
||||
}
|
||||
|
||||
return $response->withStatus(204);
|
||||
});
|
||||
})->add($container->get(AuthMiddleware::class));
|
||||
};
|
||||
82
webseite-react-php-jwt/slim-php/src/Routes/userRoutes.php
Normal file
82
webseite-react-php-jwt/slim-php/src/Routes/userRoutes.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Auth\JwtService;
|
||||
use App\Domain\User;
|
||||
use App\Middleware\AuthMiddleware;
|
||||
use App\Repository\UserRepository;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Slim\App;
|
||||
use Slim\Routing\RouteCollectorProxy;
|
||||
|
||||
return function (App $app): void {
|
||||
$container = $app->getContainer();
|
||||
|
||||
if ($container === null) {
|
||||
throw new RuntimeException('DI container is not configured');
|
||||
}
|
||||
|
||||
$json = static function (Response $response, mixed $data, int $status = 200): Response {
|
||||
$response->getBody()->write(json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
return $response
|
||||
->withHeader('Content-Type', 'application/json')
|
||||
->withStatus($status);
|
||||
};
|
||||
|
||||
$app->post('/login', function (Request $request, Response $response) use ($container, $json): Response {
|
||||
$data = (array) $request->getParsedBody();
|
||||
$login = trim((string) ($data['username'] ?? $data['email'] ?? $data['login'] ?? ''));
|
||||
$password = (string) ($data['password'] ?? '');
|
||||
|
||||
if ($login === '' || $password === '') {
|
||||
return $json($response, ['error' => 'Username and password are required'], 400);
|
||||
}
|
||||
|
||||
$users = $container->get(UserRepository::class);
|
||||
$user = $users->findByUsernameOrEmail($login);
|
||||
|
||||
if ($user === null || !password_verify($password, $user->passwordHash)) {
|
||||
return $json($response, ['error' => 'Invalid credentials'], 401);
|
||||
}
|
||||
|
||||
return $json($response, [
|
||||
'user' => $user->toArray(),
|
||||
'token' => $container->get(JwtService::class)->encode($user),
|
||||
]);
|
||||
});
|
||||
|
||||
$app->group('', function (RouteCollectorProxy $group) use ($container, $json): void {
|
||||
$group->get('/me', function (Request $request, Response $response) use ($json): Response {
|
||||
/** @var User $user */
|
||||
$user = $request->getAttribute('user');
|
||||
|
||||
return $json($response, $user->toArray());
|
||||
});
|
||||
|
||||
$group->post('/logout', function (Request $request, Response $response): Response {
|
||||
return $response->withStatus(204);
|
||||
});
|
||||
|
||||
$group->get('/users', function (Request $request, Response $response) use ($container, $json): Response {
|
||||
$items = array_map(
|
||||
static fn (User $user) => $user->toArray(),
|
||||
$container->get(UserRepository::class)->findAll()
|
||||
);
|
||||
|
||||
return $json($response, $items);
|
||||
});
|
||||
|
||||
$group->get('/users/{id}', function (Request $request, Response $response, array $args) use ($container, $json): Response {
|
||||
$user = $container->get(UserRepository::class)->findById((int) $args['id']);
|
||||
|
||||
if ($user === null) {
|
||||
return $json($response, ['error' => 'User not found'], 404);
|
||||
}
|
||||
|
||||
return $json($response, $user->toArray());
|
||||
});
|
||||
})->add($container->get(AuthMiddleware::class));
|
||||
};
|
||||
Reference in New Issue
Block a user