migrate to cloud gateway
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
parent
55f53e64e9
commit
edccf3331a
|
@ -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]))
|
||||||
|
|
|
@ -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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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
|
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(
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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": []
|
||||||
|
|
|
@ -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"
|
||||||
|
|
28
schema.yml
28
schema.yml
|
@ -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
|
||||||
|
|
|
@ -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")}
|
||||||
|
|
Reference in New Issue