summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDaniel Weipert <git@mail.dweipert.de>2025-08-20 14:58:10 +0200
committerDaniel Weipert <git@mail.dweipert.de>2025-08-20 14:58:10 +0200
commit6dc0447320272aaae51a98eb6606597019f986d3 (patch)
tree0acb527801a1d9eac943b5e0b0ccb33e610cd755
parentd08f4c83470c25d35d24594bd73e4effdac191a0 (diff)
login produces devices and tokens
-rw-r--r--migrations/20250819.php4
-rw-r--r--src/Controllers/KeyController.php34
-rw-r--r--src/Controllers/LoginController.php54
-rw-r--r--src/Errors/AppException.php12
-rw-r--r--src/Models/Device.php23
-rw-r--r--src/Models/Tokens.php137
-rw-r--r--src/Models/User.php7
-rw-r--r--src/Router/routes_client_server.php5
-rw-r--r--src/Support/RequestValidator.php67
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,
+ );
+ }
+ }
+}