From 0ef78647fb7efb9ede08cc6fb1d1faa258b76f70 Mon Sep 17 00:00:00 2001 From: Alexandre CHAZAL Date: Mon, 6 Dec 2021 18:29:43 +0100 Subject: [PATCH 01/13] fix(password): fixed pep8 lint errors --- app/password.py | 112 +++++++++++++++++++++++++++++------------------ requirements.txt | 4 +- 2 files changed, 72 insertions(+), 44 deletions(-) diff --git a/app/password.py b/app/password.py index 5153c8f..88f7efb 100644 --- a/app/password.py +++ b/app/password.py @@ -1,51 +1,64 @@ -from . import ldap_client - +from . import ( + ldap_client +) from flask import ( - Blueprint, render_template, flash, + Blueprint, + render_template, + flash, current_app ) -from flask_wtf import FlaskForm +from flask_wtf import ( + FlaskForm +) from wtforms import ( - StringField, PasswordField, - SubmitField, EmailField + StringField, + PasswordField, + SubmitField, + EmailField ) from wtforms.validators import ( - ValidationError, DataRequired, - EqualTo, Length, Regexp, Email + ValidationError, + DataRequired, + EqualTo, + Length, + Regexp, + Email ) bp = Blueprint('password', __name__, url_prefix='/password') + class ChangePasswordForm(FlaskForm): # Minimal password length minlength = 9 # Form - username = StringField(label=('Login'), - validators=[DataRequired(), - Length(max=64)], - render_kw={"onkeyup": "validate_username()"}) - currentpassword = PasswordField(label=('Current password'), - validators=[DataRequired()]) - newpassword = PasswordField(label=('New password'), - validators=[DataRequired(), - Length(min=minlength), - Regexp("^(?=.*[a-z])"), - Regexp("^(?=.*[A-Z])"), - Regexp("^(?=.*\\d)"), - #Regexp( - # "(?=.*[@$!%*#?&])", message="Password must contain a special character" - #),], - ], - render_kw={"onkeyup": f"validate_username_form({minlength})"}) + username = StringField(label=('Login'), + validators=[DataRequired(), + Length(max=64)], + render_kw={"onkeyup": "validate_username()"}) + currentpassword = PasswordField(label=('Current password'), + validators=[DataRequired()]) + newpassword = PasswordField(label=('New password'), + validators=[DataRequired(), + Length(min=minlength), + Regexp("^(?=.*[a-z])"), + Regexp("^(?=.*[A-Z])"), + Regexp("^(?=.*\\d)"), + ], + render_kw={"onkeyup": "validate_username_form" + f"({minlength})"}) confirm_password = PasswordField( - label=('Confirm Password'), + label=('Confirm Password'), validators=[DataRequired(message='* Required'), - EqualTo('newpassword')], + EqualTo('newpassword')], render_kw={"onkeyup": f"validate_username_form({minlength})"}) - submit = SubmitField(label=('Change my password'), render_kw={"disabled": "true", - "onclick": f"validate_username_form({minlength})"}) + submit = SubmitField( + label=('Change my password'), + render_kw={ + "disabled": "true", + "onclick": f"validate_username_form({minlength})"}) # Validators def validate_username(self, username): @@ -55,32 +68,45 @@ class ChangePasswordForm(FlaskForm): raise ValidationError( f"Character {char} is not allowed in an username.") -class ResetPasswordForm(FlaskForm): - email = EmailField(label=('Email address'), - validators=[DataRequired(), Email()], - render_kw={"onkeyup": f"validate_email()"}) - submit = SubmitField(label=('Change my password'), render_kw={"disabled": "true", - "onclick": f"validate_email()"}) +class ResetPasswordForm(FlaskForm): + email = EmailField(label=('Email address'), + validators=[DataRequired(), Email()], + render_kw={"onkeyup": "validate_email()"}) + + submit = SubmitField( + label=('Change my password'), + render_kw={ + "disabled": "true", + "onclick": "validate_email()"}) + @bp.route('/change', methods=["GET", "POST"]) def change(): form = ChangePasswordForm() if form.validate_on_submit(): - client = ldap_client.Client(address=current_app.config["LDAP_ADDR"], port=current_app.config["LDAP_PORT"], base_dn=current_app.config["BASE_DN"], tls=current_app.config["LDAP_TLS"]) + client = ldap_client.Client(address=current_app.config["LDAP_ADDR"], + port=current_app.config["LDAP_PORT"], + base_dn=current_app.config["BASE_DN"], + tls=current_app.config["LDAP_TLS"]) - bind_status = client.bind(form.username._value(), form.currentpassword._value()) - if bind_status[0] == False: - flash(f"Connection failed, are you sure that your login and password are correct ? ({client.link.last_error})") - elif client.change_pwd(bind_status[1], form.newpassword._value()) == False: - flash(f"An error occured and your password was not changed, sorry. ({client.link.last_error})") + bind_status = client.bind( + form.username._value(), form.currentpassword._value()) + if bind_status[0] is False: + flash("Connection failed, are you sure that your login and" + f" password are correct ? ({client.link.last_error})") + elif client.change_pwd(bind_status[1], + form.newpassword._value()) is False: + flash("An error occured and your password was not changed, sorry." + f"({client.link.last_error})") client.unbind() else: flash('Your password has been changed !') client.unbind() - + return render_template('change.html', form=form) + @bp.route('/reset', methods=["GET"]) def reset(): - return render_template('reset.html') \ No newline at end of file + return render_template('reset.html') diff --git a/requirements.txt b/requirements.txt index 071afd4..8f5ecee 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,4 +10,6 @@ Werkzeug==2.0.2 zipp==3.6.0 ldap3 Flask-WTF==1.0.0 -email-validator \ No newline at end of file +email-validator +flake8 +bandit \ No newline at end of file -- 2.49.1 From 4f5bcb0d7a1b0f970ff16d331704ab09d2ccd3d3 Mon Sep 17 00:00:00 2001 From: Alexandre CHAZAL Date: Mon, 6 Dec 2021 18:37:06 +0100 Subject: [PATCH 02/13] fix(config): fixed pep8 lint errors --- app/config.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/config.py b/app/config.py index d1601cd..584bb54 100644 --- a/app/config.py +++ b/app/config.py @@ -1,5 +1,6 @@ import os + class Config(object): SECRET_KEY = os.environ.get("SECRET_KEY") if SECRET_KEY is None: @@ -21,11 +22,13 @@ class Config(object): SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_SAMESITE = 'None' + class DevelopmentConfig(Config): TESTING = True DEBUG = True DEVELOPMENT = True + class ProductionConfig(Config): TESTING = False DEBUG = False -- 2.49.1 From 61fa6096e2bf9d016c32a887d5f6ca587e7143aa Mon Sep 17 00:00:00 2001 From: Alexandre CHAZAL Date: Mon, 6 Dec 2021 19:33:26 +0100 Subject: [PATCH 03/13] fix(init): added docstrings and indented --- app/__init__.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index 33a09db..8afb621 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,8 +1,21 @@ +""" +Module that initializes the app +""" from flask import Flask from . import password, config -def create_app(): - app = Flask(__name__, template_folder="ui/templates", static_folder="ui/static") + +def create_app() -> Flask: + """ + Creates the Flask app object + + Returns: + Flask: App's object + """ + app = Flask( + __name__, + template_folder="ui/templates", + static_folder="ui/static") app.config.from_object(config.ProductionConfig()) app.register_blueprint(password.bp) -- 2.49.1 From c51403ed2d26c153eae06e65c7008261b6e7c8b2 Mon Sep 17 00:00:00 2001 From: Alexandre CHAZAL Date: Mon, 6 Dec 2021 19:33:43 +0100 Subject: [PATCH 04/13] fix(ldap_client): added docstrings, re-indented correctly, moved Client class's parameters to a Dataclass --- app/ldap_client.py | 130 +++++++++++++++++++++++++++++++-------------- 1 file changed, 90 insertions(+), 40 deletions(-) diff --git a/app/ldap_client.py b/app/ldap_client.py index 976c67a..fc38758 100644 --- a/app/ldap_client.py +++ b/app/ldap_client.py @@ -1,60 +1,110 @@ -import ldap3 +""" +Module used to connect to the target LDAP server +""" from typing import Tuple +from dataclasses import dataclass +import ldap3 + + +@dataclass +class ClientParams: + """ + Parameters for the Client class + """ + address: str + base_dn: str + port: int = 389 + tls: bool = False + primary_attribute: str = "uid" + class Client(): - def __init__(self, address: str, port: int, base_dn: str, primary_attribute: str = "uid", tls: bool = False): - self.server = ldap3.Server(host=address, port=port, use_ssl=tls) - self.base_dn = base_dn - self.address = address - self.port = port - self.tls = tls - self.primary_attribute = primary_attribute + """ + Class that represents a connection to an LDAP server. + + Attributes: + server (ldap3.Connection): An ldap3.Server object + base_dn (str): The target base DN + address (str): The target server's address + port (int): The target server's port + tls (bool): A boolean to indicate TLS usage + primary_attribute (str): The clients' LDAP primary attributes + link (ldap3.Connection): Represents the current link status + """ + def __init__(self, params: ClientParams): + self.server = ldap3.Server( + host=params.address, + port=params.port, + use_ssl=params.tls) + self.base_dn = params.base_dn + self.address = params.address + self.port = params.port + self.tls = params.tls + self.primary_attribute = params.primary_attribute + self.link = None def bind(self, user: str, bind_passwd: str) -> Tuple[bool, str]: + """ + Binds to the target server. + + Args: + user (str): The target username + bind_passwd (str): The target user's password + + Returns: + (bool, str): (True, "") if the bind was successful + (False, "") if it wasn't. + """ user_dn = f"{self.primary_attribute}={user},{self.base_dn}" - self.link = ldap3.Connection(self.server, user=user_dn, password=bind_passwd) + self.link = ldap3.Connection(self.server, + user=user_dn, + password=bind_passwd) - try: - status = self.link.bind() - except Exception as _: - status = False + status = self.link.bind() + if status is False: + print(f"[!!] Could not bind {user_dn} to the LDAP directory: " + f"{self.link.last_error}") + return (False, "") - if status == False: - print(f"[!!] Could not bind {user_dn} to the LDAP directory: {self.link.last_error}") - return (status, "") - return (status, user_dn) def unbind(self) -> bool: - if self.link.bound != True: - return False - - try: - self.link.unbind() - except Exception as e: - pass + """ + Unbinds and disconnect from the server. - return True + Returns: + bool: True if the operation was successful, False if it wasn't. + """ + if self.link.bound is False: + return False + + return self.link.unbind() def change_pwd(self, user_dn: str, new_password: str) -> bool: - if self.link.bound == False: + """ + Changes a specific user's DN. + + Args: + user_dn (str): The target user's DN + new_password (str): The wanted password + + Returns: + bool: True if the operation was successful, False if it wasn't. + """ + if self.link.bound is False: print("[!!] Can't change the password: not bound to the server") return False - - status = self.link.modify(user_dn, {'userPassword': [(ldap3.MODIFY_REPLACE, [new_password])]}) - if status == True: + + status = self.link.modify( + user_dn, { + 'userPassword': [ + (ldap3.MODIFY_REPLACE, [new_password])]}) + if status: print(f"[++] Changed password of user {user_dn}") - else: - print(f"[!!] Could not change password of user {user_dn}: {self.link.last_error}") + else: + print( + f"[!!] Could not change password of user {user_dn}: " + f"{self.link.last_error}") return status - -if __name__ == "__main__": - client = Client("dc01.lan.alxczl.fr", 636, "cn=users,cn=accounts,dc=lan,dc=alxczl,dc=fr", True) - client_dn = "uid=alexandre,cn=users,cn=accounts,dc=lan,dc=alxczl,dc=fr" - res = client.bind(client_dn, "Getshrektm8") - if res[0] == False: - print(client.link.result["description"]) - - #client.link.unbind() \ No newline at end of file -- 2.49.1 From 889e8b3b203e783d4c676fd5a8aa840232831bfb Mon Sep 17 00:00:00 2001 From: Alexandre CHAZAL Date: Mon, 6 Dec 2021 19:35:43 +0100 Subject: [PATCH 05/13] fix(wsgi): added docstring --- wsgi.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/wsgi.py b/wsgi.py index 3f0abc1..da001fe 100644 --- a/wsgi.py +++ b/wsgi.py @@ -1,6 +1,11 @@ -import os, sys +""" +WSGI entrypont +""" +import os +import sys +from app import create_app + app_path = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, app_path) -from app import create_app application = create_app() -- 2.49.1 From 7591c7691539773a4660f27bd2cd046af28c7c14 Mon Sep 17 00:00:00 2001 From: Alexandre CHAZAL Date: Mon, 6 Dec 2021 19:46:00 +0100 Subject: [PATCH 06/13] fix(config): added docstrings --- app/config.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/config.py b/app/config.py index 584bb54..b8cbe3d 100644 --- a/app/config.py +++ b/app/config.py @@ -1,3 +1,6 @@ +""" +A module that is used to store the current app's config +""" import os -- 2.49.1 From 8d501d6a85d4054a5ff7432ff5c4e7f3ab566f4d Mon Sep 17 00:00:00 2001 From: Alexandre CHAZAL Date: Mon, 6 Dec 2021 19:49:14 +0100 Subject: [PATCH 07/13] fix(password): fixed pylint errors --- app/password.py | 45 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/app/password.py b/app/password.py index 88f7efb..13202e4 100644 --- a/app/password.py +++ b/app/password.py @@ -1,6 +1,6 @@ -from . import ( - ldap_client -) +""" +Module that represents the /password route +""" from flask import ( Blueprint, render_template, @@ -25,10 +25,19 @@ from wtforms.validators import ( Email ) +from . import ( + ldap_client +) + + bp = Blueprint('password', __name__, url_prefix='/password') class ChangePasswordForm(FlaskForm): + """ + A Flask form that asks users about various informations needed + to change their password. + """ # Minimal password length minlength = 9 @@ -61,7 +70,10 @@ class ChangePasswordForm(FlaskForm): "onclick": f"validate_username_form({minlength})"}) # Validators - def validate_username(self, username): + def validate_username(self): + """ + A validation function for the username input field + """ excluded_chars = " *?!'^+%&/()=}][{$#;\\\"" for char in self.username.data: if char in excluded_chars: @@ -70,6 +82,10 @@ class ChangePasswordForm(FlaskForm): class ResetPasswordForm(FlaskForm): + """ + A Flask form that asks users about their email, used to + reset their passwords. + """ email = EmailField(label=('Email address'), validators=[DataRequired(), Email()], render_kw={"onkeyup": "validate_email()"}) @@ -83,20 +99,26 @@ class ResetPasswordForm(FlaskForm): @bp.route('/change', methods=["GET", "POST"]) def change(): + """ + The /password/change route method + """ form = ChangePasswordForm() if form.validate_on_submit(): - client = ldap_client.Client(address=current_app.config["LDAP_ADDR"], - port=current_app.config["LDAP_PORT"], - base_dn=current_app.config["BASE_DN"], - tls=current_app.config["LDAP_TLS"]) + ldap_params = ldap_client.ClientParams( + address=current_app.config["LDAP_ADDR"], + port=current_app.config["LDAP_PORT"], + base_dn=current_app.config["BASE_DN"], + tls=current_app.config["LDAP_TLS"]) + client = ldap_client.Client(ldap_params) bind_status = client.bind( - form.username._value(), form.currentpassword._value()) + form.username.data, form.currentpassword.data) + if bind_status[0] is False: flash("Connection failed, are you sure that your login and" f" password are correct ? ({client.link.last_error})") elif client.change_pwd(bind_status[1], - form.newpassword._value()) is False: + form.newpassword.data) is False: flash("An error occured and your password was not changed, sorry." f"({client.link.last_error})") client.unbind() @@ -109,4 +131,7 @@ def change(): @bp.route('/reset', methods=["GET"]) def reset(): + """ + The /password/reset route method + """ return render_template('reset.html') -- 2.49.1 From 2c80006400ccefa7013800f28ac70af92785ae61 Mon Sep 17 00:00:00 2001 From: Alexandre CHAZAL Date: Mon, 6 Dec 2021 19:55:50 +0100 Subject: [PATCH 08/13] feat(drone): added linting and analysis in the CI --- .drone.yml | 22 +++++++++++++++++++++- requirements.txt | 1 + 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/.drone.yml b/.drone.yml index 979ad14..a3a5f38 100644 --- a/.drone.yml +++ b/.drone.yml @@ -8,7 +8,27 @@ platform: arch: amd64 steps: - - name: docker + - name: Install packages + image: python + commands: + - pip install -r requirements.txt + + - name: Lint - pylint + image: python + commands: + - pylint wsgi.py app/*.py + + - name: Lint - flake8 + image: python + commands: + - flake8 wsgi.py app/*.py + + - name: Security analysis with bandit + image: python + commands: + - bandit -r app/ + + - name: Publish the image image: plugins/docker settings: repo: registry.alxczl.fr/ldap-interface diff --git a/requirements.txt b/requirements.txt index 8f5ecee..602c8b6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,4 +12,5 @@ ldap3 Flask-WTF==1.0.0 email-validator flake8 +pylint bandit \ No newline at end of file -- 2.49.1 From 439020049f5c3d9f548e41bc1ba116b74b1da78b Mon Sep 17 00:00:00 2001 From: Alexandre CHAZAL Date: Mon, 6 Dec 2021 20:08:12 +0100 Subject: [PATCH 09/13] fix(drone): added cache between tests --- .drone.yml | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/.drone.yml b/.drone.yml index a3a5f38..0a44f92 100644 --- a/.drone.yml +++ b/.drone.yml @@ -12,21 +12,33 @@ steps: image: python commands: - pip install -r requirements.txt + volumes: + - name: pip_cache + path: /root/.cache/pip - name: Lint - pylint image: python commands: - pylint wsgi.py app/*.py + volumes: + - name: pip_cache + path: /root/.cache/pip - name: Lint - flake8 image: python commands: - flake8 wsgi.py app/*.py + volumes: + - name: pip_cache + path: /root/.cache/pip - name: Security analysis with bandit image: python commands: - bandit -r app/ + volumes: + - name: pip_cache + path: /root/.cache/pip - name: Publish the image image: plugins/docker @@ -40,4 +52,9 @@ steps: auto_tag: true when: branch: - - master \ No newline at end of file + - master + +volumes: + - name: pip_cache + host: + path: /tmp/drone/cache/pip_cache \ No newline at end of file -- 2.49.1 From 2b0c7e6696c1fc7ecf0e7634d1dab76d4dcadda3 Mon Sep 17 00:00:00 2001 From: Alexandre CHAZAL Date: Mon, 6 Dec 2021 20:47:24 +0100 Subject: [PATCH 10/13] fix(drone): added pip install to load from cache --- .drone.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.drone.yml b/.drone.yml index 0a44f92..6e95ef0 100644 --- a/.drone.yml +++ b/.drone.yml @@ -19,6 +19,7 @@ steps: - name: Lint - pylint image: python commands: + - pip install -r requirements.txt - pylint wsgi.py app/*.py volumes: - name: pip_cache @@ -27,6 +28,7 @@ steps: - name: Lint - flake8 image: python commands: + - pip install -r requirements.txt - flake8 wsgi.py app/*.py volumes: - name: pip_cache @@ -35,6 +37,7 @@ steps: - name: Security analysis with bandit image: python commands: + - pip install -r requirements.txt - bandit -r app/ volumes: - name: pip_cache -- 2.49.1 From c10e01706af5b9cc9ce690072fbff16f396ef1d7 Mon Sep 17 00:00:00 2001 From: Alexandre CHAZAL Date: Mon, 6 Dec 2021 20:51:43 +0100 Subject: [PATCH 11/13] fix(config): removed object inheritance from base class --- app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/config.py b/app/config.py index b8cbe3d..abece9c 100644 --- a/app/config.py +++ b/app/config.py @@ -4,7 +4,7 @@ A module that is used to store the current app's config import os -class Config(object): +class Config(): SECRET_KEY = os.environ.get("SECRET_KEY") if SECRET_KEY is None: raise Exception("[!!] No secret key was given") -- 2.49.1 From b1f8c3f27750dc0b97c4bf801a2f44ef168e9a34 Mon Sep 17 00:00:00 2001 From: Alexandre CHAZAL Date: Mon, 6 Dec 2021 20:52:05 +0100 Subject: [PATCH 12/13] fix(drone): now ignore config.py in pylint step --- .drone.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.drone.yml b/.drone.yml index 6e95ef0..79525af 100644 --- a/.drone.yml +++ b/.drone.yml @@ -20,7 +20,7 @@ steps: image: python commands: - pip install -r requirements.txt - - pylint wsgi.py app/*.py + - pylint --ignore-paths app/config.py wsgi.py app/*.py volumes: - name: pip_cache path: /root/.cache/pip -- 2.49.1 From d56d3e28eeb41a0d196ccf1e5ab054789ea6640d Mon Sep 17 00:00:00 2001 From: Alexandre CHAZAL Date: Mon, 6 Dec 2021 21:27:38 +0100 Subject: [PATCH 13/13] feat(tag): added .tag --- .tag | 1 + 1 file changed, 1 insertion(+) create mode 100644 .tag diff --git a/.tag b/.tag new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/.tag @@ -0,0 +1 @@ +1.0.0 -- 2.49.1