Skip to content
Merged
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 trojstenid/profiles/templates/profile/profile.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ <h1 class="font-bold text-2xl mt-3">
{{ object.get_full_name }}
</h1>
<div>
{% if "veduci@iam.trojsten.sk" in groups %}
{% if VEDUCI_GROUP in groups %}
<span class="bg-red-600 text-white mt-1 font-bold gap-1 text-sm px-1 py-0.5 rounded-sm inline-flex items-center">
<span class="iconify inline size-4 text-white" data-icon="mdi:account-tie"></span>
Vedúci
Expand Down
4 changes: 3 additions & 1 deletion trojstenid/profiles/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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"
)
Expand Down
1 change: 1 addition & 0 deletions trojstenid/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
4 changes: 3 additions & 1 deletion trojstenid/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand All @@ -31,7 +31,9 @@
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")),
]

Expand Down
8 changes: 7 additions & 1 deletion trojstenid/users/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,20 @@

from trojstenid.schools.admin import UserSchoolRecordInline

from .models import User
from .models import User, WifiPassword


class CustomUserAdmin(UserAdmin):
fieldsets = UserAdmin.fieldsets + ((None, {"fields": ["avatar_file"]}),) # pyright:ignore
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"
Expand Down
2 changes: 2 additions & 0 deletions trojstenid/users/groups.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
VEDUCI_GROUP = "veduci@iam.trojsten.sk"
WIFI_GROUP = "wifi@iam.trojsten.sk"
49 changes: 49 additions & 0 deletions trojstenid/users/migrations/0010_wifipassword.py
Original file line number Diff line number Diff line change
@@ -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,
),
),
],
),
]
44 changes: 44 additions & 0 deletions trojstenid/users/models.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -51,6 +53,48 @@ 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)

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):
Expand Down
58 changes: 58 additions & 0 deletions trojstenid/users/templates/settings/wifi.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
{% extends "settings_base.html" %}

{% block title %}Wi-Fi - {{ block.super }}{% endblock %}

{% block content %}
<div class="mx-auto max-w-2xl space-y-8">
<div>
<h2 class="text-xl font-bold text-gray-900">Wi-Fi</h2>
<p class="mt-2 text-sm text-gray-600">
Tu si vieš vytvoriť vlastné heslo na pripojenie do Trojsten Wi-Fi.
</p>
</div>

{% if generated_password %}
<div class="rounded-md bg-yellow-50 p-4 text-sm text-yellow-800">
Heslo si ulož teraz. Z bezpečnostných dôvodov ho už viac nezobrazíme.
</div>
{% endif %}

<dl class="divide-y divide-gray-100 rounded-lg border border-gray-200 bg-white text-sm">
<div class="grid grid-cols-3 gap-4 px-4 py-3">
<dt class="font-medium text-gray-900">EAP metóda</dt>
<dd class="col-span-2 text-gray-700">EAP-TTLS/PAP</dd>
</div>
<div class="grid grid-cols-3 gap-4 px-4 py-3">
<dt class="font-medium text-gray-900">Doména certifikátu</dt>
<dd class="col-span-2 text-gray-700">radius.trojsten.sk</dd>
</div>
<div class="grid grid-cols-3 gap-4 px-4 py-3">
<dt class="font-medium text-gray-900">Používateľské meno</dt>
<dd class="col-span-2 font-mono text-gray-700">{{ wifi_password.username|default:user.username }}</dd>
</div>
<div class="grid grid-cols-3 gap-4 px-4 py-3">
<dt class="font-medium text-gray-900">Heslo</dt>
<dd class="col-span-2 font-mono text-gray-700">
{% if generated_password %}
{{ generated_password }}
{% elif wifi_password %}
Heslo už bolo vygenerované a nedá sa zobraziť.
{% else %}
Zatiaľ nemáš vygenerované heslo.
{% endif %}
</dd>
</div>
</dl>

<form method="post">
{% csrf_token %}
<button class="btn btn-blue" type="submit">
{% if wifi_password %}Vygenerovať nové heslo{% else %}Vygenerovať heslo{% endif %}
</button>
{% if wifi_password %}
<p class="mt-2 text-sm text-gray-600">Nové heslo nahradí staré.</p>
{% endif %}
</form>

</div>
{% endblock content %}
15 changes: 7 additions & 8 deletions trojstenid/users/templatetags/settings.py
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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
39 changes: 35 additions & 4 deletions trojstenid/users/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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"

Expand Down
43 changes: 42 additions & 1 deletion trojstenid/users/views_api.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,50 @@
import logging
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

logger = logging.getLogger(__name__)


@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)
):
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()


class UserListView(generics.ListAPIView):
"""
Expand Down
Loading