diff options
author | Daniel Weipert <git@mail.dweipert.de> | 2025-08-20 14:58:10 +0200 |
---|---|---|
committer | Daniel Weipert <git@mail.dweipert.de> | 2025-08-20 14:58:10 +0200 |
commit | 6dc0447320272aaae51a98eb6606597019f986d3 (patch) | |
tree | 0acb527801a1d9eac943b5e0b0ccb33e610cd755 | |
parent | d08f4c83470c25d35d24594bd73e4effdac191a0 (diff) |
login produces devices and tokens
-rw-r--r-- | migrations/20250819.php | 4 | ||||
-rw-r--r-- | src/Controllers/KeyController.php | 34 | ||||
-rw-r--r-- | src/Controllers/LoginController.php | 54 | ||||
-rw-r--r-- | src/Errors/AppException.php | 12 | ||||
-rw-r--r-- | src/Models/Device.php | 23 | ||||
-rw-r--r-- | src/Models/Tokens.php | 137 | ||||
-rw-r--r-- | src/Models/User.php | 7 | ||||
-rw-r--r-- | src/Router/routes_client_server.php | 5 | ||||
-rw-r--r-- | src/Support/RequestValidator.php | 67 |
9 files changed, 307 insertions, 36 deletions
diff --git a/migrations/20250819.php b/migrations/20250819.php index cfd0f65..061b59c 100644 --- a/migrations/20250819.php +++ b/migrations/20250819.php @@ -37,9 +37,13 @@ Database::getInstance()->query(<<<SQL "access_token" varchar(255) not null, "refresh_token" varchar(255) not null, + "expires_at" timestamptz(3) not null, + "user_id" varchar(255) not null, "device_id" varchar(255), + "created_at" timestamptz(3) not null, + foreign key (user_id, device_id) references devices(user_id, id) ); SQL); diff --git a/src/Controllers/KeyController.php b/src/Controllers/KeyController.php index a999e40..5050d9b 100644 --- a/src/Controllers/KeyController.php +++ b/src/Controllers/KeyController.php @@ -2,6 +2,10 @@ namespace App\Controllers; +use App\Errors\AppException; +use App\Errors\ErrorCode; +use App\Models\Tokens; +use App\Support\RequestValidator; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\JsonResponse; @@ -29,4 +33,34 @@ class KeyController public function query(string $serverName): Response {} + + /** + * POST /_matrix/client/v3/refresh + */ + public function refresh(): Response + { + $request = Request::createFromGlobals(); + RequestValidator::validateJson(); + $body = json_decode($request->getContent(), true); + + $tokens = Tokens::fetchWithRefreshToken($body["refresh_token"]); + + if (empty($tokens)) { + throw new AppException( + ErrorCode::UNKNOWN_TOKEN, + "Soft logged out", + Response::HTTP_UNAUTHORIZED, + ["soft_logout" => true], + ); + } + + $newTokens = Tokens::new($tokens->getUserId(), $tokens->getDeviceId()); + $newTokens->insert(); + + return new JsonResponse([ + "access_token" => $newTokens->getAccessToken(), + "expires_in" => $newTokens->getExpiresIn(), + "refresh_token" => $newTokens->getRefreshToken(), + ]); + } } diff --git a/src/Controllers/LoginController.php b/src/Controllers/LoginController.php index 1ff234c..fd48f25 100644 --- a/src/Controllers/LoginController.php +++ b/src/Controllers/LoginController.php @@ -7,7 +7,9 @@ use App\Errors\AppException; use App\Errors\ErrorCode; use App\Errors\UnknownError; use App\Models\Device; +use App\Models\Tokens; use App\Models\User; +use App\Support\RequestValidator; use App\Types\LoginFlow; use App\Types\LoginType; use App\Types\UserRegistrationKind; @@ -36,6 +38,7 @@ class LoginController { $request = Request::createFromGlobals(); $body = json_decode($request->getContent(), true); + RequestValidator::validateJson(); // validate login type $loginType = null; @@ -53,24 +56,43 @@ class LoginController $user = User::fetchWithPassword($userId, $body["password"]); if (! $user) { - throw new AppException(ErrorCode::FORBIDDEN, "Invalid credentials.", Response::HTTP_FORBIDDEN); + throw new AppException(ErrorCode::FORBIDDEN, "Invalid credentials", Response::HTTP_FORBIDDEN); } $deviceId = $body["device_id"] ?? ""; - $device = $user->fetchDevice($deviceId); - if (! $device) { + $device = null; + $tokens = null; + + // create new device with tokens + if (empty($deviceId)) { $device = Device::new( $user->getId(), initialDisplayName: $body["initial_device_display_name"] ?? "", ); + $device->insert(); + + $tokens = Tokens::new($userId, $device->getId()); + $tokens->insert(); + } else { // fetch existing device and tokens + $device = $user->fetchDevice($deviceId); + $tokens = Tokens::fetch($userId, $device->getId()); + + if (empty($tokens)) { + throw new AppException( + ErrorCode::UNKNOWN_TOKEN, + "Soft logged out", + Response::HTTP_UNAUTHORIZED, + ["soft_logout" => true], + ); + } } return new JsonResponse([ - "access_token" => $device->getAccessToken(), + "access_token" => $tokens->getAccessToken(), "device_id" => $device->getId(), - "expires_in_ms" => 60000, - "refresh_token" => $device->getRefreshToken(), + "expires_in_ms" => $tokens->getExpiresIn(), + "refresh_token" => $tokens->getRefreshToken(), "user_id" => $user->getId(), #"well_known" => [], ]); @@ -83,8 +105,15 @@ class LoginController { $request = Request::createFromGlobals(); $body = json_decode($request->getContent(), true); + RequestValidator::validateJson(); - $kind = UserRegistrationKind::from($request->query->get("kind") ?? "user"); + // validate kind + $kind = null; + try { + $kind = UserRegistrationKind::from($request->query->get("kind") ?? "user"); + } catch (\ValueError $error) { + throw new UnknownError("Bad registration kind.", Response::HTTP_BAD_REQUEST); + } $username = $body["username"]; $userId = "@$username:$_ENV[DOMAIN]"; @@ -95,16 +124,19 @@ class LoginController ]); $device_id = $body["device_id"] ?? ""; - $initialDeviceDisplayName = $body["initialDeviceDisplayName"] ?? ""; + $initialDeviceDisplayName = $body["initial_device_display_name"] ?? ""; $device = Device::new($userId, $device_id, $initialDeviceDisplayName); $device->insert(); + $tokens = Tokens::new($userId, $device->getId()); + $tokens->insert(); + return new JsonResponse([ - "access_token" => $device->getAccessToken(), + "access_token" => $tokens->getAccessToken(), "device_id" => $device->getId(), - "expires_in_ms" => 60000, - "refresh_token" => $device->getRefreshToken(), + "expires_in_ms" => $tokens->getExpiresIn(), + "refresh_token" => $tokens->getRefreshToken(), "user_id" => $userId, ]); } diff --git a/src/Errors/AppException.php b/src/Errors/AppException.php index e4359e6..2a554c7 100644 --- a/src/Errors/AppException.php +++ b/src/Errors/AppException.php @@ -4,8 +4,18 @@ namespace App\Errors; class AppException extends Exception { + public function __construct( + ErrorCode $errorCode, + string $message, + int $httpCode, + private array $additionalData = [] + ) + { + parent::__construct($errorCode, $message, $httpCode); + } + public function getAdditionalData(): array { - return []; + return $this->additionalData; } } diff --git a/src/Models/Device.php b/src/Models/Device.php index 2b09c6b..82d9e5e 100644 --- a/src/Models/Device.php +++ b/src/Models/Device.php @@ -93,31 +93,24 @@ class Device implements ConnectsToDatabase $deviceId = $deviceId ?: md5($userId . random_bytes(512)); $initialDisplayName = $initialDisplayName ?: "capybara"; - $accessToken = md5($userId . random_bytes(512)); - $refreshToken = md5($userId . random_bytes(512)); - return new self( $deviceId, $userId, $initialDisplayName, - $accessToken, - $refreshToken, ); } public function insert(): bool { - return Database::getInstance()->query( + return !! Database::getInstance()->query( <<<SQL - insert into devices (id, user_id, name, access_token, refresh_token) - values (:id, :user_id, :name, :access_token, :refresh_token) + insert into devices (id, user_id, name) + values (:id, :user_id, :name) SQL, [ "id" => $this->id, "user_id" => $this->userId, "name" => $this->name, - "access_token" => $this->accessToken, - "refresh_token" => $this->refreshToken, ] ); } @@ -137,14 +130,4 @@ class Device implements ConnectsToDatabase { return $this->name; } - - public function getAccessToken(): string - { - return $this->accessToken; - } - - public function getRefreshToken(): string - { - return $this->refreshToken; - } } diff --git a/src/Models/Tokens.php b/src/Models/Tokens.php index a94c876..4ad8e1d 100644 --- a/src/Models/Tokens.php +++ b/src/Models/Tokens.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Database; use App\Support\ConnectsToDatabase; class Tokens implements ConnectsToDatabase @@ -9,7 +10,9 @@ class Tokens implements ConnectsToDatabase public function __construct( private string $accessToken, private string $refreshToken, + private \DateTime $expiresAt, private string $userId, + private \DateTime $createdAt, private string $deviceId = "", ) {} @@ -19,23 +22,151 @@ class Tokens implements ConnectsToDatabase return new self( $row["access_token"], $row["refresh_token"], + new \DateTime($row["expires_at"]), $row["user_id"], + new \DateTime($row["created_at"]), $row["device_id"], ); } - public static function fetch(): ?self - {} + public static function fetch(string $userId = "", string $deviceId = "", bool $isExpired = false): ?self + { + if (empty($userId)) { + throw new \InvalidArgumentException("missing user id"); + } + + $isExpiredSql = ""; + if ($isExpired) { + $isExpiredSql = "and expires_at <= current_timestamp"; + } else { + $isExpiredSql = "and expires_at > current_timestamp"; + } + + $row = []; + if (empty($deviceId)) { + $row = Database::getInstance()->query( + <<<SQL + select * from tokens + where user_id=:user_id and device_id is null + $isExpiredSql + SQL, + [ + "user_id" => $userId, + ] + )->fetch(); + } else { + $row = Database::getInstance()->query( + <<<SQL + select * from tokens + where user_id=:user_id and device_id=:device_id + $isExpiredSql + SQL, + [ + "user_id" => $userId, + "device_id" => $deviceId, + ] + )->fetch(); + } + + if (empty($row)) { + return null; + } + + return self::fromDatabase($row); + } public static function fetchAll(): array {} + public static function fetchWithRefreshToken(string $refreshToken): ?self + { + $row = Database::getInstance()->query( + <<<SQL + select * from tokens + where refresh_token=:refresh_token + order by created_at desc + SQL, + [ + "refresh_token" => $refreshToken, + ] + )->fetch(); + + if (empty($row)) { + return null; + } + + return self::fromDatabase($row); + } + + public static function new(string $userId, string $deviceId, string $expiryTime = ""): self + { + $expiryTime = ($expiryTime ?: ($_ENV["TOKEN_DEFAULT_LIFETIME"] ?? "")) ?: "5min"; + + return new self( + md5($userId . random_bytes(512)), + md5($userId . random_bytes(512)), + (new \DateTime("now"))->modify("+$expiryTime"), + $userId, + new \DateTime("now"), + $deviceId, + ); + } + public function insert(): bool - {} + { + return !! Database::getInstance()->query(<<<SQL + insert into tokens (access_token, refresh_token, expires_at, user_id, device_id, created_at) + values (:access_token, :refresh_token, to_timestamp(:expires_at), :user_id, :device_id, to_timestamp(:created_at)) + SQL, [ + "access_token" => $this->accessToken, + "refresh_token" => $this->refreshToken, + "expires_at" => $this->expiresAt->format("U.v"), + "user_id" => $this->userId, + "device_id" => $this->deviceId, + "created_at" => $this->createdAt->format("U.v"), + ]); + } public function update(): bool {} public function delete(): bool {} + + public function getExpiresIn(): int + { + return intval( + ($this->expiresAt->format("U.v") - (new \DateTime("now"))->format("U.v")) * 1000.0 + ); + } + + public function getAccessToken(): string + { + return $this->accessToken; + } + + public function getRefreshToken(): string + { + return $this->refreshToken; + } + + public function getExpiresAt(): int + { + return $this->expiresAt; + } + + public function getUserId(): string + { + return $this->userId; + } + + public function getDeviceId(): string + { + return $this->deviceId; + } + + public function getCreatedAt(): int + { + return $this->createdAt; + } } diff --git a/src/Models/User.php b/src/Models/User.php index 5d198e7..354c466 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -50,9 +50,14 @@ class User implements ConnectsToDatabase return self::fromDatabase($row); } + public static function new(string $id): self + { + return new self($id); + } + public function insert(): bool { - return Database::getInstance()->query( + return !! Database::getInstance()->query( <<<SQL insert into users (id) values (:id) diff --git a/src/Router/routes_client_server.php b/src/Router/routes_client_server.php index a0b117b..8979af5 100644 --- a/src/Router/routes_client_server.php +++ b/src/Router/routes_client_server.php @@ -46,4 +46,9 @@ return function (RouteConfigurator $routes): void ->add("matrix_client_v3_sync", "/_matrix/client/v3/sync") ->controller([SyncController::class, "sync"]) ->methods(["GET"]); + + $routes + ->add("matrix_client_v3_refresh", "/_matrix/client/v3/refresh") + ->controller([KeyController::class, "refresh"]) + ->methods(["POST"]); }; diff --git a/src/Support/RequestValidator.php b/src/Support/RequestValidator.php new file mode 100644 index 0000000..40ede8b --- /dev/null +++ b/src/Support/RequestValidator.php @@ -0,0 +1,67 @@ +<?php + +namespace App\Support; + +use App\Errors\AppException; +use App\Errors\ErrorCode; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; + +class RequestValidator +{ + private array $requestBody; + private array $requestQuery; + + public function __construct(private Request $request) + { + $this->requestBody = json_decode($request->getContent(), true); + self::validateJson(); + + $this->requestQuery = $request->query->all(); + } + + /** + * types are validated with gettype + * @see gettype + * + * @param array<mixed,mixed> $schemaRequired + * @param array<mixed,mixed> $schemaOptional + */ + public function validateBody(array $schemaRequired, array $schemaOptional = []): void + { + throw new AppException( + ErrorCode::BAD_JSON, + "Request body is missing required values", + Response::HTTP_UNPROCESSABLE_ENTITY, + ); + } + + /** + * types are validated with gettype + * @see gettype + * + * @param array<mixed,mixed> $schemaRequired + * @param array<mixed,mixed> $schemaOptional + */ + public function validateQuery(array $schemaRequired, array $schemaOptional = []): void + { + throw new AppException( + ErrorCode::BAD_JSON, + "Request query is missing required values", + Response::HTTP_UNPROCESSABLE_ENTITY, + ); + } + + private function traverseRecursive(): void {} + + public static function validateJson(): void + { + if (json_last_error() !== JSON_ERROR_NONE) { + throw new AppException( + ErrorCode::NOT_JSON, + "Request did not contain valid JSON", + Response::HTTP_BAD_REQUEST, + ); + } + } +} |