diff --git a/apiserver/plane/authentication/adapter/error.py b/apiserver/plane/authentication/adapter/error.py index e349e0a308..04d68792c4 100644 --- a/apiserver/plane/authentication/adapter/error.py +++ b/apiserver/plane/authentication/adapter/error.py @@ -69,11 +69,13 @@ AUTHENTICATION_ERROR_CODES = { "RATE_LIMIT_EXCEEDED": 5900, # Unknown "AUTHENTICATION_FAILED": 5999, + # user not onboarded + "USER_NOT_ONBOARDED": 6000, + "TOKEN_NOT_SET": 6005, } class AuthenticationException(Exception): - error_code = None error_message = None payload = {} diff --git a/apiserver/plane/authentication/urls.py b/apiserver/plane/authentication/urls.py index 26908491f0..c3ed657fa3 100644 --- a/apiserver/plane/authentication/urls.py +++ b/apiserver/plane/authentication/urls.py @@ -45,6 +45,11 @@ from .views import ( SignInAuthSpaceEndpoint, SignUpAuthSpaceEndpoint, SignOutAuthSpaceEndpoint, + # mobile web view authentication + MobileSignInAuthEndpoint, + MobileMagicSignInEndpoint, + MobileSessionTokenCheckEndpoint, + MobileSignOutAuthEndpoint, ) urlpatterns = [ @@ -264,4 +269,25 @@ urlpatterns = [ SAMLLogoutEndpoint.as_view(), name="saml", ), + # mobile web view authentication + path( + "mobile/sign-in/", + MobileSignInAuthEndpoint.as_view(), + name="mobile-sign-in", + ), + path( + "mobile/magic-sign-in/", + MobileMagicSignInEndpoint.as_view(), + name="mobile-magic-sign-in", + ), + path( + "mobile/token-check/", + MobileSessionTokenCheckEndpoint.as_view(), + name="mobile-token-check", + ), + path( + "mobile/sign-out/", + MobileSignOutAuthEndpoint.as_view(), + name="mobile-sign-out", + ), ] diff --git a/apiserver/plane/authentication/utils/mobile/login.py b/apiserver/plane/authentication/utils/mobile/login.py new file mode 100644 index 0000000000..eda125e9a2 --- /dev/null +++ b/apiserver/plane/authentication/utils/mobile/login.py @@ -0,0 +1,96 @@ +import json +import random +import string + +# Django imports +from django.contrib.auth import login +from django.conf import settings + +# Module imports +from plane.db.models import Profile +from plane.settings.redis import redis_instance +from plane.authentication.utils.host import base_host + + +def generate_random_string(length=32): + return "".join( + random.choices( + string.ascii_uppercase + string.ascii_lowercase + string.digits, + k=length, + ) + ) + + +class ValidateAuthToken: + ri = None + expiry = 6000 + token = None + + def __init__(self, token=None): + self.ri = redis_instance() + if token: + self.token = token + else: + self.token = generate_random_string() + + def token_exists(self): + if self.token and self.ri: + token_details = self.ri.get(self.token) + if token_details: + return True + return False + + def set_value(self, session_key): + if self.token and self.ri: + self.ri.set( + self.token, + json.dumps({"session_id": session_key}), + ex=self.expiry, + ) + else: + raise ValueError("Token or Redis instance not set") + + def get_value(self): + if self.token and self.ri: + token_details = self.ri.get(self.token) + if token_details: + return json.loads(token_details) or None + return None + + def remove_token(self): + if self.token and self.ri: + self.ri.delete(self.token) + else: + raise ValueError("Token or Redis instance not set") + + +def mobile_validate_user_onboarding(user): + profile, _ = Profile.objects.get_or_create(user=user) + + if not profile.is_onboarded: + return False + return True + + +def mobile_user_login( + request, user, is_app=False, is_admin=False, is_space=False +): + login(request=request, user=user) + + # If is admin cookie set the custom age + if is_admin: + request.session.set_expiry(settings.ADMIN_SESSION_COOKIE_AGE) + + device_info = { + "user_agent": request.META.get("HTTP_USER_AGENT", ""), + "ip_address": request.META.get("REMOTE_ADDR", ""), + "domain": base_host( + request=request, + is_app=is_app, + is_admin=is_admin, + is_space=is_space, + ), + } + request.session["device_info"] = device_info + request.session.save() + return request.session diff --git a/apiserver/plane/authentication/views/__init__.py b/apiserver/plane/authentication/views/__init__.py index 0d4537ae39..7652e8eadf 100644 --- a/apiserver/plane/authentication/views/__init__.py +++ b/apiserver/plane/authentication/views/__init__.py @@ -28,6 +28,7 @@ from .app.magic import ( MagicSignUpEndpoint, ) + from .app.oidc import ( OIDCAuthInitiateEndpoint, OIDCallbackEndpoint, @@ -44,6 +45,7 @@ from .app.saml import ( from .app.signout import SignOutAuthEndpoint + from .space.email import SignInAuthSpaceEndpoint, SignUpAuthSpaceEndpoint from .space.github import ( @@ -79,3 +81,10 @@ from .app.password_management import ( ForgotPasswordEndpoint, ResetPasswordEndpoint, ) + + +# Mobile web view authentication exports +from .app.mobile.email import MobileSignInAuthEndpoint +from .app.mobile.magic import MobileMagicSignInEndpoint +from .app.mobile.token import MobileSessionTokenCheckEndpoint +from .app.mobile.signout import MobileSignOutAuthEndpoint \ No newline at end of file diff --git a/apiserver/plane/authentication/views/app/email.py b/apiserver/plane/authentication/views/app/email.py index 08a3e8b01e..80abbe0978 100644 --- a/apiserver/plane/authentication/views/app/email.py +++ b/apiserver/plane/authentication/views/app/email.py @@ -24,7 +24,6 @@ from plane.authentication.adapter.error import ( class SignInAuthEndpoint(View): - def post(self, request): next_path = request.POST.get("next_path") # Check instance configuration @@ -139,7 +138,6 @@ class SignInAuthEndpoint(View): class SignUpAuthEndpoint(View): - def post(self, request): next_path = request.POST.get("next_path") # Check instance configuration diff --git a/apiserver/plane/authentication/views/app/mobile/email.py b/apiserver/plane/authentication/views/app/mobile/email.py new file mode 100644 index 0000000000..b4c78a342a --- /dev/null +++ b/apiserver/plane/authentication/views/app/mobile/email.py @@ -0,0 +1,163 @@ +# Python imports +from urllib.parse import urlencode, urljoin + +# Django imports +from django.core.exceptions import ValidationError +from django.core.validators import validate_email +from django.http import HttpResponseRedirect +from django.views import View + +# Module imports +from plane.authentication.provider.credentials.email import EmailProvider +from plane.authentication.utils.mobile.login import ( + ValidateAuthToken, + mobile_user_login, + mobile_validate_user_onboarding, +) +from plane.license.models import Instance +from plane.authentication.utils.host import base_host +from plane.authentication.utils.user_auth_workflow import ( + post_user_auth_workflow, +) +from plane.db.models import User +from plane.authentication.adapter.error import ( + AuthenticationException, + AUTHENTICATION_ERROR_CODES, +) +from plane.utils.exception_logger import log_exception + + +class MobileSignInAuthEndpoint(View): + def post(self, request): + # Check instance configuration + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + # Redirection params + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "INSTANCE_NOT_CONFIGURED" + ], + error_message="INSTANCE_NOT_CONFIGURED", + ) + params = exc.get_error_dict() + # Base URL join + url = urljoin( + base_host(request=request, is_app=True), + "m/auth/?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + # set the referrer as session to redirect after login + email = request.POST.get("email", False) + password = request.POST.get("password", False) + + # Raise exception if any of the above are missing + if not email or not password: + # Redirection params + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "REQUIRED_EMAIL_PASSWORD_SIGN_IN" + ], + error_message="REQUIRED_EMAIL_PASSWORD_SIGN_IN", + payload={"email": str(email)}, + ) + params = exc.get_error_dict() + url = urljoin( + base_host(request=request, is_app=True), + "m/auth/?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + # Validate email + email = email.strip().lower() + try: + validate_email(email) + except ValidationError: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL_SIGN_IN"], + error_message="INVALID_EMAIL_SIGN_IN", + payload={"email": str(email)}, + ) + params = exc.get_error_dict() + url = urljoin( + base_host(request=request, is_app=True), + "m/auth/?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + existing_user = User.objects.filter(email=email).first() + if not existing_user: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["USER_DOES_NOT_EXIST"], + error_message="USER_DOES_NOT_EXIST", + payload={"email": str(email)}, + ) + params = exc.get_error_dict() + url = urljoin( + base_host(request=request, is_app=True), + "m/auth/?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + try: + provider = EmailProvider( + request=request, + key=email, + code=password, + is_signup=False, + callback=post_user_auth_workflow, + ) + user = provider.authenticate() + + # validating the user can be redirected to the referrer path + is_onboarded = mobile_validate_user_onboarding(user=user) + if not is_onboarded: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "USER_NOT_ONBOARDED" + ], + error_message="USER_NOT_ONBOARDED", + payload={"email": str(email)}, + ) + params = exc.get_error_dict() + url = urljoin( + base_host(request=request, is_app=True), + "m/auth/?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + # Login the user and record his device info + session = mobile_user_login( + request=request, user=user, is_app=True + ) + session_key = session.session_key + + session_token = ValidateAuthToken() + session_token.set_value(session_key) + + # redirect to referrer path + url = urljoin( + base_host(request=request, is_app=True), + "m/auth/?" + urlencode({"token": session_token.token}), + ) + return HttpResponseRedirect(url) + except AuthenticationException as e: + params = e.get_error_dict() + url = urljoin( + base_host(request=request, is_app=True), + "m/auth/?" + urlencode(params), + ) + return HttpResponseRedirect(url) + except ValueError as e: + log_exception(e) + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["TOKEN_NOT_SET"], + error_message="TOKEN_NOT_SET", + payload={"email": str(email)}, + ) + params = exc.get_error_dict() + url = urljoin( + base_host(request=request, is_app=True), + "m/auth/?" + urlencode(params), + ) + return HttpResponseRedirect(url) diff --git a/apiserver/plane/authentication/views/app/mobile/magic.py b/apiserver/plane/authentication/views/app/mobile/magic.py new file mode 100644 index 0000000000..cbf866cdf5 --- /dev/null +++ b/apiserver/plane/authentication/views/app/mobile/magic.py @@ -0,0 +1,147 @@ +# Python imports +from urllib.parse import urlencode, urljoin + +# Django imports +from django.http import HttpResponseRedirect +from django.views import View + + +# Module imports +from plane.authentication.provider.credentials.magic_code import ( + MagicCodeProvider, +) +from plane.authentication.utils.mobile.login import ( + ValidateAuthToken, + mobile_user_login, + mobile_validate_user_onboarding, +) +from plane.authentication.utils.user_auth_workflow import ( + post_user_auth_workflow, +) +from plane.authentication.utils.host import base_host +from plane.db.models import User +from plane.license.models import Instance +from plane.authentication.adapter.error import ( + AuthenticationException, + AUTHENTICATION_ERROR_CODES, +) +from plane.utils.exception_logger import log_exception + + +class MobileMagicSignInEndpoint(View): + def post(self, request): + # Check instance configuration + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + # Redirection params + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "INSTANCE_NOT_CONFIGURED" + ], + error_message="INSTANCE_NOT_CONFIGURED", + ) + params = exc.get_error_dict() + # Base URL join + url = urljoin( + base_host(request=request, is_app=True), + "m/auth/?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + # set the referrer as session to redirect after login + email = request.POST.get("email", False) + code = request.POST.get("code", False) + + # Raise exception if any of the above are missing + if not email or not code: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED" + ], + error_message="MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED", + ) + params = exc.get_error_dict() + url = urljoin( + base_host(request=request, is_app=True), + "m/auth/?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + # Existing User + email = email.strip().lower() + code = code.strip() + + existing_user = User.objects.filter(email=email).first() + if not existing_user: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["USER_DOES_NOT_EXIST"], + error_message="USER_DOES_NOT_EXIST", + ) + params = exc.get_error_dict() + url = urljoin( + base_host(request=request, is_app=True), + "m/auth/?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + try: + provider = MagicCodeProvider( + request=request, + key=f"magic_{email}", + code=code, + callback=post_user_auth_workflow, + ) + user = provider.authenticate() + + is_onboarded = mobile_validate_user_onboarding(user=user) + if not is_onboarded: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "USER_NOT_ONBOARDED" + ], + error_message="USER_NOT_ONBOARDED", + payload={"email": str(email)}, + ) + params = exc.get_error_dict() + url = urljoin( + base_host(request=request, is_app=True), + "m/auth/?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + # Login the user and record his device info + session = mobile_user_login( + request=request, user=user, is_app=True + ) + session_key = session.session_key + + session_token = ValidateAuthToken() + session_token.set_value(session_key) + + # redirect to referrer path + url = urljoin( + base_host(request=request, is_app=True), + "m/auth/?" + urlencode({"token": session_token.token}), + ) + return HttpResponseRedirect(url) + + except AuthenticationException as e: + params = e.get_error_dict() + url = urljoin( + base_host(request=request, is_app=True), + "m/auth/?" + urlencode(params), + ) + return HttpResponseRedirect(url) + except ValueError as e: + log_exception(e) + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["TOKEN_NOT_SET"], + error_message="TOKEN_NOT_SET", + payload={"email": str(email)}, + ) + params = exc.get_error_dict() + url = urljoin( + base_host(request=request, is_app=True), + "m/auth/?" + urlencode(params), + ) + return HttpResponseRedirect(url) diff --git a/apiserver/plane/authentication/views/app/mobile/signout.py b/apiserver/plane/authentication/views/app/mobile/signout.py new file mode 100644 index 0000000000..28f0b50305 --- /dev/null +++ b/apiserver/plane/authentication/views/app/mobile/signout.py @@ -0,0 +1,34 @@ +# Django imports +from django.contrib.auth import logout +from django.utils import timezone + +# Third party imports +from rest_framework.response import Response +from rest_framework import status +from rest_framework.views import APIView + +# Module imports +from plane.authentication.utils.host import user_ip +from plane.db.models import User + + +class MobileSignOutAuthEndpoint(APIView): + def post(self, request): + # Get user + try: + user = User.objects.get(pk=request.user.id) + user.last_logout_ip = user_ip(request=request) + user.last_logout_time = timezone.now() + user.save() + + # Logout user + logout(request) + return Response( + {"message": "User sign out successfully"}, + status=status.HTTP_200_OK, + ) + except Exception: + return Response( + {"message": "Something went wrong"}, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/apiserver/plane/authentication/views/app/mobile/token.py b/apiserver/plane/authentication/views/app/mobile/token.py new file mode 100644 index 0000000000..c59d0b8c08 --- /dev/null +++ b/apiserver/plane/authentication/views/app/mobile/token.py @@ -0,0 +1,61 @@ +# Django imports +from django.conf import settings + +# Third party imports +from rest_framework import status +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.views import APIView + +# Module imports +from plane.authentication.utils.mobile.login import ( + ValidateAuthToken, +) + + +class MobileSessionTokenCheckEndpoint(APIView): + permission_classes = [ + AllowAny, + ] + + def post(self, request): + try: + token = request.data.get("token", False) + + # Check if token is empty + if not token or token == "": + return Response( + {"error": "Token is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Validate the token + session_token = ValidateAuthToken(token) + token_exists = session_token.token_exists() + if not token_exists: + return Response( + {"error": "Invalid token"}, + status=status.HTTP_403_FORBIDDEN, + ) + + # Get the session id + session_details = session_token.get_value() + if not session_details: + return Response( + {"error": "Invalid token"}, + status=status.HTTP_403_FORBIDDEN, + ) + + # Remove the token + session_token.remove_token() + response_session = { + "session_name": settings.SESSION_COOKIE_NAME, + "session_id": session_details.get("session_id"), + } + + return Response(response_session, status=status.HTTP_200_OK) + except Exception: + return Response( + {"error": "Something went wrong"}, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/apiserver/plane/authentication/views/app/signout.py b/apiserver/plane/authentication/views/app/signout.py index fccf557697..075a28a7df 100644 --- a/apiserver/plane/authentication/views/app/signout.py +++ b/apiserver/plane/authentication/views/app/signout.py @@ -12,7 +12,6 @@ from plane.authentication.adapter.saml import SAMLAdapter class SignOutAuthEndpoint(View): - def post(self, request): # Get user try: diff --git a/web/app/m/auth/page.tsx b/web/app/m/auth/page.tsx new file mode 100644 index 0000000000..0d0e01c511 --- /dev/null +++ b/web/app/m/auth/page.tsx @@ -0,0 +1,42 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react"; +import Image from "next/image"; +import { useTheme } from "next-themes"; +// components +import { AuthRoot } from "@/plane-web/components/mobile"; +// assets +import PlaneBackgroundPatternDark from "@/public/auth/background-pattern-dark.svg"; +import PlaneBackgroundPattern from "@/public/auth/background-pattern.svg"; +import BlackHorizontalLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.png"; +import WhiteHorizontalLogo from "@/public/plane-logos/white-horizontal-with-blue-logo.png"; + +const MobileAuth = observer(() => { + const { resolvedTheme } = useTheme(); + + const pageBackgroundPattern = resolvedTheme === "dark" ? PlaneBackgroundPatternDark : PlaneBackgroundPattern; + const brandLogo = resolvedTheme === "light" ? BlackHorizontalLogo : WhiteHorizontalLogo; + + return ( +
{AUTH_HEADER_CONTENT_OPTIONS[authStep]?.description}
+ )} +