Files
neuf-www/dnscms/tests/test_oidc.py
T

378 lines
12 KiB
Python

import logging
import re
import pytest
import requests
from authlib.integrations.django_client import OAuthError
from django.contrib.auth.models import Group, Permission, User
from django.http import HttpResponseRedirect
from django.urls import reverse
from joserfc.errors import JoseError
from dnscms import oidc
from dnscms.models import GaltinnUser
from dnscms.oidc import WAGTAIL_EDITORS_GROUP, LoginRefused, resolve_user
pytestmark = pytest.mark.django_db
EDITOR_GROUP = "Aktive"
def claims(**overrides):
base = {
"sub": "42",
"preferred_username": "kari",
"email": "kari@neuf.test",
"given_name": "Kari",
"family_name": "Nordmann",
"groups": [EDITOR_GROUP, "Styret"],
}
return {**base, **overrides}
@pytest.fixture(autouse=True)
def oidc_settings(settings):
settings.GALTINN_EDITOR_GROUP = EDITOR_GROUP
settings.OIDC_RP_CLIENT_ID = "client-id"
def link(user, galtinn_id="42"):
return GaltinnUser.objects.create(user=user, galtinn_id=galtinn_id)
def editor(client, django_user_model, **kwargs):
user = django_user_model.objects.create_user(username="editor", password="pw", **kwargs)
user.user_permissions.add(Permission.objects.get(codename="access_admin"))
client.force_login(user)
return user
def test_linked_member_is_synced_from_claims():
user = User.objects.create_user(username="stale-name", email="stale@example.test")
link(user)
resolved = resolve_user(claims(preferred_username="kari-new"))
assert resolved.pk == user.pk
assert resolved.username == "kari-new"
assert resolved.email == "kari@neuf.test"
assert resolved.first_name == "Kari"
def test_linked_member_is_reactivated_and_added_to_editors():
user = User.objects.create_user(username="kari", is_active=False)
link(user)
resolved = resolve_user(claims())
assert resolved.is_active
assert resolved.groups.filter(name=WAGTAIL_EDITORS_GROUP).exists()
def test_linked_member_loses_local_password():
user = User.objects.create_user(username="kari", password="pw")
link(user)
resolved = resolve_user(claims())
assert not resolved.has_usable_password()
def test_integer_sub_matches_stored_id():
user = User.objects.create_user(username="kari")
link(user)
assert resolve_user(claims(sub=42)).pk == user.pk
def test_linked_username_collision_refuses_login(caplog):
linked = User.objects.create_user(username="kari-old", email="old@example.test")
link(linked)
other = User.objects.create_user(username="kari", email="other@example.test")
with caplog.at_level(logging.ERROR), pytest.raises(LoginRefused):
resolve_user(claims())
linked.refresh_from_db()
other.refresh_from_db()
assert linked.username == "kari-old"
assert linked.email == "old@example.test"
assert other.email == "other@example.test"
assert "resolve manually" in caplog.text
def test_linked_non_member_is_deactivated_and_loses_editors():
user = User.objects.create_user(username="kari-old", email="old@example.test")
user.groups.add(Group.objects.get(name=WAGTAIL_EDITORS_GROUP))
link(user)
with pytest.raises(LoginRefused):
resolve_user(claims(groups=["Styret"]))
user.refresh_from_db()
assert not user.is_active
assert not user.groups.exists()
assert user.username == "kari-old"
assert user.email == "old@example.test"
def test_linked_non_member_superuser_stays_active():
user = User.objects.create_superuser(username="kari", email="old@example.test", password="pw")
link(user)
with pytest.raises(LoginRefused):
resolve_user(claims(groups=[]))
user.refresh_from_db()
assert user.is_active
assert user.email == "old@example.test"
assert user.has_usable_password()
@pytest.mark.parametrize(
"local",
[
{"username": "kari", "email": "other@example.test"},
{"username": "KARI", "email": "other@example.test"},
{"username": "somebody", "email": "kari@neuf.test"},
{"username": "somebody", "email": "KARI@neuf.test"},
],
)
@pytest.mark.parametrize("groups", [[EDITOR_GROUP], []])
def test_unlinked_local_user_is_never_matched(local, groups, caplog):
user = User.objects.create_superuser(password="pw", **local)
with caplog.at_level(logging.ERROR), pytest.raises(LoginRefused):
resolve_user(claims(groups=groups))
user.refresh_from_db()
assert user.username == local["username"]
assert user.email == local["email"]
assert user.is_active
assert user.has_usable_password()
assert User.objects.count() == 1
assert not GaltinnUser.objects.exists()
assert ("link manually" in caplog.text) == bool(groups)
def test_link_is_never_rebound_to_another_galtinn_id():
user = User.objects.create_user(username="kari", email="kari@neuf.test")
link(user, galtinn_id="7")
with pytest.raises(LoginRefused):
resolve_user(claims())
assert GaltinnUser.objects.get(user=user).galtinn_id == "7"
def test_repeat_login_keeps_single_link():
resolve_user(claims())
resolve_user(claims())
assert GaltinnUser.objects.get(galtinn_id="42").user.username == "kari"
assert User.objects.count() == 1
def test_unknown_member_is_created_with_galtinn_identity():
resolved = resolve_user(claims())
assert resolved.username == "kari"
assert resolved.email == "kari@neuf.test"
assert resolved.first_name == "Kari"
assert not resolved.has_usable_password()
assert resolved.groups.filter(name=WAGTAIL_EDITORS_GROUP).exists()
assert GaltinnUser.objects.filter(user=resolved, galtinn_id="42").exists()
def test_unknown_non_member_is_not_created():
with pytest.raises(LoginRefused):
resolve_user(claims(groups=["Styret"]))
assert not User.objects.exists()
@pytest.mark.parametrize("name", ["sub", "preferred_username", "email"])
@pytest.mark.parametrize("value", [None, ""])
def test_missing_claim_refuses_login(name, value):
bad = claims(**{name: value}) if value is not None else claims()
if value is None:
del bad[name]
with pytest.raises(LoginRefused):
resolve_user(bad)
assert not User.objects.exists()
def test_empty_editor_group_setting_fails_closed(settings):
settings.GALTINN_EDITOR_GROUP = ""
with pytest.raises(LoginRefused):
resolve_user(claims())
assert not User.objects.exists()
def test_editors_group_missing_fails_loudly():
Group.objects.filter(name=WAGTAIL_EDITORS_GROUP).delete()
with pytest.raises(Group.DoesNotExist):
resolve_user(claims())
assert not User.objects.exists()
def test_login_redirects_to_galtinn_with_configured_callback(client, monkeypatch, settings):
settings.WAGTAIL_BASE_URL = "https://cms.example.test"
seen = {}
def authorize_redirect(request, redirect_uri):
seen["redirect_uri"] = redirect_uri
return HttpResponseRedirect("https://galtinn.example.test/authorize")
monkeypatch.setattr(oidc.oauth.galtinn, "authorize_redirect", authorize_redirect)
response = client.get(reverse("oidc_login"), {"next": "/admin/pages/3/"})
assert response.url == "https://galtinn.example.test/authorize"
assert seen["redirect_uri"] == "https://cms.example.test/oidc/callback/"
assert client.session["oidc_next"] == "/admin/pages/3/"
@pytest.mark.parametrize("setting", ["OIDC_RP_CLIENT_ID", "GALTINN_EDITOR_GROUP"])
def test_views_are_404_when_not_configured(client, settings, setting):
setattr(settings, setting, "")
assert client.get(reverse("oidc_login")).status_code == 404
assert client.get(reverse("oidc_callback")).status_code == 404
@pytest.mark.parametrize(
("next_url", "expected"),
[("/admin/pages/3/", "/admin/pages/3/"), ("https://evil.example.test/", "/admin/")],
)
def test_callback_only_follows_local_next(client, monkeypatch, next_url, expected):
monkeypatch.setattr(
oidc.oauth.galtinn, "authorize_access_token", lambda request: {"userinfo": claims()}
)
session = client.session
session["oidc_next"] = next_url
session.save()
response = client.get(reverse("oidc_callback"))
assert response.url == expected
@pytest.mark.parametrize(
"error", [OAuthError("denied"), JoseError("bad token"), requests.ConnectionError("down")]
)
def test_callback_flow_failure_redirects_to_login_with_message(client, monkeypatch, error):
def fail(request):
raise error
monkeypatch.setattr(oidc.oauth.galtinn, "authorize_access_token", fail)
response = client.get(reverse("oidc_callback"), follow=True)
assert response.redirect_chain[-1][0] == reverse("wagtailadmin_login")
assert any("feilet" in str(m) for m in response.context["messages"])
assert "_auth_user_id" not in client.session
def test_callback_without_userinfo_is_refused(client, monkeypatch):
monkeypatch.setattr(oidc.oauth.galtinn, "authorize_access_token", lambda request: {})
response = client.get(reverse("oidc_callback"), follow=True)
assert any("ikke tilgang" in str(m) for m in response.context["messages"])
assert "_auth_user_id" not in client.session
def test_callback_logs_in_and_redirects_to_admin(client, monkeypatch):
monkeypatch.setattr(
oidc.oauth.galtinn, "authorize_access_token", lambda request: {"userinfo": claims()}
)
response = client.get(reverse("oidc_callback"))
assert response.status_code == 302
assert response.url == reverse("wagtailadmin_home")
user = User.objects.get(username="kari")
assert client.session["_auth_user_id"] == str(user.pk)
def test_callback_refusal_redirects_to_login_with_message(client, monkeypatch):
monkeypatch.setattr(
oidc.oauth.galtinn,
"authorize_access_token",
lambda request: {"userinfo": claims(groups=[])},
)
response = client.get(reverse("oidc_callback"), follow=True)
assert response.redirect_chain[-1][0] == reverse("wagtailadmin_login")
messages = [str(m) for m in response.context["messages"]]
assert any("ikke tilgang" in m for m in messages)
assert "_auth_user_id" not in client.session
def test_login_page_shows_galtinn_button_once(client):
response = client.get(reverse("wagtailadmin_login"))
assert response.content.count(reverse("oidc_login").encode()) == 1
assert b"Logg inn med lokal bruker" in response.content
assert b"login-form" in response.content
def test_login_page_is_local_only_when_not_configured(client, settings):
settings.OIDC_RP_CLIENT_ID = ""
html = client.get(reverse("wagtailadmin_login")).content.decode()
assert reverse("oidc_login") not in html
assert "Logg inn med lokal bruker" not in html
assert re.search(r'<div class="local-login" id="local-login"\s*>', html)
def test_linked_account_page_shows_galtinn_note_and_disables_fields(client, django_user_model):
link(editor(client, django_user_model))
html = client.get(reverse("wagtailadmin_account")).content.decode()
assert "Navn og e-post må endres i Galtinn." in html
for field in ["first_name", "last_name", "email"]:
tag = re.search(rf'<input[^>]*name="name_email-{field}"[^>]*>', html).group(0)
assert "disabled" in tag
def test_linked_account_post_cannot_change_name_or_email(client, django_user_model):
user = editor(client, django_user_model, email="editor@neuf.test", first_name="Ed")
link(user)
client.post(
reverse("wagtailadmin_account"),
{"name_email-first_name": "Hacked", "name_email-email": "hacked@example.test"},
)
user.refresh_from_db()
assert user.first_name == "Ed"
assert user.email == "editor@neuf.test"
def test_local_account_page_keeps_editable_name_and_email(client, django_user_model):
editor(client, django_user_model)
html = client.get(reverse("wagtailadmin_account")).content.decode()
assert "Navn og e-post må endres i Galtinn." not in html
tag = re.search(r'<input[^>]*name="name_email-email"[^>]*>', html).group(0)
assert "disabled" not in tag
def test_account_page_requires_login(client):
response = client.get(reverse("wagtailadmin_account"))
assert response.status_code == 302
assert reverse("wagtailadmin_login") in response.url