BTHLABS-58: Share Extension in Apple Apps

This commit is contained in:
2025-10-04 08:02:13 +02:00
parent 0c12f52569
commit 99e9226338
122 changed files with 5488 additions and 411 deletions

View File

@@ -0,0 +1,29 @@
# Generated by Django 5.2.3 on 2025-09-22 07:20
import uuid6
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0005_accesstoken'),
]
operations = [
migrations.CreateModel(
name='AuthKey',
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)),
],
options={
'verbose_name': 'Auth Key',
'verbose_name_plural': 'Auth Keys',
},
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.3 on 2025-10-01 07:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0006_authkey'),
]
operations = [
migrations.AddField(
model_name='authkey',
name='consumed_at',
field=models.DateTimeField(blank=True, db_index=True, default=None, editable=False, null=True),
),
]

View File

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

View File

@@ -0,0 +1,43 @@
# -*- 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 ActiveAuthKeysManager(models.Manager):
def get_queryset(self) -> models.QuerySet[AuthKey]:
return super().get_queryset().filter(
deleted_at__isnull=True,
)
class AuthKey(Model):
key = models.CharField(
blank=False,
default=None,
null=False,
max_length=128,
db_index=True,
unique=True,
editable=False,
)
consumed_at = models.DateTimeField(
blank=True,
null=True,
default=None,
db_index=True,
editable=False,
)
objects = models.Manager()
active_objects = ActiveAuthKeysManager()
class Meta:
verbose_name = _('Auth Key')
verbose_name_plural = _('Auth Keys')
def __str__(self) -> str:
return f'<AuthKey pk={self.pk} key={self.key}>'

View File

@@ -1 +1,3 @@
from .access_tokens import AccessTokensService # noqa: F401
from .accounts import AccountsService # noqa: F401
from .auth_keys import AuthKeysService # noqa: F401

View File

@@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import logging
import uuid
from hotpocket_backend.apps.accounts.models import Account
LOGGER = logging.getLogger(__name__)
class AccountsService:
class AccountsServiceError(Exception):
pass
class AccountNotFound(AccountsServiceError):
pass
def get(self, *, pk: uuid.UUID) -> Account:
try:
query_set = Account.objects.filter(is_active=True)
return query_set.get(pk=pk)
except Account.DoesNotExist as exception:
raise self.AccountNotFound(
f'Account not found: pk=`{pk}`',
) from exception

View File

@@ -0,0 +1,72 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import datetime
import logging
import uuid
from django.utils.timezone import now
import uuid6
from hotpocket_backend.apps.accounts.models import AuthKey
from hotpocket_backend.apps.core.conf import settings
LOGGER = logging.getLogger(__name__)
class AuthKeysService:
class AuthKeysServiceError(Exception):
pass
class AuthKeyNotFound(AuthKeysServiceError):
pass
class AuthKeyExpired(AuthKeysServiceError):
pass
class AuthKeyAccessDenied(AuthKeysServiceError):
pass
def create(self, *, account_uuid: uuid.UUID) -> AuthKey:
key = str(uuid6.uuid7())
return AuthKey.objects.create(
account_uuid=account_uuid,
key=key,
)
def get(self, *, pk: uuid.UUID) -> AuthKey:
try:
query_set = AuthKey.active_objects
return query_set.get(pk=pk)
except AuthKey.DoesNotExist as exception:
raise self.AuthKeyNotFound(
f'Auth Key not found: pk=`{pk}`',
) from exception
def get_by_key(self, *, key: str, ttl: int | None = None) -> AuthKey:
try:
query_set = AuthKey.active_objects
result = query_set.get(key=key)
if ttl is None:
ttl = settings.AUTH_KEY_TTL
if ttl > 0:
if result.created_at < now() - datetime.timedelta(seconds=ttl):
raise self.AuthKeyExpired(
f'Auth Key expired: pk=`{key}`',
)
if result.consumed_at is not None:
raise self.AuthKeyExpired(
f'Auth Key already consumed: pk=`{key}`',
)
return result
except AuthKey.DoesNotExist as exception:
raise self.AuthKeyNotFound(
f'Auth Key not found: key=`{key}`',
) from exception

View File

@@ -30,3 +30,5 @@ class PSettings(typing.Protocol):
SAVES_ASSOCIATION_ADAPTER: str
UPLOADS_PATH: pathlib.Path
AUTH_KEY_TTL: int

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.2.3 on 2025-10-01 05:35
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('saves', '0007_association_target_description_and_more'),
]
operations = [
migrations.AlterField(
model_name='save',
name='url',
field=models.CharField(default=None, validators=[django.core.validators.URLValidator(schemes=['http', 'https'])]),
),
]

View File

@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from django.core import validators
from django.db import models
from django.utils.translation import gettext_lazy as _
@@ -20,6 +21,9 @@ class Save(Model):
)
url = models.CharField(
blank=False, null=False, default=None,
validators=[
validators.URLValidator(schemes=['http', 'https']),
],
)
content = models.BinaryField(
blank=True, null=True, default=None, editable=False,

View File

@@ -23,3 +23,11 @@ class UIAccessTokenOriginApp(enum.Enum):
SAFARI_WEB_EXTENSION = _('Safari Web Extension')
CHROME_EXTENSION = _('Chrome Extension')
FIREFOX_EXTENSION = _('Firefox Extension')
HOTPOCKET_DESKTOP = _('HotPocket Desktop')
HOTPOCKET_MOBILE = _('HotPocket Mobile')
class AuthSource(enum.Enum):
BROWSER_EXTENSION = 'HotPocketExtension'
DESKTOP = 'HotPocketDesktop'
MOBILE = 'HotPocketMobile'

View File

@@ -7,23 +7,37 @@ from bthlabs_jsonrpc_core import register_method
from django import db
from django.http import HttpRequest
from hotpocket_soa.services import AccessTokensService
from hotpocket_soa.services import (
AccessTokensService,
AccountsService,
AuthKeysService,
)
LOGGER = logging.getLogger(__name__)
@register_method('accounts.access_tokens.create')
@register_method('accounts.access_tokens.create', namespace='accounts')
def create(request: HttpRequest,
auth_key: str,
meta: dict,
) -> str:
with db.transaction.atomic():
try:
assert 'extension_auth_key' in request.session, 'Auth key missing'
assert request.session['extension_auth_key'] == auth_key, (
'Auth key mismatch'
auth_key_object = AuthKeysService().get_by_key(
account_uuid=None,
key=auth_key,
)
except AssertionError as exception:
except AuthKeysService.AuthKeyNotFound as exception:
LOGGER.error(
'Unable to issue access token: %s',
exception,
exc_info=exception,
)
raise
try:
account = AccountsService().get(pk=auth_key_object.account_uuid)
except AccountsService.AccountNotFound as exception:
LOGGER.error(
'Unable to issue access token: %s',
exception,
@@ -32,12 +46,9 @@ def create(request: HttpRequest,
raise
access_token = AccessTokensService().create(
account_uuid=request.user.pk,
account_uuid=account.pk,
origin=request.META['HTTP_ORIGIN'],
meta=meta,
)
request.session.pop('extension_auth_key')
request.session.save()
return access_token.key

View File

@@ -13,16 +13,18 @@ from hotpocket_soa.services import AccessTokensService
LOGGER = logging.getLogger(__name__)
@register_method('accounts.auth.check')
@register_method('accounts.auth.check', namespace='accounts')
def check(request: HttpRequest) -> bool:
return request.user.is_anonymous is False
@register_method('accounts.auth.check_access_token')
@register_method('accounts.auth.check_access_token', namespace='accounts')
def check_access_token(request: HttpRequest,
access_token: str,
meta: dict | None = None,
) -> bool:
assert request.user.is_anonymous is False, 'Not authenticated'
result = True
try:

View File

@@ -11,8 +11,27 @@
<div class="alert alert-success mt-3" role="alert">
<h4 class="alert-heading">{% translate 'Done!' %}</h4>
<p class="lead mb-0">
{% translate "You've successfully logged in to the extension." %}
{% if app_redirect_url %}
{% translate "You've successfully logged in to the application." %}
{% else %}
{% translate "You've successfully logged in to the extension." %}
{% endif %}
</p>
</div>
</div>
{% endblock %}
{% block page_scripts %}
{% if app_redirect_url %}
<script type="text/javascript">
(() => {
window.setTimeout(
() => {
window.location.replace('{{ app_redirect_url|safe }}');
},
1000,
);
})();
</script>
{% endif %}
{% endblock %}

View File

@@ -137,6 +137,8 @@ def render_access_token_app(access_token: AccessTokenOut) -> str:
AccessTokenOriginApp.SAFARI_WEB_EXTENSION,
AccessTokenOriginApp.CHROME_EXTENSION,
AccessTokenOriginApp.FIREFOX_EXTENSION,
AccessTokenOriginApp.HOTPOCKET_DESKTOP,
AccessTokenOriginApp.HOTPOCKET_MOBILE,
)
if origin_app in extension_origin_apps:
app = UIAccessTokenOriginApp[origin_app.value].value
@@ -152,7 +154,7 @@ def render_access_token_app(access_token: AccessTokenOut) -> str:
@register.filter(name='render_access_token_platform')
def render_access_token_platform(access_token: AccessTokenOut) -> str:
match access_token.meta.get('platform', None):
case 'MacIntel':
case 'MacIntel' | 'macOS':
return 'macOS'
case 'iPhone':

View File

@@ -59,6 +59,13 @@ urlpatterns = [
accounts.apps.DeleteView.as_view(),
name='ui.accounts.apps.delete',
),
path(
'accounts/rpc/',
JSONRPCView.as_view(
namespace='accounts',
),
name='ui.accounts.rpc',
),
path('accounts/', accounts.index.index, name='ui.accounts.index'),
path(
'imports/pocket/',

View File

@@ -2,27 +2,56 @@
from __future__ import annotations
import logging
import urllib.parse
import uuid
from django import db
from django.core.exceptions import PermissionDenied
from django.http import HttpRequest, HttpResponse
from django.shortcuts import redirect, render
from django.urls import reverse
from hotpocket_backend.apps.ui.constants import AuthSource
from hotpocket_soa.services import AuthKeysService
LOGGER = logging.getLogger(__name__)
SOURCE_TO_REDIRECT_SCHEME = {
AuthSource.DESKTOP.value: 'hotpocket-desktop',
AuthSource.MOBILE.value: 'hotpocket-mobile',
}
def authenticate(request: HttpRequest) -> HttpResponse:
if request.user.is_anonymous is False:
auth_key = str(uuid.uuid4())
source = request.GET.get(
'source',
request.session.get('extension_source', AuthSource.BROWSER_EXTENSION.value),
)
session_token = request.GET.get(
'session_token', request.session.get('extension_session_token', None),
)
request.session['extension_auth_key'] = auth_key
request.session.save()
if source == AuthSource.BROWSER_EXTENSION.value:
session_token = str(uuid.uuid4())
elif source in (AuthSource.DESKTOP.value, AuthSource.MOBILE.value):
assert session_token not in ('', None), 'Session token missing'
else:
raise ValueError(f'Unknown source: `{source}`')
request.session['extension_source'] = source
request.session['extension_session_token'] = session_token
request.session.save()
if request.user.is_anonymous is False:
with db.transaction.atomic():
auth_key = AuthKeysService().create(
account_uuid=request.user.pk,
)
return redirect(reverse(
'ui.integrations.extension.post_authenticate',
query=[
('auth_key', auth_key),
('auth_key', auth_key.key),
],
))
@@ -36,12 +65,35 @@ def post_authenticate(request: HttpRequest) -> HttpResponse:
assert request.user.is_anonymous is False, 'Not authenticated'
auth_key = request.GET.get('auth_key', None)
assert request.session.get('extension_auth_key', None) == auth_key, (
'Auth key mismatch'
)
assert auth_key is not None, 'Auth key missing'
source = request.session.get('extension_source', None)
assert source is not None, 'Source is missing'
session_token = request.session.get('extension_session_token', None)
assert session_token is not None, 'Session token is missing'
app_redirect_url = None
if source in (AuthSource.DESKTOP.value, AuthSource.MOBILE.value):
app_redirect_url = urllib.parse.urlunsplit((
SOURCE_TO_REDIRECT_SCHEME[source],
'post-authenticate',
'/',
urllib.parse.urlencode([
('session_token', session_token),
('auth_key', auth_key),
]),
'',
))
request.session.pop('extension_source')
request.session.pop('extension_session_token')
request.session.save()
return render(
request, 'ui/integrations/extension/post_authenticate.html',
request,
'ui/integrations/extension/post_authenticate.html',
{
'app_redirect_url': app_redirect_url,
},
)
except AssertionError as exception:
LOGGER.error(

View File

@@ -79,3 +79,5 @@ CORS_ALLOW_HEADERS = (
*default_headers,
'cookie',
)
AUTH_KEY_TTL = 30