diff --git a/.github/workflows/ci-outpost.yml b/.github/workflows/ci-outpost.yml index 19a60cb6e..fe8ebf3b8 100644 --- a/.github/workflows/ci-outpost.yml +++ b/.github/workflows/ci-outpost.yml @@ -39,6 +39,8 @@ jobs: - uses: actions/setup-go@v4 with: go-version-file: "go.mod" + - name: Setup authentik env + uses: ./.github/actions/setup - name: Generate API run: make gen-client-go - name: Go unittests diff --git a/Dockerfile b/Dockerfile index bbbcee63e..95d20d999 100644 --- a/Dockerfile +++ b/Dockerfile @@ -146,10 +146,10 @@ USER 1000 ENV TMPDIR=/dev/shm/ \ PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ - PATH="/ak-root/venv/bin:$PATH" \ + PATH="/ak-root/venv/bin:/lifecycle:$PATH" \ VENV_PATH="/ak-root/venv" \ POETRY_VIRTUALENVS_CREATE=false -HEALTHCHECK --interval=30s --timeout=30s --start-period=60s --retries=3 CMD [ "/lifecycle/ak", "healthcheck" ] +HEALTHCHECK --interval=30s --timeout=30s --start-period=60s --retries=3 CMD [ "ak", "healthcheck" ] -ENTRYPOINT [ "dumb-init", "--", "/lifecycle/ak" ] +ENTRYPOINT [ "dumb-init", "--", "ak" ] diff --git a/Makefile b/Makefile index daae5c81d..54c53afdf 100644 --- a/Makefile +++ b/Makefile @@ -160,7 +160,7 @@ gen: gen-build gen-clean gen-client-ts web-build: web-install ## Build the Authentik UI cd web && npm run build -web: web-lint-fix web-lint web-check-compile ## Automatically fix formatting issues in the Authentik UI source code, lint the code, and compile it +web: web-lint-fix web-lint web-check-compile web-i18n-extract ## Automatically fix formatting issues in the Authentik UI source code, lint the code, and compile it web-install: ## Install the necessary libraries to build the Authentik UI cd web && npm ci diff --git a/README.md b/README.md index 360455a22..8f5f9c2a9 100644 --- a/README.md +++ b/README.md @@ -41,15 +41,3 @@ See [SECURITY.md](SECURITY.md) ## Adoption and Contributions Your organization uses authentik? We'd love to add your logo to the readme and our website! Email us @ hello@goauthentik.io or open a GitHub Issue/PR! For more information on how to contribute to authentik, please refer to our [CONTRIBUTING.md file](./CONTRIBUTING.md). - -## Sponsors - -This project is proudly sponsored by: - -
- - - -
- -DigitalOcean provides development and testing resources for authentik. diff --git a/authentik/core/api/users.py b/authentik/core/api/users.py index 2029ff7a6..09a5dc553 100644 --- a/authentik/core/api/users.py +++ b/authentik/core/api/users.py @@ -616,8 +616,10 @@ class UserViewSet(UsedByMixin, ModelViewSet): if not request.user.has_perm("impersonate"): LOGGER.debug("User attempted to impersonate without permissions", user=request.user) return Response(status=401) - user_to_be = self.get_object() + if user_to_be.pk == self.request.user.pk: + LOGGER.debug("User attempted to impersonate themselves", user=request.user) + return Response(status=401) request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER] = request.user request.session[SESSION_KEY_IMPERSONATE_USER] = user_to_be diff --git a/authentik/core/management/commands/worker.py b/authentik/core/management/commands/worker.py index e400e83ab..b22187efe 100644 --- a/authentik/core/management/commands/worker.py +++ b/authentik/core/management/commands/worker.py @@ -29,7 +29,7 @@ class Command(BaseCommand): no_color=False, quiet=True, optimization="fair", - autoscale=(3, 1), + autoscale=(CONFIG.get_int("worker.concurrency"), 1), task_events=True, beat=options.get("beat", True), schedule_filename=f"{tempdir}/celerybeat-schedule", diff --git a/authentik/core/tests/test_impersonation.py b/authentik/core/tests/test_impersonation.py index 6d17393a8..6d07f4bbc 100644 --- a/authentik/core/tests/test_impersonation.py +++ b/authentik/core/tests/test_impersonation.py @@ -6,6 +6,7 @@ from rest_framework.test import APITestCase from authentik.core.models import User from authentik.core.tests.utils import create_test_admin_user +from authentik.lib.config import CONFIG class TestImpersonation(APITestCase): @@ -46,12 +47,42 @@ class TestImpersonation(APITestCase): """test impersonation without permissions""" self.client.force_login(self.other_user) - self.client.get(reverse("authentik_api:user-impersonate", kwargs={"pk": self.user.pk})) + response = self.client.post( + reverse("authentik_api:user-impersonate", kwargs={"pk": self.user.pk}) + ) + self.assertEqual(response.status_code, 403) response = self.client.get(reverse("authentik_api:user-me")) response_body = loads(response.content.decode()) self.assertEqual(response_body["user"]["username"], self.other_user.username) + @CONFIG.patch("impersonation", False) + def test_impersonate_disabled(self): + """test impersonation that is disabled""" + self.client.force_login(self.user) + + response = self.client.post( + reverse("authentik_api:user-impersonate", kwargs={"pk": self.other_user.pk}) + ) + self.assertEqual(response.status_code, 401) + + response = self.client.get(reverse("authentik_api:user-me")) + response_body = loads(response.content.decode()) + self.assertEqual(response_body["user"]["username"], self.user.username) + + def test_impersonate_self(self): + """test impersonation that user can't impersonate themselves""" + self.client.force_login(self.user) + + response = self.client.post( + reverse("authentik_api:user-impersonate", kwargs={"pk": self.user.pk}) + ) + self.assertEqual(response.status_code, 401) + + response = self.client.get(reverse("authentik_api:user-me")) + response_body = loads(response.content.decode()) + self.assertEqual(response_body["user"]["username"], self.user.username) + def test_un_impersonate_empty(self): """test un-impersonation without impersonating first""" self.client.force_login(self.other_user) diff --git a/authentik/flows/views/executor.py b/authentik/flows/views/executor.py index 31f08c107..92b944670 100644 --- a/authentik/flows/views/executor.py +++ b/authentik/flows/views/executor.py @@ -42,6 +42,7 @@ from authentik.flows.models import ( FlowDesignation, FlowStageBinding, FlowToken, + InvalidResponseAction, Stage, ) from authentik.flows.planner import ( @@ -105,7 +106,7 @@ class FlowExecutorView(APIView): flow: Flow plan: Optional[FlowPlan] = None - current_binding: FlowStageBinding + current_binding: Optional[FlowStageBinding] = None current_stage: Stage current_stage_view: View @@ -411,6 +412,19 @@ class FlowExecutorView(APIView): Optionally, an exception can be passed, which will be shown if the current user is a superuser.""" self._logger.debug("f(exec): Stage invalid") + if self.current_binding and self.current_binding.invalid_response_action in [ + InvalidResponseAction.RESTART, + InvalidResponseAction.RESTART_WITH_CONTEXT, + ]: + keep_context = ( + self.current_binding.invalid_response_action + == InvalidResponseAction.RESTART_WITH_CONTEXT + ) + self._logger.debug( + "f(exec): Invalid response, restarting flow", + keep_context=keep_context, + ) + return self.restart_flow(keep_context) self.cancel() challenge_view = AccessDeniedChallengeView(self, error_message) challenge_view.request = self.request diff --git a/authentik/lib/default.yml b/authentik/lib/default.yml index 76792f9c8..793bece13 100644 --- a/authentik/lib/default.yml +++ b/authentik/lib/default.yml @@ -111,3 +111,6 @@ web: # No default here as it's set dynamically # workers: 2 threads: 4 + +worker: + concurrency: 2 diff --git a/authentik/root/settings.py b/authentik/root/settings.py index 4bec86fd9..a7ed583ae 100644 --- a/authentik/root/settings.py +++ b/authentik/root/settings.py @@ -37,6 +37,7 @@ CSRF_HEADER_NAME = "HTTP_X_AUTHENTIK_CSRF" LANGUAGE_COOKIE_NAME = "authentik_language" SESSION_COOKIE_NAME = "authentik_session" SESSION_COOKIE_DOMAIN = CONFIG.get("cookie_domain", None) +APPEND_SLASH = False AUTHENTICATION_BACKENDS = [ "django.contrib.auth.backends.ModelBackend", @@ -332,7 +333,7 @@ LOCALE_PATHS = ["./locale"] CELERY = { "task_soft_time_limit": 600, "worker_max_tasks_per_child": 50, - "worker_concurrency": 2, + "worker_concurrency": CONFIG.get_int("worker.concurrency"), "beat_schedule": { "clean_expired_models": { "task": "authentik.core.tasks.clean_expired_models", diff --git a/authentik/sources/ldap/sync/base.py b/authentik/sources/ldap/sync/base.py index 2a769e101..a131d935c 100644 --- a/authentik/sources/ldap/sync/base.py +++ b/authentik/sources/ldap/sync/base.py @@ -133,7 +133,7 @@ class BaseLDAPSynchronizer: def build_user_properties(self, user_dn: str, **kwargs) -> dict[str, Any]: """Build attributes for User object based on property mappings.""" props = self._build_object_properties(user_dn, self._source.property_mappings, **kwargs) - props["path"] = self._source.get_user_path() + props.setdefault("path", self._source.get_user_path()) return props def build_group_properties(self, group_dn: str, **kwargs) -> dict[str, Any]: @@ -151,7 +151,9 @@ class BaseLDAPSynchronizer: continue mapping: LDAPPropertyMapping try: - value = mapping.evaluate(user=None, request=None, ldap=kwargs, dn=object_dn) + value = mapping.evaluate( + user=None, request=None, ldap=kwargs, dn=object_dn, source=self._source + ) if value is None: self._logger.warning("property mapping returned None", mapping=mapping) continue diff --git a/authentik/sources/ldap/tests/mock_ad.py b/authentik/sources/ldap/tests/mock_ad.py index 7a13e92aa..b0914bda4 100644 --- a/authentik/sources/ldap/tests/mock_ad.py +++ b/authentik/sources/ldap/tests/mock_ad.py @@ -55,7 +55,7 @@ def mock_ad_connection(password: str) -> Connection: "revision": 0, "objectSid": "user0", "objectClass": "person", - "distinguishedName": "cn=user0,ou=users,dc=goauthentik,dc=io", + "distinguishedName": "cn=user0,ou=foo,ou=users,dc=goauthentik,dc=io", "userAccountControl": ( UserAccountControl.ACCOUNTDISABLE + UserAccountControl.NORMAL_ACCOUNT ), diff --git a/authentik/sources/ldap/tests/test_sync.py b/authentik/sources/ldap/tests/test_sync.py index 6bf459017..13035ac2b 100644 --- a/authentik/sources/ldap/tests/test_sync.py +++ b/authentik/sources/ldap/tests/test_sync.py @@ -123,6 +123,7 @@ class LDAPSyncTests(TestCase): user = User.objects.filter(username="user0_sn").first() self.assertEqual(user.attributes["foo"], "bar") self.assertFalse(user.is_active) + self.assertEqual(user.path, "goauthentik.io/sources/ldap/users/foo") self.assertFalse(User.objects.filter(username="user1_sn").exists()) def test_sync_users_openldap(self): diff --git a/blueprints/system/sources-ldap.yaml b/blueprints/system/sources-ldap.yaml index fa0056390..dbf8891e7 100644 --- a/blueprints/system/sources-ldap.yaml +++ b/blueprints/system/sources-ldap.yaml @@ -4,6 +4,27 @@ metadata: blueprints.goauthentik.io/system: "true" name: System - LDAP Source - Mappings entries: + - identifiers: + managed: goauthentik.io/sources/ldap/default-dn-path + model: authentik_sources_ldap.ldappropertymapping + attrs: + name: "authentik default LDAP Mapping: DN to User Path" + object_field: "path" + expression: | + dn = ldap.get("distinguishedName") + path_elements = [] + for pair in dn.split(","): + attr, _, value = pair.partition("=") + # Ignore elements from the Root DSE and the canonical name of the object + if attr.lower() in ["cn", "dc"]: + continue + path_elements.append(value) + path_elements.reverse() + + path = source.get_user_path() + if len(path_elements) > 0: + path = f"{path}/{'/'.join(path_elements)}" + return path - identifiers: managed: goauthentik.io/sources/ldap/default-name model: authentik_sources_ldap.ldappropertymapping diff --git a/cmd/server/server.go b/cmd/server/server.go index f80c98544..8e3c3f28b 100644 --- a/cmd/server/server.go +++ b/cmd/server/server.go @@ -9,6 +9,7 @@ import ( "github.com/getsentry/sentry-go" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" + "goauthentik.io/internal/common" "goauthentik.io/internal/config" "goauthentik.io/internal/constants" diff --git a/go.mod b/go.mod index 2c212348c..1caf0f9a2 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,6 @@ require ( beryju.io/ldap v0.1.0 github.com/Netflix/go-env v0.0.0-20210215222557-e437a7e7f9fb github.com/coreos/go-oidc v2.2.1+incompatible - github.com/garyburd/redigo v1.6.4 github.com/getsentry/sentry-go v0.24.1 github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1 github.com/go-ldap/ldap/v3 v3.4.6 @@ -23,6 +22,7 @@ require ( github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484 github.com/pires/go-proxyproto v0.7.0 github.com/prometheus/client_golang v1.16.0 + github.com/redis/go-redis/v9 v9.2.0 github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.7.0 github.com/stretchr/testify v1.8.4 @@ -30,7 +30,6 @@ require ( golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab golang.org/x/oauth2 v0.12.0 golang.org/x/sync v0.3.0 - gopkg.in/boj/redistore.v1 v1.0.0-20160128113310-fc113767cd6b gopkg.in/yaml.v2 v2.4.0 layeh.com/radius v0.0.0-20210819152912-ad72663a72ab ) @@ -41,6 +40,7 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/felixge/httpsnoop v1.0.1 // indirect github.com/go-asn1-ber/asn1-ber v1.5.5 // indirect github.com/go-http-utils/fresh v0.0.0-20161124030543-7231e26a4b27 // indirect diff --git a/go.sum b/go.sum index 269215118..db4212ebb 100644 --- a/go.sum +++ b/go.sum @@ -48,6 +48,8 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3d github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= @@ -63,14 +65,14 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/felixge/httpsnoop v1.0.1 h1:lvB5Jl89CsZtGIWuTcDM1E/vkVs49/Ml7JJe07l8SPQ= github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/garyburd/redigo v1.6.4 h1:LFu2R3+ZOPgSMWMOL+saa/zXRjw0ID2G8FepO53BGlg= -github.com/garyburd/redigo v1.6.4/go.mod h1:rTb6epsqigu3kYKBnaF028A7Tf/Aw5s0cqA47doKKqw= github.com/getsentry/sentry-go v0.24.1 h1:W6/0GyTy8J6ge6lVCc94WB6Gx2ZuLrgopnn9w8Hiwuk= github.com/getsentry/sentry-go v0.24.1/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= github.com/go-asn1-ber/asn1-ber v1.5.5 h1:MNHlNMBDgEKD4TcKr36vQN68BA00aDfjIt3/bD50WnA= @@ -286,6 +288,8 @@ github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc= github.com/prometheus/procfs v0.10.1 h1:kYK1Va/YMlutzCGazswoHKo//tZVlFpKYh+PymziUAg= github.com/prometheus/procfs v0.10.1/go.mod h1:nwNm2aOCAYw8uTR/9bWRREkZFxAUcWzPHWJq+XBB/FM= +github.com/redis/go-redis/v9 v9.2.0 h1:zwMdX0A4eVzse46YN18QhuDiM4uf3JmkOB4VZrdt5uI= +github.com/redis/go-redis/v9 v9.2.0/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.2.2/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= @@ -638,8 +642,6 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -gopkg.in/boj/redistore.v1 v1.0.0-20160128113310-fc113767cd6b h1:U/Uqd1232+wrnHOvWNaxrNqn/kFnr4yu4blgPtQt0N8= -gopkg.in/boj/redistore.v1 v1.0.0-20160128113310-fc113767cd6b/go.mod h1:fgfIZMlsafAHpspcks2Bul+MWUNw/2dyQmjC2faKjtg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/gounicorn/gounicorn.go b/internal/gounicorn/gounicorn.go index b07a8c61a..0f328c943 100644 --- a/internal/gounicorn/gounicorn.go +++ b/internal/gounicorn/gounicorn.go @@ -1,14 +1,20 @@ package gounicorn import ( + "fmt" "os" "os/exec" + "os/signal" "runtime" + "strconv" + "strings" "syscall" "time" log "github.com/sirupsen/logrus" + "goauthentik.io/internal/config" + "goauthentik.io/internal/utils" ) type GoUnicorn struct { @@ -17,6 +23,7 @@ type GoUnicorn struct { log *log.Entry p *exec.Cmd + pidFile string started bool killed bool alive bool @@ -33,15 +40,36 @@ func New(healthcheck func() bool) *GoUnicorn { HealthyCallback: func() {}, } g.initCmd() + c := make(chan os.Signal, 1) + signal.Notify(c, syscall.SIGHUP, syscall.SIGUSR2) + go func() { + for sig := range c { + if sig == syscall.SIGHUP { + g.log.Info("SIGHUP received, forwarding to gunicorn") + g.Reload() + } else if sig == syscall.SIGUSR2 { + g.log.Info("SIGUSR2 received, restarting gunicorn") + g.Restart() + } + } + }() return g } func (g *GoUnicorn) initCmd() { - command := "gunicorn" - args := []string{"-c", "./lifecycle/gunicorn.conf.py", "authentik.root.asgi:application"} - if config.Get().Debug { - command = "./manage.py" - args = []string{"dev_server"} + command := "./manage.py" + args := []string{"dev_server"} + if !config.Get().Debug { + pidFile, err := os.CreateTemp("", "authentik-gunicorn.*.pid") + if err != nil { + panic(fmt.Errorf("failed to create temporary pid file: %v", err)) + } + g.pidFile = pidFile.Name() + command = "gunicorn" + args = []string{"-c", "./lifecycle/gunicorn.conf.py", "authentik.root.asgi:application"} + if g.pidFile != "" { + args = append(args, "--pid", g.pidFile) + } } g.log.WithField("args", args).WithField("cmd", command).Debug("Starting gunicorn") g.p = exec.Command(command, args...) @@ -55,13 +83,10 @@ func (g *GoUnicorn) IsRunning() bool { } func (g *GoUnicorn) Start() error { - if g.killed { - g.log.Debug("Not restarting gunicorn since we're shutdown") - return nil - } if g.started { g.initCmd() } + g.killed = false g.started = true go g.healthcheck() return g.p.Run() @@ -85,8 +110,76 @@ func (g *GoUnicorn) healthcheck() { } } +func (g *GoUnicorn) Reload() { + g.log.WithField("method", "reload").Info("reloading gunicorn") + err := g.p.Process.Signal(syscall.SIGHUP) + if err != nil { + g.log.WithError(err).Warning("failed to reload gunicorn") + } +} + +func (g *GoUnicorn) Restart() { + g.log.WithField("method", "restart").Info("restart gunicorn") + if g.pidFile == "" { + g.log.Warning("pidfile is non existent, cannot restart") + return + } + + err := g.p.Process.Signal(syscall.SIGUSR2) + if err != nil { + g.log.WithError(err).Warning("failed to restart gunicorn") + return + } + + newPidFile := fmt.Sprintf("%s.2", g.pidFile) + + // Wait for the new PID file to be created + for range time.NewTicker(1 * time.Second).C { + _, err = os.Stat(newPidFile) + if err == nil || !os.IsNotExist(err) { + break + } + g.log.Debugf("waiting for new gunicorn pidfile to appear at %s", newPidFile) + } + if err != nil { + g.log.WithError(err).Warning("failed to find the new gunicorn process, aborting") + return + } + + newPidB, err := os.ReadFile(newPidFile) + if err != nil { + g.log.WithError(err).Warning("failed to find the new gunicorn process, aborting") + return + } + newPidS := strings.TrimSpace(string(newPidB[:])) + newPid, err := strconv.Atoi(newPidS) + if err != nil { + g.log.WithError(err).Warning("failed to find the new gunicorn process, aborting") + return + } + g.log.Warningf("new gunicorn PID is %d", newPid) + + newProcess, err := utils.FindProcess(newPid) + if newProcess == nil || err != nil { + g.log.WithError(err).Warning("failed to find the new gunicorn process, aborting") + return + } + + // The new process has started, let's gracefully kill the old one + g.log.Warning("killing old gunicorn") + err = g.p.Process.Signal(syscall.SIGTERM) + if err != nil { + g.log.Warning("failed to kill old instance of gunicorn") + } + + g.p.Process = newProcess + // No need to close any files and the .2 pid file is deleted by Gunicorn +} + func (g *GoUnicorn) Kill() { - g.killed = true + if !g.started { + return + } var err error if runtime.GOOS == "darwin" { g.log.WithField("method", "kill").Warning("stopping gunicorn") @@ -98,4 +191,11 @@ func (g *GoUnicorn) Kill() { if err != nil { g.log.WithError(err).Warning("failed to stop gunicorn") } + if g.pidFile != "" { + err := os.Remove(g.pidFile) + if err != nil { + g.log.WithError(err).Warning("failed to remove pidfile") + } + } + g.killed = true } diff --git a/internal/outpost/proxyv2/application/application.go b/internal/outpost/proxyv2/application/application.go index 3c8af5af3..657bcbec7 100644 --- a/internal/outpost/proxyv2/application/application.go +++ b/internal/outpost/proxyv2/application/application.go @@ -280,7 +280,7 @@ func (a *Application) handleSignOut(rw http.ResponseWriter, r *http.Request) { "id_token_hint": []string{cc.RawToken}, } redirect += "?" + uv.Encode() - err = a.Logout(cc.Sub) + err = a.Logout(r.Context(), cc.Sub) if err != nil { a.log.WithError(err).Warning("failed to logout of other sessions") } diff --git a/internal/outpost/proxyv2/application/session.go b/internal/outpost/proxyv2/application/session.go index bf426c10e..65fb7fed1 100644 --- a/internal/outpost/proxyv2/application/session.go +++ b/internal/outpost/proxyv2/application/session.go @@ -1,23 +1,23 @@ package application import ( + "context" "fmt" "math" "net/http" "net/url" "os" "path" - "strconv" "strings" - "github.com/garyburd/redigo/redis" "github.com/gorilla/securecookie" "github.com/gorilla/sessions" + "github.com/redis/go-redis/v9" "goauthentik.io/api/v3" "goauthentik.io/internal/config" "goauthentik.io/internal/outpost/proxyv2/codecs" "goauthentik.io/internal/outpost/proxyv2/constants" - "gopkg.in/boj/redistore.v1" + "goauthentik.io/internal/outpost/proxyv2/redisstore" ) const RedisKeyPrefix = "authentik_proxy_session_" @@ -30,20 +30,26 @@ func (a *Application) getStore(p api.ProxyOutpostConfig, externalHost *url.URL) maxAge = int(*t) + 1 } if a.isEmbedded { - rs, err := redistore.NewRediStoreWithDB(10, "tcp", fmt.Sprintf("%s:%d", config.Get().Redis.Host, config.Get().Redis.Port), config.Get().Redis.Password, strconv.Itoa(config.Get().Redis.DB)) + client := redis.NewClient(&redis.Options{ + Addr: fmt.Sprintf("%s:%d", config.Get().Redis.Host, config.Get().Redis.Port), + // Username: config.Get().Redis.Password, + Password: config.Get().Redis.Password, + DB: config.Get().Redis.DB, + }) + + // New default RedisStore + rs, err := redisstore.NewRedisStore(context.Background(), client) if err != nil { panic(err) } - rs.Codecs = codecs.CodecsFromPairs(maxAge, []byte(*p.CookieSecret)) - rs.SetMaxLength(math.MaxInt) - rs.SetKeyPrefix(RedisKeyPrefix) - rs.Options.HttpOnly = true - if strings.ToLower(externalHost.Scheme) == "https" { - rs.Options.Secure = true - } - rs.Options.Domain = *p.CookieDomain - rs.Options.SameSite = http.SameSiteLaxMode + rs.KeyPrefix(RedisKeyPrefix) + rs.Options(sessions.Options{ + HttpOnly: strings.ToLower(externalHost.Scheme) == "https", + Domain: *p.CookieDomain, + SameSite: http.SameSiteLaxMode, + }) + a.log.Trace("using redis session backend") return rs } @@ -80,7 +86,7 @@ func (a *Application) getAllCodecs() []securecookie.Codec { return cs } -func (a *Application) Logout(sub string) error { +func (a *Application) Logout(ctx context.Context, sub string) error { if _, ok := a.sessions.(*sessions.FilesystemStore); ok { files, err := os.ReadDir(os.TempDir()) if err != nil { @@ -120,31 +126,22 @@ func (a *Application) Logout(sub string) error { } } } - if rs, ok := a.sessions.(*redistore.RediStore); ok { - pool := rs.Pool.Get() - defer pool.Close() - rep, err := pool.Do("KEYS", fmt.Sprintf("%s*", RedisKeyPrefix)) + if rs, ok := a.sessions.(*redisstore.RedisStore); ok { + client := rs.Client() + defer client.Close() + keys, err := client.Keys(ctx, fmt.Sprintf("%s*", RedisKeyPrefix)).Result() if err != nil { return err } - keys, err := redis.Strings(rep, err) - if err != nil { - return err - } - serializer := redistore.GobSerializer{} + serializer := redisstore.GobSerializer{} for _, key := range keys { - v, err := pool.Do("GET", key) + v, err := client.Get(ctx, key).Result() if err != nil { a.log.WithError(err).Warning("failed to get value") continue } - b, err := redis.Bytes(v, err) - if err != nil { - a.log.WithError(err).Warning("failed to load value") - continue - } s := sessions.Session{} - err = serializer.Deserialize(b, &s) + err = serializer.Deserialize([]byte(v), &s) if err != nil { a.log.WithError(err).Warning("failed to deserialize") continue @@ -156,7 +153,7 @@ func (a *Application) Logout(sub string) error { claims := c.(Claims) if claims.Sub == sub { a.log.WithField("key", key).Trace("deleting session") - _, err := pool.Do("DEL", key) + _, err := client.Del(ctx, key).Result() if err != nil { a.log.WithError(err).Warning("failed to delete key") continue diff --git a/internal/outpost/proxyv2/redisstore/LICENSE b/internal/outpost/proxyv2/redisstore/LICENSE new file mode 100644 index 000000000..b13f8490e --- /dev/null +++ b/internal/outpost/proxyv2/redisstore/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Ruben Cervilla + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/internal/outpost/proxyv2/redisstore/redisstore.go b/internal/outpost/proxyv2/redisstore/redisstore.go new file mode 100644 index 000000000..21c812412 --- /dev/null +++ b/internal/outpost/proxyv2/redisstore/redisstore.go @@ -0,0 +1,200 @@ +package redisstore + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/base32" + "encoding/gob" + "errors" + "io" + "net/http" + "strings" + "time" + + "github.com/gorilla/sessions" + "github.com/redis/go-redis/v9" +) + +// RedisStore stores gorilla sessions in Redis +type RedisStore struct { + // client to connect to redis + client redis.UniversalClient + // default options to use when a new session is created + options sessions.Options + // key prefix with which the session will be stored + keyPrefix string + // key generator + keyGen KeyGenFunc + // session serializer + serializer SessionSerializer +} + +// KeyGenFunc defines a function used by store to generate a key +type KeyGenFunc func() (string, error) + +// NewRedisStore returns a new RedisStore with default configuration +func NewRedisStore(ctx context.Context, client redis.UniversalClient) (*RedisStore, error) { + rs := &RedisStore{ + options: sessions.Options{ + Path: "/", + MaxAge: 86400 * 30, + }, + client: client, + keyPrefix: "session:", + keyGen: generateRandomKey, + serializer: GobSerializer{}, + } + + return rs, rs.client.Ping(ctx).Err() +} + +func (s *RedisStore) Client() redis.UniversalClient { + return s.client +} + +// Get returns a session for the given name after adding it to the registry. +func (s *RedisStore) Get(r *http.Request, name string) (*sessions.Session, error) { + return sessions.GetRegistry(r).Get(s, name) +} + +// New returns a session for the given name without adding it to the registry. +func (s *RedisStore) New(r *http.Request, name string) (*sessions.Session, error) { + session := sessions.NewSession(s, name) + opts := s.options + session.Options = &opts + session.IsNew = true + + c, err := r.Cookie(name) + if err != nil { + return session, nil + } + session.ID = c.Value + + err = s.load(r.Context(), session) + if err == nil { + session.IsNew = false + } else if err == redis.Nil { + err = nil // no data stored + } + return session, err +} + +// Save adds a single session to the response. +// +// If the Options.MaxAge of the session is <= 0 then the session file will be +// deleted from the store. With this process it enforces the properly +// session cookie handling so no need to trust in the cookie management in the +// web browser. +func (s *RedisStore) Save(r *http.Request, w http.ResponseWriter, session *sessions.Session) error { + // Delete if max-age is <= 0 + if session.Options.MaxAge <= 0 { + if err := s.delete(r.Context(), session); err != nil { + return err + } + http.SetCookie(w, sessions.NewCookie(session.Name(), "", session.Options)) + return nil + } + + if session.ID == "" { + id, err := s.keyGen() + if err != nil { + return errors.New("redisstore: failed to generate session id") + } + session.ID = id + } + if err := s.save(r.Context(), session); err != nil { + return err + } + + http.SetCookie(w, sessions.NewCookie(session.Name(), session.ID, session.Options)) + return nil +} + +// Options set options to use when a new session is created +func (s *RedisStore) Options(opts sessions.Options) { + s.options = opts +} + +// KeyPrefix sets the key prefix to store session in Redis +func (s *RedisStore) KeyPrefix(keyPrefix string) { + s.keyPrefix = keyPrefix +} + +// KeyGen sets the key generator function +func (s *RedisStore) KeyGen(f KeyGenFunc) { + s.keyGen = f +} + +// Serializer sets the session serializer to store session +func (s *RedisStore) Serializer(ss SessionSerializer) { + s.serializer = ss +} + +// Close closes the Redis store +func (s *RedisStore) Close() error { + return s.client.Close() +} + +// save writes session in Redis +func (s *RedisStore) save(ctx context.Context, session *sessions.Session) error { + b, err := s.serializer.Serialize(session) + if err != nil { + return err + } + + return s.client.Set(ctx, s.keyPrefix+session.ID, b, time.Duration(session.Options.MaxAge)*time.Second).Err() +} + +// load reads session from Redis +func (s *RedisStore) load(ctx context.Context, session *sessions.Session) error { + cmd := s.client.Get(ctx, s.keyPrefix+session.ID) + if cmd.Err() != nil { + return cmd.Err() + } + + b, err := cmd.Bytes() + if err != nil { + return err + } + + return s.serializer.Deserialize(b, session) +} + +// delete deletes session in Redis +func (s *RedisStore) delete(ctx context.Context, session *sessions.Session) error { + return s.client.Del(ctx, s.keyPrefix+session.ID).Err() +} + +// SessionSerializer provides an interface for serialize/deserialize a session +type SessionSerializer interface { + Serialize(s *sessions.Session) ([]byte, error) + Deserialize(b []byte, s *sessions.Session) error +} + +// Gob serializer +type GobSerializer struct{} + +func (gs GobSerializer) Serialize(s *sessions.Session) ([]byte, error) { + buf := new(bytes.Buffer) + enc := gob.NewEncoder(buf) + err := enc.Encode(s.Values) + if err == nil { + return buf.Bytes(), nil + } + return nil, err +} + +func (gs GobSerializer) Deserialize(d []byte, s *sessions.Session) error { + dec := gob.NewDecoder(bytes.NewBuffer(d)) + return dec.Decode(&s.Values) +} + +// generateRandomKey returns a new random key +func generateRandomKey() (string, error) { + k := make([]byte, 64) + if _, err := io.ReadFull(rand.Reader, k); err != nil { + return "", err + } + return strings.TrimRight(base32.StdEncoding.EncodeToString(k), "="), nil +} diff --git a/internal/outpost/proxyv2/redisstore/redisstore_test.go b/internal/outpost/proxyv2/redisstore/redisstore_test.go new file mode 100644 index 000000000..f7853b250 --- /dev/null +++ b/internal/outpost/proxyv2/redisstore/redisstore_test.go @@ -0,0 +1,158 @@ +package redisstore + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gorilla/sessions" + "github.com/redis/go-redis/v9" +) + +const ( + redisAddr = "localhost:6379" +) + +func TestNew(t *testing.T) { + client := redis.NewClient(&redis.Options{ + Addr: redisAddr, + }) + + store, err := NewRedisStore(context.Background(), client) + if err != nil { + t.Fatal("failed to create redis store", err) + } + + req, err := http.NewRequest("GET", "http://www.example.com", nil) + if err != nil { + t.Fatal("failed to create request", err) + } + + session, err := store.New(req, "hello") + if err != nil { + t.Fatal("failed to create session", err) + } + if session.IsNew == false { + t.Fatal("session is not new") + } +} + +func TestOptions(t *testing.T) { + client := redis.NewClient(&redis.Options{ + Addr: redisAddr, + }) + + store, err := NewRedisStore(context.Background(), client) + if err != nil { + t.Fatal("failed to create redis store", err) + } + + opts := sessions.Options{ + Path: "/path", + MaxAge: 99999, + } + store.Options(opts) + + req, err := http.NewRequest("GET", "http://www.example.com", nil) + if err != nil { + t.Fatal("failed to create request", err) + } + + session, err := store.New(req, "hello") + if err != nil { + t.Fatal("failed to create store", err) + } + if session.Options.Path != opts.Path || session.Options.MaxAge != opts.MaxAge { + t.Fatal("failed to set options") + } +} + +func TestSave(t *testing.T) { + client := redis.NewClient(&redis.Options{ + Addr: redisAddr, + }) + + store, err := NewRedisStore(context.Background(), client) + if err != nil { + t.Fatal("failed to create redis store", err) + } + + req, err := http.NewRequest("GET", "http://www.example.com", nil) + if err != nil { + t.Fatal("failed to create request", err) + } + w := httptest.NewRecorder() + + session, err := store.New(req, "hello") + if err != nil { + t.Fatal("failed to create session", err) + } + + session.Values["key"] = "value" + err = session.Save(req, w) + if err != nil { + t.Fatal("failed to save: ", err) + } +} + +func TestDelete(t *testing.T) { + client := redis.NewClient(&redis.Options{ + Addr: redisAddr, + }) + + store, err := NewRedisStore(context.Background(), client) + if err != nil { + t.Fatal("failed to create redis store", err) + } + + req, err := http.NewRequest("GET", "http://www.example.com", nil) + if err != nil { + t.Fatal("failed to create request", err) + } + w := httptest.NewRecorder() + + session, err := store.New(req, "hello") + if err != nil { + t.Fatal("failed to create session", err) + } + + session.Values["key"] = "value" + err = session.Save(req, w) + if err != nil { + t.Fatal("failed to save session: ", err) + } + + session.Options.MaxAge = -1 + err = session.Save(req, w) + if err != nil { + t.Fatal("failed to delete session: ", err) + } +} + +func TestClose(t *testing.T) { + client := redis.NewClient(&redis.Options{ + Addr: redisAddr, + }) + + cmd := client.Ping(context.Background()) + err := cmd.Err() + if err != nil { + t.Fatal("connection is not opened") + } + + store, err := NewRedisStore(context.Background(), client) + if err != nil { + t.Fatal("failed to create redis store", err) + } + + err = store.Close() + if err != nil { + t.Fatal("failed to close") + } + + cmd = client.Ping(context.Background()) + if cmd.Err() == nil { + t.Fatal("connection is properly closed") + } +} diff --git a/internal/utils/process.go b/internal/utils/process.go new file mode 100644 index 000000000..366d0d1c4 --- /dev/null +++ b/internal/utils/process.go @@ -0,0 +1,39 @@ +package utils + +import ( + "errors" + "fmt" + "os" + "syscall" +) + +func FindProcess(pid int) (*os.Process, error) { + if pid <= 0 { + return nil, fmt.Errorf("invalid pid %v", pid) + } + // The error doesn't mean anything on Unix systems, let's just check manually + // that the new gunicorn master has properly started + // https://github.com/golang/go/issues/34396 + proc, err := os.FindProcess(pid) + if err != nil { + return nil, err + } + err = proc.Signal(syscall.Signal(0)) + if err == nil { + return proc, nil + } + if errors.Is(err, os.ErrProcessDone) { + return nil, nil + } + errno, ok := err.(syscall.Errno) + if !ok { + return nil, err + } + switch errno { + case syscall.ESRCH: + return nil, nil + case syscall.EPERM: + return proc, nil + } + return nil, err +} diff --git a/internal/web/web.go b/internal/web/web.go index e0b3a749d..ea7ac1746 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -9,6 +9,7 @@ import ( "net/url" "os" "path" + "time" "github.com/gorilla/handlers" "github.com/gorilla/mux" @@ -109,6 +110,21 @@ func (ws *WebServer) attemptStartBackend() { } err := ws.g.Start() log.WithField("logger", "authentik.router").WithError(err).Warning("gunicorn process died, restarting") + if err != nil { + log.WithField("logger", "authentik.router").WithError(err).Error("gunicorn failed to start, restarting") + continue + } + failedChecks := 0 + for range time.NewTicker(30 * time.Second).C { + if !ws.g.IsRunning() { + log.WithField("logger", "authentik.router").Warningf("gunicorn process failed healthcheck %d times", failedChecks) + failedChecks += 1 + } + if failedChecks >= 3 { + log.WithField("logger", "authentik.router").WithError(err).Error("gunicorn process failed healthcheck three times, restarting") + break + } + } } } diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index dfab6fc45..a9ea8f75d 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -10,17 +10,17 @@ # Charles Leclerc, 2023 # Titouan Petit, 2023 # Kyllian Delaye-Maillot, 2023 -# Marc Schmitt, 2023 # Manuel Viens, 2023 +# Marc Schmitt, 2023 # #, fuzzy msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-07-21 13:04+0000\n" +"POT-Creation-Date: 2023-09-15 09:51+0000\n" "PO-Revision-Date: 2022-09-26 16:47+0000\n" -"Last-Translator: Manuel Viens, 2023\n" +"Last-Translator: Marc Schmitt, 2023\n" "Language-Team: French (https://app.transifex.com/authentik/teams/119923/fr/)\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -33,11 +33,11 @@ msgstr "" msgid "Successfully re-scheduled Task %(name)s!" msgstr "La Tâche %(name)s a bien été reprogrammée !" -#: authentik/api/schema.py:21 +#: authentik/api/schema.py:25 msgid "Generic API Error" msgstr "Erreur d'API Générique" -#: authentik/api/schema.py:29 +#: authentik/api/schema.py:33 msgid "Validation Error" msgstr "Erreur de Validation" @@ -97,12 +97,12 @@ msgstr "Fournisseur SAML depuis métadonnées" msgid "Create a SAML Provider by importing its Metadata." msgstr "Créer un fournisseur SAML en important ses métadonnées." -#: authentik/core/api/users.py:144 +#: authentik/core/api/users.py:158 msgid "No leading or trailing slashes allowed." msgstr "" "Les barres obliques, ou slashes, de tête ou de queue ne sont pas autorisées." -#: authentik/core/api/users.py:147 +#: authentik/core/api/users.py:161 msgid "No empty segments in user path allowed." msgstr "Les segments vides dans le chemin utilisateur ne sont pas autorisés." @@ -114,19 +114,19 @@ msgstr "nom" msgid "Users added to this group will be superusers." msgstr "Les utilisateurs ajoutés à ce groupe seront des super-utilisateurs." -#: authentik/core/models.py:162 +#: authentik/core/models.py:142 msgid "User's display name." msgstr "Nom d'affichage de l'utilisateur" -#: authentik/core/models.py:256 authentik/providers/oauth2/models.py:294 +#: authentik/core/models.py:268 authentik/providers/oauth2/models.py:295 msgid "User" msgstr "Utilisateur" -#: authentik/core/models.py:257 +#: authentik/core/models.py:269 msgid "Users" msgstr "Utilisateurs" -#: authentik/core/models.py:270 +#: authentik/core/models.py:282 msgid "" "Flow used for authentication when the associated application is accessed by " "an un-authenticated user." @@ -134,11 +134,11 @@ msgstr "" "Flux utilisé lors d'authentification quand l'application associée est " "accédée par un utilisateur non-authentifié." -#: authentik/core/models.py:280 +#: authentik/core/models.py:292 msgid "Flow used when authorizing this provider." msgstr "Flux utilisé lors de l'autorisation de ce fournisseur." -#: authentik/core/models.py:292 +#: authentik/core/models.py:304 msgid "" "Accessed from applications; optional backchannel providers for protocols " "like LDAP and SCIM." @@ -146,32 +146,32 @@ msgstr "" "Accès à partir d'applications ; fournisseurs optionnels de canaux de retour " "pour des protocoles tels que LDAP et SCIM." -#: authentik/core/models.py:347 +#: authentik/core/models.py:359 msgid "Application's display Name." msgstr "Nom d'affichage de l'application" -#: authentik/core/models.py:348 +#: authentik/core/models.py:360 msgid "Internal application name, used in URLs." msgstr "Nom de l'application interne, utilisé dans les URLs." -#: authentik/core/models.py:360 +#: authentik/core/models.py:372 msgid "Open launch URL in a new browser tab or window." msgstr "" "Ouvrir l'URL de lancement dans une nouvelle fenêtre ou un nouvel onglet." -#: authentik/core/models.py:424 +#: authentik/core/models.py:436 msgid "Application" msgstr "Application" -#: authentik/core/models.py:425 +#: authentik/core/models.py:437 msgid "Applications" msgstr "Applications" -#: authentik/core/models.py:431 +#: authentik/core/models.py:443 msgid "Use the source-specific identifier" msgstr "Utiliser l'identifiant spécifique à la source" -#: authentik/core/models.py:433 +#: authentik/core/models.py:445 msgid "" "Link to a user with identical email address. Can have security implications " "when a source doesn't validate email addresses." @@ -179,7 +179,7 @@ msgstr "" "Lier à un utilisateur avec une adresse email identique. Peut avoir des " "implications de sécurité lorsqu'une source ne valide pas les adresses email." -#: authentik/core/models.py:437 +#: authentik/core/models.py:449 msgid "" "Use the user's email address, but deny enrollment when the email address " "already exists." @@ -187,7 +187,7 @@ msgstr "" "Utiliser l'adresse courriel de l'utilisateur, mais refuser l'inscription " "lorsque celle-ci existe déjà." -#: authentik/core/models.py:440 +#: authentik/core/models.py:452 msgid "" "Link to a user with identical username. Can have security implications when " "a username is used with another source." @@ -196,7 +196,7 @@ msgstr "" "problèmes de sécurité si ce nom d'utilisateur est partagé avec une autre " "source." -#: authentik/core/models.py:444 +#: authentik/core/models.py:456 msgid "" "Use the user's username, but deny enrollment when the username already " "exists." @@ -204,23 +204,23 @@ msgstr "" "Utiliser le nom d'utilisateur, mais refuser l'inscription si celui-ci existe" " déjà." -#: authentik/core/models.py:451 +#: authentik/core/models.py:463 msgid "Source's display Name." msgstr "Nom d'affichage de la source." -#: authentik/core/models.py:452 +#: authentik/core/models.py:464 msgid "Internal source name, used in URLs." msgstr "Nom interne de la source, utilisé dans les URLs." -#: authentik/core/models.py:471 +#: authentik/core/models.py:483 msgid "Flow to use when authenticating existing users." msgstr "Flux à utiliser pour authentifier les utilisateurs existants." -#: authentik/core/models.py:480 +#: authentik/core/models.py:492 msgid "Flow to use when enrolling new users." msgstr "Flux à utiliser pour inscrire les nouveaux utilisateurs." -#: authentik/core/models.py:488 +#: authentik/core/models.py:500 msgid "" "How the source determines if an existing user should be authenticated or a " "new user enrolled." @@ -228,31 +228,31 @@ msgstr "" "Comment la source détermine si un utilisateur existant doit être authentifié" " ou un nouvelle utilisateur doit être inscrit." -#: authentik/core/models.py:660 +#: authentik/core/models.py:672 msgid "Token" msgstr "Jeton" -#: authentik/core/models.py:661 +#: authentik/core/models.py:673 msgid "Tokens" msgstr "Jetons" -#: authentik/core/models.py:702 +#: authentik/core/models.py:714 msgid "Property Mapping" msgstr "Mappage de propriété" -#: authentik/core/models.py:703 +#: authentik/core/models.py:715 msgid "Property Mappings" msgstr "Mappages de propriété" -#: authentik/core/models.py:738 +#: authentik/core/models.py:750 msgid "Authenticated Session" msgstr "Session Authentifiée" -#: authentik/core/models.py:739 +#: authentik/core/models.py:751 msgid "Authenticated Sessions" msgstr "Sessions Authentifiées" -#: authentik/core/sources/flow_manager.py:193 +#: authentik/core/sources/flow_manager.py:189 #, python-format msgid "" "Request to authenticate with %(source)s has been denied. Please authenticate" @@ -261,22 +261,22 @@ msgstr "" "La requête d'authentification avec %(source)s a été refusée. Merci de vous " "authentifier avec la source utilisée précédemment." -#: authentik/core/sources/flow_manager.py:245 +#: authentik/core/sources/flow_manager.py:241 msgid "Configured flow does not exist." msgstr "Le flux configuré n'existe pas." -#: authentik/core/sources/flow_manager.py:275 -#: authentik/core/sources/flow_manager.py:327 +#: authentik/core/sources/flow_manager.py:271 +#: authentik/core/sources/flow_manager.py:323 #, python-format msgid "Successfully authenticated with %(source)s!" msgstr "Authentifié avec succès avec %(source)s!" -#: authentik/core/sources/flow_manager.py:299 +#: authentik/core/sources/flow_manager.py:295 #, python-format msgid "Successfully linked %(source)s!" msgstr "%(source)s lié avec succès!" -#: authentik/core/sources/flow_manager.py:318 +#: authentik/core/sources/flow_manager.py:314 msgid "Source is not configured for enrollment." msgstr "La source n'est pas configurée pour l'inscription." @@ -334,12 +334,12 @@ msgstr "" msgid "Go home" msgstr "Retourner à l'accueil" -#: authentik/core/templates/login/base_full.html:90 +#: authentik/core/templates/login/base_full.html:89 msgid "Powered by authentik" msgstr "Propulsé par authentik" #: authentik/core/views/apps.py:53 -#: authentik/providers/oauth2/views/authorize.py:391 +#: authentik/providers/oauth2/views/authorize.py:393 #: authentik/providers/oauth2/views/device_init.py:70 #: authentik/providers/saml/views/sso.py:70 #, python-format @@ -370,6 +370,14 @@ msgstr "Paire de clé/certificat" msgid "Certificate-Key Pairs" msgstr "Paires de clé/certificat" +#: authentik/enterprise/models.py:193 +msgid "License Usage" +msgstr "Utilisation de la licence" + +#: authentik/enterprise/models.py:194 +msgid "License Usage Records" +msgstr "Registre d'utilisation de la licence" + #: authentik/events/models.py:290 msgid "Event" msgstr "Évènement" @@ -479,7 +487,7 @@ msgstr "Mappage de Webhook" msgid "Webhook Mappings" msgstr "Mappages de Webhook" -#: authentik/events/monitored_tasks.py:198 +#: authentik/events/monitored_tasks.py:205 msgid "Task has not been run yet." msgstr "Tâche pas encore exécutée." @@ -655,7 +663,7 @@ msgstr "" msgid "Invalid kubeconfig" msgstr "kubeconfig invalide" -#: authentik/outposts/models.py:121 +#: authentik/outposts/models.py:122 msgid "" "If enabled, use the local connection. Required Docker socket/Kubernetes " "Integration" @@ -663,15 +671,15 @@ msgstr "" "Si activé, utilise la connexion locale. L'intégration Docker " "socket/Kubernetes est requise" -#: authentik/outposts/models.py:151 +#: authentik/outposts/models.py:152 msgid "Outpost Service-Connection" msgstr "Connexion de service de l'avant-poste" -#: authentik/outposts/models.py:152 +#: authentik/outposts/models.py:153 msgid "Outpost Service-Connections" msgstr "Connexions de service de l'avant-poste" -#: authentik/outposts/models.py:160 +#: authentik/outposts/models.py:161 msgid "" "Can be in the format of 'unix://