Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions backend/app/Http/Actions/Users/DeleteAccountAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php

declare(strict_types=1);

namespace HiEvents\Http\Actions\Users;

use HiEvents\Http\Actions\BaseAction;
use HiEvents\Http\Request\User\DeleteAccountRequest;
use HiEvents\Http\ResponseCodes;
use HiEvents\Models\User;
use HiEvents\Services\Domain\User\AccountDeletionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cookie;
use RuntimeException;

class DeleteAccountAction extends BaseAction
{
public function __construct(
private readonly AccountDeletionService $accountDeletionService,
)
{
}

public function __invoke(DeleteAccountRequest $request): JsonResponse
{
/** @var User|null $user */
$user = Auth::user();

if (!$user) {
return $this->errorResponse(__('Unauthenticated.'), ResponseCodes::HTTP_UNAUTHORIZED);
}

try {
$this->accountDeletionService->deleteUserAccount(
user: $user,
confirmationWord: (string)$request->validated('confirmation'),
password: (string)$request->validated('password'),
);
} catch (RuntimeException $e) {
$statusCode = $e->getCode();
$message = $e->getMessage() ?: __('Unable to delete account.');

// Validation-like errors
if ($statusCode === ResponseCodes::HTTP_UNPROCESSABLE_ENTITY) {
$errors = [];
if (str_contains(strtolower($message), 'delete')) {
$errors['confirmation'] = $message;
}
if (str_contains(strtolower($message), 'password')) {
$errors['password'] = $message;
}

return $this->errorResponse(
message: $message,
statusCode: ResponseCodes::HTTP_UNPROCESSABLE_ENTITY,
errors: $errors,
);
}

// Default to 409 conflict for ownership blocks or generic 400.
return $this->errorResponse(
message: $message,
statusCode: in_array($statusCode, [ResponseCodes::HTTP_CONFLICT, ResponseCodes::HTTP_FORBIDDEN], true)
? $statusCode
: ResponseCodes::HTTP_BAD_REQUEST,
);
}

Auth::logout();

return $this->jsonResponse([
'message' => __('Account deleted successfully.'),
], ResponseCodes::HTTP_OK)->withCookie(Cookie::forget('token'));
}
}
2 changes: 2 additions & 0 deletions backend/app/Http/Kernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use HiEvents\Http\Middleware\TrustProxies;
use HiEvents\Http\Middleware\ValidateSignature;
use HiEvents\Http\Middleware\VaporBinaryResponseMiddleware;
use HiEvents\Http\Middleware\EnsureFrontendOrigin;
use HiEvents\Http\Middleware\VerifyCsrfToken;
use Illuminate\Auth\Middleware\AuthenticateWithBasicAuth;
use Illuminate\Auth\Middleware\Authorize;
Expand Down Expand Up @@ -91,6 +92,7 @@ class Kernel extends HttpKernel
'can' => Authorize::class,
'guest' => RedirectIfAuthenticated::class,
'password.confirm' => RequirePassword::class,
'csrf.origin' => EnsureFrontendOrigin::class,
'signed' => ValidateSignature::class,
'throttle' => ThrottleRequests::class,
'verified' => EnsureEmailIsVerified::class,
Expand Down
47 changes: 47 additions & 0 deletions backend/app/Http/Middleware/EnsureFrontendOrigin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

namespace HiEvents\Http\Middleware;

use Closure;
use HiEvents\Http\ResponseCodes;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class EnsureFrontendOrigin
{
public function handle(Request $request, Closure $next): Response
{
// If the request is authenticated via Authorization header (bearer token),
// CSRF is not applicable because the browser cannot attach it cross-site.
$authorization = (string)$request->headers->get('Authorization');
$hasBearerToken = str_starts_with($authorization, 'Bearer ');

// If there's no auth cookie, treat it as non-cookie auth.
$hasAuthCookie = $request->cookies->has('token');

if ($hasBearerToken || !$hasAuthCookie) {
return $next($request);
}

// For cookie-authenticated state-changing requests, enforce same-origin.
if (in_array(strtoupper($request->method()), ['POST', 'PUT', 'PATCH', 'DELETE'], true)) {
$allowedOrigin = rtrim((string)config('app.frontend_url'), '/');

$origin = rtrim((string)$request->headers->get('Origin'), '/');
$referer = (string)$request->headers->get('Referer');

$originOk = $origin !== '' && $origin === $allowedOrigin;
$refererOk = $referer !== '' && str_starts_with($referer, $allowedOrigin . '/');

if (!$originOk && !$refererOk) {
return response()->json([
'message' => __('Invalid request origin.'),
], ResponseCodes::HTTP_FORBIDDEN);
}
}

return $next($request);
}
}
18 changes: 18 additions & 0 deletions backend/app/Http/Request/User/DeleteAccountRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace HiEvents\Http\Request\User;

use HiEvents\Http\Request\BaseRequest;

class DeleteAccountRequest extends BaseRequest
{
public function rules(): array
{
return [
'confirmation' => 'required|string|min:3',
'password' => 'required|string|min:8',
];
}
}
4 changes: 4 additions & 0 deletions backend/app/Providers/RouteServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ public function boot(): void
return Limit::perHour(20)->by($request->route('order_short_id') ?? $request->ip());
});

RateLimiter::for('delete-account', function (Request $request) {
return Limit::perHour(5)->by($request->user()?->id ?: $request->ip());
});

$this->routes(function () {
Route::middleware('api')
->group(base_path('routes/api.php'));
Expand Down
130 changes: 130 additions & 0 deletions backend/app/Services/Domain/User/AccountDeletionService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
<?php

declare(strict_types=1);

namespace HiEvents\Services\Domain\User;

use HiEvents\DomainObjects\Status\EventStatus;
use HiEvents\DomainObjects\Status\UserStatus;
use HiEvents\Http\ResponseCodes;
use HiEvents\Models\AccountUser;
use HiEvents\Models\Event;
use HiEvents\Models\User;
use Illuminate\Contracts\Hashing\Hasher;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Str;
use RuntimeException;

class AccountDeletionService
{
public function __construct(
private readonly DatabaseManager $databaseManager,
private readonly Hasher $hasher,
)
{
}

/**
* @throws RuntimeException
*/
public function deleteUserAccount(User $user, string $confirmationWord, string $password): void
{
$this->assertConfirmationWord($confirmationWord);
$this->assertPassword($user, $password);
$this->assertNoOrphanedAccounts($user);

$this->databaseManager->transaction(function () use ($user): void {
// Remove memberships/pivot rows.
AccountUser::query()
->where('user_id', $user->id)
->forceDelete();

// Scrub personal data while keeping NOT NULL constraints valid.
$anonEmail = sprintf('deleted+%d@%s', $user->id, 'example.invalid');

$user->forceFill([
'email' => $anonEmail,
'pending_email' => null,
'first_name' => 'Deleted',
'last_name' => null,
'timezone' => Config::get('app.default_timezone', 'UTC') ?: 'UTC',
'locale' => Config::get('app.locale', 'en'),
'email_verified_at' => null,
'remember_token' => null,
'marketing_opted_in_at' => null,
// Prevent future logins even if soft-deletes are bypassed.
'password' => $this->hasher->make(Str::random(64)),
])->save();

// Soft-delete to preserve FK integrity (audit logs etc.)
$user->delete();
});
}

private function assertConfirmationWord(string $confirmationWord): void
{
if (strtoupper(trim($confirmationWord)) !== 'DELETE') {
throw new RuntimeException(__('Please type DELETE to confirm account deletion.'), ResponseCodes::HTTP_UNPROCESSABLE_ENTITY);
}
}

private function assertPassword(User $user, string $password): void
{
if (!$this->hasher->check($password, (string)$user->password)) {
throw new RuntimeException(__('The provided password is incorrect.'), ResponseCodes::HTTP_UNPROCESSABLE_ENTITY);
}
}

private function assertNoOrphanedAccounts(User $user): void
{
$ownedAccountIds = AccountUser::query()
->where('user_id', $user->id)
->where('is_account_owner', true)
->whereNull('deleted_at')
->pluck('account_id')
->unique()
->values();

foreach ($ownedAccountIds as $accountId) {
$ownerCount = AccountUser::query()
->where('account_id', $accountId)
->where('is_account_owner', true)
->whereNull('deleted_at')
->count();

// If there are other owners, deletion won't orphan the account.
if ($ownerCount > 1) {
continue;
}

$otherMembersExist = AccountUser::query()
->where('account_id', $accountId)
->whereNull('deleted_at')
->where('user_id', '!=', $user->id)
->whereIn('status', [UserStatus::ACTIVE->name, UserStatus::INVITED->name])
->exists();

// Published = LIVE
$publishedEventsExist = Event::query()
->where('account_id', $accountId)
->where('status', EventStatus::LIVE->name)
->exists();

// Upcoming = LIVE + future start date
$upcomingEventsExist = Event::query()
->where('account_id', $accountId)
->where('status', EventStatus::LIVE->name)
->whereNotNull('start_date')
->where('start_date', '>', now())
->exists();

if ($otherMembersExist || $publishedEventsExist || $upcomingEventsExist) {
throw new RuntimeException(
__('Account deletion is blocked because you are the only owner of an organization that still has other members and/or published/upcoming events. Transfer ownership or delete the organization first.'),
ResponseCodes::HTTP_CONFLICT
);
}
}
}
}
5 changes: 5 additions & 0 deletions backend/routes/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@
use HiEvents\Http\Actions\Users\ResendInvitationAction;
use HiEvents\Http\Actions\Users\UpdateMeAction;
use HiEvents\Http\Actions\Users\UpdateUserAction;
use HiEvents\Http\Actions\Users\DeleteAccountAction;
use HiEvents\Http\Actions\Admin\Accounts\AssignConfigurationAction;
use HiEvents\Http\Actions\Admin\Accounts\GetAccountAction as GetAdminAccountAction;
use HiEvents\Http\Actions\Admin\Accounts\GetAllAccountsAction as GetAllAdminAccountsAction;
Expand Down Expand Up @@ -230,6 +231,10 @@ function (Router $router): void {
// Users
$router->get('/users/me', GetMeAction::class);
$router->put('/users/me', UpdateMeAction::class);

// Account deletion (current user)
$router->delete('/settings/account', DeleteAccountAction::class)
->middleware(['throttle:delete-account', 'csrf.origin']);
$router->post('/users', CreateUserAction::class);
$router->get('/users', GetUsersAction::class);
$router->get('/users/{user_id}', GetUserAction::class);
Expand Down
Loading
Loading