This commit is contained in:
DCCONSTRUCTIONS
2026-04-18 18:39:25 +03:00
commit 3ba092b60c
4944 changed files with 497564 additions and 0 deletions
@@ -0,0 +1,4 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
@@ -0,0 +1,357 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Python imports
import logging
import os
import uuid
from io import BytesIO
import requests
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
# Django imports
from django.utils import timezone
from plane.bgtasks.user_activation_email_task import user_activation_email
# Module imports
from plane.authentication.utils.password_validation import is_password_policy_valid
from plane.db.models import FileAsset, Profile, User, WorkspaceMemberInvite
from plane.license.utils.instance_value import get_configuration_value
from plane.settings.storage import S3Storage
from plane.utils.exception_logger import log_exception
from plane.utils.host import base_host
from plane.utils.ip_address import get_client_ip
from .error import AUTHENTICATION_ERROR_CODES, AuthenticationException
class Adapter:
"""Common interface for all auth providers"""
def __init__(self, request, provider, callback=None):
self.request = request
self.provider = provider
self.callback = callback
self.token_data = None
self.user_data = None
self.logger = logging.getLogger("plane.authentication")
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 sanitize_email(self, email):
# Check if email is present
if not email:
self.logger.error("Email is not present")
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"],
error_message="INVALID_EMAIL",
payload={"email": email},
)
# Sanitize email
email = str(email).lower().strip()
# validate email
try:
validate_email(email)
except ValidationError:
self.logger.warning(f"Email is not valid: {email}")
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"],
error_message="INVALID_EMAIL",
payload={"email": email},
)
# Return email
return email
def validate_password(self, email):
"""Validate password strength"""
if not is_password_policy_valid(self.code):
self.logger.warning("Password is not strong enough")
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["PASSWORD_TOO_WEAK"],
error_message="PASSWORD_TOO_WEAK",
payload={"email": email},
)
return
def __check_signup(self, email):
"""Check if sign up is enabled or not and raise exception if not enabled"""
# Get configuration value
(ENABLE_SIGNUP,) = get_configuration_value([
{"key": "ENABLE_SIGNUP", "default": os.environ.get("ENABLE_SIGNUP", "1")}
])
# Check if sign up is disabled and invite is present or not
if ENABLE_SIGNUP == "0" and not WorkspaceMemberInvite.objects.filter(email=email).exists():
self.logger.warning("Sign up is disabled and invite is not present")
# Raise exception
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["SIGNUP_DISABLED"],
error_message="SIGNUP_DISABLED",
payload={"email": email},
)
return True
def get_avatar_download_headers(self):
return {}
def check_sync_enabled(self):
"""Check if sync is enabled for the provider"""
provider_config_map = {
"google": "ENABLE_GOOGLE_SYNC",
"github": "ENABLE_GITHUB_SYNC",
"gitlab": "ENABLE_GITLAB_SYNC",
"gitea": "ENABLE_GITEA_SYNC",
}
config_key = provider_config_map.get(self.provider)
if config_key:
(enabled,) = get_configuration_value([{"key": config_key, "default": os.environ.get(config_key, "0")}])
return enabled == "1"
return False
def download_and_upload_avatar(self, avatar_url, user):
"""
Downloads avatar from OAuth provider and uploads to our storage.
Returns the uploaded file path or None if failed.
"""
if not avatar_url:
return None
try:
headers = self.get_avatar_download_headers()
# Download the avatar image
response = requests.get(avatar_url, timeout=10, headers=headers)
response.raise_for_status()
# Check content length before downloading
content_length = response.headers.get("Content-Length")
max_size = settings.DATA_UPLOAD_MAX_MEMORY_SIZE
if content_length and int(content_length) > max_size:
return None
# Get content type and determine file extension
content_type = response.headers.get("Content-Type", "image/jpeg")
extension_map = {
"image/jpeg": "jpg",
"image/jpg": "jpg",
"image/png": "png",
"image/gif": "gif",
"image/webp": "webp",
}
extension = extension_map.get(content_type)
if not extension:
return None
# Download with size limit
chunks = []
total_size = 0
for chunk in response.iter_content(chunk_size=8192):
total_size += len(chunk)
if total_size > max_size:
return None
chunks.append(chunk)
content = b"".join(chunks)
file_size = len(content)
# Generate unique filename
filename = f"{uuid.uuid4().hex}-user-avatar.{extension}"
storage = S3Storage(request=self.request)
# Create file-like object
file_obj = BytesIO(response.content)
file_obj.seek(0)
# Upload using boto3 directly
upload_success = storage.upload_file(file_obj=file_obj, object_name=filename, content_type=content_type)
if not upload_success:
return None
# Get storage metadata
storage_metadata = storage.get_object_metadata(object_name=filename)
# Create FileAsset record
file_asset = FileAsset.objects.create(
attributes={"name": f"{self.provider}-avatar.{extension}", "type": content_type, "size": file_size},
asset=filename,
size=file_size,
user=user,
created_by=user,
entity_type=FileAsset.EntityTypeContext.USER_AVATAR,
is_uploaded=True,
storage_metadata=storage_metadata,
)
return file_asset
except Exception as e:
log_exception(e)
# Return None if upload fails, so original URL can be used as fallback
return None
def save_user_data(self, 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 = get_client_ip(request=self.request)
user.last_login_uagent = self.request.META.get("HTTP_USER_AGENT")
user.token_updated_at = timezone.now()
# If user is not active, send the activation email and set the user as active
if not user.is_active:
user_activation_email.delay(base_host(request=self.request), user.id)
# Set user as active
user.is_active = True
user.save()
return user
def delete_old_avatar(self, user):
"""Delete the old avatar if it exists"""
try:
if user.avatar_asset:
asset = FileAsset.objects.get(pk=user.avatar_asset_id)
storage = S3Storage(request=self.request)
storage.delete_files(object_names=[asset.asset.name])
# Delete the user avatar
asset.delete()
user.avatar_asset = None
user.avatar = ""
user.save()
return
except FileAsset.DoesNotExist:
pass
except Exception as e:
log_exception(e)
return
def sync_user_data(self, user):
# Update user details
first_name = self.user_data.get("user", {}).get("first_name", "")
last_name = self.user_data.get("user", {}).get("last_name", "")
user.first_name = first_name if first_name else ""
user.last_name = last_name if last_name else ""
# Get email
email = self.user_data.get("email")
# Get display name
display_name = self.user_data.get("user", {}).get("display_name")
# If display name is not provided, generate a random display name
if not display_name:
display_name = User.get_display_name(email)
# Set display name
user.display_name = display_name
# Download and upload avatar only if the avatar is different from the one in the storage
avatar = self.user_data.get("user", {}).get("avatar", "")
# Delete the old avatar if it exists
self.delete_old_avatar(user=user)
avatar_asset = self.download_and_upload_avatar(avatar_url=avatar, user=user)
if avatar_asset:
user.avatar_asset = avatar_asset
# If avatar upload fails, set the avatar to the original URL
else:
user.avatar = avatar
user.save()
return user
def complete_login_or_signup(self):
# Get email
email = self.user_data.get("email")
# Sanitize email
email = self.sanitize_email(email)
# Check if the user is present
user = User.objects.filter(email=email).first()
# Check if sign up case or login
is_signup = bool(user)
# If user is not present, create a new user
if not user:
# New user
self.__check_signup(email)
# Initialize user
user = User(email=email, username=uuid.uuid4().hex)
# Check if password is autoset
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
# Validate password
else:
# Validate password
self.validate_password(email)
# Set password
user.set_password(self.code)
user.is_password_autoset = False
# Set user details
first_name = self.user_data.get("user", {}).get("first_name", "")
last_name = self.user_data.get("user", {}).get("last_name", "")
user.first_name = first_name if first_name else ""
user.last_name = last_name if last_name else ""
user.save()
# Download and upload avatar
avatar = self.user_data.get("user", {}).get("avatar", "")
if avatar:
avatar_asset = self.download_and_upload_avatar(avatar_url=avatar, user=user)
if avatar_asset:
user.avatar_asset = avatar_asset
user.avatar = avatar
# If avatar upload fails, set the avatar to the original URL
else:
user.avatar = avatar
# Create profile
Profile.objects.create(user=user)
# Check if IDP sync is enabled and user is not signing up
if self.check_sync_enabled() and not is_signup:
user = self.sync_user_data(user=user)
# Save user data
user = self.save_user_data(user=user)
# Call callback if present
if self.callback:
self.callback(user, is_signup, self.request)
# Create or update account if token data is present
if self.token_data:
self.create_update_account(user=user)
# Return user
return user
@@ -0,0 +1,18 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from plane.authentication.adapter.base import Adapter
class CredentialAdapter(Adapter):
"""Common interface for all credential providers"""
def __init__(self, request, provider, callback=None):
super().__init__(request=request, provider=provider, callback=callback)
self.request = request
self.provider = provider
def authenticate(self):
self.set_user_data()
return self.complete_login_or_signup()
@@ -0,0 +1,92 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
AUTHENTICATION_ERROR_CODES = {
# Global
"INSTANCE_NOT_CONFIGURED": 5000,
"INVALID_EMAIL": 5005,
"EMAIL_REQUIRED": 5010,
"SIGNUP_DISABLED": 5015,
"MAGIC_LINK_LOGIN_DISABLED": 5016,
"PASSWORD_LOGIN_DISABLED": 5018,
"USER_ACCOUNT_DEACTIVATED": 5019,
# Password strength
"INVALID_PASSWORD": 5020,
"PASSWORD_TOO_WEAK": 5021,
"SMTP_NOT_CONFIGURED": 5025,
# Sign Up
"USER_ALREADY_EXIST": 5030,
"AUTHENTICATION_FAILED_SIGN_UP": 5035,
"REQUIRED_EMAIL_PASSWORD_SIGN_UP": 5040,
"INVALID_EMAIL_SIGN_UP": 5045,
"INVALID_EMAIL_MAGIC_SIGN_UP": 5050,
"MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED": 5055,
"EMAIL_PASSWORD_AUTHENTICATION_DISABLED": 5056,
# Sign In
"USER_DOES_NOT_EXIST": 5060,
"AUTHENTICATION_FAILED_SIGN_IN": 5065,
"REQUIRED_EMAIL_PASSWORD_SIGN_IN": 5070,
"INVALID_EMAIL_SIGN_IN": 5075,
"INVALID_EMAIL_MAGIC_SIGN_IN": 5080,
"MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED": 5085,
# Both Sign in and Sign up for magic
"INVALID_MAGIC_CODE_SIGN_IN": 5090,
"INVALID_MAGIC_CODE_SIGN_UP": 5092,
"EXPIRED_MAGIC_CODE_SIGN_IN": 5095,
"EXPIRED_MAGIC_CODE_SIGN_UP": 5097,
"EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN": 5100,
"EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP": 5102,
# Oauth
"OAUTH_NOT_CONFIGURED": 5104,
"GOOGLE_NOT_CONFIGURED": 5105,
"GITHUB_NOT_CONFIGURED": 5110,
"GITHUB_USER_NOT_IN_ORG": 5122,
"GITLAB_NOT_CONFIGURED": 5111,
"GITEA_NOT_CONFIGURED": 5112,
"GOOGLE_OAUTH_PROVIDER_ERROR": 5115,
"GITHUB_OAUTH_PROVIDER_ERROR": 5120,
"GITLAB_OAUTH_PROVIDER_ERROR": 5121,
"GITEA_OAUTH_PROVIDER_ERROR": 5123,
# Reset Password
"INVALID_PASSWORD_TOKEN": 5125,
"EXPIRED_PASSWORD_TOKEN": 5130,
# Change password
"INCORRECT_OLD_PASSWORD": 5135,
"MISSING_PASSWORD": 5138,
"INVALID_NEW_PASSWORD": 5140,
# set password
"PASSWORD_ALREADY_SET": 5145,
# Admin
"ADMIN_ALREADY_EXIST": 5150,
"REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME": 5155,
"INVALID_ADMIN_EMAIL": 5160,
"INVALID_ADMIN_PASSWORD": 5165,
"REQUIRED_ADMIN_EMAIL_PASSWORD": 5170,
"ADMIN_AUTHENTICATION_FAILED": 5175,
"ADMIN_USER_ALREADY_EXIST": 5180,
"ADMIN_USER_DOES_NOT_EXIST": 5185,
"ADMIN_USER_DEACTIVATED": 5190,
# Rate limit
"RATE_LIMIT_EXCEEDED": 5900,
# Unknown
"AUTHENTICATION_FAILED": 5999,
}
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
@@ -0,0 +1,34 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Third party imports
from rest_framework.views import exception_handler
from rest_framework.exceptions import NotAuthenticated
from rest_framework.exceptions import Throttled
# Module imports
from plane.authentication.adapter.error import (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
)
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):
response.status_code = 401
# Check if an Throttled exception is raised.
if isinstance(exc, Throttled):
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["RATE_LIMIT_EXCEEDED"],
error_message="RATE_LIMIT_EXCEEDED",
)
response.data = exc.get_error_dict()
response.status_code = 429
# Return the response that is generated by the default exception handler.
return response
@@ -0,0 +1,136 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Python imports
import requests
from django.db import DatabaseError, IntegrityError
# Django imports
from django.utils import timezone
from plane.authentication.adapter.error import (
AUTHENTICATION_ERROR_CODES,
AuthenticationException,
)
# Module imports
from plane.db.models import Account
from plane.utils.exception_logger import log_exception
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,
callback=None,
):
super().__init__(request=request, provider=provider, callback=callback)
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 authentication_error_code(self):
if self.provider == "google":
return "GOOGLE_OAUTH_PROVIDER_ERROR"
elif self.provider == "github":
return "GITHUB_OAUTH_PROVIDER_ERROR"
elif self.provider == "gitlab":
return "GITLAB_OAUTH_PROVIDER_ERROR"
elif self.provider == "gitea":
return "GITEA_OAUTH_PROVIDER_ERROR"
else:
return "OAUTH_NOT_CONFIGURED"
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):
try:
headers = headers or {}
response = requests.post(self.get_token_url(), data=data, headers=headers)
response.raise_for_status()
return response.json()
except requests.RequestException:
self.logger.warning("Error getting user token")
code = self.authentication_error_code()
raise AuthenticationException(error_code=AUTHENTICATION_ERROR_CODES[code], error_message=str(code))
def get_user_response(self):
try:
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()
except requests.RequestException:
self.logger.warning(
"Error getting user response",
extra={
"headers": headers,
},
)
code = self.authentication_error_code()
raise AuthenticationException(error_code=AUTHENTICATION_ERROR_CODES[code], error_message=str(code))
def set_user_data(self, data):
self.user_data = data
def create_update_account(self, user):
try:
# Check if the account already exists
account = Account.objects.filter(
user=user,
provider=self.provider,
provider_account_id=self.user_data.get("user").get("provider_id"),
).first()
# Update the account if it exists
if account:
account.access_token = self.token_data.get("access_token")
account.refresh_token = self.token_data.get("refresh_token", None)
account.access_token_expired_at = self.token_data.get("access_token_expired_at")
account.refresh_token_expired_at = self.token_data.get("refresh_token_expired_at")
account.last_connected_at = timezone.now()
account.id_token = self.token_data.get("id_token", "")
account.save()
# Create a new account if it does not exist
else:
Account.objects.create(
user=user,
provider=self.provider,
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(),
id_token=self.token_data.get("id_token", ""),
)
except (DatabaseError, IntegrityError) as e:
log_exception(e)