feat: session auth implementation (#4411)

* feat: session authentication and god-mode implementation (#4302)

* dev: move authentication to base class for credentials

* chore: new account creation

* dev: return error as query parameter

* dev: accounts and profile endpoints for user

* fix: user store updates

* fix: store fixes

* fix: type fixes

* dev: set is_password_autoset and is_email_verifier for auth providers

* dev: move all auth configuration to different apps

* dev: fix circular imports

* dev: remove unused imports

* dev: fix imports for authentication

* dev: update endpoints to use rest framework api viewa

* fix: onboarding fixes

* dev: session model changes

* fix: session model and add check for last name first name and avatar

* dev: fix referer redirect

* dev: remove auth imports

* dev: fix imports

* dev: update migrations

* fix: instance admin login

* comflict: conflicts resolved

* dev: fix import errors and email check endpoint

* fix: error messages and redirects after login

* dev: configs api

* fix: is github enabled boolean

* dev: merge config and instance api

* conflict: merge conflict resolved

* dev: instance admin sign up endpoint

* dev: enable magic link login

* dev: configure instance variables for github and google enabled

* chore: typo fixes

* fix: god mode docker file changes

* build-error: resolved build errors

* fix: docker compose changes

* dev: add email credential check endpoint

* fix: minor package changes

* fix: docker related changes

* dev: add nginx rules in the nginx template

* dev: refactor the url patterns

* fix: docker changes

* fix: docker files for god-mode

* fix: static export

* fix: nginx conf

* dev: smtp sender refused exception

* fix: godmode fixes

* chore: god mode revamp.

* dev: add csrf secured flag

* fix: oauth redirect uri and session settings

* chore: god mode app changes.  (#3982)

* chore: send test email functionality.

* style: authentication methods page UI revamp.

* chore: create workspace popup.

* fix: user me endpoint

* dev: fix redirection after authentication

* dev: handle god mode redirection

* fix: redirections

* fix: auth related hooks

* fix: store related fixes

* dev: fix session authentication for rest apis

* fix: linting errors

* fix: removing references of useStore=

* dev: fix redirection and password validation

* dev: add useUser hook

* fix: build fixes and lint issues

* fix: removing useApplication hook

* fix: build errors

* fix: delete unused files

* fix: auth build fixes

* fix: bugfixes

* dev: alter avatar to support more than 255 chars

* dev: fix profile endpoint and increase session expiry time and update session on every request

* chore: resolved the migration

* chore: resolved merge conflicts

* dev: error codes and error messages for the auth flow

* dev: instance admin sign up and sign in endpoint

* dev: use zxcvbn to validate password strength

* dev: add extra parameters when error handling on instance god mode

* chore: auth init

* chore: signin/ signup form ui updates and password strength meter.

* chore: update password fields.

* chore: validations and error handling.

* chore: updated sign-up form

* chore: updated workflow and updated the code structure

* chore: instance empty state for god-mode.

* chore: instance and auth wrappers update

* fix: renaming godmode

* fix: docker changes

* chore: updated authentication wrappers

* chore: updated the authentication workflow and rendered all pages

* fix: build errors

* fix: docker related fixes

* fix: tailing slash added to space and admin for valid nginx locations

* chore: seperate pages for signup and login

* git-action modified for admin file changes

* feature build action updated for admin app

* self host modified

* chore: resolved build errors and handled signin and signup in a seperate route

* chore: sign-in and sign-up revamp.

* fix: migration conflicts

* dev: migrations

* chore: handled redirection

* dev: admin url

* dev: create seperate endpoint for instance admin me

* dev: instance admin endpoint

* git action fixed

* chore: handled auth wrappers

* dev: add serializer and remove print logs

* fix: build errors

* dev: fix migrations

* dev: instance folder structuring

* fix: linting errors

* chore: resolved build errors

* chore: updated store and auth workflow and updates api service types

* chore: Replaced Next Link with Anchoer tag for god-mode redirection

* add 3333 port to allowed origins

* make password login working again

* dev: fix redirection, add admin signout endpoint and fix email credential check endpoint

* fix unique code sign in

* fix small build error

* enable sign out

* dev: add google client secret variable to configure instance

* dev: add referer for redirection

* fix origin urls for oauths

* admin setup and login separation

* dev: fix user redirection and tour completed endpoint

* fix build errors

* dev: add set password endpoint

* dev: remove user creation logic for redirection

* fix unique code page

* fix forgot password

* chore: onboarding revamp.

* dev: fix workspace slug redirection in login

* chore: invited user onboarding flow update.

* chore: fix switch or delete account modal.

* fix members exception

* refactor auth flows and add invitations to auth flow

* fix sig in sign up url

* fix action url

* fix build errors

* dev: fix user set password when logging in

* dev: reset password endpoint

* chore: confirm password validation for signup and onboarding.

* enable reset password

* fix build error

* chore: minor UI updates.

* chore: forgot and reset password UI revamp.

* fix authentication re directions

* dev: auth redirections

* change url paths for signup and signin

* dev: make the user logged in when changing passwords

* dev: next path redirection for web and space app

* dev: next path for magic sign in endpoint

* dev: github space endpoint

* chore: minor ui updates and fixes in web app.

* set password screen

* fix multiple unique code generation

* dev: next path base redirection

* dev: remove print logs

* dev: auth space endpoints

* fix build errors

* dev: invalidate cache on configuration update, god mode exception errors and authentication failed code

* dev: fix space endpoints and add extra endpoints

* chore: space auth revamp.

* dev: add sign up for space app

* fix: build errors.

* fix: auth redirection logic.

* chore: space app onboarding revamp.

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: gurusainath <gurusainath007@gmail.com>
Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>
Co-authored-by: Manish Gupta <59428681+mguptahub@users.noreply.github.com>
Co-authored-by: Manish Gupta <manish@mgupta.me>
Co-authored-by: = <=>
Co-authored-by: rahulramesha <rahulramesham@gmail.com>

* chore: updated file structure for admin

* chore: updated admin-sidebar

* chore: auth error handling

* chore: onboarding UI updates and dark mode fixes.

* chore: add `user personalization` step to onboarding profile setup screen.

* chore: fix minor UI bugs

* chore: authentication workflow changes

* chore: handled signin workflow

* style: switch or delete account workflow

* chore: god mode redirection URL

* feat(dashboard): improve label readability (#4321)

change none label for all time in dashbard filters

* chore: god-mode redirection

* chore: onboarding ui updates and accept invitation workflow updates.

* chore: rename unique code auth form.

* style: space auth ux copy.

* chore: updated intance and auth wrapper logic

* chore: update default layout style.

* chore: update confirm password.

* chore: backend redirection

* style: update banner ui

* chore: minor ui updates and validation fix.

* chore: removed old auth hook

* chore: handled auth wrapper

* chore: handled store loaders in the user

* chore: handled logs

* chore: add loading spinners for all auth and onboarding form buttons.

* chore: add background pattern in admin auth forms and minor ui fixes.

* chore: UI changes and revamp components for authentication

* chore: auth UI consistency in web, space and admin.

* chore: resolved build errors

* chore: removed old auth hooks

* chore: handled lint errors in use accounts

* chore: updated authentication wrapper logic in web app

* [WEB -1149] dev: update dependencies (#4333)

* dev: upgrade dependencies remove unwanted dependency and add ruff as local dependency

* dev: add comments

* chore: authentication wrapper fetch user

* chore: updated store loader

* chore: removed old auth wrapper and replaced the imports with new auth wrapper

* chore: join workspace invitation workflow updates

* chore: build error resolved in deploy

* chore: handled onboarding step error in web app

* chore: SMTP Name and Password validation removed

* chore: handled seo and signout logic and new user popup

* chore: added redirection to plane in the sidebar

* chore: resolved build errors

* dev: admin session cookie update

* chore: updated cookie session time for admin

* dev: add start date and end date to projects (#4355)

* chore: add email security dropdown and remove SMTP username and password validation.

* chore: add tooltip to admin sidebar help-section.

* chore: add dropdown to collapsed admin sidebar.

* chore: profile themning

* chore: updated page error messages and theme in command palette

* dev: add email validation in email check apis

* dev: remove start date and end date from project

* chore: updated space folder structure and updated the store hooks

* dev: error codes for authentication

* chore: handled authentication in space and web apps

* chore: banner redirect handling the email

* dev: god mode error codes

* chore: updated error codes

* chore: updated onboarding images

* dev: signout endpoints and saving login domain while creating sessions

* feat: Self Host Data Backup (#4383)

* feat: implemented backup , support for docker-compose tool, readme updated

* minor fix in shell script

* codacy fixes

* chore: handled build errors in web

* chore: updated react, react-dom, and next versions

* chore: updated password autioset in the signin

* dev: add logo prop to views and pages

* chore: updated api service and handled the set password in store

* chore: handled build errors and code cleanup

* dev: return 401 when the session is not valid

* dev: users/me exception for api

* chore: installed lodash in space app

* dev: add auth route in nginx

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: gurusainath <gurusainath007@gmail.com>
Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>
Co-authored-by: Manish Gupta <59428681+mguptahub@users.noreply.github.com>
Co-authored-by: Manish Gupta <manish@mgupta.me>
Co-authored-by: rahulramesha <rahulramesham@gmail.com>
Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
Co-authored-by: Daniel Alba <56451942+redrum15@users.noreply.github.com>
Co-authored-by: Nikhil <118773738+pablohashescobar@users.noreply.github.com>
This commit is contained in:
sriram veeraghanta 2024-05-08 23:01:20 +05:30 committed by GitHub
parent ae43d05714
commit 59335618b4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
903 changed files with 25736 additions and 16041 deletions

View file

@ -7,6 +7,8 @@ from .user import (
UserAdminLiteSerializer,
UserMeSerializer,
UserMeSettingsSerializer,
ProfileSerializer,
AccountSerializer,
)
from .workspace import (
WorkSpaceSerializer,

View file

@ -2,8 +2,15 @@
from rest_framework import serializers
# Module import
from plane.db.models import (
Account,
Profile,
User,
Workspace,
WorkspaceMemberInvite,
)
from .base import BaseSerializer
from plane.db.models import User, Workspace, WorkspaceMemberInvite
class UserSerializer(BaseSerializer):
@ -23,7 +30,6 @@ class UserSerializer(BaseSerializer):
"last_logout_ip",
"last_login_uagent",
"token_updated_at",
"is_onboarded",
"is_bot",
"is_password_autoset",
"is_email_verified",
@ -50,19 +56,11 @@ class UserMeSerializer(BaseSerializer):
"is_active",
"is_bot",
"is_email_verified",
"is_managed",
"is_onboarded",
"is_tour_completed",
"mobile_number",
"role",
"onboarding_step",
"user_timezone",
"username",
"theme",
"last_workspace_id",
"use_case",
"is_password_autoset",
"is_email_verified",
"last_login_medium",
]
read_only_fields = fields
@ -83,25 +81,28 @@ class UserMeSettingsSerializer(BaseSerializer):
workspace_invites = WorkspaceMemberInvite.objects.filter(
email=obj.email
).count()
# profile
profile = Profile.objects.get(user=obj)
if (
obj.last_workspace_id is not None
profile.last_workspace_id is not None
and Workspace.objects.filter(
pk=obj.last_workspace_id,
pk=profile.last_workspace_id,
workspace_member__member=obj.id,
workspace_member__is_active=True,
).exists()
):
workspace = Workspace.objects.filter(
pk=obj.last_workspace_id,
pk=profile.last_workspace_id,
workspace_member__member=obj.id,
workspace_member__is_active=True,
).first()
return {
"last_workspace_id": obj.last_workspace_id,
"last_workspace_id": profile.last_workspace_id,
"last_workspace_slug": (
workspace.slug if workspace is not None else ""
),
"fallback_workspace_id": obj.last_workspace_id,
"fallback_workspace_id": profile.last_workspace_id,
"fallback_workspace_slug": (
workspace.slug if workspace is not None else ""
),
@ -200,3 +201,15 @@ class ResetPasswordSerializer(serializers.Serializer):
"""
new_password = serializers.CharField(required=True, min_length=8)
class ProfileSerializer(BaseSerializer):
class Meta:
model = Profile
fields = "__all__"
class AccountSerializer(BaseSerializer):
class Meta:
model = Account
fields = "__all__"

View file

@ -1,7 +1,6 @@
from .analytic import urlpatterns as analytic_urls
from .api import urlpatterns as api_urls
from .asset import urlpatterns as asset_urls
from .authentication import urlpatterns as authentication_urls
from .config import urlpatterns as configuration_urls
from .cycle import urlpatterns as cycle_urls
from .dashboard import urlpatterns as dashboard_urls
from .estimate import urlpatterns as estimate_urls
@ -16,16 +15,12 @@ from .search import urlpatterns as search_urls
from .state import urlpatterns as state_urls
from .user import urlpatterns as user_urls
from .views import urlpatterns as view_urls
from .workspace import urlpatterns as workspace_urls
from .api import urlpatterns as api_urls
from .webhook import urlpatterns as webhook_urls
from .workspace import urlpatterns as workspace_urls
urlpatterns = [
*analytic_urls,
*asset_urls,
*authentication_urls,
*configuration_urls,
*cycle_urls,
*dashboard_urls,
*estimate_urls,

View file

@ -1,65 +0,0 @@
from django.urls import path
from rest_framework_simplejwt.views import TokenRefreshView
from plane.app.views import (
# Authentication
SignInEndpoint,
SignOutEndpoint,
MagicGenerateEndpoint,
MagicSignInEndpoint,
OauthEndpoint,
EmailCheckEndpoint,
## End Authentication
# Auth Extended
ForgotPasswordEndpoint,
ResetPasswordEndpoint,
ChangePasswordEndpoint,
## End Auth Extender
# API Tokens
ApiTokenEndpoint,
## End API Tokens
)
urlpatterns = [
# Social Auth
path("email-check/", EmailCheckEndpoint.as_view(), name="email"),
path("social-auth/", OauthEndpoint.as_view(), name="oauth"),
# Auth
path("sign-in/", SignInEndpoint.as_view(), name="sign-in"),
path("sign-out/", SignOutEndpoint.as_view(), name="sign-out"),
# magic sign in
path(
"magic-generate/",
MagicGenerateEndpoint.as_view(),
name="magic-generate",
),
path(
"magic-sign-in/", MagicSignInEndpoint.as_view(), name="magic-sign-in"
),
path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
# Password Manipulation
path(
"users/me/change-password/",
ChangePasswordEndpoint.as_view(),
name="change-password",
),
path(
"reset-password/<uidb64>/<token>/",
ResetPasswordEndpoint.as_view(),
name="password-reset",
),
path(
"forgot-password/",
ForgotPasswordEndpoint.as_view(),
name="forgot-password",
),
# API Tokens
path("api-tokens/", ApiTokenEndpoint.as_view(), name="api-tokens"),
path(
"api-tokens/<uuid:pk>/", ApiTokenEndpoint.as_view(), name="api-tokens"
),
## End API Tokens
]

View file

@ -1,17 +0,0 @@
from django.urls import path
from plane.app.views import ConfigurationEndpoint, MobileConfigurationEndpoint
urlpatterns = [
path(
"configs/",
ConfigurationEndpoint.as_view(),
name="configuration",
),
path(
"mobile-configs/",
MobileConfigurationEndpoint.as_view(),
name="configuration",
),
]

View file

@ -1,20 +1,19 @@
from django.urls import path
from plane.app.views import (
## User
UserEndpoint,
AccountEndpoint,
ProfileEndpoint,
UpdateUserOnBoardedEndpoint,
UpdateUserTourCompletedEndpoint,
UserActivityEndpoint,
ChangePasswordEndpoint,
SetUserPasswordEndpoint,
UserActivityGraphEndpoint,
## User
UserEndpoint,
UserIssueCompletedGraphEndpoint,
UserWorkspaceDashboardEndpoint,
## End User
## Workspaces
UserWorkSpacesEndpoint,
UserActivityGraphEndpoint,
UserIssueCompletedGraphEndpoint,
UserWorkspaceDashboardEndpoint,
## End Workspaces
)
urlpatterns = [
@ -39,6 +38,25 @@ urlpatterns = [
),
name="users",
),
# Profile
path(
"users/me/profile/",
ProfileEndpoint.as_view(),
name="accounts",
),
# End profile
# Accounts
path(
"users/me/accounts/",
AccountEndpoint.as_view(),
name="accounts",
),
path(
"users/me/accounts/<uuid:pk>/",
AccountEndpoint.as_view(),
name="accounts",
),
## End Accounts
path(
"users/me/instance-admin/",
UserEndpoint.as_view(
@ -48,11 +66,6 @@ urlpatterns = [
),
name="users",
),
path(
"users/me/change-password/",
ChangePasswordEndpoint.as_view(),
name="change-password",
),
path(
"users/me/onboard/",
UpdateUserOnBoardedEndpoint.as_view(),
@ -90,10 +103,5 @@ urlpatterns = [
UserWorkspaceDashboardEndpoint.as_view(),
name="user-workspace-dashboard",
),
path(
"users/me/set-password/",
SetUserPasswordEndpoint.as_view(),
name="set-password",
),
## End User Graph
]

View file

@ -28,7 +28,6 @@ from .user.base import (
UserActivityEndpoint,
)
from .oauth import OauthEndpoint
from .base import BaseAPIView, BaseViewSet
@ -92,6 +91,8 @@ from .cycle.base import (
CycleFavoriteViewSet,
TransferCycleIssueEndpoint,
CycleUserPropertiesEndpoint,
CycleViewSet,
TransferCycleIssueEndpoint,
)
from .cycle.issue import (
CycleIssueViewSet,
@ -152,21 +153,6 @@ from .issue.subscriber import (
IssueSubscriberViewSet,
)
from .auth_extended import (
ForgotPasswordEndpoint,
ResetPasswordEndpoint,
ChangePasswordEndpoint,
SetUserPasswordEndpoint,
EmailCheckEndpoint,
MagicGenerateEndpoint,
)
from .authentication import (
SignInEndpoint,
SignOutEndpoint,
MagicSignInEndpoint,
)
from .module.base import (
ModuleViewSet,
@ -200,7 +186,6 @@ from .external.base import (
GPTIntegrationEndpoint,
UnsplashEndpoint,
)
from .estimate.base import (
ProjectEstimatePointEndpoint,
BulkEstimatePointEndpoint,
@ -219,13 +204,11 @@ from .analytic.base import (
from .notification.base import (
NotificationViewSet,
UnreadNotificationEndpoint,
MarkAllReadNotificationViewSet,
UserNotificationPreferenceEndpoint,
)
from .exporter.base import ExportIssuesEndpoint
from .config import ConfigurationEndpoint, MobileConfigurationEndpoint
from .webhook.base import (
WebhookEndpoint,
@ -236,3 +219,7 @@ from .webhook.base import (
from .dashboard.base import DashboardEndpoint, WidgetsEndpoint
from .error_404 import custom_404_view
from .exporter.base import ExportIssuesEndpoint
from .notification.base import MarkAllReadNotificationViewSet
from .user.base import AccountEndpoint, ProfileEndpoint

View file

@ -1,5 +1,5 @@
# Django imports
from django.db.models import Count, Sum, F
from django.db.models import Count, F, Sum
from django.db.models.functions import ExtractMonth
from django.utils import timezone
@ -7,13 +7,14 @@ from django.utils import timezone
from rest_framework import status
from rest_framework.response import Response
# Module imports
from plane.app.views import BaseAPIView, BaseViewSet
from plane.app.permissions import WorkSpaceAdminPermission
from plane.db.models import Issue, AnalyticView, Workspace
from plane.app.serializers import AnalyticViewSerializer
from plane.utils.analytics_plot import build_graph_plot
# Module imports
from plane.app.views.base import BaseAPIView, BaseViewSet
from plane.bgtasks.analytic_plot_export import analytic_export_task
from plane.db.models import AnalyticView, Issue, Workspace
from plane.utils.analytics_plot import build_graph_plot
from plane.utils.issue_filters import issue_filters

View file

@ -1,482 +0,0 @@
## Python imports
import uuid
import os
import json
import random
import string
## Django imports
from django.contrib.auth.tokens import PasswordResetTokenGenerator
from django.utils.encoding import (
smart_str,
smart_bytes,
DjangoUnicodeDecodeError,
)
from django.contrib.auth.hashers import make_password
from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode
from django.core.validators import validate_email
from django.core.exceptions import ValidationError
## Third Party Imports
from rest_framework import status
from rest_framework.response import Response
from rest_framework.permissions import AllowAny
from rest_framework_simplejwt.tokens import RefreshToken
## Module imports
from . import BaseAPIView
from plane.app.serializers import (
ChangePasswordSerializer,
ResetPasswordSerializer,
UserSerializer,
)
from plane.db.models import User, WorkspaceMemberInvite
from plane.license.utils.instance_value import get_configuration_value
from plane.bgtasks.forgot_password_task import forgot_password
from plane.license.models import Instance
from plane.settings.redis import redis_instance
from plane.bgtasks.magic_link_code_task import magic_link
from plane.bgtasks.event_tracking_task import auth_events
def get_tokens_for_user(user):
refresh = RefreshToken.for_user(user)
return (
str(refresh.access_token),
str(refresh),
)
def generate_magic_token(email):
key = "magic_" + str(email)
## Generate a random token
token = (
"".join(random.choices(string.ascii_lowercase, k=4))
+ "-"
+ "".join(random.choices(string.ascii_lowercase, k=4))
+ "-"
+ "".join(random.choices(string.ascii_lowercase, k=4))
)
# Initialize the redis instance
ri = redis_instance()
# Check if the key already exists in python
if ri.exists(key):
data = json.loads(ri.get(key))
current_attempt = data["current_attempt"] + 1
if data["current_attempt"] > 2:
return key, token, False
value = {
"current_attempt": current_attempt,
"email": email,
"token": token,
}
expiry = 600
ri.set(key, json.dumps(value), ex=expiry)
else:
value = {"current_attempt": 0, "email": email, "token": token}
expiry = 600
ri.set(key, json.dumps(value), ex=expiry)
return key, token, True
def generate_password_token(user):
uidb64 = urlsafe_base64_encode(smart_bytes(user.id))
token = PasswordResetTokenGenerator().make_token(user)
return uidb64, token
class ForgotPasswordEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def post(self, request):
email = request.data.get("email")
try:
validate_email(email)
except ValidationError:
return Response(
{"error": "Please enter a valid email"},
status=status.HTTP_400_BAD_REQUEST,
)
# Get the user
user = User.objects.filter(email=email).first()
if user:
# Get the reset token for user
uidb64, token = generate_password_token(user=user)
current_site = request.META.get("HTTP_ORIGIN")
# send the forgot password email
forgot_password.delay(
user.first_name, user.email, uidb64, token, current_site
)
return Response(
{"message": "Check your email to reset your password"},
status=status.HTTP_200_OK,
)
return Response(
{"error": "Please check the email"},
status=status.HTTP_400_BAD_REQUEST,
)
class ResetPasswordEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def post(self, request, uidb64, token):
try:
# Decode the id from the uidb64
id = smart_str(urlsafe_base64_decode(uidb64))
user = User.objects.get(id=id)
# check if the token is valid for the user
if not PasswordResetTokenGenerator().check_token(user, token):
return Response(
{"error": "Token is invalid"},
status=status.HTTP_401_UNAUTHORIZED,
)
# Reset the password
serializer = ResetPasswordSerializer(data=request.data)
if serializer.is_valid():
# set_password also hashes the password that the user will get
user.set_password(serializer.data.get("new_password"))
user.is_password_autoset = False
user.save()
# Log the user in
# Generate access token for the user
access_token, refresh_token = get_tokens_for_user(user)
data = {
"access_token": access_token,
"refresh_token": refresh_token,
}
return Response(data, status=status.HTTP_200_OK)
return Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
except DjangoUnicodeDecodeError:
return Response(
{"error": "token is not valid, please check the new one"},
status=status.HTTP_401_UNAUTHORIZED,
)
class ChangePasswordEndpoint(BaseAPIView):
def post(self, request):
serializer = ChangePasswordSerializer(data=request.data)
user = User.objects.get(pk=request.user.id)
if serializer.is_valid():
if not user.check_password(serializer.data.get("old_password")):
return Response(
{"error": "Old password is not correct"},
status=status.HTTP_400_BAD_REQUEST,
)
# set_password also hashes the password that the user will get
user.set_password(serializer.data.get("new_password"))
user.is_password_autoset = False
user.save()
return Response(
{"message": "Password updated successfully"},
status=status.HTTP_200_OK,
)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class SetUserPasswordEndpoint(BaseAPIView):
def post(self, request):
user = User.objects.get(pk=request.user.id)
password = request.data.get("password", False)
# If the user password is not autoset then return error
if not user.is_password_autoset:
return Response(
{
"error": "Your password is already set please change your password from profile"
},
status=status.HTTP_400_BAD_REQUEST,
)
# Check password validation
if not password and len(str(password)) < 8:
return Response(
{"error": "Password is not valid"},
status=status.HTTP_400_BAD_REQUEST,
)
# Set the user password
user.set_password(password)
user.is_password_autoset = False
user.save()
serializer = UserSerializer(user)
return Response(serializer.data, status=status.HTTP_200_OK)
class MagicGenerateEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def post(self, request):
email = request.data.get("email", False)
# Check the instance registration
instance = Instance.objects.first()
if instance is None or not instance.is_setup_done:
return Response(
{"error": "Instance is not configured"},
status=status.HTTP_400_BAD_REQUEST,
)
if not email:
return Response(
{"error": "Please provide a valid email address"},
status=status.HTTP_400_BAD_REQUEST,
)
# Clean up the email
email = email.strip().lower()
validate_email(email)
# check if the email exists not
if not User.objects.filter(email=email).exists():
# Create a user
_ = User.objects.create(
email=email,
username=uuid.uuid4().hex,
password=make_password(uuid.uuid4().hex),
is_password_autoset=True,
)
## Generate a random token
token = (
"".join(random.choices(string.ascii_lowercase, k=4))
+ "-"
+ "".join(random.choices(string.ascii_lowercase, k=4))
+ "-"
+ "".join(random.choices(string.ascii_lowercase, k=4))
)
ri = redis_instance()
key = "magic_" + str(email)
# Check if the key already exists in python
if ri.exists(key):
data = json.loads(ri.get(key))
current_attempt = data["current_attempt"] + 1
if data["current_attempt"] > 2:
return Response(
{
"error": "Max attempts exhausted. Please try again later."
},
status=status.HTTP_400_BAD_REQUEST,
)
value = {
"current_attempt": current_attempt,
"email": email,
"token": token,
}
expiry = 600
ri.set(key, json.dumps(value), ex=expiry)
else:
value = {"current_attempt": 0, "email": email, "token": token}
expiry = 600
ri.set(key, json.dumps(value), ex=expiry)
# If the smtp is configured send through here
current_site = request.META.get("HTTP_ORIGIN")
magic_link.delay(email, key, token, current_site)
return Response({"key": key}, status=status.HTTP_200_OK)
class EmailCheckEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def post(self, request):
# Check the instance registration
instance = Instance.objects.first()
if instance is None or not instance.is_setup_done:
return Response(
{"error": "Instance is not configured"},
status=status.HTTP_400_BAD_REQUEST,
)
# Get configuration values
ENABLE_SIGNUP, ENABLE_MAGIC_LINK_LOGIN = get_configuration_value(
[
{
"key": "ENABLE_SIGNUP",
"default": os.environ.get("ENABLE_SIGNUP"),
},
{
"key": "ENABLE_MAGIC_LINK_LOGIN",
"default": os.environ.get("ENABLE_MAGIC_LINK_LOGIN"),
},
]
)
email = request.data.get("email", False)
if not email:
return Response(
{"error": "Email is required"},
status=status.HTTP_400_BAD_REQUEST,
)
# validate the email
try:
validate_email(email)
except ValidationError:
return Response(
{"error": "Email is not valid"},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if the user exists
user = User.objects.filter(email=email).first()
current_site = request.META.get("HTTP_ORIGIN")
# If new user
if user is None:
# Create the user
if (
ENABLE_SIGNUP == "0"
and not WorkspaceMemberInvite.objects.filter(
email=email,
).exists()
):
return Response(
{
"error": "New account creation is disabled. Please contact your site administrator"
},
status=status.HTTP_400_BAD_REQUEST,
)
# Create the user with default values
user = User.objects.create(
email=email,
username=uuid.uuid4().hex,
password=make_password(uuid.uuid4().hex),
is_password_autoset=True,
)
if not bool(
ENABLE_MAGIC_LINK_LOGIN,
):
return Response(
{"error": "Magic link sign in is disabled."},
status=status.HTTP_400_BAD_REQUEST,
)
# Send event
auth_events.delay(
user=user.id,
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="Sign up",
medium="Magic link",
first_time=True,
)
key, token, current_attempt = generate_magic_token(email=email)
if not current_attempt:
return Response(
{
"error": "Max attempts exhausted. Please try again later."
},
status=status.HTTP_400_BAD_REQUEST,
)
# Trigger the email
magic_link.delay(email, "magic_" + str(email), token, current_site)
return Response(
{
"is_password_autoset": user.is_password_autoset,
"is_existing": False,
},
status=status.HTTP_200_OK,
)
# Existing user
else:
if user.is_password_autoset:
## Generate a random token
if not bool(ENABLE_MAGIC_LINK_LOGIN):
return Response(
{"error": "Magic link sign in is disabled."},
status=status.HTTP_400_BAD_REQUEST,
)
auth_events.delay(
user=user.id,
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="Sign in",
medium="Magic link",
first_time=False,
)
# Generate magic token
key, token, current_attempt = generate_magic_token(email=email)
if not current_attempt:
return Response(
{
"error": "Max attempts exhausted. Please try again later."
},
status=status.HTTP_400_BAD_REQUEST,
)
# Trigger the email
magic_link.delay(email, key, token, current_site)
return Response(
{
"is_password_autoset": user.is_password_autoset,
"is_existing": True,
},
status=status.HTTP_200_OK,
)
else:
auth_events.delay(
user=user.id,
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="Sign in",
medium="Email",
first_time=False,
)
# User should enter password to login
return Response(
{
"is_password_autoset": user.is_password_autoset,
"is_existing": True,
},
status=status.HTTP_200_OK,
)

View file

@ -1,453 +0,0 @@
# Python imports
import os
import uuid
import json
# Django imports
from django.utils import timezone
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.contrib.auth.hashers import make_password
# Third party imports
from rest_framework.response import Response
from rest_framework.permissions import AllowAny
from rest_framework import status
from rest_framework_simplejwt.tokens import RefreshToken
from sentry_sdk import capture_message
# Module imports
from . import BaseAPIView
from plane.db.models import (
User,
WorkspaceMemberInvite,
WorkspaceMember,
ProjectMemberInvite,
ProjectMember,
)
from plane.settings.redis import redis_instance
from plane.license.models import Instance
from plane.license.utils.instance_value import get_configuration_value
from plane.bgtasks.event_tracking_task import auth_events
def get_tokens_for_user(user):
refresh = RefreshToken.for_user(user)
return (
str(refresh.access_token),
str(refresh),
)
class SignUpEndpoint(BaseAPIView):
permission_classes = (AllowAny,)
def post(self, request):
# Check if the instance configuration is done
instance = Instance.objects.first()
if instance is None or not instance.is_setup_done:
return Response(
{"error": "Instance is not configured"},
status=status.HTTP_400_BAD_REQUEST,
)
email = request.data.get("email", False)
password = request.data.get("password", False)
## Raise exception if any of the above are missing
if not email or not password:
return Response(
{"error": "Both email and password are required"},
status=status.HTTP_400_BAD_REQUEST,
)
# Validate the email
email = email.strip().lower()
try:
validate_email(email)
except ValidationError:
return Response(
{"error": "Please provide a valid email address."},
status=status.HTTP_400_BAD_REQUEST,
)
# get configuration values
# Get configuration values
(ENABLE_SIGNUP,) = get_configuration_value(
[
{
"key": "ENABLE_SIGNUP",
"default": os.environ.get("ENABLE_SIGNUP"),
},
]
)
# If the sign up is not enabled and the user does not have invite disallow him from creating the account
if (
ENABLE_SIGNUP == "0"
and not WorkspaceMemberInvite.objects.filter(
email=email,
).exists()
):
return Response(
{
"error": "New account creation is disabled. Please contact your site administrator"
},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if the user already exists
if User.objects.filter(email=email).exists():
return Response(
{"error": "User with this email already exists"},
status=status.HTTP_400_BAD_REQUEST,
)
user = User.objects.create(email=email, username=uuid.uuid4().hex)
user.set_password(password)
# settings last actives for the user
user.is_password_autoset = False
user.last_active = timezone.now()
user.last_login_time = timezone.now()
user.last_login_ip = request.META.get("REMOTE_ADDR")
user.last_login_uagent = request.META.get("HTTP_USER_AGENT")
user.token_updated_at = timezone.now()
user.save()
access_token, refresh_token = get_tokens_for_user(user)
data = {
"access_token": access_token,
"refresh_token": refresh_token,
}
return Response(data, status=status.HTTP_200_OK)
class SignInEndpoint(BaseAPIView):
permission_classes = (AllowAny,)
def post(self, request):
# Check if the instance configuration is done
instance = Instance.objects.first()
if instance is None or not instance.is_setup_done:
return Response(
{"error": "Instance is not configured"},
status=status.HTTP_400_BAD_REQUEST,
)
email = request.data.get("email", False)
password = request.data.get("password", False)
## Raise exception if any of the above are missing
if not email or not password:
return Response(
{"error": "Both email and password are required"},
status=status.HTTP_400_BAD_REQUEST,
)
# Validate email
email = email.strip().lower()
try:
validate_email(email)
except ValidationError:
return Response(
{"error": "Please provide a valid email address."},
status=status.HTTP_400_BAD_REQUEST,
)
# Get the user
user = User.objects.filter(email=email).first()
# Existing user
if user:
# Check user password
if not user.check_password(password):
return Response(
{
"error": "Sorry, we could not find a user with the provided credentials. Please try again."
},
status=status.HTTP_403_FORBIDDEN,
)
# Create the user
else:
(ENABLE_SIGNUP,) = get_configuration_value(
[
{
"key": "ENABLE_SIGNUP",
"default": os.environ.get("ENABLE_SIGNUP"),
},
]
)
# Create the user
if (
ENABLE_SIGNUP == "0"
and not WorkspaceMemberInvite.objects.filter(
email=email,
).exists()
):
return Response(
{
"error": "New account creation is disabled. Please contact your site administrator"
},
status=status.HTTP_400_BAD_REQUEST,
)
user = User.objects.create(
email=email,
username=uuid.uuid4().hex,
password=make_password(password),
is_password_autoset=False,
)
# settings last active for the user
user.is_active = True
user.last_active = timezone.now()
user.last_login_time = timezone.now()
user.last_login_ip = request.META.get("REMOTE_ADDR")
user.last_login_uagent = request.META.get("HTTP_USER_AGENT")
user.token_updated_at = timezone.now()
user.save()
# Check if user has any accepted invites for workspace and add them to workspace
workspace_member_invites = WorkspaceMemberInvite.objects.filter(
email=user.email, accepted=True
)
WorkspaceMember.objects.bulk_create(
[
WorkspaceMember(
workspace_id=workspace_member_invite.workspace_id,
member=user,
role=workspace_member_invite.role,
)
for workspace_member_invite in workspace_member_invites
],
ignore_conflicts=True,
)
# Check if user has any project invites
project_member_invites = ProjectMemberInvite.objects.filter(
email=user.email, accepted=True
)
# Add user to workspace
WorkspaceMember.objects.bulk_create(
[
WorkspaceMember(
workspace_id=project_member_invite.workspace_id,
role=(
project_member_invite.role
if project_member_invite.role in [5, 10, 15]
else 15
),
member=user,
created_by_id=project_member_invite.created_by_id,
)
for project_member_invite in project_member_invites
],
ignore_conflicts=True,
)
# Now add the users to project
ProjectMember.objects.bulk_create(
[
ProjectMember(
workspace_id=project_member_invite.workspace_id,
role=(
project_member_invite.role
if project_member_invite.role in [5, 10, 15]
else 15
),
member=user,
created_by_id=project_member_invite.created_by_id,
)
for project_member_invite in project_member_invites
],
ignore_conflicts=True,
)
# Delete all the invites
workspace_member_invites.delete()
project_member_invites.delete()
# Send event
auth_events.delay(
user=user.id,
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="Sign in",
medium="Email",
first_time=False,
)
access_token, refresh_token = get_tokens_for_user(user)
data = {
"access_token": access_token,
"refresh_token": refresh_token,
}
return Response(data, status=status.HTTP_200_OK)
class SignOutEndpoint(BaseAPIView):
def post(self, request):
refresh_token = request.data.get("refresh_token", False)
if not refresh_token:
capture_message("No refresh token provided")
return Response(
{"error": "No refresh token provided"},
status=status.HTTP_400_BAD_REQUEST,
)
user = User.objects.get(pk=request.user.id)
user.last_logout_time = timezone.now()
user.last_logout_ip = request.META.get("REMOTE_ADDR")
user.save()
token = RefreshToken(refresh_token)
token.blacklist()
return Response({"message": "success"}, status=status.HTTP_200_OK)
class MagicSignInEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def post(self, request):
# Check if the instance configuration is done
instance = Instance.objects.first()
if instance is None or not instance.is_setup_done:
return Response(
{"error": "Instance is not configured"},
status=status.HTTP_400_BAD_REQUEST,
)
user_token = request.data.get("token", "").strip()
key = request.data.get("key", "").strip().lower()
if not key or user_token == "":
return Response(
{"error": "User token and key are required"},
status=status.HTTP_400_BAD_REQUEST,
)
ri = redis_instance()
if ri.exists(key):
data = json.loads(ri.get(key))
token = data["token"]
email = data["email"]
if str(token) == str(user_token):
user = User.objects.get(email=email)
# Send event
auth_events.delay(
user=user.id,
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="Sign in",
medium="Magic link",
first_time=False,
)
user.is_active = True
user.is_email_verified = True
user.last_active = timezone.now()
user.last_login_time = timezone.now()
user.last_login_ip = request.META.get("REMOTE_ADDR")
user.last_login_uagent = request.META.get("HTTP_USER_AGENT")
user.token_updated_at = timezone.now()
user.save()
# Check if user has any accepted invites for workspace and add them to workspace
workspace_member_invites = (
WorkspaceMemberInvite.objects.filter(
email=user.email, accepted=True
)
)
WorkspaceMember.objects.bulk_create(
[
WorkspaceMember(
workspace_id=workspace_member_invite.workspace_id,
member=user,
role=workspace_member_invite.role,
)
for workspace_member_invite in workspace_member_invites
],
ignore_conflicts=True,
)
# Check if user has any project invites
project_member_invites = ProjectMemberInvite.objects.filter(
email=user.email, accepted=True
)
# Add user to workspace
WorkspaceMember.objects.bulk_create(
[
WorkspaceMember(
workspace_id=project_member_invite.workspace_id,
role=(
project_member_invite.role
if project_member_invite.role in [5, 10, 15]
else 15
),
member=user,
created_by_id=project_member_invite.created_by_id,
)
for project_member_invite in project_member_invites
],
ignore_conflicts=True,
)
# Now add the users to project
ProjectMember.objects.bulk_create(
[
ProjectMember(
workspace_id=project_member_invite.workspace_id,
role=(
project_member_invite.role
if project_member_invite.role in [5, 10, 15]
else 15
),
member=user,
created_by_id=project_member_invite.created_by_id,
)
for project_member_invite in project_member_invites
],
ignore_conflicts=True,
)
# Delete all the invites
workspace_member_invites.delete()
project_member_invites.delete()
access_token, refresh_token = get_tokens_for_user(user)
data = {
"access_token": access_token,
"refresh_token": refresh_token,
}
return Response(data, status=status.HTTP_200_OK)
else:
return Response(
{
"error": "Your login code was incorrect. Please try again."
},
status=status.HTTP_400_BAD_REQUEST,
)
else:
return Response(
{"error": "The magic code/link has expired please try again"},
status=status.HTTP_400_BAD_REQUEST,
)

View file

@ -19,6 +19,7 @@ from rest_framework.views import APIView
from rest_framework.viewsets import ModelViewSet
# Module imports
from plane.authentication.session import BaseSessionAuthentication
from plane.utils.exception_logger import log_exception
from plane.utils.paginator import BasePaginator
@ -49,6 +50,10 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
SearchFilter,
)
authentication_classes = [
BaseSessionAuthentication,
]
filterset_fields = []
search_fields = []
@ -161,6 +166,10 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
SearchFilter,
)
authentication_classes = [
BaseSessionAuthentication,
]
filterset_fields = []
search_fields = []

View file

@ -1,248 +0,0 @@
# Python imports
import os
# Django imports
# Third party imports
from rest_framework.permissions import AllowAny
from rest_framework import status
from rest_framework.response import Response
# Module imports
from .base import BaseAPIView
from plane.license.utils.instance_value import get_configuration_value
from plane.utils.cache import cache_response
class ConfigurationEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
@cache_response(60 * 60 * 2, user=False)
def get(self, request):
# Get all the configuration
(
GOOGLE_CLIENT_ID,
GITHUB_CLIENT_ID,
GITHUB_APP_NAME,
EMAIL_HOST_USER,
EMAIL_HOST_PASSWORD,
ENABLE_MAGIC_LINK_LOGIN,
ENABLE_EMAIL_PASSWORD,
SLACK_CLIENT_ID,
POSTHOG_API_KEY,
POSTHOG_HOST,
UNSPLASH_ACCESS_KEY,
OPENAI_API_KEY,
) = get_configuration_value(
[
{
"key": "GOOGLE_CLIENT_ID",
"default": os.environ.get("GOOGLE_CLIENT_ID", None),
},
{
"key": "GITHUB_CLIENT_ID",
"default": os.environ.get("GITHUB_CLIENT_ID", None),
},
{
"key": "GITHUB_APP_NAME",
"default": os.environ.get("GITHUB_APP_NAME", None),
},
{
"key": "EMAIL_HOST_USER",
"default": os.environ.get("EMAIL_HOST_USER", None),
},
{
"key": "EMAIL_HOST_PASSWORD",
"default": os.environ.get("EMAIL_HOST_PASSWORD", None),
},
{
"key": "ENABLE_MAGIC_LINK_LOGIN",
"default": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "1"),
},
{
"key": "ENABLE_EMAIL_PASSWORD",
"default": os.environ.get("ENABLE_EMAIL_PASSWORD", "1"),
},
{
"key": "SLACK_CLIENT_ID",
"default": os.environ.get("SLACK_CLIENT_ID", None),
},
{
"key": "POSTHOG_API_KEY",
"default": os.environ.get("POSTHOG_API_KEY", None),
},
{
"key": "POSTHOG_HOST",
"default": os.environ.get("POSTHOG_HOST", None),
},
{
"key": "UNSPLASH_ACCESS_KEY",
"default": os.environ.get("UNSPLASH_ACCESS_KEY", "1"),
},
{
"key": "OPENAI_API_KEY",
"default": os.environ.get("OPENAI_API_KEY", "1"),
},
]
)
data = {}
# Authentication
data["google_client_id"] = (
GOOGLE_CLIENT_ID
if GOOGLE_CLIENT_ID and GOOGLE_CLIENT_ID != '""'
else None
)
data["github_client_id"] = (
GITHUB_CLIENT_ID
if GITHUB_CLIENT_ID and GITHUB_CLIENT_ID != '""'
else None
)
data["github_app_name"] = GITHUB_APP_NAME
data["magic_login"] = (
bool(EMAIL_HOST_USER) and bool(EMAIL_HOST_PASSWORD)
) and ENABLE_MAGIC_LINK_LOGIN == "1"
data["email_password_login"] = ENABLE_EMAIL_PASSWORD == "1"
# Slack client
data["slack_client_id"] = SLACK_CLIENT_ID
# Posthog
data["posthog_api_key"] = POSTHOG_API_KEY
data["posthog_host"] = POSTHOG_HOST
# Unsplash
data["has_unsplash_configured"] = bool(UNSPLASH_ACCESS_KEY)
# Open AI settings
data["has_openai_configured"] = bool(OPENAI_API_KEY)
# File size settings
data["file_size_limit"] = float(
os.environ.get("FILE_SIZE_LIMIT", 5242880)
)
# is smtp configured
data["is_smtp_configured"] = bool(EMAIL_HOST_USER) and bool(
EMAIL_HOST_PASSWORD
)
return Response(data, status=status.HTTP_200_OK)
class MobileConfigurationEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
@cache_response(60 * 60 * 2, user=False)
def get(self, request):
(
GOOGLE_CLIENT_ID,
GOOGLE_SERVER_CLIENT_ID,
GOOGLE_IOS_CLIENT_ID,
EMAIL_HOST_USER,
EMAIL_HOST_PASSWORD,
ENABLE_MAGIC_LINK_LOGIN,
ENABLE_EMAIL_PASSWORD,
POSTHOG_API_KEY,
POSTHOG_HOST,
UNSPLASH_ACCESS_KEY,
OPENAI_API_KEY,
) = get_configuration_value(
[
{
"key": "GOOGLE_CLIENT_ID",
"default": os.environ.get("GOOGLE_CLIENT_ID", None),
},
{
"key": "GOOGLE_SERVER_CLIENT_ID",
"default": os.environ.get("GOOGLE_SERVER_CLIENT_ID", None),
},
{
"key": "GOOGLE_IOS_CLIENT_ID",
"default": os.environ.get("GOOGLE_IOS_CLIENT_ID", None),
},
{
"key": "EMAIL_HOST_USER",
"default": os.environ.get("EMAIL_HOST_USER", None),
},
{
"key": "EMAIL_HOST_PASSWORD",
"default": os.environ.get("EMAIL_HOST_PASSWORD", None),
},
{
"key": "ENABLE_MAGIC_LINK_LOGIN",
"default": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "1"),
},
{
"key": "ENABLE_EMAIL_PASSWORD",
"default": os.environ.get("ENABLE_EMAIL_PASSWORD", "1"),
},
{
"key": "POSTHOG_API_KEY",
"default": os.environ.get("POSTHOG_API_KEY", None),
},
{
"key": "POSTHOG_HOST",
"default": os.environ.get("POSTHOG_HOST", None),
},
{
"key": "UNSPLASH_ACCESS_KEY",
"default": os.environ.get("UNSPLASH_ACCESS_KEY", "1"),
},
{
"key": "OPENAI_API_KEY",
"default": os.environ.get("OPENAI_API_KEY", "1"),
},
]
)
data = {}
# Authentication
data["google_client_id"] = (
GOOGLE_CLIENT_ID
if GOOGLE_CLIENT_ID and GOOGLE_CLIENT_ID != '""'
else None
)
data["google_server_client_id"] = (
GOOGLE_SERVER_CLIENT_ID
if GOOGLE_SERVER_CLIENT_ID and GOOGLE_SERVER_CLIENT_ID != '""'
else None
)
data["google_ios_client_id"] = (
(GOOGLE_IOS_CLIENT_ID)[::-1]
if GOOGLE_IOS_CLIENT_ID is not None
else None
)
# Posthog
data["posthog_api_key"] = POSTHOG_API_KEY
data["posthog_host"] = POSTHOG_HOST
data["magic_login"] = (
bool(EMAIL_HOST_USER) and bool(EMAIL_HOST_PASSWORD)
) and ENABLE_MAGIC_LINK_LOGIN == "1"
data["email_password_login"] = ENABLE_EMAIL_PASSWORD == "1"
# Posthog
data["posthog_api_key"] = POSTHOG_API_KEY
data["posthog_host"] = POSTHOG_HOST
# Unsplash
data["has_unsplash_configured"] = bool(UNSPLASH_ACCESS_KEY)
# Open AI settings
data["has_openai_configured"] = bool(OPENAI_API_KEY)
# File size settings
data["file_size_limit"] = float(
os.environ.get("FILE_SIZE_LIMIT", 5242880)
)
# is smtp configured
data["is_smtp_configured"] = not (
bool(EMAIL_HOST_USER) and bool(EMAIL_HOST_PASSWORD)
)
return Response(data, status=status.HTTP_200_OK)

View file

@ -1,458 +0,0 @@
# Python imports
import uuid
import requests
import os
# Django imports
from django.utils import timezone
# Third Party modules
from rest_framework.response import Response
from rest_framework import exceptions
from rest_framework.permissions import AllowAny
from rest_framework_simplejwt.tokens import RefreshToken
from rest_framework import status
from sentry_sdk import capture_exception
# sso authentication
from google.oauth2 import id_token
from google.auth.transport import requests as google_auth_request
# Module imports
from plane.db.models import (
SocialLoginConnection,
User,
WorkspaceMemberInvite,
WorkspaceMember,
ProjectMemberInvite,
ProjectMember,
)
from plane.bgtasks.event_tracking_task import auth_events
from .base import BaseAPIView
from plane.license.models import Instance
from plane.license.utils.instance_value import get_configuration_value
def get_tokens_for_user(user):
refresh = RefreshToken.for_user(user)
return (
str(refresh.access_token),
str(refresh),
)
def validate_google_token(token, client_id):
try:
id_info = id_token.verify_oauth2_token(
token, google_auth_request.Request(), client_id
)
email = id_info.get("email")
first_name = id_info.get("given_name")
last_name = id_info.get("family_name", "")
data = {
"email": email,
"first_name": first_name,
"last_name": last_name,
}
return data
except Exception as e:
capture_exception(e)
raise exceptions.AuthenticationFailed("Error with Google connection.")
def get_access_token(request_token: str, client_id: str) -> str:
"""Obtain the request token from github.
Given the client id, client secret and request issued out by GitHub, this method
should give back an access token
Parameters
----------
CLIENT_ID: str
A string representing the client id issued out by github
CLIENT_SECRET: str
A string representing the client secret issued out by github
request_token: str
A string representing the request token issued out by github
Throws
------
ValueError:
if CLIENT_ID or CLIENT_SECRET or request_token is empty or not a string
Returns
-------
access_token: str
A string representing the access token issued out by github
"""
if not request_token:
raise ValueError("The request token has to be supplied!")
(CLIENT_SECRET,) = get_configuration_value(
[
{
"key": "GITHUB_CLIENT_SECRET",
"default": os.environ.get("GITHUB_CLIENT_SECRET", None),
},
]
)
url = f"https://github.com/login/oauth/access_token?client_id={client_id}&client_secret={CLIENT_SECRET}&code={request_token}"
headers = {"accept": "application/json"}
res = requests.post(url, headers=headers)
data = res.json()
access_token = data["access_token"]
return access_token
def get_user_data(access_token: str) -> dict:
"""
Obtain the user data from github.
Given the access token, this method should give back the user data
"""
if not access_token:
raise ValueError("The request token has to be supplied!")
if not isinstance(access_token, str):
raise ValueError("The request token has to be a string!")
access_token = "token " + access_token
url = "https://api.github.com/user"
headers = {"Authorization": access_token}
resp = requests.get(url=url, headers=headers)
user_data = resp.json()
response = requests.get(
url="https://api.github.com/user/emails", headers=headers
).json()
_ = [
user_data.update({"email": item.get("email")})
for item in response
if item.get("primary") is True
]
return user_data
class OauthEndpoint(BaseAPIView):
permission_classes = [AllowAny]
def post(self, request):
try:
# Check if instance is registered or not
instance = Instance.objects.first()
if instance is None and not instance.is_setup_done:
return Response(
{"error": "Instance is not configured"},
status=status.HTTP_400_BAD_REQUEST,
)
medium = request.data.get("medium", False)
id_token = request.data.get("credential", False)
client_id = request.data.get("clientId", False)
GOOGLE_CLIENT_ID, GITHUB_CLIENT_ID = get_configuration_value(
[
{
"key": "GOOGLE_CLIENT_ID",
"default": os.environ.get("GOOGLE_CLIENT_ID"),
},
{
"key": "GITHUB_CLIENT_ID",
"default": os.environ.get("GITHUB_CLIENT_ID"),
},
]
)
if not medium or not id_token:
return Response(
{
"error": "Something went wrong. Please try again later or contact the support team."
},
status=status.HTTP_400_BAD_REQUEST,
)
if medium == "google":
if not GOOGLE_CLIENT_ID:
return Response(
{"error": "Google login is not configured"},
status=status.HTTP_400_BAD_REQUEST,
)
data = validate_google_token(id_token, client_id)
if medium == "github":
if not GITHUB_CLIENT_ID:
return Response(
{"error": "Github login is not configured"},
status=status.HTTP_400_BAD_REQUEST,
)
access_token = get_access_token(id_token, client_id)
data = get_user_data(access_token)
email = data.get("email", None)
if email is None:
return Response(
{
"error": "Something went wrong. Please try again later or contact the support team."
},
status=status.HTTP_400_BAD_REQUEST,
)
if "@" in email:
user = User.objects.get(email=email)
email = data["email"]
mobile_number = uuid.uuid4().hex
email_verified = True
else:
return Response(
{
"error": "Something went wrong. Please try again later or contact the support team."
},
status=status.HTTP_400_BAD_REQUEST,
)
user.is_active = True
user.last_active = timezone.now()
user.last_login_time = timezone.now()
user.last_login_ip = request.META.get("REMOTE_ADDR")
user.last_login_medium = "oauth"
user.last_login_uagent = request.META.get("HTTP_USER_AGENT")
user.is_email_verified = email_verified
user.save()
# Check if user has any accepted invites for workspace and add them to workspace
workspace_member_invites = WorkspaceMemberInvite.objects.filter(
email=user.email, accepted=True
)
WorkspaceMember.objects.bulk_create(
[
WorkspaceMember(
workspace_id=workspace_member_invite.workspace_id,
member=user,
role=workspace_member_invite.role,
)
for workspace_member_invite in workspace_member_invites
],
ignore_conflicts=True,
)
# Check if user has any project invites
project_member_invites = ProjectMemberInvite.objects.filter(
email=user.email, accepted=True
)
# Add user to workspace
WorkspaceMember.objects.bulk_create(
[
WorkspaceMember(
workspace_id=project_member_invite.workspace_id,
role=(
project_member_invite.role
if project_member_invite.role in [5, 10, 15]
else 15
),
member=user,
created_by_id=project_member_invite.created_by_id,
)
for project_member_invite in project_member_invites
],
ignore_conflicts=True,
)
# Now add the users to project
ProjectMember.objects.bulk_create(
[
ProjectMember(
workspace_id=project_member_invite.workspace_id,
role=(
project_member_invite.role
if project_member_invite.role in [5, 10, 15]
else 15
),
member=user,
created_by_id=project_member_invite.created_by_id,
)
for project_member_invite in project_member_invites
],
ignore_conflicts=True,
)
# Delete all the invites
workspace_member_invites.delete()
project_member_invites.delete()
SocialLoginConnection.objects.update_or_create(
medium=medium,
extra_data={},
user=user,
defaults={
"token_data": {"id_token": id_token},
"last_login_at": timezone.now(),
},
)
# Send event
auth_events.delay(
user=user.id,
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="Sign in",
medium=medium.upper(),
first_time=False,
)
access_token, refresh_token = get_tokens_for_user(user)
data = {
"access_token": access_token,
"refresh_token": refresh_token,
}
return Response(data, status=status.HTTP_200_OK)
except User.DoesNotExist:
(ENABLE_SIGNUP,) = get_configuration_value(
[
{
"key": "ENABLE_SIGNUP",
"default": os.environ.get("ENABLE_SIGNUP", "0"),
}
]
)
if (
ENABLE_SIGNUP == "0"
and not WorkspaceMemberInvite.objects.filter(
email=email,
).exists()
):
return Response(
{
"error": "New account creation is disabled. Please contact your site administrator"
},
status=status.HTTP_400_BAD_REQUEST,
)
username = uuid.uuid4().hex
if "@" in email:
email = data["email"]
mobile_number = uuid.uuid4().hex
email_verified = True
else:
return Response(
{
"error": "Something went wrong. Please try again later or contact the support team."
},
status=status.HTTP_400_BAD_REQUEST,
)
user = User.objects.create(
username=username,
email=email,
mobile_number=mobile_number,
first_name=data.get("first_name", ""),
last_name=data.get("last_name", ""),
is_email_verified=email_verified,
is_password_autoset=True,
)
user.set_password(uuid.uuid4().hex)
user.last_active = timezone.now()
user.last_login_time = timezone.now()
user.last_login_ip = request.META.get("REMOTE_ADDR")
user.last_login_medium = "oauth"
user.last_login_uagent = request.META.get("HTTP_USER_AGENT")
user.token_updated_at = timezone.now()
user.save()
# Check if user has any accepted invites for workspace and add them to workspace
workspace_member_invites = WorkspaceMemberInvite.objects.filter(
email=user.email, accepted=True
)
WorkspaceMember.objects.bulk_create(
[
WorkspaceMember(
workspace_id=workspace_member_invite.workspace_id,
member=user,
role=workspace_member_invite.role,
)
for workspace_member_invite in workspace_member_invites
],
ignore_conflicts=True,
)
# Check if user has any project invites
project_member_invites = ProjectMemberInvite.objects.filter(
email=user.email, accepted=True
)
# Add user to workspace
WorkspaceMember.objects.bulk_create(
[
WorkspaceMember(
workspace_id=project_member_invite.workspace_id,
role=(
project_member_invite.role
if project_member_invite.role in [5, 10, 15]
else 15
),
member=user,
created_by_id=project_member_invite.created_by_id,
)
for project_member_invite in project_member_invites
],
ignore_conflicts=True,
)
# Now add the users to project
ProjectMember.objects.bulk_create(
[
ProjectMember(
workspace_id=project_member_invite.workspace_id,
role=(
project_member_invite.role
if project_member_invite.role in [5, 10, 15]
else 15
),
member=user,
created_by_id=project_member_invite.created_by_id,
)
for project_member_invite in project_member_invites
],
ignore_conflicts=True,
)
# Delete all the invites
workspace_member_invites.delete()
project_member_invites.delete()
# Send event
auth_events.delay(
user=user.id,
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="Sign up",
medium=medium.upper(),
first_time=True,
)
SocialLoginConnection.objects.update_or_create(
medium=medium,
extra_data={},
user=user,
defaults={
"token_data": {"id_token": id_token},
"last_login_at": timezone.now(),
},
)
access_token, refresh_token = get_tokens_for_user(user)
data = {
"access_token": access_token,
"refresh_token": refresh_token,
}
return Response(data, status=status.HTTP_201_CREATED)

View file

@ -7,13 +7,22 @@ from rest_framework.response import Response
# Module imports
from plane.app.serializers import (
AccountSerializer,
IssueActivitySerializer,
ProfileSerializer,
UserMeSerializer,
UserMeSettingsSerializer,
UserSerializer,
)
from plane.app.views.base import BaseAPIView, BaseViewSet
from plane.db.models import IssueActivity, ProjectMember, User, WorkspaceMember
from plane.db.models import (
Account,
IssueActivity,
Profile,
ProjectMember,
User,
WorkspaceMember,
)
from plane.license.models import Instance, InstanceAdmin
from plane.utils.cache import cache_response, invalidate_cache
from plane.utils.paginator import BasePaginator
@ -143,15 +152,20 @@ class UserEndpoint(BaseViewSet):
# Deactivate the user
user.is_active = False
user.last_workspace_id = None
user.is_tour_completed = False
user.is_onboarded = False
user.onboarding_step = {
# Profile updates
profile = Profile.objects.get(user=user)
profile.last_workspace_id = None
profile.is_tour_completed = False
profile.is_onboarded = False
profile.onboarding_step = {
"workspace_join": False,
"profile_complete": False,
"workspace_create": False,
"workspace_invite": False,
}
profile.save()
user.save()
return Response(status=status.HTTP_204_NO_CONTENT)
@ -160,9 +174,9 @@ class UpdateUserOnBoardedEndpoint(BaseAPIView):
@invalidate_cache(path="/api/users/me/")
def patch(self, request):
user = User.objects.get(pk=request.user.id, is_active=True)
user.is_onboarded = request.data.get("is_onboarded", False)
user.save()
profile = Profile.objects.get(user_id=request.user.id)
profile.is_onboarded = request.data.get("is_onboarded", False)
profile.save()
return Response(
{"message": "Updated successfully"}, status=status.HTTP_200_OK
)
@ -172,9 +186,11 @@ class UpdateUserTourCompletedEndpoint(BaseAPIView):
@invalidate_cache(path="/api/users/me/")
def patch(self, request):
user = User.objects.get(pk=request.user.id, is_active=True)
user.is_tour_completed = request.data.get("is_tour_completed", False)
user.save()
profile = Profile.objects.get(user_id=request.user.id)
profile.is_tour_completed = request.data.get(
"is_tour_completed", False
)
profile.save()
return Response(
{"message": "Updated successfully"}, status=status.HTTP_200_OK
)
@ -194,3 +210,41 @@ class UserActivityEndpoint(BaseAPIView, BasePaginator):
issue_activities, many=True
).data,
)
class AccountEndpoint(BaseAPIView):
def get(self, request, pk=None):
if pk:
account = Account.objects.get(pk=pk, user=request.user)
serializer = AccountSerializer(account)
return Response(serializer.data, status=status.HTTP_200_OK)
account = Account.objects.filter(user=request.user)
serializer = AccountSerializer(account, many=True)
return Response(
serializer.data,
status=status.HTTP_200_OK,
)
def delete(self, request, pk):
account = Account.objects.get(pk=pk, user=request.user)
account.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class ProfileEndpoint(BaseAPIView):
def get(self, request):
profile = Profile.objects.get(user=request.user)
serializer = ProfileSerializer(profile)
return Response(serializer.data, status=status.HTTP_200_OK)
def patch(self, request):
profile = Profile.objects.get(user=request.user)
serializer = ProfileSerializer(
profile, data=request.data, partial=True
)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

View file

@ -0,0 +1,115 @@
# Python imports
import os
import uuid
# Django imports
from django.utils import timezone
# Third party imports
from zxcvbn import zxcvbn
# Module imports
from plane.db.models import (
Profile,
User,
WorkspaceMemberInvite,
)
from plane.license.utils.instance_value import get_configuration_value
from .error import AuthenticationException, AUTHENTICATION_ERROR_CODES
class Adapter:
"""Common interface for all auth providers"""
def __init__(self, request, provider):
self.request = request
self.provider = provider
self.token_data = None
self.user_data = None
def get_user_token(self, data, headers=None):
raise NotImplementedError
def get_user_response(self):
raise NotImplementedError
def set_token_data(self, data):
self.token_data = data
def set_user_data(self, data):
self.user_data = data
def create_update_account(self, user):
raise NotImplementedError
def authenticate(self):
raise NotImplementedError
def complete_login_or_signup(self):
email = self.user_data.get("email")
user = User.objects.filter(email=email).first()
if not user:
# New user
(ENABLE_SIGNUP,) = get_configuration_value(
[
{
"key": "ENABLE_SIGNUP",
"default": os.environ.get("ENABLE_SIGNUP", "1"),
},
]
)
if (
ENABLE_SIGNUP == "0"
and not WorkspaceMemberInvite.objects.filter(
email=email,
).exists()
):
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["SIGNUP_DISABLED"],
error_message="SIGNUP_DISABLED",
payload={"email": email},
)
user = User(email=email, username=uuid.uuid4().hex)
if self.user_data.get("user").get("is_password_autoset"):
user.set_password(uuid.uuid4().hex)
user.is_password_autoset = True
user.is_email_verified = True
else:
# Validate password
results = zxcvbn(self.code)
if results["score"] < 3:
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"INVALID_PASSWORD"
],
error_message="INVALID_PASSWORD",
payload={"email": email},
)
user.set_password(self.code)
user.is_password_autoset = False
avatar = self.user_data.get("user", {}).get("avatar", "")
first_name = self.user_data.get("user", {}).get("first_name", "")
last_name = self.user_data.get("user", {}).get("last_name", "")
user.avatar = avatar if avatar else ""
user.first_name = first_name if first_name else ""
user.last_name = last_name if last_name else ""
user.save()
Profile.objects.create(user=user)
# Update user details
user.last_login_medium = self.provider
user.last_active = timezone.now()
user.last_login_time = timezone.now()
user.last_login_ip = self.request.META.get("REMOTE_ADDR")
user.last_login_uagent = self.request.META.get("HTTP_USER_AGENT")
user.token_updated_at = timezone.now()
user.save()
if self.token_data:
self.create_update_account(user=user)
return user

View file

@ -0,0 +1,14 @@
from plane.authentication.adapter.base import Adapter
class CredentialAdapter(Adapter):
"""Common interface for all credential providers"""
def __init__(self, request, provider):
super().__init__(request, provider)
self.request = request
self.provider = provider
def authenticate(self):
self.set_user_data()
return self.complete_login_or_signup()

View file

@ -0,0 +1,71 @@
AUTHENTICATION_ERROR_CODES = {
# Global
"INSTANCE_NOT_CONFIGURED": 5000,
"INVALID_EMAIL": 5012,
"EMAIL_REQUIRED": 5013,
"SIGNUP_DISABLED": 5001,
# Password strength
"INVALID_PASSWORD": 5002,
"SMTP_NOT_CONFIGURED": 5007,
# Sign Up
"USER_ALREADY_EXIST": 5003,
"AUTHENTICATION_FAILED_SIGN_UP": 5006,
"REQUIRED_EMAIL_PASSWORD_SIGN_UP": 5015,
"INVALID_EMAIL_SIGN_UP": 5017,
"INVALID_EMAIL_MAGIC_SIGN_UP": 5019,
"MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED": 5023,
# Sign In
"USER_DOES_NOT_EXIST": 5004,
"AUTHENTICATION_FAILED_SIGN_IN": 5005,
"REQUIRED_EMAIL_PASSWORD_SIGN_IN": 5014,
"INVALID_EMAIL_SIGN_IN": 5016,
"INVALID_EMAIL_MAGIC_SIGN_IN": 5018,
"MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED": 5022,
# Both Sign in and Sign up
"INVALID_MAGIC_CODE": 5008,
"EXPIRED_MAGIC_CODE": 5009,
# Oauth
"GOOGLE_NOT_CONFIGURED": 5010,
"GITHUB_NOT_CONFIGURED": 5011,
"GOOGLE_OAUTH_PROVIDER_ERROR": 5021,
"GITHUB_OAUTH_PROVIDER_ERROR": 5020,
# Reset Password
"INVALID_PASSWORD_TOKEN": 5024,
"EXPIRED_PASSWORD_TOKEN": 5025,
# Change password
"INCORRECT_OLD_PASSWORD": 5026,
"INVALID_NEW_PASSWORD": 5027,
# set passowrd
"PASSWORD_ALREADY_SET": 5028,
# Admin
"ADMIN_ALREADY_EXIST": 5029,
"REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME": 5030,
"INVALID_ADMIN_EMAIL": 5031,
"INVALID_ADMIN_PASSWORD": 5032,
"REQUIRED_ADMIN_EMAIL_PASSWORD": 5033,
"ADMIN_AUTHENTICATION_FAILED": 5034,
"ADMIN_USER_ALREADY_EXIST": 5035,
"ADMIN_USER_DOES_NOT_EXIST": 5036,
}
class AuthenticationException(Exception):
error_code = None
error_message = None
payload = {}
def __init__(self, error_code, error_message, payload={}):
self.error_code = error_code
self.error_message = error_message
self.payload = payload
def get_error_dict(self):
error = {
"error_code": self.error_code,
"error_message": self.error_message,
}
for key in self.payload:
error[key] = self.payload[key]
return error

View file

@ -0,0 +1,18 @@
from rest_framework.views import exception_handler
from rest_framework.exceptions import NotAuthenticated
def auth_exception_handler(exc, context):
# Call the default exception handler first, to get the standard error response.
response = exception_handler(exc, context)
# Check if an AuthenticationFailed exception is raised.
if isinstance(exc, NotAuthenticated):
# Return 403 if the users me api fails
request = context["request"]
if request.path == "/api/users/me/":
response.status_code = 403
# else return 401
else:
response.status_code = 401
return response

View file

@ -0,0 +1,88 @@
# Python imports
import requests
# Django imports
from django.utils import timezone
# Module imports
from plane.db.models import Account
from .base import Adapter
class OauthAdapter(Adapter):
def __init__(
self,
request,
provider,
client_id,
scope,
redirect_uri,
auth_url,
token_url,
userinfo_url,
client_secret=None,
code=None,
):
super().__init__(request, provider)
self.client_id = client_id
self.scope = scope
self.redirect_uri = redirect_uri
self.auth_url = auth_url
self.token_url = token_url
self.userinfo_url = userinfo_url
self.client_secret = client_secret
self.code = code
def get_auth_url(self):
return self.auth_url
def get_token_url(self):
return self.token_url
def get_user_info_url(self):
return self.userinfo_url
def authenticate(self):
self.set_token_data()
self.set_user_data()
return self.complete_login_or_signup()
def get_user_token(self, data, headers=None):
headers = headers or {}
response = requests.post(
self.get_token_url(), data=data, headers=headers
)
response.raise_for_status()
return response.json()
def get_user_response(self):
headers = {
"Authorization": f"Bearer {self.token_data.get('access_token')}"
}
response = requests.get(self.get_user_info_url(), headers=headers)
response.raise_for_status()
return response.json()
def set_user_data(self, data):
self.user_data = data
def create_update_account(self, user):
account, created = Account.objects.update_or_create(
user=user,
provider=self.provider,
defaults={
"provider_account_id": self.user_data.get("user").get(
"provider_id"
),
"access_token": self.token_data.get("access_token"),
"refresh_token": self.token_data.get("refresh_token", None),
"access_token_expired_at": self.token_data.get(
"access_token_expired_at"
),
"refresh_token_expired_at": self.token_data.get(
"refresh_token_expired_at"
),
"last_connected_at": timezone.now(),
},
)

View file

@ -0,0 +1,5 @@
from django.apps import AppConfig
class AuthConfig(AppConfig):
name = "plane.authentication"

View file

@ -0,0 +1,94 @@
import time
from importlib import import_module
from django.conf import settings
from django.contrib.sessions.backends.base import UpdateError
from django.contrib.sessions.exceptions import SessionInterrupted
from django.utils.cache import patch_vary_headers
from django.utils.deprecation import MiddlewareMixin
from django.utils.http import http_date
class SessionMiddleware(MiddlewareMixin):
def __init__(self, get_response):
super().__init__(get_response)
engine = import_module(settings.SESSION_ENGINE)
self.SessionStore = engine.SessionStore
def process_request(self, request):
if "instances" in request.path:
session_key = request.COOKIES.get(
settings.ADMIN_SESSION_COOKIE_NAME
)
else:
session_key = request.COOKIES.get(settings.SESSION_COOKIE_NAME)
request.session = self.SessionStore(session_key)
def process_response(self, request, response):
"""
If request.session was modified, or if the configuration is to save the
session every time, save the changes and set a session cookie or delete
the session cookie if the session has been emptied.
"""
try:
accessed = request.session.accessed
modified = request.session.modified
empty = request.session.is_empty()
except AttributeError:
return response
# First check if we need to delete this cookie.
# The session should be deleted only if the session is entirely empty.
is_admin_path = "instances" in request.path
cookie_name = (
settings.ADMIN_SESSION_COOKIE_NAME
if is_admin_path
else settings.SESSION_COOKIE_NAME
)
if cookie_name in request.COOKIES and empty:
response.delete_cookie(
cookie_name,
path=settings.SESSION_COOKIE_PATH,
domain=settings.SESSION_COOKIE_DOMAIN,
samesite=settings.SESSION_COOKIE_SAMESITE,
)
patch_vary_headers(response, ("Cookie",))
else:
if accessed:
patch_vary_headers(response, ("Cookie",))
if (modified or settings.SESSION_SAVE_EVERY_REQUEST) and not empty:
if request.session.get_expire_at_browser_close():
max_age = None
expires = None
else:
# Use different max_age based on whether it's an admin cookie
if is_admin_path:
max_age = settings.ADMIN_SESSION_COOKIE_AGE
else:
max_age = request.session.get_expiry_age()
expires_time = time.time() + max_age
expires = http_date(expires_time)
# Save the session data and refresh the client cookie.
if response.status_code < 500:
try:
request.session.save()
except UpdateError:
raise SessionInterrupted(
"The request's session was deleted before the "
"request completed. The user may have logged "
"out in a concurrent request, for example."
)
response.set_cookie(
cookie_name,
request.session.session_key,
max_age=max_age,
expires=expires,
domain=settings.SESSION_COOKIE_DOMAIN,
path=settings.SESSION_COOKIE_PATH,
secure=settings.SESSION_COOKIE_SECURE or None,
httponly=settings.SESSION_COOKIE_HTTPONLY or None,
samesite=settings.SESSION_COOKIE_SAMESITE,
)
return response

View file

@ -0,0 +1,97 @@
# Module imports
from plane.authentication.adapter.credential import CredentialAdapter
from plane.db.models import User
from plane.authentication.adapter.error import (
AUTHENTICATION_ERROR_CODES,
AuthenticationException,
)
class EmailProvider(CredentialAdapter):
provider = "email"
def __init__(
self,
request,
key=None,
code=None,
is_signup=False,
):
super().__init__(request, self.provider)
self.key = key
self.code = code
self.is_signup = is_signup
def set_user_data(self):
if self.is_signup:
# Check if the user already exists
if User.objects.filter(email=self.key).exists():
raise AuthenticationException(
error_message="USER_ALREADY_EXIST",
error_code=AUTHENTICATION_ERROR_CODES[
"USER_ALREADY_EXIST"
],
)
super().set_user_data(
{
"email": self.key,
"user": {
"avatar": "",
"first_name": "",
"last_name": "",
"provider_id": "",
"is_password_autoset": False,
},
}
)
return
else:
user = User.objects.filter(
email=self.key,
).first()
# User does not exists
if not user:
raise AuthenticationException(
error_message="USER_DOES_NOT_EXIST",
error_code=AUTHENTICATION_ERROR_CODES[
"USER_DOES_NOT_EXIST"
],
payload={
"email": self.key,
},
)
# Check user password
if not user.check_password(self.code):
raise AuthenticationException(
error_message=(
"AUTHENTICATION_FAILED_SIGN_UP"
if self.is_signup
else "AUTHENTICATION_FAILED_SIGN_IN"
),
error_code=AUTHENTICATION_ERROR_CODES[
(
"AUTHENTICATION_FAILED_SIGN_UP"
if self.is_signup
else "AUTHENTICATION_FAILED_SIGN_IN"
)
],
payload={"email": self.key},
)
super().set_user_data(
{
"email": self.key,
"user": {
"avatar": "",
"first_name": "",
"last_name": "",
"provider_id": "",
"is_password_autoset": False,
},
}
)
return

View file

@ -0,0 +1,130 @@
# Python imports
import json
import os
import random
import string
# Module imports
from plane.authentication.adapter.credential import CredentialAdapter
from plane.license.utils.instance_value import get_configuration_value
from plane.settings.redis import redis_instance
from plane.authentication.adapter.error import (
AUTHENTICATION_ERROR_CODES,
AuthenticationException,
)
class MagicCodeProvider(CredentialAdapter):
provider = "magic-code"
def __init__(
self,
request,
key,
code=None,
):
(EMAIL_HOST, EMAIL_HOST_USER, EMAIL_HOST_PASSWORD) = (
get_configuration_value(
[
{
"key": "EMAIL_HOST",
"default": os.environ.get("EMAIL_HOST"),
},
{
"key": "EMAIL_HOST_USER",
"default": os.environ.get("EMAIL_HOST_USER"),
},
{
"key": "EMAIL_HOST_PASSWORD",
"default": os.environ.get("EMAIL_HOST_PASSWORD"),
},
]
)
)
if not (EMAIL_HOST):
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["SMTP_NOT_CONFIGURED"],
error_message="SMTP_NOT_CONFIGURED",
payload={"email": str(self.key)},
)
super().__init__(request, self.provider)
self.key = key
self.code = code
def initiate(self):
## Generate a random token
token = (
"".join(random.choices(string.ascii_lowercase, k=4))
+ "-"
+ "".join(random.choices(string.ascii_lowercase, k=4))
+ "-"
+ "".join(random.choices(string.ascii_lowercase, k=4))
)
ri = redis_instance()
key = "magic_" + str(self.key)
# Check if the key already exists in python
if ri.exists(key):
data = json.loads(ri.get(key))
current_attempt = data["current_attempt"] + 1
if data["current_attempt"] > 2:
return key, ""
value = {
"current_attempt": current_attempt,
"email": str(self.key),
"token": token,
}
expiry = 600
ri.set(key, json.dumps(value), ex=expiry)
else:
value = {"current_attempt": 0, "email": self.key, "token": token}
expiry = 600
ri.set(key, json.dumps(value), ex=expiry)
return key, token
def set_user_data(self):
ri = redis_instance()
if ri.exists(self.key):
data = json.loads(ri.get(self.key))
token = data["token"]
email = data["email"]
if str(token) == str(self.code):
super().set_user_data(
{
"email": email,
"user": {
"avatar": "",
"first_name": "",
"last_name": "",
"provider_id": "",
"is_password_autoset": True,
},
}
)
return
else:
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"INVALID_MAGIC_CODE"
],
error_message="INVALID_MAGIC_CODE",
payload={"email": str(email)},
)
else:
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["EXPIRED_MAGIC_CODE"],
error_message="EXPIRED_MAGIC_CODE",
payload={"email": str(self.key)},
)

View file

@ -0,0 +1,136 @@
# Python imports
import os
from datetime import datetime
from urllib.parse import urlencode
import pytz
import requests
# Module imports
from plane.authentication.adapter.oauth import OauthAdapter
from plane.license.utils.instance_value import get_configuration_value
from plane.authentication.adapter.error import (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
)
class GitHubOAuthProvider(OauthAdapter):
token_url = "https://github.com/login/oauth/access_token"
userinfo_url = "https://api.github.com/user"
provider = "github"
scope = "read:user user:email"
def __init__(self, request, code=None, state=None):
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET = get_configuration_value(
[
{
"key": "GITHUB_CLIENT_ID",
"default": os.environ.get("GITHUB_CLIENT_ID"),
},
{
"key": "GITHUB_CLIENT_SECRET",
"default": os.environ.get("GITHUB_CLIENT_SECRET"),
},
]
)
if not (GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET):
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["GITHUB_NOT_CONFIGURED"],
error_message="GITHUB_NOT_CONFIGURED",
)
client_id = GITHUB_CLIENT_ID
client_secret = GITHUB_CLIENT_SECRET
redirect_uri = (
f"{request.scheme}://{request.get_host()}/auth/github/callback/"
)
url_params = {
"client_id": client_id,
"redirect_uri": redirect_uri,
"scope": self.scope,
"state": state,
}
auth_url = (
f"https://github.com/login/oauth/authorize?{urlencode(url_params)}"
)
super().__init__(
request,
self.provider,
client_id,
self.scope,
redirect_uri,
auth_url,
self.token_url,
self.userinfo_url,
client_secret,
code,
)
def set_token_data(self):
data = {
"client_id": self.client_id,
"client_secret": self.client_secret,
"code": self.code,
"redirect_uri": self.redirect_uri,
}
token_response = self.get_user_token(
data=data, headers={"Accept": "application/json"}
)
super().set_token_data(
{
"access_token": token_response.get("access_token"),
"refresh_token": token_response.get("refresh_token", None),
"access_token_expired_at": (
datetime.fromtimestamp(
token_response.get("expires_in"),
tz=pytz.utc,
)
if token_response.get("expires_in")
else None
),
"refresh_token_expired_at": (
datetime.fromtimestamp(
token_response.get("refresh_token_expired_at"),
tz=pytz.utc,
)
if token_response.get("refresh_token_expired_at")
else None
),
}
)
def __get_email(self, headers):
# Github does not provide email in user response
emails_url = "https://api.github.com/user/emails"
emails_response = requests.get(emails_url, headers=headers).json()
email = next(
(email["email"] for email in emails_response if email["primary"]),
None,
)
return email
def set_user_data(self):
user_info_response = self.get_user_response()
headers = {
"Authorization": f"Bearer {self.token_data.get('access_token')}",
"Accept": "application/json",
}
email = self.__get_email(headers=headers)
super().set_user_data(
{
"email": email,
"user": {
"provider_id": user_info_response.get("id"),
"email": email,
"avatar": user_info_response.get("avatar_url"),
"first_name": user_info_response.get("name"),
"last_name": user_info_response.get("family_name"),
"is_password_autoset": True,
},
}
)

View file

@ -0,0 +1,117 @@
# Python imports
import os
from datetime import datetime
from urllib.parse import urlencode
import pytz
# Module imports
from plane.authentication.adapter.oauth import OauthAdapter
from plane.license.utils.instance_value import get_configuration_value
from plane.authentication.adapter.error import (
AUTHENTICATION_ERROR_CODES,
AuthenticationException,
)
class GoogleOAuthProvider(OauthAdapter):
token_url = "https://oauth2.googleapis.com/token"
userinfo_url = "https://www.googleapis.com/oauth2/v2/userinfo"
scope = "https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile"
provider = "google"
def __init__(self, request, code=None, state=None):
(GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET) = get_configuration_value(
[
{
"key": "GOOGLE_CLIENT_ID",
"default": os.environ.get("GOOGLE_CLIENT_ID"),
},
{
"key": "GOOGLE_CLIENT_SECRET",
"default": os.environ.get("GOOGLE_CLIENT_SECRET"),
},
]
)
if not (GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET):
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["GOOGLE_NOT_CONFIGURED"],
error_message="GOOGLE_NOT_CONFIGURED",
)
client_id = GOOGLE_CLIENT_ID
client_secret = GOOGLE_CLIENT_SECRET
redirect_uri = (
f"{request.scheme}://{request.get_host()}/auth/google/callback/"
)
url_params = {
"client_id": client_id,
"scope": self.scope,
"redirect_uri": redirect_uri,
"response_type": "code",
"access_type": "offline",
"prompt": "consent",
"state": state,
}
auth_url = f"https://accounts.google.com/o/oauth2/v2/auth?{urlencode(url_params)}"
super().__init__(
request,
self.provider,
client_id,
self.scope,
redirect_uri,
auth_url,
self.token_url,
self.userinfo_url,
client_secret,
code,
)
def set_token_data(self):
data = {
"code": self.code,
"client_id": self.client_id,
"client_secret": self.client_secret,
"redirect_uri": self.redirect_uri,
"grant_type": "authorization_code",
}
token_response = self.get_user_token(data=data)
super().set_token_data(
{
"access_token": token_response.get("access_token"),
"refresh_token": token_response.get("refresh_token", None),
"access_token_expired_at": (
datetime.fromtimestamp(
token_response.get("expires_in"),
tz=pytz.utc,
)
if token_response.get("expires_in")
else None
),
"refresh_token_expired_at": (
datetime.fromtimestamp(
token_response.get("refresh_token_expired_at"),
tz=pytz.utc,
)
if token_response.get("refresh_token_expired_at")
else None
),
}
)
def set_user_data(self):
user_info_response = self.get_user_response()
user_data = {
"email": user_info_response.get("email"),
"user": {
"avatar": user_info_response.get("picture"),
"first_name": user_info_response.get("given_name"),
"last_name": user_info_response.get("family_name"),
"provider_id": user_info_response.get("id"),
"is_password_autoset": True,
},
}
super().set_user_data(user_data)

View file

@ -0,0 +1,8 @@
from rest_framework.authentication import SessionAuthentication
class BaseSessionAuthentication(SessionAuthentication):
# Disable csrf for the rest apis
def enforce_csrf(self, request):
return

View file

@ -0,0 +1,184 @@
from django.urls import path
from .views import (
CSRFTokenEndpoint,
EmailCheckSignInEndpoint,
EmailCheckSignUpEndpoint,
ForgotPasswordEndpoint,
SetUserPasswordEndpoint,
ResetPasswordEndpoint,
# App
GitHubCallbackEndpoint,
GitHubOauthInitiateEndpoint,
GoogleCallbackEndpoint,
GoogleOauthInitiateEndpoint,
MagicGenerateEndpoint,
MagicSignInEndpoint,
MagicSignUpEndpoint,
SignInAuthEndpoint,
SignOutAuthEndpoint,
SignUpAuthEndpoint,
# Space
EmailCheckEndpoint,
GitHubCallbackSpaceEndpoint,
GitHubOauthInitiateSpaceEndpoint,
GoogleCallbackSpaceEndpoint,
GoogleOauthInitiateSpaceEndpoint,
MagicGenerateSpaceEndpoint,
MagicSignInSpaceEndpoint,
MagicSignUpSpaceEndpoint,
SignInAuthSpaceEndpoint,
SignUpAuthSpaceEndpoint,
SignOutAuthSpaceEndpoint,
)
urlpatterns = [
# credentials
path(
"sign-in/",
SignInAuthEndpoint.as_view(),
name="sign-in",
),
path(
"sign-up/",
SignUpAuthEndpoint.as_view(),
name="sign-up",
),
path(
"spaces/sign-in/",
SignInAuthSpaceEndpoint.as_view(),
name="sign-in",
),
path(
"spaces/sign-up/",
SignUpAuthSpaceEndpoint.as_view(),
name="sign-in",
),
# signout
path(
"sign-out/",
SignOutAuthEndpoint.as_view(),
name="sign-out",
),
path(
"spaces/sign-out/",
SignOutAuthSpaceEndpoint.as_view(),
name="sign-out",
),
# csrf token
path(
"get-csrf-token/",
CSRFTokenEndpoint.as_view(),
name="get_csrf_token",
),
# Magic sign in
path(
"magic-generate/",
MagicGenerateEndpoint.as_view(),
name="magic-generate",
),
path(
"magic-sign-in/",
MagicSignInEndpoint.as_view(),
name="magic-sign-in",
),
path(
"magic-sign-up/",
MagicSignUpEndpoint.as_view(),
name="magic-sign-up",
),
path(
"get-csrf-token/",
CSRFTokenEndpoint.as_view(),
name="get_csrf_token",
),
path(
"spaces/magic-generate/",
MagicGenerateSpaceEndpoint.as_view(),
name="magic-generate",
),
path(
"spaces/magic-sign-in/",
MagicSignInSpaceEndpoint.as_view(),
name="magic-sign-in",
),
path(
"spaces/magic-sign-up/",
MagicSignUpSpaceEndpoint.as_view(),
name="magic-sign-up",
),
## Google Oauth
path(
"google/",
GoogleOauthInitiateEndpoint.as_view(),
name="google-initiate",
),
path(
"google/callback/",
GoogleCallbackEndpoint.as_view(),
name="google-callback",
),
path(
"spaces/google/",
GoogleOauthInitiateSpaceEndpoint.as_view(),
name="google-initiate",
),
path(
"google/callback/",
GoogleCallbackSpaceEndpoint.as_view(),
name="google-callback",
),
## Github Oauth
path(
"github/",
GitHubOauthInitiateEndpoint.as_view(),
name="github-initiate",
),
path(
"github/callback/",
GitHubCallbackEndpoint.as_view(),
name="github-callback",
),
path(
"spaces/github/",
GitHubOauthInitiateSpaceEndpoint.as_view(),
name="github-initiate",
),
path(
"spaces/github/callback/",
GitHubCallbackSpaceEndpoint.as_view(),
name="github-callback",
),
# Email Check
path(
"sign-up/email-check/",
EmailCheckSignUpEndpoint.as_view(),
name="email-check-sign-up",
),
path(
"sign-in/email-check/",
EmailCheckSignInEndpoint.as_view(),
name="email-check-sign-in",
),
path(
"spaces/email-check/",
EmailCheckEndpoint.as_view(),
name="email-check",
),
# Password
path(
"forgot-password/",
ForgotPasswordEndpoint.as_view(),
name="forgot-password",
),
path(
"reset-password/<uidb64>/<token>/",
ResetPasswordEndpoint.as_view(),
name="forgot-password",
),
path(
"set-password/",
SetUserPasswordEndpoint.as_view(),
name="set-password",
),
]

View file

@ -0,0 +1,14 @@
from urllib.parse import urlsplit
def base_host(request):
"""Utility function to return host / origin from the request"""
return (
request.META.get("HTTP_ORIGIN")
or f"{urlsplit(request.META.get('HTTP_REFERER')).scheme}://{urlsplit(request.META.get('HTTP_REFERER')).netloc}"
or f"{request.scheme}://{request.get_host()}"
)
def user_ip(request):
return str(request.META.get("REMOTE_ADDR"))

View file

@ -0,0 +1,17 @@
# Django imports
from django.contrib.auth import login
# Module imports
from plane.authentication.utils.host import base_host
def user_login(request, user):
login(request=request, user=user)
device_info = {
"user_agent": request.META.get("HTTP_USER_AGENT", ""),
"ip_address": request.META.get("REMOTE_ADDR", ""),
"domain": base_host(request=request),
}
request.session["device_info"] = device_info
request.session.save()
return

View file

@ -0,0 +1,42 @@
from plane.db.models import Profile, Workspace, WorkspaceMemberInvite
def get_redirection_path(user):
# Handle redirections
profile = Profile.objects.get(user=user)
# Redirect to onboarding if the user is not onboarded yet
if not profile.is_onboarded:
return "onboarding"
# Redirect to the last workspace if the user has last workspace
if profile.last_workspace_id and Workspace.objects.filter(
pk=profile.last_workspace_id,
workspace_member__member_id=user.id,
workspace_member__is_active=True,
):
workspace = Workspace.objects.filter(
pk=profile.last_workspace_id,
workspace_member__member_id=user.id,
workspace_member__is_active=True,
).first()
return f"{workspace.slug}"
fallback_workspace = (
Workspace.objects.filter(
workspace_member__member_id=user.id,
workspace_member__is_active=True,
)
.order_by("created_at")
.first()
)
# Redirect to fallback workspace
if fallback_workspace:
return f"{fallback_workspace.slug}"
# Redirect to invitations if the user has unaccepted invitations
if WorkspaceMemberInvite.objects.filter(email=user.email).count():
return "invitations"
# Redirect the user to create workspace
return "create-workspace"

View file

@ -0,0 +1,72 @@
from plane.db.models import (
ProjectMember,
ProjectMemberInvite,
WorkspaceMember,
WorkspaceMemberInvite,
)
def process_workspace_project_invitations(user):
"""This function takes in User and adds him to all workspace and projects that the user has accepted invited of"""
# Check if user has any accepted invites for workspace and add them to workspace
workspace_member_invites = WorkspaceMemberInvite.objects.filter(
email=user.email, accepted=True
)
WorkspaceMember.objects.bulk_create(
[
WorkspaceMember(
workspace_id=workspace_member_invite.workspace_id,
member=user,
role=workspace_member_invite.role,
)
for workspace_member_invite in workspace_member_invites
],
ignore_conflicts=True,
)
# Check if user has any project invites
project_member_invites = ProjectMemberInvite.objects.filter(
email=user.email, accepted=True
)
# Add user to workspace
WorkspaceMember.objects.bulk_create(
[
WorkspaceMember(
workspace_id=project_member_invite.workspace_id,
role=(
project_member_invite.role
if project_member_invite.role in [5, 10, 15]
else 15
),
member=user,
created_by_id=project_member_invite.created_by_id,
)
for project_member_invite in project_member_invites
],
ignore_conflicts=True,
)
# Now add the users to project
ProjectMember.objects.bulk_create(
[
ProjectMember(
workspace_id=project_member_invite.workspace_id,
role=(
project_member_invite.role
if project_member_invite.role in [5, 10, 15]
else 15
),
member=user,
created_by_id=project_member_invite.created_by_id,
)
for project_member_invite in project_member_invites
],
ignore_conflicts=True,
)
# Delete all the invites
workspace_member_invites.delete()
project_member_invites.delete()

View file

@ -0,0 +1,52 @@
from .common import (
ChangePasswordEndpoint,
CSRFTokenEndpoint,
ForgotPasswordEndpoint,
ResetPasswordEndpoint,
SetUserPasswordEndpoint,
)
from .app.check import EmailCheckSignInEndpoint, EmailCheckSignUpEndpoint
from .app.email import (
SignInAuthEndpoint,
SignUpAuthEndpoint,
)
from .app.github import (
GitHubCallbackEndpoint,
GitHubOauthInitiateEndpoint,
)
from .app.google import (
GoogleCallbackEndpoint,
GoogleOauthInitiateEndpoint,
)
from .app.magic import (
MagicGenerateEndpoint,
MagicSignInEndpoint,
MagicSignUpEndpoint,
)
from .app.signout import SignOutAuthEndpoint
from .space.email import SignInAuthSpaceEndpoint, SignUpAuthSpaceEndpoint
from .space.github import (
GitHubCallbackSpaceEndpoint,
GitHubOauthInitiateSpaceEndpoint,
)
from .space.google import (
GoogleCallbackSpaceEndpoint,
GoogleOauthInitiateSpaceEndpoint,
)
from .space.magic import (
MagicGenerateSpaceEndpoint,
MagicSignInSpaceEndpoint,
MagicSignUpSpaceEndpoint,
)
from .space.signout import SignOutAuthSpaceEndpoint
from .space.check import EmailCheckEndpoint

View file

@ -0,0 +1,148 @@
# Django imports
from django.core.validators import validate_email
from django.core.exceptions import ValidationError
# 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.db.models import User
from plane.license.models import Instance
from plane.authentication.adapter.error import (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
)
class EmailCheckSignUpEndpoint(APIView):
permission_classes = [
AllowAny,
]
def post(self, request):
# Check instance configuration
instance = Instance.objects.first()
if instance is None or not instance.is_setup_done:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"INSTANCE_NOT_CONFIGURED"
],
error_message="INSTANCE_NOT_CONFIGURED",
)
return Response(
exc.get_error_dict(),
status=status.HTTP_400_BAD_REQUEST,
)
email = request.data.get("email", False)
# Return error if email is not present
if not email:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["EMAIL_REQUIRED"],
error_message="EMAIL_REQUIRED",
)
return Response(
exc.get_error_dict(),
status=status.HTTP_400_BAD_REQUEST,
)
# Validate email
try:
validate_email(email)
except ValidationError:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"],
error_message="INVALID_EMAIL",
)
return Response(
exc.get_error_dict(),
status=status.HTTP_400_BAD_REQUEST,
)
existing_user = User.objects.filter(email=email).first()
if existing_user:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["USER_ALREADY_EXIST"],
error_message="USER_ALREADY_EXIST",
)
return Response(
exc.get_error_dict(),
status=status.HTTP_400_BAD_REQUEST,
)
return Response(
{"status": True},
status=status.HTTP_200_OK,
)
class EmailCheckSignInEndpoint(APIView):
permission_classes = [
AllowAny,
]
def post(self, request):
# Check instance configuration
instance = Instance.objects.first()
if instance is None or not instance.is_setup_done:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"INSTANCE_NOT_CONFIGURED"
],
error_message="INSTANCE_NOT_CONFIGURED",
)
return Response(
exc.get_error_dict(),
status=status.HTTP_400_BAD_REQUEST,
)
email = request.data.get("email", False)
# Return error if email is not present
if not email:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["EMAIL_REQUIRED"],
error_message="EMAIL_REQUIRED",
)
return Response(
exc.get_error_dict(),
status=status.HTTP_400_BAD_REQUEST,
)
# Validate email
try:
validate_email(email)
except ValidationError:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"],
error_message="INVALID_EMAIL",
)
return Response(
exc.get_error_dict(),
status=status.HTTP_400_BAD_REQUEST,
)
existing_user = User.objects.filter(email=email).first()
if existing_user:
return Response(
{
"status": True,
"is_password_autoset": existing_user.is_password_autoset,
},
status=status.HTTP_200_OK,
)
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["USER_DOES_NOT_EXIST"],
error_message="USER_DOES_NOT_EXIST",
)
return Response(
exc.get_error_dict(),
status=status.HTTP_400_BAD_REQUEST,
)

View file

@ -0,0 +1,240 @@
# 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.login import user_login
from plane.license.models import Instance
from plane.authentication.utils.host import base_host
from plane.authentication.utils.redirection_path import get_redirection_path
from plane.authentication.utils.workspace_project_join import (
process_workspace_project_invitations,
)
from plane.db.models import User
from plane.authentication.adapter.error import (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
)
class SignInAuthEndpoint(View):
def post(self, request):
next_path = request.POST.get("next_path")
# 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()
if next_path:
params["next_path"] = str(next_path)
# Base URL join
url = urljoin(
base_host(request=request),
"accounts/sign-in?" + urlencode(params),
)
return HttpResponseRedirect(url)
# set the referer 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()
# Next path
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host(request=request),
"accounts/sign-in?" + 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()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host(request=request),
"accounts/sign-in?" + urlencode(params),
)
return HttpResponseRedirect(url)
if not User.objects.filter(email=email).exists():
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()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host(request=request),
"accounts/sign-in?" + urlencode(params),
)
return HttpResponseRedirect(url)
try:
provider = EmailProvider(
request=request, key=email, code=password, is_signup=False
)
user = provider.authenticate()
# Login the user and record his device info
user_login(request=request, user=user)
# Process workspace and project invitations
process_workspace_project_invitations(user=user)
# Get the redirection path
if next_path:
path = str(next_path)
else:
path = get_redirection_path(user=user)
# redirect to referer path
url = urljoin(base_host(request=request), path)
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host(request=request),
"accounts/sign-in?" + urlencode(params),
)
return HttpResponseRedirect(url)
class SignUpAuthEndpoint(View):
def post(self, request):
next_path = request.POST.get("next_path")
# 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()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host(request=request),
"?" + urlencode(params),
)
return HttpResponseRedirect(url)
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_UP"
],
error_message="REQUIRED_EMAIL_PASSWORD_SIGN_UP",
payload={"email": str(email)},
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host(request=request),
"?" + urlencode(params),
)
return HttpResponseRedirect(url)
# Validate the email
email = email.strip().lower()
try:
validate_email(email)
except ValidationError:
# Redirection params
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL_SIGN_UP"],
error_message="INVALID_EMAIL_SIGN_UP",
payload={"email": str(email)},
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host(request=request),
"?" + urlencode(params),
)
return HttpResponseRedirect(url)
if User.objects.filter(email=email).exists():
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["USER_ALREADY_EXIST"],
error_message="USER_ALREADY_EXIST",
payload={"email": str(email)},
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host(request=request),
"?" + urlencode(params),
)
return HttpResponseRedirect(url)
try:
provider = EmailProvider(
request=request, key=email, code=password, is_signup=True
)
user = provider.authenticate()
# Login the user and record his device info
user_login(request=request, user=user)
# Process workspace and project invitations
process_workspace_project_invitations(user=user)
# Get the redirection path
if next_path:
path = next_path
else:
path = get_redirection_path(user=user)
# redirect to referer path
url = urljoin(base_host(request=request), path)
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host(request=request),
"?" + urlencode(params),
)
return HttpResponseRedirect(url)

View file

@ -0,0 +1,133 @@
import uuid
from urllib.parse import urlencode, urljoin
# Django import
from django.core.exceptions import ImproperlyConfigured
from django.http import HttpResponseRedirect
from django.views import View
# Module imports
from plane.authentication.provider.oauth.github import GitHubOAuthProvider
from plane.authentication.utils.login import user_login
from plane.authentication.utils.redirection_path import get_redirection_path
from plane.authentication.utils.workspace_project_join import (
process_workspace_project_invitations,
)
from plane.license.models import Instance
from plane.authentication.utils.host import base_host
from plane.authentication.adapter.error import (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
)
class GitHubOauthInitiateEndpoint(View):
def get(self, request):
# Get host and next path
request.session["host"] = base_host(request=request)
next_path = request.GET.get("next_path")
if next_path:
request.session["next_path"] = str(next_path)
# Check instance configuration
instance = Instance.objects.first()
if instance is None or not instance.is_setup_done:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"INSTANCE_NOT_CONFIGURED"
],
error_message="INSTANCE_NOT_CONFIGURED",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host(request=request),
"?" + urlencode(params),
)
return HttpResponseRedirect(url)
try:
state = uuid.uuid4().hex
provider = GitHubOAuthProvider(request=request, state=state)
request.session["state"] = state
auth_url = provider.get_auth_url()
return HttpResponseRedirect(auth_url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host(request=request),
"?" + urlencode(params),
)
return HttpResponseRedirect(url)
class GitHubCallbackEndpoint(View):
def get(self, request):
code = request.GET.get("code")
state = request.GET.get("state")
base_host = request.session.get("host")
next_path = request.session.get("next_path")
if state != request.session.get("state", ""):
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"GITHUB_OAUTH_PROVIDER_ERROR"
],
error_message="GITHUB_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host,
"?" + urlencode(params),
)
return HttpResponseRedirect(url)
if not code:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"GITHUB_OAUTH_PROVIDER_ERROR"
],
error_message="GITHUB_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host,
"?" + urlencode(params),
)
return HttpResponseRedirect(url)
try:
provider = GitHubOAuthProvider(
request=request,
code=code,
)
user = provider.authenticate()
# Login the user and record his device info
user_login(request=request, user=user)
# Process workspace and project invitations
process_workspace_project_invitations(user=user)
# Get the redirection path
if next_path:
path = next_path
else:
path = get_redirection_path(user=user)
# redirect to referer path
url = urljoin(base_host, path)
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host,
"?" + urlencode(params),
)
return HttpResponseRedirect(url)

View file

@ -0,0 +1,128 @@
# Python imports
import uuid
from urllib.parse import urlencode, urljoin
# Django import
from django.core.exceptions import ImproperlyConfigured
from django.http import HttpResponseRedirect
from django.views import View
from plane.authentication.provider.oauth.google import GoogleOAuthProvider
from plane.authentication.utils.login import user_login
from plane.authentication.utils.redirection_path import get_redirection_path
from plane.authentication.utils.workspace_project_join import (
process_workspace_project_invitations,
)
# Module imports
from plane.license.models import Instance
from plane.authentication.utils.host import base_host
from plane.authentication.adapter.error import (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
)
class GoogleOauthInitiateEndpoint(View):
def get(self, request):
request.session["host"] = base_host(request=request)
next_path = request.GET.get("next_path")
if next_path:
request.session["next_path"] = str(next_path)
# Check instance configuration
instance = Instance.objects.first()
if instance is None or not instance.is_setup_done:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"INSTANCE_NOT_CONFIGURED"
],
error_message="INSTANCE_NOT_CONFIGURED",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host(request=request),
"?" + urlencode(params),
)
return HttpResponseRedirect(url)
try:
state = uuid.uuid4().hex
provider = GoogleOAuthProvider(request=request, state=state)
request.session["state"] = state
auth_url = provider.get_auth_url()
return HttpResponseRedirect(auth_url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host(request=request),
"?" + urlencode(params),
)
return HttpResponseRedirect(url)
class GoogleCallbackEndpoint(View):
def get(self, request):
code = request.GET.get("code")
state = request.GET.get("state")
base_host = request.session.get("host")
next_path = request.session.get("next_path")
if state != request.session.get("state", ""):
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"GOOGLE_OAUTH_PROVIDER_ERROR"
],
error_message="GOOGLE_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host,
"?" + urlencode(params),
)
return HttpResponseRedirect(url)
if not code:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"GOOGLE_OAUTH_PROVIDER_ERROR"
],
error_message="GOOGLE_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = next_path
url = urljoin(
base_host,
"?" + urlencode(params),
)
return HttpResponseRedirect(url)
try:
provider = GoogleOAuthProvider(
request=request,
code=code,
)
user = provider.authenticate()
# Login the user and record his device info
user_login(request=request, user=user)
# Process workspace and project invitations
process_workspace_project_invitations(user=user)
# Get the redirection path
path = get_redirection_path(user=user)
# redirect to referer path
url = urljoin(base_host, str(next_path) if next_path else path)
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host,
"?" + urlencode(params),
)
return HttpResponseRedirect(url)

View file

@ -0,0 +1,211 @@
# Python imports
from urllib.parse import urlencode, urljoin
# Django imports
from django.core.validators import validate_email
from django.http import HttpResponseRedirect
from django.views import View
# 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.provider.credentials.magic_code import (
MagicCodeProvider,
)
from plane.authentication.utils.login import user_login
from plane.authentication.utils.redirection_path import get_redirection_path
from plane.authentication.utils.workspace_project_join import (
process_workspace_project_invitations,
)
from plane.bgtasks.magic_link_code_task import magic_link
from plane.license.models import Instance
from plane.authentication.utils.host import base_host
from plane.db.models import User, Profile
from plane.authentication.adapter.error import (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
)
class MagicGenerateEndpoint(APIView):
permission_classes = [
AllowAny,
]
def post(self, request):
# Check if instance is configured
instance = Instance.objects.first()
if instance is None or not instance.is_setup_done:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"INSTANCE_NOT_CONFIGURED"
],
error_message="INSTANCE_NOT_CONFIGURED",
)
return Response(
exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST
)
origin = request.META.get("HTTP_ORIGIN", "/")
email = request.data.get("email", False)
try:
# Clean up the email
email = email.strip().lower()
validate_email(email)
adapter = MagicCodeProvider(request=request, key=email)
key, token = adapter.initiate()
# If the smtp is configured send through here
magic_link.delay(email, key, token, origin)
return Response({"key": str(key)}, status=status.HTTP_200_OK)
except AuthenticationException as e:
params = e.get_error_dict()
return Response(
params,
status=status.HTTP_400_BAD_REQUEST,
)
class MagicSignInEndpoint(View):
def post(self, request):
# set the referer as session to redirect after login
code = request.POST.get("code", "").strip()
email = request.POST.get("email", "").strip().lower()
next_path = request.POST.get("next_path")
if code == "" or email == "":
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()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host(request=request),
"accounts/sign-in?" + urlencode(params),
)
return HttpResponseRedirect(url)
if not User.objects.filter(email=email).exists():
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["USER_DOES_NOT_EXIST"],
error_message="USER_DOES_NOT_EXIST",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host(request=request),
"accounts/sign-in?" + urlencode(params),
)
return HttpResponseRedirect(url)
try:
provider = MagicCodeProvider(
request=request, key=f"magic_{email}", code=code
)
user = provider.authenticate()
profile = Profile.objects.get(user=user)
# Login the user and record his device info
user_login(request=request, user=user)
# Process workspace and project invitations
process_workspace_project_invitations(user=user)
if user.is_password_autoset and profile.is_onboarded:
path = "accounts/set-password"
else:
# Get the redirection path
path = (
str(next_path)
if next_path
else str(process_workspace_project_invitations(user=user))
)
# redirect to referer path
url = urljoin(base_host(request=request), path)
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host(request=request),
"accounts/sign-in?" + urlencode(params),
)
return HttpResponseRedirect(url)
class MagicSignUpEndpoint(View):
def post(self, request):
# set the referer as session to redirect after login
code = request.POST.get("code", "").strip()
email = request.POST.get("email", "").strip().lower()
next_path = request.POST.get("next_path")
if code == "" or email == "":
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED"
],
error_message="MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host(request=request),
"?" + urlencode(params),
)
return HttpResponseRedirect(url)
if User.objects.filter(email=email).exists():
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["USER_ALREADY_EXIST"],
error_message="USER_ALREADY_EXIST",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host(request=request),
"?" + urlencode(params),
)
return HttpResponseRedirect(url)
try:
provider = MagicCodeProvider(
request=request, key=f"magic_{email}", code=code
)
user = provider.authenticate()
# Login the user and record his device info
user_login(request=request, user=user)
# Process workspace and project invitations
process_workspace_project_invitations(user=user)
# Get the redirection path
if next_path:
path = str(next_path)
else:
path = get_redirection_path(user=user)
# redirect to referer path
url = urljoin(base_host(request=request), path)
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host(request=request),
"?" + urlencode(params),
)
return HttpResponseRedirect(url)

View file

@ -0,0 +1,34 @@
# Python imports
from urllib.parse import urlencode, urljoin
# Django imports
from django.views import View
from django.contrib.auth import logout
from django.http import HttpResponseRedirect
from django.utils import timezone
# Module imports
from plane.authentication.utils.host import user_ip, base_host
from plane.db.models import User
class SignOutAuthEndpoint(View):
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()
# Log the user out
logout(request)
url = urljoin(
base_host(request=request),
"accounts/sign-in?" + urlencode({"success": "true"}),
)
return HttpResponseRedirect(url)
except Exception:
return HttpResponseRedirect(
base_host(request=request), "accounts/sign-in"
)

View file

@ -0,0 +1,326 @@
# Python imports
import os
from urllib.parse import urlencode, urljoin
# Django imports
from django.contrib.auth.tokens import PasswordResetTokenGenerator
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.http import HttpResponseRedirect
from django.middleware.csrf import get_token
from django.utils.encoding import (
DjangoUnicodeDecodeError,
smart_bytes,
smart_str,
)
from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode
from django.views import View
# 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
from zxcvbn import zxcvbn
## Module imports
from plane.app.serializers import (
ChangePasswordSerializer,
UserSerializer,
)
from plane.authentication.utils.login import user_login
from plane.bgtasks.forgot_password_task import forgot_password
from plane.db.models import User
from plane.license.models import Instance
from plane.license.utils.instance_value import get_configuration_value
from plane.authentication.utils.host import base_host
from plane.authentication.adapter.error import (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
)
class CSRFTokenEndpoint(APIView):
permission_classes = [
AllowAny,
]
def get(self, request):
# Generate a CSRF token
csrf_token = get_token(request)
# Return the CSRF token in a JSON response
return Response(
{"csrf_token": str(csrf_token)}, status=status.HTTP_200_OK
)
def generate_password_token(user):
uidb64 = urlsafe_base64_encode(smart_bytes(user.id))
token = PasswordResetTokenGenerator().make_token(user)
return uidb64, token
class ForgotPasswordEndpoint(APIView):
permission_classes = [
AllowAny,
]
def post(self, request):
email = request.data.get("email")
# Check instance configuration
instance = Instance.objects.first()
if instance is None or not instance.is_setup_done:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"INSTANCE_NOT_CONFIGURED"
],
error_message="INSTANCE_NOT_CONFIGURED",
)
return Response(
exc.get_error_dict(),
status=status.HTTP_400_BAD_REQUEST,
)
(EMAIL_HOST, EMAIL_HOST_USER, EMAIL_HOST_PASSWORD) = (
get_configuration_value(
[
{
"key": "EMAIL_HOST",
"default": os.environ.get("EMAIL_HOST"),
},
{
"key": "EMAIL_HOST_USER",
"default": os.environ.get("EMAIL_HOST_USER"),
},
{
"key": "EMAIL_HOST_PASSWORD",
"default": os.environ.get("EMAIL_HOST_PASSWORD"),
},
]
)
)
if not (EMAIL_HOST):
exc = AuthenticationException(
error_message="SMTP_NOT_CONFIGURED",
error_code=AUTHENTICATION_ERROR_CODES["SMTP_NOT_CONFIGURED"],
)
return Response(
exc.get_error_dict(),
status=status.HTTP_400_BAD_REQUEST,
)
try:
validate_email(email)
except ValidationError:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"],
error_message="INVALID_EMAIL",
)
return Response(
exc.get_error_dict(),
status=status.HTTP_400_BAD_REQUEST,
)
# Get the user
user = User.objects.filter(email=email).first()
if user:
# Get the reset token for user
uidb64, token = generate_password_token(user=user)
current_site = request.META.get("HTTP_ORIGIN")
# send the forgot password email
forgot_password.delay(
user.first_name, user.email, uidb64, token, current_site
)
return Response(
{"message": "Check your email to reset your password"},
status=status.HTTP_200_OK,
)
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["USER_DOES_NOT_EXIST"],
error_message="USER_DOES_NOT_EXIST",
)
return Response(
exc.get_error_dict(),
status=status.HTTP_400_BAD_REQUEST,
)
class ResetPasswordEndpoint(View):
def post(self, request, uidb64, token):
try:
# Decode the id from the uidb64
id = smart_str(urlsafe_base64_decode(uidb64))
user = User.objects.get(id=id)
# check if the token is valid for the user
if not PasswordResetTokenGenerator().check_token(user, token):
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"INVALID_PASSWORD_TOKEN"
],
error_message="INVALID_PASSWORD_TOKEN",
)
params = exc.get_error_dict()
url = urljoin(
base_host(request=request),
"accounts/reset-password?" + urlencode(params),
)
return HttpResponseRedirect(url)
password = request.POST.get("password", False)
if not password:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"],
error_message="INVALID_PASSWORD",
)
url = urljoin(
base_host(request=request),
"?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
# Check the password complexity
results = zxcvbn(password)
if results["score"] < 3:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"],
error_message="INVALID_PASSWORD",
)
url = urljoin(
base_host(request=request),
"accounts/reset-password?"
+ urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
# set_password also hashes the password that the user will get
user.set_password(password)
user.is_password_autoset = False
user.save()
url = urljoin(
base_host(request=request),
"accounts/sign-in?" + urlencode({"success", True}),
)
return HttpResponseRedirect(url)
except DjangoUnicodeDecodeError:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"EXPIRED_PASSWORD_TOKEN"
],
error_message="EXPIRED_PASSWORD_TOKEN",
)
url = urljoin(
base_host(request=request),
"accounts/reset-password?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
class ChangePasswordEndpoint(APIView):
def post(self, request):
serializer = ChangePasswordSerializer(data=request.data)
user = User.objects.get(pk=request.user.id)
if serializer.is_valid():
if not user.check_password(serializer.data.get("old_password")):
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"INCORRECT_OLD_PASSWORD"
],
error_message="INCORRECT_OLD_PASSWORD",
payload={"error": "Old password is not correct"},
)
return Response(
exc.get_error_dict(),
status=status.HTTP_400_BAD_REQUEST,
)
# check the password score
results = zxcvbn(serializer.data.get("new_password"))
if results["score"] < 3:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"INVALID_NEW_PASSWORD"
],
error_message="INVALID_NEW_PASSWORD",
)
return Response(
exc.get_error_dict(),
status=status.HTTP_400_BAD_REQUEST,
)
# set_password also hashes the password that the user will get
user.set_password(serializer.data.get("new_password"))
user.is_password_autoset = False
user.save()
user_login(user=user, request=request)
return Response(
{"message": "Password updated successfully"},
status=status.HTTP_200_OK,
)
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"],
error_message="INVALID_PASSWORD",
)
return Response(
exc.get_error_dict(),
status=status.HTTP_400_BAD_REQUEST,
)
class SetUserPasswordEndpoint(APIView):
def post(self, request):
user = User.objects.get(pk=request.user.id)
password = request.data.get("password", False)
# If the user password is not autoset then return error
if not user.is_password_autoset:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["PASSWORD_ALREADY_SET"],
error_message="PASSWORD_ALREADY_SET",
payload={
"error": "Your password is already set please change your password from profile"
},
)
return Response(
exc.get_error_dict(),
status=status.HTTP_400_BAD_REQUEST,
)
# Check password validation
if not password:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"],
error_message="INVALID_PASSWORD",
)
return Response(
exc.get_error_dict(),
status=status.HTTP_400_BAD_REQUEST,
)
results = zxcvbn(password)
if results["score"] < 3:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"],
error_message="INVALID_PASSWORD",
)
return Response(
exc.get_error_dict(),
status=status.HTTP_400_BAD_REQUEST,
)
# Set the user password
user.set_password(password)
user.is_password_autoset = False
user.save()
# Login the user as the session is invalidated
user_login(user=user, request=request)
# Return the user
serializer = UserSerializer(user)
return Response(serializer.data, status=status.HTTP_200_OK)

View file

@ -0,0 +1,82 @@
# Django imports
from django.core.validators import validate_email
from django.core.exceptions import ValidationError
# 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.db.models import User
from plane.license.models import Instance
from plane.authentication.adapter.error import (
AUTHENTICATION_ERROR_CODES,
AuthenticationException,
)
class EmailCheckEndpoint(APIView):
permission_classes = [
AllowAny,
]
def post(self, request):
# Check instance configuration
instance = Instance.objects.first()
if instance is None or not instance.is_setup_done:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"INSTANCE_NOT_CONFIGURED"
],
error_message="INSTANCE_NOT_CONFIGURED",
)
return Response(
exc.get_error_dict(),
status=status.HTTP_400_BAD_REQUEST,
)
email = request.data.get("email", False)
# Return error if email is not present
if not email:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["EMAIL_REQUIRED"],
error_message="EMAIL_REQUIRED",
)
return Response(
exc.get_error_dict(),
status=status.HTTP_400_BAD_REQUEST,
)
# Validate email
try:
validate_email(email)
except ValidationError:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"],
error_message="INVALID_EMAIL",
)
return Response(
exc.get_error_dict(),
status=status.HTTP_400_BAD_REQUEST,
)
# Check if a user already exists with the given email
existing_user = User.objects.filter(email=email).first()
# If existing user
if existing_user:
return Response(
{
"existing": True,
"is_password_autoset": existing_user.is_password_autoset,
},
status=status.HTTP_200_OK,
)
# Else return response
return Response(
{"existing": False, "is_password_autoset": False},
status=status.HTTP_200_OK,
)

View file

@ -0,0 +1,224 @@
# 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.login import user_login
from plane.license.models import Instance
from plane.authentication.utils.host import base_host
from plane.db.models import User
from plane.authentication.adapter.error import (
AUTHENTICATION_ERROR_CODES,
AuthenticationException,
)
class SignInAuthSpaceEndpoint(View):
def post(self, request):
next_path = request.POST.get("next_path")
# 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()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host(request=request),
"accounts/sign-in?" + urlencode(params),
)
return HttpResponseRedirect(url)
# set the referer 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:
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()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host(request=request),
"spaces/accounts/sign-in?" + 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()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host(request=request),
"spaces/accounts/sign-in?" + urlencode(params),
)
return HttpResponseRedirect(url)
if not User.objects.filter(email=email).exists():
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()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host(request=request),
"spaces/accounts/sign-in?" + urlencode(params),
)
return HttpResponseRedirect(url)
try:
provider = EmailProvider(
request=request, key=email, code=password, is_signup=False
)
user = provider.authenticate()
# Login the user and record his device info
user_login(request=request, user=user)
# redirect to next path
url = urljoin(
base_host(request=request),
str(next_path) if next_path else "/",
)
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host(request=request),
"spaces/accounts/sign-in?" + urlencode(params),
)
return HttpResponseRedirect(url)
class SignUpAuthSpaceEndpoint(View):
def post(self, request):
next_path = request.POST.get("next_path")
# 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()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host(request=request),
"spaces?" + urlencode(params),
)
return HttpResponseRedirect(url)
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_UP"
],
error_message="REQUIRED_EMAIL_PASSWORD_SIGN_UP",
payload={"email": str(email)},
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host(request=request),
"spaces?" + urlencode(params),
)
return HttpResponseRedirect(url)
# Validate the email
email = email.strip().lower()
try:
validate_email(email)
except ValidationError:
# Redirection params
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL_SIGN_UP"],
error_message="INVALID_EMAIL_SIGN_UP",
payload={"email": str(email)},
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host(request=request),
"spaces?" + urlencode(params),
)
return HttpResponseRedirect(url)
if User.objects.filter(email=email).exists():
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["USER_ALREADY_EXIST"],
error_message="USER_ALREADY_EXIST",
payload={"email": str(email)},
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host(request=request),
"spaces?" + urlencode(params),
)
return HttpResponseRedirect(url)
try:
provider = EmailProvider(
request=request, key=email, code=password, is_signup=True
)
user = provider.authenticate()
# Login the user and record his device info
user_login(request=request, user=user)
# redirect to referer path
url = urljoin(
base_host(request=request),
str(next_path) if next_path else "spaces",
)
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host(request=request),
"spaces?" + urlencode(params),
)
return HttpResponseRedirect(url)

View file

@ -0,0 +1,125 @@
# Python imports
import uuid
from urllib.parse import urlencode, urljoin
# Django import
from django.core.exceptions import ImproperlyConfigured
from django.http import HttpResponseRedirect
from django.views import View
# Module imports
from plane.authentication.provider.oauth.github import GitHubOAuthProvider
from plane.authentication.utils.login import user_login
from plane.license.models import Instance
from plane.authentication.utils.host import base_host
from plane.authentication.adapter.error import (
AUTHENTICATION_ERROR_CODES,
AuthenticationException,
)
class GitHubOauthInitiateSpaceEndpoint(View):
def get(self, request):
# Get host and next path
request.session["host"] = base_host(request=request)
next_path = request.GET.get("next_path")
if next_path:
request.session["next_path"] = str(next_path)
# Check instance configuration
instance = Instance.objects.first()
if instance is None or not instance.is_setup_done:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"INSTANCE_NOT_CONFIGURED"
],
error_message="INSTANCE_NOT_CONFIGURED",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host(request=request),
"?" + urlencode(params),
)
return HttpResponseRedirect(url)
try:
state = uuid.uuid4().hex
provider = GitHubOAuthProvider(request=request, state=state)
request.session["state"] = state
auth_url = provider.get_auth_url()
return HttpResponseRedirect(auth_url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host(request=request),
"?" + urlencode(params),
)
return HttpResponseRedirect(url)
class GitHubCallbackSpaceEndpoint(View):
def get(self, request):
code = request.GET.get("code")
state = request.GET.get("state")
base_host = request.session.get("host")
next_path = request.session.get("next_path")
if state != request.session.get("state", ""):
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"GITHUB_OAUTH_PROVIDER_ERROR"
],
error_message="GITHUB_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host,
"?" + urlencode(params),
)
return HttpResponseRedirect(url)
if not code:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"GITHUB_OAUTH_PROVIDER_ERROR"
],
error_message="GITHUB_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host,
"?" + urlencode(params),
)
return HttpResponseRedirect(url)
try:
provider = GitHubOAuthProvider(
request=request,
code=code,
)
user = provider.authenticate()
# Login the user and record his device info
user_login(request=request, user=user)
# Process workspace and project invitations
# redirect to referer path
url = urljoin(base_host, str(next_path) if next_path else "/")
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host,
"?" + urlencode(params),
)
return HttpResponseRedirect(url)

View file

@ -0,0 +1,120 @@
# Python imports
import uuid
from urllib.parse import urlencode, urljoin
# Django import
from django.http import HttpResponseRedirect
from django.views import View
# Module imports
from plane.authentication.provider.oauth.google import GoogleOAuthProvider
from plane.authentication.utils.login import user_login
from plane.license.models import Instance
from plane.authentication.utils.host import base_host
from plane.authentication.adapter.error import (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
)
class GoogleOauthInitiateSpaceEndpoint(View):
def get(self, request):
request.session["host"] = base_host(request=request)
next_path = request.GET.get("next_path")
if next_path:
request.session["next_path"] = str(next_path)
# Check instance configuration
instance = Instance.objects.first()
if instance is None or not instance.is_setup_done:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"INSTANCE_NOT_CONFIGURED"
],
error_message="INSTANCE_NOT_CONFIGURED",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host(request=request),
"?" + urlencode(params),
)
return HttpResponseRedirect(url)
try:
state = uuid.uuid4().hex
provider = GoogleOAuthProvider(request=request, state=state)
request.session["state"] = state
auth_url = provider.get_auth_url()
return HttpResponseRedirect(auth_url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host(request=request),
"?" + urlencode(params),
)
return HttpResponseRedirect(url)
class GoogleCallbackSpaceEndpoint(View):
def get(self, request):
code = request.GET.get("code")
state = request.GET.get("state")
base_host = request.session.get("host")
next_path = request.session.get("next_path")
if state != request.session.get("state", ""):
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"GOOGLE_OAUTH_PROVIDER_ERROR"
],
error_message="GOOGLE_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host,
"?" + urlencode(params),
)
return HttpResponseRedirect(url)
if not code:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"GOOGLE_OAUTH_PROVIDER_ERROR"
],
error_message="GOOGLE_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = next_path
url = urljoin(
base_host,
"?" + urlencode(params),
)
return HttpResponseRedirect(url)
try:
provider = GoogleOAuthProvider(
request=request,
code=code,
)
user = provider.authenticate()
# Login the user and record his device info
user_login(request=request, user=user)
# redirect to referer path
url = urljoin(
base_host, str(next_path) if next_path else "/spaces"
)
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host,
"?" + urlencode(params),
)
return HttpResponseRedirect(url)

View file

@ -0,0 +1,196 @@
# Python imports
from urllib.parse import urlencode, urljoin
# Django imports
from django.core.exceptions import ImproperlyConfigured, ValidationError
from django.core.validators import validate_email
from django.http import HttpResponseRedirect
from django.views import View
# 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.provider.credentials.magic_code import (
MagicCodeProvider,
)
from plane.authentication.utils.login import user_login
from plane.bgtasks.magic_link_code_task import magic_link
from plane.license.models import Instance
from plane.authentication.utils.host import base_host
from plane.db.models import User, Profile
from plane.authentication.adapter.error import (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
)
class MagicGenerateSpaceEndpoint(APIView):
permission_classes = [
AllowAny,
]
def post(self, request):
# Check if instance is configured
instance = Instance.objects.first()
if instance is None or not instance.is_setup_done:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"INSTANCE_NOT_CONFIGURED"
],
error_message="INSTANCE_NOT_CONFIGURED",
)
return Response(
exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST
)
origin = base_host(request=request)
email = request.data.get("email", False)
try:
# Clean up the email
email = email.strip().lower()
validate_email(email)
adapter = MagicCodeProvider(request=request, key=email)
key, token = adapter.initiate()
# If the smtp is configured send through here
magic_link.delay(email, key, token, origin)
return Response({"key": str(key)}, status=status.HTTP_200_OK)
except AuthenticationException as e:
return Response(
e.get_error_dict(),
status=status.HTTP_400_BAD_REQUEST,
)
class MagicSignInSpaceEndpoint(View):
def post(self, request):
# set the referer as session to redirect after login
code = request.POST.get("code", "").strip()
email = request.POST.get("email", "").strip().lower()
next_path = request.POST.get("next_path")
if code == "" or email == "":
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()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host(request=request),
"spaces/accounts/sign-in?" + urlencode(params),
)
return HttpResponseRedirect(url)
if not User.objects.filter(email=email).exists():
params = {
"error_code": "USER_DOES_NOT_EXIST",
"error_message": "User could not be found with the given email.",
}
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host(request=request),
"accounts/sign-in?" + urlencode(params),
)
return HttpResponseRedirect(url)
try:
provider = MagicCodeProvider(
request=request, key=f"magic_{email}", code=code
)
user = provider.authenticate()
# Login the user and record his device info
user_login(request=request, user=user)
# redirect to referer path
profile = Profile.objects.get(user=user)
if user.is_password_autoset and profile.is_onboarded:
path = "spaces/accounts/set-password"
else:
# Get the redirection path
path = str(next_path) if next_path else "spaces"
url = urljoin(base_host(request=request), path)
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host(request=request),
"spaces/accounts/sign-in?" + urlencode(params),
)
return HttpResponseRedirect(url)
class MagicSignUpSpaceEndpoint(View):
def post(self, request):
# set the referer as session to redirect after login
code = request.POST.get("code", "").strip()
email = request.POST.get("email", "").strip().lower()
next_path = request.POST.get("next_path")
if code == "" or email == "":
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED"
],
error_message="MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host(request=request),
"spaces/accounts/sign-in?" + urlencode(params),
)
return HttpResponseRedirect(url)
if User.objects.filter(email=email).exists():
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["USER_ALREADY_EXIST"],
error_message="USER_ALREADY_EXIST",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host(request=request),
"?" + urlencode(params),
)
return HttpResponseRedirect(url)
try:
provider = MagicCodeProvider(
request=request, key=f"magic_{email}", code=code
)
user = provider.authenticate()
# Login the user and record his device info
user_login(request=request, user=user)
# redirect to referer path
url = urljoin(
base_host(request=request),
str(next_path) if next_path else "spaces",
)
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host(request=request),
"spaces/accounts/sign-in?" + urlencode(params),
)
return HttpResponseRedirect(url)

View file

@ -0,0 +1,34 @@
# Python imports
from urllib.parse import urlencode, urljoin
# Django imports
from django.views import View
from django.contrib.auth import logout
from django.http import HttpResponseRedirect
from django.utils import timezone
# Module imports
from plane.authentication.utils.host import base_host, user_ip
from plane.db.models import User
class SignOutAuthSpaceEndpoint(View):
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()
# Log the user out
logout(request)
url = urljoin(
base_host(request=request),
"accounts/sign-in?" + urlencode({"success": "true"}),
)
return HttpResponseRedirect(url)
except Exception:
return HttpResponseRedirect(
base_host(request=request), "accounts/sign-in"
)

View file

@ -5,6 +5,7 @@ import logging
from celery import shared_task
# Django imports
# Third party imports
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
from django.utils.html import strip_tags

View file

@ -5,6 +5,7 @@ import logging
from celery import shared_task
# Django imports
# Third party imports
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
from django.utils.html import strip_tags

View file

@ -2,7 +2,10 @@
import getpass
# Django imports
from django.core.management import BaseCommand
from django.core.management import BaseCommand, CommandError
# Third party imports
from zxcvbn import zxcvbn
# Module imports
from plane.db.models import User
@ -46,6 +49,13 @@ class Command(BaseCommand):
self.stderr.write("Error: Blank passwords aren't allowed.")
return
results = zxcvbn(password)
if results["score"] < 3:
raise CommandError(
"Password is too common please set a complex password"
)
# Set user password
user.set_password(password)
user.is_password_autoset = False

View file

@ -0,0 +1,270 @@
# Generated by Django 4.2.10 on 2024-04-04 08:47
import uuid
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
import plane.db.models.user
def migrate_user_profile(apps, schema_editor):
Profile = apps.get_model("db", "Profile")
User = apps.get_model("db", "User")
Profile.objects.bulk_create(
[
Profile(
user_id=user.get("id"),
theme=user.get("theme"),
is_tour_completed=user.get("is_tour_completed"),
use_case=user.get("use_case"),
is_onboarded=user.get("is_onboarded"),
last_workspace_id=user.get("last_workspace_id"),
billing_address_country=user.get("billing_address_country"),
billing_address=user.get("billing_address"),
has_billing_address=user.get("has_billing_address"),
)
for user in User.objects.values(
"id",
"theme",
"is_tour_completed",
"onboarding_step",
"use_case",
"role",
"is_onboarded",
"last_workspace_id",
"billing_address_country",
"billing_address",
"has_billing_address",
)
],
batch_size=1000,
)
class Migration(migrations.Migration):
dependencies = [
("db", "0064_auto_20240409_1134"),
]
operations = [
migrations.AlterField(
model_name="user",
name="avatar",
field=models.TextField(blank=True),
),
migrations.CreateModel(
name="Session",
fields=[
(
"session_data",
models.TextField(verbose_name="session data"),
),
(
"expire_date",
models.DateTimeField(
db_index=True, verbose_name="expire date"
),
),
(
"device_info",
models.JSONField(blank=True, default=None, null=True),
),
(
"session_key",
models.CharField(
max_length=128, primary_key=True, serialize=False
),
),
("user_id", models.CharField(max_length=50, null=True)),
],
options={
"verbose_name": "session",
"verbose_name_plural": "sessions",
"db_table": "sessions",
"abstract": False,
},
),
migrations.CreateModel(
name="Profile",
fields=[
(
"created_at",
models.DateTimeField(
auto_now_add=True, verbose_name="Created At"
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True, verbose_name="Last Modified At"
),
),
(
"id",
models.UUIDField(
db_index=True,
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
unique=True,
),
),
("theme", models.JSONField(default=dict)),
("is_tour_completed", models.BooleanField(default=False)),
(
"onboarding_step",
models.JSONField(
default=plane.db.models.user.get_default_onboarding
),
),
("use_case", models.TextField(blank=True, null=True)),
(
"role",
models.CharField(blank=True, max_length=300, null=True),
),
("is_onboarded", models.BooleanField(default=False)),
("last_workspace_id", models.UUIDField(null=True)),
(
"billing_address_country",
models.CharField(default="INDIA", max_length=255),
),
("billing_address", models.JSONField(null=True)),
("has_billing_address", models.BooleanField(default=False)),
("company_name", models.CharField(blank=True, max_length=255)),
(
"user",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="profile",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Profile",
"verbose_name_plural": "Profiles",
"db_table": "profiles",
"ordering": ("-created_at",),
},
),
migrations.CreateModel(
name="Account",
fields=[
(
"created_at",
models.DateTimeField(
auto_now_add=True, verbose_name="Created At"
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True, verbose_name="Last Modified At"
),
),
(
"id",
models.UUIDField(
db_index=True,
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
unique=True,
),
),
("provider_account_id", models.CharField(max_length=255)),
(
"provider",
models.CharField(
choices=[("google", "Google"), ("github", "Github")]
),
),
("access_token", models.TextField()),
("access_token_expired_at", models.DateTimeField(null=True)),
("refresh_token", models.TextField(blank=True, null=True)),
("refresh_token_expired_at", models.DateTimeField(null=True)),
(
"last_connected_at",
models.DateTimeField(default=django.utils.timezone.now),
),
("metadata", models.JSONField(default=dict)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="accounts",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Account",
"verbose_name_plural": "Accounts",
"db_table": "accounts",
"ordering": ("-created_at",),
"unique_together": {("provider", "provider_account_id")},
},
),
migrations.RunPython(migrate_user_profile),
migrations.RemoveField(
model_name="user",
name="billing_address",
),
migrations.RemoveField(
model_name="user",
name="billing_address_country",
),
migrations.RemoveField(
model_name="user",
name="has_billing_address",
),
migrations.RemoveField(
model_name="user",
name="is_onboarded",
),
migrations.RemoveField(
model_name="user",
name="is_tour_completed",
),
migrations.RemoveField(
model_name="user",
name="last_workspace_id",
),
migrations.RemoveField(
model_name="user",
name="my_issues_prop",
),
migrations.RemoveField(
model_name="user",
name="onboarding_step",
),
migrations.RemoveField(
model_name="user",
name="role",
),
migrations.RemoveField(
model_name="user",
name="theme",
),
migrations.RemoveField(
model_name="user",
name="use_case",
),
migrations.AddField(
model_name="globalview",
name="logo_props",
field=models.JSONField(default=dict),
),
migrations.AddField(
model_name="page",
name="logo_props",
field=models.JSONField(default=dict),
),
]

View file

@ -1,78 +1,80 @@
from .base import BaseModel
from .user import User
from .workspace import (
Workspace,
WorkspaceMember,
Team,
WorkspaceMemberInvite,
TeamMember,
WorkspaceTheme,
WorkspaceUserProperties,
WorkspaceBaseModel,
)
from .project import (
Project,
ProjectMember,
ProjectBaseModel,
ProjectMemberInvite,
ProjectIdentifier,
ProjectFavorite,
ProjectDeployBoard,
ProjectPublicMember,
)
from .issue import (
Issue,
IssueActivity,
IssueProperty,
IssueComment,
IssueLabel,
IssueAssignee,
Label,
IssueBlocker,
IssueRelation,
IssueMention,
IssueLink,
IssueSequence,
IssueAttachment,
IssueSubscriber,
IssueReaction,
CommentReaction,
IssueVote,
)
from .analytic import AnalyticView
from .api import APIActivityLog, APIToken
from .asset import FileAsset
from .social_connection import SocialLoginConnection
from .state import State
from .cycle import Cycle, CycleIssue, CycleFavorite, CycleUserProperties
from .view import GlobalView, IssueView, IssueViewFavorite
from .module import (
Module,
ModuleMember,
ModuleIssue,
ModuleLink,
ModuleFavorite,
ModuleUserProperties,
)
from .api import APIToken, APIActivityLog
from .base import BaseModel
from .cycle import Cycle, CycleFavorite, CycleIssue, CycleUserProperties
from .dashboard import Dashboard, DashboardWidget, Widget
from .estimate import Estimate, EstimatePoint
from .exporter import ExporterHistory
from .importer import Importer
from .inbox import Inbox, InboxIssue
from .integration import (
WorkspaceIntegration,
Integration,
GithubCommentSync,
GithubIssueSync,
GithubRepository,
GithubRepositorySync,
GithubIssueSync,
GithubCommentSync,
Integration,
SlackProjectSync,
WorkspaceIntegration,
)
from .issue import (
CommentReaction,
Issue,
IssueActivity,
IssueAssignee,
IssueAttachment,
IssueBlocker,
IssueComment,
IssueLabel,
IssueLink,
IssueMention,
IssueProperty,
IssueReaction,
IssueRelation,
IssueSequence,
IssueSubscriber,
IssueVote,
Label,
)
from .module import (
Module,
ModuleFavorite,
ModuleIssue,
ModuleLink,
ModuleMember,
ModuleUserProperties,
)
from .notification import (
EmailNotificationLog,
Notification,
UserNotificationPreference,
)
from .page import Page, PageFavorite, PageLabel, PageLog
from .project import (
Project,
ProjectBaseModel,
ProjectDeployBoard,
ProjectFavorite,
ProjectIdentifier,
ProjectMember,
ProjectMemberInvite,
ProjectPublicMember,
)
from .session import Session
from .social_connection import SocialLoginConnection
from .state import State
from .user import Account, Profile, User
from .view import GlobalView, IssueView, IssueViewFavorite
from .webhook import Webhook, WebhookLog
from .workspace import (
Team,
TeamMember,
Workspace,
WorkspaceBaseModel,
WorkspaceMember,
WorkspaceMemberInvite,
WorkspaceTheme,
WorkspaceUserProperties,
)
from .importer import Importer

View file

@ -1,13 +1,14 @@
# Python imports
from uuid import uuid4
from django.conf import settings
from django.core.exceptions import ValidationError
# Django import
from django.db import models
from django.core.exceptions import ValidationError
from django.conf import settings
# Module import
from . import BaseModel
from .base import BaseModel
def get_upload_path(instance, filename):

View file

@ -1,9 +1,9 @@
# Django imports
from django.db import models
from django.conf import settings
from django.db import models
# Module imports
from . import ProjectBaseModel
from .project import ProjectBaseModel
def get_default_filters():

View file

@ -4,8 +4,8 @@ import uuid
from django.db import models
# Module imports
from . import BaseModel
from ..mixins import TimeAuditModel
from .base import BaseModel
class Dashboard(BaseModel):

View file

@ -1,9 +1,9 @@
# Django imports
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.core.validators import MinValueValidator, MaxValueValidator
# Module imports
from . import ProjectBaseModel
from .project import ProjectBaseModel
class Estimate(ProjectBaseModel):

View file

@ -3,13 +3,14 @@ import uuid
# Python imports
from uuid import uuid4
# Django imports
from django.db import models
from django.conf import settings
from django.contrib.postgres.fields import ArrayField
# Django imports
from django.db import models
# Module imports
from . import BaseModel
from .base import BaseModel
def generate_token():

View file

@ -1,9 +1,9 @@
# Django imports
from django.db import models
from django.conf import settings
from django.db import models
# Module imports
from . import ProjectBaseModel
from .project import ProjectBaseModel
class Importer(ProjectBaseModel):

View file

@ -2,7 +2,7 @@
from django.db import models
# Module imports
from plane.db.models import ProjectBaseModel
from plane.db.models.project import ProjectBaseModel
class Inbox(ProjectBaseModel):

View file

@ -4,7 +4,7 @@
from django.db import models
# Module imports
from plane.db.models import ProjectBaseModel
from plane.db.models.project import ProjectBaseModel
class GithubRepository(ProjectBaseModel):

View file

@ -4,7 +4,7 @@
from django.db import models
# Module imports
from plane.db.models import ProjectBaseModel
from plane.db.models.project import ProjectBaseModel
class SlackProjectSync(ProjectBaseModel):

View file

@ -2,19 +2,20 @@
from uuid import uuid4
# Django imports
from django.contrib.postgres.fields import ArrayField
from django.db import models
from django.conf import settings
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.core.validators import MinValueValidator, MaxValueValidator
from django.core.exceptions import ValidationError
from django.utils import timezone
# Module imports
from . import ProjectBaseModel
from plane.utils.html_processor import strip_tags
from .project import ProjectBaseModel
def get_default_properties():
return {

View file

@ -1,9 +1,9 @@
# Django imports
from django.db import models
from django.conf import settings
from django.db import models
# Module imports
from . import ProjectBaseModel
from .project import ProjectBaseModel
def get_default_filters():

View file

@ -1,9 +1,10 @@
# Django imports
from django.db import models
from django.conf import settings
from django.db import models
# Module imports
from . import BaseModel
from .base import BaseModel
class Notification(BaseModel):

View file

@ -1,13 +1,15 @@
import uuid
from django.conf import settings
# Django imports
from django.db import models
from django.conf import settings
# Module imports
from . import ProjectBaseModel
from plane.utils.html_processor import strip_tags
from .project import ProjectBaseModel
def get_view_props():
return {"full_width": False}
@ -40,6 +42,7 @@ class Page(ProjectBaseModel):
archived_at = models.DateField(null=True)
is_locked = models.BooleanField(default=False)
view_props = models.JSONField(default=get_view_props)
logo_props = models.JSONField(default=dict)
class Meta:
verbose_name = "Page"
@ -121,7 +124,7 @@ class PageBlock(ProjectBaseModel):
if self.completed_at and self.issue:
try:
from plane.db.models import State, Issue
from plane.db.models import Issue, State
completed_state = State.objects.filter(
group="completed", project=self.project

View file

@ -2,15 +2,15 @@
from uuid import uuid4
# Django imports
from django.db import models
from django.conf import settings
from django.core.validators import MinValueValidator, MaxValueValidator
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
# Modeule imports
from plane.db.mixins import AuditModel
# Module imports
from . import BaseModel
from .base import BaseModel
ROLE_CHOICES = (
(20, "Admin"),

View file

@ -0,0 +1,65 @@
# Python imports
import string
# Django imports
from django.contrib.sessions.backends.db import SessionStore as DBSessionStore
from django.contrib.sessions.base_session import AbstractBaseSession
from django.db import models
from django.utils.crypto import get_random_string
VALID_KEY_CHARS = string.ascii_lowercase + string.digits
class Session(AbstractBaseSession):
device_info = models.JSONField(
null=True,
blank=True,
default=None,
)
session_key = models.CharField(
max_length=128,
primary_key=True,
)
user_id = models.CharField(
null=True,
max_length=50,
)
@classmethod
def get_session_store_class(cls):
return SessionStore
class Meta(AbstractBaseSession.Meta):
db_table = "sessions"
class SessionStore(DBSessionStore):
@classmethod
def get_model_class(cls):
return Session
def _get_new_session_key(self):
"""
Return a new session key that is not present in the current backend.
Override this method to use a custom session key generation mechanism.
"""
while True:
session_key = get_random_string(128, VALID_KEY_CHARS)
if not self.exists(session_key):
return session_key
def create_model_instance(self, data):
obj = super().create_model_instance(data)
try:
user_id = data.get("_auth_user_id")
except (ValueError, TypeError):
user_id = None
obj.user_id = user_id
# Save the device info
device_info = data.get("device_info")
obj.device_info = (
device_info if isinstance(device_info, dict) else None
)
return obj

View file

@ -1,10 +1,10 @@
# Django imports
from django.db import models
from django.conf import settings
from django.db import models
from django.utils import timezone
# Module import
from . import BaseModel
from .base import BaseModel
class SocialLoginConnection(BaseModel):

View file

@ -3,7 +3,7 @@ from django.db import models
from django.template.defaultfilters import slugify
# Module imports
from . import ProjectBaseModel
from .project import ProjectBaseModel
class State(ProjectBaseModel):

View file

@ -16,6 +16,9 @@ from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils import timezone
# Module imports
from ..mixins import TimeAuditModel
def get_default_onboarding():
return {
@ -35,15 +38,17 @@ class User(AbstractBaseUser, PermissionsMixin):
primary_key=True,
)
username = models.CharField(max_length=128, unique=True)
# user fields
mobile_number = models.CharField(max_length=255, blank=True, null=True)
email = models.CharField(
max_length=255, null=True, blank=True, unique=True
)
# identity
display_name = models.CharField(max_length=255, default="")
first_name = models.CharField(max_length=255, blank=True)
last_name = models.CharField(max_length=255, blank=True)
avatar = models.CharField(max_length=255, blank=True)
avatar = models.TextField(blank=True)
cover_image = models.URLField(blank=True, null=True, max_length=800)
# tracking metrics
@ -67,19 +72,10 @@ class User(AbstractBaseUser, PermissionsMixin):
is_staff = models.BooleanField(default=False)
is_email_verified = models.BooleanField(default=False)
is_password_autoset = models.BooleanField(default=False)
is_onboarded = models.BooleanField(default=False)
# random token generated
token = models.CharField(max_length=64, blank=True)
billing_address_country = models.CharField(max_length=255, default="INDIA")
billing_address = models.JSONField(null=True)
has_billing_address = models.BooleanField(default=False)
USER_TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones))
user_timezone = models.CharField(
max_length=255, default="UTC", choices=USER_TIMEZONE_CHOICES
)
last_active = models.DateTimeField(default=timezone.now, null=True)
last_login_time = models.DateTimeField(null=True)
last_logout_time = models.DateTimeField(null=True)
@ -91,18 +87,17 @@ class User(AbstractBaseUser, PermissionsMixin):
)
last_login_uagent = models.TextField(blank=True)
token_updated_at = models.DateTimeField(null=True)
last_workspace_id = models.UUIDField(null=True)
my_issues_prop = models.JSONField(null=True)
role = models.CharField(max_length=300, null=True, blank=True)
# my_issues_prop = models.JSONField(null=True)
is_bot = models.BooleanField(default=False)
theme = models.JSONField(default=dict)
display_name = models.CharField(max_length=255, default="")
is_tour_completed = models.BooleanField(default=False)
onboarding_step = models.JSONField(default=get_default_onboarding)
use_case = models.TextField(blank=True, null=True)
# timezone
USER_TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones))
user_timezone = models.CharField(
max_length=255, default="UTC", choices=USER_TIMEZONE_CHOICES
)
USERNAME_FIELD = "email"
REQUIRED_FIELDS = ["username"]
objects = UserManager()
@ -139,6 +134,71 @@ class User(AbstractBaseUser, PermissionsMixin):
super(User, self).save(*args, **kwargs)
class Profile(TimeAuditModel):
id = models.UUIDField(
default=uuid.uuid4,
unique=True,
editable=False,
db_index=True,
primary_key=True,
)
# User
user = models.OneToOneField(
"db.User", on_delete=models.CASCADE, related_name="profile"
)
# General
theme = models.JSONField(default=dict)
# Onboarding
is_tour_completed = models.BooleanField(default=False)
onboarding_step = models.JSONField(default=get_default_onboarding)
use_case = models.TextField(blank=True, null=True)
role = models.CharField(max_length=300, null=True, blank=True) # job role
is_onboarded = models.BooleanField(default=False)
# Last visited workspace
last_workspace_id = models.UUIDField(null=True)
# address data
billing_address_country = models.CharField(max_length=255, default="INDIA")
billing_address = models.JSONField(null=True)
has_billing_address = models.BooleanField(default=False)
company_name = models.CharField(max_length=255, blank=True)
class Meta:
verbose_name = "Profile"
verbose_name_plural = "Profiles"
db_table = "profiles"
ordering = ("-created_at",)
class Account(TimeAuditModel):
id = models.UUIDField(
default=uuid.uuid4,
unique=True,
editable=False,
db_index=True,
primary_key=True,
)
user = models.ForeignKey(
"db.User", on_delete=models.CASCADE, related_name="accounts"
)
provider_account_id = models.CharField(max_length=255)
provider = models.CharField(
choices=(("google", "Google"), ("github", "Github")),
)
access_token = models.TextField()
access_token_expired_at = models.DateTimeField(null=True)
refresh_token = models.TextField(null=True, blank=True)
refresh_token_expired_at = models.DateTimeField(null=True)
last_connected_at = models.DateTimeField(default=timezone.now)
metadata = models.JSONField(default=dict)
class Meta:
unique_together = ["provider", "provider_account_id"]
verbose_name = "Account"
verbose_name_plural = "Accounts"
db_table = "accounts"
ordering = ("-created_at",)
@receiver(post_save, sender=User)
def create_user_notification(sender, instance, created, **kwargs):
# create preferences

View file

@ -1,9 +1,11 @@
# Django imports
from django.db import models
from django.conf import settings
from django.db import models
# Module import
from . import ProjectBaseModel, BaseModel, WorkspaceBaseModel
from .base import BaseModel
from .project import ProjectBaseModel
from .workspace import WorkspaceBaseModel
def get_default_filters():
@ -62,6 +64,7 @@ class GlobalView(BaseModel):
)
query_data = models.JSONField(default=dict)
sort_order = models.FloatField(default=65535)
logo_props = models.JSONField(default=dict)
class Meta:
verbose_name = "Global View"
@ -84,6 +87,7 @@ class GlobalView(BaseModel):
return f"{self.name} <{self.workspace.name}>"
# DEPRECATED TODO: - Remove in next release
class IssueView(WorkspaceBaseModel):
name = models.CharField(max_length=255, verbose_name="View Name")
description = models.TextField(verbose_name="View Description", blank=True)

View file

@ -1,11 +1,10 @@
# Django imports
from django.db import models
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
# Module imports
from . import BaseModel
from .base import BaseModel
ROLE_CHOICES = (
(20, "Owner"),

View file

@ -1,5 +1,6 @@
from .instance import (
InstanceSerializer,
InstanceAdminSerializer,
InstanceConfigurationSerializer,
)
from .configuration import InstanceConfigurationSerializer
from .admin import InstanceAdminSerializer, InstanceAdminMeSerializer

View file

@ -0,0 +1,41 @@
# Module imports
from .base import BaseSerializer
from plane.db.models import User
from plane.app.serializers import UserAdminLiteSerializer
from plane.license.models import InstanceAdmin
class InstanceAdminMeSerializer(BaseSerializer):
class Meta:
model = User
fields = [
"id",
"avatar",
"cover_image",
"date_joined",
"display_name",
"email",
"first_name",
"last_name",
"is_active",
"is_bot",
"is_email_verified",
"user_timezone",
"username",
"is_password_autoset",
"is_email_verified",
]
read_only_fields = fields
class InstanceAdminSerializer(BaseSerializer):
user_detail = UserAdminLiteSerializer(source="user", read_only=True)
class Meta:
model = InstanceAdmin
fields = "__all__"
read_only_fields = [
"id",
"instance",
"user",
]

View file

@ -0,0 +1,5 @@
from rest_framework import serializers
class BaseSerializer(serializers.ModelSerializer):
id = serializers.PrimaryKeyRelatedField(read_only=True)

View file

@ -0,0 +1,17 @@
from .base import BaseSerializer
from plane.license.models import InstanceConfiguration
from plane.license.utils.encryption import decrypt_data
class InstanceConfigurationSerializer(BaseSerializer):
class Meta:
model = InstanceConfiguration
fields = "__all__"
def to_representation(self, instance):
data = super().to_representation(instance)
# Decrypt secrets value
if instance.is_encrypted and instance.value is not None:
data["value"] = decrypt_data(instance.value)
return data

View file

@ -1,8 +1,7 @@
# Module imports
from plane.license.models import Instance, InstanceAdmin, InstanceConfiguration
from plane.license.models import Instance
from plane.app.serializers import BaseSerializer
from plane.app.serializers import UserAdminLiteSerializer
from plane.license.utils.encryption import decrypt_data
class InstanceSerializer(BaseSerializer):
@ -23,30 +22,3 @@ class InstanceSerializer(BaseSerializer):
"last_checked_at",
"is_setup_done",
]
class InstanceAdminSerializer(BaseSerializer):
user_detail = UserAdminLiteSerializer(source="user", read_only=True)
class Meta:
model = InstanceAdmin
fields = "__all__"
read_only_fields = [
"id",
"instance",
"user",
]
class InstanceConfigurationSerializer(BaseSerializer):
class Meta:
model = InstanceConfiguration
fields = "__all__"
def to_representation(self, instance):
data = super().to_representation(instance)
# Decrypt secrets value
if instance.is_encrypted and instance.value is not None:
data["value"] = decrypt_data(instance.value)
return data

View file

@ -1,7 +1,19 @@
from .instance import (
InstanceEndpoint,
InstanceAdminEndpoint,
InstanceConfigurationEndpoint,
InstanceAdminSignInEndpoint,
SignUpScreenVisitedEndpoint,
)
from .configuration import (
EmailCredentialCheckEndpoint,
InstanceConfigurationEndpoint,
)
from .admin import (
InstanceAdminEndpoint,
InstanceAdminSignInEndpoint,
InstanceAdminSignUpEndpoint,
InstanceAdminUserMeEndpoint,
InstanceAdminSignOutEndpoint,
)

View file

@ -0,0 +1,421 @@
# Python imports
from urllib.parse import urlencode, urljoin
import uuid
from zxcvbn import zxcvbn
# Django imports
from django.http import HttpResponseRedirect
from django.views import View
from django.core.validators import validate_email
from django.core.exceptions import ValidationError
from django.utils import timezone
from django.contrib.auth.hashers import make_password
from django.contrib.auth import logout
# Third party imports
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import AllowAny
# Module imports
from .base import BaseAPIView
from plane.license.api.permissions import InstanceAdminPermission
from plane.license.api.serializers import (
InstanceAdminMeSerializer,
InstanceAdminSerializer,
)
from plane.license.models import Instance, InstanceAdmin
from plane.db.models import User, Profile
from plane.utils.cache import cache_response, invalidate_cache
from plane.authentication.utils.login import user_login
from plane.authentication.utils.host import base_host, user_ip
from plane.authentication.adapter.error import (
AUTHENTICATION_ERROR_CODES,
AuthenticationException,
)
class InstanceAdminEndpoint(BaseAPIView):
permission_classes = [
InstanceAdminPermission,
]
@invalidate_cache(path="/api/instances/", user=False)
# Create an instance admin
def post(self, request):
email = request.data.get("email", False)
role = request.data.get("role", 20)
if not email:
return Response(
{"error": "Email is required"},
status=status.HTTP_400_BAD_REQUEST,
)
instance = Instance.objects.first()
if instance is None:
return Response(
{"error": "Instance is not registered yet"},
status=status.HTTP_403_FORBIDDEN,
)
# Fetch the user
user = User.objects.get(email=email)
instance_admin = InstanceAdmin.objects.create(
instance=instance,
user=user,
role=role,
)
serializer = InstanceAdminSerializer(instance_admin)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@cache_response(60 * 60 * 2, user=False)
def get(self, request):
instance = Instance.objects.first()
if instance is None:
return Response(
{"error": "Instance is not registered yet"},
status=status.HTTP_403_FORBIDDEN,
)
instance_admins = InstanceAdmin.objects.filter(instance=instance)
serializer = InstanceAdminSerializer(instance_admins, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@invalidate_cache(path="/api/instances/", user=False)
def delete(self, request, pk):
instance = Instance.objects.first()
InstanceAdmin.objects.filter(instance=instance, pk=pk).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class InstanceAdminSignUpEndpoint(View):
permission_classes = [
AllowAny,
]
@invalidate_cache(path="/api/instances/", user=False)
def post(self, request):
# Check instance first
instance = Instance.objects.first()
if instance is None:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"INSTANCE_NOT_CONFIGURED"
],
error_message="INSTANCE_NOT_CONFIGURED",
)
url = urljoin(
base_host(request=request),
"god-mode/setup?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
# check if the instance has already an admin registered
if InstanceAdmin.objects.first():
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["ADMIN_ALREADY_EXIST"],
error_message="ADMIN_ALREADY_EXIST",
)
url = urljoin(
base_host(request=request),
"god-mode/setup?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
# Get the email and password from all the user
email = request.POST.get("email", False)
password = request.POST.get("password", False)
first_name = request.POST.get("first_name", False)
last_name = request.POST.get("last_name", "")
company_name = request.POST.get("company_name", "")
is_telemetry_enabled = request.POST.get("is_telemetry_enabled", True)
# return error if the email and password is not present
if not email or not password or not first_name:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME"
],
error_message="REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME",
payload={
"email": email,
"first_name": first_name,
"last_name": last_name,
"company_name": company_name,
"is_telemetry_enabled": is_telemetry_enabled,
},
)
url = urljoin(
base_host(request=request),
"god-mode/setup?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
# Validate the email
email = email.strip().lower()
try:
validate_email(email)
except ValidationError:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["INVALID_ADMIN_EMAIL"],
error_message="INVALID_ADMIN_EMAIL",
payload={
"email": email,
"first_name": first_name,
"last_name": last_name,
"company_name": company_name,
"is_telemetry_enabled": is_telemetry_enabled,
},
)
url = urljoin(
base_host(request=request),
"god-mode/setup?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
# Check if already a user exists or not
# Existing user
if User.objects.filter(email=email).exists():
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"ADMIN_USER_ALREADY_EXIST"
],
error_message="ADMIN_USER_ALREADY_EXIST",
payload={
"email": email,
"first_name": first_name,
"last_name": last_name,
"company_name": company_name,
"is_telemetry_enabled": is_telemetry_enabled,
},
)
url = urljoin(
base_host(request=request),
"god-mode/setup?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
else:
results = zxcvbn(password)
if results["score"] < 3:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"INVALID_ADMIN_PASSWORD"
],
error_message="INVALID_ADMIN_PASSWORD",
payload={
"email": email,
"first_name": first_name,
"last_name": last_name,
"company_name": company_name,
"is_telemetry_enabled": is_telemetry_enabled,
},
)
url = urljoin(
base_host(request=request),
"god-mode/setup?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
user = User.objects.create(
first_name=first_name,
last_name=last_name,
email=email,
username=uuid.uuid4().hex,
password=make_password(password),
is_password_autoset=False,
)
_ = Profile.objects.create(user=user, company_name=company_name)
# settings last active for the user
user.is_active = True
user.last_active = timezone.now()
user.last_login_time = timezone.now()
user.last_login_ip = request.META.get("REMOTE_ADDR")
user.last_login_uagent = request.META.get("HTTP_USER_AGENT")
user.token_updated_at = timezone.now()
user.save()
# Register the user as an instance admin
_ = InstanceAdmin.objects.create(
user=user,
instance=instance,
)
# Make the setup flag True
instance.is_setup_done = True
instance.is_telemetry_enabled = is_telemetry_enabled
instance.save()
# get tokens for user
user_login(request=request, user=user)
url = urljoin(base_host(request=request), "god-mode/general")
return HttpResponseRedirect(url)
class InstanceAdminSignInEndpoint(View):
permission_classes = [
AllowAny,
]
@invalidate_cache(path="/api/instances/", user=False)
def post(self, request):
# Check instance first
instance = Instance.objects.first()
if instance is None:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"INSTANCE_NOT_CONFIGURED"
],
error_message="INSTANCE_NOT_CONFIGURED",
)
url = urljoin(
base_host(request=request),
"god-mode/login?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
# Get email and password
email = request.POST.get("email", False)
password = request.POST.get("password", False)
# return error if the email and password is not present
if not email or not password:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"REQUIRED_ADMIN_EMAIL_PASSWORD"
],
error_message="REQUIRED_ADMIN_EMAIL_PASSWORD",
payload={
"email": email,
},
)
url = urljoin(
base_host(request=request),
"god-mode/login?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
# Validate the email
email = email.strip().lower()
try:
validate_email(email)
except ValidationError:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["INVALID_ADMIN_EMAIL"],
error_message="INVALID_ADMIN_EMAIL",
payload={
"email": email,
},
)
url = urljoin(
base_host(request=request),
"god-mode/login?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
# Fetch the user
user = User.objects.filter(email=email).first()
# Error out if the user is not present
if not user:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"ADMIN_USER_DOES_NOT_EXIST"
],
error_message="ADMIN_USER_DOES_NOT_EXIST",
payload={
"email": email,
},
)
url = urljoin(
base_host(request=request),
"god-mode/login?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
# Check password of the user
if not user.check_password(password):
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"ADMIN_AUTHENTICATION_FAILED"
],
error_message="ADMIN_AUTHENTICATION_FAILED",
payload={
"email": email,
},
)
url = urljoin(
base_host(request=request),
"god-mode/login?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
# Check if the user is an instance admin
if not InstanceAdmin.objects.filter(instance=instance, user=user):
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"ADMIN_AUTHENTICATION_FAILED"
],
error_message="ADMIN_AUTHENTICATION_FAILED",
payload={
"email": email,
},
)
url = urljoin(
base_host(request=request),
"god-mode/login?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
# settings last active for the user
user.is_active = True
user.last_active = timezone.now()
user.last_login_time = timezone.now()
user.last_login_ip = request.META.get("REMOTE_ADDR")
user.last_login_uagent = request.META.get("HTTP_USER_AGENT")
user.token_updated_at = timezone.now()
user.save()
# get tokens for user
user_login(request=request, user=user)
url = urljoin(base_host(request=request), "god-mode/general")
return HttpResponseRedirect(url)
class InstanceAdminUserMeEndpoint(BaseAPIView):
permission_classes = [
InstanceAdminPermission,
]
def get(self, request):
serializer = InstanceAdminMeSerializer(request.user)
return Response(
serializer.data,
status=status.HTTP_200_OK,
)
class InstanceAdminSignOutEndpoint(View):
permission_classes = [
InstanceAdminPermission,
]
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()
# Log the user out
logout(request)
url = urljoin(
base_host(request=request),
"accounts/sign-in?" + urlencode({"success": "true"}),
)
return HttpResponseRedirect(url)
except Exception:
return HttpResponseRedirect(
base_host(request=request), "accounts/sign-in"
)

View file

@ -0,0 +1,132 @@
# Python imports
import zoneinfo
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db import IntegrityError
# Django imports
from django.utils import timezone
from django_filters.rest_framework import DjangoFilterBackend
# Third part imports
from rest_framework import status
from rest_framework.filters import SearchFilter
from rest_framework.response import Response
from rest_framework.views import APIView
# Module imports
from plane.license.api.permissions import InstanceAdminPermission
from plane.authentication.session import BaseSessionAuthentication
from plane.utils.exception_logger import log_exception
from plane.utils.paginator import BasePaginator
class TimezoneMixin:
"""
This enables timezone conversion according
to the user set timezone
"""
def initial(self, request, *args, **kwargs):
super().initial(request, *args, **kwargs)
if request.user.is_authenticated:
timezone.activate(zoneinfo.ZoneInfo(request.user.user_timezone))
else:
timezone.deactivate()
class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
permission_classes = [
InstanceAdminPermission,
]
filter_backends = (
DjangoFilterBackend,
SearchFilter,
)
authentication_classes = [
BaseSessionAuthentication,
]
filterset_fields = []
search_fields = []
def filter_queryset(self, queryset):
for backend in list(self.filter_backends):
queryset = backend().filter_queryset(self.request, queryset, self)
return queryset
def handle_exception(self, exc):
"""
Handle any exception that occurs, by returning an appropriate response,
or re-raising the error.
"""
try:
response = super().handle_exception(exc)
return response
except Exception as e:
if isinstance(e, IntegrityError):
return Response(
{"error": "The payload is not valid"},
status=status.HTTP_400_BAD_REQUEST,
)
if isinstance(e, ValidationError):
return Response(
{"error": "Please provide valid detail"},
status=status.HTTP_400_BAD_REQUEST,
)
if isinstance(e, ObjectDoesNotExist):
return Response(
{"error": "The required object does not exist."},
status=status.HTTP_404_NOT_FOUND,
)
if isinstance(e, KeyError):
return Response(
{"error": "The required key does not exist."},
status=status.HTTP_400_BAD_REQUEST,
)
log_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
def dispatch(self, request, *args, **kwargs):
try:
response = super().dispatch(request, *args, **kwargs)
if settings.DEBUG:
from django.db import connection
print(
f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}"
)
return response
except Exception as exc:
response = self.handle_exception(exc)
return exc
@property
def fields(self):
fields = [
field
for field in self.request.GET.get("fields", "").split(",")
if field
]
return fields if fields else None
@property
def expand(self):
expand = [
expand
for expand in self.request.GET.get("expand", "").split(",")
if expand
]
return expand if expand else None

View file

@ -0,0 +1,168 @@
# Python imports
from smtplib import (
SMTPAuthenticationError,
SMTPConnectError,
SMTPRecipientsRefused,
SMTPSenderRefused,
SMTPServerDisconnected,
)
# Django imports
from django.core.mail import (
BadHeaderError,
EmailMultiAlternatives,
get_connection,
)
# Third party imports
from rest_framework import status
from rest_framework.response import Response
# Module imports
from .base import BaseAPIView
from plane.license.api.permissions import InstanceAdminPermission
from plane.license.models import InstanceConfiguration
from plane.license.api.serializers import InstanceConfigurationSerializer
from plane.license.utils.encryption import encrypt_data
from plane.utils.cache import cache_response, invalidate_cache
from plane.license.utils.instance_value import (
get_email_configuration,
)
class InstanceConfigurationEndpoint(BaseAPIView):
permission_classes = [
InstanceAdminPermission,
]
@cache_response(60 * 60 * 2, user=False)
def get(self, request):
instance_configurations = InstanceConfiguration.objects.all()
serializer = InstanceConfigurationSerializer(
instance_configurations, many=True
)
return Response(serializer.data, status=status.HTTP_200_OK)
@invalidate_cache(path="/api/instances/configurations/", user=False)
@invalidate_cache(path="/api/instances/", user=False)
def patch(self, request):
configurations = InstanceConfiguration.objects.filter(
key__in=request.data.keys()
)
bulk_configurations = []
for configuration in configurations:
value = request.data.get(configuration.key, configuration.value)
if configuration.is_encrypted:
configuration.value = encrypt_data(value)
else:
configuration.value = value
bulk_configurations.append(configuration)
InstanceConfiguration.objects.bulk_update(
bulk_configurations, ["value"], batch_size=100
)
serializer = InstanceConfigurationSerializer(configurations, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
class EmailCredentialCheckEndpoint(BaseAPIView):
def post(self, request):
receiver_email = request.data.get("receiver_email", False)
if not receiver_email:
return Response(
{"error": "Receiver email is required"},
status=status.HTTP_400_BAD_REQUEST,
)
(
EMAIL_HOST,
EMAIL_HOST_USER,
EMAIL_HOST_PASSWORD,
EMAIL_PORT,
EMAIL_USE_TLS,
EMAIL_USE_SSL,
EMAIL_FROM,
) = get_email_configuration()
# Configure all the connections
connection = get_connection(
host=EMAIL_HOST,
port=int(EMAIL_PORT),
username=EMAIL_HOST_USER,
password=EMAIL_HOST_PASSWORD,
use_tls=EMAIL_USE_TLS == "1",
use_ssl=EMAIL_USE_SSL == "1",
)
# Prepare email details
subject = "Email Notification from Plane"
message = (
"This is a sample email notification sent from Plane application."
)
# Send the email
try:
msg = EmailMultiAlternatives(
subject=subject,
body=message,
from_email=EMAIL_FROM,
to=[receiver_email],
connection=connection,
)
msg.send(fail_silently=False)
return Response(
{"message": "Email successfully sent."},
status=status.HTTP_200_OK,
)
except BadHeaderError:
return Response(
{"error": "Invalid email header."},
status=status.HTTP_400_BAD_REQUEST,
)
except SMTPAuthenticationError:
return Response(
{"error": "Invalid credentials provided"},
status=status.HTTP_400_BAD_REQUEST,
)
except SMTPConnectError:
return Response(
{"error": "Could not connect with the SMTP server."},
status=status.HTTP_400_BAD_REQUEST,
)
except SMTPSenderRefused:
return Response(
{"error": "From address is invalid."},
status=status.HTTP_400_BAD_REQUEST,
)
except SMTPServerDisconnected:
return Response(
{"error": "SMTP server disconnected unexpectedly."},
status=status.HTTP_400_BAD_REQUEST,
)
except SMTPRecipientsRefused:
return Response(
{"error": "All recipient addresses were refused."},
status=status.HTTP_400_BAD_REQUEST,
)
except TimeoutError:
return Response(
{
"error": "Timeout error while trying to connect to the SMTP server."
},
status=status.HTTP_400_BAD_REQUEST,
)
except ConnectionError:
return Response(
{
"error": "Network connection error. Please check your internet connection."
},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception:
return Response(
{
"error": "Could not send email. Please check your configuration"
},
status=status.HTTP_400_BAD_REQUEST,
)

View file

@ -1,33 +1,29 @@
# Python imports
import uuid
import os
# Django imports
from django.utils import timezone
from django.contrib.auth.hashers import make_password
from django.core.validators import validate_email
from django.core.exceptions import ValidationError
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from rest_framework.permissions import AllowAny
from rest_framework_simplejwt.tokens import RefreshToken
from rest_framework.response import Response
# Module imports
from plane.app.views import BaseAPIView
from plane.license.models import Instance, InstanceAdmin, InstanceConfiguration
from plane.license.api.serializers import (
InstanceSerializer,
InstanceAdminSerializer,
InstanceConfigurationSerializer,
)
from plane.db.models import Workspace
from plane.license.api.permissions import (
InstanceAdminPermission,
)
from plane.db.models import User
from plane.license.utils.encryption import encrypt_data
from plane.license.api.serializers import (
InstanceSerializer,
)
from plane.license.models import Instance
from plane.license.utils.instance_value import (
get_configuration_value,
)
from plane.utils.cache import cache_response, invalidate_cache
class InstanceEndpoint(BaseAPIView):
def get_permissions(self):
if self.request.method == "PATCH":
@ -51,7 +47,115 @@ class InstanceEndpoint(BaseAPIView):
serializer = InstanceSerializer(instance)
data = serializer.data
data["is_activated"] = True
return Response(data, status=status.HTTP_200_OK)
# Get all the configuration
(
IS_GOOGLE_ENABLED,
IS_GITHUB_ENABLED,
GITHUB_APP_NAME,
EMAIL_HOST,
EMAIL_HOST_USER,
EMAIL_HOST_PASSWORD,
ENABLE_MAGIC_LINK_LOGIN,
ENABLE_EMAIL_PASSWORD,
SLACK_CLIENT_ID,
POSTHOG_API_KEY,
POSTHOG_HOST,
UNSPLASH_ACCESS_KEY,
OPENAI_API_KEY,
) = get_configuration_value(
[
{
"key": "IS_GOOGLE_ENABLED",
"default": os.environ.get("IS_GOOGLE_ENABLED", "0"),
},
{
"key": "IS_GITHUB_ENABLED",
"default": os.environ.get("IS_GITHUB_ENABLED", "0"),
},
{
"key": "GITHUB_APP_NAME",
"default": os.environ.get("GITHUB_APP_NAME", ""),
},
{
"key": "EMAIL_HOST",
"default": os.environ.get("EMAIL_HOST", ""),
},
{
"key": "EMAIL_HOST_USER",
"default": os.environ.get("EMAIL_HOST_USER", ""),
},
{
"key": "EMAIL_HOST_PASSWORD",
"default": os.environ.get("EMAIL_HOST_PASSWORD", ""),
},
{
"key": "ENABLE_MAGIC_LINK_LOGIN",
"default": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "1"),
},
{
"key": "ENABLE_EMAIL_PASSWORD",
"default": os.environ.get("ENABLE_EMAIL_PASSWORD", "1"),
},
{
"key": "SLACK_CLIENT_ID",
"default": os.environ.get("SLACK_CLIENT_ID", None),
},
{
"key": "POSTHOG_API_KEY",
"default": os.environ.get("POSTHOG_API_KEY", None),
},
{
"key": "POSTHOG_HOST",
"default": os.environ.get("POSTHOG_HOST", None),
},
{
"key": "UNSPLASH_ACCESS_KEY",
"default": os.environ.get("UNSPLASH_ACCESS_KEY", ""),
},
{
"key": "OPENAI_API_KEY",
"default": os.environ.get("OPENAI_API_KEY", ""),
},
]
)
data = {}
# Authentication
data["is_google_enabled"] = IS_GOOGLE_ENABLED == "1"
data["is_github_enabled"] = IS_GITHUB_ENABLED == "1"
data["is_magic_login_enabled"] = ENABLE_MAGIC_LINK_LOGIN == "1"
data["is_email_password_enabled"] = ENABLE_EMAIL_PASSWORD == "1"
# Github app name
data["github_app_name"] = str(GITHUB_APP_NAME)
# Slack client
data["slack_client_id"] = SLACK_CLIENT_ID
# Posthog
data["posthog_api_key"] = POSTHOG_API_KEY
data["posthog_host"] = POSTHOG_HOST
# Unsplash
data["has_unsplash_configured"] = bool(UNSPLASH_ACCESS_KEY)
# Open AI settings
data["has_openai_configured"] = bool(OPENAI_API_KEY)
# File size settings
data["file_size_limit"] = float(
os.environ.get("FILE_SIZE_LIMIT", 5242880)
)
# is smtp configured
data["is_smtp_configured"] = (
bool(EMAIL_HOST)
)
instance_data = serializer.data
instance_data["workspaces_exist"] = Workspace.objects.count() > 1
response_data = {"config": data, "instance": instance_data}
return Response(response_data, status=status.HTTP_200_OK)
@invalidate_cache(path="/api/instances/", user=False)
def patch(self, request):
@ -66,196 +170,6 @@ class InstanceEndpoint(BaseAPIView):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class InstanceAdminEndpoint(BaseAPIView):
permission_classes = [
InstanceAdminPermission,
]
@invalidate_cache(path="/api/instances/", user=False)
# Create an instance admin
def post(self, request):
email = request.data.get("email", False)
role = request.data.get("role", 20)
if not email:
return Response(
{"error": "Email is required"},
status=status.HTTP_400_BAD_REQUEST,
)
instance = Instance.objects.first()
if instance is None:
return Response(
{"error": "Instance is not registered yet"},
status=status.HTTP_403_FORBIDDEN,
)
# Fetch the user
user = User.objects.get(email=email)
instance_admin = InstanceAdmin.objects.create(
instance=instance,
user=user,
role=role,
)
serializer = InstanceAdminSerializer(instance_admin)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@cache_response(60 * 60 * 2)
def get(self, request):
instance = Instance.objects.first()
if instance is None:
return Response(
{"error": "Instance is not registered yet"},
status=status.HTTP_403_FORBIDDEN,
)
instance_admins = InstanceAdmin.objects.filter(instance=instance)
serializer = InstanceAdminSerializer(instance_admins, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@invalidate_cache(path="/api/instances/", user=False)
def delete(self, request, pk):
instance = Instance.objects.first()
InstanceAdmin.objects.filter(instance=instance, pk=pk).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class InstanceConfigurationEndpoint(BaseAPIView):
permission_classes = [
InstanceAdminPermission,
]
@cache_response(60 * 60 * 2, user=False)
def get(self, request):
instance_configurations = InstanceConfiguration.objects.all()
serializer = InstanceConfigurationSerializer(
instance_configurations, many=True
)
return Response(serializer.data, status=status.HTTP_200_OK)
@invalidate_cache(path="/api/configs/", user=False)
@invalidate_cache(path="/api/mobile-configs/", user=False)
def patch(self, request):
configurations = InstanceConfiguration.objects.filter(
key__in=request.data.keys()
)
bulk_configurations = []
for configuration in configurations:
value = request.data.get(configuration.key, configuration.value)
if configuration.is_encrypted:
configuration.value = encrypt_data(value)
else:
configuration.value = value
bulk_configurations.append(configuration)
InstanceConfiguration.objects.bulk_update(
bulk_configurations, ["value"], batch_size=100
)
serializer = InstanceConfigurationSerializer(configurations, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
def get_tokens_for_user(user):
refresh = RefreshToken.for_user(user)
return (
str(refresh.access_token),
str(refresh),
)
class InstanceAdminSignInEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
@invalidate_cache(path="/api/instances/", user=False)
def post(self, request):
# Check instance first
instance = Instance.objects.first()
if instance is None:
return Response(
{"error": "Instance is not configured"},
status=status.HTTP_400_BAD_REQUEST,
)
# check if the instance is already activated
if InstanceAdmin.objects.first():
return Response(
{"error": "Admin for this instance is already registered"},
status=status.HTTP_400_BAD_REQUEST,
)
# Get the email and password from all the user
email = request.data.get("email", False)
password = request.data.get("password", False)
# return error if the email and password is not present
if not email or not password:
return Response(
{"error": "Email and password are required"},
status=status.HTTP_400_BAD_REQUEST,
)
# Validate the email
email = email.strip().lower()
try:
validate_email(email)
except ValidationError:
return Response(
{"error": "Please provide a valid email address."},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if already a user exists or not
user = User.objects.filter(email=email).first()
# Existing user
if user:
# Check user password
if not user.check_password(password):
return Response(
{
"error": "Sorry, we could not find a user with the provided credentials. Please try again."
},
status=status.HTTP_403_FORBIDDEN,
)
else:
user = User.objects.create(
email=email,
username=uuid.uuid4().hex,
password=make_password(password),
is_password_autoset=False,
)
# settings last active for the user
user.is_active = True
user.last_active = timezone.now()
user.last_login_time = timezone.now()
user.last_login_ip = request.META.get("REMOTE_ADDR")
user.last_login_uagent = request.META.get("HTTP_USER_AGENT")
user.token_updated_at = timezone.now()
user.save()
# Register the user as an instance admin
_ = InstanceAdmin.objects.create(
user=user,
instance=instance,
)
# Make the setup flag True
instance.is_setup_done = True
instance.save()
# get tokens for user
access_token, refresh_token = get_tokens_for_user(user)
data = {
"access_token": access_token,
"refresh_token": refresh_token,
}
return Response(data, status=status.HTTP_200_OK)
class SignUpScreenVisitedEndpoint(BaseAPIView):
permission_classes = [
AllowAny,

View file

@ -13,6 +13,7 @@ class Command(BaseCommand):
def handle(self, *args, **options):
from plane.license.utils.encryption import encrypt_data
from plane.license.utils.instance_value import get_configuration_value
config_keys = [
# Authentication Settings
@ -40,6 +41,12 @@ class Command(BaseCommand):
"category": "GOOGLE",
"is_encrypted": False,
},
{
"key": "GOOGLE_CLIENT_SECRET",
"value": os.environ.get("GOOGLE_CLIENT_SECRET"),
"category": "GOOGLE",
"is_encrypted": True,
},
{
"key": "GITHUB_CLIENT_ID",
"value": os.environ.get("GITHUB_CLIENT_ID"),
@ -137,3 +144,80 @@ class Command(BaseCommand):
f"{obj.key} configuration already exists"
)
)
keys = ["IS_GOOGLE_ENABLED", "IS_GITHUB_ENABLED"]
if not InstanceConfiguration.objects.filter(key__in=keys).exists():
for key in keys:
if key == "IS_GOOGLE_ENABLED":
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET = (
get_configuration_value(
[
{
"key": "GOOGLE_CLIENT_ID",
"default": os.environ.get(
"GOOGLE_CLIENT_ID", ""
),
},
{
"key": "GOOGLE_CLIENT_SECRET",
"default": os.environ.get(
"GOOGLE_CLIENT_SECRET", "0"
),
},
]
)
)
if bool(GOOGLE_CLIENT_ID) and bool(GOOGLE_CLIENT_SECRET):
value = "1"
else:
value = "0"
InstanceConfiguration.objects.create(
key=key,
value=value,
category="AUTHENTICATION",
is_encrypted=False,
)
self.stdout.write(
self.style.SUCCESS(
f"{key} loaded with value from environment variable."
)
)
if key == "IS_GITHUB_ENABLED":
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET = (
get_configuration_value(
[
{
"key": "GITHUB_CLIENT_ID",
"default": os.environ.get(
"GITHUB_CLIENT_ID", ""
),
},
{
"key": "GITHUB_CLIENT_SECRET",
"default": os.environ.get(
"GITHUB_CLIENT_SECRET", "0"
),
},
]
)
)
if bool(GITHUB_CLIENT_ID) and bool(GITHUB_CLIENT_SECRET):
value = "1"
else:
value = "0"
InstanceConfiguration.objects.create(
key="IS_GITHUB_ENABLED",
value=value,
category="AUTHENTICATION",
is_encrypted=False,
)
self.stdout.write(
self.style.SUCCESS(
f"{key} loaded with value from environment variable."
)
)
else:
for key in keys:
self.stdout.write(
self.style.WARNING(f"{key} configuration already exists")
)

View file

@ -1,11 +1,15 @@
from django.urls import path
from plane.license.api.views import (
InstanceEndpoint,
EmailCredentialCheckEndpoint,
InstanceAdminEndpoint,
InstanceConfigurationEndpoint,
InstanceAdminSignInEndpoint,
InstanceAdminSignUpEndpoint,
InstanceConfigurationEndpoint,
InstanceEndpoint,
SignUpScreenVisitedEndpoint,
InstanceAdminUserMeEndpoint,
InstanceAdminSignOutEndpoint,
)
urlpatterns = [
@ -19,6 +23,16 @@ urlpatterns = [
InstanceAdminEndpoint.as_view(),
name="instance-admins",
),
path(
"admins/me/",
InstanceAdminUserMeEndpoint.as_view(),
name="instance-admins",
),
path(
"admins/sign-out/",
InstanceAdminSignOutEndpoint.as_view(),
name="instance-admins",
),
path(
"admins/<uuid:pk>/",
InstanceAdminEndpoint.as_view(),
@ -34,9 +48,19 @@ urlpatterns = [
InstanceAdminSignInEndpoint.as_view(),
name="instance-admin-sign-in",
),
path(
"admins/sign-up/",
InstanceAdminSignUpEndpoint.as_view(),
name="instance-admin-sign-in",
),
path(
"admins/sign-up-screen-visited/",
SignUpScreenVisitedEndpoint.as_view(),
name="instance-sign-up",
),
path(
"email-credentials-check/",
EmailCredentialCheckEndpoint.as_view(),
name="email-credential-check",
),
]

View file

@ -3,7 +3,6 @@
# Python imports
import os
import ssl
from datetime import timedelta
from urllib.parse import urlparse
import certifi
@ -45,10 +44,9 @@ INSTALLED_APPS = [
"plane.middleware",
"plane.license",
"plane.api",
"plane.authentication",
# Third-party things
"rest_framework",
"rest_framework.authtoken",
"rest_framework_simplejwt.token_blacklist",
"corsheaders",
"django_celery_beat",
"storages",
@ -58,7 +56,7 @@ INSTALLED_APPS = [
MIDDLEWARE = [
"corsheaders.middleware.CorsMiddleware",
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"plane.authentication.middleware.session.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
@ -71,7 +69,7 @@ MIDDLEWARE = [
# Rest Framework settings
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": (
"rest_framework_simplejwt.authentication.JWTAuthentication",
"rest_framework.authentication.SessionAuthentication",
),
"DEFAULT_PERMISSION_CLASSES": (
"rest_framework.permissions.IsAuthenticated",
@ -80,6 +78,7 @@ REST_FRAMEWORK = {
"DEFAULT_FILTER_BACKENDS": (
"django_filters.rest_framework.DjangoFilterBackend",
),
"EXCEPTION_HANDLER": "plane.authentication.adapter.exception.auth_exception_handler",
}
# Django Auth Backend
@ -109,9 +108,6 @@ TEMPLATES = [
},
]
# Cookie Settings
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
# CORS Settings
CORS_ALLOW_CREDENTIALS = True
@ -122,8 +118,14 @@ cors_allowed_origins = [
]
if cors_allowed_origins:
CORS_ALLOWED_ORIGINS = cors_allowed_origins
secure_origins = (
False
if [origin for origin in cors_allowed_origins if "http:" in origin]
else True
)
else:
CORS_ALLOW_ALL_ORIGINS = True
secure_origins = False
# Application Settings
WSGI_APPLICATION = "plane.wsgi.application"
@ -246,35 +248,6 @@ if AWS_S3_ENDPOINT_URL:
AWS_S3_URL_PROTOCOL = f"{parsed_url.scheme}:"
# JWT Auth Configuration
SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=43200),
"REFRESH_TOKEN_LIFETIME": timedelta(days=43200),
"ROTATE_REFRESH_TOKENS": False,
"BLACKLIST_AFTER_ROTATION": False,
"UPDATE_LAST_LOGIN": False,
"ALGORITHM": "HS256",
"SIGNING_KEY": SECRET_KEY,
"VERIFYING_KEY": None,
"AUDIENCE": None,
"ISSUER": None,
"JWK_URL": None,
"LEEWAY": 0,
"AUTH_HEADER_TYPES": ("Bearer",),
"AUTH_HEADER_NAME": "HTTP_AUTHORIZATION",
"USER_ID_FIELD": "id",
"USER_ID_CLAIM": "user_id",
"USER_AUTHENTICATION_RULE": "rest_framework_simplejwt.authentication.default_user_authentication_rule",
"AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",),
"TOKEN_TYPE_CLAIM": "token_type",
"TOKEN_USER_CLASS": "rest_framework_simplejwt.models.TokenUser",
"JTI_CLAIM": "jti",
"SLIDING_TOKEN_REFRESH_EXP_CLAIM": "refresh_exp",
"SLIDING_TOKEN_LIFETIME": timedelta(minutes=5),
"SLIDING_TOKEN_REFRESH_LIFETIME": timedelta(days=1),
}
# Celery Configuration
CELERY_TIMEZONE = TIME_ZONE
CELERY_TASK_SERIALIZER = "json"
@ -350,3 +323,22 @@ INSTANCE_KEY = os.environ.get(
SKIP_ENV_VAR = os.environ.get("SKIP_ENV_VAR", "1") == "1"
DATA_UPLOAD_MAX_MEMORY_SIZE = int(os.environ.get("FILE_SIZE_LIMIT", 5242880))
# Cookie Settings
SESSION_COOKIE_SECURE = secure_origins
SESSION_COOKIE_HTTPONLY = True
SESSION_ENGINE = "plane.db.models.session"
SESSION_COOKIE_AGE = 604800
SESSION_COOKIE_NAME = "plane-session-id"
SESSION_COOKIE_DOMAIN = os.environ.get("COOKIE_DOMAIN", None)
SESSION_SAVE_EVERY_REQUEST = True
# Admin Cookie
ADMIN_SESSION_COOKIE_NAME = "plane-admin-session-id"
ADMIN_SESSION_COOKIE_AGE = 3600
# CSRF cookies
CSRF_COOKIE_SECURE = secure_origins
CSRF_COOKIE_HTTPONLY = True
CSRF_TRUSTED_ORIGINS = cors_allowed_origins
CSRF_COOKIE_DOMAIN = os.environ.get("COOKIE_DOMAIN", None)

View file

@ -35,7 +35,11 @@ CORS_ALLOWED_ORIGINS = [
"http://127.0.0.1:3000",
"http://localhost:4000",
"http://127.0.0.1:4000",
"http://localhost:3333",
"http://127.0.0.1:3333",
]
CSRF_TRUSTED_ORIGINS = CORS_ALLOWED_ORIGINS
CORS_ALLOW_ALL_ORIGINS = True
LOG_DIR = os.path.join(BASE_DIR, "logs") # noqa

View file

@ -2,10 +2,9 @@
"""
from django.urls import path, include, re_path
from django.views.generic import TemplateView
from django.conf import settings
from django.urls import include, path, re_path
from django.views.generic import TemplateView
handler404 = "plane.app.views.error_404.custom_404_view"
@ -15,6 +14,7 @@ urlpatterns = [
path("api/public/", include("plane.space.urls")),
path("api/instances/", include("plane.license.urls")),
path("api/v1/", include("plane.api.urls")),
path("auth/", include("plane.authentication.urls")),
path("", include("plane.web.urls")),
]

View file

@ -1,37 +1,63 @@
# base requirements
# django
Django==4.2.11
psycopg==3.1.12
djangorestframework==3.14.0
redis==4.6.0
django-cors-headers==4.2.0
whitenoise==6.5.0
django-allauth==0.55.2
faker==18.11.2
django-filter==23.2
jsonmodels==2.6.0
djangorestframework-simplejwt==5.3.0
sentry-sdk==1.30.0
django-storages==1.14
django-crum==0.7.9
google-auth==2.22.0
google-api-python-client==2.97.0
django-redis==5.3.0
uvicorn==0.23.2
channels==4.0.0
openai==1.2.4
slack-sdk==3.21.3
celery==5.3.4
django_celery_beat==2.5.0
psycopg-binary==3.1.12
psycopg-c==3.1.12
scout-apm==2.26.1
openpyxl==3.1.2
python-json-logger==2.0.7
beautifulsoup4==4.12.2
# rest framework
djangorestframework==3.15.1
# postgres
psycopg==3.1.18
psycopg-binary==3.1.18
psycopg-c==3.1.18
dj-database-url==2.1.0
posthog==3.0.2
cryptography==42.0.4
lxml==4.9.3
boto3==1.28.40
# redis
redis==5.0.4
django-redis==5.4.0
# cors
django-cors-headers==4.3.1
# celery
celery==5.4.0
django_celery_beat==2.6.0
# file serve
whitenoise==6.6.0
# fake data
faker==25.0.0
# filters
django-filter==24.2
# json model
jsonmodels==2.7.0
# sentry
sentry-sdk==2.0.1
# storage
django-storages==1.14.2
# user management
django-crum==0.7.9
# web server
uvicorn==0.29.0
# sockets
channels==4.1.0
# ai
openai==1.25.0
# slack
slack-sdk==3.27.1
# apm
scout-apm==3.1.0
# xlsx generation
openpyxl==3.1.2
# logging
python-json-logger==2.0.7
# html parser
beautifulsoup4==4.12.3
# analytics
posthog==3.5.0
# crypto
cryptography==42.0.5
# html validator
lxml==5.2.1
# s3
boto3==1.34.96
# password validator
zxcvbn==4.4.28
# timezone
pytz==2024.1
# jwt
jwt==1.3.1

View file

@ -1,3 +1,5 @@
-r base.txt
django-debug-toolbar==4.1.0
# debug toolbar
django-debug-toolbar==4.3.0
# formatter
ruff==0.4.2

View file

@ -1,3 +1,3 @@
-r base.txt
# server
gunicorn==22.0.0

View file

@ -1,4 +1,4 @@
-r base.txt
# test checker
pytest==7.1.2
coverage==6.5.0