Skip to content

Commit

Permalink
#148 Create superuser on first access to the app (#399)
Browse files Browse the repository at this point in the history
* Add first time setup page

* Add first time setup check middleware

* Hide admin from normal users nav bar

* Fix tests

* Update Readme
  • Loading branch information
TobySuch authored Nov 10, 2024
1 parent 4a7e64d commit 558eebb
Show file tree
Hide file tree
Showing 9 changed files with 161 additions and 16 deletions.
12 changes: 3 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,7 @@ By default, the `docker-compose.yml` will use the latest image from GHCR. Howeve
docker compose -f docker/docker-compose.yml up
```

5. To create an admin user, run the following command and follow the prompts:

```
docker compose -f docker/docker-compose.yml exec web python manage.py createsuperuser
```

6. After creating the admin user, you will be able to log into the site using the credentials you entered.
5. Access the site on the configured port. You will be asked to setup an admin user when you first visit the site.

### Using Postgresql

Expand All @@ -80,7 +74,7 @@ These instructions are for setting up the project in development mode which may

1. Download or clone this repository.
2. Make a copy of the `.env EXAMPLE` file and name it `.env.dev`. In your new copy, make sure `DEBUG` is set to 1, and change any values that are set to `CHANGEME` to the appropriate values for your development environment.
3. In the .env.dev file, add values for the following variables: `DJANGO_SUPERUSER_EMAIL` and `DJANGO_SUPERUSER_PASSWORD`. These will be used to create an admin user when the containers are started. For example:
3. (Optional) In the .env.dev file, add values for the following variables: `DJANGO_SUPERUSER_EMAIL` and `DJANGO_SUPERUSER_PASSWORD`. These will be used to create an admin user when the containers are started. For example:

```
[email protected]
Expand All @@ -93,6 +87,6 @@ DJANGO_SUPERUSER_PASSWORD=CHANGEME
docker compose -f docker/dev/docker-compose.dev.yml up --build
```

4. Once the containers are running, you should be able to access the site in your web browser at `127.0.0.1:8000`. By default, the admin user will automatically be created and you will be able to log in using the credentials you entered in the .env.dev file.
4. Once the containers are running, you should be able to access the site in your web browser at `127.0.0.1:8000`. If you added environment variables for the superuser, you should be able to login with those credentials. Otherwise you will be prompted to create a super user every time to start up the server.

If you would like contribute to this project, please read the [contributing guidelines](CONTRIBUTING.md) for more information.
3 changes: 2 additions & 1 deletion shifter/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ gunicorn==23.0.0
psycopg==3.2.3
psycopg-binary==3.2.3
sqlparse==0.5.1
typing_extensions==4.12.2
typing_extensions==4.12.2
tblib==3.0.0
1 change: 1 addition & 0 deletions shifter/shifter/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"shifter_auth.middleware.ensure_first_time_setup_completed",
"shifter_auth.middleware.ensure_password_changed",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
Expand Down
21 changes: 21 additions & 0 deletions shifter/shifter_auth/middleware.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
from django.contrib.auth import get_user_model
from django.shortcuts import redirect
from django.urls import reverse


def is_first_time_setup_required():
User = get_user_model()
return User.objects.count() == 0


def ensure_password_changed(get_response):
CHANGE_PASSWORD_URL = "shifter_auth:settings"

Expand All @@ -21,3 +27,18 @@ def middleware(request):
return response

return middleware


def ensure_first_time_setup_completed(get_response):
FIRST_TIME_SETUP_URL = "shifter_auth:first-time-setup"

def middleware(request):
if is_first_time_setup_required() and request.path != reverse(
FIRST_TIME_SETUP_URL
):
return redirect(FIRST_TIME_SETUP_URL)

response = get_response(request)
return response

return middleware
43 changes: 43 additions & 0 deletions shifter/shifter_auth/templates/shifter_auth/setup.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{% extends 'base.html' %}

{% block title %}<title>First Time Setup | Shifter</title>{% endblock %}

{% block content %}
<div class="standard-page-width">
<div class="py-2">
<h1 class="title">First Time Setup</h1>
</div>
<div class="border-b-2 border-gray-400">
<p class="text-gray-800 pb-2">Welcome to Shifter!</p>
<p class="text-gray-800 pb-2">First you need to create an admin user. Admins are able to create accounts for other people, and can be used as a regular user.</p>
</div>
<div class="p-2">
<form class="space-y-1 p2" method="post">
{% csrf_token %}
{% if form.non_field_errors %}<div class="error-box">{{ form.non_field_errors }}</div>{% endif %}

<div class="flex">
<p class="text-gray-800">{{ form.email.label }}:</p>
{% if form.email.errors %}<div class="ml-2 error-box grow">{{ form.email.errors }}</div>{% endif %}
</div>
<input name="{{ form.email.html_name }}" class="w-full input-primary" placeholder="[email protected]">

<div class="flex">
<p class="text-gray-800">{{ form.password.label }}:</p>
{% if form.password.errors %}<div class="ml-2 error-box grow">{{ form.password.errors }}</div>{% endif %}
</div>
<input name="{{ form.password.html_name }}" type="password" class="w-full input-primary" placeholder="••••••••">

<div class="flex">
<p class="text-gray-800">{{ form.confirm_password.label }}:</p>
{% if form.confirm_password.errors %}<div class="ml-2 error-box grow">{{ form.confirm_password.errors }}</div>{% endif %}
</div>
<input name="{{ form.confirm_password.html_name }}" type="password" class="w-full input-primary" placeholder="••••••••">

<div class="flex justify-end py-2">
<input type="submit" value="Register User" class="btn-primary">
</div>
</form>
</div>
</div>
{% endblock %}
47 changes: 46 additions & 1 deletion shifter/shifter_auth/tests.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
from django.contrib.auth import get_user, get_user_model
from django.test import Client, TestCase
from django.test import Client, RequestFactory, TestCase
from django.urls import reverse

from shifter_auth.middleware import (
ensure_first_time_setup_completed,
is_first_time_setup_required,
)

TEST_USER_EMAIL = "[email protected]"
TEST_STAFF_USER_EMAIL = "[email protected]"
TEST_ADDITIONAL_USER_EMAIL = "[email protected]"
Expand Down Expand Up @@ -221,3 +226,43 @@ def test_new_user_not_staff(self):
self.assertEqual(
User.objects.filter(email=TEST_ADDITIONAL_USER_EMAIL).count(), 0
)


class FirstTimeSetupTest(TestCase):
def setUp(self):
self.factory = RequestFactory()

def test_is_first_time_setup_required_true(self):
User = get_user_model()
User.objects.all().delete()
self.assertTrue(is_first_time_setup_required())

def test_is_first_time_setup_required_false(self):
User = get_user_model()
User.objects.create_user(TEST_USER_EMAIL, TEST_USER_PASSWORD)
self.assertFalse(is_first_time_setup_required())

def test_ensure_first_time_setup_completed_redirect(self):
User = get_user_model()
User.objects.all().delete()

middleware = ensure_first_time_setup_completed(lambda request: None)
request = self.factory.get(reverse("shifter_files:index"))
response = middleware(request)
self.assertEqual(response.status_code, 302)
self.assertEqual(
response.url, reverse("shifter_auth:first-time-setup")
)

def test_ensure_first_time_setup_completed_no_redirect(self):
User = get_user_model()
User.objects.create_user(
TEST_ADDITIONAL_USER_EMAIL, TEST_USER_PASSWORD
)
middleware = ensure_first_time_setup_completed(lambda request: None)
request = self.factory.get(reverse("shifter_files:index"))
request.user = get_user_model().objects.create_user(
TEST_USER_EMAIL, TEST_USER_PASSWORD
)
response = middleware(request)
self.assertIsNone(response)
8 changes: 7 additions & 1 deletion shifter/shifter_auth/urls.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
from django.contrib.auth.views import LoginView
from django.urls import path

from .views import CreateNewUserView, SettingsView, logoutView
from .views import (
CreateNewUserView,
FirstTimeSetupView,
SettingsView,
logoutView,
)

app_name = "shifter_auth"
urlpatterns = [
Expand All @@ -16,4 +21,5 @@
path("logout", logoutView, name="logout"),
path("settings", SettingsView.as_view(), name="settings"),
path("new-user", CreateNewUserView.as_view(), name="create-new-user"),
path("setup", FirstTimeSetupView.as_view(), name="first-time-setup"),
]
36 changes: 35 additions & 1 deletion shifter/shifter_auth/views.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from django.contrib import messages
from django.contrib.auth import get_user_model, logout
from django.contrib.auth import get_user_model, login, logout
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.shortcuts import redirect
Expand All @@ -8,6 +8,7 @@
from django.views.generic.edit import FormView

from .forms import ChangePasswordForm, NewUserForm
from .middleware import is_first_time_setup_required


@require_POST
Expand Down Expand Up @@ -56,3 +57,36 @@ def form_valid(self, form):
)

return super().form_valid(form)


class FirstTimeSetupView(UserPassesTestMixin, FormView):
template_name = "shifter_auth/setup.html"
form_class = NewUserForm
success_url = reverse_lazy("shifter_files:index")
permission_denied_message = "First time setup has already been completed."

def test_func(self):
return is_first_time_setup_required()

def form_valid(self, form):
User = get_user_model()
email = form.cleaned_data["email"]
password = form.cleaned_data["password"]
user = User.objects.create_user(email, password)
user.change_password_on_login = False
user.is_staff = True
user.is_superuser = True
user.save()
messages.add_message(
self.request,
messages.INFO,
(
"Here you can upload one or multiple files. Once the files "
"have been uploaded, you will be given a link to share for "
"others to download."
),
)

login(self.request, user)

return super().form_valid(form)
6 changes: 3 additions & 3 deletions shifter/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -33,20 +33,20 @@
<a href="{% url 'shifter_auth:settings' %}" class="block py-2 px-4 rounded hover:bg-gray-100">Settings</a>
</li>
<li>
{% if user.is_staff %}
<button id="dropdownNavbarLink" data-dropdown-toggle="dropdownNavbar" class="flex block py-2 px-4 w-full md:w-auto rounded hover:bg-gray-100">Admin <svg class="ml-1 my-auto w-5 h-5" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"></path></svg></button>
<!-- Dropdown menu -->
<div id="dropdownNavbar" class="hidden z-10 w-44 font-normal bg-white rounded divide-y divide-gray-100 shadow" style="position: absolute; inset: 0px auto auto 0px; margin: 0px; transform: translate(0px, 10px);" data-popper-reference-hidden="" data-popper-escaped="" data-popper-placement="bottom">
<ul class="py-1 text-sm text-gray-700" aria-labelledby="dropdownLargeButton">
{% if user.is_staff %}
<li>
<a href="{% url 'shifter_auth:create-new-user' %}" class="block py-2 px-4 hover:bg-gray-100">Register New User</a>
</li>
<li>
<a href="{% url 'shifter_site_settings:site-settings' %}" class="block py-2 px-4 hover:bg-gray-100">Site Settings</a>
</li>
{% endif %}
</ul>
</div>
{% endif %}
</li>
<li>
<form method="post" action="{% url 'shifter_auth:logout' %}">
Expand All @@ -58,7 +58,7 @@
</ul>
</div>
{% else %}
{% if request.resolver_match.url_name != 'login' %}
{% if request.resolver_match.url_name != 'login' and request.resolver_match.url_name != 'first-time-setup' %}
<a href="{% url 'shifter_auth:login' %}" class="flex">
<span class="btn-primary">Sign In</span>
</a>
Expand Down

0 comments on commit 558eebb

Please sign in to comment.