From 0d0bb1a55939f2a383ea378e205b4fe1b1db1cf3 Mon Sep 17 00:00:00 2001 From: Jens L Date: Mon, 22 May 2023 17:24:12 +0200 Subject: [PATCH] root: add install ID (#5717) * root: add install ID Signed-off-by: Jens Langhammer * fix tests Signed-off-by: Jens Langhammer * add fallback when no migrations table exists Signed-off-by: Jens Langhammer * fix lint Signed-off-by: Jens Langhammer --------- Signed-off-by: Jens Langhammer --- authentik/core/models.py | 4 +- authentik/flows/views/inspector.py | 3 +- authentik/root/install_id.py | 17 +++++++ authentik/root/middleware.py | 13 ++++-- .../stages/authenticator_validate/stage.py | 3 +- .../authenticator_validate/tests/test_totp.py | 8 ++-- lifecycle/gunicorn.conf.py | 5 +-- lifecycle/system_migrations/install_id.py | 45 +++++++++++++++++++ 8 files changed, 84 insertions(+), 14 deletions(-) create mode 100644 authentik/root/install_id.py create mode 100644 lifecycle/system_migrations/install_id.py diff --git a/authentik/core/models.py b/authentik/core/models.py index 3be7aefae..3fd8a44a0 100644 --- a/authentik/core/models.py +++ b/authentik/core/models.py @@ -5,7 +5,6 @@ from typing import Any, Optional from uuid import uuid4 from deepmerge import always_merger -from django.conf import settings from django.contrib.auth.hashers import check_password from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import UserManager as DjangoUserManager @@ -33,6 +32,7 @@ from authentik.lib.models import ( ) from authentik.lib.utils.http import get_client_ip from authentik.policies.models import PolicyBindingModel +from authentik.root.install_id import get_install_id LOGGER = get_logger() USER_ATTRIBUTE_DEBUG = "goauthentik.io/user/debug" @@ -217,7 +217,7 @@ class User(SerializerModel, GuardianUserMixin, AbstractUser): @property def uid(self) -> str: """Generate a globally unique UID, based on the user ID and the hashed secret key""" - return sha256(f"{self.id}-{settings.SECRET_KEY}".encode("ascii")).hexdigest() + return sha256(f"{self.id}-{get_install_id()}".encode("ascii")).hexdigest() def locale(self, request: Optional[HttpRequest] = None) -> str: """Get the locale the user has configured""" diff --git a/authentik/flows/views/inspector.py b/authentik/flows/views/inspector.py index 9cf9e7ce5..3593c65da 100644 --- a/authentik/flows/views/inspector.py +++ b/authentik/flows/views/inspector.py @@ -23,6 +23,7 @@ from authentik.flows.api.bindings import FlowStageBindingSerializer from authentik.flows.models import Flow from authentik.flows.planner import FlowPlan from authentik.flows.views.executor import SESSION_KEY_HISTORY, SESSION_KEY_PLAN +from authentik.root.install_id import get_install_id class FlowInspectorPlanSerializer(PassiveSerializer): @@ -51,7 +52,7 @@ class FlowInspectorPlanSerializer(PassiveSerializer): """Get a unique session ID""" request: Request = self.context["request"] return sha256( - f"{request._request.session.session_key}-{settings.SECRET_KEY}".encode("ascii") + f"{request._request.session.session_key}-{get_install_id()}".encode("ascii") ).hexdigest() diff --git a/authentik/root/install_id.py b/authentik/root/install_id.py new file mode 100644 index 000000000..415cd0664 --- /dev/null +++ b/authentik/root/install_id.py @@ -0,0 +1,17 @@ +"""install ID""" +from functools import lru_cache +from uuid import uuid4 + +from django.conf import settings +from django.db import connection + + +@lru_cache +def get_install_id() -> str: + """Get install ID of this instance. The method is cached as the install ID is + not expected to change""" + if settings.TEST: + return str(uuid4()) + with connection.cursor() as cursor: + cursor.execute("SELECT id FROM authentik_install_id LIMIT 1;") + return cursor.fetchone()[0] diff --git a/authentik/root/middleware.py b/authentik/root/middleware.py index a6b0d9c7e..353a58ff0 100644 --- a/authentik/root/middleware.py +++ b/authentik/root/middleware.py @@ -1,4 +1,5 @@ """Dynamically set SameSite depending if the upstream connection is TLS or not""" +from functools import lru_cache from hashlib import sha512 from time import time from timeit import default_timer @@ -16,10 +17,16 @@ from jwt import PyJWTError, decode, encode from structlog.stdlib import get_logger from authentik.lib.utils.http import get_client_ip +from authentik.root.install_id import get_install_id LOGGER = get_logger("authentik.asgi") ACR_AUTHENTIK_SESSION = "goauthentik.io/core/default" -SIGNING_HASH = sha512(settings.SECRET_KEY.encode()).hexdigest() + + +@lru_cache +def get_signing_hash(): + """Get cookie JWT signing hash""" + return sha512(get_install_id().encode()).hexdigest() class SessionMiddleware(UpstreamSessionMiddleware): @@ -47,7 +54,7 @@ class SessionMiddleware(UpstreamSessionMiddleware): # for testing setups, where the session is directly set session_key = key if settings.TEST else None try: - session_payload = decode(key, SIGNING_HASH, algorithms=["HS256"]) + session_payload = decode(key, get_signing_hash(), algorithms=["HS256"]) session_key = session_payload["sid"] except (KeyError, PyJWTError): pass @@ -114,7 +121,7 @@ class SessionMiddleware(UpstreamSessionMiddleware): } if request.user.is_authenticated: payload["sub"] = request.user.uid - value = encode(payload=payload, key=SIGNING_HASH) + value = encode(payload=payload, key=get_signing_hash()) if settings.TEST: value = request.session.session_key response.set_cookie( diff --git a/authentik/stages/authenticator_validate/stage.py b/authentik/stages/authenticator_validate/stage.py index 5923c2089..6a7257f9c 100644 --- a/authentik/stages/authenticator_validate/stage.py +++ b/authentik/stages/authenticator_validate/stage.py @@ -20,6 +20,7 @@ from authentik.flows.models import FlowDesignation, NotConfiguredAction, Stage from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER from authentik.flows.stage import ChallengeStageView from authentik.lib.utils.time import timedelta_from_string +from authentik.root.install_id import get_install_id from authentik.stages.authenticator_sms.models import SMSDevice from authentik.stages.authenticator_validate.challenge import ( DeviceChallenge, @@ -316,7 +317,7 @@ class AuthenticatorValidateStageView(ChallengeStageView): def cookie_jwt_key(self) -> str: """Signing key for MFA Cookie for this stage""" return sha256( - f"{settings.SECRET_KEY}:{self.executor.current_stage.pk.hex}".encode("ascii") + f"{get_install_id()}:{self.executor.current_stage.pk.hex}".encode("ascii") ).hexdigest() def check_mfa_cookie(self, allowed_devices: list[Device]): diff --git a/authentik/stages/authenticator_validate/tests/test_totp.py b/authentik/stages/authenticator_validate/tests/test_totp.py index 10250fcaa..e5ecb7fbc 100644 --- a/authentik/stages/authenticator_validate/tests/test_totp.py +++ b/authentik/stages/authenticator_validate/tests/test_totp.py @@ -3,7 +3,6 @@ from datetime import datetime, timedelta from hashlib import sha256 from time import sleep -from django.conf import settings from django.test.client import RequestFactory from django.urls.base import reverse from django_otp.oath import TOTP @@ -17,6 +16,7 @@ from authentik.flows.stage import StageView from authentik.flows.tests import FlowTestCase from authentik.flows.views.executor import FlowExecutorView from authentik.lib.generators import generate_id +from authentik.root.install_id import get_install_id from authentik.stages.authenticator_validate.challenge import ( get_challenge_for_device, validate_challenge_code, @@ -194,7 +194,7 @@ class AuthenticatorValidateStageTOTPTests(FlowTestCase): "stage": stage.pk.hex + generate_id(), "exp": (datetime.now() + timedelta(days=3)).timestamp(), }, - key=sha256(f"{settings.SECRET_KEY}:{stage.pk.hex}".encode("ascii")).hexdigest(), + key=sha256(f"{get_install_id()}:{stage.pk.hex}".encode("ascii")).hexdigest(), ) response = self.client.post( reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), @@ -233,7 +233,7 @@ class AuthenticatorValidateStageTOTPTests(FlowTestCase): "stage": stage.pk.hex, "exp": (datetime.now() + timedelta(days=3)).timestamp(), }, - key=sha256(f"{settings.SECRET_KEY}:{stage.pk.hex}".encode("ascii")).hexdigest(), + key=sha256(f"{get_install_id()}:{stage.pk.hex}".encode("ascii")).hexdigest(), ) response = self.client.post( reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), @@ -272,7 +272,7 @@ class AuthenticatorValidateStageTOTPTests(FlowTestCase): "stage": stage.pk.hex, "exp": (datetime.now() - timedelta(days=3)).timestamp(), }, - key=sha256(f"{settings.SECRET_KEY}:{stage.pk.hex}".encode("ascii")).hexdigest(), + key=sha256(f"{get_install_id()}:{stage.pk.hex}".encode("ascii")).hexdigest(), ) response = self.client.post( reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), diff --git a/lifecycle/gunicorn.conf.py b/lifecycle/gunicorn.conf.py index d2969edfb..ccf80ffb4 100644 --- a/lifecycle/gunicorn.conf.py +++ b/lifecycle/gunicorn.conf.py @@ -15,6 +15,7 @@ from authentik import get_full_version from authentik.lib.config import CONFIG from authentik.lib.utils.http import get_http_session from authentik.lib.utils.reflection import get_env +from authentik.root.install_id import get_install_id from lifecycle.worker import DjangoUvicornWorker if TYPE_CHECKING: @@ -148,9 +149,7 @@ if not CONFIG.y_bool("disable_startup_analytics", False): ), }, headers={ - "User-Agent": sha512(str(CONFIG.y("secret_key")).encode("ascii")).hexdigest()[ - :16 - ], + "User-Agent": sha512(get_install_id().encode("ascii")).hexdigest()[:16], "Content-Type": "application/json", }, timeout=5, diff --git a/lifecycle/system_migrations/install_id.py b/lifecycle/system_migrations/install_id.py new file mode 100644 index 000000000..e00430478 --- /dev/null +++ b/lifecycle/system_migrations/install_id.py @@ -0,0 +1,45 @@ +# flake8: noqa +from uuid import uuid4 + +from authentik.lib.config import CONFIG +from lifecycle.migrate import BaseMigration + +SQL_STATEMENT = """BEGIN TRANSACTION; +CREATE TABLE IF NOT EXISTS authentik_install_id ( + id TEXT NOT NULL +); +COMMIT;""" + + +class Migration(BaseMigration): + def needs_migration(self) -> bool: + self.cur.execute( + "select * from information_schema.tables where table_name = 'authentik_install_id';" + ) + return not bool(self.cur.rowcount) + + def upgrade(self, migrate=False): + self.cur.execute(SQL_STATEMENT) + self.con.commit() + if migrate: + # If we already have migrations in the database, assume we're upgrading an existing install + # and set the install id to the secret key + self.cur.execute( + "INSERT INTO authentik_install_id (id) VALUES (%s)", (CONFIG.y("secret_key"),) + ) + else: + # Otherwise assume a new install, generate an install ID based on a UUID + install_id = str(uuid4()) + self.cur.execute("INSERT INTO authentik_install_id (id) VALUES (%s)", (install_id,)) + self.con.commit() + + def run(self): + self.cur.execute( + "select * from information_schema.tables where table_name = 'django_migrations';" + ) + if not bool(self.cur.rowcount): + # No django_migrations table, so generate a new id + return self.upgrade(migrate=False) + self.cur.execute("select count(*) from django_migrations;") + migrations = self.cur.fetchone()[0] + return self.upgrade(migrate=migrations > 0)