Skip to content

Commit

Permalink
Merge pull request #278 from vtsykun/feat/users-token
Browse files Browse the repository at this point in the history
Allow to generate multiple credentials for API-only users
  • Loading branch information
vtsykun authored Sep 15, 2024
2 parents f6372f0 + 5db75e0 commit 01b20cd
Show file tree
Hide file tree
Showing 5 changed files with 51 additions and 18 deletions.
38 changes: 27 additions & 11 deletions src/Controller/UserController.php
Original file line number Diff line number Diff line change
Expand Up @@ -218,15 +218,16 @@ protected function handleUpdate(Request $request, User $user, $flashMessage)
}

#[Route('/profile/tokens', name: 'profile_list_tokens')]
public function tokenList(PatTokenManager $manager)
#[Route('/users/{name}/tokens', name: 'users_list_tokens')]
public function tokenList(PatTokenManager $manager, #[Vars(['name' => 'username'])] ?User $user = null)
{
$user = $this->getUser();
$user = $this->getPatTokenUser($user);
$tokens = $this->registry->getRepository(ApiToken::class)->findAllTokens($user);
foreach ($tokens as $token) {
$token->setAttributes($manager->getStats($token->getId()));
}

return $this->render('user/token_list.html.twig', ['tokens' => $tokens]);
return $this->render('user/token_list.html.twig', ['tokens' => $tokens, 'user' => $user]);
}

#[Route('/users/sessions/list', name: 'users_login_attempts_all')]
Expand Down Expand Up @@ -261,9 +262,11 @@ public function loginAttemptsAction(#[Vars(['name' => 'username'])] ?User $user
}

#[Route('/profile/tokens/{id}/delete', name: 'profile_remove_tokens', methods: ['POST'])]
public function tokenDelete(#[Vars] ApiToken $token)
#[Route('/users/{name}/tokens/{id}/delete', name: 'users_remove_tokens', methods: ['POST'])]
public function tokenDelete(#[Vars] ApiToken $token, #[Vars(['name' => 'username'])] ?User $user = null)
{
$user = $this->getUser();
$user = $this->getPatTokenUser($user);

$identifier = $token->getOwner() ? $token->getOwner()->getUserIdentifier() : $token->getUserIdentifier();
if ($identifier === $user->getUserIdentifier()) {
$this->getEM()->remove($token);
Expand All @@ -276,18 +279,27 @@ public function tokenDelete(#[Vars] ApiToken $token)
throw $this->createNotFoundException('Token not found');
}

protected function getPatTokenUser(?User $user = null): UserInterface
{
if (null !== $user && ($user->isAdmin() || !$this->isGranted('ROLE_ADMIN'))) {
throw $this->createAccessDeniedException();
}

return $user ?: $this->getUser();
}

#[Route('/profile/tokens/new', name: 'profile_add_tokens')]
public function tokenAdd(Request $request)
#[Route('/users/{name}/tokens/new', name: 'users_add_tokens')]
public function tokenAdd(Request $request, #[Vars(['name' => 'username'])] ?User $user = null)
{
$token = new ApiToken();
$form = $this->createForm(ApiTokenType::class, $token);
$form = $this->createForm(ApiTokenType::class, $token, ['user' => $user]);

$user = $this->getPatTokenUser($user);
if ($request->getMethod() === 'POST') {
$em = $this->registry->getManager();
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$user = $this->getUser();

if ($user instanceof User) {
$token->setOwner($user);
} else {
Expand All @@ -298,13 +310,17 @@ public function tokenAdd(Request $request)
$em->persist($token);
$em->flush();
$this->addFlash('success', 'Token was generated');
return new RedirectResponse($this->generateUrl('profile_list_tokens'));

return $user === $this->getUser() ?
new RedirectResponse($this->generateUrl('profile_list_tokens')) :
new RedirectResponse($this->generateUrl('users_list_tokens', ['name' => $user->getUserIdentifier()]));
}
}

return $this->render('user/token_add.html.twig', [
'form' => $form->createView(),
'entity' => $token
'entity' => $token,
'user' => $user,
]);
}

Expand Down
13 changes: 9 additions & 4 deletions src/Form/Type/ApiTokenType.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Packeton\Form\Type;

use Packeton\Entity\ApiToken;
use Packeton\Entity\User;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
Expand Down Expand Up @@ -45,7 +46,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void
'multiple' => true,
'expanded' => true,
'attr' => ['with_value' => true],
'choices' => array_flip($this->getScores())
'choices' => array_flip($this->getScores($options['user']))
]);

$builder->addEventListener(FormEvents::POST_SUBMIT, $this->postSubmit(...));
Expand All @@ -69,25 +70,29 @@ public function postSubmit(FormEvent $event): void
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefault('data_class', ApiToken::class);
$resolver->setDefault('user', null);
}

protected function getScores(): array
protected function getScores(?User $user = null): array
{
$asMaintainer = $user?->isMaintainer() || (null === $user && $this->checker->isGranted('ROLE_MAINTAINER'));
$asAdmin = $user?->isAdmin() || (null === $user && $this->checker->isGranted('ROLE_ADMIN'));

$base = [
'metadata' => 'Read composer packages.json metadata and ZIP archive access',
'mirror:all' => 'Full access to mirrored packages',
'mirror:read' => 'Read-only CI token to mirrored packages',
];

if ($this->checker->isGranted('ROLE_MAINTAINER')) {
if ($asMaintainer) {
$base += [
'webhooks' => 'Update packages webhook',
'feeds' => 'Atom/RSS feed releases',
'packages:read' => 'Read only access to packages API',
'packages:all' => 'Submit and read packages API',
];
}
if ($this->checker->isGranted('ROLE_ADMIN')) {
if ($asAdmin) {
$base += [
'users' => 'Access to user API',
'groups' => 'Access to groups API',
Expand Down
3 changes: 3 additions & 0 deletions src/Resolver/ControllerArgumentResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ public function resolve(Request $request, ArgumentMetadata $argument): iterable

foreach ($mapping as $varName => $value) {
if (empty($value)) {
if ($argument->hasDefaultValue()) {
return [$argument->getDefaultValue()];
}
throw new \UnexpectedValueException('Missing "'.$varName.'" in request attributes, cannot resolve $'.$argument->getName());
}
}
Expand Down
7 changes: 7 additions & 0 deletions templates/user/profile.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@
<div class="btn-group btn-group-xs">
<a href="{{ path('users_update', {name: user.userIdentifier}) }}" class="btn btn-primary">Edit</a>
</div>

{% if is_granted('ROLE_ADMIN') and user.admin == false %}
<div class="btn-group btn-group-xs">
<a href="{{ path('users_list_tokens', {name: user.userIdentifier}) }}" class="btn btn-success">Pat Tokens</a>
</div>
{% endif %}

<div class="btn-group btn-group-xs">
<form class="delete onsubmit-confirm action" action="{{ path('user_delete', {name: user.userIdentifier}) }}" method="post" >
{{ csrf_token_input('delete') }}
Expand Down
8 changes: 5 additions & 3 deletions templates/user/token_list.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@
{% block title %}Authentication Tokens{% endblock %}

{% block content %}
{% set is_profile = app.user.userIdentifier == user.userIdentifier %}

<section class="row">
<h2 class="title">Your Authentication Tokens</h2>
<h2 class="title">{% if is_profile %}Your {% else %} {{ user.userIdentifier|capitalize }} {% endif %} Authentication Tokens</h2>

<b style="font-size: 1.3em">All Tokens</b>
{% set csrfToken = csrf_token('actions') %}
<div style="float:right; margin: 20px">
<a class="btn btn-primary" href="{{ path('profile_add_tokens') }}">Add Token</a>
<a class="btn btn-primary" href="{{ is_profile ? path('profile_add_tokens') : path('users_add_tokens', {'name': user.userIdentifier}) }}">Add Token</a>
</div>
<table class="table table-mb table-bordered">
<thead style="background: #dddddd">
Expand Down Expand Up @@ -43,7 +45,7 @@
<a data-toggle="collapse" href="#tok{{ token.id }}" class="btn btn-primary">Show</a>
</div>
<div class="btn-group">
<form class="delete onsubmit-confirm action" action="{{ path('profile_remove_tokens', {'id': token.id }) }}" method="post" >
<form class="delete onsubmit-confirm action" action="{{ is_profile ? path('profile_remove_tokens', {'id': token.id }) : path('users_remove_tokens', {'id': token.id, 'name': user.userIdentifier }) }}" method="post" >
<input type="hidden" name="token" value="{{ csrfToken }}"/>
<button class="btn btn-danger" type="submit">Delete</button>
</form>
Expand Down

0 comments on commit 01b20cd

Please sign in to comment.