aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorB. Stack <bgstack15@gmail.com>2023-06-16 19:51:55 -0400
committerB. Stack <bgstack15@gmail.com>2023-06-24 08:22:59 -0400
commit1b201c3b10db7182277d3e7c63e780080a51b27a (patch)
treedf705dde15f57a4b46d3b25b2f423f9728ded356
parentAddresses some flake8 warnings. (diff)
downloadnewspipe-1b201c3b10db7182277d3e7c63e780080a51b27a.tar.gz
newspipe-1b201c3b10db7182277d3e7c63e780080a51b27a.tar.bz2
newspipe-1b201c3b10db7182277d3e7c63e780080a51b27a.zip
WIP: initial ldap support
Still need schema support for attribute user.external_auth, probably of type bool.
-rw-r--r--instance/config.py14
-rw-r--r--instance/sqlite.py14
-rw-r--r--newspipe/controllers/__init__.py2
-rw-r--r--newspipe/controllers/user.py136
-rw-r--r--newspipe/web/forms.py62
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
bgstack15