diff --git a/authentik/core/auth.py b/authentik/core/auth.py new file mode 100644 index 000000000..42a84cf14 --- /dev/null +++ b/authentik/core/auth.py @@ -0,0 +1,56 @@ +"""Authenticate with tokens""" + +from typing import Any, Optional + +from django.contrib.auth.backends import ModelBackend +from django.http.request import HttpRequest + +from authentik.core.models import Token, TokenIntents, User +from authentik.flows.planner import FlowPlan +from authentik.flows.views import SESSION_KEY_PLAN +from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS + + +class InbuiltBackend(ModelBackend): + """Inbuilt backend""" + + def authenticate( + self, request: HttpRequest, username: Optional[str], password: Optional[str], **kwargs: Any + ) -> Optional[User]: + user = super().authenticate(request, username=username, password=password, **kwargs) + if not user: + return None + # Since we can't directly pass other variables to signals, and we want to log the method + # and the token used, we assume we're running in a flow and set a variable in the context + flow_plan: FlowPlan = request.session[SESSION_KEY_PLAN] + flow_plan.context[PLAN_CONTEXT_METHOD] = "password" + request.session[SESSION_KEY_PLAN] = flow_plan + return user + + +class TokenBackend(ModelBackend): + """Authenticate with token""" + + def authenticate( + self, request: HttpRequest, username: Optional[str], password: Optional[str], **kwargs: Any + ) -> Optional[User]: + try: + user = User._default_manager.get_by_natural_key(username) + except User.DoesNotExist: + # Run the default password hasher once to reduce the timing + # difference between an existing and a nonexistent user (#20760). + User().set_password(password) + return None + tokens = Token.filter_not_expired( + user=user, key=password, intent=TokenIntents.INTENT_APP_PASSWORD + ) + if not tokens.exists(): + return None + token = tokens.first() + # Since we can't directly pass other variables to signals, and we want to log the method + # and the token used, we assume we're running in a flow and set a variable in the context + flow_plan: FlowPlan = request.session[SESSION_KEY_PLAN] + flow_plan.context[PLAN_CONTEXT_METHOD] = "app_password" + flow_plan.context[PLAN_CONTEXT_METHOD_ARGS] = {"token": token} + request.session[SESSION_KEY_PLAN] = flow_plan + return token.user diff --git a/authentik/core/sources/flow_manager.py b/authentik/core/sources/flow_manager.py index 7666da07f..0cac12440 100644 --- a/authentik/core/sources/flow_manager.py +++ b/authentik/core/sources/flow_manager.py @@ -25,7 +25,7 @@ from authentik.flows.planner import ( from authentik.flows.views import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN from authentik.lib.utils.urls import redirect_with_qs from authentik.policies.utils import delete_none_keys -from authentik.stages.password import BACKEND_DJANGO +from authentik.stages.password import BACKEND_INBUILT from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT @@ -189,7 +189,7 @@ class SourceFlowManager: kwargs.update( { # Since we authenticate the user by their token, they have no backend set - PLAN_CONTEXT_AUTHENTICATION_BACKEND: BACKEND_DJANGO, + PLAN_CONTEXT_AUTHENTICATION_BACKEND: BACKEND_INBUILT, PLAN_CONTEXT_SSO: True, PLAN_CONTEXT_SOURCE: self.source, PLAN_CONTEXT_REDIRECT: final_redirect, diff --git a/authentik/core/token_auth.py b/authentik/core/token_auth.py deleted file mode 100644 index cc52ed116..000000000 --- a/authentik/core/token_auth.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Authenticate with tokens""" - -from typing import Any, Optional - -from django.contrib.auth.backends import ModelBackend -from django.http.request import HttpRequest - -from authentik.core.models import Token, TokenIntents, User - - -class TokenBackend(ModelBackend): - """Authenticate with token""" - - def authenticate( - self, request: HttpRequest, username: Optional[str], password: Optional[str], **kwargs: Any - ) -> Optional[User]: - try: - user = User._default_manager.get_by_natural_key(username) - except User.DoesNotExist: - # Run the default password hasher once to reduce the timing - # difference between an existing and a nonexistent user (#20760). - User().set_password(password) - return None - tokens = Token.filter_not_expired( - user=user, key=password, intent=TokenIntents.INTENT_APP_PASSWORD - ) - if not tokens.exists(): - return None - return tokens.first().user diff --git a/authentik/events/signals.py b/authentik/events/signals.py index 7a4b5a032..be46da3d4 100644 --- a/authentik/events/signals.py +++ b/authentik/events/signals.py @@ -15,6 +15,7 @@ from authentik.flows.planner import PLAN_CONTEXT_SOURCE, FlowPlan from authentik.flows.views import SESSION_KEY_PLAN from authentik.stages.invitation.models import Invitation from authentik.stages.invitation.signals import invitation_used +from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS from authentik.stages.user_write.signals import user_write @@ -47,6 +48,10 @@ def on_user_logged_in(sender, request: HttpRequest, user: User, **_): if PLAN_CONTEXT_SOURCE in flow_plan.context: # Login request came from an external source, save it in the context thread.kwargs["using_source"] = flow_plan.context[PLAN_CONTEXT_SOURCE] + if PLAN_CONTEXT_METHOD in flow_plan.context: + thread.kwargs["method"] = flow_plan.context[PLAN_CONTEXT_METHOD] + # Save the login method used + thread.kwargs["method_args"] = flow_plan.context.get(PLAN_CONTEXT_METHOD_ARGS, {}) thread.user = user thread.run() diff --git a/authentik/flows/migrations/0008_default_flows.py b/authentik/flows/migrations/0008_default_flows.py index e2498b6c5..d507f209c 100644 --- a/authentik/flows/migrations/0008_default_flows.py +++ b/authentik/flows/migrations/0008_default_flows.py @@ -6,7 +6,7 @@ from django.db.backends.base.schema import BaseDatabaseSchemaEditor from authentik.flows.models import FlowDesignation from authentik.stages.identification.models import UserFields -from authentik.stages.password import BACKEND_APP_PASSWORD, BACKEND_DJANGO, BACKEND_LDAP +from authentik.stages.password import BACKEND_APP_PASSWORD, BACKEND_INBUILT, BACKEND_LDAP def create_default_authentication_flow(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): @@ -26,7 +26,7 @@ def create_default_authentication_flow(apps: Apps, schema_editor: BaseDatabaseSc password_stage, _ = PasswordStage.objects.using(db_alias).update_or_create( name="default-authentication-password", - defaults={"backends": [BACKEND_DJANGO, BACKEND_LDAP, BACKEND_APP_PASSWORD]}, + defaults={"backends": [BACKEND_INBUILT, BACKEND_LDAP, BACKEND_APP_PASSWORD]}, ) login_stage, _ = UserLoginStage.objects.using(db_alias).update_or_create( diff --git a/authentik/recovery/views.py b/authentik/recovery/views.py index 27b0023af..3253ab332 100644 --- a/authentik/recovery/views.py +++ b/authentik/recovery/views.py @@ -7,7 +7,7 @@ from django.utils.translation import gettext as _ from django.views import View from authentik.core.models import Token, TokenIntents -from authentik.stages.password import BACKEND_DJANGO +from authentik.stages.password import BACKEND_INBUILT class UseTokenView(View): @@ -19,7 +19,7 @@ class UseTokenView(View): if not tokens.exists(): raise Http404 token = tokens.first() - login(request, token.user, backend=BACKEND_DJANGO) + login(request, token.user, backend=BACKEND_INBUILT) token.delete() messages.warning(request, _("Used recovery-link to authenticate.")) return redirect("authentik_core:if-admin") diff --git a/authentik/root/settings.py b/authentik/root/settings.py index ab3f59cc5..08dc5697b 100644 --- a/authentik/root/settings.py +++ b/authentik/root/settings.py @@ -73,7 +73,8 @@ LANGUAGE_COOKIE_NAME = f"authentik_language{_cookie_suffix}" SESSION_COOKIE_NAME = f"authentik_session{_cookie_suffix}" AUTHENTICATION_BACKENDS = [ - "django.contrib.auth.backends.ModelBackend", + "authentik.core.auth.InbuiltBackend", + "authentik.core.auth.TokenBackend", "guardian.backends.ObjectPermissionBackend", ] diff --git a/authentik/sources/ldap/auth.py b/authentik/sources/ldap/auth.py index 9cf55d36d..10bd55b40 100644 --- a/authentik/sources/ldap/auth.py +++ b/authentik/sources/ldap/auth.py @@ -7,7 +7,10 @@ from django.http import HttpRequest from structlog.stdlib import get_logger from authentik.core.models import User +from authentik.flows.planner import FlowPlan +from authentik.flows.views import SESSION_KEY_PLAN from authentik.sources.ldap.models import LDAPSource +from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS LOGGER = get_logger() LDAP_DISTINGUISHED_NAME = "distinguishedName" @@ -24,6 +27,13 @@ class LDAPBackend(ModelBackend): LOGGER.debug("LDAP Auth attempt", source=source) user = self.auth_user(source, **kwargs) if user: + # Since we can't directly pass other variables to signals, and we want to log + # the method and the token used, we assume we're running in a flow and + # set a variable in the context + flow_plan: FlowPlan = request.session[SESSION_KEY_PLAN] + flow_plan.context[PLAN_CONTEXT_METHOD] = "ldap" + flow_plan.context[PLAN_CONTEXT_METHOD_ARGS] = {"source": source} + request.session[SESSION_KEY_PLAN] = flow_plan return user return None diff --git a/authentik/sources/ldap/settings.py b/authentik/sources/ldap/settings.py index 850e9a04d..d3b90e901 100644 --- a/authentik/sources/ldap/settings.py +++ b/authentik/sources/ldap/settings.py @@ -1,10 +1,6 @@ """LDAP Settings""" from celery.schedules import crontab -AUTHENTICATION_BACKENDS = [ - "authentik.sources.ldap.auth.LDAPBackend", -] - CELERY_BEAT_SCHEDULE = { "sources_ldap_sync": { "task": "authentik.sources.ldap.tasks.ldap_sync_all", diff --git a/authentik/sources/saml/processors/response.py b/authentik/sources/saml/processors/response.py index 0e41b44c8..c2537770c 100644 --- a/authentik/sources/saml/processors/response.py +++ b/authentik/sources/saml/processors/response.py @@ -39,7 +39,7 @@ from authentik.sources.saml.processors.constants import ( from authentik.sources.saml.processors.request import SESSION_REQUEST_ID from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT -from authentik.stages.user_login.stage import BACKEND_DJANGO +from authentik.stages.user_login.stage import BACKEND_INBUILT LOGGER = get_logger() if TYPE_CHECKING: @@ -136,7 +136,7 @@ class ResponseProcessor: self._source.authentication_flow, **{ PLAN_CONTEXT_PENDING_USER: user, - PLAN_CONTEXT_AUTHENTICATION_BACKEND: BACKEND_DJANGO, + PLAN_CONTEXT_AUTHENTICATION_BACKEND: BACKEND_INBUILT, }, ) @@ -199,7 +199,7 @@ class ResponseProcessor: self._source.authentication_flow, **{ PLAN_CONTEXT_PENDING_USER: matching_users.first(), - PLAN_CONTEXT_AUTHENTICATION_BACKEND: BACKEND_DJANGO, + PLAN_CONTEXT_AUTHENTICATION_BACKEND: BACKEND_INBUILT, PLAN_CONTEXT_REDIRECT: final_redirect, }, ) diff --git a/authentik/stages/identification/tests.py b/authentik/stages/identification/tests.py index c9d5e267f..fb2beeabe 100644 --- a/authentik/stages/identification/tests.py +++ b/authentik/stages/identification/tests.py @@ -9,7 +9,7 @@ from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding from authentik.providers.oauth2.generators import generate_client_secret from authentik.sources.oauth.models import OAuthSource from authentik.stages.identification.models import IdentificationStage, UserFields -from authentik.stages.password import BACKEND_DJANGO +from authentik.stages.password import BACKEND_INBUILT from authentik.stages.password.models import PasswordStage @@ -68,7 +68,7 @@ class TestIdentificationStage(TestCase): def test_valid_with_password(self): """Test with valid email and password in single step""" - pw_stage = PasswordStage.objects.create(name="password", backends=[BACKEND_DJANGO]) + pw_stage = PasswordStage.objects.create(name="password", backends=[BACKEND_INBUILT]) self.stage.password_stage = pw_stage self.stage.save() form_data = {"uid_field": self.user.email, "password": self.password} @@ -86,7 +86,7 @@ class TestIdentificationStage(TestCase): def test_invalid_with_password(self): """Test with valid email and invalid password in single step""" - pw_stage = PasswordStage.objects.create(name="password", backends=[BACKEND_DJANGO]) + pw_stage = PasswordStage.objects.create(name="password", backends=[BACKEND_INBUILT]) self.stage.password_stage = pw_stage self.stage.save() form_data = { diff --git a/authentik/stages/invitation/tests.py b/authentik/stages/invitation/tests.py index 45d0c97f5..6f84dca7b 100644 --- a/authentik/stages/invitation/tests.py +++ b/authentik/stages/invitation/tests.py @@ -17,7 +17,7 @@ from authentik.flows.tests.test_views import TO_STAGE_RESPONSE_MOCK from authentik.flows.views import SESSION_KEY_PLAN from authentik.stages.invitation.models import Invitation, InvitationStage from authentik.stages.invitation.stage import INVITATION_TOKEN_KEY, PLAN_CONTEXT_PROMPT -from authentik.stages.password import BACKEND_DJANGO +from authentik.stages.password import BACKEND_INBUILT from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND @@ -45,7 +45,7 @@ class TestUserLoginStage(TestCase): """Test without any invitation, continue_flow_without_invitation not set.""" plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) plan.context[PLAN_CONTEXT_PENDING_USER] = self.user - plan.context[PLAN_CONTEXT_AUTHENTICATION_BACKEND] = BACKEND_DJANGO + plan.context[PLAN_CONTEXT_AUTHENTICATION_BACKEND] = BACKEND_INBUILT session = self.client.session session[SESSION_KEY_PLAN] = plan session.save() @@ -74,7 +74,7 @@ class TestUserLoginStage(TestCase): self.stage.save() plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) plan.context[PLAN_CONTEXT_PENDING_USER] = self.user - plan.context[PLAN_CONTEXT_AUTHENTICATION_BACKEND] = BACKEND_DJANGO + plan.context[PLAN_CONTEXT_AUTHENTICATION_BACKEND] = BACKEND_INBUILT session = self.client.session session[SESSION_KEY_PLAN] = plan session.save() diff --git a/authentik/stages/password/__init__.py b/authentik/stages/password/__init__.py index fe333bd84..d5c73cc96 100644 --- a/authentik/stages/password/__init__.py +++ b/authentik/stages/password/__init__.py @@ -1,4 +1,4 @@ """Backend paths""" -BACKEND_DJANGO = "django.contrib.auth.backends.ModelBackend" +BACKEND_INBUILT = "authentik.core.auth.InbuiltBackend" BACKEND_LDAP = "authentik.sources.ldap.auth.LDAPBackend" -BACKEND_APP_PASSWORD = "authentik.core.token_auth.TokenBackend" # nosec +BACKEND_APP_PASSWORD = "authentik.core.auth.TokenBackend" # nosec diff --git a/authentik/stages/password/migrations/0007_app_password.py b/authentik/stages/password/migrations/0007_app_password.py index 168424c95..349758219 100644 --- a/authentik/stages/password/migrations/0007_app_password.py +++ b/authentik/stages/password/migrations/0007_app_password.py @@ -4,7 +4,7 @@ from django.apps.registry import Apps from django.db import migrations, models from django.db.backends.base.schema import BaseDatabaseSchemaEditor -from authentik.stages.password import BACKEND_APP_PASSWORD +from authentik.stages.password import BACKEND_APP_PASSWORD, BACKEND_INBUILT def update_default_backends(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): @@ -19,6 +19,18 @@ def update_default_backends(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) stage.save() +def replace_inbuilt(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): + PasswordStage = apps.get_model("authentik_stages_password", "passwordstage") + db_alias = schema_editor.connection.alias + + for stage in PasswordStage.objects.using(db_alias).all(): + if "django.contrib.auth.backends.ModelBackend" not in stage.backends: + continue + stage.backends.remove("django.contrib.auth.backends.ModelBackend") + stage.backends.append(BACKEND_INBUILT) + stage.save() + + class Migration(migrations.Migration): dependencies = [ @@ -33,11 +45,8 @@ class Migration(migrations.Migration): field=django.contrib.postgres.fields.ArrayField( base_field=models.TextField( choices=[ - ( - "django.contrib.auth.backends.ModelBackend", - "User database + standard password", - ), - ("authentik.core.token_auth.TokenBackend", "User database + app passwords"), + ("authentik.core.auth.InbuiltBackend", "User database + standard password"), + ("authentik.core.auth.TokenBackend", "User database + app passwords"), ( "authentik.sources.ldap.auth.LDAPBackend", "User database + LDAP password", diff --git a/authentik/stages/password/models.py b/authentik/stages/password/models.py index 50932f29e..5369052a2 100644 --- a/authentik/stages/password/models.py +++ b/authentik/stages/password/models.py @@ -9,14 +9,14 @@ from rest_framework.serializers import BaseSerializer from authentik.core.types import UserSettingSerializer from authentik.flows.models import ConfigurableStage, Stage -from authentik.stages.password import BACKEND_APP_PASSWORD, BACKEND_DJANGO, BACKEND_LDAP +from authentik.stages.password import BACKEND_APP_PASSWORD, BACKEND_INBUILT, BACKEND_LDAP def get_authentication_backends(): """Return all available authentication backends as tuple set""" return [ ( - BACKEND_DJANGO, + BACKEND_INBUILT, _("User database + standard password"), ), ( diff --git a/authentik/stages/password/stage.py b/authentik/stages/password/stage.py index f5cf96c22..aaef61618 100644 --- a/authentik/stages/password/stage.py +++ b/authentik/stages/password/stage.py @@ -27,6 +27,8 @@ from authentik.stages.password.models import PasswordStage LOGGER = get_logger() PLAN_CONTEXT_AUTHENTICATION_BACKEND = "user_backend" +PLAN_CONTEXT_METHOD = "method" +PLAN_CONTEXT_METHOD_ARGS = "method_args" SESSION_INVALID_TRIES = "user_invalid_tries" diff --git a/authentik/stages/password/tests.py b/authentik/stages/password/tests.py index 39a191098..b30a3187c 100644 --- a/authentik/stages/password/tests.py +++ b/authentik/stages/password/tests.py @@ -14,7 +14,7 @@ from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan from authentik.flows.tests.test_views import TO_STAGE_RESPONSE_MOCK from authentik.flows.views import SESSION_KEY_PLAN from authentik.providers.oauth2.generators import generate_client_secret -from authentik.stages.password import BACKEND_DJANGO +from authentik.stages.password import BACKEND_INBUILT from authentik.stages.password.models import PasswordStage MOCK_BACKEND_AUTHENTICATE = MagicMock(side_effect=PermissionDenied("test")) @@ -36,7 +36,7 @@ class TestPasswordStage(TestCase): slug="test-password", designation=FlowDesignation.AUTHENTICATION, ) - self.stage = PasswordStage.objects.create(name="password", backends=[BACKEND_DJANGO]) + self.stage = PasswordStage.objects.create(name="password", backends=[BACKEND_INBUILT]) self.binding = FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2) @patch( diff --git a/authentik/stages/user_login/stage.py b/authentik/stages/user_login/stage.py index 773ccc3ab..9de80233d 100644 --- a/authentik/stages/user_login/stage.py +++ b/authentik/stages/user_login/stage.py @@ -8,7 +8,7 @@ from structlog.stdlib import get_logger from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER from authentik.flows.stage import StageView from authentik.lib.utils.time import timedelta_from_string -from authentik.stages.password import BACKEND_DJANGO +from authentik.stages.password import BACKEND_INBUILT from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND LOGGER = get_logger() @@ -26,7 +26,7 @@ class UserLoginStageView(StageView): LOGGER.debug(message) return self.executor.stage_invalid() backend = self.executor.plan.context.get( - PLAN_CONTEXT_AUTHENTICATION_BACKEND, BACKEND_DJANGO + PLAN_CONTEXT_AUTHENTICATION_BACKEND, BACKEND_INBUILT ) login( self.request, diff --git a/authentik/stages/user_logout/tests.py b/authentik/stages/user_logout/tests.py index 2958e3f97..cf2ec16bd 100644 --- a/authentik/stages/user_logout/tests.py +++ b/authentik/stages/user_logout/tests.py @@ -9,7 +9,7 @@ from authentik.flows.markers import StageMarker from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan from authentik.flows.views import SESSION_KEY_PLAN -from authentik.stages.password import BACKEND_DJANGO +from authentik.stages.password import BACKEND_INBUILT from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND from authentik.stages.user_logout.models import UserLogoutStage @@ -34,7 +34,7 @@ class TestUserLogoutStage(TestCase): """Test with a valid pending user and backend""" plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) plan.context[PLAN_CONTEXT_PENDING_USER] = self.user - plan.context[PLAN_CONTEXT_AUTHENTICATION_BACKEND] = BACKEND_DJANGO + plan.context[PLAN_CONTEXT_AUTHENTICATION_BACKEND] = BACKEND_INBUILT session = self.client.session session[SESSION_KEY_PLAN] = plan session.save()