|
@@ -4,10 +4,12 @@ declare(strict_types=1);
|
|
|
|
|
|
|
|
namespace App\Controllers;
|
|
namespace App\Controllers;
|
|
|
|
|
|
|
|
|
|
+use App\Auth\LocalAdmin;
|
|
|
use App\Auth\OidcClient;
|
|
use App\Auth\OidcClient;
|
|
|
use App\Auth\SessionGuard;
|
|
use App\Auth\SessionGuard;
|
|
|
use App\Http\Request;
|
|
use App\Http\Request;
|
|
|
use App\Http\Response;
|
|
use App\Http\Response;
|
|
|
|
|
+use App\Http\View;
|
|
|
use App\Repositories\UserRepository;
|
|
use App\Repositories\UserRepository;
|
|
|
use App\Services\AuditLogger;
|
|
use App\Services\AuditLogger;
|
|
|
use PDO;
|
|
use PDO;
|
|
@@ -19,6 +21,7 @@ final class AuthController
|
|
|
private readonly PDO $pdo,
|
|
private readonly PDO $pdo,
|
|
|
private readonly UserRepository $users,
|
|
private readonly UserRepository $users,
|
|
|
private readonly AuditLogger $audit,
|
|
private readonly AuditLogger $audit,
|
|
|
|
|
+ private readonly View $view,
|
|
|
) {
|
|
) {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -170,6 +173,108 @@ final class AuthController
|
|
|
return Response::redirect('/');
|
|
return Response::redirect('/');
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /** GET /auth/local — render the local-admin login form. 404 when disabled. */
|
|
|
|
|
+ public function loginLocalForm(Request $req): Response
|
|
|
|
|
+ {
|
|
|
|
|
+ if (!LocalAdmin::isEnabled()) {
|
|
|
|
|
+ return Response::text('Not Found', 404);
|
|
|
|
|
+ }
|
|
|
|
|
+ SessionGuard::start();
|
|
|
|
|
+ $error = $req->queryString('error') === '1';
|
|
|
|
|
+ return Response::html($this->view->render('auth/local', [
|
|
|
|
|
+ 'title' => 'Local sign-in',
|
|
|
|
|
+ 'currentUser' => null,
|
|
|
|
|
+ 'csrfToken' => SessionGuard::csrfToken(),
|
|
|
|
|
+ 'email' => LocalAdmin::email(),
|
|
|
|
|
+ 'error' => $error,
|
|
|
|
|
+ ]));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** POST /auth/local — verify credentials, upsert user, start session. */
|
|
|
|
|
+ public function loginLocal(Request $req): Response
|
|
|
|
|
+ {
|
|
|
|
|
+ if (!LocalAdmin::isEnabled()) {
|
|
|
|
|
+ return Response::text('Not Found', 404);
|
|
|
|
|
+ }
|
|
|
|
|
+ SessionGuard::start();
|
|
|
|
|
+
|
|
|
|
|
+ if (!SessionGuard::verifyCsrf($req)) {
|
|
|
|
|
+ return Response::text('CSRF token invalid', 403);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $email = $req->postString('email');
|
|
|
|
|
+ $password = isset($req->post['password']) && is_scalar($req->post['password'])
|
|
|
|
|
+ ? (string) $req->post['password']
|
|
|
|
|
+ : '';
|
|
|
|
|
+
|
|
|
|
|
+ if (!LocalAdmin::verify($email, $password)) {
|
|
|
|
|
+ $this->logFailure($req, 'local_admin_credential_mismatch');
|
|
|
|
|
+ return Response::redirect('/auth/local?error=1');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $this->pdo->beginTransaction();
|
|
|
|
|
+ try {
|
|
|
|
|
+ $isFirstUser = $this->users->count() === 0;
|
|
|
|
|
+ $result = $this->users->upsertFromOidc(
|
|
|
|
|
+ oid: LocalAdmin::oid(),
|
|
|
|
|
+ email: LocalAdmin::email(),
|
|
|
|
|
+ name: LocalAdmin::displayName(),
|
|
|
|
|
+ promoteToAdmin: $isFirstUser,
|
|
|
|
|
+ forceAdmin: true,
|
|
|
|
|
+ );
|
|
|
|
|
+ $user = $result['user'];
|
|
|
|
|
+ $before = $result['before']?->toAuditSnapshot();
|
|
|
|
|
+
|
|
|
|
|
+ $action = $before === null ? 'CREATE' : 'UPDATE';
|
|
|
|
|
+ $this->audit->record(
|
|
|
|
|
+ action: $action,
|
|
|
|
|
+ entityType: 'user',
|
|
|
|
|
+ entityId: $user->id,
|
|
|
|
|
+ before: $before,
|
|
|
|
|
+ after: $user->toAuditSnapshot(),
|
|
|
|
|
+ userId: $user->id,
|
|
|
|
|
+ userEmail: $user->email,
|
|
|
|
|
+ ipAddress: $req->ip(),
|
|
|
|
|
+ userAgent: $req->userAgent(),
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ if ($isFirstUser) {
|
|
|
|
|
+ $this->audit->record(
|
|
|
|
|
+ action: 'BOOTSTRAP_ADMIN',
|
|
|
|
|
+ entityType: 'user',
|
|
|
|
|
+ entityId: $user->id,
|
|
|
|
|
+ before: null,
|
|
|
|
|
+ after: ['is_admin' => 1, 'via' => 'local'],
|
|
|
|
|
+ userId: $user->id,
|
|
|
|
|
+ userEmail: $user->email,
|
|
|
|
|
+ ipAddress: $req->ip(),
|
|
|
|
|
+ userAgent: $req->userAgent(),
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $this->audit->record(
|
|
|
|
|
+ action: 'LOGIN',
|
|
|
|
|
+ entityType: 'user',
|
|
|
|
|
+ entityId: $user->id,
|
|
|
|
|
+ before: null,
|
|
|
|
|
+ after: ['via' => 'local'],
|
|
|
|
|
+ userId: $user->id,
|
|
|
|
|
+ userEmail: $user->email,
|
|
|
|
|
+ ipAddress: $req->ip(),
|
|
|
|
|
+ userAgent: $req->userAgent(),
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ $this->pdo->commit();
|
|
|
|
|
+ } catch (Throwable $e) {
|
|
|
|
|
+ $this->pdo->rollBack();
|
|
|
|
|
+ $this->logFailure($req, 'local_admin_upsert_failed: ' . $e->getMessage());
|
|
|
|
|
+ return Response::redirect('/auth/local?error=1');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ SessionGuard::login($user);
|
|
|
|
|
+ return Response::redirect('/');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/** Write a LOGIN_FAILED audit row in its own tx; never throws. */
|
|
/** Write a LOGIN_FAILED audit row in its own tx; never throws. */
|
|
|
private function logFailure(Request $req, string $reason): void
|
|
private function logFailure(Request $req, string $reason): void
|
|
|
{
|
|
{
|