Initial Version

This commit is contained in:
root
2025-12-21 10:09:54 -05:00
commit 2fbddd7dbc
366 changed files with 41999 additions and 0 deletions

View File

@@ -0,0 +1,361 @@
<?php
declare(strict_types=1);
namespace KTXC\Controllers;
use KTXC\Http\Cookie;
use KTXC\Http\Request\Request;
use KTXC\Http\Response\JsonResponse;
use KTXC\Http\Response\RedirectResponse;
use KTXC\Security\Authentication\AuthenticationRequest;
use KTXC\Security\Authentication\AuthenticationResponse;
use KTXC\Security\AuthenticationManager;
use KTXF\Controller\ControllerAbstract;
use KTXF\Routing\Attributes\AnonymousRoute;
use KTXF\Routing\Attributes\AuthenticatedRoute;
/**
* Authentication Controller
*/
class AuthenticationController extends ControllerAbstract
{
public function __construct(
private readonly AuthenticationManager $authManager
) {}
// =========================================================================
// Authentication Operations
// =========================================================================
/**
* Start authentication session
*/
#[AnonymousRoute('/auth/start', name: 'auth.start', methods: ['GET'])]
public function start(): JsonResponse
{
$request = AuthenticationRequest::start();
$response = $this->authManager->handle($request);
return $this->buildJsonResponse($response);
}
/**
* Identify user for identity-first login flow
*/
#[AnonymousRoute('/auth/identify', name: 'auth.identify', methods: ['POST'])]
public function identify(string $session, string $identity): JsonResponse
{
if (empty($session) || empty($identity)) {
return new JsonResponse(
['error' => 'Session and identity are required', 'error_code' => 'invalid_request'],
JsonResponse::HTTP_BAD_REQUEST
);
}
$request = AuthenticationRequest::identify($session, trim($identity));
$response = $this->authManager->handle($request);
return $this->buildJsonResponse($response);
}
/**
* Start a challenge for methods that require it (SMS, email, TOTP)
*/
#[AnonymousRoute('/auth/challenge', name: 'auth.challenge', methods: ['POST'])]
public function challenge(string $session, string $method): JsonResponse
{
if (empty($session) || empty($method)) {
return new JsonResponse(
['error' => 'Session ID and method are required', 'error_code' => 'invalid_request'],
JsonResponse::HTTP_BAD_REQUEST
);
}
$request = AuthenticationRequest::challenge($session, $method);
$response = $this->authManager->handle($request);
return $this->buildJsonResponse($response);
}
/**
* Verify a credential or challenge response
*/
#[AnonymousRoute('/auth/verify', name: 'auth.verify', methods: ['POST'])]
public function verify(string $session, string $method, string $response): JsonResponse
{
if (empty($session) || empty($method)) {
return new JsonResponse(
['error' => 'Session ID and method are required', 'error_code' => 'invalid_request'],
JsonResponse::HTTP_BAD_REQUEST
);
}
$request = AuthenticationRequest::verify($session, $method, $response);
$authResponse = $this->authManager->handle($request);
return $this->buildJsonResponse($authResponse);
}
/**
* Begin redirect-based authentication (OIDC/SAML)
*/
#[AnonymousRoute('/auth/redirect', name: 'auth.redirect', methods: ['POST'])]
public function redirect(Request $request): JsonResponse
{
$data = $this->getRequestData($request);
$sessionId = $data['session'] ?? '';
$method = $data['method'] ?? '';
$returnUrl = $data['return_url'] ?? '/';
if (empty($sessionId) || empty($method)) {
return new JsonResponse(
['error' => 'Session ID and method are required', 'error_code' => 'invalid_request'],
JsonResponse::HTTP_BAD_REQUEST
);
}
$scheme = $request->isSecure() ? 'https' : 'http';
$host = $request->getHost();
$callbackUrl = "{$scheme}://{$host}/auth/callback/{$method}";
$authRequest = AuthenticationRequest::redirect($sessionId, $method, $callbackUrl, $returnUrl);
$response = $this->authManager->handle($authRequest);
return $this->buildJsonResponse($response);
}
/**
* Handle callback from identity provider (OIDC/SAML)
*/
#[AnonymousRoute('/auth/callback/{provider}', name: 'auth.callback', methods: ['GET', 'POST'])]
public function callback(Request $request, string $provider): JsonResponse|RedirectResponse
{
$params = $request->isMethod('POST')
? $request->request->all()
: $request->query->all();
$sessionId = $params['state'] ?? null;
if (!$sessionId) {
return $this->redirectWithError('Missing state parameter');
}
$authRequest = AuthenticationRequest::callback($sessionId, $provider, $params);
$response = $this->authManager->handle($authRequest);
if ($response->isSuccess()) {
$returnUrl = $response->returnUrl ?? '/';
$httpResponse = new RedirectResponse($returnUrl);
if ($response->hasTokens()) {
return $this->setTokenCookies($httpResponse, $response->tokens, $request->isSecure());
}
return $httpResponse;
}
if ($response->isPending()) {
return new RedirectResponse('/login/mfa?session=' . urlencode($response->sessionId));
}
return $this->redirectWithError($response->errorMessage ?? 'Authentication failed');
}
/**
* Get current session status
*/
#[AnonymousRoute('/auth/status', name: 'auth.status', methods: ['GET'])]
public function status(Request $request): JsonResponse
{
$sessionId = $request->query->get('session', '');
if (empty($sessionId)) {
return new JsonResponse(
['error' => 'Session ID is required', 'error_code' => 'invalid_request'],
JsonResponse::HTTP_BAD_REQUEST
);
}
$authRequest = AuthenticationRequest::status($sessionId);
$response = $this->authManager->handle($authRequest);
return $this->buildJsonResponse($response);
}
/**
* Cancel authentication session
*/
#[AnonymousRoute('/auth/session', name: 'auth.session.cancel', methods: ['DELETE'])]
public function cancel(Request $request): JsonResponse
{
$sessionId = $request->query->get('session', '');
$authRequest = AuthenticationRequest::cancel($sessionId);
$this->authManager->handle($authRequest);
return new JsonResponse(['status' => 'cancelled', 'message' => 'Session cancelled']);
}
// =========================================================================
// Token Operations
// =========================================================================
/**
* Refresh access token
*/
#[AnonymousRoute('/auth/refresh', name: 'auth.refresh', methods: ['POST'])]
public function refresh(Request $request): JsonResponse
{
$refreshToken = $request->cookies->get('refreshToken');
if (!$refreshToken) {
return new JsonResponse(
['error' => 'Refresh token required', 'error_code' => 'missing_token'],
JsonResponse::HTTP_UNAUTHORIZED
);
}
$authRequest = AuthenticationRequest::refresh($refreshToken);
$response = $this->authManager->handle($authRequest);
if ($response->isFailed()) {
$httpResponse = new JsonResponse($response->toArray(), $response->httpStatus);
return $this->clearTokenCookies($httpResponse);
}
$httpResponse = new JsonResponse(['status' => 'success', 'message' => 'Token refreshed']);
if ($response->tokens && isset($response->tokens['access'])) {
$httpResponse->headers->setCookie(
Cookie::create('accessToken')
->withValue($response->tokens['access'])
->withExpires(time() + 900)
->withPath('/')
->withSecure($request->isSecure())
->withHttpOnly(true)
->withSameSite(Cookie::SAMESITE_STRICT)
);
}
return $httpResponse;
}
/**
* Logout current device
*/
#[AuthenticatedRoute('/auth/logout', name: 'auth.logout', methods: ['POST'])]
public function logout(Request $request): JsonResponse
{
$token = $request->cookies->get('accessToken');
$authRequest = AuthenticationRequest::logout($token, false);
$this->authManager->handle($authRequest);
$response = new JsonResponse(['status' => 'success', 'message' => 'Logged out successfully']);
return $this->clearTokenCookies($response);
}
/**
* Logout all devices
*/
#[AuthenticatedRoute('/auth/logout-all', name: 'auth.logout.all', methods: ['POST'])]
public function logoutAll(Request $request): JsonResponse
{
$token = $request->cookies->get('accessToken');
$authRequest = AuthenticationRequest::logout($token, true);
$this->authManager->handle($authRequest);
$response = new JsonResponse(['status' => 'success', 'message' => 'Logged out from all devices']);
return $this->clearTokenCookies($response);
}
// =========================================================================
// Response Helpers
// =========================================================================
/**
* Build JSON response from AuthenticationResponse
*/
private function buildJsonResponse(AuthenticationResponse $response): JsonResponse
{
$httpResponse = new JsonResponse($response->toArray(), $response->httpStatus);
// Set token cookies if present
if ($response->hasTokens()) {
return $this->setTokenCookies($httpResponse, $response->tokens, true);
}
return $httpResponse;
}
/**
* Set authentication token cookies
*/
private function setTokenCookies(JsonResponse|RedirectResponse $response, array $tokens, bool $secure = true): JsonResponse|RedirectResponse
{
if (isset($tokens['access'])) {
$response->headers->setCookie(
Cookie::create('accessToken')
->withValue($tokens['access'])
->withExpires(time() + 900)
->withPath('/')
->withSecure($secure)
->withHttpOnly(true)
->withSameSite(Cookie::SAMESITE_STRICT)
);
}
if (isset($tokens['refresh'])) {
$response->headers->setCookie(
Cookie::create('refreshToken')
->withValue($tokens['refresh'])
->withExpires(time() + 604800)
->withPath('/auth/refresh')
->withSecure($secure)
->withHttpOnly(true)
->withSameSite(Cookie::SAMESITE_STRICT)
);
}
return $response;
}
/**
* Clear authentication token cookies
*/
private function clearTokenCookies(JsonResponse $response): JsonResponse
{
$response->headers->clearCookie('accessToken', '/');
$response->headers->clearCookie('refreshToken', '/auth/refresh');
return $response;
}
/**
* Redirect with error message
*/
private function redirectWithError(string $error): RedirectResponse
{
return new RedirectResponse('/login?error=' . urlencode($error));
}
/**
* Get request data from JSON body or form data
*/
private function getRequestData(Request $request): array
{
$contentType = $request->headers->get('Content-Type', '');
if (str_contains($contentType, 'application/json')) {
try {
return $request->toArray();
} catch (\Throwable) {
return [];
}
}
return $request->request->all();
}
}

View File

@@ -0,0 +1,145 @@
<?php
namespace KTXC\Controllers;
use KTXC\Http\Response\Response;
use KTXC\Http\Response\FileResponse;
use KTXC\Http\Response\JsonResponse;
use KTXC\Http\Response\RedirectResponse;
use KTXC\Server;
use KTXF\Controller\ControllerAbstract;
use KTXF\Routing\Attributes\AnonymousRoute;
use KTXC\Service\SecurityService;
use KTXC\SessionIdentity;
use KTXC\SessionTenant;
use KTXC\Http\Request\Request;
class DefaultController extends ControllerAbstract
{
public function __construct(
private readonly SecurityService $securityService,
private readonly SessionTenant $tenant,
private readonly SessionIdentity $identity,
) {}
#[AnonymousRoute('/', name: 'root', methods: ['GET'])]
public function home(Request $request): Response
{
// If an authenticated identity is available, serve the private app
if ($this->identity->identifier()) {
return new FileResponse(
Server::runtimeRootLocation() . '/public/private.html',
Response::HTTP_OK,
['Content-Type' => 'text/html']
);
}
// User is not authenticated - serve the public app
// If there's an accessToken cookie present but invalid, clear it
$response = new FileResponse(
Server::runtimeRootLocation() . '/public/public.html',
Response::HTTP_OK,
['Content-Type' => 'text/html']
);
// Clear any stale auth cookies since the user is not authenticated
if ($request->cookies->has('accessToken')) {
$response->headers->clearCookie('accessToken', '/');
}
if ($request->cookies->has('refreshToken')) {
$response->headers->clearCookie('refreshToken', '/security/refresh');
}
return $response;
}
#[AnonymousRoute('/login', name: 'login', methods: ['GET'])]
public function login(): Response
{
return new FileResponse(
Server::runtimeRootLocation() . '/public/public.html',
Response::HTTP_OK,
['Content-Type' => 'text/html']
);
}
#[AnonymousRoute('/logout', name: 'logout_get', methods: ['GET'])]
public function logoutGet(Request $request): Response
{
// Blacklist the current access token if present
$accessToken = $request->cookies->get('accessToken');
if ($accessToken) {
$claims = $this->securityService->extractTokenClaims($accessToken);
if ($claims && isset($claims['jti'])) {
$this->securityService->logout($claims['jti'], $claims['exp'] ?? null);
}
}
$response = new RedirectResponse(
'/login',
Response::HTTP_SEE_OTHER
);
// Clear both authentication cookies
$response->headers->clearCookie('accessToken', '/');
$response->headers->clearCookie('refreshToken', '/security/refresh');
return $response;
}
#[AnonymousRoute('/logout', name: 'logout_post', methods: ['POST'])]
public function logoutPost(Request $request): Response
{
// Blacklist the current access token if present
$accessToken = $request->cookies->get('accessToken');
if ($accessToken) {
$claims = $this->securityService->extractTokenClaims($accessToken);
if ($claims && isset($claims['jti'])) {
$this->securityService->logout($claims['jti'], $claims['exp'] ?? null);
}
}
$response = new JsonResponse(['message' => 'Logged out successfully']);
// Clear both authentication cookies
$response->headers->clearCookie('accessToken', '/');
$response->headers->clearCookie('refreshToken', '/security/refresh');
return $response;
}
/**
* Catch-all route for SPA routing.
* Serves the appropriate HTML based on authentication status,
* allowing client-side routing to handle the actual path.
*/
#[AnonymousRoute('/{path}', name: 'spa_catchall', methods: ['GET'])]
public function catchAll(Request $request, string $path = ''): Response
{
// If an authenticated identity is available, serve the private app
if ($this->identity->identifier()) {
return new FileResponse(
Server::runtimeRootLocation() . '/public/private.html',
Response::HTTP_OK,
['Content-Type' => 'text/html']
);
}
// User is not authenticated - serve the public app
$response = new FileResponse(
Server::runtimeRootLocation() . '/public/public.html',
Response::HTTP_OK,
['Content-Type' => 'text/html']
);
// Clear any stale auth cookies since the user is not authenticated
if ($request->cookies->has('accessToken')) {
$response->headers->clearCookie('accessToken', '/');
}
if ($request->cookies->has('refreshToken')) {
$response->headers->clearCookie('refreshToken', '/security/refresh');
}
return $response;
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace KTXC\Controllers;
use KTXC\Http\Response\JsonResponse;
use KTXC\Module\ModuleManager;
use KTXF\Controller\ControllerAbstract;
use KTXC\SessionTenant;
use KTXF\Routing\Attributes\AuthenticatedRoute;
class InitController extends ControllerAbstract
{
public function __construct(
private readonly SessionTenant $tenant,
private readonly ModuleManager $moduleManager,
) {}
#[AuthenticatedRoute('/init', name: 'init', methods: ['GET'])]
public function index(): JsonResponse {
$configuration = [];
// modules
$configuration['modules'] = [];
foreach ($this->moduleManager->list() as $module) {
if (!method_exists($module, 'bootUi')) {
continue;
}
$configuration['modules'][$module->handle()] = $module->bootUi();
}
// tenant
$configuration['tenant'] = [
'id' => $this->tenant->identifier(),
'domain' => $this->tenant->domain(),
'label' => $this->tenant->label(),
];
return new JsonResponse($configuration);
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace KTXC\Controllers;
use KTXC\Module\ModuleManager;
use KTXF\Controller\ControllerAbstract;
use KTXF\Routing\Attributes\AuthenticatedRoute;
use KTXC\Http\Response\JsonResponse;
class ModuleController extends ControllerAbstract
{
public function __construct(
private readonly ModuleManager $moduleManager
) { }
#[AuthenticatedRoute('/modules/list', name: 'modules.index', methods: ['GET'])]
public function index(): JsonResponse
{
$modules = $this->moduleManager->list(false);
return new JsonResponse(['modules' => $modules]);
}
#[AuthenticatedRoute('/modules/manage', name: 'modules.manage', methods: ['POST'])]
public function manage(string $handle, string $action): JsonResponse
{
// Verify module exists
$moduleInstance = $this->moduleManager->moduleInstance($handle, null);
if (!$moduleInstance) {
return new JsonResponse(['error' => 'Module "' . $handle . '" not found.'], 404);
}
switch ($action) {
case 'install':
$this->moduleManager->install($handle);
return new JsonResponse(['message' => 'Module "' . $handle . '" installed successfully.']);
case 'uninstall':
$this->moduleManager->uninstall($handle);
return new JsonResponse(['message' => 'Module "' . $handle . '" uninstalled successfully.']);
case 'enable':
$this->moduleManager->enable($handle);
return new JsonResponse(['message' => 'Module "' . $handle . '" enabled successfully.']);
case 'disable':
$this->moduleManager->disable($handle);
return new JsonResponse(['message' => 'Module "' . $handle . '" disabled successfully.']);
case 'upgrade':
$this->moduleManager->upgrade($handle);
return new JsonResponse(['message' => 'Module "' . $handle . '" upgraded successfully.']);
default:
return new JsonResponse(['error' => 'Invalid action.'], 400);
}
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace KTXC\Controllers;
use KTXC\Http\Response\JsonResponse;
use KTXC\Service\UserService;
use KTXC\SessionIdentity;
use KTXC\SessionTenant;
use KTXF\Controller\ControllerAbstract;
use KTXF\Routing\Attributes\AuthenticatedRoute;
class UserSettingsController extends ControllerAbstract
{
public function __construct(
private readonly SessionTenant $tenantIdentity,
private readonly SessionIdentity $userIdentity,
private readonly UserService $userService
) {}
/**
* retrieve user settings
*
* @param array $settings list of settings to retrieve
*
* @example request body:
* {
* "settings": ["key1", "key2"]
* }
*/
#[AuthenticatedRoute('/user/settings/read', name: 'user.settings.read', methods: ['PUT', 'PATCH'])]
public function read(array $settings = []): JsonResponse
{
// authorize request
$tenantId = $this->tenantIdentity->identifier();
$userId = $this->userIdentity->identifier();
return $this->userService->fetchSettings($tenantId, $userId, $settings);
}
/**
* store user settings
*
* @param array $settings key-value pairs of settings to store
*
* @example request body:
* {
* "key1": "value1",
* "key2": "value2"
* }
*/
#[AuthenticatedRoute('/user/settings/write', name: 'user.settings.write', methods: ['PUT', 'PATCH'])]
public function write(array $settings): JsonResponse
{
return new JsonResponse(['status' => 'not_implemented'], JsonResponse::HTTP_NOT_IMPLEMENTED);
}
}