Skip to content

Commit

Permalink
[WIP] basic operations for user api
Browse files Browse the repository at this point in the history
TODO:
- Enable/disable TOTP
- (Re)set recovery token
- Recover password
- Unify error handling
- Unify request validation
- Testing
  • Loading branch information
y3n4 committed Apr 21, 2024
1 parent 3c32e37 commit 8ff53eb
Show file tree
Hide file tree
Showing 35 changed files with 853 additions and 59 deletions.
4 changes: 4 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,7 @@ JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=857ce6bfb8d058ca8f51421d40864305d96be4be4a30d75e843b2d13958e6fde
###< lexik/jwt-authentication-bundle ###

###> nelmio/cors-bundle ###
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
###< nelmio/cors-bundle ###
8 changes: 5 additions & 3 deletions config/packages/security.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ security:
pattern: ^/(_(profiler|error|wdt)|css|images|js)/
security: false
api-login:
pattern: ^/api/login
pattern: ^/api/user/login
stateless: false
json_login:
check_path: api_login
Expand Down Expand Up @@ -167,5 +167,7 @@ security:
- { path: "^/alias", roles: ROLE_USER, allow_if: "!is_granted('ROLE_SPAM')"}
- { path: "^/account", roles: ROLE_USER, allow_if: "!is_granted('ROLE_SPAM')"}
- { path: "^/admin", roles: ROLE_DOMAIN_ADMIN }
- { path: "^/api/login", roles: PUBLIC_ACCESS }
- { path: "^/api", roles: ROLE_USER }
- { path: "^/api/user/login", roles: PUBLIC_ACCESS }
- { path: "^/api/user/login/2fa", roles: IS_AUTHENTICATED_2FA_IN_PROGRESS }
- { path: "^/api/user/register", roles: PUBLIC_ACCESS }
- { path: "^/api/user/", roles: ROLE_USER }
114 changes: 114 additions & 0 deletions src/Controller/Api/AliasController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
<?php

namespace App\Controller\Api;

use App\Dto\PasswordDto;
use App\Dto\LocalpartDto;
use App\Entity\Alias;
use App\Handler\AliasHandler;
use App\Handler\DeleteHandler;
use App\Repository\AliasRepository;
use App\Exception\ValidationException;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;

class AliasController extends AbstractController
{
private readonly AliasRepository $aliasRepository;

public function __construct(
private readonly Security $security,
private readonly AliasHandler $aliasHandler,
private readonly DeleteHandler $deleteHandler,
private readonly EntityManagerInterface $manager,
) {
$this->aliasRepository = $manager->getRepository(Alias::class);
}

#[Route('/api/user/aliases', name: 'get_user_aliases', methods: ['GET'])]
public function getAliases(): JsonResponse
{
$user = $this->security->getUser();
if ($customAliases = $this->aliasRepository->findByUser($user, false, false)) {
$customAliasData = array_map(function (Alias $alias) {
return $alias->getSource();
}, $customAliases);
}
if ($randomAliases = $this->aliasRepository->findByUser($user, true, false)) {
$randomAliasData = array_map(function (Alias $alias) {
return $alias->getSource();
}, $randomAliases);
}
return $this->json(
[
'status' => 'success',
'customAliases' => $customAliasData,
'randomAliases' => $randomAliasData,
],
200
);
}

/**
* Creates random alias if request is empty ('{}'),
* Creates custom alias if request contains "localpart" key
*/
#[Route('/api/user/aliases', name: 'post_user_alias', methods: ['POST'])]
public function createRandomAlias(#[MapRequestPayload] LocalpartDto $dto): JsonResponse
{
$user = $this->security->getUser();
try {
$alias = $this->aliasHandler->create($user, $dto->localpart);
} catch (ValidationException $e) {
return $this->json([
'status' => 'failed',
'message' => 'email address is unavailable'
], 400);
}
if (!$alias) {
return $this->json([
'status' => 'failed',
'message' => 'limit reached'
], 400);
}
return $this->json([
'status' => 'success',
'alias' => $alias->getSource()
], 200);
}

/**
* Delegates password validation to PasswordDto
* Deletes random user-owned alias with source ${uid} if it exists
*/
#[Route('/api/user/aliases/{uid}', name: 'delete_user_alias', methods: ['DELETE'])]
public function getAlias(#[MapRequestPayload] PasswordDto $dto, string $uid): JsonResponse
{
$user = $this->security->getUser();
/**
* Return random aliases owned by authenticated user
* @var Alias $alias
*/
$alias = $this->aliasRepository->findOneByUserAndSource($user, $uid);
if ($alias === null) {
return $this->json([
'status' => 'failed',
'message' => 'forbidden'
], 400);
}
if (!$alias->isRandom()) {
return $this->json([
'status' => 'failed',
'message' => 'contact administrator for custom alias deletion'
], 400);
}
$this->deleteHandler->deleteAlias($alias);
$dto->erasePassword();
return $this->json(['status' => 'success'], 200);
}
}
Original file line number Diff line number Diff line change
@@ -1,30 +1,29 @@
<?php

namespace App\Controller;
namespace App\Controller\Api;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Scheb\TwoFactorBundle\Security\Authentication\Token\TwoFactorTokenInterface;

class ApiLoginController extends AbstractController
class LoginController extends AbstractController
{
#[Route('/api/login', name: 'api_login', methods: ['POST'])]
#[Route('/api/user/login', name: 'api_login', methods: ['POST'])]
public function apilogin()
{
}

#[Route('/api/login/2fa', name: 'api_login_2fa', methods: ['POST'])]
#[Route('/api/user/login/2fa', name: 'api_login_2fa', methods: ['POST'])]
public function apilogin2fa(TokenInterface $token): Response
{
// TODO: get this working
// TODO: should be handled by firewall?
if (!$token instanceof TwoFactorTokenInterface) {
$error = $this->createAccessDeniedException("User not in 2fa process");
$jsonResponse = new Response(json_encode($error), Response::HTTP_BAD_REQUEST);
$jsonResponse->headers->set('Content-Type', 'application/json');
return $jsonResponse;
}

}
}
10 changes: 10 additions & 0 deletions src/Controller/Api/RecoveryController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

namespace App\Controller\Api;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;

// TODO
class RecoveryController extends AbstractController
{
}
64 changes: 64 additions & 0 deletions src/Controller/Api/RegistrationController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

namespace App\Controller\Api;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
use App\Dto\RegisterRequestDto;
use App\Entity\User;
use App\Handler\RegistrationHandler;
use App\Form\Model\Registration;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;

class RegistrationController extends AbstractController
{
public function __construct(
private readonly RegistrationHandler $registrationHandler,
private readonly EntityManagerInterface $manager,
private readonly JWTTokenManagerInterface $jwtManager,
) {
}

#[Route('/api/user/register', name: 'post_user_register', methods: ['POST'])]
public function register(#[MapRequestPayload] RegisterRequestDto $dto): JsonResponse
{
if (!$this->registrationHandler->isRegistrationOpen()) {
return $this->json([
'status' => 'failed',
'message' => 'registration closed'
], 423);
}
// TODO Could be moved to Dto validation, but requires to initialize whole DTO class for validation with little benefit
if ($dto->newPassword !== $dto->newPasswordConfirm) {
return $this->json([
'status' => 'failed',
'message' => 'passwords do not match'
], 400);
}
$registration = new Registration();
$registration->setVoucher($dto->voucher);
$registration->setPlainPassword($dto->newPassword);
$registration->setEmail($dto->email);
$this->registrationHandler->handle($registration);
$dto->eraseNewPassword();

if (null === $user = $this->manager->getRepository(User::class)->findByEmail($registration->getEmail())) {
return $this->json([
'status' => 'failed',
'message' => 'unknown error when creating user'
], 500);
}
$recoveryToken = $user->getPlainRecoveryToken();
$jwtToken = $this->jwtManager->create($user);
$user->eraseCredentials();
return $this->json([
'message' => 'success',
'recoveryToken' => $recoveryToken,
'token' => $jwtToken,
], 200);
}
}
10 changes: 10 additions & 0 deletions src/Controller/Api/TotpController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

namespace App\Controller\Api;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;

// TODO
class TotpController extends AbstractController
{
}
86 changes: 86 additions & 0 deletions src/Controller/Api/UserController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php

namespace App\Controller\Api;

use App\Dto\PasswordDto;
use App\Dto\PasswordChangeDto;
use App\Entity\User;
use App\Handler\DeleteHandler;
use App\Helper\PasswordUpdater;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use App\Handler\MailCryptKeyHandler;
use Doctrine\ORM\EntityManagerInterface;

class UserController extends AbstractController
{
public function __construct(
private readonly Security $security,
private readonly DeleteHandler $deleteHandler,
private readonly PasswordUpdater $passwordUpdater,
private readonly MailCryptKeyHandler $mailCryptKeyHandler,
private readonly EntityManagerInterface $manager,
) {
}

#[Route('/api/user', name: 'get_user', methods: ['GET'])]
public function getSelf(): JsonResponse
{
/** @var User $user */
$user = $this->security->getUser();
return $this->json([
'status' => 'success',
'username' => $user->getEmail(),
'totp_enabled' => $user->isTotpAuthenticationEnabled(),
], 200);
}

#[Route('/api/user', name: 'patch_user', methods: ['PATCH'])]
public function patchSelf(#[MapRequestPayload] PasswordChangeDto $dto): JsonResponse
{
/** @var User $user */
$user = $this->security->getUser();
// TODO: move this to proper validator
if ($dto->newPassword !== $dto->newPasswordConfirm) {
return $this->json([
'status' => 'failed',
'message' => 'passwords do not match'
], 400);
}
if ($dto->newPassword === $dto->password) {
return $this->json([
'status' => 'failed',
'message' => 'new password and old password are same'
], 400);
}
$user->setPlainPassword($dto->newPassword);
$this->passwordUpdater->updatePassword($user);
// Reencrypt the MailCrypt key with new password
if ($user->hasMailCryptSecretBox()) {
$this->mailCryptKeyHandler->update($user, $dto->password);
}
$this->manager->flush();

$user->eraseCredentials();
$dto->eraseNewPassword();
$dto->erasePassword();
return $this->json(['status' => 'success'], 200);
}

/**
* Delegates password validation to PasswordDto
*/
#[Route('/api/user', name: 'delete_user', methods: ['DELETE'])]
public function deleteSelf(#[MapRequestPayload] PasswordDto $dto): JsonResponse
{
/** @var User $user */
$user = $this->security->getUser();
$this->security->logout();
$this->deleteHandler->deleteUser($user);
$dto->erasePassword();
return $this->json(['status' => 'success'], 200);
}
}
Loading

0 comments on commit 8ff53eb

Please sign in to comment.