---
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
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"))