diff --git a/api/README.md b/api/README.md index efd0d03..a62e640 100644 --- a/api/README.md +++ b/api/README.md @@ -15,8 +15,9 @@ Authorization: Bearer ### Auth | Method | Path | Description | |--------|------|-------------| -| POST | `/auth/login` | Login with email/password, returns JWT | -| POST | `/auth/logout` | Logout (client-side token discard) | +| POST | `/auth/login` | Login with email/password, returns JWT + refresh_token | +| POST | `/auth/refresh` | Exchange refresh_token for new JWT + refresh_token | +| POST | `/auth/logout` | Revoke refresh token (logout) | | GET | `/auth/me` | Current user info (requires auth) | ### Bootstrap diff --git a/api/migrations/001_add_refresh_tokens.sql b/api/migrations/001_add_refresh_tokens.sql new file mode 100644 index 0000000..8858756 --- /dev/null +++ b/api/migrations/001_add_refresh_tokens.sql @@ -0,0 +1,18 @@ +-- Migration: Add refresh_tokens table +-- For JWT refresh token flow + +CREATE TABLE IF NOT EXISTS refresh_tokens ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash VARCHAR(64) NOT NULL UNIQUE, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + revoked_at TIMESTAMP WITH TIME ZONE DEFAULT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Index for quick lookups +CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id); +CREATE INDEX idx_refresh_tokens_hash ON refresh_tokens(token_hash) WHERE revoked_at IS NULL; + +COMMENT ON TABLE refresh_tokens IS 'OAuth2-style refresh tokens for long-lived sessions'; \ No newline at end of file diff --git a/api/public/index.php b/api/public/index.php index 56c9351..421060e 100644 --- a/api/public/index.php +++ b/api/public/index.php @@ -5,11 +5,12 @@ declare(strict_types=1); require __DIR__ . '/../vendor/autoload.php'; use App\Config\Container; +use Symfony\Component\Dotenv\Dotenv; // Load .env file safely $envFile = __DIR__ . '/../.env'; if (file_exists($envFile)) { - $dotenv = new \Symfony\Component\Dotenv\Dotenv(); + $dotenv = new Dotenv(); $dotenv->load($envFile); } diff --git a/api/src/Controllers/AuthController.php b/api/src/Controllers/AuthController.php index 04c09ad..17e9458 100644 --- a/api/src/Controllers/AuthController.php +++ b/api/src/Controllers/AuthController.php @@ -5,12 +5,16 @@ declare(strict_types=1); namespace App\Controllers; use App\Models\User; +use App\Models\RefreshToken; use Firebase\JWT\JWT; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; final class AuthController { + private const JWT_EXPIRY = 8 * 60 * 60; // 8 hours + private const REFRESH_EXPIRY_DAYS = 30; + public function login(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface { $body = json_decode($request->getBody()->getContents(), true); @@ -32,6 +36,80 @@ final class AuthController } // Generate JWT + $jwt = $this->generateJwt($user); + + // Generate refresh token + $refreshToken = $this->generateRefreshToken($user->id); + + $userData = $user->toArray(); + unset($userData['password_hash']); + + return $this->json($response, [ + 'token' => $jwt, + 'refresh_token' => $refreshToken, + 'expires_in' => self::JWT_EXPIRY, + 'user' => $userData, + ]); + } + + public function refresh(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface + { + $body = json_decode($request->getBody()->getContents(), true); + $refreshToken = $body['refresh_token'] ?? ''; + + if (empty($refreshToken)) { + return $this->json($response, ['message' => 'Refresh token required'], 400); + } + + // Find and validate refresh token + $tokenRecord = RefreshToken::findValid($refreshToken); + + if (!$tokenRecord) { + return $this->json($response, ['message' => 'Invalid or expired refresh token'], 401); + } + + // Load user + $user = User::find($tokenRecord->user_id); + + if (!$user || !$user->is_active) { + return $this->json($response, ['message' => 'User not found or disabled'], 401); + } + + // Generate new JWT + $jwt = $this->generateJwt($user); + + // Optionally rotate refresh token (revoke old, create new) + $tokenRecord->revoke(); + $newRefreshToken = $this->generateRefreshToken($user->id); + + $userData = $user->toArray(); + unset($userData['password_hash']); + + return $this->json($response, [ + 'token' => $jwt, + 'refresh_token' => $newRefreshToken, + 'expires_in' => self::JWT_EXPIRY, + 'user' => $userData, + ]); + } + + public function logout(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface + { + $body = json_decode($request->getBody()->getContents(), true); + $refreshToken = $body['refresh_token'] ?? null; + + if ($refreshToken) { + $tokenRecord = RefreshToken::findValid($refreshToken); + if ($tokenRecord) { + $tokenRecord->revoke(); + } + } + + return $this->json($response, ['message' => 'Logged out']); + } + + private function generateJwt(User $user): string + { $now = time(); $payload = [ 'iss' => 'api.fahrschultermin.de', @@ -39,35 +117,28 @@ final class AuthController 'tenant_id' => $user->tenant_id, 'role' => $user->role, 'iat' => $now, - 'exp' => $now + (8 * 60 * 60), // 8 hours + 'exp' => $now + self::JWT_EXPIRY, ]; - $jwt = JWT::encode($payload, $_ENV['JWT_SECRET'] ?? 'this-is-a-32-character-secret-key-for-testing', 'HS256'); - - $userData = $user->toArray(); - unset($userData['password_hash']); - - return $this->json($response, [ - 'token' => $jwt, - 'user' => $userData, - ]); - } - - public function me(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface - { - $user = $request->getAttribute('user'); - - if (!$user) { - return $this->json($response, ['message' => 'Unauthenticated'], 401); + $secret = $_ENV['JWT_SECRET'] ?? null; + if (!$secret) { + throw new \RuntimeException('JWT_SECRET environment variable not set'); } - - return $this->json($response, ['user' => $user]); + return JWT::encode($payload, $secret, 'HS256'); } - public function logout(ResponseInterface $response): ResponseInterface + private function generateRefreshToken(int $userId): string { - // JWT is stateless, logout is client-side (discard token) - return $this->json($response, ['message' => 'Logged out']); + $token = bin2hex(random_bytes(32)); // 64-char hex token + $hash = hash('sha256', $token); + + RefreshToken::create([ + 'user_id' => $userId, + 'token_hash' => $hash, + 'expires_at' => \Carbon\Carbon::now()->addDays(self::REFRESH_EXPIRY_DAYS), + ]); + + return $token; } private function json(ResponseInterface $response, array $data, int $status = 200): ResponseInterface diff --git a/api/src/Models/RefreshToken.php b/api/src/Models/RefreshToken.php new file mode 100644 index 0000000..483a477 --- /dev/null +++ b/api/src/Models/RefreshToken.php @@ -0,0 +1,52 @@ + 'datetime', + 'revoked_at' => 'datetime', + ]; + + /** + * Find a valid (non-revoked, non-expired) refresh token. + */ + public static function findValid(string $token): ?self + { + $hash = hash('sha256', $token); + + return self::where('token_hash', $hash) + ->whereNull('revoked_at') + ->where('expires_at', '>', \Carbon\Carbon::now()) + ->first(); + } + + /** + * Revoke this refresh token (logout). + */ + public function revoke(): void + { + $this->update(['revoked_at' => \Carbon\Carbon::now()]); + } + + /** + * Revoke all refresh tokens for a user. + */ + public static function revokeAllForUser(int $userId): void + { + self::where('user_id', $userId) + ->whereNull('revoked_at') + ->update(['revoked_at' => \Carbon\Carbon::now()]); + } +} \ No newline at end of file diff --git a/api/src/Routes/api.php b/api/src/Routes/api.php index 337811f..82df864 100644 --- a/api/src/Routes/api.php +++ b/api/src/Routes/api.php @@ -14,6 +14,7 @@ use App\Middleware\JwtMiddleware; return function (\Slim\App $app): void { // Auth routes (public) $app->post('/api/v1/auth/login', [AuthController::class, 'login']); + $app->post('/api/v1/auth/refresh', [AuthController::class, 'refresh']); $app->post('/api/v1/auth/logout', [AuthController::class, 'logout']); // Protected routes