migrate to cloud gateway

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Jens Langhammer 2023-12-15 17:04:47 +01:00
parent 55f53e64e9
commit edccf3331a
No known key found for this signature in database
9 changed files with 154 additions and 101 deletions

View File

@ -62,7 +62,7 @@ class LicenseKey:
except PyJWTError: except PyJWTError:
raise ValidationError("Unable to verify license") raise ValidationError("Unable to verify license")
x5c: list[str] = headers.get("x5c", []) x5c: list[str] = headers.get("x5c", [])
if len(x5c) < 1: if len(x5c) < 2:
raise ValidationError("Unable to verify license") raise ValidationError("Unable to verify license")
try: try:
our_cert = load_der_x509_certificate(b64decode(x5c[0])) our_cert = load_der_x509_certificate(b64decode(x5c[0]))

View File

@ -15,7 +15,7 @@ class AuthenticatorMobileStageSerializer(StageSerializer):
"configure_flow", "configure_flow",
"friendly_name", "friendly_name",
"item_matching_mode", "item_matching_mode",
"firebase_config", "cgw_endpoint",
] ]

View File

@ -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

View File

@ -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 import uuid
@ -45,7 +45,7 @@ class Migration(migrations.Migration):
default="number_matching_3", default="number_matching_3",
), ),
), ),
("firebase_config", models.JSONField(default=dict, help_text="temp")), ("cgw_endpoint", models.URLField()),
( (
"configure_flow", "configure_flow",
models.ForeignKey( models.ForeignKey(

View File

@ -1,28 +1,21 @@
"""Mobile authenticator stage""" """Mobile authenticator stage"""
from json import dumps
from secrets import choice from secrets import choice
from time import sleep
from typing import Optional from typing import Optional
from uuid import uuid4 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.contrib.auth import get_user_model
from django.db import models from django.db import models
from django.http import HttpRequest from django.http import HttpRequest
from django.utils.translation import gettext as __ from django.utils.translation import gettext as __
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views import View from django.views import View
from firebase_admin import credentials, initialize_app from grpc import RpcError
from firebase_admin.exceptions import FirebaseError
from firebase_admin.messaging import (
AndroidConfig,
AndroidNotification,
APNSConfig,
APNSPayload,
Aps,
Message,
Notification,
send,
)
from rest_framework.serializers import BaseSerializer, Serializer from rest_framework.serializers import BaseSerializer, Serializer
from structlog.stdlib import get_logger 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.generators import generate_code_fixed_length, generate_id
from authentik.lib.models import SerializerModel from authentik.lib.models import SerializerModel
from authentik.stages.authenticator.models import Device from authentik.stages.authenticator.models import Device
from authentik.stages.authenticator_mobile.cloud_gateway import get_client
from authentik.tenants.utils import DEFAULT_TENANT from authentik.tenants.utils import DEFAULT_TENANT
LOGGER = get_logger() LOGGER = get_logger()
@ -56,7 +50,7 @@ class AuthenticatorMobileStage(ConfigurableStage, FriendlyNamedStage, Stage):
item_matching_mode = models.TextField( item_matching_mode = models.TextField(
choices=ItemMatchingMode.choices, default=ItemMatchingMode.NUMBER_MATCHING_3 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": def create_transaction(self, device: "MobileDevice") -> "MobileTransaction":
"""Create a transaction for `device` with the config of this stage.""" """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 transaction.correct_item = TransactionStates.ACCEPT
if self.item_matching_mode == ItemMatchingMode.NUMBER_MATCHING_2: if self.item_matching_mode == ItemMatchingMode.NUMBER_MATCHING_2:
transaction.decision_items = [ transaction.decision_items = [
generate_code_fixed_length(2), str(generate_code_fixed_length(2)),
generate_code_fixed_length(2), str(generate_code_fixed_length(2)),
generate_code_fixed_length(2), str(generate_code_fixed_length(2)),
] ]
transaction.correct_item = choice(transaction.decision_items) transaction.correct_item = choice(transaction.decision_items)
if self.item_matching_mode == ItemMatchingMode.NUMBER_MATCHING_3: if self.item_matching_mode == ItemMatchingMode.NUMBER_MATCHING_3:
transaction.decision_items = [ transaction.decision_items = [
generate_code_fixed_length(3), str(generate_code_fixed_length(3)),
generate_code_fixed_length(3), str(generate_code_fixed_length(3)),
generate_code_fixed_length(3), str(generate_code_fixed_length(3)),
] ]
transaction.correct_item = choice(transaction.decision_items) transaction.correct_item = choice(transaction.decision_items)
transaction.save() transaction.save()
@ -181,70 +175,56 @@ class MobileTransaction(ExpiringModel):
def send_message(self, request: Optional[HttpRequest], **context): def send_message(self, request: Optional[HttpRequest], **context):
"""Send mobile message""" """Send mobile message"""
app = initialize_app(
credentials.Certificate(self.device.stage.firebase_config), name=str(self.tx_id)
)
branding = DEFAULT_TENANT.branding_title branding = DEFAULT_TENANT.branding_title
domain = "" domain = ""
if request: if request:
branding = request.tenant.branding_title branding = request.tenant.branding_title
domain = request.get_host() domain = request.get_host()
user: User = self.device.user user: User = self.device.user
message = Message(
notification=Notification( client = get_client(self.device.stage.cgw_endpoint)
title=__("%(brand)s authentication request" % {"brand": branding}),
body=__(
"%(user)s is attempting to log in to %(domain)s"
% {
"user": user.username, # pylint: disable=no-member
"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,
)
try: try:
response = send(message, app=app) 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"
% {
"user": user.username, # pylint: disable=no-member
"domain": domain,
}
),
tx_id=str(self.tx_id),
items=self.decision_items,
mode=self.device.stage.item_matching_mode,
)
)
LOGGER.debug("Sent notification", id=response, tx_id=self.tx_id) 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) LOGGER.warning("failed to push", exc=exc, tx_id=self.tx_id)
return True return True
def wait_for_response(self, max_checks=30) -> TransactionStates: def wait_for_response(self, max_checks=30) -> TransactionStates:
"""Wait for a change in status""" """Wait for a change in status"""
checks = 0 client = get_client(self.device.stage.cgw_endpoint)
while True: for response in client.CheckStatus(
self.refresh_from_db() AuthenticationCheckRequest(tx_id=self.tx_id, attempts=max_checks)
if self.status in [TransactionStates.ACCEPT, TransactionStates.DENY]: ).next():
self.delete() response: AuthenticationResponse
return self.status if response.status == AuthenticationResponseStatus.ANSWERED:
checks += 1 self.selected_item = response.decided_item
if checks > max_checks: self.save()
self.delete() elif response.status == AuthenticationResponseStatus.FAILED:
raise TimeoutError() raise TimeoutError()
sleep(1) elif response.status in [
AuthenticationResponseStatus.UNKNOWN,
AuthenticationResponseStatus.SENT,
]:
continue
self.delete()
class MobileDeviceToken(ExpiringModel): class MobileDeviceToken(ExpiringModel):

View File

@ -6153,11 +6153,12 @@
], ],
"title": "Item matching mode" "title": "Item matching mode"
}, },
"firebase_config": { "cgw_endpoint": {
"type": "object", "type": "string",
"additionalProperties": true, "format": "uri",
"title": "Firebase config", "maxLength": 200,
"description": "temp" "minLength": 1,
"title": "Cgw endpoint"
} }
}, },
"required": [] "required": []

View File

@ -177,7 +177,7 @@ wsproto = "*"
xmlsec = "*" xmlsec = "*"
zxcvbn = "*" zxcvbn = "*"
jsonpatch = "*" jsonpatch = "*"
firebase-admin = "*" authentik-cloud-gateway-client-dev = {version = "*", allow-prereleases = true, source = "test-pypi"}
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
bandit = "*" bandit = "*"
@ -203,6 +203,16 @@ requests-mock = "*"
ruff = "*" ruff = "*"
selenium = "*" selenium = "*"
[[tool.poetry.source]]
name = "test-pypi"
url = "https://test.pypi.org/simple/"
priority = "primary"
[[tool.poetry.source]]
name = "PyPI"
priority = "primary"
[build-system] [build-system]
requires = ["poetry-core>=1.0.0"] requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"

View File

@ -30427,11 +30427,12 @@ components:
nullable: true nullable: true
item_matching_mode: item_matching_mode:
$ref: '#/components/schemas/ItemMatchingModeEnum' $ref: '#/components/schemas/ItemMatchingModeEnum'
firebase_config: cgw_endpoint:
type: object type: string
additionalProperties: {} format: uri
description: temp maxLength: 200
required: required:
- cgw_endpoint
- component - component
- meta_model_name - meta_model_name
- name - name
@ -30461,11 +30462,13 @@ components:
minLength: 1 minLength: 1
item_matching_mode: item_matching_mode:
$ref: '#/components/schemas/ItemMatchingModeEnum' $ref: '#/components/schemas/ItemMatchingModeEnum'
firebase_config: cgw_endpoint:
type: object type: string
additionalProperties: {} format: uri
description: temp minLength: 1
maxLength: 200
required: required:
- cgw_endpoint
- name - name
AuthenticatorSMSChallenge: AuthenticatorSMSChallenge:
type: object type: object
@ -38231,10 +38234,11 @@ components:
minLength: 1 minLength: 1
item_matching_mode: item_matching_mode:
$ref: '#/components/schemas/ItemMatchingModeEnum' $ref: '#/components/schemas/ItemMatchingModeEnum'
firebase_config: cgw_endpoint:
type: object type: string
additionalProperties: {} format: uri
description: temp minLength: 1
maxLength: 200
PatchedAuthenticatorSMSStageRequest: PatchedAuthenticatorSMSStageRequest:
type: object type: object
description: AuthenticatorSMSStage Serializer description: AuthenticatorSMSStage Serializer

View File

@ -110,16 +110,15 @@ export class AuthenticatorMobileStageForm extends ModelForm<AuthenticatorMobileS
</ak-radio> </ak-radio>
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-form-element-horizontal <ak-form-element-horizontal
label=${msg("Firebase config")} label=${msg("Cloud Gateway endpoint")}
?required=${false} ?required=${false}
name="firebaseConfig" name="cgwEndpoint"
> >
<ak-codemirror <input
mode="javascript" type="text"
value="${first(this.instance?.firebaseConfig, {})}" value="${first(this.instance?.cgwEndpoint, "http://localhost:3415")}"
> class="pf-c-form-control"
</ak-codemirror> />
<p class="pf-c-form__helper-text">${msg("Firebase JSON.")}</p>
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-form-element-horizontal <ak-form-element-horizontal
label=${msg("Configuration flow")} label=${msg("Configuration flow")}