From 9091791436cf54be0d140d89fbb9f69683836246 Mon Sep 17 00:00:00 2001 From: Daniel Yohan Date: Mon, 28 Dec 2020 16:33:11 -0300 Subject: [PATCH 01/12] Added router my profile update with keycloak --- Pipfile | 1 + Pipfile.lock | 141 +++++++++++++----- README.md | 2 + weni/api/v1/account/__init__.py | 0 weni/api/v1/account/serializers.py | 14 ++ weni/api/v1/account/views.py | 36 +++++ weni/api/v1/keycloak.py | 33 ++++ weni/api/v1/routers.py | 2 + weni/authentication/__init__.py | 0 weni/authentication/admin.py | 8 + weni/authentication/apps.py | 8 + .../authentication/migrations/0001_initial.py | 116 ++++++++++++++ weni/authentication/migrations/__init__.py | 0 weni/authentication/models.py | 74 +++++++++ weni/authentication/signals.py | 24 +++ weni/authentication/tests.py | 0 weni/common/migrations/0001_initial.py | 2 +- weni/common/models.py | 3 +- weni/settings.py | 8 + 19 files changed, 431 insertions(+), 41 deletions(-) create mode 100644 weni/api/v1/account/__init__.py create mode 100644 weni/api/v1/account/serializers.py create mode 100644 weni/api/v1/account/views.py create mode 100644 weni/api/v1/keycloak.py create mode 100644 weni/authentication/__init__.py create mode 100644 weni/authentication/admin.py create mode 100644 weni/authentication/apps.py create mode 100644 weni/authentication/migrations/0001_initial.py create mode 100644 weni/authentication/migrations/__init__.py create mode 100644 weni/authentication/models.py create mode 100644 weni/authentication/signals.py create mode 100644 weni/authentication/tests.py diff --git a/Pipfile b/Pipfile index 5a70d1c4..13b17f7f 100644 --- a/Pipfile +++ b/Pipfile @@ -20,6 +20,7 @@ django-celery-results = "~=1.1.2" redis = "~=3.5.3" django-celery-beat = "~=2.1.0" psycopg2-binary = "~=2.7.7" +python-keycloak = "~=0.24.0" [requires] python_version = "3.6" diff --git a/Pipfile.lock b/Pipfile.lock index 1f032d72..6d1d3247 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "c06a83e244d956054bb43bfcfbc5a23297cb58ebaa3a0f1b652482b9acf5a57b" + "sha256": "db5f5fa35679b89bbeb8985e74207c390a2cc3ec9e61fa63157214d9fa81f3ad" }, "pipfile-spec": 6, "requires": { @@ -192,6 +192,14 @@ "index": "pypi", "version": "==1.19.4" }, + "ecdsa": { + "hashes": [ + "sha256:64c613005f13efec6541bb0a33290d0d03c27abab5f15fbab20fb0ee162bdd8e", + "sha256:e108a5fe92c67639abae3260e43561af914e7fd0d27bae6d2ec1312ae7934dfe" + ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==0.14.1" + }, "idna": { "hashes": [ "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", @@ -338,6 +346,24 @@ "index": "pypi", "version": "==2.7.7" }, + "pyasn1": { + "hashes": [ + "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359", + "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576", + "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf", + "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7", + "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d", + "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00", + "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8", + "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86", + "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12", + "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776", + "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba", + "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2", + "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3" + ], + "version": "==0.4.8" + }, "pycparser": { "hashes": [ "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", @@ -376,12 +402,26 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.8.1" }, + "python-jose": { + "hashes": [ + "sha256:4e4192402e100b5fb09de5a8ea6bcc39c36ad4526341c123d401e2561720335b", + "sha256:67d7dfff599df676b04a996520d9be90d6cdb7e6dd10b4c7cacc0c3e2e92f2be" + ], + "version": "==3.2.0" + }, + "python-keycloak": { + "hashes": [ + "sha256:f21ba80385e128eb24159d132b12254c3171d83080a1e6bf7e7dd5590c0b82b1" + ], + "index": "pypi", + "version": "==0.24.0" + }, "pytz": { "hashes": [ - "sha256:3e6b7dd2d1e0a59084bcee14a17af60c5c562cdc16d828e8eba2e683d3a7e268", - "sha256:5c55e189b682d420be27c6995ba6edce0c0a77dd67bfbe2ae6607134d5851ffd" + "sha256:16962c5fb8db4a8f63a26646d8886e9d769b6c511543557bc84e9569fb9a9cb4", + "sha256:180befebb1927b16f6b57101720075a984c019ac16b1b7575673bea42c6c3da5" ], - "version": "==2020.4" + "version": "==2020.5" }, "redis": { "hashes": [ @@ -399,6 +439,14 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==2.25.1" }, + "rsa": { + "hashes": [ + "sha256:109ea5a66744dd859bf16fe904b8d8b627adafb9408753161e766a92e7d681fa", + "sha256:6166864e23d6b5195a5cfed6cd9fed0fe774e226d8f854fcb23b7bbef0350233" + ], + "markers": "python_version >= '3.5' and python_version < '4'", + "version": "==4.6" + }, "ruamel.yaml": { "hashes": [ "sha256:012b9470a0ea06e4e44e99e7920277edf6b46eee0232a04487ea73a7386340a5", @@ -526,43 +574,58 @@ }, "coverage": { "hashes": [ - "sha256:0203acd33d2298e19b57451ebb0bed0ab0c602e5cf5a818591b4918b1f97d516", - "sha256:0f313707cdecd5cd3e217fc68c78a960b616604b559e9ea60cc16795c4304259", - "sha256:1c6703094c81fa55b816f5ae542c6ffc625fec769f22b053adb42ad712d086c9", - "sha256:1d44bb3a652fed01f1f2c10d5477956116e9b391320c94d36c6bf13b088a1097", - "sha256:280baa8ec489c4f542f8940f9c4c2181f0306a8ee1a54eceba071a449fb870a0", - "sha256:29a6272fec10623fcbe158fdf9abc7a5fa032048ac1d8631f14b50fbfc10d17f", - "sha256:2b31f46bf7b31e6aa690d4c7a3d51bb262438c6dcb0d528adde446531d0d3bb7", - "sha256:2d43af2be93ffbad25dd959899b5b809618a496926146ce98ee0b23683f8c51c", - "sha256:381ead10b9b9af5f64646cd27107fb27b614ee7040bb1226f9c07ba96625cbb5", - "sha256:47a11bdbd8ada9b7ee628596f9d97fbd3851bd9999d398e9436bd67376dbece7", - "sha256:4d6a42744139a7fa5b46a264874a781e8694bb32f1d76d8137b68138686f1729", - "sha256:50691e744714856f03a86df3e2bff847c2acede4c191f9a1da38f088df342978", - "sha256:530cc8aaf11cc2ac7430f3614b04645662ef20c348dce4167c22d99bec3480e9", - "sha256:582ddfbe712025448206a5bc45855d16c2e491c2dd102ee9a2841418ac1c629f", - "sha256:63808c30b41f3bbf65e29f7280bf793c79f54fb807057de7e5238ffc7cc4d7b9", - "sha256:71b69bd716698fa62cd97137d6f2fdf49f534decb23a2c6fc80813e8b7be6822", - "sha256:7858847f2d84bf6e64c7f66498e851c54de8ea06a6f96a32a1d192d846734418", - "sha256:78e93cc3571fd928a39c0b26767c986188a4118edc67bc0695bc7a284da22e82", - "sha256:7f43286f13d91a34fadf61ae252a51a130223c52bfefb50310d5b2deb062cf0f", - "sha256:86e9f8cd4b0cdd57b4ae71a9c186717daa4c5a99f3238a8723f416256e0b064d", - "sha256:8f264ba2701b8c9f815b272ad568d555ef98dfe1576802ab3149c3629a9f2221", - "sha256:9342dd70a1e151684727c9c91ea003b2fb33523bf19385d4554f7897ca0141d4", - "sha256:9361de40701666b034c59ad9e317bae95c973b9ff92513dd0eced11c6adf2e21", - "sha256:9669179786254a2e7e57f0ecf224e978471491d660aaca833f845b72a2df3709", - "sha256:aac1ba0a253e17889550ddb1b60a2063f7474155465577caa2a3b131224cfd54", - "sha256:aef72eae10b5e3116bac6957de1df4d75909fc76d1499a53fb6387434b6bcd8d", - "sha256:bd3166bb3b111e76a4f8e2980fa1addf2920a4ca9b2b8ca36a3bc3dedc618270", - "sha256:c1b78fb9700fc961f53386ad2fd86d87091e06ede5d118b8a50dea285a071c24", - "sha256:c3888a051226e676e383de03bf49eb633cd39fc829516e5334e69b8d81aae751", - "sha256:c5f17ad25d2c1286436761b462e22b5020d83316f8e8fcb5deb2b3151f8f1d3a", - "sha256:c851b35fc078389bc16b915a0a7c1d5923e12e2c5aeec58c52f4aa8085ac8237", - "sha256:cb7df71de0af56000115eafd000b867d1261f786b5eebd88a0ca6360cccfaca7", - "sha256:cedb2f9e1f990918ea061f28a0f0077a07702e3819602d3507e2ff98c8d20636", - "sha256:e8caf961e1b1a945db76f1b5fa9c91498d15f545ac0ababbe575cfab185d3bd8" + "sha256:08b3ba72bd981531fd557f67beee376d6700fba183b167857038997ba30dd297", + "sha256:2757fa64e11ec12220968f65d086b7a29b6583d16e9a544c889b22ba98555ef1", + "sha256:3102bb2c206700a7d28181dbe04d66b30780cde1d1c02c5f3c165cf3d2489497", + "sha256:3498b27d8236057def41de3585f317abae235dd3a11d33e01736ffedb2ef8606", + "sha256:378ac77af41350a8c6b8801a66021b52da8a05fd77e578b7380e876c0ce4f528", + "sha256:38f16b1317b8dd82df67ed5daa5f5e7c959e46579840d77a67a4ceb9cef0a50b", + "sha256:3911c2ef96e5ddc748a3c8b4702c61986628bb719b8378bf1e4a6184bbd48fe4", + "sha256:3a3c3f8863255f3c31db3889f8055989527173ef6192a283eb6f4db3c579d830", + "sha256:3b14b1da110ea50c8bcbadc3b82c3933974dbeea1832e814aab93ca1163cd4c1", + "sha256:535dc1e6e68fad5355f9984d5637c33badbdc987b0c0d303ee95a6c979c9516f", + "sha256:6f61319e33222591f885c598e3e24f6a4be3533c1d70c19e0dc59e83a71ce27d", + "sha256:723d22d324e7997a651478e9c5a3120a0ecbc9a7e94071f7e1954562a8806cf3", + "sha256:76b2775dda7e78680d688daabcb485dc87cf5e3184a0b3e012e1d40e38527cc8", + "sha256:782a5c7df9f91979a7a21792e09b34a658058896628217ae6362088b123c8500", + "sha256:7e4d159021c2029b958b2363abec4a11db0ce8cd43abb0d9ce44284cb97217e7", + "sha256:8dacc4073c359f40fcf73aede8428c35f84639baad7e1b46fce5ab7a8a7be4bb", + "sha256:8f33d1156241c43755137288dea619105477961cfa7e47f48dbf96bc2c30720b", + "sha256:8ffd4b204d7de77b5dd558cdff986a8274796a1e57813ed005b33fd97e29f059", + "sha256:93a280c9eb736a0dcca19296f3c30c720cb41a71b1f9e617f341f0a8e791a69b", + "sha256:9a4f66259bdd6964d8cf26142733c81fb562252db74ea367d9beb4f815478e72", + "sha256:9a9d4ff06804920388aab69c5ea8a77525cf165356db70131616acd269e19b36", + "sha256:a2070c5affdb3a5e751f24208c5c4f3d5f008fa04d28731416e023c93b275277", + "sha256:a4857f7e2bc6921dbd487c5c88b84f5633de3e7d416c4dc0bb70256775551a6c", + "sha256:a607ae05b6c96057ba86c811d9c43423f35e03874ffb03fbdcd45e0637e8b631", + "sha256:a66ca3bdf21c653e47f726ca57f46ba7fc1f260ad99ba783acc3e58e3ebdb9ff", + "sha256:ab110c48bc3d97b4d19af41865e14531f300b482da21783fdaacd159251890e8", + "sha256:b239711e774c8eb910e9b1ac719f02f5ae4bf35fa0420f438cdc3a7e4e7dd6ec", + "sha256:be0416074d7f253865bb67630cf7210cbc14eb05f4099cc0f82430135aaa7a3b", + "sha256:c46643970dff9f5c976c6512fd35768c4a3819f01f61169d8cdac3f9290903b7", + "sha256:c5ec71fd4a43b6d84ddb88c1df94572479d9a26ef3f150cef3dacefecf888105", + "sha256:c6e5174f8ca585755988bc278c8bb5d02d9dc2e971591ef4a1baabdf2d99589b", + "sha256:c89b558f8a9a5a6f2cfc923c304d49f0ce629c3bd85cb442ca258ec20366394c", + "sha256:cc44e3545d908ecf3e5773266c487ad1877be718d9dc65fc7eb6e7d14960985b", + "sha256:cc6f8246e74dd210d7e2b56c76ceaba1cc52b025cd75dbe96eb48791e0250e98", + "sha256:cd556c79ad665faeae28020a0ab3bda6cd47d94bec48e36970719b0b86e4dcf4", + "sha256:ce6f3a147b4b1a8b09aae48517ae91139b1b010c5f36423fa2b866a8b23df879", + "sha256:ceb499d2b3d1d7b7ba23abe8bf26df5f06ba8c71127f188333dddcf356b4b63f", + "sha256:cef06fb382557f66d81d804230c11ab292d94b840b3cb7bf4450778377b592f4", + "sha256:e448f56cfeae7b1b3b5bcd99bb377cde7c4eb1970a525c770720a352bc4c8044", + "sha256:e52d3d95df81c8f6b2a1685aabffadf2d2d9ad97203a40f8d61e51b70f191e4e", + "sha256:ee2f1d1c223c3d2c24e3afbb2dd38be3f03b1a8d6a83ee3d9eb8c36a52bee899", + "sha256:f2c6888eada180814b8583c3e793f3f343a692fc802546eed45f40a001b1169f", + "sha256:f51dbba78d68a44e99d484ca8c8f604f17e957c1ca09c3ebc2c7e3bbd9ba0448", + "sha256:f54de00baf200b4539a5a092a759f000b5f45fd226d6d25a76b0dff71177a714", + "sha256:fa10fee7e32213f5c7b0d6428ea92e3a3fdd6d725590238a3f92c0de1c78b9d2", + "sha256:fabeeb121735d47d8eab8671b6b031ce08514c86b7ad8f7d5490a7b6dcd6267d", + "sha256:fac3c432851038b3e6afe086f777732bcf7f6ebbfd90951fa04ee53db6d0bcdd", + "sha256:fda29412a66099af6d6de0baa6bd7c52674de177ec2ad2630ca264142d69c6c7", + "sha256:ff1330e8bc996570221b450e2d539134baa9465f5cb98aff0e0f73f34172e0ae" ], "index": "pypi", - "version": "==5.3" + "version": "==5.3.1" }, "dataclasses": { "hashes": [ diff --git a/README.md b/README.md index e6c446f8..f0152028 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ You can set environment variables in your OS, write on ```.env``` file or pass v | LANGUAGE_CODE | ```string``` | ```en-us``` | A string representing the language code for this installation.This should be in standard [language ID format](https://docs.djangoproject.com/en/2.0/topics/i18n/#term-language-code). | TIME_ZONE | ```string``` | ```UTC``` | A string representing the time zone for this installation. See the [list of time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones). | STATIC_URL | ```string``` | ```/static/``` | URL to use when referring to static files located in ```STATIC_ROOT```. +| OIDC_RP_SERVER_URL | ```string``` | ```None``` | Open ID Connect Server URL, example: https://accounts.weni.ai/auth/. +| OIDC_RP_REALM_NAME | ```string``` | ```None``` | Open ID Connect Realm Name. | OIDC_RP_CLIENT_ID | ```string``` | ```None``` | OpenID Connect client ID provided by your OP. | OIDC_RP_CLIENT_SECRET | ```string``` | ```None``` | OpenID Connect client secret provided by your OP. | OIDC_OP_AUTHORIZATION_ENDPOINT | ```string``` | ```None``` | URL of your OpenID Connect provider authorization endpoint. diff --git a/weni/api/v1/account/__init__.py b/weni/api/v1/account/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/weni/api/v1/account/serializers.py b/weni/api/v1/account/serializers.py new file mode 100644 index 00000000..b48b9414 --- /dev/null +++ b/weni/api/v1/account/serializers.py @@ -0,0 +1,14 @@ +from django.utils.translation import ugettext_lazy as _ +from rest_framework import serializers + +from weni.authentication.models import User + + +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ["username", "email", "first_name", "last_name"] + ref_name = None + + username = serializers.CharField(label=_("Username"), read_only=True) + email = serializers.EmailField(label=_("Email"), read_only=True) diff --git a/weni/api/v1/account/views.py b/weni/api/v1/account/views.py new file mode 100644 index 00000000..29a378cc --- /dev/null +++ b/weni/api/v1/account/views.py @@ -0,0 +1,36 @@ +from rest_framework import mixins, permissions +from rest_framework.viewsets import GenericViewSet + +from weni.api.v1.account.serializers import UserSerializer +from weni.authentication.models import User + + +class MyUserProfileViewSet( + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + GenericViewSet, +): + """ + Manager current user profile. + retrieve: + Get current user profile + update: + Update current user profile. + partial_update: + Update, partially, current user profile. + """ + + serializer_class = UserSerializer + queryset = User.objects + lookup_field = None + permission_classes = [permissions.IsAuthenticated] + + def get_object(self, *args, **kwargs): + request = self.request + user = request.user + + # May raise a permission denied + self.check_object_permissions(self.request, user) + + return user diff --git a/weni/api/v1/keycloak.py b/weni/api/v1/keycloak.py new file mode 100644 index 00000000..21c0e975 --- /dev/null +++ b/weni/api/v1/keycloak.py @@ -0,0 +1,33 @@ +from django.conf import settings +from keycloak import KeycloakAdmin + + +class KeycloakControl: + def __init__(self): + self.instance = self.get_instance() + + def get_instance(self) -> KeycloakAdmin: + return KeycloakAdmin( + server_url=settings.OIDC_RP_SERVER_URL, + realm_name=settings.OIDC_RP_REALM_NAME, + client_id=settings.OIDC_RP_CLIENT_ID, + client_secret_key=settings.OIDC_RP_CLIENT_SECRET, + verify=True, + auto_refresh_token=["get", "post", "put", "delete"], + ) + + def get_user_id_by_email(self, email: str) -> str: + """ + Get internal keycloak user id from email + This is required for further actions against this user. + + UserRepresentation + https://www.keycloak.org/docs-api/8.0/rest-api/index.html#_userrepresentation + + :param email: id in UserRepresentation + + :return: user_id + """ + + users = self.instance.get_users(query={"email": email}) + return next((user["id"] for user in users if user["email"] == email), None) diff --git a/weni/api/v1/routers.py b/weni/api/v1/routers.py index aa4c0173..66915a56 100644 --- a/weni/api/v1/routers.py +++ b/weni/api/v1/routers.py @@ -1,5 +1,6 @@ from rest_framework import routers +from weni.api.v1.account.views import MyUserProfileViewSet from weni.api.v1.dashboard.views import NewsletterViewSet, StatusServiceViewSet @@ -79,3 +80,4 @@ def get_lookup_regex(self, viewset, lookup_prefix=""): # pragma: no cover router = Router() router.register("dashboard/newsletter", NewsletterViewSet) router.register("dashboard/status-service", StatusServiceViewSet) +router.register("account/my-profile", MyUserProfileViewSet) diff --git a/weni/authentication/__init__.py b/weni/authentication/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/weni/authentication/admin.py b/weni/authentication/admin.py new file mode 100644 index 00000000..6f700267 --- /dev/null +++ b/weni/authentication/admin.py @@ -0,0 +1,8 @@ +from django.contrib import admin + +from weni.authentication.models import User + + +@admin.register(User) +class UserAdmin(admin.ModelAdmin): + pass diff --git a/weni/authentication/apps.py b/weni/authentication/apps.py new file mode 100644 index 00000000..ae15bb1e --- /dev/null +++ b/weni/authentication/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class AuthenticationConfig(AppConfig): + name = "weni.authentication" + + def ready(self): + from .signals import update_user_keycloak # noqa: F401 diff --git a/weni/authentication/migrations/0001_initial.py b/weni/authentication/migrations/0001_initial.py new file mode 100644 index 00000000..68a982cf --- /dev/null +++ b/weni/authentication/migrations/0001_initial.py @@ -0,0 +1,116 @@ +# Generated by Django 2.2.17 on 2020-12-28 14:24 + +import django.contrib.auth.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("auth", "0011_update_proxy_permissions"), + ] + + operations = [ + migrations.CreateModel( + name="User", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("password", models.CharField(max_length=128, verbose_name="password")), + ( + "last_login", + models.DateTimeField( + blank=True, null=True, verbose_name="last login" + ), + ), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + ), + ), + ( + "first_name", + models.CharField( + blank=True, max_length=30, verbose_name="first name" + ), + ), + ( + "last_name", + models.CharField( + blank=True, max_length=150, verbose_name="last name" + ), + ), + ( + "email", + models.EmailField( + help_text="User's email.", + max_length=254, + unique=True, + verbose_name="email", + ), + ), + ( + "username", + models.CharField( + error_messages={ + "unique": "A user with that username already exists." + }, + help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", + max_length=150, + unique=True, + validators=[ + django.contrib.auth.validators.UnicodeUsernameValidator() + ], + verbose_name="username", + ), + ), + ( + "is_staff", + models.BooleanField(default=False, verbose_name="staff status"), + ), + ("is_active", models.BooleanField(default=True, verbose_name="active")), + ( + "joined_at", + models.DateField(auto_now_add=True, verbose_name="joined at"), + ), + ( + "groups", + models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.Group", + verbose_name="groups", + ), + ), + ( + "user_permissions", + models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.Permission", + verbose_name="user permissions", + ), + ), + ], + options={ + "verbose_name": "user", + "verbose_name_plural": "users", + }, + ), + ] diff --git a/weni/authentication/migrations/__init__.py b/weni/authentication/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/weni/authentication/models.py b/weni/authentication/models.py new file mode 100644 index 00000000..b38a97e6 --- /dev/null +++ b/weni/authentication/models.py @@ -0,0 +1,74 @@ +from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager +from django.contrib.auth.models import PermissionsMixin +from django.contrib.auth.tokens import PasswordResetTokenGenerator +from django.contrib.auth.validators import UnicodeUsernameValidator +from django.db import models +from django.utils.translation import ugettext_lazy as _ + + +class UserManager(BaseUserManager): + def _create_user(self, email, username, password=None, **extra_fields): + if not email: + raise ValueError("The given email must be set") + if not username: + raise ValueError("The given nick must be set") + + email = self.normalize_email(email) + user = self.model(email=email, username=username, **extra_fields) + user.set_password(password) + user.save(using=self._db) + return user + + def create_user(self, email, username, password=None, **extra_fields): + extra_fields.setdefault("is_superuser", False) + + return self._create_user(email, username, password, **extra_fields) + + def create_superuser(self, email, username, password=None, **extra_fields): + extra_fields.setdefault("is_superuser", True) + extra_fields.setdefault("is_staff", True) + + if extra_fields.get("is_superuser") is not True: + raise ValueError("Superuser must have is_superuser=True.") + + return self._create_user(email, username, password, **extra_fields) + + +class User(AbstractBaseUser, PermissionsMixin): + class Meta: + verbose_name = _("user") + verbose_name_plural = _("users") + + USERNAME_FIELD = "email" + REQUIRED_FIELDS = ["username"] + + first_name = models.CharField(_("first name"), max_length=30, blank=True) + last_name = models.CharField(_("last name"), max_length=150, blank=True) + email = models.EmailField(_("email"), unique=True, help_text=_("User's email.")) + + username = models.CharField( + _("username"), + max_length=150, + unique=True, + help_text=_( + "Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only." + ), + validators=[UnicodeUsernameValidator()], + error_messages={ + "unique": _("A user with that username already exists."), + }, + ) + + is_staff = models.BooleanField(_("staff status"), default=False) + is_active = models.BooleanField(_("active"), default=True) + + joined_at = models.DateField(_("joined at"), auto_now_add=True) + + objects = UserManager() + + @property + def token_generator(self): + return PasswordResetTokenGenerator() + + def check_password_reset_token(self, token): + return self.token_generator.check_token(self, token) diff --git a/weni/authentication/signals.py b/weni/authentication/signals.py new file mode 100644 index 00000000..c2e25736 --- /dev/null +++ b/weni/authentication/signals.py @@ -0,0 +1,24 @@ +from django.db import models +from django.dispatch import receiver +from django.utils.translation import ugettext_lazy as _ +from keycloak import exceptions +from rest_framework.exceptions import ValidationError + +from weni.api.v1.keycloak import KeycloakControl +from weni.authentication.models import User + + +@receiver(models.signals.pre_save, sender=User) +def update_user_keycloak(instance, **kwargs): + try: + keycloak_instance = KeycloakControl() + + user_id = keycloak_instance.get_user_id_by_email(email=instance.email) + keycloak_instance.get_instance().update_user( + user_id=user_id, + payload={"firstName": instance.first_name, "lastName": instance.last_name}, + ) + except exceptions.KeycloakGetError: + raise ValidationError( + _("System temporarily unavailable, please try again later.") + ) diff --git a/weni/authentication/tests.py b/weni/authentication/tests.py new file mode 100644 index 00000000..e69de29b diff --git a/weni/common/migrations/0001_initial.py b/weni/common/migrations/0001_initial.py index 2f3af067..73331e59 100644 --- a/weni/common/migrations/0001_initial.py +++ b/weni/common/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 2.2.17 on 2020-12-16 14:38 +# Generated by Django 2.2.17 on 2020-12-28 14:24 from django.conf import settings from django.db import migrations, models diff --git a/weni/common/models.py b/weni/common/models.py index d2ef6761..b8deb02c 100644 --- a/weni/common/models.py +++ b/weni/common/models.py @@ -1,9 +1,10 @@ -from django.contrib.auth.models import User from django.db import models from django.db.models.signals import post_save from django.dispatch import receiver from django.utils.translation import ugettext_lazy as _ +from weni.authentication.models import User + class Newsletter(models.Model): class Meta: diff --git a/weni/settings.py b/weni/settings.py index 20a67dff..b9283916 100644 --- a/weni/settings.py +++ b/weni/settings.py @@ -59,6 +59,7 @@ "drf_yasg2", "django_filters", "mozilla_django_oidc", + "weni.authentication.apps.AuthenticationConfig", "weni.common", "django_celery_results", "django_celery_beat", @@ -101,6 +102,11 @@ DATABASES = {"default": env.db(var="DEFAULT_DATABASE", default="sqlite:///db.sqlite3")} +# Auth + +AUTH_USER_MODEL = "authentication.User" + + # Password validation # https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators @@ -158,6 +164,8 @@ ) # mozilla-django-oidc +OIDC_RP_SERVER_URL = env.str("OIDC_RP_SERVER_URL") +OIDC_RP_REALM_NAME = env.str("OIDC_RP_REALM_NAME") OIDC_RP_CLIENT_ID = env.str("OIDC_RP_CLIENT_ID") OIDC_RP_CLIENT_SECRET = env.str("OIDC_RP_CLIENT_SECRET") OIDC_OP_AUTHORIZATION_ENDPOINT = env.str("OIDC_OP_AUTHORIZATION_ENDPOINT") From 15592ad68ed282f3dd7dcfd85288c61f9eda3496 Mon Sep 17 00:00:00 2001 From: Daniel Yohan Date: Mon, 28 Dec 2020 16:41:22 -0300 Subject: [PATCH 02/12] Fix TravisCI --- .travis.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.travis.yml b/.travis.yml index cac5bdd7..4e6472b2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -17,6 +17,8 @@ install: env: global: - SECRET_KEY=SK + - OIDC_RP_SERVER_URL= + - OIDC_RP_REALM_NAME= - OIDC_RP_CLIENT_ID= - OIDC_RP_CLIENT_SECRET= - OIDC_OP_AUTHORIZATION_ENDPOINT= From 15ee5cd937dade9b7234f1c14459c0a2ce4065f4 Mon Sep 17 00:00:00 2001 From: Daniel Yohan Date: Tue, 29 Dec 2020 11:16:44 -0300 Subject: [PATCH 03/12] Fix tests --- weni/api/v1/tests/utils.py | 5 +++-- weni/authentication/signals.py | 27 ++++++++++++++++----------- weni/common/tests.py | 2 +- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/weni/api/v1/tests/utils.py b/weni/api/v1/tests/utils.py index 675f557a..33a31b12 100644 --- a/weni/api/v1/tests/utils.py +++ b/weni/api/v1/tests/utils.py @@ -1,8 +1,9 @@ -from django.contrib.auth.models import User from rest_framework.authtoken.models import Token +from weni.authentication.models import User + def create_user_and_token(nickname="fake"): user = User.objects.create_user("{}@user.com".format(nickname), nickname) token, create = Token.objects.get_or_create(user=user) - return (user, token) + return user, token diff --git a/weni/authentication/signals.py b/weni/authentication/signals.py index c2e25736..ad89a9d5 100644 --- a/weni/authentication/signals.py +++ b/weni/authentication/signals.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.db import models from django.dispatch import receiver from django.utils.translation import ugettext_lazy as _ @@ -10,15 +11,19 @@ @receiver(models.signals.pre_save, sender=User) def update_user_keycloak(instance, **kwargs): - try: - keycloak_instance = KeycloakControl() + if not settings.TESTING: + try: + keycloak_instance = KeycloakControl() - user_id = keycloak_instance.get_user_id_by_email(email=instance.email) - keycloak_instance.get_instance().update_user( - user_id=user_id, - payload={"firstName": instance.first_name, "lastName": instance.last_name}, - ) - except exceptions.KeycloakGetError: - raise ValidationError( - _("System temporarily unavailable, please try again later.") - ) + user_id = keycloak_instance.get_user_id_by_email(email=instance.email) + keycloak_instance.get_instance().update_user( + user_id=user_id, + payload={ + "firstName": instance.first_name, + "lastName": instance.last_name, + }, + ) + except exceptions.KeycloakGetError: + raise ValidationError( + _("System temporarily unavailable, please try again later.") + ) diff --git a/weni/common/tests.py b/weni/common/tests.py index f39bbff8..d7ce7fa7 100644 --- a/weni/common/tests.py +++ b/weni/common/tests.py @@ -1,6 +1,6 @@ -from django.contrib.auth.models import User from django.test import TestCase +from weni.authentication.models import User from weni.common.models import Newsletter, Service, ServiceStatus From 0d68ba916809ca20d97278a4fc82c3b048030a28 Mon Sep 17 00:00:00 2001 From: Daniel Yohan Date: Tue, 29 Dec 2020 12:14:01 -0300 Subject: [PATCH 04/12] Added support s3 send avatar photo user --- Pipfile | 2 + Pipfile.lock | 42 ++++++++++++++++++- README.md | 4 ++ .../migrations/0002_user_photo.py | 18 ++++++++ .../migrations/0003_auto_20201229_1508.py | 24 +++++++++++ weni/authentication/models.py | 6 +++ weni/authentication/signals.py | 7 ++-- weni/settings.py | 13 ++++++ weni/storages.py | 15 +++++++ 9 files changed, 126 insertions(+), 5 deletions(-) create mode 100644 weni/authentication/migrations/0002_user_photo.py create mode 100644 weni/authentication/migrations/0003_auto_20201229_1508.py create mode 100644 weni/storages.py diff --git a/Pipfile b/Pipfile index 13b17f7f..1a270b0f 100644 --- a/Pipfile +++ b/Pipfile @@ -21,6 +21,8 @@ redis = "~=3.5.3" django-celery-beat = "~=2.1.0" psycopg2-binary = "~=2.7.7" python-keycloak = "~=0.24.0" +boto3 = "~=1.16.44" +django-storages = "~=1.11.1" [requires] python_version = "3.6" diff --git a/Pipfile.lock b/Pipfile.lock index 6d1d3247..63082202 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "db5f5fa35679b89bbeb8985e74207c390a2cc3ec9e61fa63157214d9fa81f3ad" + "sha256": "34e357b6f21f58c628bb2b212c5e4fa2b85342189b777c9634a4b2994ea53793" }, "pipfile-spec": 6, "requires": { @@ -31,6 +31,21 @@ ], "version": "==3.6.3.0" }, + "boto3": { + "hashes": [ + "sha256:ba8de10d3ede338d51ae47e428b97dcc1d1b507741aa98697e63e879a147f4aa", + "sha256:e3f10ed6d9ca98415fdec15c85e50a89ec38d6229bce3fafd5e7965b16c4ebc5" + ], + "index": "pypi", + "version": "==1.16.44" + }, + "botocore": { + "hashes": [ + "sha256:4ff05bc089ba78a5996f06dcfddf8ca51583e30ce779ed95e9952e90c1907420", + "sha256:7725e08c95ae96c4dbd955cb4ae44a0c06d3e41f76a7feb0a941c27a44c63113" + ], + "version": "==1.19.44" + }, "celery": { "hashes": [ "sha256:a92e1d56e650781fb747032a3997d16236d037c8199eacd5217d1a72893bca45", @@ -169,6 +184,14 @@ "index": "pypi", "version": "==2.4.0" }, + "django-storages": { + "hashes": [ + "sha256:c823dbf56c9e35b0999a13d7e05062b837bae36c518a40255d522fbe3750fbb4", + "sha256:f28765826d507a0309cfaa849bd084894bc71d81bf0d09479168d44785396f80" + ], + "index": "pypi", + "version": "==1.11.1" + }, "django-timezone-field": { "hashes": [ "sha256:068dc2c9b11c2230e126f511a515609d46f8cc49278b293e7536be07997fe892", @@ -239,6 +262,14 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==2.11.2" }, + "jmespath": { + "hashes": [ + "sha256:b85d0567b8666149a93172712e68920734333c0ce7e89b78b3e987f71e5ed4f9", + "sha256:cdf6525904cc597730141d61b36f2e4b8ecc257c420fa2f4549bac2c2d0cb72f" + ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==0.10.0" + }, "josepy": { "hashes": [ "sha256:502a36f86efe2a6d09bf7018bca9fd8f8f24d8090a966aa037dbc844459ff9c8", @@ -491,6 +522,13 @@ "markers": "python_version < '3.9' and platform_python_implementation == 'CPython'", "version": "==0.2.2" }, + "s3transfer": { + "hashes": [ + "sha256:2482b4259524933a022d59da830f51bd746db62f047d6eb213f2f8855dcb8a13", + "sha256:921a37e2aefc64145e7b73d50c71bb4f26f46e4c9f414dc648c6245ff92cf7db" + ], + "version": "==0.3.3" + }, "six": { "hashes": [ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", @@ -529,7 +567,7 @@ "sha256:19188f96923873c92ccb987120ec4acaa12f0461fa9ce5d3d0772bc965a39e08", "sha256:d8ff90d979214d7b4f8ce956e80f4028fc6860e4431f731ea4a8c08f23f99473" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", + "markers": "python_version != '3.4'", "version": "==1.26.2" }, "vine": { diff --git a/README.md b/README.md index f0152028..7604e9c6 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,10 @@ You can set environment variables in your OS, write on ```.env``` file or pass v | OIDC_OP_JWKS_ENDPOINT | ```string``` | ```None``` | URL of your OpenID Connect provider JWKS endpoint. | OIDC_RP_SIGN_ALGO | ```string``` | ```RS256``` | Sets the algorithm the IdP uses to sign ID tokens. | OIDC_DRF_AUTH_BACKEND | ```string``` | ```weni.oidc_authentication.WeniOIDCAuthenticationBackend``` | Define the authentication middleware for the django rest framework. +| AWS_ACCESS_KEY_ID | ```string``` | ```None``` | Specify Access Key ID S3. +| AWS_SECRET_ACCESS_KEY | ```string``` | ```None``` | Specify Secret Access Key ID S3. +| AWS_STORAGE_BUCKET_NAME | ```string``` | ```None``` | Specify Bucket Name S3. +| AWS_S3_REGION_NAME | ```string``` | ```None``` | Specify the Bucket S3 region. ## License diff --git a/weni/authentication/migrations/0002_user_photo.py b/weni/authentication/migrations/0002_user_photo.py new file mode 100644 index 00000000..16831eb6 --- /dev/null +++ b/weni/authentication/migrations/0002_user_photo.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.17 on 2020-12-29 14:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentication", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="photo", + field=models.FileField(null=True, upload_to="", verbose_name="photo user"), + ), + ] diff --git a/weni/authentication/migrations/0003_auto_20201229_1508.py b/weni/authentication/migrations/0003_auto_20201229_1508.py new file mode 100644 index 00000000..b97aad73 --- /dev/null +++ b/weni/authentication/migrations/0003_auto_20201229_1508.py @@ -0,0 +1,24 @@ +# Generated by Django 2.2.17 on 2020-12-29 15:08 + +from django.db import migrations, models +import weni.storages + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentication", "0002_user_photo"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="photo", + field=models.FileField( + null=True, + storage=weni.storages.AvatarUserMediaStorage(), + upload_to="", + verbose_name="photo user", + ), + ), + ] diff --git a/weni/authentication/models.py b/weni/authentication/models.py index b38a97e6..36650571 100644 --- a/weni/authentication/models.py +++ b/weni/authentication/models.py @@ -5,6 +5,8 @@ from django.db import models from django.utils.translation import ugettext_lazy as _ +from weni.storages import AvatarUserMediaStorage + class UserManager(BaseUserManager): def _create_user(self, email, username, password=None, **extra_fields): @@ -59,6 +61,10 @@ class Meta: }, ) + photo = models.FileField( + _("photo user"), storage=AvatarUserMediaStorage(), null=True + ) + is_staff = models.BooleanField(_("staff status"), default=False) is_active = models.BooleanField(_("active"), default=True) diff --git a/weni/authentication/signals.py b/weni/authentication/signals.py index ad89a9d5..a89bfe68 100644 --- a/weni/authentication/signals.py +++ b/weni/authentication/signals.py @@ -24,6 +24,7 @@ def update_user_keycloak(instance, **kwargs): }, ) except exceptions.KeycloakGetError: - raise ValidationError( - _("System temporarily unavailable, please try again later.") - ) + if not settings.DEBUG: + raise ValidationError( + _("System temporarily unavailable, please try again later.") + ) diff --git a/weni/settings.py b/weni/settings.py index b9283916..3585ae4c 100644 --- a/weni/settings.py +++ b/weni/settings.py @@ -25,6 +25,10 @@ TIME_ZONE=(str, "UTC"), STATIC_URL=(str, "/static/"), CELERY_BROKER_URL=(str, "redis://localhost:6379/0"), + AWS_ACCESS_KEY_ID=(str, None), + AWS_SECRET_ACCESS_KEY=(str, None), + AWS_STORAGE_BUCKET_NAME=(str, None), + AWS_S3_REGION_NAME=(str, None), ) # Build paths inside the project like this: os.path.join(BASE_DIR, ...) @@ -63,6 +67,7 @@ "weni.common", "django_celery_results", "django_celery_beat", + "storages", ] MIDDLEWARE = [ @@ -196,3 +201,11 @@ CELERY_ACCEPT_CONTENT = ["application/json"] CELERY_RESULT_SERIALIZER = "json" CELERY_TASK_SERIALIZER = "json" + +# AWS + +AWS_ACCESS_KEY_ID = env.str("AWS_ACCESS_KEY_ID") +AWS_SECRET_ACCESS_KEY = env.str("AWS_SECRET_ACCESS_KEY") + +AWS_STORAGE_BUCKET_NAME = env.str("AWS_STORAGE_BUCKET_NAME") +AWS_S3_REGION_NAME = env.str("AWS_S3_REGION_NAME") diff --git a/weni/storages.py b/weni/storages.py new file mode 100644 index 00000000..60e1c3e3 --- /dev/null +++ b/weni/storages.py @@ -0,0 +1,15 @@ +import uuid + +from storages.backends.s3boto3 import S3Boto3Storage + + +class AvatarUserMediaStorage(S3Boto3Storage): + location = "media/user/avatars/" + default_acl = "public-read" + file_overwrite = False + custom_domain = False + + def get_available_name(self, name, max_length=None): + ext = name.split(".")[-1] + filename = "av_%s.%s" % (uuid.uuid4(), ext) + return super().get_available_name(filename, max_length) From 46d641771d3476bd370600365f19ca75b07d788e Mon Sep 17 00:00:00 2001 From: Daniel Yohan Date: Tue, 29 Dec 2020 12:30:37 -0300 Subject: [PATCH 05/12] Updated model user --- Pipfile | 1 + Pipfile.lock | 36 +++++++++++++++++++++++++++++- weni/api/v1/account/serializers.py | 3 ++- weni/authentication/models.py | 2 +- 4 files changed, 39 insertions(+), 3 deletions(-) diff --git a/Pipfile b/Pipfile index 1a270b0f..b314008c 100644 --- a/Pipfile +++ b/Pipfile @@ -23,6 +23,7 @@ psycopg2-binary = "~=2.7.7" python-keycloak = "~=0.24.0" boto3 = "~=1.16.44" django-storages = "~=1.11.1" +pillow = "~=8.0.1" [requires] python_version = "3.6" diff --git a/Pipfile.lock b/Pipfile.lock index 63082202..41e5c476 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "34e357b6f21f58c628bb2b212c5e4fa2b85342189b777c9634a4b2994ea53793" + "sha256": "48e61f0fdeeb4f866a35d57447f9e8bdb25662dbad586506470ab0a4d793ced2" }, "pipfile-spec": 6, "requires": { @@ -341,6 +341,40 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==20.8" }, + "pillow": { + "hashes": [ + "sha256:006de60d7580d81f4a1a7e9f0173dc90a932e3905cc4d47ea909bc946302311a", + "sha256:0a2e8d03787ec7ad71dc18aec9367c946ef8ef50e1e78c71f743bc3a770f9fae", + "sha256:0eeeae397e5a79dc088d8297a4c2c6f901f8fb30db47795113a4a605d0f1e5ce", + "sha256:11c5c6e9b02c9dac08af04f093eb5a2f84857df70a7d4a6a6ad461aca803fb9e", + "sha256:2fb113757a369a6cdb189f8df3226e995acfed0a8919a72416626af1a0a71140", + "sha256:4b0ef2470c4979e345e4e0cc1bbac65fda11d0d7b789dbac035e4c6ce3f98adb", + "sha256:59e903ca800c8cfd1ebe482349ec7c35687b95e98cefae213e271c8c7fffa021", + "sha256:5abd653a23c35d980b332bc0431d39663b1709d64142e3652890df4c9b6970f6", + "sha256:5f9403af9c790cc18411ea398a6950ee2def2a830ad0cfe6dc9122e6d528b302", + "sha256:6b4a8fd632b4ebee28282a9fef4c341835a1aa8671e2770b6f89adc8e8c2703c", + "sha256:6c1aca8231625115104a06e4389fcd9ec88f0c9befbabd80dc206c35561be271", + "sha256:795e91a60f291e75de2e20e6bdd67770f793c8605b553cb6e4387ce0cb302e09", + "sha256:7ba0ba61252ab23052e642abdb17fd08fdcfdbbf3b74c969a30c58ac1ade7cd3", + "sha256:7c9401e68730d6c4245b8e361d3d13e1035cbc94db86b49dc7da8bec235d0015", + "sha256:81f812d8f5e8a09b246515fac141e9d10113229bc33ea073fec11403b016bcf3", + "sha256:895d54c0ddc78a478c80f9c438579ac15f3e27bf442c2a9aa74d41d0e4d12544", + "sha256:8de332053707c80963b589b22f8e0229f1be1f3ca862a932c1bcd48dafb18dd8", + "sha256:92c882b70a40c79de9f5294dc99390671e07fc0b0113d472cbea3fde15db1792", + "sha256:95edb1ed513e68bddc2aee3de66ceaf743590bf16c023fb9977adc4be15bd3f0", + "sha256:b63d4ff734263ae4ce6593798bcfee6dbfb00523c82753a3a03cbc05555a9cc3", + "sha256:bd7bf289e05470b1bc74889d1466d9ad4a56d201f24397557b6f65c24a6844b8", + "sha256:cc3ea6b23954da84dbee8025c616040d9aa5eaf34ea6895a0a762ee9d3e12e11", + "sha256:cc9ec588c6ef3a1325fa032ec14d97b7309db493782ea8c304666fb10c3bd9a7", + "sha256:d3d07c86d4efa1facdf32aa878bd508c0dc4f87c48125cc16b937baa4e5b5e11", + "sha256:d8a96747df78cda35980905bf26e72960cba6d355ace4780d4bdde3b217cdf1e", + "sha256:e38d58d9138ef972fceb7aeec4be02e3f01d383723965bfcef14d174c8ccd039", + "sha256:eb472586374dc66b31e36e14720747595c2b265ae962987261f044e5cce644b5", + "sha256:fbd922f702582cb0d71ef94442bfca57624352622d75e3be7a1e7e9360b07e72" + ], + "index": "pypi", + "version": "==8.0.1" + }, "psycopg2-binary": { "hashes": [ "sha256:19a2d1f3567b30f6c2bb3baea23f74f69d51f0c06c2e2082d0d9c28b0733a4c2", diff --git a/weni/api/v1/account/serializers.py b/weni/api/v1/account/serializers.py index b48b9414..b4cf71da 100644 --- a/weni/api/v1/account/serializers.py +++ b/weni/api/v1/account/serializers.py @@ -7,8 +7,9 @@ class UserSerializer(serializers.ModelSerializer): class Meta: model = User - fields = ["username", "email", "first_name", "last_name"] + fields = ["username", "email", "first_name", "last_name", "photo"] ref_name = None username = serializers.CharField(label=_("Username"), read_only=True) email = serializers.EmailField(label=_("Email"), read_only=True) + photo = serializers.ImageField(label=_("User photo"), read_only=True) diff --git a/weni/authentication/models.py b/weni/authentication/models.py index 36650571..37af1209 100644 --- a/weni/authentication/models.py +++ b/weni/authentication/models.py @@ -61,7 +61,7 @@ class Meta: }, ) - photo = models.FileField( + photo = models.ImageField( _("photo user"), storage=AvatarUserMediaStorage(), null=True ) From 0a4424f5e30f29f851396d6c9b0733b3937c5333 Mon Sep 17 00:00:00 2001 From: Daniel Yohan Date: Tue, 29 Dec 2020 13:11:09 -0300 Subject: [PATCH 06/12] Added router upload photo user avatar --- Pipfile | 1 + Pipfile.lock | 10 +++++++++- weni/api/v1/account/serializers.py | 4 ++++ weni/api/v1/account/views.py | 31 ++++++++++++++++++++++++++++-- weni/storages.py | 9 ++++++--- 5 files changed, 49 insertions(+), 6 deletions(-) diff --git a/Pipfile b/Pipfile index b314008c..2d579f22 100644 --- a/Pipfile +++ b/Pipfile @@ -24,6 +24,7 @@ python-keycloak = "~=0.24.0" boto3 = "~=1.16.44" django-storages = "~=1.11.1" pillow = "~=8.0.1" +filetype = "~=1.0.7" [requires] python_version = "3.6" diff --git a/Pipfile.lock b/Pipfile.lock index 41e5c476..19f9e5c4 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "48e61f0fdeeb4f866a35d57447f9e8bdb25662dbad586506470ab0a4d793ced2" + "sha256": "71edc588e8da3923b0c126108ac6607bef0d43fe20521b9c2145f011c4fda120" }, "pipfile-spec": 6, "requires": { @@ -223,6 +223,14 @@ "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.14.1" }, + "filetype": { + "hashes": [ + "sha256:353369948bb1c09b8b3ea3d78390b5586e9399bff9aab894a1dff954e31a66f6", + "sha256:da393ece8d98b47edf2dd5a85a2c8733e44b769e32c71af4cd96ed8d38d96aa7" + ], + "index": "pypi", + "version": "==1.0.7" + }, "idna": { "hashes": [ "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", diff --git a/weni/api/v1/account/serializers.py b/weni/api/v1/account/serializers.py index b4cf71da..06522c9b 100644 --- a/weni/api/v1/account/serializers.py +++ b/weni/api/v1/account/serializers.py @@ -13,3 +13,7 @@ class Meta: username = serializers.CharField(label=_("Username"), read_only=True) email = serializers.EmailField(label=_("Email"), read_only=True) photo = serializers.ImageField(label=_("User photo"), read_only=True) + + +class UserPhotoSerializer(serializers.Serializer): + file = serializers.FileField(required=True) diff --git a/weni/api/v1/account/views.py b/weni/api/v1/account/views.py index 29a378cc..7fd39ebf 100644 --- a/weni/api/v1/account/views.py +++ b/weni/api/v1/account/views.py @@ -1,7 +1,12 @@ -from rest_framework import mixins, permissions +import filetype +from django.utils.translation import ugettext_lazy as _ +from rest_framework import mixins, permissions, parsers +from rest_framework.decorators import action +from rest_framework.exceptions import UnsupportedMediaType +from rest_framework.response import Response from rest_framework.viewsets import GenericViewSet -from weni.api.v1.account.serializers import UserSerializer +from weni.api.v1.account.serializers import UserSerializer, UserPhotoSerializer from weni.authentication.models import User @@ -34,3 +39,25 @@ def get_object(self, *args, **kwargs): self.check_object_permissions(self.request, user) return user + + @action( + detail=True, + methods=["POST"], + url_name="profile-upload-photo", + parser_classes=[parsers.MultiPartParser], + serializer_class=UserPhotoSerializer, + ) + def upload_photo(self, request, **kwargs): + f = request.FILES.get("file") + + serializer = UserPhotoSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + if filetype.is_image(f): + self.request.user.photo = f + self.request.user.save(update_fields=["photo"]) + return Response({"photo": self.request.user.photo.url}) + raise UnsupportedMediaType( + filetype.get_type(f.content_type).extension, + detail=_("Unauthorized file type, upload an image file"), + ) diff --git a/weni/storages.py b/weni/storages.py index 60e1c3e3..e6265a69 100644 --- a/weni/storages.py +++ b/weni/storages.py @@ -8,8 +8,11 @@ class AvatarUserMediaStorage(S3Boto3Storage): default_acl = "public-read" file_overwrite = False custom_domain = False + override_available_name = True def get_available_name(self, name, max_length=None): - ext = name.split(".")[-1] - filename = "av_%s.%s" % (uuid.uuid4(), ext) - return super().get_available_name(filename, max_length) + if self.override_available_name: + ext = name.split(".")[-1] + filename = "av_%s.%s" % (uuid.uuid4(), ext) + return super().get_available_name(filename, max_length) + return super().get_available_name(name, max_length) From c812f0d6a1dcd317ec1308eaddb17d6808a11451 Mon Sep 17 00:00:00 2001 From: Daniel Yohan Date: Tue, 29 Dec 2020 13:22:04 -0300 Subject: [PATCH 07/12] Added router delete photo user --- weni/api/v1/account/views.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/weni/api/v1/account/views.py b/weni/api/v1/account/views.py index 7fd39ebf..a35fb2d3 100644 --- a/weni/api/v1/account/views.py +++ b/weni/api/v1/account/views.py @@ -61,3 +61,15 @@ def upload_photo(self, request, **kwargs): filetype.get_type(f.content_type).extension, detail=_("Unauthorized file type, upload an image file"), ) + + @action( + detail=True, + methods=["DELETE"], + url_name="profile-upload-photo", + parser_classes=[parsers.MultiPartParser], + ) + def delete_photo(self, request, **kwargs): + self.request.user.photo.storage.delete(self.request.user.photo.name) + self.request.user.photo = None + self.request.user.save(update_fields=["photo"]) + return Response({}) From 4f323236ecb82f24ec11f66043bcfa28cd614766 Mon Sep 17 00:00:00 2001 From: Daniel Yohan Date: Tue, 29 Dec 2020 13:31:13 -0300 Subject: [PATCH 08/12] Added tests account --- weni/api/v1/account/views.py | 4 ++-- weni/api/v1/keycloak.py | 2 +- weni/api/v1/tests/test_account.py | 26 ++++++++++++++++++++++++++ 3 files changed, 29 insertions(+), 3 deletions(-) create mode 100644 weni/api/v1/tests/test_account.py diff --git a/weni/api/v1/account/views.py b/weni/api/v1/account/views.py index a35fb2d3..7b6f6889 100644 --- a/weni/api/v1/account/views.py +++ b/weni/api/v1/account/views.py @@ -47,7 +47,7 @@ def get_object(self, *args, **kwargs): parser_classes=[parsers.MultiPartParser], serializer_class=UserPhotoSerializer, ) - def upload_photo(self, request, **kwargs): + def upload_photo(self, request, **kwargs): # pragma: no cover f = request.FILES.get("file") serializer = UserPhotoSerializer(data=request.data) @@ -68,7 +68,7 @@ def upload_photo(self, request, **kwargs): url_name="profile-upload-photo", parser_classes=[parsers.MultiPartParser], ) - def delete_photo(self, request, **kwargs): + def delete_photo(self, request, **kwargs): # pragma: no cover self.request.user.photo.storage.delete(self.request.user.photo.name) self.request.user.photo = None self.request.user.save(update_fields=["photo"]) diff --git a/weni/api/v1/keycloak.py b/weni/api/v1/keycloak.py index 21c0e975..bf3ad3bd 100644 --- a/weni/api/v1/keycloak.py +++ b/weni/api/v1/keycloak.py @@ -2,7 +2,7 @@ from keycloak import KeycloakAdmin -class KeycloakControl: +class KeycloakControl: # pragma: no cover def __init__(self): self.instance = self.get_instance() diff --git a/weni/api/v1/tests/test_account.py b/weni/api/v1/tests/test_account.py new file mode 100644 index 00000000..f1a81204 --- /dev/null +++ b/weni/api/v1/tests/test_account.py @@ -0,0 +1,26 @@ +import json + +from django.test import TestCase, RequestFactory +from rest_framework import status + +from weni.api.v1.account.views import MyUserProfileViewSet +from weni.api.v1.tests.utils import create_user_and_token + + +class ListMyProfileTestCase(TestCase): + def setUp(self): + self.factory = RequestFactory() + self.user, self.user_token = create_user_and_token() + + def request(self, token): + authorization_header = {"HTTP_AUTHORIZATION": "Token {}".format(token.key)} + request = self.factory.get("/v2/account/my-profile/", **authorization_header) + response = MyUserProfileViewSet.as_view({"get": "retrieve"})(request) + response.render() + content_data = json.loads(response.content) + return (response, content_data) + + def test_okay(self): + response, content_data = self.request(self.user_token) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(content_data.get("username"), self.user.username) From 7e6fa7de472fc0584f9f780f31fa6d0ebcdb6d1c Mon Sep 17 00:00:00 2001 From: Daniel Yohan Date: Tue, 29 Dec 2020 15:19:52 -0300 Subject: [PATCH 09/12] Added change password with keycloak --- weni/api/v1/account/serializers.py | 8 +++++++ weni/api/v1/account/views.py | 38 ++++++++++++++++++++++++++++-- weni/api/v1/fields.py | 7 ++++++ 3 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 weni/api/v1/fields.py diff --git a/weni/api/v1/account/serializers.py b/weni/api/v1/account/serializers.py index 06522c9b..c15a16c6 100644 --- a/weni/api/v1/account/serializers.py +++ b/weni/api/v1/account/serializers.py @@ -1,6 +1,8 @@ +from django.contrib.auth.password_validation import validate_password from django.utils.translation import ugettext_lazy as _ from rest_framework import serializers +from weni.api.v1.fields import PasswordField from weni.authentication.models import User @@ -17,3 +19,9 @@ class Meta: class UserPhotoSerializer(serializers.Serializer): file = serializers.FileField(required=True) + + +class ChangePasswordSerializer(serializers.Serializer): + password = PasswordField( + write_only=True, validators=[validate_password], label=_("Password") + ) diff --git a/weni/api/v1/account/views.py b/weni/api/v1/account/views.py index 7b6f6889..46b687e7 100644 --- a/weni/api/v1/account/views.py +++ b/weni/api/v1/account/views.py @@ -1,12 +1,19 @@ import filetype +from django.conf import settings from django.utils.translation import ugettext_lazy as _ +from keycloak import KeycloakGetError from rest_framework import mixins, permissions, parsers from rest_framework.decorators import action -from rest_framework.exceptions import UnsupportedMediaType +from rest_framework.exceptions import UnsupportedMediaType, ValidationError from rest_framework.response import Response from rest_framework.viewsets import GenericViewSet -from weni.api.v1.account.serializers import UserSerializer, UserPhotoSerializer +from weni.api.v1.account.serializers import ( + UserSerializer, + UserPhotoSerializer, + ChangePasswordSerializer, +) +from weni.api.v1.keycloak import KeycloakControl from weni.authentication.models import User @@ -73,3 +80,30 @@ def delete_photo(self, request, **kwargs): # pragma: no cover self.request.user.photo = None self.request.user.save(update_fields=["photo"]) return Response({}) + + @action( + detail=True, + methods=["POST"], + url_name="profile-change-password", + serializer_class=ChangePasswordSerializer, + ) + def change_password(self, request, **kwargs): # pragma: no cover + serializer = ChangePasswordSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + try: + keycloak_instance = KeycloakControl() + + user_id = keycloak_instance.get_user_id_by_email( + email=self.request.user.email + ) + keycloak_instance.instance.set_user_password( + user_id=user_id, password=request.data.get("password"), temporary=False + ) + except KeycloakGetError: + if not settings.DEBUG: + raise ValidationError( + _("System temporarily unavailable, please try again later.") + ) + + return Response() diff --git a/weni/api/v1/fields.py b/weni/api/v1/fields.py new file mode 100644 index 00000000..aeaaf4fc --- /dev/null +++ b/weni/api/v1/fields.py @@ -0,0 +1,7 @@ +from rest_framework import serializers + + +class PasswordField(serializers.CharField): + def __init__(self, *args, **kwargs): + kwargs.pop("trim_whitespace", None) + super().__init__(trim_whitespace=False, **kwargs) From e3a983701f7f11827c9ef8e2b4dc8c981cd44306 Mon Sep 17 00:00:00 2001 From: Daniel Yohan Date: Tue, 29 Dec 2020 15:56:21 -0300 Subject: [PATCH 10/12] Added update photo rocket chat --- weni/api/v1/account/views.py | 10 ++++++++ .../migrations/0004_auto_20201229_1851.py | 24 +++++++++++++++++++ weni/common/admin.py | 12 +++++++++- .../migrations/0002_service_rocket_chat.py | 18 ++++++++++++++ weni/common/models.py | 1 + weni/common/tasks.py | 1 - weni/utils.py | 18 ++++++++++++++ 7 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 weni/authentication/migrations/0004_auto_20201229_1851.py create mode 100644 weni/common/migrations/0002_service_rocket_chat.py create mode 100644 weni/utils.py diff --git a/weni/api/v1/account/views.py b/weni/api/v1/account/views.py index 46b687e7..63935459 100644 --- a/weni/api/v1/account/views.py +++ b/weni/api/v1/account/views.py @@ -15,6 +15,7 @@ ) from weni.api.v1.keycloak import KeycloakControl from weni.authentication.models import User +from weni.utils import upload_photo_rocket class MyUserProfileViewSet( @@ -63,6 +64,15 @@ def upload_photo(self, request, **kwargs): # pragma: no cover if filetype.is_image(f): self.request.user.photo = f self.request.user.save(update_fields=["photo"]) + + # Update avatar in all rocket chat registered + for service in self.request.user.service_status.all(): + upload_photo_rocket( + server_rocket=service.service.url, + jwt_token=self.request.auth, + avatar_url=self.request.user.photo.url, + ) + return Response({"photo": self.request.user.photo.url}) raise UnsupportedMediaType( filetype.get_type(f.content_type).extension, diff --git a/weni/authentication/migrations/0004_auto_20201229_1851.py b/weni/authentication/migrations/0004_auto_20201229_1851.py new file mode 100644 index 00000000..adc1609c --- /dev/null +++ b/weni/authentication/migrations/0004_auto_20201229_1851.py @@ -0,0 +1,24 @@ +# Generated by Django 2.2.17 on 2020-12-29 18:51 + +from django.db import migrations, models +import weni.storages + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentication", "0003_auto_20201229_1508"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="photo", + field=models.ImageField( + null=True, + storage=weni.storages.AvatarUserMediaStorage(), + upload_to="", + verbose_name="photo user", + ), + ), + ] diff --git a/weni/common/admin.py b/weni/common/admin.py index 4203a578..b2ce034a 100644 --- a/weni/common/admin.py +++ b/weni/common/admin.py @@ -1,8 +1,18 @@ from django.contrib import admin -from weni.common.models import Newsletter +from weni.common.models import Newsletter, Service, ServiceStatus @admin.register(Newsletter) class NewsletterAdmin(admin.ModelAdmin): pass + + +@admin.register(Service) +class ServiceAdmin(admin.ModelAdmin): + pass + + +@admin.register(ServiceStatus) +class ServiceStatusAdmin(admin.ModelAdmin): + pass diff --git a/weni/common/migrations/0002_service_rocket_chat.py b/weni/common/migrations/0002_service_rocket_chat.py new file mode 100644 index 00000000..53b2553b --- /dev/null +++ b/weni/common/migrations/0002_service_rocket_chat.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.17 on 2020-12-29 18:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("common", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="service", + name="rocket_chat", + field=models.BooleanField(default=False, verbose_name="rocket chat"), + ), + ] diff --git a/weni/common/models.py b/weni/common/models.py index b8deb02c..aa5b4853 100644 --- a/weni/common/models.py +++ b/weni/common/models.py @@ -21,6 +21,7 @@ class Meta: url = models.URLField(_("service url"), unique=True) status = models.BooleanField(_("status service"), default=False) + rocket_chat = models.BooleanField(_("rocket chat"), default=False) created_at = models.DateTimeField(_("created at"), auto_now_add=True) default = models.BooleanField(_("service default"), default=False) diff --git a/weni/common/tasks.py b/weni/common/tasks.py index 432a8ac7..5bda52bb 100644 --- a/weni/common/tasks.py +++ b/weni/common/tasks.py @@ -13,7 +13,6 @@ def is_page_available(url: str, method_request: requests, **kwargs) -> bool: False. """ try: - url = "https://" + url response = method_request(url=url, **kwargs) if int(response.status_code) == 200: return True diff --git a/weni/utils.py b/weni/utils.py new file mode 100644 index 00000000..b06ee838 --- /dev/null +++ b/weni/utils.py @@ -0,0 +1,18 @@ +import requests + + +def upload_photo_rocket(server_rocket: str, jwt_token: str, avatar_url: str) -> bool: + login = requests.post( + url="{}/api/v1/login/".format(server_rocket), + json={"serviceName": "keycloak", "accessToken": jwt_token, "expiresIn": 200}, + ).json() + + set_photo = requests.post( + url="https://platform-rocket-test.push.al/api/v1/users.setAvatar", + headers={ + "X-Auth-Token": login.get("data", {}).get("authToken"), + "X-User-Id": login.get("data", {}).get("userId"), + }, + json={"avatarUrl": avatar_url}, + ) + return True if set_photo.status_code == 200 else False From 21a7cc842b0608168e79f66e5923fc6061721e76 Mon Sep 17 00:00:00 2001 From: Daniel Yohan Date: Tue, 29 Dec 2020 16:03:45 -0300 Subject: [PATCH 11/12] Updated lloop get all services --- weni/api/v1/account/views.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/weni/api/v1/account/views.py b/weni/api/v1/account/views.py index 63935459..dd7a8ba5 100644 --- a/weni/api/v1/account/views.py +++ b/weni/api/v1/account/views.py @@ -66,7 +66,9 @@ def upload_photo(self, request, **kwargs): # pragma: no cover self.request.user.save(update_fields=["photo"]) # Update avatar in all rocket chat registered - for service in self.request.user.service_status.all(): + for service in self.request.user.service_status.filter( + service__rocket_chat=True + ): upload_photo_rocket( server_rocket=service.service.url, jwt_token=self.request.auth, From 34d8f8b9ec76f06acebb99beeb9c98552f25e847 Mon Sep 17 00:00:00 2001 From: Daniel Yohan Date: Tue, 29 Dec 2020 16:05:52 -0300 Subject: [PATCH 12/12] Updated method update avatar rocket chat --- weni/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/weni/utils.py b/weni/utils.py index b06ee838..a52466ef 100644 --- a/weni/utils.py +++ b/weni/utils.py @@ -8,7 +8,7 @@ def upload_photo_rocket(server_rocket: str, jwt_token: str, avatar_url: str) -> ).json() set_photo = requests.post( - url="https://platform-rocket-test.push.al/api/v1/users.setAvatar", + url="{}/api/v1/users.setAvatar".format(server_rocket), headers={ "X-Auth-Token": login.get("data", {}).get("authToken"), "X-User-Id": login.get("data", {}).get("userId"),