mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-07-06 12:55:12 +02:00
Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fee40fd527 | |||
| f59e53f6dc | |||
| 2b379987ac | |||
| 4b8ccf426d | |||
| 4805ce9e04 | |||
| 845a8d846b | |||
| 1497500c4f | |||
| 9e9e60ccec | |||
| ca14f77f41 | |||
| 0fb37a59fa | |||
| e74d9177df | |||
| 106d721279 | |||
| d0e9c05283 | |||
| 4e16831f4d | |||
| 7f5a91c11f | |||
| 009a7038c8 | |||
| 4273c541c5 | |||
| 5c4cb16a0a | |||
| 9641e169f2 | |||
| 25ff0214ab | |||
| 0f9d333834 | |||
| ae115cca15 | |||
| bb23ac6df9 | |||
| 7db0fcf097 | |||
| 02896f21ed | |||
| 5082c17d0f | |||
| 33570296e0 | |||
| fc99491f78 | |||
| cb0d379261 | |||
| 524e390a62 | |||
| db7e22b627 | |||
| 6987b54dba | |||
| b44563b09b | |||
| e839f31104 | |||
| 2282625790 | |||
| aa8b559152 | |||
| e1862b8241 | |||
| eb6be8548c | |||
| 6ee4e21939 | |||
| bdd8aed891 | |||
| 801b2a9edd | |||
| 968499f1ab | |||
| 5b351821b1 | |||
| 7b49072848 |
@@ -38,3 +38,21 @@ TASK_WORKERS=1 # This only work if you're using the single container option. Inc
|
|||||||
#OIDC_CLIENT_SECRET=""
|
#OIDC_CLIENT_SECRET=""
|
||||||
#OIDC_SERVER_URL=""
|
#OIDC_SERVER_URL=""
|
||||||
#OIDC_ALLOW_SIGNUP=true
|
#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="<INSERT A SAFE SECRET HERE>"
|
||||||
|
#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 <token>" on /oauth/register/).
|
||||||
|
#OAUTH2_DCR_ENABLED=false
|
||||||
|
#OAUTH2_DCR_INITIAL_ACCESS_TOKEN=""
|
||||||
|
|||||||
@@ -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 `<OIDC_CLIENT_NAME>` with the slugfied value set in OIDC_CLIENT_NAME or the default `openid-connect` if you haven't set this variable.
|
Replace `https://your.wygiwyh.domain` with the actual URL where your WYGIWYH instance is accessible. And `<OIDC_CLIENT_NAME>` 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_<key>.<secret>" \
|
||||||
|
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
|
# How it works
|
||||||
|
|
||||||
Check out our [Wiki](https://github.com/eitchtee/WYGIWYH/wiki) for more information.
|
Check out our [Wiki](https://github.com/eitchtee/WYGIWYH/wiki) for more information.
|
||||||
|
|||||||
+33
-2
@@ -72,6 +72,7 @@ INSTALLED_APPS = [
|
|||||||
"rest_framework",
|
"rest_framework",
|
||||||
"rest_framework.authtoken",
|
"rest_framework.authtoken",
|
||||||
"drf_spectacular",
|
"drf_spectacular",
|
||||||
|
"oauth2_provider",
|
||||||
"django_cotton",
|
"django_cotton",
|
||||||
"apps.rules.apps.RulesConfig",
|
"apps.rules.apps.RulesConfig",
|
||||||
"apps.calendar_view.apps.CalendarViewConfig",
|
"apps.calendar_view.apps.CalendarViewConfig",
|
||||||
@@ -344,6 +345,11 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
|||||||
LOGIN_REDIRECT_URL = "/"
|
LOGIN_REDIRECT_URL = "/"
|
||||||
LOGIN_URL = "/login/"
|
LOGIN_URL = "/login/"
|
||||||
LOGOUT_REDIRECT_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
|
# Allauth settings
|
||||||
AUTHENTICATION_BACKENDS = [
|
AUTHENTICATION_BACKENDS = [
|
||||||
@@ -382,6 +388,12 @@ SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = True
|
|||||||
ACCOUNT_ADAPTER = "allauth.account.adapter.DefaultAccountAdapter"
|
ACCOUNT_ADAPTER = "allauth.account.adapter.DefaultAccountAdapter"
|
||||||
SOCIALACCOUNT_ADAPTER = "apps.users.adapters.AutoConnectSocialAccountAdapter"
|
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 FORMS
|
||||||
CRISPY_ALLOWED_TEMPLATE_PACKS = [
|
CRISPY_ALLOWED_TEMPLATE_PACKS = [
|
||||||
"crispy_forms/pure_text",
|
"crispy_forms/pure_text",
|
||||||
@@ -446,14 +458,33 @@ REST_FRAMEWORK = {
|
|||||||
"rest_framework.filters.OrderingFilter",
|
"rest_framework.filters.OrderingFilter",
|
||||||
],
|
],
|
||||||
"DEFAULT_AUTHENTICATION_CLASSES": [
|
"DEFAULT_AUTHENTICATION_CLASSES": [
|
||||||
"rest_framework.authentication.BasicAuthentication",
|
"oauth2_provider.contrib.rest_framework.OAuth2Authentication",
|
||||||
"rest_framework.authentication.SessionAuthentication",
|
"apps.api.authentication.APITokenAuthentication",
|
||||||
"rest_framework.authentication.TokenAuthentication",
|
"rest_framework.authentication.TokenAuthentication",
|
||||||
|
"rest_framework.authentication.SessionAuthentication",
|
||||||
|
"rest_framework.authentication.BasicAuthentication",
|
||||||
],
|
],
|
||||||
"DEFAULT_PAGINATION_CLASS": "apps.api.custom.pagination.CustomPageNumberPagination",
|
"DEFAULT_PAGINATION_CLASS": "apps.api.custom.pagination.CustomPageNumberPagination",
|
||||||
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
"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 <token>`.
|
||||||
|
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 = {
|
SPECTACULAR_SETTINGS = {
|
||||||
"TITLE": "WYGIWYH API",
|
"TITLE": "WYGIWYH API",
|
||||||
"DESCRIPTION": "A no-frills expense tracker",
|
"DESCRIPTION": "A no-frills expense tracker",
|
||||||
|
|||||||
@@ -22,6 +22,29 @@ from drf_spectacular.views import (
|
|||||||
SpectacularSwaggerView,
|
SpectacularSwaggerView,
|
||||||
)
|
)
|
||||||
from allauth.socialaccount.providers.openid_connect.views import login, callback
|
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 = [
|
urlpatterns = [
|
||||||
@@ -39,6 +62,20 @@ urlpatterns = [
|
|||||||
name="swagger-ui",
|
name="swagger-ui",
|
||||||
),
|
),
|
||||||
path("auth/", include("allauth.urls")), # allauth urls
|
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/<str:provider_id>/login/", login, name="openid_connect_login"),
|
# path("auth/oidc/<str:provider_id>/login/", login, name="openid_connect_login"),
|
||||||
# path(
|
# path(
|
||||||
# "auth/oidc/<str:provider_id>/login/callback/",
|
# "auth/oidc/<str:provider_id>/login/callback/",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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())
|
||||||
@@ -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)
|
||||||
@@ -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."
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -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))
|
||||||
@@ -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())
|
||||||
+37
-1
@@ -4,13 +4,19 @@ from django.contrib.auth.forms import (
|
|||||||
UserCreationForm,
|
UserCreationForm,
|
||||||
AdminPasswordChangeForm,
|
AdminPasswordChangeForm,
|
||||||
)
|
)
|
||||||
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.contrib.auth.admin import GroupAdmin as BaseGroupAdmin
|
from django.contrib.auth.admin import GroupAdmin as BaseGroupAdmin
|
||||||
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
||||||
from django.contrib.auth.models import Group
|
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)
|
admin.site.unregister(Group)
|
||||||
|
|
||||||
@@ -77,3 +83,33 @@ class GroupAdmin(BaseGroupAdmin, ModelAdmin):
|
|||||||
|
|
||||||
|
|
||||||
admin.site.register(UserSettings)
|
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
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from apps.common.middleware.thread_local import get_current_user
|
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.crispy.submit import NoClassSubmit
|
||||||
from apps.common.widgets.tom_select import TomSelect
|
from apps.common.widgets.tom_select import TomSelect
|
||||||
from apps.users.models import UserSettings
|
from apps.users.models import UserSettings
|
||||||
@@ -16,6 +19,7 @@ from django.contrib.auth.forms import (
|
|||||||
UsernameField,
|
UsernameField,
|
||||||
)
|
)
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
@@ -427,3 +431,49 @@ class UserAddForm(UserCreationForm):
|
|||||||
if commit:
|
if commit:
|
||||||
user.save()
|
user.save()
|
||||||
return user
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
File diff suppressed because one or more lines are too long
+119
-2
@@ -1,9 +1,14 @@
|
|||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import secrets
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.models import AbstractUser, Group
|
from django.contrib.auth.models import AbstractUser, Group
|
||||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
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 django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from apps.users.managers import UserManager
|
from apps.users.managers import UserManager
|
||||||
@@ -410,7 +415,7 @@ timezones = [
|
|||||||
("Pacific/Galapagos", "Pacific/Galapagos"),
|
("Pacific/Galapagos", "Pacific/Galapagos"),
|
||||||
("Pacific/Gambier", "Pacific/Gambier"),
|
("Pacific/Gambier", "Pacific/Gambier"),
|
||||||
("Pacific/Guadalcanal", "Pacific/Guadalcanal"),
|
("Pacific/Guadalcanal", "Pacific/Guadalcanal"),
|
||||||
("P2025-06-29T01:43:14.671389745Z acific/Guam", "Pacific/Guam"),
|
("Pacific/Guam", "Pacific/Guam"),
|
||||||
("Pacific/Honolulu", "Pacific/Honolulu"),
|
("Pacific/Honolulu", "Pacific/Honolulu"),
|
||||||
("Pacific/Kanton", "Pacific/Kanton"),
|
("Pacific/Kanton", "Pacific/Kanton"),
|
||||||
("Pacific/Kiritimati", "Pacific/Kiritimati"),
|
("Pacific/Kiritimati", "Pacific/Kiritimati"),
|
||||||
@@ -524,3 +529,115 @@ class UserSettings(models.Model):
|
|||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
super().clean()
|
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()
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -32,6 +32,21 @@ urlpatterns = [
|
|||||||
views.update_settings,
|
views.update_settings,
|
||||||
name="user_settings",
|
name="user_settings",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"user/api-tokens/add/",
|
||||||
|
views.api_token_add,
|
||||||
|
name="user_api_token_add",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"user/api-tokens/<int:token_id>/revoke/",
|
||||||
|
views.api_token_revoke,
|
||||||
|
name="user_api_token_revoke",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"user/api-tokens/<int:token_id>/delete/",
|
||||||
|
views.api_token_delete,
|
||||||
|
name="user_api_token_delete",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"users/",
|
"users/",
|
||||||
views.users_index,
|
views.users_index,
|
||||||
|
|||||||
+66
-2
@@ -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.htmx import only_htmx
|
||||||
from apps.common.decorators.user import htmx_login_required, is_superuser
|
from apps.common.decorators.user import htmx_login_required, is_superuser
|
||||||
from apps.users.forms import (
|
from apps.users.forms import (
|
||||||
|
APITokenCreateForm,
|
||||||
LoginForm,
|
LoginForm,
|
||||||
UserAddForm,
|
UserAddForm,
|
||||||
UserSettingsForm,
|
UserSettingsForm,
|
||||||
UserUpdateForm,
|
UserUpdateForm,
|
||||||
)
|
)
|
||||||
from apps.users.models import UserSettings
|
from apps.users.models import APIToken, UserSettings
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth import get_user_model, logout
|
from django.contrib.auth import get_user_model, logout
|
||||||
from django.contrib.auth.decorators import login_required
|
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.http import HttpResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views.decorators.http import require_http_methods
|
from django.views.decorators.http import require_http_methods
|
||||||
|
|
||||||
@@ -112,7 +114,69 @@ def update_settings(request):
|
|||||||
else:
|
else:
|
||||||
form = UserSettingsForm(instance=user_settings)
|
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
|
@only_htmx
|
||||||
|
|||||||
+472
-206
File diff suppressed because it is too large
Load Diff
+430
-206
File diff suppressed because it is too large
Load Diff
+474
-206
File diff suppressed because it is too large
Load Diff
+469
-229
File diff suppressed because it is too large
Load Diff
+458
-206
File diff suppressed because it is too large
Load Diff
+430
-206
File diff suppressed because it is too large
Load Diff
+472
-206
File diff suppressed because it is too large
Load Diff
+439
-208
File diff suppressed because it is too large
Load Diff
+473
-219
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+470
-206
File diff suppressed because it is too large
Load Diff
+432
-206
File diff suppressed because it is too large
Load Diff
+432
-206
File diff suppressed because it is too large
Load Diff
+462
-206
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -34,8 +34,7 @@
|
|||||||
{% if demo_mode %}
|
{% if demo_mode %}
|
||||||
<div class="px-3 m-0" id="demo-mode-alert" hx-preserve>
|
<div class="px-3 m-0" id="demo-mode-alert" hx-preserve>
|
||||||
<div class="alert alert-warning my-3 relative" role="alert">
|
<div class="alert alert-warning my-3 relative" role="alert">
|
||||||
<strong>{% trans "This is a demo!" %}</strong> {% trans "Any data you add here will be wiped in 24hrs or less"
|
<strong>{% trans "This is a demo!" %}</strong>{% trans "Any data you add here will be wiped in 24hrs or less" %}
|
||||||
%}
|
|
||||||
<button type="button" class="btn btn-sm btn-ghost absolute right-2 top-1/2 -translate-y-1/2"
|
<button type="button" class="btn btn-sm btn-ghost absolute right-2 top-1/2 -translate-y-1/2"
|
||||||
onclick="this.parentElement.style.display='none'" aria-label="Close">✕</button>
|
onclick="this.parentElement.style.display='none'" aria-label="Close">✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
{% load crispy_forms_tags %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="text-lg font-bold font-mono">{% translate "API Tokens" %}</div>
|
||||||
|
<p class="text-sm opacity-70">
|
||||||
|
{% translate "Use these tokens for automations such as n8n. The raw token is shown only once after creation." %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if raw_token %}
|
||||||
|
<div class="bg-primary-content p-3 rounded-box">
|
||||||
|
<div class="w-full">
|
||||||
|
<div class="font-semibold mb-1">{% translate "Copy this token now" %}</div>
|
||||||
|
<p class="text-sm opacity-80 mb-3">
|
||||||
|
{% translate "It will not be shown again after this response." %}
|
||||||
|
</p>
|
||||||
|
<div class="join w-full">
|
||||||
|
<input id="raw-token-value"
|
||||||
|
type="text"
|
||||||
|
readonly
|
||||||
|
value="{{ raw_token }}"
|
||||||
|
class="input input-sm join-item w-full"
|
||||||
|
_="on focus call me.select()" />
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-sm btn-secondary join-item"
|
||||||
|
_="on click call navigator.clipboard.writeText(#raw-token-value.value)
|
||||||
|
then put 'Copied!' into me
|
||||||
|
then wait 1.5s
|
||||||
|
then put 'Copy' into me">
|
||||||
|
{% translate "Copy" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form hx-post="{% url 'user_api_token_add' %}" hx-target="#api-token-settings" hx-swap="innerHTML" novalidate>
|
||||||
|
{% crispy api_token_form %}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if api_tokens %}
|
||||||
|
<div class="overflow-x-auto mt-4">
|
||||||
|
<table class="table table-zebra">
|
||||||
|
<tbody>
|
||||||
|
{% for token in api_tokens %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<span class="font-medium font-mono">{{ token.name }}</span>
|
||||||
|
{% if token.revoked_at %}
|
||||||
|
<span class="badge badge-sm badge-ghost">{% translate "Revoked" %}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-sm badge-success">{% translate "Active" %}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs opacity-60 font-mono break-all mt-1">{{ token.token_key }}</div>
|
||||||
|
<div class="text-xs opacity-60">
|
||||||
|
{% 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 %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="table-col-auto text-right align-top">
|
||||||
|
{% if not token.revoked_at %}
|
||||||
|
<a class="btn btn-error btn-sm"
|
||||||
|
role="button"
|
||||||
|
data-tippy-content="{% translate 'Revoke' %}"
|
||||||
|
hx-delete="{% url 'user_api_token_revoke' token_id=token.id %}"
|
||||||
|
hx-target="#api-token-settings"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-trigger="confirmed"
|
||||||
|
data-bypass-on-ctrl="true"
|
||||||
|
data-title="{% translate 'Revoke token?' %}"
|
||||||
|
data-text="{% translate 'This token will stop working immediately.' %}"
|
||||||
|
data-confirm-text="{% translate 'Yes, revoke it!' %}"
|
||||||
|
_="install prompt_swal">
|
||||||
|
<i class="fa-solid fa-ban fa-fw"></i>
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<a class="btn btn-error btn-sm"
|
||||||
|
role="button"
|
||||||
|
data-tippy-content="{% translate 'Delete' %}"
|
||||||
|
hx-delete="{% url 'user_api_token_delete' token_id=token.id %}"
|
||||||
|
hx-target="#api-token-settings"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-trigger="confirmed"
|
||||||
|
data-bypass-on-ctrl="true"
|
||||||
|
data-title="{% translate 'Delete token?' %}"
|
||||||
|
data-text="{% translate 'This permanently removes the token from the list. It cannot be undone.' %}"
|
||||||
|
data-confirm-text="{% translate 'Yes, delete it!' %}"
|
||||||
|
_="install prompt_swal">
|
||||||
|
<i class="fa-solid fa-trash fa-fw"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="mt-4">
|
||||||
|
<c-msg.empty title="{% translate "No API tokens" %}" remove-padding></c-msg.empty>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
@@ -8,4 +8,8 @@
|
|||||||
<form hx-post="{% url 'user_settings' %}" hx-target="#generic-offcanvas" novalidate>
|
<form hx-post="{% url 'user_settings' %}" hx-target="#generic-offcanvas" novalidate>
|
||||||
{% crispy form %}
|
{% crispy form %}
|
||||||
</form>
|
</form>
|
||||||
|
<div class="divider my-6"></div>
|
||||||
|
<div id="api-token-settings">
|
||||||
|
{% include "users/fragments/api_tokens.html" %}
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -15,5 +15,6 @@ python manage.py migrate
|
|||||||
touch /tmp/migrations_complete
|
touch /tmp/migrations_complete
|
||||||
|
|
||||||
python manage.py setup_users
|
python manage.py setup_users
|
||||||
|
python manage.py setup_oauth
|
||||||
|
|
||||||
exec python manage.py runserver 0.0.0.0:$INTERNAL_PORT
|
exec python manage.py runserver 0.0.0.0:$INTERNAL_PORT
|
||||||
|
|||||||
@@ -16,5 +16,6 @@ python manage.py migrate
|
|||||||
touch /tmp/migrations_complete
|
touch /tmp/migrations_complete
|
||||||
|
|
||||||
python manage.py setup_users
|
python manage.py setup_users
|
||||||
|
python manage.py setup_oauth
|
||||||
|
|
||||||
exec gunicorn WYGIWYH.wsgi:application --bind 0.0.0.0:$INTERNAL_PORT --timeout 600
|
exec gunicorn WYGIWYH.wsgi:application --bind 0.0.0.0:$INTERNAL_PORT --timeout 600
|
||||||
|
|||||||
Generated
+744
-1402
File diff suppressed because it is too large
Load Diff
+18
-18
@@ -16,33 +16,33 @@
|
|||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@alpinejs/collapse": "^3.15.1",
|
"@alpinejs/collapse": "^3.15.12",
|
||||||
"@alpinejs/mask": "^3.15.1",
|
"@alpinejs/mask": "^3.15.12",
|
||||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||||
"@fortawesome/fontawesome-free": "^7.1.0",
|
"@fortawesome/fontawesome-free": "^7.2.0",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"@rollup/plugin-commonjs": "^29.0.0",
|
"@rollup/plugin-commonjs": "^29.0.3",
|
||||||
"@tailwindcss/vite": "^4.1.17",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"air-datepicker": "^3.6.0",
|
"air-datepicker": "^3.6.0",
|
||||||
"alpinejs": "^3.15.1",
|
"alpinejs": "^3.15.12",
|
||||||
"autoprefixer": "^10.4.22",
|
"autoprefixer": "^10.5.0",
|
||||||
"autosize": "^6.0.1",
|
"autosize": "^6.0.1",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"chartjs-chart-sankey": "^0.14.0",
|
"chartjs-chart-sankey": "^0.14.3",
|
||||||
"daisyui": "5.5.19",
|
"daisyui": "5.5.20",
|
||||||
"htmx.org": "^2.0.8",
|
"htmx.org": "^2.0.10",
|
||||||
"hyperscript.org": "^0.9.14",
|
"hyperscript.org": "^0.9.91",
|
||||||
"mathjs": "^15.2.0",
|
"mathjs": "^15.2.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.15",
|
||||||
"pulltorefreshjs": "^0.1.22",
|
"pulltorefreshjs": "^0.1.22",
|
||||||
"sass": "^1.94.0",
|
"sass": "^1.100.0",
|
||||||
"sweetalert2": "^11.26.3",
|
"sweetalert2": "^11.26.25",
|
||||||
"tailwindcss": "^4.1.17",
|
"tailwindcss": "^4.3.0",
|
||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
"tom-select": "^2.4.3",
|
"tom-select": "^2.6.1",
|
||||||
"tw-bootstrap-grid": "^1.3.2",
|
"tw-bootstrap-grid": "^1.4.0",
|
||||||
"vite": "7.3.2"
|
"vite": "8.0.16"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"rollup": "npm:@rollup/wasm-node"
|
"rollup": "npm:@rollup/wasm-node"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import _hyperscript from 'hyperscript.org';
|
import 'hyperscript.org';
|
||||||
import './_htmx.js';
|
import './_htmx.js';
|
||||||
import Alpine from "alpinejs";
|
import Alpine from "alpinejs";
|
||||||
import mask from '@alpinejs/mask';
|
import mask from '@alpinejs/mask';
|
||||||
@@ -6,10 +6,6 @@ import collapse from '@alpinejs/collapse'
|
|||||||
import { create, all } from 'mathjs';
|
import { create, all } from 'mathjs';
|
||||||
|
|
||||||
window.Alpine = Alpine;
|
window.Alpine = Alpine;
|
||||||
if (!window._hyperscript) {
|
|
||||||
window._hyperscript = _hyperscript;
|
|
||||||
_hyperscript.browserInit();
|
|
||||||
}
|
|
||||||
window.math = create(all, {
|
window.math = create(all, {
|
||||||
number: 'BigNumber',
|
number: 'BigNumber',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -60,12 +60,15 @@ window.TomSelect = function createDynamicTomSelect(element) {
|
|||||||
]
|
]
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
},
|
},
|
||||||
onDropdownOpen: function () {
|
onDropdownOpen: function () {
|
||||||
schedulePopperUpdate(this);
|
schedulePopperUpdate(this);
|
||||||
},
|
},
|
||||||
onItemAdd: function () {
|
onItemAdd: function () {
|
||||||
|
if (this.settings.mode === 'multi') {
|
||||||
|
this.setTextboxValue();
|
||||||
|
this.refreshOptions(false);
|
||||||
|
}
|
||||||
schedulePopperUpdate(this);
|
schedulePopperUpdate(this);
|
||||||
},
|
},
|
||||||
onItemRemove: function () {
|
onItemRemove: function () {
|
||||||
|
|||||||
+19
-18
@@ -5,34 +5,35 @@ description = "An opinionated and powerful finance tracker."
|
|||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crispy-bootstrap5==2025.6",
|
"crispy-bootstrap5==2026.3",
|
||||||
"django~=5.2.13",
|
"django~=5.2.15",
|
||||||
"django-allauth[socialaccount]~=65.14.1",
|
"django-allauth[socialaccount]~=65.18.0",
|
||||||
"django-browser-reload==1.21.0",
|
"django-browser-reload==1.21.0",
|
||||||
"django-cachalot~=2.8.0",
|
"django-cachalot~=2.9.0",
|
||||||
"django-cotton<2.3.0",
|
"django-cotton~=2.7.2",
|
||||||
"django-crispy-forms==2.5",
|
"django-crispy-forms==2.6",
|
||||||
"django-debug-toolbar==6.1.0",
|
"django-debug-toolbar==6.3.0",
|
||||||
"django-filter==25.2",
|
"django-filter==25.2",
|
||||||
"django-hijack==3.7.4",
|
"django-hijack==3.7.8",
|
||||||
"django-import-export~=4.3.9",
|
"django-import-export~=4.4.1",
|
||||||
|
"django-oauth-toolkit~=3.0.1",
|
||||||
"django-pwa~=2.0.1",
|
"django-pwa~=2.0.1",
|
||||||
"django-vite==3.1.0",
|
"django-vite==3.1.0",
|
||||||
"djangorestframework~=3.16.0",
|
"djangorestframework~=3.17.1",
|
||||||
"drf-spectacular~=0.29.0",
|
"drf-spectacular~=0.29.0",
|
||||||
"gunicorn==23.0.0",
|
"gunicorn==26.0.0",
|
||||||
"mistune~=3.1.3",
|
"mistune~=3.2.1",
|
||||||
"openpyxl~=3.1.5",
|
"openpyxl~=3.1.5",
|
||||||
"procrastinate[django]~=3.5.3",
|
"procrastinate[django]~=3.8.1",
|
||||||
"psycopg[binary,pool]==3.3.3",
|
"psycopg[binary,pool]==3.3.4",
|
||||||
"pydantic~=2.12.3",
|
"pydantic~=2.13.4",
|
||||||
"python-dateutil~=2.9.0.post0",
|
"python-dateutil~=2.9.0.post0",
|
||||||
"pytz>=2025.2",
|
"pytz>=2025.2",
|
||||||
"pyyaml~=6.0.2",
|
"pyyaml~=6.0.2",
|
||||||
"requests~=2.33.0",
|
"requests~=2.34.2",
|
||||||
"simpleeval~=1.0.3",
|
"simpleeval~=1.0.3",
|
||||||
"watchfiles==1.1.1",
|
"watchfiles==1.2.0",
|
||||||
"whitenoise[brotli]==6.11.0",
|
"whitenoise[brotli]==6.12.0",
|
||||||
"xlrd~=2.0.1",
|
"xlrd~=2.0.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user