core: use custom inbuilt backend, set backend login information in flow plan for events

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
Jens Langhammer 2021-08-23 17:07:06 +02:00
parent 2655768f5a
commit 69a0153619
19 changed files with 115 additions and 65 deletions

56
authentik/core/auth.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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",

View File

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

View File

@ -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 = {

View File

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

View File

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

View File

@ -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",

View File

@ -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"),
),
(

View File

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

View File

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

View File

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

View File

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