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 .github/workflows/black.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,4 @@ jobs:
- name: Black Code Formatter
uses: lgeiger/black-action@v1.0.1
with:
args: --check --target-version py311 --line-length 120 --skip-string-normalization --exclude '(migrations|urls\.py)' stregsystem stregreport kiosk razzia
args: --check --target-version py311 --line-length 120 --skip-string-normalization --exclude '(migrations|urls\.py)' stregsystem stregreport kiosk razzia sso
6 changes: 6 additions & 0 deletions sso/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class SsoConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "sso"
62 changes: 62 additions & 0 deletions sso/auth_backends.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
from datetime import timedelta

from django.contrib.auth.models import User
from django.utils import timezone

from sso.models import MemberOTPRequest
from stregsystem.models import Member


class PasswordlessMemberBackend:
"""
Minimal passwordless authentication backend.
"""

MAX_OTP_ATTEMPTS = 3
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

is this meant to be hardcoded?

OTP_DURATION_MIN = 5

def authenticate(self, request, username=None, otp=None, **kwargs):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

add types

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Optional pls

if username is None or not otp:
return None

try:
member = Member.objects.get(username=username)
except Member.DoesNotExist:
return None

otp_request = MemberOTPRequest.objects.filter(member=member, is_valid=True).order_by("-created_at").first()
if not otp_request:
return None

# Too old request
if otp_request.created_at < timezone.now() - timedelta(minutes=self.OTP_DURATION_MIN):
otp_request.is_valid = False
otp_request.save()
return None

# Too many tries for this OTP
if otp_request.failed_attempts >= self.MAX_OTP_ATTEMPTS:
otp_request.is_valid = False
otp_request.save()
return None

# Test whether correct OTP provided
if otp_request.code != otp:
otp_request.failed_attempts += 1
otp_request.save()
return None

# Login successful - Clear login attempts
otp_request.is_valid = False
otp_request.save()

if member.paired_user is None:
member.generate_companion_user()

return member.paired_user

def get_user(self, user_id):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

what type is user_id? could be str, int, bytes or whatever?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

function can return None, and the return statement doesn't make it clear what it returns

I'm not too familiar with django but i feel like objects.get(pk=user_id) could return vector[User] or User

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

list*

try:
return User.objects.get(pk=user_id)
except User.DoesNotExist:
return None
41 changes: 41 additions & 0 deletions sso/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Generated by Django 4.1.13 on 2026-04-03 22:19

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

initial = True

dependencies = [
("stregsystem", "0025_member_paired_user"),
]

operations = [
migrations.CreateModel(
name="MemberOTPRequest",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("code", models.CharField(max_length=6)),
("failed_attempts", models.PositiveSmallIntegerField(default=0)),
("is_valid", models.BooleanField(default=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"member",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="stregsystem.member",
),
),
],
),
]
Empty file added sso/migrations/__init__.py
Empty file.
14 changes: 14 additions & 0 deletions sso/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from django.contrib.admin.models import LogEntry
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.db import models

from stregsystem.models import Member


class MemberOTPRequest(models.Model):
member = models.ForeignKey(Member, on_delete=models.CASCADE)
code = models.CharField(max_length=6)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

might we end up supporting configurable length later?

failed_attempts = models.PositiveSmallIntegerField(default=0)
is_valid = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
216 changes: 216 additions & 0 deletions sso/templates/modal/login.html
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this is too pretty for the stregsystem

Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
{% extends "modal/modal_base.html" %}

{% block title %}Log ind{% endblock %}

{% block head %}
<style>
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

not strictly related to this pr, but i think it would be nice to have shared css?

h1 {
font-size: 1.3rem;
font-weight: 600;
margin-bottom: .35rem;
}

.subtitle {
font-size: .9rem;
color: #666;
margin-bottom: 1.5rem;
}

/* SSO banner */
.sso-banner {
font-size: .85rem;
color: #555;
background: #f0f4ff;
border: 1px solid #c7d7ff;
border-radius: 6px;
padding: .6rem .85rem;
margin-bottom: 1.25rem;
}

/* OTP grid */
.otp-row {
display: flex;
gap: 6px;
margin-bottom: 1rem;
}

.otp-row input {
width: 100%;
aspect-ratio: 1;
text-align: center;
font-size: 1.2rem;
font-family: monospace;
border: 1px solid #ccc;
border-radius: 6px;
outline: none;
transition: border-color .15s;
padding: 0;
margin: 0;
}
.otp-row input:focus { border-color: #4a7cf7; }

.otp-row .f-cell {
background: #fffbe6;
border-color: #e0c050;
color: #8a6200;
pointer-events: none;
}

/* Utility row below form */
.form-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: .85rem;
font-size: .85rem;
color: #666;
}

.form-footer a,
.form-footer button {
background: none;
border: none;
padding: 0;
color: #4a7cf7;
cursor: pointer;
font-size: .85rem;
text-decoration: none;
}
.form-footer button:disabled { color: #aaa; cursor: default; }

/* Stage dots */
.stage-dots { display: flex; gap: 5px; margin-bottom: 1.5rem; }
.stage-dots span {
height: 3px;
border-radius: 2px;
background: #ddd;
width: 20px;
transition: background .2s, width .2s;
}
.stage-dots .active { background: #4a7cf7; width: 28px; }
.stage-dots .done { background: #2ecc71; }
</style>
{% endblock %}

{% block content %}
<div class="sso-banner">
Log ind for at fortsætte
</div>

{% if stage == 1 or not stage %}

<div class="stage-dots"><span class="active"></span><span></span></div>
<h1>Kære Fember</h1>
<p class="subtitle">Indtast din stregbruger for at fortsætte.</p>
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

is stregbruger the common name for a user for the users?


<form method="POST" action="{% url 'sso_login' %}{% if next %}?next={{ next|urlencode }}{% endif %}" novalidate>
{% csrf_token %}
<input type="hidden" name="stage" value="1"/>
{% if next %}<input type="hidden" name="next" value="{{ next }}"/>{% endif %}

<label for="username">Brugernavn</label>
<input
type="text" id="username" name="username"
value="{{ username|default:'' }}"
autocomplete="username" autofocus spellcheck="false" autocapitalize="none"
/>

<button type="submit">Fortsæt →</button>
</form>

{% elif stage == 2 %}

<div class="stage-dots"><span class="done"></span><span class="active"></span></div>
<h1>Tjek din indbakke</h1>
<p class="subtitle">Indtast F&#8209;koden, der blev sendt til <strong>{{ masked_email }}</strong></p>

<form method="POST" action="{% url 'sso_login' %}{% if next %}?next={{ next|urlencode }}{% endif %}" id="otp-form" novalidate>
{% csrf_token %}
<input type="hidden" name="stage" value="2"/>
<input type="hidden" name="username" value="{{ username }}"/>
{% if next %}<input type="hidden" name="next" value="{{ next }}"/>{% endif %}
<input type="hidden" name="otp_combined" id="otp-combined"/>

<label>F‑Code</label>
<div class="otp-row" id="otp-grid">
<input type="text" value="F" readonly tabindex="-1" class="f-cell" aria-label="F prefix"/>
{% for i in "12345" %}
<input
type="text" inputmode="numeric" pattern="[0-9]"
name="otp_{{ i }}" maxlength="1" class="digit-cell"
autocomplete="one-time-code" aria-label="Digit {{ i }}"
{% if i == "1" %}autofocus{% endif %}
/>
{% endfor %}
</div>

<button type="submit">Log ind →</button>

<div class="form-footer">
<span>Udløber om <strong id="countdown">05:00</strong></span>
<form method="POST" action="{% url 'sso_resend_otp' %}" style="display:contents">
{% csrf_token %}
<input type="hidden" name="username" value="{{ username }}"/>
{% if next %}<input type="hidden" name="next" value="{{ next }}"/>{% endif %}
<button type="submit" id="resend-btn" disabled>Gensend F-kode</button>
</form>
</div>
</form>

<div class="form-footer" style="margin-top:.75rem">
<a href="{% url 'sso_login' %}{% if next %}?next={{ next }}{% endif %}">← Indtast et andet brugernavn</a>
</div>

{% endif %}

<script>
(function () {
const grid = document.getElementById('otp-grid');
if (!grid) return;

const cells = Array.from(grid.querySelectorAll('.digit-cell'));
const combined = document.getElementById('otp-combined');
const form = document.getElementById('otp-form');

const sync = () => combined.value = 'F' + cells.map(c => c.value).join('');

cells.forEach((cell, i) => {
cell.addEventListener('input', () => {
cell.value = cell.value.replace(/\D/g, '').slice(-1);
sync();
if (cell.value && i < cells.length - 1) cells[i + 1].focus();
if (cells.every(c => c.value)) (form.requestSubmit ?? form.submit).call(form);
});

cell.addEventListener('keydown', e => {
if (e.key === 'Backspace' && !cell.value && i > 0) {
cells[i - 1].value = '';
cells[i - 1].focus();
sync();
}
if (e.key === 'ArrowLeft' && i > 0) cells[i - 1].focus();
if (e.key === 'ArrowRight' && i < cells.length - 1) cells[i + 1].focus();
});

cell.addEventListener('paste', e => {
e.preventDefault();
const digits = (e.clipboardData.getData('text')).replace(/\D/g, '').slice(0, 5);
digits.split('').forEach((d, j) => cells[j] && (cells[j].value = d));
cells[Math.min(digits.length, cells.length - 1)].focus();
sync();
if (digits.length === 5) (form.requestSubmit ?? form.submit).call(form);
});
});

const el = document.getElementById('countdown');
const btn = document.getElementById('resend-btn');
let s = 300;

const timer = setInterval(() => {
s--;
el.textContent = `${String(Math.floor(s/60)).padStart(2,'0')}:${String(s%60).padStart(2,'0')}`;
if (s <= 0) { clearInterval(timer); el.textContent = 'Udløbet'; btn.disabled = false; }
}, 1000);
})();
</script>
{% endblock %}
Loading
Loading