diff --git a/Backend/rabbitmq/Dockerfile b/Backend/rabbitmq/Dockerfile deleted file mode 100644 index 216a5f0..0000000 --- a/Backend/rabbitmq/Dockerfile +++ /dev/null @@ -1,28 +0,0 @@ -FROM alpine:3.20 - -# trunk-ignore(hadolint/DL3018) -RUN apk add --no-cache erlang openssl bash curl xz - -# Set environment variable for RabbitMQ installation directory -RUN addgroup -S rabbitmq && adduser -S rabbitmq -G rabbitmq -ENV RABBITMQ_HOME=/usr/local/bin/rabbitmq_server-3.13.3 -RUN mkdir -p $RABBITMQ_HOME && chown -R rabbitmq:rabbitmq /usr/local/bin/ && \ - # Download and install RabbitMQ - curl -L -o rabbitmq-server-generic-unix-3.13.3.tar.xz https://github.com/rabbitmq/rabbitmq-server/releases/download/v3.13.3/rabbitmq-server-generic-unix-3.13.3.tar.xz && \ - tar xvf rabbitmq-server-generic-unix-3.13.3.tar.xz -C /usr/local/bin/ && \ - rm rabbitmq-server-generic-unix-3.13.3.tar.xz - - # Add RabbitMQ binaries to PATH -ENV PATH=$PATH:$RABBITMQ_HOME/sbin -USER root -RUN chown -R rabbitmq:rabbitmq $RABBITMQ_HOME -USER rabbitmq -RUN rabbitmq-plugins enable rabbitmq_management -# Expose ports used by RabbitMQ -EXPOSE 5672 15672 - -# Add HEALTHCHECK instruction -HEALTHCHECK --interval=30s --timeout=2s CMD curl -sSf http://localhost:15672 > /dev/null && echo "success" || echo "failure" - -# Run RabbitMQ server -CMD ["rabbitmq-server"] diff --git a/Backend/token_service/README.md b/Backend/token_service/README.md index efd60c0..8cf6d96 100644 --- a/Backend/token_service/README.md +++ b/Backend/token_service/README.md @@ -16,11 +16,24 @@ The API runs on port 8000 and exposed to 8001. ## Tutorial to use the token_service There are three endpoints in the token_service. The endpoints are: -- `auth/token/refresh/` - This endpoint is used to refresh the access token. +- `auth/token/refresh/` - This endpoint is used to refresh the access token. to refresh the access token you need to send a request to this endpoint with the refresh token in the request body. The request will be like this: +```json +{ + "id": "user_id", + "refresh": "your refresh token" +} +``` + - `auth/token/gen-tokens/` - This endpoint is used to generate the refresh and access tokens. - `auth/token/invalidate-tokens/` - This endpoint is used by user-service logout or delete user to invalidate the refresh and access tokens. -- `auth/token/validate-token/` - This endpoint is used to validate the access token. - +- `auth/token/validate-token/` - This endpoint is used to validate the access token. If you send a request from frontend to this API your request will be like this: +```json +{ + "id": "user_id", + "access": "your access token", + "is_frontend": true +} +``` ## The UserTokens model The UserTokens model is used to store the refresh and access tokens. The UserTokens model has the following fields: diff --git a/Backend/token_service/token_service/token_app/views.py b/Backend/token_service/token_service/token_app/views.py index fd0b482..2f6b98a 100644 --- a/Backend/token_service/token_service/token_app/views.py +++ b/Backend/token_service/token_service/token_app/views.py @@ -19,6 +19,13 @@ logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) +def check_secret(request, response_message, status_code): + secret_key = request.headers.get('X-SERVICE-SECRET') + if secret_key is None or secret_key != SECRET: + response_message = {"error": "Unauthorized request"} + status_code = status.HTTP_401_UNAUTHORIZED + return response_message, status_code + class CustomTokenObtainPairView(TokenObtainPairView): """ CustomTokenObtainPairView class to handle token request. @@ -43,15 +50,11 @@ def post(self, request, *args, **kwargs) -> Response: Returns: Response: The response object containing the token details. """ - secret_key = request.headers.get('X-SERVICE-SECRET') + response_message = {} - if secret_key is None: - response_message = {"error": "Unauthorized request"} - logger.info('Invalid secret keys = %s', response_message) - status_code = status.HTTP_401_UNAUTHORIZED - else: - response_message = {} - status_code = status.HTTP_201_CREATED + status_code = status.HTTP_201_CREATED + response_message, status_code = check_secret(request, response_message, status_code) + if "error" not in response_message and status_code == status.HTTP_201_CREATED: id = request.data.get("id") username = request.data.get("username") if not username or not id: @@ -60,8 +63,8 @@ def post(self, request, *args, **kwargs) -> Response: else: try: user, create = UserTokens.objects.get_or_create(id=id, username=username) - logger.info('user= %s', user.username) - logger.info('create= %s', create) + # logger.info('user= %s', user.username) + # logger.info('create= %s', create) if create: refresh = RefreshToken.for_user(user) access_token = str(refresh.access_token) @@ -97,7 +100,8 @@ def post(self, request, *args, **kwargs) -> Response: response_message = {"error": str(err)} status_code = status.HTTP_500_INTERNAL_SERVER_ERROR - logger.info('response_message = %s', response_message) + # logger.info('response_message = %s', response_message) + # logger.info('status_code = %s', status_code) return Response(response_message, status=status_code) class CustomTokenRefreshView(TokenRefreshView): @@ -120,9 +124,19 @@ def post(self, request, *args, **kwargs) -> Response: {"error": "Refresh token is required"}, status=status.HTTP_400_BAD_REQUEST ) try: + user_id = request.data.get("id") + if not user_id: + return Response({"error": "User id is required"}, status=status.HTTP_400_BAD_REQUEST) + user_object = UserTokens.objects.filter(id=user_id) + if not user_object: + return Response({"error": "User not found"}, status=status.HTTP_404_NOT_FOUND) + token_data = user_object.token_data refresh_token = bearer.split(' ')[1] refresh = RefreshToken(refresh_token) access_token = str(refresh.access_token) + token_data["access"] = str(refresh.access_token) + user_object.token_data=token_data + user_object.save() return Response({"access": access_token}, status=status.HTTP_200_OK) except Exception as err: return Response({"error": "Could not generate access token", "details": str(err)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) @@ -152,68 +166,76 @@ def validate_token(self, access_token) -> bool: return False def validate_token_for_user(self, request, *args, **kwargs): - - try: - status_code = status.HTTP_200_OK - response_message = {} - access = request.data.get("access") - id = request.data.get("id") - if not access or not id: - response_message = {"error": "Access token and id are required"} - status_code = status.HTTP_400_BAD_REQUEST - result = self.validate_token(access) - if result: - logger.info("result= %s", result) - user = UserTokens.objects.filter(id = id, token_data__access = access).first() - - logger.info("user.username= %s", user.username) - logger.info("user.token_data['access']= %s", user.token_data["access"]) + response_message = {} + status_code = status.HTTP_200_OK + isfrontend = request.data.get("is_frontend") + if isfrontend is None: + response_message, status_code = check_secret(request, response_message, status_code) + if "error" not in response_message and status_code == status.HTTP_200_OK: + try: + status_code = status.HTTP_200_OK + response_message = {} + access = request.data.get("access") + id = request.data.get("id") + if not access or not id: + response_message = {"error": "Access token and id are required"} + status_code = status.HTTP_400_BAD_REQUEST + result = self.validate_token(access) if result: - response_message = {"access_token": "Valid token"} - else: - response_message = {"error": "token mismatch"} - status_code = status.HTTP_401_UNAUTHORIZED - except jwt.ExpiredSignatureError: - response_message = {"error": "token is expired"} - status_code = status.HTTP_401_UNAUTHORIZED - except jwt.InvalidTokenError: - response_message = {"error": "Invalid token"} - status_code = status.HTTP_401_UNAUTHORIZED - except Http404: - response_message = {"error": "User has not logged in yet!!"} - status_code = status.HTTP_401_UNAUTHORIZED - except Exception as err: - response_message = {"error": str(err)} - status_code = status.HTTP_500_INTERNAL_SERVER_ERROR - logger.info("response_message= %s", response_message) + # logger.info("result= %s", result) + user = UserTokens.objects.filter(id = id, token_data__access = access).first() + + # logger.info("user.username= %s", user.username) + # logger.info("user.token_data['access']= %s", user.token_data["access"]) + if result: + response_message = {"access_token": "Valid token"} + else: + response_message = {"error": "token mismatch"} + status_code = status.HTTP_401_UNAUTHORIZED + except jwt.ExpiredSignatureError: + response_message = {"error": "token is expired"} + status_code = status.HTTP_401_UNAUTHORIZED + except jwt.InvalidTokenError: + response_message = {"error": "Invalid token"} + status_code = status.HTTP_401_UNAUTHORIZED + except Http404: + response_message = {"error": "User has not logged in yet!!"} + status_code = status.HTTP_401_UNAUTHORIZED + except Exception as err: + response_message = {"error": str(err)} + status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + # logger.info("response_message= %s", response_message) return Response(response_message, status=status_code) class InvalidateToken(viewsets.ViewSet): def invalidate_token_for_user(self, request, *args, **kwargs) -> Response: - try: - status_code = status.HTTP_200_OK - access = request.data.get("access") - id = request.data.get("id") - if not access or not id: - response_message = {"error": "Access token and id are required"} - status_code = status.HTTP_400_BAD_REQUEST - else: - check_token = ValidateToken() - if check_token.validate_token(access): - user = get_object_or_404(UserTokens, id=id) - if user is not None: - user.delete() - response_message = {"detail":"User logged out"} - except jwt.ExpiredSignatureError: - response_message = {"error": "Access token is expired"} - status_code = status.HTTP_401_UNAUTHORIZED - except jwt.InvalidTokenError: - response_message = {"error": "Invalid access token"} - status_code = status.HTTP_401_UNAUTHORIZED - except Http404: - response_message = {"error": "User has not logged in yet"} - status_code = status.HTTP_401_UNAUTHORIZED - except Exception as err: - response_message = {"error": str(err)} - status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + response_message = {} + status_code = status.HTTP_200_OK + response_message, status_code = check_secret(request, response_message, status_code) + if "error" not in response_message and status_code == status.HTTP_200_OK: + try: + access = request.data.get("access") + id = request.data.get("id") + if not access or not id: + response_message = {"error": "Access token and id are required"} + status_code = status.HTTP_400_BAD_REQUEST + else: + check_token = ValidateToken() + if check_token.validate_token(access): + user = get_object_or_404(UserTokens, id=id) + if user is not None: + user.delete() + response_message = {"detail":"User logged out"} + except jwt.ExpiredSignatureError: + response_message = {"error": "Access token is expired"} + status_code = status.HTTP_401_UNAUTHORIZED + except jwt.InvalidTokenError: + response_message = {"error": "Invalid access token"} + status_code = status.HTTP_401_UNAUTHORIZED + except Http404: + response_message = {"error": "User has not logged in yet"} + status_code = status.HTTP_401_UNAUTHORIZED + except Exception as err: + response_message = {"error": str(err)} + status_code = status.HTTP_500_INTERNAL_SERVER_ERROR return Response(response_message, status=status_code) diff --git a/Backend/user_service/README.md b/Backend/user_service/README.md index 016a50d..e97b3b1 100644 --- a/Backend/user_service/README.md +++ b/Backend/user_service/README.md @@ -6,15 +6,13 @@ Upon GET,PUT and DELETE requests for a user record, the user service will retrie ## Docker container configuration -Every single REST API endpoint has their own database. The database is a PostgreSQL database. The database name for all the endpoints is `postgres`. The database user and password for all the endpoints is inside the env file. The database port for all the endpoints is `5432`. +Every single REST API endpoint has their own database. The database is a PostgreSQL database. The database name for this endpoints is `user_service`. The database user and password for all the endpoints is inside the env file. The database port for all the endpoints is `5432`. The requirements package is inside the requirements.txt file. -The run_consumer.sh file is used to run the consumer.py file inside the user_management/user_management folder. The consumer.py file is used to consume the message from the RabbitMQ message broker and check if the username and password are correct. -The tools.sh and run_consumer.sh files are running inside the Docker container using the supervisord service. The supervisord service is used to run multiple services inside the Docker container. -The tools.sh file is used to run the init_database.sh file and run the API. +The tools.sh file is used to run the API. The API runs inside a virtual environment. The virtual environment is created inside the Docker container using command python3.12 -m venv venv. The virtual environment is activated using command source venv/bin/activate inside the tools.sh file. -The API runs on port 8000 and exposed to 8000. +The API runs on port 8000. ## Tutorial to use the user_service @@ -30,12 +28,61 @@ You should send a JSON object with the following fields: "password": "password" } ``` +- `"user/register/sendemailotp/"` "send add email to confirmemail model and send otp to user email using POST method" +You should send a JSON object with the following fields: +```JSON +{ + "email": "email" +} +``` + +- `"user/register/verifyemailotp/"` "verify email otp using POST method" +You should send a JSON object with the following fields: +```JSON +{ + "email": "email", + "otp": "otp" +} +``` + +- `"user/register/availableuser/"` "check if the username and email are available using POST method" +You should send a JSON object with the following fields: +```JSON +{ + "username": "username", + "email": "email" +} +``` - `http://localhost:3000/user/` "List users records using GET method" -- `http://localhost:3000/user//` "without angel brackets" "retrieve, update and delete user record using GET, PUT and DELETE methods respectively" +- `http://localhost:3000/user//` "without angel brackets" "retrieve, update and delete user record using GET, PATCH and DELETE methods respectively" +You can enable otp by sending a JSON object with the following fields: +```JSON +{ + "otp_status": "True" +} +``` - `http://localhost:3000/user/login/` "login user using POST method" +You should send a JSON object with the following fields the user will receive an otp in the email if their otp_status is True: + +```JSON +{ + "username": "username", + "password": "password" +} +``` + +- `http://localhost:3000/user/login/verifyotp/` "send user otp using POST method" +You should send a JSON object with the following fields: +```JSON +{ + "username": "username", + "password": "password", + "otp": "otp" +} +``` - `http://localhost:3000/user/logout/` "logout user using POST method" -- `"http://localhost:3000/user//friends/"` "List friends of a user using GET method" +- `http://localhost:3000/user//friends/` "List friends of a user using GET method" The endpoint will return value is a JSON object with the following fields: ```JSON [ @@ -46,14 +93,14 @@ The endpoint will return value is a JSON object with the following fields: } ] ``` -- `"http://localhost:3000/user//request/"` send friend request to a user in a JSON object using POST method the JSON object should contain the following fields: +- `http://localhost:3000/user//request/` send friend request to a user in a JSON object using POST method the JSON object should contain the following fields: ```JSON { - "username": "username", + "username": "username" } ``` -- `"http://localhost:3000/user//accept//"` accept friend request PUT method -- `"http://localhost:3000/user//pending/"` "List pending friend requests of a user using GET method" +- `http://localhost:3000/user//accept//` accept friend request PUT method +- `http://localhost:3000/user//pending/` "List pending friend requests of a user using GET method" The endpoint will return value is a JSON object with the following fields: ```JSON [ @@ -66,8 +113,8 @@ The endpoint will return value is a JSON object with the following fields: } ] ``` -- `"http://localhost:3000/user//reject//"` "Accept or reject friend request using PUT method" -- `"http://localhost:3000/user//friends//remove/"` "Remove friend using DELETE method" +- `http://localhost:3000/user//reject//` "Accept or reject friend request using PUT method" +- `http://localhost:3000/user//friends//remove/` "Remove friend using DELETE method" The API will store the username, email and hashed password in the User table. @@ -76,11 +123,240 @@ The username and email are unique. The User table consists of the following fields: You can find it in user_management/user_management/users/models.py -| Field Name | Data Type | Description | -| ---------- | --------- | ---------------------------------- | -| id | Integer | Primary Key | -| username | String | User Name | -| email | String | User Email | -| password | String | User Password (Password is hashed) | +| Field Name | Data Type | Description | +| --------------- | --------- | ---------------------------------- | +| id | Integer | Primary Key | +| username | String | User Name | +| email | String | User Email | +| password | String | User Password (Password is hashed) | +| friends | ManyToMany| Friends of the user | +| avatar | Image | User Avatar | +| otp_status | Boolean | OTP Status | +| otp | Integer | OTP | +| otp_expiry_time | DateTime | OTP Expiry Time | + + +The ConfirmEmail table consists of the following fields: + +| Field Name | Data Type | Description | +| --------------- | --------- | ---------------------------------- | +| user_email | String | User Email | +| verify_status | Boolean | OTP Status | +| otp | Integer | OTP | +| otp_expiry_time | DateTime | OTP Expiry Time | + + +## WebSocket Integration Documentation + +### Overview + +This document provides an overview of how WebSocket integration has been implemented in the Django project, enabling real-time online status updates and notifications. It includes information on the setup, testing, and how the frontend can access the WebSocket services. + +### Backend Setup + +## GameRoom model +The GameRoom model is used to store the game room information. The GameRoom model consists of the following fields: + +| Field Name | Data Type | Description | +| ---------- | --------------------------- | ----------- | +| room_name | String | Room Name | +| player1 | ForeignKey from UserProfile | Player 1 | +| player2 | ForeignKey from UserProfile | Player 2 | + +#### Dependencies + +Django Channels and Redis were installed: + +```bash +pip install channels +pip install channels-redis +``` + +#### Project Configuration + +- **`settings.py`:** + + `channels` was added to `INSTALLED_APPS` and the channel layers were configured with Redis: + + ```python + INSTALLED_APPS = [ + # other apps + 'channels', + ] + + ASGI_APPLICATION = 'your_project_name.asgi.application' + + CHANNEL_LAYERS = { + "default": { + "BACKEND": "channels_redis.pubsub.RedisPubSubChannelLayer", + "CONFIG": { + "hosts": [{ + "address": "redis://redis:6379", + "ssl_cert_reqs": None, + }], + }, + }, + } + ``` + +- **`asgi.py`:** + + `asgi.py` was set up to handle WebSocket connections: + + ```python + import os + from django.core.asgi import get_asgi_application + from channels.routing import ProtocolTypeRouter, URLRouter + from channels.auth import AuthMiddlewareStack + import user_app.routing + + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'your_project_name.settings') + + application = ProtocolTypeRouter({ + 'http': get_asgi_application(), + 'websocket': AuthMiddlewareStack( + URLRouter( + user_app.routing.websocket_urlpatterns + ) + ) + }) + ``` + +- **`routing.py`:** + + WebSocket URL patterns were defined: + + ```python + from django.urls import re_path + from . import consumers + + websocket_urlpatterns = [ + re_path(r'ws/online/', consumers.OnlineStatusConsumer.as_asgi()), + re_path(r'ws/game/room/(?P\w+)/$', GameRoomConsumer.GameRoomConsumer.as_asgi()), + ] + ``` + +#### Nginx Configuration + +Nginx was set up to support WebSocket connections. The `nginx.conf` was updated: + +```nginx +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 443 ssl; + # other configurations + + location /ws/ { + proxy_pass http://websocket-service; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +#### Docker Configuration + +The Docker setup included Redis service: + +```yaml +services: + redis: + image: redis + container_name: redis + networks: + - transcendence_network + +### Testing WebSocket Connections + +#### Using Postman + +1. **Connect to WebSocket:** + + - Open Postman. + - Select "New" -> "WebSocket Request". + - Enter the WebSocket URL: `ws://127.0.0.1:3000/ws/online/`. + +2. **Send Messages:** + + - Use the JSON format to send messages: + ```json + { + "username": "example_user", + "type": "open" + } + ``` + - To close the connection: + ```json + { + "username": "example_user", + "type": "close" + } + ``` + +### Frontend Access + +To integrate WebSocket connections in the frontend, follow these steps: + +1. **Connect to WebSocket:** + + Use JavaScript to create a WebSocket connection: + + ```javascript + const socket = new WebSocket('ws://127.0.0.1:3000/ws/online/'); + + socket.onopen = function(event) { + console.log('WebSocket is connected.'); + socket.send(JSON.stringify({ + username: 'example_user', + type: 'open' + })); + }; + + socket.onmessage = function(event) { + const data = JSON.parse(event.data); + console.log('Message from server ', data); + }; + + socket.onclose = function(event) { + console.log('WebSocket is closed now.'); + }; + + socket.onerror = function(error) { + console.log('WebSocket Error: ' + error); + }; + ``` + +2. **Handling WebSocket Events:** + + Handle WebSocket events to update the frontend UI accordingly. For example: + + ```javascript + socket.onmessage = function(event) { + const data = JSON.parse(event.data); + if (data.online_status !== undefined) { + updateUserStatus(data.username, data.online_status); + } + }; + + function updateUserStatus(username, status) { + const userElement = document.getElementById(username); + if (userElement) { + userElement.className = status ? 'online' : 'offline'; + } + } + ``` + +### Summary + +The setup included configuring Django Channels, Redis, and Nginx to support WebSocket connections. The frontend can connect to the WebSocket service and handle events to provide real-time updates to users. + -Later I will limit the access to the API using nginx reverse proxy and only the frontend will be able to access the API. diff --git a/Backend/user_service/user_service/README.md b/Backend/user_service/user_service/README.md deleted file mode 100644 index efa4596..0000000 --- a/Backend/user_service/user_service/README.md +++ /dev/null @@ -1,320 +0,0 @@ -# User_service - -The user service is responsible for creating, reading, updating and deleting user records. The user service receives the username, email and password then stores them in the User table. The username and email are unique and the password is hashed and write-only. - -Upon GET,PUT and DELETE requests for a user record, the user service will retrieve the access token from the request header and send it to the profile service to check if the access token exists in the UserTokens table. If the access token exists, the user service will process the request for the user record if the user record exists in the User table. - -## Docker container configuration - -Every single REST API endpoint has their own database. The database is a PostgreSQL database. The database name for this endpoints is `user_service`. The database user and password for all the endpoints is inside the env file. The database port for all the endpoints is `5432`. - -The requirements package is inside the requirements.txt file. -The tools.sh file is used to run the API. -The API runs inside a virtual environment. The virtual environment is created inside the Docker container using command python3.12 -m venv venv. The virtual environment is activated using command source venv/bin/activate inside the tools.sh file. - -The API runs on port 8000. - -## Tutorial to use the user_service - -After running the makefile, you can access the API using the following url: - -- `http://localhost:3000/user/register/` "create user record using POST method" -You should send a JSON object with the following fields: - -```JSON -{ - "username": "username", - "email": "email", - "password": "password" -} -``` - -- `http://localhost:3000/user/` "List users records using GET method" -- `http://localhost:3000/user//` "without angel brackets" "retrieve, update and delete user record using GET, PUT and DELETE methods respectively" -You can enable otp by sending a JSON object with the following fields: -```JSON -{ - "otp_status": "True" -} -``` -- `http://localhost:3000/user/login/` "login user using POST method" -- `http://localhost:3000/user/verifyotp/` "send user otp using POST method" -You should send a JSON object with the following fields: -```JSON -{ - "username": "username", - "password": "password", - "otp": "otp" -} -``` -- `http://localhost:3000/user/logout/` "logout user using POST method" -- `http://localhost:3000/user//friends/` "List friends of a user using GET method" -The endpoint will return value is a JSON object with the following fields: -```JSON -[ - { - "id": "id", - "username": "xxx", - "status": "status" - } -] -``` -- `http://localhost:3000/user//request/` send friend request to a user in a JSON object using POST method the JSON object should contain the following fields: -```JSON -{ - "username": "username" -} -``` -- `http://localhost:3000/user//accept//` accept friend request PUT method -- `http://localhost:3000/user//pending/` "List pending friend requests of a user using GET method" -The endpoint will return value is a JSON object with the following fields: -```JSON -[ - { - "sender_id": "id", - "sender_username": "xxx", - "receiver_id": "id", - "receiver_username": "xxx", - "status": "status" - } -] -``` -- `http://localhost:3000/user//reject//` "Accept or reject friend request using PUT method" -- `http://localhost:3000/user//friends//remove/` "Remove friend using DELETE method" - - -The API will store the username, email and hashed password in the User table. -The username and email are unique. - -The User table consists of the following fields: -You can find it in user_management/user_management/users/models.py - -| Field Name | Data Type | Description | -| --------------- | --------- | ---------------------------------- | -| id | Integer | Primary Key | -| username | String | User Name | -| email | String | User Email | -| password | String | User Password (Password is hashed) | -| friends | ManyToMany| Friends of the user | -| avatar | Image | User Avatar | -| otp_status | Boolean | OTP Status | -| otp | Integer | OTP | -| otp_expiry_time | DateTime | OTP Expiry Time | - -Later I will limit the access to the API using nginx reverse proxy and only the frontend will be able to access the API. - -## WebSocket Integration Documentation - -### Overview - -This document provides an overview of how WebSocket integration has been implemented in the Django project, enabling real-time online status updates and notifications. It includes information on the setup, testing, and how the frontend can access the WebSocket services. - -### Backend Setup - -## GameRoom model -The GameRoom model is used to store the game room information. The GameRoom model consists of the following fields: - -| Field Name | Data Type | Description | -| ---------- | --------------------------- | ----------- | -| room_name | String | Room Name | -| player1 | ForeignKey from UserProfile | Player 1 | -| player2 | ForeignKey from UserProfile | Player 2 | - -#### Dependencies - -Django Channels and Redis were installed: - -```bash -pip install channels -pip install channels-redis -``` - -#### Project Configuration - -- **`settings.py`:** - - `channels` was added to `INSTALLED_APPS` and the channel layers were configured with Redis: - - ```python - INSTALLED_APPS = [ - # other apps - 'channels', - ] - - ASGI_APPLICATION = 'your_project_name.asgi.application' - - CHANNEL_LAYERS = { - "default": { - "BACKEND": "channels_redis.pubsub.RedisPubSubChannelLayer", - "CONFIG": { - "hosts": [{ - "address": "redis://redis:6379", - "ssl_cert_reqs": None, - }], - }, - }, - } - ``` - -- **`asgi.py`:** - - `asgi.py` was set up to handle WebSocket connections: - - ```python - import os - from django.core.asgi import get_asgi_application - from channels.routing import ProtocolTypeRouter, URLRouter - from channels.auth import AuthMiddlewareStack - import user_app.routing - - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'your_project_name.settings') - - application = ProtocolTypeRouter({ - 'http': get_asgi_application(), - 'websocket': AuthMiddlewareStack( - URLRouter( - user_app.routing.websocket_urlpatterns - ) - ) - }) - ``` - -- **`routing.py`:** - - WebSocket URL patterns were defined: - - ```python - from django.urls import re_path - from . import consumers - - websocket_urlpatterns = [ - re_path(r'ws/notify/', consumers.NotificationConsumer.as_asgi()), - re_path(r'ws/online/', consumers.OnlineStatusConsumer.as_asgi()), - re_path(r'ws//', consumers.PersonalChatConsumer.as_asgi()), - ] - ``` - -#### Nginx Configuration - -Nginx was set up to support WebSocket connections. The `nginx.conf` was updated: - -```nginx -map $http_upgrade $connection_upgrade { - default upgrade; - '' close; -} - -server { - listen 443 ssl; - # other configurations - - location /ws/ { - proxy_pass http://websocket-service; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } -} -``` - -#### Docker Configuration - -The Docker setup included Redis service: - -```yaml -services: - redis: - image: redis - container_name: redis - networks: - - transcendence_network - -### Testing WebSocket Connections - -#### Using Postman - -1. **Connect to WebSocket:** - - - Open Postman. - - Select "New" -> "WebSocket Request". - - Enter the WebSocket URL: `ws://127.0.0.1:3000/ws/online/`. - -2. **Send Messages:** - - - Use the JSON format to send messages: - ```json - { - "username": "example_user", - "type": "open" - } - ``` - - To close the connection: - ```json - { - "username": "example_user", - "type": "close" - } - ``` - -### Frontend Access - -To integrate WebSocket connections in the frontend, follow these steps: - -1. **Connect to WebSocket:** - - Use JavaScript to create a WebSocket connection: - - ```javascript - const socket = new WebSocket('ws://127.0.0.1:3000/ws/online/'); - - socket.onopen = function(event) { - console.log('WebSocket is connected.'); - socket.send(JSON.stringify({ - username: 'example_user', - type: 'open' - })); - }; - - socket.onmessage = function(event) { - const data = JSON.parse(event.data); - console.log('Message from server ', data); - }; - - socket.onclose = function(event) { - console.log('WebSocket is closed now.'); - }; - - socket.onerror = function(error) { - console.log('WebSocket Error: ' + error); - }; - ``` - -2. **Handling WebSocket Events:** - - Handle WebSocket events to update the frontend UI accordingly. For example: - - ```javascript - socket.onmessage = function(event) { - const data = JSON.parse(event.data); - if (data.online_status !== undefined) { - updateUserStatus(data.username, data.online_status); - } - }; - - function updateUserStatus(username, status) { - const userElement = document.getElementById(username); - if (userElement) { - userElement.className = status ? 'online' : 'offline'; - } - } - ``` - -### Summary - -The setup included configuring Django Channels, Redis, and Nginx to support WebSocket connections. The frontend can connect to the WebSocket service and handle events to provide real-time updates to users. - - diff --git a/Backend/user_service/user_service/user_app/GameRoomConsumer.py b/Backend/user_service/user_service/user_app/GameRoomConsumer.py index 55df4bf..350172f 100644 --- a/Backend/user_service/user_service/user_app/GameRoomConsumer.py +++ b/Backend/user_service/user_service/user_app/GameRoomConsumer.py @@ -139,7 +139,7 @@ def create_and_send_game_history_record(self): from .models import GameRoom from .serializers import GameRoomSerializer from django.utils.timezone import now - + request = {} response = {} gameroom_obj = GameRoom.objects.get(room_name=self.room_name) @@ -166,4 +166,4 @@ def check_room_players(self): if obj is not None: if obj.player1 is not None and obj.player2 is not None: return True - return False \ No newline at end of file + return False diff --git a/Backend/user_service/user_service/user_app/NotificationConsumer.py b/Backend/user_service/user_service/user_app/NotificationConsumer.py deleted file mode 100644 index fcca9c8..0000000 --- a/Backend/user_service/user_service/user_app/NotificationConsumer.py +++ /dev/null @@ -1,33 +0,0 @@ -import json -from channels.generic.websocket import AsyncWebsocketConsumer -import asyncio -import threading -from channels.db import database_sync_to_async -from django.views.decorators.csrf import csrf_exempt -from django.utils.decorators import method_decorator - -@method_decorator(csrf_exempt, name='dispatch') -class NotificationConsumer(AsyncWebsocketConsumer): - async def connect(self): - my_id = self.scope['user'].id - self.room_group_name = f'{my_id}' - await self.channel_layer.group_add( - self.room_group_name, - self.channel_name - ) - - await self.accept() - - async def disconnect(self, code): - self.channel_layer.group_discard( - self.room_group_name, - self.channel_name - ) - - async def send_notification(self, event): - data = json.loads(event.get('value')) - count = data['count'] - print(count) - await self.send(text_data=json.dumps({ - 'count':count - })) diff --git a/Backend/user_service/user_service/user_app/PersonalChatConsumer.py b/Backend/user_service/user_service/user_app/PersonalChatConsumer.py deleted file mode 100644 index fd476e1..0000000 --- a/Backend/user_service/user_service/user_app/PersonalChatConsumer.py +++ /dev/null @@ -1,70 +0,0 @@ -import json -from channels.generic.websocket import AsyncWebsocketConsumer -import asyncio -import threading -from channels.db import database_sync_to_async -from django.views.decorators.csrf import csrf_exempt -from django.utils.decorators import method_decorator - -@method_decorator(csrf_exempt, name='dispatch') -class PersonalChatConsumer(AsyncWebsocketConsumer): - async def connect(self): - my_id = self.scope['user'].id - other_user_id = self.scope['url_route']['kwargs']['id'] - if int(my_id) > int(other_user_id): - self.room_name = f'{my_id}-{other_user_id}' - else: - self.room_name = f'{other_user_id}-{my_id}' - - self.room_group_name = 'chat_%s' % self.room_name - - await self.channel_layer.group_add( - self.room_group_name, - self.channel_name - ) - - await self.accept() - - async def receive(self, text_data: str = "", bytes_data=None): - data = json.loads(text_data) - print(data) - message = data['message'] - username = data['username'] - receiver = data['receiver'] - - await self.save_message(username, self.room_group_name, message, receiver) - await self.channel_layer.group_send( - self.room_group_name, - { - 'type': 'chat_message', - 'message': message, - 'username': username, - } - ) - - async def chat_message(self, event): - message = event['message'] - username = event['username'] - - await self.send(text_data=json.dumps({ - 'message': message, - 'username': username - })) - - async def disconnect(self, code): - self.channel_layer.group_discard( - self.room_group_name, - self.channel_name - ) - - @database_sync_to_async - def save_message(self, username, thread_name, message, receiver): - from .models import ChatModel, ChatNotification - from django.contrib.auth.models import User - - chat_obj = ChatModel.objects.create( - sender=username, message=message, thread_name=thread_name) - other_user_id = self.scope['url_route']['kwargs']['id'] - get_user = User.objects.get(id=other_user_id) - if receiver == get_user.username: - ChatNotification.objects.create(chat=chat_obj, user=get_user) \ No newline at end of file diff --git a/Backend/user_service/user_service/user_app/admin.py b/Backend/user_service/user_service/user_app/admin.py index 909c4bf..c94c8b0 100644 --- a/Backend/user_service/user_service/user_app/admin.py +++ b/Backend/user_service/user_service/user_app/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from user_app.models import ChatModel, UserProfileModel, ChatNotification +from user_app.models import UserProfileModel # Register your models here. -admin.site.register(ChatModel) +# admin.site.register(ChatModel) admin.site.register(UserProfileModel) -admin.site.register(ChatNotification) +# admin.site.register(ChatNotification) diff --git a/Backend/user_service/user_service/user_app/models.py b/Backend/user_service/user_service/user_app/models.py index bc6324e..6285aba 100644 --- a/Backend/user_service/user_service/user_app/models.py +++ b/Backend/user_service/user_service/user_app/models.py @@ -11,6 +11,12 @@ def user_directory_path(instance, filename): filename = f'{uuid.uuid4()}.{ext}' return os.path.join(str(instance.id), filename) +class ConfirmEmail(models.Model): + user_email = models.EmailField(unique=True, primary_key=True) + verify_status = models.BooleanField(default=False) + otp = models.CharField(null=True, blank=True) + otp_expiry_time = models.DateTimeField(blank=True, null=True) + class UserProfileModel(AbstractUser): """ User class to define the user model. @@ -22,11 +28,12 @@ class UserProfileModel(AbstractUser): Email: The email field is required for the user model. """ + email = models.OneToOneField(ConfirmEmail, related_name='user_profile_email', on_delete=models.CASCADE) avatar = models.ImageField(upload_to=user_directory_path, null=True, blank=True, default='default.jpg') friends = models.ManyToManyField("self", blank=True, symmetrical=True) online_status = models.BooleanField(default=False) otp_status = models.BooleanField(default=False, blank=True, null=True) - otp = models.IntegerField(blank=True, null=True) + otp = models.CharField(blank=True, null=True) otp_expiry_time = models.DateTimeField(blank=True, null=True) REQUIRED_FIELDS = ["email"] @@ -47,23 +54,6 @@ def __str__(self): class Meta: unique_together = ('sender_user', 'receiver_user') -class ChatModel(models.Model): - sender = models.CharField(max_length=100, default=None) - message = models.TextField(null=True, blank=True) - thread_name = models.CharField(null=True, blank=True, max_length=50) - timestamp = models.DateTimeField(auto_now_add=True) - - def __str__(self) -> str: - return self.message or "" - -class ChatNotification(models.Model): - chat = models.ForeignKey(to=ChatModel, on_delete=models.CASCADE) - user = models.ForeignKey(to=UserProfileModel, on_delete=models.CASCADE) - is_seen = models.BooleanField(default=False) - - def __str__(self) -> str: - return self.user.username - class GameRoom(models.Model): room_name = models.CharField(max_length=50, unique=True) player1 = models.ForeignKey(UserProfileModel, on_delete=models.CASCADE, null=True, related_name='player1') diff --git a/Backend/user_service/user_service/user_app/routing.py b/Backend/user_service/user_service/user_app/routing.py index 94935d9..28012ce 100644 --- a/Backend/user_service/user_service/user_app/routing.py +++ b/Backend/user_service/user_service/user_app/routing.py @@ -1,8 +1,7 @@ from django.urls import re_path -from . import NotificationConsumer, GameRoomConsumer, OnlineStatusConsumer +from . import GameRoomConsumer, OnlineStatusConsumer websocket_urlpatterns = [ - re_path(r'ws/notify/', NotificationConsumer.NotificationConsumer.as_asgi()), re_path(r'ws/online/', OnlineStatusConsumer.OnlineStatusConsumer.as_asgi()), re_path(r'ws/game/room/(?P\w+)/$', GameRoomConsumer.GameRoomConsumer.as_asgi()), ] diff --git a/Backend/user_service/user_service/user_app/serializers.py b/Backend/user_service/user_service/user_app/serializers.py index 4ffecb8..24613e8 100644 --- a/Backend/user_service/user_service/user_app/serializers.py +++ b/Backend/user_service/user_service/user_app/serializers.py @@ -2,7 +2,7 @@ from django.core.exceptions import ValidationError from rest_framework import serializers from rest_framework.validators import UniqueValidator -from .models import UserProfileModel, FriendRequest, GameRoom +from .models import UserProfileModel, FriendRequest, GameRoom, ConfirmEmail from .validators import CustomPasswordValidator class GameRoomSerializer(serializers.ModelSerializer): @@ -57,9 +57,7 @@ class UserSerializer(serializers.ModelSerializer): create: Method to create a new user. update: Method to update a user. """ - email = serializers.EmailField( - validators=[UniqueValidator(queryset=UserProfileModel.objects.all())] - ) + email = serializers.PrimaryKeyRelatedField(queryset=ConfirmEmail.objects.all()) avatar = serializers.ImageField(required=False) friends = serializers.PrimaryKeyRelatedField( many=True, queryset=UserProfileModel.objects.all(), required=False # required=False means that the field is not required @@ -80,10 +78,9 @@ class Meta: "otp_expiry_time" ] extra_kwargs = {"password": {"write_only": True}} - ### Password should be strong password, minimum 8 characters, at least one uppercase letter, one lowercase letter, one number and one special character - def create(self, validate_data) -> UserProfileModel: + def create(self, validated_data) -> UserProfileModel: """ Method to create a new user. @@ -91,23 +88,23 @@ def create(self, validate_data) -> UserProfileModel: The password is validated using CustomPasswordValidator. The password is hashed before saving the user object. Args: - validate_data: The data to validate. + validated_data: The data to validate. Returns: User: The user object. """ try: - validate_password(validate_data["password"]) + validate_password(validated_data["password"]) except ValidationError as err: raise serializers.ValidationError(detail=err.messages) from err - password = validate_data.pop("password", None) - instance = self.Meta.model(**validate_data) + password = validated_data.pop("password", None) + instance = self.Meta.model(**validated_data) if password is not None: instance.set_password(password) instance.save() return instance - def update(self, instance, validate_data) -> UserProfileModel: + def update(self, instance, validated_data) -> UserProfileModel: """ Method to update a user. @@ -116,7 +113,7 @@ def update(self, instance, validate_data) -> UserProfileModel: Args: instance: The user object. - validate_data: The data to validate. + validated_data: The data to validate. Returns: User: The updated user object. @@ -125,7 +122,7 @@ def update(self, instance, validate_data) -> UserProfileModel: serializers.ValidationError: If the password is the same as the current password. """ - for attr, value in validate_data.items(): + for attr, value in validated_data.items(): if attr == "password" and value is not None: if instance.check_password(value): raise serializers.ValidationError(detail="New password must be different from the current password.") @@ -141,3 +138,8 @@ def update(self, instance, validate_data) -> UserProfileModel: setattr(instance, attr, value) instance.save() return instance + +class ConfirmEmailSerializer(serializers.ModelSerializer): + class Meta: + model = ConfirmEmail + fields = '__all__' diff --git a/Backend/user_service/user_service/user_app/signals.py b/Backend/user_service/user_service/user_app/signals.py index dc693a4..876a5cf 100755 --- a/Backend/user_service/user_service/user_app/signals.py +++ b/Backend/user_service/user_service/user_app/signals.py @@ -1,27 +1,10 @@ from django.db.models.signals import post_save from django.dispatch import receiver -from .models import UserProfileModel, ChatNotification +from .models import UserProfileModel import json from channels.layers import get_channel_layer from asgiref.sync import async_to_sync -@receiver(post_save, sender=ChatNotification) -def send_notification(sender, instance, created, **kwargs): - if created: - channel_layer = get_channel_layer() - notification_obj = ChatNotification.objects.filter(is_seen=False, user=instance.user).count() - user_id = str(instance.user.id) - data = { - 'count':notification_obj - } - - async_to_sync(channel_layer.group_send)( - user_id, { - 'type':'send_notification', - 'value':json.dumps(data) - } - ) - @receiver(post_save, sender=UserProfileModel) def send_onlineStatus(sender, instance, created, **kwargs): if not created: diff --git a/Backend/user_service/user_service/user_app/urls.py b/Backend/user_service/user_service/user_app/urls.py index faea2ff..6d47e4a 100644 --- a/Backend/user_service/user_service/user_app/urls.py +++ b/Backend/user_service/user_service/user_app/urls.py @@ -5,9 +5,12 @@ urlpatterns = [ path("user/register/",RegisterViewSet.as_view({"post": "create_user",}),name="user-register",), + path("user/register/sendemailotp/",RegisterViewSet.as_view({"post": "send_email_otp",}),name="send-email-otp",), + path("user/register/verifyemailotp/",RegisterViewSet.as_view({"post": "verify_email_otp",}),name="verify-email-otp",), + path("user/register/availableuser/",RegisterViewSet.as_view({"post": "available_username_email",}),name="available-user-obj",), path("user/",UserViewSet.as_view({"get": "users_list",}),name="users-list",), path("user/login/",UserLoginView.as_view({"post": "login",}),name="user-login",), - path("user/verifyotp/",UserLoginView.as_view({"post": "verify_otp",}),name="verify-otp",), + path("user/login/verifyotp/",UserLoginView.as_view({"post": "verify_otp",}),name="verify-otp",), path("user//",UserViewSet.as_view({"get": "retrieve_user","patch": "update_user","delete": "destroy_user",}),name="user-detail",), path("user//logout/", UserLogoutView.as_view({"post": "logout",}),name="user-logout",), path("user//friends/", FriendsViewSet.as_view({"get": "friends_list"}), name="friends-list"), diff --git a/Backend/user_service/user_service/user_app/user_session_views.py b/Backend/user_service/user_service/user_app/user_session_views.py index 989eee8..b573849 100644 --- a/Backend/user_service/user_service/user_app/user_session_views.py +++ b/Backend/user_service/user_service/user_app/user_session_views.py @@ -1,5 +1,6 @@ import json from django.contrib.auth import authenticate +from django.contrib.auth.hashers import check_password, make_password from django.shortcuts import get_object_or_404 from rest_framework import status, viewsets from rest_framework.permissions import AllowAny, IsAuthenticated @@ -7,14 +8,13 @@ from rest_framework_simplejwt.authentication import JWTAuthentication from .models import UserProfileModel as User from .serializers import UserSerializer -from .models import UserProfileModel from datetime import timedelta from django.core.mail import send_mail from django.utils.timezone import now from django.conf import settings import logging import requests -import random +import secrets import os from dotenv import load_dotenv @@ -22,109 +22,117 @@ TOEKNSERVICE = os.environ.get('TOKEN_SERVICE') headers = { - "X-SERVICE-SECRET": settings.SECRET_KEY # Replace with your actual secret key + "X-SERVICE-SECRET": settings.SECRET_KEY } logger = logging.getLogger(__name__) -def generate_password(): - return random.randint(100000,999999) +def generate_secret(): + return secrets.randbelow(900000) + 100000 + class UserLoginView(viewsets.ViewSet): permission_classes = [AllowAny] - def authenticate_user(self, request): - username = request.data.get("username") - password = request.data.get("password") + def send_email(self, email, otp): + send_mail( + 'Verification Code', + f'Your verification code is: {otp}', + settings.EMAIL_HOST_USER, + [email], + fail_silently=False, + ) - status_code = status.HTTP_200_OK - response = {} - response_message = {} + def authenticate_user(self, request, username, password): if username and password: user = authenticate(username=username, password=password) if user is not None: - return True - return False + return user + return None def login(self, request): - username = request.data.get("username") - password = request.data.get("password") - status_code = status.HTTP_200_OK response = {} response_message = {} + username = request.data.get("username") + password = request.data.get("password") if username and password: - user = authenticate(username=username, password=password) + user = self.authenticate_user(request, username, password) if user is not None: if user.is_active: serializer = UserSerializer(user) - # send post request to token-service if user.otp_status: - user.otp = generate_password() - user.otp_expiry_time = now() + timedelta(minutes=1) + otp = generate_secret() + user.otp = make_password(str(otp)) + user.otp_expiry_time = now() + timedelta(minutes=3) user.save() - send_mail( - 'Verification Code', - f'Your verification code is: {user.otp}', - settings.EMAIL_HOST_USER, - [user.email], - fail_silently=False, - ) + logger.info('user email = %s', serializer.data["email"]) + self.send_email(serializer.data["email"], otp) response_message = {"detail":"Verification password sent to your email"} status_code = status.HTTP_200_OK else: data = {"id": serializer.data["id"], "username": serializer.data["username"]} response = requests.post(f"{TOEKNSERVICE}/auth/token/gen-tokens/", data=data, headers=headers) if response.status_code == 201: + user.online_status = True + user.save() response_message = response.json() - logger.info('user_data = %s', response.json()) + # logger.info('user_data = %s', response.json()) if "error" in response_message: status_code = response_message.get("status_code") response_message = response.json() - else: - status_code = status.HTTP_200_OK else: - response_message = {"detail": "User is Inactive"} + response_message = {"error": "User is Inactive"} status_code = status.HTTP_401_UNAUTHORIZED else: - response_message = {"detail": "Invalid username or password"} + response_message = {"error": "Invalid username or password"} status_code = status.HTTP_400_BAD_REQUEST else: - response_message = {"detail": "Username or password is missing"} + response_message = {"error": "username and password fields are required"} status_code = status.HTTP_400_BAD_REQUEST return Response(response_message, status=status_code) + #TODO: use check password for verify def verify_otp(self, request): + status_code = status.HTTP_200_OK + response = {} + response_message = {} username = request.data.get("username") password = request.data.get("password") otp = request.data.get("otp") - response_message = {} - status_code = status.HTTP_200_OK - if username and password: - user = authenticate(username=username, password=password) - if user is not None: - if user.otp_status: - if user.otp == otp: - if user.otp_expiry_time > now(): - data = {"id": user.id, "username": username} - response = requests.post(f'{TOEKNSERVICE}/auth/token/gen-tokens/', data=data) - user.otp = None - user.otp_expiry_time = None - if response.status_code == 201: - response_message = response.json() - logger.info('user_data = %s', response_message) - if "error" in response_message: - status_code = response_message.get("status_code") + if username and password and otp: + user = self.authenticate_user(request, username, password) + if user is not None: + if user.otp_status: + if check_password(str(otp), user.otp): + if user.otp_expiry_time > now(): + data = {"id": user.id, "username": username} + response = requests.post(f'{TOEKNSERVICE}/auth/token/gen-tokens/', data=data, headers=headers) + if response.status_code == 201: + response_message = response.json() + user.otp = None + user.otp_expiry_time = None + user.online_status = True + user.save() + # logger.info('user_data = %s', response_message) + if "error" in response_message: + status_code = response_message.get("status_code") + else: + status_code = status.HTTP_200_OK else: - status_code = status.HTTP_200_OK + response_message = {"error":"expired code"} + status_code = status.HTTP_401_UNAUTHORIZED else: - response_message = {"error":"expired password"} + response_message = {"error":"Invalid code"} status_code = status.HTTP_401_UNAUTHORIZED else: - response_message = {"error":"Invalid password"} - status_code = status.HTTP_401_UNAUTHORIZED + response_message = {"error":"You have not enable 2FA yet!"} + status_code = status.HTTP_400_BAD_REQUEST else: - response_message = {"error":"You have not enable 2FA yet!"} + response_message = {"error": "Invalid username or password"} status_code = status.HTTP_400_BAD_REQUEST + else: + response_message = {"error": "username, password and otp fields are required"} + status_code = status.HTTP_400_BAD_REQUEST return Response(response_message, status=status_code) class UserLogoutView(viewsets.ViewSet): @@ -144,12 +152,14 @@ def logout(self, request, pk=None): status_code =status.HTTP_400_BAD_REQUEST access_token = bearer.split(' ')[1] data = {"id":pk, "access": access_token} - response_data = requests.post(f"{TOEKNSERVICE}/auth/token/invalidate-tokens/", data=data) + response_data = requests.post(f"{TOEKNSERVICE}/auth/token/invalidate-tokens/", data=data, headers=headers) if response_data.status_code == 200: response_message = response_data.json() if "error" in response_message: status_code = response_message.get("status_code") else: response_message = {"detail": "User logged out successfully"} + user.online_status = False + user.save() status_code = status.HTTP_200_OK return Response(response_message, status=status_code) diff --git a/Backend/user_service/user_service/user_app/views.py b/Backend/user_service/user_service/user_app/views.py index e6c216f..05d1ce1 100644 --- a/Backend/user_service/user_service/user_app/views.py +++ b/Backend/user_service/user_service/user_app/views.py @@ -2,23 +2,35 @@ from django.shortcuts import get_object_or_404, render from django.http import Http404 from django.core.exceptions import ValidationError as DjangoValidationError +from rest_framework.exceptions import ValidationError from rest_framework import status, viewsets from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from rest_framework_simplejwt.authentication import JWTAuthentication -from rest_framework.exceptions import ValidationError -from django.db.models import Q -from .models import UserProfileModel, FriendRequest +from django.contrib.auth.hashers import check_password, make_password +from .models import UserProfileModel, FriendRequest, ConfirmEmail from rest_framework.parsers import MultiPartParser, FormParser from rest_framework.decorators import parser_classes -from .serializers import UserSerializer, FriendSerializer +from django.utils.timezone import now, timedelta +from .serializers import UserSerializer, FriendSerializer, ConfirmEmailSerializer +from django.core.mail import send_mail +from django.conf import settings +from django.db.models import Q +from dotenv import load_dotenv +from .user_session_views import generate_secret +import logging import requests import os -from dotenv import load_dotenv load_dotenv() + TOEKNSERVICE = os.environ.get('TOKEN_SERVICE') +logger = logging.getLogger(__name__) +headers = { + "X-SERVICE-SECRET": settings.SECRET_KEY # Replace with your actual secret key +} + def extract_token(request): bearer = request.headers.get("Authorization") if not bearer or not bearer.startswith('Bearer '): @@ -32,7 +44,7 @@ def validate_token(request) -> None: access_token = extract_token(request) if access_token: data = {"id": request.user.id, "access": access_token} - response = requests.post(f"{TOEKNSERVICE}/auth/token/validate-token/", data=data) + response = requests.post(f"{TOEKNSERVICE}/auth/token/validate-token/", data=data, headers=headers) response_data = response.json() if "error" in response_data: raise ValidationError(detail=response_data, code=response_data.get("status_code")) @@ -119,14 +131,25 @@ def update_user(self, request, pk=None) -> Response: Response: The response object containing the updated user data. """ try: + response_message = {} + status_code = status.HTTP_200_OK validate_token(request) - data = get_object_or_404(UserProfileModel, id=pk) - if data != request.user and not request.user.is_superuser: + user_obj = get_object_or_404(UserProfileModel, id=pk) + if user_obj != request.user and not request.user.is_superuser: return Response(status=status.HTTP_401_UNAUTHORIZED) - serializer = UserSerializer(instance=data, data=request.data, partial=True) - serializer.is_valid(raise_exception=True) - serializer.save() - return Response(serializer.data, status=status.HTTP_202_ACCEPTED) + data = request.data + current_email = user_obj.email + if "email" in data: + response_message, status_code = self.handle_email(data, user_obj) + if not response_message: + serializer = UserSerializer(instance=user_obj, data=data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + if serializer.data["email"] != current_email.user_email: + current_email.delete() + response_message = serializer.data + status_code = status.HTTP_202_ACCEPTED + return Response(response_message, status=status_code) except ValidationError as err: item_lists = [] for item in err.detail: @@ -134,6 +157,23 @@ def update_user(self, request, pk=None) -> Response: return Response({'error': item_lists}, status=status.HTTP_400_BAD_REQUEST) except Exception as err: return Response({"error": str(err)}, status=status.HTTP_400_BAD_REQUEST) + + def handle_email(self, data, user_obj): + response_message = {} + status_code = status.HTTP_200_OK + new_email = data["email"] + new_email_obj = ConfirmEmail.objects.filter(user_email=new_email).first() + if new_email_obj and new_email_obj.verify_status: + existing_user = UserProfileModel.objects.filter(email=new_email).exclude(id=user_obj.id).first() + if existing_user: + response_message = {"error": "Email already exists"} + status_code = status.HTTP_400_BAD_REQUEST + else: + data["email"] = new_email_obj.pk + else: + response_message = {"error": "You have not confirmed your email yet!"}, + status_code=status.HTTP_401_UNAUTHORIZED + return response_message, status_code def destroy_user(self, request, pk=None) -> Response: """ @@ -155,7 +195,7 @@ def destroy_user(self, request, pk=None) -> Response: return Response(status=status.HTTP_401_UNAUTHORIZED) access_token = extract_token(request) request_data = {"id":pk, "access": access_token} - response_data = requests.post(f"{TOEKNSERVICE}/auth/token/invalidate-tokens/", data=request_data) + response_data = requests.post(f"{TOEKNSERVICE}/auth/token/invalidate-tokens/", data=request_data, headers=headers) data.delete() return Response(status=status.HTTP_204_NO_CONTENT) except Exception as err: @@ -175,6 +215,102 @@ class RegisterViewSet(viewsets.ViewSet): """ permission_classes = [AllowAny] + def send_email(self, email, otp): + send_mail( + 'Email verification code', + f'Your verification code is: {otp}', + settings.EMAIL_HOST_USER, + [email], + fail_silently=False, + ) + + def send_email_otp(self, request) -> Response: + email = request.data.get("email") + response_message = {} + status_code = status.HTTP_200_OK + if email is not None: + try: + email_obj, create = ConfirmEmail.objects.get_or_create(user_email=email) + if email_obj.verify_status == False: + otp = generate_secret() + email_obj.otp = make_password(str(otp)) + email_obj.otp_expiry_time = now() + timedelta(minutes=3) + email_obj.save() + self.send_email(email_obj.user_email, otp) + response_message = {"detail":"Email verification code sent to your email"} + else: + response_message = {"error": "This email already verified."} + status_code = status.HTTP_401_UNAUTHORIZED + except Exception as err: + response_message = {"error": str(err)} + status_code = status.HTTP_400_BAD_REQUEST + else: + response_message = {"error": "email field required"} + status_code = status.HTTP_404_NOT_FOUND + return Response(response_message, status=status_code) + + def verify_email_otp(self, request) -> Response: + response_message = {} + status_code = status.HTTP_200_OK + email = request.data.get('email') + if email is not None: + email_obj = get_object_or_404(ConfirmEmail, user_email = email) + if email_obj.verify_status == False: + otp = request.data.get('otp') + if otp is not None: + if check_password(str(otp), email_obj.otp): + if email_obj.otp_expiry_time > now(): + response_message = {"detail":"Email verified"} + email_obj.otp = None + email_obj.otp_expiry_time = None + email_obj.verify_status = True + email_obj.save() + else: + response_message = {"error":"otp expired"} + status_code = status.HTTP_401_UNAUTHORIZED + else: + response_message = {'error':"Invalid otp"} + status_code = status.HTTP_401_UNAUTHORIZED + else: + response_message = {'error':'otp field required'} + status_code = status.HTTP_400_BAD_REQUEST + else: + response_message = {'error':'This email is already verified.'} + status_code = status.HTTP_400_BAD_REQUEST + else: + response_message = {'error': 'email field required'} + status_code = status.HTTP_400_BAD_REQUEST + return Response(response_message, status=status_code) + + def available_username_email(self, request) -> Response: + email = request.data.get("email") + username = request.data.get("username") + response_message = {} + status_code = status.HTTP_200_OK + if username is not None or email is not None: + user_obj = UserProfileModel.objects.filter(username = username).first() + email_obj = UserProfileModel.objects.filter(email = email).first() + if user_obj is not None and email_obj is not None: + response_message = {"error": "username and email are not available"} + status_code = status.HTTP_400_BAD_REQUEST + elif user_obj is not None: + response_message = {"error": "username is not available"} + status_code = status.HTTP_400_BAD_REQUEST + elif email_obj is not None: + response_message = {"error": "email is not available"} + status_code = status.HTTP_400_BAD_REQUEST + else: + if email is not None and username is not None: + response_message = {"detail": "username and email are available"} + elif username is not None: + response_message = {"detail": "username is available"} + elif email is not None: + response_message = {"detail": "email is available"} + else: + response_message = {'error':"username and email fields are required"} + status_code = status.HTTP_400_BAD_REQUEST + return Response(response_message, status=status_code) + def create_user(self, request) -> Response: """ Method to create a new user. @@ -186,21 +322,43 @@ def create_user(self, request) -> Response: Returns: Response: The response object containing the user data. """ + response_message = {} + status_code = status.HTTP_201_CREATED try: - serializer = UserSerializer(data=request.data) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_201_CREATED) - if serializer.errors: - data = serializer.errors - if "email" in data: - data["email"] = ["A user with that email already exists."] - return Response({"error":data}, status=status.HTTP_400_BAD_REQUEST) + email = request.data.get("email") + email_obj = get_object_or_404(ConfirmEmail, user_email = email) + result = UserProfileModel.objects.filter(email = email_obj.pk).first() + if email_obj is not None: + if email_obj.verify_status == True: + if result is not None: + response_message = {'error': {"email":"A user with that email already exists."}} + status_code=status.HTTP_400_BAD_REQUEST + else: + data = request.data + data["email"] = email_obj.pk + serializer = UserSerializer(data=data) + if serializer.is_valid(): + serializer.save() + response_message = serializer.data + if serializer.errors: + response_message = {"error":serializer.errors}, + status_code=status.HTTP_400_BAD_REQUEST + else: + response_message = {"error": "You have not confirmed your email yet!"}, + status_code=status.HTTP_401_UNAUTHORIZED + except Http404: + response_message = {"error": "You have not verified your email yet!"} + status_code = status.HTTP_401_UNAUTHORIZED except ValidationError as err: item_lists = [] for item in err.detail: item_lists.append(item) - return Response({'error': item_lists}, status=status.HTTP_400_BAD_REQUEST) + response_message = {'error': item_lists} + status_code=status.HTTP_400_BAD_REQUEST + except Exception as err: + response_message = {'error':str(err)} + status_code = status.HTTP_400_BAD_REQUEST + return Response(response_message, status = status_code) class FriendsViewSet(viewsets.ViewSet): authentication_classes = [JWTAuthentication] diff --git a/Backend/user_service/user_service/user_service/test/test_user_service.py b/Backend/user_service/user_service/user_service/test/test_user_service.py index 251c3be..799ab16 100644 --- a/Backend/user_service/user_service/user_service/test/test_user_service.py +++ b/Backend/user_service/user_service/user_service/test/test_user_service.py @@ -6,9 +6,15 @@ from user_app.views import UserViewSet, RegisterViewSet, FriendsViewSet, validate_token from user_app.user_session_views import UserLoginView, UserLogoutView from rest_framework import status -from user_app.models import UserProfileModel +from user_app.models import UserProfileModel, ConfirmEmail +from user_app.serializers import UserSerializer import os from dotenv import load_dotenv +from django.conf import settings + +headers = { + "X-SERVICE-SECRET": settings.SECRET_KEY +} load_dotenv() TOEKNSERVICE = os.environ.get('TOKEN_SERVICE') @@ -27,13 +33,29 @@ def user_data(): 'password': 'Test@123' } +def admin_data(): + return { + 'id': 1, + 'username': 'adminuser', + 'email': 'adminuser@123.com', + 'password': 'Admin@123' + } + @pytest.fixture def user(db): - return UserProfileModel.objects.create_user(username='testuser', email='testuser@123.com', password='Test@123') + email_obj = ConfirmEmail.objects.create(user_email = 'testuser@123.com', verify_status = True) + user_serializer = UserSerializer(data = {"username":'testuser', "email":email_obj.pk, "password":'Test@123'}) + user_serializer.is_valid(raise_exception=True) + user_serializer.save() + return UserProfileModel.objects.get(username='testuser') @pytest.fixture def admin_user(db): - return UserProfileModel.objects.create_superuser(username='admin', email='admin@123.com', password='Admin@123') + admin_email = ConfirmEmail.objects.create(user_email = 'admintest@123.com', verify_status = True) + user_obj = UserSerializer(data = {"username":'adminuser', "email":admin_email.pk, "password":'Admin@123'}) + user_obj.is_valid(raise_exception=True) + user_obj.save() + return UserProfileModel.objects.get(username='adminuser') @pytest.fixture def user_token(user): @@ -52,7 +74,9 @@ def mock_validate_token(): @pytest.mark.django_db def test_user_register(api_client, user_data): + email_obj = ConfirmEmail.objects.create(user_email = 'testuser@123.com', verify_status = True) url = reverse('user-register') + user_data["email"] = email_obj.pk response = api_client.post(url, user_data, format='json') assert response.status_code == 201 assert response.data['id'] == 1 @@ -60,12 +84,18 @@ def test_user_register(api_client, user_data): assert response.data['email'] == 'testuser@123.com' @pytest.mark.django_db -def test_users_list(api_client, admin_user, admin_token, user_data): +def test_users_list(api_client, admin_user, admin_token): # Mock the RabbitMQ interactions - user1 = UserProfileModel.objects.create_user(username='testuser1',email='testuser1@123.com',password='Test@123') - user2 = UserProfileModel.objects.create_user(username='testuser2',email='testuser2@123.com',password='Test@123') + email1_obj = ConfirmEmail.objects.create(user_email = 'testuser1@123.com', verify_status = True) + user1 = UserSerializer(data = {'username':'testuser1','email' : email1_obj.pk,'password':'Test@123'}) + user1.is_valid(raise_exception=True) + user1.save() + email2_obj = ConfirmEmail.objects.create(user_email = 'testuser2@123.com', verify_status = True) + user2 = UserSerializer(data = {'username':'testuser2','email' : email2_obj.pk,'password':'Test@123'}) + user2.is_valid(raise_exception=True) + user2.save() - # Authenticate the request + # Authenticate the request token = admin_token api_client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}') @@ -80,7 +110,7 @@ def test_users_list(api_client, admin_user, admin_token, user_data): @pytest.mark.django_db def test_user_login(api_client, admin_user): data = { - "username":"admin", + "username":"adminuser", "password":"Admin@123" } url = reverse("user-login") @@ -102,7 +132,7 @@ def test_user_logout(mock_post, api_client, admin_user, admin_token): assert response.data["detail"] == 'User logged out successfully' mock_post.assert_called_once_with( f"{TOEKNSERVICE}/auth/token/invalidate-tokens/", - data={"access": admin_token, 'id':admin_user.id} + data={"access": admin_token, 'id':admin_user.id}, headers=headers ) assert UserProfileModel.objects.filter(username=admin_user.username).exists() @@ -117,13 +147,14 @@ def test_retrieve_user(api_client, user, user_token): assert response.status_code == status.HTTP_200_OK assert response.data['id'] == user.id assert response.data['username'] == user.username - assert response.data['email'] == user.email + assert response.data['email'] == user.email.user_email @pytest.mark.django_db def test_update_user(api_client, user, user_token): + email_obj = ConfirmEmail.objects.create(user_email = "newuser@123.com", verify_status=True) data = { "username": "newuser", - "email": "newuser@123.com" + "email": email_obj.pk } api_client.credentials(HTTP_AUTHORIZATION=f'Bearer {user_token}') @@ -150,7 +181,7 @@ def test_valid_data_friend_request_functions(api_client, admin_user, user, user_ print("\ntestuser sends a friend request to admin user") api_client.credentials(HTTP_AUTHORIZATION=f'Bearer {user_token}') url_request = reverse('send-request', kwargs={'user_pk': user.id}) - response_request = api_client.post(url_request, data={'username': 'admin'}, format='json') + response_request = api_client.post(url_request, data={'username': 'adminuser'}, format='json') assert response_request.status_code == status.HTTP_201_CREATED assert response_request.data["detail"]=='Friend request sent' @@ -159,7 +190,7 @@ def test_valid_data_friend_request_functions(api_client, admin_user, user, user_ url_request = reverse('friend-request-list', kwargs={'user_pk': admin_user.id}) response_request = api_client.get(url_request, format='json') assert response_request.data[0]["sender_username"] == 'testuser' - assert response_request.data[0]["receiver_username"] == 'admin' + assert response_request.data[0]["receiver_username"] == 'adminuser' assert response_request.data[0]["status"] == 'pending' assert response_request.status_code == status.HTTP_200_OK @@ -178,7 +209,7 @@ def test_valid_data_friend_request_functions(api_client, admin_user, user, user_ print("\ntest user has admin in its friends list") url_request = reverse('friends-list', kwargs={'user_pk': user.id}) response_request = api_client.get(url_request, format='json') - assert response_request.data[0]["username"] == "admin" + assert response_request.data[0]["username"] == "adminuser" assert response_request.status_code == 200 print("\ntestuser delete the admin user from its friends list") @@ -201,7 +232,7 @@ def test_reject_friend_request(api_client, admin_user, user, user_token, admin_t print("\ntestuser sends a friend request to admin user") api_client.credentials(HTTP_AUTHORIZATION=f'Bearer {user_token}') url_request = reverse('send-request', kwargs={'user_pk': user.id}) - response_request = api_client.post(url_request, {'username': 'admin'}, format='json') + response_request = api_client.post(url_request, {'username': 'adminuser'}, format='json') assert response_request.status_code == status.HTTP_201_CREATED assert response_request.data["detail"]=='Friend request sent' @@ -210,7 +241,7 @@ def test_reject_friend_request(api_client, admin_user, user, user_token, admin_t url_request = reverse('friend-request-list', kwargs={'user_pk': admin_user.id}) response_request = api_client.get(url_request, format='json') assert response_request.data[0]["sender_username"] == 'testuser' - assert response_request.data[0]["receiver_username"] == 'admin' + assert response_request.data[0]["receiver_username"] == 'adminuser' assert response_request.data[0]["status"] == 'pending' assert response_request.status_code == status.HTTP_200_OK diff --git a/Makefile b/Makefile index f296478..e57e503 100644 --- a/Makefile +++ b/Makefile @@ -108,7 +108,7 @@ dlog: fi docker exec -it $(filter-out $@,$(MAKECMDGOALS)) bash -c 'cat /var/log/django_debug.log' -.PHONY: dlog-err +.PHONY: dlogerr dlog-err: @if [ -z "$(filter-out $@,$(MAKECMDGOALS))" ]; then \ echo "Error: No container name provided."; \ diff --git a/docker-compose.yml b/docker-compose.yml index c51c427..efe02d3 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,20 +24,6 @@ services: networks: - transcendence_network - rabbitmq: - container_name: rabbitmq - image: rabbitmq - env_file: - - .env - build: - context: . - dockerfile: Backend/rabbitmq/Dockerfile - ports: - - 5672:5672 - - 15672:15672 - networks: - - transcendence_network - user-service: container_name: user-service image: user-service @@ -54,7 +40,6 @@ services: - www_data:/app/www/avatars # - database:/var/lib/postgresql/ depends_on: - - rabbitmq - postgresql token-service: @@ -70,7 +55,6 @@ services: ports: - 8000:8000 depends_on: - - rabbitmq - postgresql game-history: @@ -84,7 +68,6 @@ services: networks: - transcendence_network depends_on: - - rabbitmq - postgresql game-server: @@ -100,7 +83,6 @@ services: networks: - transcendence_network depends_on: - - rabbitmq - postgresql postgresql: