Compare commits

...

173 Commits

Author SHA1 Message Date
c15e4b24a1 release: 2021.12.5 2022-01-06 21:29:12 +01:00
b6f518ffe6 lifecycle: fix tests in container not working
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2022-01-06 21:29:08 +01:00
4e476fd4e9 website/docs: update 2021.12.5 release notes
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2022-01-06 21:15:27 +01:00
03503363e5 core: fix UserSelfSerializer's save() overwriting other user attributes
closes #2070

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2022-01-06 18:23:06 +01:00
22d6621b02 root run backup every 24 hours
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2022-01-06 15:29:11 +01:00
0023df64c8 root: bump python packages
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2022-01-06 14:31:54 +01:00
59a259e43a build(deps): bump @rollup/plugin-node-resolve in /web (#2066) 2022-01-06 08:48:54 +01:00
c6f39f5eb4 build(deps): bump lit from 2.0.2 to 2.1.0 in /web (#2067) 2022-01-06 08:48:27 +01:00
e3c0aad48a build(deps): bump goauthentik.io/api from 0.2021124.8 to 0.2021124.9 (#2068) 2022-01-06 08:48:07 +01:00
91dd33cee6 policies/reputation: trigger save on update
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2022-01-05 22:06:20 +01:00
5a2c367e89 policies/reputation: fix test
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2022-01-05 21:44:15 +01:00
3b05c9cb1a web: Update Web API Client version (#2065)
Signed-off-by: GitHub <noreply@github.com>

Co-authored-by: BeryJu <BeryJu@users.noreply.github.com>
2022-01-05 21:18:19 +01:00
6e53f1689d policies/reputation: rework reputation to use a single entry, include geo_ip data
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2022-01-05 21:02:33 +01:00
e3be0f2550 Merge branch 'next' 2022-01-05 10:00:52 +01:00
294f2243c1 build(deps): bump rollup from 2.62.0 to 2.63.0 in /web (#2064) 2022-01-05 08:47:09 +01:00
7b1373e8d6 core: fix lint error
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2022-01-04 23:17:37 +01:00
e70b486f20 outposts: handle error in certificate cleanup
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2022-01-04 22:53:37 +01:00
b90174f153 root: use django-dbbackup 4
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2022-01-04 22:17:07 +01:00
7d7acd8494 root: add ak wrapper script to be installed with poetry
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2022-01-04 22:17:07 +01:00
4d9d7c5efb Translate /web/src/locales/en.po in tr (#2063)
translation completed for the source file '/web/src/locales/en.po'
on the 'tr' language.

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2022-01-04 22:17:02 +01:00
d614b3608d root: use packaged version of django-dbbackup
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2022-01-04 22:06:12 +01:00
beb2715fa7 root: bump python dependencies
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2022-01-04 22:05:12 +01:00
5769ff45b5 core: add goauthentik.io/user/can-change-name
closes #2054

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2022-01-04 19:03:12 +01:00
9d6f79558f tenants: forbid creation of multiple default tenants
closes #2059

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2022-01-04 19:01:20 +01:00
41d5bff9d3 web/admin: fix delete form for tenants missing columns
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2022-01-04 18:54:56 +01:00
ec84ba9b6d website/docs: prepare 2021.12.5
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2022-01-04 18:35:45 +01:00
042a62f99e build(deps): bump @typescript-eslint/parser from 5.8.1 to 5.9.0 in /web (#2055) 2022-01-04 05:44:30 +01:00
907f02cfee core: compile backend translations (#2057) 2022-01-04 05:43:59 +01:00
53fe412bf9 build(deps): bump @typescript-eslint/eslint-plugin in /web (#2056) 2022-01-04 05:43:27 +01:00
ef9e177fe9 build(deps): bump goauthentik.io/api from 0.2021124.6 to 0.2021124.8 (#2058) 2022-01-04 05:42:52 +01:00
28e675596b web/flows: only add helper username input if using native shadow dom to prevent browser confusion
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2022-01-03 22:30:56 +01:00
9b7f57cc75 web/flows: add workaround for autofocus not working in password stage
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2022-01-03 22:25:28 +01:00
935a8f4d58 core: add tests for non-applicable flows with flow manager
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2022-01-03 22:14:52 +01:00
01fcbb325b website/integrations: add github org checking policy example
closes #2047

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2022-01-03 22:06:24 +01:00
7d3d17acb9 core: add error handling in source flow manager when flow isn't applicable
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2022-01-03 21:57:55 +01:00
e434321f7c website/integrations: remove github url as they are auto-managed
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2022-01-03 21:38:19 +01:00
ebd476be14 sources/oauth: fix sources not allowing blank values
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

#2047
2022-01-03 21:36:14 +01:00
31ba543c62 *: don't use exception keyword with structlog
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2022-01-03 21:33:52 +01:00
a101d48b5a core: passthrough connection and additional data to FlowManager
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

#2047
2022-01-03 21:31:26 +01:00
4c166dcf52 web/elements: re-enable codemirror line numbers (fixed on firefox)
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2022-01-03 21:30:28 +01:00
47b1f025e1 web/admin: move additional scopes
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2022-01-03 21:30:15 +01:00
8f44c792ac sources/oauth: fix github provider not including correct base scopes
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

#2047
2022-01-03 21:04:18 +01:00
e57b6f2347 web/admin: mark additional scopes as non-required
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

#2047
2022-01-03 20:59:20 +01:00
275d0dfd03 web: Update Web API Client version (#2053)
Signed-off-by: GitHub <noreply@github.com>

Co-authored-by: BeryJu <BeryJu@users.noreply.github.com>
2022-01-03 16:46:09 +01:00
f18cbace7a Translate /locale/en/LC_MESSAGES/django.po in de (#2052)
translation completed for the source file '/locale/en/LC_MESSAGES/django.po'
on the 'de' language.

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2022-01-03 16:45:57 +01:00
212220554f sources/oauth: add additional scopes field to get additional data from provider
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

#2047
2022-01-03 16:43:52 +01:00
a596392bc3 web: Update Web API Client version (#2051)
Signed-off-by: GitHub <noreply@github.com>

Co-authored-by: BeryJu <BeryJu@users.noreply.github.com>
2022-01-03 13:35:51 +01:00
3e22740eac core: add API endpoint to directly set user's password
closes #2040

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2022-01-03 13:31:58 +01:00
d18a691f63 core: prevent LDAP password being set for internal hash upgrades
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2022-01-03 13:23:42 +01:00
3cd5e68bc1 web/admin: add missing Okta label
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2022-01-03 12:36:21 +01:00
c741c13132 internal: fix listen attempt on shutdown
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2022-01-03 12:36:11 +01:00
924f6f104a build(deps): bump eslint from 8.5.0 to 8.6.0 in /web (#2048) 2022-01-03 10:34:36 +01:00
454594025b build(deps): bump goauthentik.io/api from 0.2021124.5 to 0.2021124.6 (#2049) 2022-01-03 10:34:19 +01:00
e72097292c web/flows: fix helper form not being removed from identification stage (improve password manager compatibility)
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2022-01-02 20:03:34 +01:00
ab17a12184 web/user: fix auto-detected locale not being re-activated when switching to auto-detect
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2022-01-02 20:01:51 +01:00
776f3f69a5 core: compile backend translations (#2046) 2022-01-02 10:37:18 +01:00
8560c7150a Translate /locale/en/LC_MESSAGES/django.po in tr (#2044) 2022-01-02 00:15:18 +01:00
301386fb4a Translate /web/src/locales/en.po in tr (#2045) 2022-01-02 00:12:51 +01:00
68e8b6990b web: Update Web API Client version (#2043)
Signed-off-by: GitHub <noreply@github.com>

Co-authored-by: BeryJu <BeryJu@users.noreply.github.com>
2022-01-01 20:28:23 +01:00
4f800c4758 web/flows: include user in access denied stage
closes #2039

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2022-01-01 20:25:49 +01:00
90c31c2214 flows: add test helpers to simplify and improve checking of stages, remove force_str
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2022-01-01 20:25:32 +01:00
50e3d317b2 flows: use WithUserInfoChallenge for AccessDeniedChallenge
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

#2039
2022-01-01 19:45:34 +01:00
3eed7bb010 lib: dont send any sentry events when testing
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2022-01-01 18:56:14 +01:00
0ef8edc9f1 web/user: add language selection
closes #2041

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2022-01-01 18:25:03 +01:00
a6373ebb33 web: fix tr locale not being loaded
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-31 16:19:17 +01:00
bf8ce55eea web/admin: fix display when groups/users don't fit on a single row
closes #2030

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-31 12:03:21 +01:00
61b4fcb5f3 build(deps): bump @rollup/plugin-node-resolve in /web (#2032) 2021-12-31 08:54:30 +01:00
81275e3bd1 build(deps): bump @babel/preset-env from 7.16.5 to 7.16.7 in /web (#2033) 2021-12-31 08:54:13 +01:00
7988bf7748 build(deps): bump @babel/plugin-proposal-decorators in /web (#2034) 2021-12-31 08:54:03 +01:00
00d8eec360 build(deps): bump @babel/core from 7.16.5 to 7.16.7 in /web (#2035) 2021-12-31 08:53:08 +01:00
82150c8e84 build(deps): bump @babel/preset-typescript from 7.16.5 to 7.16.7 in /web (#2036) 2021-12-31 08:52:58 +01:00
1dbd749a74 build(deps): bump @babel/plugin-transform-runtime in /web (#2037) 2021-12-31 08:52:44 +01:00
a96479f16c build(deps): bump goauthentik.io/api from 0.2021124.3 to 0.2021124.5 (#2038) 2021-12-31 08:52:27 +01:00
5d5fb1f37e web/elements: fix alignment of chipgroup on modal add
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-30 22:37:53 +01:00
b6f4d6a5eb web/elements: fix spacing between chips in chip-group
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

#2030
2021-12-30 22:34:55 +01:00
8ab5c04c2c web/admin: show flow title in list
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-30 22:10:31 +01:00
386944117e web: Update Web API Client version (#2031)
Signed-off-by: GitHub <noreply@github.com>

Co-authored-by: BeryJu <BeryJu@users.noreply.github.com>
2021-12-30 22:02:52 +01:00
9154b9b85d web/user: rework user source connection UI
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-30 21:59:41 +01:00
fc19372709 flows: fix migration removing flow titles
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-30 21:00:00 +01:00
e5d9c6537c web: add tr to locales
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-30 16:56:28 +01:00
bf5cbac314 web/admin: fix alignment in outpost list when expanding rows
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-30 16:35:32 +01:00
5cca637a3d root: add opencontainer labels to dockerfiles
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-30 16:33:13 +01:00
5bfb8b454b web: fix broken links
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-30 16:27:16 +01:00
4d96437972 web: Update Web API Client version (#2028)
Signed-off-by: GitHub <noreply@github.com>

Co-authored-by: BeryJu <BeryJu@users.noreply.github.com>
2021-12-30 15:19:13 +01:00
d03b0b8152 outposts: include outposts build hash in state
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-30 15:16:34 +01:00
c249b55ff5 *: use py3.10 syntax for unions, remove old Type[] import when possible
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-30 14:59:01 +01:00
1e1876b34c root: bump python dependencies
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-29 13:03:29 +01:00
a27493ad1b build(deps): bump @rollup/plugin-replace from 3.0.0 to 3.0.1 in /web (#2027)
Bumps [@rollup/plugin-replace](https://github.com/rollup/plugins/tree/HEAD/packages/replace) from 3.0.0 to 3.0.1.
- [Release notes](https://github.com/rollup/plugins/releases)
- [Changelog](https://github.com/rollup/plugins/blob/master/packages/replace/CHANGELOG.md)
- [Commits](https://github.com/rollup/plugins/commits/alias-v3.0.1/packages/replace)

---
updated-dependencies:
- dependency-name: "@rollup/plugin-replace"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-12-29 12:27:35 +01:00
95b1ab820e build(deps): bump @typescript-eslint/eslint-plugin in /web (#2026) 2021-12-28 09:21:09 +01:00
5cf9f0002b build(deps): bump @typescript-eslint/parser from 5.8.0 to 5.8.1 in /web (#2025) 2021-12-28 09:15:39 +01:00
fc7a452b0c flows: update default flow titles
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-27 22:04:35 +01:00
25ee0e4b45 root: bump dependencies
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-27 20:45:15 +01:00
46f12e62e8 flows: don't create EventAction.FLOW_EXECUTION
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-27 15:07:33 +01:00
4245dea25a build(deps): bump rollup from 2.61.1 to 2.62.0 in /web (#2020) 2021-12-27 08:46:37 +01:00
908db3df81 build(deps): bump goauthentik.io/api from 0.2021124.2 to 0.2021124.3 (#2021) 2021-12-27 08:46:24 +01:00
ef4f9aa437 Translate /web/src/locales/en.po in tr (#2019)
translation completed updated for the source file '/web/src/locales/en.po'
on the 'tr' language.

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2021-12-26 18:44:41 +01:00
902dd83c67 Translate /web/src/locales/en.po in tr (#2016)
translation completed updated for the source file '/web/src/locales/en.po'
on the 'tr' language.

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2021-12-26 18:39:20 +01:00
1c4b78b5f4 Translate /web/src/locales/en.po in tr (#2005) 2021-12-26 18:37:10 +01:00
d854d819d1 web/flows: fix duplicate loading spinners when using webauthn
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-26 15:14:56 +01:00
f246da6b73 outposts/proxy: fix error checking for type assertion
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-26 14:57:32 +01:00
4a56b5e827 web: fix background for modals on light theme
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-26 14:53:23 +01:00
53b10e64f8 outposts: fix error when client hasn't be initialised
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-26 14:26:48 +01:00
27e4c7027c web: fix potential panic
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-26 14:24:44 +01:00
410d1b97cd outposts/proxy: add support for multiple states, when multiple requests are redirect at once
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-26 14:16:02 +01:00
f93f7e635b web: fix styling for modals, ensure correct classes are used
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-25 20:30:35 +01:00
74eba04735 web: remove page header colour, match user navbar to admin sidebar
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-25 19:46:53 +01:00
01bdaffe36 root: remove kubernetes version constraint
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-25 19:23:31 +01:00
f6b556713a root: fix missing ssh directory from container
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-25 19:18:47 +01:00
abe38bb16a outposts: fix __exit__ being called without params
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-25 17:52:20 +01:00
f2b8d45999 web/admin: include key type in list
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-25 16:54:28 +01:00
3f61dff1cb web: Update Web API Client version (#1996)
Signed-off-by: GitHub <noreply@github.com>

Co-authored-by: BeryJu <BeryJu@users.noreply.github.com>
2021-12-25 16:53:57 +01:00
b19da6d774 crypto: return private key's type (required for some oauth2 providers)
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-25 16:51:28 +01:00
7c55616e29 outposts: fix creation of from_env docker client
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-25 16:48:23 +01:00
952a7f07c1 website/docs: fix typo
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-25 16:38:56 +01:00
6510b97c1e outposts: add remote docker integration via SSH
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-25 16:31:34 +01:00
19b707a0fb ci: fix translation command
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-25 13:42:08 +01:00
320a600349 root: migrate pipenv to poetry (#1995) 2021-12-24 23:25:38 +01:00
10110deae5 web/admin: add Admin in titlebar for admin interface
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-24 20:04:21 +01:00
884c546f32 outposts: clean up flow executor
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-24 19:52:19 +01:00
abec906677 root: bump python packages
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-24 15:13:36 +01:00
22d1dd801c root: also use analytics uuid for sentry
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-24 15:13:27 +01:00
03891cbe09 build(deps): bump chart.js from 3.6.2 to 3.7.0 in /web (#1993) 2021-12-24 09:50:16 +01:00
3c5157dfd4 build(deps): bump fuse.js from 6.5.0 to 6.5.3 in /web (#1992) 2021-12-24 09:49:31 +01:00
d241e8d51d build(deps): bump @types/chart.js from 2.9.34 to 2.9.35 in /web (#1991) 2021-12-24 09:49:14 +01:00
7ba15884ed build(deps): bump goauthentik.io/api from 0.2021123.3 to 0.2021124.2 (#1994) 2021-12-24 09:48:47 +01:00
47356915b1 outposts: fix outpost's sentry not sending release
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-23 19:01:32 +01:00
2520c92b78 website/docs: add additional docs
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-23 18:51:18 +01:00
e7e0e6d213 lib: strip values for timedelta from string
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-23 18:49:35 +01:00
ca0250e19f core: add meta theme-color
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-23 18:49:24 +01:00
cf4c7c1bcb web: fix missing closing tag
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-23 18:35:33 +01:00
670af8789a web: Update Web API Client version (#1990)
Signed-off-by: GitHub <noreply@github.com>

Co-authored-by: BeryJu <BeryJu@users.noreply.github.com>
2021-12-23 18:29:32 +01:00
5c5634830f stages/identification: add field for passwordless flow
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-23 18:27:00 +01:00
b6b0edb7ad website/docs: use compose override for certbot instead separate stack
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-23 18:03:35 +01:00
45440abc80 web: Update Web API Client version (#1989)
Signed-off-by: GitHub <noreply@github.com>

Co-authored-by: BeryJu <BeryJu@users.noreply.github.com>
2021-12-23 11:05:34 +01:00
9c42b75567 release: 2021.12.4 2021-12-23 10:32:48 +01:00
e9a477c1eb root: cleanup bumpversion config
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-23 10:32:46 +01:00
fa60655a5d build(deps): bump github.com/getsentry/sentry-go from 0.11.0 to 0.12.0 (#1987) 2021-12-23 09:54:46 +01:00
5d729b4878 build(deps): bump fuse.js from 6.4.6 to 6.5.0 in /web (#1986) 2021-12-23 09:48:27 +01:00
8692f7233f build(deps): bump goauthentik.io/api from 0.2021123.2 to 0.2021123.3 (#1988) 2021-12-23 09:47:56 +01:00
457e17fec3 website/docs: add small let's encrypt docs
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-23 00:59:06 +01:00
87e99625e6 internal: update tenant certificates on outpost refresh
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-23 00:38:49 +01:00
6f32eeea43 website/docs: prepare 2021.12.4
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-22 23:37:04 +01:00
dfcf8b2d40 root: update python dependencies
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-22 23:36:56 +01:00
846006f2e3 events: create test notification with event with data
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-22 23:32:29 +01:00
f557b2129f *: fix random typos
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-22 23:13:18 +01:00
6dc2003e34 providers/oauth2: fix tests validating JWT incorrectly
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-22 23:00:57 +01:00
0149c89003 providers/oauth2: fix invalid assignments in JWKS view
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-22 22:41:28 +01:00
f458cae954 providers/proxy: add error handing when field is already gone
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-22 22:31:53 +01:00
f01d117ce6 providers/proxy: fix imports in migrations
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-22 22:25:02 +01:00
2bde43e5dc crypto: use older syntax for type union
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-22 22:22:45 +01:00
84cc0b5490 web: Update Web API Client version (#1984)
Signed-off-by: GitHub <noreply@github.com>

Co-authored-by: BeryJu <BeryJu@users.noreply.github.com>
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-22 22:16:44 +01:00
2f3026084e providers/oauth2: remove jwt_alg field and set algorithm based on selected keypair, select HS256 when no keypair is selected
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-22 22:09:49 +01:00
89696edbee website/integrations: cleanup
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-22 21:46:46 +01:00
c1f0833c09 crypto: improve support for non-rsa private keys (discovery)
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-22 21:46:22 +01:00
c77f804b77 web/user: fix user details not rendering when loading to a different user settings tab and then switching
closes #1664

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-22 20:13:52 +01:00
8e83209631 stages/authenticator_validate: fix lint error
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-22 18:14:35 +01:00
2e48e0cc2f stages/authenticator_validate: fix prompt not triggering when using in non-authentication context
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-22 18:03:02 +01:00
e72f0ab160 stages/authenticator_validation: refuse passwordless flow if flow is not for authentication
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-22 18:02:43 +01:00
a3c681cc44 website/docs: cleanup old image names
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-22 17:38:09 +01:00
5b3a9e29fb stages/authenticator_validate: add passwordless login
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-22 17:34:46 +01:00
15803dc67d website/docs: revert traefik to not use header regex
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-22 15:30:01 +01:00
ff37e064c9 build(deps): bump goauthentik.io/api from 0.2021123.1 to 0.2021123.2 (#1983)
Bumps [goauthentik.io/api](https://github.com/goauthentik/client-go) from 0.2021123.1 to 0.2021123.2.
- [Release notes](https://github.com/goauthentik/client-go/releases)
- [Commits](https://github.com/goauthentik/client-go/compare/v0.2021123.1...v0.2021123.2)

---
updated-dependencies:
- dependency-name: goauthentik.io/api
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-12-22 11:58:06 +01:00
ef8e922e2a web: Update Web API Client version (#1982)
Signed-off-by: GitHub <noreply@github.com>

Co-authored-by: BeryJu <BeryJu@users.noreply.github.com>
2021-12-22 11:51:15 +01:00
34b11524f1 tenants: add web certificate field, make authentik's core certificate configurable based on keypair
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-22 11:43:45 +01:00
9e2492be5c web/elements: fix link from notification drawer not working in user interface
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-22 10:49:10 +01:00
b3ba083ff0 internal: cleanup logging, remove duplicate code
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-22 10:33:21 +01:00
22a8603892 internal: add custom proxy certificates support to embedded outpost
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-22 10:16:01 +01:00
d83d058a4b build(deps): bump @docusaurus/plugin-client-redirects in /website (#1980) 2021-12-22 09:47:58 +01:00
ec3fd4a3ab build(deps): bump @docusaurus/preset-classic in /website (#1979) 2021-12-22 09:41:01 +01:00
0764668b14 build(deps): bump goauthentik.io/api from 0.2021122.2 to 0.2021123.1 (#1981) 2021-12-22 09:40:00 +01:00
16b6c17305 Revert "policies: don't always clear application cache on post_save"
This reverts commit 5ef385f0bb.

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

# Conflicts:
#	authentik/policies/signals.py
2021-12-22 00:23:19 +01:00
e60509697a web/admin: fix explore integration not opening in new tab
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-12-22 00:03:28 +01:00
85364af9e9 web: Update Web API Client version (#1978)
Signed-off-by: GitHub <noreply@github.com>

Co-authored-by: BeryJu <BeryJu@users.noreply.github.com>
2021-12-21 21:28:01 +01:00
299 changed files with 17837 additions and 7074 deletions

View File

@ -1,5 +1,5 @@
[bumpversion] [bumpversion]
current_version = 2021.12.3 current_version = 2021.12.5
tag = True tag = True
commit = True commit = True
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-?(?P<release>.*) parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-?(?P<release>.*)
@ -17,7 +17,7 @@ values =
beta beta
stable stable
[bumpversion:file:website/docs/installation/docker-compose.md] [bumpversion:file:pyproject.toml]
[bumpversion:file:docker-compose.yml] [bumpversion:file:docker-compose.yml]
@ -30,7 +30,3 @@ values =
[bumpversion:file:internal/constants/constants.go] [bumpversion:file:internal/constants/constants.go]
[bumpversion:file:web/src/constants.ts] [bumpversion:file:web/src/constants.ts]
[bumpversion:file:website/docs/outposts/manual-deploy-docker-compose.md]
[bumpversion:file:website/docs/outposts/manual-deploy-kubernetes.md]

View File

@ -33,40 +33,36 @@ jobs:
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: actions/setup-python@v2 - uses: actions/setup-python@v2
with:
python-version: '3.9'
- uses: actions/setup-node@v2 - uses: actions/setup-node@v2
with: with:
node-version: '16' node-version: '16'
- id: cache-pipenv - id: cache-poetry
uses: actions/cache@v2.1.7 uses: actions/cache@v2.1.7
with: with:
path: ~/.local/share/virtualenvs path: ~/.cache/pypoetry/virtualenvs
key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }} key: ${{ runner.os }}-poetry-cache-v3-${{ hashFiles('**/poetry.lock') }}
- name: prepare - name: prepare
env: env:
INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }} INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
run: scripts/ci_prepare.sh run: scripts/ci_prepare.sh
- name: run job - name: run job
run: pipenv run make ci-${{ matrix.job }} run: poetry run make ci-${{ matrix.job }}
test-migrations: test-migrations:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: actions/setup-python@v2 - uses: actions/setup-python@v2
with: - id: cache-poetry
python-version: '3.9'
- id: cache-pipenv
uses: actions/cache@v2.1.7 uses: actions/cache@v2.1.7
with: with:
path: ~/.local/share/virtualenvs path: ~/.cache/pypoetry/virtualenvs
key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }} key: ${{ runner.os }}-poetry-cache-v3-${{ hashFiles('**/poetry.lock') }}
- name: prepare - name: prepare
env: env:
INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }} INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
run: scripts/ci_prepare.sh run: scripts/ci_prepare.sh
- name: run migrations - name: run migrations
run: pipenv run python -m lifecycle.migrate run: poetry run python -m lifecycle.migrate
test-migrations-from-stable: test-migrations-from-stable:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@ -74,75 +70,79 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: actions/setup-python@v2 - uses: actions/setup-python@v2
with:
python-version: '3.9'
- name: prepare variables - name: prepare variables
id: ev id: ev
run: | run: |
python ./scripts/gh_env.py python ./scripts/gh_env.py
- id: cache-pipenv sudo pip install -U pipenv
- id: cache-poetry
uses: actions/cache@v2.1.7 uses: actions/cache@v2.1.7
with: with:
path: ~/.local/share/virtualenvs path: ~/.cache/pypoetry/virtualenvs
key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }} key: ${{ runner.os }}-poetry-cache-v3-${{ hashFiles('**/poetry.lock') }}
- name: checkout stable - name: checkout stable
id: stable
run: | run: |
# Save current branch
current=$(git branch --show)
echo ##[set-output name=originalBranch]$current
# Copy current, latest config to local # Copy current, latest config to local
cp authentik/lib/default.yml local.env.yml cp authentik/lib/default.yml local.env.yml
cp -R .github .. cp -R .github ..
cp -R scripts .. cp -R scripts ..
cp -R poetry.lock pyproject.toml ..
git checkout $(git describe --abbrev=0 --match 'version/*') git checkout $(git describe --abbrev=0 --match 'version/*')
rm -rf .github/ scripts/ rm -rf .github/ scripts/
mv ../.github ../scripts . mv ../.github ../scripts ../poetry.lock ../pyproject.toml .
- name: prepare - name: prepare
env: env:
INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }} INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
run: | run: |
scripts/ci_prepare.sh scripts/ci_prepare.sh
# Sync anyways since stable will have different dependencies # Sync anyways since stable will have different dependencies
pipenv sync --dev # TODO: Remove after next stable release
if [[ -f "Pipfile.lock" ]]; then
pipenv install --dev
fi
poetry install
- name: run migrations to stable - name: run migrations to stable
run: pipenv run python -m lifecycle.migrate run: poetry run python -m lifecycle.migrate
- name: checkout current code - name: checkout current code
run: | run: |
set -x set -x
git fetch git fetch
git reset --hard HEAD git reset --hard HEAD
git checkout ${{ steps.stable.outputs.originalBranch }} # TODO: Remove after next stable release
pipenv sync --dev rm -f poetry.lock
git checkout $GITHUB_SHA
# TODO: Remove after next stable release
if [[ -f "Pipfile.lock" ]]; then
pipenv install --dev
fi
poetry install
- name: prepare - name: prepare
env: env:
INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }} INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
run: scripts/ci_prepare.sh run: scripts/ci_prepare.sh
- name: migrate to latest - name: migrate to latest
run: pipenv run python -m lifecycle.migrate run: poetry run python -m lifecycle.migrate
test-unittest: test-unittest:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: actions/setup-python@v2 - uses: actions/setup-python@v2
with: - id: cache-poetry
python-version: '3.9'
- id: cache-pipenv
uses: actions/cache@v2.1.7 uses: actions/cache@v2.1.7
with: with:
path: ~/.local/share/virtualenvs path: ~/.cache/pypoetry/virtualenvs
key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }} key: ${{ runner.os }}-poetry-cache-v3-${{ hashFiles('**/poetry.lock') }}
- name: prepare - name: prepare
env: env:
INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }} INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
run: scripts/ci_prepare.sh run: scripts/ci_prepare.sh
- uses: testspace-com/setup-testspace@v1 - uses: testspace-com/setup-testspace@v1
with: with:
domain: ${{github.repository_owner}} domain: ${{github.repository_owner}}
- name: run unittest - name: run unittest
run: | run: |
pipenv run make test poetry run make test
pipenv run coverage xml poetry run coverage xml
- name: run testspace - name: run testspace
if: ${{ always() }} if: ${{ always() }}
run: | run: |
@ -154,16 +154,14 @@ jobs:
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: actions/setup-python@v2 - uses: actions/setup-python@v2
with: - id: cache-poetry
python-version: '3.9'
- id: cache-pipenv
uses: actions/cache@v2.1.7 uses: actions/cache@v2.1.7
with: with:
path: ~/.local/share/virtualenvs path: ~/.cache/pypoetry/virtualenvs
key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }} key: ${{ runner.os }}-poetry-cache-v3-${{ hashFiles('**/poetry.lock') }}
- name: prepare - name: prepare
env: env:
INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }} INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
run: scripts/ci_prepare.sh run: scripts/ci_prepare.sh
- uses: testspace-com/setup-testspace@v1 - uses: testspace-com/setup-testspace@v1
with: with:
@ -172,8 +170,8 @@ jobs:
uses: helm/kind-action@v1.2.0 uses: helm/kind-action@v1.2.0
- name: run integration - name: run integration
run: | run: |
pipenv run make test-integration poetry run make test-integration
pipenv run coverage xml poetry run coverage xml
- name: run testspace - name: run testspace
if: ${{ always() }} if: ${{ always() }}
run: | run: |
@ -185,8 +183,6 @@ jobs:
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: actions/setup-python@v2 - uses: actions/setup-python@v2
with:
python-version: '3.9'
- uses: actions/setup-node@v2 - uses: actions/setup-node@v2
with: with:
node-version: '16' node-version: '16'
@ -195,14 +191,14 @@ jobs:
- uses: testspace-com/setup-testspace@v1 - uses: testspace-com/setup-testspace@v1
with: with:
domain: ${{github.repository_owner}} domain: ${{github.repository_owner}}
- id: cache-pipenv - id: cache-poetry
uses: actions/cache@v2.1.7 uses: actions/cache@v2.1.7
with: with:
path: ~/.local/share/virtualenvs path: ~/.cache/pypoetry/virtualenvs
key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }} key: ${{ runner.os }}-poetry-cache-v3-${{ hashFiles('**/poetry.lock') }}
- name: prepare - name: prepare
env: env:
INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }} INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
run: | run: |
scripts/ci_prepare.sh scripts/ci_prepare.sh
docker-compose -f tests/e2e/docker-compose.yml up -d docker-compose -f tests/e2e/docker-compose.yml up -d
@ -219,8 +215,8 @@ jobs:
npm run build npm run build
- name: run e2e - name: run e2e
run: | run: |
pipenv run make test-e2e-provider poetry run make test-e2e-provider
pipenv run coverage xml poetry run coverage xml
- name: run testspace - name: run testspace
if: ${{ always() }} if: ${{ always() }}
run: | run: |
@ -232,8 +228,6 @@ jobs:
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: actions/setup-python@v2 - uses: actions/setup-python@v2
with:
python-version: '3.9'
- uses: actions/setup-node@v2 - uses: actions/setup-node@v2
with: with:
node-version: '16' node-version: '16'
@ -242,14 +236,14 @@ jobs:
- uses: testspace-com/setup-testspace@v1 - uses: testspace-com/setup-testspace@v1
with: with:
domain: ${{github.repository_owner}} domain: ${{github.repository_owner}}
- id: cache-pipenv - id: cache-poetry
uses: actions/cache@v2.1.7 uses: actions/cache@v2.1.7
with: with:
path: ~/.local/share/virtualenvs path: ~/.cache/pypoetry/virtualenvs
key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }} key: ${{ runner.os }}-poetry-cache-v3-${{ hashFiles('**/poetry.lock') }}
- name: prepare - name: prepare
env: env:
INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }} INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
run: | run: |
scripts/ci_prepare.sh scripts/ci_prepare.sh
docker-compose -f tests/e2e/docker-compose.yml up -d docker-compose -f tests/e2e/docker-compose.yml up -d
@ -266,8 +260,8 @@ jobs:
npm run build npm run build
- name: run e2e - name: run e2e
run: | run: |
pipenv run make test-e2e-rest poetry run make test-e2e-rest
pipenv run coverage xml poetry run coverage xml
- name: run testspace - name: run testspace
if: ${{ always() }} if: ${{ always() }}
run: | run: |

View File

@ -30,14 +30,14 @@ jobs:
with: with:
push: ${{ github.event_name == 'release' }} push: ${{ github.event_name == 'release' }}
tags: | tags: |
beryju/authentik:2021.12.3, beryju/authentik:2021.12.5,
beryju/authentik:latest, beryju/authentik:latest,
ghcr.io/goauthentik/server:2021.12.3, ghcr.io/goauthentik/server:2021.12.5,
ghcr.io/goauthentik/server:latest ghcr.io/goauthentik/server:latest
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
context: . context: .
- name: Building Docker Image (stable) - name: Building Docker Image (stable)
if: ${{ github.event_name == 'release' && !contains('2021.12.3', 'rc') }} if: ${{ github.event_name == 'release' && !contains('2021.12.5', 'rc') }}
run: | run: |
docker pull beryju/authentik:latest docker pull beryju/authentik:latest
docker tag beryju/authentik:latest beryju/authentik:stable docker tag beryju/authentik:latest beryju/authentik:stable
@ -78,14 +78,14 @@ jobs:
with: with:
push: ${{ github.event_name == 'release' }} push: ${{ github.event_name == 'release' }}
tags: | tags: |
beryju/authentik-${{ matrix.type }}:2021.12.3, beryju/authentik-${{ matrix.type }}:2021.12.5,
beryju/authentik-${{ matrix.type }}:latest, beryju/authentik-${{ matrix.type }}:latest,
ghcr.io/goauthentik/${{ matrix.type }}:2021.12.3, ghcr.io/goauthentik/${{ matrix.type }}:2021.12.5,
ghcr.io/goauthentik/${{ matrix.type }}:latest ghcr.io/goauthentik/${{ matrix.type }}:latest
file: ${{ matrix.type }}.Dockerfile file: ${{ matrix.type }}.Dockerfile
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
- name: Building Docker Image (stable) - name: Building Docker Image (stable)
if: ${{ github.event_name == 'release' && !contains('2021.12.3', 'rc') }} if: ${{ github.event_name == 'release' && !contains('2021.12.5', 'rc') }}
run: | run: |
docker pull beryju/authentik-${{ matrix.type }}:latest docker pull beryju/authentik-${{ matrix.type }}:latest
docker tag beryju/authentik-${{ matrix.type }}:latest beryju/authentik-${{ matrix.type }}:stable docker tag beryju/authentik-${{ matrix.type }}:latest beryju/authentik-${{ matrix.type }}:stable
@ -170,7 +170,7 @@ jobs:
SENTRY_PROJECT: authentik SENTRY_PROJECT: authentik
SENTRY_URL: https://sentry.beryju.org SENTRY_URL: https://sentry.beryju.org
with: with:
version: authentik@2021.12.3 version: authentik@2021.12.5
environment: beryjuorg-prod environment: beryjuorg-prod
sourcemaps: './web/dist' sourcemaps: './web/dist'
url_prefix: '~/static/dist' url_prefix: '~/static/dist'

View File

@ -22,22 +22,20 @@ jobs:
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: actions/setup-python@v2 - uses: actions/setup-python@v2
with: - id: cache-poetry
python-version: '3.9'
- id: cache-pipenv
uses: actions/cache@v2.1.7 uses: actions/cache@v2.1.7
with: with:
path: ~/.local/share/virtualenvs path: ~/.cache/pypoetry/virtualenvs
key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }} key: ${{ runner.os }}-poetry-cache-v3-${{ hashFiles('**/poetry.lock') }}
- name: prepare - name: prepare
env: env:
INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }} INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y gettext sudo apt-get install -y gettext
scripts/ci_prepare.sh scripts/ci_prepare.sh
- name: run compile - name: run compile
run: pipenv run ./manage.py compilemessages run: poetry run ./manage.py compilemessages
- name: Create Pull Request - name: Create Pull Request
uses: peter-evans/create-pull-request@v3 uses: peter-evans/create-pull-request@v3
id: cpr id: cpr

View File

@ -11,7 +11,8 @@
"saml", "saml",
"totp", "totp",
"webauthn", "webauthn",
"traefik" "traefik",
"passwordless"
], ],
"python.linting.pylintEnabled": true, "python.linting.pylintEnabled": true,
"todo-tree.tree.showCountsInTree": true, "todo-tree.tree.showCountsInTree": true,

View File

@ -1,16 +1,4 @@
# Stage 1: Lock python dependencies # Stage 1: Build website
FROM docker.io/python:3.10.1-slim-bullseye as locker
COPY ./Pipfile /app/
COPY ./Pipfile.lock /app/
WORKDIR /app/
RUN pip install pipenv && \
pipenv lock -r > requirements.txt && \
pipenv lock -r --dev-only > requirements-dev.txt
# Stage 2: Build website
FROM --platform=${BUILDPLATFORM} docker.io/node:16 as website-builder FROM --platform=${BUILDPLATFORM} docker.io/node:16 as website-builder
COPY ./website /work/website/ COPY ./website /work/website/
@ -18,7 +6,7 @@ COPY ./website /work/website/
ENV NODE_ENV=production ENV NODE_ENV=production
RUN cd /work/website && npm i && npm run build-docs-only RUN cd /work/website && npm i && npm run build-docs-only
# Stage 3: Build webui # Stage 2: Build webui
FROM --platform=${BUILDPLATFORM} docker.io/node:16 as web-builder FROM --platform=${BUILDPLATFORM} docker.io/node:16 as web-builder
COPY ./web /work/web/ COPY ./web /work/web/
@ -27,7 +15,7 @@ COPY ./website /work/website/
ENV NODE_ENV=production ENV NODE_ENV=production
RUN cd /work/web && npm i && npm run build RUN cd /work/web && npm i && npm run build
# Stage 4: Build go proxy # Stage 3: Build go proxy
FROM docker.io/golang:1.17.5-bullseye AS builder FROM docker.io/golang:1.17.5-bullseye AS builder
WORKDIR /work WORKDIR /work
@ -43,29 +31,38 @@ COPY ./go.sum /work/go.sum
RUN go build -o /work/authentik ./cmd/server/main.go RUN go build -o /work/authentik ./cmd/server/main.go
# Stage 5: Run # Stage 4: Run
FROM docker.io/python:3.10.1-slim-bullseye FROM docker.io/python:3.10.1-slim-bullseye
LABEL org.opencontainers.image.url https://goauthentik.io
LABEL org.opencontainers.image.description goauthentik.io Main server image, see https://goauthentik.io for more info.
LABEL org.opencontainers.image.source https://github.com/goauthentik/authentik
WORKDIR / WORKDIR /
COPY --from=locker /app/requirements.txt /
COPY --from=locker /app/requirements-dev.txt /
ARG GIT_BUILD_HASH ARG GIT_BUILD_HASH
ENV GIT_BUILD_HASH=$GIT_BUILD_HASH ENV GIT_BUILD_HASH=$GIT_BUILD_HASH
COPY ./pyproject.toml /
COPY ./poetry.lock /
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
curl ca-certificates gnupg git runit libpq-dev \ curl ca-certificates gnupg git runit libpq-dev \
postgresql-client build-essential libxmlsec1-dev \ postgresql-client build-essential libxmlsec1-dev \
pkg-config libmaxminddb0 && \ pkg-config libmaxminddb0 && \
pip install -r /requirements.txt --no-cache-dir && \ pip install poetry && \
poetry config virtualenvs.create false && \
poetry install --no-dev && \
rm -rf ~/.cache/pypoetry && \
apt-get remove --purge -y build-essential git && \ apt-get remove --purge -y build-essential git && \
apt-get autoremove --purge -y && \ apt-get autoremove --purge -y && \
apt-get clean && \ apt-get clean && \
rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/ && \ rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/ && \
adduser --system --no-create-home --uid 1000 --group --home /authentik authentik && \ adduser --system --no-create-home --uid 1000 --group --home /authentik authentik && \
mkdir -p /backups /certs /media && \ mkdir -p /backups /certs /media && \
chown authentik:authentik /backups /certs /media mkdir -p /authentik/.ssh && \
chown authentik:authentik /backups /certs /media /authentik/.ssh
COPY ./authentik/ /authentik COPY ./authentik/ /authentik
COPY ./pyproject.toml / COPY ./pyproject.toml /

View File

@ -35,6 +35,7 @@ lint-fix:
lint: lint:
bandit -r authentik tests lifecycle -x node_modules bandit -r authentik tests lifecycle -x node_modules
pylint authentik tests lifecycle pylint authentik tests lifecycle
golangci-lint run -v
i18n-extract: i18n-extract-core web-extract i18n-extract: i18n-extract-core web-extract
@ -105,20 +106,24 @@ web-extract:
# These targets are use by GitHub actions to allow usage of matrix # These targets are use by GitHub actions to allow usage of matrix
# which makes the YAML File a lot smaller # which makes the YAML File a lot smaller
ci-pylint: ci--meta-debug:
python -V
node --version
ci-pylint: ci--meta-debug
pylint authentik tests lifecycle pylint authentik tests lifecycle
ci-black: ci-black: ci--meta-debug
black --check authentik tests lifecycle black --check authentik tests lifecycle
ci-isort: ci-isort: ci--meta-debug
isort --check authentik tests lifecycle isort --check authentik tests lifecycle
ci-bandit: ci-bandit: ci--meta-debug
bandit -r authentik tests lifecycle bandit -r authentik tests lifecycle
ci-pyright: ci-pyright: ci--meta-debug
pyright e2e lifecycle pyright e2e lifecycle
ci-pending-migrations: ci-pending-migrations: ci--meta-debug
./manage.py makemigrations --check ./manage.py makemigrations --check

68
Pipfile
View File

@ -1,68 +0,0 @@
[[source]]
name = "pypi"
url = "https://pypi.org/simple"
verify_ssl = true
[packages]
boto3 = "*"
celery = "*"
channels = "*"
channels-redis = "*"
codespell = "*"
colorama = "*"
dacite = "*"
deepmerge = "*"
defusedxml = "*"
django = "*"
django-dbbackup = { git = 'https://github.com/django-dbbackup/django-dbbackup.git', ref = '9d1909c30a3271c8c9c8450add30d6e0b996e145' }
django-filter = "*"
django-guardian = "*"
django-model-utils = "*"
django-otp = "*"
django-prometheus = "*"
django-redis = "*"
django-storages = "*"
djangorestframework = "*"
djangorestframework-guardian = "*"
docker = "*"
drf-spectacular = "*"
duo-client = "*"
facebook-sdk = "*"
geoip2 = "*"
gunicorn = "*"
kubernetes = "==v19.15.0"
ldap3 = "*"
lxml = "*"
packaging = "*"
psycopg2-binary = "*"
pycryptodome = "*"
pyjwt = "*"
pyyaml = "*"
requests-oauthlib = "*"
sentry-sdk = { git = 'https://github.com/beryju/sentry-python.git', ref = '379aee28b15d3b87b381317746c4efd24b3d7bc3' }
service_identity = "*"
structlog = "*"
swagger-spec-validator = "*"
twisted = "==21.7.0"
ua-parser = "*"
urllib3 = {extras = ["secure"],version = "*"}
uvicorn = {extras = ["standard"],version = "*"}
webauthn = "*"
xmlsec = "*"
flower = "*"
wsproto = "*"
[dev-packages]
bandit = "*"
black = "==21.11b1"
bump2version = "*"
colorama = "*"
coverage = {extras = ["toml"],version = "*"}
pylint = "*"
pylint-django = "*"
pytest = "*"
pytest-django = "*"
pytest-randomly = "*"
requests-mock = "*"
selenium = "*"
importlib-metadata = "*"

2514
Pipfile.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +1,3 @@
"""authentik""" """authentik"""
__version__ = "2021.12.3" __version__ = "2021.12.5"
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"

View File

@ -95,7 +95,7 @@ class TaskViewSet(ViewSet):
_("Successfully re-scheduled Task %(name)s!" % {"name": task.task_name}), _("Successfully re-scheduled Task %(name)s!" % {"name": task.task_name}),
) )
return Response(status=204) return Response(status=204)
except ImportError: # pragma: no cover except (ImportError, AttributeError): # pragma: no cover
# if we get an import error, the module path has probably changed # if we get an import error, the module path has probably changed
task.delete() task.delete()
return Response(status=500) return Response(status=500)

View File

@ -1,7 +1,7 @@
"""API Authentication""" """API Authentication"""
from base64 import b64decode from base64 import b64decode
from binascii import Error from binascii import Error
from typing import Any, Optional, Union from typing import Any, Optional
from django.conf import settings from django.conf import settings
from rest_framework.authentication import BaseAuthentication, get_authorization_header from rest_framework.authentication import BaseAuthentication, get_authorization_header
@ -69,7 +69,7 @@ def token_secret_key(value: str) -> Optional[User]:
class TokenAuthentication(BaseAuthentication): class TokenAuthentication(BaseAuthentication):
"""Token-based authentication using HTTP Bearer authentication""" """Token-based authentication using HTTP Bearer authentication"""
def authenticate(self, request: Request) -> Union[tuple[User, Any], None]: def authenticate(self, request: Request) -> tuple[User, Any] | None:
"""Token-based authentication using HTTP Bearer authentication""" """Token-based authentication using HTTP Bearer authentication"""
auth = get_authorization_header(request) auth = get_authorization_header(request)

View File

@ -46,11 +46,7 @@ from authentik.policies.expiry.api import PasswordExpiryPolicyViewSet
from authentik.policies.expression.api import ExpressionPolicyViewSet from authentik.policies.expression.api import ExpressionPolicyViewSet
from authentik.policies.hibp.api import HaveIBeenPwendPolicyViewSet from authentik.policies.hibp.api import HaveIBeenPwendPolicyViewSet
from authentik.policies.password.api import PasswordPolicyViewSet from authentik.policies.password.api import PasswordPolicyViewSet
from authentik.policies.reputation.api import ( from authentik.policies.reputation.api import ReputationPolicyViewSet, ReputationViewSet
IPReputationViewSet,
ReputationPolicyViewSet,
UserReputationViewSet,
)
from authentik.providers.ldap.api import LDAPOutpostConfigViewSet, LDAPProviderViewSet from authentik.providers.ldap.api import LDAPOutpostConfigViewSet, LDAPProviderViewSet
from authentik.providers.oauth2.api.provider import OAuth2ProviderViewSet from authentik.providers.oauth2.api.provider import OAuth2ProviderViewSet
from authentik.providers.oauth2.api.scope import ScopeMappingViewSet from authentik.providers.oauth2.api.scope import ScopeMappingViewSet
@ -151,8 +147,7 @@ router.register("policies/event_matcher", EventMatcherPolicyViewSet)
router.register("policies/haveibeenpwned", HaveIBeenPwendPolicyViewSet) router.register("policies/haveibeenpwned", HaveIBeenPwendPolicyViewSet)
router.register("policies/password_expiry", PasswordExpiryPolicyViewSet) router.register("policies/password_expiry", PasswordExpiryPolicyViewSet)
router.register("policies/password", PasswordPolicyViewSet) router.register("policies/password", PasswordPolicyViewSet)
router.register("policies/reputation/users", UserReputationViewSet) router.register("policies/reputation/scores", ReputationViewSet)
router.register("policies/reputation/ips", IPReputationViewSet)
router.register("policies/reputation", ReputationPolicyViewSet) router.register("policies/reputation", ReputationPolicyViewSet)
router.register("providers/all", ProviderViewSet) router.register("providers/all", ProviderViewSet)

View File

@ -3,6 +3,7 @@ from datetime import timedelta
from json import loads from json import loads
from typing import Optional from typing import Optional
from django.contrib.auth import update_session_auth_hash
from django.db.models.query import QuerySet from django.db.models.query import QuerySet
from django.db.transaction import atomic from django.db.transaction import atomic
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
@ -46,6 +47,7 @@ from authentik.core.api.utils import LinkSerializer, PassiveSerializer, is_dict
from authentik.core.middleware import SESSION_IMPERSONATE_ORIGINAL_USER, SESSION_IMPERSONATE_USER from authentik.core.middleware import SESSION_IMPERSONATE_ORIGINAL_USER, SESSION_IMPERSONATE_USER
from authentik.core.models import ( from authentik.core.models import (
USER_ATTRIBUTE_CHANGE_EMAIL, USER_ATTRIBUTE_CHANGE_EMAIL,
USER_ATTRIBUTE_CHANGE_NAME,
USER_ATTRIBUTE_CHANGE_USERNAME, USER_ATTRIBUTE_CHANGE_USERNAME,
USER_ATTRIBUTE_SA, USER_ATTRIBUTE_SA,
USER_ATTRIBUTE_TOKEN_EXPIRING, USER_ATTRIBUTE_TOKEN_EXPIRING,
@ -134,6 +136,16 @@ class UserSelfSerializer(ModelSerializer):
raise ValidationError("Not allowed to change email.") raise ValidationError("Not allowed to change email.")
return email return email
def validate_name(self, name: str):
"""Check if the user is allowed to change their name"""
if self.instance.group_attributes().get(
USER_ATTRIBUTE_CHANGE_NAME, CONFIG.y_bool("default_user_change_name", True)
):
return name
if name != self.instance.name:
raise ValidationError("Not allowed to change name.")
return name
def validate_username(self, username: str): def validate_username(self, username: str):
"""Check if the user is allowed to change their username""" """Check if the user is allowed to change their username"""
if self.instance.group_attributes().get( if self.instance.group_attributes().get(
@ -144,6 +156,13 @@ class UserSelfSerializer(ModelSerializer):
raise ValidationError("Not allowed to change username.") raise ValidationError("Not allowed to change username.")
return username return username
def save(self, **kwargs):
if self.instance:
attributes: dict = self.instance.attributes
attributes.update(self.validated_data.get("attributes", {}))
self.validated_data["attributes"] = attributes
return super().save(**kwargs)
class Meta: class Meta:
model = User model = User
@ -359,6 +378,35 @@ class UserViewSet(UsedByMixin, ModelViewSet):
).data ).data
return Response(serializer.initial_data) return Response(serializer.initial_data)
@permission_required("authentik_core.reset_user_password")
@extend_schema(
request=inline_serializer(
"UserPasswordSetSerializer",
{
"password": CharField(required=True),
},
),
responses={
204: "",
400: "",
},
)
@action(detail=True, methods=["POST"])
# pylint: disable=invalid-name, unused-argument
def set_password(self, request: Request, pk: int) -> Response:
"""Set password for user"""
user: User = self.get_object()
try:
user.set_password(request.data.get("password"))
user.save()
except (ValidationError, IntegrityError) as exc:
LOGGER.debug("Failed to set password", exc=exc)
return Response(status=400)
if user.pk == request.user.pk and SESSION_IMPERSONATE_USER not in self.request.session:
LOGGER.debug("Updating session hash after password change")
update_session_auth_hash(self.request, user)
return Response(status=204)
@extend_schema(request=UserSelfSerializer, responses={200: SessionUserSerializer(many=False)}) @extend_schema(request=UserSelfSerializer, responses={200: SessionUserSerializer(many=False)})
@action( @action(
methods=["PUT"], methods=["PUT"],

View File

@ -15,7 +15,6 @@ import authentik.lib.models
def migrate_sessions(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): def migrate_sessions(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
db_alias = schema_editor.connection.alias
from django.contrib.sessions.backends.cache import KEY_PREFIX from django.contrib.sessions.backends.cache import KEY_PREFIX
from django.core.cache import cache from django.core.cache import cache

View File

@ -12,7 +12,6 @@ import authentik.core.models
def migrate_sessions(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): def migrate_sessions(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
db_alias = schema_editor.connection.alias
from django.contrib.sessions.backends.cache import KEY_PREFIX from django.contrib.sessions.backends.cache import KEY_PREFIX
from django.core.cache import cache from django.core.cache import cache

View File

@ -1,12 +1,13 @@
"""authentik core models""" """authentik core models"""
from datetime import timedelta from datetime import timedelta
from hashlib import md5, sha256 from hashlib import md5, sha256
from typing import Any, Optional, Type from typing import Any, Optional
from urllib.parse import urlencode from urllib.parse import urlencode
from uuid import uuid4 from uuid import uuid4
from deepmerge import always_merger from deepmerge import always_merger
from django.conf import settings from django.conf import settings
from django.contrib.auth.hashers import check_password
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.contrib.auth.models import UserManager as DjangoUserManager from django.contrib.auth.models import UserManager as DjangoUserManager
from django.db import models from django.db import models
@ -38,6 +39,7 @@ USER_ATTRIBUTE_SA = "goauthentik.io/user/service-account"
USER_ATTRIBUTE_SOURCES = "goauthentik.io/user/sources" USER_ATTRIBUTE_SOURCES = "goauthentik.io/user/sources"
USER_ATTRIBUTE_TOKEN_EXPIRING = "goauthentik.io/user/token-expires" # nosec USER_ATTRIBUTE_TOKEN_EXPIRING = "goauthentik.io/user/token-expires" # nosec
USER_ATTRIBUTE_CHANGE_USERNAME = "goauthentik.io/user/can-change-username" USER_ATTRIBUTE_CHANGE_USERNAME = "goauthentik.io/user/can-change-username"
USER_ATTRIBUTE_CHANGE_NAME = "goauthentik.io/user/can-change-name"
USER_ATTRIBUTE_CHANGE_EMAIL = "goauthentik.io/user/can-change-email" USER_ATTRIBUTE_CHANGE_EMAIL = "goauthentik.io/user/can-change-email"
USER_ATTRIBUTE_CAN_OVERRIDE_IP = "goauthentik.io/user/override-ips" USER_ATTRIBUTE_CAN_OVERRIDE_IP = "goauthentik.io/user/override-ips"
@ -160,6 +162,22 @@ class User(GuardianUserMixin, AbstractUser):
self.password_change_date = now() self.password_change_date = now()
return super().set_password(password) return super().set_password(password)
def check_password(self, raw_password: str) -> bool:
"""
Return a boolean of whether the raw_password was correct. Handles
hashing formats behind the scenes.
Slightly changed version which doesn't send a signal for such internal hash upgrades
"""
def setter(raw_password):
self.set_password(raw_password, signal=False)
# Password hash upgrades shouldn't be considered password changes.
self._password = None
self.save(update_fields=["password"])
return check_password(raw_password, self.password, setter)
@property @property
def uid(self) -> str: def uid(self) -> str:
"""Generate a globall unique UID, based on the user ID and the hashed secret key""" """Generate a globall unique UID, based on the user ID and the hashed secret key"""
@ -224,7 +242,7 @@ class Provider(SerializerModel):
raise NotImplementedError raise NotImplementedError
@property @property
def serializer(self) -> Type[Serializer]: def serializer(self) -> type[Serializer]:
"""Get serializer for this model""" """Get serializer for this model"""
raise NotImplementedError raise NotImplementedError
@ -505,7 +523,7 @@ class PropertyMapping(SerializerModel, ManagedModel):
raise NotImplementedError raise NotImplementedError
@property @property
def serializer(self) -> Type[Serializer]: def serializer(self) -> type[Serializer]:
"""Get serializer for this model""" """Get serializer for this model"""
raise NotImplementedError raise NotImplementedError

View File

@ -1,5 +1,5 @@
"""authentik core signals""" """authentik core signals"""
from typing import TYPE_CHECKING, Type from typing import TYPE_CHECKING
from django.contrib.auth.signals import user_logged_in, user_logged_out from django.contrib.auth.signals import user_logged_in, user_logged_out
from django.contrib.sessions.backends.cache import KEY_PREFIX from django.contrib.sessions.backends.cache import KEY_PREFIX
@ -62,7 +62,7 @@ def user_logged_out_session(sender, request: HttpRequest, user: "User", **_):
@receiver(pre_delete) @receiver(pre_delete)
def authenticated_session_delete(sender: Type[Model], instance: "AuthenticatedSession", **_): def authenticated_session_delete(sender: type[Model], instance: "AuthenticatedSession", **_):
"""Delete session when authenticated session is deleted""" """Delete session when authenticated session is deleted"""
from authentik.core.models import AuthenticatedSession from authentik.core.models import AuthenticatedSession

View File

@ -1,6 +1,6 @@
"""Source decision helper""" """Source decision helper"""
from enum import Enum from enum import Enum
from typing import Any, Optional, Type from typing import Any, Optional
from django.contrib import messages from django.contrib import messages
from django.db import IntegrityError from django.db import IntegrityError
@ -14,6 +14,7 @@ from structlog.stdlib import get_logger
from authentik.core.models import Source, SourceUserMatchingModes, User, UserSourceConnection from authentik.core.models import Source, SourceUserMatchingModes, User, UserSourceConnection
from authentik.core.sources.stage import PLAN_CONTEXT_SOURCES_CONNECTION, PostUserEnrollmentStage from authentik.core.sources.stage import PLAN_CONTEXT_SOURCES_CONNECTION, PostUserEnrollmentStage
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction
from authentik.flows.exceptions import FlowNonApplicableException
from authentik.flows.models import Flow, Stage, in_memory_stage from authentik.flows.models import Flow, Stage, in_memory_stage
from authentik.flows.planner import ( from authentik.flows.planner import (
PLAN_CONTEXT_PENDING_USER, PLAN_CONTEXT_PENDING_USER,
@ -24,6 +25,8 @@ from authentik.flows.planner import (
) )
from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN
from authentik.lib.utils.urls import redirect_with_qs from authentik.lib.utils.urls import redirect_with_qs
from authentik.policies.denied import AccessDeniedResponse
from authentik.policies.types import PolicyResult
from authentik.policies.utils import delete_none_keys from authentik.policies.utils import delete_none_keys
from authentik.stages.password import BACKEND_INBUILT from authentik.stages.password import BACKEND_INBUILT
from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
@ -50,7 +53,10 @@ class SourceFlowManager:
identifier: str identifier: str
connection_type: Type[UserSourceConnection] = UserSourceConnection connection_type: type[UserSourceConnection] = UserSourceConnection
enroll_info: dict[str, Any]
policy_context: dict[str, Any]
def __init__( def __init__(
self, self,
@ -64,6 +70,7 @@ class SourceFlowManager:
self.identifier = identifier self.identifier = identifier
self.enroll_info = enroll_info self.enroll_info = enroll_info
self._logger = get_logger().bind(source=source, identifier=identifier) self._logger = get_logger().bind(source=source, identifier=identifier)
self.policy_context = {}
# pylint: disable=too-many-return-statements # pylint: disable=too-many-return-statements
def get_action(self, **kwargs) -> tuple[Action, Optional[UserSourceConnection]]: def get_action(self, **kwargs) -> tuple[Action, Optional[UserSourceConnection]]:
@ -144,7 +151,8 @@ class SourceFlowManager:
except IntegrityError as exc: except IntegrityError as exc:
self._logger.warning("failed to get action", exc=exc) self._logger.warning("failed to get action", exc=exc)
return redirect("/") return redirect("/")
self._logger.debug("get_action() says", action=action, connection=connection) self._logger.debug("get_action", action=action, connection=connection)
try:
if connection: if connection:
if action == Action.LINK: if action == Action.LINK:
self._logger.debug("Linking existing user") self._logger.debug("Linking existing user")
@ -155,9 +163,11 @@ class SourceFlowManager:
if action == Action.ENROLL: if action == Action.ENROLL:
self._logger.debug("Handling enrollment of new user") self._logger.debug("Handling enrollment of new user")
return self.handle_enroll(connection) return self.handle_enroll(connection)
except FlowNonApplicableException as exc:
self._logger.warning("Flow non applicable", exc=exc)
return self.error_handler(exc, exc.policy_result)
# Default case, assume deny # Default case, assume deny
messages.error( error = (
self.request,
_( _(
( (
"Request to authenticate with %(source)s has been denied. Please authenticate " "Request to authenticate with %(source)s has been denied. Please authenticate "
@ -166,7 +176,17 @@ class SourceFlowManager:
% {"source": self.source.name} % {"source": self.source.name}
), ),
) )
return redirect(reverse("authentik_core:root-redirect")) return self.error_handler(error)
def error_handler(
self, error: Exception, policy_result: Optional[PolicyResult] = None
) -> HttpResponse:
"""Handle any errors by returning an access denied stage"""
response = AccessDeniedResponse(self.request)
response.error_message = str(error)
if policy_result:
response.policy_result = policy_result
return response
# pylint: disable=unused-argument # pylint: disable=unused-argument
def get_stages_to_append(self, flow: Flow) -> list[Stage]: def get_stages_to_append(self, flow: Flow) -> list[Stage]:
@ -179,7 +199,9 @@ class SourceFlowManager:
] ]
return [] return []
def _handle_login_flow(self, flow: Flow, **kwargs) -> HttpResponse: def _handle_login_flow(
self, flow: Flow, connection: UserSourceConnection, **kwargs
) -> HttpResponse:
"""Prepare Authentication Plan, redirect user FlowExecutor""" """Prepare Authentication Plan, redirect user FlowExecutor"""
# Ensure redirect is carried through when user was trying to # Ensure redirect is carried through when user was trying to
# authorize application # authorize application
@ -193,8 +215,10 @@ class SourceFlowManager:
PLAN_CONTEXT_SSO: True, PLAN_CONTEXT_SSO: True,
PLAN_CONTEXT_SOURCE: self.source, PLAN_CONTEXT_SOURCE: self.source,
PLAN_CONTEXT_REDIRECT: final_redirect, PLAN_CONTEXT_REDIRECT: final_redirect,
PLAN_CONTEXT_SOURCES_CONNECTION: connection,
} }
) )
kwargs.update(self.policy_context)
if not flow: if not flow:
return HttpResponseBadRequest() return HttpResponseBadRequest()
# We run the Flow planner here so we can pass the Pending user in the context # We run the Flow planner here so we can pass the Pending user in the context
@ -220,7 +244,7 @@ class SourceFlowManager:
_("Successfully authenticated with %(source)s!" % {"source": self.source.name}), _("Successfully authenticated with %(source)s!" % {"source": self.source.name}),
) )
flow_kwargs = {PLAN_CONTEXT_PENDING_USER: connection.user} flow_kwargs = {PLAN_CONTEXT_PENDING_USER: connection.user}
return self._handle_login_flow(self.source.authentication_flow, **flow_kwargs) return self._handle_login_flow(self.source.authentication_flow, connection, **flow_kwargs)
def handle_existing_user_link( def handle_existing_user_link(
self, self,
@ -264,8 +288,8 @@ class SourceFlowManager:
return HttpResponseBadRequest() return HttpResponseBadRequest()
return self._handle_login_flow( return self._handle_login_flow(
self.source.enrollment_flow, self.source.enrollment_flow,
connection,
**{ **{
PLAN_CONTEXT_PROMPT: delete_none_keys(self.enroll_info), PLAN_CONTEXT_PROMPT: delete_none_keys(self.enroll_info),
PLAN_CONTEXT_SOURCES_CONNECTION: connection,
}, },
) )

View File

@ -6,7 +6,6 @@ from os import environ
from boto3.exceptions import Boto3Error from boto3.exceptions import Boto3Error
from botocore.exceptions import BotoCoreError, ClientError from botocore.exceptions import BotoCoreError, ClientError
from dbbackup.db.exceptions import CommandConnectorError from dbbackup.db.exceptions import CommandConnectorError
from django.conf import settings
from django.contrib.humanize.templatetags.humanize import naturaltime from django.contrib.humanize.templatetags.humanize import naturaltime
from django.contrib.sessions.backends.cache import KEY_PREFIX from django.contrib.sessions.backends.cache import KEY_PREFIX
from django.core import management from django.core import management
@ -63,8 +62,6 @@ def should_backup() -> bool:
return False return False
if not CONFIG.y_bool("postgresql.backup.enabled"): if not CONFIG.y_bool("postgresql.backup.enabled"):
return False return False
if settings.DEBUG:
return False
return True return True

View File

@ -5,6 +5,8 @@
{% block head %} {% block head %}
<script src="{% static 'dist/admin/AdminInterface.js' %}" type="module"></script> <script src="{% static 'dist/admin/AdminInterface.js' %}" type="module"></script>
<meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
{% endblock %} {% endblock %}
{% block body %} {% block body %}

View File

@ -5,6 +5,8 @@
{% block head %} {% block head %}
<script src="{% static 'dist/user/UserInterface.js' %}" type="module"></script> <script src="{% static 'dist/user/UserInterface.js' %}" type="module"></script>
<meta name="theme-color" content="#151515" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#151515" media="(prefers-color-scheme: dark)">
{% endblock %} {% endblock %}
{% block body %} {% block body %}

View File

@ -1,6 +1,5 @@
"""Test Applications API""" """Test Applications API"""
from django.urls import reverse from django.urls import reverse
from django.utils.encoding import force_str
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from authentik.core.models import Application from authentik.core.models import Application
@ -32,7 +31,7 @@ class TestApplicationsAPI(APITestCase):
) )
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertJSONEqual(force_str(response.content), {"messages": [], "passing": True}) self.assertJSONEqual(response.content.decode(), {"messages": [], "passing": True})
response = self.client.get( response = self.client.get(
reverse( reverse(
"authentik_api:application-check-access", "authentik_api:application-check-access",
@ -40,14 +39,14 @@ class TestApplicationsAPI(APITestCase):
) )
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertJSONEqual(force_str(response.content), {"messages": ["dummy"], "passing": False}) self.assertJSONEqual(response.content.decode(), {"messages": ["dummy"], "passing": False})
def test_list(self): def test_list(self):
"""Test list operation without superuser_full_list""" """Test list operation without superuser_full_list"""
self.client.force_login(self.user) self.client.force_login(self.user)
response = self.client.get(reverse("authentik_api:application-list")) response = self.client.get(reverse("authentik_api:application-list"))
self.assertJSONEqual( self.assertJSONEqual(
force_str(response.content), response.content.decode(),
{ {
"pagination": { "pagination": {
"next": 0, "next": 0,
@ -83,7 +82,7 @@ class TestApplicationsAPI(APITestCase):
reverse("authentik_api:application-list") + "?superuser_full_list=true" reverse("authentik_api:application-list") + "?superuser_full_list=true"
) )
self.assertJSONEqual( self.assertJSONEqual(
force_str(response.content), response.content.decode(),
{ {
"pagination": { "pagination": {
"next": 0, "next": 0,

View File

@ -2,7 +2,6 @@
from json import loads from json import loads
from django.urls.base import reverse from django.urls.base import reverse
from django.utils.encoding import force_str
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from authentik.core.models import User from authentik.core.models import User
@ -28,5 +27,5 @@ class TestAuthenticatedSessionsAPI(APITestCase):
self.client.force_login(self.other_user) self.client.force_login(self.other_user)
response = self.client.get(reverse("authentik_api:authenticatedsession-list")) response = self.client.get(reverse("authentik_api:authenticatedsession-list"))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
body = loads(force_str(response.content)) body = loads(response.content.decode())
self.assertEqual(body["pagination"]["count"], 1) self.assertEqual(body["pagination"]["count"], 1)

View File

@ -1,6 +1,6 @@
"""authentik core models tests""" """authentik core models tests"""
from time import sleep from time import sleep
from typing import Callable, Type from typing import Callable
from django.test import RequestFactory, TestCase from django.test import RequestFactory, TestCase
from django.utils.timezone import now from django.utils.timezone import now
@ -27,7 +27,7 @@ class TestModels(TestCase):
self.assertFalse(token.is_expired) self.assertFalse(token.is_expired)
def source_tester_factory(test_model: Type[Stage]) -> Callable: def source_tester_factory(test_model: type[Stage]) -> Callable:
"""Test source""" """Test source"""
factory = RequestFactory() factory = RequestFactory()
@ -47,7 +47,7 @@ def source_tester_factory(test_model: Type[Stage]) -> Callable:
return tester return tester
def provider_tester_factory(test_model: Type[Stage]) -> Callable: def provider_tester_factory(test_model: type[Stage]) -> Callable:
"""Test provider""" """Test provider"""
def tester(self: TestModels): def tester(self: TestModels):

View File

@ -6,8 +6,12 @@ from guardian.utils import get_anonymous_user
from authentik.core.models import SourceUserMatchingModes, User from authentik.core.models import SourceUserMatchingModes, User
from authentik.core.sources.flow_manager import Action from authentik.core.sources.flow_manager import Action
from authentik.flows.models import Flow, FlowDesignation
from authentik.lib.generators import generate_id from authentik.lib.generators import generate_id
from authentik.lib.tests.utils import get_request from authentik.lib.tests.utils import get_request
from authentik.policies.denied import AccessDeniedResponse
from authentik.policies.expression.models import ExpressionPolicy
from authentik.policies.models import PolicyBinding
from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
from authentik.sources.oauth.views.callback import OAuthSourceFlowManager from authentik.sources.oauth.views.callback import OAuthSourceFlowManager
@ -17,7 +21,7 @@ class TestSourceFlowManager(TestCase):
def setUp(self) -> None: def setUp(self) -> None:
super().setUp() super().setUp()
self.source = OAuthSource.objects.create(name="test") self.source: OAuthSource = OAuthSource.objects.create(name="test")
self.factory = RequestFactory() self.factory = RequestFactory()
self.identifier = generate_id() self.identifier = generate_id()
@ -143,3 +147,34 @@ class TestSourceFlowManager(TestCase):
action, _ = flow_manager.get_action() action, _ = flow_manager.get_action()
self.assertEqual(action, Action.ENROLL) self.assertEqual(action, Action.ENROLL)
flow_manager.get_flow() flow_manager.get_flow()
def test_error_non_applicable_flow(self):
"""Test error handling when a source selected flow is non-applicable due to a policy"""
self.source.user_matching_mode = SourceUserMatchingModes.USERNAME_LINK
flow = Flow.objects.create(
name="test", slug="test", title="test", designation=FlowDesignation.ENROLLMENT
)
policy = ExpressionPolicy.objects.create(
name="false", expression="""ak_message("foo");return False"""
)
PolicyBinding.objects.create(
policy=policy,
target=flow,
order=0,
)
self.source.enrollment_flow = flow
self.source.save()
flow_manager = OAuthSourceFlowManager(
self.source,
get_request("/", user=AnonymousUser()),
self.identifier,
{"username": "foo"},
)
action, _ = flow_manager.get_action()
self.assertEqual(action, Action.ENROLL)
response = flow_manager.get_flow()
self.assertIsInstance(response, AccessDeniedResponse)
# pylint: disable=no-member
self.assertEqual(response.error_message, "foo")

View File

@ -2,9 +2,15 @@
from django.urls.base import reverse from django.urls.base import reverse
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from authentik.core.models import USER_ATTRIBUTE_CHANGE_EMAIL, USER_ATTRIBUTE_CHANGE_USERNAME, User from authentik.core.models import (
USER_ATTRIBUTE_CHANGE_EMAIL,
USER_ATTRIBUTE_CHANGE_NAME,
USER_ATTRIBUTE_CHANGE_USERNAME,
User,
)
from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_tenant from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_tenant
from authentik.flows.models import FlowDesignation from authentik.flows.models import FlowDesignation
from authentik.lib.generators import generate_key
from authentik.stages.email.models import EmailStage from authentik.stages.email.models import EmailStage
from authentik.tenants.models import Tenant from authentik.tenants.models import Tenant
@ -18,11 +24,28 @@ class TestUsersAPI(APITestCase):
def test_update_self(self): def test_update_self(self):
"""Test update_self""" """Test update_self"""
self.admin.attributes["foo"] = "bar"
self.admin.save()
self.admin.refresh_from_db()
self.client.force_login(self.admin) self.client.force_login(self.admin)
response = self.client.put( response = self.client.put(
reverse("authentik_api:user-update-self"), data={"username": "foo", "name": "foo"} reverse("authentik_api:user-update-self"), data={"username": "foo", "name": "foo"}
) )
self.admin.refresh_from_db()
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(self.admin.attributes["foo"], "bar")
self.assertEqual(self.admin.username, "foo")
self.assertEqual(self.admin.name, "foo")
def test_update_self_name_denied(self):
"""Test update_self"""
self.admin.attributes[USER_ATTRIBUTE_CHANGE_NAME] = False
self.admin.save()
self.client.force_login(self.admin)
response = self.client.put(
reverse("authentik_api:user-update-self"), data={"username": "foo", "name": "foo"}
)
self.assertEqual(response.status_code, 400)
def test_update_self_username_denied(self): def test_update_self_username_denied(self):
"""Test update_self""" """Test update_self"""
@ -68,6 +91,18 @@ class TestUsersAPI(APITestCase):
) )
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
def test_set_password(self):
"""Test Direct password set"""
self.client.force_login(self.admin)
new_pw = generate_key()
response = self.client.post(
reverse("authentik_api:user-set-password", kwargs={"pk": self.admin.pk}),
data={"password": new_pw},
)
self.assertEqual(response.status_code, 204)
self.admin.refresh_from_db()
self.assertTrue(self.admin.check_password(new_pw))
def test_recovery(self): def test_recovery(self):
"""Test user recovery link (no recovery flow set)""" """Test user recovery link (no recovery flow set)"""
flow = create_test_flow(FlowDesignation.RECOVERY) flow = create_test_flow(FlowDesignation.RECOVERY)

View File

@ -29,3 +29,4 @@ class UserSettingSerializer(PassiveSerializer):
component = CharField() component = CharField()
title = CharField() title = CharField()
configure_url = CharField(required=False) configure_url = CharField(required=False)
icon_url = CharField()

View File

@ -1,4 +1,6 @@
"""Crypto API Views""" """Crypto API Views"""
from typing import Optional
from cryptography.hazmat.backends import default_backend from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.serialization import load_pem_private_key from cryptography.hazmat.primitives.serialization import load_pem_private_key
from cryptography.x509 import load_pem_x509_certificate from cryptography.x509 import load_pem_x509_certificate
@ -31,6 +33,7 @@ class CertificateKeyPairSerializer(ModelSerializer):
cert_expiry = DateTimeField(source="certificate.not_valid_after", read_only=True) cert_expiry = DateTimeField(source="certificate.not_valid_after", read_only=True)
cert_subject = SerializerMethodField() cert_subject = SerializerMethodField()
private_key_available = SerializerMethodField() private_key_available = SerializerMethodField()
private_key_type = SerializerMethodField()
certificate_download_url = SerializerMethodField() certificate_download_url = SerializerMethodField()
private_key_download_url = SerializerMethodField() private_key_download_url = SerializerMethodField()
@ -43,6 +46,13 @@ class CertificateKeyPairSerializer(ModelSerializer):
"""Show if this keypair has a private key configured or not""" """Show if this keypair has a private key configured or not"""
return instance.key_data != "" and instance.key_data is not None return instance.key_data != "" and instance.key_data is not None
def get_private_key_type(self, instance: CertificateKeyPair) -> Optional[str]:
"""Get the private key's type, if set"""
key = instance.private_key
if key:
return key.__class__.__name__.replace("_", "").lower().replace("privatekey", "")
return None
def get_certificate_download_url(self, instance: CertificateKeyPair) -> str: def get_certificate_download_url(self, instance: CertificateKeyPair) -> str:
"""Get URL to download certificate""" """Get URL to download certificate"""
return ( return (
@ -72,7 +82,7 @@ class CertificateKeyPairSerializer(ModelSerializer):
return value return value
def validate_key_data(self, value: str) -> str: def validate_key_data(self, value: str) -> str:
"""Verify that input is a valid PEM RSA Key""" """Verify that input is a valid PEM Key"""
# Since this field is optional, data can be empty. # Since this field is optional, data can be empty.
if value != "": if value != "":
try: try:
@ -98,6 +108,7 @@ class CertificateKeyPairSerializer(ModelSerializer):
"cert_expiry", "cert_expiry",
"cert_subject", "cert_subject",
"private_key_available", "private_key_available",
"private_key_type",
"certificate_download_url", "certificate_download_url",
"private_key_download_url", "private_key_download_url",
"managed", "managed",

View File

@ -44,7 +44,7 @@ class CertificateBuilder:
"""Build self-signed certificate""" """Build self-signed certificate"""
one_day = datetime.timedelta(1, 0, 0) one_day = datetime.timedelta(1, 0, 0)
self.__private_key = rsa.generate_private_key( self.__private_key = rsa.generate_private_key(
public_exponent=65537, key_size=2048, backend=default_backend() public_exponent=65537, key_size=4096, backend=default_backend()
) )
self.__public_key = self.__private_key.public_key() self.__public_key = self.__private_key.public_key()
alt_names: list[x509.GeneralName] = [x509.DNSName(x) for x in subject_alt_names or []] alt_names: list[x509.GeneralName] = [x509.DNSName(x) for x in subject_alt_names or []]

View File

@ -6,6 +6,11 @@ from uuid import uuid4
from cryptography.hazmat.backends import default_backend from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric.ec import (
EllipticCurvePrivateKey,
EllipticCurvePublicKey,
)
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey
from cryptography.hazmat.primitives.serialization import load_pem_private_key from cryptography.hazmat.primitives.serialization import load_pem_private_key
from cryptography.x509 import Certificate, load_pem_x509_certificate from cryptography.x509 import Certificate, load_pem_x509_certificate
@ -36,8 +41,8 @@ class CertificateKeyPair(ManagedModel, CreatedUpdatedModel):
) )
_cert: Optional[Certificate] = None _cert: Optional[Certificate] = None
_private_key: Optional[RSAPrivateKey] = None _private_key: Optional[RSAPrivateKey | EllipticCurvePrivateKey | Ed25519PrivateKey] = None
_public_key: Optional[RSAPublicKey] = None _public_key: Optional[RSAPublicKey | EllipticCurvePublicKey | Ed25519PublicKey] = None
@property @property
def certificate(self) -> Certificate: def certificate(self) -> Certificate:
@ -49,14 +54,16 @@ class CertificateKeyPair(ManagedModel, CreatedUpdatedModel):
return self._cert return self._cert
@property @property
def public_key(self) -> Optional[RSAPublicKey]: def public_key(self) -> Optional[RSAPublicKey | EllipticCurvePublicKey | Ed25519PublicKey]:
"""Get public key of the private key""" """Get public key of the private key"""
if not self._public_key: if not self._public_key:
self._public_key = self.private_key.public_key() self._public_key = self.private_key.public_key()
return self._public_key return self._public_key
@property @property
def private_key(self) -> Optional[RSAPrivateKey]: def private_key(
self,
) -> Optional[RSAPrivateKey | EllipticCurvePrivateKey | Ed25519PrivateKey]:
"""Get python cryptography PrivateKey instance""" """Get python cryptography PrivateKey instance"""
if not self._private_key and self.key_data != "": if not self._private_key and self.key_data != "":
try: try:

View File

@ -24,7 +24,7 @@ MANAGED_DISCOVERED = "goauthentik.io/crypto/discovered/%s"
def ensure_private_key_valid(body: str): def ensure_private_key_valid(body: str):
"""Attempt loading of an RSA Private key without password""" """Attempt loading of a PEM Private key without password"""
load_pem_private_key( load_pem_private_key(
str.encode("\n".join([x.strip() for x in body.split("\n")])), str.encode("\n".join([x.strip() for x in body.split("\n")])),
password=None, password=None,
@ -42,7 +42,7 @@ def ensure_certificate_valid(body: str):
@CELERY_APP.task(bind=True, base=MonitoredTask) @CELERY_APP.task(bind=True, base=MonitoredTask)
@prefill_task @prefill_task
def certificate_discovery(self: MonitoredTask): def certificate_discovery(self: MonitoredTask):
"""Discover and update certificates form the filesystem""" """Discover, import and update certificates from the filesystem"""
certs = {} certs = {}
private_keys = {} private_keys = {}
discovered = 0 discovered = 0
@ -52,6 +52,9 @@ def certificate_discovery(self: MonitoredTask):
continue continue
if path.is_dir(): if path.is_dir():
continue continue
# For certbot setups, we want to ignore archive.
if "archive" in file:
continue
# Support certbot's directory structure # Support certbot's directory structure
if path.name in ["fullchain.pem", "privkey.pem"]: if path.name in ["fullchain.pem", "privkey.pem"]:
cert_name = path.parent.name cert_name = path.parent.name
@ -60,7 +63,7 @@ def certificate_discovery(self: MonitoredTask):
try: try:
with open(path, "r+", encoding="utf-8") as _file: with open(path, "r+", encoding="utf-8") as _file:
body = _file.read() body = _file.read()
if "BEGIN RSA PRIVATE KEY" in body: if "PRIVATE KEY" in body:
private_keys[cert_name] = ensure_private_key_valid(body) private_keys[cert_name] = ensure_private_key_valid(body)
else: else:
certs[cert_name] = ensure_certificate_valid(body) certs[cert_name] = ensure_certificate_valid(body)

View File

@ -146,7 +146,7 @@ class TestCrypto(APITestCase):
client_secret=generate_key(), client_secret=generate_key(),
authorization_flow=create_test_flow(), authorization_flow=create_test_flow(),
redirect_uris="http://localhost", redirect_uris="http://localhost",
rsa_key=keypair, signing_key=keypair,
) )
response = self.client.get( response = self.client.get(
reverse( reverse(

View File

@ -15,12 +15,14 @@ from authentik.api.decorators import permission_required
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import PassiveSerializer from authentik.core.api.utils import PassiveSerializer
from authentik.events.models import ( from authentik.events.models import (
Event,
Notification, Notification,
NotificationSeverity, NotificationSeverity,
NotificationTransport, NotificationTransport,
NotificationTransportError, NotificationTransportError,
TransportMode, TransportMode,
) )
from authentik.events.utils import get_user
class NotificationTransportSerializer(ModelSerializer): class NotificationTransportSerializer(ModelSerializer):
@ -86,6 +88,12 @@ class NotificationTransportViewSet(UsedByMixin, ModelViewSet):
severity=NotificationSeverity.NOTICE, severity=NotificationSeverity.NOTICE,
body=f"Test Notification from transport {transport.name}", body=f"Test Notification from transport {transport.name}",
user=request.user, user=request.user,
event=Event(
action="Test",
user=get_user(request.user),
app=self.__class__.__module__,
context={"foo": "bar"},
),
) )
try: try:
response = NotificationTransportTestSerializer( response = NotificationTransportTestSerializer(

View File

@ -35,12 +35,11 @@ class GeoIPReader:
def __open(self): def __open(self):
"""Get GeoIP Reader, if configured, otherwise none""" """Get GeoIP Reader, if configured, otherwise none"""
path = CONFIG.y("authentik.geoip") path = CONFIG.y("geoip")
if path == "" or not path: if path == "" or not path:
return return
try: try:
reader = Reader(path) self.__reader = Reader(path)
self.__reader = reader
self.__last_mtime = stat(path).st_mtime self.__last_mtime = stat(path).st_mtime
LOGGER.info("Loaded GeoIP database", last_write=self.__last_mtime) LOGGER.info("Loaded GeoIP database", last_write=self.__last_mtime)
except OSError as exc: except OSError as exc:

View File

@ -19,7 +19,7 @@ def convert_user_to_json(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
Event = apps.get_model("authentik_events", "Event") Event = apps.get_model("authentik_events", "Event")
db_alias = schema_editor.connection.alias db_alias = schema_editor.connection.alias
for event in Event.objects.all(): for event in Event.objects.using(db_alias).all():
event.delete() event.delete()
# Because event objects cannot be updated, we have to re-create them # Because event objects cannot be updated, we have to re-create them
event.pk = None event.pk = None

View File

@ -10,7 +10,7 @@ def convert_user_to_json(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
Event = apps.get_model("authentik_events", "Event") Event = apps.get_model("authentik_events", "Event")
db_alias = schema_editor.connection.alias db_alias = schema_editor.connection.alias
for event in Event.objects.all(): for event in Event.objects.using(db_alias).all():
event.delete() event.delete()
# Because event objects cannot be updated, we have to re-create them # Because event objects cannot be updated, we have to re-create them
event.pk = None event.pk = None

View File

@ -4,7 +4,7 @@ from collections import Counter
from datetime import timedelta from datetime import timedelta
from inspect import currentframe from inspect import currentframe
from smtplib import SMTPException from smtplib import SMTPException
from typing import TYPE_CHECKING, Optional, Type, Union from typing import TYPE_CHECKING, Optional
from uuid import uuid4 from uuid import uuid4
from django.conf import settings from django.conf import settings
@ -190,7 +190,7 @@ class Event(ExpiringModel):
@staticmethod @staticmethod
def new( def new(
action: Union[str, EventAction], action: str | EventAction,
app: Optional[str] = None, app: Optional[str] = None,
**kwargs, **kwargs,
) -> "Event": ) -> "Event":
@ -517,7 +517,7 @@ class NotificationWebhookMapping(PropertyMapping):
return "ak-property-mapping-notification-form" return "ak-property-mapping-notification-form"
@property @property
def serializer(self) -> Type["Serializer"]: def serializer(self) -> type["Serializer"]:
from authentik.events.api.notification_mapping import NotificationWebhookMappingSerializer from authentik.events.api.notification_mapping import NotificationWebhookMappingSerializer
return NotificationWebhookMappingSerializer return NotificationWebhookMappingSerializer

View File

@ -72,7 +72,7 @@ class WithUserInfoChallenge(Challenge):
pending_user_avatar = CharField() pending_user_avatar = CharField()
class AccessDeniedChallenge(Challenge): class AccessDeniedChallenge(WithUserInfoChallenge):
"""Challenge when a flow's active stage calls `stage_invalid()`.""" """Challenge when a flow's active stage calls `stage_invalid()`."""
error_message = CharField(required=False) error_message = CharField(required=False)

View File

@ -1,11 +1,14 @@
"""flow exceptions""" """flow exceptions"""
from authentik.lib.sentry import SentryIgnoredException from authentik.lib.sentry import SentryIgnoredException
from authentik.policies.types import PolicyResult
class FlowNonApplicableException(SentryIgnoredException): class FlowNonApplicableException(SentryIgnoredException):
"""Flow does not apply to current user (denied by policy).""" """Flow does not apply to current user (denied by policy)."""
policy_result: PolicyResult
class EmptyFlowException(SentryIgnoredException): class EmptyFlowException(SentryIgnoredException):
"""Flow has no stages.""" """Flow has no stages."""

View File

@ -10,8 +10,8 @@ def add_title_for_defaults(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
"default-invalidation-flow": "Default Invalidation Flow", "default-invalidation-flow": "Default Invalidation Flow",
"default-source-enrollment": "Welcome to authentik! Please select a username.", "default-source-enrollment": "Welcome to authentik! Please select a username.",
"default-source-authentication": "Welcome to authentik!", "default-source-authentication": "Welcome to authentik!",
"default-provider-authorization-implicit-consent": "Default Provider Authorization Flow (implicit consent)", "default-provider-authorization-implicit-consent": "Redirecting to %(app)s",
"default-provider-authorization-explicit-consent": "Default Provider Authorization Flow (explicit consent)", "default-provider-authorization-explicit-consent": "Redirecting to %(app)s",
"default-password-change": "Change password", "default-password-change": "Change password",
} }
db_alias = schema_editor.connection.alias db_alias = schema_editor.connection.alias

View File

@ -0,0 +1,27 @@
# Generated by Django 4.0 on 2021-12-27 21:03
from django.apps.registry import Apps
from django.db import migrations, models
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
def update_title_for_defaults(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
slug_title_map = {
"default-provider-authorization-implicit-consent": "Redirecting to %(app)s",
"default-provider-authorization-explicit-consent": "Redirecting to %(app)s",
}
db_alias = schema_editor.connection.alias
Flow = apps.get_model("authentik_flows", "Flow")
for flow in Flow.objects.using(db_alias).all():
if flow.slug not in slug_title_map:
continue
flow.title = slug_title_map[flow.slug]
flow.save()
class Migration(migrations.Migration):
dependencies = [
("authentik_flows", "0020_flowtoken"),
]
operations = [migrations.RunPython(update_title_for_defaults)]

View File

@ -1,7 +1,7 @@
"""Flow models""" """Flow models"""
from base64 import b64decode, b64encode from base64 import b64decode, b64encode
from pickle import dumps, loads # nosec from pickle import dumps, loads # nosec
from typing import TYPE_CHECKING, Optional, Type from typing import TYPE_CHECKING, Optional
from uuid import uuid4 from uuid import uuid4
from django.db import models from django.db import models
@ -63,7 +63,7 @@ class Stage(SerializerModel):
objects = InheritanceManager() objects = InheritanceManager()
@property @property
def type(self) -> Type["StageView"]: def type(self) -> type["StageView"]:
"""Return StageView class that implements logic for this stage""" """Return StageView class that implements logic for this stage"""
# This is a bit of a workaround, since we can't set class methods with setattr # This is a bit of a workaround, since we can't set class methods with setattr
if hasattr(self, "__in_memory_type"): if hasattr(self, "__in_memory_type"):
@ -86,7 +86,7 @@ class Stage(SerializerModel):
return f"Stage {self.name}" return f"Stage {self.name}"
def in_memory_stage(view: Type["StageView"]) -> Stage: def in_memory_stage(view: type["StageView"]) -> Stage:
"""Creates an in-memory stage instance, based on a `view` as view.""" """Creates an in-memory stage instance, based on a `view` as view."""
stage = Stage() stage = Stage()
# Because we can't pickle a locally generated function, # Because we can't pickle a locally generated function,

View File

@ -152,7 +152,9 @@ class FlowPlanner:
engine.build() engine.build()
result = engine.result result = engine.result
if not result.passing: if not result.passing:
raise FlowNonApplicableException(",".join(result.messages)) exc = FlowNonApplicableException(",".join(result.messages))
exc.policy_result = result
raise exc
# User is passing so far, check if we have a cached plan # User is passing so far, check if we have a cached plan
cached_plan_key = cache_key(self.flow, user) cached_plan_key = cache_key(self.flow, user)
cached_plan = cache.get(cached_plan_key, None) cached_plan = cache.get(cached_plan_key, None)

View File

@ -1,4 +1,6 @@
"""authentik stage Base view""" """authentik stage Base view"""
from typing import TYPE_CHECKING, Optional
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.http import HttpRequest from django.http import HttpRequest
from django.http.request import QueryDict from django.http.request import QueryDict
@ -11,15 +13,19 @@ from structlog.stdlib import get_logger
from authentik.core.models import DEFAULT_AVATAR, User from authentik.core.models import DEFAULT_AVATAR, User
from authentik.flows.challenge import ( from authentik.flows.challenge import (
AccessDeniedChallenge,
Challenge, Challenge,
ChallengeResponse, ChallengeResponse,
ChallengeTypes,
ContextualFlowInfo, ContextualFlowInfo,
HttpChallengeResponse, HttpChallengeResponse,
WithUserInfoChallenge, WithUserInfoChallenge,
) )
from authentik.flows.models import InvalidResponseAction from authentik.flows.models import InvalidResponseAction
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_PENDING_USER from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_PENDING_USER
from authentik.flows.views.executor import FlowExecutorView
if TYPE_CHECKING:
from authentik.flows.views.executor import FlowExecutorView
PLAN_CONTEXT_PENDING_USER_IDENTIFIER = "pending_user_identifier" PLAN_CONTEXT_PENDING_USER_IDENTIFIER = "pending_user_identifier"
LOGGER = get_logger() LOGGER = get_logger()
@ -28,11 +34,11 @@ LOGGER = get_logger()
class StageView(View): class StageView(View):
"""Abstract Stage, inherits TemplateView but can be combined with FormView""" """Abstract Stage, inherits TemplateView but can be combined with FormView"""
executor: FlowExecutorView executor: "FlowExecutorView"
request: HttpRequest = None request: HttpRequest = None
def __init__(self, executor: FlowExecutorView, **kwargs): def __init__(self, executor: "FlowExecutorView", **kwargs):
self.executor = executor self.executor = executor
super().__init__(**kwargs) super().__init__(**kwargs)
@ -43,6 +49,8 @@ class StageView(View):
other things besides the form display. other things besides the form display.
If no user is pending, returns request.user""" If no user is pending, returns request.user"""
if not self.executor.plan:
return self.request.user
if PLAN_CONTEXT_PENDING_USER_IDENTIFIER in self.executor.plan.context and for_display: if PLAN_CONTEXT_PENDING_USER_IDENTIFIER in self.executor.plan.context and for_display:
return User( return User(
username=self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER_IDENTIFIER), username=self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER_IDENTIFIER),
@ -108,6 +116,8 @@ class ChallengeStageView(StageView):
def format_title(self) -> str: def format_title(self) -> str:
"""Allow usage of placeholder in flow title.""" """Allow usage of placeholder in flow title."""
if not self.executor.plan:
return self.executor.flow.title
return self.executor.flow.title % { return self.executor.flow.title % {
"app": self.executor.plan.context.get(PLAN_CONTEXT_APPLICATION, "") "app": self.executor.plan.context.get(PLAN_CONTEXT_APPLICATION, "")
} }
@ -169,3 +179,27 @@ class ChallengeStageView(StageView):
stage_view=self, stage_view=self,
) )
return HttpChallengeResponse(challenge_response) return HttpChallengeResponse(challenge_response)
class AccessDeniedChallengeView(ChallengeStageView):
"""Used internally by FlowExecutor's stage_invalid()"""
error_message: Optional[str]
def __init__(self, executor: "FlowExecutorView", error_message: Optional[str] = None, **kwargs):
super().__init__(executor, **kwargs)
self.error_message = error_message
def get_challenge(self, *args, **kwargs) -> Challenge:
return AccessDeniedChallenge(
data={
"error_message": self.error_message or "Unknown error",
"type": ChallengeTypes.NATIVE.value,
"component": "ak-stage-access-denied",
}
)
# This can never be reached since this challenge is created on demand and only the
# .get() method is called
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse: # pragma: no cover
return self.executor.cancel()

View File

@ -0,0 +1,51 @@
"""Test helpers"""
from json import loads
from typing import Any, Optional
from django.http.response import HttpResponse
from django.urls.base import reverse
from rest_framework.test import APITestCase
from authentik.core.models import User
from authentik.flows.challenge import ChallengeTypes
from authentik.flows.models import Flow
class FlowTestCase(APITestCase):
"""Helpers for testing flows and stages."""
# pylint: disable=invalid-name
def assertStageResponse(
self,
response: HttpResponse,
flow: Optional[Flow] = None,
user: Optional[User] = None,
**kwargs,
) -> dict[str, Any]:
"""Assert various attributes of a stage response"""
raw_response = loads(response.content.decode())
self.assertIsNotNone(raw_response["component"])
self.assertIsNotNone(raw_response["type"])
if flow:
self.assertIn("flow_info", raw_response)
self.assertEqual(raw_response["flow_info"]["background"], flow.background_url)
self.assertEqual(
raw_response["flow_info"]["cancel_url"], reverse("authentik_flows:cancel")
)
# We don't check the flow title since it will most likely go
# through ChallengeStageView.format_title() so might not match 1:1
# self.assertEqual(raw_response["flow_info"]["title"], flow.title)
self.assertIsNotNone(raw_response["flow_info"]["title"])
if user:
self.assertEqual(raw_response["pending_user"], user.username)
self.assertEqual(raw_response["pending_user_avatar"], user.avatar)
for key, expected in kwargs.items():
self.assertEqual(raw_response[key], expected)
return raw_response
# pylint: disable=invalid-name
def assertStageRedirects(self, response: HttpResponse, to: str) -> dict[str, Any]:
"""Wrapper around assertStageResponse that checks for a redirect"""
return self.assertStageResponse(
response, component="xak-flow-redirect", to=to, type=ChallengeTypes.REDIRECT.value
)

View File

@ -4,16 +4,14 @@ from unittest.mock import MagicMock, PropertyMock, patch
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.test.client import RequestFactory from django.test.client import RequestFactory
from django.urls import reverse from django.urls import reverse
from django.utils.encoding import force_str
from rest_framework.test import APITestCase
from authentik.core.models import User from authentik.core.models import User
from authentik.flows.challenge import ChallengeTypes
from authentik.flows.exceptions import FlowNonApplicableException from authentik.flows.exceptions import FlowNonApplicableException
from authentik.flows.markers import ReevaluateMarker, StageMarker from authentik.flows.markers import ReevaluateMarker, StageMarker
from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding, InvalidResponseAction from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding, InvalidResponseAction
from authentik.flows.planner import FlowPlan, FlowPlanner from authentik.flows.planner import FlowPlan, FlowPlanner
from authentik.flows.stage import PLAN_CONTEXT_PENDING_USER_IDENTIFIER, StageView from authentik.flows.stage import PLAN_CONTEXT_PENDING_USER_IDENTIFIER, StageView
from authentik.flows.tests import FlowTestCase
from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_PLAN, FlowExecutorView from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_PLAN, FlowExecutorView
from authentik.lib.config import CONFIG from authentik.lib.config import CONFIG
from authentik.policies.dummy.models import DummyPolicy from authentik.policies.dummy.models import DummyPolicy
@ -37,7 +35,7 @@ def to_stage_response(request: HttpRequest, source: HttpResponse):
TO_STAGE_RESPONSE_MOCK = MagicMock(side_effect=to_stage_response) TO_STAGE_RESPONSE_MOCK = MagicMock(side_effect=to_stage_response)
class TestFlowExecutor(APITestCase): class TestFlowExecutor(FlowTestCase):
"""Test executor""" """Test executor"""
def setUp(self): def setUp(self):
@ -90,18 +88,11 @@ class TestFlowExecutor(APITestCase):
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertJSONEqual( self.assertStageResponse(
force_str(response.content), response,
{ flow=flow,
"component": "ak-stage-access-denied", error_message=FlowNonApplicableException.__doc__,
"error_message": FlowNonApplicableException.__doc__, component="ak-stage-access-denied",
"flow_info": {
"background": flow.background_url,
"cancel_url": reverse("authentik_flows:cancel"),
"title": "",
},
"type": ChallengeTypes.NATIVE.value,
},
) )
@patch( @patch(
@ -283,14 +274,7 @@ class TestFlowExecutor(APITestCase):
# We do this request without the patch, so the policy results in false # We do this request without the patch, so the policy results in false
response = self.client.post(exec_url) response = self.client.post(exec_url)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertJSONEqual( self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))
force_str(response.content),
{
"component": "xak-flow-redirect",
"to": reverse("authentik_core:root-redirect"),
"type": ChallengeTypes.REDIRECT.value,
},
)
def test_reevaluate_keep(self): def test_reevaluate_keep(self):
"""Test planner with re-evaluate (everything is kept)""" """Test planner with re-evaluate (everything is kept)"""
@ -360,14 +344,7 @@ class TestFlowExecutor(APITestCase):
# We do this request without the patch, so the policy results in false # We do this request without the patch, so the policy results in false
response = self.client.post(exec_url) response = self.client.post(exec_url)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertJSONEqual( self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))
force_str(response.content),
{
"component": "xak-flow-redirect",
"to": reverse("authentik_core:root-redirect"),
"type": ChallengeTypes.REDIRECT.value,
},
)
def test_reevaluate_remove_consecutive(self): def test_reevaluate_remove_consecutive(self):
"""Test planner with re-evaluate (consecutive stages are removed)""" """Test planner with re-evaluate (consecutive stages are removed)"""
@ -407,18 +384,7 @@ class TestFlowExecutor(APITestCase):
# First request, run the planner # First request, run the planner
response = self.client.get(exec_url) response = self.client.get(exec_url)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertJSONEqual( self.assertStageResponse(response, flow, component="ak-stage-dummy")
force_str(response.content),
{
"type": ChallengeTypes.NATIVE.value,
"component": "ak-stage-dummy",
"flow_info": {
"background": flow.background_url,
"cancel_url": reverse("authentik_flows:cancel"),
"title": "",
},
},
)
plan: FlowPlan = self.client.session[SESSION_KEY_PLAN] plan: FlowPlan = self.client.session[SESSION_KEY_PLAN]
@ -441,31 +407,13 @@ class TestFlowExecutor(APITestCase):
# but it won't save it, hence we can't check the plan # but it won't save it, hence we can't check the plan
response = self.client.get(exec_url) response = self.client.get(exec_url)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertJSONEqual( self.assertStageResponse(response, flow, component="ak-stage-dummy")
force_str(response.content),
{
"type": ChallengeTypes.NATIVE.value,
"component": "ak-stage-dummy",
"flow_info": {
"background": flow.background_url,
"cancel_url": reverse("authentik_flows:cancel"),
"title": "",
},
},
)
# fourth request, this confirms the last stage (dummy4) # fourth request, this confirms the last stage (dummy4)
# We do this request without the patch, so the policy results in false # We do this request without the patch, so the policy results in false
response = self.client.post(exec_url) response = self.client.post(exec_url)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertJSONEqual( self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))
force_str(response.content),
{
"component": "xak-flow-redirect",
"to": reverse("authentik_core:root-redirect"),
"type": ChallengeTypes.REDIRECT.value,
},
)
def test_stageview_user_identifier(self): def test_stageview_user_identifier(self):
"""Test PLAN_CONTEXT_PENDING_USER_IDENTIFIER""" """Test PLAN_CONTEXT_PENDING_USER_IDENTIFIER"""
@ -532,35 +480,16 @@ class TestFlowExecutor(APITestCase):
# First request, run the planner # First request, run the planner
response = self.client.get(exec_url) response = self.client.get(exec_url)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertJSONEqual( self.assertStageResponse(
force_str(response.content), response,
{ flow,
"type": ChallengeTypes.NATIVE.value, component="ak-stage-identification",
"component": "ak-stage-identification", password_fields=False,
"flow_info": { primary_action="Log in",
"background": flow.background_url, sources=[],
"cancel_url": reverse("authentik_flows:cancel"), show_source_labels=False,
"title": "", user_fields=[UserFields.E_MAIL],
},
"password_fields": False,
"primary_action": "Log in",
"sources": [],
"show_source_labels": False,
"user_fields": [UserFields.E_MAIL],
},
) )
response = self.client.post(exec_url, {"uid_field": "invalid-string"}, follow=True) response = self.client.post(exec_url, {"uid_field": "invalid-string"}, follow=True)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertJSONEqual( self.assertStageResponse(response, flow, component="ak-stage-access-denied")
force_str(response.content),
{
"component": "ak-stage-access-denied",
"error_message": None,
"flow_info": {
"background": flow.background_url,
"cancel_url": reverse("authentik_flows:cancel"),
"title": "",
},
"type": ChallengeTypes.NATIVE.value,
},
)

View File

@ -1,5 +1,5 @@
"""base model tests""" """base model tests"""
from typing import Callable, Type from typing import Callable
from django.test import TestCase from django.test import TestCase
@ -12,7 +12,7 @@ class TestModels(TestCase):
"""Generic model properties tests""" """Generic model properties tests"""
def model_tester_factory(test_model: Type[Stage]) -> Callable: def model_tester_factory(test_model: type[Stage]) -> Callable:
"""Test a form""" """Test a form"""
def tester(self: TestModels): def tester(self: TestModels):

View File

@ -1,5 +1,5 @@
"""stage view tests""" """stage view tests"""
from typing import Callable, Type from typing import Callable
from django.test import RequestFactory, TestCase from django.test import RequestFactory, TestCase
@ -16,7 +16,7 @@ class TestViews(TestCase):
self.exec = FlowExecutorView(request=self.factory.get("/")) self.exec = FlowExecutorView(request=self.factory.get("/"))
def view_tester_factory(view_class: Type[StageView]) -> Callable: def view_tester_factory(view_class: type[StageView]) -> Callable:
"""Test a form""" """Test a form"""
def tester(self: TestViews): def tester(self: TestViews):

View File

@ -2,7 +2,7 @@
from contextlib import contextmanager from contextlib import contextmanager
from copy import deepcopy from copy import deepcopy
from json import loads from json import loads
from typing import Any, Type from typing import Any
from dacite import from_dict from dacite import from_dict
from dacite.exceptions import DaciteError from dacite.exceptions import DaciteError
@ -87,7 +87,7 @@ class FlowImporter:
def _validate_single(self, entry: FlowBundleEntry) -> BaseSerializer: def _validate_single(self, entry: FlowBundleEntry) -> BaseSerializer:
"""Validate a single entry""" """Validate a single entry"""
model_app_label, model_name = entry.model.split(".") model_app_label, model_name = entry.model.split(".")
model: Type[SerializerModel] = apps.get_model(model_app_label, model_name) model: type[SerializerModel] = apps.get_model(model_app_label, model_name)
if not isinstance(model(), ALLOWED_MODELS): if not isinstance(model(), ALLOWED_MODELS):
raise EntryInvalidError(f"Model {model} not allowed") raise EntryInvalidError(f"Model {model} not allowed")

View File

@ -10,7 +10,6 @@ from django.http import Http404, HttpRequest, HttpResponse, HttpResponseRedirect
from django.http.request import QueryDict from django.http.request import QueryDict
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.urls.base import reverse
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.clickjacking import xframe_options_sameorigin from django.views.decorators.clickjacking import xframe_options_sameorigin
from django.views.generic import View from django.views.generic import View
@ -26,7 +25,6 @@ from structlog.stdlib import BoundLogger, get_logger
from authentik.core.models import USER_ATTRIBUTE_DEBUG from authentik.core.models import USER_ATTRIBUTE_DEBUG
from authentik.events.models import Event, EventAction, cleanse_dict from authentik.events.models import Event, EventAction, cleanse_dict
from authentik.flows.challenge import ( from authentik.flows.challenge import (
AccessDeniedChallenge,
Challenge, Challenge,
ChallengeResponse, ChallengeResponse,
ChallengeTypes, ChallengeTypes,
@ -51,6 +49,7 @@ from authentik.flows.planner import (
FlowPlan, FlowPlan,
FlowPlanner, FlowPlanner,
) )
from authentik.flows.stage import AccessDeniedChallengeView
from authentik.lib.sentry import SentryIgnoredException from authentik.lib.sentry import SentryIgnoredException
from authentik.lib.utils.errors import exception_to_string from authentik.lib.utils.errors import exception_to_string
from authentik.lib.utils.reflection import all_subclasses, class_to_path from authentik.lib.utils.reflection import all_subclasses, class_to_path
@ -371,12 +370,6 @@ class FlowExecutorView(APIView):
NEXT_ARG_NAME, "authentik_core:root-redirect" NEXT_ARG_NAME, "authentik_core:root-redirect"
) )
self.cancel() self.cancel()
Event.new(
action=EventAction.FLOW_EXECUTION,
flow=self.flow,
designation=self.flow.designation,
successful=True,
).from_http(self.request)
return to_stage_response(self.request, redirect_with_qs(next_param)) return to_stage_response(self.request, redirect_with_qs(next_param))
def stage_ok(self) -> HttpResponse: def stage_ok(self) -> HttpResponse:
@ -412,21 +405,9 @@ class FlowExecutorView(APIView):
is a superuser.""" is a superuser."""
self._logger.debug("f(exec): Stage invalid") self._logger.debug("f(exec): Stage invalid")
self.cancel() self.cancel()
response = HttpChallengeResponse( challenge_view = AccessDeniedChallengeView(self, error_message)
AccessDeniedChallenge( challenge_view.request = self.request
{ return to_stage_response(self.request, challenge_view.get(self.request))
"error_message": error_message,
"type": ChallengeTypes.NATIVE.value,
"component": "ak-stage-access-denied",
"flow_info": {
"title": self.flow.title,
"background": self.flow.background_url,
"cancel_url": reverse("authentik_flows:cancel"),
},
}
)
)
return to_stage_response(self.request, response)
def cancel(self): def cancel(self):
"""Cancel current execution and return a redirect""" """Cancel current execution and return a redirect"""

View File

@ -78,6 +78,7 @@ footer_links:
- name: authentik Website - name: authentik Website
href: https://goauthentik.io/?utm_source=authentik href: https://goauthentik.io/?utm_source=authentik
default_user_change_name: true
default_user_change_email: true default_user_change_email: true
default_user_change_username: true default_user_change_username: true

View File

@ -97,7 +97,7 @@ def before_send(event: dict, hint: dict) -> Optional[dict]:
if "exc_info" in hint: if "exc_info" in hint:
_, exc_value, _ = hint["exc_info"] _, exc_value, _ = hint["exc_info"]
if isinstance(exc_value, ignored_classes): if isinstance(exc_value, ignored_classes):
LOGGER.debug("dropping exception", exception=exc_value) LOGGER.debug("dropping exception", exc=exc_value)
return None return None
if "logger" in event: if "logger" in event:
if event["logger"] in [ if event["logger"] in [
@ -114,6 +114,6 @@ def before_send(event: dict, hint: dict) -> Optional[dict]:
]: ]:
return None return None
LOGGER.debug("sending event to sentry", exc=exc_value, source_logger=event.get("logger", None)) LOGGER.debug("sending event to sentry", exc=exc_value, source_logger=event.get("logger", None))
if settings.DEBUG: if settings.DEBUG or settings.TEST:
return None return None
return event return event

View File

@ -13,4 +13,4 @@ class TestSentry(TestCase):
def test_error_sent(self): def test_error_sent(self):
"""Test error sent""" """Test error sent"""
self.assertEqual({}, before_send({}, {"exc_info": (0, ValueError(), 0)})) self.assertEqual(None, before_send({}, {"exc_info": (0, ValueError(), 0)}))

View File

@ -1,5 +1,5 @@
"""base model tests""" """base model tests"""
from typing import Callable, Type from typing import Callable
from django.test import TestCase from django.test import TestCase
from rest_framework.serializers import BaseSerializer from rest_framework.serializers import BaseSerializer
@ -13,7 +13,7 @@ class TestModels(TestCase):
"""Generic model properties tests""" """Generic model properties tests"""
def model_tester_factory(test_model: Type[Stage]) -> Callable: def model_tester_factory(test_model: type[Stage]) -> Callable:
"""Test a form""" """Test a form"""
def tester(self: TestModels): def tester(self: TestModels):

View File

@ -2,7 +2,6 @@
import os import os
from importlib import import_module from importlib import import_module
from pathlib import Path from pathlib import Path
from typing import Union
from django.conf import settings from django.conf import settings
from kubernetes.config.incluster_config import SERVICE_HOST_ENV_NAME from kubernetes.config.incluster_config import SERVICE_HOST_ENV_NAME
@ -30,7 +29,7 @@ def class_to_path(cls: type) -> str:
return f"{cls.__module__}.{cls.__name__}" return f"{cls.__module__}.{cls.__name__}"
def path_to_class(path: Union[str, None]) -> Union[type, None]: def path_to_class(path: str | None) -> type | None:
"""Import module and return class""" """Import module and return class"""
if not path: if not path:
return None return None

View File

@ -34,7 +34,7 @@ def timedelta_from_string(expr: str) -> datetime.timedelta:
key, value = duration_pair.split("=") key, value = duration_pair.split("=")
if key.lower() not in ALLOWED_KEYS: if key.lower() not in ALLOWED_KEYS:
continue continue
kwargs[key.lower()] = float(value) kwargs[key.lower()] = float(value.strip())
if len(kwargs) < 1: if len(kwargs) < 1:
raise ValueError("No valid keys to pass to timedelta") raise ValueError("No valid keys to pass to timedelta")
return datetime.timedelta(**kwargs) return datetime.timedelta(**kwargs)

View File

@ -1,5 +1,5 @@
"""Managed objects manager""" """Managed objects manager"""
from typing import Callable, Optional, Type from typing import Callable, Optional
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
@ -11,11 +11,11 @@ LOGGER = get_logger()
class EnsureOp: class EnsureOp:
"""Ensure operation, executed as part of an ObjectManager run""" """Ensure operation, executed as part of an ObjectManager run"""
_obj: Type[ManagedModel] _obj: type[ManagedModel]
_managed_uid: str _managed_uid: str
_kwargs: dict _kwargs: dict
def __init__(self, obj: Type[ManagedModel], managed_uid: str, **kwargs) -> None: def __init__(self, obj: type[ManagedModel], managed_uid: str, **kwargs) -> None:
self._obj = obj self._obj = obj
self._managed_uid = managed_uid self._managed_uid = managed_uid
self._kwargs = kwargs self._kwargs = kwargs
@ -32,7 +32,7 @@ class EnsureExists(EnsureOp):
def __init__( def __init__(
self, self,
obj: Type[ManagedModel], obj: type[ManagedModel],
managed_uid: str, managed_uid: str,
created_callback: Optional[Callable] = None, created_callback: Optional[Callable] = None,
**kwargs, **kwargs,

View File

@ -1,4 +1,6 @@
"""Outpost API Views""" """Outpost API Views"""
from os import environ
from dacite.core import from_dict from dacite.core import from_dict
from dacite.exceptions import DaciteError from dacite.exceptions import DaciteError
from django_filters.filters import ModelMultipleChoiceFilter from django_filters.filters import ModelMultipleChoiceFilter
@ -12,6 +14,7 @@ from rest_framework.response import Response
from rest_framework.serializers import JSONField, ModelSerializer, ValidationError from rest_framework.serializers import JSONField, ModelSerializer, ValidationError
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from authentik import ENV_GIT_HASH_KEY
from authentik.core.api.providers import ProviderSerializer from authentik.core.api.providers import ProviderSerializer
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import PassiveSerializer, is_dict from authentik.core.api.utils import PassiveSerializer, is_dict
@ -98,8 +101,12 @@ class OutpostHealthSerializer(PassiveSerializer):
last_seen = DateTimeField(read_only=True) last_seen = DateTimeField(read_only=True)
version = CharField(read_only=True) version = CharField(read_only=True)
version_should = CharField(read_only=True) version_should = CharField(read_only=True)
version_outdated = BooleanField(read_only=True) version_outdated = BooleanField(read_only=True)
build_hash = CharField(read_only=True, required=False)
build_hash_should = CharField(read_only=True, required=False)
class OutpostFilter(FilterSet): class OutpostFilter(FilterSet):
"""Filter for Outposts""" """Filter for Outposts"""
@ -146,6 +153,8 @@ class OutpostViewSet(UsedByMixin, ModelViewSet):
"version": state.version, "version": state.version,
"version_should": state.version_should, "version_should": state.version_should,
"version_outdated": state.version_outdated, "version_outdated": state.version_outdated,
"build_hash": state.build_hash,
"build_hash_should": environ.get(ENV_GIT_HASH_KEY, ""),
} }
) )
return Response(OutpostHealthSerializer(states, many=True).data) return Response(OutpostHealthSerializer(states, many=True).data)

View File

@ -9,7 +9,11 @@ from structlog.testing import capture_logs
from authentik import ENV_GIT_HASH_KEY, __version__ from authentik import ENV_GIT_HASH_KEY, __version__
from authentik.lib.config import CONFIG from authentik.lib.config import CONFIG
from authentik.lib.sentry import SentryIgnoredException from authentik.lib.sentry import SentryIgnoredException
from authentik.outposts.models import Outpost, OutpostServiceConnection from authentik.outposts.models import (
Outpost,
OutpostServiceConnection,
OutpostServiceConnectionState,
)
FIELD_MANAGER = "goauthentik.io" FIELD_MANAGER = "goauthentik.io"
@ -28,11 +32,25 @@ class DeploymentPort:
inner_port: Optional[int] = None inner_port: Optional[int] = None
class BaseClient:
"""Base class for custom clients"""
def fetch_state(self) -> OutpostServiceConnectionState:
"""Get state, version info"""
raise NotImplementedError
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
"""Cleanup after usage"""
class BaseController: class BaseController:
"""Base Outpost deployment controller""" """Base Outpost deployment controller"""
deployment_ports: list[DeploymentPort] deployment_ports: list[DeploymentPort]
client: BaseClient
outpost: Outpost outpost: Outpost
connection: OutpostServiceConnection connection: OutpostServiceConnection
@ -63,6 +81,14 @@ class BaseController:
self.down() self.down()
return [x["event"] for x in logs] return [x["event"] for x in logs]
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
"""Cleanup after usage"""
if hasattr(self, "client"):
self.client.__exit__(exc_type, exc_value, traceback)
def get_static_deployment(self) -> str: def get_static_deployment(self) -> str:
"""Return a static deployment configuration""" """Return a static deployment configuration"""
raise NotImplementedError raise NotImplementedError

View File

@ -1,17 +1,75 @@
"""Docker controller""" """Docker controller"""
from time import sleep from time import sleep
from typing import Optional
from urllib.parse import urlparse
from django.conf import settings from django.conf import settings
from django.utils.text import slugify from django.utils.text import slugify
from docker import DockerClient from docker import DockerClient as UpstreamDockerClient
from docker.errors import DockerException, NotFound from docker.errors import DockerException, NotFound
from docker.models.containers import Container from docker.models.containers import Container
from docker.utils.utils import kwargs_from_env
from structlog.stdlib import get_logger
from yaml import safe_dump from yaml import safe_dump
from authentik import __version__ from authentik import __version__
from authentik.outposts.controllers.base import BaseController, ControllerException from authentik.outposts.controllers.base import BaseClient, BaseController, ControllerException
from authentik.outposts.docker_ssh import DockerInlineSSH
from authentik.outposts.docker_tls import DockerInlineTLS
from authentik.outposts.managed import MANAGED_OUTPOST from authentik.outposts.managed import MANAGED_OUTPOST
from authentik.outposts.models import DockerServiceConnection, Outpost, ServiceConnectionInvalid from authentik.outposts.models import (
DockerServiceConnection,
Outpost,
OutpostServiceConnectionState,
ServiceConnectionInvalid,
)
class DockerClient(UpstreamDockerClient, BaseClient):
"""Custom docker client, which can handle TLS and SSH from a database."""
tls: Optional[DockerInlineTLS]
ssh: Optional[DockerInlineSSH]
def __init__(self, connection: DockerServiceConnection):
self.tls = None
self.ssh = None
if connection.local:
# Same result as DockerClient.from_env
super().__init__(**kwargs_from_env())
else:
parsed_url = urlparse(connection.url)
tls_config = False
if parsed_url.scheme == "ssh":
self.ssh = DockerInlineSSH(parsed_url.hostname, connection.tls_authentication)
self.ssh.write()
else:
self.tls = DockerInlineTLS(
verification_kp=connection.tls_verification,
authentication_kp=connection.tls_authentication,
)
tls_config = self.tls.write()
super().__init__(
base_url=connection.url,
tls=tls_config,
)
self.logger = get_logger()
# Ensure the client actually works
self.containers.list()
def fetch_state(self) -> OutpostServiceConnectionState:
try:
return OutpostServiceConnectionState(version=self.info()["ServerVersion"], healthy=True)
except (ServiceConnectionInvalid, DockerException):
return OutpostServiceConnectionState(version="", healthy=False)
def __exit__(self, exc_type, exc_value, traceback):
if self.tls:
self.logger.debug("Cleaning up TLS")
self.tls.cleanup()
if self.ssh:
self.logger.debug("Cleaning up SSH")
self.ssh.cleanup()
class DockerController(BaseController): class DockerController(BaseController):
@ -27,8 +85,9 @@ class DockerController(BaseController):
if outpost.managed == MANAGED_OUTPOST: if outpost.managed == MANAGED_OUTPOST:
return return
try: try:
self.client = connection.client() self.client = DockerClient(connection)
except ServiceConnectionInvalid as exc: except DockerException as exc:
self.logger.warning(exc)
raise ControllerException from exc raise ControllerException from exc
@property @property

View File

@ -1,34 +1,67 @@
"""Kubernetes deployment controller""" """Kubernetes deployment controller"""
from io import StringIO from io import StringIO
from typing import Type
from kubernetes.client import VersionApi, VersionInfo
from kubernetes.client.api_client import ApiClient from kubernetes.client.api_client import ApiClient
from kubernetes.client.configuration import Configuration
from kubernetes.client.exceptions import OpenApiException from kubernetes.client.exceptions import OpenApiException
from kubernetes.config.config_exception import ConfigException
from kubernetes.config.incluster_config import load_incluster_config
from kubernetes.config.kube_config import load_kube_config_from_dict
from structlog.testing import capture_logs from structlog.testing import capture_logs
from urllib3.exceptions import HTTPError from urllib3.exceptions import HTTPError
from yaml import dump_all from yaml import dump_all
from authentik.outposts.controllers.base import BaseController, ControllerException from authentik.outposts.controllers.base import BaseClient, BaseController, ControllerException
from authentik.outposts.controllers.k8s.base import KubernetesObjectReconciler from authentik.outposts.controllers.k8s.base import KubernetesObjectReconciler
from authentik.outposts.controllers.k8s.deployment import DeploymentReconciler from authentik.outposts.controllers.k8s.deployment import DeploymentReconciler
from authentik.outposts.controllers.k8s.secret import SecretReconciler from authentik.outposts.controllers.k8s.secret import SecretReconciler
from authentik.outposts.controllers.k8s.service import ServiceReconciler from authentik.outposts.controllers.k8s.service import ServiceReconciler
from authentik.outposts.controllers.k8s.service_monitor import PrometheusServiceMonitorReconciler from authentik.outposts.controllers.k8s.service_monitor import PrometheusServiceMonitorReconciler
from authentik.outposts.models import KubernetesServiceConnection, Outpost, ServiceConnectionInvalid from authentik.outposts.models import (
KubernetesServiceConnection,
Outpost,
OutpostServiceConnectionState,
ServiceConnectionInvalid,
)
class KubernetesClient(ApiClient, BaseClient):
"""Custom kubernetes client based on service connection"""
def __init__(self, connection: KubernetesServiceConnection):
config = Configuration()
try:
if connection.local:
load_incluster_config(client_configuration=config)
else:
load_kube_config_from_dict(connection.kubeconfig, client_configuration=config)
super().__init__(config)
except ConfigException as exc:
raise ServiceConnectionInvalid from exc
def fetch_state(self) -> OutpostServiceConnectionState:
"""Get version info"""
try:
api_instance = VersionApi(self)
version: VersionInfo = api_instance.get_code()
return OutpostServiceConnectionState(version=version.git_version, healthy=True)
except (OpenApiException, HTTPError, ServiceConnectionInvalid):
return OutpostServiceConnectionState(version="", healthy=False)
class KubernetesController(BaseController): class KubernetesController(BaseController):
"""Manage deployment of outpost in kubernetes""" """Manage deployment of outpost in kubernetes"""
reconcilers: dict[str, Type[KubernetesObjectReconciler]] reconcilers: dict[str, type[KubernetesObjectReconciler]]
reconcile_order: list[str] reconcile_order: list[str]
client: ApiClient client: KubernetesClient
connection: KubernetesServiceConnection connection: KubernetesServiceConnection
def __init__(self, outpost: Outpost, connection: KubernetesServiceConnection) -> None: def __init__(self, outpost: Outpost, connection: KubernetesServiceConnection) -> None:
super().__init__(outpost, connection) super().__init__(outpost, connection)
self.client = connection.client() self.client = KubernetesClient(connection)
self.reconcilers = { self.reconcilers = {
"secret": SecretReconciler, "secret": SecretReconciler,
"deployment": DeploymentReconciler, "deployment": DeploymentReconciler,

View File

@ -0,0 +1,82 @@
"""Docker SSH helper"""
import os
from pathlib import Path
from tempfile import gettempdir
from authentik.crypto.models import CertificateKeyPair
HEADER = "### Managed by authentik"
FOOTER = "### End Managed by authentik"
def opener(path, flags):
"""File opener to create files as 700 perms"""
return os.open(path, flags, 0o700)
class DockerInlineSSH:
"""Create paramiko ssh config from CertificateKeyPair"""
host: str
keypair: CertificateKeyPair
key_path: str
config_path: Path
header: str
def __init__(self, host: str, keypair: CertificateKeyPair) -> None:
self.host = host
self.keypair = keypair
self.config_path = Path("~/.ssh/config").expanduser()
self.header = f"{HEADER} - {self.host}\n"
def write_config(self, key_path: str) -> bool:
"""Update the local user's ssh config file"""
with open(self.config_path, "a+", encoding="utf-8") as ssh_config:
if self.header in ssh_config.readlines():
return False
ssh_config.writelines(
[
self.header,
f"Host {self.host}\n",
f" IdentityFile {key_path}\n",
f"{FOOTER}\n",
"\n",
]
)
return True
def write_key(self):
"""Write keypair's private key to a temporary file"""
path = Path(gettempdir(), f"{self.keypair.pk}_private.pem")
with open(path, "w", encoding="utf8", opener=opener) as _file:
_file.write(self.keypair.key_data)
return str(path)
def write(self):
"""Write keyfile and update ssh config"""
self.key_path = self.write_key()
was_written = self.write_config(self.key_path)
if not was_written:
self.cleanup()
def cleanup(self):
"""Cleanup when we're done"""
try:
os.unlink(self.key_path)
with open(self.config_path, "r+", encoding="utf-8") as ssh_config:
start = 0
end = 0
lines = ssh_config.readlines()
for idx, line in enumerate(lines):
if line == self.header:
start = idx
if start != 0 and line == f"{FOOTER}\n":
end = idx
with open(self.config_path, "w+", encoding="utf-8") as ssh_config:
lines = lines[:start] + lines[end + 2 :]
ssh_config.writelines(lines)
except OSError:
# If we fail deleting a file it doesn't matter that much
# since we're just in a container
pass

View File

@ -1,4 +1,5 @@
"""Create Docker TLSConfig from CertificateKeyPair""" """Create Docker TLSConfig from CertificateKeyPair"""
from os import unlink
from pathlib import Path from pathlib import Path
from tempfile import gettempdir from tempfile import gettempdir
from typing import Optional from typing import Optional
@ -14,6 +15,8 @@ class DockerInlineTLS:
verification_kp: Optional[CertificateKeyPair] verification_kp: Optional[CertificateKeyPair]
authentication_kp: Optional[CertificateKeyPair] authentication_kp: Optional[CertificateKeyPair]
_paths: list[str]
def __init__( def __init__(
self, self,
verification_kp: Optional[CertificateKeyPair], verification_kp: Optional[CertificateKeyPair],
@ -21,14 +24,21 @@ class DockerInlineTLS:
) -> None: ) -> None:
self.verification_kp = verification_kp self.verification_kp = verification_kp
self.authentication_kp = authentication_kp self.authentication_kp = authentication_kp
self._paths = []
def write_file(self, name: str, contents: str) -> str: def write_file(self, name: str, contents: str) -> str:
"""Wrapper for mkstemp that uses fdopen""" """Wrapper for mkstemp that uses fdopen"""
path = Path(gettempdir(), name) path = Path(gettempdir(), name)
with open(path, "w", encoding="utf8") as _file: with open(path, "w", encoding="utf8") as _file:
_file.write(contents) _file.write(contents)
self._paths.append(str(path))
return str(path) return str(path)
def cleanup(self):
"""Clean up certificates when we're done"""
for path in self._paths:
unlink(path)
def write(self) -> TLSConfig: def write(self) -> TLSConfig:
"""Create TLSConfig with Certificate Key pairs""" """Create TLSConfig with Certificate Key pairs"""
# So yes, this is quite ugly. But sadly, there is no clean way to pass # So yes, this is quite ugly. But sadly, there is no clean way to pass

View File

@ -2,7 +2,7 @@
from dataclasses import asdict, dataclass, field from dataclasses import asdict, dataclass, field
from datetime import datetime from datetime import datetime
from os import environ from os import environ
from typing import Iterable, Optional, Union from typing import Iterable, Optional
from uuid import uuid4 from uuid import uuid4
from dacite import from_dict from dacite import from_dict
@ -11,21 +11,11 @@ from django.core.cache import cache
from django.db import IntegrityError, models, transaction from django.db import IntegrityError, models, transaction
from django.db.models.base import Model from django.db.models.base import Model
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from docker.client import DockerClient
from docker.errors import DockerException
from guardian.models import UserObjectPermission from guardian.models import UserObjectPermission
from guardian.shortcuts import assign_perm from guardian.shortcuts import assign_perm
from kubernetes.client import VersionApi, VersionInfo
from kubernetes.client.api_client import ApiClient
from kubernetes.client.configuration import Configuration
from kubernetes.client.exceptions import OpenApiException
from kubernetes.config.config_exception import ConfigException
from kubernetes.config.incluster_config import load_incluster_config
from kubernetes.config.kube_config import load_kube_config_from_dict
from model_utils.managers import InheritanceManager from model_utils.managers import InheritanceManager
from packaging.version import LegacyVersion, Version, parse from packaging.version import LegacyVersion, Version, parse
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from urllib3.exceptions import HTTPError
from authentik import ENV_GIT_HASH_KEY, __version__ from authentik import ENV_GIT_HASH_KEY, __version__
from authentik.core.models import ( from authentik.core.models import (
@ -44,7 +34,7 @@ from authentik.lib.sentry import SentryIgnoredException
from authentik.lib.utils.errors import exception_to_string from authentik.lib.utils.errors import exception_to_string
from authentik.managed.models import ManagedModel from authentik.managed.models import ManagedModel
from authentik.outposts.controllers.k8s.utils import get_namespace from authentik.outposts.controllers.k8s.utils import get_namespace
from authentik.outposts.docker_tls import DockerInlineTLS from authentik.tenants.models import Tenant
OUR_VERSION = parse(__version__) OUR_VERSION = parse(__version__)
OUTPOST_HELLO_INTERVAL = 10 OUTPOST_HELLO_INTERVAL = 10
@ -86,7 +76,7 @@ class OutpostConfig:
class OutpostModel(Model): class OutpostModel(Model):
"""Base model for providers that need more objects than just themselves""" """Base model for providers that need more objects than just themselves"""
def get_required_objects(self) -> Iterable[Union[models.Model, str]]: def get_required_objects(self) -> Iterable[models.Model | str]:
"""Return a list of all required objects""" """Return a list of all required objects"""
return [self] return [self]
@ -149,10 +139,6 @@ class OutpostServiceConnection(models.Model):
return OutpostServiceConnectionState("", False) return OutpostServiceConnectionState("", False)
return state return state
def fetch_state(self) -> OutpostServiceConnectionState:
"""Fetch current Service Connection state"""
raise NotImplementedError
@property @property
def component(self) -> str: def component(self) -> str:
"""Return component used to edit this object""" """Return component used to edit this object"""
@ -210,35 +196,6 @@ class DockerServiceConnection(OutpostServiceConnection):
def __str__(self) -> str: def __str__(self) -> str:
return f"Docker Service-Connection {self.name}" return f"Docker Service-Connection {self.name}"
def client(self) -> DockerClient:
"""Get DockerClient"""
try:
client = None
if self.local:
client = DockerClient.from_env()
else:
client = DockerClient(
base_url=self.url,
tls=DockerInlineTLS(
verification_kp=self.tls_verification,
authentication_kp=self.tls_authentication,
).write(),
)
client.containers.list()
except DockerException as exc:
LOGGER.warning(exc)
raise ServiceConnectionInvalid from exc
return client
def fetch_state(self) -> OutpostServiceConnectionState:
try:
client = self.client()
return OutpostServiceConnectionState(
version=client.info()["ServerVersion"], healthy=True
)
except ServiceConnectionInvalid:
return OutpostServiceConnectionState(version="", healthy=False)
class Meta: class Meta:
verbose_name = _("Docker Service-Connection") verbose_name = _("Docker Service-Connection")
@ -265,27 +222,6 @@ class KubernetesServiceConnection(OutpostServiceConnection):
def __str__(self) -> str: def __str__(self) -> str:
return f"Kubernetes Service-Connection {self.name}" return f"Kubernetes Service-Connection {self.name}"
def fetch_state(self) -> OutpostServiceConnectionState:
try:
client = self.client()
api_instance = VersionApi(client)
version: VersionInfo = api_instance.get_code()
return OutpostServiceConnectionState(version=version.git_version, healthy=True)
except (OpenApiException, HTTPError, ServiceConnectionInvalid):
return OutpostServiceConnectionState(version="", healthy=False)
def client(self) -> ApiClient:
"""Get Kubernetes client configured from kubeconfig"""
config = Configuration()
try:
if self.local:
load_incluster_config(client_configuration=config)
else:
load_kube_config_from_dict(self.kubeconfig, client_configuration=config)
return ApiClient(config)
except ConfigException as exc:
raise ServiceConnectionInvalid from exc
class Meta: class Meta:
verbose_name = _("Kubernetes Service-Connection") verbose_name = _("Kubernetes Service-Connection")
@ -385,7 +321,8 @@ class Outpost(ManagedModel):
user.user_permissions.add(permission.first()) user.user_permissions.add(permission.first())
LOGGER.debug( LOGGER.debug(
"Updated service account's permissions", "Updated service account's permissions",
perms=UserObjectPermission.objects.filter(user=user), obj_perms=UserObjectPermission.objects.filter(user=user),
perms=user.user_permissions.all(),
) )
@property @property
@ -438,9 +375,9 @@ class Outpost(ManagedModel):
Token.objects.filter(identifier=self.token_identifier).delete() Token.objects.filter(identifier=self.token_identifier).delete()
return self.token return self.token
def get_required_objects(self) -> Iterable[Union[models.Model, str]]: def get_required_objects(self) -> Iterable[models.Model | str]:
"""Get an iterator of all objects the user needs read access to""" """Get an iterator of all objects the user needs read access to"""
objects: list[Union[models.Model, str]] = [ objects: list[models.Model | str] = [
self, self,
"authentik_events.add_event", "authentik_events.add_event",
] ]
@ -449,6 +386,10 @@ class Outpost(ManagedModel):
objects.extend(provider.get_required_objects()) objects.extend(provider.get_required_objects())
else: else:
objects.append(provider) objects.append(provider)
if self.managed:
for tenant in Tenant.objects.filter(web_certificate__isnull=False):
objects.append(tenant)
objects.append(tenant.web_certificate)
return objects return objects
def __str__(self) -> str: def __str__(self) -> str:
@ -463,7 +404,7 @@ class OutpostState:
channel_ids: list[str] = field(default_factory=list) channel_ids: list[str] = field(default_factory=list)
last_seen: Optional[datetime] = field(default=None) last_seen: Optional[datetime] = field(default=None)
version: Optional[str] = field(default=None) version: Optional[str] = field(default=None)
version_should: Union[Version, LegacyVersion] = field(default=OUR_VERSION) version_should: Version | LegacyVersion = field(default=OUR_VERSION)
build_hash: str = field(default="") build_hash: str = field(default="")
_outpost: Optional[Outpost] = field(default=None) _outpost: Optional[Outpost] = field(default=None)

View File

@ -10,6 +10,7 @@ from authentik.crypto.models import CertificateKeyPair
from authentik.lib.utils.reflection import class_to_path from authentik.lib.utils.reflection import class_to_path
from authentik.outposts.models import Outpost, OutpostServiceConnection from authentik.outposts.models import Outpost, OutpostServiceConnection
from authentik.outposts.tasks import CACHE_KEY_OUTPOST_DOWN, outpost_controller, outpost_post_save from authentik.outposts.tasks import CACHE_KEY_OUTPOST_DOWN, outpost_controller, outpost_post_save
from authentik.tenants.models import Tenant
LOGGER = get_logger() LOGGER = get_logger()
UPDATE_TRIGGERING_MODELS = ( UPDATE_TRIGGERING_MODELS = (
@ -17,6 +18,7 @@ UPDATE_TRIGGERING_MODELS = (
OutpostServiceConnection, OutpostServiceConnection,
Provider, Provider,
CertificateKeyPair, CertificateKeyPair,
Tenant,
) )

View File

@ -25,6 +25,8 @@ from authentik.events.monitored_tasks import (
) )
from authentik.lib.utils.reflection import path_to_class from authentik.lib.utils.reflection import path_to_class
from authentik.outposts.controllers.base import BaseController, ControllerException from authentik.outposts.controllers.base import BaseController, ControllerException
from authentik.outposts.controllers.docker import DockerClient
from authentik.outposts.controllers.kubernetes import KubernetesClient
from authentik.outposts.models import ( from authentik.outposts.models import (
DockerServiceConnection, DockerServiceConnection,
KubernetesServiceConnection, KubernetesServiceConnection,
@ -45,21 +47,21 @@ LOGGER = get_logger()
CACHE_KEY_OUTPOST_DOWN = "outpost_teardown_%s" CACHE_KEY_OUTPOST_DOWN = "outpost_teardown_%s"
def controller_for_outpost(outpost: Outpost) -> Optional[BaseController]: def controller_for_outpost(outpost: Outpost) -> Optional[type[BaseController]]:
"""Get a controller for the outpost, when a service connection is defined""" """Get a controller for the outpost, when a service connection is defined"""
if not outpost.service_connection: if not outpost.service_connection:
return None return None
service_connection = outpost.service_connection service_connection = outpost.service_connection
if outpost.type == OutpostType.PROXY: if outpost.type == OutpostType.PROXY:
if isinstance(service_connection, DockerServiceConnection): if isinstance(service_connection, DockerServiceConnection):
return ProxyDockerController(outpost, service_connection) return ProxyDockerController
if isinstance(service_connection, KubernetesServiceConnection): if isinstance(service_connection, KubernetesServiceConnection):
return ProxyKubernetesController(outpost, service_connection) return ProxyKubernetesController
if outpost.type == OutpostType.LDAP: if outpost.type == OutpostType.LDAP:
if isinstance(service_connection, DockerServiceConnection): if isinstance(service_connection, DockerServiceConnection):
return LDAPDockerController(outpost, service_connection) return LDAPDockerController
if isinstance(service_connection, KubernetesServiceConnection): if isinstance(service_connection, KubernetesServiceConnection):
return LDAPKubernetesController(outpost, service_connection) return LDAPKubernetesController
return None return None
@ -71,7 +73,12 @@ def outpost_service_connection_state(connection_pk: Any):
) )
if not connection: if not connection:
return return
state = connection.fetch_state() if isinstance(connection, DockerServiceConnection):
cls = DockerClient
if isinstance(connection, KubernetesServiceConnection):
cls = KubernetesClient
with cls(connection) as client:
state = client.fetch_state()
cache.set(connection.state_key, state, timeout=None) cache.set(connection.state_key, state, timeout=None)
@ -114,9 +121,10 @@ def outpost_controller(
return return
self.set_uid(slugify(outpost.name)) self.set_uid(slugify(outpost.name))
try: try:
controller = controller_for_outpost(outpost) controller_type = controller_for_outpost(outpost)
if not controller: if not controller_type:
return return
with controller_type(outpost, outpost.service_connection) as controller:
logs = getattr(controller, f"{action}_with_logs")() logs = getattr(controller, f"{action}_with_logs")()
LOGGER.debug("---------------Outpost Controller logs starting----------------") LOGGER.debug("---------------Outpost Controller logs starting----------------")
for log in logs: for log in logs:

View File

@ -1,16 +1,14 @@
"""Password flow tests""" """Password flow tests"""
from django.urls.base import reverse from django.urls.base import reverse
from django.utils.encoding import force_str
from rest_framework.test import APITestCase
from authentik.core.models import User from authentik.core.models import User
from authentik.flows.challenge import ChallengeTypes
from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding
from authentik.flows.tests import FlowTestCase
from authentik.policies.password.models import PasswordPolicy from authentik.policies.password.models import PasswordPolicy
from authentik.stages.prompt.models import FieldTypes, Prompt, PromptStage from authentik.stages.prompt.models import FieldTypes, Prompt, PromptStage
class TestPasswordPolicyFlow(APITestCase): class TestPasswordPolicyFlow(FlowTestCase):
"""Test Password Policy""" """Test Password Policy"""
def setUp(self) -> None: def setUp(self) -> None:
@ -53,11 +51,11 @@ class TestPasswordPolicyFlow(APITestCase):
{"password": "akadmin"}, {"password": "akadmin"},
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertJSONEqual( self.assertStageResponse(
force_str(response.content), response,
{ self.flow,
"component": "ak-stage-prompt", component="ak-stage-prompt",
"fields": [ fields=[
{ {
"field_key": "password", "field_key": "password",
"label": "PASSWORD_LABEL", "label": "PASSWORD_LABEL",
@ -68,14 +66,7 @@ class TestPasswordPolicyFlow(APITestCase):
"sub_text": "", "sub_text": "",
} }
], ],
"flow_info": { response_errors={
"background": self.flow.background_url,
"cancel_url": reverse("authentik_flows:cancel"),
"title": "",
},
"response_errors": {
"non_field_errors": [{"code": "invalid", "string": self.policy.error_message}] "non_field_errors": [{"code": "invalid", "string": self.policy.error_message}]
}, },
"type": ChallengeTypes.NATIVE.value,
},
) )

View File

@ -1,11 +1,11 @@
"""Source API Views""" """Reputation policy API Views"""
from rest_framework import mixins from rest_framework import mixins
from rest_framework.serializers import ModelSerializer from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import GenericViewSet, ModelViewSet from rest_framework.viewsets import GenericViewSet, ModelViewSet
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
from authentik.policies.api.policies import PolicySerializer from authentik.policies.api.policies import PolicySerializer
from authentik.policies.reputation.models import IPReputation, ReputationPolicy, UserReputation from authentik.policies.reputation.models import Reputation, ReputationPolicy
class ReputationPolicySerializer(PolicySerializer): class ReputationPolicySerializer(PolicySerializer):
@ -29,59 +29,32 @@ class ReputationPolicyViewSet(UsedByMixin, ModelViewSet):
ordering = ["name"] ordering = ["name"]
class IPReputationSerializer(ModelSerializer): class ReputationSerializer(ModelSerializer):
"""IPReputation Serializer""" """Reputation Serializer"""
class Meta: class Meta:
model = IPReputation model = Reputation
fields = [ fields = [
"pk", "pk",
"identifier",
"ip", "ip",
"ip_geo_data",
"score", "score",
"updated", "updated",
] ]
class IPReputationViewSet( class ReputationViewSet(
mixins.RetrieveModelMixin, mixins.RetrieveModelMixin,
mixins.DestroyModelMixin, mixins.DestroyModelMixin,
UsedByMixin, UsedByMixin,
mixins.ListModelMixin, mixins.ListModelMixin,
GenericViewSet, GenericViewSet,
): ):
"""IPReputation Viewset""" """Reputation Viewset"""
queryset = IPReputation.objects.all() queryset = Reputation.objects.all()
serializer_class = IPReputationSerializer serializer_class = ReputationSerializer
search_fields = ["ip", "score"] search_fields = ["identifier", "ip", "score"]
filterset_fields = ["ip", "score"] filterset_fields = ["identifier", "ip", "score"]
ordering = ["ip"] ordering = ["ip"]
class UserReputationSerializer(ModelSerializer):
"""UserReputation Serializer"""
class Meta:
model = UserReputation
fields = [
"pk",
"username",
"score",
"updated",
]
class UserReputationViewSet(
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
UsedByMixin,
mixins.ListModelMixin,
GenericViewSet,
):
"""UserReputation Viewset"""
queryset = UserReputation.objects.all()
serializer_class = UserReputationSerializer
search_fields = ["username", "score"]
filterset_fields = ["username", "score"]
ordering = ["username"]

View File

@ -13,3 +13,4 @@ class AuthentikPolicyReputationConfig(AppConfig):
def ready(self): def ready(self):
import_module("authentik.policies.reputation.signals") import_module("authentik.policies.reputation.signals")
import_module("authentik.policies.reputation.tasks")

View File

@ -0,0 +1,40 @@
# Generated by Django 4.0.1 on 2022-01-05 18:56
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_policies_reputation", "0002_auto_20210529_2046"),
]
operations = [
migrations.CreateModel(
name="Reputation",
fields=[
(
"reputation_uuid",
models.UUIDField(
default=uuid.uuid4, primary_key=True, serialize=False, unique=True
),
),
("identifier", models.TextField()),
("ip", models.GenericIPAddressField()),
("ip_geo_data", models.JSONField(default=dict)),
("score", models.BigIntegerField(default=0)),
("updated", models.DateTimeField(auto_now_add=True)),
],
options={
"unique_together": {("identifier", "ip")},
},
),
migrations.DeleteModel(
name="IPReputation",
),
migrations.DeleteModel(
name="UserReputation",
),
]

View File

@ -1,17 +1,20 @@
"""authentik reputation request policy""" """authentik reputation request policy"""
from django.core.cache import cache from uuid import uuid4
from django.db import models from django.db import models
from django.db.models import Sum
from django.db.models.query_utils import Q
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from rest_framework.serializers import BaseSerializer from rest_framework.serializers import BaseSerializer
from structlog import get_logger from structlog import get_logger
from authentik.lib.models import SerializerModel
from authentik.lib.utils.http import get_client_ip from authentik.lib.utils.http import get_client_ip
from authentik.policies.models import Policy from authentik.policies.models import Policy
from authentik.policies.types import PolicyRequest, PolicyResult from authentik.policies.types import PolicyRequest, PolicyResult
LOGGER = get_logger() LOGGER = get_logger()
CACHE_KEY_IP_PREFIX = "authentik_reputation_ip_" CACHE_KEY_PREFIX = "goauthentik.io/policies/reputation/scores/"
CACHE_KEY_USER_PREFIX = "authentik_reputation_user_"
class ReputationPolicy(Policy): class ReputationPolicy(Policy):
@ -33,17 +36,19 @@ class ReputationPolicy(Policy):
def passes(self, request: PolicyRequest) -> PolicyResult: def passes(self, request: PolicyRequest) -> PolicyResult:
remote_ip = get_client_ip(request.http_request) remote_ip = get_client_ip(request.http_request)
passing = False query = Q()
if self.check_ip: if self.check_ip:
score = cache.get_or_set(CACHE_KEY_IP_PREFIX + remote_ip, 0) query |= Q(ip=remote_ip)
passing += passing or score <= self.threshold
LOGGER.debug("Score for IP", ip=remote_ip, score=score, passing=passing)
if self.check_username: if self.check_username:
score = cache.get_or_set(CACHE_KEY_USER_PREFIX + request.user.username, 0) query |= Q(identifier=request.user.username)
passing += passing or score <= self.threshold score = (
Reputation.objects.filter(query).aggregate(total_score=Sum("score"))["total_score"] or 0
)
passing = score <= self.threshold
LOGGER.debug( LOGGER.debug(
"Score for Username", "Score for user",
username=request.user.username, username=request.user.username,
remote_ip=remote_ip,
score=score, score=score,
passing=passing, passing=passing,
) )
@ -55,23 +60,27 @@ class ReputationPolicy(Policy):
verbose_name_plural = _("Reputation Policies") verbose_name_plural = _("Reputation Policies")
class IPReputation(models.Model): class Reputation(SerializerModel):
"""Store score coming from the same IP""" """Reputation for user and or IP."""
ip = models.GenericIPAddressField(unique=True) reputation_uuid = models.UUIDField(primary_key=True, unique=True, default=uuid4)
score = models.IntegerField(default=0)
updated = models.DateTimeField(auto_now=True)
def __str__(self): identifier = models.TextField()
return f"IPReputation for {self.ip} @ {self.score}" ip = models.GenericIPAddressField()
ip_geo_data = models.JSONField(default=dict)
score = models.BigIntegerField(default=0)
updated = models.DateTimeField(auto_now_add=True)
class UserReputation(models.Model): @property
"""Store score attempting to log in as the same username""" def serializer(self) -> BaseSerializer:
from authentik.policies.reputation.api import ReputationSerializer
username = models.TextField() return ReputationSerializer
score = models.IntegerField(default=0)
updated = models.DateTimeField(auto_now=True)
def __str__(self): def __str__(self) -> str:
return f"UserReputation for {self.username} @ {self.score}" return f"Reputation {self.identifier}/{self.ip} @ {self.score}"
class Meta:
unique_together = ("identifier", "ip")

View File

@ -2,13 +2,8 @@
from celery.schedules import crontab from celery.schedules import crontab
CELERY_BEAT_SCHEDULE = { CELERY_BEAT_SCHEDULE = {
"policies_reputation_ip_save": { "policies_reputation_save": {
"task": "authentik.policies.reputation.tasks.save_ip_reputation", "task": "authentik.policies.reputation.tasks.save_reputation",
"schedule": crontab(minute="*/5"),
"options": {"queue": "authentik_scheduled"},
},
"policies_reputation_user_save": {
"task": "authentik.policies.reputation.tasks.save_user_reputation",
"schedule": crontab(minute="*/5"), "schedule": crontab(minute="*/5"),
"options": {"queue": "authentik_scheduled"}, "options": {"queue": "authentik_scheduled"},
}, },

View File

@ -7,28 +7,32 @@ from structlog.stdlib import get_logger
from authentik.lib.config import CONFIG from authentik.lib.config import CONFIG
from authentik.lib.utils.http import get_client_ip from authentik.lib.utils.http import get_client_ip
from authentik.policies.reputation.models import CACHE_KEY_IP_PREFIX, CACHE_KEY_USER_PREFIX from authentik.policies.reputation.models import CACHE_KEY_PREFIX
from authentik.policies.reputation.tasks import save_reputation
from authentik.stages.identification.signals import identification_failed from authentik.stages.identification.signals import identification_failed
LOGGER = get_logger() LOGGER = get_logger()
CACHE_TIMEOUT = int(CONFIG.y("redis.cache_timeout_reputation")) CACHE_TIMEOUT = int(CONFIG.y("redis.cache_timeout_reputation"))
def update_score(request: HttpRequest, username: str, amount: int): def update_score(request: HttpRequest, identifier: str, amount: int):
"""Update score for IP and User""" """Update score for IP and User"""
remote_ip = get_client_ip(request) remote_ip = get_client_ip(request)
try: try:
# We only update the cache here, as its faster than writing to the DB # We only update the cache here, as its faster than writing to the DB
cache.get_or_set(CACHE_KEY_IP_PREFIX + remote_ip, 0, CACHE_TIMEOUT) score = cache.get_or_set(
cache.incr(CACHE_KEY_IP_PREFIX + remote_ip, amount) CACHE_KEY_PREFIX + remote_ip + identifier,
{"ip": remote_ip, "identifier": identifier, "score": 0},
cache.get_or_set(CACHE_KEY_USER_PREFIX + username, 0, CACHE_TIMEOUT) CACHE_TIMEOUT,
cache.incr(CACHE_KEY_USER_PREFIX + username, amount) )
score["score"] += amount
cache.set(CACHE_KEY_PREFIX + remote_ip + identifier, score)
except ValueError as exc: except ValueError as exc:
LOGGER.warning("failed to set reputation", exc=exc) LOGGER.warning("failed to set reputation", exc=exc)
LOGGER.debug("Updated score", amount=amount, for_user=username, for_ip=remote_ip) LOGGER.debug("Updated score", amount=amount, for_user=identifier, for_ip=remote_ip)
save_reputation.delay()
@receiver(user_login_failed) @receiver(user_login_failed)

View File

@ -2,14 +2,15 @@
from django.core.cache import cache from django.core.cache import cache
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.events.geo import GEOIP_READER
from authentik.events.monitored_tasks import ( from authentik.events.monitored_tasks import (
MonitoredTask, MonitoredTask,
TaskResult, TaskResult,
TaskResultStatus, TaskResultStatus,
prefill_task, prefill_task,
) )
from authentik.policies.reputation.models import IPReputation, UserReputation from authentik.policies.reputation.models import Reputation
from authentik.policies.reputation.signals import CACHE_KEY_IP_PREFIX, CACHE_KEY_USER_PREFIX from authentik.policies.reputation.signals import CACHE_KEY_PREFIX
from authentik.root.celery import CELERY_APP from authentik.root.celery import CELERY_APP
LOGGER = get_logger() LOGGER = get_logger()
@ -17,29 +18,16 @@ LOGGER = get_logger()
@CELERY_APP.task(bind=True, base=MonitoredTask) @CELERY_APP.task(bind=True, base=MonitoredTask)
@prefill_task @prefill_task
def save_ip_reputation(self: MonitoredTask): def save_reputation(self: MonitoredTask):
"""Save currently cached reputation to database""" """Save currently cached reputation to database"""
objects_to_update = [] objects_to_update = []
for key, score in cache.get_many(cache.keys(CACHE_KEY_IP_PREFIX + "*")).items(): for _, score in cache.get_many(cache.keys(CACHE_KEY_PREFIX + "*")).items():
remote_ip = key.replace(CACHE_KEY_IP_PREFIX, "") rep, _ = Reputation.objects.get_or_create(
rep, _ = IPReputation.objects.get_or_create(ip=remote_ip) ip=score["ip"],
rep.score = score identifier=score["identifier"],
objects_to_update.append(rep)
IPReputation.objects.bulk_update(objects_to_update, ["score"])
self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL, ["Successfully updated IP Reputation"]))
@CELERY_APP.task(bind=True, base=MonitoredTask)
@prefill_task
def save_user_reputation(self: MonitoredTask):
"""Save currently cached reputation to database"""
objects_to_update = []
for key, score in cache.get_many(cache.keys(CACHE_KEY_USER_PREFIX + "*")).items():
username = key.replace(CACHE_KEY_USER_PREFIX, "")
rep, _ = UserReputation.objects.get_or_create(username=username)
rep.score = score
objects_to_update.append(rep)
UserReputation.objects.bulk_update(objects_to_update, ["score"])
self.set_status(
TaskResult(TaskResultStatus.SUCCESSFUL, ["Successfully updated User Reputation"])
) )
rep.ip_geo_data = GEOIP_READER.city_dict(score["ip"]) or {}
rep.score = score["score"]
objects_to_update.append(rep)
Reputation.objects.bulk_update(objects_to_update, ["score", "ip_geo_data"])
self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL, ["Successfully updated Reputation"]))

View File

@ -4,15 +4,8 @@ from django.core.cache import cache
from django.test import RequestFactory, TestCase from django.test import RequestFactory, TestCase
from authentik.core.models import User from authentik.core.models import User
from authentik.lib.utils.http import DEFAULT_IP from authentik.policies.reputation.models import CACHE_KEY_PREFIX, Reputation, ReputationPolicy
from authentik.policies.reputation.models import ( from authentik.policies.reputation.tasks import save_reputation
CACHE_KEY_IP_PREFIX,
CACHE_KEY_USER_PREFIX,
IPReputation,
ReputationPolicy,
UserReputation,
)
from authentik.policies.reputation.tasks import save_ip_reputation, save_user_reputation
from authentik.policies.types import PolicyRequest from authentik.policies.types import PolicyRequest
@ -24,9 +17,8 @@ class TestReputationPolicy(TestCase):
self.request = self.request_factory.get("/") self.request = self.request_factory.get("/")
self.test_ip = "127.0.0.1" self.test_ip = "127.0.0.1"
self.test_username = "test" self.test_username = "test"
cache.delete(CACHE_KEY_IP_PREFIX + self.test_ip) keys = cache.keys(CACHE_KEY_PREFIX + "*")
cache.delete(CACHE_KEY_IP_PREFIX + DEFAULT_IP) cache.delete_many(keys)
cache.delete(CACHE_KEY_USER_PREFIX + self.test_username)
# We need a user for the one-to-one in userreputation # We need a user for the one-to-one in userreputation
self.user = User.objects.create(username=self.test_username) self.user = User.objects.create(username=self.test_username)
@ -35,20 +27,26 @@ class TestReputationPolicy(TestCase):
# Trigger negative reputation # Trigger negative reputation
authenticate(self.request, username=self.test_username, password=self.test_username) authenticate(self.request, username=self.test_username, password=self.test_username)
# Test value in cache # Test value in cache
self.assertEqual(cache.get(CACHE_KEY_IP_PREFIX + self.test_ip), -1) self.assertEqual(
cache.get(CACHE_KEY_PREFIX + self.test_ip + self.test_username),
{"ip": "127.0.0.1", "identifier": "test", "score": -1},
)
# Save cache and check db values # Save cache and check db values
save_ip_reputation.delay().get() save_reputation.delay().get()
self.assertEqual(IPReputation.objects.get(ip=self.test_ip).score, -1) self.assertEqual(Reputation.objects.get(ip=self.test_ip).score, -1)
def test_user_reputation(self): def test_user_reputation(self):
"""test User reputation""" """test User reputation"""
# Trigger negative reputation # Trigger negative reputation
authenticate(self.request, username=self.test_username, password=self.test_username) authenticate(self.request, username=self.test_username, password=self.test_username)
# Test value in cache # Test value in cache
self.assertEqual(cache.get(CACHE_KEY_USER_PREFIX + self.test_username), -1) self.assertEqual(
cache.get(CACHE_KEY_PREFIX + self.test_ip + self.test_username),
{"ip": "127.0.0.1", "identifier": "test", "score": -1},
)
# Save cache and check db values # Save cache and check db values
save_user_reputation.delay().get() save_reputation.delay().get()
self.assertEqual(UserReputation.objects.get(username=self.test_username).score, -1) self.assertEqual(Reputation.objects.get(identifier=self.test_username).score, -1)
def test_policy(self): def test_policy(self):
"""Test Policy""" """Test Policy"""

View File

@ -1,5 +1,5 @@
"""LDAP Provider""" """LDAP Provider"""
from typing import Iterable, Optional, Type, Union from typing import Iterable, Optional
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -78,7 +78,7 @@ class LDAPProvider(OutpostModel, Provider):
return "ak-provider-ldap-form" return "ak-provider-ldap-form"
@property @property
def serializer(self) -> Type[Serializer]: def serializer(self) -> type[Serializer]:
from authentik.providers.ldap.api import LDAPProviderSerializer from authentik.providers.ldap.api import LDAPProviderSerializer
return LDAPProviderSerializer return LDAPProviderSerializer
@ -86,7 +86,7 @@ class LDAPProvider(OutpostModel, Provider):
def __str__(self): def __str__(self):
return f"LDAP Provider {self.name}" return f"LDAP Provider {self.name}"
def get_required_objects(self) -> Iterable[Union[models.Model, str]]: def get_required_objects(self) -> Iterable[models.Model | str]:
required_models = [self, "authentik_core.view_user", "authentik_core.view_group"] required_models = [self, "authentik_core.view_user", "authentik_core.view_group"]
if self.certificate is not None: if self.certificate is not None:
required_models.append(self.certificate) required_models.append(self.certificate)

View File

@ -1,31 +1,23 @@
"""OAuth2Provider API Views""" """OAuth2Provider API Views"""
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from drf_spectacular.utils import OpenApiResponse, extend_schema from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.fields import CharField from rest_framework.fields import CharField
from rest_framework.generics import get_object_or_404 from rest_framework.generics import get_object_or_404
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.serializers import ValidationError
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from authentik.core.api.providers import ProviderSerializer from authentik.core.api.providers import ProviderSerializer
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import PassiveSerializer from authentik.core.api.utils import PassiveSerializer
from authentik.core.models import Provider from authentik.core.models import Provider
from authentik.providers.oauth2.models import JWTAlgorithms, OAuth2Provider from authentik.providers.oauth2.models import OAuth2Provider
class OAuth2ProviderSerializer(ProviderSerializer): class OAuth2ProviderSerializer(ProviderSerializer):
"""OAuth2Provider Serializer""" """OAuth2Provider Serializer"""
def validate_jwt_alg(self, value):
"""Ensure that when RS256 is selected, a certificate-key-pair is selected"""
if self.initial_data.get("rsa_key", None) is None and value == JWTAlgorithms.RS256:
raise ValidationError(_("RS256 requires a Certificate-Key-Pair to be selected."))
return value
class Meta: class Meta:
model = OAuth2Provider model = OAuth2Provider
@ -37,8 +29,7 @@ class OAuth2ProviderSerializer(ProviderSerializer):
"access_code_validity", "access_code_validity",
"token_validity", "token_validity",
"include_claims_in_id_token", "include_claims_in_id_token",
"jwt_alg", "signing_key",
"rsa_key",
"redirect_uris", "redirect_uris",
"sub_mode", "sub_mode",
"property_mappings", "property_mappings",
@ -73,8 +64,7 @@ class OAuth2ProviderViewSet(UsedByMixin, ModelViewSet):
"access_code_validity", "access_code_validity",
"token_validity", "token_validity",
"include_claims_in_id_token", "include_claims_in_id_token",
"jwt_alg", "signing_key",
"rsa_key",
"redirect_uris", "redirect_uris",
"sub_mode", "sub_mode",
"property_mappings", "property_mappings",

View File

@ -0,0 +1,25 @@
# Generated by Django 4.0 on 2021-12-22 21:04
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
(
"authentik_providers_oauth2",
"0007_auto_20201016_1107_squashed_0017_alter_oauth2provider_token_validity",
),
]
operations = [
migrations.RenameField(
model_name="oauth2provider",
old_name="rsa_key",
new_name="signing_key",
),
migrations.RemoveField(
model_name="oauth2provider",
name="jwt_alg",
),
]

View File

@ -6,9 +6,11 @@ import time
from dataclasses import asdict, dataclass, field from dataclasses import asdict, dataclass, field
from datetime import datetime from datetime import datetime
from hashlib import sha256 from hashlib import sha256
from typing import Any, Optional, Type from typing import Any, Optional
from urllib.parse import urlparse from urllib.parse import urlparse
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
from dacite import from_dict from dacite import from_dict
from django.db import models from django.db import models
from django.http import HttpRequest from django.http import HttpRequest
@ -88,6 +90,7 @@ class JWTAlgorithms(models.TextChoices):
HS256 = "HS256", _("HS256 (Symmetric Encryption)") HS256 = "HS256", _("HS256 (Symmetric Encryption)")
RS256 = "RS256", _("RS256 (Asymmetric Encryption)") RS256 = "RS256", _("RS256 (Asymmetric Encryption)")
EC256 = "EC256", _("EC256 (Asymmetric Encryption)")
class ScopeMapping(PropertyMapping): class ScopeMapping(PropertyMapping):
@ -109,7 +112,7 @@ class ScopeMapping(PropertyMapping):
return "ak-property-mapping-scope-form" return "ak-property-mapping-scope-form"
@property @property
def serializer(self) -> Type[Serializer]: def serializer(self) -> type[Serializer]:
from authentik.providers.oauth2.api.scope import ScopeMappingSerializer from authentik.providers.oauth2.api.scope import ScopeMappingSerializer
return ScopeMappingSerializer return ScopeMappingSerializer
@ -145,13 +148,6 @@ class OAuth2Provider(Provider):
verbose_name=_("Client Secret"), verbose_name=_("Client Secret"),
default=generate_key, default=generate_key,
) )
jwt_alg = models.CharField(
max_length=10,
choices=JWTAlgorithms.choices,
default=JWTAlgorithms.RS256,
verbose_name=_("JWT Algorithm"),
help_text=_(JWTAlgorithms.__doc__),
)
redirect_uris = models.TextField( redirect_uris = models.TextField(
default="", default="",
blank=True, blank=True,
@ -207,7 +203,7 @@ class OAuth2Provider(Provider):
help_text=_(("Configure how the issuer field of the ID Token should be filled.")), help_text=_(("Configure how the issuer field of the ID Token should be filled.")),
) )
rsa_key = models.ForeignKey( signing_key = models.ForeignKey(
CertificateKeyPair, CertificateKeyPair,
verbose_name=_("RSA Key"), verbose_name=_("RSA Key"),
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
@ -231,29 +227,18 @@ class OAuth2Provider(Provider):
token.access_token = token.create_access_token(user, request) token.access_token = token.create_access_token(user, request)
return token return token
def get_jwt_key(self) -> str: def get_jwt_key(self) -> tuple[str, str]:
""" """Get either the configured certificate or the client secret"""
Takes a provider and returns the set of keys associated with it. if not self.signing_key:
Returns a list of keys. # No Certificate at all, assume HS256
""" return self.client_secret, JWTAlgorithms.HS256
if self.jwt_alg == JWTAlgorithms.RS256: key: CertificateKeyPair = self.signing_key
# if the user selected RS256 but didn't select a private_key = key.private_key
# CertificateKeyPair, we fall back to HS256 if isinstance(private_key, RSAPrivateKey):
if not self.rsa_key: return key.key_data, JWTAlgorithms.RS256
Event.new( if isinstance(private_key, EllipticCurvePrivateKey):
EventAction.CONFIGURATION_ERROR, return key.key_data, JWTAlgorithms.EC256
provider=self, raise Exception(f"Invalid private key type: {type(private_key)}")
message="Provider was configured for RS256, but no key was selected.",
).save()
self.jwt_alg = JWTAlgorithms.HS256
self.save()
else:
return self.rsa_key.key_data
if self.jwt_alg == JWTAlgorithms.HS256:
return self.client_secret
raise Exception("Unsupported key algorithm.")
def get_issuer(self, request: HttpRequest) -> Optional[str]: def get_issuer(self, request: HttpRequest) -> Optional[str]:
"""Get issuer, based on request""" """Get issuer, based on request"""
@ -282,7 +267,7 @@ class OAuth2Provider(Provider):
return "ak-provider-oauth2-form" return "ak-provider-oauth2-form"
@property @property
def serializer(self) -> Type[Serializer]: def serializer(self) -> type[Serializer]:
from authentik.providers.oauth2.api.provider import OAuth2ProviderSerializer from authentik.providers.oauth2.api.provider import OAuth2ProviderSerializer
return OAuth2ProviderSerializer return OAuth2ProviderSerializer
@ -293,13 +278,13 @@ class OAuth2Provider(Provider):
def encode(self, payload: dict[str, Any]) -> str: def encode(self, payload: dict[str, Any]) -> str:
"""Represent the ID Token as a JSON Web Token (JWT).""" """Represent the ID Token as a JSON Web Token (JWT)."""
headers = {} headers = {}
if self.rsa_key: if self.signing_key:
headers["kid"] = self.rsa_key.kid headers["kid"] = self.signing_key.kid
key = self.get_jwt_key() key, alg = self.get_jwt_key()
# If the provider does not have an RSA Key assigned, it was switched to Symmetric # If the provider does not have an RSA Key assigned, it was switched to Symmetric
self.refresh_from_db() self.refresh_from_db()
# pyright: reportGeneralTypeIssues=false # pyright: reportGeneralTypeIssues=false
return encode(payload, key, algorithm=self.jwt_alg, headers=headers) return encode(payload, key, algorithm=alg, headers=headers)
class Meta: class Meta:

View File

@ -1,32 +0,0 @@
"""Test oauth2 provider API"""
from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.providers.oauth2.models import JWTAlgorithms
class TestOAuth2ProviderAPI(APITestCase):
"""Test oauth2 provider API"""
def setUp(self) -> None:
super().setUp()
self.user = create_test_admin_user()
self.client.force_login(self.user)
def test_validate(self):
"""Test OAuth2 Provider validation"""
response = self.client.post(
reverse(
"authentik_api:oauth2provider-list",
),
data={
"name": "test",
"jwt_alg": str(JWTAlgorithms.RS256),
"authorization_flow": create_test_flow().pk,
},
)
self.assertJSONEqual(
response.content.decode(),
{"jwt_alg": ["RS256 requires a Certificate-Key-Pair to be selected."]},
)

View File

@ -1,7 +1,6 @@
"""Test authorize view""" """Test authorize view"""
from django.test import RequestFactory from django.test import RequestFactory
from django.urls import reverse from django.urls import reverse
from django.utils.encoding import force_str
from authentik.core.models import Application from authentik.core.models import Application
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
@ -201,7 +200,7 @@ class TestAuthorize(OAuthTestCase):
) )
code: AuthorizationCode = AuthorizationCode.objects.filter(user=user).first() code: AuthorizationCode = AuthorizationCode.objects.filter(user=user).first()
self.assertJSONEqual( self.assertJSONEqual(
force_str(response.content), response.content.decode(),
{ {
"component": "xak-flow-redirect", "component": "xak-flow-redirect",
"type": ChallengeTypes.REDIRECT.value, "type": ChallengeTypes.REDIRECT.value,
@ -218,7 +217,7 @@ class TestAuthorize(OAuthTestCase):
client_secret=generate_key(), client_secret=generate_key(),
authorization_flow=flow, authorization_flow=flow,
redirect_uris="http://localhost", redirect_uris="http://localhost",
rsa_key=create_test_cert(), signing_key=create_test_cert(),
) )
Application.objects.create(name="app", slug="app", provider=provider) Application.objects.create(name="app", slug="app", provider=provider)
state = generate_id() state = generate_id()
@ -240,7 +239,7 @@ class TestAuthorize(OAuthTestCase):
) )
token: RefreshToken = RefreshToken.objects.filter(user=user).first() token: RefreshToken = RefreshToken.objects.filter(user=user).first()
self.assertJSONEqual( self.assertJSONEqual(
force_str(response.content), response.content.decode(),
{ {
"component": "xak-flow-redirect", "component": "xak-flow-redirect",
"type": ChallengeTypes.REDIRECT.value, "type": ChallengeTypes.REDIRECT.value,

View File

@ -3,7 +3,6 @@ import json
from django.test import RequestFactory from django.test import RequestFactory
from django.urls.base import reverse from django.urls.base import reverse
from django.utils.encoding import force_str
from authentik.core.models import Application from authentik.core.models import Application
from authentik.core.tests.utils import create_test_cert, create_test_flow from authentik.core.tests.utils import create_test_cert, create_test_flow
@ -25,13 +24,13 @@ class TestJWKS(OAuthTestCase):
client_id="test", client_id="test",
authorization_flow=create_test_flow(), authorization_flow=create_test_flow(),
redirect_uris="http://local.invalid", redirect_uris="http://local.invalid",
rsa_key=create_test_cert(), signing_key=create_test_cert(),
) )
app = Application.objects.create(name="test", slug="test", provider=provider) app = Application.objects.create(name="test", slug="test", provider=provider)
response = self.client.get( response = self.client.get(
reverse("authentik_providers_oauth2:jwks", kwargs={"application_slug": app.slug}) reverse("authentik_providers_oauth2:jwks", kwargs={"application_slug": app.slug})
) )
body = json.loads(force_str(response.content)) body = json.loads(response.content.decode())
self.assertEqual(len(body["keys"]), 1) self.assertEqual(len(body["keys"]), 1)
def test_hs256(self): def test_hs256(self):
@ -46,4 +45,4 @@ class TestJWKS(OAuthTestCase):
response = self.client.get( response = self.client.get(
reverse("authentik_providers_oauth2:jwks", kwargs={"application_slug": app.slug}) reverse("authentik_providers_oauth2:jwks", kwargs={"application_slug": app.slug})
) )
self.assertJSONEqual(force_str(response.content), {}) self.assertJSONEqual(response.content.decode(), {})

View File

@ -3,7 +3,6 @@ from base64 import b64encode
from django.test import RequestFactory from django.test import RequestFactory
from django.urls import reverse from django.urls import reverse
from django.utils.encoding import force_str
from authentik.core.models import Application from authentik.core.models import Application
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
@ -35,7 +34,7 @@ class TestToken(OAuthTestCase):
client_secret=generate_key(), client_secret=generate_key(),
authorization_flow=create_test_flow(), authorization_flow=create_test_flow(),
redirect_uris="http://testserver", redirect_uris="http://testserver",
rsa_key=create_test_cert(), signing_key=create_test_cert(),
) )
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode() header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
user = create_test_admin_user() user = create_test_admin_user()
@ -62,7 +61,7 @@ class TestToken(OAuthTestCase):
client_secret=generate_key(), client_secret=generate_key(),
authorization_flow=create_test_flow(), authorization_flow=create_test_flow(),
redirect_uris="http://testserver", redirect_uris="http://testserver",
rsa_key=create_test_cert(), signing_key=create_test_cert(),
) )
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode() header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
request = self.factory.post( request = self.factory.post(
@ -85,7 +84,7 @@ class TestToken(OAuthTestCase):
client_secret=generate_key(), client_secret=generate_key(),
authorization_flow=create_test_flow(), authorization_flow=create_test_flow(),
redirect_uris="http://local.invalid", redirect_uris="http://local.invalid",
rsa_key=create_test_cert(), signing_key=create_test_cert(),
) )
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode() header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
user = create_test_admin_user() user = create_test_admin_user()
@ -114,7 +113,7 @@ class TestToken(OAuthTestCase):
client_secret=generate_key(), client_secret=generate_key(),
authorization_flow=create_test_flow(), authorization_flow=create_test_flow(),
redirect_uris="http://local.invalid", redirect_uris="http://local.invalid",
rsa_key=create_test_cert(), signing_key=create_test_cert(),
) )
# Needs to be assigned to an application for iss to be set # Needs to be assigned to an application for iss to be set
self.app.provider = provider self.app.provider = provider
@ -135,7 +134,7 @@ class TestToken(OAuthTestCase):
) )
new_token: RefreshToken = RefreshToken.objects.filter(user=user).first() new_token: RefreshToken = RefreshToken.objects.filter(user=user).first()
self.assertJSONEqual( self.assertJSONEqual(
force_str(response.content), response.content.decode(),
{ {
"access_token": new_token.access_token, "access_token": new_token.access_token,
"refresh_token": new_token.refresh_token, "refresh_token": new_token.refresh_token,
@ -156,7 +155,7 @@ class TestToken(OAuthTestCase):
client_secret=generate_key(), client_secret=generate_key(),
authorization_flow=create_test_flow(), authorization_flow=create_test_flow(),
redirect_uris="http://local.invalid", redirect_uris="http://local.invalid",
rsa_key=create_test_cert(), signing_key=create_test_cert(),
) )
# Needs to be assigned to an application for iss to be set # Needs to be assigned to an application for iss to be set
self.app.provider = provider self.app.provider = provider
@ -184,7 +183,7 @@ class TestToken(OAuthTestCase):
self.assertEqual(response["Access-Control-Allow-Credentials"], "true") self.assertEqual(response["Access-Control-Allow-Credentials"], "true")
self.assertEqual(response["Access-Control-Allow-Origin"], "http://local.invalid") self.assertEqual(response["Access-Control-Allow-Origin"], "http://local.invalid")
self.assertJSONEqual( self.assertJSONEqual(
force_str(response.content), response.content.decode(),
{ {
"access_token": new_token.access_token, "access_token": new_token.access_token,
"refresh_token": new_token.refresh_token, "refresh_token": new_token.refresh_token,
@ -205,7 +204,7 @@ class TestToken(OAuthTestCase):
client_secret=generate_key(), client_secret=generate_key(),
authorization_flow=create_test_flow(), authorization_flow=create_test_flow(),
redirect_uris="http://local.invalid", redirect_uris="http://local.invalid",
rsa_key=create_test_cert(), signing_key=create_test_cert(),
) )
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode() header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
user = create_test_admin_user() user = create_test_admin_user()
@ -230,7 +229,7 @@ class TestToken(OAuthTestCase):
self.assertNotIn("Access-Control-Allow-Credentials", response) self.assertNotIn("Access-Control-Allow-Credentials", response)
self.assertNotIn("Access-Control-Allow-Origin", response) self.assertNotIn("Access-Control-Allow-Origin", response)
self.assertJSONEqual( self.assertJSONEqual(
force_str(response.content), response.content.decode(),
{ {
"access_token": new_token.access_token, "access_token": new_token.access_token,
"refresh_token": new_token.refresh_token, "refresh_token": new_token.refresh_token,
@ -250,7 +249,7 @@ class TestToken(OAuthTestCase):
client_secret=generate_key(), client_secret=generate_key(),
authorization_flow=create_test_flow(), authorization_flow=create_test_flow(),
redirect_uris="http://testserver", redirect_uris="http://testserver",
rsa_key=create_test_cert(), signing_key=create_test_cert(),
) )
# Needs to be assigned to an application for iss to be set # Needs to be assigned to an application for iss to be set
self.app.provider = provider self.app.provider = provider

View File

@ -3,7 +3,6 @@ import json
from dataclasses import asdict from dataclasses import asdict
from django.urls import reverse from django.urls import reverse
from django.utils.encoding import force_str
from authentik.core.models import Application from authentik.core.models import Application
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
@ -27,7 +26,7 @@ class TestUserinfo(OAuthTestCase):
client_secret=generate_key(), client_secret=generate_key(),
authorization_flow=create_test_flow(), authorization_flow=create_test_flow(),
redirect_uris="", redirect_uris="",
rsa_key=create_test_cert(), signing_key=create_test_cert(),
) )
self.provider.property_mappings.set(ScopeMapping.objects.all()) self.provider.property_mappings.set(ScopeMapping.objects.all())
# Needs to be assigned to an application for iss to be set # Needs to be assigned to an application for iss to be set
@ -54,7 +53,7 @@ class TestUserinfo(OAuthTestCase):
HTTP_AUTHORIZATION=f"Bearer {self.token.access_token}", HTTP_AUTHORIZATION=f"Bearer {self.token.access_token}",
) )
self.assertJSONEqual( self.assertJSONEqual(
force_str(res.content), res.content.decode(),
{ {
"name": self.user.name, "name": self.user.name,
"given_name": self.user.name, "given_name": self.user.name,
@ -77,7 +76,7 @@ class TestUserinfo(OAuthTestCase):
HTTP_AUTHORIZATION=f"Bearer {self.token.access_token}", HTTP_AUTHORIZATION=f"Bearer {self.token.access_token}",
) )
self.assertJSONEqual( self.assertJSONEqual(
force_str(res.content), res.content.decode(),
{ {
"name": self.user.name, "name": self.user.name,
"given_name": self.user.name, "given_name": self.user.name,

View File

@ -19,13 +19,13 @@ class OAuthTestCase(TestCase):
def validate_jwt(self, token: RefreshToken, provider: OAuth2Provider): def validate_jwt(self, token: RefreshToken, provider: OAuth2Provider):
"""Validate that all required fields are set""" """Validate that all required fields are set"""
key = provider.client_secret key, alg = provider.get_jwt_key()
if provider.jwt_alg == JWTAlgorithms.RS256: if alg != JWTAlgorithms.HS256:
key = provider.rsa_key.public_key key = provider.signing_key.public_key
jwt = decode( jwt = decode(
token.access_token, token.access_token,
key, key,
algorithms=[provider.jwt_alg], algorithms=[alg],
audience=provider.client_id, audience=provider.client_id,
) )
id_token = token.id_token.to_dict() id_token = token.id_token.to_dict()

View File

@ -1,12 +1,17 @@
"""authentik OAuth2 JWKS Views""" """authentik OAuth2 JWKS Views"""
from base64 import urlsafe_b64encode from base64 import urlsafe_b64encode
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey from cryptography.hazmat.primitives.asymmetric.ec import (
EllipticCurvePrivateKey,
EllipticCurvePublicKey,
)
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey
from django.http import HttpRequest, HttpResponse, JsonResponse from django.http import HttpRequest, HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.views import View from django.views import View
from authentik.core.models import Application from authentik.core.models import Application
from authentik.crypto.models import CertificateKeyPair
from authentik.providers.oauth2.models import JWTAlgorithms, OAuth2Provider from authentik.providers.oauth2.models import JWTAlgorithms, OAuth2Provider
@ -25,18 +30,35 @@ class JWKSView(View):
"""Show RSA Key data for Provider""" """Show RSA Key data for Provider"""
application = get_object_or_404(Application, slug=application_slug) application = get_object_or_404(Application, slug=application_slug)
provider: OAuth2Provider = get_object_or_404(OAuth2Provider, pk=application.provider_id) provider: OAuth2Provider = get_object_or_404(OAuth2Provider, pk=application.provider_id)
signing_key: CertificateKeyPair = provider.signing_key
response_data = {} response_data = {}
if provider.jwt_alg == JWTAlgorithms.RS256 and provider.rsa_key: if signing_key:
public_key: RSAPublicKey = provider.rsa_key.private_key.public_key() private_key = signing_key.private_key
print(type(private_key))
if isinstance(private_key, RSAPrivateKey):
public_key: RSAPublicKey = private_key.public_key()
public_numbers = public_key.public_numbers() public_numbers = public_key.public_numbers()
response_data["keys"] = [ response_data["keys"] = [
{ {
"kty": "RSA", "kty": "RSA",
"alg": "RS256", "alg": JWTAlgorithms.RS256,
"use": "sig", "use": "sig",
"kid": provider.rsa_key.kid, "kid": signing_key.kid,
"n": b64_enc(public_numbers.n),
"e": b64_enc(public_numbers.e),
}
]
elif isinstance(private_key, EllipticCurvePrivateKey):
public_key: EllipticCurvePublicKey = private_key.public_key()
public_numbers = public_key.public_numbers()
response_data["keys"] = [
{
"kty": "EC",
"alg": JWTAlgorithms.EC256,
"use": "sig",
"kid": signing_key.kid,
"n": b64_enc(public_numbers.n), "n": b64_enc(public_numbers.n),
"e": b64_enc(public_numbers.e), "e": b64_enc(public_numbers.e),
} }

View File

@ -39,6 +39,7 @@ class ProviderInfoView(View):
) )
if SCOPE_OPENID not in scopes: if SCOPE_OPENID not in scopes:
scopes.append(SCOPE_OPENID) scopes.append(SCOPE_OPENID)
_, supported_alg = provider.get_jwt_key()
return { return {
"issuer": provider.get_issuer(self.request), "issuer": provider.get_issuer(self.request),
"authorization_endpoint": self.request.build_absolute_uri( "authorization_endpoint": self.request.build_absolute_uri(
@ -78,7 +79,7 @@ class ProviderInfoView(View):
GRANT_TYPE_REFRESH_TOKEN, GRANT_TYPE_REFRESH_TOKEN,
GrantTypes.IMPLICIT, GrantTypes.IMPLICIT,
], ],
"id_token_signing_alg_values_supported": [provider.jwt_alg], "id_token_signing_alg_values_supported": [supported_alg],
# See: http://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes # See: http://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes
"subject_types_supported": ["public"], "subject_types_supported": ["public"],
"token_endpoint_auth_methods_supported": [ "token_endpoint_auth_methods_supported": [

View File

@ -2,6 +2,7 @@
import django.db.models.deletion import django.db.models.deletion
from django.apps.registry import Apps from django.apps.registry import Apps
from django.core.exceptions import FieldError
from django.db import migrations, models from django.db import migrations, models
from django.db.backends.base.schema import BaseDatabaseSchemaEditor from django.db.backends.base.schema import BaseDatabaseSchemaEditor
@ -10,12 +11,17 @@ import authentik.providers.proxy.models
def migrate_defaults(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): def migrate_defaults(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
from authentik.providers.proxy.models import JWTAlgorithms, ProxyProvider from authentik.providers.oauth2.models import JWTAlgorithms
from authentik.providers.proxy.models import ProxyProvider
db_alias = schema_editor.connection.alias db_alias = schema_editor.connection.alias
try:
for provider in ProxyProvider.objects.using(db_alias).filter(jwt_alg=JWTAlgorithms.RS256): for provider in ProxyProvider.objects.using(db_alias).filter(jwt_alg=JWTAlgorithms.RS256):
provider.set_oauth_defaults() provider.set_oauth_defaults()
provider.save() provider.save()
except FieldError:
# If the jwt_alg field doesn't exist, just ignore this migration
pass
def migrate_mode(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): def migrate_mode(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):

View File

@ -1,17 +1,23 @@
# Generated by Django 3.2.6 on 2021-09-09 11:24 # Generated by Django 3.2.6 on 2021-09-09 11:24
from django.apps.registry import Apps from django.apps.registry import Apps
from django.core.exceptions import FieldError
from django.db import migrations from django.db import migrations
from django.db.backends.base.schema import BaseDatabaseSchemaEditor from django.db.backends.base.schema import BaseDatabaseSchemaEditor
def migrate_defaults(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): def migrate_defaults(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
from authentik.providers.proxy.models import JWTAlgorithms, ProxyProvider from authentik.providers.oauth2.models import JWTAlgorithms
from authentik.providers.proxy.models import ProxyProvider
db_alias = schema_editor.connection.alias db_alias = schema_editor.connection.alias
try:
for provider in ProxyProvider.objects.using(db_alias).filter(jwt_alg=JWTAlgorithms.RS256): for provider in ProxyProvider.objects.using(db_alias).filter(jwt_alg=JWTAlgorithms.RS256):
provider.set_oauth_defaults() provider.set_oauth_defaults()
provider.save() provider.save()
except FieldError:
# If the jwt_alg field doesn't exist, just ignore this migration
pass
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@ -1,7 +1,7 @@
"""authentik proxy models""" """authentik proxy models"""
import string import string
from random import SystemRandom from random import SystemRandom
from typing import Iterable, Optional, Type, Union from typing import Iterable, Optional
from urllib.parse import urljoin from urllib.parse import urljoin
from django.db import models from django.db import models
@ -16,12 +16,7 @@ from authentik.providers.oauth2.constants import (
SCOPE_OPENID_EMAIL, SCOPE_OPENID_EMAIL,
SCOPE_OPENID_PROFILE, SCOPE_OPENID_PROFILE,
) )
from authentik.providers.oauth2.models import ( from authentik.providers.oauth2.models import ClientTypes, OAuth2Provider, ScopeMapping
ClientTypes,
JWTAlgorithms,
OAuth2Provider,
ScopeMapping,
)
SCOPE_AK_PROXY = "ak_proxy" SCOPE_AK_PROXY = "ak_proxy"
@ -115,7 +110,7 @@ class ProxyProvider(OutpostModel, OAuth2Provider):
return "ak-provider-proxy-form" return "ak-provider-proxy-form"
@property @property
def serializer(self) -> Type[Serializer]: def serializer(self) -> type[Serializer]:
from authentik.providers.proxy.api import ProxyProviderSerializer from authentik.providers.proxy.api import ProxyProviderSerializer
return ProxyProviderSerializer return ProxyProviderSerializer
@ -128,8 +123,7 @@ class ProxyProvider(OutpostModel, OAuth2Provider):
def set_oauth_defaults(self): def set_oauth_defaults(self):
"""Ensure all OAuth2-related settings are correct""" """Ensure all OAuth2-related settings are correct"""
self.client_type = ClientTypes.CONFIDENTIAL self.client_type = ClientTypes.CONFIDENTIAL
self.jwt_alg = JWTAlgorithms.HS256 self.signing_key = None
self.rsa_key = None
scopes = ScopeMapping.objects.filter( scopes = ScopeMapping.objects.filter(
scope_name__in=[ scope_name__in=[
SCOPE_OPENID, SCOPE_OPENID,
@ -144,7 +138,7 @@ class ProxyProvider(OutpostModel, OAuth2Provider):
def __str__(self): def __str__(self):
return f"Proxy Provider {self.name}" return f"Proxy Provider {self.name}"
def get_required_objects(self) -> Iterable[Union[models.Model, str]]: def get_required_objects(self) -> Iterable[models.Model | str]:
required_models = [self] required_models = [self]
if self.certificate is not None: if self.certificate is not None:
required_models.append(self.certificate) required_models.append(self.certificate)

View File

@ -1,5 +1,5 @@
"""authentik saml_idp Models""" """authentik saml_idp Models"""
from typing import Optional, Type from typing import Optional
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
@ -163,7 +163,7 @@ class SAMLProvider(Provider):
return None return None
@property @property
def serializer(self) -> Type[Serializer]: def serializer(self) -> type[Serializer]:
from authentik.providers.saml.api import SAMLProviderSerializer from authentik.providers.saml.api import SAMLProviderSerializer
return SAMLProviderSerializer return SAMLProviderSerializer
@ -192,7 +192,7 @@ class SAMLPropertyMapping(PropertyMapping):
return "ak-property-mapping-saml-form" return "ak-property-mapping-saml-form"
@property @property
def serializer(self) -> Type[Serializer]: def serializer(self) -> type[Serializer]:
from authentik.providers.saml.api import SAMLPropertyMappingSerializer from authentik.providers.saml.api import SAMLPropertyMappingSerializer
return SAMLPropertyMappingSerializer return SAMLPropertyMappingSerializer

View File

@ -1,7 +1,7 @@
"""SAML AuthNRequest Parser and dataclass""" """SAML AuthNRequest Parser and dataclass"""
from base64 import b64decode from base64 import b64decode
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional, Union from typing import Optional
from urllib.parse import quote_plus from urllib.parse import quote_plus
import xmlsec import xmlsec
@ -54,9 +54,7 @@ class AuthNRequestParser:
def __init__(self, provider: SAMLProvider): def __init__(self, provider: SAMLProvider):
self.provider = provider self.provider = provider
def _parse_xml( def _parse_xml(self, decoded_xml: str | bytes, relay_state: Optional[str]) -> AuthNRequest:
self, decoded_xml: Union[str, bytes], relay_state: Optional[str]
) -> AuthNRequest:
root = ElementTree.fromstring(decoded_xml) root = ElementTree.fromstring(decoded_xml)
# http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf # http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf

View File

@ -69,7 +69,7 @@ def task_error_hook(task_id, exception: Exception, traceback, *args, **kwargs):
"""Create system event for failed task""" """Create system event for failed task"""
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction
LOGGER.warning("Task failure", exception=exception) LOGGER.warning("Task failure", exc=exception)
if before_send({}, {"exc_info": (None, exception, None)}) is not None: if before_send({}, {"exc_info": (None, exception, None)}) is not None:
Event.new(EventAction.SYSTEM_EXCEPTION, message=exception_to_string(exception)).save() Event.new(EventAction.SYSTEM_EXCEPTION, message=exception_to_string(exception)).save()

View File

@ -357,7 +357,7 @@ CELERY_BEAT_SCHEDULE = {
}, },
"db_backup": { "db_backup": {
"task": "authentik.core.tasks.backup_database", "task": "authentik.core.tasks.backup_database",
"schedule": crontab(minute=0, hour=0), "schedule": crontab(hour="*/24"),
"options": {"queue": "authentik_scheduled"}, "options": {"queue": "authentik_scheduled"},
}, },
} }
@ -425,6 +425,7 @@ if _ERROR_REPORTING:
set_tag("authentik.build_hash", build_hash) set_tag("authentik.build_hash", build_hash)
set_tag("authentik.env", env) set_tag("authentik.env", env)
set_tag("authentik.component", "backend") set_tag("authentik.component", "backend")
set_tag("authentik.uuid", sha512(SECRET_KEY.encode("ascii")).hexdigest()[:16])
j_print( j_print(
"Error reporting is enabled", "Error reporting is enabled",
env=CONFIG.y("error_reporting.environment", "customer"), env=CONFIG.y("error_reporting.environment", "customer"),

View File

@ -26,8 +26,8 @@ class PytestTestRunner: # pragma: no cover
settings.TEST = True settings.TEST = True
settings.CELERY_TASK_ALWAYS_EAGER = True settings.CELERY_TASK_ALWAYS_EAGER = True
CONFIG.y_set("authentik.avatars", "none") CONFIG.y_set("avatars", "none")
CONFIG.y_set("authentik.geoip", "tests/GeoLite2-City-Test.mmdb") CONFIG.y_set("geoip", "tests/GeoLite2-City-Test.mmdb")
CONFIG.y_set( CONFIG.y_set(
"outposts.container_image_base", "outposts.container_image_base",
f"ghcr.io/goauthentik/dev-%(type)s:{get_docker_tag()}", f"ghcr.io/goauthentik/dev-%(type)s:{get_docker_tag()}",

View File

@ -1,6 +1,5 @@
"""authentik LDAP Models""" """authentik LDAP Models"""
from ssl import CERT_REQUIRED from ssl import CERT_REQUIRED
from typing import Type
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -101,7 +100,7 @@ class LDAPSource(Source):
return "ak-source-ldap-form" return "ak-source-ldap-form"
@property @property
def serializer(self) -> Type[Serializer]: def serializer(self) -> type[Serializer]:
from authentik.sources.ldap.api import LDAPSourceSerializer from authentik.sources.ldap.api import LDAPSourceSerializer
return LDAPSourceSerializer return LDAPSourceSerializer
@ -157,7 +156,7 @@ class LDAPPropertyMapping(PropertyMapping):
return "ak-property-mapping-ldap-form" return "ak-property-mapping-ldap-form"
@property @property
def serializer(self) -> Type[Serializer]: def serializer(self) -> type[Serializer]:
from authentik.sources.ldap.api import LDAPPropertyMappingSerializer from authentik.sources.ldap.api import LDAPPropertyMappingSerializer
return LDAPPropertyMappingSerializer return LDAPPropertyMappingSerializer

View File

@ -74,6 +74,7 @@ class OAuthSourceSerializer(SourceSerializer):
"consumer_key", "consumer_key",
"consumer_secret", "consumer_secret",
"callback_url", "callback_url",
"additional_scopes",
"type", "type",
] ]
extra_kwargs = {"consumer_secret": {"write_only": True}} extra_kwargs = {"consumer_secret": {"write_only": True}}
@ -99,6 +100,7 @@ class OAuthSourceViewSet(UsedByMixin, ModelViewSet):
"access_token_url", "access_token_url",
"profile_url", "profile_url",
"consumer_key", "consumer_key",
"additional_scopes",
] ]
ordering = ["name"] ordering = ["name"]

View File

@ -58,6 +58,9 @@ class BaseOAuthClient:
args = self.get_redirect_args() args = self.get_redirect_args()
additional = parameters or {} additional = parameters or {}
args.update(additional) args.update(additional)
# Special handling for scope, since it's set as array
# to make additional scopes easier
args["scope"] = " ".join(sorted(set(args["scope"])))
params = urlencode(args, quote_via=quote) params = urlencode(args, quote_via=quote)
LOGGER.info("redirect args", **args) LOGGER.info("redirect args", **args)
authorization_url = self.source.type.authorization_url or "" authorization_url = self.source.type.authorization_url or ""

View File

@ -0,0 +1,18 @@
# Generated by Django 4.0 on 2022-01-03 14:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_sources_oauth", "0005_update_provider_type_names"),
]
operations = [
migrations.AddField(
model_name="oauthsource",
name="additional_scopes",
field=models.TextField(default="", blank=True, verbose_name="Additional Scopes"),
),
]

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