M0: Skelett + Auth-Grundgerüst (Keycloak-OIDC + API-Token)
- Symfony 7.4 LTS, PHP 8.4, FrankenPHP, SQLite (3-Layer src/UI|Logic|Data) - Auth: manueller OidcClient (Authorization-Code) im UI-Layer, kein Security-Bundle/league; Session-Gate via SessionAuthSubscriber - API-Guard via statischem X-Api-Token (ApiTokenSubscriber); ExceptionSubscriber - UserEntity (Auto-Provision) + Init-Migration - Docker (php:8.4-frankenphp) + compose (essen.ljh31.de, traefik-net external) - SmokeTests (7) grün: Redirect-Fälle, API-Token, Auth-Render, Logout Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\UI\Http\Api;
|
||||
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
|
||||
/**
|
||||
* `X-Api-Token`-Guard für `/api/*`.
|
||||
*
|
||||
* Erwartet einen statischen Token aus der Umgebungsvariablen `API_TOKEN`
|
||||
* (siehe .env.dist). Bei Fehlerschlag → 401.
|
||||
*
|
||||
* Nur für stateless Consumer (z. B. Home Assistant). Für die Web-Session
|
||||
* ist die SessionAuthSubscriber zuständig.
|
||||
*/
|
||||
final class ApiTokenSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $expectedToken,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
RequestEvent::class => ['handle', 40],
|
||||
];
|
||||
}
|
||||
|
||||
public function handle(RequestEvent $event): void
|
||||
{
|
||||
if (true !== $event->isMainRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$request = $event->getRequest();
|
||||
if (!str_starts_with($request->getPathInfo(), '/api/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$provided = (string) $request->headers->get('X-Api-Token', '');
|
||||
|
||||
if ($this->expectedToken === null
|
||||
|| $this->expectedToken === ''
|
||||
|| !hash_equals($this->expectedToken, $provided)) {
|
||||
throw new UnauthorizedHttpException('api', 'API-Token fehlt oder ist ungültig.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\UI\Http\Auth;
|
||||
|
||||
use App\Data\Auth\Entity\UserEntity;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Session\SessionInterface;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
/**
|
||||
* Keycloak-OIDC-Flow (Authorization-Code):
|
||||
*
|
||||
* /login → /auth/callback (State + CSRF)
|
||||
* /auth/callback → Session-Set → /
|
||||
* /logout → Session-Löschen → /login
|
||||
*
|
||||
* Session-Keys: `auth_sub`, `auth_name`, `auth_email`
|
||||
* (für den `SessionAuthSubscriber`, der für protected routes prüft.)
|
||||
*/
|
||||
final class OidcAuthController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly OidcClient $oidc,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly UrlGeneratorInterface $router,
|
||||
) {
|
||||
}
|
||||
|
||||
private function session(Request $request): SessionInterface
|
||||
{
|
||||
return $request->getSession();
|
||||
}
|
||||
|
||||
/** GET /login → Keycloak-Auth-Endpoint */
|
||||
public function login(Request $request): RedirectResponse
|
||||
{
|
||||
$session = $this->session($request);
|
||||
$session->set('oidc_state', bin2hex(random_bytes(24)));
|
||||
|
||||
return new RedirectResponse(
|
||||
$this->oidc->authorizationEndpoint($session->get('oidc_state'))
|
||||
);
|
||||
}
|
||||
|
||||
/** GET /auth/callback?code=…&state=… — OIDC-Finalisierung + Auto-Provision */
|
||||
public function callback(Request $request): RedirectResponse
|
||||
{
|
||||
$session = $this->session($request);
|
||||
|
||||
$state = (string) $request->query->get('state', '');
|
||||
$code = (string) $request->query->get('code', '');
|
||||
$error = (string) $request->query->get('error', '');
|
||||
$sessionState = (string) $session->get('oidc_state', '');
|
||||
|
||||
if ($error !== '' || $state !== $sessionState || $state === '' || $code === '') {
|
||||
$session->remove('oidc_state');
|
||||
return new RedirectResponse($this->router->generate('login'));
|
||||
}
|
||||
$session->remove('oidc_state');
|
||||
|
||||
try {
|
||||
$tokens = $this->oidc->exchangeCode($code);
|
||||
$claims = $this->oidc->verifyIdToken($tokens['id_token']);
|
||||
} catch (\Throwable) {
|
||||
return new RedirectResponse($this->router->generate('login'));
|
||||
}
|
||||
|
||||
$sub = (string) ($claims['sub'] ?? '');
|
||||
$username = (string) ($claims['preferred_username'] ?? '');
|
||||
$email = (string) ($claims['email'] ?? '');
|
||||
|
||||
if ($sub === '' || $email === '') {
|
||||
return new RedirectResponse($this->router->generate('login'));
|
||||
}
|
||||
|
||||
// User-Zeile persistieren (Auto-Provision, für spätere Features)
|
||||
$user = $this->em
|
||||
->getRepository(UserEntity::class)
|
||||
->findOneBy(['kcSub' => $sub]);
|
||||
if ($user === null) {
|
||||
$user = new UserEntity();
|
||||
$user->setKcSub($sub);
|
||||
$this->em->persist($user);
|
||||
}
|
||||
$user->setUsername($username === '' ? $email : $username);
|
||||
$user->setEmail($email);
|
||||
$user->setUpdatedAm(new \DateTimeImmutable());
|
||||
$this->em->flush();
|
||||
|
||||
$session->set('auth_sub', $sub);
|
||||
$session->set('auth_name', $username === '' ? $email : $username);
|
||||
$session->set('auth_email', $email);
|
||||
|
||||
return new RedirectResponse($this->router->generate('home'));
|
||||
}
|
||||
|
||||
/** GET /logout → Session-Löschen → /login */
|
||||
public function logout(Request $request): RedirectResponse
|
||||
{
|
||||
$this->session($request)->invalidate();
|
||||
return new RedirectResponse($this->router->generate('login'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\UI\Http\Auth;
|
||||
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
/**
|
||||
* Direkter OIDC-Client für Keycloak (realm-agnostisch – issuer reicht).
|
||||
*
|
||||
* Flow: authorization_code (Confidential-Client mit client_secret, PKCE optional aus).
|
||||
*/
|
||||
final class OidcClient
|
||||
{
|
||||
/** @var string[] */
|
||||
private array $scopes;
|
||||
private string $issuer;
|
||||
|
||||
public function __construct(
|
||||
private readonly HttpClientInterface $httpClient,
|
||||
string $issuer,
|
||||
private readonly string $clientId,
|
||||
private readonly ?string $clientSecret,
|
||||
private readonly string $redirectUri,
|
||||
/** @var string[] */
|
||||
array $scopes = ['openid', 'email', 'profile'],
|
||||
) {
|
||||
$this->issuer = rtrim($issuer, '/');
|
||||
$this->scopes = $scopes;
|
||||
}
|
||||
|
||||
public function issuer(): string { return $this->issuer; }
|
||||
public function redirectUri(): string { return $this->redirectUri; }
|
||||
public function clientId(): string { return $this->clientId; }
|
||||
|
||||
public function authorizationEndpoint(?string $state = null): string
|
||||
{
|
||||
$q = [
|
||||
'response_type' => 'code',
|
||||
'client_id' => $this->clientId,
|
||||
'redirect_uri' => $this->redirectUri,
|
||||
'scope' => implode(' ', $this->scopes),
|
||||
'prompt' => 'login',
|
||||
];
|
||||
if ($state !== null && $state !== '') {
|
||||
$q['state'] = $state;
|
||||
}
|
||||
return $this->issuer . '/protocol/openid-connect/auth?' . http_build_query($q);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $code Authorization-Code aus Keycloak-Redirect
|
||||
* @return array{
|
||||
* id_token: string,
|
||||
* access_token: string,
|
||||
* token_type: string
|
||||
* }
|
||||
* @throws \RuntimeException
|
||||
*/
|
||||
public function exchangeCode(string $code): array
|
||||
{
|
||||
$body = [
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $code,
|
||||
'redirect_uri' => $this->redirectUri,
|
||||
'client_id' => $this->clientId,
|
||||
];
|
||||
if ($this->clientSecret !== null && $this->clientSecret !== '') {
|
||||
$body['client_secret'] = $this->clientSecret;
|
||||
}
|
||||
|
||||
$resp = $this->httpClient->request('POST', $this->issuer . '/protocol/openid-connect/token', [
|
||||
'json' => $body,
|
||||
'timeout' => 10,
|
||||
]);
|
||||
$sc = $resp->getStatusCode();
|
||||
$content = $resp->getContent(false);
|
||||
if ($sc >= 400) {
|
||||
throw new \RuntimeException("Keycloak token-Request fehlgeschlagen (HTTP $sc): $content");
|
||||
}
|
||||
$data = json_decode($content, true);
|
||||
if (!is_array($data) || empty($data['id_token'])) {
|
||||
throw new \RuntimeException('Keycloak token-Response fehlte id_token.');
|
||||
}
|
||||
return [
|
||||
'id_token' => $data['id_token'],
|
||||
'access_token' => $data['access_token'] ?? '',
|
||||
'token_type' => $data['token_type'] ?? 'Bearer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifiziert ein IdToken (RS256) aus Keycloak:
|
||||
* - issuer === configured
|
||||
* - audience enthält clientId (oder fehlt)
|
||||
* - exp & nbf
|
||||
* - Signatur gegen Keycloak JWKS
|
||||
*
|
||||
* @return array<string, mixed> Claims
|
||||
* @throws \RuntimeException
|
||||
*/
|
||||
public function verifyIdToken(string $idToken): array
|
||||
{
|
||||
$parts = explode('.', trim($idToken));
|
||||
if (count($parts) !== 3) {
|
||||
throw new \RuntimeException('Ungültiges IdToken-Format.');
|
||||
}
|
||||
[$h64, $p64, $s64] = $parts;
|
||||
$header = $this->jwtB64ToJson($h64, 'header');
|
||||
$payload = $this->jwtB64ToJson($p64, 'payload');
|
||||
|
||||
if (($header['alg'] ?? null) !== 'RS256') {
|
||||
throw new \RuntimeException('Nur RS256-JWTs werden verifiziert.');
|
||||
}
|
||||
|
||||
if (($payload['iss'] ?? null) !== $this->issuer) {
|
||||
throw new \RuntimeException('IdToken-iss passt nicht zu issuer.');
|
||||
}
|
||||
$aud = $payload['aud'] ?? null;
|
||||
if (is_string($aud) && $aud !== $this->clientId && !in_array($this->clientId, [$aud], true)) {
|
||||
throw new \RuntimeException('IdToken aud enthält nicht clientId.');
|
||||
}
|
||||
if (is_array($aud) && !in_array($this->clientId, $aud, true)) {
|
||||
throw new \RuntimeException('IdToken aud enthält nicht clientId.');
|
||||
}
|
||||
|
||||
$now = time();
|
||||
if (isset($payload['exp']) && (int) $payload['exp'] < $now) {
|
||||
throw new \RuntimeException('IdToken abgelaufen.');
|
||||
}
|
||||
if (isset($payload['nbf']) && (int) $payload['nbf'] > $now + 60) {
|
||||
throw new \RuntimeException('IdToken nbf in der Zukunft.');
|
||||
}
|
||||
|
||||
// --- Signatur ---
|
||||
$jwk = $this->findJwkForKid($header['kid'] ?? null);
|
||||
$pem = $this->buildRs256PemFromJwk($jwk);
|
||||
|
||||
if (openssl_verify("$h64.$p64", $this->b64urlDecode($s64), $pem, OPENSSL_ALGO_SHA256) !== 1) {
|
||||
throw new \RuntimeException('IdToken-Signatur ungültig.');
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function jwtB64ToJson(string $b64url, string $part): array
|
||||
{
|
||||
$json = json_decode($this->b64urlDecode($b64url), true);
|
||||
if (!is_array($json)) {
|
||||
throw new \RuntimeException("IdToken-$part war kein JSON.");
|
||||
}
|
||||
return $json;
|
||||
}
|
||||
|
||||
private function b64urlDecode(string $b64url): string
|
||||
{
|
||||
$b64url = strtr($b64url, '-_', '+/');
|
||||
switch (strlen($b64url) % 4) {
|
||||
case 2: $b64url .= '=='; break;
|
||||
case 3: $b64url .= '='; break;
|
||||
default: break;
|
||||
}
|
||||
$raw = base64_decode($b64url, true);
|
||||
if ($raw === false) {
|
||||
throw new \RuntimeException('IdToken b64url decodefehler.');
|
||||
}
|
||||
return $raw;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
private function findJwkForKid(?string $kid): ?array
|
||||
{
|
||||
$resp = $this->httpClient->request('GET', $this->issuer . '/protocol/openid-connect/certs', ['timeout' => 10]);
|
||||
$data = $resp->toArray(false);
|
||||
$keys = $data['keys'] ?? null;
|
||||
if (!is_array($keys) || $keys === []) {
|
||||
throw new \RuntimeException('Keycloak JWKS leer.');
|
||||
}
|
||||
foreach ($keys as $key) {
|
||||
if (!is_array($key)) continue;
|
||||
if ($kid !== null && ($key['kid'] ?? null) !== $kid) continue;
|
||||
if (($key['alg'] ?? null) === 'RS256') {
|
||||
return $key;
|
||||
}
|
||||
}
|
||||
throw new \RuntimeException('Kein passender JWKS-RS256-Key gefunden.');
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $jwk */
|
||||
private function buildRs256PemFromJwk(array $jwk): string
|
||||
{
|
||||
if (($jwk['kty'] ?? null) !== 'RSA') {
|
||||
throw new \RuntimeException('JWKS-Key ist kein RSA.');
|
||||
}
|
||||
$n = $this->b64urlDecode($jwk['n']);
|
||||
$e = $this->b64urlDecode($jwk['e']);
|
||||
|
||||
$mod = $this->derInteger($n);
|
||||
$exp = $this->derInteger($e);
|
||||
$rsakey =
|
||||
"\x30" . $this->derLen(strlen($mod) + strlen($exp))
|
||||
. "\x02" . $this->derLen(strlen($mod)) . $mod
|
||||
. "\x02" . $this->derLen(strlen($exp)) . $exp;
|
||||
|
||||
// rsaEncryption OID 1.2.840.113549.1.1.1 → DER: 2A 86 48 86 F7 0D 01 01 01
|
||||
$algId = "\x06\x09\x2a\x86\x48\x86\xf7\x0d\x01\x01\x01";
|
||||
$algSeq = "\x30\x0d" . $algId . "\x05\x00";
|
||||
$bitstring = "\x03" . $this->derLen(strlen($rsakey) + 1) . "\x00" . $rsakey;
|
||||
$spki = "\x30" . $this->derLen(strlen($algSeq) + strlen($bitstring)) . $algSeq . $bitstring;
|
||||
|
||||
$pem = "-----BEGIN PUBLIC KEY-----\n"
|
||||
. chunk_split(base64_encode($spki), 64, "\n")
|
||||
. "-----END PUBLIC KEY-----\n";
|
||||
return $pem;
|
||||
}
|
||||
|
||||
private function derInteger(string $data): string
|
||||
{
|
||||
if ($data === '') throw new \RuntimeException('Mod/Exp leer.');
|
||||
if (ord($data[0]) & 0x80) {
|
||||
$data = "\x00" . $data;
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function derLen(int $len): string
|
||||
{
|
||||
if ($len < 128) {
|
||||
return chr($len);
|
||||
}
|
||||
$bytes = '';
|
||||
while ($len > 0) {
|
||||
$bytes = chr($len & 0xFF) . $bytes;
|
||||
$len >>= 8;
|
||||
}
|
||||
return chr(0x80 | strlen($bytes)) . $bytes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\UI\Http\Auth;
|
||||
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
/**
|
||||
* Session-Auth-Gate für die Front-App.
|
||||
*
|
||||
* Protected (default): `auth_sub` in der Session muss vorhanden sein,
|
||||
* sonst Redirect auf /login (bzw. 401-JSON für API-Requests – die
|
||||
* behandelt die ApiTokenSubscriber mit höherer Priorität).
|
||||
*
|
||||
* Public-Pfade werden übersprungen.
|
||||
*/
|
||||
final class SessionAuthSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
private const PUBLIC_PREFIXES = [
|
||||
'/login',
|
||||
'/auth/',
|
||||
'/logout',
|
||||
'/api/',
|
||||
'/css/',
|
||||
'/js/',
|
||||
'/images/',
|
||||
'/fonts/',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly UrlGeneratorInterface $router,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
RequestEvent::class => ['handle', 30],
|
||||
];
|
||||
}
|
||||
|
||||
public function handle(RequestEvent $event): void
|
||||
{
|
||||
if (true !== $event->isMainRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$request = $event->getRequest();
|
||||
foreach (self::PUBLIC_PREFIXES as $prefix) {
|
||||
if (str_starts_with($request->getPathInfo(), $prefix)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if ('' === (string) $request->getSession()->get('auth_sub', '')) {
|
||||
$event->setResponse(
|
||||
new RedirectResponse(
|
||||
$this->router->generate('login', ['redirect' => $request->getPathInfo()])
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\UI\Http;
|
||||
|
||||
use App\Logic\Common\Exception\DomainException;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
|
||||
|
||||
/**
|
||||
* Zentraler Exception-Subscriber.
|
||||
*
|
||||
* - DomainException → HTTP-Status gemäß getHttpStatusCode().
|
||||
* - HttpException → bei /api/* als JSON.
|
||||
* - Sonst → 500 (Details nur in dev).
|
||||
*/
|
||||
final class ExceptionSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly bool $debug = false,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
ExceptionEvent::class => ['handle', 200],
|
||||
];
|
||||
}
|
||||
|
||||
public function handle(ExceptionEvent $event): void
|
||||
{
|
||||
$e = $event->getThrowable();
|
||||
$request = $event->getRequest();
|
||||
$api = str_starts_with($request->getPathInfo(), '/api/');
|
||||
$wantsJson = $api || $request->headers->get('Accept') === 'application/json';
|
||||
|
||||
if ($e instanceof DomainException) {
|
||||
$event->setResponse(
|
||||
$this->render($request, $e->getHttpStatusCode(), $this->shortName(get_class($e)), $e->getMessage(), $wantsJson)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($e instanceof HttpExceptionInterface && $api) {
|
||||
$event->setResponse(
|
||||
$this->render($request, $e->getStatusCode(), $this->shortName(get_class($e)), $e->getMessage(), $wantsJson)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
$message = $this->debug ? sprintf('%s: %s', get_debug_type($e), $e->getMessage()) : 'Interner Serverfehler.';
|
||||
$event->setResponse($this->render($request, 500, 'Internal', $message, $wantsJson));
|
||||
}
|
||||
|
||||
private function shortName(string $class): string
|
||||
{
|
||||
$pos = strrpos($class, '\\');
|
||||
$name = $pos === false ? $class : substr($class, (int) $pos + 1);
|
||||
return $name;
|
||||
}
|
||||
|
||||
private function render(Request $request, int $status, string $name, string $message, bool $wantsJson): Response
|
||||
{
|
||||
$body = ['error' => $name, 'message' => $message];
|
||||
|
||||
return str_starts_with($request->getPathInfo(), '/api/') || $wantsJson
|
||||
? new JsonResponse($body, $status)
|
||||
: new Response($message, $status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\UI\Http;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
final class HomeController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* GET / — Hello-World-Statusseite (M0).
|
||||
*/
|
||||
public function index(Request $request, string $redirect = '/'): Response
|
||||
{
|
||||
$session = $request->getSession();
|
||||
$name = (string) $session->get('auth_name', '');
|
||||
$email = (string) $session->get('auth_email', '');
|
||||
|
||||
return $this->render('home/index.html.twig', [
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
'redirect' => $redirect === '' ? '/' : $redirect,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user