diff --git a/dockers/apikaz/source/app.py b/dockers/apikaz/source/app.py index 6fc40ad..50d1afe 100644 --- a/dockers/apikaz/source/app.py +++ b/dockers/apikaz/source/app.py @@ -14,6 +14,8 @@ from flask import Flask, jsonify, send_from_directory, request, abort, json, Res from flask_mail import Mail, Message from flasgger import Swagger from flask_restful import Api, Resource +from flask_jwt_extended import JWTManager, create_access_token, jwt_required + from passlib.hash import sha512_crypt from unidecode import unidecode from email_validator import validate_email, EmailNotValidError @@ -23,6 +25,7 @@ from bs4 import BeautifulSoup from datetime import datetime app = Flask(__name__) +jwt = JWTManager(app) api = Api(app) app.logger.setLevel(logging.DEBUG) @@ -33,8 +36,41 @@ swagger = Swagger(app, template={ "title": "L'API Kaz de la mort qui tue", "version": "0.2.0", "description": "Permettre des opérations de gestion des services kaz avec des écrans Ouaib" - } + }, + "tags": [ + {"name": "Authentication", "description": "Auth related operations"}, + {"name": "Test", "description": "pour tester des conneries"}, + {"name": "Password", "description": "Gestion Mdp"}, + {"name": "Paheko", "description": "Gestion Paheko"}, + {"name": "Mattermost", "description": "Gestion Mattermost Authent"}, + {"name": "Mattermost User", "description": "Gestion Mattermost User"}, + {"name": "Mattermost Team", "description": "Gestion Mattermost Team"}, + {"name": "Ldap", "description": "Gestion Ldap"}, + {"name": "Cloud", "description": "Gestion Cloud Général"}, + {"name": "Sympa", "description": "Gestion Sympa"}, + {"name": "Quota", "description": "Gestion Quota"}, + {"name": "Dns", "description": "Gestion Dns"}, + {"name": "Kaz User", "description": "Gestion Kaz User"} + ], + "securityDefinitions": { + "basicAuth": { + "type": "basic", + "description": "Basic Authentication with username and password" + }, + "Bearer": { + "type": "apiKey", + "name": "Authorization", + "in": "header", + "description": "JWT Authorization header using the Bearer scheme. Example: 'Bearer {token}'" + } + } }) + +#TODO: +# check variables +# fail2ban (ou alors sur traefik) +# découper app.py en service +# quels scripts bash garder ? #************************************************* @@ -47,7 +83,7 @@ swagger = Swagger(app, template={ #TODO: au lieu d'avoir les IP en dur, prendre le fichier allow_ip' trusted_ips = [ -"82.64.20.246", +"82.64.20.246", "31.39.14.228", "51.75.112.172", "80.11.47.59", @@ -58,44 +94,17 @@ trusted_ips = [ "80.67.176.91", "89.234.177.119", "78.127.1.19", -"80.215.236.243", -"78.117.86.68", -"80.215.236.168" +"80.215.236.243" ] - -@app.before_request -def limit_remote_addr(): - if request.environ['HTTP_X_FORWARDED_FOR'] not in trusted_ips: - abort(jsonify(message="Et pis quoi encore "+request.environ['HTTP_X_FORWARDED_FOR']), 400) - + + #************************************************* - -@app.route('/print_env') -def print_environment(): - # Crée une chaîne de caractères pour stocker les variables d'environnement - env_string = "" - - # Itère sur les variables d'environnement et les ajoute à la chaîne de caractères - for key, value in os.environ.items(): - env_string += f"{key}: {value}\n" + "
" - - # Retourne la chaîne de caractères contenant les variables d'environnement - return env_string - -#************************************************* -#***** DEBUT Quelques fonctions utiles *********** -#************************************************* - -#pour injecter la date dans dans le contexte des template -@app.context_processor -def inject_now(): - return {'now': datetime.now} - -#************************************************* -#***** FIN Quelques fonctions utiles *********** -#************************************************* - #variables globales +#************************************************* + +#le secret pour générer les tokens +#app.config['JWT_SECRET_KEY'] = os.environ.get('JWT_SECRET_KEY') +app.config['JWT_SECRET_KEY'] = os.environ.get('JWT_SECRET_KEY', 'your_jwt_secret_key') #le paheko de kaz paheko_ident=os.environ.get('paheko_API_USER') @@ -148,12 +157,64 @@ MAIL_USERNAME=app.config['MAIL_USERNAME'] serveur_imap = os.environ.get('serveur_imap') mot_de_passe_mail=os.environ.get('mot_de_passe_mail') +#************************************************* +@app.before_request +def limit_remote_addr(): + if request.environ['HTTP_X_FORWARDED_FOR'] not in trusted_ips: + abort(jsonify(message="Et pis quoi encore ?"), 400) + +#************************************************* +#authent mdp/pass basique +def check_auth(username, password): + return username == os.environ.get('apikaz_doc_user') and password == os.environ.get('apikaz_doc_password') + +def authenticate(): + return Response('tssssss.\n', 401, {'WWW-Authenticate': 'Basic realm="Login Required"'}) + +@app.before_request +def require_basic_auth(): + if request.path.startswith('/apidocs') or request.path.startswith('/print_env'): + #if request.path.startswith('/'): + auth = request.authorization + if not auth or not check_auth(auth.username, auth.password): + return authenticate() + + +#************************************************* +#DANGER: ne jamais mettre print_env en PROD +@app.route('/print_env') +def print_environment(): + # Crée une chaîne de caractères pour stocker les variables d'environnement + env_string = "" + + # Itère sur les variables d'environnement et les ajoute à la chaîne de caractères + for key, value in os.environ.items(): + env_string += f"{key}: {value}\n" + "
" + + # Retourne la chaîne de caractères contenant les variables d'environnement + return env_string + +#************************************************* +#***** DEBUT Quelques fonctions utiles *********** +#************************************************* + +#pour injecter la date dans dans le contexte des template +@app.context_processor +def inject_now(): + return {'now': datetime.now} + +#************************************************* +#***** FIN Quelques fonctions utiles *********** +#************************************************* + + #************************************************* @app.route('/favicon.ico') def favicon(): # return send_from_directory(os.path.join(app.root_path, 'static'),'favicon.ico') return '', 204 + #************************************************* #la page d'accueil est vide @@ -161,16 +222,51 @@ def favicon(): def silence(): return "" + +#************************************************* +# obtenir un token +@app.route('/get_token', methods=['GET']) +def get_token(): + """ + Get JWT token with basic auth + --- + tags: + - Authentication + security: + - basicAuth: [] + responses: + 200: + description: Token generated successfully + schema: + type: object + properties: + access_token: + type: string + description: JWT access token + 401: + description: Unauthorized + """ + auth = request.authorization + if auth and check_auth(auth.username, auth.password): + # Créez un token JWT après une authentification réussie + access_token = create_access_token(identity=auth.username) + return jsonify(access_token=access_token) + else: + return authenticate() + #************************************************* #*******MDP*************************************** #************************************************* class Password_create(Resource): + @jwt_required() def get(self): """ créer un password qui colle avec les appli kaz --- tags: - Password + security: + - Bearer: [] parameters: [] responses: 200: @@ -184,7 +280,7 @@ class Password_create(Resource): try: output = subprocess.check_output(cmd, shell=True, stderr=subprocess.STDOUT) new_password="_"+output.decode("utf-8")+"_" - return new_password,200 + return new_password,200 except subprocess.CalledProcessError as e: return e.output.decode("utf-8"), 400 @@ -196,12 +292,16 @@ api.add_resource(Password_create, '/password/create') #************************************************* class Paheko_categories(Resource): + + @jwt_required() def get(self): """ Récupérer les catégories Paheko avec le compteur associé --- tags: - Paheko + security: + - Bearer: [] parameters: [] responses: 200: @@ -227,12 +327,16 @@ api.add_resource(Paheko_categories, '/paheko/user/categories') #************************************************* class Paheko_users(Resource): + + @jwt_required() def get(self,categorie): """ Afficher les membres d'une catégorie Paheko --- tags: - Paheko + security: + - Bearer: [] parameters: - in: path name: categorie @@ -248,6 +352,9 @@ class Paheko_users(Resource): global paheko_ident, paheko_pass, paheko_url auth = (paheko_ident, paheko_pass) + if not categorie.isdigit(): + return 'Id de category non valide', 400 + api_url = paheko_url + '/api/user/category/'+categorie+'.json' response = requests.get(api_url, auth=auth) @@ -263,19 +370,23 @@ api.add_resource(Paheko_users, '/paheko/user/category/') #************************************************* class Paheko_user(Resource): + def __init__(self): global paheko_ident, paheko_pass, paheko_url self.paheko_ident = paheko_ident self.paheko_pass = paheko_pass self.paheko_url = paheko_url self.auth = (self.paheko_ident, self.paheko_pass) - + + @jwt_required() def get(self,ident): """ Afficher un membre de Paheko par son email kaz ou son numéro ou le non court de l'orga --- tags: - Paheko + security: + - Bearer: [] parameters: - in: path name: ident @@ -289,8 +400,10 @@ class Paheko_user(Resource): description: N'existe pas """ - if '@' in ident: - data = { "sql": f"select * from users where email='{ident}'" } + emailmatchregexp = re.compile(r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$") + + if emailmatchregexp.match(ident): + data = { "sql": f"select * from users where email='{ident}' or alias = '{ident}'" } api_url = self.paheko_url + '/api/sql/' response = requests.post(api_url, auth=self.auth, data=data) #TODO: if faut Rechercher count et vérifier que = 1 et supprimer le count=1 dans la réponse @@ -298,26 +411,35 @@ class Paheko_user(Resource): api_url = self.paheko_url + '/api/user/'+ident response = requests.get(api_url, auth=self.auth) else: - data = { "sql": f"select * from users where admin_orga=1 and nom_orga='{ident}'" } + nomorga = re.sub(r'\W+', '', ident) # on vire les caractères non alphanumérique + data = { "sql": f"select * from users where admin_orga=1 and nom_orga='{nomorga}'" } api_url = self.paheko_url + '/api/sql/' response = requests.post(api_url, auth=self.auth, data=data) #TODO:if faut Rechercher count et vérifier que = 1 et supprimer le count=1 dans la réponse if response.status_code == 200: data = response.json() - return jsonify(data) + if data["count"] == 1: + return jsonify(data["results"][0]) + elif data["count"] == 0: + return "pas de résultat", 400 + else: + return "Plusieurs utilisateurs correspondent ?!", 400 else: #return jsonify({'error': 'La requête a échoué'}), response.status_code return "pas de résultat", response.status_code #************************************************* + @jwt_required() def put(self,ident,field,new_value): """ Modifie la valeur d'un champ d'un membre paheko (ident= numéro paheko ou email kaz) --- tags: - Paheko + security: + - Bearer: [] parameters: - in: path name: ident @@ -345,7 +467,8 @@ class Paheko_user(Resource): """ #récupérer le numero paheko si on fournit un email kaz - if '@' in ident: + emailmatchregexp = re.compile(r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$") + if emailmatchregexp.match(ident): data = { "sql": f"select id from users where email='{ident}'" } api_url = self.paheko_url + '/api/sql/' response = requests.post(api_url, auth=self.auth, data=data) @@ -361,16 +484,23 @@ class Paheko_user(Resource): ident = data['results'][0]['id'] else: return "pas de résultat", response.status_code - + elif not ident.isdigit(): + return "Identifiant utilisateur invalide", response.status_code + + regexp = re.compile("[^a-zA-Z0-9 \\r\\n\\t" + re.escape(string.punctuation) + "]") + valeur = regexp.sub('',new_value) # mouais, il faudrait être beaucoup plus précis ici en fonction des champs qu'on accepte... + + champ = re.sub(r'\W+','',field) # pas de caractères non alphanumériques ici, dans l'idéal, c'est à choisir dans une liste plutot + api_url = self.paheko_url + '/api/user/'+str(ident) - payload = {field: new_value} + payload = {champ: valeur} response = requests.post(api_url, auth=self.auth, data=payload) return response.json(),response.status_code #************************************************* api.add_resource(Paheko_user, '/paheko/user/', endpoint='paheko_get_user', methods=['GET']) -api.add_resource(Paheko_user, '/paheko/user///', endpoint='paheko_maj_user', methods=['PUT']) +api.add_resource(Paheko_user, '/paheko/user///', endpoint='paheko_maj_user', methods=['PUT']) #************************************************* @@ -382,12 +512,15 @@ class Paheko_users_action(Resource): self.paheko_pass = paheko_pass self.paheko_url = paheko_url + @jwt_required() def get(self, action): """ retourne tous les membres de paheko avec une action à mener (création du compte kaz / modification...) --- tags: - - Paheko + - Paheko + security: + - Bearer: [] parameters: - in: path name: action @@ -411,7 +544,7 @@ class Paheko_users_action(Resource): else: return "pas de résultat", response.status_code -api.add_resource(Paheko_users_action, '/paheko/users/') +api.add_resource(Paheko_users_action, '/paheko/users/') #************************************************* #*******MATTERMOST******************************** @@ -431,12 +564,16 @@ def Mattermost_authenticate(): #************************************************* class Mattermost_message(Resource): + + @jwt_required() def post(self,message,equipe="kaz",canal="creation-comptes"): """ Envoyer un message dans une Equipe/Canal de MM --- tags: - Mattermost + security: + - Bearer: [] parameters: - in: path name: equipe @@ -476,12 +613,15 @@ class Mattermost_user(Resource): Mattermost_authenticate() #************************************************* + @jwt_required() def get(self,user): """ Le user existe t-il sur MM ? --- tags: - Mattermost User + security: + - Bearer: [] parameters: - in: path name: user @@ -503,13 +643,15 @@ class Mattermost_user(Resource): return 404 # Le nom d'utilisateur n'existe pas #************************************************* - + @jwt_required() def post(self,user,email,password): """ Créer un utilisateur sur MM --- tags: - Mattermost User + security: + - Bearer: [] parameters: - in: path name: user @@ -541,12 +683,15 @@ class Mattermost_user(Resource): #************************************************* + @jwt_required() def delete(self,email): """ Supprimer un utilisateur sur MM --- tags: - Mattermost User + security: + - Bearer: [] parameters: - in: path name: email @@ -568,12 +713,16 @@ class Mattermost_user(Resource): return e.output.decode("utf-8"), 400 #************************************************* + + @jwt_required() def put(self,email,new_password): """ Changer un password pour un utilisateur de MM --- tags: - Mattermost User + security: + - Bearer: [] parameters: - in: path name: email @@ -607,12 +756,16 @@ api.add_resource(Mattermost_user, '/mattermost/user/change/password//', endpoint='ldap_user #TODO: pas réussi à faire une seule classe Cloud_user avec 2 méthodes get/delete class Cloud_user(Resource): + + @jwt_required() def get(self, email): """ Existe dans le cloud général ? --- tags: - Cloud + security: + - Bearer: [] parameters: - in: path name: email @@ -1167,6 +1346,8 @@ api.add_resource(Cloud_user, '/cloud/user/') #************************************************* class Cloud_user_delete(Resource): + + @jwt_required() def delete(self, email): """ Supprime le compte dans le cloud général @@ -1174,6 +1355,8 @@ class Cloud_user_delete(Resource): --- tags: - Cloud + security: + - Bearer: [] parameters: - in: path name: email @@ -1209,12 +1392,15 @@ api.add_resource(Cloud_user_delete, '/cloud/user/delete/') #************************************************* # class Cloud_user_change(Resource): +# @jwt_required() # def put(self, email, new_password): # """ # Modifie le mot de passe d'un Utilisateur dans le cloud général: QUESTION: A PRIORI INUTILE CAR LIE AU LDAP # --- # tags: # - Cloud +# security: +# - Bearer: [] # parameters: # - in: path # name: email @@ -1278,12 +1464,15 @@ class Sympa_user(Resource): except subprocess.CalledProcessError as e: return e.output.decode("utf-8"), 400 # Retourne la sortie de la commande et un code d'erreur 400 + @jwt_required() def post(self, email, liste): """ Ajouter un email dans une liste sympa --- tags: - Sympa + security: + - Bearer: [] parameters: - in: path name: email @@ -1302,12 +1491,15 @@ class Sympa_user(Resource): output, status_code = self._execute_sympa_command(email, liste, 'add') return output, status_code + @jwt_required() def delete(self, email, liste): """ Supprimer un email dans une liste sympa --- tags: - Sympa + security: + - Bearer: [] parameters: - in: path name: email @@ -1340,13 +1532,15 @@ class Quota(Resource): # sur kazkouil.fr, j'ai modifié /etc/dovecot/conf.d/20-lmtp.conf #mail_plugins = $mail_plugins sieve quota - + @jwt_required() def get(self, email): """ - Récupérer la place prise par une BAL + Récupérer la place prise par une BAL (EN COURS) --- tags: - - Quota (EN COURS) + - Quota + security: + - Bearer: [] parameters: - in: path name: email @@ -1402,12 +1596,15 @@ class Dns_serveurs(Resource): self.gandi_key = gandi_key self.gandi_url_api = gandi_url_api + @jwt_required() def get(self): """ Renvoie tous les serveurs kaz de la zone dns --- tags: - Dns + security: + - Bearer: [] responses: 200: description: Succès, liste des serveurs @@ -1440,13 +1637,15 @@ class Dns(Resource): self.dns_serveurs_resource = Dns_serveurs() #************************************************* - + @jwt_required() def get(self,sdomaine): """ Le sous-domaine existe t-il dans la zone dns avec un enreg CNAME ? --- tags: - Dns + security: + - Bearer: [] parameters: - in: path name: sdomaine @@ -1466,12 +1665,15 @@ class Dns(Resource): #************************************************* + @jwt_required() def delete(self,sdomaine): """ suppression du sdomaine --- tags: - Dns + security: + - Bearer: [] parameters: - in: path name: sdomaine @@ -1490,12 +1692,15 @@ class Dns(Resource): #************************************************* + @jwt_required() def post(self,sdomaine,serveur): """ Créé le sous-domaine de type CNAME qui pointe sur serveur --- tags: - Dns + security: + - Bearer: [] parameters: - in: path name: sdomaine @@ -1564,12 +1769,15 @@ class Kaz_user(Resource): #******************************************************************************************** + @jwt_required() def delete(self): """ Utile pour les tests de createUser. Avant le POST de /kaz/create/users. Ça permet de supprimer/maj les comptes. --- tags: - - Kaz + - Kaz User + security: + - Bearer: [] parameters: [] responses: 201: @@ -1608,12 +1816,15 @@ class Kaz_user(Resource): #******************************************************************************************** + @jwt_required() def post(self): """ Créé un nouveau kaznaute: inscription sur MM / Cloud / email + msg sur MM + email à partir de action="a créer" sur paheko --- tags: - - Kaz + - Kaz User + security: + - Bearer: [] parameters: [] responses: 201: @@ -1770,12 +1981,15 @@ class Test(Resource): #global mattermost_url, sympa_url, webmail_url, mdp_url, site_url, nc_url #******************************************************************************************** + @jwt_required() def get(self): """ - Pour tester des conneries + Pour tester des conneries: # test lançement de cmde ssh sur des serveurs distants: --- tags: - - a simple test + - Test + security: + - Bearer: [] parameters: [] responses: 201: @@ -1784,6 +1998,23 @@ class Test(Resource): description: KO """ #******************************************************************************************** + + +# test lançcement de cmde ssh sur des serveurs distants: +# il faut au préalable que la clé publique de root du conteneur apikaz soit dans authorized key du user fabricer de la machine 163.172.94.54 +# clé à créer dans le Dockerfile +# risque sécu ? + + + cmd="ssh -p 2201 fabricer@163.172.94.54 mkdir -p /tmp/toto" + try: + output = subprocess.check_output(cmd, shell=True, stderr=subprocess.STDOUT) + return "ok",200 + + except subprocess.CalledProcessError as e: + return e.output.decode("utf-8"), 400 + +#******************************************************************************************** # #***** test suppression de toutes les équipes de MM sauf KAZ # res,status=self.mattermost_team_resource=Mattermost_team().get() # for equipe in res: @@ -1794,39 +2025,39 @@ class Test(Resource): #**** test messagerie - NOM="toto" - EMAIL_SOUHAITE='f@kaz.bzh' - PASSWORD="toto" - QUOTA="1" - ADMIN_ORGA="0" - - context = { - 'ADMIN_ORGA': ADMIN_ORGA, - 'NOM': NOM, - 'EMAIL_SOUHAITE': EMAIL_SOUHAITE, - 'PASSWORD': PASSWORD, - 'QUOTA': QUOTA, - 'URL_WEBMAIL': webmail_url, - 'URL_AGORA': mattermost_url, - 'URL_MDP': mdp_url, - 'URL_LISTE': sympa_url, - 'URL_SITE': site_url, - 'URL_CLOUD': cloud_url - } - - subject = "KAZ: confirmation d'inscription !" - sender=app.config['MAIL_USERNAME'] - reply_to = app.config['MAIL_REPLY_TO'] - - msg = Message(subject=subject, sender=sender, reply_to=reply_to, recipients=[EMAIL_SOUHAITE]) - msg.html = render_template('email_inscription.html', **context) - - # Parsez le contenu HTML avec BeautifulSoup - soup = BeautifulSoup(msg.html, 'html.parser') - msg.body = soup.get_text() - - mail.send(msg) - return "Message envoyé!" +# NOM="toto" +# EMAIL_SOUHAITE='f@kaz.bzh' +# PASSWORD="toto" +# QUOTA="1" +# ADMIN_ORGA="0" +# +# context = { +# 'ADMIN_ORGA': ADMIN_ORGA, +# 'NOM': NOM, +# 'EMAIL_SOUHAITE': EMAIL_SOUHAITE, +# 'PASSWORD': PASSWORD, +# 'QUOTA': QUOTA, +# 'URL_WEBMAIL': webmail_url, +# 'URL_AGORA': mattermost_url, +# 'URL_MDP': mdp_url, +# 'URL_LISTE': sympa_url, +# 'URL_SITE': site_url, +# 'URL_CLOUD': cloud_url +# } +# +# subject = "KAZ: confirmation d'inscription !" +# sender=app.config['MAIL_USERNAME'] +# reply_to = app.config['MAIL_REPLY_TO'] +# +# msg = Message(subject=subject, sender=sender, reply_to=reply_to, recipients=[EMAIL_SOUHAITE]) +# msg.html = render_template('email_inscription.html', **context) +# +# # Parsez le contenu HTML avec BeautifulSoup +# soup = BeautifulSoup(msg.html, 'html.parser') +# msg.body = soup.get_text() +# +# mail.send(msg) +# return "Message envoyé!" #******************************************************************************************** # #**** test ms erreur @@ -1847,8 +2078,7 @@ class Test(Resource): # sleep(20) # return str(lock_file), 201 -api.add_resource(Test, '/atest', endpoint='atest', methods=['GET']) - +api.add_resource(Test, '/test', endpoint='test', methods=['GET']) #************************************************* #************************************************* diff --git a/dockers/apikaz/source/requirements.txt b/dockers/apikaz/source/requirements.txt index 62ab067..f0c8dd3 100644 --- a/dockers/apikaz/source/requirements.txt +++ b/dockers/apikaz/source/requirements.txt @@ -7,3 +7,5 @@ passlib unidecode email-validator python-ldap +flask-jwt-extended +BeautifulSoup4