├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── docs ├── README.md ├── avatar.md ├── index.md ├── installation.md ├── mixins.md └── views.md ├── mkdocs.yml ├── requirements.txt ├── setup.cfg ├── setup.py ├── tox.ini └── user_management ├── __init__.py ├── api ├── __init__.py ├── authentication.py ├── avatar │ ├── __init__.py │ ├── serializers.py │ ├── tests │ │ ├── __init__.py │ │ ├── test_serializers.py │ │ ├── test_urls.py │ │ └── test_views.py │ ├── urls │ │ ├── __init__.py │ │ ├── profile_avatar.py │ │ └── user_avatar.py │ └── views.py ├── exceptions.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── remove_expired_tokens.py ├── models.py ├── permissions.py ├── serializers.py ├── templates │ └── user_management │ │ ├── account_validation_email.html │ │ ├── account_validation_email.txt │ │ ├── password_reset_email.html │ │ └── password_reset_email.txt ├── tests │ ├── __init__.py │ ├── test_authentication.py │ ├── test_exceptions.py │ ├── test_management_commands.py │ ├── test_models.py │ ├── test_serializers.py │ ├── test_throttling.py │ ├── test_urls.py │ ├── test_views.py │ └── urls.py ├── throttling.py ├── urls │ ├── __init__.py │ ├── auth.py │ ├── password_reset.py │ ├── profile.py │ ├── register.py │ ├── users.py │ └── verify_email.py └── views.py ├── models ├── __init__.py ├── admin.py ├── admin_forms.py ├── backends.py ├── mixins.py └── tests │ ├── __init__.py │ ├── factories.py │ ├── models.py │ ├── notifications.py │ ├── test_admin.py │ ├── test_admin_forms.py │ ├── test_backends.py │ ├── test_models.py │ └── utils.py ├── tests ├── __init__.py ├── run.py ├── testmigrations │ ├── __init__.py │ ├── api │ │ ├── 0001_initial.py │ │ ├── 0002_authtoken_user.py │ │ └── __init__.py │ └── tests │ │ ├── 0001_initial.py │ │ ├── 0002_case_insensitive_email.py │ │ └── __init__.py └── utils.py ├── ui ├── __init__.py ├── exceptions.py ├── tests │ ├── __init__.py │ ├── test_exceptions.py │ ├── test_urls.py │ └── test_views.py ├── urls.py └── views.py └── utils ├── __init__.py ├── notifications.py ├── sentry.py ├── tests ├── __init__.py ├── test_sentry.py ├── test_validators.py └── test_views.py ├── validators.py └── views.py /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | env: 3 | global: 4 | - DATABASE_URL='postgres://postgres@localhost/user_management' 5 | - TERM='xterm-256color' 6 | matrix: 7 | - TOX_ENV=py36-django111 8 | - TOX_ENV=py37-django111 9 | - TOX_ENV=py38-django111 10 | - TOX_ENV=py36-django22 11 | - TOX_ENV=py37-django22 12 | - TOX_ENV=py38-django22 13 | - TOX_ENV=py36-django30 14 | - TOX_ENV=py37-django30 15 | - TOX_ENV=py38-django30 16 | - TOX_ENV=py36-djangopre 17 | - TOX_ENV=py37-djangopre 18 | - TOX_ENV=py38-djangopre 19 | matrix: 20 | fast_finish: true 21 | allow_failures: 22 | - env: TOX_ENV=py36-djangopre 23 | - env: TOX_ENV=py37-djangopre 24 | - env: TOX_ENV=py38-djangopre 25 | services: 26 | - postgresql 27 | addons: 28 | postgresql: "9.4" 29 | install: 30 | - pip install -U pip 31 | - pip install tox==1.9.0 coveralls 32 | before_script: 33 | - psql -c 'CREATE DATABASE user_management' -U postgres; 34 | script: 35 | - tox -e $TOX_ENV 36 | after_success: 37 | coveralls 38 | notifications: 39 | email: false 40 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Changelog for django-user-management 2 | 3 | This project uses Semantic Versioning (2.0). 4 | 5 | ## 18.0.0 6 | 7 | * BREAKING: Add a app_name to each of the urls entry points. See the docs/views.md for updated default url namespaces. 8 | * Fix Pillow security issue 9 | * Drop support for Python < 3.6 10 | * Add support for Python 3.6, 3.7 and 3.8 11 | * Drop support for Django < 1.11 12 | * Add Django 2.2 and 3.0 to travis 13 | * Update djangorestframework>=3.9.1 for XSS fix https://github.com/encode/django-rest-framework/commit/75a489150ae24c2f3c794104a8e98fa43e2c9ce9 14 | * Update incuna_mail>=4.1.0 for Django 3 support 15 | 16 | ## 17.0.0 17 | 18 | * Backwards incompatible: Use `CIEmailField` from Django 1.11 for a case-insensitive email field. 19 | 20 | ## 16.1.1 21 | 22 | * Fix bug using VERIFIED_QUERYSTRING with a LOGIN_URL. 23 | 24 | ## 16.1.0 25 | 26 | * Allow VerifyUserEmailView get_redirect_url function to accept an extra string. 27 | * Allow user login after email verification providing a setting is true in an apps settings file. 28 | This works in Django 1.10. 29 | 30 | ## 16.0.1 31 | 32 | * Fix email verification when `LOGIN_URL` is a url name. 33 | 34 | ## 16.0.0 35 | 36 | * Update `VerifyUserEmailView` to redirect to login without providing a next. 37 | * Redirect to the login when attempting to verify an email address that is already verified. 38 | 39 | ## 15.0.0 40 | 41 | * Updated for compatibility with Python 3.5 and Django 1.10 42 | 43 | ## v14.5.0 44 | 45 | * Allow changing the subject of email verification and password reset emails with 46 | Django settings (`DUM_PASSWORD_RESET_SUBJECT` and `DUM_VALIDATE_EMAIL_SUBJECT`). 47 | 48 | ## v14.4.0 49 | 50 | * Make `VerifyUserEmailView` redirect to the login page if `LOGIN_URL` is set (and `/` 51 | otherwise). 52 | 53 | ## v14.3.0 54 | 55 | * Add `headers` to `utils.email_handler` enabling custom email headers to be sent. 56 | 57 | ## v14.2.1 58 | 59 | * Fix the URL for `VerifyUserEmailView`. 60 | 61 | ## v14.2.0 62 | 63 | * Refactor the UI `VerifyUserEmailView` to function in the same way as the existing API 64 | `VerifyAccountView`. 65 | 66 | ## v14.1.0 67 | 68 | * Add `VerifyUserEmailView` to handle links from registration verification emails. 69 | 70 | ## v14.0.0 71 | 72 | * Clarify error message when your old and new passwords match, you will need to update translations. 73 | * Add translation for email in `RegistrationSerializer` and `UserSerializerCreate`. 74 | 75 | ## v13.1.1 76 | 77 | * Swap `request.DATA` (deprecated in DRF v3.0, removed in DRF v3.2) for `request.data`. 78 | 79 | ## v13.1.0 80 | 81 | * Make token to verify account to expires if `VERIFY_ACCOUNT_EXPIRY` is set to 82 | a value in seconds. 83 | 84 | ### Notes 85 | 86 | * If `VERIFY_ACCOUNT_EXPIRY` is not set the token will never expire. 87 | 88 | ## v13.0.0 89 | 90 | * Make `RegistrationSerializer` and `EmailSerializerBase` fields a tuple. 91 | 92 | ### Notes 93 | 94 | `RegistrationSerializer` or `EmailSerializerBase` subclasses adding new fields 95 | with a `list` will generate a `TypeError`: 96 | 97 | ``` 98 | class CustomRegistration(RegistrationSerializer): 99 | class Meta(RegistrationSerializer.Meta): 100 | fields = RegistrationSerializer.Meta.fields + ['custom_field'] 101 | 102 | TypeError: can only concatenate tuple (not "list") to tuple` 103 | ``` 104 | 105 | To fix the previous error we use a tuple instead: 106 | 107 | ``` 108 | class CustomRegistration(RegistrationSerializer): 109 | class Meta(RegistrationSerializer.Meta): 110 | fields = RegistrationSerializer.Meta.fields + ('custom_field',) 111 | ``` 112 | 113 | ## v12.0.1 114 | 115 | * Ensure new and old passwords differ when changing password. 116 | 117 | ## v12.0.0 118 | 119 | * Update factories to use `class Meta:` syntax instead of `FACTORY_FOR`. 120 | 121 | ## v11.1.0 122 | 123 | * Add correct HTML to HTML email templates. 124 | * Add `django v1.8` support. 125 | 126 | ## v11.0.0 127 | 128 | * Add `django-rest-framework v3` support. 129 | * Drop `django-rest-framework v2` support. 130 | 131 | ## v10.1.0 132 | 133 | * Allow authenticated user to receive a new confirmation email. 134 | 135 | ### Notes 136 | 137 | * Previously only anonymous could request a new confirmation email. 138 | 139 | ## v10.0.0 140 | 141 | * Replace `default_token_generator` with `django.core.signing`. 142 | 143 | ### Notes 144 | 145 | * Previously not validated emails would be invalid. 146 | 147 | ## v9.0.1 148 | 149 | * Send `user_logged_in` and `user_logged_out` signals from `GetAuthToken` view. 150 | 151 | ## v9.0.0 152 | 153 | * Replace `email_verification_required` flag with `email_verified` flag. 154 | * Note that `email_verified == not email_verification_required`. 155 | * A data migration will be necessary. 156 | 157 | ## v8.2.0 158 | 159 | **This release backports a specific change from v14.0.0** 160 | 161 | * Clarify error message when your old and new passwords match, you will need to update translations. 162 | 163 | ## v8.1.2 164 | 165 | **This release backports a specific change from v12.0.1** 166 | 167 | * Ensure new and old passwords differ when changing password. 168 | 169 | 170 | ## v8.1.1 (Partial backport of fefdf6a from v11) 171 | 172 | * Bugfix: Don't show "passwords do not match" when the first password is invalid. 173 | 174 | ## v8.1.0 175 | 176 | * Add docstrings for views. 177 | 178 | Docstrings will be displayed in `django-rest-framework` browsable API. 179 | 180 | ## v8.0.1 181 | 182 | * Fix translation for notifications. 183 | 184 | ## v8.0.0 185 | 186 | * Use `incuna-pigeon` for notifications. 187 | 188 | ## v7.0.1 189 | 190 | * Fix `UserChangeForm` admin form `fields` to only include fields used in `UserAdmin.fieldsets`. 191 | 192 | ## v7.0.0 193 | 194 | * Add `delete` to `ProfileDetail` view 195 | 196 | ### Notes 197 | 198 | * When an object is referencing the user model with a foreign key it is possible 199 | to define the behavior with `on_delete`. 200 | 201 | see https://docs.djangoproject.com/en/1.7/ref/models/fields/#django.db.models.ForeignKey.on_delete 202 | 203 | ## v6.0.0 204 | 205 | * Raise an error when user is not active at login 206 | 207 | ## v5.0.0 208 | 209 | * Return 400 instead of 401 when `uidb64` or `token` is expired or not valid. 210 | 211 | ## v4.2.0 212 | 213 | * Return `AuthenticationFailed` `401` instead of `404` `NotFound` for not valid 214 | `uidb64` and `token` 215 | 216 | ## v4.1.0 217 | 218 | * Add `ResendConfirmationEmail` view. 219 | 220 | ## v4.0.1 221 | 222 | * Remove calculation in translatable string 223 | 224 | ## v4.0.0 225 | 226 | * Enforce complex passwords 227 | 228 | ## v3.5.3 229 | 230 | * Get user by natural key in `ValidateEmailMixin`. 231 | 232 | ## v3.5.2 233 | 234 | * Get user by natural key in `PasswordResetEmail`. 235 | 236 | ## v3.5.1 237 | 238 | * Add timezone support: projects with `USE_TZ=True` will now work correctly 239 | 240 | ## v3.5.0 241 | 242 | * Split `BasicUserFieldsMixin` and `VerifyEmailMixin` into mixins. 243 | 244 | ## v3.4.0 245 | 246 | * Auth tokens offer expiration functionality. 247 | 248 | ## v3.3.0 249 | 250 | * Add custom Sentry logging class to disallow sensitive data being logged by Sentry client. 251 | 252 | ## v3.2.0 253 | 254 | * Add `UsernameLoginRateThrottle` to throttle users based on their username. 255 | * `GetToken` throttle extended with `UsernameLoginRateThrottle`. 256 | 257 | ## v3.1.0 258 | 259 | * Allow POST to avatar views. 260 | * Allow authentication with `token` as a form field on avatar views. 261 | * Replace `django-inmemorystorage==0.1.1` with `dj-inmemorystorage==1.2.0` in tests. 262 | 263 | ## v3.0.1 264 | 265 | * `GetToken` throttles `POST` requests only. 266 | 267 | ## v3.0.0 268 | 269 | **Backwards incompatible** due to required authentication when using `ProfileAvatar` 270 | 271 | * `PasswordResetEmail` now only throttled on `POST` requests. 272 | * Added `DELETE` method to `ProfileAvatar`. 273 | * `ProfileAvatar` now requires authentication. 274 | 275 | ## v2.1.1 276 | 277 | * Add missing plaintext account validation email 278 | * Add missing `/` to html account validation email 279 | 280 | ## v2.1.0 281 | 282 | * Update `create_user` to set last_login with a default. 283 | 284 | **Note: this change has been done for the upcoming version django > 1.7.0.** 285 | `User.last_login` default is removed from django > 1.7.0. For existing 286 | project using `django-user-management` project migrations would be run 287 | after `django.contrib.auth` migrations. The project migrations will cancel 288 | `last_login` `IS NULL`. 289 | 290 | ## v2.0.0 291 | 292 | **Backwards incompatible** due to incuna-mail update 293 | 294 | * Update VerifyEmailMixin.send_validation_email to send a multipart email by default 295 | * Allow overriding the verification email's subject and django templates 296 | * Update incuna-mail to v2.0.0 297 | 298 | ## v1.2.2 299 | 300 | * Fix bug where VerifyUserAdmin.get_fieldsets is called twice 301 | 302 | ## v1.2.1 303 | 304 | * Bump required version of `incuna-mail` in order to fix circular import. 305 | 306 | ## v1.2.0 307 | 308 | * Protect auth login and password reset views against throttling. 309 | 310 | ## v1.1.4 311 | 312 | * Add email field to PasswordResetEmail response to OPTIONS request 313 | 314 | ## v1.1.3 315 | 316 | * Fix error in OneTimeUseAPIMixin that made it 500 with bad urls 317 | 318 | ## v1.1.2 319 | 320 | * Add hooks to PasswordResetEmail view to allow easier subclassing 321 | 322 | ## v1.1.1 323 | 324 | * Improve UserFactory to deal with passwords neatly. 325 | 326 | ## v1.1.0 327 | 328 | * Add CaseInsensitiveEmailBackend authentication backend 329 | * Consistently convert email addresses to lower-case 330 | 331 | ## v1.0.0 332 | 333 | * Move sending of verification emails into UserRegister view from VerifyEmailMixin. 334 | * Add delete method to GetToken view. 335 | * Return HTTP_201_CREATED and ok message from VerifyAccountView. 336 | 337 | ## v0.2.0 338 | 339 | * Move `avatar` code to self-contained app so it does not break 340 | when extra dependencies are not installed. 341 | 342 | **Note: this is backward incompatible release.** 343 | Avatar related code should now be imported from `api.avatar` namespace 344 | instead of previous `api` namespace. An example `ProfileAvatar` class view 345 | lives now at `user_management.api.avatar.views.ProfileAvatar` 346 | (not `user_management.api.views.ProfileAvatar`). 347 | 348 | ## v0.1.0 349 | 350 | * Bump required version of incuna_mail to 0.2 351 | 352 | ## v0.0.9 353 | 354 | * Add labels to password serializers' fields. 355 | 356 | ## v0.0.8 357 | 358 | * Add user avatar model mixin, serializer and endpoint. 359 | **Requires djangorestframework>=2.3.13.** 360 | 361 | ## v0.0.7 362 | 363 | * Ensure all urls accept a trailing slash. 364 | 365 | ## v0.0.6 366 | 367 | * Separate user detail / list urls from (my) profile. 368 | * Rename views to not end View. 369 | * Make users views hyperlinked. 370 | * Add `user_management_api` namesapce to api urls. Include with 371 | `include('user_management.api.urls', namespace='something', app_name='user_management_api')` 372 | 373 | 374 | ## v0.0.5 375 | 376 | * Add admin forms and simple UserAdmin 377 | * Add VerifyUserAdmin 378 | * Order UserAdmin by name, not email 379 | * Use python 2 compatible super 380 | 381 | ## v0.0.4 382 | 383 | * Added users list 384 | * Added urls and url tests 385 | 386 | ## v0.0.3 387 | 388 | * Add wheel support 389 | 390 | ## v0.0.2 391 | 392 | * Rename users template dir to user_management 393 | * Rename UserSerializer to RegistrationSerializer 394 | * Check new superusers are active by default 395 | * Make User model abstract. 396 | * Convert abstract models to mixins. 397 | * Reorganise app into models and api modules. 398 | * Separate verify_email_urls. 399 | * Use self.normalize_email 400 | * Better duplicate email test. 401 | * Add .travis.yml 402 | * Add Python 2.7 compatibility 403 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014, Incuna Ltd 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 15 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 16 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 17 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 18 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 19 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 20 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 21 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 22 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 23 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include user_management/**/templates * 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL := /bin/bash 2 | 3 | keepdb=1 4 | 5 | help: 6 | @echo "Usage:" 7 | @echo " make test | Run the tests." 8 | @echo " make test keepdb=0 | Run the tests without keeping the db." 9 | @echo " make run-doc | Run the docs locally." 10 | 11 | test: export KEEPDB=$(keepdb) 12 | test: 13 | @coverage run ./user_management/tests/run.py 14 | @coverage report 15 | @flake8 16 | 17 | run-doc: 18 | @mkdocs serve 19 | 20 | release: 21 | @(git diff --quiet && git diff --cached --quiet) || (echo "You have uncommitted changes - stash or commit your changes"; exit 1) 22 | @git clean -dxf 23 | @python setup.py sdist bdist_wheel 24 | @twine upload dist/* 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-user-management 2 | [![Build Status](https://travis-ci.org/incuna/django-user-management.png?branch=merge-version)](https://travis-ci.org/incuna/django-user-management) [![Coverage Status](https://coveralls.io/repos/incuna/django-user-management/badge.png?branch=master)](https://coveralls.io/r/incuna/django-user-management?branch=master) [![Requirements Status](https://requires.io/github/incuna/django-user-management/requirements.svg?branch=master)](https://requires.io/github/incuna/django-user-management/requirements/?branch=master) 3 | 4 | User management model mixins and API views/serializers based on [`Django`](https://github.com/django/django) 5 | and [`djangorestframework`](https://github.com/tomchristie/django-rest-framework). 6 | 7 | All documentation is in the [docs](docs/) directory. 8 | 9 | - [Installation](docs/installation.md) 10 | - [Mixins](docs/mixins.md) 11 | - [Views](docs/views.md) 12 | - [Avatar](docs/avatar.md) 13 | 14 | `user_management` model mixins give flexibility to create your own `User` model. 15 | By default all mixins are optional. Our mixins allow to create, identify users 16 | (from their emails instead of their username) as well as sending password reset 17 | and account validation emails. 18 | 19 | `user_management` API views and serializers can be grouped into five sections: 20 | * `auth`: authenticate and destroy a user session 21 | * `password_reset`: send and confirm a request to reset a password 22 | * `profile`: retrieve/update/delete the current user profile 23 | * `register`: create an account and send an email to validate it 24 | * `users`: give a list and a detail (retrieve, update, destroy) views about users 25 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | index.md -------------------------------------------------------------------------------- /docs/avatar.md: -------------------------------------------------------------------------------- 1 | # Avatar 2 | 3 | ## Mixin 4 | 5 | `user_management.models.mixins.AvatarMixin` adds an avatar field. The 6 | serializers for this field require `django-imagekit` to be installed. 7 | 8 | ## Installation 9 | 10 | To install `django-user-management` with avatar functionality: 11 | 12 | pip install django-user-management[avatar] 13 | 14 | Add the urls to your `ROOT_URLCONF`: 15 | 16 | urlpatterns = [ 17 | ... 18 | url(r'', include('user_management.api.avatar.urls.avatar')), 19 | ... 20 | ] 21 | 22 | ## View 23 | 24 | `user_management.api.avatar.views.ProfileAvatar` provides an endpoint to retrieve 25 | and update the logged-in user's avatar. 26 | 27 | `user_management.api.avatar.views.UserAvatar` provides an endpoint to retrieve 28 | and update another user's avatar. Only an admin user can update other users' data. 29 | 30 | Both avatar views provides an endpoint to retrieve a thumbnail of the 31 | authenticated user's avatar. 32 | 33 | Thumbnail options can be specified as GET arguments. Options are: 34 | width: Specify the width (in pixels) to resize/crop to. 35 | height: Specify the height (in pixels) to resize/crop to. 36 | crop: Whether to crop or not (allowed values: 0 or 1) 37 | anchor: Where to anchor the crop, top/bottom/left/right (allowed values: t, b, l, r) 38 | upscale: Whether to upscale or not (allowed values: 0 or 1) 39 | 40 | If no options are specified, the user's avatar is returned. 41 | 42 | For example, to return an avatar cropped to 100x100 anchored to the top right: 43 | avatar?width=100&height=100&crop=1&anchor=tr 44 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Welcome to django-user-management documentation 2 | 3 | Documentation generated with [mkdocs.org](http://mkdocs.org). 4 | 5 | 6 | `django-user-management` contains user management model mixins and API views. The mixins 7 | provide common user-related functionality such as custom avatars and email verification 8 | during the registration process. The API views provide endpoints to support this 9 | functionality. 10 | 11 | ## API endpoints 12 | 13 | Including `user_management.api.urls` will give the following API endpoints: 14 | - `auth` 15 | - `password_reset` 16 | - `profile` 17 | - `register` 18 | 19 | If you need more control, urls are split across several files and can be included 20 | [individually](docs/views). 21 | 22 | Auth: 23 | 24 | - url: `/auth` 25 | 26 | Password reset: 27 | 28 | - url: `/auth/password_reset/confirm//` 29 | - url: `/auth/password_reset` 30 | 31 | Profile: 32 | 33 | - url: `/profile` 34 | - url: `/profile/password` 35 | 36 | Register: 37 | 38 | - url: `/register` 39 | - url: `/resend-confirmation-email` 40 | 41 | Users: 42 | 43 | - url: `/users` 44 | - url: `/users/` 45 | 46 | Verify email: 47 | 48 | - url: `/verify_email//` 49 | 50 | Profile avatar: 51 | 52 | - url: `/profile/avatar` 53 | 54 | User avatar: 55 | 56 | - url: `/users//avatar` 57 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | ## Dependencies 4 | 5 | djangorestframework 6 | incuna_mail 7 | 8 | ## Install with pip 9 | 10 | Install the package: 11 | 12 | pip install django-user-management 13 | 14 | Install with avatar functionality: 15 | 16 | pip install django-user-management[avatar] 17 | 18 | Install with filtering sensitive data out of Sentry: 19 | 20 | pip install django-user-management[utils] 21 | 22 | 23 | ## User model 24 | 25 | To create a custom user model using the `django-user-management` functionality, declare 26 | your user class like this: 27 | 28 | from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin 29 | from user_management.models.mixins import ActiveUserMixin 30 | 31 | 32 | class User(ActiveUserMixin, PermissionsMixin, AbstractBaseUser): 33 | pass 34 | 35 | If you want to use the `VerifyEmailMixin`, substitute it for `ActiveUserMixin`. 36 | 37 | Make sure the app containing your custom user model is added to `settings.INSTALLED_APPS`, 38 | and set `settings.AUTH_USER_MODEL` to be the path to your custom user model. 39 | 40 | If you use `EmailUserMixin` or any of its derivatives, you'll need to set up Postgres to support a `CIText` extension in your migration. Add the following to your migration: 41 | 42 | from django.contrib.postgres.operations import CITextExtension 43 | 44 | operations = [ 45 | CITextExtension(), 46 | ... 47 | ] 48 | 49 | ## Authtoken 50 | 51 | If you have `user_management.api` in your `INSTALLED_APPS`, you'll also need to create a migration in your project for the `AuthToken` model. 52 | 53 | Add the following to `settings.MIGRATION_MODULES`: 54 | 55 | MIGRATION_MODULES = { 56 | ... 57 | 'api': 'core.projectmigrations.user_management_api', # substitute the path to your projectmigrations folder 58 | ... 59 | } 60 | 61 | Then run `python manage.py makemigrations api` to create the migration you need. This avoids database errors involving the relation `users_user` not existing when Django tries to synchronise the "unmigrated" `api` app before setting up the rest of the database. 62 | -------------------------------------------------------------------------------- /docs/mixins.md: -------------------------------------------------------------------------------- 1 | # Custom user model mixins 2 | 3 | ## ActiveUserMixin 4 | 5 | `user_management.models.mixins.ActiveUserMixin` provides a base custom user 6 | mixin with a `name`, `email`, `date_joined`, `is_staff`, and `is_active`. 7 | 8 | ## VerifyEmailMixin 9 | 10 | `user_management.models.mixins.VerifyEmailMixin` extends ActiveUserMixin to 11 | provide functionality to verify the email. It includes an additional 12 | `email_verified` field. 13 | 14 | By default, users will be created with `is_active = False`. A verification email 15 | will be sent including a link to verify the email and activate the account. 16 | 17 | ## AvatarMixin 18 | 19 | `user_management.models.mixins.AvatarMixin` adds an avatar field. The 20 | serializers for this field require `django-imagekit` to be installed. 21 | -------------------------------------------------------------------------------- /docs/views.md: -------------------------------------------------------------------------------- 1 | # Views 2 | 3 | ## To use the API views 4 | 5 | Add to your `INSTALLED_APPS` in `settings.py`: 6 | 7 | INSTALLED_APPS = ( 8 | ... 9 | 'user_management.api', 10 | ... 11 | ) 12 | 13 | Ensure your `DEFAULT_AUTHENTICATION_CLASSES` include the following: 14 | 15 | REST_FRAMEWORK = { 16 | 'DEFAULT_AUTHENTICATION_CLASSES': { 17 | 'rest_framework.authentication.TokenAuthentication', 18 | 'rest_framework.authentication.SessionAuthentication', 19 | }, 20 | } 21 | 22 | Add the URLs to your `ROOT_URLCONF`: 23 | 24 | urlpatterns = [ 25 | ... 26 | url('', include('user_management.api.urls', namespace='user_management_api_core')), 27 | ... 28 | ] 29 | 30 | If you are using the `VerifyEmailMixin`, then you'll also need to include 31 | `user_management.api.urls.verify_email` or `user_management.ui.urls`: 32 | 33 | urlpatterns = [ 34 | ... 35 | url('', include('user_management.api.urls.verify_email', namespace='user_management_api_verify')), # or 36 | url('', include('user_management.ui.urls', namespace='user_management_ui')), 37 | ... 38 | ] 39 | 40 | If you are using the `AvatarMixin`, then you'll also need to include 41 | `user_management.api.avatar.urls.avatar`: 42 | 43 | urlpatterns = [ 44 | ... 45 | url('', include('user_management.api.avatar.urls.avatar', namespace='user_management_api_avatar')), 46 | ... 47 | ] 48 | 49 | 50 | If you need more fine-grained control, you can replace `user_management.api.urls` 51 | with a selection from: 52 | 53 | urlpatterns = [ 54 | ... 55 | url('', include('user_management.api.urls.auth')), 56 | url('', include('user_management.api.urls.password_reset')), 57 | url('', include('user_management.api.urls.profile')), 58 | url('', include('user_management.api.urls.register')), 59 | ... 60 | ] 61 | 62 | 63 | ## Throttling protection 64 | 65 | The `/auth/` and `/auth/password_reset/` URLs are protected against throttling using the 66 | built-in [DRF throttle module](http://www.django-rest-framework.org/api-guide/throttling). 67 | 68 | The default throttle rates are: 69 | 70 | 'logins': '10/hour' 71 | 'passwords': '3/hour' 72 | 73 | You can customise the throttling rates by setting `REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']` 74 | in your `settings.py`: 75 | 76 | REST_FRAMEWORK = { 77 | 'DEFAULT_THROTTLE_RATES': { 78 | 'logins': '100/day', 79 | 'passwords': 100/day', 80 | }, 81 | } 82 | 83 | 84 | ## Filtering sensitive data 85 | 86 | A custom Sentry logging class is available to prevent sensitive data from being logged 87 | by the Sentry client. 88 | 89 | Activate it in `settings.py` by adding: 90 | 91 | SENTRY_CLIENT = 'user_management.utils.sentry.SensitiveDjangoClient' 92 | 93 | 94 | ## Expiry of authentication tokens 95 | 96 | By default, DRF does not offer expiration for authentication tokens, nor any form 97 | of validation for the expired tokens. `django-user-management` is here to help! 98 | 99 | To use this functionality, override the authentication class for DRF in `settings.py`: 100 | 101 | REST_FRAMEWORK = { 102 | ... 103 | 'DEFAULT_AUTHENTICATION_CLASSES': 'user_management.api.authentication.TokenAuthentication', 104 | ... 105 | } 106 | 107 | There's a management command that can be run regularly (e.g. via cronjob) to clear expired tokens: 108 | 109 | python manage.py remove_expired_tokens 110 | 111 | ### Token expiry times 112 | 113 | You can set a custom expiry time for the auth tokens by adding the below to `settings.py`: 114 | 115 | AUTH_TOKEN_MAX_AGE = (default: 200 days) 116 | AUTH_TOKEN_MAX_INACTIVITY = (default: 12 hours) 117 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: django-user-management 2 | pages: 3 | - [index.md, Home] 4 | - [installation.md, Installation] 5 | - [mixins.md, Mixins] 6 | - [views.md, Views] 7 | - [avatar.md, Avatar] 8 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -e .[avatar] 2 | -e .[utils] 3 | colour-runner==0.0.4 4 | coverage==4.2.0 5 | dj-database-url==0.4.1 6 | dj-inmemorystorage==2.1.0 7 | factory-boy==2.8.1 8 | flake8==2.5.4 9 | flake8-import-order==0.7 10 | incuna-test-utils==8.0.0 11 | mkdocs==0.15.3 12 | mock==2.0.0 13 | pillow>3.3.2 14 | psycopg2-binary==2.8.5 15 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | 4 | [flake8] 5 | max-line-length = 90 6 | exclude = *migrations* 7 | statistics = true 8 | application-import-names = user_management 9 | import-order-style = smarkets 10 | 11 | [coverage:run] 12 | omit = *migrations*, *tests* 13 | source = user_management 14 | 15 | [coverage:report] 16 | fail_under = 100 17 | show_missing = true 18 | skip_covered = true 19 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages, setup 2 | 3 | 4 | version = '18.0.0' 5 | 6 | 7 | install_requires = ( 8 | 'djangorestframework>=3.9.1,<3.11', 9 | 'incuna_mail>=4.1.0,<4.2.0', 10 | 'incuna-pigeon>=0.1.0,<1.0.0', 11 | ) 12 | 13 | extras_require = { 14 | 'avatar': [ 15 | 'django-imagekit>=3.2', 16 | ], 17 | 'utils': [ 18 | 'raven>=5.1.1', 19 | ], 20 | } 21 | 22 | setup( 23 | name='django-user-management', 24 | packages=find_packages(), 25 | include_package_data=True, 26 | version=version, 27 | description='User management model mixins and api views.', 28 | long_description_content_type='text/markdown', 29 | long_description=open('README.md').read(), 30 | keywords='django rest framework user management api', 31 | author='Incuna', 32 | author_email='admin@incuna.com', 33 | url='https://github.com/incuna/django-user-management/', 34 | install_requires=install_requires, 35 | extras_require=extras_require, 36 | zip_safe=False, 37 | license='BSD', 38 | classifiers=[ 39 | 'Development Status :: 5 - Production/Stable', 40 | 'Environment :: Web Environment', 41 | 'Framework :: Django', 42 | 'Intended Audience :: Developers', 43 | 'License :: OSI Approved :: BSD License', 44 | 'Programming Language :: Python :: 3', 45 | 'Programming Language :: Python :: 3.6', 46 | 'Programming Language :: Python :: 3.7', 47 | 'Programming Language :: Python :: 3.8', 48 | 'Topic :: Software Development', 49 | 'Topic :: Utilities', 50 | ], 51 | ) 52 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = {py36,py37,py38}-django{111,22,30,pre} 3 | 4 | # do not install the app into virtualenv (makes it faster to run) 5 | skipsdist = True 6 | 7 | [testenv] 8 | deps = 9 | --upgrade 10 | --pre 11 | -rrequirements.txt 12 | django111: Django>=1.11,<1.12 13 | django22: Django>=2.2,<3 14 | django30: Django>=3,<3.1 15 | djangopre: Django 16 | 17 | commands = 18 | coverage run ./user_management/tests/run.py 19 | django111: coverage report --show-missing --fail-under=100 20 | django22: coverage report --show-missing --fail-under=100 21 | django30: coverage report --show-missing --fail-under=100 22 | djangopre: coverage report --show-missing 23 | -------------------------------------------------------------------------------- /user_management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/incuna/django-user-management/28cd481d333fa313601825dab4b05f3e51974fe8/user_management/__init__.py -------------------------------------------------------------------------------- /user_management/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/incuna/django-user-management/28cd481d333fa313601825dab4b05f3e51974fe8/user_management/api/__init__.py -------------------------------------------------------------------------------- /user_management/api/authentication.py: -------------------------------------------------------------------------------- 1 | from django.utils import timezone 2 | from django.utils.translation import ugettext_lazy as _ 3 | from rest_framework import authentication, exceptions 4 | from rest_framework.authentication import TokenAuthentication as DRFTokenAuthentication 5 | 6 | from .models import AuthToken 7 | 8 | 9 | class FormTokenAuthentication(authentication.BaseAuthentication): 10 | def authenticate(self, request): 11 | """ 12 | Authenticate a user from a token form field 13 | 14 | Errors thrown here will be swallowed by django-rest-framework, and it 15 | expects us to return None if authentication fails. 16 | """ 17 | try: 18 | key = request.data['token'] 19 | except KeyError: 20 | return 21 | 22 | try: 23 | token = AuthToken.objects.get(key=key) 24 | except AuthToken.DoesNotExist: 25 | return 26 | 27 | return (token.user, token) 28 | 29 | 30 | class TokenAuthentication(DRFTokenAuthentication): 31 | model = AuthToken 32 | 33 | def authenticate_credentials(self, key): 34 | """Custom authentication to check if auth token has expired.""" 35 | user, token = super(TokenAuthentication, self).authenticate_credentials(key) 36 | 37 | if token.expires < timezone.now(): 38 | msg = _('Token has expired.') 39 | raise exceptions.AuthenticationFailed(msg) 40 | 41 | # Update the token's expiration date 42 | token.update_expiry() 43 | 44 | return (user, token) 45 | -------------------------------------------------------------------------------- /user_management/api/avatar/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/incuna/django-user-management/28cd481d333fa313601825dab4b05f3e51974fe8/user_management/api/avatar/__init__.py -------------------------------------------------------------------------------- /user_management/api/avatar/serializers.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.db import models 3 | from imagekit.cachefiles import ImageCacheFile 4 | from imagekit.registry import generator_registry 5 | from imagekit.templatetags.imagekit import DEFAULT_THUMBNAIL_GENERATOR 6 | from rest_framework import serializers 7 | 8 | User = get_user_model() 9 | 10 | 11 | class ThumbnailField(serializers.ImageField): 12 | """ 13 | Image field that returns an images url. 14 | Pass get parameters to thumbnail the image. 15 | Options are: 16 | width: Specify the width (in pixels) to resize / crop to. 17 | height: Specify the height (in pixels) to resize / crop to. 18 | crop: Whether to crop or not [1,0] 19 | anchor: Where to anchor the crop [t,r,b,l] 20 | upscale: Whether to upscale or not [1,0] 21 | 22 | If no options are specified the users avatar is returned. 23 | 24 | To crop to 100x100 anchored to the top right: 25 | ?width=100&height=100&crop=1&anchor=tr 26 | """ 27 | def __init__(self, *args, **kwargs): 28 | self.generator_id = kwargs.pop('generator_id', DEFAULT_THUMBNAIL_GENERATOR) 29 | super(ThumbnailField, self).__init__(*args, **kwargs) 30 | 31 | def get_generator_kwargs(self, query_params): 32 | width = int(query_params.get('width', 0)) or None 33 | height = int(query_params.get('height', 0)) or None 34 | return { 35 | 'width': width, 36 | 'height': height, 37 | 'anchor': query_params.get('anchor', None), 38 | 'crop': query_params.get('crop', None), 39 | 'upscale': query_params.get('upscale', None) 40 | } 41 | 42 | def generate_thumbnail(self, source, **kwargs): 43 | generator = generator_registry.get( 44 | self.generator_id, 45 | source=source, 46 | **kwargs) 47 | return ImageCacheFile(generator) 48 | 49 | def to_native(self, image): 50 | if not image.name: 51 | return None 52 | 53 | request = self.context.get('request', None) 54 | if request is None: 55 | return image.url 56 | 57 | kwargs = self.get_generator_kwargs(request.query_params) 58 | if kwargs.get('width') or kwargs.get('height'): 59 | image = self.generate_thumbnail(image, **kwargs) 60 | 61 | return request.build_absolute_uri(image.url) 62 | 63 | 64 | class AvatarSerializer(serializers.ModelSerializer): 65 | # Override default field_mapping to map ImageField to HyperlinkedImageField. 66 | # As there is only one field this is the only mapping needed. 67 | field_mapping = { 68 | models.ImageField: ThumbnailField, 69 | } 70 | 71 | class Meta: 72 | model = User 73 | fields = ('avatar',) 74 | -------------------------------------------------------------------------------- /user_management/api/avatar/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/incuna/django-user-management/28cd481d333fa313601825dab4b05f3e51974fe8/user_management/api/avatar/tests/__init__.py -------------------------------------------------------------------------------- /user_management/api/avatar/tests/test_serializers.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from mock import MagicMock, patch 3 | 4 | from .. import serializers 5 | 6 | 7 | class ThumbnailField(TestCase): 8 | def test_get_generator_kwargs(self): 9 | expected = { 10 | 'width': 50, 11 | 'height': 50, 12 | 'anchor': 'tr', 13 | 'crop': 1, 14 | 'upscale': 1, 15 | } 16 | field = serializers.ThumbnailField() 17 | kwargs = field.get_generator_kwargs(expected) 18 | self.assertEqual(kwargs, expected) 19 | 20 | def test_get_generator_kwargs_defaults(self): 21 | expected = { 22 | 'width': None, 23 | 'height': None, 24 | 'anchor': None, 25 | 'crop': None, 26 | 'upscale': None, 27 | } 28 | field = serializers.ThumbnailField() 29 | kwargs = field.get_generator_kwargs({}) 30 | self.assertEqual(kwargs, expected) 31 | 32 | def test_get_generator_kwargs_limited(self): 33 | expected = { 34 | 'width': None, 35 | 'height': None, 36 | 'anchor': None, 37 | 'crop': None, 38 | 'upscale': None, 39 | } 40 | field = serializers.ThumbnailField() 41 | kwargs = field.get_generator_kwargs({'ignored': 'value'}) 42 | self.assertEqual(kwargs, expected) 43 | 44 | def test_generate_thumbnail(self): 45 | field = serializers.ThumbnailField() 46 | source = 'test' 47 | kwargs = {'width': 10} 48 | generator = 'generator' 49 | 50 | get_path = 'user_management.api.avatar.serializers.generator_registry.get' 51 | image_cache_path = 'user_management.api.avatar.serializers.ImageCacheFile' 52 | with patch(get_path) as get: 53 | get.return_value = generator 54 | with patch(image_cache_path) as ImageCacheFile: 55 | field.generate_thumbnail(source, **kwargs) 56 | 57 | get.assert_called_once_with(field.generator_id, source=source, **kwargs) 58 | ImageCacheFile.assert_called_once_with(generator) 59 | 60 | def test_to_native_no_image(self): 61 | """Calling to_native with empty image should return None.""" 62 | field = serializers.ThumbnailField() 63 | mocked_image = MagicMock() 64 | mocked_image.name = None 65 | image = field.to_native(mocked_image) 66 | self.assertEqual(image, None) 67 | 68 | def mock_parent(self, context): 69 | parent = MagicMock() 70 | parent._context = context 71 | parent.parent = None 72 | return parent 73 | 74 | def test_to_native_no_request(self): 75 | """Calling to_native with no request returns the image url.""" 76 | field = serializers.ThumbnailField() 77 | 78 | field.parent = self.mock_parent({'request': None}) 79 | 80 | expected = '/url/' 81 | mocked_image = MagicMock( 82 | name='image.png', 83 | url=expected 84 | ) 85 | image = field.to_native(mocked_image) 86 | self.assertEqual(image, expected) 87 | 88 | def test_to_native_no_kwargs(self): 89 | """Calling to_native with no QUERY_PARAMS returns the absolute image url.""" 90 | field = serializers.ThumbnailField() 91 | request = MagicMock() 92 | expected = 'test.com/url/' 93 | request.build_absolute_uri.return_value = expected 94 | 95 | field.parent = self.mock_parent({'request': request}) 96 | 97 | field.get_generator_kwargs = MagicMock(return_value={}) 98 | mocked_image = MagicMock( 99 | name='image.png', 100 | url='/anything/' 101 | ) 102 | image = field.to_native(mocked_image) 103 | self.assertEqual(image, expected) 104 | request.build_absolute_uri.assert_called_once_with(mocked_image.url) 105 | 106 | def test_to_native_calls_generate_thumbnail(self): 107 | """Calling to_native with QUERY_PARAMS calls generate_thumbnail.""" 108 | field = serializers.ThumbnailField() 109 | 110 | request = MagicMock() 111 | field.parent = self.mock_parent({'request': request}) 112 | 113 | kwargs = {'width': 100} 114 | field.get_generator_kwargs = MagicMock(return_value=kwargs) 115 | 116 | thumbnailed_image = MagicMock( 117 | url='/thumbnail/' 118 | ) 119 | field.generate_thumbnail = MagicMock(return_value=thumbnailed_image) 120 | 121 | expected = 'test.com/url/' 122 | request.build_absolute_uri.return_value = expected 123 | 124 | mocked_image = MagicMock( 125 | name='image.png', 126 | url='/anything/' 127 | ) 128 | image = field.to_native(mocked_image) 129 | self.assertEqual(image, expected) 130 | field.generate_thumbnail.assert_called_once_with(mocked_image, **kwargs) 131 | request.build_absolute_uri.assert_called_once_with(thumbnailed_image.url) 132 | -------------------------------------------------------------------------------- /user_management/api/avatar/tests/test_urls.py: -------------------------------------------------------------------------------- 1 | from incuna_test_utils.testcases.urls import URLTestCase 2 | 3 | from .. import views 4 | 5 | 6 | class TestURLs(URLTestCase): 7 | """Ensure the urls work.""" 8 | 9 | def test_profile_avatar_url(self): 10 | self.assert_url_matches_view( 11 | view=views.ProfileAvatar, 12 | expected_url='/profile/avatar', 13 | url_name='user_management_api_avatar:profile_avatar') 14 | 15 | def test_user_avatar_url(self): 16 | self.assert_url_matches_view( 17 | view=views.UserAvatar, 18 | expected_url='/users/1/avatar', 19 | url_name='user_management_api_avatar:user_avatar', 20 | url_kwargs={'pk': 1}) 21 | -------------------------------------------------------------------------------- /user_management/api/avatar/tests/test_views.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | 3 | from django.contrib.auth import get_user_model 4 | from django.test.client import Client 5 | from django.urls import reverse 6 | from mock import patch 7 | from PIL import Image 8 | from rest_framework import status 9 | from rest_framework.test import APIRequestFactory, force_authenticate 10 | 11 | from user_management.api.avatar import views 12 | from user_management.models.tests.factories import AuthTokenFactory, UserFactory 13 | from user_management.models.tests.utils import APIRequestTestCase 14 | 15 | 16 | User = get_user_model() 17 | TEST_SERVER = 'http://testserver' 18 | 19 | 20 | def _simple_png(): 21 | """Create a 1x1 black png in memory.""" 22 | image_file = BytesIO() 23 | image = Image.new('RGBA', (1, 1)) 24 | image.save(image_file, 'png') 25 | image_file._committed = True 26 | image_file.name = 'test.png' 27 | image_file.url = '{0}/{1}'.format( 28 | TEST_SERVER, 29 | image_file.name 30 | ) 31 | image_file.seek(0) 32 | return image_file 33 | SIMPLE_PNG = _simple_png() 34 | 35 | 36 | class TestProfileAvatar(APIRequestTestCase): 37 | view_class = views.ProfileAvatar 38 | 39 | def tearDown(self): 40 | SIMPLE_PNG.seek(0) 41 | 42 | def test_get(self): 43 | user = UserFactory.build(avatar=SIMPLE_PNG) 44 | 45 | request = self.create_request(user=user) 46 | view = self.view_class.as_view() 47 | response = view(request) 48 | self.assertEqual(response.status_code, status.HTTP_200_OK) 49 | self.assertEqual(response.data['avatar'], SIMPLE_PNG.url) 50 | 51 | def test_get_no_avatar(self): 52 | user = UserFactory.build() 53 | 54 | request = self.create_request(user=user) 55 | view = self.view_class.as_view() 56 | response = view(request) 57 | self.assertEqual(response.status_code, status.HTTP_200_OK) 58 | self.assertEqual(response.data['avatar'], None) 59 | 60 | def test_unauthenticated_put(self): 61 | """ 62 | Test that unauthenticated users cannot put avatars. 63 | 64 | The view should respond with a 401 response, confirming the user 65 | is unauthorised to put to the view. 66 | """ 67 | data = {'avatar': SIMPLE_PNG} 68 | request = APIRequestFactory().put('/', data=data) 69 | view = self.view_class.as_view() 70 | response = view(request) 71 | 72 | self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) 73 | 74 | def test_authenticated_put(self): 75 | user = UserFactory.create() 76 | data = {'avatar': SIMPLE_PNG} 77 | 78 | request = APIRequestFactory().put('/', data=data) 79 | request.user = user 80 | force_authenticate(request, user) 81 | 82 | view = self.view_class.as_view() 83 | with patch('django.core.files.storage.Storage.url') as mocked_url: 84 | mocked_url.return_value = 'mocked-url' 85 | response = view(request) 86 | self.assertEqual(response.status_code, status.HTTP_200_OK) 87 | SIMPLE_PNG.seek(0) 88 | user = User.objects.get(pk=user.pk) 89 | self.assertEqual(user.avatar.read(), SIMPLE_PNG.read()) 90 | 91 | def test_options(self): 92 | request = self.create_request('options') 93 | view = self.view_class.as_view() 94 | response = view(request) 95 | self.assertEqual(response.status_code, status.HTTP_200_OK) 96 | 97 | def test_get_resize(self): 98 | user = UserFactory.build(avatar=SIMPLE_PNG) 99 | 100 | data = { 101 | 'width': 10, 102 | 'height': 10, 103 | } 104 | request = self.create_request(user=user, data=data) 105 | view = self.view_class.as_view() 106 | expected_url = 'mocked-url' 107 | with patch('django.core.files.storage.Storage.url') as mocked_url: 108 | mocked_url.return_value = expected_url 109 | response = view(request) 110 | self.assertEqual(response.status_code, status.HTTP_200_OK) 111 | self.assertNotEqual(response.data['avatar'], expected_url) 112 | 113 | def test_get_resize_width(self): 114 | user = UserFactory.build(avatar=SIMPLE_PNG) 115 | 116 | data = { 117 | 'width': 10, 118 | } 119 | request = self.create_request(user=user, data=data) 120 | view = self.view_class.as_view() 121 | expected_url = 'mocked-url' 122 | with patch('django.core.files.storage.Storage.url') as mocked_url: 123 | mocked_url.return_value = expected_url 124 | response = view(request) 125 | self.assertEqual(response.status_code, status.HTTP_200_OK) 126 | self.assertNotEqual(response.data['avatar'], expected_url) 127 | 128 | def test_get_resize_height(self): 129 | user = UserFactory.build(avatar=SIMPLE_PNG) 130 | 131 | data = { 132 | 'height': 10, 133 | } 134 | request = self.create_request(user=user, data=data) 135 | view = self.view_class.as_view() 136 | expected_url = 'mocked-url' 137 | with patch('django.core.files.storage.Storage.url') as mocked_url: 138 | mocked_url.return_value = expected_url 139 | response = view(request) 140 | self.assertEqual(response.status_code, status.HTTP_200_OK) 141 | self.assertNotEqual(response.data['avatar'], expected_url) 142 | 143 | def test_delete_without_avatar(self): 144 | user = UserFactory.create() 145 | request = self.create_request('delete', user=user) 146 | view = self.view_class.as_view() 147 | response = view(request) 148 | 149 | self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) 150 | 151 | def test_delete_with_avatar(self): 152 | user = UserFactory.create(avatar=SIMPLE_PNG) 153 | request = self.create_request('delete', user=user) 154 | view = self.view_class.as_view() 155 | response = view(request) 156 | 157 | self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) 158 | 159 | user = User.objects.get(pk=user.pk) 160 | self.assertFalse(user.avatar) 161 | 162 | def test_send_without_token_header(self): 163 | """Test support for legacy browsers that cannot support AJAX uploads. 164 | 165 | This shows three things: 166 | - users can authenticate by submitting the token in the form data. 167 | - users can use a POST fallback. 168 | - csrf is not required (the token is equivalent). 169 | """ 170 | client = Client(enforce_csrf_checks=True) 171 | user = UserFactory.create() 172 | token = AuthTokenFactory(user=user) 173 | 174 | data = {'avatar': SIMPLE_PNG, 'token': token.key} 175 | url = reverse('user_management_api_avatar:profile_avatar') 176 | response = client.post(url, data=data) 177 | 178 | self.assertEqual(response.status_code, status.HTTP_200_OK) 179 | 180 | self.assertIn('avatar', response.data) 181 | 182 | 183 | class TestUserAvatar(APIRequestTestCase): 184 | view_class = views.UserAvatar 185 | 186 | def setUp(self): 187 | self.user = UserFactory.build() 188 | self.other_user = UserFactory.build(avatar=SIMPLE_PNG) 189 | 190 | def tearDown(self): 191 | SIMPLE_PNG.seek(0) 192 | 193 | def get_response(self, request): 194 | """ 195 | Create a response object by patching view_class.get_object to return 196 | self.other_user, allowing self.other_user to not be saved. 197 | """ 198 | view = self.view_class.as_view() 199 | with patch.object(self.view_class, 'get_object') as get_object: 200 | get_object.return_value = self.other_user 201 | response = view(request) 202 | return response 203 | 204 | def check_method_forbidden(self, method): 205 | request = self.create_request(method, user=self.user) 206 | view = self.view_class.as_view() 207 | response = view(request) 208 | self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) 209 | 210 | def test_get_anonymous(self): 211 | request = self.create_request(auth=False) 212 | view = self.view_class.as_view() 213 | response = view(request) 214 | self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) 215 | 216 | def test_post_unauthorised(self): 217 | self.check_method_forbidden('post') 218 | 219 | def test_put_unauthorised(self): 220 | self.check_method_forbidden('put') 221 | 222 | def test_patch_unauthorised(self): 223 | self.check_method_forbidden('patch') 224 | 225 | def test_delete_unauthorised(self): 226 | self.check_method_forbidden('delete') 227 | 228 | def test_delete_not_allowed(self): 229 | """ Tests DELETE user for staff not allowed""" 230 | self.user.is_staff = True 231 | request = self.create_request('delete', user=self.user) 232 | view = self.view_class.as_view() 233 | response = view(request) 234 | self.assertEqual( 235 | response.status_code, 236 | status.HTTP_405_METHOD_NOT_ALLOWED, 237 | ) 238 | 239 | def test_get(self): 240 | request = self.create_request(user=self.user) 241 | response = self.get_response(request) 242 | self.assertEqual(response.status_code, status.HTTP_200_OK) 243 | self.assertEqual(response.data['avatar'], SIMPLE_PNG.url) 244 | 245 | def test_get_no_avatar(self): 246 | self.other_user.avatar = None 247 | request = self.create_request(user=self.user) 248 | response = self.get_response(request) 249 | self.assertEqual(response.status_code, status.HTTP_200_OK) 250 | self.assertEqual(response.data['avatar'], None) 251 | 252 | def test_get_resize(self): 253 | data = { 254 | 'width': 10, 255 | 'height': 10, 256 | } 257 | request = self.create_request(user=self.user, data=data) 258 | expected_url = 'mocked-url' 259 | with patch('django.core.files.storage.Storage.url') as mocked_url: 260 | mocked_url.return_value = expected_url 261 | response = self.get_response(request) 262 | self.assertEqual(response.status_code, status.HTTP_200_OK) 263 | self.assertNotEqual(response.data['avatar'], expected_url) 264 | 265 | def test_get_resize_width(self): 266 | data = { 267 | 'width': 10, 268 | } 269 | request = self.create_request(user=self.user, data=data) 270 | expected_url = 'mocked-url' 271 | with patch('django.core.files.storage.Storage.url') as mocked_url: 272 | mocked_url.return_value = expected_url 273 | response = self.get_response(request) 274 | self.assertEqual(response.status_code, status.HTTP_200_OK) 275 | self.assertNotEqual(response.data['avatar'], expected_url) 276 | 277 | def test_get_resize_height(self): 278 | data = { 279 | 'height': 10, 280 | } 281 | request = self.create_request(user=self.user, data=data) 282 | expected_url = 'mocked-url' 283 | with patch('django.core.files.storage.Storage.url') as mocked_url: 284 | mocked_url.return_value = expected_url 285 | response = self.get_response(request) 286 | self.assertEqual(response.status_code, status.HTTP_200_OK) 287 | self.assertNotEqual(response.data['avatar'], expected_url) 288 | 289 | def test_put(self): 290 | user = UserFactory.build(is_staff=True) 291 | other_user = UserFactory.create() 292 | data = {'avatar': SIMPLE_PNG} 293 | 294 | request = APIRequestFactory().put('/', data=data) 295 | request.user = user 296 | force_authenticate(request, user) 297 | 298 | view = self.view_class.as_view() 299 | with patch('django.core.files.storage.Storage.url') as mocked_url: 300 | mocked_url.return_value = 'mocked-url' 301 | response = view(request, pk=other_user.pk) 302 | self.assertEqual(response.status_code, status.HTTP_200_OK) 303 | SIMPLE_PNG.seek(0) 304 | user = User.objects.get(pk=other_user.pk) 305 | self.assertEqual(user.avatar.read(), SIMPLE_PNG.read()) 306 | 307 | def test_patch(self): 308 | user = UserFactory.build(is_staff=True) 309 | other_user = UserFactory.create() 310 | data = {'avatar': SIMPLE_PNG} 311 | 312 | request = APIRequestFactory().patch('/', data=data) 313 | request.user = user 314 | force_authenticate(request, user) 315 | 316 | view = self.view_class.as_view() 317 | with patch('django.core.files.storage.Storage.url') as mocked_url: 318 | mocked_url.return_value = 'mocked-url' 319 | response = view(request, pk=other_user.pk) 320 | self.assertEqual(response.status_code, status.HTTP_200_OK) 321 | SIMPLE_PNG.seek(0) 322 | user = User.objects.get(pk=other_user.pk) 323 | self.assertEqual(user.avatar.read(), SIMPLE_PNG.read()) 324 | 325 | def test_send_without_token_header(self): 326 | """Test support for legacy browsers that cannot support AJAX uploads. 327 | 328 | This shows three things: 329 | - users can authenticate by submitting the token in the form data. 330 | - users can use a POST fallback. 331 | - csrf is not required (the token is equivalent). 332 | """ 333 | client = Client(enforce_csrf_checks=True) 334 | user = UserFactory.create(is_staff=True) 335 | token = AuthTokenFactory(user=user) 336 | 337 | data = {'avatar': SIMPLE_PNG, 'token': token.key} 338 | url_kwargs = {'pk': user.pk} 339 | url = reverse('user_management_api_avatar:user_avatar', kwargs=url_kwargs) 340 | response = client.post(url, data=data) 341 | 342 | self.assertEqual(response.status_code, status.HTTP_200_OK) 343 | 344 | self.assertIn('avatar', response.data) 345 | -------------------------------------------------------------------------------- /user_management/api/avatar/urls/__init__.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import include, url 2 | 3 | 4 | app_name = 'user_management_api_avatar' 5 | urlpatterns = [ 6 | url(r'', include('user_management.api.avatar.urls.profile_avatar')), 7 | url(r'', include('user_management.api.avatar.urls.user_avatar')), 8 | ] 9 | -------------------------------------------------------------------------------- /user_management/api/avatar/urls/profile_avatar.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | from .. import views 4 | 5 | 6 | urlpatterns = [ 7 | url( 8 | regex=r'^profile/avatar/?$', 9 | view=views.ProfileAvatar.as_view(), 10 | name='profile_avatar', 11 | ), 12 | ] 13 | -------------------------------------------------------------------------------- /user_management/api/avatar/urls/user_avatar.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | from .. import views 4 | 5 | 6 | urlpatterns = [ 7 | url( 8 | regex=r'^users/(?P\d+)/avatar/?$', 9 | view=views.UserAvatar.as_view(), 10 | name='user_avatar', 11 | ), 12 | ] 13 | -------------------------------------------------------------------------------- /user_management/api/avatar/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from rest_framework import generics, parsers, response 3 | from rest_framework.permissions import IsAuthenticated 4 | from rest_framework.settings import api_settings 5 | from rest_framework.status import HTTP_204_NO_CONTENT 6 | 7 | from user_management.api import authentication, permissions 8 | from . import serializers 9 | 10 | 11 | User = get_user_model() 12 | 13 | 14 | class AvatarAPIViewBase(generics.RetrieveUpdateAPIView): 15 | """ 16 | Base class for avatar views. Probably shouldn't be used directly. 17 | 18 | Instead, subclass, and at least define the `permission_classes`. 19 | 20 | Can retrieve, update & delete the authenticated user's avatar. Pass GET 21 | parameters to retrieve a thumbnail of the avatar. 22 | 23 | Thumbnail options are specified as get parameters. Options are: 24 | width: Specify the width (in pixels) to resize / crop to. 25 | height: Specify the height (in pixels) to resize / crop to. 26 | crop: Whether to crop or not [1,0] 27 | anchor: Where to anchor the crop [t,r,b,l] 28 | upscale: Whether to upscale or not [1,0] 29 | 30 | To crop avatar to 100x100 anchored to the top right: 31 | avatar?width=100&height=100&crop=1&anchor=tr 32 | 33 | If no options are specified the users avatar is returned. 34 | """ 35 | authentication_classes = api_settings.DEFAULT_AUTHENTICATION_CLASSES + [ 36 | authentication.FormTokenAuthentication, 37 | ] 38 | queryset = User.objects.all() 39 | parser_classes = (parsers.MultiPartParser,) 40 | serializer_class = serializers.AvatarSerializer 41 | 42 | def post(self, *args, **kwargs): 43 | """ 44 | Browsers like to do POST with multipart forms. 45 | 46 | As this is a fallback, we need to allow for that. 47 | """ 48 | return self.put(*args, **kwargs) 49 | 50 | 51 | class ProfileAvatar(AvatarAPIViewBase): 52 | """ 53 | Retrieve and update the authenticated user's avatar. 54 | 55 | We don't inherit from `rest_framework.mixins.DestroyModelMixin` because we 56 | need a custom DELETE with no common functionality. 57 | """ 58 | permission_classes = (IsAuthenticated,) 59 | 60 | def get_object(self): 61 | return self.request.user 62 | 63 | def delete(self, request, *args, **kwargs): 64 | """ 65 | Delete the user's avatar. 66 | 67 | We set `user.avatar = None` instead of calling `user.avatar.delete()` 68 | to avoid test errors with `django.inmemorystorage`. 69 | """ 70 | user = self.get_object() 71 | user.avatar = None 72 | user.save() 73 | return response.Response(status=HTTP_204_NO_CONTENT) 74 | 75 | 76 | class UserAvatar(AvatarAPIViewBase): 77 | """ 78 | Retrieve and update the user's avatar based upon `pk` in the url. 79 | 80 | Allow all users access to "safe" HTTP methods, but limit access to "unsafe" 81 | methods to users with the `is_staff` flag set. 82 | """ 83 | permission_classes = (IsAuthenticated, permissions.IsAdminOrReadOnly) 84 | -------------------------------------------------------------------------------- /user_management/api/exceptions.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import ugettext_lazy as _ 2 | from rest_framework import status 3 | from rest_framework.exceptions import APIException 4 | 5 | 6 | class InvalidExpiredToken(APIException): 7 | """Exception to confirm an account.""" 8 | status_code = status.HTTP_400_BAD_REQUEST 9 | default_detail = _('Invalid or expired token.') 10 | -------------------------------------------------------------------------------- /user_management/api/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/incuna/django-user-management/28cd481d333fa313601825dab4b05f3e51974fe8/user_management/api/management/__init__.py -------------------------------------------------------------------------------- /user_management/api/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/incuna/django-user-management/28cd481d333fa313601825dab4b05f3e51974fe8/user_management/api/management/commands/__init__.py -------------------------------------------------------------------------------- /user_management/api/management/commands/remove_expired_tokens.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | from django.utils import timezone 3 | 4 | from user_management.api.models import AuthToken 5 | 6 | 7 | class Command(BaseCommand): 8 | help = "Remove expired auth tokens from the database." 9 | 10 | def handle(self, *args, **options): 11 | now = timezone.now() 12 | AuthToken.objects.filter(expires__lte=now).delete() 13 | -------------------------------------------------------------------------------- /user_management/api/models.py: -------------------------------------------------------------------------------- 1 | import binascii 2 | import datetime 3 | import os 4 | 5 | from django.conf import settings 6 | from django.db import models 7 | from django.utils import timezone 8 | 9 | 10 | MINUTE = 60 11 | HOUR = 60 * MINUTE 12 | DAY = 24 * HOUR 13 | # Max expiry time for auth tokens 14 | DEFAULT_AUTH_TOKEN_MAX_AGE = 200 * DAY 15 | # Max inactivity time 16 | DEFAULT_AUTH_TOKEN_MAX_INACTIVITY = 12 * HOUR 17 | 18 | 19 | def update_expiry(created=None): 20 | now = timezone.now() 21 | 22 | if created is None: 23 | created = now 24 | 25 | max_age = getattr( 26 | settings, 27 | 'AUTH_TOKEN_MAX_AGE', 28 | DEFAULT_AUTH_TOKEN_MAX_AGE, 29 | ) 30 | max_age = created + datetime.timedelta(seconds=max_age) 31 | 32 | max_inactivity = getattr( 33 | settings, 34 | 'AUTH_TOKEN_MAX_INACTIVITY', 35 | DEFAULT_AUTH_TOKEN_MAX_INACTIVITY, 36 | ) 37 | max_inactivity = now + datetime.timedelta(seconds=max_inactivity) 38 | 39 | return min(max_inactivity, max_age) 40 | 41 | 42 | class AuthToken(models.Model): 43 | """ 44 | Model for auth tokens with added functionality of controlling 45 | expiration time of tokens. 46 | 47 | Similar to DRF's model but with extra `expires` field. 48 | 49 | It also has FK (not OneToOne relation) to user as the user can have 50 | many tokens (multiple devices) in order to token expiration to work. 51 | """ 52 | key = models.CharField(max_length=40, primary_key=True) 53 | user = models.ForeignKey( 54 | settings.AUTH_USER_MODEL, 55 | related_name='authtoken', 56 | on_delete=models.CASCADE, 57 | ) 58 | created = models.DateTimeField(default=timezone.now, editable=False) 59 | expires = models.DateTimeField(default=update_expiry, editable=False) 60 | 61 | def __str__(self): 62 | return self.key 63 | 64 | def save(self, *args, **kwargs): 65 | if not self.key: 66 | self.key = self.generate_key() 67 | return super(AuthToken, self).save(*args, **kwargs) 68 | 69 | def generate_key(self): 70 | return binascii.hexlify(os.urandom(20)).decode() 71 | 72 | def update_expiry(self, commit=True): 73 | """Update token's expiration datetime on every auth action.""" 74 | self.expires = update_expiry(self.created) 75 | if commit: 76 | self.save() 77 | -------------------------------------------------------------------------------- /user_management/api/permissions.py: -------------------------------------------------------------------------------- 1 | from rest_framework.permissions import BasePermission, SAFE_METHODS 2 | 3 | 4 | class IsNotAuthenticated(BasePermission): 5 | """Permit only users that are NOT logged in!""" 6 | def has_permission(self, request, view): 7 | return request.user.is_anonymous 8 | 9 | 10 | class IsAdminOrReadOnly(BasePermission): 11 | """ 12 | Ensures user is staff when creating or updating an user otherwise return a 13 | HTTP forbidden (403) 14 | """ 15 | def has_permission(self, request, view): 16 | # safe methods are get, head, options 17 | if request.method in SAFE_METHODS: 18 | return True 19 | 20 | return request.user.is_staff 21 | -------------------------------------------------------------------------------- /user_management/api/serializers.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.utils.translation import ugettext_lazy as _ 3 | from rest_framework import serializers, validators 4 | 5 | from user_management.utils.validators import validate_password_strength 6 | 7 | 8 | User = get_user_model() 9 | 10 | 11 | class UniqueEmailValidator(validators.UniqueValidator): 12 | def filter_queryset(self, value, queryset): 13 | """Check lower-cased email is unique.""" 14 | return super(UniqueEmailValidator, self).filter_queryset( 15 | value.lower(), 16 | queryset, 17 | ) 18 | 19 | 20 | unique_email_validator = UniqueEmailValidator( 21 | queryset=User.objects.all(), 22 | message=_('That email address has already been registered.'), 23 | ) 24 | 25 | 26 | class ValidateEmailMixin(object): 27 | def validate_email(self, value): 28 | return value.lower() 29 | 30 | 31 | class EmailSerializerBase(serializers.Serializer): 32 | """Serializer defining a read-only `email` field.""" 33 | email = serializers.EmailField(max_length=511, label=_('Email address')) 34 | 35 | class Meta: 36 | fields = ('email',) 37 | 38 | 39 | class RegistrationSerializer(ValidateEmailMixin, serializers.ModelSerializer): 40 | email = serializers.EmailField( 41 | label=_('Email address'), 42 | validators=[unique_email_validator], 43 | ) 44 | password = serializers.CharField( 45 | write_only=True, 46 | min_length=8, 47 | label=_('Password'), 48 | validators=[validate_password_strength], 49 | ) 50 | password2 = serializers.CharField( 51 | write_only=True, 52 | min_length=8, 53 | label=_('Repeat password'), 54 | ) 55 | 56 | class Meta: 57 | fields = ('name', 'email', 'password', 'password2') 58 | model = User 59 | 60 | def validate(self, attrs): 61 | password2 = attrs.pop('password2') 62 | if password2 != attrs.get('password'): 63 | msg = _('Your passwords do not match.') 64 | raise serializers.ValidationError({'password2': msg}) 65 | return attrs 66 | 67 | def create(self, validated_data): 68 | password = validated_data.pop('password') 69 | user = self.Meta.model.objects.create(**validated_data) 70 | user.set_password(password) 71 | user.save() 72 | return user 73 | 74 | 75 | class PasswordChangeSerializer(serializers.ModelSerializer): 76 | old_password = serializers.CharField( 77 | write_only=True, 78 | label=_('Old password'), 79 | ) 80 | new_password = serializers.CharField( 81 | write_only=True, 82 | min_length=8, 83 | label=_('New password'), 84 | validators=[validate_password_strength], 85 | ) 86 | new_password2 = serializers.CharField( 87 | write_only=True, 88 | min_length=8, 89 | label=_('Repeat new password'), 90 | ) 91 | 92 | class Meta: 93 | model = User 94 | fields = ('old_password', 'new_password', 'new_password2') 95 | 96 | def update(self, instance, validated_data): 97 | """Check the old password is valid and set the new password.""" 98 | if not instance.check_password(validated_data['old_password']): 99 | msg = _('Invalid password.') 100 | raise serializers.ValidationError({'old_password': msg}) 101 | 102 | instance.set_password(validated_data['new_password']) 103 | instance.save() 104 | return instance 105 | 106 | def validate(self, attrs): 107 | if attrs.get('new_password') != attrs['new_password2']: 108 | msg = _('Your new passwords do not match.') 109 | raise serializers.ValidationError({'new_password2': msg}) 110 | if attrs.get('old_password') == attrs.get('new_password'): 111 | msg = _('Your new password must not be the same as your old password.') 112 | raise serializers.ValidationError({'new_password': msg}) 113 | return attrs 114 | 115 | 116 | class PasswordResetSerializer(serializers.ModelSerializer): 117 | new_password = serializers.CharField( 118 | write_only=True, 119 | min_length=8, 120 | label=_('New password'), 121 | validators=[validate_password_strength], 122 | ) 123 | new_password2 = serializers.CharField( 124 | write_only=True, 125 | min_length=8, 126 | label=_('Repeat new password'), 127 | ) 128 | 129 | class Meta: 130 | model = User 131 | fields = ('new_password', 'new_password2') 132 | 133 | def update(self, instance, validated_data): 134 | """Set the new password for the user.""" 135 | instance.set_password(validated_data['new_password']) 136 | instance.save() 137 | return instance 138 | 139 | def validate(self, attrs): 140 | if attrs.get('new_password') != attrs['new_password2']: 141 | msg = _('Your new passwords do not match.') 142 | raise serializers.ValidationError({'new_password2': msg}) 143 | return attrs 144 | 145 | 146 | class PasswordResetEmailSerializer(EmailSerializerBase): 147 | """Serializer defining an `email` field to reset password.""" 148 | 149 | 150 | class ProfileSerializer(serializers.ModelSerializer): 151 | class Meta: 152 | model = User 153 | fields = ('name', 'email', 'date_joined') 154 | read_only_fields = ('email', 'date_joined') 155 | 156 | 157 | class ResendConfirmationEmailSerializer(EmailSerializerBase): 158 | """Serializer defining an `email` field to resend a confirmation email.""" 159 | def validate_email(self, email): 160 | """ 161 | Validate if email exists and requires a verification. 162 | 163 | `validate_email` will set a `user` attribute on the instance allowing 164 | the view to send an email confirmation. 165 | """ 166 | try: 167 | self.user = User.objects.get_by_natural_key(email) 168 | except User.DoesNotExist: 169 | msg = _('A user with this email address does not exist.') 170 | raise serializers.ValidationError(msg) 171 | 172 | if self.user.email_verified: 173 | msg = _('User email address is already verified.') 174 | raise serializers.ValidationError(msg) 175 | return email 176 | 177 | 178 | class UserSerializer(serializers.HyperlinkedModelSerializer): 179 | class Meta: 180 | model = User 181 | fields = ('url', 'name', 'email', 'date_joined') 182 | read_only_fields = ('email', 'date_joined') 183 | extra_kwargs = { 184 | 'url': { 185 | 'lookup_field': 'pk', 186 | 'view_name': 'user_management_api_users:user_detail', 187 | } 188 | } 189 | 190 | 191 | class UserSerializerCreate(ValidateEmailMixin, UserSerializer): 192 | email = serializers.EmailField( 193 | label=_('Email address'), 194 | validators=[unique_email_validator], 195 | ) 196 | 197 | class Meta(UserSerializer.Meta): 198 | read_only_fields = ('date_joined',) 199 | -------------------------------------------------------------------------------- /user_management/api/templates/user_management/account_validation_email.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 |

3 | {% blocktrans with name=site.name %} 4 | You are receiving this email because your email address has been used to 5 | register an account at {{ name }}. 6 | {% endblocktrans %} 7 |

8 |

9 | {% blocktrans %} 10 | Please click the following link to complete your registration: 11 | {% endblocktrans %} 12 |

13 |

{{ protocol }}://{{ site.domain }}/#/register/verify/{{ token }}/

14 |

15 | {% blocktrans with name=site.name %} 16 | The {{ name }} team. 17 | {% endblocktrans %} 18 |

19 | -------------------------------------------------------------------------------- /user_management/api/templates/user_management/account_validation_email.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %}{% blocktrans with name=site.name %} 2 | You are receiving this email because your email address has been used to 3 | register an account at {{ name }}. 4 | {% endblocktrans %} 5 | {% blocktrans %} 6 | Please click the following link to complete your registration: 7 | {% endblocktrans %} 8 | 9 | {{ protocol }}://{{ site.domain }}/#/register/verify/{{ token }}/ 10 | 11 | {% blocktrans with name=site.name %} 12 | The {{ name }} team. 13 | {% endblocktrans %} 14 | -------------------------------------------------------------------------------- /user_management/api/templates/user_management/password_reset_email.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 |

3 | {% blocktrans with name=site.name %} 4 | You are receiving this email because you requested a password reset 5 | for your user account at {{ name }}. 6 | {% endblocktrans %} 7 |

8 |

9 | {% blocktrans %} 10 | Please go to the following page and choose a new password: 11 | {% endblocktrans %} 12 |

13 |

{{ protocol }}://{{ site.domain }}{% url 'user_management_api_core:password_reset_confirm' uidb64=uid token=token %}

14 |

15 | {% blocktrans with name=site.name %} 16 | The {{ name }} team. 17 | {% endblocktrans %} 18 |

19 | -------------------------------------------------------------------------------- /user_management/api/templates/user_management/password_reset_email.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% blocktrans with name=site.name %} 3 | You are receiving this email because you requested a password reset 4 | for your user account at {{ name }}. 5 | {% endblocktrans %} 6 | 7 | {% blocktrans %} 8 | Please go to the following page and choose a new password: 9 | {% endblocktrans %} 10 | 11 | {{ protocol }}://{{ site.domain }}{% url 'user_management_api_core:password_reset_confirm' uidb64=uid token=token %} 12 | 13 | {% blocktrans with name=site.name %} 14 | The {{ name }} team. 15 | {% endblocktrans %} 16 | -------------------------------------------------------------------------------- /user_management/api/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/incuna/django-user-management/28cd481d333fa313601825dab4b05f3e51974fe8/user_management/api/tests/__init__.py -------------------------------------------------------------------------------- /user_management/api/tests/test_authentication.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import mock 4 | from django.http import QueryDict 5 | from django.test import TestCase 6 | from django.utils import timezone 7 | from rest_framework import exceptions 8 | 9 | from user_management.models.tests.factories import AuthTokenFactory, UserFactory 10 | from ..authentication import FormTokenAuthentication, TokenAuthentication 11 | 12 | 13 | class TestFormTokenAuthentication(TestCase): 14 | def test_no_token(self): 15 | request = mock.Mock(data=QueryDict('')) 16 | response = FormTokenAuthentication().authenticate(request) 17 | self.assertIsNone(response) 18 | 19 | def test_invalid_token(self): 20 | data = QueryDict('', mutable=True) 21 | data.update({'token': 'WOOT'}) 22 | request = mock.Mock(data=data) 23 | response = FormTokenAuthentication().authenticate(request) 24 | self.assertIsNone(response) 25 | 26 | def test_valid_token(self): 27 | token = AuthTokenFactory.create() 28 | data = QueryDict('', mutable=True) 29 | data.update({'token': token.key}) 30 | request = mock.Mock(data=data) 31 | response = FormTokenAuthentication().authenticate(request) 32 | expected = (token.user, token) 33 | self.assertEqual(response, expected) 34 | 35 | 36 | class TestTokenAuthentication(TestCase): 37 | def setUp(self): 38 | self.now = timezone.now() 39 | self.days = 1 40 | self.key = 'k$y' 41 | self.user = UserFactory.create() 42 | self.auth = TokenAuthentication() 43 | 44 | def _create_token(self, when): 45 | token = AuthTokenFactory.create( 46 | key=self.key, 47 | user=self.user, 48 | expires=when, 49 | ) 50 | 51 | return token 52 | 53 | def test_token_expiry_if_valid(self): 54 | # Token is valid till tomorrow 55 | tomorrow = self.now + datetime.timedelta(days=self.days) 56 | token_old = self._create_token(when=tomorrow) 57 | 58 | user, token = self.auth.authenticate_credentials(self.key) 59 | 60 | self.assertEqual(token, token_old) 61 | self.assertEqual(user, self.user) 62 | 63 | def test_token_expiry_if_has_expired(self): 64 | # Token has expired yesterday 65 | yesterday = self.now - datetime.timedelta(days=self.days) 66 | self._create_token(when=yesterday) 67 | 68 | with self.assertRaises(exceptions.AuthenticationFailed): 69 | self.auth.authenticate_credentials(self.key) 70 | 71 | def test_token_inactivity(self): 72 | # Create token valid till tomorrow 73 | tomorrow = self.now + datetime.timedelta(days=self.days) 74 | token_example = self._create_token(when=tomorrow) 75 | 76 | user, token = self.auth.authenticate_credentials(self.key) 77 | 78 | # Ensure tokens are correct 79 | self.assertEqual(token, token_example) 80 | 81 | # Auth again with very low inactivity time 82 | # Token's expiry gets updated in auth process 83 | with self.settings(AUTH_TOKEN_MAX_INACTIVITY=0): 84 | self.auth.authenticate_credentials(self.key) 85 | 86 | # User's token has expired now 87 | with self.assertRaises(exceptions.AuthenticationFailed): 88 | self.auth.authenticate_credentials(self.key) 89 | -------------------------------------------------------------------------------- /user_management/api/tests/test_exceptions.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from rest_framework.status import HTTP_400_BAD_REQUEST 3 | 4 | from ..exceptions import InvalidExpiredToken 5 | 6 | 7 | class InvalidExpiredTokenTest(TestCase): 8 | """Assert `InvalidExpiredToken` behaves as expected.""" 9 | def test_raise(self): 10 | """Assert `InvalidExpiredToken` can be raised.""" 11 | with self.assertRaises(InvalidExpiredToken) as error: 12 | raise InvalidExpiredToken 13 | self.assertEqual(error.exception.status_code, HTTP_400_BAD_REQUEST) 14 | message = error.exception.detail.format() 15 | self.assertEqual(message, 'Invalid or expired token.') 16 | -------------------------------------------------------------------------------- /user_management/api/tests/test_management_commands.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import mock 4 | from django.test.utils import override_settings 5 | from django.utils import timezone 6 | 7 | from user_management.api.models import AuthToken 8 | from user_management.models.tests import utils 9 | from user_management.models.tests.factories import AuthTokenFactory 10 | from ..management.commands import remove_expired_tokens 11 | 12 | 13 | class TestRemoveExpiredTokensManagementCommand(utils.APIRequestTestCase): 14 | def setUp(self): 15 | self.command = remove_expired_tokens.Command() 16 | self.command.stdout = mock.MagicMock() 17 | 18 | def test_no_tokens_removed(self): 19 | """Tests that non-expired tokens are not removed.""" 20 | tomorrow = timezone.now() + datetime.timedelta(days=1) 21 | token = AuthTokenFactory.create(expires=tomorrow) 22 | 23 | self.command.handle() 24 | 25 | expected = AuthToken.objects.all() 26 | self.assertCountEqual(expected, [token]) 27 | 28 | @override_settings(AUTH_TOKEN_MAX_EXPIRY=7) 29 | def test_expired_tokens(self): 30 | """Ensure expired token is removed from db while valid one remains.""" 31 | now = timezone.now() 32 | tomorrow = now + datetime.timedelta(days=1) 33 | valid_token = AuthTokenFactory.create(expires=tomorrow) 34 | 35 | # this token is expired 36 | long_ago = now - datetime.timedelta(days=33) 37 | AuthTokenFactory.create(expires=long_ago) 38 | 39 | self.command.handle() 40 | expected = AuthToken.objects.all() 41 | 42 | self.assertCountEqual(expected, [valid_token]) 43 | -------------------------------------------------------------------------------- /user_management/api/tests/test_models.py: -------------------------------------------------------------------------------- 1 | from user_management.models.tests import factories, utils 2 | from ..models import AuthToken 3 | 4 | 5 | class TestAuthToken(utils.APIRequestTestCase): 6 | model = AuthToken 7 | 8 | def test_fields(self): 9 | fields = {field.name for field in self.model._meta.get_fields()} 10 | 11 | expected = ( 12 | # Inherited from subclassed model 13 | 'key', 14 | 'user', 15 | 'created', 16 | 17 | 'expires', 18 | ) 19 | 20 | self.assertCountEqual(fields, expected) 21 | 22 | def test_unicode(self): 23 | uni_key = 'OSSUM' 24 | obj = self.model(key=uni_key) 25 | self.assertEqual(str(obj), uni_key) 26 | 27 | def test_multiple_tokens(self): 28 | user = factories.UserFactory.create() 29 | tokens = factories.AuthTokenFactory.create_batch(2, user=user) 30 | 31 | expected_tokens = self.model.objects.filter(user=user) 32 | 33 | self.assertCountEqual(tokens, expected_tokens) 34 | -------------------------------------------------------------------------------- /user_management/api/tests/test_serializers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import string 3 | 4 | from django.test import TestCase 5 | from rest_framework.fields import Field 6 | from rest_framework.reverse import reverse 7 | from rest_framework.serializers import ValidationError 8 | 9 | from user_management.models.tests.factories import UserFactory 10 | from user_management.models.tests.utils import RequestTestCase 11 | from user_management.tests.utils import iso_8601 12 | from .. import serializers 13 | 14 | 15 | class ProfileSerializerTest(TestCase): 16 | def test_serialize(self): 17 | user = UserFactory.build() 18 | serializer = serializers.ProfileSerializer(user) 19 | 20 | expected = { 21 | 'name': user.name, 22 | 'email': user.email, 23 | 'date_joined': iso_8601(user.date_joined), 24 | } 25 | self.assertEqual(serializer.data, expected) 26 | 27 | def test_deserialize(self): 28 | user = UserFactory.build() 29 | data = { 30 | 'name': 'New Name', 31 | } 32 | serializer = serializers.ProfileSerializer(user, data=data) 33 | self.assertTrue(serializer.is_valid()) 34 | 35 | 36 | class PasswordChangeSerializerTest(TestCase): 37 | def test_deserialize_passwords(self): 38 | old_password = '0ld_passworD' 39 | new_password = 'n3w_Password' 40 | 41 | user = UserFactory.create(password=old_password) 42 | 43 | serializer = serializers.PasswordChangeSerializer(user, data={ 44 | 'old_password': old_password, 45 | 'new_password': new_password, 46 | 'new_password2': new_password, 47 | }) 48 | self.assertTrue(serializer.is_valid()) 49 | 50 | serializer.save() 51 | self.assertTrue(user.check_password(new_password)) 52 | 53 | def test_deserialize_invalid_old_password(self): 54 | old_password = '0ld_passworD' 55 | new_password = 'n3w_Password' 56 | 57 | user = UserFactory.build(password=old_password) 58 | 59 | serializer = serializers.PasswordChangeSerializer() 60 | data = { 61 | 'old_password': 'invalid_password', 62 | 'new_password': new_password, 63 | 'new_password2': new_password, 64 | } 65 | with self.assertRaises(ValidationError): 66 | serializer.update(user, data) 67 | 68 | def test_deserialize_invalid_new_password(self): 69 | old_password = '0ld_passworD' 70 | new_password = '2Short' 71 | 72 | user = UserFactory.build(password=old_password) 73 | 74 | serializer = serializers.PasswordChangeSerializer(user, data={ 75 | 'old_password': old_password, 76 | 'new_password': new_password, 77 | 'new_password2': new_password, 78 | }) 79 | self.assertFalse(serializer.is_valid()) 80 | self.assertIn('new_password', serializer.errors) 81 | 82 | def test_deserialize_mismatched_passwords(self): 83 | old_password = '0ld_passworD' 84 | new_password = 'n3w_Password' 85 | other_password = 'other_new_password' 86 | 87 | user = UserFactory.create(password=old_password) 88 | 89 | serializer = serializers.PasswordChangeSerializer(user, data={ 90 | 'old_password': old_password, 91 | 'new_password': new_password, 92 | 'new_password2': other_password, 93 | }) 94 | self.assertFalse(serializer.is_valid()) 95 | 96 | def test_no_change_password(self): 97 | """A password cannot be changed if it doesn't change!""" 98 | password = 'Same_passw0rd' 99 | 100 | user = UserFactory.create(password=password) 101 | 102 | serializer = serializers.PasswordChangeSerializer(user, data={ 103 | 'old_password': password, 104 | 'new_password': password, 105 | 'new_password2': password, 106 | } 107 | ) 108 | self.assertFalse(serializer.is_valid()) 109 | self.assertIn('new_password', serializer.errors) 110 | 111 | 112 | class PasswordResetSerializerTest(TestCase): 113 | def test_deserialize_passwords(self): 114 | new_password = 'n3w_Password' 115 | user = UserFactory.create() 116 | 117 | serializer = serializers.PasswordResetSerializer(user, data={ 118 | 'new_password': new_password, 119 | 'new_password2': new_password, 120 | }) 121 | self.assertTrue(serializer.is_valid()) 122 | 123 | serializer.save() 124 | self.assertTrue(user.check_password(new_password)) 125 | 126 | def test_deserialize_invalid_new_password(self): 127 | new_password = '2Short' 128 | user = UserFactory.build() 129 | 130 | serializer = serializers.PasswordResetSerializer(user, data={ 131 | 'new_password': new_password, 132 | 'new_password2': new_password, 133 | }) 134 | self.assertFalse(serializer.is_valid()) 135 | self.assertIn('new_password', serializer.errors) 136 | 137 | def test_deserialize_mismatched_passwords(self): 138 | new_password = 'n3w_Password' 139 | other_password = 'other_new_password' 140 | user = UserFactory.create() 141 | 142 | serializer = serializers.PasswordResetSerializer(user, data={ 143 | 'new_password': new_password, 144 | 'new_password2': other_password, 145 | }) 146 | self.assertFalse(serializer.is_valid()) 147 | 148 | 149 | class RegistrationSerializerTest(TestCase): 150 | def setUp(self): 151 | super(RegistrationSerializerTest, self).setUp() 152 | self.data = { 153 | 'name': "Robert'); DROP TABLE Students;--'", 154 | 'email': 'Bobby.Tables+327@xkcd.com', 155 | 'password': 'Sup3RSecre7paSSw0rD', 156 | 'password2': 'Sup3RSecre7paSSw0rD', 157 | } 158 | 159 | def test_deserialize(self): 160 | serializer = serializers.RegistrationSerializer(data=self.data) 161 | self.assertTrue(serializer.is_valid()) 162 | 163 | validated_data = serializer.validated_data 164 | self.assertEqual(validated_data['name'], self.data['name']) 165 | self.assertEqual(validated_data['email'], self.data['email'].lower()) 166 | self.assertEqual(validated_data['password'], self.data['password']) 167 | 168 | def test_deserialize_invalid_new_password(self): 169 | self.data['password'] = '2short' 170 | 171 | serializer = serializers.RegistrationSerializer(data=self.data) 172 | self.assertFalse(serializer.is_valid()) 173 | self.assertIn('password', serializer.errors) 174 | 175 | def test_deserialize_mismatched_passwords(self): 176 | self.data['password2'] = 'different_password' 177 | serializer = serializers.RegistrationSerializer(data=self.data) 178 | self.assertFalse(serializer.is_valid()) 179 | self.assertIn('password2', serializer.errors) 180 | 181 | def test_deserialize_no_email(self): 182 | self.data['email'] = None 183 | 184 | serializer = serializers.RegistrationSerializer(data=self.data) 185 | self.assertFalse(serializer.is_valid()) 186 | self.assertIn('email', serializer.errors) 187 | 188 | 189 | class UserSerializerTest(RequestTestCase): 190 | def test_serialize(self): 191 | user = UserFactory.create() 192 | request = self.create_request() 193 | context = {'request': request} 194 | serializer = serializers.UserSerializer(user, context=context) 195 | 196 | url = reverse( 197 | 'user_management_api_users:user_detail', 198 | kwargs={'pk': user.pk}, 199 | request=request, 200 | ) 201 | 202 | expected = { 203 | 'url': url, 204 | 'name': user.name, 205 | 'email': user.email, 206 | 'date_joined': iso_8601(user.date_joined), 207 | } 208 | self.assertEqual(serializer.data, expected) 209 | 210 | def test_deserialize(self): 211 | user = UserFactory.build() 212 | data = { 213 | 'name': 'New Name', 214 | } 215 | serializer = serializers.UserSerializer(user, data=data) 216 | self.assertTrue(serializer.is_valid()) 217 | 218 | def test_deserialize_create_email_in_use(self): 219 | other_user = UserFactory.create() 220 | data = { 221 | 'name': "Robert'); DROP TABLE Students;--'", 222 | 'email': other_user.email, 223 | 'password': 'Sup3RSecre7paSSw0rD', 224 | 'password2': 'Sup3RSecre7paSSw0rD', 225 | } 226 | serializer = serializers.RegistrationSerializer(data=data) 227 | self.assertFalse(serializer.is_valid()) 228 | self.assertEqual( 229 | serializer._errors['email'], 230 | ['That email address has already been registered.']) 231 | 232 | def test_deserialize_update_email_in_use(self): 233 | user = UserFactory.create() 234 | other_user = UserFactory.create() 235 | data = { 236 | 'name': "Robert'); DROP TABLE Students;--'", 237 | 'email': other_user.email, 238 | 'password': 'Sup3RSecre7paSSw0rD', 239 | 'password2': 'Sup3RSecre7paSSw0rD', 240 | } 241 | serializer = serializers.RegistrationSerializer(user, data=data) 242 | self.assertFalse(serializer.is_valid()) 243 | self.assertEqual( 244 | serializer._errors['email'], 245 | ['That email address has already been registered.']) 246 | 247 | 248 | class TestUserSerializerCreate(TestCase): 249 | def test_deserialize_no_email(self): 250 | data = { 251 | 'name': "Robert'); DROP TABLE Students;--'", 252 | 'email': None, 253 | 'password': 'Sup3RSecre7paSSw0rD', 254 | 'password2': 'Sup3RSecre7paSSw0rD', 255 | } 256 | 257 | serializer = serializers.UserSerializerCreate(data=data) 258 | self.assertFalse(serializer.is_valid()) 259 | self.assertIn('email', serializer.errors) 260 | 261 | def test_deserialize_email_in_use(self): 262 | other_user = UserFactory.create() 263 | data = { 264 | 'name': "Robert'); DROP TABLE Students;--'", 265 | 'email': other_user.email, 266 | 'password': 'Sup3RSecre7paSSw0rD', 267 | 'password2': 'Sup3RSecre7paSSw0rD', 268 | } 269 | serializer = serializers.UserSerializerCreate(data=data) 270 | self.assertFalse(serializer.is_valid()) 271 | self.assertEqual( 272 | serializer._errors['email'], 273 | ['That email address has already been registered.'], 274 | ) 275 | 276 | 277 | class SerializerPasswordsTest(TestCase): 278 | too_simple = ( 279 | 'Password must have at least ' + 280 | 'one upper case letter, one lower case letter, and one number.' 281 | ) 282 | 283 | too_fancy = ( 284 | 'Password only accepts the following symbols ' + string.punctuation 285 | ) 286 | 287 | serializers = ( 288 | (serializers.PasswordResetSerializer, 'new_password'), 289 | (serializers.PasswordChangeSerializer, 'new_password'), 290 | (serializers.RegistrationSerializer, 'password'), 291 | ) 292 | 293 | def assert_validation_error(self, serializer_class, field, data, expected): 294 | serializer = serializer_class(data=data) 295 | serializer.is_valid() # Perform validation 296 | on_missing = '{} not in {}.errors'.format(field, serializer) 297 | self.assertTrue(field in serializer.errors, on_missing) 298 | error = serializer.errors[field] 299 | on_wrong = 'Expected "{}", got "{}" on {}'.format( 300 | expected, 301 | error, 302 | serializer, 303 | ) 304 | self.assertEqual(error, [expected], on_wrong) 305 | 306 | def assert_no_validation_error(self, serializer_class, field, data): 307 | serializer = serializer_class(data=data) 308 | serializer.is_valid() # Perform validation 309 | on_present = '{} unexpectedly in {}.errors'.format(field, serializer) 310 | self.assertFalse(field in serializer.errors, on_present) 311 | 312 | def test_missing(self): 313 | data = {} 314 | for serializer_class, field in self.serializers: 315 | msg = Field.default_error_messages['required'] 316 | self.assert_validation_error(serializer_class, field, data, msg) 317 | 318 | def test_too_short(self): 319 | for serializer_class, field in self.serializers: 320 | data = {field: 'Aa1'} 321 | msg = 'Ensure this field has at least 8 characters.' 322 | self.assert_validation_error(serializer_class, field, data, msg) 323 | 324 | def test_no_upper(self): 325 | for serializer_class, field in self.serializers: 326 | data = {field: 'aaaa1111'} 327 | msg = self.too_simple 328 | self.assert_validation_error(serializer_class, field, data, msg) 329 | 330 | def test_no_lower(self): 331 | for serializer_class, field in self.serializers: 332 | data = {field: 'AAAA1111'} 333 | msg = self.too_simple 334 | self.assert_validation_error(serializer_class, field, data, msg) 335 | 336 | def test_no_number(self): 337 | for serializer_class, field in self.serializers: 338 | data = {field: 'AAAAaaaa'} 339 | msg = self.too_simple 340 | self.assert_validation_error(serializer_class, field, data, msg) 341 | 342 | def test_symbols(self): 343 | """Ensure all acceptable symbols are acceptable.""" 344 | for serializer_class, field in self.serializers: 345 | for symbol in string.punctuation: 346 | data = {field: 'AAaa111' + symbol} 347 | self.assert_no_validation_error(serializer_class, field, data) 348 | 349 | def test_non_ascii(self): 350 | for serializer_class, field in self.serializers: 351 | data = {field: u'AA11aa££'} # £ is not an ASCII character. 352 | msg = self.too_fancy 353 | self.assert_validation_error(serializer_class, field, data, msg) 354 | 355 | def test_ok(self): 356 | for serializer_class, field in self.serializers: 357 | data = {field: 'AAAaaa11'} 358 | self.assert_no_validation_error(serializer_class, field, data) 359 | 360 | 361 | class ResendConfirmationEmailSerializerTest(TestCase): 362 | def test_serialize(self): 363 | """Assert user can request a new email confirmation.""" 364 | user = UserFactory.create() 365 | data = {'email': user.email} 366 | serializer = serializers.ResendConfirmationEmailSerializer(data=data) 367 | self.assertTrue(serializer.is_valid(), msg=serializer.errors) 368 | 369 | def test_user_does_not_exist(self): 370 | """Assert user should exist before sending email confirmation.""" 371 | data = {'email': 'a-non-existing@user.com'} 372 | serializer = serializers.ResendConfirmationEmailSerializer(data=data) 373 | self.assertFalse(serializer.is_valid()) 374 | 375 | def test_user_already_validated(self): 376 | """Assert confirmation email is not send if user was already verified.""" 377 | user = UserFactory.create(email_verified=True) 378 | data = {'email': user.email} 379 | serializer = serializers.ResendConfirmationEmailSerializer(data=data) 380 | self.assertFalse(serializer.is_valid()) 381 | -------------------------------------------------------------------------------- /user_management/api/tests/test_throttling.py: -------------------------------------------------------------------------------- 1 | from django.core.cache import cache 2 | from django.urls import reverse 3 | from mock import patch 4 | from rest_framework import status 5 | from rest_framework.test import APIRequestFactory 6 | 7 | from user_management.api import views 8 | from user_management.models.tests.factories import UserFactory 9 | from user_management.models.tests.utils import APIRequestTestCase 10 | from .. import throttling 11 | 12 | THROTTLE_RATE_PATH = 'rest_framework.throttling.ScopedRateThrottle.THROTTLE_RATES' 13 | 14 | 15 | class ClearCacheMixin: 16 | """Clear cache on `tearDown`. 17 | 18 | `django-rest-framework` puts a successful (not throttled) request into a cache.""" 19 | def tearDown(self): 20 | cache.clear() 21 | 22 | 23 | class GetAuthTokenTest(ClearCacheMixin, APIRequestTestCase): 24 | """Test `GetAuthToken` is throttled.""" 25 | throttle_expected_status = status.HTTP_429_TOO_MANY_REQUESTS 26 | view_class = views.GetAuthToken 27 | 28 | def setUp(self): 29 | self.auth_url = reverse('user_management_api_core:auth') 30 | 31 | @patch(THROTTLE_RATE_PATH, new={'logins': '1/minute'}) 32 | def test_user_auth_throttle(self): 33 | """Ensure POST requests are throttled correctly.""" 34 | data = { 35 | 'username': 'jimmy@example.com', 36 | 'password': 'password;lol', 37 | } 38 | view = self.view_class.as_view() 39 | 40 | # no token attached hence HTTP 400 41 | request = self.create_request('post', auth=False, data=data) 42 | response = view(request) 43 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 44 | 45 | # request should be throttled now 46 | # (new request created to prevent django.http.request.RawPostDataException) 47 | request = self.create_request('post', auth=False, data=data) 48 | response = view(request) 49 | self.assertEqual(response.status_code, self.throttle_expected_status) 50 | 51 | @patch(THROTTLE_RATE_PATH, new={'logins': '1/minute'}) 52 | def test_user_auth_throttle_ip(self): 53 | """Ensure user gets throttled from a single IP address.""" 54 | data = {} 55 | 56 | request = APIRequestFactory().post(self.auth_url, data, REMOTE_ADDR='127.0.0.1') 57 | response = self.view_class.as_view()(request) 58 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 59 | 60 | request = APIRequestFactory().post(self.auth_url, data) 61 | response = self.view_class.as_view()(request) 62 | self.assertEqual(response.status_code, self.throttle_expected_status) 63 | 64 | @patch(THROTTLE_RATE_PATH, new={'logins': '1/minute'}) 65 | def test_user_auth_throttle_username(self): 66 | """Ensure username is throttled no matter what IP the user connects on.""" 67 | data = {'username': 'jimmy'} 68 | request = APIRequestFactory().post(self.auth_url, data, REMOTE_ADDR='127.0.0.1') 69 | response = self.view_class.as_view()(request) 70 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 71 | 72 | # Different user name to different ip is not throttled 73 | data2 = {'username': 'another_jimmy_here'} 74 | request = APIRequestFactory().post(self.auth_url, data2, REMOTE_ADDR='127.0.0.2') 75 | response = self.view_class.as_view()(request) 76 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 77 | 78 | # Same username to different ip is throttled 79 | request = APIRequestFactory().post(self.auth_url, data, REMOTE_ADDR='127.0.0.3') 80 | response = self.view_class.as_view()(request) 81 | self.assertEqual(response.status_code, self.throttle_expected_status) 82 | 83 | @patch(THROTTLE_RATE_PATH, new={'logins': '1/minute'}) 84 | def test_authenticated_user_not_throttled(self): 85 | """An authenticated user is not throttled by UsernameLoginRateThrottle.""" 86 | 87 | class View(self.view_class): 88 | throttle_classes = [throttling.UsernameLoginRateThrottle] 89 | 90 | view = View.as_view() 91 | 92 | # We are not throttled here 93 | request = self.create_request('post', data={}) 94 | response = view(request) 95 | self.assertNotEqual(response.status_code, self.throttle_expected_status) 96 | 97 | # Or here 98 | # (new request created to prevent django.http.request.RawPostDataException) 99 | request = self.create_request('post', data={}) 100 | response = view(request) 101 | self.assertNotEqual(response.status_code, self.throttle_expected_status) 102 | 103 | 104 | class TestPasswordResetEmail(ClearCacheMixin, APIRequestTestCase): 105 | """Test `PasswordResetEmail` is throttled.""" 106 | view_class = views.PasswordResetEmail 107 | 108 | @patch(THROTTLE_RATE_PATH, new={'passwords': '1/minute'}) 109 | def test_post_rate_limit(self): 110 | """Ensure the POST requests are rate limited.""" 111 | email = 'exists@example.com' 112 | UserFactory.create(email=email) 113 | 114 | data = {'email': email} 115 | request = self.create_request('post', data=data, auth=False) 116 | view = self.view_class.as_view() 117 | 118 | response = view(request) 119 | self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) 120 | 121 | # request is throttled 122 | response = view(request) 123 | self.assertEqual(response.status_code, status.HTTP_429_TOO_MANY_REQUESTS) 124 | 125 | 126 | class TestResendConfirmationEmail(ClearCacheMixin, APIRequestTestCase): 127 | """Test `ResendConfirmationEmail` is throttled.""" 128 | view_class = views.ResendConfirmationEmail 129 | 130 | @patch(THROTTLE_RATE_PATH, new={'confirmations': '1/minute'}) 131 | def test_post_rate_limit(self): 132 | """Assert POST requests are rate limited.""" 133 | user = UserFactory.create() 134 | data = {'email': user.email} 135 | 136 | request = self.create_request('post', data=data, auth=False) 137 | view = self.view_class.as_view() 138 | 139 | response = view(request) 140 | self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) 141 | 142 | # Duplicate the request here as we are accessing request.data twice. To 143 | # get the `request.data`, `djangorestframework` `read` the `POST` request 144 | # (a stream) without rewinding the stream. 145 | # This was producing an `Assertion` error only happening in test where 146 | # calling the `view` with the same `request` twice is returning: 147 | # {'detail': 'JSON parse error - No JSON object could be decoded'}` 148 | # https://github.com/tomchristie/django-rest-framework/blob/650a91ac24cbd3e5b4ad5d7d7c6706fdf6160a78/rest_framework/parsers.py#L60-L64 149 | request = self.create_request('post', data=data, auth=False) 150 | view = self.view_class.as_view() 151 | response = view(request) 152 | self.assertEqual( 153 | response.status_code, 154 | status.HTTP_429_TOO_MANY_REQUESTS, 155 | msg=response.data, 156 | ) 157 | -------------------------------------------------------------------------------- /user_management/api/tests/test_urls.py: -------------------------------------------------------------------------------- 1 | from incuna_test_utils.testcases.urls import URLTestCase 2 | 3 | from .. import views 4 | 5 | 6 | class TestURLs(URLTestCase): 7 | """Ensure the urls work.""" 8 | 9 | def test_auth_token_url(self): 10 | self.assert_url_matches_view( 11 | view=views.GetAuthToken, 12 | expected_url='/auth', 13 | url_name='user_management_api_core:auth') 14 | 15 | def test_password_reset_confirm_url(self): 16 | self.assert_url_matches_view( 17 | view=views.PasswordReset, 18 | expected_url='/auth/password_reset/confirm/a/x-y', 19 | url_name='user_management_api_core:password_reset_confirm', 20 | url_kwargs={'uidb64': 'a', 'token': 'x-y'}) 21 | 22 | def test_password_reset_url(self): 23 | self.assert_url_matches_view( 24 | view=views.PasswordResetEmail, 25 | expected_url='/auth/password_reset', 26 | url_name='user_management_api_core:password_reset') 27 | 28 | def test_profile_detail_url(self): 29 | self.assert_url_matches_view( 30 | view=views.ProfileDetail, 31 | expected_url='/profile', 32 | url_name='user_management_api_core:profile_detail') 33 | 34 | def test_password_change_url(self): 35 | self.assert_url_matches_view( 36 | view=views.PasswordChange, 37 | expected_url='/profile/password', 38 | url_name='user_management_api_core:password_change') 39 | 40 | def test_register_url(self): 41 | self.assert_url_matches_view( 42 | view=views.UserRegister, 43 | expected_url='/register', 44 | url_name='user_management_api_core:register') 45 | 46 | def test_user_detail_url(self): 47 | self.assert_url_matches_view( 48 | view=views.UserDetail, 49 | expected_url='/users/1', 50 | url_name='user_management_api_users:user_detail', 51 | url_kwargs={'pk': 1}) 52 | 53 | def test_user_list_url(self): 54 | self.assert_url_matches_view( 55 | view=views.UserList, 56 | expected_url='/users', 57 | url_name='user_management_api_users:user_list') 58 | 59 | def test_verify_email(self): 60 | """Assert `verify_user` is defined.""" 61 | token = 'a-token' 62 | self.assert_url_matches_view( 63 | view=views.VerifyAccountView, 64 | expected_url='/verify_email/{}'.format(token), 65 | url_name='user_management_api_verify:verify_user', 66 | url_kwargs={'token': token}, 67 | ) 68 | 69 | def test_resend_confirmation_email(self): 70 | """Assert `resend_confirmation_email` is defined.""" 71 | self.assert_url_matches_view( 72 | view=views.ResendConfirmationEmail, 73 | expected_url='/resend-confirmation-email', 74 | url_name='user_management_api_core:resend_confirmation_email', 75 | ) 76 | -------------------------------------------------------------------------------- /user_management/api/tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import include, url 2 | from django.contrib.auth.views import LoginView 3 | 4 | 5 | urlpatterns = [ 6 | url(r'', include( 7 | 'user_management.api.urls', 8 | namespace='user_management_api_core', 9 | )), 10 | url(r'', include( 11 | 'user_management.api.urls.verify_email', 12 | namespace='user_management_api_verify', 13 | )), 14 | url(r'', include( 15 | 'user_management.api.urls.users', 16 | namespace='user_management_api_users', 17 | )), 18 | url(r'', include( 19 | 'user_management.api.avatar.urls', 20 | namespace='user_management_api_avatar', 21 | )), 22 | url(r'', include( 23 | 'user_management.ui.urls', 24 | namespace='user_management_ui', 25 | )), 26 | url(r'^login/$', LoginView.as_view(), name='login') 27 | ] 28 | -------------------------------------------------------------------------------- /user_management/api/throttling.py: -------------------------------------------------------------------------------- 1 | from rest_framework.throttling import ScopedRateThrottle 2 | 3 | 4 | class DefaultRateMixin(object): 5 | def get_rate(self): 6 | try: 7 | return self.THROTTLE_RATES[self.scope] 8 | except KeyError: 9 | return self.default_rate 10 | 11 | 12 | class PostRequestThrottleMixin(object): 13 | def allow_request(self, request, view): 14 | """ 15 | Throttle POST requests only. 16 | """ 17 | if request.method != 'POST': 18 | return True 19 | 20 | return super(PostRequestThrottleMixin, self).allow_request(request, view) 21 | 22 | 23 | class ScopedRateThrottleBase( 24 | DefaultRateMixin, PostRequestThrottleMixin, ScopedRateThrottle): 25 | """Base class to define a scoped rate throttle on POST request.""" 26 | 27 | 28 | class LoginRateThrottle(ScopedRateThrottleBase): 29 | default_rate = '10/hour' 30 | 31 | 32 | class UsernameLoginRateThrottle(LoginRateThrottle): 33 | def get_cache_key(self, request, view): 34 | if request.user.is_authenticated: 35 | return None # Only throttle unauthenticated requests 36 | 37 | ident = request.POST.get('username') 38 | if ident is None: 39 | return None # Only throttle username requests 40 | 41 | return self.cache_format % { 42 | 'scope': self.scope, 43 | 'ident': ident.strip().lower(), 44 | } 45 | 46 | 47 | class PasswordResetRateThrottle(ScopedRateThrottleBase): 48 | """Set `default_rate` for scoped rate POST requests on password reset.""" 49 | default_rate = '3/hour' 50 | 51 | 52 | class ResendConfirmationEmailRateThrottle(ScopedRateThrottleBase): 53 | """Set `default_rate` for scoped rate POST requests on `ResendConfirmationEmail`.""" 54 | default_rate = '3/hour' 55 | -------------------------------------------------------------------------------- /user_management/api/urls/__init__.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import include, url 2 | 3 | 4 | app_name = 'user_management_api_core' 5 | urlpatterns = [ 6 | url(r'', include('user_management.api.urls.auth')), 7 | url(r'', include('user_management.api.urls.password_reset')), 8 | url(r'', include('user_management.api.urls.profile')), 9 | url(r'', include('user_management.api.urls.register')), 10 | ] 11 | -------------------------------------------------------------------------------- /user_management/api/urls/auth.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | from .. import views 4 | 5 | 6 | urlpatterns = [ 7 | url( 8 | regex=r'^auth/?$', 9 | view=views.GetAuthToken.as_view(), 10 | name='auth', 11 | ), 12 | ] 13 | -------------------------------------------------------------------------------- /user_management/api/urls/password_reset.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | from django.views.decorators.csrf import csrf_exempt 3 | 4 | from .. import views 5 | 6 | 7 | urlpatterns = [ 8 | url( 9 | regex=( 10 | r'^auth/password_reset/confirm/(?P[0-9A-Za-z_\-]+)/' 11 | r'(?P[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/?$' 12 | ), 13 | view=csrf_exempt(views.PasswordReset.as_view()), 14 | name='password_reset_confirm', 15 | ), 16 | url( 17 | regex=r'^auth/password_reset/?$', 18 | view=views.PasswordResetEmail.as_view(), 19 | name='password_reset', 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /user_management/api/urls/profile.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | from .. import views 4 | 5 | 6 | urlpatterns = [ 7 | url( 8 | regex=r'^profile/?$', 9 | view=views.ProfileDetail.as_view(), 10 | name='profile_detail', 11 | ), 12 | url( 13 | regex=r'^profile/password/?$', 14 | view=views.PasswordChange.as_view(), 15 | name='password_change', 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /user_management/api/urls/register.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | from .. import views 4 | 5 | 6 | urlpatterns = [ 7 | url( 8 | regex=r'^register/?$', 9 | view=views.UserRegister.as_view(), 10 | name='register', 11 | ), 12 | url( 13 | regex=r'^resend-confirmation-email/?$', 14 | view=views.ResendConfirmationEmail.as_view(), 15 | name='resend_confirmation_email', 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /user_management/api/urls/users.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | from .. import views 4 | 5 | 6 | app_name = 'user_management_api_users' 7 | urlpatterns = [ 8 | url( 9 | regex=r'^users/?$', 10 | view=views.UserList.as_view(), 11 | name='user_list' 12 | ), 13 | url( 14 | regex=r'^users/(?P\d+)/?$', 15 | view=views.UserDetail.as_view(), 16 | name='user_detail' 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /user_management/api/urls/verify_email.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | from django.views.decorators.csrf import csrf_exempt 3 | 4 | from .. import views 5 | 6 | 7 | app_name = 'user_management_api_verify' 8 | urlpatterns = [ 9 | url( 10 | regex=( 11 | r'^verify_email/(?P[0-9A-Za-z:\-_]+)/?$' 12 | ), 13 | view=csrf_exempt(views.VerifyAccountView.as_view()), 14 | name='verify_user', 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /user_management/api/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model, signals 2 | from django.contrib.auth.tokens import default_token_generator 3 | from django.utils.encoding import force_text 4 | from django.utils.http import urlsafe_base64_decode 5 | from django.utils.translation import ugettext_lazy as _ 6 | from rest_framework import generics, response, status, views 7 | from rest_framework.authentication import get_authorization_header 8 | from rest_framework.authtoken.views import ObtainAuthToken 9 | from rest_framework.exceptions import PermissionDenied 10 | from rest_framework.permissions import AllowAny, IsAuthenticated 11 | 12 | from user_management.utils.views import VerifyAccountViewMixin 13 | from . import exceptions, models, permissions, serializers, throttling 14 | 15 | User = get_user_model() 16 | 17 | 18 | class GetAuthToken(ObtainAuthToken): 19 | """ 20 | Obtain an authentication token. 21 | 22 | Define a `POST` (create) method to authenticate a user using their `email` and 23 | `password` and return a `token` if successful. 24 | The `token` remains valid until `settings.AUTH_TOKEN_MAX_AGE` time has passed. 25 | 26 | `DELETE` method removes the current `token` from the database. 27 | """ 28 | model = models.AuthToken 29 | throttle_classes = [ 30 | throttling.UsernameLoginRateThrottle, 31 | throttling.LoginRateThrottle, 32 | ] 33 | throttle_scope = 'logins' 34 | 35 | def post(self, request): 36 | """Create auth token. Differs from DRF that it always creates new token 37 | but not re-using them.""" 38 | serializer = self.serializer_class(data=request.data) 39 | if serializer.is_valid(): 40 | user = serializer.validated_data['user'] 41 | signals.user_logged_in.send(type(self), user=user, request=request) 42 | token = self.model.objects.create(user=user) 43 | token.update_expiry() 44 | return response.Response({'token': token.key}) 45 | 46 | return response.Response( 47 | serializer.errors, status=status.HTTP_400_BAD_REQUEST) 48 | 49 | def delete(self, request, *args, **kwargs): 50 | """Delete auth token when `delete` request was issued.""" 51 | # Logic repeated from DRF because one cannot easily reuse it 52 | auth = get_authorization_header(request).split() 53 | 54 | if not auth or auth[0].lower() != b'token': 55 | return response.Response(status=status.HTTP_400_BAD_REQUEST) 56 | 57 | if len(auth) == 1: 58 | msg = 'Invalid token header. No credentials provided.' 59 | return response.Response(msg, status=status.HTTP_400_BAD_REQUEST) 60 | elif len(auth) > 2: 61 | msg = 'Invalid token header. Token string should not contain spaces.' 62 | return response.Response(msg, status=status.HTTP_400_BAD_REQUEST) 63 | 64 | try: 65 | token = self.model.objects.get(key=auth[1].decode('utf-8')) 66 | except self.model.DoesNotExist: 67 | pass 68 | else: 69 | token.delete() 70 | signals.user_logged_out.send( 71 | type(self), 72 | user=token.user, 73 | request=request, 74 | ) 75 | return response.Response(status=status.HTTP_204_NO_CONTENT) 76 | 77 | 78 | class UserRegister(generics.CreateAPIView): 79 | """ 80 | Register a new `User`. 81 | 82 | An email to validate the new account is sent if `email_verified` 83 | is set to `False`. 84 | """ 85 | serializer_class = serializers.RegistrationSerializer 86 | permission_classes = [permissions.IsNotAuthenticated] 87 | 88 | def create(self, request, *args, **kwargs): 89 | serializer = self.get_serializer(data=request.data) 90 | if serializer.is_valid(): 91 | return self.is_valid(serializer) 92 | return self.is_invalid(serializer) 93 | 94 | def is_invalid(self, serializer): 95 | return response.Response( 96 | data=serializer.errors, 97 | status=status.HTTP_400_BAD_REQUEST, 98 | ) 99 | 100 | def is_valid(self, serializer): 101 | user = serializer.save() 102 | if not user.email_verified: 103 | user.send_validation_email() 104 | ok_message = _( 105 | 'Your account has been created and an activation link sent ' + 106 | 'to your email address. Please check your email to continue.' 107 | ) 108 | else: 109 | ok_message = _('Your account has been created.') 110 | 111 | return response.Response( 112 | data={'data': ok_message}, 113 | status=status.HTTP_201_CREATED, 114 | ) 115 | 116 | 117 | class PasswordResetEmail(generics.GenericAPIView): 118 | """ 119 | Send a password reset email to a user on request. 120 | 121 | A user can request a password request email by providing their email address. 122 | If the user is not found no error is raised. 123 | """ 124 | permission_classes = [permissions.IsNotAuthenticated] 125 | template_name = 'user_management/password_reset_email.html' 126 | serializer_class = serializers.PasswordResetEmailSerializer 127 | throttle_classes = [throttling.PasswordResetRateThrottle] 128 | throttle_scope = 'passwords' 129 | 130 | def post(self, request, *args, **kwargs): 131 | serializer = self.get_serializer(data=request.data) 132 | if not serializer.is_valid(): 133 | return response.Response( 134 | serializer.errors, 135 | status=status.HTTP_400_BAD_REQUEST, 136 | ) 137 | 138 | email = serializer.data['email'] 139 | try: 140 | user = User.objects.get_by_natural_key(email) 141 | except User.DoesNotExist: 142 | pass 143 | else: 144 | user.send_password_reset() 145 | 146 | msg = _('Password reset request successful. Please check your email.') 147 | return response.Response(msg, status=status.HTTP_204_NO_CONTENT) 148 | 149 | 150 | class OneTimeUseAPIMixin(object): 151 | """ 152 | Use a `uid` and a `token` to allow one-time access to a view. 153 | 154 | Set user as a class attribute or raise an `InvalidExpiredToken`. 155 | """ 156 | def initial(self, request, *args, **kwargs): 157 | uidb64 = kwargs['uidb64'] 158 | uid = urlsafe_base64_decode(force_text(uidb64)) 159 | 160 | try: 161 | self.user = User.objects.get(pk=uid) 162 | except User.DoesNotExist: 163 | raise exceptions.InvalidExpiredToken() 164 | 165 | token = kwargs['token'] 166 | if not default_token_generator.check_token(self.user, token): 167 | raise exceptions.InvalidExpiredToken() 168 | 169 | return super(OneTimeUseAPIMixin, self).initial( 170 | request, 171 | *args, 172 | **kwargs 173 | ) 174 | 175 | 176 | class PasswordReset(OneTimeUseAPIMixin, generics.UpdateAPIView): 177 | """ 178 | Reset a user's password. 179 | 180 | This view is generally called when a user has followed an email link to 181 | reset a password. 182 | 183 | This view will check first if the `uid` and `token` are valid. 184 | 185 | `PasswordReset` is called with an `UPDATE` containing the new password 186 | (`new_password` and `new_password2`). 187 | """ 188 | permission_classes = [permissions.IsNotAuthenticated] 189 | model = User 190 | serializer_class = serializers.PasswordResetSerializer 191 | 192 | def get_object(self): 193 | return self.user 194 | 195 | 196 | class PasswordChange(generics.UpdateAPIView): 197 | """ 198 | Change a user's password. 199 | 200 | Give ability to `PUT` (update) a password when authenticated by submitting current 201 | password. 202 | """ 203 | model = User 204 | permission_classes = (IsAuthenticated,) 205 | serializer_class = serializers.PasswordChangeSerializer 206 | 207 | def get_object(self): 208 | return self.request.user 209 | 210 | 211 | class VerifyAccountView(VerifyAccountViewMixin, views.APIView): 212 | """ 213 | Verify a new user's email address. Accepts a POST request with a token in the URL. 214 | """ 215 | permission_classes = [AllowAny] 216 | 217 | def initial(self, request, *args, **kwargs): 218 | self.verify_token(request, *args, **kwargs) 219 | return super(VerifyAccountView, self).initial(request, *args, **kwargs) 220 | 221 | def post(self, request, *args, **kwargs): 222 | self.activate_user() 223 | return response.Response( 224 | data={'data': self.ok_message}, 225 | status=status.HTTP_201_CREATED, 226 | ) 227 | 228 | 229 | class ProfileDetail(generics.RetrieveUpdateDestroyAPIView): 230 | """ 231 | Allow a user to view and edit their profile information. 232 | 233 | `GET`, `UPDATE` and `DELETE` current logged-in user. 234 | """ 235 | model = User 236 | permission_classes = (IsAuthenticated,) 237 | serializer_class = serializers.ProfileSerializer 238 | 239 | def get_object(self): 240 | return self.request.user 241 | 242 | 243 | class UserList(generics.ListCreateAPIView): 244 | """ 245 | Return information about all users and allow creation of new users. 246 | 247 | Allow to `GET` a list users and to `POST` new user for admin user only. 248 | """ 249 | queryset = User.objects.all() 250 | permission_classes = (IsAuthenticated, permissions.IsAdminOrReadOnly) 251 | serializer_class = serializers.UserSerializerCreate 252 | 253 | 254 | class UserDetail(generics.RetrieveUpdateDestroyAPIView): 255 | """ 256 | Display information about a user. 257 | 258 | Allow admin users to update or delete user information. 259 | """ 260 | queryset = User.objects.all() 261 | permission_classes = (IsAuthenticated, permissions.IsAdminOrReadOnly) 262 | serializer_class = serializers.UserSerializer 263 | 264 | 265 | class ResendConfirmationEmail(generics.GenericAPIView): 266 | """ 267 | Resend a confirmation email. 268 | 269 | `POST` request to resend a confirmation email for existing user. If user is 270 | authenticated the email sent should match. 271 | """ 272 | permission_classes = [AllowAny] 273 | serializer_class = serializers.ResendConfirmationEmailSerializer 274 | throttle_classes = [throttling.ResendConfirmationEmailRateThrottle] 275 | throttle_scope = 'confirmations' 276 | 277 | def initial(self, request, *args, **kwargs): 278 | """Disallow users other than the user whose email is being reset.""" 279 | email = request.data.get('email') 280 | if request.user.is_authenticated and email != request.user.email: 281 | raise PermissionDenied() 282 | 283 | return super(ResendConfirmationEmail, self).initial( 284 | request, 285 | *args, 286 | **kwargs 287 | ) 288 | 289 | def post(self, request, *args, **kwargs): 290 | """Validate `email` and send a request to confirm it.""" 291 | serializer = self.serializer_class(data=request.data) 292 | 293 | if not serializer.is_valid(): 294 | return response.Response( 295 | serializer.errors, 296 | status=status.HTTP_400_BAD_REQUEST, 297 | ) 298 | 299 | serializer.user.send_validation_email() 300 | msg = _('Email confirmation sent.') 301 | return response.Response(msg, status=status.HTTP_204_NO_CONTENT) 302 | -------------------------------------------------------------------------------- /user_management/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/incuna/django-user-management/28cd481d333fa313601825dab4b05f3e51974fe8/user_management/models/__init__.py -------------------------------------------------------------------------------- /user_management/models/admin.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | 3 | from django.contrib.auth import get_user_model 4 | from django.contrib.auth.admin import UserAdmin as BaseUserAdmin 5 | 6 | from . import admin_forms 7 | 8 | 9 | User = get_user_model() 10 | 11 | 12 | class UserAdmin(BaseUserAdmin): 13 | form = admin_forms.UserChangeForm 14 | add_form = admin_forms.UserCreationForm 15 | fieldsets = ( 16 | (None, {'fields': ('email', 'password')}), 17 | ('Personal info', {'fields': ('name',)}), 18 | ('Permissions', { 19 | 'fields': ( 20 | 'is_active', 21 | 'is_staff', 22 | 'is_superuser', 23 | 'groups', 24 | 'user_permissions', 25 | ) 26 | }), 27 | ('Important dates', { 28 | 'fields': ('last_login', 'date_joined'), 29 | }), 30 | ) 31 | 32 | add_fieldsets = ( 33 | (None, { 34 | 'classes': ('wide',), 35 | 'fields': ('email', 'password1', 'password2'), 36 | }), 37 | ) 38 | 39 | list_display = ('name', 'email') 40 | list_filter = ('is_active',) 41 | readonly_fields = ('date_joined',) 42 | search_fields = ('name', 'email') 43 | ordering = ('name',) 44 | 45 | 46 | class VerifyUserAdmin(UserAdmin): 47 | readonly_fields = ('date_joined', 'email_verified') 48 | 49 | def get_fieldsets(self, request, obj=None): 50 | fieldsets = super(VerifyUserAdmin, self).get_fieldsets(request, obj) 51 | fieldsets_dict = OrderedDict(fieldsets) 52 | 53 | try: 54 | fields = list(fieldsets_dict['Permissions']['fields']) 55 | except KeyError: 56 | return fieldsets 57 | 58 | try: 59 | index = fields.index('is_active') 60 | except ValueError: 61 | # If get_fieldsets is called twice, 'is_active' will already be 62 | # removed and fieldsets will be correct so return it 63 | return fieldsets 64 | 65 | fields[index] = ('is_active', 'email_verified') 66 | fieldsets_dict['Permissions']['fields'] = tuple(fields) 67 | return tuple(fieldsets_dict.items()) 68 | -------------------------------------------------------------------------------- /user_management/models/admin_forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.contrib.auth import get_user_model 3 | from django.contrib.auth.forms import ReadOnlyPasswordHashField 4 | from django.utils.translation import ugettext_lazy as _ 5 | 6 | 7 | User = get_user_model() 8 | 9 | 10 | class UserCreationForm(forms.ModelForm): 11 | """ 12 | A form that creates a user with no privileges from the given username and 13 | password. 14 | """ 15 | error_messages = { 16 | 'duplicate_email': _('A user with that email address already exists.'), 17 | 'password_mismatch': _("The two password fields didn't match."), 18 | } 19 | password1 = forms.CharField( 20 | label=_('Password'), 21 | widget=forms.PasswordInput, 22 | ) 23 | password2 = forms.CharField( 24 | label=_('Password confirmation'), 25 | widget=forms.PasswordInput, 26 | help_text=_('Enter the same password as above for verification.'), 27 | ) 28 | 29 | class Meta: 30 | fields = ('email',) 31 | model = User 32 | 33 | def clean_email(self): 34 | """ 35 | Since User.email is unique, this check is redundant, 36 | but it sets a nicer error message than the ORM. See #13147. 37 | """ 38 | email = self.cleaned_data['email'] 39 | try: 40 | User._default_manager.get(email__iexact=email) 41 | except User.DoesNotExist: 42 | return email.lower() 43 | raise forms.ValidationError(self.error_messages['duplicate_email']) 44 | 45 | def clean(self): 46 | cleaned_data = super(UserCreationForm, self).clean() 47 | password1 = cleaned_data.get('password1') 48 | password2 = cleaned_data.get('password2') 49 | if password1 and password2 and password1 != password2: 50 | raise forms.ValidationError( 51 | self.error_messages['password_mismatch']) 52 | return cleaned_data 53 | 54 | def save(self, commit=True): 55 | user = super(UserCreationForm, self).save(commit=False) 56 | user.set_password(self.cleaned_data['password1']) 57 | if commit: 58 | user.save() 59 | return user 60 | 61 | 62 | class UserChangeForm(forms.ModelForm): 63 | """ 64 | A form for updating users. 65 | 66 | Includes all the fields on the user, but replaces the password field with 67 | admin's password hash display field. 68 | """ 69 | password = ReadOnlyPasswordHashField() 70 | 71 | class Meta: 72 | fields = ( 73 | 'email', 74 | 'groups', 75 | 'is_active', 76 | 'is_staff', 77 | 'is_superuser', 78 | 'last_login', 79 | 'name', 80 | 'password', 81 | 'user_permissions', 82 | ) 83 | model = User 84 | 85 | def clean_password(self): 86 | """ 87 | Regardless of what the user provides, return the initial value. 88 | 89 | This is done here, rather than on the field, because the field does 90 | not have access to the initial value. 91 | """ 92 | return self.initial['password'] 93 | -------------------------------------------------------------------------------- /user_management/models/backends.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.contrib.auth.backends import ModelBackend 3 | 4 | 5 | class CaseInsensitiveEmailBackend(ModelBackend): 6 | def authenticate(self, request, username=None, password=None, **kwargs): 7 | UserModel = get_user_model() 8 | if username is None: 9 | username = kwargs.get(UserModel.USERNAME_FIELD) 10 | 11 | username_field = UserModel.USERNAME_FIELD + '__iexact' 12 | try: 13 | user = UserModel.objects.get(**{username_field: username}) 14 | except UserModel.DoesNotExist: 15 | # Run the default password hasher once to reduce the timing 16 | # difference between an existing and a non-existing user (#20760). 17 | UserModel().set_password(password) 18 | else: 19 | if user.check_password(password) and self.user_can_authenticate(user): 20 | return user 21 | -------------------------------------------------------------------------------- /user_management/models/mixins.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import BaseUserManager 2 | from django.contrib.auth.tokens import default_token_generator 3 | from django.contrib.postgres.fields import CIEmailField 4 | from django.contrib.sites.models import Site 5 | from django.core import checks, signing 6 | from django.db import models 7 | from django.utils import timezone 8 | from django.utils.encoding import force_bytes 9 | from django.utils.http import urlsafe_base64_encode 10 | from django.utils.translation import ugettext_lazy as _ 11 | 12 | from user_management.utils import notifications 13 | 14 | 15 | class UserManager(BaseUserManager): 16 | """Django requires user managers to have create_user & create_superuser.""" 17 | def create_user(self, email, password=None, **extra_fields): 18 | if not email: 19 | raise ValueError(_('The given email address must be set')) 20 | email = self.normalize_email(email).lower() 21 | user = self.model( 22 | email=email, 23 | last_login=timezone.now(), 24 | **extra_fields 25 | ) 26 | user.set_password(password) 27 | user.save(using=self._db) 28 | return user 29 | 30 | def create_superuser(self, email, password, **extra_fields): 31 | fields = { 32 | 'is_staff': True, 33 | 'is_superuser': True, 34 | } 35 | fields.update(extra_fields) 36 | user = self.create_user(email, password, **fields) 37 | return user 38 | 39 | def get_by_natural_key(self, email): 40 | """Get user by email with case-insensitive exact match. 41 | 42 | `get_by_natural_key` is used to `authenticate` a user, see: 43 | https://github.com/django/django/blob/c5780adeecfbd85a80b5aa7130dd86e78b23e497/django/contrib/auth/backends.py#L16 44 | """ 45 | return self.get(email__iexact=email) 46 | 47 | 48 | class DateJoinedUserMixin(models.Model): 49 | date_joined = models.DateTimeField( 50 | verbose_name=_('date joined'), 51 | default=timezone.now, 52 | editable=False, 53 | ) 54 | 55 | class Meta: 56 | abstract = True 57 | 58 | 59 | class EmailUserMixin(models.Model): 60 | email = CIEmailField( 61 | verbose_name=_('Email address'), 62 | unique=True, 63 | max_length=511, 64 | ) 65 | email_verified = True 66 | 67 | objects = UserManager() 68 | 69 | USERNAME_FIELD = 'email' 70 | 71 | class Meta: 72 | abstract = True 73 | 74 | 75 | class IsStaffUserMixin(models.Model): 76 | is_staff = models.BooleanField(_('staff status'), default=False) 77 | 78 | class Meta: 79 | abstract = True 80 | 81 | 82 | class NameUserMethodsMixin: 83 | def get_full_name(self): 84 | return self.name 85 | 86 | def get_short_name(self): 87 | return self.name 88 | 89 | def __str__(self): 90 | return self.name 91 | 92 | 93 | class NameUserMixin(NameUserMethodsMixin, models.Model): 94 | name = models.CharField( 95 | verbose_name=_('Name'), 96 | max_length=255, 97 | ) 98 | REQUIRED_FIELDS = ['name'] 99 | 100 | class Meta: 101 | abstract = True 102 | ordering = ['name'] 103 | 104 | 105 | class BasicUserFieldsMixin( 106 | DateJoinedUserMixin, EmailUserMixin, IsStaffUserMixin, NameUserMixin): 107 | class Meta: 108 | abstract = True 109 | 110 | 111 | class ActiveUserMixin(models.Model): 112 | is_active = models.BooleanField(_('active'), default=True) 113 | 114 | class Meta: 115 | abstract = True 116 | 117 | 118 | class VerifyEmailManager(UserManager): 119 | def create_superuser(self, email, password, **extra_fields): 120 | fields = { 121 | 'is_active': True, 122 | } 123 | fields.update(extra_fields) 124 | user = super(VerifyEmailManager, self).create_superuser( 125 | email, 126 | password, 127 | **fields) 128 | return user 129 | 130 | 131 | class EmailVerifyUserMethodsMixin: 132 | """ 133 | Define how validation and password reset emails are sent. 134 | 135 | `password_reset_notification` and `validation_notification` can be overriden to 136 | provide custom settings to send emails. 137 | """ 138 | password_reset_notification = notifications.PasswordResetNotification 139 | validation_notification = notifications.ValidationNotification 140 | 141 | def generate_validation_token(self): 142 | """Generate user token for account validation.""" 143 | data = {'email': self.email} 144 | return signing.dumps(data) 145 | 146 | def generate_token(self): 147 | """Generate user token for password reset.""" 148 | return default_token_generator.make_token(self) 149 | 150 | def generate_uid(self): 151 | """Generate user uid for password reset.""" 152 | return urlsafe_base64_encode(force_bytes(self.pk)) 153 | 154 | def send_validation_email(self): 155 | """Send a validation email to the user's email address.""" 156 | if self.email_verified: 157 | raise ValueError(_('Cannot validate already active user.')) 158 | 159 | site = Site.objects.get_current() 160 | self.validation_notification(user=self, site=site).notify() 161 | 162 | def send_password_reset(self): 163 | """Send a password reset to the user's email address.""" 164 | site = Site.objects.get_current() 165 | self.password_reset_notification(user=self, site=site).notify() 166 | 167 | 168 | class EmailVerifyUserMixin(EmailVerifyUserMethodsMixin, models.Model): 169 | is_active = models.BooleanField(_('active'), default=False) 170 | email_verified = models.BooleanField( 171 | _('Email verified?'), 172 | default=False, 173 | help_text=_('Indicates if the email address has been verified.')) 174 | 175 | objects = VerifyEmailManager() 176 | 177 | class Meta: 178 | abstract = True 179 | 180 | @classmethod 181 | def check(cls, **kwargs): 182 | errors = super(EmailVerifyUserMixin, cls).check(**kwargs) 183 | errors.extend(cls._check_manager(**kwargs)) 184 | return errors 185 | 186 | @classmethod 187 | def _check_manager(cls, **kwargs): 188 | if isinstance(cls.objects, VerifyEmailManager): 189 | return [] 190 | 191 | return [ 192 | checks.Warning( 193 | "Manager should be an instance of 'VerifyEmailManager'", 194 | hint="Subclass a custom manager from 'VerifyEmailManager'", 195 | obj=cls, 196 | id='user_management.W001', 197 | ), 198 | ] 199 | 200 | 201 | class VerifyEmailMixin(EmailVerifyUserMixin, BasicUserFieldsMixin): 202 | class Meta: 203 | abstract = True 204 | 205 | 206 | class AvatarMixin(models.Model): 207 | avatar = models.ImageField(upload_to='user_avatar', null=True, blank=True) 208 | 209 | class Meta: 210 | abstract = True 211 | -------------------------------------------------------------------------------- /user_management/models/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/incuna/django-user-management/28cd481d333fa313601825dab4b05f3e51974fe8/user_management/models/tests/__init__.py -------------------------------------------------------------------------------- /user_management/models/tests/factories.py: -------------------------------------------------------------------------------- 1 | import factory 2 | 3 | from django.contrib.auth import get_user_model 4 | 5 | from user_management.api.models import AuthToken 6 | from user_management.models.tests.models import VerifyEmailUser 7 | 8 | 9 | class UserFactory(factory.DjangoModelFactory): 10 | name = factory.Sequence('Test User {}'.format) 11 | email = factory.Sequence('email{}@example.com'.format) 12 | is_active = True 13 | 14 | class Meta: 15 | model = get_user_model() 16 | 17 | @factory.post_generation 18 | def password(self, create, extracted='default password', **kwargs): 19 | self.raw_password = extracted 20 | self.set_password(self.raw_password) 21 | if create: 22 | self.save() 23 | 24 | 25 | class VerifyEmailUserFactory(UserFactory): 26 | email_verified = False 27 | 28 | class Meta: 29 | model = VerifyEmailUser 30 | 31 | 32 | class AuthTokenFactory(factory.DjangoModelFactory): 33 | key = factory.Sequence('key{}'.format) 34 | user = factory.SubFactory(UserFactory) 35 | 36 | class Meta: 37 | model = AuthToken 38 | -------------------------------------------------------------------------------- /user_management/models/tests/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin 2 | from django.db import models 3 | 4 | 5 | from .notifications import CustomPasswordResetNotification 6 | from ..mixins import ( 7 | AvatarMixin, 8 | BasicUserFieldsMixin, 9 | DateJoinedUserMixin, 10 | EmailUserMixin, 11 | EmailVerifyUserMixin, 12 | IsStaffUserMixin, 13 | NameUserMethodsMixin, 14 | VerifyEmailMixin, 15 | ) 16 | 17 | 18 | class User(AvatarMixin, VerifyEmailMixin, PermissionsMixin, AbstractBaseUser): 19 | pass 20 | 21 | 22 | class BasicUser(BasicUserFieldsMixin, AbstractBaseUser): 23 | pass 24 | 25 | 26 | class VerifyEmailUser(VerifyEmailMixin, AbstractBaseUser): 27 | pass 28 | 29 | 30 | class CustomVerifyEmailUser(VerifyEmailMixin, AbstractBaseUser): 31 | """Customise the notification class to send a password reset.""" 32 | password_reset_notification = CustomPasswordResetNotification 33 | 34 | 35 | class CustomBasicUserFieldsMixin( 36 | NameUserMethodsMixin, EmailUserMixin, DateJoinedUserMixin, 37 | IsStaffUserMixin): 38 | name = models.TextField() 39 | 40 | USERNAME_FIELD = 'email' 41 | 42 | class Meta: 43 | abstract = True 44 | 45 | 46 | class CustomNameUser( 47 | AvatarMixin, EmailVerifyUserMixin, CustomBasicUserFieldsMixin, 48 | AbstractBaseUser): 49 | pass 50 | -------------------------------------------------------------------------------- /user_management/models/tests/notifications.py: -------------------------------------------------------------------------------- 1 | from user_management.utils.notifications import PasswordResetNotification 2 | 3 | 4 | class CustomPasswordResetNotification(PasswordResetNotification): 5 | """Test setting a custom notification to alter how we send the password reset.""" 6 | text_email_template = 'my_custom_email.txt' 7 | html_email_template = None 8 | headers = {'test-header': 'Test'} 9 | -------------------------------------------------------------------------------- /user_management/models/tests/test_admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib.admin.sites import AdminSite 2 | from django.test import TestCase 3 | 4 | from .factories import UserFactory 5 | from .models import User 6 | from ..admin import VerifyUserAdmin 7 | 8 | 9 | class VerifyUserAdminTest(TestCase): 10 | def setUp(self): 11 | self.site = AdminSite() 12 | 13 | def test_create_fieldsets(self): 14 | expected_fieldsets = ( 15 | (None, { 16 | 'classes': ('wide',), 17 | 'fields': ('email', 'password1', 'password2'), 18 | }), 19 | ) 20 | 21 | verify_user_admin = VerifyUserAdmin(User, self.site) 22 | self.assertEqual( 23 | verify_user_admin.get_fieldsets(request=None), 24 | expected_fieldsets, 25 | ) 26 | 27 | def test_fieldsets(self): 28 | expected_fieldsets = ( 29 | (None, {'fields': ('email', 'password')}), 30 | ('Personal info', {'fields': ('name',)}), 31 | ('Permissions', { 32 | 'fields': ( 33 | ('is_active', 'email_verified'), 34 | 'is_staff', 35 | 'is_superuser', 36 | 'groups', 37 | 'user_permissions', 38 | ) 39 | }), 40 | ('Important dates', { 41 | 'fields': ('last_login', 'date_joined'), 42 | }), 43 | ) 44 | user = UserFactory.build() 45 | 46 | verify_user_admin = VerifyUserAdmin(User, self.site) 47 | self.assertEqual( 48 | verify_user_admin.get_fieldsets(request=None, obj=user), 49 | expected_fieldsets, 50 | ) 51 | 52 | # Django admin can call get_fieldsets twice, so check we don't break 53 | self.assertEqual( 54 | verify_user_admin.get_fieldsets(request=None, obj=user), 55 | expected_fieldsets, 56 | ) 57 | -------------------------------------------------------------------------------- /user_management/models/tests/test_admin_forms.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ValidationError 2 | from django.test import TestCase 3 | from incuna_test_utils.compat import Python2AssertMixin 4 | 5 | from . factories import UserFactory 6 | from .. import admin_forms 7 | 8 | 9 | class UserCreationFormTest(Python2AssertMixin, TestCase): 10 | """Assert UserCreationForm` behaves properly.""" 11 | form = admin_forms.UserCreationForm 12 | 13 | def test_fields(self): 14 | """Assert `fields`.""" 15 | fields = self.form.base_fields.keys() 16 | expected = ('email', 'password1', 'password2') 17 | self.assertCountEqual(fields, expected) 18 | 19 | def test_required_fields(self): 20 | """Assert required `fields` are correct.""" 21 | form = self.form({}) 22 | expected = 'This field is required.' 23 | self.assertIn(expected, form.errors['email']) 24 | self.assertIn(expected, form.errors['password1']) 25 | self.assertIn(expected, form.errors['password2']) 26 | 27 | def test_clean_email(self): 28 | email = 'Test@example.com' 29 | 30 | form = self.form() 31 | form.cleaned_data = {'email': email} 32 | 33 | self.assertEqual(form.clean_email(), email.lower()) 34 | 35 | def test_clean_duplicate_email(self): 36 | user = UserFactory.create() 37 | 38 | form = self.form() 39 | form.cleaned_data = {'email': user.email} 40 | 41 | with self.assertRaises(ValidationError): 42 | form.clean_email() 43 | 44 | def test_clean(self): 45 | data = {'password1': 'pass123', 'password2': 'pass123'} 46 | 47 | form = self.form() 48 | form.cleaned_data = data 49 | 50 | self.assertEqual(form.clean(), data) 51 | 52 | def test_clean_mismatched(self): 53 | data = {'password1': 'pass123', 'password2': 'pass321'} 54 | 55 | form = self.form() 56 | form.cleaned_data = data 57 | 58 | with self.assertRaises(ValidationError): 59 | form.clean() 60 | 61 | def test_save(self): 62 | data = { 63 | 'email': 'test@example.com', 64 | 'password1': 'pass123', 65 | 'password2': 'pass123', 66 | } 67 | 68 | form = self.form(data) 69 | self.assertTrue(form.is_valid(), form.errors.items()) 70 | 71 | user = form.save() 72 | self.assertEqual(user.email, data['email']) 73 | 74 | 75 | class UserChangeFormTest(Python2AssertMixin, TestCase): 76 | """Assert `UserChangeForm` behaves properly.""" 77 | form = admin_forms.UserChangeForm 78 | 79 | def test_fields(self): 80 | """Assert `fields`.""" 81 | fields = self.form.base_fields.keys() 82 | expected = ( 83 | 'email', 84 | 'groups', 85 | 'is_active', 86 | 'is_staff', 87 | 'is_superuser', 88 | 'last_login', 89 | 'name', 90 | 'password', 91 | 'user_permissions', 92 | ) 93 | self.assertCountEqual(fields, expected) 94 | 95 | def test_clean_password(self): 96 | password = 'pass123' 97 | data = {'password': password} 98 | user = UserFactory.build() 99 | 100 | form = self.form(data, instance=user) 101 | self.assertNotEqual(form.clean_password(), password) 102 | -------------------------------------------------------------------------------- /user_management/models/tests/test_backends.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from .factories import UserFactory 4 | from ..backends import CaseInsensitiveEmailBackend 5 | 6 | 7 | class CaseInsensitveEmailBackendTest(TestCase): 8 | request = 'any' 9 | 10 | def test_authenticate(self): 11 | """ 12 | Check case-insensitive username authentication 13 | """ 14 | 15 | email = 'test-Email@example.com' 16 | password = 'arandomsuperstrongpassword' 17 | 18 | user = UserFactory.create(email=email, password=password, is_active=True) 19 | 20 | backend = CaseInsensitiveEmailBackend() 21 | authenticated_user = backend.authenticate( 22 | self.request, 23 | username='Test-email@example.com', 24 | password=password, 25 | ) 26 | 27 | self.assertEqual(user, authenticated_user) 28 | 29 | def test_authenticate_no_username(self): 30 | """ 31 | Passing USERNAME_FIELD to authenticate is valid 32 | """ 33 | 34 | email = 'test-Email@example.com' 35 | password = 'arandomsuperstrongpassword' 36 | 37 | user = UserFactory.create(email=email, password=password, is_active=True) 38 | 39 | backend = CaseInsensitiveEmailBackend() 40 | authenticated_user = backend.authenticate( 41 | self.request, 42 | email='Test-email@example.com', 43 | password=password, 44 | ) 45 | 46 | self.assertEqual(user, authenticated_user) 47 | 48 | def test_authenticate_no_user(self): 49 | """ 50 | If no username is provided, just return None 51 | """ 52 | 53 | email = 'test-Email@example.com' 54 | password = 'arandomsuperstrongpassword' 55 | 56 | backend = CaseInsensitiveEmailBackend() 57 | authenticated_user = backend.authenticate( 58 | self.request, 59 | email=email, 60 | password=password, 61 | ) 62 | 63 | self.assertIs(authenticated_user, None) 64 | -------------------------------------------------------------------------------- /user_management/models/tests/test_models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django.contrib.sites.models import Site 4 | from django.core import checks 5 | from django.db.models import TextField 6 | from django.db.utils import IntegrityError 7 | from django.test import TestCase 8 | from django.utils import timezone 9 | 10 | from mock import Mock, patch 11 | 12 | from user_management.models.tests import utils 13 | from user_management.utils.notifications import validation_email_context 14 | from . import models 15 | from .factories import UserFactory 16 | from .. import mixins 17 | 18 | 19 | PASSWORD_CONTEXT = 'user_management.utils.notifications.password_reset_email_context' 20 | VALIDATION_CONTEXT = 'user_management.utils.notifications.validation_email_context' 21 | SEND_METHOD = 'user_management.utils.notifications.incuna_mail.send' 22 | 23 | 24 | class TestUser(utils.APIRequestTestCase): 25 | """Test the "User" model""" 26 | model = models.User 27 | 28 | def test_fields(self): 29 | """Do we have the fields we expect?""" 30 | fields = {field.name for field in self.model._meta.get_fields()} 31 | expected = { 32 | # On model 33 | 'id', 34 | 'name', 35 | 'date_joined', 36 | 'email', 37 | 'email_verified', 38 | 'is_active', 39 | 'is_staff', 40 | 'is_superuser', 41 | 'last_login', 42 | 'password', 43 | 'avatar', 44 | 45 | # Incoming 46 | 'groups', # Django permission groups 47 | 'user_permissions', 48 | 'logentry', # Django admin logs 49 | 'authtoken', 50 | 'auth_token', # Rest framework authtoken 51 | } 52 | 53 | self.assertCountEqual(fields, expected) 54 | 55 | def test_str(self): 56 | """Does "User.__str__()" work as expected?""" 57 | expected = 'Leopold Stotch' 58 | user = self.model(name=expected) 59 | self.assertEqual(str(user), expected) 60 | 61 | def test_name_methods(self): 62 | """Do "User.get_full_name()" & "get_short_name()" work as expected?""" 63 | expected = 'Professor Chaos' 64 | user = self.model(name=expected) 65 | self.assertEqual(user.get_full_name(), expected) 66 | self.assertEqual(user.get_short_name(), expected) 67 | 68 | def test_case_insensitive_uniqueness(self): 69 | self.model(email='CAPS@example.com').save() 70 | with self.assertRaises(IntegrityError): 71 | self.model(email='caps@example.com').save() 72 | 73 | 74 | class TestUserManager(TestCase): 75 | manager = models.User.objects 76 | 77 | def test_create_user_without_email(self): 78 | with self.assertRaises(ValueError): 79 | self.manager.create_user(email='') 80 | self.assertFalse(self.manager.count()) 81 | 82 | def test_create_duplicate_email(self): 83 | existing_user = UserFactory.create() 84 | with self.assertRaises(IntegrityError): 85 | self.manager.create_user(email=existing_user.email.upper()) 86 | 87 | def test_create_user(self): 88 | time_before = timezone.now() 89 | data = { 90 | 'email': 'valid@example.com', 91 | 'name': 'Mysterion', 92 | 'password': 'I can N3ver DIE!' 93 | } 94 | 95 | # Call creation method of manager 96 | with self.assertNumQueries(1): 97 | # Only one query: 98 | # INSERT INTO "users_user" ("fields",) 99 | # VALUES ('blah') RETURNING "users_user"."id" 100 | result = self.manager.create_user(**data) 101 | 102 | # Check that user returned is the right one 103 | user = self.manager.get() 104 | self.assertEqual(user, result) 105 | 106 | # Check that the password is valid 107 | self.assertTrue(user.check_password(data['password'])) 108 | 109 | # Check name/email is correct 110 | self.assertEqual(user.name, data['name']) 111 | self.assertEqual(user.email, data['email']) 112 | 113 | # Check defaults 114 | self.assertFalse(user.is_active) 115 | self.assertFalse(user.is_staff) 116 | self.assertFalse(user.is_superuser) 117 | self.assertFalse(user.email_verified) 118 | 119 | # Check that the time is correct (or at least, in range) 120 | time_after = timezone.now() 121 | self.assertTrue(time_before < user.date_joined < time_after) 122 | 123 | def test_create_user_uppercase_email(self): 124 | email = 'VALID@EXAMPLE.COM' 125 | 126 | user = self.manager.create_user(email) 127 | self.assertEqual(email.lower(), user.email) 128 | 129 | def test_set_last_login(self): 130 | email = 'valid@example.com' 131 | 132 | before = timezone.now() 133 | user = self.manager.create_user(email) 134 | after = timezone.now() 135 | 136 | self.assertTrue(before < user.last_login < after) 137 | 138 | def test_create_superuser(self): 139 | email = 'valid@example.com' 140 | password = 'password' 141 | 142 | # Call creation method of manager: 143 | with self.assertNumQueries(1): 144 | # Only one query: 145 | # INSERT INTO "users_user" ("fields",) 146 | # VALUES ('blah') RETURNING "users_user"."id" 147 | result = self.manager.create_superuser(email, password) 148 | 149 | # Check that user returned is the right one 150 | user = self.manager.get() 151 | self.assertEqual(user, result) 152 | 153 | # Check that the password is valid 154 | self.assertTrue(user.check_password(password)) 155 | 156 | # Check defaults 157 | self.assertTrue(user.is_active) 158 | self.assertTrue(user.is_staff) 159 | self.assertTrue(user.is_superuser) 160 | 161 | def test_get_by_natural_key(self): 162 | """Assert email is case-insensitive.""" 163 | email = 'WHATDID@YOU.SAY' 164 | existing_user = UserFactory.create(email=email) 165 | 166 | user = self.manager.get_by_natural_key(email.lower()) 167 | self.assertEqual(user, existing_user) 168 | 169 | 170 | class TestVerifyEmailMixin(TestCase): 171 | model = models.VerifyEmailUser 172 | 173 | def test_save(self): 174 | user = self.model() 175 | user.save() 176 | self.assertFalse(user.is_active) 177 | self.assertFalse(user.email_verified) 178 | 179 | def test_email_context(self): 180 | """Assert `password_reset_email_context` returns the correct data.""" 181 | mocked_user = Mock() 182 | mocked_site = Mock() 183 | 184 | class DummyNotification: 185 | user = mocked_user 186 | site = mocked_site 187 | 188 | notification = DummyNotification() 189 | context = validation_email_context(notification) 190 | 191 | expected_context = { 192 | 'protocol': 'https', 193 | 'token': mocked_user.generate_validation_token(), 194 | 'site': mocked_site, 195 | } 196 | self.assertEqual(context, expected_context) 197 | 198 | def test_send_validation_email(self): 199 | context = {} 200 | site = Site.objects.get_current() 201 | user = self.model(email='email@email.email') 202 | 203 | with patch(VALIDATION_CONTEXT) as get_context: 204 | get_context.return_value = context 205 | with patch(SEND_METHOD) as send: 206 | user.send_validation_email() 207 | 208 | expected = { 209 | 'to': user.email, 210 | 'template_name': 'user_management/account_validation_email.txt', 211 | 'html_template_name': 'user_management/account_validation_email.html', 212 | 'subject': '{} account validate'.format(site.domain), 213 | 'context': context, 214 | 'headers': {}, 215 | } 216 | send.assert_called_once_with(**expected) 217 | 218 | def test_verified_email(self): 219 | user = self.model(email_verified=True) 220 | 221 | with patch(SEND_METHOD) as send: 222 | with self.assertRaises(ValueError): 223 | user.send_validation_email() 224 | 225 | self.assertFalse(send.called) 226 | 227 | def test_manager_check_valid(self): 228 | errors = self.model.check() 229 | self.assertEqual(errors, []) 230 | 231 | def test_manager_check_invalid(self): 232 | class InvalidUser(self.model): 233 | objects = mixins.UserManager() 234 | 235 | expected = [ 236 | checks.Warning( 237 | "Manager should be an instance of 'VerifyEmailManager'", 238 | hint="Subclass a custom manager from 'VerifyEmailManager'", 239 | obj=InvalidUser, 240 | id='user_management.W001', 241 | ), 242 | ] 243 | errors = InvalidUser.check() 244 | self.assertEqual(errors, expected) 245 | 246 | 247 | class TestCustomNameUser(utils.APIRequestTestCase): 248 | model = models.CustomNameUser 249 | 250 | def test_fields(self): 251 | """Do we have the fields we expect?""" 252 | fields = {field.name for field in self.model._meta.get_fields()} 253 | expected = { 254 | # On model 255 | 'id', 256 | 'name', 257 | 'date_joined', 258 | 'email', 259 | 'email_verified', 260 | 'is_active', 261 | 'is_staff', 262 | 'last_login', 263 | 'password', 264 | 'avatar', 265 | } 266 | 267 | self.assertCountEqual(fields, expected) 268 | 269 | def test_name(self): 270 | expected = u'Cú Chulainn' 271 | model = self.model(name=expected) 272 | 273 | self.assertEqual(model.get_full_name(), expected) 274 | self.assertEqual(str(model), expected) 275 | field = self.model._meta.get_field('name') 276 | self.assertIsInstance(field, TextField) 277 | 278 | def test_manager_check_invalid(self): 279 | errors = self.model.check() 280 | self.assertEqual(errors, []) 281 | 282 | 283 | class TestCustomPasswordResetNotification(TestCase): 284 | """Assert we can customise the notification to send a password reset.""" 285 | model = models.CustomVerifyEmailUser 286 | 287 | def test_send_password_reset_email(self): 288 | """Assert `text_email_template` and `html_template_name` can be customised.""" 289 | context = {} 290 | site = Site.objects.get_current() 291 | user = self.model(email='email@email.email') 292 | 293 | with patch(PASSWORD_CONTEXT) as get_context: 294 | get_context.return_value = context 295 | with patch(SEND_METHOD) as send: 296 | user.send_password_reset() 297 | 298 | expected = { 299 | 'to': user.email, 300 | 'template_name': 'my_custom_email.txt', 301 | 'html_template_name': None, 302 | 'subject': '{} password reset'.format(site.domain), 303 | 'context': context, 304 | 'headers': {'test-header': 'Test'}, 305 | } 306 | send.assert_called_once_with(**expected) 307 | -------------------------------------------------------------------------------- /user_management/models/tests/utils.py: -------------------------------------------------------------------------------- 1 | from incuna_test_utils.compat import Python2AssertMixin 2 | from incuna_test_utils.testcases.api_request import BaseAPIRequestTestCase 3 | from incuna_test_utils.testcases.request import BaseRequestTestCase 4 | 5 | from .factories import UserFactory 6 | 7 | 8 | class APIRequestTestCase(Python2AssertMixin, BaseAPIRequestTestCase): 9 | user_factory = UserFactory 10 | 11 | 12 | class RequestTestCase(Python2AssertMixin, BaseRequestTestCase): 13 | user_factory = UserFactory 14 | -------------------------------------------------------------------------------- /user_management/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/incuna/django-user-management/28cd481d333fa313601825dab4b05f3e51974fe8/user_management/tests/__init__.py -------------------------------------------------------------------------------- /user_management/tests/run.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | """From http://stackoverflow.com/a/12260597/400691""" 3 | import os 4 | import sys 5 | from ast import literal_eval 6 | 7 | import dj_database_url 8 | import django 9 | from colour_runner.django_runner import ColourRunnerMixin 10 | from django.conf import settings 11 | 12 | 13 | KEEPDB = literal_eval(os.environ.get('KEEPDB', 'False')) 14 | MIGRATION_MODULES = { 15 | 'api': 'user_management.tests.testmigrations.api', 16 | 'tests': 'user_management.tests.testmigrations.tests', 17 | } 18 | 19 | 20 | settings.configure( 21 | DATABASES={ 22 | 'default': dj_database_url.config( 23 | default='postgres://localhost/user_management_api', 24 | ), 25 | }, 26 | DEFAULT_FILE_STORAGE='inmemorystorage.InMemoryStorage', 27 | INSTALLED_APPS=( 28 | # Put contenttypes before auth to work around test issue. 29 | # See: https://code.djangoproject.com/ticket/10827#comment:12 30 | 'django.contrib.sites', 31 | 'django.contrib.contenttypes', 32 | 'django.contrib.auth', 33 | 'django.contrib.sessions', 34 | 'django.contrib.admin', 35 | 'django.contrib.messages', 36 | 37 | 'rest_framework.authtoken', 38 | 39 | # Added for templates 40 | 'user_management.api', 41 | 'user_management.ui', 42 | 'user_management.models.tests', 43 | ), 44 | PASSWORD_HASHERS=('django.contrib.auth.hashers.MD5PasswordHasher',), 45 | SITE_ID=1, 46 | AUTH_USER_MODEL='tests.User', 47 | AUTHENTICATION_BACKENDS=( 48 | 'user_management.models.backends.CaseInsensitiveEmailBackend', 49 | ), 50 | MIDDLEWARE=( 51 | 'django.contrib.sessions.middleware.SessionMiddleware', 52 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 53 | 'django.contrib.messages.middleware.MessageMiddleware', 54 | ), 55 | ROOT_URLCONF='user_management.api.tests.urls', 56 | REST_FRAMEWORK={ 57 | 'DEFAULT_AUTHENTICATION_CLASSES': ( 58 | 'rest_framework.authentication.TokenAuthentication', 59 | ), 60 | }, 61 | SENTRY_CLIENT='user_management.utils.sentry.SensitiveDjangoClient', 62 | USE_TZ=True, 63 | TIME_ZONE='UTC', 64 | MIGRATION_MODULES=MIGRATION_MODULES, 65 | TEMPLATES=[ 66 | { 67 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 68 | 'APP_DIRS': True, 69 | 'OPTIONS': { 70 | 'context_processors': [ 71 | 'django.contrib.auth.context_processors.auth', 72 | 'django.contrib.messages.context_processors.messages', 73 | ] 74 | } 75 | }, 76 | ] 77 | ) 78 | 79 | 80 | django.setup() 81 | 82 | 83 | # DiscoverRunner requires `django.setup()` to have been called 84 | from django.test.runner import DiscoverRunner # noqa 85 | 86 | 87 | class TestRunner(ColourRunnerMixin, DiscoverRunner): 88 | pass 89 | 90 | 91 | test_runner = TestRunner(verbosity=1, keepdb=KEEPDB) 92 | failures = test_runner.run_tests(['user_management']) 93 | if failures: 94 | sys.exit(1) 95 | -------------------------------------------------------------------------------- /user_management/tests/testmigrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/incuna/django-user-management/28cd481d333fa313601825dab4b05f3e51974fe8/user_management/tests/testmigrations/__init__.py -------------------------------------------------------------------------------- /user_management/tests/testmigrations/api/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | import user_management.api.models 6 | import django.utils.timezone 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='AuthToken', 17 | fields=[ 18 | ('key', models.CharField(primary_key=True, serialize=False, max_length=40)), 19 | ('created', models.DateTimeField(editable=False, default=django.utils.timezone.now)), 20 | ('expires', models.DateTimeField(editable=False, default=user_management.api.models.update_expiry)), 21 | ], 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /user_management/tests/testmigrations/api/0002_authtoken_user.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | from django.conf import settings 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('api', '0001_initial'), 12 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 13 | ] 14 | 15 | operations = [ 16 | migrations.AddField( 17 | model_name='authtoken', 18 | name='user', 19 | field=models.ForeignKey(to=settings.AUTH_USER_MODEL, related_name='authtoken', on_delete=models.CASCADE), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /user_management/tests/testmigrations/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/incuna/django-user-management/28cd481d333fa313601825dab4b05f3e51974fe8/user_management/tests/testmigrations/api/__init__.py -------------------------------------------------------------------------------- /user_management/tests/testmigrations/tests/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | import django.utils.timezone 6 | import user_management.models.mixins 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('auth', '0006_require_contenttypes_0002'), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='User', 18 | fields=[ 19 | ('id', models.AutoField(primary_key=True, auto_created=True, serialize=False, verbose_name='ID')), 20 | ('password', models.CharField(verbose_name='password', max_length=128)), 21 | ('last_login', models.DateTimeField(null=True, verbose_name='last login', blank=True)), 22 | ('is_superuser', models.BooleanField(help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status', default=False)), 23 | ('date_joined', models.DateTimeField(editable=False, verbose_name='date joined', default=django.utils.timezone.now)), 24 | ('email', models.EmailField(unique=True, verbose_name='Email address', max_length=511)), 25 | ('is_staff', models.BooleanField(verbose_name='staff status', default=False)), 26 | ('name', models.CharField(verbose_name='Name', max_length=255)), 27 | ('is_active', models.BooleanField(verbose_name='active', default=False)), 28 | ('email_verified', models.BooleanField(help_text='Indicates if the email address has been verified.', verbose_name='Email verified?', default=False)), 29 | ('avatar', models.ImageField(null=True, blank=True, upload_to='user_avatar')), 30 | ('groups', models.ManyToManyField(help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_query_name='user', blank=True, related_name='user_set', to='auth.Group', verbose_name='groups')), 31 | ('user_permissions', models.ManyToManyField(help_text='Specific permissions for this user.', related_query_name='user', blank=True, related_name='user_set', to='auth.Permission', verbose_name='user permissions')), 32 | ], 33 | options={ 34 | 'abstract': False, 35 | }, 36 | bases=(user_management.models.mixins.EmailVerifyUserMethodsMixin, user_management.models.mixins.NameUserMethodsMixin, models.Model), 37 | ), 38 | migrations.CreateModel( 39 | name='BasicUser', 40 | fields=[ 41 | ('id', models.AutoField(primary_key=True, auto_created=True, serialize=False, verbose_name='ID')), 42 | ('password', models.CharField(verbose_name='password', max_length=128)), 43 | ('last_login', models.DateTimeField(null=True, verbose_name='last login', blank=True)), 44 | ('date_joined', models.DateTimeField(editable=False, verbose_name='date joined', default=django.utils.timezone.now)), 45 | ('email', models.EmailField(unique=True, verbose_name='Email address', max_length=511)), 46 | ('is_staff', models.BooleanField(verbose_name='staff status', default=False)), 47 | ('name', models.CharField(verbose_name='Name', max_length=255)), 48 | ], 49 | options={ 50 | 'abstract': False, 51 | }, 52 | bases=(user_management.models.mixins.NameUserMethodsMixin, models.Model), 53 | ), 54 | migrations.CreateModel( 55 | name='CustomNameUser', 56 | fields=[ 57 | ('id', models.AutoField(primary_key=True, auto_created=True, serialize=False, verbose_name='ID')), 58 | ('password', models.CharField(verbose_name='password', max_length=128)), 59 | ('last_login', models.DateTimeField(null=True, verbose_name='last login', blank=True)), 60 | ('date_joined', models.DateTimeField(editable=False, verbose_name='date joined', default=django.utils.timezone.now)), 61 | ('email', models.EmailField(unique=True, verbose_name='Email address', max_length=511)), 62 | ('is_staff', models.BooleanField(verbose_name='staff status', default=False)), 63 | ('is_active', models.BooleanField(verbose_name='active', default=False)), 64 | ('email_verified', models.BooleanField(help_text='Indicates if the email address has been verified.', verbose_name='Email verified?', default=False)), 65 | ('avatar', models.ImageField(null=True, blank=True, upload_to='user_avatar')), 66 | ('name', models.TextField()), 67 | ], 68 | options={ 69 | 'abstract': False, 70 | }, 71 | bases=(user_management.models.mixins.EmailVerifyUserMethodsMixin, user_management.models.mixins.NameUserMethodsMixin, models.Model), 72 | ), 73 | migrations.CreateModel( 74 | name='CustomVerifyEmailUser', 75 | fields=[ 76 | ('id', models.AutoField(primary_key=True, auto_created=True, serialize=False, verbose_name='ID')), 77 | ('password', models.CharField(verbose_name='password', max_length=128)), 78 | ('last_login', models.DateTimeField(null=True, verbose_name='last login', blank=True)), 79 | ('date_joined', models.DateTimeField(editable=False, verbose_name='date joined', default=django.utils.timezone.now)), 80 | ('email', models.EmailField(unique=True, verbose_name='Email address', max_length=511)), 81 | ('is_staff', models.BooleanField(verbose_name='staff status', default=False)), 82 | ('name', models.CharField(verbose_name='Name', max_length=255)), 83 | ('is_active', models.BooleanField(verbose_name='active', default=False)), 84 | ('email_verified', models.BooleanField(help_text='Indicates if the email address has been verified.', verbose_name='Email verified?', default=False)), 85 | ], 86 | options={ 87 | 'abstract': False, 88 | }, 89 | bases=(user_management.models.mixins.EmailVerifyUserMethodsMixin, user_management.models.mixins.NameUserMethodsMixin, models.Model), 90 | ), 91 | migrations.CreateModel( 92 | name='VerifyEmailUser', 93 | fields=[ 94 | ('id', models.AutoField(primary_key=True, auto_created=True, serialize=False, verbose_name='ID')), 95 | ('password', models.CharField(verbose_name='password', max_length=128)), 96 | ('last_login', models.DateTimeField(null=True, verbose_name='last login', blank=True)), 97 | ('date_joined', models.DateTimeField(editable=False, verbose_name='date joined', default=django.utils.timezone.now)), 98 | ('email', models.EmailField(unique=True, verbose_name='Email address', max_length=511)), 99 | ('is_staff', models.BooleanField(verbose_name='staff status', default=False)), 100 | ('name', models.CharField(verbose_name='Name', max_length=255)), 101 | ('is_active', models.BooleanField(verbose_name='active', default=False)), 102 | ('email_verified', models.BooleanField(help_text='Indicates if the email address has been verified.', verbose_name='Email verified?', default=False)), 103 | ], 104 | options={ 105 | 'abstract': False, 106 | }, 107 | bases=(user_management.models.mixins.EmailVerifyUserMethodsMixin, user_management.models.mixins.NameUserMethodsMixin, models.Model), 108 | ), 109 | ] 110 | -------------------------------------------------------------------------------- /user_management/tests/testmigrations/tests/0002_case_insensitive_email.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11 on 2017-06-21 10:00 3 | from __future__ import unicode_literals 4 | 5 | import django.contrib.postgres.fields.citext 6 | from django.contrib.postgres.operations import CITextExtension 7 | from django.db import migrations 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [ 13 | ('tests', '0001_initial'), 14 | ('api', '0002_authtoken_user'), 15 | ] 16 | 17 | operations = [ 18 | CITextExtension(), 19 | migrations.AlterField( 20 | model_name='user', 21 | name='email', 22 | field=django.contrib.postgres.fields.citext.CIEmailField(max_length=511, unique=True, verbose_name='Email address'), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /user_management/tests/testmigrations/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/incuna/django-user-management/28cd481d333fa313601825dab4b05f3e51974fe8/user_management/tests/testmigrations/tests/__init__.py -------------------------------------------------------------------------------- /user_management/tests/utils.py: -------------------------------------------------------------------------------- 1 | def iso_8601(datetime): 2 | """Convert a datetime into an iso 8601 string.""" 3 | if datetime is None: 4 | return datetime 5 | value = datetime.isoformat() 6 | if value.endswith('+00:00'): 7 | value = value[:-6] + 'Z' 8 | return value 9 | -------------------------------------------------------------------------------- /user_management/ui/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/incuna/django-user-management/28cd481d333fa313601825dab4b05f3e51974fe8/user_management/ui/__init__.py -------------------------------------------------------------------------------- /user_management/ui/exceptions.py: -------------------------------------------------------------------------------- 1 | from django.http import Http404 2 | from django.utils.translation import ugettext_lazy as _ 3 | 4 | 5 | class InvalidExpiredToken(Http404): 6 | """Exception to confirm an account.""" 7 | message = _('Invalid or expired token.') 8 | 9 | 10 | class AlreadyVerifiedException(Exception): 11 | message = _('Email already verified.') 12 | -------------------------------------------------------------------------------- /user_management/ui/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/incuna/django-user-management/28cd481d333fa313601825dab4b05f3e51974fe8/user_management/ui/tests/__init__.py -------------------------------------------------------------------------------- /user_management/ui/tests/test_exceptions.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from ..exceptions import InvalidExpiredToken 4 | 5 | 6 | class TestInvalidExpiredToken(TestCase): 7 | """Assert `InvalidExpiredToken` behaves as expected.""" 8 | def test_raise(self): 9 | """Assert `InvalidExpiredToken` can be raised.""" 10 | with self.assertRaises(InvalidExpiredToken) as error: 11 | raise InvalidExpiredToken 12 | 13 | self.assertEqual(error.exception.message, 'Invalid or expired token.') 14 | -------------------------------------------------------------------------------- /user_management/ui/tests/test_urls.py: -------------------------------------------------------------------------------- 1 | from incuna_test_utils.testcases.urls import URLTestCase 2 | 3 | from .. import views 4 | 5 | 6 | class TestURLs(URLTestCase): 7 | def test_password_reset_confirm_url(self): 8 | self.assert_url_matches_view( 9 | view=views.VerifyUserEmailView, 10 | expected_url='/register/verify/x:-y/', 11 | url_name='user_management_ui:registration-verify', 12 | url_kwargs={'token': 'x:-y'}, 13 | ) 14 | -------------------------------------------------------------------------------- /user_management/ui/tests/test_views.py: -------------------------------------------------------------------------------- 1 | from django.test import override_settings 2 | 3 | from user_management.models.tests.factories import VerifyEmailUserFactory 4 | from user_management.models.tests.models import VerifyEmailUser 5 | from user_management.models.tests.utils import RequestTestCase 6 | 7 | from .. import views 8 | 9 | 10 | @override_settings(AUTH_USER_MODEL='tests.VerifyEmailUser') 11 | class TestVerifyUserEmailView(RequestTestCase): 12 | view_class = views.VerifyUserEmailView 13 | 14 | def test_get(self): 15 | """A user clicks the link in their email and activates their account.""" 16 | user = VerifyEmailUserFactory.create(email_verified=False) 17 | token = user.generate_validation_token() 18 | 19 | request = self.create_request('get', auth=False) 20 | view = self.view_class.as_view() 21 | response = view(request, token=token) 22 | self.assertEqual(response.status_code, 302) 23 | 24 | self.assertEqual(response.url, '/accounts/login/') 25 | 26 | user = VerifyEmailUser.objects.get(pk=user.pk) 27 | 28 | self.assertTrue(user.email_verified) 29 | self.assertTrue(user.is_active) 30 | 31 | self.assertEqual( 32 | self.view_class.success_message, 33 | str(request._messages.store[0]), 34 | ) 35 | 36 | @override_settings(LOGIN_ON_EMAIL_VERIFICATION=True) 37 | def test_auto_login_get(self): 38 | """A user is automatically logged in when they activate their account.""" 39 | user = VerifyEmailUserFactory.create(email_verified=False) 40 | token = user.generate_validation_token() 41 | 42 | request = self.create_request('get', auth=False) 43 | self.add_session_to_request(request) 44 | 45 | view = self.view_class.as_view() 46 | view(request, token=token) 47 | 48 | self.assertEqual(int(request.session['_auth_user_id']), user.pk) 49 | 50 | @override_settings(LOGIN_URL='login') 51 | def test_get_named_login_url(self): 52 | """A user clicks the link in their email and activates their account.""" 53 | user = VerifyEmailUserFactory.create(email_verified=False) 54 | token = user.generate_validation_token() 55 | 56 | request = self.create_request('get', auth=False) 57 | view = self.view_class.as_view() 58 | response = view(request, token=token) 59 | self.assertEqual(response.status_code, 302) 60 | 61 | self.assertEqual(response.url, '/login/') 62 | 63 | def test_get_nonsense_token(self): 64 | """The view is accessed with a broken token and 404s.""" 65 | token = 'I_am_a_token' 66 | 67 | request = self.create_request('get', auth=False) 68 | view = self.view_class.as_view() 69 | with(self.assertRaises(self.view_class.invalid_exception_class)): 70 | view(request, token=token) 71 | 72 | def test_get_registered_user(self): 73 | """The view is accessed for an already-verified user then redirect.""" 74 | user = VerifyEmailUserFactory.create(email_verified=True) 75 | token = user.generate_validation_token() 76 | 77 | request = self.create_request('get', auth=False) 78 | view = self.view_class.as_view() 79 | 80 | response = view(request, token=token) 81 | self.assertEqual(response.status_code, 302) 82 | 83 | self.assertEqual(response.url, '/accounts/login/') 84 | 85 | user = VerifyEmailUser.objects.get(pk=user.pk) 86 | 87 | self.assertTrue(user.email_verified) 88 | self.assertTrue(user.is_active) 89 | 90 | self.assertEqual( 91 | self.view_class.already_verified_message, 92 | str(request._messages.store[0]), 93 | ) 94 | 95 | query_string = 'validated' 96 | 97 | @override_settings(VERIFIED_QUERYSTRING=query_string) 98 | def test_get_redirect_url_with_query_string(self): 99 | view = views.VerifyUserEmailView() 100 | view.already_verified = False 101 | response = view.get_redirect_url() 102 | expected_url = '/accounts/login/?' + self.query_string 103 | 104 | self.assertEqual(response, expected_url) 105 | 106 | @override_settings(LOGIN_URL='login') 107 | @override_settings(VERIFIED_QUERYSTRING=query_string) 108 | def test_get_redirect_url_with_query_string_and_login_url(self): 109 | view = views.VerifyUserEmailView() 110 | view.already_verified = False 111 | response = view.get_redirect_url() 112 | expected_url = '/login/?' + self.query_string 113 | 114 | self.assertEqual(response, expected_url) 115 | 116 | @override_settings(VERIFIED_QUERYSTRING=query_string) 117 | def test_get_redirect_url_with_verified_user(self): 118 | view = views.VerifyUserEmailView() 119 | view.already_verified = True 120 | response = view.get_redirect_url() 121 | 122 | self.assertEqual(response, '/accounts/login/') 123 | 124 | def test_get_redirect_url_without_query_setting(self): 125 | view = views.VerifyUserEmailView() 126 | view.already_verified = False 127 | response = view.get_redirect_url() 128 | 129 | self.assertEqual(response, '/accounts/login/') 130 | -------------------------------------------------------------------------------- /user_management/ui/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | from . import views 4 | 5 | 6 | app_name = 'user_management_ui' 7 | urlpatterns = [ 8 | url( 9 | r'^register/verify/(?P[0-9A-Za-z:\-_]+)/$', 10 | views.VerifyUserEmailView.as_view(), 11 | name='registration-verify', 12 | ), 13 | ] 14 | -------------------------------------------------------------------------------- /user_management/ui/views.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib import auth, messages 3 | from django.shortcuts import resolve_url 4 | from django.utils.translation import ugettext_lazy as _ 5 | from django.views import generic 6 | 7 | from user_management.utils.views import VerifyAccountViewMixin 8 | from .exceptions import AlreadyVerifiedException, InvalidExpiredToken 9 | 10 | 11 | class VerifyUserEmailView(VerifyAccountViewMixin, generic.RedirectView): 12 | """ 13 | A view which verifies a user's email address. 14 | 15 | Accessed via a link in an email sent to the user, which contains both a 16 | uid generated from the user's pk, and a token also generated from the user 17 | object, for verification. If everything lines up, it makes the user 18 | active. 19 | 20 | As a RedirectView, this will return a HTTP 302 to LOGIN_URL on success. 21 | """ 22 | permanent = False 23 | already_verified = False 24 | success_message = _('Your email address was confirmed.') 25 | already_verified_message = _('Your email is already confirmed.') 26 | invalid_exception_class = InvalidExpiredToken 27 | permission_denied_class = AlreadyVerifiedException 28 | 29 | def get_redirect_url(self, *args, **kwargs): 30 | query_string = getattr(settings, 'VERIFIED_QUERYSTRING', '') 31 | url_extra = '' 32 | 33 | if query_string and not self.already_verified: 34 | url_extra = '?' + query_string 35 | 36 | return resolve_url(settings.LOGIN_URL) + url_extra 37 | 38 | def dispatch(self, request, *args, **kwargs): 39 | try: 40 | self.verify_token(request, *args, **kwargs) 41 | except self.permission_denied_class: 42 | self.already_verified = True 43 | self.success_message = self.already_verified_message 44 | return super(VerifyUserEmailView, self).dispatch(request, *args, **kwargs) 45 | 46 | def get(self, request, *args, **kwargs): 47 | auto_login = getattr(settings, 'LOGIN_ON_EMAIL_VERIFICATION', False) 48 | 49 | if not self.already_verified: 50 | self.activate_user() 51 | if auto_login is True: 52 | self.user.backend = settings.AUTHENTICATION_BACKENDS[0] 53 | auth.login(request=request, user=self.user) 54 | messages.success(request, self.success_message) 55 | return super(VerifyUserEmailView, self).get(request, *args, **kwargs) 56 | -------------------------------------------------------------------------------- /user_management/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/incuna/django-user-management/28cd481d333fa313601825dab4b05f3e51974fe8/user_management/utils/__init__.py -------------------------------------------------------------------------------- /user_management/utils/notifications.py: -------------------------------------------------------------------------------- 1 | import incuna_mail 2 | from django.conf import settings 3 | from django.utils.translation import ugettext_lazy as _ 4 | from pigeon.notification import Notification 5 | 6 | 7 | def password_reset_email_context(notification): 8 | """Email context to reset a user password.""" 9 | return { 10 | 'protocol': 'https', 11 | 'uid': notification.user.generate_uid(), 12 | 'token': notification.user.generate_token(), 13 | 'site': notification.site, 14 | } 15 | 16 | 17 | def validation_email_context(notification): 18 | """Email context to verify a user email.""" 19 | return { 20 | 'protocol': 'https', 21 | 'token': notification.user.generate_validation_token(), 22 | 'site': notification.site, 23 | } 24 | 25 | 26 | def email_handler(notification, email_context): 27 | """Send a notification by email.""" 28 | incuna_mail.send( 29 | to=notification.user.email, 30 | subject=notification.email_subject, 31 | template_name=notification.text_email_template, 32 | html_template_name=notification.html_email_template, 33 | context=email_context(notification), 34 | headers=getattr(notification, 'headers', {}), 35 | ) 36 | 37 | 38 | def password_reset_email_handler(notification): 39 | """Password reset email handler.""" 40 | base_subject = _('{domain} password reset').format(domain=notification.site.domain) 41 | subject = getattr(settings, 'DUM_PASSWORD_RESET_SUBJECT', base_subject) 42 | notification.email_subject = subject 43 | email_handler(notification, password_reset_email_context) 44 | 45 | 46 | def validation_email_handler(notification): 47 | """Validation email handler.""" 48 | base_subject = _('{domain} account validate').format(domain=notification.site.domain) 49 | subject = getattr(settings, 'DUM_VALIDATE_EMAIL_SUBJECT', base_subject) 50 | notification.email_subject = subject 51 | email_handler(notification, validation_email_context) 52 | 53 | 54 | class PasswordResetNotification(Notification): 55 | """`PasswordResetNotification` defines text and html email templates.""" 56 | handlers = (password_reset_email_handler,) 57 | text_email_template = 'user_management/password_reset_email.txt' 58 | html_email_template = 'user_management/password_reset_email.html' 59 | 60 | 61 | class ValidationNotification(Notification): 62 | """`ValidationNotification` defines text and html email templates.""" 63 | handlers = (validation_email_handler,) 64 | text_email_template = 'user_management/account_validation_email.txt' 65 | html_email_template = 'user_management/account_validation_email.html' 66 | -------------------------------------------------------------------------------- /user_management/utils/sentry.py: -------------------------------------------------------------------------------- 1 | from django.views.debug import SafeExceptionReporterFilter 2 | from raven.contrib.django.client import DjangoClient 3 | 4 | 5 | class SensitiveDjangoClient(DjangoClient): 6 | """ 7 | Hide sensitive request data from being logged by Sentry. 8 | 9 | Borrowed from http://stackoverflow.com/a/23966581/240995 10 | """ 11 | def get_data_from_request(self, request): 12 | request.POST = SafeExceptionReporterFilter().get_post_parameters(request) 13 | result = super(SensitiveDjangoClient, self).get_data_from_request(request) 14 | 15 | # override the request.data with POST data 16 | # POST data contains no sensitive info in it 17 | result['request']['data'] = request.POST 18 | 19 | # remove the whole cookie as it contains DRF auth token and session id 20 | if 'cookies' in result['request']: 21 | del result['request']['cookies'] 22 | if 'Cookie' in result['request']['headers']: 23 | del result['request']['headers']['Cookie'] 24 | 25 | return result 26 | -------------------------------------------------------------------------------- /user_management/utils/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/incuna/django-user-management/28cd481d333fa313601825dab4b05f3e51974fe8/user_management/utils/tests/__init__.py -------------------------------------------------------------------------------- /user_management/utils/tests/test_sentry.py: -------------------------------------------------------------------------------- 1 | from raven.contrib.django.models import get_client 2 | from rest_framework.test import APIRequestFactory 3 | 4 | from user_management.models.tests.utils import APIRequestTestCase 5 | 6 | 7 | class TestSentryClient(APIRequestTestCase): 8 | def test_cookies(self): 9 | request = APIRequestFactory().post('/') 10 | request.COOKIES['username'] = 'jimmy' 11 | 12 | raven = get_client() 13 | result = raven.get_data_from_request(request) 14 | 15 | self.assertFalse('cookies' in result['request']) 16 | self.assertFalse('Cookie' in result['request']['headers']) 17 | -------------------------------------------------------------------------------- /user_management/utils/tests/test_validators.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import string 3 | 4 | from django.core.exceptions import ValidationError 5 | from django.test import TestCase 6 | 7 | from ..validators import validate_password_strength 8 | 9 | 10 | class PasswordsTest(TestCase): 11 | too_simple = ( 12 | 'Password must have at least ' + 13 | 'one upper case letter, one lower case letter, and one number.' 14 | ) 15 | 16 | too_fancy = ( 17 | 'Password only accepts the following symbols ' + string.punctuation 18 | ) 19 | 20 | def test_no_upper(self): 21 | password = 'aaaa1111' 22 | with self.assertRaises(ValidationError) as error: 23 | validate_password_strength(password) 24 | self.assertEqual(error.exception.message, self.too_simple) 25 | 26 | def test_no_lower(self): 27 | password = 'AAAA1111' 28 | with self.assertRaises(ValidationError) as error: 29 | validate_password_strength(password) 30 | 31 | self.assertEqual(error.exception.message, self.too_simple) 32 | 33 | def test_no_number(self): 34 | password = 'AAAAaaaa' 35 | with self.assertRaises(ValidationError) as error: 36 | validate_password_strength(password) 37 | 38 | self.assertEqual(error.exception.message, self.too_simple) 39 | 40 | def test_symbols(self): 41 | """Ensure all acceptable symbols are acceptable.""" 42 | for symbol in string.punctuation: 43 | password = 'AAaa111' + symbol 44 | self.assertIsNone(validate_password_strength(password)) 45 | 46 | def test_non_ascii(self): 47 | password = u'AA11aa££' # £ is not an ASCII character. 48 | with self.assertRaises(ValidationError) as error: 49 | validate_password_strength(password) 50 | 51 | self.assertEqual(error.exception.message, self.too_fancy) 52 | 53 | def test_ok(self): 54 | password = 'AAAaaa11' 55 | self.assertIsNone(validate_password_strength(password)) 56 | -------------------------------------------------------------------------------- /user_management/utils/tests/test_views.py: -------------------------------------------------------------------------------- 1 | import mock 2 | from django.contrib.auth import get_user_model 3 | from django.test import override_settings 4 | 5 | from user_management.models.tests.factories import VerifyEmailUserFactory 6 | from user_management.models.tests.utils import APIRequestTestCase 7 | from .. import views 8 | 9 | User = get_user_model() 10 | 11 | 12 | @override_settings(AUTH_USER_MODEL='tests.VerifyEmailUser') 13 | class TestVerifyAccountView(APIRequestTestCase): 14 | view_class = views.VerifyAccountViewMixin 15 | 16 | @classmethod 17 | def setUpTestData(cls): 18 | cls.request = mock.MagicMock 19 | cls.view_instance = cls.view_class() 20 | cls.user = VerifyEmailUserFactory.create(email_verified=False) 21 | cls.token = cls.user.generate_validation_token() 22 | 23 | def test_verify_token_allowed(self): 24 | """Assert a user can verify its own email.""" 25 | self.view_instance.verify_token(self.request, token=self.token) 26 | self.assertEqual(self.view_instance.user, self.user) 27 | 28 | def test_verify_token_invalid_user(self): 29 | """Assert a nonexistent user throws an exception.""" 30 | user = VerifyEmailUserFactory.build() 31 | token = user.generate_validation_token() 32 | with self.assertRaises(self.view_instance.invalid_exception_class): 33 | self.view_instance.verify_token(self.request, token=token) 34 | 35 | def test_verify_token_invalid_token(self): 36 | """Assert forged token return a bad request.""" 37 | token = 'nimporte-nawak' 38 | with self.assertRaises(self.view_instance.invalid_exception_class): 39 | self.view_instance.verify_token(self.request, token=token) 40 | 41 | def test_default_expiry_token(self): 42 | """Assert `DEFAULT_VERIFY_ACCOUNT_EXPIRY` doesn't expire by default.""" 43 | with mock.patch('django.core.signing.loads') as signing_loads: 44 | signing_loads.return_value = {'email': self.user.email} 45 | self.view_instance.verify_token(self.request, token=self.token) 46 | 47 | signing_loads.assert_called_once_with(self.token, max_age=None) 48 | 49 | @override_settings(VERIFY_ACCOUNT_EXPIRY=0) 50 | def test_verify_token_expired_token(self): 51 | """Assert token expires when VERIFY_ACCOUNT_EXPIRY is set.""" 52 | with self.assertRaises(self.view_instance.invalid_exception_class): 53 | self.view_instance.verify_token(self.request, token=self.token) 54 | 55 | def test_verify_token_verified_email(self): 56 | """Assert verified user cannot verify email.""" 57 | user = VerifyEmailUserFactory.create(email_verified=True) 58 | token = user.generate_validation_token() 59 | with self.assertRaises(self.view_instance.permission_denied_class): 60 | self.view_instance.verify_token(self.request, token=token) 61 | 62 | def test_activate_user(self): 63 | user = VerifyEmailUserFactory.create(email_verified=False, is_active=False) 64 | self.view_instance.user = user 65 | 66 | self.view_instance.activate_user() 67 | self.assertTrue(user.email_verified) 68 | self.assertTrue(user.is_active) 69 | -------------------------------------------------------------------------------- /user_management/utils/validators.py: -------------------------------------------------------------------------------- 1 | from string import ( 2 | ascii_letters, 3 | ascii_lowercase, 4 | ascii_uppercase, 5 | digits, 6 | punctuation 7 | ) 8 | 9 | from django.core.exceptions import ValidationError 10 | from django.utils.translation import ugettext_lazy as _ 11 | 12 | 13 | too_simple = _( 14 | 'Password must have at least ' + 15 | 'one upper case letter, one lower case letter, and one number.' 16 | ) 17 | 18 | too_fancy = _( 19 | 'Password only accepts the following symbols !"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~' 20 | ) 21 | 22 | 23 | def validate_password_strength(value): 24 | """ 25 | Passwords should be tough. 26 | 27 | That means they should use: 28 | - mixed case letters, 29 | - numbers, 30 | - (optionally) ascii symbols and spaces. 31 | 32 | The (contrversial?) decision to limit the passwords to ASCII only 33 | is for the sake of: 34 | - simplicity (no need to normalise UTF-8 input) 35 | - security (some character sets are visible as typed into password fields) 36 | 37 | TODO: In future, it may be worth considering: 38 | - rejecting common passwords. (Where can we get a list?) 39 | - rejecting passwords with too many repeated characters. 40 | 41 | It should be noted that no restriction has been placed on the length of the 42 | password here, as that can easily be achieved with use of the min_length 43 | attribute of a form/serializer field. 44 | """ 45 | used_chars = set(value) 46 | good_chars = set(ascii_letters + digits + punctuation + ' ') 47 | required_sets = (ascii_uppercase, ascii_lowercase, digits) 48 | 49 | if not used_chars.issubset(good_chars): 50 | raise ValidationError(too_fancy) 51 | 52 | for required in required_sets: 53 | if not used_chars.intersection(required): 54 | raise ValidationError(too_simple) 55 | -------------------------------------------------------------------------------- /user_management/utils/views.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.auth import get_user_model 3 | from django.core import signing 4 | from django.utils.translation import ugettext_lazy as _ 5 | from rest_framework.exceptions import PermissionDenied 6 | 7 | from user_management.api.exceptions import InvalidExpiredToken 8 | 9 | 10 | class VerifyAccountViewMixin(object): 11 | """ 12 | Verify a new user's email address. 13 | 14 | Verify a newly created account by checking the `token` in the URL kwargs. 15 | """ 16 | ok_message = _('Your account has been verified.') 17 | invalid_exception_class = InvalidExpiredToken 18 | permission_denied_class = PermissionDenied 19 | 20 | # Default token never expires. 21 | DEFAULT_VERIFY_ACCOUNT_EXPIRY = None 22 | 23 | def verify_token(self, request, *args, **kwargs): 24 | """ 25 | Use `token` to allow one-time access to a view. 26 | 27 | Set the user as a class attribute or raise an `InvalidExpiredToken`. 28 | 29 | Token expiry can be set in `settings` with `VERIFY_ACCOUNT_EXPIRY` and is 30 | set in seconds. 31 | """ 32 | User = get_user_model() 33 | 34 | try: 35 | max_age = settings.VERIFY_ACCOUNT_EXPIRY 36 | except AttributeError: 37 | max_age = self.DEFAULT_VERIFY_ACCOUNT_EXPIRY 38 | 39 | try: 40 | email_data = signing.loads(kwargs['token'], max_age=max_age) 41 | except signing.BadSignature: 42 | raise self.invalid_exception_class 43 | 44 | email = email_data['email'] 45 | 46 | try: 47 | self.user = User.objects.get_by_natural_key(email) 48 | except User.DoesNotExist: 49 | raise self.invalid_exception_class 50 | 51 | if self.user.email_verified: 52 | raise self.permission_denied_class 53 | 54 | def activate_user(self): 55 | self.user.email_verified = True 56 | self.user.is_active = True 57 | self.user.save() 58 | --------------------------------------------------------------------------------