This repository has been archived by the owner on Feb 24, 2022. It is now read-only.
-
-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
Showing
16 changed files
with
448 additions
and
11 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
} | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.