Skip to content
Permalink
Browse files

Bootstrap Solution for Initial Superuser (#31)

* 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 36d67c259f3d75e0f18f6acc03fe9742c39a0e13
@@ -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 not shown.
@@ -6,7 +6,7 @@ services:
volumes:
- postgres_data:/var/lib/postgresql/data/
ports:
- "5432:5432"
- 15432:5432
mig3:
build: .
command: ./runserver.sh
@@ -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
@@ -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,
}
)
@@ -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":
@@ -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>
@@ -1,4 +1,4 @@
{% load staticfiles %}
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
@@ -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;
@@ -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()
@@ -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

0 comments on commit 36d67c2

Please sign in to comment.
You can’t perform that action at this time.