BTHLABS-50: Safari Web extension

Co-authored-by: Tomek Wójcik <labs@tomekwojcik.pl>
Co-committed-by: Tomek Wójcik <labs@tomekwojcik.pl>
This commit is contained in:
2025-09-08 18:11:36 +00:00
committed by Tomek Wójcik
parent ffecf780ee
commit b6d02dbe78
184 changed files with 7536 additions and 163 deletions

View File

@@ -1 +1,2 @@
from .access_token import AccessToken # noqa: F401
from .account import AccountAdmin # noqa: F401

View File

@@ -0,0 +1,17 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from django.contrib import admin
from hotpocket_backend.apps.accounts.models import AccessToken
class AccessTokenAdmin(admin.ModelAdmin):
list_display = ('pk', 'account_uuid', 'origin', 'created_at', 'is_active')
search_fields = ('pk', 'account_uuid', 'key', 'origin')
def has_delete_permission(self, request, obj=None):
return request.user.is_superuser
admin.site.register(AccessToken, AccessTokenAdmin)

View File

@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import logging
import typing
from django.contrib.auth.backends import ModelBackend, UserModel
from django.http import HttpRequest
from hotpocket_backend.apps.accounts.models import AccessToken, Account
LOGGER = logging.getLogger(__name__)
class AccessTokenBackend(ModelBackend):
def authenticate(self,
request: HttpRequest,
access_token: AccessToken | None,
) -> Account | None:
if not access_token:
return None
try:
user = UserModel.objects.get(pk=access_token.account_uuid)
except UserModel.DoesNotExist as exception:
LOGGER.error(
'Unhandled exception in AccessToken auth: %s',
exception,
exc_info=exception,
)
if self.user_can_authenticate(user) is False:
return None
request.access_token = access_token
return typing.cast(Account, user)

View File

@@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import logging
from django.contrib import auth
from django.core.exceptions import ImproperlyConfigured
from django.http import HttpRequest
from django.utils.deprecation import MiddlewareMixin
from hotpocket_backend.apps.accounts.models import AccessToken, Account
LOGGER = logging.getLogger(__name__)
class AccessTokenMiddleware(MiddlewareMixin):
def process_request(self, request: HttpRequest):
if not hasattr(request, 'user'):
raise ImproperlyConfigured('No `AuthenticationMiddleware`?')
authorization_header = request.headers.get('Authorization', None)
if authorization_header is None:
return
try:
scheme, authorization = authorization_header.split(' ', maxsplit=1)
assert scheme == 'Bearer', (
f'Unsupported authorization scheme: `{scheme}`'
)
access_token = AccessToken.active_objects.get(key=authorization)
except (ValueError, AssertionError, AccessToken.DoesNotExist, Account.DoesNotExist) as exception:
LOGGER.error(
'Unhandled exception in AccessToken middleware: %s',
exception,
exc_info=exception,
)
return
account = auth.authenticate(request, access_token=access_token)
if account:
request.user = account

View File

@@ -0,0 +1,31 @@
# Generated by Django 5.2.3 on 2025-09-04 18:50
import uuid6
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0004_alter_account_settings_and_more'),
]
operations = [
migrations.CreateModel(
name='AccessToken',
fields=[
('id', models.UUIDField(default=uuid6.uuid7, editable=False, primary_key=True, serialize=False)),
('account_uuid', models.UUIDField(db_index=True, default=None)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('deleted_at', models.DateTimeField(blank=True, db_index=True, default=None, editable=False, null=True)),
('key', models.CharField(db_index=True, default=None, editable=False, max_length=128, unique=True)),
('origin', models.CharField(db_index=True, default=None)),
('meta', models.JSONField(blank=True, default=dict, null=True)),
],
options={
'verbose_name': 'Access Token',
'verbose_name_plural': 'Access Tokens',
},
),
]

View File

@@ -1 +1,2 @@
from .access_token import AccessToken # noqa: F401
from .account import Account # noqa: F401

View File

@@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from django.db import models
from django.utils.translation import gettext_lazy as _
from hotpocket_backend.apps.core.models import Model
class ActiveAccessTokensManager(models.Manager):
def get_queryset(self) -> models.QuerySet[AccessToken]:
return super().get_queryset().filter(
deleted_at__isnull=True,
)
class AccessToken(Model):
key = models.CharField(
blank=False,
default=None,
null=False,
max_length=128,
db_index=True,
unique=True,
editable=False,
)
origin = models.CharField(
blank=False, default=None, null=False, db_index=True,
)
meta = models.JSONField(blank=True, default=dict, null=True)
objects = models.Manager()
active_objects = ActiveAccessTokensManager()
class Meta:
verbose_name = _('Access Token')
verbose_name_plural = _('Access Tokens')
def __str__(self) -> str:
return f'<AccessToken pk={self.pk} account_uuid={self.account_uuid}>'

View File

@@ -0,0 +1 @@
from .access_tokens import AccessTokensService # noqa: F401

View File

@@ -0,0 +1,81 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import hashlib
import hmac
import logging
import uuid
from django.db import models
import uuid6
from hotpocket_backend.apps.accounts.models import AccessToken
from hotpocket_backend.apps.core.conf import settings
from hotpocket_soa.dto.accounts import AccessTokensQuery
LOGGER = logging.getLogger(__name__)
class AccessTokensService:
class AccessTokensServiceError(Exception):
pass
class AccessTokenNotFound(AccessTokensServiceError):
pass
def create(self,
*,
account_uuid: uuid.UUID,
origin: str,
meta: dict,
) -> AccessToken:
pk = uuid6.uuid7()
key = hmac.new(
settings.SECRET_KEY.encode('ascii'),
msg=pk.bytes,
digestmod=hashlib.sha256,
)
return AccessToken.objects.create(
pk=pk,
account_uuid=account_uuid,
key=key.hexdigest(),
origin=origin,
meta=meta,
)
def get(self, *, pk: uuid.UUID) -> AccessToken:
try:
query_set = AccessToken.active_objects
return query_set.get(pk=pk)
except AccessToken.DoesNotExist as exception:
raise self.AccessTokenNotFound(
f'Access Token not found: pk=`{pk}`',
) from exception
def search(self,
*,
query: AccessTokensQuery,
offset: int = 0,
limit: int = 10,
order_by: str = '-pk',
) -> models.QuerySet[AccessToken]:
filters = [
models.Q(account_uuid=query.account_uuid),
]
if query.before is not None:
filters.append(models.Q(pk__lt=query.before))
result = AccessToken.active_objects.\
filter(*filters).\
order_by(order_by)
return result[offset:offset + limit]
def delete(self, *, pk: uuid.UUID) -> bool:
access_token = self.get(pk=pk)
access_token.soft_delete()
return True