-
Notifications
You must be signed in to change notification settings - Fork 52
Implement Member SSO (extracted from #617) #637
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: next
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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" |
| 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 | ||
| OTP_DURATION_MIN = 5 | ||
|
|
||
| def authenticate(self, request, username=None, otp=None, **kwargs): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. add types
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. what type is user_id? could be str, int, bytes or whatever?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
| 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", | ||
| ), | ||
| ), | ||
| ], | ||
| ), | ||
| ] |
| 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) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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> | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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> | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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‑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 %} | ||
There was a problem hiding this comment.
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?