Mark SAML users as so in database and disable local password changing then.

This commit is contained in:
Jan Böhmer 2023-02-21 00:29:50 +01:00
parent 78ec0f1ea3
commit 97c3b9002a
15 changed files with 1414 additions and 1264 deletions

View file

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use App\Migration\AbstractMultiPlatformMigration;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20230220221024 extends AbstractMultiPlatformMigration
{
public function getDescription(): string
{
return 'Added support for SAML/Keycloak';
}
public function mySQLUp(Schema $schema): void
{
$this->addSql('ALTER TABLE `users` ADD saml_user TINYINT(1) NOT NULL DEFAULT 0');
}
public function mySQLDown(Schema $schema): void
{
$this->addSql('ALTER TABLE `users` DROP saml_user');
}
public function sqLiteUp(Schema $schema): void
{
$this->addSql('ALTER TABLE users ADD saml_user BOOLEAN NOT NULL DEFAULT 0');
}
public function sqLiteDown(Schema $schema): void
{
$this->addSql('ALTER TABLE `users` DROP saml_user');
}
}

View file

@ -83,6 +83,10 @@ class UserSettingsController extends AbstractController
return new RuntimeException('This controller only works only for Part-DB User objects!');
}
if ($user->isSamlUser()) {
throw new RuntimeException('You can not remove U2F keys from SAML users!');
}
if (empty($user->getBackupCodes())) {
$this->addFlash('error', 'tfa_backup.no_codes_enabled');
@ -112,6 +116,10 @@ class UserSettingsController extends AbstractController
throw new RuntimeException('This controller only works only for Part-DB User objects!');
}
if ($user->isSamlUser()) {
throw new RuntimeException('You can not remove U2F keys from SAML users!');
}
if ($this->isCsrfTokenValid('delete'.$user->getId(), $request->request->get('_token'))) {
//Handle U2F key removal
if ($request->request->has('key_id')) {
@ -192,6 +200,10 @@ class UserSettingsController extends AbstractController
return new RuntimeException('This controller only works only for Part-DB User objects!');
}
if ($user->isSamlUser()) {
throw new RuntimeException('You can not remove U2F keys from SAML users!');
}
if ($this->isCsrfTokenValid('devices_reset'.$user->getId(), $request->request->get('_token'))) {
$user->invalidateTrustedDeviceTokens();
$entityManager->flush();
@ -281,14 +293,14 @@ class UserSettingsController extends AbstractController
])
->add('old_password', PasswordType::class, [
'label' => 'user.settings.pw_old.label',
'disabled' => $this->demo_mode,
'disabled' => $this->demo_mode || $user->isSamlUser(),
'attr' => [
'autocomplete' => 'current-password',
],
'constraints' => [new UserPassword()],
]) //This constraint checks, if the current user pw was inputted.
->add('new_password', RepeatedType::class, [
'disabled' => $this->demo_mode,
'disabled' => $this->demo_mode || $user->isSamlUser(),
'type' => PasswordType::class,
'first_options' => [
'label' => 'user.settings.pw_new.label',
@ -307,7 +319,10 @@ class UserSettingsController extends AbstractController
'max' => 128,
])],
])
->add('submit', SubmitType::class, ['label' => 'save'])
->add('submit', SubmitType::class, [
'label' => 'save',
'disabled' => $this->demo_mode || $user->isSamlUser(),
])
->getForm();
$pw_form->handleRequest($request);
@ -327,7 +342,9 @@ class UserSettingsController extends AbstractController
}
//Handle 2FA things
$google_form = $this->createForm(TFAGoogleSettingsType::class, $user);
$google_form = $this->createForm(TFAGoogleSettingsType::class, $user, [
'disabled' => $this->demo_mode || $user->isSamlUser(),
]);
$google_enabled = $user->isGoogleAuthenticatorEnabled();
if (!$google_enabled && !$form->isSubmitted()) {
$user->setGoogleAuthenticatorSecret($googleAuthenticator->generateSecret());
@ -335,7 +352,7 @@ class UserSettingsController extends AbstractController
}
$google_form->handleRequest($request);
if (!$this->demo_mode && $google_form->isSubmitted() && $google_form->isValid()) {
if (!$this->demo_mode && !$user->isSamlUser() && $google_form->isSubmitted() && $google_form->isValid()) {
if (!$google_enabled) {
//Save 2FA settings (save secrets)
$user->setGoogleAuthenticatorSecret($google_form->get('googleAuthenticatorSecret')->getData());
@ -369,7 +386,7 @@ class UserSettingsController extends AbstractController
])->getForm();
$backup_form->handleRequest($request);
if (!$this->demo_mode && $backup_form->isSubmitted() && $backup_form->isValid()) {
if (!$this->demo_mode && !$user->isSamlUser() && $backup_form->isSubmitted() && $backup_form->isValid()) {
$backupCodeManager->regenerateBackupCodes($user);
$em->flush();
$this->addFlash('success', 'user.settings.2fa.backup_codes.regenerated');

View file

@ -20,9 +20,11 @@
namespace App\Controller;
use App\Entity\UserSystem\User;
use App\Entity\UserSystem\WebauthnKey;
use Doctrine\ORM\EntityManagerInterface;
use Jbtronics\TFAWebauthn\Services\TFAWebauthnRegistrationHelper;
use RuntimeException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
@ -31,6 +33,13 @@ use function Symfony\Component\Translation\t;
class WebauthnKeyRegistrationController extends AbstractController
{
private bool $demo_mode;
public function __construct(bool $demo_mode)
{
$this->demo_mode = $demo_mode;
}
/**
* @Route("/webauthn/register", name="webauthn_register")
*/
@ -39,6 +48,20 @@ class WebauthnKeyRegistrationController extends AbstractController
//When user change its settings, he should be logged in fully.
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
if ($this->demo_mode) {
throw new RuntimeException('You can not do 2FA things in demo mode');
}
$user = $this->getUser();
if (!$user instanceof User) {
throw new RuntimeException('This controller only works only for Part-DB User objects!');
}
if ($user->isSamlUser()) {
throw new RuntimeException('You can not remove U2F keys from SAML users!');
}
//If form was submitted, check the auth response
if ($request->getMethod() === 'POST') {
$webauthnResponse = $request->request->get('_auth_code');

View file

@ -240,10 +240,16 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
/**
* @var DateTime the time until the password reset token is valid
* @ORM\Column(type="datetime", nullable=true)
* @ORM\Column(type="datetime", nullable=true, columnDefinition="DEFAULT NULL")
*/
protected $pw_reset_expires;
/**
* @var bool True if the user was created by a SAML provider (and therefore cannot change its password)
* @ORM\Column(type="boolean")
*/
protected bool $saml_user = false;
public function __construct()
{
parent::__construct();
@ -863,6 +869,28 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
$this->webauthn_keys->add($webauthnKey);
}
/**
* Returns true, if the user was created by the SAML authentication.
* @return bool
*/
public function isSamlUser(): bool
{
return $this->saml_user;
}
/**
* Sets the saml_user flag.
* @param bool $saml_user
* @return User
*/
public function setSamlUser(bool $saml_user): User
{
$this->saml_user = $saml_user;
return $this;
}
public function setSamlAttributes(array $attributes)
{
//When mail attribute exists, set it

View file

@ -65,7 +65,7 @@ class UserAdminForm extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options): void
{
/** @var AbstractStructuralDBElement $entity */
/** @var User $entity */
$entity = $options['data'];
$is_new = null === $entity->getID();
@ -164,7 +164,7 @@ class UserAdminForm extends AbstractType
'invalid_message' => 'password_must_match',
'required' => false,
'mapped' => false,
'disabled' => !$this->security->isGranted('set_password', $entity),
'disabled' => !$this->security->isGranted('set_password', $entity) || $entity->isSamlUser(),
'constraints' => [new Length([
'min' => 6,
'max' => 128,
@ -174,7 +174,7 @@ class UserAdminForm extends AbstractType
->add('need_pw_change', CheckboxType::class, [
'required' => false,
'label' => 'user.edit.needs_pw_change',
'disabled' => !$this->security->isGranted('set_password', $entity),
'disabled' => !$this->security->isGranted('set_password', $entity) || $entity->isSamlUser(),
])
->add('disabled', CheckboxType::class, [

View file

@ -57,7 +57,7 @@ class UserSettingsType extends AbstractType
$builder
->add('name', TextType::class, [
'label' => 'user.username.label',
'disabled' => !$this->security->isGranted('edit_username', $options['data']) || $this->demo_mode,
'disabled' => !$this->security->isGranted('edit_username', $options['data']) || $this->demo_mode || $options['data']->isSamlUser(),
])
->add('first_name', TextType::class, [
'required' => false,

View file

@ -45,10 +45,16 @@ class NamedDBElementRepository extends DBElementRepository
$node->setId($entity->getID());
$result[] = $node;
if ($entity instanceof User && $entity->isDisabled()) {
if ($entity instanceof User) {
if ($entity->isDisabled()) {
//If this is an user, then add a badge when it is disabled
$node->setIcon('fa-fw fa-treeview fa-solid fa-user-lock text-muted');
}
if ($entity->isSamlUser()) {
$node->setIcon('fa-fw fa-treeview fa-solid fa-house-user text-muted');
}
}
}
return $result;

View file

@ -32,6 +32,8 @@ class SamlUserFactory implements SamlUserFactoryInterface
$user->setName($username);
$user->setNeedPwChange(false);
$user->setPassword('$$SAML$$');
//This is a SAML user now!
$user->setSamlUser(true);
$user->setSamlAttributes($attributes);

View file

@ -33,6 +33,10 @@
</div>
<div class="tab-pane" id="password">
<div class="offset-3 mb-3 col-9">
<span class="badge badge-warning bg-warning"><i class="fa-solid fa-house-user"></i> {% trans %}user.saml_user{% endtrans %}</span>
</div>
{{ form_row(form.new_password) }}
{{ form_row(form.need_pw_change) }}
{{ form_row(form.disabled) }}

View file

@ -151,7 +151,9 @@
<p><b>{% trans %}tfa_u2f.no_keys_registered{% endtrans %}</b></p>
{% endif %}
{% if not user.samlUser %}
<a href="{{ path('webauthn_register') }}" class="btn btn-success" ><i class="fas fa-plus-square fa-fw"></i> {% trans %}tfa_u2f.add_new_key{% endtrans %}</a>
{% endif %}
</div>
<div class="tab-pane fade" id="tfa-trustedDevices" role="tabpanel" aria-labelledby="trustedDevices-tab-tab">
@ -163,7 +165,7 @@
<input type="hidden" name="_method" value="DELETE">
<input type="hidden" name="_token" value="{{ csrf_token('devices_reset' ~ user.id) }}">
<button class="btn btn-danger" type="submit">{% trans %}tfa_trustedDevices.invalidate.btn{% endtrans %}</button>
<button class="btn btn-danger" type="submit" {% if user.samlUser %}disabled{% endif %}>{% trans %}tfa_trustedDevices.invalidate.btn{% endtrans %}</button>
</form>
</div>

View file

@ -52,9 +52,16 @@
<div class="form-group row">
<label class="col-form-label col-md-4">{% trans %}group.label{% endtrans %}</label>
<div class="col-md-8">
<p class="form-control-plaintext">{{ user.group.fullPath }}</p>
<p class="form-control-plaintext">{{ user.group.fullPath ?? '' }}</p>
</div>
</div>
{% if user.samlUser %}
<div class="form-group row">
<div class="col-md-8 offset-md-4">
<span class="badge badge-primary bg-primary"><i class="fa-solid fa-house-user"></i> {% trans %}user.saml_user{% endtrans %}</span>
</div>
</div>
{% endif %}
</div>
</div>
</div>

View file

@ -52,6 +52,15 @@
{% block content %}
{{ parent() }}
{% if user.samlUser %}
<div class="alert alert-warning mt-3" role="alert">
<h4 class="alert-heading">{% trans %}user.saml_user{% endtrans %}</h4>
<p>
{% trans %}user.saml_user.pw_change_hint{% endtrans %}
</p>
</div>
{% endif %}
{% include "users/_2fa_settings.html.twig" %}
<div class="card mt-4">

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,7 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="en" trgLang="en">
<file id="security.en">
<unit id="aazoCks" name="user.login_error.user_disabled">
<segment state="translated">
<segment>
<source>user.login_error.user_disabled</source>
<target>Your account is disabled! Contact an administrator if you think this is wrong.</target>
</segment>

View file

@ -37,7 +37,7 @@
<note priority="1">Part-DB1\src\Entity\UserSystem\Group.php:0</note>
<note priority="1">Part-DB1\src\Entity\UserSystem\User.php:0</note>
</notes>
<segment state="translated">
<segment>
<source>part.master_attachment.must_be_picture</source>
<target>The preview attachment must be a valid picture!</target>
</segment>
@ -82,7 +82,7 @@
<note priority="1">src\Entity\StructuralDBElement.php:0</note>
<note priority="1">src\Entity\Supplier.php:0</note>
</notes>
<segment state="translated">
<segment>
<source>structural.entity.unique_name</source>
<target>An element with this name already exists on this level!</target>
</segment>
@ -102,7 +102,7 @@
<note category="file-source" priority="1">Part-DB1\src\Entity\Parameters\StorelocationParameter.php:0</note>
<note category="file-source" priority="1">Part-DB1\src\Entity\Parameters\SupplierParameter.php:0</note>
</notes>
<segment state="translated">
<segment>
<source>parameters.validator.min_lesser_typical</source>
<target>Value must be lesser or equal the the typical value ({{ compared_value }}).</target>
</segment>
@ -122,7 +122,7 @@
<note category="file-source" priority="1">Part-DB1\src\Entity\Parameters\StorelocationParameter.php:0</note>
<note category="file-source" priority="1">Part-DB1\src\Entity\Parameters\SupplierParameter.php:0</note>
</notes>
<segment state="translated">
<segment>
<source>parameters.validator.min_lesser_max</source>
<target>Value must be lesser than the maximum value ({{ compared_value }}).</target>
</segment>
@ -142,7 +142,7 @@
<note category="file-source" priority="1">Part-DB1\src\Entity\Parameters\StorelocationParameter.php:0</note>
<note category="file-source" priority="1">Part-DB1\src\Entity\Parameters\SupplierParameter.php:0</note>
</notes>
<segment state="translated">
<segment>
<source>parameters.validator.max_greater_typical</source>
<target>Value must be greater or equal than the typical value ({{ compared_value }}).</target>
</segment>
@ -152,7 +152,7 @@
<note category="file-source" priority="1">Part-DB1\src\Entity\UserSystem\User.php:0</note>
<note priority="1">Part-DB1\src\Entity\UserSystem\User.php:0</note>
</notes>
<segment state="translated">
<segment>
<source>validator.user.username_already_used</source>
<target>A user with this name is already exisiting</target>
</segment>
@ -162,7 +162,7 @@
<note category="file-source" priority="1">Part-DB1\src\Entity\UserSystem\User.php:0</note>
<note priority="1">Part-DB1\src\Entity\UserSystem\User.php:0</note>
</notes>
<segment state="translated">
<segment>
<source>user.invalid_username</source>
<target>The username must contain only letters, numbers, underscores, dots, pluses or minuses.</target>
</segment>
@ -171,7 +171,7 @@
<notes>
<note category="state" priority="1">obsolete</note>
</notes>
<segment state="translated">
<segment>
<source>validator.noneofitschild.self</source>
<target>An element can not be its own parent.</target>
</segment>
@ -180,121 +180,121 @@
<notes>
<note category="state" priority="1">obsolete</note>
</notes>
<segment state="translated">
<segment>
<source>validator.noneofitschild.children</source>
<target>You can not assign children element as parent (This would cause loops).</target>
</segment>
</unit>
<unit id="ayNr6QK" name="validator.select_valid_category">
<segment state="translated">
<segment>
<source>validator.select_valid_category</source>
<target>Please select a valid category!</target>
</segment>
</unit>
<unit id="6vIlN5q" name="validator.part_lot.only_existing">
<segment state="translated">
<segment>
<source>validator.part_lot.only_existing</source>
<target>Can not add new parts to this location as it is marked as "Only Existing"</target>
</segment>
</unit>
<unit id="3xoKOIS" name="validator.part_lot.location_full.no_increase">
<segment state="translated">
<segment>
<source>validator.part_lot.location_full.no_increase</source>
<target>Location is full. Amount can not be increased (new value must be smaller than {{ old_amount }}).</target>
</segment>
</unit>
<unit id="R6Ov4Yt" name="validator.part_lot.location_full">
<segment state="translated">
<segment>
<source>validator.part_lot.location_full</source>
<target>Location is full. Can not add new parts to it.</target>
</segment>
</unit>
<unit id="BNQk2e7" name="validator.part_lot.single_part">
<segment state="translated">
<segment>
<source>validator.part_lot.single_part</source>
<target>This location can only contain a single part and it is already full!</target>
</segment>
</unit>
<unit id="4gPskOG" name="validator.attachment.must_not_be_null">
<segment state="translated">
<segment>
<source>validator.attachment.must_not_be_null</source>
<target>You must select an attachment type!</target>
</segment>
</unit>
<unit id="cDDVrWT" name="validator.orderdetail.supplier_must_not_be_null">
<segment state="translated">
<segment>
<source>validator.orderdetail.supplier_must_not_be_null</source>
<target>You must select an supplier!</target>
</segment>
</unit>
<unit id="k5DDdB4" name="validator.measurement_unit.use_si_prefix_needs_unit">
<segment state="translated">
<segment>
<source>validator.measurement_unit.use_si_prefix_needs_unit</source>
<target>To enable SI prefixes, you have to set a unit symbol!</target>
</segment>
</unit>
<unit id="DuzIOCr" name="part.ipn.must_be_unique">
<segment state="translated">
<segment>
<source>part.ipn.must_be_unique</source>
<target>The internal part number must be unique. {{ value }} is already in use!</target>
</segment>
</unit>
<unit id="Z4Kuuo2" name="validator.project.bom_entry.name_or_part_needed">
<segment state="translated">
<segment>
<source>validator.project.bom_entry.name_or_part_needed</source>
<target>You have to choose a part for a part BOM entry or set a name for a non-part BOM entry.</target>
</segment>
</unit>
<unit id="WF_v4ih" name="project.bom_entry.name_already_in_bom">
<segment state="translated">
<segment>
<source>project.bom_entry.name_already_in_bom</source>
<target>There is already an BOM entry with this name!</target>
</segment>
</unit>
<unit id="5v4p85H" name="project.bom_entry.part_already_in_bom">
<segment state="translated">
<segment>
<source>project.bom_entry.part_already_in_bom</source>
<target>This part already exists in the BOM!</target>
</segment>
</unit>
<unit id="3lM32Tw" name="project.bom_entry.mountnames_quantity_mismatch">
<segment state="translated">
<segment>
<source>project.bom_entry.mountnames_quantity_mismatch</source>
<target>The number of mountnames has to match the BOMs quantity!</target>
</segment>
</unit>
<unit id="x47D5WT" name="project.bom_entry.can_not_add_own_builds_part">
<segment state="translated">
<segment>
<source>project.bom_entry.can_not_add_own_builds_part</source>
<target>You can not add a project's own builds part to the BOM.</target>
</segment>
</unit>
<unit id="2x2XDI_" name="project.bom_has_to_include_all_subelement_parts">
<segment state="translated">
<segment>
<source>project.bom_has_to_include_all_subelement_parts</source>
<target>The project BOM has to include all subprojects builds parts. Part %part_name% of project %project_name% missing!</target>
</segment>
</unit>
<unit id="U9b1EzD" name="project.bom_entry.price_not_allowed_on_parts">
<segment state="translated">
<segment>
<source>project.bom_entry.price_not_allowed_on_parts</source>
<target>Prices are not allowed on BOM entries associated with a part. Define the price on the part instead.</target>
</segment>
</unit>
<unit id="ID056SR" name="validator.project_build.lot_bigger_than_needed">
<segment state="translated">
<segment>
<source>validator.project_build.lot_bigger_than_needed</source>
<target>You have selected more quantity to withdraw than needed! Remove unnecessary quantity.</target>
</segment>
</unit>
<unit id="6hV5UqD" name="validator.project_build.lot_smaller_than_needed">
<segment state="translated">
<segment>
<source>validator.project_build.lot_smaller_than_needed</source>
<target>You have selected less quantity to withdraw than needed for the build! Add additional quantity.</target>
</segment>
</unit>
<unit id="G9ZKt.4" name="part.name.must_match_category_regex">
<segment state="translated">
<segment>
<source>part.name.must_match_category_regex</source>
<target>The part name does not match the regular expression stated by the category: %regex%</target>
</segment>