FAQ | This is a LIVE service | Changelog

Skip to content
Commits on Source (37)
# Changelog
## [0.7.0](https://gitlab.developers.cam.ac.uk/uis/devops/iam/activate-account/api/compare/0.6.0...0.7.0) (2024-11-27)
### Features
* add `generate-schema` task to Poe configuration ([5e9325e](https://gitlab.developers.cam.ac.uk/uis/devops/iam/activate-account/api/commit/5e9325ee35ced508c5832c10dc4319425defe457))
* add custom MethodNotAllowedErrorSerializer ([412e468](https://gitlab.developers.cam.ac.uk/uis/devops/iam/activate-account/api/commit/412e4681b82ea5d078a63fffd0b83fa5b737eabf))
* add generated openapi.yaml ([b092c51](https://gitlab.developers.cam.ac.uk/uis/devops/iam/activate-account/api/commit/b092c513614af897a84e763cd3349791b6618ea0))
* add openapi-spec-validator ([11cc102](https://gitlab.developers.cam.ac.uk/uis/devops/iam/activate-account/api/commit/11cc102e1a6642f85b6ba1d97eaeb619c93fa233))
* add operation IDs to API endpoints for enhanced documentation clarity ([f9d6e61](https://gitlab.developers.cam.ac.uk/uis/devops/iam/activate-account/api/commit/f9d6e6112287a18455e33a3a0f355c498de76b24))
* add TokenResponseSerializer & extend_schema for LoginView ([64495fe](https://gitlab.developers.cam.ac.uk/uis/devops/iam/activate-account/api/commit/64495fe366c83da39a06388735a3445cf25f1e07))
* **api:** add `TokenAuthenticationExtension` for OpenAPI schema support ([28c8f40](https://gitlab.developers.cam.ac.uk/uis/devops/iam/activate-account/api/commit/28c8f404c4f74624ac8942e6683b7e9d5f62bc55))
* **api:** enhance AccountDetailsView with detailed OpenAPI response schemas ([fa5f170](https://gitlab.developers.cam.ac.uk/uis/devops/iam/activate-account/api/commit/fa5f1706ff2d948160c11ba577ab876963437e09))
* **auth:** add `EmptySerializer` to token management views ([1306986](https://gitlab.developers.cam.ac.uk/uis/devops/iam/activate-account/api/commit/13069865db8747ba5dab3d51a8415664d2a6a077))
* **authentication:** add TokenErrorSerializer for OAuth2 error handling ([8d36fb2](https://gitlab.developers.cam.ac.uk/uis/devops/iam/activate-account/api/commit/8d36fb2f11efe9ed69e706d745bfc92f3e95640e))
* **authentication:** enhance LogoutView & LogoutAllView with detailed OpenAPI response schemas ([df6038f](https://gitlab.developers.cam.ac.uk/uis/devops/iam/activate-account/api/commit/df6038f706065e1b2b402917c79c28b17488a170))
* **docs:** add OpenAPI specification generation instructions ([4307935](https://gitlab.developers.cam.ac.uk/uis/devops/iam/activate-account/api/commit/43079354c9c01805d88637a1d858fcd44487740e))
* **settings:** add version retrieval from `pyproject.toml` ([0b88af1](https://gitlab.developers.cam.ac.uk/uis/devops/iam/activate-account/api/commit/0b88af1ca72fe5ee4f5cf15aec9aaf47bbec1e86))
* update based on review ([6e41ad7](https://gitlab.developers.cam.ac.uk/uis/devops/iam/activate-account/api/commit/6e41ad76bf6f3325a5ba26636b62d32c691ccd74))
### Bug Fixes
* **api:** update version of api in path ([7dc591f](https://gitlab.developers.cam.ac.uk/uis/devops/iam/activate-account/api/commit/7dc591fc6de0026f43453c3ea612d8cbf7eb4ae4))
* **openapi:** set DEFAULT_VERSION to v1alpha1 to fix missing account endpoints in spec ([c546ab7](https://gitlab.developers.cam.ac.uk/uis/devops/iam/activate-account/api/commit/c546ab7a7e38145e8351d82ac22bcbe4472942ee))
* update description of 405 error ([72a9f43](https://gitlab.developers.cam.ac.uk/uis/devops/iam/activate-account/api/commit/72a9f4397e25e7012a6e9810f2911686141b5959))
* update formatting ([e08d42c](https://gitlab.developers.cam.ac.uk/uis/devops/iam/activate-account/api/commit/e08d42ce5e38b2139be768f8c120e2e38110bb3b))
* update isort formatting ([614a83c](https://gitlab.developers.cam.ac.uk/uis/devops/iam/activate-account/api/commit/614a83c25dd0ab2e6829c34f9d864fc0e7b963cd))
## [0.6.0](https://gitlab.developers.cam.ac.uk/uis/devops/iam/activate-account/api/compare/0.5.0...0.6.0) (2024-11-13)
### Features
......
......@@ -64,10 +64,6 @@ For a fuller description of how to debug follow the
The project is configured with Gitlab AutoDevOps via Gitlab CI using the .gitlab-ci.yml file.
## Copyright License
See the [LICENSE](LICENSE) file for details.
## Session authentication
In order to leverage existing tooling for OAuth2, it was decided that retrieving the access token
......@@ -134,3 +130,17 @@ Pragma: no-cache
```
To authenticate to other API endpoints, we would set the following header: `Authorization: Bearer abcdefg1234567`.
## Generating the OpenAPI Specification
To generate the OpenAPI specification for this project, you can use the configured Poetry task. Run the following command:
```bash
poetry poe generate-schema
```
This command utilises the `poe` task `generate-schema` to build and run the necessary Docker commands, generating the `openapi.yaml` file which contains the OpenAPI schema for the API.
## Copyright License
See the [LICENSE](LICENSE) file for details.
import functools
import tomllib
from pathlib import Path
@functools.cache
def get_version():
"""
Retrieves the project's version number from pyproject.toml.
This function is intended to ensure that the version is obtained from a
single source of truth (pyproject.toml), avoiding duplication.
Returns:
str: The version number as defined in pyproject.toml (e.g. "0.1.0").
Raises:
FileNotFoundError: If pyproject.toml cannot be found at the expected location.
"""
# Navigate to the pyproject.toml file located in the project root
pyproject_path = Path(__file__).resolve().parents[1] / "pyproject.toml"
# Open and parse the pyproject.toml file using tomllib
with pyproject_path.open("rb") as f:
pyproject_data = tomllib.load(f)
# Return the version number from the [tool.poetry] section
return pyproject_data["tool"]["poetry"]["version"]
# Define the __version__ attribute for the project
__version__ = get_version()
......@@ -7,6 +7,8 @@ import structlog
from api.versions import AVAILABLE_VERSIONS
from .._version import __version__
# By default, make use of connection pooling for the default database and use the Postgres engine.
DATABASES = {
"default": {
......@@ -160,8 +162,9 @@ REST_FRAMEWORK = {
"djangorestframework_camel_case.parser.CamelCaseFormParser",
"djangorestframework_camel_case.parser.CamelCaseMultiPartParser",
],
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning",
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.NamespaceVersioning",
"ALLOWED_VERSIONS": AVAILABLE_VERSIONS,
"DEFAULT_VERSION": "v1alpha1",
"EXCEPTION_HANDLER": "authentication.errors.exception_handler",
}
......@@ -181,13 +184,33 @@ REST_KNOX = {
SPECTACULAR_SETTINGS = {
"TITLE": "Activate Account",
"DESCRIPTION": "Activate Account API",
"VERSION": "1.0.0",
"VERSION": __version__,
"SERVE_INCLUDE_SCHEMA": False,
"CONTACT": {
"name": "UIS DevOps Division",
"url": "https://guidebook.devops.uis.cam.ac.uk/en/latest/",
"email": "devops@uis.cam.ac.uk",
},
"TAGS": [
{
"name": "authentication",
"description": "Create and manage user authentication tokens, including login and "
"logout functionalities.",
},
{
"name": "account management",
"description": "Endpoints related to retrieving and updating account details of the "
"logged-in user.",
},
{
"name": "api versioning",
"description": "List and manage different versions of the API, providing access points"
" to version-specific endpoints.",
},
],
"EXTENSIONS": [
("authentication.authentication.TokenAuthenticationExtension", "builtin"),
],
}
# Allow all origins to access API.
......
......@@ -16,7 +16,7 @@ Including another URLconf
from django.conf import settings
from django.http import HttpResponse
from django.urls import include, path, re_path
from django.urls import include, path
# Django debug toolbar is only installed in developer builds
try:
......@@ -39,10 +39,7 @@ urlpatterns = [
path("", include("api.urls")),
# Include the v1alpha urls - allowing for any release version, the version is validated
# by setting REST_FRAMEWORK.ALLOWED_VERSIONS in settings.base
re_path(
r"^(?P<version>v1alpha\d{1,3})/",
include("api.v1alpha.urls", "v1alpha"),
),
path("v1alpha1/", include(("api.v1alpha.urls", "v1alpha1"), namespace="v1alpha1")),
]
# Selectively enable django debug toolbar URLs. Only if the toolbar is
......
from io import StringIO
import pytest
import yaml
from django.core.management import call_command
from openapi_spec_validator import validate
from openapi_spec_validator.validation.exceptions import OpenAPIValidationError
def test_openapi_schema():
"""
Test OpenAPI schema generation and validate it against the OpenAPI specification.
"""
try:
with StringIO() as output:
# Run the Django management command that generates the OpenAPI schema
call_command("spectacular", stdout=output)
schema_data = yaml.safe_load(output.getvalue())
assert "openapi" in schema_data
assert "paths" in schema_data
validate(schema_data)
except OpenAPIValidationError as e:
pytest.fail(f"OpenAPI schema validation failed: {e}")
......@@ -4,6 +4,10 @@ from activate_account.models import Account, AccountDetails
class AccountSerializer(serializers.ModelSerializer):
"""
Provides account details, including name, affiliation, college, and terms acceptance status.
"""
name = serializers.CharField(source="account_details.name", default="")
affiliation = serializers.CharField(source="account_details.affiliation", default="")
college = serializers.CharField(source="account_details.college", default="")
......@@ -12,6 +16,21 @@ class AccountSerializer(serializers.ModelSerializer):
)
def update(self, instance, validated_data):
"""
Updates the terms_accepted status of an account's related AccountDetails instance.
Args:
instance (Account): The account instance to be updated.
validated_data (dict): The validated data for the update.
Returns:
Account: The updated account instance.
If terms_accepted is provided and true, this method ensures that the AccountDetails
instance is updated accordingly, marking terms as accepted.
If AccountDetails doesn't exist, it will be created.
"""
account_details_data = validated_data.get("account_details", {})
if account_details_data.get("terms_accepted"):
......@@ -22,6 +41,14 @@ class AccountSerializer(serializers.ModelSerializer):
return instance
class Meta:
"""
Meta class to define the model and fields to be used in the serializer.
model (Model): Specifies the Account model that this serializer relates to.
fields (tuple): Defines the fields to be included in the serialized output.
read_only_fields (tuple): Fields that should not be edited through the serializer.
"""
model = Account
fields = (
"crsid",
......@@ -40,3 +67,11 @@ class AccountSerializer(serializers.ModelSerializer):
"affiliation",
"college",
)
class MethodNotAllowedErrorSerializer(serializers.Serializer):
"""
Represents an error response when attempting an unsupported operation.
"""
detail = serializers.CharField()
......@@ -5,7 +5,7 @@ from rest_framework import status
@pytest.fixture
def url():
return reverse("v1alpha:account", kwargs={"version": "v1alpha1"})
return reverse("v1alpha1:account")
def test_account_details(authenticated_api_client, account, account_details, url):
......
......@@ -4,7 +4,6 @@ URL routing schema for API
"""
from django.urls import include, path
from drf_spectacular.views import SpectacularAPIView
from rest_framework import routers
from api.v1alpha.views import AccountDetailsView
......@@ -16,6 +15,4 @@ router = routers.DefaultRouter()
urlpatterns = [
path("", include(router.urls)),
path("account/", AccountDetailsView.as_view(), name="account"),
# OpenAPI 3 documentation with Swagger UI
path("schema/", SpectacularAPIView.as_view(), name="schema"),
]
......@@ -3,20 +3,55 @@ Views implementing the API endpoints.
"""
from drf_spectacular.utils import extend_schema
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework import generics
from api.v1alpha.serializers import AccountSerializer
from api.v1alpha.serializers import AccountSerializer, MethodNotAllowedErrorSerializer
@extend_schema(
tags=["Account details"],
summary="Retrieve or update account details of the current logged in account",
description="Endpoint that returns or updates the details of the current logged in account.",
)
class AccountDetailsView(generics.RetrieveUpdateAPIView):
serializer_class = AccountSerializer
http_method_names = ["get", "patch", "options", "head"]
def get_object(self):
return self.request.user
@extend_schema(
tags=["account management"],
summary="Retrieve Account Details",
description="Retrieves the account details of the currently logged-in user.",
responses={
200: OpenApiResponse(
response=AccountSerializer,
description="Successful retrieval of account details.",
),
},
methods=["GET"],
operation_id="getAccount",
)
def get(self, request, *args, **kwargs):
return super().get(request, *args, **kwargs)
@extend_schema(
tags=["account management"],
summary="Update Account Details",
description="Updates the account details of the currently logged-in user.",
responses={
200: OpenApiResponse(
response=AccountSerializer,
description="Successful update of account details.",
),
204: OpenApiResponse(
description="Successful update with no content to return "
"when updating certain fields."
),
405: OpenApiResponse(
response=MethodNotAllowedErrorSerializer,
description="Attempted to perform an unsupported operation.",
),
},
methods=["PATCH"],
operation_id="patchAccount",
)
def patch(self, request, *args, **kwargs):
return super().patch(request, *args, **kwargs)
......@@ -4,7 +4,7 @@ A view which exposes the available versions of the API.
"""
from django.urls.base import reverse
from drf_spectacular.utils import extend_schema, inline_serializer
from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer
from rest_framework import serializers
from rest_framework.response import Response
from rest_framework.views import APIView
......@@ -33,17 +33,22 @@ class VersionsView(APIView):
"Lists the available versions of the API, responding with a dictionary of version "
"name to the url that version can be accessed at"
),
tags=("API Versions",),
tags=["api versioning"],
responses={
200: inline_serializer(name="APIVersions", fields=build_version_response_schema())
200: OpenApiResponse(
response=inline_serializer(
name="APIVersions", fields=build_version_response_schema()
),
description="A dictionary of version name to the url "
"that version can be accessed at",
),
},
operation_id="getApiVersions",
)
def get(self, request):
return Response(
{
version: request.build_absolute_uri(
reverse(f"{app_name}:api-root", args=[version])
)
version: request.build_absolute_uri(reverse(f"{app_name}:api-root"))
for version, app_name in VERSIONS_TO_APP_NAME.items()
}
)
from django.utils import timezone
from drf_spectacular.extensions import OpenApiAuthenticationExtension
from knox.auth import TokenAuthentication as KnoxTokenAuthentication
from rest_framework import exceptions
class TokenAuthenticationExtension(OpenApiAuthenticationExtension):
target_class = "authentication.authentication.TokenAuthentication"
name = "TokenAuth"
def get_security_definition(self, auto_schema):
return {"type": "http", "scheme": "bearer"}
class TokenAuthentication(KnoxTokenAuthentication):
def validate_user(self, auth_token):
"""
......
......@@ -8,7 +8,21 @@ from authentication.errors import InvalidGrantError, UnsupportedGrantTypeError
logger = structlog.get_logger(__name__)
class TokenResponseSerializer(serializers.Serializer):
"""
Defines the structure of a successful token response.
"""
expires_in = serializers.IntegerField()
access_token = serializers.CharField()
token_type = serializers.CharField()
class TokenRequestSerializer(serializers.Serializer):
"""
Represents the data required to request a token.
"""
grant_type = serializers.CharField()
crsid = serializers.CharField(required=False, allow_blank=True)
last_name = serializers.CharField(required=False, allow_blank=True)
......@@ -16,13 +30,49 @@ class TokenRequestSerializer(serializers.Serializer):
code = serializers.CharField()
def validate_grant_type(self, grant_type):
"""
Validates the grant type provided in the token request.
This method checks if the provided grant type matches the SESSION_GRANT_TYPE. If not,
it raises an UnsupportedGrantTypeError.
Args:
grant_type (str): The grant type specified in the request.
Returns:
str: The validated grant type if it matches the expected type.
Raises:
UnsupportedGrantTypeError: If the provided grant type is not supported.
"""
if grant_type != SESSION_GRANT_TYPE:
raise UnsupportedGrantTypeError("Unsupported grant type")
return grant_type
def validate(self, data):
# Check that exactly one of crsid and last_name are present.
"""
Validates the entire data set of the token request.
This method performs checks to ensure that exactly one of the identification fields
(crsid or last name) is provided and that an account exists that matches the provided
credentials including the 'date_of_birth' and 'code'. If the validations pass, the account
object is included in the validated data returned.
Args:
data (dict): The complete data of the token request to be validated.
Returns:
dict: The original data dictionary with an added 'account' key that holds the Account
object.
Raises:
serializers.ValidationError: If both or neither of the identification fields (crsid,
last name) are provided.
InvalidGrantError: If no matching account is found based on the provided credentials.
"""
if "crsid" in data and "last_name" in data:
raise serializers.ValidationError("At most one of crsid and last name may be provided")
if "crsid" not in data and "last_name" not in data:
......@@ -54,3 +104,35 @@ class TokenRequestSerializer(serializers.Serializer):
data["account"] = account
return data
class TokenErrorSerializer(serializers.Serializer):
"""
OAuth2-compatible error response returned from the token endpoint when a token could not be
issued.
"""
error = serializers.ChoiceField(
choices=[
(
"unsupported_grant_type",
"The authorization grant type is not supported by " "the authorization server.",
),
(
"invalid_request",
"The request is missing a required parameter, includes "
"an unsupported parameter value or is otherwise malformed.",
),
("invalid_grant", "The provided credentials do not match any known user"),
]
)
error_description = serializers.CharField(required=False)
error_uri = serializers.URLField(required=False)
class EmptySerializer(serializers.Serializer):
"""
Defines an empty response.
"""
pass
......@@ -15,7 +15,7 @@ pytestmark = pytest.mark.django_db
@pytest.fixture
def authentication_test_url():
return reverse("v1alpha:account", kwargs={"version": "v1alpha1"})
return reverse("v1alpha1:account")
def test_unauthenticated_version_view(api_client, authentication_test_url):
......
from drf_spectacular.utils import OpenApiResponse, extend_schema
from knox.views import LoginView as KnoxLoginView
from rest_framework import parsers, renderers, serializers, status, views
from rest_framework.response import Response
from authentication.errors import OAuth2Error
from authentication.serializers import TokenRequestSerializer
from authentication.serializers import (
EmptySerializer,
TokenErrorSerializer,
TokenRequestSerializer,
TokenResponseSerializer,
)
class LoginView(KnoxLoginView):
......@@ -22,6 +28,23 @@ class LoginView(KnoxLoginView):
kwargs["context"] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)
@extend_schema(
summary="Authenticate a User",
description="Authenticates a user and returns an access token.",
request=TokenRequestSerializer,
responses={
200: TokenResponseSerializer,
400: OpenApiResponse(
response=TokenErrorSerializer,
description=(
"Invalid request, such as both or none of crsid and last name are provided, "
"or no matching user."
),
),
},
tags=["authentication"],
operation_id="getToken",
)
def post(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
......@@ -74,10 +97,28 @@ class LogoutView(views.APIView):
throttle_classes = ()
versioning_class = None
renderer_classes = (renderers.JSONRenderer,)
serializer_class = EmptySerializer
def get_post_response(self, request):
return Response(None, status=status.HTTP_204_NO_CONTENT)
@extend_schema(
tags=["authentication"],
summary="Log out User",
description="Logs out the current logged-in user by revoking their authentication token.",
responses={
204: OpenApiResponse(
response=EmptySerializer,
description="Successfully logged out, no content to return.",
),
401: OpenApiResponse(
response=TokenErrorSerializer,
description="Unauthorized request, possibly due to an invalid token.",
),
},
methods=["POST"],
operation_id="revokeTokenSession",
)
def post(self, request, format=None):
request._auth.delete()
return self.get_post_response(request)
......@@ -87,10 +128,29 @@ class LogoutAllView(views.APIView):
throttle_classes = ()
versioning_class = None
renderer_classes = (renderers.JSONRenderer,)
serializer_class = EmptySerializer
def get_post_response(self, request):
return Response(None, status=status.HTTP_204_NO_CONTENT)
@extend_schema(
tags=["authentication"],
summary="Log out from All Sessions",
description="Logs out the current logged-in user from all sessions by revoking all their "
"authentication tokens.",
responses={
204: OpenApiResponse(
response=EmptySerializer,
description="Successfully logged out from all sessions, no content to return.",
),
401: OpenApiResponse(
response=TokenErrorSerializer,
description="Unauthorized request, possibly due to an invalid token.",
),
},
methods=["POST"],
operation_id="revokeAllTokenSessions",
)
def post(self, request, format=None):
request.user.auth_token_set.all().delete()
return self.get_post_response(request)
This diff is collapsed.
[tool.poetry]
name = "activate_account"
version = "0.6.0"
version = "0.7.0"
description = ""
authors = [ ]
readme = "README.md"
......@@ -48,6 +48,8 @@ pytest-django = "^4.5.2"
pytest-docker-tools = "^3.1.3"
pytest-env = "^0.8.2"
tox = "4.14.1"
openapi-spec-validator = "^0.7.1"
pyyaml = "^6.0.2"
[tool.poetry.group.dev.dependencies.coverage]
extras = [ "toml" ]
......@@ -119,6 +121,10 @@ cmd = "tox"
help = "Run the Python test suite via pytest using the locally installed Python version"
cmd = "pytest"
[tool.poe.tasks.generate-schema]
help = "Generate OpenAPI schema using drf-spectacular"
cmd = "docker compose run --build --rm manage spectacular --fail-on-warn --file openapi.yaml"
[tool.pytest.ini_options]
env = [
"DJANGO_SETTINGS_MODULE=activate_account_project.settings.testing",
......