From 1b201c3b10db7182277d3e7c63e780080a51b27a Mon Sep 17 00:00:00 2001 From: "B. Stack" Date: Fri, 16 Jun 2023 19:51:55 -0400 Subject: WIP: initial ldap support Still need schema support for attribute user.external_auth, probably of type bool. --- instance/config.py | 14 ++++ instance/sqlite.py | 14 ++++ newspipe/controllers/__init__.py | 2 +- newspipe/controllers/user.py | 136 +++++++++++++++++++++++++++++++++++++++ newspipe/web/forms.py | 62 ++++++++++++++++-- 5 files changed, 221 insertions(+), 7 deletions(-) diff --git a/instance/config.py b/instance/config.py index eae58a53..af5fe9b9 100644 --- a/instance/config.py +++ b/instance/config.py @@ -71,3 +71,17 @@ ADMIN_EMAIL = "admin@admin.localhost" LOG_LEVEL = "info" LOG_PATH = "./var/newspipe.log" SELF_REGISTRATION = True + +# Ldap, optional +LDAP_ENABLED = True +LDAP_URI = "ldaps://ipa.internal.com:636" +LDAP_USER_BASE = "cn=users,cn=accounts,dc=ipa,dc=internal,dc=com" +LDAP_GROUP_BASE = "cn=groups,cn=accounts,dc=ipa,dc=internal,dc=com" +LDAP_USER_MATCH_ATTRIB = "uid" +LDAP_USER_DISPLAY_ATTRIB = "uid" +LDAP_USER_ATTRIB_MEMBEROF = "memberof" +LDAP_GROUP_DISPLAY_ATTRIB = "cn" +LDAP_BIND_DN = "uid=sampleuser,cn=users,cn=accounts,dc=ipa,dc=internal,dc=com" +LDAP_BIND_PASSWORD = "examplepassword" +# Additional filter to restrict user lookup. If not equivalent to False, will be logical-anded to the user-match-attribute search filter. +LDAP_FILTER = "(memberOf=cn=newspipe-users,cn=groups,cn=accounts,dc=ipa,dc=internal,dc=com)" diff --git a/instance/sqlite.py b/instance/sqlite.py index 9d171b89..abde387a 100644 --- a/instance/sqlite.py +++ b/instance/sqlite.py @@ -64,3 +64,17 @@ LOG_LEVEL = "info" LOG_PATH = "./var/newspipe.log" SELF_REGISTRATION = True SQLALCHEMY_TRACK_MODIFICATIONS = False + +# Ldap, optional +LDAP_ENABLED = True +LDAP_URI = "ldaps://ipa.internal.com:636" +LDAP_USER_BASE = "cn=users,cn=accounts,dc=ipa,dc=internal,dc=com" +LDAP_GROUP_BASE = "cn=groups,cn=accounts,dc=ipa,dc=internal,dc=com" +LDAP_USER_MATCH_ATTRIB = "uid" +LDAP_USER_DISPLAY_ATTRIB = "uid" +LDAP_USER_ATTRIB_MEMBEROF = "memberof" +LDAP_GROUP_DISPLAY_ATTRIB = "cn" +LDAP_BIND_DN = "uid=sampleuser,cn=users,cn=accounts,dc=ipa,dc=internal,dc=com" +LDAP_BIND_PASSWORD = "examplepassword" +# Additional filter to restrict user lookup. If not equivalent to False, will be logical-anded to the user-match-attribute search filter. +LDAP_FILTER = "(memberOf=cn=newspipe-users,cn=groups,cn=accounts,dc=ipa,dc=internal,dc=com)" diff --git a/newspipe/controllers/__init__.py b/newspipe/controllers/__init__.py index 6769aa32..449d93e9 100644 --- a/newspipe/controllers/__init__.py +++ b/newspipe/controllers/__init__.py @@ -1,7 +1,7 @@ from .feed import FeedController from .category import CategoryController # noreorder from .article import ArticleController -from .user import UserController +from .user import UserController, LdapuserController from .icon import IconController from .bookmark import BookmarkController from .tag import BookmarkTagController diff --git a/newspipe/controllers/user.py b/newspipe/controllers/user.py index 64dac06c..37af3215 100644 --- a/newspipe/controllers/user.py +++ b/newspipe/controllers/user.py @@ -2,12 +2,18 @@ import logging from werkzeug.security import check_password_hash from werkzeug.security import generate_password_hash +from urllib.parse import urlparse from .abstract import AbstractController from newspipe.models import User logger = logging.getLogger(__name__) +# FOR LDAP +# Reference: session_app +import ldap3 +from ldap3.core.exceptions import LDAPBindError, LDAPPasswordIsMandatoryError + class UserController(AbstractController): _db_cls = User @@ -29,3 +35,133 @@ class UserController(AbstractController): def update(self, filters, attrs): self._handle_password(attrs) return super().update(filters, attrs) + +class LdapuserController(object): + def check_password(self, user, password, config): + this_uri = self.get_next_ldap_server(config) + #return this_uri + this_user = self.list_matching_users( + server_uri=this_uri, + bind_dn=config['LDAP_BIND_DN'], + bind_pw=config['LDAP_BIND_PASSWORD'], + user_base=config['LDAP_USER_BASE'], + username=user, + user_match_attrib=config['LDAP_USER_MATCH_ATTRIB'], + _filter=config['LDAP_FILTER'] if "LDAP_FILTER" in config else "", + ) + # list_matching_users always returns list, so if it contains <> 1 we are in trouble + if len(this_user) != 1: + print(f"WARNING: cannot determine unique user for {config['LDAP_USER_MATCH_ATTRIB']}={user} which returned {this_user}") + return False + # logger does not work here+flask for some reason. Very sad! + # now we have exactly one user, this_user[0] + this_user = this_user[0] + + ldapuser = self.authenticated_user( + server_uri=this_uri, + user_dn=this_user, + password=password + ) + if ldapuser: + return ldapuser + #return str(config) + #return this_user + return False + + def get_next_ldap_server(self, config): +# on first ldap_login attempt, cache this lookup result: + if 'LDAP_HOSTS' not in config: + this_domain = urlparse(config['LDAP_URI']).hostname + config['LDAP_HOSTS'] = self.list_ldap_servers_for_domain(this_domain) + else: + # rotate them! So every ldap_login attempt will use the next ldap server in the list. + this_list = config['LDAP_HOSTS'] + a = this_list[0] + this_list.append(a) + this_list.pop(0) + config['LDAP_HOSTS'] = this_list + # construct a new, full uri. + this_netloc = config['LDAP_HOSTS'][0] + up = urlparse(config['LDAP_URI']) + if up.port: + this_netloc += f":{up.port}" + this_uri = up._replace(netloc=this_netloc).geturl() + return this_uri + + def list_matching_users(self, server_uri= "", bind_dn = "", bind_pw = "", connection = None, user_base = "", username = "", user_match_attrib = "", _filter = ""): + search_filter=f"({user_match_attrib}={username})" + if _filter: + search_filter = f"(&{search_filter}{_filter})" + if connection and isinstance(connection, ldap3.core.connection.Connection): + conn = connection + else: + conn = self.get_ldap_connection(server_uri, bind_dn, bind_pw) + conn.search( + search_base=user_base, + search_filter=search_filter, + search_scope="SUBTREE" + ) + print(f"DEBUG: search_base {user_base}") + print(f"DEBUG: search_filter {search_filter}") + result = [] + for i in conn.entries: + result.append(i.entry_dn) + print(f"DEBUG: result {result}") + return result + + def get_ldap_connection(self, server_uri, bind_dn, bind_pw): + server = ldap3.Server(server_uri) + conn = ldap3.Connection(server, auto_bind=True,user=bind_dn, password=bind_pw) + return conn + + def list_ldap_servers_for_domain(self, domain): + # return list of hostnames from the _ldap._tcp.{domain} SRV lookup + try: + import dns + import dns.resolver + except: + print("Need python3-dns or dnspython installed for dns lookups.") + return [domain] + namelist = [] + try: + query = dns.resolver.query(f"_ldap._tcp.{domain}","SRV") + except dns.resolver.NXDOMAIN: + # no records exist that match the request, so we were probably given a specific hostname, and an empty query will trigger the logic below that will add the original domain to the list. + query = [] + for i in query: + namelist.append(i.target.to_text().rstrip(".")) + if not len(namelist): + namelist.append(domain) + return namelist + + def ldap_login(self,username,password): + #print(f"DEBUG: Trying user {username} with pw '{password}'") + this_uri = self.get_next_ldap_server(app) + # Perform the ldap interactions + user = self.authenticated_user( + server_uri=this_uri, + user_dn=username, + password=password + ) + if user: + return user + else: + return False + return False + + def authenticated_user(self, server_uri, user_dn, password): + print(f"server_uri: {server_uri}") + print(f"user_dn: {user_dn}") + try: + conn = self.get_ldap_connection(server_uri, user_dn, password) + return conn + except LDAPBindError as e: + if 'invalidCredentials' in str(e): + print("Invalid credentials.") + return False + else: + raise e + #except (LDAPPasswordIsMandatoryError, LDAPBindError): + # print("Either an ldap password is required, or we had another bind error.") + # return False + return False diff --git a/newspipe/web/forms.py b/newspipe/web/forms.py index 1240e4ab..4cd552e0 100644 --- a/newspipe/web/forms.py +++ b/newspipe/web/forms.py @@ -1,4 +1,5 @@ #! /usr/bin/env python +# vim: set ts=4 sts=4 sw=4 et: # Newspipe - A web news aggregator. # Copyright (C) 2010-2023 Cédric Bonhomme - https://www.cedricbonhomme.org # @@ -24,6 +25,7 @@ __revision__ = "$Date: 2015/05/06 $" __copyright__ = "Copyright (c) Cedric Bonhomme" __license__ = "GPLv3" +import logging from flask import redirect, url_for from flask_babel import lazy_gettext from flask_wtf import FlaskForm @@ -41,10 +43,12 @@ from wtforms import ( ) from wtforms.fields.html5 import EmailField, URLField -from newspipe.controllers import UserController +from newspipe.bootstrap import application +from newspipe.controllers import UserController, LdapuserController from newspipe.lib import misc_utils from newspipe.models import User +logger = logging.getLogger(__name__) class SignupForm(FlaskForm): """ @@ -138,19 +142,65 @@ class SigninForm(RedirectForm): def validate(self): validated = super().validate() + # try ldap before doing anything else + ldap_enabled = application.config["LDAP_ENABLED"] if "LDAP_ENABLED" in application.config else False + ldapuser = None + if ldap_enabled: + ucontrldap = LdapuserController() + try: + # this returns False if invalid username or password. + ldapuser = ucontrldap.check_password( + user = self.nickmane.data, + password = self.password.data, + config = application.config + ) + if ldapuser: + self.nickmane.errors.append(f"validated ldap user {self.nickmane.data}") + else: + #self.nickmane.errors.append(f"Invalid username or password.") + raise NotFound + except NotFound: + pass # just assume the user is trying a local account ucontr = UserController() try: user = ucontr.get(nickname=self.nickmane.data) except NotFound: - self.nickmane.errors.append("Wrong nickname") - validated = False + if ldap_enabled and ldapuser: + try: + user = ucontr.create( + nickname=self.nickmane.data, + password="", + automatic_crawling=True, + is_admin=False, + is_active=True, + ) + if user: + validated = True + self.user = user + except: + self.nickmane.errors.append(f"Unable to provision user for valid ldap user {self.nickmane.data}") + validated = False + else: + self.nickmane.errors.append("Wrong nickname") + validated = False else: if not user.is_active: self.nickmane.errors.append("Account not active") validated = False - if not ucontr.check_password(user, self.password.data): - self.password.errors.append("Wrong password") - validated = False + # must short-circuit the password check for ldap users + if not ldapuser: + try: + # with an external_auth user but external auth disabled in config now, the empty password on the user in the database will fail + if not ucontr.check_password(user, self.password.data): + self.password.errors.append("Wrong password") + validated = False + except AttributeError: + if ldap_enabled: + self.password.errors.append("Wrong password") + validated = False + else: + self.password.errors.append("External auth unavailable. Contact the admin.") + validated = False self.user = user return validated -- cgit