migrate to cloud gateway
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
parent
247b7a7c87
commit
245153c6de
|
@ -62,7 +62,7 @@ class LicenseKey:
|
|||
except PyJWTError:
|
||||
raise ValidationError("Unable to verify license")
|
||||
x5c: list[str] = headers.get("x5c", [])
|
||||
if len(x5c) < 1:
|
||||
if len(x5c) < 2:
|
||||
raise ValidationError("Unable to verify license")
|
||||
try:
|
||||
our_cert = load_der_x509_certificate(b64decode(x5c[0]))
|
||||
|
|
|
@ -15,7 +15,7 @@ class AuthenticatorMobileStageSerializer(StageSerializer):
|
|||
"configure_flow",
|
||||
"friendly_name",
|
||||
"item_matching_mode",
|
||||
"firebase_config",
|
||||
"cgw_endpoint",
|
||||
]
|
||||
|
||||
|
||||
|
|
|
@ -0,0 +1,59 @@
|
|||
"""Cloud-gateway client helpers"""
|
||||
from functools import lru_cache
|
||||
|
||||
from authentik_cloud_gateway_client.authenticationPush_pb2_grpc import AuthenticationPushStub
|
||||
from django.conf import settings
|
||||
from grpc import (
|
||||
UnaryStreamClientInterceptor,
|
||||
UnaryUnaryClientInterceptor,
|
||||
insecure_channel,
|
||||
intercept_channel,
|
||||
)
|
||||
from grpc._interceptor import _ClientCallDetails
|
||||
|
||||
|
||||
class AuthInterceptor(UnaryUnaryClientInterceptor, UnaryStreamClientInterceptor):
|
||||
"""GRPC auth interceptor"""
|
||||
|
||||
def __init__(self, token: str) -> None:
|
||||
super().__init__()
|
||||
self.token = token
|
||||
|
||||
def _intercept_client_call_details(self, details: _ClientCallDetails) -> _ClientCallDetails:
|
||||
"""inject auth header"""
|
||||
metadata = []
|
||||
if details.metadata is not None:
|
||||
metadata = list(details.metadata)
|
||||
metadata.append(
|
||||
(
|
||||
"authorization",
|
||||
f"Bearer {self.token}",
|
||||
)
|
||||
)
|
||||
return _ClientCallDetails(
|
||||
details.method,
|
||||
details.timeout,
|
||||
metadata,
|
||||
details.credentials,
|
||||
details.wait_for_ready,
|
||||
details.compression,
|
||||
)
|
||||
|
||||
def intercept_unary_unary(self, continuation, client_call_details: _ClientCallDetails, request):
|
||||
return continuation(self._intercept_client_call_details(client_call_details), request)
|
||||
|
||||
def intercept_unary_stream(
|
||||
self, continuation, client_call_details: _ClientCallDetails, request
|
||||
):
|
||||
return continuation(self._intercept_client_call_details(client_call_details), request)
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_client(addr: str):
|
||||
"""get a cached client to a cloud-gateway"""
|
||||
target = addr
|
||||
if settings.DEBUG:
|
||||
target = insecure_channel(target)
|
||||
channel = intercept_channel(target, AuthInterceptor("foo"))
|
||||
client = AuthenticationPushStub(channel)
|
||||
return client
|
|
@ -1,4 +1,4 @@
|
|||
# Generated by Django 4.2.7 on 2023-12-14 20:06
|
||||
# Generated by Django 4.2.7 on 2023-12-15 16:02
|
||||
|
||||
import uuid
|
||||
|
||||
|
@ -45,7 +45,7 @@ class Migration(migrations.Migration):
|
|||
default="number_matching_3",
|
||||
),
|
||||
),
|
||||
("firebase_config", models.JSONField(default=dict, help_text="temp")),
|
||||
("cgw_endpoint", models.URLField()),
|
||||
(
|
||||
"configure_flow",
|
||||
models.ForeignKey(
|
||||
|
|
|
@ -1,28 +1,21 @@
|
|||
"""Mobile authenticator stage"""
|
||||
from json import dumps
|
||||
from secrets import choice
|
||||
from time import sleep
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from authentik_cloud_gateway_client.authenticationPush_pb2 import (
|
||||
AuthenticationCheckRequest,
|
||||
AuthenticationRequest,
|
||||
AuthenticationResponse,
|
||||
AuthenticationResponseStatus,
|
||||
)
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import models
|
||||
from django.http import HttpRequest
|
||||
from django.utils.translation import gettext as __
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views import View
|
||||
from firebase_admin import credentials, initialize_app
|
||||
from firebase_admin.exceptions import FirebaseError
|
||||
from firebase_admin.messaging import (
|
||||
AndroidConfig,
|
||||
AndroidNotification,
|
||||
APNSConfig,
|
||||
APNSPayload,
|
||||
Aps,
|
||||
Message,
|
||||
Notification,
|
||||
send,
|
||||
)
|
||||
from grpc import RpcError
|
||||
from rest_framework.serializers import BaseSerializer, Serializer
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
|
@ -32,6 +25,7 @@ from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage
|
|||
from authentik.lib.generators import generate_code_fixed_length, generate_id
|
||||
from authentik.lib.models import SerializerModel
|
||||
from authentik.stages.authenticator.models import Device
|
||||
from authentik.stages.authenticator_mobile.cloud_gateway import get_client
|
||||
from authentik.tenants.utils import DEFAULT_TENANT
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
@ -56,7 +50,7 @@ class AuthenticatorMobileStage(ConfigurableStage, FriendlyNamedStage, Stage):
|
|||
item_matching_mode = models.TextField(
|
||||
choices=ItemMatchingMode.choices, default=ItemMatchingMode.NUMBER_MATCHING_3
|
||||
)
|
||||
firebase_config = models.JSONField(default=dict, help_text="temp")
|
||||
cgw_endpoint = models.URLField()
|
||||
|
||||
def create_transaction(self, device: "MobileDevice") -> "MobileTransaction":
|
||||
"""Create a transaction for `device` with the config of this stage."""
|
||||
|
@ -66,16 +60,16 @@ class AuthenticatorMobileStage(ConfigurableStage, FriendlyNamedStage, Stage):
|
|||
transaction.correct_item = TransactionStates.ACCEPT
|
||||
if self.item_matching_mode == ItemMatchingMode.NUMBER_MATCHING_2:
|
||||
transaction.decision_items = [
|
||||
generate_code_fixed_length(2),
|
||||
generate_code_fixed_length(2),
|
||||
generate_code_fixed_length(2),
|
||||
str(generate_code_fixed_length(2)),
|
||||
str(generate_code_fixed_length(2)),
|
||||
str(generate_code_fixed_length(2)),
|
||||
]
|
||||
transaction.correct_item = choice(transaction.decision_items)
|
||||
if self.item_matching_mode == ItemMatchingMode.NUMBER_MATCHING_3:
|
||||
transaction.decision_items = [
|
||||
generate_code_fixed_length(3),
|
||||
generate_code_fixed_length(3),
|
||||
generate_code_fixed_length(3),
|
||||
str(generate_code_fixed_length(3)),
|
||||
str(generate_code_fixed_length(3)),
|
||||
str(generate_code_fixed_length(3)),
|
||||
]
|
||||
transaction.correct_item = choice(transaction.decision_items)
|
||||
transaction.save()
|
||||
|
@ -181,17 +175,18 @@ class MobileTransaction(ExpiringModel):
|
|||
|
||||
def send_message(self, request: Optional[HttpRequest], **context):
|
||||
"""Send mobile message"""
|
||||
app = initialize_app(
|
||||
credentials.Certificate(self.device.stage.firebase_config), name=str(self.tx_id)
|
||||
)
|
||||
branding = DEFAULT_TENANT.branding_title
|
||||
domain = ""
|
||||
if request:
|
||||
branding = request.tenant.branding_title
|
||||
domain = request.get_host()
|
||||
user: User = self.device.user
|
||||
message = Message(
|
||||
notification=Notification(
|
||||
|
||||
client = get_client(self.device.stage.cgw_endpoint)
|
||||
try:
|
||||
response = client.SendRequest(
|
||||
AuthenticationRequest(
|
||||
device_token=self.device.firebase_token,
|
||||
title=__("%(brand)s authentication request" % {"brand": branding}),
|
||||
body=__(
|
||||
"%(user)s is attempting to log in to %(domain)s"
|
||||
|
@ -200,51 +195,36 @@ class MobileTransaction(ExpiringModel):
|
|||
"domain": domain,
|
||||
}
|
||||
),
|
||||
),
|
||||
android=AndroidConfig(
|
||||
priority="normal",
|
||||
notification=AndroidNotification(icon="stock_ticker_update", color="#f45342"),
|
||||
data={
|
||||
"authentik_tx_id": str(self.tx_id),
|
||||
"authentik_user_decision_items": dumps(self.decision_items),
|
||||
},
|
||||
),
|
||||
apns=APNSConfig(
|
||||
headers={"apns-push-type": "alert", "apns-priority": "10"},
|
||||
payload=APNSPayload(
|
||||
aps=Aps(
|
||||
badge=0,
|
||||
sound="default",
|
||||
content_available=True,
|
||||
category="cat_authentik_push_authorization",
|
||||
),
|
||||
interruption_level="time-sensitive",
|
||||
authentik_tx_id=str(self.tx_id),
|
||||
authentik_user_decision_items=self.decision_items,
|
||||
),
|
||||
),
|
||||
token=self.device.firebase_token,
|
||||
tx_id=str(self.tx_id),
|
||||
items=self.decision_items,
|
||||
mode=self.device.stage.item_matching_mode,
|
||||
)
|
||||
)
|
||||
try:
|
||||
response = send(message, app=app)
|
||||
LOGGER.debug("Sent notification", id=response, tx_id=self.tx_id)
|
||||
except (ValueError, FirebaseError) as exc:
|
||||
except RpcError as exc:
|
||||
LOGGER.warning("failed to push", exc=exc, code=exc.code(), tx_id=self.tx_id)
|
||||
except ValueError as exc:
|
||||
LOGGER.warning("failed to push", exc=exc, tx_id=self.tx_id)
|
||||
return True
|
||||
|
||||
def wait_for_response(self, max_checks=30) -> TransactionStates:
|
||||
"""Wait for a change in status"""
|
||||
checks = 0
|
||||
while True:
|
||||
self.refresh_from_db()
|
||||
if self.status in [TransactionStates.ACCEPT, TransactionStates.DENY]:
|
||||
self.delete()
|
||||
return self.status
|
||||
checks += 1
|
||||
if checks > max_checks:
|
||||
self.delete()
|
||||
client = get_client(self.device.stage.cgw_endpoint)
|
||||
for response in client.CheckStatus(
|
||||
AuthenticationCheckRequest(tx_id=self.tx_id, attempts=max_checks)
|
||||
).next():
|
||||
response: AuthenticationResponse
|
||||
if response.status == AuthenticationResponseStatus.ANSWERED:
|
||||
self.selected_item = response.decided_item
|
||||
self.save()
|
||||
elif response.status == AuthenticationResponseStatus.FAILED:
|
||||
raise TimeoutError()
|
||||
sleep(1)
|
||||
elif response.status in [
|
||||
AuthenticationResponseStatus.UNKNOWN,
|
||||
AuthenticationResponseStatus.SENT,
|
||||
]:
|
||||
continue
|
||||
self.delete()
|
||||
|
||||
|
||||
class MobileDeviceToken(ExpiringModel):
|
||||
|
|
|
@ -6032,11 +6032,12 @@
|
|||
],
|
||||
"title": "Item matching mode"
|
||||
},
|
||||
"firebase_config": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"title": "Firebase config",
|
||||
"description": "temp"
|
||||
"cgw_endpoint": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"maxLength": 200,
|
||||
"minLength": 1,
|
||||
"title": "Cgw endpoint"
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
|
|
|
@ -172,7 +172,7 @@ wsproto = "*"
|
|||
xmlsec = "*"
|
||||
zxcvbn = "*"
|
||||
jsonpatch = "*"
|
||||
firebase-admin = "*"
|
||||
authentik-cloud-gateway-client-dev = {version = "*", allow-prereleases = true, source = "test-pypi"}
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
bandit = "*"
|
||||
|
@ -198,6 +198,16 @@ requests-mock = "*"
|
|||
ruff = "*"
|
||||
selenium = "*"
|
||||
|
||||
[[tool.poetry.source]]
|
||||
name = "test-pypi"
|
||||
url = "https://test.pypi.org/simple/"
|
||||
priority = "primary"
|
||||
|
||||
|
||||
[[tool.poetry.source]]
|
||||
name = "PyPI"
|
||||
priority = "primary"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=1.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
|
28
schema.yml
28
schema.yml
|
@ -29542,11 +29542,12 @@ components:
|
|||
nullable: true
|
||||
item_matching_mode:
|
||||
$ref: '#/components/schemas/ItemMatchingModeEnum'
|
||||
firebase_config:
|
||||
type: object
|
||||
additionalProperties: {}
|
||||
description: temp
|
||||
cgw_endpoint:
|
||||
type: string
|
||||
format: uri
|
||||
maxLength: 200
|
||||
required:
|
||||
- cgw_endpoint
|
||||
- component
|
||||
- meta_model_name
|
||||
- name
|
||||
|
@ -29576,11 +29577,13 @@ components:
|
|||
minLength: 1
|
||||
item_matching_mode:
|
||||
$ref: '#/components/schemas/ItemMatchingModeEnum'
|
||||
firebase_config:
|
||||
type: object
|
||||
additionalProperties: {}
|
||||
description: temp
|
||||
cgw_endpoint:
|
||||
type: string
|
||||
format: uri
|
||||
minLength: 1
|
||||
maxLength: 200
|
||||
required:
|
||||
- cgw_endpoint
|
||||
- name
|
||||
AuthenticatorSMSChallenge:
|
||||
type: object
|
||||
|
@ -37187,10 +37190,11 @@ components:
|
|||
minLength: 1
|
||||
item_matching_mode:
|
||||
$ref: '#/components/schemas/ItemMatchingModeEnum'
|
||||
firebase_config:
|
||||
type: object
|
||||
additionalProperties: {}
|
||||
description: temp
|
||||
cgw_endpoint:
|
||||
type: string
|
||||
format: uri
|
||||
minLength: 1
|
||||
maxLength: 200
|
||||
PatchedAuthenticatorSMSStageRequest:
|
||||
type: object
|
||||
description: AuthenticatorSMSStage Serializer
|
||||
|
|
|
@ -110,16 +110,15 @@ export class AuthenticatorMobileStageForm extends ModelForm<AuthenticatorMobileS
|
|||
</ak-radio>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal
|
||||
label=${msg("Firebase config")}
|
||||
label=${msg("Cloud Gateway endpoint")}
|
||||
?required=${false}
|
||||
name="firebaseConfig"
|
||||
name="cgwEndpoint"
|
||||
>
|
||||
<ak-codemirror
|
||||
mode="javascript"
|
||||
value="${first(this.instance?.firebaseConfig, {})}"
|
||||
>
|
||||
</ak-codemirror>
|
||||
<p class="pf-c-form__helper-text">${msg("Firebase JSON.")}</p>
|
||||
<input
|
||||
type="text"
|
||||
value="${first(this.instance?.cgwEndpoint, "http://localhost:3415")}"
|
||||
class="pf-c-form-control"
|
||||
/>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal
|
||||
label=${msg("Configuration flow")}
|
||||
|
|
Reference in New Issue