~cedric/newspipe

1

[PATCH] ldap-auth

Details
Message ID
<CABSMGann0K3zj2v1OC2L5xkM1NhJGfBV4efOSQtmee1f44g3Dw@mail.gmail.com>
DKIM signature
missing
Download raw message
---
 README.md                                     |   1 +
 instance/config.py                            |  16 +++
 instance/sqlite.py                            |  16 +++
 ...604bed382_add_string_user_external_auth.py |  24 ++++
 newspipe/controllers/__init__.py              |   2 +-
 newspipe/controllers/user.py                  | 136 ++++++++++++++++++
 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                         |  68 +++++++--
 newspipe/web/views/admin.py                   |   4 +-
 newspipe/web/views/user.py                    |   6 +-
 newspipe/web/views/views.py                   |   4 +-
 14 files changed, 271 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..4eb7ea82 100644
--- a/instance/config.py
+++ b/instance/config.py
@@ -71,3 +71,19 @@ 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..abc7cdb9 100644
--- a/instance/sqlite.py
+++ b/instance/sqlite.py
@@ -64,3 +64,19 @@ 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..ee284a1d
--- /dev/null
+++ b/migrations/versions/2a5604bed382_add_string_user_external_auth.py
@@ -0,0 +1,24 @@
+"""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..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/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 %} <span style="color: red;">{{ error }}<br
/></span>{% endfor %}

       {{ form.password.label }}
-      {{ form.password(class_="form-control") }} {% for error in
form.password.errors %} <span style="color: red;">{{ error }}<br
/></span>{% endfor %}
+      {% if pw_disabled %}{{
form.password(class_="form-control",disabled=True) }}{% else %}{{
form.password(class_="form-control") }}{% endif %} {% for error in
form.password.errors %} <span style="color: red;">{{ error }}<br
/></span>{% endfor %}

       {{ form.automatic_crawling.label }}
       {{ form.automatic_crawling(class_="form-check-input") }} {% for
error in form.automatic_crawling.errors %} <span style="color:
red;">{{ error }}<br /></span>{% 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 @@
             <th>{{ _('Nickname') }}</th>
             <th>{{ _('Member since') }}</th>
             <th>{{ _('Last seen') }}</th>
+            <th>{{ _('External auth') }}</th>
             <th>{{ _('Actions') }}</th>
         </tr>
     </thead>
@@ -26,6 +27,7 @@
             </td>
             <td class="date">{{ user.date_created | datetime }}</td>
             <td class="date">{{ user.last_seen | datetime }}</td>
+            <td class="date">{{ user.external_auth | safe }}</td>
             <td>
                 <a href="{{ url_for("admin.user_form",
user_id=user.id) }}"><i class="fa fa-pencil-square-o"
aria-hidden="true" title="{{ _('Edit this user') }}"></i></a>
                 {% if user.id != current_user.id %}
diff --git a/newspipe/templates/profile.html b/newspipe/templates/profile.html
index 6cb59ed5..f5ae6992 100644
--- a/newspipe/templates/profile.html
+++ b/newspipe/templates/profile.html
@@ -21,13 +21,13 @@

                     <div class="col">
                         {{ form.nickname.label }}
-                        {{ form.nickname(class_="form-control") }} {%
for error in form.nickname.errors %} <span style="color: red;">{{
error }}<br /></span>{% endfor %}
+                        {% if nick_disabled %}{{
form.nickname(class_="form-control", disabled=True) }}{% else %}{{
form.nickname(class_="form-control") }}{% endif %} {% for error in
form.nickname.errors %} <span style="color: red;">{{ error }}<br
/></span>{% endfor %}

-                        {{ form.password.label }}
+                        {% if not user.external_auth %}{{
form.password.label }}
                         {{ form.password(class_="form-control") }} {%
for error in form.password.errors %} <span style="color: red;">{{
error }}<br /></span>{% endfor %}

                         {{ form.password_conf.label }}
-                        {{ form.password_conf(class_="form-control")
}} {% for error in form.password_conf.errors %} <span style="color:
red;">{{ error }}<br /></span>{% endfor %}
+                        {{ form.password_conf(class_="form-control")
}} {% for error in form.password_conf.errors %} <span style="color:
red;">{{ error }}<br /></span>{% endfor %}{% else%}{% for error in
form.password.errors %} <span style="color: red;">{{ error }}<br
/></span>{% endfor %}No password management for auth type {{
user.external_auth }}{% endif %}
                     </div>

                     <div class="col">
diff --git a/newspipe/web/forms.py b/newspipe/web/forms.py
index 1240e4ab..0f6200f8 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,66 @@ 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 +239,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 +265,7 @@ 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..6f412901 100644
--- a/newspipe/web/views/admin.py
+++ b/newspipe/web/views/admin.py
@@ -46,10 +46,12 @@ def user_form(user_id=None):
         user = UserController().get(id=user_id)
         form = UserForm(obj=user)
         message = gettext("Edit the user <i>%(nick)s</i>", 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..7bb6e6b1 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,7 @@ 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"))
-- 
2.40.1
Details
Message ID
<12234004.O9o76ZdvQC@numero5>
In-Reply-To
<CABSMGann0K3zj2v1OC2L5xkM1NhJGfBV4efOSQtmee1f44g3Dw@mail.gmail.com> (view parent)
DKIM signature
missing
Download raw message
Hello,

Thank you very much for the patch, it's very nice.

I applied it earlier today and I was not able to reply to you. I'll do quick 
tests.

Thank you very much !


-- 
            Cedric

On mardi 27 juin 2023 01:48:21 CEST B Stack wrote:
> ---
>  README.md                                     |   1 +
>  instance/config.py                            |  16 +++
>  instance/sqlite.py                            |  16 +++
>  ...604bed382_add_string_user_external_auth.py |  24 ++++
>  newspipe/controllers/__init__.py              |   2 +-
>  newspipe/controllers/user.py                  | 136 ++++++++++++++++++
>  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                         |  68 +++++++--
>  newspipe/web/views/admin.py                   |   4 +-
>  newspipe/web/views/user.py                    |   6 +-
>  newspipe/web/views/views.py                   |   4 +-
>  14 files changed, 271 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..4eb7ea82 100644
> --- a/instance/config.py
> +++ b/instance/config.py
> @@ -71,3 +71,19 @@ 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=co
> m)" diff --git a/instance/sqlite.py b/instance/sqlite.py
> index 9d171b89..abc7cdb9 100644
> --- a/instance/sqlite.py
> +++ b/instance/sqlite.py
> @@ -64,3 +64,19 @@ 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=co
> m)" 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..ee284a1d
> --- /dev/null
> +++ b/migrations/versions/2a5604bed382_add_string_user_external_auth.py
> @@ -0,0 +1,24 @@
> +"""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..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/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 %} <span style="color: red;">{{ error }}<br
> /></span>{% endfor %}
> 
>        {{ form.password.label }}
> -      {{ form.password(class_="form-control") }} {% for error in
> form.password.errors %} <span style="color: red;">{{ error }}<br
> /></span>{% endfor %}
> +      {% if pw_disabled %}{{
> form.password(class_="form-control",disabled=True) }}{% else %}{{
> form.password(class_="form-control") }}{% endif %} {% for error in
> form.password.errors %} <span style="color: red;">{{ error }}<br
> /></span>{% endfor %}
> 
>        {{ form.automatic_crawling.label }}
>        {{ form.automatic_crawling(class_="form-check-input") }} {% for
> error in form.automatic_crawling.errors %} <span style="color:
> red;">{{ error }}<br /></span>{% 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 @@
>              <th>{{ _('Nickname') }}</th>
>              <th>{{ _('Member since') }}</th>
>              <th>{{ _('Last seen') }}</th>
> +            <th>{{ _('External auth') }}</th>
>              <th>{{ _('Actions') }}</th>
>          </tr>
>      </thead>
> @@ -26,6 +27,7 @@
>              </td>
>              <td class="date">{{ user.date_created | datetime }}</td>
>              <td class="date">{{ user.last_seen | datetime }}</td>
> +            <td class="date">{{ user.external_auth | safe }}</td>
>              <td>
>                  <a href="{{ url_for("admin.user_form",
> user_id=user.id) }}"><i class="fa fa-pencil-square-o"
> aria-hidden="true" title="{{ _('Edit this user') }}"></i></a>
>                  {% if user.id != current_user.id %}
> diff --git a/newspipe/templates/profile.html
> b/newspipe/templates/profile.html index 6cb59ed5..f5ae6992 100644
> --- a/newspipe/templates/profile.html
> +++ b/newspipe/templates/profile.html
> @@ -21,13 +21,13 @@
> 
>                      <div class="col">
>                          {{ form.nickname.label }}
> -                        {{ form.nickname(class_="form-control") }} {%
> for error in form.nickname.errors %} <span style="color: red;">{{
> error }}<br /></span>{% endfor %}
> +                        {% if nick_disabled %}{{
> form.nickname(class_="form-control", disabled=True) }}{% else %}{{
> form.nickname(class_="form-control") }}{% endif %} {% for error in
> form.nickname.errors %} <span style="color: red;">{{ error }}<br
> /></span>{% endfor %}
> 
> -                        {{ form.password.label }}
> +                        {% if not user.external_auth %}{{
> form.password.label }}
>                          {{ form.password(class_="form-control") }} {%
> for error in form.password.errors %} <span style="color: red;">{{
> error }}<br /></span>{% endfor %}
> 
>                          {{ form.password_conf.label }}
> -                        {{ form.password_conf(class_="form-control")
> }} {% for error in form.password_conf.errors %} <span style="color:
> red;">{{ error }}<br /></span>{% endfor %}
> +                        {{ form.password_conf(class_="form-control")
> }} {% for error in form.password_conf.errors %} <span style="color:
> red;">{{ error }}<br /></span>{% endfor %}{% else%}{% for error in
> form.password.errors %} <span style="color: red;">{{ error }}<br
> /></span>{% endfor %}No password management for auth type {{
> user.external_auth }}{% endif %}
>                      </div>
> 
>                      <div class="col">
> diff --git a/newspipe/web/forms.py b/newspipe/web/forms.py
> index 1240e4ab..0f6200f8 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,66 @@ 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 +239,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 +265,7 @@ 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..6f412901 100644
> --- a/newspipe/web/views/admin.py
> +++ b/newspipe/web/views/admin.py
> @@ -46,10 +46,12 @@ def user_form(user_id=None):
>          user = UserController().get(id=user_id)
>          form = UserForm(obj=user)
>          message = gettext("Edit the user <i>%(nick)s</i>",
> 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..7bb6e6b1 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,7 @@ 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"))
Reply to thread Export thread (mbox)