From b380ed510b5071fdb72820c94f43183872a9930c Mon Sep 17 00:00:00 2001 From: Prajaktas8876 Date: Thu, 6 Aug 2026 18:17:33 +0530 Subject: [PATCH] Add LDAP Aunthentication --- .env | 20 +++-- Dockerfile | 9 +- app/__init__.py | 15 ++-- app/config.py | 31 ++++--- app/models/user_model.py | 5 +- app/routes/auth.py | 93 +++++---------------- app/services/ldap_service.py | 158 +++++++++++------------------------ app/services/user_service.py | 47 +++++++++-- docker-compose.yml | 6 +- requirements.txt | 4 +- run.py | 6 +- 11 files changed, 169 insertions(+), 225 deletions(-) diff --git a/.env b/.env index 86c7bd2..648c509 100644 --- a/.env +++ b/.env @@ -4,7 +4,7 @@ FLASK_ENV=development FLASK_DEBUG=True FLASK_HOST=0.0.0.0 -FLASK_PORT=5015 +FLASK_PORT=5011 # ----------------------------- # Security @@ -23,15 +23,17 @@ DB_USER=root DB_PASSWORD=root # DATABASE_URL=mysql+pymysql://root:root@localhost/comparisondb - - # ----------------------------- -# LDAP Configuration new +# LDAP Configuration # ----------------------------- -LDAP_SERVER=ldap://host.docker.internal -LDAP_PORT=389 -LDAP_USE_SSL=False +USE_LDAP_AUTH=true +LDAP_URL=ldap://192.168.0.25:389 +LDAP_BIND_DN=cn=admin,dc=lcepl,dc=org +LDAP_BIND_PASSWORD=Lcepl1950@2026 +LDAP_BASE_DN=dc=lcepl,dc=org LDAP_DOMAIN=lcepl.org -LDAP_BASE_DN=DC=lcepl,DC=org -LDAP_SEARCH_BASE=OU=Users,DC=lcepl,DC=org + +# OpenLDAP standard username attribute +LDAP_SEARCH_FILTER=(uid={username}) + diff --git a/Dockerfile b/Dockerfile index 75cb21a..cd83530 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,20 +5,23 @@ WORKDIR /app # Install system dependencies RUN apt-get update && apt-get install -y \ gcc \ + default-libmysqlclient-dev \ + pkg-config \ && rm -rf /var/lib/apt/lists/* # Copy requirements and install Python dependencies COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +RUN pip install --no-cache-dir -r requirements.txt gunicorn # Copy application code COPY . . # Create necessary directories RUN mkdir -p app/logs app/static/uploads app/static/downloads +ENV FLASK_APP=run.py # Expose port EXPOSE 5001 -# Run the application -CMD ["python", "run.py"] +# Run the application with Gunicorn (production WSGI server) +CMD ["gunicorn", "--bind", "0.0.0.0:5001", "run:app"] \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py index 8838957..8f82c56 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,6 +1,6 @@ from flask import Flask, redirect, url_for from app.config import Config -from app.services.db_service import db +from app.services.db_service import db, migrate from app.services.logger_service import LoggerService def create_app(): @@ -9,6 +9,9 @@ def create_app(): # Initialize extensions db.init_app(app) + migrate.init_app(app, db) + with app.app_context(): + db.create_all() # Initialize Logger LoggerService.init_app(app) @@ -35,10 +38,7 @@ def register_blueprints(app): from app.routes.file_report import file_report_bp from app.routes.generate_comparison_report import generate_report_bp from app.routes.file_format import file_format_bp - - # new from app.routes.activity_routes import activity_bp - from app.routes.engineering_master_routes import engi_bp app.register_blueprint(auth_bp) app.register_blueprint(user_bp) @@ -47,12 +47,9 @@ def register_blueprints(app): app.register_blueprint(file_import_bp) app.register_blueprint(file_report_bp) app.register_blueprint(generate_report_bp) - app.register_blueprint(file_format_bp) - - # new + app.register_blueprint(file_format_bp ) app.register_blueprint(activity_bp) - app.register_blueprint(engi_bp) - + def register_error_handlers(app): diff --git a/app/config.py b/app/config.py index 54b2182..10d0711 100644 --- a/app/config.py +++ b/app/config.py @@ -1,4 +1,6 @@ import os +# project base url +BASE_DIR = os.path.abspath(os.path.dirname(__file__)) class Config: # secret key @@ -21,14 +23,23 @@ class Config: ) SQLALCHEMY_TRACK_MODIFICATIONS = False + # uploads folder path + UPLOAD_FOLDER = os.path.join(BASE_DIR, "static", "uploads") + # file extension + ALLOWED_EXTENSIONS = {"xlsx", "xls", "csv"} - - # LDAP Configuration New - LDAP_SERVER = os.getenv("LDAP_SERVER") - LDAP_PORT = int(os.getenv("LDAP_PORT", 389)) - LDAP_USE_SSL = os.getenv("LDAP_USE_SSL", "False").lower() == "true" - - LDAP_BASE_DN = os.getenv("LDAP_BASE_DN") - LDAP_DOMAIN = os.getenv("LDAP_DOMAIN") - - LDAP_SEARCH_BASE = os.getenv("LDAP_SEARCH_BASE") \ No newline at end of file + # ---------------- LDAP settings ---------------- + USE_LDAP_AUTH = os.getenv("USE_LDAP_AUTH", "false").lower() == "true" + # e.g. "ldap://192.168.0.25:389" or "ldaps://192.168.0.25:636" (preferred, encrypted) + LDAP_SERVER = os.getenv("LDAP_URL", "ldap://192.168.0.25:389") + # Service/admin account used only to SEARCH for a user's real DN. + # The user's own password is never used for this bind. + LDAP_BIND_DN = os.getenv("LDAP_BIND_DN", "cn=admin,dc=lcepl,dc=org") + LDAP_BIND_PASSWORD = os.getenv("LDAP_BIND_PASSWORD", "") + # Base DN to search for user entries under + LDAP_BASE_DN = os.getenv("LDAP_BASE_DN", "dc=lcepl,dc=org") + # Used only as a fallback to build an email if the directory entry has none + LDAP_DOMAIN = os.getenv("LDAP_DOMAIN", "lcepl.org") + # Filter used to find the user's entry by their login username. + # Standard OpenLDAP attribute is "uid". Active Directory would use sAMAccountName. + LDAP_SEARCH_FILTER = os.getenv("LDAP_SEARCH_FILTER", "(uid={username})") diff --git a/app/models/user_model.py b/app/models/user_model.py index 838cf45..f97e65a 100644 --- a/app/models/user_model.py +++ b/app/models/user_model.py @@ -7,10 +7,13 @@ class User(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(200), nullable=False) email = db.Column(db.String(120), unique=True, nullable=False) - password_hash = db.Column(db.String(255), nullable=False) + password_hash = db.Column(db.String(255), nullable=True) + auth_source = db.Column(db.String(20), nullable=False, default="local") def set_password(self, password): self.password_hash = generate_password_hash(password) def check_password(self, password): + if not self.password_hash: + return False return check_password_hash(self.password_hash, password) diff --git a/app/routes/auth.py b/app/routes/auth.py index 88d6855..c9565ef 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -1,98 +1,49 @@ -from flask import (Blueprint, render_template, request, redirect, url_for, flash, session, current_app) - +from flask import Blueprint, render_template, request, redirect, url_for, flash, session from app.services.user_service import UserService -from app.constants.messages import SuccessMessage, ErrorMessage -from app.constants.http_status import HTTPStatus auth_bp = Blueprint("auth", __name__) - -# ========================== -# LOGIN -# ========================== @auth_bp.route("/login", methods=["GET", "POST"]) def login(): - if session.get("user_id"): - current_app.logger.info("User already logged in.") return redirect(url_for("dashboard.dashboard")) if request.method == "POST": + email = request.form.get("email") + password = request.form.get("password") - try: - email = request.form.get("email", "").strip() - password = request.form.get("password", "") + user = UserService.validate_login(email, password) + if user: + session["user_id"] = user.id + session["user_name"] = user.name + session["user_email"] = user.email + flash("Login successful", "success") + return redirect(url_for("dashboard.dashboard")) - if not email or not password: - flash(ErrorMessage.INVALID_REQUEST, "danger") - current_app.logger.warning("Login failed. Email or password missing.") - return render_template("login.html", title="Login") - - user = UserService.validate_login(email, password) - - if user: - session.clear() - session["user_id"] = user.id - session["user_name"] = user.name - session["email"] = user.email - session.permanent = True - - current_app.logger.info(f"Login successful. User={user.name}") - flash(SuccessMessage.LOGIN, "success") - return redirect(url_for("dashboard.dashboard")) - - current_app.logger.warning(f"Invalid login attempt. Email={email}") - flash(ErrorMessage.LOGIN_FAILED,"danger") - - except Exception as e: - current_app.logger.exception("Login Error" ) - flash(ErrorMessage.INTERNAL_SERVER_ERROR,"danger") + flash("Invalid email or password", "danger") return render_template("login.html", title="Login") -# ========================== -# LOGOUT -# ========================== @auth_bp.route("/logout") def logout(): - username = session.get("user_name", "Unknown") session.clear() - current_app.logger.info(f"Logout successful. User={username}") - flash(SuccessMessage.LOGOUT,"info") - + flash("Logged out successfully", "info") return redirect(url_for("auth.login")) - -# ========================== -# REGISTER -# ========================== @auth_bp.route("/register", methods=["GET", "POST"]) def register(): - if request.method == "POST": - try: - name = request.form.get("name", "").strip() - email = request.form.get("email", "").strip() - password = request.form.get("password", "") + name = request.form.get("name") + email = request.form.get("email") + password = request.form.get("password") - if not name or not email or not password: - flash(ErrorMessage.INVALID_REQUEST,"danger") - return redirect(url_for("auth.register")) + user = UserService.register_user(name, email, password) + if not user: + flash("Email already exists", "danger") + return redirect(url_for("auth.register")) - user = UserService.register_user(name, email, password) + flash("User registered successfully", "success") + return redirect(url_for("auth.login")) - if not user: - current_app.logger.warning(f"Duplicate Registration: {email}") - flash(ErrorMessage.DUPLICATE_ENTRY,"danger") - return redirect(url_for("auth.register")) - - current_app.logger.info(f"New user registered: {email}") - flash(SuccessMessage.CREATED,"success") - return redirect(url_for("auth.login")) - - except Exception: - current_app.logger.exception("User Registration Failed" ) - flash(ErrorMessage.INTERNAL_SERVER_ERROR,"danger") - - return render_template("register.html",title="Register") \ No newline at end of file + return render_template("register.html", title="Register") diff --git a/app/services/ldap_service.py b/app/services/ldap_service.py index 128bfcf..3b365af 100644 --- a/app/services/ldap_service.py +++ b/app/services/ldap_service.py @@ -1,130 +1,72 @@ -from ldap3 import ( - Server, - Connection, - ALL, - NTLM, - SIMPLE, - SUBTREE -) - from flask import current_app -from app.config import Config +from ldap3 import Server, Connection, ALL, SUBTREE +from ldap3.core.exceptions import LDAPException class LDAPService: """ - LDAP / Active Directory Authentication Service + Handles authentication against an LDAP / OpenLDAP server using the + standard "search + bind" pattern: + 1. Bind with a service/admin account just to SEARCH for the user's DN. + 2. Re-bind using that DN + the password the user typed, to verify it. + The user's typed password is only ever used in step 2, never sent + anywhere else. """ @staticmethod def authenticate(username, password): - """ - Authenticate LDAP User - - Returns: - { - "success": True, - "user": { - "username": "...", - "name": "...", - "email": "..." - } - } - - OR - - { - "success": False, - "message": "Invalid username or password" - } - """ - if not username or not password: - return { - "success": False, - "message": "Username and Password are required." - } + return None + server = Server(current_app.config["LDAP_SERVER"], get_info=ALL) + + # --- Step 1: bind as the admin/service account to search the directory --- try: - - # ----------------------------------- - # LDAP SERVER - # ----------------------------------- - server = Server( - Config.LDAP_SERVER, - port=Config.LDAP_PORT, - use_ssl=Config.LDAP_USE_SSL, - get_info=ALL - ) - - # ----------------------------------- - # Login Format - # - # username@domain.com - # ----------------------------------- - user_dn = f"{username}@{Config.LDAP_DOMAIN}" - - conn = Connection( + admin_conn = Connection( server, - user=user_dn, - password=password, - authentication=SIMPLE, - auto_bind=True + user=current_app.config["LDAP_BIND_DN"], + password=current_app.config["LDAP_BIND_PASSWORD"], + auto_bind=True, ) + except LDAPException as e: + current_app.logger.error(f"LDAP service account bind failed: {e}") + return None - # ----------------------------------- - # Search User - # ----------------------------------- - search_filter = f"(sAMAccountName={username})" - - conn.search( - search_base=Config.LDAP_SEARCH_BASE, + # --- Step 2: find the user's real DN + profile attributes --- + try: + search_filter = current_app.config["LDAP_SEARCH_FILTER"].format(username=username) + admin_conn.search( + search_base=current_app.config["LDAP_BASE_DN"], search_filter=search_filter, search_scope=SUBTREE, - attributes=[ - "displayName", - "mail", - "givenName", - "sn", - "cn" - ] + attributes=["cn", "mail", "uid"], ) + except LDAPException as e: + current_app.logger.error(f"LDAP search failed for '{username}': {e}") + admin_conn.unbind() + return None - display_name = username - email = "" + if not admin_conn.entries: + current_app.logger.warning(f"LDAP user not found: {username}") + admin_conn.unbind() + return None - if conn.entries: + entry = admin_conn.entries[0] + user_dn = entry.entry_dn + name = str(entry.cn) if "cn" in entry and entry.cn.value else username + email = ( + str(entry.mail) + if "mail" in entry and entry.mail.value + else f"{username}@{current_app.config['LDAP_DOMAIN']}" + ) + admin_conn.unbind() - entry = conn.entries[0] + # --- Step 3: the actual auth check - bind AS the user with their password --- + try: + user_conn = Connection(server, user=user_dn, password=password, auto_bind=True) + user_conn.unbind() + except LDAPException as e: + current_app.logger.warning(f"LDAP authentication failed for '{username}': {e}") + return None - if "displayName" in entry: - display_name = str(entry.displayName) - - if "mail" in entry: - email = str(entry.mail) - - conn.unbind() - - current_app.logger.info( - f"LDAP Login Success : {username}" - ) - - return { - "success": True, - "user": { - "username": username, - "name": display_name, - "email": email - } - } - - except Exception as ex: - - current_app.logger.warning( - f"LDAP Login Failed : {username} : {str(ex)}" - ) - - return { - "success": False, - "message": "Invalid Username or Password." - } \ No newline at end of file + return {"username": username, "name": name, "email": email} diff --git a/app/services/user_service.py b/app/services/user_service.py index eca3dba..cefb9d3 100644 --- a/app/services/user_service.py +++ b/app/services/user_service.py @@ -1,6 +1,7 @@ +from flask import current_app from app.models.user_model import User from app.services.db_service import db -from flask import current_app +from app.services.ldap_service import LDAPService class UserService: @@ -9,21 +10,57 @@ class UserService: if User.query.filter_by(email=email).first(): return None - user = User(name=name, email=email) + user = User(name=name, email=email, auth_source="local") user.set_password(password) db.session.add(user) db.session.commit() - current_app.logger.info("User list viewed") return user @staticmethod - def validate_login(email, password): - user = User.query.filter_by(email=email).first() + def validate_login(identifier, password): + """ + identifier = whatever was typed in the login form. Can be an email + (local users) or an LDAP username, depending on USE_LDAP_AUTH. + """ + if current_app.config.get("USE_LDAP_AUTH"): + ldap_user = UserService._validate_ldap_login(identifier, password) + if ldap_user: + return ldap_user + return None + user = User.query.filter_by(email=identifier).first() if user and user.check_password(password): return user return None + @staticmethod + def _validate_ldap_login(username, password): + ldap_info = LDAPService.authenticate(username, password) + if not ldap_info: + return None + return UserService._get_or_create_ldap_user(ldap_info) + + @staticmethod + def _get_or_create_ldap_user(ldap_info): + """ + LDAP is the source of truth for the password. We still keep a row in + our local `users` table (no password) so the rest of the app - which + expects a User with an id - keeps working unchanged. + """ + user = User.query.filter_by(email=ldap_info["email"]).first() + if user is None: + user = User( + name=ldap_info["name"], + email=ldap_info["email"], + auth_source="ldap", + ) + db.session.add(user) + db.session.commit() + elif user.name != ldap_info["name"]: + user.name = ldap_info["name"] + db.session.commit() + return user + @staticmethod def get_all_users(): return User.query.all() diff --git a/docker-compose.yml b/docker-compose.yml index a3dace9..4424709 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,9 +17,11 @@ services: build: . container_name: comparison_app restart: always + env_file: + - .env environment: - FLASK_ENV: development - FLASK_DEBUG: "True" + FLASK_ENV: production + FLASK_DEBUG: "False" FLASK_HOST: "0.0.0.0" FLASK_PORT: "5001" diff --git a/requirements.txt b/requirements.txt index af38591..1b27226 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ Flask +ldap3 pandas openpyxl xlrd @@ -8,5 +9,4 @@ cryptography xlsxwriter matplotlib flask_sqlalchemy -flask_migrate -weasyprint \ No newline at end of file +flask_migrate \ No newline at end of file diff --git a/run.py b/run.py index bdd7cf6..9f042fa 100644 --- a/run.py +++ b/run.py @@ -1,17 +1,13 @@ from dotenv import load_dotenv load_dotenv() from app import create_app -from app.services.db_service import db import os app = create_app() if __name__ == "__main__": - with app.app_context(): - db.create_all() - app.run( host=os.getenv("FLASK_HOST"), port=int(os.getenv("FLASK_PORT")), debug=os.getenv("FLASK_DEBUG") == "True" - ) + ) \ No newline at end of file