root: add install ID (#5717)

* root: add install ID

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix tests

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add fallback when no migrations table exists

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix lint

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Jens L 2023-05-22 17:24:12 +02:00 committed by GitHub
parent e3e1fbad3f
commit 0d0bb1a559
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 84 additions and 14 deletions

View File

@ -5,7 +5,6 @@ from typing import Any, Optional
from uuid import uuid4 from uuid import uuid4
from deepmerge import always_merger from deepmerge import always_merger
from django.conf import settings
from django.contrib.auth.hashers import check_password from django.contrib.auth.hashers import check_password
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.contrib.auth.models import UserManager as DjangoUserManager 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.lib.utils.http import get_client_ip
from authentik.policies.models import PolicyBindingModel from authentik.policies.models import PolicyBindingModel
from authentik.root.install_id import get_install_id
LOGGER = get_logger() LOGGER = get_logger()
USER_ATTRIBUTE_DEBUG = "goauthentik.io/user/debug" USER_ATTRIBUTE_DEBUG = "goauthentik.io/user/debug"
@ -217,7 +217,7 @@ class User(SerializerModel, GuardianUserMixin, AbstractUser):
@property @property
def uid(self) -> str: def uid(self) -> str:
"""Generate a globally unique UID, based on the user ID and the hashed secret key""" """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: def locale(self, request: Optional[HttpRequest] = None) -> str:
"""Get the locale the user has configured""" """Get the locale the user has configured"""

View File

@ -23,6 +23,7 @@ from authentik.flows.api.bindings import FlowStageBindingSerializer
from authentik.flows.models import Flow from authentik.flows.models import Flow
from authentik.flows.planner import FlowPlan from authentik.flows.planner import FlowPlan
from authentik.flows.views.executor import SESSION_KEY_HISTORY, SESSION_KEY_PLAN from authentik.flows.views.executor import SESSION_KEY_HISTORY, SESSION_KEY_PLAN
from authentik.root.install_id import get_install_id
class FlowInspectorPlanSerializer(PassiveSerializer): class FlowInspectorPlanSerializer(PassiveSerializer):
@ -51,7 +52,7 @@ class FlowInspectorPlanSerializer(PassiveSerializer):
"""Get a unique session ID""" """Get a unique session ID"""
request: Request = self.context["request"] request: Request = self.context["request"]
return sha256( 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() ).hexdigest()

View File

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

View File

@ -1,4 +1,5 @@
"""Dynamically set SameSite depending if the upstream connection is TLS or not""" """Dynamically set SameSite depending if the upstream connection is TLS or not"""
from functools import lru_cache
from hashlib import sha512 from hashlib import sha512
from time import time from time import time
from timeit import default_timer from timeit import default_timer
@ -16,10 +17,16 @@ from jwt import PyJWTError, decode, encode
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.lib.utils.http import get_client_ip from authentik.lib.utils.http import get_client_ip
from authentik.root.install_id import get_install_id
LOGGER = get_logger("authentik.asgi") LOGGER = get_logger("authentik.asgi")
ACR_AUTHENTIK_SESSION = "goauthentik.io/core/default" 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): class SessionMiddleware(UpstreamSessionMiddleware):
@ -47,7 +54,7 @@ class SessionMiddleware(UpstreamSessionMiddleware):
# for testing setups, where the session is directly set # for testing setups, where the session is directly set
session_key = key if settings.TEST else None session_key = key if settings.TEST else None
try: try:
session_payload = decode(key, SIGNING_HASH, algorithms=["HS256"]) session_payload = decode(key, get_signing_hash(), algorithms=["HS256"])
session_key = session_payload["sid"] session_key = session_payload["sid"]
except (KeyError, PyJWTError): except (KeyError, PyJWTError):
pass pass
@ -114,7 +121,7 @@ class SessionMiddleware(UpstreamSessionMiddleware):
} }
if request.user.is_authenticated: if request.user.is_authenticated:
payload["sub"] = request.user.uid payload["sub"] = request.user.uid
value = encode(payload=payload, key=SIGNING_HASH) value = encode(payload=payload, key=get_signing_hash())
if settings.TEST: if settings.TEST:
value = request.session.session_key value = request.session.session_key
response.set_cookie( response.set_cookie(

View File

@ -20,6 +20,7 @@ from authentik.flows.models import FlowDesignation, NotConfiguredAction, Stage
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
from authentik.flows.stage import ChallengeStageView from authentik.flows.stage import ChallengeStageView
from authentik.lib.utils.time import timedelta_from_string 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_sms.models import SMSDevice
from authentik.stages.authenticator_validate.challenge import ( from authentik.stages.authenticator_validate.challenge import (
DeviceChallenge, DeviceChallenge,
@ -316,7 +317,7 @@ class AuthenticatorValidateStageView(ChallengeStageView):
def cookie_jwt_key(self) -> str: def cookie_jwt_key(self) -> str:
"""Signing key for MFA Cookie for this stage""" """Signing key for MFA Cookie for this stage"""
return sha256( 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() ).hexdigest()
def check_mfa_cookie(self, allowed_devices: list[Device]): def check_mfa_cookie(self, allowed_devices: list[Device]):

View File

@ -3,7 +3,6 @@ from datetime import datetime, timedelta
from hashlib import sha256 from hashlib import sha256
from time import sleep from time import sleep
from django.conf import settings
from django.test.client import RequestFactory from django.test.client import RequestFactory
from django.urls.base import reverse from django.urls.base import reverse
from django_otp.oath import TOTP 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.tests import FlowTestCase
from authentik.flows.views.executor import FlowExecutorView from authentik.flows.views.executor import FlowExecutorView
from authentik.lib.generators import generate_id from authentik.lib.generators import generate_id
from authentik.root.install_id import get_install_id
from authentik.stages.authenticator_validate.challenge import ( from authentik.stages.authenticator_validate.challenge import (
get_challenge_for_device, get_challenge_for_device,
validate_challenge_code, validate_challenge_code,
@ -194,7 +194,7 @@ class AuthenticatorValidateStageTOTPTests(FlowTestCase):
"stage": stage.pk.hex + generate_id(), "stage": stage.pk.hex + generate_id(),
"exp": (datetime.now() + timedelta(days=3)).timestamp(), "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( response = self.client.post(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
@ -233,7 +233,7 @@ class AuthenticatorValidateStageTOTPTests(FlowTestCase):
"stage": stage.pk.hex, "stage": stage.pk.hex,
"exp": (datetime.now() + timedelta(days=3)).timestamp(), "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( response = self.client.post(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
@ -272,7 +272,7 @@ class AuthenticatorValidateStageTOTPTests(FlowTestCase):
"stage": stage.pk.hex, "stage": stage.pk.hex,
"exp": (datetime.now() - timedelta(days=3)).timestamp(), "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( response = self.client.post(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),

View File

@ -15,6 +15,7 @@ from authentik import get_full_version
from authentik.lib.config import CONFIG from authentik.lib.config import CONFIG
from authentik.lib.utils.http import get_http_session from authentik.lib.utils.http import get_http_session
from authentik.lib.utils.reflection import get_env from authentik.lib.utils.reflection import get_env
from authentik.root.install_id import get_install_id
from lifecycle.worker import DjangoUvicornWorker from lifecycle.worker import DjangoUvicornWorker
if TYPE_CHECKING: if TYPE_CHECKING:
@ -148,9 +149,7 @@ if not CONFIG.y_bool("disable_startup_analytics", False):
), ),
}, },
headers={ headers={
"User-Agent": sha512(str(CONFIG.y("secret_key")).encode("ascii")).hexdigest()[ "User-Agent": sha512(get_install_id().encode("ascii")).hexdigest()[:16],
:16
],
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
timeout=5, timeout=5,

View File

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