diff --git a/kdb-bot/src/bot_api/abc/auth_service_abc.py b/kdb-bot/src/bot_api/abc/auth_service_abc.py index cd54128d..d7cfb9eb 100644 --- a/kdb-bot/src/bot_api/abc/auth_service_abc.py +++ b/kdb-bot/src/bot_api/abc/auth_service_abc.py @@ -43,7 +43,10 @@ class AuthServiceABC(ABC): async def find_auth_user_by_email_async(self, email: str) -> AuthUserDTO: pass @abstractmethod - async def add_auth_user_async(self, user_dto: AuthUserDTO) -> int: pass + async def add_auth_user_async(self, user_dto: AuthUserDTO): pass + + @abstractmethod + async def add_auth_user_by_discord_async(self, user_dto: AuthUserDTO): pass @abstractmethod async def update_user_async(self, update_user_dto: UpdateAuthUserDTO): pass diff --git a/kdb-bot/src/bot_api/api.py b/kdb-bot/src/bot_api/api.py index b79a12d2..800898f5 100644 --- a/kdb-bot/src/bot_api/api.py +++ b/kdb-bot/src/bot_api/api.py @@ -5,13 +5,17 @@ from functools import partial import eventlet from cpl_core.dependency_injection import ServiceProviderABC +from cpl_core.utils import CredentialManager from eventlet import wsgi from flask import Flask, request, jsonify, Response, make_response from flask_cors import CORS from flask_socketio import SocketIO +from werkzeug.exceptions import NotFound from bot_api.configuration.api_settings import ApiSettings +from bot_api.configuration.authentication_settings import AuthenticationSettings from bot_api.configuration.frontend_settings import FrontendSettings +from bot_api.exception.service_error_code_enum import ServiceErrorCode from bot_api.exception.service_exception import ServiceException from bot_api.logging.api_logger import ApiLogger from bot_api.model.error_dto import ErrorDTO @@ -26,6 +30,7 @@ class Api(Flask): services: ServiceProviderABC, api_settings: ApiSettings, frontend_settings: FrontendSettings, + auth_settings: AuthenticationSettings, *args, **kwargs ): if not args: @@ -36,6 +41,7 @@ class Api(Flask): self._logger = logger self._services = services self._apt_settings = api_settings + self._auth_settings = auth_settings self._cors = CORS(self, support_credentials=True) @@ -71,6 +77,10 @@ class Api(Flask): self._logger.error(__name__, ex.get_detailed_message()) error = ErrorDTO(ex.error_code, ex.message) return jsonify(error.to_dict()), 500 + elif isinstance(e, NotFound): + self._logger.error(__name__, e.description) + error = ErrorDTO(ServiceErrorCode.NotFound, e.description) + return jsonify(error.to_dict()), 404 else: tracking_id = uuid.uuid4() user_message = f'Tracking Id: {tracking_id}' @@ -86,6 +96,7 @@ class Api(Flask): def start(self): self._logger.info(__name__, f'Starting API {self._apt_settings.host}:{self._apt_settings.port}') self._register_routes() + self.secret_key = CredentialManager.decrypt(self._auth_settings.secret_key) # from waitress import serve # https://docs.pylonsproject.org/projects/waitress/en/stable/arguments.html # serve(self, host=self._apt_settings.host, port=self._apt_settings.port, threads=10, connection_limit=1000, channel_timeout=10) diff --git a/kdb-bot/src/bot_api/api_module.py b/kdb-bot/src/bot_api/api_module.py index 7348388f..78a94c57 100644 --- a/kdb-bot/src/bot_api/api_module.py +++ b/kdb-bot/src/bot_api/api_module.py @@ -11,6 +11,7 @@ from flask import Flask from bot_api.abc.auth_service_abc import AuthServiceABC from bot_api.api import Api from bot_api.api_thread import ApiThread +from bot_api.controller.auth_discord_controller import AuthDiscordController from bot_api.controller.discord.server_controller import ServerController from bot_api.controller.gui_controller import GuiController from bot_api.controller.auth_controller import AuthController @@ -44,6 +45,7 @@ class ApiModule(ModuleABC): services.add_transient(AuthServiceABC, AuthService) services.add_transient(AuthController) + services.add_transient(AuthDiscordController) services.add_transient(GuiController) services.add_transient(DiscordService) services.add_transient(ServerController) diff --git a/kdb-bot/src/bot_api/bot-api.json b/kdb-bot/src/bot_api/bot-api.json index 86b1012e..9b117119 100644 --- a/kdb-bot/src/bot_api/bot-api.json +++ b/kdb-bot/src/bot_api/bot-api.json @@ -16,7 +16,8 @@ "LicenseName": "", "LicenseDescription": "", "Dependencies": [ - "cpl-core==2022.10.0.post6" + "cpl-core==2022.10.0.post6", + "requests-oauthlib==1.3.1" ], "DevDependencies": [ "cpl-cli==2022.10.0" diff --git a/kdb-bot/src/bot_api/config/apisettings.edrafts-lapi.json b/kdb-bot/src/bot_api/config/apisettings.edrafts-lapi.json index d71694b2..9c269d8a 100644 --- a/kdb-bot/src/bot_api/config/apisettings.edrafts-lapi.json +++ b/kdb-bot/src/bot_api/config/apisettings.edrafts-lapi.json @@ -5,12 +5,22 @@ "RedirectToHTTPS": false }, "Authentication": { - "SecretKey": "F3b5LDz+#Jvzg=W!@gsa%xsF", + "SecretKey": "RjNiNUxEeisjSnZ6Zz1XIUBnc2EleHNG", "Issuer": "http://localhost:5000", "Audience": "http://localhost:4200", "TokenExpireTime": 1, "RefreshTokenExpireTime": 7 }, + "DiscordAuthentication": { + "ClientSecret": "V3FTb3JYVFBiVktEeHZxdWJDWW4xcnBCbXRwdmpwcy0=", + "RedirectURL": "http://localhost:5000/api/auth/discord/register", + "Scope": [ + "identify", + "email" + ], + "TokenURL": "https://discordapp.com/api/oauth2/token", + "AuthURL": "https://discordapp.com/api/oauth2/authorize" + }, "Frontend": { "URL": "http://localhost:4200/" } diff --git a/kdb-bot/src/bot_api/configuration/discord_authentication_settings.py b/kdb-bot/src/bot_api/configuration/discord_authentication_settings.py new file mode 100644 index 00000000..e13c20ba --- /dev/null +++ b/kdb-bot/src/bot_api/configuration/discord_authentication_settings.py @@ -0,0 +1,48 @@ +import traceback + +from cpl_core.configuration.configuration_model_abc import ConfigurationModelABC +from cpl_core.console import Console +from cpl_query.extension import List + + +class DiscordAuthenticationSettings(ConfigurationModelABC): + + def __init__(self): + ConfigurationModelABC.__init__(self) + + self._client_secret = '' + self._redirect_url = '' + self._scope = List() + self._token_url = '' + self._auth_url = '' + + @property + def client_secret(self) -> str: + return self._client_secret + + @property + def redirect_url(self) -> str: + return self._redirect_url + + @property + def scope(self) -> List[str]: + return self._scope + + @property + def token_url(self) -> str: + return self._token_url + + @property + def auth_url(self) -> str: + return self._auth_url + + def from_dict(self, settings: dict): + try: + self._client_secret = settings['ClientSecret'] + self._redirect_url = settings['RedirectURL'] + self._scope = List(str, settings['Scope']) + self._token_url = settings['TokenURL'] + self._auth_url = settings['AuthURL'] + except Exception as e: + Console.error(f'[ ERROR ] [ {__name__} ]: Reading error in {type(self).__name__} settings') + Console.error(f'[ EXCEPTION ] [ {__name__} ]: {e} -> {traceback.format_exc()}') diff --git a/kdb-bot/src/bot_api/controller/auth_discord_controller.py b/kdb-bot/src/bot_api/controller/auth_discord_controller.py new file mode 100644 index 00000000..8efe4c06 --- /dev/null +++ b/kdb-bot/src/bot_api/controller/auth_discord_controller.py @@ -0,0 +1,83 @@ +import os +import uuid + +from cpl_core.configuration import ConfigurationABC +from cpl_core.environment import ApplicationEnvironmentABC +from cpl_core.mailing import EMailClientABC, EMailClientSettings +from cpl_core.utils import CredentialManager +from cpl_discord.service import DiscordBotServiceABC +from cpl_translation import TranslatePipe +from flask import jsonify +from flask import request, session +from requests_oauthlib import OAuth2Session + +from bot_api.abc.auth_service_abc import AuthServiceABC +from bot_api.api import Api +from bot_api.configuration.discord_authentication_settings import DiscordAuthenticationSettings +from bot_api.logging.api_logger import ApiLogger +from bot_api.model.auth_user_dto import AuthUserDTO +from bot_api.route.route import Route +from bot_data.model.auth_role_enum import AuthRoleEnum + +# Disable SSL requirement +os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = '1' + + +class AuthDiscordController: + BasePath = '/api/auth/discord' + + def __init__( + self, + auth_settings: DiscordAuthenticationSettings, + config: ConfigurationABC, + env: ApplicationEnvironmentABC, + logger: ApiLogger, + bot: DiscordBotServiceABC, + t: TranslatePipe, + api: Api, + mail_settings: EMailClientSettings, + mailer: EMailClientABC, + auth_service: AuthServiceABC + ): + self._auth_settings = auth_settings + self._config = config + self._env = env + self._logger = logger + self._bot = bot + self._t = t + self._api = api + self._mail_settings = mail_settings + self._mailer = mailer + self._auth_service = auth_service + + @Route.get(f'{BasePath}/get-url') + async def get_url(self): + oauth = OAuth2Session(self._bot.user.id, redirect_uri=self._auth_settings.redirect_url, scope=self._auth_settings.scope) + login_url, state = oauth.authorization_url(self._auth_settings.auth_url) + session['state'] = state + # return jsonify({'loginURL': login_url}) + return 'Login with Discord' + + @Route.get(f'{BasePath}/register') + async def discord_register(self): + discord = OAuth2Session(self._bot.user.id, redirect_uri=self._auth_settings.redirect_url, state=session['state'], scope=self._auth_settings.scope) + token = discord.fetch_token( + self._auth_settings.token_url, + client_secret=CredentialManager.decrypt(self._auth_settings.client_secret), + authorization_response=request.url, + ) + session['discord_token'] = token + discord = OAuth2Session(self._bot.user.id, token=token) + response = discord.get('https://discordapp.com/api' + '/users/@me').json() + + await self._auth_service.add_auth_user_by_discord_async(AuthUserDTO( + 0, + response['username'], + response['discriminator'], + response['email'], + str(uuid.uuid4()), + None, + AuthRoleEnum.normal, + response['id'] + )) + return '', 200 diff --git a/kdb-bot/src/bot_api/service/auth_service.py b/kdb-bot/src/bot_api/service/auth_service.py index 7e4c969b..99d4fad5 100644 --- a/kdb-bot/src/bot_api/service/auth_service.py +++ b/kdb-bot/src/bot_api/service/auth_service.py @@ -9,6 +9,7 @@ import jwt from cpl_core.database.context import DatabaseContextABC from cpl_core.environment import ApplicationEnvironmentABC from cpl_core.mailing import EMailClientABC, EMail +from cpl_core.utils import CredentialManager from cpl_query.extension import List from cpl_translation import TranslatePipe from flask import request @@ -29,6 +30,8 @@ from bot_api.model.token_dto import TokenDTO from bot_api.model.update_auth_user_dto import UpdateAuthUserDTO from bot_api.transformer.auth_user_transformer import AuthUserTransformer as AUT from bot_data.abc.auth_user_repository_abc import AuthUserRepositoryABC +from bot_data.abc.user_repository_abc import UserRepositoryABC +from bot_data.model.auth_role_enum import AuthRoleEnum from bot_data.model.auth_user import AuthUser _email_regex = r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b' @@ -41,6 +44,7 @@ class AuthService(AuthServiceABC): env: ApplicationEnvironmentABC, logger: ApiLogger, auth_users: AuthUserRepositoryABC, + users: UserRepositoryABC, db: DatabaseContextABC, mailer: MailThread, t: TranslatePipe, @@ -53,6 +57,7 @@ class AuthService(AuthServiceABC): self._environment = env self._logger = logger self._auth_users = auth_users + self._users = users self._db = db self._mailer = mailer self._t = t @@ -80,7 +85,7 @@ class AuthService(AuthServiceABC): 'iss': self._auth_settings.issuer, 'aud': self._auth_settings.audience }, - key=self._auth_settings.secret_key + key=CredentialManager.decrypt(self._auth_settings.secret_key) ) return token @@ -88,7 +93,7 @@ class AuthService(AuthServiceABC): def decode_token(self, token: str) -> dict: return jwt.decode( token, - key=self._auth_settings.secret_key, + key=CredentialManager.decrypt(self._auth_settings.secret_key), issuer=self._auth_settings.issuer, audience=self._auth_settings.audience, algorithms=['HS256'] @@ -105,7 +110,7 @@ class AuthService(AuthServiceABC): return jwt.decode( token, - key=self._auth_settings.secret_key, + key=CredentialManager.decrypt(self._auth_settings.secret_key), issuer=self._auth_settings.issuer, audience=self._auth_settings.audience, algorithms=['HS256'] @@ -119,7 +124,7 @@ class AuthService(AuthServiceABC): return jwt.decode( token, - key=self._auth_settings.secret_key, + key=CredentialManager.decrypt(self._auth_settings.secret_key), issuer=self._auth_settings.issuer, audience=self._auth_settings.audience, algorithms=['HS256'] @@ -196,12 +201,15 @@ class AuthService(AuthServiceABC): user = self._auth_users.find_auth_user_by_email(email) return AUT.to_dto(user) if user is not None else None - async def add_auth_user_async(self, user_dto: AuthUser): + async def add_auth_user_async(self, user_dto: AuthUserDTO): db_user = self._auth_users.find_auth_user_by_email(user_dto.email) if db_user is not None: raise ServiceException(ServiceErrorCode.InvalidUser, 'User already exists') user = AUT.to_db(user_dto) + if self._auth_users.get_all_auth_users().count() == 0: + user.auth_role = AuthRoleEnum.admin + user.password_salt = uuid.uuid4() user.password = self._hash_sha256(user_dto.password, user.password_salt) if not self._is_email_valid(user.email): @@ -217,6 +225,25 @@ class AuthService(AuthServiceABC): self._logger.error(__name__, f'Cannot add user with E-Mal {user_dto.email}', e) raise ServiceException(ServiceErrorCode.UnableToAdd, "Invalid E-Mail") + async def add_auth_user_by_discord_async(self, user_dto: AuthUserDTO): + db_user = self._auth_users.find_auth_user_by_email(user_dto.email) + # user exists + if db_user is not None and db_user.user_id is not None: + raise ServiceException(ServiceErrorCode.InvalidUser, 'User already exists') + + # user exists but discord user id not set + elif db_user is not None and db_user.user_id is None: + user = self._users.get_users_by_discord_id(user_dto.user_id).single() + db_user.user_id = user.user_id + self._auth_users.update_auth_user(db_user) + self._db.save_changes() + return AUT.to_dto(db_user) + + # user does not exists + await self.add_auth_user_async(user_dto) + db_user = self._auth_users.get_auth_user_by_email(user_dto.email) + return AUT.to_dto(db_user, password=db_user.password) + async def update_user_async(self, update_user_dto: UpdateAuthUserDTO): if update_user_dto is None: raise ServiceException(ServiceErrorCode.InvalidData, f'User is empty') diff --git a/kdb-bot/src/bot_api/transformer/auth_user_transformer.py b/kdb-bot/src/bot_api/transformer/auth_user_transformer.py index 768cc7ef..4256f39e 100644 --- a/kdb-bot/src/bot_api/transformer/auth_user_transformer.py +++ b/kdb-bot/src/bot_api/transformer/auth_user_transformer.py @@ -26,13 +26,13 @@ class AuthUserTransformer(TransformerABC): ) @staticmethod - def to_dto(db: AuthUser) -> AuthUserDTO: + def to_dto(db: AuthUser, password: str = None) -> AuthUserDTO: return AuthUserDTO( db.id, db.first_name, db.last_name, db.email, - '', + '' if password is None else password, db.confirmation_id, db.auth_role, db.user_id