aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorB. Stack <bgstack15@gmail.com>2023-06-27 09:21:24 +0200
committerCédric Bonhomme <cedric@cedricbonhomme.org>2023-06-27 09:21:24 +0200
commitdbb1d2bce8f00a3b9e0d1074841fe835349740a7 (patch)
treeea425b1c164a3901a047f6a7ffddbc48378f7e27
parentAddresses some flake8 warnings. (diff)
downloadnewspipe-dbb1d2bce8f00a3b9e0d1074841fe835349740a7.tar.gz
newspipe-dbb1d2bce8f00a3b9e0d1074841fe835349740a7.tar.bz2
newspipe-dbb1d2bce8f00a3b9e0d1074841fe835349740a7.zip
[PATCH] ldap-auth
-rw-r--r--README.md1
-rw-r--r--instance/config.py18
-rw-r--r--instance/sqlite.py18
-rw-r--r--migrations/versions/2a5604bed382_add_string_user_external_auth.py23
-rw-r--r--newspipe/controllers/__init__.py2
-rw-r--r--newspipe/controllers/user.py143
-rw-r--r--newspipe/models/user.py1
-rw-r--r--newspipe/templates/admin/create_user.html2
-rw-r--r--newspipe/templates/admin/dashboard.html2
-rw-r--r--newspipe/templates/profile.html6
-rw-r--r--newspipe/web/forms.py81
-rw-r--r--newspipe/web/views/admin.py9
-rw-r--r--newspipe/web/views/user.py8
-rw-r--r--newspipe/web/views/views.py4
14 files changed, 301 insertions, 17 deletions
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 %} <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..12e0682a 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..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 <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..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"))
bgstack15