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..0a44285 100644 --- a/app/WYGIWYH/urls.py +++ b/app/WYGIWYH/urls.py @@ -22,6 +22,29 @@ from drf_spectacular.views import ( SpectacularSwaggerView, ) from allauth.socialaccount.providers.openid_connect.views import login, callback +from apps.common.decorators.demo import disabled_on_demo +from apps.common.oauth_views import ( + authorization_server_metadata, + dynamic_client_registration, +) +from oauth2_provider import urls as _dot_urls + + +def _decorate_included(patterns, decorator): + """Apply ``decorator`` to every view callback inside an included URLconf. + + django.urls does not support decorating ``include()`` directly, so we wrap + each URLPattern's callback here. The OAuth2 endpoints issue credentials, so + gate them behind the same DEMO-mode guard used elsewhere. + """ + wrapped = [] + for pattern in patterns: + pattern.callback = decorator(pattern.callback) + wrapped.append(pattern) + return wrapped + + +_oauth_patterns = _decorate_included(_dot_urls.urlpatterns, disabled_on_demo) urlpatterns = [ @@ -39,6 +62,20 @@ urlpatterns = [ name="swagger-ui", ), path("auth/", include("allauth.urls")), # allauth urls + path( + "oauth/", + include((_oauth_patterns, _dot_urls.app_name), namespace="oauth2_provider"), + ), + path( + ".well-known/oauth-authorization-server", + disabled_on_demo(authorization_server_metadata), + name="oauth-authorization-server-metadata", + ), + path( + "oauth/register/", + disabled_on_demo(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/api/tests/test_demo_mode.py b/app/apps/api/tests/test_demo_mode.py new file mode 100644 index 0000000..e7d4e18 --- /dev/null +++ b/app/apps/api/tests/test_demo_mode.py @@ -0,0 +1,166 @@ +from datetime import timedelta + +from django.contrib.auth import get_user_model +from django.test import TestCase, override_settings +from django.urls import reverse +from django.utils import timezone +from oauth2_provider.models import get_access_token_model, get_application_model + +from apps.users.models import APIToken + +User = get_user_model() +Application = get_application_model() +AccessToken = get_access_token_model() + + +@override_settings(DEMO=True) +class DemoModeAPITests(TestCase): + """The DEMO-mode gate (apps.api.permissions.NotInDemoMode) must reject + API access regardless of the authentication method used, including the + PAT and OAuth2 backends introduced for MCP integrations.""" + + def setUp(self): + self.user = User.objects.create_user( + email="demo@example.com", + password="test-password", + ) + + def test_pat_cannot_access_api_in_demo_mode(self): + _token, raw_token = APIToken.objects.create_token( + user=self.user, name="n8n" + ) + + response = self.client.get( + "/api/accounts/", + HTTP_AUTHORIZATION=f"Token {raw_token}", + ) + + self.assertEqual(response.status_code, 403) + + def test_oauth_access_token_cannot_access_api_in_demo_mode(self): + app = Application.objects.create( + name="Test Client", + client_type=Application.CLIENT_CONFIDENTIAL, + authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, + redirect_uris="http://127.0.0.1:8765/callback", + client_secret="secret", + ) + access_token = AccessToken.objects.create( + user=self.user, + scope="mcp", + expires=timezone.now() + timedelta(hours=1), + token="demo-oauth-access-token-xyz", + application=app, + ) + + response = self.client.get( + "/api/accounts/", + HTTP_AUTHORIZATION=f"Bearer {access_token.token}", + ) + + self.assertEqual(response.status_code, 403) + + def test_superuser_pat_can_access_api_in_demo_mode(self): + admin = User.objects.create_superuser( + email="admin@example.com", + password="test-password", + ) + _token, raw_token = APIToken.objects.create_token( + user=admin, name="admin" + ) + + response = self.client.get( + "/api/accounts/", + HTTP_AUTHORIZATION=f"Token {raw_token}", + ) + + # NotInDemoMode grants superusers access in DEMO mode; the request is + # authenticated by the PAT, so the API responds normally (never 403). + self.assertNotEqual(response.status_code, 403) + + +@override_settings(DEMO=True) +class DemoModeOAuthEndpointTests(TestCase): + """OAuth2 issuance and discovery endpoints must be disabled in DEMO mode + so demo tenants cannot obtain (or even discover) credentials.""" + + def setUp(self): + self.user = User.objects.create_user( + email="demo@example.com", + password="test-password", + ) + + def test_oauth_authorize_rejects_non_superuser_in_demo_mode(self): + self.client.force_login(self.user) + + response = self.client.get(reverse("oauth2_provider:authorize")) + + self.assertEqual(response.status_code, 403) + + def test_oauth_token_rejects_non_superuser_in_demo_mode(self): + self.client.force_login(self.user) + + response = self.client.post(reverse("oauth2_provider:token")) + + self.assertEqual(response.status_code, 403) + + def test_oauth_authorization_server_metadata_rejects_in_demo_mode(self): + response = self.client.get(reverse("oauth-authorization-server-metadata")) + + self.assertEqual(response.status_code, 403) + + def test_oauth_dynamic_client_registration_rejects_in_demo_mode(self): + response = self.client.post( + reverse("oauth-dynamic-client-registration"), + data="{}", + content_type="application/json", + ) + + self.assertEqual(response.status_code, 403) + + +@override_settings(DEMO=True) +class DemoModeAPITokenViewsTests(TestCase): + """The PAT management UI must be disabled in DEMO mode just like the + other mutating user views.""" + + def setUp(self): + self.user = User.objects.create_user( + email="demo@example.com", + password="test-password", + ) + self.client.force_login(self.user) + self.htmx_headers = {"HTTP_HX_REQUEST": "true"} + + def test_cannot_create_api_token_from_ui_in_demo_mode(self): + response = self.client.post( + reverse("user_api_token_add"), + {"name": "n8n", "expires_in_days": "30"}, + **self.htmx_headers, + ) + + self.assertEqual(response.status_code, 403) + self.assertEqual(APIToken.objects.count(), 0) + + def test_cannot_revoke_api_token_from_ui_in_demo_mode(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, 403) + token.refresh_from_db() + self.assertIsNone(token.revoked_at) + + def test_cannot_delete_api_token_from_ui_in_demo_mode(self): + token, _ = APIToken.objects.create_token(user=self.user, name="n8n") + + response = self.client.delete( + reverse("user_api_token_delete", kwargs={"token_id": token.id}), + **self.htmx_headers, + ) + + self.assertEqual(response.status_code, 403) + self.assertTrue(APIToken.objects.filter(id=token.id).exists()) \ No newline at end of file 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..8c14847 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,49 @@ 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-primary", + ), + ), + ) + + 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/migrations/0027_alter_usersettings_timezone.py b/app/apps/users/migrations/0027_alter_usersettings_timezone.py new file mode 100644 index 0000000..bf630d0 --- /dev/null +++ b/app/apps/users/migrations/0027_alter_usersettings_timezone.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.15 on 2026-06-27 20:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0026_apitoken'), + ] + + operations = [ + migrations.AlterField( + model_name='usersettings', + name='timezone', + field=models.CharField(choices=[('auto', 'Auto'), ('Africa/Abidjan', 'Africa/Abidjan'), ('Africa/Accra', 'Africa/Accra'), ('Africa/Addis_Ababa', 'Africa/Addis_Ababa'), ('Africa/Algiers', 'Africa/Algiers'), ('Africa/Asmara', 'Africa/Asmara'), ('Africa/Bamako', 'Africa/Bamako'), ('Africa/Bangui', 'Africa/Bangui'), ('Africa/Banjul', 'Africa/Banjul'), ('Africa/Bissau', 'Africa/Bissau'), ('Africa/Blantyre', 'Africa/Blantyre'), ('Africa/Brazzaville', 'Africa/Brazzaville'), ('Africa/Bujumbura', 'Africa/Bujumbura'), ('Africa/Cairo', 'Africa/Cairo'), ('Africa/Casablanca', 'Africa/Casablanca'), ('Africa/Ceuta', 'Africa/Ceuta'), ('Africa/Conakry', 'Africa/Conakry'), ('Africa/Dakar', 'Africa/Dakar'), ('Africa/Dar_es_Salaam', 'Africa/Dar_es_Salaam'), ('Africa/Djibouti', 'Africa/Djibouti'), ('Africa/Douala', 'Africa/Douala'), ('Africa/El_Aaiun', 'Africa/El_Aaiun'), ('Africa/Freetown', 'Africa/Freetown'), ('Africa/Gaborone', 'Africa/Gaborone'), ('Africa/Harare', 'Africa/Harare'), ('Africa/Johannesburg', 'Africa/Johannesburg'), ('Africa/Juba', 'Africa/Juba'), ('Africa/Kampala', 'Africa/Kampala'), ('Africa/Khartoum', 'Africa/Khartoum'), ('Africa/Kigali', 'Africa/Kigali'), ('Africa/Kinshasa', 'Africa/Kinshasa'), ('Africa/Lagos', 'Africa/Lagos'), ('Africa/Libreville', 'Africa/Libreville'), ('Africa/Lome', 'Africa/Lome'), ('Africa/Luanda', 'Africa/Luanda'), ('Africa/Lubumbashi', 'Africa/Lubumbashi'), ('Africa/Lusaka', 'Africa/Lusaka'), ('Africa/Malabo', 'Africa/Malabo'), ('Africa/Maputo', 'Africa/Maputo'), ('Africa/Maseru', 'Africa/Maseru'), ('Africa/Mbabane', 'Africa/Mbabane'), ('Africa/Mogadishu', 'Africa/Mogadishu'), ('Africa/Monrovia', 'Africa/Monrovia'), ('Africa/Nairobi', 'Africa/Nairobi'), ('Africa/Ndjamena', 'Africa/Ndjamena'), ('Africa/Niamey', 'Africa/Niamey'), ('Africa/Nouakchott', 'Africa/Nouakchott'), ('Africa/Ouagadougou', 'Africa/Ouagadougou'), ('Africa/Porto-Novo', 'Africa/Porto-Novo'), ('Africa/Sao_Tome', 'Africa/Sao_Tome'), ('Africa/Tripoli', 'Africa/Tripoli'), ('Africa/Tunis', 'Africa/Tunis'), ('Africa/Windhoek', 'Africa/Windhoek'), ('America/Adak', 'America/Adak'), ('America/Anchorage', 'America/Anchorage'), ('America/Anguilla', 'America/Anguilla'), ('America/Antigua', 'America/Antigua'), ('America/Araguaina', 'America/Araguaina'), ('America/Argentina/Buenos_Aires', 'America/Argentina/Buenos_Aires'), ('America/Argentina/Catamarca', 'America/Argentina/Catamarca'), ('America/Argentina/Cordoba', 'America/Argentina/Cordoba'), ('America/Argentina/Jujuy', 'America/Argentina/Jujuy'), ('America/Argentina/La_Rioja', 'America/Argentina/La_Rioja'), ('America/Argentina/Mendoza', 'America/Argentina/Mendoza'), ('America/Argentina/Rio_Gallegos', 'America/Argentina/Rio_Gallegos'), ('America/Argentina/Salta', 'America/Argentina/Salta'), ('America/Argentina/San_Juan', 'America/Argentina/San_Juan'), ('America/Argentina/San_Luis', 'America/Argentina/San_Luis'), ('America/Argentina/Tucuman', 'America/Argentina/Tucuman'), ('America/Argentina/Ushuaia', 'America/Argentina/Ushuaia'), ('America/Aruba', 'America/Aruba'), ('America/Asuncion', 'America/Asuncion'), ('America/Atikokan', 'America/Atikokan'), ('America/Bahia', 'America/Bahia'), ('America/Bahia_Banderas', 'America/Bahia_Banderas'), ('America/Barbados', 'America/Barbados'), ('America/Belem', 'America/Belem'), ('America/Belize', 'America/Belize'), ('America/Blanc-Sablon', 'America/Blanc-Sablon'), ('America/Boa_Vista', 'America/Boa_Vista'), ('America/Bogota', 'America/Bogota'), ('America/Boise', 'America/Boise'), ('America/Cambridge_Bay', 'America/Cambridge_Bay'), ('America/Campo_Grande', 'America/Campo_Grande'), ('America/Cancun', 'America/Cancun'), ('America/Caracas', 'America/Caracas'), ('America/Cayenne', 'America/Cayenne'), ('America/Cayman', 'America/Cayman'), ('America/Chicago', 'America/Chicago'), ('America/Chihuahua', 'America/Chihuahua'), ('America/Ciudad_Juarez', 'America/Ciudad_Juarez'), ('America/Costa_Rica', 'America/Costa_Rica'), ('America/Coyhaique', 'America/Coyhaique'), ('America/Creston', 'America/Creston'), ('America/Cuiaba', 'America/Cuiaba'), ('America/Curacao', 'America/Curacao'), ('America/Danmarkshavn', 'America/Danmarkshavn'), ('America/Dawson', 'America/Dawson'), ('America/Dawson_Creek', 'America/Dawson_Creek'), ('America/Denver', 'America/Denver'), ('America/Detroit', 'America/Detroit'), ('America/Dominica', 'America/Dominica'), ('America/Edmonton', 'America/Edmonton'), ('America/Eirunepe', 'America/Eirunepe'), ('America/El_Salvador', 'America/El_Salvador'), ('America/Fort_Nelson', 'America/Fort_Nelson'), ('America/Fortaleza', 'America/Fortaleza'), ('America/Glace_Bay', 'America/Glace_Bay'), ('America/Goose_Bay', 'America/Goose_Bay'), ('America/Grand_Turk', 'America/Grand_Turk'), ('America/Grenada', 'America/Grenada'), ('America/Guadeloupe', 'America/Guadeloupe'), ('America/Guatemala', 'America/Guatemala'), ('America/Guayaquil', 'America/Guayaquil'), ('America/Guyana', 'America/Guyana'), ('America/Halifax', 'America/Halifax'), ('America/Havana', 'America/Havana'), ('America/Hermosillo', 'America/Hermosillo'), ('America/Indiana/Indianapolis', 'America/Indiana/Indianapolis'), ('America/Indiana/Knox', 'America/Indiana/Knox'), ('America/Indiana/Marengo', 'America/Indiana/Marengo'), ('America/Indiana/Petersburg', 'America/Indiana/Petersburg'), ('America/Indiana/Tell_City', 'America/Indiana/Tell_City'), ('America/Indiana/Vevay', 'America/Indiana/Vevay'), ('America/Indiana/Vincennes', 'America/Indiana/Vincennes'), ('America/Indiana/Winamac', 'America/Indiana/Winamac'), ('America/Inuvik', 'America/Inuvik'), ('America/Iqaluit', 'America/Iqaluit'), ('America/Jamaica', 'America/Jamaica'), ('America/Juneau', 'America/Juneau'), ('America/Kentucky/Louisville', 'America/Kentucky/Louisville'), ('America/Kentucky/Monticello', 'America/Kentucky/Monticello'), ('America/Kralendijk', 'America/Kralendijk'), ('America/La_Paz', 'America/La_Paz'), ('America/Lima', 'America/Lima'), ('America/Los_Angeles', 'America/Los_Angeles'), ('America/Lower_Princes', 'America/Lower_Princes'), ('America/Maceio', 'America/Maceio'), ('America/Managua', 'America/Managua'), ('America/Manaus', 'America/Manaus'), ('America/Marigot', 'America/Marigot'), ('America/Martinique', 'America/Martinique'), ('America/Matamoros', 'America/Matamoros'), ('America/Mazatlan', 'America/Mazatlan'), ('America/Menominee', 'America/Menominee'), ('America/Merida', 'America/Merida'), ('America/Metlakatla', 'America/Metlakatla'), ('America/Mexico_City', 'America/Mexico_City'), ('America/Miquelon', 'America/Miquelon'), ('America/Moncton', 'America/Moncton'), ('America/Monterrey', 'America/Monterrey'), ('America/Montevideo', 'America/Montevideo'), ('America/Montserrat', 'America/Montserrat'), ('America/Nassau', 'America/Nassau'), ('America/New_York', 'America/New_York'), ('America/Nome', 'America/Nome'), ('America/Noronha', 'America/Noronha'), ('America/North_Dakota/Beulah', 'America/North_Dakota/Beulah'), ('America/North_Dakota/Center', 'America/North_Dakota/Center'), ('America/North_Dakota/New_Salem', 'America/North_Dakota/New_Salem'), ('America/Nuuk', 'America/Nuuk'), ('America/Ojinaga', 'America/Ojinaga'), ('America/Panama', 'America/Panama'), ('America/Paramaribo', 'America/Paramaribo'), ('America/Phoenix', 'America/Phoenix'), ('America/Port-au-Prince', 'America/Port-au-Prince'), ('America/Port_of_Spain', 'America/Port_of_Spain'), ('America/Porto_Velho', 'America/Porto_Velho'), ('America/Puerto_Rico', 'America/Puerto_Rico'), ('America/Punta_Arenas', 'America/Punta_Arenas'), ('America/Rankin_Inlet', 'America/Rankin_Inlet'), ('America/Recife', 'America/Recife'), ('America/Regina', 'America/Regina'), ('America/Resolute', 'America/Resolute'), ('America/Rio_Branco', 'America/Rio_Branco'), ('America/Santarem', 'America/Santarem'), ('America/Santiago', 'America/Santiago'), ('America/Santo_Domingo', 'America/Santo_Domingo'), ('America/Sao_Paulo', 'America/Sao_Paulo'), ('America/Scoresbysund', 'America/Scoresbysund'), ('America/Sitka', 'America/Sitka'), ('America/St_Barthelemy', 'America/St_Barthelemy'), ('America/St_Johns', 'America/St_Johns'), ('America/St_Kitts', 'America/St_Kitts'), ('America/St_Lucia', 'America/St_Lucia'), ('America/St_Thomas', 'America/St_Thomas'), ('America/St_Vincent', 'America/St_Vincent'), ('America/Swift_Current', 'America/Swift_Current'), ('America/Tegucigalpa', 'America/Tegucigalpa'), ('America/Thule', 'America/Thule'), ('America/Tijuana', 'America/Tijuana'), ('America/Toronto', 'America/Toronto'), ('America/Tortola', 'America/Tortola'), ('America/Vancouver', 'America/Vancouver'), ('America/Whitehorse', 'America/Whitehorse'), ('America/Winnipeg', 'America/Winnipeg'), ('America/Yakutat', 'America/Yakutat'), ('Antarctica/Casey', 'Antarctica/Casey'), ('Antarctica/Davis', 'Antarctica/Davis'), ('Antarctica/DumontDUrville', 'Antarctica/DumontDUrville'), ('Antarctica/Macquarie', 'Antarctica/Macquarie'), ('Antarctica/Mawson', 'Antarctica/Mawson'), ('Antarctica/McMurdo', 'Antarctica/McMurdo'), ('Antarctica/Palmer', 'Antarctica/Palmer'), ('Antarctica/Rothera', 'Antarctica/Rothera'), ('Antarctica/Syowa', 'Antarctica/Syowa'), ('Antarctica/Troll', 'Antarctica/Troll'), ('Antarctica/Vostok', 'Antarctica/Vostok'), ('Arctic/Longyearbyen', 'Arctic/Longyearbyen'), ('Asia/Aden', 'Asia/Aden'), ('Asia/Almaty', 'Asia/Almaty'), ('Asia/Amman', 'Asia/Amman'), ('Asia/Anadyr', 'Asia/Anadyr'), ('Asia/Aqtau', 'Asia/Aqtau'), ('Asia/Aqtobe', 'Asia/Aqtobe'), ('Asia/Ashgabat', 'Asia/Ashgabat'), ('Asia/Atyrau', 'Asia/Atyrau'), ('Asia/Baghdad', 'Asia/Baghdad'), ('Asia/Bahrain', 'Asia/Bahrain'), ('Asia/Baku', 'Asia/Baku'), ('Asia/Bangkok', 'Asia/Bangkok'), ('Asia/Barnaul', 'Asia/Barnaul'), ('Asia/Beirut', 'Asia/Beirut'), ('Asia/Bishkek', 'Asia/Bishkek'), ('Asia/Brunei', 'Asia/Brunei'), ('Asia/Chita', 'Asia/Chita'), ('Asia/Colombo', 'Asia/Colombo'), ('Asia/Damascus', 'Asia/Damascus'), ('Asia/Dhaka', 'Asia/Dhaka'), ('Asia/Dili', 'Asia/Dili'), ('Asia/Dubai', 'Asia/Dubai'), ('Asia/Dushanbe', 'Asia/Dushanbe'), ('Asia/Famagusta', 'Asia/Famagusta'), ('Asia/Gaza', 'Asia/Gaza'), ('Asia/Hebron', 'Asia/Hebron'), ('Asia/Ho_Chi_Minh', 'Asia/Ho_Chi_Minh'), ('Asia/Hong_Kong', 'Asia/Hong_Kong'), ('Asia/Hovd', 'Asia/Hovd'), ('Asia/Irkutsk', 'Asia/Irkutsk'), ('Asia/Jakarta', 'Asia/Jakarta'), ('Asia/Jayapura', 'Asia/Jayapura'), ('Asia/Jerusalem', 'Asia/Jerusalem'), ('Asia/Kabul', 'Asia/Kabul'), ('Asia/Kamchatka', 'Asia/Kamchatka'), ('Asia/Karachi', 'Asia/Karachi'), ('Asia/Kathmandu', 'Asia/Kathmandu'), ('Asia/Khandyga', 'Asia/Khandyga'), ('Asia/Kolkata', 'Asia/Kolkata'), ('Asia/Krasnoyarsk', 'Asia/Krasnoyarsk'), ('Asia/Kuala_Lumpur', 'Asia/Kuala_Lumpur'), ('Asia/Kuching', 'Asia/Kuching'), ('Asia/Kuwait', 'Asia/Kuwait'), ('Asia/Macau', 'Asia/Macau'), ('Asia/Magadan', 'Asia/Magadan'), ('Asia/Makassar', 'Asia/Makassar'), ('Asia/Manila', 'Asia/Manila'), ('Asia/Muscat', 'Asia/Muscat'), ('Asia/Nicosia', 'Asia/Nicosia'), ('Asia/Novokuznetsk', 'Asia/Novokuznetsk'), ('Asia/Novosibirsk', 'Asia/Novosibirsk'), ('Asia/Omsk', 'Asia/Omsk'), ('Asia/Oral', 'Asia/Oral'), ('Asia/Phnom_Penh', 'Asia/Phnom_Penh'), ('Asia/Pontianak', 'Asia/Pontianak'), ('Asia/Pyongyang', 'Asia/Pyongyang'), ('Asia/Qatar', 'Asia/Qatar'), ('Asia/Qostanay', 'Asia/Qostanay'), ('Asia/Qyzylorda', 'Asia/Qyzylorda'), ('Asia/Riyadh', 'Asia/Riyadh'), ('Asia/Sakhalin', 'Asia/Sakhalin'), ('Asia/Samarkand', 'Asia/Samarkand'), ('Asia/Seoul', 'Asia/Seoul'), ('Asia/Shanghai', 'Asia/Shanghai'), ('Asia/Singapore', 'Asia/Singapore'), ('Asia/Srednekolymsk', 'Asia/Srednekolymsk'), ('Asia/Taipei', 'Asia/Taipei'), ('Asia/Tashkent', 'Asia/Tashkent'), ('Asia/Tbilisi', 'Asia/Tbilisi'), ('Asia/Tehran', 'Asia/Tehran'), ('Asia/Thimphu', 'Asia/Thimphu'), ('Asia/Tokyo', 'Asia/Tokyo'), ('Asia/Tomsk', 'Asia/Tomsk'), ('Asia/Ulaanbaatar', 'Asia/Ulaanbaatar'), ('Asia/Urumqi', 'Asia/Urumqi'), ('Asia/Ust-Nera', 'Asia/Ust-Nera'), ('Asia/Vientiane', 'Asia/Vientiane'), ('Asia/Vladivostok', 'Asia/Vladivostok'), ('Asia/Yakutsk', 'Asia/Yakutsk'), ('Asia/Yangon', 'Asia/Yangon'), ('Asia/Yekaterinburg', 'Asia/Yekaterinburg'), ('Asia/Yerevan', 'Asia/Yerevan'), ('Atlantic/Azores', 'Atlantic/Azores'), ('Atlantic/Bermuda', 'Atlantic/Bermuda'), ('Atlantic/Canary', 'Atlantic/Canary'), ('Atlantic/Cape_Verde', 'Atlantic/Cape_Verde'), ('Atlantic/Faroe', 'Atlantic/Faroe'), ('Atlantic/Madeira', 'Atlantic/Madeira'), ('Atlantic/Reykjavik', 'Atlantic/Reykjavik'), ('Atlantic/South_Georgia', 'Atlantic/South_Georgia'), ('Atlantic/St_Helena', 'Atlantic/St_Helena'), ('Atlantic/Stanley', 'Atlantic/Stanley'), ('Australia/Adelaide', 'Australia/Adelaide'), ('Australia/Brisbane', 'Australia/Brisbane'), ('Australia/Broken_Hill', 'Australia/Broken_Hill'), ('Australia/Darwin', 'Australia/Darwin'), ('Australia/Eucla', 'Australia/Eucla'), ('Australia/Hobart', 'Australia/Hobart'), ('Australia/Lindeman', 'Australia/Lindeman'), ('Australia/Lord_Howe', 'Australia/Lord_Howe'), ('Australia/Melbourne', 'Australia/Melbourne'), ('Australia/Perth', 'Australia/Perth'), ('Australia/Sydney', 'Australia/Sydney'), ('Canada/Atlantic', 'Canada/Atlantic'), ('Canada/Central', 'Canada/Central'), ('Canada/Eastern', 'Canada/Eastern'), ('Canada/Mountain', 'Canada/Mountain'), ('Canada/Newfoundland', 'Canada/Newfoundland'), ('Canada/Pacific', 'Canada/Pacific'), ('Europe/Amsterdam', 'Europe/Amsterdam'), ('Europe/Andorra', 'Europe/Andorra'), ('Europe/Astrakhan', 'Europe/Astrakhan'), ('Europe/Athens', 'Europe/Athens'), ('Europe/Belgrade', 'Europe/Belgrade'), ('Europe/Berlin', 'Europe/Berlin'), ('Europe/Bratislava', 'Europe/Bratislava'), ('Europe/Brussels', 'Europe/Brussels'), ('Europe/Bucharest', 'Europe/Bucharest'), ('Europe/Budapest', 'Europe/Budapest'), ('Europe/Busingen', 'Europe/Busingen'), ('Europe/Chisinau', 'Europe/Chisinau'), ('Europe/Copenhagen', 'Europe/Copenhagen'), ('Europe/Dublin', 'Europe/Dublin'), ('Europe/Gibraltar', 'Europe/Gibraltar'), ('Europe/Guernsey', 'Europe/Guernsey'), ('Europe/Helsinki', 'Europe/Helsinki'), ('Europe/Isle_of_Man', 'Europe/Isle_of_Man'), ('Europe/Istanbul', 'Europe/Istanbul'), ('Europe/Jersey', 'Europe/Jersey'), ('Europe/Kaliningrad', 'Europe/Kaliningrad'), ('Europe/Kirov', 'Europe/Kirov'), ('Europe/Kyiv', 'Europe/Kyiv'), ('Europe/Lisbon', 'Europe/Lisbon'), ('Europe/Ljubljana', 'Europe/Ljubljana'), ('Europe/London', 'Europe/London'), ('Europe/Luxembourg', 'Europe/Luxembourg'), ('Europe/Madrid', 'Europe/Madrid'), ('Europe/Malta', 'Europe/Malta'), ('Europe/Mariehamn', 'Europe/Mariehamn'), ('Europe/Minsk', 'Europe/Minsk'), ('Europe/Monaco', 'Europe/Monaco'), ('Europe/Moscow', 'Europe/Moscow'), ('Europe/Oslo', 'Europe/Oslo'), ('Europe/Paris', 'Europe/Paris'), ('Europe/Podgorica', 'Europe/Podgorica'), ('Europe/Prague', 'Europe/Prague'), ('Europe/Riga', 'Europe/Riga'), ('Europe/Rome', 'Europe/Rome'), ('Europe/Samara', 'Europe/Samara'), ('Europe/San_Marino', 'Europe/San_Marino'), ('Europe/Sarajevo', 'Europe/Sarajevo'), ('Europe/Saratov', 'Europe/Saratov'), ('Europe/Simferopol', 'Europe/Simferopol'), ('Europe/Skopje', 'Europe/Skopje'), ('Europe/Sofia', 'Europe/Sofia'), ('Europe/Stockholm', 'Europe/Stockholm'), ('Europe/Tallinn', 'Europe/Tallinn'), ('Europe/Tirane', 'Europe/Tirane'), ('Europe/Ulyanovsk', 'Europe/Ulyanovsk'), ('Europe/Vaduz', 'Europe/Vaduz'), ('Europe/Vatican', 'Europe/Vatican'), ('Europe/Vienna', 'Europe/Vienna'), ('Europe/Vilnius', 'Europe/Vilnius'), ('Europe/Volgograd', 'Europe/Volgograd'), ('Europe/Warsaw', 'Europe/Warsaw'), ('Europe/Zagreb', 'Europe/Zagreb'), ('Europe/Zurich', 'Europe/Zurich'), ('GMT', 'GMT'), ('Indian/Antananarivo', 'Indian/Antananarivo'), ('Indian/Chagos', 'Indian/Chagos'), ('Indian/Christmas', 'Indian/Christmas'), ('Indian/Cocos', 'Indian/Cocos'), ('Indian/Comoro', 'Indian/Comoro'), ('Indian/Kerguelen', 'Indian/Kerguelen'), ('Indian/Mahe', 'Indian/Mahe'), ('Indian/Maldives', 'Indian/Maldives'), ('Indian/Mauritius', 'Indian/Mauritius'), ('Indian/Mayotte', 'Indian/Mayotte'), ('Indian/Reunion', 'Indian/Reunion'), ('Pacific/Apia', 'Pacific/Apia'), ('Pacific/Auckland', 'Pacific/Auckland'), ('Pacific/Bougainville', 'Pacific/Bougainville'), ('Pacific/Chatham', 'Pacific/Chatham'), ('Pacific/Chuuk', 'Pacific/Chuuk'), ('Pacific/Easter', 'Pacific/Easter'), ('Pacific/Efate', 'Pacific/Efate'), ('Pacific/Fakaofo', 'Pacific/Fakaofo'), ('Pacific/Fiji', 'Pacific/Fiji'), ('Pacific/Funafuti', 'Pacific/Funafuti'), ('Pacific/Galapagos', 'Pacific/Galapagos'), ('Pacific/Gambier', 'Pacific/Gambier'), ('Pacific/Guadalcanal', 'Pacific/Guadalcanal'), ('Pacific/Guam', 'Pacific/Guam'), ('Pacific/Honolulu', 'Pacific/Honolulu'), ('Pacific/Kanton', 'Pacific/Kanton'), ('Pacific/Kiritimati', 'Pacific/Kiritimati'), ('Pacific/Kosrae', 'Pacific/Kosrae'), ('Pacific/Kwajalein', 'Pacific/Kwajalein'), ('Pacific/Majuro', 'Pacific/Majuro'), ('Pacific/Marquesas', 'Pacific/Marquesas'), ('Pacific/Midway', 'Pacific/Midway'), ('Pacific/Nauru', 'Pacific/Nauru'), ('Pacific/Niue', 'Pacific/Niue'), ('Pacific/Norfolk', 'Pacific/Norfolk'), ('Pacific/Noumea', 'Pacific/Noumea'), ('Pacific/Pago_Pago', 'Pacific/Pago_Pago'), ('Pacific/Palau', 'Pacific/Palau'), ('Pacific/Pitcairn', 'Pacific/Pitcairn'), ('Pacific/Pohnpei', 'Pacific/Pohnpei'), ('Pacific/Port_Moresby', 'Pacific/Port_Moresby'), ('Pacific/Rarotonga', 'Pacific/Rarotonga'), ('Pacific/Saipan', 'Pacific/Saipan'), ('Pacific/Tahiti', 'Pacific/Tahiti'), ('Pacific/Tarawa', 'Pacific/Tarawa'), ('Pacific/Tongatapu', 'Pacific/Tongatapu'), ('Pacific/Wake', 'Pacific/Wake'), ('Pacific/Wallis', 'Pacific/Wallis'), ('US/Alaska', 'US/Alaska'), ('US/Arizona', 'US/Arizona'), ('US/Central', 'US/Central'), ('US/Eastern', 'US/Eastern'), ('US/Hawaii', 'US/Hawaii'), ('US/Mountain', 'US/Mountain'), ('US/Pacific', 'US/Pacific'), ('UTC', 'UTC')], default='auto', max_length=50, verbose_name='Time Zone'), + ), + ] diff --git a/app/apps/users/models.py b/app/apps/users/models.py index d1e4b32..11826e9 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 @@ -410,7 +415,7 @@ timezones = [ ("Pacific/Galapagos", "Pacific/Galapagos"), ("Pacific/Gambier", "Pacific/Gambier"), ("Pacific/Guadalcanal", "Pacific/Guadalcanal"), - ("P2025-06-29T01:43:14.671389745Z acific/Guam", "Pacific/Guam"), + ("Pacific/Guam", "Pacific/Guam"), ("Pacific/Honolulu", "Pacific/Honolulu"), ("Pacific/Kanton", "Pacific/Kanton"), ("Pacific/Kiritimati", "Pacific/Kiritimati"), @@ -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..5bdb615 --- /dev/null +++ b/app/apps/users/tests.py @@ -0,0 +1,73 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone + +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") + + def test_can_delete_revoked_api_token(self): + token, _ = APIToken.objects.create_token(user=self.user, name="n8n") + token.revoked_at = timezone.now() + token.save(update_fields=["revoked_at"]) + + response = self.client.delete( + reverse("user_api_token_delete", kwargs={"token_id": token.id}), + **self.htmx_headers, + ) + + self.assertEqual(response.status_code, 200) + self.assertFalse(APIToken.objects.filter(id=token.id).exists()) + + def test_cannot_delete_other_users_api_token(self): + other = get_user_model().objects.create_user( + email="other@example.com", password="test-password" + ) + token, _ = APIToken.objects.create_token(user=other, name="theirs") + + response = self.client.delete( + reverse("user_api_token_delete", kwargs={"token_id": token.id}), + **self.htmx_headers, + ) + + self.assertEqual(response.status_code, 404) + self.assertTrue(APIToken.objects.filter(id=token.id).exists()) diff --git a/app/apps/users/urls.py b/app/apps/users/urls.py index b98c743..ca2faaa 100644 --- a/app/apps/users/urls.py +++ b/app/apps/users/urls.py @@ -32,6 +32,21 @@ 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( + "user/api-tokens//delete/", + views.api_token_delete, + name="user_api_token_delete", + ), path( "users/", views.users_index, diff --git a/app/apps/users/views.py b/app/apps/users/views.py index c7bf625..523acc1 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,69 @@ 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 +@disabled_on_demo +@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 +@disabled_on_demo +@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 +@htmx_login_required +@disabled_on_demo +@require_http_methods(["DELETE"]) +def api_token_delete(request, token_id): + token = get_object_or_404(APIToken, id=token_id, user=request.user) + token.delete() + messages.success(request, _("API token deleted successfully")) + return _render_api_tokens(request) @only_htmx diff --git a/app/templates/layouts/base.html b/app/templates/layouts/base.html index fbb5c13..8b8368d 100644 --- a/app/templates/layouts/base.html +++ b/app/templates/layouts/base.html @@ -34,8 +34,7 @@ {% if demo_mode %}
@@ -54,4 +53,4 @@ {% endblock extra_js_body %} - \ No newline at end of file + diff --git a/app/templates/users/fragments/api_tokens.html b/app/templates/users/fragments/api_tokens.html new file mode 100644 index 0000000..702a173 --- /dev/null +++ b/app/templates/users/fragments/api_tokens.html @@ -0,0 +1,116 @@ +{% 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 %} +
+
+
{% translate "Copy this token now" %}
+

+ {% translate "It will not be shown again after this response." %} +

+
+ + +
+
+
+{% 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 %} + + + + {% else %} + + + + {% 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 d3c541b..5e05a42 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" },