Compare commits

..

86 Commits

Author SHA1 Message Date
33cdbd7776 release: 2021.2.1-rc1 2021-02-06 20:10:50 +01:00
18bc54214d web: increase height of multi-select 2021-02-06 19:19:57 +01:00
db7e9f9b95 sources/ldap: set default group property mapping 2021-02-06 19:17:39 +01:00
a885247d36 docs: update release notes for 2021.2 2021-02-06 19:09:42 +01:00
91282c7bd8 web: add page for Proxy Provider 2021-02-06 18:57:25 +01:00
830b8bcd5b web: add page for OAuth2 Provider 2021-02-06 18:39:15 +01:00
0f5e6d0d8c api: add dark theme for API Browser 2021-02-06 18:09:24 +01:00
6aa6615608 web: add view page for SAML Provider 2021-02-06 18:07:13 +01:00
91d6a3c8c7 providers/*: simplify provider API 2021-02-06 17:31:29 +01:00
a6ac82c492 *: rewrite managed objects, use nullable text flag instead of boolean as uid (#533) 2021-02-06 15:56:21 +00:00
05d777c373 Merge pull request #528 from BeryJu/ldap-groupOfNames
sources/ldap: support group to user memberships
2021-02-06 16:07:36 +01:00
32cf960053 sources/ldap: add property_mappings_group to make group mapping more customisable 2021-02-06 15:27:07 +01:00
83bf639926 sources/ldap: use both entryDN and dn (for active-directory) 2021-02-05 15:17:57 +01:00
2717742bd2 sources/ldap: don't remove users from group which were not synced from AD 2021-02-05 15:17:20 +01:00
ef70e93bbd Merge branch 'master' into ldap-groupOfNames 2021-02-05 14:52:39 +01:00
478d3430eb sources/ldap: use openldap tests for entire sync 2021-02-05 14:29:22 +01:00
9c1ade59e9 sources/ldap: add more flatten to user sync, start adding tests for OpenLDAP 2021-02-05 13:36:27 +01:00
fadf746234 managed: allow for matching on multiple interfaces 2021-02-05 13:18:44 +01:00
397dfc29f1 sources/ldap: change default object filters to use objectClass= instead of objectCategory 2021-02-05 11:43:39 +01:00
b0e3b8b39d sources/ldap: use entryDN attribute from ldap3 as opposed to implicit DN attribute 2021-02-05 11:43:13 +01:00
df9ae796d4 build(deps): bump boto3 from 1.17.1 to 1.17.2 (#529)
Bumps [boto3](https://github.com/boto/boto3) from 1.17.1 to 1.17.2.
- [Release notes](https://github.com/boto/boto3/releases)
- [Changelog](https://github.com/boto/boto3/blob/develop/CHANGELOG.rst)
- [Commits](https://github.com/boto/boto3/compare/1.17.1...1.17.2)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-05 11:00:31 +01:00
dfdad5388f build(deps): bump @sentry/tracing from 6.0.4 to 6.1.0 in /web (#531)
Bumps [@sentry/tracing](https://github.com/getsentry/sentry-javascript) from 6.0.4 to 6.1.0.
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/6.0.4...6.1.0)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-05 11:00:19 +01:00
c38ea69bdd build(deps-dev): bump autopep8 from 1.5.4 to 1.5.5 (#530)
Bumps [autopep8](https://github.com/hhatto/autopep8) from 1.5.4 to 1.5.5.
- [Release notes](https://github.com/hhatto/autopep8/releases)
- [Commits](https://github.com/hhatto/autopep8/compare/v1.5.4...v1.5.5)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-05 09:52:46 +01:00
dca6f43858 build(deps): bump @sentry/browser from 6.0.4 to 6.1.0 in /web (#532)
Bumps [@sentry/browser](https://github.com/getsentry/sentry-javascript) from 6.0.4 to 6.1.0.
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/6.0.4...6.1.0)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-05 09:52:33 +01:00
51cbb7cc8e ci: fix warning when setting branchName in PR 2021-02-05 00:38:08 +01:00
1f8130e685 events: improve information sent in notification emails 2021-02-04 21:44:59 +01:00
580d59e921 web: add EventInfoPage 2021-02-04 21:28:01 +01:00
e639d8ab56 sources/ldap: add case when group does not have uniqueness attribute 2021-02-04 21:18:49 +01:00
9f478bb46a web: fix type warnings 2021-02-04 21:10:13 +01:00
7a16f97908 web: add ak-expand for event list to show full context 2021-02-04 20:59:18 +01:00
dd8c1eeb52 web: add ak-expand 2021-02-04 20:56:40 +01:00
005b4d8dda sources/ldap: fix linting issues 2021-02-04 20:36:05 +01:00
de2d8b2d85 providers/oauth2: pass application to configuration error event 2021-02-04 20:35:37 +01:00
7d107991a2 sources/ldap: fix count for membership, fix wrong attribute being searched 2021-02-04 20:22:28 +01:00
14dc420747 sources/ldap: rewrite group membership syncing 2021-02-04 20:06:42 +01:00
89dc4db30b sources/ldap: load operational attributes (#526) 2021-02-04 12:37:55 +01:00
cc3fccb27e sources/ldap: use dn attribute for distinguishedName, ignore users with no distinguishedName
closes #527
2021-02-04 12:10:57 +01:00
add20de8de providers/*: fix api linting issues 2021-02-04 10:27:55 +01:00
7e2a471903 web: fix linting issues 2021-02-04 10:22:14 +01:00
9ca9e67ffa web: fix pagination not working correctly sometimes, fix pagination not showing when changing pages 2021-02-04 10:09:19 +01:00
178417fe67 web: start implementing provider list 2021-02-04 10:09:19 +01:00
53f002a123 events: allow searching by event id 2021-02-04 10:09:19 +01:00
c7c387eb38 providers/*: add assigned application name and slug 2021-02-04 10:09:19 +01:00
1b3760a4b7 events: don't log successful system tasks 2021-02-04 10:09:18 +01:00
704a502089 build(deps): bump @types/codemirror from 0.0.107 to 0.0.108 in /web (#523)
Bumps [@types/codemirror](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/codemirror) from 0.0.107 to 0.0.108.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/codemirror)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-04 09:36:52 +01:00
3b12ef80eb build(deps): bump boto3 from 1.17.0 to 1.17.1 (#522)
Bumps [boto3](https://github.com/boto/boto3) from 1.17.0 to 1.17.1.
- [Release notes](https://github.com/boto/boto3/releases)
- [Changelog](https://github.com/boto/boto3/blob/develop/CHANGELOG.rst)
- [Commits](https://github.com/boto/boto3/compare/1.17.0...1.17.1)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-04 09:36:38 +01:00
1101810fea admin: show more details for policy testing 2021-02-03 22:09:46 +01:00
1ab5289e2e admin: add test view for property mappings 2021-02-03 21:58:56 +01:00
ac24fc9ce3 web: add javascript mode to codemirror 2021-02-03 21:58:30 +01:00
4b24b185f2 admin: fix context not being passed correctly to policy test view 2021-02-03 21:40:03 +01:00
ea0ba5ae30 stages/password: use form.add_error 2021-02-03 21:39:03 +01:00
44686de74e docs: prepare 2021.2 releases 2021-02-03 21:29:13 +01:00
b74c08620a admin: add link to changelog to update events 2021-02-03 21:19:51 +01:00
e25d03d8f4 Managed objects (#519)
* managed: add base manager and Ops

* core: use ManagedModel for Token and PropertyMapping

* providers/saml: implement managed objects for SAML Provider

* sources/ldap: migrate to managed

* providers/oauth2: migrate to managed

* providers/proxy: migrate to managed

* *: load .managed in apps

* managed: add reconcile task, run on startup

* providers/oauth2: fix import path for managed

* providers/saml: don't set FriendlyName when mapping is none

* *: use ObjectManager in tests to ensure objects exist

* ci: use vmImage ubuntu-latest

* providers/saml: add new mapping for username and user id

* tests: remove docker proxy

* tests/e2e: use updated attribute names

* docs: update SAML docs

* tests/e2e: fix remaining saml cases

* outposts: make tokens as managed

* *: make PropertyMapping SerializerModel

* web: add page for property-mappings

* web: add codemirror to common_styles because codemirror

* docs: fix member-of in nextcloud

* docs: nextcloud add admin

* web: fix refresh reloading data two times

* web: add loading lock to table to prevent double loads

* web: add ability to use null in QueryArgs (value will be skipped)

* web: add hide option to property mappings

* web: fix linting
2021-02-03 21:18:31 +01:00
f8f26d2a23 build(deps): bump rollup from 2.38.3 to 2.38.4 in /web (#520)
Bumps [rollup](https://github.com/rollup/rollup) from 2.38.3 to 2.38.4.
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v2.38.3...v2.38.4)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-03 09:32:12 +01:00
1f2e177e3e build(deps): bump boto3 from 1.16.63 to 1.17.0 (#521)
Bumps [boto3](https://github.com/boto/boto3) from 1.16.63 to 1.17.0.
- [Release notes](https://github.com/boto/boto3/releases)
- [Changelog](https://github.com/boto/boto3/blob/develop/CHANGELOG.rst)
- [Commits](https://github.com/boto/boto3/compare/1.16.63...1.17.0)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-03 09:32:01 +01:00
cfed41439e events: add send_once flag to send webhooks only once 2021-02-02 19:34:55 +01:00
3ac148d01c events: only title for slack webhook 2021-02-02 19:18:51 +01:00
3e696d6ac8 flows: use global logger for stored plans 2021-02-02 17:29:03 +01:00
0114bc0d6a flows: fix lint errors 2021-02-02 17:02:02 +01:00
c60934f9b1 flows: fix benchmark using wrong context 2021-02-02 16:27:21 +01:00
09bdcfaab0 flows: optimise logging 2021-02-02 16:27:03 +01:00
624206281e policies: optimise logging 2021-02-02 16:12:41 +01:00
4d7e64c48c web: adjust trace sample rate 2021-02-02 15:50:29 +01:00
3d112e7688 root: use filtering_bound_logger for speed improvements 2021-02-02 15:43:44 +01:00
3c4ff65a01 stages/consent: fix wrong widget for expire 2021-02-02 15:01:33 +01:00
d7f54ce5d5 build(deps): bump @sentry/tracing from 6.0.3 to 6.0.4 in /web (#515)
Bumps [@sentry/tracing](https://github.com/getsentry/sentry-javascript) from 6.0.3 to 6.0.4.
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/6.0.3...6.0.4)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-02 11:23:47 +01:00
bc55c97fa2 build(deps): bump @sentry/browser from 6.0.3 to 6.0.4 in /web (#516)
Bumps [@sentry/browser](https://github.com/getsentry/sentry-javascript) from 6.0.3 to 6.0.4.
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/6.0.3...6.0.4)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-02 11:21:41 +01:00
d9a907e39e build(deps-dev): bump @typescript-eslint/eslint-plugin in /web (#518)
Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 4.14.1 to 4.14.2.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v4.14.2/packages/eslint-plugin)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-02 11:21:28 +01:00
8616647045 build(deps): bump rollup from 2.38.2 to 2.38.3 in /web (#517)
Bumps [rollup](https://github.com/rollup/rollup) from 2.38.2 to 2.38.3.
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v2.38.2...v2.38.3)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-02 10:03:18 +01:00
4d861e2830 build(deps-dev): bump @typescript-eslint/parser in /web (#514)
Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 4.14.1 to 4.14.2.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v4.14.2/packages/parser)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-02 10:03:03 +01:00
881730f52e build(deps): bump django from 3.1.5 to 3.1.6 (#513)
Bumps [django](https://github.com/django/django) from 3.1.5 to 3.1.6.
- [Release notes](https://github.com/django/django/releases)
- [Commits](https://github.com/django/django/compare/3.1.5...3.1.6)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-02 10:02:49 +01:00
e78577d470 build(deps): bump @sentry/tracing from 6.0.2 to 6.0.3 in /web (#511)
Bumps [@sentry/tracing](https://github.com/getsentry/sentry-javascript) from 6.0.2 to 6.0.3.
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/6.0.2...6.0.3)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-01 10:15:59 +01:00
d502f4d77d build(deps): bump boto3 from 1.16.62 to 1.16.63 (#507)
Bumps [boto3](https://github.com/boto/boto3) from 1.16.62 to 1.16.63.
- [Release notes](https://github.com/boto/boto3/releases)
- [Changelog](https://github.com/boto/boto3/blob/develop/CHANGELOG.rst)
- [Commits](https://github.com/boto/boto3/compare/1.16.62...1.16.63)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-01 09:12:43 +01:00
3c5f7deba9 build(deps): bump packaging from 20.8 to 20.9 (#506)
Bumps [packaging](https://github.com/pypa/packaging) from 20.8 to 20.9.
- [Release notes](https://github.com/pypa/packaging/releases)
- [Changelog](https://github.com/pypa/packaging/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pypa/packaging/compare/20.8...20.9)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-01 09:12:30 +01:00
b61334c482 build(deps-dev): bump eslint from 7.18.0 to 7.19.0 in /web (#508)
Bumps [eslint](https://github.com/eslint/eslint) from 7.18.0 to 7.19.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/master/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/compare/v7.18.0...v7.19.0)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-01 09:12:20 +01:00
eb762632d0 build(deps-dev): bump @rollup/plugin-typescript in /web (#509)
Bumps [@rollup/plugin-typescript](https://github.com/rollup/plugins) from 8.1.0 to 8.1.1.
- [Release notes](https://github.com/rollup/plugins/releases)
- [Commits](https://github.com/rollup/plugins/compare/typescript-v8.1.0...typescript-v8.1.1)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-01 09:12:10 +01:00
6a882249aa build(deps): bump rollup from 2.38.1 to 2.38.2 in /web (#510)
Bumps [rollup](https://github.com/rollup/rollup) from 2.38.1 to 2.38.2.
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v2.38.1...v2.38.2)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-01 09:11:58 +01:00
94f6bbd431 build(deps): bump @sentry/browser from 6.0.2 to 6.0.3 in /web (#512)
Bumps [@sentry/browser](https://github.com/getsentry/sentry-javascript) from 6.0.2 to 6.0.3.
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/6.0.2...6.0.3)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-01 09:11:42 +01:00
3926ee9eb6 core: clear application cache upon application creation 2021-01-30 18:12:14 +01:00
7fbf915e0a policies: fix application cached not being cleared correctly 2021-01-30 18:12:01 +01:00
5af9e8c05d core: improve application caching 2021-01-30 18:03:44 +01:00
7c0c453d9f web: fix new provider dropdown being cut off 2021-01-30 12:38:33 +01:00
d8ae56ed19 providers/saml: fix imported provider not saving properties correctly 2021-01-30 12:33:27 +01:00
a9a65ceca6 Merge branch 'version-2021.1' 2021-01-29 10:45:55 +01:00
c11fd884b8 docs: separate 2021.1 fixes by patch release 2021-01-29 10:45:50 +01:00
168 changed files with 3796 additions and 1247 deletions

View File

@ -1,5 +1,5 @@
[bumpversion]
current_version = 2021.1.4-stable
current_version = 2021.2.1-rc1
tag = True
commit = True
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-(?P<release>.*)

View File

@ -18,11 +18,11 @@ jobs:
- name: Building Docker Image
run: docker build
--no-cache
-t beryju/authentik:2021.1.4-stable
-t beryju/authentik:2021.2.1-rc1
-t beryju/authentik:latest
-f Dockerfile .
- name: Push Docker Container to Registry (versioned)
run: docker push beryju/authentik:2021.1.4-stable
run: docker push beryju/authentik:2021.2.1-rc1
- name: Push Docker Container to Registry (latest)
run: docker push beryju/authentik:latest
build-proxy:
@ -48,11 +48,11 @@ jobs:
cd outpost/
docker build \
--no-cache \
-t beryju/authentik-proxy:2021.1.4-stable \
-t beryju/authentik-proxy:2021.2.1-rc1 \
-t beryju/authentik-proxy:latest \
-f proxy.Dockerfile .
- name: Push Docker Container to Registry (versioned)
run: docker push beryju/authentik-proxy:2021.1.4-stable
run: docker push beryju/authentik-proxy:2021.2.1-rc1
- name: Push Docker Container to Registry (latest)
run: docker push beryju/authentik-proxy:latest
build-static:
@ -69,11 +69,11 @@ jobs:
cd web/
docker build \
--no-cache \
-t beryju/authentik-static:2021.1.4-stable \
-t beryju/authentik-static:2021.2.1-rc1 \
-t beryju/authentik-static:latest \
-f Dockerfile .
- name: Push Docker Container to Registry (versioned)
run: docker push beryju/authentik-static:2021.1.4-stable
run: docker push beryju/authentik-static:2021.2.1-rc1
- name: Push Docker Container to Registry (latest)
run: docker push beryju/authentik-static:latest
test-release:
@ -107,5 +107,5 @@ jobs:
SENTRY_PROJECT: authentik
SENTRY_URL: https://sentry.beryju.org
with:
tagName: 2021.1.4-stable
tagName: 2021.2.1-rc1
environment: beryjuorg-prod

88
Pipfile.lock generated
View File

@ -74,18 +74,17 @@
},
"boto3": {
"hashes": [
"sha256:a280123db79e73478bd23933486f3a0ffa2397d1a6381f32573f2731ff48c59a",
"sha256:bb91fecf982e1bbfb68bb6bd2c9a0cce3c84ac6f97dd338d1ef9e47780679091"
"sha256:1a282c1cd7d5028cbb3a75d747df32162295253f55d263ac85840e264830963b"
],
"index": "pypi",
"version": "==1.16.62"
"version": "==1.17.2"
},
"botocore": {
"hashes": [
"sha256:1046c152e5865aabbe6b10b2d33e652b3dd072516f3976e96cacc6b7c4460d02",
"sha256:29b4b9be5b40f392a033926c08c004c01bd6471384ef6f12eaa49ee3870a010c"
"sha256:7442fdbbdc841bfac7f94f92ecb807de070e32ed205743eb72d4ea27c5e8e778",
"sha256:bf587b044983a91a0124cc133ff167b8528c19fbbc8f0b956d9a1ac256cad7d7"
],
"version": "==1.19.62"
"version": "==1.20.2"
},
"cachetools": {
"hashes": [
@ -127,6 +126,7 @@
"sha256:6bc25fc545a6b3d57b5f8618e59fc13d3a3a68431e8ca5fd4c13241cd70d0009",
"sha256:798caa2a2384b1cbe8a2a139d80734c9db54f9cc155c99d7cc92441a23871c03",
"sha256:7c6b1dece89874d9541fc974917b631406233ea0440d0bdfbb8e03bf39a49b3b",
"sha256:7ef7d4ced6b325e92eb4d3502946c78c5367bc416398d387b39591532536734e",
"sha256:840793c68105fe031f34d6a086eaea153a0cd5c491cde82a74b420edd0a2b909",
"sha256:8d6603078baf4e11edc4168a514c5ce5b3ba6e3e9c374298cb88437957960a53",
"sha256:9cc46bc107224ff5b6d04369e7c595acb700c3613ad7bcf2e2012f62ece80c35",
@ -265,11 +265,11 @@
},
"django": {
"hashes": [
"sha256:2d78425ba74c7a1a74b196058b261b9733a8570782f4e2828974777ccca7edf7",
"sha256:efa2ab96b33b20c2182db93147a0c3cd7769d418926f9e9f140a60dca7c64ca9"
"sha256:169e2e7b4839a7910b393eec127fd7cbae62e80fa55f89c6510426abf673fe5f",
"sha256:c6c0462b8b361f8691171af1fb87eceb4442da28477e12200c40420176206ba7"
],
"index": "pypi",
"version": "==3.1.5"
"version": "==3.1.6"
},
"django-cors-middleware": {
"hashes": [
@ -397,10 +397,10 @@
},
"google-auth": {
"hashes": [
"sha256:0b0e026b412a0ad096e753907559e4bdb180d9ba9f68dd9036164db4fdc4ad2e",
"sha256:ce752cc51c31f479dbf9928435ef4b07514b20261b021c7383bee4bda646acb8"
"sha256:008e23ed080674f69f9d2d7d80db4c2591b9bb307d136cea7b3bc129771d211d",
"sha256:514e39f4190ca972200ba33876da5a8857c5665f2b4ccc36c8b8ee21228aae80"
],
"version": "==1.24.0"
"version": "==1.25.0"
},
"gunicorn": {
"hashes": [
@ -522,10 +522,10 @@
},
"jinja2": {
"hashes": [
"sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0",
"sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035"
"sha256:03e47ad063331dd6a3f04a43eddca8a966a26ba0c5b7207a9a9e4e08f1b29419",
"sha256:a6d58433de0ae800347cab1fa3043cebbabe8baa9d29e668f1c768cb87a333c6"
],
"version": "==2.11.2"
"version": "==2.11.3"
},
"jmespath": {
"hashes": [
@ -614,8 +614,12 @@
"sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235",
"sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5",
"sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42",
"sha256:195d7d2c4fbb0ee8139a6cf67194f3973a6b3042d742ebe0a9ed36d8b6f0c07f",
"sha256:22c178a091fc6630d0d045bdb5992d2dfe14e3259760e713c490da5323866c39",
"sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff",
"sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b",
"sha256:2beec1e0de6924ea551859edb9e7679da6e4870d32cb766240ce17e0a0ba2014",
"sha256:3b8a6499709d29c2e2399569d96719a1b21dcd94410a586a18526b143ec8470f",
"sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1",
"sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e",
"sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183",
@ -624,24 +628,39 @@
"sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1",
"sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15",
"sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1",
"sha256:6f1e273a344928347c1290119b493a1f0303c52f5a5eae5f16d74f48c15d4a85",
"sha256:6fffc775d90dcc9aed1b89219549b329a9250d918fd0b8fa8d93d154918422e1",
"sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e",
"sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b",
"sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905",
"sha256:7fed13866cf14bba33e7176717346713881f56d9d2bcebab207f7a036f41b850",
"sha256:84dee80c15f1b560d55bcfe6d47b27d070b4681c699c572af2e3c7cc90a3b8e0",
"sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735",
"sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d",
"sha256:98bae9582248d6cf62321dcb52aaf5d9adf0bad3b40582925ef7c7f0ed85fceb",
"sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e",
"sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d",
"sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c",
"sha256:a6a744282b7718a2a62d2ed9d993cad6f5f585605ad352c11de459f4108df0a1",
"sha256:acf08ac40292838b3cbbb06cfe9b2cb9ec78fce8baca31ddb87aaac2e2dc3bc2",
"sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21",
"sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2",
"sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5",
"sha256:b1dba4527182c95a0db8b6060cc98ac49b9e2f5e64320e2b56e47cb2831978c7",
"sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b",
"sha256:b7d644ddb4dbd407d31ffb699f1d140bc35478da613b441c582aeb7c43838dd8",
"sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6",
"sha256:bf5aa3cbcfdf57fa2ee9cd1822c862ef23037f5c832ad09cfea57fa846dec193",
"sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f",
"sha256:caabedc8323f1e93231b52fc32bdcde6db817623d33e100708d9a68e1f53b26b",
"sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f",
"sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2",
"sha256:d53bc011414228441014aa71dbec320c66468c1030aae3a6e29778a3382d96e5",
"sha256:d73a845f227b0bfe8a7455ee623525ee656a9e2e749e4742706d80a6065d5e2c",
"sha256:d9be0ba6c527163cbed5e0857c451fcd092ce83947944d6c14bc95441203f032",
"sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7",
"sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"
"sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be",
"sha256:feb7b34d6325451ef96bc0e36e1a6c0c1c64bc1fbec4b854f4529e51887b1621"
],
"version": "==1.1.1"
},
@ -687,11 +706,11 @@
},
"packaging": {
"hashes": [
"sha256:24e0da08660a87484d1602c30bb4902d74816b6985b93de36926f5bc95741858",
"sha256:78598185a7008a470d64526a8059de9aaa449238f280fc9eb6b13ba6c4109093"
"sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5",
"sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"
],
"index": "pypi",
"version": "==20.8"
"version": "==20.9"
},
"prometheus-client": {
"hashes": [
@ -900,10 +919,10 @@
},
"pytz": {
"hashes": [
"sha256:16962c5fb8db4a8f63a26646d8886e9d769b6c511543557bc84e9569fb9a9cb4",
"sha256:180befebb1927b16f6b57101720075a984c019ac16b1b7575673bea42c6c3da5"
"sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da",
"sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798"
],
"version": "==2020.5"
"version": "==2021.1"
},
"pyyaml": {
"hashes": [
@ -1084,7 +1103,6 @@
"sha256:de3eedaad74a2683334e282005cd8d7f22f4d55fa690a2a1020a416cb0a47e73"
],
"index": "pypi",
"markers": null,
"version": "==1.26.3"
},
"uvicorn": {
@ -1275,10 +1293,11 @@
},
"autopep8": {
"hashes": [
"sha256:d21d3901cb0da6ebd1e83fc9b0dfbde8b46afc2ede4fe32fbda0c7c6118ca094"
"sha256:9e136c472c475f4ee4978b51a88a494bfcd4e3ed17950a44a988d9e434837bea",
"sha256:cae4bc0fb616408191af41d062d7ec7ef8679c7f27b068875ca3a9e2878d5443"
],
"index": "pypi",
"version": "==1.5.4"
"version": "==1.5.5"
},
"bandit": {
"hashes": [
@ -1382,11 +1401,11 @@
},
"django": {
"hashes": [
"sha256:2d78425ba74c7a1a74b196058b261b9733a8570782f4e2828974777ccca7edf7",
"sha256:efa2ab96b33b20c2182db93147a0c3cd7769d418926f9e9f140a60dca7c64ca9"
"sha256:169e2e7b4839a7910b393eec127fd7cbae62e80fa55f89c6510426abf673fe5f",
"sha256:c6c0462b8b361f8691171af1fb87eceb4442da28477e12200c40420176206ba7"
],
"index": "pypi",
"version": "==3.1.5"
"version": "==3.1.6"
},
"django-debug-toolbar": {
"hashes": [
@ -1487,11 +1506,11 @@
},
"packaging": {
"hashes": [
"sha256:24e0da08660a87484d1602c30bb4902d74816b6985b93de36926f5bc95741858",
"sha256:78598185a7008a470d64526a8059de9aaa449238f280fc9eb6b13ba6c4109093"
"sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5",
"sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"
],
"index": "pypi",
"version": "==20.8"
"version": "==20.9"
},
"pathspec": {
"hashes": [
@ -1616,10 +1635,10 @@
},
"pytz": {
"hashes": [
"sha256:16962c5fb8db4a8f63a26646d8886e9d769b6c511543557bc84e9569fb9a9cb4",
"sha256:180befebb1927b16f6b57101720075a984c019ac16b1b7575673bea42c6c3da5"
"sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da",
"sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798"
],
"version": "==2020.5"
"version": "==2021.1"
},
"pyyaml": {
"hashes": [
@ -1808,7 +1827,6 @@
"sha256:de3eedaad74a2683334e282005cd8d7f22f4d55fa690a2a1020a416cb0a47e73"
],
"index": "pypi",
"markers": null,
"version": "==1.26.3"
},
"wrapt": {

View File

@ -1,2 +1,2 @@
"""authentik"""
__version__ = "2021.1.4-stable"
__version__ = "2021.2.1-rc1"

View File

@ -1,5 +1,8 @@
"""authentik admin tasks"""
import re
from django.core.cache import cache
from django.core.validators import URLValidator
from packaging.version import parse
from requests import RequestException, get
from structlog.stdlib import get_logger
@ -11,7 +14,9 @@ from authentik.root.celery import CELERY_APP
LOGGER = get_logger()
VERSION_CACHE_KEY = "authentik_latest_version"
VERSION_CACHE_TIMEOUT = 2 * 60 * 60 # 2 hours
VERSION_CACHE_TIMEOUT = 8 * 60 * 60 # 8 hours
# Chop of the first ^ because we want to search the entire string
URL_FINDER = URLValidator.regex.pattern[1:]
@CELERY_APP.task(bind=True, base=MonitoredTask)
@ -39,7 +44,10 @@ def update_latest_version(self: MonitoredTask):
context__new_version=upstream_version,
).exists():
return
Event.new(EventAction.UPDATE_AVAILABLE, new_version=upstream_version).save()
event_dict = {"new_version": upstream_version}
if match := re.search(URL_FINDER, data.get("body", "")):
event_dict["message"] = f"Changelog: {match.group()}"
Event.new(EventAction.UPDATE_AVAILABLE, **event_dict).save()
except (RequestException, IndexError) as exc:
cache.set(VERSION_CACHE_KEY, "0.0.0", VERSION_CACHE_TIMEOUT)
self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc))

View File

@ -3,7 +3,42 @@
{% load i18n %}
{% block above_form %}
<h1>{% blocktrans with policy=policy %}Test policy {{ policy }}{% endblocktrans %}</h1>
<h1>{% blocktrans with policy=policy %}Test {{ policy }}{% endblocktrans %}</h1>
{% endblock %}
{% block beneath_form %}
{% if result %}
<div class="pf-c-form__group ">
<div class="pf-c-form__group-label">
<label class="pf-c-form__label" for="context-1">
<span class="pf-c-form__label-text">{% trans 'Passing' %}</span>
</label>
</div>
<div class="pf-c-form__group-control">
<div class="c-form__horizontal-group">
<span class="pf-c-form__label-text">{{ result.passing|yesno:"Yes,No" }}</span>
</div>
</div>
</div>
<div class="pf-c-form__group ">
<div class="pf-c-form__group-label">
<label class="pf-c-form__label" for="context-1">
<span class="pf-c-form__label-text">{% trans 'Messages' %}</span>
</label>
</div>
<div class="pf-c-form__group-control">
<div class="c-form__horizontal-group">
<ul>
{% for m in result.messages %}
<li><span class="pf-c-form__label-text">{{ m }}</span></li>
{% empty %}
<li><span class="pf-c-form__label-text">-</span></li>
{% endfor %}
</ul>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block action %}

View File

@ -0,0 +1,28 @@
{% extends 'generic/form.html' %}
{% load i18n %}
{% block above_form %}
<h1>{% blocktrans with property_mapping=property_mapping %}Test {{ property_mapping }}{% endblocktrans %}</h1>
{% endblock %}
{% block beneath_form %}
{% if result %}
<div class="pf-c-form__group ">
<div class="pf-c-form__group-label">
<label class="pf-c-form__label" for="context-1">
<span class="pf-c-form__label-text">{% trans 'Result' %}</span>
</label>
</div>
<div class="pf-c-form__group-control">
<div class="c-form__horizontal-group">
<ak-codemirror mode="javascript"><textarea class="pf-c-form-control">{{ result }}</textarea></ak-codemirror>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block action %}
{% trans 'Test' %}
{% endblock %}

View File

@ -32,7 +32,8 @@ REQUEST_MOCK_VALID = Mock(
return_value=MockResponse(
200,
"""{
"tag_name": "version/99999999.9999999"
"tag_name": "version/99999999.9999999",
"body": "https://goauthentik.io/test"
}""",
)
)
@ -52,6 +53,7 @@ class TestAdminTasks(TestCase):
Event.objects.filter(
action=EventAction.UPDATE_AVAILABLE,
context__new_version="99999999.9999999",
context__message="Changelog: https://goauthentik.io/test",
).exists()
)
# test that a consecutive check doesn't create a duplicate event
@ -61,6 +63,7 @@ class TestAdminTasks(TestCase):
Event.objects.filter(
action=EventAction.UPDATE_AVAILABLE,
context__new_version="99999999.9999999",
context__message="Changelog: https://goauthentik.io/test",
)
),
1,

View File

@ -238,11 +238,6 @@ urlpatterns = [
name="flow-delete",
),
# Property Mappings
path(
"property-mappings/",
property_mappings.PropertyMappingListView.as_view(),
name="property-mappings",
),
path(
"property-mappings/create/",
property_mappings.PropertyMappingCreateView.as_view(),
@ -258,6 +253,11 @@ urlpatterns = [
property_mappings.PropertyMappingDeleteView.as_view(),
name="property-mapping-delete",
),
path(
"property-mappings/<uuid:pk>/test/",
property_mappings.PropertyMappingTestView.as_view(),
name="property-mapping-test",
),
# Users
path("users/", users.UserListView.as_view(), name="users"),
path("users/create/", users.UserCreateView.as_view(), name="user-create"),

View File

@ -28,7 +28,7 @@ class PolicyCacheClearView(AdminRequiredMixin, SuccessMessageMixin, FormView):
cache.delete_many(keys)
LOGGER.debug("Cleared Policy cache", keys=len(keys))
# Also delete user application cache
keys = user_app_cache_key("*")
keys = cache.keys(user_app_cache_key("*"))
cache.delete_many(keys)
return super().post(request, *args, **kwargs)

View File

@ -1,13 +1,11 @@
"""authentik Policy administration"""
from typing import Any, Dict
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.mixins import (
PermissionRequiredMixin as DjangoPermissionRequiredMixin,
)
from django.contrib.messages.views import SuccessMessageMixin
from django.db.models import QuerySet
from django.http import HttpResponse
from django.urls import reverse_lazy
from django.utils.translation import gettext as _
@ -99,7 +97,7 @@ class PolicyTestView(LoginRequiredMixin, DetailView, PermissionRequiredMixin, Fo
template_name = "administration/policy/test.html"
object = None
def get_object(self, queryset=None) -> QuerySet:
def get_object(self, queryset=None) -> Policy:
return (
Policy.objects.filter(pk=self.kwargs.get("pk")).select_subclasses().first()
)
@ -118,12 +116,10 @@ class PolicyTestView(LoginRequiredMixin, DetailView, PermissionRequiredMixin, Fo
p_request = PolicyRequest(user)
p_request.http_request = self.request
p_request.context = form.cleaned_data
p_request.context = form.cleaned_data.get("context", {})
proc = PolicyProcess(PolicyBinding(policy=policy), p_request, None)
result = proc.execute()
if result.passing:
messages.success(self.request, _("User successfully passed policy."))
else:
messages.error(self.request, _("User didn't pass policy."))
return self.render_to_response(self.get_context_data(form=form, result=result))
context = self.get_context_data(form=form)
context["result"] = result
return self.render_to_response(context)

View File

@ -1,41 +1,29 @@
"""authentik PropertyMapping administration"""
from json import dumps
from typing import Any
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.mixins import (
PermissionRequiredMixin as DjangoPermissionRequiredMixin,
)
from django.contrib.messages.views import SuccessMessageMixin
from django.http import HttpResponse
from django.urls import reverse_lazy
from django.utils.translation import gettext as _
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
from django.views.generic import FormView
from django.views.generic.detail import DetailView
from guardian.mixins import PermissionRequiredMixin
from authentik.admin.forms.policies import PolicyTestForm
from authentik.admin.views.utils import (
BackSuccessUrlMixin,
DeleteMessageView,
InheritanceCreateView,
InheritanceListView,
InheritanceUpdateView,
SearchListMixin,
UserPaginateListMixin,
)
from authentik.core.models import PropertyMapping
class PropertyMappingListView(
LoginRequiredMixin,
PermissionListMixin,
UserPaginateListMixin,
SearchListMixin,
InheritanceListView,
):
"""Show list of all property_mappings"""
model = PropertyMapping
permission_required = "authentik_core.view_propertymapping"
template_name = "administration/property_mapping/list.html"
ordering = "name"
search_fields = ["name", "expression"]
class PropertyMappingCreateView(
SuccessMessageMixin,
BackSuccessUrlMixin,
@ -81,3 +69,44 @@ class PropertyMappingDeleteView(
template_name = "generic/delete.html"
success_url = reverse_lazy("authentik_admin:property-mappings")
success_message = _("Successfully deleted Property Mapping")
class PropertyMappingTestView(
LoginRequiredMixin, DetailView, PermissionRequiredMixin, FormView
):
"""View to test property mappings"""
model = PropertyMapping
form_class = PolicyTestForm
permission_required = "authentik_core.view_propertymapping"
template_name = "administration/property_mapping/test.html"
object = None
def get_object(self, queryset=None) -> PropertyMapping:
return (
PropertyMapping.objects.filter(pk=self.kwargs.get("pk"))
.select_subclasses()
.first()
)
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
kwargs["property_mapping"] = self.get_object()
return super().get_context_data(**kwargs)
def post(self, *args, **kwargs) -> HttpResponse:
self.object = self.get_object()
return super().post(*args, **kwargs)
def form_valid(self, form: PolicyTestForm) -> HttpResponse:
mapping = self.get_object()
user = form.cleaned_data.get("user")
context = self.get_context_data(form=form)
try:
result = mapping.evaluate(
user, self.request, **form.cleaned_data.get("context", {})
)
context["result"] = dumps(result, indent=4)
except Exception as exc: # pylint: disable=broad-except
context["result"] = str(exc)
return self.render_to_response(context)

View File

@ -1,7 +1,31 @@
{% extends "rest_framework/base.html" %}
{% block title %}{% if name %}{{ name }} {% endif %}authentik{% endblock %}
{% block branding %}
<span class='navbar-brand'>
authentik
</span>
{% endblock %}
{% block style %}
{{ block.super }}
<style>
body {
background-color: #18191a;
color: #fafafa;
}
.prettyprint {
background-color: #1c1e21;
color: #fafafa;
border: 1px solid #2b2e33;
}
.pln {
color: #fafafa;
}
.well {
background-color: #1c1e21;
border: 1px solid #2b2e33;
}
</style>
{% endblock %}

View File

@ -4,9 +4,6 @@ from django.apps import AppConfig, apps
from django.contrib import admin
from django.contrib.admin.sites import AlreadyRegistered
from guardian.admin import GuardedModelAdmin
from structlog.stdlib import get_logger
LOGGER = get_logger()
def admin_autoregister(app: AppConfig):
@ -20,5 +17,4 @@ def admin_autoregister(app: AppConfig):
for _app in apps.get_app_configs():
if _app.label.startswith("authentik_"):
LOGGER.debug("Registering application for dj-admin", application=_app.label)
admin_autoregister(_app)

View File

@ -91,7 +91,7 @@ class ApplicationViewSet(ModelViewSet):
queryset = self._filter_queryset_for_list(self.get_queryset())
self.paginate_queryset(queryset)
should_cache = "search" not in request.GET
should_cache = request.GET.get("search", "") == ""
allowed_applications = []
if not should_cache:

View File

@ -2,22 +2,36 @@
from rest_framework.serializers import ModelSerializer, SerializerMethodField
from rest_framework.viewsets import ReadOnlyModelViewSet
from authentik.core.api.utils import MetaNameSerializer
from authentik.core.models import PropertyMapping
class PropertyMappingSerializer(ModelSerializer):
class PropertyMappingSerializer(ModelSerializer, MetaNameSerializer):
"""PropertyMapping Serializer"""
__type__ = SerializerMethodField(method_name="get_type")
object_type = SerializerMethodField(method_name="get_type")
def get_type(self, obj):
"""Get object type so that we know which API Endpoint to use to get the full object"""
return obj._meta.object_name.lower().replace("propertymapping", "")
def to_representation(self, instance: PropertyMapping):
# pyright: reportGeneralTypeIssues=false
if instance.__class__ == PropertyMapping:
return super().to_representation(instance)
return instance.serializer(instance=instance).data
class Meta:
model = PropertyMapping
fields = ["pk", "name", "expression", "__type__"]
fields = [
"pk",
"name",
"expression",
"object_type",
"verbose_name",
"verbose_name_plural",
]
class PropertyMappingViewSet(ReadOnlyModelViewSet):
@ -25,6 +39,11 @@ class PropertyMappingViewSet(ReadOnlyModelViewSet):
queryset = PropertyMapping.objects.none()
serializer_class = PropertyMappingSerializer
search_fields = [
"name",
]
filterset_fields = {"managed": ["isnull"]}
ordering = ["name"]
def get_queryset(self):
return PropertyMapping.objects.select_subclasses()

View File

@ -1,4 +1,5 @@
"""Provider API Views"""
from rest_framework.fields import ReadOnlyField
from rest_framework.serializers import ModelSerializer, SerializerMethodField
from rest_framework.viewsets import ModelViewSet
@ -9,18 +10,15 @@ from authentik.core.models import Provider
class ProviderSerializer(ModelSerializer, MetaNameSerializer):
"""Provider Serializer"""
assigned_application_slug = ReadOnlyField(source="application.slug")
assigned_application_name = ReadOnlyField(source="application.name")
object_type = SerializerMethodField()
def get_object_type(self, obj):
"""Get object type so that we know which API Endpoint to use to get the full object"""
return obj._meta.object_name.lower().replace("provider", "")
def to_representation(self, instance: Provider):
# pyright: reportGeneralTypeIssues=false
if instance.__class__ == Provider:
return super().to_representation(instance)
return instance.serializer(instance=instance).data
class Meta:
model = Provider
@ -31,6 +29,8 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer):
"authorization_flow",
"property_mappings",
"object_type",
"assigned_application_slug",
"assigned_application_name",
"verbose_name",
"verbose_name_plural",
]
@ -44,6 +44,10 @@ class ProviderViewSet(ModelViewSet):
filterset_fields = {
"application": ["isnull"],
}
search_fields = [
"name",
"application__name",
]
def get_queryset(self):
return Provider.objects.select_subclasses()

View File

@ -1,4 +1,6 @@
"""authentik core app config"""
from importlib import import_module
from django.apps import AppConfig
@ -9,3 +11,6 @@ class AuthentikCoreConfig(AppConfig):
label = "authentik_core"
verbose_name = "authentik Core"
mountpoint = ""
def ready(self):
import_module("authentik.core.signals")

View File

@ -0,0 +1,35 @@
# Generated by Django 3.1.4 on 2021-01-30 18:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0016_auto_20201202_2234"),
]
operations = [
migrations.AddField(
model_name="propertymapping",
name="managed",
field=models.TextField(
default=None,
help_text="Objects which are managed by authentik. These objects are created and updated automatically. This is flag only indicates that an object can be overwritten by migrations. You can still modify the objects via the API, but expect changes to be overwritten in a later update.",
null=True,
verbose_name="Managed by authentik",
unique=True,
),
),
migrations.AddField(
model_name="token",
name="managed",
field=models.TextField(
default=None,
help_text="Objects which are managed by authentik. These objects are created and updated automatically. This is flag only indicates that an object can be overwritten by migrations. You can still modify the objects via the API, but expect changes to be overwritten in a later update.",
null=True,
verbose_name="Managed by authentik",
unique=True,
),
),
]

View File

@ -22,6 +22,7 @@ from authentik.core.signals import password_changed
from authentik.core.types import UILoginButton
from authentik.flows.models import Flow
from authentik.lib.models import CreatedUpdatedModel, SerializerModel
from authentik.managed.models import ManagedModel
from authentik.policies.models import PolicyBindingModel
LOGGER = get_logger()
@ -313,7 +314,7 @@ class TokenIntents(models.TextChoices):
INTENT_RECOVERY = "recovery"
class Token(ExpiringModel):
class Token(ManagedModel, ExpiringModel):
"""Token used to authenticate the User for API Access or confirm another Stage like Email."""
token_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
@ -341,7 +342,7 @@ class Token(ExpiringModel):
]
class PropertyMapping(models.Model):
class PropertyMapping(SerializerModel, ManagedModel):
"""User-defined key -> x mapping which can be used by providers to expose extra data."""
pm_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
@ -355,6 +356,11 @@ class PropertyMapping(models.Model):
"""Return Form class used to edit this object"""
raise NotImplementedError
@property
def serializer(self) -> Type[Serializer]:
"""Get serializer for this model"""
raise NotImplementedError
def evaluate(
self, user: Optional[User], request: Optional[HttpRequest], **kwargs
) -> Any:

View File

@ -1,5 +1,24 @@
"""authentik core signals"""
from django.core.cache import cache
from django.core.signals import Signal
from django.db.models.signals import post_save
from django.dispatch import receiver
# Arguments: user: User, password: str
password_changed = Signal()
@receiver(post_save)
# pylint: disable=unused-argument
def post_save_application(sender, instance, created: bool, **_):
"""Clear user's application cache upon application creation"""
from authentik.core.models import Application
from authentik.core.api.applications import user_app_cache_key
if sender != Application:
return
if not created:
return
# Also delete user application cache
keys = cache.keys(user_app_cache_key("*"))
cache.delete_many(keys)

View File

@ -50,6 +50,7 @@ class EventViewSet(ReadOnlyModelViewSet):
serializer_class = EventSerializer
ordering = ["-created"]
search_fields = [
"event_uuid",
"user",
"action",
"app",

View File

@ -15,6 +15,7 @@ class NotificationTransportForm(forms.ModelForm):
"name",
"mode",
"webhook_url",
"send_once",
]
widgets = {
"name": forms.TextInput(),

View File

@ -0,0 +1,52 @@
# Generated by Django 3.1.6 on 2021-02-02 18:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_events", "0011_notification_rules_default_v1"),
]
operations = [
migrations.AddField(
model_name="notificationtransport",
name="send_once",
field=models.BooleanField(
default=False,
help_text="Only send notification once, for example when sending a webhook into a chat channel.",
),
),
migrations.AlterField(
model_name="event",
name="action",
field=models.TextField(
choices=[
("login", "Login"),
("login_failed", "Login Failed"),
("logout", "Logout"),
("user_write", "User Write"),
("suspicious_request", "Suspicious Request"),
("password_set", "Password Set"),
("token_view", "Token View"),
("invitation_used", "Invite Used"),
("authorize_application", "Authorize Application"),
("source_linked", "Source Linked"),
("impersonation_started", "Impersonation Started"),
("impersonation_ended", "Impersonation Ended"),
("policy_execution", "Policy Execution"),
("policy_exception", "Policy Exception"),
("property_mapping_exception", "Property Mapping Exception"),
("system_task_execution", "System Task Execution"),
("system_task_exception", "System Task Exception"),
("configuration_error", "Configuration Error"),
("model_created", "Model Created"),
("model_updated", "Model Updated"),
("model_deleted", "Model Deleted"),
("update_available", "Update Available"),
("custom_", "Custom Prefix"),
]
),
),
]

View File

@ -184,6 +184,12 @@ class NotificationTransport(models.Model):
mode = models.TextField(choices=TransportMode.choices)
webhook_url = models.TextField(blank=True)
send_once = models.BooleanField(
default=False,
help_text=_(
"Only send notification once, for example when sending a webhook into a chat channel."
),
)
def send(self, notification: "Notification") -> list[str]:
"""Send notification to user, called from async task"""
@ -254,7 +260,6 @@ class NotificationTransport(models.Model):
}
if notification.event:
body["attachments"][0]["title"] = notification.event.action
body["attachments"][0]["text"] = notification.event.action
try:
response = post(self.webhook_url, json=body)
response.raise_for_status()
@ -267,17 +272,24 @@ class NotificationTransport(models.Model):
def send_email(self, notification: "Notification") -> list[str]:
"""Send notification via global email configuration"""
body_trunc = (
(notification.body[:75] + "..")
if len(notification.body) > 75
else notification.body
)
subject = "authentik Notification: "
key_value = {}
if notification.event:
subject += notification.event.action
for key, value in notification.event.context.items():
if not isinstance(value, str):
continue
key_value[key] = value
else:
subject += notification.body[:75]
mail = TemplateEmailMessage(
subject=f"authentik Notification: {body_trunc}",
subject=subject,
template_name="email/generic.html",
to=[notification.user.email],
template_context={
"title": subject,
"body": notification.body,
"key_value": key_value,
},
)
# Email is sent directly here, as the call to send() should have been from a task.

View File

@ -124,13 +124,6 @@ class MonitoredTask(Task):
task_call_args=args,
task_call_kwargs=kwargs,
).save(self.result_timeout_hours)
Event.new(
EventAction.SYSTEM_TASK_EXECUTION,
message=(
f"Task {self.__name__} finished successfully: "
"\n".join(self._result.messages)
),
).save()
return super().after_return(status, retval, task_id, args, kwargs, einfo=einfo)
# pylint: disable=too-many-arguments

View File

@ -65,15 +65,17 @@ def event_trigger_handler(event_uuid: str, trigger_name: str):
LOGGER.debug("e(trigger): event trigger matched", trigger=trigger)
# Create the notification objects
for user in trigger.group.users.all():
notification = Notification.objects.create(
severity=trigger.severity, body=event.summary, event=event, user=user
)
for transport in trigger.transports.all():
for transport in trigger.transports.all():
for user in trigger.group.users.all():
LOGGER.debug("created notif")
notification = Notification.objects.create(
severity=trigger.severity, body=event.summary, event=event, user=user
)
notification_transport.apply_async(
args=[notification.pk, transport.pk], queue="authentik_events"
)
if transport.send_once:
break
@CELERY_APP.task(

View File

@ -8,6 +8,7 @@ from authentik.core.models import Group, User
from authentik.events.models import (
Event,
EventAction,
Notification,
NotificationRule,
NotificationTransport,
)
@ -21,7 +22,7 @@ class TestEventsNotifications(TestCase):
def setUp(self) -> None:
self.group = Group.objects.create(name="test-group")
self.user = User.objects.create(name="test-user")
self.user = User.objects.create(name="test-user", username="test")
self.group.users.add(self.user)
self.group.save()
@ -88,3 +89,26 @@ class TestEventsNotifications(TestCase):
):
Event.new(EventAction.CUSTOM_PREFIX).save()
self.assertEqual(passes.call_count, 1)
def test_transport_once(self):
"""Test transport's send_once"""
user2 = User.objects.create(name="test2-user", username="test2")
self.group.users.add(user2)
self.group.save()
transport = NotificationTransport.objects.create(
name="transport", send_once=True
)
NotificationRule.objects.filter(name__startswith="default").delete()
trigger = NotificationRule.objects.create(name="trigger", group=self.group)
trigger.transports.add(transport)
trigger.save()
matcher = EventMatcherPolicy.objects.create(
name="matcher", action=EventAction.CUSTOM_PREFIX
)
PolicyBinding.objects.create(target=trigger, policy=matcher, order=0)
execute_mock = MagicMock()
with patch("authentik.events.models.NotificationTransport.send", execute_mock):
Event.new(EventAction.CUSTOM_PREFIX).save()
self.assertEqual(Notification.objects.count(), 1)

View File

@ -1,6 +1,6 @@
"""authentik benchmark command"""
from csv import DictWriter
from multiprocessing import Manager, Process, cpu_count
from multiprocessing import Manager, cpu_count, get_context
from sys import stdout
from time import time
@ -15,9 +15,11 @@ from authentik.flows.models import Flow
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner
LOGGER = get_logger()
FORK_CTX = get_context("fork")
PROCESS_CLASS = FORK_CTX.Process
class FlowPlanProcess(Process): # pragma: no cover
class FlowPlanProcess(PROCESS_CLASS): # pragma: no cover
"""Test process which executes flow planner"""
def __init__(self, index, return_dict, flow, user) -> None:

View File

@ -6,7 +6,7 @@ from django.core.cache import cache
from django.http import HttpRequest
from sentry_sdk.hub import Hub
from sentry_sdk.tracing import Span
from structlog.stdlib import get_logger
from structlog.stdlib import BoundLogger, get_logger
from authentik.core.models import User
from authentik.events.models import cleanse_dict
@ -16,7 +16,6 @@ from authentik.flows.models import Flow, FlowStageBinding, Stage
from authentik.policies.engine import PolicyEngine
LOGGER = get_logger()
PLAN_CONTEXT_PENDING_USER = "pending_user"
PLAN_CONTEXT_SSO = "is_sso"
PLAN_CONTEXT_REDIRECT = "redirect"
@ -88,10 +87,13 @@ class FlowPlanner:
flow: Flow
_logger: BoundLogger
def __init__(self, flow: Flow):
self.use_cache = True
self.allow_empty_flows = False
self.flow = flow
self._logger = get_logger().bind(flow=flow)
def plan(
self, request: HttpRequest, default_context: Optional[Dict[str, Any]] = None
@ -103,7 +105,9 @@ class FlowPlanner:
span.set_data("flow", self.flow)
span.set_data("request", request)
LOGGER.debug("f(plan): Starting planning process", flow=self.flow)
self._logger.debug(
"f(plan): starting planning process",
)
# Bit of a workaround here, if there is a pending user set in the default context
# we use that user for our cache key
# to make sure they don't get the generic response
@ -125,15 +129,16 @@ class FlowPlanner:
cached_plan_key = cache_key(self.flow, user)
cached_plan = cache.get(cached_plan_key, None)
if cached_plan and self.use_cache:
LOGGER.debug(
"f(plan): Taking plan from cache",
flow=self.flow,
self._logger.debug(
"f(plan): taking plan from cache",
key=cached_plan_key,
)
# Reset the context as this isn't factored into caching
cached_plan.context = default_context or {}
return cached_plan
LOGGER.debug("f(plan): building plan", flow=self.flow)
self._logger.debug(
"f(plan): building plan",
)
plan = self._build_plan(user, request, default_context)
cache.set(cache_key(self.flow, user), plan)
if not plan.stages and not self.allow_empty_flows:
@ -165,39 +170,34 @@ class FlowPlanner:
stage = binding.stage
marker = StageMarker()
if binding.evaluate_on_plan:
LOGGER.debug(
self._logger.debug(
"f(plan): evaluating on plan",
stage=binding.stage,
flow=self.flow,
)
engine = PolicyEngine(binding, user, request)
engine.request.context = plan.context
engine.build()
if engine.passing:
LOGGER.debug(
"f(plan): Stage passing",
self._logger.debug(
"f(plan): stage passing",
stage=binding.stage,
flow=self.flow,
)
else:
stage = None
else:
LOGGER.debug(
self._logger.debug(
"f(plan): not evaluating on plan",
stage=binding.stage,
flow=self.flow,
)
if binding.re_evaluate_policies and stage:
LOGGER.debug(
"f(plan): Stage has re-evaluate marker",
self._logger.debug(
"f(plan): stage has re-evaluate marker",
stage=binding.stage,
flow=self.flow,
)
marker = ReevaluateMarker(binding=binding, user=user)
if stage:
plan.append(stage, marker)
LOGGER.debug(
"f(plan): Finished building",
flow=self.flow,
self._logger.debug(
"f(plan): finished building",
)
return plan

View File

@ -15,7 +15,7 @@ from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator
from django.views.decorators.clickjacking import xframe_options_sameorigin
from django.views.generic import TemplateView, View
from structlog.stdlib import get_logger
from structlog.stdlib import BoundLogger, get_logger
from authentik.core.models import USER_ATTRIBUTE_DEBUG
from authentik.events.models import cleanse_dict
@ -49,45 +49,48 @@ class FlowExecutorView(View):
current_stage: Stage
current_stage_view: View
_logger: BoundLogger
def setup(self, request: HttpRequest, flow_slug: str):
super().setup(request, flow_slug=flow_slug)
self.flow = get_object_or_404(Flow.objects.select_related(), slug=flow_slug)
self._logger = get_logger().bind(flow_slug=flow_slug)
def handle_invalid_flow(self, exc: BaseException) -> HttpResponse:
"""When a flow is non-applicable check if user is on the correct domain"""
if NEXT_ARG_NAME in self.request.GET:
if not is_url_absolute(self.request.GET.get(NEXT_ARG_NAME)):
LOGGER.debug("f(exec): Redirecting to next on fail")
self._logger.debug("f(exec): Redirecting to next on fail")
return redirect(self.request.GET.get(NEXT_ARG_NAME))
message = exc.__doc__ if exc.__doc__ else str(exc)
return self.stage_invalid(error_message=message)
# pylint: disable=unused-argument
def dispatch(self, request: HttpRequest, flow_slug: str) -> HttpResponse:
# Early check if theres an active Plan for the current session
if SESSION_KEY_PLAN in self.request.session:
self.plan = self.request.session[SESSION_KEY_PLAN]
if self.plan.flow_pk != self.flow.pk.hex:
LOGGER.warning(
self._logger.warning(
"f(exec): Found existing plan for other flow, deleteing plan",
flow_slug=flow_slug,
)
# Existing plan is deleted from session and instance
self.plan = None
self.cancel()
LOGGER.debug("f(exec): Continuing existing plan", flow_slug=flow_slug)
self._logger.debug("f(exec): Continuing existing plan")
# Don't check session again as we've either already loaded the plan or we need to plan
if not self.plan:
LOGGER.debug(
"f(exec): No active Plan found, initiating planner", flow_slug=flow_slug
)
self._logger.debug("f(exec): No active Plan found, initiating planner")
try:
self.plan = self._initiate_plan()
except FlowNonApplicableException as exc:
LOGGER.warning("f(exec): Flow not applicable to current user", exc=exc)
self._logger.warning(
"f(exec): Flow not applicable to current user", exc=exc
)
return to_stage_response(self.request, self.handle_invalid_flow(exc))
except EmptyFlowException as exc:
LOGGER.warning("f(exec): Flow is empty", exc=exc)
self._logger.warning("f(exec): Flow is empty", exc=exc)
# To match behaviour with loading an empty flow plan from cache,
# we don't show an error message here, but rather call _flow_done()
return self._flow_done()
@ -95,10 +98,10 @@ class FlowExecutorView(View):
# as it hasn't been successfully passed yet
next_stage = self.plan.next(self.request)
if not next_stage:
LOGGER.debug("f(exec): no more stages, flow is done.")
self._logger.debug("f(exec): no more stages, flow is done.")
return self._flow_done()
self.current_stage = next_stage
LOGGER.debug(
self._logger.debug(
"f(exec): Current stage",
current_stage=self.current_stage,
flow_slug=self.flow.slug,
@ -112,32 +115,30 @@ class FlowExecutorView(View):
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
"""pass get request to current stage"""
LOGGER.debug(
self._logger.debug(
"f(exec): Passing GET",
view_class=class_to_path(self.current_stage_view.__class__),
stage=self.current_stage,
flow_slug=self.flow.slug,
)
try:
stage_response = self.current_stage_view.get(request, *args, **kwargs)
return to_stage_response(request, stage_response)
except Exception as exc: # pylint: disable=broad-except
LOGGER.exception(exc)
self._logger.exception(exc)
return to_stage_response(request, FlowErrorResponse(request, exc))
def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
"""pass post request to current stage"""
LOGGER.debug(
self._logger.debug(
"f(exec): Passing POST",
view_class=class_to_path(self.current_stage_view.__class__),
stage=self.current_stage,
flow_slug=self.flow.slug,
)
try:
stage_response = self.current_stage_view.post(request, *args, **kwargs)
return to_stage_response(request, stage_response)
except Exception as exc: # pylint: disable=broad-except
LOGGER.exception(exc)
self._logger.exception(exc)
return to_stage_response(request, FlowErrorResponse(request, exc))
def _initiate_plan(self) -> FlowPlan:
@ -163,26 +164,23 @@ class FlowExecutorView(View):
def stage_ok(self) -> HttpResponse:
"""Callback called by stages upon successful completion.
Persists updated plan and context to session."""
LOGGER.debug(
self._logger.debug(
"f(exec): Stage ok",
stage_class=class_to_path(self.current_stage_view.__class__),
flow_slug=self.flow.slug,
)
self.plan.pop()
self.request.session[SESSION_KEY_PLAN] = self.plan
if self.plan.stages:
LOGGER.debug(
self._logger.debug(
"f(exec): Continuing with next stage",
reamining=len(self.plan.stages),
flow_slug=self.flow.slug,
)
return redirect_with_qs(
"authentik_flows:flow-executor", self.request.GET, **self.kwargs
)
# User passed all stages
LOGGER.debug(
self._logger.debug(
"f(exec): User passed all stages",
flow_slug=self.flow.slug,
context=cleanse_dict(self.plan.context),
)
return self._flow_done()
@ -193,7 +191,7 @@ class FlowExecutorView(View):
Optionally, an exception can be passed, which will be shown if the current user
is a superuser."""
LOGGER.debug("f(exec): Stage invalid", flow_slug=self.flow.slug)
self._logger.debug("f(exec): Stage invalid")
self.cancel()
response = AccessDeniedResponse(
self.request, template="flows/denied_shell.html"

View File

@ -1,7 +1,6 @@
"""logging helpers"""
from logging import Logger
from os import getpid
from typing import Callable
# pylint: disable=unused-argument
@ -9,15 +8,3 @@ def add_process_id(logger: Logger, method_name: str, event_dict):
"""Add the current process ID"""
event_dict["pid"] = getpid()
return event_dict
def add_common_fields(environment: str) -> Callable:
"""Add a common field to easily search for authentik logs"""
def add_common_field(logger: Logger, method_name: str, event_dict):
"""Add a common field to easily search for authentik logs"""
event_dict["app"] = "authentik"
event_dict["app_environment"] = environment
return event_dict
return add_common_field

View File

@ -59,6 +59,5 @@ def before_send(event, hint):
if "exc_info" in hint:
_, exc_value, _ = hint["exc_info"]
if isinstance(exc_value, ignored_classes):
LOGGER.info("Supressing error %r", exc_value)
return None
return event

View File

16
authentik/managed/apps.py Normal file
View File

@ -0,0 +1,16 @@
"""authentik Managed app"""
from django.apps import AppConfig
class AuthentikManagedConfig(AppConfig):
"""authentik Managed app"""
name = "authentik.managed"
label = "authentik_Managed"
verbose_name = "authentik Managed"
def ready(self) -> None:
from authentik.managed.tasks import managed_reconcile
# pyright: reportGeneralTypeIssues=false
managed_reconcile() # pylint: disable=no-value-for-parameter

View File

@ -0,0 +1,56 @@
"""Managed objects manager"""
from typing import Type
from structlog.stdlib import get_logger
from authentik.managed.models import ManagedModel
LOGGER = get_logger()
class EnsureOp:
"""Ensure operation, executed as part of an ObjectManager run"""
_obj: Type[ManagedModel]
_managed_uid: str
_kwargs: dict
def __init__(self, obj: Type[ManagedModel], managed_uid: str, **kwargs) -> None:
self._obj = obj
self._managed_uid = managed_uid
self._kwargs = kwargs
def run(self):
"""Do the actual ensure action"""
raise NotImplementedError
class EnsureExists(EnsureOp):
"""Ensure object exists, with kwargs as given values"""
def run(self):
self._kwargs.setdefault("managed", self._managed_uid)
self._obj.objects.update_or_create(
**{
"managed": self._managed_uid,
"defaults": self._kwargs,
}
)
class ObjectManager:
"""Base class for Apps Object manager"""
def run(self):
"""Main entrypoint for tasks, iterate through all implementation of this
and execute all operations"""
for sub in ObjectManager.__subclasses__():
sub_inst = sub()
ops = sub_inst.reconcile()
LOGGER.debug("Reconciling managed objects", manager=sub.__name__)
for operation in ops:
operation.run()
def reconcile(self) -> list[EnsureOp]:
"""Method which is implemented in subclass that returns a list of Operations"""
raise NotImplementedError

View File

@ -0,0 +1,31 @@
"""Managed Object models"""
from django.db import models
from django.db.models import QuerySet
from django.utils.translation import gettext_lazy as _
class ManagedModel(models.Model):
"""Model which can be managed by authentik exclusively"""
managed = models.TextField(
default=None,
null=True,
verbose_name=_("Managed by authentik"),
help_text=_(
(
"Objects which are managed by authentik. These objects are created and updated "
"automatically. This is flag only indicates that an object can be overwritten by "
"migrations. You can still modify the objects via the API, but expect changes "
"to be overwritten in a later update."
)
),
unique=True,
)
def managed_objects(self) -> QuerySet:
"""Get all objects which are managed"""
return self.objects.exclude(managed__isnull=True)
class Meta:
abstract = True

View File

@ -0,0 +1,10 @@
"""managed Settings"""
from celery.schedules import crontab
CELERY_BEAT_SCHEDULE = {
"managed_reconcile": {
"task": "authentik.managed.tasks.managed_reconcile",
"schedule": crontab(minute="*/5"),
"options": {"queue": "authentik_scheduled"},
},
}

View File

@ -0,0 +1,20 @@
"""managed tasks"""
from django.db import DatabaseError
from authentik.core.tasks import CELERY_APP
from authentik.events.monitored_tasks import MonitoredTask, TaskResult, TaskResultStatus
from authentik.managed.manager import ObjectManager
@CELERY_APP.task(bind=True, base=MonitoredTask)
def managed_reconcile(self: MonitoredTask):
"""Run ObjectManager to ensure objects are up-to-date"""
try:
ObjectManager().run()
self.set_status(
TaskResult(
TaskResultStatus.SUCCESSFUL, ["Successfully updated managed models."]
)
)
except DatabaseError as exc:
self.set_status(TaskResult(TaskResultStatus.WARNING, [str(exc)]))

View File

@ -363,6 +363,7 @@ class Outpost(models.Model):
intent=TokenIntents.INTENT_API,
description=f"Autogenerated by authentik for Outpost {self.name}",
expiring=False,
managed="goauthentik.io/outpost",
)
def get_required_objects(self) -> Iterable[models.Model]:

View File

@ -8,14 +8,13 @@ from django.core.cache import cache
from django.http import HttpRequest
from sentry_sdk.hub import Hub
from sentry_sdk.tracing import Span
from structlog.stdlib import get_logger
from structlog.stdlib import BoundLogger, get_logger
from authentik.core.models import User
from authentik.policies.models import Policy, PolicyBinding, PolicyBindingModel
from authentik.policies.process import PolicyProcess, cache_key
from authentik.policies.types import PolicyRequest, PolicyResult
LOGGER = get_logger()
CURRENT_PROCESS = current_process()
@ -49,6 +48,7 @@ class PolicyEngine:
use_cache: bool
request: PolicyRequest
logger: BoundLogger
mode: PolicyEngineMode
# Allow objects with no policies attached to pass
empty_result: bool
@ -62,6 +62,7 @@ class PolicyEngine:
def __init__(
self, pbm: PolicyBindingModel, user: User, request: HttpRequest = None
):
self.logger = get_logger().bind()
self.mode = PolicyEngineMode.MODE_AND
# For backwards compatibility, set empty_result to true
# objects with no policies attached will pass.
@ -105,18 +106,18 @@ class PolicyEngine:
key = cache_key(binding, self.request)
cached_policy = cache.get(key, None)
if cached_policy and self.use_cache:
LOGGER.debug(
self.logger.debug(
"P_ENG: Taking result from cache",
policy=binding.policy,
cache_key=key,
)
self.__cached_policies.append(cached_policy)
continue
LOGGER.debug("P_ENG: Evaluating policy", policy=binding.policy)
self.logger.debug("P_ENG: Evaluating policy", policy=binding.policy)
our_end, task_end = Pipe(False)
task = PolicyProcess(binding, self.request, task_end)
task.daemon = False
LOGGER.debug("P_ENG: Starting Process", policy=binding.policy)
self.logger.debug("P_ENG: Starting Process", policy=binding.policy)
if not CURRENT_PROCESS._config.get("daemon"):
task.run()
else:

View File

@ -0,0 +1,46 @@
# Generated by Django 3.1.6 on 2021-02-02 18:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_policies_event_matcher", "0004_auto_20210112_2158"),
]
operations = [
migrations.AlterField(
model_name="eventmatcherpolicy",
name="action",
field=models.TextField(
blank=True,
choices=[
("login", "Login"),
("login_failed", "Login Failed"),
("logout", "Logout"),
("user_write", "User Write"),
("suspicious_request", "Suspicious Request"),
("password_set", "Password Set"),
("token_view", "Token View"),
("invitation_used", "Invite Used"),
("authorize_application", "Authorize Application"),
("source_linked", "Source Linked"),
("impersonation_started", "Impersonation Started"),
("impersonation_ended", "Impersonation Ended"),
("policy_execution", "Policy Execution"),
("policy_exception", "Policy Exception"),
("property_mapping_exception", "Property Mapping Exception"),
("system_task_execution", "System Task Execution"),
("system_task_exception", "System Task Exception"),
("configuration_error", "Configuration Error"),
("model_created", "Model Created"),
("model_updated", "Model Updated"),
("model_deleted", "Model Deleted"),
("update_available", "Update Available"),
("custom_", "Custom Prefix"),
],
help_text="Match created events with this action type. When left empty, all action types will be matched.",
),
),
]

View File

@ -0,0 +1,73 @@
# Generated by Django 3.1.6 on 2021-02-03 11:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_policies_event_matcher", "0005_auto_20210202_1821"),
]
operations = [
migrations.AlterField(
model_name="eventmatcherpolicy",
name="app",
field=models.TextField(
blank=True,
choices=[
("authentik.admin", "authentik Admin"),
("authentik.api", "authentik API"),
("authentik.events", "authentik Events"),
("authentik.crypto", "authentik Crypto"),
("authentik.flows", "authentik Flows"),
("authentik.outposts", "authentik Outpost"),
("authentik.lib", "authentik lib"),
("authentik.policies", "authentik Policies"),
("authentik.policies.dummy", "authentik Policies.Dummy"),
(
"authentik.policies.event_matcher",
"authentik Policies.Event Matcher",
),
("authentik.policies.expiry", "authentik Policies.Expiry"),
("authentik.policies.expression", "authentik Policies.Expression"),
(
"authentik.policies.group_membership",
"authentik Policies.Group Membership",
),
("authentik.policies.hibp", "authentik Policies.HaveIBeenPwned"),
("authentik.policies.password", "authentik Policies.Password"),
("authentik.policies.reputation", "authentik Policies.Reputation"),
("authentik.providers.proxy", "authentik Providers.Proxy"),
("authentik.providers.oauth2", "authentik Providers.OAuth2"),
("authentik.providers.saml", "authentik Providers.SAML"),
("authentik.recovery", "authentik Recovery"),
("authentik.sources.ldap", "authentik Sources.LDAP"),
("authentik.sources.oauth", "authentik Sources.OAuth"),
("authentik.sources.saml", "authentik Sources.SAML"),
("authentik.stages.captcha", "authentik Stages.Captcha"),
("authentik.stages.consent", "authentik Stages.Consent"),
("authentik.stages.dummy", "authentik Stages.Dummy"),
("authentik.stages.email", "authentik Stages.Email"),
("authentik.stages.prompt", "authentik Stages.Prompt"),
(
"authentik.stages.identification",
"authentik Stages.Identification",
),
("authentik.stages.invitation", "authentik Stages.User Invitation"),
("authentik.stages.user_delete", "authentik Stages.User Delete"),
("authentik.stages.user_login", "authentik Stages.User Login"),
("authentik.stages.user_logout", "authentik Stages.User Logout"),
("authentik.stages.user_write", "authentik Stages.User Write"),
("authentik.stages.otp_static", "authentik Stages.OTP.Static"),
("authentik.stages.otp_time", "authentik Stages.OTP.Time"),
("authentik.stages.otp_validate", "authentik Stages.OTP.Validate"),
("authentik.stages.password", "authentik Stages.Password"),
("authentik.managed", "authentik Managed"),
("authentik.core", "authentik Core"),
],
default="",
help_text="Match events created by selected application. When left empty, all applications are matched.",
),
),
]

View File

@ -17,6 +17,9 @@ from authentik.policies.types import PolicyRequest, PolicyResult
LOGGER = get_logger()
TRACEBACK_HEADER = "Traceback (most recent call last):\n"
FORK_CTX = get_context("fork")
PROCESS_CLASS = FORK_CTX.Process
def cache_key(binding: PolicyBinding, request: PolicyRequest) -> str:
"""Generate Cache key for policy"""
@ -28,10 +31,6 @@ def cache_key(binding: PolicyBinding, request: PolicyRequest) -> str:
return prefix
FORK_CTX = get_context("fork")
PROCESS_CLASS = FORK_CTX.Process
class PolicyProcess(PROCESS_CLASS):
"""Evaluate a single policy within a seprate process"""
@ -103,17 +102,16 @@ class PolicyProcess(PROCESS_CLASS):
# Invert result if policy.negate is set
if self.binding.negate:
policy_result.passing = not policy_result.passing
key = cache_key(self.binding, self.request)
cache.set(key, policy_result)
LOGGER.debug(
"P_ENG(proc): Finished",
"P_ENG(proc): finished and cached ",
policy=self.binding.policy,
result=policy_result,
process="PolicyProcess",
passing=policy_result.passing,
user=self.request.user,
)
key = cache_key(self.binding, self.request)
cache.set(key, policy_result)
LOGGER.debug("P_ENG(proc): Cached policy evaluation", key=key)
return policy_result
def run(self): # pragma: no cover

View File

@ -26,5 +26,5 @@ def invalidate_policy_cache(sender, instance, **_):
cache.delete_many(keys)
LOGGER.debug("Invalidating policy cache", policy=instance, keys=total)
# Also delete user application cache
keys = user_app_cache_key("*")
keys = cache.keys(user_app_cache_key("*"))
cache.delete_many(keys)

View File

@ -1,20 +1,27 @@
"""OAuth2Provider API Views"""
from rest_framework.serializers import ModelSerializer
from django.shortcuts import reverse
from drf_yasg2.utils import swagger_auto_schema
from rest_framework.decorators import action
from rest_framework.fields import ReadOnlyField
from rest_framework.generics import get_object_or_404
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer, Serializer
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.providers import ProviderSerializer
from authentik.core.api.utils import MetaNameSerializer
from authentik.core.models import Provider
from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping
class OAuth2ProviderSerializer(ModelSerializer, MetaNameSerializer):
class OAuth2ProviderSerializer(ProviderSerializer):
"""OAuth2Provider Serializer"""
class Meta:
model = OAuth2Provider
fields = [
"pk",
"name",
fields = ProviderSerializer.Meta.fields + [
"authorization_flow",
"client_type",
"client_id",
@ -27,25 +34,83 @@ class OAuth2ProviderSerializer(ModelSerializer, MetaNameSerializer):
"sub_mode",
"property_mappings",
"issuer_mode",
"verbose_name",
"verbose_name_plural",
]
class OAuth2ProviderSetupURLs(Serializer):
"""OAuth2 Provider Metadata serializer"""
issuer = ReadOnlyField()
authorize = ReadOnlyField()
token = ReadOnlyField()
user_info = ReadOnlyField()
provider_info = ReadOnlyField()
def create(self, request: Request) -> Response:
raise NotImplementedError
def update(self, request: Request) -> Response:
raise NotImplementedError
class OAuth2ProviderViewSet(ModelViewSet):
"""OAuth2Provider Viewset"""
queryset = OAuth2Provider.objects.all()
serializer_class = OAuth2ProviderSerializer
@action(methods=["GET"], detail=True)
@swagger_auto_schema(responses={200: OAuth2ProviderSetupURLs(many=False)})
# pylint: disable=invalid-name
def setup_urls(self, request: Request, pk: int) -> str:
"""Return metadata as XML string"""
provider = get_object_or_404(OAuth2Provider, pk=pk)
data = {
"issuer": provider.get_issuer(request),
"authorize": request.build_absolute_uri(
reverse(
"authentik_providers_oauth2:authorize",
)
),
"token": request.build_absolute_uri(
reverse(
"authentik_providers_oauth2:token",
)
),
"user_info": request.build_absolute_uri(
reverse(
"authentik_providers_oauth2:userinfo",
)
),
"provider_info": None,
}
try:
data["provider_info"] = request.build_absolute_uri(
reverse(
"authentik_providers_oauth2:provider-info",
kwargs={"application_slug": provider.application.slug},
)
)
except Provider.application.RelatedObjectDoesNotExist: # pylint: disable=no-member
pass
return Response(data)
class ScopeMappingSerializer(ModelSerializer):
class ScopeMappingSerializer(ModelSerializer, MetaNameSerializer):
"""ScopeMapping Serializer"""
class Meta:
model = ScopeMapping
fields = ["pk", "name", "scope_name", "description", "expression"]
fields = [
"pk",
"name",
"scope_name",
"description",
"expression",
"verbose_name",
"verbose_name_plural",
]
class ScopeMappingViewSet(ModelViewSet):

View File

@ -1,4 +1,6 @@
"""authentik auth oauth provider app config"""
from importlib import import_module
from django.apps import AppConfig
@ -12,3 +14,6 @@ class AuthentikProviderOAuth2Config(AppConfig):
"authentik.providers.oauth2.urls": "application/o/",
"authentik.providers.oauth2.urls_github": "",
}
def ready(self) -> None:
import_module("authentik.providers.oauth2.managed")

View File

@ -23,11 +23,12 @@ class OAuth2Error(SentryIgnoredException):
def __repr__(self) -> str:
return self.error
def to_event(self, message: Optional[str] = None) -> Event:
def to_event(self, message: Optional[str] = None, **kwargs) -> Event:
"""Create configuration_error Event and save it."""
return Event.new(
EventAction.CONFIGURATION_ERROR,
message=message or self.description,
**kwargs,
)
@ -49,10 +50,11 @@ class RedirectUriError(OAuth2Error):
self.provided_uri = provided_uri
self.allowed_uris = allowed_uris
def to_event(self) -> Event:
def to_event(self, **kwargs) -> Event:
return super().to_event(
f"Invalid redirect URI was used. Client used '{self.provided_uri}'. "
f"Allowed redirect URIs are {','.join(self.allowed_uris)}"
f"Allowed redirect URIs are {','.join(self.allowed_uris)}",
**kwargs,
)
@ -68,8 +70,10 @@ class ClientIdError(OAuth2Error):
super().__init__()
self.client_id = client_id
def to_event(self) -> Event:
return super().to_event(f"Invalid client identifier: {self.client_id}.")
def to_event(self, **kwargs) -> Event:
return super().to_event(
f"Invalid client identifier: {self.client_id}.", **kwargs
)
class UserAuthError(OAuth2Error):

View File

@ -0,0 +1,58 @@
"""OAuth2 Provider managed objects"""
from authentik.managed.manager import EnsureExists, ObjectManager
from authentik.providers.oauth2.models import ScopeMapping
SCOPE_OPENID_EXPRESSION = """
# This scope is required by the OpenID-spec, and must as such exist in authentik.
# The scope by itself does not grant any information
return {}
"""
SCOPE_EMAIL_EXPRESSION = """
return {
"email": user.email,
"email_verified": True
}
"""
SCOPE_PROFILE_EXPRESSION = """
return {
# Because authentik only saves the user's full name, and has no concept of first and last names,
# the full name is used as given name.
# You can override this behaviour in custom mappings, i.e. `user.name.split(" ")`
"name": user.name,
"given_name": user.name,
"family_name": "",
"preferred_username": user.username,
"nickname": user.username,
}
"""
class ScopeMappingManager(ObjectManager):
"""OAuth2 Provider managed objects"""
def reconcile(self):
return [
EnsureExists(
ScopeMapping,
"goauthentik.io/providers/oauth2/scope-openid",
name="authentik default OAuth Mapping: OpenID 'openid'",
scope_name="openid",
expression=SCOPE_OPENID_EXPRESSION,
),
EnsureExists(
ScopeMapping,
"goauthentik.io/providers/oauth2/scope-email",
name="authentik default OAuth Mapping: OpenID 'email'",
scope_name="email",
description="Email address",
expression=SCOPE_EMAIL_EXPRESSION,
),
EnsureExists(
ScopeMapping,
"goauthentik.io/providers/oauth2/scope-profile",
name="authentik default OAuth Mapping: OpenID 'profile'",
scope_name="profile",
description="General Profile Information",
expression=SCOPE_PROFILE_EXPRESSION,
),
]

View File

@ -10,54 +10,6 @@ import authentik.core.models
import authentik.lib.utils.time
import authentik.providers.oauth2.generators
SCOPE_OPENID_EXPRESSION = """# This is only required for OpenID Applications, but does not grant any information by itself.
return {}
"""
SCOPE_EMAIL_EXPRESSION = """return {
"email": user.email,
"email_verified": True
}
"""
SCOPE_PROFILE_EXPRESSION = """return {
"name": user.name,
"given_name": user.name,
"family_name": "",
"preferred_username": user.username,
"nickname": user.username,
}
"""
def create_default_scopes(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
ScopeMapping = apps.get_model("authentik_providers_oauth2", "ScopeMapping")
ScopeMapping.objects.update_or_create(
scope_name="openid",
defaults={
"name": "Autogenerated OAuth2 Mapping: OpenID 'openid'",
"scope_name": "openid",
"description": "",
"expression": SCOPE_OPENID_EXPRESSION,
},
)
ScopeMapping.objects.update_or_create(
scope_name="email",
defaults={
"name": "Autogenerated OAuth2 Mapping: OpenID 'email'",
"scope_name": "email",
"description": "Email address",
"expression": SCOPE_EMAIL_EXPRESSION,
},
)
ScopeMapping.objects.update_or_create(
scope_name="profile",
defaults={
"name": "Autogenerated OAuth2 Mapping: OpenID 'profile'",
"scope_name": "profile",
"description": "General Profile Information",
"expression": SCOPE_PROFILE_EXPRESSION,
},
)
class Migration(migrations.Migration):
@ -235,7 +187,6 @@ class Migration(migrations.Migration):
},
bases=("authentik_core.propertymapping",),
),
migrations.RunPython(create_default_scopes),
migrations.CreateModel(
name="RefreshToken",
fields=[

View File

@ -0,0 +1,33 @@
# Generated by Django 3.1.6 on 2021-02-03 09:24
from django.apps.registry import Apps
from django.db import migrations
scope_uid_map = {
"openid": "goauthentik.io/providers/oauth2/scope-openid",
"email": "goauthentik.io/providers/oauth2/scope-email",
"profile": "goauthentik.io/providers/oauth2/scope-profile",
"ak_proxy": "goauthentik.io/providers/proxy/scope-proxy",
}
def set_managed_flag(apps: Apps, schema_editor):
ScopeMapping = apps.get_model("authentik_providers_oauth2", "ScopeMapping")
db_alias = schema_editor.connection.alias
for mapping in ScopeMapping.objects.using(db_alias).filter(
name__startswith="Autogenerated "
):
mapping.managed = scope_uid_map[mapping.scope_name]
mapping.save()
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0017_managed"),
("authentik_providers_oauth2", "0010_auto_20201227_1804"),
]
operations = [
migrations.RunPython(set_managed_flag),
]

View File

@ -14,7 +14,6 @@ from django.conf import settings
from django.db import models
from django.forms import ModelForm
from django.http import HttpRequest
from django.shortcuts import reverse
from django.utils import dateformat, timezone
from django.utils.translation import gettext_lazy as _
from jwkest.jwk import Key, RSAKey, SYMKey, import_rsa_key
@ -25,7 +24,6 @@ from authentik.core.models import ExpiringModel, PropertyMapping, Provider, User
from authentik.crypto.models import CertificateKeyPair
from authentik.events.models import Event, EventAction
from authentik.events.utils import get_user
from authentik.lib.utils.template import render_to_string
from authentik.lib.utils.time import timedelta_from_string, timedelta_string_validator
from authentik.providers.oauth2.apps import AuthentikProviderOAuth2Config
from authentik.providers.oauth2.constants import ACR_AUTHENTIK_DEFAULT
@ -118,6 +116,12 @@ class ScopeMapping(PropertyMapping):
return ScopeMappingForm
@property
def serializer(self) -> Type[Serializer]:
from authentik.providers.oauth2.api import ScopeMappingSerializer
return ScopeMappingSerializer
def __str__(self):
return f"Scope Mapping {self.name} ({self.scope_name})"
@ -303,41 +307,6 @@ class OAuth2Provider(Provider):
jws = JWS(payload, alg=self.jwt_alg)
return jws.sign_compact(keys)
def html_setup_urls(self, request: HttpRequest) -> Optional[str]:
"""return template and context modal with URLs for authorize, token, openid-config, etc"""
try:
# pylint: disable=no-member
return render_to_string(
"providers/oauth2/setup_url_modal.html",
{
"provider": self,
"issuer": self.get_issuer(request),
"authorize": request.build_absolute_uri(
reverse(
"authentik_providers_oauth2:authorize",
)
),
"token": request.build_absolute_uri(
reverse(
"authentik_providers_oauth2:token",
)
),
"userinfo": request.build_absolute_uri(
reverse(
"authentik_providers_oauth2:userinfo",
)
),
"provider_info": request.build_absolute_uri(
reverse(
"authentik_providers_oauth2:provider-info",
kwargs={"application_slug": self.application.slug},
)
),
},
)
except Provider.application.RelatedObjectDoesNotExist:
return None
class Meta:
verbose_name = _("OAuth2/OpenID Provider")

View File

@ -1,50 +0,0 @@
{% load i18n %}
<ak-modal-button>
<button slot="trigger" class="pf-c-button pf-m-tertiary">
{% trans 'View Setup URLs' %}
</button>
<div slot="modal">
<div class="pf-c-modal-box__header">
<h1 class="pf-c-title pf-m-2xl" id="modal-title">{% trans 'Setup URLs' %}</h1>
</div>
<div class="pf-c-modal-box__body" id="modal-description">
<form class="pf-c-form">
<div class="pf-c-form__group">
<label class="pf-c-form__label" for="help-text-simple-form-name">
<span class="pf-c-form__label-text">{% trans 'OpenID Configuration URL' %}</span>
</label>
<input class="pf-c-form-control" readonly type="text" value="{{ provider_info }}" />
</div>
<div class="pf-c-form__group">
<label class="pf-c-form__label" for="help-text-simple-form-name">
<span class="pf-c-form__label-text">{% trans 'OpenID Configuration Issuer' %}</span>
</label>
<input class="pf-c-form-control" readonly type="text" value="{{ issuer }}" />
</div>
<hr>
<div class="pf-c-form__group">
<label class="pf-c-form__label" for="help-text-simple-form-name">
<span class="pf-c-form__label-text">{% trans 'Authorize URL' %}</span>
</label>
<input class="pf-c-form-control" readonly type="text" value="{{ authorize }}" />
</div>
<div class="pf-c-form__group">
<label class="pf-c-form__label" for="help-text-simple-form-name">
<span class="pf-c-form__label-text">{% trans 'Token URL' %}</span>
</label>
<input class="pf-c-form-control" readonly type="text" value="{{ token }}" />
</div>
<div class="pf-c-form__group">
<label class="pf-c-form__label" for="help-text-simple-form-name">
<span class="pf-c-form__label-text">{% trans 'Userinfo Endpoint' %}</span>
</label>
<input class="pf-c-form-control" readonly type="text" value="{{ userinfo }}" />
</div>
</form>
</div>
<footer class="pf-c-modal-box__footer pf-m-align-left">
<a class="pf-c-button pf-m-primary">{% trans 'Close' %}</a>
</footer>
</div>
</ak-modal-button>

View File

@ -256,12 +256,12 @@ class OAuthFulfillmentStage(StageView):
).from_http(self.request)
return redirect(self.create_response_uri())
except (ClientIdError, RedirectUriError) as error:
error.to_event().from_http(request)
error.to_event(application=application).from_http(request)
self.executor.stage_invalid()
# pylint: disable=no-member
return bad_request_message(request, error.description, title=error.error)
except AuthorizeError as error:
error.to_event().from_http(request)
error.to_event(application=application).from_http(request)
self.executor.stage_invalid()
return redirect(error.create_uri())
@ -379,7 +379,7 @@ class AuthorizationFlowInitView(PolicyAccessView):
try:
self.params = OAuthAuthorizationParams.from_request(self.request)
except AuthorizeError as error:
error.to_event().from_http(self.request)
error.to_event(redirect_uri=error.redirect_uri).from_http(self.request)
raise RequestValidationError(redirect(error.create_uri()))
except OAuth2Error as error:
error.to_event().from_http(self.request)
@ -396,7 +396,7 @@ class AuthorizationFlowInitView(PolicyAccessView):
self.params.grant_type,
self.params.state,
)
error.to_event().from_http(self.request)
error.to_event(redirect_uri=error.redirect_uri).from_http(self.request)
raise RequestValidationError(redirect(error.create_uri()))
def resolve_provider_application(self):

View File

@ -6,7 +6,7 @@ from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer, Serializer
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.utils import MetaNameSerializer
from authentik.core.api.providers import ProviderSerializer
from authentik.providers.oauth2.views.provider import ProviderInfoView
from authentik.providers.proxy.models import ProxyProvider
@ -34,7 +34,7 @@ class OpenIDConnectConfigurationSerializer(Serializer):
raise NotImplementedError
class ProxyProviderSerializer(MetaNameSerializer, ModelSerializer):
class ProxyProviderSerializer(ProviderSerializer):
"""ProxyProvider Serializer"""
def create(self, validated_data):
@ -50,9 +50,7 @@ class ProxyProviderSerializer(MetaNameSerializer, ModelSerializer):
class Meta:
model = ProxyProvider
fields = [
"pk",
"name",
fields = ProviderSerializer.Meta.fields + [
"internal_host",
"external_host",
"internal_host_ssl_validation",
@ -61,8 +59,6 @@ class ProxyProviderSerializer(MetaNameSerializer, ModelSerializer):
"basic_auth_enabled",
"basic_auth_password_attribute",
"basic_auth_user_attribute",
"verbose_name",
"verbose_name_plural",
]

View File

@ -1,4 +1,6 @@
"""authentik Proxy app"""
from importlib import import_module
from django.apps import AppConfig
@ -8,3 +10,6 @@ class AuthentikProviderProxyConfig(AppConfig):
name = "authentik.providers.proxy"
label = "authentik_providers_proxy"
verbose_name = "authentik Providers.Proxy"
def ready(self) -> None:
import_module("authentik.providers.proxy.managed")

View File

@ -0,0 +1,28 @@
"""OAuth2 Provider managed objects"""
from authentik.managed.manager import EnsureExists, ObjectManager
from authentik.providers.oauth2.models import ScopeMapping
from authentik.providers.proxy.models import SCOPE_AK_PROXY
SCOPE_AK_PROXY_EXPRESSION = """
# This mapping is used by the authentik proxy. It passes extra user attributes,
# which are used for example for the HTTP-Basic Authentication mapping.
return {
"ak_proxy": {
"user_attributes": user.group_attributes()
}
}"""
class ProxyScopeMappingManager(ObjectManager):
"""OAuth2 Provider managed objects"""
def reconcile(self):
return [
EnsureExists(
ScopeMapping,
"goauthentik.io/providers/proxy/scope-proxy",
name="authentik default OAuth Mapping: proxy outpost",
scope_name=SCOPE_AK_PROXY,
expression=SCOPE_AK_PROXY_EXPRESSION,
),
]

View File

@ -1,35 +1,5 @@
# Generated by Django 3.1.4 on 2020-12-14 09:42
from django.apps.registry import Apps
from django.db import migrations
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
SCOPE_AK_PROXY_EXPRESSION = """return {
"ak_proxy": {
"user_attributes": user.group_attributes()
}
}"""
def create_proxy_scope(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
from authentik.providers.proxy.models import SCOPE_AK_PROXY, ProxyProvider
ScopeMapping = apps.get_model("authentik_providers_oauth2", "ScopeMapping")
ScopeMapping.objects.filter(scope_name="pb_proxy").delete()
ScopeMapping.objects.update_or_create(
scope_name=SCOPE_AK_PROXY,
defaults={
"name": "Autogenerated OAuth2 Mapping: authentik Proxy",
"scope_name": SCOPE_AK_PROXY,
"description": "",
"expression": SCOPE_AK_PROXY_EXPRESSION,
},
)
for provider in ProxyProvider.objects.all():
provider.set_oauth_defaults()
provider.save()
class Migration(migrations.Migration):
@ -38,4 +8,4 @@ class Migration(migrations.Migration):
("authentik_providers_proxy", "0009_auto_20201007_1721"),
]
operations = [migrations.RunPython(create_proxy_scope)]
operations = []

View File

@ -6,7 +6,6 @@ from urllib.parse import urljoin
from django.db import models
from django.forms import ModelForm
from django.http import HttpRequest
from django.utils.translation import gettext as _
from rest_framework.serializers import Serializer
@ -119,10 +118,6 @@ class ProxyProvider(OutpostModel, OAuth2Provider):
"""Use external_host as launch URL"""
return self.external_host
def html_setup_urls(self, request: HttpRequest) -> Optional[str]:
"""Overwrite Setup URLs as they are not needed for proxy"""
return None
def set_oauth_defaults(self):
"""Ensure all OAuth2-related settings are correct"""
self.client_type = ClientTypes.CONFIDENTIAL

View File

@ -1,20 +1,26 @@
"""SAMLProvider API Views"""
from rest_framework.serializers import ModelSerializer
from drf_yasg2.utils import swagger_auto_schema
from rest_framework.decorators import action
from rest_framework.fields import ReadOnlyField
from rest_framework.generics import get_object_or_404
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer, Serializer
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.providers import ProviderSerializer
from authentik.core.api.utils import MetaNameSerializer
from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider
from authentik.providers.saml.views import DescriptorDownloadView
class SAMLProviderSerializer(ModelSerializer, MetaNameSerializer):
class SAMLProviderSerializer(ProviderSerializer):
"""SAMLProvider Serializer"""
class Meta:
model = SAMLProvider
fields = [
"pk",
"name",
fields = ProviderSerializer.Meta.fields + [
"acs_url",
"audience",
"issuer",
@ -27,25 +33,52 @@ class SAMLProviderSerializer(ModelSerializer, MetaNameSerializer):
"signature_algorithm",
"signing_kp",
"verification_kp",
"verbose_name",
"verbose_name_plural",
]
class SAMLMetadataSerializer(Serializer):
"""SAML Provider Metadata serializer"""
metadata = ReadOnlyField()
def create(self, request: Request) -> Response:
raise NotImplementedError
def update(self, request: Request) -> Response:
raise NotImplementedError
class SAMLProviderViewSet(ModelViewSet):
"""SAMLProvider Viewset"""
queryset = SAMLProvider.objects.all()
serializer_class = SAMLProviderSerializer
@action(methods=["GET"], detail=True)
@swagger_auto_schema(responses={200: SAMLMetadataSerializer(many=False)})
# pylint: disable=invalid-name
def metadata(self, request: Request, pk: int) -> str:
"""Return metadata as XML string"""
provider = get_object_or_404(SAMLProvider, pk=pk)
metadata = DescriptorDownloadView.get_metadata(request, provider)
return Response({"metadata": metadata})
class SAMLPropertyMappingSerializer(ModelSerializer):
class SAMLPropertyMappingSerializer(ModelSerializer, MetaNameSerializer):
"""SAMLPropertyMapping Serializer"""
class Meta:
model = SAMLPropertyMapping
fields = ["pk", "name", "saml_name", "friendly_name", "expression"]
fields = [
"pk",
"name",
"saml_name",
"friendly_name",
"expression",
"verbose_name",
"verbose_name_plural",
]
class SAMLPropertyMappingViewSet(ModelViewSet):

View File

@ -1,4 +1,5 @@
"""authentik SAML IdP app config"""
from importlib import import_module
from django.apps import AppConfig
@ -10,3 +11,6 @@ class AuthentikProviderSAMLConfig(AppConfig):
label = "authentik_providers_saml"
verbose_name = "authentik Providers.SAML"
mountpoint = "application/saml/"
def ready(self) -> None:
import_module("authentik.providers.saml.managed")

View File

@ -0,0 +1,67 @@
"""SAML Provider managed objects"""
from authentik.managed.manager import EnsureExists, ObjectManager
from authentik.providers.saml.models import SAMLPropertyMapping
GROUP_EXPRESSION = """
for group in user.ak_groups.all():
yield group.name
"""
class SAMLProviderManager(ObjectManager):
"""SAML Provider managed objects"""
def reconcile(self):
return [
EnsureExists(
SAMLPropertyMapping,
"goauthentik.io/providers/saml/upn",
name="authentik default SAML Mapping: UPN",
saml_name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn",
expression="return user.attributes.get('upn', user.email)",
),
EnsureExists(
SAMLPropertyMapping,
"goauthentik.io/providers/saml/name",
name="authentik default SAML Mapping: Name",
saml_name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
expression="return user.name",
),
EnsureExists(
SAMLPropertyMapping,
"goauthentik.io/providers/saml/email",
name="authentik default SAML Mapping: Email",
saml_name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
expression="return user.email",
),
EnsureExists(
SAMLPropertyMapping,
"goauthentik.io/providers/saml/username",
name="authentik default SAML Mapping: Username",
saml_name="http://schemas.goauthentik.io/2021/02/saml/username",
expression="return user.username",
),
EnsureExists(
SAMLPropertyMapping,
"goauthentik.io/providers/saml/uid",
name="authentik default SAML Mapping: User ID",
saml_name="http://schemas.goauthentik.io/2021/02/saml/uid",
expression="return user.pk",
),
EnsureExists(
SAMLPropertyMapping,
"goauthentik.io/providers/saml/groups",
name="authentik default SAML Mapping: Groups",
saml_name="http://schemas.xmlsoap.org/claims/Group",
expression=GROUP_EXPRESSION,
),
EnsureExists(
SAMLPropertyMapping,
"goauthentik.io/providers/saml/ms-windowsaccountname",
name="authentik default SAML Mapping: WindowsAccountname (Username)",
saml_name=(
"http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname"
),
expression="return user.username",
),
]

View File

@ -3,61 +3,10 @@
from django.db import migrations
def create_default_property_mappings(apps, schema_editor):
"""Create default SAML Property Mappings"""
SAMLPropertyMapping = apps.get_model(
"authentik_providers_saml", "SAMLPropertyMapping"
)
db_alias = schema_editor.connection.alias
defaults = [
{
"FriendlyName": "eduPersonPrincipalName",
"Name": "urn:oid:1.3.6.1.4.1.5923.1.1.1.6",
"Expression": "return user.email",
},
{
"FriendlyName": "cn",
"Name": "http://schemas.xmlsoap.org/claims/CommonName",
"Expression": "return user.name",
},
{
"FriendlyName": "mail",
"Name": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
"Expression": "return user.email",
},
{
"FriendlyName": "displayName",
"Name": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname",
"Expression": "return user.username",
},
{
"FriendlyName": "uid",
"Name": "urn:oid:0.9.2342.19200300.100.1.1",
"Expression": "return user.pk",
},
{
"FriendlyName": "member-of",
"Name": "http://schemas.xmlsoap.org/claims/Group",
"Expression": "for group in user.ak_groups.all():\n yield group.name",
},
]
for default in defaults:
SAMLPropertyMapping.objects.using(db_alias).get_or_create(
saml_name=default["Name"],
friendly_name=default["FriendlyName"],
expression=default["Expression"],
defaults={
"name": f"Autogenerated SAML Mapping: {default['FriendlyName']} -> {default['Expression']}"
},
)
class Migration(migrations.Migration):
dependencies = [
("authentik_providers_saml", "0001_initial"),
]
operations = [
migrations.RunPython(create_default_property_mappings),
]
operations = []

View File

@ -0,0 +1,57 @@
# Generated by Django 3.1.6 on 2021-02-02 19:21
from django.db import migrations
saml_name_map = {
"http://schemas.xmlsoap.org/claims/CommonName": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname": "http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname",
"member-of": "http://schemas.xmlsoap.org/claims/Group",
"http://schemas.xmlsoap.org/claims/Group": "http://schemas.xmlsoap.org/claims/Group",
"urn:oid:0.9.2342.19200300.100.1.1": "http://schemas.goauthentik.io/2021/02/saml/uid",
"urn:oid:0.9.2342.19200300.100.1.3": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
"urn:oid:1.3.6.1.4.1.5923.1.1.1.6": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn",
"urn:oid:2.16.840.1.113730.3.1.241": "http://schemas.goauthentik.io/2021/02/saml/username",
"urn:oid:2.5.4.3": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
}
saml_name_uid_map = {
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn": "goauthentik.io/providers/saml/upn",
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name": "goauthentik.io/providers/saml/name",
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress": "goauthentik.io/providers/saml/email",
"http://schemas.goauthentik.io/2021/02/saml/username": "goauthentik.io/providers/saml/username",
"http://schemas.goauthentik.io/2021/02/saml/uid": "goauthentik.io/providers/saml/uid",
"http://schemas.xmlsoap.org/claims/Group": "goauthentik.io/providers/saml/groups",
"http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname": "goauthentik.io/providers/saml/ms-windowsaccountname",
}
def add_managed_update(apps, schema_editor):
"""Create default SAML Property Mappings"""
SAMLPropertyMapping = apps.get_model(
"authentik_providers_saml", "SAMLPropertyMapping"
)
db_alias = schema_editor.connection.alias
for pm in SAMLPropertyMapping.objects.using(db_alias).filter(
name__startswith="Autogenerated "
):
if pm.saml_name not in saml_name_map:
continue
new_name = saml_name_map[pm.saml_name]
if not new_name:
pm.delete()
continue
pm.saml_name = new_name
pm.managed = saml_name_uid_map[new_name]
pm.save()
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0017_managed"),
("authentik_providers_saml", "0011_samlprovider_name_id_mapping"),
]
operations = [
migrations.RunPython(add_managed_update),
]

View File

@ -4,15 +4,12 @@ from urllib.parse import urlparse
from django.db import models
from django.forms import ModelForm
from django.http import HttpRequest
from django.shortcuts import reverse
from django.utils.translation import gettext_lazy as _
from rest_framework.serializers import Serializer
from structlog.stdlib import get_logger
from authentik.core.models import PropertyMapping, Provider
from authentik.crypto.models import CertificateKeyPair
from authentik.lib.utils.template import render_to_string
from authentik.lib.utils.time import timedelta_string_validator
from authentik.sources.saml.processors.constants import (
DSA_SHA1,
@ -182,31 +179,6 @@ class SAMLProvider(Provider):
def __str__(self):
return f"SAML Provider {self.name}"
def link_download_metadata(self):
"""Get link to download XML metadata for admin interface"""
try:
# pylint: disable=no-member
return reverse(
"authentik_providers_saml:metadata",
kwargs={"application_slug": self.application.slug},
)
except Provider.application.RelatedObjectDoesNotExist:
return None
def html_metadata_view(self, request: HttpRequest) -> Optional[str]:
"""return template and context modal to view Metadata without downloading it"""
from authentik.providers.saml.views import DescriptorDownloadView
try:
# pylint: disable=no-member
metadata = DescriptorDownloadView.get_metadata(request, self)
return render_to_string(
"providers/saml/admin_metadata_modal.html",
{"provider": self, "metadata": metadata},
)
except Provider.application.RelatedObjectDoesNotExist:
return None
class Meta:
verbose_name = _("SAML Provider")
@ -225,6 +197,12 @@ class SAMLPropertyMapping(PropertyMapping):
return SAMLPropertyMappingForm
@property
def serializer(self) -> Type[Serializer]:
from authentik.providers.saml.api import SAMLPropertyMappingSerializer
return SAMLPropertyMappingSerializer
def __str__(self):
name = self.friendly_name if self.friendly_name != "" else self.saml_name
return f"{self.name} ({name})"

View File

@ -15,6 +15,7 @@ from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider
from authentik.providers.saml.processors.request_parser import AuthNRequest
from authentik.providers.saml.utils import get_random_id
from authentik.providers.saml.utils.time import get_time_string
from authentik.sources.ldap.auth import LDAP_DISTINGUISHED_NAME
from authentik.sources.saml.exceptions import UnsupportedNameIDFormat
from authentik.sources.saml.processors.constants import (
DIGEST_ALGORITHM_TRANSLATION_MAP,
@ -80,7 +81,8 @@ class AssertionProcessor:
continue
attribute = Element(f"{{{NS_SAML_ASSERTION}}}Attribute")
attribute.attrib["FriendlyName"] = mapping.friendly_name
if mapping.friendly_name and mapping.friendly_name != "":
attribute.attrib["FriendlyName"] = mapping.friendly_name
attribute.attrib["Name"] = mapping.saml_name
if not isinstance(value, (list, GeneratorType)):
@ -172,7 +174,7 @@ class AssertionProcessor:
if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_X509:
# This attribute is statically set by the LDAP source
name_id.text = self.http_request.user.attributes.get(
"distinguishedName", persistent
LDAP_DISTINGUISHED_NAME, persistent
)
return name_id
if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_WINDOWS:

View File

@ -76,6 +76,7 @@ class ServiceProviderMetadata:
provider.property_mappings.set(
SAMLPropertyMapping.objects.filter(name__startswith="Autogenerated")
)
provider.save()
return provider

View File

@ -1,22 +0,0 @@
{% load i18n %}
<ak-modal-button>
<button slot="trigger" class="pf-c-button pf-m-tertiary">
{% trans 'View Metadata' %}
</button>
<div slot="modal">
<div class="pf-c-modal-box__header">
<h1 class="pf-c-title pf-m-2xl" id="modal-title">{% trans 'Metadata' %}</h1>
</div>
<div class="pf-c-modal-box__body" id="modal-description">
<form method="post">
<ak-codemirror mode="xml"><textarea class="pf-c-form-control"
readonly>{{ metadata }}</textarea></ak-codemirror>
</form>
</div>
<footer class="pf-c-modal-box__footer pf-m-align-left">
<a class="pf-c-button pf-m-primary">{% trans 'Close' %}</a>
</footer>
</div>
</ak-modal-button>

View File

@ -8,6 +8,7 @@ from lxml import etree # nosec
from authentik.crypto.models import CertificateKeyPair
from authentik.flows.models import Flow
from authentik.managed.manager import ObjectManager
from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider
from authentik.providers.saml.processors.assertion import AssertionProcessor
from authentik.providers.saml.processors.request_parser import AuthNRequestParser
@ -20,6 +21,7 @@ class TestSchema(TestCase):
"""Test Requests and Responses against schema"""
def setUp(self):
ObjectManager().run()
cert = CertificateKeyPair.objects.first()
self.provider: SAMLProvider = SAMLProvider.objects.create(
authorization_flow=Flow.objects.get(

View File

@ -11,6 +11,7 @@ https://docs.djangoproject.com/en/2.1/ref/settings/
"""
import importlib
import logging
import os
import sys
from json import dumps
@ -26,7 +27,7 @@ from sentry_sdk.integrations.redis import RedisIntegration
from authentik import __version__
from authentik.core.middleware import structlog_add_request_id
from authentik.lib.config import CONFIG
from authentik.lib.logging import add_common_fields, add_process_id
from authentik.lib.logging import add_process_id
from authentik.lib.sentry import before_send
@ -129,6 +130,7 @@ INSTALLED_APPS = [
"django_prometheus",
"channels",
"dbbackup",
"authentik.managed.apps.AuthentikManagedConfig",
]
GUARDIAN_MONKEY_PATCH = False
@ -336,7 +338,7 @@ if not DEBUG and _ERROR_REPORTING:
RedisIntegration(),
],
before_send=before_send,
release="authentik@%s" % __version__,
release=f"authentik@{__version__}",
traces_sample_rate=0.6,
environment=CONFIG.y("error_reporting.environment", "customer"),
send_default_pii=CONFIG.y_bool("error_reporting.send_pii", False),
@ -353,23 +355,26 @@ if not DEBUG and _ERROR_REPORTING:
STATIC_URL = "/static/"
MEDIA_URL = "/media/"
LOG_LEVEL = CONFIG.y("log_level").upper()
structlog.configure_once(
processors=[
structlog.stdlib.add_log_level,
structlog.stdlib.add_logger_name,
structlog.threadlocal.merge_threadlocal_context,
add_process_id,
add_common_fields(CONFIG.y("error_reporting.environment", "customer")),
structlog_add_request_id,
structlog.stdlib.PositionalArgumentsFormatter(),
structlog.processors.TimeStamper(),
structlog.processors.TimeStamper(fmt="iso", utc=False),
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
],
context_class=structlog.threadlocal.wrap_dict(dict),
logger_factory=structlog.stdlib.LoggerFactory(),
wrapper_class=structlog.stdlib.BoundLogger,
wrapper_class=structlog.make_filtering_bound_logger(
getattr(logging, LOG_LEVEL, logging.WARNING)
),
cache_logger_on_first_use=True,
)
@ -410,8 +415,6 @@ LOGGING = {
TEST = False
TEST_RUNNER = "authentik.root.test_runner.PytestTestRunner"
LOG_LEVEL = CONFIG.y("log_level").upper()
_LOGGING_HANDLER_MAP = {
"": LOG_LEVEL,

View File

@ -13,7 +13,7 @@ class PytestTestRunner: # pragma: no cover
self.keepdb = keepdb
settings.TEST = True
settings.CELERY_TASK_ALWAYS_EAGER = True
CONFIG.raw.get("authentik")["avatars"] = "none"
CONFIG.y_set("authentik.avatars", "none")
def run_tests(self, test_labels):
"""Run pytest and return the exitcode.

View File

@ -50,7 +50,7 @@ for _authentik_app in get_apps():
LOGGER.debug(
"Mounted URLs",
app_name=_authentik_app.name,
mountpoint=mountpoint,
app_mountpoint=mountpoint,
namespace=namespace,
)

View File

@ -22,23 +22,31 @@ class LDAPSourceSerializer(ModelSerializer, MetaNameSerializer):
"additional_group_dn",
"user_object_filter",
"group_object_filter",
"user_group_membership_field",
"group_membership_field",
"object_uniqueness_field",
"sync_users",
"sync_users_password",
"sync_groups",
"sync_parent_group",
"property_mappings",
"property_mappings_group",
]
extra_kwargs = {"bind_password": {"write_only": True}}
class LDAPPropertyMappingSerializer(ModelSerializer):
class LDAPPropertyMappingSerializer(ModelSerializer, MetaNameSerializer):
"""LDAP PropertyMapping Serializer"""
class Meta:
model = LDAPPropertyMapping
fields = ["pk", "name", "expression", "object_field"]
fields = [
"pk",
"name",
"expression",
"object_field",
"verbose_name",
"verbose_name_plural",
]
class LDAPSourceViewSet(ModelViewSet):

View File

@ -13,3 +13,4 @@ class AuthentikSourceLDAPConfig(AppConfig):
def ready(self):
import_module("authentik.sources.ldap.signals")
import_module("authentik.sources.ldap.managed")

View File

@ -10,6 +10,7 @@ from authentik.core.models import User
from authentik.sources.ldap.models import LDAPSource
LOGGER = get_logger()
LDAP_DISTINGUISHED_NAME = "distinguishedName"
class LDAPBackend(ModelBackend):
@ -35,7 +36,7 @@ class LDAPBackend(ModelBackend):
if not users.exists():
return None
user: User = users.first()
if "distinguishedName" not in user.attributes:
if LDAP_DISTINGUISHED_NAME not in user.attributes:
LOGGER.debug(
"User doesn't have DN set, assuming not LDAP imported.", user=user
)
@ -63,7 +64,7 @@ class LDAPBackend(ModelBackend):
try:
temp_connection = ldap3.Connection(
source.connection.server,
user=user.attributes.get("distinguishedName"),
user=user.attributes.get(LDAP_DISTINGUISHED_NAME),
password=password,
raise_exceptions=True,
)

View File

@ -14,6 +14,9 @@ class LDAPSourceForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["property_mappings"].queryset = LDAPPropertyMapping.objects.all()
self.fields[
"property_mappings_group"
].queryset = LDAPPropertyMapping.objects.all()
class Meta:
@ -33,14 +36,16 @@ class LDAPSourceForm(forms.ModelForm):
"sync_users_password",
"sync_groups",
"property_mappings",
"property_mappings_group",
"additional_user_dn",
"additional_group_dn",
"user_object_filter",
"group_object_filter",
"user_group_membership_field",
"group_membership_field",
"object_uniqueness_field",
"sync_parent_group",
]
labels = {"property_mappings_group": _("Group property mappings")}
widgets = {
"name": forms.TextInput(),
"server_uri": forms.TextInput(),
@ -51,7 +56,7 @@ class LDAPSourceForm(forms.ModelForm):
"additional_group_dn": forms.TextInput(),
"user_object_filter": forms.TextInput(),
"group_object_filter": forms.TextInput(),
"user_group_membership_field": forms.TextInput(),
"group_membership_field": forms.TextInput(),
"object_uniqueness_field": forms.TextInput(),
}

View File

@ -0,0 +1,69 @@
"""LDAP Source managed objects"""
from authentik.managed.manager import EnsureExists, ObjectManager
from authentik.sources.ldap.models import LDAPPropertyMapping
class LDAPProviderManager(ObjectManager):
"""LDAP Source managed objects"""
def reconcile(self):
return [
EnsureExists(
LDAPPropertyMapping,
"goauthentik.io/sources/ldap/default-name",
name="authentik default LDAP Mapping: Name",
object_field="name",
expression="return ldap.get('name')",
),
EnsureExists(
LDAPPropertyMapping,
"goauthentik.io/sources/ldap/default-mail",
name="authentik default LDAP Mapping: mail",
object_field="email",
expression="return ldap.get('mail')",
),
# Active Directory-specific mappings
EnsureExists(
LDAPPropertyMapping,
"goauthentik.io/sources/ldap/ms-samaccountname",
name="authentik default Active Directory Mapping: sAMAccountName",
object_field="username",
expression="return ldap.get('sAMAccountName')",
),
EnsureExists(
LDAPPropertyMapping,
"goauthentik.io/sources/ldap/ms-userprincipalname",
name="authentik default Active Directory Mapping: userPrincipalName",
object_field="attributes.upn",
expression="return ldap.get('userPrincipalName')",
),
EnsureExists(
LDAPPropertyMapping,
"goauthentik.io/sources/ldap/ms-givenName",
name="authentik default Active Directory Mapping: givenName",
object_field="attributes.givenName",
expression="return ldap.get('givenName')",
),
EnsureExists(
LDAPPropertyMapping,
"goauthentik.io/sources/ldap/ms-sn",
name="authentik default Active Directory Mapping: sn",
object_field="attributes.sn",
expression="return ldap.get('sn')",
),
# OpenLDAP specific mappings
EnsureExists(
LDAPPropertyMapping,
"goauthentik.io/sources/ldap/openldap-uid",
name="authentik default OpenLDAP Mapping: uid",
object_field="username",
expression="return ldap.get('uid')",
),
EnsureExists(
LDAPPropertyMapping,
"goauthentik.io/sources/ldap/openldap-cn",
name="authentik default OpenLDAP Mapping: cn",
object_field="name",
expression="return ldap.get('cn')",
),
]

View File

@ -1,37 +1,12 @@
# Generated by Django 3.0.6 on 2020-05-23 19:30
from django.apps.registry import Apps
from django.db import migrations
def create_default_ad_property_mappings(apps: Apps, schema_editor):
LDAPPropertyMapping = apps.get_model(
"authentik_sources_ldap", "LDAPPropertyMapping"
)
mapping = {
"name": "return ldap.get('name')",
"first_name": "return ldap.get('givenName')",
"last_name": "return ldap.get('sn')",
"username": "return ldap.get('sAMAccountName')",
"email": "return ldap.get('mail')",
}
db_alias = schema_editor.connection.alias
for object_field, expression in mapping.items():
LDAPPropertyMapping.objects.using(db_alias).get_or_create(
expression=expression,
object_field=object_field,
defaults={
"name": f"Autogenerated LDAP Mapping: {expression} -> {object_field}"
},
)
class Migration(migrations.Migration):
dependencies = [
("authentik_sources_ldap", "0002_ldapsource_sync_users"),
]
operations = [
migrations.RunPython(create_default_ad_property_mappings),
]
operations = []

View File

@ -1,50 +1,12 @@
# Generated by Django 3.1.1 on 2020-09-15 19:19
from django.apps.registry import Apps
from django.db import migrations
def create_default_property_mappings(apps: Apps, schema_editor):
LDAPPropertyMapping = apps.get_model(
"authentik_sources_ldap", "LDAPPropertyMapping"
)
db_alias = schema_editor.connection.alias
mapping = {
"name": "name",
"first_name": "givenName",
"last_name": "sn",
"email": "mail",
}
for object_field, ldap_field in mapping.items():
expression = f"return ldap.get('{ldap_field}')"
LDAPPropertyMapping.objects.using(db_alias).get_or_create(
expression=expression,
object_field=object_field,
defaults={
"name": f"Autogenerated LDAP Mapping: {ldap_field} -> {object_field}"
},
)
ad_mapping = {
"username": "sAMAccountName",
"attributes.upn": "userPrincipalName",
}
for object_field, ldap_field in ad_mapping.items():
expression = f"return ldap.get('{ldap_field}')"
LDAPPropertyMapping.objects.using(db_alias).get_or_create(
expression=expression,
object_field=object_field,
defaults={
"name": f"Autogenerated Active Directory Mapping: {ldap_field} -> {object_field}"
},
)
class Migration(migrations.Migration):
dependencies = [
("authentik_sources_ldap", "0005_auto_20200913_1947"),
]
operations = [
migrations.RunPython(create_default_property_mappings),
]
operations = []

View File

@ -0,0 +1,34 @@
# Generated by Django 3.1.6 on 2021-02-02 20:51
from django.apps.registry import Apps
from django.db import migrations
def set_managed_flag(apps: Apps, schema_editor):
LDAPPropertyMapping = apps.get_model(
"authentik_sources_ldap", "LDAPPropertyMapping"
)
db_alias = schema_editor.connection.alias
field_to_uid = {
"name": "goauthentik.io/sources/ldap/default-name",
"email": "goauthentik.io/sources/ldap/default-mail",
"username": "goauthentik.io/sources/ldap/ms-samaccountname",
"attributes.upn": "goauthentik.io/sources/ldap/ms-userprincipalname",
"first_name": "goauthentik.io/sources/ldap/ms-givenName",
"last_name": "goauthentik.io/sources/ldap/ms-sn",
}
for mapping in LDAPPropertyMapping.objects.using(db_alias).filter(
name__startswith="Autogenerated "
):
mapping.managed = field_to_uid.get(mapping.object_field)
mapping.save()
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0017_managed"),
("authentik_sources_ldap", "0007_ldapsource_sync_users_password"),
]
operations = [migrations.RunPython(set_managed_flag)]

View File

@ -0,0 +1,24 @@
# Generated by Django 3.1.6 on 2021-02-04 18:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_sources_ldap", "0008_managed"),
]
operations = [
migrations.RemoveField(
model_name="ldapsource",
name="user_group_membership_field",
),
migrations.AddField(
model_name="ldapsource",
name="group_membership_field",
field=models.TextField(
default="member", help_text="Field which contains members of a group."
),
),
]

View File

@ -0,0 +1,29 @@
# Generated by Django 3.1.6 on 2021-02-05 10:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_sources_ldap", "0009_auto_20210204_1834"),
]
operations = [
migrations.AlterField(
model_name="ldapsource",
name="group_object_filter",
field=models.TextField(
default="(objectClass=group)",
help_text="Consider Objects matching this filter to be Groups.",
),
),
migrations.AlterField(
model_name="ldapsource",
name="user_object_filter",
field=models.TextField(
default="(objectClass=person)",
help_text="Consider Objects matching this filter to be Users.",
),
),
]

View File

@ -0,0 +1,43 @@
# Generated by Django 3.1.6 on 2021-02-06 14:01
from django.apps.registry import Apps
from django.db import migrations, models
def set_default_group_mappings(apps: Apps, schema_editor):
LDAPPropertyMapping = apps.get_model(
"authentik_sources_ldap", "LDAPPropertyMapping"
)
LDAPSource = apps.get_model("authentik_sources_ldap", "LDAPSource")
db_alias = schema_editor.connection.alias
for source in LDAPSource.objects.using(db_alias).all():
if source.property_mappings_group.exists():
continue
source.property_mappings_group.set(
LDAPPropertyMapping.objects.using(db_alias).filter(
managed="goauthentik.io/sources/ldap/default-name"
)
)
source.save()
class Migration(migrations.Migration):
dependencies = [
("authentik_sources_ldap", "0010_auto_20210205_1027"),
]
operations = [
migrations.AddField(
model_name="ldapsource",
name="property_mappings_group",
field=models.ManyToManyField(
blank=True,
default=None,
help_text="Property mappings used for group creation/updating.",
to="authentik_core.PropertyMapping",
),
),
migrations.RunPython(set_default_group_mappings),
]

View File

@ -38,20 +38,27 @@ class LDAPSource(Source):
)
user_object_filter = models.TextField(
default="(objectCategory=Person)",
default="(objectClass=person)",
help_text=_("Consider Objects matching this filter to be Users."),
)
user_group_membership_field = models.TextField(
default="memberOf", help_text=_("Field which contains Groups of user.")
group_membership_field = models.TextField(
default="member", help_text=_("Field which contains members of a group.")
)
group_object_filter = models.TextField(
default="(objectCategory=Group)",
default="(objectClass=group)",
help_text=_("Consider Objects matching this filter to be Groups."),
)
object_uniqueness_field = models.TextField(
default="objectSid", help_text=_("Field which contains a unique Identifier.")
)
property_mappings_group = models.ManyToManyField(
PropertyMapping,
default=None,
blank=True,
help_text=_("Property mappings used for group creation/updating."),
)
sync_users = models.BooleanField(default=True)
sync_users_password = models.BooleanField(
default=True,
@ -130,6 +137,12 @@ class LDAPPropertyMapping(PropertyMapping):
return LDAPPropertyMappingForm
@property
def serializer(self) -> Type[Serializer]:
from authentik.sources.ldap.api import LDAPPropertyMappingSerializer
return LDAPPropertyMappingSerializer
def __str__(self):
return self.name

View File

@ -8,6 +8,7 @@ import ldap3.core.exceptions
from structlog.stdlib import get_logger
from authentik.core.models import User
from authentik.sources.ldap.auth import LDAP_DISTINGUISHED_NAME
from authentik.sources.ldap.models import LDAPSource
LOGGER = get_logger()
@ -74,9 +75,9 @@ class LDAPPasswordChanger:
def change_password(self, user: User, password: str):
"""Change user's password"""
user_dn = user.attributes.get("distinguishedName", None)
user_dn = user.attributes.get(LDAP_DISTINGUISHED_NAME, None)
if not user_dn:
raise AttributeError("User has no distinguishedName set.")
raise AttributeError(f"User has no {LDAP_DISTINGUISHED_NAME} set.")
self._source.connection.extend.microsoft.modify_password(user_dn, password)
def _ad_check_password_existing(self, password: str, user_dn: str) -> bool:
@ -117,9 +118,9 @@ class LDAPPasswordChanger:
"""
if user:
# Check if password contains sAMAccountName or displayNames
if "distinguishedName" in user.attributes:
if LDAP_DISTINGUISHED_NAME in user.attributes:
existing_user_check = self._ad_check_password_existing(
password, user.attributes.get("distinguishedName")
password, user.attributes.get(LDAP_DISTINGUISHED_NAME)
)
if not existing_user_check:
LOGGER.debug("Password failed name check", user=user)

View File

@ -1,191 +0,0 @@
"""Sync LDAP Users and groups into authentik"""
from typing import Any, Dict
import ldap3
import ldap3.core.exceptions
from django.db.utils import IntegrityError
from structlog.stdlib import get_logger
from authentik.core.exceptions import PropertyMappingExpressionException
from authentik.core.models import Group, User
from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource
LOGGER = get_logger()
class LDAPSynchronizer:
"""Sync LDAP Users and groups into authentik"""
_source: LDAPSource
def __init__(self, source: LDAPSource):
self._source = source
@property
def base_dn_users(self) -> str:
"""Shortcut to get full base_dn for user lookups"""
if self._source.additional_user_dn:
return f"{self._source.additional_user_dn},{self._source.base_dn}"
return self._source.base_dn
@property
def base_dn_groups(self) -> str:
"""Shortcut to get full base_dn for group lookups"""
if self._source.additional_group_dn:
return f"{self._source.additional_group_dn},{self._source.base_dn}"
return self._source.base_dn
def sync_groups(self) -> int:
"""Iterate over all LDAP Groups and create authentik_core.Group instances"""
if not self._source.sync_groups:
LOGGER.warning("Group syncing is disabled for this Source")
return -1
groups = self._source.connection.extend.standard.paged_search(
search_base=self.base_dn_groups,
search_filter=self._source.group_object_filter,
search_scope=ldap3.SUBTREE,
attributes=ldap3.ALL_ATTRIBUTES,
)
group_count = 0
for group in groups:
attributes = group.get("attributes", {})
if self._source.object_uniqueness_field not in attributes:
LOGGER.warning(
"Cannot find uniqueness Field in attributes", user=attributes.keys()
)
continue
uniq = attributes[self._source.object_uniqueness_field]
_, created = Group.objects.update_or_create(
attributes__ldap_uniq=uniq,
parent=self._source.sync_parent_group,
defaults={
"name": attributes.get("name", ""),
"attributes": {
"ldap_uniq": uniq,
"distinguishedName": attributes.get("distinguishedName"),
},
},
)
LOGGER.debug(
"Synced group", group=attributes.get("name", ""), created=created
)
group_count += 1
return group_count
def sync_users(self) -> int:
"""Iterate over all LDAP Users and create authentik_core.User instances"""
if not self._source.sync_users:
LOGGER.warning("User syncing is disabled for this Source")
return -1
users = self._source.connection.extend.standard.paged_search(
search_base=self.base_dn_users,
search_filter=self._source.user_object_filter,
search_scope=ldap3.SUBTREE,
attributes=ldap3.ALL_ATTRIBUTES,
)
user_count = 0
for user in users:
attributes = user.get("attributes", {})
if self._source.object_uniqueness_field not in attributes:
LOGGER.warning(
"Cannot find uniqueness Field in attributes", user=user.keys()
)
continue
uniq = attributes[self._source.object_uniqueness_field]
try:
defaults = self._build_object_properties(attributes)
user, created = User.objects.update_or_create(
attributes__ldap_uniq=uniq,
defaults=defaults,
)
except IntegrityError as exc:
LOGGER.warning("Failed to create user", exc=exc)
LOGGER.warning(
(
"To merge new User with existing user, set the User's "
f"Attribute 'ldap_uniq' to '{uniq}'"
)
)
else:
if created:
user.set_unusable_password()
user.save()
LOGGER.debug(
"Synced User", user=attributes.get("name", ""), created=created
)
user_count += 1
return user_count
def sync_membership(self):
"""Iterate over all Users and assign Groups using memberOf Field"""
users = self._source.connection.extend.standard.paged_search(
search_base=self.base_dn_users,
search_filter=self._source.user_object_filter,
search_scope=ldap3.SUBTREE,
attributes=[
self._source.user_group_membership_field,
self._source.object_uniqueness_field,
],
)
group_cache: Dict[str, Group] = {}
for user in users:
member_of = user.get("attributes", {}).get(
self._source.user_group_membership_field, []
)
uniq = user.get("attributes", {}).get(
self._source.object_uniqueness_field, []
)
for group_dn in member_of:
# Check if group_dn is within our base_dn_groups, and skip if not
if not group_dn.endswith(self.base_dn_groups):
continue
# Check if we fetched the group already, and if not cache it for later
if group_dn not in group_cache:
groups = Group.objects.filter(
attributes__distinguishedName=group_dn
)
if not groups.exists():
LOGGER.warning(
"Group does not exist in our DB yet, run sync_groups first.",
group=group_dn,
)
return
group_cache[group_dn] = groups.first()
group = group_cache[group_dn]
users = User.objects.filter(attributes__ldap_uniq=uniq)
group.users.add(*list(users))
# Now that all users are added, lets write everything
for _, group in group_cache.items():
group.save()
LOGGER.debug("Successfully updated group membership")
def _build_object_properties(
self, attributes: Dict[str, Any]
) -> Dict[str, Dict[Any, Any]]:
properties = {"attributes": {}}
for mapping in self._source.property_mappings.all().select_subclasses():
if not isinstance(mapping, LDAPPropertyMapping):
continue
mapping: LDAPPropertyMapping
try:
value = mapping.evaluate(user=None, request=None, ldap=attributes)
if value is None:
continue
object_field = mapping.object_field
if object_field.startswith("attributes."):
properties["attributes"][
object_field.replace("attributes.", "")
] = value
else:
properties[object_field] = value
except PropertyMappingExpressionException as exc:
LOGGER.warning("Mapping failed to evaluate", exc=exc, mapping=mapping)
continue
if self._source.object_uniqueness_field in attributes:
properties["attributes"]["ldap_uniq"] = attributes.get(
self._source.object_uniqueness_field
)
properties["attributes"]["distinguishedName"] = attributes.get(
"distinguishedName"
)
return properties

View File

View File

@ -0,0 +1,95 @@
"""Sync LDAP Users and groups into authentik"""
from typing import Any
from django.db.models.query import QuerySet
from structlog.stdlib import BoundLogger, get_logger
from authentik.core.exceptions import PropertyMappingExpressionException
from authentik.sources.ldap.auth import LDAP_DISTINGUISHED_NAME
from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource
LDAP_UNIQUENESS = "ldap_uniq"
class BaseLDAPSynchronizer:
"""Sync LDAP Users and groups into authentik"""
_source: LDAPSource
_logger: BoundLogger
def __init__(self, source: LDAPSource):
self._source = source
self._logger = get_logger().bind(source=source)
@property
def base_dn_users(self) -> str:
"""Shortcut to get full base_dn for user lookups"""
if self._source.additional_user_dn:
return f"{self._source.additional_user_dn},{self._source.base_dn}"
return self._source.base_dn
@property
def base_dn_groups(self) -> str:
"""Shortcut to get full base_dn for group lookups"""
if self._source.additional_group_dn:
return f"{self._source.additional_group_dn},{self._source.base_dn}"
return self._source.base_dn
def sync(self) -> int:
"""Sync function, implemented in subclass"""
raise NotImplementedError()
def _flatten(self, value: Any) -> Any:
"""Flatten `value` if its a list"""
if isinstance(value, list):
if len(value) < 1:
return None
return value[0]
return value
def build_user_properties(self, user_dn: str, **kwargs) -> dict[str, Any]:
"""Build attributes for User object based on property mappings."""
return self._build_object_properties(
user_dn, self._source.property_mappings, **kwargs
)
def build_group_properties(self, group_dn: str, **kwargs) -> dict[str, Any]:
"""Build attributes for Group object based on property mappings."""
return self._build_object_properties(
group_dn, self._source.property_mappings_group, **kwargs
)
def _build_object_properties(
self, object_dn: str, mappings: QuerySet, **kwargs
) -> dict[str, dict[Any, Any]]:
properties = {"attributes": {}}
for mapping in mappings.all().select_subclasses():
if not isinstance(mapping, LDAPPropertyMapping):
continue
mapping: LDAPPropertyMapping
try:
value = mapping.evaluate(
user=None, request=None, ldap=kwargs, dn=object_dn
)
if value is None:
continue
object_field = mapping.object_field
if object_field.startswith("attributes."):
# Because returning a list might desired, we can't
# rely on self._flatten here. Instead, just save the result as-is
properties["attributes"][
object_field.replace("attributes.", "")
] = value
else:
properties[object_field] = self._flatten(value)
except PropertyMappingExpressionException as exc:
self._logger.warning(
"Mapping failed to evaluate", exc=exc, mapping=mapping
)
continue
if self._source.object_uniqueness_field in kwargs:
properties["attributes"][LDAP_UNIQUENESS] = self._flatten(
kwargs.get(self._source.object_uniqueness_field)
)
properties["attributes"][LDAP_DISTINGUISHED_NAME] = object_dn
return properties

View File

@ -0,0 +1,61 @@
"""Sync LDAP Users and groups into authentik"""
import ldap3
import ldap3.core.exceptions
from django.db.utils import IntegrityError
from authentik.core.models import Group
from authentik.sources.ldap.sync.base import LDAP_UNIQUENESS, BaseLDAPSynchronizer
class GroupLDAPSynchronizer(BaseLDAPSynchronizer):
"""Sync LDAP Users and groups into authentik"""
def sync(self) -> int:
"""Iterate over all LDAP Groups and create authentik_core.Group instances"""
if not self._source.sync_groups:
self._logger.warning("Group syncing is disabled for this Source")
return -1
groups = self._source.connection.extend.standard.paged_search(
search_base=self.base_dn_groups,
search_filter=self._source.group_object_filter,
search_scope=ldap3.SUBTREE,
attributes=[ldap3.ALL_ATTRIBUTES, ldap3.ALL_OPERATIONAL_ATTRIBUTES],
)
group_count = 0
for group in groups:
attributes = group.get("attributes", {})
group_dn = self._flatten(
self._flatten(group.get("entryDN", group.get("dn")))
)
if self._source.object_uniqueness_field not in attributes:
self._logger.warning(
"Cannot find uniqueness Field in attributes",
attributes=attributes.keys(),
dn=group_dn,
)
continue
uniq = self._flatten(attributes[self._source.object_uniqueness_field])
try:
defaults = self.build_group_properties(group_dn, **attributes)
self._logger.debug("Creating group with attributes", **defaults)
if "name" not in defaults:
raise IntegrityError("Name was not set by propertymappings")
ak_group, created = Group.objects.update_or_create(
**{
f"attributes__{LDAP_UNIQUENESS}": uniq,
"parent": self._source.sync_parent_group,
"defaults": defaults,
}
)
except IntegrityError as exc:
self._logger.warning("Failed to create group", exc=exc)
self._logger.warning(
(
"To merge new group with existing group, set the group's "
f"Attribute '{LDAP_UNIQUENESS}' to '{uniq}'"
)
)
else:
self._logger.debug("Synced group", group=ak_group.name, created=created)
group_count += 1
return group_count

View File

@ -0,0 +1,86 @@
"""Sync LDAP Users and groups into authentik"""
from typing import Any, Optional
import ldap3
import ldap3.core.exceptions
from django.db.models import Q
from authentik.core.models import Group, User
from authentik.sources.ldap.auth import LDAP_DISTINGUISHED_NAME
from authentik.sources.ldap.models import LDAPSource
from authentik.sources.ldap.sync.base import LDAP_UNIQUENESS, BaseLDAPSynchronizer
class MembershipLDAPSynchronizer(BaseLDAPSynchronizer):
"""Sync LDAP Users and groups into authentik"""
group_cache: dict[str, Group]
def __init__(self, source: LDAPSource):
super().__init__(source)
self.group_cache: dict[str, Group] = {}
def sync(self) -> int:
"""Iterate over all Users and assign Groups using memberOf Field"""
groups = self._source.connection.extend.standard.paged_search(
search_base=self.base_dn_groups,
search_filter=self._source.group_object_filter,
search_scope=ldap3.SUBTREE,
attributes=[
self._source.group_membership_field,
self._source.object_uniqueness_field,
LDAP_DISTINGUISHED_NAME,
],
)
membership_count = 0
for group in groups:
members = group.get("attributes", {}).get(
self._source.group_membership_field, []
)
ak_group = self.get_group(group)
if not ak_group:
continue
users = User.objects.filter(
Q(**{f"attributes__{LDAP_DISTINGUISHED_NAME}__in": members})
| Q(
**{
f"attributes__{LDAP_DISTINGUISHED_NAME}__isnull": True,
"ak_groups__in": [ak_group],
}
)
)
membership_count += 1
membership_count += users.count()
ak_group.users.set(users)
ak_group.save()
self._logger.debug("Successfully updated group membership")
return membership_count
def get_group(self, group_dict: dict[str, Any]) -> Optional[Group]:
"""Check if we fetched the group already, and if not cache it for later"""
group_dn = group_dict.get("attributes", {}).get(LDAP_DISTINGUISHED_NAME, [])
group_uniq = group_dict.get("attributes", {}).get(
self._source.object_uniqueness_field, []
)
# group_uniq might be a single string or an array with (hopefully) a single string
if isinstance(group_uniq, list):
if len(group_uniq) < 1:
self._logger.warning(
"Group does not have a uniqueness attribute.",
group=group_dn,
)
return None
group_uniq = group_uniq[0]
if group_uniq not in self.group_cache:
groups = Group.objects.filter(
**{f"attributes__{LDAP_UNIQUENESS}": group_uniq}
)
if not groups.exists():
self._logger.warning(
"Group does not exist in our DB yet, run sync_groups first.",
group=group_dn,
)
return None
self.group_cache[group_uniq] = groups.first()
return self.group_cache[group_uniq]

View File

@ -0,0 +1,63 @@
"""Sync LDAP Users into authentik"""
import ldap3
import ldap3.core.exceptions
from django.db.utils import IntegrityError
from authentik.core.models import User
from authentik.sources.ldap.sync.base import LDAP_UNIQUENESS, BaseLDAPSynchronizer
class UserLDAPSynchronizer(BaseLDAPSynchronizer):
"""Sync LDAP Users into authentik"""
def sync(self) -> int:
"""Iterate over all LDAP Users and create authentik_core.User instances"""
if not self._source.sync_users:
self._logger.warning("User syncing is disabled for this Source")
return -1
users = self._source.connection.extend.standard.paged_search(
search_base=self.base_dn_users,
search_filter=self._source.user_object_filter,
search_scope=ldap3.SUBTREE,
attributes=[ldap3.ALL_ATTRIBUTES, ldap3.ALL_OPERATIONAL_ATTRIBUTES],
)
user_count = 0
for user in users:
attributes = user.get("attributes", {})
user_dn = self._flatten(user.get("entryDN", user.get("dn")))
if self._source.object_uniqueness_field not in attributes:
self._logger.warning(
"Cannot find uniqueness Field in attributes",
attributes=attributes.keys(),
dn=user_dn,
)
continue
uniq = self._flatten(attributes[self._source.object_uniqueness_field])
try:
defaults = self.build_user_properties(user_dn, **attributes)
self._logger.debug("Creating user with attributes", **defaults)
if "username" not in defaults:
raise IntegrityError("Username was not set by propertymappings")
ak_user, created = User.objects.update_or_create(
**{
f"attributes__{LDAP_UNIQUENESS}": uniq,
"defaults": defaults,
}
)
except IntegrityError as exc:
self._logger.warning("Failed to create user", exc=exc)
self._logger.warning(
(
"To merge new user with existing user, set the user's "
f"Attribute '{LDAP_UNIQUENESS}' to '{uniq}'"
)
)
else:
if created:
ak_user.set_unusable_password()
ak_user.save()
self._logger.debug(
"Synced User", user=ak_user.username, created=created
)
user_count += 1
return user_count

View File

@ -8,7 +8,9 @@ from ldap3.core.exceptions import LDAPException
from authentik.events.monitored_tasks import MonitoredTask, TaskResult, TaskResultStatus
from authentik.root.celery import CELERY_APP
from authentik.sources.ldap.models import LDAPSource
from authentik.sources.ldap.sync import LDAPSynchronizer
from authentik.sources.ldap.sync.groups import GroupLDAPSynchronizer
from authentik.sources.ldap.sync.membership import MembershipLDAPSynchronizer
from authentik.sources.ldap.sync.users import UserLDAPSynchronizer
@CELERY_APP.task()
@ -29,16 +31,21 @@ def ldap_sync(self: MonitoredTask, source_pk: int):
return
self.set_uid(slugify(source.name))
try:
syncer = LDAPSynchronizer(source)
user_count = syncer.sync_users()
group_count = syncer.sync_groups()
syncer.sync_membership()
messages = []
for sync_class in [
UserLDAPSynchronizer,
GroupLDAPSynchronizer,
MembershipLDAPSynchronizer,
]:
sync_inst = sync_class(source)
count = sync_inst.sync()
messages.append(f"Synced {count} objects from {sync_class.__name__}")
cache_key = source.state_cache_prefix("last_sync")
cache.set(cache_key, time(), timeout=60 * 60)
self.set_status(
TaskResult(
TaskResultStatus.SUCCESSFUL,
[f"Synced {user_count} users", f"Synced {group_count} groups"],
messages,
)
)
except LDAPException as exc:

View File

@ -3,90 +3,94 @@
from ldap3 import MOCK_SYNC, OFFLINE_AD_2012_R2, Connection, Server
def _build_mock_connection(password: str) -> Connection:
"""Create mock connection"""
def mock_ad_connection(password: str) -> Connection:
"""Create mock AD connection"""
server = Server("my_fake_server", get_info=OFFLINE_AD_2012_R2)
_pass = "foo" # noqa # nosec
connection = Connection(
server,
user="cn=my_user,DC=AD2012,DC=LAB",
user="cn=my_user,dc=goauthentik,dc=io",
password=_pass,
client_strategy=MOCK_SYNC,
)
# Entry for password checking
connection.strategy.add_entry(
"cn=user,ou=users,DC=AD2012,DC=LAB",
"cn=user,ou=users,dc=goauthentik,dc=io",
{
"name": "test-user",
"objectSid": "unique-test-group",
"objectCategory": "Person",
"objectClass": "person",
"displayName": "Erin M. Hagens",
"sAMAccountName": "sAMAccountName",
"distinguishedName": "cn=user,ou=users,DC=AD2012,DC=LAB",
"distinguishedName": "cn=user,ou=users,dc=goauthentik,dc=io",
},
)
connection.strategy.add_entry(
"cn=group1,ou=groups,DC=AD2012,DC=LAB",
"cn=group1,ou=groups,dc=goauthentik,dc=io",
{
"name": "test-group",
"objectSid": "unique-test-group",
"objectCategory": "Group",
"distinguishedName": "cn=group1,ou=groups,DC=AD2012,DC=LAB",
"objectClass": "group",
"distinguishedName": "cn=group1,ou=groups,dc=goauthentik,dc=io",
"member": ["cn=user0,ou=users,dc=goauthentik,dc=io"],
},
)
# Group without SID
connection.strategy.add_entry(
"cn=group2,ou=groups,DC=AD2012,DC=LAB",
"cn=group2,ou=groups,dc=goauthentik,dc=io",
{
"name": "test-group",
"objectCategory": "Group",
"distinguishedName": "cn=group2,ou=groups,DC=AD2012,DC=LAB",
"objectClass": "group",
"distinguishedName": "cn=group2,ou=groups,dc=goauthentik,dc=io",
},
)
connection.strategy.add_entry(
"cn=user0,ou=users,DC=AD2012,DC=LAB",
"cn=user0,ou=users,dc=goauthentik,dc=io",
{
"userPassword": password,
"sAMAccountName": "user0_sn",
"name": "user0_sn",
"revision": 0,
"objectSid": "user0",
"objectCategory": "Person",
"memberOf": "cn=group1,ou=groups,DC=AD2012,DC=LAB",
"objectClass": "person",
"distinguishedName": "cn=user0,ou=users,dc=goauthentik,dc=io",
},
)
# User without SID
connection.strategy.add_entry(
"cn=user1,ou=users,DC=AD2012,DC=LAB",
"cn=user1,ou=users,dc=goauthentik,dc=io",
{
"userPassword": "test1111",
"sAMAccountName": "user2_sn",
"name": "user1_sn",
"revision": 0,
"objectCategory": "Person",
"objectClass": "person",
"distinguishedName": "cn=user1,ou=users,dc=goauthentik,dc=io",
},
)
# Duplicate users
connection.strategy.add_entry(
"cn=user2,ou=users,DC=AD2012,DC=LAB",
"cn=user2,ou=users,dc=goauthentik,dc=io",
{
"userPassword": "test2222",
"sAMAccountName": "user2_sn",
"name": "user2_sn",
"revision": 0,
"objectSid": "unique-test2222",
"objectCategory": "Person",
"objectClass": "person",
"distinguishedName": "cn=user2,ou=users,dc=goauthentik,dc=io",
},
)
connection.strategy.add_entry(
"cn=user3,ou=users,DC=AD2012,DC=LAB",
"cn=user3,ou=users,dc=goauthentik,dc=io",
{
"userPassword": "test2222",
"sAMAccountName": "user2_sn",
"name": "user2_sn",
"revision": 0,
"objectSid": "unique-test2222",
"objectCategory": "Person",
"objectClass": "person",
"distinguishedName": "cn=user3,ou=users,dc=goauthentik,dc=io",
},
)
connection.bind()

View File

@ -0,0 +1,81 @@
"""ldap testing utils"""
from ldap3 import MOCK_SYNC, OFFLINE_SLAPD_2_4, Connection, Server
def mock_slapd_connection(password: str) -> Connection:
"""Create mock AD connection"""
server = Server("my_fake_server", get_info=OFFLINE_SLAPD_2_4)
_pass = "foo" # noqa # nosec
connection = Connection(
server,
user="cn=my_user,dc=goauthentik,dc=io",
password=_pass,
client_strategy=MOCK_SYNC,
)
# Entry for password checking
connection.strategy.add_entry(
"cn=user,ou=users,dc=goauthentik,dc=io",
{
"name": "test-user",
"uid": "unique-test-group",
"objectClass": "person",
"displayName": "Erin M. Hagens",
},
)
connection.strategy.add_entry(
"cn=group1,ou=groups,dc=goauthentik,dc=io",
{
"cn": "group1",
"uid": "unique-test-group",
"objectClass": "groupOfNames",
"member": ["cn=user0,ou=users,dc=goauthentik,dc=io"],
},
)
# Group without SID
connection.strategy.add_entry(
"cn=group2,ou=groups,dc=goauthentik,dc=io",
{
"cn": "group2",
"objectClass": "groupOfNames",
},
)
connection.strategy.add_entry(
"cn=user0,ou=users,dc=goauthentik,dc=io",
{
"userPassword": password,
"name": "user0_sn",
"uid": "user0_sn",
"objectClass": "person",
},
)
# User without SID
connection.strategy.add_entry(
"cn=user1,ou=users,dc=goauthentik,dc=io",
{
"userPassword": "test1111",
"name": "user1_sn",
"objectClass": "person",
},
)
# Duplicate users
connection.strategy.add_entry(
"cn=user2,ou=users,dc=goauthentik,dc=io",
{
"userPassword": "test2222",
"name": "user2_sn",
"uid": "unique-test2222",
"objectClass": "person",
},
)
connection.strategy.add_entry(
"cn=user3,ou=users,dc=goauthentik,dc=io",
{
"userPassword": "test2222",
"name": "user2_sn",
"uid": "unique-test2222",
"objectClass": "person",
},
)
connection.bind()
return connection

View File

@ -1,47 +1,87 @@
"""LDAP Source tests"""
from unittest.mock import Mock, PropertyMock, patch
from django.db.models import Q
from django.test import TestCase
from authentik.core.models import User
from authentik.managed.manager import ObjectManager
from authentik.providers.oauth2.generators import generate_client_secret
from authentik.sources.ldap.auth import LDAPBackend
from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource
from authentik.sources.ldap.sync import LDAPSynchronizer
from authentik.sources.ldap.tests.utils import _build_mock_connection
from authentik.sources.ldap.sync.users import UserLDAPSynchronizer
from authentik.sources.ldap.tests.mock_ad import mock_ad_connection
from authentik.sources.ldap.tests.mock_slapd import mock_slapd_connection
LDAP_PASSWORD = generate_client_secret()
LDAP_CONNECTION_PATCH = PropertyMock(return_value=_build_mock_connection(LDAP_PASSWORD))
class LDAPSyncTests(TestCase):
"""LDAP Sync tests"""
def setUp(self):
ObjectManager().run()
self.source = LDAPSource.objects.create(
name="ldap",
slug="ldap",
base_dn="DC=AD2012,DC=LAB",
base_dn="dc=goauthentik,dc=io",
additional_user_dn="ou=users",
additional_group_dn="ou=groups",
)
self.source.property_mappings.set(LDAPPropertyMapping.objects.all())
self.source.save()
@patch("authentik.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
def test_auth_synced_user(self):
def test_auth_synced_user_ad(self):
"""Test Cached auth"""
syncer = LDAPSynchronizer(self.source)
syncer.sync_users()
user = User.objects.get(username="user0_sn")
auth_user_by_bind = Mock(return_value=user)
with patch(
"authentik.sources.ldap.auth.LDAPBackend.auth_user_by_bind",
auth_user_by_bind,
):
backend = LDAPBackend()
self.assertEqual(
backend.authenticate(None, username="user0_sn", password=LDAP_PASSWORD),
user,
self.source.property_mappings.set(
LDAPPropertyMapping.objects.filter(
Q(name__startswith="authentik default LDAP Mapping")
| Q(name__startswith="authentik default Active Directory Mapping")
)
)
self.source.save()
connection = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD))
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
user_sync = UserLDAPSynchronizer(self.source)
user_sync.sync()
user = User.objects.get(username="user0_sn")
auth_user_by_bind = Mock(return_value=user)
with patch(
"authentik.sources.ldap.auth.LDAPBackend.auth_user_by_bind",
auth_user_by_bind,
):
backend = LDAPBackend()
self.assertEqual(
backend.authenticate(
None, username="user0_sn", password=LDAP_PASSWORD
),
user,
)
def test_auth_synced_user_openldap(self):
"""Test Cached auth"""
self.source.object_uniqueness_field = "uid"
self.source.property_mappings.set(
LDAPPropertyMapping.objects.filter(
Q(name__startswith="authentik default LDAP Mapping")
| Q(name__startswith="authentik default OpenLDAP Mapping")
)
)
self.source.save()
connection = PropertyMock(return_value=mock_slapd_connection(LDAP_PASSWORD))
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
user_sync = UserLDAPSynchronizer(self.source)
user_sync.sync()
user = User.objects.get(username="user0_sn")
auth_user_by_bind = Mock(return_value=user)
with patch(
"authentik.sources.ldap.auth.LDAPBackend.auth_user_by_bind",
auth_user_by_bind,
):
backend = LDAPBackend()
self.assertEqual(
backend.authenticate(
None, username="user0_sn", password=LDAP_PASSWORD
),
user,
)

View File

@ -7,10 +7,10 @@ from authentik.core.models import User
from authentik.providers.oauth2.generators import generate_client_secret
from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource
from authentik.sources.ldap.password import LDAPPasswordChanger
from authentik.sources.ldap.tests.utils import _build_mock_connection
from authentik.sources.ldap.tests.mock_ad import mock_ad_connection
LDAP_PASSWORD = generate_client_secret()
LDAP_CONNECTION_PATCH = PropertyMock(return_value=_build_mock_connection(LDAP_PASSWORD))
LDAP_CONNECTION_PATCH = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD))
class LDAPPasswordTests(TestCase):
@ -20,7 +20,7 @@ class LDAPPasswordTests(TestCase):
self.source = LDAPSource.objects.create(
name="ldap",
slug="ldap",
base_dn="DC=AD2012,DC=LAB",
base_dn="dc=goauthentik,dc=io",
additional_user_dn="ou=users",
additional_group_dn="ou=groups",
)
@ -41,7 +41,7 @@ class LDAPPasswordTests(TestCase):
pwc = LDAPPasswordChanger(self.source)
user = User.objects.create(
username="test",
attributes={"distinguishedName": "cn=user,ou=users,DC=AD2012,DC=LAB"},
attributes={"distinguishedName": "cn=user,ou=users,dc=goauthentik,dc=io"},
)
self.assertFalse(pwc.ad_password_complexity("test", user)) # 1 category
self.assertFalse(pwc.ad_password_complexity("test1", user)) # 2 categories

View File

@ -1,51 +1,141 @@
"""LDAP Source tests"""
from unittest.mock import PropertyMock, patch
from django.db.models import Q
from django.test import TestCase
from authentik.core.models import Group, User
from authentik.managed.manager import ObjectManager
from authentik.providers.oauth2.generators import generate_client_secret
from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource
from authentik.sources.ldap.sync import LDAPSynchronizer
from authentik.sources.ldap.sync.groups import GroupLDAPSynchronizer
from authentik.sources.ldap.sync.membership import MembershipLDAPSynchronizer
from authentik.sources.ldap.sync.users import UserLDAPSynchronizer
from authentik.sources.ldap.tasks import ldap_sync_all
from authentik.sources.ldap.tests.utils import _build_mock_connection
from authentik.sources.ldap.tests.mock_ad import mock_ad_connection
from authentik.sources.ldap.tests.mock_slapd import mock_slapd_connection
LDAP_PASSWORD = generate_client_secret()
LDAP_CONNECTION_PATCH = PropertyMock(return_value=_build_mock_connection(LDAP_PASSWORD))
class LDAPSyncTests(TestCase):
"""LDAP Sync tests"""
def setUp(self):
ObjectManager().run()
self.source = LDAPSource.objects.create(
name="ldap",
slug="ldap",
base_dn="DC=AD2012,DC=LAB",
base_dn="dc=goauthentik,dc=io",
additional_user_dn="ou=users",
additional_group_dn="ou=groups",
)
self.source.property_mappings.set(LDAPPropertyMapping.objects.all())
self.source.save()
@patch("authentik.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
def test_sync_users(self):
def test_sync_users_ad(self):
"""Test user sync"""
syncer = LDAPSynchronizer(self.source)
syncer.sync_users()
self.assertTrue(User.objects.filter(username="user0_sn").exists())
self.assertFalse(User.objects.filter(username="user1_sn").exists())
self.source.property_mappings.set(
LDAPPropertyMapping.objects.filter(
Q(managed__startswith="goauthentik.io/sources/ldap/default")
| Q(managed__startswith="goauthentik.io/sources/ldap/ms")
)
)
self.source.save()
connection = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD))
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
user_sync = UserLDAPSynchronizer(self.source)
user_sync.sync()
self.assertTrue(User.objects.filter(username="user0_sn").exists())
self.assertFalse(User.objects.filter(username="user1_sn").exists())
@patch("authentik.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
def test_sync_groups(self):
def test_sync_users_openldap(self):
"""Test user sync"""
self.source.object_uniqueness_field = "uid"
self.source.property_mappings.set(
LDAPPropertyMapping.objects.filter(
Q(managed__startswith="goauthentik.io/sources/ldap/default")
| Q(managed__startswith="goauthentik.io/sources/ldap/openldap")
)
)
self.source.save()
connection = PropertyMock(return_value=mock_slapd_connection(LDAP_PASSWORD))
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
user_sync = UserLDAPSynchronizer(self.source)
user_sync.sync()
self.assertTrue(User.objects.filter(username="user0_sn").exists())
self.assertFalse(User.objects.filter(username="user1_sn").exists())
def test_sync_groups_ad(self):
"""Test group sync"""
syncer = LDAPSynchronizer(self.source)
syncer.sync_groups()
syncer.sync_membership()
group = Group.objects.filter(name="test-group")
self.assertTrue(group.exists())
self.source.property_mappings.set(
LDAPPropertyMapping.objects.filter(
Q(managed__startswith="goauthentik.io/sources/ldap/default")
| Q(managed__startswith="goauthentik.io/sources/ldap/ms")
)
)
self.source.property_mappings_group.set(
LDAPPropertyMapping.objects.filter(
managed="goauthentik.io/sources/ldap/default-name"
)
)
self.source.save()
connection = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD))
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
group_sync = GroupLDAPSynchronizer(self.source)
group_sync.sync()
membership_sync = MembershipLDAPSynchronizer(self.source)
membership_sync.sync()
group = Group.objects.filter(name="test-group")
self.assertTrue(group.exists())
@patch("authentik.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
def test_tasks(self):
def test_sync_groups_openldap(self):
"""Test group sync"""
self.source.object_uniqueness_field = "uid"
self.source.group_object_filter = "(objectClass=groupOfNames)"
self.source.property_mappings.set(
LDAPPropertyMapping.objects.filter(
Q(managed__startswith="goauthentik.io/sources/ldap/default")
| Q(managed__startswith="goauthentik.io/sources/ldap/openldap")
)
)
self.source.property_mappings_group.set(
LDAPPropertyMapping.objects.filter(
managed="goauthentik.io/sources/ldap/openldap-cn"
)
)
self.source.save()
connection = PropertyMock(return_value=mock_slapd_connection(LDAP_PASSWORD))
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
group_sync = GroupLDAPSynchronizer(self.source)
group_sync.sync()
membership_sync = MembershipLDAPSynchronizer(self.source)
membership_sync.sync()
group = Group.objects.filter(name="group1")
self.assertTrue(group.exists())
def test_tasks_ad(self):
"""Test Scheduled tasks"""
ldap_sync_all.delay().get()
self.source.property_mappings.set(
LDAPPropertyMapping.objects.filter(
Q(managed__startswith="goauthentik.io/sources/ldap/default")
| Q(managed__startswith="goauthentik.io/sources/ldap/ms")
)
)
self.source.save()
connection = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD))
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
ldap_sync_all.delay().get()
def test_tasks_openldap(self):
"""Test Scheduled tasks"""
self.source.object_uniqueness_field = "uid"
self.source.group_object_filter = "(objectClass=groupOfNames)"
self.source.property_mappings.set(
LDAPPropertyMapping.objects.filter(
Q(managed__startswith="goauthentik.io/sources/ldap/default")
| Q(managed__startswith="goauthentik.io/sources/ldap/openldap")
)
)
self.source.save()
connection = PropertyMock(return_value=mock_slapd_connection(LDAP_PASSWORD))
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
ldap_sync_all.delay().get()

View File

@ -17,4 +17,5 @@ class ConsentStageForm(forms.ModelForm):
fields = ["name", "mode", "consent_expire_in"]
widgets = {
"name": forms.TextInput(),
"consent_expire_in": forms.TextInput(),
}

View File

@ -1,5 +1,7 @@
{% extends "email/base.html" %}
{% load i18n %}
{% block content %}
<tr>
<td class="alert alert-brand">
@ -14,6 +16,29 @@
{{ body }}
</td>
</tr>
{% if key_value %}
<tr>
<td class="content-block aligncenter">
<table class="invoice">
<tr>
<td>{% trans "Additional Information" %}</td>
</tr>
<tr>
<td>
<table class="invoice-items" cellpadding="0" cellspacing="0">
{% for key, value in key_value.items %}
<tr>
<td>{{ key }}</td>
<td class="alignright">{{ value }}</td>
</tr>
{% endfor %}
</table>
</td>
</tr>
</table>
</td>
</tr>
{% endif %}
</table>
</td>
</tr>

View File

@ -5,7 +5,6 @@ from django.contrib.auth import _clean_credentials
from django.contrib.auth.backends import BaseBackend
from django.contrib.auth.signals import user_login_failed
from django.core.exceptions import PermissionDenied
from django.forms.utils import ErrorList
from django.http import HttpRequest, HttpResponse
from django.utils.translation import gettext as _
from django.views.generic import FormView
@ -116,8 +115,7 @@ class PasswordStageView(FormView, StageView):
# No user was found -> invalid credentials
LOGGER.debug("Invalid credentials")
# Manually inject error into form
errors = form._errors.setdefault("password", ErrorList())
errors.append(_("Invalid password"))
form.add_error("password", _("Invalid password"))
return self.form_invalid(form)
# User instance returned from authenticate() has .backend property set
self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = user

Some files were not shown because too many files have changed in this diff Show More