Skip to content

Commit

Permalink
Implemented Remember me for passwordless login (#4064)
Browse files Browse the repository at this point in the history
Fixes #4009. 

Implements a session extension for the magic link by
utilizing an extra `remember-me` URL param. Also adds a configuration
option to extend the session age to `SESSION_COOKIE_AGE_LONG` when OAuth
is utilized for the login.
  • Loading branch information
wes-otf authored Aug 16, 2024
1 parent aa73e2b commit d1725f5
Show file tree
Hide file tree
Showing 6 changed files with 114 additions and 13 deletions.
4 changes: 2 additions & 2 deletions docs/setup/administrators/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,9 @@ The age of session cookies, in seconds.

This determines the length of time for which the user will remain logged in. The default value is 12 hours.

SSESSION_COOKIE_AGE = env.int("SESSION_COOKIE_AGE", 60 * 60 * 12)
SESSION_COOKIE_AGE = env.int("SESSION_COOKIE_AGE", 60 * 60 * 12)

The age of session cookies when users check "Remember me" etc., in seconds. The default value is 2 weeks.
The age of session cookies when users login with OAuth or check "Remember me" etc., in seconds. The default value is 2 weeks.

SESSION_COOKIE_AGE_LONG = env.int("SESSION_COOKIE_AGE_LONG", 60 * 60 * 24 * 7 * 2)

Expand Down
10 changes: 10 additions & 0 deletions hypha/apply/users/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,16 @@ class PasswordlessAuthForm(forms.Form):
widget=forms.EmailInput(attrs={"autofocus": True, "autocomplete": "email"}),
)

if settings.SESSION_COOKIE_AGE <= settings.SESSION_COOKIE_AGE_LONG:
remember_me = forms.BooleanField(
label=_("Remember me"),
help_text=_(
"On trusted devices only, keeps you logged in for a longer period."
),
required=False,
widget=forms.CheckboxInput(),
)

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.request = kwargs.pop("request", None)
Expand Down
51 changes: 44 additions & 7 deletions hypha/apply/users/services.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import Optional

from django.conf import settings
from django.contrib.auth import get_user_model
from django.http import HttpRequest
Expand All @@ -22,9 +24,24 @@ class PasswordlessAuthService:

next_url = None

def __init__(self, request: HttpRequest, redirect_field_name: str = "next") -> None:
def __init__(
self,
request: HttpRequest,
redirect_field_name: Optional[str] = "next",
extended_session: Optional[bool] = False,
) -> None:
"""The service utilized to handle passwordless auth requests.
Determines if a user is logging in or signing up, and sends the appropriate magic links.
Args:
request: HttpRequest object.
redirect_field: The name of a field containing the redirect URL.
extended_session: Include the `remember-me` param in the magic link, defaults to False.
"""
self.redirect_field_name = redirect_field_name
self.next_url = get_redirect_url(request, self.redirect_field_name)
self.extended_session = extended_session
self.request = request
self.site = Site.find_for_request(request)

Expand All @@ -35,8 +52,8 @@ def _get_login_path(self, user):
"users:do_passwordless_login", kwargs={"uidb64": uid, "token": token}
)

if self.next_url:
login_path = f"{login_path}?next={self.next_url}"
if params := self._get_url_params():
login_path = f"{login_path}?{params}"

return login_path

Expand All @@ -48,11 +65,33 @@ def _get_signup_path(self, signup_obj):
"users:do_passwordless_signup", kwargs={"uidb64": uid, "token": token}
)

if self.next_url:
signup_path = f"{signup_path}?next={self.next_url}"
if params := self._get_url_params():
signup_path = f"{signup_path}?{params}"

return signup_path

def _get_url_params(self) -> None | str:
"""Gets a URL encoded string of params for the magic link
Populates the redirect url & remember me params if they exist
Returns:
A url encoded string if params exist, `None` otherwise.
"""
params = []

# Utilized this instead of QueryDict to allow `remember-me` to not need a value.
# Redundant to have a useless value (ie. `remember-me=1`) when the login view only checks for the key
if self.next_url:
params.append(f"next={self.next_url}")
if self.extended_session:
params.append("remember-me")

if params:
return "&".join(params)

return None

def get_email_context(self) -> dict:
return {
"org_long_name": settings.ORG_LONG_NAME,
Expand Down Expand Up @@ -135,8 +174,6 @@ def initiate_login_signup(self, email: str) -> None:
Args:
email: Email address to send the email to.
request: HttpRequest object.
next_url: URL to redirect to after login/signup. Defaults to None.
Returns:
None
Expand Down
45 changes: 43 additions & 2 deletions hypha/apply/users/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from django.contrib.sites.shortcuts import get_current_site
from django.core.exceptions import PermissionDenied
from django.core.signing import TimestampSigner, dumps, loads
from django.http import HttpResponse, HttpResponseRedirect
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import Http404, get_object_or_404, redirect, render
from django.template.loader import render_to_string
from django.template.response import TemplateResponse
Expand All @@ -29,6 +29,7 @@
from django.utils.http import urlsafe_base64_decode
from django.utils.translation import gettext_lazy as _
from django.views.decorators.cache import never_cache
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.debug import sensitive_post_parameters
from django.views.generic import UpdateView
from django.views.generic.base import TemplateView
Expand All @@ -40,6 +41,8 @@
from elevate.utils import grant_elevated_privileges
from elevate.views import redirect_to_elevate
from hijack.views import AcquireUserView
from social_django.utils import psa
from social_django.views import complete
from two_factor.forms import AuthenticationTokenForm, BackupTokenForm
from two_factor.utils import default_device, get_otpauth_url, totp_digits
from two_factor.views import BackupTokensView as TwoFactorBackupTokensView
Expand Down Expand Up @@ -583,7 +586,9 @@ def post(self, request):
form = self.get_form()
if form.is_valid():
service = PasswordlessAuthService(
request, redirect_field_name=self.redirect_field_name
request,
redirect_field_name=self.redirect_field_name,
extended_session=form.cleaned_data.get("remember_me", False),
)

email = form.cleaned_data["email"]
Expand Down Expand Up @@ -617,6 +622,10 @@ def get(self, request, uidb64, token, *args, **kwargs):
if user and self.check_token(user, token):
user.backend = settings.CUSTOM_AUTH_BACKEND

# Check for "?remember-me" query param, set the session age to long if exists
if "remember-me" in request.GET:
self.request.session.set_expiry(settings.SESSION_COOKIE_AGE_LONG)

if default_device(user):
# User has mfa, set the user details and redirect to 2fa login
self.storage.reset()
Expand Down Expand Up @@ -659,6 +668,10 @@ def get(self, request, *args, **kwargs):
user.save()
pending_signup.delete()

# Check for "?remember-me" query param, set the session age to long if exists
if "remember-me" in request.GET:
self.request.session.set_expiry(settings.SESSION_COOKIE_AGE_LONG)

user.backend = settings.CUSTOM_AUTH_BACKEND
login(request, user)

Expand Down Expand Up @@ -765,3 +778,31 @@ def set_password_view(request):
email_subject_template="users/emails/set_password_subject.txt",
)
return HttpResponse("✓ Check your email for password set link.")


@never_cache
@csrf_exempt
@psa(f"{settings.SOCIAL_AUTH_URL_NAMESPACE}:complete")
def oauth_complete(
request: HttpRequest, backend: str, *args, **kwargs
) -> HttpResponseRedirect:
"""View utilized after an OAuth login is successful.
This is utilized to extend the OAuth session age to the `SESSION_COOKIE_AGE_LONG`.
Args:
request:
The request with a custom `backend` attribute that is populated by the `social_django.utils.psa` decorator
backend:
String containing the backend being utilized
Returns:
A `HttpResponseRedirect` to bring the user to a landing page or the `next` URL.
"""
redirect = complete(request, backend, *args, **kwargs)

request.backend.strategy.request.session.set_expiry(
settings.SESSION_COOKIE_AGE_LONG
)

return redirect
1 change: 1 addition & 0 deletions hypha/core/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,5 @@ def markdown_to_html(text: str) -> str:
renderer="html",
plugins=["strikethrough", "footnotes", "table", "url"],
)

return md(text)
16 changes: 14 additions & 2 deletions hypha/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from hypha.apply.api import urls as api_urls
from hypha.apply.dashboard import urls as dashboard_urls
from hypha.apply.users.urls import urlpatterns as user_urls
from hypha.apply.users.views import become
from hypha.apply.users.views import become, oauth_complete
from hypha.apply.utils.views import custom_wagtail_page_delete

urlpatterns = [
Expand All @@ -35,7 +35,10 @@
path("dashboard/", include(dashboard_urls)),
path("sitemap.xml", sitemap),
path("upload/", include(django_file_form_urls)),
path("", include("social_django.urls", namespace="social")),
# path("complete/<str:backend>/", oauth_complete, name=f"{settings.SOCIAL_AUTH_URL_NAMESPACE}:complete"),
path(
"", include("social_django.urls", namespace=settings.SOCIAL_AUTH_URL_NAMESPACE)
),
path("", include(tf_urls, "two_factor")),
path("", include((user_urls, "users"))),
path("tinymce/", include("tinymce.urls")),
Expand Down Expand Up @@ -78,6 +81,15 @@
path("test500/", dj_default_views.server_error),
]

# Override the social auth `<SOCIAL_NAMESPACE>:complete` to allow for extending the OAuth session
urlpatterns = [
path(
"complete/<str:backend>/",
oauth_complete,
name=f"{settings.SOCIAL_AUTH_URL_NAMESPACE}:complete",
)
] + urlpatterns

urlpatterns += [
re_path(
r"^images/([^/]*)/(\d*)/([^/]*)/[^/]*$",
Expand Down

0 comments on commit d1725f5

Please sign in to comment.