Skip to content

Commit

Permalink
Merge pull request #5 from Ilhasoft/develop
Browse files Browse the repository at this point in the history
Develop
  • Loading branch information
dyohan9 authored Dec 31, 2020
2 parents 458f502 + f27053d commit 903cd86
Show file tree
Hide file tree
Showing 32 changed files with 814 additions and 47 deletions.
2 changes: 2 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
5 changes: 5 additions & 0 deletions Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ 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"
boto3 = "~=1.16.44"
django-storages = "~=1.11.1"
pillow = "~=8.0.1"
filetype = "~=1.0.7"

[requires]
python_version = "3.6"
223 changes: 183 additions & 40 deletions Pipfile.lock

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -26,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
Expand Down
Empty file added weni/api/v1/account/__init__.py
Empty file.
27 changes: 27 additions & 0 deletions weni/api/v1/account/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
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


class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
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)


class UserPhotoSerializer(serializers.Serializer):
file = serializers.FileField(required=True)


class ChangePasswordSerializer(serializers.Serializer):
password = PasswordField(
write_only=True, validators=[validate_password], label=_("Password")
)
121 changes: 121 additions & 0 deletions weni/api/v1/account/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
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, ValidationError
from rest_framework.response import Response
from rest_framework.viewsets import GenericViewSet

from weni.api.v1.account.serializers import (
UserSerializer,
UserPhotoSerializer,
ChangePasswordSerializer,
)
from weni.api.v1.keycloak import KeycloakControl
from weni.authentication.models import User
from weni.utils import upload_photo_rocket


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

@action(
detail=True,
methods=["POST"],
url_name="profile-upload-photo",
parser_classes=[parsers.MultiPartParser],
serializer_class=UserPhotoSerializer,
)
def upload_photo(self, request, **kwargs): # pragma: no cover
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"])

# Update avatar in all rocket chat registered
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,
avatar_url=self.request.user.photo.url,
)

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"),
)

@action(
detail=True,
methods=["DELETE"],
url_name="profile-upload-photo",
parser_classes=[parsers.MultiPartParser],
)
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"])
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()
7 changes: 7 additions & 0 deletions weni/api/v1/fields.py
Original file line number Diff line number Diff line change
@@ -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)
33 changes: 33 additions & 0 deletions weni/api/v1/keycloak.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from django.conf import settings
from keycloak import KeycloakAdmin


class KeycloakControl: # pragma: no cover
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)
2 changes: 2 additions & 0 deletions weni/api/v1/routers.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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)
26 changes: 26 additions & 0 deletions weni/api/v1/tests/test_account.py
Original file line number Diff line number Diff line change
@@ -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)
5 changes: 3 additions & 2 deletions weni/api/v1/tests/utils.py
Original file line number Diff line number Diff line change
@@ -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
Empty file added weni/authentication/__init__.py
Empty file.
8 changes: 8 additions & 0 deletions weni/authentication/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from django.contrib import admin

from weni.authentication.models import User


@admin.register(User)
class UserAdmin(admin.ModelAdmin):
pass
8 changes: 8 additions & 0 deletions weni/authentication/apps.py
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 903cd86

Please sign in to comment.