From dbb1d2bce8f00a3b9e0d1074841fe835349740a7 Mon Sep 17 00:00:00 2001 From: "B. Stack" Date: Tue, 27 Jun 2023 09:21:24 +0200 Subject: [PATCH] ldap-auth --- README.md | 1 + instance/config.py | 18 +++ instance/sqlite.py | 18 +++ .../2a5604bed382_add_string_user_external_auth.py | 23 ++++ newspipe/controllers/__init__.py | 2 +- newspipe/controllers/user.py | 143 +++++++++++++++++++++ newspipe/models/user.py | 1 + newspipe/templates/admin/create_user.html | 2 +- newspipe/templates/admin/dashboard.html | 2 + newspipe/templates/profile.html | 6 +- newspipe/web/forms.py | 81 ++++++++++-- newspipe/web/views/admin.py | 9 +- newspipe/web/views/user.py | 8 +- newspipe/web/views/views.py | 4 +- 14 files changed, 301 insertions(+), 17 deletions(-) create mode 100644 migrations/versions/2a5604bed382_add_string_user_external_auth.py diff --git a/README.md b/README.md index d3883ef4..2813f69d 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ https://www.newspipe.org * detection of inactive feeds; * share articles on Pinboard, Reddit and Twitter; * management of bookmarks (with import from Pinboard). +* Optional ldap authentication ## Deployment diff --git a/instance/config.py b/instance/config.py index eae58a53..e3362694 100644 --- a/instance/config.py +++ b/instance/config.py @@ -71,3 +71,21 @@ ADMIN_EMAIL = "admin@admin.localhost" LOG_LEVEL = "info" LOG_PATH = "./var/newspipe.log" SELF_REGISTRATION = True + +# Ldap, optional +LDAP_ENABLED = False +# LDAP_URI will automatically try the _ldap._tcp lookups like for a kerberos domain but +# will fall back to this exact domain (server) name if such a TXT record is not found. +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 (e.g., undefined), 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..de6aab23 100644 --- a/instance/sqlite.py +++ b/instance/sqlite.py @@ -64,3 +64,21 @@ LOG_LEVEL = "info" LOG_PATH = "./var/newspipe.log" SELF_REGISTRATION = True SQLALCHEMY_TRACK_MODIFICATIONS = False + +# Ldap, optional +LDAP_ENABLED = False +# LDAP_URI will automatically try the _ldap._tcp lookups like for a kerberos domain but +# will fall back to this exact domain (server) name if such a TXT record is not found. +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 (e.g., undefined), 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/migrations/versions/2a5604bed382_add_string_user_external_auth.py b/migrations/versions/2a5604bed382_add_string_user_external_auth.py new file mode 100644 index 00000000..95fe3ac9 --- /dev/null +++ b/migrations/versions/2a5604bed382_add_string_user_external_auth.py @@ -0,0 +1,23 @@ +"""add_string_user_external_auth + +Revision ID: 2a5604bed382 +Revises: bdd38bd755cb +Create Date: 2023-06-17 15:30:40.434393 + +""" +# revision identifiers, used by Alembic. +revision = "2a5604bed382" +down_revision = "bdd38bd755cb" +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.add_column("user", sa.Column("external_auth", sa.String(), nullable=True)) + + +def downgrade(): + op.drop_column("user", "external_auth") 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..e259940e 100644 --- a/newspipe/controllers/user.py +++ b/newspipe/controllers/user.py @@ -1,4 +1,5 @@ import logging +from urllib.parse import urlparse from werkzeug.security import check_password_hash from werkzeug.security import generate_password_hash @@ -8,6 +9,11 @@ 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,140 @@ class UserController(AbstractController): def update(self, filters, attrs): self._handle_password(attrs) return super().update(filters, attrs) + + +class LdapuserController: + 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/models/user.py b/newspipe/models/user.py index b095fdf1..72c35afc 100644 --- a/newspipe/models/user.py +++ b/newspipe/models/user.py @@ -46,6 +46,7 @@ class User(db.Model, UserMixin, RightMixin): id = db.Column(db.Integer, primary_key=True) nickname = db.Column(db.String(), unique=True) pwdhash = db.Column(db.String()) + external_auth = db.Column(db.String(), default="", nullable=True) automatic_crawling = db.Column(db.Boolean(), default=True) diff --git a/newspipe/templates/admin/create_user.html b/newspipe/templates/admin/create_user.html index 2cfe4518..550cfd1f 100644 --- a/newspipe/templates/admin/create_user.html +++ b/newspipe/templates/admin/create_user.html @@ -9,7 +9,7 @@ {{ form.nickname(class_="form-control") }} {% for error in form.nickname.errors %} {{ error }}
{% endfor %} {{ form.password.label }} - {{ form.password(class_="form-control") }} {% for error in form.password.errors %} {{ error }}
{% endfor %} + {% if pw_disabled %}{{ form.password(class_="form-control",disabled=True) }}{% else %}{{ form.password(class_="form-control") }}{% endif %} {% for error in form.password.errors %} {{ error }}
{% endfor %} {{ form.automatic_crawling.label }} {{ form.automatic_crawling(class_="form-check-input") }} {% for error in form.automatic_crawling.errors %} {{ error }}
{% endfor %} diff --git a/newspipe/templates/admin/dashboard.html b/newspipe/templates/admin/dashboard.html index db56be25..370ab702 100644 --- a/newspipe/templates/admin/dashboard.html +++ b/newspipe/templates/admin/dashboard.html @@ -9,6 +9,7 @@ {{ _('Nickname') }} {{ _('Member since') }} {{ _('Last seen') }} + {{ _('External auth') }} {{ _('Actions') }} @@ -26,6 +27,7 @@ {{ user.date_created | datetime }} {{ user.last_seen | datetime }} + {{ user.external_auth | safe }} {% if user.id != current_user.id %} diff --git a/newspipe/templates/profile.html b/newspipe/templates/profile.html index 6cb59ed5..12e0682a 100644 --- a/newspipe/templates/profile.html +++ b/newspipe/templates/profile.html @@ -21,13 +21,13 @@
{{ form.nickname.label }} - {{ form.nickname(class_="form-control") }} {% for error in form.nickname.errors %} {{ error }}
{% endfor %} + {% if nick_disabled %}{{ form.nickname(class_="form-control", disabled=True) }}{% else %}{{ form.nickname(class_="form-control") }}{% endif %} {% for error in form.nickname.errors %} {{ error }}
{% endfor %} - {{ form.password.label }} + {% if not user.external_auth %}{{ form.password.label }} {{ form.password(class_="form-control") }} {% for error in form.password.errors %} {{ error }}
{% endfor %} {{ form.password_conf.label }} - {{ form.password_conf(class_="form-control") }} {% for error in form.password_conf.errors %} {{ error }}
{% endfor %} + {{ form.password_conf(class_="form-control") }} {% for error in form.password_conf.errors %} {{ error }}
{% endfor %}{% else%}{% for error in form.password.errors %} {{ error }}
{% endfor %}No password management for auth type {{ user.external_auth }}{% endif %}
diff --git a/newspipe/web/forms.py b/newspipe/web/forms.py index 1240e4ab..dba2e1b8 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,13 @@ 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 +143,76 @@ 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, + external_auth="ldap", + ) + 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 {user.external_auth} unavailable. Contact the admin." + ) + validated = False self.user = user return validated @@ -188,7 +250,8 @@ class ProfileForm(FlaskForm): nickname = TextField( lazy_gettext("Nickname"), - [validators.Required(lazy_gettext("Please enter your nickname."))], + # [validators.Required(lazy_gettext("Please enter your nickname."))], + [validators.Optional()], ) password = PasswordField(lazy_gettext("Password")) password_conf = PasswordField(lazy_gettext("Password")) @@ -213,7 +276,9 @@ class ProfileForm(FlaskForm): ) self.password.errors.append(message) validated = False - if self.nickname.data != User.make_valid_nickname(self.nickname.data): + if self.nickname.data and ( + self.nickname.data != User.make_valid_nickname(self.nickname.data) + ): self.nickname.errors.append( lazy_gettext( "This nickname has " diff --git a/newspipe/web/views/admin.py b/newspipe/web/views/admin.py index b35a3f96..41169754 100644 --- a/newspipe/web/views/admin.py +++ b/newspipe/web/views/admin.py @@ -46,10 +46,17 @@ def user_form(user_id=None): user = UserController().get(id=user_id) form = UserForm(obj=user) message = gettext("Edit the user %(nick)s", nick=user.nickname) + if user.external_auth: + message += f" (external auth type: {user.external_auth})" else: form = UserForm() message = gettext("Add a new user") - return render_template("/admin/create_user.html", form=form, message=message) + return render_template( + "/admin/create_user.html", + form=form, + message=message, + pw_disabled=bool(user.external_auth), + ) @admin_bp.route("/user/create", methods=["POST"]) diff --git a/newspipe/web/views/user.py b/newspipe/web/views/user.py index b8d01967..1945be89 100644 --- a/newspipe/web/views/user.py +++ b/newspipe/web/views/user.py @@ -9,6 +9,7 @@ from flask_login import current_user from flask_login import login_required from flask_paginate import get_page_args from flask_paginate import Pagination +from werkzeug.exceptions import BadRequest from newspipe.bootstrap import application from newspipe.controllers import ArticleController @@ -165,6 +166,9 @@ def profile(): if request.method == "POST": if form.validate(): try: + # for external user, just force the exact same username. + if user.external_auth or not form.nickname.data: + form.nickname.data = user.nickname user_contr.update( {"id": current_user.id}, { @@ -195,7 +199,9 @@ def profile(): if request.method == "GET": form = ProfileForm(obj=user) - return render_template("profile.html", user=user, form=form) + return render_template( + "profile.html", user=user, form=form, nick_disabled=bool(user.external_auth) + ) @user_bp.route("/delete_account", methods=["GET"]) diff --git a/newspipe/web/views/views.py b/newspipe/web/views/views.py index 7ff2a2e4..bb7bff2f 100644 --- a/newspipe/web/views/views.py +++ b/newspipe/web/views/views.py @@ -25,7 +25,7 @@ logger = logging.getLogger(__name__) @current_app.errorhandler(401) def authentication_required(error): - if application.conf["API_ROOT"] in request.url: + if application.config["API_ROOT"] in request.url: return error flash(gettext("Authentication required."), "info") return redirect(url_for("login")) @@ -33,7 +33,7 @@ def authentication_required(error): @current_app.errorhandler(403) def authentication_failed(error): - if application.conf["API_ROOT"] in request.url: + if application.config["API_ROOT"] in request.url: return error flash(gettext("Forbidden."), "danger") return redirect(url_for("login")) -- cgit