Skip to content

Commit

Permalink
API: User token (#953)
Browse files Browse the repository at this point in the history
* add description field

* add AccessTokenCreateView

* token reveal workflow

* do not allow API access with token of inactive user

* add password revokation view

* squash migration

* add user token tests

* expand documentation on API tokens

* expand documentation on API tokens

* self-review improvements

* some obvious review fixes

* seperate personal API tokens from OAuth grants.
  • Loading branch information
felixrindt authored May 28, 2023
1 parent b9d4aec commit 86dc616
Show file tree
Hide file tree
Showing 30 changed files with 809 additions and 193 deletions.
83 changes: 63 additions & 20 deletions docs/api/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,75 @@ ephios provides a REST-API built with Django-Rest-Framework.
Auth
----

You can authenticate against the API in two ways: session based or with an OAuth2 flow.
You can authenticate against the API using session based authentication or
tokens acquired using the OAuth2 flow or manually from the user settings.

Session based authentication
^^^^^^^^^^^^^^^^^^^^^^^^^^^^

A client can authenticate against the API using `session based authentication <https://www.django-rest-framework.org/api-guide/authentication/#sessionauthentication>`_.
This allows to visit API endpoints in the browser and use the API in a similar way as the web interface. Login is required and permissions are checked just like in the web interface.
This allows to visit API endpoints in the browser and use the API in a
similar way as the web interface. Login is required and permissions are
checked just like in the web interface.
This is the recommended way to access the API from interactive ephios
webpages (javascript/AJAX).

Token based authentication
^^^^^^^^^^^^^^^^^^^^^^^^^^

ephios acts as an OAuth2 provider which allows to obtain an API token that can be used to authenticate against the API.
To get started, you need to create an OAuth2 application under "OAuth2 applications" in the management section of the settins in your ephios instance.
Set the client type to confidential, the authorization grant type to "authorization-code" and the redirect uri to the url of your client application.
Note down client id and client secret. You can integrate the OAuth2 flow with a library in your preferred language: https://oauth.net/code/
Personal API tokens can be acquired from the user settings page and used in
custom applications or scripts to access the API.
When creating a token you should provide a helpful description as well as set
a fitting expiration date.

Set the following values in your client application:
ephios uses Scopes to restrict the validity of API tokens to those endpoints
that are needed by the application that uses the token. The scope should be
as narrow as possible to prevent abuse of the token.

The following scopes are available:

================== =========================================================
PUBLIC_READ Read public data like available events and shifts
PUBLIC_WRITE Write public data like available events and shifts
ME_READ Read own personal data and participations
ME_WRITE Write own personal data and participations
CONFIDENTIAL_READ Read confidential data like all users profile and participations
CONFIDENTIAL_WRITE Write confidential data like all users profile and participations
================== =========================================================

In your requests to the API the token must be presented in the
Authorization header like this:

``Authorization: Bearer <access token>``

For example, a curl request to the events endpoint would look like this:

``curl -X GET https://your-instance.ephios.de/api/events/ -H 'Authorization: Bearer gvRYPujOFh6iiLbT5Dw'``

Permissions are checked based on the tokens scope and the permissions of the
user that created the token. For example, if a token with the scope
``PUBLIC_READ`` is used, the token can be used to access the events endpoint,
but only events that are visible to that user will be returned.

.. note:: We plan on integrating API-Keys that are independent of users in the future.

ephios as an OAuth2 provider
^^^^^^^^^^^^^^^^^^^^^^^^^^^^

ephios can act as an OAuth2 provider which allows third-party applications to
obtain API tokens for its users. The API tokens acquired using the OAuth2 flow
function just like the manually created ones.

To get started, you need to create an OAuth2 application under
"OAuth2 applications" in the management section of the settings in your
ephios instance.
Set the client type to confidential, the authorization grant type
to "authorization-code" and the redirect uri to the url of your client
application.
Note down client id and client secret. You can integrate the OAuth2 flow
with a library in your preferred language: https://oauth.net/code/

Set the following values in your third-party application:

====================== =====================================================
CLIENT_ID client id you created in ephios
Expand All @@ -31,19 +83,10 @@ AUTHORIZATION_ENDPOINT https://your-ephios-instance.com/api/oauth/authorize/
TOKEN_ENDPOINT https://your-ephios-instance.com/api/oauth/token/
====================== =====================================================

You also need to specify which scopes you want to request. The following scopes are available:

================== =========================================================
PUBLIC_READ Read public data like events and shifts
PUBLIC_WRITE Write public data like events and shifts
ME_READ Read own user profile and personal data
ME_WRITE Write own user profile and personal data
CONFIDENTIAL_READ Read confidential data like participations and user data
CONFIDENTIAL_WRITE Write confidential data like participations and user data
================== =========================================================

With these values, you should be able to initiate the OAuth2 flow. After the user has authorized your application, you will receive an access token that you can use to authenticate against the API endpoints described below.
You must present the access token in the Authorization header of your requests like this: ``Authorization: Bearer <access token>``
With these values, you should be able to initiate the OAuth2 flow, where you
also need to specify which scope you want to request from the user.
After the user has authorized your application, you will receive an access token
that you can use to authenticate against the API endpoints described above.

Endpoints
---------
Expand Down
24 changes: 24 additions & 0 deletions ephios/api/access/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from oauth2_provider.contrib.rest_framework import OAuth2Authentication

from ephios.api.models import AccessToken
from ephios.core.models import UserProfile


class CustomOAuth2Authentication(OAuth2Authentication):
"""
Overwrites the default OAuth2Authentication to not allow inactive users to authenticate.
"""

def authenticate(self, request):
oauth_result = super().authenticate(request)
if oauth_result is None:
return None
user, token = oauth_result
if not user.is_active:
return None
return user, token


def revoke_all_access_tokens(user: UserProfile):
for access_token in AccessToken.objects.filter(user=user):
access_token.revoke_related()
9 changes: 9 additions & 0 deletions ephios/api/access/oauth2_urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from oauth2_provider.urls import base_urlpatterns

app_name = "oauth2_provider"

management_urlpatterns = [
# views to manage oauth2 applications are part of the API django app url config under the "API" namespace
]

urlpatterns = base_urlpatterns + management_urlpatterns
47 changes: 0 additions & 47 deletions ephios/api/access/urls.py

This file was deleted.

119 changes: 117 additions & 2 deletions ephios/api/access/views.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,124 @@
from django import forms
from django.contrib import messages
from django.core.exceptions import PermissionDenied
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse_lazy
from django.utils import timezone
from django.utils.crypto import get_random_string
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from django.views import View
from django.views.generic import CreateView, TemplateView
from guardian.mixins import LoginRequiredMixin
from oauth2_provider import views as oauth2_views
from oauth2_provider.models import get_application_model
from oauth2_provider.views import ApplicationList
from oauth2_provider.scopes import get_scopes_backend

from ephios.api.models import AccessToken
from ephios.extra.mixins import StaffRequiredMixin
from ephios.extra.widgets import CustomSplitDateTimeWidget


class AllUserApplicationList(StaffRequiredMixin, ApplicationList):
class AllUserApplicationList(StaffRequiredMixin, oauth2_views.ApplicationList):
def get_queryset(self):
return get_application_model().objects.all()


class ApplicationDelete(oauth2_views.ApplicationDelete):
success_url = reverse_lazy("api:settings-oauth-app-list")


class AccessTokensListView(oauth2_views.AuthorizedTokensListView):
template_name = "api/access_token_list.html"

context_object_name = "personal_access_tokens"

def get_queryset(self):
# personal API tokens
qs = super().get_queryset().filter(user=self.request.user, application__id=None)
return qs.order_by("-created")

def get_context_data(self, *, object_list=None, **kwargs):
context = super().get_context_data(object_list=object_list, **kwargs)
context["oauth_access_tokens"] = AccessToken.objects.filter(
user=self.request.user,
application_id__isnull=False,
)
return context


class TokenScopesChoiceField(forms.MultipleChoiceField):
def clean(self, value):
scopes_list = super().clean(value)
return " ".join(scopes_list)

def to_python(self, value):
# this should be the corresponding method to clean,
# but it is not called/tested
if isinstance(value, str):
return value.split(" ")
return value


class AccessTokenForm(forms.ModelForm):
description = forms.CharField(
widget=forms.TextInput(attrs={"placeholder": _("What is this token for?")}),
required=True,
)
scope = TokenScopesChoiceField(
choices=[
(scope, mark_safe(f"<code>{scope}</code>: {description}"))
for scope, description in get_scopes_backend().get_all_scopes().items()
],
widget=forms.CheckboxSelectMultiple,
help_text=_("For security reasons, only select the scopes that are actually needed."),
)
expires = forms.SplitDateTimeField(
widget=CustomSplitDateTimeWidget,
required=False,
)

class Meta:
model = AccessToken
fields = ["description", "scope", "expires"]


def generate_key():
return get_random_string(50)


class AccessTokenCreateView(LoginRequiredMixin, CreateView):
model = AccessToken
form_class = AccessTokenForm
template_name = "api/access_token_form.html"

def form_valid(self, form):
"""If the form is valid, save the associated model."""
token = form.save(commit=False)
token.user = self.request.user
token.token = generate_key()
token.save()
return redirect("api:settings-access-token-reveal", pk=token.pk)


class AccessTokenRevealView(LoginRequiredMixin, TemplateView):
template_name = "api/access_token_reveal.html"

def get(self, request, *args, **kwargs):
try:
token = AccessToken.objects.get(pk=self.kwargs["pk"], user=self.request.user)
except AccessToken.DoesNotExist:
raise PermissionDenied
if token.created < timezone.now() - timezone.timedelta(seconds=30):
messages.error(request, _("Token is too old to be revealed."))
return redirect("api:settings-access-token-list")
context = self.get_context_data(token=token, **kwargs)
return self.render_to_response(context)


class AccessTokenRevokeView(LoginRequiredMixin, View):
def post(self, request, *args, **kwargs):
# user can only revoke own tokens
get_object_or_404(AccessToken, pk=request.POST["pk"], user=request.user).revoke_related()
messages.success(request, _("Token was revoked."))
return redirect("api:settings-access-token-list")
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Generated by Django 4.1.7 on 2023-05-28 18:11

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("api", "0001_initial"),
]

operations = [
migrations.AddField(
model_name="accesstoken",
name="description",
field=models.CharField(blank=True, max_length=1000, verbose_name="Description"),
),
migrations.AddField(
model_name="accesstoken",
name="revoked",
field=models.BooleanField(null=True),
),
migrations.AlterField(
model_name="accesstoken",
name="expires",
field=models.DateTimeField(null=True),
),
]
Loading

0 comments on commit 86dc616

Please sign in to comment.