From 84511437e601e85bf74f1c8fddf31b95aac54bfe Mon Sep 17 00:00:00 2001 From: Radek Czajka Date: Wed, 19 Nov 2025 14:04:09 +0100 Subject: [PATCH 1/1] Session transfer tokens. --- .../migrations/0009_sessiontransfertoken.py | 27 ++++++++++++ src/api/models.py | 27 +++++++++++- src/api/urls.py | 2 + src/api/views.py | 44 +++++++++++++++++-- 4 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 src/api/migrations/0009_sessiontransfertoken.py diff --git a/src/api/migrations/0009_sessiontransfertoken.py b/src/api/migrations/0009_sessiontransfertoken.py new file mode 100644 index 000000000..a277811aa --- /dev/null +++ b/src/api/migrations/0009_sessiontransfertoken.py @@ -0,0 +1,27 @@ +# Generated by Django 4.0.8 on 2025-11-19 13:00 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('api', '0008_alter_token_token_type'), + ] + + operations = [ + migrations.CreateModel( + name='SessionTransferToken', + fields=[ + ('token', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('expires_at', models.DateTimeField()), + ('used', models.BooleanField(default=False)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/src/api/models.py b/src/api/models.py index 4f0556561..eade03754 100644 --- a/src/api/models.py +++ b/src/api/models.py @@ -1,12 +1,14 @@ # This file is part of Wolne Lektury, licensed under GNU Affero GPLv3 or later. # Copyright © Fundacja Wolne Lektury. See NOTICE for more information. # +from datetime import timedelta +import uuid from django.conf import settings from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.db import models from django.db.models.signals import pre_delete - +from django.utils import timezone from catalogue.models import Book, Tag @@ -121,3 +123,26 @@ class Token(models.Model): def __str__(self): return "%s Token %s for %s" % (self.get_token_type_display(), self.key, self.consumer) + + +class SessionTransferToken(models.Model): + token = models.UUIDField(default=uuid.uuid4, primary_key=True, editable=False) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, models.CASCADE) + created_at = models.DateTimeField(auto_now_add=True) + expires_at = models.DateTimeField() + used = models.BooleanField(default=False) + + @classmethod + def create_for_user(cls, user, lifetime_seconds=30): + return cls.objects.create( + user=user, + expires_at=timezone.now() + timedelta(seconds=lifetime_seconds) + ) + + def is_valid(self): + if self.used: + return False + if timezone.now() > self.expires_at: + return False + return True diff --git a/src/api/urls.py b/src/api/urls.py index 558ed9c18..182e0dd6f 100644 --- a/src/api/urls.py +++ b/src/api/urls.py @@ -18,6 +18,8 @@ urlpatterns1 = [ path('deleteAccount/', views.DeleteAccountView.as_view()), path('password/', views.PasswordView.as_view()), path('password-reset/', views.ResetPasswordView.as_view()), + path("session-transfer/", views.SessionTransferTokenView.as_view()), + path("session-transfer/continue/", views.ConsumeSessionTransferTokenView.as_view(), name="api-session-continue"), path('', include('catalogue.api.urls2')), path('', include('social.api.urls2')), diff --git a/src/api/views.py b/src/api/views.py index e1ca2e250..86275aaf4 100644 --- a/src/api/views.py +++ b/src/api/views.py @@ -4,13 +4,13 @@ from time import time from allauth.account.forms import ResetPasswordForm from django.conf import settings -from django.contrib.auth import authenticate +from django.contrib.auth import authenticate, login from django.contrib.auth.decorators import login_required from django.contrib.auth.models import User from django import forms -from django.http import HttpResponse +from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden from django.http import Http404 -from django.shortcuts import render +from django.shortcuts import redirect, render from django.views.generic.base import View from oauthlib.common import urlencode, generate_token from oauthlib.oauth1 import RequestTokenEndpoint, AccessTokenEndpoint @@ -20,7 +20,7 @@ from rest_framework.response import Response from rest_framework.views import APIView from rest_framework.generics import GenericAPIView, RetrieveAPIView, get_object_or_404 from catalogue.models import Book -from .models import BookUserData, KEY_SIZE, SECRET_SIZE, Token +from .models import BookUserData, KEY_SIZE, SECRET_SIZE, Token, SessionTransferToken from social.models import UserConfirmation from . import serializers from .request_validator import PistonRequestValidator @@ -380,3 +380,39 @@ class ResetPasswordView(GenericAPIView): form.is_valid() form.save(request) return Response({}) + + +class SessionTransferTokenView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request): + ott = SessionTransferToken.create_for_user(request.user) + return Response({ + "token": str(ott.token) + }) + + +class ConsumeSessionTransferTokenView(View): + def get(self, request): + token_str = request.GET.get("token") + next_url = request.GET.get("next", "/") #TODO: validate + + if not token_str: + return HttpResponseBadRequest("Missing token") + + try: + ott = SessionTransferToken.objects.get(token=token_str) + except SessionTransferToken.DoesNotExist: + return HttpResponseBadRequest("Invalid token") + + if not ott.is_valid(): + return HttpResponseForbidden("Token expired or already used") + + # Mark token as used + ott.used = True + ott.save(update_fields=["used"]) + + # Log in the user via Django session + login(request, ott.user) + + return redirect(next_url) -- 2.20.1