diff --git a/src/Entity/UserSystem/WebauthnKey.php b/src/Entity/UserSystem/WebauthnKey.php index 5a6b70010..abb77a966 100644 --- a/src/Entity/UserSystem/WebauthnKey.php +++ b/src/Entity/UserSystem/WebauthnKey.php @@ -52,7 +52,7 @@ class WebauthnKey extends BasePublicKeyCredentialSource implements TimeStampable #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)] protected ?\DateTimeInterface $last_time_used = null; - + public function getName(): string { return $this->name; diff --git a/src/Security/TwoFactor/WebauthnKeyLastUseTwoFactorProvider.php b/src/Security/TwoFactor/WebauthnKeyLastUseTwoFactorProvider.php new file mode 100644 index 000000000..5d67e36f9 --- /dev/null +++ b/src/Security/TwoFactor/WebauthnKeyLastUseTwoFactorProvider.php @@ -0,0 +1,106 @@ +<?php +/* + * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony). + * + * Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +declare(strict_types=1); + + +namespace App\Security\TwoFactor; + +use App\Entity\UserSystem\WebauthnKey; +use Doctrine\ORM\EntityManagerInterface; +use Jbtronics\TFAWebauthn\Services\UserPublicKeyCredentialSourceRepository; +use Jbtronics\TFAWebauthn\Services\WebauthnProvider; +use Scheb\TwoFactorBundle\Security\TwoFactor\AuthenticationContextInterface; +use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\TwoFactorFormRendererInterface; +use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\TwoFactorProviderInterface; +use Symfony\Component\DependencyInjection\Attribute\AsDecorator; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated; + +/** + * This class decorates the Webauthn TwoFactorProvider and adds additional logic which allows us to set a last used date + * on the used webauthn key, which can be viewed in the user settings. + */ +#[AsDecorator('jbtronics_webauthn_tfa.two_factor_provider')] +class WebauthnKeyLastUseTwoFactorProvider implements TwoFactorProviderInterface +{ + + public function __construct( + #[AutowireDecorated] + private TwoFactorProviderInterface $decorated, + private EntityManagerInterface $entityManager, + #[Autowire(service: 'jbtronics_webauthn_tfa.user_public_key_source_repo')] + private UserPublicKeyCredentialSourceRepository $publicKeyCredentialSourceRepository, + #[Autowire(service: 'jbtronics_webauthn_tfa.webauthn_provider')] + private WebauthnProvider $webauthnProvider, + ) + { + } + + public function beginAuthentication(AuthenticationContextInterface $context): bool + { + return $this->decorated->beginAuthentication($context); + } + + public function prepareAuthentication(object $user): void + { + $this->decorated->prepareAuthentication($user); + } + + public function validateAuthenticationCode(object $user, string $authenticationCode): bool + { + //Try to extract the used webauthn key from the code + $webauthnKey = $this->getWebauthnKeyFromCode($authenticationCode); + + //Perform the actual validation like normal + $tmp = $this->decorated->validateAuthenticationCode($user, $authenticationCode); + + //Update the last used date of the webauthn key, if the validation was successful + if($tmp && $webauthnKey !== null) { + $webauthnKey->updateLastTimeUsed(); + $this->entityManager->flush(); + } + + return $tmp; + } + + public function getFormRenderer(): TwoFactorFormRendererInterface + { + return $this->decorated->getFormRenderer(); + } + + private function getWebauthnKeyFromCode(string $authenticationCode): ?WebauthnKey + { + $publicKeyCredentialLoader = $this->webauthnProvider->getPublicKeyCredentialLoader(); + + //Try to load the public key credential from the code + $publicKeyCredential = $publicKeyCredentialLoader->load($authenticationCode); + + //Find the credential source for the given credential id + $publicKeyCredentialSource = $this->publicKeyCredentialSourceRepository->findOneByCredentialId($publicKeyCredential->rawId); + + //If the credential source is not an instance of WebauthnKey, return null + if(!($publicKeyCredentialSource instanceof WebauthnKey)) { + return null; + } + + return $publicKeyCredentialSource; + } +} \ No newline at end of file diff --git a/templates/helper.twig b/templates/helper.twig index f6de89d6a..b6cf2dbe0 100644 --- a/templates/helper.twig +++ b/templates/helper.twig @@ -230,4 +230,12 @@ {% endfor %} </tbody> </table> -{% endmacro parameters_table %} \ No newline at end of file +{% endmacro parameters_table %} + +{% macro format_date_nullable(datetime) %} + {% if datetime is null %} + <i>{% trans %}datetime.never{% endtrans %}</i> + {% else %} + {{ datetime|format_datetime }} + {% endif %} +{% endmacro %} \ No newline at end of file diff --git a/templates/users/_2fa_settings.html.twig b/templates/users/_2fa_settings.html.twig index 2b7f9c4c0..80392c175 100644 --- a/templates/users/_2fa_settings.html.twig +++ b/templates/users/_2fa_settings.html.twig @@ -1,5 +1,7 @@ {# @var user \App\Entity\UserSystem\User #} +{% import "helper.twig" as helper %} + <div class="card mt-4"> <div class="card-header"> <i class="fa fa-shield-alt fa-fw" aria-hidden="true"></i> @@ -124,6 +126,7 @@ <th>#</th> <th>{% trans %}tfa_u2f.keys.name{% endtrans %}</th> <th>{% trans %}tfa_u2f.keys.added_date{% endtrans %}</th> + <th>{% trans %}api_tokens.last_time_used{% endtrans %}</th> <th></th> </tr> </thead> @@ -133,6 +136,7 @@ <td>{{ loop.index }} <b>(U2F)</b></td> <td>{{ key.name }}</td> <td>{{ key.addedDate | format_datetime }}</td> + <td></td> {# For legacy keys no last time use date is saved #} <td><button type="submit" class="btn btn-danger btn-sm" name="key_id" value="{{ key.id }}"><i class="fas fa-trash-alt fa-fw"></i> {% trans %}tfa_u2f.key_delete{% endtrans %}</button></td> </tr> {% endfor %} @@ -141,6 +145,7 @@ <td>{{ loop.index }} <b>(WebAuthn)</b></td> <td>{{ key.name }}</td> <td>{{ key.addedDate | format_datetime }}</td> + <td>{{ helper.format_date_nullable(key.lastTimeUsed) }}</td> <td><button type="submit" class="btn btn-danger btn-sm" name="webauthn_key_id" value="{{ key.id }}"><i class="fas fa-trash-alt fa-fw"></i> {% trans %}tfa_u2f.key_delete{% endtrans %}</button></td> </tr> {% endfor %} diff --git a/templates/users/_api_tokens.html.twig b/templates/users/_api_tokens.html.twig index de8771dbe..4c7c83e8a 100644 --- a/templates/users/_api_tokens.html.twig +++ b/templates/users/_api_tokens.html.twig @@ -1,12 +1,6 @@ {# @var user \App\Entity\UserSystem\User #} -{% macro format_date(datetime) %} - {% if datetime is null %} - <i>{% trans %}datetime.never{% endtrans %}</i> - {% else %} - {{ datetime|format_datetime }} - {% endif %} -{% endmacro %} +{% import "helper.twig" as helper %} <div class="card mt-4"> <div class="card-header"> @@ -48,15 +42,15 @@ <small class="text-muted">{% trans%}api_token.ends_with{% endtrans%} ...<i>{{ api_token.lastTokenChars }}</i></small></td> <td>{{ api_token.level.translationKey|trans }}</td> <td> - {{ _self.format_date(api_token.validUntil) }} + {{ helper.format_date_nullable(api_token.validUntil) }} {% if api_token.valid %} <span class="badge bg-success badge-success">{% trans %}api_token.valid{% endtrans %}</span> {% else %} <span class="badge bg-warning badge-warning">{% trans %}api_token.expired{% endtrans %}</span> {% endif %} </td> - <td>{{ _self.format_date(api_token.addedDate) }}</td> - <td>{{ _self.format_date(api_token.lastTimeUsed) }}</td> + <td>{{ helper.format_date_nullable(api_token.addedDate) }}</td> + <td>{{ helper.format_date_nullable(api_token.lastTimeUsed) }}</td> <td> <button type="submit" class="btn btn-danger btn-sm" name="token_id" value="{{ api_token.id }}" {% if not is_granted('@api.manage_tokens') %}disabled="disabled"{% endif %}>