From bf57ae7269f5f7567e43717aba454668c15cf68c Mon Sep 17 00:00:00 2001
From: Nicolas ROCHE <nroche@entrouvert.com>
Date: Wed, 4 Sep 2019 18:02:32 +0200
Subject: [PATCH] api: add a hashed_password attribute for user api (#35482)

---
 src/authentic2/api_views.py | 29 +++++++++++++++++++++++---
 tests/test_api.py           | 41 +++++++++++++++++++++++++++++++++++++
 2 files changed, 67 insertions(+), 3 deletions(-)

diff --git a/src/authentic2/api_views.py b/src/authentic2/api_views.py
index 38ce142f..4a446e4f 100644
--- a/src/authentic2/api_views.py
+++ b/src/authentic2/api_views.py
@@ -20,6 +20,7 @@ import smtplib
 import django
 from django.db import models
 from django.contrib.auth import get_user_model
+from django.contrib.auth.hashers import identify_hasher
 from django.core.exceptions import MultipleObjectsReturned
 from django.utils.translation import ugettext as _
 from django.utils.encoding import force_text
@@ -340,6 +341,7 @@ class BaseUserSerializer(serializers.ModelSerializer):
     send_registration_email_next_url = serializers.URLField(write_only=True, required=False)
     password = serializers.CharField(max_length=128, required=False)
     force_password_reset = serializers.BooleanField(write_only=True, required=False, default=False)
+    hashed_password = serializers.CharField(max_length=128, required=False)
 
     def __init__(self, *args, **kwargs):
         super(BaseUserSerializer, self).__init__(*args, **kwargs)
@@ -392,6 +394,7 @@ class BaseUserSerializer(serializers.ModelSerializer):
         attributes = validated_data.pop('attributes', {})
         is_verified = validated_data.pop('is_verified', {})
         password = validated_data.pop('password', None)
+        hashed_password = validated_data.pop('hashed_password', None)
         self.check_perm('custom_user.add_user', validated_data.get('ou'))
         instance = super(BaseUserSerializer, self).create(validated_data)
         # prevent update on a get_or_create
@@ -413,6 +416,9 @@ class BaseUserSerializer(serializers.ModelSerializer):
         instance.save()
         if force_password_reset:
             PasswordReset.objects.get_or_create(user=instance)
+        if hashed_password is not None:
+            instance.password = hashed_password
+            instance.save()
         if send_registration_email and validated_data.get('email'):
             try:
                 utils.send_password_reset_mail(
@@ -437,6 +443,7 @@ class BaseUserSerializer(serializers.ModelSerializer):
         attributes = validated_data.pop('attributes', {})
         is_verified = validated_data.pop('is_verified', {})
         password = validated_data.pop('password', None)
+        hashed_password = validated_data.pop('hashed_password', None)
         # Double check: to move an user from one ou into another you must be administrator of both
         self.check_perm('custom_user.change_user', instance.ou)
         if 'ou' in validated_data:
@@ -465,6 +472,9 @@ class BaseUserSerializer(serializers.ModelSerializer):
             instance.save()
         if force_password_reset:
             PasswordReset.objects.get_or_create(user=instance)
+        if hashed_password is not None:
+            instance.password = hashed_password
+            instance.save()
         return instance
 
     def validate(self, data):
@@ -491,10 +501,23 @@ class BaseUserSerializer(serializers.ModelSerializer):
             if ou and ou.email_is_unique and qs.filter(ou=ou, email=data['email']).exists():
                 already_used = True
 
+        errors = {}
         if already_used:
-            raise serializers.ValidationError({
-                'email': 'email already used',
-            })
+            errors['email'] = 'email already used'
+        if data.get('password') and data.get('hashed_password'):
+            errors['password'] = 'conflict with provided hashed_password'
+        if data.get('hashed_password'):
+            try:
+                hasher = identify_hasher(data.get('hashed_password'))
+            except ValueError:
+                errors['hashed_password'] = "unknown hash format"
+            else:
+                try:
+                    hasher.safe_summary(data.get('hashed_password'))
+                except Exception:
+                    errors['hashed_password'] = "hash format error"
+        if errors:
+            raise serializers.ValidationError(errors)
         return data
 
     class Meta:
diff --git a/tests/test_api.py b/tests/test_api.py
index 7145c44c..2a1f8860 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -22,6 +22,7 @@ import random
 import uuid
 
 
+import django
 from django.contrib.auth.hashers import check_password
 from django.contrib.auth import get_user_model
 from django.contrib.contenttypes.models import ContentType
@@ -1268,3 +1269,43 @@ def test_api_roles_get_or_create(settings, ou1, app, admin):
     assert uuid == resp.json['uuid']
     assert Role.objects.get(uuid=uuid).name == 'Role 2'
     assert Role.objects.get(uuid=uuid).slug == 'role-1'
+
+
+def test_api_users_hashed_password(settings, app, admin):
+    app.authorization = ('Basic', (admin.username, admin.username))
+    payload = {
+        'email': 'john.doe@example.net',
+        'first_name': 'John',
+        'last_name': 'Doe',
+        'hashed_password': 'pbkdf2_sha256$36000$re9zaUj1ize0$bX1cqB91ni4aMOtRh8//TLaJkX+xnD2w84MCQx9AJcE=',
+    }
+    resp = app.post_json('/api/users/?get_or_create=email', params=payload, status=201)
+    id = resp.json['id']
+    assert User.objects.get(id=id).first_name == 'John'
+    assert User.objects.get(id=id).last_name == 'Doe'
+    assert User.objects.get(id=id).check_password('admin')
+    password = User.objects.get(id=id).password
+
+    payload['hashed_password'] = 'sha-oldap$$e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4'
+    resp = app.post_json('/api/users/?update_or_create=email', params=payload, status=200)
+    assert User.objects.get(id=id).password != password
+    assert User.objects.get(id=id).check_password('secret')
+
+    payload['password'] = 'secret'
+    resp = app.post_json('/api/users/?update_or_create=email', params=payload, status=400)
+    assert resp.json['result'] == 0
+    assert resp.json['errors']['password'] == ['conflict with provided hashed_password']
+
+    del payload['password']
+    payload['hashed_password'] = 'unknown_format'
+    resp = app.post_json('/api/users/?update_or_create=email', params=payload, status=400)
+    assert resp.json['result'] == 0
+    assert resp.json['errors']['hashed_password'] == ['unknown hash format']
+
+    payload['hashed_password'] = 'argon2$wrong_format'
+    resp = app.post_json('/api/users/?update_or_create=email', params=payload, status=400)
+    assert resp.json['result'] == 0
+    if django.VERSION >= (1, 10, 0):
+        assert resp.json['errors']['hashed_password'] == ['hash format error']
+    else:  #  before Django 1.10 Argon2 is not recognized
+        assert resp.json['errors']['hashed_password'] == ['unknown hash format']
-- 
2.23.0.rc1

