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:
parent
ae43d05714
commit
59335618b4
903 changed files with 25736 additions and 16041 deletions
|
|
@ -7,6 +7,8 @@ from .user import (
|
|||
UserAdminLiteSerializer,
|
||||
UserMeSerializer,
|
||||
UserMeSettingsSerializer,
|
||||
ProfileSerializer,
|
||||
AccountSerializer,
|
||||
)
|
||||
from .workspace import (
|
||||
WorkSpaceSerializer,
|
||||
|
|
|
|||
|
|
@ -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__"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]
|
||||
|
|
@ -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",
|
||||
),
|
||||
]
|
||||
|
|
@ -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
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
0
apiserver/plane/authentication/__init__.py
Normal file
0
apiserver/plane/authentication/__init__.py
Normal file
0
apiserver/plane/authentication/adapter/__init__.py
Normal file
0
apiserver/plane/authentication/adapter/__init__.py
Normal file
115
apiserver/plane/authentication/adapter/base.py
Normal file
115
apiserver/plane/authentication/adapter/base.py
Normal 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
|
||||
14
apiserver/plane/authentication/adapter/credential.py
Normal file
14
apiserver/plane/authentication/adapter/credential.py
Normal 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()
|
||||
71
apiserver/plane/authentication/adapter/error.py
Normal file
71
apiserver/plane/authentication/adapter/error.py
Normal 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
|
||||
18
apiserver/plane/authentication/adapter/exception.py
Normal file
18
apiserver/plane/authentication/adapter/exception.py
Normal 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
|
||||
88
apiserver/plane/authentication/adapter/oauth.py
Normal file
88
apiserver/plane/authentication/adapter/oauth.py
Normal 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(),
|
||||
},
|
||||
)
|
||||
5
apiserver/plane/authentication/apps.py
Normal file
5
apiserver/plane/authentication/apps.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AuthConfig(AppConfig):
|
||||
name = "plane.authentication"
|
||||
0
apiserver/plane/authentication/middleware/__init__.py
Normal file
0
apiserver/plane/authentication/middleware/__init__.py
Normal file
94
apiserver/plane/authentication/middleware/session.py
Normal file
94
apiserver/plane/authentication/middleware/session.py
Normal 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
|
||||
0
apiserver/plane/authentication/provider/__init__.py
Normal file
0
apiserver/plane/authentication/provider/__init__.py
Normal file
97
apiserver/plane/authentication/provider/credentials/email.py
Normal file
97
apiserver/plane/authentication/provider/credentials/email.py
Normal 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
|
||||
|
|
@ -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)},
|
||||
)
|
||||
136
apiserver/plane/authentication/provider/oauth/github.py
Normal file
136
apiserver/plane/authentication/provider/oauth/github.py
Normal 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,
|
||||
},
|
||||
}
|
||||
)
|
||||
117
apiserver/plane/authentication/provider/oauth/google.py
Normal file
117
apiserver/plane/authentication/provider/oauth/google.py
Normal 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)
|
||||
8
apiserver/plane/authentication/session.py
Normal file
8
apiserver/plane/authentication/session.py
Normal 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
|
||||
184
apiserver/plane/authentication/urls.py
Normal file
184
apiserver/plane/authentication/urls.py
Normal 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",
|
||||
),
|
||||
]
|
||||
14
apiserver/plane/authentication/utils/host.py
Normal file
14
apiserver/plane/authentication/utils/host.py
Normal 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"))
|
||||
17
apiserver/plane/authentication/utils/login.py
Normal file
17
apiserver/plane/authentication/utils/login.py
Normal 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
|
||||
42
apiserver/plane/authentication/utils/redirection_path.py
Normal file
42
apiserver/plane/authentication/utils/redirection_path.py
Normal 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"
|
||||
|
|
@ -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()
|
||||
52
apiserver/plane/authentication/views/__init__.py
Normal file
52
apiserver/plane/authentication/views/__init__.py
Normal 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
|
||||
148
apiserver/plane/authentication/views/app/check.py
Normal file
148
apiserver/plane/authentication/views/app/check.py
Normal 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,
|
||||
)
|
||||
240
apiserver/plane/authentication/views/app/email.py
Normal file
240
apiserver/plane/authentication/views/app/email.py
Normal 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)
|
||||
133
apiserver/plane/authentication/views/app/github.py
Normal file
133
apiserver/plane/authentication/views/app/github.py
Normal 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)
|
||||
128
apiserver/plane/authentication/views/app/google.py
Normal file
128
apiserver/plane/authentication/views/app/google.py
Normal 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)
|
||||
211
apiserver/plane/authentication/views/app/magic.py
Normal file
211
apiserver/plane/authentication/views/app/magic.py
Normal 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)
|
||||
34
apiserver/plane/authentication/views/app/signout.py
Normal file
34
apiserver/plane/authentication/views/app/signout.py
Normal 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"
|
||||
)
|
||||
326
apiserver/plane/authentication/views/common.py
Normal file
326
apiserver/plane/authentication/views/common.py
Normal 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)
|
||||
82
apiserver/plane/authentication/views/space/check.py
Normal file
82
apiserver/plane/authentication/views/space/check.py
Normal 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,
|
||||
)
|
||||
224
apiserver/plane/authentication/views/space/email.py
Normal file
224
apiserver/plane/authentication/views/space/email.py
Normal 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)
|
||||
125
apiserver/plane/authentication/views/space/github.py
Normal file
125
apiserver/plane/authentication/views/space/github.py
Normal 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)
|
||||
120
apiserver/plane/authentication/views/space/google.py
Normal file
120
apiserver/plane/authentication/views/space/google.py
Normal 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)
|
||||
196
apiserver/plane/authentication/views/space/magic.py
Normal file
196
apiserver/plane/authentication/views/space/magic.py
Normal 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)
|
||||
34
apiserver/plane/authentication/views/space/signout.py
Normal file
34
apiserver/plane/authentication/views/space/signout.py
Normal 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"
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
270
apiserver/plane/db/migrations/0065_auto_20240415_0937.py
Normal file
270
apiserver/plane/db/migrations/0065_auto_20240415_0937.py
Normal 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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
65
apiserver/plane/db/models/session.py
Normal file
65
apiserver/plane/db/models/session.py
Normal 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
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from .instance import (
|
||||
InstanceSerializer,
|
||||
InstanceAdminSerializer,
|
||||
InstanceConfigurationSerializer,
|
||||
)
|
||||
|
||||
from .configuration import InstanceConfigurationSerializer
|
||||
from .admin import InstanceAdminSerializer, InstanceAdminMeSerializer
|
||||
|
|
|
|||
41
apiserver/plane/license/api/serializers/admin.py
Normal file
41
apiserver/plane/license/api/serializers/admin.py
Normal 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",
|
||||
]
|
||||
5
apiserver/plane/license/api/serializers/base.py
Normal file
5
apiserver/plane/license/api/serializers/base.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from rest_framework import serializers
|
||||
|
||||
|
||||
class BaseSerializer(serializers.ModelSerializer):
|
||||
id = serializers.PrimaryKeyRelatedField(read_only=True)
|
||||
17
apiserver/plane/license/api/serializers/configuration.py
Normal file
17
apiserver/plane/license/api/serializers/configuration.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
421
apiserver/plane/license/api/views/admin.py
Normal file
421
apiserver/plane/license/api/views/admin.py
Normal 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"
|
||||
)
|
||||
132
apiserver/plane/license/api/views/base.py
Normal file
132
apiserver/plane/license/api/views/base.py
Normal 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
|
||||
168
apiserver/plane/license/api/views/configuration.py
Normal file
168
apiserver/plane/license/api/views/configuration.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
-r base.txt
|
||||
|
||||
# server
|
||||
gunicorn==22.0.0
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
-r base.txt
|
||||
|
||||
# test checker
|
||||
pytest==7.1.2
|
||||
coverage==6.5.0
|
||||
Loading…
Add table
Add a link
Reference in a new issue