Initial Version
This commit is contained in:
361
core/lib/Controllers/AuthenticationController.php
Normal file
361
core/lib/Controllers/AuthenticationController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user