Skip to content
This repository has been archived by the owner on Feb 24, 2022. It is now read-only.

Commit

Permalink
Bootstrap Solution for Initial Superuser (#31)
Browse files Browse the repository at this point in the history
* Provide port for development outside of containers

* Use app configs per django recommendations

* Added user account log message

* Create bootstrapping for initial superuser

* Handle pre-migration state in check

* Load static is the new shit

* Coverage for admin creation

* CQIs

* Add keyword name for parameter

* Remove redundant keyword to make codacy shut up.

* Fix accidental rename
  • Loading branch information
mverteuil committed May 16, 2019
1 parent ec0d989 commit 36d67c2
Show file tree
Hide file tree
Showing 16 changed files with 448 additions and 11 deletions.
2 changes: 2 additions & 0 deletions .env.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ project: mig3
environment:
ALLOWED_HOSTS: "localhost"
DATABASE_URL: postgres://postgres:postgres@db:5432/postgres
DEBUG: 1
NODE_ENV: "development"
SECRET_KEY: IF_I_SEE_THIS_IN_PRODUCTION_YOURE_FIRED
SECRET_URL_SALT: YOU_BETTER_CHANGE_THIS
TEMPLATE_DIRS: ""
Binary file added assets/mig3-models.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ services:
volumes:
- postgres_data:/var/lib/postgresql/data/
ports:
- "5432:5432"
- 15432:5432
mig3:
build: .
command: ./runserver.sh
Expand Down
33 changes: 32 additions & 1 deletion mig3/accounts/apps.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,38 @@
from django.apps import AppConfig
import logging

from django.apps import AppConfig, apps
from django.conf import settings
from django.core import checks
from django.db import ProgrammingError
from django.urls import reverse

from accounts.utils import URLSignature

logger = logging.getLogger(__name__)


class AccountsConfig(AppConfig):
"""User and API consumer account and authentication details."""

name = "accounts"


@checks.register()
def check_for_missing_administrator_account(app_configs, **kwargs):
"""Check for an existing administrator account and provide a bootstrapping URL if missing."""
errors = []
user_model = apps.get_model("accounts", model_name="UserAccount")
try:
if not user_model.objects.filter(is_superuser=True).count() > 0:
host = "http://localhost:8000" if settings.DEBUG else "https://your-site.heroku.com"
create_admin_url = reverse("create_admin", kwargs={"secret": URLSignature.generate_signature()})
errors.append(
checks.Warning(
f"Please visit this URL and create your administrator account: {host}{create_admin_url}",
id="accounts.W001",
obj=user_model,
)
)
except ProgrammingError:
pass # Migrations have not run yet.
return errors
28 changes: 28 additions & 0 deletions mig3/accounts/forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from django.contrib.auth import get_user_model
from django.contrib.auth.forms import UserCreationForm
from django.core.exceptions import ValidationError


class CreateAdministratorForm(UserCreationForm):
"""Create administrator accounts."""

class Meta: # noqa: D106
model = get_user_model()
fields = ("email", "name", "password1", "password2")

def clean(self):
"""Ensure that administrator hasn't already been created."""
if get_user_model().objects.filter(is_superuser=True).exists():
raise ValidationError("Administrator already exists.")
return super().clean()

def save(self, commit: bool = True):
"""Create superuser with validated form data."""
return self._meta.model.objects.create_superuser(
**{
"email": self.cleaned_data["email"],
"name": self.cleaned_data["name"],
"password": self.cleaned_data["password1"],
"commit": commit,
}
)
4 changes: 3 additions & 1 deletion mig3/accounts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,11 @@ def _create_user(self, email: str, password: str, **extra_fields: dict) -> "User
if not email:
raise ValueError("Email is a required field.")
email = self.normalize_email(email)
commit = extra_fields.pop("commit", True)
user = self.model(email=email, **extra_fields)
user.set_password(password)
user.save(using=self._db)
if commit:
user.save(using=self._db)
return user

def create_user(self, email: str, password: str = None, **extra_fields: dict) -> "UserAccount":
Expand Down
165 changes: 165 additions & 0 deletions mig3/accounts/templates/create_administrator.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
<script type="application/javascript">
var password = document.getElementById("password"),
confirm_password = document.getElementById("confirm_password");

function validatePassword() {
if (password.value != confirm_password.value) {
confirm_password.setCustomValidity("Passwords Don't Match");
} else {
confirm_password.setCustomValidity("");
}
}

password.onchange = validatePassword;
confirm_password.onkeyup = validatePassword;
</script>
<style>
@import url(https://fonts.googleapis.com/css?family=Roboto:300);

.errorlist {
margin: 0;
padding: 1.5em 0;
list-style-type: none;
color: red;
font-weight: bold;
}

.create-admin-page {
width: 360px;
padding: 8% 0 0;
margin: auto;
}

.form {
position: relative;
z-index: 1;
background: #ffffff;
max-width: 360px;
margin: 0 auto 100px;
padding: 45px;
text-align: center;
box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.2),
0 5px 5px 0 rgba(0, 0, 0, 0.24);
}

.form input {
font-family: "Roboto", sans-serif;
outline: 0;
background: #f2f2f2;
width: 100%;
border: 0;
margin: 0 0 15px;
padding: 15px;
box-sizing: border-box;
font-size: 14px;
}

.form button {
font-family: "Roboto", sans-serif;
text-transform: uppercase;
outline: 0;
background: #4caf50;
width: 100%;
border: 0;
padding: 15px;
color: #ffffff;
font-size: 14px;
-webkit-transition: all 0.3 ease;
transition: all 0.3 ease;
cursor: pointer;
}

.form button:hover,
.form button:active,
.form button:focus {
background: #43a047;
}

.form .message {
margin: 15px 0 0;
color: #b3b3b3;
font-size: 12px;
}

.form .message a {
color: #4caf50;
text-decoration: none;
}

body {
background: darkred; /* fallback for old browsers */
/* prettier-ignore */
background-image: url({% static "mig3-logo.png" %});
background-position: top;
font-family: "Roboto", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
</style>
</head>
<body>
<div class="create-admin-page">
<div class="form">
<form
id="admin-form"
class="admin-form"
method="post"
action="{{ form_action }}"
>
<h1>✈ Create Administrator</h1>
{% csrf_token %}
<div>
<input
aria-label="Administrator Email Address"
name="email"
required
type="text"
placeholder="Administrator Email Address"
/>
{{ form.email.errors }}
</div>
<div>
<input
aria-label="Administrator Name"
name="name"
required
type="text"
placeholder="Administrator Name"
/>
{{ form.name.errors }}
</div>
<div>
<input
aria-label="Administrator Password"
name="password1"
required
type="password"
placeholder="Administrator Password"
/>
{{ form.password1.errors }}
</div>
<div>
<input
aria-label="Confirm Administrator Password"
name="password2"
required
type="password"
placeholder="Confirm Administrator Password"
/>
{{ form.password2.errors }}
</div>
{{ form.non_field_errors }}
<input
aria-label="Create Administrator"
type="submit"
value="Create Administrator"
/>
</form>
</div>
</div>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{% load staticfiles %}
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
Expand Down Expand Up @@ -76,7 +76,7 @@
}

body {
background: #76b852; /* fallback for old browsers */
background: darkred; /* fallback for old browsers */
/* prettier-ignore */
background-image: url({% static "mig3-logo.png" %});
background-position: top;
Expand Down
34 changes: 34 additions & 0 deletions mig3/accounts/tests/test_forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from .. import forms


def test_create_administrator(db):
"""Should create administrator with valid form data."""
password = "p4ssw0rd!"
form = forms.CreateAdministratorForm(
data={"email": "admin@example.com", "name": "Test Administrator", "password1": password, "password2": password}
)
assert form.is_valid(), form.errors
result = form.save()
assert result.email == "admin@example.com"
assert result.name == "Test Administrator"
assert result.check_password(password)


def test_create_administrator_with_weak_password(db):
"""Should require a strong password."""
password = "password"
form = forms.CreateAdministratorForm(
data={"email": "admin@example.com", "name": "Test Administrator", "password1": password, "password2": password}
)
assert not form.is_valid()
assert "This password is too common." in form.errors["password2"]


def test_create_administrator_with_existing(admin_user):
"""Should refuse to create administrator once one already exists."""
password = "p4ssw0rd!"
form = forms.CreateAdministratorForm(
data={"email": "admin@example.com", "name": "Test Administrator", "password1": password, "password2": password}
)
assert not form.is_valid()
assert "Administrator already exists." in form.non_field_errors()
73 changes: 73 additions & 0 deletions mig3/accounts/tests/test_url_signature.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
from unittest import mock

from django.core.signing import BadSignature, SignatureExpired, TimestampSigner

from accounts.utils import URLSignature


@mock.patch("accounts.utils.signing.TimestampSigner", autospec=TimestampSigner)
def test_signing_salt(patched_signer, settings):
"""Should apply salt from settings when signing values."""
settings.SECRET_URL_SALT = "TEST"
URLSignature.generate_signature()

assert patched_signer.call_count == 1, patched_signer.call_args_list
patched_signer.assert_called_once_with(salt=settings.SECRET_URL_SALT)


@mock.patch("accounts.utils.signing.TimestampSigner", autospec=TimestampSigner)
def test_signed_value(patched_signer):
"""Should sign argument value."""
URLSignature.generate_signature("EXPECTED")
patched_signer().sign.assert_called_once_with("EXPECTED")


@mock.patch("accounts.utils.signing.TimestampSigner", autospec=TimestampSigner)
def test_signed_result(patched_signer):
"""Should return signed value."""
result = URLSignature.generate_signature()
assert result is patched_signer().sign.return_value


@mock.patch("accounts.utils.signing.TimestampSigner", autospec=TimestampSigner)
def test_validating_salt(patched_signer, settings):
"""Should apply salt from settings when validating values."""
settings.SECRET_URL_SALT = "TEST"
URLSignature.validate_signature(mock.Mock(name="secret"))
patched_signer.assert_called_once_with(salt=settings.SECRET_URL_SALT)


@mock.patch("accounts.utils.signing.TimestampSigner", autospec=TimestampSigner)
def test_valid_signature_match(patched_signer):
"""Should confirm match of unsigned value and expected value."""
mock_secret = mock.Mock(name="secret")
result = URLSignature.validate_signature(mock_secret, value=patched_signer().unsign.return_value)
patched_signer().unsign.assert_called_once_with(mock_secret, max_age=None)
assert result is True


@mock.patch("accounts.utils.signing.TimestampSigner", autospec=TimestampSigner)
def test_bad_signature(patched_signer):
"""Should treat signature as invalid with a bad signature."""
patched_signer().unsign.side_effect = [BadSignature]
result = URLSignature.validate_signature("EXPECTED")
patched_signer().unsign.assert_called_once_with("EXPECTED", max_age=None)
assert result is False


@mock.patch("accounts.utils.signing.TimestampSigner", autospec=TimestampSigner)
def test_max_age_check(patched_signer):
"""Should use max age if caller requests it."""
mock_max_age = mock.MagicMock(name="max_age", autospec=int)
URLSignature.validate_signature("EXPECTED", max_age=mock_max_age)
patched_signer().unsign.assert_called_once_with("EXPECTED", max_age=mock_max_age)


@mock.patch("accounts.utils.signing.TimestampSigner", autospec=TimestampSigner)
def test_max_age_expired(patched_signer):
"""Should treat signature as invalid when expired."""
mock_max_age = mock.MagicMock(name="max_age", autospec=int)
patched_signer().unsign.side_effect = [SignatureExpired]
result = URLSignature.validate_signature("EXPECTED", max_age=mock_max_age)
patched_signer().unsign.assert_called_once_with("EXPECTED", max_age=mock_max_age)
assert result is False
Loading

0 comments on commit 36d67c2

Please sign in to comment.