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:
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]))

View File

@ -15,7 +15,7 @@ class AuthenticatorMobileStageSerializer(StageSerializer):
"configure_flow",
"friendly_name",
"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
@ -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(

View File

@ -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,70 +175,56 @@ 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(
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,
)
client = get_client(self.device.stage.cgw_endpoint)
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)
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):

View File

@ -6153,11 +6153,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": []

View File

@ -177,7 +177,7 @@ wsproto = "*"
xmlsec = "*"
zxcvbn = "*"
jsonpatch = "*"
firebase-admin = "*"
authentik-cloud-gateway-client-dev = {version = "*", allow-prereleases = true, source = "test-pypi"}
[tool.poetry.dev-dependencies]
bandit = "*"
@ -203,6 +203,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"

View File

@ -30427,11 +30427,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
@ -30461,11 +30462,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
@ -38231,10 +38234,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

View File

@ -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")}