Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion MANIFEST.in
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
include AUTHORS LICENSE NOTICE pycroft/model/alembic.ini web/default_config.toml
include AUTHORS LICENSE NOTICE pycroft/model/alembic.ini web/default_config.toml web/client_secrets.json
recursive-include pycroft/templates/ *
recursive-include pycroft/helpers/printing/assets *
recursive-include pycroft/messages *.mo *.po *.pot
Expand Down
5 changes: 5 additions & 0 deletions docker-compose.base.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ services:
# alternative: `scripts.server_run:prepare_server(echo=True)`
FLASK_APP: scripts.server_run:prepare_server
FLASK_ENV: development
# OIDC Authentication
OIDC_CLIENT_SECRETS: 'web/client_secrets.json'
OIDC_SCOPES: 'openid email profile'
OIDC_INTROSPECTION_AUTH_METHOD: 'client_secret_post'
OIDC_ENABLED: true
dev:
extends: dev-base
volumes:
Expand Down
5 changes: 5 additions & 0 deletions pycroft/model/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
validates,
Mapped,
mapped_column,
Session,
)
from sqlalchemy.orm.attributes import flag_modified
from sqlalchemy.orm.collections import attribute_keyed_dict
Expand Down Expand Up @@ -395,6 +396,10 @@ def wifi_password(self, value):
def has_wifi_access(self):
return self.wifi_passwd_hash is not None

@staticmethod
def get(login: str, session: Session) -> User | None:
return session.scalar(select(User).where(User.login == login))

@staticmethod
def verify_and_get(login: str, plaintext_password: str) -> User | None:
try:
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ dependencies = [
"Flask-Login ~= 0.6.2",
"Flask-RESTful ~= 0.3.7",
"Flask-WTF ~= 1.1.1",
"Flask-OIDC ~= 2.4.0",
"GitPython ~= 3.1.43",
"netaddr ~= 1.3.0",
"Jinja2 ~= 3.1.4",
Expand Down
35 changes: 35 additions & 0 deletions tests/frontend/user/test_oidc_login.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Copyright (c) 2026. The Pycroft Authors. See the AUTHORS file.
# This file is part of the Pycroft project and licensed under the terms of
# the Apache License, Version 2.0. See the LICENSE file for details

import pytest
from flask import url_for
from sqlalchemy.orm import Session

from tests.factories import UserFactory
from tests.frontend.assertions import TestClient


@pytest.fixture(scope="module")
def client(module_test_client: TestClient) -> TestClient:
return module_test_client


@pytest.mark.usefixtures("session")
class TestUserOidcLogin:
@pytest.fixture(scope="class")
def user(self, class_session: Session):
user = UserFactory(
login="oidc",
)
class_session.flush()
return user

def test_oidc_login(self, client: TestClient, app, user):
app.config["OIDC_ENABLED"] = False
client.assert_ok("login.login")
app.config["OIDC_TESTING_PROFILE"] = {"email": "email", "preferred_username": "oidc"}
with client.flashes_message("Erfolgreich angemeldet.", category="success"):
response = client.get(url_for("login.login"))
assert response.status_code == 302
assert response.location == url_for("user.overview")
4 changes: 4 additions & 0 deletions tests/model/test_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ class Test_User_Passwords:
def user(self, class_session):
return factories.UserFactory()

def test_get(self, user, session):
assert User.get(user.login, session) == user
assert User.get(user.login + "_wrong", session) is None

def test_password_hash_validator(self, user, session):
password = generate_password(4)
pw_hash = hash_password(password)
Expand Down
148 changes: 148 additions & 0 deletions uv.lock

Large diffs are not rendered by default.

26 changes: 17 additions & 9 deletions web/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
mpskclient,
)

from .blueprints.login import login_manager
from .blueprints.login import oidc, login_manager
from .commands import register_commands
from .templates import page_resources

Expand All @@ -67,14 +67,20 @@ class PycroftFlask(Flask):
def __init__(self, *a: t.Any, **kw: t.Any) -> None:
super().__init__(*a, **kw)
# config keys to support:
self.maybe_add_config_from_env([
'PYCROFT_API_KEY',
'HADES_CELERY_APP_NAME',
'HADES_BROKER_URI',
'HADES_RESULT_BACKEND_URI',
'HADES_TIMEOUT',
'HADES_ROUTING_KEY',
])
self.maybe_add_config_from_env(
[
"PYCROFT_API_KEY",
"HADES_CELERY_APP_NAME",
"HADES_BROKER_URI",
"HADES_RESULT_BACKEND_URI",
"HADES_TIMEOUT",
"HADES_ROUTING_KEY",
"OIDC_CLIENT_SECRETS",
"OIDC_SCOPES",
"OIDC_INTROSPECTION_AUTH_METHOD",
"OIDC_ENABLED",
]
)

def maybe_add_config_from_env(self, keys: t.Iterable[str]) -> None:
"""Write keys from the environment to the app's config
Expand All @@ -98,6 +104,7 @@ def make_app(hades_logs: bool = True) -> PycroftFlask:

# initialization code
login_manager.init_app(app)
oidc.init_app(app, prefix="/oidc")
app.register_blueprint(user.bp, url_prefix="/user")
app.register_blueprint(facilities.bp, url_prefix="/facilities")
app.register_blueprint(infrastructure.bp, url_prefix="/infrastructure")
Expand Down Expand Up @@ -187,6 +194,7 @@ def require_login() -> ResponseReturnValue | None:
"login",
"api",
"health",
"oidc_auth",
None,
):
lm = t.cast(LoginManager, current_app.login_manager) # type: ignore[attr-defined]
Expand Down
14 changes: 14 additions & 0 deletions web/blueprints/login/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@
import typing as t

from flask import Blueprint, render_template, flash, redirect, url_for, request
from flask import session as flask_session
from flask.typing import ResponseValue
from flask_login import (
AnonymousUserMixin, LoginManager, current_user, login_required, login_user,
logout_user)
from flask_oidc import OpenIDConnect

from pycroft.model.session import session
from pycroft.model.user import User
Expand All @@ -30,6 +32,8 @@ class AnonymousUser(AnonymousUserMixin):
current_properties_set: t.Container[str] = frozenset()


oidc = OpenIDConnect()

login_manager = LoginManager()
login_manager.anonymous_user = AnonymousUser
login_manager.login_view = "login.login"
Expand All @@ -54,12 +58,22 @@ def login() -> ResponseValue:
flash("Erfolgreich angemeldet.", "success")
return redirect(request.args.get("next") or url_for("user.overview"))
flash("Benutzername und/oder Passwort falsch", "error")
if oidc.user_loggedin:
info = flask_session["oidc_auth_profile"]
username = info.get("pycroft_login", info.get("preferred_username", None))
user = User.get(username, session)
if info is not None and username is not None and user is not None:
login_user(user)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Also, you need to verify the condition that the user has the Mitgliederverwalter role set: Not all users of the internal realm (i.e., all active members) are allowed to log in to pycroft.
this is mapped in keycloak, but I do not know how it is exposed in the user profile.

flash("Erfolgreich angemeldet.", "success")
return redirect(request.args.get("next") or url_for("user.overview"))
return render_template("login/login.html", form=form, next=request.args.get("next"))


@bp.route("/logout")
@login_required
def logout() -> ResponseValue:
if oidc.user_loggedin:
return redirect(url_for("oidc_auth.logout", next=url_for("login.logout")))
logout_user()
flash("Sie sind jetzt abgemeldet!", "info")
return redirect(url_for(".login"))
7 changes: 7 additions & 0 deletions web/client_secrets.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"web": {
"issuer": "http://localhost:8080/realms/internal",
"client_id": "pycroft",
"client_secret": "lirumlarum loeffelstiel wer das liest der weiss zu viel"
}
}
2 changes: 2 additions & 0 deletions web/templates/login/login.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,6 @@ <h1>Anmelden</h1>
{% endblock %}
{% block single_row_content %}
{{ forms.simple_form(form, url_for(".login", next=next), url_for('facilities.overview'), submit_text="Login" ) }}
<hr/>
<a href="{{ url_for('oidc_auth.login') }}">OpenID Connect</a>
{% endblock %}
Loading