Temp commit
This commit is contained in:
parent
378ae41b39
commit
e118af5d53
151
app/authentication/backend/application/signin_hub.py
Normal file
151
app/authentication/backend/application/signin_hub.py
Normal file
@ -0,0 +1,151 @@
|
||||
from app.authentication.backend.models.constants import (
|
||||
NewUserMethod,
|
||||
UserAccountProperty,
|
||||
UserLoginAction,
|
||||
)
|
||||
from typing import Optional, Tuple
|
||||
from infra.i18n.region_handler import RegionHandler
|
||||
from infra.models.constants import UserRegion
|
||||
from infra.log.log_utils import log_entry_exit_async
|
||||
from app.authentication.backend.infra.user_management.user_auth_handler import (
|
||||
UserAuthManager,
|
||||
)
|
||||
from app.authentication.backend.business.signin_manager import SignInManager
|
||||
|
||||
|
||||
class SignInHub:
|
||||
def __init__(self) -> None:
|
||||
self.user_auth_manager = UserAuthManager()
|
||||
self.signin_manager = SignInManager()
|
||||
self.basic_profile_store = BasicProfileStore()
|
||||
self.provider_profile_store = ProviderProfileStore()
|
||||
self.code_depot_manager = CodeDepotManager()
|
||||
self.notification_center = NotificationCenter(sender_id=settings.SYSTEM_USER_ID)
|
||||
self.event_dispatcher = UserEventDispatcher(owner_id=settings.SYSTEM_USER_ID)
|
||||
self.module_logger = ModuleLogger(sender_id=UserManager)
|
||||
|
||||
async def signin_with_email_and_code(
|
||||
self, email: str, code: str, host: str, time_zone: Optional[str] = "UTC"
|
||||
) -> Tuple[int, Optional[int], Optional[str], Optional[str]]:
|
||||
"""
|
||||
Interacts with the business layer to handle the sign-in process with email and code.
|
||||
"""
|
||||
return await self.signin_manager.signin_with_email_and_code(
|
||||
email=email, code=code, host=host, time_zone=time_zone
|
||||
)
|
||||
|
||||
@log_entry_exit_async
|
||||
async def signin_with_email_and_code(
|
||||
self, email: str, code: str, host: str, time_zone: Optional[str] = "UTC"
|
||||
) -> Tuple[UserLoginAction, Optional[int], Optional[str], Optional[str]]:
|
||||
"""Try to signin with email and code.
|
||||
create a new user account, if the email address has never been used before.
|
||||
|
||||
Args:
|
||||
email (str): email address
|
||||
code (str): auth code to be verified
|
||||
host (str): the host address by which the client access the frontend service
|
||||
|
||||
Returns:
|
||||
[int, Optional[int], Optional[str], Optional[str]]:
|
||||
- int: UserLoginAction
|
||||
- Optional[int]: user role
|
||||
- Optional[str]: user_id
|
||||
- Optional[str]: flid
|
||||
"""
|
||||
|
||||
# Step 1: Verify the email and code
|
||||
try:
|
||||
user_id, is_new_user, preferred_region = (
|
||||
await self.signin_manager.verify_email_with_code(email, code, host)
|
||||
)
|
||||
except InvalidAuthCodeException:
|
||||
await self.logger.log_info(
|
||||
info="The auth code is invalid.",
|
||||
properties={"email": email, "code": code},
|
||||
)
|
||||
return [UserLoginAction.VERIFY_EMAIL_WITH_AUTH_CODE, None, None, None, None]
|
||||
|
||||
# Step 2: Handle new user creation if necessary
|
||||
# This will be moved to the Freeleaps
|
||||
if is_new_user:
|
||||
user_id = await self.user_service.create_new_user(
|
||||
NewUserMethod.EMAIL, preferred_region, email, time_zone
|
||||
)
|
||||
await self.event_service.log_signup_event(
|
||||
user_id, email, preferred_region, time_zone
|
||||
)
|
||||
|
||||
# Step 3: Fetch user account and handle login actions
|
||||
user_account = await self.user_service.get_user_account(user_id)
|
||||
await self.event_service.log_user_login_event(user_id)
|
||||
|
||||
# Step 4: Handle special actions (FLID reset, password reset, etc.)
|
||||
if await self.user_service.is_flid_reset_required(user_id):
|
||||
return [
|
||||
UserLoginAction.REVIEW_AND_REVISE_FLID,
|
||||
user_account.user_role,
|
||||
user_id,
|
||||
email.split("@")[0],
|
||||
preferred_region,
|
||||
]
|
||||
|
||||
user_flid = await self.user_service.get_flid(user_id)
|
||||
|
||||
if await self.auth_service.is_password_reset_required(user_id):
|
||||
return [
|
||||
UserLoginAction.NEW_USER_SET_PASSWORD,
|
||||
user_account.user_role,
|
||||
user_id,
|
||||
user_flid,
|
||||
user_account.preferred_region,
|
||||
]
|
||||
|
||||
return [
|
||||
UserLoginAction.EXISTING_USER_PASSWORD_REQUIRED,
|
||||
user_account.user_role,
|
||||
user_id,
|
||||
user_flid,
|
||||
user_account.preferred_region,
|
||||
]
|
||||
|
||||
@log_entry_exit_async
|
||||
async def __create_new_user_account(
|
||||
self, method: NewUserMethod, region: UserRegion
|
||||
) -> str:
|
||||
"""create a new user account document in DB
|
||||
|
||||
Args:
|
||||
method (NewUserMethod): the method the new user came from
|
||||
region : preferred user region detected via the user log-in website
|
||||
|
||||
Returns:
|
||||
str: id of user account
|
||||
"""
|
||||
if NewUserMethod.EMAIL == method:
|
||||
user_account = UserAccountDoc(
|
||||
profile_id=None,
|
||||
account_id=None,
|
||||
service_plan_id=None,
|
||||
properties=int(UserAccountProperty.EMAIL_VERIFIED),
|
||||
capabilities=int(Capability.VISITOR),
|
||||
user_role=int(AdministrativeRole.PERSONAL),
|
||||
region=region,
|
||||
)
|
||||
user_account = await user_account.create()
|
||||
|
||||
elif NewUserMethod.MOBILE == method:
|
||||
user_account = UserAccountDoc(
|
||||
profile_id=None,
|
||||
account_id=None,
|
||||
service_plan_id=None,
|
||||
properties=int(UserAccountProperty.MOBILE_VERIFIED),
|
||||
capabilities=int(Capability.VISITOR),
|
||||
user_role=int(AdministrativeRole.PERSONAL),
|
||||
region=region,
|
||||
)
|
||||
user_account = await user_account.create()
|
||||
|
||||
# Create other doc in collections for the new user
|
||||
await UserAchievement(str(user_account.id)).create_activeness_achievement()
|
||||
return str(user_account.id)
|
||||
95
app/authentication/backend/business/signin_manager.py
Normal file
95
app/authentication/backend/business/signin_manager.py
Normal file
@ -0,0 +1,95 @@
|
||||
# business/auth/signin_business.py
|
||||
from typing import Tuple, Optional
|
||||
from app.authentication.backend.services.auth.user_auth_service import UserAuthService
|
||||
from infra.i18n.region_handler import RegionHandler
|
||||
from services.auth.achievement_service import AchievementService
|
||||
from services.auth.event_service import EventService
|
||||
from services.auth.profile_service import ProfileService
|
||||
from utils.enums.auth import UserLoginAction, NewUserMethod
|
||||
from infra.config import settings
|
||||
from infra.exception.exceptions import InvalidAuthCodeException
|
||||
from app.authentication.backend.models.constants import UserLoginAction
|
||||
from app.authentication.backend.services.user.user_management_service import (
|
||||
UserManagementService,
|
||||
)
|
||||
|
||||
|
||||
class SignInManager:
|
||||
def __init__(self):
|
||||
self.user_auth_service = UserAuthService()
|
||||
self.region_handler = RegionHandler()
|
||||
self.user_management_service = UserManagementService()
|
||||
self.achievement_service = AchievementService()
|
||||
self.event_service = EventService()
|
||||
self.profile_service = ProfileService()
|
||||
|
||||
async def signin_with_email_and_code(
|
||||
self, email: str, code: str, host: str, time_zone: Optional[str] = "UTC"
|
||||
) -> Tuple[int, Optional[int], Optional[str], Optional[str]]:
|
||||
"""
|
||||
Handles the business logic for signing in with email and code.
|
||||
"""
|
||||
user_id = await self.user_auth_service.get_user_id_by_email(email)
|
||||
is_new_user = user_id is None
|
||||
preferred_region = self.region_handler.detect_from_host(host)
|
||||
|
||||
if await self.user_auth_service.verify_email_with_code(email, code):
|
||||
if is_new_user:
|
||||
user_id = await self.user_management_service.create_new_user_account(
|
||||
method=NewUserMethod.EMAIL, region=preferred_region
|
||||
)
|
||||
await self.user_service.initialize_new_user_data(
|
||||
user_id=user_id,
|
||||
email=email,
|
||||
region=preferred_region,
|
||||
time_zone=time_zone,
|
||||
)
|
||||
await self.event_service.log_signup_event(
|
||||
user_id=user_id,
|
||||
email=email,
|
||||
region=preferred_region,
|
||||
time_zone=time_zone,
|
||||
)
|
||||
|
||||
user_account = await self.user_service.get_user_account(user_id)
|
||||
# This will be done by sending the notification back to Freeleaps App
|
||||
# await self.achievement_service.record_login_event(user_id)
|
||||
|
||||
if await self.profile_service.is_flid_reset_required(user_id):
|
||||
return (
|
||||
UserLoginAction.REVIEW_AND_REVISE_FLID,
|
||||
user_account.role,
|
||||
user_id,
|
||||
email.split("@")[0],
|
||||
preferred_region,
|
||||
)
|
||||
|
||||
flid = await self.profile_service.get_user_flid(user_id)
|
||||
|
||||
if await self.user_service.is_password_reset_required(user_id):
|
||||
return (
|
||||
UserLoginAction.NEW_USER_SET_PASSWORD,
|
||||
user_account.role,
|
||||
user_id,
|
||||
flid,
|
||||
preferred_region,
|
||||
)
|
||||
|
||||
return (
|
||||
UserLoginAction.USER_SIGNED_IN,
|
||||
user_account.role,
|
||||
user_id,
|
||||
flid,
|
||||
preferred_region,
|
||||
)
|
||||
else:
|
||||
return UserLoginAction.VERIFY_EMAIL_WITH_AUTH_CODE, None, None, None, None
|
||||
|
||||
async def verify_email_with_code(self, email: str, code: str, host: str):
|
||||
user_id = await self.user_auth_service.get_user_id_by_email(email)
|
||||
if not await self.user_auth_service.verify_email_with_code(email, code):
|
||||
raise InvalidAuthCodeException()
|
||||
|
||||
is_new_user = user_id is None
|
||||
preferred_region = self.region_handler.detect_from_host(host)
|
||||
return user_id, is_new_user, preferred_region
|
||||
288
app/authentication/backend/infra/auth/user_auth_handler.py
Normal file
288
app/authentication/backend/infra/auth/user_auth_handler.py
Normal file
@ -0,0 +1,288 @@
|
||||
import bcrypt
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from infra.utils.string import generate_auth_code
|
||||
from app.authentication.backend.infra.code_management.depot_handler import (
|
||||
CodeDepotHandler,
|
||||
)
|
||||
|
||||
|
||||
from app.authentication.backend.models.constants import (
|
||||
AuthType,
|
||||
)
|
||||
from app.authentication.backend.models.models import (
|
||||
AuthCodeDoc,
|
||||
UserEmailDoc,
|
||||
UserMobileDoc,
|
||||
UserPasswordDoc,
|
||||
)
|
||||
|
||||
|
||||
class UserAuthHandler:
|
||||
def __init__(self):
|
||||
self.code_depot_manager = CodeDepotHandler()
|
||||
|
||||
async def verify_user_with_password(self, user_id: str, password: str) -> bool:
|
||||
"""Verify user's password
|
||||
Args:
|
||||
user_id (str): user identity, _id in UserAccountDoc
|
||||
password (str): password user provided, clear text
|
||||
|
||||
Returns:
|
||||
bool: True if password is correct, else return False
|
||||
"""
|
||||
|
||||
user_password = await UserPasswordDoc.find(
|
||||
UserPasswordDoc.user_id == user_id
|
||||
).first_or_none()
|
||||
|
||||
if user_password:
|
||||
# password is reseted to empty string, cannot be verified
|
||||
if user_password.password == "":
|
||||
return False
|
||||
|
||||
if bcrypt.checkpw(
|
||||
password.encode("utf-8"), user_password.password.encode("utf-8")
|
||||
):
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
else:
|
||||
return False
|
||||
|
||||
async def get_user_password(self, user_id: str) -> Optional[str]:
|
||||
"""Get user password through the user_id
|
||||
|
||||
Args:
|
||||
user_id (str): user identity, _id in UserAccountDoc
|
||||
|
||||
Returns:
|
||||
str: password hash
|
||||
"""
|
||||
|
||||
user_password = await UserPasswordDoc.find(
|
||||
UserPasswordDoc.user_id == user_id
|
||||
).first_or_none()
|
||||
|
||||
if user_password is None:
|
||||
return None
|
||||
else:
|
||||
return user_password.password
|
||||
|
||||
async def get_user_email(self, user_id: str) -> Optional[str]:
|
||||
"""get user email through the user_id
|
||||
|
||||
Args:
|
||||
user_id (str): user identity, _id in UserAccountDoc
|
||||
|
||||
Returns:
|
||||
str: email address
|
||||
"""
|
||||
user_email = await UserEmailDoc.find(
|
||||
UserEmailDoc.user_id == user_id
|
||||
).first_or_none()
|
||||
|
||||
if user_email is None:
|
||||
return None
|
||||
else:
|
||||
return user_email.email
|
||||
|
||||
async def get_user_id_by_email(self, email: str) -> Optional[str]:
|
||||
"""get user id through email from user_email doc
|
||||
|
||||
Args:
|
||||
email (str): email address, compare email address in lowercase
|
||||
|
||||
Returns:
|
||||
Optional[str]: user_id or None
|
||||
"""
|
||||
user_email = await UserEmailDoc.find(
|
||||
UserEmailDoc.email == email.lower()
|
||||
).first_or_none()
|
||||
|
||||
if user_email is None:
|
||||
return None
|
||||
else:
|
||||
return user_email.user_id
|
||||
|
||||
def user_sign_out(self, token):
|
||||
pass
|
||||
|
||||
async def generate_auth_code_for_email(self, email: str) -> str:
|
||||
"""send auth code to email address
|
||||
|
||||
Args:
|
||||
email (str): email address
|
||||
"""
|
||||
auth_code = generate_auth_code()
|
||||
expiry = datetime.now(timezone.utc) + timedelta(minutes=5)
|
||||
auth_code_doc = AuthCodeDoc(
|
||||
auth_code=auth_code,
|
||||
method=email.lower(),
|
||||
method_type=AuthType.EMAIL,
|
||||
expiry=expiry,
|
||||
)
|
||||
|
||||
await auth_code_doc.create()
|
||||
return auth_code
|
||||
|
||||
async def verify_email_code(self, email: str, code: str) -> bool:
|
||||
"""sign in with email and code
|
||||
|
||||
Args:
|
||||
email (str): email address
|
||||
code (str): auth code to be verified
|
||||
|
||||
Returns:
|
||||
bool: True if code is valid, False otherwise
|
||||
"""
|
||||
result = await AuthCodeDoc.find(
|
||||
AuthCodeDoc.method == email.lower(),
|
||||
AuthCodeDoc.auth_code == code,
|
||||
AuthCodeDoc.expiry > datetime.now(timezone.utc),
|
||||
AuthCodeDoc.method_type == AuthType.EMAIL,
|
||||
).first_or_none()
|
||||
|
||||
if result:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
async def get_user_mobile(self, user_id: str) -> Optional[str]:
|
||||
"""get user mobile number through the user_id
|
||||
|
||||
Args:
|
||||
user_id (str): user identity, _id in UserAccountDoc
|
||||
|
||||
Returns:
|
||||
str: mobile number
|
||||
"""
|
||||
user_mobile = await UserMobileDoc.find(
|
||||
UserMobileDoc.user_id == user_id
|
||||
).first_or_none()
|
||||
|
||||
if user_mobile is None:
|
||||
return None
|
||||
else:
|
||||
return user_mobile.mobile
|
||||
|
||||
async def generate_auth_code_for_mobile(self, mobile: str) -> str:
|
||||
"""send auth code to mobile number
|
||||
|
||||
Args:
|
||||
mobile (str): mobile number
|
||||
"""
|
||||
auth_code = generate_auth_code()
|
||||
expiry = datetime.now(timezone.utc) + timedelta(minutes=5)
|
||||
auth_code_doc = AuthCodeDoc(
|
||||
auth_code=auth_code,
|
||||
method=mobile.lower(),
|
||||
method_type=AuthType.MOBILE,
|
||||
expiry=expiry,
|
||||
)
|
||||
|
||||
await auth_code_doc.create()
|
||||
return auth_code
|
||||
|
||||
async def verify_mobile_with_code(self, mobile, code):
|
||||
"""sign in with mobile and code
|
||||
|
||||
Args:
|
||||
mobile (str): mobile number
|
||||
code (str): auth code to be verified
|
||||
|
||||
Returns:
|
||||
bool: True if code is valid, False otherwise
|
||||
"""
|
||||
result = await AuthCodeDoc.find(
|
||||
AuthCodeDoc.method == mobile.lower(),
|
||||
AuthCodeDoc.auth_code == code,
|
||||
AuthCodeDoc.expiry > datetime.now(timezone.utc),
|
||||
AuthCodeDoc.method_type == AuthType.MOBILE,
|
||||
).first_or_none()
|
||||
|
||||
if result:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
async def save_email_auth_method(self, user_id: str, email: str):
|
||||
"""save email auth method to user_email doc
|
||||
|
||||
Args:
|
||||
user_id (str): user id
|
||||
email (str): email address
|
||||
"""
|
||||
user_email = await UserEmailDoc.find(
|
||||
UserEmailDoc.user_id == user_id
|
||||
).first_or_none()
|
||||
|
||||
if user_email is None:
|
||||
new_user_email = UserEmailDoc(user_id=user_id, email=email.lower())
|
||||
await new_user_email.create()
|
||||
else:
|
||||
user_email.email = email.lower()
|
||||
await user_email.save()
|
||||
|
||||
async def save_password_auth_method(self, user_id: str, user_flid, password: str):
|
||||
"""save password auth method to user_password doc
|
||||
|
||||
Args:
|
||||
user_id (str): user id
|
||||
password (str): user password
|
||||
"""
|
||||
password_hashed = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt())
|
||||
|
||||
user_password = await UserPasswordDoc.find(
|
||||
UserPasswordDoc.user_id == user_id
|
||||
).first_or_none()
|
||||
|
||||
if user_password is None:
|
||||
new_user_password = UserPasswordDoc(
|
||||
user_id=user_id, password=password_hashed
|
||||
)
|
||||
await new_user_password.create()
|
||||
else:
|
||||
user_password.password = password_hashed
|
||||
await user_password.save()
|
||||
|
||||
result = await self.code_depot_manager.update_depot_user_password(
|
||||
user_flid, password
|
||||
)
|
||||
if not result:
|
||||
raise Exception("Failed to update user password in code depot")
|
||||
|
||||
async def reset_password(self, user_id: str):
|
||||
"""clean password auth method from user_password doc
|
||||
|
||||
Args:
|
||||
user_id (str): user id
|
||||
"""
|
||||
user_password = await UserPasswordDoc.find(
|
||||
UserPasswordDoc.user_id == user_id
|
||||
).first_or_none()
|
||||
|
||||
if user_password:
|
||||
user_password.password = ""
|
||||
await user_password.save()
|
||||
else:
|
||||
raise Exception("User password was not set before.")
|
||||
|
||||
async def is_password_reset_required(self, user_id: str) -> bool:
|
||||
"""check if password is required for the user
|
||||
|
||||
Args:
|
||||
user_id (str): user id
|
||||
|
||||
Returns:
|
||||
bool: True if password is required, False otherwise
|
||||
"""
|
||||
user_password = await UserPasswordDoc.find(
|
||||
UserPasswordDoc.user_id == user_id
|
||||
).first_or_none()
|
||||
|
||||
if user_password:
|
||||
return user_password.password == ""
|
||||
else:
|
||||
return True
|
||||
@ -0,0 +1,416 @@
|
||||
import pytz
|
||||
import random
|
||||
from infra.log.module_logger import ModuleLogger
|
||||
from typing import Optional
|
||||
|
||||
from app.authentication.backend.common.config.app_settings import app_settings
|
||||
|
||||
from app.authentication.backend.models.constants import DepotStatus
|
||||
from app.authentication.backend.infra.code_management.gitea.gitea import Gitea
|
||||
from app.authentication.backend.models.gitea.models import CodeDepotDoc
|
||||
from infra.exception.exceptions import InvalidOperationError
|
||||
from infra.utils.date import get_sunday
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from dateutil.parser import parse
|
||||
|
||||
|
||||
class CodeDepotHandler:
|
||||
def __init__(self) -> None:
|
||||
self.gitea_url = app_settings.GITEA_URL
|
||||
self.gitea_token = app_settings.GITEA_TOKEN
|
||||
self.gitea_org = app_settings.GITEA_DEPOT_ORGANIZATION
|
||||
|
||||
self.code_depot_domain_name = app_settings.CODE_DEPOT_DOMAIN_NAME
|
||||
self.code_depot_ssh_port = app_settings.CODE_DEPOT_SSH_PORT
|
||||
self.code_depot_http_port = app_settings.CODE_DEPOT_HTTP_PORT
|
||||
|
||||
self.gitea_admin = Gitea(
|
||||
self.gitea_url, token_text=self.gitea_token, auth=None, verify=False
|
||||
)
|
||||
self.product_org = self.gitea_admin.get_org_by_name(self.gitea_org)
|
||||
self.module_logger = ModuleLogger(sender_id="CodeDepotManager")
|
||||
|
||||
async def check_depot_name_availabe(self, code_depot_name: str) -> bool:
|
||||
"""Return True if the depot name is available, otherwise return False
|
||||
|
||||
Parameters:
|
||||
code_depot_name (str): the name of the code depot
|
||||
|
||||
Returns:
|
||||
bool: True if the depot name is availabe, otherwise return False
|
||||
"""
|
||||
|
||||
result = await CodeDepotDoc.find_one(CodeDepotDoc.depot_name == code_depot_name)
|
||||
|
||||
if result:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
def check_code_depot_exist(self, code_depot_name: str) -> bool:
|
||||
"""Check and return True if the code deport with the name exist in Gitea, otherwise return False
|
||||
|
||||
Parameters:
|
||||
code_depot_name (str): the name of the code depot
|
||||
|
||||
Returns:
|
||||
bool: True if the code depot exist, otherwise return False
|
||||
"""
|
||||
all_depots = self.product_org.get_repositories()
|
||||
for depot in all_depots:
|
||||
if depot.name.lower() == code_depot_name.lower():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def __generate_new_code_depot_name(
|
||||
self, code_depot_name: str, gitea_code_depot_names: list[str]
|
||||
) -> str:
|
||||
"""Generate a new code depot name if the code depot name already exists
|
||||
|
||||
Parameters:
|
||||
code_depot_name (str): the name of the code depot
|
||||
|
||||
Returns:
|
||||
str: the new code depot name
|
||||
"""
|
||||
code_depot_name = code_depot_name.lower()
|
||||
depot_doc = await CodeDepotDoc.find_one(
|
||||
CodeDepotDoc.depot_name == code_depot_name
|
||||
)
|
||||
|
||||
# if the depot name already exists in the database or Gitea, generate a new name
|
||||
if depot_doc or (code_depot_name in gitea_code_depot_names):
|
||||
new_code_depot_name = "{}-{}".format(
|
||||
code_depot_name, random.randint(10000, 99999)
|
||||
)
|
||||
while new_code_depot_name in gitea_code_depot_names:
|
||||
new_code_depot_name = "{}-{}".format(
|
||||
code_depot_name, random.randint(10000, 99999)
|
||||
)
|
||||
|
||||
return await self.__generate_new_code_depot_name(
|
||||
new_code_depot_name, gitea_code_depot_names
|
||||
)
|
||||
else:
|
||||
return code_depot_name
|
||||
|
||||
async def create_code_depot(self, product_id, code_depot_name) -> Optional[str]:
|
||||
"""Create a new git code depot
|
||||
|
||||
Parameters:
|
||||
product_id (str): the id of the product
|
||||
code_depot_name (str): the name of the code depot
|
||||
|
||||
Returns:
|
||||
str: return code depot id if it's created successfully, else return None
|
||||
"""
|
||||
gitea_code_depot_names = [
|
||||
depot.name.lower() for depot in self.product_org.get_repositories()
|
||||
]
|
||||
code_depot_name_to_be_created = await self.__generate_new_code_depot_name(
|
||||
code_depot_name, gitea_code_depot_names
|
||||
)
|
||||
|
||||
new_depot_doc = CodeDepotDoc(
|
||||
depot_name=code_depot_name_to_be_created,
|
||||
product_id=product_id,
|
||||
depot_status=DepotStatus.TO_BE_CREATED,
|
||||
)
|
||||
await new_depot_doc.create()
|
||||
|
||||
try:
|
||||
self.product_org.create_repo(
|
||||
repoName=code_depot_name_to_be_created,
|
||||
description=product_id,
|
||||
private=True,
|
||||
autoInit=True,
|
||||
default_branch="main",
|
||||
)
|
||||
except Exception as e:
|
||||
await self.module_logger.log_exception(
|
||||
exception=e,
|
||||
text=f"Failed to create depot {code_depot_name_to_be_created} in Gitea. Error: {e}",
|
||||
properties={
|
||||
"code_depot_name_to_be_created": code_depot_name_to_be_created
|
||||
},
|
||||
)
|
||||
raise InvalidOperationError(
|
||||
f"Failed to create depot {code_depot_name_to_be_created} in Gitea"
|
||||
)
|
||||
|
||||
await new_depot_doc.set({CodeDepotDoc.depot_status: DepotStatus.CREATED})
|
||||
await new_depot_doc.save()
|
||||
|
||||
return str(new_depot_doc.id)
|
||||
|
||||
def get_depot_ssh_url(self, code_depot_name: str) -> str:
|
||||
"""Return the ssh url of the code depot
|
||||
|
||||
Parameters:
|
||||
depot_name (str): the name of the depot
|
||||
|
||||
Returns:
|
||||
str: the ssh url of the code depot
|
||||
"""
|
||||
if self.code_depot_ssh_port != "22":
|
||||
return f"git@{self.code_depot_domain_name}:{self.code_depot_ssh_port}/{self.gitea_org}/{code_depot_name}.git"
|
||||
else:
|
||||
return f"git@{self.code_depot_domain_name}/{self.gitea_org}/{code_depot_name}.git"
|
||||
|
||||
def get_depot_http_url(self, code_depot_name: str) -> str:
|
||||
"""Return the http url of the code depot
|
||||
|
||||
Parameters:
|
||||
depot_name (str): the name of the depot
|
||||
|
||||
Returns:
|
||||
str: the http url of the code depot
|
||||
"""
|
||||
if self.code_depot_http_port in ["443", "3443"]:
|
||||
return f"https://{self.code_depot_domain_name}:{self.code_depot_http_port}/{self.gitea_org}/{code_depot_name}.git"
|
||||
else:
|
||||
return f"http://{self.code_depot_domain_name}:{self.code_depot_http_port}/{self.gitea_org}/{code_depot_name}.git"
|
||||
|
||||
def get_depot_http_url_with_user_name(
|
||||
self, code_depot_name: str, user_name: str
|
||||
) -> str:
|
||||
"""Return the http url of the code depot
|
||||
|
||||
Parameters:
|
||||
depot_name (str): the name of the depot
|
||||
|
||||
Returns:
|
||||
str: the http url of the code depot
|
||||
"""
|
||||
if self.code_depot_http_port in ["443", "3443"]:
|
||||
return f"https://{user_name}@{self.code_depot_domain_name}:{self.code_depot_http_port}/{self.gitea_org}/{code_depot_name}.git"
|
||||
else:
|
||||
return f"http://{user_name}@{self.code_depot_domain_name}:{self.code_depot_http_port}/{self.gitea_org}/{code_depot_name}.git"
|
||||
|
||||
async def get_depot_users(self, code_depot_name: str) -> list[str]:
|
||||
"""Return list of user names have permission to access the depot
|
||||
|
||||
Parameters:
|
||||
depot_name (str): the name of the depot
|
||||
|
||||
Returns:
|
||||
list: list of user names
|
||||
"""
|
||||
|
||||
result = await CodeDepotDoc.find_one(CodeDepotDoc.depot_name == code_depot_name)
|
||||
|
||||
if result:
|
||||
return result.collaborators
|
||||
else:
|
||||
return []
|
||||
|
||||
async def update_depot_user_password(self, user_name: str, password: str) -> bool:
|
||||
"""Update the password of the user in Gitea
|
||||
|
||||
Parameters:
|
||||
user_name (str): the name of the user
|
||||
password (str): the new password of the user
|
||||
|
||||
Returns:
|
||||
bool: True if operations succeed, otherwise return False
|
||||
"""
|
||||
depot_user = self.gitea_admin.get_user_by_name(user_name)
|
||||
|
||||
if depot_user:
|
||||
try:
|
||||
self.gitea_admin.update_user_password(user_name, password)
|
||||
except Exception as e:
|
||||
await self.module_logger.log_exception(
|
||||
exception=e,
|
||||
text=f"Failed to update password for user {user_name} in Gitea.",
|
||||
properties={"user_name": user_name},
|
||||
)
|
||||
return False
|
||||
else:
|
||||
await self.module_logger.log_error(
|
||||
error=f"User {user_name} does not exist in Gitea.",
|
||||
properties={"user_name": user_name},
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def create_depot_user(
|
||||
self, user_name: str, password: str, email: str
|
||||
) -> bool:
|
||||
"""Create a new user in Gitea
|
||||
Parameters:
|
||||
user_name (str): the name of the user
|
||||
password (str): the password of the user
|
||||
email (str): email address of the user
|
||||
|
||||
Returns:
|
||||
bool: True if operations succeed, otherwise return False
|
||||
"""
|
||||
depot_user = self.gitea_admin.get_user_by_name(user_name)
|
||||
|
||||
if depot_user:
|
||||
await self.module_logger.log_info(
|
||||
info=f"User {user_name} exist in Gitea.",
|
||||
properties={"user_name": user_name},
|
||||
)
|
||||
return True
|
||||
else:
|
||||
try:
|
||||
await self.module_logger.log_info(
|
||||
info=f"Create user {user_name} in Gitea with password.",
|
||||
properties={"user_name": user_name},
|
||||
)
|
||||
depot_user = self.gitea_admin.create_user(
|
||||
user_name=user_name,
|
||||
login_name=user_name,
|
||||
password=password,
|
||||
email=email,
|
||||
change_pw=False,
|
||||
send_notify=False,
|
||||
)
|
||||
except Exception as e:
|
||||
await self.module_logger.log_exception(
|
||||
exception=e,
|
||||
text=f"Failed to create user {user_name} in Gitea.",
|
||||
properties={"user_name": user_name},
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def grant_user_depot_access(
|
||||
self, user_name: str, code_depot_name: str
|
||||
) -> bool:
|
||||
"""Grant user access to the code depot in Gitea
|
||||
|
||||
Parameters:
|
||||
user_name (str): the name of the user
|
||||
code_depot_name (str): the name of the code depot
|
||||
|
||||
Returns:
|
||||
bool: True if operations succeed, otherwise return False
|
||||
"""
|
||||
try:
|
||||
code_depot = self.product_org.get_repository(code_depot_name)
|
||||
except Exception as e:
|
||||
await self.module_logger.log_exception(
|
||||
exception=e,
|
||||
text=f"Failed to get depot {code_depot_name} in Gitea.",
|
||||
properties={"code_depot_name": code_depot_name},
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
if code_depot.add_collaborator(user_name=user_name, permission="Write"):
|
||||
code_depot_doc = await CodeDepotDoc.find_one(
|
||||
CodeDepotDoc.depot_name == code_depot_name
|
||||
)
|
||||
|
||||
if code_depot_doc:
|
||||
if user_name not in code_depot_doc.collaborators:
|
||||
code_depot_doc.collaborators.append(user_name)
|
||||
await code_depot_doc.save()
|
||||
except Exception as e:
|
||||
await self.module_logger.log_exception(
|
||||
exception=e,
|
||||
text=f"Failed to grant permission for user {user_name} to access code depot {code_depot_name}.",
|
||||
properties={"user_name": user_name, "code_depot_name": code_depot_name},
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def generate_statistic_result(
|
||||
self, code_depot_name: str
|
||||
) -> Optional[dict[str, any]]:
|
||||
"""Call Gitea API and collect statistic result of the repository.
|
||||
|
||||
Args:
|
||||
code_depot_name (str): the name of the code depot
|
||||
|
||||
Returns:
|
||||
dict[str, any]: statistic result
|
||||
"""
|
||||
|
||||
try:
|
||||
code_depot = self.product_org.get_repository(code_depot_name)
|
||||
except Exception as e:
|
||||
await self.module_logger.log_exception(
|
||||
exception=e,
|
||||
text=f"Failed to get depot {code_depot_name} in Gitea.",
|
||||
properties={"code_depot_name": code_depot_name},
|
||||
)
|
||||
return None
|
||||
|
||||
commits = code_depot.get_commits()
|
||||
|
||||
if len(commits) > 0:
|
||||
commit_dates = []
|
||||
for commit in commits:
|
||||
commit_dates.append(parse(commit.created).date())
|
||||
|
||||
today = datetime.now(timezone.utc)
|
||||
last_sunday = get_sunday(today)
|
||||
# only get commits statistic of the last 20 weeks
|
||||
last_20_sundays = [last_sunday - timedelta(weeks=i) for i in range(20)]
|
||||
|
||||
weekly_commits = {}
|
||||
for date in last_20_sundays:
|
||||
weekly_commits[date.strftime("%Y-%m-%d")] = 0
|
||||
|
||||
for commit_date in commit_dates:
|
||||
commit_date_sunday = get_sunday(commit_date).strftime("%Y-%m-%d")
|
||||
if commit_date_sunday in weekly_commits:
|
||||
weekly_commits[commit_date_sunday] += 1
|
||||
|
||||
last_update = parse(commits[-1].created)
|
||||
results = {
|
||||
"total_commits": len(commits),
|
||||
"last_commiter": commits[-1].commit["committer"]["name"],
|
||||
"last_update": last_update.astimezone(pytz.UTC),
|
||||
"weekly_commits": weekly_commits,
|
||||
}
|
||||
else:
|
||||
results = {
|
||||
"total_commits": 0,
|
||||
"last_commiter": "",
|
||||
"last_update": None,
|
||||
"weekly_commits": {},
|
||||
}
|
||||
|
||||
return results
|
||||
|
||||
async def analyze_code_depots(self):
|
||||
code_depot_docs = await CodeDepotDoc.find(
|
||||
CodeDepotDoc.depot_status == DepotStatus.CREATED
|
||||
).to_list()
|
||||
|
||||
for code_depot_doc in code_depot_docs:
|
||||
await self.module_logger.log_info(
|
||||
info="Start to analyze code depot {}".format(code_depot_doc.depot_name),
|
||||
properties={"code_depot_name": code_depot_doc.depot_name},
|
||||
)
|
||||
statistic_result = await self.generate_statistic_result(
|
||||
code_depot_doc.depot_name
|
||||
)
|
||||
|
||||
# cannot get the statistic result, probably it cannot find the
|
||||
# git repository in gitea
|
||||
if not statistic_result:
|
||||
continue
|
||||
|
||||
code_depot_doc.total_commits = statistic_result["total_commits"]
|
||||
code_depot_doc.last_commiter = statistic_result["last_commiter"]
|
||||
code_depot_doc.last_update = statistic_result["last_update"]
|
||||
code_depot_doc.weekly_commits = statistic_result["weekly_commits"]
|
||||
await code_depot_doc.save()
|
||||
|
||||
async def fetch_code_depot(self, code_depot_id: str) -> Optional[dict[str, any]]:
|
||||
code_depot_doc = await CodeDepotDoc.get(code_depot_id)
|
||||
if code_depot_doc:
|
||||
return code_depot_doc.model_dump()
|
||||
else:
|
||||
return None
|
||||
@ -0,0 +1,32 @@
|
||||
from .api_objects import (
|
||||
AlreadyExistsException,
|
||||
Branch,
|
||||
Comment,
|
||||
Commit,
|
||||
Content,
|
||||
Issue,
|
||||
MigrationServices,
|
||||
Milestone,
|
||||
NotFoundException,
|
||||
Organization,
|
||||
Repository,
|
||||
Team,
|
||||
User,
|
||||
)
|
||||
from .gitea import AlreadyExistsException, Gitea, NotFoundException
|
||||
|
||||
__all__ = [
|
||||
"Gitea",
|
||||
"User",
|
||||
"Organization",
|
||||
"Team",
|
||||
"Repository",
|
||||
"Branch",
|
||||
"NotFoundException",
|
||||
"AlreadyExistsException",
|
||||
"Issue",
|
||||
"Milestone",
|
||||
"Commit",
|
||||
"Comment",
|
||||
"Content",
|
||||
]
|
||||
@ -0,0 +1,120 @@
|
||||
from .exceptions import (
|
||||
MissiongEqualyImplementation,
|
||||
ObjectIsInvalid,
|
||||
RawRequestEndpointMissing,
|
||||
)
|
||||
|
||||
|
||||
class ReadonlyApiObject:
|
||||
def __init__(self, gitea):
|
||||
self.gitea = gitea
|
||||
self.deleted = False # set if .delete was called, so that an exception is risen
|
||||
|
||||
def __str__(self):
|
||||
return "GiteaAPIObject (%s):" % (type(self))
|
||||
|
||||
def __eq__(self, other):
|
||||
"""Compare only fields that are part of the gitea-data identity"""
|
||||
raise MissiongEqualyImplementation()
|
||||
|
||||
def __hash__(self):
|
||||
"""Hash only fields that are part of the gitea-data identity"""
|
||||
raise MissiongEqualyImplementation()
|
||||
|
||||
_fields_to_parsers = {}
|
||||
|
||||
@classmethod
|
||||
def request(cls, gitea):
|
||||
if hasattr("API_OBJECT", cls):
|
||||
return cls._request(gitea)
|
||||
else:
|
||||
raise RawRequestEndpointMissing()
|
||||
|
||||
@classmethod
|
||||
def _request(cls, gitea, args):
|
||||
result = cls._get_gitea_api_object(gitea, args)
|
||||
api_object = cls.parse_response(gitea, result)
|
||||
return api_object
|
||||
|
||||
@classmethod
|
||||
def _get_gitea_api_object(cls, gitea, args):
|
||||
"""Retrieving an object always as GET_API_OBJECT"""
|
||||
return gitea.requests_get(cls.API_OBJECT.format(**args))
|
||||
|
||||
@classmethod
|
||||
def parse_response(cls, gitea, result) -> "ReadonlyApiObject":
|
||||
# gitea.logger.debug("Found api object of type %s (id: %s)" % (type(cls), id))
|
||||
api_object = cls(gitea)
|
||||
cls._initialize(gitea, api_object, result)
|
||||
return api_object
|
||||
|
||||
@classmethod
|
||||
def _initialize(cls, gitea, api_object, result):
|
||||
for name, value in result.items():
|
||||
if name in cls._fields_to_parsers and value is not None:
|
||||
parse_func = cls._fields_to_parsers[name]
|
||||
value = parse_func(gitea, value)
|
||||
cls._add_read_property(name, value, api_object)
|
||||
# add all patchable fields missing in the request to be writable
|
||||
for name in cls._fields_to_parsers.keys():
|
||||
if not hasattr(api_object, name):
|
||||
cls._add_read_property(name, None, api_object)
|
||||
|
||||
@classmethod
|
||||
def _add_read_property(cls, name, value, api_object):
|
||||
if not hasattr(api_object, name):
|
||||
setattr(api_object, "_" + name, value)
|
||||
prop = property((lambda n: lambda self: self._get_var(n))(name))
|
||||
setattr(cls, name, prop)
|
||||
else:
|
||||
raise AttributeError(f"Attribute {name} already exists on api object.")
|
||||
|
||||
def _get_var(self, name):
|
||||
if self.deleted:
|
||||
raise ObjectIsInvalid()
|
||||
return getattr(self, "_" + name)
|
||||
|
||||
|
||||
class ApiObject(ReadonlyApiObject):
|
||||
_patchable_fields = set()
|
||||
|
||||
def __init__(self, gitea):
|
||||
super().__init__(gitea)
|
||||
self._dirty_fields = set()
|
||||
|
||||
def commit(self):
|
||||
raise NotImplemented()
|
||||
|
||||
_parsers_to_fields = {}
|
||||
|
||||
def get_dirty_fields(self):
|
||||
dirty_fields_values = {}
|
||||
for field in self._dirty_fields:
|
||||
value = getattr(self, field)
|
||||
if field in self._parsers_to_fields:
|
||||
dirty_fields_values[field] = self._parsers_to_fields[field](value)
|
||||
else:
|
||||
dirty_fields_values[field] = value
|
||||
return dirty_fields_values
|
||||
|
||||
@classmethod
|
||||
def _initialize(cls, gitea, api_object, result):
|
||||
super()._initialize(gitea, api_object, result)
|
||||
for name in cls._patchable_fields:
|
||||
cls._add_write_property(name, None, api_object)
|
||||
|
||||
@classmethod
|
||||
def _add_write_property(cls, name, value, api_object):
|
||||
if not hasattr(api_object, "_" + name):
|
||||
setattr(api_object, "_" + name, value)
|
||||
prop = property(
|
||||
(lambda n: lambda self: self._get_var(n))(name),
|
||||
(lambda n: lambda self, v: self.__set_var(n, v))(name),
|
||||
)
|
||||
setattr(cls, name, prop)
|
||||
|
||||
def __set_var(self, name, i):
|
||||
if self.deleted:
|
||||
raise ObjectIsInvalid()
|
||||
self._dirty_fields.add(name)
|
||||
setattr(self, "_" + name, i)
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,30 @@
|
||||
class AlreadyExistsException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class NotFoundException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ObjectIsInvalid(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ConflictException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class RawRequestEndpointMissing(Exception):
|
||||
"""This ApiObject can only be obtained through other api objects and does not have
|
||||
diret .request method."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class MissiongEqualyImplementation(Exception):
|
||||
"""
|
||||
Each Object obtained from the gitea api must be able to check itself for equality in relation to its
|
||||
fields obtained from gitea. Risen if an api object is lacking the proper implementation.
|
||||
"""
|
||||
|
||||
pass
|
||||
392
app/authentication/backend/infra/code_management/gitea/gitea.py
Normal file
392
app/authentication/backend/infra/code_management/gitea/gitea.py
Normal file
@ -0,0 +1,392 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, List, Union
|
||||
|
||||
import requests
|
||||
import urllib3
|
||||
from frozendict import frozendict
|
||||
|
||||
from .api_objects import Organization, Repository, Team, User
|
||||
from .exceptions import AlreadyExistsException, ConflictException, NotFoundException
|
||||
|
||||
|
||||
class Gitea:
|
||||
"""Object to establish a session with Gitea."""
|
||||
|
||||
ADMIN_CREATE_USER = """/admin/users"""
|
||||
ADMIN_EDIT_USER = """/admin/users/%s""" # <username>
|
||||
GET_USERS_ADMIN = """/admin/users"""
|
||||
ADMIN_REPO_CREATE = """/admin/users/%s/repos""" # <ownername>
|
||||
GITEA_VERSION = """/version"""
|
||||
GET_USER = """/user"""
|
||||
CREATE_ORG = """/admin/users/%s/orgs""" # <username>
|
||||
CREATE_TEAM = """/orgs/%s/teams""" # <orgname>
|
||||
|
||||
def __init__(
|
||||
self, gitea_url: str, token_text=None, auth=None, verify=True, log_level="INFO"
|
||||
):
|
||||
"""Initializing Gitea-instance
|
||||
|
||||
Args:
|
||||
gitea_url (str): The Gitea instance URL.
|
||||
token_text (str, None): The access token, by default None.
|
||||
auth (tuple, None): The user credentials
|
||||
`(username, password)`, by default None.
|
||||
verify (bool): If True, allow insecure server connections
|
||||
when using SSL.
|
||||
log_level (str): The log level, by default `INFO`.
|
||||
"""
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.logger.setLevel(log_level)
|
||||
self.headers = {
|
||||
"Content-type": "application/json",
|
||||
}
|
||||
self.url = gitea_url
|
||||
self.requests = requests.Session()
|
||||
|
||||
# Manage authentification
|
||||
if not token_text and not auth:
|
||||
raise ValueError("Please provide auth or token_text, but not both")
|
||||
if token_text:
|
||||
self.headers["Authorization"] = "token " + token_text
|
||||
if auth:
|
||||
self.requests.auth = auth
|
||||
|
||||
# Manage SSL certification verification
|
||||
self.requests.verify = verify
|
||||
if not verify:
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
def __get_url(self, endpoint):
|
||||
url = self.url + "/api/v1" + endpoint
|
||||
self.logger.debug("Url: %s" % url)
|
||||
return url
|
||||
|
||||
@staticmethod
|
||||
def parse_result(result) -> Dict:
|
||||
"""Parses the result-JSON to a dict."""
|
||||
if result.text and len(result.text) > 3:
|
||||
return json.loads(result.text)
|
||||
return {}
|
||||
|
||||
def requests_get(self, endpoint: str, params=frozendict(), sudo=None):
|
||||
combined_params = {}
|
||||
combined_params.update(params)
|
||||
if sudo:
|
||||
combined_params["sudo"] = sudo.username
|
||||
request = self.requests.get(
|
||||
self.__get_url(endpoint), headers=self.headers, params=combined_params
|
||||
)
|
||||
if request.status_code not in [200, 201, 204]:
|
||||
message = f"Received status code: {request.status_code} ({request.url})"
|
||||
if request.status_code in [404]:
|
||||
raise NotFoundException(message)
|
||||
if request.status_code in [403]:
|
||||
raise Exception(
|
||||
f"Unauthorized: {request.url} - Check your permissions and try again! ({message})"
|
||||
)
|
||||
if request.status_code in [409]:
|
||||
raise ConflictException(message)
|
||||
raise Exception(message)
|
||||
return self.parse_result(request)
|
||||
|
||||
def requests_get_paginated(
|
||||
self,
|
||||
endpoint: str,
|
||||
params=frozendict(),
|
||||
sudo=None,
|
||||
page_key: str = "page",
|
||||
page_limit: int = 0,
|
||||
):
|
||||
page = 1
|
||||
combined_params = {}
|
||||
combined_params.update(params)
|
||||
aggregated_result = []
|
||||
while True:
|
||||
combined_params[page_key] = page
|
||||
result = self.requests_get(endpoint, combined_params, sudo)
|
||||
if not result:
|
||||
return aggregated_result
|
||||
aggregated_result.extend(result)
|
||||
page += 1
|
||||
if page_limit and page > page_limit:
|
||||
return aggregated_result
|
||||
|
||||
def requests_put(self, endpoint: str, data: dict = None):
|
||||
if not data:
|
||||
data = {}
|
||||
request = self.requests.put(
|
||||
self.__get_url(endpoint), headers=self.headers, data=json.dumps(data)
|
||||
)
|
||||
if request.status_code not in [200, 204]:
|
||||
message = f"Received status code: {request.status_code} ({request.url}) {request.text}"
|
||||
self.logger.error(message)
|
||||
raise Exception(message)
|
||||
|
||||
def requests_delete(self, endpoint: str):
|
||||
request = self.requests.delete(self.__get_url(endpoint), headers=self.headers)
|
||||
if request.status_code not in [204]:
|
||||
message = f"Received status code: {request.status_code} ({request.url})"
|
||||
self.logger.error(message)
|
||||
raise Exception(message)
|
||||
|
||||
def requests_post(self, endpoint: str, data: dict):
|
||||
request = self.requests.post(
|
||||
self.__get_url(endpoint), headers=self.headers, data=json.dumps(data)
|
||||
)
|
||||
if request.status_code not in [200, 201, 202]:
|
||||
if (
|
||||
"already exists" in request.text
|
||||
or "e-mail already in use" in request.text
|
||||
):
|
||||
self.logger.warning(request.text)
|
||||
raise AlreadyExistsException()
|
||||
self.logger.error(
|
||||
f"Received status code: {request.status_code} ({request.url})"
|
||||
)
|
||||
self.logger.error(f"With info: {data} ({self.headers})")
|
||||
self.logger.error(f"Answer: {request.text}")
|
||||
raise Exception(
|
||||
f"Received status code: {request.status_code} ({request.url}), {request.text}"
|
||||
)
|
||||
return self.parse_result(request)
|
||||
|
||||
def requests_patch(self, endpoint: str, data: dict):
|
||||
request = self.requests.patch(
|
||||
self.__get_url(endpoint), headers=self.headers, data=json.dumps(data)
|
||||
)
|
||||
if request.status_code not in [200, 201]:
|
||||
error_message = (
|
||||
f"Received status code: {request.status_code} ({request.url}) {data}"
|
||||
)
|
||||
self.logger.error(error_message)
|
||||
raise Exception(error_message)
|
||||
return self.parse_result(request)
|
||||
|
||||
def get_orgs_public_members_all(self, orgname):
|
||||
path = "/orgs/" + orgname + "/public_members"
|
||||
return self.requests_get(path)
|
||||
|
||||
def get_orgs(self):
|
||||
path = "/admin/orgs"
|
||||
results = self.requests_get(path)
|
||||
return [Organization.parse_response(self, result) for result in results]
|
||||
|
||||
def get_org_by_name(self, org_name: str) -> Organization:
|
||||
path = "/admin/orgs"
|
||||
results = self.requests_get(path)
|
||||
|
||||
for result in results:
|
||||
org = Organization.parse_response(self, result)
|
||||
if org.name == org_name:
|
||||
return org
|
||||
|
||||
def get_user(self):
|
||||
result = self.requests_get(Gitea.GET_USER)
|
||||
return User.parse_response(self, result)
|
||||
|
||||
def get_version(self) -> str:
|
||||
result = self.requests_get(Gitea.GITEA_VERSION)
|
||||
return result["version"]
|
||||
|
||||
def get_users(self) -> List[User]:
|
||||
results = self.requests_get(Gitea.GET_USERS_ADMIN)
|
||||
return [User.parse_response(self, result) for result in results]
|
||||
|
||||
def get_user_by_email(self, email: str) -> User:
|
||||
users = self.get_users()
|
||||
for user in users:
|
||||
if user.email == email or email in user.emails:
|
||||
return user
|
||||
return None
|
||||
|
||||
def get_user_by_name(self, username: str) -> User:
|
||||
users = self.get_users()
|
||||
for user in users:
|
||||
if user.username == username:
|
||||
return user
|
||||
return None
|
||||
|
||||
def update_user_password(self, user_name: str, password: str):
|
||||
request_data = {"password": password, "login_name": user_name}
|
||||
result = self.requests_patch(
|
||||
Gitea.ADMIN_EDIT_USER % user_name, data=request_data
|
||||
)
|
||||
self.logger.debug("Gitea response - update_user_password(): %s", result)
|
||||
|
||||
def create_user(
|
||||
self,
|
||||
user_name: str,
|
||||
email: str,
|
||||
password: str,
|
||||
full_name: str = None,
|
||||
login_name: str = None,
|
||||
change_pw=True,
|
||||
send_notify=True,
|
||||
source_id=0,
|
||||
):
|
||||
"""Create User.
|
||||
Throws:
|
||||
AlreadyExistsException, if the User exists already
|
||||
Exception, if something else went wrong.
|
||||
"""
|
||||
if not login_name:
|
||||
login_name = user_name
|
||||
if not full_name:
|
||||
full_name = user_name
|
||||
request_data = {
|
||||
"source_id": source_id,
|
||||
"login_name": login_name,
|
||||
"full_name": full_name,
|
||||
"username": user_name,
|
||||
"email": email,
|
||||
"password": password,
|
||||
"send_notify": send_notify,
|
||||
"must_change_password": change_pw,
|
||||
}
|
||||
|
||||
self.logger.debug("Gitea post payload: %s", request_data)
|
||||
result = self.requests_post(Gitea.ADMIN_CREATE_USER, data=request_data)
|
||||
if "id" in result:
|
||||
self.logger.info(
|
||||
"Successfully created User %s <%s> (id %s)",
|
||||
result["login"],
|
||||
result["email"],
|
||||
result["id"],
|
||||
)
|
||||
self.logger.debug("Gitea response: %s", result)
|
||||
else:
|
||||
self.logger.error(result["message"])
|
||||
raise Exception("User not created... (gitea: %s)" % result["message"])
|
||||
user = User.parse_response(self, result)
|
||||
return user
|
||||
|
||||
def create_repo(
|
||||
self,
|
||||
repoOwner: Union[User, Organization],
|
||||
repoName: str,
|
||||
description: str = "",
|
||||
private: bool = False,
|
||||
autoInit=True,
|
||||
gitignores: str = None,
|
||||
license: str = None,
|
||||
readme: str = "Default",
|
||||
issue_labels: str = None,
|
||||
default_branch="master",
|
||||
):
|
||||
"""Create a Repository as the administrator
|
||||
|
||||
Throws:
|
||||
AlreadyExistsException: If the Repository exists already.
|
||||
Exception: If something else went wrong.
|
||||
|
||||
Note:
|
||||
Non-admin users can not use this method. Please use instead
|
||||
`gitea.User.create_repo` or `gitea.Organization.create_repo`.
|
||||
"""
|
||||
# although this only says user in the api, this also works for
|
||||
# organizations
|
||||
assert isinstance(repoOwner, User) or isinstance(repoOwner, Organization)
|
||||
result = self.requests_post(
|
||||
Gitea.ADMIN_REPO_CREATE % repoOwner.username,
|
||||
data={
|
||||
"name": repoName,
|
||||
"description": description,
|
||||
"private": private,
|
||||
"auto_init": autoInit,
|
||||
"gitignores": gitignores,
|
||||
"license": license,
|
||||
"issue_labels": issue_labels,
|
||||
"readme": readme,
|
||||
"default_branch": default_branch,
|
||||
},
|
||||
)
|
||||
if "id" in result:
|
||||
self.logger.info("Successfully created Repository %s " % result["name"])
|
||||
else:
|
||||
self.logger.error(result["message"])
|
||||
raise Exception("Repository not created... (gitea: %s)" % result["message"])
|
||||
return Repository.parse_response(self, result)
|
||||
|
||||
def create_org(
|
||||
self,
|
||||
owner: User,
|
||||
orgName: str,
|
||||
description: str,
|
||||
location="",
|
||||
website="",
|
||||
full_name="",
|
||||
):
|
||||
assert isinstance(owner, User)
|
||||
result = self.requests_post(
|
||||
Gitea.CREATE_ORG % owner.username,
|
||||
data={
|
||||
"username": orgName,
|
||||
"description": description,
|
||||
"location": location,
|
||||
"website": website,
|
||||
"full_name": full_name,
|
||||
},
|
||||
)
|
||||
if "id" in result:
|
||||
self.logger.info(
|
||||
"Successfully created Organization %s" % result["username"]
|
||||
)
|
||||
else:
|
||||
self.logger.error(
|
||||
"Organization not created... (gitea: %s)" % result["message"]
|
||||
)
|
||||
self.logger.error(result["message"])
|
||||
raise Exception(
|
||||
"Organization not created... (gitea: %s)" % result["message"]
|
||||
)
|
||||
return Organization.parse_response(self, result)
|
||||
|
||||
def create_team(
|
||||
self,
|
||||
org: Organization,
|
||||
name: str,
|
||||
description: str = "",
|
||||
permission: str = "read",
|
||||
can_create_org_repo: bool = False,
|
||||
includes_all_repositories: bool = False,
|
||||
units=(
|
||||
"repo.code",
|
||||
"repo.issues",
|
||||
"repo.ext_issues",
|
||||
"repo.wiki",
|
||||
"repo.pulls",
|
||||
"repo.releases",
|
||||
"repo.ext_wiki",
|
||||
),
|
||||
):
|
||||
"""Creates a Team.
|
||||
|
||||
Args:
|
||||
org (Organization): Organization the Team will be part of.
|
||||
name (str): The Name of the Team to be created.
|
||||
description (str): Optional, None, short description of the new Team.
|
||||
permission (str): Optional, 'read', What permissions the members
|
||||
"""
|
||||
result = self.requests_post(
|
||||
Gitea.CREATE_TEAM % org.username,
|
||||
data={
|
||||
"name": name,
|
||||
"description": description,
|
||||
"permission": permission,
|
||||
"can_create_org_repo": can_create_org_repo,
|
||||
"includes_all_repositories": includes_all_repositories,
|
||||
"units": units,
|
||||
},
|
||||
)
|
||||
if "id" in result:
|
||||
self.logger.info("Successfully created Team %s" % result["name"])
|
||||
else:
|
||||
self.logger.error("Team not created... (gitea: %s)" % result["message"])
|
||||
self.logger.error(result["message"])
|
||||
raise Exception("Team not created... (gitea: %s)" % result["message"])
|
||||
api_object = Team.parse_response(self, result)
|
||||
setattr(
|
||||
api_object, "_organization", org
|
||||
) # fixes strange behaviour of gitea not returning a valid organization here.
|
||||
return api_object
|
||||
@ -0,0 +1,7 @@
|
||||
This folder will be extracted to a separate service - DevOps Service
|
||||
Scope:
|
||||
- Gitea’s code management. Continuous Deployment.
|
||||
See doc:
|
||||
- Freeleaps All-In-One development solution spec & high-level design.docx .
|
||||
|
||||
Code temporarily put here before it's up.
|
||||
@ -0,0 +1,2 @@
|
||||
class UserProfileHandler:
|
||||
pass
|
||||
43
app/authentication/backend/models/constants.py
Normal file
43
app/authentication/backend/models/constants.py
Normal file
@ -0,0 +1,43 @@
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class NewUserMethod(IntEnum):
|
||||
EMAIL = 1
|
||||
MOBILE = 2
|
||||
|
||||
|
||||
class UserAccountProperty(IntEnum):
|
||||
EMAIL_VERIFIED = 1
|
||||
MOBILE_VERIFIED = 2
|
||||
PAYMENT_SETUP = 4
|
||||
ACCEPT_REQUEST = 8
|
||||
READY_PROVIDER = 16
|
||||
MANAGE_PROJECT = 32
|
||||
|
||||
|
||||
class UserLoginAction(IntEnum):
|
||||
VERIFY_EMAIL_WITH_AUTH_CODE = 0
|
||||
EXISTING_USER_PASSWORD_REQUIRED = 1
|
||||
NEW_USER_SET_PASSWORD = 2
|
||||
EMAIL_NOT_ASSOCIATED_WITH_USER = 3
|
||||
REVIEW_AND_REVISE_FLID = 4
|
||||
USER_SIGNED_IN = 100
|
||||
|
||||
|
||||
class AuthType(IntEnum):
|
||||
MOBILE = 0
|
||||
EMAIL = 1
|
||||
PASSWORD = 2
|
||||
|
||||
|
||||
class DepotStatus(IntEnum):
|
||||
TO_BE_CREATED = 0
|
||||
CREATED = 1
|
||||
DELETED = 2
|
||||
|
||||
|
||||
class UserAccountStatus(IntEnum):
|
||||
TO_BE_CREATED = 0
|
||||
CREATED = 1
|
||||
DELETED = 2
|
||||
DEACTIVATED = 3
|
||||
14
app/authentication/backend/models/gitea/constants.py
Normal file
14
app/authentication/backend/models/gitea/constants.py
Normal file
@ -0,0 +1,14 @@
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class DepotStatus(IntEnum):
|
||||
TO_BE_CREATED = 0
|
||||
CREATED = 1
|
||||
DELETED = 2
|
||||
|
||||
|
||||
class UserAccountStatus(IntEnum):
|
||||
TO_BE_CREATED = 0
|
||||
CREATED = 1
|
||||
DELETED = 2
|
||||
DEACTIVATED = 3
|
||||
21
app/authentication/backend/models/gitea/models.py
Normal file
21
app/authentication/backend/models/gitea/models.py
Normal file
@ -0,0 +1,21 @@
|
||||
from typing import Dict, Optional
|
||||
from datetime import datetime, timezone
|
||||
from beanie import Document
|
||||
from app.authentication.backend.models.gitea.constants import (
|
||||
DepotStatus,
|
||||
UserAccountStatus,
|
||||
)
|
||||
|
||||
|
||||
class CodeDepotDoc(Document):
|
||||
depot_name: str
|
||||
product_id: str
|
||||
depot_status: DepotStatus
|
||||
collaborators: list[str] = []
|
||||
total_commits: Optional[int] = 0
|
||||
last_commiter: Optional[str] = ""
|
||||
last_update: Optional[datetime] = datetime.now(timezone.utc)
|
||||
weekly_commits: Optional[Dict[str, int]] = {}
|
||||
|
||||
class Settings:
|
||||
name = "code_depot"
|
||||
1
app/authentication/backend/models/gitea/readme.md
Normal file
1
app/authentication/backend/models/gitea/readme.md
Normal file
@ -0,0 +1 @@
|
||||
Models here will be moved out when building the DevOps Service
|
||||
37
app/authentication/backend/models/models.py
Normal file
37
app/authentication/backend/models/models.py
Normal file
@ -0,0 +1,37 @@
|
||||
from datetime import datetime
|
||||
from beanie import Document
|
||||
from .constants import AuthType
|
||||
|
||||
|
||||
class UserPasswordDoc(Document):
|
||||
user_id: str
|
||||
password: str
|
||||
|
||||
class Settings:
|
||||
name = "user_password"
|
||||
|
||||
|
||||
class UserEmailDoc(Document):
|
||||
user_id: str
|
||||
email: str
|
||||
|
||||
class Settings:
|
||||
name = "user_email"
|
||||
|
||||
|
||||
class UserMobileDoc(Document):
|
||||
user_id: str
|
||||
mobile: str
|
||||
|
||||
class Settings:
|
||||
name = "user_mobile"
|
||||
|
||||
|
||||
class AuthCodeDoc(Document):
|
||||
auth_code: str
|
||||
method: str
|
||||
method_type: AuthType
|
||||
expiry: datetime
|
||||
|
||||
class Settings:
|
||||
name = "user_auth_code"
|
||||
26
app/authentication/backend/models/permission/constants.py
Normal file
26
app/authentication/backend/models/permission/constants.py
Normal file
@ -0,0 +1,26 @@
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class AdministrativeRole(IntEnum):
|
||||
NONE = 0
|
||||
PERSONAL = 1
|
||||
BUSINESS = 2
|
||||
CONTRIBUTOR = 4
|
||||
ADMINISTRATOR = 8
|
||||
# now UI cannot siginin if user role is 8
|
||||
|
||||
|
||||
class Capability(IntEnum):
|
||||
VISITOR = 1
|
||||
COMMUNICATOR = 2
|
||||
REQUESTER = 4
|
||||
PROVIDER = 8
|
||||
DEVELOPER = 16
|
||||
|
||||
|
||||
class Feature(IntEnum):
|
||||
ANY = 0xFFFFFFFF
|
||||
SENDMESSAGE = 0x1
|
||||
INITIATEREQUEST = 0x2
|
||||
MAKEPROPOSAL = 0x4
|
||||
CREATEPROJECT = 0x8
|
||||
24
app/authentication/backend/models/user/constants.py
Normal file
24
app/authentication/backend/models/user/constants.py
Normal file
@ -0,0 +1,24 @@
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class NewUserMethod(IntEnum):
|
||||
EMAIL = 1
|
||||
MOBILE = 2
|
||||
|
||||
|
||||
class UserAccountProperty(IntEnum):
|
||||
EMAIL_VERIFIED = 1
|
||||
MOBILE_VERIFIED = 2
|
||||
PAYMENT_SETUP = 4
|
||||
ACCEPT_REQUEST = 8
|
||||
READY_PROVIDER = 16
|
||||
MANAGE_PROJECT = 32
|
||||
|
||||
|
||||
class UserLoginAction(IntEnum):
|
||||
VERIFY_EMAIL_WITH_AUTH_CODE = 0
|
||||
EXISTING_USER_PASSWORD_REQUIRED = 1
|
||||
NEW_USER_SET_PASSWORD = 2
|
||||
EMAIL_NOT_ASSOCIATED_WITH_USER = 3
|
||||
REVIEW_AND_REVISE_FLID = 4
|
||||
USER_SIGNED_IN = 100
|
||||
20
app/authentication/backend/models/user/models.py
Normal file
20
app/authentication/backend/models/user/models.py
Normal file
@ -0,0 +1,20 @@
|
||||
from typing import Optional
|
||||
|
||||
from beanie import Document
|
||||
|
||||
from .constants import UserAccountProperty
|
||||
from .permission.constants import AdministrativeRole, Capability
|
||||
from infra.models.constants import UserRegion
|
||||
|
||||
|
||||
class UserAccountDoc(Document):
|
||||
profile_id: Optional[str]
|
||||
account_id: Optional[str]
|
||||
service_plan_id: Optional[str]
|
||||
properties: UserAccountProperty
|
||||
capabilities: Capability
|
||||
user_role: int = AdministrativeRole.NONE
|
||||
preferred_region: UserRegion = UserRegion.ZH_CN
|
||||
|
||||
class Settings:
|
||||
name = "user_account"
|
||||
@ -0,0 +1,30 @@
|
||||
from app.authentication.backend.infra.user_management.user_auth_handler import (
|
||||
UserAuthManager,
|
||||
)
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class UserAuthService:
|
||||
def __init__(self):
|
||||
self.user_auth_manager = UserAuthManager()
|
||||
|
||||
async def get_user_id_by_email(self, email: str) -> Optional[str]:
|
||||
return await self.user_auth_manager.get_user_id_by_email(email)
|
||||
|
||||
async def verify_email_code(self, email: str, code: str) -> bool:
|
||||
return await self.user_auth_manager.verify_email_code(email, code)
|
||||
|
||||
async def create_new_user_account(self, method: str, region: str) -> str:
|
||||
return await self.user_auth_manager.create_user_account(method, region)
|
||||
|
||||
async def initialize_new_user_data(
|
||||
self, user_id: str, email: str, region: str, time_zone: str
|
||||
):
|
||||
# Initialize user data
|
||||
await self.user_auth_manager.init_user_data(user_id, email, region, time_zone)
|
||||
|
||||
async def get_user_account(self, user_id: str):
|
||||
return await self.user_auth_manager.get_user_account(user_id)
|
||||
|
||||
async def is_password_reset_required(self, user_id: str) -> bool:
|
||||
return await self.user_auth_manager.is_password_reset_required(user_id)
|
||||
@ -0,0 +1,80 @@
|
||||
import random
|
||||
from typing import Optional, Dict, Tuple, List
|
||||
from infra.log.module_logger import ModuleLogger
|
||||
|
||||
from app.authentication.backend.application.models.user.constants import (
|
||||
NewUserMethod,
|
||||
UserAccountProperty,
|
||||
UserLoginAction,
|
||||
)
|
||||
from app.authentication.backend.application.user.models import UserAccountDoc
|
||||
from app.authentication.backend.business.permission.constants import (
|
||||
AdministrativeRole,
|
||||
Capability,
|
||||
)
|
||||
from app.authentication.backend.infra.auth.user_auth_handler import (
|
||||
UserAuthHandler,
|
||||
)
|
||||
from app.authentication.backend.infra.user.user_profile_handler import (
|
||||
UserProfileHandler,
|
||||
)
|
||||
from backend.infra.config.backend import settings
|
||||
from backend.infra.log.log_utils import log_entry_exit_async
|
||||
from backend.business.credit.user import UserAchievement
|
||||
from backend.services.profile.basic import BasicProfileStore
|
||||
from backend.services.profile.provider import ProviderProfileStore
|
||||
from backend.services.common.constants import UserRegion
|
||||
from backend.services.common.region import RegionHandler
|
||||
from backend.infra.depot.depot_manager import CodeDepotManager
|
||||
from backend.infra.utils.string import check_password_complexity
|
||||
from backend.business.notification.notification_center import NotificationCenter
|
||||
from backend.business.events.user_event_dispatcher import UserEventDispatcher
|
||||
from backend.infra.exception.exceptions import InvalidDataError
|
||||
|
||||
|
||||
class UserManagementService:
|
||||
def __init__(self) -> None:
|
||||
self.user_auth_handler = UserAuthHandler()
|
||||
self.user_profile_handler = UserProfileHandler()
|
||||
self.module_logger = ModuleLogger(sender_id=UserManagementService)
|
||||
|
||||
@log_entry_exit_async
|
||||
async def create_new_user_account(
|
||||
self, method: NewUserMethod, region: UserRegion
|
||||
) -> str:
|
||||
"""create a new user account document in DB
|
||||
|
||||
Args:
|
||||
method (NewUserMethod): the method the new user came from
|
||||
region : preferred user region detected via the user log-in website
|
||||
|
||||
Returns:
|
||||
str: id of user account
|
||||
"""
|
||||
if NewUserMethod.EMAIL == method:
|
||||
user_account = UserAccountDoc(
|
||||
profile_id=None,
|
||||
account_id=None,
|
||||
service_plan_id=None,
|
||||
properties=int(UserAccountProperty.EMAIL_VERIFIED),
|
||||
capabilities=int(Capability.VISITOR),
|
||||
user_role=int(AdministrativeRole.PERSONAL),
|
||||
region=region,
|
||||
)
|
||||
user_account = await user_account.create()
|
||||
|
||||
elif NewUserMethod.MOBILE == method:
|
||||
user_account = UserAccountDoc(
|
||||
profile_id=None,
|
||||
account_id=None,
|
||||
service_plan_id=None,
|
||||
properties=int(UserAccountProperty.MOBILE_VERIFIED),
|
||||
capabilities=int(Capability.VISITOR),
|
||||
user_role=int(AdministrativeRole.PERSONAL),
|
||||
region=region,
|
||||
)
|
||||
user_account = await user_account.create()
|
||||
|
||||
# Create other doc in collections for the new user
|
||||
await UserAchievement(str(user_account.id)).create_activeness_achievement()
|
||||
return str(user_account.id)
|
||||
20
app/authentication/common/config/app_settings.py
Normal file
20
app/authentication/common/config/app_settings.py
Normal file
@ -0,0 +1,20 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class AppSettings(BaseSettings):
|
||||
NAME: str = "central_storage"
|
||||
|
||||
GITEA_URL: str = ""
|
||||
GITEA_TOKEN: str = ""
|
||||
GITEA_DEPOT_ORGANIZATION: str = ""
|
||||
|
||||
CODE_DEPOT_DOMAIN_NAME: str = ""
|
||||
CODE_DEPOT_SSH_PORT: str = ""
|
||||
CODE_DEPOT_HTTP_PORT: str = ""
|
||||
|
||||
class Config:
|
||||
env_file = ".myapp.env"
|
||||
env_file_encoding = "utf-8"
|
||||
|
||||
|
||||
app_settings = AppSettings()
|
||||
75
app/authentication/webapi/bootstrap/application.py
Normal file
75
app/authentication/webapi/bootstrap/application.py
Normal file
@ -0,0 +1,75 @@
|
||||
import logging
|
||||
from fastapi import FastAPI
|
||||
from fastapi.openapi.utils import get_openapi
|
||||
|
||||
from webapi.providers import common
|
||||
from webapi.providers import logger
|
||||
from webapi.providers import router
|
||||
from webapi.providers import database
|
||||
from webapi.providers import scheduler
|
||||
from webapi.providers import exception_handler
|
||||
from .freeleaps_app import FreeleapsApp
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
logging.info("App initializing")
|
||||
|
||||
app = FreeleapsApp()
|
||||
|
||||
register(app, exception_handler)
|
||||
register(app, database)
|
||||
register(app, logger)
|
||||
register(app, router)
|
||||
register(app, scheduler)
|
||||
register(app, common)
|
||||
|
||||
# Call the custom_openapi function to change the OpenAPI version
|
||||
customize_openapi_security(app)
|
||||
return app
|
||||
|
||||
|
||||
# This function overrides the OpenAPI schema version to 3.0.0
|
||||
def customize_openapi_security(app: FastAPI) -> None:
|
||||
|
||||
def custom_openapi():
|
||||
if app.openapi_schema:
|
||||
return app.openapi_schema
|
||||
|
||||
# Generate OpenAPI schema
|
||||
openapi_schema = get_openapi(
|
||||
title="FreeLeaps API",
|
||||
version="3.1.0",
|
||||
description="FreeLeaps API Documentation",
|
||||
routes=app.routes,
|
||||
)
|
||||
|
||||
# Ensure the components section exists in the OpenAPI schema
|
||||
if "components" not in openapi_schema:
|
||||
openapi_schema["components"] = {}
|
||||
|
||||
# Add security scheme to components
|
||||
openapi_schema["components"]["securitySchemes"] = {
|
||||
"bearerAuth": {
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
"bearerFormat": "JWT"
|
||||
}
|
||||
}
|
||||
|
||||
# Add security requirement globally
|
||||
openapi_schema["security"] = [{"bearerAuth": []}]
|
||||
|
||||
app.openapi_schema = openapi_schema
|
||||
return app.openapi_schema
|
||||
|
||||
app.openapi = custom_openapi
|
||||
|
||||
|
||||
def register(app, provider):
|
||||
logging.info(provider.__name__ + " registering")
|
||||
provider.register(app)
|
||||
|
||||
|
||||
def boot(app, provider):
|
||||
logging.info(provider.__name__ + " booting")
|
||||
provider.boot(app)
|
||||
6
app/authentication/webapi/bootstrap/freeleaps_app.py
Normal file
6
app/authentication/webapi/bootstrap/freeleaps_app.py
Normal file
@ -0,0 +1,6 @@
|
||||
from fastapi import FastAPI
|
||||
|
||||
|
||||
class FreeleapsApp(FastAPI):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
25
app/authentication/webapi/config/site_settings.py
Normal file
25
app/authentication/webapi/config/site_settings.py
Normal file
@ -0,0 +1,25 @@
|
||||
import os
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class SiteSettings(BaseSettings):
|
||||
NAME: str = "appname"
|
||||
DEBUG: bool = True
|
||||
|
||||
ENV: str = "dev"
|
||||
|
||||
SERVER_HOST: str = "0.0.0.0"
|
||||
SERVER_PORT: int = 8103
|
||||
|
||||
URL: str = "http://localhost"
|
||||
TIME_ZONE: str = "UTC"
|
||||
|
||||
BASE_PATH: str = os.path.dirname(os.path.dirname((os.path.abspath(__file__))))
|
||||
|
||||
class Config:
|
||||
env_file = ".devbase-webapi.env"
|
||||
env_file_encoding = "utf-8"
|
||||
|
||||
|
||||
site_settings = SiteSettings()
|
||||
30
app/authentication/webapi/main.py
Executable file
30
app/authentication/webapi/main.py
Executable file
@ -0,0 +1,30 @@
|
||||
from webapi.bootstrap.application import create_app
|
||||
from webapi.config.site_settings import site_settings
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from strawberry.fastapi import GraphQLRouter
|
||||
from strawberry.fastapi.handlers import GraphQLTransportWSHandler, GraphQLWSHandler
|
||||
import uvicorn
|
||||
from typing import Any
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
@app.get("/", status_code=301)
|
||||
async def root():
|
||||
"""
|
||||
TODO: redirect client to /doc#
|
||||
"""
|
||||
return RedirectResponse("docs")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app="main:app", host=site_settings.SERVER_HOST, port=site_settings.SERVER_PORT)
|
||||
|
||||
def get_context() -> Any:
|
||||
# Define your context function. This is where you can set up authentication, database connections, etc.
|
||||
return {}
|
||||
|
||||
def get_root_value() -> Any:
|
||||
# Define your root value function. This can be used to customize the root value for GraphQL operations.
|
||||
return {}
|
||||
31
app/authentication/webapi/providers/common.py
Normal file
31
app/authentication/webapi/providers/common.py
Normal file
@ -0,0 +1,31 @@
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from webapi.config.site_settings import site_settings
|
||||
|
||||
|
||||
def register(app):
|
||||
app.debug = site_settings.DEBUG
|
||||
app.title = site_settings.NAME
|
||||
|
||||
add_global_middleware(app)
|
||||
|
||||
# This hook ensures that a connection is opened to handle any queries
|
||||
# generated by the request.
|
||||
@app.on_event("startup")
|
||||
def startup():
|
||||
pass
|
||||
|
||||
# This hook ensures that the connection is closed when we've finished
|
||||
# processing the request.
|
||||
@app.on_event("shutdown")
|
||||
def shutdown():
|
||||
pass
|
||||
|
||||
|
||||
def add_global_middleware(app):
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
25
app/authentication/webapi/providers/database.py
Normal file
25
app/authentication/webapi/providers/database.py
Normal file
@ -0,0 +1,25 @@
|
||||
from infra.config.app_settings import app_settings
|
||||
from beanie import init_beanie
|
||||
from motor.motor_asyncio import AsyncIOMotorClient
|
||||
from app.central_storage.backend.models.models import DocumentDoc
|
||||
|
||||
|
||||
def register(app):
|
||||
app.debug = "auth_mongo_debug"
|
||||
app.title = "auth_mongo_name"
|
||||
|
||||
@app.on_event("startup")
|
||||
async def start_database():
|
||||
await initiate_database()
|
||||
|
||||
|
||||
async def initiate_database():
|
||||
client = AsyncIOMotorClient(
|
||||
app_settings.MONGODB_URI,
|
||||
serverSelectionTimeoutMS=60000,
|
||||
minPoolSize=5, # Minimum number of connections in the pool
|
||||
maxPoolSize=20, # Maximum number of connections in the pool
|
||||
)
|
||||
await init_beanie(
|
||||
database=client[app_settings.MONGODB_NAME], document_models=[DocumentDoc]
|
||||
)
|
||||
39
app/authentication/webapi/providers/exception_handler.py
Normal file
39
app/authentication/webapi/providers/exception_handler.py
Normal file
@ -0,0 +1,39 @@
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.status import (
|
||||
HTTP_400_BAD_REQUEST,
|
||||
HTTP_401_UNAUTHORIZED,
|
||||
HTTP_403_FORBIDDEN,
|
||||
HTTP_404_NOT_FOUND,
|
||||
HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
|
||||
async def custom_http_exception_handler(request: Request, exc: HTTPException):
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={"error": exc.detail},
|
||||
)
|
||||
|
||||
|
||||
|
||||
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||
return JSONResponse(
|
||||
status_code=HTTP_400_BAD_REQUEST,
|
||||
content={"error": str(exc)},
|
||||
)
|
||||
|
||||
async def exception_handler(request: Request, exc: Exception):
|
||||
return JSONResponse(
|
||||
status_code=HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={"error": str(exc)},
|
||||
)
|
||||
|
||||
|
||||
def register(app: FastAPI):
|
||||
app.add_exception_handler(HTTPException, custom_http_exception_handler)
|
||||
app.add_exception_handler(RequestValidationError, validation_exception_handler)
|
||||
app.add_exception_handler(Exception, exception_handler)
|
||||
60
app/authentication/webapi/providers/logger.py
Normal file
60
app/authentication/webapi/providers/logger.py
Normal file
@ -0,0 +1,60 @@
|
||||
import logging
|
||||
import sys
|
||||
from loguru import logger
|
||||
from common.config.log_settings import log_settings
|
||||
|
||||
|
||||
def register(app=None):
|
||||
level = log_settings.LOG_LEVEL
|
||||
file_path = log_settings.LOG_PATH
|
||||
retention = log_settings.LOG_RETENTION
|
||||
rotation = log_settings.LOG_ROTATION
|
||||
|
||||
# intercept everything at the root logger
|
||||
logging.root.handlers = [InterceptHandler()]
|
||||
logging.root.setLevel(level)
|
||||
|
||||
# remove every other logger's handlers
|
||||
# and propagate to root logger
|
||||
for name in logging.root.manager.loggerDict.keys():
|
||||
logging.getLogger(name).handlers = []
|
||||
logging.getLogger(name).propagate = True
|
||||
|
||||
# configure loguru
|
||||
logger.add(
|
||||
sink=sys.stdout
|
||||
)
|
||||
logger.add(
|
||||
sink=file_path,
|
||||
level=level,
|
||||
retention=retention,
|
||||
rotation=rotation
|
||||
)
|
||||
|
||||
logger.disable("pika.adapters")
|
||||
logger.disable("pika.connection")
|
||||
logger.disable("pika.channel")
|
||||
logger.disable("pika.callback")
|
||||
logger.disable("pika.frame")
|
||||
logger.disable("pika.spec")
|
||||
logger.disable("aiormq.connection")
|
||||
logger.disable("urllib3.connectionpool")
|
||||
|
||||
|
||||
class InterceptHandler(logging.Handler):
|
||||
def emit(self, record):
|
||||
# Get corresponding Loguru level if it exists
|
||||
try:
|
||||
level = logger.level(record.levelname).name
|
||||
except ValueError:
|
||||
level = record.levelno
|
||||
|
||||
# Find caller from where originated the logged message
|
||||
frame, depth = logging.currentframe(), 2
|
||||
while frame.f_code.co_filename == logging.__file__:
|
||||
frame = frame.f_back
|
||||
depth += 1
|
||||
|
||||
logger.opt(depth=depth, exception=record.exc_info).log(
|
||||
level, record.getMessage()
|
||||
)
|
||||
34
app/authentication/webapi/providers/router.py
Normal file
34
app/authentication/webapi/providers/router.py
Normal file
@ -0,0 +1,34 @@
|
||||
from webapi.routes import api_router
|
||||
|
||||
from starlette import routing
|
||||
|
||||
|
||||
def register(app):
|
||||
app.include_router(
|
||||
api_router,
|
||||
prefix="/api",
|
||||
tags=["api"],
|
||||
dependencies=[],
|
||||
responses={404: {"description": "no page found"}},
|
||||
)
|
||||
|
||||
if app.debug:
|
||||
for route in app.routes:
|
||||
if not isinstance(route, routing.WebSocketRoute):
|
||||
print(
|
||||
{
|
||||
"path": route.path,
|
||||
"endpoint": route.endpoint,
|
||||
"name": route.name,
|
||||
"methods": route.methods,
|
||||
}
|
||||
)
|
||||
else:
|
||||
print(
|
||||
{
|
||||
"path": route.path,
|
||||
"endpoint": route.endpoint,
|
||||
"name": route.name,
|
||||
"type": "web socket route",
|
||||
}
|
||||
)
|
||||
8
app/authentication/webapi/providers/scheduler.py
Normal file
8
app/authentication/webapi/providers/scheduler.py
Normal file
@ -0,0 +1,8 @@
|
||||
import asyncio
|
||||
|
||||
|
||||
def register(app):
|
||||
@app.on_event("startup")
|
||||
async def start_scheduler():
|
||||
#create your scheduler here
|
||||
pass
|
||||
5
app/authentication/webapi/routes/__init__.py
Normal file
5
app/authentication/webapi/routes/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
websocket_router = APIRouter()
|
||||
15
app/authentication/webapi/routes/api.py
Normal file
15
app/authentication/webapi/routes/api.py
Normal file
@ -0,0 +1,15 @@
|
||||
from fastapi.routing import APIRoute
|
||||
from starlette import routing
|
||||
|
||||
|
||||
def post_process_router(app) -> None:
|
||||
"""
|
||||
Simplify operation IDs so that generated API clients have simpler function
|
||||
names.
|
||||
|
||||
Should be called only after all routes have been added.
|
||||
"""
|
||||
for route in app.routes:
|
||||
if isinstance(route, APIRoute):
|
||||
if hasattr(route, "operation_id"):
|
||||
route.operation_id = route.name # in this case, 'read_items'
|
||||
20
app/authentication/webapi/routes/signin/__init__.py
Normal file
20
app/authentication/webapi/routes/signin/__init__.py
Normal file
@ -0,0 +1,20 @@
|
||||
from fastapi import APIRouter
|
||||
from .try_signin_with_email import router as ts_router
|
||||
from .signin_with_email_and_code import router as sw_router
|
||||
from .update_user_password import router as up_router
|
||||
from .signin_with_email_and_password import router as se_router
|
||||
from .sign_out import router as so_router
|
||||
from .reset_password_through_email import router as rp_router
|
||||
from .refresh_token import router as rt_router
|
||||
from .update_user_flid import router as uu_router
|
||||
|
||||
router = APIRouter(prefix="/signin")
|
||||
|
||||
router.include_router(ts_router, tags=["signin"])
|
||||
router.include_router(sw_router, tags=["signin"])
|
||||
router.include_router(up_router, tags=["signin"])
|
||||
router.include_router(se_router, tags=["signin"])
|
||||
router.include_router(so_router, tags=["signin"])
|
||||
router.include_router(rp_router, tags=["signin"])
|
||||
router.include_router(uu_router, tags=["signin"])
|
||||
router.include_router(rt_router, tags=["signin"])
|
||||
@ -0,0 +1,37 @@
|
||||
from backend.application.user.user_manager import UserManager
|
||||
from pydantic import BaseModel
|
||||
from fastapi import APIRouter
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Web API
|
||||
# reset_password_through_email
|
||||
#
|
||||
|
||||
|
||||
class UserSignWithEmailBody(BaseModel):
|
||||
email: str
|
||||
host: str
|
||||
|
||||
|
||||
class UserSignWithEmailResponse(BaseModel):
|
||||
signin_type: int
|
||||
|
||||
|
||||
@router.post(
|
||||
"/reset-password-through-email",
|
||||
operation_id="user-reset-password-through-email",
|
||||
summary="user reset password through email",
|
||||
description="A client user forgets the password. \
|
||||
The system will send auth code the their email\
|
||||
to let the user reset the password",
|
||||
response_description="action: UserLoginAction",
|
||||
)
|
||||
async def reset_password_through_email(
|
||||
item: UserSignWithEmailBody,
|
||||
):
|
||||
result = await UserManager().reset_password_through_email(item.email, item.host)
|
||||
result = {"action": result}
|
||||
return JSONResponse(content=jsonable_encoder(result))
|
||||
@ -0,0 +1,87 @@
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.application.user.user_manager import UserManager
|
||||
from backend.infra.authentication.auth import access_security
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Web API
|
||||
# signin-with-email-n-code
|
||||
#
|
||||
|
||||
|
||||
class RequestIn(BaseModel):
|
||||
email: str
|
||||
code: str
|
||||
host: str
|
||||
time_zone: Optional[str] = "UTC"
|
||||
|
||||
|
||||
class ResponseOut(BaseModel):
|
||||
# 1: succeeded
|
||||
signin_result: int
|
||||
# the access token for futhur communication with server
|
||||
access_token: Optional[str] = None
|
||||
# the refresh token for new access token generation
|
||||
refresh_token: Optional[str] = None
|
||||
# the identity of the signed in user
|
||||
identity: Optional[str] = None
|
||||
# the date time when the access toke will be expired
|
||||
expires_in: Optional[datetime] = None
|
||||
# the system assigned role of the user.
|
||||
role: Optional[int] = None
|
||||
# preferred region for user
|
||||
preferred_region: Optional[str] = None
|
||||
|
||||
|
||||
@router.post(
|
||||
"/signin-with-email-and-code",
|
||||
operation_id="user-signin-with-email-and-code",
|
||||
summary="try to signin with email and authentication code",
|
||||
description="client user is trying to sign in with their email and the authenication code \
|
||||
the system sent to the email in previous step.",
|
||||
response_model=ResponseOut,
|
||||
)
|
||||
async def signin_with_email_and_code(item: RequestIn) -> ResponseOut:
|
||||
(
|
||||
signed_in,
|
||||
adminstrative_role,
|
||||
identity,
|
||||
flid,
|
||||
preferred_region,
|
||||
) = await UserManager().signin_with_email_and_code(
|
||||
item.email, item.code, item.host, item.time_zone
|
||||
)
|
||||
|
||||
logging.debug(
|
||||
f"signedin={signed_in}, adminstrative_role={adminstrative_role}, identity={identity}"
|
||||
)
|
||||
|
||||
if signed_in and identity and adminstrative_role:
|
||||
subject = {"id": identity, "role": adminstrative_role}
|
||||
access_token = access_security.create_access_token(subject=subject)
|
||||
refresh_token = access_security.create_refresh_token(subject=subject)
|
||||
expires_in = datetime.now(timezone.utc) + access_security.access_expires_delta
|
||||
else:
|
||||
access_token = None
|
||||
refresh_token = None
|
||||
expires_in = None
|
||||
|
||||
result = {
|
||||
"signin_result": signed_in,
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"identity": identity,
|
||||
"expires_in": expires_in,
|
||||
"role": adminstrative_role,
|
||||
"flid": flid,
|
||||
"preferred_region": preferred_region,
|
||||
}
|
||||
return JSONResponse(content=jsonable_encoder(result))
|
||||
@ -0,0 +1,87 @@
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.application.user.user_manager import UserManager
|
||||
from infra.token.token_manager import TokenManager
|
||||
|
||||
router = APIRouter()
|
||||
token_manager = TokenManager()
|
||||
# Web API
|
||||
# signin-with-email-n-code
|
||||
#
|
||||
|
||||
|
||||
class RequestIn(BaseModel):
|
||||
email: str
|
||||
code: str
|
||||
host: str
|
||||
time_zone: Optional[str] = "UTC"
|
||||
|
||||
|
||||
class ResponseOut(BaseModel):
|
||||
# 1: succeeded
|
||||
signin_result: int
|
||||
# the access token for futhur communication with server
|
||||
access_token: Optional[str] = None
|
||||
# the refresh token for new access token generation
|
||||
refresh_token: Optional[str] = None
|
||||
# the identity of the signed in user
|
||||
identity: Optional[str] = None
|
||||
# the date time when the access toke will be expired
|
||||
expires_in: Optional[datetime] = None
|
||||
# the system assigned role of the user.
|
||||
role: Optional[int] = None
|
||||
# preferred region for user
|
||||
preferred_region: Optional[str] = None
|
||||
|
||||
|
||||
@router.post(
|
||||
"/signin-with-email-and-code",
|
||||
operation_id="user-signin-with-email-and-code",
|
||||
summary="try to signin with email and authentication code",
|
||||
description="client user is trying to sign in with their email and the authenication code \
|
||||
the system sent to the email in previous step.",
|
||||
response_model=ResponseOut,
|
||||
)
|
||||
async def signin_with_email_and_code(item: RequestIn) -> ResponseOut:
|
||||
(
|
||||
signed_in,
|
||||
adminstrative_role,
|
||||
identity,
|
||||
flid,
|
||||
preferred_region,
|
||||
) = await UserManager().signin_with_email_and_code(
|
||||
item.email, item.code, item.host, item.time_zone
|
||||
)
|
||||
|
||||
logging.debug(
|
||||
f"signedin={signed_in}, adminstrative_role={adminstrative_role}, identity={identity}"
|
||||
)
|
||||
|
||||
if signed_in and identity and adminstrative_role:
|
||||
subject = {"id": identity, "role": adminstrative_role}
|
||||
access_token = token_manager.create_access_token(subject=subject)
|
||||
refresh_token = token_manager.create_refresh_token(subject=subject)
|
||||
expires_in = datetime.now(timezone.utc) + timedelta(minutes=30)
|
||||
else:
|
||||
access_token = None
|
||||
refresh_token = None
|
||||
expires_in = None
|
||||
|
||||
result = {
|
||||
"signin_result": signed_in,
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"identity": identity,
|
||||
"expires_in": expires_in,
|
||||
"role": adminstrative_role,
|
||||
"flid": flid,
|
||||
"preferred_region": preferred_region,
|
||||
}
|
||||
return JSONResponse(content=jsonable_encoder(result))
|
||||
@ -0,0 +1,31 @@
|
||||
from fastapi import APIRouter, Security
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi_jwt import JwtAuthorizationCredentials
|
||||
from backend.infra.authentication.auth import access_security
|
||||
from backend.application.user.user_manager import (
|
||||
UserManager,
|
||||
) # Assuming UserManager handles user-related queries
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/get-latest-login-by-user-id/{user_id}",
|
||||
operation_id="get-latest-login-by-user-id",
|
||||
summary="Get the latest login timestamp for a given user",
|
||||
description="Fetches the latest login timestamp for a specific user by user_id.",
|
||||
response_description="Returns the latest login timestamp in Unix time seconds, or null if no login found",
|
||||
)
|
||||
async def get_latest_login_by_user_id(
|
||||
user_id: str,
|
||||
credentials: JwtAuthorizationCredentials = Security(access_security),
|
||||
):
|
||||
# Assume UserManager is responsible for handling user data
|
||||
result = await UserManager().fetch_latest_login(user_id)
|
||||
|
||||
if result is None:
|
||||
return JSONResponse(content=jsonable_encoder({"timestamp": None}))
|
||||
|
||||
return JSONResponse(content=jsonable_encoder({"timestamp": result}))
|
||||
@ -0,0 +1,42 @@
|
||||
from backend.application.user.user_manager import UserManager
|
||||
from pydantic import BaseModel
|
||||
from fastapi import APIRouter, Security
|
||||
from backend.infra.authentication.auth import access_security
|
||||
from fastapi_jwt import JwtAuthorizationCredentials
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Web API
|
||||
# update_user_password
|
||||
#
|
||||
|
||||
|
||||
class RequestIn(BaseModel):
|
||||
password: str
|
||||
password2: str
|
||||
|
||||
|
||||
@router.post(
|
||||
"/update-user-password",
|
||||
operation_id="user_update_user_password",
|
||||
summary="updathe user's sign-in password",
|
||||
description="Update the user's sign-in password. If the password was not set yet, this will enable the user to log in using the password",
|
||||
response_description="signin_type:0 meaning simplified(using email) signin, \
|
||||
1 meaning standard(using FLID and passward) signin",
|
||||
)
|
||||
async def update_user_password(
|
||||
item: RequestIn,
|
||||
credentials: JwtAuthorizationCredentials = Security(access_security),
|
||||
):
|
||||
user_id = credentials["id"]
|
||||
if item.password != item.password2:
|
||||
return JSONResponse(
|
||||
content=jsonable_encoder(
|
||||
{"error": "password and password2 are not the same"}
|
||||
)
|
||||
)
|
||||
else:
|
||||
result = await UserManager().update_user_password(user_id, item.password)
|
||||
return JSONResponse(content=jsonable_encoder(result))
|
||||
12
app/authentication/webapi/routes/tokens/__init__.py
Normal file
12
app/authentication/webapi/routes/tokens/__init__.py
Normal file
@ -0,0 +1,12 @@
|
||||
from fastapi import APIRouter
|
||||
from .generate_tokens import router as generate_tokens_router
|
||||
from .refresh_token import router as refresh_token_router
|
||||
from .verify_token import router as verify_token_router
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
router.include_router(
|
||||
generate_tokens_router, prefix="/token", tags=["Token Management"]
|
||||
)
|
||||
router.include_router(refresh_token_router, prefix="/token", tags=["Token Management"])
|
||||
router.include_router(verify_token_router, prefix="/token", tags=["Token Management"])
|
||||
35
app/authentication/webapi/routes/tokens/generate_tokens.py
Normal file
35
app/authentication/webapi/routes/tokens/generate_tokens.py
Normal file
@ -0,0 +1,35 @@
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from infra.token.token_manager import TokenManager
|
||||
|
||||
router = APIRouter()
|
||||
token_manager = TokenManager() # Initialize TokenManager
|
||||
|
||||
|
||||
class TokenRequest(BaseModel):
|
||||
id: str
|
||||
role: int
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
expires_in: datetime
|
||||
|
||||
|
||||
@router.post("/generate-tokens", response_model=TokenResponse)
|
||||
async def generate_tokens(request: TokenRequest):
|
||||
"""
|
||||
Endpoint to generate access and refresh tokens.
|
||||
"""
|
||||
subject = {"id": request.id, "role": request.role}
|
||||
access_token = token_manager.create_access_token(subject)
|
||||
refresh_token = token_manager.create_refresh_token(subject)
|
||||
expires_in = datetime.now(timezone.utc) + timedelta(
|
||||
minutes=token_manager.access_token_expire_minutes
|
||||
)
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token, refresh_token=refresh_token, expires_in=expires_in
|
||||
)
|
||||
33
app/authentication/webapi/routes/tokens/refresh_token.py
Normal file
33
app/authentication/webapi/routes/tokens/refresh_token.py
Normal file
@ -0,0 +1,33 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from infra.token.token_manager import TokenManager
|
||||
|
||||
router = APIRouter()
|
||||
token_manager = TokenManager() # Initialize TokenManager
|
||||
|
||||
|
||||
class RefreshTokenRequest(BaseModel):
|
||||
refresh_token: str
|
||||
id: str
|
||||
role: int
|
||||
|
||||
|
||||
class RefreshTokenResponse(BaseModel):
|
||||
access_token: str
|
||||
|
||||
|
||||
@router.post("/refresh-token", response_model=RefreshTokenResponse)
|
||||
async def refresh_token(request: RefreshTokenRequest):
|
||||
"""
|
||||
Endpoint to refresh the access token using a valid refresh token.
|
||||
"""
|
||||
subject = {"id": request.id, "role": request.role}
|
||||
|
||||
try:
|
||||
access_token = token_manager.refresh_access_token(
|
||||
request.refresh_token, subject
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
return RefreshTokenResponse(access_token=access_token)
|
||||
27
app/authentication/webapi/routes/tokens/verify_token.py
Normal file
27
app/authentication/webapi/routes/tokens/verify_token.py
Normal file
@ -0,0 +1,27 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from infra.token.token_manager import TokenManager
|
||||
|
||||
router = APIRouter()
|
||||
token_manager = TokenManager() # Initialize TokenManager
|
||||
|
||||
|
||||
class VerifyTokenRequest(BaseModel):
|
||||
token: str
|
||||
|
||||
|
||||
class VerifyTokenResponse(BaseModel):
|
||||
valid: bool
|
||||
payload: dict
|
||||
|
||||
|
||||
@router.post("/verify-token", response_model=VerifyTokenResponse)
|
||||
async def verify_token(request: VerifyTokenRequest):
|
||||
"""
|
||||
Endpoint to verify if a token is valid and return the payload.
|
||||
"""
|
||||
try:
|
||||
payload = token_manager.decode_token(request.token)
|
||||
return VerifyTokenResponse(valid=True, payload=payload)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||
@ -1,4 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
# This script starts the FastAPI server using uvicorn
|
||||
uvicorn webapi.bootstrap.application:create_app --reload --host 0.0.0.0 --port 8005
|
||||
@ -21,3 +21,8 @@ class InvalidOperationError(Exception):
|
||||
class InvalidDataError(Exception):
|
||||
def __init__(self, message: str = "Invalid Data"):
|
||||
self.message = message
|
||||
|
||||
|
||||
class InvalidAuthCodeException(Exception):
|
||||
def __init__(self, message: str = "Invalid Auth Code"):
|
||||
self.message = message
|
||||
|
||||
19
infra/i18n/region_handler.py
Normal file
19
infra/i18n/region_handler.py
Normal file
@ -0,0 +1,19 @@
|
||||
from infra.models.constants import UserRegion
|
||||
|
||||
|
||||
class RegionHandler:
|
||||
def __init__(self):
|
||||
self._zh_cn_patterns = [".cn", "cn.", "host"]
|
||||
|
||||
def detect_from_host(self, host: str) -> UserRegion:
|
||||
# Now we set user preferred region based on host
|
||||
for parttern in self._zh_cn_patterns:
|
||||
if parttern in host.lower():
|
||||
return UserRegion.ZH_CN
|
||||
return UserRegion.OTHER
|
||||
|
||||
# async def get_user_region(self, user_id: str) -> UserRegion:
|
||||
# user_profile = await BasicProfileDoc.find_one(
|
||||
# BasicProfileDoc.user_id == user_id
|
||||
# )
|
||||
# return user_profile.region if user_profile else UserRegion.OTHER
|
||||
6
infra/models/constants.py
Normal file
6
infra/models/constants.py
Normal file
@ -0,0 +1,6 @@
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class UserRegion(IntEnum):
|
||||
OTHER = 0
|
||||
ZH_CN = 1
|
||||
@ -1,7 +1,6 @@
|
||||
# application/auth/token/token_manager.py
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Dict
|
||||
from jose import jwt
|
||||
from jose import jwt, JWTError
|
||||
from infra.config.app_settings import app_settings
|
||||
|
||||
|
||||
@ -15,11 +14,6 @@ class TokenManager:
|
||||
def create_access_token(self, subject: Dict[str, str]) -> str:
|
||||
"""
|
||||
Generates an access token with a short expiration time.
|
||||
Args:
|
||||
subject (Dict[str, str]): A dictionary containing user information like 'id' and 'role'.
|
||||
|
||||
Returns:
|
||||
str: Encoded JWT access token.
|
||||
"""
|
||||
expire = datetime.now(timezone.utc) + timedelta(
|
||||
minutes=self.access_token_expire_minutes
|
||||
@ -31,11 +25,6 @@ class TokenManager:
|
||||
def create_refresh_token(self, subject: Dict[str, str]) -> str:
|
||||
"""
|
||||
Generates a refresh token with a longer expiration time.
|
||||
Args:
|
||||
subject (Dict[str, str]): A dictionary containing user information like 'id' and 'role'.
|
||||
|
||||
Returns:
|
||||
str: Encoded JWT refresh token.
|
||||
"""
|
||||
expire = datetime.now(timezone.utc) + timedelta(
|
||||
days=self.refresh_token_expire_days
|
||||
@ -47,13 +36,28 @@ class TokenManager:
|
||||
def decode_token(self, token: str) -> Dict:
|
||||
"""
|
||||
Decodes a JWT token and returns the payload.
|
||||
Args:
|
||||
token (str): Encoded JWT token.
|
||||
|
||||
Returns:
|
||||
Dict: Decoded token payload.
|
||||
|
||||
Raises:
|
||||
JWTError: If the token is invalid or expired.
|
||||
"""
|
||||
return jwt.decode(token, self.secret_key, algorithms=[self.algorithm])
|
||||
try:
|
||||
payload = jwt.decode(token, self.secret_key, algorithms=[self.algorithm])
|
||||
return payload
|
||||
except JWTError:
|
||||
raise ValueError("Invalid token")
|
||||
|
||||
def verify_refresh_token(self, token: str) -> bool:
|
||||
"""
|
||||
Verifies a refresh token to ensure it is valid and not expired.
|
||||
"""
|
||||
try:
|
||||
payload = self.decode_token(token)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def refresh_access_token(self, refresh_token: str, subject: Dict[str, str]) -> str:
|
||||
"""
|
||||
Verifies the refresh token and creates a new access token.
|
||||
"""
|
||||
if self.verify_refresh_token(refresh_token):
|
||||
return self.create_access_token(subject)
|
||||
else:
|
||||
raise ValueError("Invalid refresh token")
|
||||
|
||||
22
infra/utils/date.py
Normal file
22
infra/utils/date.py
Normal file
@ -0,0 +1,22 @@
|
||||
import datetime
|
||||
from datetime import timedelta, timezone
|
||||
|
||||
|
||||
def get_sunday(date):
|
||||
return date - datetime.timedelta(days=date.weekday()) + timedelta(days=6)
|
||||
|
||||
|
||||
def get_last_sunday_dates(number, include_current_week=True):
|
||||
now_utc = datetime.datetime.now(timezone.utc)
|
||||
today = datetime.datetime(now_utc.year, now_utc.month, now_utc.day)
|
||||
if include_current_week:
|
||||
days_to_last_sunday = (6 - today.weekday()) % 7
|
||||
last_sunday = today + datetime.timedelta(days=days_to_last_sunday)
|
||||
else:
|
||||
days_to_last_sunday = (today.weekday() - 6) % 7
|
||||
last_sunday = today - datetime.timedelta(days=days_to_last_sunday)
|
||||
last_n_sundays = []
|
||||
for i in range(number):
|
||||
sunday = last_sunday - datetime.timedelta(days=i * 7)
|
||||
last_n_sundays.append(sunday.date())
|
||||
return last_n_sundays
|
||||
88
infra/utils/string.py
Normal file
88
infra/utils/string.py
Normal file
@ -0,0 +1,88 @@
|
||||
import random
|
||||
import re
|
||||
import jieba
|
||||
from typing import List
|
||||
|
||||
SKILL_TAGS = [
|
||||
"C++",
|
||||
"Java",
|
||||
"Python",
|
||||
"TypeScript",
|
||||
"iOS",
|
||||
"Android",
|
||||
"Web",
|
||||
"Javascript",
|
||||
"Vue",
|
||||
"Go",
|
||||
]
|
||||
|
||||
|
||||
# dynamically update skill tags? maybe based on the most commonly extracted keywords to help the system adapt to change
|
||||
def updateSkillTags(string):
|
||||
SKILL_TAGS.append(string)
|
||||
|
||||
|
||||
def generate_auth_code():
|
||||
filtered = "0123456789"
|
||||
code = "".join(random.choice(filtered) for i in range(6))
|
||||
return code
|
||||
|
||||
|
||||
# TODO: Need to optimize
|
||||
def generate_self_intro_summary(content_html: str) -> str:
|
||||
element_html = re.compile("<.*?>")
|
||||
content_text = re.sub(element_html, "", content_html).strip()
|
||||
return content_text[:50]
|
||||
|
||||
|
||||
# TODO: Need to optimize
|
||||
def extract_skill_tags(content_html: str) -> List[str]:
|
||||
element_html = re.compile("<.*?>")
|
||||
content_text = re.sub(element_html, "", content_html).strip()
|
||||
words = set([word.lower() for word in jieba.cut(content_text) if word.strip()])
|
||||
|
||||
results = []
|
||||
for tag in SKILL_TAGS:
|
||||
if tag.lower() in words:
|
||||
results.append(tag)
|
||||
return results
|
||||
|
||||
|
||||
def extract_title(content_html: str) -> List[str]:
|
||||
element_html = re.compile("<.*?>")
|
||||
content_text = re.sub(element_html, "\n", content_html).strip()
|
||||
|
||||
cut_point_indexes = []
|
||||
for cut_point in [".", ",", ";", "\r", "\n"]:
|
||||
result = content_text.find(cut_point)
|
||||
if result > 0:
|
||||
cut_point_indexes.append(result)
|
||||
|
||||
title = (
|
||||
content_text[: min(cut_point_indexes)]
|
||||
if len(cut_point_indexes) > 0
|
||||
else content_text
|
||||
)
|
||||
return title
|
||||
|
||||
|
||||
def check_password_complexity(password):
|
||||
lowercase_pattern = r"[a-z]"
|
||||
uppercase_pattern = r"[A-Z]"
|
||||
digit_pattern = r"\d"
|
||||
special_pattern = r'[!@#$%^&*(),.?":{}|<>]'
|
||||
|
||||
password_lowercase_one = bool(re.search(lowercase_pattern, password))
|
||||
password_uppercase_one = bool(re.search(uppercase_pattern, password))
|
||||
password_digit_one = bool(re.search(digit_pattern, password))
|
||||
password_special_one = bool(re.search(special_pattern, password))
|
||||
|
||||
if (
|
||||
password_lowercase_one
|
||||
and password_uppercase_one
|
||||
and password_digit_one
|
||||
and password_special_one
|
||||
):
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
Loading…
Reference in New Issue
Block a user