diff --git a/cl/assets/templates/oauth2_provider/authorize.html b/cl/assets/templates/oauth2_provider/authorize.html new file mode 100644 index 0000000000..392ebba902 --- /dev/null +++ b/cl/assets/templates/oauth2_provider/authorize.html @@ -0,0 +1,81 @@ +{% extends "base.html" %} + +{% block title %}{% if error %}Authorization error{% else %}Authorize {{ application.name }}{% endif %} – CourtListener.com{% endblock %} + +{% block privacy %} + +{% endblock %} + +{% block footer-scripts %}{% endblock %} +{% block sidebar %}{% endblock %} + +{% block content %} +
+
+ {% if not error %} +

Authorize {{ application.name }}?

+

+ Signed in as {{ request.user.username }}. +

+ +

+ The application {{ application.name }} is + requesting access to your CourtListener account. If you + approve, it will be able to: +

+ + + + {% if form.redirect_uri.value %} +

+ After you decide, you will be redirected to: +
+ {{ form.redirect_uri.value }} +

+ {% endif %} + + + + {% if form.errors %} +
+ {{ form.errors }} + {{ form.non_field_errors }} +
+ {% endif %} + +
{% csrf_token %} + {% for field in form %} + {% if field.is_hidden %}{{ field }}{% endif %} + {% endfor %} +
+ + +
+
+ {% else %} +

Something went wrong

+

We couldn’t complete this authorization request.

+

+ Error: {{ error.error }} + {% if error.description %} +
Details: {{ error.description }} + {% endif %} +

+

+ Try returning to the application that sent you here and + starting over. If the problem continues, please + contact us. +

+ {% endif %} +
+
+{% endblock %} diff --git a/cl/users/templates/includes/developer-tabs.html b/cl/users/templates/includes/developer-tabs.html index df15a3628e..3cc8c80d5d 100644 --- a/cl/users/templates/includes/developer-tabs.html +++ b/cl/users/templates/includes/developer-tabs.html @@ -17,5 +17,9 @@  Webhooks +
  • + +  Authorized Apps +
  • diff --git a/cl/users/templates/profile/oauth_authorizations.html b/cl/users/templates/profile/oauth_authorizations.html new file mode 100644 index 0000000000..fa6d6ae07e --- /dev/null +++ b/cl/users/templates/profile/oauth_authorizations.html @@ -0,0 +1,83 @@ +{% extends "profile/nav.html" %} +{% load humanize %} + +{% block title %}{{ page_title }} – CourtListener.com{% endblock %} + +{% block nav-api %}active{% endblock %} + +{% block content %} + {# Navigation Tabs #} + {% include "includes/developer-tabs.html" %} + + +
    +

    Authorized Apps

    +

    + Apps you have granted access to your CourtListener account. +

    + + {% if messages %} + {% for message in messages %} +

    {{ message|escape }}

    + {% endfor %} + {% endif %} + + {% if authorizations %} +
    + + + + + + + + + + + + {% for auth in authorizations %} + + + + + + + + {% endfor %} + +
    ApplicationPermissionsFirst grantedLast used
    + {{ auth.application.name }} + +
      + {% for scope in auth.scopes %} +
    • {{ scope }}
    • + {% endfor %} +
    +
    + {{ auth.first_granted|naturaltime }} + + {{ auth.last_used|naturaltime }} + +
    + {% csrf_token %} + + +
    +
    +
    + {% else %} +

    + You haven’t authorized any apps yet. +

    +

    + When you grant a third-party application access to your account + through the OAuth flow, it will appear here so you can review + or revoke its access at any time. +

    + {% endif %} +
    + +{% endblock %} diff --git a/cl/users/urls.py b/cl/users/urls.py index d791ba439a..01fa99c55f 100644 --- a/cl/users/urls.py +++ b/cl/users/urls.py @@ -120,6 +120,11 @@ path("profile/api-token/", views.view_api_token, name="view_api_token"), path("profile/api-usage/", views.view_api_usage, name="view_api_usage"), path("profile/webhooks/", views.view_webhooks, name="view_webhooks"), + path( + "profile/oauth-apps/", + views.view_oauth_authorizations, + name="view_oauth_authorizations", + ), path("profile/your-support/", view_donations, name="profile_your_support"), re_path( "profile/webhooks/(logs|test-logs)/", diff --git a/cl/users/views.py b/cl/users/views.py index c96f381237..c04c95b129 100644 --- a/cl/users/views.py +++ b/cl/users/views.py @@ -33,6 +33,10 @@ sensitive_variables, ) from django.views.decorators.http import require_http_methods +from oauth2_provider.models import ( + get_access_token_model, + get_refresh_token_model, +) from rest_framework.renderers import JSONRenderer from waffle import switch_is_active @@ -786,6 +790,73 @@ def view_webhooks(request: AuthenticatedHttpRequest) -> HttpResponse: ) +@login_required +@never_cache +@require_http_methods(["GET", "POST"]) +def view_oauth_authorizations( + request: AuthenticatedHttpRequest, +) -> HttpResponse: + """List the OAuth applications the user has granted access to.""" + AccessToken = get_access_token_model() + RefreshToken = get_refresh_token_model() + + if request.method == "POST": + app_id = request.POST.get("application_id") + if app_id: + AccessToken.objects.filter( + user=request.user, application_id=app_id + ).delete() + RefreshToken.objects.filter( + user=request.user, application_id=app_id + ).delete() + messages.success(request, "Access revoked.") + return HttpResponseRedirect(reverse("view_oauth_authorizations")) + + scopes_settings = settings.OAUTH2_PROVIDER.get("SCOPES", {}) + tokens = ( + AccessToken.objects.filter( + user=request.user, application__isnull=False + ) + .select_related("application") + .order_by("-updated") + ) + by_app: dict[int, dict] = {} + for tok in tokens: + entry = by_app.setdefault( + tok.application_id, + { + "application": tok.application, + "first_granted": tok.created, + "last_used": tok.updated, + "scope_names": set(), + }, + ) + entry["first_granted"] = min(entry["first_granted"], tok.created) + entry["last_used"] = max(entry["last_used"], tok.updated) + entry["scope_names"].update(tok.scope.split()) + + # One row per Application, aggregated across any access/refresh + # tokens the user holds for that application. + authorizations = [] + for entry in by_app.values(): + entry["scopes"] = [ + scopes_settings.get(s, s) for s in sorted(entry["scope_names"]) + ] + authorizations.append(entry) + authorizations.sort(key=lambda e: e["last_used"], reverse=True) + + return TemplateResponse( + request, + "profile/oauth_authorizations.html", + { + "private": True, + "page": "api_oauth", + "page_title": "Authorized Apps", + "authorizations": authorizations, + }, + ) + + @login_required @never_cache def view_webhook_logs(