From 4273c541c5cf4d4e35265fe61fc156524acdad30 Mon Sep 17 00:00:00 2001 From: obervinov Date: Wed, 24 Jun 2026 19:15:31 +0400 Subject: [PATCH] Add API tokens and OAuth2 client support for external integrations - Personal API tokens (model, user-settings UI, admin, management command, DRF auth class) for non-interactive API access from automations like n8n. Raw token shown once; only a SHA-256 hash is stored; last_used_at writes are throttled. - OAuth2 authorization server via django-oauth-toolkit with authorization server metadata and optional, off-by-default Dynamic Client Registration (RFC 7591), so remote OAuth/MCP clients can authenticate and self-register. - Tests for token auth, DCR gating and the management commands, plus .env.example and README documentation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 18 ++ README.md | 43 +++ app/WYGIWYH/settings.py | 35 +- app/WYGIWYH/urls.py | 15 + app/apps/api/authentication.py | 64 ++++ app/apps/api/tests/test_authentication.py | 109 +++++++ .../management/commands/create_api_token.py | 58 ++++ .../common/management/commands/setup_oauth.py | 129 ++++++++ app/apps/common/oauth_views.py | 253 +++++++++++++++ app/apps/common/tests/test_oauth.py | 298 ++++++++++++++++++ app/apps/users/admin.py | 38 ++- app/apps/users/forms.py | 48 +++ app/apps/users/migrations/0026_apitoken.py | 36 +++ app/apps/users/models.py | 119 ++++++- app/apps/users/tests.py | 45 +++ app/apps/users/urls.py | 10 + app/apps/users/views.py | 55 +++- app/templates/users/fragments/api_tokens.html | 101 ++++++ .../users/fragments/user_settings.html | 4 + docker/dev/django/start | 1 + docker/prod/django/start | 1 + pyproject.toml | 1 + uv.lock | 30 ++ 23 files changed, 1505 insertions(+), 6 deletions(-) create mode 100644 app/apps/api/authentication.py create mode 100644 app/apps/api/tests/test_authentication.py create mode 100644 app/apps/common/management/commands/create_api_token.py create mode 100644 app/apps/common/management/commands/setup_oauth.py create mode 100644 app/apps/common/oauth_views.py create mode 100644 app/apps/common/tests/test_oauth.py create mode 100644 app/apps/users/migrations/0026_apitoken.py create mode 100644 app/apps/users/tests.py create mode 100644 app/templates/users/fragments/api_tokens.html diff --git a/.env.example b/.env.example index d4f6da8..e43e85a 100644 --- a/.env.example +++ b/.env.example @@ -38,3 +38,21 @@ TASK_WORKERS=1 # This only work if you're using the single container option. Inc #OIDC_CLIENT_SECRET="" #OIDC_SERVER_URL="" #OIDC_ALLOW_SIGNUP=true + +# Personal access tokens. How often (seconds) a token's last_used_at is rewritten. +#API_TOKEN_LAST_USED_UPDATE_INTERVAL=600 + +# MCP OAuth Application. Uncomment to auto-create/update the OAuth client +# used by remote MCP integrations after migrations complete. +#MCP_OAUTH_CLIENT_NAME="WYGIWYH MCP" +#MCP_OAUTH_CLIENT_ID="mcp-wygiwyh" +#MCP_OAUTH_CLIENT_SECRET="" +#MCP_OAUTH_REDIRECT_URIS="http://127.0.0.1:8765/callback" +#MCP_OAUTH_SKIP_AUTHORIZATION=false + +# Dynamic Client Registration (RFC 7591). Disabled by default because an open +# registration endpoint lets anyone create OAuth applications. Enable only if +# remote MCP clients must self-register, and optionally require an initial +# access token (sent as "Authorization: Bearer " on /oauth/register/). +#OAUTH2_DCR_ENABLED=false +#OAUTH2_DCR_INITIAL_ACCESS_TOKEN="" diff --git a/README.md b/README.md index 49123e8..bcf5b80 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,49 @@ When configuring your OIDC provider, you will need to provide a callback URL (al Replace `https://your.wygiwyh.domain` with the actual URL where your WYGIWYH instance is accessible. And `` with the slugfied value set in OIDC_CLIENT_NAME or the default `openid-connect` if you haven't set this variable. +### API Tokens for n8n and other automations + +If you need a stable non-browser credential for automations such as `n8n`, WYGIWYH can also issue its own user-bound API tokens. This avoids Keycloak login flows and can be used directly against `/api/`. + +Create a token from the container or application shell: + +```bash +python manage.py create_api_token you@example.com --name n8n +``` + +Optional expiration: + +```bash +python manage.py create_api_token you@example.com --name n8n --expires-in-days 90 +``` + +The command prints the raw token **once**. Store it in your secret manager and use it like this: + +```bash +curl -H "Authorization: Token wygiwyh_pat_." \ + https://your.wygiwyh.domain/api/accounts/ +``` + +Recommended usage for automation is a dedicated WYGIWYH user such as `n8n@...`, so API ownership and audit trails stay separate from your interactive account. + +### MCP OAuth Application Bootstrap + +If you want WYGIWYH to act as the OAuth authorization server for a remote MCP server, you can let the container create or update the OAuth application automatically on startup. + +Set these environment variables: + +| Variable | Description | +|---|---| +| `MCP_OAUTH_CLIENT_NAME` | Optional display name for the OAuth client. Defaults to `WYGIWYH MCP`. | +| `MCP_OAUTH_CLIENT_ID` | Client ID that will be created or updated in `django-oauth-toolkit`. | +| `MCP_OAUTH_CLIENT_SECRET` | Client secret for that OAuth application. | +| `MCP_OAUTH_REDIRECT_URIS` | Space-separated redirect URIs allowed for the MCP OAuth client. | +| `MCP_OAUTH_SKIP_AUTHORIZATION` | Set to `true` to bypass the consent screen. Defaults to `false`. | + +When these variables are present, startup runs `python manage.py setup_oauth` after migrations and keeps the OAuth application in sync without needing a manual Django admin step. + +WYGIWYH also exposes OAuth Dynamic Client Registration at `/.well-known/oauth-authorization-server` via `registration_endpoint`, so MCP clients that support RFC 7591 can self-register instead of relying on a pre-created `MCP_OAUTH_CLIENT_ID` / `MCP_OAUTH_CLIENT_SECRET`. The current implementation supports `authorization_code` + PKCE clients using `none`, `client_secret_basic`, or `client_secret_post` token endpoint auth methods. + # How it works Check out our [Wiki](https://github.com/eitchtee/WYGIWYH/wiki) for more information. diff --git a/app/WYGIWYH/settings.py b/app/WYGIWYH/settings.py index 6598562..c829278 100644 --- a/app/WYGIWYH/settings.py +++ b/app/WYGIWYH/settings.py @@ -72,6 +72,7 @@ INSTALLED_APPS = [ "rest_framework", "rest_framework.authtoken", "drf_spectacular", + "oauth2_provider", "django_cotton", "apps.rules.apps.RulesConfig", "apps.calendar_view.apps.CalendarViewConfig", @@ -344,6 +345,11 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" LOGIN_REDIRECT_URL = "/" LOGIN_URL = "/login/" LOGOUT_REDIRECT_URL = "/login/" +# Public base URL advertised in OAuth metadata. Falls back to the first entry +# of the existing space-separated URL env var, then to the request host. +PUBLIC_BASE_URL = ( + os.getenv("PUBLIC_BASE_URL", "") or os.getenv("URL", "").split(" ")[0] +).rstrip("/") # Allauth settings AUTHENTICATION_BACKENDS = [ @@ -382,6 +388,12 @@ SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = True ACCOUNT_ADAPTER = "allauth.account.adapter.DefaultAccountAdapter" SOCIALACCOUNT_ADAPTER = "apps.users.adapters.AutoConnectSocialAccountAdapter" +# Personal access tokens. last_used_at is only rewritten once per interval to +# avoid a database write on every authenticated request. +API_TOKEN_LAST_USED_UPDATE_INTERVAL = int( + os.getenv("API_TOKEN_LAST_USED_UPDATE_INTERVAL", "600") +) + # CRISPY FORMS CRISPY_ALLOWED_TEMPLATE_PACKS = [ "crispy_forms/pure_text", @@ -446,14 +458,33 @@ REST_FRAMEWORK = { "rest_framework.filters.OrderingFilter", ], "DEFAULT_AUTHENTICATION_CLASSES": [ - "rest_framework.authentication.BasicAuthentication", - "rest_framework.authentication.SessionAuthentication", + "oauth2_provider.contrib.rest_framework.OAuth2Authentication", + "apps.api.authentication.APITokenAuthentication", "rest_framework.authentication.TokenAuthentication", + "rest_framework.authentication.SessionAuthentication", + "rest_framework.authentication.BasicAuthentication", ], "DEFAULT_PAGINATION_CLASS": "apps.api.custom.pagination.CustomPageNumberPagination", "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", } +OAUTH2_PROVIDER = { + "PKCE_REQUIRED": True, + "ACCESS_TOKEN_EXPIRE_SECONDS": int( + os.getenv("OAUTH2_ACCESS_TOKEN_EXPIRE_SECONDS", "3600") + ), + "SCOPES": { + "mcp": "Access WYGIWYH from MCP clients.", + }, +} + +# Dynamic Client Registration (RFC 7591). Disabled by default: an open +# registration endpoint lets anyone create OAuth applications. Enable it only +# when remote MCP clients must self-register, and optionally require an initial +# access token presented as `Authorization: Bearer `. +OAUTH2_DCR_ENABLED = os.getenv("OAUTH2_DCR_ENABLED", "false").lower() == "true" +OAUTH2_DCR_INITIAL_ACCESS_TOKEN = os.getenv("OAUTH2_DCR_INITIAL_ACCESS_TOKEN", "") + SPECTACULAR_SETTINGS = { "TITLE": "WYGIWYH API", "DESCRIPTION": "A no-frills expense tracker", diff --git a/app/WYGIWYH/urls.py b/app/WYGIWYH/urls.py index f0294ee..d417260 100644 --- a/app/WYGIWYH/urls.py +++ b/app/WYGIWYH/urls.py @@ -22,6 +22,10 @@ from drf_spectacular.views import ( SpectacularSwaggerView, ) from allauth.socialaccount.providers.openid_connect.views import login, callback +from apps.common.oauth_views import ( + authorization_server_metadata, + dynamic_client_registration, +) urlpatterns = [ @@ -39,6 +43,17 @@ urlpatterns = [ name="swagger-ui", ), path("auth/", include("allauth.urls")), # allauth urls + path("oauth/", include("oauth2_provider.urls", namespace="oauth2_provider")), + path( + ".well-known/oauth-authorization-server", + authorization_server_metadata, + name="oauth-authorization-server-metadata", + ), + path( + "oauth/register/", + dynamic_client_registration, + name="oauth-dynamic-client-registration", + ), # path("auth/oidc//login/", login, name="openid_connect_login"), # path( # "auth/oidc//login/callback/", diff --git a/app/apps/api/authentication.py b/app/apps/api/authentication.py new file mode 100644 index 0000000..10ec79f --- /dev/null +++ b/app/apps/api/authentication.py @@ -0,0 +1,64 @@ +from datetime import timedelta + +from django.conf import settings +from django.utils import timezone +from rest_framework.authentication import BaseAuthentication, get_authorization_header +from rest_framework.exceptions import AuthenticationFailed + +from apps.users.models import APIToken + + +class APITokenAuthentication(BaseAuthentication): + keyword = "Token" + + def authenticate(self, request): + auth = get_authorization_header(request).split() + if not auth or auth[0].lower() != self.keyword.lower().encode(): + return None + + if len(auth) != 2: + raise AuthenticationFailed("Invalid API token header.") + + try: + raw_token = auth[1].decode("utf-8") + except UnicodeDecodeError as exc: + raise AuthenticationFailed("Invalid API token header.") from exc + + # Only claim tokens carrying our prefix; otherwise return None so the + # request falls through to other authenticators (e.g. DRF's built-in + # TokenAuthentication, which shares the "Token" keyword). + if not raw_token.startswith(APIToken.TOKEN_PREFIX): + return None + + try: + token_key, token_secret = APIToken.parse_raw_token(raw_token) + except ValueError as exc: + raise AuthenticationFailed("Invalid API token.") from exc + + token = APIToken.objects.select_related("user").filter(token_key=token_key).first() + if token is None or not token.check_secret(token_secret): + raise AuthenticationFailed("Invalid API token.") + if token.revoked_at is not None: + raise AuthenticationFailed("API token has been revoked.") + if token.is_expired(): + raise AuthenticationFailed("API token has expired.") + if not token.user.is_active: + raise AuthenticationFailed("User account is disabled.") + + self._touch_last_used(token) + return (token.user, token) + + @staticmethod + def _touch_last_used(token): + # Avoid a write on every request: only refresh once per interval. + now = timezone.now() + interval = settings.API_TOKEN_LAST_USED_UPDATE_INTERVAL + if ( + token.last_used_at is None + or (now - token.last_used_at) >= timedelta(seconds=interval) + ): + token.last_used_at = now + token.save(update_fields=["last_used_at"]) + + def authenticate_header(self, request): + return self.keyword diff --git a/app/apps/api/tests/test_authentication.py b/app/apps/api/tests/test_authentication.py new file mode 100644 index 0000000..161cc5f --- /dev/null +++ b/app/apps/api/tests/test_authentication.py @@ -0,0 +1,109 @@ +from datetime import timedelta + +from django.contrib.auth import get_user_model +from django.test import RequestFactory, TestCase, override_settings +from django.utils import timezone +from rest_framework.exceptions import AuthenticationFailed + +from apps.api.authentication import APITokenAuthentication +from apps.users.models import APIToken + + +class APITokenAuthenticationTests(TestCase): + def setUp(self): + self.factory = RequestFactory() + self.authentication = APITokenAuthentication() + self.user = get_user_model().objects.create_user( + email="automation@example.com", + password="test-password", + ) + + def test_returns_none_without_token_header(self): + request = self.factory.get("/api/accounts/") + self.assertIsNone(self.authentication.authenticate(request)) + + def test_authenticates_valid_api_token(self): + token, raw_token = APIToken.objects.create_token(user=self.user, name="n8n") + request = self.factory.get( + "/api/accounts/", + HTTP_AUTHORIZATION=f"Token {raw_token}", + ) + + authenticated_user, authenticated_token = self.authentication.authenticate(request) + + self.assertEqual(authenticated_user, self.user) + self.assertEqual(authenticated_token.pk, token.pk) + token.refresh_from_db() + self.assertIsNotNone(token.last_used_at) + + def test_rejects_expired_api_token(self): + token, raw_token = APIToken.objects.create_token(user=self.user, name="n8n") + token.expires_at = timezone.now() - timedelta(minutes=1) + token.save(update_fields=["expires_at"]) + request = self.factory.get( + "/api/accounts/", + HTTP_AUTHORIZATION=f"Token {raw_token}", + ) + + with self.assertRaisesRegex(AuthenticationFailed, "expired"): + self.authentication.authenticate(request) + + def test_rejects_revoked_api_token(self): + token, raw_token = APIToken.objects.create_token(user=self.user, name="n8n") + token.revoked_at = timezone.now() + token.save(update_fields=["revoked_at"]) + request = self.factory.get( + "/api/accounts/", + HTTP_AUTHORIZATION=f"Token {raw_token}", + ) + + with self.assertRaisesRegex(AuthenticationFailed, "revoked"): + self.authentication.authenticate(request) + + def test_stores_secret_as_sha256_not_raw(self): + token, raw_token = APIToken.objects.create_token(user=self.user, name="n8n") + _key, secret = APIToken.parse_raw_token(raw_token) + + self.assertNotIn(secret, token.token_hash) + self.assertEqual(len(token.token_hash), 64) + self.assertTrue(token.check_secret(secret)) + + def test_falls_through_for_non_prefixed_token(self): + request = self.factory.get( + "/api/accounts/", + HTTP_AUTHORIZATION="Token deadbeefdeadbeefdeadbeef", + ) + # Not our prefix: return None so another authenticator can handle it. + self.assertIsNone(self.authentication.authenticate(request)) + + @override_settings(API_TOKEN_LAST_USED_UPDATE_INTERVAL=600) + def test_last_used_at_is_throttled_within_interval(self): + token, raw_token = APIToken.objects.create_token(user=self.user, name="n8n") + request = self.factory.get( + "/api/accounts/", + HTTP_AUTHORIZATION=f"Token {raw_token}", + ) + + self.authentication.authenticate(request) + token.refresh_from_db() + first_used = token.last_used_at + self.assertIsNotNone(first_used) + + self.authentication.authenticate(request) + token.refresh_from_db() + self.assertEqual(token.last_used_at, first_used) + + @override_settings(API_TOKEN_LAST_USED_UPDATE_INTERVAL=0) + def test_last_used_at_updates_after_interval(self): + token, raw_token = APIToken.objects.create_token(user=self.user, name="n8n") + token.last_used_at = timezone.now() - timedelta(minutes=5) + token.save(update_fields=["last_used_at"]) + stale = token.last_used_at + request = self.factory.get( + "/api/accounts/", + HTTP_AUTHORIZATION=f"Token {raw_token}", + ) + + self.authentication.authenticate(request) + token.refresh_from_db() + self.assertGreater(token.last_used_at, stale) diff --git a/app/apps/common/management/commands/create_api_token.py b/app/apps/common/management/commands/create_api_token.py new file mode 100644 index 0000000..70c31ce --- /dev/null +++ b/app/apps/common/management/commands/create_api_token.py @@ -0,0 +1,58 @@ +from datetime import timedelta + +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand, CommandError +from django.utils import timezone + +from apps.users.models import APIToken + + +class Command(BaseCommand): + help = "Creates a hashed API token for a WYGIWYH user and prints the raw token once." + + def add_arguments(self, parser): + parser.add_argument("email", help="WYGIWYH user email that will own this token.") + parser.add_argument( + "--name", + default="n8n", + help="Human-readable token name. Defaults to 'n8n'.", + ) + parser.add_argument( + "--expires-in-days", + type=int, + default=None, + help="Optional token lifetime in whole days.", + ) + + def handle(self, *args, **options): + email = options["email"].strip() + name = options["name"].strip() + expires_in_days = options["expires_in_days"] + + if not email: + raise CommandError("Email is required.") + if not name: + raise CommandError("Token name cannot be empty.") + if expires_in_days is not None and expires_in_days <= 0: + raise CommandError("--expires-in-days must be greater than zero.") + + user = get_user_model().objects.filter(email__iexact=email).first() + if user is None: + raise CommandError(f"No WYGIWYH user exists for '{email}'.") + + expires_at = None + if expires_in_days is not None: + expires_at = timezone.now() + timedelta(days=expires_in_days) + + token, raw_token = APIToken.objects.create_token( + user=user, + name=name, + expires_at=expires_at, + ) + + self.stdout.write( + self.style.SUCCESS( + f"Created API token '{token.name}' for {user.email} ({token.token_key})." + ) + ) + self.stdout.write(raw_token) diff --git a/app/apps/common/management/commands/setup_oauth.py b/app/apps/common/management/commands/setup_oauth.py new file mode 100644 index 0000000..8140a59 --- /dev/null +++ b/app/apps/common/management/commands/setup_oauth.py @@ -0,0 +1,129 @@ +import os + +from django.contrib.auth.hashers import check_password +from django.core.exceptions import ValidationError +from django.core.management.base import BaseCommand, CommandError +from oauth2_provider.models import get_application_model + + +Application = get_application_model() + + +def _get_env(name: str) -> str: + return os.getenv(name, "").strip() + + +def _get_bool_env(name: str, default: bool = False) -> bool: + raw = _get_env(name) + if not raw: + return default + return raw.lower() in {"1", "true", "yes", "on"} + + +class Command(BaseCommand): + help = ( + "Creates or updates the OAuth application used by MCP clients when " + "MCP_OAUTH_CLIENT_* environment variables are configured." + ) + + def handle(self, *args, **options): + client_id = _get_env("MCP_OAUTH_CLIENT_ID") + client_secret = _get_env("MCP_OAUTH_CLIENT_SECRET") + redirect_uris = " ".join(_get_env("MCP_OAUTH_REDIRECT_URIS").split()) + name = _get_env("MCP_OAUTH_CLIENT_NAME") or "WYGIWYH MCP" + skip_authorization = _get_bool_env("MCP_OAUTH_SKIP_AUTHORIZATION", default=False) + + if not any([client_id, client_secret, redirect_uris]): + self.stdout.write( + self.style.NOTICE( + "MCP OAuth client env vars are not set. Skipping OAuth application setup." + ) + ) + return + + missing = [] + if not client_id: + missing.append("MCP_OAUTH_CLIENT_ID") + if not client_secret: + missing.append("MCP_OAUTH_CLIENT_SECRET") + if not redirect_uris: + missing.append("MCP_OAUTH_REDIRECT_URIS") + if missing: + raise CommandError( + "Missing required MCP OAuth settings: " + ", ".join(missing) + ) + + application, created = Application.objects.get_or_create( + client_id=client_id, + defaults={ + "name": name, + "client_type": Application.CLIENT_CONFIDENTIAL, + "authorization_grant_type": Application.GRANT_AUTHORIZATION_CODE, + "redirect_uris": redirect_uris, + "skip_authorization": skip_authorization, + "client_secret": client_secret, + "hash_client_secret": True, + }, + ) + + updated_fields = [] + if application.name != name: + application.name = name + updated_fields.append("name") + if application.client_type != Application.CLIENT_CONFIDENTIAL: + application.client_type = Application.CLIENT_CONFIDENTIAL + updated_fields.append("client_type") + if ( + application.authorization_grant_type + != Application.GRANT_AUTHORIZATION_CODE + ): + application.authorization_grant_type = Application.GRANT_AUTHORIZATION_CODE + updated_fields.append("authorization_grant_type") + if application.redirect_uris != redirect_uris: + application.redirect_uris = redirect_uris + updated_fields.append("redirect_uris") + if application.skip_authorization != skip_authorization: + application.skip_authorization = skip_authorization + updated_fields.append("skip_authorization") + if application.hash_client_secret is not True: + application.hash_client_secret = True + updated_fields.append("hash_client_secret") + if not application.client_secret or not check_password( + client_secret, + application.client_secret, + ): + application.client_secret = client_secret + updated_fields.append("client_secret") + + try: + application.full_clean() + except ValidationError as exc: + errors = "; ".join( + f"{field}: {', '.join(messages)}" + for field, messages in exc.message_dict.items() + ) + raise CommandError(f"Invalid MCP OAuth application settings: {errors}") from exc + + if created: + application.save() + self.stdout.write( + self.style.SUCCESS( + f"Created MCP OAuth application '{application.client_id}'." + ) + ) + return + + if updated_fields: + application.save(update_fields=updated_fields) + self.stdout.write( + self.style.SUCCESS( + f"Updated MCP OAuth application '{application.client_id}'." + ) + ) + return + + self.stdout.write( + self.style.SUCCESS( + f"MCP OAuth application '{application.client_id}' is already up to date." + ) + ) diff --git a/app/apps/common/oauth_views.py b/app/apps/common/oauth_views.py new file mode 100644 index 0000000..5eda6a0 --- /dev/null +++ b/app/apps/common/oauth_views.py @@ -0,0 +1,253 @@ +import hmac +import json +import time +from secrets import token_urlsafe + +from django.conf import settings +from django.core.exceptions import ValidationError +from django.http import JsonResponse +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_http_methods +from oauth2_provider.models import get_application_model + + +Application = get_application_model() + +SUPPORTED_TOKEN_ENDPOINT_AUTH_METHODS = { + "none": Application.CLIENT_PUBLIC, + "client_secret_basic": Application.CLIENT_CONFIDENTIAL, + "client_secret_post": Application.CLIENT_CONFIDENTIAL, +} +SUPPORTED_GRANT_TYPES = {"authorization_code", "refresh_token"} +SUPPORTED_RESPONSE_TYPES = {"code"} + + +def _base_url(request): + return settings.PUBLIC_BASE_URL or request.build_absolute_uri("/").rstrip("/") + + +def _json_error(error, error_description, status=400): + response = JsonResponse( + {"error": error, "error_description": error_description}, + status=status, + ) + response["Cache-Control"] = "no-store" + response["Pragma"] = "no-cache" + return response + + +def _set_no_store_headers(response): + response["Cache-Control"] = "no-store" + response["Pragma"] = "no-cache" + return response + + +def _parse_json_request_body(request): + try: + payload = json.loads(request.body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ValueError("Request body must be valid JSON.") from exc + + if not isinstance(payload, dict): + raise ValueError("Request body must be a JSON object.") + + return payload + + +def _get_string_list(payload, field_name, *, required=False, default=None): + value = payload.get(field_name, default) + if value is None: + if required: + raise ValueError(f"'{field_name}' is required.") + return None + + if not isinstance(value, list) or not value: + raise ValueError(f"'{field_name}' must be a non-empty array of strings.") + + normalized = [] + for item in value: + if not isinstance(item, str) or not item.strip(): + raise ValueError(f"'{field_name}' must contain only non-empty strings.") + normalized.append(item.strip()) + return normalized + + +def _get_supported_scopes(): + return set(settings.OAUTH2_PROVIDER.get("SCOPES", {}).keys()) + + +def _dcr_initial_access_token_ok(request): + """Validate the optional RFC 7591 initial access token, if one is configured.""" + expected = settings.OAUTH2_DCR_INITIAL_ACCESS_TOKEN + if not expected: + return True + + header = request.META.get("HTTP_AUTHORIZATION", "") + scheme, _, value = header.partition(" ") + if scheme.lower() != "bearer" or not value: + return False + return hmac.compare_digest(value, expected) + + +@require_http_methods(["GET"]) +def authorization_server_metadata(request): + base_url = _base_url(request) + metadata = { + "issuer": base_url, + "authorization_endpoint": f"{base_url}/oauth/authorize/", + "token_endpoint": f"{base_url}/oauth/token/", + "revocation_endpoint": f"{base_url}/oauth/revoke_token/", + "introspection_endpoint": f"{base_url}/oauth/introspect/", + "scopes_supported": sorted(settings.OAUTH2_PROVIDER["SCOPES"].keys()), + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code", "refresh_token"], + "token_endpoint_auth_methods_supported": [ + "none", + "client_secret_basic", + "client_secret_post", + ], + "code_challenge_methods_supported": ["S256"], + } + # Only advertise registration when DCR is actually enabled. + if settings.OAUTH2_DCR_ENABLED: + metadata["registration_endpoint"] = f"{base_url}/oauth/register/" + return JsonResponse(metadata) + + +@csrf_exempt +@require_http_methods(["POST"]) +def dynamic_client_registration(request): + if not settings.OAUTH2_DCR_ENABLED: + return _json_error( + "not_found", + "Dynamic client registration is disabled.", + status=404, + ) + + if not _dcr_initial_access_token_ok(request): + return _json_error( + "invalid_token", + "A valid initial access token is required to register a client.", + status=401, + ) + + try: + payload = _parse_json_request_body(request) + redirect_uris = _get_string_list(payload, "redirect_uris", required=True) + grant_types = _get_string_list( + payload, + "grant_types", + default=["authorization_code"], + ) + response_types = _get_string_list( + payload, + "response_types", + default=["code"], + ) + except ValueError as exc: + return _json_error("invalid_client_metadata", str(exc)) + + unsupported_grant_types = sorted(set(grant_types) - SUPPORTED_GRANT_TYPES) + if unsupported_grant_types: + return _json_error( + "invalid_client_metadata", + "Unsupported grant_types: " + ", ".join(unsupported_grant_types), + ) + + if "authorization_code" not in grant_types: + return _json_error( + "invalid_client_metadata", + "grant_types must include 'authorization_code'.", + ) + + unsupported_response_types = sorted(set(response_types) - SUPPORTED_RESPONSE_TYPES) + if unsupported_response_types: + return _json_error( + "invalid_client_metadata", + "Unsupported response_types: " + + ", ".join(unsupported_response_types), + ) + + if "code" not in response_types: + return _json_error( + "invalid_client_metadata", + "response_types must include 'code'.", + ) + + token_endpoint_auth_method = payload.get( + "token_endpoint_auth_method", + "client_secret_basic", + ) + if token_endpoint_auth_method not in SUPPORTED_TOKEN_ENDPOINT_AUTH_METHODS: + return _json_error( + "invalid_client_metadata", + "Unsupported token_endpoint_auth_method: " + + token_endpoint_auth_method, + ) + + supported_scopes = _get_supported_scopes() + raw_scope = payload.get("scope", "mcp") + if not isinstance(raw_scope, str): + return _json_error( + "invalid_client_metadata", + "'scope' must be a space-delimited string.", + ) + requested_scope = raw_scope.strip() or "mcp" + requested_scopes = set(requested_scope.split()) + unsupported_scopes = sorted(requested_scopes - supported_scopes) + if unsupported_scopes: + return _json_error( + "invalid_client_metadata", + "Unsupported scope values: " + ", ".join(unsupported_scopes), + ) + + client_name = str(payload.get("client_name", "Dynamic MCP Client")).strip() + if not client_name: + client_name = "Dynamic MCP Client" + + client_secret = None + client_type = SUPPORTED_TOKEN_ENDPOINT_AUTH_METHODS[token_endpoint_auth_method] + if client_type == Application.CLIENT_CONFIDENTIAL: + client_secret = token_urlsafe(48) + + application = Application( + name=client_name, + client_type=client_type, + authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, + redirect_uris=" ".join(redirect_uris), + skip_authorization=False, + hash_client_secret=True, + client_secret=client_secret or "", + ) + + try: + application.full_clean() + except ValidationError as exc: + errors = [] + for field, messages in exc.message_dict.items(): + errors.extend(f"{field}: {message}" for message in messages) + return _json_error( + "invalid_client_metadata", + "; ".join(errors), + ) + + application.save() + + response_payload = { + "client_id": application.client_id, + "client_id_issued_at": int(time.time()), + "client_name": client_name, + "redirect_uris": redirect_uris, + # Report what was actually provisioned, not the raw request echo. The app + # is created with the authorization_code grant; refresh_token is implicit + # to that grant in django-oauth-toolkit rather than a separate capability. + "grant_types": sorted(set(grant_types) & SUPPORTED_GRANT_TYPES), + "response_types": sorted(set(response_types) & SUPPORTED_RESPONSE_TYPES), + "scope": " ".join(sorted(requested_scopes)), + "token_endpoint_auth_method": token_endpoint_auth_method, + } + if client_secret is not None: + response_payload["client_secret"] = client_secret + response_payload["client_secret_expires_at"] = 0 + + return _set_no_store_headers(JsonResponse(response_payload, status=201)) diff --git a/app/apps/common/tests/test_oauth.py b/app/apps/common/tests/test_oauth.py new file mode 100644 index 0000000..b6d7edd --- /dev/null +++ b/app/apps/common/tests/test_oauth.py @@ -0,0 +1,298 @@ +import os +import json +from io import StringIO +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.contrib.auth.hashers import check_password +from django.core.management import call_command +from django.test import SimpleTestCase, TestCase, override_settings +from django.utils import timezone +from django.urls import reverse +from oauth2_provider.models import get_application_model + +from apps.users.models import APIToken + +Application = get_application_model() + + +@override_settings( + PUBLIC_BASE_URL="https://wygiwyh.example.com", + SECRET_KEY="test-secret-key", + OAUTH2_PROVIDER={"SCOPES": {"mcp": "Access WYGIWYH from MCP clients."}}, +) +class AuthorizationServerMetadataTests(SimpleTestCase): + @override_settings(OAUTH2_DCR_ENABLED=True) + def test_returns_oauth_authorization_server_metadata(self): + response = self.client.get(reverse("oauth-authorization-server-metadata")) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["issuer"], "https://wygiwyh.example.com") + self.assertEqual( + response.json()["authorization_endpoint"], + "https://wygiwyh.example.com/oauth/authorize/", + ) + self.assertEqual( + response.json()["registration_endpoint"], + "https://wygiwyh.example.com/oauth/register/", + ) + self.assertEqual(response.json()["scopes_supported"], ["mcp"]) + self.assertIn("none", response.json()["token_endpoint_auth_methods_supported"]) + + @override_settings(OAUTH2_DCR_ENABLED=False) + def test_omits_registration_endpoint_when_dcr_disabled(self): + response = self.client.get(reverse("oauth-authorization-server-metadata")) + + self.assertEqual(response.status_code, 200) + self.assertNotIn("registration_endpoint", response.json()) + + +@override_settings( + PUBLIC_BASE_URL="https://wygiwyh.example.com", + SECRET_KEY="test-secret-key", + OAUTH2_PROVIDER={"SCOPES": {"mcp": "Access WYGIWYH from MCP clients."}}, + OAUTH2_DCR_ENABLED=True, + OAUTH2_DCR_INITIAL_ACCESS_TOKEN="", +) +class DynamicClientRegistrationTests(TestCase): + def test_registers_public_client_for_pkce_flow(self): + response = self.client.post( + reverse("oauth-dynamic-client-registration"), + data=json.dumps( + { + "client_name": "Copilot MCP", + "redirect_uris": ["http://127.0.0.1:8765/callback"], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "scope": "mcp", + "token_endpoint_auth_method": "none", + } + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, 201) + payload = response.json() + self.assertEqual(payload["client_name"], "Copilot MCP") + self.assertEqual( + payload["redirect_uris"], + ["http://127.0.0.1:8765/callback"], + ) + self.assertEqual( + payload["grant_types"], + ["authorization_code", "refresh_token"], + ) + self.assertEqual(payload["response_types"], ["code"]) + self.assertEqual(payload["scope"], "mcp") + self.assertEqual(payload["token_endpoint_auth_method"], "none") + self.assertNotIn("client_secret", payload) + + application = Application.objects.get(client_id=payload["client_id"]) + self.assertEqual(application.name, "Copilot MCP") + self.assertEqual(application.client_type, Application.CLIENT_PUBLIC) + self.assertEqual( + application.authorization_grant_type, + Application.GRANT_AUTHORIZATION_CODE, + ) + self.assertEqual( + application.redirect_uris, + "http://127.0.0.1:8765/callback", + ) + + def test_registers_confidential_client_with_generated_secret(self): + response = self.client.post( + reverse("oauth-dynamic-client-registration"), + data=json.dumps( + { + "client_name": "Confidential MCP", + "redirect_uris": ["http://127.0.0.1:8765/callback"], + "token_endpoint_auth_method": "client_secret_basic", + } + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, 201) + payload = response.json() + self.assertEqual(payload["token_endpoint_auth_method"], "client_secret_basic") + self.assertEqual(payload["scope"], "mcp") + self.assertEqual(payload["client_secret_expires_at"], 0) + self.assertTrue(payload["client_secret"]) + + application = Application.objects.get(client_id=payload["client_id"]) + self.assertEqual(application.client_type, Application.CLIENT_CONFIDENTIAL) + self.assertTrue(check_password(payload["client_secret"], application.client_secret)) + + def test_rejects_unsupported_token_auth_method(self): + response = self.client.post( + reverse("oauth-dynamic-client-registration"), + data=json.dumps( + { + "redirect_uris": ["http://127.0.0.1:8765/callback"], + "token_endpoint_auth_method": "private_key_jwt", + } + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["error"], "invalid_client_metadata") + self.assertIn("token_endpoint_auth_method", response.json()["error_description"]) + + def test_rejects_missing_redirect_uris(self): + response = self.client.post( + reverse("oauth-dynamic-client-registration"), + data=json.dumps({"client_name": "No redirect"}), + content_type="application/json", + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["error"], "invalid_client_metadata") + self.assertIn("redirect_uris", response.json()["error_description"]) + + @override_settings(OAUTH2_DCR_ENABLED=False) + def test_returns_404_when_dcr_disabled(self): + response = self.client.post( + reverse("oauth-dynamic-client-registration"), + data=json.dumps({"redirect_uris": ["http://127.0.0.1:8765/callback"]}), + content_type="application/json", + ) + + self.assertEqual(response.status_code, 404) + self.assertEqual(Application.objects.count(), 0) + + +@override_settings( + PUBLIC_BASE_URL="https://wygiwyh.example.com", + SECRET_KEY="test-secret-key", + OAUTH2_PROVIDER={"SCOPES": {"mcp": "Access WYGIWYH from MCP clients."}}, + OAUTH2_DCR_ENABLED=True, + OAUTH2_DCR_INITIAL_ACCESS_TOKEN="s3cret-iat", +) +class DynamicClientRegistrationInitialAccessTokenTests(TestCase): + def test_rejects_registration_without_initial_access_token(self): + response = self.client.post( + reverse("oauth-dynamic-client-registration"), + data=json.dumps({"redirect_uris": ["http://127.0.0.1:8765/callback"]}), + content_type="application/json", + ) + + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json()["error"], "invalid_token") + self.assertEqual(Application.objects.count(), 0) + + def test_allows_registration_with_initial_access_token(self): + response = self.client.post( + reverse("oauth-dynamic-client-registration"), + data=json.dumps( + { + "redirect_uris": ["http://127.0.0.1:8765/callback"], + "token_endpoint_auth_method": "none", + } + ), + content_type="application/json", + HTTP_AUTHORIZATION="Bearer s3cret-iat", + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual(Application.objects.count(), 1) + + +class SetupOAuthCommandTests(TestCase): + @patch.dict( + os.environ, + { + "MCP_OAUTH_CLIENT_ID": "mcp-wygiwyh", + "MCP_OAUTH_CLIENT_SECRET": "super-secret", + "MCP_OAUTH_REDIRECT_URIS": "http://127.0.0.1:8765/callback", + }, + clear=False, + ) + def test_creates_mcp_oauth_application(self): + call_command("setup_oauth") + + application = Application.objects.get(client_id="mcp-wygiwyh") + self.assertEqual(application.name, "WYGIWYH MCP") + self.assertEqual(application.client_type, Application.CLIENT_CONFIDENTIAL) + self.assertEqual( + application.authorization_grant_type, + Application.GRANT_AUTHORIZATION_CODE, + ) + self.assertEqual( + application.redirect_uris, + "http://127.0.0.1:8765/callback", + ) + self.assertFalse(application.skip_authorization) + self.assertTrue(check_password("super-secret", application.client_secret)) + + @patch.dict( + os.environ, + { + "MCP_OAUTH_CLIENT_ID": "mcp-wygiwyh", + "MCP_OAUTH_CLIENT_SECRET": "new-secret", + "MCP_OAUTH_REDIRECT_URIS": "http://127.0.0.1:8765/callback http://localhost:8765/callback", + "MCP_OAUTH_CLIENT_NAME": "WYGIWYH MCP Production", + "MCP_OAUTH_SKIP_AUTHORIZATION": "true", + }, + clear=False, + ) + def test_updates_existing_mcp_oauth_application(self): + Application.objects.create( + client_id="mcp-wygiwyh", + client_secret="old-secret", + name="Old Name", + client_type=Application.CLIENT_CONFIDENTIAL, + authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, + redirect_uris="http://127.0.0.1:8765/callback", + skip_authorization=False, + ) + + call_command("setup_oauth") + + application = Application.objects.get(client_id="mcp-wygiwyh") + self.assertEqual(application.name, "WYGIWYH MCP Production") + self.assertEqual( + application.redirect_uris, + "http://127.0.0.1:8765/callback http://localhost:8765/callback", + ) + self.assertTrue(application.skip_authorization) + self.assertTrue(check_password("new-secret", application.client_secret)) + + +class CreateAPITokenCommandTests(TestCase): + def setUp(self): + self.user = get_user_model().objects.create_user( + email="n8n@example.com", + password="test-password", + ) + + def test_creates_hashed_api_token_and_prints_raw_value(self): + stdout = StringIO() + + call_command( + "create_api_token", + self.user.email, + "--name", + "n8n sync", + stdout=stdout, + ) + + token = APIToken.objects.get(user=self.user, name="n8n sync") + lines = [line.strip() for line in stdout.getvalue().splitlines() if line.strip()] + raw_token = lines[-1] + + self.assertTrue(raw_token.startswith(APIToken.TOKEN_PREFIX)) + self.assertNotEqual(token.token_hash, raw_token) + self.assertTrue(token.check_secret(APIToken.parse_raw_token(raw_token)[1])) + + def test_supports_expiring_tokens(self): + call_command( + "create_api_token", + self.user.email, + "--expires-in-days", + "7", + ) + + token = APIToken.objects.get(user=self.user) + self.assertIsNotNone(token.expires_at) + self.assertGreater(token.expires_at, timezone.now()) diff --git a/app/apps/users/admin.py b/app/apps/users/admin.py index fbb929a..28e45b1 100644 --- a/app/apps/users/admin.py +++ b/app/apps/users/admin.py @@ -4,13 +4,19 @@ from django.contrib.auth.forms import ( UserCreationForm, AdminPasswordChangeForm, ) +from django.utils import timezone from django.utils.translation import gettext_lazy as _ from django.contrib import admin from django.contrib.auth.admin import GroupAdmin as BaseGroupAdmin from django.contrib.auth.admin import UserAdmin as BaseUserAdmin from django.contrib.auth.models import Group -from apps.users.models import User, UserSettings +from apps.users.models import APIToken, User, UserSettings + + +@admin.action(description=_("Revoke selected API tokens")) +def revoke_api_tokens(modeladmin, request, queryset): + queryset.update(revoked_at=timezone.now()) admin.site.unregister(Group) @@ -77,3 +83,33 @@ class GroupAdmin(BaseGroupAdmin, ModelAdmin): admin.site.register(UserSettings) + + +@admin.register(APIToken) +class APITokenAdmin(admin.ModelAdmin): + actions = [revoke_api_tokens] + list_display = ( + "name", + "user", + "token_key", + "created_at", + "last_used_at", + "expires_at", + "revoked_at", + ) + search_fields = ("name", "user__email", "token_key") + # Never expose the secret hash in the form; it must not be editable. + exclude = ("token_hash",) + readonly_fields = ( + "user", + "name", + "token_key", + "created_at", + "updated_at", + "last_used_at", + "expires_at", + "revoked_at", + ) + + def has_add_permission(self, request): + return False diff --git a/app/apps/users/forms.py b/app/apps/users/forms.py index a2b06e9..1080210 100644 --- a/app/apps/users/forms.py +++ b/app/apps/users/forms.py @@ -1,4 +1,7 @@ +from datetime import timedelta + from apps.common.middleware.thread_local import get_current_user +from apps.users.models import APIToken from apps.common.widgets.crispy.submit import NoClassSubmit from apps.common.widgets.tom_select import TomSelect from apps.users.models import UserSettings @@ -16,6 +19,7 @@ from django.contrib.auth.forms import ( UsernameField, ) from django.db import transaction +from django.utils import timezone from django.utils.translation import gettext_lazy as _ @@ -427,3 +431,47 @@ class UserAddForm(UserCreationForm): if commit: user.save() return user + + +class APITokenCreateForm(forms.Form): + name = forms.CharField( + max_length=255, + label=_("Token name"), + help_text=_("Use a descriptive name such as n8n, Home Assistant, or backup job."), + ) + expires_in_days = forms.IntegerField( + required=False, + min_value=1, + label=_("Expires in days"), + help_text=_("Leave empty for a non-expiring token."), + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.helper = FormHelper() + self.helper.form_tag = False + self.helper.form_method = "post" + self.helper.layout = Layout( + "name", + "expires_in_days", + FormActions( + NoClassSubmit( + "submit", + _("Create token"), + css_class="btn btn-outline-primary w-full", + ), + ), + ) + + def save(self, user): + expires_in_days = self.cleaned_data.get("expires_in_days") + expires_at = None + if expires_in_days: + expires_at = timezone.now() + timedelta(days=expires_in_days) + + return APIToken.objects.create_token( + user=user, + name=self.cleaned_data["name"], + expires_at=expires_at, + ) diff --git a/app/apps/users/migrations/0026_apitoken.py b/app/apps/users/migrations/0026_apitoken.py new file mode 100644 index 0000000..0809d42 --- /dev/null +++ b/app/apps/users/migrations/0026_apitoken.py @@ -0,0 +1,36 @@ +# Generated by Django 5.2.15 on 2026-06-24 09:21 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0025_alter_usersettings_default_account'), + ] + + operations = [ + migrations.CreateModel( + name='APIToken', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, verbose_name='Name')), + ('token_key', models.CharField(db_index=True, max_length=16, unique=True, verbose_name='Token key')), + ('token_hash', models.CharField(max_length=255, verbose_name='Token hash')), + ('last_used_at', models.DateTimeField(blank=True, null=True, verbose_name='Last used at')), + ('expires_at', models.DateTimeField(blank=True, null=True, verbose_name='Expires at')), + ('revoked_at', models.DateTimeField(blank=True, null=True, verbose_name='Revoked at')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_tokens', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'verbose_name': 'API token', + 'verbose_name_plural': 'API tokens', + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['user', 'revoked_at'], name='users_apito_user_id_73edec_idx'), models.Index(fields=['expires_at'], name='users_apito_expires_2b737c_idx')], + }, + ), + ] diff --git a/app/apps/users/models.py b/app/apps/users/models.py index d1e4b32..e00ade6 100644 --- a/app/apps/users/models.py +++ b/app/apps/users/models.py @@ -1,9 +1,14 @@ +import hashlib +import hmac +import secrets + import pytz from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.models import AbstractUser, Group from django.core.validators import MaxValueValidator, MinValueValidator -from django.db import models +from django.db import IntegrityError, models, transaction +from django.utils import timezone from django.utils.translation import gettext_lazy as _ from apps.users.managers import UserManager @@ -524,3 +529,115 @@ class UserSettings(models.Model): def clean(self): super().clean() + + +class APITokenManager(models.Manager): + def create_token(self, *, user, name: str, expires_at=None): + token_secret = secrets.token_urlsafe(32) + token_hash = self.model.hash_secret(token_secret) + + # token_key is unique; the pre-check in generate_token_key still leaves a + # tiny race window under concurrency, so retry on the unique-constraint + # violation with a fresh key instead of failing the request. + last_error = None + for _ in range(5): + token = self.model( + user=user, + name=name, + token_key=self.model.generate_token_key(), + token_hash=token_hash, + expires_at=expires_at, + ) + token.full_clean() + try: + with transaction.atomic(): + token.save() + except IntegrityError as exc: + last_error = exc + continue + return token, token.build_raw_token(token_secret) + + raise last_error + + +class APIToken(models.Model): + TOKEN_PREFIX = "wygiwyh_pat_" + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="api_tokens", + verbose_name=_("User"), + ) + name = models.CharField(max_length=255, verbose_name=_("Name")) + token_key = models.CharField( + max_length=16, + unique=True, + db_index=True, + verbose_name=_("Token key"), + ) + token_hash = models.CharField(max_length=255, verbose_name=_("Token hash")) + last_used_at = models.DateTimeField( + null=True, + blank=True, + verbose_name=_("Last used at"), + ) + expires_at = models.DateTimeField( + null=True, + blank=True, + verbose_name=_("Expires at"), + ) + revoked_at = models.DateTimeField( + null=True, + blank=True, + verbose_name=_("Revoked at"), + ) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated at")) + + objects = APITokenManager() + + class Meta: + indexes = [ + models.Index(fields=["user", "revoked_at"]), + models.Index(fields=["expires_at"]), + ] + ordering = ["-created_at"] + verbose_name = _("API token") + verbose_name_plural = _("API tokens") + + def __str__(self): + return f"{self.user} / {self.name}" + + @classmethod + def generate_token_key(cls) -> str: + while True: + candidate = secrets.token_hex(8) + if not cls.objects.filter(token_key=candidate).exists(): + return candidate + + @classmethod + def parse_raw_token(cls, raw_token: str): + if not raw_token.startswith(cls.TOKEN_PREFIX): + raise ValueError("Token is missing the expected prefix.") + + payload = raw_token.removeprefix(cls.TOKEN_PREFIX) + token_key, separator, token_secret = payload.partition(".") + if not separator or not token_key or not token_secret: + raise ValueError("Token is malformed.") + return token_key, token_secret + + def build_raw_token(self, token_secret: str) -> str: + return f"{self.TOKEN_PREFIX}{self.token_key}.{token_secret}" + + @staticmethod + def hash_secret(token_secret: str) -> str: + # The secret is a 256-bit random value (secrets.token_urlsafe(32)), so a + # single SHA-256 is sufficient and avoids a slow KDF on every request. + return hashlib.sha256(token_secret.encode("utf-8")).hexdigest() + + def check_secret(self, raw_secret: str) -> bool: + return hmac.compare_digest(self.token_hash, self.hash_secret(raw_secret)) + + def is_expired(self) -> bool: + return self.expires_at is not None and self.expires_at <= timezone.now() diff --git a/app/apps/users/tests.py b/app/apps/users/tests.py new file mode 100644 index 0000000..ed80bfc --- /dev/null +++ b/app/apps/users/tests.py @@ -0,0 +1,45 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse + +from apps.users.models import APIToken + + +class UserAPITokenViewsTests(TestCase): + def setUp(self): + self.user = get_user_model().objects.create_user( + email="user@example.com", + password="test-password", + ) + self.client.force_login(self.user) + self.htmx_headers = {"HTTP_HX_REQUEST": "true"} + + def test_user_settings_renders_api_token_section(self): + response = self.client.get(reverse("user_settings"), **self.htmx_headers) + + self.assertContains(response, "API Tokens") + self.assertContains(response, reverse("user_api_token_add")) + + def test_can_create_api_token_from_ui(self): + response = self.client.post( + reverse("user_api_token_add"), + {"name": "n8n", "expires_in_days": "30"}, + **self.htmx_headers, + ) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Copy this token now") + self.assertEqual(APIToken.objects.filter(user=self.user, name="n8n").count(), 1) + + def test_can_revoke_own_api_token(self): + token, _ = APIToken.objects.create_token(user=self.user, name="n8n") + + response = self.client.delete( + reverse("user_api_token_revoke", kwargs={"token_id": token.id}), + **self.htmx_headers, + ) + + self.assertEqual(response.status_code, 200) + token.refresh_from_db() + self.assertIsNotNone(token.revoked_at) + self.assertContains(response, "Revoked") diff --git a/app/apps/users/urls.py b/app/apps/users/urls.py index b98c743..4cb4aaa 100644 --- a/app/apps/users/urls.py +++ b/app/apps/users/urls.py @@ -32,6 +32,16 @@ urlpatterns = [ views.update_settings, name="user_settings", ), + path( + "user/api-tokens/add/", + views.api_token_add, + name="user_api_token_add", + ), + path( + "user/api-tokens//revoke/", + views.api_token_revoke, + name="user_api_token_revoke", + ), path( "users/", views.users_index, diff --git a/app/apps/users/views.py b/app/apps/users/views.py index c7bf625..a29ace4 100644 --- a/app/apps/users/views.py +++ b/app/apps/users/views.py @@ -2,12 +2,13 @@ from apps.common.decorators.demo import disabled_on_demo from apps.common.decorators.htmx import only_htmx from apps.common.decorators.user import htmx_login_required, is_superuser from apps.users.forms import ( + APITokenCreateForm, LoginForm, UserAddForm, UserSettingsForm, UserUpdateForm, ) -from apps.users.models import UserSettings +from apps.users.models import APIToken, UserSettings from django.contrib import messages from django.contrib.auth import get_user_model, logout from django.contrib.auth.decorators import login_required @@ -18,6 +19,7 @@ from django.core.exceptions import PermissionDenied from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse +from django.utils import timezone from django.utils.translation import gettext_lazy as _ from django.views.decorators.http import require_http_methods @@ -112,7 +114,56 @@ def update_settings(request): else: form = UserSettingsForm(instance=user_settings) - return render(request, "users/fragments/user_settings.html", {"form": form}) + return render( + request, + "users/fragments/user_settings.html", + { + "form": form, + "api_token_form": APITokenCreateForm(), + "api_tokens": request.user.api_tokens.all(), + }, + ) + + +def _render_api_tokens(request, *, form=None, raw_token=None): + return render( + request, + "users/fragments/api_tokens.html", + { + "api_token_form": form or APITokenCreateForm(), + "api_tokens": request.user.api_tokens.all(), + "raw_token": raw_token, + }, + ) + + +@only_htmx +@htmx_login_required +@require_http_methods(["POST"]) +def api_token_add(request): + form = APITokenCreateForm(request.POST) + if form.is_valid(): + _token, raw_token = form.save(user=request.user) + messages.success(request, _("API token created successfully")) + return _render_api_tokens( + request, + form=APITokenCreateForm(), + raw_token=raw_token, + ) + + return _render_api_tokens(request, form=form) + + +@only_htmx +@htmx_login_required +@require_http_methods(["DELETE"]) +def api_token_revoke(request, token_id): + token = get_object_or_404(APIToken, id=token_id, user=request.user) + if token.revoked_at is None: + token.revoked_at = timezone.now() + token.save(update_fields=["revoked_at"]) + messages.success(request, _("API token revoked successfully")) + return _render_api_tokens(request) @only_htmx diff --git a/app/templates/users/fragments/api_tokens.html b/app/templates/users/fragments/api_tokens.html new file mode 100644 index 0000000..9f08263 --- /dev/null +++ b/app/templates/users/fragments/api_tokens.html @@ -0,0 +1,101 @@ +{% load crispy_forms_tags %} +{% load i18n %} + +
+
{% translate "API Tokens" %}
+

+ {% translate "Use these tokens for automations such as n8n. The raw token is shown only once after creation." %} +

+
+ +{% if raw_token %} + +{% endif %} + +
+ {% crispy api_token_form %} +
+ +{% if api_tokens %} +
+ + + {% for token in api_tokens %} + + + + + {% endfor %} + +
+
+ {{ token.name }} + {% if token.revoked_at %} + {% translate "Revoked" %} + {% else %} + {% translate "Active" %} + {% endif %} +
+
{{ token.token_key }}
+
+ {% blocktranslate with created=token.created_at|date:"Y-m-d" %}Created {{ created }}{% endblocktranslate %} + · + {% if token.last_used_at %} + {% blocktranslate with used=token.last_used_at|date:"Y-m-d H:i" %}last used {{ used }}{% endblocktranslate %} + {% else %} + {% translate "never used" %} + {% endif %} + · + {% if token.expires_at %} + {% blocktranslate with expires=token.expires_at|date:"Y-m-d" %}expires {{ expires }}{% endblocktranslate %} + {% else %} + {% translate "no expiry" %} + {% endif %} +
+
+ {% if not token.revoked_at %} + + + + {% endif %} +
+
+{% else %} +
+ +
+{% endif %} diff --git a/app/templates/users/fragments/user_settings.html b/app/templates/users/fragments/user_settings.html index 26e5102..58ed58c 100644 --- a/app/templates/users/fragments/user_settings.html +++ b/app/templates/users/fragments/user_settings.html @@ -8,4 +8,8 @@
{% crispy form %}
+
+
+ {% include "users/fragments/api_tokens.html" %} +
{% endblock %} diff --git a/docker/dev/django/start b/docker/dev/django/start index 80d4bc3..9b03c88 100644 --- a/docker/dev/django/start +++ b/docker/dev/django/start @@ -15,5 +15,6 @@ python manage.py migrate touch /tmp/migrations_complete python manage.py setup_users +python manage.py setup_oauth exec python manage.py runserver 0.0.0.0:$INTERNAL_PORT diff --git a/docker/prod/django/start b/docker/prod/django/start index 2eca2dc..71f0f0c 100644 --- a/docker/prod/django/start +++ b/docker/prod/django/start @@ -16,5 +16,6 @@ python manage.py migrate touch /tmp/migrations_complete python manage.py setup_users +python manage.py setup_oauth exec gunicorn WYGIWYH.wsgi:application --bind 0.0.0.0:$INTERNAL_PORT --timeout 600 diff --git a/pyproject.toml b/pyproject.toml index 9ccc5fd..5d47147 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "django-filter==25.2", "django-hijack==3.7.8", "django-import-export~=4.4.1", + "django-oauth-toolkit~=3.0.1", "django-pwa~=2.0.1", "django-vite==3.1.0", "djangorestframework~=3.17.1", diff --git a/uv.lock b/uv.lock index 6476d54..0dfbe60 100644 --- a/uv.lock +++ b/uv.lock @@ -485,6 +485,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/91/d1/110aeb2acffea56d4222861b5678c2643f0bda00081e40d687077348bb7c/django_import_export-4.4.1-py3-none-any.whl", hash = "sha256:8be2782e505ae303ccb02070a1b4c528995922126fca9ee449eb28666835dd4b", size = 157691, upload-time = "2026-05-05T12:42:42.554Z" }, ] +[[package]] +name = "django-oauth-toolkit" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "jwcrypto" }, + { name = "oauthlib" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/d3/d7628a7a4899bf5aafc9c1ec121c507470b37a247f7628acae6e0f78e0d6/django_oauth_toolkit-3.0.1.tar.gz", hash = "sha256:7200e4a9fb229b145a6d808cbf0423b6d69a87f68557437733eec3c0cf71db02", size = 99816, upload-time = "2024-09-07T14:07:57.124Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/40/e556bc19ba65356fe5f0e48ca01c50e81f7c630042fa7411b6ab428ecf68/django_oauth_toolkit-3.0.1-py3-none-any.whl", hash = "sha256:3ef00b062a284f2031b0732b32dc899e3bbf0eac221bbb1cffcb50b8932e55ed", size = 77299, upload-time = "2024-09-07T14:08:43.225Z" }, +] + [[package]] name = "django-pwa" version = "2.0.1" @@ -604,6 +619,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "jwcrypto" +version = "1.5.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8c/90/f065668004d22715c1940d6e88e4c3afc8ee16d5664e4478d2c8fd23a250/jwcrypto-1.5.7.tar.gz", hash = "sha256:70204d7cca406eda8c82352e3c41ba2d946610dafd19e54403f0a1f4f18633c6", size = 89535, upload-time = "2026-04-07T00:35:36.116Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/24/fb7da4d6613de7001feaf540d4b5969c6b5a1c42839043b0196cb13aa057/jwcrypto-1.5.7-py3-none-any.whl", hash = "sha256:729463fefe28b6de5cf1ebfda3e94f1a1b41d2799148ef98a01cb9678ebe2bb0", size = 94799, upload-time = "2026-04-07T00:35:35.085Z" }, +] + [[package]] name = "mistune" version = "3.2.1" @@ -1349,6 +1377,7 @@ dependencies = [ { name = "django-filter" }, { name = "django-hijack" }, { name = "django-import-export" }, + { name = "django-oauth-toolkit" }, { name = "django-pwa" }, { name = "django-vite" }, { name = "djangorestframework" }, @@ -1382,6 +1411,7 @@ requires-dist = [ { name = "django-filter", specifier = "==25.2" }, { name = "django-hijack", specifier = "==3.7.8" }, { name = "django-import-export", specifier = "~=4.4.1" }, + { name = "django-oauth-toolkit", specifier = "~=3.0.1" }, { name = "django-pwa", specifier = "~=2.0.1" }, { name = "django-vite", specifier = "==3.1.0" }, { name = "djangorestframework", specifier = "~=3.17.1" },