From e3dd833adaaa107365e2ba8e2e26dc208a42c36e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zahradn=C3=ADk?= Date: Thu, 16 Jul 2026 15:25:51 +0200 Subject: [PATCH 1/3] add model and UI --- .../profiles/templates/profile/profile.html | 2 +- trojstenid/profiles/views.py | 4 +- trojstenid/urls.py | 1 + trojstenid/users/admin.py | 8 ++- trojstenid/users/groups.py | 2 + .../users/migrations/0010_wifipassword.py | 49 ++++++++++++++++ trojstenid/users/models.py | 30 ++++++++++ trojstenid/users/templates/settings/wifi.html | 58 +++++++++++++++++++ trojstenid/users/templatetags/settings.py | 15 +++-- trojstenid/users/views.py | 39 +++++++++++-- 10 files changed, 193 insertions(+), 15 deletions(-) create mode 100644 trojstenid/users/groups.py create mode 100644 trojstenid/users/migrations/0010_wifipassword.py create mode 100644 trojstenid/users/templates/settings/wifi.html diff --git a/trojstenid/profiles/templates/profile/profile.html b/trojstenid/profiles/templates/profile/profile.html index 8ee5039..9efa719 100644 --- a/trojstenid/profiles/templates/profile/profile.html +++ b/trojstenid/profiles/templates/profile/profile.html @@ -12,7 +12,7 @@

{{ object.get_full_name }}

- {% if "veduci@iam.trojsten.sk" in groups %} + {% if VEDUCI_GROUP in groups %} Vedúci diff --git a/trojstenid/profiles/views.py b/trojstenid/profiles/views.py index 2953695..11db96a 100644 --- a/trojstenid/profiles/views.py +++ b/trojstenid/profiles/views.py @@ -7,6 +7,7 @@ from PIL import ImageColor from trojstenid.badges.models import Badge +from trojstenid.users.groups import VEDUCI_GROUP from trojstenid.users.models import User @@ -52,9 +53,10 @@ def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) user = ctx["object"] ctx["show_details"] = self.request.user.groups.filter( - name="veduci@iam.trojsten.sk" + name=VEDUCI_GROUP ).exists() ctx["groups"] = user.groups.values_list("name", flat=True) + ctx["VEDUCI_GROUP"] = VEDUCI_GROUP ctx["badges"] = Badge.objects.filter(badgeassignment__user=user).select_related( "group" ) diff --git a/trojstenid/urls.py b/trojstenid/urls.py index 9adf7ca..aa6c4f4 100644 --- a/trojstenid/urls.py +++ b/trojstenid/urls.py @@ -32,6 +32,7 @@ path("oauth/", include("trojstenid.users.urls_oauth", namespace="oauth2_provider")), path("api/", include("trojstenid.users.urls_api", namespace="api")), path("groups/", include("trojstenid.users.urls_groups")), + path("wifi/", views.WifiPasswordView.as_view(), name="wifi_password"), path("django-rq/", include("django_rq.urls")), ] diff --git a/trojstenid/users/admin.py b/trojstenid/users/admin.py index 32c8326..2c35cda 100644 --- a/trojstenid/users/admin.py +++ b/trojstenid/users/admin.py @@ -5,7 +5,7 @@ from trojstenid.schools.admin import UserSchoolRecordInline -from .models import User +from .models import User, WifiPassword class CustomUserAdmin(UserAdmin): @@ -13,6 +13,12 @@ class CustomUserAdmin(UserAdmin): inlines = tuple(UserAdmin.inlines) + (UserSchoolRecordInline,) +@admin.register(WifiPassword) +class WifiPasswordAdmin(admin.ModelAdmin): + list_display = ("username", "user") + search_fields = ("username", "user__username", "allowed_callers") + + admin.site.register(User, CustomUserAdmin) admin.site.site_title = "Trojsten ID" admin.site.site_header = "Trojsten ID" diff --git a/trojstenid/users/groups.py b/trojstenid/users/groups.py new file mode 100644 index 0000000..c898ec0 --- /dev/null +++ b/trojstenid/users/groups.py @@ -0,0 +1,2 @@ +VEDUCI_GROUP = "veduci@iam.trojsten.sk" +WIFI_GROUP = "wifi@iam.trojsten.sk" diff --git a/trojstenid/users/migrations/0010_wifipassword.py b/trojstenid/users/migrations/0010_wifipassword.py new file mode 100644 index 0000000..b7932be --- /dev/null +++ b/trojstenid/users/migrations/0010_wifipassword.py @@ -0,0 +1,49 @@ +# Generated by Django 5.2.4 on 2026-07-16 13:14 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +import trojstenid.users.models + + +class Migration(migrations.Migration): + dependencies = [ + ("users", "0009_user_now_known_as"), + ] + + operations = [ + migrations.CreateModel( + name="WifiPassword", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "username", + models.CharField( + max_length=150, + unique=True, + validators=[trojstenid.users.models.UsernameValidator()], + ), + ), + ("password", models.CharField(max_length=128)), + ("allowed_callers", models.TextField(blank=True)), + ( + "user", + models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/trojstenid/users/models.py b/trojstenid/users/models.py index a9b756b..aba159d 100644 --- a/trojstenid/users/models.py +++ b/trojstenid/users/models.py @@ -1,8 +1,10 @@ import re +import secrets from datetime import date from pathlib import PurePath from typing import TYPE_CHECKING +from django.contrib.auth.hashers import check_password, make_password from django.contrib.auth.models import AbstractUser, Group from django.core import validators from django.core.exceptions import ObjectDoesNotExist @@ -51,6 +53,34 @@ class UsernameValidator(validators.RegexValidator): username_validators = [UsernameValidator()] +WIFI_PASSWORD_ALPHABET = "346789ABCDEFGHJKLMNPQRTUVWXY" # noqa: S105 +WIFI_PASSWORD_LENGTH = 12 + + +class WifiPassword(models.Model): + user = models.OneToOneField( + "users.User", on_delete=models.CASCADE, blank=True, null=True + ) + username = models.CharField( + max_length=150, unique=True, validators=username_validators + ) + password = models.CharField(max_length=128) + allowed_callers = models.TextField(blank=True) + + def __str__(self): + return self.username + + def set_password(self, raw_password=None): + if raw_password is None: + raw_password = "".join( + secrets.choice(WIFI_PASSWORD_ALPHABET) + for _ in range(WIFI_PASSWORD_LENGTH) + ) + self.password = make_password(raw_password) + return raw_password + + def check_password(self, raw_password): + return check_password(raw_password, self.password) class User(AbstractUser): diff --git a/trojstenid/users/templates/settings/wifi.html b/trojstenid/users/templates/settings/wifi.html new file mode 100644 index 0000000..ed432fe --- /dev/null +++ b/trojstenid/users/templates/settings/wifi.html @@ -0,0 +1,58 @@ +{% extends "settings_base.html" %} + +{% block title %}Wi-Fi - {{ block.super }}{% endblock %} + +{% block content %} +
+
+

Wi-Fi

+

+ Tu si vieš vytvoriť vlastné heslo na pripojenie do Trojsten Wi-Fi. +

+
+ + {% if generated_password %} +
+ Heslo si ulož teraz. Z bezpečnostných dôvodov ho už viac nezobrazíme. +
+ {% endif %} + +
+
+
EAP metóda
+
EAP-TTLS/PAP
+
+
+
Doména certifikátu
+
radius.trojsten.sk
+
+
+
Používateľské meno
+
{{ wifi_password.username|default:user.username }}
+
+
+
Heslo
+
+ {% if generated_password %} + {{ generated_password }} + {% elif wifi_password %} + Heslo už bolo vygenerované a nedá sa zobraziť. + {% else %} + Zatiaľ nemáš vygenerované heslo. + {% endif %} +
+
+
+ +
+ {% csrf_token %} + + {% if wifi_password %} +

Nové heslo nahradí staré.

+ {% endif %} +
+ +
+{% endblock content %} diff --git a/trojstenid/users/templatetags/settings.py b/trojstenid/users/templatetags/settings.py index fb539ee..6ec8b0a 100644 --- a/trojstenid/users/templatetags/settings.py +++ b/trojstenid/users/templatetags/settings.py @@ -1,8 +1,8 @@ from django import template -register = template.Library() +from trojstenid.users.groups import VEDUCI_GROUP, WIFI_GROUP -VEDUCI_GROUP = "veduci@iam.trojsten.sk" +register = template.Library() @register.inclusion_tag("_partials/navbar_items.html", takes_context=True) @@ -16,12 +16,11 @@ def navbar_menu(context): ] user = context.get("user") - if ( - user - and user.is_authenticated - and user.groups.filter(name=VEDUCI_GROUP).exists() - ): - items.append(("mdi:account-group", "Skupiny", "group_list")) + if user and user.is_authenticated: + if user.groups.filter(name=WIFI_GROUP).exists(): + items.append(("mdi:wifi", "Wi-Fi", "wifi_password")) + if user.groups.filter(name=VEDUCI_GROUP).exists(): + items.append(("mdi:account-group", "Skupiny", "group_list")) context["items"] = items return context diff --git a/trojstenid/users/views.py b/trojstenid/users/views.py index b1e4f31..22c1fbc 100644 --- a/trojstenid/users/views.py +++ b/trojstenid/users/views.py @@ -6,19 +6,25 @@ from oauth2_provider.views import AuthorizationView from trojstenid.users.forms.settings import ProfileForm -from trojstenid.users.models import Application, User +from trojstenid.users.groups import VEDUCI_GROUP, WIFI_GROUP +from trojstenid.users.models import Application, User, WifiPassword from trojstenid.users.tasks import sync_groups -VEDUCI_GROUP = "veduci@iam.trojsten.sk" - class VeduciRequiredMixin(LoginRequiredMixin, UserPassesTestMixin): - """Allows access only to members of veduci@iam.trojsten.sk.""" + """Allows access only to veduci.""" def test_func(self): return self.request.user.groups.filter(name=VEDUCI_GROUP).exists() +class WifiRequiredMixin(LoginRequiredMixin, UserPassesTestMixin): + """Allows access only to wifi users.""" + + def test_func(self): + return self.request.user.groups.filter(name=WIFI_GROUP).exists() + + class TrojstenAuthorizationView(AuthorizationView): def dispatch(self, request, *args, **kwargs): application = get_object_or_404( @@ -66,6 +72,31 @@ def dispatch(self, request, *args, **kwargs): return super().dispatch(request, *args, **kwargs) +class WifiPasswordView(WifiRequiredMixin, TemplateView): + template_name = "settings/wifi.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["wifi_password"] = WifiPassword.objects.filter( + user=self.request.user + ).first() + return context + + def post(self, request, *args, **kwargs): + wifi_password, _ = WifiPassword.objects.get_or_create( + user=request.user, + defaults={"username": request.user.username, "password": ""}, + ) + raw_password = wifi_password.set_password() + wifi_password.save() + messages.success(request, "Wi-Fi heslo bolo vygenerované.") + + context = self.get_context_data(**kwargs) + context["wifi_password"] = wifi_password + context["generated_password"] = raw_password + return self.render_to_response(context) + + class GroupListView(VeduciRequiredMixin, TemplateView): template_name = "groups/group_list.html" From 8b14283735d359492c3a8a823eba878295c740c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zahradn=C3=ADk?= Date: Thu, 16 Jul 2026 15:27:40 +0200 Subject: [PATCH 2/3] add radius auth endpoint --- trojstenid/settings.py | 1 + trojstenid/urls.py | 3 ++- trojstenid/users/models.py | 14 ++++++++++++++ trojstenid/users/views_api.py | 34 +++++++++++++++++++++++++++++++++- 4 files changed, 50 insertions(+), 2 deletions(-) diff --git a/trojstenid/settings.py b/trojstenid/settings.py index 1018198..179ff2a 100644 --- a/trojstenid/settings.py +++ b/trojstenid/settings.py @@ -253,6 +253,7 @@ GITHUB_ORG_NAME = env("GITHUB_ORG_NAME", default="trojsten") GITHUB_TEAMS = env.dict("GITHUB_TEAMS", default={}) +RADIUS_AUTH_TOKEN = env("RADIUS_AUTH_TOKEN", default="") RQ_QUEUES = { "default": { diff --git a/trojstenid/urls.py b/trojstenid/urls.py index aa6c4f4..bc6eff3 100644 --- a/trojstenid/urls.py +++ b/trojstenid/urls.py @@ -20,7 +20,7 @@ from django.contrib import admin from django.urls import include, path -from trojstenid.users import views +from trojstenid.users import views, views_api urlpatterns = [ path("", views.LandingPageView.as_view()), @@ -31,6 +31,7 @@ path("accounts/", include("allauth.urls")), path("oauth/", include("trojstenid.users.urls_oauth", namespace="oauth2_provider")), path("api/", include("trojstenid.users.urls_api", namespace="api")), + path("wifi/check/", views_api.RadiusCheckView.as_view(), name="radius_check"), path("groups/", include("trojstenid.users.urls_groups")), path("wifi/", views.WifiPasswordView.as_view(), name="wifi_password"), path("django-rq/", include("django_rq.urls")), diff --git a/trojstenid/users/models.py b/trojstenid/users/models.py index aba159d..500a724 100644 --- a/trojstenid/users/models.py +++ b/trojstenid/users/models.py @@ -82,6 +82,20 @@ def set_password(self, raw_password=None): def check_password(self, raw_password): return check_password(raw_password, self.password) + def allows_caller(self, calling_station_id): + callers = [ + c.strip().upper() for c in self.allowed_callers.split(",") if c.strip() + ] + if not callers: + return True + caller = calling_station_id.strip().upper() + normalized_caller = caller.replace(":", "").replace("-", "") + return any( + caller == allowed + or normalized_caller == allowed.replace(":", "").replace("-", "") + for allowed in callers + ) + class User(AbstractUser): id: int diff --git a/trojstenid/users/views_api.py b/trojstenid/users/views_api.py index 46bd62f..93ce986 100644 --- a/trojstenid/users/views_api.py +++ b/trojstenid/users/views_api.py @@ -1,10 +1,42 @@ +from hmac import compare_digest + +from django.conf import settings from django.db.models import Q +from django.http import HttpResponse, HttpResponseForbidden, HttpResponseNotFound +from django.utils.decorators import method_decorator +from django.views import View +from django.views.decorators.csrf import csrf_exempt from rest_framework import generics, permissions -from trojstenid.users.models import User +from trojstenid.users.models import User, WifiPassword from trojstenid.users.serializers import UserListSerializer, UserSerializer +@method_decorator(csrf_exempt, name="dispatch") +class RadiusCheckView(View): + def post(self, request, *args, **kwargs): + token = settings.RADIUS_AUTH_TOKEN + if not token: + return HttpResponseNotFound() + + auth = request.headers.get("Authorization", "") + if not auth.startswith("Bearer ") or not compare_digest(auth[7:], token): + return HttpResponseForbidden() + + username = request.POST.get("User-Name", "") + password = request.POST.get("User-Password", "") + calling_station_id = request.POST.get("Calling-Station-Id", "") + + wifi_password = WifiPassword.objects.filter(username=username).first() + if ( + wifi_password + and wifi_password.check_password(password) + and wifi_password.allows_caller(calling_station_id) + ): + return HttpResponse("OK") + return HttpResponseForbidden() + + class UserListView(generics.ListAPIView): """ List all users. From 6c41d2dbb5f841f29ca800a8a24b9bbd3f715ea6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zahradn=C3=ADk?= Date: Thu, 16 Jul 2026 15:37:53 +0200 Subject: [PATCH 3/3] log wifi logins --- trojstenid/users/views_api.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/trojstenid/users/views_api.py b/trojstenid/users/views_api.py index 93ce986..e6038ec 100644 --- a/trojstenid/users/views_api.py +++ b/trojstenid/users/views_api.py @@ -1,3 +1,4 @@ +import logging from hmac import compare_digest from django.conf import settings @@ -11,6 +12,8 @@ from trojstenid.users.models import User, WifiPassword from trojstenid.users.serializers import UserListSerializer, UserSerializer +logger = logging.getLogger(__name__) + @method_decorator(csrf_exempt, name="dispatch") class RadiusCheckView(View): @@ -33,6 +36,12 @@ def post(self, request, *args, **kwargs): and wifi_password.check_password(password) and wifi_password.allows_caller(calling_station_id) ): + logger.info( + "successful wifi login username=%s calling_station_id=%s nas=%s", + username, + calling_station_id, + request.POST.get("NAS-Identifier", ""), + ) return HttpResponse("OK") return HttpResponseForbidden()