Skip to content

Commit

Permalink
Add profile serializer setting for simpler customization of serialize…
Browse files Browse the repository at this point in the history
…r Profile and Sign-up fields
  • Loading branch information
ajharry69 committed Jun 5, 2020
1 parent eb46466 commit b4d5774
Show file tree
Hide file tree
Showing 9 changed files with 108 additions and 67 deletions.
2 changes: 1 addition & 1 deletion djangorestxauth/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,6 @@
'ACCOUNT_ACTIVATION_TOKEN_EXPIRY': timedelta(days=1),
# period within which a user is considered new since account creation date
'NEWBIE_VALIDITY_PERIOD': timedelta(days=1),
'AUTO_HASH_PASSWORD_ON_SAVE': True,
'WRAP_DRF_RESPONSE': False,
'POST_REQUEST_USERNAME_FIELD': 'username',
'POST_REQUEST_PASSWORD_FIELD': 'password',
Expand All @@ -170,6 +169,7 @@
'VERIFICATION_ENDPOINT': 'verification-code/verify/',
'PASSWORD_RESET_ENDPOINT': 'password-reset/verify/',
'ACTIVATION_ENDPOINT': 'activation/activate/',
'USER_PROFILE_SERIALIZER': 'xauth.serializers.ProfileSerializer',
}

AUTH_USER_MODEL = 'xauth.User'
Expand Down
2 changes: 1 addition & 1 deletion docs/api-guide/classes/token/methods.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Returns payload from `token`'s JWT claims.
## refresh()
**Type:** `dict`

Does the token signing and encryption. Re-initializes [`normal`][properties-normal-url] and
Recreates tokens(normal and encrypted). Re-initializes [`normal`][properties-normal-url] and
[`encrypted`][properties-encrypted-url] properties and returns `dict` of [`tokens`][properties-tokens-url].

[properties-tokens-url]: /api-guide/classes/token/properties/#tokens
Expand Down
43 changes: 35 additions & 8 deletions docs/api-guide/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,38 @@ XAUTH = {

**NOTE:** All `XAUTH` setting names must be written in **capital letters(uppercase)** for a setting to take effect.

## USER_PROFILE_SERIALIZER
**Type:** `str`

**Default:** 'xauth.serializers.ProfileSerializer'

**Usage:** Serializers allow complex data such as querysets and model instances to be converted to native Python data
types that can then be easily rendered into JSON, XML or other content types. More on serializers can be found
[here][drf-serializer-url] or [here][drf-serializer-tutorial-url].

**Note:** `fields` declared in this serializer will be inherited by `xauth.serializer.SignUpSerializer` with addition
of `password` field that is only relevant for sign-up.

### Conditions
`django-rest-xauth` makes the following assumptions of the serializer class it expects from this setting:

1. It is a direct or indirect subclass of `rest_framework.serializers.Serializer`.
2. It contains a nested `Meta` class which contains a `model` and `fields` properties.

**Consider an example of this default serializer class**
```python
from rest_framework import serializers
from django.contrib.auth import get_user_model

class ProfileSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='xauth:profile')

class Meta:
model = get_user_model()
fields = tuple(get_user_model().PUBLIC_READ_WRITE_FIELDS) + ('url',)
read_only_fields = tuple(get_user_model().READ_ONLY_FIELDS)
```

## APP_NAME
**Type**: `str`

Expand Down Expand Up @@ -105,13 +137,6 @@ the `django-rest-xauth` should be channelled.

**Usage**: period within which a user will be considered new from the time of account creation.

## AUTO_HASH_PASSWORD_ON_SAVE
**Type**: `bool`

**Default**: `True`

**Usage**: if `True` user's password will be hashed whenever save method is called

## WRAP_DRF_RESPONSE
**Type**: `bool`

Expand Down Expand Up @@ -183,4 +208,6 @@ correctness before user's password is changed to a new one(provided through a **
**Usage**: used to create a url through which user's security question's answer will be verified/validated for
correctness before account is considered activated.

[basic-auth-scheme]: https://en.wikipedia.org/wiki/Basic_access_authentication
[basic-auth-scheme]: https://en.wikipedia.org/wiki/Basic_access_authentication
[drf-serializer-url]: https://www.django-rest-framework.org/api-guide/serializers/
[drf-serializer-tutorial-url]: https://www.django-rest-framework.org/tutorial/1-serialization/
65 changes: 28 additions & 37 deletions xauth/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,22 +44,26 @@ def create_user(
mobile_number=None,
date_of_birth=None,
provider=None, ):
user = self.__raw_user(email, username, password, surname, first_name, last_name, mobile_number,
date_of_birth, )
user.save(auto_hash_password=True, using=self._db)
user = self.__user(email, username, password, surname, first_name, last_name, mobile_number,
date_of_birth, )
user.password = user.get_hashed(password)
user.save(using=self._db)
return user

def create_superuser(self, email, username, password, first_name=None, last_name=None, ):
user = self.__raw_user(email, username, password, first_name=first_name, last_name=last_name, )
if not valid_str(password):
# password was not provided
raise ValueError('superuser password is required')
user = self.create_user(email, username, password, first_name=first_name, last_name=last_name, )
user.is_active = True
user.is_staff = True
user.is_superuser = True
user.is_verified = True
user.save(auto_hash_password=True, using=self._db)
user.save(using=self._db)
return user

def __raw_user(self, email, username, password, surname=None, first_name=None, last_name=None,
mobile_number=None, date_of_birth=None, provider=None, ):
def __user(self, email, username, password, surname=None, first_name=None, last_name=None,
mobile_number=None, date_of_birth=None, provider=None, ):
if not valid_str(email):
raise ValueError('email is required')
return self.model(
Expand All @@ -81,7 +85,6 @@ class User(AbstractBaseUser, PermissionsMixin):
"""
__DEVICE_IP = None
__NEWBIE_GP = XAUTH.get('NEWBIE_VALIDITY_PERIOD', timedelta(days=1))
__AUTO_HASH = XAUTH.get('AUTO_HASH_PASSWORD_ON_SAVE', True)
__ENFORCE_ACCOUNT_VERIFICATION = XAUTH.get('ENFORCE_ACCOUNT_VERIFICATION', True)
__PROVIDERS = [(k, k) for k, _ in enums.AuthProvider.__members__.items()]
__DEFAULT_PROVIDER = enums.AuthProvider.EMAIL.name
Expand Down Expand Up @@ -131,17 +134,17 @@ def __str__(self):
def __repr__(self):
return json.dumps(self.token_payload())

def save(self, auto_hash_password=__AUTO_HASH, *args, **kwargs):
def save(self, *args, **kwargs):
"""
use email as the username if it wasn't provided
:param auto_hash_password if True, `self.password` will be hashed before saving to database. Default(`False`)
"""
# TODO: split name if contains space to surname, firstname, lastname
self.provider = self.provider if valid_str(self.provider) else self.__DEFAULT_PROVIDER
_username = self.username
self.username = self.normalize_username(_username if _username and len(_username) > 0 else self.email)
if auto_hash_password is True:
self.__reinitialize_password_with_hash()
if not valid_str(self.password):
# do not store a null(None) password
self.set_unusable_password()
self.is_verified = self.__get_ascertained_verification_status()
reset_empty_nullable_to_null(self, self.NULLABLE_FIELDS)
super(User, self).save(*args, **kwargs)
Expand Down Expand Up @@ -331,9 +334,9 @@ def reset_password(self, temporary_password, new_password):
if metadata.check_temporary_password(raw_password=temporary_password):
# temporary password matched(correct)
# update user's password
self.password = new_password
self.password = self.get_hashed(new_password)
# prevent hashing of other irrelevant table column(s)
self.save(auto_hash_password=True, update_fields=['password'])
self.save(update_fields=['password'])
# reset temporary password & password generation time to None
metadata.temporary_password = None
metadata.tp_gen_time = None
Expand Down Expand Up @@ -364,7 +367,7 @@ def verify(self, code):
# update user's verification status
self.is_verified = True
# prevent's automatic hashing of irrelevant password
self.save(auto_hash_password=False, update_fields=['is_verified'])
self.save(update_fields=['is_verified'])
# reset verification code & code generation time to None
metadata.verification_code = None
metadata.vc_gen_time = None
Expand All @@ -390,7 +393,7 @@ def activate_account(self, security_question_answer):
if metadata.check_security_question_answer(raw_answer=security_question_answer):
# answer was correct, activate account
self.is_active = True
self.save(auto_hash_password=False, update_fields=['is_active'])
self.save(update_fields=['is_active'])
return self.token, None
else:
# wrong answer
Expand Down Expand Up @@ -561,35 +564,23 @@ def token_payload(self) -> dict:
payload[field] = val
return payload

def _hash_code(self, raw_code):
def get_hashed(self, raw):
"""
Uses `settings.PASSWORD_HASHERS` to create and return a hashed `code` just like creating a hashed
password
:param raw_code: data to be hashed. Provide None to set an unusable hash code(password)
:param raw: data to be hashed. Provide None to set an unusable hash code(password)
:return: hashed version of `code`
"""
# temporarily hold the user's password
acc_password = self.password
# hash the code. will reinitialize the password
self.set_unusable_password() if raw_code is None else self.set_password(raw_code)
self.set_password(raw) if valid_str(raw) else self.set_unusable_password()
code = self.password # hashed code retrieved from password
# re-[instate|initialize] user's password to it's previous value
self.password = acc_password
return code

def __reinitialize_password_with_hash(self):
_password = self.password
if valid_str(_password):
# password was provided
self.set_password(_password)
else:
# password was not provided
if self.is_superuser:
# raise error for superuser accounts without password
raise ValueError('superuser password is required')
self.set_unusable_password()

def __get_ascertained_verification_status(self):
"""
:returns correct verification status based on 'user type'(superuser/staff) and `self.provider`
Expand Down Expand Up @@ -644,9 +635,9 @@ def save(self, *args, **kwargs):
raw_code = self.verification_code
raw_password = self.temporary_password
if valid_str(raw_code):
self.verification_code = self._hash_code(raw_code)
self.verification_code = self._get_hashed(raw_code)
if valid_str(raw_password):
self.temporary_password = self._hash_code(raw_password)
self.temporary_password = self._get_hashed(raw_password)
self.__reinitialize_security_answer()
super(Metadata, self).save(*args, **kwargs)

Expand Down Expand Up @@ -687,8 +678,8 @@ def is_usable_code(self, hash_code) -> bool:
return user.has_usable_password()

# noinspection PyUnresolvedReferences,PyProtectedMember
def _hash_code(self, raw_code):
return self.user._hash_code(raw_code)
def _get_hashed(self, raw):
return self.user.get_hashed(raw)

# noinspection PyUnresolvedReferences
def __verify_this_against_other_code(self, this, other):
Expand All @@ -698,7 +689,7 @@ def __verify_this_against_other_code(self, this, other):

# noinspection PyUnresolvedReferences
def __reinitialize_security_answer(self):
hashed_answer = self._hash_code(self.security_question_answer)
hashed_answer = self._get_hashed(self.security_question_answer)
meta = self.user.metadata
if meta.security_question.usable:
# providing an answer only makes sense if the question being answered
Expand Down Expand Up @@ -772,6 +763,6 @@ def remaining(self):
# maximum attempts reached. deactivate account
self.user.is_active = False
# noinspection PyUnresolvedReferences
self.user.save(auto_hash_password=False)
self.user.save()
return rem
return -1
37 changes: 31 additions & 6 deletions xauth/serializers.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import importlib

from django.contrib.auth import get_user_model
from rest_framework import serializers

from xauth.models import SecurityQuestion
from xauth.utils import valid_str
from xauth.utils.settings import XAUTH


class ProfileSerializer(serializers.ModelSerializer):
Expand All @@ -11,8 +15,20 @@ class ProfileSerializer(serializers.ModelSerializer):

class Meta:
model = get_user_model()
fields = get_user_model().PUBLIC_READ_WRITE_FIELDS + ('url',)
read_only_fields = get_user_model().READ_ONLY_FIELDS
fields = tuple(get_user_model().PUBLIC_READ_WRITE_FIELDS) + ('url',)
read_only_fields = tuple(get_user_model().READ_ONLY_FIELDS)


def get_serializer_class():
sr = XAUTH.get('USER_PROFILE_SERIALIZER', 'xauth.serializers.ProfileSerializer')
if valid_str(sr):
# probably a serializer class
module_name, class_name = sr.rsplit('.', 1)
return getattr(importlib.import_module(module_name), class_name)
return ProfileSerializer


serializer_class = get_serializer_class()


class AuthTokenOnlySerializer(serializers.HyperlinkedModelSerializer):
Expand All @@ -24,11 +40,11 @@ class Meta:
fields = 'normal', 'encrypted',


class AuthSerializer(ProfileSerializer):
class AuthSerializer(serializer_class):
token = serializers.DictField(source='token.tokens', read_only=True, )

class Meta(ProfileSerializer.Meta):
fields = ProfileSerializer.Meta.fields + ('token',)
class Meta(serializer_class.Meta):
fields = tuple(serializer_class.Meta.fields) + ('token',)

def validate(self, attrs):
return super().validate(attrs)
Expand All @@ -39,7 +55,16 @@ class SignUpSerializer(AuthSerializer):
style={'input_type': 'password'})

class Meta(AuthSerializer.Meta):
fields = AuthSerializer.Meta.fields + get_user_model().WRITE_ONLY_FIELDS
fields = tuple(AuthSerializer.Meta.fields) + tuple(get_user_model().WRITE_ONLY_FIELDS)

def create(self, validated_data):
# saves password in plain text
user = super().create(validated_data)
if user.has_usable_password():
# hash and re-save password
user.password = user.get_hashed(user.password)
user.save()
return user


class SecurityQuestionSerializer(serializers.HyperlinkedModelSerializer):
Expand Down
2 changes: 1 addition & 1 deletion xauth/tests/test_account_activation_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ class AccountActivationViewTestCase(SecurityQuestionAPITestCase):
def setUp(self) -> None:
super().setUp()
self.user.is_active = False
self.user.save(auto_hash_password=False)
self.user.save()
self.correct_security_question_answer = 'blue'
update_metadata(self.user, self.security_question, self.correct_security_question_answer)

Expand Down
2 changes: 1 addition & 1 deletion xauth/tests/test_password_reset_verify_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ def test_reset_password_with_correct_temporary_password_returns_200(self):
new_password = 'new_password'
self.assertIs(self.user.check_password(self.password), True)
self.user.is_verified = True # without this token expiry test would fail
self.user.save(auto_hash_password=False, )
self.user.save()
self.assertIs(self.user.check_password(self.password), True)
pr_token = self.user.password_reset_token.encrypted
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {pr_token}')
Expand Down
17 changes: 9 additions & 8 deletions xauth/tests/test_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ def create_user_with_security_question(self, correct_fav_color):
user = get_user_model().objects.create_user(email='user@mail-domain.com', username='user123', )
user.is_verified = True
user.is_active = False
user.save(auto_hash_password=False)
user.save()
# updates user's metadata
meta = update_metadata(user, sec_quest=self.security_quest, sec_ans=correct_fav_color)
return meta, user
Expand Down Expand Up @@ -137,10 +137,11 @@ def test_creating_user_without_manager_methods_works(self):
def test_check_password_for_created_user_returns_True(self):
password = 'password123!'
user = get_user_model().objects.create(email='user@mail-domain.com', )
user.password = password
user.save(auto_hash_password=True)
user1 = User(email='user1@mail-domain.com', password=password)
user1.save(auto_hash_password=True)
user.password = user.get_hashed(password)
user.save()
user1 = User(email='user1@mail-domain.com')
user1.password = user1.get_hashed(password)
user1.save()

self.assertEqual(user.check_password(password), True)
self.assertEqual(user1.check_password(password), True)
Expand Down Expand Up @@ -258,7 +259,7 @@ def test_reset_password_with_correct_password_returns_Token_expiring_after_60day
user = get_user_model().objects.create_user(email='user@mail-domain.com', username='user123', )
self.assertIs(user.is_verified, False)
user.is_verified = True
user.save(auto_hash_password=False, update_fields=['is_active'])
user.save(update_fields=['is_active'])
self.assertIs(user.is_verified, True)
self.assertIs(user.has_usable_password(), False)
_, correct_password = user.request_password_reset(send_mail=False)
Expand Down Expand Up @@ -311,7 +312,7 @@ def test_request_verification_with_a_verified_user_returns_Token_and_None_code(s
password = 'password'
user = get_user_model().objects.create_user(email='user@mail-domain.com', username='user123', password=password)
user.is_verified = True
user.save(auto_hash_password=False)
user.save()
token, code = user.request_verification(send_mail=False)

self.assertIs(user.is_verified, True)
Expand All @@ -323,7 +324,7 @@ def test_verifying_a_verified_user_returns_Token_and_None_message(self):
password = 'password'
user = get_user_model().objects.create_user(email='user@mail-domain.com', username='user123', password=password)
user.is_verified = True
user.save(auto_hash_password=False)
user.save()
token, message = user.verify('123456')

self.assertIs(user.is_verified, True)
Expand Down

0 comments on commit b4d5774

Please sign in to comment.