-
Notifications
You must be signed in to change notification settings - Fork 9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Backend Security Audit fixes #352
Changes from 8 commits
b585295
3204c7e
ca03c46
cc77cd0
73b18a1
810ea86
6b17024
69aabe8
c7d7b0b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -16,4 +16,4 @@ class Meta: | |
abstract = True | ||
|
||
def __str__(self): | ||
return "ah yes" | ||
return "__str__ not defined for this model" | ||
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -24,7 +24,7 @@ class CustomUserAdmin(UserAdmin): | |
) | ||
}, | ||
), | ||
("Admin Options", {"classes": ("collapse",), "fields": ("is_staff", "groups")}), | ||
("Admin Options", {"classes": ("collapse",), "fields": ("is_active", "is_staff", "is_superuser", "groups")}), | ||
) | ||
add_fieldsets = ( | ||
( | ||
|
@@ -35,7 +35,15 @@ class CustomUserAdmin(UserAdmin): | |
}, | ||
), | ||
) | ||
list_display = ("email", "first_name", "last_name", "is_active", "is_staff", "is_superuser", "permissions") | ||
list_display = ( | ||
"email", | ||
"permissions", | ||
"is_active", | ||
"is_staff", | ||
"is_superuser", | ||
"first_name", | ||
"last_name", | ||
) | ||
list_display_links = ( | ||
"is_active", | ||
"email", | ||
|
@@ -57,7 +65,7 @@ class CustomUserAdmin(UserAdmin): | |
ordering = [] | ||
|
||
def permissions(self, obj): | ||
return ", ".join([g.name for g in obj.groups.all()]) | ||
return [g.name for g in obj.groups.all()] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @oudeismetis - I trust that you checked that this works? This is changing the return type from There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. correct. I pulled this from a project where I made the change. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Great! This is good to go then. |
||
|
||
class Media(AutocompleteAdminMedia): | ||
pass | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -11,11 +11,26 @@ | |
logger = logging.getLogger(__name__) | ||
|
||
|
||
class UserQuerySet(models.QuerySet): | ||
def for_user(self, user): | ||
if not user or user.is_anonymous: | ||
return self.none() | ||
elif user.is_staff: | ||
return self.all() | ||
return self.filter(pk=user.pk) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍🏻 |
||
|
||
|
||
class UserManager(BaseUserManager): | ||
"""Custom User model manager, eliminating the 'username' field.""" | ||
|
||
use_in_migrations = True | ||
|
||
def get_queryset(self): | ||
return UserQuerySet(self.model, using=self.db) | ||
|
||
def for_user(self, user): | ||
return self.get_queryset().for_user(user) | ||
|
||
def _create_user(self, email, password, **extra_fields): | ||
""" | ||
Create and save a User with the given email and password. | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,8 +1,8 @@ | ||
from rest_framework import permissions | ||
|
||
|
||
class CreateOnlyPermissions(permissions.BasePermission): | ||
def has_permission(self, request, view): | ||
if view.action == "create": | ||
return True | ||
return False | ||
class HasUserPermissions(permissions.BasePermission): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Q, is there a situation where There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should not happen ever There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Notice that the viewset where this is used also handles user creation requests. There are automated tests that cover that case. I do think I've had to handle some sort of none case in the past, but I haven't seen that in a long while. But should that bug arise it would be a small/quick/obvious fix There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think the Authentication Middleware always adds There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That tracks. Otherwise There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ah nvm this is used on the object level! |
||
"""Admins should be able to perform any action, regular users should be able to edit and delete self.""" | ||
|
||
def has_object_permission(self, request, view, obj): | ||
return request.user.is_authenticated and (request.user.is_staff or obj == request.user) |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -16,6 +16,7 @@ class Meta: | |
"last_name", | ||
"full_name", | ||
) | ||
read_only_fields = ["email"] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Default behavior shouldn't allow changing of a users email just by having a valid token. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @paribaker I know you had thoughts about this one |
||
|
||
|
||
class UserLoginSerializer(serializers.ModelSerializer): | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -4,6 +4,7 @@ | |
from django.contrib.auth import authenticate | ||
from django.test import override_settings | ||
from django.test.client import RequestFactory | ||
from rest_framework import status | ||
from rest_framework.response import Response | ||
|
||
from .models import User | ||
|
@@ -34,6 +35,13 @@ def test_create_user(): | |
assert not user.is_superuser | ||
|
||
|
||
@pytest.mark.django_db | ||
def test_create_user_api(api_client): | ||
data = {"email": "example@example.com", "password": "password", "first_name": "Test", "last_name": "User"} | ||
res = api_client.post("/api/users/", data, format="json") | ||
assert res.status_code == status.HTTP_201_CREATED, res.data | ||
|
||
|
||
@pytest.mark.django_db | ||
def test_create_superuser(): | ||
superuser = User.objects.create_superuser(email="test@example.com", password="password", first_name="Leslie", last_name="Burke") | ||
|
@@ -50,7 +58,59 @@ def test_create_user_from_factory(sample_user): | |
@pytest.mark.django_db | ||
def test_user_can_login(api_client, sample_user): | ||
res = api_client.post("/api/login/", {"email": sample_user.email, "password": "password"}, format="json") | ||
assert res.status_code == 200 | ||
assert res.status_code == status.HTTP_200_OK | ||
|
||
|
||
@pytest.mark.django_db | ||
def test_wrong_email(api_client, sample_user): | ||
res = api_client.post("/api/login/", {"email": "wrong@example.com", "password": "password"}, format="json") | ||
assert res.status_code == status.HTTP_400_BAD_REQUEST | ||
|
||
|
||
@pytest.mark.django_db | ||
def test_wrong_password(api_client, sample_user): | ||
res = api_client.post("/api/login/", {"email": sample_user.email, "password": "wrong"}, format="json") | ||
assert res.status_code == status.HTTP_400_BAD_REQUEST | ||
|
||
|
||
@pytest.mark.django_db | ||
def test_get_user(api_client, sample_user): | ||
api_client.force_authenticate(sample_user) | ||
res = api_client.get(f"/api/users/{sample_user.pk}/") | ||
assert res.status_code == status.HTTP_200_OK | ||
assert res.data["email"] == sample_user.email | ||
|
||
|
||
@pytest.mark.django_db | ||
def test_get_other_user(api_client, sample_user, user_factory): | ||
api_client.force_authenticate(sample_user) | ||
other_user = user_factory() | ||
other_user.save() | ||
res = api_client.get(f"/api/users/{other_user.pk}/") | ||
assert res.status_code == status.HTTP_404_NOT_FOUND | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note that this is a 404. The other user does exist, but we don't want to make bad actors aware of that fact. So it's a 404. |
||
|
||
|
||
@pytest.mark.django_db | ||
def test_update_user(api_client, sample_user): | ||
existing_email = sample_user.email | ||
api_client.force_authenticate(sample_user) | ||
data = {"email": "example@example.com", "password": "password", "first_name": "Test", "last_name": "User"} | ||
res = api_client.put(f"/api/users/{sample_user.pk}/", data, format="json") | ||
assert res.status_code == status.HTTP_200_OK | ||
sample_user.refresh_from_db() | ||
# Email should NOT have changed | ||
assert sample_user.email == existing_email | ||
assert sample_user.first_name == data["first_name"] == res.data["first_name"] | ||
assert sample_user.last_name == data["last_name"] == res.data["last_name"] | ||
|
||
|
||
@pytest.mark.django_db | ||
def test_delete_user(api_client, sample_user): | ||
api_client.force_authenticate(sample_user) | ||
res = api_client.delete(f"/api/users/{sample_user.pk}/") | ||
assert res.status_code == status.HTTP_204_NO_CONTENT | ||
sample_user.refresh_from_db() | ||
assert sample_user.is_active is False | ||
|
||
|
||
@pytest.mark.use_requests | ||
|
@@ -67,7 +127,7 @@ def test_password_reset(caplog, api_client, sample_user): | |
|
||
# Verify the link works for reseting the password | ||
response = api_client.post(password_reset_url, data={"password": "new_password"}, format="json") | ||
assert response.status_code == 200 | ||
assert response.status_code == status.HTTP_200_OK | ||
|
||
# New Password should now work for authentication | ||
serializer = UserLoginSerializer(data={"email": sample_user.email, "password": "new_password"}) | ||
|
@@ -87,7 +147,7 @@ class TestPreviewTemplateView: | |
@override_settings(DEBUG=False) | ||
def test_disabled_if_not_debug(self, client): | ||
response = client.post(self.url) | ||
assert response.status_code == 404 | ||
assert response.status_code == status.HTTP_404_NOT_FOUND | ||
|
||
@override_settings(DEBUG=True) | ||
def test_enabled_if_debug(self, client): | ||
|
@@ -98,19 +158,19 @@ def test_enabled_if_debug(self, client): | |
@override_settings(DEBUG=True) | ||
def test_no_template_provided(self, client): | ||
response = client.post(self.url, data={"_send_to": "someone@example.com"}) | ||
assert response.status_code == 400 | ||
assert response.status_code == status.HTTP_400_BAD_REQUEST | ||
assert any("You must provide a template name" in e for e in response.json()) | ||
|
||
@override_settings(DEBUG=True) | ||
def test_invalid_template_provided(self, client): | ||
response = client.post(f"{self.url}?template=SOME_TEMPLATE/WHICH_DOES_NOT/EXIST", data={"_send_to": "someone@example.com"}) | ||
assert response.status_code == 400 | ||
assert response.status_code == status.HTTP_400_BAD_REQUEST | ||
assert any("Invalid template name" in e for e in response.json()) | ||
|
||
@override_settings(DEBUG=True) | ||
def test_missing_send_to(self, client): | ||
response = client.post(f"{self.url}?template=SOME_TEMPLATE/WHICH_DOES_NOT/EXIST") | ||
assert response.status_code == 400 | ||
assert response.status_code == status.HTTP_400_BAD_REQUEST | ||
assert "This field is required." in response.json()["_send_to"] | ||
|
||
def test_parse_value_without_model(self): | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -18,11 +18,11 @@ | |
from rest_framework.exceptions import ValidationError | ||
from rest_framework.response import Response | ||
|
||
from {{ cookiecutter.project_slug }}.core.forms import PreviewTemplateForm | ||
from {{ cookiecutter.project_slug }}.utils.emails import send_html_email | ||
|
||
from .forms import PreviewTemplateForm | ||
from .models import User | ||
from .permissions import CreateOnlyPermissions | ||
from .permissions import HasUserPermissions | ||
from .serializers import UserLoginSerializer, UserRegistrationSerializer, UserSerializer | ||
|
||
logger = logging.getLogger(__name__) | ||
|
@@ -50,45 +50,45 @@ def post(self, request, *args, **kwargs): | |
return Response(response_data) | ||
|
||
|
||
class UserViewSet( | ||
viewsets.GenericViewSet, | ||
mixins.RetrieveModelMixin, | ||
mixins.ListModelMixin, | ||
mixins.UpdateModelMixin, | ||
mixins.DestroyModelMixin, | ||
): | ||
class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We implement some of these with overrides below. |
||
queryset = User.objects.all() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I tried to remove this but it raises an error: I couldn't find any deep discussion on how to better use the queryset here. You can't auto-filter here by the logged in user or anything like that. All examples just have it as There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah, in that case I think you could replace it with There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I like it, but that doesn't work. A couple of tests are failing now because the chain of filters starts by finding nothing. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. setting it to just There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, I think that's a good place to land |
||
serializer_class = UserSerializer | ||
|
||
# No auth required to create user | ||
# Auth required for all other actions | ||
permission_classes = (permissions.IsAuthenticated | CreateOnlyPermissions,) | ||
permission_classes = (HasUserPermissions,) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Create still works fine. There are automated tests now for all of this so should this break on a project, a dev would have to also remove the test to get around it. |
||
|
||
@transaction.atomic | ||
def create(self, request, *args, **kwargs): | ||
def get_queryset(self): | ||
""" | ||
Endpoint to create/register a new user. | ||
Users should only find themselves by default | ||
""" | ||
return super().get_queryset().for_user(self.request.user) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Are you sure? I think get_queryset is also called for update methods |
||
|
||
@transaction.atomic | ||
def create(self, request, *args, **kwargs): | ||
serializer = UserRegistrationSerializer(data=request.data) | ||
serializer.is_valid(raise_exception=True) | ||
serializer.save() # This calls .create() on serializer | ||
user = serializer.instance | ||
|
||
user = serializer.save() | ||
# Log-in user and re-serialize response | ||
response_data = UserLoginSerializer.login(user, request) | ||
return Response(response_data, status=status.HTTP_201_CREATED) | ||
|
||
def update(self, request, *args, **kwargs): | ||
""" | ||
Endpoint to create/register a new user. | ||
""" | ||
serializer = UserSerializer(data=request.data, instance=self.get_object(), partial=True) | ||
|
||
serializer.is_valid(raise_exception=True) | ||
serializer.save() | ||
user = serializer.data | ||
return Response(serializer.data, status=status.HTTP_200_OK) | ||
|
||
return Response(user, status=status.HTTP_200_OK) | ||
def destroy(self, request, *args, **kwargs): | ||
""" | ||
When deleting a user's account, just disable their account first | ||
The user may have a regret and try to get their account back | ||
A background job should then properly delete the data after X days | ||
""" | ||
user = self.get_object() | ||
user.is_active = False | ||
user.save() | ||
return Response(status=status.HTTP_204_NO_CONTENT) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Comment above should be clear. |
||
|
||
|
||
@api_view(["post"]) | ||
|
@@ -117,7 +117,7 @@ def request_reset_link(request, *args, **kwargs): | |
def reset_password(request, *args, **kwargs): | ||
user_id = kwargs.get("uid") | ||
token = kwargs.get("token") | ||
user = User.objects.filter(id=user_id).first() | ||
user = User.objects.filter(pk=user_id).first() | ||
if not user or not token: | ||
raise ValidationError(detail={"non-field-error": "Invalid or expired token"}) | ||
is_valid = default_token_generator.check_token(user, token) | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -38,6 +38,9 @@ | |
if CURRENT_DOMAIN not in ALLOWED_HOSTS: | ||
ALLOWED_HOSTS.append(CURRENT_DOMAIN) | ||
|
||
# Used by the corsheaders app/middleware (django-cors-headers) to allow multiple domains to access the backend | ||
CORS_ALLOWED_ORIGINS = [f"https://{host}" for host in ALLOWED_HOSTS] | ||
|
||
# Application definition | ||
|
||
INSTALLED_APPS = [ | ||
|
@@ -306,7 +309,6 @@ | |
if not IN_DEV: | ||
SECURE_SSL_REDIRECT = True | ||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") | ||
MIDDLEWARE += ["django.middleware.security.SecurityMiddleware"] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. duplicate from another spot in this file. |
||
|
||
# | ||
# Custom logging configuration | ||
|
@@ -390,11 +392,6 @@ def filter(self, record): | |
# Popular testing framework that allows logging to stdout while running unit tests | ||
TEST_RUNNER = "django_nose.NoseTestSuiteRunner" | ||
|
||
CORS_ALLOWED_ORIGINS = ["https://{{ cookiecutter.project_slug|replace('_', '-') }}-staging.herokuapp.com", "https://{{ cookiecutter.project_slug|replace('_', '-') }}.herokuapp.com"] | ||
{% if cookiecutter.client_app.lower() != 'none' -%} | ||
CORS_ALLOWED_ORIGINS.append("http://localhost:8080") | ||
{% endif -%} | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. See further up. |
||
SWAGGER_SETTINGS = { | ||
"LOGIN_URL": "/login", | ||
"USE_SESSION_AUTH": False, | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,9 @@ | ||
from django.contrib import admin | ||
from django.urls import include, path | ||
|
||
admin.site.site_header = "{{ cookiecutter.project_name }} Admin" | ||
admin.site.site_title = "{{ cookiecutter.project_name }}" | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just a nice little addition so that managing multiple projects makes it more obvious which admin you are in. Also, non-dev project stakeholders don't know (or need to care) what the "Django Admin" is. It's just the admin for the app. |
||
urlpatterns = [ | ||
path(r"staff/", admin.site.urls), | ||
path(r"", include("{{ cookiecutter.project_slug }}.common.favicon_urls")), | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
gentle nudge for devs to set this for their new models
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you