Skip to content
Draft
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
8841350
base implementation
metallkopf Nov 24, 2024
f28ee27
add defaults
metallkopf Nov 24, 2024
3c8e496
simple tests
metallkopf Nov 29, 2024
9e53b1a
feat: added openldap to -slim image
redimp Dec 1, 2024
3c8ff78
fixup
redimp Dec 1, 2024
55ecec7
wip: added debug output
redimp Dec 1, 2024
6804d68
added example to test ldap
redimp Dec 1, 2024
b322c65
patch ldap docker
metallkopf Dec 2, 2024
67d0a82
auth_examples/ldap: fixed SECRET_KEY for easier testing
redimp Dec 5, 2024
5ec93a3
fix: user add/edit stores the provider in the database
redimp Dec 5, 2024
34d6769
auto register ldap users on login
metallkopf Dec 6, 2024
8118ed3
auto register test
metallkopf Dec 6, 2024
2f96664
chore: make sure tox installs the dev environment (to get fakeldap)
redimp Dec 8, 2024
615c511
docs: added README to ldap-auth exmaple, enabled register on login
redimp Dec 8, 2024
62e8f0d
docu: Update helm readme to use right helm repository URL
Dec 20, 2024
aaa4ab1
feat: added option to add line numbers to code blocks
redimp Jan 11, 2025
11d3b5b
chore(deps): bump jinja2 from 3.1.4 to 3.1.5
dependabot[bot] Jan 8, 2025
cb4cab4
feat: added database migration
redimp Jan 12, 2025
37fc2cd
added mtime(filename): return modification datetime of file
redimp Jan 18, 2025
4bac818
added Cache to models
redimp Jan 18, 2025
923a0b1
chore: ignore settings with wildcards
redimp Jan 18, 2025
98980cc
fix: word-break n the sidebar optimized. Do not break the ::before el…
redimp Jan 18, 2025
634cec8
test: added some tests to the wiki link compatibility mode
redimp Jan 19, 2025
f596585
chore: reworked parse_wikilink
redimp Jan 19, 2025
89448e4
chore(deps): updated mermaidjs to 11.4.1
redimp Jan 19, 2025
44f8bf3
feat: added some responsiveness to the sidebar
redimp Jan 19, 2025
ffecd35
base implementation
metallkopf Nov 24, 2024
54b475c
feat: added database migration
redimp Jan 12, 2025
9645271
fixup: merge error
redimp Feb 4, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions docker/Dockerfile.slim
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ FROM alpine:3.20.1 AS compile-stage
LABEL maintainer="Ralph Thesen <mail@redimp.de>"
# install build environment (mostly necessary to build Pillow on armv6/7)
RUN apk add python3 python3-dev py3-virtualenv \
zlib-dev jpeg-dev gcc musl-dev
zlib-dev jpeg-dev gcc musl-dev \
build-base openldap-dev
# prepare environment
RUN python3 -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
Expand All @@ -24,7 +25,7 @@ RUN --mount=type=cache,target=/root/.cache \
# copy otterwiki source and tests
COPY otterwiki /src/otterwiki
# install the otterwiki
RUN pip install .
RUN pip install .[ldap]

#
# production stage
Expand All @@ -42,7 +43,7 @@ RUN delgroup www-data && \
adduser -S -D -u 33 -s /sbin/nologin -h /app -G www-data www-data
# install python and git
RUN apk add python3 git uwsgi uwsgi-python3 \
zlib jpeg
zlib jpeg openldap sqlite
# copy virtual environment
COPY --chown=www-data:www-data --from=compile-stage /opt/venv /opt/venv
# Make sure we use the virtualenv:
Expand Down
3 changes: 3 additions & 0 deletions docs/auth_examples/ldap-auth/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
run:
docker compose build
docker compose up --remove-orphans
62 changes: 62 additions & 0 deletions docs/auth_examples/ldap-auth/compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
services:
otterwiki:
depends_on:
- ldap
image: otterwiki-test-ldap
build:
context: ../../..
dockerfile: docker/Dockerfile.slim
ports:
- "8080:8080"
environment:
- LOG_LEVEL=DEBUG
- LDAP_URI=ldap://ldap:389
- LDAP_USERNAME=cn=Manager,dc=ldap,dc=local
- LDAP_PASSWORD=secret
- LDAP_BASE=dc=ldap,dc=local
- LDAP_SCOPE=subtree
- LDAP_DOMAIN=ldap.org
# fixed SECRET_KEY for easier testing while keeping the session
- SECRET_KEY=aabbaabbaabbaabbaabbaabbaabbaabbaabbaabbaabbaabbaabb
volumes:
- app-data:/app-data
command:
- sh
- -c
- |
cat <<EOF >> /tmp/provider.sql
PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;
DROP TABLE IF EXISTS user;
CREATE TABLE user (
id INTEGER NOT NULL,
name VARCHAR(128),
email VARCHAR(128),
password_hash VARCHAR(512),
first_seen DATETIME,
last_seen DATETIME,
is_approved BOOLEAN,
is_admin BOOLEAN,
email_confirmed BOOLEAN,
allow_read BOOLEAN,
allow_write BOOLEAN,
allow_upload BOOLEAN,
provider VARCHAR(8),
PRIMARY KEY (id)
);
INSERT INTO user VALUES(2,'John','john@ldap.org',NULL,'2024-12-01 19:28:13.273738','2024-12-01 19:28:13.273750',1,1,1,1,1,1,'ldap');
INSERT INTO user VALUES(3,'Fulano','fulano@ldap.org',NULL,'2024-12-01 19:28:49.696271','2024-12-01 19:28:49.696281',1,0,0,1,1,0,'ldap');
INSERT INTO user VALUES(4,'Max','max@ldap.org',NULL,'2024-12-01 19:29:11.958025','2024-12-01 19:29:11.958039',1,0,0,1,1,1,'ldap');
COMMIT;
EOF
test -f /app-data/db.sqlite || sqlite3 -init /tmp/provider.sql /app-data/db.sqlite
/entrypoint.sh
/usr/sbin/uwsgi --ini /app/uwsgi.ini
stop_signal: SIGINT
ldap:
image: otterwiki-example-ldap
build: example-ldap
stop_signal: SIGINT

volumes:
app-data:
18 changes: 18 additions & 0 deletions docs/auth_examples/ldap-auth/example-ldap/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
FROM almalinux:9

COPY *.ldif /

RUN dnf install -y epel-release procps \
&& dnf install -y openldap-clients openldap-servers \
&& slapd -u ldap -h ldapi:/// \
&& ldapadd -Y EXTERNAL -H ldapi:/// -f /etc/openldap/schema/cosine.ldif \
&& ldapadd -Y EXTERNAL -H ldapi:/// -f /etc/openldap/schema/inetorgperson.ldif \
&& ldapadd -Y EXTERNAL -H ldapi:/// -f /etc/openldap/schema/nis.ldif \
&& ldapmodify -Y EXTERNAL -H ldapi:/// -f /config.ldif \
&& ldapadd -H ldapi:/// -D "cn=Manager,dc=ldap,dc=local" -w secret -f /directory.ldif \
&& pkill -INT slapd \
&& dnf clean all && rm -rf /var/cache/yum

EXPOSE 389/tcp

ENTRYPOINT /sbin/slapd -u ldap -h "ldap:/// ldapi:///" -d 256
14 changes: 14 additions & 0 deletions docs/auth_examples/ldap-auth/example-ldap/config.ldif
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
dn: olcDatabase={2}mdb,cn=config
changetype: modify
replace: olcSuffix
olcSuffix: dc=ldap,dc=local

dn: olcDatabase={2}mdb,cn=config
changetype: modify
replace: olcRootDN
olcRootDN: cn=Manager,dc=ldap,dc=local

dn: olcDatabase={2}mdb,cn=config
changetype: modify
replace: olcRootPW
olcRootPW: secret
49 changes: 49 additions & 0 deletions docs/auth_examples/ldap-auth/example-ldap/directory.ldif
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
dn: dc=ldap,dc=local
dc: ldap
o: ldap
objectClass: dcObject
objectClass: organization
objectClass: top

dn: ou=Main,dc=ldap,dc=local
ou: Main
objectClass: organizationalUnit
objectClass: top

dn: ou=Branch,dc=ldap,dc=local
ou: Branch
objectClass: organizationalUnit
objectClass: top

dn: cn=John Doe,ou=Main,dc=ldap,dc=local
cn: John Doe
givenName: John
sn: Doe
objectClass: inetOrgPerson
objectClass: person
objectClass: top
userPassword: 12345678
uid: john
mail: john@ldap.org

dn: cn=Fulano de Tal,ou=Main,dc=ldap,dc=local
cn: Fulano de Tal
givenName: Fulano
sn: de Tal
objectClass: inetOrgPerson
objectClass: person
objectClass: top
userPassword: password
uid: fulano
mail: fulano@ldap.org

dn: cn=Max Mustermann,ou=Branch,dc=ldap,dc=local
cn: Max Mustermann
givenName: Max
sn: Mustermann
objectClass: inetOrgPerson
objectClass: person
objectClass: top
userPassword: qwertyui
uid: max
mail: max@ldap.org
81 changes: 75 additions & 6 deletions otterwiki/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@
from datetime import datetime
import hmac

try:
import ldap
has_ldap = True
except ImportError as e:
app.logger.debug(f"Unable to import ldap: {e}")
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this try/catch block was added to allow ldap to be an optional dependency

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added the log to make sure, I have all the dependencies added to the docker image.

has_ldap = False


def check_password_hash_backport(pwhash, password):
# split pwhash to check the method
Expand All @@ -46,6 +53,48 @@ def check_password_hash_backport(pwhash, password):
return check_password_hash(pwhash, password)


def check_ldap_bind(email, password):
app.logger.debug(f"check_ldap_bind({email=})")
if not has_ldap or not app.config.get("LDAP_URI"):
return False

try:
conn = ldap.initialize(app.config["LDAP_URI"])
conn.set_option(ldap.OPT_REFERRALS, 0)
conn.protocol = int(app.config["LDAP_PROTOCOL"])

conn.simple_bind_s(app.config["LDAP_USERNAME"],
app.config["LDAP_PASSWORD"])
except Exception as e:
app.logger.error("check_ldap_bind(): Exception {}".format(e))
return False

try:
scope = app.config.get("LDAP_SCOPE", "").upper()

if scope == "BASE":
scope = ldap.SCOPE_BASE
elif scope == "ONELEVEL":
scope = ldap.SCOPE_ONELEVEL
else:
scope = ldap.SCOPE_SUBTREE

filter_ = "(&{}({}={}))".format(app.config["LDAP_FILTER"],
app.config["LDAP_ATTRIBUTE"], email)
result = conn.search_s(app.config["LDAP_BASE"], scope, filter_)

if not result:
return False

conn.simple_bind_s(result[0][0], password)
return True
except ldap.INVALID_CREDENTIALS:
return False
except Exception as e:
app.logger.error("check_ldap_bind(): Exception {}".format(e))
return False


class SimpleAuth:
class User(UserMixin, UserModel):
pass
Expand Down Expand Up @@ -102,11 +151,16 @@ def handle_logout(self):

def check_credentials(self, email, password):
user = self.User.query.filter_by(email=email).first()
if not user or not check_password_hash_backport(
if not user:
return None
elif user.provider == "local" and check_password_hash_backport(
user.password_hash, password
):
return None
return user
return user
elif user.provider == "ldap" and check_ldap_bind(email, password):
return user

return None

def handle_login(self, email=None, password=None, remember=None):
if email is not None:
Expand Down Expand Up @@ -140,7 +194,7 @@ def handle_login(self, email=None, password=None, remember=None):
if not next_page or urlsplit(next_page).netloc != "":
next_page = url_for("index")
# check if the users password_hash is going to be deprecated
if user.password_hash.startswith("sha256$"):
if user.password_hash and user.password_hash.startswith("sha256$"):
app.logger.warning(
f"User has deprecated password hash: {user.email}"
)
Expand Down Expand Up @@ -214,16 +268,23 @@ def create_user(self, email, name, password=None):
# generate random password
password = random_password()
# hash password
hashed_password = generate_password_hash(password, method="scrypt")
if email.split("@", 1)[-1] == app.config.get("LDAP_DOMAIN"):
hashed_password = ""
provider = "ldap"
else:
hashed_password = generate_password_hash(password, method="scrypt")
provider = "local"
# handle flags
# first user is admin
if len(self.User.query.all()) < 1:
is_admin = True
is_approved = True
email_confirmed = False
else:
is_admin = False
# handle auto approval
is_approved = app.config["AUTO_APPROVAL"] is True
email_confirmed = provider == "ldap"
# create user object
user = self.User( # pyright: ignore
name=name,
Expand All @@ -233,6 +294,8 @@ def create_user(self, email, name, password=None):
last_seen=datetime.now(),
is_admin=is_admin,
is_approved=is_approved,
provider=provider,
email_confirmed=email_confirmed,
)
# add to database
db.session.add(user)
Expand All @@ -241,7 +304,8 @@ def create_user(self, email, name, password=None):
app.logger.info(
"auth: New user registered: {} <{}>".format(name, email)
)
if app.config["EMAIL_NEEDS_CONFIRMATION"] and not is_admin:
if app.config["EMAIL_NEEDS_CONFIRMATION"] and not is_admin and \
provider == "local":
self.request_confirmation(email)
else:
# notify user
Expand Down Expand Up @@ -314,6 +378,9 @@ def handle_register(self, email, name, password1, password2):
toast("The passwords do not match.", "error")
elif password1 is None or len(password1) < 8:
toast("The password must be at least 8 characters long.", "error")
elif email.split("@", 1)[-1] == app.config.get("LDAP_DOMAIN") and \
not check_ldap_bind(email, password1):
toast("Invalid email address or password.", "error")
else:
# register account
self.create_user(email, name, password=password1)
Expand Down Expand Up @@ -404,6 +471,8 @@ def handle_recover_password(self, email):
toast("This email address is invalid.", "error")
elif user is None:
toast("This email address is unknown.", "error")
elif user.provider != "local":
toast("Can't change password from this provider.", "error")
else:
# recovery process
token = serialize(email, salt="lost-password-email")
Expand Down
5 changes: 3 additions & 2 deletions otterwiki/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(128))
email = db.Column(db.String(128), index=True, unique=True)
password_hash = db.Column(db.String(512))
password_hash = db.Column(db.String(512), default="")
first_seen = db.Column(TimeStamp())
last_seen = db.Column(TimeStamp())
is_approved = db.Column(db.Boolean(), default=False)
Expand All @@ -58,6 +58,7 @@ class User(db.Model):
allow_read = db.Column(db.Boolean(), default=False)
allow_write = db.Column(db.Boolean(), default=False)
allow_upload = db.Column(db.Boolean(), default=False)
provider = db.Column(db.String(8), default="local")

def __repr__(self):
permissions = ""
Expand All @@ -69,4 +70,4 @@ def __repr__(self):
permissions += "U"
if self.is_admin:
permissions += "A"
return f"<User {self.id} '{self.name} <{self.email}>' {permissions}>"
return f"<User {self.id} '{self.name} <{self.email}>' {permissions} {self.provider}>"
4 changes: 4 additions & 0 deletions otterwiki/preferences.py
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,7 @@ def handle_user_add(form):
# update user from form
user.name = form.get("name").strip() # pyright: ignore
user.email = form.get("email").strip() # pyright: ignore
user.provider = form.get("provider").strip() # pyright: ignore
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this addition was producing an error on my end

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Juste tested this on the resent commit 615c511 adding a User works as expected. Can ypu please try to reproduce the error?


for value, _ in [
("email_confirmed", "email confirmed"),
Expand Down Expand Up @@ -505,6 +506,9 @@ def handle_user_edit(uid, form):
else:
user.password_hash = generate_password_hash(form.get("password1"))
msgs.append("Updated password")
if user.provider != form.get("provider","local"):
user.provider = form.get("provider","local").strip() # pyright: ignore
msgs.append("Updated provider")
user_was_already_approved = user.is_approved
# handle all the flags
for value, label in [
Expand Down
9 changes: 9 additions & 0 deletions otterwiki/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,15 @@
SIDEBAR_SHORTCUTS="home pageindex createpage",
ROBOTS_TXT="allow",
WIKILINK_STYLE="",
LDAP_URI="",
LDAP_USERNAME="",
LDAP_PASSWORD="",
LDAP_BASE="",
LDAP_PROTOCOL=3,
LDAP_FILTER="(objectClass=person)",
LDAP_ATTRIBUTE="mail",
LDAP_SCOPE="subtree",
LDAP_DOMAIN="",
)
app.config.from_envvar("OTTERWIKI_SETTINGS", silent=True)

Expand Down
Loading