Skip to content

Commit

Permalink
Add a "create-user" management command #458 (#459)
Browse files Browse the repository at this point in the history
* Add a "create-user" management command #458

Signed-off-by: Thomas Druez <tdruez@nexb.com>

* Refine the stdout/stderr syntax on management commands #458

Signed-off-by: Thomas Druez <tdruez@nexb.com>

* Add password prompt for the create-user command #458

Signed-off-by: Thomas Druez <tdruez@nexb.com>

* Add documentation for the create-user command #458

Signed-off-by: Thomas Druez <tdruez@nexb.com>
  • Loading branch information
tdruez authored Jun 24, 2022
1 parent e391966 commit d8c911e
Show file tree
Hide file tree
Showing 17 changed files with 215 additions and 42 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ v31.0.0 (next)
Reference: https://tracker.debian.org/pkg/wait-for-it
https://github.com/nexB/scancode.io/issues/387

- Add a "create-user" management command to create new user with its API key.
https://github.com/nexB/scancode.io/issues/458

- Add a "tag" field on the CodebaseResource model.
The layer details are stored in this field in the "docker" pipeline.
https://github.com/nexB/scancode.io/issues/443
Expand Down
34 changes: 34 additions & 0 deletions docs/command-line-interface.rst
Original file line number Diff line number Diff line change
Expand Up @@ -238,3 +238,37 @@ Deletes a project and its related work directories.
Optional arguments:

- ``--no-input`` Does not prompt the user for input of any kind.


.. _cli_create_user:

`$ scanpipe create-user <username>`
-----------------------------------

.. note:: This command is to be used when ScanCode.io's authentication system
:ref:`scancodeio_settings_require_authentication` is enabled.

Creates a user and generates an API key for authentication.

You will be prompted for a password. After you enter one, the user will be created
immediately.

The API key for the new user account will be displayed on the terminal output.

.. code-block:: console
User <username> created with API key: abcdef123456
The API key can also be retrieved from the :guilabel:`Profile settings` menu in the UI.

.. warning::
Your API key is like a password and should be treated with the same care.

By default, this command will prompt for a password for the new user account.
When run non-interactively with the ``--no-input`` option, no password will be set,
and the user account will only be able to authenticate with the REST API using its
API key.

Optional arguments:

- ``--no-input`` Does not prompt the user for input of any kind.
6 changes: 3 additions & 3 deletions docs/rest-api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ local development setup.
Authentication
--------------

When the authentication setting is enabled on a ScanCode.io instance—disabled by
default—you will have to include an authentication token ``API key`` in the
Authorization HTTP header of each request.
When the authentication setting :ref:`scancodeio_settings_require_authentication`
is enabled on a ScanCode.io instance (disabled by default), you will have to include
an authentication token ``API key`` in the Authorization HTTP header of each request.

The key should be prefixed by the string literal "Token" with whitespace
separating the two strings. For example::
Expand Down
8 changes: 6 additions & 2 deletions docs/scancodeio-settings.rst
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,14 @@ The authentication system can be enable with this settings::

SCANCODEIO_REQUIRE_AUTHENTICATION=True

Once enabled, all the Web UI views and REST API endpoints will force theuser to login
Once enabled, all the Web UI views and REST API endpoints will force the user to login
to gain access.

See :ref:`rest_api_authentication` for ``API key`` system with the REST API.
A management command :ref:`cli_create_user` is available to create users and
generate their API key for authentication.

See :ref:`rest_api_authentication` for details on using the ``API key``
authentication system in the REST API.

.. _scancodeio_settings_workspace_location:

Expand Down
2 changes: 1 addition & 1 deletion scancodeio/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
"OPTIONS": {
"min_length": 14,
"min_length": env.int("SCANCODEIO_PASSWORD_MIN_LENGTH", default=12),
},
},
{
Expand Down
10 changes: 5 additions & 5 deletions scanpipe/management/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ def handle_input_files(self, inputs_files):
self.project.add_input_source(filename, source="uploaded", save=True)

msg = "File(s) copied to the project inputs directory:"
self.stdout.write(self.style.SUCCESS(msg))
self.stdout.write(msg, self.style.SUCCESS)
msg = "\n".join(["- " + filename for filename in copied])
self.stdout.write(msg)

Expand All @@ -176,14 +176,14 @@ def handle_input_urls(self, input_urls):
if downloads:
self.project.add_downloads(downloads)
msg = "File(s) downloaded to the project inputs directory:"
self.stdout.write(self.style.SUCCESS(msg))
self.stdout.write(msg, self.style.SUCCESS)
msg = "\n".join(["- " + downloaded.filename for downloaded in downloads])
self.stdout.write(msg)

if errors:
self.stdout.write(self.style.ERROR("Could not fetch URL(s):"))
msg = "\n".join(["- " + url for url in errors])
self.stdout.write(self.style.ERROR(msg))
msg = "Could not fetch URL(s):\n"
msg += "\n".join(["- " + url for url in errors])
self.stderr.write(msg)


def validate_input_files(file_locations):
Expand Down
2 changes: 1 addition & 1 deletion scanpipe/management/commands/add-pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,4 @@ def handle(self, *pipeline_names, **options):
self.project.add_pipeline(pipeline_name)

msg = "Pipeline(s) added to the project"
self.stdout.write(self.style.SUCCESS(msg))
self.stdout.write(msg, self.style.SUCCESS)
2 changes: 1 addition & 1 deletion scanpipe/management/commands/archive-project.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,4 @@ def handle(self, *inputs, **options):
raise CommandError(error)

msg = f"The {self.project} project has been archived."
self.stdout.write(self.style.SUCCESS(msg))
self.stdout.write(msg, self.style.SUCCESS)
2 changes: 1 addition & 1 deletion scanpipe/management/commands/create-project.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ def handle(self, *args, **options):

project.save()
msg = f"Project {name} created with work directory {project.work_directory}"
self.stdout.write(self.style.SUCCESS(msg))
self.stdout.write(msg, self.style.SUCCESS)

for pipeline_name in pipeline_names:
project.add_pipeline(pipeline_name)
Expand Down
117 changes: 117 additions & 0 deletions scanpipe/management/commands/create-user.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# SPDX-License-Identifier: Apache-2.0
#
# http://nexb.com and https://github.com/nexB/scancode.io
# The ScanCode.io software is licensed under the Apache License version 2.0.
# Data generated with ScanCode.io is provided as-is without warranties.
# ScanCode is a trademark of nexB Inc.
#
# You may not use this software except in compliance with the License.
# You may obtain a copy of the License at: http://apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software distributed
# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
# CONDITIONS OF ANY KIND, either express or implied. See the License for the
# specific language governing permissions and limitations under the License.
#
# Data Generated with ScanCode.io is provided on an "AS IS" BASIS, WITHOUT WARRANTIES
# OR CONDITIONS OF ANY KIND, either express or implied. No content created from
# ScanCode.io should be considered or used as legal advice. Consult an Attorney
# for any legal advice.
#
# ScanCode.io is a free software code scanning tool from nexB Inc. and others.
# Visit https://github.com/nexB/scancode.io for support and download.

import getpass

from django.contrib.auth import get_user_model
from django.contrib.auth.password_validation import validate_password
from django.core import exceptions
from django.core.management.base import BaseCommand
from django.core.management.base import CommandError

from rest_framework.authtoken.models import Token


class Command(BaseCommand):
help = "Create a user and generate an API key for authentication."
requires_migrations_checks = True

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.UserModel = get_user_model()
self.username_field = self.UserModel._meta.get_field(
self.UserModel.USERNAME_FIELD
)

def add_arguments(self, parser):
parser.add_argument("username", help="Specifies the username for the user.")
parser.add_argument(
"--no-input",
action="store_false",
dest="interactive",
help="Do not prompt the user for input of any kind.",
)

def handle(self, *args, **options):
username = options["username"]

error_msg = self._validate_username(username)
if error_msg:
raise CommandError(error_msg)

password = None
if options["interactive"]:
password = self.get_password_from_stdin(username)

user = self.UserModel._default_manager.create_user(username, password=password)
token, _ = Token._default_manager.get_or_create(user=user)

if options["verbosity"] >= 1:
msg = f"User {username} created with API key: {token.key}"
self.stdout.write(msg, self.style.SUCCESS)

def get_password_from_stdin(self, username):
# Validators, such as UserAttributeSimilarityValidator, depends on other user's
# fields data for password validation.
fake_user_data = {
self.UserModel.USERNAME_FIELD: username,
}

password = None
while password is None:
password1 = getpass.getpass()
password2 = getpass.getpass("Password (again): ")
if password1 != password2:
self.stderr.write("Error: Your passwords didn't match.")
continue
if password1.strip() == "":
self.stderr.write("Error: Blank passwords aren't allowed.")
continue
try:
validate_password(password2, self.UserModel(**fake_user_data))
except exceptions.ValidationError as err:
self.stderr.write("\n".join(err.messages))
response = input(
"Bypass password validation and create user anyway? [y/N]: "
)
if response.lower() != "y":
continue
password = password1

return password

def _validate_username(self, username):
"""
Validate username. If invalid, return a string error message.
"""
if self.username_field.unique:
try:
self.UserModel._default_manager.get_by_natural_key(username)
except self.UserModel.DoesNotExist:
pass
else:
return "Error: That username is already taken."

try:
self.username_field.clean(username, None)
except exceptions.ValidationError as e:
return "; ".join(e.messages)
2 changes: 1 addition & 1 deletion scanpipe/management/commands/delete-project.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,4 @@ def handle(self, *inputs, **options):
self.project.delete()

msg = f"All the {self.project} project data have been removed."
self.stdout.write(self.style.SUCCESS(msg))
self.stdout.write(msg, self.style.SUCCESS)
16 changes: 6 additions & 10 deletions scanpipe/management/commands/execute.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ def handle(self, *args, **options):

run.execute_task_async()
msg = f"{run.pipeline_name} added to the tasks queue for execution."
self.stdout.write(self.style.SUCCESS(msg))
self.stdout.write(msg, self.style.SUCCESS)
sys.exit(0)

self.stdout.write(f"Start the {run.pipeline_name} pipeline execution...")
Expand All @@ -68,20 +68,16 @@ def handle(self, *args, **options):
tasks.execute_pipeline_task(run.pk)
except KeyboardInterrupt:
run.set_task_stopped()
self.stderr.write(self.style.ERROR("Pipeline execution stopped."))
sys.exit(1)
raise CommandError("Pipeline execution stopped.")
except Exception as e:
run.set_task_ended(exitcode=1, output=str(e))
self.stderr.write(self.style.ERROR(e))
sys.exit(1)
raise CommandError(e)

run.refresh_from_db()

if run.task_succeeded:
msg = f"{run.pipeline_name} successfully executed on project {self.project}"
self.stdout.write(self.style.SUCCESS(msg))
self.stdout.write(msg, self.style.SUCCESS)
else:
msg = f"Error during {run.pipeline_name} execution:\n"
self.stderr.write(self.style.ERROR(msg))
self.stderr.write(run.task_output)
sys.exit(1)
msg = f"Error during {run.pipeline_name} execution:\n{run.task_output}"
raise CommandError(msg)
12 changes: 4 additions & 8 deletions scanpipe/management/commands/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,25 +87,21 @@ def add_arguments(self, parser):
def handle(self, *pipeline_names, **options):
if options["list"]:
for pipeline_name, pipeline_class in scanpipe_app.pipelines.items():
self.stdout.write("- " + self.style.SUCCESS(pipeline_name))
self.stdout.write("- " + pipeline_name, self.style.SUCCESS)
self.stdout.write(indent(pipeline_class.get_doc(), " "), ending="\n\n")
sys.exit(0)

if not is_graphviz_installed():
raise CommandError("Graphviz is not installed.")

if not pipeline_names:
self.stderr.write(
self.style.ERROR("The pipeline-names argument is required.")
)
sys.exit(1)
raise CommandError("The pipeline-names argument is required.")

outputs = []
for pipeline_name in pipeline_names:
pipeline_class = scanpipe_app.pipelines.get(pipeline_name)
if not pipeline_class:
self.stderr.write(self.style.ERROR(f"{pipeline_name} is not valid."))
sys.exit(1)
raise CommandError(f"{pipeline_name} is not valid.")

output_directory = options.get("output")
outputs.append(
Expand All @@ -114,7 +110,7 @@ def handle(self, *pipeline_names, **options):

separator = "\n - "
msg = f"Graph(s) generated:{separator}" + separator.join(outputs)
self.stdout.write(self.style.SUCCESS(msg))
self.stdout.write(msg, self.style.SUCCESS)

@staticmethod
def generate_graph_png(pipeline_name, pipeline_class, output_directory):
Expand Down
1 change: 0 additions & 1 deletion scanpipe/management/commands/list-project.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@

from scanpipe.filters import ProjectFilterSet
from scanpipe.management.commands import RunStatusCommandMixin
from scanpipe.models import Project


class Command(BaseCommand, RunStatusCommandMixin):
Expand Down
2 changes: 1 addition & 1 deletion scanpipe/management/commands/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,4 @@ def handle(self, *args, **options):

if isinstance(output_file, list):
output_file = "\n".join([str(path) for path in output_file])
self.stdout.write(self.style.SUCCESS(str(output_file)))
self.stdout.write(str(output_file), self.style.SUCCESS)
2 changes: 1 addition & 1 deletion scanpipe/management/commands/reset-project.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,4 @@ def handle(self, *inputs, **options):
f"All data, except inputs, for the {self.project} project have been "
f"removed."
)
self.stdout.write(self.style.SUCCESS(msg))
self.stdout.write(msg, self.style.SUCCESS)
Loading

0 comments on commit d8c911e

Please # to comment.